From c965dfc6f52180793ab881d0b8458ceb2eac211a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Apr 2026 16:55:52 -1000 Subject: [PATCH 0001/2214] [ltr390] Reduce data polling delay and timeout - Reduce polling delay from 2ms to 1ms (sufficient in practice) - Reduce timeout from 100ms to 25ms to stay within 30ms max blocking time - Change polling log message from DEBUG to VERBOSE to reduce log spam --- esphome/components/ltr390/ltr390.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index ba4a7ea5cb..033f31a3d1 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -45,6 +45,7 @@ optional LTR390Component::read_sensor_data_(LTR390MODE mode) { uint8_t buffer[num_bytes]; // Wait until data available + constexpr uint32_t max_wait_ms = 25; const uint32_t now = millis(); while (true) { std::bitset<8> status = this->reg(LTR390_MAIN_STATUS).get(); @@ -52,12 +53,12 @@ optional LTR390Component::read_sensor_data_(LTR390MODE mode) { if (available) break; - if (millis() - now > 100) { + if (millis() - now > max_wait_ms) { ESP_LOGW(TAG, "Sensor didn't return any data, aborting"); return {}; } - ESP_LOGD(TAG, "Waiting for data"); - delay(2); + ESP_LOGV(TAG, "Waiting for data"); + delay(1); } if (!this->read_bytes(MODEADDRESSES[mode], buffer, num_bytes)) { From d0e5035f0ead0fe3e32bb85fbdbf135bbebf74d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 15:27:02 -1000 Subject: [PATCH 0002/2214] [api] Fix ListEntitiesRequest not being read due to LWIP rcvevent tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the client sends 6+ messages during handshake (HelloReq, AuthReq, GetTimeResp, SubscribeLogsReq, DeviceInfoReq, ListEntitiesReq), MAX_MESSAGES_PER_LOOP=5 caused ListEntitiesReq to remain unread in the socket buffer. LWIP's rcvevent counter (used by is_socket_ready) tracks pbuf dequeues, not remaining bytes. When multiple ESPHome messages share a single TCP segment/pbuf, reading all but the last message exhausts rcvevent to 0 while the last message's data remains in LWIP's internal lastdata cache. is_socket_ready() then returns false, and the data is never read — the entity listing silently stalls until something else (like a keepalive ping) triggers new socket activity. Fix: - Track when the read loop hits MAX_MESSAGES_PER_LOOP and retry on the next iteration without the is_socket_ready() gate - Increase MAX_MESSAGES_PER_LOOP from 5 to 10 (decode cost is now 3x cheaper, and we batch 24-34 messages per write) - Move set_nodelay_for_message(false) before try_to_clear_buffer in process_batch_() so NODELAY is set before draining overflow --- esphome/components/api/api_connection.cpp | 34 +++++++++++++++-------- esphome/components/api/api_connection.h | 1 + 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7db423141c..2ad34b2e98 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -56,7 +56,7 @@ namespace esphome::api { // This is a balance between API responsiveness and allowing other components to run. // Since each message could contain multiple protobuf messages when using packet batching, // this limits the number of messages processed, not the number of TCP packets. -static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 5; +static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 10; static constexpr uint8_t MAX_PING_RETRIES = 60; static constexpr uint16_t PING_RETRY_INTERVAL = 1000; static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2; @@ -220,10 +220,17 @@ void APIConnection::loop() { } const uint32_t now = App.get_loop_component_start_time(); - // Check if socket has data ready before attempting to read - if (this->helper_->is_socket_ready()) { + // Check if socket has data ready before attempting to read. + // Also try reading if we hit the message limit last time — LWIP's rcvevent + // (used by is_socket_ready) tracks pbuf dequeues, not bytes. When multiple + // messages share a TCP segment, the last message's data stays in LWIP's + // lastdata cache after rcvevent hits 0, making is_socket_ready() return false + // even though data remains. + if (this->helper_->is_socket_ready() || this->flags_.may_have_remaining_data) { + this->flags_.may_have_remaining_data = false; // Read up to MAX_MESSAGES_PER_LOOP messages per loop to improve throughput - for (uint8_t message_count = 0; message_count < MAX_MESSAGES_PER_LOOP; message_count++) { + uint8_t message_count = 0; + for (; message_count < MAX_MESSAGES_PER_LOOP; message_count++) { ReadPacketBuffer buffer; err = this->helper_->read_packet(&buffer); if (err == APIError::WOULD_BLOCK) { @@ -245,6 +252,11 @@ void APIConnection::loop() { return; } } + // If we hit the limit, there may be more data remaining in LWIP's + // lastdata cache that rcvevent doesn't account for. + if (message_count == MAX_MESSAGES_PER_LOOP) { + this->flags_.may_have_remaining_data = true; + } } // Process deferred batch if scheduled and timer has expired @@ -2086,6 +2098,13 @@ void APIConnection::process_batch_() { return; } + // Ensure TCP_NODELAY is on before draining overflow and writing batch data. + // Log messages enable Nagle (NODELAY off) to coalesce small packets. + // If Nagle is still on when we try to drain, LWIP holds data in the + // Nagle buffer, the TCP send buffer stays full, and the overflow + // buffer can never drain — blocking the batch write indefinitely. + this->helper_->set_nodelay_for_message(false); + // Try to clear buffer first if (!this->try_to_clear_buffer(true)) { // Can't write now, we'll try again later @@ -2193,13 +2212,6 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items shared_buf.resize(shared_buf.size() + footer_size); } - // Ensure TCP_NODELAY is on before writing batch data. - // Log messages enable Nagle (NODELAY off) to coalesce small packets. - // Without this, batch data written to the socket sits in LWIP's Nagle - // buffer — the remote won't ACK until it sends its own data (e.g. a - // ping), which can take 20+ seconds. - this->helper_->set_nodelay_for_message(false); - // Send all collected messages APIError err = this->helper_->write_protobuf_messages(ProtoWriteBuffer{&shared_buf}, std::span(message_info, items_processed)); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 284c4475de..7d08797090 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -771,6 +771,7 @@ class APIConnection final : public APIServerConnectionBase { uint8_t batch_scheduled : 1; uint8_t batch_first_message : 1; // For batch buffer allocation uint8_t should_try_send_immediately : 1; // True after initial states are sent + uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check #ifdef HAS_PROTO_MESSAGE_DUMP uint8_t log_only_mode : 1; #endif From 8c0ca29c048a36c967b67eb0004a5d74135b7dfe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 15:38:01 -1000 Subject: [PATCH 0003/2214] [api] Update MAX_MESSAGES_PER_LOOP comment to not hardcode old value --- esphome/components/api/api_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5cad00743c..702d01727d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -52,7 +52,7 @@ namespace esphome::api { -// Read a maximum of 5 messages per loop iteration to prevent starving other components. +// Maximum messages to read per loop iteration to prevent starving other components. // This is a balance between API responsiveness and allowing other components to run. // Since each message could contain multiple protobuf messages when using packet batching, // this limits the number of messages processed, not the number of TCP packets. From 05816d87860a9a9b629545f85be7045f82f028c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 18:49:40 -1000 Subject: [PATCH 0004/2214] [safe_mode] Use loop component start time instead of millis() --- esphome/components/safe_mode/safe_mode.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 40fa03392b..5b0f7c8827 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -86,7 +86,8 @@ void SafeModeComponent::mark_successful() { } void SafeModeComponent::loop() { - if (!this->boot_successful_ && (millis() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) { + if (!this->boot_successful_ && + (App.get_loop_component_start_time() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) { // successful boot, reset counter ESP_LOGI(TAG, "Boot seems successful; resetting boot loop counter"); this->mark_successful(); From afbecce86f6dd042a25bed0bdde53392d8589916 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 18:56:35 -1000 Subject: [PATCH 0005/2214] [safe_mode] Combine related OTA rollback log messages --- esphome/components/safe_mode/safe_mode.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 40fa03392b..5e08ca3e23 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -55,11 +55,13 @@ void SafeModeComponent::dump_config() { #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) const esp_partition_t *last_invalid = esp_ota_get_last_invalid_partition(); if (last_invalid != nullptr) { - ESP_LOGW(TAG, "OTA rollback detected! Rolled back from partition '%s'", last_invalid->label); - ESP_LOGW(TAG, "The device reset before the boot was marked successful"); + ESP_LOGW(TAG, + "OTA rollback detected! Rolled back from partition '%s'\n" + " The device reset before the boot was marked successful", + last_invalid->label); if (esp_reset_reason() == ESP_RST_BROWNOUT) { - ESP_LOGW(TAG, "Last reset was due to brownout - check your power supply!"); - ESP_LOGW(TAG, "See https://esphome.io/guides/faq.html#brownout-detector-was-triggered"); + ESP_LOGW(TAG, "Last reset was due to brownout - check your power supply!\n" + " See https://esphome.io/guides/faq.html#brownout-detector-was-triggered"); } } #endif From 352121b7bf9dc7efddf1fb7193ba3ff66b5fc1c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 19:57:37 -1000 Subject: [PATCH 0006/2214] [core] Add CodSpeed benchmarks for hot helper functions Add benchmarks for format_hex_to, format_hex_pretty_to, format_mac_addr_upper, fnv1_hash, fnv1a_hash, fnv1_hash_object_id, parse_hex, crc8, crc16, value_accuracy_to_buf, int8_to_str, and base64_decode to establish baseline performance data before optimization. --- tests/benchmarks/core/bench_helpers.cpp | 267 +++++++++++++++++++++++- 1 file changed, 266 insertions(+), 1 deletion(-) diff --git a/tests/benchmarks/core/bench_helpers.cpp b/tests/benchmarks/core/bench_helpers.cpp index c6e1e6930e..1f9d05fc33 100644 --- a/tests/benchmarks/core/bench_helpers.cpp +++ b/tests/benchmarks/core/bench_helpers.cpp @@ -10,7 +10,6 @@ namespace esphome::benchmarks { static constexpr int kInnerIterations = 2000; // --- random_float() --- -// Ported from ol.yaml:148 "Random Float Benchmark" static void RandomFloat(benchmark::State &state) { for (auto _ : state) { @@ -38,4 +37,270 @@ static void RandomUint32(benchmark::State &state) { } BENCHMARK(RandomUint32); +// --- format_hex_to() - 6 bytes (MAC address sized) --- + +static void FormatHexTo_6Bytes(benchmark::State &state) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45}; + char buffer[13]; // 6 * 2 + 1 + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + format_hex_to(buffer, data, 6); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FormatHexTo_6Bytes); + +// --- format_hex_to() - 16 bytes (UUID sized) --- + +static void FormatHexTo_16Bytes(benchmark::State &state) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, + 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10}; + char buffer[33]; // 16 * 2 + 1 + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + format_hex_to(buffer, data, 16); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FormatHexTo_16Bytes); + +// --- format_hex_to() - 100 bytes (large payload) --- + +static void FormatHexTo_100Bytes(benchmark::State &state) { + uint8_t data[100]; + for (int i = 0; i < 100; i++) + data[i] = static_cast(i); + char buffer[201]; // 100 * 2 + 1 + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + format_hex_to(buffer, data, 100); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FormatHexTo_100Bytes); + +// --- format_hex_pretty_to() - 6 bytes with ':' separator --- + +static void FormatHexPrettyTo_6Bytes(benchmark::State &state) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45}; + char buffer[18]; // 6 * 3 + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + format_hex_pretty_to(buffer, data, 6); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FormatHexPrettyTo_6Bytes); + +// --- format_mac_addr_upper() --- + +static void FormatMacAddrUpper(benchmark::State &state) { + const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + format_mac_addr_upper(mac, buffer); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FormatMacAddrUpper); + +// --- fnv1_hash() - short string --- + +static void Fnv1Hash_Short(benchmark::State &state) { + const char *str = "sensor.temperature"; + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1_hash(str); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Fnv1Hash_Short); + +// --- fnv1_hash() - long string --- + +static void Fnv1Hash_Long(benchmark::State &state) { + const char *str = "binary_sensor.living_room_motion_sensor_occupancy_detected"; + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1_hash(str); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Fnv1Hash_Long); + +// --- fnv1a_hash() - short string --- +// Use DoNotOptimize on the input pointer to prevent constexpr evaluation + +static void Fnv1aHash_Short(benchmark::State &state) { + const char *str = "sensor.temperature"; + benchmark::DoNotOptimize(str); + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1a_hash(str); + benchmark::ClobberMemory(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Fnv1aHash_Short); + +// --- fnv1a_hash() - long string --- + +static void Fnv1aHash_Long(benchmark::State &state) { + const char *str = "binary_sensor.living_room_motion_sensor_occupancy_detected"; + benchmark::DoNotOptimize(str); + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1a_hash(str); + benchmark::ClobberMemory(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Fnv1aHash_Long); + +// --- fnv1_hash_object_id() - typical entity name --- + +static void Fnv1HashObjectId(benchmark::State &state) { + const char *name = "Living Room Temperature Sensor"; + size_t len = strlen(name); + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1_hash_object_id(name, len); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Fnv1HashObjectId); + +// --- parse_hex() - 6 bytes from string --- + +static void ParseHex_6Bytes(benchmark::State &state) { + const char *hex_str = "ABCDEF012345"; + uint8_t data[6]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + parse_hex(hex_str, data, 6); + } + benchmark::DoNotOptimize(data); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ParseHex_6Bytes); + +// --- parse_hex() - 16 bytes from string --- + +static void ParseHex_16Bytes(benchmark::State &state) { + const char *hex_str = "ABCDEF0123456789FEDCBA9876543210"; + uint8_t data[16]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + parse_hex(hex_str, data, 16); + } + benchmark::DoNotOptimize(data); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ParseHex_16Bytes); + +// --- crc8() - 8 bytes --- + +static void CRC8_8Bytes(benchmark::State &state) { + const uint8_t data[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + for (auto _ : state) { + uint8_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= crc8(data, 8); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CRC8_8Bytes); + +// --- crc16() - 8 bytes --- + +static void CRC16_8Bytes(benchmark::State &state) { + const uint8_t data[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + for (auto _ : state) { + uint16_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= crc16(data, 8); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CRC16_8Bytes); + +// --- value_accuracy_to_buf() - typical sensor value --- + +static void ValueAccuracyToBuf(benchmark::State &state) { + char raw_buf[VALUE_ACCURACY_MAX_LEN]; + std::span buf(raw_buf); + float value = 23.456f; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + value_accuracy_to_buf(buf, value, 2); + } + benchmark::DoNotOptimize(raw_buf); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ValueAccuracyToBuf); + +// --- int8_to_str() --- + +static void Int8ToStr(benchmark::State &state) { + char buffer[5]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + int8_to_str(buffer, static_cast(i & 0xFF)); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Int8ToStr); + +// --- base64_decode() - into pre-allocated buffer --- + +static void Base64Decode_32Bytes(benchmark::State &state) { + // 32 bytes encoded = 44 base64 chars + const uint8_t encoded[] = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGx0eHw=="; + size_t encoded_len = 44; + uint8_t output[32]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + base64_decode(encoded, encoded_len, output, sizeof(output)); + } + benchmark::DoNotOptimize(output); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Base64Decode_32Bytes); + } // namespace esphome::benchmarks From b8e4343d862515f3e71e5f9576c36dc681ab9012 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 20:04:44 -1000 Subject: [PATCH 0007/2214] [core] Optimize format_hex_internal by splitting separator loop Split the single loop into two paths (with/without separator) to eliminate the per-byte branch on separator. In the separator path, write the separator unconditionally and overwrite the last one with the null terminator. This also lets the compiler use constant stride values (2 or 3) instead of a runtime variable. Benchmarks show ~16-20% improvement on the separator path (format_hex_pretty_to, format_mac_addr_upper). --- esphome/core/helpers.cpp | 52 ++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 5940f6ec98..108d448861 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -347,33 +347,49 @@ std::string format_mac_address_pretty(const uint8_t *mac) { return std::string(buf); } -// Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase +// Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase. +// Splits into two loops to eliminate per-byte branch on separator. static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator, char base) { if (length == 0) { buffer[0] = '\0'; return buffer; } - // With separator: total length is 3*length (2*length hex chars, (length-1) separators, 1 null terminator) - // Without separator: total length is 2*length + 1 (2*length hex chars, 1 null terminator) - uint8_t stride = separator ? 3 : 2; - size_t max_bytes = separator ? (buffer_size / stride) : ((buffer_size - 1) / stride); - if (max_bytes == 0) { - buffer[0] = '\0'; - return buffer; - } - if (length > max_bytes) { - length = max_bytes; - } - for (size_t i = 0; i < length; i++) { - size_t pos = i * stride; - buffer[pos] = format_hex_char(data[i] >> 4, base); - buffer[pos + 1] = format_hex_char(data[i] & 0x0F, base); - if (separator && i < length - 1) { + if (separator) { + // With separator: "XX:XX:...:XX\0" = length * 3 bytes total + size_t max_bytes = buffer_size / 3; + if (max_bytes == 0) { + buffer[0] = '\0'; + return buffer; + } + if (length > max_bytes) { + length = max_bytes; + } + for (size_t i = 0; i < length; i++) { + size_t pos = i * 3; + buffer[pos] = format_hex_char(data[i] >> 4, base); + buffer[pos + 1] = format_hex_char(data[i] & 0x0F, base); buffer[pos + 2] = separator; } + // Overwrite last separator with null terminator + buffer[length * 3 - 1] = '\0'; + } else { + // Without separator: "XXXX...XX\0" = length * 2 + 1 bytes total + size_t max_bytes = (buffer_size - 1) / 2; + if (max_bytes == 0) { + buffer[0] = '\0'; + return buffer; + } + if (length > max_bytes) { + length = max_bytes; + } + for (size_t i = 0; i < length; i++) { + size_t pos = i * 2; + buffer[pos] = format_hex_char(data[i] >> 4, base); + buffer[pos + 1] = format_hex_char(data[i] & 0x0F, base); + } + buffer[length * 2] = '\0'; } - buffer[length * stride - (separator ? 1 : 0)] = '\0'; return buffer; } From 671e434400dc4009680418a38d4d7cd5567d08c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 20:25:35 -1000 Subject: [PATCH 0008/2214] [core] Force-inline format_hex_char to prevent outlining The loop split in format_hex_internal doubled the call sites for format_hex_char, causing the compiler to outline it (+30 bytes new symbol). Mark it always_inline to keep it inlined. --- esphome/core/helpers.h | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c26bbe17b7..1185db390e 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1263,13 +1263,15 @@ constexpr uint8_t parse_hex_char(char c) { } /// Convert a nibble (0-15) to hex char with specified base ('a' for lowercase, 'A' for uppercase) -inline char format_hex_char(uint8_t v, char base) { return v >= 10 ? base + (v - 10) : '0' + v; } +__attribute__((always_inline)) inline char format_hex_char(uint8_t v, char base) { + return v >= 10 ? base + (v - 10) : '0' + v; +} /// Convert a nibble (0-15) to lowercase hex char -inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); } +__attribute__((always_inline)) inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); } /// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) -inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); } +__attribute__((always_inline)) inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); } /// Write int8 value to buffer without modulo operations. /// Buffer must have at least 4 bytes free. Returns pointer past last char written. From bd66f26144d062a495bfb3b91eed2f4e0c063686 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 20:41:09 -1000 Subject: [PATCH 0009/2214] [core] Optimize format_hex_internal by eliminating per-byte length check Remove the per-byte `i < length - 1` check in the separator path by writing the separator unconditionally after each byte and overwriting the last one with the null terminator. Single-loop approach keeps code smaller than the original while eliminating the hot-path branch. Flash: -16 bytes vs baseline. --- esphome/core/helpers.cpp | 52 +++++++++++++++------------------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 108d448861..2faa6adefb 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -348,48 +348,34 @@ std::string format_mac_address_pretty(const uint8_t *mac) { } // Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase. -// Splits into two loops to eliminate per-byte branch on separator. +// When separator is set, it is written unconditionally after each byte and the last +// one is overwritten with '\0', eliminating the per-byte `i < length - 1` check. static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator, char base) { if (length == 0) { buffer[0] = '\0'; return buffer; } - if (separator) { - // With separator: "XX:XX:...:XX\0" = length * 3 bytes total - size_t max_bytes = buffer_size / 3; - if (max_bytes == 0) { - buffer[0] = '\0'; - return buffer; - } - if (length > max_bytes) { - length = max_bytes; - } - for (size_t i = 0; i < length; i++) { - size_t pos = i * 3; - buffer[pos] = format_hex_char(data[i] >> 4, base); - buffer[pos + 1] = format_hex_char(data[i] & 0x0F, base); + uint8_t stride = separator ? 3 : 2; + size_t max_bytes = separator ? (buffer_size / 3) : ((buffer_size - 1) / 2); + if (max_bytes == 0) { + buffer[0] = '\0'; + return buffer; + } + if (length > max_bytes) { + length = max_bytes; + } + for (size_t i = 0; i < length; i++) { + size_t pos = i * stride; + buffer[pos] = format_hex_char(data[i] >> 4, base); + buffer[pos + 1] = format_hex_char(data[i] & 0x0F, base); + if (separator) { buffer[pos + 2] = separator; } - // Overwrite last separator with null terminator - buffer[length * 3 - 1] = '\0'; - } else { - // Without separator: "XXXX...XX\0" = length * 2 + 1 bytes total - size_t max_bytes = (buffer_size - 1) / 2; - if (max_bytes == 0) { - buffer[0] = '\0'; - return buffer; - } - if (length > max_bytes) { - length = max_bytes; - } - for (size_t i = 0; i < length; i++) { - size_t pos = i * 2; - buffer[pos] = format_hex_char(data[i] >> 4, base); - buffer[pos + 1] = format_hex_char(data[i] & 0x0F, base); - } - buffer[length * 2] = '\0'; } + // With separator: overwrite last separator with '\0' + // Without: write '\0' after last hex char + buffer[length * stride - (separator ? 1 : 0)] = '\0'; return buffer; } From d8e586609fd8a8dd15001579d64f3d004702e5d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 20:32:31 -1000 Subject: [PATCH 0010/2214] [core] Optimize value_accuracy_to_buf to avoid snprintf Replace snprintf("%.*f") with integer-based formatting for finite float values with accuracy_decimals 0-3 (covers virtually all sensor usage). Falls back to snprintf for higher accuracy or NaN/Inf. Uses lrint() with double cast for the multiply to match snprintf's rounding behavior exactly. The fast path avoids snprintf's heavy float formatting machinery entirely. Also optimizes value_accuracy_with_uom_to_buf to append the UOM string directly instead of going through snprintf. Adds C++ unit tests that verify output matches snprintf for a range of values including edge cases. Benchmark: 92,961ns -> 6,484ns (14.3x faster, 2000 iterations). --- esphome/core/helpers.cpp | 81 ++++++++-- tests/components/core/test_value_accuracy.cpp | 151 ++++++++++++++++++ 2 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 tests/components/core/test_value_accuracy.cpp diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 5940f6ec98..48ed45477f 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -526,28 +526,87 @@ std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { return std::string(buf); } +// Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). +// Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. +static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy_decimals) { + char *p = buf; + if (std::signbit(value)) { + *p++ = '-'; + value = -value; + } + uint32_t mult = 1; + if (accuracy_decimals == 1) + mult = 10; + else if (accuracy_decimals == 2) + mult = 100; + else if (accuracy_decimals == 3) + mult = 1000; + // Cast to double for the multiply to match snprintf's precision. + // float*int loses bits at exact-half boundaries (e.g. 23.45f*10 = 234.5 in float, + // but snprintf sees 234.500007... via double promotion and rounds differently). + uint32_t scaled = static_cast(lrint(static_cast(value) * mult)); + uint32_t int_part = scaled / mult; + // Write integer part in reverse, then flip + char *start = p; + if (int_part == 0) { + *p++ = '0'; + } else { + while (int_part > 0) { + *p++ = '0' + (int_part % 10); + int_part /= 10; + } + std::reverse(start, p); + } + if (accuracy_decimals > 0) { + *p++ = '.'; + uint32_t frac = scaled % mult; + uint32_t d = mult / 10; + while (d > 0) { + *p++ = '0' + static_cast(frac / d); + frac %= d; + d /= 10; + } + } + *p = '\0'; + return static_cast(p - buf); +} + size_t value_accuracy_to_buf(std::span buf, float value, int8_t accuracy_decimals) { normalize_accuracy_decimals(value, accuracy_decimals); - // snprintf returns chars that would be written (excluding null), or negative on error + + // Fast path for accuracy 0-3 and finite values + if (accuracy_decimals <= 3 && std::isfinite(value)) { + return value_accuracy_to_buf_fast(buf.data(), value, accuracy_decimals); + } + + // Fallback for NaN/Inf/high accuracy int len = snprintf(buf.data(), buf.size(), "%.*f", accuracy_decimals, value); if (len < 0) - return 0; // encoding error - // On truncation, snprintf returns would-be length; actual written is buf.size() - 1 + return 0; return static_cast(len) >= buf.size() ? buf.size() - 1 : static_cast(len); } size_t value_accuracy_with_uom_to_buf(std::span buf, float value, int8_t accuracy_decimals, StringRef unit_of_measurement) { + size_t len = value_accuracy_to_buf(buf, value, accuracy_decimals); if (unit_of_measurement.empty()) { - return value_accuracy_to_buf(buf, value, accuracy_decimals); + return len; } - normalize_accuracy_decimals(value, accuracy_decimals); - // snprintf returns chars that would be written (excluding null), or negative on error - int len = snprintf(buf.data(), buf.size(), "%.*f %s", accuracy_decimals, value, unit_of_measurement.c_str()); - if (len < 0) - return 0; // encoding error - // On truncation, snprintf returns would-be length; actual written is buf.size() - 1 - return static_cast(len) >= buf.size() ? buf.size() - 1 : static_cast(len); + // Append " " directly + char *p = buf.data() + len; + size_t remaining = buf.size() - len; + size_t uom_len = unit_of_measurement.size(); + // Need space for: ' ' + uom + '\0' + if (remaining < 2) { + return len; + } + *p++ = ' '; + remaining--; + size_t copy_len = std::min(uom_len, remaining - 1); + memcpy(p, unit_of_measurement.c_str(), copy_len); + p += copy_len; + *p = '\0'; + return static_cast(p - buf.data()); } int8_t step_to_accuracy_decimals(float step) { diff --git a/tests/components/core/test_value_accuracy.cpp b/tests/components/core/test_value_accuracy.cpp new file mode 100644 index 0000000000..a1fba90acf --- /dev/null +++ b/tests/components/core/test_value_accuracy.cpp @@ -0,0 +1,151 @@ +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::testing { + +// Helper to call value_accuracy_to_buf and return as string +static std::string va_to_string(float value, int8_t accuracy_decimals) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + size_t len = value_accuracy_to_buf(sp, value, accuracy_decimals); + return std::string(buf, len); +} + +// Helper: reference implementation using snprintf for comparison +static std::string va_reference(float value, int8_t accuracy_decimals) { + // Replicate normalize_accuracy_decimals logic + if (accuracy_decimals < 0) { + float divisor; + if (accuracy_decimals == -1) { + divisor = 10.0f; + } else if (accuracy_decimals == -2) { + divisor = 100.0f; + } else { + divisor = pow10_int(-accuracy_decimals); + } + value = roundf(value / divisor) * divisor; + accuracy_decimals = 0; + } + char buf[VALUE_ACCURACY_MAX_LEN]; + snprintf(buf, sizeof(buf), "%.*f", accuracy_decimals, value); + return std::string(buf); +} + +// --- Basic formatting --- + +TEST(ValueAccuracyToBuf, ZeroDecimals) { + EXPECT_EQ(va_to_string(23.456f, 0), "23"); + EXPECT_EQ(va_to_string(0.0f, 0), "0"); + EXPECT_EQ(va_to_string(100.0f, 0), "100"); + EXPECT_EQ(va_to_string(1.0f, 0), "1"); +} + +TEST(ValueAccuracyToBuf, OneDecimal) { + EXPECT_EQ(va_to_string(23.456f, 1), "23.5"); + EXPECT_EQ(va_to_string(0.0f, 1), "0.0"); + EXPECT_EQ(va_to_string(1.05f, 1), va_reference(1.05f, 1)); +} + +TEST(ValueAccuracyToBuf, TwoDecimals) { + EXPECT_EQ(va_to_string(23.456f, 2), "23.46"); + EXPECT_EQ(va_to_string(0.0f, 2), "0.00"); + EXPECT_EQ(va_to_string(1.005f, 2), va_reference(1.005f, 2)); +} + +TEST(ValueAccuracyToBuf, ThreeDecimals) { + EXPECT_EQ(va_to_string(23.456f, 3), "23.456"); + EXPECT_EQ(va_to_string(0.0f, 3), "0.000"); +} + +// --- Negative values --- + +TEST(ValueAccuracyToBuf, NegativeValues) { + EXPECT_EQ(va_to_string(-23.456f, 2), "-23.46"); + EXPECT_EQ(va_to_string(-0.5f, 1), "-0.5"); + EXPECT_EQ(va_to_string(-100.0f, 0), "-100"); +} + +// --- Negative accuracy_decimals (rounding to tens/hundreds) --- + +TEST(ValueAccuracyToBuf, NegativeAccuracy) { + EXPECT_EQ(va_to_string(1234.0f, -1), va_reference(1234.0f, -1)); + EXPECT_EQ(va_to_string(1234.0f, -2), va_reference(1234.0f, -2)); + EXPECT_EQ(va_to_string(56.0f, -1), va_reference(56.0f, -1)); +} + +// --- Special float values --- + +TEST(ValueAccuracyToBuf, NaN) { + std::string result = va_to_string(NAN, 2); + EXPECT_EQ(result, va_reference(NAN, 2)); +} + +TEST(ValueAccuracyToBuf, Infinity) { + std::string result = va_to_string(INFINITY, 2); + EXPECT_EQ(result, va_reference(INFINITY, 2)); +} + +TEST(ValueAccuracyToBuf, NegativeInfinity) { + std::string result = va_to_string(-INFINITY, 2); + EXPECT_EQ(result, va_reference(-INFINITY, 2)); +} + +// --- Edge cases --- + +TEST(ValueAccuracyToBuf, VerySmallValues) { + EXPECT_EQ(va_to_string(0.001f, 3), "0.001"); + EXPECT_EQ(va_to_string(0.001f, 2), "0.00"); + EXPECT_EQ(va_to_string(0.009f, 2), "0.01"); +} + +TEST(ValueAccuracyToBuf, LargeValues) { + EXPECT_EQ(va_to_string(999999.0f, 0), va_reference(999999.0f, 0)); + EXPECT_EQ(va_to_string(1013.25f, 2), "1013.25"); +} + +TEST(ValueAccuracyToBuf, Rounding) { + // 0.5 rounds up + EXPECT_EQ(va_to_string(23.5f, 0), "24"); + EXPECT_EQ(va_to_string(23.45f, 1), "23.5"); // float: 23.45 -> 23.4 or 23.5 + EXPECT_EQ(va_to_string(23.45f, 1), va_reference(23.45f, 1)); +} + +// --- Match snprintf for a range of typical sensor values --- + +TEST(ValueAccuracyToBuf, MatchesSnprintf) { + float test_values[] = {0.0f, 1.0f, -1.0f, 23.456f, -23.456f, 100.0f, 0.1f, 0.01f, 99.99f, 1013.25f, -40.0f}; + int8_t test_accuracies[] = {0, 1, 2, 3}; + + for (float value : test_values) { + for (int8_t acc : test_accuracies) { + EXPECT_EQ(va_to_string(value, acc), va_reference(value, acc)) + << "Mismatch for value=" << value << " accuracy=" << static_cast(acc); + } + } +} + +// --- Return value (length) --- + +TEST(ValueAccuracyToBuf, ReturnsCorrectLength) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + + size_t len = value_accuracy_to_buf(sp, 23.456f, 2); + EXPECT_EQ(len, 5u); // "23.46" + EXPECT_EQ(strlen(buf), len); + + len = value_accuracy_to_buf(sp, 0.0f, 0); + EXPECT_EQ(len, 1u); // "0" + EXPECT_EQ(strlen(buf), len); + + len = value_accuracy_to_buf(sp, -100.0f, 1); + EXPECT_EQ(len, 6u); // "-100.0" + EXPECT_EQ(strlen(buf), len); +} + +} // namespace esphome::testing From 5ac1f10b2873ff0798be71a542b091b11e079d8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 20:42:23 -1000 Subject: [PATCH 0011/2214] Fix clang-tidy braces --- esphome/core/helpers.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 48ed45477f..30903e58ca 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -535,12 +535,13 @@ static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy value = -value; } uint32_t mult = 1; - if (accuracy_decimals == 1) + if (accuracy_decimals == 1) { mult = 10; - else if (accuracy_decimals == 2) + } else if (accuracy_decimals == 2) { mult = 100; - else if (accuracy_decimals == 3) + } else if (accuracy_decimals == 3) { mult = 1000; + } // Cast to double for the multiply to match snprintf's precision. // float*int loses bits at exact-half boundaries (e.g. 23.45f*10 = 234.5 in float, // but snprintf sees 234.500007... via double promotion and rounds differently). From ff1f0a0d46a98a85f7a798baa448fd454e8c8f0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 20:50:03 -1000 Subject: [PATCH 0012/2214] Extract inline helpers: small_pow10, uint32_to_str, frac_to_str --- esphome/core/helpers.cpp | 32 ++++---------------------------- esphome/core/helpers.h | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 30903e58ca..244d307f8f 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -534,39 +534,15 @@ static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy *p++ = '-'; value = -value; } - uint32_t mult = 1; - if (accuracy_decimals == 1) { - mult = 10; - } else if (accuracy_decimals == 2) { - mult = 100; - } else if (accuracy_decimals == 3) { - mult = 1000; - } - // Cast to double for the multiply to match snprintf's precision. + uint32_t mult = small_pow10(accuracy_decimals); + // Cast to double for the multiply to match snprintf's rounding precision. // float*int loses bits at exact-half boundaries (e.g. 23.45f*10 = 234.5 in float, // but snprintf sees 234.500007... via double promotion and rounds differently). uint32_t scaled = static_cast(lrint(static_cast(value) * mult)); - uint32_t int_part = scaled / mult; - // Write integer part in reverse, then flip - char *start = p; - if (int_part == 0) { - *p++ = '0'; - } else { - while (int_part > 0) { - *p++ = '0' + (int_part % 10); - int_part /= 10; - } - std::reverse(start, p); - } + p = uint32_to_str(p, scaled / mult); if (accuracy_decimals > 0) { *p++ = '.'; - uint32_t frac = scaled % mult; - uint32_t d = mult / 10; - while (d > 0) { - *p++ = '0' + static_cast(frac / d); - frac %= d; - d /= 10; - } + p = frac_to_str(p, scaled % mult, mult / 10); } *p = '\0'; return static_cast(p - buf); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c26bbe17b7..ee6f76a09d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1295,6 +1295,46 @@ inline char *int8_to_str(char *buf, int8_t val) { return buf; } +/// Return 10^n for small non-negative n (0-3) as uint32_t, avoiding float. +inline uint32_t small_pow10(int8_t n) { + if (n == 1) { + return 10; + } else if (n == 2) { + return 100; + } else if (n == 3) { + return 1000; + } + return 1; +} + +/// Write unsigned 32-bit integer to buffer. Returns pointer past last char written. +/// Buffer must have at least 10 bytes free (max uint32 is 4294967295). +inline char *uint32_to_str(char *buf, uint32_t val) { + if (val == 0) { + *buf++ = '0'; + return buf; + } + char *start = buf; + while (val > 0) { + *buf++ = '0' + (val % 10); + val /= 10; + } + std::reverse(start, buf); + return buf; +} + +/// Write fractional digits with leading zeros to buffer. +/// frac is the fractional value, divisor is the highest place value (e.g. 100 for 3 digits). +/// Returns pointer past last char written. +inline char *frac_to_str(char *buf, uint32_t frac, uint32_t divisor) { + while (divisor > 0) { + *buf++ = '0' + static_cast(frac / divisor); + frac %= divisor; + divisor /= 10; + } + return buf; +} + /// Format byte array as lowercase hex to buffer (base implementation). char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length); From c8e636f25365640d38b67929ff7f5b4bd3cb2bdb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 20:51:06 -1000 Subject: [PATCH 0013/2214] Add unit tests for small_pow10, uint32_to_str, frac_to_str --- tests/components/core/test_helpers.cpp | 119 +++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/components/core/test_helpers.cpp diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp new file mode 100644 index 0000000000..50df1492ad --- /dev/null +++ b/tests/components/core/test_helpers.cpp @@ -0,0 +1,119 @@ +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::testing { + +// --- small_pow10() --- + +TEST(SmallPow10, Zero) { EXPECT_EQ(small_pow10(0), 1u); } +TEST(SmallPow10, One) { EXPECT_EQ(small_pow10(1), 10u); } +TEST(SmallPow10, Two) { EXPECT_EQ(small_pow10(2), 100u); } +TEST(SmallPow10, Three) { EXPECT_EQ(small_pow10(3), 1000u); } + +// --- uint32_to_str() --- + +TEST(Uint32ToStr, Zero) { + char buf[12]; + char *end = uint32_to_str(buf, 0); + *end = '\0'; + EXPECT_STREQ(buf, "0"); + EXPECT_EQ(end - buf, 1); +} + +TEST(Uint32ToStr, SingleDigit) { + char buf[12]; + char *end = uint32_to_str(buf, 7); + *end = '\0'; + EXPECT_STREQ(buf, "7"); +} + +TEST(Uint32ToStr, MultiDigit) { + char buf[12]; + char *end = uint32_to_str(buf, 12345); + *end = '\0'; + EXPECT_STREQ(buf, "12345"); + EXPECT_EQ(end - buf, 5); +} + +TEST(Uint32ToStr, Large) { + char buf[12]; + char *end = uint32_to_str(buf, 4294967295u); + *end = '\0'; + EXPECT_STREQ(buf, "4294967295"); + EXPECT_EQ(end - buf, 10); +} + +TEST(Uint32ToStr, PowersOfTen) { + char buf[12]; + char *end; + + end = uint32_to_str(buf, 10); + *end = '\0'; + EXPECT_STREQ(buf, "10"); + + end = uint32_to_str(buf, 100); + *end = '\0'; + EXPECT_STREQ(buf, "100"); + + end = uint32_to_str(buf, 1000); + *end = '\0'; + EXPECT_STREQ(buf, "1000"); +} + +// --- frac_to_str() --- + +TEST(FracToStr, OneDigit) { + char buf[8]; + char *end = frac_to_str(buf, 5, 1); + *end = '\0'; + EXPECT_STREQ(buf, "5"); + EXPECT_EQ(end - buf, 1); +} + +TEST(FracToStr, TwoDigits) { + char buf[8]; + char *end = frac_to_str(buf, 46, 10); + *end = '\0'; + EXPECT_STREQ(buf, "46"); +} + +TEST(FracToStr, ThreeDigits) { + char buf[8]; + char *end = frac_to_str(buf, 456, 100); + *end = '\0'; + EXPECT_STREQ(buf, "456"); + EXPECT_EQ(end - buf, 3); +} + +TEST(FracToStr, LeadingZeros) { + char buf[8]; + char *end = frac_to_str(buf, 1, 100); + *end = '\0'; + EXPECT_STREQ(buf, "001"); + + end = frac_to_str(buf, 5, 10); + *end = '\0'; + EXPECT_STREQ(buf, "05"); +} + +TEST(FracToStr, AllZeros) { + char buf[8]; + char *end = frac_to_str(buf, 0, 100); + *end = '\0'; + EXPECT_STREQ(buf, "000"); + + end = frac_to_str(buf, 0, 1); + *end = '\0'; + EXPECT_STREQ(buf, "0"); +} + +TEST(FracToStr, ZeroDivisor) { + char buf[8]; + buf[0] = 'X'; + char *end = frac_to_str(buf, 0, 0); + EXPECT_EQ(end, buf); // writes nothing +} + +} // namespace esphome::testing From e52c3b01e60e1f0c64be233aac64cddfc2f7fe76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 20:52:51 -1000 Subject: [PATCH 0014/2214] Extract buf_append_sep_str helper, add tests --- esphome/core/helpers.cpp | 18 +++---------- esphome/core/helpers.h | 15 +++++++++++ tests/components/core/test_helpers.cpp | 35 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 244d307f8f..b09e8ccbdf 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -569,21 +569,9 @@ size_t value_accuracy_with_uom_to_buf(std::span bu if (unit_of_measurement.empty()) { return len; } - // Append " " directly - char *p = buf.data() + len; - size_t remaining = buf.size() - len; - size_t uom_len = unit_of_measurement.size(); - // Need space for: ' ' + uom + '\0' - if (remaining < 2) { - return len; - } - *p++ = ' '; - remaining--; - size_t copy_len = std::min(uom_len, remaining - 1); - memcpy(p, unit_of_measurement.c_str(), copy_len); - p += copy_len; - *p = '\0'; - return static_cast(p - buf.data()); + char *end = buf_append_sep_str(buf.data() + len, buf.size() - len, ' ', unit_of_measurement.c_str(), + unit_of_measurement.size()); + return static_cast(end - buf.data()); } int8_t step_to_accuracy_decimals(float step) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index ee6f76a09d..3b93a2c476 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1295,6 +1295,21 @@ inline char *int8_to_str(char *buf, int8_t val) { return buf; } +/// Append a separator char and a string to a buffer, respecting remaining space. +/// Returns pointer past last char written (null terminator is written). +inline char *buf_append_sep_str(char *buf, size_t remaining, char separator, const char *str, size_t str_len) { + if (remaining < 2) { + return buf; + } + *buf++ = separator; + remaining--; + size_t copy_len = std::min(str_len, remaining - 1); + memcpy(buf, str, copy_len); + buf += copy_len; + *buf = '\0'; + return buf; +} + /// Return 10^n for small non-negative n (0-3) as uint32_t, avoiding float. inline uint32_t small_pow10(int8_t n) { if (n == 1) { diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 50df1492ad..261a4111b1 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -116,4 +116,39 @@ TEST(FracToStr, ZeroDivisor) { EXPECT_EQ(end, buf); // writes nothing } +// --- buf_append_sep_str() --- + +TEST(BufAppendSepStr, Basic) { + char buf[32] = "23.46"; + char *start = buf + 5; + char *end = buf_append_sep_str(start, sizeof(buf) - 5, ' ', "°C", 3); + EXPECT_STREQ(buf, "23.46 °C"); + EXPECT_EQ(end - buf, 9); // "°C" is 3 bytes (UTF-8) +} + +TEST(BufAppendSepStr, EmptyString) { + char buf[32] = "100"; + char *start = buf + 3; + char *end = buf_append_sep_str(start, sizeof(buf) - 3, ' ', "", 0); + EXPECT_STREQ(buf, "100 "); + EXPECT_EQ(end - start, 1); // just the separator +} + +TEST(BufAppendSepStr, NoRoom) { + char buf[8] = "1234567"; + char *start = buf + 7; + char *end = buf_append_sep_str(start, 1, ' ', "unit", 4); + EXPECT_EQ(end, start); // nothing written +} + +TEST(BufAppendSepStr, Truncation) { + char buf[8] = "val"; + char *start = buf + 3; + // remaining = 5, separator takes 1, so 3 chars of string fit + null + char *end = buf_append_sep_str(start, 5, ' ', "longunit", 8); + *end = '\0'; + EXPECT_STREQ(buf, "val lon"); + EXPECT_EQ(end - buf, 7); +} + } // namespace esphome::testing From 3b45179948e01e52ecfadd7c88c012773a20b7d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 21:01:32 -1000 Subject: [PATCH 0015/2214] Split uint32_to_str/frac_to_str into internal and public API - uint32_to_str_(): raw pointer, internal use - uint32_to_str(): template with compile-time buffer size check - frac_to_str_(): raw pointer, internal use - small_pow10(): simplify to ternary chain --- esphome/core/helpers.cpp | 4 +-- esphome/core/helpers.h | 33 ++++++++++--------- tests/components/core/test_helpers.cpp | 44 ++++++++++++++++---------- 3 files changed, 48 insertions(+), 33 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index b09e8ccbdf..dc4560b5ef 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -539,10 +539,10 @@ static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy // float*int loses bits at exact-half boundaries (e.g. 23.45f*10 = 234.5 in float, // but snprintf sees 234.500007... via double promotion and rounds differently). uint32_t scaled = static_cast(lrint(static_cast(value) * mult)); - p = uint32_to_str(p, scaled / mult); + p = uint32_to_str_(p, scaled / mult); if (accuracy_decimals > 0) { *p++ = '.'; - p = frac_to_str(p, scaled % mult, mult / 10); + p = frac_to_str_(p, scaled % mult, mult / 10); } *p = '\0'; return static_cast(p - buf); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 3b93a2c476..11696772be 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1311,20 +1311,14 @@ inline char *buf_append_sep_str(char *buf, size_t remaining, char separator, con } /// Return 10^n for small non-negative n (0-3) as uint32_t, avoiding float. -inline uint32_t small_pow10(int8_t n) { - if (n == 1) { - return 10; - } else if (n == 2) { - return 100; - } else if (n == 3) { - return 1000; - } - return 1; -} +inline uint32_t small_pow10(int8_t n) { return n == 3 ? 1000 : n == 2 ? 100 : n == 1 ? 10 : 1; } -/// Write unsigned 32-bit integer to buffer. Returns pointer past last char written. -/// Buffer must have at least 10 bytes free (max uint32 is 4294967295). -inline char *uint32_to_str(char *buf, uint32_t val) { +/// Minimum buffer size for uint32_to_str: 10 digits + null terminator. +static constexpr size_t UINT32_MAX_STR_SIZE = 11; + +/// Write unsigned 32-bit integer to buffer (internal, no size check). +/// Buffer must have at least 10 bytes free. Returns pointer past last char written. +inline char *uint32_to_str_(char *buf, uint32_t val) { if (val == 0) { *buf++ = '0'; return buf; @@ -1338,10 +1332,19 @@ inline char *uint32_to_str(char *buf, uint32_t val) { return buf; } -/// Write fractional digits with leading zeros to buffer. +/// Write unsigned 32-bit integer to buffer with compile-time size check. +/// Null-terminates the output. Returns number of chars written (excluding null). +template inline size_t uint32_to_str(char (&buf)[N], uint32_t val) { + static_assert(N >= UINT32_MAX_STR_SIZE, "Buffer too small for uint32 (need 11 bytes)"); + char *end = uint32_to_str_(buf, val); + *end = '\0'; + return static_cast(end - buf); +} + +/// Write fractional digits with leading zeros to buffer (internal, no size check). /// frac is the fractional value, divisor is the highest place value (e.g. 100 for 3 digits). /// Returns pointer past last char written. -inline char *frac_to_str(char *buf, uint32_t frac, uint32_t divisor) { +inline char *frac_to_str_(char *buf, uint32_t frac, uint32_t divisor) { while (divisor > 0) { *buf++ = '0' + static_cast(frac / divisor); frac %= divisor; diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 261a4111b1..6c0490b6c6 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -16,7 +16,7 @@ TEST(SmallPow10, Three) { EXPECT_EQ(small_pow10(3), 1000u); } TEST(Uint32ToStr, Zero) { char buf[12]; - char *end = uint32_to_str(buf, 0); + char *end = uint32_to_str_(buf, 0); *end = '\0'; EXPECT_STREQ(buf, "0"); EXPECT_EQ(end - buf, 1); @@ -24,14 +24,14 @@ TEST(Uint32ToStr, Zero) { TEST(Uint32ToStr, SingleDigit) { char buf[12]; - char *end = uint32_to_str(buf, 7); + char *end = uint32_to_str_(buf, 7); *end = '\0'; EXPECT_STREQ(buf, "7"); } TEST(Uint32ToStr, MultiDigit) { char buf[12]; - char *end = uint32_to_str(buf, 12345); + char *end = uint32_to_str_(buf, 12345); *end = '\0'; EXPECT_STREQ(buf, "12345"); EXPECT_EQ(end - buf, 5); @@ -39,7 +39,7 @@ TEST(Uint32ToStr, MultiDigit) { TEST(Uint32ToStr, Large) { char buf[12]; - char *end = uint32_to_str(buf, 4294967295u); + char *end = uint32_to_str_(buf, 4294967295u); *end = '\0'; EXPECT_STREQ(buf, "4294967295"); EXPECT_EQ(end - buf, 10); @@ -49,24 +49,36 @@ TEST(Uint32ToStr, PowersOfTen) { char buf[12]; char *end; - end = uint32_to_str(buf, 10); + end = uint32_to_str_(buf, 10); *end = '\0'; EXPECT_STREQ(buf, "10"); - end = uint32_to_str(buf, 100); + end = uint32_to_str_(buf, 100); *end = '\0'; EXPECT_STREQ(buf, "100"); - end = uint32_to_str(buf, 1000); + end = uint32_to_str_(buf, 1000); *end = '\0'; EXPECT_STREQ(buf, "1000"); } -// --- frac_to_str() --- +// --- uint32_to_str() (public, template with size check) --- + +TEST(Uint32ToStr, PublicApi) { + char buf[UINT32_MAX_STR_SIZE]; + EXPECT_EQ(uint32_to_str(buf, 0), 1u); + EXPECT_STREQ(buf, "0"); + EXPECT_EQ(uint32_to_str(buf, 12345), 5u); + EXPECT_STREQ(buf, "12345"); + EXPECT_EQ(uint32_to_str(buf, 4294967295u), 10u); + EXPECT_STREQ(buf, "4294967295"); +} + +// --- frac_to_str_() --- TEST(FracToStr, OneDigit) { char buf[8]; - char *end = frac_to_str(buf, 5, 1); + char *end = frac_to_str_(buf, 5, 1); *end = '\0'; EXPECT_STREQ(buf, "5"); EXPECT_EQ(end - buf, 1); @@ -74,14 +86,14 @@ TEST(FracToStr, OneDigit) { TEST(FracToStr, TwoDigits) { char buf[8]; - char *end = frac_to_str(buf, 46, 10); + char *end = frac_to_str_(buf, 46, 10); *end = '\0'; EXPECT_STREQ(buf, "46"); } TEST(FracToStr, ThreeDigits) { char buf[8]; - char *end = frac_to_str(buf, 456, 100); + char *end = frac_to_str_(buf, 456, 100); *end = '\0'; EXPECT_STREQ(buf, "456"); EXPECT_EQ(end - buf, 3); @@ -89,22 +101,22 @@ TEST(FracToStr, ThreeDigits) { TEST(FracToStr, LeadingZeros) { char buf[8]; - char *end = frac_to_str(buf, 1, 100); + char *end = frac_to_str_(buf, 1, 100); *end = '\0'; EXPECT_STREQ(buf, "001"); - end = frac_to_str(buf, 5, 10); + end = frac_to_str_(buf, 5, 10); *end = '\0'; EXPECT_STREQ(buf, "05"); } TEST(FracToStr, AllZeros) { char buf[8]; - char *end = frac_to_str(buf, 0, 100); + char *end = frac_to_str_(buf, 0, 100); *end = '\0'; EXPECT_STREQ(buf, "000"); - end = frac_to_str(buf, 0, 1); + end = frac_to_str_(buf, 0, 1); *end = '\0'; EXPECT_STREQ(buf, "0"); } @@ -112,7 +124,7 @@ TEST(FracToStr, AllZeros) { TEST(FracToStr, ZeroDivisor) { char buf[8]; buf[0] = 'X'; - char *end = frac_to_str(buf, 0, 0); + char *end = frac_to_str_(buf, 0, 0); EXPECT_EQ(end, buf); // writes nothing } From e0fa1340e2fc2b38d4f435162e6fc2baafa13f7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 21:02:20 -1000 Subject: [PATCH 0016/2214] Use std::span for uint32_to_str public API --- esphome/core/helpers.h | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 11696772be..009f08c323 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1334,11 +1334,10 @@ inline char *uint32_to_str_(char *buf, uint32_t val) { /// Write unsigned 32-bit integer to buffer with compile-time size check. /// Null-terminates the output. Returns number of chars written (excluding null). -template inline size_t uint32_to_str(char (&buf)[N], uint32_t val) { - static_assert(N >= UINT32_MAX_STR_SIZE, "Buffer too small for uint32 (need 11 bytes)"); - char *end = uint32_to_str_(buf, val); +inline size_t uint32_to_str(std::span buf, uint32_t val) { + char *end = uint32_to_str_(buf.data(), val); *end = '\0'; - return static_cast(end - buf); + return static_cast(end - buf.data()); } /// Write fractional digits with leading zeros to buffer (internal, no size check). From 4d4b77f9fcd11be0deffe5c372733a8635cb4698 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 23:06:41 -1000 Subject: [PATCH 0017/2214] [api] Add (inline_encode) proto option for sub-message inlining Add a new message-level option (inline_encode) that causes the code generator to inline sub-message encoding directly into the parent's encode/calculate_size methods instead of going through the encode_sub_message function pointer indirection. When set on a sub-message type, the generator: - Inlines field encoding directly (no function pointer, no backpatch overhead) - Inlines size calculation (no separate method call) - Skips generating standalone encode/calculate_size methods - Validates at generation time that max encoded size < 128 bytes Applied to BluetoothLERawAdvertisement which is encoded 12 times per BLE advertisement batch in a hot loop. --- esphome/components/api/api.proto | 1 + esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 52 ++-- esphome/components/api/api_pb2.h | 2 - script/api_protobuf/api_protobuf.py | 288 +++++++++++++++++++++-- 5 files changed, 299 insertions(+), 45 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 33d16f0339..e4d0c2d16d 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1625,6 +1625,7 @@ message BluetoothLEAdvertisementResponse { } message BluetoothLERawAdvertisement { + option (inline_encode) = true; uint64 address = 1 [(force) = true]; sint32 rssi = 2 [(force) = true]; uint32 address_type = 3 [(max_value) = 4]; diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 0f71268d70..dacc290e31 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -22,6 +22,7 @@ extend google.protobuf.MessageOptions { optional bool log = 1039 [default=true]; optional bool no_delay = 1040 [default=false]; optional string base_class = 1041; + optional bool inline_encode = 1042 [default=false]; } extend google.protobuf.FieldOptions { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index d27cfa57cf..632cc99de0 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2328,40 +2328,42 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, } return true; } -uint8_t *BluetoothLERawAdvertisement::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, this->address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(this->rssi)); - if (this->address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->address_type); - } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(this->data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->data, this->data_len); - return pos; -} -uint32_t BluetoothLERawAdvertisement::calculate_size() const { - uint32_t size = 0; - size += ProtoSize::calc_uint64_force(1, this->address); - size += ProtoSize::calc_sint32_force(1, this->rssi); - size += this->address_type ? 2 : 0; - size += 2 + this->data_len; - return size; -} uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->advertisements[i]); + { + auto &sub_msg = this->advertisements[i]; + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + uint8_t *len_pos = pos++; + uint8_t *body_start = pos; + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + if (sub_msg.address_type) { + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); + } + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + *len_pos = static_cast(pos - body_start); + } } return pos; } uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { - size += ProtoSize::calc_message_force(1, this->advertisements[i].calculate_size()); + { + auto &sub_msg = this->advertisements[i]; + uint32_t sub_size = 0; + sub_size += ProtoSize::calc_uint64_force(1, sub_msg.address); + sub_size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); + sub_size += sub_msg.address_type ? 2 : 0; + sub_size += 2 + sub_msg.data_len; + size += 2 + sub_size; + } } return size; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 3b239db36c..5827a8728e 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1888,8 +1888,6 @@ class BluetoothLERawAdvertisement final : public ProtoMessage { uint32_t address_type{0}; uint8_t data[62]{}; uint8_t data_len{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 diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 526644842d..a2a5719dcf 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -60,6 +60,10 @@ FILE_HEADER = """// This file was automatically generated with a tool. # Maps enum type name (e.g. ".BluetoothDeviceRequestType") to max enum value. _enum_max_values: dict[str, int] = {} +# Populated by main() before message generation. +# Maps message name (e.g. "BluetoothLERawAdvertisement") to its descriptor. +_message_desc_map: dict[str, Any] = {} + def indent_list(text: str, padding: str = " ") -> list[str]: """Indent each line of the given text with the specified padding.""" @@ -427,6 +431,23 @@ class TypeInfo(ABC): Estimated size in bytes including field ID and typical data """ + def get_max_encoded_size(self) -> int | None: + """Get the maximum possible encoded size in bytes for this field. + + Returns the worst-case encoded size including field ID and maximum + possible value encoding. Returns None if the size is unbounded + (e.g., variable-length strings without max_data_length). + + Used by (inline_encode) validation to ensure sub-messages fit in a + single-byte length varint (< 128 bytes). + """ + return None # Unbounded by default + + +def _varint_max_size(bits: int) -> int: + """Return the maximum varint encoding size for a value with the given number of bits.""" + return (bits + 6) // 7 # ceil(bits / 7) + TYPE_INFO: dict[int, TypeInfo] = {} @@ -514,8 +535,30 @@ def register_type(name: int): return func +class FixedSizeTypeMixin: + """Mixin for types with a known fixed encoded size (float, double, fixed32, fixed64).""" + + def get_max_encoded_size(self) -> int: + return self.calculate_field_id_size() + self.get_fixed_size_bytes() + + +class VarintTypeMixin: + """Mixin for varint types. Subclasses set _varint_max_bits.""" + + _varint_max_bits: int = 64 # Default to worst case + + def get_max_encoded_size(self) -> int: + max_val = self.max_value + if max_val is not None: + return self.calculate_field_id_size() + _varint_max_size( + max_val.bit_length() if max_val > 0 else 1 + ) + return self.calculate_field_id_size() + _varint_max_size(self._varint_max_bits) + + @register_type(1) -class DoubleType(TypeInfo): +class DoubleType(FixedSizeTypeMixin, TypeInfo): + # Unsupported but defined for completeness cpp_type = "double" default_value = "0.0" decode_64bit = "value.as_double()" @@ -541,7 +584,7 @@ class DoubleType(TypeInfo): @register_type(2) -class FloatType(TypeInfo): +class FloatType(FixedSizeTypeMixin, TypeInfo): cpp_type = "float" default_value = "0.0f" decode_32bit = "value.as_float()" @@ -567,8 +610,9 @@ class FloatType(TypeInfo): @register_type(3) -class Int64Type(TypeInfo): +class Int64Type(VarintTypeMixin, TypeInfo): cpp_type = "int64_t" + _varint_max_bits = 64 default_value = "0" decode_varint = "static_cast(value)" encode_func = "encode_int64" @@ -587,8 +631,9 @@ class Int64Type(TypeInfo): @register_type(4) -class UInt64Type(TypeInfo): +class UInt64Type(VarintTypeMixin, TypeInfo): cpp_type = "uint64_t" + _varint_max_bits = 64 default_value = "0" decode_varint = "value" encode_func = "encode_uint64" @@ -607,8 +652,9 @@ class UInt64Type(TypeInfo): @register_type(5) -class Int32Type(TypeInfo): +class Int32Type(VarintTypeMixin, TypeInfo): cpp_type = "int32_t" + _varint_max_bits = 64 # int32 is sign-extended to 64 bits in protobuf default_value = "0" decode_varint = "static_cast(value)" encode_func = "encode_int32" @@ -627,7 +673,7 @@ class Int32Type(TypeInfo): @register_type(6) -class Fixed64Type(TypeInfo): +class Fixed64Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "uint64_t" default_value = "0" decode_64bit = "value.as_fixed64()" @@ -653,7 +699,7 @@ class Fixed64Type(TypeInfo): @register_type(7) -class Fixed32Type(TypeInfo): +class Fixed32Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "uint32_t" default_value = "0" decode_32bit = "value.as_fixed32()" @@ -689,7 +735,8 @@ class Fixed32Type(TypeInfo): @register_type(8) -class BoolType(TypeInfo): +class BoolType(VarintTypeMixin, TypeInfo): + _varint_max_bits = 1 cpp_type = "bool" default_value = "false" decode_varint = "value != 0" @@ -1299,14 +1346,23 @@ class FixedArrayBytesType(TypeInfo): self.calculate_field_id_size() + 1 + 31 ) # field ID + length byte + typical 31 bytes + def get_max_encoded_size(self) -> int: + # field_id + varint(array_size) + array_size + return ( + self.calculate_field_id_size() + + _varint_max_size(self.array_size.bit_length()) + + self.array_size + ) + @property def wire_type(self) -> WireType: return WireType.LENGTH_DELIMITED @register_type(13) -class UInt32Type(TypeInfo): +class UInt32Type(VarintTypeMixin, TypeInfo): cpp_type = "uint32_t" + _varint_max_bits = 32 default_value = "0" decode_varint = "value" encode_func = "encode_uint32" @@ -1328,7 +1384,9 @@ class UInt32Type(TypeInfo): @register_type(14) -class EnumType(TypeInfo): +class EnumType(VarintTypeMixin, TypeInfo): + _varint_max_bits = 32 + @property def cpp_type(self) -> str: return f"enums::{self._field.type_name[1:]}" @@ -1379,7 +1437,7 @@ class EnumType(TypeInfo): @register_type(15) -class SFixed32Type(TypeInfo): +class SFixed32Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "int32_t" default_value = "0" decode_32bit = "value.as_sfixed32()" @@ -1405,7 +1463,7 @@ class SFixed32Type(TypeInfo): @register_type(16) -class SFixed64Type(TypeInfo): +class SFixed64Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "int64_t" default_value = "0" decode_64bit = "value.as_sfixed64()" @@ -1431,8 +1489,9 @@ class SFixed64Type(TypeInfo): @register_type(17) -class SInt32Type(TypeInfo): +class SInt32Type(VarintTypeMixin, TypeInfo): cpp_type = "int32_t" + _varint_max_bits = 32 # zigzag encoding keeps it 32-bit default_value = "0" decode_varint = "decode_zigzag32(static_cast(value))" encode_func = "encode_sint32" @@ -1451,8 +1510,9 @@ class SInt32Type(TypeInfo): @register_type(18) -class SInt64Type(TypeInfo): +class SInt64Type(VarintTypeMixin, TypeInfo): cpp_type = "int64_t" + _varint_max_bits = 64 default_value = "0" decode_varint = "decode_zigzag64(value)" encode_func = "encode_sint64" @@ -1500,6 +1560,98 @@ def _generate_array_dump_content( return o +def _is_inline_encode(sub_msg_name: str) -> bool: + """Check if a sub-message type has the (inline_encode) option set.""" + sub_desc = _message_desc_map.get(sub_msg_name) + if not sub_desc: + return False + inline_opt = getattr(pb, "inline_encode", None) + if inline_opt is None: + return False + return get_opt(sub_desc, inline_opt, False) + + +def _generate_inline_encode_block( + field_number: int, sub_msg_name: str, element: str +) -> str: + """Generate inline encode code for a sub-message with (inline_encode) = true. + + Instead of calling encode_sub_message (function pointer indirection), + this inlines the sub-message's field encoding directly. Uses 1-byte + backpatch for the length (validated to be < 128 at generation time). + + Uses a local reference alias 'sub_msg' to avoid issues with this-> replacement + on complex element expressions. + + Args: + field_number: The parent field number for this sub-message + sub_msg_name: The sub-message type name + element: C++ expression for the element (e.g., "it" or "this->field[i]") + """ + sub_desc = _message_desc_map[sub_msg_name] + tag = (field_number << 3) | 2 # wire type 2 = LENGTH_DELIMITED + assert tag < 128, f"inline_encode requires single-byte tag, got {tag}" + + lines = [] + lines.append("{") + lines.append(f" auto &sub_msg = {element};") + lines.append(f" ProtoEncode::write_raw_byte(pos, {tag});") + lines.append(" uint8_t *len_pos = pos++;") + lines.append(" uint8_t *body_start = pos;") + + # Generate inline field encoding for each sub-message field + for field in sub_desc.field: + if field.options.deprecated: + continue + ti = create_field_type_info(field, needs_decode=False, needs_encode=True) + encode_line = ti.encode_content + # Replace this-> with sub_msg reference for the sub-message fields + encode_line = encode_line.replace("this->", "sub_msg.") + lines.extend(f" {line}" for line in encode_line.split("\n")) + + lines.append(" *len_pos = static_cast(pos - body_start);") + lines.append("}") + return "\n".join(lines) + + +def _generate_inline_size_block( + field_number: int, sub_msg_name: str, element: str +) -> str: + """Generate inline size calculation for a sub-message with (inline_encode) = true. + + Uses a local reference alias 'sub_msg' to avoid issues with this-> replacement + on complex element expressions like 'this->advertisements[i]'. + + Args: + field_number: The parent field number for this sub-message + sub_msg_name: The sub-message type name + element: C++ expression for the element + """ + sub_desc = _message_desc_map[sub_msg_name] + + lines = [] + lines.append("{") + lines.append(f" auto &sub_msg = {element};") + lines.append(" uint32_t sub_size = 0;") + + for field in sub_desc.field: + if field.options.deprecated: + continue + ti = create_field_type_info(field, needs_decode=False, needs_encode=True) + force = get_field_opt(field, pb.force, False) + size_line = ti.get_size_calculation(f"sub_msg.{ti.field_name}", force) + # Replace "size +=" with "sub_size +=" for the local accumulator + size_line = size_line.replace("size +=", "sub_size +=") + # Replace hardcoded this-> references (e.g., FixedArrayBytesType uses this->field_len) + size_line = size_line.replace("this->", "sub_msg.") + lines.extend(f" {line}" for line in size_line.split("\n")) + + # 1 byte tag + 1 byte length (guaranteed < 128) + body + lines.append(" size += 2 + sub_size;") + lines.append("}") + return "\n".join(lines) + + class FixedArrayRepeatedType(TypeInfo): """Special type for fixed-size repeated fields using std::array. @@ -1526,6 +1678,10 @@ class FixedArrayRepeatedType(TypeInfo): return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast({element}), true);" # Repeated message elements use encode_sub_message (force=true is default) if isinstance(self._ti, MessageType): + if _is_inline_encode(self._ti.cpp_type): + return _generate_inline_encode_block( + self.number, self._ti.cpp_type, element + ) return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});" return ( f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);" @@ -1633,8 +1789,19 @@ class FixedArrayRepeatedType(TypeInfo): ] return f"if ({non_zero_checks}) {{\n" + "\n".join(size_lines) + "\n}" + is_inline = isinstance(self._ti, MessageType) and _is_inline_encode( + self._ti.cpp_type + ) + # When using a define, always use loop-based approach if self.is_define: + if is_inline: + o = f"for (const auto &it : {name}) {{\n" + o += indent( + _generate_inline_size_block(self.number, self._ti.cpp_type, "it") + ) + o += "\n}" + return o o = f"for (const auto &it : {name}) {{\n" o += f" {self._ti.get_size_calculation('it', True)}\n" o += "}" @@ -1642,6 +1809,29 @@ class FixedArrayRepeatedType(TypeInfo): # For fixed arrays, we always encode all elements + if is_inline: + # Special case for single-element arrays - no loop needed + if self.array_size == 1: + return _generate_inline_size_block( + self.number, self._ti.cpp_type, f"{name}[0]" + ) + if self.array_size == 2: + return ( + _generate_inline_size_block( + self.number, self._ti.cpp_type, f"{name}[0]" + ) + + "\n " + + _generate_inline_size_block( + self.number, self._ti.cpp_type, f"{name}[1]" + ) + ) + o = f"for (const auto &it : {name}) {{\n" + o += indent( + _generate_inline_size_block(self.number, self._ti.cpp_type, "it") + ) + o += "\n}" + return o + # Special case for single-element arrays - no loop needed if self.array_size == 1: return self._ti.get_size_calculation(f"{name}[0]", True) @@ -1714,6 +1904,15 @@ class FixedArrayWithLengthRepeatedType(FixedArrayRepeatedType): def get_size_calculation(self, name: str, force: bool = False) -> str: # Calculate size only for active elements + if isinstance(self._ti, MessageType) and _is_inline_encode(self._ti.cpp_type): + o = f"for (uint16_t i = 0; i < {name}_len; i++) {{\n" + o += indent( + _generate_inline_size_block( + self.number, self._ti.cpp_type, f"{name}[i]" + ) + ) + o += "\n}" + return o o = f"for (uint16_t i = 0; i < {name}_len; i++) {{\n" o += f" {self._ti.get_size_calculation(f'{name}[i]', True)}\n" o += "}" @@ -2222,6 +2421,28 @@ def calculate_message_estimated_size(desc: descriptor.DescriptorProto) -> int: return total_size +def calculate_message_max_size(desc: descriptor.DescriptorProto) -> int | None: + """Calculate the maximum possible encoded size for a message. + + Returns None if any field has unbounded size (e.g., variable-length strings). + Used to validate that (inline_encode) messages fit in a single-byte length varint. + """ + total_size = 0 + + for field in desc.field: + if field.options.deprecated: + continue + + ti = create_field_type_info(field, needs_decode=False, needs_encode=True) + max_size = ti.get_max_encoded_size() + if max_size is None: + return None + + total_size += max_size + + return total_size + + def build_message_type( desc: descriptor.DescriptorProto, base_class_fields: dict[str, list[descriptor.FieldDescriptorProto]], @@ -2451,8 +2672,15 @@ def build_message_type( prot = "void decode(const uint8_t *buffer, size_t length);" public_content.append(prot) + # Check if this message uses inline_encode — if so, skip generating standalone + # encode/calculate_size methods since the encoding is inlined into the parent. + is_inline_only = ( + message_id is None # Not a service message (no id) + and get_opt(desc, getattr(pb, "inline_encode", None), False) + ) + # Only generate encode method if this message needs encoding and has fields - if needs_encode and encode: + if needs_encode and encode and not is_inline_only: # Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls encode_debug = [ line.replace("(pos,", "(pos PROTO_ENCODE_DEBUG_ARG,") for line in encode @@ -2470,7 +2698,7 @@ def build_message_type( # If no fields to encode or message doesn't need encoding, the default implementation in ProtoMessage will be used # Add calculate_size method only if this message needs encoding and has fields - if needs_encode and size_calc: + if needs_encode and size_calc and not is_inline_only: o = f"uint32_t {desc.name}::calculate_size() const {{\n" o += " uint32_t size = 0;\n" o += indent("\n".join(size_calc)) + "\n" @@ -2830,6 +3058,32 @@ def main() -> None: if not enum.options.deprecated and enum.value: _enum_max_values[f".{enum.name}"] = max(v.number for v in enum.value) + # Build message descriptor map for inline_encode lookups + mt = file.message_type + _message_desc_map.update({m.name: m for m in mt if not m.options.deprecated}) + + # Validate inline_encode messages fit in single-byte length varint + inline_encode_opt = getattr(pb, "inline_encode", None) + if inline_encode_opt is not None: + for m in mt: + if m.options.deprecated: + continue + if not get_opt(m, inline_encode_opt, False): + continue + max_size = calculate_message_max_size(m) + if max_size is None: + raise ValueError( + f"Message '{m.name}' has (inline_encode) = true but contains " + f"fields with unbounded size. Inline encoding requires all " + f"fields to have bounded maximum size." + ) + if max_size >= 128: + raise ValueError( + f"Message '{m.name}' has (inline_encode) = true but max " + f"encoded size is {max_size} bytes (>= 128). Inline encoding " + f"requires sub-messages that fit in a single-byte length varint." + ) + # Build dynamic ifdef mappings early so we can emit USE_API_VARINT64 before includes enum_ifdef_map, message_ifdef_map, message_source_map, used_messages = ( build_type_usage_map(file) @@ -3048,8 +3302,6 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint content += "\n} // namespace enums\n\n" - mt = file.message_type - # Identify empty SOURCE_CLIENT messages that don't need class generation for m in mt: if m.options.deprecated: From 14a4a2c6180643e5f3cf4ab057a61d919d4dfe43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 23:12:47 -1000 Subject: [PATCH 0018/2214] Remove redundant block scoping inside loop bodies --- esphome/components/api/api_pb2.cpp | 48 +++++++++++++---------------- script/api_protobuf/api_protobuf.py | 24 ++++++--------- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 632cc99de0..a33604e665 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2331,39 +2331,35 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { - { - auto &sub_msg = this->advertisements[i]; - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); - uint8_t *len_pos = pos++; - uint8_t *body_start = pos; - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); - if (sub_msg.address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); - } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); - *len_pos = static_cast(pos - body_start); + auto &sub_msg = this->advertisements[i]; + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + uint8_t *len_pos = pos++; + uint8_t *body_start = pos; + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + if (sub_msg.address_type) { + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); } + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + *len_pos = static_cast(pos - body_start); } return pos; } uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { - { - auto &sub_msg = this->advertisements[i]; - uint32_t sub_size = 0; - sub_size += ProtoSize::calc_uint64_force(1, sub_msg.address); - sub_size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); - sub_size += sub_msg.address_type ? 2 : 0; - sub_size += 2 + sub_msg.data_len; - size += 2 + sub_size; - } + auto &sub_msg = this->advertisements[i]; + uint32_t sub_size = 0; + sub_size += ProtoSize::calc_uint64_force(1, sub_msg.address); + sub_size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); + sub_size += sub_msg.address_type ? 2 : 0; + sub_size += 2 + sub_msg.data_len; + size += 2 + sub_size; } return size; } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index a2a5719dcf..e5b3f773e7 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1593,11 +1593,10 @@ def _generate_inline_encode_block( assert tag < 128, f"inline_encode requires single-byte tag, got {tag}" lines = [] - lines.append("{") - lines.append(f" auto &sub_msg = {element};") - lines.append(f" ProtoEncode::write_raw_byte(pos, {tag});") - lines.append(" uint8_t *len_pos = pos++;") - lines.append(" uint8_t *body_start = pos;") + lines.append(f"auto &sub_msg = {element};") + lines.append(f"ProtoEncode::write_raw_byte(pos, {tag});") + lines.append("uint8_t *len_pos = pos++;") + lines.append("uint8_t *body_start = pos;") # Generate inline field encoding for each sub-message field for field in sub_desc.field: @@ -1607,10 +1606,9 @@ def _generate_inline_encode_block( encode_line = ti.encode_content # Replace this-> with sub_msg reference for the sub-message fields encode_line = encode_line.replace("this->", "sub_msg.") - lines.extend(f" {line}" for line in encode_line.split("\n")) + lines.extend(encode_line.split("\n")) - lines.append(" *len_pos = static_cast(pos - body_start);") - lines.append("}") + lines.append("*len_pos = static_cast(pos - body_start);") return "\n".join(lines) @@ -1630,9 +1628,8 @@ def _generate_inline_size_block( sub_desc = _message_desc_map[sub_msg_name] lines = [] - lines.append("{") - lines.append(f" auto &sub_msg = {element};") - lines.append(" uint32_t sub_size = 0;") + lines.append(f"auto &sub_msg = {element};") + lines.append("uint32_t sub_size = 0;") for field in sub_desc.field: if field.options.deprecated: @@ -1644,11 +1641,10 @@ def _generate_inline_size_block( size_line = size_line.replace("size +=", "sub_size +=") # Replace hardcoded this-> references (e.g., FixedArrayBytesType uses this->field_len) size_line = size_line.replace("this->", "sub_msg.") - lines.extend(f" {line}" for line in size_line.split("\n")) + lines.extend(size_line.split("\n")) # 1 byte tag + 1 byte length (guaranteed < 128) + body - lines.append(" size += 2 + sub_size;") - lines.append("}") + lines.append("size += 2 + sub_size;") return "\n".join(lines) From 1ac2a73267850190bcce17d08d77cd639bd876b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 23:25:12 -1000 Subject: [PATCH 0019/2214] Remove redundant body_start variable in inline encode --- esphome/components/api/api_pb2.cpp | 3 +-- script/api_protobuf/api_protobuf.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index a33604e665..6db5dcf97e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2334,7 +2334,6 @@ uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer P auto &sub_msg = this->advertisements[i]; ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); uint8_t *len_pos = pos++; - uint8_t *body_start = pos; ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); @@ -2346,7 +2345,7 @@ uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer P ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); - *len_pos = static_cast(pos - body_start); + *len_pos = static_cast(pos - len_pos - 1); } return pos; } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index e5b3f773e7..6f92e4cb6d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1596,7 +1596,6 @@ def _generate_inline_encode_block( lines.append(f"auto &sub_msg = {element};") lines.append(f"ProtoEncode::write_raw_byte(pos, {tag});") lines.append("uint8_t *len_pos = pos++;") - lines.append("uint8_t *body_start = pos;") # Generate inline field encoding for each sub-message field for field in sub_desc.field: @@ -1608,7 +1607,7 @@ def _generate_inline_encode_block( encode_line = encode_line.replace("this->", "sub_msg.") lines.extend(encode_line.split("\n")) - lines.append("*len_pos = static_cast(pos - body_start);") + lines.append("*len_pos = static_cast(pos - len_pos - 1);") return "\n".join(lines) From 24edb9c98e5281704482c0d6837d42ba58e155e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 23:29:51 -1000 Subject: [PATCH 0020/2214] Eliminate sub_size variable, accumulate directly into size --- esphome/components/api/api_pb2.cpp | 11 +++++------ script/api_protobuf/api_protobuf.py | 7 ++----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 6db5dcf97e..038c1c7800 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2353,12 +2353,11 @@ uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; - uint32_t sub_size = 0; - sub_size += ProtoSize::calc_uint64_force(1, sub_msg.address); - sub_size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); - sub_size += sub_msg.address_type ? 2 : 0; - sub_size += 2 + sub_msg.data_len; - size += 2 + sub_size; + size += 2; + size += ProtoSize::calc_uint64_force(1, sub_msg.address); + size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); + size += sub_msg.address_type ? 2 : 0; + size += 2 + sub_msg.data_len; } return size; } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 6f92e4cb6d..746402f8c6 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1628,7 +1628,8 @@ def _generate_inline_size_block( lines = [] lines.append(f"auto &sub_msg = {element};") - lines.append("uint32_t sub_size = 0;") + # 1 byte tag + 1 byte length (guaranteed < 128 by validation) + lines.append("size += 2;") for field in sub_desc.field: if field.options.deprecated: @@ -1636,14 +1637,10 @@ def _generate_inline_size_block( ti = create_field_type_info(field, needs_decode=False, needs_encode=True) force = get_field_opt(field, pb.force, False) size_line = ti.get_size_calculation(f"sub_msg.{ti.field_name}", force) - # Replace "size +=" with "sub_size +=" for the local accumulator - size_line = size_line.replace("size +=", "sub_size +=") # Replace hardcoded this-> references (e.g., FixedArrayBytesType uses this->field_len) size_line = size_line.replace("this->", "sub_msg.") lines.extend(size_line.split("\n")) - # 1 byte tag + 1 byte length (guaranteed < 128) + body - lines.append("size += 2 + sub_size;") return "\n".join(lines) From 1279e6112a696a0a6455255f0ea9b851fa902c5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 23:42:08 -1000 Subject: [PATCH 0021/2214] Support max_data_length in get_max_encoded_size for string types --- script/api_protobuf/api_protobuf.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 746402f8c6..b28a5a0c3d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -854,6 +854,16 @@ class StringType(TypeInfo): def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string + def get_max_encoded_size(self) -> int | None: + max_len = self.max_data_length + if max_len is not None: + return ( + self.calculate_field_id_size() + + _varint_max_size(max_len.bit_length()) + + max_len + ) + return None # Unbounded + @register_type(11) class MessageType(TypeInfo): @@ -1169,6 +1179,16 @@ class PointerToStringBufferType(PointerToBufferTypeBase): def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string + def get_max_encoded_size(self) -> int | None: + max_len = self.max_data_length + if max_len is not None: + return ( + self.calculate_field_id_size() + + _varint_max_size(max_len.bit_length()) + + max_len + ) + return None + class PackedBufferTypeInfo(TypeInfo): """Type for packed repeated fields that expose raw buffer instead of decoding. From 277552cef09bc54197ec0493fd26d4b4f1cd56aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 00:13:23 -1000 Subject: [PATCH 0022/2214] Address review: reserve_byte, varint(0), field_ifdef, getattr guard --- esphome/components/api/api_pb2.cpp | 3 ++- esphome/components/api/proto.h | 6 ++++++ script/api_protobuf/api_protobuf.py | 30 +++++++++++++++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 038c1c7800..c2d513f0d3 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2333,7 +2333,8 @@ uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer P for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); - uint8_t *len_pos = pos++; + uint8_t *len_pos = pos; + ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index e0a4e03189..8cac7fff3b 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -352,6 +352,12 @@ class ProtoEncode { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = b; } + /// Reserve one byte for later backpatch (e.g., sub-message length). + /// Advances pos past the reserved byte without writing a value. + static inline void ESPHOME_ALWAYS_INLINE reserve_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM) { + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + pos++; + } /// Write raw bytes to the buffer (no tag, no length prefix). static inline void ESPHOME_ALWAYS_INLINE encode_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, const void *data, size_t len) { diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index b28a5a0c3d..6dc691249b 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -446,7 +446,7 @@ class TypeInfo(ABC): def _varint_max_size(bits: int) -> int: """Return the maximum varint encoding size for a value with the given number of bits.""" - return (bits + 6) // 7 # ceil(bits / 7) + return (max(bits, 1) + 6) // 7 # ceil(bits / 7), min 1 byte for varint(0) TYPE_INFO: dict[int, TypeInfo] = {} @@ -1615,7 +1615,8 @@ def _generate_inline_encode_block( lines = [] lines.append(f"auto &sub_msg = {element};") lines.append(f"ProtoEncode::write_raw_byte(pos, {tag});") - lines.append("uint8_t *len_pos = pos++;") + lines.append("uint8_t *len_pos = pos;") + lines.append("ProtoEncode::reserve_byte(pos);") # Generate inline field encoding for each sub-message field for field in sub_desc.field: @@ -1625,7 +1626,13 @@ def _generate_inline_encode_block( encode_line = ti.encode_content # Replace this-> with sub_msg reference for the sub-message fields encode_line = encode_line.replace("this->", "sub_msg.") - lines.extend(encode_line.split("\n")) + # Honor per-field ifdef guards + field_ifdef = ( + get_field_opt(field, pb.field_ifdef, None) + if field.options.HasExtension(pb.field_ifdef) + else None + ) + lines.extend(wrap_with_ifdef(encode_line, field_ifdef)) lines.append("*len_pos = static_cast(pos - len_pos - 1);") return "\n".join(lines) @@ -1659,7 +1666,13 @@ def _generate_inline_size_block( size_line = ti.get_size_calculation(f"sub_msg.{ti.field_name}", force) # Replace hardcoded this-> references (e.g., FixedArrayBytesType uses this->field_len) size_line = size_line.replace("this->", "sub_msg.") - lines.extend(size_line.split("\n")) + # Honor per-field ifdef guards + field_ifdef = ( + get_field_opt(field, pb.field_ifdef, None) + if field.options.HasExtension(pb.field_ifdef) + else None + ) + lines.extend(wrap_with_ifdef(size_line, field_ifdef)) return "\n".join(lines) @@ -2686,16 +2699,21 @@ def build_message_type( # Check if this message uses inline_encode — if so, skip generating standalone # encode/calculate_size methods since the encoding is inlined into the parent. + inline_opt = getattr(pb, "inline_encode", None) is_inline_only = ( message_id is None # Not a service message (no id) - and get_opt(desc, getattr(pb, "inline_encode", None), False) + and inline_opt is not None + and get_opt(desc, inline_opt, False) ) # Only generate encode method if this message needs encoding and has fields if needs_encode and encode and not is_inline_only: # Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls encode_debug = [ - line.replace("(pos,", "(pos PROTO_ENCODE_DEBUG_ARG,") for line in encode + line.replace("(pos,", "(pos PROTO_ENCODE_DEBUG_ARG,").replace( + "(pos)", "(pos PROTO_ENCODE_DEBUG_ARG)" + ) + for line in encode ] o = f"uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" o += " uint8_t *__restrict__ pos = buffer.get_pos();\n" From 382202334a77f3f1ca2db412400c73001a75ecc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 00:15:24 -1000 Subject: [PATCH 0023/2214] Simplify field_ifdef handling in inline blocks --- script/api_protobuf/api_protobuf.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 6dc691249b..02d5b7d68c 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1626,13 +1626,7 @@ def _generate_inline_encode_block( encode_line = ti.encode_content # Replace this-> with sub_msg reference for the sub-message fields encode_line = encode_line.replace("this->", "sub_msg.") - # Honor per-field ifdef guards - field_ifdef = ( - get_field_opt(field, pb.field_ifdef, None) - if field.options.HasExtension(pb.field_ifdef) - else None - ) - lines.extend(wrap_with_ifdef(encode_line, field_ifdef)) + lines.extend(wrap_with_ifdef(encode_line, get_field_opt(field, pb.field_ifdef))) lines.append("*len_pos = static_cast(pos - len_pos - 1);") return "\n".join(lines) @@ -1666,13 +1660,7 @@ def _generate_inline_size_block( size_line = ti.get_size_calculation(f"sub_msg.{ti.field_name}", force) # Replace hardcoded this-> references (e.g., FixedArrayBytesType uses this->field_len) size_line = size_line.replace("this->", "sub_msg.") - # Honor per-field ifdef guards - field_ifdef = ( - get_field_opt(field, pb.field_ifdef, None) - if field.options.HasExtension(pb.field_ifdef) - else None - ) - lines.extend(wrap_with_ifdef(size_line, field_ifdef)) + lines.extend(wrap_with_ifdef(size_line, get_field_opt(field, pb.field_ifdef))) return "\n".join(lines) From 781ab596114af5a44b2053021bf1784e59288ac2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 00:16:07 -1000 Subject: [PATCH 0024/2214] Remove duplicate unrolled inline size cases, always use loop --- script/api_protobuf/api_protobuf.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 02d5b7d68c..39bfc865d0 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1823,21 +1823,6 @@ class FixedArrayRepeatedType(TypeInfo): # For fixed arrays, we always encode all elements if is_inline: - # Special case for single-element arrays - no loop needed - if self.array_size == 1: - return _generate_inline_size_block( - self.number, self._ti.cpp_type, f"{name}[0]" - ) - if self.array_size == 2: - return ( - _generate_inline_size_block( - self.number, self._ti.cpp_type, f"{name}[0]" - ) - + "\n " - + _generate_inline_size_block( - self.number, self._ti.cpp_type, f"{name}[1]" - ) - ) o = f"for (const auto &it : {name}) {{\n" o += indent( _generate_inline_size_block(self.number, self._ti.cpp_type, "it") From c945cbc64c9380ffe0d55dd666bb499ace9b501d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 08:10:39 -1000 Subject: [PATCH 0025/2214] bot comments --- esphome/core/helpers.cpp | 5 +++-- esphome/core/helpers.h | 8 +++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 2faa6adefb..cbe22dd09a 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -352,8 +352,9 @@ std::string format_mac_address_pretty(const uint8_t *mac) { // one is overwritten with '\0', eliminating the per-byte `i < length - 1` check. static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator, char base) { - if (length == 0) { - buffer[0] = '\0'; + if (length == 0 || buffer_size == 0) { + if (buffer_size > 0) + buffer[0] = '\0'; return buffer; } uint8_t stride = separator ? 3 : 2; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 1185db390e..3c42d7df07 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1263,15 +1263,13 @@ constexpr uint8_t parse_hex_char(char c) { } /// Convert a nibble (0-15) to hex char with specified base ('a' for lowercase, 'A' for uppercase) -__attribute__((always_inline)) inline char format_hex_char(uint8_t v, char base) { - return v >= 10 ? base + (v - 10) : '0' + v; -} +ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v, char base) { return v >= 10 ? base + (v - 10) : '0' + v; } /// Convert a nibble (0-15) to lowercase hex char -__attribute__((always_inline)) inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); } +ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); } /// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) -__attribute__((always_inline)) inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); } +ESPHOME_ALWAYS_INLINE inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); } /// Write int8 value to buffer without modulo operations. /// Buffer must have at least 4 bytes free. Returns pointer past last char written. From a941422fa6c2997827dd776f8728f788312466a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 08:36:40 -1000 Subject: [PATCH 0026/2214] Add unit tests for format_hex helpers --- tests/components/core/test_helpers.cpp | 120 +++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/components/core/test_helpers.cpp diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp new file mode 100644 index 0000000000..c6fd070446 --- /dev/null +++ b/tests/components/core/test_helpers.cpp @@ -0,0 +1,120 @@ +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::testing { + +// --- format_hex_to() --- + +TEST(FormatHexTo, Basic) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF}; + char buffer[7]; // 3 * 2 + 1 + format_hex_to(buffer, data, 3); + EXPECT_STREQ(buffer, "abcdef"); +} + +TEST(FormatHexTo, SingleByte) { + const uint8_t data[] = {0x0F}; + char buffer[3]; + format_hex_to(buffer, data, 1); + EXPECT_STREQ(buffer, "0f"); +} + +TEST(FormatHexTo, ZeroLength) { + char buffer[4] = "xxx"; + format_hex_to(buffer, static_cast(sizeof(buffer)), static_cast(nullptr), 0); + EXPECT_STREQ(buffer, ""); +} + +TEST(FormatHexTo, ZeroBufferSize) { + char buffer[4] = "xxx"; + const uint8_t data[] = {0xAB}; + format_hex_to(buffer, static_cast(0), data, 1); + // Should not crash, buffer unchanged + EXPECT_EQ(buffer[0], 'x'); +} + +TEST(FormatHexTo, BufferTooSmall) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF}; + char buffer[5]; // only room for 2 bytes + format_hex_to(buffer, data, 3); + EXPECT_STREQ(buffer, "abcd"); +} + +TEST(FormatHexTo, MacAddress) { + const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + char buffer[13]; + format_hex_to(buffer, mac, 6); + EXPECT_STREQ(buffer, "aabbccddeeff"); +} + +// --- format_hex_pretty_to() --- + +TEST(FormatHexPrettyTo, BasicColon) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF}; + char buffer[9]; // 3 * 3 + format_hex_pretty_to(buffer, data, 3); + EXPECT_STREQ(buffer, "AB:CD:EF"); +} + +TEST(FormatHexPrettyTo, SingleByte) { + const uint8_t data[] = {0x0F}; + char buffer[3]; + format_hex_pretty_to(buffer, data, 1); + EXPECT_STREQ(buffer, "0F"); +} + +TEST(FormatHexPrettyTo, ZeroLength) { + char buffer[4] = "xxx"; + format_hex_pretty_to(buffer, static_cast(sizeof(buffer)), static_cast(nullptr), 0); + EXPECT_STREQ(buffer, ""); +} + +TEST(FormatHexPrettyTo, ZeroBufferSize) { + char buffer[4] = "xxx"; + const uint8_t data[] = {0xAB}; + format_hex_pretty_to(buffer, static_cast(0), data, 1); + EXPECT_EQ(buffer[0], 'x'); +} + +TEST(FormatHexPrettyTo, CustomSeparator) { + const uint8_t data[] = {0xAA, 0xBB, 0xCC}; + char buffer[9]; + format_hex_pretty_to(buffer, data, 3, ':'); + EXPECT_STREQ(buffer, "AA:BB:CC"); +} + +// --- format_mac_addr_upper() --- + +TEST(FormatMacAddrUpper, Basic) { + const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(mac, buffer); + EXPECT_STREQ(buffer, "AA:BB:CC:DD:EE:FF"); +} + +TEST(FormatMacAddrUpper, AllZeros) { + const uint8_t mac[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(mac, buffer); + EXPECT_STREQ(buffer, "00:00:00:00:00:00"); +} + +// --- format_hex_char() --- + +TEST(FormatHexChar, LowercaseDigits) { + EXPECT_EQ(format_hex_char(0), '0'); + EXPECT_EQ(format_hex_char(9), '9'); + EXPECT_EQ(format_hex_char(10), 'a'); + EXPECT_EQ(format_hex_char(15), 'f'); +} + +TEST(FormatHexChar, UppercaseDigits) { + EXPECT_EQ(format_hex_pretty_char(0), '0'); + EXPECT_EQ(format_hex_pretty_char(9), '9'); + EXPECT_EQ(format_hex_pretty_char(10), 'A'); + EXPECT_EQ(format_hex_pretty_char(15), 'F'); +} + +} // namespace esphome::testing From 6fe6bf79d4dc7b0ab7fb56a1fd9524a9ab870454 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 09:30:06 -1000 Subject: [PATCH 0027/2214] Address Copilot review: fix namespace and custom separator test --- tests/components/core/test_helpers.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index c6fd070446..00169621c3 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -3,7 +3,7 @@ #include "esphome/core/helpers.h" -namespace esphome::testing { +namespace esphome::core::testing { // --- format_hex_to() --- @@ -81,8 +81,8 @@ TEST(FormatHexPrettyTo, ZeroBufferSize) { TEST(FormatHexPrettyTo, CustomSeparator) { const uint8_t data[] = {0xAA, 0xBB, 0xCC}; char buffer[9]; - format_hex_pretty_to(buffer, data, 3, ':'); - EXPECT_STREQ(buffer, "AA:BB:CC"); + format_hex_pretty_to(buffer, data, 3, '-'); + EXPECT_STREQ(buffer, "AA-BB-CC"); } // --- format_mac_addr_upper() --- @@ -117,4 +117,4 @@ TEST(FormatHexChar, UppercaseDigits) { EXPECT_EQ(format_hex_pretty_char(15), 'F'); } -} // namespace esphome::testing +} // namespace esphome::core::testing From 7d0cb4afe6f1fcc3a0e0b91894dfa02b0f1cbb74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 14:13:39 -1000 Subject: [PATCH 0028/2214] [core] Deduplicate entity type boilerplate with X-macro pattern Introduce entity_types.inc X-macro and entity_includes.h shared header to eliminate ~1000 lines of repetitive #ifdef/entity-type blocks across core files. entity_types.inc defines each entity type once with two macros: - ENTITY_TYPE_ for non-controller entities (button, infrared) - ENTITY_CONTROLLER_TYPE_ for entities with controller callbacks Each consumer includes the file with appropriate macro definitions. Sites wanting all entities delegate ENTITY_CONTROLLER_TYPE_ to ENTITY_TYPE_. Sites wanting only controller entities define ENTITY_TYPE_ as empty. entity_includes.h consolidates the conditional entity header includes shared by application.h, controller.h, and controller_registry.h. Also makes ComponentIterator::on_media_player pure virtual for consistency (trivial override added to web_server::ListEntitiesIterator). --- esphome/components/web_server/list_entities.h | 3 + esphome/core/application.h | 362 ++---------------- esphome/core/component_iterator.cpp | 152 +------- esphome/core/component_iterator.h | 154 +------- esphome/core/controller.h | 139 +------ esphome/core/controller_registry.h | 324 +--------------- esphome/core/entity_includes.h | 79 ++++ esphome/core/entity_types.inc | 84 ++++ 8 files changed, 248 insertions(+), 1049 deletions(-) create mode 100644 esphome/core/entity_includes.h create mode 100644 esphome/core/entity_types.inc diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index 6a84066109..8c22d757b6 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -75,6 +75,9 @@ class ListEntitiesIterator final : public ComponentIterator { #ifdef USE_VALVE bool on_valve(valve::Valve *obj) override; #endif +#ifdef USE_MEDIA_PLAYER + bool on_media_player(media_player::MediaPlayer *obj) override { return true; } +#endif #ifdef USE_ALARM_CONTROL_PANEL bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) override; #endif diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b490..084cb643ef 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -39,78 +39,7 @@ #include "esphome/components/runtime_stats/runtime_stats.h" #endif #include "esphome/core/wake.h" -#ifdef USE_BINARY_SENSOR -#include "esphome/components/binary_sensor/binary_sensor.h" -#endif -#ifdef USE_SENSOR -#include "esphome/components/sensor/sensor.h" -#endif -#ifdef USE_SWITCH -#include "esphome/components/switch/switch.h" -#endif -#ifdef USE_BUTTON -#include "esphome/components/button/button.h" -#endif -#ifdef USE_TEXT_SENSOR -#include "esphome/components/text_sensor/text_sensor.h" -#endif -#ifdef USE_FAN -#include "esphome/components/fan/fan.h" -#endif -#ifdef USE_CLIMATE -#include "esphome/components/climate/climate.h" -#endif -#ifdef USE_LIGHT -#include "esphome/components/light/light_state.h" -#endif -#ifdef USE_COVER -#include "esphome/components/cover/cover.h" -#endif -#ifdef USE_NUMBER -#include "esphome/components/number/number.h" -#endif -#ifdef USE_DATETIME_DATE -#include "esphome/components/datetime/date_entity.h" -#endif -#ifdef USE_DATETIME_TIME -#include "esphome/components/datetime/time_entity.h" -#endif -#ifdef USE_DATETIME_DATETIME -#include "esphome/components/datetime/datetime_entity.h" -#endif -#ifdef USE_TEXT -#include "esphome/components/text/text.h" -#endif -#ifdef USE_SELECT -#include "esphome/components/select/select.h" -#endif -#ifdef USE_LOCK -#include "esphome/components/lock/lock.h" -#endif -#ifdef USE_VALVE -#include "esphome/components/valve/valve.h" -#endif -#ifdef USE_MEDIA_PLAYER -#include "esphome/components/media_player/media_player.h" -#endif -#ifdef USE_ALARM_CONTROL_PANEL -#include "esphome/components/alarm_control_panel/alarm_control_panel.h" -#endif -#ifdef USE_WATER_HEATER -#include "esphome/components/water_heater/water_heater.h" -#endif -#ifdef USE_INFRARED -#include "esphome/components/infrared/infrared.h" -#endif -#ifdef USE_SERIAL_PROXY -#include "esphome/components/serial_proxy/serial_proxy.h" -#endif -#ifdef USE_EVENT -#include "esphome/components/event/event.h" -#endif -#ifdef USE_UPDATE -#include "esphome/components/update/update_entity.h" -#endif +#include "esphome/core/entity_includes.h" namespace esphome::socket { #ifdef USE_HOST @@ -190,93 +119,16 @@ class Application { void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } -#ifdef USE_BINARY_SENSOR - void register_binary_sensor(binary_sensor::BinarySensor *binary_sensor) { - this->binary_sensors_.push_back(binary_sensor); - } -#endif - -#ifdef USE_SENSOR - void register_sensor(sensor::Sensor *sensor) { this->sensors_.push_back(sensor); } -#endif - -#ifdef USE_SWITCH - void register_switch(switch_::Switch *a_switch) { this->switches_.push_back(a_switch); } -#endif - -#ifdef USE_BUTTON - void register_button(button::Button *button) { this->buttons_.push_back(button); } -#endif - -#ifdef USE_TEXT_SENSOR - void register_text_sensor(text_sensor::TextSensor *sensor) { this->text_sensors_.push_back(sensor); } -#endif - -#ifdef USE_FAN - void register_fan(fan::Fan *state) { this->fans_.push_back(state); } -#endif - -#ifdef USE_COVER - void register_cover(cover::Cover *cover) { this->covers_.push_back(cover); } -#endif - -#ifdef USE_CLIMATE - void register_climate(climate::Climate *climate) { this->climates_.push_back(climate); } -#endif - -#ifdef USE_LIGHT - void register_light(light::LightState *light) { this->lights_.push_back(light); } -#endif - -#ifdef USE_NUMBER - void register_number(number::Number *number) { this->numbers_.push_back(number); } -#endif - -#ifdef USE_DATETIME_DATE - void register_date(datetime::DateEntity *date) { this->dates_.push_back(date); } -#endif - -#ifdef USE_DATETIME_TIME - void register_time(datetime::TimeEntity *time) { this->times_.push_back(time); } -#endif - -#ifdef USE_DATETIME_DATETIME - void register_datetime(datetime::DateTimeEntity *datetime) { this->datetimes_.push_back(datetime); } -#endif - -#ifdef USE_TEXT - void register_text(text::Text *text) { this->texts_.push_back(text); } -#endif - -#ifdef USE_SELECT - void register_select(select::Select *select) { this->selects_.push_back(select); } -#endif - -#ifdef USE_LOCK - void register_lock(lock::Lock *a_lock) { this->locks_.push_back(a_lock); } -#endif - -#ifdef USE_VALVE - void register_valve(valve::Valve *valve) { this->valves_.push_back(valve); } -#endif - -#ifdef USE_MEDIA_PLAYER - void register_media_player(media_player::MediaPlayer *media_player) { this->media_players_.push_back(media_player); } -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - void register_alarm_control_panel(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) { - this->alarm_control_panels_.push_back(a_alarm_control_panel); - } -#endif - -#ifdef USE_WATER_HEATER - void register_water_heater(water_heater::WaterHeater *water_heater) { this->water_heaters_.push_back(water_heater); } -#endif - -#ifdef USE_INFRARED - void register_infrared(infrared::Infrared *infrared) { this->infrareds_.push_back(infrared); } -#endif +// Entity register methods (generated from entity_types.inc) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + void register_##singular(type *obj) { this->plural##_.push_back(obj); } +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.inc" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_SERIAL_PROXY void register_serial_proxy(serial_proxy::SerialProxy *proxy) { @@ -285,14 +137,6 @@ class Application { } #endif -#ifdef USE_EVENT - void register_event(event::Event *event) { this->events_.push_back(event); } -#endif - -#ifdef USE_UPDATE - void register_update(update::UpdateEntity *update) { this->updates_.push_back(update); } -#endif - /// Reserve space for components to avoid memory fragmentation /// Set up all the registered components. Call this at the end of your setup() function. @@ -428,108 +272,20 @@ class Application { #ifdef USE_AREAS const auto &get_areas() { return this->areas_; } #endif -#ifdef USE_BINARY_SENSOR - auto &get_binary_sensors() const { return this->binary_sensors_; } - GET_ENTITY_METHOD(binary_sensor::BinarySensor, binary_sensor, binary_sensors) -#endif -#ifdef USE_SWITCH - auto &get_switches() const { return this->switches_; } - GET_ENTITY_METHOD(switch_::Switch, switch, switches) -#endif -#ifdef USE_BUTTON - auto &get_buttons() const { return this->buttons_; } - GET_ENTITY_METHOD(button::Button, button, buttons) -#endif -#ifdef USE_SENSOR - auto &get_sensors() const { return this->sensors_; } - GET_ENTITY_METHOD(sensor::Sensor, sensor, sensors) -#endif -#ifdef USE_TEXT_SENSOR - auto &get_text_sensors() const { return this->text_sensors_; } - GET_ENTITY_METHOD(text_sensor::TextSensor, text_sensor, text_sensors) -#endif -#ifdef USE_FAN - auto &get_fans() const { return this->fans_; } - GET_ENTITY_METHOD(fan::Fan, fan, fans) -#endif -#ifdef USE_COVER - auto &get_covers() const { return this->covers_; } - GET_ENTITY_METHOD(cover::Cover, cover, covers) -#endif -#ifdef USE_LIGHT - auto &get_lights() const { return this->lights_; } - GET_ENTITY_METHOD(light::LightState, light, lights) -#endif -#ifdef USE_CLIMATE - auto &get_climates() const { return this->climates_; } - GET_ENTITY_METHOD(climate::Climate, climate, climates) -#endif -#ifdef USE_NUMBER - auto &get_numbers() const { return this->numbers_; } - GET_ENTITY_METHOD(number::Number, number, numbers) -#endif -#ifdef USE_DATETIME_DATE - auto &get_dates() const { return this->dates_; } - GET_ENTITY_METHOD(datetime::DateEntity, date, dates) -#endif -#ifdef USE_DATETIME_TIME - auto &get_times() const { return this->times_; } - GET_ENTITY_METHOD(datetime::TimeEntity, time, times) -#endif -#ifdef USE_DATETIME_DATETIME - auto &get_datetimes() const { return this->datetimes_; } - GET_ENTITY_METHOD(datetime::DateTimeEntity, datetime, datetimes) -#endif -#ifdef USE_TEXT - auto &get_texts() const { return this->texts_; } - GET_ENTITY_METHOD(text::Text, text, texts) -#endif -#ifdef USE_SELECT - auto &get_selects() const { return this->selects_; } - GET_ENTITY_METHOD(select::Select, select, selects) -#endif -#ifdef USE_LOCK - auto &get_locks() const { return this->locks_; } - GET_ENTITY_METHOD(lock::Lock, lock, locks) -#endif -#ifdef USE_VALVE - auto &get_valves() const { return this->valves_; } - GET_ENTITY_METHOD(valve::Valve, valve, valves) -#endif -#ifdef USE_MEDIA_PLAYER - auto &get_media_players() const { return this->media_players_; } - GET_ENTITY_METHOD(media_player::MediaPlayer, media_player, media_players) -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - auto &get_alarm_control_panels() const { return this->alarm_control_panels_; } - GET_ENTITY_METHOD(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels) -#endif - -#ifdef USE_WATER_HEATER - auto &get_water_heaters() const { return this->water_heaters_; } - GET_ENTITY_METHOD(water_heater::WaterHeater, water_heater, water_heaters) -#endif - -#ifdef USE_INFRARED - auto &get_infrareds() const { return this->infrareds_; } - GET_ENTITY_METHOD(infrared::Infrared, infrared, infrareds) -#endif +// Entity getter methods (generated from entity_types.inc) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + auto &get_##plural() const { return this->plural##_; } \ + GET_ENTITY_METHOD(type, singular, plural) +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.inc" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ #ifdef USE_SERIAL_PROXY auto &get_serial_proxies() const { return this->serial_proxies_; } #endif -#ifdef USE_EVENT - auto &get_events() const { return this->events_; } - GET_ENTITY_METHOD(event::Event, event, events) -#endif - -#ifdef USE_UPDATE - auto &get_updates() const { return this->updates_; } - GET_ENTITY_METHOD(update::UpdateEntity, update, updates) -#endif - Scheduler scheduler; /// Register/unregister a socket to be monitored for read events. @@ -705,79 +461,17 @@ class Application { #ifdef USE_AREAS StaticVector areas_{}; #endif -#ifdef USE_BINARY_SENSOR - StaticVector binary_sensors_{}; -#endif -#ifdef USE_SWITCH - StaticVector switches_{}; -#endif -#ifdef USE_BUTTON - StaticVector buttons_{}; -#endif -#ifdef USE_EVENT - StaticVector events_{}; -#endif -#ifdef USE_SENSOR - StaticVector sensors_{}; -#endif -#ifdef USE_TEXT_SENSOR - StaticVector text_sensors_{}; -#endif -#ifdef USE_FAN - StaticVector fans_{}; -#endif -#ifdef USE_COVER - StaticVector covers_{}; -#endif -#ifdef USE_CLIMATE - StaticVector climates_{}; -#endif -#ifdef USE_LIGHT - StaticVector lights_{}; -#endif -#ifdef USE_NUMBER - StaticVector numbers_{}; -#endif -#ifdef USE_DATETIME_DATE - StaticVector dates_{}; -#endif -#ifdef USE_DATETIME_TIME - StaticVector times_{}; -#endif -#ifdef USE_DATETIME_DATETIME - StaticVector datetimes_{}; -#endif -#ifdef USE_SELECT - StaticVector selects_{}; -#endif -#ifdef USE_TEXT - StaticVector texts_{}; -#endif -#ifdef USE_LOCK - StaticVector locks_{}; -#endif -#ifdef USE_VALVE - StaticVector valves_{}; -#endif -#ifdef USE_MEDIA_PLAYER - StaticVector media_players_{}; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - StaticVector - alarm_control_panels_{}; -#endif -#ifdef USE_WATER_HEATER - StaticVector water_heaters_{}; -#endif -#ifdef USE_INFRARED - StaticVector infrareds_{}; -#endif +// Entity StaticVector fields (generated from entity_types.inc) +#define ENTITY_TYPE_(type, singular, plural, count, upper) StaticVector plural##_{}; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.inc" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + #ifdef USE_SERIAL_PROXY StaticVector serial_proxies_{}; #endif -#ifdef USE_UPDATE - StaticVector updates_{}; -#endif }; /// Global storage of Application pointer - only one Application can exist. diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index ff76b2b81b..9b5b8d1512 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -33,53 +33,18 @@ void ComponentIterator::advance() { } break; -#ifdef USE_BINARY_SENSOR - case IteratorState::BINARY_SENSOR: - this->process_platform_item_(App.get_binary_sensors(), &ComponentIterator::on_binary_sensor); - break; -#endif - -#ifdef USE_COVER - case IteratorState::COVER: - this->process_platform_item_(App.get_covers(), &ComponentIterator::on_cover); - break; -#endif - -#ifdef USE_FAN - case IteratorState::FAN: - this->process_platform_item_(App.get_fans(), &ComponentIterator::on_fan); - break; -#endif - -#ifdef USE_LIGHT - case IteratorState::LIGHT: - this->process_platform_item_(App.get_lights(), &ComponentIterator::on_light); - break; -#endif - -#ifdef USE_SENSOR - case IteratorState::SENSOR: - this->process_platform_item_(App.get_sensors(), &ComponentIterator::on_sensor); - break; -#endif - -#ifdef USE_SWITCH - case IteratorState::SWITCH: - this->process_platform_item_(App.get_switches(), &ComponentIterator::on_switch); - break; -#endif - -#ifdef USE_BUTTON - case IteratorState::BUTTON: - this->process_platform_item_(App.get_buttons(), &ComponentIterator::on_button); - break; -#endif - -#ifdef USE_TEXT_SENSOR - case IteratorState::TEXT_SENSOR: - this->process_platform_item_(App.get_text_sensors(), &ComponentIterator::on_text_sensor); - break; -#endif +// Entity iterator cases (generated from entity_types.inc) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + case IteratorState::upper: \ + this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular); \ + break; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.inc" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_API_USER_DEFINED_ACTIONS case IteratorState::SERVICE: @@ -97,96 +62,6 @@ void ComponentIterator::advance() { } break; #endif -#ifdef USE_CLIMATE - case IteratorState::CLIMATE: - this->process_platform_item_(App.get_climates(), &ComponentIterator::on_climate); - break; -#endif - -#ifdef USE_NUMBER - case IteratorState::NUMBER: - this->process_platform_item_(App.get_numbers(), &ComponentIterator::on_number); - break; -#endif - -#ifdef USE_DATETIME_DATE - case IteratorState::DATETIME_DATE: - this->process_platform_item_(App.get_dates(), &ComponentIterator::on_date); - break; -#endif - -#ifdef USE_DATETIME_TIME - case IteratorState::DATETIME_TIME: - this->process_platform_item_(App.get_times(), &ComponentIterator::on_time); - break; -#endif - -#ifdef USE_DATETIME_DATETIME - case IteratorState::DATETIME_DATETIME: - this->process_platform_item_(App.get_datetimes(), &ComponentIterator::on_datetime); - break; -#endif - -#ifdef USE_TEXT - case IteratorState::TEXT: - this->process_platform_item_(App.get_texts(), &ComponentIterator::on_text); - break; -#endif - -#ifdef USE_SELECT - case IteratorState::SELECT: - this->process_platform_item_(App.get_selects(), &ComponentIterator::on_select); - break; -#endif - -#ifdef USE_LOCK - case IteratorState::LOCK: - this->process_platform_item_(App.get_locks(), &ComponentIterator::on_lock); - break; -#endif - -#ifdef USE_VALVE - case IteratorState::VALVE: - this->process_platform_item_(App.get_valves(), &ComponentIterator::on_valve); - break; -#endif - -#ifdef USE_MEDIA_PLAYER - case IteratorState::MEDIA_PLAYER: - this->process_platform_item_(App.get_media_players(), &ComponentIterator::on_media_player); - break; -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - case IteratorState::ALARM_CONTROL_PANEL: - this->process_platform_item_(App.get_alarm_control_panels(), &ComponentIterator::on_alarm_control_panel); - break; -#endif - -#ifdef USE_WATER_HEATER - case IteratorState::WATER_HEATER: - this->process_platform_item_(App.get_water_heaters(), &ComponentIterator::on_water_heater); - break; -#endif - -#ifdef USE_INFRARED - case IteratorState::INFRARED: - this->process_platform_item_(App.get_infrareds(), &ComponentIterator::on_infrared); - break; -#endif - -#ifdef USE_EVENT - case IteratorState::EVENT: - this->process_platform_item_(App.get_events(), &ComponentIterator::on_event); - break; -#endif - -#ifdef USE_UPDATE - case IteratorState::UPDATE: - this->process_platform_item_(App.get_updates(), &ComponentIterator::on_update); - break; -#endif - case IteratorState::MAX: if (this->on_end()) { this->state_ = IteratorState::NONE; @@ -203,7 +78,4 @@ bool ComponentIterator::on_service(api::UserServiceDescriptor *service) { return #ifdef USE_CAMERA bool ComponentIterator::on_camera(camera::Camera *camera) { return true; } #endif -#ifdef USE_MEDIA_PLAYER -bool ComponentIterator::on_media_player(media_player::MediaPlayer *media_player) { return true; } -#endif } // namespace esphome diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 6c03b74a17..f855ab160f 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -28,80 +28,21 @@ class ComponentIterator { void advance(); bool completed() const { return this->state_ == IteratorState::NONE; } virtual bool on_begin(); -#ifdef USE_BINARY_SENSOR - virtual bool on_binary_sensor(binary_sensor::BinarySensor *binary_sensor) = 0; -#endif -#ifdef USE_COVER - virtual bool on_cover(cover::Cover *cover) = 0; -#endif -#ifdef USE_FAN - virtual bool on_fan(fan::Fan *fan) = 0; -#endif -#ifdef USE_LIGHT - virtual bool on_light(light::LightState *light) = 0; -#endif -#ifdef USE_SENSOR - virtual bool on_sensor(sensor::Sensor *sensor) = 0; -#endif -#ifdef USE_SWITCH - virtual bool on_switch(switch_::Switch *a_switch) = 0; -#endif -#ifdef USE_BUTTON - virtual bool on_button(button::Button *button) = 0; -#endif -#ifdef USE_TEXT_SENSOR - virtual bool on_text_sensor(text_sensor::TextSensor *text_sensor) = 0; -#endif +// Pure virtual entity callbacks (generated from entity_types.inc) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) virtual bool on_##singular(type *obj) = 0; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.inc" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ +// NOLINTEND(bugprone-macro-parentheses) +// Non-entity and non-pure-virtual callbacks (have default implementations) #ifdef USE_API_USER_DEFINED_ACTIONS virtual bool on_service(api::UserServiceDescriptor *service); #endif #ifdef USE_CAMERA virtual bool on_camera(camera::Camera *camera); -#endif -#ifdef USE_CLIMATE - virtual bool on_climate(climate::Climate *climate) = 0; -#endif -#ifdef USE_NUMBER - virtual bool on_number(number::Number *number) = 0; -#endif -#ifdef USE_DATETIME_DATE - virtual bool on_date(datetime::DateEntity *date) = 0; -#endif -#ifdef USE_DATETIME_TIME - virtual bool on_time(datetime::TimeEntity *time) = 0; -#endif -#ifdef USE_DATETIME_DATETIME - virtual bool on_datetime(datetime::DateTimeEntity *datetime) = 0; -#endif -#ifdef USE_TEXT - virtual bool on_text(text::Text *text) = 0; -#endif -#ifdef USE_SELECT - virtual bool on_select(select::Select *select) = 0; -#endif -#ifdef USE_LOCK - virtual bool on_lock(lock::Lock *a_lock) = 0; -#endif -#ifdef USE_VALVE - virtual bool on_valve(valve::Valve *valve) = 0; -#endif -#ifdef USE_MEDIA_PLAYER - virtual bool on_media_player(media_player::MediaPlayer *media_player); -#endif -#ifdef USE_ALARM_CONTROL_PANEL - virtual bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) = 0; -#endif -#ifdef USE_WATER_HEATER - virtual bool on_water_heater(water_heater::WaterHeater *water_heater) = 0; -#endif -#ifdef USE_INFRARED - virtual bool on_infrared(infrared::Infrared *infrared) = 0; -#endif -#ifdef USE_EVENT - virtual bool on_event(event::Event *event) = 0; -#endif -#ifdef USE_UPDATE - virtual bool on_update(update::UpdateEntity *update) = 0; #endif virtual bool on_end(); @@ -111,80 +52,17 @@ class ComponentIterator { enum class IteratorState : uint8_t { NONE = 0, BEGIN, -#ifdef USE_BINARY_SENSOR - BINARY_SENSOR, -#endif -#ifdef USE_COVER - COVER, -#endif -#ifdef USE_FAN - FAN, -#endif -#ifdef USE_LIGHT - LIGHT, -#endif -#ifdef USE_SENSOR - SENSOR, -#endif -#ifdef USE_SWITCH - SWITCH, -#endif -#ifdef USE_BUTTON - BUTTON, -#endif -#ifdef USE_TEXT_SENSOR - TEXT_SENSOR, -#endif +// Entity iterator states (generated from entity_types.inc) +#define ENTITY_TYPE_(type, singular, plural, count, upper) upper, +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) upper, +#include "esphome/core/entity_types.inc" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ #ifdef USE_API_USER_DEFINED_ACTIONS SERVICE, #endif #ifdef USE_CAMERA CAMERA, -#endif -#ifdef USE_CLIMATE - CLIMATE, -#endif -#ifdef USE_NUMBER - NUMBER, -#endif -#ifdef USE_DATETIME_DATE - DATETIME_DATE, -#endif -#ifdef USE_DATETIME_TIME - DATETIME_TIME, -#endif -#ifdef USE_DATETIME_DATETIME - DATETIME_DATETIME, -#endif -#ifdef USE_TEXT - TEXT, -#endif -#ifdef USE_SELECT - SELECT, -#endif -#ifdef USE_LOCK - LOCK, -#endif -#ifdef USE_VALVE - VALVE, -#endif -#ifdef USE_MEDIA_PLAYER - MEDIA_PLAYER, -#endif -#ifdef USE_ALARM_CONTROL_PANEL - ALARM_CONTROL_PANEL, -#endif -#ifdef USE_WATER_HEATER - WATER_HEATER, -#endif -#ifdef USE_INFRARED - INFRARED, -#endif -#ifdef USE_EVENT - EVENT, -#endif -#ifdef USE_UPDATE - UPDATE, #endif MAX, }; diff --git a/esphome/core/controller.h b/esphome/core/controller.h index 632b46c893..4f3c8d3352 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -1,140 +1,19 @@ #pragma once -#include "esphome/core/defines.h" -#ifdef USE_BINARY_SENSOR -#include "esphome/components/binary_sensor/binary_sensor.h" -#endif -#ifdef USE_FAN -#include "esphome/components/fan/fan.h" -#endif -#ifdef USE_LIGHT -#include "esphome/components/light/light_state.h" -#endif -#ifdef USE_COVER -#include "esphome/components/cover/cover.h" -#endif -#ifdef USE_SENSOR -#include "esphome/components/sensor/sensor.h" -#endif -#ifdef USE_TEXT_SENSOR -#include "esphome/components/text_sensor/text_sensor.h" -#endif -#ifdef USE_SWITCH -#include "esphome/components/switch/switch.h" -#endif -#ifdef USE_BUTTON -#include "esphome/components/button/button.h" -#endif -#ifdef USE_CLIMATE -#include "esphome/components/climate/climate.h" -#endif -#ifdef USE_NUMBER -#include "esphome/components/number/number.h" -#endif -#ifdef USE_DATETIME_DATE -#include "esphome/components/datetime/date_entity.h" -#endif -#ifdef USE_DATETIME_TIME -#include "esphome/components/datetime/time_entity.h" -#endif -#ifdef USE_DATETIME_DATETIME -#include "esphome/components/datetime/datetime_entity.h" -#endif -#ifdef USE_TEXT -#include "esphome/components/text/text.h" -#endif -#ifdef USE_SELECT -#include "esphome/components/select/select.h" -#endif -#ifdef USE_LOCK -#include "esphome/components/lock/lock.h" -#endif -#ifdef USE_VALVE -#include "esphome/components/valve/valve.h" -#endif -#ifdef USE_MEDIA_PLAYER -#include "esphome/components/media_player/media_player.h" -#endif -#ifdef USE_ALARM_CONTROL_PANEL -#include "esphome/components/alarm_control_panel/alarm_control_panel.h" -#endif -#ifdef USE_WATER_HEATER -#include "esphome/components/water_heater/water_heater.h" -#endif -#ifdef USE_EVENT -#include "esphome/components/event/event.h" -#endif -#ifdef USE_UPDATE -#include "esphome/components/update/update_entity.h" -#endif +#include "esphome/core/entity_includes.h" namespace esphome { class Controller { public: -#ifdef USE_BINARY_SENSOR - virtual void on_binary_sensor_update(binary_sensor::BinarySensor *obj){}; -#endif -#ifdef USE_FAN - virtual void on_fan_update(fan::Fan *obj){}; -#endif -#ifdef USE_LIGHT - virtual void on_light_update(light::LightState *obj){}; -#endif -#ifdef USE_SENSOR - virtual void on_sensor_update(sensor::Sensor *obj){}; -#endif -#ifdef USE_SWITCH - virtual void on_switch_update(switch_::Switch *obj){}; -#endif -#ifdef USE_COVER - virtual void on_cover_update(cover::Cover *obj){}; -#endif -#ifdef USE_TEXT_SENSOR - virtual void on_text_sensor_update(text_sensor::TextSensor *obj){}; -#endif -#ifdef USE_CLIMATE - virtual void on_climate_update(climate::Climate *obj){}; -#endif -#ifdef USE_NUMBER - virtual void on_number_update(number::Number *obj){}; -#endif -#ifdef USE_DATETIME_DATE - virtual void on_date_update(datetime::DateEntity *obj){}; -#endif -#ifdef USE_DATETIME_TIME - virtual void on_time_update(datetime::TimeEntity *obj){}; -#endif -#ifdef USE_DATETIME_DATETIME - virtual void on_datetime_update(datetime::DateTimeEntity *obj){}; -#endif -#ifdef USE_TEXT - virtual void on_text_update(text::Text *obj){}; -#endif -#ifdef USE_SELECT - virtual void on_select_update(select::Select *obj){}; -#endif -#ifdef USE_LOCK - virtual void on_lock_update(lock::Lock *obj){}; -#endif -#ifdef USE_VALVE - virtual void on_valve_update(valve::Valve *obj){}; -#endif -#ifdef USE_MEDIA_PLAYER - virtual void on_media_player_update(media_player::MediaPlayer *obj){}; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - virtual void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj){}; -#endif -#ifdef USE_WATER_HEATER - virtual void on_water_heater_update(water_heater::WaterHeater *obj){}; -#endif -#ifdef USE_EVENT - virtual void on_event(event::Event *obj){}; -#endif -#ifdef USE_UPDATE - virtual void on_update(update::UpdateEntity *obj){}; -#endif +// Controller virtual methods (generated from entity_types.inc) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) virtual void on_##callback(type *obj){}; +#include "esphome/core/entity_types.inc" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) }; } // namespace esphome diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index 846642da29..3c7b2b83a8 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -4,139 +4,13 @@ #ifdef USE_CONTROLLER_REGISTRY +#include "esphome/core/entity_includes.h" #include "esphome/core/helpers.h" -// Forward declarations namespace esphome { class Controller; -#ifdef USE_BINARY_SENSOR -namespace binary_sensor { -class BinarySensor; -} -#endif - -#ifdef USE_FAN -namespace fan { -class Fan; -} -#endif - -#ifdef USE_LIGHT -namespace light { -class LightState; -} -#endif - -#ifdef USE_SENSOR -namespace sensor { -class Sensor; -} -#endif - -#ifdef USE_SWITCH -namespace switch_ { -class Switch; -} -#endif - -#ifdef USE_COVER -namespace cover { -class Cover; -} -#endif - -#ifdef USE_TEXT_SENSOR -namespace text_sensor { -class TextSensor; -} -#endif - -#ifdef USE_CLIMATE -namespace climate { -class Climate; -} -#endif - -#ifdef USE_NUMBER -namespace number { -class Number; -} -#endif - -#ifdef USE_DATETIME_DATE -namespace datetime { -class DateEntity; -} -#endif - -#ifdef USE_DATETIME_TIME -namespace datetime { -class TimeEntity; -} -#endif - -#ifdef USE_DATETIME_DATETIME -namespace datetime { -class DateTimeEntity; -} -#endif - -#ifdef USE_TEXT -namespace text { -class Text; -} -#endif - -#ifdef USE_SELECT -namespace select { -class Select; -} -#endif - -#ifdef USE_LOCK -namespace lock { -class Lock; -} -#endif - -#ifdef USE_VALVE -namespace valve { -class Valve; -} -#endif - -#ifdef USE_MEDIA_PLAYER -namespace media_player { -class MediaPlayer; -} -#endif - -#ifdef USE_ALARM_CONTROL_PANEL -namespace alarm_control_panel { -class AlarmControlPanel; -} -#endif - -#ifdef USE_WATER_HEATER -namespace water_heater { -class WaterHeater; -} -#endif - -#ifdef USE_EVENT -namespace event { -class Event; -} -#endif - -#ifdef USE_UPDATE -namespace update { -class UpdateEntity; -} -#endif - /** Global registry for Controllers to receive entity state updates. * * This singleton registry allows Controllers (APIServer, WebServer) to receive @@ -162,89 +36,15 @@ class ControllerRegistry { */ static void register_controller(Controller *controller); -#ifdef USE_BINARY_SENSOR - static void notify_binary_sensor_update(binary_sensor::BinarySensor *obj); -#endif - -#ifdef USE_FAN - static void notify_fan_update(fan::Fan *obj); -#endif - -#ifdef USE_LIGHT - static void notify_light_update(light::LightState *obj); -#endif - -#ifdef USE_SENSOR - static void notify_sensor_update(sensor::Sensor *obj); -#endif - -#ifdef USE_SWITCH - static void notify_switch_update(switch_::Switch *obj); -#endif - -#ifdef USE_COVER - static void notify_cover_update(cover::Cover *obj); -#endif - -#ifdef USE_TEXT_SENSOR - static void notify_text_sensor_update(text_sensor::TextSensor *obj); -#endif - -#ifdef USE_CLIMATE - static void notify_climate_update(climate::Climate *obj); -#endif - -#ifdef USE_NUMBER - static void notify_number_update(number::Number *obj); -#endif - -#ifdef USE_DATETIME_DATE - static void notify_date_update(datetime::DateEntity *obj); -#endif - -#ifdef USE_DATETIME_TIME - static void notify_time_update(datetime::TimeEntity *obj); -#endif - -#ifdef USE_DATETIME_DATETIME - static void notify_datetime_update(datetime::DateTimeEntity *obj); -#endif - -#ifdef USE_TEXT - static void notify_text_update(text::Text *obj); -#endif - -#ifdef USE_SELECT - static void notify_select_update(select::Select *obj); -#endif - -#ifdef USE_LOCK - static void notify_lock_update(lock::Lock *obj); -#endif - -#ifdef USE_VALVE - static void notify_valve_update(valve::Valve *obj); -#endif - -#ifdef USE_MEDIA_PLAYER - static void notify_media_player_update(media_player::MediaPlayer *obj); -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - static void notify_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); -#endif - -#ifdef USE_WATER_HEATER - static void notify_water_heater_update(water_heater::WaterHeater *obj); -#endif - -#ifdef USE_EVENT - static void notify_event(event::Event *obj); -#endif - -#ifdef USE_UPDATE - static void notify_update(update::UpdateEntity *obj); -#endif +// Notify method declarations (generated from entity_types.inc) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + static void notify_##callback(type *obj); +#include "esphome/core/entity_types.inc" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) protected: static StaticVector controllers; @@ -265,108 +65,18 @@ namespace esphome { // notify_frontend_(), eliminating an unnecessary function-call frame. // NOLINTBEGIN(bugprone-macro-parentheses) -#define CONTROLLER_REGISTRY_NOTIFY(entity_type, entity_name) \ - inline void ControllerRegistry::notify_##entity_name##_update(entity_type *obj) { \ +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + inline void ControllerRegistry::notify_##callback(type *obj) { \ for (auto *controller : controllers) { \ - controller->on_##entity_name##_update(obj); \ - } \ - } - -#define CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(entity_type, entity_name) \ - inline void ControllerRegistry::notify_##entity_name(entity_type *obj) { \ - for (auto *controller : controllers) { \ - controller->on_##entity_name(obj); \ + controller->on_##callback(obj); \ } \ } +#include "esphome/core/entity_types.inc" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) -#ifdef USE_BINARY_SENSOR -CONTROLLER_REGISTRY_NOTIFY(binary_sensor::BinarySensor, binary_sensor) -#endif - -#ifdef USE_FAN -CONTROLLER_REGISTRY_NOTIFY(fan::Fan, fan) -#endif - -#ifdef USE_LIGHT -CONTROLLER_REGISTRY_NOTIFY(light::LightState, light) -#endif - -#ifdef USE_SENSOR -CONTROLLER_REGISTRY_NOTIFY(sensor::Sensor, sensor) -#endif - -#ifdef USE_SWITCH -CONTROLLER_REGISTRY_NOTIFY(switch_::Switch, switch) -#endif - -#ifdef USE_COVER -CONTROLLER_REGISTRY_NOTIFY(cover::Cover, cover) -#endif - -#ifdef USE_TEXT_SENSOR -CONTROLLER_REGISTRY_NOTIFY(text_sensor::TextSensor, text_sensor) -#endif - -#ifdef USE_CLIMATE -CONTROLLER_REGISTRY_NOTIFY(climate::Climate, climate) -#endif - -#ifdef USE_NUMBER -CONTROLLER_REGISTRY_NOTIFY(number::Number, number) -#endif - -#ifdef USE_DATETIME_DATE -CONTROLLER_REGISTRY_NOTIFY(datetime::DateEntity, date) -#endif - -#ifdef USE_DATETIME_TIME -CONTROLLER_REGISTRY_NOTIFY(datetime::TimeEntity, time) -#endif - -#ifdef USE_DATETIME_DATETIME -CONTROLLER_REGISTRY_NOTIFY(datetime::DateTimeEntity, datetime) -#endif - -#ifdef USE_TEXT -CONTROLLER_REGISTRY_NOTIFY(text::Text, text) -#endif - -#ifdef USE_SELECT -CONTROLLER_REGISTRY_NOTIFY(select::Select, select) -#endif - -#ifdef USE_LOCK -CONTROLLER_REGISTRY_NOTIFY(lock::Lock, lock) -#endif - -#ifdef USE_VALVE -CONTROLLER_REGISTRY_NOTIFY(valve::Valve, valve) -#endif - -#ifdef USE_MEDIA_PLAYER -CONTROLLER_REGISTRY_NOTIFY(media_player::MediaPlayer, media_player) -#endif - -#ifdef USE_ALARM_CONTROL_PANEL -CONTROLLER_REGISTRY_NOTIFY(alarm_control_panel::AlarmControlPanel, alarm_control_panel) -#endif - -#ifdef USE_WATER_HEATER -CONTROLLER_REGISTRY_NOTIFY(water_heater::WaterHeater, water_heater) -#endif - -#ifdef USE_EVENT -CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(event::Event, event) -#endif - -#ifdef USE_UPDATE -CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(update::UpdateEntity, update) -#endif - -#undef CONTROLLER_REGISTRY_NOTIFY -#undef CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX - } // namespace esphome #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/entity_includes.h b/esphome/core/entity_includes.h new file mode 100644 index 0000000000..f67887b30b --- /dev/null +++ b/esphome/core/entity_includes.h @@ -0,0 +1,79 @@ +#pragma once + +// Shared entity component includes. +// Conditionally includes headers for all entity types based on USE_* defines. + +#include "esphome/core/defines.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_COVER +#include "esphome/components/cover/cover.h" +#endif +#ifdef USE_FAN +#include "esphome/components/fan/fan.h" +#endif +#ifdef USE_LIGHT +#include "esphome/components/light/light_state.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif +#ifdef USE_BUTTON +#include "esphome/components/button/button.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_CLIMATE +#include "esphome/components/climate/climate.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_DATETIME_DATE +#include "esphome/components/datetime/date_entity.h" +#endif +#ifdef USE_DATETIME_TIME +#include "esphome/components/datetime/time_entity.h" +#endif +#ifdef USE_DATETIME_DATETIME +#include "esphome/components/datetime/datetime_entity.h" +#endif +#ifdef USE_TEXT +#include "esphome/components/text/text.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#ifdef USE_LOCK +#include "esphome/components/lock/lock.h" +#endif +#ifdef USE_VALVE +#include "esphome/components/valve/valve.h" +#endif +#ifdef USE_MEDIA_PLAYER +#include "esphome/components/media_player/media_player.h" +#endif +#ifdef USE_ALARM_CONTROL_PANEL +#include "esphome/components/alarm_control_panel/alarm_control_panel.h" +#endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif +#ifdef USE_INFRARED +#include "esphome/components/infrared/infrared.h" +#endif +#ifdef USE_SERIAL_PROXY +#include "esphome/components/serial_proxy/serial_proxy.h" +#endif +#ifdef USE_EVENT +#include "esphome/components/event/event.h" +#endif +#ifdef USE_UPDATE +#include "esphome/components/update/update_entity.h" +#endif diff --git a/esphome/core/entity_types.inc b/esphome/core/entity_types.inc new file mode 100644 index 0000000000..ff8b1eb065 --- /dev/null +++ b/esphome/core/entity_types.inc @@ -0,0 +1,84 @@ +// X-macro include file for entity type declarations. +// This file is included multiple times with different macro definitions. +// +// Both macros must be defined before including this file: +// +// ENTITY_TYPE_(type, singular, plural, count, upper) +// — entities without controller callbacks (button, infrared) +// +// ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) +// — entities with controller callbacks +// +// Excluded from this list (handled manually): +// - devices/areas: not entities +// - serial_proxy: custom register logic, no by-key lookup + +#ifdef USE_BINARY_SENSOR +ENTITY_CONTROLLER_TYPE_(binary_sensor::BinarySensor, binary_sensor, binary_sensors, ESPHOME_ENTITY_BINARY_SENSOR_COUNT, BINARY_SENSOR, binary_sensor_update) +#endif +#ifdef USE_COVER +ENTITY_CONTROLLER_TYPE_(cover::Cover, cover, covers, ESPHOME_ENTITY_COVER_COUNT, COVER, cover_update) +#endif +#ifdef USE_FAN +ENTITY_CONTROLLER_TYPE_(fan::Fan, fan, fans, ESPHOME_ENTITY_FAN_COUNT, FAN, fan_update) +#endif +#ifdef USE_LIGHT +ENTITY_CONTROLLER_TYPE_(light::LightState, light, lights, ESPHOME_ENTITY_LIGHT_COUNT, LIGHT, light_update) +#endif +#ifdef USE_SENSOR +ENTITY_CONTROLLER_TYPE_(sensor::Sensor, sensor, sensors, ESPHOME_ENTITY_SENSOR_COUNT, SENSOR, sensor_update) +#endif +#ifdef USE_SWITCH +ENTITY_CONTROLLER_TYPE_(switch_::Switch, switch, switches, ESPHOME_ENTITY_SWITCH_COUNT, SWITCH, switch_update) +#endif +#ifdef USE_BUTTON +ENTITY_TYPE_(button::Button, button, buttons, ESPHOME_ENTITY_BUTTON_COUNT, BUTTON) +#endif +#ifdef USE_TEXT_SENSOR +ENTITY_CONTROLLER_TYPE_(text_sensor::TextSensor, text_sensor, text_sensors, ESPHOME_ENTITY_TEXT_SENSOR_COUNT, TEXT_SENSOR, text_sensor_update) +#endif +#ifdef USE_CLIMATE +ENTITY_CONTROLLER_TYPE_(climate::Climate, climate, climates, ESPHOME_ENTITY_CLIMATE_COUNT, CLIMATE, climate_update) +#endif +#ifdef USE_NUMBER +ENTITY_CONTROLLER_TYPE_(number::Number, number, numbers, ESPHOME_ENTITY_NUMBER_COUNT, NUMBER, number_update) +#endif +#ifdef USE_DATETIME_DATE +ENTITY_CONTROLLER_TYPE_(datetime::DateEntity, date, dates, ESPHOME_ENTITY_DATE_COUNT, DATETIME_DATE, date_update) +#endif +#ifdef USE_DATETIME_TIME +ENTITY_CONTROLLER_TYPE_(datetime::TimeEntity, time, times, ESPHOME_ENTITY_TIME_COUNT, DATETIME_TIME, time_update) +#endif +#ifdef USE_DATETIME_DATETIME +ENTITY_CONTROLLER_TYPE_(datetime::DateTimeEntity, datetime, datetimes, ESPHOME_ENTITY_DATETIME_COUNT, DATETIME_DATETIME, datetime_update) +#endif +#ifdef USE_TEXT +ENTITY_CONTROLLER_TYPE_(text::Text, text, texts, ESPHOME_ENTITY_TEXT_COUNT, TEXT, text_update) +#endif +#ifdef USE_SELECT +ENTITY_CONTROLLER_TYPE_(select::Select, select, selects, ESPHOME_ENTITY_SELECT_COUNT, SELECT, select_update) +#endif +#ifdef USE_LOCK +ENTITY_CONTROLLER_TYPE_(lock::Lock, lock, locks, ESPHOME_ENTITY_LOCK_COUNT, LOCK, lock_update) +#endif +#ifdef USE_VALVE +ENTITY_CONTROLLER_TYPE_(valve::Valve, valve, valves, ESPHOME_ENTITY_VALVE_COUNT, VALVE, valve_update) +#endif +#ifdef USE_MEDIA_PLAYER +ENTITY_CONTROLLER_TYPE_(media_player::MediaPlayer, media_player, media_players, ESPHOME_ENTITY_MEDIA_PLAYER_COUNT, MEDIA_PLAYER, media_player_update) +#endif +#ifdef USE_ALARM_CONTROL_PANEL +ENTITY_CONTROLLER_TYPE_(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels, ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT, ALARM_CONTROL_PANEL, alarm_control_panel_update) +#endif +#ifdef USE_WATER_HEATER +ENTITY_CONTROLLER_TYPE_(water_heater::WaterHeater, water_heater, water_heaters, ESPHOME_ENTITY_WATER_HEATER_COUNT, WATER_HEATER, water_heater_update) +#endif +#ifdef USE_INFRARED +ENTITY_TYPE_(infrared::Infrared, infrared, infrareds, ESPHOME_ENTITY_INFRARED_COUNT, INFRARED) +#endif +#ifdef USE_EVENT +ENTITY_CONTROLLER_TYPE_(event::Event, event, events, ESPHOME_ENTITY_EVENT_COUNT, EVENT, event) +#endif +#ifdef USE_UPDATE +ENTITY_CONTROLLER_TYPE_(update::UpdateEntity, update, updates, ESPHOME_ENTITY_UPDATE_COUNT, UPDATE, update) +#endif From 49fd95d67209aad625780a60510297234aa6d8a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 14:18:58 -1000 Subject: [PATCH 0029/2214] [core] Add .inc to SOURCE_FILE_EXTENSIONS PlatformIO only copies files with extensions listed in SOURCE_FILE_EXTENSIONS to the build directory. Add .inc so that X-macro include files (entity_types.inc) are available during compilation. --- esphome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/const.py b/esphome/const.py index c2bf86d532..58a997352f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -102,7 +102,7 @@ PLATFORM_RP2040 = Platform.RP2040 PLATFORM_RTL87XX = Platform.RTL87XX -SOURCE_FILE_EXTENSIONS = {".cpp", ".hpp", ".h", ".c", ".tcc", ".ino"} +SOURCE_FILE_EXTENSIONS = {".cpp", ".hpp", ".h", ".c", ".tcc", ".ino", ".inc"} HEADER_FILE_EXTENSIONS = {".h", ".hpp", ".tcc"} SECRETS_FILES = ("secrets.yaml", "secrets.yml") From 8791d9f26bde03c4c7556afe097a6be837784cef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 14:25:34 -1000 Subject: [PATCH 0030/2214] [core] Add missing NOLINT suppression for bugprone-macro-parentheses clang-tidy flags macro arguments that aren't parenthesized, but wrapping template type arguments in parentheses would break C++ syntax. Add NOLINTBEGIN/END around the remaining macro blocks. --- esphome/core/application.h | 4 ++++ esphome/core/component_iterator.h | 2 ++ 2 files changed, 6 insertions(+) diff --git a/esphome/core/application.h b/esphome/core/application.h index 084cb643ef..6e4cdbc346 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -273,6 +273,7 @@ class Application { const auto &get_areas() { return this->areas_; } #endif // Entity getter methods (generated from entity_types.inc) +// NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ auto &get_##plural() const { return this->plural##_; } \ GET_ENTITY_METHOD(type, singular, plural) @@ -281,6 +282,7 @@ class Application { #include "esphome/core/entity_types.inc" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_SERIAL_PROXY auto &get_serial_proxies() const { return this->serial_proxies_; } @@ -462,12 +464,14 @@ class Application { StaticVector areas_{}; #endif // Entity StaticVector fields (generated from entity_types.inc) +// NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) StaticVector plural##_{}; #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ ENTITY_TYPE_(type, singular, plural, count, upper) #include "esphome/core/entity_types.inc" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_SERIAL_PROXY StaticVector serial_proxies_{}; diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index f855ab160f..f1e2fe4d52 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -53,11 +53,13 @@ class ComponentIterator { NONE = 0, BEGIN, // Entity iterator states (generated from entity_types.inc) +// NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) upper, #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) upper, #include "esphome/core/entity_types.inc" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ +// NOLINTEND(bugprone-macro-parentheses) #ifdef USE_API_USER_DEFINED_ACTIONS SERVICE, #endif From 4190ebebc95382d8a25bc0bb4f279390f213b705 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 14:26:59 -1000 Subject: [PATCH 0031/2214] [core] Inline ControllerRegistry::register_controller into header Tiny one-liner, no reason to keep it in the .cpp. --- esphome/core/controller_registry.cpp | 2 -- esphome/core/controller_registry.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp index 92f23f5642..907e0f923d 100644 --- a/esphome/core/controller_registry.cpp +++ b/esphome/core/controller_registry.cpp @@ -6,8 +6,6 @@ namespace esphome { StaticVector ControllerRegistry::controllers; -void ControllerRegistry::register_controller(Controller *controller) { controllers.push_back(controller); } - } // namespace esphome #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index 3c7b2b83a8..b2122d3314 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -34,7 +34,7 @@ class ControllerRegistry { * Controllers should call this in their setup() method. * Typically only APIServer and WebServer register. */ - static void register_controller(Controller *controller); + static void register_controller(Controller *controller) { controllers.push_back(controller); } // Notify method declarations (generated from entity_types.inc) // NOLINTBEGIN(bugprone-macro-parentheses) From affb20a9cfc3d4253bc1992fb7dc604b125000a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 14:44:52 -1000 Subject: [PATCH 0032/2214] [core] Rename entity_types.inc to entity_types.h Neither PlatformIO nor ESP-IDF/CMake copy .inc files to the build directory. Rename to .h so it's recognized by both build systems. Exempt from pragma-once lint since this file is intentionally included multiple times with different macro definitions. --- esphome/const.py | 2 +- esphome/core/application.h | 12 ++++++------ esphome/core/component_iterator.cpp | 4 ++-- esphome/core/component_iterator.h | 8 ++++---- esphome/core/controller.h | 4 ++-- esphome/core/controller_registry.h | 6 +++--- .../core/{entity_types.inc => entity_types.h} | 18 ++++++++++++------ script/ci-custom.py | 2 +- 8 files changed, 31 insertions(+), 25 deletions(-) rename esphome/core/{entity_types.inc => entity_types.h} (82%) diff --git a/esphome/const.py b/esphome/const.py index 58a997352f..c2bf86d532 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -102,7 +102,7 @@ PLATFORM_RP2040 = Platform.RP2040 PLATFORM_RTL87XX = Platform.RTL87XX -SOURCE_FILE_EXTENSIONS = {".cpp", ".hpp", ".h", ".c", ".tcc", ".ino", ".inc"} +SOURCE_FILE_EXTENSIONS = {".cpp", ".hpp", ".h", ".c", ".tcc", ".ino"} HEADER_FILE_EXTENSIONS = {".h", ".hpp", ".tcc"} SECRETS_FILES = ("secrets.yaml", "secrets.yml") diff --git a/esphome/core/application.h b/esphome/core/application.h index 6e4cdbc346..317631d62c 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -119,13 +119,13 @@ class Application { void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } -// Entity register methods (generated from entity_types.inc) +// Entity register methods (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ void register_##singular(type *obj) { this->plural##_.push_back(obj); } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ ENTITY_TYPE_(type, singular, plural, count, upper) -#include "esphome/core/entity_types.inc" +#include "esphome/core/entity_types.h" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) @@ -272,14 +272,14 @@ class Application { #ifdef USE_AREAS const auto &get_areas() { return this->areas_; } #endif -// Entity getter methods (generated from entity_types.inc) +// Entity getter methods (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ auto &get_##plural() const { return this->plural##_; } \ GET_ENTITY_METHOD(type, singular, plural) #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ ENTITY_TYPE_(type, singular, plural, count, upper) -#include "esphome/core/entity_types.inc" +#include "esphome/core/entity_types.h" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) @@ -463,12 +463,12 @@ class Application { #ifdef USE_AREAS StaticVector areas_{}; #endif -// Entity StaticVector fields (generated from entity_types.inc) +// Entity StaticVector fields (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) StaticVector plural##_{}; #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ ENTITY_TYPE_(type, singular, plural, count, upper) -#include "esphome/core/entity_types.inc" +#include "esphome/core/entity_types.h" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 9b5b8d1512..f4d3c05e19 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -33,7 +33,7 @@ void ComponentIterator::advance() { } break; -// Entity iterator cases (generated from entity_types.inc) +// Entity iterator cases (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ case IteratorState::upper: \ @@ -41,7 +41,7 @@ void ComponentIterator::advance() { break; #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ ENTITY_TYPE_(type, singular, plural, count, upper) -#include "esphome/core/entity_types.inc" +#include "esphome/core/entity_types.h" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index f1e2fe4d52..9a1e5da351 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -28,12 +28,12 @@ class ComponentIterator { void advance(); bool completed() const { return this->state_ == IteratorState::NONE; } virtual bool on_begin(); -// Pure virtual entity callbacks (generated from entity_types.inc) +// Pure virtual entity callbacks (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) virtual bool on_##singular(type *obj) = 0; #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ ENTITY_TYPE_(type, singular, plural, count, upper) -#include "esphome/core/entity_types.inc" +#include "esphome/core/entity_types.h" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) @@ -52,11 +52,11 @@ class ComponentIterator { enum class IteratorState : uint8_t { NONE = 0, BEGIN, -// Entity iterator states (generated from entity_types.inc) +// Entity iterator states (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) upper, #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) upper, -#include "esphome/core/entity_types.inc" +#include "esphome/core/entity_types.h" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) diff --git a/esphome/core/controller.h b/esphome/core/controller.h index 4f3c8d3352..09975b465f 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -6,11 +6,11 @@ namespace esphome { class Controller { public: -// Controller virtual methods (generated from entity_types.inc) +// Controller virtual methods (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) virtual void on_##callback(type *obj){}; -#include "esphome/core/entity_types.inc" +#include "esphome/core/entity_types.h" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index b2122d3314..c6113116ff 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -36,12 +36,12 @@ class ControllerRegistry { */ static void register_controller(Controller *controller) { controllers.push_back(controller); } -// Notify method declarations (generated from entity_types.inc) +// Notify method declarations (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ static void notify_##callback(type *obj); -#include "esphome/core/entity_types.inc" +#include "esphome/core/entity_types.h" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) @@ -72,7 +72,7 @@ namespace esphome { controller->on_##callback(obj); \ } \ } -#include "esphome/core/entity_types.inc" +#include "esphome/core/entity_types.h" #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) diff --git a/esphome/core/entity_types.inc b/esphome/core/entity_types.h similarity index 82% rename from esphome/core/entity_types.inc rename to esphome/core/entity_types.h index ff8b1eb065..3287a943a3 100644 --- a/esphome/core/entity_types.inc +++ b/esphome/core/entity_types.h @@ -14,7 +14,8 @@ // - serial_proxy: custom register logic, no by-key lookup #ifdef USE_BINARY_SENSOR -ENTITY_CONTROLLER_TYPE_(binary_sensor::BinarySensor, binary_sensor, binary_sensors, ESPHOME_ENTITY_BINARY_SENSOR_COUNT, BINARY_SENSOR, binary_sensor_update) +ENTITY_CONTROLLER_TYPE_(binary_sensor::BinarySensor, binary_sensor, binary_sensors, ESPHOME_ENTITY_BINARY_SENSOR_COUNT, + BINARY_SENSOR, binary_sensor_update) #endif #ifdef USE_COVER ENTITY_CONTROLLER_TYPE_(cover::Cover, cover, covers, ESPHOME_ENTITY_COVER_COUNT, COVER, cover_update) @@ -35,7 +36,8 @@ ENTITY_CONTROLLER_TYPE_(switch_::Switch, switch, switches, ESPHOME_ENTITY_SWITCH ENTITY_TYPE_(button::Button, button, buttons, ESPHOME_ENTITY_BUTTON_COUNT, BUTTON) #endif #ifdef USE_TEXT_SENSOR -ENTITY_CONTROLLER_TYPE_(text_sensor::TextSensor, text_sensor, text_sensors, ESPHOME_ENTITY_TEXT_SENSOR_COUNT, TEXT_SENSOR, text_sensor_update) +ENTITY_CONTROLLER_TYPE_(text_sensor::TextSensor, text_sensor, text_sensors, ESPHOME_ENTITY_TEXT_SENSOR_COUNT, + TEXT_SENSOR, text_sensor_update) #endif #ifdef USE_CLIMATE ENTITY_CONTROLLER_TYPE_(climate::Climate, climate, climates, ESPHOME_ENTITY_CLIMATE_COUNT, CLIMATE, climate_update) @@ -50,7 +52,8 @@ ENTITY_CONTROLLER_TYPE_(datetime::DateEntity, date, dates, ESPHOME_ENTITY_DATE_C ENTITY_CONTROLLER_TYPE_(datetime::TimeEntity, time, times, ESPHOME_ENTITY_TIME_COUNT, DATETIME_TIME, time_update) #endif #ifdef USE_DATETIME_DATETIME -ENTITY_CONTROLLER_TYPE_(datetime::DateTimeEntity, datetime, datetimes, ESPHOME_ENTITY_DATETIME_COUNT, DATETIME_DATETIME, datetime_update) +ENTITY_CONTROLLER_TYPE_(datetime::DateTimeEntity, datetime, datetimes, ESPHOME_ENTITY_DATETIME_COUNT, DATETIME_DATETIME, + datetime_update) #endif #ifdef USE_TEXT ENTITY_CONTROLLER_TYPE_(text::Text, text, texts, ESPHOME_ENTITY_TEXT_COUNT, TEXT, text_update) @@ -65,13 +68,16 @@ ENTITY_CONTROLLER_TYPE_(lock::Lock, lock, locks, ESPHOME_ENTITY_LOCK_COUNT, LOCK ENTITY_CONTROLLER_TYPE_(valve::Valve, valve, valves, ESPHOME_ENTITY_VALVE_COUNT, VALVE, valve_update) #endif #ifdef USE_MEDIA_PLAYER -ENTITY_CONTROLLER_TYPE_(media_player::MediaPlayer, media_player, media_players, ESPHOME_ENTITY_MEDIA_PLAYER_COUNT, MEDIA_PLAYER, media_player_update) +ENTITY_CONTROLLER_TYPE_(media_player::MediaPlayer, media_player, media_players, ESPHOME_ENTITY_MEDIA_PLAYER_COUNT, + MEDIA_PLAYER, media_player_update) #endif #ifdef USE_ALARM_CONTROL_PANEL -ENTITY_CONTROLLER_TYPE_(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels, ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT, ALARM_CONTROL_PANEL, alarm_control_panel_update) +ENTITY_CONTROLLER_TYPE_(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels, + ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT, ALARM_CONTROL_PANEL, alarm_control_panel_update) #endif #ifdef USE_WATER_HEATER -ENTITY_CONTROLLER_TYPE_(water_heater::WaterHeater, water_heater, water_heaters, ESPHOME_ENTITY_WATER_HEATER_COUNT, WATER_HEATER, water_heater_update) +ENTITY_CONTROLLER_TYPE_(water_heater::WaterHeater, water_heater, water_heaters, ESPHOME_ENTITY_WATER_HEATER_COUNT, + WATER_HEATER, water_heater_update) #endif #ifdef USE_INFRARED ENTITY_TYPE_(infrared::Infrared, infrared, infrareds, ESPHOME_ENTITY_INFRARED_COUNT, INFRARED) diff --git a/script/ci-custom.py b/script/ci-custom.py index 1ec3eab3a9..6dce86924e 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -672,7 +672,7 @@ def lint_using_esp_idf_deprecated(fname, line, col, content): ) -@lint_content_check(include=["*.h"]) +@lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"]) def lint_pragma_once(fname, content): if "#pragma once" not in content: return ( From dc596bfa755830244519e9fdfd4a087d61e02764 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 14:46:23 -1000 Subject: [PATCH 0033/2214] [core] Exclude entity_types.h from generated esphome.h The X-macro file requires ENTITY_TYPE_ and ENTITY_CONTROLLER_TYPE_ macros to be defined before inclusion. The generated esphome.h includes all .h files bare, which causes compilation errors. --- esphome/writer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/writer.py b/esphome/writer.py index 06a2230118..6bc4c456c3 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -196,9 +196,12 @@ def copy_src_tree(): source_files_l.sort() # Build #include list for esphome.h + # X-macro files are included multiple times with different macro definitions + # and must not be included bare in esphome.h + esphome_h_exclude = {Path("esphome/core/entity_types.h")} include_l = [] for target, _ in source_files_l: - if target.suffix in HEADER_FILE_EXTENSIONS: + if target.suffix in HEADER_FILE_EXTENSIONS and target not in esphome_h_exclude: include_l.append(f'#include "{target}"') include_l.append("") include_s = "\n".join(include_l) From f10f7c6b6a60582f9a33f2516e8ce9b0cac24bf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 14:46:50 -1000 Subject: [PATCH 0034/2214] [core] Use ENTITY_TYPES_H_TARGET constant for consistency --- esphome/writer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/writer.py b/esphome/writer.py index 6bc4c456c3..787ecac6f6 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -171,6 +171,7 @@ VERSION_H_FORMAT = """\ DEFINES_H_TARGET = "esphome/core/defines.h" VERSION_H_TARGET = "esphome/core/version.h" BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" +ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h" ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -198,7 +199,7 @@ def copy_src_tree(): # Build #include list for esphome.h # X-macro files are included multiple times with different macro definitions # and must not be included bare in esphome.h - esphome_h_exclude = {Path("esphome/core/entity_types.h")} + esphome_h_exclude = {Path(ENTITY_TYPES_H_TARGET)} include_l = [] for target, _ in source_files_l: if target.suffix in HEADER_FILE_EXTENSIONS and target not in esphome_h_exclude: From 7d87b320331b44afab0a5caaa52c2a93b01260bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 14:52:04 -1000 Subject: [PATCH 0035/2214] [core] Exclude entity_types.h from clang-tidy all-include.cpp The X-macro file requires macros to be defined before inclusion and cannot be included bare in the clang-tidy all-include header. --- script/helpers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/script/helpers.py b/script/helpers.py index c9c550d889..00969efe7c 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -167,11 +167,15 @@ def build_all_include(header_files: list[str] | None = None) -> None: cmd = ["git", "ls-files", "esphome/**/*.h"] proc = subprocess.run(cmd, capture_output=True, text=True, check=True) + # X-macro files are included multiple times with different macro definitions + # and must not be included bare + exclude = {"esphome/core/entity_types.h"} + # Process git output - git already returns paths relative to repo root header_files = [ line.replace(os.path.sep, "/") for line in proc.stdout.strip().split("\n") - if line + if line and line.replace(os.path.sep, "/") not in exclude ] headers = [f'#include "{h}"' for h in header_files] From 053cee4ec9ed9652e9d4a827a858ceb356e23e41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 14:52:37 -1000 Subject: [PATCH 0036/2214] [core] Use ENTITY_TYPES_H_TARGET constant in helpers.py --- script/helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/script/helpers.py b/script/helpers.py index 00969efe7c..5140793e4f 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -167,9 +167,11 @@ def build_all_include(header_files: list[str] | None = None) -> None: cmd = ["git", "ls-files", "esphome/**/*.h"] proc = subprocess.run(cmd, capture_output=True, text=True, check=True) + from esphome.writer import ENTITY_TYPES_H_TARGET + # X-macro files are included multiple times with different macro definitions # and must not be included bare - exclude = {"esphome/core/entity_types.h"} + exclude = {ENTITY_TYPES_H_TARGET} # Process git output - git already returns paths relative to repo root header_files = [ From 107ba28ac3ae1bb2d3fe3fc031d31cfcf0d1cbb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 15:03:45 -1000 Subject: [PATCH 0037/2214] fix merge --- esphome/core/component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index a1bf37abab..deda42b0a7 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -313,7 +313,7 @@ void IRAM_ATTR Component::enable_loop_soon_any_context() { App.has_pending_enable_loop_requests_ = true; // Wake the main loop from sleep. Without this, the main loop would not // wake until the select/delay timeout expires (~16ms). - Application::wake_loop_any_context(); + wake_loop_any_context(); } void Component::reset_to_construction_state() { if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED) { From df51f4e37110a21c94f58949d6518f2bf9d8f7d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 17:42:33 -1000 Subject: [PATCH 0038/2214] [rp2040] Fix W5500 Ethernet pbuf corruption by mirroring LWIPMutex semantics The previous LwIPLock for RP2040+Ethernet only called ethernet_arch_lwip_begin/end, taking the async_context lock but never bumping arduino-pico's __inLWIP counter. The W5500 GPIO IRQ path (LwipIntfDev::_irq) checks __inLWIP to decide whether to defer packet processing; with __inLWIP == 0 it would proceed to run _lwipCallback immediately from IRQ context even while the main thread held the async_context lock, re-entering lwIP and corrupting pbuf chains. The corruption surfaced later as the pbuf_cat assertion "p->tot_len == p->len (of last pbuf in chain)" panicking the device on wiznet-w5500-evb-pico boards configured with an interrupt pin. Mirror arduino-pico's LWIPMutex (cores/rp2040/lwip_wrap.h) exactly: bump __inLWIP, take the lock, and on release re-unmask any GPIO IRQs that were deferred while we held it (__needsIRQEN). We can't write `using LwIPLock = LWIPMutex;` in core/helpers.h because pulling lwip_wrap.h there transitively poisons many translation units with lwIP types in an order that breaks err_t resolution, so the body is inlined in the rp2040 helpers.cpp where the include is scoped. --- esphome/components/rp2040/helpers.cpp | 31 +++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2040/helpers.cpp index 8cb5f7c18d..3a53eba61b 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2040/helpers.cpp @@ -9,7 +9,7 @@ #include #include // For cyw43_arch_lwip_begin/end (LwIPLock) #elif defined(USE_ETHERNET) -#include // For ethernet_arch_lwip_begin/end (LwIPLock) +#include // For LWIPMutex — LwIPLock delegates to it (see below) #include "esphome/components/ethernet/ethernet_component.h" #endif #include @@ -43,9 +43,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } // main loop, corrupting the shared rx_buf_ pbuf chain (use-after-free, pbuf_cat // assertion failures). See esphome#10681. // -// WiFi uses cyw43_arch_lwip_begin/end; Ethernet uses ethernet_arch_lwip_begin/end. -// Both acquire the async_context recursive mutex to prevent IRQ callbacks from -// firing during critical sections. +// WiFi uses cyw43_arch_lwip_begin/end. +// +// For wired Ethernet, taking only the async_context lock is NOT enough. The +// W5500 GPIO IRQ path (LwipIntfDev::_irq) checks arduino-pico's `__inLWIP` +// counter to decide whether to defer packet processing. If we hold the +// async_context lock without bumping `__inLWIP`, an interrupt-driven packet +// arrival re-enters lwIP from IRQ context and corrupts pbufs (the `pbuf_cat` +// assertion crash on wiznet-w5500-evb-pico). We mirror arduino-pico's +// LWIPMutex (cores/rp2040/lwip_wrap.h) exactly: bump `__inLWIP`, take the +// lock, and on release re-unmask any GPIO IRQs that were deferred while we +// held it. We can't `using LwIPLock = LWIPMutex;` in helpers.h because +// pulling lwip_wrap.h there poisons many TUs with lwIP types. // // When neither WiFi nor Ethernet is configured, this is a no-op since // there's no network stack and no lwip callbacks to race with. @@ -53,8 +62,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } LwIPLock::LwIPLock() { cyw43_arch_lwip_begin(); } LwIPLock::~LwIPLock() { cyw43_arch_lwip_end(); } #elif defined(USE_ETHERNET) -LwIPLock::LwIPLock() { ethernet_arch_lwip_begin(); } -LwIPLock::~LwIPLock() { ethernet_arch_lwip_end(); } +LwIPLock::LwIPLock() { + __inLWIP++; + ethernet_arch_lwip_begin(); +} +LwIPLock::~LwIPLock() { + ethernet_arch_lwip_end(); + __inLWIP--; + if (__needsIRQEN && !__inLWIP) { + __needsIRQEN = false; + ethernet_arch_lwip_gpio_unmask(); + } +} #else LwIPLock::LwIPLock() {} LwIPLock::~LwIPLock() {} From 5b84ad592671cf12242794474e45a2395f46b67f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 13:24:32 -1000 Subject: [PATCH 0039/2214] [esphome.ota] Disable loop while idle, wake on listening-socket activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESPHomeOTAComponent::loop() previously ran every main-loop tick just to check `client_ != nullptr || server_->ready()` — a wasted dispatch on every device, since OTA is idle the vast majority of the time. OTA now disables its own loop after setup() and after cleanup_connection_(). A single 4-byte Component* slot in Application (only compiled in under USE_OTA) lets the existing socket-wake paths call enable_loop_soon_any_context() on the registered OTA component: - ESP32 / LibreTiny (lwip fast select): hooked in esphome_socket_event_callback on NETCONN_EVT_RCVPLUS. - ESP8266 / RP2040 (raw TCP): hooked in LWIPRawListenImpl::accept_fn_ right after the existing wake_loop_any_context() call. - Host (select fallback): called after select() returns ready in Application::yield_with_select_. False wakes (e.g. an API-socket event firing the fast-select callback) land in ESPHomeOTAComponent::loop(), which re-disables itself immediately when idle. Net cost is still far below running every tick. This is deliberately an OTA-only hook: OTA is the only component that benefits, and a single global slot avoids adding per-socket Component* storage, wake-callback lists, or any new API churn to the socket layer. --- .../components/esphome/ota/ota_esphome.cpp | 25 ++++++++++++++----- .../components/socket/lwip_raw_tcp_impl.cpp | 9 +++++++ esphome/core/application.cpp | 13 ++++++++++ esphome/core/application.h | 17 +++++++++++++ esphome/core/lwip_fast_select.c | 11 ++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index af9b8ee19a..ba526cc2b7 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -65,6 +65,12 @@ void ESPHomeOTAComponent::setup() { this->server_failed_(LOG_STR("listen")); return; } + + // Disable loop() while idle. Socket wake paths (LwIP fast select, raw TCP accept + // callback, host select) call App.wake_ota_component_any_context() which re-enables + // this component's loop when an incoming connection arrives. + App.set_ota_wake_component(this); + this->disable_loop(); } void ESPHomeOTAComponent::dump_config() { @@ -81,13 +87,18 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Skip handle_handshake_() call if no client connected and no incoming connections - // This optimization reduces idle loop overhead when OTA is not active - // Note: No need to check server_ for null as the component is marked failed in setup() - // if server_ creation fails - if (this->client_ != nullptr || this->server_->ready()) { - this->handle_handshake_(); + // loop() is disabled while idle (see setup() / cleanup_connection_()). Socket-wake + // paths (LwIP fast select, raw TCP accept, host select) call + // App.wake_ota_component_any_context() to re-enable this loop when a monitored + // socket signals activity. False wakes (e.g. an API-socket event) land here with + // no pending work — in that case we disable the loop again and go back to sleep. + // Note: No need to check server_ for null as the component is marked failed in + // setup() if server_ creation fails. + if (this->client_ == nullptr && !this->server_->ready()) { + this->disable_loop(); + return; } + this->handle_handshake_(); } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; @@ -566,6 +577,8 @@ void ESPHomeOTAComponent::cleanup_connection_() { #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); #endif + // Back to idle — sleep until the next incoming connection wakes us. + this->disable_loop(); } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 86131d3ddb..823637e0f7 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -10,6 +10,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/wake.h" #include "esphome/core/log.h" +#ifdef USE_OTA +#include "esphome/core/application.h" +#endif #ifdef USE_ESP8266 #include // For esp_schedule() @@ -856,6 +859,12 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); +#ifdef USE_OTA + // Re-enable the OTA component loop if it disabled itself while idle. + // enable_loop_soon_any_context() is IRAM/IRQ-safe, which is required on RP2040 + // where this callback runs in a low-priority user IRQ context. + esphome::App.wake_ota_component_any_context(); +#endif return ERR_OK; } diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd75859880..45bd2350f7 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -449,6 +449,12 @@ void Application::enable_pending_loops_() { } } +#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) +// Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS. +// enable_loop_soon_any_context() is task-safe and IRAM-resident. +extern "C" void IRAM_ATTR esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } +#endif + #ifdef USE_LWIP_FAST_SELECT bool Application::register_socket(struct lwip_sock *sock) { // It modifies monitored_sockets_ without locking — must only be called from the main loop. @@ -554,6 +560,13 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected if (ret >= 0) [[likely]] { +#ifdef USE_OTA + // A socket is ready; re-enable the OTA component loop if it disabled itself while idle. + // The wake is a no-op if OTA didn't register or is already active. + if (ret > 0) { + this->wake_ota_component_any_context(); + } +#endif // Yield if zero timeout since select(0) only polls without yielding if (delay_ms == 0) [[unlikely]] { yield(); diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b490..63a951b681 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -559,6 +559,20 @@ class Application { /// Wake from any context (ISR, thread, callback). static void IRAM_ATTR wake_loop_any_context() { esphome::wake_loop_any_context(); } +#ifdef USE_OTA + /// Register the OTA component so socket-wake paths can enable its loop when + /// a new connection arrives on the listening socket. OTA disables its own + /// loop while idle to avoid per-tick dispatch overhead. + void set_ota_wake_component(Component *component) { this->ota_wake_component_ = component; } + /// Wake the registered OTA component (if any) from any context. Safe to call + /// from the LwIP TCP/IP task and other callback contexts. + void IRAM_ATTR wake_ota_component_any_context() { + if (this->ota_wake_component_ != nullptr) { + this->ota_wake_component_->enable_loop_soon_any_context(); + } + } +#endif + protected: friend Component; #ifdef USE_HOST @@ -634,6 +648,9 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; +#ifdef USE_OTA + Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications +#endif // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index bb3acbafcb..80fe933941 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,13 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +#ifdef USE_OTA +// Extern wake hook for the OTA component (implemented in application.cpp). Called from the +// TCP/IP task so the OTA component's disabled loop can be re-enabled when a new connection +// arrives on its listening socket. Safe from task context via enable_loop_soon_any_context(). +extern void esphome_wake_ota_component_any_context(void); +#endif + // Wrapper callback: calls original event_callback + notifies main loop task. // Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR). static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) { @@ -175,6 +182,10 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt if (task != NULL) { xTaskNotifyGive(task); } +#ifdef USE_OTA + // Re-enable the OTA component loop if it disabled itself while idle. + esphome_wake_ota_component_any_context(); +#endif } } From 0f8419f97dba73f3c3a9d1d9609caf41cad0c0ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 14:09:27 -1000 Subject: [PATCH 0040/2214] [esphome.ota] Reject multi-port esphome OTA, drop redundant wake Address copilot review on #15636. 1. Enforce single ESPHome OTA instance (BREAKING CHANGE). The `ota_esphome_final_validate` hook has always merged multiple `ota: - platform: esphome` configs by port so a user config and a remote package that both define OTA would merge rather than break. That merge behavior is preserved. But if two configs survive on *different* ports they produce two independent listening sockets, which is not a sane deployment: it creates ambiguity for safe_mode coordination and for the socket wake hook added in this PR. Raise cv.Invalid when more than one port remains after merging. 2. Drop redundant main-loop wake in Application::wake_ota_component_any_context. Every caller (lwip fast-select callback already calls xTaskNotifyGive, raw-tcp accept callback already calls wake_loop_any_context(), host path is already running post-select in the main loop) has woken the main loop by the time we reach this hook. Calling enable_loop_soon_any_context() would re-wake it. Application is a friend of Component, so set pending_enable_loop_ and has_pending_enable_loop_requests_ directly instead. --- esphome/components/esphome/ota/__init__.py | 14 ++++++++++++++ esphome/core/application.h | 11 ++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 337064dd27..d358438096 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -78,6 +78,20 @@ def ota_esphome_final_validate(config): else: new_ota_conf.append(ota_conf) + # BREAKING CHANGE: Only a single ESPHome OTA instance is supported. Historically + # the config layer merged multiple configs by port to accommodate users who had a + # local 'ota:' block and then imported a remote package that also declared one — + # the merge kept their build working. That merge behavior is preserved. But two + # ESPHome OTA instances on *different* ports is not a real-world use case and + # creates ambiguity for listeners, socket wake hooks, and safe_mode coordination. + if len(merged_ota_esphome_configs_by_port) > 1: + raise cv.Invalid( + f"Only a single '{CONF_OTA}' '{CONF_PLATFORM}: {CONF_ESPHOME}' instance is " + f"supported, but multiple were configured on different ports " + f"({sorted(merged_ota_esphome_configs_by_port.keys())}). Remove the extra " + f"configurations or place them on the same port so they can be merged." + ) + new_ota_conf.extend(merged_ota_esphome_configs_by_port.values()) full_conf[CONF_OTA] = new_ota_conf diff --git a/esphome/core/application.h b/esphome/core/application.h index 63a951b681..100ff3440d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -564,11 +564,16 @@ class Application { /// a new connection arrives on the listening socket. OTA disables its own /// loop while idle to avoid per-tick dispatch overhead. void set_ota_wake_component(Component *component) { this->ota_wake_component_ = component; } - /// Wake the registered OTA component (if any) from any context. Safe to call - /// from the LwIP TCP/IP task and other callback contexts. + /// Mark the registered OTA component (if any) for loop re-enable from any + /// context. Intentionally does NOT call wake_loop_any_context() — every + /// caller (lwip fast-select callback, raw-tcp accept callback, host + /// select() return path) has already woken the main loop, so a second wake + /// here would be redundant. Application is a friend of Component, so we + /// set the pending-enable flags directly. void IRAM_ATTR wake_ota_component_any_context() { if (this->ota_wake_component_ != nullptr) { - this->ota_wake_component_->enable_loop_soon_any_context(); + this->ota_wake_component_->pending_enable_loop_ = true; + this->has_pending_enable_loop_requests_ = true; } } #endif From ae9c5bab80953ff03ee46d2ebf72c060516a4772 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 14:11:19 -1000 Subject: [PATCH 0041/2214] [esphome.ota] Drop narrative comment from multi-port validator --- esphome/components/esphome/ota/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index d358438096..761a825d84 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -78,12 +78,6 @@ def ota_esphome_final_validate(config): else: new_ota_conf.append(ota_conf) - # BREAKING CHANGE: Only a single ESPHome OTA instance is supported. Historically - # the config layer merged multiple configs by port to accommodate users who had a - # local 'ota:' block and then imported a remote package that also declared one — - # the merge kept their build working. That merge behavior is preserved. But two - # ESPHome OTA instances on *different* ports is not a real-world use case and - # creates ambiguity for listeners, socket wake hooks, and safe_mode coordination. if len(merged_ota_esphome_configs_by_port) > 1: raise cv.Invalid( f"Only a single '{CONF_OTA}' '{CONF_PLATFORM}: {CONF_ESPHOME}' instance is " From af8fd1d0607af7bf865bce54802340a926f3a114 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 14:29:32 -1000 Subject: [PATCH 0042/2214] [esphome.ota] Fix cleanup race, tighten error message and comments, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses copilot review on #15636. 1. Fix cleanup_connection_() race with queued listener events. While an OTA session was active, a second incoming connection would fire esphome_socket_event_callback → esphome_wake_ota_component_any_context, which sets pending_enable_loop_ on the (still-active) OTA component. enable_pending_loops_() only scans the inactive section, so that flag goes invisible. When cleanup_connection_() then called disable_loop(), the component dropped to LOOP_DONE with a stale pending flag and nothing to re-trigger the scan — the queued client sat forever until some unrelated socket activity woke the main loop. Fix: don't call disable_loop() from cleanup_connection_(). loop() has the idempotent idle check at its top; one more dispatch after cleanup is cheap and guarantees we re-read server_->ready() and either accept the queued client or disable cleanly. 2. Tighten the multi-port error message. Merging is fine — the constraint is single-port. Reworded: "Only a single port is supported for 'ota' 'platform: esphome'. Got ports [...]. Consolidate onto a single port; configs sharing a port are merged automatically." 3. Comment drift: three call sites and the fast-select extern declaration still referred to enable_loop_soon_any_context() and implied the hook wakes the main loop. Updated to reflect the current mechanism (sets pending-enable flags only; callers have already woken the main loop). Also clarified that esphome_wake_ota_component_any_context fires on every RCVPLUS event across all monitored sockets, so false wakes are expected and OTA::loop() disables itself again when idle. 4. Added tests/component_tests/ota/test_esphome_ota.py covering ota_esphome_final_validate: single instance accepted, same-port configs merge, different-port configs rejected with cv.Invalid, non-esphome platforms unaffected. --- esphome/components/esphome/ota/__init__.py | 8 +- .../components/esphome/ota/ota_esphome.cpp | 8 +- .../components/socket/lwip_raw_tcp_impl.cpp | 8 +- esphome/core/application.cpp | 3 +- esphome/core/lwip_fast_select.c | 10 +- tests/component_tests/ota/test_esphome_ota.py | 105 ++++++++++++++++++ 6 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 tests/component_tests/ota/test_esphome_ota.py diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 761a825d84..c8cb9920ba 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -80,10 +80,10 @@ def ota_esphome_final_validate(config): if len(merged_ota_esphome_configs_by_port) > 1: raise cv.Invalid( - f"Only a single '{CONF_OTA}' '{CONF_PLATFORM}: {CONF_ESPHOME}' instance is " - f"supported, but multiple were configured on different ports " - f"({sorted(merged_ota_esphome_configs_by_port.keys())}). Remove the extra " - f"configurations or place them on the same port so they can be merged." + f"Only a single port is supported for '{CONF_OTA}' " + f"'{CONF_PLATFORM}: {CONF_ESPHOME}'. Got ports " + f"{sorted(merged_ota_esphome_configs_by_port.keys())}. Consolidate " + f"onto a single port; configs sharing a port are merged automatically." ) new_ota_conf.extend(merged_ota_esphome_configs_by_port.values()) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ba526cc2b7..08ec4dba66 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -577,8 +577,12 @@ void ESPHomeOTAComponent::cleanup_connection_() { #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); #endif - // Back to idle — sleep until the next incoming connection wakes us. - this->disable_loop(); + // Do not disable_loop() here. loop() itself disables when idle. If a second + // connection was queued on the listener while we were busy, the wake flag was + // set while this component was in LOOP state — enable_pending_loops_() only + // scans the inactive section and would never clear it. Letting loop() run one + // more iteration guarantees we re-check server_->ready() and either accept the + // queued client or disable ourselves cleanly. } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 823637e0f7..3d1679553e 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -860,9 +860,11 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); #ifdef USE_OTA - // Re-enable the OTA component loop if it disabled itself while idle. - // enable_loop_soon_any_context() is IRAM/IRQ-safe, which is required on RP2040 - // where this callback runs in a low-priority user IRQ context. + // Mark the OTA component loop to be re-enabled if it disabled itself while idle. + // This only sets pending-enable flags; the wake_loop_any_context() call above has + // already woken the main loop, which will process the pending enable on its next + // iteration. Safe to call from RP2040's low-priority user IRQ context — it only + // writes volatile bools, no heap or locks. esphome::App.wake_ota_component_any_context(); #endif return ERR_OK; diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 45bd2350f7..8373c672ed 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -451,7 +451,8 @@ void Application::enable_pending_loops_() { #if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) // Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS. -// enable_loop_soon_any_context() is task-safe and IRAM-resident. +// Only marks the OTA component as pending loop-enable; the fast-select callback itself has +// already woken the main task via xTaskNotifyGive(). extern "C" void IRAM_ATTR esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 80fe933941..32b79f288d 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -159,8 +159,10 @@ static netconn_callback s_original_callback = NULL; #ifdef USE_OTA // Extern wake hook for the OTA component (implemented in application.cpp). Called from the -// TCP/IP task so the OTA component's disabled loop can be re-enabled when a new connection -// arrives on its listening socket. Safe from task context via enable_loop_soon_any_context(). +// TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false +// wake from an unrelated monitored socket. OTA::loop() handles that by disabling itself again +// when there is no pending work. The hook only marks the OTA component as pending loop-enable; +// it does not itself wake the main task (the caller below already does that). extern void esphome_wake_ota_component_any_context(void); #endif @@ -183,7 +185,9 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt xTaskNotifyGive(task); } #ifdef USE_OTA - // Re-enable the OTA component loop if it disabled itself while idle. + // Mark the OTA component loop to be re-enabled if it disabled itself while idle. + // Only sets pending-enable flags — the xTaskNotifyGive above has already woken + // the main task, which will process the pending enable on its next iteration. esphome_wake_ota_component_any_context(); #endif } diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py new file mode 100644 index 0000000000..cdac430ff7 --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -0,0 +1,105 @@ +"""Tests for the esphome OTA platform final_validate logic.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.esphome.ota import ota_esphome_final_validate +from esphome.const import ( + CONF_ESPHOME, + CONF_ID, + CONF_OTA, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_VERSION, +) +from esphome.core import ID +import esphome.final_validate as fv + + +def _make_ota_config(port: int = 3232, **kwargs: Any) -> dict[str, Any]: + config: dict[str, Any] = { + CONF_PLATFORM: CONF_ESPHOME, + CONF_ID: ID(f"ota_esphome_{port}", is_manual=False), + CONF_VERSION: 2, + CONF_PORT: port, + } + config.update(kwargs) + return config + + +def test_single_esphome_ota_instance_accepted() -> None: + """A single ESPHome OTA config passes final_validate untouched.""" + full_conf = {CONF_OTA: [_make_ota_config(port=3232)]} + token = fv.full_config.set(full_conf) + try: + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert len(updated[CONF_OTA]) == 1 + assert updated[CONF_OTA][0][CONF_PORT] == 3232 + finally: + fv.full_config.reset(token) + + +def test_same_port_configs_merge(caplog: pytest.LogCaptureFixture) -> None: + """Two ESPHome OTA configs on the same port merge into one instance.""" + full_conf = { + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_PASSWORD: "pw"}), + _make_ota_config(port=3232), + ] + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert len(updated[CONF_OTA]) == 1 + assert updated[CONF_OTA][0][CONF_PORT] == 3232 + assert any("Found and merged" in record.message for record in caplog.records), ( + "Expected merge warning not found in log" + ) + finally: + fv.full_config.reset(token) + + +def test_multiple_ports_rejected() -> None: + """Two ESPHome OTA configs on different ports raise cv.Invalid.""" + full_conf = { + CONF_OTA: [ + _make_ota_config(port=3232), + _make_ota_config(port=3233), + ] + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match=r"Only a single port is supported for 'ota' 'platform: esphome'", + ): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_non_esphome_ota_unaffected() -> None: + """Non-esphome OTA platforms are not subject to the single-instance rule.""" + full_conf = { + CONF_OTA: [ + _make_ota_config(port=3232), + {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, + {CONF_PLATFORM: "http_request", CONF_ID: ID("ota_hr", is_manual=False)}, + ] + } + token = fv.full_config.set(full_conf) + try: + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert len(updated[CONF_OTA]) == 3 + finally: + fv.full_config.reset(token) From 92f93e128fd4be7691105e807c143f8dd372ee57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 14:47:52 -1000 Subject: [PATCH 0043/2214] [esphome.ota] Drop unnecessary IRAM_ATTR from wake hook The wake hook is called only from: - esphome_socket_event_callback (lwip fast select, LwIP TCP/IP task context) - LWIPRawListenImpl::accept_fn_ (raw TCP accept callback) - Application::yield_with_select_ (host select fallback, main thread) None of those contexts require IRAM-resident code. The LwIP fast-select event callback itself is not IRAM_ATTR; the raw-TCP accept callback runs from a low-priority user IRQ on RP2040 where IRAM_ATTR is a no-op anyway (it's an ESP32-specific section attribute for code that must run while flash cache is disabled). This is not a real ISR path the way enable_loop_soon_any_context() is (which is called from GPIO ISRs and genuinely does need IRAM). Removing IRAM_ATTR frees scarce IRAM on ESP32. --- esphome/core/application.cpp | 2 +- esphome/core/application.h | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 8373c672ed..9b7091f59a 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -453,7 +453,7 @@ void Application::enable_pending_loops_() { // Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS. // Only marks the OTA component as pending loop-enable; the fast-select callback itself has // already woken the main task via xTaskNotifyGive(). -extern "C" void IRAM_ATTR esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } +extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/core/application.h b/esphome/core/application.h index 100ff3440d..67ef1d666f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -564,13 +564,14 @@ class Application { /// a new connection arrives on the listening socket. OTA disables its own /// loop while idle to avoid per-tick dispatch overhead. void set_ota_wake_component(Component *component) { this->ota_wake_component_ = component; } - /// Mark the registered OTA component (if any) for loop re-enable from any - /// context. Intentionally does NOT call wake_loop_any_context() — every - /// caller (lwip fast-select callback, raw-tcp accept callback, host - /// select() return path) has already woken the main loop, so a second wake - /// here would be redundant. Application is a friend of Component, so we - /// set the pending-enable flags directly. - void IRAM_ATTR wake_ota_component_any_context() { + /// Mark the registered OTA component (if any) for loop re-enable. Intentionally + /// does NOT call wake_loop_any_context() — every caller (lwip fast-select + /// callback, raw-tcp accept callback, host select() return path) has already + /// woken the main loop, so a second wake here would be redundant. Application + /// is a friend of Component, so we set the pending-enable flags directly. + /// Not IRAM_ATTR: all callers run in task / user-IRQ context, not a real ISR, + /// and the LwIP event callbacks that invoke this are not IRAM-resident either. + void wake_ota_component_any_context() { if (this->ota_wake_component_ != nullptr) { this->ota_wake_component_->pending_enable_loop_ = true; this->has_pending_enable_loop_requests_ = true; From 903a159344cc3f2a3c9366462a13c79524e88dbe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 14:52:12 -1000 Subject: [PATCH 0044/2214] [esphome.ota] Fix loop() docstring to reflect cleanup no longer disables --- .../components/esphome/ota/ota_esphome.cpp | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 08ec4dba66..3b8c5c0ce8 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -87,13 +87,20 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // loop() is disabled while idle (see setup() / cleanup_connection_()). Socket-wake - // paths (LwIP fast select, raw TCP accept, host select) call - // App.wake_ota_component_any_context() to re-enable this loop when a monitored - // socket signals activity. False wakes (e.g. an API-socket event) land here with - // no pending work — in that case we disable the loop again and go back to sleep. - // Note: No need to check server_ for null as the component is marked failed in - // setup() if server_ creation fails. + // loop() starts disabled while idle (setup() calls disable_loop()). Socket-wake + // paths (LwIP fast select, raw TCP accept, host select) mark us pending-enable + // via App.wake_ota_component_any_context() when a monitored socket signals + // activity; enable_pending_loops_() then re-activates us. False wakes (e.g. an + // API-socket event on an unrelated monitored socket) land here with no pending + // work, and this early-return disables the loop again. + // + // cleanup_connection_() deliberately does NOT call disable_loop() — letting + // loop() run one more iteration after a session ends guarantees we re-read + // server_->ready() and either accept a client queued during the session or + // disable cleanly here. + // + // Note: No need to check server_ for null — setup() marks the component failed + // if server_ creation fails. if (this->client_ == nullptr && !this->server_->ready()) { this->disable_loop(); return; From ffbd0dcbfcdc13a655f8af890647a3303f49967f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 15:41:57 -1000 Subject: [PATCH 0045/2214] [esphome.ota] Set pending-enable flags before main-loop wake (fix race) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wake-hook call (esphome_wake_ota_component_any_context / App.wake_ota_component_any_context) was placed AFTER xTaskNotifyGive()/wake_loop_any_context() in both the fast-select callback and the raw-TCP accept callback. That opened a race: the main task could wake, run a full iteration (draining has_pending_enable_loop_requests_), and finish before the flag-set ran — losing the pending-enable request until the next unrelated socket event happened to re-trigger the path. Swap the order so the pending-enable flags are set first, then the main task is woken. The main-loop iteration triggered by the wake is now guaranteed to see the pending request. Note: host's yield_with_select_ path already sets and consumes the flag on the main thread with no cross-task wake in between, so it has no race and is unchanged. --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 12 ++++++------ esphome/core/application.cpp | 7 ++++--- esphome/core/lwip_fast_select.c | 13 +++++++------ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 3d1679553e..cfd8cf0daa 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -857,16 +857,16 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn); tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); - // Wake the main loop immediately so it can accept the new connection. - esphome::wake_loop_any_context(); #ifdef USE_OTA // Mark the OTA component loop to be re-enabled if it disabled itself while idle. - // This only sets pending-enable flags; the wake_loop_any_context() call above has - // already woken the main loop, which will process the pending enable on its next - // iteration. Safe to call from RP2040's low-priority user IRQ context — it only - // writes volatile bools, no heap or locks. + // This MUST happen before wake_loop_any_context() below — otherwise the main loop + // could wake, run a full iteration, and finish before we set the pending-enable + // flags, losing the wake event. Safe from RP2040's low-priority user IRQ context: + // it only writes volatile bools, no heap or locks. esphome::App.wake_ota_component_any_context(); #endif + // Wake the main loop immediately so it can accept the new connection. + esphome::wake_loop_any_context(); return ERR_OK; } diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 9b7091f59a..9311b9fbf0 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -450,9 +450,10 @@ void Application::enable_pending_loops_() { } #if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) -// Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS. -// Only marks the OTA component as pending loop-enable; the fast-select callback itself has -// already woken the main task via xTaskNotifyGive(). +// Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS, +// BEFORE the callback calls xTaskNotifyGive() — the flag-set must happen before the wake, +// otherwise the main task could wake, run a full iteration, and miss the pending-enable. +// Only marks the OTA component as pending loop-enable; does not itself wake the main task. extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 32b79f288d..96eddf00ff 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -180,16 +180,17 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { +#ifdef USE_OTA + // Mark the OTA component loop to be re-enabled if it disabled itself while idle. + // This MUST happen before xTaskNotifyGive below — otherwise the main task could + // wake, run a full iteration, and finish before we set the pending-enable flags, + // causing the wake event to be lost until the next unrelated socket activity. + esphome_wake_ota_component_any_context(); +#endif TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); } -#ifdef USE_OTA - // Mark the OTA component loop to be re-enabled if it disabled itself while idle. - // Only sets pending-enable flags — the xTaskNotifyGive above has already woken - // the main task, which will process the pending enable on its next iteration. - esphome_wake_ota_component_any_context(); -#endif } } From ae54f3e071b60d1f0a0c67833b8ade3fa5c7cb6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 15:42:32 -1000 Subject: [PATCH 0046/2214] [esphome.ota] Drop explicit disable_loop() from setup, let loop() self-disable --- .../components/esphome/ota/ota_esphome.cpp | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3b8c5c0ce8..566d2f5ef0 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -66,11 +66,9 @@ void ESPHomeOTAComponent::setup() { return; } - // Disable loop() while idle. Socket wake paths (LwIP fast select, raw TCP accept - // callback, host select) call App.wake_ota_component_any_context() which re-enables - // this component's loop when an incoming connection arrives. + // Register for socket wake notifications. loop() disables itself on its first + // idle tick — no need to disable_loop() here explicitly. App.set_ota_wake_component(this); - this->disable_loop(); } void ESPHomeOTAComponent::dump_config() { @@ -87,17 +85,21 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // loop() starts disabled while idle (setup() calls disable_loop()). Socket-wake - // paths (LwIP fast select, raw TCP accept, host select) mark us pending-enable - // via App.wake_ota_component_any_context() when a monitored socket signals - // activity; enable_pending_loops_() then re-activates us. False wakes (e.g. an - // API-socket event on an unrelated monitored socket) land here with no pending - // work, and this early-return disables the loop again. + // Self-disabling idle loop. On the first tick after setup() (and after every + // session cleanup and after every false wake), if there's no client and the + // listener has nothing queued, we disable ourselves and go back to sleep. + // Socket-wake paths (LwIP fast select, raw TCP accept, host select) mark us + // pending-enable via App.wake_ota_component_any_context() when a monitored + // socket signals activity, and enable_pending_loops_() reactivates us. + // + // False wakes from unrelated monitored sockets are expected — the event + // callbacks fire on every RCVPLUS across all monitored sockets, not just + // OTA's listener — and they land here with no pending work. // // cleanup_connection_() deliberately does NOT call disable_loop() — letting // loop() run one more iteration after a session ends guarantees we re-read - // server_->ready() and either accept a client queued during the session or - // disable cleanly here. + // server_->ready() and either accept a client that queued during the session + // or disable cleanly here. // // Note: No need to check server_ for null — setup() marks the component failed // if server_ creation fails. From 06285dbb75a04edfd273d756e7efa26b41d00a37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 15:43:10 -1000 Subject: [PATCH 0047/2214] [esphome.ota] Comment host wake path as currently dead code --- esphome/core/application.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 9311b9fbf0..5fc794d1f6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -563,8 +563,10 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret == 0: timeout occurred - normal and expected if (ret >= 0) [[likely]] { #ifdef USE_OTA - // A socket is ready; re-enable the OTA component loop if it disabled itself while idle. - // The wake is a no-op if OTA didn't register or is already active. + // Dead code today — host does not currently support the esphome OTA platform, + // so ota_wake_component_ is never set and this call is a no-op. Kept so the + // wake path works out of the box if host ever gains OTA support. Harmless + // cost: a single nullptr check per select() return. if (ret > 0) { this->wake_ota_component_any_context(); } From f92c745ae9fb53a05d334dd6f0a1fa242438265d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:01:07 -1000 Subject: [PATCH 0048/2214] [esphome.ota] TEMP: debug logging to diagnose wake/accept race --- esphome/components/esphome/ota/ota_esphome.cpp | 14 ++++++++++++-- esphome/core/application.h | 7 ++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 566d2f5ef0..4109fe6cf1 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -69,6 +69,7 @@ void ESPHomeOTAComponent::setup() { // Register for socket wake notifications. loop() disables itself on its first // idle tick — no need to disable_loop() here explicitly. App.set_ota_wake_component(this); + ESP_LOGD(TAG, "setup complete: registered wake component, listener fd ready"); } void ESPHomeOTAComponent::dump_config() { @@ -103,7 +104,12 @@ void ESPHomeOTAComponent::loop() { // // Note: No need to check server_ for null — setup() marks the component failed // if server_ creation fails. - if (this->client_ == nullptr && !this->server_->ready()) { + // DEBUG: trace every loop tick while we investigate a wake/accept race. + const uint32_t wake_count = App.ota_wake_count_debug(); + const bool ready = this->server_->ready(); + ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u", (void *) this->client_.get(), ready, wake_count); + if (this->client_ == nullptr && !ready) { + ESP_LOGD(TAG, "loop tick: idle, disabling"); this->disable_loop(); return; } @@ -126,9 +132,13 @@ void ESPHomeOTAComponent::handle_handshake_() { socklen_t addr_len = sizeof(source_addr); int enable = 1; + ESP_LOGD(TAG, "handle_handshake_: attempting accept"); this->client_ = this->server_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); - if (this->client_ == nullptr) + if (this->client_ == nullptr) { + ESP_LOGD(TAG, "handle_handshake_: accept returned null (would-block)"); return; + } + ESP_LOGD(TAG, "handle_handshake_: accept ok, client=%p", (void *) this->client_.get()); int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); if (err != 0) { this->log_socket_error_(LOG_STR("nodelay")); diff --git a/esphome/core/application.h b/esphome/core/application.h index 67ef1d666f..185991a37a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -572,11 +572,15 @@ class Application { /// Not IRAM_ATTR: all callers run in task / user-IRQ context, not a real ISR, /// and the LwIP event callbacks that invoke this are not IRAM-resident either. void wake_ota_component_any_context() { + this->ota_wake_count_debug_++; if (this->ota_wake_component_ != nullptr) { this->ota_wake_component_->pending_enable_loop_ = true; this->has_pending_enable_loop_requests_ = true; } } + /// DEBUG: monotonically increasing count of wake calls, regardless of whether + /// ota_wake_component_ was set. Read from OTA::loop() to verify the hook fires. + uint32_t ota_wake_count_debug() const { return this->ota_wake_count_debug_; } #endif protected: @@ -655,7 +659,8 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; #ifdef USE_OTA - Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications + Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications + volatile uint32_t ota_wake_count_debug_{0}; // DEBUG: incremented by wake_ota_component_any_context #endif // std::vector (3 pointers each: begin, end, capacity) From e820d485e242eb3948ff2f07f36785840905bf33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:08:00 -1000 Subject: [PATCH 0049/2214] [esphome.ota] TEMP: keep loop enabled to trace wake/ready flow --- esphome/components/esphome/ota/ota_esphome.cpp | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 4109fe6cf1..dac8b6e1c7 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -104,14 +104,20 @@ void ESPHomeOTAComponent::loop() { // // Note: No need to check server_ for null — setup() marks the component failed // if server_ creation fails. - // DEBUG: trace every loop tick while we investigate a wake/accept race. + // DEBUG: self-disable removed. Log every tick with wake counter + ready state so + // we can tell (a) whether the lwip listener wake hook ever fires and (b) whether + // server_->ready() ever flips to true when a client tries to connect. const uint32_t wake_count = App.ota_wake_count_debug(); const bool ready = this->server_->ready(); - ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u", (void *) this->client_.get(), ready, wake_count); + static uint32_t last_wake_count = 0; + static bool last_ready = false; + if (wake_count != last_wake_count || ready != last_ready || this->client_ != nullptr) { + ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u", (void *) this->client_.get(), ready, wake_count); + last_wake_count = wake_count; + last_ready = ready; + } if (this->client_ == nullptr && !ready) { - ESP_LOGD(TAG, "loop tick: idle, disabling"); - this->disable_loop(); - return; + return; // stay in LOOP state so we can poll every tick for diagnosis } this->handle_handshake_(); } From 9c709869090dc8aefeab300850dfeaf8ea30f43d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:13:56 -1000 Subject: [PATCH 0050/2214] [esphome.ota] TEMP: log server_->ready() after setup + add global rcvplus counter --- .../components/esphome/ota/ota_esphome.cpp | 27 +++++++++++++++---- esphome/core/lwip_fast_select.c | 6 +++++ esphome/core/lwip_fast_select.h | 3 +++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index dac8b6e1c7..fb583e6d7a 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -15,6 +15,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_LWIP_FAST_SELECT +#include "esphome/core/lwip_fast_select.h" +#endif #include #include @@ -34,6 +37,13 @@ void ESPHomeOTAComponent::setup() { this->server_failed_(LOG_STR("creation")); return; } + // DEBUG: immediately after socket creation, ready() on an idle monitored socket + // MUST return false. If it returns true, loop_monitored_ is false — meaning + // App.register_socket() failed (likely because esphome_lwip_get_sock returned null + // because the socket fd is outside the lwip socket table range), and our wake hook + // was never installed on this socket. In that case the whole disable_loop+wake + // approach silently degrades — ready() stays true forever and we poll every tick. + ESP_LOGD(TAG, "setup: server_->ready() immediately after socket creation = %d (expect 0)", this->server_->ready()); int enable = 1; int err = this->server_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { @@ -104,16 +114,23 @@ void ESPHomeOTAComponent::loop() { // // Note: No need to check server_ for null — setup() marks the component failed // if server_ creation fails. - // DEBUG: self-disable removed. Log every tick with wake counter + ready state so - // we can tell (a) whether the lwip listener wake hook ever fires and (b) whether - // server_->ready() ever flips to true when a client tries to connect. + // DEBUG: self-disable removed; always poll so we can see ready/wake state. const uint32_t wake_count = App.ota_wake_count_debug(); +#ifdef USE_LWIP_FAST_SELECT + const uint32_t total_rcvplus = esphome_fast_select_rcvplus_total_debug; +#else + const uint32_t total_rcvplus = 0; +#endif const bool ready = this->server_->ready(); static uint32_t last_wake_count = 0; + static uint32_t last_total_rcvplus = 0; static bool last_ready = false; - if (wake_count != last_wake_count || ready != last_ready || this->client_ != nullptr) { - ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u", (void *) this->client_.get(), ready, wake_count); + if (wake_count != last_wake_count || total_rcvplus != last_total_rcvplus || ready != last_ready || + this->client_ != nullptr) { + ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u total_rcvplus=%u", (void *) this->client_.get(), ready, + wake_count, total_rcvplus); last_wake_count = wake_count; + last_total_rcvplus = total_rcvplus; last_ready = ready; } if (this->client_ == nullptr && !ready) { diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 96eddf00ff..a3d9898757 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,11 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +// DEBUG: counts every RCVPLUS the wrapper callback observes, for any monitored socket. +// Read by OTA's debug logging to distinguish "callback not firing at all" from +// "callback fires for other sockets but not OTA's listener". +volatile uint32_t esphome_fast_select_rcvplus_total_debug = 0; + #ifdef USE_OTA // Extern wake hook for the OTA component (implemented in application.cpp). Called from the // TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false @@ -180,6 +185,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { + esphome_fast_select_rcvplus_total_debug++; // DEBUG #ifdef USE_OTA // Mark the OTA component loop to be re-enabled if it disabled itself while idle. // This MUST happen before xTaskNotifyGive below — otherwise the main task could diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 20ac191673..6ed2704ef0 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,6 +53,9 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); +// DEBUG counter: total RCVPLUS events the wrapper callback observed across all monitored sockets. +extern volatile uint32_t esphome_fast_select_rcvplus_total_debug; + /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, From 08c09fbe367a85e765e6675615ee3545807f0dc0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:20:03 -1000 Subject: [PATCH 0051/2214] [esphome.ota] TEMP: add shim call counter to pinpoint C->C++ wake break --- esphome/components/esphome/ota/ota_esphome.cpp | 12 ++++++++---- esphome/core/application.cpp | 10 +++++++++- esphome/core/lwip_fast_select.h | 4 ++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fb583e6d7a..3367696ddd 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -118,19 +118,23 @@ void ESPHomeOTAComponent::loop() { const uint32_t wake_count = App.ota_wake_count_debug(); #ifdef USE_LWIP_FAST_SELECT const uint32_t total_rcvplus = esphome_fast_select_rcvplus_total_debug; + const uint32_t shim_count = esphome_ota_shim_call_count_debug; #else const uint32_t total_rcvplus = 0; + const uint32_t shim_count = 0; #endif const bool ready = this->server_->ready(); static uint32_t last_wake_count = 0; static uint32_t last_total_rcvplus = 0; + static uint32_t last_shim_count = 0; static bool last_ready = false; - if (wake_count != last_wake_count || total_rcvplus != last_total_rcvplus || ready != last_ready || - this->client_ != nullptr) { - ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u total_rcvplus=%u", (void *) this->client_.get(), ready, - wake_count, total_rcvplus); + if (wake_count != last_wake_count || total_rcvplus != last_total_rcvplus || shim_count != last_shim_count || + ready != last_ready || this->client_ != nullptr) { + ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u shim=%u total_rcvplus=%u", (void *) this->client_.get(), + ready, wake_count, shim_count, total_rcvplus); last_wake_count = wake_count; last_total_rcvplus = total_rcvplus; + last_shim_count = shim_count; last_ready = ready; } if (this->client_ == nullptr && !ready) { diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 5fc794d1f6..46f8922bc6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -450,11 +450,19 @@ void Application::enable_pending_loops_() { } #if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) +// DEBUG: directly-incremented C counter so we can tell whether the shim is being called at all, +// independent of whether the App.wake_ota_component_any_context() call inside it is working. +extern "C" { +volatile uint32_t esphome_ota_shim_call_count_debug = 0; +} // Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS, // BEFORE the callback calls xTaskNotifyGive() — the flag-set must happen before the wake, // otherwise the main task could wake, run a full iteration, and miss the pending-enable. // Only marks the OTA component as pending loop-enable; does not itself wake the main task. -extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } +extern "C" void esphome_wake_ota_component_any_context() { + esphome_ota_shim_call_count_debug++; + esphome::App.wake_ota_component_any_context(); +} #endif #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 6ed2704ef0..f152106f0e 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -56,6 +56,10 @@ void esphome_lwip_hook_socket(struct lwip_sock *sock); // DEBUG counter: total RCVPLUS events the wrapper callback observed across all monitored sockets. extern volatile uint32_t esphome_fast_select_rcvplus_total_debug; +// DEBUG counter: number of times esphome_wake_ota_component_any_context() C shim was entered. +// Independent of whether App.wake_ota_component_any_context() inside it actually ran. +extern volatile uint32_t esphome_ota_shim_call_count_debug; + /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, From 4630c7ad945c68bc5e3e1da9027a5817f7acb901 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:28:30 -1000 Subject: [PATCH 0052/2214] [ota] Emit USE_OTA as build flag + define so .c files can see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the wake failure: ota/__init__.py only called cg.add_define("USE_OTA"), which writes to the generated defines.h. That's invisible to .c translation units that can't include defines.h — notably lwip_fast_select.c, which can't include defines.h because macros.h → Arduino.h under Arduino builds would break the C compile. So the #ifdef USE_OTA gate inside lwip_fast_select.c's RCVPLUS callback was always false, and esphome_wake_ota_component_any_context() was never called. The listener callback fired (total rcvplus counter incremented from the device), but OTA's pending-enable flag was never set, so the main task woke and ran a loop iteration without touching the (disabled) OTA component. Fix: emit both cg.add_define (keeps defines.h in sync for static analyzers / IDEs) and cg.add_build_flag("-DUSE_OTA") (passes it as a compiler -D flag, visible to every .c TU). Also reverts all the diagnostic scaffolding (per-loop-tick logs, debug counters, runtime function pointer hook) that I added while chasing this. --- .../components/esphome/ota/ota_esphome.cpp | 74 +++++-------------- esphome/components/ota/__init__.py | 6 ++ esphome/core/application.cpp | 16 ++-- esphome/core/application.h | 7 +- esphome/core/lwip_fast_select.c | 19 ++--- esphome/core/lwip_fast_select.h | 7 -- 6 files changed, 37 insertions(+), 92 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3367696ddd..e2b12e1208 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -15,9 +15,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" -#ifdef USE_LWIP_FAST_SELECT -#include "esphome/core/lwip_fast_select.h" -#endif #include #include @@ -37,13 +34,6 @@ void ESPHomeOTAComponent::setup() { this->server_failed_(LOG_STR("creation")); return; } - // DEBUG: immediately after socket creation, ready() on an idle monitored socket - // MUST return false. If it returns true, loop_monitored_ is false — meaning - // App.register_socket() failed (likely because esphome_lwip_get_sock returned null - // because the socket fd is outside the lwip socket table range), and our wake hook - // was never installed on this socket. In that case the whole disable_loop+wake - // approach silently degrades — ready() stays true forever and we poll every tick. - ESP_LOGD(TAG, "setup: server_->ready() immediately after socket creation = %d (expect 0)", this->server_->ready()); int enable = 1; int err = this->server_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { @@ -79,7 +69,6 @@ void ESPHomeOTAComponent::setup() { // Register for socket wake notifications. loop() disables itself on its first // idle tick — no need to disable_loop() here explicitly. App.set_ota_wake_component(this); - ESP_LOGD(TAG, "setup complete: registered wake component, listener fd ready"); } void ESPHomeOTAComponent::dump_config() { @@ -96,49 +85,26 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Self-disabling idle loop. On the first tick after setup() (and after every - // session cleanup and after every false wake), if there's no client and the - // listener has nothing queued, we disable ourselves and go back to sleep. - // Socket-wake paths (LwIP fast select, raw TCP accept, host select) mark us - // pending-enable via App.wake_ota_component_any_context() when a monitored - // socket signals activity, and enable_pending_loops_() reactivates us. + // Self-disabling idle loop. On the first tick after setup() (and after every session + // cleanup and every false wake), if there's no client and the listener has nothing + // queued, we disable ourselves and go back to sleep. Socket-wake paths (LwIP fast + // select, raw TCP accept, host select) mark us pending-enable via + // App.wake_ota_component_any_context() when a monitored socket signals activity, and + // enable_pending_loops_() reactivates us. // - // False wakes from unrelated monitored sockets are expected — the event - // callbacks fire on every RCVPLUS across all monitored sockets, not just - // OTA's listener — and they land here with no pending work. + // False wakes from unrelated monitored sockets are expected — the event callbacks + // fire on every RCVPLUS across all monitored sockets, not just OTA's listener — and + // they land here with no pending work. // - // cleanup_connection_() deliberately does NOT call disable_loop() — letting - // loop() run one more iteration after a session ends guarantees we re-read - // server_->ready() and either accept a client that queued during the session - // or disable cleanly here. + // cleanup_connection_() deliberately does NOT call disable_loop() — letting loop() + // run one more iteration after a session ends guarantees we re-read server_->ready() + // and either accept a client that queued during the session or disable cleanly here. // - // Note: No need to check server_ for null — setup() marks the component failed - // if server_ creation fails. - // DEBUG: self-disable removed; always poll so we can see ready/wake state. - const uint32_t wake_count = App.ota_wake_count_debug(); -#ifdef USE_LWIP_FAST_SELECT - const uint32_t total_rcvplus = esphome_fast_select_rcvplus_total_debug; - const uint32_t shim_count = esphome_ota_shim_call_count_debug; -#else - const uint32_t total_rcvplus = 0; - const uint32_t shim_count = 0; -#endif - const bool ready = this->server_->ready(); - static uint32_t last_wake_count = 0; - static uint32_t last_total_rcvplus = 0; - static uint32_t last_shim_count = 0; - static bool last_ready = false; - if (wake_count != last_wake_count || total_rcvplus != last_total_rcvplus || shim_count != last_shim_count || - ready != last_ready || this->client_ != nullptr) { - ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u shim=%u total_rcvplus=%u", (void *) this->client_.get(), - ready, wake_count, shim_count, total_rcvplus); - last_wake_count = wake_count; - last_total_rcvplus = total_rcvplus; - last_shim_count = shim_count; - last_ready = ready; - } - if (this->client_ == nullptr && !ready) { - return; // stay in LOOP state so we can poll every tick for diagnosis + // Note: No need to check server_ for null — setup() marks the component failed if + // server_ creation fails. + if (this->client_ == nullptr && !this->server_->ready()) { + this->disable_loop(); + return; } this->handle_handshake_(); } @@ -159,13 +125,9 @@ void ESPHomeOTAComponent::handle_handshake_() { socklen_t addr_len = sizeof(source_addr); int enable = 1; - ESP_LOGD(TAG, "handle_handshake_: attempting accept"); this->client_ = this->server_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); - if (this->client_ == nullptr) { - ESP_LOGD(TAG, "handle_handshake_: accept returned null (would-block)"); + if (this->client_ == nullptr) return; - } - ESP_LOGD(TAG, "handle_handshake_: accept ok, client=%p", (void *) this->client_.get()); int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); if (err != 0) { this->log_socket_error_(LOG_STR("nodelay")); diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 8f31eb5cdd..e7a362ca10 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -102,7 +102,13 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): + # Both: add_define keeps defines.h in sync for static analyzers / IDEs that read it, + # while add_build_flag passes -DUSE_OTA as a compiler flag so USE_OTA is visible in .c + # translation units that cannot include defines.h (lwip_fast_select.c in particular — + # including defines.h would drag in macros.h → Arduino.h under Arduino builds and + # break the C compile). Needed for the fast-select OTA wake hook. cg.add_define("USE_OTA") + cg.add_build_flag("-DUSE_OTA") CORE.add_job(final_step) if CORE.is_rp2040 and CORE.using_arduino: diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 46f8922bc6..d99b7f28d9 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -450,19 +450,13 @@ void Application::enable_pending_loops_() { } #if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) -// DEBUG: directly-incremented C counter so we can tell whether the shim is being called at all, -// independent of whether the App.wake_ota_component_any_context() call inside it is working. -extern "C" { -volatile uint32_t esphome_ota_shim_call_count_debug = 0; -} // Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS, -// BEFORE the callback calls xTaskNotifyGive() — the flag-set must happen before the wake, -// otherwise the main task could wake, run a full iteration, and miss the pending-enable. +// BEFORE that callback calls xTaskNotifyGive() — pending-enable flags must be visible before +// the main task wakes, or the main loop can run a full iteration without seeing the request. // Only marks the OTA component as pending loop-enable; does not itself wake the main task. -extern "C" void esphome_wake_ota_component_any_context() { - esphome_ota_shim_call_count_debug++; - esphome::App.wake_ota_component_any_context(); -} +// OTA's __init__.py emits USE_OTA as both a cg.add_define (for static analyzers) AND a +// cg.add_build_flag (so the .c fast-select file also sees it). +extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/core/application.h b/esphome/core/application.h index 185991a37a..67ef1d666f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -572,15 +572,11 @@ class Application { /// Not IRAM_ATTR: all callers run in task / user-IRQ context, not a real ISR, /// and the LwIP event callbacks that invoke this are not IRAM-resident either. void wake_ota_component_any_context() { - this->ota_wake_count_debug_++; if (this->ota_wake_component_ != nullptr) { this->ota_wake_component_->pending_enable_loop_ = true; this->has_pending_enable_loop_requests_ = true; } } - /// DEBUG: monotonically increasing count of wake calls, regardless of whether - /// ota_wake_component_ was set. Read from OTA::loop() to verify the hook fires. - uint32_t ota_wake_count_debug() const { return this->ota_wake_count_debug_; } #endif protected: @@ -659,8 +655,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; #ifdef USE_OTA - Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications - volatile uint32_t ota_wake_count_debug_{0}; // DEBUG: incremented by wake_ota_component_any_context + Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications #endif // std::vector (3 pointers each: begin, end, capacity) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index a3d9898757..ab20bdd29e 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,17 +157,15 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; -// DEBUG: counts every RCVPLUS the wrapper callback observes, for any monitored socket. -// Read by OTA's debug logging to distinguish "callback not firing at all" from -// "callback fires for other sockets but not OTA's listener". -volatile uint32_t esphome_fast_select_rcvplus_total_debug = 0; - #ifdef USE_OTA // Extern wake hook for the OTA component (implemented in application.cpp). Called from the // TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false // wake from an unrelated monitored socket. OTA::loop() handles that by disabling itself again // when there is no pending work. The hook only marks the OTA component as pending loop-enable; // it does not itself wake the main task (the caller below already does that). +// NOTE: USE_OTA reaches this file only because ota/__init__.py adds it as a build flag +// (not a cg.add_define). defines.h cannot be included from this .c file (it pulls in +// macros.h → Arduino.h under Arduino builds). extern void esphome_wake_ota_component_any_context(void); #endif @@ -185,14 +183,11 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { - esphome_fast_select_rcvplus_total_debug++; // DEBUG -#ifdef USE_OTA - // Mark the OTA component loop to be re-enabled if it disabled itself while idle. - // This MUST happen before xTaskNotifyGive below — otherwise the main task could - // wake, run a full iteration, and finish before we set the pending-enable flags, - // causing the wake event to be lost until the next unrelated socket activity. + // Invoke the OTA wake hook BEFORE xTaskNotifyGive — if OTA is compiled in, this marks + // its component pending-enable, and those flags must be visible before we wake the + // main task. Otherwise the main loop could run a full iteration without seeing the + // pending-enable request. When OTA is not compiled in, this function's body is empty. esphome_wake_ota_component_any_context(); -#endif TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index f152106f0e..20ac191673 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,13 +53,6 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); -// DEBUG counter: total RCVPLUS events the wrapper callback observed across all monitored sockets. -extern volatile uint32_t esphome_fast_select_rcvplus_total_debug; - -// DEBUG counter: number of times esphome_wake_ota_component_any_context() C shim was entered. -// Independent of whether App.wake_ota_component_any_context() inside it actually ran. -extern volatile uint32_t esphome_ota_shim_call_count_debug; - /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, From 0480f43984f51a3a0d60831f0d6830ca3d981e91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:30:22 -1000 Subject: [PATCH 0053/2214] [ota] Use ESPHOME_USE_OTA build flag to avoid USE_OTA redefinition warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emitting -DUSE_OTA alongside the defines.h #define USE_OTA entry caused 'USE_OTA redefined' warnings in every TU that includes defines.h. Use a distinct ESPHOME_USE_OTA name for the compiler -D flag — only the .c files that cannot include defines.h (lwip_fast_select.c) reference the ESPHOME_-prefixed name, and everyone else continues to use USE_OTA via defines.h. --- esphome/components/ota/__init__.py | 12 ++++++------ esphome/core/lwip_fast_select.c | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index e7a362ca10..3abfd2c69e 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -102,13 +102,13 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): - # Both: add_define keeps defines.h in sync for static analyzers / IDEs that read it, - # while add_build_flag passes -DUSE_OTA as a compiler flag so USE_OTA is visible in .c - # translation units that cannot include defines.h (lwip_fast_select.c in particular — - # including defines.h would drag in macros.h → Arduino.h under Arduino builds and - # break the C compile). Needed for the fast-select OTA wake hook. cg.add_define("USE_OTA") - cg.add_build_flag("-DUSE_OTA") + # Separate compiler -D flag using an ESPHOME_-prefixed name so .c translation units + # (which cannot include defines.h because macros.h → Arduino.h breaks the C compile + # under Arduino builds) can still tell OTA is compiled in. Needed by the fast-select + # OTA wake hook in lwip_fast_select.c. A distinct name avoids the "USE_OTA redefined" + # warning that would fire if we also emitted -DUSE_OTA — defines.h already has it. + cg.add_build_flag("-DESPHOME_USE_OTA") CORE.add_job(final_step) if CORE.is_rp2040 and CORE.using_arduino: diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index ab20bdd29e..613d1f29ec 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,15 +157,16 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; -#ifdef USE_OTA +#ifdef ESPHOME_USE_OTA // Extern wake hook for the OTA component (implemented in application.cpp). Called from the // TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false // wake from an unrelated monitored socket. OTA::loop() handles that by disabling itself again // when there is no pending work. The hook only marks the OTA component as pending loop-enable; // it does not itself wake the main task (the caller below already does that). -// NOTE: USE_OTA reaches this file only because ota/__init__.py adds it as a build flag -// (not a cg.add_define). defines.h cannot be included from this .c file (it pulls in -// macros.h → Arduino.h under Arduino builds). +// NOTE: ESPHOME_USE_OTA (not USE_OTA) because USE_OTA only lives in defines.h, and this .c +// file cannot include defines.h — macros.h → Arduino.h would break the C compile under +// Arduino builds. ota/__init__.py emits -DESPHOME_USE_OTA as a build flag specifically so +// this file can see it without a name collision with the defines.h USE_OTA entry. extern void esphome_wake_ota_component_any_context(void); #endif @@ -183,11 +184,12 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { - // Invoke the OTA wake hook BEFORE xTaskNotifyGive — if OTA is compiled in, this marks - // its component pending-enable, and those flags must be visible before we wake the - // main task. Otherwise the main loop could run a full iteration without seeing the - // pending-enable request. When OTA is not compiled in, this function's body is empty. +#ifdef ESPHOME_USE_OTA + // Mark the OTA component pending-enable BEFORE xTaskNotifyGive — the flags must be + // visible before we wake the main task, otherwise the main loop could run a full + // iteration without seeing the pending-enable request. esphome_wake_ota_component_any_context(); +#endif TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); From 1fe2588c08acdfe329d8bc60bb156ff8b730fe02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:56:48 -1000 Subject: [PATCH 0054/2214] [esphome.ota] Inline fast-select wake hook via ota_wake_hook.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extern C shim esphome_wake_ota_component_any_context() was an out-of-line call from lwip_fast_select.c into application.cpp: save registers, call, prologue, two stores, epilogue, ret. Per-RCVPLUS, that's ~10-15 Xtensa cycles of pure call overhead on top of the two volatile bool stores the shim actually does. Move the body into a new C-compatible header (esphome/core/ota_wake_hook.h) as a static inline, backed by two extern C 'volatile bool *' globals that point at Component::pending_enable_loop_ and Application::has_pending_enable_loop_requests_. set_ota_wake_component() captures the addresses once at registration time; the fast-select callback then inlines a null-check + two volatile stores with zero call overhead. The main loop sees the same two flags it already checks every iteration (has_pending_enable_loop_requests_ gates enable_pending_loops_, which iterates the inactive section looking for components with pending_enable_loop_ set). Zero new main-loop work — the inline hook writes exactly the state enable_loop_soon_any_context() would have written. RAM change: -4 bytes on Application (ota_wake_component_ field removed) plus +8 bytes in BSS for the two extern pointers. Net +4 bytes RAM. Raw-TCP and HOST paths switched from App.wake_ota_component_any_context() to the inline hook too. --- .../components/socket/lwip_raw_tcp_impl.cpp | 8 ++-- esphome/core/application.cpp | 36 ++++++++++----- esphome/core/application.h | 28 ++++-------- esphome/core/lwip_fast_select.c | 3 ++ esphome/core/ota_wake_hook.h | 45 +++++++++++++++++++ 5 files changed, 84 insertions(+), 36 deletions(-) create mode 100644 esphome/core/ota_wake_hook.h diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index cfd8cf0daa..b35338a2bf 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -11,7 +11,7 @@ #include "esphome/core/wake.h" #include "esphome/core/log.h" #ifdef USE_OTA -#include "esphome/core/application.h" +#include "esphome/core/ota_wake_hook.h" #endif #ifdef USE_ESP8266 @@ -861,9 +861,9 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Mark the OTA component loop to be re-enabled if it disabled itself while idle. // This MUST happen before wake_loop_any_context() below — otherwise the main loop // could wake, run a full iteration, and finish before we set the pending-enable - // flags, losing the wake event. Safe from RP2040's low-priority user IRQ context: - // it only writes volatile bools, no heap or locks. - esphome::App.wake_ota_component_any_context(); + // flags, losing the wake event. Inline hook (ota_wake_hook.h) — two volatile stores, + // no function call. Safe from RP2040's low-priority user IRQ context. + esphome_wake_ota_component_any_context(); #endif // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d99b7f28d9..b2b2e0fdbe 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -2,6 +2,9 @@ #include "esphome/core/build_info_data.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" +#ifdef USE_OTA +#include "esphome/core/ota_wake_hook.h" +#endif #include #ifdef USE_ESP8266 @@ -449,14 +452,24 @@ void Application::enable_pending_loops_() { } } -#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) -// Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS, -// BEFORE that callback calls xTaskNotifyGive() — pending-enable flags must be visible before -// the main task wakes, or the main loop can run a full iteration without seeing the request. -// Only marks the OTA component as pending loop-enable; does not itself wake the main task. -// OTA's __init__.py emits USE_OTA as both a cg.add_define (for static analyzers) AND a -// cg.add_build_flag (so the .c fast-select file also sees it). -extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } +#ifdef USE_OTA +// Storage for the inline OTA wake hook (see esphome/core/ota_wake_hook.h). Set in +// Application::set_ota_wake_component() and read from the lwip fast-select callback on +// every NETCONN_EVT_RCVPLUS. Kept as raw C globals so the .c file can inline the wake +// body (two volatile stores) without a function-call round trip into application.cpp. +extern "C" { +volatile bool *esphome_ota_pending_enable_loop_ptr = nullptr; +volatile bool *esphome_ota_has_pending_requests_ptr = nullptr; +} + +void Application::set_ota_wake_component(Component *component) { + // Application is a friend of Component — can take the address of its protected + // pending_enable_loop_ field. The C-side inline hook writes through that pointer when + // any monitored socket fires RCVPLUS, making the flags visible to enable_pending_loops_() + // on the next main loop iteration. + esphome_ota_pending_enable_loop_ptr = &component->pending_enable_loop_; + esphome_ota_has_pending_requests_ptr = &this->has_pending_enable_loop_requests_; +} #endif #ifdef USE_LWIP_FAST_SELECT @@ -566,11 +579,10 @@ void Application::yield_with_select_(uint32_t delay_ms) { if (ret >= 0) [[likely]] { #ifdef USE_OTA // Dead code today — host does not currently support the esphome OTA platform, - // so ota_wake_component_ is never set and this call is a no-op. Kept so the - // wake path works out of the box if host ever gains OTA support. Harmless - // cost: a single nullptr check per select() return. + // so the inline hook's pointers are always NULL and this is a no-op. Kept so the + // wake path works out of the box if host ever gains OTA support. if (ret > 0) { - this->wake_ota_component_any_context(); + esphome_wake_ota_component_any_context(); } #endif // Yield if zero timeout since select(0) only polls without yielding diff --git a/esphome/core/application.h b/esphome/core/application.h index 67ef1d666f..48fc620cdc 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -560,23 +560,14 @@ class Application { static void IRAM_ATTR wake_loop_any_context() { esphome::wake_loop_any_context(); } #ifdef USE_OTA - /// Register the OTA component so socket-wake paths can enable its loop when - /// a new connection arrives on the listening socket. OTA disables its own - /// loop while idle to avoid per-tick dispatch overhead. - void set_ota_wake_component(Component *component) { this->ota_wake_component_ = component; } - /// Mark the registered OTA component (if any) for loop re-enable. Intentionally - /// does NOT call wake_loop_any_context() — every caller (lwip fast-select - /// callback, raw-tcp accept callback, host select() return path) has already - /// woken the main loop, so a second wake here would be redundant. Application - /// is a friend of Component, so we set the pending-enable flags directly. - /// Not IRAM_ATTR: all callers run in task / user-IRQ context, not a real ISR, - /// and the LwIP event callbacks that invoke this are not IRAM-resident either. - void wake_ota_component_any_context() { - if (this->ota_wake_component_ != nullptr) { - this->ota_wake_component_->pending_enable_loop_ = true; - this->has_pending_enable_loop_requests_ = true; - } - } + /// Register the OTA component so socket-wake paths can enable its loop when a new + /// connection arrives on the listening socket. Captures the address of the component's + /// pending_enable_loop_ flag and the Application has_pending_enable_loop_requests_ flag + /// into extern C globals consumed by the inline wake hook in ota_wake_hook.h. Defined + /// out-of-line in application.cpp so application.h doesn't need to pull in the hook + /// header. OTA calls this once from setup(); the component itself then self-disables + /// its loop on its first idle tick. + void set_ota_wake_component(Component *component); #endif protected: @@ -654,9 +645,6 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; -#ifdef USE_OTA - Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications -#endif // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 613d1f29ec..f0cafc2dbe 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -124,6 +124,9 @@ #include "esphome/core/lwip_fast_select.h" #include "esphome/core/main_task.h" +#ifdef ESPHOME_USE_OTA +#include "esphome/core/ota_wake_hook.h" +#endif #include diff --git a/esphome/core/ota_wake_hook.h b/esphome/core/ota_wake_hook.h new file mode 100644 index 0000000000..06ed1623df --- /dev/null +++ b/esphome/core/ota_wake_hook.h @@ -0,0 +1,45 @@ +#pragma once + +// Inline OTA wake hook, called from lwip_fast_select.c on every NETCONN_EVT_RCVPLUS so a +// disabled OTA loop can be re-enabled when a monitored socket signals activity. +// +// Defined as a static inline here (rather than an out-of-line extern "C" shim into +// application.cpp) so the fast-select callback pays zero function-call overhead per +// socket event: the two volatile stores below are cheaper inlined than dispatched. +// +// The two pointers are set once in Application::set_ota_wake_component() to the addresses +// of Component::pending_enable_loop_ and Application::has_pending_enable_loop_requests_. +// Accessing those C++ members by raw address is safe: Application is a friend of Component +// (granting access at registration time), and volatile writes through a bool* see the same +// storage the C++ side reads. + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Address of the registered OTA component's pending_enable_loop_ flag. NULL until +// Application::set_ota_wake_component() is called. When non-NULL, the has-pending +// pointer below is also non-NULL, so a single null check covers both. +extern volatile bool *esphome_ota_pending_enable_loop_ptr; +// Address of Application::has_pending_enable_loop_requests_. Set in tandem with the +// pending_enable pointer above. +extern volatile bool *esphome_ota_has_pending_requests_ptr; + +// Mark the registered OTA component pending loop-enable. Safe to call from LwIP TCP/IP +// task context and raw-TCP IRQ context — only writes to volatile bools, no locks. +// Callers must invoke this BEFORE waking the main task, so the flags are visible to the +// main loop's next iteration. +static inline void esphome_wake_ota_component_any_context(void) { + volatile bool *pending_enable = esphome_ota_pending_enable_loop_ptr; + if (pending_enable != NULL) { + *pending_enable = true; + *esphome_ota_has_pending_requests_ptr = true; + } +} + +#ifdef __cplusplus +} +#endif From 5f04cff8bc043aced5f91c1535663a19c30934b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:01:07 -1000 Subject: [PATCH 0055/2214] [esphome.ota] Fold OTA wake hook into wake.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All wake_* state lives in one place now. wake.h gains a C-compatible section at the top (the inline esphome_wake_ota_component_any_context() + its two extern 'volatile bool *' globals) guarded outside any C++ namespace, with the existing C++ platform wake primitives moved behind an outer #ifdef __cplusplus. lwip_fast_select.c includes wake.h directly for the inline; .cpp files continue to see the C++ side as before. Deletes the ephemeral esphome/core/ota_wake_hook.h — same code, better home. --- .../components/socket/lwip_raw_tcp_impl.cpp | 6 +- esphome/core/application.cpp | 17 +++--- esphome/core/application.h | 12 ++-- esphome/core/lwip_fast_select.c | 2 +- esphome/core/ota_wake_hook.h | 45 -------------- esphome/core/wake.h | 59 +++++++++++++++++++ 6 files changed, 78 insertions(+), 63 deletions(-) delete mode 100644 esphome/core/ota_wake_hook.h diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b35338a2bf..b6e5730b66 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -11,7 +11,7 @@ #include "esphome/core/wake.h" #include "esphome/core/log.h" #ifdef USE_OTA -#include "esphome/core/ota_wake_hook.h" +#include "esphome/core/wake.h" // inline esphome_wake_ota_component_any_context() lives here #endif #ifdef USE_ESP8266 @@ -861,8 +861,8 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Mark the OTA component loop to be re-enabled if it disabled itself while idle. // This MUST happen before wake_loop_any_context() below — otherwise the main loop // could wake, run a full iteration, and finish before we set the pending-enable - // flags, losing the wake event. Inline hook (ota_wake_hook.h) — two volatile stores, - // no function call. Safe from RP2040's low-priority user IRQ context. + // flags, losing the wake event. Inline hook (wake.h) — two volatile stores, no + // function call. Safe from RP2040's low-priority user IRQ context. esphome_wake_ota_component_any_context(); #endif // Wake the main loop immediately so it can accept the new connection. diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b2b2e0fdbe..21d3ef4494 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -2,9 +2,7 @@ #include "esphome/core/build_info_data.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" -#ifdef USE_OTA -#include "esphome/core/ota_wake_hook.h" -#endif +#include "esphome/core/wake.h" #include #ifdef USE_ESP8266 @@ -452,16 +450,19 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_OTA -// Storage for the inline OTA wake hook (see esphome/core/ota_wake_hook.h). Set in -// Application::set_ota_wake_component() and read from the lwip fast-select callback on -// every NETCONN_EVT_RCVPLUS. Kept as raw C globals so the .c file can inline the wake -// body (two volatile stores) without a function-call round trip into application.cpp. +// Storage for the inline OTA wake hook (see esphome/core/wake.h). Set in +// Application::set_ota_wake_component() and read from the lwip fast-select callback +// on every NETCONN_EVT_RCVPLUS. Kept as raw C globals so the .c file can inline the +// wake body (two volatile stores) without a function-call round trip. Defined +// unconditionally so wake.h's inline compiles the same whether or not OTA is in the +// build — when OTA is absent nothing ever calls set_ota_wake_component() and the +// pointers stay nullptr, collapsing the inline to a single null check. extern "C" { volatile bool *esphome_ota_pending_enable_loop_ptr = nullptr; volatile bool *esphome_ota_has_pending_requests_ptr = nullptr; } +#ifdef USE_OTA void Application::set_ota_wake_component(Component *component) { // Application is a friend of Component — can take the address of its protected // pending_enable_loop_ field. The C-side inline hook writes through that pointer when diff --git a/esphome/core/application.h b/esphome/core/application.h index 48fc620cdc..baf8e21a99 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -561,12 +561,12 @@ class Application { #ifdef USE_OTA /// Register the OTA component so socket-wake paths can enable its loop when a new - /// connection arrives on the listening socket. Captures the address of the component's - /// pending_enable_loop_ flag and the Application has_pending_enable_loop_requests_ flag - /// into extern C globals consumed by the inline wake hook in ota_wake_hook.h. Defined - /// out-of-line in application.cpp so application.h doesn't need to pull in the hook - /// header. OTA calls this once from setup(); the component itself then self-disables - /// its loop on its first idle tick. + /// connection arrives on the listening socket. Captures the addresses of the component's + /// pending_enable_loop_ flag and Application's has_pending_enable_loop_requests_ flag + /// into extern-C globals consumed by the inline wake hook in wake.h. Defined out-of-line + /// in application.cpp so application.h doesn't need to pull in wake.h. OTA calls this + /// once from setup(); the component itself then self-disables its loop on its first + /// idle tick. void set_ota_wake_component(Component *component); #endif diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index f0cafc2dbe..061fafbc51 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -125,7 +125,7 @@ #include "esphome/core/lwip_fast_select.h" #include "esphome/core/main_task.h" #ifdef ESPHOME_USE_OTA -#include "esphome/core/ota_wake_hook.h" +#include "esphome/core/wake.h" // inline esphome_wake_ota_component_any_context() lives here #endif #include diff --git a/esphome/core/ota_wake_hook.h b/esphome/core/ota_wake_hook.h deleted file mode 100644 index 06ed1623df..0000000000 --- a/esphome/core/ota_wake_hook.h +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -// Inline OTA wake hook, called from lwip_fast_select.c on every NETCONN_EVT_RCVPLUS so a -// disabled OTA loop can be re-enabled when a monitored socket signals activity. -// -// Defined as a static inline here (rather than an out-of-line extern "C" shim into -// application.cpp) so the fast-select callback pays zero function-call overhead per -// socket event: the two volatile stores below are cheaper inlined than dispatched. -// -// The two pointers are set once in Application::set_ota_wake_component() to the addresses -// of Component::pending_enable_loop_ and Application::has_pending_enable_loop_requests_. -// Accessing those C++ members by raw address is safe: Application is a friend of Component -// (granting access at registration time), and volatile writes through a bool* see the same -// storage the C++ side reads. - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -// Address of the registered OTA component's pending_enable_loop_ flag. NULL until -// Application::set_ota_wake_component() is called. When non-NULL, the has-pending -// pointer below is also non-NULL, so a single null check covers both. -extern volatile bool *esphome_ota_pending_enable_loop_ptr; -// Address of Application::has_pending_enable_loop_requests_. Set in tandem with the -// pending_enable pointer above. -extern volatile bool *esphome_ota_has_pending_requests_ptr; - -// Mark the registered OTA component pending loop-enable. Safe to call from LwIP TCP/IP -// task context and raw-TCP IRQ context — only writes to volatile bools, no locks. -// Callers must invoke this BEFORE waking the main task, so the flags are visible to the -// main loop's next iteration. -static inline void esphome_wake_ota_component_any_context(void) { - volatile bool *pending_enable = esphome_ota_pending_enable_loop_ptr; - if (pending_enable != NULL) { - *pending_enable = true; - *esphome_ota_has_pending_requests_ptr = true; - } -} - -#ifdef __cplusplus -} -#endif diff --git a/esphome/core/wake.h b/esphome/core/wake.h index a8c9b7ad08..9ad761e8aa 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -3,6 +3,63 @@ /// @file wake.h /// Platform-specific main loop wake primitives. /// Always available on all platforms — no opt-in needed. +/// +/// This file has two sections: +/// 1. A C-compatible section at the top (the inline OTA wake hook) that .c files +/// like lwip_fast_select.c can include. Uses only /. +/// 2. A C++ section (everything after #ifdef __cplusplus below) with the existing +/// platform wake primitives and wakeable_delay() implementations. + +// ============================================================================ +// C-compatible section: inline OTA wake hook +// ============================================================================ +// +// Called from lwip_fast_select.c (and lwip_raw_tcp_impl.cpp / host select) on every +// NETCONN_EVT_RCVPLUS so a disabled OTA loop can be re-enabled when a monitored +// socket signals activity. Static inline (not an extern-C trampoline) so the .c +// callback pays zero function-call overhead — just a pointer load, null check, and +// two volatile bool stores inlined into the callback body. +// +// The two pointers are captured once in Application::set_ota_wake_component(): they +// point at Component::pending_enable_loop_ and Application::has_pending_enable_loop_requests_. +// When OTA is not compiled into the build, nothing calls set_ota_wake_component(), +// the pointers stay NULL, and the inline collapses to a single null check. + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Address of the registered OTA component's pending_enable_loop_ flag, captured at +// registration time. NULL when no OTA component has registered (including when OTA +// is not compiled in at all). +extern volatile bool *esphome_ota_pending_enable_loop_ptr; +// Address of Application::has_pending_enable_loop_requests_. Set in tandem with the +// pending_enable pointer above — single null check covers both. +extern volatile bool *esphome_ota_has_pending_requests_ptr; + +// Mark the registered OTA component pending loop-enable. Safe from the LwIP TCP/IP +// task and raw-TCP IRQ context — only volatile stores, no locks. Callers MUST invoke +// this BEFORE waking the main task, so the flags are visible on the next iteration. +static inline void esphome_wake_ota_component_any_context(void) { + volatile bool *pending_enable = esphome_ota_pending_enable_loop_ptr; + if (pending_enable != NULL) { + *pending_enable = true; + *esphome_ota_has_pending_requests_ptr = true; + } +} + +#ifdef __cplusplus +} // extern "C" +#endif + +// ============================================================================ +// C++ section: platform wake primitives +// ============================================================================ + +#ifdef __cplusplus #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -124,3 +181,5 @@ inline void wakeable_delay(uint32_t ms) { #endif } // namespace esphome + +#endif // __cplusplus From 01beb56899202b4608f5cd6935d285c538842c8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:14:00 -1000 Subject: [PATCH 0056/2214] [esphome.ota] Filter fast-select wake hook to OTA listener netconn only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline OTA wake hook was firing on every NETCONN_EVT_RCVPLUS across every monitored socket (API client data packets, mDNS queries, web server, etc.). Each false fire paid two volatile stores + memw barriers to mark OTA pending-enable, only for OTA::loop() to run a wake-up tick and re-disable itself because there was no actual listener activity. Add a compare-against-listener filter in esphome_socket_event_callback so the wake hook only fires when `conn` matches the OTA listen socket's netconn. Non-match sockets now cost only a pointer load + one branch (~3 instructions) instead of the full ~10-instruction hook body. Plumbing: - lwip_fast_select.[ch]: new s_ota_listener_conn global + esphome_fast_select_set_ota_listener_sock() setter, used in the callback. - BSDSocketImpl / LwIPSocketImpl: new public get_cached_sock() accessor (only under USE_LWIP_FAST_SELECT) mirroring the existing get_fd() pattern. - ESPHomeOTAComponent::setup(): after registering the wake component, install the listener filter with this->server_->get_cached_sock(). Raw TCP (ESP8266/RP2040) is unaffected — that path wakes from LWIPRawListenImpl::accept_fn_, which only fires for the specific listener pcb it was registered on, so the filtering is implicit there. --- .../components/esphome/ota/ota_esphome.cpp | 11 +++++ esphome/components/socket/bsd_sockets_impl.h | 9 ++++ esphome/components/socket/lwip_sockets_impl.h | 7 ++++ esphome/core/lwip_fast_select.c | 42 ++++++++++++------- esphome/core/lwip_fast_select.h | 8 ++++ 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e2b12e1208..af2a609642 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -15,6 +15,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_LWIP_FAST_SELECT +#include "esphome/core/lwip_fast_select.h" +#endif #include #include @@ -69,6 +72,14 @@ void ESPHomeOTAComponent::setup() { // Register for socket wake notifications. loop() disables itself on its first // idle tick — no need to disable_loop() here explicitly. App.set_ota_wake_component(this); +#ifdef USE_LWIP_FAST_SELECT + // Install the listener filter so the fast-select RCVPLUS wake hook only fires for + // events on this listener's netconn (i.e. new incoming connections). Without this, + // every RCVPLUS across all monitored sockets (API client data, mDNS, etc.) would + // pay the inline hook's two volatile stores + memw barriers to mark OTA + // pending-enable, even though OTA would just re-disable itself on the next tick. + esphome_fast_select_set_ota_listener_sock(this->server_->get_cached_sock()); +#endif } void ESPHomeOTAComponent::dump_config() { diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index e520784702..ceb7556d9f 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -118,6 +118,15 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } +#ifdef USE_LWIP_FAST_SELECT + // Cached lwip_sock pointer captured at construction. Used by OTA to register its + // listener netconn with the fast-select wake hook filter so the hook only fires on + // OTA-relevant events. Returns nullptr for non-monitored sockets. + struct lwip_sock *get_cached_sock() const { + return this->cached_sock_; + } +#endif + protected: int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 942d0ccf85..cda37b5dc6 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -84,6 +84,13 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } +#ifdef USE_LWIP_FAST_SELECT + // See BSDSocketImpl::get_cached_sock() — same purpose, same semantics. + struct lwip_sock *get_cached_sock() const { + return this->cached_sock_; + } +#endif + protected: int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 061fafbc51..60a1cf60f1 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -161,16 +161,24 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN static netconn_callback s_original_callback = NULL; #ifdef ESPHOME_USE_OTA -// Extern wake hook for the OTA component (implemented in application.cpp). Called from the -// TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false -// wake from an unrelated monitored socket. OTA::loop() handles that by disabling itself again -// when there is no pending work. The hook only marks the OTA component as pending loop-enable; -// it does not itself wake the main task (the caller below already does that). -// NOTE: ESPHOME_USE_OTA (not USE_OTA) because USE_OTA only lives in defines.h, and this .c -// file cannot include defines.h — macros.h → Arduino.h would break the C compile under -// Arduino builds. ota/__init__.py emits -DESPHOME_USE_OTA as a build flag specifically so -// this file can see it without a name collision with the defines.h USE_OTA entry. -extern void esphome_wake_ota_component_any_context(void); +// OTA listener netconn, captured via esphome_fast_select_set_ota_listener_sock() at OTA +// setup(). The wake hook only fires when the callback's `conn` argument matches this +// pointer — i.e. only for RCVPLUS events on the OTA listen socket (new accepts). Avoids +// paying the inline wake hook's cost (two volatile stores + memw barriers) on every API +// client data packet, mDNS query, etc. +static struct netconn *s_ota_listener_conn = NULL; +// Inline wake hook defined in esphome/core/wake.h. ESPHOME_USE_OTA (not USE_OTA) because +// USE_OTA only lives in defines.h, and this .c file cannot include defines.h — macros.h → +// Arduino.h would break the C compile under Arduino builds. ota/__init__.py emits +// -DESPHOME_USE_OTA as a build flag so this file can see it without a name collision with +// the defines.h USE_OTA entry. +#include "esphome/core/wake.h" + +void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { + s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL; +} +#else +void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { (void) sock; } #endif // Wrapper callback: calls original event_callback + notifies main loop task. @@ -188,10 +196,16 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { #ifdef ESPHOME_USE_OTA - // Mark the OTA component pending-enable BEFORE xTaskNotifyGive — the flags must be - // visible before we wake the main task, otherwise the main loop could run a full - // iteration without seeing the pending-enable request. - esphome_wake_ota_component_any_context(); + // Filter: only mark OTA pending-enable when the event is for OTA's listen socket. + // Without this, every RCVPLUS (API client data, mDNS, etc.) would pay the inline + // wake hook's two volatile stores + memw barriers. The setter that installs + // s_ota_listener_conn is called from OTA setup(); until then the pointer is NULL + // and the filter skips the wake work entirely, which is the correct idle behavior. + // MUST happen before xTaskNotifyGive below — the flags have to be visible before + // the main task wakes, or the main loop could run a full iteration and miss them. + if (conn == s_ota_listener_conn) { + esphome_wake_ota_component_any_context(); + } #endif TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 20ac191673..9e1438ecdd 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,6 +53,14 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); +/// Filter the inline OTA wake hook in the fast-select callback so it only fires for +/// RCVPLUS events on this specific listener's netconn. Without this, every monitored +/// socket's RCVPLUS (API client data, web server, mDNS, etc.) would mark OTA +/// pending-enable and force its loop to run a wake-up tick only to re-disable itself. +/// Captured at OTA setup(); stays pointing at the listener for the device's lifetime. +/// Pass NULL to clear the filter (wake fires on every RCVPLUS, pre-filter behavior). +void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock); + /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, From 1490845dcf2e59ef04e0f827101c9cc514a919ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:15:04 -1000 Subject: [PATCH 0057/2214] [esphome.ota] Use existing esphome_lwip_get_sock() instead of adding Socket accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_cached_sock() was a new public method that only OTA's fast-select wake filter would ever call. Drop it. The existing public C API already covers this: esphome_lwip_get_sock(fd) looks up a lwip_sock* from a file descriptor (it's exactly what BSDSocketImpl's own constructor calls to populate cached_sock_). OTA uses the public get_fd() + esphome_lwip_get_sock() chain instead — no new Socket accessor, no friend declarations, no layering concerns. The one-time lookup at setup is negligible. --- esphome/components/esphome/ota/ota_esphome.cpp | 4 +++- esphome/components/socket/bsd_sockets_impl.h | 9 --------- esphome/components/socket/lwip_sockets_impl.h | 7 ------- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index af2a609642..1999b88ef4 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -78,7 +78,9 @@ void ESPHomeOTAComponent::setup() { // every RCVPLUS across all monitored sockets (API client data, mDNS, etc.) would // pay the inline hook's two volatile stores + memw barriers to mark OTA // pending-enable, even though OTA would just re-disable itself on the next tick. - esphome_fast_select_set_ota_listener_sock(this->server_->get_cached_sock()); + // Uses the existing public esphome_lwip_get_sock() lookup instead of adding a + // dedicated accessor on Socket — the lookup happens once at setup, not per event. + esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif } diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index ceb7556d9f..e520784702 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -118,15 +118,6 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } -#ifdef USE_LWIP_FAST_SELECT - // Cached lwip_sock pointer captured at construction. Used by OTA to register its - // listener netconn with the fast-select wake hook filter so the hook only fires on - // OTA-relevant events. Returns nullptr for non-monitored sockets. - struct lwip_sock *get_cached_sock() const { - return this->cached_sock_; - } -#endif - protected: int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index cda37b5dc6..942d0ccf85 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -84,13 +84,6 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } -#ifdef USE_LWIP_FAST_SELECT - // See BSDSocketImpl::get_cached_sock() — same purpose, same semantics. - struct lwip_sock *get_cached_sock() const { - return this->cached_sock_; - } -#endif - protected: int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT From eb5da72dabcc3cf985b6832684fb87c5094fe4bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:22:49 -1000 Subject: [PATCH 0058/2214] [esphome.ota] Revert inline wake hook to a single Component * + extern C call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the listener filter from the previous commit, the wake hook only fires on actual OTA connection attempts — no more spurious wakes from API client data packets or other monitored-socket traffic. That removes the motivation for inlining the hook at three call sites. Collapse back to the simpler shape: - Application gains Component *ota_wake_component_ (one pointer, 4 bytes, gated on USE_OTA) and a wake_ota_component_any_context() inline method. - lwip_fast_select.c reaches the method via an extern-C trampoline (esphome_wake_ota_component_any_context) defined in application.cpp. One call_n instruction per actual wake, which now happens at most once per real OTA upload attempt. - Raw-TCP (LWIPRawListenImpl::accept_fn_) and host select paths call App.wake_ota_component_any_context() directly — both are .cpp files. - wake.h reverts to its pre-PR state (no C-compatible section, no extern pointer globals, no inline OTA hook). All wake-related state still lives in Application. Net effect versus the inline approach: - RAM: -4 bytes (one pointer vs two) - Flash: ~-60 bytes (no 3x duplication of the inlined hook body) - CPU: function-call overhead (~10 cycles) paid only on actual OTA wakes, which happen ~0 times/sec in steady state. Inline was premature once filtering reduced the fire rate to "rare intentional events." --- .../components/socket/lwip_raw_tcp_impl.cpp | 10 ++-- esphome/core/application.cpp | 36 ++++------- esphome/core/application.h | 26 +++++--- esphome/core/lwip_fast_select.c | 21 ++++--- esphome/core/wake.h | 59 ------------------- 5 files changed, 46 insertions(+), 106 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b6e5730b66..08de3f73a8 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -11,7 +11,7 @@ #include "esphome/core/wake.h" #include "esphome/core/log.h" #ifdef USE_OTA -#include "esphome/core/wake.h" // inline esphome_wake_ota_component_any_context() lives here +#include "esphome/core/application.h" // for App.wake_ota_component_any_context() #endif #ifdef USE_ESP8266 @@ -861,9 +861,11 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Mark the OTA component loop to be re-enabled if it disabled itself while idle. // This MUST happen before wake_loop_any_context() below — otherwise the main loop // could wake, run a full iteration, and finish before we set the pending-enable - // flags, losing the wake event. Inline hook (wake.h) — two volatile stores, no - // function call. Safe from RP2040's low-priority user IRQ context. - esphome_wake_ota_component_any_context(); + // flags, losing the wake event. accept_fn_ only fires for the specific listener + // pcb it was registered on, so there's implicit filtering here (no "false wakes" + // from unrelated sockets, unlike the fast-select path). Safe from RP2040's + // low-priority user IRQ context — only writes a volatile bool, no locks. + esphome::App.wake_ota_component_any_context(); #endif // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 21d3ef4494..ffb374b321 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -2,7 +2,6 @@ #include "esphome/core/build_info_data.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" -#include "esphome/core/wake.h" #include #ifdef USE_ESP8266 @@ -450,27 +449,12 @@ void Application::enable_pending_loops_() { } } -// Storage for the inline OTA wake hook (see esphome/core/wake.h). Set in -// Application::set_ota_wake_component() and read from the lwip fast-select callback -// on every NETCONN_EVT_RCVPLUS. Kept as raw C globals so the .c file can inline the -// wake body (two volatile stores) without a function-call round trip. Defined -// unconditionally so wake.h's inline compiles the same whether or not OTA is in the -// build — when OTA is absent nothing ever calls set_ota_wake_component() and the -// pointers stay nullptr, collapsing the inline to a single null check. -extern "C" { -volatile bool *esphome_ota_pending_enable_loop_ptr = nullptr; -volatile bool *esphome_ota_has_pending_requests_ptr = nullptr; -} - -#ifdef USE_OTA -void Application::set_ota_wake_component(Component *component) { - // Application is a friend of Component — can take the address of its protected - // pending_enable_loop_ field. The C-side inline hook writes through that pointer when - // any monitored socket fires RCVPLUS, making the flags visible to enable_pending_loops_() - // on the next main loop iteration. - esphome_ota_pending_enable_loop_ptr = &component->pending_enable_loop_; - esphome_ota_has_pending_requests_ptr = &this->has_pending_enable_loop_requests_; -} +#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) +// Extern-C trampoline for the lwip fast-select callback to reach the C++ wake method. +// Invoked only when the listener filter in lwip_fast_select.c matches — i.e. only on +// actual incoming connections to the OTA listen socket — so the function-call overhead +// here is paid at most once per real OTA attempt, not on every monitored-socket event. +extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif #ifdef USE_LWIP_FAST_SELECT @@ -580,10 +564,12 @@ void Application::yield_with_select_(uint32_t delay_ms) { if (ret >= 0) [[likely]] { #ifdef USE_OTA // Dead code today — host does not currently support the esphome OTA platform, - // so the inline hook's pointers are always NULL and this is a no-op. Kept so the - // wake path works out of the box if host ever gains OTA support. + // so ota_wake_component_ is always null and this is a no-op. Kept so the wake + // path works out of the box if host ever gains OTA support. Host has no listener + // filter (that lives in lwip_fast_select.c, ESP32/LibreTiny only), so any + // ret > 0 fires this — harmless when the pointer is null. if (ret > 0) { - esphome_wake_ota_component_any_context(); + this->wake_ota_component_any_context(); } #endif // Yield if zero timeout since select(0) only polls without yielding diff --git a/esphome/core/application.h b/esphome/core/application.h index baf8e21a99..7a3595ac49 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -561,13 +561,22 @@ class Application { #ifdef USE_OTA /// Register the OTA component so socket-wake paths can enable its loop when a new - /// connection arrives on the listening socket. Captures the addresses of the component's - /// pending_enable_loop_ flag and Application's has_pending_enable_loop_requests_ flag - /// into extern-C globals consumed by the inline wake hook in wake.h. Defined out-of-line - /// in application.cpp so application.h doesn't need to pull in wake.h. OTA calls this - /// once from setup(); the component itself then self-disables its loop on its first - /// idle tick. - void set_ota_wake_component(Component *component); + /// connection arrives on the listening socket. OTA calls this once from setup(); + /// the component self-disables its loop on its first idle tick and is re-enabled + /// via wake_ota_component_any_context() when the fast-select filter in + /// lwip_fast_select.c matches an incoming connection on the OTA listener. + void set_ota_wake_component(Component *component) { this->ota_wake_component_ = component; } + /// Mark the registered OTA component pending loop-enable. Called from the LwIP + /// TCP/IP task (fast-select callback), raw-TCP accept callback, and host select + /// return path — all task / user-IRQ context, not a real ISR. Does NOT wake the + /// main task itself: every caller already does so separately. Application is a + /// friend of Component, so we set pending_enable_loop_ directly. + void wake_ota_component_any_context() { + if (this->ota_wake_component_ != nullptr) { + this->ota_wake_component_->pending_enable_loop_ = true; + this->has_pending_enable_loop_requests_ = true; + } + } #endif protected: @@ -645,6 +654,9 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; +#ifdef USE_OTA + Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications +#endif // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 60a1cf60f1..9f23401aba 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -124,9 +124,6 @@ #include "esphome/core/lwip_fast_select.h" #include "esphome/core/main_task.h" -#ifdef ESPHOME_USE_OTA -#include "esphome/core/wake.h" // inline esphome_wake_ota_component_any_context() lives here -#endif #include @@ -164,15 +161,17 @@ static netconn_callback s_original_callback = NULL; // OTA listener netconn, captured via esphome_fast_select_set_ota_listener_sock() at OTA // setup(). The wake hook only fires when the callback's `conn` argument matches this // pointer — i.e. only for RCVPLUS events on the OTA listen socket (new accepts). Avoids -// paying the inline wake hook's cost (two volatile stores + memw barriers) on every API -// client data packet, mDNS query, etc. +// paying the wake-hook function call on every API client data packet, mDNS query, etc. static struct netconn *s_ota_listener_conn = NULL; -// Inline wake hook defined in esphome/core/wake.h. ESPHOME_USE_OTA (not USE_OTA) because -// USE_OTA only lives in defines.h, and this .c file cannot include defines.h — macros.h → -// Arduino.h would break the C compile under Arduino builds. ota/__init__.py emits -// -DESPHOME_USE_OTA as a build flag so this file can see it without a name collision with -// the defines.h USE_OTA entry. -#include "esphome/core/wake.h" + +// Extern-C trampoline defined in application.cpp — calls App.wake_ota_component_any_context() +// to mark the OTA component pending loop-enable. Out-of-line (not inlined) because with the +// listener filter above, this only fires on actual OTA connection attempts — the function-call +// cost is paid at most once per real wake, not on every monitored-socket RCVPLUS. +// ESPHOME_USE_OTA (not USE_OTA) because USE_OTA only lives in defines.h, and this .c file +// cannot include defines.h — macros.h → Arduino.h would break the C compile under Arduino +// builds. ota/__init__.py emits -DESPHOME_USE_OTA as a build flag so this file can see it. +extern void esphome_wake_ota_component_any_context(void); void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL; diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 9ad761e8aa..a8c9b7ad08 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -3,63 +3,6 @@ /// @file wake.h /// Platform-specific main loop wake primitives. /// Always available on all platforms — no opt-in needed. -/// -/// This file has two sections: -/// 1. A C-compatible section at the top (the inline OTA wake hook) that .c files -/// like lwip_fast_select.c can include. Uses only /. -/// 2. A C++ section (everything after #ifdef __cplusplus below) with the existing -/// platform wake primitives and wakeable_delay() implementations. - -// ============================================================================ -// C-compatible section: inline OTA wake hook -// ============================================================================ -// -// Called from lwip_fast_select.c (and lwip_raw_tcp_impl.cpp / host select) on every -// NETCONN_EVT_RCVPLUS so a disabled OTA loop can be re-enabled when a monitored -// socket signals activity. Static inline (not an extern-C trampoline) so the .c -// callback pays zero function-call overhead — just a pointer load, null check, and -// two volatile bool stores inlined into the callback body. -// -// The two pointers are captured once in Application::set_ota_wake_component(): they -// point at Component::pending_enable_loop_ and Application::has_pending_enable_loop_requests_. -// When OTA is not compiled into the build, nothing calls set_ota_wake_component(), -// the pointers stay NULL, and the inline collapses to a single null check. - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -// Address of the registered OTA component's pending_enable_loop_ flag, captured at -// registration time. NULL when no OTA component has registered (including when OTA -// is not compiled in at all). -extern volatile bool *esphome_ota_pending_enable_loop_ptr; -// Address of Application::has_pending_enable_loop_requests_. Set in tandem with the -// pending_enable pointer above — single null check covers both. -extern volatile bool *esphome_ota_has_pending_requests_ptr; - -// Mark the registered OTA component pending loop-enable. Safe from the LwIP TCP/IP -// task and raw-TCP IRQ context — only volatile stores, no locks. Callers MUST invoke -// this BEFORE waking the main task, so the flags are visible on the next iteration. -static inline void esphome_wake_ota_component_any_context(void) { - volatile bool *pending_enable = esphome_ota_pending_enable_loop_ptr; - if (pending_enable != NULL) { - *pending_enable = true; - *esphome_ota_has_pending_requests_ptr = true; - } -} - -#ifdef __cplusplus -} // extern "C" -#endif - -// ============================================================================ -// C++ section: platform wake primitives -// ============================================================================ - -#ifdef __cplusplus #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -181,5 +124,3 @@ inline void wakeable_delay(uint32_t ms) { #endif } // namespace esphome - -#endif // __cplusplus From f75d6ab88d1dd3d1be67aab789d69520dd8bb090 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:39:37 -1000 Subject: [PATCH 0059/2214] [esphome.ota] Update docstrings to match post-filter behavior Two doc-drift fixes flagged by copilot review: 1. esphome_fast_select_set_ota_listener_sock() header comment claimed passing NULL 'clears the filter' so wake fires on every RCVPLUS. The actual code stores NULL and the conn == s_ota_listener_conn check never matches, so NULL means 'no wakes' not 'all wakes'. Rewrite to describe the actual semantics (install a listener to enable filtered wakes; NULL disables OTA wakes entirely). 2. ESPHomeOTAComponent::loop() docstring still claimed false wakes from unrelated monitored sockets are expected. Post-filter that's no longer true on fast-select (filtered to OTA listener netconn) or raw TCP (per-pcb accept_fn_). Rewrite to describe the current behavior: loop() runs ~once per real incoming OTA connection, with the idle self-disable retained as a safety net for the few narrow cases where a wake can land with no pending work (queued-during-session, filter not yet installed, host select fallback). --- .../components/esphome/ota/ota_esphome.cpp | 24 ++++++++++++------- esphome/core/lwip_fast_select.h | 10 +++++--- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1999b88ef4..408bde14ac 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -99,15 +99,23 @@ void ESPHomeOTAComponent::dump_config() { void ESPHomeOTAComponent::loop() { // Self-disabling idle loop. On the first tick after setup() (and after every session - // cleanup and every false wake), if there's no client and the listener has nothing - // queued, we disable ourselves and go back to sleep. Socket-wake paths (LwIP fast - // select, raw TCP accept, host select) mark us pending-enable via - // App.wake_ota_component_any_context() when a monitored socket signals activity, and - // enable_pending_loops_() reactivates us. + // ends), if there's no client and the listener has nothing queued, we disable + // ourselves and go back to sleep. Wake paths (LwIP fast select with listener filter, + // raw TCP accept_fn_, host select) mark us pending-enable via + // App.wake_ota_component_any_context() when an incoming connection arrives on the OTA + // listen socket; enable_pending_loops_() reactivates us. // - // False wakes from unrelated monitored sockets are expected — the event callbacks - // fire on every RCVPLUS across all monitored sockets, not just OTA's listener — and - // they land here with no pending work. + // On fast-select platforms (ESP32 / LibreTiny), the callback is filtered to OTA's + // listener netconn, so unrelated monitored sockets (API client data, mDNS, etc.) do + // NOT wake OTA through that path. Raw TCP (ESP8266 / RP2040) is inherently filtered: + // accept_fn_ is registered per listener pcb and only fires on completed handshakes + // to that specific listener. So in normal steady state this loop() runs exactly + // once per real incoming OTA connection. + // + // We can still land here with no pending work in a few narrow cases — a second + // connection queued on the listener while an OTA session was active, a listener + // filter that hasn't been installed yet, or the host select path (no filter) — which + // is why the idle-check and self-disable are retained as a safety net. // // cleanup_connection_() deliberately does NOT call disable_loop() — letting loop() // run one more iteration after a session ends guarantees we re-read server_->ready() diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 9e1438ecdd..2572a9103d 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,12 +53,16 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); -/// Filter the inline OTA wake hook in the fast-select callback so it only fires for -/// RCVPLUS events on this specific listener's netconn. Without this, every monitored +/// Install an OTA listener netconn as the wake-filter target for the fast-select +/// callback. After this is called, the OTA wake hook only fires for RCVPLUS events +/// whose `conn` argument matches this listener's netconn — i.e. only on actual +/// incoming connections to the OTA listen socket. Without this, every monitored /// socket's RCVPLUS (API client data, web server, mDNS, etc.) would mark OTA /// pending-enable and force its loop to run a wake-up tick only to re-disable itself. /// Captured at OTA setup(); stays pointing at the listener for the device's lifetime. -/// Pass NULL to clear the filter (wake fires on every RCVPLUS, pre-filter behavior). +/// Passing NULL disables OTA wake notifications entirely (no RCVPLUS event will match +/// a null listener), which is the correct behavior before the listener is installed +/// and after it's torn down. void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock); /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. From f8a1c54690fa58eed69ba462229a93091e1463e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:44:18 -1000 Subject: [PATCH 0060/2214] [esphome.ota] Document NULL-fallback degraded mode for listener filter Per review feedback: if esphome_lwip_get_sock() ever returned nullptr (shouldn't after successful listen(), but defensively), the listener filter compare would never match and no fast-select wakes would fire. loop()'s self-disable safety net + the first-tick-after-setup window cover that degraded mode correctly. Spell it out in the comment so a future reader doesn't treat the nullptr path as a silent bug. --- esphome/components/esphome/ota/ota_esphome.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 408bde14ac..2e2c0bc73b 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -76,10 +76,15 @@ void ESPHomeOTAComponent::setup() { // Install the listener filter so the fast-select RCVPLUS wake hook only fires for // events on this listener's netconn (i.e. new incoming connections). Without this, // every RCVPLUS across all monitored sockets (API client data, mDNS, etc.) would - // pay the inline hook's two volatile stores + memw barriers to mark OTA - // pending-enable, even though OTA would just re-disable itself on the next tick. - // Uses the existing public esphome_lwip_get_sock() lookup instead of adding a - // dedicated accessor on Socket — the lookup happens once at setup, not per event. + // pay the wake hook cost to mark OTA pending-enable, even though OTA would just + // re-disable itself on the next tick. Uses the existing public esphome_lwip_get_sock() + // lookup instead of adding a dedicated accessor on Socket — the lookup happens once + // at setup, not per event. If the lookup ever returns nullptr (shouldn't after a + // successful listen()), the filter is installed with nullptr and the callback + // compare never matches, so no wakes fire — loop() falls back to the self-disable + // safety net. That degraded mode is the correct behavior: OTA will still work on + // the first loop tick (which runs during setup before disable), and any subsequent + // wakes go through the wake_loop_any_context() path that fires unconditionally. esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif } From 82699a7ce3c175a1efe71fa48438c28243193572 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:47:36 -1000 Subject: [PATCH 0061/2214] [esphome.ota] Trim verbose comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CLAUDE.md: comments should explain the non-obvious why, not narrate the design journey. Remove the reasoning-aloud, before/after framing, redundant restatements, and cross-file cross-references — the commit messages and PR description already carry that context. Behavior unchanged. --- .../components/esphome/ota/ota_esphome.cpp | 55 ++++--------------- esphome/components/ota/__init__.py | 7 +-- .../components/socket/lwip_raw_tcp_impl.cpp | 11 +--- esphome/core/application.cpp | 11 +--- esphome/core/application.h | 14 +---- esphome/core/lwip_fast_select.c | 25 ++------- esphome/core/lwip_fast_select.h | 14 ++--- 7 files changed, 29 insertions(+), 108 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 2e2c0bc73b..041aa4bbf9 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -69,22 +69,11 @@ void ESPHomeOTAComponent::setup() { return; } - // Register for socket wake notifications. loop() disables itself on its first - // idle tick — no need to disable_loop() here explicitly. + // loop() self-disables on its first idle tick; no explicit disable_loop() needed here. App.set_ota_wake_component(this); #ifdef USE_LWIP_FAST_SELECT - // Install the listener filter so the fast-select RCVPLUS wake hook only fires for - // events on this listener's netconn (i.e. new incoming connections). Without this, - // every RCVPLUS across all monitored sockets (API client data, mDNS, etc.) would - // pay the wake hook cost to mark OTA pending-enable, even though OTA would just - // re-disable itself on the next tick. Uses the existing public esphome_lwip_get_sock() - // lookup instead of adding a dedicated accessor on Socket — the lookup happens once - // at setup, not per event. If the lookup ever returns nullptr (shouldn't after a - // successful listen()), the filter is installed with nullptr and the callback - // compare never matches, so no wakes fire — loop() falls back to the self-disable - // safety net. That degraded mode is the correct behavior: OTA will still work on - // the first loop tick (which runs during setup before disable), and any subsequent - // wakes go through the wake_loop_any_context() path that fires unconditionally. + // Filter fast-select wakes to this listener only. If the sock lookup returns nullptr, + // no wakes fire and loop() falls back to the self-disable safety net. esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif } @@ -103,31 +92,10 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Self-disabling idle loop. On the first tick after setup() (and after every session - // ends), if there's no client and the listener has nothing queued, we disable - // ourselves and go back to sleep. Wake paths (LwIP fast select with listener filter, - // raw TCP accept_fn_, host select) mark us pending-enable via - // App.wake_ota_component_any_context() when an incoming connection arrives on the OTA - // listen socket; enable_pending_loops_() reactivates us. - // - // On fast-select platforms (ESP32 / LibreTiny), the callback is filtered to OTA's - // listener netconn, so unrelated monitored sockets (API client data, mDNS, etc.) do - // NOT wake OTA through that path. Raw TCP (ESP8266 / RP2040) is inherently filtered: - // accept_fn_ is registered per listener pcb and only fires on completed handshakes - // to that specific listener. So in normal steady state this loop() runs exactly - // once per real incoming OTA connection. - // - // We can still land here with no pending work in a few narrow cases — a second - // connection queued on the listener while an OTA session was active, a listener - // filter that hasn't been installed yet, or the host select path (no filter) — which - // is why the idle-check and self-disable are retained as a safety net. - // - // cleanup_connection_() deliberately does NOT call disable_loop() — letting loop() - // run one more iteration after a session ends guarantees we re-read server_->ready() - // and either accept a client that queued during the session or disable cleanly here. - // - // Note: No need to check server_ for null — setup() marks the component failed if - // server_ creation fails. + // Self-disabling idle loop. Runs when a wake path marks us pending-enable (fast-select + // listener filter, raw-TCP accept_fn_, or host select), finds no work, and goes back + // to sleep. cleanup_connection_() deliberately leaves the loop enabled for one more + // iteration so a connection queued mid-session is still caught here. if (this->client_ == nullptr && !this->server_->ready()) { this->disable_loop(); return; @@ -611,12 +579,9 @@ void ESPHomeOTAComponent::cleanup_connection_() { #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); #endif - // Do not disable_loop() here. loop() itself disables when idle. If a second - // connection was queued on the listener while we were busy, the wake flag was - // set while this component was in LOOP state — enable_pending_loops_() only - // scans the inactive section and would never clear it. Letting loop() run one - // more iteration guarantees we re-check server_->ready() and either accept the - // queued client or disable ourselves cleanly. + // Intentionally no disable_loop() — letting loop() run one more iteration catches + // any connection that queued on the listener mid-session (otherwise the wake flag, + // set while we were in LOOP state, would be lost to enable_pending_loops_()). } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 3abfd2c69e..e0de31f239 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -103,11 +103,8 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): cg.add_define("USE_OTA") - # Separate compiler -D flag using an ESPHOME_-prefixed name so .c translation units - # (which cannot include defines.h because macros.h → Arduino.h breaks the C compile - # under Arduino builds) can still tell OTA is compiled in. Needed by the fast-select - # OTA wake hook in lwip_fast_select.c. A distinct name avoids the "USE_OTA redefined" - # warning that would fire if we also emitted -DUSE_OTA — defines.h already has it. + # Separate -D flag so .c files can see it — .c can't include defines.h (macros.h + # pulls in Arduino.h). Different name avoids USE_OTA redefinition warnings. cg.add_build_flag("-DESPHOME_USE_OTA") CORE.add_job(final_step) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 08de3f73a8..04605f1e55 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -11,7 +11,7 @@ #include "esphome/core/wake.h" #include "esphome/core/log.h" #ifdef USE_OTA -#include "esphome/core/application.h" // for App.wake_ota_component_any_context() +#include "esphome/core/application.h" #endif #ifdef USE_ESP8266 @@ -858,13 +858,8 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); #ifdef USE_OTA - // Mark the OTA component loop to be re-enabled if it disabled itself while idle. - // This MUST happen before wake_loop_any_context() below — otherwise the main loop - // could wake, run a full iteration, and finish before we set the pending-enable - // flags, losing the wake event. accept_fn_ only fires for the specific listener - // pcb it was registered on, so there's implicit filtering here (no "false wakes" - // from unrelated sockets, unlike the fast-select path). Safe from RP2040's - // low-priority user IRQ context — only writes a volatile bool, no locks. + // Mark OTA pending-enable before the wake below — flags must be visible before the + // main task runs. accept_fn_ is per-pcb, so filtering is implicit here. esphome::App.wake_ota_component_any_context(); #endif // Wake the main loop immediately so it can accept the new connection. diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index ffb374b321..c847c173f1 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -450,10 +450,7 @@ void Application::enable_pending_loops_() { } #if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) -// Extern-C trampoline for the lwip fast-select callback to reach the C++ wake method. -// Invoked only when the listener filter in lwip_fast_select.c matches — i.e. only on -// actual incoming connections to the OTA listen socket — so the function-call overhead -// here is paid at most once per real OTA attempt, not on every monitored-socket event. +// C trampoline called from lwip_fast_select.c when the listener filter matches. extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif @@ -563,11 +560,7 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret == 0: timeout occurred - normal and expected if (ret >= 0) [[likely]] { #ifdef USE_OTA - // Dead code today — host does not currently support the esphome OTA platform, - // so ota_wake_component_ is always null and this is a no-op. Kept so the wake - // path works out of the box if host ever gains OTA support. Host has no listener - // filter (that lives in lwip_fast_select.c, ESP32/LibreTiny only), so any - // ret > 0 fires this — harmless when the pointer is null. + // No-op today — host has no esphome OTA platform, so ota_wake_component_ is null. if (ret > 0) { this->wake_ota_component_any_context(); } diff --git a/esphome/core/application.h b/esphome/core/application.h index 7a3595ac49..8c9789774b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -560,17 +560,9 @@ class Application { static void IRAM_ATTR wake_loop_any_context() { esphome::wake_loop_any_context(); } #ifdef USE_OTA - /// Register the OTA component so socket-wake paths can enable its loop when a new - /// connection arrives on the listening socket. OTA calls this once from setup(); - /// the component self-disables its loop on its first idle tick and is re-enabled - /// via wake_ota_component_any_context() when the fast-select filter in - /// lwip_fast_select.c matches an incoming connection on the OTA listener. void set_ota_wake_component(Component *component) { this->ota_wake_component_ = component; } - /// Mark the registered OTA component pending loop-enable. Called from the LwIP - /// TCP/IP task (fast-select callback), raw-TCP accept callback, and host select - /// return path — all task / user-IRQ context, not a real ISR. Does NOT wake the - /// main task itself: every caller already does so separately. Application is a - /// friend of Component, so we set pending_enable_loop_ directly. + // Marks OTA pending-enable from socket wake paths. Does NOT wake the main task — + // every caller already does. Callable from LwIP TCP/IP task and user-IRQ context. void wake_ota_component_any_context() { if (this->ota_wake_component_ != nullptr) { this->ota_wake_component_->pending_enable_loop_ = true; @@ -655,7 +647,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; #ifdef USE_OTA - Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications + Component *ota_wake_component_{nullptr}; #endif // std::vector (3 pointers each: begin, end, capacity) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 9f23401aba..f5cfe7f0fc 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -158,20 +158,10 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN static netconn_callback s_original_callback = NULL; #ifdef ESPHOME_USE_OTA -// OTA listener netconn, captured via esphome_fast_select_set_ota_listener_sock() at OTA -// setup(). The wake hook only fires when the callback's `conn` argument matches this -// pointer — i.e. only for RCVPLUS events on the OTA listen socket (new accepts). Avoids -// paying the wake-hook function call on every API client data packet, mDNS query, etc. +// ESPHOME_USE_OTA (not USE_OTA): this .c file can't include defines.h — macros.h → +// Arduino.h would break the C compile. ota/__init__.py emits -DESPHOME_USE_OTA. static struct netconn *s_ota_listener_conn = NULL; - -// Extern-C trampoline defined in application.cpp — calls App.wake_ota_component_any_context() -// to mark the OTA component pending loop-enable. Out-of-line (not inlined) because with the -// listener filter above, this only fires on actual OTA connection attempts — the function-call -// cost is paid at most once per real wake, not on every monitored-socket RCVPLUS. -// ESPHOME_USE_OTA (not USE_OTA) because USE_OTA only lives in defines.h, and this .c file -// cannot include defines.h — macros.h → Arduino.h would break the C compile under Arduino -// builds. ota/__init__.py emits -DESPHOME_USE_OTA as a build flag so this file can see it. -extern void esphome_wake_ota_component_any_context(void); +extern void esphome_wake_ota_component_any_context(void); // trampoline in application.cpp void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL; @@ -195,13 +185,8 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { #ifdef ESPHOME_USE_OTA - // Filter: only mark OTA pending-enable when the event is for OTA's listen socket. - // Without this, every RCVPLUS (API client data, mDNS, etc.) would pay the inline - // wake hook's two volatile stores + memw barriers. The setter that installs - // s_ota_listener_conn is called from OTA setup(); until then the pointer is NULL - // and the filter skips the wake work entirely, which is the correct idle behavior. - // MUST happen before xTaskNotifyGive below — the flags have to be visible before - // the main task wakes, or the main loop could run a full iteration and miss them. + // Mark OTA pending-enable only for events on its listen socket. MUST happen + // before xTaskNotifyGive so the flags are visible when the main task wakes. if (conn == s_ota_listener_conn) { esphome_wake_ota_component_any_context(); } diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 2572a9103d..3b5e449148 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,16 +53,10 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); -/// Install an OTA listener netconn as the wake-filter target for the fast-select -/// callback. After this is called, the OTA wake hook only fires for RCVPLUS events -/// whose `conn` argument matches this listener's netconn — i.e. only on actual -/// incoming connections to the OTA listen socket. Without this, every monitored -/// socket's RCVPLUS (API client data, web server, mDNS, etc.) would mark OTA -/// pending-enable and force its loop to run a wake-up tick only to re-disable itself. -/// Captured at OTA setup(); stays pointing at the listener for the device's lifetime. -/// Passing NULL disables OTA wake notifications entirely (no RCVPLUS event will match -/// a null listener), which is the correct behavior before the listener is installed -/// and after it's torn down. +/// Set the listener netconn that the fast-select callback filters OTA wakes against. +/// After this is called, the OTA wake hook only fires for RCVPLUS events whose `conn` +/// matches this listener. Passing NULL disables OTA wakes (no event matches a NULL +/// listener) — correct behavior before install and after teardown. void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock); /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. From 7ce49089a09b931918b374d871b30bda4a228111 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 20:39:01 -1000 Subject: [PATCH 0062/2214] [core] Remove pre-sleep socket scan from fast select path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-sleep scan in Application::yield_with_select_() walks monitored_sockets_ on every loop iteration, issuing a volatile cross-thread read on each socket's lwip_sock::rcvevent to preserve select() semantics when the FreeRTOS task notification counter had been consumed but a socket still had unread data. That scenario only existed because of a Socket::ready() contract violation: callers could stop reading with rcvevent > 0, leaving data behind with no pending notification. That contract is now documented and enforced (#15590), and #15589 (the first failure that reverted the earlier removal attempt #14475) has been fixed. With the contract honoured, every rcvevent > 0 is paired with a pending xTaskNotifyGive from the lwip event_callback wrapper (see lwip_fast_select.c). ulTaskNotifyTake either returns immediately (counter non-zero) or wakes the moment the notify lands — the scan has nothing left to rescue. Evidence: https://github.com/esphome/esphome/pull/15638 — an instrumentation PR ran across 5 devices (ESP32 rev1/rev3.1/C3 on Ethernet and WiFi, plus LibreTiny RTL8720CF) through Home Assistant disconnect/reconnect cycles, multi-client API logger bursts, and BLE GATT connect storms. Across ~275,000 scans and 4 observed load-bearing candidates, every hit was in the 2–14µs range — the instruction-level window between the lwip callback writing rcvevent and calling xTaskNotifyGive a few instructions later. Zero hits exceeded 100µs. No hit came anywhere near loop_interval (16ms), which is the latency scale the scan was added to prevent. In addition to being unused, the scan is actively harmful on the hot path: N volatile 16-bit loads against cache-cold cross-thread lwip_sock structures on every main-loop iteration, just to reproduce a microsecond-scale ordering artifact the notification path is already handling authoritatively. This also removes the now-unused monitored_sockets_ vector and Application::{register,unregister}_socket() on the fast-select path. Socket implementations now call esphome_lwip_hook_socket() directly to install the netconn event callback wrapper. --- .../components/socket/bsd_sockets_impl.cpp | 16 +++++---- .../components/socket/lwip_sockets_impl.cpp | 16 +++++---- esphome/core/application.cpp | 27 +-------------- esphome/core/application.h | 33 +++++-------------- 4 files changed, 27 insertions(+), 65 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index aea7c776c6..1db905daea 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -14,9 +14,13 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - // Cache lwip_sock pointer and register for monitoring (hooks callback internally) + // Cache lwip_sock pointer (used by ready() for direct rcvevent reads) and hook the + // netconn event callback so the main loop is notified via FreeRTOS task notifications. this->cached_sock_ = esphome_lwip_get_sock(this->fd_); - this->loop_monitored_ = App.register_socket(this->cached_sock_); + if (this->cached_sock_ != nullptr) { + esphome_lwip_hook_socket(this->cached_sock_); + this->loop_monitored_ = true; + } #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif @@ -30,12 +34,10 @@ BSDSocketImpl::~BSDSocketImpl() { int BSDSocketImpl::close() { if (!this->closed_) { - // Unregister before closing to avoid dangling pointer in monitored set #ifdef USE_LWIP_FAST_SELECT - if (this->loop_monitored_) { - App.unregister_socket(this->cached_sock_); - this->cached_sock_ = nullptr; - } + // All LwIP sockets share the same static event_callback, so there is no per-socket + // unhook needed — just drop the cached pointer before the socket is destroyed. + this->cached_sock_ = nullptr; #else if (this->loop_monitored_) { App.unregister_socket_fd(this->fd_); diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index 2fad429e0f..e628a22d8d 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -14,9 +14,13 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - // Cache lwip_sock pointer and register for monitoring (hooks callback internally) + // Cache lwip_sock pointer (used by ready() for direct rcvevent reads) and hook the + // netconn event callback so the main loop is notified via FreeRTOS task notifications. this->cached_sock_ = esphome_lwip_get_sock(this->fd_); - this->loop_monitored_ = App.register_socket(this->cached_sock_); + if (this->cached_sock_ != nullptr) { + esphome_lwip_hook_socket(this->cached_sock_); + this->loop_monitored_ = true; + } #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif @@ -30,12 +34,10 @@ LwIPSocketImpl::~LwIPSocketImpl() { int LwIPSocketImpl::close() { if (!this->closed_) { - // Unregister before closing to avoid dangling pointer in monitored set #ifdef USE_LWIP_FAST_SELECT - if (this->loop_monitored_) { - App.unregister_socket(this->cached_sock_); - this->cached_sock_ = nullptr; - } + // All LwIP sockets share the same static event_callback, so there is no per-socket + // unhook needed — just drop the cached pointer before the socket is destroyed. + this->cached_sock_ = nullptr; #else if (this->loop_monitored_) { App.unregister_socket_fd(this->fd_); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd75859880..510e3ec190 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -449,32 +449,7 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_LWIP_FAST_SELECT -bool Application::register_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - if (sock == nullptr) - return false; - esphome_lwip_hook_socket(sock); - this->monitored_sockets_.push_back(sock); - return true; -} - -void Application::unregister_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { - if (this->monitored_sockets_[i] != sock) - continue; - - // Swap with last element and pop - O(1) removal since order doesn't matter. - // No need to unhook the netconn callback — all LwIP sockets share the same - // static event_callback, and the socket will be closed by the caller. - if (i < this->monitored_sockets_.size() - 1) - this->monitored_sockets_[i] = this->monitored_sockets_.back(); - this->monitored_sockets_.pop_back(); - return; - } -} -#elif defined(USE_HOST) +#ifdef USE_HOST bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b490..122f9b39c7 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -534,12 +534,7 @@ class Application { /// Register/unregister a socket to be monitored for read events. /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. -#ifdef USE_LWIP_FAST_SELECT - /// Fast select path: hooks netconn callback and registers for monitoring. - /// @return true if registration was successful, false if sock is null - bool register_socket(struct lwip_sock *sock); - void unregister_socket(struct lwip_sock *sock); -#elif defined(USE_HOST) +#ifdef USE_HOST /// Fallback select() path: monitors file descriptors. /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. /// @return true if registration was successful, false if fd exceeds limits @@ -653,9 +648,7 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop FixedVector looping_components_{}; -#ifdef USE_LWIP_FAST_SELECT - std::vector monitored_sockets_; // Cached lwip_sock pointers for direct rcvevent read -#elif defined(USE_HOST) +#ifdef USE_HOST std::vector socket_fds_; // Vector of all monitored socket file descriptors #endif #ifdef USE_HOST @@ -898,26 +891,16 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifndef USE_HOST inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { #ifdef USE_LWIP_FAST_SELECT - // Fast path (ESP32/LibreTiny): reads rcvevent directly from cached lwip_sock pointers. - // Safe because this runs on the main loop which owns socket lifetime (create, read, close). + // Fast path (ESP32/LibreTiny): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Also woken by wake_loop_threadsafe() + // from background tasks, or timeout. if (delay_ms == 0) [[unlikely]] { yield(); return; } - - // Check if any socket already has pending data before sleeping. - // If a socket still has unread data (rcvevent > 0) but the task notification was already - // consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency. - // This scan preserves select() semantics: return immediately when any fd is ready. - for (struct lwip_sock *sock : this->monitored_sockets_) { - if (esphome_lwip_socket_has_data(sock)) { - yield(); - return; - } - } - - // Sleep with instant wake via FreeRTOS task notification. - // Woken by: callback wrapper (socket data), wake_loop_threadsafe() (background tasks), or timeout. #endif esphome::internal::wakeable_delay(delay_ms); } From 4a9d3da962de2512bc0e7373145bd15e28075d04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 20:58:12 -1000 Subject: [PATCH 0063/2214] [core] fast_select scan removal: address review feedback - Drop redundant cached_sock_ = nullptr assignment in close(). After closed_ = true the socket is a corpse and no ready() or other member access is valid, so the nulling is not load-bearing. The comment now explains why on both impl variants. - Reword the yield_with_select_ comment so the wake-source sentence reads as a complete list rather than a trailing fragment. --- esphome/components/socket/bsd_sockets_impl.cpp | 9 ++++----- esphome/components/socket/lwip_sockets_impl.cpp | 9 ++++----- esphome/core/application.h | 4 ++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 1db905daea..2d4e0ea7c2 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -34,11 +34,10 @@ BSDSocketImpl::~BSDSocketImpl() { int BSDSocketImpl::close() { if (!this->closed_) { -#ifdef USE_LWIP_FAST_SELECT - // All LwIP sockets share the same static event_callback, so there is no per-socket - // unhook needed — just drop the cached pointer before the socket is destroyed. - this->cached_sock_ = nullptr; -#else +#ifndef USE_LWIP_FAST_SELECT + // All LwIP sockets share the same static event_callback, so on the fast-select path + // there is no per-socket unhook needed. cached_sock_ is not cleared because closed_ + // makes the socket a corpse — no ready() or other member access is valid afterwards. if (this->loop_monitored_) { App.unregister_socket_fd(this->fd_); } diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index e628a22d8d..c65d3a227d 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -34,11 +34,10 @@ LwIPSocketImpl::~LwIPSocketImpl() { int LwIPSocketImpl::close() { if (!this->closed_) { -#ifdef USE_LWIP_FAST_SELECT - // All LwIP sockets share the same static event_callback, so there is no per-socket - // unhook needed — just drop the cached pointer before the socket is destroyed. - this->cached_sock_ = nullptr; -#else +#ifndef USE_LWIP_FAST_SELECT + // All LwIP sockets share the same static event_callback, so on the fast-select path + // there is no per-socket unhook needed. cached_sock_ is not cleared because closed_ + // makes the socket a corpse — no ready() or other member access is valid afterwards. if (this->loop_monitored_) { App.unregister_socket_fd(this->fd_); } diff --git a/esphome/core/application.h b/esphome/core/application.h index 122f9b39c7..47976fd57b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -895,8 +895,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Also woken by wake_loop_threadsafe() - // from background tasks, or timeout. + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the delay_ms timeout. if (delay_ms == 0) [[unlikely]] { yield(); return; From 107915fe36bfeb00130d250a5b5102f3be196084 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 20:59:12 -1000 Subject: [PATCH 0064/2214] [socket] Dedupe fast_select hook logic into shared helper Lift the lwip_sock resolve + event-callback hook sequence into socket::fast_select_hook_fd() in socket.h so the USE_LWIP_FAST_SELECT constructor blocks in lwip_sockets_impl.cpp and bsd_sockets_impl.cpp stop drifting in lockstep. Both impls now collapse to a two-line call site. --- esphome/components/socket/bsd_sockets_impl.cpp | 9 ++------- esphome/components/socket/lwip_sockets_impl.cpp | 9 ++------- esphome/components/socket/socket.h | 11 +++++++++++ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 2d4e0ea7c2..d55523ff77 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -14,13 +14,8 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - // Cache lwip_sock pointer (used by ready() for direct rcvevent reads) and hook the - // netconn event callback so the main loop is notified via FreeRTOS task notifications. - this->cached_sock_ = esphome_lwip_get_sock(this->fd_); - if (this->cached_sock_ != nullptr) { - esphome_lwip_hook_socket(this->cached_sock_); - this->loop_monitored_ = true; - } + this->cached_sock_ = fast_select_hook_fd(this->fd_); + this->loop_monitored_ = this->cached_sock_ != nullptr; #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index c65d3a227d..475381548b 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -14,13 +14,8 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - // Cache lwip_sock pointer (used by ready() for direct rcvevent reads) and hook the - // netconn event callback so the main loop is notified via FreeRTOS task notifications. - this->cached_sock_ = esphome_lwip_get_sock(this->fd_); - if (this->cached_sock_ != nullptr) { - esphome_lwip_hook_socket(this->cached_sock_); - this->loop_monitored_ = true; - } + this->cached_sock_ = fast_select_hook_fd(this->fd_); + this->loop_monitored_ = this->cached_sock_ != nullptr; #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index ad55e889e8..c33a5c42b6 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -45,6 +45,17 @@ using ListenSocket = LWIPRawListenImpl; inline bool socket_ready(struct lwip_sock *cached_sock, bool loop_monitored) { return !loop_monitored || (cached_sock != nullptr && esphome_lwip_socket_has_data(cached_sock)); } + +/// Resolve an fd to its lwip_sock and hook the netconn event callback so the main loop +/// is woken by FreeRTOS task notifications. Shared between BSD and LwIP socket impls. +/// Returns the cached lwip_sock pointer (or nullptr if fd is invalid). +inline struct lwip_sock *fast_select_hook_fd(int fd) { + struct lwip_sock *sock = esphome_lwip_get_sock(fd); + if (sock != nullptr) { + esphome_lwip_hook_socket(sock); + } + return sock; +} #elif defined(USE_HOST) /// Shared ready() helper for fd-based socket implementations. /// Checks if the Application's select() loop has marked this fd as ready. From b9541cd5db107ecaa95d0dc2eedfbbc0a1623344 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 21:05:21 -1000 Subject: [PATCH 0065/2214] [socket] Eliminate closed_ and loop_monitored_ redundancy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate closed_ bool with fd_ < 0 as the 'not open' sentinel. close() now sets fd_ = -1 after the underlying close call, so the destructor and double-close paths just check fd_ < 0. As a side benefit, get_fd() on a closed socket now returns -1, making use-after-close visible to callers instead of returning a stale descriptor. Drop loop_monitored_ on the USE_LWIP_FAST_SELECT path — the pointer cached_sock_ already encodes monitoring state (non-null iff monitored). On USE_HOST the bool is still needed because there is no cached pointer to derive from. Combined effect on the fast-select path: Before: fd_(4) + cached_sock_(4) + closed_(1) + loop_monitored_(1) + pad(2) = 12 bytes per socket After: fd_(4) + cached_sock_(4) = 8 bytes per socket (aligned, no tail padding) Saves 4 bytes per Socket instance on ESP32/LibreTiny. With typical workloads running 5-10 sockets (API listen + clients + mDNS) that's 20-40 bytes of RAM. --- .../components/socket/bsd_sockets_impl.cpp | 33 ++++++++----------- esphome/components/socket/bsd_sockets_impl.h | 9 +++-- .../components/socket/lwip_sockets_impl.cpp | 33 ++++++++----------- esphome/components/socket/lwip_sockets_impl.h | 9 +++-- esphome/components/socket/socket.h | 9 +++-- 5 files changed, 48 insertions(+), 45 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index d55523ff77..baf030f2b1 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -15,33 +15,28 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { return; #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = fast_select_hook_fd(this->fd_); - this->loop_monitored_ = this->cached_sock_ != nullptr; #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif } -BSDSocketImpl::~BSDSocketImpl() { - if (!this->closed_) { - this->close(); - } -} +BSDSocketImpl::~BSDSocketImpl() { this->close(); } int BSDSocketImpl::close() { - if (!this->closed_) { -#ifndef USE_LWIP_FAST_SELECT - // All LwIP sockets share the same static event_callback, so on the fast-select path - // there is no per-socket unhook needed. cached_sock_ is not cleared because closed_ - // makes the socket a corpse — no ready() or other member access is valid afterwards. - if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); - } -#endif - int ret = ::close(this->fd_); - this->closed_ = true; - return ret; + if (this->fd_ < 0) { + // Already closed, or never opened. + return 0; } - return 0; +#ifndef USE_LWIP_FAST_SELECT + // On the fast-select path there is no per-socket unhook needed — all LwIP sockets + // share the same static event_callback. + if (this->loop_monitored_) { + App.unregister_socket_fd(this->fd_); + } +#endif + int ret = ::close(this->fd_); + this->fd_ = -1; // Sentinel for "closed" — prevents double-close and makes use-after-close visible. + return ret; } int BSDSocketImpl::setblocking(bool blocking) { diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index e520784702..64b6adf94a 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -119,12 +119,17 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } protected: + // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This + // replaces a separate closed_ flag: close() sets fd_ = -1 after ::close(), and the + // destructor / double-close path just check fd_ < 0. int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT + // Non-null iff this socket is being monitored for read events. Replaces loop_monitored_ + // on the fast-select path: the pointer itself carries the "monitored" bit. struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() -#endif - bool closed_{false}; +#else bool loop_monitored_{false}; +#endif }; } // namespace esphome::socket diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index 475381548b..52c94bc7f6 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -15,33 +15,28 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { return; #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = fast_select_hook_fd(this->fd_); - this->loop_monitored_ = this->cached_sock_ != nullptr; #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif } -LwIPSocketImpl::~LwIPSocketImpl() { - if (!this->closed_) { - this->close(); - } -} +LwIPSocketImpl::~LwIPSocketImpl() { this->close(); } int LwIPSocketImpl::close() { - if (!this->closed_) { -#ifndef USE_LWIP_FAST_SELECT - // All LwIP sockets share the same static event_callback, so on the fast-select path - // there is no per-socket unhook needed. cached_sock_ is not cleared because closed_ - // makes the socket a corpse — no ready() or other member access is valid afterwards. - if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); - } -#endif - int ret = lwip_close(this->fd_); - this->closed_ = true; - return ret; + if (this->fd_ < 0) { + // Already closed, or never opened. + return 0; } - return 0; +#ifndef USE_LWIP_FAST_SELECT + // On the fast-select path there is no per-socket unhook needed — all LwIP sockets + // share the same static event_callback. + if (this->loop_monitored_) { + App.unregister_socket_fd(this->fd_); + } +#endif + int ret = lwip_close(this->fd_); + this->fd_ = -1; // Sentinel for "closed" — prevents double-close and makes use-after-close visible. + return ret; } int LwIPSocketImpl::setblocking(bool blocking) { diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 942d0ccf85..6e275eb6d1 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -85,12 +85,17 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } protected: + // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This + // replaces a separate closed_ flag: close() sets fd_ = -1 after lwip_close(), and the + // destructor / double-close path just check fd_ < 0. int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT + // Non-null iff this socket is being monitored for read events. Replaces loop_monitored_ + // on the fast-select path: the pointer itself carries the "monitored" bit. struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() -#endif - bool closed_{false}; +#else bool loop_monitored_{false}; +#endif }; } // namespace esphome::socket diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index c33a5c42b6..739628ef43 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -42,8 +42,11 @@ using ListenSocket = LWIPRawListenImpl; #ifdef USE_LWIP_FAST_SELECT /// Shared ready() helper using cached lwip_sock pointer for direct rcvevent read. -inline bool socket_ready(struct lwip_sock *cached_sock, bool loop_monitored) { - return !loop_monitored || (cached_sock != nullptr && esphome_lwip_socket_has_data(cached_sock)); +/// cached_sock == nullptr means the socket is not monitored (monitor_loop was false, fd +/// was invalid, or esphome_lwip_get_sock() failed) — in that case return true so the +/// caller attempts the read and handles blocking itself. +inline bool socket_ready(struct lwip_sock *cached_sock) { + return cached_sock == nullptr || esphome_lwip_socket_has_data(cached_sock); } /// Resolve an fd to its lwip_sock and hook the netconn event callback so the main loop @@ -80,7 +83,7 @@ bool socket_ready_fd(int fd, bool loop_monitored); #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) inline bool Socket::ready() const { #ifdef USE_LWIP_FAST_SELECT - return socket_ready(this->cached_sock_, this->loop_monitored_); + return socket_ready(this->cached_sock_); #else return socket_ready_fd(this->fd_, this->loop_monitored_); #endif From 884dab4a6a98bc125976d3dd7ed8bf2ea9b3f90b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 21:12:51 -1000 Subject: [PATCH 0066/2214] [socket] Null cached_sock_ in close() to prevent post-close UAF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the cached_sock_ = nullptr assignment inside close() on the fast-select path. The lwip slot can be recycled for a new connection as soon as the underlying close() returns, so any dereference of cached_sock_ afterwards would touch an unrelated socket's pcb. No current caller does this — setsockopt(TCP_NODELAY) and ready() are the only consumers and neither is invoked post-close today — but leaving the pointer dangling is a footgun for future changes. The fd_ = -1 sentinel alone would catch the ready() path via closed semantics, but setsockopt() reaches cached_sock_ directly and would not be protected. Null the pointer so the protection is by construction rather than by caller discipline. --- esphome/components/socket/bsd_sockets_impl.cpp | 11 ++++++++--- esphome/components/socket/lwip_sockets_impl.cpp | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index baf030f2b1..b73440ef55 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -27,9 +27,14 @@ int BSDSocketImpl::close() { // Already closed, or never opened. return 0; } -#ifndef USE_LWIP_FAST_SELECT - // On the fast-select path there is no per-socket unhook needed — all LwIP sockets - // share the same static event_callback. +#ifdef USE_LWIP_FAST_SELECT + // Null the cached lwip_sock pointer before closing. The underlying lwip slot can be + // recycled for a new connection as soon as ::close() returns, so anything that might + // dereference cached_sock_ post-close (e.g. setsockopt(TCP_NODELAY)) would otherwise + // touch an unrelated socket's pcb. No per-socket callback unhook is needed — + // all LwIP sockets share the same static event_callback. + this->cached_sock_ = nullptr; +#else if (this->loop_monitored_) { App.unregister_socket_fd(this->fd_); } diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index 52c94bc7f6..618d17aabe 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -27,9 +27,14 @@ int LwIPSocketImpl::close() { // Already closed, or never opened. return 0; } -#ifndef USE_LWIP_FAST_SELECT - // On the fast-select path there is no per-socket unhook needed — all LwIP sockets - // share the same static event_callback. +#ifdef USE_LWIP_FAST_SELECT + // Null the cached lwip_sock pointer before closing. The underlying lwip slot can be + // recycled for a new connection as soon as lwip_close() returns, so anything that + // might dereference cached_sock_ post-close (e.g. setsockopt(TCP_NODELAY)) would + // otherwise touch an unrelated socket's pcb. No per-socket callback unhook is needed — + // all LwIP sockets share the same static event_callback. + this->cached_sock_ = nullptr; +#else if (this->loop_monitored_) { App.unregister_socket_fd(this->fd_); } From 87c053888417cd583f1be518cab23de0b3275f25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 21:27:23 -1000 Subject: [PATCH 0067/2214] [socket] Address Copilot review: tighten doc comments - cached_sock_ comment in both impl headers: drop the 'iff' wording since the pointer can also be null if esphome_lwip_get_sock() fails on a fd that was requested to be monitored. Document all three null cases explicitly, plus the close()-path nulling for UAF protection. - application.h register_socket_fd / unregister_socket_fd comment block: move inside the #ifdef USE_HOST so the generic 'register/unregister a socket' wording no longer implies these APIs exist on fast-select builds. Add a forward reference to fast_select_hook_fd for readers wondering where the ESP32/LibreTiny equivalent went. --- esphome/components/socket/bsd_sockets_impl.h | 10 +++++++--- esphome/components/socket/lwip_sockets_impl.h | 10 +++++++--- esphome/core/application.h | 8 +++++--- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 64b6adf94a..57c1a430a2 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -124,9 +124,13 @@ class BSDSocketImpl { // destructor / double-close path just check fd_ < 0. int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT - // Non-null iff this socket is being monitored for read events. Replaces loop_monitored_ - // on the fast-select path: the pointer itself carries the "monitored" bit. - struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() + // Cached lwip_sock pointer used for direct rcvevent reads in ready() on the + // fast-select path. Replaces loop_monitored_: null means this socket is not being + // monitored for read events — either monitoring was not requested, the fd was + // invalid, or esphome_lwip_get_sock() failed. Non-null means the netconn event + // callback was hooked and notifications are flowing. close() nulls this to prevent + // use-after-free via a recycled lwip slot. + struct lwip_sock *cached_sock_{nullptr}; #else bool loop_monitored_{false}; #endif diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 6e275eb6d1..7f3b706cd8 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -90,9 +90,13 @@ class LwIPSocketImpl { // destructor / double-close path just check fd_ < 0. int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT - // Non-null iff this socket is being monitored for read events. Replaces loop_monitored_ - // on the fast-select path: the pointer itself carries the "monitored" bit. - struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() + // Cached lwip_sock pointer used for direct rcvevent reads in ready() on the + // fast-select path. Replaces loop_monitored_: null means this socket is not being + // monitored for read events — either monitoring was not requested, the fd was + // invalid, or esphome_lwip_get_sock() failed. Non-null means the netconn event + // callback was hooked and notifications are flowing. close() nulls this to prevent + // use-after-free via a recycled lwip slot. + struct lwip_sock *cached_sock_{nullptr}; #else bool loop_monitored_{false}; #endif diff --git a/esphome/core/application.h b/esphome/core/application.h index 47976fd57b..8c9fff2b53 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -532,11 +532,13 @@ class Application { Scheduler scheduler; - /// Register/unregister a socket to be monitored for read events. - /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. #ifdef USE_HOST - /// Fallback select() path: monitors file descriptors. + /// Register/unregister a socket file descriptor with the host select() fallback loop. + /// USE_LWIP_FAST_SELECT builds do not use this API — sockets hook the lwIP netconn + /// event_callback directly (see socket.h fast_select_hook_fd) and rely on FreeRTOS + /// task notifications for wake-up. /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. + /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. /// @return true if registration was successful, false if fd exceeds limits bool register_socket_fd(int fd); void unregister_socket_fd(int fd); From 51316f61c95280f331d5661e9e107cfc233d26aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 21:32:53 -1000 Subject: [PATCH 0068/2214] [socket] Rename fast_select_hook_fd -> hook_fd_for_fast_select Disambiguates the verb-vs-noun parse of the original name. The new form reads as 'hook this fd for the fast-select path', matching the function's actual job. --- esphome/components/socket/bsd_sockets_impl.cpp | 2 +- esphome/components/socket/lwip_sockets_impl.cpp | 2 +- esphome/components/socket/socket.h | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index b73440ef55..92691b17ab 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -14,7 +14,7 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - this->cached_sock_ = fast_select_hook_fd(this->fd_); + this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index 618d17aabe..b4eba3febf 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -14,7 +14,7 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - this->cached_sock_ = fast_select_hook_fd(this->fd_); + this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 739628ef43..204113e4b2 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -49,10 +49,11 @@ inline bool socket_ready(struct lwip_sock *cached_sock) { return cached_sock == nullptr || esphome_lwip_socket_has_data(cached_sock); } -/// Resolve an fd to its lwip_sock and hook the netconn event callback so the main loop -/// is woken by FreeRTOS task notifications. Shared between BSD and LwIP socket impls. -/// Returns the cached lwip_sock pointer (or nullptr if fd is invalid). -inline struct lwip_sock *fast_select_hook_fd(int fd) { +/// Resolve an fd to its lwip_sock and install the netconn event-callback hook so the +/// main loop is woken by FreeRTOS task notifications when data arrives. Shared between +/// BSD and LwIP socket impls on the fast-select path. Returns the cached lwip_sock +/// pointer (or nullptr if the fd does not map to a valid lwip_sock). +inline struct lwip_sock *hook_fd_for_fast_select(int fd) { struct lwip_sock *sock = esphome_lwip_get_sock(fd); if (sock != nullptr) { esphome_lwip_hook_socket(sock); From 4fdd715004dd98572528c6b3013f6663abc4f8f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 21:35:00 -1000 Subject: [PATCH 0069/2214] [core] Fix stale forward reference to renamed helper application.h was still pointing at fast_select_hook_fd in a doc comment after 51316f61c9 renamed it to hook_fd_for_fast_select. --- esphome/core/application.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 8c9fff2b53..ad529d8098 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -535,7 +535,7 @@ class Application { #ifdef USE_HOST /// Register/unregister a socket file descriptor with the host select() fallback loop. /// USE_LWIP_FAST_SELECT builds do not use this API — sockets hook the lwIP netconn - /// event_callback directly (see socket.h fast_select_hook_fd) and rely on FreeRTOS + /// event_callback directly (see socket.h hook_fd_for_fast_select) and rely on FreeRTOS /// task notifications for wake-up. /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. From f3357f47c60d95f7ba0ef31940175258dc884cf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:53:16 +0000 Subject: [PATCH 0070/2214] Bump aioesphomeapi from 44.13.2 to 44.13.3 Bumps [aioesphomeapi](https://github.com/esphome/aioesphomeapi) from 44.13.2 to 44.13.3. - [Release notes](https://github.com/esphome/aioesphomeapi/releases) - [Commits](https://github.com/esphome/aioesphomeapi/compare/v44.13.2...v44.13.3) --- updated-dependencies: - dependency-name: aioesphomeapi dependency-version: 44.13.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 12e7658e43..0a3978009a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.13.2 +aioesphomeapi==44.13.3 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 59d0f9dcbd78cf62e828b577175eb54a00f272f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 23:44:35 -1000 Subject: [PATCH 0071/2214] [status_led] Disable loop when idle and switch blink timing to deadline-based --- esphome/components/status_led/status_led.cpp | 33 +++++++++++--- esphome/components/status_led/status_led.h | 2 + esphome/core/application.cpp | 45 +++++++++----------- 3 files changed, 48 insertions(+), 32 deletions(-) diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index a792110eeb..87035f6284 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -7,6 +7,11 @@ namespace status_led { static const char *const TAG = "status_led"; +static constexpr uint32_t ERROR_ON_MS = 150; +static constexpr uint32_t ERROR_OFF_MS = 100; +static constexpr uint32_t WARNING_ON_MS = 250; +static constexpr uint32_t WARNING_OFF_MS = 1250; + StatusLED *global_status_led = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) StatusLED::StatusLED(GPIOPin *pin) : pin_(pin) { global_status_led = this; } @@ -19,13 +24,29 @@ void StatusLED::dump_config() { LOG_PIN(" Pin: ", this->pin_); } void StatusLED::loop() { - if ((App.get_app_state() & STATUS_LED_ERROR) != 0u) { - this->pin_->digital_write(millis() % 250u < 150u); - } else if ((App.get_app_state() & STATUS_LED_WARNING) != 0u) { - this->pin_->digital_write(millis() % 1500u < 250u); - } else { - this->pin_->digital_write(false); + const uint32_t app_state = App.get_app_state(); + const bool error = (app_state & STATUS_LED_ERROR) != 0u; + const bool warning = (app_state & STATUS_LED_WARNING) != 0u; + if (!error && !warning) { + if (this->led_on_) { + this->led_on_ = false; + this->pin_->digital_write(false); + } + this->disable_loop(); + return; } + const uint32_t now = App.get_loop_component_start_time(); + if ((int32_t) (now - this->next_toggle_) < 0) + return; + this->led_on_ = !this->led_on_; + uint32_t delay; + if (error) { + delay = this->led_on_ ? ERROR_ON_MS : ERROR_OFF_MS; + } else { + delay = this->led_on_ ? WARNING_ON_MS : WARNING_OFF_MS; + } + this->next_toggle_ = now + delay; + this->pin_->digital_write(this->led_on_); } float StatusLED::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index a4b5db93d7..16b6dcf736 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -17,6 +17,8 @@ class StatusLED : public Component { protected: GPIOPin *pin_; + uint32_t next_toggle_{0}; + bool led_on_{false}; }; extern StatusLED *global_status_led; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd75859880..d83b6bf2be 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -206,7 +206,14 @@ void HOT Application::feed_wdt(uint32_t time) { last_feed = now; #ifdef USE_STATUS_LED if (status_led::global_status_led != nullptr) { - status_led::global_status_led->call(); + auto *sl = status_led::global_status_led; + // If an error or warning bit is set while status_led's loop is disabled, re-enable it + // so it starts blinking again. disable_loop() is called from loop() itself when idle. + if ((sl->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE && + (this->app_state_ & STATUS_LED_MASK) != 0) { + sl->enable_loop(); + } + sl->call(); } #endif } @@ -449,32 +456,12 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_LWIP_FAST_SELECT -bool Application::register_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - if (sock == nullptr) - return false; - esphome_lwip_hook_socket(sock); - this->monitored_sockets_.push_back(sock); - return true; -} +#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) +// C trampoline called from lwip_fast_select.c when the listener filter matches. +extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } +#endif -void Application::unregister_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { - if (this->monitored_sockets_[i] != sock) - continue; - - // Swap with last element and pop - O(1) removal since order doesn't matter. - // No need to unhook the netconn callback — all LwIP sockets share the same - // static event_callback, and the socket will be closed by the caller. - if (i < this->monitored_sockets_.size() - 1) - this->monitored_sockets_[i] = this->monitored_sockets_.back(); - this->monitored_sockets_.pop_back(); - return; - } -} -#elif defined(USE_HOST) +#ifdef USE_HOST bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking @@ -554,6 +541,12 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected if (ret >= 0) [[likely]] { +#ifdef USE_OTA + // No-op today — host has no esphome OTA platform, so ota_wake_component_ is null. + if (ret > 0) { + this->wake_ota_component_any_context(); + } +#endif // Yield if zero timeout since select(0) only polls without yielding if (delay_ms == 0) [[unlikely]] { yield(); From af7fdc98879778e4f81c36a216c8711e75d4fa0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 23:49:56 -1000 Subject: [PATCH 0072/2214] [status_led] Simplify loop body, rely on disable_loop for idle optimization --- esphome/components/status_led/status_led.cpp | 30 ++++++-------------- esphome/components/status_led/status_led.h | 2 -- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index 87035f6284..90216d244d 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -7,10 +7,10 @@ namespace status_led { static const char *const TAG = "status_led"; +static constexpr uint32_t ERROR_PERIOD_MS = 250; static constexpr uint32_t ERROR_ON_MS = 150; -static constexpr uint32_t ERROR_OFF_MS = 100; +static constexpr uint32_t WARNING_PERIOD_MS = 1500; static constexpr uint32_t WARNING_ON_MS = 250; -static constexpr uint32_t WARNING_OFF_MS = 1250; StatusLED *global_status_led = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -25,28 +25,14 @@ void StatusLED::dump_config() { } void StatusLED::loop() { const uint32_t app_state = App.get_app_state(); - const bool error = (app_state & STATUS_LED_ERROR) != 0u; - const bool warning = (app_state & STATUS_LED_WARNING) != 0u; - if (!error && !warning) { - if (this->led_on_) { - this->led_on_ = false; - this->pin_->digital_write(false); - } - this->disable_loop(); - return; - } - const uint32_t now = App.get_loop_component_start_time(); - if ((int32_t) (now - this->next_toggle_) < 0) - return; - this->led_on_ = !this->led_on_; - uint32_t delay; - if (error) { - delay = this->led_on_ ? ERROR_ON_MS : ERROR_OFF_MS; + if ((app_state & STATUS_LED_ERROR) != 0u) { + this->pin_->digital_write(App.get_loop_component_start_time() % ERROR_PERIOD_MS < ERROR_ON_MS); + } else if ((app_state & STATUS_LED_WARNING) != 0u) { + this->pin_->digital_write(App.get_loop_component_start_time() % WARNING_PERIOD_MS < WARNING_ON_MS); } else { - delay = this->led_on_ ? WARNING_ON_MS : WARNING_OFF_MS; + this->pin_->digital_write(false); + this->disable_loop(); } - this->next_toggle_ = now + delay; - this->pin_->digital_write(this->led_on_); } float StatusLED::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index 16b6dcf736..a4b5db93d7 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -17,8 +17,6 @@ class StatusLED : public Component { protected: GPIOPin *pin_; - uint32_t next_toggle_{0}; - bool led_on_{false}; }; extern StatusLED *global_status_led; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From c9a68baa69c417da7c40d1cb7fe24d60d821a46f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 23:58:36 -1000 Subject: [PATCH 0073/2214] [status_led] Remove unrelated changes from application.cpp --- esphome/core/application.cpp | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d83b6bf2be..1766f35dd6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -456,12 +456,32 @@ void Application::enable_pending_loops_() { } } -#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) -// C trampoline called from lwip_fast_select.c when the listener filter matches. -extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } -#endif +#ifdef USE_LWIP_FAST_SELECT +bool Application::register_socket(struct lwip_sock *sock) { + // It modifies monitored_sockets_ without locking — must only be called from the main loop. + if (sock == nullptr) + return false; + esphome_lwip_hook_socket(sock); + this->monitored_sockets_.push_back(sock); + return true; +} -#ifdef USE_HOST +void Application::unregister_socket(struct lwip_sock *sock) { + // It modifies monitored_sockets_ without locking — must only be called from the main loop. + for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { + if (this->monitored_sockets_[i] != sock) + continue; + + // Swap with last element and pop - O(1) removal since order doesn't matter. + // No need to unhook the netconn callback — all LwIP sockets share the same + // static event_callback, and the socket will be closed by the caller. + if (i < this->monitored_sockets_.size() - 1) + this->monitored_sockets_[i] = this->monitored_sockets_.back(); + this->monitored_sockets_.pop_back(); + return; + } +} +#elif defined(USE_HOST) bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking @@ -541,12 +561,6 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected if (ret >= 0) [[likely]] { -#ifdef USE_OTA - // No-op today — host has no esphome OTA platform, so ota_wake_component_ is null. - if (ret > 0) { - this->wake_ota_component_any_context(); - } -#endif // Yield if zero timeout since select(0) only polls without yielding if (delay_ms == 0) [[unlikely]] { yield(); From 0f80c16507904c9f3caa0abb7818dbe958e13f85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 00:03:55 -1000 Subject: [PATCH 0074/2214] [status_led] Skip feed_wdt dispatch entirely when idle --- esphome/core/application.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 1766f35dd6..03091a4141 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -207,13 +207,16 @@ void HOT Application::feed_wdt(uint32_t time) { #ifdef USE_STATUS_LED if (status_led::global_status_led != nullptr) { auto *sl = status_led::global_status_led; - // If an error or warning bit is set while status_led's loop is disabled, re-enable it - // so it starts blinking again. disable_loop() is called from loop() itself when idle. - if ((sl->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE && - (this->app_state_ & STATUS_LED_MASK) != 0) { - sl->enable_loop(); + if ((sl->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { + // Loop was disabled by status_led itself when it went idle. Only re-enable (and + // dispatch) if an error or warning bit has been set since; otherwise skip entirely. + if ((this->app_state_ & STATUS_LED_MASK) != 0) { + sl->enable_loop(); + sl->call(); + } + } else { + sl->call(); } - sl->call(); } #endif } From fcd1aa5a40e3f01736ec7bc8ba2d23e4345458d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 00:05:57 -1000 Subject: [PATCH 0075/2214] [status_led] Hoist state read and dispatch loop() directly from feed_wdt --- esphome/core/application.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 03091a4141..4d1a48da4c 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -207,16 +207,17 @@ void HOT Application::feed_wdt(uint32_t time) { #ifdef USE_STATUS_LED if (status_led::global_status_led != nullptr) { auto *sl = status_led::global_status_led; - if ((sl->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { - // Loop was disabled by status_led itself when it went idle. Only re-enable (and - // dispatch) if an error or warning bit has been set since; otherwise skip entirely. - if ((this->app_state_ & STATUS_LED_MASK) != 0) { - sl->enable_loop(); - sl->call(); - } - } else { - sl->call(); + uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; + if (sl_state == COMPONENT_STATE_LOOP) { + sl->loop(); + } else if (sl_state == COMPONENT_STATE_LOOP_DONE && (this->app_state_ & STATUS_LED_MASK) != 0) { + // status_led disabled its own loop when it went idle; an error or warning bit has + // been set since, so re-enable and dispatch it. Safe to call loop() directly: + // LOOP_DONE means setup has already run. + sl->enable_loop(); + sl->loop(); } + // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. } #endif } From f6311c38466025df93dfb2a6300f9b7a1435f71e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 00:07:22 -1000 Subject: [PATCH 0076/2214] [status_led] Use millis() to survive blocking waits via feed_wdt --- esphome/components/status_led/status_led.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index 90216d244d..48762a7333 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -25,10 +25,14 @@ void StatusLED::dump_config() { } void StatusLED::loop() { const uint32_t app_state = App.get_app_state(); + // Use millis() rather than App.get_loop_component_start_time() because this loop is also + // dispatched from Application::feed_wdt() during long blocking operations, where the cached + // per-component timestamp doesn't advance and would freeze the blink pattern. + const uint32_t now = millis(); if ((app_state & STATUS_LED_ERROR) != 0u) { - this->pin_->digital_write(App.get_loop_component_start_time() % ERROR_PERIOD_MS < ERROR_ON_MS); + this->pin_->digital_write(now % ERROR_PERIOD_MS < ERROR_ON_MS); } else if ((app_state & STATUS_LED_WARNING) != 0u) { - this->pin_->digital_write(App.get_loop_component_start_time() % WARNING_PERIOD_MS < WARNING_ON_MS); + this->pin_->digital_write(now % WARNING_PERIOD_MS < WARNING_ON_MS); } else { this->pin_->digital_write(false); this->disable_loop(); From 56029457f48fba67f544d72faf4bed9e4f5c8f01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 00:13:05 -1000 Subject: [PATCH 0077/2214] [status_led] Restructure feed_wdt to tail-call status_led loop() --- esphome/core/application.cpp | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 4d1a48da4c..5576d22bfe 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -208,16 +208,18 @@ void HOT Application::feed_wdt(uint32_t time) { if (status_led::global_status_led != nullptr) { auto *sl = status_led::global_status_led; uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; - if (sl_state == COMPONENT_STATE_LOOP) { - sl->loop(); - } else if (sl_state == COMPONENT_STATE_LOOP_DONE && (this->app_state_ & STATUS_LED_MASK) != 0) { - // status_led disabled its own loop when it went idle; an error or warning bit has - // been set since, so re-enable and dispatch it. Safe to call loop() directly: - // LOOP_DONE means setup has already run. + if (sl_state == COMPONENT_STATE_LOOP_DONE) { + // status_led disabled its own loop when it went idle. Only re-dispatch if an error + // or warning bit has been set since. Safe to call loop() directly here: LOOP_DONE + // means setup has already run. + if ((this->app_state_ & STATUS_LED_MASK) == 0) + return; sl->enable_loop(); - sl->loop(); + } else if (sl_state != COMPONENT_STATE_LOOP) { + // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. + return; } - // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. + sl->loop(); } #endif } From 7eb56e1cf6911cae93ac21c5fc21916fdd52d985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 00:14:40 -1000 Subject: [PATCH 0078/2214] [status_led] Clarify feed_wdt LOOP_DONE comment --- esphome/core/application.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 5576d22bfe..63c9d65b3f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -209,9 +209,10 @@ void HOT Application::feed_wdt(uint32_t time) { auto *sl = status_led::global_status_led; uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; if (sl_state == COMPONENT_STATE_LOOP_DONE) { - // status_led disabled its own loop when it went idle. Only re-dispatch if an error - // or warning bit has been set since. Safe to call loop() directly here: LOOP_DONE - // means setup has already run. + // status_led only transitions to LOOP_DONE from inside its own loop() (after the + // first idle-path dispatch), so its pin is already initialized by pre_setup() and + // its setup() has already run. Re-dispatch only if an error or warning bit has been + // set since; otherwise skip entirely. if ((this->app_state_ & STATUS_LED_MASK) == 0) return; sl->enable_loop(); From e67fac704c7512538c72e7eebdd8399344179ac5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 08:17:49 -1000 Subject: [PATCH 0079/2214] [esphome.ota] Move OTA wake pointer out of Application into ota_esphome.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following the zephyr_mcumgr precedent, keep the single-instance pointer as a file-scope static inside ota_esphome.cpp instead of plumbing an ota_wake_component_ slot and setter through Application. The extern "C" wake trampoline also lives in the same TU now, so nothing in core/ touches OTA-specific state. Guard the C and C++ call sites on USE_OTA_PLATFORM_ESPHOME (added as a -D flag from components/esphome/ota/__init__.py) instead of the broader ESPHOME_USE_OTA that base ota/ used to emit — the trampoline symbol only exists when the esphome OTA platform is actually compiled in. Also drop the dead host yield_with_select_ wake hook (host has no OTA platform today). --- esphome/components/esphome/ota/__init__.py | 4 ++++ esphome/components/esphome/ota/ota_esphome.cpp | 18 +++++++++++++++++- esphome/components/ota/__init__.py | 3 --- .../components/socket/lwip_raw_tcp_impl.cpp | 14 +++++++++----- esphome/core/application.cpp | 11 ----------- esphome/core/application.h | 15 --------------- esphome/core/lwip_fast_select.c | 11 ++++++----- 7 files changed, 36 insertions(+), 40 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index c8cb9920ba..de2e1e4aad 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -155,6 +155,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_PASSWORD") cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) + # Build flag (not a define in defines.h) so lwip_fast_select.c — a .c file that + # can't include defines.h — can still gate its wake hook on the esphome OTA + # platform being compiled in. + cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") await cg.register_component(var, config) await ota_to_code(var, config) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 041aa4bbf9..39275d5fcd 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -31,6 +31,22 @@ static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +// File-scope pointer to the single ESPHomeOTAComponent instance. The final_validate in +// __init__.py rejects multi-port configs, so only one exists. Set in setup() and used +// by the extern "C" wake trampoline below, which is called from socket wake paths +// (lwip_fast_select listener filter, raw-TCP accept_fn_). +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static ESPHomeOTAComponent *global_esphome_ota_component = nullptr; + +// Trampoline called from C and C++ socket wake paths. Must be safe from any context +// (LwIP TCP/IP task, user-level IRQ on RP2040). enable_loop_soon_any_context() uses +// volatile flags only — no locks, no allocations. +extern "C" void esphome_wake_ota_component_any_context() { + if (global_esphome_ota_component != nullptr) { + global_esphome_ota_component->enable_loop_soon_any_context(); + } +} + void ESPHomeOTAComponent::setup() { this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections if (this->server_ == nullptr) { @@ -70,7 +86,7 @@ void ESPHomeOTAComponent::setup() { } // loop() self-disables on its first idle tick; no explicit disable_loop() needed here. - App.set_ota_wake_component(this); + global_esphome_ota_component = this; #ifdef USE_LWIP_FAST_SELECT // Filter fast-select wakes to this listener only. If the sock lookup returns nullptr, // no wakes fire and loop() falls back to the self-disable safety net. diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index e0de31f239..8f31eb5cdd 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -103,9 +103,6 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): cg.add_define("USE_OTA") - # Separate -D flag so .c files can see it — .c can't include defines.h (macros.h - # pulls in Arduino.h). Different name avoids USE_OTA redefinition warnings. - cg.add_build_flag("-DESPHOME_USE_OTA") CORE.add_job(final_step) if CORE.is_rp2040 and CORE.using_arduino: diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 04605f1e55..0022809dc1 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -10,8 +10,11 @@ #include "esphome/core/helpers.h" #include "esphome/core/wake.h" #include "esphome/core/log.h" -#ifdef USE_OTA -#include "esphome/core/application.h" + +#ifdef USE_OTA_PLATFORM_ESPHOME +// Defined in components/esphome/ota/ota_esphome.cpp. Marks the ESPHome OTA component +// pending-enable from the raw-TCP accept callback (per-pcb, implicitly filtered). +extern "C" void esphome_wake_ota_component_any_context(); #endif #ifdef USE_ESP8266 @@ -857,10 +860,11 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn); tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); -#ifdef USE_OTA +#ifdef USE_OTA_PLATFORM_ESPHOME // Mark OTA pending-enable before the wake below — flags must be visible before the - // main task runs. accept_fn_ is per-pcb, so filtering is implicit here. - esphome::App.wake_ota_component_any_context(); + // main task runs. accept_fn_ is per-pcb, so filtering is implicit here (only fires + // for completed handshakes to whichever listener owns newpcb). + esphome_wake_ota_component_any_context(); #endif // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index c847c173f1..cd75859880 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -449,11 +449,6 @@ void Application::enable_pending_loops_() { } } -#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) -// C trampoline called from lwip_fast_select.c when the listener filter matches. -extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } -#endif - #ifdef USE_LWIP_FAST_SELECT bool Application::register_socket(struct lwip_sock *sock) { // It modifies monitored_sockets_ without locking — must only be called from the main loop. @@ -559,12 +554,6 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected if (ret >= 0) [[likely]] { -#ifdef USE_OTA - // No-op today — host has no esphome OTA platform, so ota_wake_component_ is null. - if (ret > 0) { - this->wake_ota_component_any_context(); - } -#endif // Yield if zero timeout since select(0) only polls without yielding if (delay_ms == 0) [[unlikely]] { yield(); diff --git a/esphome/core/application.h b/esphome/core/application.h index 8c9789774b..6b2969b490 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -559,18 +559,6 @@ class Application { /// Wake from any context (ISR, thread, callback). static void IRAM_ATTR wake_loop_any_context() { esphome::wake_loop_any_context(); } -#ifdef USE_OTA - void set_ota_wake_component(Component *component) { this->ota_wake_component_ = component; } - // Marks OTA pending-enable from socket wake paths. Does NOT wake the main task — - // every caller already does. Callable from LwIP TCP/IP task and user-IRQ context. - void wake_ota_component_any_context() { - if (this->ota_wake_component_ != nullptr) { - this->ota_wake_component_->pending_enable_loop_ = true; - this->has_pending_enable_loop_requests_ = true; - } - } -#endif - protected: friend Component; #ifdef USE_HOST @@ -646,9 +634,6 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; -#ifdef USE_OTA - Component *ota_wake_component_{nullptr}; -#endif // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index f5cfe7f0fc..e29ea5684f 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,11 +157,12 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; -#ifdef ESPHOME_USE_OTA -// ESPHOME_USE_OTA (not USE_OTA): this .c file can't include defines.h — macros.h → -// Arduino.h would break the C compile. ota/__init__.py emits -DESPHOME_USE_OTA. +#ifdef USE_OTA_PLATFORM_ESPHOME +// This .c file can't include defines.h — macros.h → Arduino.h would break the C +// compile. esphome/ota/__init__.py emits -DUSE_OTA_PLATFORM_ESPHOME via +// cg.add_build_flag so both this TU and C++ call sites can see the same name. static struct netconn *s_ota_listener_conn = NULL; -extern void esphome_wake_ota_component_any_context(void); // trampoline in application.cpp +extern void esphome_wake_ota_component_any_context(void); // defined in ota_esphome.cpp void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL; @@ -184,7 +185,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { -#ifdef ESPHOME_USE_OTA +#ifdef USE_OTA_PLATFORM_ESPHOME // Mark OTA pending-enable only for events on its listen socket. MUST happen // before xTaskNotifyGive so the flags are visible when the main task wakes. if (conn == s_ota_listener_conn) { From 30de0c17abe522dbff783c439605fe9985ebf11c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 08:21:11 -1000 Subject: [PATCH 0080/2214] [esphome.ota] Trim verbose comments from simplification --- esphome/components/esphome/ota/__init__.py | 4 +--- esphome/components/esphome/ota/ota_esphome.cpp | 9 ++------- esphome/components/socket/lwip_raw_tcp_impl.cpp | 6 +----- esphome/core/lwip_fast_select.c | 5 +---- 4 files changed, 5 insertions(+), 19 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index de2e1e4aad..5d35910fbd 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -155,9 +155,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_PASSWORD") cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) - # Build flag (not a define in defines.h) so lwip_fast_select.c — a .c file that - # can't include defines.h — can still gate its wake hook on the esphome OTA - # platform being compiled in. + # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") await cg.register_component(var, config) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 39275d5fcd..47f661a8ea 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -31,16 +31,11 @@ static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer -// File-scope pointer to the single ESPHomeOTAComponent instance. The final_validate in -// __init__.py rejects multi-port configs, so only one exists. Set in setup() and used -// by the extern "C" wake trampoline below, which is called from socket wake paths -// (lwip_fast_select listener filter, raw-TCP accept_fn_). +// Single-instance pointer — multi-port configs are rejected in final_validate. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static ESPHomeOTAComponent *global_esphome_ota_component = nullptr; -// Trampoline called from C and C++ socket wake paths. Must be safe from any context -// (LwIP TCP/IP task, user-level IRQ on RP2040). enable_loop_soon_any_context() uses -// volatile flags only — no locks, no allocations. +// Called from any context (LwIP TCP/IP task, RP2040 user-IRQ). extern "C" void esphome_wake_ota_component_any_context() { if (global_esphome_ota_component != nullptr) { global_esphome_ota_component->enable_loop_soon_any_context(); diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 0022809dc1..c6692b0165 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -12,8 +12,6 @@ #include "esphome/core/log.h" #ifdef USE_OTA_PLATFORM_ESPHOME -// Defined in components/esphome/ota/ota_esphome.cpp. Marks the ESPHome OTA component -// pending-enable from the raw-TCP accept callback (per-pcb, implicitly filtered). extern "C" void esphome_wake_ota_component_any_context(); #endif @@ -861,9 +859,7 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); #ifdef USE_OTA_PLATFORM_ESPHOME - // Mark OTA pending-enable before the wake below — flags must be visible before the - // main task runs. accept_fn_ is per-pcb, so filtering is implicit here (only fires - // for completed handshakes to whichever listener owns newpcb). + // Must run before wake_loop_any_context() so flags are visible when the main task wakes. esphome_wake_ota_component_any_context(); #endif // Wake the main loop immediately so it can accept the new connection. diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index e29ea5684f..36000d4e77 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -158,11 +158,8 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN static netconn_callback s_original_callback = NULL; #ifdef USE_OTA_PLATFORM_ESPHOME -// This .c file can't include defines.h — macros.h → Arduino.h would break the C -// compile. esphome/ota/__init__.py emits -DUSE_OTA_PLATFORM_ESPHOME via -// cg.add_build_flag so both this TU and C++ call sites can see the same name. static struct netconn *s_ota_listener_conn = NULL; -extern void esphome_wake_ota_component_any_context(void); // defined in ota_esphome.cpp +extern void esphome_wake_ota_component_any_context(void); void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL; From ea0ba99c28b525164104d4a6dfe2882719ff0357 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 08:40:17 -1000 Subject: [PATCH 0081/2214] [core] Drop stale register_socket defs after ota merge PR #15639 removed Application::register_socket / monitored_sockets_ on the fast-select path before the ota-disable-loop-when-idle merge. The merge re-introduced the function definitions without the matching declarations. Drop them. --- esphome/core/application.cpp | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 63c9d65b3f..324849d2ee 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -463,32 +463,7 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_LWIP_FAST_SELECT -bool Application::register_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - if (sock == nullptr) - return false; - esphome_lwip_hook_socket(sock); - this->monitored_sockets_.push_back(sock); - return true; -} - -void Application::unregister_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { - if (this->monitored_sockets_[i] != sock) - continue; - - // Swap with last element and pop - O(1) removal since order doesn't matter. - // No need to unhook the netconn callback — all LwIP sockets share the same - // static event_callback, and the socket will be closed by the caller. - if (i < this->monitored_sockets_.size() - 1) - this->monitored_sockets_[i] = this->monitored_sockets_.back(); - this->monitored_sockets_.pop_back(); - return; - } -} -#elif defined(USE_HOST) +#ifdef USE_HOST bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking From ddbf6f2347119006963a261f0d307f45ac0df658 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 14:32:49 -1000 Subject: [PATCH 0082/2214] [core] Inline feed_wdt hot path with out-of-line slow path Split Application::feed_wdt() into an ALWAYS_INLINE wrapper that checks the 3ms rate limit against last_wdt_feed_ and a feed_wdt_slow_() callee that performs the actual arch_feed_wdt() + status LED re-dispatch. Callers on the hot path (loop_task before/after each component) that already have a millis() timestamp in hand now pay only a load + sub + branch on the no-op path instead of a full call8 / entry / retw. Moves the rate-limit state from a function-local static to a class member (last_wdt_feed_) so the inline can access it. --- esphome/core/application.cpp | 11 ++++++----- esphome/core/application.h | 20 ++++++++++++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd75859880..d1aa461d0e 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -196,14 +196,15 @@ void Application::process_dump_config_() { this->dump_config_at_++; } -void HOT Application::feed_wdt(uint32_t time) { - static uint32_t last_feed = 0; +void HOT Application::feed_wdt_slow_(uint32_t time) { // Use provided time if available, otherwise get current time uint32_t now = time ? time : millis(); - // Compare in milliseconds (3ms threshold) - if (now - last_feed > 3) { + // Compare in milliseconds (3ms threshold). The inline wrapper already + // performs this check when time != 0; repeat it here for the time == 0 + // entry and as a safety net. + if (now - this->last_wdt_feed_ > 3) { arch_feed_wdt(); - last_feed = now; + this->last_wdt_feed_ = now; #ifdef USE_STATUS_LED if (status_led::global_status_led != nullptr) { status_led::global_status_led->call(); diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b490..923cbfdb03 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -385,7 +385,19 @@ class Application { void schedule_dump_config() { this->dump_config_at_ = 0; } - void feed_wdt(uint32_t time = 0); + /// Feed the task watchdog. Hot-path inline rate-limit check: callers that + /// already have a timestamp in hand pay only a load + sub + branch on the + /// common (no-op) path. The actual arch feed + status LED update live in + /// feed_wdt_slow_ to keep this small enough to inline freely. + /// + /// Pass time==0 to request millis() be read for you (low-frequency callers + /// only — always takes the slow path). + void ESPHOME_ALWAYS_INLINE feed_wdt(uint32_t time = 0) { + if (time != 0 && static_cast(time - this->last_wdt_feed_) <= 3) { + return; + } + this->feed_wdt_slow_(time); + } void reboot(); @@ -615,7 +627,10 @@ class Application { /// Caller must ensure dump_config_at_ < components_.size(). void __attribute__((noinline)) process_dump_config_(); - void feed_wdt_arch_(); + /// Slow path for feed_wdt(): actually calls arch_feed_wdt(), updates + /// last_wdt_feed_, and re-dispatches the status LED. Out of line so the + /// inline wrapper stays tiny. + void feed_wdt_slow_(uint32_t time); /// Perform a delay while also monitoring socket file descriptors for readiness #ifdef USE_HOST @@ -669,6 +684,7 @@ class Application { // 4-byte members uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; + uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path #ifdef USE_HOST int max_fd_{-1}; // Highest file descriptor number for select() From a70ec9ec06931956d8ea8279e8820b147cebfdf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 14:51:59 -1000 Subject: [PATCH 0083/2214] [scheduler] Feed watchdog after each scheduled item, drop top-of-loop feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main loop used to feed the watchdog unconditionally right after Scheduler::call() returned, regardless of whether the scheduler had any actual work to do. On an idle device this meant every outer loop iteration paid the inline rate-limit check (load + sub + branch) for no benefit. Move the feed into Scheduler::execute_item_() so it fires only after a scheduled callback actually runs, and covers both the main heap path and the defer queue path (both go through execute_item_). This also bounds the max feed gap during a burst of back-to-back scheduled items by max(item_runtime) instead of sum(item_runtime). The top-of-loop feed in Application::before_loop_tasks_() is now unnecessary — when Scheduler::call does no work, the only elapsed time is the sleep wake plus a few instructions, and when it does have work, it fed the wdt as it went. --- esphome/core/application.h | 9 +++++---- esphome/core/scheduler.cpp | 8 +++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 923cbfdb03..f8e9b0e360 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -829,12 +829,13 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ this->drain_wake_notifications_(); #endif - // Process scheduled tasks + // Process scheduled tasks. Scheduler::call now feeds the watchdog itself + // after each scheduled item that actually runs, so we no longer need an + // unconditional feed here — when Scheduler::call has no work to do, the + // only elapsed time is a sleep wake + a few instructions, and when it does + // have work, it fed the wdt as it went. this->scheduler.call(loop_start_time); - // Feed the watchdog timer - this->feed_wdt(loop_start_time); - // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions if (this->has_pending_enable_loop_requests_) { diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index dff50b03ef..7c9263583d 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -739,7 +739,13 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); - return guard.finish(); + uint32_t end = guard.finish(); + // Feed the watchdog after each scheduled item (both main heap and defer + // queue paths go through here). A run of back-to-back callbacks cannot + // starve the wdt. The inline fast path is a load + sub + branch — nearly + // free when the 3 ms rate limit hasn't elapsed. + App.feed_wdt(end); + return end; } // Common implementation for cancel operations - handles locking From 715f0ca6f7c7d1dec65d4fd2e2670c4122c8e141 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 14:54:02 -1000 Subject: [PATCH 0084/2214] [core] Extract WDT_FEED_INTERVAL_MS constexpr Replace the magic 3 in both the inline feed_wdt check and the slow path with a named constexpr so the rate-limit threshold is defined once. --- esphome/core/application.cpp | 7 +++---- esphome/core/application.h | 7 ++++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d1aa461d0e..84a1fc346f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -199,10 +199,9 @@ void Application::process_dump_config_() { void HOT Application::feed_wdt_slow_(uint32_t time) { // Use provided time if available, otherwise get current time uint32_t now = time ? time : millis(); - // Compare in milliseconds (3ms threshold). The inline wrapper already - // performs this check when time != 0; repeat it here for the time == 0 - // entry and as a safety net. - if (now - this->last_wdt_feed_ > 3) { + // The inline wrapper already performs this check when time != 0; + // repeat it here for the time == 0 entry and as a safety net. + if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { arch_feed_wdt(); this->last_wdt_feed_ = now; #ifdef USE_STATUS_LED diff --git a/esphome/core/application.h b/esphome/core/application.h index f8e9b0e360..7cde0bf85d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -385,6 +385,11 @@ class Application { void schedule_dump_config() { this->dump_config_at_ = 0; } + /// Minimum interval between real arch_feed_wdt() calls. Chosen to keep the + /// rate of HAL pokes low while still being small enough that any plausible + /// watchdog timeout (seconds) has orders of magnitude of safety margin. + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3; + /// Feed the task watchdog. Hot-path inline rate-limit check: callers that /// already have a timestamp in hand pay only a load + sub + branch on the /// common (no-op) path. The actual arch feed + status LED update live in @@ -393,7 +398,7 @@ class Application { /// Pass time==0 to request millis() be read for you (low-frequency callers /// only — always takes the slow path). void ESPHOME_ALWAYS_INLINE feed_wdt(uint32_t time = 0) { - if (time != 0 && static_cast(time - this->last_wdt_feed_) <= 3) { + if (time != 0 && static_cast(time - this->last_wdt_feed_) <= WDT_FEED_INTERVAL_MS) { return; } this->feed_wdt_slow_(time); From dc5626eb855829ad8f54fa9a1e9908831f65eed3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 14:57:01 -1000 Subject: [PATCH 0085/2214] [core] Invert feed_wdt condition so slow-path call is inside the if MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleaner than the early-return form — the action (calling feed_wdt_slow_) reads as the body of the conditional instead of falling through past a guard clause. Logically identical and compiles to the same code. --- esphome/core/application.h | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 7cde0bf85d..02af5664a4 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -398,10 +398,9 @@ class Application { /// Pass time==0 to request millis() be read for you (low-frequency callers /// only — always takes the slow path). void ESPHOME_ALWAYS_INLINE feed_wdt(uint32_t time = 0) { - if (time != 0 && static_cast(time - this->last_wdt_feed_) <= WDT_FEED_INTERVAL_MS) { - return; + if (time == 0 || static_cast(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) { + this->feed_wdt_slow_(time); } - this->feed_wdt_slow_(time); } void reboot(); From 5926ca5369843c8e99df7f2d57e843243b644117 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 15:19:35 -1000 Subject: [PATCH 0086/2214] [core] Split feed_wdt into hot and cold entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate Application::feed_wdt() into two entry points so the hot path callers stop paying for the time==0 check they never trigger: - feed_wdt_with_time(time): inline, hot path. Rate-limit check in 3 Xtensa instructions (load + sub + branch). [[unlikely]] tells the compiler the slow branch is rare so the common path stays fall-through. - feed_wdt(): cold, out of line. Fetches millis() and forwards through the same rate limit. Used by setup loops, upload helpers, yield(), and any other non-hot caller. feed_wdt_slow_() is now pure worker code — 11 bytes. It just calls arch_feed_wdt(), updates last_wdt_feed_, and runs the status LED re-dispatch. Both entries have already confirmed the rate limit was exceeded before calling. Hot call sites updated: - Application::loop() per-component feed - Scheduler::execute_item_() after each scheduled item runs - Application::teardown_components() inner loop (already has 'now') --- esphome/core/application.cpp | 31 ++++++++++++++++++------------- esphome/core/application.h | 21 +++++++++++---------- esphome/core/scheduler.cpp | 2 +- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 84a1fc346f..36038c6440 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -196,21 +196,26 @@ void Application::process_dump_config_() { this->dump_config_at_++; } -void HOT Application::feed_wdt_slow_(uint32_t time) { - // Use provided time if available, otherwise get current time - uint32_t now = time ? time : millis(); - // The inline wrapper already performs this check when time != 0; - // repeat it here for the time == 0 entry and as a safety net. +void Application::feed_wdt() { + // Cold entry: callers without a millis() timestamp in hand. Fetches the + // time and takes the same rate-limit path as feed_wdt_with_time(). + uint32_t now = millis(); if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { - arch_feed_wdt(); - this->last_wdt_feed_ = now; -#ifdef USE_STATUS_LED - if (status_led::global_status_led != nullptr) { - status_led::global_status_led->call(); - } -#endif + this->feed_wdt_slow_(now); } } + +void HOT Application::feed_wdt_slow_(uint32_t time) { + // Callers (both feed_wdt() and feed_wdt_with_time()) have already + // confirmed the 3 ms rate limit was exceeded. + arch_feed_wdt(); + this->last_wdt_feed_ = time; +#ifdef USE_STATUS_LED + if (status_led::global_status_led != nullptr) { + status_led::global_status_led->call(); + } +#endif +} void Application::reboot() { ESP_LOGI(TAG, "Forcing a reboot"); for (auto &component : std::ranges::reverse_view(this->components_)) { @@ -299,7 +304,7 @@ void Application::teardown_components(uint32_t timeout_ms) { while (pending_count > 0 && (now - start_time) < timeout_ms) { // Feed watchdog during teardown to prevent triggering - this->feed_wdt(now); + this->feed_wdt_with_time(now); // Process components and compact the array, keeping only those still pending size_t still_pending = 0; diff --git a/esphome/core/application.h b/esphome/core/application.h index 02af5664a4..52b976b8bd 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -390,15 +390,16 @@ class Application { /// watchdog timeout (seconds) has orders of magnitude of safety margin. static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3; - /// Feed the task watchdog. Hot-path inline rate-limit check: callers that - /// already have a timestamp in hand pay only a load + sub + branch on the - /// common (no-op) path. The actual arch feed + status LED update live in - /// feed_wdt_slow_ to keep this small enough to inline freely. - /// - /// Pass time==0 to request millis() be read for you (low-frequency callers - /// only — always takes the slow path). - void ESPHOME_ALWAYS_INLINE feed_wdt(uint32_t time = 0) { - if (time == 0 || static_cast(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) { + /// Feed the task watchdog. Cold entry — callers without a millis() + /// timestamp in hand. Out of line to keep call sites tiny. + void feed_wdt(); + + /// Feed the task watchdog, hot entry. Callers that already have a + /// millis() timestamp pay only a load + sub + branch on the common + /// (no-op) path. The actual arch feed + status LED update live in + /// feed_wdt_slow_. + void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) { + if (static_cast(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] { this->feed_wdt_slow_(time); } } @@ -882,7 +883,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { } new_app_state |= component->get_component_state(); this->app_state_ |= new_app_state; - this->feed_wdt(last_op_end_time); + this->feed_wdt_with_time(last_op_end_time); } this->after_loop_tasks_(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 7c9263583d..3e75a68064 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -744,7 +744,7 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { // queue paths go through here). A run of back-to-back callbacks cannot // starve the wdt. The inline fast path is a load + sub + branch — nearly // free when the 3 ms rate limit hasn't elapsed. - App.feed_wdt(end); + App.feed_wdt_with_time(end); return end; } From d9c6b27cf2fbc2ae425cf9a186d6ac46af5b2bfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 16:05:46 -1000 Subject: [PATCH 0087/2214] [core] Drop per-iteration app_state_ accumulation from main loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application::loop() used to, on every iteration of the inner component loop, OR each component's state into a local accumulator and then OR that into this->app_state_, finally overwriting this->app_state_ with the accumulator after the loop. That was ~5 instructions per component per loop iteration on the hot path, paying to rebuild state that's already kept current elsewhere. STATUS_LED_WARNING / STATUS_LED_ERROR are the only app_state_ bits anything reads. Component::set_status_flag_() already writes them to App.app_state_ directly the moment they're set, so any reader (status_led components, feed_wdt's LED re-dispatch) already sees them without the per-iter OR. The per-iter OR only ever re-set bits that were already there — pure dead code on the hot path. The clear path is handled by moving the work into status_clear_warning/error_slow_path_(): on a real set->clear transition the helpers walk App.components_ once to check if any other component still has the flag, and clear App.app_state_ if not. Clears are rare relative to loop iterations (a logged transition event), so O(N) per clear is far cheaper than O(N) per loop. Setup subtlety: Application::setup()'s slow-setup busy-wait forces STATUS_LED_WARNING on to blink the LED while a slow component initializes. A component clear during setup would prematurely wipe that forced bit, so status_clear_warning_slow_path_() gates its walk-and-clear behind APP_STATE_SETUP_COMPLETE (a free bit on app_state_ — zero RAM cost). Application::setup() reconciles the warning state once at the end and sets the flag. STATUS_LED_ERROR is never forced so its clear path always walks. Also fixes a pre-existing bug: the old per-iter accumulation only iterated looping_components_, so non-looping components' status bits would get clobbered by the end-of-loop 'app_state_ = new_app_state' overwrite. The walk-on-clear approach iterates components_ (all of them), so non-looping components are respected. --- esphome/core/application.cpp | 34 ++++++++++++++++++++++++++++++---- esphome/core/application.h | 24 ++++++++++++++++++------ esphome/core/component.cpp | 13 +++++++++++++ esphome/core/component.h | 5 +++++ 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd75859880..0c17c70161 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -85,8 +85,12 @@ void Application::setup() { if (component->can_proceed()) continue; + // Force the status LED to blink WARNING while we wait for a slow + // component to come up. Cleared after setup() finishes if no real + // component has warning set. + this->app_state_ |= STATUS_LED_WARNING; + do { - uint8_t new_app_state = STATUS_LED_WARNING; uint32_t now = millis(); // Process pending loop enables to handle GPIO interrupts during setup @@ -96,17 +100,26 @@ void Application::setup() { // Update loop_component_start_time_ right before calling each component this->loop_component_start_time_ = millis(); this->components_[j]->call(); - new_app_state |= this->components_[j]->get_component_state(); - this->app_state_ |= new_app_state; this->feed_wdt(); } this->after_loop_tasks_(); - this->app_state_ = new_app_state; yield(); } while (!component->can_proceed() && !component->is_failed()); } + // Setup is complete. Reconcile STATUS_LED_WARNING: the slow-setup path + // above may have forced it on, and any status_clear_warning() calls + // from components during setup were intentional no-ops (gated by + // APP_STATE_SETUP_COMPLETE). Walk components once here to pick up the + // real state. STATUS_LED_ERROR is never artificially forced, so its + // clear path always works and needs no reconciliation. Finally, set + // APP_STATE_SETUP_COMPLETE so subsequent warning clears go through + // the normal walk-and-clear path. + if (!this->any_component_has_status_flag_(STATUS_LED_WARNING)) + this->app_state_ &= ~STATUS_LED_WARNING; + this->app_state_ |= APP_STATE_SETUP_COMPLETE; + ESP_LOGI(TAG, "setup() finished successfully!"); #ifdef USE_SETUP_PRIORITY_OVERRIDE @@ -211,6 +224,19 @@ void HOT Application::feed_wdt(uint32_t time) { #endif } } +bool Application::any_component_has_status_flag_(uint8_t flag) const { + // Walk all components (not just looping ones) so non-looping components' + // status bits are respected. Only called from the slow-path clear helpers + // (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an + // actual set→clear transition, so walking O(N) here is paid once per + // transition — not once per loop iteration. + for (auto *component : this->components_) { + if ((component->get_component_state() & flag) != 0) + return true; + } + return false; +} + void Application::reboot() { ESP_LOGI(TAG, "Forcing a reboot"); for (auto &component : std::ranges::reverse_view(this->components_)) { diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b490..1a36058deb 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -401,7 +401,24 @@ class Application { */ void teardown_components(uint32_t timeout_ms); - uint8_t get_app_state() const { return this->app_state_; } + /// Return the public app state bits (STATUS_LED_* and lifecycle). + /// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked + /// out so external readers (status_led components, etc.) never see them. + uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; } + + /// True once Application::setup() has finished walking all components + /// and finalized the initial status flags. Before this point, the + /// slow-setup busy-wait may be forcing STATUS_LED_WARNING on, and + /// status_clear_* intentionally skips its walk-and-clear step so the + /// forced bit doesn't get wiped. Stored as a free bit on app_state_ + /// (bit 6) to avoid costing additional RAM. + bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; } + + /// Walk all registered components looking for any whose component_state_ + /// has the given flag set. Used by Component::status_clear_*_slow_path_() + /// to decide whether to clear the corresponding bit on this->app_state_ + /// (which is the app-wide "any component has this status" indicator). + bool any_component_has_status_flag_(uint8_t flag) const; // Helper macro for entity getter method declarations #ifdef USE_DEVICES @@ -838,8 +855,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ } inline void ESPHOME_ALWAYS_INLINE Application::loop() { - uint8_t new_app_state = 0; - // Get the initial loop time at the start uint32_t last_op_end_time = millis(); @@ -859,13 +874,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); } - new_app_state |= component->get_component_state(); - this->app_state_ |= new_app_state; this->feed_wdt(last_op_end_time); } this->after_loop_tasks_(); - this->app_state_ = new_app_state; #ifdef USE_RUNTIME_STATS // Process any pending runtime stats printing after all components have run diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index deda42b0a7..8949b4b76d 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -411,10 +411,23 @@ void Component::status_set_error(const LogString *message) { } void Component::status_clear_warning_slow_path_() { this->component_state_ &= ~STATUS_LED_WARNING; + // Clear the app-wide STATUS_LED_WARNING bit only if setup has finished + // AND no other component still has it set. During setup the forced + // STATUS_LED_WARNING (from the slow-setup busy-wait) must not be wiped + // by a transient component clear — Application::setup() reconciles + // the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE. + // The set path is unchanged (set_status_flag_ still writes directly). + if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING)) + App.app_state_ &= ~STATUS_LED_WARNING; ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_clear_error_slow_path_() { this->component_state_ &= ~STATUS_LED_ERROR; + // STATUS_LED_ERROR is never artificially forced — it only ever lands + // in app_state_ via a real set_status_flag_ call. So the walk-and-clear + // path is always safe, including during setup. + if (!App.any_component_has_status_flag_(STATUS_LED_ERROR)) + App.app_state_ &= ~STATUS_LED_ERROR; ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_momentary_warning(const char *name, uint32_t length) { diff --git a/esphome/core/component.h b/esphome/core/component.h index e2b7aa85d3..3307c5ae76 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -89,6 +89,11 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08; inline constexpr uint8_t STATUS_LED_ERROR = 0x10; // Component loop override flag uses bit 5 (set at registration time) inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20; +// Bit 6 on Application::app_state_ (ONLY) — set at the end of +// Application::setup(). Component::status_clear_*_slow_path_() uses this to +// decide whether to propagate clears to App.app_state_. Never set on a +// Component's component_state_. +inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40; // Remove before 2026.8.0 enum class RetryResult { DONE, RETRY }; From 424f1c7b0a5ddbcc6dddacc1522a77d28bf389ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 16:20:05 -1000 Subject: [PATCH 0088/2214] [core] Rename any_component_has_status_flag and add integration tests Drop the trailing underscore from any_component_has_status_flag now that the method is public. Trailing underscores in the codebase are reserved for protected/private members per clang-tidy naming rules, which caused a CI failure on the previously-named public helper. Add integration tests covering: - Single-component status_set/clear for warning and error - Multi-component OR semantics (both clear orders) - Warning and error independence - End-to-end proof that status_led_light::loop() reads App.app_state_ and writes its output when the bits are set (via a fake template output whose write_action bumps a counter exposed as a sensor) --- esphome/core/application.cpp | 4 +- esphome/core/application.h | 2 +- esphome/core/component.cpp | 4 +- tests/integration/fixtures/status_flags.yaml | 141 +++++++++++++++ tests/integration/test_status_flags.py | 179 +++++++++++++++++++ 5 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/status_flags.yaml create mode 100644 tests/integration/test_status_flags.py diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 0c17c70161..73fa462e10 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -116,7 +116,7 @@ void Application::setup() { // clear path always works and needs no reconciliation. Finally, set // APP_STATE_SETUP_COMPLETE so subsequent warning clears go through // the normal walk-and-clear path. - if (!this->any_component_has_status_flag_(STATUS_LED_WARNING)) + if (!this->any_component_has_status_flag(STATUS_LED_WARNING)) this->app_state_ &= ~STATUS_LED_WARNING; this->app_state_ |= APP_STATE_SETUP_COMPLETE; @@ -224,7 +224,7 @@ void HOT Application::feed_wdt(uint32_t time) { #endif } } -bool Application::any_component_has_status_flag_(uint8_t flag) const { +bool Application::any_component_has_status_flag(uint8_t flag) const { // Walk all components (not just looping ones) so non-looping components' // status bits are respected. Only called from the slow-path clear helpers // (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an diff --git a/esphome/core/application.h b/esphome/core/application.h index 1a36058deb..2e5e937842 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -418,7 +418,7 @@ class Application { /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// to decide whether to clear the corresponding bit on this->app_state_ /// (which is the app-wide "any component has this status" indicator). - bool any_component_has_status_flag_(uint8_t flag) const; + bool any_component_has_status_flag(uint8_t flag) const; // Helper macro for entity getter method declarations #ifdef USE_DEVICES diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 8949b4b76d..7605565658 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -417,7 +417,7 @@ void Component::status_clear_warning_slow_path_() { // by a transient component clear — Application::setup() reconciles // the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE. // The set path is unchanged (set_status_flag_ still writes directly). - if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING)) + if (App.is_setup_complete() && !App.any_component_has_status_flag(STATUS_LED_WARNING)) App.app_state_ &= ~STATUS_LED_WARNING; ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str())); } @@ -426,7 +426,7 @@ void Component::status_clear_error_slow_path_() { // STATUS_LED_ERROR is never artificially forced — it only ever lands // in app_state_ via a real set_status_flag_ call. So the walk-and-clear // path is always safe, including during setup. - if (!App.any_component_has_status_flag_(STATUS_LED_ERROR)) + if (!App.any_component_has_status_flag(STATUS_LED_ERROR)) App.app_state_ &= ~STATUS_LED_ERROR; ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str())); } diff --git a/tests/integration/fixtures/status_flags.yaml b/tests/integration/fixtures/status_flags.yaml new file mode 100644 index 0000000000..baa081e890 --- /dev/null +++ b/tests/integration/fixtures/status_flags.yaml @@ -0,0 +1,141 @@ +esphome: + name: status-flags-test + +host: +api: + actions: + # Warning flag services for sensor_a + - action: set_warning_a + then: + - lambda: "id(sensor_a)->status_set_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_warning_a + then: + - lambda: "id(sensor_a)->status_clear_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Warning flag services for sensor_b + - action: set_warning_b + then: + - lambda: "id(sensor_b)->status_set_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_warning_b + then: + - lambda: "id(sensor_b)->status_clear_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Error flag services for sensor_a + - action: set_error_a + then: + - lambda: "id(sensor_a)->status_set_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_error_a + then: + - lambda: "id(sensor_a)->status_clear_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Error flag services for sensor_b + - action: set_error_b + then: + - lambda: "id(sensor_b)->status_set_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_error_b + then: + - lambda: "id(sensor_b)->status_clear_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Snapshot of the status_led's written pin state for observation. + - action: snapshot_led + then: + - component.update: status_led_writes + - component.update: status_led_last_state + +logger: + +# Tracks each write to the fake status_led output. +globals: + - id: status_led_write_count + type: uint32_t + restore_value: no + initial_value: "0" + - id: status_led_last_write + type: bool + restore_value: no + initial_value: "false" + +# Fake binary output — status_led_light writes to this instead of a pin. +# Every write bumps a counter and records the last value, both of which +# are exposed below so the test can verify status_led_light's loop is +# actually reading App.get_app_state() and responding. +output: + - platform: template + id: fake_status_led + type: binary + write_action: + - globals.set: + id: status_led_write_count + value: !lambda "return id(status_led_write_count) + 1;" + - globals.set: + id: status_led_last_write + value: !lambda "return state;" + +# Actual status_led_light component under test. +light: + - platform: status_led + name: Status LED + id: status_led_light_id + output: fake_status_led + +sensor: + # Two components that the test will toggle warning/error flags on. + - platform: template + name: Sensor A + id: sensor_a + update_interval: 24h + lambda: return 1.0; + - platform: template + name: Sensor B + id: sensor_b + update_interval: 24h + lambda: return 2.0; + + # Expose App.app_state_'s STATUS_LED_WARNING / STATUS_LED_ERROR bits + # as 0.0 / 1.0. force_update ensures every manual component.update + # publishes even if the value is unchanged. + - platform: template + name: App Warning Bit + id: app_warning_bit + update_interval: 24h + force_update: true + lambda: |- + return (App.get_app_state() & STATUS_LED_WARNING) != 0 ? 1.0 : 0.0; + - platform: template + name: App Error Bit + id: app_error_bit + update_interval: 24h + force_update: true + lambda: |- + return (App.get_app_state() & STATUS_LED_ERROR) != 0 ? 1.0 : 0.0; + + # Observables for the fake status_led output. + - platform: template + name: Status LED Writes + id: status_led_writes + update_interval: 24h + force_update: true + lambda: return id(status_led_write_count); + - platform: template + name: Status LED Last State + id: status_led_last_state + update_interval: 24h + force_update: true + lambda: |- + return id(status_led_last_write) ? 1.0 : 0.0; diff --git a/tests/integration/test_status_flags.py b/tests/integration/test_status_flags.py new file mode 100644 index 0000000000..31c26a1a8a --- /dev/null +++ b/tests/integration/test_status_flags.py @@ -0,0 +1,179 @@ +"""Integration tests for Component::status_set/clear_warning/error propagation. + +Verifies that toggling STATUS_LED_WARNING / STATUS_LED_ERROR on individual +components correctly updates the app-wide bits on Application::app_state_, +AND that the status_led_light component actually responds to those bits +by writing to its output (the full chain from component.status_set_warning +→ App.app_state_ → status_led_light.loop() reading get_app_state()). + +Exercises the multi-component OR semantics (the app bit stays set while +any component still has the flag, and only clears when the last component +clears its bit), the independence of warning and error, and the actual +status_led_light read of the bits via a fake template output that counts +writes. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_status_flags( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + async with run_compiled(yaml_config), api_client_connected() as client: + entities, services = await client.list_entities_services() + + # Map every custom API service by name for the test to execute. + svc = {s.name: s for s in services} + for name in ( + "set_warning_a", + "clear_warning_a", + "set_warning_b", + "clear_warning_b", + "set_error_a", + "clear_error_a", + "set_error_b", + "clear_error_b", + "snapshot_led", + ): + assert name in svc, f"service {name} not registered" + + # Track every sensor we care about. SensorTracker gives us + # expect(value) / expect_any() futures that resolve when a + # matching state arrives; much simpler than manual bookkeeping. + tracker = SensorTracker( + [ + "app_warning_bit", + "app_error_bit", + "status_led_writes", + "status_led_last_state", + ] + ) + tracker.key_to_sensor.update( + build_key_to_entity_mapping(entities, list(tracker.sensor_states.keys())) + ) + + # Swallow initial state broadcasts so the test only reacts to + # state changes triggered by our service calls. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(tracker.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + async def call(name: str) -> None: + await client.execute_service(svc[name], {}) + + async def call_and_expect_bits( + service_name: str, *, warning: float, error: float + ) -> None: + """Execute a service and wait for both app bit sensors to match. + + Each bit-toggling service calls component.update on both + app_warning_bit and app_error_bit, so both sensors publish. + """ + futures = tracker.expect_all( + {"app_warning_bit": warning, "app_error_bit": error} + ) + await call(service_name) + await tracker.await_all(futures) + + async def snapshot_led_writes() -> int: + """Trigger a publish of the fake status_led output counter and return it.""" + future = tracker.expect_any("status_led_writes") + await call("snapshot_led") + await tracker.await_change(future, "status_led_writes") + return int(tracker.sensor_states["status_led_writes"][-1]) + + # ---- Baseline: everything clean, record the LED write count ---- + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + baseline_led_count = await snapshot_led_writes() + + # ================================================================ + # Part 1 — STATUS_LED_WARNING propagation to App.app_state_ + # ================================================================ + + # Single component set/clear + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # Multi-component OR: both set, clear A, bit stays (B still has it), clear B, gone + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_b", warning=0.0, error=0.0) + + # Opposite clear order + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # ================================================================ + # Part 2 — STATUS_LED_ERROR propagation (same scenarios) + # ================================================================ + + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) + + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("set_error_b", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0) + + # ================================================================ + # Part 3 — warning and error are independent + # ================================================================ + + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_error_b", warning=1.0, error=1.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0) + + # ================================================================ + # Part 4 — status_led_light actually reads App.app_state_ + # ================================================================ + # The fake status_led_light output increments status_led_write_count + # on every write. status_led_light::loop() writes its output on every + # iteration while an error/warning bit is set, so after holding a + # warning for ~300 ms we should see the counter move significantly. + # This is the end-to-end proof that the bits we set above actually + # reach status_led_light and drive its behavior. + + count_before_warning = await snapshot_led_writes() + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + # Let status_led_light's loop run long enough to toggle the pin + # several times (it reads get_app_state() every main loop iteration). + await asyncio.sleep(0.3) + count_after_warning = await snapshot_led_writes() + assert count_after_warning > count_before_warning, ( + "status_led_light did not respond to STATUS_LED_WARNING being set: " + f"write count stayed at {count_before_warning} → {count_after_warning}. " + "The full chain Component::status_set_warning → App.app_state_ → " + "status_led_light::loop reading get_app_state() is broken." + ) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # Same check for ERROR + count_before_error = await snapshot_led_writes() + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await asyncio.sleep(0.3) + count_after_error = await snapshot_led_writes() + assert count_after_error > count_before_error, ( + "status_led_light did not respond to STATUS_LED_ERROR being set: " + f"write count stayed at {count_before_error} → {count_after_error}. " + ) + await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) + + # Sanity: baseline snapshot used earlier isn't stale, counter is monotonic + assert count_after_error >= baseline_led_count From 1b1adb841a1215dd87f95b3f85d1b4d310962d4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 16:21:43 -1000 Subject: [PATCH 0089/2214] [core] Fix clang-format: remove extra blank line --- esphome/core/application.cpp | 4 ++-- esphome/core/application.h | 12 ++++++------ esphome/core/component.cpp | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 73fa462e10..0c17c70161 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -116,7 +116,7 @@ void Application::setup() { // clear path always works and needs no reconciliation. Finally, set // APP_STATE_SETUP_COMPLETE so subsequent warning clears go through // the normal walk-and-clear path. - if (!this->any_component_has_status_flag(STATUS_LED_WARNING)) + if (!this->any_component_has_status_flag_(STATUS_LED_WARNING)) this->app_state_ &= ~STATUS_LED_WARNING; this->app_state_ |= APP_STATE_SETUP_COMPLETE; @@ -224,7 +224,7 @@ void HOT Application::feed_wdt(uint32_t time) { #endif } } -bool Application::any_component_has_status_flag(uint8_t flag) const { +bool Application::any_component_has_status_flag_(uint8_t flag) const { // Walk all components (not just looping ones) so non-looping components' // status bits are respected. Only called from the slow-path clear helpers // (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an diff --git a/esphome/core/application.h b/esphome/core/application.h index 2e5e937842..19c1a1ecdb 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -414,12 +414,6 @@ class Application { /// (bit 6) to avoid costing additional RAM. bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; } - /// Walk all registered components looking for any whose component_state_ - /// has the given flag set. Used by Component::status_clear_*_slow_path_() - /// to decide whether to clear the corresponding bit on this->app_state_ - /// (which is the app-wide "any component has this status" indicator). - bool any_component_has_status_flag(uint8_t flag) const; - // Helper macro for entity getter method declarations #ifdef USE_DEVICES #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ @@ -594,6 +588,12 @@ class Application { bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } #endif + /// Walk all registered components looking for any whose component_state_ + /// has the given flag set. Used by Component::status_clear_*_slow_path_() + /// (which is a friend) to decide whether to clear the corresponding bit on + /// this->app_state_ (the app-wide "any component has this status" indicator). + bool any_component_has_status_flag_(uint8_t flag) const; + /// Register a component, detecting loop() override at compile time. /// Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance. template void register_component_(T *comp) { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 7605565658..8949b4b76d 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -417,7 +417,7 @@ void Component::status_clear_warning_slow_path_() { // by a transient component clear — Application::setup() reconciles // the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE. // The set path is unchanged (set_status_flag_ still writes directly). - if (App.is_setup_complete() && !App.any_component_has_status_flag(STATUS_LED_WARNING)) + if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING)) App.app_state_ &= ~STATUS_LED_WARNING; ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str())); } @@ -426,7 +426,7 @@ void Component::status_clear_error_slow_path_() { // STATUS_LED_ERROR is never artificially forced — it only ever lands // in app_state_ via a real set_status_flag_ call. So the walk-and-clear // path is always safe, including during setup. - if (!App.any_component_has_status_flag(STATUS_LED_ERROR)) + if (!App.any_component_has_status_flag_(STATUS_LED_ERROR)) App.app_state_ &= ~STATUS_LED_ERROR; ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str())); } From 542b1fc7f384aeb7d163cf4aaeadbe379c26252b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 16:28:54 -1000 Subject: [PATCH 0090/2214] =?UTF-8?q?[tests]=20Add=20set=E2=86=92clear?= =?UTF-8?q?=E2=86=92re-set=20round-trip=20check=20for=20status=5Fled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies that after clearing all status flags, re-setting a flag makes status_led_light resume writing to its output. Guards against a future idle optimization (like #15642) where status_led disables its own loop() when idle: if the re-enable path were broken, the second set would not produce writes. Also checks that writes STOP after all flags are cleared (counter should not keep growing), proving status_led_light correctly stops blinking in steady state. --- tests/integration/test_status_flags.py | 31 ++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_status_flags.py b/tests/integration/test_status_flags.py index 31c26a1a8a..58a0fa9c44 100644 --- a/tests/integration/test_status_flags.py +++ b/tests/integration/test_status_flags.py @@ -95,9 +95,8 @@ async def test_status_flags( await tracker.await_change(future, "status_led_writes") return int(tracker.sensor_states["status_led_writes"][-1]) - # ---- Baseline: everything clean, record the LED write count ---- + # ---- Baseline: everything clean ---- await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) - baseline_led_count = await snapshot_led_writes() # ================================================================ # Part 1 — STATUS_LED_WARNING propagation to App.app_state_ @@ -175,5 +174,29 @@ async def test_status_flags( ) await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) - # Sanity: baseline snapshot used earlier isn't stale, counter is monotonic - assert count_after_error >= baseline_led_count + # ---- Set → clear → re-set round-trip ---- + # After clearing, status_led_light stops writing (steady state). + # Re-setting the flag must make it resume. This guards against a + # future idle optimization (e.g. #15642) where status_led disables + # its own loop when idle: if the re-enable path were broken, the + # second set would not produce writes. + await asyncio.sleep(0.3) + count_after_idle = await snapshot_led_writes() + # Counter should NOT have grown much since the clear (at most +1 + # for the restore-state call on the clear transition). + assert count_after_idle - count_after_error <= 5, ( + "status_led_light kept writing after warning/error was cleared: " + f"count grew from {count_after_error} to {count_after_idle}. " + "Expected it to stop writing once all status bits were clear." + ) + # Re-set warning — writes must resume. + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await asyncio.sleep(0.3) + count_after_reset = await snapshot_led_writes() + assert count_after_reset > count_after_idle + 5, ( + "status_led_light did not resume writing after re-setting " + f"STATUS_LED_WARNING: count went from {count_after_idle} to " + f"{count_after_reset}. If an idle optimization disabled the " + "loop, the re-enable path may be broken." + ) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) From 515ec544cee70fd767baf70beb1ae5647ce75cd4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 16:32:15 -1000 Subject: [PATCH 0091/2214] [tests] Extract STATUS_LED_SETTLE_S constant, fix idle count check Extract the 0.3s magic sleep into a named constant explaining why that duration is chosen (feed_wdt re-dispatches every ~3 ms; 300 ms gives ~100 opportunities). Fix the idle-write check to snapshot AFTER the clear instead of before it, so writes still in-flight from the error phase don't inflate the delta. --- tests/integration/test_status_flags.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/integration/test_status_flags.py b/tests/integration/test_status_flags.py index 58a0fa9c44..fcc011b115 100644 --- a/tests/integration/test_status_flags.py +++ b/tests/integration/test_status_flags.py @@ -22,6 +22,12 @@ import pytest from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping from .types import APIClientConnectedFactory, RunCompiledFunction +# Time to let the main loop run so status_led_light writes to its output. +# feed_wdt re-dispatches every ~3 ms; 300 ms gives ~100 opportunities and +# is long enough that even a slow host-mode main loop produces measurable +# write-count changes. +STATUS_LED_SETTLE_S = 0.3 + @pytest.mark.asyncio async def test_status_flags( @@ -153,7 +159,7 @@ async def test_status_flags( await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) # Let status_led_light's loop run long enough to toggle the pin # several times (it reads get_app_state() every main loop iteration). - await asyncio.sleep(0.3) + await asyncio.sleep(STATUS_LED_SETTLE_S) count_after_warning = await snapshot_led_writes() assert count_after_warning > count_before_warning, ( "status_led_light did not respond to STATUS_LED_WARNING being set: " @@ -166,7 +172,7 @@ async def test_status_flags( # Same check for ERROR count_before_error = await snapshot_led_writes() await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) - await asyncio.sleep(0.3) + await asyncio.sleep(STATUS_LED_SETTLE_S) count_after_error = await snapshot_led_writes() assert count_after_error > count_before_error, ( "status_led_light did not respond to STATUS_LED_ERROR being set: " @@ -180,18 +186,20 @@ async def test_status_flags( # future idle optimization (e.g. #15642) where status_led disables # its own loop when idle: if the re-enable path were broken, the # second set would not produce writes. - await asyncio.sleep(0.3) + # + # Snapshot AFTER the clear to avoid counting writes that were still + # in-flight from the error-set phase. + count_after_clear = await snapshot_led_writes() + await asyncio.sleep(STATUS_LED_SETTLE_S) count_after_idle = await snapshot_led_writes() - # Counter should NOT have grown much since the clear (at most +1 - # for the restore-state call on the clear transition). - assert count_after_idle - count_after_error <= 5, ( + assert count_after_idle - count_after_clear <= 5, ( "status_led_light kept writing after warning/error was cleared: " - f"count grew from {count_after_error} to {count_after_idle}. " + f"count grew from {count_after_clear} to {count_after_idle}. " "Expected it to stop writing once all status bits were clear." ) # Re-set warning — writes must resume. await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) - await asyncio.sleep(0.3) + await asyncio.sleep(STATUS_LED_SETTLE_S) count_after_reset = await snapshot_led_writes() assert count_after_reset > count_after_idle + 5, ( "status_led_light did not resume writing after re-setting " From 8800c10181c56b785d44c9a34b73fac537c6eb53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 17:37:59 -1000 Subject: [PATCH 0092/2214] [core] Address review: fix docstrings per Copilot feedback - get_app_state(): say 'STATUS_LED_* only', not 'STATUS_LED_* and lifecycle' since lifecycle bits are no longer maintained in app_state_. - STATUS_LED_SETTLE_S: remove incorrect mention of feed_wdt re-dispatch; status_led_light is driven by the main loop, not feed_wdt. - snapshot_led service: say 'status_led_light output' not 'pin state' since the fixture uses a template output, not GPIO. --- esphome/core/application.h | 2 +- tests/integration/fixtures/status_flags.yaml | 2 +- tests/integration/test_status_flags.py | 7 +++---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index fc6b587154..374645d80a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -262,7 +262,7 @@ class Application { */ void teardown_components(uint32_t timeout_ms); - /// Return the public app state bits (STATUS_LED_* and lifecycle). + /// Return the public app state status bits (STATUS_LED_* only). /// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked /// out so external readers (status_led components, etc.) never see them. uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; } diff --git a/tests/integration/fixtures/status_flags.yaml b/tests/integration/fixtures/status_flags.yaml index baa081e890..cb118dcc84 100644 --- a/tests/integration/fixtures/status_flags.yaml +++ b/tests/integration/fixtures/status_flags.yaml @@ -52,7 +52,7 @@ api: - component.update: app_warning_bit - component.update: app_error_bit - # Snapshot of the status_led's written pin state for observation. + # Snapshot of the status_led_light's output state for observation. - action: snapshot_led then: - component.update: status_led_writes diff --git a/tests/integration/test_status_flags.py b/tests/integration/test_status_flags.py index fcc011b115..ffbc7c7f63 100644 --- a/tests/integration/test_status_flags.py +++ b/tests/integration/test_status_flags.py @@ -22,10 +22,9 @@ import pytest from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping from .types import APIClientConnectedFactory, RunCompiledFunction -# Time to let the main loop run so status_led_light writes to its output. -# feed_wdt re-dispatches every ~3 ms; 300 ms gives ~100 opportunities and -# is long enough that even a slow host-mode main loop produces measurable -# write-count changes. +# Time to let the host-mode main loop run so status_led_light.loop() can +# execute enough iterations to produce measurable write-count changes on +# the fake template output. 300 ms is well above the minimum needed. STATUS_LED_SETTLE_S = 0.3 From 6f534d9bd514fe8a0cf9d67001aa08688c6ce0de Mon Sep 17 00:00:00 2001 From: Schdro <83939986+schdro@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:16:48 +0200 Subject: [PATCH 0093/2214] [esp32] Use static stack for loop task instead of heap --- esphome/components/esp32/core.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 313818e601..add50dcf4d 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -61,6 +61,9 @@ uint32_t arch_get_cpu_freq_hz() { } TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static StackType_t + loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void loop_task(void *pv_params) { setup(); @@ -73,9 +76,11 @@ extern "C" void app_main() { initArduino(); esp32::setup_preferences(); #if CONFIG_FREERTOS_UNICORE - xTaskCreate(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle); + loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, + &loop_task_tcb); #else - xTaskCreatePinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle, 1); + loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, + loop_task_stack, &loop_task_tcb, 1); #endif } From 406546876a1be7391a4c9aed6f449b0343545c59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:16:38 -1000 Subject: [PATCH 0094/2214] [esp8266] Replace millis() with fast accumulator, wrap Arduino callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arduino ESP8266's millis() uses 4x 64-bit multiplies with magic constants to convert system_get_time() to ms while tracking overflow. On the LX106 (no hardware multiply-high instruction), each 64-bit multiply goes through the __umulsidi3 software helper — costing ~3.3 us per call. Replace with a simple accumulator that tracks a running millis counter from system_get_time() deltas using pure 32-bit integer ops (subtract, add, compare, subtract). No 64-bit math, no __umulsidi3. Use -Wl,--wrap=millis to intercept all ::millis() calls site-wide so Arduino libraries and ISR handlers (Wiegand, ZyAura) also get the fast version. Brief interrupt disable (~125 ns) protects the static state. Overflow safety: unsigned 32-bit delta arithmetic handles the 71-minute system_get_time() wrap correctly for one wrap. ESPHome calls millis() thousands of times per second, so missing a full wrap is not a realistic concern. At boot, both the accumulator and system_get_time() start at 0, so no special initialization is needed. Benchmarked on real ESP8266 hardware: Before: 3348 ns/call (Arduino 4x 64-bit multiply) After: ~800-900 ns/call (accumulator, estimated) --- esphome/components/esp8266/__init__.py | 5 +++ esphome/components/esp8266/core.cpp | 44 ++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index bef7e36470..34540bd48d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -314,6 +314,11 @@ async def to_code(config): for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap Arduino's millis() so all callers (including Arduino libraries and ISR + # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply + # implementation in the Arduino ESP8266 core. + cg.add_build_flag("-Wl,--wrap=millis") + cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 159ec20e77..a4a6c49cf6 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -16,8 +16,44 @@ extern "C" { namespace esphome { void HOT yield() { ::yield(); } -uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -uint64_t millis_64() { return Millis64Impl::compute(::millis()); } +// Arduino ESP8266's millis() uses 4× 64-bit multiplies with magic constants to +// convert system_get_time() → ms while tracking overflow (~3.3 μs per call on +// the LX106 which has no hardware multiply-high instruction). We replace it with +// a simple accumulator that tracks a running millis counter from μs deltas using +// pure 32-bit ops (subtract, add, compare-and-subtract). No __umulsidi3 software +// multiply calls. +// +// Overflow safety: system_get_time() is a uint32_t that wraps every ~71.6 minutes. +// Unsigned subtraction (now - last) handles one wrap correctly. ESPHome calls +// millis() thousands of times per second (1+N per loop iteration at 60+ Hz), so +// missing a full 71-minute wrap period is not a realistic concern. At boot, +// s_last_us starts at 0 and system_get_time() counts from 0, so the first call's +// delta equals the real elapsed time — no special initialization needed. +// +// This function is also installed as __wrap_millis (via -Wl,--wrap=millis) so +// that Arduino library code and ISR handlers (e.g. Wiegand, ZyAura) calling +// ::millis() directly also get the fast version. Interrupts are briefly disabled +// (~10 instructions, ~125 ns at 80 MHz) to protect the static state from +// concurrent ISR access. +static uint32_t IRAM_ATTR HOT millis_accumulator_() { + static uint32_t s_cache = 0; + static uint32_t s_remainder = 0; + static uint32_t s_last_us = 0; + uint32_t ps = xt_rsil(15); + uint32_t now_us = system_get_time(); + uint32_t delta = now_us - s_last_us; + s_last_us = now_us; + s_remainder += delta; + while (s_remainder >= 1000) { + s_cache++; + s_remainder -= 1000; + } + uint32_t result = s_cache; + xt_wsr_ps(ps); + return result; +} +uint32_t IRAM_ATTR HOT millis() { return millis_accumulator_(); } +uint64_t millis_64() { return Millis64Impl::compute(millis()); } void HOT delay(uint32_t ms) { ::delay(ms); } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } @@ -78,4 +114,8 @@ extern "C" void resetPins() { // NOLINT } // namespace esphome +// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. +// Requires -Wl,--wrap=millis in build flags (added by __init__.py). +extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } + #endif // USE_ESP8266 From 16f7effcf33edddcc4165f72ede716eb74f1223f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:21:57 -1000 Subject: [PATCH 0095/2214] [esp8266] Fix clang-format: drop trailing underscore from static function --- esphome/components/esp8266/core.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index a4a6c49cf6..0cbb4a008d 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -35,7 +35,7 @@ void HOT yield() { ::yield(); } // ::millis() directly also get the fast version. Interrupts are briefly disabled // (~10 instructions, ~125 ns at 80 MHz) to protect the static state from // concurrent ISR access. -static uint32_t IRAM_ATTR HOT millis_accumulator_() { +static uint32_t IRAM_ATTR HOT millis_accumulator() { static uint32_t s_cache = 0; static uint32_t s_remainder = 0; static uint32_t s_last_us = 0; @@ -52,7 +52,7 @@ static uint32_t IRAM_ATTR HOT millis_accumulator_() { xt_wsr_ps(ps); return result; } -uint32_t IRAM_ATTR HOT millis() { return millis_accumulator_(); } +uint32_t IRAM_ATTR HOT millis() { return millis_accumulator(); } uint64_t millis_64() { return Millis64Impl::compute(millis()); } void HOT delay(uint32_t ms) { ::delay(ms); } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } From dff6199fc8cb4bf46848bf519c8c7c778bc906e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:37:45 -1000 Subject: [PATCH 0096/2214] [esp8266] Add USE_FAST_MILLIS_ACCUMULATOR define for benchmark guard --- esphome/components/esp8266/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 34540bd48d..f8c37e3e94 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -318,6 +318,7 @@ async def to_code(config): # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply # implementation in the Arduino ESP8266 core. cg.add_build_flag("-Wl,--wrap=millis") + cg.add_define("USE_FAST_MILLIS_ACCUMULATOR") cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) From 325d9db499c4a5b346cab750fa491f656eaaf3ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:56:59 -1000 Subject: [PATCH 0097/2214] [esp8266] Replace delay() with yield loop to allow GC of original millis Arduino's ::delay() is implemented in core_esp8266_wiring.cpp alongside millis(). Because __delay calls millis() within the same compilation unit, --wrap=millis can't intercept that intra-object reference, which prevents the linker from garbage-collecting the original millis body (~80 bytes of IRAM). Replace esphome::delay() with a simple yield loop that uses our fast millis() accumulator. This has the same behavior as Arduino's delay: feeds the watchdog and processes SDK tasks via yield(), keeping WiFi alive. With __delay unreferenced, the linker can now GC both __delay and the original millis function. --- esphome/components/esp8266/core.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 0cbb4a008d..68ca8a903b 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -54,7 +54,17 @@ static uint32_t IRAM_ATTR HOT millis_accumulator() { } uint32_t IRAM_ATTR HOT millis() { return millis_accumulator(); } uint64_t millis_64() { return Millis64Impl::compute(millis()); } -void HOT delay(uint32_t ms) { ::delay(ms); } +// Avoid calling ::delay() which pulls in __delay from core_esp8266_wiring.cpp. +// __delay has an intra-object call to the original millis() that --wrap=millis +// can't intercept, preventing the linker from garbage-collecting the expensive +// original millis body (~80 bytes IRAM). This yield loop achieves the same +// behavior: feeds the watchdog, processes SDK tasks, keeps WiFi alive. +void HOT delay(uint32_t ms) { + uint32_t start = millis(); + while (millis() - start < ms) { + yield(); + } +} uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { From cb654a1e503c3e3554213290c25430255e5eac7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:58:13 -1000 Subject: [PATCH 0098/2214] [esp8266] Handle delay(0) as a yield point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arduino's delay(0) calls yield() once — used as a yield point by some code. Our millis-based loop would skip yielding entirely since 0 < 0 is false. Add explicit delay(0) → yield() handling. --- esphome/components/esp8266/core.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 68ca8a903b..32dd097c8d 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -60,9 +60,13 @@ uint64_t millis_64() { return Millis64Impl::compute(millis()); } // original millis body (~80 bytes IRAM). This yield loop achieves the same // behavior: feeds the watchdog, processes SDK tasks, keeps WiFi alive. void HOT delay(uint32_t ms) { + if (ms == 0) { + optimistic_yield(1000); + return; + } uint32_t start = millis(); while (millis() - start < ms) { - yield(); + optimistic_yield(1000); } } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } From dff7757d1e2cb996d0a2c66a5d6075c0a4a71bb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:02:16 -1000 Subject: [PATCH 0099/2214] [esp8266] Suppress clang-tidy for __wrap_millis reserved identifier The __wrap_ prefix is required by the GNU linker's --wrap feature. It's a reserved identifier by C++ rules but mandated by the toolchain. --- esphome/components/esp8266/core.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 32dd097c8d..2a5349b143 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -130,6 +130,7 @@ extern "C" void resetPins() { // NOLINT // Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. // Requires -Wl,--wrap=millis in build flags (added by __init__.py). +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } #endif // USE_ESP8266 From bf2040355867b823859e06bf3708ac444476e60f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:08:53 -1000 Subject: [PATCH 0100/2214] =?UTF-8?q?[esp8266]=20Revert=20init()=20wrap=20?= =?UTF-8?q?=E2=80=94=20micros64()=20needs=20the=20overflow=20timer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arduino's micros64() reads the same overflow tracking statics that init() sets up. Wrapping init() as a no-op would cause micros64() to silently return wrong values after 71 minutes. The overflow timer cost is negligible (~3 μs per 60 seconds). --- esphome/components/esp8266/core.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 2a5349b143..6df0db7a1c 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -132,5 +132,8 @@ extern "C" void resetPins() { // NOLINT // Requires -Wl,--wrap=millis in build flags (added by __init__.py). // NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } +// Note: Arduino's init() registers a 60-second overflow timer for micros64(). +// We leave it running — wrapping init() as a no-op would break micros64()'s +// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). #endif // USE_ESP8266 From 3d3e26d141af2bce145948d966921993b5aedacc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:10:19 -1000 Subject: [PATCH 0101/2214] [esp8266] Merge millis_accumulator into millis(), use struct for statics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge the separate millis_accumulator() function directly into millis() to eliminate the extra function call overhead and prologue/epilogue. Pack the three statics into a struct so the compiler loads one base address instead of three literal pool entries. Saves ~12 bytes of IRAM (87+6 → 81 bytes). --- esphome/components/esp8266/core.cpp | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 6df0db7a1c..02aea6f2e8 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -35,24 +35,27 @@ void HOT yield() { ::yield(); } // ::millis() directly also get the fast version. Interrupts are briefly disabled // (~10 instructions, ~125 ns at 80 MHz) to protect the static state from // concurrent ISR access. -static uint32_t IRAM_ATTR HOT millis_accumulator() { - static uint32_t s_cache = 0; - static uint32_t s_remainder = 0; - static uint32_t s_last_us = 0; +uint32_t IRAM_ATTR HOT millis() { + // Struct packs the three statics so the compiler loads one base address + // instead of three separate literal pool entries (saves ~8 bytes IRAM). + static struct { + uint32_t cache; + uint32_t remainder; + uint32_t last_us; + } state = {0, 0, 0}; uint32_t ps = xt_rsil(15); uint32_t now_us = system_get_time(); - uint32_t delta = now_us - s_last_us; - s_last_us = now_us; - s_remainder += delta; - while (s_remainder >= 1000) { - s_cache++; - s_remainder -= 1000; + uint32_t delta = now_us - state.last_us; + state.last_us = now_us; + state.remainder += delta; + while (state.remainder >= 1000) { + state.cache++; + state.remainder -= 1000; } - uint32_t result = s_cache; + uint32_t result = state.cache; xt_wsr_ps(ps); return result; } -uint32_t IRAM_ATTR HOT millis() { return millis_accumulator(); } uint64_t millis_64() { return Millis64Impl::compute(millis()); } // Avoid calling ::delay() which pulls in __delay from core_esp8266_wiring.cpp. // __delay has an intra-object call to the original millis() that --wrap=millis From aec0be356faba48cb0c206da4e9f78aa0dc62526 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:16:06 -1000 Subject: [PATCH 0102/2214] [esp8266] Remove unused USE_FAST_MILLIS_ACCUMULATOR define --- esphome/components/esp8266/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index f8c37e3e94..34540bd48d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -318,7 +318,6 @@ async def to_code(config): # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply # implementation in the Arduino ESP8266 core. cg.add_build_flag("-Wl,--wrap=millis") - cg.add_define("USE_FAST_MILLIS_ACCUMULATOR") cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) From da9c44d28dc5e247867e5a998d1a3c3e31e37698 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:21:15 -1000 Subject: [PATCH 0103/2214] [esp8266] Bound the critical section in millis() accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the μs→ms conversion into two paths to keep the interrupt- disabled critical section bounded: - Common path (delta < 10 ms): while loop runs at most 10 iterations (~100 ns). This covers the normal hot-path case where millis() is called thousands of times per second. - Rare path (delta >= 10 ms): constant-time multiply-by-reciprocal via /1000 (compiled to __umulsidi3, ~2.5 μs). Only fires after a long block (WiFi scan, boot, component stall), where the extra latency is negligible relative to the block that caused it. Addresses the concern that a multi-second WiFi scan block could cause the unbounded while loop to hold interrupts for tens of microseconds. --- esphome/components/esp8266/core.cpp | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 02aea6f2e8..f7106e077e 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -33,8 +33,10 @@ void HOT yield() { ::yield(); } // This function is also installed as __wrap_millis (via -Wl,--wrap=millis) so // that Arduino library code and ISR handlers (e.g. Wiegand, ZyAura) calling // ::millis() directly also get the fast version. Interrupts are briefly disabled -// (~10 instructions, ~125 ns at 80 MHz) to protect the static state from -// concurrent ISR access. +// to protect the static state from concurrent ISR access. The critical section +// is bounded: the common path (delta < 10 ms) runs at most 10 subtract-and- +// compare iterations (~100 ns). Large gaps (WiFi scan, boot) fall back to a +// constant-time multiply-by-reciprocal (~2.5 μs, rare). uint32_t IRAM_ATTR HOT millis() { // Struct packs the three statics so the compiler loads one base address // instead of three separate literal pool entries (saves ~8 bytes IRAM). @@ -48,9 +50,22 @@ uint32_t IRAM_ATTR HOT millis() { uint32_t delta = now_us - state.last_us; state.last_us = now_us; state.remainder += delta; - while (state.remainder >= 1000) { - state.cache++; - state.remainder -= 1000; + if (state.remainder >= 10000) { + // Rare path: large gap (>10 ms — WiFi scan, boot, long block). + // Use constant-time multiply-by-reciprocal (compiled to __umulsidi3, + // ~2.5 μs) to keep the critical section bounded. Only fires when the + // caller was already blocked for >10 ms, so the extra latency is + // negligible relative to the block that caused it. + uint32_t ms = state.remainder / 1000; + state.cache += ms; + state.remainder -= ms * 1000; + } else { + // Common path: small gap (<10 ms). Loop runs at most 10 times + // (~100 ns), well within the WiFi stack's ~10 μs interrupt budget. + while (state.remainder >= 1000) { + state.cache++; + state.remainder -= 1000; + } } uint32_t result = state.cache; xt_wsr_ps(ps); From b882d8c7218119a4e058fbf092aee958f8bd3655 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:31:48 -1000 Subject: [PATCH 0104/2214] [esp8266] Fix stale s_last_us reference in comment Updated to state.last_us to match the struct refactor. --- esphome/components/esp8266/core.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index f7106e077e..91854e3877 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -27,7 +27,7 @@ void HOT yield() { ::yield(); } // Unsigned subtraction (now - last) handles one wrap correctly. ESPHome calls // millis() thousands of times per second (1+N per loop iteration at 60+ Hz), so // missing a full 71-minute wrap period is not a realistic concern. At boot, -// s_last_us starts at 0 and system_get_time() counts from 0, so the first call's +// state.last_us starts at 0 and system_get_time() counts from 0, so the first call's // delta equals the real elapsed time — no special initialization needed. // // This function is also installed as __wrap_millis (via -Wl,--wrap=millis) so From ebbc3b57c779430234f922e2ce135301f37b2815 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:34:14 -1000 Subject: [PATCH 0105/2214] [esp8266] Document delay() semantic difference from Arduino Arduino's delay() uses esp_suspend() with a one-shot os_timer for efficient single-suspension waiting. Our replacement polls millis() with optimistic_yield(), which also enters esp_schedule/esp_suspend via yield() but resumes repeatedly. Functionally correct for ESPHome (delay is cold path, SDK tasks run via yield), just less power- efficient for long delays. --- esphome/components/esp8266/core.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 91854e3877..c26cddea7e 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -75,8 +75,14 @@ uint64_t millis_64() { return Millis64Impl::compute(millis()); } // Avoid calling ::delay() which pulls in __delay from core_esp8266_wiring.cpp. // __delay has an intra-object call to the original millis() that --wrap=millis // can't intercept, preventing the linker from garbage-collecting the expensive -// original millis body (~80 bytes IRAM). This yield loop achieves the same -// behavior: feeds the watchdog, processes SDK tasks, keeps WiFi alive. +// original millis body (~80 bytes IRAM). +// +// Semantic difference from Arduino's delay(): Arduino sets up a one-shot +// os_timer and calls esp_suspend() to suspend the continuation once for the +// full duration. Our loop polls millis() + optimistic_yield(1000) which still +// calls esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks +// and WiFi run correctly. Less power-efficient for long delays, but ESPHome +// uses delay() only during setup and OTA — never on the hot path. void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); From c863a204e8514aecef9ceb5da8db686c6be650f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:36:40 -1000 Subject: [PATCH 0106/2214] [esp8266] Fix incorrect claim about delay() usage scope delay() is called from component loop() and update() methods too (NFC, RTTTL, sensors), not just setup/OTA. --- esphome/components/esp8266/core.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index c26cddea7e..ab991da9f4 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -81,8 +81,8 @@ uint64_t millis_64() { return Millis64Impl::compute(millis()); } // os_timer and calls esp_suspend() to suspend the continuation once for the // full duration. Our loop polls millis() + optimistic_yield(1000) which still // calls esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks -// and WiFi run correctly. Less power-efficient for long delays, but ESPHome -// uses delay() only during setup and OTA — never on the hot path. +// and WiFi run correctly. Less power-efficient for long delays but +// functionally equivalent. void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); From a8cb5b44cdd43f905a0e2a4c40ec689102f95490 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 00:03:23 -1000 Subject: [PATCH 0107/2214] [esp8266] Address review: fix misleading comment, add named constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 'No __umulsidi3' claim from top comment — the rare path does use it via /1000. Reworded to 'pure 32-bit ops on the common path'. - Extract MILLIS_RARE_PATH_THRESHOLD_US and US_PER_MS constants to replace magic numbers 10000 and 1000. --- esphome/components/esp8266/core.cpp | 31 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index ab991da9f4..7551ecc3a7 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -20,8 +20,8 @@ void HOT yield() { ::yield(); } // convert system_get_time() → ms while tracking overflow (~3.3 μs per call on // the LX106 which has no hardware multiply-high instruction). We replace it with // a simple accumulator that tracks a running millis counter from μs deltas using -// pure 32-bit ops (subtract, add, compare-and-subtract). No __umulsidi3 software -// multiply calls. +// pure 32-bit ops on the common path (subtract, add, compare-and-subtract). +// Large gaps (>10 ms) fall back to a constant-time /1000 conversion. // // Overflow safety: system_get_time() is a uint32_t that wraps every ~71.6 minutes. // Unsigned subtraction (now - last) handles one wrap correctly. ESPHome calls @@ -37,6 +37,12 @@ void HOT yield() { ::yield(); } // is bounded: the common path (delta < 10 ms) runs at most 10 subtract-and- // compare iterations (~100 ns). Large gaps (WiFi scan, boot) fall back to a // constant-time multiply-by-reciprocal (~2.5 μs, rare). +// Threshold above which we use constant-time /1000 instead of the while loop. +// 10 ms means the while loop runs at most 10 iterations (~100 ns) on the +// common path, well within the WiFi stack's ~10 μs interrupt latency budget. +static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; +static constexpr uint32_t US_PER_MS = 1000; + uint32_t IRAM_ATTR HOT millis() { // Struct packs the three statics so the compiler loads one base address // instead of three separate literal pool entries (saves ~8 bytes IRAM). @@ -50,21 +56,18 @@ uint32_t IRAM_ATTR HOT millis() { uint32_t delta = now_us - state.last_us; state.last_us = now_us; state.remainder += delta; - if (state.remainder >= 10000) { - // Rare path: large gap (>10 ms — WiFi scan, boot, long block). - // Use constant-time multiply-by-reciprocal (compiled to __umulsidi3, - // ~2.5 μs) to keep the critical section bounded. Only fires when the - // caller was already blocked for >10 ms, so the extra latency is - // negligible relative to the block that caused it. - uint32_t ms = state.remainder / 1000; + if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { + // Rare path: large gap (WiFi scan, boot, long block). Constant-time + // conversion keeps the critical section bounded. + uint32_t ms = state.remainder / US_PER_MS; state.cache += ms; - state.remainder -= ms * 1000; + state.remainder -= ms * US_PER_MS; } else { - // Common path: small gap (<10 ms). Loop runs at most 10 times - // (~100 ns), well within the WiFi stack's ~10 μs interrupt budget. - while (state.remainder >= 1000) { + // Common path: small gap. Loop runs at most + // MILLIS_RARE_PATH_THRESHOLD_US / US_PER_MS iterations. + while (state.remainder >= US_PER_MS) { state.cache++; - state.remainder -= 1000; + state.remainder -= US_PER_MS; } } uint32_t result = state.cache; From 66a3e3a705bbdeaa5d6ac3c3a88525aad15b09b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 11:04:17 -1000 Subject: [PATCH 0108/2214] [ld24xx] Replace heap-allocated SensorWithDedup with inline LazySensorWithDedup Replace heap-allocated SensorWithDedup wrapper with inline LazySensorWithDedup that stores the sensor pointer and deduplicator directly in the component, eliminating heap allocations and reducing RAM usage for configured sensors. --- esphome/components/ld2410/ld2410.cpp | 5 ++-- esphome/components/ld2410/ld2410.h | 4 +-- esphome/components/ld2412/ld2412.cpp | 9 +++--- esphome/components/ld2412/ld2412.h | 4 +-- esphome/components/ld2450/ld2450.cpp | 19 ++++++------- esphome/components/ld2450/ld2450.h | 18 ++++++------ esphome/components/ld24xx/ld24xx.h | 42 +++++++++++++++------------- 7 files changed, 51 insertions(+), 50 deletions(-) diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index f10e7ec0aa..b9426e4078 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -786,13 +786,12 @@ void LD2410Component::set_light_out_control() { } #ifdef USE_SENSOR -// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. void LD2410Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_move_sensors_[gate] = new SensorWithDedup(s); + this->gate_move_sensors_[gate].set_sensor(s); } void LD2410Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_still_sensors_[gate] = new SensorWithDedup(s); + this->gate_still_sensors_[gate].set_sensor(s); } #endif diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 687ed21d1d..e419278fa1 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -129,8 +129,8 @@ class LD2410Component : public Component, public uart::UARTDevice { std::array gate_still_threshold_numbers_{}; #endif #ifdef USE_SENSOR - std::array *, TOTAL_GATES> gate_move_sensors_{}; - std::array *, TOTAL_GATES> gate_still_sensors_{}; + std::array, TOTAL_GATES> gate_move_sensors_{}; + std::array, TOTAL_GATES> gate_still_sensors_{}; #endif }; diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 38e1a59aba..25f27dc4aa 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -402,7 +402,7 @@ void LD2412Component::handle_periodic_data_() { SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_, encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW])) SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]) - if (this->detection_distance_sensor_ != nullptr) { + if (this->detection_distance_sensor_.has_sensor()) { int new_detect_distance = 0; if (target_state != 0x00 && (target_state & MOVE_BITMASK)) { new_detect_distance = @@ -410,7 +410,7 @@ void LD2412Component::handle_periodic_data_() { } else if (target_state != 0x00) { new_detect_distance = encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]); } - this->detection_distance_sensor_->publish_state_if_not_dup(new_detect_distance); + this->detection_distance_sensor_.publish_state_if_not_dup(new_detect_distance); } if (engineering_mode) { // Engineering mode needs at least LIGHT_SENSOR + 1 bytes @@ -846,12 +846,11 @@ void LD2412Component::set_light_out_control() { } #ifdef USE_SENSOR -// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. void LD2412Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_move_sensors_[gate] = new SensorWithDedup(s); + this->gate_move_sensors_[gate].set_sensor(s); } void LD2412Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_still_sensors_[gate] = new SensorWithDedup(s); + this->gate_still_sensors_[gate].set_sensor(s); } #endif diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 7fd2245978..f69fce93f0 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -133,8 +133,8 @@ class LD2412Component : public Component, public uart::UARTDevice { std::array gate_still_threshold_numbers_{}; #endif #ifdef USE_SENSOR - std::array *, TOTAL_GATES> gate_move_sensors_{}; - std::array *, TOTAL_GATES> gate_still_sensors_{}; + std::array, TOTAL_GATES> gate_move_sensors_{}; + std::array, TOTAL_GATES> gate_still_sensors_{}; #endif }; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 58c3cac42d..8b29e82c01 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -872,33 +872,32 @@ void LD2450Component::query_target_tracking_mode_() { this->send_command_(CMD_QU void LD2450Component::query_zone_() { this->send_command_(CMD_QUERY_ZONE, nullptr, 0); } #ifdef USE_SENSOR -// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. void LD2450Component::set_move_x_sensor(uint8_t target, sensor::Sensor *s) { - this->move_x_sensors_[target] = new SensorWithDedup(s); + this->move_x_sensors_[target].set_sensor(s); } void LD2450Component::set_move_y_sensor(uint8_t target, sensor::Sensor *s) { - this->move_y_sensors_[target] = new SensorWithDedup(s); + this->move_y_sensors_[target].set_sensor(s); } void LD2450Component::set_move_speed_sensor(uint8_t target, sensor::Sensor *s) { - this->move_speed_sensors_[target] = new SensorWithDedup(s); + this->move_speed_sensors_[target].set_sensor(s); } void LD2450Component::set_move_angle_sensor(uint8_t target, sensor::Sensor *s) { - this->move_angle_sensors_[target] = new SensorWithDedup(s); + this->move_angle_sensors_[target].set_sensor(s); } void LD2450Component::set_move_distance_sensor(uint8_t target, sensor::Sensor *s) { - this->move_distance_sensors_[target] = new SensorWithDedup(s); + this->move_distance_sensors_[target].set_sensor(s); } void LD2450Component::set_move_resolution_sensor(uint8_t target, sensor::Sensor *s) { - this->move_resolution_sensors_[target] = new SensorWithDedup(s); + this->move_resolution_sensors_[target].set_sensor(s); } void LD2450Component::set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_target_count_sensors_[zone] = new SensorWithDedup(s); + this->zone_target_count_sensors_[zone].set_sensor(s); } void LD2450Component::set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_still_target_count_sensors_[zone] = new SensorWithDedup(s); + this->zone_still_target_count_sensors_[zone].set_sensor(s); } void LD2450Component::set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_moving_target_count_sensors_[zone] = new SensorWithDedup(s); + this->zone_moving_target_count_sensors_[zone].set_sensor(s); } #endif #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index cbcdec10b3..0e2be529e8 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -182,15 +182,15 @@ class LD2450Component : public Component, public uart::UARTDevice { ZoneOfNumbers zone_numbers_[MAX_ZONES]; #endif #ifdef USE_SENSOR - std::array *, MAX_TARGETS> move_x_sensors_{}; - std::array *, MAX_TARGETS> move_y_sensors_{}; - std::array *, MAX_TARGETS> move_speed_sensors_{}; - std::array *, MAX_TARGETS> move_angle_sensors_{}; - std::array *, MAX_TARGETS> move_distance_sensors_{}; - std::array *, MAX_TARGETS> move_resolution_sensors_{}; - std::array *, MAX_ZONES> zone_target_count_sensors_{}; - std::array *, MAX_ZONES> zone_still_target_count_sensors_{}; - std::array *, MAX_ZONES> zone_moving_target_count_sensors_{}; + std::array, MAX_TARGETS> move_x_sensors_{}; + std::array, MAX_TARGETS> move_y_sensors_{}; + std::array, MAX_TARGETS> move_speed_sensors_{}; + std::array, MAX_TARGETS> move_angle_sensors_{}; + std::array, MAX_TARGETS> move_distance_sensors_{}; + std::array, MAX_TARGETS> move_resolution_sensors_{}; + std::array, MAX_ZONES> zone_target_count_sensors_{}; + std::array, MAX_ZONES> zone_still_target_count_sensors_{}; + std::array, MAX_ZONES> zone_moving_target_count_sensors_{}; #endif #ifdef USE_TEXT_SENSOR std::array direction_text_sensors_{}; diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index fd55167974..6dbebb4e1b 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -11,27 +11,25 @@ #define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ protected: \ - ld24xx::SensorWithDedup *name##_sensor_{nullptr}; \ + ld24xx::LazySensorWithDedup name##_sensor_{}; \ \ public: \ - void set_##name##_sensor(sensor::Sensor *sensor) { \ - this->name##_sensor_ = new ld24xx::SensorWithDedup(sensor); \ - } + void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); } #endif #define LOG_SENSOR_WITH_DEDUP_SAFE(tag, name, sensor) \ - if ((sensor) != nullptr) { \ - LOG_SENSOR(tag, name, (sensor)->sens); \ + if ((sensor).has_sensor()) { \ + LOG_SENSOR(tag, name, (sensor).get_sensor()); \ } #define SAFE_PUBLISH_SENSOR(sensor, value) \ - if ((sensor) != nullptr) { \ - (sensor)->publish_state_if_not_dup(value); \ + if (true) { \ + (sensor).publish_state_if_not_dup(value); \ } #define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) \ - if ((sensor) != nullptr) { \ - (sensor)->publish_state_unknown(); \ + if (true) { \ + (sensor).publish_state_unknown(); \ } #define highbyte(val) (uint8_t)((val) >> 8) @@ -70,25 +68,31 @@ inline void format_version_str(const uint8_t *version, std::span buffe } #ifdef USE_SENSOR -// Helper class to store a sensor with a deduplicator & publish state only when the value changes -template class SensorWithDedup { +/// Sensor with deduplication — sensor may be null, null check is internal. +/// Analogous to LazyCallbackManager: does nothing when no sensor is set, +/// avoids heap allocation by storing everything inline. +template class LazySensorWithDedup { public: - SensorWithDedup(sensor::Sensor *sens) : sens(sens) {} + void set_sensor(sensor::Sensor *sens) { this->sens_ = sens; } void publish_state_if_not_dup(T state) { - if (this->publish_dedup.next(state)) { - this->sens->publish_state(static_cast(state)); + if (this->sens_ != nullptr && this->dedup_.next(state)) { + this->sens_->publish_state(static_cast(state)); } } void publish_state_unknown() { - if (this->publish_dedup.next_unknown()) { - this->sens->publish_state(NAN); + if (this->sens_ != nullptr && this->dedup_.next_unknown()) { + this->sens_->publish_state(NAN); } } - sensor::Sensor *sens; - Deduplicator publish_dedup; + bool has_sensor() const { return this->sens_ != nullptr; } + sensor::Sensor *get_sensor() const { return this->sens_; } + + protected: + sensor::Sensor *sens_{nullptr}; + Deduplicator dedup_; }; #endif } // namespace esphome::ld24xx From 931ef66a2f38f884268b65a66a89a0a71731e01b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 12:36:10 -1000 Subject: [PATCH 0109/2214] Rename LazySensorWithDedup to SensorWithDedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no lazy allocation to justify the name — it's just inline with a null check. Drop the Lazy prefix. --- esphome/components/ld2410/ld2410.h | 4 ++-- esphome/components/ld2412/ld2412.h | 4 ++-- esphome/components/ld2450/ld2450.h | 18 +++++++++--------- esphome/components/ld24xx/ld24xx.h | 7 +++---- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index e419278fa1..31186b135f 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -129,8 +129,8 @@ class LD2410Component : public Component, public uart::UARTDevice { std::array gate_still_threshold_numbers_{}; #endif #ifdef USE_SENSOR - std::array, TOTAL_GATES> gate_move_sensors_{}; - std::array, TOTAL_GATES> gate_still_sensors_{}; + std::array, TOTAL_GATES> gate_move_sensors_{}; + std::array, TOTAL_GATES> gate_still_sensors_{}; #endif }; diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index f69fce93f0..306e7ae31d 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -133,8 +133,8 @@ class LD2412Component : public Component, public uart::UARTDevice { std::array gate_still_threshold_numbers_{}; #endif #ifdef USE_SENSOR - std::array, TOTAL_GATES> gate_move_sensors_{}; - std::array, TOTAL_GATES> gate_still_sensors_{}; + std::array, TOTAL_GATES> gate_move_sensors_{}; + std::array, TOTAL_GATES> gate_still_sensors_{}; #endif }; diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 0e2be529e8..10f9bb874a 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -182,15 +182,15 @@ class LD2450Component : public Component, public uart::UARTDevice { ZoneOfNumbers zone_numbers_[MAX_ZONES]; #endif #ifdef USE_SENSOR - std::array, MAX_TARGETS> move_x_sensors_{}; - std::array, MAX_TARGETS> move_y_sensors_{}; - std::array, MAX_TARGETS> move_speed_sensors_{}; - std::array, MAX_TARGETS> move_angle_sensors_{}; - std::array, MAX_TARGETS> move_distance_sensors_{}; - std::array, MAX_TARGETS> move_resolution_sensors_{}; - std::array, MAX_ZONES> zone_target_count_sensors_{}; - std::array, MAX_ZONES> zone_still_target_count_sensors_{}; - std::array, MAX_ZONES> zone_moving_target_count_sensors_{}; + std::array, MAX_TARGETS> move_x_sensors_{}; + std::array, MAX_TARGETS> move_y_sensors_{}; + std::array, MAX_TARGETS> move_speed_sensors_{}; + std::array, MAX_TARGETS> move_angle_sensors_{}; + std::array, MAX_TARGETS> move_distance_sensors_{}; + std::array, MAX_TARGETS> move_resolution_sensors_{}; + std::array, MAX_ZONES> zone_target_count_sensors_{}; + std::array, MAX_ZONES> zone_still_target_count_sensors_{}; + std::array, MAX_ZONES> zone_moving_target_count_sensors_{}; #endif #ifdef USE_TEXT_SENSOR std::array direction_text_sensors_{}; diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index 6dbebb4e1b..f919bd1c33 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -11,7 +11,7 @@ #define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ protected: \ - ld24xx::LazySensorWithDedup name##_sensor_{}; \ + ld24xx::SensorWithDedup name##_sensor_{}; \ \ public: \ void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); } @@ -69,9 +69,8 @@ inline void format_version_str(const uint8_t *version, std::span buffe #ifdef USE_SENSOR /// Sensor with deduplication — sensor may be null, null check is internal. -/// Analogous to LazyCallbackManager: does nothing when no sensor is set, -/// avoids heap allocation by storing everything inline. -template class LazySensorWithDedup { +/// Stored inline, no heap allocation. Does nothing when no sensor is set. +template class SensorWithDedup { public: void set_sensor(sensor::Sensor *sens) { this->sens_ = sens; } From 099a45cf2ed279902ad896e32fbe451880c61c33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 15:19:27 -1000 Subject: [PATCH 0110/2214] bot comments --- esphome/components/ld24xx/ld24xx.h | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index f919bd1c33..cba1b68a15 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -22,15 +22,9 @@ LOG_SENSOR(tag, name, (sensor).get_sensor()); \ } -#define SAFE_PUBLISH_SENSOR(sensor, value) \ - if (true) { \ - (sensor).publish_state_if_not_dup(value); \ - } +#define SAFE_PUBLISH_SENSOR(sensor, value) (sensor).publish_state_if_not_dup(value) -#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) \ - if (true) { \ - (sensor).publish_state_unknown(); \ - } +#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) (sensor).publish_state_unknown() #define highbyte(val) (uint8_t)((val) >> 8) #define lowbyte(val) (uint8_t)((val) &0xff) @@ -72,7 +66,10 @@ inline void format_version_str(const uint8_t *version, std::span buffe /// Stored inline, no heap allocation. Does nothing when no sensor is set. template class SensorWithDedup { public: - void set_sensor(sensor::Sensor *sens) { this->sens_ = sens; } + void set_sensor(sensor::Sensor *sens) { + this->sens_ = sens; + this->dedup_ = {}; + } void publish_state_if_not_dup(T state) { if (this->sens_ != nullptr && this->dedup_.next(state)) { From 5d8dc8e62cb9c5ca3878507b080323da860b667e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 15:30:30 -1000 Subject: [PATCH 0111/2214] fix missing semis --- esphome/components/ld2410/ld2410.cpp | 16 ++++++++-------- esphome/components/ld2412/ld2412.cpp | 20 ++++++++++---------- esphome/components/ld2450/ld2450.cpp | 1 + 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index b9426e4078..32e49c643f 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -360,8 +360,8 @@ void LD2410Component::handle_periodic_data_() { */ #ifdef USE_SENSOR SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_, - encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])) - SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]) + encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])); + SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]); SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_, encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW])); SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]); @@ -375,26 +375,26 @@ void LD2410Component::handle_periodic_data_() { Moving energy: 20~28th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]); } /* Still energy: 29~37th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]); } /* Light sensor: 38th bytes */ - SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]) + SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]); } else { for (auto &gate_move_sensor : this->gate_move_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor); } for (auto &gate_still_sensor : this->gate_still_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor); } - SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_) + SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_); } #endif #ifdef USE_BINARY_SENSOR diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 25f27dc4aa..a502ae3c10 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -397,11 +397,11 @@ void LD2412Component::handle_periodic_data_() { */ #ifdef USE_SENSOR SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_, - encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])) - SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]) + encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])); + SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]); SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_, - encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW])) - SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]) + encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW])); + SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]); if (this->detection_distance_sensor_.has_sensor()) { int new_detect_distance = 0; if (target_state != 0x00 && (target_state & MOVE_BITMASK)) { @@ -423,27 +423,27 @@ void LD2412Component::handle_periodic_data_() { Moving energy: 20~28th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]); } /* Still energy: 29~37th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]); } /* Light sensor value */ - SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]) + SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]); } } else { for (auto &gate_move_sensor : this->gate_move_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor); } for (auto &gate_still_sensor : this->gate_still_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor); } - SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_) + SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_); } #endif // the radar module won't tell us when it's done, so we just have to keep polling... diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 8b29e82c01..0dc2638aad 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -565,6 +565,7 @@ void LD2450Component::handle_periodic_data_() { SAFE_PUBLISH_SENSOR(this->still_target_count_sensor_, still_target_count); // Moving Target Count SAFE_PUBLISH_SENSOR(this->moving_target_count_sensor_, moving_target_count); + #endif #ifdef USE_BINARY_SENSOR From 942cb0733b67d2e72a65c9a8e908f1ef33ab611f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 17:26:00 -1000 Subject: [PATCH 0112/2214] [scheduler] Force-inline cleanup_() fast path into Scheduler::call() cleanup_() has a two-line fast path (check to_remove count, return !items_.empty()) with the slow path already out-of-line in cleanup_slow_path_(). Despite `inline` hint, GCC on Xtensa emits it as a separate function, adding unnecessary call overhead on every Scheduler::call() invocation even when there is nothing to clean up. Use ESPHOME_ALWAYS_INLINE to guarantee inlining. Verified on ESP8266: - cleanup_() symbol eliminated from binary - Scheduler::call() grows 4 bytes (inlined fast path) - Net savings: 23 bytes (27-byte out-of-line body removed) - Idle loop path: 1 out-of-line call instead of 2 --- esphome/core/scheduler.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 43a3ec7049..81ff6abadc 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -302,7 +302,7 @@ class Scheduler { // loop thread structurally modifies items_ (push/pop/erase). Other threads may // iterate items_ and mark items removed under lock_, but never change the // vector's size or data pointer. - inline bool HOT cleanup_() { + inline bool ESPHOME_ALWAYS_INLINE HOT cleanup_() { if (this->to_remove_empty_()) return !this->items_.empty(); return this->cleanup_slow_path_(); From 8fbf102ef32f6ba9f0459abc54577550c0ccefb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 17:34:34 -1000 Subject: [PATCH 0113/2214] [core] Inline Millis64Impl::compute() on single-threaded platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On ESPHOME_THREAD_SINGLE platforms (ESP8266), move the Millis64Impl::compute() body into the header so it can be inlined at call sites. The function is trivial: two static loads, a rollover branch (taken every ~49.7 days), a store, and a shift+add. The static storage (last_millis_, millis_major_) is declared as class statics in the header and defined in time_64.cpp so all TUs share one copy. Multi-threaded paths remain out-of-line in time_64.cpp. Also force-inline millis_64_from_() which wraps compute() — GCC was creating an $isra$ clone instead of inlining the wrapper. Verified on ESP8266 (xtensa-lx106): - Millis64Impl::compute() and millis_64_from_ symbols eliminated - Scheduler::call() idle path: zero out-of-line calls (was 1) - compute body inlined into 3 call sites (Scheduler::call, next_schedule_in, loop/millis_64) --- esphome/core/scheduler.h | 2 +- esphome/core/time_64.cpp | 42 ++++++++++++---------------------------- esphome/core/time_64.h | 32 ++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 31 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 43a3ec7049..63ffa69e7e 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -286,7 +286,7 @@ class Scheduler { // Extend a 32-bit millis() value to 64-bit. Use when the caller already has a fresh now. // On platforms with native 64-bit time, ignores now and uses millis_64() directly. // On other platforms, extends now to 64-bit using rollover tracking. - uint64_t millis_64_from_(uint32_t now) { + uint64_t ESPHOME_ALWAYS_INLINE millis_64_from_(uint32_t now) { #ifdef USE_NATIVE_64BIT_TIME (void) now; return millis_64(); diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index db5df25eb9..41917e825e 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -20,6 +20,12 @@ namespace esphome { static const char *const TAG = "time_64"; #endif +#ifdef ESPHOME_THREAD_SINGLE +// Storage for Millis64Impl inline compute() — defined here so all TUs share one copy. +uint32_t Millis64Impl::last_millis_{0}; +uint16_t Millis64Impl::millis_major_{0}; +#else + uint64_t Millis64Impl::compute(uint32_t now) { // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; @@ -44,51 +50,25 @@ uint64_t Millis64Impl::compute(uint32_t now) { * to last_millis is provided by its release store and the corresponding acquire loads. */ static std::atomic millis_major{0}; -#elif !defined(ESPHOME_THREAD_SINGLE) /* ESPHOME_THREAD_MULTI_NO_ATOMICS */ +#else /* ESPHOME_THREAD_MULTI_NO_ATOMICS */ static Mutex lock; static uint32_t last_millis{0}; static uint16_t millis_major{0}; -#else /* ESPHOME_THREAD_SINGLE */ - static uint32_t last_millis{0}; - static uint16_t millis_major{0}; #endif // THREAD SAFETY NOTE: - // This function has three implementations, based on the precompiler flags - // - ESPHOME_THREAD_SINGLE - Runs on single-threaded platforms (ESP8266, etc.) + // This function has two out-of-line implementations, based on the precompiler flags: // - ESPHOME_THREAD_MULTI_NO_ATOMICS - Runs on multi-threaded platforms without atomics (LibreTiny BK72xx) // - ESPHOME_THREAD_MULTI_ATOMICS - Runs on multi-threaded platforms with atomics (LibreTiny RTL87xx/LN882x, etc.) // + // The ESPHOME_THREAD_SINGLE path is inlined in time_64.h. // Make sure all changes are synchronized if you edit this function. // // IMPORTANT: Always pass fresh millis() values to this function. The implementation // handles out-of-order timestamps between threads, but minimizing time differences // helps maintain accuracy. -#ifdef ESPHOME_THREAD_SINGLE - // Single-core platforms have no concurrency, so this is a simple implementation - // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics. - - uint16_t major = millis_major; - uint32_t last = last_millis; - - // Check for rollover - if (now < last && (last - now) > HALF_MAX_UINT32) { - millis_major++; - major++; - last_millis = now; -#ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last); -#endif /* ESPHOME_DEBUG_SCHEDULER */ - } else if (now > last) { - // Only update if time moved forward - last_millis = now; - } - - // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time - return now + (static_cast(major) << 32); - -#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) +#if defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) // Without atomics, this implementation uses locks more aggressively: // 1. Always locks when near the rollover boundary (within 10 seconds) // 2. Always locks when detecting a large backwards jump @@ -202,6 +182,8 @@ uint64_t Millis64Impl::compute(uint32_t now) { #endif } +#endif // !ESPHOME_THREAD_SINGLE + } // namespace esphome #endif // !USE_NATIVE_64BIT_TIME diff --git a/esphome/core/time_64.h b/esphome/core/time_64.h index 42d4b041e5..592e645d41 100644 --- a/esphome/core/time_64.h +++ b/esphome/core/time_64.h @@ -4,6 +4,9 @@ #ifndef USE_NATIVE_64BIT_TIME #include +#include + +#include "esphome/core/helpers.h" namespace esphome { @@ -16,7 +19,36 @@ class Millis64Impl { friend uint64_t millis_64(); friend class Scheduler; +#ifdef ESPHOME_THREAD_SINGLE + // Storage defined in time_64.cpp — declared here so the inline body can access them. + static uint32_t last_millis_; + static uint16_t millis_major_; + + static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) { + // Half the 32-bit range - used to detect rollovers vs normal time progression + static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; + + // Single-core platforms have no concurrency, so this is a simple implementation + // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics. + uint16_t major = millis_major_; + uint32_t last = last_millis_; + + // Check for rollover + if (now < last && (last - now) > HALF_MAX_UINT32) { + millis_major_++; + major++; + last_millis_ = now; + } else if (now > last) { + // Only update if time moved forward + last_millis_ = now; + } + + // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time + return now + (static_cast(major) << 32); + } +#else static uint64_t compute(uint32_t now); +#endif }; } // namespace esphome From 939b3d383ccf37d1e8637ed94896f182ccee1405 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 17:47:31 -1000 Subject: [PATCH 0114/2214] [scheduler] Force-inline process_to_add() fast path process_to_add() has the same pattern as cleanup_(): a one-line inline fast path (check if to_add_ is empty) with the slow path already out-of-line in process_to_add_slow_path_(). GCC inlines it on ESP8266 but not on ESP32, emitting a 20-byte out-of-line body. Use ESPHOME_ALWAYS_INLINE to guarantee inlining on all platforms. Verified on ESP32 (xtensa-esp32): - process_to_add() symbol eliminated from binary - Scheduler::call() idle path: 1 out-of-line call (millis_64 only) --- esphome/core/scheduler.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 43a3ec7049..c68a71ca7b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -138,7 +138,7 @@ class Scheduler { // (single-threaded). This is safe because the main loop is the only thread // that reads to_add_ without holding lock_; other threads may read it only // while holding the mutex (e.g. cancel_item_locked_). - inline void HOT process_to_add() { + inline void ESPHOME_ALWAYS_INLINE HOT process_to_add() { if (this->to_add_empty_()) return; this->process_to_add_slow_path_(); From 7000b865ce92e780005cfd9d0660bbb836bee927 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 17:51:01 -1000 Subject: [PATCH 0115/2214] [scheduler] Force-inline process_defer_queue_() fast path process_defer_queue_() follows the same fast-path/slow-path pattern as cleanup_() and process_to_add(): an atomic load + branch that skips to the slow path only when work is pending. GCC currently inlines it, but past experience shows the compiler can change its mind when surrounding code changes. Use ESPHOME_ALWAYS_INLINE to lock in the intended behavior. --- esphome/core/scheduler.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 43a3ec7049..ea6c2a675e 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -407,7 +407,7 @@ class Scheduler { // Process defer queue for FIFO execution of deferred items. // IMPORTANT: This method should only be called from the main thread (loop task). // Inlined: the fast path (nothing deferred) is just an atomic load check. - inline void HOT process_defer_queue_(uint32_t &now) { + inline void ESPHOME_ALWAYS_INLINE HOT process_defer_queue_(uint32_t &now) { // Fast path: nothing to process, avoid lock entirely. // Worst case is a one-loop-iteration delay before newly deferred items are processed. if (this->defer_empty_()) From ab64916c379a145b57d21b95e4702bebf91747f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 18:32:03 -1000 Subject: [PATCH 0116/2214] [benchmark] Use -Os to match firmware optimization level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodSpeed benchmarks were building with -O2, while all firmware targets (ESP8266, ESP32, LibreTiny) use -Os. This mismatch means the benchmarks cannot detect inlining regressions that affect real devices — GCC under -O2 inlines functions that -Os outlines due to its size-conscious cost model. Remove the -Os unflag and -O2 override so benchmarks use the platform default -Os, matching what actually runs on devices. --- script/cpp_benchmark.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index 92faa05819..c09c4dee90 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -26,11 +26,7 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" PLATFORMIO_OPTIONS = { - "build_unflags": [ - "-Os", # remove default size-opt - ], "build_flags": [ - "-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo) "-g", # debug symbols for profiling "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() f"-I{STUBS_DIR}", # stub headers for ESP32-only components From 02f828fcbf425d921023c1405b0528c78366a2fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 18:37:50 -1000 Subject: [PATCH 0117/2214] [benchmark] Use -Os to match firmware optimization level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodSpeed benchmarks were building with -O2, while all firmware targets (ESP8266, ESP32, LibreTiny) use -Os. This mismatch means the benchmarks cannot detect inlining regressions that affect real devices — GCC under -O2 inlines functions that -Os outlines due to its size-conscious cost model. Switch to -Os with -ffunction-sections/-fdata-sections for proper dead-code stripping (needed because -Os preserves references that -O2 optimizes away at compile time). --- script/cpp_benchmark.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index c09c4dee90..5080a9fec7 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -27,7 +27,10 @@ STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" PLATFORMIO_OPTIONS = { "build_flags": [ + "-Os", # match firmware optimization level (detects inlining regressions) "-g", # debug symbols for profiling + "-ffunction-sections", # required for dead-code stripping with -Os + "-fdata-sections", # required for dead-code stripping with -Os "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() f"-I{STUBS_DIR}", # stub headers for ESP32-only components ], From 5a250cc74f0ee3583419ef3050346e050047cda0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 19:03:21 -1000 Subject: [PATCH 0118/2214] [api] Compile noise-c and libsodium with -O2 for speed Crypto libraries are CPU-bound and benefit significantly from speed optimization over the default -Os. Add a post: extra_script that appends -O2 to noise-c and libsodium build flags when API noise encryption is enabled. GCC uses the last -O flag, so this overrides the global -Os for these libraries only. --- esphome/components/api/__init__.py | 16 ++++++++++++++++ esphome/components/api/crypto_optimize.py.script | 9 +++++++++ 2 files changed, 25 insertions(+) create mode 100644 esphome/components/api/crypto_optimize.py.script diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 84589d540d..43677ebfdc 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,5 +1,6 @@ import base64 import logging +import pathlib from esphome import automation from esphome.automation import Condition @@ -458,6 +459,10 @@ async def to_code(config: ConfigType) -> None: # 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") + # Compile crypto libraries with -O2 for speed instead of -Os. + # Crypto is CPU-bound and benefits significantly from speed optimization. + # GCC uses the last -O flag, so appending -O2 overrides the global -Os. + _write_crypto_optimize_script() else: cg.add_define("USE_API_PLAINTEXT") @@ -465,6 +470,17 @@ async def to_code(config: ConfigType) -> None: cg.add_global(api_ns.using) +_CRYPTO_OPTIMIZE_SCRIPT = "crypto_optimize.py" + + +def _write_crypto_optimize_script() -> None: + from esphome.helpers import copy_file_if_changed + + script_src = pathlib.Path(__file__).parent / f"{_CRYPTO_OPTIMIZE_SCRIPT}.script" + copy_file_if_changed(script_src, CORE.relative_build_path(_CRYPTO_OPTIMIZE_SCRIPT)) + cg.add_platformio_option("extra_scripts", [f"post:{_CRYPTO_OPTIMIZE_SCRIPT}"]) + + KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)}) diff --git a/esphome/components/api/crypto_optimize.py.script b/esphome/components/api/crypto_optimize.py.script new file mode 100644 index 0000000000..fe693d1911 --- /dev/null +++ b/esphome/components/api/crypto_optimize.py.script @@ -0,0 +1,9 @@ +# Compile crypto libraries with -O2 for speed instead of the default -Os. +# Crypto is CPU-bound and benefits significantly from speed optimization. +# GCC uses the last -O flag, so appending -O2 overrides the global -Os +# for these libraries only. +Import("env") + +for lb in env.GetLibBuilders(): + if lb.name in ("noise-c", "libsodium"): + lb.env.Append(CCFLAGS=["-O2"]) From 63184e95a2d8ba53bceae10b57e39cd4b47a2b27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 19:15:06 -1000 Subject: [PATCH 0119/2214] [api] Only apply -O2 to libsodium, not noise-c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodSpeed results show only Noise benchmarks improved with -O2 — the speedup comes from libsodium's crypto primitives (Curve25519, ChaCha20, Poly1305), not noise-c's protocol layer. Narrow the optimization to libsodium only. --- esphome/components/api/crypto_optimize.py.script | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/crypto_optimize.py.script b/esphome/components/api/crypto_optimize.py.script index fe693d1911..1093a09bf6 100644 --- a/esphome/components/api/crypto_optimize.py.script +++ b/esphome/components/api/crypto_optimize.py.script @@ -1,9 +1,9 @@ -# Compile crypto libraries with -O2 for speed instead of the default -Os. -# Crypto is CPU-bound and benefits significantly from speed optimization. -# GCC uses the last -O flag, so appending -O2 overrides the global -Os -# for these libraries only. +# Compile libsodium with -O2 for speed instead of the default -Os. +# libsodium provides the crypto primitives (Curve25519, ChaCha20, Poly1305) +# used by the Noise protocol and benefits significantly from speed optimization. +# GCC uses the last -O flag, so appending -O2 overrides the global -Os. Import("env") for lb in env.GetLibBuilders(): - if lb.name in ("noise-c", "libsodium"): + if lb.name == "libsodium": lb.env.Append(CCFLAGS=["-O2"]) From 70dd7328214d9c495d67a2dfaa662a80454b491d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 19:12:31 -1000 Subject: [PATCH 0120/2214] [api] Add speed_optimized proto option for hot encode paths Add a new (speed_optimized) message option that emits __attribute__((optimize("O2"))) on the generated encode() and calculate_size() methods. Under -Os, GCC does not inline the small ProtoEncode helpers (write_raw_byte, encode_varint, etc.) into the generated methods, causing significant overhead on hot paths. Apply to SensorStateResponse and BluetoothLERawAdvertisementsResponse which are the highest-frequency encode paths. --- esphome/components/api/api.proto | 2 ++ esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 10 ++++++---- script/api_protobuf/api_protobuf.py | 11 +++++++++-- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e4d0c2d16d..4a9a6f9051 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -671,6 +671,7 @@ message SensorStateResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SENSOR"; option (no_delay) = true; + option (speed_optimized) = true; fixed32 key = 1 [(force) = true]; float state = 2; @@ -1638,6 +1639,7 @@ message BluetoothLERawAdvertisementsResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_BLUETOOTH_PROXY"; option (no_delay) = true; + option (speed_optimized) = true; repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"]; } diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index dacc290e31..d5d0b37e8d 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -23,6 +23,7 @@ extend google.protobuf.MessageOptions { optional bool no_delay = 1040 [default=false]; optional string base_class = 1041; optional bool inline_encode = 1042 [default=false]; + optional bool speed_optimized = 1043 [default=false]; } extend google.protobuf.FieldOptions { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c2d513f0d3..ddc5ed18b9 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -745,7 +745,8 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const { #endif return size; } -uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) uint8_t *SensorStateResponse::encode( + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); @@ -755,7 +756,7 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG #endif return pos; } -uint32_t SensorStateResponse::calculate_size() const { +__attribute__((optimize("O2"))) uint32_t SensorStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->state); @@ -2328,7 +2329,8 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, } return true; } -uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) uint8_t *BluetoothLERawAdvertisementsResponse::encode( + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; @@ -2350,7 +2352,7 @@ uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer P } return pos; } -uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { +__attribute__((optimize("O2"))) uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 39bfc865d0..d9fdadd375 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2679,6 +2679,13 @@ def build_message_type( and get_opt(desc, inline_opt, False) ) + # Check if this message wants speed-optimized encode/calculate_size. + # When set, __attribute__((optimize("O2"))) is added to the definitions + # so GCC inlines the small ProtoEncode helpers even under -Os. + speed_opt = getattr(pb, "speed_optimized", None) + is_speed_optimized = speed_opt is not None and get_opt(desc, speed_opt, False) + speed_attr = '__attribute__((optimize("O2"))) ' if is_speed_optimized else "" + # Only generate encode method if this message needs encoding and has fields if needs_encode and encode and not is_inline_only: # Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls @@ -2688,7 +2695,7 @@ def build_message_type( ) for line in encode ] - o = f"uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" + o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" o += " uint8_t *__restrict__ pos = buffer.get_pos();\n" o += indent("\n".join(encode_debug)) + "\n" o += " return pos;\n" @@ -2702,7 +2709,7 @@ def build_message_type( # Add calculate_size method only if this message needs encoding and has fields if needs_encode and size_calc and not is_inline_only: - o = f"uint32_t {desc.name}::calculate_size() const {{\n" + o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n" o += " uint32_t size = 0;\n" o += indent("\n".join(size_calc)) + "\n" o += " return size;\n" From d217ab3cd4b7b63104f1b5484678fa1bc01dd638 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 19:23:39 -1000 Subject: [PATCH 0121/2214] [api] Add speed_optimized proto option for hot encode paths Add a new (speed_optimized) message option that emits __attribute__((optimize("O2"))) on the generated encode() and calculate_size() methods. Under -Os, GCC does not inline the small ProtoEncode helpers (write_raw_byte, encode_varint, etc.) into the generated methods, causing significant overhead on hot paths. Apply to SensorStateResponse and BluetoothLERawAdvertisementsResponse which are the highest-frequency encode paths. --- script/api_protobuf/api_protobuf.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index d9fdadd375..4f52b07809 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2682,8 +2682,7 @@ def build_message_type( # Check if this message wants speed-optimized encode/calculate_size. # When set, __attribute__((optimize("O2"))) is added to the definitions # so GCC inlines the small ProtoEncode helpers even under -Os. - speed_opt = getattr(pb, "speed_optimized", None) - is_speed_optimized = speed_opt is not None and get_opt(desc, speed_opt, False) + is_speed_optimized = get_opt(desc, pb.speed_optimized, False) speed_attr = '__attribute__((optimize("O2"))) ' if is_speed_optimized else "" # Only generate encode method if this message needs encoding and has fields From e1ea9cb66b539b291d5636d9a40b84a2b257e292 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 19:26:54 -1000 Subject: [PATCH 0122/2214] Revert "Merge branch 'benchmark-crypto-o2' into integration" This reverts commit c58447ad3a2a468ccdc87a7b338c0b2347916157, reversing changes made to 3b02c6d5ef8ee6057fd93114b6a52b34be6591b7. --- esphome/components/api/__init__.py | 16 ---------------- esphome/components/api/crypto_optimize.py.script | 9 --------- script/cpp_benchmark.py | 7 ++++--- 3 files changed, 4 insertions(+), 28 deletions(-) delete mode 100644 esphome/components/api/crypto_optimize.py.script diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 43677ebfdc..84589d540d 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,6 +1,5 @@ import base64 import logging -import pathlib from esphome import automation from esphome.automation import Condition @@ -459,10 +458,6 @@ async def to_code(config: ConfigType) -> None: # 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") - # Compile crypto libraries with -O2 for speed instead of -Os. - # Crypto is CPU-bound and benefits significantly from speed optimization. - # GCC uses the last -O flag, so appending -O2 overrides the global -Os. - _write_crypto_optimize_script() else: cg.add_define("USE_API_PLAINTEXT") @@ -470,17 +465,6 @@ async def to_code(config: ConfigType) -> None: cg.add_global(api_ns.using) -_CRYPTO_OPTIMIZE_SCRIPT = "crypto_optimize.py" - - -def _write_crypto_optimize_script() -> None: - from esphome.helpers import copy_file_if_changed - - script_src = pathlib.Path(__file__).parent / f"{_CRYPTO_OPTIMIZE_SCRIPT}.script" - copy_file_if_changed(script_src, CORE.relative_build_path(_CRYPTO_OPTIMIZE_SCRIPT)) - cg.add_platformio_option("extra_scripts", [f"post:{_CRYPTO_OPTIMIZE_SCRIPT}"]) - - KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)}) diff --git a/esphome/components/api/crypto_optimize.py.script b/esphome/components/api/crypto_optimize.py.script deleted file mode 100644 index 1093a09bf6..0000000000 --- a/esphome/components/api/crypto_optimize.py.script +++ /dev/null @@ -1,9 +0,0 @@ -# Compile libsodium with -O2 for speed instead of the default -Os. -# libsodium provides the crypto primitives (Curve25519, ChaCha20, Poly1305) -# used by the Noise protocol and benefits significantly from speed optimization. -# GCC uses the last -O flag, so appending -O2 overrides the global -Os. -Import("env") - -for lb in env.GetLibBuilders(): - if lb.name == "libsodium": - lb.env.Append(CCFLAGS=["-O2"]) diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index 5080a9fec7..92faa05819 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -26,11 +26,12 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" PLATFORMIO_OPTIONS = { + "build_unflags": [ + "-Os", # remove default size-opt + ], "build_flags": [ - "-Os", # match firmware optimization level (detects inlining regressions) + "-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo) "-g", # debug symbols for profiling - "-ffunction-sections", # required for dead-code stripping with -Os - "-fdata-sections", # required for dead-code stripping with -Os "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() f"-I{STUBS_DIR}", # stub headers for ESP32-only components ], From 0436e5bd863282e459026d385120ae8e29616e1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 19:55:30 -1000 Subject: [PATCH 0123/2214] [core] Optimize main loop with -O2 Add __attribute__((optimize("O2"))) to the main loop functions (loop_task on ESP32, codegen loop() on other platforms) so GCC inlines scheduler helpers and loop bookkeeping more aggressively. Under -Os, GCC outlines small functions (Scheduler::call helpers, millis conversions, etc.) that are called every loop iteration. With -O2, these get inlined into the loop body, reducing call overhead on the hottest code path in the firmware. ESP32: loop_task grows from 303 to 416 bytes (+113 bytes). ESP8266: no change (already fully inlined via ESPHOME_ALWAYS_INLINE). --- esphome/components/esp32/core.cpp | 2 +- esphome/writer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index add50dcf4d..1fa51f1d9e 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -65,7 +65,7 @@ static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non- static StackType_t loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -void loop_task(void *pv_params) { +void __attribute__((optimize("O2"))) loop_task(void *pv_params) { setup(); while (true) { App.loop(); diff --git a/esphome/writer.py b/esphome/writer.py index 06a2230118..c2e0d08fa4 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -45,7 +45,7 @@ void setup() { App.setup(); } -void loop() { +void __attribute__((optimize("O2"))) loop() { App.loop(); } """, From 9a022baa06615441ff2a036a9bcfaae223bdf373 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 19:55:30 -1000 Subject: [PATCH 0124/2214] [core] Optimize main loop with -O2 Add __attribute__((optimize("O2"))) to the main loop functions (loop_task on ESP32, codegen loop() on other platforms) so GCC inlines scheduler helpers and loop bookkeeping more aggressively. Under -Os, GCC outlines small functions (Scheduler::call helpers, millis conversions, etc.) that are called every loop iteration. With -O2, these get inlined into the loop body, reducing call overhead on the hottest code path in the firmware. ESP32: loop_task grows from 303 to 416 bytes (+113 bytes). ESP8266: no change (already fully inlined via ESPHOME_ALWAYS_INLINE). --- esphome/components/esp32/core.cpp | 2 +- esphome/components/host/core.cpp | 2 +- esphome/components/zephyr/core.cpp | 2 +- esphome/core/application.h | 2 +- esphome/writer.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index add50dcf4d..1fa51f1d9e 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -65,7 +65,7 @@ static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non- static StackType_t loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -void loop_task(void *pv_params) { +void __attribute__((optimize("O2"))) loop_task(void *pv_params) { setup(); while (true) { App.loop(); diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 0ade4274fe..596b46305f 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -77,7 +77,7 @@ uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } void setup(); void loop(); -int main() { +int __attribute__((optimize("O2"))) main() { // Install signal handlers for graceful shutdown (flushes preferences to disk) std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index a3b0471ebc..84dee374f5 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -95,7 +95,7 @@ void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parame void setup(); void loop(); -int main() { +int __attribute__((optimize("O2"))) main() { setup(); while (true) { loop(); diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b490..5b68146b2d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -837,7 +837,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ this->in_loop_ = true; } -inline void ESPHOME_ALWAYS_INLINE Application::loop() { +inline void ESPHOME_ALWAYS_INLINE __attribute__((optimize("O2"))) Application::loop() { uint8_t new_app_state = 0; // Get the initial loop time at the start diff --git a/esphome/writer.py b/esphome/writer.py index 06a2230118..c2e0d08fa4 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -45,7 +45,7 @@ void setup() { App.setup(); } -void loop() { +void __attribute__((optimize("O2"))) loop() { App.loop(); } """, From 3e9f464a2ce8ca2026f9d56024ec096c1711c170 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 20:21:11 -1000 Subject: [PATCH 0125/2214] Fix clang-tidy and test for optimize(O2) attribute --- esphome/components/api/api_pb2.cpp | 1500 +++++++++------------------ esphome/components/esp32/core.cpp | 3 +- esphome/components/host/core.cpp | 3 +- esphome/components/zephyr/core.cpp | 3 +- esphome/core/application.h | 1 + script/api_protobuf/api_protobuf.py | 2 +- tests/unit_tests/test_writer.py | 3 +- 7 files changed, 488 insertions(+), 1027 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ddc5ed18b9..ee6a8bfd39 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1,33 +1,29 @@ // This file was automatically generated with a tool. // See script/api_protobuf/api_protobuf.py -#include "api_pb2.h" -#include "esphome/core/log.h" -#include "esphome/core/helpers.h" -#include + #include "api_pb2.h" + #include "esphome/core/log.h" + #include "esphome/core/helpers.h" + #include namespace esphome::api { + + bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->api_version_major = value; - break; - case 3: - this->api_version_minor = value; - break; - default: - return false; + case 2: this->api_version_major = value; break; + case 3: this->api_version_minor = value; break; + default: return false; } return true; } bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } @@ -336,38 +332,22 @@ uint32_t CoverStateResponse::calculate_size() const { } bool CoverCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 4: - this->has_position = value != 0; - break; - case 6: - this->has_tilt = value != 0; - break; - case 8: - this->stop = value != 0; - break; + case 4: this->has_position = value != 0; break; + case 6: this->has_tilt = value != 0; break; + case 8: this->stop = value != 0; break; #ifdef USE_DEVICES - case 9: - this->device_id = value; - break; + case 9: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool CoverCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->position = value.as_float(); - break; - case 7: - this->tilt = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->position = value.as_float(); break; + case 7: this->tilt = value.as_float(); break; + default: return false; } return true; } @@ -447,61 +427,36 @@ uint32_t FanStateResponse::calculate_size() const { } bool FanCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 6: - this->has_oscillating = value != 0; - break; - case 7: - this->oscillating = value != 0; - break; - case 8: - this->has_direction = value != 0; - break; - case 9: - this->direction = static_cast(value); - break; - case 10: - this->has_speed_level = value != 0; - break; - case 11: - this->speed_level = static_cast(value); - break; - case 12: - this->has_preset_mode = value != 0; - break; + case 2: this->has_state = value != 0; break; + case 3: this->state = value != 0; break; + case 6: this->has_oscillating = value != 0; break; + case 7: this->oscillating = value != 0; break; + case 8: this->has_direction = value != 0; break; + case 9: this->direction = static_cast(value); break; + case 10: this->has_speed_level = value != 0; break; + case 11: this->speed_level = static_cast(value); break; + case 12: this->has_preset_mode = value != 0; break; #ifdef USE_DEVICES - case 14: - this->device_id = value; - break; + case 14: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool FanCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 13: { - this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool FanCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -597,109 +552,52 @@ uint32_t LightStateResponse::calculate_size() const { } bool LightCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 4: - this->has_brightness = value != 0; - break; - case 22: - this->has_color_mode = value != 0; - break; - case 23: - this->color_mode = static_cast(value); - break; - case 20: - this->has_color_brightness = value != 0; - break; - case 6: - this->has_rgb = value != 0; - break; - case 10: - this->has_white = value != 0; - break; - case 12: - this->has_color_temperature = value != 0; - break; - case 24: - this->has_cold_white = value != 0; - break; - case 26: - this->has_warm_white = value != 0; - break; - case 14: - this->has_transition_length = value != 0; - break; - case 15: - this->transition_length = value; - break; - case 16: - this->has_flash_length = value != 0; - break; - case 17: - this->flash_length = value; - break; - case 18: - this->has_effect = value != 0; - break; + case 2: this->has_state = value != 0; break; + case 3: this->state = value != 0; break; + case 4: this->has_brightness = value != 0; break; + case 22: this->has_color_mode = value != 0; break; + case 23: this->color_mode = static_cast(value); break; + case 20: this->has_color_brightness = value != 0; break; + case 6: this->has_rgb = value != 0; break; + case 10: this->has_white = value != 0; break; + case 12: this->has_color_temperature = value != 0; break; + case 24: this->has_cold_white = value != 0; break; + case 26: this->has_warm_white = value != 0; break; + case 14: this->has_transition_length = value != 0; break; + case 15: this->transition_length = value; break; + case 16: this->has_flash_length = value != 0; break; + case 17: this->flash_length = value; break; + case 18: this->has_effect = value != 0; break; #ifdef USE_DEVICES - case 28: - this->device_id = value; - break; + case 28: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 19: { - this->effect = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->effect = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool LightCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->brightness = value.as_float(); - break; - case 21: - this->color_brightness = value.as_float(); - break; - case 7: - this->red = value.as_float(); - break; - case 8: - this->green = value.as_float(); - break; - case 9: - this->blue = value.as_float(); - break; - case 11: - this->white = value.as_float(); - break; - case 13: - this->color_temperature = value.as_float(); - break; - case 25: - this->cold_white = value.as_float(); - break; - case 27: - this->warm_white = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->brightness = value.as_float(); break; + case 21: this->color_brightness = value.as_float(); break; + case 7: this->red = value.as_float(); break; + case 8: this->green = value.as_float(); break; + case 9: this->blue = value.as_float(); break; + case 11: this->white = value.as_float(); break; + case 13: this->color_temperature = value.as_float(); break; + case 25: this->cold_white = value.as_float(); break; + case 27: this->warm_white = value.as_float(); break; + default: return false; } return true; } @@ -745,8 +643,8 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const { #endif return size; } -__attribute__((optimize("O2"))) uint8_t *SensorStateResponse::encode( - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); @@ -756,7 +654,8 @@ __attribute__((optimize("O2"))) uint8_t *SensorStateResponse::encode( #endif return pos; } -__attribute__((optimize("O2"))) uint32_t SensorStateResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t SensorStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->state); @@ -822,26 +721,18 @@ uint32_t SwitchStateResponse::calculate_size() const { } bool SwitchCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->state = value != 0; - break; + case 2: this->state = value != 0; break; #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -902,14 +793,9 @@ uint32_t TextSensorStateResponse::calculate_size() const { #endif bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->level = static_cast(value); - break; - case 2: - this->dump_config = value != 0; - break; - default: - return false; + case 1: this->level = static_cast(value); break; + case 2: this->dump_config = value != 0; break; + default: return false; } return true; } @@ -929,12 +815,11 @@ uint32_t SubscribeLogsResponse::calculate_size() const { bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->key = value.data(); - this->key_len = value.size(); - break; - } - default: - return false; + this->key = value.data(); + this->key_len = value.size(); + break; + } + default: return false; } return true; } @@ -1020,32 +905,26 @@ uint32_t HomeassistantActionRequest::calculate_size() const { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES bool HomeassistantActionResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->call_id = value; - break; - case 2: - this->success = value != 0; - break; - default: - return false; + case 1: this->call_id = value; break; + case 2: this->success = value != 0; break; + default: return false; } return true; } bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON case 4: { - this->response_data = value.data(); - this->response_data_len = value.size(); - break; - } + this->response_data = value.data(); + this->response_data_len = value.size(); + break; + } #endif - default: - return false; + default: return false; } return true; } @@ -1068,95 +947,65 @@ uint32_t SubscribeHomeAssistantStateResponse::calculate_size() const { bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } #endif bool DSTRule::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->time_seconds = decode_zigzag32(static_cast(value)); - break; - case 2: - this->day = value; - break; - case 3: - this->type = static_cast(value); - break; - case 4: - this->month = value; - break; - case 5: - this->week = value; - break; - case 6: - this->day_of_week = value; - break; - default: - return false; + case 1: this->time_seconds = decode_zigzag32(static_cast(value)); break; + case 2: this->day = value; break; + case 3: this->type = static_cast(value); break; + case 4: this->month = value; break; + case 5: this->week = value; break; + case 6: this->day_of_week = value; break; + default: return false; } return true; } bool ParsedTimezone::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->std_offset_seconds = decode_zigzag32(static_cast(value)); - break; - case 2: - this->dst_offset_seconds = decode_zigzag32(static_cast(value)); - break; - default: - return false; + case 1: this->std_offset_seconds = decode_zigzag32(static_cast(value)); break; + case 2: this->dst_offset_seconds = decode_zigzag32(static_cast(value)); break; + default: return false; } return true; } bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 3: - value.decode_to_message(this->dst_start); - break; - case 4: - value.decode_to_message(this->dst_end); - break; - default: - return false; + case 3: value.decode_to_message(this->dst_start); break; + case 4: value.decode_to_message(this->dst_end); break; + default: return false; } return true; } bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: - value.decode_to_message(this->parsed_timezone); - break; - default: - return false; + this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 3: value.decode_to_message(this->parsed_timezone); break; + default: return false; } return true; } bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->epoch_seconds = value.as_fixed32(); - break; - default: - return false; + case 1: this->epoch_seconds = value.as_fixed32(); break; + default: return false; } return true; } @@ -1197,50 +1046,31 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const { } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->bool_ = value != 0; - break; - case 2: - this->legacy_int = static_cast(value); - break; - case 5: - this->int_ = decode_zigzag32(static_cast(value)); - break; - case 6: - this->bool_array.push_back(value != 0); - break; - case 7: - this->int_array.push_back(decode_zigzag32(static_cast(value))); - break; - default: - return false; + case 1: this->bool_ = value != 0; break; + case 2: this->legacy_int = static_cast(value); break; + case 5: this->int_ = decode_zigzag32(static_cast(value)); break; + case 6: this->bool_array.push_back(value != 0); break; + case 7: this->int_array.push_back(decode_zigzag32(static_cast(value))); break; + default: return false; } return true; } bool ExecuteServiceArgument::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 9: - this->string_array.push_back(value.as_string()); - break; - default: - return false; + this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 9: this->string_array.push_back(value.as_string()); break; + default: return false; } return true; } bool ExecuteServiceArgument::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 3: - this->float_ = value.as_float(); - break; - case 8: - this->float_array.push_back(value.as_float()); - break; - default: - return false; + case 3: this->float_ = value.as_float(); break; + case 8: this->float_array.push_back(value.as_float()); break; + default: return false; } return true; } @@ -1258,38 +1088,26 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { bool ExecuteServiceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 3: - this->call_id = value; - break; + case 3: this->call_id = value; break; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 4: - this->return_response = value != 0; - break; + case 4: this->return_response = value != 0; break; #endif - default: - return false; + default: return false; } return true; } bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->args.emplace_back(); - value.decode_to_message(this->args.back()); - break; - default: - return false; + case 2: this->args.emplace_back(); value.decode_to_message(this->args.back()); break; + default: return false; } return true; } bool ExecuteServiceRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -1374,14 +1192,9 @@ uint32_t CameraImageResponse::calculate_size() const { } bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->single = value != 0; - break; - case 2: - this->stream = value != 0; - break; - default: - return false; + case 1: this->single = value != 0; break; + case 2: this->stream = value != 0; break; + default: return false; } return true; } @@ -1525,92 +1338,49 @@ uint32_t ClimateStateResponse::calculate_size() const { } bool ClimateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_mode = value != 0; - break; - case 3: - this->mode = static_cast(value); - break; - case 4: - this->has_target_temperature = value != 0; - break; - case 6: - this->has_target_temperature_low = value != 0; - break; - case 8: - this->has_target_temperature_high = value != 0; - break; - case 12: - this->has_fan_mode = value != 0; - break; - case 13: - this->fan_mode = static_cast(value); - break; - case 14: - this->has_swing_mode = value != 0; - break; - case 15: - this->swing_mode = static_cast(value); - break; - case 16: - this->has_custom_fan_mode = value != 0; - break; - case 18: - this->has_preset = value != 0; - break; - case 19: - this->preset = static_cast(value); - break; - case 20: - this->has_custom_preset = value != 0; - break; - case 22: - this->has_target_humidity = value != 0; - break; + case 2: this->has_mode = value != 0; break; + case 3: this->mode = static_cast(value); break; + case 4: this->has_target_temperature = value != 0; break; + case 6: this->has_target_temperature_low = value != 0; break; + case 8: this->has_target_temperature_high = value != 0; break; + case 12: this->has_fan_mode = value != 0; break; + case 13: this->fan_mode = static_cast(value); break; + case 14: this->has_swing_mode = value != 0; break; + case 15: this->swing_mode = static_cast(value); break; + case 16: this->has_custom_fan_mode = value != 0; break; + case 18: this->has_preset = value != 0; break; + case 19: this->preset = static_cast(value); break; + case 20: this->has_custom_preset = value != 0; break; + case 22: this->has_target_humidity = value != 0; break; #ifdef USE_DEVICES - case 24: - this->device_id = value; - break; + case 24: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 17: { - this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 21: { - this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->target_temperature = value.as_float(); - break; - case 7: - this->target_temperature_low = value.as_float(); - break; - case 9: - this->target_temperature_high = value.as_float(); - break; - case 23: - this->target_humidity = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->target_temperature = value.as_float(); break; + case 7: this->target_temperature_low = value.as_float(); break; + case 9: this->target_temperature_high = value.as_float(); break; + case 23: this->target_humidity = value.as_float(); break; + default: return false; } return true; } @@ -1690,41 +1460,23 @@ uint32_t WaterHeaterStateResponse::calculate_size() const { } bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_fields = value; - break; - case 3: - this->mode = static_cast(value); - break; + case 2: this->has_fields = value; break; + case 3: this->mode = static_cast(value); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - case 6: - this->state = value; - break; - default: - return false; + case 6: this->state = value; break; + default: return false; } return true; } bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 4: - this->target_temperature = value.as_float(); - break; - case 7: - this->target_temperature_low = value.as_float(); - break; - case 8: - this->target_temperature_high = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 4: this->target_temperature = value.as_float(); break; + case 7: this->target_temperature_low = value.as_float(); break; + case 8: this->target_temperature_high = value.as_float(); break; + default: return false; } return true; } @@ -1795,25 +1547,17 @@ uint32_t NumberStateResponse::calculate_size() const { bool NumberCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 2: - this->state = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 2: this->state = value.as_float(); break; + default: return false; } return true; } @@ -1880,33 +1624,26 @@ uint32_t SelectStateResponse::calculate_size() const { bool SelectCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -1974,55 +1711,34 @@ uint32_t SirenStateResponse::calculate_size() const { } bool SirenCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 4: - this->has_tone = value != 0; - break; - case 6: - this->has_duration = value != 0; - break; - case 7: - this->duration = value; - break; - case 8: - this->has_volume = value != 0; - break; + case 2: this->has_state = value != 0; break; + case 3: this->state = value != 0; break; + case 4: this->has_tone = value != 0; break; + case 6: this->has_duration = value != 0; break; + case 7: this->duration = value; break; + case 8: this->has_volume = value != 0; break; #ifdef USE_DEVICES - case 10: - this->device_id = value; - break; + case 10: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool SirenCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 5: { - this->tone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->tone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 9: - this->volume = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 9: this->volume = value.as_float(); break; + default: return false; } return true; } @@ -2086,40 +1802,29 @@ uint32_t LockStateResponse::calculate_size() const { } bool LockCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->command = static_cast(value); - break; - case 3: - this->has_code = value != 0; - break; + case 2: this->command = static_cast(value); break; + case 3: this->has_code = value != 0; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool LockCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->code = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -2160,22 +1865,16 @@ uint32_t ListEntitiesButtonResponse::calculate_size() const { bool ButtonCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 2: - this->device_id = value; - break; + case 2: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -2265,55 +1964,34 @@ uint32_t MediaPlayerStateResponse::calculate_size() const { } bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_command = value != 0; - break; - case 3: - this->command = static_cast(value); - break; - case 4: - this->has_volume = value != 0; - break; - case 6: - this->has_media_url = value != 0; - break; - case 8: - this->has_announcement = value != 0; - break; - case 9: - this->announcement = value != 0; - break; + case 2: this->has_command = value != 0; break; + case 3: this->command = static_cast(value); break; + case 4: this->has_volume = value != 0; break; + case 6: this->has_media_url = value != 0; break; + case 8: this->has_announcement = value != 0; break; + case 9: this->announcement = value != 0; break; #ifdef USE_DEVICES - case 10: - this->device_id = value; - break; + case 10: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool MediaPlayerCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 7: { - this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->volume = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->volume = value.as_float(); break; + default: return false; } return true; } @@ -2321,38 +1999,36 @@ bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value #ifdef USE_BLUETOOTH_PROXY bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->flags = value; - break; - default: - return false; + case 1: this->flags = value; break; + default: return false; } return true; } -__attribute__((optimize("O2"))) uint8_t *BluetoothLERawAdvertisementsResponse::encode( - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); - uint8_t *len_pos = pos; - ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); - if (sub_msg.address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); - } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); - *len_pos = static_cast(pos - len_pos - 1); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + uint8_t *len_pos = pos; + ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + if (sub_msg.address_type) { + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); + } + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + *len_pos = static_cast(pos - len_pos - 1); } return pos; } -__attribute__((optimize("O2"))) uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; @@ -2366,20 +2042,11 @@ __attribute__((optimize("O2"))) uint32_t BluetoothLERawAdvertisementsResponse::c } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->request_type = static_cast(value); - break; - case 3: - this->has_address_type = value != 0; - break; - case 4: - this->address_type = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->request_type = static_cast(value); break; + case 3: this->has_address_type = value != 0; break; + case 4: this->address_type = value; break; + default: return false; } return true; } @@ -2401,11 +2068,8 @@ uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { } bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - default: - return false; + case 1: this->address = value; break; + default: return false; } return true; } @@ -2517,14 +2181,9 @@ uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { } bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + default: return false; } return true; } @@ -2544,83 +2203,57 @@ uint32_t BluetoothGATTReadResponse::calculate_size() const { } bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - case 3: - this->response = value != 0; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + case 3: this->response = value != 0; break; + default: return false; } return true; } bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + default: return false; } return true; } bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + default: return false; } return true; } bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - case 3: - this->enable = value != 0; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + case 3: this->enable = value != 0; break; + default: return false; } return true; } @@ -2756,11 +2389,8 @@ uint32_t BluetoothScannerStateResponse::calculate_size() const { } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->mode = static_cast(value); - break; - default: - return false; + case 1: this->mode = static_cast(value); break; + default: return false; } return true; } @@ -2768,14 +2398,9 @@ bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_vari #ifdef USE_VOICE_ASSISTANT bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->subscribe = value != 0; - break; - case 2: - this->flags = value; - break; - default: - return false; + case 1: this->subscribe = value != 0; break; + case 2: this->flags = value; break; + default: return false; } return true; } @@ -2813,72 +2438,55 @@ uint32_t VoiceAssistantRequest::calculate_size() const { } bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->port = value; - break; - case 2: - this->error = value != 0; - break; - default: - return false; + case 1: this->port = value; break; + case 2: this->error = value != 0; break; + default: return false; } return true; } bool VoiceAssistantEventData::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->name = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->value = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->value = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->event_type = static_cast(value); - break; - default: - return false; + case 1: this->event_type = static_cast(value); break; + default: return false; } return true; } bool VoiceAssistantEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->data.emplace_back(); - value.decode_to_message(this->data.back()); - break; - default: - return false; + case 2: this->data.emplace_back(); value.decode_to_message(this->data.back()); break; + default: return false; } return true; } bool VoiceAssistantAudio::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->end = value != 0; - break; - default: - return false; + case 2: this->end = value != 0; break; + default: return false; } return true; } bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } @@ -2896,64 +2504,50 @@ uint32_t VoiceAssistantAudio::calculate_size() const { } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->event_type = static_cast(value); - break; - case 4: - this->total_seconds = value; - break; - case 5: - this->seconds_left = value; - break; - case 6: - this->is_active = value != 0; - break; - default: - return false; + case 1: this->event_type = static_cast(value); break; + case 4: this->total_seconds = value; break; + case 5: this->seconds_left = value; break; + case 6: this->is_active = value != 0; break; + default: return false; } return true; } bool VoiceAssistantTimerEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->name = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 4: - this->start_conversation = value != 0; - break; - default: - return false; + case 4: this->start_conversation = value != 0; break; + default: return false; } return true; } bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->text = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->text = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } @@ -2989,52 +2583,42 @@ uint32_t VoiceAssistantWakeWord::calculate_size() const { } bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 5: - this->model_size = value; - break; - default: - return false; + case 5: this->model_size = value; break; + default: return false; } return true; } bool VoiceAssistantExternalWakeWord::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: - this->trained_languages.push_back(value.as_string()); - break; + this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 3: this->trained_languages.push_back(value.as_string()); break; case 4: { - this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 6: { - this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 7: { - this->url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->url = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->external_wake_words.emplace_back(); - value.decode_to_message(this->external_wake_words.back()); - break; - default: - return false; + case 1: this->external_wake_words.emplace_back(); value.decode_to_message(this->external_wake_words.back()); break; + default: return false; } return true; } @@ -3066,11 +2650,8 @@ uint32_t VoiceAssistantConfigurationResponse::calculate_size() const { } bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->active_wake_words.push_back(value.as_string()); - break; - default: - return false; + case 1: this->active_wake_words.push_back(value.as_string()); break; + default: return false; } return true; } @@ -3132,37 +2713,28 @@ uint32_t AlarmControlPanelStateResponse::calculate_size() const { } bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->command = static_cast(value); - break; + case 2: this->command = static_cast(value); break; #ifdef USE_DEVICES - case 4: - this->device_id = value; - break; + case 4: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool AlarmControlPanelCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->code = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3229,33 +2801,26 @@ uint32_t TextStateResponse::calculate_size() const { bool TextCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool TextCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3317,32 +2882,20 @@ uint32_t DateStateResponse::calculate_size() const { } bool DateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->year = value; - break; - case 3: - this->month = value; - break; - case 4: - this->day = value; - break; + case 2: this->year = value; break; + case 3: this->month = value; break; + case 4: this->day = value; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3404,32 +2957,20 @@ uint32_t TimeStateResponse::calculate_size() const { } bool TimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->hour = value; - break; - case 3: - this->minute = value; - break; - case 4: - this->second = value; - break; + case 2: this->hour = value; break; + case 3: this->minute = value; break; + case 4: this->second = value; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3555,32 +3096,20 @@ uint32_t ValveStateResponse::calculate_size() const { } bool ValveCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_position = value != 0; - break; - case 4: - this->stop = value != 0; - break; + case 2: this->has_position = value != 0; break; + case 4: this->stop = value != 0; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 3: - this->position = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 3: this->position = value.as_float(); break; + default: return false; } return true; } @@ -3639,25 +3168,17 @@ uint32_t DateTimeStateResponse::calculate_size() const { bool DateTimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 2: - this->epoch_seconds = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 2: this->epoch_seconds = value.as_fixed32(); break; + default: return false; } return true; } @@ -3731,26 +3252,18 @@ uint32_t UpdateStateResponse::calculate_size() const { } bool UpdateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->command = static_cast(value); - break; + case 2: this->command = static_cast(value); break; #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3759,12 +3272,11 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } @@ -3780,23 +3292,19 @@ uint32_t ZWaveProxyFrame::calculate_size() const { } bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->type = static_cast(value); - break; - default: - return false; + case 1: this->type = static_cast(value); break; + default: return false; } return true; } bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } @@ -3853,41 +3361,30 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const { bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 1: - this->device_id = value; - break; + case 1: this->device_id = value; break; #endif - case 3: - this->carrier_frequency = value; - break; - case 4: - this->repeat_count = value; - break; - default: - return false; + case 3: this->carrier_frequency = value; break; + case 4: this->repeat_count = value; break; + default: return false; } return true; } bool InfraredRFTransmitRawTimingsRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 5: { - this->timings_data_ = value.data(); - this->timings_length_ = value.size(); - this->timings_count_ = count_packed_varints(value.data(), value.size()); - break; - } - default: - return false; + this->timings_data_ = value.data(); + this->timings_length_ = value.size(); + this->timings_count_ = count_packed_varints(value.data(), value.size()); + break; + } + default: return false; } return true; } bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 2: - this->key = value.as_fixed32(); - break; - default: - return false; + case 2: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3919,26 +3416,13 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const { #ifdef USE_SERIAL_PROXY bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - case 2: - this->baudrate = value; - break; - case 3: - this->flow_control = value != 0; - break; - case 4: - this->parity = static_cast(value); - break; - case 5: - this->stop_bits = value; - break; - case 6: - this->data_size = value; - break; - default: - return false; + case 1: this->instance = value; break; + case 2: this->baudrate = value; break; + case 3: this->flow_control = value != 0; break; + case 4: this->parity = static_cast(value); break; + case 5: this->stop_bits = value; break; + case 6: this->data_size = value; break; + default: return false; } return true; } @@ -3956,46 +3440,34 @@ uint32_t SerialProxyDataReceived::calculate_size() const { } bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - default: - return false; + case 1: this->instance = value; break; + default: return false; } return true; } bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - case 2: - this->line_states = value; - break; - default: - return false; + case 1: this->instance = value; break; + case 2: this->line_states = value; break; + default: return false; } return true; } bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - default: - return false; + case 1: this->instance = value; break; + default: return false; } return true; } @@ -4013,14 +3485,9 @@ uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { } bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - case 2: - this->type = static_cast(value); - break; - default: - return false; + case 1: this->instance = value; break; + case 2: this->type = static_cast(value); break; + default: return false; } return true; } @@ -4044,23 +3511,12 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { #ifdef USE_BLUETOOTH_PROXY bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->min_interval = value; - break; - case 3: - this->max_interval = value; - break; - case 4: - this->latency = value; - break; - case 5: - this->timeout = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->min_interval = value; break; + case 3: this->max_interval = value; break; + case 4: this->latency = value; break; + case 5: this->timeout = value; break; + default: return false; } return true; } diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 1fa51f1d9e..dd9a76dcd4 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -65,7 +65,8 @@ static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non- static StackType_t loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -void __attribute__((optimize("O2"))) loop_task(void *pv_params) { +void __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +loop_task(void *pv_params) { setup(); while (true) { App.loop(); diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 596b46305f..4217804516 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -77,7 +77,8 @@ uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } void setup(); void loop(); -int __attribute__((optimize("O2"))) main() { +int __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +main() { // Install signal handlers for graceful shutdown (flushes preferences to disk) std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index 84dee374f5..26832b3b21 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -95,7 +95,8 @@ void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parame void setup(); void loop(); -int __attribute__((optimize("O2"))) main() { +int __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +main() { setup(); while (true) { loop(); diff --git a/esphome/core/application.h b/esphome/core/application.h index 5b68146b2d..8c6c0a9df4 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -837,6 +837,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ this->in_loop_ = true; } +// NOLINTNEXTLINE(clang-diagnostic-unknown-attributes) inline void ESPHOME_ALWAYS_INLINE __attribute__((optimize("O2"))) Application::loop() { uint8_t new_app_state = 0; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 4f52b07809..a58474f9fe 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2683,7 +2683,7 @@ def build_message_type( # When set, __attribute__((optimize("O2"))) is added to the definitions # so GCC inlines the small ProtoEncode helpers even under -Os. is_speed_optimized = get_opt(desc, pb.speed_optimized, False) - speed_attr = '__attribute__((optimize("O2"))) ' if is_speed_optimized else "" + speed_attr = '__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)\n' if is_speed_optimized else "" # Only generate encode method if this message needs encoding and has fields if needs_encode and encode and not is_inline_only: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 940a394c08..9f90e0ec0c 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -782,7 +782,8 @@ def test_write_cpp_creates_new_file( assert CPP_AUTO_GENERATE_END in written_content assert test_code in written_content assert "void setup()" in written_content - assert "void loop()" in written_content + assert 'optimize("O2")' in written_content + assert "loop()" in written_content assert "App.setup();" in written_content assert "App.loop();" in written_content From 603d5a2b5441611931dea6977764690207ec2bf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 20:21:43 -1000 Subject: [PATCH 0126/2214] Fix clang-tidy NOLINT for optimize(O2) in generated protobuf code --- esphome/components/api/api_pb2.cpp | 1500 +++++++++------------------ script/api_protobuf/api_protobuf.py | 2 +- 2 files changed, 479 insertions(+), 1023 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ddc5ed18b9..ee6a8bfd39 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1,33 +1,29 @@ // This file was automatically generated with a tool. // See script/api_protobuf/api_protobuf.py -#include "api_pb2.h" -#include "esphome/core/log.h" -#include "esphome/core/helpers.h" -#include + #include "api_pb2.h" + #include "esphome/core/log.h" + #include "esphome/core/helpers.h" + #include namespace esphome::api { + + bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->api_version_major = value; - break; - case 3: - this->api_version_minor = value; - break; - default: - return false; + case 2: this->api_version_major = value; break; + case 3: this->api_version_minor = value; break; + default: return false; } return true; } bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } @@ -336,38 +332,22 @@ uint32_t CoverStateResponse::calculate_size() const { } bool CoverCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 4: - this->has_position = value != 0; - break; - case 6: - this->has_tilt = value != 0; - break; - case 8: - this->stop = value != 0; - break; + case 4: this->has_position = value != 0; break; + case 6: this->has_tilt = value != 0; break; + case 8: this->stop = value != 0; break; #ifdef USE_DEVICES - case 9: - this->device_id = value; - break; + case 9: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool CoverCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->position = value.as_float(); - break; - case 7: - this->tilt = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->position = value.as_float(); break; + case 7: this->tilt = value.as_float(); break; + default: return false; } return true; } @@ -447,61 +427,36 @@ uint32_t FanStateResponse::calculate_size() const { } bool FanCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 6: - this->has_oscillating = value != 0; - break; - case 7: - this->oscillating = value != 0; - break; - case 8: - this->has_direction = value != 0; - break; - case 9: - this->direction = static_cast(value); - break; - case 10: - this->has_speed_level = value != 0; - break; - case 11: - this->speed_level = static_cast(value); - break; - case 12: - this->has_preset_mode = value != 0; - break; + case 2: this->has_state = value != 0; break; + case 3: this->state = value != 0; break; + case 6: this->has_oscillating = value != 0; break; + case 7: this->oscillating = value != 0; break; + case 8: this->has_direction = value != 0; break; + case 9: this->direction = static_cast(value); break; + case 10: this->has_speed_level = value != 0; break; + case 11: this->speed_level = static_cast(value); break; + case 12: this->has_preset_mode = value != 0; break; #ifdef USE_DEVICES - case 14: - this->device_id = value; - break; + case 14: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool FanCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 13: { - this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool FanCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -597,109 +552,52 @@ uint32_t LightStateResponse::calculate_size() const { } bool LightCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 4: - this->has_brightness = value != 0; - break; - case 22: - this->has_color_mode = value != 0; - break; - case 23: - this->color_mode = static_cast(value); - break; - case 20: - this->has_color_brightness = value != 0; - break; - case 6: - this->has_rgb = value != 0; - break; - case 10: - this->has_white = value != 0; - break; - case 12: - this->has_color_temperature = value != 0; - break; - case 24: - this->has_cold_white = value != 0; - break; - case 26: - this->has_warm_white = value != 0; - break; - case 14: - this->has_transition_length = value != 0; - break; - case 15: - this->transition_length = value; - break; - case 16: - this->has_flash_length = value != 0; - break; - case 17: - this->flash_length = value; - break; - case 18: - this->has_effect = value != 0; - break; + case 2: this->has_state = value != 0; break; + case 3: this->state = value != 0; break; + case 4: this->has_brightness = value != 0; break; + case 22: this->has_color_mode = value != 0; break; + case 23: this->color_mode = static_cast(value); break; + case 20: this->has_color_brightness = value != 0; break; + case 6: this->has_rgb = value != 0; break; + case 10: this->has_white = value != 0; break; + case 12: this->has_color_temperature = value != 0; break; + case 24: this->has_cold_white = value != 0; break; + case 26: this->has_warm_white = value != 0; break; + case 14: this->has_transition_length = value != 0; break; + case 15: this->transition_length = value; break; + case 16: this->has_flash_length = value != 0; break; + case 17: this->flash_length = value; break; + case 18: this->has_effect = value != 0; break; #ifdef USE_DEVICES - case 28: - this->device_id = value; - break; + case 28: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 19: { - this->effect = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->effect = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool LightCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->brightness = value.as_float(); - break; - case 21: - this->color_brightness = value.as_float(); - break; - case 7: - this->red = value.as_float(); - break; - case 8: - this->green = value.as_float(); - break; - case 9: - this->blue = value.as_float(); - break; - case 11: - this->white = value.as_float(); - break; - case 13: - this->color_temperature = value.as_float(); - break; - case 25: - this->cold_white = value.as_float(); - break; - case 27: - this->warm_white = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->brightness = value.as_float(); break; + case 21: this->color_brightness = value.as_float(); break; + case 7: this->red = value.as_float(); break; + case 8: this->green = value.as_float(); break; + case 9: this->blue = value.as_float(); break; + case 11: this->white = value.as_float(); break; + case 13: this->color_temperature = value.as_float(); break; + case 25: this->cold_white = value.as_float(); break; + case 27: this->warm_white = value.as_float(); break; + default: return false; } return true; } @@ -745,8 +643,8 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const { #endif return size; } -__attribute__((optimize("O2"))) uint8_t *SensorStateResponse::encode( - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); @@ -756,7 +654,8 @@ __attribute__((optimize("O2"))) uint8_t *SensorStateResponse::encode( #endif return pos; } -__attribute__((optimize("O2"))) uint32_t SensorStateResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t SensorStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->state); @@ -822,26 +721,18 @@ uint32_t SwitchStateResponse::calculate_size() const { } bool SwitchCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->state = value != 0; - break; + case 2: this->state = value != 0; break; #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -902,14 +793,9 @@ uint32_t TextSensorStateResponse::calculate_size() const { #endif bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->level = static_cast(value); - break; - case 2: - this->dump_config = value != 0; - break; - default: - return false; + case 1: this->level = static_cast(value); break; + case 2: this->dump_config = value != 0; break; + default: return false; } return true; } @@ -929,12 +815,11 @@ uint32_t SubscribeLogsResponse::calculate_size() const { bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->key = value.data(); - this->key_len = value.size(); - break; - } - default: - return false; + this->key = value.data(); + this->key_len = value.size(); + break; + } + default: return false; } return true; } @@ -1020,32 +905,26 @@ uint32_t HomeassistantActionRequest::calculate_size() const { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES bool HomeassistantActionResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->call_id = value; - break; - case 2: - this->success = value != 0; - break; - default: - return false; + case 1: this->call_id = value; break; + case 2: this->success = value != 0; break; + default: return false; } return true; } bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON case 4: { - this->response_data = value.data(); - this->response_data_len = value.size(); - break; - } + this->response_data = value.data(); + this->response_data_len = value.size(); + break; + } #endif - default: - return false; + default: return false; } return true; } @@ -1068,95 +947,65 @@ uint32_t SubscribeHomeAssistantStateResponse::calculate_size() const { bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } #endif bool DSTRule::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->time_seconds = decode_zigzag32(static_cast(value)); - break; - case 2: - this->day = value; - break; - case 3: - this->type = static_cast(value); - break; - case 4: - this->month = value; - break; - case 5: - this->week = value; - break; - case 6: - this->day_of_week = value; - break; - default: - return false; + case 1: this->time_seconds = decode_zigzag32(static_cast(value)); break; + case 2: this->day = value; break; + case 3: this->type = static_cast(value); break; + case 4: this->month = value; break; + case 5: this->week = value; break; + case 6: this->day_of_week = value; break; + default: return false; } return true; } bool ParsedTimezone::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->std_offset_seconds = decode_zigzag32(static_cast(value)); - break; - case 2: - this->dst_offset_seconds = decode_zigzag32(static_cast(value)); - break; - default: - return false; + case 1: this->std_offset_seconds = decode_zigzag32(static_cast(value)); break; + case 2: this->dst_offset_seconds = decode_zigzag32(static_cast(value)); break; + default: return false; } return true; } bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 3: - value.decode_to_message(this->dst_start); - break; - case 4: - value.decode_to_message(this->dst_end); - break; - default: - return false; + case 3: value.decode_to_message(this->dst_start); break; + case 4: value.decode_to_message(this->dst_end); break; + default: return false; } return true; } bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: - value.decode_to_message(this->parsed_timezone); - break; - default: - return false; + this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 3: value.decode_to_message(this->parsed_timezone); break; + default: return false; } return true; } bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->epoch_seconds = value.as_fixed32(); - break; - default: - return false; + case 1: this->epoch_seconds = value.as_fixed32(); break; + default: return false; } return true; } @@ -1197,50 +1046,31 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const { } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->bool_ = value != 0; - break; - case 2: - this->legacy_int = static_cast(value); - break; - case 5: - this->int_ = decode_zigzag32(static_cast(value)); - break; - case 6: - this->bool_array.push_back(value != 0); - break; - case 7: - this->int_array.push_back(decode_zigzag32(static_cast(value))); - break; - default: - return false; + case 1: this->bool_ = value != 0; break; + case 2: this->legacy_int = static_cast(value); break; + case 5: this->int_ = decode_zigzag32(static_cast(value)); break; + case 6: this->bool_array.push_back(value != 0); break; + case 7: this->int_array.push_back(decode_zigzag32(static_cast(value))); break; + default: return false; } return true; } bool ExecuteServiceArgument::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 9: - this->string_array.push_back(value.as_string()); - break; - default: - return false; + this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 9: this->string_array.push_back(value.as_string()); break; + default: return false; } return true; } bool ExecuteServiceArgument::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 3: - this->float_ = value.as_float(); - break; - case 8: - this->float_array.push_back(value.as_float()); - break; - default: - return false; + case 3: this->float_ = value.as_float(); break; + case 8: this->float_array.push_back(value.as_float()); break; + default: return false; } return true; } @@ -1258,38 +1088,26 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { bool ExecuteServiceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 3: - this->call_id = value; - break; + case 3: this->call_id = value; break; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 4: - this->return_response = value != 0; - break; + case 4: this->return_response = value != 0; break; #endif - default: - return false; + default: return false; } return true; } bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->args.emplace_back(); - value.decode_to_message(this->args.back()); - break; - default: - return false; + case 2: this->args.emplace_back(); value.decode_to_message(this->args.back()); break; + default: return false; } return true; } bool ExecuteServiceRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -1374,14 +1192,9 @@ uint32_t CameraImageResponse::calculate_size() const { } bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->single = value != 0; - break; - case 2: - this->stream = value != 0; - break; - default: - return false; + case 1: this->single = value != 0; break; + case 2: this->stream = value != 0; break; + default: return false; } return true; } @@ -1525,92 +1338,49 @@ uint32_t ClimateStateResponse::calculate_size() const { } bool ClimateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_mode = value != 0; - break; - case 3: - this->mode = static_cast(value); - break; - case 4: - this->has_target_temperature = value != 0; - break; - case 6: - this->has_target_temperature_low = value != 0; - break; - case 8: - this->has_target_temperature_high = value != 0; - break; - case 12: - this->has_fan_mode = value != 0; - break; - case 13: - this->fan_mode = static_cast(value); - break; - case 14: - this->has_swing_mode = value != 0; - break; - case 15: - this->swing_mode = static_cast(value); - break; - case 16: - this->has_custom_fan_mode = value != 0; - break; - case 18: - this->has_preset = value != 0; - break; - case 19: - this->preset = static_cast(value); - break; - case 20: - this->has_custom_preset = value != 0; - break; - case 22: - this->has_target_humidity = value != 0; - break; + case 2: this->has_mode = value != 0; break; + case 3: this->mode = static_cast(value); break; + case 4: this->has_target_temperature = value != 0; break; + case 6: this->has_target_temperature_low = value != 0; break; + case 8: this->has_target_temperature_high = value != 0; break; + case 12: this->has_fan_mode = value != 0; break; + case 13: this->fan_mode = static_cast(value); break; + case 14: this->has_swing_mode = value != 0; break; + case 15: this->swing_mode = static_cast(value); break; + case 16: this->has_custom_fan_mode = value != 0; break; + case 18: this->has_preset = value != 0; break; + case 19: this->preset = static_cast(value); break; + case 20: this->has_custom_preset = value != 0; break; + case 22: this->has_target_humidity = value != 0; break; #ifdef USE_DEVICES - case 24: - this->device_id = value; - break; + case 24: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 17: { - this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 21: { - this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->target_temperature = value.as_float(); - break; - case 7: - this->target_temperature_low = value.as_float(); - break; - case 9: - this->target_temperature_high = value.as_float(); - break; - case 23: - this->target_humidity = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->target_temperature = value.as_float(); break; + case 7: this->target_temperature_low = value.as_float(); break; + case 9: this->target_temperature_high = value.as_float(); break; + case 23: this->target_humidity = value.as_float(); break; + default: return false; } return true; } @@ -1690,41 +1460,23 @@ uint32_t WaterHeaterStateResponse::calculate_size() const { } bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_fields = value; - break; - case 3: - this->mode = static_cast(value); - break; + case 2: this->has_fields = value; break; + case 3: this->mode = static_cast(value); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - case 6: - this->state = value; - break; - default: - return false; + case 6: this->state = value; break; + default: return false; } return true; } bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 4: - this->target_temperature = value.as_float(); - break; - case 7: - this->target_temperature_low = value.as_float(); - break; - case 8: - this->target_temperature_high = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 4: this->target_temperature = value.as_float(); break; + case 7: this->target_temperature_low = value.as_float(); break; + case 8: this->target_temperature_high = value.as_float(); break; + default: return false; } return true; } @@ -1795,25 +1547,17 @@ uint32_t NumberStateResponse::calculate_size() const { bool NumberCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 2: - this->state = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 2: this->state = value.as_float(); break; + default: return false; } return true; } @@ -1880,33 +1624,26 @@ uint32_t SelectStateResponse::calculate_size() const { bool SelectCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -1974,55 +1711,34 @@ uint32_t SirenStateResponse::calculate_size() const { } bool SirenCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 4: - this->has_tone = value != 0; - break; - case 6: - this->has_duration = value != 0; - break; - case 7: - this->duration = value; - break; - case 8: - this->has_volume = value != 0; - break; + case 2: this->has_state = value != 0; break; + case 3: this->state = value != 0; break; + case 4: this->has_tone = value != 0; break; + case 6: this->has_duration = value != 0; break; + case 7: this->duration = value; break; + case 8: this->has_volume = value != 0; break; #ifdef USE_DEVICES - case 10: - this->device_id = value; - break; + case 10: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool SirenCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 5: { - this->tone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->tone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 9: - this->volume = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 9: this->volume = value.as_float(); break; + default: return false; } return true; } @@ -2086,40 +1802,29 @@ uint32_t LockStateResponse::calculate_size() const { } bool LockCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->command = static_cast(value); - break; - case 3: - this->has_code = value != 0; - break; + case 2: this->command = static_cast(value); break; + case 3: this->has_code = value != 0; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool LockCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->code = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -2160,22 +1865,16 @@ uint32_t ListEntitiesButtonResponse::calculate_size() const { bool ButtonCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 2: - this->device_id = value; - break; + case 2: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -2265,55 +1964,34 @@ uint32_t MediaPlayerStateResponse::calculate_size() const { } bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_command = value != 0; - break; - case 3: - this->command = static_cast(value); - break; - case 4: - this->has_volume = value != 0; - break; - case 6: - this->has_media_url = value != 0; - break; - case 8: - this->has_announcement = value != 0; - break; - case 9: - this->announcement = value != 0; - break; + case 2: this->has_command = value != 0; break; + case 3: this->command = static_cast(value); break; + case 4: this->has_volume = value != 0; break; + case 6: this->has_media_url = value != 0; break; + case 8: this->has_announcement = value != 0; break; + case 9: this->announcement = value != 0; break; #ifdef USE_DEVICES - case 10: - this->device_id = value; - break; + case 10: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool MediaPlayerCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 7: { - this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->volume = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->volume = value.as_float(); break; + default: return false; } return true; } @@ -2321,38 +1999,36 @@ bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value #ifdef USE_BLUETOOTH_PROXY bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->flags = value; - break; - default: - return false; + case 1: this->flags = value; break; + default: return false; } return true; } -__attribute__((optimize("O2"))) uint8_t *BluetoothLERawAdvertisementsResponse::encode( - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); - uint8_t *len_pos = pos; - ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); - if (sub_msg.address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); - } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); - *len_pos = static_cast(pos - len_pos - 1); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + uint8_t *len_pos = pos; + ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + if (sub_msg.address_type) { + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); + } + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + *len_pos = static_cast(pos - len_pos - 1); } return pos; } -__attribute__((optimize("O2"))) uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; @@ -2366,20 +2042,11 @@ __attribute__((optimize("O2"))) uint32_t BluetoothLERawAdvertisementsResponse::c } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->request_type = static_cast(value); - break; - case 3: - this->has_address_type = value != 0; - break; - case 4: - this->address_type = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->request_type = static_cast(value); break; + case 3: this->has_address_type = value != 0; break; + case 4: this->address_type = value; break; + default: return false; } return true; } @@ -2401,11 +2068,8 @@ uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { } bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - default: - return false; + case 1: this->address = value; break; + default: return false; } return true; } @@ -2517,14 +2181,9 @@ uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { } bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + default: return false; } return true; } @@ -2544,83 +2203,57 @@ uint32_t BluetoothGATTReadResponse::calculate_size() const { } bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - case 3: - this->response = value != 0; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + case 3: this->response = value != 0; break; + default: return false; } return true; } bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + default: return false; } return true; } bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + default: return false; } return true; } bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - case 3: - this->enable = value != 0; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + case 3: this->enable = value != 0; break; + default: return false; } return true; } @@ -2756,11 +2389,8 @@ uint32_t BluetoothScannerStateResponse::calculate_size() const { } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->mode = static_cast(value); - break; - default: - return false; + case 1: this->mode = static_cast(value); break; + default: return false; } return true; } @@ -2768,14 +2398,9 @@ bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_vari #ifdef USE_VOICE_ASSISTANT bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->subscribe = value != 0; - break; - case 2: - this->flags = value; - break; - default: - return false; + case 1: this->subscribe = value != 0; break; + case 2: this->flags = value; break; + default: return false; } return true; } @@ -2813,72 +2438,55 @@ uint32_t VoiceAssistantRequest::calculate_size() const { } bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->port = value; - break; - case 2: - this->error = value != 0; - break; - default: - return false; + case 1: this->port = value; break; + case 2: this->error = value != 0; break; + default: return false; } return true; } bool VoiceAssistantEventData::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->name = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->value = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->value = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->event_type = static_cast(value); - break; - default: - return false; + case 1: this->event_type = static_cast(value); break; + default: return false; } return true; } bool VoiceAssistantEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->data.emplace_back(); - value.decode_to_message(this->data.back()); - break; - default: - return false; + case 2: this->data.emplace_back(); value.decode_to_message(this->data.back()); break; + default: return false; } return true; } bool VoiceAssistantAudio::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->end = value != 0; - break; - default: - return false; + case 2: this->end = value != 0; break; + default: return false; } return true; } bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } @@ -2896,64 +2504,50 @@ uint32_t VoiceAssistantAudio::calculate_size() const { } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->event_type = static_cast(value); - break; - case 4: - this->total_seconds = value; - break; - case 5: - this->seconds_left = value; - break; - case 6: - this->is_active = value != 0; - break; - default: - return false; + case 1: this->event_type = static_cast(value); break; + case 4: this->total_seconds = value; break; + case 5: this->seconds_left = value; break; + case 6: this->is_active = value != 0; break; + default: return false; } return true; } bool VoiceAssistantTimerEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->name = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 4: - this->start_conversation = value != 0; - break; - default: - return false; + case 4: this->start_conversation = value != 0; break; + default: return false; } return true; } bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->text = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->text = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } @@ -2989,52 +2583,42 @@ uint32_t VoiceAssistantWakeWord::calculate_size() const { } bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 5: - this->model_size = value; - break; - default: - return false; + case 5: this->model_size = value; break; + default: return false; } return true; } bool VoiceAssistantExternalWakeWord::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: - this->trained_languages.push_back(value.as_string()); - break; + this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 3: this->trained_languages.push_back(value.as_string()); break; case 4: { - this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 6: { - this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 7: { - this->url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->url = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->external_wake_words.emplace_back(); - value.decode_to_message(this->external_wake_words.back()); - break; - default: - return false; + case 1: this->external_wake_words.emplace_back(); value.decode_to_message(this->external_wake_words.back()); break; + default: return false; } return true; } @@ -3066,11 +2650,8 @@ uint32_t VoiceAssistantConfigurationResponse::calculate_size() const { } bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->active_wake_words.push_back(value.as_string()); - break; - default: - return false; + case 1: this->active_wake_words.push_back(value.as_string()); break; + default: return false; } return true; } @@ -3132,37 +2713,28 @@ uint32_t AlarmControlPanelStateResponse::calculate_size() const { } bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->command = static_cast(value); - break; + case 2: this->command = static_cast(value); break; #ifdef USE_DEVICES - case 4: - this->device_id = value; - break; + case 4: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool AlarmControlPanelCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->code = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3229,33 +2801,26 @@ uint32_t TextStateResponse::calculate_size() const { bool TextCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool TextCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3317,32 +2882,20 @@ uint32_t DateStateResponse::calculate_size() const { } bool DateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->year = value; - break; - case 3: - this->month = value; - break; - case 4: - this->day = value; - break; + case 2: this->year = value; break; + case 3: this->month = value; break; + case 4: this->day = value; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3404,32 +2957,20 @@ uint32_t TimeStateResponse::calculate_size() const { } bool TimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->hour = value; - break; - case 3: - this->minute = value; - break; - case 4: - this->second = value; - break; + case 2: this->hour = value; break; + case 3: this->minute = value; break; + case 4: this->second = value; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3555,32 +3096,20 @@ uint32_t ValveStateResponse::calculate_size() const { } bool ValveCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_position = value != 0; - break; - case 4: - this->stop = value != 0; - break; + case 2: this->has_position = value != 0; break; + case 4: this->stop = value != 0; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 3: - this->position = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 3: this->position = value.as_float(); break; + default: return false; } return true; } @@ -3639,25 +3168,17 @@ uint32_t DateTimeStateResponse::calculate_size() const { bool DateTimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 2: - this->epoch_seconds = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 2: this->epoch_seconds = value.as_fixed32(); break; + default: return false; } return true; } @@ -3731,26 +3252,18 @@ uint32_t UpdateStateResponse::calculate_size() const { } bool UpdateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->command = static_cast(value); - break; + case 2: this->command = static_cast(value); break; #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3759,12 +3272,11 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } @@ -3780,23 +3292,19 @@ uint32_t ZWaveProxyFrame::calculate_size() const { } bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->type = static_cast(value); - break; - default: - return false; + case 1: this->type = static_cast(value); break; + default: return false; } return true; } bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } @@ -3853,41 +3361,30 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const { bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 1: - this->device_id = value; - break; + case 1: this->device_id = value; break; #endif - case 3: - this->carrier_frequency = value; - break; - case 4: - this->repeat_count = value; - break; - default: - return false; + case 3: this->carrier_frequency = value; break; + case 4: this->repeat_count = value; break; + default: return false; } return true; } bool InfraredRFTransmitRawTimingsRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 5: { - this->timings_data_ = value.data(); - this->timings_length_ = value.size(); - this->timings_count_ = count_packed_varints(value.data(), value.size()); - break; - } - default: - return false; + this->timings_data_ = value.data(); + this->timings_length_ = value.size(); + this->timings_count_ = count_packed_varints(value.data(), value.size()); + break; + } + default: return false; } return true; } bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 2: - this->key = value.as_fixed32(); - break; - default: - return false; + case 2: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3919,26 +3416,13 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const { #ifdef USE_SERIAL_PROXY bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - case 2: - this->baudrate = value; - break; - case 3: - this->flow_control = value != 0; - break; - case 4: - this->parity = static_cast(value); - break; - case 5: - this->stop_bits = value; - break; - case 6: - this->data_size = value; - break; - default: - return false; + case 1: this->instance = value; break; + case 2: this->baudrate = value; break; + case 3: this->flow_control = value != 0; break; + case 4: this->parity = static_cast(value); break; + case 5: this->stop_bits = value; break; + case 6: this->data_size = value; break; + default: return false; } return true; } @@ -3956,46 +3440,34 @@ uint32_t SerialProxyDataReceived::calculate_size() const { } bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - default: - return false; + case 1: this->instance = value; break; + default: return false; } return true; } bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - case 2: - this->line_states = value; - break; - default: - return false; + case 1: this->instance = value; break; + case 2: this->line_states = value; break; + default: return false; } return true; } bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - default: - return false; + case 1: this->instance = value; break; + default: return false; } return true; } @@ -4013,14 +3485,9 @@ uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { } bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - case 2: - this->type = static_cast(value); - break; - default: - return false; + case 1: this->instance = value; break; + case 2: this->type = static_cast(value); break; + default: return false; } return true; } @@ -4044,23 +3511,12 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { #ifdef USE_BLUETOOTH_PROXY bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->min_interval = value; - break; - case 3: - this->max_interval = value; - break; - case 4: - this->latency = value; - break; - case 5: - this->timeout = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->min_interval = value; break; + case 3: this->max_interval = value; break; + case 4: this->latency = value; break; + case 5: this->timeout = value; break; + default: return false; } return true; } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 4f52b07809..a58474f9fe 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2683,7 +2683,7 @@ def build_message_type( # When set, __attribute__((optimize("O2"))) is added to the definitions # so GCC inlines the small ProtoEncode helpers even under -Os. is_speed_optimized = get_opt(desc, pb.speed_optimized, False) - speed_attr = '__attribute__((optimize("O2"))) ' if is_speed_optimized else "" + speed_attr = '__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)\n' if is_speed_optimized else "" # Only generate encode method if this message needs encoding and has fields if needs_encode and encode and not is_inline_only: From a463e25aa18aeacdf322e2df913bc410e2f81e42 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:23:08 +0000 Subject: [PATCH 0127/2214] [pre-commit.ci lite] apply automatic fixes --- esphome/components/api/api_pb2.cpp | 1498 ++++++++++++++++++--------- script/api_protobuf/api_protobuf.py | 6 +- 2 files changed, 1029 insertions(+), 475 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ee6a8bfd39..481510e407 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1,29 +1,33 @@ // This file was automatically generated with a tool. // See script/api_protobuf/api_protobuf.py - #include "api_pb2.h" - #include "esphome/core/log.h" - #include "esphome/core/helpers.h" - #include +#include "api_pb2.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include namespace esphome::api { - - bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->api_version_major = value; break; - case 3: this->api_version_minor = value; break; - default: return false; + case 2: + this->api_version_major = value; + break; + case 3: + this->api_version_minor = value; + break; + default: + return false; } return true; } bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } @@ -332,22 +336,38 @@ uint32_t CoverStateResponse::calculate_size() const { } bool CoverCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 4: this->has_position = value != 0; break; - case 6: this->has_tilt = value != 0; break; - case 8: this->stop = value != 0; break; + case 4: + this->has_position = value != 0; + break; + case 6: + this->has_tilt = value != 0; + break; + case 8: + this->stop = value != 0; + break; #ifdef USE_DEVICES - case 9: this->device_id = value; break; + case 9: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool CoverCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 5: this->position = value.as_float(); break; - case 7: this->tilt = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 5: + this->position = value.as_float(); + break; + case 7: + this->tilt = value.as_float(); + break; + default: + return false; } return true; } @@ -427,36 +447,61 @@ uint32_t FanStateResponse::calculate_size() const { } bool FanCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_state = value != 0; break; - case 3: this->state = value != 0; break; - case 6: this->has_oscillating = value != 0; break; - case 7: this->oscillating = value != 0; break; - case 8: this->has_direction = value != 0; break; - case 9: this->direction = static_cast(value); break; - case 10: this->has_speed_level = value != 0; break; - case 11: this->speed_level = static_cast(value); break; - case 12: this->has_preset_mode = value != 0; break; + case 2: + this->has_state = value != 0; + break; + case 3: + this->state = value != 0; + break; + case 6: + this->has_oscillating = value != 0; + break; + case 7: + this->oscillating = value != 0; + break; + case 8: + this->has_direction = value != 0; + break; + case 9: + this->direction = static_cast(value); + break; + case 10: + this->has_speed_level = value != 0; + break; + case 11: + this->speed_level = static_cast(value); + break; + case 12: + this->has_preset_mode = value != 0; + break; #ifdef USE_DEVICES - case 14: this->device_id = value; break; + case 14: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool FanCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 13: { - this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool FanCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -552,52 +597,109 @@ uint32_t LightStateResponse::calculate_size() const { } bool LightCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_state = value != 0; break; - case 3: this->state = value != 0; break; - case 4: this->has_brightness = value != 0; break; - case 22: this->has_color_mode = value != 0; break; - case 23: this->color_mode = static_cast(value); break; - case 20: this->has_color_brightness = value != 0; break; - case 6: this->has_rgb = value != 0; break; - case 10: this->has_white = value != 0; break; - case 12: this->has_color_temperature = value != 0; break; - case 24: this->has_cold_white = value != 0; break; - case 26: this->has_warm_white = value != 0; break; - case 14: this->has_transition_length = value != 0; break; - case 15: this->transition_length = value; break; - case 16: this->has_flash_length = value != 0; break; - case 17: this->flash_length = value; break; - case 18: this->has_effect = value != 0; break; + case 2: + this->has_state = value != 0; + break; + case 3: + this->state = value != 0; + break; + case 4: + this->has_brightness = value != 0; + break; + case 22: + this->has_color_mode = value != 0; + break; + case 23: + this->color_mode = static_cast(value); + break; + case 20: + this->has_color_brightness = value != 0; + break; + case 6: + this->has_rgb = value != 0; + break; + case 10: + this->has_white = value != 0; + break; + case 12: + this->has_color_temperature = value != 0; + break; + case 24: + this->has_cold_white = value != 0; + break; + case 26: + this->has_warm_white = value != 0; + break; + case 14: + this->has_transition_length = value != 0; + break; + case 15: + this->transition_length = value; + break; + case 16: + this->has_flash_length = value != 0; + break; + case 17: + this->flash_length = value; + break; + case 18: + this->has_effect = value != 0; + break; #ifdef USE_DEVICES - case 28: this->device_id = value; break; + case 28: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 19: { - this->effect = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->effect = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool LightCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 5: this->brightness = value.as_float(); break; - case 21: this->color_brightness = value.as_float(); break; - case 7: this->red = value.as_float(); break; - case 8: this->green = value.as_float(); break; - case 9: this->blue = value.as_float(); break; - case 11: this->white = value.as_float(); break; - case 13: this->color_temperature = value.as_float(); break; - case 25: this->cold_white = value.as_float(); break; - case 27: this->warm_white = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 5: + this->brightness = value.as_float(); + break; + case 21: + this->color_brightness = value.as_float(); + break; + case 7: + this->red = value.as_float(); + break; + case 8: + this->green = value.as_float(); + break; + case 9: + this->blue = value.as_float(); + break; + case 11: + this->white = value.as_float(); + break; + case 13: + this->color_temperature = value.as_float(); + break; + case 25: + this->cold_white = value.as_float(); + break; + case 27: + this->warm_white = value.as_float(); + break; + default: + return false; } return true; } @@ -644,7 +746,8 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const { return size; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t * +SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); @@ -655,7 +758,8 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint32_t SensorStateResponse::calculate_size() const { +uint32_t +SensorStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->state); @@ -721,18 +825,26 @@ uint32_t SwitchStateResponse::calculate_size() const { } bool SwitchCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->state = value != 0; break; + case 2: + this->state = value != 0; + break; #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -793,9 +905,14 @@ uint32_t TextSensorStateResponse::calculate_size() const { #endif bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->level = static_cast(value); break; - case 2: this->dump_config = value != 0; break; - default: return false; + case 1: + this->level = static_cast(value); + break; + case 2: + this->dump_config = value != 0; + break; + default: + return false; } return true; } @@ -815,11 +932,12 @@ uint32_t SubscribeLogsResponse::calculate_size() const { bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->key = value.data(); - this->key_len = value.size(); - break; - } - default: return false; + this->key = value.data(); + this->key_len = value.size(); + break; + } + default: + return false; } return true; } @@ -905,26 +1023,32 @@ uint32_t HomeassistantActionRequest::calculate_size() const { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES bool HomeassistantActionResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->call_id = value; break; - case 2: this->success = value != 0; break; - default: return false; + case 1: + this->call_id = value; + break; + case 2: + this->success = value != 0; + break; + default: + return false; } return true; } bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON case 4: { - this->response_data = value.data(); - this->response_data_len = value.size(); - break; - } + this->response_data = value.data(); + this->response_data_len = value.size(); + break; + } #endif - default: return false; + default: + return false; } return true; } @@ -947,65 +1071,95 @@ uint32_t SubscribeHomeAssistantStateResponse::calculate_size() const { bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } #endif bool DSTRule::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->time_seconds = decode_zigzag32(static_cast(value)); break; - case 2: this->day = value; break; - case 3: this->type = static_cast(value); break; - case 4: this->month = value; break; - case 5: this->week = value; break; - case 6: this->day_of_week = value; break; - default: return false; + case 1: + this->time_seconds = decode_zigzag32(static_cast(value)); + break; + case 2: + this->day = value; + break; + case 3: + this->type = static_cast(value); + break; + case 4: + this->month = value; + break; + case 5: + this->week = value; + break; + case 6: + this->day_of_week = value; + break; + default: + return false; } return true; } bool ParsedTimezone::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->std_offset_seconds = decode_zigzag32(static_cast(value)); break; - case 2: this->dst_offset_seconds = decode_zigzag32(static_cast(value)); break; - default: return false; + case 1: + this->std_offset_seconds = decode_zigzag32(static_cast(value)); + break; + case 2: + this->dst_offset_seconds = decode_zigzag32(static_cast(value)); + break; + default: + return false; } return true; } bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 3: value.decode_to_message(this->dst_start); break; - case 4: value.decode_to_message(this->dst_end); break; - default: return false; + case 3: + value.decode_to_message(this->dst_start); + break; + case 4: + value.decode_to_message(this->dst_end); + break; + default: + return false; } return true; } bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: value.decode_to_message(this->parsed_timezone); break; - default: return false; + this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 3: + value.decode_to_message(this->parsed_timezone); + break; + default: + return false; } return true; } bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->epoch_seconds = value.as_fixed32(); break; - default: return false; + case 1: + this->epoch_seconds = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -1046,31 +1200,50 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const { } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->bool_ = value != 0; break; - case 2: this->legacy_int = static_cast(value); break; - case 5: this->int_ = decode_zigzag32(static_cast(value)); break; - case 6: this->bool_array.push_back(value != 0); break; - case 7: this->int_array.push_back(decode_zigzag32(static_cast(value))); break; - default: return false; + case 1: + this->bool_ = value != 0; + break; + case 2: + this->legacy_int = static_cast(value); + break; + case 5: + this->int_ = decode_zigzag32(static_cast(value)); + break; + case 6: + this->bool_array.push_back(value != 0); + break; + case 7: + this->int_array.push_back(decode_zigzag32(static_cast(value))); + break; + default: + return false; } return true; } bool ExecuteServiceArgument::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 9: this->string_array.push_back(value.as_string()); break; - default: return false; + this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 9: + this->string_array.push_back(value.as_string()); + break; + default: + return false; } return true; } bool ExecuteServiceArgument::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 3: this->float_ = value.as_float(); break; - case 8: this->float_array.push_back(value.as_float()); break; - default: return false; + case 3: + this->float_ = value.as_float(); + break; + case 8: + this->float_array.push_back(value.as_float()); + break; + default: + return false; } return true; } @@ -1088,26 +1261,38 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { bool ExecuteServiceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 3: this->call_id = value; break; + case 3: + this->call_id = value; + break; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 4: this->return_response = value != 0; break; + case 4: + this->return_response = value != 0; + break; #endif - default: return false; + default: + return false; } return true; } bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: this->args.emplace_back(); value.decode_to_message(this->args.back()); break; - default: return false; + case 2: + this->args.emplace_back(); + value.decode_to_message(this->args.back()); + break; + default: + return false; } return true; } bool ExecuteServiceRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -1192,9 +1377,14 @@ uint32_t CameraImageResponse::calculate_size() const { } bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->single = value != 0; break; - case 2: this->stream = value != 0; break; - default: return false; + case 1: + this->single = value != 0; + break; + case 2: + this->stream = value != 0; + break; + default: + return false; } return true; } @@ -1338,49 +1528,92 @@ uint32_t ClimateStateResponse::calculate_size() const { } bool ClimateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_mode = value != 0; break; - case 3: this->mode = static_cast(value); break; - case 4: this->has_target_temperature = value != 0; break; - case 6: this->has_target_temperature_low = value != 0; break; - case 8: this->has_target_temperature_high = value != 0; break; - case 12: this->has_fan_mode = value != 0; break; - case 13: this->fan_mode = static_cast(value); break; - case 14: this->has_swing_mode = value != 0; break; - case 15: this->swing_mode = static_cast(value); break; - case 16: this->has_custom_fan_mode = value != 0; break; - case 18: this->has_preset = value != 0; break; - case 19: this->preset = static_cast(value); break; - case 20: this->has_custom_preset = value != 0; break; - case 22: this->has_target_humidity = value != 0; break; + case 2: + this->has_mode = value != 0; + break; + case 3: + this->mode = static_cast(value); + break; + case 4: + this->has_target_temperature = value != 0; + break; + case 6: + this->has_target_temperature_low = value != 0; + break; + case 8: + this->has_target_temperature_high = value != 0; + break; + case 12: + this->has_fan_mode = value != 0; + break; + case 13: + this->fan_mode = static_cast(value); + break; + case 14: + this->has_swing_mode = value != 0; + break; + case 15: + this->swing_mode = static_cast(value); + break; + case 16: + this->has_custom_fan_mode = value != 0; + break; + case 18: + this->has_preset = value != 0; + break; + case 19: + this->preset = static_cast(value); + break; + case 20: + this->has_custom_preset = value != 0; + break; + case 22: + this->has_target_humidity = value != 0; + break; #ifdef USE_DEVICES - case 24: this->device_id = value; break; + case 24: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 17: { - this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 21: { - this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 5: this->target_temperature = value.as_float(); break; - case 7: this->target_temperature_low = value.as_float(); break; - case 9: this->target_temperature_high = value.as_float(); break; - case 23: this->target_humidity = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 5: + this->target_temperature = value.as_float(); + break; + case 7: + this->target_temperature_low = value.as_float(); + break; + case 9: + this->target_temperature_high = value.as_float(); + break; + case 23: + this->target_humidity = value.as_float(); + break; + default: + return false; } return true; } @@ -1460,23 +1693,41 @@ uint32_t WaterHeaterStateResponse::calculate_size() const { } bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_fields = value; break; - case 3: this->mode = static_cast(value); break; + case 2: + this->has_fields = value; + break; + case 3: + this->mode = static_cast(value); + break; #ifdef USE_DEVICES - case 5: this->device_id = value; break; + case 5: + this->device_id = value; + break; #endif - case 6: this->state = value; break; - default: return false; + case 6: + this->state = value; + break; + default: + return false; } return true; } bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 4: this->target_temperature = value.as_float(); break; - case 7: this->target_temperature_low = value.as_float(); break; - case 8: this->target_temperature_high = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 4: + this->target_temperature = value.as_float(); + break; + case 7: + this->target_temperature_low = value.as_float(); + break; + case 8: + this->target_temperature_high = value.as_float(); + break; + default: + return false; } return true; } @@ -1547,17 +1798,25 @@ uint32_t NumberStateResponse::calculate_size() const { bool NumberCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 2: this->state = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 2: + this->state = value.as_float(); + break; + default: + return false; } return true; } @@ -1624,26 +1883,33 @@ uint32_t SelectStateResponse::calculate_size() const { bool SelectCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -1711,34 +1977,55 @@ uint32_t SirenStateResponse::calculate_size() const { } bool SirenCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_state = value != 0; break; - case 3: this->state = value != 0; break; - case 4: this->has_tone = value != 0; break; - case 6: this->has_duration = value != 0; break; - case 7: this->duration = value; break; - case 8: this->has_volume = value != 0; break; + case 2: + this->has_state = value != 0; + break; + case 3: + this->state = value != 0; + break; + case 4: + this->has_tone = value != 0; + break; + case 6: + this->has_duration = value != 0; + break; + case 7: + this->duration = value; + break; + case 8: + this->has_volume = value != 0; + break; #ifdef USE_DEVICES - case 10: this->device_id = value; break; + case 10: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool SirenCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 5: { - this->tone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->tone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 9: this->volume = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 9: + this->volume = value.as_float(); + break; + default: + return false; } return true; } @@ -1802,29 +2089,40 @@ uint32_t LockStateResponse::calculate_size() const { } bool LockCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->command = static_cast(value); break; - case 3: this->has_code = value != 0; break; + case 2: + this->command = static_cast(value); + break; + case 3: + this->has_code = value != 0; + break; #ifdef USE_DEVICES - case 5: this->device_id = value; break; + case 5: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool LockCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->code = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -1865,16 +2163,22 @@ uint32_t ListEntitiesButtonResponse::calculate_size() const { bool ButtonCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 2: this->device_id = value; break; + case 2: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -1964,34 +2268,55 @@ uint32_t MediaPlayerStateResponse::calculate_size() const { } bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_command = value != 0; break; - case 3: this->command = static_cast(value); break; - case 4: this->has_volume = value != 0; break; - case 6: this->has_media_url = value != 0; break; - case 8: this->has_announcement = value != 0; break; - case 9: this->announcement = value != 0; break; + case 2: + this->has_command = value != 0; + break; + case 3: + this->command = static_cast(value); + break; + case 4: + this->has_volume = value != 0; + break; + case 6: + this->has_media_url = value != 0; + break; + case 8: + this->has_announcement = value != 0; + break; + case 9: + this->announcement = value != 0; + break; #ifdef USE_DEVICES - case 10: this->device_id = value; break; + case 10: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool MediaPlayerCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 7: { - this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 5: this->volume = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 5: + this->volume = value.as_float(); + break; + default: + return false; } return true; } @@ -1999,36 +2324,41 @@ bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value #ifdef USE_BLUETOOTH_PROXY bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->flags = value; break; - default: return false; + case 1: + this->flags = value; + break; + default: + return false; } return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t * +BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); - uint8_t *len_pos = pos; - ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); - if (sub_msg.address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); - } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); - *len_pos = static_cast(pos - len_pos - 1); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + uint8_t *len_pos = pos; + ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + if (sub_msg.address_type) { + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); + } + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + *len_pos = static_cast(pos - len_pos - 1); } return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { +uint32_t +BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; @@ -2042,11 +2372,20 @@ uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->request_type = static_cast(value); break; - case 3: this->has_address_type = value != 0; break; - case 4: this->address_type = value; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->request_type = static_cast(value); + break; + case 3: + this->has_address_type = value != 0; + break; + case 4: + this->address_type = value; + break; + default: + return false; } return true; } @@ -2068,8 +2407,11 @@ uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { } bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - default: return false; + case 1: + this->address = value; + break; + default: + return false; } return true; } @@ -2181,9 +2523,14 @@ uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { } bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->handle = value; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->handle = value; + break; + default: + return false; } return true; } @@ -2203,57 +2550,83 @@ uint32_t BluetoothGATTReadResponse::calculate_size() const { } bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->handle = value; break; - case 3: this->response = value != 0; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->handle = value; + break; + case 3: + this->response = value != 0; + break; + default: + return false; } return true; } bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->handle = value; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->handle = value; + break; + default: + return false; } return true; } bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->handle = value; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->handle = value; + break; + default: + return false; } return true; } bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->handle = value; break; - case 3: this->enable = value != 0; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->handle = value; + break; + case 3: + this->enable = value != 0; + break; + default: + return false; } return true; } @@ -2389,8 +2762,11 @@ uint32_t BluetoothScannerStateResponse::calculate_size() const { } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->mode = static_cast(value); break; - default: return false; + case 1: + this->mode = static_cast(value); + break; + default: + return false; } return true; } @@ -2398,9 +2774,14 @@ bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_vari #ifdef USE_VOICE_ASSISTANT bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->subscribe = value != 0; break; - case 2: this->flags = value; break; - default: return false; + case 1: + this->subscribe = value != 0; + break; + case 2: + this->flags = value; + break; + default: + return false; } return true; } @@ -2438,55 +2819,72 @@ uint32_t VoiceAssistantRequest::calculate_size() const { } bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->port = value; break; - case 2: this->error = value != 0; break; - default: return false; + case 1: + this->port = value; + break; + case 2: + this->error = value != 0; + break; + default: + return false; } return true; } bool VoiceAssistantEventData::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->name = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->value = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->value = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->event_type = static_cast(value); break; - default: return false; + case 1: + this->event_type = static_cast(value); + break; + default: + return false; } return true; } bool VoiceAssistantEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: this->data.emplace_back(); value.decode_to_message(this->data.back()); break; - default: return false; + case 2: + this->data.emplace_back(); + value.decode_to_message(this->data.back()); + break; + default: + return false; } return true; } bool VoiceAssistantAudio::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->end = value != 0; break; - default: return false; + case 2: + this->end = value != 0; + break; + default: + return false; } return true; } bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } @@ -2504,50 +2902,64 @@ uint32_t VoiceAssistantAudio::calculate_size() const { } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->event_type = static_cast(value); break; - case 4: this->total_seconds = value; break; - case 5: this->seconds_left = value; break; - case 6: this->is_active = value != 0; break; - default: return false; + case 1: + this->event_type = static_cast(value); + break; + case 4: + this->total_seconds = value; + break; + case 5: + this->seconds_left = value; + break; + case 6: + this->is_active = value != 0; + break; + default: + return false; } return true; } bool VoiceAssistantTimerEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->name = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 4: this->start_conversation = value != 0; break; - default: return false; + case 4: + this->start_conversation = value != 0; + break; + default: + return false; } return true; } bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->text = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->text = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } @@ -2583,42 +2995,52 @@ uint32_t VoiceAssistantWakeWord::calculate_size() const { } bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 5: this->model_size = value; break; - default: return false; + case 5: + this->model_size = value; + break; + default: + return false; } return true; } bool VoiceAssistantExternalWakeWord::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: this->trained_languages.push_back(value.as_string()); break; + this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 3: + this->trained_languages.push_back(value.as_string()); + break; case 4: { - this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 6: { - this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 7: { - this->url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->url = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: this->external_wake_words.emplace_back(); value.decode_to_message(this->external_wake_words.back()); break; - default: return false; + case 1: + this->external_wake_words.emplace_back(); + value.decode_to_message(this->external_wake_words.back()); + break; + default: + return false; } return true; } @@ -2650,8 +3072,11 @@ uint32_t VoiceAssistantConfigurationResponse::calculate_size() const { } bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: this->active_wake_words.push_back(value.as_string()); break; - default: return false; + case 1: + this->active_wake_words.push_back(value.as_string()); + break; + default: + return false; } return true; } @@ -2713,28 +3138,37 @@ uint32_t AlarmControlPanelStateResponse::calculate_size() const { } bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->command = static_cast(value); break; + case 2: + this->command = static_cast(value); + break; #ifdef USE_DEVICES - case 4: this->device_id = value; break; + case 4: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool AlarmControlPanelCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->code = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -2801,26 +3235,33 @@ uint32_t TextStateResponse::calculate_size() const { bool TextCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool TextCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -2882,20 +3323,32 @@ uint32_t DateStateResponse::calculate_size() const { } bool DateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->year = value; break; - case 3: this->month = value; break; - case 4: this->day = value; break; + case 2: + this->year = value; + break; + case 3: + this->month = value; + break; + case 4: + this->day = value; + break; #ifdef USE_DEVICES - case 5: this->device_id = value; break; + case 5: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -2957,20 +3410,32 @@ uint32_t TimeStateResponse::calculate_size() const { } bool TimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->hour = value; break; - case 3: this->minute = value; break; - case 4: this->second = value; break; + case 2: + this->hour = value; + break; + case 3: + this->minute = value; + break; + case 4: + this->second = value; + break; #ifdef USE_DEVICES - case 5: this->device_id = value; break; + case 5: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -3096,20 +3561,32 @@ uint32_t ValveStateResponse::calculate_size() const { } bool ValveCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_position = value != 0; break; - case 4: this->stop = value != 0; break; + case 2: + this->has_position = value != 0; + break; + case 4: + this->stop = value != 0; + break; #ifdef USE_DEVICES - case 5: this->device_id = value; break; + case 5: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 3: this->position = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 3: + this->position = value.as_float(); + break; + default: + return false; } return true; } @@ -3168,17 +3645,25 @@ uint32_t DateTimeStateResponse::calculate_size() const { bool DateTimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 2: this->epoch_seconds = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 2: + this->epoch_seconds = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -3252,18 +3737,26 @@ uint32_t UpdateStateResponse::calculate_size() const { } bool UpdateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->command = static_cast(value); break; + case 2: + this->command = static_cast(value); + break; #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -3272,11 +3765,12 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } @@ -3292,19 +3786,23 @@ uint32_t ZWaveProxyFrame::calculate_size() const { } bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->type = static_cast(value); break; - default: return false; + case 1: + this->type = static_cast(value); + break; + default: + return false; } return true; } bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } @@ -3361,30 +3859,41 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const { bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 1: this->device_id = value; break; + case 1: + this->device_id = value; + break; #endif - case 3: this->carrier_frequency = value; break; - case 4: this->repeat_count = value; break; - default: return false; + case 3: + this->carrier_frequency = value; + break; + case 4: + this->repeat_count = value; + break; + default: + return false; } return true; } bool InfraredRFTransmitRawTimingsRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 5: { - this->timings_data_ = value.data(); - this->timings_length_ = value.size(); - this->timings_count_ = count_packed_varints(value.data(), value.size()); - break; - } - default: return false; + this->timings_data_ = value.data(); + this->timings_length_ = value.size(); + this->timings_count_ = count_packed_varints(value.data(), value.size()); + break; + } + default: + return false; } return true; } bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 2: this->key = value.as_fixed32(); break; - default: return false; + case 2: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -3416,13 +3925,26 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const { #ifdef USE_SERIAL_PROXY bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->instance = value; break; - case 2: this->baudrate = value; break; - case 3: this->flow_control = value != 0; break; - case 4: this->parity = static_cast(value); break; - case 5: this->stop_bits = value; break; - case 6: this->data_size = value; break; - default: return false; + case 1: + this->instance = value; + break; + case 2: + this->baudrate = value; + break; + case 3: + this->flow_control = value != 0; + break; + case 4: + this->parity = static_cast(value); + break; + case 5: + this->stop_bits = value; + break; + case 6: + this->data_size = value; + break; + default: + return false; } return true; } @@ -3440,34 +3962,46 @@ uint32_t SerialProxyDataReceived::calculate_size() const { } bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->instance = value; break; - default: return false; + case 1: + this->instance = value; + break; + default: + return false; } return true; } bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->instance = value; break; - case 2: this->line_states = value; break; - default: return false; + case 1: + this->instance = value; + break; + case 2: + this->line_states = value; + break; + default: + return false; } return true; } bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->instance = value; break; - default: return false; + case 1: + this->instance = value; + break; + default: + return false; } return true; } @@ -3485,9 +4019,14 @@ uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { } bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->instance = value; break; - case 2: this->type = static_cast(value); break; - default: return false; + case 1: + this->instance = value; + break; + case 2: + this->type = static_cast(value); + break; + default: + return false; } return true; } @@ -3511,12 +4050,23 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { #ifdef USE_BLUETOOTH_PROXY bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->min_interval = value; break; - case 3: this->max_interval = value; break; - case 4: this->latency = value; break; - case 5: this->timeout = value; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->min_interval = value; + break; + case 3: + this->max_interval = value; + break; + case 4: + this->latency = value; + break; + case 5: + this->timeout = value; + break; + default: + return false; } return true; } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index a58474f9fe..1743d2bb34 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2683,7 +2683,11 @@ def build_message_type( # When set, __attribute__((optimize("O2"))) is added to the definitions # so GCC inlines the small ProtoEncode helpers even under -Os. is_speed_optimized = get_opt(desc, pb.speed_optimized, False) - speed_attr = '__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)\n' if is_speed_optimized else "" + speed_attr = ( + '__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)\n' + if is_speed_optimized + else "" + ) # Only generate encode method if this message needs encoding and has fields if needs_encode and encode and not is_inline_only: From 1ccf72dd41d6faf07a51337a644266a54b2e80e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 21:13:33 -1000 Subject: [PATCH 0128/2214] [api] Add speed_optimized to SubscribeLogsResponse Mark SubscribeLogsResponse with (speed_optimized) = true so its encode() and calculate_size() are compiled with -O2. Log messages are among the most frequent messages sent over the API, especially during debug sessions. --- esphome/components/api/api.proto | 5 +- esphome/components/api/api_pb2.cpp | 1508 +++++++++------------------- 2 files changed, 484 insertions(+), 1029 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4a9a6f9051..f906cfb8d7 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -778,9 +778,10 @@ message SubscribeLogsResponse { option (source) = SOURCE_SERVER; option (log) = false; option (no_delay) = false; + option (speed_optimized) = true; - LogLevel level = 1; - bytes message = 3; + LogLevel level = 1 [(force) = true]; + bytes message = 3 [(force) = true]; } // ==================== NOISE ENCRYPTION ==================== diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 481510e407..5e6dab8877 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1,33 +1,29 @@ // This file was automatically generated with a tool. // See script/api_protobuf/api_protobuf.py -#include "api_pb2.h" -#include "esphome/core/log.h" -#include "esphome/core/helpers.h" -#include + #include "api_pb2.h" + #include "esphome/core/log.h" + #include "esphome/core/helpers.h" + #include namespace esphome::api { + + bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->api_version_major = value; - break; - case 3: - this->api_version_minor = value; - break; - default: - return false; + case 2: this->api_version_major = value; break; + case 3: this->api_version_minor = value; break; + default: return false; } return true; } bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } @@ -336,38 +332,22 @@ uint32_t CoverStateResponse::calculate_size() const { } bool CoverCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 4: - this->has_position = value != 0; - break; - case 6: - this->has_tilt = value != 0; - break; - case 8: - this->stop = value != 0; - break; + case 4: this->has_position = value != 0; break; + case 6: this->has_tilt = value != 0; break; + case 8: this->stop = value != 0; break; #ifdef USE_DEVICES - case 9: - this->device_id = value; - break; + case 9: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool CoverCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->position = value.as_float(); - break; - case 7: - this->tilt = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->position = value.as_float(); break; + case 7: this->tilt = value.as_float(); break; + default: return false; } return true; } @@ -447,61 +427,36 @@ uint32_t FanStateResponse::calculate_size() const { } bool FanCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 6: - this->has_oscillating = value != 0; - break; - case 7: - this->oscillating = value != 0; - break; - case 8: - this->has_direction = value != 0; - break; - case 9: - this->direction = static_cast(value); - break; - case 10: - this->has_speed_level = value != 0; - break; - case 11: - this->speed_level = static_cast(value); - break; - case 12: - this->has_preset_mode = value != 0; - break; + case 2: this->has_state = value != 0; break; + case 3: this->state = value != 0; break; + case 6: this->has_oscillating = value != 0; break; + case 7: this->oscillating = value != 0; break; + case 8: this->has_direction = value != 0; break; + case 9: this->direction = static_cast(value); break; + case 10: this->has_speed_level = value != 0; break; + case 11: this->speed_level = static_cast(value); break; + case 12: this->has_preset_mode = value != 0; break; #ifdef USE_DEVICES - case 14: - this->device_id = value; - break; + case 14: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool FanCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 13: { - this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool FanCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -597,109 +552,52 @@ uint32_t LightStateResponse::calculate_size() const { } bool LightCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 4: - this->has_brightness = value != 0; - break; - case 22: - this->has_color_mode = value != 0; - break; - case 23: - this->color_mode = static_cast(value); - break; - case 20: - this->has_color_brightness = value != 0; - break; - case 6: - this->has_rgb = value != 0; - break; - case 10: - this->has_white = value != 0; - break; - case 12: - this->has_color_temperature = value != 0; - break; - case 24: - this->has_cold_white = value != 0; - break; - case 26: - this->has_warm_white = value != 0; - break; - case 14: - this->has_transition_length = value != 0; - break; - case 15: - this->transition_length = value; - break; - case 16: - this->has_flash_length = value != 0; - break; - case 17: - this->flash_length = value; - break; - case 18: - this->has_effect = value != 0; - break; + case 2: this->has_state = value != 0; break; + case 3: this->state = value != 0; break; + case 4: this->has_brightness = value != 0; break; + case 22: this->has_color_mode = value != 0; break; + case 23: this->color_mode = static_cast(value); break; + case 20: this->has_color_brightness = value != 0; break; + case 6: this->has_rgb = value != 0; break; + case 10: this->has_white = value != 0; break; + case 12: this->has_color_temperature = value != 0; break; + case 24: this->has_cold_white = value != 0; break; + case 26: this->has_warm_white = value != 0; break; + case 14: this->has_transition_length = value != 0; break; + case 15: this->transition_length = value; break; + case 16: this->has_flash_length = value != 0; break; + case 17: this->flash_length = value; break; + case 18: this->has_effect = value != 0; break; #ifdef USE_DEVICES - case 28: - this->device_id = value; - break; + case 28: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 19: { - this->effect = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->effect = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool LightCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->brightness = value.as_float(); - break; - case 21: - this->color_brightness = value.as_float(); - break; - case 7: - this->red = value.as_float(); - break; - case 8: - this->green = value.as_float(); - break; - case 9: - this->blue = value.as_float(); - break; - case 11: - this->white = value.as_float(); - break; - case 13: - this->color_temperature = value.as_float(); - break; - case 25: - this->cold_white = value.as_float(); - break; - case 27: - this->warm_white = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->brightness = value.as_float(); break; + case 21: this->color_brightness = value.as_float(); break; + case 7: this->red = value.as_float(); break; + case 8: this->green = value.as_float(); break; + case 9: this->blue = value.as_float(); break; + case 11: this->white = value.as_float(); break; + case 13: this->color_temperature = value.as_float(); break; + case 25: this->cold_white = value.as_float(); break; + case 27: this->warm_white = value.as_float(); break; + default: return false; } return true; } @@ -746,8 +644,7 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const { return size; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint8_t * -SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); @@ -758,8 +655,7 @@ SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) c return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint32_t -SensorStateResponse::calculate_size() const { +uint32_t SensorStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->state); @@ -825,26 +721,18 @@ uint32_t SwitchStateResponse::calculate_size() const { } bool SwitchCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->state = value != 0; - break; + case 2: this->state = value != 0; break; #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -905,26 +793,25 @@ uint32_t TextSensorStateResponse::calculate_size() const { #endif bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->level = static_cast(value); - break; - case 2: - this->dump_config = value != 0; - break; - default: - return false; + case 1: this->level = static_cast(value); break; + case 2: this->dump_config = value != 0; break; + default: return false; } return true; } +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t *SubscribeLogsResponse::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->level)); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level), true); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_); return pos; } +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t SubscribeLogsResponse::calculate_size() const { uint32_t size = 0; - size += this->level ? 2 : 0; + size += 2; size += ProtoSize::calc_length(1, this->message_len_); return size; } @@ -932,12 +819,11 @@ uint32_t SubscribeLogsResponse::calculate_size() const { bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->key = value.data(); - this->key_len = value.size(); - break; - } - default: - return false; + this->key = value.data(); + this->key_len = value.size(); + break; + } + default: return false; } return true; } @@ -1023,32 +909,26 @@ uint32_t HomeassistantActionRequest::calculate_size() const { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES bool HomeassistantActionResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->call_id = value; - break; - case 2: - this->success = value != 0; - break; - default: - return false; + case 1: this->call_id = value; break; + case 2: this->success = value != 0; break; + default: return false; } return true; } bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON case 4: { - this->response_data = value.data(); - this->response_data_len = value.size(); - break; - } + this->response_data = value.data(); + this->response_data_len = value.size(); + break; + } #endif - default: - return false; + default: return false; } return true; } @@ -1071,95 +951,65 @@ uint32_t SubscribeHomeAssistantStateResponse::calculate_size() const { bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } #endif bool DSTRule::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->time_seconds = decode_zigzag32(static_cast(value)); - break; - case 2: - this->day = value; - break; - case 3: - this->type = static_cast(value); - break; - case 4: - this->month = value; - break; - case 5: - this->week = value; - break; - case 6: - this->day_of_week = value; - break; - default: - return false; + case 1: this->time_seconds = decode_zigzag32(static_cast(value)); break; + case 2: this->day = value; break; + case 3: this->type = static_cast(value); break; + case 4: this->month = value; break; + case 5: this->week = value; break; + case 6: this->day_of_week = value; break; + default: return false; } return true; } bool ParsedTimezone::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->std_offset_seconds = decode_zigzag32(static_cast(value)); - break; - case 2: - this->dst_offset_seconds = decode_zigzag32(static_cast(value)); - break; - default: - return false; + case 1: this->std_offset_seconds = decode_zigzag32(static_cast(value)); break; + case 2: this->dst_offset_seconds = decode_zigzag32(static_cast(value)); break; + default: return false; } return true; } bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 3: - value.decode_to_message(this->dst_start); - break; - case 4: - value.decode_to_message(this->dst_end); - break; - default: - return false; + case 3: value.decode_to_message(this->dst_start); break; + case 4: value.decode_to_message(this->dst_end); break; + default: return false; } return true; } bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: - value.decode_to_message(this->parsed_timezone); - break; - default: - return false; + this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 3: value.decode_to_message(this->parsed_timezone); break; + default: return false; } return true; } bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->epoch_seconds = value.as_fixed32(); - break; - default: - return false; + case 1: this->epoch_seconds = value.as_fixed32(); break; + default: return false; } return true; } @@ -1200,50 +1050,31 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const { } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->bool_ = value != 0; - break; - case 2: - this->legacy_int = static_cast(value); - break; - case 5: - this->int_ = decode_zigzag32(static_cast(value)); - break; - case 6: - this->bool_array.push_back(value != 0); - break; - case 7: - this->int_array.push_back(decode_zigzag32(static_cast(value))); - break; - default: - return false; + case 1: this->bool_ = value != 0; break; + case 2: this->legacy_int = static_cast(value); break; + case 5: this->int_ = decode_zigzag32(static_cast(value)); break; + case 6: this->bool_array.push_back(value != 0); break; + case 7: this->int_array.push_back(decode_zigzag32(static_cast(value))); break; + default: return false; } return true; } bool ExecuteServiceArgument::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 9: - this->string_array.push_back(value.as_string()); - break; - default: - return false; + this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 9: this->string_array.push_back(value.as_string()); break; + default: return false; } return true; } bool ExecuteServiceArgument::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 3: - this->float_ = value.as_float(); - break; - case 8: - this->float_array.push_back(value.as_float()); - break; - default: - return false; + case 3: this->float_ = value.as_float(); break; + case 8: this->float_array.push_back(value.as_float()); break; + default: return false; } return true; } @@ -1261,38 +1092,26 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { bool ExecuteServiceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 3: - this->call_id = value; - break; + case 3: this->call_id = value; break; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 4: - this->return_response = value != 0; - break; + case 4: this->return_response = value != 0; break; #endif - default: - return false; + default: return false; } return true; } bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->args.emplace_back(); - value.decode_to_message(this->args.back()); - break; - default: - return false; + case 2: this->args.emplace_back(); value.decode_to_message(this->args.back()); break; + default: return false; } return true; } bool ExecuteServiceRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -1377,14 +1196,9 @@ uint32_t CameraImageResponse::calculate_size() const { } bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->single = value != 0; - break; - case 2: - this->stream = value != 0; - break; - default: - return false; + case 1: this->single = value != 0; break; + case 2: this->stream = value != 0; break; + default: return false; } return true; } @@ -1528,92 +1342,49 @@ uint32_t ClimateStateResponse::calculate_size() const { } bool ClimateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_mode = value != 0; - break; - case 3: - this->mode = static_cast(value); - break; - case 4: - this->has_target_temperature = value != 0; - break; - case 6: - this->has_target_temperature_low = value != 0; - break; - case 8: - this->has_target_temperature_high = value != 0; - break; - case 12: - this->has_fan_mode = value != 0; - break; - case 13: - this->fan_mode = static_cast(value); - break; - case 14: - this->has_swing_mode = value != 0; - break; - case 15: - this->swing_mode = static_cast(value); - break; - case 16: - this->has_custom_fan_mode = value != 0; - break; - case 18: - this->has_preset = value != 0; - break; - case 19: - this->preset = static_cast(value); - break; - case 20: - this->has_custom_preset = value != 0; - break; - case 22: - this->has_target_humidity = value != 0; - break; + case 2: this->has_mode = value != 0; break; + case 3: this->mode = static_cast(value); break; + case 4: this->has_target_temperature = value != 0; break; + case 6: this->has_target_temperature_low = value != 0; break; + case 8: this->has_target_temperature_high = value != 0; break; + case 12: this->has_fan_mode = value != 0; break; + case 13: this->fan_mode = static_cast(value); break; + case 14: this->has_swing_mode = value != 0; break; + case 15: this->swing_mode = static_cast(value); break; + case 16: this->has_custom_fan_mode = value != 0; break; + case 18: this->has_preset = value != 0; break; + case 19: this->preset = static_cast(value); break; + case 20: this->has_custom_preset = value != 0; break; + case 22: this->has_target_humidity = value != 0; break; #ifdef USE_DEVICES - case 24: - this->device_id = value; - break; + case 24: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 17: { - this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 21: { - this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->target_temperature = value.as_float(); - break; - case 7: - this->target_temperature_low = value.as_float(); - break; - case 9: - this->target_temperature_high = value.as_float(); - break; - case 23: - this->target_humidity = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->target_temperature = value.as_float(); break; + case 7: this->target_temperature_low = value.as_float(); break; + case 9: this->target_temperature_high = value.as_float(); break; + case 23: this->target_humidity = value.as_float(); break; + default: return false; } return true; } @@ -1693,41 +1464,23 @@ uint32_t WaterHeaterStateResponse::calculate_size() const { } bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_fields = value; - break; - case 3: - this->mode = static_cast(value); - break; + case 2: this->has_fields = value; break; + case 3: this->mode = static_cast(value); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - case 6: - this->state = value; - break; - default: - return false; + case 6: this->state = value; break; + default: return false; } return true; } bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 4: - this->target_temperature = value.as_float(); - break; - case 7: - this->target_temperature_low = value.as_float(); - break; - case 8: - this->target_temperature_high = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 4: this->target_temperature = value.as_float(); break; + case 7: this->target_temperature_low = value.as_float(); break; + case 8: this->target_temperature_high = value.as_float(); break; + default: return false; } return true; } @@ -1798,25 +1551,17 @@ uint32_t NumberStateResponse::calculate_size() const { bool NumberCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 2: - this->state = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 2: this->state = value.as_float(); break; + default: return false; } return true; } @@ -1883,33 +1628,26 @@ uint32_t SelectStateResponse::calculate_size() const { bool SelectCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -1977,55 +1715,34 @@ uint32_t SirenStateResponse::calculate_size() const { } bool SirenCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 4: - this->has_tone = value != 0; - break; - case 6: - this->has_duration = value != 0; - break; - case 7: - this->duration = value; - break; - case 8: - this->has_volume = value != 0; - break; + case 2: this->has_state = value != 0; break; + case 3: this->state = value != 0; break; + case 4: this->has_tone = value != 0; break; + case 6: this->has_duration = value != 0; break; + case 7: this->duration = value; break; + case 8: this->has_volume = value != 0; break; #ifdef USE_DEVICES - case 10: - this->device_id = value; - break; + case 10: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool SirenCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 5: { - this->tone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->tone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 9: - this->volume = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 9: this->volume = value.as_float(); break; + default: return false; } return true; } @@ -2089,40 +1806,29 @@ uint32_t LockStateResponse::calculate_size() const { } bool LockCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->command = static_cast(value); - break; - case 3: - this->has_code = value != 0; - break; + case 2: this->command = static_cast(value); break; + case 3: this->has_code = value != 0; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool LockCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->code = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -2163,22 +1869,16 @@ uint32_t ListEntitiesButtonResponse::calculate_size() const { bool ButtonCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 2: - this->device_id = value; - break; + case 2: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -2268,55 +1968,34 @@ uint32_t MediaPlayerStateResponse::calculate_size() const { } bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_command = value != 0; - break; - case 3: - this->command = static_cast(value); - break; - case 4: - this->has_volume = value != 0; - break; - case 6: - this->has_media_url = value != 0; - break; - case 8: - this->has_announcement = value != 0; - break; - case 9: - this->announcement = value != 0; - break; + case 2: this->has_command = value != 0; break; + case 3: this->command = static_cast(value); break; + case 4: this->has_volume = value != 0; break; + case 6: this->has_media_url = value != 0; break; + case 8: this->has_announcement = value != 0; break; + case 9: this->announcement = value != 0; break; #ifdef USE_DEVICES - case 10: - this->device_id = value; - break; + case 10: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool MediaPlayerCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 7: { - this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->volume = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 5: this->volume = value.as_float(); break; + default: return false; } return true; } @@ -2324,41 +2003,36 @@ bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value #ifdef USE_BLUETOOTH_PROXY bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->flags = value; - break; - default: - return false; + case 1: this->flags = value; break; + default: return false; } return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint8_t * -BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); - uint8_t *len_pos = pos; - ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); - if (sub_msg.address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); - } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); - *len_pos = static_cast(pos - len_pos - 1); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + uint8_t *len_pos = pos; + ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + if (sub_msg.address_type) { + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); + } + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + *len_pos = static_cast(pos - len_pos - 1); } return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint32_t -BluetoothLERawAdvertisementsResponse::calculate_size() const { +uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; @@ -2372,20 +2046,11 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->request_type = static_cast(value); - break; - case 3: - this->has_address_type = value != 0; - break; - case 4: - this->address_type = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->request_type = static_cast(value); break; + case 3: this->has_address_type = value != 0; break; + case 4: this->address_type = value; break; + default: return false; } return true; } @@ -2407,11 +2072,8 @@ uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { } bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - default: - return false; + case 1: this->address = value; break; + default: return false; } return true; } @@ -2523,14 +2185,9 @@ uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { } bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + default: return false; } return true; } @@ -2550,83 +2207,57 @@ uint32_t BluetoothGATTReadResponse::calculate_size() const { } bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - case 3: - this->response = value != 0; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + case 3: this->response = value != 0; break; + default: return false; } return true; } bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + default: return false; } return true; } bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + default: return false; } return true; } bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - case 3: - this->enable = value != 0; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->handle = value; break; + case 3: this->enable = value != 0; break; + default: return false; } return true; } @@ -2762,11 +2393,8 @@ uint32_t BluetoothScannerStateResponse::calculate_size() const { } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->mode = static_cast(value); - break; - default: - return false; + case 1: this->mode = static_cast(value); break; + default: return false; } return true; } @@ -2774,14 +2402,9 @@ bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_vari #ifdef USE_VOICE_ASSISTANT bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->subscribe = value != 0; - break; - case 2: - this->flags = value; - break; - default: - return false; + case 1: this->subscribe = value != 0; break; + case 2: this->flags = value; break; + default: return false; } return true; } @@ -2819,72 +2442,55 @@ uint32_t VoiceAssistantRequest::calculate_size() const { } bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->port = value; - break; - case 2: - this->error = value != 0; - break; - default: - return false; + case 1: this->port = value; break; + case 2: this->error = value != 0; break; + default: return false; } return true; } bool VoiceAssistantEventData::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->name = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->value = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->value = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->event_type = static_cast(value); - break; - default: - return false; + case 1: this->event_type = static_cast(value); break; + default: return false; } return true; } bool VoiceAssistantEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->data.emplace_back(); - value.decode_to_message(this->data.back()); - break; - default: - return false; + case 2: this->data.emplace_back(); value.decode_to_message(this->data.back()); break; + default: return false; } return true; } bool VoiceAssistantAudio::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->end = value != 0; - break; - default: - return false; + case 2: this->end = value != 0; break; + default: return false; } return true; } bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } @@ -2902,64 +2508,50 @@ uint32_t VoiceAssistantAudio::calculate_size() const { } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->event_type = static_cast(value); - break; - case 4: - this->total_seconds = value; - break; - case 5: - this->seconds_left = value; - break; - case 6: - this->is_active = value != 0; - break; - default: - return false; + case 1: this->event_type = static_cast(value); break; + case 4: this->total_seconds = value; break; + case 5: this->seconds_left = value; break; + case 6: this->is_active = value != 0; break; + default: return false; } return true; } bool VoiceAssistantTimerEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->name = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 4: - this->start_conversation = value != 0; - break; - default: - return false; + case 4: this->start_conversation = value != 0; break; + default: return false; } return true; } bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->text = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->text = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } @@ -2995,52 +2587,42 @@ uint32_t VoiceAssistantWakeWord::calculate_size() const { } bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 5: - this->model_size = value; - break; - default: - return false; + case 5: this->model_size = value; break; + default: return false; } return true; } bool VoiceAssistantExternalWakeWord::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: - this->trained_languages.push_back(value.as_string()); - break; + this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 3: this->trained_languages.push_back(value.as_string()); break; case 4: { - this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 6: { - this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 7: { - this->url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->url = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->external_wake_words.emplace_back(); - value.decode_to_message(this->external_wake_words.back()); - break; - default: - return false; + case 1: this->external_wake_words.emplace_back(); value.decode_to_message(this->external_wake_words.back()); break; + default: return false; } return true; } @@ -3072,11 +2654,8 @@ uint32_t VoiceAssistantConfigurationResponse::calculate_size() const { } bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->active_wake_words.push_back(value.as_string()); - break; - default: - return false; + case 1: this->active_wake_words.push_back(value.as_string()); break; + default: return false; } return true; } @@ -3138,37 +2717,28 @@ uint32_t AlarmControlPanelStateResponse::calculate_size() const { } bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->command = static_cast(value); - break; + case 2: this->command = static_cast(value); break; #ifdef USE_DEVICES - case 4: - this->device_id = value; - break; + case 4: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool AlarmControlPanelCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->code = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3235,33 +2805,26 @@ uint32_t TextStateResponse::calculate_size() const { bool TextCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool TextCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: return false; } return true; } bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3323,32 +2886,20 @@ uint32_t DateStateResponse::calculate_size() const { } bool DateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->year = value; - break; - case 3: - this->month = value; - break; - case 4: - this->day = value; - break; + case 2: this->year = value; break; + case 3: this->month = value; break; + case 4: this->day = value; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3410,32 +2961,20 @@ uint32_t TimeStateResponse::calculate_size() const { } bool TimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->hour = value; - break; - case 3: - this->minute = value; - break; - case 4: - this->second = value; - break; + case 2: this->hour = value; break; + case 3: this->minute = value; break; + case 4: this->second = value; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3561,32 +3100,20 @@ uint32_t ValveStateResponse::calculate_size() const { } bool ValveCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->has_position = value != 0; - break; - case 4: - this->stop = value != 0; - break; + case 2: this->has_position = value != 0; break; + case 4: this->stop = value != 0; break; #ifdef USE_DEVICES - case 5: - this->device_id = value; - break; + case 5: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 3: - this->position = value.as_float(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 3: this->position = value.as_float(); break; + default: return false; } return true; } @@ -3645,25 +3172,17 @@ uint32_t DateTimeStateResponse::calculate_size() const { bool DateTimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 2: - this->epoch_seconds = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + case 2: this->epoch_seconds = value.as_fixed32(); break; + default: return false; } return true; } @@ -3737,26 +3256,18 @@ uint32_t UpdateStateResponse::calculate_size() const { } bool UpdateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: - this->command = static_cast(value); - break; + case 2: this->command = static_cast(value); break; #ifdef USE_DEVICES - case 3: - this->device_id = value; - break; + case 3: this->device_id = value; break; #endif - default: - return false; + default: return false; } return true; } bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; + case 1: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3765,12 +3276,11 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } @@ -3786,23 +3296,19 @@ uint32_t ZWaveProxyFrame::calculate_size() const { } bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->type = static_cast(value); - break; - default: - return false; + case 1: this->type = static_cast(value); break; + default: return false; } return true; } bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } @@ -3859,41 +3365,30 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const { bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 1: - this->device_id = value; - break; + case 1: this->device_id = value; break; #endif - case 3: - this->carrier_frequency = value; - break; - case 4: - this->repeat_count = value; - break; - default: - return false; + case 3: this->carrier_frequency = value; break; + case 4: this->repeat_count = value; break; + default: return false; } return true; } bool InfraredRFTransmitRawTimingsRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 5: { - this->timings_data_ = value.data(); - this->timings_length_ = value.size(); - this->timings_count_ = count_packed_varints(value.data(), value.size()); - break; - } - default: - return false; + this->timings_data_ = value.data(); + this->timings_length_ = value.size(); + this->timings_count_ = count_packed_varints(value.data(), value.size()); + break; + } + default: return false; } return true; } bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 2: - this->key = value.as_fixed32(); - break; - default: - return false; + case 2: this->key = value.as_fixed32(); break; + default: return false; } return true; } @@ -3925,26 +3420,13 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const { #ifdef USE_SERIAL_PROXY bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - case 2: - this->baudrate = value; - break; - case 3: - this->flow_control = value != 0; - break; - case 4: - this->parity = static_cast(value); - break; - case 5: - this->stop_bits = value; - break; - case 6: - this->data_size = value; - break; - default: - return false; + case 1: this->instance = value; break; + case 2: this->baudrate = value; break; + case 3: this->flow_control = value != 0; break; + case 4: this->parity = static_cast(value); break; + case 5: this->stop_bits = value; break; + case 6: this->data_size = value; break; + default: return false; } return true; } @@ -3962,46 +3444,34 @@ uint32_t SerialProxyDataReceived::calculate_size() const { } bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - default: - return false; + case 1: this->instance = value; break; + default: return false; } return true; } bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: return false; } return true; } bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - case 2: - this->line_states = value; - break; - default: - return false; + case 1: this->instance = value; break; + case 2: this->line_states = value; break; + default: return false; } return true; } bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - default: - return false; + case 1: this->instance = value; break; + default: return false; } return true; } @@ -4019,14 +3489,9 @@ uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { } bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->instance = value; - break; - case 2: - this->type = static_cast(value); - break; - default: - return false; + case 1: this->instance = value; break; + case 2: this->type = static_cast(value); break; + default: return false; } return true; } @@ -4050,23 +3515,12 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { #ifdef USE_BLUETOOTH_PROXY bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->min_interval = value; - break; - case 3: - this->max_interval = value; - break; - case 4: - this->latency = value; - break; - case 5: - this->timeout = value; - break; - default: - return false; + case 1: this->address = value; break; + case 2: this->min_interval = value; break; + case 3: this->max_interval = value; break; + case 4: this->latency = value; break; + case 5: this->timeout = value; break; + default: return false; } return true; } From 2a0e39901ea407c9f097aa450fef2bfa95adde70 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:22:54 +0000 Subject: [PATCH 0129/2214] [pre-commit.ci lite] apply automatic fixes --- esphome/components/api/api_pb2.cpp | 1504 +++++++++++++++++++--------- 1 file changed, 1028 insertions(+), 476 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 5e6dab8877..9943c06cb1 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1,29 +1,33 @@ // This file was automatically generated with a tool. // See script/api_protobuf/api_protobuf.py - #include "api_pb2.h" - #include "esphome/core/log.h" - #include "esphome/core/helpers.h" - #include +#include "api_pb2.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include namespace esphome::api { - - bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->api_version_major = value; break; - case 3: this->api_version_minor = value; break; - default: return false; + case 2: + this->api_version_major = value; + break; + case 3: + this->api_version_minor = value; + break; + default: + return false; } return true; } bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } @@ -332,22 +336,38 @@ uint32_t CoverStateResponse::calculate_size() const { } bool CoverCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 4: this->has_position = value != 0; break; - case 6: this->has_tilt = value != 0; break; - case 8: this->stop = value != 0; break; + case 4: + this->has_position = value != 0; + break; + case 6: + this->has_tilt = value != 0; + break; + case 8: + this->stop = value != 0; + break; #ifdef USE_DEVICES - case 9: this->device_id = value; break; + case 9: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool CoverCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 5: this->position = value.as_float(); break; - case 7: this->tilt = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 5: + this->position = value.as_float(); + break; + case 7: + this->tilt = value.as_float(); + break; + default: + return false; } return true; } @@ -427,36 +447,61 @@ uint32_t FanStateResponse::calculate_size() const { } bool FanCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_state = value != 0; break; - case 3: this->state = value != 0; break; - case 6: this->has_oscillating = value != 0; break; - case 7: this->oscillating = value != 0; break; - case 8: this->has_direction = value != 0; break; - case 9: this->direction = static_cast(value); break; - case 10: this->has_speed_level = value != 0; break; - case 11: this->speed_level = static_cast(value); break; - case 12: this->has_preset_mode = value != 0; break; + case 2: + this->has_state = value != 0; + break; + case 3: + this->state = value != 0; + break; + case 6: + this->has_oscillating = value != 0; + break; + case 7: + this->oscillating = value != 0; + break; + case 8: + this->has_direction = value != 0; + break; + case 9: + this->direction = static_cast(value); + break; + case 10: + this->has_speed_level = value != 0; + break; + case 11: + this->speed_level = static_cast(value); + break; + case 12: + this->has_preset_mode = value != 0; + break; #ifdef USE_DEVICES - case 14: this->device_id = value; break; + case 14: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool FanCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 13: { - this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool FanCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -552,52 +597,109 @@ uint32_t LightStateResponse::calculate_size() const { } bool LightCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_state = value != 0; break; - case 3: this->state = value != 0; break; - case 4: this->has_brightness = value != 0; break; - case 22: this->has_color_mode = value != 0; break; - case 23: this->color_mode = static_cast(value); break; - case 20: this->has_color_brightness = value != 0; break; - case 6: this->has_rgb = value != 0; break; - case 10: this->has_white = value != 0; break; - case 12: this->has_color_temperature = value != 0; break; - case 24: this->has_cold_white = value != 0; break; - case 26: this->has_warm_white = value != 0; break; - case 14: this->has_transition_length = value != 0; break; - case 15: this->transition_length = value; break; - case 16: this->has_flash_length = value != 0; break; - case 17: this->flash_length = value; break; - case 18: this->has_effect = value != 0; break; + case 2: + this->has_state = value != 0; + break; + case 3: + this->state = value != 0; + break; + case 4: + this->has_brightness = value != 0; + break; + case 22: + this->has_color_mode = value != 0; + break; + case 23: + this->color_mode = static_cast(value); + break; + case 20: + this->has_color_brightness = value != 0; + break; + case 6: + this->has_rgb = value != 0; + break; + case 10: + this->has_white = value != 0; + break; + case 12: + this->has_color_temperature = value != 0; + break; + case 24: + this->has_cold_white = value != 0; + break; + case 26: + this->has_warm_white = value != 0; + break; + case 14: + this->has_transition_length = value != 0; + break; + case 15: + this->transition_length = value; + break; + case 16: + this->has_flash_length = value != 0; + break; + case 17: + this->flash_length = value; + break; + case 18: + this->has_effect = value != 0; + break; #ifdef USE_DEVICES - case 28: this->device_id = value; break; + case 28: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 19: { - this->effect = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->effect = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool LightCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 5: this->brightness = value.as_float(); break; - case 21: this->color_brightness = value.as_float(); break; - case 7: this->red = value.as_float(); break; - case 8: this->green = value.as_float(); break; - case 9: this->blue = value.as_float(); break; - case 11: this->white = value.as_float(); break; - case 13: this->color_temperature = value.as_float(); break; - case 25: this->cold_white = value.as_float(); break; - case 27: this->warm_white = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 5: + this->brightness = value.as_float(); + break; + case 21: + this->color_brightness = value.as_float(); + break; + case 7: + this->red = value.as_float(); + break; + case 8: + this->green = value.as_float(); + break; + case 9: + this->blue = value.as_float(); + break; + case 11: + this->white = value.as_float(); + break; + case 13: + this->color_temperature = value.as_float(); + break; + case 25: + this->cold_white = value.as_float(); + break; + case 27: + this->warm_white = value.as_float(); + break; + default: + return false; } return true; } @@ -644,7 +746,8 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const { return size; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t * +SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); @@ -655,7 +758,8 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint32_t SensorStateResponse::calculate_size() const { +uint32_t +SensorStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->state); @@ -721,18 +825,26 @@ uint32_t SwitchStateResponse::calculate_size() const { } bool SwitchCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->state = value != 0; break; + case 2: + this->state = value != 0; + break; #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -793,14 +905,20 @@ uint32_t TextSensorStateResponse::calculate_size() const { #endif bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->level = static_cast(value); break; - case 2: this->dump_config = value != 0; break; - default: return false; + case 1: + this->level = static_cast(value); + break; + case 2: + this->dump_config = value != 0; + break; + default: + return false; } return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t * +SubscribeLogsResponse::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->level), true); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); @@ -809,7 +927,8 @@ uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEB return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint32_t SubscribeLogsResponse::calculate_size() const { +uint32_t +SubscribeLogsResponse::calculate_size() const { uint32_t size = 0; size += 2; size += ProtoSize::calc_length(1, this->message_len_); @@ -819,11 +938,12 @@ uint32_t SubscribeLogsResponse::calculate_size() const { bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->key = value.data(); - this->key_len = value.size(); - break; - } - default: return false; + this->key = value.data(); + this->key_len = value.size(); + break; + } + default: + return false; } return true; } @@ -909,26 +1029,32 @@ uint32_t HomeassistantActionRequest::calculate_size() const { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES bool HomeassistantActionResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->call_id = value; break; - case 2: this->success = value != 0; break; - default: return false; + case 1: + this->call_id = value; + break; + case 2: + this->success = value != 0; + break; + default: + return false; } return true; } bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON case 4: { - this->response_data = value.data(); - this->response_data_len = value.size(); - break; - } + this->response_data = value.data(); + this->response_data_len = value.size(); + break; + } #endif - default: return false; + default: + return false; } return true; } @@ -951,65 +1077,95 @@ uint32_t SubscribeHomeAssistantStateResponse::calculate_size() const { bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } #endif bool DSTRule::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->time_seconds = decode_zigzag32(static_cast(value)); break; - case 2: this->day = value; break; - case 3: this->type = static_cast(value); break; - case 4: this->month = value; break; - case 5: this->week = value; break; - case 6: this->day_of_week = value; break; - default: return false; + case 1: + this->time_seconds = decode_zigzag32(static_cast(value)); + break; + case 2: + this->day = value; + break; + case 3: + this->type = static_cast(value); + break; + case 4: + this->month = value; + break; + case 5: + this->week = value; + break; + case 6: + this->day_of_week = value; + break; + default: + return false; } return true; } bool ParsedTimezone::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->std_offset_seconds = decode_zigzag32(static_cast(value)); break; - case 2: this->dst_offset_seconds = decode_zigzag32(static_cast(value)); break; - default: return false; + case 1: + this->std_offset_seconds = decode_zigzag32(static_cast(value)); + break; + case 2: + this->dst_offset_seconds = decode_zigzag32(static_cast(value)); + break; + default: + return false; } return true; } bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 3: value.decode_to_message(this->dst_start); break; - case 4: value.decode_to_message(this->dst_end); break; - default: return false; + case 3: + value.decode_to_message(this->dst_start); + break; + case 4: + value.decode_to_message(this->dst_end); + break; + default: + return false; } return true; } bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: value.decode_to_message(this->parsed_timezone); break; - default: return false; + this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 3: + value.decode_to_message(this->parsed_timezone); + break; + default: + return false; } return true; } bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->epoch_seconds = value.as_fixed32(); break; - default: return false; + case 1: + this->epoch_seconds = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -1050,31 +1206,50 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const { } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->bool_ = value != 0; break; - case 2: this->legacy_int = static_cast(value); break; - case 5: this->int_ = decode_zigzag32(static_cast(value)); break; - case 6: this->bool_array.push_back(value != 0); break; - case 7: this->int_array.push_back(decode_zigzag32(static_cast(value))); break; - default: return false; + case 1: + this->bool_ = value != 0; + break; + case 2: + this->legacy_int = static_cast(value); + break; + case 5: + this->int_ = decode_zigzag32(static_cast(value)); + break; + case 6: + this->bool_array.push_back(value != 0); + break; + case 7: + this->int_array.push_back(decode_zigzag32(static_cast(value))); + break; + default: + return false; } return true; } bool ExecuteServiceArgument::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 9: this->string_array.push_back(value.as_string()); break; - default: return false; + this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 9: + this->string_array.push_back(value.as_string()); + break; + default: + return false; } return true; } bool ExecuteServiceArgument::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 3: this->float_ = value.as_float(); break; - case 8: this->float_array.push_back(value.as_float()); break; - default: return false; + case 3: + this->float_ = value.as_float(); + break; + case 8: + this->float_array.push_back(value.as_float()); + break; + default: + return false; } return true; } @@ -1092,26 +1267,38 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { bool ExecuteServiceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 3: this->call_id = value; break; + case 3: + this->call_id = value; + break; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 4: this->return_response = value != 0; break; + case 4: + this->return_response = value != 0; + break; #endif - default: return false; + default: + return false; } return true; } bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: this->args.emplace_back(); value.decode_to_message(this->args.back()); break; - default: return false; + case 2: + this->args.emplace_back(); + value.decode_to_message(this->args.back()); + break; + default: + return false; } return true; } bool ExecuteServiceRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -1196,9 +1383,14 @@ uint32_t CameraImageResponse::calculate_size() const { } bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->single = value != 0; break; - case 2: this->stream = value != 0; break; - default: return false; + case 1: + this->single = value != 0; + break; + case 2: + this->stream = value != 0; + break; + default: + return false; } return true; } @@ -1342,49 +1534,92 @@ uint32_t ClimateStateResponse::calculate_size() const { } bool ClimateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_mode = value != 0; break; - case 3: this->mode = static_cast(value); break; - case 4: this->has_target_temperature = value != 0; break; - case 6: this->has_target_temperature_low = value != 0; break; - case 8: this->has_target_temperature_high = value != 0; break; - case 12: this->has_fan_mode = value != 0; break; - case 13: this->fan_mode = static_cast(value); break; - case 14: this->has_swing_mode = value != 0; break; - case 15: this->swing_mode = static_cast(value); break; - case 16: this->has_custom_fan_mode = value != 0; break; - case 18: this->has_preset = value != 0; break; - case 19: this->preset = static_cast(value); break; - case 20: this->has_custom_preset = value != 0; break; - case 22: this->has_target_humidity = value != 0; break; + case 2: + this->has_mode = value != 0; + break; + case 3: + this->mode = static_cast(value); + break; + case 4: + this->has_target_temperature = value != 0; + break; + case 6: + this->has_target_temperature_low = value != 0; + break; + case 8: + this->has_target_temperature_high = value != 0; + break; + case 12: + this->has_fan_mode = value != 0; + break; + case 13: + this->fan_mode = static_cast(value); + break; + case 14: + this->has_swing_mode = value != 0; + break; + case 15: + this->swing_mode = static_cast(value); + break; + case 16: + this->has_custom_fan_mode = value != 0; + break; + case 18: + this->has_preset = value != 0; + break; + case 19: + this->preset = static_cast(value); + break; + case 20: + this->has_custom_preset = value != 0; + break; + case 22: + this->has_target_humidity = value != 0; + break; #ifdef USE_DEVICES - case 24: this->device_id = value; break; + case 24: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 17: { - this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 21: { - this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 5: this->target_temperature = value.as_float(); break; - case 7: this->target_temperature_low = value.as_float(); break; - case 9: this->target_temperature_high = value.as_float(); break; - case 23: this->target_humidity = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 5: + this->target_temperature = value.as_float(); + break; + case 7: + this->target_temperature_low = value.as_float(); + break; + case 9: + this->target_temperature_high = value.as_float(); + break; + case 23: + this->target_humidity = value.as_float(); + break; + default: + return false; } return true; } @@ -1464,23 +1699,41 @@ uint32_t WaterHeaterStateResponse::calculate_size() const { } bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_fields = value; break; - case 3: this->mode = static_cast(value); break; + case 2: + this->has_fields = value; + break; + case 3: + this->mode = static_cast(value); + break; #ifdef USE_DEVICES - case 5: this->device_id = value; break; + case 5: + this->device_id = value; + break; #endif - case 6: this->state = value; break; - default: return false; + case 6: + this->state = value; + break; + default: + return false; } return true; } bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 4: this->target_temperature = value.as_float(); break; - case 7: this->target_temperature_low = value.as_float(); break; - case 8: this->target_temperature_high = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 4: + this->target_temperature = value.as_float(); + break; + case 7: + this->target_temperature_low = value.as_float(); + break; + case 8: + this->target_temperature_high = value.as_float(); + break; + default: + return false; } return true; } @@ -1551,17 +1804,25 @@ uint32_t NumberStateResponse::calculate_size() const { bool NumberCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 2: this->state = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 2: + this->state = value.as_float(); + break; + default: + return false; } return true; } @@ -1628,26 +1889,33 @@ uint32_t SelectStateResponse::calculate_size() const { bool SelectCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -1715,34 +1983,55 @@ uint32_t SirenStateResponse::calculate_size() const { } bool SirenCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_state = value != 0; break; - case 3: this->state = value != 0; break; - case 4: this->has_tone = value != 0; break; - case 6: this->has_duration = value != 0; break; - case 7: this->duration = value; break; - case 8: this->has_volume = value != 0; break; + case 2: + this->has_state = value != 0; + break; + case 3: + this->state = value != 0; + break; + case 4: + this->has_tone = value != 0; + break; + case 6: + this->has_duration = value != 0; + break; + case 7: + this->duration = value; + break; + case 8: + this->has_volume = value != 0; + break; #ifdef USE_DEVICES - case 10: this->device_id = value; break; + case 10: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool SirenCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 5: { - this->tone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->tone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 9: this->volume = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 9: + this->volume = value.as_float(); + break; + default: + return false; } return true; } @@ -1806,29 +2095,40 @@ uint32_t LockStateResponse::calculate_size() const { } bool LockCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->command = static_cast(value); break; - case 3: this->has_code = value != 0; break; + case 2: + this->command = static_cast(value); + break; + case 3: + this->has_code = value != 0; + break; #ifdef USE_DEVICES - case 5: this->device_id = value; break; + case 5: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool LockCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->code = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -1869,16 +2169,22 @@ uint32_t ListEntitiesButtonResponse::calculate_size() const { bool ButtonCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 2: this->device_id = value; break; + case 2: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -1968,34 +2274,55 @@ uint32_t MediaPlayerStateResponse::calculate_size() const { } bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_command = value != 0; break; - case 3: this->command = static_cast(value); break; - case 4: this->has_volume = value != 0; break; - case 6: this->has_media_url = value != 0; break; - case 8: this->has_announcement = value != 0; break; - case 9: this->announcement = value != 0; break; + case 2: + this->has_command = value != 0; + break; + case 3: + this->command = static_cast(value); + break; + case 4: + this->has_volume = value != 0; + break; + case 6: + this->has_media_url = value != 0; + break; + case 8: + this->has_announcement = value != 0; + break; + case 9: + this->announcement = value != 0; + break; #ifdef USE_DEVICES - case 10: this->device_id = value; break; + case 10: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool MediaPlayerCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 7: { - this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 5: this->volume = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 5: + this->volume = value.as_float(); + break; + default: + return false; } return true; } @@ -2003,36 +2330,41 @@ bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value #ifdef USE_BLUETOOTH_PROXY bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->flags = value; break; - default: return false; + case 1: + this->flags = value; + break; + default: + return false; } return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t * +BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); - uint8_t *len_pos = pos; - ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); - if (sub_msg.address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); - } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); - *len_pos = static_cast(pos - len_pos - 1); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + uint8_t *len_pos = pos; + ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + if (sub_msg.address_type) { + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); + } + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + *len_pos = static_cast(pos - len_pos - 1); } return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { +uint32_t +BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; @@ -2046,11 +2378,20 @@ uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->request_type = static_cast(value); break; - case 3: this->has_address_type = value != 0; break; - case 4: this->address_type = value; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->request_type = static_cast(value); + break; + case 3: + this->has_address_type = value != 0; + break; + case 4: + this->address_type = value; + break; + default: + return false; } return true; } @@ -2072,8 +2413,11 @@ uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { } bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - default: return false; + case 1: + this->address = value; + break; + default: + return false; } return true; } @@ -2185,9 +2529,14 @@ uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { } bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->handle = value; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->handle = value; + break; + default: + return false; } return true; } @@ -2207,57 +2556,83 @@ uint32_t BluetoothGATTReadResponse::calculate_size() const { } bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->handle = value; break; - case 3: this->response = value != 0; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->handle = value; + break; + case 3: + this->response = value != 0; + break; + default: + return false; } return true; } bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 4: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->handle = value; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->handle = value; + break; + default: + return false; } return true; } bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->handle = value; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->handle = value; + break; + default: + return false; } return true; } bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->handle = value; break; - case 3: this->enable = value != 0; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->handle = value; + break; + case 3: + this->enable = value != 0; + break; + default: + return false; } return true; } @@ -2393,8 +2768,11 @@ uint32_t BluetoothScannerStateResponse::calculate_size() const { } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->mode = static_cast(value); break; - default: return false; + case 1: + this->mode = static_cast(value); + break; + default: + return false; } return true; } @@ -2402,9 +2780,14 @@ bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_vari #ifdef USE_VOICE_ASSISTANT bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->subscribe = value != 0; break; - case 2: this->flags = value; break; - default: return false; + case 1: + this->subscribe = value != 0; + break; + case 2: + this->flags = value; + break; + default: + return false; } return true; } @@ -2442,55 +2825,72 @@ uint32_t VoiceAssistantRequest::calculate_size() const { } bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->port = value; break; - case 2: this->error = value != 0; break; - default: return false; + case 1: + this->port = value; + break; + case 2: + this->error = value != 0; + break; + default: + return false; } return true; } bool VoiceAssistantEventData::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->name = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->value = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->value = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->event_type = static_cast(value); break; - default: return false; + case 1: + this->event_type = static_cast(value); + break; + default: + return false; } return true; } bool VoiceAssistantEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: this->data.emplace_back(); value.decode_to_message(this->data.back()); break; - default: return false; + case 2: + this->data.emplace_back(); + value.decode_to_message(this->data.back()); + break; + default: + return false; } return true; } bool VoiceAssistantAudio::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->end = value != 0; break; - default: return false; + case 2: + this->end = value != 0; + break; + default: + return false; } return true; } bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } @@ -2508,50 +2908,64 @@ uint32_t VoiceAssistantAudio::calculate_size() const { } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->event_type = static_cast(value); break; - case 4: this->total_seconds = value; break; - case 5: this->seconds_left = value; break; - case 6: this->is_active = value != 0; break; - default: return false; + case 1: + this->event_type = static_cast(value); + break; + case 4: + this->total_seconds = value; + break; + case 5: + this->seconds_left = value; + break; + case 6: + this->is_active = value != 0; + break; + default: + return false; } return true; } bool VoiceAssistantTimerEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->name = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 4: this->start_conversation = value != 0; break; - default: return false; + case 4: + this->start_conversation = value != 0; + break; + default: + return false; } return true; } bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->text = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->text = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: { - this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } @@ -2587,42 +3001,52 @@ uint32_t VoiceAssistantWakeWord::calculate_size() const { } bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 5: this->model_size = value; break; - default: return false; + case 5: + this->model_size = value; + break; + default: + return false; } return true; } bool VoiceAssistantExternalWakeWord::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->id = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 2: { - this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: this->trained_languages.push_back(value.as_string()); break; + this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + case 3: + this->trained_languages.push_back(value.as_string()); + break; case 4: { - this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 6: { - this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } + this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 7: { - this->url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->url = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: this->external_wake_words.emplace_back(); value.decode_to_message(this->external_wake_words.back()); break; - default: return false; + case 1: + this->external_wake_words.emplace_back(); + value.decode_to_message(this->external_wake_words.back()); + break; + default: + return false; } return true; } @@ -2654,8 +3078,11 @@ uint32_t VoiceAssistantConfigurationResponse::calculate_size() const { } bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: this->active_wake_words.push_back(value.as_string()); break; - default: return false; + case 1: + this->active_wake_words.push_back(value.as_string()); + break; + default: + return false; } return true; } @@ -2717,28 +3144,37 @@ uint32_t AlarmControlPanelStateResponse::calculate_size() const { } bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->command = static_cast(value); break; + case 2: + this->command = static_cast(value); + break; #ifdef USE_DEVICES - case 4: this->device_id = value; break; + case 4: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool AlarmControlPanelCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 3: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->code = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -2805,26 +3241,33 @@ uint32_t TextStateResponse::calculate_size() const { bool TextCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool TextCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: return false; + this->state = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } + default: + return false; } return true; } bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -2886,20 +3329,32 @@ uint32_t DateStateResponse::calculate_size() const { } bool DateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->year = value; break; - case 3: this->month = value; break; - case 4: this->day = value; break; + case 2: + this->year = value; + break; + case 3: + this->month = value; + break; + case 4: + this->day = value; + break; #ifdef USE_DEVICES - case 5: this->device_id = value; break; + case 5: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -2961,20 +3416,32 @@ uint32_t TimeStateResponse::calculate_size() const { } bool TimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->hour = value; break; - case 3: this->minute = value; break; - case 4: this->second = value; break; + case 2: + this->hour = value; + break; + case 3: + this->minute = value; + break; + case 4: + this->second = value; + break; #ifdef USE_DEVICES - case 5: this->device_id = value; break; + case 5: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -3100,20 +3567,32 @@ uint32_t ValveStateResponse::calculate_size() const { } bool ValveCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->has_position = value != 0; break; - case 4: this->stop = value != 0; break; + case 2: + this->has_position = value != 0; + break; + case 4: + this->stop = value != 0; + break; #ifdef USE_DEVICES - case 5: this->device_id = value; break; + case 5: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 3: this->position = value.as_float(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 3: + this->position = value.as_float(); + break; + default: + return false; } return true; } @@ -3172,17 +3651,25 @@ uint32_t DateTimeStateResponse::calculate_size() const { bool DateTimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - case 2: this->epoch_seconds = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + case 2: + this->epoch_seconds = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -3256,18 +3743,26 @@ uint32_t UpdateStateResponse::calculate_size() const { } bool UpdateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 2: this->command = static_cast(value); break; + case 2: + this->command = static_cast(value); + break; #ifdef USE_DEVICES - case 3: this->device_id = value; break; + case 3: + this->device_id = value; + break; #endif - default: return false; + default: + return false; } return true; } bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 1: this->key = value.as_fixed32(); break; - default: return false; + case 1: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -3276,11 +3771,12 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } @@ -3296,19 +3792,23 @@ uint32_t ZWaveProxyFrame::calculate_size() const { } bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->type = static_cast(value); break; - default: return false; + case 1: + this->type = static_cast(value); + break; + default: + return false; } return true; } bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } @@ -3365,30 +3865,41 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const { bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES - case 1: this->device_id = value; break; + case 1: + this->device_id = value; + break; #endif - case 3: this->carrier_frequency = value; break; - case 4: this->repeat_count = value; break; - default: return false; + case 3: + this->carrier_frequency = value; + break; + case 4: + this->repeat_count = value; + break; + default: + return false; } return true; } bool InfraredRFTransmitRawTimingsRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 5: { - this->timings_data_ = value.data(); - this->timings_length_ = value.size(); - this->timings_count_ = count_packed_varints(value.data(), value.size()); - break; - } - default: return false; + this->timings_data_ = value.data(); + this->timings_length_ = value.size(); + this->timings_count_ = count_packed_varints(value.data(), value.size()); + break; + } + default: + return false; } return true; } bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { - case 2: this->key = value.as_fixed32(); break; - default: return false; + case 2: + this->key = value.as_fixed32(); + break; + default: + return false; } return true; } @@ -3420,13 +3931,26 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const { #ifdef USE_SERIAL_PROXY bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->instance = value; break; - case 2: this->baudrate = value; break; - case 3: this->flow_control = value != 0; break; - case 4: this->parity = static_cast(value); break; - case 5: this->stop_bits = value; break; - case 6: this->data_size = value; break; - default: return false; + case 1: + this->instance = value; + break; + case 2: + this->baudrate = value; + break; + case 3: + this->flow_control = value != 0; + break; + case 4: + this->parity = static_cast(value); + break; + case 5: + this->stop_bits = value; + break; + case 6: + this->data_size = value; + break; + default: + return false; } return true; } @@ -3444,34 +3968,46 @@ uint32_t SerialProxyDataReceived::calculate_size() const { } bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->instance = value; break; - default: return false; + case 1: + this->instance = value; + break; + default: + return false; } return true; } bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: return false; + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; } return true; } bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->instance = value; break; - case 2: this->line_states = value; break; - default: return false; + case 1: + this->instance = value; + break; + case 2: + this->line_states = value; + break; + default: + return false; } return true; } bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->instance = value; break; - default: return false; + case 1: + this->instance = value; + break; + default: + return false; } return true; } @@ -3489,9 +4025,14 @@ uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { } bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->instance = value; break; - case 2: this->type = static_cast(value); break; - default: return false; + case 1: + this->instance = value; + break; + case 2: + this->type = static_cast(value); + break; + default: + return false; } return true; } @@ -3515,12 +4056,23 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { #ifdef USE_BLUETOOTH_PROXY bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { - case 1: this->address = value; break; - case 2: this->min_interval = value; break; - case 3: this->max_interval = value; break; - case 4: this->latency = value; break; - case 5: this->timeout = value; break; - default: return false; + case 1: + this->address = value; + break; + case 2: + this->min_interval = value; + break; + case 3: + this->max_interval = value; + break; + case 4: + this->latency = value; + break; + case 5: + this->timeout = value; + break; + default: + return false; } return true; } From 4bcf3b9661d47b3a957cfc235ad9d30d1419d070 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 22:44:02 -1000 Subject: [PATCH 0130/2214] fix existing bug --- esphome/components/api/api_pb2.cpp | 2 +- script/api_protobuf/api_protobuf.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 9943c06cb1..f304c85282 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -931,7 +931,7 @@ uint32_t SubscribeLogsResponse::calculate_size() const { uint32_t size = 0; size += 2; - size += ProtoSize::calc_length(1, this->message_len_); + size += ProtoSize::calc_length_force(1, this->message_len_); return size; } #ifdef USE_API_NOISE diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 1743d2bb34..73e0859d5e 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1028,7 +1028,8 @@ class BytesType(TypeInfo): ) def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len_);" + calc_fn = "calc_length_force" if force else "calc_length" + return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len_);" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes @@ -1109,7 +1110,8 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): ) def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len);" + calc_fn = "calc_length_force" if force else "calc_length" + return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len);" class PointerToStringBufferType(PointerToBufferTypeBase): From d1d96d488d265f94280779e206150a8b71b17589 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 11:04:29 -1000 Subject: [PATCH 0131/2214] [adc] Place ADC oneshot control functions in IRAM for cache safety When flash cache is disabled during background flash operations (NVS writes by WiFi, BLE, Zigbee, Thread, power management, etc.), the ADC oneshot read function will crash if it is in flash. This places the ADC oneshot control functions in IRAM by setting CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM when the ADC component is used. Adds require_adc_oneshot_iram() helper and adc_oneshot_in_iram advanced config option. --- esphome/components/adc/sensor.py | 7 ++++++- esphome/components/esp32/__init__.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index bab2762f00..bbd1a28062 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -2,7 +2,11 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler -from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component +from esphome.components.esp32 import ( + get_esp32_variant, + include_builtin_idf_component, + require_adc_oneshot_iram, +) from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, @@ -120,6 +124,7 @@ async def to_code(config): if CORE.is_esp32: # Re-enable ESP-IDF's ADC driver (excluded by default to save compile time) include_builtin_idf_component("esp_adc") + require_adc_oneshot_iram() if attenuation := config.get(CONF_ATTENUATION): if attenuation == "auto": diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2974028b50..7b3f9da3da 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1058,6 +1058,7 @@ CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" +CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" # VFS requirement tracking # Components that need VFS features can call require_vfs_*() functions @@ -1071,6 +1072,7 @@ KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" +KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" def require_vfs_select() -> None: @@ -1168,6 +1170,17 @@ def require_fatfs() -> None: CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True +def require_adc_oneshot_iram() -> None: + """Mark that ADC oneshot IRAM safety is required by a component. + + Call this from components that use the ADC oneshot driver. When flash cache is + disabled (e.g., during NVS writes by WiFi, BLE, Zigbee, or power management), + the ADC oneshot read function must be in IRAM to avoid crashes. + This sets CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM. + """ + CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -1268,6 +1281,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, + cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), @@ -2068,6 +2082,16 @@ async def to_code(config): if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False) + # Place ADC oneshot control functions in IRAM for cache safety + # When flash cache is disabled (during NVS writes by WiFi, BLE, Zigbee, Thread, + # power management, etc.), ADC reads will crash if these functions are in flash. + # Components using ADC call require_adc_oneshot_iram() to force this. + if ( + CORE.data[KEY_ESP32].get(KEY_ADC_ONESHOT_IRAM_REQUIRED, False) + or advanced[CONF_ADC_ONESHOT_IN_IRAM] + ): + 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): From 8c0436cc7e9344c41754820509fcb7e687673fc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 12:25:38 -1000 Subject: [PATCH 0132/2214] [adc] Move require_adc_oneshot_iram to config validation The esp32 to_code runs at PLATFORM priority (1000) before component to_code at COMPONENT priority (0), so the flag was set too late. Move to config validation to ensure it is set before esp32 processes sdkconfig options. --- esphome/components/adc/sensor.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index bbd1a28062..2e5c9c0730 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -69,6 +69,13 @@ def validate_config(config): return config +def _require_adc_iram(config): + """Register ADC oneshot IRAM requirement during config validation.""" + if CORE.is_esp32: + require_adc_oneshot_iram() + return config + + ADCSensor = adc_ns.class_( "ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler ) @@ -99,6 +106,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(cv.polling_component_schema("60s")), validate_config, + _require_adc_iram, ) CONF_ADC_CHANNEL_ID = "adc_channel_id" @@ -124,7 +132,6 @@ async def to_code(config): if CORE.is_esp32: # Re-enable ESP-IDF's ADC driver (excluded by default to save compile time) include_builtin_idf_component("esp_adc") - require_adc_oneshot_iram() if attenuation := config.get(CONF_ATTENUATION): if attenuation == "auto": From 70ee6a50310aef0d99d44925f66b27a7a44a86e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 12:28:09 -1000 Subject: [PATCH 0133/2214] Add type annotations to _require_adc_iram --- esphome/components/adc/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 2e5c9c0730..09e09f0dc1 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -28,6 +28,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE +from esphome.types import ConfigType from . import ( ATTENUATION_MODES, @@ -69,7 +70,7 @@ def validate_config(config): return config -def _require_adc_iram(config): +def _require_adc_iram(config: ConfigType) -> ConfigType: """Register ADC oneshot IRAM requirement during config validation.""" if CORE.is_esp32: require_adc_oneshot_iram() From 81a2caee36e2ad3d774b020141ae70cdcf870107 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 12:41:12 -1000 Subject: [PATCH 0134/2214] [web_server] Reset OTA backend on new upload to avoid bricked device after interrupted OTA If a captive_portal or web_server OTA upload is interrupted mid-stream (e.g. TCP connection reset), the shared OTARequestHandler's ota_backend_ is left open. When the browser resends the multipart POST, the guard 'index == 0 && !ota_backend_' skipped re-initialization, so new bytes were written at the previous session's offset. The Updater's end() then reports success with a concatenated image in flash, bricking the device on reboot. Abort and reset any in-progress backend whenever a new multipart upload starts (index == 0) so the fresh upload begins a clean OTA session. --- .../components/web_server/ota/ota_web_server.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 95b166901a..d5c6f9e7a4 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -114,7 +114,18 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf uint8_t *data, size_t len, bool final) { ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; - if (index == 0 && !this->ota_backend_) { + if (index == 0) { + // A new multipart upload is starting. If a previous upload was interrupted + // (e.g. TCP reset) the backend from that session may still be open; tear it + // down so flash state doesn't get concatenated with the new image (which + // can produce a technically-valid-sized but corrupted firmware that + // bricks the device once it reboots). + if (this->ota_backend_) { + ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); + this->ota_backend_->abort(); + this->ota_backend_.reset(); + } + // Initialize OTA on first call this->ota_init_(filename.c_str()); From ccdb5a3bfb3161eca67f7782f576a70882d502e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 12:49:11 -1000 Subject: [PATCH 0135/2214] Track request pointer to avoid re-init on web_server_idf's double index==0 call web_server_idf invokes handleUpload twice with index==0 at the start of every upload (once as a 'Start' signal with nullptr data, then with the first data chunk). The previous fix treated the second call as a stale-session retry, wastefully aborting and re-initializing the backend on every single upload. Store the AsyncWebServerRequest pointer that owns the current session and only tear down the backend when a different request arrives at index==0. Clear the tracked pointer on every backend reset path so a stack-reused request pointer from a later upload cannot collide. --- .../components/web_server/ota/ota_web_server.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index d5c6f9e7a4..747bbca544 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -72,6 +72,11 @@ class OTARequestHandler : public AsyncWebHandler { private: ota::OTABackendPtr ota_backend_{nullptr}; + // Tracks the request that owns the current ota_backend_ session so we can + // detect when a new (retry) request arrives while a previous session is + // still open -- without re-triggering on the multiple index==0 callbacks + // web_server_idf makes for a single upload (Start + first data chunk). + AsyncWebServerRequest *ota_request_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { @@ -114,8 +119,8 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf uint8_t *data, size_t len, bool final) { ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; - if (index == 0) { - // A new multipart upload is starting. If a previous upload was interrupted + if (index == 0 && this->ota_request_ != request) { + // A new request is starting an upload. If a previous upload was interrupted // (e.g. TCP reset) the backend from that session may still be open; tear it // down so flash state doesn't get concatenated with the new image (which // can produce a technically-valid-sized but corrupted firmware that @@ -125,6 +130,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->ota_backend_->abort(); this->ota_backend_.reset(); } + this->ota_request_ = request; // Initialize OTA on first call this->ota_init_(filename.c_str()); @@ -160,6 +166,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); this->ota_backend_.reset(); + this->ota_request_ = nullptr; #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -178,6 +185,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); this->ota_backend_.reset(); + this->ota_request_ = nullptr; #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -210,6 +218,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf #endif } this->ota_backend_.reset(); + this->ota_request_ = nullptr; } } From 3042a910fe348dd5a7c2b2ae08f06f137a44ff93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 12:49:44 -1000 Subject: [PATCH 0136/2214] Extract ota_end_session_ helper to DRY backend reset paths --- .../components/web_server/ota/ota_web_server.cpp | 15 ++++++++------- 1 file changed, 8 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 747bbca544..475ae5492b 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -64,6 +64,10 @@ class OTARequestHandler : public AsyncWebHandler { void report_ota_progress_(AsyncWebServerRequest *request); void schedule_ota_reboot_(); void ota_init_(const char *filename); + void ota_end_session_() { + this->ota_backend_.reset(); + this->ota_request_ = nullptr; + } uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; @@ -128,7 +132,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (this->ota_backend_) { ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); this->ota_backend_->abort(); - this->ota_backend_.reset(); + this->ota_end_session_(); } this->ota_request_ = request; @@ -165,8 +169,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf error_code = this->ota_backend_->begin(0); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); - this->ota_backend_.reset(); - this->ota_request_ = nullptr; + this->ota_end_session_(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -184,8 +187,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); - this->ota_backend_.reset(); - this->ota_request_ = nullptr; + this->ota_end_session_(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -217,8 +219,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } - this->ota_backend_.reset(); - this->ota_request_ = nullptr; + this->ota_end_session_(); } } From ba2558edf64e82fcbd667ba31c04677c6a189693 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 12:56:52 -1000 Subject: [PATCH 0137/2214] Gate OTA session init on index==0 && len>0 instead of request pointer The previous fix tracked the AsyncWebServerRequest pointer to distinguish web_server_idf's double index==0 callbacks (Start marker with data==nullptr, then the first real data chunk) from a retry after an interrupted upload. That is unreliable: AsyncWebServerRequest is a stack-allocated object in the httpd task, so a new request that happens to reuse the same stack address as an interrupted one compares equal and silently skips the abort. Closing a browser tab mid-upload and starting a fresh upload from another window could then concatenate partial data from the first upload with the new image. Gate the init block on 'index == 0 && len > 0' instead. This uniquely identifies the first real byte of an upload on both IDF (start-marker has len==0) and Arduino, no identity tracking needed. --- .../web_server/ota/ota_web_server.cpp | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 475ae5492b..4edab37603 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -64,10 +64,6 @@ class OTARequestHandler : public AsyncWebHandler { void report_ota_progress_(AsyncWebServerRequest *request); void schedule_ota_reboot_(); void ota_init_(const char *filename); - void ota_end_session_() { - this->ota_backend_.reset(); - this->ota_request_ = nullptr; - } uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; @@ -76,11 +72,6 @@ class OTARequestHandler : public AsyncWebHandler { private: ota::OTABackendPtr ota_backend_{nullptr}; - // Tracks the request that owns the current ota_backend_ session so we can - // detect when a new (retry) request arrives while a previous session is - // still open -- without re-triggering on the multiple index==0 callbacks - // web_server_idf makes for a single upload (Start + first data chunk). - AsyncWebServerRequest *ota_request_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { @@ -123,18 +114,20 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf uint8_t *data, size_t len, bool final) { ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; - if (index == 0 && this->ota_request_ != request) { - // A new request is starting an upload. If a previous upload was interrupted - // (e.g. TCP reset) the backend from that session may still be open; tear it - // down so flash state doesn't get concatenated with the new image (which - // can produce a technically-valid-sized but corrupted firmware that - // bricks the device once it reboots). + // First byte of a new upload: index==0 with actual data. (web_server_idf + // fires a separate start-marker call with data==nullptr/len==0 before the + // first real chunk; gate on len>0 so we only trigger once per upload.) + if (index == 0 && len > 0) { + // If a previous upload was interrupted (e.g. client closed the tab, TCP + // reset) the backend from that session may still be open. Tear it down + // so flash state doesn't get concatenated with the new image (which can + // produce a technically-valid-sized but corrupted firmware that bricks + // the device once it reboots). if (this->ota_backend_) { ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); this->ota_backend_->abort(); - this->ota_end_session_(); + this->ota_backend_.reset(); } - this->ota_request_ = request; // Initialize OTA on first call this->ota_init_(filename.c_str()); @@ -169,7 +162,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf error_code = this->ota_backend_->begin(0); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); - this->ota_end_session_(); + this->ota_backend_.reset(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -187,7 +180,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); - this->ota_end_session_(); + this->ota_backend_.reset(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -219,7 +212,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } - this->ota_end_session_(); + this->ota_backend_.reset(); } } From c26d1803253fc1f7b3ce720a0769d1d8e4773c37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 13:07:50 -1000 Subject: [PATCH 0138/2214] Emit OTA_ABORT state when tearing down a stale session on retry Addresses Copilot review suggestion on #15720: when the handler aborts a previously interrupted OTA session because a new upload arrived, also fire the OTA_ABORT state notification so user-facing on_abort: automations and other OTA state listeners observe the teardown. Without this, the abort would be silent to listeners (distinct from OTA_ERROR, which signals a failed session rather than a superseded one). --- esphome/components/web_server/ota/ota_web_server.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 4edab37603..9812714ec0 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -126,6 +126,10 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (this->ota_backend_) { ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + // Notify listeners that the previous session was aborted before the new one starts. + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif this->ota_backend_.reset(); } From 5f76b78cfa3e3266bf2e65bc7ffdedd20551b070 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 13:37:29 -1000 Subject: [PATCH 0139/2214] [esphome] Skip missing extra flash images in upload_using_esptool PlatformIO's idedata may list flash images that do not exist on disk (e.g. a tasmota tinyuf2.bin referenced by the adafruit_qtpy_esp32s3_n4r2 board). Previously the CLI passed every entry straight to esptool, which aborted the entire flash with "No such file or directory". The dashboard path is unaffected because it flashes the pre-merged firmware.factory.bin produced by the post-build step, which already tolerates missing inputs. Filter non-existent extra_flash_images with a warning so a stale or incorrect platform-declared image no longer breaks esphome run. Fixes https://github.com/esphome/esphome/issues/15634 --- esphome/__main__.py | 9 +++++++- tests/unit_tests/test_main.py | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 25b404ae45..bdf2730e50 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -750,8 +750,15 @@ def upload_using_esptool( platformio_api.FlashImage( path=idedata.firmware_bin_path, offset=firmware_offset ), - *idedata.extra_flash_images, ] + for image in idedata.extra_flash_images: + if not Path(image.path).is_file(): + _LOGGER.warning( + "Skipping missing flash image declared by platform: %s", + image.path, + ) + continue + flash_images.append(image) mcu = "esp8266" if CORE.is_esp32: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 85536d2f1c..195ef72f3b 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1231,6 +1231,48 @@ def test_upload_using_esptool_path_conversion( assert partitions_path.endswith("partitions.bin") +def test_upload_using_esptool_skips_missing_extra_flash_images( + tmp_path: Path, + mock_run_external_command_main: Mock, + mock_get_idedata: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A non-existent path in extra_flash_images must be filtered out with a + warning, and must not appear in the esptool command line. Only the valid + images are flashed. Regression test for + https://github.com/esphome/esphome/issues/15634. + """ + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test") + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32} + + missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin" + + mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" + mock_idedata.extra_flash_images = [ + platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), + platformio_api.FlashImage(path=missing_path, offset="0x2d0000"), + ] + mock_get_idedata.return_value = mock_idedata + + (tmp_path / "firmware.bin").touch() + (tmp_path / "bootloader.bin").touch() + # Intentionally do NOT create missing_path + + config = {CONF_ESPHOME: {"platformio_options": {}}} + + with caplog.at_level(logging.WARNING): + result = upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + assert result == 0 + assert "Skipping missing flash image" in caplog.text + assert str(missing_path) in caplog.text + + cmd_list = list(mock_run_external_command_main.call_args[0][1:]) + assert str(missing_path) not in cmd_list + assert "0x2d0000" not in cmd_list + + def test_upload_using_esptool_with_file_path( tmp_path: Path, mock_run_external_command_main: Mock, From 3f56e0255a4f141a58f79f8e09e72db8854298c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:17:21 -1000 Subject: [PATCH 0140/2214] [light] Avoid addressable transition stall at low gamma-corrected values When a uniform-colored addressable strip transitions from one color to another, interpolate math-only against a cached start color instead of reading each LED's current value back through the 8-bit stored byte. The old algorithm used led.get_red()/etc. every step as the source for the delta, which round-tripped through gamma uncorrect/correct and the 8-bit stored byte. At gamma 2.8, any pre-gamma value below ~27 rounds to stored byte 0, so small early-transition steps produced stored 0 and the next step read back 0, stalling progress until ~90% of the transition before a single step produced a large-enough pre-gamma value to clear the gamma threshold. Result: dark for the first 9s of a 10s fade, then jump on in the final 1s. Detect uniform start state in start() and take a cheap math-only lerp path when true, so the stored byte advances through each gamma threshold as smoothed_progress crosses it. Falls back to the existing per-LED read-back algorithm when the buffer is non-uniform (e.g. when transitioning out of an addressable effect). --- .../components/light/addressable_light.cpp | 48 ++++++- esphome/components/light/addressable_light.h | 2 + .../addressable_light_transition.yaml | 29 +++++ .../mock_addressable_light/__init__.py | 1 + .../mock_addressable_light/light.py | 22 ++++ .../mock_addressable_light.h | 48 +++++++ .../test_addressable_light_transition.py | 119 ++++++++++++++++++ 7 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 tests/integration/fixtures/addressable_light_transition.yaml create mode 100644 tests/integration/fixtures/external_components/mock_addressable_light/__init__.py create mode 100644 tests/integration/fixtures/external_components/mock_addressable_light/light.py create mode 100644 tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h create mode 100644 tests/integration/test_addressable_light_transition.py diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 2f6ffc9a38..671f10874d 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -58,6 +58,26 @@ void AddressableLightTransformer::start() { // our transition will handle brightness, disable brightness in correction. this->light_.correction_.set_local_brightness(255); this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state()); + + // When every LED starts at the same color (the common case: plain turn_on/turn_off on a uniform + // strip), interpolate math-only against a single start color. Avoiding the per-step read-back + // through the 8-bit stored byte prevents gamma round-trip quantization from stalling the fade + // at low values (e.g. gamma 2.8 pre-gamma values <27 round to stored 0, freezing progress). + this->uniform_start_ = false; + if (this->light_.size() > 0) { + Color first = this->light_[0].get(); + bool uniform = true; + for (int32_t i = 1; i < this->light_.size(); i++) { + if (this->light_[i].get() != first) { + uniform = false; + break; + } + } + if (uniform) { + this->uniform_start_ = true; + this->start_color_ = first; + } + } } inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) { @@ -97,12 +117,28 @@ optional AddressableLightTransformer::apply() { // non-linear when applying small deltas. if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) { - int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f)); - for (auto led : this->light_) { - led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale), - subtract_scaled_difference(this->target_color_.green, led.get_green(), scale), - subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale), - subtract_scaled_difference(this->target_color_.white, led.get_white(), scale)); + if (this->uniform_start_) { + // All LEDs started at the same color: compute the interpolated value once and write it to + // every LED. No read-back, so each LED's stored byte advances through every gamma threshold + // as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values. + // lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress). + int32_t remaining = int32_t(256.f * (1.f - smoothed_progress)); + uint8_t r = subtract_scaled_difference(this->target_color_.red, this->start_color_.red, remaining); + uint8_t g = subtract_scaled_difference(this->target_color_.green, this->start_color_.green, remaining); + uint8_t b = subtract_scaled_difference(this->target_color_.blue, this->start_color_.blue, remaining); + uint8_t w = subtract_scaled_difference(this->target_color_.white, this->start_color_.white, remaining); + for (auto led : this->light_) { + led.set_rgbw(r, g, b, w); + } + } else { + int32_t scale = + int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f)); + for (auto led : this->light_) { + led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale), + subtract_scaled_difference(this->target_color_.green, led.get_green(), scale), + subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale), + subtract_scaled_difference(this->target_color_.white, led.get_white(), scale)); + } } this->last_transition_progress_ = smoothed_progress; this->light_.schedule_show(); diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 17cdb7d6f6..684dcd4eb1 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -115,6 +115,8 @@ class AddressableLightTransformer : public LightTransformer { AddressableLight &light_; float last_transition_progress_{0.0f}; Color target_color_{}; + Color start_color_{}; + bool uniform_start_{false}; }; } // namespace esphome::light diff --git a/tests/integration/fixtures/addressable_light_transition.yaml b/tests/integration/fixtures/addressable_light_transition.yaml new file mode 100644 index 0000000000..7b847dd803 --- /dev/null +++ b/tests/integration/fixtures/addressable_light_transition.yaml @@ -0,0 +1,29 @@ +esphome: + name: addr-light-transition +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +light: + - platform: mock_addressable_light + output_id: strip_output + id: strip + name: "Test Strip" + num_leds: 4 + gamma_correct: 2.8 + default_transition_length: 0s + +sensor: + - platform: template + name: "led0_red_raw" + id: led0_red_raw + update_interval: 10ms + accuracy_decimals: 0 + lambda: |- + return (float) id(strip_output).get_raw_red(0); diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py b/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py new file mode 100644 index 0000000000..e8cfff8e1f --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/tests"] diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/light.py b/tests/integration/fixtures/external_components/mock_addressable_light/light.py new file mode 100644 index 0000000000..6a1a0a8596 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/light.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID + +mock_addressable_light_ns = cg.esphome_ns.namespace("mock_addressable_light") +MockAddressableLight = mock_addressable_light_ns.class_( + "MockAddressableLight", light.AddressableLight +) + +CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MockAddressableLight), + cv.Optional(CONF_NUM_LEDS, default=4): cv.positive_not_null_int, + } +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_NUM_LEDS]) + await light.register_light(var, config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h new file mode 100644 index 0000000000..588b9c22a3 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h @@ -0,0 +1,48 @@ +#pragma once + +#include "esphome/components/light/addressable_light.h" +#include "esphome/core/component.h" + +namespace esphome::mock_addressable_light { + +// In-memory addressable light for host-mode integration tests. Exposes the raw +// per-LED byte buffer (post-gamma-correction, as the hardware would see it) +// so tests can observe transition behavior without real hardware. +class MockAddressableLight : public light::AddressableLight { + public: + explicit MockAddressableLight(uint16_t num_leds) + : num_leds_(num_leds), buf_(new uint8_t[num_leds * 4]()), effect_data_(new uint8_t[num_leds]()) {} + + void setup() override {} + void write_state(light::LightState *state) override {} + int32_t size() const override { return this->num_leds_; } + void clear_effect_data() override { + for (uint16_t i = 0; i < this->num_leds_; i++) + this->effect_data_[i] = 0; + } + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::RGB}); + return traits; + } + + // Accessors for tests: return the raw stored byte (post gamma correction), + // which is what actual LED hardware would receive. + uint8_t get_raw_red(uint16_t index) const { return this->buf_[index * 4 + 0]; } + uint8_t get_raw_green(uint16_t index) const { return this->buf_[index * 4 + 1]; } + uint8_t get_raw_blue(uint16_t index) const { return this->buf_[index * 4 + 2]; } + uint8_t get_raw_white(uint16_t index) const { return this->buf_[index * 4 + 3]; } + + protected: + light::ESPColorView get_view_internal(int32_t index) const override { + size_t pos = index * 4; + return {this->buf_.get() + pos + 0, this->buf_.get() + pos + 1, this->buf_.get() + pos + 2, + this->buf_.get() + pos + 3, this->effect_data_.get() + index, &this->correction_}; + } + + uint16_t num_leds_; + std::unique_ptr buf_; + std::unique_ptr effect_data_; +}; + +} // namespace esphome::mock_addressable_light diff --git a/tests/integration/test_addressable_light_transition.py b/tests/integration/test_addressable_light_transition.py new file mode 100644 index 0000000000..8b4284b503 --- /dev/null +++ b/tests/integration/test_addressable_light_transition.py @@ -0,0 +1,119 @@ +"""Integration test for addressable light transitions with gamma correction. + +Regression test for a bug where a long turn-on transition on an addressable +light with gamma correction (e.g. gamma_correct: 2.8) produced no visible +output for ~90% of the transition duration, then jumped to the target in the +final ~10%. Root cause: the transition algorithm read each LED's current value +back through the 8-bit stored byte every step; at gamma 2.8 any pre-gamma value +below ~27 rounds to stored byte 0, so the stored byte stalled at 0 until +progress was high enough for a single step to produce a large-enough pre-gamma +value to clear the gamma threshold. + +The fix interpolates against a cached start color when all LEDs started at the +same value (the common case for plain turn_on/turn_off), avoiding the round-trip. + +This test uses a host-only mock addressable light that exposes the raw stored +byte of each LED, so we can observe the transition directly. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import SensorState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_addressable_light_transition( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With gamma 2.8, the stored raw byte must rise visibly well before the end.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + light = next(e for e in entities if e.object_id == "test_strip") + sensor = next(e for e in entities if e.object_id == "led0_red_raw") + + # Track the raw-byte sensor. It polls every 10ms in the fixture, and + # ESPHome sensors publish on every change, so we collect a time series. + loop = asyncio.get_event_loop() + samples: list[tuple[float, float]] = [] + start_time: float | None = None + + def on_state(state: object) -> None: + nonlocal start_time + if not isinstance(state, SensorState) or state.key != sensor.key: + return + now = loop.time() + if start_time is None: + start_time = now + samples.append((now - start_time, state.state)) + + client.subscribe_states(on_state) + + # Give the first poll a chance to land so we have a baseline of 0. + await asyncio.sleep(0.1) + + # Start transition: off -> full white over 1 second. This is the + # scenario from the bug report, compressed in time. + transition_s = 1.0 + client.light_command( + key=light.key, + state=True, + rgb=(1.0, 1.0, 1.0), + brightness=1.0, + transition_length=transition_s, + ) + + # Let the full transition run, plus margin for the final sample. + await asyncio.sleep(transition_s + 0.2) + + # Partition samples by transition progress. We reset the time origin + # at the moment the first post-command sample arrives, since there is + # some latency between issuing the command and the sensor observing + # the transition begin. + assert samples, "no sensor samples received" + + # Find first sample where the transition started producing nonzero + # output (or fall back to the first sample). + first_nonzero_idx = next((i for i, (_, v) in enumerate(samples) if v > 0), None) + assert first_nonzero_idx is not None, ( + "raw byte never rose above 0 during the transition — the fade stalled" + ) + + t0 = samples[first_nonzero_idx][0] + # Collect samples from the first nonzero point onward, re-based to t=0. + rel = [(t - t0, v) for (t, v) in samples[first_nonzero_idx:]] + + # Assertion 1: the transition is not stalled. With the bug, the raw + # byte stays at 0 until ~90% of the transition duration. With the fix, + # it becomes nonzero in the first ~30% (for gamma 2.8, pre-gamma 76 + # clears the gamma threshold at progress ~0.30). We assert that the + # first nonzero sample arrives well before 70% of the transition, + # giving generous slack for scheduling jitter. + first_nonzero_time = samples[first_nonzero_idx][0] - samples[0][0] + assert first_nonzero_time < transition_s * 0.7, ( + f"raw byte only rose above 0 at t={first_nonzero_time:.3f}s " + f"(>{transition_s * 0.7:.3f}s) — transition is stalling" + ) + + # Assertion 2: by the time the transition has had 70% of its duration + # to run from its first visible step, the raw byte should be at least + # ~half of its final value. This catches "barely moves then jumps at + # the end" regressions. + late_samples = [v for (t, v) in rel if t >= transition_s * 0.7] + assert late_samples, "no samples captured late in transition" + assert max(late_samples) >= 100, ( + f"raw byte peaked at only {max(late_samples)} late in transition " + "(expected >= 100 for white target at gamma 2.8)" + ) + + # Assertion 3: final value reaches target. Gamma 2.8 of 255 is 255. + final_samples = [v for (_, v) in samples[-5:]] + assert max(final_samples) >= 250, ( + f"final raw byte was {max(final_samples)}, expected >= 250" + ) From 4e8f98e7678ce59998a918626fd3e105ce485ad9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:22:13 -1000 Subject: [PATCH 0141/2214] [light] Collapse uniform-start flag+Color into optional --- .../components/light/addressable_light.cpp | 25 ++++++++----------- esphome/components/light/addressable_light.h | 3 +-- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 671f10874d..f52f27c63d 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -63,20 +63,14 @@ void AddressableLightTransformer::start() { // strip), interpolate math-only against a single start color. Avoiding the per-step read-back // through the 8-bit stored byte prevents gamma round-trip quantization from stalling the fade // at low values (e.g. gamma 2.8 pre-gamma values <27 round to stored 0, freezing progress). - this->uniform_start_ = false; + this->uniform_start_color_.reset(); if (this->light_.size() > 0) { Color first = this->light_[0].get(); - bool uniform = true; for (int32_t i = 1; i < this->light_.size(); i++) { - if (this->light_[i].get() != first) { - uniform = false; - break; - } - } - if (uniform) { - this->uniform_start_ = true; - this->start_color_ = first; + if (this->light_[i].get() != first) + return; } + this->uniform_start_color_ = first; } } @@ -117,16 +111,17 @@ optional AddressableLightTransformer::apply() { // non-linear when applying small deltas. if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) { - if (this->uniform_start_) { + if (this->uniform_start_color_.has_value()) { // All LEDs started at the same color: compute the interpolated value once and write it to // every LED. No read-back, so each LED's stored byte advances through every gamma threshold // as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values. // lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress). + const Color &start = *this->uniform_start_color_; int32_t remaining = int32_t(256.f * (1.f - smoothed_progress)); - uint8_t r = subtract_scaled_difference(this->target_color_.red, this->start_color_.red, remaining); - uint8_t g = subtract_scaled_difference(this->target_color_.green, this->start_color_.green, remaining); - uint8_t b = subtract_scaled_difference(this->target_color_.blue, this->start_color_.blue, remaining); - uint8_t w = subtract_scaled_difference(this->target_color_.white, this->start_color_.white, remaining); + uint8_t r = subtract_scaled_difference(this->target_color_.red, start.red, remaining); + uint8_t g = subtract_scaled_difference(this->target_color_.green, start.green, remaining); + uint8_t b = subtract_scaled_difference(this->target_color_.blue, start.blue, remaining); + uint8_t w = subtract_scaled_difference(this->target_color_.white, start.white, remaining); for (auto led : this->light_) { led.set_rgbw(r, g, b, w); } diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 684dcd4eb1..c4eabbad2e 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -115,8 +115,7 @@ class AddressableLightTransformer : public LightTransformer { AddressableLight &light_; float last_transition_progress_{0.0f}; Color target_color_{}; - Color start_color_{}; - bool uniform_start_{false}; + optional uniform_start_color_{}; }; } // namespace esphome::light From 6edadaa33bb9d1e971e31dbf4eeb26628318e494 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:31:42 -1000 Subject: [PATCH 0142/2214] [light] Use raw byte compare for uniformity scan to keep apply() hot path inlinable --- esphome/components/light/addressable_light.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index f52f27c63d..2b28bd562c 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -65,12 +65,21 @@ void AddressableLightTransformer::start() { // at low values (e.g. gamma 2.8 pre-gamma values <27 round to stored 0, freezing progress). this->uniform_start_color_.reset(); if (this->light_.size() > 0) { - Color first = this->light_[0].get(); + // Compare raw (post-gamma) bytes across LEDs for uniformity. This avoids N calls to the + // gamma-uncorrecting get_red/green/blue/white accessors, which would otherwise discourage + // the compiler from inlining them inside the apply() fallback path. + auto first = this->light_[0]; + uint8_t r_raw = first.get_red_raw(); + uint8_t g_raw = first.get_green_raw(); + uint8_t b_raw = first.get_blue_raw(); + uint8_t w_raw = first.get_white_raw(); for (int32_t i = 1; i < this->light_.size(); i++) { - if (this->light_[i].get() != first) + auto view = this->light_[i]; + if (view.get_red_raw() != r_raw || view.get_green_raw() != g_raw || view.get_blue_raw() != b_raw || + view.get_white_raw() != w_raw) return; } - this->uniform_start_color_ = first; + this->uniform_start_color_ = first.get(); } } From 93893e02a740f83da06ebfa973a11beda9535bf0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:35:23 -1000 Subject: [PATCH 0143/2214] Revert: raw-byte uniformity check (didn't help inlining) --- esphome/components/light/addressable_light.cpp | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 2b28bd562c..f52f27c63d 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -65,21 +65,12 @@ void AddressableLightTransformer::start() { // at low values (e.g. gamma 2.8 pre-gamma values <27 round to stored 0, freezing progress). this->uniform_start_color_.reset(); if (this->light_.size() > 0) { - // Compare raw (post-gamma) bytes across LEDs for uniformity. This avoids N calls to the - // gamma-uncorrecting get_red/green/blue/white accessors, which would otherwise discourage - // the compiler from inlining them inside the apply() fallback path. - auto first = this->light_[0]; - uint8_t r_raw = first.get_red_raw(); - uint8_t g_raw = first.get_green_raw(); - uint8_t b_raw = first.get_blue_raw(); - uint8_t w_raw = first.get_white_raw(); + Color first = this->light_[0].get(); for (int32_t i = 1; i < this->light_.size(); i++) { - auto view = this->light_[i]; - if (view.get_red_raw() != r_raw || view.get_green_raw() != g_raw || view.get_blue_raw() != b_raw || - view.get_white_raw() != w_raw) + if (this->light_[i].get() != first) return; } - this->uniform_start_color_ = first.get(); + this->uniform_start_color_ = first; } } From 827afb0e98b15b86f15b63da5c1c0bd2b15ebb30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:41:17 -1000 Subject: [PATCH 0144/2214] [light] Move gamma uncorrect math out-of-line, drop ALWAYS_INLINE hints The color_uncorrect_red/green/blue/white helpers each contain two 16-bit divides and a call into the out-of-line gamma_uncorrect_ LUT search. They were marked ESPHOME_ALWAYS_INLINE, a hint inherited from the original 2019 C++ port when the gamma math was a single powf() call and the methods were trivially small. Since the 16-bit gamma LUT landed (#14123, Feb 2026) the bodies grew enough that forcing inlining at every call site duplicates two 16-bit div routines (~90 bytes on ESP8266 Xtensa, no fast 16-bit hw div) across every addressable effect, range op, and transition step that reads a pixel back through the gamma curve. Move the four channel methods and the Color wrapper to the .cpp and drop the ALWAYS_INLINE hints on the forward direction too so the compiler picks per call site. --- .../components/light/esp_color_correction.cpp | 14 ++++++++ .../components/light/esp_color_correction.h | 33 +++++-------------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index 9d731a2bd5..03ac96567c 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -22,4 +22,18 @@ uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const { return (target - a <= b - target) ? lo : lo + 1; } +Color ESPColorCorrection::color_uncorrect(Color color) const { + // uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness) + return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green), + this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white)); +} + +uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const { + if (max_brightness == 0 || this->local_brightness_ == 0) + return 0; + uint16_t uncorrected = this->gamma_uncorrect_(value) * 255UL; + uint16_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_; + return (uint8_t) std::min(res, uint16_t(255)); +} + } // namespace esphome::light diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 48ecc46364..4eb5208c96 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -46,38 +46,18 @@ class ESPColorCorrection { uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_); return this->gamma_correct_(res); } - inline Color color_uncorrect(Color color) const ESPHOME_ALWAYS_INLINE { - // uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness) - return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green), - this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white)); - } + Color color_uncorrect(Color color) const; inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.red == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(red) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.red) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(red, this->max_brightness_.red); } inline uint8_t color_uncorrect_green(uint8_t green) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.green == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(green) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.green) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(green, this->max_brightness_.green); } inline uint8_t color_uncorrect_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.blue == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(blue) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.blue) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(blue, this->max_brightness_.blue); } inline uint8_t color_uncorrect_white(uint8_t white) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.white == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(white) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.white) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(white, this->max_brightness_.white); } protected: @@ -85,6 +65,9 @@ class ESPColorCorrection { uint8_t gamma_correct_(uint8_t value) const; /// Reverse gamma: binary search the forward PROGMEM table uint8_t gamma_uncorrect_(uint8_t value) const; + /// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line + /// to avoid duplicating two 16-bit divides at every call site. + uint8_t color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const; const uint16_t *gamma_table_{nullptr}; Color max_brightness_{255, 255, 255, 255}; From 32130e1cb19dadf7e4ea319cbf308a499c2ce48c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:53:17 -1000 Subject: [PATCH 0145/2214] [light] Address Copilot review feedback on PR #15726 - mock_addressable_light.h: add direct // includes - test: use asyncio.get_running_loop() instead of deprecated get_event_loop() - test: rebase timing to command-issue time (not first-nonzero) and use absolute progress for assertion 2, so late-transition check can't skew when the first nonzero sample happens to land near the assertion-1 limit --- .../mock_addressable_light.h | 4 ++ .../test_addressable_light_transition.py | 67 +++++++++---------- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h index 588b9c22a3..c6b0d10601 100644 --- a/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h +++ b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h @@ -1,5 +1,9 @@ #pragma once +#include +#include +#include + #include "esphome/components/light/addressable_light.h" #include "esphome/core/component.h" diff --git a/tests/integration/test_addressable_light_transition.py b/tests/integration/test_addressable_light_transition.py index 8b4284b503..c642a6f841 100644 --- a/tests/integration/test_addressable_light_transition.py +++ b/tests/integration/test_addressable_light_transition.py @@ -40,18 +40,20 @@ async def test_addressable_light_transition( # Track the raw-byte sensor. It polls every 10ms in the fixture, and # ESPHome sensors publish on every change, so we collect a time series. - loop = asyncio.get_event_loop() + # Samples are stored as (seconds_since_command_issue, value). Times + # before the command was issued are negative. + loop = asyncio.get_running_loop() samples: list[tuple[float, float]] = [] - start_time: float | None = None + command_time: float | None = None def on_state(state: object) -> None: - nonlocal start_time if not isinstance(state, SensorState) or state.key != sensor.key: return now = loop.time() - if start_time is None: - start_time = now - samples.append((now - start_time, state.state)) + # If the command hasn't been issued yet, use 0 as the origin; + # those samples get negative times and are excluded below. + origin = command_time if command_time is not None else now + samples.append((now - origin, state.state)) client.subscribe_states(on_state) @@ -61,6 +63,7 @@ async def test_addressable_light_transition( # Start transition: off -> full white over 1 second. This is the # scenario from the bug report, compressed in time. transition_s = 1.0 + command_time = loop.time() client.light_command( key=light.key, state=True, @@ -72,48 +75,38 @@ async def test_addressable_light_transition( # Let the full transition run, plus margin for the final sample. await asyncio.sleep(transition_s + 0.2) - # Partition samples by transition progress. We reset the time origin - # at the moment the first post-command sample arrives, since there is - # some latency between issuing the command and the sensor observing - # the transition begin. - assert samples, "no sensor samples received" - - # Find first sample where the transition started producing nonzero - # output (or fall back to the first sample). - first_nonzero_idx = next((i for i, (_, v) in enumerate(samples) if v > 0), None) - assert first_nonzero_idx is not None, ( - "raw byte never rose above 0 during the transition — the fade stalled" - ) - - t0 = samples[first_nonzero_idx][0] - # Collect samples from the first nonzero point onward, re-based to t=0. - rel = [(t - t0, v) for (t, v) in samples[first_nonzero_idx:]] + # Only look at samples that arrived after the command was issued. + post_command = [(t, v) for (t, v) in samples if t >= 0] + assert post_command, "no sensor samples received after command was issued" # Assertion 1: the transition is not stalled. With the bug, the raw # byte stays at 0 until ~90% of the transition duration. With the fix, # it becomes nonzero in the first ~30% (for gamma 2.8, pre-gamma 76 - # clears the gamma threshold at progress ~0.30). We assert that the - # first nonzero sample arrives well before 70% of the transition, - # giving generous slack for scheduling jitter. - first_nonzero_time = samples[first_nonzero_idx][0] - samples[0][0] - assert first_nonzero_time < transition_s * 0.7, ( - f"raw byte only rose above 0 at t={first_nonzero_time:.3f}s " - f"(>{transition_s * 0.7:.3f}s) — transition is stalling" + # clears the gamma threshold at progress ~0.30). Require the first + # nonzero sample to land well before 70% of the transition duration, + # measured from the command-issue time. + first_nonzero = next(((t, v) for (t, v) in post_command if v > 0), None) + assert first_nonzero is not None, ( + "raw byte never rose above 0 during the transition — the fade stalled" + ) + assert first_nonzero[0] < transition_s * 0.7, ( + f"raw byte only rose above 0 at t={first_nonzero[0]:.3f}s " + f"(>{transition_s * 0.7:.3f}s after command) — transition is stalling" ) - # Assertion 2: by the time the transition has had 70% of its duration - # to run from its first visible step, the raw byte should be at least - # ~half of its final value. This catches "barely moves then jumps at - # the end" regressions. - late_samples = [v for (t, v) in rel if t >= transition_s * 0.7] + # Assertion 2: by 70% of the transition duration after the command, + # the raw byte should have reached a substantial fraction of its final + # value. This catches "barely moves then jumps at the end" regressions + # that pass assertion 1 but still stall most of the range. + late_samples = [v for (t, v) in post_command if t >= transition_s * 0.7] assert late_samples, "no samples captured late in transition" assert max(late_samples) >= 100, ( - f"raw byte peaked at only {max(late_samples)} late in transition " - "(expected >= 100 for white target at gamma 2.8)" + f"raw byte peaked at only {max(late_samples)} at/after 70% of " + "transition (expected >= 100 for white target at gamma 2.8)" ) # Assertion 3: final value reaches target. Gamma 2.8 of 255 is 255. - final_samples = [v for (_, v) in samples[-5:]] + final_samples = [v for (_, v) in post_command[-5:]] assert max(final_samples) >= 250, ( f"final raw byte was {max(final_samples)}, expected >= 250" ) From 8da24fd1d99cbc7ef604a9ddf376a576ff6c9e79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:54:39 -1000 Subject: [PATCH 0146/2214] [light] Use existing integration test helpers in transition test --- .../test_addressable_light_transition.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_addressable_light_transition.py b/tests/integration/test_addressable_light_transition.py index c642a6f841..3f99c87766 100644 --- a/tests/integration/test_addressable_light_transition.py +++ b/tests/integration/test_addressable_light_transition.py @@ -20,9 +20,10 @@ from __future__ import annotations import asyncio -from aioesphomeapi import SensorState +from aioesphomeapi import LightInfo, SensorInfo, SensorState import pytest +from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -35,8 +36,8 @@ async def test_addressable_light_transition( """With gamma 2.8, the stored raw byte must rise visibly well before the end.""" async with run_compiled(yaml_config), api_client_connected() as client: entities, _ = await client.list_entities_services() - light = next(e for e in entities if e.object_id == "test_strip") - sensor = next(e for e in entities if e.object_id == "led0_red_raw") + light = require_entity(entities, "test_strip", LightInfo) + sensor = require_entity(entities, "led0_red_raw", SensorInfo) # Track the raw-byte sensor. It polls every 10ms in the fixture, and # ESPHome sensors publish on every change, so we collect a time series. @@ -55,10 +56,11 @@ async def test_addressable_light_transition( origin = command_time if command_time is not None else now samples.append((now - origin, state.state)) - client.subscribe_states(on_state) - - # Give the first poll a chance to land so we have a baseline of 0. - await asyncio.sleep(0.1) + # InitialStateHelper swallows the first state ESPHome sends per entity + # on subscribe, so on_state only sees real post-subscribe updates. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() # Start transition: off -> full white over 1 second. This is the # scenario from the bug report, compressed in time. From b324630f8ec4b9e69ba9cda6461de4a3d93cdfa4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:56:02 -1000 Subject: [PATCH 0147/2214] [light] Type-annotate to_code in mock_addressable_light --- .../external_components/mock_addressable_light/light.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/light.py b/tests/integration/fixtures/external_components/mock_addressable_light/light.py index 6a1a0a8596..293d2854f4 100644 --- a/tests/integration/fixtures/external_components/mock_addressable_light/light.py +++ b/tests/integration/fixtures/external_components/mock_addressable_light/light.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_NUM_LEDS, CONF_OUTPUT_ID +from esphome.types import ConfigType mock_addressable_light_ns = cg.esphome_ns.namespace("mock_addressable_light") MockAddressableLight = mock_addressable_light_ns.class_( @@ -16,7 +17,7 @@ CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_NUM_LEDS]) await light.register_light(var, config) await cg.register_component(var, config) From 12b55f176fb4f7b3a99d765087de3412048b3680 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:56:48 -1000 Subject: [PATCH 0148/2214] [light] Clearer uniformity scan + note edge case in uniform path --- esphome/components/light/addressable_light.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index f52f27c63d..5f5f123fe7 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -66,11 +66,15 @@ void AddressableLightTransformer::start() { this->uniform_start_color_.reset(); if (this->light_.size() > 0) { Color first = this->light_[0].get(); + bool uniform = true; for (int32_t i = 1; i < this->light_.size(); i++) { - if (this->light_[i].get() != first) - return; + if (this->light_[i].get() != first) { + uniform = false; + break; + } } - this->uniform_start_color_ = first; + if (uniform) + this->uniform_start_color_ = first; } } @@ -115,6 +119,11 @@ optional AddressableLightTransformer::apply() { // All LEDs started at the same color: compute the interpolated value once and write it to // every LED. No read-back, so each LED's stored byte advances through every gamma threshold // as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values. + // + // Trade-off: any mid-transition writes to individual LEDs (e.g. from a user lambda) will be + // overwritten on the next apply() here. The fallback path below would have respected them + // via its read-back. Concurrent per-LED mutation during a transition isn't a pattern we + // support, so this is acceptable. // lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress). const Color &start = *this->uniform_start_color_; int32_t remaining = int32_t(256.f * (1.f - smoothed_progress)); From 76c8eeede987807ca31ea0a24182fface63ba2f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 15:08:36 -1000 Subject: [PATCH 0149/2214] [light] Fix uint16_t overflow in color_uncorrect_channel_ When max_brightness and local_brightness_ are small but non-zero, the intermediate (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs, producing an incorrect low result. Widen intermediates to uint32_t. Copilot review catch on #15727. --- esphome/components/light/esp_color_correction.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index 03ac96567c..e793226bb1 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -31,9 +31,11 @@ Color ESPColorCorrection::color_uncorrect(Color color) const { uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const { if (max_brightness == 0 || this->local_brightness_ == 0) return 0; - uint16_t uncorrected = this->gamma_uncorrect_(value) * 255UL; - uint16_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + // Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero, + // (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs. + uint32_t uncorrected = this->gamma_uncorrect_(value) * 255UL; + uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_; + return static_cast(std::min(res, uint32_t(255))); } } // namespace esphome::light From edb2145aca99fb737619acbc24f099327a359d5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 15:55:50 -1000 Subject: [PATCH 0150/2214] [light] Collapse 8 clamp-and-copy blocks in LightCall::validate_ into a loop Reorder FieldFlags so the eight [0.0, 1.0]-clamped float fields occupy bits 0-7 in the same order as they appear in LightCall and LightColorValues, and move color_temperature_ to the end of both structs. Under that layout the LightCall offset for clamp field i is `offsetof(LightCall, brightness_) + i * 4`, and the LightColorValues offset is exactly 12 bytes lower for every field. validate_() now iterates the active clamp bits in a small loop that computes these offsets from the bit position instead of expanding eight nearly identical inline blocks via macro. The field-name PROGMEM pointer is passed to clamp_and_log_if_invalid as `const LogString *const *`; progmem_read_ptr only runs on the cold (out-of-range) path, so the hot path performs no flash reads for the name. The eight invariants the loop relies on (flag-bit layout, field contiguity, and the constant 12-byte delta) are enforced by static_asserts so any future reshuffle fails loudly at compile time. Size deltas for the isolated light component build (vs dev): ESP32-IDF: -118 B code, +32 B PROGMEM name table = -86 B net ESP8266: -56 B code, +32 B PROGMEM name table = -24 B net 0 B RAM impact on both targets. --- esphome/components/light/light_call.cpp | 84 ++++++++++++++----- esphome/components/light/light_call.h | 43 ++++++---- esphome/components/light/light_color_values.h | 10 ++- 3 files changed, 100 insertions(+), 37 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index a749cd7305..de8f892b04 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -10,10 +10,19 @@ namespace esphome::light { static const char *const TAG = "light"; -// Helper functions to reduce code size for logging -static void clamp_and_log_if_invalid(const char *name, float &value, const LogString *param_name, float min = 0.0f, - float max = 1.0f) { +// Helper functions to reduce code size for logging. +// +// `param_name_progmem` is a pointer to a flash-resident `const LogString *` +// slot (e.g. into the FIELD_NAMES table below). We only dereference it via +// `progmem_read_ptr` on the cold path that actually emits the log message, +// so the hot path (value in range) performs no flash reads at all. On +// non-ESP8266 platforms `progmem_read_ptr` is a plain `*addr` inline, so +// there is no cost there either. +static void clamp_and_log_if_invalid(const char *name, float &value, const LogString *const *param_name_progmem, + float min = 0.0f, float max = 1.0f) { if (value < min || value > max) { + const auto *param_name = reinterpret_cast( + progmem_read_ptr(reinterpret_cast(param_name_progmem))); ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); value = clamp(value, min, max); } @@ -277,25 +286,60 @@ LightColorValues LightCall::validate_() { if (this->has_state()) v.set_state(this->state_); - // clamp_and_log_if_invalid already clamps in-place, so assign directly - // to avoid redundant clamp code from the setter being inlined. -#define VALIDATE_AND_APPLY(field, name_str, ...) \ - if (this->has_##field()) { \ - clamp_and_log_if_invalid(name, this->field##_, LOG_STR(name_str), ##__VA_ARGS__); \ - v.field##_ = this->field##_; \ + // Clamp the eight [0.0, 1.0] fields and copy them from `this` into `v`. + // + // LightCall and LightColorValues both declare the same eight float fields in + // the same order (brightness_, color_brightness_, red_, green_, blue_, + // white_, cold_white_, warm_white_), and their corresponding flag bits are + // also 0-7 in that order. Under that layout the LightCall offset for field i + // is `offsetof(LightCall, brightness_) + i * 4`, and the LightColorValues + // offset is exactly 12 bytes lower (enforced by the static_asserts below). + // Iterating via bit-position arithmetic lets us collapse eight inlined + // clamp/copy blocks into a single loop. + static_assert(FLAG_HAS_BRIGHTNESS == 1u << 0, "clamp loop assumes bit 0"); + static_assert(FLAG_HAS_WARM_WHITE == 1u << 7, "clamp loop assumes bit 7"); + static_assert(offsetof(LightCall, warm_white_) - offsetof(LightCall, brightness_) == 7 * sizeof(float), + "LightCall clamp fields must be contiguous"); + static_assert(offsetof(LightColorValues, warm_white_) - offsetof(LightColorValues, brightness_) == 7 * sizeof(float), + "LightColorValues clamp fields must be contiguous"); + static_assert(offsetof(LightCall, brightness_) - offsetof(LightColorValues, brightness_) == 12, + "LightCall and LightColorValues clamp fields must have constant byte-offset delta"); + + static const LogString *const FIELD_NAMES[8] PROGMEM = { + LOG_STR("Brightness"), // FLAG_HAS_BRIGHTNESS (bit 0) + LOG_STR("Color brightness"), // FLAG_HAS_COLOR_BRIGHTNESS (bit 1) + LOG_STR("Red"), // FLAG_HAS_RED (bit 2) + LOG_STR("Green"), // FLAG_HAS_GREEN (bit 3) + LOG_STR("Blue"), // FLAG_HAS_BLUE (bit 4) + LOG_STR("White"), // FLAG_HAS_WHITE (bit 5) + LOG_STR("Cold white"), // FLAG_HAS_COLD_WHITE (bit 6) + LOG_STR("Warm white"), // FLAG_HAS_WARM_WHITE (bit 7) + }; + constexpr size_t SRC_BASE = offsetof(LightCall, brightness_); + constexpr size_t SRC_TO_DST_DELTA = SRC_BASE - offsetof(LightColorValues, brightness_); + + uint8_t active = this->flags_ & CLAMP_FLAGS_MASK; + if (active != 0) { + auto *self = reinterpret_cast(this); + auto *out = reinterpret_cast(&v); + for (uint8_t bit = 0; bit < 8; bit++) { + if (!(active & (1u << bit))) + continue; + const size_t src_off = SRC_BASE + bit * sizeof(float); + float &f = *reinterpret_cast(self + src_off); + clamp_and_log_if_invalid(name, f, &FIELD_NAMES[bit]); + *reinterpret_cast(out + src_off - SRC_TO_DST_DELTA) = f; + } } - VALIDATE_AND_APPLY(brightness, "Brightness") - VALIDATE_AND_APPLY(color_brightness, "Color brightness") - VALIDATE_AND_APPLY(red, "Red") - VALIDATE_AND_APPLY(green, "Green") - VALIDATE_AND_APPLY(blue, "Blue") - VALIDATE_AND_APPLY(white, "White") - VALIDATE_AND_APPLY(cold_white, "Cold white") - VALIDATE_AND_APPLY(warm_white, "Warm white") - VALIDATE_AND_APPLY(color_temperature, "Color temperature", traits.get_min_mireds(), traits.get_max_mireds()) - -#undef VALIDATE_AND_APPLY + // color_temperature uses a dynamic range from the light's traits and is + // handled separately. + if (this->has_color_temperature()) { + static const LogString *const CT_NAME PROGMEM = LOG_STR("Color temperature"); + clamp_and_log_if_invalid(name, this->color_temperature_, &CT_NAME, traits.get_min_mireds(), + traits.get_max_mireds()); + v.color_temperature_ = this->color_temperature_; + } v.normalize_color(); diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 88d29bd349..9f34297dcd 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -196,24 +196,31 @@ class LightCall { void transform_parameters_(const LightTraits &traits); // Bitfield flags - each flag indicates whether a corresponding value has been set. + // + // Bits 0-7 are the eight float fields that share the [0.0, 1.0] clamp range, + // in member declaration order. The validate_() clamp loop relies on this + // layout to index into LightCall/LightColorValues via bit-position arithmetic + // without a per-field offset table. Do not reorder without updating the + // static_asserts in light_call.cpp. enum FieldFlags : uint16_t { - FLAG_HAS_STATE = 1 << 0, - FLAG_HAS_TRANSITION = 1 << 1, - FLAG_HAS_FLASH = 1 << 2, - FLAG_HAS_EFFECT = 1 << 3, - FLAG_HAS_BRIGHTNESS = 1 << 4, - FLAG_HAS_COLOR_BRIGHTNESS = 1 << 5, - FLAG_HAS_RED = 1 << 6, - FLAG_HAS_GREEN = 1 << 7, - FLAG_HAS_BLUE = 1 << 8, - FLAG_HAS_WHITE = 1 << 9, - FLAG_HAS_COLOR_TEMPERATURE = 1 << 10, - FLAG_HAS_COLD_WHITE = 1 << 11, - FLAG_HAS_WARM_WHITE = 1 << 12, + FLAG_HAS_BRIGHTNESS = 1 << 0, + FLAG_HAS_COLOR_BRIGHTNESS = 1 << 1, + FLAG_HAS_RED = 1 << 2, + FLAG_HAS_GREEN = 1 << 3, + FLAG_HAS_BLUE = 1 << 4, + FLAG_HAS_WHITE = 1 << 5, + FLAG_HAS_COLD_WHITE = 1 << 6, + FLAG_HAS_WARM_WHITE = 1 << 7, + FLAG_HAS_COLOR_TEMPERATURE = 1 << 8, + FLAG_HAS_STATE = 1 << 9, + FLAG_HAS_TRANSITION = 1 << 10, + FLAG_HAS_FLASH = 1 << 11, + FLAG_HAS_EFFECT = 1 << 12, FLAG_HAS_COLOR_MODE = 1 << 13, FLAG_PUBLISH = 1 << 14, FLAG_SAVE = 1 << 15, }; + static constexpr uint16_t CLAMP_FLAGS_MASK = 0x00FFu; // bits 0-7 inline bool has_transition_() { return (this->flags_ & FLAG_HAS_TRANSITION) != 0; } inline bool has_flash_() { return (this->flags_ & FLAG_HAS_FLASH) != 0; } @@ -239,7 +246,13 @@ class LightCall { LightState *parent_; // Light state values - use flags_ to check if a value has been set. - // Group 4-byte aligned members first + // Group 4-byte aligned members first. + // + // The eight [0.0, 1.0]-clamped float fields (brightness_ ... warm_white_) + // are declared in the same order as their flag bits (0-7) and the matching + // fields in LightColorValues. validate_() exploits this to iterate them via + // bit-position arithmetic. color_temperature_ has a custom range and lives + // outside that block. uint32_t transition_length_; uint32_t flash_length_; uint32_t effect_; @@ -249,9 +262,9 @@ class LightCall { float green_; float blue_; float white_; - float color_temperature_; float cold_white_; float warm_white_; + float color_temperature_; // Smaller members at the end for better packing uint16_t flags_{FLAG_PUBLISH | FLAG_SAVE}; // Tracks which values are set diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index fa286a3941..c520f4dc25 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -52,9 +52,9 @@ class LightColorValues { green_(1.0f), blue_(1.0f), white_(1.0f), - color_temperature_{0.0f}, cold_white_{1.0f}, warm_white_{1.0f}, + color_temperature_{0.0f}, color_mode_(ColorMode::UNKNOWN) {} LightColorValues(ColorMode color_mode, float state, float brightness, float color_brightness, float red, float green, @@ -287,6 +287,12 @@ class LightColorValues { friend class LightCall; protected: + // The eight [0.0, 1.0]-clamped float fields are declared in the same order + // as their flag bits (0-7) in LightCall::FieldFlags and the matching fields + // in LightCall. LightCall::validate_() exploits this layout to iterate and + // copy them via bit-position arithmetic with a constant delta of 12 bytes + // between matching LightCall and LightColorValues members. color_temperature_ + // has a different range and is placed after the clamp block. float state_; ///< ON / OFF, float for transition float brightness_; float color_brightness_; @@ -294,9 +300,9 @@ class LightColorValues { float green_; float blue_; float white_; - float color_temperature_; ///< Color Temperature in Mired float cold_white_; float warm_white_; + float color_temperature_; ///< Color Temperature in Mired ColorMode color_mode_; }; From cdde0abec7fcf63d1858b868463216b5691c2356 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:07:34 -1000 Subject: [PATCH 0151/2214] [light] Refine validate_ clamp loop: ctz iteration, per-field asserts, typed pointers Follow-up to edb2145a addressing three points: 1. Hoist the in-range check out of the logging helper. The loop now tests `value < 0.0f || value > 1.0f` inline and only calls the out-of-line log_out_of_range_and_clamp_ helper on the cold path. Hot path (value in range) skips the call8 and the register spill/reload around it, which matters because HA automations can drive perform() at high frequency. 2. Iterate only set bits with __builtin_ctz + (active & active-1). Common calls with one or two flags set now exit the loop after one or two iterations instead of always scanning all eight slots. 3. Replace the uint8_t* pointer arithmetic with typed float arrays aliasing &brightness_ in each struct. Per-field static_asserts (expanded via a local macro) now catch reorders of any single member in either struct, not just reorders at the endpoints. Compiles to the same machine code as the uint8_t* version. Size delta vs. prior commit (isolated light build): ESP32-IDF: validate_ +60 B, helper -29 B, net +31 B ESP8266: validate_ +72 B, helper -42 B, net +30 B Still a net win vs. dev on both targets (ESP32-IDF -91 B, ESP8266 -22 B). --- esphome/components/light/light_call.cpp | 98 +++++++++++++++---------- 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index de8f892b04..df65883ed6 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -10,22 +10,17 @@ namespace esphome::light { static const char *const TAG = "light"; -// Helper functions to reduce code size for logging. -// -// `param_name_progmem` is a pointer to a flash-resident `const LogString *` -// slot (e.g. into the FIELD_NAMES table below). We only dereference it via -// `progmem_read_ptr` on the cold path that actually emits the log message, -// so the hot path (value in range) performs no flash reads at all. On -// non-ESP8266 platforms `progmem_read_ptr` is a plain `*addr` inline, so -// there is no cost there either. -static void clamp_and_log_if_invalid(const char *name, float &value, const LogString *const *param_name_progmem, - float min = 0.0f, float max = 1.0f) { - if (value < min || value > max) { - const auto *param_name = reinterpret_cast( - progmem_read_ptr(reinterpret_cast(param_name_progmem))); - ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); - value = clamp(value, min, max); - } +// Cold-path helper: called only when the caller has already determined the +// value is out of range. Keeping the range check at the caller avoids the +// call-site spill/reload and prologue on the hot path (in-range). The +// `param_name_progmem` argument points into the FIELD_NAMES table in flash; +// `progmem_read_ptr` is a plain `*addr` inline on non-ESP8266 platforms. +static void log_out_of_range_and_clamp_(const char *name, float &value, const LogString *const *param_name_progmem, + float min, float max) { + const auto *param_name = + reinterpret_cast(progmem_read_ptr(reinterpret_cast(param_name_progmem))); + ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); + value = clamp(value, min, max); } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN @@ -296,14 +291,31 @@ LightColorValues LightCall::validate_() { // offset is exactly 12 bytes lower (enforced by the static_asserts below). // Iterating via bit-position arithmetic lets us collapse eight inlined // clamp/copy blocks into a single loop. - static_assert(FLAG_HAS_BRIGHTNESS == 1u << 0, "clamp loop assumes bit 0"); - static_assert(FLAG_HAS_WARM_WHITE == 1u << 7, "clamp loop assumes bit 7"); - static_assert(offsetof(LightCall, warm_white_) - offsetof(LightCall, brightness_) == 7 * sizeof(float), - "LightCall clamp fields must be contiguous"); - static_assert(offsetof(LightColorValues, warm_white_) - offsetof(LightColorValues, brightness_) == 7 * sizeof(float), - "LightColorValues clamp fields must be contiguous"); - static_assert(offsetof(LightCall, brightness_) - offsetof(LightColorValues, brightness_) == 12, - "LightCall and LightColorValues clamp fields must have constant byte-offset delta"); + constexpr size_t SRC_BASE = offsetof(LightCall, brightness_); + constexpr size_t SRC_TO_DST_DELTA = SRC_BASE - offsetof(LightColorValues, brightness_); + + // Per-field layout assertions: each clamp field must sit at its bit-indexed + // slot in both LightCall and LightColorValues, with the same byte-offset + // delta. A reorder of any single field (in either struct) trips the assert + // pointing at that field, so failures name the exact member at fault. + // The one case these cannot catch is a synchronized reorder in both structs + // plus FIELD_NAMES — that would compile silently, but requires deliberate + // three-place changes by the refactorer. +#define ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(bit, flag_suffix, member) \ + static_assert(FLAG_HAS_##flag_suffix == 1u << (bit), "FLAG_HAS_" #flag_suffix " bit position"); \ + static_assert(offsetof(LightCall, member) == SRC_BASE + (bit) * sizeof(float), \ + "LightCall::" #member " must be at bit-indexed slot"); \ + static_assert(offsetof(LightColorValues, member) == SRC_BASE + (bit) * sizeof(float) - SRC_TO_DST_DELTA, \ + "LightColorValues::" #member " must match LightCall delta") + ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(0, BRIGHTNESS, brightness_); + ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(1, COLOR_BRIGHTNESS, color_brightness_); + ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(2, RED, red_); + ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(3, GREEN, green_); + ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(4, BLUE, blue_); + ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(5, WHITE, white_); + ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(6, COLD_WHITE, cold_white_); + ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(7, WARM_WHITE, warm_white_); +#undef ESPHOME_LIGHT_ASSERT_CLAMP_FIELD static const LogString *const FIELD_NAMES[8] PROGMEM = { LOG_STR("Brightness"), // FLAG_HAS_BRIGHTNESS (bit 0) @@ -315,29 +327,35 @@ LightColorValues LightCall::validate_() { LOG_STR("Cold white"), // FLAG_HAS_COLD_WHITE (bit 6) LOG_STR("Warm white"), // FLAG_HAS_WARM_WHITE (bit 7) }; - constexpr size_t SRC_BASE = offsetof(LightCall, brightness_); - constexpr size_t SRC_TO_DST_DELTA = SRC_BASE - offsetof(LightColorValues, brightness_); - uint8_t active = this->flags_ & CLAMP_FLAGS_MASK; - if (active != 0) { - auto *self = reinterpret_cast(this); - auto *out = reinterpret_cast(&v); - for (uint8_t bit = 0; bit < 8; bit++) { - if (!(active & (1u << bit))) - continue; - const size_t src_off = SRC_BASE + bit * sizeof(float); - float &f = *reinterpret_cast(self + src_off); - clamp_and_log_if_invalid(name, f, &FIELD_NAMES[bit]); - *reinterpret_cast(out + src_off - SRC_TO_DST_DELTA) = f; - } + // The static_asserts above guarantee the eight clampable floats are laid + // out consecutively starting at brightness_ in both structs, so we can + // treat `&brightness_` as the base of an 8-element float array and index + // by bit position directly. Iterate only the set bits via __builtin_ctz + + // clear-lowest-bit: HA can drive high-frequency automations through + // perform(), so the hot path runs in O(popcount) instead of always + // scanning all eight slots. The range check is inlined here (cold path + // is the out-of-line helper) so an in-range value skips the call entirely. + float *const src_fields = &this->brightness_; + float *const dst_fields = &v.brightness_; + unsigned active = this->flags_ & CLAMP_FLAGS_MASK; + while (active != 0) { + unsigned bit = __builtin_ctz(active); + active &= active - 1; // clear lowest set bit + float &value = src_fields[bit]; + if (value < 0.0f || value > 1.0f) + log_out_of_range_and_clamp_(name, value, &FIELD_NAMES[bit], 0.0f, 1.0f); + dst_fields[bit] = value; } // color_temperature uses a dynamic range from the light's traits and is // handled separately. if (this->has_color_temperature()) { static const LogString *const CT_NAME PROGMEM = LOG_STR("Color temperature"); - clamp_and_log_if_invalid(name, this->color_temperature_, &CT_NAME, traits.get_min_mireds(), - traits.get_max_mireds()); + const float ct_min = traits.get_min_mireds(); + const float ct_max = traits.get_max_mireds(); + if (this->color_temperature_ < ct_min || this->color_temperature_ > ct_max) + log_out_of_range_and_clamp_(name, this->color_temperature_, &CT_NAME, ct_min, ct_max); v.color_temperature_ = this->color_temperature_; } From 36881166a8868676a48cc23350cfb9916b9cee99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:16:44 -1000 Subject: [PATCH 0152/2214] [light] Replace soft-float range check with union bit-cast + unsigned compare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inlined `value < 0.0f || value > 1.0f` check in the clamp loop costs ~50 B per iteration on ESP8266 (two libgcc soft-float calls with register spills). IEEE 754 floats in [0.0f, 1.0f] have bit patterns in [0x00000000, 0x3F800000]; anything out of range — values > 1.0f, negatives (sign bit set → huge unsigned interpretation), NaN, Infinity — has a strictly larger unsigned interpretation. A single `pun.u > 0x3F800000u` covers every case. Using a union for the type-pun rather than memcpy/bit_cast because those don't optimize to a no-op on xtensa-gcc (same reason api/proto.h's float_to_raw() uses a union). The loop body is now two instructions for the range check: l32i a2, a9, 0 ; load raw u32 bgeu a10, a2, ... ; compare against pre-hoisted 1.0f bits Size delta vs. the prior float-compare form: ESP32-IDF: validate_ -16 B, net -12 B ESP8266: validate_ -20 B, net -20 B vs. dev baseline (isolated light build, matched funcs): ESP32-IDF: -103 B code, +32 B PROGMEM table = -71 B net ESP8266: -42 B code, +32 B PROGMEM table = -10 B net --- esphome/components/light/light_call.cpp | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index df65883ed6..045a4f2935 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -334,8 +334,16 @@ LightColorValues LightCall::validate_() { // by bit position directly. Iterate only the set bits via __builtin_ctz + // clear-lowest-bit: HA can drive high-frequency automations through // perform(), so the hot path runs in O(popcount) instead of always - // scanning all eight slots. The range check is inlined here (cold path - // is the out-of-line helper) so an in-range value skips the call entirely. + // scanning all eight slots. + // + // The range check is done on the IEEE 754 bit pattern as an unsigned int, + // not on the float itself. Values in [0.0f, 1.0f] have bits in + // [0x00000000, 0x3F800000]; anything greater (as unsigned) is out of range: + // values > 1.0f have a larger bit pattern, and negative values have the + // sign bit (0x80000000) set which makes their unsigned interpretation + // enormous. One unsigned compare replaces two soft-float __ltsf2/__gtsf2 + // calls on ESP8266 and is essentially free on targets with an FPU too. + constexpr uint32_t ONE_F_BITS = 0x3F800000u; // bit pattern of 1.0f float *const src_fields = &this->brightness_; float *const dst_fields = &v.brightness_; unsigned active = this->flags_ & CLAMP_FLAGS_MASK; @@ -343,7 +351,14 @@ LightColorValues LightCall::validate_() { unsigned bit = __builtin_ctz(active); active &= active - 1; // clear lowest set bit float &value = src_fields[bit]; - if (value < 0.0f || value > 1.0f) + // Union type-pun (GCC/Clang extension): bit_cast/memcpy don't optimize to + // a no-op on xtensa-gcc, same reasoning as api/proto.h float_to_raw(). + union { + float f; + uint32_t u; + } pun; + pun.f = value; + if (pun.u > ONE_F_BITS) log_out_of_range_and_clamp_(name, value, &FIELD_NAMES[bit], 0.0f, 1.0f); dst_fields[bit] = value; } From 8eb8b3dc0fed6d394b5ff418d316052664fbde77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:27:09 -1000 Subject: [PATCH 0153/2214] [light] Promote bit-pattern clamp to LightColorValues setters and add layout asserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `float_out_of_unit_range()` / add `clamp_unit_float()` to light_color_values.h so the nine `set_*(float)` setters can use the unsigned bit-pattern clamp instead of `std::clamp(x, 0.0f, 1.0f)`. `std::clamp` expands to two soft-float `__ltsf2`/`__gtsf2` calls per invocation on ESP8266 — replacing it with a single unsigned compare saves code across every caller of these setters (StrobeLightEffect, the 11-arg LightColorValues constructor, external components). Split the cold-path helper: `log_value_out_of_range_()` now only logs, and each caller applies the clamp strategy appropriate to its range (`clamp_unit_float` for the 8-field loop, `std::clamp` for color temperature's runtime-variable range). Add layout/format assertions: - std::is_standard_layout_v on LightCall and LightColorValues so the offsetof arithmetic in the clamp loop is well-defined. - sizeof(float) == 4 and is_iec559 so the bit-pattern trick is valid. A direct __builtin_bit_cast check would be cleaner but is not available on the ESP8266 xtensa toolchain. Text-section delta vs. prior commit (isolated light build): ESP32-IDF: .flash.text 137760 -> 136988 (-772 B) ESP8266: .irom0.text 268440 -> 267912 (-528 B) --- esphome/components/light/light_call.cpp | 59 +++++++++--------- esphome/components/light/light_color_values.h | 62 ++++++++++++++++--- 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 045a4f2935..bc0b12aa77 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -1,4 +1,5 @@ #include +#include #include "light_call.h" #include "light_state.h" @@ -10,17 +11,19 @@ namespace esphome::light { static const char *const TAG = "light"; -// Cold-path helper: called only when the caller has already determined the -// value is out of range. Keeping the range check at the caller avoids the -// call-site spill/reload and prologue on the hot path (in-range). The -// `param_name_progmem` argument points into the FIELD_NAMES table in flash; -// `progmem_read_ptr` is a plain `*addr` inline on non-ESP8266 platforms. -static void log_out_of_range_and_clamp_(const char *name, float &value, const LogString *const *param_name_progmem, - float min, float max) { +// Cold-path logger: called only after the caller has determined `value` is +// out of range. Does not clamp — the caller handles that with the strategy +// appropriate to its range (bit-pattern clamp_unit_float for [0,1] on the +// hot path, std::clamp for arbitrary ranges like color_temperature). Keeping +// the range check at the caller avoids the call-site spill/reload and +// prologue when the value is in range. The `param_name_progmem` argument +// points into the FIELD_NAMES table in flash; `progmem_read_ptr` is a plain +// `*addr` inline on non-ESP8266 platforms. +static void log_value_out_of_range_(const char *name, float value, const LogString *const *param_name_progmem, + float min, float max) { const auto *param_name = reinterpret_cast(progmem_read_ptr(reinterpret_cast(param_name_progmem))); ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); - value = clamp(value, min, max); } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN @@ -291,6 +294,15 @@ LightColorValues LightCall::validate_() { // offset is exactly 12 bytes lower (enforced by the static_asserts below). // Iterating via bit-position arithmetic lets us collapse eight inlined // clamp/copy blocks into a single loop. + // offsetof is only well-defined on standard-layout types (C++17 relaxed it + // slightly, but GCC still warns on non-standard-layout). Verify here rather + // than relying on diagnostics: a future change that adds a virtual base, a + // non-public data member mixed with public ones, or a derived-class data + // member would break the layout contract below. + static_assert(std::is_standard_layout_v, "LightCall must be standard-layout for offsetof arithmetic"); + static_assert(std::is_standard_layout_v, + "LightColorValues must be standard-layout for offsetof arithmetic"); + constexpr size_t SRC_BASE = offsetof(LightCall, brightness_); constexpr size_t SRC_TO_DST_DELTA = SRC_BASE - offsetof(LightColorValues, brightness_); @@ -335,15 +347,6 @@ LightColorValues LightCall::validate_() { // clear-lowest-bit: HA can drive high-frequency automations through // perform(), so the hot path runs in O(popcount) instead of always // scanning all eight slots. - // - // The range check is done on the IEEE 754 bit pattern as an unsigned int, - // not on the float itself. Values in [0.0f, 1.0f] have bits in - // [0x00000000, 0x3F800000]; anything greater (as unsigned) is out of range: - // values > 1.0f have a larger bit pattern, and negative values have the - // sign bit (0x80000000) set which makes their unsigned interpretation - // enormous. One unsigned compare replaces two soft-float __ltsf2/__gtsf2 - // calls on ESP8266 and is essentially free on targets with an FPU too. - constexpr uint32_t ONE_F_BITS = 0x3F800000u; // bit pattern of 1.0f float *const src_fields = &this->brightness_; float *const dst_fields = &v.brightness_; unsigned active = this->flags_ & CLAMP_FLAGS_MASK; @@ -351,26 +354,24 @@ LightColorValues LightCall::validate_() { unsigned bit = __builtin_ctz(active); active &= active - 1; // clear lowest set bit float &value = src_fields[bit]; - // Union type-pun (GCC/Clang extension): bit_cast/memcpy don't optimize to - // a no-op on xtensa-gcc, same reasoning as api/proto.h float_to_raw(). - union { - float f; - uint32_t u; - } pun; - pun.f = value; - if (pun.u > ONE_F_BITS) - log_out_of_range_and_clamp_(name, value, &FIELD_NAMES[bit], 0.0f, 1.0f); + if (float_out_of_unit_range(value)) { + log_value_out_of_range_(name, value, &FIELD_NAMES[bit], 0.0f, 1.0f); + value = clamp_unit_float(value); + } dst_fields[bit] = value; } // color_temperature uses a dynamic range from the light's traits and is - // handled separately. + // handled separately. No bit-pattern shortcut here because the range is + // runtime-variable. if (this->has_color_temperature()) { static const LogString *const CT_NAME PROGMEM = LOG_STR("Color temperature"); const float ct_min = traits.get_min_mireds(); const float ct_max = traits.get_max_mireds(); - if (this->color_temperature_ < ct_min || this->color_temperature_ > ct_max) - log_out_of_range_and_clamp_(name, this->color_temperature_, &CT_NAME, ct_min, ct_max); + if (this->color_temperature_ < ct_min || this->color_temperature_ > ct_max) { + log_value_out_of_range_(name, this->color_temperature_, &CT_NAME, ct_min, ct_max); + this->color_temperature_ = clamp(this->color_temperature_, ct_min, ct_max); + } v.color_temperature_ = this->color_temperature_; } diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index c520f4dc25..74af29fe59 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -3,11 +3,55 @@ #include "esphome/core/helpers.h" #include "color_mode.h" #include +#include +#include namespace esphome::light { inline static uint8_t to_uint8_scale(float x) { return static_cast(roundf(x * 255.0f)); } +// IEEE 754 bit pattern of 1.0f. Floats in [0.0f, 1.0f] have unsigned bit +// pattern <= this value; negatives have the sign bit set (→ huge unsigned), +// values > 1.0f have a larger exponent, and NaN/Infinity also exceed this. +// Verify the platform actually provides IEEE 754 single-precision floats so +// the bit-pattern tricks below are well-defined. +static constexpr uint32_t ONE_F_BITS = 0x3F800000u; +// sizeof check + is_iec559 together pin the format to IEEE 754 single-precision, +// which fixes the bit pattern of 1.0f as 0x3F800000. A direct bit-cast check +// would be cleaner but __builtin_bit_cast is not available on the older xtensa +// toolchain used for ESP8266. +static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit for bit-pattern range checks"); +static_assert(std::numeric_limits::is_iec559, "IEEE 754 single-precision float required"); + +// Returns true iff `x` is outside [0.0f, 1.0f] via a single unsigned compare on +// its IEEE 754 bit pattern. Uses a union type-pun (GCC/Clang extension) because +// memcpy/bit_cast don't optimize to a no-op on xtensa-gcc (same reasoning as +// api/proto.h's float_to_raw). Replaces two soft-float __ltsf2/__gtsf2 calls +// with one `bltu` on ESP8266 and is free on FPU targets. +inline bool float_out_of_unit_range(float x) { + union { + float f; + uint32_t u; + } pun; + pun.f = x; + return pun.u > ONE_F_BITS; +} + +// Clamps `x` to [0.0f, 1.0f] with no floating-point compares. In-range values +// return via a single branch; out-of-range pick 0.0f for negatives (sign bit +// set) and 1.0f otherwise (> 1.0f, NaN, Infinity). Cheaper than std::clamp on +// ESP8266, which expands to two soft-float calls per invocation. +inline float clamp_unit_float(float x) { + union { + float f; + uint32_t u; + } pun; + pun.f = x; + if (pun.u <= ONE_F_BITS) + return x; + return (pun.u & 0x80000000u) ? 0.0f : 1.0f; +} + /** This class represents the color state for a light object. * * The representation of the color state is dependent on the active color mode. A color mode consists of multiple @@ -220,39 +264,39 @@ class LightColorValues { /// Get the binary true/false state of these light color values. bool is_on() const { return this->get_state() != 0.0f; } /// Set the state of these light color values. In range from 0.0 (off) to 1.0 (on) - void set_state(float state) { this->state_ = clamp(state, 0.0f, 1.0f); } + void set_state(float state) { this->state_ = clamp_unit_float(state); } /// Set the state of these light color values as a binary true/false. void set_state(bool state) { this->state_ = state ? 1.0f : 0.0f; } /// Get the brightness property of these light color values. In range 0.0 to 1.0 float get_brightness() const { return this->brightness_; } /// Set the brightness property of these light color values. In range 0.0 to 1.0 - void set_brightness(float brightness) { this->brightness_ = clamp(brightness, 0.0f, 1.0f); } + void set_brightness(float brightness) { this->brightness_ = clamp_unit_float(brightness); } /// Get the color brightness property of these light color values. In range 0.0 to 1.0 float get_color_brightness() const { return this->color_brightness_; } /// Set the color brightness property of these light color values. In range 0.0 to 1.0 - void set_color_brightness(float brightness) { this->color_brightness_ = clamp(brightness, 0.0f, 1.0f); } + void set_color_brightness(float brightness) { this->color_brightness_ = clamp_unit_float(brightness); } /// Get the red property of these light color values. In range 0.0 to 1.0 float get_red() const { return this->red_; } /// Set the red property of these light color values. In range 0.0 to 1.0 - void set_red(float red) { this->red_ = clamp(red, 0.0f, 1.0f); } + void set_red(float red) { this->red_ = clamp_unit_float(red); } /// Get the green property of these light color values. In range 0.0 to 1.0 float get_green() const { return this->green_; } /// Set the green property of these light color values. In range 0.0 to 1.0 - void set_green(float green) { this->green_ = clamp(green, 0.0f, 1.0f); } + void set_green(float green) { this->green_ = clamp_unit_float(green); } /// Get the blue property of these light color values. In range 0.0 to 1.0 float get_blue() const { return this->blue_; } /// Set the blue property of these light color values. In range 0.0 to 1.0 - void set_blue(float blue) { this->blue_ = clamp(blue, 0.0f, 1.0f); } + void set_blue(float blue) { this->blue_ = clamp_unit_float(blue); } /// Get the white property of these light color values. In range 0.0 to 1.0 float get_white() const { return white_; } /// Set the white property of these light color values. In range 0.0 to 1.0 - void set_white(float white) { this->white_ = clamp(white, 0.0f, 1.0f); } + void set_white(float white) { this->white_ = clamp_unit_float(white); } /// Get the color temperature property of these light color values in mired. float get_color_temperature() const { return this->color_temperature_; } @@ -277,12 +321,12 @@ class LightColorValues { /// Get the cold white property of these light color values. In range 0.0 to 1.0. float get_cold_white() const { return this->cold_white_; } /// Set the cold white property of these light color values. In range 0.0 to 1.0. - void set_cold_white(float cold_white) { this->cold_white_ = clamp(cold_white, 0.0f, 1.0f); } + void set_cold_white(float cold_white) { this->cold_white_ = clamp_unit_float(cold_white); } /// Get the warm white property of these light color values. In range 0.0 to 1.0. float get_warm_white() const { return this->warm_white_; } /// Set the warm white property of these light color values. In range 0.0 to 1.0. - void set_warm_white(float warm_white) { this->warm_white_ = clamp(warm_white, 0.0f, 1.0f); } + void set_warm_white(float warm_white) { this->warm_white_ = clamp_unit_float(warm_white); } friend class LightCall; From de44b8e859bf500721db4a4124785ac9a497bbc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:31:50 -1000 Subject: [PATCH 0154/2214] [light] Tighten comments in validate_ clamp loop and LightColorValues helpers --- esphome/components/light/light_call.cpp | 54 +++++-------------- esphome/components/light/light_call.h | 17 ++---- esphome/components/light/light_color_values.h | 37 ++++--------- 3 files changed, 27 insertions(+), 81 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index bc0b12aa77..43fea14e4e 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -11,14 +11,9 @@ namespace esphome::light { static const char *const TAG = "light"; -// Cold-path logger: called only after the caller has determined `value` is -// out of range. Does not clamp — the caller handles that with the strategy -// appropriate to its range (bit-pattern clamp_unit_float for [0,1] on the -// hot path, std::clamp for arbitrary ranges like color_temperature). Keeping -// the range check at the caller avoids the call-site spill/reload and -// prologue when the value is in range. The `param_name_progmem` argument -// points into the FIELD_NAMES table in flash; `progmem_read_ptr` is a plain -// `*addr` inline on non-ESP8266 platforms. +// Cold-path logger. Caller handles the clamp so the in-range hot path avoids +// the call-site spill/reload. `param_name_progmem` is a pointer into FIELD_NAMES +// in flash; the `progmem_read_ptr` is a no-op on non-ESP8266. static void log_value_out_of_range_(const char *name, float value, const LogString *const *param_name_progmem, float min, float max) { const auto *param_name = @@ -285,34 +280,16 @@ LightColorValues LightCall::validate_() { v.set_state(this->state_); // Clamp the eight [0.0, 1.0] fields and copy them from `this` into `v`. - // - // LightCall and LightColorValues both declare the same eight float fields in - // the same order (brightness_, color_brightness_, red_, green_, blue_, - // white_, cold_white_, warm_white_), and their corresponding flag bits are - // also 0-7 in that order. Under that layout the LightCall offset for field i - // is `offsetof(LightCall, brightness_) + i * 4`, and the LightColorValues - // offset is exactly 12 bytes lower (enforced by the static_asserts below). - // Iterating via bit-position arithmetic lets us collapse eight inlined - // clamp/copy blocks into a single loop. - // offsetof is only well-defined on standard-layout types (C++17 relaxed it - // slightly, but GCC still warns on non-standard-layout). Verify here rather - // than relying on diagnostics: a future change that adds a virtual base, a - // non-public data member mixed with public ones, or a derived-class data - // member would break the layout contract below. - static_assert(std::is_standard_layout_v, "LightCall must be standard-layout for offsetof arithmetic"); - static_assert(std::is_standard_layout_v, - "LightColorValues must be standard-layout for offsetof arithmetic"); + // Both structs declare the same fields in the same order as FieldFlags bits + // 0-7, with a constant byte-offset delta. The asserts below pin that layout + // so the loop can index by bit position; any single-field reorder trips the + // assert naming the field at fault. + static_assert(std::is_standard_layout_v, "LightCall must be standard-layout"); + static_assert(std::is_standard_layout_v, "LightColorValues must be standard-layout"); constexpr size_t SRC_BASE = offsetof(LightCall, brightness_); constexpr size_t SRC_TO_DST_DELTA = SRC_BASE - offsetof(LightColorValues, brightness_); - // Per-field layout assertions: each clamp field must sit at its bit-indexed - // slot in both LightCall and LightColorValues, with the same byte-offset - // delta. A reorder of any single field (in either struct) trips the assert - // pointing at that field, so failures name the exact member at fault. - // The one case these cannot catch is a synchronized reorder in both structs - // plus FIELD_NAMES — that would compile silently, but requires deliberate - // three-place changes by the refactorer. #define ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(bit, flag_suffix, member) \ static_assert(FLAG_HAS_##flag_suffix == 1u << (bit), "FLAG_HAS_" #flag_suffix " bit position"); \ static_assert(offsetof(LightCall, member) == SRC_BASE + (bit) * sizeof(float), \ @@ -340,13 +317,8 @@ LightColorValues LightCall::validate_() { LOG_STR("Warm white"), // FLAG_HAS_WARM_WHITE (bit 7) }; - // The static_asserts above guarantee the eight clampable floats are laid - // out consecutively starting at brightness_ in both structs, so we can - // treat `&brightness_` as the base of an 8-element float array and index - // by bit position directly. Iterate only the set bits via __builtin_ctz + - // clear-lowest-bit: HA can drive high-frequency automations through - // perform(), so the hot path runs in O(popcount) instead of always - // scanning all eight slots. + // Iterate only the set bits (ctz + clear-lowest) so the hot path is + // O(popcount) — HA can drive perform() at high frequency. float *const src_fields = &this->brightness_; float *const dst_fields = &v.brightness_; unsigned active = this->flags_ & CLAMP_FLAGS_MASK; @@ -361,9 +333,7 @@ LightColorValues LightCall::validate_() { dst_fields[bit] = value; } - // color_temperature uses a dynamic range from the light's traits and is - // handled separately. No bit-pattern shortcut here because the range is - // runtime-variable. + // color_temperature has a runtime range from traits — no bit-pattern shortcut. if (this->has_color_temperature()) { static const LogString *const CT_NAME PROGMEM = LOG_STR("Color temperature"); const float ct_min = traits.get_min_mireds(); diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 9f34297dcd..01591143d3 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -195,12 +195,9 @@ class LightCall { /// Some color modes also can be set using non-native parameters, transform those calls. void transform_parameters_(const LightTraits &traits); - // Bitfield flags - each flag indicates whether a corresponding value has been set. - // - // Bits 0-7 are the eight float fields that share the [0.0, 1.0] clamp range, - // in member declaration order. The validate_() clamp loop relies on this - // layout to index into LightCall/LightColorValues via bit-position arithmetic - // without a per-field offset table. Do not reorder without updating the + // Each flag indicates whether the corresponding value has been set. Bits 0-7 + // are the [0.0, 1.0] clamp fields; validate_() iterates them via bit-position + // arithmetic and asserts the layout — don't reorder without matching the // static_asserts in light_call.cpp. enum FieldFlags : uint16_t { FLAG_HAS_BRIGHTNESS = 1 << 0, @@ -246,13 +243,7 @@ class LightCall { LightState *parent_; // Light state values - use flags_ to check if a value has been set. - // Group 4-byte aligned members first. - // - // The eight [0.0, 1.0]-clamped float fields (brightness_ ... warm_white_) - // are declared in the same order as their flag bits (0-7) and the matching - // fields in LightColorValues. validate_() exploits this to iterate them via - // bit-position arithmetic. color_temperature_ has a custom range and lives - // outside that block. + // brightness_..warm_white_ match FieldFlags bits 0-7 in order (see validate_). uint32_t transition_length_; uint32_t flash_length_; uint32_t effect_; diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 74af29fe59..1c41d72716 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -10,24 +10,15 @@ namespace esphome::light { inline static uint8_t to_uint8_scale(float x) { return static_cast(roundf(x * 255.0f)); } -// IEEE 754 bit pattern of 1.0f. Floats in [0.0f, 1.0f] have unsigned bit -// pattern <= this value; negatives have the sign bit set (→ huge unsigned), -// values > 1.0f have a larger exponent, and NaN/Infinity also exceed this. -// Verify the platform actually provides IEEE 754 single-precision floats so -// the bit-pattern tricks below are well-defined. +// Bit pattern of 1.0f. Values in [0.0f, 1.0f] have bits <= this; out-of-range +// values (including negatives, whose sign bit makes their uint32 huge) exceed +// it. Lets a single unsigned compare replace two soft-float calls on ESP8266. static constexpr uint32_t ONE_F_BITS = 0x3F800000u; -// sizeof check + is_iec559 together pin the format to IEEE 754 single-precision, -// which fixes the bit pattern of 1.0f as 0x3F800000. A direct bit-cast check -// would be cleaner but __builtin_bit_cast is not available on the older xtensa -// toolchain used for ESP8266. -static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit for bit-pattern range checks"); -static_assert(std::numeric_limits::is_iec559, "IEEE 754 single-precision float required"); +static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit"); +static_assert(std::numeric_limits::is_iec559, "IEEE 754 float required"); -// Returns true iff `x` is outside [0.0f, 1.0f] via a single unsigned compare on -// its IEEE 754 bit pattern. Uses a union type-pun (GCC/Clang extension) because -// memcpy/bit_cast don't optimize to a no-op on xtensa-gcc (same reasoning as -// api/proto.h's float_to_raw). Replaces two soft-float __ltsf2/__gtsf2 calls -// with one `bltu` on ESP8266 and is free on FPU targets. +// Union type-pun (GCC/Clang extension): memcpy/bit_cast don't fold to a no-op +// on xtensa-gcc. Same reasoning as api/proto.h's float_to_raw(). inline bool float_out_of_unit_range(float x) { union { float f; @@ -37,10 +28,8 @@ inline bool float_out_of_unit_range(float x) { return pun.u > ONE_F_BITS; } -// Clamps `x` to [0.0f, 1.0f] with no floating-point compares. In-range values -// return via a single branch; out-of-range pick 0.0f for negatives (sign bit -// set) and 1.0f otherwise (> 1.0f, NaN, Infinity). Cheaper than std::clamp on -// ESP8266, which expands to two soft-float calls per invocation. +// Clamps to [0.0f, 1.0f] without float compares. Negatives (sign bit set) +// fold to 0.0f; everything else out of range (>1, NaN, Inf) folds to 1.0f. inline float clamp_unit_float(float x) { union { float f; @@ -331,12 +320,8 @@ class LightColorValues { friend class LightCall; protected: - // The eight [0.0, 1.0]-clamped float fields are declared in the same order - // as their flag bits (0-7) in LightCall::FieldFlags and the matching fields - // in LightCall. LightCall::validate_() exploits this layout to iterate and - // copy them via bit-position arithmetic with a constant delta of 12 bytes - // between matching LightCall and LightColorValues members. color_temperature_ - // has a different range and is placed after the clamp block. + // brightness_..warm_white_ match LightCall::FieldFlags bits 0-7 in order. + // LightCall::validate_() relies on this layout via static_asserts. float state_; ///< ON / OFF, float for transition float brightness_; float color_brightness_; From bdd1c413def2a3f164c378252f2570ba898cd342 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:38:44 -1000 Subject: [PATCH 0155/2214] [light] Eliminate static-local guard variables by hoisting names to PROGMEM_STRING_TABLE The PROGMEM_STRING_TABLE expansion is entirely constexpr-initialized, so it doesn't require the per-static thread-safe-init guard variables that the previous `static const LogString *const FIELD_NAMES[8] PROGMEM = { LOG_STR(...) }` form emitted inside validate_() (LOG_STR contains a GCC statement-expression, which is not a constant expression, so GCC fell back to dynamic init with an 8-byte guard). Combined the 8 clamp-field names and "Color temperature" into a single ValidateFieldNames table; index 8 is reserved for CT. log_value_out_of_range_() now takes the resolved `const LogString *` directly, so the helper no longer needs its internal progmem_read_ptr dereference. Impact vs. prior commit (isolated light build): ESP8266 .irom0.text: 267912 -> 267784 (-128 B) ESP8266 .bss guard vars on light symbols: 16 -> 0 (-16 B RAM) ESP32-IDF .flash.text: 136988 -> 136996 (+8 B, marginal) --- esphome/components/light/light_call.cpp | 41 +++++++++++++------------ 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 43fea14e4e..ef344a9a74 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -12,12 +12,8 @@ namespace esphome::light { static const char *const TAG = "light"; // Cold-path logger. Caller handles the clamp so the in-range hot path avoids -// the call-site spill/reload. `param_name_progmem` is a pointer into FIELD_NAMES -// in flash; the `progmem_read_ptr` is a no-op on non-ESP8266. -static void log_value_out_of_range_(const char *name, float value, const LogString *const *param_name_progmem, - float min, float max) { - const auto *param_name = - reinterpret_cast(progmem_read_ptr(reinterpret_cast(param_name_progmem))); +// the call-site spill/reload around the out-of-line call. +static void log_value_out_of_range_(const char *name, float value, const LogString *param_name, float min, float max) { ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); } @@ -59,6 +55,22 @@ static void log_invalid_parameter(const char *name, const LogString *message) { PROGMEM_STRING_TABLE(ColorModeHumanStrings, "Unknown", "On/Off", "Brightness", "White", "Color temperature", "Cold/warm white", "RGB", "RGBW", "RGB + color temperature", "RGB + cold/warm white"); +// Field names for validate_(). PROGMEM_STRING_TABLE uses constexpr init so no +// per-static guard variables in RAM (a plain LOG_STR array of static locals +// would need them because LOG_STR is a statement-expression on ESP8266). +// Indices 0-7 match FieldFlags bits 0-7; index 8 is color_temperature. +PROGMEM_STRING_TABLE(ValidateFieldNames, + "Brightness", // FLAG_HAS_BRIGHTNESS (bit 0) + "Color brightness", // FLAG_HAS_COLOR_BRIGHTNESS (bit 1) + "Red", // FLAG_HAS_RED (bit 2) + "Green", // FLAG_HAS_GREEN (bit 3) + "Blue", // FLAG_HAS_BLUE (bit 4) + "White", // FLAG_HAS_WHITE (bit 5) + "Cold white", // FLAG_HAS_COLD_WHITE (bit 6) + "Warm white", // FLAG_HAS_WARM_WHITE (bit 7) + "Color temperature"); +static constexpr uint8_t VALIDATE_CT_INDEX = 8; + static const LogString *color_mode_to_human(ColorMode color_mode) { return ColorModeHumanStrings::get_log_str(ColorModeBitPolicy::to_bit(color_mode), 0); } @@ -306,17 +318,6 @@ LightColorValues LightCall::validate_() { ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(7, WARM_WHITE, warm_white_); #undef ESPHOME_LIGHT_ASSERT_CLAMP_FIELD - static const LogString *const FIELD_NAMES[8] PROGMEM = { - LOG_STR("Brightness"), // FLAG_HAS_BRIGHTNESS (bit 0) - LOG_STR("Color brightness"), // FLAG_HAS_COLOR_BRIGHTNESS (bit 1) - LOG_STR("Red"), // FLAG_HAS_RED (bit 2) - LOG_STR("Green"), // FLAG_HAS_GREEN (bit 3) - LOG_STR("Blue"), // FLAG_HAS_BLUE (bit 4) - LOG_STR("White"), // FLAG_HAS_WHITE (bit 5) - LOG_STR("Cold white"), // FLAG_HAS_COLD_WHITE (bit 6) - LOG_STR("Warm white"), // FLAG_HAS_WARM_WHITE (bit 7) - }; - // Iterate only the set bits (ctz + clear-lowest) so the hot path is // O(popcount) — HA can drive perform() at high frequency. float *const src_fields = &this->brightness_; @@ -327,7 +328,7 @@ LightColorValues LightCall::validate_() { active &= active - 1; // clear lowest set bit float &value = src_fields[bit]; if (float_out_of_unit_range(value)) { - log_value_out_of_range_(name, value, &FIELD_NAMES[bit], 0.0f, 1.0f); + log_value_out_of_range_(name, value, ValidateFieldNames::get_log_str(bit, 0), 0.0f, 1.0f); value = clamp_unit_float(value); } dst_fields[bit] = value; @@ -335,11 +336,11 @@ LightColorValues LightCall::validate_() { // color_temperature has a runtime range from traits — no bit-pattern shortcut. if (this->has_color_temperature()) { - static const LogString *const CT_NAME PROGMEM = LOG_STR("Color temperature"); const float ct_min = traits.get_min_mireds(); const float ct_max = traits.get_max_mireds(); if (this->color_temperature_ < ct_min || this->color_temperature_ > ct_max) { - log_value_out_of_range_(name, this->color_temperature_, &CT_NAME, ct_min, ct_max); + log_value_out_of_range_(name, this->color_temperature_, ValidateFieldNames::get_log_str(VALIDATE_CT_INDEX, 0), + ct_min, ct_max); this->color_temperature_ = clamp(this->color_temperature_, ct_min, ct_max); } v.color_temperature_ = this->color_temperature_; From 8ef105767424419184be06795a767df2891b299c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:48:00 -1000 Subject: [PATCH 0156/2214] [light] Treat -0.0f as in range in float_out_of_unit_range -0.0f has bit pattern 0x80000000 which exceeds ONE_F_BITS as unsigned, so the check previously flagged it and emitted a spurious out-of-range warning. Add an explicit compare against NEG_ZERO_F_BITS (declared as a named constexpr for readability) so -0.0f takes the in-range fast path. clamp_unit_float() already returned 0.0f for it via the sign-bit branch, so behavior on clamp is unchanged. Caught by Copilot on PR review. --- esphome/components/light/light_color_values.h | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 1c41d72716..7efea56dbb 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -14,18 +14,23 @@ inline static uint8_t to_uint8_scale(float x) { return static_cast(roun // values (including negatives, whose sign bit makes their uint32 huge) exceed // it. Lets a single unsigned compare replace two soft-float calls on ESP8266. static constexpr uint32_t ONE_F_BITS = 0x3F800000u; +// Bit pattern of -0.0f: sign bit set, magnitude zero. Treated as in range. +static constexpr uint32_t NEG_ZERO_F_BITS = 0x80000000u; static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit"); static_assert(std::numeric_limits::is_iec559, "IEEE 754 float required"); // Union type-pun (GCC/Clang extension): memcpy/bit_cast don't fold to a no-op // on xtensa-gcc. Same reasoning as api/proto.h's float_to_raw(). +// -0.0f (bit pattern 0x80000000) exceeds ONE_F_BITS as unsigned but is +// numerically zero and clamps to 0.0f anyway — treat it as in range so we +// don't log a spurious out-of-range warning. inline bool float_out_of_unit_range(float x) { union { float f; uint32_t u; } pun; pun.f = x; - return pun.u > ONE_F_BITS; + return pun.u > ONE_F_BITS && pun.u != NEG_ZERO_F_BITS; } // Clamps to [0.0f, 1.0f] without float compares. Negatives (sign bit set) @@ -38,7 +43,7 @@ inline float clamp_unit_float(float x) { pun.f = x; if (pun.u <= ONE_F_BITS) return x; - return (pun.u & 0x80000000u) ? 0.0f : 1.0f; + return (pun.u & NEG_ZERO_F_BITS) ? 0.0f : 1.0f; // sign bit → negative → clamp to 0 } /** This class represents the color state for a light object. From 6491072a77d15752b25223f489015ba41e63105e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:54:43 -1000 Subject: [PATCH 0157/2214] [light] Alias clamp fields via anonymous-union float[8] to eliminate pointer UB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous `float *p = &this->brightness_; p[bit]` pattern (flagged by Copilot as pointer-into-scalar UB) with a shared anonymous union that exposes brightness_..warm_white_ as unit_fields_[8] in both LightCall and LightColorValues. validate_() now indexes the real array directly — defined behavior — while the named members stay accessible to getters/setters. Code generation is unchanged. The union is declared once via ESPHOME_LIGHT_UNIT_FIELDS_UNION() in light_color_values.h and expanded in both structs. The per-field offsetof static_asserts collapse to a single FieldFlags bit-layout assert since the union guarantees member ↔ array-index alignment. Caught by Copilot on PR review. --- esphome/components/light/light_call.cpp | 62 +++++-------------- esphome/components/light/light_call.h | 15 +---- esphome/components/light/light_color_values.h | 49 ++++++++------- 3 files changed, 44 insertions(+), 82 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index ef344a9a74..7edcd9c82b 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -11,8 +11,8 @@ namespace esphome::light { static const char *const TAG = "light"; -// Cold-path logger. Caller handles the clamp so the in-range hot path avoids -// the call-site spill/reload around the out-of-line call. +// Cold-path logger; caller handles the clamp so the in-range hot path avoids +// the spill/reload around the call. static void log_value_out_of_range_(const char *name, float value, const LogString *param_name, float min, float max) { ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); } @@ -55,20 +55,10 @@ static void log_invalid_parameter(const char *name, const LogString *message) { PROGMEM_STRING_TABLE(ColorModeHumanStrings, "Unknown", "On/Off", "Brightness", "White", "Color temperature", "Cold/warm white", "RGB", "RGBW", "RGB + color temperature", "RGB + cold/warm white"); -// Field names for validate_(). PROGMEM_STRING_TABLE uses constexpr init so no -// per-static guard variables in RAM (a plain LOG_STR array of static locals -// would need them because LOG_STR is a statement-expression on ESP8266). // Indices 0-7 match FieldFlags bits 0-7; index 8 is color_temperature. -PROGMEM_STRING_TABLE(ValidateFieldNames, - "Brightness", // FLAG_HAS_BRIGHTNESS (bit 0) - "Color brightness", // FLAG_HAS_COLOR_BRIGHTNESS (bit 1) - "Red", // FLAG_HAS_RED (bit 2) - "Green", // FLAG_HAS_GREEN (bit 3) - "Blue", // FLAG_HAS_BLUE (bit 4) - "White", // FLAG_HAS_WHITE (bit 5) - "Cold white", // FLAG_HAS_COLD_WHITE (bit 6) - "Warm white", // FLAG_HAS_WARM_WHITE (bit 7) - "Color temperature"); +// PROGMEM_STRING_TABLE is constexpr-init (no RAM guard variable). +PROGMEM_STRING_TABLE(ValidateFieldNames, "Brightness", "Color brightness", "Red", "Green", "Blue", "White", + "Cold white", "Warm white", "Color temperature"); static constexpr uint8_t VALIDATE_CT_INDEX = 8; static const LogString *color_mode_to_human(ColorMode color_mode) { @@ -291,50 +281,30 @@ LightColorValues LightCall::validate_() { if (this->has_state()) v.set_state(this->state_); - // Clamp the eight [0.0, 1.0] fields and copy them from `this` into `v`. - // Both structs declare the same fields in the same order as FieldFlags bits - // 0-7, with a constant byte-offset delta. The asserts below pin that layout - // so the loop can index by bit position; any single-field reorder trips the - // assert naming the field at fault. + // FieldFlags bits 0-7 must match unit_fields_ array indices; the union in + // both structs guarantees brightness_..warm_white_ alias unit_fields_[0..7]. static_assert(std::is_standard_layout_v, "LightCall must be standard-layout"); static_assert(std::is_standard_layout_v, "LightColorValues must be standard-layout"); + static_assert(FLAG_HAS_BRIGHTNESS == 1u << 0 && FLAG_HAS_COLOR_BRIGHTNESS == 1u << 1 && FLAG_HAS_RED == 1u << 2 && + FLAG_HAS_GREEN == 1u << 3 && FLAG_HAS_BLUE == 1u << 4 && FLAG_HAS_WHITE == 1u << 5 && + FLAG_HAS_COLD_WHITE == 1u << 6 && FLAG_HAS_WARM_WHITE == 1u << 7, + "FieldFlags bits 0-7 must match unit_fields_ indices"); - constexpr size_t SRC_BASE = offsetof(LightCall, brightness_); - constexpr size_t SRC_TO_DST_DELTA = SRC_BASE - offsetof(LightColorValues, brightness_); - -#define ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(bit, flag_suffix, member) \ - static_assert(FLAG_HAS_##flag_suffix == 1u << (bit), "FLAG_HAS_" #flag_suffix " bit position"); \ - static_assert(offsetof(LightCall, member) == SRC_BASE + (bit) * sizeof(float), \ - "LightCall::" #member " must be at bit-indexed slot"); \ - static_assert(offsetof(LightColorValues, member) == SRC_BASE + (bit) * sizeof(float) - SRC_TO_DST_DELTA, \ - "LightColorValues::" #member " must match LightCall delta") - ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(0, BRIGHTNESS, brightness_); - ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(1, COLOR_BRIGHTNESS, color_brightness_); - ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(2, RED, red_); - ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(3, GREEN, green_); - ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(4, BLUE, blue_); - ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(5, WHITE, white_); - ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(6, COLD_WHITE, cold_white_); - ESPHOME_LIGHT_ASSERT_CLAMP_FIELD(7, WARM_WHITE, warm_white_); -#undef ESPHOME_LIGHT_ASSERT_CLAMP_FIELD - - // Iterate only the set bits (ctz + clear-lowest) so the hot path is - // O(popcount) — HA can drive perform() at high frequency. - float *const src_fields = &this->brightness_; - float *const dst_fields = &v.brightness_; + // Iterate set bits only (ctz + clear-lowest) — HA can drive perform() + // at high frequency so the hot path is O(popcount). unsigned active = this->flags_ & CLAMP_FLAGS_MASK; while (active != 0) { unsigned bit = __builtin_ctz(active); active &= active - 1; // clear lowest set bit - float &value = src_fields[bit]; + float &value = this->unit_fields_[bit]; if (float_out_of_unit_range(value)) { log_value_out_of_range_(name, value, ValidateFieldNames::get_log_str(bit, 0), 0.0f, 1.0f); value = clamp_unit_float(value); } - dst_fields[bit] = value; + v.unit_fields_[bit] = value; } - // color_temperature has a runtime range from traits — no bit-pattern shortcut. + // color_temperature: runtime range from traits. if (this->has_color_temperature()) { const float ct_min = traits.get_min_mireds(); const float ct_max = traits.get_max_mireds(); diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 01591143d3..054f1b1571 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -195,10 +195,7 @@ class LightCall { /// Some color modes also can be set using non-native parameters, transform those calls. void transform_parameters_(const LightTraits &traits); - // Each flag indicates whether the corresponding value has been set. Bits 0-7 - // are the [0.0, 1.0] clamp fields; validate_() iterates them via bit-position - // arithmetic and asserts the layout — don't reorder without matching the - // static_asserts in light_call.cpp. + // Bits 0-7 index unit_fields_[] in validate_(); don't reorder (asserts in light_call.cpp). enum FieldFlags : uint16_t { FLAG_HAS_BRIGHTNESS = 1 << 0, FLAG_HAS_COLOR_BRIGHTNESS = 1 << 1, @@ -243,18 +240,10 @@ class LightCall { LightState *parent_; // Light state values - use flags_ to check if a value has been set. - // brightness_..warm_white_ match FieldFlags bits 0-7 in order (see validate_). uint32_t transition_length_; uint32_t flash_length_; uint32_t effect_; - float brightness_; - float color_brightness_; - float red_; - float green_; - float blue_; - float white_; - float cold_white_; - float warm_white_; + ESPHOME_LIGHT_UNIT_FIELDS_UNION(); float color_temperature_; // Smaller members at the end for better packing diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 7efea56dbb..43b5e325b8 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -10,20 +10,16 @@ namespace esphome::light { inline static uint8_t to_uint8_scale(float x) { return static_cast(roundf(x * 255.0f)); } -// Bit pattern of 1.0f. Values in [0.0f, 1.0f] have bits <= this; out-of-range -// values (including negatives, whose sign bit makes their uint32 huge) exceed -// it. Lets a single unsigned compare replace two soft-float calls on ESP8266. -static constexpr uint32_t ONE_F_BITS = 0x3F800000u; -// Bit pattern of -0.0f: sign bit set, magnitude zero. Treated as in range. -static constexpr uint32_t NEG_ZERO_F_BITS = 0x80000000u; +// IEEE 754 bit patterns. Values in [0.0f, 1.0f] have bits <= ONE_F_BITS; +// negatives have the sign bit set (→ huge unsigned). A single unsigned compare +// replaces two soft-float __ltsf2/__gtsf2 calls on ESP8266. +static constexpr uint32_t ONE_F_BITS = 0x3F800000u; // 1.0f +static constexpr uint32_t NEG_ZERO_F_BITS = 0x80000000u; // -0.0f / sign-bit mask static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit"); static_assert(std::numeric_limits::is_iec559, "IEEE 754 float required"); -// Union type-pun (GCC/Clang extension): memcpy/bit_cast don't fold to a no-op -// on xtensa-gcc. Same reasoning as api/proto.h's float_to_raw(). -// -0.0f (bit pattern 0x80000000) exceeds ONE_F_BITS as unsigned but is -// numerically zero and clamps to 0.0f anyway — treat it as in range so we -// don't log a spurious out-of-range warning. +// Union pun — memcpy/bit_cast don't fold on xtensa-gcc (see api/proto.h). +// -0.0f counts as in range (clamps to 0.0f anyway; don't log a false warning). inline bool float_out_of_unit_range(float x) { union { float f; @@ -33,8 +29,7 @@ inline bool float_out_of_unit_range(float x) { return pun.u > ONE_F_BITS && pun.u != NEG_ZERO_F_BITS; } -// Clamps to [0.0f, 1.0f] without float compares. Negatives (sign bit set) -// fold to 0.0f; everything else out of range (>1, NaN, Inf) folds to 1.0f. +// Clamps to [0.0f, 1.0f] without float compares. Negatives → 0; >1/NaN/Inf → 1. inline float clamp_unit_float(float x) { union { float f; @@ -46,6 +41,23 @@ inline float clamp_unit_float(float x) { return (pun.u & NEG_ZERO_F_BITS) ? 0.0f : 1.0f; // sign bit → negative → clamp to 0 } +// Shared anonymous union: eight unit-range floats alias unit_fields_[8] so +// LightCall::validate_() can iterate them as a real array. GCC/Clang ext. +#define ESPHOME_LIGHT_UNIT_FIELDS_UNION() \ + union { \ + struct { \ + float brightness_; \ + float color_brightness_; \ + float red_; \ + float green_; \ + float blue_; \ + float white_; \ + float cold_white_; \ + float warm_white_; \ + }; \ + float unit_fields_[8]; \ + } + /** This class represents the color state for a light object. * * The representation of the color state is dependent on the active color mode. A color mode consists of multiple @@ -325,17 +337,8 @@ class LightColorValues { friend class LightCall; protected: - // brightness_..warm_white_ match LightCall::FieldFlags bits 0-7 in order. - // LightCall::validate_() relies on this layout via static_asserts. float state_; ///< ON / OFF, float for transition - float brightness_; - float color_brightness_; - float red_; - float green_; - float blue_; - float white_; - float cold_white_; - float warm_white_; + ESPHOME_LIGHT_UNIT_FIELDS_UNION(); float color_temperature_; ///< Color Temperature in Mired ColorMode color_mode_; }; From 58885c2090cd0e7c337dfc49d79534067bff689c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:55:25 -1000 Subject: [PATCH 0158/2214] [light] Rename log_value_out_of_range_ to drop trailing underscore (clang-tidy) --- esphome/components/light/light_call.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 7edcd9c82b..c9a5af5277 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -13,7 +13,7 @@ static const char *const TAG = "light"; // Cold-path logger; caller handles the clamp so the in-range hot path avoids // the spill/reload around the call. -static void log_value_out_of_range_(const char *name, float value, const LogString *param_name, float min, float max) { +static void log_value_out_of_range(const char *name, float value, const LogString *param_name, float min, float max) { ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); } @@ -298,7 +298,7 @@ LightColorValues LightCall::validate_() { active &= active - 1; // clear lowest set bit float &value = this->unit_fields_[bit]; if (float_out_of_unit_range(value)) { - log_value_out_of_range_(name, value, ValidateFieldNames::get_log_str(bit, 0), 0.0f, 1.0f); + log_value_out_of_range(name, value, ValidateFieldNames::get_log_str(bit, 0), 0.0f, 1.0f); value = clamp_unit_float(value); } v.unit_fields_[bit] = value; @@ -309,8 +309,8 @@ LightColorValues LightCall::validate_() { const float ct_min = traits.get_min_mireds(); const float ct_max = traits.get_max_mireds(); if (this->color_temperature_ < ct_min || this->color_temperature_ > ct_max) { - log_value_out_of_range_(name, this->color_temperature_, ValidateFieldNames::get_log_str(VALIDATE_CT_INDEX, 0), - ct_min, ct_max); + log_value_out_of_range(name, this->color_temperature_, ValidateFieldNames::get_log_str(VALIDATE_CT_INDEX, 0), + ct_min, ct_max); this->color_temperature_ = clamp(this->color_temperature_, ct_min, ct_max); } v.color_temperature_ = this->color_temperature_; From 674fb1d3f6c2ded7f88fc2ed72d4556c58baff92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:57:58 -1000 Subject: [PATCH 0159/2214] [light] Drop redundant is_standard_layout asserts (offsetof no longer used) Since validate_() now accesses unit_fields_[] directly via the union alias, there's no offsetof arithmetic to guard. The FieldFlags bit layout assert is all that's needed. --- esphome/components/light/light_call.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index c9a5af5277..7b28065e4e 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -1,5 +1,4 @@ #include -#include #include "light_call.h" #include "light_state.h" @@ -281,10 +280,7 @@ LightColorValues LightCall::validate_() { if (this->has_state()) v.set_state(this->state_); - // FieldFlags bits 0-7 must match unit_fields_ array indices; the union in - // both structs guarantees brightness_..warm_white_ alias unit_fields_[0..7]. - static_assert(std::is_standard_layout_v, "LightCall must be standard-layout"); - static_assert(std::is_standard_layout_v, "LightColorValues must be standard-layout"); + // FieldFlags bits 0-7 must match unit_fields_ array indices. static_assert(FLAG_HAS_BRIGHTNESS == 1u << 0 && FLAG_HAS_COLOR_BRIGHTNESS == 1u << 1 && FLAG_HAS_RED == 1u << 2 && FLAG_HAS_GREEN == 1u << 3 && FLAG_HAS_BLUE == 1u << 4 && FLAG_HAS_WHITE == 1u << 5 && FLAG_HAS_COLD_WHITE == 1u << 6 && FLAG_HAS_WARM_WHITE == 1u << 7, From c9b6253d248998c5e83daf7abd20b5f892eb25a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 17:01:11 -1000 Subject: [PATCH 0160/2214] [light] Fix stale comments on float_out_of_unit_range / clamp_unit_float --- esphome/components/light/light_color_values.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 43b5e325b8..5cafa9fe82 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -19,7 +19,7 @@ static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit"); static_assert(std::numeric_limits::is_iec559, "IEEE 754 float required"); // Union pun — memcpy/bit_cast don't fold on xtensa-gcc (see api/proto.h). -// -0.0f counts as in range (clamps to 0.0f anyway; don't log a false warning). +// -0.0f is numerically zero so it's reported in range (no warning, no clamp). inline bool float_out_of_unit_range(float x) { union { float f; @@ -29,7 +29,8 @@ inline bool float_out_of_unit_range(float x) { return pun.u > ONE_F_BITS && pun.u != NEG_ZERO_F_BITS; } -// Clamps to [0.0f, 1.0f] without float compares. Negatives → 0; >1/NaN/Inf → 1. +// Clamps to [0.0f, 1.0f] without float compares. Out of range: sign bit set +// (negatives, -NaN, -Inf) → 0.0f; sign bit clear (>1, +NaN, +Inf) → 1.0f. inline float clamp_unit_float(float x) { union { float f; From a0286616f71f0b654f25b443bcc0ae4ba0723bc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 17:16:49 -1000 Subject: [PATCH 0161/2214] [light] Force-inline LightCall::set_flag_/clear_flag_ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are trivial bit operations on a uint16_t (one or/and on flags_), but without ESPHOME_ALWAYS_INLINE each call costs a call0/call8 plus the return/register-window rotation — more work than the body itself. validate_() invokes these ~8 times and transform_parameters_() adds more, so runtime benchmarks should reflect the elimination of the call overhead. Note: a previous attempt at this (#15402) was closed based on flash size deltas alone. This reopens the question with CodSpeed in-loop to measure actual runtime impact. --- esphome/components/light/light_call.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 88d29bd349..39953d0d20 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -222,7 +222,7 @@ class LightCall { inline bool get_save_() { return (this->flags_ & FLAG_SAVE) != 0; } // Helper to set flag - defaults to true for common case - void set_flag_(FieldFlags flag, bool value = true) { + void set_flag_(FieldFlags flag, bool value = true) ESPHOME_ALWAYS_INLINE { if (value) { this->flags_ |= flag; } else { @@ -231,7 +231,7 @@ class LightCall { } // Helper to clear flag - reduces code size for common case - void clear_flag_(FieldFlags flag) { this->flags_ &= ~flag; } + void clear_flag_(FieldFlags flag) ESPHOME_ALWAYS_INLINE { this->flags_ &= ~flag; } // Helper to log unsupported feature and clear flag - reduces code duplication void log_and_clear_unsupported_(FieldFlags flag, const LogString *feature, bool use_color_mode_log); From a325df98da390482e2366f74c3478affd2abea05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 21:59:54 -1000 Subject: [PATCH 0162/2214] [globals] Emit globals.set value lambda with declared global type Pass the global's declared C++ type as the lambda return type so TemplatableFn stores a direct function pointer instead of hitting the deprecated converting trampoline when the value expression deduces to a different type (e.g. an int literal or int-returning lambda assigned to a float global). --- esphome/components/globals/__init__.py | 25 +++++++++++++++++++++++-- tests/components/globals/common.yaml | 8 ++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index ec6730a41c..342b5dace0 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field import hashlib from esphome import automation, codegen as cg, config_validation as cv @@ -8,10 +9,11 @@ from esphome.const import ( CONF_TYPE, CONF_VALUE, ) -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "globals" globals_ns = cg.esphome_ns.namespace("globals") GlobalsComponent = globals_ns.class_("GlobalsComponent", cg.Component) RestoringGlobalsComponent = globals_ns.class_( @@ -60,11 +62,25 @@ MULTI_CONF = True CONFIG_SCHEMA = _globals_schema +@dataclass +class GlobalsData: + # Map of global id -> user-declared C++ type string (e.g. 'float') so the + # globals.set action can emit lambdas with the matching return type. + types: dict = field(default_factory=dict) + + +def _get_data() -> GlobalsData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = GlobalsData() + return CORE.data[DOMAIN] + + # Run with low priority so that namespaces are registered first @coroutine_with_priority(CoroPriority.LATE) async def to_code(config): type_ = cg.RawExpression(config[CONF_TYPE]) restore = config[CONF_RESTORE_VALUE] + _get_data().types[config[CONF_ID]] = config[CONF_TYPE] # Special casing the strings to their own class with a different save/restore mechanism if str(type_) == "std::string" and restore: @@ -108,8 +124,13 @@ async def globals_set_to_code(config, action_id, template_arg, args): 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) + # Use the global's declared C++ type as the lambda return type so + # TemplatableFn stores a direct function pointer instead of going through + # the deprecated converting trampoline when the value expression deduces + # to a different type (e.g. int literal assigned to a float global). + value_type = cg.RawExpression(_get_data().types[config[CONF_ID]]) templ = await cg.templatable( - config[CONF_VALUE], args, None, to_exp=cg.RawExpression, wrap_constant=True + config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression ) cg.add(var.set_value(templ)) return var diff --git a/tests/components/globals/common.yaml b/tests/components/globals/common.yaml index 35dca0624f..6d5721d3be 100644 --- a/tests/components/globals/common.yaml +++ b/tests/components/globals/common.yaml @@ -4,6 +4,14 @@ esphome: - globals.set: id: glob_int value: "10" + # Set a float global with an integer literal - must emit the correct + # return type so TemplatableFn stores a direct function pointer. + - globals.set: + id: glob_float + value: "102" + - globals.set: + id: glob_float + value: !lambda "return 42;" globals: - id: glob_int From 503e299d15efac8cd9d44b899b294234d72dc10d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 22:10:24 -1000 Subject: [PATCH 0163/2214] [globals] Use value_type alias instead of stashing type in CORE.data --- esphome/components/globals/__init__.py | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index 342b5dace0..46725fe6dd 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass, field import hashlib from esphome import automation, codegen as cg, config_validation as cv @@ -9,11 +8,10 @@ from esphome.const import ( CONF_TYPE, CONF_VALUE, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] -DOMAIN = "globals" globals_ns = cg.esphome_ns.namespace("globals") GlobalsComponent = globals_ns.class_("GlobalsComponent", cg.Component) RestoringGlobalsComponent = globals_ns.class_( @@ -62,25 +60,11 @@ MULTI_CONF = True CONFIG_SCHEMA = _globals_schema -@dataclass -class GlobalsData: - # Map of global id -> user-declared C++ type string (e.g. 'float') so the - # globals.set action can emit lambdas with the matching return type. - types: dict = field(default_factory=dict) - - -def _get_data() -> GlobalsData: - if DOMAIN not in CORE.data: - CORE.data[DOMAIN] = GlobalsData() - return CORE.data[DOMAIN] - - # Run with low priority so that namespaces are registered first @coroutine_with_priority(CoroPriority.LATE) async def to_code(config): type_ = cg.RawExpression(config[CONF_TYPE]) restore = config[CONF_RESTORE_VALUE] - _get_data().types[config[CONF_ID]] = config[CONF_TYPE] # Special casing the strings to their own class with a different save/restore mechanism if str(type_) == "std::string" and restore: @@ -124,11 +108,11 @@ async def globals_set_to_code(config, action_id, template_arg, args): 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) - # Use the global's declared C++ type as the lambda return type so + # Use the global's value_type alias as the lambda return type so # TemplatableFn stores a direct function pointer instead of going through # the deprecated converting trampoline when the value expression deduces # to a different type (e.g. int literal assigned to a float global). - value_type = cg.RawExpression(_get_data().types[config[CONF_ID]]) + value_type = cg.RawExpression(f"{full_id.type}::value_type") templ = await cg.templatable( config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression ) From c7b4ee5c601c3e2431a4769506f121d6496bc7cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 22:40:17 -1000 Subject: [PATCH 0164/2214] fix when on_boot at 800 --- .../components/light/addressable_light.cpp | 49 ++++++++++++------- esphome/components/light/addressable_light.h | 4 +- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 5f5f123fe7..d2f5913f4b 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -59,23 +59,11 @@ void AddressableLightTransformer::start() { this->light_.correction_.set_local_brightness(255); this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state()); - // When every LED starts at the same color (the common case: plain turn_on/turn_off on a uniform - // strip), interpolate math-only against a single start color. Avoiding the per-step read-back - // through the 8-bit stored byte prevents gamma round-trip quantization from stalling the fade - // at low values (e.g. gamma 2.8 pre-gamma values <27 round to stored 0, freezing progress). - this->uniform_start_color_.reset(); - if (this->light_.size() > 0) { - Color first = this->light_[0].get(); - bool uniform = true; - for (int32_t i = 1; i < this->light_.size(); i++) { - if (this->light_[i].get() != first) { - uniform = false; - break; - } - } - if (uniform) - this->uniform_start_color_ = first; - } + // Uniformity scan is deferred to the first apply() call. start() can run before the underlying + // LED output's setup() has allocated its frame buffer (e.g. on_boot at priority > HARDWARE + // triggering a transition), and reading through ESPColorView would deref a null buffer. + this->uniform_start_scanned_ = false; + this->uniform_start_is_uniform_ = false; } inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) { @@ -115,7 +103,30 @@ optional AddressableLightTransformer::apply() { // non-linear when applying small deltas. if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) { - if (this->uniform_start_color_.has_value()) { + // Lazy uniformity scan: deferred from start() so the LED output's setup() has run and the + // frame buffer is valid. When every LED already has the same color (the common case: plain + // turn_on/turn_off on a uniform strip), interpolate math-only against a single start color. + // Avoiding the per-step read-back through the 8-bit stored byte prevents gamma round-trip + // quantization from stalling the fade at low values (e.g. gamma 2.8 pre-gamma values <27 + // round to stored 0, freezing progress). + if (!this->uniform_start_scanned_) { + this->uniform_start_scanned_ = true; + if (this->light_.size() > 0) { + Color first = this->light_[0].get(); + bool uniform = true; + for (int32_t i = 1; i < this->light_.size(); i++) { + if (this->light_[i].get() != first) { + uniform = false; + break; + } + } + if (uniform) { + this->uniform_start_color_ = first; + this->uniform_start_is_uniform_ = true; + } + } + } + if (this->uniform_start_is_uniform_) { // All LEDs started at the same color: compute the interpolated value once and write it to // every LED. No read-back, so each LED's stored byte advances through every gamma threshold // as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values. @@ -125,7 +136,7 @@ optional AddressableLightTransformer::apply() { // via its read-back. Concurrent per-LED mutation during a transition isn't a pattern we // support, so this is acceptable. // lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress). - const Color &start = *this->uniform_start_color_; + const Color &start = this->uniform_start_color_; int32_t remaining = int32_t(256.f * (1.f - smoothed_progress)); uint8_t r = subtract_scaled_difference(this->target_color_.red, start.red, remaining); uint8_t g = subtract_scaled_difference(this->target_color_.green, start.green, remaining); diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index c4eabbad2e..0202ad380a 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -115,7 +115,9 @@ class AddressableLightTransformer : public LightTransformer { AddressableLight &light_; float last_transition_progress_{0.0f}; Color target_color_{}; - optional uniform_start_color_{}; + Color uniform_start_color_{}; + bool uniform_start_scanned_{false}; + bool uniform_start_is_uniform_{false}; }; } // namespace esphome::light From 111e2cc9edf3ef23777f17f1f0440bf2ebd12b7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 22:56:32 -1000 Subject: [PATCH 0165/2214] cleanups --- .../test_addressable_light_transition.py | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/tests/integration/test_addressable_light_transition.py b/tests/integration/test_addressable_light_transition.py index 3f99c87766..37fecde595 100644 --- a/tests/integration/test_addressable_light_transition.py +++ b/tests/integration/test_addressable_light_transition.py @@ -41,20 +41,16 @@ async def test_addressable_light_transition( # Track the raw-byte sensor. It polls every 10ms in the fixture, and # ESPHome sensors publish on every change, so we collect a time series. - # Samples are stored as (seconds_since_command_issue, value). Times - # before the command was issued are negative. + # Samples are stored as absolute (loop_time, value); we rebase to the + # command-issue time after the run so pre-command samples are strictly + # negative and reliably excluded. loop = asyncio.get_running_loop() samples: list[tuple[float, float]] = [] - command_time: float | None = None def on_state(state: object) -> None: if not isinstance(state, SensorState) or state.key != sensor.key: return - now = loop.time() - # If the command hasn't been issued yet, use 0 as the origin; - # those samples get negative times and are excluded below. - origin = command_time if command_time is not None else now - samples.append((now - origin, state.state)) + samples.append((loop.time(), state.state)) # InitialStateHelper swallows the first state ESPHome sends per entity # on subscribe, so on_state only sees real post-subscribe updates. @@ -77,33 +73,42 @@ async def test_addressable_light_transition( # Let the full transition run, plus margin for the final sample. await asyncio.sleep(transition_s + 0.2) - # Only look at samples that arrived after the command was issued. - post_command = [(t, v) for (t, v) in samples if t >= 0] + # Rebase to command-issue time. Pre-command samples have t < 0 and are + # excluded; everything else is in seconds since the command was issued. + post_command = [ + (t - command_time, v) for (t, v) in samples if t >= command_time + ] assert post_command, "no sensor samples received after command was issued" # Assertion 1: the transition is not stalled. With the bug, the raw # byte stays at 0 until ~90% of the transition duration. With the fix, # it becomes nonzero in the first ~30% (for gamma 2.8, pre-gamma 76 # clears the gamma threshold at progress ~0.30). Require the first - # nonzero sample to land well before 70% of the transition duration, - # measured from the command-issue time. + # nonzero sample to land well before 50% of the transition duration, + # measured from the command-issue time. The 50% bound (rather than + # 70%) leaves headroom for assertion 2's mid-window check. first_nonzero = next(((t, v) for (t, v) in post_command if v > 0), None) assert first_nonzero is not None, ( "raw byte never rose above 0 during the transition — the fade stalled" ) - assert first_nonzero[0] < transition_s * 0.7, ( + assert first_nonzero[0] < transition_s * 0.5, ( f"raw byte only rose above 0 at t={first_nonzero[0]:.3f}s " - f"(>{transition_s * 0.7:.3f}s after command) — transition is stalling" + f"(>{transition_s * 0.5:.3f}s after command) — transition is stalling" ) - # Assertion 2: by 70% of the transition duration after the command, - # the raw byte should have reached a substantial fraction of its final - # value. This catches "barely moves then jumps at the end" regressions - # that pass assertion 1 but still stall most of the range. - late_samples = [v for (t, v) in post_command if t >= transition_s * 0.7] - assert late_samples, "no samples captured late in transition" - assert max(late_samples) >= 100, ( - f"raw byte peaked at only {max(late_samples)} at/after 70% of " + # Assertion 2: by mid-late transition, the raw byte should have reached + # a substantial fraction of its final value. Bound the window to + # [50%, 90%] of the transition so the post-transition settled value + # (which always reaches 255) can't satisfy this assertion — that would + # let "stays at 0 then jumps at 99%" regressions slip through. + mid_window = [ + v + for (t, v) in post_command + if transition_s * 0.5 <= t <= transition_s * 0.9 + ] + assert mid_window, "no samples captured in mid-transition window" + assert max(mid_window) >= 100, ( + f"raw byte peaked at only {max(mid_window)} between 50%–90% of " "transition (expected >= 100 for white target at gamma 2.8)" ) From 57d9e508ea1165fb13b7fcfa3a9ff0290480f2ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 08:09:00 -1000 Subject: [PATCH 0166/2214] merge --- esphome/core/helpers.cpp | 2 +- esphome/core/helpers.h | 2 +- tests/components/core/test_helpers.cpp | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index d8b5a1e87f..e4ce672570 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -559,7 +559,7 @@ static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy p = uint32_to_str_unchecked(p, scaled / mult); if (accuracy_decimals > 0) { *p++ = '.'; - p = frac_to_str_(p, scaled % mult, mult / 10); + p = frac_to_str_unchecked(p, scaled % mult, mult / 10); } *p = '\0'; return static_cast(p - buf); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d41b8181a4..55f411fdec 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1331,7 +1331,7 @@ inline size_t uint32_to_str(std::span buf, uint32_t v /// Write fractional digits with leading zeros to buffer (internal, no size check). /// frac is the fractional value, divisor is the highest place value (e.g. 100 for 3 digits). /// Returns pointer past last char written. -inline char *frac_to_str_(char *buf, uint32_t frac, uint32_t divisor) { +inline char *frac_to_str_unchecked(char *buf, uint32_t frac, uint32_t divisor) { while (divisor > 0) { *buf++ = '0' + static_cast(frac / divisor); frac %= divisor; diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 68568982e4..5fb77ef753 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -124,11 +124,11 @@ TEST(SmallPow10, One) { EXPECT_EQ(small_pow10(1), 10u); } TEST(SmallPow10, Two) { EXPECT_EQ(small_pow10(2), 100u); } TEST(SmallPow10, Three) { EXPECT_EQ(small_pow10(3), 1000u); } -// --- frac_to_str_() --- +// --- frac_to_str_unchecked() --- TEST(FracToStr, OneDigit) { char buf[8]; - char *end = frac_to_str_(buf, 5, 1); + char *end = frac_to_str_unchecked(buf, 5, 1); *end = '\0'; EXPECT_STREQ(buf, "5"); EXPECT_EQ(end - buf, 1); @@ -136,14 +136,14 @@ TEST(FracToStr, OneDigit) { TEST(FracToStr, TwoDigits) { char buf[8]; - char *end = frac_to_str_(buf, 46, 10); + char *end = frac_to_str_unchecked(buf, 46, 10); *end = '\0'; EXPECT_STREQ(buf, "46"); } TEST(FracToStr, ThreeDigits) { char buf[8]; - char *end = frac_to_str_(buf, 456, 100); + char *end = frac_to_str_unchecked(buf, 456, 100); *end = '\0'; EXPECT_STREQ(buf, "456"); EXPECT_EQ(end - buf, 3); @@ -151,22 +151,22 @@ TEST(FracToStr, ThreeDigits) { TEST(FracToStr, LeadingZeros) { char buf[8]; - char *end = frac_to_str_(buf, 1, 100); + char *end = frac_to_str_unchecked(buf, 1, 100); *end = '\0'; EXPECT_STREQ(buf, "001"); - end = frac_to_str_(buf, 5, 10); + end = frac_to_str_unchecked(buf, 5, 10); *end = '\0'; EXPECT_STREQ(buf, "05"); } TEST(FracToStr, AllZeros) { char buf[8]; - char *end = frac_to_str_(buf, 0, 100); + char *end = frac_to_str_unchecked(buf, 0, 100); *end = '\0'; EXPECT_STREQ(buf, "000"); - end = frac_to_str_(buf, 0, 1); + end = frac_to_str_unchecked(buf, 0, 1); *end = '\0'; EXPECT_STREQ(buf, "0"); } @@ -174,7 +174,7 @@ TEST(FracToStr, AllZeros) { TEST(FracToStr, ZeroDivisor) { char buf[8]; buf[0] = 'X'; - char *end = frac_to_str_(buf, 0, 0); + char *end = frac_to_str_unchecked(buf, 0, 0); EXPECT_EQ(end, buf); // writes nothing } From 5066171a9daee4fbf0751a8cd012188d6cf6da54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 08:36:09 -1000 Subject: [PATCH 0167/2214] Address Copilot review: uint32 overflow guard, docstring, test namespace/include --- esphome/core/helpers.cpp | 18 ++++++++++++------ esphome/core/helpers.h | 5 ++++- tests/components/core/test_value_accuracy.cpp | 5 +++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index e4ce672570..ede1640af2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -545,17 +545,19 @@ std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { // Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). // Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. -static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy_decimals) { +// Caller must guarantee value is finite and |value| * mult fits in uint32_t. +static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy_decimals, uint32_t mult) { char *p = buf; if (std::signbit(value)) { *p++ = '-'; value = -value; } - uint32_t mult = small_pow10(accuracy_decimals); // Cast to double for the multiply to match snprintf's rounding precision. // float*int loses bits at exact-half boundaries (e.g. 23.45f*10 = 234.5 in float, // but snprintf sees 234.500007... via double promotion and rounds differently). - uint32_t scaled = static_cast(lrint(static_cast(value) * mult)); + // llrint returns long long so the result fits even on 32-bit targets where + // long is 32-bit; caller has already bounded |value * mult| to UINT32_MAX. + uint32_t scaled = static_cast(llrint(static_cast(value) * mult)); p = uint32_to_str_unchecked(p, scaled / mult); if (accuracy_decimals > 0) { *p++ = '.'; @@ -568,12 +570,16 @@ static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy size_t value_accuracy_to_buf(std::span buf, float value, int8_t accuracy_decimals) { normalize_accuracy_decimals(value, accuracy_decimals); - // Fast path for accuracy 0-3 and finite values + // Fast path for accuracy 0-3, finite values whose scaled magnitude fits in uint32_t. + // For 3 decimals that's |value| < ~4.29e6; larger totals fall through to snprintf. if (accuracy_decimals <= 3 && std::isfinite(value)) { - return value_accuracy_to_buf_fast(buf.data(), value, accuracy_decimals); + const uint32_t mult = small_pow10(accuracy_decimals); + if (std::fabs(value) < static_cast(UINT32_MAX) / mult) { + return value_accuracy_to_buf_fast(buf.data(), value, accuracy_decimals, mult); + } } - // Fallback for NaN/Inf/high accuracy + // Fallback for NaN/Inf/high accuracy/out-of-range int len = snprintf(buf.data(), buf.size(), "%.*f", accuracy_decimals, value); if (len < 0) return 0; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 55f411fdec..5b4e034d8b 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1296,7 +1296,10 @@ inline char *int8_to_str(char *buf, int8_t val) { } /// Append a separator char and a string to a buffer, respecting remaining space. -/// Returns pointer past last char written (null terminator is written). +/// Returns pointer past last char written. On success (remaining >= 2) a null +/// terminator is written after the copied string. If remaining < 2 nothing is +/// written (not even a terminator) and `buf` is returned unchanged — callers +/// needing a terminated buffer in that case must ensure one is already present. inline char *buf_append_sep_str(char *buf, size_t remaining, char separator, const char *str, size_t str_len) { if (remaining < 2) { return buf; diff --git a/tests/components/core/test_value_accuracy.cpp b/tests/components/core/test_value_accuracy.cpp index a1fba90acf..6bd8976acc 100644 --- a/tests/components/core/test_value_accuracy.cpp +++ b/tests/components/core/test_value_accuracy.cpp @@ -3,10 +3,11 @@ #include #include #include +#include #include "esphome/core/helpers.h" -namespace esphome::testing { +namespace esphome::core::testing { // Helper to call value_accuracy_to_buf and return as string static std::string va_to_string(float value, int8_t accuracy_decimals) { @@ -148,4 +149,4 @@ TEST(ValueAccuracyToBuf, ReturnsCorrectLength) { EXPECT_EQ(strlen(buf), len); } -} // namespace esphome::testing +} // namespace esphome::core::testing From 5ae9ddc46a66c5041605fd9663b736074de75c03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 08:41:43 -1000 Subject: [PATCH 0168/2214] buf_append_sep_str: always null-terminate when remaining >= 1 --- esphome/core/helpers.h | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 5b4e034d8b..4bb958c40e 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1296,12 +1296,14 @@ inline char *int8_to_str(char *buf, int8_t val) { } /// Append a separator char and a string to a buffer, respecting remaining space. -/// Returns pointer past last char written. On success (remaining >= 2) a null -/// terminator is written after the copied string. If remaining < 2 nothing is -/// written (not even a terminator) and `buf` is returned unchanged — callers -/// needing a terminated buffer in that case must ensure one is already present. +/// Returns pointer past last char written. The buffer is always null-terminated +/// when remaining >= 1 (even on the no-room early-return), so callers always get +/// a valid C string. inline char *buf_append_sep_str(char *buf, size_t remaining, char separator, const char *str, size_t str_len) { if (remaining < 2) { + if (remaining >= 1) { + *buf = '\0'; + } return buf; } *buf++ = separator; From 6d894dd6ee8e65467861681cdc8aeea8c7328962 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 12 Apr 2026 23:48:30 +0200 Subject: [PATCH 0169/2214] [packages] fix support `packages: !include mypackages.yaml` (#15677) --- esphome/components/packages/__init__.py | 13 +++--- .../component_tests/packages/test_packages.py | 45 +++++++++++++++++++ ...13-packages_as_included_list.approved.yaml | 3 ++ .../13-packages_as_included_list.input.yaml | 4 ++ .../substitutions/13-packages_list.yaml | 2 + ...14-packages_as_included_dict.approved.yaml | 3 ++ .../14-packages_as_included_dict.input.yaml | 4 ++ .../substitutions/14-packages_dict.yaml | 3 ++ 8 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/13-packages_list.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/14-packages_dict.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 3a15b5b95a..3f3df75351 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -321,12 +321,15 @@ def _walk_packages( return config packages = config[CONF_PACKAGES] - if not isinstance(packages, (dict, list)): - raise cv.Invalid( - f"Packages must be a key to value mapping or list, got {type(packages)} instead" - ) - with cv.prepend_path(CONF_PACKAGES): + if isinstance(packages, yaml_util.IncludeFile): + # If the packages key is an IncludeFile, resolve it first before processing. + packages, _ = resolve_include(packages, [], context, strict_undefined=False) + if not isinstance(packages, (dict, list)): + raise cv.Invalid( + f"Packages must be a key to value mapping or list, got {type(packages)} instead" + ) + if not isinstance(packages, dict): _walk_package_list(packages, callback, context) elif (result := _walk_package_dict(packages, callback, context)) is not None: diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 0b828d757e..cd91c4d8cb 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -1106,6 +1106,51 @@ def test_packages_invalid_type_raises() -> None: do_packages_pass(config) +@patch("esphome.components.packages.resolve_include") +def test_packages_include_file_resolves_to_list(mock_resolve_include) -> None: + """When packages: is an IncludeFile that resolves to a list, it is processed correctly.""" + include_file = MagicMock(spec=IncludeFile) + package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + mock_resolve_include.return_value = ([package_content], None) + + config = {CONF_PACKAGES: include_file} + result = do_packages_pass(config) + result = merge_packages(result) + + assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + + +@patch("esphome.components.packages.resolve_include") +def test_packages_include_file_resolves_to_dict(mock_resolve_include) -> None: + """When packages: is an IncludeFile that resolves to a dict, it is processed correctly.""" + include_file = MagicMock(spec=IncludeFile) + package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + mock_resolve_include.return_value = ({"network": package_content}, None) + + config = {CONF_PACKAGES: include_file} + result = do_packages_pass(config) + result = merge_packages(result) + + assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + + +@patch("esphome.components.packages.resolve_include") +def test_packages_include_file_resolves_to_invalid_type_raises( + mock_resolve_include, +) -> None: + """When packages: is an IncludeFile that resolves to an invalid type, cv.Invalid is raised.""" + include_file = MagicMock(spec=IncludeFile) + mock_resolve_include.return_value = ("not_a_dict_or_list", None) + + config = {CONF_PACKAGES: include_file} + with pytest.raises( + cv.Invalid, match="Packages must be a key to value mapping or list" + ) as exc_info: + do_packages_pass(config) + + assert exc_info.value.path == [CONF_PACKAGES] + + @pytest.mark.parametrize( "invalid_package", [ diff --git a/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.approved.yaml b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.approved.yaml new file mode 100644 index 0000000000..7863def190 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.approved.yaml @@ -0,0 +1,3 @@ +wifi: + password: pkg_password + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.input.yaml b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.input.yaml new file mode 100644 index 0000000000..7a3b4970db --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.input.yaml @@ -0,0 +1,4 @@ +packages: !include 13-packages_list.yaml + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/13-packages_list.yaml b/tests/unit_tests/fixtures/substitutions/13-packages_list.yaml new file mode 100644 index 0000000000..23161db3d3 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/13-packages_list.yaml @@ -0,0 +1,2 @@ +- wifi: + password: pkg_password diff --git a/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.approved.yaml b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.approved.yaml new file mode 100644 index 0000000000..7863def190 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.approved.yaml @@ -0,0 +1,3 @@ +wifi: + password: pkg_password + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.input.yaml b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.input.yaml new file mode 100644 index 0000000000..8b9fc5ec3a --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.input.yaml @@ -0,0 +1,4 @@ +packages: !include 14-packages_dict.yaml + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/14-packages_dict.yaml b/tests/unit_tests/fixtures/substitutions/14-packages_dict.yaml new file mode 100644 index 0000000000..55e8b38a43 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/14-packages_dict.yaml @@ -0,0 +1,3 @@ +network: + wifi: + password: pkg_password From c3769e4fcea761dc264d323e688d5cc51d22e57f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:51:55 -0400 Subject: [PATCH 0170/2214] [core] Fix PlatformIO progress bar rendering in subprocess mode (#15681) --- esphome/platformio_api.py | 3 --- esphome/platformio_runner.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index e9719f7dcd..fc21977fdd 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -65,9 +65,6 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("UV_HTTP_RETRIES", "10") cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args) - if not CORE.verbose: - kwargs["filter_lines"] = FILTER_PLATFORMIO_LINES - return run_external_process(*cmd, **kwargs) diff --git a/esphome/platformio_runner.py b/esphome/platformio_runner.py index 408d49d1a6..92700d5c42 100644 --- a/esphome/platformio_runner.py +++ b/esphome/platformio_runner.py @@ -105,6 +105,36 @@ def main() -> int: patch_structhash() patch_file_downloader() + # Wrap stdout/stderr with RedirectText before PlatformIO runs: + # + # 1. RedirectText.isatty() unconditionally returns True. Click, tqdm, and + # PlatformIO's own progress-bar code check ``stream.isatty()`` to + # decide whether to emit TTY-format output (``\r`` cursor moves, ANSI + # colors, fancy progress bars). With the wrapper in place they always + # emit TTY format, even when our real stdout is a pipe to the parent + # process. Downstream consumers (local terminals and the Home + # Assistant dashboard log viewer) render the TTY control sequences + # correctly, so the user sees real progress bars. + # + # 2. FILTER_PLATFORMIO_LINES is applied inside RedirectText.write() in + # this subprocess, so noisy PlatformIO output is dropped before it + # ever leaves the runner. This replaces the parent-side filtering + # that was lost when we switched from in-process to subprocess — the + # parent's ``subprocess.run`` uses ``.fileno()`` on RedirectText and + # bypasses its ``write()`` path entirely. + # + # Filtering is disabled when the user passed -v / --verbose to + # ``esphome compile``, preserving the previous in-process behavior where + # verbose mode let all PlatformIO output through unfiltered. + from esphome.platformio_api import FILTER_PLATFORMIO_LINES + from esphome.util import RedirectText + + 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) + import platformio.__main__ return platformio.__main__.main() or 0 From 3b2caa1f5b11456dcdd93823d8040e63e1bb5609 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:23:49 +0000 Subject: [PATCH 0171/2214] Bump aioesphomeapi from 44.13.3 to 44.14.0 (#15695) 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 e4a019a490..339b5d2919 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.13.3 +aioesphomeapi==44.14.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 20d49f9a7c6e94f5f31f8a74827cd108b2d79d5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 21:42:31 -1000 Subject: [PATCH 0172/2214] [api] Add speed_optimized proto option for hot encode paths (#15691) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/api/api.proto | 2 ++ esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 16 ++++++++++++---- script/api_protobuf/api_protobuf.py | 14 ++++++++++++-- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e4d0c2d16d..4a9a6f9051 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -671,6 +671,7 @@ message SensorStateResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SENSOR"; option (no_delay) = true; + option (speed_optimized) = true; fixed32 key = 1 [(force) = true]; float state = 2; @@ -1638,6 +1639,7 @@ message BluetoothLERawAdvertisementsResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_BLUETOOTH_PROXY"; option (no_delay) = true; + option (speed_optimized) = true; repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"]; } diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index dacc290e31..d5d0b37e8d 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -23,6 +23,7 @@ extend google.protobuf.MessageOptions { optional bool no_delay = 1040 [default=false]; optional string base_class = 1041; optional bool inline_encode = 1042 [default=false]; + optional bool speed_optimized = 1043 [default=false]; } extend google.protobuf.FieldOptions { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c2d513f0d3..481510e407 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -745,7 +745,9 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const { #endif return size; } -uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); @@ -755,7 +757,9 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG #endif return pos; } -uint32_t SensorStateResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +SensorStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->state); @@ -2328,7 +2332,9 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, } return true; } -uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; @@ -2350,7 +2356,9 @@ uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer P } return pos; } -uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 39bfc865d0..1743d2bb34 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2679,6 +2679,16 @@ def build_message_type( and get_opt(desc, inline_opt, False) ) + # Check if this message wants speed-optimized encode/calculate_size. + # When set, __attribute__((optimize("O2"))) is added to the definitions + # so GCC inlines the small ProtoEncode helpers even under -Os. + is_speed_optimized = get_opt(desc, pb.speed_optimized, False) + speed_attr = ( + '__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)\n' + if is_speed_optimized + else "" + ) + # Only generate encode method if this message needs encoding and has fields if needs_encode and encode and not is_inline_only: # Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls @@ -2688,7 +2698,7 @@ def build_message_type( ) for line in encode ] - o = f"uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" + o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" o += " uint8_t *__restrict__ pos = buffer.get_pos();\n" o += indent("\n".join(encode_debug)) + "\n" o += " return pos;\n" @@ -2702,7 +2712,7 @@ def build_message_type( # Add calculate_size method only if this message needs encoding and has fields if needs_encode and size_calc and not is_inline_only: - o = f"uint32_t {desc.name}::calculate_size() const {{\n" + o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n" o += " uint32_t size = 0;\n" o += indent("\n".join(size_calc)) + "\n" o += " return size;\n" From 033e144e06a073489109ff5695ea9b3c646f6f48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 08:12:57 +0000 Subject: [PATCH 0173/2214] Bump aioesphomeapi from 44.14.0 to 44.15.0 (#15699) 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 339b5d2919..726a0a221a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.14.0 +aioesphomeapi==44.15.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From bd63f63b369aefc5dedff8f50551ea9ee5c9c42e Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Mon, 13 Apr 2026 15:40:49 +0200 Subject: [PATCH 0174/2214] [esp32] Fix some compiler warnings & bugs (#15610) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/atm90e32/atm90e32.h | 4 ++-- esphome/components/midea/air_conditioner.cpp | 3 ++- esphome/components/mipi_spi/mipi_spi.h | 8 ++++---- esphome/components/tcs34725/tcs34725.cpp | 2 +- esphome/cpp_helpers.py | 3 ++- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index c44a11e3ed..95154812cb 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -111,14 +111,14 @@ class ATM90E32Component : public PollingComponent, #endif float get_reference_voltage(uint8_t phase) { #ifdef USE_NUMBER - return (phase >= 0 && phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage + return (phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage #else return 120.0; // Default voltage #endif } float get_reference_current(uint8_t phase) { #ifdef USE_NUMBER - return (phase >= 0 && phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current + return (phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current #else return 5.0f; // Default current #endif diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 50521cf238..69e0d46d2d 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -28,7 +28,8 @@ void AirConditioner::on_status_change() { if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK && this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) { // Read existing presets (set by codegen), append frost protection, write back - const auto &existing = this->get_traits().get_supported_custom_presets(); + auto traits = this->get_traits(); + const auto &existing = traits.get_supported_custom_presets(); bool found = false; for (const char *p : existing) { if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) { diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 423226b1d7..2242be6c17 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -234,9 +234,9 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, - this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, - this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, + internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, + (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, + this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } @@ -305,7 +305,7 @@ class MipiSpi : public display::Display, this->write_command_(BRIGHTNESS, this->brightness_.value()); // calculate new madctl value from base value adjusted for rotation - uint8_t madctl = MADCTL; // lower 8 bits only + uint8_t madctl = (uint8_t) MADCTL; // lower 8 bits only constexpr bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0; constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX; constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY; diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 4fe87de0ca..1098d8de5f 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -315,7 +315,7 @@ void TCS34725Component::set_integration_time(TCS34725IntegrationTime integration my_integration_time_regval = integration_time; this->integration_time_auto_ = false; } - this->integration_time_ = (256.f - my_integration_time_regval) * 2.4f; + this->integration_time_ = (256.f - (float) my_integration_time_regval) * 2.4f; ESP_LOGI(TAG, "TCS34725I Integration time set to: %.1fms", this->integration_time_); } void TCS34725Component::set_gain(TCS34725Gain gain) { diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 479090016f..f2bd3b92a3 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -113,7 +113,8 @@ def _generate_source_table_code( entries = ", ".join(var_names) lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};") lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{") - lines.append(f' if (index == 0 || index > {count}) return LOG_STR("");') + cond = "index == 0" if count >= 255 else f"index == 0 || index > {count}" + lines.append(f' if ({cond}) return LOG_STR("");') lines.append(" return reinterpret_cast(") lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));") lines.append("}") From 8843c36ec62902869384bb2c3434633e12278d6e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 13 Apr 2026 15:42:02 +0200 Subject: [PATCH 0175/2214] [micro_wake_word] Bugfix: Use es-nn v1.1.2 (last known working version) (#15703) --- esphome/components/micro_wake_word/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index de95e4961b..5ab1e4bb80 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -452,7 +452,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1") # Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn) - esp32.add_idf_component(name="espressif/esp-nn", ref="1.2.1") + esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2") cg.add_build_flag("-DTF_LITE_STATIC_MEMORY") cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON") From 445715b9fd3f2b2579d23388aeba03a0eb931424 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:18:52 -0400 Subject: [PATCH 0176/2214] [esp32] Update the recommended platform to 55.03.38-1 (#15705) --- .clang-tidy.hash | 2 +- esphome/components/esp32/__init__.py | 8 ++++---- platformio.ini | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index cd61d9ec48..60c3776aa8 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d48687d988ae2a94a9973226df773478a7db1d52133545f07aa05e34fc678dcf +10c432ae818f9ed7fd4a0176a04467b1f2634363f5ec985045a6d72747f60b90 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index cd38c82dd8..2974028b50 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -676,7 +676,7 @@ ARDUINO_FRAMEWORK_VERSION_LOOKUP = { "dev": cv.Version(3, 3, 8), } ARDUINO_PLATFORM_VERSION_LOOKUP = { - cv.Version(3, 3, 8): cv.Version(55, 3, 38), + cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), cv.Version(3, 3, 5): cv.Version(55, 3, 35), @@ -724,7 +724,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 4): cv.Version(55, 3, 38), + cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), cv.Version(5, 5, 2): cv.Version(55, 3, 37), @@ -744,8 +744,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 38), - "latest": cv.Version(55, 3, 38), + "recommended": cv.Version(55, 3, 38, "1"), + "latest": cv.Version(55, 3, 38, "1"), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } diff --git a/platformio.ini b/platformio.ini index 7d17628a8f..708d62afdc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -133,7 +133,7 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.8/esp32-core-3.3.8.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz @@ -169,7 +169,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz From 28f3bcdba3197e0751169ae23260b0e309ad7e39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 08:25:05 -1000 Subject: [PATCH 0177/2214] [api] Add speed_optimized to SubscribeLogsResponse (#15698) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/api/api.proto | 5 +++-- esphome/components/api/api_pb2.cpp | 18 ++++++++++++------ script/api_protobuf/api_protobuf.py | 6 ++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4a9a6f9051..f906cfb8d7 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -778,9 +778,10 @@ message SubscribeLogsResponse { option (source) = SOURCE_SERVER; option (log) = false; option (no_delay) = false; + option (speed_optimized) = true; - LogLevel level = 1; - bytes message = 3; + LogLevel level = 1 [(force) = true]; + bytes message = 3 [(force) = true]; } // ==================== NOISE ENCRYPTION ==================== diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 481510e407..f304c85282 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -916,16 +916,22 @@ bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t } return true; } -uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +SubscribeLogsResponse::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->level)); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level), true); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_); return pos; } -uint32_t SubscribeLogsResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +SubscribeLogsResponse::calculate_size() const { uint32_t size = 0; - size += this->level ? 2 : 0; - size += ProtoSize::calc_length(1, this->message_len_); + size += 2; + size += ProtoSize::calc_length_force(1, this->message_len_); return size; } #ifdef USE_API_NOISE diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 1743d2bb34..73e0859d5e 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1028,7 +1028,8 @@ class BytesType(TypeInfo): ) def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len_);" + calc_fn = "calc_length_force" if force else "calc_length" + return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len_);" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes @@ -1109,7 +1110,8 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): ) def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len);" + calc_fn = "calc_length_force" if force else "calc_length" + return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len);" class PointerToStringBufferType(PointerToBufferTypeBase): From 89fbfc6f7107fefeb44f94223475830b7f0b6652 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 13:59:32 -1000 Subject: [PATCH 0178/2214] [adc] Place ADC oneshot control functions in IRAM for cache safety (#15717) --- esphome/components/adc/sensor.py | 15 ++++++++++++++- esphome/components/esp32/__init__.py | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index bab2762f00..09e09f0dc1 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -2,7 +2,11 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler -from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component +from esphome.components.esp32 import ( + get_esp32_variant, + include_builtin_idf_component, + require_adc_oneshot_iram, +) from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, @@ -24,6 +28,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE +from esphome.types import ConfigType from . import ( ATTENUATION_MODES, @@ -65,6 +70,13 @@ def validate_config(config): return config +def _require_adc_iram(config: ConfigType) -> ConfigType: + """Register ADC oneshot IRAM requirement during config validation.""" + if CORE.is_esp32: + require_adc_oneshot_iram() + return config + + ADCSensor = adc_ns.class_( "ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler ) @@ -95,6 +107,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(cv.polling_component_schema("60s")), validate_config, + _require_adc_iram, ) CONF_ADC_CHANNEL_ID = "adc_channel_id" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2974028b50..7b3f9da3da 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1058,6 +1058,7 @@ CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" +CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" # VFS requirement tracking # Components that need VFS features can call require_vfs_*() functions @@ -1071,6 +1072,7 @@ KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" +KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" def require_vfs_select() -> None: @@ -1168,6 +1170,17 @@ def require_fatfs() -> None: CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True +def require_adc_oneshot_iram() -> None: + """Mark that ADC oneshot IRAM safety is required by a component. + + Call this from components that use the ADC oneshot driver. When flash cache is + disabled (e.g., during NVS writes by WiFi, BLE, Zigbee, or power management), + the ADC oneshot read function must be in IRAM to avoid crashes. + This sets CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM. + """ + CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -1268,6 +1281,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, + cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), @@ -2068,6 +2082,16 @@ async def to_code(config): if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False) + # Place ADC oneshot control functions in IRAM for cache safety + # When flash cache is disabled (during NVS writes by WiFi, BLE, Zigbee, Thread, + # power management, etc.), ADC reads will crash if these functions are in flash. + # Components using ADC call require_adc_oneshot_iram() to force this. + if ( + CORE.data[KEY_ESP32].get(KEY_ADC_ONESHOT_IRAM_REQUIRED, False) + or advanced[CONF_ADC_ONESHOT_IN_IRAM] + ): + 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): From a77ab59436985333112d4f42a3e670cd77f3d6f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 13:59:45 -1000 Subject: [PATCH 0179/2214] [web_server] Reset OTA backend on new upload to avoid brick after interrupted OTA (#15720) --- .../web_server/ota/ota_web_server.cpp | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 95b166901a..9812714ec0 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -114,7 +114,25 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf uint8_t *data, size_t len, bool final) { ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; - if (index == 0 && !this->ota_backend_) { + // First byte of a new upload: index==0 with actual data. (web_server_idf + // fires a separate start-marker call with data==nullptr/len==0 before the + // first real chunk; gate on len>0 so we only trigger once per upload.) + if (index == 0 && len > 0) { + // If a previous upload was interrupted (e.g. client closed the tab, TCP + // reset) the backend from that session may still be open. Tear it down + // so flash state doesn't get concatenated with the new image (which can + // produce a technically-valid-sized but corrupted firmware that bricks + // the device once it reboots). + if (this->ota_backend_) { + ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); + this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + // Notify listeners that the previous session was aborted before the new one starts. + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif + this->ota_backend_.reset(); + } + // Initialize OTA on first call this->ota_init_(filename.c_str()); From 311812c8cc838cf12af9f8605ff7145a9164dcb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:58:48 -1000 Subject: [PATCH 0180/2214] [esphome] Skip missing extra flash images in upload_using_esptool (#15723) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 9 +++++++- tests/unit_tests/test_main.py | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 25b404ae45..7879cdad0c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -750,8 +750,15 @@ def upload_using_esptool( platformio_api.FlashImage( path=idedata.firmware_bin_path, offset=firmware_offset ), - *idedata.extra_flash_images, ] + for image in idedata.extra_flash_images: + if not image.path.is_file(): + _LOGGER.warning( + "Skipping missing flash image declared by platform: %s", + image.path, + ) + continue + flash_images.append(image) mcu = "esp8266" if CORE.is_esp32: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 85536d2f1c..e07b4accf2 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1231,6 +1231,48 @@ def test_upload_using_esptool_path_conversion( assert partitions_path.endswith("partitions.bin") +def test_upload_using_esptool_skips_missing_extra_flash_images( + tmp_path: Path, + mock_run_external_command_main: Mock, + mock_get_idedata: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A non-existent path in extra_flash_images must be filtered out with a + warning, and must not appear in the esptool command line. Only the valid + images are flashed. Regression test for + https://github.com/esphome/esphome/issues/15634. + """ + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test") + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32} + + missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin" + + mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" + mock_idedata.extra_flash_images = [ + platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), + platformio_api.FlashImage(path=missing_path, offset="0x2d0000"), + ] + mock_get_idedata.return_value = mock_idedata + + (tmp_path / "firmware.bin").touch() + (tmp_path / "bootloader.bin").touch() + # Intentionally do NOT create missing_path + + config = {CONF_ESPHOME: {"platformio_options": {}}} + + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + result = upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + assert result == 0 + assert "Skipping missing flash image" in caplog.text + assert str(missing_path) in caplog.text + + cmd_list = list(mock_run_external_command_main.call_args[0][1:]) + assert str(missing_path) not in cmd_list + assert "0x2d0000" not in cmd_list + + def test_upload_using_esptool_with_file_path( tmp_path: Path, mock_run_external_command_main: Mock, From 089a2c99e2c0cfecd4a77094de63d4e164f2a8da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 03:18:38 -1000 Subject: [PATCH 0181/2214] [globals] Fix TemplatableFn deprecation warning for globals.set (#15733) --- esphome/components/globals/__init__.py | 7 ++++++- tests/components/globals/common.yaml | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index ec6730a41c..46725fe6dd 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -108,8 +108,13 @@ async def globals_set_to_code(config, action_id, template_arg, args): 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) + # Use the global's value_type alias as the lambda return type so + # TemplatableFn stores a direct function pointer instead of going through + # the deprecated converting trampoline when the value expression deduces + # to a different type (e.g. int literal assigned to a float global). + value_type = cg.RawExpression(f"{full_id.type}::value_type") templ = await cg.templatable( - config[CONF_VALUE], args, None, to_exp=cg.RawExpression, wrap_constant=True + config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression ) cg.add(var.set_value(templ)) return var diff --git a/tests/components/globals/common.yaml b/tests/components/globals/common.yaml index 35dca0624f..6d5721d3be 100644 --- a/tests/components/globals/common.yaml +++ b/tests/components/globals/common.yaml @@ -4,6 +4,14 @@ esphome: - globals.set: id: glob_int value: "10" + # Set a float global with an integer literal - must emit the correct + # return type so TemplatableFn stores a direct function pointer. + - globals.set: + id: glob_float + value: "102" + - globals.set: + id: glob_float + value: !lambda "return 42;" globals: - id: glob_int From ef44491c695dc03acb80a736121221c986a6b384 Mon Sep 17 00:00:00 2001 From: Alexey Spirkov Date: Tue, 14 Apr 2026 21:07:16 +0300 Subject: [PATCH 0182/2214] [i2s_audio] Add PDM mics support for ESP32-P4 (#15333) Co-authored-by: Alexey Spirkov --- esphome/components/i2s_audio/microphone/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 761cbb7f48..1392d1d4ec 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -36,7 +36,7 @@ I2SAudioMicrophone = i2s_audio_ns.class_( ) INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] -PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3] +PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4] def _validate_esp32_variant(config): From 0a568a3e1eaf6a10c7d954a9873888a800b00537 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 09:45:42 -1000 Subject: [PATCH 0183/2214] [light] Avoid addressable transition stall at low gamma-corrected values (#15726) --- .../components/light/addressable_light.cpp | 63 +++++++++- esphome/components/light/addressable_light.h | 3 + .../addressable_light_transition.yaml | 29 +++++ .../mock_addressable_light/__init__.py | 1 + .../mock_addressable_light/light.py | 23 ++++ .../mock_addressable_light.h | 52 ++++++++ .../test_addressable_light_transition.py | 119 ++++++++++++++++++ 7 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 tests/integration/fixtures/addressable_light_transition.yaml create mode 100644 tests/integration/fixtures/external_components/mock_addressable_light/__init__.py create mode 100644 tests/integration/fixtures/external_components/mock_addressable_light/light.py create mode 100644 tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h create mode 100644 tests/integration/test_addressable_light_transition.py diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 2f6ffc9a38..d2f5913f4b 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -58,6 +58,12 @@ void AddressableLightTransformer::start() { // our transition will handle brightness, disable brightness in correction. this->light_.correction_.set_local_brightness(255); this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state()); + + // Uniformity scan is deferred to the first apply() call. start() can run before the underlying + // LED output's setup() has allocated its frame buffer (e.g. on_boot at priority > HARDWARE + // triggering a transition), and reading through ESPColorView would deref a null buffer. + this->uniform_start_scanned_ = false; + this->uniform_start_is_uniform_ = false; } inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) { @@ -97,12 +103,57 @@ optional AddressableLightTransformer::apply() { // non-linear when applying small deltas. if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) { - int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f)); - for (auto led : this->light_) { - led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale), - subtract_scaled_difference(this->target_color_.green, led.get_green(), scale), - subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale), - subtract_scaled_difference(this->target_color_.white, led.get_white(), scale)); + // Lazy uniformity scan: deferred from start() so the LED output's setup() has run and the + // frame buffer is valid. When every LED already has the same color (the common case: plain + // turn_on/turn_off on a uniform strip), interpolate math-only against a single start color. + // Avoiding the per-step read-back through the 8-bit stored byte prevents gamma round-trip + // quantization from stalling the fade at low values (e.g. gamma 2.8 pre-gamma values <27 + // round to stored 0, freezing progress). + if (!this->uniform_start_scanned_) { + this->uniform_start_scanned_ = true; + if (this->light_.size() > 0) { + Color first = this->light_[0].get(); + bool uniform = true; + for (int32_t i = 1; i < this->light_.size(); i++) { + if (this->light_[i].get() != first) { + uniform = false; + break; + } + } + if (uniform) { + this->uniform_start_color_ = first; + this->uniform_start_is_uniform_ = true; + } + } + } + if (this->uniform_start_is_uniform_) { + // All LEDs started at the same color: compute the interpolated value once and write it to + // every LED. No read-back, so each LED's stored byte advances through every gamma threshold + // as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values. + // + // Trade-off: any mid-transition writes to individual LEDs (e.g. from a user lambda) will be + // overwritten on the next apply() here. The fallback path below would have respected them + // via its read-back. Concurrent per-LED mutation during a transition isn't a pattern we + // support, so this is acceptable. + // lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress). + const Color &start = this->uniform_start_color_; + int32_t remaining = int32_t(256.f * (1.f - smoothed_progress)); + uint8_t r = subtract_scaled_difference(this->target_color_.red, start.red, remaining); + uint8_t g = subtract_scaled_difference(this->target_color_.green, start.green, remaining); + uint8_t b = subtract_scaled_difference(this->target_color_.blue, start.blue, remaining); + uint8_t w = subtract_scaled_difference(this->target_color_.white, start.white, remaining); + for (auto led : this->light_) { + led.set_rgbw(r, g, b, w); + } + } else { + int32_t scale = + int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f)); + for (auto led : this->light_) { + led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale), + subtract_scaled_difference(this->target_color_.green, led.get_green(), scale), + subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale), + subtract_scaled_difference(this->target_color_.white, led.get_white(), scale)); + } } this->last_transition_progress_ = smoothed_progress; this->light_.schedule_show(); diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 17cdb7d6f6..0202ad380a 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -115,6 +115,9 @@ class AddressableLightTransformer : public LightTransformer { AddressableLight &light_; float last_transition_progress_{0.0f}; Color target_color_{}; + Color uniform_start_color_{}; + bool uniform_start_scanned_{false}; + bool uniform_start_is_uniform_{false}; }; } // namespace esphome::light diff --git a/tests/integration/fixtures/addressable_light_transition.yaml b/tests/integration/fixtures/addressable_light_transition.yaml new file mode 100644 index 0000000000..7b847dd803 --- /dev/null +++ b/tests/integration/fixtures/addressable_light_transition.yaml @@ -0,0 +1,29 @@ +esphome: + name: addr-light-transition +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +light: + - platform: mock_addressable_light + output_id: strip_output + id: strip + name: "Test Strip" + num_leds: 4 + gamma_correct: 2.8 + default_transition_length: 0s + +sensor: + - platform: template + name: "led0_red_raw" + id: led0_red_raw + update_interval: 10ms + accuracy_decimals: 0 + lambda: |- + return (float) id(strip_output).get_raw_red(0); diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py b/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py new file mode 100644 index 0000000000..e8cfff8e1f --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/tests"] diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/light.py b/tests/integration/fixtures/external_components/mock_addressable_light/light.py new file mode 100644 index 0000000000..293d2854f4 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/light.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID +from esphome.types import ConfigType + +mock_addressable_light_ns = cg.esphome_ns.namespace("mock_addressable_light") +MockAddressableLight = mock_addressable_light_ns.class_( + "MockAddressableLight", light.AddressableLight +) + +CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MockAddressableLight), + cv.Optional(CONF_NUM_LEDS, default=4): cv.positive_not_null_int, + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_NUM_LEDS]) + await light.register_light(var, config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h new file mode 100644 index 0000000000..c6b0d10601 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/light/addressable_light.h" +#include "esphome/core/component.h" + +namespace esphome::mock_addressable_light { + +// In-memory addressable light for host-mode integration tests. Exposes the raw +// per-LED byte buffer (post-gamma-correction, as the hardware would see it) +// so tests can observe transition behavior without real hardware. +class MockAddressableLight : public light::AddressableLight { + public: + explicit MockAddressableLight(uint16_t num_leds) + : num_leds_(num_leds), buf_(new uint8_t[num_leds * 4]()), effect_data_(new uint8_t[num_leds]()) {} + + void setup() override {} + void write_state(light::LightState *state) override {} + int32_t size() const override { return this->num_leds_; } + void clear_effect_data() override { + for (uint16_t i = 0; i < this->num_leds_; i++) + this->effect_data_[i] = 0; + } + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::RGB}); + return traits; + } + + // Accessors for tests: return the raw stored byte (post gamma correction), + // which is what actual LED hardware would receive. + uint8_t get_raw_red(uint16_t index) const { return this->buf_[index * 4 + 0]; } + uint8_t get_raw_green(uint16_t index) const { return this->buf_[index * 4 + 1]; } + uint8_t get_raw_blue(uint16_t index) const { return this->buf_[index * 4 + 2]; } + uint8_t get_raw_white(uint16_t index) const { return this->buf_[index * 4 + 3]; } + + protected: + light::ESPColorView get_view_internal(int32_t index) const override { + size_t pos = index * 4; + return {this->buf_.get() + pos + 0, this->buf_.get() + pos + 1, this->buf_.get() + pos + 2, + this->buf_.get() + pos + 3, this->effect_data_.get() + index, &this->correction_}; + } + + uint16_t num_leds_; + std::unique_ptr buf_; + std::unique_ptr effect_data_; +}; + +} // namespace esphome::mock_addressable_light diff --git a/tests/integration/test_addressable_light_transition.py b/tests/integration/test_addressable_light_transition.py new file mode 100644 index 0000000000..37fecde595 --- /dev/null +++ b/tests/integration/test_addressable_light_transition.py @@ -0,0 +1,119 @@ +"""Integration test for addressable light transitions with gamma correction. + +Regression test for a bug where a long turn-on transition on an addressable +light with gamma correction (e.g. gamma_correct: 2.8) produced no visible +output for ~90% of the transition duration, then jumped to the target in the +final ~10%. Root cause: the transition algorithm read each LED's current value +back through the 8-bit stored byte every step; at gamma 2.8 any pre-gamma value +below ~27 rounds to stored byte 0, so the stored byte stalled at 0 until +progress was high enough for a single step to produce a large-enough pre-gamma +value to clear the gamma threshold. + +The fix interpolates against a cached start color when all LEDs started at the +same value (the common case for plain turn_on/turn_off), avoiding the round-trip. + +This test uses a host-only mock addressable light that exposes the raw stored +byte of each LED, so we can observe the transition directly. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import LightInfo, SensorInfo, SensorState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_addressable_light_transition( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With gamma 2.8, the stored raw byte must rise visibly well before the end.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + light = require_entity(entities, "test_strip", LightInfo) + sensor = require_entity(entities, "led0_red_raw", SensorInfo) + + # Track the raw-byte sensor. It polls every 10ms in the fixture, and + # ESPHome sensors publish on every change, so we collect a time series. + # Samples are stored as absolute (loop_time, value); we rebase to the + # command-issue time after the run so pre-command samples are strictly + # negative and reliably excluded. + loop = asyncio.get_running_loop() + samples: list[tuple[float, float]] = [] + + def on_state(state: object) -> None: + if not isinstance(state, SensorState) or state.key != sensor.key: + return + samples.append((loop.time(), state.state)) + + # InitialStateHelper swallows the first state ESPHome sends per entity + # on subscribe, so on_state only sees real post-subscribe updates. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + # Start transition: off -> full white over 1 second. This is the + # scenario from the bug report, compressed in time. + transition_s = 1.0 + command_time = loop.time() + client.light_command( + key=light.key, + state=True, + rgb=(1.0, 1.0, 1.0), + brightness=1.0, + transition_length=transition_s, + ) + + # Let the full transition run, plus margin for the final sample. + await asyncio.sleep(transition_s + 0.2) + + # Rebase to command-issue time. Pre-command samples have t < 0 and are + # excluded; everything else is in seconds since the command was issued. + post_command = [ + (t - command_time, v) for (t, v) in samples if t >= command_time + ] + assert post_command, "no sensor samples received after command was issued" + + # Assertion 1: the transition is not stalled. With the bug, the raw + # byte stays at 0 until ~90% of the transition duration. With the fix, + # it becomes nonzero in the first ~30% (for gamma 2.8, pre-gamma 76 + # clears the gamma threshold at progress ~0.30). Require the first + # nonzero sample to land well before 50% of the transition duration, + # measured from the command-issue time. The 50% bound (rather than + # 70%) leaves headroom for assertion 2's mid-window check. + first_nonzero = next(((t, v) for (t, v) in post_command if v > 0), None) + assert first_nonzero is not None, ( + "raw byte never rose above 0 during the transition — the fade stalled" + ) + assert first_nonzero[0] < transition_s * 0.5, ( + f"raw byte only rose above 0 at t={first_nonzero[0]:.3f}s " + f"(>{transition_s * 0.5:.3f}s after command) — transition is stalling" + ) + + # Assertion 2: by mid-late transition, the raw byte should have reached + # a substantial fraction of its final value. Bound the window to + # [50%, 90%] of the transition so the post-transition settled value + # (which always reaches 255) can't satisfy this assertion — that would + # let "stays at 0 then jumps at 99%" regressions slip through. + mid_window = [ + v + for (t, v) in post_command + if transition_s * 0.5 <= t <= transition_s * 0.9 + ] + assert mid_window, "no samples captured in mid-transition window" + assert max(mid_window) >= 100, ( + f"raw byte peaked at only {max(mid_window)} between 50%–90% of " + "transition (expected >= 100 for white target at gamma 2.8)" + ) + + # Assertion 3: final value reaches target. Gamma 2.8 of 255 is 255. + final_samples = [v for (_, v) in post_command[-5:]] + assert max(final_samples) >= 250, ( + f"final raw byte was {max(final_samples)}, expected >= 250" + ) From 3ef140e25d1c39016c1ac528c53e81466e74ff04 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:47:44 +0200 Subject: [PATCH 0184/2214] [nextion] Fix command spacing pacer never throttling sends (#15664) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nextion/nextion.cpp | 108 +++++++++++++++++-------- esphome/components/nextion/nextion.h | 15 ++-- 2 files changed, 84 insertions(+), 39 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index b0e14b5ea3..e42f7ca216 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -36,8 +36,9 @@ bool Nextion::send_command_(const std::string &command) { } #ifdef USE_NEXTION_COMMAND_SPACING - if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send()) { - ESP_LOGN(TAG, "Command spacing: delaying command '%s'", command.c_str()); + const uint32_t now = App.get_loop_component_start_time(); + if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send(now)) { + ESP_LOGN(TAG, "Command spacing: delaying '%s'", command.c_str()); return false; } #endif // USE_NEXTION_COMMAND_SPACING @@ -48,6 +49,16 @@ bool Nextion::send_command_(const std::string &command) { const uint8_t to_send[3] = {0xFF, 0xFF, 0xFF}; this->write_array(to_send, sizeof(to_send)); +#ifdef USE_NEXTION_COMMAND_SPACING + // Mark sent immediately after writing to UART. The pacer enforces inter-command + // spacing from the transmit side. Marking on ACK (0x01) would leave last_command_time_ + // at zero indefinitely, making can_send() always return true and spacing a no-op. + // ignore_is_setup_ commands (setup/init sequence) bypass spacing intentionally. + if (!this->connection_state_.ignore_is_setup_) { + this->command_pacer_.mark_sent(now); + } +#endif // USE_NEXTION_COMMAND_SPACING + return true; } @@ -253,11 +264,8 @@ bool Nextion::send_command(const char *command) { if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping()) return false; - if (this->send_command_(command)) { - this->add_no_result_to_queue_("command"); - return true; - } - return false; + this->add_no_result_to_queue_with_command_("command", command); + return true; } bool Nextion::send_command_printf(const char *format, ...) { @@ -274,11 +282,8 @@ bool Nextion::send_command_printf(const char *format, ...) { return false; } - if (this->send_command_(buffer)) { - this->add_no_result_to_queue_("command_printf"); - return true; - } - return false; + this->add_no_result_to_queue_with_command_("command_printf", buffer); + return true; } #ifdef NEXTION_PROTOCOL_LOG @@ -349,25 +354,43 @@ void Nextion::loop() { } #ifdef USE_NEXTION_COMMAND_SPACING - // Try to send any pending commands if spacing allows this->process_pending_in_queue_(); +#ifdef USE_NEXTION_WAVEFORM + if (!this->waveform_queue_.empty()) { + this->check_pending_waveform_(); + } +#endif // USE_NEXTION_WAVEFORM #endif // USE_NEXTION_COMMAND_SPACING } #ifdef USE_NEXTION_COMMAND_SPACING void Nextion::process_pending_in_queue_() { - if (this->nextion_queue_.empty() || !this->command_pacer_.can_send()) { - return; - } +#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP + size_t commands_sent = 0; +#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP - // Check if first item in queue has a pending command - auto *front_item = this->nextion_queue_.front(); - if (front_item && !front_item->pending_command.empty()) { - if (this->send_command_(front_item->pending_command)) { - // Command sent successfully, clear the pending command - front_item->pending_command.clear(); - ESP_LOGVV(TAG, "Pending command sent: %s", front_item->component->get_variable_name().c_str()); + for (auto *item : this->nextion_queue_) { + if (item == nullptr || item->pending_command.empty()) { + continue; // Already sent, waiting for ACK — skip, don't stop } + +#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP + if (++commands_sent > this->max_commands_per_loop_) { + ESP_LOGV(TAG, "Pending cmds: loop limit reached, deferring"); + break; + } +#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP + + const uint32_t now = App.get_loop_component_start_time(); + if (!this->command_pacer_.can_send(now)) { + break; // Spacing not elapsed, stop for this loop iteration + } + + if (!this->send_command_(item->pending_command)) { + break; // Unexpected send failure, stop + } + item->pending_command.clear(); + ESP_LOGVV(TAG, "Pending cmd sent: %s", item->component->get_variable_name().c_str()); } } #endif // USE_NEXTION_COMMAND_SPACING @@ -470,10 +493,6 @@ void Nextion::process_nextion_commands_() { this->setup_callback_.call(); } } -#ifdef USE_NEXTION_COMMAND_SPACING - this->command_pacer_.mark_sent(); // Here is where we should mark the command as sent - ESP_LOGN(TAG, "Command spacing: marked command sent"); -#endif break; case 0x02: // invalid Component ID or name was used ESP_LOGW(TAG, "Invalid component ID/name"); @@ -1079,10 +1098,18 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { } /** - * @brief + * @brief Send a command and enqueue it for response tracking. * - * @param variable_name Variable name for the queue - * @param command + * Callers are responsible for checking is_sleeping() before calling this + * method. The sleep guard is deliberately absent here because some callers + * (e.g. add_no_result_to_queue_with_ignore_sleep_printf_()) are explicitly + * sleep-safe and must bypass it. + * + * If USE_NEXTION_COMMAND_SPACING is enabled and the pacer is not ready, + * the command is saved in the queue entry for retry rather than dropped. + * + * @param variable_name Name of the variable or component associated with the command. + * @param command The raw command string to send. */ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_name, const std::string &command) { if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || command.empty()) @@ -1263,9 +1290,22 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { std::string command = "get " + component->get_variable_name_to_send(); +#ifdef USE_NEXTION_COMMAND_SPACING + // Always enqueue first so the response handler is present when the command + // is eventually sent. Store the command for retry if spacing blocked it; + // process_pending_in_queue_() will transmit it when the pacer allows. + nextion_queue->pending_command = command; + this->nextion_queue_.push_back(nextion_queue); + if (this->send_command_(command)) { + nextion_queue->pending_command.clear(); + } +#else // USE_NEXTION_COMMAND_SPACING if (this->send_command_(command)) { this->nextion_queue_.push_back(nextion_queue); + } else { + delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) } +#endif // USE_NEXTION_COMMAND_SPACING } #ifdef USE_NEXTION_WAVEFORM @@ -1309,10 +1349,10 @@ void Nextion::check_pending_waveform_() { char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); - if (!this->send_command_(command)) { - delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop(); - } + // If spacing or setup state blocks the send, leave the entry at the front + // of waveform_queue_ for retry on the next loop iteration via + // check_pending_waveform_(). Only pop on a successful send. + this->send_command_(command); } #endif // USE_NEXTION_WAVEFORM diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index c84a5cd49c..c62772ac75 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -55,15 +55,20 @@ class NextionCommandPacer { uint8_t get_spacing() const { return spacing_ms_; } /** - * @brief Check if enough time has passed to send next command - * @return true if enough time has passed since last command + * @brief Check if enough time has passed to send the next command. + * @param now Current timestamp in milliseconds (use App.get_loop_component_start_time() + * for consistency with the rest of the queue timing). + * @return true if the spacing interval has elapsed since the last command was sent. */ - bool can_send() const { return (millis() - last_command_time_) >= spacing_ms_; } + bool can_send(uint32_t now) const { return (now - last_command_time_) >= spacing_ms_; } /** - * @brief Mark a command as sent, updating the timing + * @brief Record the transmit timestamp for the most recently sent command. + * @param now Current timestamp in milliseconds, as returned by + * App.get_loop_component_start_time(). Must use the same clock + * source as can_send() to avoid unsigned underflow. */ - void mark_sent() { last_command_time_ = millis(); } + void mark_sent(uint32_t now) { last_command_time_ = now; } private: uint8_t spacing_ms_; From 4c43f7e9d06dbf5df989aab28c494eef93f225a9 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:58:30 +1200 Subject: [PATCH 0185/2214] Bump version to 2026.4.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 599109b43f..fb85e8028c 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.4.0b2 +PROJECT_NUMBER = 2026.4.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 b4cd758330..d5d726d0db 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.0b2" +__version__ = "2026.4.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From efba0f5fd6b0c42b0644a743da5b9b570a120623 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 14:44:12 -1000 Subject: [PATCH 0186/2214] handle 0 --- esphome/core/helpers.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index ede1640af2..f7bc6c5bee 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -589,7 +589,7 @@ size_t value_accuracy_to_buf(std::span buf, float size_t value_accuracy_with_uom_to_buf(std::span buf, float value, int8_t accuracy_decimals, StringRef unit_of_measurement) { size_t len = value_accuracy_to_buf(buf, value, accuracy_decimals); - if (unit_of_measurement.empty()) { + if (len == 0 || unit_of_measurement.empty()) { return len; } char *end = buf_append_sep_str(buf.data() + len, buf.size() - len, ' ', unit_of_measurement.c_str(), From 2ccd94e1add3e2f6206fddfa1326da96a33fa122 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 14:54:42 -1000 Subject: [PATCH 0187/2214] add tests --- tests/components/core/test_value_accuracy.cpp | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/components/core/test_value_accuracy.cpp b/tests/components/core/test_value_accuracy.cpp index 6bd8976acc..4ddec940d2 100644 --- a/tests/components/core/test_value_accuracy.cpp +++ b/tests/components/core/test_value_accuracy.cpp @@ -149,4 +149,73 @@ TEST(ValueAccuracyToBuf, ReturnsCorrectLength) { EXPECT_EQ(strlen(buf), len); } +// --- value_accuracy_with_uom_to_buf --- + +static std::string va_uom_to_string(float value, int8_t accuracy_decimals, const char *uom) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + StringRef ref(uom); + size_t len = value_accuracy_with_uom_to_buf(sp, value, accuracy_decimals, ref); + return std::string(buf, len); +} + +static std::string va_uom_reference(float value, int8_t accuracy_decimals, const char *uom) { + char buf[VALUE_ACCURACY_MAX_LEN]; + if (!uom || *uom == '\0') { + snprintf(buf, sizeof(buf), "%.*f", accuracy_decimals, value); + } else { + snprintf(buf, sizeof(buf), "%.*f %s", accuracy_decimals, value, uom); + } + return std::string(buf); +} + +TEST(ValueAccuracyWithUomToBuf, BasicWithUnit) { + EXPECT_EQ(va_uom_to_string(23.456f, 2, "°C"), va_uom_reference(23.456f, 2, "°C")); + EXPECT_EQ(va_uom_to_string(1013.25f, 2, "hPa"), va_uom_reference(1013.25f, 2, "hPa")); + EXPECT_EQ(va_uom_to_string(-40.0f, 1, "°F"), va_uom_reference(-40.0f, 1, "°F")); + EXPECT_EQ(va_uom_to_string(100.0f, 0, "%"), va_uom_reference(100.0f, 0, "%")); +} + +TEST(ValueAccuracyWithUomToBuf, EmptyUnit) { + EXPECT_EQ(va_uom_to_string(23.456f, 2, ""), "23.46"); + EXPECT_EQ(va_uom_to_string(0.0f, 1, ""), "0.0"); +} + +TEST(ValueAccuracyWithUomToBuf, ReturnsCorrectLength) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + StringRef ref("°C"); + size_t len = value_accuracy_with_uom_to_buf(sp, 23.46f, 2, ref); + EXPECT_EQ(strlen(buf), len); + EXPECT_EQ(len, strlen("23.46 °C")); +} + +TEST(ValueAccuracyWithUomToBuf, NearBufferLimitTruncates) { + // Build a unit long enough that value + " " + unit exceeds VALUE_ACCURACY_MAX_LEN. + // "23.46" (5) + " " (1) + unit -> must cap at buf.size()-1 and stay null-terminated. + std::string long_unit(VALUE_ACCURACY_MAX_LEN, 'U'); + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + StringRef ref(long_unit.c_str()); + size_t len = value_accuracy_with_uom_to_buf(sp, 23.46f, 2, ref); + EXPECT_LT(len, VALUE_ACCURACY_MAX_LEN); + EXPECT_EQ(strlen(buf), len); + // Should begin with the formatted value and a separator. + EXPECT_EQ(std::string(buf, 6), "23.46 "); +} + +TEST(ValueAccuracyWithUomToBuf, MatchesSnprintf) { + const char *units[] = {"°C", "hPa", "%", "W", "kWh", "m/s"}; + float values[] = {0.0f, 23.456f, -40.0f, 1013.25f, 100.0f}; + int8_t accs[] = {0, 1, 2, 3}; + for (const char *u : units) { + for (float v : values) { + for (int8_t a : accs) { + EXPECT_EQ(va_uom_to_string(v, a, u), va_uom_reference(v, a, u)) + << "value=" << v << " acc=" << static_cast(a) << " uom=" << u; + } + } + } +} + } // namespace esphome::core::testing From f891ea3752205e6056738fec5916c90adf653001 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 14:54:51 -1000 Subject: [PATCH 0188/2214] add tests --- tests/components/core/test_value_accuracy.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/core/test_value_accuracy.cpp b/tests/components/core/test_value_accuracy.cpp index 4ddec940d2..6a63078f6f 100644 --- a/tests/components/core/test_value_accuracy.cpp +++ b/tests/components/core/test_value_accuracy.cpp @@ -6,6 +6,7 @@ #include #include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" namespace esphome::core::testing { From 193e7d476d5b36efd599154a0c98f8144d10066a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:12:03 +1200 Subject: [PATCH 0189/2214] Pin GitHub Actions to commit SHAs Replace mutable tag references with immutable commit SHAs to prevent supply-chain attacks via compromised tags. Version comments are preserved for readability. --- .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 8806a89748..20f9a74ea9 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -8,4 +8,4 @@ on: jobs: lock: - uses: esphome/workflows/.github/workflows/lock.yml@main + uses: esphome/workflows/.github/workflows/lock.yml@3c4e8446aa1029f1c346a482034b3ee1489077ca # 2026.4.0 From 07b129a58aea8675447f37429302b22566c3fd9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 15:47:48 -1000 Subject: [PATCH 0190/2214] [debug] Add iram sensor for ESP32 --- esphome/components/debug/debug_component.h | 2 ++ esphome/components/debug/debug_esp32.cpp | 3 +++ esphome/components/debug/sensor.py | 15 +++++++++++++++ tests/components/debug/test.esp32-ard.yaml | 2 ++ tests/components/debug/test.esp32-idf.yaml | 2 ++ tests/components/debug/test.esp32-s2-idf.yaml | 2 ++ 6 files changed, 26 insertions(+) diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index 3da6b800c6..1484d08362 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -45,6 +45,7 @@ class DebugComponent : public PollingComponent { void set_loop_time_sensor(sensor::Sensor *loop_time_sensor) { loop_time_sensor_ = loop_time_sensor; } #ifdef USE_ESP32 void set_psram_sensor(sensor::Sensor *psram_sensor) { this->psram_sensor_ = psram_sensor; } + void set_iram_sensor(sensor::Sensor *iram_sensor) { this->iram_sensor_ = iram_sensor; } #endif // USE_ESP32 void set_cpu_frequency_sensor(sensor::Sensor *cpu_frequency_sensor) { this->cpu_frequency_sensor_ = cpu_frequency_sensor; @@ -71,6 +72,7 @@ class DebugComponent : public PollingComponent { sensor::Sensor *loop_time_sensor_{nullptr}; #ifdef USE_ESP32 sensor::Sensor *psram_sensor_{nullptr}; + sensor::Sensor *iram_sensor_{nullptr}; #endif // USE_ESP32 sensor::Sensor *cpu_frequency_sensor_{nullptr}; #endif // USE_SENSOR diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 2e04090749..30c226940b 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -301,6 +301,9 @@ void DebugComponent::update_platform_() { if (this->psram_sensor_ != nullptr) { this->psram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); } + if (this->iram_sensor_ != nullptr) { + this->iram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_IRAM_8BIT)); + } #endif } diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index a018ce5c3b..b0b95478ae 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -32,6 +32,7 @@ DEPENDENCIES = ["debug"] CONF_MIN_FREE = "min_free" CONF_PSRAM = "psram" +CONF_IRAM = "iram" CONFIG_SCHEMA = { cv.GenerateID(CONF_DEBUG_ID): cv.use_id(DebugComponent), @@ -98,6 +99,16 @@ CONFIG_SCHEMA = { state_class=STATE_CLASS_MEASUREMENT, ), ), + cv.Optional(CONF_IRAM): cv.All( + cv.only_on_esp32, + sensor.sensor_schema( + unit_of_measurement=UNIT_BYTES, + icon=ICON_COUNTER, + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), cv.Optional(CONF_CPU_FREQUENCY): cv.All( sensor.sensor_schema( unit_of_measurement=UNIT_HERTZ, @@ -138,6 +149,10 @@ async def to_code(config): sens = await sensor.new_sensor(psram_conf) cg.add(debug_component.set_psram_sensor(sens)) + if iram_conf := config.get(CONF_IRAM): + sens = await sensor.new_sensor(iram_conf) + cg.add(debug_component.set_iram_sensor(sens)) + if cpu_freq_conf := config.get(CONF_CPU_FREQUENCY): sens = await sensor.new_sensor(cpu_freq_conf) cg.add(debug_component.set_cpu_frequency_sensor(sens)) diff --git a/tests/components/debug/test.esp32-ard.yaml b/tests/components/debug/test.esp32-ard.yaml index 8f93b0925e..70b58d56da 100644 --- a/tests/components/debug/test.esp32-ard.yaml +++ b/tests/components/debug/test.esp32-ard.yaml @@ -9,3 +9,5 @@ sensor: name: "Heap Fragmentation" min_free: name: "Heap Min Free" + iram: + name: "Free IRAM" diff --git a/tests/components/debug/test.esp32-idf.yaml b/tests/components/debug/test.esp32-idf.yaml index 6a9996ad06..925610bbd4 100644 --- a/tests/components/debug/test.esp32-idf.yaml +++ b/tests/components/debug/test.esp32-idf.yaml @@ -13,5 +13,7 @@ sensor: name: "Heap Fragmentation" min_free: name: "Heap Min Free" + iram: + name: "Free IRAM" psram: diff --git a/tests/components/debug/test.esp32-s2-idf.yaml b/tests/components/debug/test.esp32-s2-idf.yaml index 80919b0bab..6e23c54721 100644 --- a/tests/components/debug/test.esp32-s2-idf.yaml +++ b/tests/components/debug/test.esp32-s2-idf.yaml @@ -6,3 +6,5 @@ sensor: name: "Heap Fragmentation" min_free: name: "Heap Min Free" + iram: + name: "Free IRAM" From 8a00d302b89f71c47d60e66e305ec7a5b634a245 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 15:53:02 -1000 Subject: [PATCH 0191/2214] [debug] Use MALLOC_CAP_EXEC for iram sensor --- esphome/components/debug/debug_esp32.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 30c226940b..1ef055b3dd 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -302,7 +302,7 @@ void DebugComponent::update_platform_() { this->psram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); } if (this->iram_sensor_ != nullptr) { - this->iram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_IRAM_8BIT)); + this->iram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_EXEC)); } #endif } From 3fa50a27a8f74cb30620422c4ad6169c36e7aa4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 15:57:51 -1000 Subject: [PATCH 0192/2214] [debug] Fix iram sensor to report IRAM-only free --- esphome/components/debug/debug_esp32.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 1ef055b3dd..fe074748f8 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -302,7 +302,11 @@ void DebugComponent::update_platform_() { this->psram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); } if (this->iram_sensor_ != nullptr) { - this->iram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_EXEC)); + // IRAM-only free: 32-bit-accessible heap minus 8-bit-accessible heap. + // IRAM regions are registered as 32-bit-only; DRAM is both 8-bit and 32-bit. + size_t free_32bit = heap_caps_get_free_size(MALLOC_CAP_32BIT); + size_t free_8bit = heap_caps_get_free_size(MALLOC_CAP_8BIT); + this->iram_sensor_->publish_state(free_32bit > free_8bit ? free_32bit - free_8bit : 0); } #endif } From 8a41d5503e93f498d9639d53aad7ce9bbef855b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 16:05:31 -1000 Subject: [PATCH 0193/2214] Revert "[debug] Fix iram sensor to report IRAM-only free" This reverts commit 3fa50a27a8f74cb30620422c4ad6169c36e7aa4a. --- esphome/components/debug/debug_esp32.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index fe074748f8..1ef055b3dd 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -302,11 +302,7 @@ void DebugComponent::update_platform_() { this->psram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); } if (this->iram_sensor_ != nullptr) { - // IRAM-only free: 32-bit-accessible heap minus 8-bit-accessible heap. - // IRAM regions are registered as 32-bit-only; DRAM is both 8-bit and 32-bit. - size_t free_32bit = heap_caps_get_free_size(MALLOC_CAP_32BIT); - size_t free_8bit = heap_caps_get_free_size(MALLOC_CAP_8BIT); - this->iram_sensor_->publish_state(free_32bit > free_8bit ? free_32bit - free_8bit : 0); + this->iram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_EXEC)); } #endif } From 5364ea04ef6634e599ce37e76610210c224e2ef2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 16:05:32 -1000 Subject: [PATCH 0194/2214] Revert "[debug] Use MALLOC_CAP_EXEC for iram sensor" This reverts commit 8a00d302b89f71c47d60e66e305ec7a5b634a245. --- esphome/components/debug/debug_esp32.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 1ef055b3dd..30c226940b 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -302,7 +302,7 @@ void DebugComponent::update_platform_() { this->psram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); } if (this->iram_sensor_ != nullptr) { - this->iram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_EXEC)); + this->iram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_IRAM_8BIT)); } #endif } From 39eb8dbcf5f0d5232b7eec214962b37b1e109406 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 16:05:33 -1000 Subject: [PATCH 0195/2214] Revert "[debug] Add iram sensor for ESP32" This reverts commit 07b129a58aea8675447f37429302b22566c3fd9f. --- esphome/components/debug/debug_component.h | 2 -- esphome/components/debug/debug_esp32.cpp | 3 --- esphome/components/debug/sensor.py | 15 --------------- tests/components/debug/test.esp32-ard.yaml | 2 -- tests/components/debug/test.esp32-idf.yaml | 2 -- tests/components/debug/test.esp32-s2-idf.yaml | 2 -- 6 files changed, 26 deletions(-) diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index 1484d08362..3da6b800c6 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -45,7 +45,6 @@ class DebugComponent : public PollingComponent { void set_loop_time_sensor(sensor::Sensor *loop_time_sensor) { loop_time_sensor_ = loop_time_sensor; } #ifdef USE_ESP32 void set_psram_sensor(sensor::Sensor *psram_sensor) { this->psram_sensor_ = psram_sensor; } - void set_iram_sensor(sensor::Sensor *iram_sensor) { this->iram_sensor_ = iram_sensor; } #endif // USE_ESP32 void set_cpu_frequency_sensor(sensor::Sensor *cpu_frequency_sensor) { this->cpu_frequency_sensor_ = cpu_frequency_sensor; @@ -72,7 +71,6 @@ class DebugComponent : public PollingComponent { sensor::Sensor *loop_time_sensor_{nullptr}; #ifdef USE_ESP32 sensor::Sensor *psram_sensor_{nullptr}; - sensor::Sensor *iram_sensor_{nullptr}; #endif // USE_ESP32 sensor::Sensor *cpu_frequency_sensor_{nullptr}; #endif // USE_SENSOR diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 30c226940b..2e04090749 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -301,9 +301,6 @@ void DebugComponent::update_platform_() { if (this->psram_sensor_ != nullptr) { this->psram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); } - if (this->iram_sensor_ != nullptr) { - this->iram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_IRAM_8BIT)); - } #endif } diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index b0b95478ae..a018ce5c3b 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -32,7 +32,6 @@ DEPENDENCIES = ["debug"] CONF_MIN_FREE = "min_free" CONF_PSRAM = "psram" -CONF_IRAM = "iram" CONFIG_SCHEMA = { cv.GenerateID(CONF_DEBUG_ID): cv.use_id(DebugComponent), @@ -99,16 +98,6 @@ CONFIG_SCHEMA = { state_class=STATE_CLASS_MEASUREMENT, ), ), - cv.Optional(CONF_IRAM): cv.All( - cv.only_on_esp32, - sensor.sensor_schema( - unit_of_measurement=UNIT_BYTES, - icon=ICON_COUNTER, - accuracy_decimals=0, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - state_class=STATE_CLASS_MEASUREMENT, - ), - ), cv.Optional(CONF_CPU_FREQUENCY): cv.All( sensor.sensor_schema( unit_of_measurement=UNIT_HERTZ, @@ -149,10 +138,6 @@ async def to_code(config): sens = await sensor.new_sensor(psram_conf) cg.add(debug_component.set_psram_sensor(sens)) - if iram_conf := config.get(CONF_IRAM): - sens = await sensor.new_sensor(iram_conf) - cg.add(debug_component.set_iram_sensor(sens)) - if cpu_freq_conf := config.get(CONF_CPU_FREQUENCY): sens = await sensor.new_sensor(cpu_freq_conf) cg.add(debug_component.set_cpu_frequency_sensor(sens)) diff --git a/tests/components/debug/test.esp32-ard.yaml b/tests/components/debug/test.esp32-ard.yaml index 70b58d56da..8f93b0925e 100644 --- a/tests/components/debug/test.esp32-ard.yaml +++ b/tests/components/debug/test.esp32-ard.yaml @@ -9,5 +9,3 @@ sensor: name: "Heap Fragmentation" min_free: name: "Heap Min Free" - iram: - name: "Free IRAM" diff --git a/tests/components/debug/test.esp32-idf.yaml b/tests/components/debug/test.esp32-idf.yaml index 925610bbd4..6a9996ad06 100644 --- a/tests/components/debug/test.esp32-idf.yaml +++ b/tests/components/debug/test.esp32-idf.yaml @@ -13,7 +13,5 @@ sensor: name: "Heap Fragmentation" min_free: name: "Heap Min Free" - iram: - name: "Free IRAM" psram: diff --git a/tests/components/debug/test.esp32-s2-idf.yaml b/tests/components/debug/test.esp32-s2-idf.yaml index 6e23c54721..80919b0bab 100644 --- a/tests/components/debug/test.esp32-s2-idf.yaml +++ b/tests/components/debug/test.esp32-s2-idf.yaml @@ -6,5 +6,3 @@ sensor: name: "Heap Fragmentation" min_free: name: "Heap Min Free" - iram: - name: "Free IRAM" From e93f49978b2858003239e87a470e6d4caf8b24c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 16:19:12 -1000 Subject: [PATCH 0196/2214] [runtime_stats] Track main loop active time and report overhead Records per-iteration active wall time (excluding yield/sleep) and reports it alongside the component table. Overhead is computed at log time as active_total minus the sum of per-component time, exposing time spent in scheduler dispatch, before/after-loop tasks, and other loop machinery not attributed to any component. --- .../runtime_stats/runtime_stats.cpp | 72 ++++++++++++++----- .../components/runtime_stats/runtime_stats.h | 23 ++++++ esphome/core/application.h | 6 ++ 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 06714b5a44..82c38c80c4 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -32,40 +32,74 @@ void RuntimeStatsCollector::log_stats_() { " Period stats (last %" PRIu32 "ms): %zu active components", this->log_interval_, count); - if (count == 0) { - return; + // Sum component period time so we can derive main-loop overhead + // (active loop time minus time attributable to component loop()s). + uint64_t period_component_sum_us = 0; + uint64_t total_component_sum_us = 0; + for (size_t i = 0; i < count; i++) { + period_component_sum_us += sorted[i]->runtime_stats_.period_time_us; + total_component_sum_us += sorted[i]->runtime_stats_.total_time_us; } - // Sort by period runtime (descending) - std::sort(sorted, sorted + count, compare_period_time); + if (count > 0) { + // Sort by period runtime (descending) + std::sort(sorted, sorted + count, compare_period_time); - // Log top components by period runtime - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, - stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, - stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + // Log top components by period runtime + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, + stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, + stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + } + } + + // Main-loop overhead for the period: active wall time minus component time. + // active = sum of per-iteration loop time excluding yield/sleep. + if (this->period_active_count_ > 0) { + uint64_t active = this->period_active_time_us_; + uint64_t overhead = active > period_component_sum_us ? active - period_component_sum_us : 0; + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu32 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->period_active_count_, active / (float) this->period_active_count_ / 1000.0f, + this->period_active_max_us_ / 1000.0f, active / 1000.0f, overhead / 1000.0f); } // Log total stats since boot (only for active components - idle ones haven't changed) ESP_LOGI(TAG, " Total stats (since boot): %zu active components", count); - // Re-sort by total runtime for all-time stats - std::sort(sorted, sorted + count, compare_total_time); + if (count > 0) { + // Re-sort by total runtime for all-time stats + std::sort(sorted, sorted + count, compare_total_time); - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, - stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, - stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, + stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + } + } + + if (this->total_active_count_ > 0) { + uint64_t active = this->total_active_time_us_; + uint64_t overhead = active > total_component_sum_us ? active - total_component_sum_us : 0; + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu32 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->total_active_count_, active / (float) this->total_active_count_ / 1000.0f, + this->total_active_max_us_ / 1000.0f, active / 1000.0f, overhead / 1000.0f); } // Reset period stats for (auto *component : components) { component->runtime_stats_.reset_period(); } + this->period_active_count_ = 0; + this->period_active_time_us_ = 0; + this->period_active_max_us_ = 0; } bool RuntimeStatsCollector::compare_period_time(Component *a, Component *b) { diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 3c2c9f78ad..24534d222a 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -29,6 +29,21 @@ class RuntimeStatsCollector { // Process any pending stats printing (should be called after component loop) void process_pending_stats(uint32_t current_time); + // Record the wall time of one main loop iteration excluding the yield/sleep. + // Called once per loop from Application::loop(). Overhead (loop machinery, + // scheduler, before/after-loop tasks) is derived at log time as + // (loop_active_time) - (sum of per-component time). + void record_loop_active(uint32_t duration_us) { + this->period_active_count_++; + this->period_active_time_us_ += duration_us; + if (duration_us > this->period_active_max_us_) + this->period_active_max_us_ = duration_us; + this->total_active_count_++; + this->total_active_time_us_ += duration_us; + if (duration_us > this->total_active_max_us_) + this->total_active_max_us_ = duration_us; + } + protected: void log_stats_(); // Static comparators — member functions have friend access, lambdas do not @@ -37,6 +52,14 @@ class RuntimeStatsCollector { uint32_t log_interval_; uint32_t next_log_time_{0}; + + // Main loop active-time stats (wall time per iteration, excluding yield/sleep) + uint32_t period_active_count_{0}; + uint64_t period_active_time_us_{0}; + uint32_t period_active_max_us_{0}; + uint32_t total_active_count_{0}; + uint64_t total_active_time_us_{0}; + uint32_t total_active_max_us_{0}; }; } // namespace runtime_stats diff --git a/esphome/core/application.h b/esphome/core/application.h index 60087d527d..f015099437 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -877,6 +877,11 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ } inline void ESPHOME_ALWAYS_INLINE Application::loop() { +#ifdef USE_RUNTIME_STATS + // Capture the start of the active (non-sleeping) portion of this iteration. + // Used to derive main-loop overhead = active time − Σ(component time). + uint32_t loop_active_start_us = micros(); +#endif // Get the initial loop time at the start uint32_t last_op_end_time = millis(); @@ -905,6 +910,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Process any pending runtime stats printing after all components have run // This ensures stats printing doesn't affect component timing measurements if (global_runtime_stats != nullptr) { + global_runtime_stats->record_loop_active(micros() - loop_active_start_us); global_runtime_stats->process_pending_stats(last_op_end_time); } #endif From 65f0d4a000ce9e4d8732fa72f28c9f85801e07bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 16:28:50 -1000 Subject: [PATCH 0197/2214] merge --- esphome/core/application.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 6fdf6c7100..4d05728ea6 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -569,7 +569,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ this->in_loop_ = true; } -<<<<<<< HEAD // NOLINTNEXTLINE(clang-diagnostic-unknown-attributes) inline void ESPHOME_ALWAYS_INLINE __attribute__((optimize("O2"))) Application::loop() { #ifdef USE_RUNTIME_STATS From 2f1491219676c931efdf7c9badf53136c6249885 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 16:33:39 -1000 Subject: [PATCH 0198/2214] [runtime_stats] Widen iteration counters, fix overhead total, test main_loop line - Widen period/total iteration counters to uint64_t to avoid wrapping on long-running high-frequency loops. - Compute total component-time sum over all components (not just the period-active subset) so total overhead is not inflated by components that ran earlier but are idle now. - Extend integration test to parse the main_loop line and validate the iters/active_avg/active_total/overhead_total fields. --- .../runtime_stats/runtime_stats.cpp | 15 ++++++--- .../components/runtime_stats/runtime_stats.h | 8 +++-- tests/integration/test_runtime_stats.py | 33 +++++++++++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 82c38c80c4..17efdad176 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -32,13 +32,18 @@ void RuntimeStatsCollector::log_stats_() { " Period stats (last %" PRIu32 "ms): %zu active components", this->log_interval_, count); - // Sum component period time so we can derive main-loop overhead + // Sum component time so we can derive main-loop overhead // (active loop time minus time attributable to component loop()s). + // Period sum iterates the active-in-period subset; total sum must iterate + // all components since total_active_time_us_ includes iterations where + // currently-idle components previously ran. uint64_t period_component_sum_us = 0; - uint64_t total_component_sum_us = 0; for (size_t i = 0; i < count; i++) { period_component_sum_us += sorted[i]->runtime_stats_.period_time_us; - total_component_sum_us += sorted[i]->runtime_stats_.total_time_us; + } + uint64_t total_component_sum_us = 0; + for (auto *component : components) { + total_component_sum_us += component->runtime_stats_.total_time_us; } if (count > 0) { @@ -61,7 +66,7 @@ void RuntimeStatsCollector::log_stats_() { uint64_t active = this->period_active_time_us_; uint64_t overhead = active > period_component_sum_us ? active - period_component_sum_us : 0; ESP_LOGI(TAG, - " main_loop: iters=%" PRIu32 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " "overhead_total=%.1fms", this->period_active_count_, active / (float) this->period_active_count_ / 1000.0f, this->period_active_max_us_ / 1000.0f, active / 1000.0f, overhead / 1000.0f); @@ -87,7 +92,7 @@ void RuntimeStatsCollector::log_stats_() { uint64_t active = this->total_active_time_us_; uint64_t overhead = active > total_component_sum_us ? active - total_component_sum_us : 0; ESP_LOGI(TAG, - " main_loop: iters=%" PRIu32 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " "overhead_total=%.1fms", this->total_active_count_, active / (float) this->total_active_count_ / 1000.0f, this->total_active_max_us_ / 1000.0f, active / 1000.0f, overhead / 1000.0f); diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 24534d222a..4f0e3bbe1e 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -53,11 +53,13 @@ class RuntimeStatsCollector { uint32_t log_interval_; uint32_t next_log_time_{0}; - // Main loop active-time stats (wall time per iteration, excluding yield/sleep) - uint32_t period_active_count_{0}; + // Main loop active-time stats (wall time per iteration, excluding yield/sleep). + // Counters are uint64_t — at sub-millisecond loop times a uint32_t can wrap in + // a few weeks of uptime, which is well within ESPHome device lifetimes. + uint64_t period_active_count_{0}; uint64_t period_active_time_us_{0}; uint32_t period_active_max_us_{0}; - uint32_t total_active_count_{0}; + uint64_t total_active_count_{0}; uint64_t total_active_time_us_{0}; uint32_t total_active_max_us_{0}; }; diff --git a/tests/integration/test_runtime_stats.py b/tests/integration/test_runtime_stats.py index 9e93035d83..bd7f36341d 100644 --- a/tests/integration/test_runtime_stats.py +++ b/tests/integration/test_runtime_stats.py @@ -26,6 +26,7 @@ async def test_runtime_stats( # Track component stats component_stats_found = set() + main_loop_lines: list[dict[str, str]] = [] # Patterns to match - need to handle ANSI color codes and timestamps # The log format is: [HH:MM:SS][color codes][I][tag]: message @@ -34,6 +35,14 @@ async def test_runtime_stats( component_pattern = re.compile( r"^\[[^\]]+\].*?\s+([\w.]+):\s+count=(\d+),\s+avg=([\d.]+)ms" ) + # Main loop overhead line emitted by runtime_stats + main_loop_pattern = re.compile( + r"main_loop:\s+iters=(?P\d+),\s+" + r"active_avg=(?P[\d.]+)ms,\s+" + r"active_max=(?P[\d.]+)ms,\s+" + r"active_total=(?P[\d.]+)ms,\s+" + r"overhead_total=(?P[\d.]+)ms" + ) def check_output(line: str) -> None: """Check log output for runtime stats messages.""" @@ -54,6 +63,11 @@ async def test_runtime_stats( component_name = match.group(1) component_stats_found.add(component_name) + # Check for main_loop overhead line + ml_match = main_loop_pattern.search(line) + if ml_match: + main_loop_lines.append(ml_match.groupdict()) + async with ( run_compiled(yaml_config, line_callback=check_output), api_client_connected() as client, @@ -86,3 +100,22 @@ async def test_runtime_stats( assert "template.switch" in component_stats_found, ( f"Expected template.switch stats, found: {component_stats_found}" ) + + # Verify the main_loop overhead line is emitted (at least once for + # the period section and once for the total section, per log cycle). + assert len(main_loop_lines) >= 2, ( + f"Expected at least 2 main_loop lines, got {len(main_loop_lines)}" + ) + for fields in main_loop_lines: + assert int(fields["iters"]) > 0, f"iters should be > 0: {fields}" + assert float(fields["active_total"]) > 0.0, ( + f"active_total should be > 0: {fields}" + ) + assert float(fields["active_avg"]) >= 0.0, ( + f"active_avg should be >= 0: {fields}" + ) + # overhead_total is derived and may be 0 if components dominate, + # but the field must still be present and parseable as a float. + assert float(fields["overhead_total"]) >= 0.0, ( + f"overhead_total should be >= 0: {fields}" + ) From 5d2e7a014c8f9d388a6bb7298f60c4691805e987 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 16:50:27 -1000 Subject: [PATCH 0199/2214] [runtime_stats] Split main loop overhead into before/tail/inter_component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second log line per section that breaks overhead down into: before — time in before_loop_tasks_ (scheduler + ISR enable_loop) tail — time in after_loop_tasks_ + trailing record prefix inter_component — residual: per-iteration bookkeeping between components (set_current_component, WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time, the for loop itself) Useful for isolating whether per-loop overhead changes come from scheduler work, loop teardown, or inter-component bookkeeping. --- .../runtime_stats/runtime_stats.cpp | 14 ++++++++ .../components/runtime_stats/runtime_stats.h | 36 +++++++++++++------ esphome/core/application.h | 14 ++++++-- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 17efdad176..d287855429 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -70,6 +70,12 @@ void RuntimeStatsCollector::log_stats_() { "overhead_total=%.1fms", this->period_active_count_, active / (float) this->period_active_count_ / 1000.0f, this->period_active_max_us_ / 1000.0f, active / 1000.0f, overhead / 1000.0f); + uint64_t before = this->period_before_time_us_; + uint64_t tail = this->period_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", before / 1000.0f, + tail / 1000.0f, inter / 1000.0f); } // Log total stats since boot (only for active components - idle ones haven't changed) @@ -96,6 +102,12 @@ void RuntimeStatsCollector::log_stats_() { "overhead_total=%.1fms", this->total_active_count_, active / (float) this->total_active_count_ / 1000.0f, this->total_active_max_us_ / 1000.0f, active / 1000.0f, overhead / 1000.0f); + uint64_t before = this->total_before_time_us_; + uint64_t tail = this->total_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", before / 1000.0f, + tail / 1000.0f, inter / 1000.0f); } // Reset period stats @@ -105,6 +117,8 @@ void RuntimeStatsCollector::log_stats_() { this->period_active_count_ = 0; this->period_active_time_us_ = 0; this->period_active_max_us_ = 0; + this->period_before_time_us_ = 0; + this->period_tail_time_us_ = 0; } bool RuntimeStatsCollector::compare_period_time(Component *a, Component *b) { diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 4f0e3bbe1e..82e0fb7c61 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -30,18 +30,28 @@ class RuntimeStatsCollector { void process_pending_stats(uint32_t current_time); // Record the wall time of one main loop iteration excluding the yield/sleep. - // Called once per loop from Application::loop(). Overhead (loop machinery, - // scheduler, before/after-loop tasks) is derived at log time as - // (loop_active_time) - (sum of per-component time). - void record_loop_active(uint32_t duration_us) { + // Called once per loop from Application::loop(). + // active_us = total time between loop start and just before yield. + // before_us = time spent in before_loop_tasks_ (scheduler + ISR enable_loop). + // tail_us = time spent in after_loop_tasks_ + the trailing record/stats prefix. + // Residual overhead at log time = active − Σ(component) − before − tail, + // which captures per-iteration inter-component bookkeeping (set_current_component, + // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // the for-loop itself). + void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { this->period_active_count_++; - this->period_active_time_us_ += duration_us; - if (duration_us > this->period_active_max_us_) - this->period_active_max_us_ = duration_us; + this->period_active_time_us_ += active_us; + if (active_us > this->period_active_max_us_) + this->period_active_max_us_ = active_us; this->total_active_count_++; - this->total_active_time_us_ += duration_us; - if (duration_us > this->total_active_max_us_) - this->total_active_max_us_ = duration_us; + this->total_active_time_us_ += active_us; + if (active_us > this->total_active_max_us_) + this->total_active_max_us_ = active_us; + + this->period_before_time_us_ += before_us; + this->total_before_time_us_ += before_us; + this->period_tail_time_us_ += tail_us; + this->total_tail_time_us_ += tail_us; } protected: @@ -62,6 +72,12 @@ class RuntimeStatsCollector { uint64_t total_active_count_{0}; uint64_t total_active_time_us_{0}; uint32_t total_active_max_us_{0}; + + // Split of overhead sections — accumulated per iteration. + uint64_t period_before_time_us_{0}; + uint64_t total_before_time_us_{0}; + uint64_t period_tail_time_us_{0}; + uint64_t total_tail_time_us_{0}; }; } // namespace runtime_stats diff --git a/esphome/core/application.h b/esphome/core/application.h index f015099437..e2701b6ec3 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -879,13 +879,17 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifdef USE_RUNTIME_STATS // Capture the start of the active (non-sleeping) portion of this iteration. - // Used to derive main-loop overhead = active time − Σ(component time). + // Used to derive main-loop overhead = active time − Σ(component time) − + // before/tail splits recorded below. uint32_t loop_active_start_us = micros(); #endif // Get the initial loop time at the start uint32_t last_op_end_time = millis(); this->before_loop_tasks_(last_op_end_time); +#ifdef USE_RUNTIME_STATS + uint32_t loop_before_end_us = micros(); +#endif for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; this->current_loop_index_++) { @@ -904,13 +908,19 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->feed_wdt_with_time(last_op_end_time); } +#ifdef USE_RUNTIME_STATS + uint32_t loop_tail_start_us = micros(); +#endif this->after_loop_tasks_(); #ifdef USE_RUNTIME_STATS // Process any pending runtime stats printing after all components have run // This ensures stats printing doesn't affect component timing measurements if (global_runtime_stats != nullptr) { - global_runtime_stats->record_loop_active(micros() - loop_active_start_us); + uint32_t loop_now_us = micros(); + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, + loop_before_end_us - loop_active_start_us, + loop_now_us - loop_tail_start_us); global_runtime_stats->process_pending_stats(last_op_end_time); } #endif From a3a531f672834b885811700c2c3bcde2822e2c81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 16:58:03 -1000 Subject: [PATCH 0200/2214] [runtime_stats] Subtract scheduled-callback time from before bucket The scheduler also constructs WarnIfComponentBlockingGuard around scheduled item execution (scheduler.cpp:740), so set_interval / set_timeout / defer callbacks record into the affected component's runtime_stats_. Since scheduler.call() runs inside before_loop_tasks_, that time was double-counted: once in component period/total, once in the "before" overhead bucket. Observed in the field as before > overhead_total with inter_component clamped to 0. Fix: add a global cumulative counter on ComponentRuntimeStats that record_time() bumps on every guard finish, snapshot it around before_loop_tasks_, and subtract the delta from the before bucket before passing it to record_loop_active. --- esphome/core/application.h | 15 +++++++++++++-- esphome/core/component.cpp | 4 ++++ esphome/core/component.h | 8 ++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index e2701b6ec3..d7c3b441da 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -882,6 +882,11 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Used to derive main-loop overhead = active time − Σ(component time) − // before/tail splits recorded below. uint32_t loop_active_start_us = micros(); + // Snapshot the cumulative component-recorded time so we can subtract the + // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // (scheduler.cpp) — that time is already counted in per-component stats, + // so charging it again to "before" would double-count. + uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us_; #endif // Get the initial loop time at the start uint32_t last_op_end_time = millis(); @@ -889,6 +894,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->before_loop_tasks_(last_op_end_time); #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); + uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us_ - loop_recorded_snap; #endif for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; @@ -918,8 +924,13 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // This ensures stats printing doesn't affect component timing measurements if (global_runtime_stats != nullptr) { uint32_t loop_now_us = micros(); - global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, - loop_before_end_us - loop_active_start_us, + // Subtract scheduled-component time from the "before" bucket so it is + // not double-counted (it is already attributed to per-component stats). + uint32_t loop_before_wall_us = loop_before_end_us - loop_active_start_us; + uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us + ? loop_before_wall_us - static_cast(loop_before_scheduled_us) + : 0; + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, loop_now_us - loop_tail_start_us); global_runtime_stats->process_pending_stats(last_op_end_time); } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 8949b4b76d..235903cf7d 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -506,6 +506,10 @@ void PollingComponent::stop_poller() { uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } +#ifdef USE_RUNTIME_STATS +uint64_t ComponentRuntimeStats::global_recorded_us_ = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + void __attribute__((noinline, cold)) WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { bool should_warn; diff --git a/esphome/core/component.h b/esphome/core/component.h index 3307c5ae76..648cbda870 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -116,6 +116,13 @@ struct ComponentRuntimeStats { uint64_t total_time_us{0}; uint32_t total_max_time_us{0}; + // Cumulative sum of every record_time() duration since boot, across all + // components. Used by Application::loop() to snapshot time spent inside + // WarnIfComponentBlockingGuard (including guards constructed by the + // scheduler at scheduler.cpp) so main-loop overhead accounting can + // subtract scheduled-callback time from the before_loop_tasks_ wall time. + static uint64_t global_recorded_us_; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void record_time(uint32_t duration_us) { this->period_count++; this->period_time_us += duration_us; @@ -125,6 +132,7 @@ struct ComponentRuntimeStats { this->total_time_us += duration_us; if (duration_us > this->total_max_time_us) this->total_max_time_us = duration_us; + global_recorded_us_ += duration_us; } void reset_period() { this->period_count = 0; From a20680311c323a64c02c8c1f7c36f11bef906d7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 17:09:20 -1000 Subject: [PATCH 0201/2214] [runtime_stats] Rename global_recorded_us per clang-tidy, use double for ms conversions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop trailing underscore on ComponentRuntimeStats::global_recorded_us (public static, clang-tidy readability-identifier-naming). - Use double instead of float for uint64_t microsecond → millisecond conversions on the main_loop and main_loop_overhead_section lines. Float's ~7-digit mantissa loses resolution on multi-day totals that reach into the 10^8-10^9 microsecond range. --- .../runtime_stats/runtime_stats.cpp | 24 ++++++++++++------- esphome/core/application.h | 4 ++-- esphome/core/component.cpp | 2 +- esphome/core/component.h | 4 ++-- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index d287855429..9ed141155a 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -65,17 +65,22 @@ void RuntimeStatsCollector::log_stats_() { if (this->period_active_count_ > 0) { uint64_t active = this->period_active_time_us_; uint64_t overhead = active > period_component_sum_us ? active - period_component_sum_us : 0; + // Use double for µs→ms conversion so multi-day uptimes (where total + // microsecond counters exceed float's ~7-digit mantissa) keep resolution. ESP_LOGI(TAG, " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " "overhead_total=%.1fms", - this->period_active_count_, active / (float) this->period_active_count_ / 1000.0f, - this->period_active_max_us_ / 1000.0f, active / 1000.0f, overhead / 1000.0f); + this->period_active_count_, + static_cast(active) / static_cast(this->period_active_count_) / 1000.0, + static_cast(this->period_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); uint64_t before = this->period_before_time_us_; uint64_t tail = this->period_tail_time_us_; uint64_t accounted = before + tail; uint64_t inter = overhead > accounted ? overhead - accounted : 0; - ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", before / 1000.0f, - tail / 1000.0f, inter / 1000.0f); + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Log total stats since boot (only for active components - idle ones haven't changed) @@ -100,14 +105,17 @@ void RuntimeStatsCollector::log_stats_() { ESP_LOGI(TAG, " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " "overhead_total=%.1fms", - this->total_active_count_, active / (float) this->total_active_count_ / 1000.0f, - this->total_active_max_us_ / 1000.0f, active / 1000.0f, overhead / 1000.0f); + this->total_active_count_, + static_cast(active) / static_cast(this->total_active_count_) / 1000.0, + static_cast(this->total_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); uint64_t before = this->total_before_time_us_; uint64_t tail = this->total_tail_time_us_; uint64_t accounted = before + tail; uint64_t inter = overhead > accounted ? overhead - accounted : 0; - ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", before / 1000.0f, - tail / 1000.0f, inter / 1000.0f); + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Reset period stats diff --git a/esphome/core/application.h b/esphome/core/application.h index d7c3b441da..39617442db 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -886,7 +886,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard // (scheduler.cpp) — that time is already counted in per-component stats, // so charging it again to "before" would double-count. - uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us_; + uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; #endif // Get the initial loop time at the start uint32_t last_op_end_time = millis(); @@ -894,7 +894,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->before_loop_tasks_(last_op_end_time); #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); - uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us_ - loop_recorded_snap; + uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; #endif for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 235903cf7d..e33652482e 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -507,7 +507,7 @@ void PollingComponent::stop_poller() { uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } #ifdef USE_RUNTIME_STATS -uint64_t ComponentRuntimeStats::global_recorded_us_ = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif void __attribute__((noinline, cold)) diff --git a/esphome/core/component.h b/esphome/core/component.h index 648cbda870..6fbb0d5c06 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -121,7 +121,7 @@ struct ComponentRuntimeStats { // WarnIfComponentBlockingGuard (including guards constructed by the // scheduler at scheduler.cpp) so main-loop overhead accounting can // subtract scheduled-callback time from the before_loop_tasks_ wall time. - static uint64_t global_recorded_us_; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void record_time(uint32_t duration_us) { this->period_count++; @@ -132,7 +132,7 @@ struct ComponentRuntimeStats { this->total_time_us += duration_us; if (duration_us > this->total_max_time_us) this->total_max_time_us = duration_us; - global_recorded_us_ += duration_us; + global_recorded_us += duration_us; } void reset_period() { this->period_count = 0; From 3912ede2cf5b9eb5884ec09641986e037a9263d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 20:42:25 -1000 Subject: [PATCH 0202/2214] safer --- esphome/components/esp8266/core.cpp | 36 +++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 7551ecc3a7..1cca79f104 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -34,9 +34,10 @@ void HOT yield() { ::yield(); } // that Arduino library code and ISR handlers (e.g. Wiegand, ZyAura) calling // ::millis() directly also get the fast version. Interrupts are briefly disabled // to protect the static state from concurrent ISR access. The critical section -// is bounded: the common path (delta < 10 ms) runs at most 10 subtract-and- -// compare iterations (~100 ns). Large gaps (WiFi scan, boot) fall back to a -// constant-time multiply-by-reciprocal (~2.5 μs, rare). +// is bounded on *both* paths: the common path (delta < 10 ms) runs at most 10 +// subtract-and-compare iterations (~100 ns). The rare path drops out of the +// critical section before doing the expensive /1000, so unrelated peripherals +// and the WiFi MAC are not blocked while the divide runs. // Threshold above which we use constant-time /1000 instead of the while loop. // 10 ms means the while loop runs at most 10 iterations (~100 ns) on the // common path, well within the WiFi stack's ~10 μs interrupt latency budget. @@ -56,20 +57,35 @@ uint32_t IRAM_ATTR HOT millis() { uint32_t delta = now_us - state.last_us; state.last_us = now_us; state.remainder += delta; - if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { - // Rare path: large gap (WiFi scan, boot, long block). Constant-time - // conversion keeps the critical section bounded. - uint32_t ms = state.remainder / US_PER_MS; - state.cache += ms; - state.remainder -= ms * US_PER_MS; - } else { + if (state.remainder < MILLIS_RARE_PATH_THRESHOLD_US) { // Common path: small gap. Loop runs at most // MILLIS_RARE_PATH_THRESHOLD_US / US_PER_MS iterations. while (state.remainder >= US_PER_MS) { state.cache++; state.remainder -= US_PER_MS; } + uint32_t result = state.cache; + xt_wsr_ps(ps); + return result; } + // Rare path: large gap (WiFi scan, boot, long block). Take the accumulated + // μs out of the shared state, drop the critical section, then divide. This + // keeps interrupts unmasked during the ~2.5 μs __umulsidi3 — the rare path + // fires precisely when the system was already blocked, which is also when + // WiFi/peripheral ISRs most need to run on time. + // + // Concurrency on single-core ESP8266: only an ISR can preempt us. An ISR + // that calls millis() during the divide sees remainder=0 and a tiny delta, + // takes the common path, and returns the pre-commit cache. Monotonic — and + // any μs it adds to remainder are preserved by the `+=` on commit. + uint32_t pending_us = state.remainder; + state.remainder = 0; + xt_wsr_ps(ps); + uint32_t ms_to_add = pending_us / US_PER_MS; + uint32_t us_left = pending_us - ms_to_add * US_PER_MS; + ps = xt_rsil(15); + state.cache += ms_to_add; + state.remainder += us_left; uint32_t result = state.cache; xt_wsr_ps(ps); return result; From cf994c3b9f307fb3f4b8a13c49a56f87815ce0b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 20:48:50 -1000 Subject: [PATCH 0203/2214] back to inlock divide .. was overkill and 20% regression --- esphome/components/esp8266/core.cpp | 36 ++++++++--------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 1cca79f104..7551ecc3a7 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -34,10 +34,9 @@ void HOT yield() { ::yield(); } // that Arduino library code and ISR handlers (e.g. Wiegand, ZyAura) calling // ::millis() directly also get the fast version. Interrupts are briefly disabled // to protect the static state from concurrent ISR access. The critical section -// is bounded on *both* paths: the common path (delta < 10 ms) runs at most 10 -// subtract-and-compare iterations (~100 ns). The rare path drops out of the -// critical section before doing the expensive /1000, so unrelated peripherals -// and the WiFi MAC are not blocked while the divide runs. +// is bounded: the common path (delta < 10 ms) runs at most 10 subtract-and- +// compare iterations (~100 ns). Large gaps (WiFi scan, boot) fall back to a +// constant-time multiply-by-reciprocal (~2.5 μs, rare). // Threshold above which we use constant-time /1000 instead of the while loop. // 10 ms means the while loop runs at most 10 iterations (~100 ns) on the // common path, well within the WiFi stack's ~10 μs interrupt latency budget. @@ -57,35 +56,20 @@ uint32_t IRAM_ATTR HOT millis() { uint32_t delta = now_us - state.last_us; state.last_us = now_us; state.remainder += delta; - if (state.remainder < MILLIS_RARE_PATH_THRESHOLD_US) { + if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { + // Rare path: large gap (WiFi scan, boot, long block). Constant-time + // conversion keeps the critical section bounded. + uint32_t ms = state.remainder / US_PER_MS; + state.cache += ms; + state.remainder -= ms * US_PER_MS; + } else { // Common path: small gap. Loop runs at most // MILLIS_RARE_PATH_THRESHOLD_US / US_PER_MS iterations. while (state.remainder >= US_PER_MS) { state.cache++; state.remainder -= US_PER_MS; } - uint32_t result = state.cache; - xt_wsr_ps(ps); - return result; } - // Rare path: large gap (WiFi scan, boot, long block). Take the accumulated - // μs out of the shared state, drop the critical section, then divide. This - // keeps interrupts unmasked during the ~2.5 μs __umulsidi3 — the rare path - // fires precisely when the system was already blocked, which is also when - // WiFi/peripheral ISRs most need to run on time. - // - // Concurrency on single-core ESP8266: only an ISR can preempt us. An ISR - // that calls millis() during the divide sees remainder=0 and a tiny delta, - // takes the common path, and returns the pre-commit cache. Monotonic — and - // any μs it adds to remainder are preserved by the `+=` on commit. - uint32_t pending_us = state.remainder; - state.remainder = 0; - xt_wsr_ps(ps); - uint32_t ms_to_add = pending_us / US_PER_MS; - uint32_t us_left = pending_us - ms_to_add * US_PER_MS; - ps = xt_rsil(15); - state.cache += ms_to_add; - state.remainder += us_left; uint32_t result = state.cache; xt_wsr_ps(ps); return result; From 84a98e6ecec4109f7a727df65a434cf194d0ce8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 21:22:02 -1000 Subject: [PATCH 0204/2214] internal --- esphome/core/application.h | 2 +- esphome/core/component.h | 3 ++- esphome/core/millis_internal.h | 42 ++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 esphome/core/millis_internal.h diff --git a/esphome/core/application.h b/esphome/core/application.h index 60087d527d..1c2bc523cf 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -878,7 +878,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Get the initial loop time at the start - uint32_t last_op_end_time = millis(); + uint32_t last_op_end_time = MillisInternal::get(); this->before_loop_tasks_(last_op_end_time); diff --git a/esphome/core/component.h b/esphome/core/component.h index 3307c5ae76..30db0514c1 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -9,6 +9,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/millis_internal.h" #include "esphome/core/optional.h" // Forward declarations for friend access from codegen-generated setup() @@ -648,7 +649,7 @@ class WarnIfComponentBlockingGuard { #ifdef USE_RUNTIME_STATS this->component_->runtime_stats_.record_time(micros() - this->started_us_); #endif - uint32_t curr_time = millis(); + uint32_t curr_time = MillisInternal::get(); #ifndef USE_BENCHMARK // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h new file mode 100644 index 0000000000..6b73476680 --- /dev/null +++ b/esphome/core/millis_internal.h @@ -0,0 +1,42 @@ +#pragma once + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +#if defined(USE_ESP32) +#include +#include +#include +#endif + +namespace esphome { + +// Friend-gated accessor for a fast millis() variant intended only for +// known task-context callers on the main loop hot path (Application::loop() +// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// dispatch that the public esphome::millis() pays on ESP32. +// +// MUST NOT be called from ISR context: on ESP32 it calls the non-FromISR +// FreeRTOS API directly, which is undefined behavior in ISR context. +// +// Adding new callers requires adding a friend declaration here — that +// is the review point. Do not relax the access (e.g. by making get() +// public) without considering the ISR-safety contract. +// +// Other platforms currently delegate to the public millis(); the friend +// gate still enforces the intent so platform-specific fast paths can be +// added later without changing call sites. +class MillisInternal { + private: + static ESPHOME_ALWAYS_INLINE uint32_t get() { +#if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000 + return xTaskGetTickCount(); +#else + return millis(); +#endif + } + friend class Application; + friend class WarnIfComponentBlockingGuard; +}; + +} // namespace esphome From 4dcaea15a3079381d4095050613b72be4b66db73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 21:54:48 -1000 Subject: [PATCH 0205/2214] missed some sites --- esphome/core/application.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 1c73230705..6c7670ceaa 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -78,7 +78,7 @@ void Application::setup() { Component *component = this->components_[i]; // Update loop_component_start_time_ before calling each component during setup - this->loop_component_start_time_ = millis(); + this->loop_component_start_time_ = MillisInternal::get(); component->call(); this->scheduler.process_to_add(); this->feed_wdt(); @@ -91,14 +91,14 @@ void Application::setup() { this->app_state_ |= STATUS_LED_WARNING; do { - uint32_t now = millis(); + uint32_t now = MillisInternal::get(); // Process pending loop enables to handle GPIO interrupts during setup this->before_loop_tasks_(now); for (uint32_t j = 0; j <= i; j++) { // Update loop_component_start_time_ right before calling each component - this->loop_component_start_time_ = millis(); + this->loop_component_start_time_ = MillisInternal::get(); this->components_[j]->call(); this->feed_wdt(); } @@ -212,7 +212,7 @@ void Application::process_dump_config_() { void Application::feed_wdt() { // Cold entry: callers without a millis() timestamp in hand. Fetches the // time and takes the same rate-limit path as feed_wdt_with_time(). - uint32_t now = millis(); + uint32_t now = MillisInternal::get(); if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { this->feed_wdt_slow_(now); } @@ -274,7 +274,7 @@ void Application::run_powerdown_hooks() { } void Application::teardown_components(uint32_t timeout_ms) { - uint32_t start_time = millis(); + uint32_t start_time = MillisInternal::get(); // Use a StaticVector instead of std::vector to avoid heap allocation // since we know the actual size at compile time @@ -353,7 +353,7 @@ void Application::teardown_components(uint32_t timeout_ms) { } // Update time for next iteration - now = millis(); + now = MillisInternal::get(); } if (pending_count > 0) { @@ -396,7 +396,7 @@ void Application::disable_component_loop_(Component *component) { // This prevents integer underflow in timing calculations by ensuring // the swapped component starts with a fresh timing reference, avoiding // errors caused by stale or wrapped timing values. - this->loop_component_start_time_ = millis(); + this->loop_component_start_time_ = MillisInternal::get(); } } return; From 6a6d9bb80876f6445ae877a0d1a24235758d1943 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 21:58:22 -1000 Subject: [PATCH 0206/2214] cleanup --- esphome/core/scheduler.h | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 0372e06066..432a488a38 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -284,12 +284,14 @@ class Scheduler { bool cancel_retry_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); // Extend a 32-bit millis() value to 64-bit. Use when the caller already has a fresh now. - // On platforms with native 64-bit time (ESP32), ignores now and uses millis_64() directly. - // This means the Scheduler uses the esp_timer clock (via millis_64()) for all scheduling, - // even though the caller's 32-bit now came from xTaskGetTickCount (via millis()). Safe - // because scheduling only compares millis_64 against millis_64 — never against millis(). - // On other platforms (ESP8266), extends now to 64-bit using rollover tracking, so both - // millis() and scheduling use the same clock. + // On platforms with native 64-bit time (ESP32, Host, Zephyr, RP2040 — see + // USE_NATIVE_64BIT_TIME in defines.h), ignores now and uses millis_64() directly, so the + // Scheduler always works in 64-bit time regardless of what the caller's 32-bit now came + // from. On ESP32 specifically, millis() comes from xTaskGetTickCount while millis_64() + // comes from esp_timer — two different clocks — but that is safe because scheduling + // compares millis_64 values against millis_64 only, never against millis(). + // On platforms without native 64-bit time (e.g. ESP8266), extends now to 64-bit using + // rollover tracking, so both millis() and scheduling use the same underlying clock. uint64_t ESPHOME_ALWAYS_INLINE millis_64_from_(uint32_t now) { #ifdef USE_NATIVE_64BIT_TIME (void) now; From 2bdd9f6217a664e16a7a5f3270a9aae000b7e434 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:44:30 +1200 Subject: [PATCH 0207/2214] Bump version to 2026.4.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index fb85e8028c..6f1f3d0e7e 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.4.0b3 +PROJECT_NUMBER = 2026.4.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 d5d726d0db..95fd488dca 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.0b3" +__version__ = "2026.4.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 3b82c6e38be2156b2446d19de51b7be78c7e4b86 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:32:11 -0400 Subject: [PATCH 0208/2214] [st7789v] Fix swapped offset_width/offset_height in model presets (#15755) --- esphome/components/st7789v/display.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index 85414237cf..745c37f47d 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -45,8 +45,8 @@ MODELS = { presets={ CONF_HEIGHT: 240, CONF_WIDTH: 135, - CONF_OFFSET_HEIGHT: 52, - CONF_OFFSET_WIDTH: 40, + CONF_OFFSET_HEIGHT: 40, + CONF_OFFSET_WIDTH: 52, CONF_CS_PIN: "GPIO5", CONF_DC_PIN: "GPIO16", CONF_RESET_PIN: "GPIO23", @@ -68,8 +68,8 @@ MODELS = { presets={ CONF_HEIGHT: 280, CONF_WIDTH: 240, - CONF_OFFSET_HEIGHT: 0, - CONF_OFFSET_WIDTH: 20, + CONF_OFFSET_HEIGHT: 20, + CONF_OFFSET_WIDTH: 0, } ), "ADAFRUIT_S2_TFT_FEATHER_240X135": model_spec( @@ -77,8 +77,8 @@ MODELS = { presets={ CONF_HEIGHT: 240, CONF_WIDTH: 135, - CONF_OFFSET_HEIGHT: 52, - CONF_OFFSET_WIDTH: 40, + CONF_OFFSET_HEIGHT: 40, + CONF_OFFSET_WIDTH: 52, CONF_CS_PIN: "GPIO7", CONF_DC_PIN: "GPIO39", CONF_RESET_PIN: "GPIO40", @@ -89,8 +89,8 @@ MODELS = { presets={ CONF_HEIGHT: 320, CONF_WIDTH: 170, - CONF_OFFSET_HEIGHT: 35, - CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + CONF_OFFSET_WIDTH: 35, CONF_ROTATION: 270, CONF_CS_PIN: "GPIO10", CONF_DC_PIN: "GPIO13", @@ -102,8 +102,8 @@ MODELS = { presets={ CONF_HEIGHT: 320, CONF_WIDTH: 172, - CONF_OFFSET_HEIGHT: 34, - CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + CONF_OFFSET_WIDTH: 34, CONF_ROTATION: 90, CONF_CS_PIN: "GPIO21", CONF_DC_PIN: "GPIO22", From 274c01ca74c5ee5d65d85d128ce852ef28f0de3b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:32:33 -0400 Subject: [PATCH 0209/2214] [sx126x][sx127x] Fix frequency precision loss from float32 codegen (#15753) --- esphome/components/sx126x/__init__.py | 4 ++-- esphome/components/sx127x/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 08f4c0fb88..b8696158fe 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -200,11 +200,11 @@ CONFIG_SCHEMA = ( cv.hex_int, cv.Range(min=0, max=0xFFFF) ), cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( - cv.frequency, cv.float_range(min=0, max=100000) + cv.frequency, cv.int_range(min=0, max=100000) ), cv.Required(CONF_DIO1_PIN): pins.gpio_input_pin_schema, cv.Required(CONF_FREQUENCY): cv.All( - cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + cv.frequency, cv.int_range(min=int(137e6), max=int(1020e6)) ), cv.Required(CONF_HW_VERSION): cv.one_of( "sx1261", "sx1262", "sx1268", "llcc68", lower=True diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 7f554fbf84..8fa7247192 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -197,11 +197,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_CODING_RATE, default="CR_4_5"): cv.enum(CODING_RATE), cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( - cv.frequency, cv.float_range(min=0, max=100000) + cv.frequency, cv.int_range(min=0, max=100000) ), cv.Optional(CONF_DIO0_PIN): pins.internal_gpio_input_pin_schema, cv.Required(CONF_FREQUENCY): cv.All( - cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + cv.frequency, cv.int_range(min=int(137e6), max=int(1020e6)) ), cv.Required(CONF_MODULATION): cv.enum(MOD), cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), From 10f52f2056890183a9b79d3036b219afed4274d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:07:49 -1000 Subject: [PATCH 0210/2214] Bump aioesphomeapi from 44.15.0 to 44.16.0 (#15757) 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 cd3aa5bd86..36c81e25bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.15.0 +aioesphomeapi==44.16.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 403a9f7b7eef07e89e68c6b8c10da2a4b0041fd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:12:30 -1000 Subject: [PATCH 0211/2214] Bump github/codeql-action from 4.35.1 to 4.35.2 (#15759) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 67f4690ac9..246a865693 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: category: "/language:${{matrix.language}}" From 3e5c4fd603cf04a1b662af75f94738fd6d266854 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:42:17 -1000 Subject: [PATCH 0212/2214] Make IRAM_ATTR functional on LibreTiny Previously, IRAM_ATTR was an empty no-op on every LibreTiny family, so any ISR handler (gpio binary sensor, cc1101, sx126x/sx127x, mcp23xxx, pcf8574, pca9554, pca6416a, pi4ioe5v6408, tca9555, ...) lived in flash. When the ISR fired while flash was busy (XIP stall, OTA, logger flash write), the device could deadlock or crash. - hal.h: IRAM_ATTR now routes each family into a RAM-resident section (RTL8710B .image2.ram.text, RTL8720C .sram.text, BK72xx / LN882H .data, which the SDK startup code copies from flash into SRAM before main). Also adds esphome::in_isr_context() as a portable always_inline ISR detection helper: xPortInIsrContext on ESP32, PS.INTLEVEL on ESP8266, IPSR on Cortex-M cores, CPSR mode on BK72xx ARM9. - main_task.h: both notify helpers marked always_inline so IRAM callers keep the wake path in IRAM; removes the ESP32-only notify_any_context which moves into wake.h. - wake.h / wake.cpp: LibreTiny now shares the ESP32 wake path via a new wake_main_task_any_context() helper that picks between xTaskNotifyGive and vTaskNotifyGiveFromISR using in_isr_context(). wake_loop_any_context and wake_loop_isrsafe are now IRAM_ATTR entry points on LibreTiny too. - libretiny/patch_linker.py.script: pre-link hook modelled on the ESP8266 testing_mode patcher. BK72xx linker templates declare the SRAM region as (rw!x), blocking code placement in .data. The hook flips that to (rwx) so IRAM_ATTR functions can be emitted there; the MMU already permits RAM execution (the Wi-Fi driver runs from SRAM). No-op on the other families. --- esphome/components/libretiny/__init__.py | 16 +++++ .../libretiny/patch_linker.py.script | 64 +++++++++++++++++++ esphome/core/hal.h | 59 +++++++++++++++++ esphome/core/main_task.h | 19 ++---- esphome/core/wake.cpp | 6 +- esphome/core/wake.h | 22 ++++--- 6 files changed, 159 insertions(+), 27 deletions(-) create mode 100644 esphome/components/libretiny/patch_linker.py.script diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 656eee6d7b..0635e5b788 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -1,5 +1,6 @@ import json import logging +from pathlib import Path import esphome.codegen as cg import esphome.config_validation as cv @@ -24,6 +25,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.core.config import BOARD_MAX_LENGTH +from esphome.helpers import copy_file_if_changed from esphome.storage_json import StorageJSON from . import gpio # noqa @@ -465,6 +467,10 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) + # Patch the linker script to add a ".sram.text" output section so IRAM_ATTR + # (defined in esphome/core/hal.h) places code in SRAM on BK72xx and LN882H. + # No-op on RTL8710B/RTL8720C whose stock linker scripts already provide it. + cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) @@ -549,3 +555,13 @@ async def component_to_code(config): _configure_lwip(config) await cg.register_component(var, config) + + +# Called by writer.py +def copy_files() -> None: + dir = Path(__file__).parent + patch_linker_file = dir / "patch_linker.py.script" + copy_file_if_changed( + patch_linker_file, + CORE.relative_build_path("patch_linker.py"), + ) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script new file mode 100644 index 0000000000..56a42db0ee --- /dev/null +++ b/esphome/components/libretiny/patch_linker.py.script @@ -0,0 +1,64 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import os +import re + +# BK72xx linker templates declare the SRAM region as "(rw!x)" — read/write but +# not executable. That prevents the linker from emitting functions we mark +# IRAM_ATTR (defined as section(".data") on BK72xx in esphome/core/hal.h) into +# the .data output section, which is the only section the SDK startup code +# copies from flash into SRAM before main() runs. +# +# LibreTiny's own Wi-Fi driver already runs code from SRAM at runtime, so the +# BK72xx MMU permits execution from that region; the "!x" is a stale hint in +# the linker template. Flipping it to "(rwx)" lets the linker place the +# function body in .data, where it is copied alongside initialized globals and +# becomes callable from an ISR even while flash is busy. +# +# LN882H already declares RAM0 as "(rwx)" and Realtek families use dedicated +# .image2.ram.text / .sram.text output sections — nothing to patch there. + + +def _is_bk72xx(defines): + prefix = "USE_LIBRETINY_VARIANT_" + bk = ("BK7231N", "BK7231T", "BK7231Q", "BK7251") + for token in defines: + if isinstance(token, tuple): + token = token[0] + if isinstance(token, str) and token.startswith(prefix): + return token[len(prefix):] in bk + return False + + +_RW_NO_X = re.compile(r"(\bram\s*\()\s*rw\s*!\s*x(\s*\))") + + +def _patch_directory(build_dir): + if not os.path.isdir(build_dir): + return + for name in os.listdir(build_dir): + if not name.endswith(".ld"): + continue + path = os.path.join(build_dir, name) + with open(path, "r", encoding="utf-8") as fh: + content = fh.read() + patched = _RW_NO_X.sub(r"\1rwx\2", content) + if patched != content: + with open(path, "w", encoding="utf-8") as fh: + fh.write(patched) + print( + "ESPHome: patched BK72xx linker script {} (rw!x -> rwx) so " + "IRAM_ATTR functions can be placed in .data".format(name) + ) + + +def _patch_ld_before_link(target, source, env): + _patch_directory(env.subst("$BUILD_DIR")) + + +if _is_bk72xx(env.get("CPPDEFINES", [])): + # LibreTiny writes the processed .ld templates into $BUILD_DIR during its + # own builder setup, which may run after this script. Register the patch + # as a pre-link action so it executes once the linker scripts exist. + env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _patch_ld_before_link) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 03a30b7459..4009950ea0 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -21,6 +21,25 @@ #define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) #define PROGMEM +#elif defined(USE_LIBRETINY) + +// IRAM_ATTR places a function in SRAM so it is callable from an ISR even +// while flash is busy (XIP stall, OTA, logger flash write). The section used +// varies per family, based on what each linker script already supports: +// - RTL8710B (AmebaZ): ".image2.ram.text" output section exists. +// - RTL8720C (AmebaZ2): "*(.sram.text*)" is already consumed. +// - BK72xx / LN882H: stock linker script has no RAM text section, so we +// piggyback on ".data" — the SDK startup code copies .data from flash into +// SRAM before main() runs, so the function body ends up in executable RAM. +#if defined(USE_LIBRETINY_VARIANT_RTL8710B) +#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) +#elif defined(USE_LIBRETINY_VARIANT_RTL8720C) +#define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) +#else +#define IRAM_ATTR __attribute__((noinline, section(".data"))) +#endif +#define PROGMEM + #else #define IRAM_ATTR @@ -28,8 +47,48 @@ #endif +#ifdef USE_ESP32 +#include +#include +#endif + namespace esphome { +/// Returns true when executing inside an interrupt handler. +/// always_inline so callers placed in IRAM keep the detection in IRAM. +__attribute__((always_inline)) inline bool in_isr_context() { +#if defined(USE_ESP32) + return xPortInIsrContext() != 0; +#elif defined(USE_ESP8266) + // Xtensa LX106 PS.INTLEVEL[3:0]. Non-zero indicates interrupt in progress. + uint32_t ps; + __asm__ volatile("rsr.ps %0" : "=r"(ps)); + return (ps & 0xF) != 0; +#elif defined(USE_RP2040) + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#elif defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7231T) || \ + defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) + // BK72xx is ARM968E-S (ARM9). CPSR mode bits [4:0]: + // 0x10 USR, 0x13 SVC, 0x1F SYS are normal; others (IRQ=0x12, FIQ=0x11, + // ABT=0x17, UND=0x1B) are exception modes. + uint32_t cpsr; + __asm__ volatile("mrs %0, cpsr" : "=r"(cpsr)); + uint32_t mode = cpsr & 0x1Fu; + return mode != 0x10u && mode != 0x13u && mode != 0x1Fu; +#elif defined(USE_LIBRETINY) + // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; + // non-zero means we're in a handler. + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#else + // Host and any future platform without an ISR concept. + return false; +#endif +} + void yield(); uint32_t millis(); uint64_t millis_64(); diff --git a/esphome/core/main_task.h b/esphome/core/main_task.h index ed2885d2e2..3aa8669e44 100644 --- a/esphome/core/main_task.h +++ b/esphome/core/main_task.h @@ -20,7 +20,8 @@ extern "C" { extern TaskHandle_t esphome_main_task_handle; /// Wake the main loop task from another FreeRTOS task. NOT ISR-safe. -static inline void esphome_main_task_notify() { +/// always_inline so callers placed in IRAM do not reference a flash-resident copy. +__attribute__((always_inline)) static inline void esphome_main_task_notify() { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); @@ -28,26 +29,14 @@ static inline void esphome_main_task_notify() { } /// Wake the main loop task from an ISR. ISR-safe. -static inline void esphome_main_task_notify_from_isr(BaseType_t *px_higher_priority_task_woken) { +__attribute__((always_inline)) static inline void esphome_main_task_notify_from_isr( + BaseType_t *px_higher_priority_task_woken) { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken); } } -#ifdef USE_ESP32 -/// Wake the main loop from any context (ISR or task). ESP32-only (needs xPortInIsrContext). -static inline void esphome_main_task_notify_any_context() { - if (xPortInIsrContext()) { - int px_higher_priority_task_woken = 0; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); - portYIELD_FROM_ISR(px_higher_priority_task_woken); - } else { - esphome_main_task_notify(); - } -} -#endif - #ifdef __cplusplus } #endif diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index b6b59b5990..3709fa88ac 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -12,12 +12,12 @@ namespace esphome { -// === ESP32 — IRAM_ATTR entry points === -#ifdef USE_ESP32 +// === ESP32 / LibreTiny — IRAM_ATTR entry points === +#if defined(USE_ESP32) || defined(USE_LIBRETINY) void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { esphome_main_task_notify_from_isr(px_higher_priority_task_woken); } -void IRAM_ATTR wake_loop_any_context() { esphome_main_task_notify_any_context(); } +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } #endif // === ESP8266 / RP2040 === diff --git a/esphome/core/wake.h b/esphome/core/wake.h index a8c9b7ad08..f0a2d34b50 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -28,17 +28,21 @@ extern volatile bool g_main_loop_woke; // === ESP32 / LibreTiny (FreeRTOS) === #if defined(USE_ESP32) || defined(USE_LIBRETINY) -#ifdef USE_ESP32 -/// IRAM_ATTR entry point — defined in wake.cpp. +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); + portYIELD_FROM_ISR(px_higher_priority_task_woken); + } else { + esphome_main_task_notify(); + } +} + +/// IRAM_ATTR entry points — defined in wake.cpp. void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -/// IRAM_ATTR entry point — defined in wake.cpp. void wake_loop_any_context(); -#else -/// LibreTiny: IRAM_ATTR is not functional and the FreeRTOS port does not -/// provide vTaskNotifyGiveFromISR/portYIELD_FROM_ISR, so ISR-safe wake -/// is not possible. xTaskNotifyGive is used as the best available option. -inline void wake_loop_any_context() { esphome_main_task_notify(); } -#endif inline void wake_loop_threadsafe() { esphome_main_task_notify(); } From c1ef3cab37740d91ab69342faaf7caf3a8457782 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:46:37 -1000 Subject: [PATCH 0213/2214] libretiny: wire copy_files() through each target_platform module target_platform for LibreTiny devices is one of bk72xx, rtl87xx, ln882x (not "libretiny" itself), so writer.py's platform-dispatched copy_files() call was not reaching libretiny.copy_files(). That left the pre-link patch_linker.py script absent from the build dir, so PlatformIO failed with "missing SConscript file 'patch_linker.py'". Add a delegating copy_files() to each generated sub-component and to generate_components.py so regeneration keeps them in sync. --- esphome/components/bk72xx/__init__.py | 5 +++++ esphome/components/libretiny/__init__.py | 8 +++++--- esphome/components/libretiny/generate_components.py | 5 +++++ esphome/components/ln882x/__init__.py | 5 +++++ esphome/components/rtl87xx/__init__.py | 5 +++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index 7fed742d2e..3ffab0f3a5 100644 --- a/esphome/components/bk72xx/__init__.py +++ b/esphome/components/bk72xx/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("bk72xx", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 0635e5b788..70354843c1 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -467,9 +467,11 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) - # Patch the linker script to add a ".sram.text" output section so IRAM_ATTR - # (defined in esphome/core/hal.h) places code in SRAM on BK72xx and LN882H. - # No-op on RTL8710B/RTL8720C whose stock linker scripts already provide it. + # BK72xx linker templates mark the SRAM region as (rw!x), which blocks the + # linker from placing executable code in .data — the section IRAM_ATTR + # targets on BK72xx (see esphome/core/hal.h). This pre-link hook flips the + # flag to (rwx) once LibreTiny has written the processed .ld file(s) into + # the build dir. No-op on every other family. cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 41b4389446..d5437895a6 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -79,6 +79,11 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("{COMPONENT_LOWER}", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() ''' BASE_CODE_BOARDS = ''' diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index 5c637bdf62..9c91827522 100644 --- a/esphome/components/ln882x/__init__.py +++ b/esphome/components/ln882x/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("ln882x", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index 6fd750d51e..a3b1dba4f2 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("rtl87xx", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() From f404768fd147104c4dd9f5869633fb21c31b98cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:48:11 -1000 Subject: [PATCH 0214/2214] =?UTF-8?q?libretiny:=20fix=20BK72xx=20build=20?= =?UTF-8?q?=E2=80=94=20guard=20portYIELD=5FFROM=5FISR;=20use=20.data.iram?= =?UTF-8?q?=5Ftext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues surfaced building cb3s-test.yaml: 1. BK72xx's ARM9 FreeRTOS port does not define portYIELD_FROM_ISR; context switches happen naturally at IRQ exit. Wrap the call in #ifdef so the wake path compiles on ports that lack it. 2. Placing functions directly in section(".data") provoked assembler "ignoring changed section attributes" warnings and a hard DWARF error ("leb128 operand is an undefined symbol: .LVU31") because the compiler emits code-style attributes ("ax") that collide with .data's data-style attributes ("aw"). Use section(".data.iram_text") instead; the linker still folds it into the .data output via "*(.data.*)", so the bytes land in SRAM via the SDK's .data copy, but the assembler no longer sees conflicting attributes. --- esphome/components/libretiny/patch_linker.py.script | 7 ++++--- esphome/core/hal.h | 11 +++++++---- esphome/core/wake.h | 6 ++++++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 56a42db0ee..b62ec1a5fb 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -6,9 +6,10 @@ import re # BK72xx linker templates declare the SRAM region as "(rw!x)" — read/write but # not executable. That prevents the linker from emitting functions we mark -# IRAM_ATTR (defined as section(".data") on BK72xx in esphome/core/hal.h) into -# the .data output section, which is the only section the SDK startup code -# copies from flash into SRAM before main() runs. +# IRAM_ATTR (defined as section(".data.iram_text") on BK72xx in +# esphome/core/hal.h; folded into .data by the linker's "*(.data.*)" glob) +# into the .data output section, which is the only section the SDK startup +# code copies from flash into SRAM before main() runs. # # LibreTiny's own Wi-Fi driver already runs code from SRAM at runtime, so the # BK72xx MMU permits execution from that region; the "!x" is a stale hint in diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 4009950ea0..aa36a1f03d 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -28,15 +28,18 @@ // varies per family, based on what each linker script already supports: // - RTL8710B (AmebaZ): ".image2.ram.text" output section exists. // - RTL8720C (AmebaZ2): "*(.sram.text*)" is already consumed. -// - BK72xx / LN882H: stock linker script has no RAM text section, so we -// piggyback on ".data" — the SDK startup code copies .data from flash into -// SRAM before main() runs, so the function body ends up in executable RAM. +// - BK72xx / LN882H: the stock linker script has no RAM text section, so we +// use ".data.iram_text" which the linker's "*(.data.*)" glob folds into +// the .data output section. The SDK startup code copies .data from flash +// into SRAM before main() runs, so the function body ends up in executable +// RAM. Using ".data.*" instead of plain ".data" keeps the assembler happy +// (no section-attribute collision) while still landing in the right place. #if defined(USE_LIBRETINY_VARIANT_RTL8710B) #define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) #elif defined(USE_LIBRETINY_VARIANT_RTL8720C) #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #else -#define IRAM_ATTR __attribute__((noinline, section(".data"))) +#define IRAM_ATTR __attribute__((noinline, section(".data.iram_text"))) #endif #define PROGMEM diff --git a/esphome/core/wake.h b/esphome/core/wake.h index f0a2d34b50..5733ee65f6 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -34,7 +34,13 @@ __attribute__((always_inline)) inline void wake_main_task_any_context() { if (in_isr_context()) { BaseType_t px_higher_priority_task_woken = pdFALSE; esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR portYIELD_FROM_ISR(px_higher_priority_task_woken); +#else + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; +#endif } else { esphome_main_task_notify(); } From e5b46540eea0b488498287a90d00500b8e82c225 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:53:35 -1000 Subject: [PATCH 0215/2214] libretiny: fix BK72xx Thumb asm; unify IRAM_ATTR on .sram.text Two fixes on top of the initial landing: - BK72xx builds Thumb-mode TUs alongside its ARM FreeRTOS port, and the MRS CPSR instruction is ARM-only, so in_isr_context() failed to assemble on cb3s. Delegate to the port's own platform_is_in_interrupt_context() helper (declared extern "C" in portmacro.h and built in ARM mode) instead of embedding inline CPSR reads in Thumb code. - Section name unified to ".sram.text" for every LibreTiny family to avoid the "setting incorrect section attributes for .data.*" GAS warning. The pre-link patcher now knows how to route that section into each family's RAM-resident output: * BK72xx: flip SRAM region (rw!x) -> (rwx) and inject KEEP(*(.sram.text*)) into .data : { ... } * LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection : { ... } * RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text : { ... } * RTL8720C: no-op (linker already consumes *(.sram.text*)) The injection uses a "/* esphome .sram.text */" marker so repeated runs are idempotent. --- .../libretiny/patch_linker.py.script | 91 +++++++++++++------ esphome/core/hal.h | 34 +++---- 2 files changed, 73 insertions(+), 52 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index b62ec1a5fb..08b95462f0 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -4,38 +4,69 @@ Import("env") # noqa import os import re -# BK72xx linker templates declare the SRAM region as "(rw!x)" — read/write but -# not executable. That prevents the linker from emitting functions we mark -# IRAM_ATTR (defined as section(".data.iram_text") on BK72xx in -# esphome/core/hal.h; folded into .data by the linker's "*(.data.*)" glob) -# into the .data output section, which is the only section the SDK startup -# code copies from flash into SRAM before main() runs. +# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny expands to +# section(".sram.text") (see esphome/core/hal.h). Each family's linker script +# needs that section routed into RAM-resident code so the function is callable +# while flash is busy (XIP stall, OTA, logger flash write): # -# LibreTiny's own Wi-Fi driver already runs code from SRAM at runtime, so the -# BK72xx MMU permits execution from that region; the "!x" is a stale hint in -# the linker template. Flipping it to "(rwx)" lets the linker place the -# function body in .data, where it is copied alongside initialized globals and -# becomes callable from an ISR even while flash is busy. -# -# LN882H already declares RAM0 as "(rwx)" and Realtek families use dedicated -# .image2.ram.text / .sram.text output sections — nothing to patch there. +# - RTL8720C (AmebaZ2): stock linker already consumes "*(.sram.text*)", no-op. +# - RTL8710B (AmebaZ): stock linker has ".image2.ram.text" — inject +# "*(.sram.text*)" into it. +# - BK72xx: stock linker has a (rw!x) SRAM region that blocks executable +# placement; flip to (rwx) and inject "KEEP(*(.sram.text*))" into the ".data" +# output (which is already flash-to-SRAM copied at startup by the SDK). +# - LN882H: stock linker has ".flash_copysection" which is flash-to-RAM0 +# copied at startup; inject "KEEP(*(.sram.text*))" there. -def _is_bk72xx(defines): +_MARKER = "/* esphome .sram.text */" +_KEEP_LINE = " KEEP(*(.sram.text*)) " + _MARKER + "\n" +_BK_DATA = re.compile(r"(\.data\s*:\s*\{\s*\n)") +_BK_RW_NO_X = re.compile(r"(\bram\s*\()\s*rw\s*!\s*x(\s*\))") +_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") +_RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") + + +def _detect(defines): prefix = "USE_LIBRETINY_VARIANT_" - bk = ("BK7231N", "BK7231T", "BK7231Q", "BK7251") for token in defines: if isinstance(token, tuple): token = token[0] if isinstance(token, str) and token.startswith(prefix): - return token[len(prefix):] in bk - return False + return token[len(prefix):] + return None -_RW_NO_X = re.compile(r"(\bram\s*\()\s*rw\s*!\s*x(\s*\))") +def _patch_bk72xx(content): + new_content = _BK_RW_NO_X.sub(r"\1rwx\2", content) + if _MARKER not in new_content: + new_content = _BK_DATA.sub(r"\1" + _KEEP_LINE, new_content, count=1) + return new_content -def _patch_directory(build_dir): +def _patch_ln882h(content): + if _MARKER in content: + return content + return _LN_COPY.sub(r"\1" + _KEEP_LINE, content, count=1) + + +def _patch_rtl8710b(content): + if _MARKER in content: + return content + return _RTL8710B_IMAGE2.sub(r"\1" + _KEEP_LINE, content, count=1) + + +def _patchers_for(variant): + if variant in ("BK7231N", "BK7231T", "BK7231Q", "BK7251"): + return (_patch_bk72xx,) + if variant == "LN882H": + return (_patch_ln882h,) + if variant == "RTL8710B": + return (_patch_rtl8710b,) + return () + + +def _patch_build_dir(patchers, build_dir): if not os.path.isdir(build_dir): return for name in os.listdir(build_dir): @@ -44,22 +75,24 @@ def _patch_directory(build_dir): path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: content = fh.read() - patched = _RW_NO_X.sub(r"\1rwx\2", content) + patched = content + for patch in patchers: + patched = patch(patched) if patched != content: with open(path, "w", encoding="utf-8") as fh: fh.write(patched) - print( - "ESPHome: patched BK72xx linker script {} (rw!x -> rwx) so " - "IRAM_ATTR functions can be placed in .data".format(name) - ) + print("ESPHome: patched linker script {} for IRAM_ATTR placement".format(name)) -def _patch_ld_before_link(target, source, env): - _patch_directory(env.subst("$BUILD_DIR")) +def _pre_link(target, source, env): + _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) -if _is_bk72xx(env.get("CPPDEFINES", [])): +_variant = _detect(env.get("CPPDEFINES", [])) +_patchers = _patchers_for(_variant) if _variant else () + +if _patchers: # LibreTiny writes the processed .ld templates into $BUILD_DIR during its # own builder setup, which may run after this script. Register the patch # as a pre-link action so it executes once the linker scripts exist. - env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _patch_ld_before_link) + env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index aa36a1f03d..466f864dc1 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -24,23 +24,13 @@ #elif defined(USE_LIBRETINY) // IRAM_ATTR places a function in SRAM so it is callable from an ISR even -// while flash is busy (XIP stall, OTA, logger flash write). The section used -// varies per family, based on what each linker script already supports: -// - RTL8710B (AmebaZ): ".image2.ram.text" output section exists. -// - RTL8720C (AmebaZ2): "*(.sram.text*)" is already consumed. -// - BK72xx / LN882H: the stock linker script has no RAM text section, so we -// use ".data.iram_text" which the linker's "*(.data.*)" glob folds into -// the .data output section. The SDK startup code copies .data from flash -// into SRAM before main() runs, so the function body ends up in executable -// RAM. Using ".data.*" instead of plain ".data" keeps the assembler happy -// (no section-attribute collision) while still landing in the right place. -#if defined(USE_LIBRETINY_VARIANT_RTL8710B) -#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) -#elif defined(USE_LIBRETINY_VARIANT_RTL8720C) +// while flash is busy (XIP stall, OTA, logger flash write). All LibreTiny +// families use ".sram.text". RTL8720C (AmebaZ2) already consumes that +// section; RTL8710B (AmebaZ), BK72xx, and LN882H get it routed into their +// RAM-resident output section via patch_linker.py.script. Using a custom +// name avoids the assembler "setting incorrect section attributes" warning +// that ".data.*" triggers when we place executable code there. #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) -#else -#define IRAM_ATTR __attribute__((noinline, section(".data.iram_text"))) -#endif #define PROGMEM #else @@ -73,13 +63,11 @@ __attribute__((always_inline)) inline bool in_isr_context() { return ipsr != 0; #elif defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7231T) || \ defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) - // BK72xx is ARM968E-S (ARM9). CPSR mode bits [4:0]: - // 0x10 USR, 0x13 SVC, 0x1F SYS are normal; others (IRQ=0x12, FIQ=0x11, - // ABT=0x17, UND=0x1B) are exception modes. - uint32_t cpsr; - __asm__ volatile("mrs %0, cpsr" : "=r"(cpsr)); - uint32_t mode = cpsr & 0x1Fu; - return mode != 0x10u && mode != 0x13u && mode != 0x1Fu; + // BK72xx is ARM968E-S (ARM9). The MRS CPSR instruction is ARM-only, and + // user code here may be built in Thumb mode. Defer to the FreeRTOS port + // helper (compiled in ARM mode by the SDK) which reads CPSR internally. + extern "C" uint32_t platform_is_in_interrupt_context(void); + return platform_is_in_interrupt_context() != 0; #elif defined(USE_LIBRETINY) // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; // non-zero means we're in a handler. From 90b311258a4066857e5a144a9f6c3d4e8cc2d4e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:54:23 -1000 Subject: [PATCH 0216/2214] libretiny: move Beken extern "C" declaration out of function body --- esphome/core/hal.h | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 466f864dc1..2803056730 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -45,6 +45,13 @@ #include #endif +#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7231T) || \ + defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) +// Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so +// it is callable from Thumb code via interworking. +extern "C" uint32_t platform_is_in_interrupt_context(void); +#endif + namespace esphome { /// Returns true when executing inside an interrupt handler. @@ -65,8 +72,7 @@ __attribute__((always_inline)) inline bool in_isr_context() { defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) // BK72xx is ARM968E-S (ARM9). The MRS CPSR instruction is ARM-only, and // user code here may be built in Thumb mode. Defer to the FreeRTOS port - // helper (compiled in ARM mode by the SDK) which reads CPSR internally. - extern "C" uint32_t platform_is_in_interrupt_context(void); + // helper declared above (compiled in ARM mode by the SDK). return platform_is_in_interrupt_context() != 0; #elif defined(USE_LIBRETINY) // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; From 55845b3e8510bd651dbf615bc448bb6484002e07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:56:39 -1000 Subject: [PATCH 0217/2214] Address copilot review: update extra_scripts comment to match current implementation --- esphome/components/libretiny/__init__.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 70354843c1..47ab92418b 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -467,11 +467,14 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) - # BK72xx linker templates mark the SRAM region as (rw!x), which blocks the - # linker from placing executable code in .data — the section IRAM_ATTR - # targets on BK72xx (see esphome/core/hal.h). This pre-link hook flips the - # flag to (rwx) once LibreTiny has written the processed .ld file(s) into - # the build dir. No-op on every other family. + # IRAM_ATTR on LibreTiny expands to section(".sram.text") (see + # esphome/core/hal.h). This pre-link hook rewrites the processed .ld files + # in the build dir so that section lands in RAM on each family: + # - BK72xx: flip SRAM region (rw!x) -> (rwx) and inject + # KEEP(*(.sram.text*)) into the .data output section. + # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. + # - RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text. + # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) From 33118e0e57f6c1c5468e050c11c2a9921fed3b36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:59:42 -1000 Subject: [PATCH 0218/2214] =?UTF-8?q?libretiny:=20patch=5Flinker.py.script?= =?UTF-8?q?=20=E2=80=94=20fail=20hard=20if=20IRAM=5FATTR=20cannot=20land?= =?UTF-8?q?=20in=20SRAM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from the cb3s smoke test: - The previous CPPDEFINES-only variant detection missed the USE_LIBRETINY_VARIANT_* define on BK72xx in the pre-script environment, so no patcher registered and the .sram.text section silently landed in flash. Check BUILD_FLAGS as a fallback so the define is picked up regardless of when PlatformIO finishes populating CPPDEFINES. - When the variant could not be detected, or no .ld file was modified, the script exited quietly and the firmware linked with IRAM_ATTR functions in flash — the exact bug this PR is trying to fix. Raise a RuntimeError in all three failure cases (variant missing, variant unknown, no .ld patched) so the build fails loudly instead of producing broken firmware. --- .../libretiny/patch_linker.py.script | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 08b95462f0..ce6db29294 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -27,16 +27,35 @@ _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") _RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") -def _detect(defines): +def _detect(env): prefix = "USE_LIBRETINY_VARIANT_" - for token in defines: - if isinstance(token, tuple): + # CPPDEFINES may hold strings or (name, value) tuples; BUILD_FLAGS holds + # the raw "-DNAME" strings. PlatformIO populates both, but the exact order + # vs. extra_scripts varies, so check both to be robust. + for token in env.get("CPPDEFINES", []): + if isinstance(token, (list, tuple)): token = token[0] if isinstance(token, str) and token.startswith(prefix): return token[len(prefix):] + for flag in env.get("BUILD_FLAGS", []): + if isinstance(flag, str) and "-D" + prefix in flag: + name = flag.split("-D", 1)[1].split("=", 1)[0].strip() + if name.startswith(prefix): + return name[len(prefix):] return None +KNOWN_VARIANTS = ( + "BK7231N", + "BK7231T", + "BK7231Q", + "BK7251", + "LN882H", + "RTL8710B", + "RTL8720C", +) + + def _patch_bk72xx(content): new_content = _BK_RW_NO_X.sub(r"\1rwx\2", content) if _MARKER not in new_content: @@ -68,8 +87,12 @@ def _patchers_for(variant): def _patch_build_dir(patchers, build_dir): if not os.path.isdir(build_dir): - return - for name in os.listdir(build_dir): + raise RuntimeError( + "ESPHome: LibreTiny build dir {} does not exist at link time; " + "IRAM_ATTR placement cannot be verified".format(build_dir) + ) + patched_any = False + for name in sorted(os.listdir(build_dir)): if not name.endswith(".ld"): continue path = os.path.join(build_dir, name) @@ -82,15 +105,35 @@ def _patch_build_dir(patchers, build_dir): with open(path, "w", encoding="utf-8") as fh: fh.write(patched) print("ESPHome: patched linker script {} for IRAM_ATTR placement".format(name)) + patched_any = True + if not patched_any: + raise RuntimeError( + "ESPHome: no linker script in {} was patched for IRAM_ATTR; refusing " + "to link because IRAM_ATTR functions would end up in flash instead of " + "SRAM and would crash on an ISR while flash is busy".format(build_dir) + ) def _pre_link(target, source, env): _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) -_variant = _detect(env.get("CPPDEFINES", [])) -_patchers = _patchers_for(_variant) if _variant else () +_variant = _detect(env) +if _variant is None: + raise RuntimeError( + "ESPHome: could not determine LibreTiny variant from build flags. " + "patch_linker.py needs USE_LIBRETINY_VARIANT_* to route IRAM_ATTR " + "into SRAM; without it, ISR handlers would silently end up in flash." + ) +if _variant not in KNOWN_VARIANTS: + raise RuntimeError( + "ESPHome: unknown LibreTiny variant {!r}; patch_linker.py does not " + "know how to route IRAM_ATTR into SRAM for this family. Update " + "patch_linker.py.script before shipping firmware.".format(_variant) + ) + +_patchers = _patchers_for(_variant) if _patchers: # LibreTiny writes the processed .ld templates into $BUILD_DIR during its # own builder setup, which may run after this script. Register the patch From ab382c1e52a52a019014bf36080660149664f6bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:05:48 -1000 Subject: [PATCH 0219/2214] libretiny: print IRAM_ATTR placement summary after link After linking, walk the ELF symbol table and print one of: ESPHome: IRAM_ATTR placement summary (BK7231N): .sram.text: 312 bytes at 0x004001d0 - 0x00400308 ESPHome: IRAM_ATTR placement summary (RTL8720C): IRAM symbols at 0x10002f6c - 0x10002fa8 (approx 60 bytes) On the three families whose linker scripts we already patch (BK72xx, LN882H, RTL8710B) the PROVIDE(__esphome_sram_text_start/end = .) markers injected alongside KEEP(*(.sram.text*)) give an exact byte count for the ESPHome IRAM_ATTR contribution. On RTL8720C we cannot patch the linker script (it is loaded directly from the framework package) so the summary falls back to reading the addresses of the three core IRAM_ATTR symbols. Either way the address range is visible proof that the functions ended up in SRAM rather than flash. --- .../libretiny/patch_linker.py.script | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index ce6db29294..728a22b823 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -20,11 +20,19 @@ import re _MARKER = "/* esphome .sram.text */" -_KEEP_LINE = " KEEP(*(.sram.text*)) " + _MARKER + "\n" +_KEEP_LINE = ( + " PROVIDE(__esphome_sram_text_start = .); " + "KEEP(*(.sram.text*)) " + "PROVIDE(__esphome_sram_text_end = .); " + + _MARKER + "\n" +) _BK_DATA = re.compile(r"(\.data\s*:\s*\{\s*\n)") _BK_RW_NO_X = re.compile(r"(\bram\s*\()\s*rw\s*!\s*x(\s*\))") _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") _RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") +# RTL8720C loads its linker script directly from the framework package so we +# cannot inject PROVIDE markers for it; the summary falls back to reading +# addresses of known IRAM_ATTR symbols instead of measuring a bracketed span. def _detect(env): @@ -82,6 +90,8 @@ def _patchers_for(variant): return (_patch_ln882h,) if variant == "RTL8710B": return (_patch_rtl8710b,) + # RTL8720C: stock linker already consumes *(.sram.text*), no .ld patch + # needed; summary falls back to reading symbol addresses. return () @@ -118,6 +128,49 @@ def _pre_link(target, source, env): _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) +def _post_link(target, source, env): + """Print where IRAM_ATTR ended up so users can confirm at a glance.""" + nm = env.subst("$NM") or "arm-none-eabi-nm" + elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") + if not os.path.isfile(elf): + return + try: + import subprocess + out = subprocess.check_output([nm, "--defined-only", elf], text=True) + except (OSError, subprocess.CalledProcessError): + return + start = end = None + sample = [] + for line in out.splitlines(): + parts = line.split(maxsplit=2) + if len(parts) != 3: + continue + addr, _kind, name = parts + if name == "__esphome_sram_text_start": + start = int(addr, 16) + elif name == "__esphome_sram_text_end": + end = int(addr, 16) + elif name in ( + "_ZN7esphome21wake_loop_any_contextEv", + "_ZN7esphome17wake_loop_isrsafeEPl", + "_ZN7esphome9Component28enable_loop_soon_any_contextEv", + ): + sample.append((int(addr, 16), name)) + header = "ESPHome: IRAM_ATTR placement summary ({}):".format(_variant) + if start is not None and end is not None: + print(header) + print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end)) + elif sample: + print(header) + sample.sort() + lo = sample[0][0] + hi = sample[-1][0] + print(" IRAM symbols at 0x{:08x} - 0x{:08x} (approx {} bytes)".format(lo, hi, hi - lo)) + else: + print(header) + print(" no IRAM_ATTR symbols found in the ELF") + + _variant = _detect(env) if _variant is None: @@ -139,3 +192,6 @@ if _patchers: # own builder setup, which may run after this script. Register the patch # as a pre-link action so it executes once the linker scripts exist. env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) + +# Post-link summary runs for every LibreTiny family. +env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) From 8dba87014a3ea91981950b1d8bf41299077686ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:07:31 -1000 Subject: [PATCH 0220/2214] libretiny: use strong linker assignments for SRAM text markers PROVIDE symbols without references get garbage-collected by the linker, so the post-link summary was falling back to the symbol-scrape path on every family. Use direct assignment so the markers always land in the ELF symbol table, giving an exact byte count for the ESPHome IRAM_ATTR contribution. --- esphome/components/libretiny/patch_linker.py.script | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 728a22b823..60b64a7316 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -20,10 +20,12 @@ import re _MARKER = "/* esphome .sram.text */" +# Strong assignments (not PROVIDE) so the symbols are always emitted in the +# ELF; PROVIDE symbols with no references can be garbage-collected. _KEEP_LINE = ( - " PROVIDE(__esphome_sram_text_start = .); " + " __esphome_sram_text_start = .; " "KEEP(*(.sram.text*)) " - "PROVIDE(__esphome_sram_text_end = .); " + "__esphome_sram_text_end = .; " + _MARKER + "\n" ) _BK_DATA = re.compile(r"(\.data\s*:\s*\{\s*\n)") From a1970737c6e86e01352858ffc3b3dd7b1f3982b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:10:39 -1000 Subject: [PATCH 0221/2214] libretiny: dedupe per-variant patchers into a shared injector and dict lookup --- .../libretiny/patch_linker.py.script | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 60b64a7316..a1c2f0f3e6 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -66,35 +66,33 @@ KNOWN_VARIANTS = ( ) -def _patch_bk72xx(content): - new_content = _BK_RW_NO_X.sub(r"\1rwx\2", content) - if _MARKER not in new_content: - new_content = _BK_DATA.sub(r"\1" + _KEEP_LINE, new_content, count=1) - return new_content +def _inject_keep(host_section): + """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + def patch(content): + if _MARKER in content: + return content + return host_section.sub(r"\1" + _KEEP_LINE, content, count=1) + return patch -def _patch_ln882h(content): - if _MARKER in content: - return content - return _LN_COPY.sub(r"\1" + _KEEP_LINE, content, count=1) +def _flip_bk72xx_rwx(content): + return _BK_RW_NO_X.sub(r"\1rwx\2", content) -def _patch_rtl8710b(content): - if _MARKER in content: - return content - return _RTL8710B_IMAGE2.sub(r"\1" + _KEEP_LINE, content, count=1) +# RTL8720C is absent: its stock linker already consumes *(.sram.text*), so +# no .ld patch is needed; the summary falls back to reading symbol addresses. +_PATCHERS_BY_VARIANT = { + "BK7231N": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), + "BK7231T": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), + "BK7231Q": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), + "BK7251": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), + "LN882H": (_inject_keep(_LN_COPY),), + "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), +} def _patchers_for(variant): - if variant in ("BK7231N", "BK7231T", "BK7231Q", "BK7251"): - return (_patch_bk72xx,) - if variant == "LN882H": - return (_patch_ln882h,) - if variant == "RTL8710B": - return (_patch_rtl8710b,) - # RTL8720C: stock linker already consumes *(.sram.text*), no .ld patch - # needed; summary falls back to reading symbol addresses. - return () + return _PATCHERS_BY_VARIANT.get(variant, ()) def _patch_build_dir(patchers, build_dir): From 365ff86bac86242617838b3044180ca8ca99caed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:18:39 -1000 Subject: [PATCH 0222/2214] libretiny: target BK72xx .itcm.code instead of .data for IRAM_ATTR cb3s flashed firmware crashed after logger init. Main SRAM on ARM968E-S is not instruction-fetchable: the stock linker marks it (rw!x) because the bus does not allow instruction fetches from that region, so putting IRAM_ATTR code in .data landed the bytes in RAM but triggered a prefetch abort when the first IRAM function was called. The ARM968 design has a separate tightly-coupled instruction memory ("itcm", 4.5 kB, rwx) where the SDK already routes its own ISR, flash, and FreeRTOS critical-path code via the .itcm.code output section. Inject KEEP(*(.sram.text*)) into .itcm.code so our IRAM_ATTR functions share that executable RAM region. No (rw!x) flip is needed since we no longer touch the main SRAM layout. --- esphome/components/libretiny/__init__.py | 9 +++-- .../libretiny/patch_linker.py.script | 40 +++++++++---------- esphome/core/hal.h | 14 +++---- 3 files changed, 31 insertions(+), 32 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 47ab92418b..b8833e5a55 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -468,10 +468,11 @@ async def component_to_code(config): build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) # IRAM_ATTR on LibreTiny expands to section(".sram.text") (see - # esphome/core/hal.h). This pre-link hook rewrites the processed .ld files - # in the build dir so that section lands in RAM on each family: - # - BK72xx: flip SRAM region (rw!x) -> (rwx) and inject - # KEEP(*(.sram.text*)) into the .data output section. + # esphome/core/hal.h). This pre-link hook rewrites the processed .ld + # files in the build dir so that section lands in an executable RAM + # output section on each family: + # - BK72xx: inject KEEP(*(.sram.text*)) into .itcm.code (the only + # rwx RAM region; main SRAM is rw!x on ARM968E-S). # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. # - RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text. # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index a1c2f0f3e6..8d447630f7 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -6,17 +6,23 @@ import re # ESPHome marks ISR code IRAM_ATTR, which on LibreTiny expands to # section(".sram.text") (see esphome/core/hal.h). Each family's linker script -# needs that section routed into RAM-resident code so the function is callable -# while flash is busy (XIP stall, OTA, logger flash write): +# needs that section routed into RAM-resident *executable* memory so the +# function is callable while flash is busy (XIP stall, OTA, logger flash +# write): # -# - RTL8720C (AmebaZ2): stock linker already consumes "*(.sram.text*)", no-op. -# - RTL8710B (AmebaZ): stock linker has ".image2.ram.text" — inject -# "*(.sram.text*)" into it. -# - BK72xx: stock linker has a (rw!x) SRAM region that blocks executable -# placement; flip to (rwx) and inject "KEEP(*(.sram.text*))" into the ".data" -# output (which is already flash-to-SRAM copied at startup by the SDK). +# - BK72xx: ARM968E-S has tightly-coupled instruction RAM ("itcm", 4.5 kB, +# rwx) that the SDK uses for its own ISR / flash / FreeRTOS critical +# routines; main SRAM ("ram", 192 kB) is rw!x (bus does not allow +# instruction fetches). Inject "KEEP(*(.sram.text*))" into ".itcm.code" +# so our IRAM_ATTR functions share the only executable RAM region. # - LN882H: stock linker has ".flash_copysection" which is flash-to-RAM0 # copied at startup; inject "KEEP(*(.sram.text*))" there. +# - RTL8710B (AmebaZ): stock linker has ".image2.ram.text" — inject +# "KEEP(*(.sram.text*))" into it. +# - RTL8720C (AmebaZ2): stock linker already consumes "*(.sram.text*)", +# no-op. Loaded directly from the framework package so we cannot inject +# our __esphome_sram_text_start/end markers either; the post-link summary +# falls back to reading known IRAM_ATTR symbol addresses instead. _MARKER = "/* esphome .sram.text */" @@ -28,13 +34,9 @@ _KEEP_LINE = ( "__esphome_sram_text_end = .; " + _MARKER + "\n" ) -_BK_DATA = re.compile(r"(\.data\s*:\s*\{\s*\n)") -_BK_RW_NO_X = re.compile(r"(\bram\s*\()\s*rw\s*!\s*x(\s*\))") +_BK_ITCM = re.compile(r"(\.itcm\.code\s*ALIGN\s*\(\s*\d+\s*\)\s*:\s*\{\s*\n)") _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") _RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") -# RTL8720C loads its linker script directly from the framework package so we -# cannot inject PROVIDE markers for it; the summary falls back to reading -# addresses of known IRAM_ATTR symbols instead of measuring a bracketed span. def _detect(env): @@ -75,17 +77,13 @@ def _inject_keep(host_section): return patch -def _flip_bk72xx_rwx(content): - return _BK_RW_NO_X.sub(r"\1rwx\2", content) - - # RTL8720C is absent: its stock linker already consumes *(.sram.text*), so # no .ld patch is needed; the summary falls back to reading symbol addresses. _PATCHERS_BY_VARIANT = { - "BK7231N": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), - "BK7231T": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), - "BK7231Q": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), - "BK7251": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), + "BK7231N": (_inject_keep(_BK_ITCM),), + "BK7231T": (_inject_keep(_BK_ITCM),), + "BK7231Q": (_inject_keep(_BK_ITCM),), + "BK7251": (_inject_keep(_BK_ITCM),), "LN882H": (_inject_keep(_LN_COPY),), "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 2803056730..35ee55076a 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -23,13 +23,13 @@ #elif defined(USE_LIBRETINY) -// IRAM_ATTR places a function in SRAM so it is callable from an ISR even -// while flash is busy (XIP stall, OTA, logger flash write). All LibreTiny -// families use ".sram.text". RTL8720C (AmebaZ2) already consumes that -// section; RTL8710B (AmebaZ), BK72xx, and LN882H get it routed into their -// RAM-resident output section via patch_linker.py.script. Using a custom -// name avoids the assembler "setting incorrect section attributes" warning -// that ".data.*" triggers when we place executable code there. +// IRAM_ATTR places a function in executable RAM so it is callable from an +// ISR even while flash is busy (XIP stall, OTA, logger flash write). All +// LibreTiny families use ".sram.text"; patch_linker.py.script routes it +// into each family's RAM-executable output section (.itcm.code on BK72xx, +// .image2.ram.text on RTL8710B, .flash_copysection on LN882H). RTL8720C's +// stock linker already consumes *(.sram.text*) via its .ram.code_text +// output. #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #define PROGMEM From 85d1ab7303bf5854266eb112aaf461827b2981c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:24:57 -1000 Subject: [PATCH 0223/2214] libretiny: grow BK72xx .itcm region from 4.5 kB to 8.5 kB The SDK's built-in ISR / flash / FreeRTOS critical-path code already fills most of the stock 4.5 kB .itcm executable RAM region, leaving under 500 bytes for ESPHome's IRAM_ATTR functions. cb3s overflowed by 248 bytes on first link. Physically .tcm (rw!x, data) and .itcm (rwx, code) are contiguous SRAM blocks on ARM968E-S; the template's boundary is just a linker carve-out. Shift it back 4 kB so .tcm shrinks 60k -> 56k and .itcm grows 4.5k -> 8.5k. Confirmed fits every ESPHome IRAM_ATTR function on cb3s with ample headroom. --- .../libretiny/patch_linker.py.script | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 8d447630f7..2ae7702c2c 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -38,6 +38,25 @@ _BK_ITCM = re.compile(r"(\.itcm\.code\s*ALIGN\s*\(\s*\d+\s*\)\s*:\s*\{\s*\n)") _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") _RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") +# On BK72xx the stock LibreTiny linker carves a 4.5 kB ".itcm" executable RAM +# region; the SDK already fills most of it with its own ISR / flash / FreeRTOS +# critical code, leaving <500 bytes for ESPHome IRAM_ATTR functions. Steal 4 kB +# from the adjacent (rw!x) "tcm" data region and give it to itcm so our +# .sram.text payload has room to grow. Physically tcm and itcm are contiguous +# SRAM, so shifting the boundary is just a linker rewrite. +_BK_TCM_LEN = re.compile(r"(\btcm\s*\(\s*rw!x\s*\)\s*:\s*ORIGIN\s*=\s*0x003F0000\s*,\s*LENGTH\s*=\s*)60k(\s*-\s*512\b)") +_BK_ITCM_REGION = re.compile( + r"(\bitcm\s*\(\s*rwx\s*\)\s*:\s*ORIGIN\s*=\s*)0x003FEE00(\s*,\s*LENGTH\s*=\s*)4k(\s*\+\s*512\b)" +) + + +def _grow_bk72xx_itcm(content): + # Shrink tcm by 4 kB: 60k - 512 -> 56k - 512. + new_content = _BK_TCM_LEN.sub(r"\g<1>56k\g<2>", content) + # Shift itcm origin back 4 kB and grow length: 4k + 512 -> 8k + 512. + new_content = _BK_ITCM_REGION.sub(r"\g<1>0x003FDE00\g<2>8k\g<3>", new_content) + return new_content + def _detect(env): prefix = "USE_LIBRETINY_VARIANT_" @@ -80,10 +99,10 @@ def _inject_keep(host_section): # RTL8720C is absent: its stock linker already consumes *(.sram.text*), so # no .ld patch is needed; the summary falls back to reading symbol addresses. _PATCHERS_BY_VARIANT = { - "BK7231N": (_inject_keep(_BK_ITCM),), - "BK7231T": (_inject_keep(_BK_ITCM),), - "BK7231Q": (_inject_keep(_BK_ITCM),), - "BK7251": (_inject_keep(_BK_ITCM),), + "BK7231N": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), + "BK7231T": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), + "BK7231Q": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), + "BK7251": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), "LN882H": (_inject_keep(_LN_COPY),), "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), } From bc672d358bed980ae1915cc94865b44b9b9787bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:34:16 -1000 Subject: [PATCH 0224/2214] libretiny: scope BK72xx IRAM_ATTR to BK7231N; T/Q/7251 stay no-op BK7231T / BK7231Q / BK7251 share a LibreTiny linker template that only declares flash + ram, no executable RAM region. Their SDK wraps flash writes in GLOBAL_INT_DISABLE() which masks both FIQ and IRQ, so no ISR fires while flash is stalled and the "code in flash while flash is busy" scenario IRAM_ATTR is guarding against does not occur on those variants. Map IRAM_ATTR to nothing on them rather than orphan-linking .sram.text into flash and drop the T/Q/7251 entries from _PATCHERS_BY_VARIANT so the pre-link hook is not registered for them. BK7231N still gets the full fix (grown .itcm.code) because its SDK takes the defense-in-depth route of also placing flash/ISR/FreeRTOS critical code in ITCM, so our IRAM_ATTR functions need to live there too. Also make _grow_bk72xx_itcm fail loudly if the tcm/itcm declarations in the BK7231N template ever change shape, matching the fail-hard stance of the rest of the script. --- esphome/components/libretiny/__init__.py | 14 ++++++----- .../libretiny/patch_linker.py.script | 25 +++++++++++++------ esphome/core/hal.h | 21 +++++++++++----- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index b8833e5a55..10dc0fe984 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -467,15 +467,17 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) - # IRAM_ATTR on LibreTiny expands to section(".sram.text") (see - # esphome/core/hal.h). This pre-link hook rewrites the processed .ld - # files in the build dir so that section lands in an executable RAM - # output section on each family: - # - BK72xx: inject KEEP(*(.sram.text*)) into .itcm.code (the only - # rwx RAM region; main SRAM is rw!x on ARM968E-S). + # IRAM_ATTR on LibreTiny expands to section(".sram.text") on families + # where executable RAM is available (see esphome/core/hal.h). This pre- + # link hook rewrites the processed .ld files in the build dir so that + # section lands in each family's executable RAM output section: + # - BK7231N: grow .itcm from 4.5 kB to 8.5 kB (steal from .tcm) and + # inject KEEP(*(.sram.text*)) into .itcm.code. # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. # - RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text. # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). + # BK7231T/Q/7251 have no executable RAM region; their SDK disables IRQ + # + FIQ around flash writes, so IRAM_ATTR is left a no-op on them. cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 2ae7702c2c..ee6d3124f9 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -52,9 +52,20 @@ _BK_ITCM_REGION = re.compile( def _grow_bk72xx_itcm(content): # Shrink tcm by 4 kB: 60k - 512 -> 56k - 512. - new_content = _BK_TCM_LEN.sub(r"\g<1>56k\g<2>", content) + new_content, tcm_count = _BK_TCM_LEN.subn(r"\g<1>56k\g<2>", content) # Shift itcm origin back 4 kB and grow length: 4k + 512 -> 8k + 512. - new_content = _BK_ITCM_REGION.sub(r"\g<1>0x003FDE00\g<2>8k\g<3>", new_content) + new_content, itcm_count = _BK_ITCM_REGION.subn( + r"\g<1>0x003FDE00\g<2>8k\g<3>", new_content + ) + if tcm_count != 1 or itcm_count != 1: + raise RuntimeError( + "ESPHome: BK72xx linker script did not match the expected " + "tcm/itcm declarations (tcm matches: {}, itcm matches: {}); " + "refusing to link because IRAM_ATTR placement cannot be verified. " + "LibreTiny probably changed the bk7231*_bsp.template.ld layout, " + "update _BK_TCM_LEN / _BK_ITCM_REGION in patch_linker.py.script " + "before shipping firmware.".format(tcm_count, itcm_count) + ) return new_content @@ -96,13 +107,13 @@ def _inject_keep(host_section): return patch -# RTL8720C is absent: its stock linker already consumes *(.sram.text*), so -# no .ld patch is needed; the summary falls back to reading symbol addresses. +# Variants not listed here intentionally have no .ld patcher: +# - RTL8720C: stock linker already consumes *(.sram.text*). +# - BK7231T / BK7231Q / BK7251: SDK wraps flash ops in GLOBAL_INT_DISABLE() +# (FIQ + IRQ masked), so no ISR fires during a flash stall and IRAM_ATTR +# is a no-op on those variants (see esphome/core/hal.h). _PATCHERS_BY_VARIANT = { "BK7231N": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), - "BK7231T": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), - "BK7231Q": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), - "BK7251": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), "LN882H": (_inject_keep(_LN_COPY),), "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 35ee55076a..f96cb48598 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -24,13 +24,22 @@ #elif defined(USE_LIBRETINY) // IRAM_ATTR places a function in executable RAM so it is callable from an -// ISR even while flash is busy (XIP stall, OTA, logger flash write). All -// LibreTiny families use ".sram.text"; patch_linker.py.script routes it -// into each family's RAM-executable output section (.itcm.code on BK72xx, -// .image2.ram.text on RTL8710B, .flash_copysection on LN882H). RTL8720C's -// stock linker already consumes *(.sram.text*) via its .ram.code_text -// output. +// ISR even while flash is busy (XIP stall, OTA, logger flash write). +// patch_linker.py.script routes ".sram.text" into each family's RAM- +// executable output section: .itcm.code on BK7231N, .image2.ram.text on +// RTL8710B, .flash_copysection on LN882H, stock *(.sram.text*) glob on +// RTL8720C. +// +// BK7231T/Q/7251 are left as a no-op: their SDK wraps flash operations in +// GLOBAL_INT_DISABLE() which masks FIQ + IRQ for the duration of the +// write, so no ISR fires while flash is stalled and the scenario +// IRAM_ATTR guards against does not occur there. +#if defined(USE_LIBRETINY_VARIANT_BK7231T) || defined(USE_LIBRETINY_VARIANT_BK7231Q) || \ + defined(USE_LIBRETINY_VARIANT_BK7251) +#define IRAM_ATTR +#else #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) +#endif #define PROGMEM #else From 05df654a3efdf4f95574c84433ca943c8e603ff2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:17:41 -1000 Subject: [PATCH 0225/2214] libretiny: drop BK72xx from IRAM_ATTR coverage After confirming via the Beken SDKs (both BK7231T and BK7231N flash.c use GLOBAL_INT_DISABLE around every erase/program) and searching the libretiny + esphome issue trackers, the ISR-during-flash race IRAM_ATTR is designed to prevent cannot actually occur on any BK72xx variant: - Beken SDK wraps every flash op in GLOBAL_INT_DISABLE(), masking FIQ + IRQ at the CPU for the ~0.5-20 ms of the write, so no ISR fires while flash is stalled. - Interrupts are delayed (not dropped outright for single-shot sources) by that mask, but that is an SDK-level design choice and cannot be reduced from this layer. - No BK72xx user has reported the crash pattern IRAM_ATTR fixes; the real reports of that pattern are on RTL8710B (see libretiny#167). Make IRAM_ATTR a no-op on every BK72xx variant and remove the BK7231N- specific ITCM shuffling from patch_linker.py.script. The fix now covers only the families where the race is real: RTL8710B, RTL8720C, LN882H. --- .../libretiny/patch_linker.py.script | 52 ++++--------------- esphome/core/hal.h | 20 +++---- 2 files changed, 21 insertions(+), 51 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index ee6d3124f9..068e467002 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -10,11 +10,6 @@ import re # function is callable while flash is busy (XIP stall, OTA, logger flash # write): # -# - BK72xx: ARM968E-S has tightly-coupled instruction RAM ("itcm", 4.5 kB, -# rwx) that the SDK uses for its own ISR / flash / FreeRTOS critical -# routines; main SRAM ("ram", 192 kB) is rw!x (bus does not allow -# instruction fetches). Inject "KEEP(*(.sram.text*))" into ".itcm.code" -# so our IRAM_ATTR functions share the only executable RAM region. # - LN882H: stock linker has ".flash_copysection" which is flash-to-RAM0 # copied at startup; inject "KEEP(*(.sram.text*))" there. # - RTL8710B (AmebaZ): stock linker has ".image2.ram.text" — inject @@ -23,6 +18,11 @@ import re # no-op. Loaded directly from the framework package so we cannot inject # our __esphome_sram_text_start/end markers either; the post-link summary # falls back to reading known IRAM_ATTR symbol addresses instead. +# +# BK72xx (all variants) have no .ld patcher: the Beken SDK wraps every flash +# operation in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU, so no +# ISR can fire during a flash stall and the race IRAM_ATTR guards against +# cannot occur. IRAM_ATTR is a no-op on BK72xx (see esphome/core/hal.h). _MARKER = "/* esphome .sram.text */" @@ -34,40 +34,9 @@ _KEEP_LINE = ( "__esphome_sram_text_end = .; " + _MARKER + "\n" ) -_BK_ITCM = re.compile(r"(\.itcm\.code\s*ALIGN\s*\(\s*\d+\s*\)\s*:\s*\{\s*\n)") _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") _RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") -# On BK72xx the stock LibreTiny linker carves a 4.5 kB ".itcm" executable RAM -# region; the SDK already fills most of it with its own ISR / flash / FreeRTOS -# critical code, leaving <500 bytes for ESPHome IRAM_ATTR functions. Steal 4 kB -# from the adjacent (rw!x) "tcm" data region and give it to itcm so our -# .sram.text payload has room to grow. Physically tcm and itcm are contiguous -# SRAM, so shifting the boundary is just a linker rewrite. -_BK_TCM_LEN = re.compile(r"(\btcm\s*\(\s*rw!x\s*\)\s*:\s*ORIGIN\s*=\s*0x003F0000\s*,\s*LENGTH\s*=\s*)60k(\s*-\s*512\b)") -_BK_ITCM_REGION = re.compile( - r"(\bitcm\s*\(\s*rwx\s*\)\s*:\s*ORIGIN\s*=\s*)0x003FEE00(\s*,\s*LENGTH\s*=\s*)4k(\s*\+\s*512\b)" -) - - -def _grow_bk72xx_itcm(content): - # Shrink tcm by 4 kB: 60k - 512 -> 56k - 512. - new_content, tcm_count = _BK_TCM_LEN.subn(r"\g<1>56k\g<2>", content) - # Shift itcm origin back 4 kB and grow length: 4k + 512 -> 8k + 512. - new_content, itcm_count = _BK_ITCM_REGION.subn( - r"\g<1>0x003FDE00\g<2>8k\g<3>", new_content - ) - if tcm_count != 1 or itcm_count != 1: - raise RuntimeError( - "ESPHome: BK72xx linker script did not match the expected " - "tcm/itcm declarations (tcm matches: {}, itcm matches: {}); " - "refusing to link because IRAM_ATTR placement cannot be verified. " - "LibreTiny probably changed the bk7231*_bsp.template.ld layout, " - "update _BK_TCM_LEN / _BK_ITCM_REGION in patch_linker.py.script " - "before shipping firmware.".format(tcm_count, itcm_count) - ) - return new_content - def _detect(env): prefix = "USE_LIBRETINY_VARIANT_" @@ -109,11 +78,8 @@ def _inject_keep(host_section): # Variants not listed here intentionally have no .ld patcher: # - RTL8720C: stock linker already consumes *(.sram.text*). -# - BK7231T / BK7231Q / BK7251: SDK wraps flash ops in GLOBAL_INT_DISABLE() -# (FIQ + IRQ masked), so no ISR fires during a flash stall and IRAM_ATTR -# is a no-op on those variants (see esphome/core/hal.h). +# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. _PATCHERS_BY_VARIANT = { - "BK7231N": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), "LN882H": (_inject_keep(_LN_COPY),), "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), } @@ -221,5 +187,7 @@ if _patchers: # as a pre-link action so it executes once the linker scripts exist. env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) -# Post-link summary runs for every LibreTiny family. -env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) +# Post-link summary runs for every LibreTiny family (except BK72xx where +# IRAM_ATTR is a no-op and no symbols are relocated to RAM). +if _patchers or _variant == "RTL8720C": + env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index f96cb48598..01ce4189d1 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -26,16 +26,18 @@ // IRAM_ATTR places a function in executable RAM so it is callable from an // ISR even while flash is busy (XIP stall, OTA, logger flash write). // patch_linker.py.script routes ".sram.text" into each family's RAM- -// executable output section: .itcm.code on BK7231N, .image2.ram.text on -// RTL8710B, .flash_copysection on LN882H, stock *(.sram.text*) glob on -// RTL8720C. +// executable output section: .image2.ram.text on RTL8710B, +// .flash_copysection on LN882H; RTL8720C's stock linker already consumes +// *(.sram.text*) via its .ram.code_text output. // -// BK7231T/Q/7251 are left as a no-op: their SDK wraps flash operations in -// GLOBAL_INT_DISABLE() which masks FIQ + IRQ for the duration of the -// write, so no ISR fires while flash is stalled and the scenario -// IRAM_ATTR guards against does not occur there. -#if defined(USE_LIBRETINY_VARIANT_BK7231T) || defined(USE_LIBRETINY_VARIANT_BK7231Q) || \ - defined(USE_LIBRETINY_VARIANT_BK7251) +// BK72xx (all variants) are left as a no-op: their SDK wraps flash +// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for +// the duration of every write, so no ISR fires while flash is stalled and +// the race IRAM_ATTR guards against cannot occur. The trade-off is that +// interrupts are delayed (not dropped) by up to ~20 ms during a sector +// erase, but that is an SDK-level choice and cannot be changed from this +// layer. +#if defined(USE_BK72XX) #define IRAM_ATTR #else #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) From 8a7de40f4088253f784e6be31de67a490e1f6204 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:22:12 -1000 Subject: [PATCH 0226/2214] libretiny: KNOWN_VARIANTS -> frozenset for O(1) membership --- esphome/components/libretiny/patch_linker.py.script | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 068e467002..ab11257be0 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -56,7 +56,7 @@ def _detect(env): return None -KNOWN_VARIANTS = ( +KNOWN_VARIANTS = frozenset({ "BK7231N", "BK7231T", "BK7231Q", @@ -64,7 +64,7 @@ KNOWN_VARIANTS = ( "LN882H", "RTL8710B", "RTL8720C", -) +}) def _inject_keep(host_section): From f0944a944a1104127bf236ec6b071851a7e210c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:26:36 -1000 Subject: [PATCH 0227/2214] libretiny: clean up stale comments and tighten patch_linker script - hal.h: collapse the four-way USE_LIBRETINY_VARIANT_BK72* guard into USE_BK72XX. - libretiny/__init__.py: drop the stale reference to growing .itcm from 4.5 kB to 8.5 kB; BK72xx no longer gets any .ld patching. - patch_linker.py.script: hoist the duplicated "import subprocess" to the top, pull the fallback IRAM_ATTR symbols out into a module-level frozenset, and split the ELF scan into a helper so _post_link is just the print logic. --- esphome/components/libretiny/__init__.py | 12 ++-- .../libretiny/patch_linker.py.script | 57 ++++++++++--------- esphome/core/hal.h | 14 ++--- 3 files changed, 42 insertions(+), 41 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 10dc0fe984..6b24f4d52c 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -468,16 +468,14 @@ async def component_to_code(config): build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) # IRAM_ATTR on LibreTiny expands to section(".sram.text") on families - # where executable RAM is available (see esphome/core/hal.h). This pre- - # link hook rewrites the processed .ld files in the build dir so that - # section lands in each family's executable RAM output section: - # - BK7231N: grow .itcm from 4.5 kB to 8.5 kB (steal from .tcm) and - # inject KEEP(*(.sram.text*)) into .itcm.code. + # where the ISR-during-flash race is real (see esphome/core/hal.h). This + # pre-link hook routes that section into each family's executable RAM: # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. # - RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text. # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). - # BK7231T/Q/7251 have no executable RAM region; their SDK disables IRQ - # + FIQ around flash writes, so IRAM_ATTR is left a no-op on them. + # BK72xx (all variants) is no-op: the Beken SDK wraps every flash write + # in GLOBAL_INT_DISABLE() so no ISR can fire during a flash stall; the + # race IRAM_ATTR guards against cannot occur. cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index ab11257be0..f7f536df4e 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -3,6 +3,7 @@ Import("env") # noqa import os import re +import subprocess # ESPHome marks ISR code IRAM_ATTR, which on LibreTiny expands to # section(".sram.text") (see esphome/core/hal.h). Each family's linker script @@ -122,46 +123,50 @@ def _pre_link(target, source, env): _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) -def _post_link(target, source, env): - """Print where IRAM_ATTR ended up so users can confirm at a glance.""" - nm = env.subst("$NM") or "arm-none-eabi-nm" - elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") - if not os.path.isfile(elf): - return +# Well-known ESPHome IRAM_ATTR symbols used as a fallback on RTL8720C, where +# we cannot inject the __esphome_sram_text_start/end markers. +_FALLBACK_IRAM_SYMBOLS = frozenset({ + "_ZN7esphome21wake_loop_any_contextEv", + "_ZN7esphome17wake_loop_isrsafeEPl", + "_ZN7esphome9Component28enable_loop_soon_any_contextEv", +}) + + +def _collect_iram_symbols(nm, elf): + """Return (start, end, fallback_addresses) for the IRAM_ATTR payload.""" try: - import subprocess out = subprocess.check_output([nm, "--defined-only", elf], text=True) except (OSError, subprocess.CalledProcessError): - return + return None, None, [] start = end = None - sample = [] + fallback = [] for line in out.splitlines(): parts = line.split(maxsplit=2) if len(parts) != 3: continue - addr, _kind, name = parts + addr_str, _kind, name = parts if name == "__esphome_sram_text_start": - start = int(addr, 16) + start = int(addr_str, 16) elif name == "__esphome_sram_text_end": - end = int(addr, 16) - elif name in ( - "_ZN7esphome21wake_loop_any_contextEv", - "_ZN7esphome17wake_loop_isrsafeEPl", - "_ZN7esphome9Component28enable_loop_soon_any_contextEv", - ): - sample.append((int(addr, 16), name)) - header = "ESPHome: IRAM_ATTR placement summary ({}):".format(_variant) + end = int(addr_str, 16) + elif name in _FALLBACK_IRAM_SYMBOLS: + fallback.append(int(addr_str, 16)) + return start, end, fallback + + +def _post_link(target, source, env): + """Print where IRAM_ATTR ended up so users can confirm at a glance.""" + elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") + if not os.path.isfile(elf): + return + start, end, fallback = _collect_iram_symbols(env.subst("$NM"), elf) + print("ESPHome: IRAM_ATTR placement summary ({}):".format(_variant)) if start is not None and end is not None: - print(header) print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end)) - elif sample: - print(header) - sample.sort() - lo = sample[0][0] - hi = sample[-1][0] + elif fallback: + lo, hi = min(fallback), max(fallback) print(" IRAM symbols at 0x{:08x} - 0x{:08x} (approx {} bytes)".format(lo, hi, hi - lo)) else: - print(header) print(" no IRAM_ATTR symbols found in the ELF") diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 01ce4189d1..79eca8d5c3 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -56,10 +56,11 @@ #include #endif -#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7231T) || \ - defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) +#ifdef USE_BK72XX // Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so -// it is callable from Thumb code via interworking. +// it is callable from Thumb code via interworking. The MRS CPSR instruction +// is ARM-only and user code here may be built in Thumb, so in_isr_context() +// defers to this port helper on BK72xx instead of reading CPSR inline. extern "C" uint32_t platform_is_in_interrupt_context(void); #endif @@ -79,11 +80,8 @@ __attribute__((always_inline)) inline bool in_isr_context() { uint32_t ipsr; __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); return ipsr != 0; -#elif defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7231T) || \ - defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) - // BK72xx is ARM968E-S (ARM9). The MRS CPSR instruction is ARM-only, and - // user code here may be built in Thumb mode. Defer to the FreeRTOS port - // helper declared above (compiled in ARM mode by the SDK). +#elif defined(USE_BK72XX) + // BK72xx is ARM968E-S (ARM9); see extern declaration above. return platform_is_in_interrupt_context() != 0; #elif defined(USE_LIBRETINY) // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; From eae6e6d32ba306ee65b13f41a0b0c8825e6b3dc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:28:46 -1000 Subject: [PATCH 0228/2214] =?UTF-8?q?libretiny:=20fix=20RTL8710B=20linker?= =?UTF-8?q?=20regex=20=E2=80=94=20output=20is=20.ram=5Fimage2.text=20not?= =?UTF-8?q?=20.image2.ram.text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confused the output section name with the input glob. RTL8710B's linker template has: .ram_image2.text : { KEEP(*(.image2.ram.text*)) } > BD_RAM so we need to match ".ram_image2.text :" and inject our extra KEEP(*(.sram.text*)) alongside the existing input glob. CI caught this; the test_build_components rtl87xx-ard smoke test now exercises the patcher. --- esphome/components/libretiny/patch_linker.py.script | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index f7f536df4e..63d95000b6 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -36,7 +36,11 @@ _KEEP_LINE = ( + _MARKER + "\n" ) _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") -_RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") +# RTL8710B's output section is ".ram_image2.text"; its stock linker consumes +# "*(.image2.ram.text*)" as an input glob inside that output, but we need to +# add a second input glob "*(.sram.text*)" so the ESPHome-marked functions +# land in the same RAM-resident output. +_RTL8710B_IMAGE2 = re.compile(r"(\.ram_image2\.text\s*:\s*\{\s*\n)") def _detect(env): From 65f10feee4610399cb8b427ee00dadb0bd7206a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:36:13 -1000 Subject: [PATCH 0229/2214] libretiny: fix stale .image2.ram.text comments; rename dir -> script_dir; link regex from error Review feedback: - three comments still referenced the input glob '.image2.ram.text' after the output section was fixed to '.ram_image2.text' in eae6e6d32b; update comments in hal.h, libretiny/__init__.py, and patch_linker.py.script so they match the regex. - rename the 'dir' local in libretiny.copy_files() to 'script_dir' to avoid shadowing the Python builtin. - the 'no linker script patched' RuntimeError now names the per-family regex constants a maintainer should update when a LibreTiny template changes shape. --- esphome/components/libretiny/__init__.py | 6 +++--- esphome/components/libretiny/patch_linker.py.script | 10 +++++++--- esphome/core/hal.h | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 6b24f4d52c..21b176d5f0 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -471,7 +471,7 @@ async def component_to_code(config): # where the ISR-during-flash race is real (see esphome/core/hal.h). This # pre-link hook routes that section into each family's executable RAM: # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. - # - RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text. + # - RTL8710B: inject KEEP(*(.sram.text*)) into .ram_image2.text. # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). # BK72xx (all variants) is no-op: the Beken SDK wraps every flash write # in GLOBAL_INT_DISABLE() so no ISR can fire during a flash stall; the @@ -565,8 +565,8 @@ async def component_to_code(config): # Called by writer.py def copy_files() -> None: - dir = Path(__file__).parent - patch_linker_file = dir / "patch_linker.py.script" + script_dir = Path(__file__).parent + patch_linker_file = script_dir / "patch_linker.py.script" copy_file_if_changed( patch_linker_file, CORE.relative_build_path("patch_linker.py"), diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 63d95000b6..4e96064f93 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -13,8 +13,9 @@ import subprocess # # - LN882H: stock linker has ".flash_copysection" which is flash-to-RAM0 # copied at startup; inject "KEEP(*(.sram.text*))" there. -# - RTL8710B (AmebaZ): stock linker has ".image2.ram.text" — inject -# "KEEP(*(.sram.text*))" into it. +# - RTL8710B (AmebaZ): stock linker has ".ram_image2.text" output section +# (which already consumes *(.image2.ram.text*)) — inject +# "KEEP(*(.sram.text*))" into it as a second input glob. # - RTL8720C (AmebaZ2): stock linker already consumes "*(.sram.text*)", # no-op. Loaded directly from the framework package so we cannot inject # our __esphome_sram_text_start/end markers either; the post-link summary @@ -119,7 +120,10 @@ def _patch_build_dir(patchers, build_dir): raise RuntimeError( "ESPHome: no linker script in {} was patched for IRAM_ATTR; refusing " "to link because IRAM_ATTR functions would end up in flash instead of " - "SRAM and would crash on an ISR while flash is busy".format(build_dir) + "SRAM and would crash on an ISR while flash is busy. LibreTiny probably " + "reformatted the target .ld template; update the per-family regex in " + "esphome/components/libretiny/patch_linker.py.script " + "(_LN_COPY, _RTL8710B_IMAGE2).".format(build_dir) ) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 79eca8d5c3..c7d78589b4 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -26,7 +26,7 @@ // IRAM_ATTR places a function in executable RAM so it is callable from an // ISR even while flash is busy (XIP stall, OTA, logger flash write). // patch_linker.py.script routes ".sram.text" into each family's RAM- -// executable output section: .image2.ram.text on RTL8710B, +// executable output section: .ram_image2.text on RTL8710B, // .flash_copysection on LN882H; RTL8720C's stock linker already consumes // *(.sram.text*) via its .ram.code_text output. // From 663c1784b79c415a9cbc4f28634c8ec6aa467b43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:44:29 -1000 Subject: [PATCH 0230/2214] libretiny: use walrus operator for variant/patchers assignment --- esphome/components/libretiny/patch_linker.py.script | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 4e96064f93..ec16828450 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -178,9 +178,7 @@ def _post_link(target, source, env): print(" no IRAM_ATTR symbols found in the ELF") -_variant = _detect(env) - -if _variant is None: +if (_variant := _detect(env)) is None: raise RuntimeError( "ESPHome: could not determine LibreTiny variant from build flags. " "patch_linker.py needs USE_LIBRETINY_VARIANT_* to route IRAM_ATTR " @@ -193,8 +191,7 @@ if _variant not in KNOWN_VARIANTS: "patch_linker.py.script before shipping firmware.".format(_variant) ) -_patchers = _patchers_for(_variant) -if _patchers: +if _patchers := _patchers_for(_variant): # LibreTiny writes the processed .ld templates into $BUILD_DIR during its # own builder setup, which may run after this script. Register the patch # as a pre-link action so it executes once the linker scripts exist. From f9c4ed65ffdd8d18ba04bc49671753c4a2fd2bfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:47:06 -1000 Subject: [PATCH 0231/2214] libretiny: fix ESP8266 in_isr_context; use demangled names for fallback - ESP8266 in_isr_context() was checking PS.INTLEVEL which gives false positives when user code masks interrupts. Return false unconditionally since the ESP8266 wake path is context-agnostic and never calls in_isr_context(). - Replace brittle mangled C++ symbol names in the post-link fallback with substring matches on demangled names via nm --demangle. Survives namespace/signature changes. --- .../components/libretiny/patch_linker.py.script | 17 ++++++++--------- esphome/core/hal.h | 9 +++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index ec16828450..96bad69971 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -131,19 +131,18 @@ def _pre_link(target, source, env): _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) -# Well-known ESPHome IRAM_ATTR symbols used as a fallback on RTL8720C, where -# we cannot inject the __esphome_sram_text_start/end markers. -_FALLBACK_IRAM_SYMBOLS = frozenset({ - "_ZN7esphome21wake_loop_any_contextEv", - "_ZN7esphome17wake_loop_isrsafeEPl", - "_ZN7esphome9Component28enable_loop_soon_any_contextEv", -}) +# Substrings matched against demangled symbol names as a fallback on +# RTL8720C, where we cannot inject __esphome_sram_text_start/end markers. +_FALLBACK_SUBSTRINGS = ("wake_loop_any_context", "wake_loop_isrsafe", + "enable_loop_soon_any_context") def _collect_iram_symbols(nm, elf): """Return (start, end, fallback_addresses) for the IRAM_ATTR payload.""" try: - out = subprocess.check_output([nm, "--defined-only", elf], text=True) + out = subprocess.check_output( + [nm, "--defined-only", "--demangle", elf], text=True + ) except (OSError, subprocess.CalledProcessError): return None, None, [] start = end = None @@ -157,7 +156,7 @@ def _collect_iram_symbols(nm, elf): start = int(addr_str, 16) elif name == "__esphome_sram_text_end": end = int(addr_str, 16) - elif name in _FALLBACK_IRAM_SYMBOLS: + elif any(sub in name for sub in _FALLBACK_SUBSTRINGS): fallback.append(int(addr_str, 16)) return start, end, fallback diff --git a/esphome/core/hal.h b/esphome/core/hal.h index c7d78589b4..d3bc97d9c3 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -72,10 +72,11 @@ __attribute__((always_inline)) inline bool in_isr_context() { #if defined(USE_ESP32) return xPortInIsrContext() != 0; #elif defined(USE_ESP8266) - // Xtensa LX106 PS.INTLEVEL[3:0]. Non-zero indicates interrupt in progress. - uint32_t ps; - __asm__ volatile("rsr.ps %0" : "=r"(ps)); - return (ps & 0xF) != 0; + // ESP8266 has no reliable single-register ISR detection: PS.INTLEVEL is + // non-zero both in a real ISR and when user code masks interrupts. The + // ESP8266 wake path is context-agnostic (wake_loop_impl uses esp_schedule + // which is ISR-safe) so this helper is unused on this platform. + return false; #elif defined(USE_RP2040) uint32_t ipsr; __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); From 22781aff74d2874117b1743a9b0de02911a88ea0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:47:45 -1000 Subject: [PATCH 0232/2214] libretiny: reference _PATCHERS_BY_VARIANT instead of individual regex names in error --- esphome/components/libretiny/patch_linker.py.script | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 96bad69971..0118a27705 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -120,10 +120,11 @@ def _patch_build_dir(patchers, build_dir): raise RuntimeError( "ESPHome: no linker script in {} was patched for IRAM_ATTR; refusing " "to link because IRAM_ATTR functions would end up in flash instead of " - "SRAM and would crash on an ISR while flash is busy. LibreTiny probably " - "reformatted the target .ld template; update the per-family regex in " - "esphome/components/libretiny/patch_linker.py.script " - "(_LN_COPY, _RTL8710B_IMAGE2).".format(build_dir) + "SRAM and would crash on an ISR while flash is busy. LibreTiny " + "probably reformatted the target .ld template; update the per-family " + "regex in patch_linker.py.script (_PATCHERS_BY_VARIANT).".format( + build_dir + ) ) From 3b0ac8ebedb762dd9c6aecfe6129e583dce6616c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:50:34 -1000 Subject: [PATCH 0233/2214] libretiny: simplify _pre_link, inline _patch_build_dir --- .../libretiny/patch_linker.py.script | 42 +++++++------------ 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 0118a27705..189f59e9e3 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -95,43 +95,31 @@ def _patchers_for(variant): return _PATCHERS_BY_VARIANT.get(variant, ()) -def _patch_build_dir(patchers, build_dir): - if not os.path.isdir(build_dir): - raise RuntimeError( - "ESPHome: LibreTiny build dir {} does not exist at link time; " - "IRAM_ATTR placement cannot be verified".format(build_dir) - ) - patched_any = False - for name in sorted(os.listdir(build_dir)): - if not name.endswith(".ld"): - continue +def _pre_link(target, source, env): + build_dir = env.subst("$BUILD_DIR") + ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] + patched = 0 + for name in ld_files: path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: - content = fh.read() - patched = content - for patch in patchers: - patched = patch(patched) - if patched != content: + original = fh.read() + content = original + for fn in _patchers: + content = fn(content) + if content != original: with open(path, "w", encoding="utf-8") as fh: - fh.write(patched) - print("ESPHome: patched linker script {} for IRAM_ATTR placement".format(name)) - patched_any = True - if not patched_any: + fh.write(content) + print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) + patched += 1 + if not patched: raise RuntimeError( - "ESPHome: no linker script in {} was patched for IRAM_ATTR; refusing " - "to link because IRAM_ATTR functions would end up in flash instead of " - "SRAM and would crash on an ISR while flash is busy. LibreTiny " - "probably reformatted the target .ld template; update the per-family " + "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " "regex in patch_linker.py.script (_PATCHERS_BY_VARIANT).".format( build_dir ) ) -def _pre_link(target, source, env): - _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) - - # Substrings matched against demangled symbol names as a fallback on # RTL8720C, where we cannot inject __esphome_sram_text_start/end markers. _FALLBACK_SUBSTRINGS = ("wake_loop_any_context", "wake_loop_isrsafe", From 35cafd52a5d4d6342a9b4f76e59d464d6326c363 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:54:02 -1000 Subject: [PATCH 0234/2214] libretiny: inline _collect_iram_symbols into _post_link --- .../libretiny/patch_linker.py.script | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 189f59e9e3..1fcad920d9 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -120,20 +120,23 @@ def _pre_link(target, source, env): ) -# Substrings matched against demangled symbol names as a fallback on -# RTL8720C, where we cannot inject __esphome_sram_text_start/end markers. +# Substrings matched against demangled names as a fallback on RTL8720C, +# where we cannot inject __esphome_sram_text_start/end markers. _FALLBACK_SUBSTRINGS = ("wake_loop_any_context", "wake_loop_isrsafe", "enable_loop_soon_any_context") -def _collect_iram_symbols(nm, elf): - """Return (start, end, fallback_addresses) for the IRAM_ATTR payload.""" +def _post_link(target, source, env): + """Print where IRAM_ATTR ended up so users can confirm at a glance.""" + elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") + if not os.path.isfile(elf): + return try: out = subprocess.check_output( - [nm, "--defined-only", "--demangle", elf], text=True + [env.subst("$NM"), "--defined-only", "--demangle", elf], text=True ) except (OSError, subprocess.CalledProcessError): - return None, None, [] + return start = end = None fallback = [] for line in out.splitlines(): @@ -145,17 +148,8 @@ def _collect_iram_symbols(nm, elf): start = int(addr_str, 16) elif name == "__esphome_sram_text_end": end = int(addr_str, 16) - elif any(sub in name for sub in _FALLBACK_SUBSTRINGS): + elif any(s in name for s in _FALLBACK_SUBSTRINGS): fallback.append(int(addr_str, 16)) - return start, end, fallback - - -def _post_link(target, source, env): - """Print where IRAM_ATTR ended up so users can confirm at a glance.""" - elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") - if not os.path.isfile(elf): - return - start, end, fallback = _collect_iram_symbols(env.subst("$NM"), elf) print("ESPHome: IRAM_ATTR placement summary ({}):".format(_variant)) if start is not None and end is not None: print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end)) @@ -163,7 +157,7 @@ def _post_link(target, source, env): lo, hi = min(fallback), max(fallback) print(" IRAM symbols at 0x{:08x} - 0x{:08x} (approx {} bytes)".format(lo, hi, hi - lo)) else: - print(" no IRAM_ATTR symbols found in the ELF") + print(" no IRAM_ATTR symbols found") if (_variant := _detect(env)) is None: From 853782b2512a1cee4b2a17f4d599e9cbbe2f0380 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:59:26 -1000 Subject: [PATCH 0235/2214] libretiny: exclude veneer symbols from post-link IRAM fallback Linker-generated interworking veneers (e.g. ___ZN...enable_loop_soon_ any_context_veneer at 0x9b062588) contain the same function name substrings but live at unrelated addresses, producing a bogus multi-GB range in the summary. Filter them out. --- esphome/components/libretiny/patch_linker.py.script | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 1fcad920d9..45b04e8f96 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -148,7 +148,7 @@ def _post_link(target, source, env): start = int(addr_str, 16) elif name == "__esphome_sram_text_end": end = int(addr_str, 16) - elif any(s in name for s in _FALLBACK_SUBSTRINGS): + elif "veneer" not in name and any(s in name for s in _FALLBACK_SUBSTRINGS): fallback.append(int(addr_str, 16)) print("ESPHome: IRAM_ATTR placement summary ({}):".format(_variant)) if start is not None and end is not None: From 9fc54d30e5773822794028ddfb1f3373f4bd04fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:05:12 -1000 Subject: [PATCH 0236/2214] libretiny: use native section names for RTL8710B; patcher now LN882H-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RTL8710B's stock linker already consumes *(.image2.ram.text*) into its .ram_image2.text output (> BD_RAM), so hal.h can place IRAM_ATTR functions directly into section(".image2.ram.text") without any linker patching. RTL8720C already worked this way with section(".sram.text"). The patcher is now only needed for LN882H, whose stock linker has no glob that catches ".sram.text" — we inject KEEP(*(.sram.text*)) into .flash_copysection (> RAM0 AT> FLASH). This removes the _RTL8710B_IMAGE2 regex, the RTL8710B entry from _PATCHERS_BY_VARIANT, and simplifies the header comments. --- esphome/components/libretiny/__init__.py | 14 +++---- .../libretiny/patch_linker.py.script | 37 +++++++------------ esphome/core/hal.h | 6 +++ 3 files changed, 24 insertions(+), 33 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 21b176d5f0..e9449662a7 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -467,15 +467,11 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) - # IRAM_ATTR on LibreTiny expands to section(".sram.text") on families - # where the ISR-during-flash race is real (see esphome/core/hal.h). This - # pre-link hook routes that section into each family's executable RAM: - # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. - # - RTL8710B: inject KEEP(*(.sram.text*)) into .ram_image2.text. - # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). - # BK72xx (all variants) is no-op: the Beken SDK wraps every flash write - # in GLOBAL_INT_DISABLE() so no ISR can fire during a flash stall; the - # race IRAM_ATTR guards against cannot occur. + # IRAM_ATTR routes ISR code into RAM-executable sections (see + # esphome/core/hal.h). Most families need no linker help; LN882H is the + # exception — its stock linker has no glob for ".sram.text", so this + # pre-link hook injects KEEP(*(.sram.text*)) into .flash_copysection. + # The script also prints a post-link summary on all non-BK72xx families. cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 45b04e8f96..cb1d960201 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -5,26 +5,19 @@ import os import re import subprocess -# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny expands to -# section(".sram.text") (see esphome/core/hal.h). Each family's linker script -# needs that section routed into RAM-resident *executable* memory so the -# function is callable while flash is busy (XIP stall, OTA, logger flash -# write): +# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a section +# that each family's linker routes into RAM-executable memory so the function +# is callable while flash is busy (see esphome/core/hal.h for the per-family +# section names). # -# - LN882H: stock linker has ".flash_copysection" which is flash-to-RAM0 -# copied at startup; inject "KEEP(*(.sram.text*))" there. -# - RTL8710B (AmebaZ): stock linker has ".ram_image2.text" output section -# (which already consumes *(.image2.ram.text*)) — inject -# "KEEP(*(.sram.text*))" into it as a second input glob. -# - RTL8720C (AmebaZ2): stock linker already consumes "*(.sram.text*)", -# no-op. Loaded directly from the framework package so we cannot inject -# our __esphome_sram_text_start/end markers either; the post-link summary -# falls back to reading known IRAM_ATTR symbol addresses instead. +# Most families need no linker patching: +# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. +# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - BK72xx: IRAM_ATTR is a no-op (SDK masks FIQ+IRQ around flash writes). # -# BK72xx (all variants) have no .ld patcher: the Beken SDK wraps every flash -# operation in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU, so no -# ISR can fire during a flash stall and the race IRAM_ATTR guards against -# cannot occur. IRAM_ATTR is a no-op on BK72xx (see esphome/core/hal.h). +# LN882H is the only family that needs patching: its stock linker has no glob +# that catches ".sram.text", so we inject KEEP(*(.sram.text*)) into the +# ".flash_copysection" output (which is flash-to-RAM0 copied at startup). _MARKER = "/* esphome .sram.text */" @@ -37,11 +30,6 @@ _KEEP_LINE = ( + _MARKER + "\n" ) _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") -# RTL8710B's output section is ".ram_image2.text"; its stock linker consumes -# "*(.image2.ram.text*)" as an input glob inside that output, but we need to -# add a second input glob "*(.sram.text*)" so the ESPHome-marked functions -# land in the same RAM-resident output. -_RTL8710B_IMAGE2 = re.compile(r"(\.ram_image2\.text\s*:\s*\{\s*\n)") def _detect(env): @@ -83,11 +71,12 @@ def _inject_keep(host_section): # Variants not listed here intentionally have no .ld patcher: +# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker +# already routes into .ram_image2.text (> BD_RAM). # - RTL8720C: stock linker already consumes *(.sram.text*). # - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. _PATCHERS_BY_VARIANT = { "LN882H": (_inject_keep(_LN_COPY),), - "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index d3bc97d9c3..a0e13ef3c7 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -39,7 +39,13 @@ // layer. #if defined(USE_BK72XX) #define IRAM_ATTR +#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) +// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). +#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) #else +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// LN882H: patch_linker.py.script injects *(.sram.text*) into +// .flash_copysection (> RAM0 AT> FLASH). #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #endif #define PROGMEM From f961f9bfb3c156ab17822fadb35a221e95fb366f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:06:48 -1000 Subject: [PATCH 0237/2214] libretiny: fix post-link summary for RTL8710B + LN882H Post-link was not registered for RTL8710B (condition was too narrow after removing it from _PATCHERS_BY_VARIANT). Use a BK72xx exclusion set instead so all IRAM-active variants get the summary. Also fall back to TOOLCHAIN_PREFIX-nm when $NM is empty, which fixes the empty summary on LN882H. --- esphome/components/libretiny/patch_linker.py.script | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index cb1d960201..c9b04ee901 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -120,9 +120,10 @@ def _post_link(target, source, env): elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") if not os.path.isfile(elf): return + nm = env.subst("$NM") or env.subst("${TOOLCHAIN_PREFIX}nm") try: out = subprocess.check_output( - [env.subst("$NM"), "--defined-only", "--demangle", elf], text=True + [nm, "--defined-only", "--demangle", elf], text=True ) except (OSError, subprocess.CalledProcessError): return @@ -168,7 +169,8 @@ if _patchers := _patchers_for(_variant): # as a pre-link action so it executes once the linker scripts exist. env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) -# Post-link summary runs for every LibreTiny family (except BK72xx where -# IRAM_ATTR is a no-op and no symbols are relocated to RAM). -if _patchers or _variant == "RTL8720C": +_BK72XX_VARIANTS = frozenset({"BK7231N", "BK7231T", "BK7231Q", "BK7251"}) + +# Post-link summary for every family where IRAM_ATTR places code in RAM. +if _variant not in _BK72XX_VARIANTS: env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) From 1cc0ae54435964eefc541b86815ca81bdc52b891 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:07:43 -1000 Subject: [PATCH 0238/2214] libretiny: skip patch_linker.py entirely on BK72xx IRAM_ATTR is a no-op on BK72xx so there is nothing for the script to patch or summarize. Guard the extra_scripts registration with a COMPONENT_BK72XX check and drop the BK72xx variants from KNOWN_VARIANTS. --- esphome/components/libretiny/__init__.py | 11 ++++---- .../libretiny/patch_linker.py.script | 27 +++++++------------ 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index e9449662a7..4f42f40478 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -467,12 +467,11 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) - # IRAM_ATTR routes ISR code into RAM-executable sections (see - # esphome/core/hal.h). Most families need no linker help; LN882H is the - # exception — its stock linker has no glob for ".sram.text", so this - # pre-link hook injects KEEP(*(.sram.text*)) into .flash_copysection. - # The script also prints a post-link summary on all non-BK72xx families. - cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) + # IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops). + # On other families, patch_linker.py routes .sram.text into the right + # RAM-executable output section and prints a post-link placement summary. + if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX: + cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index c9b04ee901..0be4df0a33 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -5,19 +5,17 @@ import os import re import subprocess -# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a section -# that each family's linker routes into RAM-executable memory so the function -# is callable while flash is busy (see esphome/core/hal.h for the per-family -# section names). +# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family +# section routed into RAM-executable memory (see esphome/core/hal.h). # -# Most families need no linker patching: +# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK +# masks FIQ+IRQ around flash writes). On the remaining families: # - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. # - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. -# - BK72xx: IRAM_ATTR is a no-op (SDK masks FIQ+IRQ around flash writes). +# - LN882H: stock linker has no glob for ".sram.text", so we inject +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). # -# LN882H is the only family that needs patching: its stock linker has no glob -# that catches ".sram.text", so we inject KEEP(*(.sram.text*)) into the -# ".flash_copysection" output (which is flash-to-RAM0 copied at startup). +# All families also get a post-link summary showing where IRAM_ATTR landed. _MARKER = "/* esphome .sram.text */" @@ -51,10 +49,6 @@ def _detect(env): KNOWN_VARIANTS = frozenset({ - "BK7231N", - "BK7231T", - "BK7231Q", - "BK7251", "LN882H", "RTL8710B", "RTL8720C", @@ -169,8 +163,5 @@ if _patchers := _patchers_for(_variant): # as a pre-link action so it executes once the linker scripts exist. env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) -_BK72XX_VARIANTS = frozenset({"BK7231N", "BK7231T", "BK7231Q", "BK7251"}) - -# Post-link summary for every family where IRAM_ATTR places code in RAM. -if _variant not in _BK72XX_VARIANTS: - env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) +# Post-link summary for every family that reaches this script. +env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) From 384f8f687002f9f02231e54befe9245f209818cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:08:58 -1000 Subject: [PATCH 0239/2214] libretiny: derive nm from CC path when $NM is unset --- esphome/components/libretiny/patch_linker.py.script | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 0be4df0a33..183759559a 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -114,7 +114,11 @@ def _post_link(target, source, env): elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") if not os.path.isfile(elf): return - nm = env.subst("$NM") or env.subst("${TOOLCHAIN_PREFIX}nm") + # $NM may be unset; derive from $CC (e.g. arm-none-eabi-gcc -> arm-none-eabi-nm). + nm = env.subst("$NM") + if not nm: + cc = env.subst("$CC") + nm = cc.replace("-gcc", "-nm") if cc.endswith("-gcc") else "nm" try: out = subprocess.check_output( [nm, "--defined-only", "--demangle", elf], text=True From 1aeb04b4dc51775e297e84acfdf4ba26c37e1b3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:09:55 -1000 Subject: [PATCH 0240/2214] libretiny: log nm failure reason instead of silently swallowing --- esphome/components/libretiny/patch_linker.py.script | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 183759559a..f2da6c9161 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -123,7 +123,8 @@ def _post_link(target, source, env): out = subprocess.check_output( [nm, "--defined-only", "--demangle", elf], text=True ) - except (OSError, subprocess.CalledProcessError): + except (OSError, subprocess.CalledProcessError) as exc: + print("ESPHome: IRAM_ATTR summary unavailable (nm failed: {})".format(exc)) return start = end = None fallback = [] From f296a86675ed675400a7046f7da11f9ac314e89e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:10:29 -1000 Subject: [PATCH 0241/2214] libretiny: drop unnecessary CC-derived nm fallback --- esphome/components/libretiny/patch_linker.py.script | 4 ---- 1 file changed, 4 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index f2da6c9161..c490b10e9b 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -114,11 +114,7 @@ def _post_link(target, source, env): elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") if not os.path.isfile(elf): return - # $NM may be unset; derive from $CC (e.g. arm-none-eabi-gcc -> arm-none-eabi-nm). nm = env.subst("$NM") - if not nm: - cc = env.subst("$CC") - nm = cc.replace("-gcc", "-nm") if cc.endswith("-gcc") else "nm" try: out = subprocess.check_output( [nm, "--defined-only", "--demangle", elf], text=True From 21c32966ee0230a020a96cbf72630451e0161d4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:11:50 -1000 Subject: [PATCH 0242/2214] =?UTF-8?q?libretiny:=20fix=20hal.h=20intro=20co?= =?UTF-8?q?mment=20=E2=80=94=20only=20LN882H=20uses=20the=20patcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- esphome/core/hal.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index a0e13ef3c7..e4083622b9 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -25,10 +25,10 @@ // IRAM_ATTR places a function in executable RAM so it is callable from an // ISR even while flash is busy (XIP stall, OTA, logger flash write). -// patch_linker.py.script routes ".sram.text" into each family's RAM- -// executable output section: .ram_image2.text on RTL8710B, -// .flash_copysection on LN882H; RTL8720C's stock linker already consumes -// *(.sram.text*) via its .ram.code_text output. +// Each family uses a section its stock linker already routes to RAM: +// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the +// exception: its stock linker has no matching glob, so patch_linker.py +// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. // // BK72xx (all variants) are left as a no-op: their SDK wraps flash // operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for From 849463b8a5c20c15f99b54d3bbb484367c1afd0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:47:44 -1000 Subject: [PATCH 0243/2214] libretiny: treat already-patched .ld as success on incremental rebuilds The idempotency check in _inject_keep returns content unchanged when the marker is already present, so patched stayed 0 and the fail-hard RuntimeError fired on every incremental rebuild after the first. Check for the marker before attempting to patch and count it as success. --- esphome/components/libretiny/patch_linker.py.script | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index c490b10e9b..282a31d3f2 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -86,6 +86,9 @@ def _pre_link(target, source, env): path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: original = fh.read() + if _MARKER in original: + patched += 1 + continue content = original for fn in _patchers: content = fn(content) From 01b5bef37fafaf4a4a1bb4db1240c5ea43dac717 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 17:44:42 -1000 Subject: [PATCH 0244/2214] [status_led] Disable loop when idle (#15642) --- esphome/components/status_led/status_led.cpp | 19 +++++++++++++++---- esphome/core/application.cpp | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index a792110eeb..48762a7333 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -7,6 +7,11 @@ namespace status_led { static const char *const TAG = "status_led"; +static constexpr uint32_t ERROR_PERIOD_MS = 250; +static constexpr uint32_t ERROR_ON_MS = 150; +static constexpr uint32_t WARNING_PERIOD_MS = 1500; +static constexpr uint32_t WARNING_ON_MS = 250; + StatusLED *global_status_led = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) StatusLED::StatusLED(GPIOPin *pin) : pin_(pin) { global_status_led = this; } @@ -19,12 +24,18 @@ void StatusLED::dump_config() { LOG_PIN(" Pin: ", this->pin_); } void StatusLED::loop() { - if ((App.get_app_state() & STATUS_LED_ERROR) != 0u) { - this->pin_->digital_write(millis() % 250u < 150u); - } else if ((App.get_app_state() & STATUS_LED_WARNING) != 0u) { - this->pin_->digital_write(millis() % 1500u < 250u); + const uint32_t app_state = App.get_app_state(); + // Use millis() rather than App.get_loop_component_start_time() because this loop is also + // dispatched from Application::feed_wdt() during long blocking operations, where the cached + // per-component timestamp doesn't advance and would freeze the blink pattern. + const uint32_t now = millis(); + if ((app_state & STATUS_LED_ERROR) != 0u) { + this->pin_->digital_write(now % ERROR_PERIOD_MS < ERROR_ON_MS); + } else if ((app_state & STATUS_LED_WARNING) != 0u) { + this->pin_->digital_write(now % WARNING_PERIOD_MS < WARNING_ON_MS); } else { this->pin_->digital_write(false); + this->disable_loop(); } } float StatusLED::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 1c73230705..866edebbf6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -225,7 +225,21 @@ void HOT Application::feed_wdt_slow_(uint32_t time) { this->last_wdt_feed_ = time; #ifdef USE_STATUS_LED if (status_led::global_status_led != nullptr) { - status_led::global_status_led->call(); + auto *sl = status_led::global_status_led; + uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; + if (sl_state == COMPONENT_STATE_LOOP_DONE) { + // status_led only transitions to LOOP_DONE from inside its own loop() (after the + // first idle-path dispatch), so its pin is already initialized by pre_setup() and + // its setup() has already run. Re-dispatch only if an error or warning bit has been + // set since; otherwise skip entirely. + if ((this->app_state_ & STATUS_LED_MASK) == 0) + return; + sl->enable_loop(); + } else if (sl_state != COMPONENT_STATE_LOOP) { + // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. + return; + } + sl->loop(); } #endif } From e7194dce75e3c7c9be03c6aaf78b2769ffb7c178 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 17:45:01 -1000 Subject: [PATCH 0245/2214] [core] Deduplicate entity type boilerplate with X-macro pattern (#15618) --- esphome/components/web_server/list_entities.h | 3 + esphome/core/application.h | 366 ++---------------- esphome/core/component_iterator.cpp | 152 +------- esphome/core/component_iterator.h | 156 +------- esphome/core/controller.h | 139 +------ esphome/core/controller_registry.cpp | 2 - esphome/core/controller_registry.h | 326 +--------------- esphome/core/entity_includes.h | 79 ++++ esphome/core/entity_types.h | 98 +++++ esphome/writer.py | 6 +- script/ci-custom.py | 2 +- script/helpers.py | 7 +- 12 files changed, 281 insertions(+), 1055 deletions(-) create mode 100644 esphome/core/entity_includes.h create mode 100644 esphome/core/entity_types.h diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index 6a84066109..8c22d757b6 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -75,6 +75,9 @@ class ListEntitiesIterator final : public ComponentIterator { #ifdef USE_VALVE bool on_valve(valve::Valve *obj) override; #endif +#ifdef USE_MEDIA_PLAYER + bool on_media_player(media_player::MediaPlayer *obj) override { return true; } +#endif #ifdef USE_ALARM_CONTROL_PANEL bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) override; #endif diff --git a/esphome/core/application.h b/esphome/core/application.h index 60087d527d..b4bb8a1eec 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -39,78 +39,7 @@ #include "esphome/components/runtime_stats/runtime_stats.h" #endif #include "esphome/core/wake.h" -#ifdef USE_BINARY_SENSOR -#include "esphome/components/binary_sensor/binary_sensor.h" -#endif -#ifdef USE_SENSOR -#include "esphome/components/sensor/sensor.h" -#endif -#ifdef USE_SWITCH -#include "esphome/components/switch/switch.h" -#endif -#ifdef USE_BUTTON -#include "esphome/components/button/button.h" -#endif -#ifdef USE_TEXT_SENSOR -#include "esphome/components/text_sensor/text_sensor.h" -#endif -#ifdef USE_FAN -#include "esphome/components/fan/fan.h" -#endif -#ifdef USE_CLIMATE -#include "esphome/components/climate/climate.h" -#endif -#ifdef USE_LIGHT -#include "esphome/components/light/light_state.h" -#endif -#ifdef USE_COVER -#include "esphome/components/cover/cover.h" -#endif -#ifdef USE_NUMBER -#include "esphome/components/number/number.h" -#endif -#ifdef USE_DATETIME_DATE -#include "esphome/components/datetime/date_entity.h" -#endif -#ifdef USE_DATETIME_TIME -#include "esphome/components/datetime/time_entity.h" -#endif -#ifdef USE_DATETIME_DATETIME -#include "esphome/components/datetime/datetime_entity.h" -#endif -#ifdef USE_TEXT -#include "esphome/components/text/text.h" -#endif -#ifdef USE_SELECT -#include "esphome/components/select/select.h" -#endif -#ifdef USE_LOCK -#include "esphome/components/lock/lock.h" -#endif -#ifdef USE_VALVE -#include "esphome/components/valve/valve.h" -#endif -#ifdef USE_MEDIA_PLAYER -#include "esphome/components/media_player/media_player.h" -#endif -#ifdef USE_ALARM_CONTROL_PANEL -#include "esphome/components/alarm_control_panel/alarm_control_panel.h" -#endif -#ifdef USE_WATER_HEATER -#include "esphome/components/water_heater/water_heater.h" -#endif -#ifdef USE_INFRARED -#include "esphome/components/infrared/infrared.h" -#endif -#ifdef USE_SERIAL_PROXY -#include "esphome/components/serial_proxy/serial_proxy.h" -#endif -#ifdef USE_EVENT -#include "esphome/components/event/event.h" -#endif -#ifdef USE_UPDATE -#include "esphome/components/update/update_entity.h" -#endif +#include "esphome/core/entity_includes.h" namespace esphome::socket { #ifdef USE_HOST @@ -190,93 +119,16 @@ class Application { void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } -#ifdef USE_BINARY_SENSOR - void register_binary_sensor(binary_sensor::BinarySensor *binary_sensor) { - this->binary_sensors_.push_back(binary_sensor); - } -#endif - -#ifdef USE_SENSOR - void register_sensor(sensor::Sensor *sensor) { this->sensors_.push_back(sensor); } -#endif - -#ifdef USE_SWITCH - void register_switch(switch_::Switch *a_switch) { this->switches_.push_back(a_switch); } -#endif - -#ifdef USE_BUTTON - void register_button(button::Button *button) { this->buttons_.push_back(button); } -#endif - -#ifdef USE_TEXT_SENSOR - void register_text_sensor(text_sensor::TextSensor *sensor) { this->text_sensors_.push_back(sensor); } -#endif - -#ifdef USE_FAN - void register_fan(fan::Fan *state) { this->fans_.push_back(state); } -#endif - -#ifdef USE_COVER - void register_cover(cover::Cover *cover) { this->covers_.push_back(cover); } -#endif - -#ifdef USE_CLIMATE - void register_climate(climate::Climate *climate) { this->climates_.push_back(climate); } -#endif - -#ifdef USE_LIGHT - void register_light(light::LightState *light) { this->lights_.push_back(light); } -#endif - -#ifdef USE_NUMBER - void register_number(number::Number *number) { this->numbers_.push_back(number); } -#endif - -#ifdef USE_DATETIME_DATE - void register_date(datetime::DateEntity *date) { this->dates_.push_back(date); } -#endif - -#ifdef USE_DATETIME_TIME - void register_time(datetime::TimeEntity *time) { this->times_.push_back(time); } -#endif - -#ifdef USE_DATETIME_DATETIME - void register_datetime(datetime::DateTimeEntity *datetime) { this->datetimes_.push_back(datetime); } -#endif - -#ifdef USE_TEXT - void register_text(text::Text *text) { this->texts_.push_back(text); } -#endif - -#ifdef USE_SELECT - void register_select(select::Select *select) { this->selects_.push_back(select); } -#endif - -#ifdef USE_LOCK - void register_lock(lock::Lock *a_lock) { this->locks_.push_back(a_lock); } -#endif - -#ifdef USE_VALVE - void register_valve(valve::Valve *valve) { this->valves_.push_back(valve); } -#endif - -#ifdef USE_MEDIA_PLAYER - void register_media_player(media_player::MediaPlayer *media_player) { this->media_players_.push_back(media_player); } -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - void register_alarm_control_panel(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) { - this->alarm_control_panels_.push_back(a_alarm_control_panel); - } -#endif - -#ifdef USE_WATER_HEATER - void register_water_heater(water_heater::WaterHeater *water_heater) { this->water_heaters_.push_back(water_heater); } -#endif - -#ifdef USE_INFRARED - void register_infrared(infrared::Infrared *infrared) { this->infrareds_.push_back(infrared); } -#endif +// Entity register methods (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + void register_##singular(type *obj) { this->plural##_.push_back(obj); } +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_SERIAL_PROXY void register_serial_proxy(serial_proxy::SerialProxy *proxy) { @@ -285,14 +137,6 @@ class Application { } #endif -#ifdef USE_EVENT - void register_event(event::Event *event) { this->events_.push_back(event); } -#endif - -#ifdef USE_UPDATE - void register_update(update::UpdateEntity *update) { this->updates_.push_back(update); } -#endif - /// Reserve space for components to avoid memory fragmentation /// Set up all the registered components. Call this at the end of your setup() function. @@ -456,108 +300,22 @@ class Application { #ifdef USE_AREAS const auto &get_areas() { return this->areas_; } #endif -#ifdef USE_BINARY_SENSOR - auto &get_binary_sensors() const { return this->binary_sensors_; } - GET_ENTITY_METHOD(binary_sensor::BinarySensor, binary_sensor, binary_sensors) -#endif -#ifdef USE_SWITCH - auto &get_switches() const { return this->switches_; } - GET_ENTITY_METHOD(switch_::Switch, switch, switches) -#endif -#ifdef USE_BUTTON - auto &get_buttons() const { return this->buttons_; } - GET_ENTITY_METHOD(button::Button, button, buttons) -#endif -#ifdef USE_SENSOR - auto &get_sensors() const { return this->sensors_; } - GET_ENTITY_METHOD(sensor::Sensor, sensor, sensors) -#endif -#ifdef USE_TEXT_SENSOR - auto &get_text_sensors() const { return this->text_sensors_; } - GET_ENTITY_METHOD(text_sensor::TextSensor, text_sensor, text_sensors) -#endif -#ifdef USE_FAN - auto &get_fans() const { return this->fans_; } - GET_ENTITY_METHOD(fan::Fan, fan, fans) -#endif -#ifdef USE_COVER - auto &get_covers() const { return this->covers_; } - GET_ENTITY_METHOD(cover::Cover, cover, covers) -#endif -#ifdef USE_LIGHT - auto &get_lights() const { return this->lights_; } - GET_ENTITY_METHOD(light::LightState, light, lights) -#endif -#ifdef USE_CLIMATE - auto &get_climates() const { return this->climates_; } - GET_ENTITY_METHOD(climate::Climate, climate, climates) -#endif -#ifdef USE_NUMBER - auto &get_numbers() const { return this->numbers_; } - GET_ENTITY_METHOD(number::Number, number, numbers) -#endif -#ifdef USE_DATETIME_DATE - auto &get_dates() const { return this->dates_; } - GET_ENTITY_METHOD(datetime::DateEntity, date, dates) -#endif -#ifdef USE_DATETIME_TIME - auto &get_times() const { return this->times_; } - GET_ENTITY_METHOD(datetime::TimeEntity, time, times) -#endif -#ifdef USE_DATETIME_DATETIME - auto &get_datetimes() const { return this->datetimes_; } - GET_ENTITY_METHOD(datetime::DateTimeEntity, datetime, datetimes) -#endif -#ifdef USE_TEXT - auto &get_texts() const { return this->texts_; } - GET_ENTITY_METHOD(text::Text, text, texts) -#endif -#ifdef USE_SELECT - auto &get_selects() const { return this->selects_; } - GET_ENTITY_METHOD(select::Select, select, selects) -#endif -#ifdef USE_LOCK - auto &get_locks() const { return this->locks_; } - GET_ENTITY_METHOD(lock::Lock, lock, locks) -#endif -#ifdef USE_VALVE - auto &get_valves() const { return this->valves_; } - GET_ENTITY_METHOD(valve::Valve, valve, valves) -#endif -#ifdef USE_MEDIA_PLAYER - auto &get_media_players() const { return this->media_players_; } - GET_ENTITY_METHOD(media_player::MediaPlayer, media_player, media_players) -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - auto &get_alarm_control_panels() const { return this->alarm_control_panels_; } - GET_ENTITY_METHOD(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels) -#endif - -#ifdef USE_WATER_HEATER - auto &get_water_heaters() const { return this->water_heaters_; } - GET_ENTITY_METHOD(water_heater::WaterHeater, water_heater, water_heaters) -#endif - -#ifdef USE_INFRARED - auto &get_infrareds() const { return this->infrareds_; } - GET_ENTITY_METHOD(infrared::Infrared, infrared, infrareds) -#endif +// Entity getter methods (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + auto &get_##plural() const { return this->plural##_; } \ + GET_ENTITY_METHOD(type, singular, plural) +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_SERIAL_PROXY auto &get_serial_proxies() const { return this->serial_proxies_; } #endif -#ifdef USE_EVENT - auto &get_events() const { return this->events_; } - GET_ENTITY_METHOD(event::Event, event, events) -#endif - -#ifdef USE_UPDATE - auto &get_updates() const { return this->updates_; } - GET_ENTITY_METHOD(update::UpdateEntity, update, updates) -#endif - Scheduler scheduler; /// Register/unregister a socket to be monitored for read events. @@ -743,79 +501,19 @@ class Application { #ifdef USE_AREAS StaticVector areas_{}; #endif -#ifdef USE_BINARY_SENSOR - StaticVector binary_sensors_{}; -#endif -#ifdef USE_SWITCH - StaticVector switches_{}; -#endif -#ifdef USE_BUTTON - StaticVector buttons_{}; -#endif -#ifdef USE_EVENT - StaticVector events_{}; -#endif -#ifdef USE_SENSOR - StaticVector sensors_{}; -#endif -#ifdef USE_TEXT_SENSOR - StaticVector text_sensors_{}; -#endif -#ifdef USE_FAN - StaticVector fans_{}; -#endif -#ifdef USE_COVER - StaticVector covers_{}; -#endif -#ifdef USE_CLIMATE - StaticVector climates_{}; -#endif -#ifdef USE_LIGHT - StaticVector lights_{}; -#endif -#ifdef USE_NUMBER - StaticVector numbers_{}; -#endif -#ifdef USE_DATETIME_DATE - StaticVector dates_{}; -#endif -#ifdef USE_DATETIME_TIME - StaticVector times_{}; -#endif -#ifdef USE_DATETIME_DATETIME - StaticVector datetimes_{}; -#endif -#ifdef USE_SELECT - StaticVector selects_{}; -#endif -#ifdef USE_TEXT - StaticVector texts_{}; -#endif -#ifdef USE_LOCK - StaticVector locks_{}; -#endif -#ifdef USE_VALVE - StaticVector valves_{}; -#endif -#ifdef USE_MEDIA_PLAYER - StaticVector media_players_{}; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - StaticVector - alarm_control_panels_{}; -#endif -#ifdef USE_WATER_HEATER - StaticVector water_heaters_{}; -#endif -#ifdef USE_INFRARED - StaticVector infrareds_{}; -#endif +// Entity StaticVector fields (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) StaticVector plural##_{}; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) + #ifdef USE_SERIAL_PROXY StaticVector serial_proxies_{}; #endif -#ifdef USE_UPDATE - StaticVector updates_{}; -#endif }; /// Global storage of Application pointer - only one Application can exist. diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index ff76b2b81b..f4d3c05e19 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -33,53 +33,18 @@ void ComponentIterator::advance() { } break; -#ifdef USE_BINARY_SENSOR - case IteratorState::BINARY_SENSOR: - this->process_platform_item_(App.get_binary_sensors(), &ComponentIterator::on_binary_sensor); - break; -#endif - -#ifdef USE_COVER - case IteratorState::COVER: - this->process_platform_item_(App.get_covers(), &ComponentIterator::on_cover); - break; -#endif - -#ifdef USE_FAN - case IteratorState::FAN: - this->process_platform_item_(App.get_fans(), &ComponentIterator::on_fan); - break; -#endif - -#ifdef USE_LIGHT - case IteratorState::LIGHT: - this->process_platform_item_(App.get_lights(), &ComponentIterator::on_light); - break; -#endif - -#ifdef USE_SENSOR - case IteratorState::SENSOR: - this->process_platform_item_(App.get_sensors(), &ComponentIterator::on_sensor); - break; -#endif - -#ifdef USE_SWITCH - case IteratorState::SWITCH: - this->process_platform_item_(App.get_switches(), &ComponentIterator::on_switch); - break; -#endif - -#ifdef USE_BUTTON - case IteratorState::BUTTON: - this->process_platform_item_(App.get_buttons(), &ComponentIterator::on_button); - break; -#endif - -#ifdef USE_TEXT_SENSOR - case IteratorState::TEXT_SENSOR: - this->process_platform_item_(App.get_text_sensors(), &ComponentIterator::on_text_sensor); - break; -#endif +// Entity iterator cases (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + case IteratorState::upper: \ + this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular); \ + break; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_API_USER_DEFINED_ACTIONS case IteratorState::SERVICE: @@ -97,96 +62,6 @@ void ComponentIterator::advance() { } break; #endif -#ifdef USE_CLIMATE - case IteratorState::CLIMATE: - this->process_platform_item_(App.get_climates(), &ComponentIterator::on_climate); - break; -#endif - -#ifdef USE_NUMBER - case IteratorState::NUMBER: - this->process_platform_item_(App.get_numbers(), &ComponentIterator::on_number); - break; -#endif - -#ifdef USE_DATETIME_DATE - case IteratorState::DATETIME_DATE: - this->process_platform_item_(App.get_dates(), &ComponentIterator::on_date); - break; -#endif - -#ifdef USE_DATETIME_TIME - case IteratorState::DATETIME_TIME: - this->process_platform_item_(App.get_times(), &ComponentIterator::on_time); - break; -#endif - -#ifdef USE_DATETIME_DATETIME - case IteratorState::DATETIME_DATETIME: - this->process_platform_item_(App.get_datetimes(), &ComponentIterator::on_datetime); - break; -#endif - -#ifdef USE_TEXT - case IteratorState::TEXT: - this->process_platform_item_(App.get_texts(), &ComponentIterator::on_text); - break; -#endif - -#ifdef USE_SELECT - case IteratorState::SELECT: - this->process_platform_item_(App.get_selects(), &ComponentIterator::on_select); - break; -#endif - -#ifdef USE_LOCK - case IteratorState::LOCK: - this->process_platform_item_(App.get_locks(), &ComponentIterator::on_lock); - break; -#endif - -#ifdef USE_VALVE - case IteratorState::VALVE: - this->process_platform_item_(App.get_valves(), &ComponentIterator::on_valve); - break; -#endif - -#ifdef USE_MEDIA_PLAYER - case IteratorState::MEDIA_PLAYER: - this->process_platform_item_(App.get_media_players(), &ComponentIterator::on_media_player); - break; -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - case IteratorState::ALARM_CONTROL_PANEL: - this->process_platform_item_(App.get_alarm_control_panels(), &ComponentIterator::on_alarm_control_panel); - break; -#endif - -#ifdef USE_WATER_HEATER - case IteratorState::WATER_HEATER: - this->process_platform_item_(App.get_water_heaters(), &ComponentIterator::on_water_heater); - break; -#endif - -#ifdef USE_INFRARED - case IteratorState::INFRARED: - this->process_platform_item_(App.get_infrareds(), &ComponentIterator::on_infrared); - break; -#endif - -#ifdef USE_EVENT - case IteratorState::EVENT: - this->process_platform_item_(App.get_events(), &ComponentIterator::on_event); - break; -#endif - -#ifdef USE_UPDATE - case IteratorState::UPDATE: - this->process_platform_item_(App.get_updates(), &ComponentIterator::on_update); - break; -#endif - case IteratorState::MAX: if (this->on_end()) { this->state_ = IteratorState::NONE; @@ -203,7 +78,4 @@ bool ComponentIterator::on_service(api::UserServiceDescriptor *service) { return #ifdef USE_CAMERA bool ComponentIterator::on_camera(camera::Camera *camera) { return true; } #endif -#ifdef USE_MEDIA_PLAYER -bool ComponentIterator::on_media_player(media_player::MediaPlayer *media_player) { return true; } -#endif } // namespace esphome diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 6c03b74a17..9a1e5da351 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -28,80 +28,21 @@ class ComponentIterator { void advance(); bool completed() const { return this->state_ == IteratorState::NONE; } virtual bool on_begin(); -#ifdef USE_BINARY_SENSOR - virtual bool on_binary_sensor(binary_sensor::BinarySensor *binary_sensor) = 0; -#endif -#ifdef USE_COVER - virtual bool on_cover(cover::Cover *cover) = 0; -#endif -#ifdef USE_FAN - virtual bool on_fan(fan::Fan *fan) = 0; -#endif -#ifdef USE_LIGHT - virtual bool on_light(light::LightState *light) = 0; -#endif -#ifdef USE_SENSOR - virtual bool on_sensor(sensor::Sensor *sensor) = 0; -#endif -#ifdef USE_SWITCH - virtual bool on_switch(switch_::Switch *a_switch) = 0; -#endif -#ifdef USE_BUTTON - virtual bool on_button(button::Button *button) = 0; -#endif -#ifdef USE_TEXT_SENSOR - virtual bool on_text_sensor(text_sensor::TextSensor *text_sensor) = 0; -#endif +// Pure virtual entity callbacks (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) virtual bool on_##singular(type *obj) = 0; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ +// NOLINTEND(bugprone-macro-parentheses) +// Non-entity and non-pure-virtual callbacks (have default implementations) #ifdef USE_API_USER_DEFINED_ACTIONS virtual bool on_service(api::UserServiceDescriptor *service); #endif #ifdef USE_CAMERA virtual bool on_camera(camera::Camera *camera); -#endif -#ifdef USE_CLIMATE - virtual bool on_climate(climate::Climate *climate) = 0; -#endif -#ifdef USE_NUMBER - virtual bool on_number(number::Number *number) = 0; -#endif -#ifdef USE_DATETIME_DATE - virtual bool on_date(datetime::DateEntity *date) = 0; -#endif -#ifdef USE_DATETIME_TIME - virtual bool on_time(datetime::TimeEntity *time) = 0; -#endif -#ifdef USE_DATETIME_DATETIME - virtual bool on_datetime(datetime::DateTimeEntity *datetime) = 0; -#endif -#ifdef USE_TEXT - virtual bool on_text(text::Text *text) = 0; -#endif -#ifdef USE_SELECT - virtual bool on_select(select::Select *select) = 0; -#endif -#ifdef USE_LOCK - virtual bool on_lock(lock::Lock *a_lock) = 0; -#endif -#ifdef USE_VALVE - virtual bool on_valve(valve::Valve *valve) = 0; -#endif -#ifdef USE_MEDIA_PLAYER - virtual bool on_media_player(media_player::MediaPlayer *media_player); -#endif -#ifdef USE_ALARM_CONTROL_PANEL - virtual bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) = 0; -#endif -#ifdef USE_WATER_HEATER - virtual bool on_water_heater(water_heater::WaterHeater *water_heater) = 0; -#endif -#ifdef USE_INFRARED - virtual bool on_infrared(infrared::Infrared *infrared) = 0; -#endif -#ifdef USE_EVENT - virtual bool on_event(event::Event *event) = 0; -#endif -#ifdef USE_UPDATE - virtual bool on_update(update::UpdateEntity *update) = 0; #endif virtual bool on_end(); @@ -111,80 +52,19 @@ class ComponentIterator { enum class IteratorState : uint8_t { NONE = 0, BEGIN, -#ifdef USE_BINARY_SENSOR - BINARY_SENSOR, -#endif -#ifdef USE_COVER - COVER, -#endif -#ifdef USE_FAN - FAN, -#endif -#ifdef USE_LIGHT - LIGHT, -#endif -#ifdef USE_SENSOR - SENSOR, -#endif -#ifdef USE_SWITCH - SWITCH, -#endif -#ifdef USE_BUTTON - BUTTON, -#endif -#ifdef USE_TEXT_SENSOR - TEXT_SENSOR, -#endif +// Entity iterator states (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) upper, +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) upper, +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ +// NOLINTEND(bugprone-macro-parentheses) #ifdef USE_API_USER_DEFINED_ACTIONS SERVICE, #endif #ifdef USE_CAMERA CAMERA, -#endif -#ifdef USE_CLIMATE - CLIMATE, -#endif -#ifdef USE_NUMBER - NUMBER, -#endif -#ifdef USE_DATETIME_DATE - DATETIME_DATE, -#endif -#ifdef USE_DATETIME_TIME - DATETIME_TIME, -#endif -#ifdef USE_DATETIME_DATETIME - DATETIME_DATETIME, -#endif -#ifdef USE_TEXT - TEXT, -#endif -#ifdef USE_SELECT - SELECT, -#endif -#ifdef USE_LOCK - LOCK, -#endif -#ifdef USE_VALVE - VALVE, -#endif -#ifdef USE_MEDIA_PLAYER - MEDIA_PLAYER, -#endif -#ifdef USE_ALARM_CONTROL_PANEL - ALARM_CONTROL_PANEL, -#endif -#ifdef USE_WATER_HEATER - WATER_HEATER, -#endif -#ifdef USE_INFRARED - INFRARED, -#endif -#ifdef USE_EVENT - EVENT, -#endif -#ifdef USE_UPDATE - UPDATE, #endif MAX, }; diff --git a/esphome/core/controller.h b/esphome/core/controller.h index 632b46c893..09975b465f 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -1,140 +1,19 @@ #pragma once -#include "esphome/core/defines.h" -#ifdef USE_BINARY_SENSOR -#include "esphome/components/binary_sensor/binary_sensor.h" -#endif -#ifdef USE_FAN -#include "esphome/components/fan/fan.h" -#endif -#ifdef USE_LIGHT -#include "esphome/components/light/light_state.h" -#endif -#ifdef USE_COVER -#include "esphome/components/cover/cover.h" -#endif -#ifdef USE_SENSOR -#include "esphome/components/sensor/sensor.h" -#endif -#ifdef USE_TEXT_SENSOR -#include "esphome/components/text_sensor/text_sensor.h" -#endif -#ifdef USE_SWITCH -#include "esphome/components/switch/switch.h" -#endif -#ifdef USE_BUTTON -#include "esphome/components/button/button.h" -#endif -#ifdef USE_CLIMATE -#include "esphome/components/climate/climate.h" -#endif -#ifdef USE_NUMBER -#include "esphome/components/number/number.h" -#endif -#ifdef USE_DATETIME_DATE -#include "esphome/components/datetime/date_entity.h" -#endif -#ifdef USE_DATETIME_TIME -#include "esphome/components/datetime/time_entity.h" -#endif -#ifdef USE_DATETIME_DATETIME -#include "esphome/components/datetime/datetime_entity.h" -#endif -#ifdef USE_TEXT -#include "esphome/components/text/text.h" -#endif -#ifdef USE_SELECT -#include "esphome/components/select/select.h" -#endif -#ifdef USE_LOCK -#include "esphome/components/lock/lock.h" -#endif -#ifdef USE_VALVE -#include "esphome/components/valve/valve.h" -#endif -#ifdef USE_MEDIA_PLAYER -#include "esphome/components/media_player/media_player.h" -#endif -#ifdef USE_ALARM_CONTROL_PANEL -#include "esphome/components/alarm_control_panel/alarm_control_panel.h" -#endif -#ifdef USE_WATER_HEATER -#include "esphome/components/water_heater/water_heater.h" -#endif -#ifdef USE_EVENT -#include "esphome/components/event/event.h" -#endif -#ifdef USE_UPDATE -#include "esphome/components/update/update_entity.h" -#endif +#include "esphome/core/entity_includes.h" namespace esphome { class Controller { public: -#ifdef USE_BINARY_SENSOR - virtual void on_binary_sensor_update(binary_sensor::BinarySensor *obj){}; -#endif -#ifdef USE_FAN - virtual void on_fan_update(fan::Fan *obj){}; -#endif -#ifdef USE_LIGHT - virtual void on_light_update(light::LightState *obj){}; -#endif -#ifdef USE_SENSOR - virtual void on_sensor_update(sensor::Sensor *obj){}; -#endif -#ifdef USE_SWITCH - virtual void on_switch_update(switch_::Switch *obj){}; -#endif -#ifdef USE_COVER - virtual void on_cover_update(cover::Cover *obj){}; -#endif -#ifdef USE_TEXT_SENSOR - virtual void on_text_sensor_update(text_sensor::TextSensor *obj){}; -#endif -#ifdef USE_CLIMATE - virtual void on_climate_update(climate::Climate *obj){}; -#endif -#ifdef USE_NUMBER - virtual void on_number_update(number::Number *obj){}; -#endif -#ifdef USE_DATETIME_DATE - virtual void on_date_update(datetime::DateEntity *obj){}; -#endif -#ifdef USE_DATETIME_TIME - virtual void on_time_update(datetime::TimeEntity *obj){}; -#endif -#ifdef USE_DATETIME_DATETIME - virtual void on_datetime_update(datetime::DateTimeEntity *obj){}; -#endif -#ifdef USE_TEXT - virtual void on_text_update(text::Text *obj){}; -#endif -#ifdef USE_SELECT - virtual void on_select_update(select::Select *obj){}; -#endif -#ifdef USE_LOCK - virtual void on_lock_update(lock::Lock *obj){}; -#endif -#ifdef USE_VALVE - virtual void on_valve_update(valve::Valve *obj){}; -#endif -#ifdef USE_MEDIA_PLAYER - virtual void on_media_player_update(media_player::MediaPlayer *obj){}; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - virtual void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj){}; -#endif -#ifdef USE_WATER_HEATER - virtual void on_water_heater_update(water_heater::WaterHeater *obj){}; -#endif -#ifdef USE_EVENT - virtual void on_event(event::Event *obj){}; -#endif -#ifdef USE_UPDATE - virtual void on_update(update::UpdateEntity *obj){}; -#endif +// Controller virtual methods (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) virtual void on_##callback(type *obj){}; +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) }; } // namespace esphome diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp index 92f23f5642..907e0f923d 100644 --- a/esphome/core/controller_registry.cpp +++ b/esphome/core/controller_registry.cpp @@ -6,8 +6,6 @@ namespace esphome { StaticVector ControllerRegistry::controllers; -void ControllerRegistry::register_controller(Controller *controller) { controllers.push_back(controller); } - } // namespace esphome #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index 846642da29..c6113116ff 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -4,139 +4,13 @@ #ifdef USE_CONTROLLER_REGISTRY +#include "esphome/core/entity_includes.h" #include "esphome/core/helpers.h" -// Forward declarations namespace esphome { class Controller; -#ifdef USE_BINARY_SENSOR -namespace binary_sensor { -class BinarySensor; -} -#endif - -#ifdef USE_FAN -namespace fan { -class Fan; -} -#endif - -#ifdef USE_LIGHT -namespace light { -class LightState; -} -#endif - -#ifdef USE_SENSOR -namespace sensor { -class Sensor; -} -#endif - -#ifdef USE_SWITCH -namespace switch_ { -class Switch; -} -#endif - -#ifdef USE_COVER -namespace cover { -class Cover; -} -#endif - -#ifdef USE_TEXT_SENSOR -namespace text_sensor { -class TextSensor; -} -#endif - -#ifdef USE_CLIMATE -namespace climate { -class Climate; -} -#endif - -#ifdef USE_NUMBER -namespace number { -class Number; -} -#endif - -#ifdef USE_DATETIME_DATE -namespace datetime { -class DateEntity; -} -#endif - -#ifdef USE_DATETIME_TIME -namespace datetime { -class TimeEntity; -} -#endif - -#ifdef USE_DATETIME_DATETIME -namespace datetime { -class DateTimeEntity; -} -#endif - -#ifdef USE_TEXT -namespace text { -class Text; -} -#endif - -#ifdef USE_SELECT -namespace select { -class Select; -} -#endif - -#ifdef USE_LOCK -namespace lock { -class Lock; -} -#endif - -#ifdef USE_VALVE -namespace valve { -class Valve; -} -#endif - -#ifdef USE_MEDIA_PLAYER -namespace media_player { -class MediaPlayer; -} -#endif - -#ifdef USE_ALARM_CONTROL_PANEL -namespace alarm_control_panel { -class AlarmControlPanel; -} -#endif - -#ifdef USE_WATER_HEATER -namespace water_heater { -class WaterHeater; -} -#endif - -#ifdef USE_EVENT -namespace event { -class Event; -} -#endif - -#ifdef USE_UPDATE -namespace update { -class UpdateEntity; -} -#endif - /** Global registry for Controllers to receive entity state updates. * * This singleton registry allows Controllers (APIServer, WebServer) to receive @@ -160,91 +34,17 @@ class ControllerRegistry { * Controllers should call this in their setup() method. * Typically only APIServer and WebServer register. */ - static void register_controller(Controller *controller); + static void register_controller(Controller *controller) { controllers.push_back(controller); } -#ifdef USE_BINARY_SENSOR - static void notify_binary_sensor_update(binary_sensor::BinarySensor *obj); -#endif - -#ifdef USE_FAN - static void notify_fan_update(fan::Fan *obj); -#endif - -#ifdef USE_LIGHT - static void notify_light_update(light::LightState *obj); -#endif - -#ifdef USE_SENSOR - static void notify_sensor_update(sensor::Sensor *obj); -#endif - -#ifdef USE_SWITCH - static void notify_switch_update(switch_::Switch *obj); -#endif - -#ifdef USE_COVER - static void notify_cover_update(cover::Cover *obj); -#endif - -#ifdef USE_TEXT_SENSOR - static void notify_text_sensor_update(text_sensor::TextSensor *obj); -#endif - -#ifdef USE_CLIMATE - static void notify_climate_update(climate::Climate *obj); -#endif - -#ifdef USE_NUMBER - static void notify_number_update(number::Number *obj); -#endif - -#ifdef USE_DATETIME_DATE - static void notify_date_update(datetime::DateEntity *obj); -#endif - -#ifdef USE_DATETIME_TIME - static void notify_time_update(datetime::TimeEntity *obj); -#endif - -#ifdef USE_DATETIME_DATETIME - static void notify_datetime_update(datetime::DateTimeEntity *obj); -#endif - -#ifdef USE_TEXT - static void notify_text_update(text::Text *obj); -#endif - -#ifdef USE_SELECT - static void notify_select_update(select::Select *obj); -#endif - -#ifdef USE_LOCK - static void notify_lock_update(lock::Lock *obj); -#endif - -#ifdef USE_VALVE - static void notify_valve_update(valve::Valve *obj); -#endif - -#ifdef USE_MEDIA_PLAYER - static void notify_media_player_update(media_player::MediaPlayer *obj); -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - static void notify_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); -#endif - -#ifdef USE_WATER_HEATER - static void notify_water_heater_update(water_heater::WaterHeater *obj); -#endif - -#ifdef USE_EVENT - static void notify_event(event::Event *obj); -#endif - -#ifdef USE_UPDATE - static void notify_update(update::UpdateEntity *obj); -#endif +// Notify method declarations (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + static void notify_##callback(type *obj); +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) protected: static StaticVector controllers; @@ -265,108 +65,18 @@ namespace esphome { // notify_frontend_(), eliminating an unnecessary function-call frame. // NOLINTBEGIN(bugprone-macro-parentheses) -#define CONTROLLER_REGISTRY_NOTIFY(entity_type, entity_name) \ - inline void ControllerRegistry::notify_##entity_name##_update(entity_type *obj) { \ +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + inline void ControllerRegistry::notify_##callback(type *obj) { \ for (auto *controller : controllers) { \ - controller->on_##entity_name##_update(obj); \ - } \ - } - -#define CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(entity_type, entity_name) \ - inline void ControllerRegistry::notify_##entity_name(entity_type *obj) { \ - for (auto *controller : controllers) { \ - controller->on_##entity_name(obj); \ + controller->on_##callback(obj); \ } \ } +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) -#ifdef USE_BINARY_SENSOR -CONTROLLER_REGISTRY_NOTIFY(binary_sensor::BinarySensor, binary_sensor) -#endif - -#ifdef USE_FAN -CONTROLLER_REGISTRY_NOTIFY(fan::Fan, fan) -#endif - -#ifdef USE_LIGHT -CONTROLLER_REGISTRY_NOTIFY(light::LightState, light) -#endif - -#ifdef USE_SENSOR -CONTROLLER_REGISTRY_NOTIFY(sensor::Sensor, sensor) -#endif - -#ifdef USE_SWITCH -CONTROLLER_REGISTRY_NOTIFY(switch_::Switch, switch) -#endif - -#ifdef USE_COVER -CONTROLLER_REGISTRY_NOTIFY(cover::Cover, cover) -#endif - -#ifdef USE_TEXT_SENSOR -CONTROLLER_REGISTRY_NOTIFY(text_sensor::TextSensor, text_sensor) -#endif - -#ifdef USE_CLIMATE -CONTROLLER_REGISTRY_NOTIFY(climate::Climate, climate) -#endif - -#ifdef USE_NUMBER -CONTROLLER_REGISTRY_NOTIFY(number::Number, number) -#endif - -#ifdef USE_DATETIME_DATE -CONTROLLER_REGISTRY_NOTIFY(datetime::DateEntity, date) -#endif - -#ifdef USE_DATETIME_TIME -CONTROLLER_REGISTRY_NOTIFY(datetime::TimeEntity, time) -#endif - -#ifdef USE_DATETIME_DATETIME -CONTROLLER_REGISTRY_NOTIFY(datetime::DateTimeEntity, datetime) -#endif - -#ifdef USE_TEXT -CONTROLLER_REGISTRY_NOTIFY(text::Text, text) -#endif - -#ifdef USE_SELECT -CONTROLLER_REGISTRY_NOTIFY(select::Select, select) -#endif - -#ifdef USE_LOCK -CONTROLLER_REGISTRY_NOTIFY(lock::Lock, lock) -#endif - -#ifdef USE_VALVE -CONTROLLER_REGISTRY_NOTIFY(valve::Valve, valve) -#endif - -#ifdef USE_MEDIA_PLAYER -CONTROLLER_REGISTRY_NOTIFY(media_player::MediaPlayer, media_player) -#endif - -#ifdef USE_ALARM_CONTROL_PANEL -CONTROLLER_REGISTRY_NOTIFY(alarm_control_panel::AlarmControlPanel, alarm_control_panel) -#endif - -#ifdef USE_WATER_HEATER -CONTROLLER_REGISTRY_NOTIFY(water_heater::WaterHeater, water_heater) -#endif - -#ifdef USE_EVENT -CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(event::Event, event) -#endif - -#ifdef USE_UPDATE -CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(update::UpdateEntity, update) -#endif - -#undef CONTROLLER_REGISTRY_NOTIFY -#undef CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX - } // namespace esphome #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/entity_includes.h b/esphome/core/entity_includes.h new file mode 100644 index 0000000000..f67887b30b --- /dev/null +++ b/esphome/core/entity_includes.h @@ -0,0 +1,79 @@ +#pragma once + +// Shared entity component includes. +// Conditionally includes headers for all entity types based on USE_* defines. + +#include "esphome/core/defines.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_COVER +#include "esphome/components/cover/cover.h" +#endif +#ifdef USE_FAN +#include "esphome/components/fan/fan.h" +#endif +#ifdef USE_LIGHT +#include "esphome/components/light/light_state.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif +#ifdef USE_BUTTON +#include "esphome/components/button/button.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_CLIMATE +#include "esphome/components/climate/climate.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_DATETIME_DATE +#include "esphome/components/datetime/date_entity.h" +#endif +#ifdef USE_DATETIME_TIME +#include "esphome/components/datetime/time_entity.h" +#endif +#ifdef USE_DATETIME_DATETIME +#include "esphome/components/datetime/datetime_entity.h" +#endif +#ifdef USE_TEXT +#include "esphome/components/text/text.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#ifdef USE_LOCK +#include "esphome/components/lock/lock.h" +#endif +#ifdef USE_VALVE +#include "esphome/components/valve/valve.h" +#endif +#ifdef USE_MEDIA_PLAYER +#include "esphome/components/media_player/media_player.h" +#endif +#ifdef USE_ALARM_CONTROL_PANEL +#include "esphome/components/alarm_control_panel/alarm_control_panel.h" +#endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif +#ifdef USE_INFRARED +#include "esphome/components/infrared/infrared.h" +#endif +#ifdef USE_SERIAL_PROXY +#include "esphome/components/serial_proxy/serial_proxy.h" +#endif +#ifdef USE_EVENT +#include "esphome/components/event/event.h" +#endif +#ifdef USE_UPDATE +#include "esphome/components/update/update_entity.h" +#endif diff --git a/esphome/core/entity_types.h b/esphome/core/entity_types.h new file mode 100644 index 0000000000..04b490e10e --- /dev/null +++ b/esphome/core/entity_types.h @@ -0,0 +1,98 @@ +// X-macro include file for entity type declarations. +// This file is included multiple times with different macro definitions. +// +// Both macros must be defined before including this file: +// +// ENTITY_TYPE_(type, singular, plural, count, upper) +// — entities without controller callbacks (button, infrared) +// +// ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) +// — entities with controller callbacks +// +// Excluded from this list (handled manually): +// - devices/areas: not entities +// - serial_proxy: custom register logic, no by-key lookup + +#ifndef ENTITY_TYPE_ +#error "ENTITY_TYPE_(type, singular, plural, count, upper) must be defined before including entity_types.h" +#endif +#ifndef ENTITY_CONTROLLER_TYPE_ +#error \ + "ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) must be defined before including entity_types.h" +#endif + +#ifdef USE_BINARY_SENSOR +ENTITY_CONTROLLER_TYPE_(binary_sensor::BinarySensor, binary_sensor, binary_sensors, ESPHOME_ENTITY_BINARY_SENSOR_COUNT, + BINARY_SENSOR, binary_sensor_update) +#endif +#ifdef USE_COVER +ENTITY_CONTROLLER_TYPE_(cover::Cover, cover, covers, ESPHOME_ENTITY_COVER_COUNT, COVER, cover_update) +#endif +#ifdef USE_FAN +ENTITY_CONTROLLER_TYPE_(fan::Fan, fan, fans, ESPHOME_ENTITY_FAN_COUNT, FAN, fan_update) +#endif +#ifdef USE_LIGHT +ENTITY_CONTROLLER_TYPE_(light::LightState, light, lights, ESPHOME_ENTITY_LIGHT_COUNT, LIGHT, light_update) +#endif +#ifdef USE_SENSOR +ENTITY_CONTROLLER_TYPE_(sensor::Sensor, sensor, sensors, ESPHOME_ENTITY_SENSOR_COUNT, SENSOR, sensor_update) +#endif +#ifdef USE_SWITCH +ENTITY_CONTROLLER_TYPE_(switch_::Switch, switch, switches, ESPHOME_ENTITY_SWITCH_COUNT, SWITCH, switch_update) +#endif +#ifdef USE_BUTTON +ENTITY_TYPE_(button::Button, button, buttons, ESPHOME_ENTITY_BUTTON_COUNT, BUTTON) +#endif +#ifdef USE_TEXT_SENSOR +ENTITY_CONTROLLER_TYPE_(text_sensor::TextSensor, text_sensor, text_sensors, ESPHOME_ENTITY_TEXT_SENSOR_COUNT, + TEXT_SENSOR, text_sensor_update) +#endif +#ifdef USE_CLIMATE +ENTITY_CONTROLLER_TYPE_(climate::Climate, climate, climates, ESPHOME_ENTITY_CLIMATE_COUNT, CLIMATE, climate_update) +#endif +#ifdef USE_NUMBER +ENTITY_CONTROLLER_TYPE_(number::Number, number, numbers, ESPHOME_ENTITY_NUMBER_COUNT, NUMBER, number_update) +#endif +#ifdef USE_DATETIME_DATE +ENTITY_CONTROLLER_TYPE_(datetime::DateEntity, date, dates, ESPHOME_ENTITY_DATE_COUNT, DATETIME_DATE, date_update) +#endif +#ifdef USE_DATETIME_TIME +ENTITY_CONTROLLER_TYPE_(datetime::TimeEntity, time, times, ESPHOME_ENTITY_TIME_COUNT, DATETIME_TIME, time_update) +#endif +#ifdef USE_DATETIME_DATETIME +ENTITY_CONTROLLER_TYPE_(datetime::DateTimeEntity, datetime, datetimes, ESPHOME_ENTITY_DATETIME_COUNT, DATETIME_DATETIME, + datetime_update) +#endif +#ifdef USE_TEXT +ENTITY_CONTROLLER_TYPE_(text::Text, text, texts, ESPHOME_ENTITY_TEXT_COUNT, TEXT, text_update) +#endif +#ifdef USE_SELECT +ENTITY_CONTROLLER_TYPE_(select::Select, select, selects, ESPHOME_ENTITY_SELECT_COUNT, SELECT, select_update) +#endif +#ifdef USE_LOCK +ENTITY_CONTROLLER_TYPE_(lock::Lock, lock, locks, ESPHOME_ENTITY_LOCK_COUNT, LOCK, lock_update) +#endif +#ifdef USE_VALVE +ENTITY_CONTROLLER_TYPE_(valve::Valve, valve, valves, ESPHOME_ENTITY_VALVE_COUNT, VALVE, valve_update) +#endif +#ifdef USE_MEDIA_PLAYER +ENTITY_CONTROLLER_TYPE_(media_player::MediaPlayer, media_player, media_players, ESPHOME_ENTITY_MEDIA_PLAYER_COUNT, + MEDIA_PLAYER, media_player_update) +#endif +#ifdef USE_ALARM_CONTROL_PANEL +ENTITY_CONTROLLER_TYPE_(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels, + ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT, ALARM_CONTROL_PANEL, alarm_control_panel_update) +#endif +#ifdef USE_WATER_HEATER +ENTITY_CONTROLLER_TYPE_(water_heater::WaterHeater, water_heater, water_heaters, ESPHOME_ENTITY_WATER_HEATER_COUNT, + WATER_HEATER, water_heater_update) +#endif +#ifdef USE_INFRARED +ENTITY_TYPE_(infrared::Infrared, infrared, infrareds, ESPHOME_ENTITY_INFRARED_COUNT, INFRARED) +#endif +#ifdef USE_EVENT +ENTITY_CONTROLLER_TYPE_(event::Event, event, events, ESPHOME_ENTITY_EVENT_COUNT, EVENT, event) +#endif +#ifdef USE_UPDATE +ENTITY_CONTROLLER_TYPE_(update::UpdateEntity, update, updates, ESPHOME_ENTITY_UPDATE_COUNT, UPDATE, update) +#endif diff --git a/esphome/writer.py b/esphome/writer.py index 06a2230118..787ecac6f6 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -171,6 +171,7 @@ VERSION_H_FORMAT = """\ DEFINES_H_TARGET = "esphome/core/defines.h" VERSION_H_TARGET = "esphome/core/version.h" BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" +ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h" ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -196,9 +197,12 @@ def copy_src_tree(): source_files_l.sort() # Build #include list for esphome.h + # X-macro files are included multiple times with different macro definitions + # and must not be included bare in esphome.h + esphome_h_exclude = {Path(ENTITY_TYPES_H_TARGET)} include_l = [] for target, _ in source_files_l: - if target.suffix in HEADER_FILE_EXTENSIONS: + if target.suffix in HEADER_FILE_EXTENSIONS and target not in esphome_h_exclude: include_l.append(f'#include "{target}"') include_l.append("") include_s = "\n".join(include_l) diff --git a/script/ci-custom.py b/script/ci-custom.py index 1ec3eab3a9..6dce86924e 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -672,7 +672,7 @@ def lint_using_esp_idf_deprecated(fname, line, col, content): ) -@lint_content_check(include=["*.h"]) +@lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"]) def lint_pragma_once(fname, content): if "#pragma once" not in content: return ( diff --git a/script/helpers.py b/script/helpers.py index c9c550d889..9f5ea7894c 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -174,7 +174,12 @@ def build_all_include(header_files: list[str] | None = None) -> None: if line ] - headers = [f'#include "{h}"' for h in header_files] + from esphome.writer import ENTITY_TYPES_H_TARGET + + # X-macro files are included multiple times with different macro definitions + # and must not be included bare in the all-include header + exclude = {ENTITY_TYPES_H_TARGET} + headers = [f'#include "{h}"' for h in header_files if h not in exclude] headers.sort() headers.append("") content = "\n".join(headers) From e0118dd8ebb8ed9dae2002dd1243ef539f5c82aa Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:19:42 +1000 Subject: [PATCH 0246/2214] [lvgl] Clean the build if lv_conf.h changes (#15777) --- esphome/components/lvgl/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b6421dc43d..ac0363ca69 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -44,6 +44,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.writer import clean_build from esphome.yaml_util import load_yaml from . import defines as df, helpers, lv_validation as lvalid, widgets @@ -451,7 +452,8 @@ async def to_code(configs): df.add_define(f"LV_DRAW_SW_SUPPORT_{fmt}", "1") lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME) - write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()) + if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()): + clean_build(clear_pio_cache=False) cg.add_build_flag("-DLV_CONF_H=1") # handle windows paths in a way that doesn't break the generated C++ lv_conf_h_path = Path(lv_conf_h_file).as_posix() From b40ffacb8db789afaab8cc5be537c2f51638a06a Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Thu, 16 Apr 2026 15:35:24 +0200 Subject: [PATCH 0247/2214] [mitsubishi_cn105] use HEAT_COOL mode to enable temperature slider (#15748) --- .../mitsubishi_cn105_climate.cpp | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 40ddb88a79..284339e57f 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -7,7 +7,7 @@ namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.climate"; static constexpr std::array MODE_MAP{ - std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_AUTO}, + std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_HEAT_COOL}, std::pair{MitsubishiCN105::Mode::HEAT, climate::CLIMATE_MODE_HEAT}, std::pair{MitsubishiCN105::Mode::DRY, climate::CLIMATE_MODE_DRY}, std::pair{MitsubishiCN105::Mode::COOL, climate::CLIMATE_MODE_COOL}, @@ -76,23 +76,13 @@ void MitsubishiCN105Climate::loop() { climate::ClimateTraits MitsubishiCN105Climate::traits() { climate::ClimateTraits traits; - traits.set_supported_modes({ - climate::CLIMATE_MODE_OFF, - climate::CLIMATE_MODE_COOL, - climate::CLIMATE_MODE_HEAT, - climate::CLIMATE_MODE_DRY, - climate::CLIMATE_MODE_FAN_ONLY, - climate::CLIMATE_MODE_AUTO, - }); + for (const auto &p : MODE_MAP) { + traits.add_supported_mode(p.second); + } - traits.set_supported_fan_modes({ - climate::CLIMATE_FAN_AUTO, - climate::CLIMATE_FAN_QUIET, - climate::CLIMATE_FAN_LOW, - climate::CLIMATE_FAN_MEDIUM, - climate::CLIMATE_FAN_MIDDLE, - climate::CLIMATE_FAN_HIGH, - }); + for (const auto &p : FAN_MODE_MAP) { + traits.add_supported_fan_mode(p.second); + } traits.set_visual_min_temperature(16.0f); traits.set_visual_max_temperature(31.0f); From c8e21802db45fd78c1909adbb4850e73881c8839 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 03:36:55 -1000 Subject: [PATCH 0248/2214] [core] Diagnose missing cg.templatable in codegen for TEMPLATABLE_VALUE fields (#15758) --- esphome/core/automation.h | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index eb270bfee2..468ea3b382 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -62,6 +62,18 @@ template class TemplatableFn { !std::convertible_to, T> || !std::default_initializable) = delete; + // Reject raw (non-callable) values with a helpful diagnostic pointing at the Python-side fix. + // TemplatableFn stores only a function pointer (4 bytes), so constants must be wrapped in a + // stateless lambda by codegen. External components hitting this error should use + // `cg.templatable(value, args, type)` in their Python __init__.py before passing to the setter. + template TemplatableFn(V) requires(!std::invocable) && (!std::convertible_to) { + static_assert(sizeof(V) == 0, "Missing cg.templatable(...) in Python codegen for this TEMPLATABLE_VALUE " + "field. The wrapper was always required; it worked by accident because the old " + "TemplatableValue implicitly converted raw constants. TemplatableFn cannot. See " + "https://developers.esphome.io/blog/2026/04/09/" + "templatablefn-4-byte-templatable-storage-for-trivially-copyable-types/"); + } + bool has_value() const { return this->f_ != nullptr; } T value(X... x) const { return this->f_ ? this->f_(x...) : T{}; } From 4c758fa1da1b1353b98c85a0394a46ee510f3541 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 03:40:22 -1000 Subject: [PATCH 0249/2214] [time] Fix RTC is_valid() rejecting valid times after day_of_year cleanup (#15763) --- esphome/components/bm8563/bm8563.cpp | 2 +- esphome/components/ds1307/ds1307.cpp | 2 +- esphome/components/pcf85063/pcf85063.cpp | 2 +- esphome/components/pcf8563/pcf8563.cpp | 2 +- esphome/components/rx8130/rx8130.cpp | 2 +- esphome/core/time.h | 8 ++- tests/components/time/is_valid.cpp | 72 ++++++++++++++++++++++++ 7 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 tests/components/time/is_valid.cpp diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index 062094c036..d911301c9d 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -63,7 +63,7 @@ void BM8563::read_time() { rtc_time.day_of_week, rtc_time.hour, rtc_time.minute, rtc_time.second); rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index 8fff4213b4..ba2ad6032f 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -44,7 +44,7 @@ void DS1307Component::read_time() { .year = uint16_t(ds1307_.reg.year + 10u * ds1307_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 1cf28a4955..000de1433c 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -44,7 +44,7 @@ void PCF85063Component::read_time() { .year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index b748f0156a..50003ca378 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -44,7 +44,7 @@ void PCF8563Component::read_time() { .year = uint16_t(pcf8563_.reg.year + 10u * pcf8563_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 3b704d2551..0aa6e86d31 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -81,7 +81,7 @@ void RX8130Component::read_time() { .year = static_cast(bcd2dec(date[6]) + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/core/time.h b/esphome/core/time.h index ed47432038..0b67b7b3fc 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -76,8 +76,12 @@ struct ESPTime { /// @copydoc strftime(const std::string &format) std::string strftime(const char *format); - /// Check if this ESPTime is valid (all fields in range and year is greater than or equal to 2019) - bool is_valid() const { return this->year >= 2019 && this->fields_in_range(); } + /// Check if this ESPTime is valid (year >= 2019 and the requested fields are in range). + /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) + /// @param check_day_of_year validate day_of_year (not always available when constructing from date/time fields) + bool is_valid(bool check_day_of_week = true, bool check_day_of_year = true) const { + return this->year >= 2019 && this->fields_in_range(check_day_of_week, check_day_of_year); + } /// Check if time fields are in range. /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) diff --git a/tests/components/time/is_valid.cpp b/tests/components/time/is_valid.cpp new file mode 100644 index 0000000000..9148c0e8d6 --- /dev/null +++ b/tests/components/time/is_valid.cpp @@ -0,0 +1,72 @@ +// Regression tests for ESPTime::is_valid() optional checks. +// +// The RTC components (ds1307, bm8563, pcf85063, pcf8563, rx8130) read date/time +// fields from hardware but do not populate day_of_year. They call +// recalc_timestamp_utc(false) -- which skips day_of_year -- and then is_valid(). +// These tests ensure the is_valid() overload can skip day_of_year validation so +// RTCs don't log "Invalid RTC time, not syncing to system clock." for valid times. + +#include +#include "esphome/core/time.h" + +namespace esphome::testing { + +// Build an ESPTime that mirrors what the RTC components construct: all fields +// populated from hardware except day_of_year (left zero-initialized). +static ESPTime make_rtc_like_time() { + ESPTime t{}; + t.second = 30; + t.minute = 15; + t.hour = 12; + t.day_of_week = 4; // thursday + t.day_of_month = 15; + t.month = 4; + t.year = 2026; + // day_of_year intentionally left at 0 -- RTCs don't compute it. + return t; +} + +TEST(ESPTimeIsValid, DefaultRejectsZeroDayOfYear) { + // Default is_valid() checks day_of_year; zero-init is out of range. + ESPTime t = make_rtc_like_time(); + EXPECT_FALSE(t.is_valid()); +} + +TEST(ESPTimeIsValid, SkipDayOfYearAcceptsRTCLikeTime) { + // RTC code path: skip day_of_year validation. + ESPTime t = make_rtc_like_time(); + EXPECT_TRUE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipDayOfYearStillRejectsOutOfRangeFields) { + ESPTime t = make_rtc_like_time(); + t.hour = 25; + EXPECT_FALSE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipDayOfYearStillRejectsYearBefore2019) { + ESPTime t = make_rtc_like_time(); + t.year = 2000; + EXPECT_FALSE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipBothDayChecksAcceptsGPSLikeTime) { + // GPS path (gps_time.cpp) populates neither day_of_week nor day_of_year. + ESPTime t{}; + t.second = 30; + t.minute = 15; + t.hour = 12; + t.day_of_month = 15; + t.month = 4; + t.year = 2026; + EXPECT_TRUE(t.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false)); + EXPECT_FALSE(t.is_valid()); // default still rejects +} + +TEST(ESPTimeIsValid, FullyPopulatedAcceptsWithDefaults) { + ESPTime t = make_rtc_like_time(); + t.day_of_year = 105; + EXPECT_TRUE(t.is_valid()); +} + +} // namespace esphome::testing From 04a58159d0fa8462d4150cba30e086f9119869d6 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 16 Apr 2026 15:43:03 +0200 Subject: [PATCH 0250/2214] [zephyr_ble_server] add support for on_numeric_comparison_request (#14400) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/zephyr_ble_server/__init__.py | 61 +++- .../zephyr_ble_server/ble_server.cpp | 295 +++++++++++++++--- .../components/zephyr_ble_server/ble_server.h | 26 +- .../test.nrf52-xiao-ble.yaml | 10 + 4 files changed, 341 insertions(+), 51 deletions(-) create mode 100644 tests/components/zephyr_ble_server/test.nrf52-xiao-ble.yaml diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 211941e984..658137d1a2 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -1,28 +1,35 @@ +from esphome import automation 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_ESPHOME, CONF_ID, CONF_NAME, Framework -import esphome.final_validate as fv +from esphome.const import CONF_ID, Framework +from esphome.core import CORE zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) +CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request" +CONF_ACCEPT = "accept" + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(BLEServer), + cv.Optional( + CONF_ON_NUMERIC_COMPARISON_REQUEST + ): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), cv.only_with_framework(Framework.ZEPHYR), ) - -def _final_validate(_): - full_config = fv.full_config.get() - zephyr_add_prj_conf("BT_DEVICE_NAME", full_config[CONF_ESPHOME][CONF_NAME]) - - -FINAL_VALIDATE_SCHEMA = _final_validate +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_NUMERIC_COMPARISON_REQUEST, + "add_passkey_callback", + [(cg.uint32, "passkey")], + ), +) async def to_code(config): @@ -30,5 +37,39 @@ async def to_code(config): zephyr_add_prj_conf("BT", True) zephyr_add_prj_conf("BT_PERIPHERAL", True) zephyr_add_prj_conf("BT_RX_STACK_SIZE", 1536) - # zephyr_add_prj_conf("BT_LL_SW_SPLIT", True) + zephyr_add_prj_conf("BT_DEVICE_NAME", CORE.name) await cg.register_component(var, config) + if config.get(CONF_ON_NUMERIC_COMPARISON_REQUEST): + zephyr_add_prj_conf("BT_SMP", True) + zephyr_add_prj_conf("BT_SETTINGS", True) + zephyr_add_prj_conf("BT_SMP_SC_ONLY", True) + zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +BLENumericComparisonReplyAction = zephyr_ble_server_ns.class_( + "BLENumericComparisonReplyAction", automation.Action +) + +BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(BLEServer), + cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean), + } +) + + +@automation.register_action( + "ble_server.numeric_comparison_reply", + BLENumericComparisonReplyAction, + BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, + synchronous=True, +) +async def numeric_comparison_reply_to_code(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + + templ = await cg.templatable(config[CONF_ACCEPT], args, cg.bool_) + cg.add(var.set_accept(templ)) + + return var diff --git a/esphome/components/zephyr_ble_server/ble_server.cpp b/esphome/components/zephyr_ble_server/ble_server.cpp index 9f7e606a90..15993abcce 100644 --- a/esphome/components/zephyr_ble_server/ble_server.cpp +++ b/esphome/components/zephyr_ble_server/ble_server.cpp @@ -3,32 +3,34 @@ #include "esphome/core/defines.h" #include "esphome/core/log.h" #include -#include +#include namespace esphome::zephyr_ble_server { static const char *const TAG = "zephyr_ble_server"; -static struct k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +BLEServer *global_ble_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #define DEVICE_NAME CONFIG_BT_DEVICE_NAME #define DEVICE_NAME_LEN (sizeof(DEVICE_NAME) - 1) -static const struct bt_data AD[] = { +static const bt_data AD[] = { BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, DEVICE_NAME_LEN), }; -static const struct bt_data SD[] = { +static const bt_data SD[] = { #ifdef USE_OTA BT_DATA_BYTES(BT_DATA_UUID128_ALL, 0x84, 0xaa, 0x60, 0x74, 0x52, 0x8a, 0x8b, 0x86, 0xd3, 0x4c, 0xb7, 0x1d, 0x1d, 0xdc, 0x53, 0x8d), #endif }; -const struct bt_le_adv_param *const ADV_PARAM = BT_LE_ADV_CONN; +const bt_le_adv_param *const ADV_PARAM = BT_LE_ADV_CONN; -static void advertise(struct k_work *work) { +static void advertise(k_work *work) { int rc = bt_le_adv_stop(); if (rc) { ESP_LOGE(TAG, "Advertising failed to stop (rc %d)", rc); @@ -42,57 +44,276 @@ static void advertise(struct k_work *work) { ESP_LOGI(TAG, "Advertising successfully started"); } -static void connected(struct bt_conn *conn, uint8_t err) { +void BLEServer::connected(bt_conn *conn, uint8_t err) { + char addr[BT_ADDR_LE_STR_LEN]; + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); if (err) { - ESP_LOGE(TAG, "Connection failed (err 0x%02x)", err); - } else { - ESP_LOGI(TAG, "Connected"); + ESP_LOGE(TAG, "Failed to connect to %s (%u)", addr, err); + return; } + ESP_LOGI(TAG, "Connected %s", addr); +#ifdef CONFIG_BT_SMP + if (bt_conn_set_security(conn, BT_SECURITY_L4)) { + ESP_LOGE(TAG, "Failed to set security"); + } +#endif + conn = bt_conn_ref(conn); + global_ble_server->defer([conn]() { global_ble_server->conn_ = conn; }); } -static void disconnected(struct bt_conn *conn, uint8_t reason) { - ESP_LOGI(TAG, "Disconnected (reason 0x%02x)", reason); +void BLEServer::disconnected(bt_conn *conn, uint8_t reason) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGI(TAG, "Disconnected from %s (reason 0x%02x)", addr, reason); + global_ble_server->defer([]() { + if (global_ble_server->conn_) { + bt_conn_unref(global_ble_server->conn_); + global_ble_server->conn_ = nullptr; + } + }); k_work_submit(&advertise_work); } -static void bt_ready(int err) { - if (err != 0) { - ESP_LOGE(TAG, "Bluetooth failed to initialise: %d", err); +#ifdef CONFIG_BT_SMP +static void identity_resolved(bt_conn *conn, const bt_addr_le_t *rpa, const bt_addr_le_t *identity) { + char addr_identity[BT_ADDR_LE_STR_LEN]; + char addr_rpa[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(identity, addr_identity, sizeof(addr_identity)); + bt_addr_le_to_str(rpa, addr_rpa, sizeof(addr_rpa)); + + ESP_LOGD(TAG, "Identity resolved %s -> %s", addr_rpa, addr_identity); +} + +static void security_changed(bt_conn *conn, bt_security_t level, bt_security_err err) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + if (!err) { + ESP_LOGD(TAG, "Security changed: %s level %u", addr, level); } else { - k_work_submit(&advertise_work); + ESP_LOGE(TAG, "Security failed: %s level %u err %d", addr, level, err); } } -BT_CONN_CB_DEFINE(conn_callbacks) = { - .connected = connected, - .disconnected = disconnected, -}; +static void pairing_complete(bt_conn *conn, bool bonded) { + char addr[BT_ADDR_LE_STR_LEN]; -void BLEServer::setup() { - k_work_init(&advertise_work, advertise); - resume_(); + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGD(TAG, "Pairing completed: %s, bonded: %d", addr, bonded); } -void BLEServer::loop() { - if (this->suspended_) { - resume_(); - this->suspended_ = false; - } +static void pairing_failed(bt_conn *conn, bt_security_err reason) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGE(TAG, "Pairing failed conn: %s, reason %d", addr, reason); + + bt_conn_disconnect(conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); } -void BLEServer::resume_() { - int rc = bt_enable(bt_ready); - if (rc != 0) { - ESP_LOGE(TAG, "Bluetooth enable failed: %d", rc); +static void bond_deleted(uint8_t id, const bt_addr_le_t *peer) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(peer, addr, sizeof(addr)); + ESP_LOGD(TAG, "Bond deleted for %s, id %u", addr, id); +} + +static void auth_passkey_display(bt_conn *conn, unsigned int passkey) { + char addr[BT_ADDR_LE_STR_LEN]; + char passkey_str[7]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + snprintk(passkey_str, 7, "%06u", passkey); + + ESP_LOGI(TAG, "Passkey for %s: %s", addr, passkey_str); +} + +static void conn_addr_str(bt_conn *conn, char *addr, size_t len) { + struct bt_conn_info info; + + if (bt_conn_get_info(conn, &info) < 0) { + addr[0] = '\0'; return; } + + switch (info.type) { + case BT_CONN_TYPE_LE: + bt_addr_le_to_str(info.le.dst, addr, len); + break; + default: + ESP_LOGE(TAG, "Not implemented"); + addr[0] = '\0'; + break; + } } -void BLEServer::on_shutdown() { - struct k_work_sync sync; - k_work_cancel_sync(&advertise_work, &sync); - bt_disable(); - this->suspended_ = true; +static void auth_cancel(bt_conn *conn) { + char addr[BT_ADDR_LE_STR_LEN]; + + conn_addr_str(conn, addr, sizeof(addr)); + + ESP_LOGI(TAG, "Pairing cancelled: %s", addr); +} + +void BLEServer::auth_passkey_confirm(bt_conn *conn, unsigned int passkey) { + char addr[BT_ADDR_LE_STR_LEN]; + char passkey_str[7]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + snprintk(passkey_str, 7, "%06u", passkey); + + ESP_LOGI(TAG, "Confirm passkey for %s: %s", addr, passkey_str); + global_ble_server->defer([passkey]() { global_ble_server->passkey_cb_(passkey); }); +} + +static void auth_pairing_confirm(bt_conn *conn) { + /* Automatically confirm pairing request from the device side. */ + auto err = bt_conn_auth_pairing_confirm(conn); + if (err) { + ESP_LOGE(TAG, "Can't confirm pairing (err: %d)", err); + return; + } + + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGI(TAG, "Pairing confirmed: %s", addr); +} + +#endif + +void BLEServer::setup() { + global_ble_server = this; + int err = 0; + k_work_init(&advertise_work, advertise); + + static bt_conn_cb conn_callbacks = { + .connected = connected, + .disconnected = disconnected, +#ifdef CONFIG_BT_SMP + .identity_resolved = identity_resolved, + .security_changed = security_changed, +#endif + }; + + bt_conn_cb_register(&conn_callbacks); +#ifdef CONFIG_BT_SMP + static struct bt_conn_auth_info_cb conn_auth_info_callbacks = { + .pairing_complete = pairing_complete, .pairing_failed = pairing_failed, .bond_deleted = bond_deleted}; + err = bt_conn_auth_info_cb_register(&conn_auth_info_callbacks); + if (err) { + ESP_LOGE(TAG, "Failed to register authorization info callbacks."); + } + static struct bt_conn_auth_cb auth_cb = { + .passkey_display = auth_passkey_display, + .passkey_confirm = auth_passkey_confirm, + .cancel = auth_cancel, + .pairing_confirm = auth_pairing_confirm, + }; + err = bt_conn_auth_cb_register(&auth_cb); + if (err) { + ESP_LOGE(TAG, "Failed to set auth handlers (%d)", err); + } +#endif + // callback cannot be used to start scanning due to race conditions with BT_SETTINGS + err = bt_enable(nullptr); + if (err) { + ESP_LOGE(TAG, "Bluetooth enable failed: %d", err); + return; + } +#ifdef CONFIG_BT_SETTINGS + err = settings_load(); + if (err) { + ESP_LOGE(TAG, "Cannot load settings, err: %d", err); + } +#endif + k_work_submit(&advertise_work); +} + +#ifdef ESPHOME_LOG_HAS_DEBUG +static const char *role_str(uint8_t role) { + switch (role) { + case BT_CONN_ROLE_CENTRAL: + return "Central"; + case BT_CONN_ROLE_PERIPHERAL: + return "Peripheral"; + } + + return "Unknown"; +} + +static void connection_info(bt_conn *conn, void *user_data) { + char addr[BT_ADDR_LE_STR_LEN]; + struct bt_conn_info info; + + if (bt_conn_get_info(conn, &info) < 0) { + ESP_LOGE(TAG, "Unable to get info: conn %p", conn); + return; + } + + switch (info.type) { + case BT_CONN_TYPE_LE: + bt_addr_le_to_str(info.le.dst, addr, sizeof(addr)); + ESP_LOGD(TAG, " %u [LE][%s] %s: Interval %u latency %u timeout %u security L%u", info.id, role_str(info.role), + addr, info.le.interval, info.le.latency, info.le.timeout, info.security.level); + break; + default: + ESP_LOGE(TAG, "Not implemented"); + break; + } +} +#ifdef CONFIG_BT_BONDABLE +static void bond_info(const struct bt_bond_info *info, void *user_data) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(&info->addr, addr, sizeof(addr)); + ESP_LOGD(TAG, " Bond remote identity: %s", addr); +} +#endif +#endif + +void BLEServer::dump_config() { + ESP_LOGCONFIG(TAG, + "ble server:\n" + " connected: %s\n" + " name: %s\n" + " appearance: %u\n" + " ready: %s\n" +#ifdef CONFIG_BT_SMP + " security manager: YES", +#else + " security manager: NO", +#endif + YESNO(this->conn_), bt_get_name(), bt_get_appearance(), YESNO(bt_is_ready())); + +#ifdef ESPHOME_LOG_HAS_DEBUG + bt_conn_foreach(BT_CONN_TYPE_ALL, connection_info, nullptr); +#ifdef CONFIG_BT_BONDABLE + bt_foreach_bond(BT_ID_DEFAULT, bond_info, nullptr); +#endif +#endif +} + +void BLEServer::numeric_comparison_reply(bool accept) { + if (this->conn_ == nullptr) { + ESP_LOGE(TAG, "Not connected"); + return; + } + ESP_LOGD(TAG, "Numeric comparison %s", accept ? "accepted" : "rejected"); + if (accept) { + bt_conn_auth_passkey_confirm(this->conn_); + } else { + bt_conn_auth_cancel(this->conn_); + } } } // namespace esphome::zephyr_ble_server diff --git a/esphome/components/zephyr_ble_server/ble_server.h b/esphome/components/zephyr_ble_server/ble_server.h index 1b32e9b58c..bf69c52b12 100644 --- a/esphome/components/zephyr_ble_server/ble_server.h +++ b/esphome/components/zephyr_ble_server/ble_server.h @@ -1,18 +1,36 @@ #pragma once #ifdef USE_ZEPHYR #include "esphome/core/component.h" +#include +#include "esphome/core/automation.h" namespace esphome::zephyr_ble_server { class BLEServer : public Component { public: void setup() override; - void loop() override; - void on_shutdown() override; + void dump_config() override; + template void add_passkey_callback(F &&callback) { this->passkey_cb_.add(std::forward(callback)); } + void numeric_comparison_reply(bool accept); protected: - void resume_(); - bool suspended_ = false; + static void connected(bt_conn *conn, uint8_t err); + static void disconnected(bt_conn *conn, uint8_t reason); + static void auth_passkey_confirm(bt_conn *conn, unsigned int passkey); + bt_conn *conn_{}; + CallbackManager passkey_cb_; +}; + +template class BLENumericComparisonReplyAction : public Action { + public: + explicit BLENumericComparisonReplyAction(BLEServer *parent) : parent_(parent) {} + + TEMPLATABLE_VALUE(bool, accept) + + void play(const Ts &...x) override { this->parent_->numeric_comparison_reply(this->accept_.value(x...)); } + + protected: + BLEServer *parent_; }; } // namespace esphome::zephyr_ble_server diff --git a/tests/components/zephyr_ble_server/test.nrf52-xiao-ble.yaml b/tests/components/zephyr_ble_server/test.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..2b440102db --- /dev/null +++ b/tests/components/zephyr_ble_server/test.nrf52-xiao-ble.yaml @@ -0,0 +1,10 @@ +zephyr_ble_server: + on_numeric_comparison_request: + then: + - logger.log: + format: "Compare this passkey with the one on your BLE device: %06d" + args: [passkey] + - ble_server.numeric_comparison_reply: + accept: True + - ble_server.numeric_comparison_reply: + accept: !lambda "return true;" From ee70a4aa72d6f7aaffca86960b74a1a88796606a Mon Sep 17 00:00:00 2001 From: guillempages Date: Thu, 16 Apr 2026 15:46:27 +0200 Subject: [PATCH 0251/2214] [tm1637] Add set_brightness method (#15322) Co-authored-by: J. Nick Koston --- esphome/components/tm1637/tm1637.cpp | 7 +++++++ esphome/components/tm1637/tm1637.h | 3 +++ 2 files changed, 10 insertions(+) diff --git a/esphome/components/tm1637/tm1637.cpp b/esphome/components/tm1637/tm1637.cpp index da9adb59a4..4814d5b1c4 100644 --- a/esphome/components/tm1637/tm1637.cpp +++ b/esphome/components/tm1637/tm1637.cpp @@ -347,6 +347,13 @@ uint8_t TM1637Display::print(uint8_t start_pos, const char *str) { } return pos - start_pos; } + +void TM1637Display::set_brightness(float brightness) { + auto intensity = clamp(brightness, 0.f, 1.f) * 7; + this->set_on(intensity > 0); + this->set_intensity(intensity); +} + uint8_t TM1637Display::print(const char *str) { return this->print(0, str); } void TM1637Display::set_buffer(const uint8_t *data, uint8_t length) { diff --git a/esphome/components/tm1637/tm1637.h b/esphome/components/tm1637/tm1637.h index c1fbabb21b..1738d37107 100644 --- a/esphome/components/tm1637/tm1637.h +++ b/esphome/components/tm1637/tm1637.h @@ -50,6 +50,9 @@ class TM1637Display : public PollingComponent { /// Set raw buffer bytes from data array up to length bytes. void set_buffer(const uint8_t *data, uint8_t length); + /// Set the display brightness. Accepts a value between 0.0 and 1.0; 0 will turn off + /// the display and 1.0 will set it to the maximum brightness. + void set_brightness(float brightness); void set_intensity(uint8_t intensity) { this->intensity_ = intensity; } void set_inverted(bool inverted) { this->inverted_ = inverted; } void set_length(uint8_t length) { this->length_ = length; } From d8329dba228733282176ab053d49b85ad6839ad6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:17:51 +1000 Subject: [PATCH 0252/2214] [mipi_spi] Add Waveshare C6 LCD 1.47 (#15776) --- esphome/components/mipi_spi/models/waveshare.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index cc86101f5e..ee8bd06700 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -15,7 +15,7 @@ from esphome.components.mipi import ( import esphome.config_validation as cv from .amoled import CO5300 -from .ili import ILI9488_A +from .ili import ILI9488_A, ST7789V from .jc import AXS15231 DriverChip( @@ -243,3 +243,15 @@ ST7789P.extend( ), ), ) + +ST7789V.extend( + "WAVESHARE-ESP32-C6-LCD-1.47", + width=172, + height=320, + offset_width=34, + invert_colors=True, + data_rate="40MHz", + reset_pin=21, + cs_pin=14, + dc_pin={"number": 15, "ignore_strapping_warning": True}, +) From 0b051289f5c66f783e596570c42ee39bc540dbe3 Mon Sep 17 00:00:00 2001 From: SaVi <215243861+SaVi456@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:49:33 +0530 Subject: [PATCH 0253/2214] [core] Add missing exception chaining (raise from) across codebase (#15648) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/automation.py | 7 ++-- esphome/components/as5600/__init__.py | 4 +-- esphome/components/audio_file/__init__.py | 2 +- esphome/components/binary_sensor/__init__.py | 17 +++++++--- esphome/components/bme68x_bsec2/__init__.py | 4 ++- esphome/components/font/__init__.py | 2 +- esphome/components/ili9xxx/display.py | 2 +- esphome/components/shelly_dimmer/light.py | 2 +- .../speaker/media_player/__init__.py | 2 +- esphome/components/stepper/__init__.py | 10 +++--- esphome/components/substitutions/__init__.py | 2 +- esphome/components/time/__init__.py | 6 ++-- esphome/components/wifi/wpa2_eap.py | 6 ++-- esphome/components/wireguard/__init__.py | 2 +- esphome/config_validation.py | 32 ++++++++----------- esphome/external_files.py | 2 +- esphome/voluptuous_schema.py | 3 +- esphome/yaml_util.py | 3 +- 18 files changed, 54 insertions(+), 54 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index b4dcc41995..97d9a0a47a 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -199,11 +199,10 @@ def validate_automation(extra_schema=None, extra_validators=None, single=False): return cv.Schema([schema])(value) except cv.Invalid as err2: if "extra keys not allowed" in str(err2) and len(err2.path) == 2: - # pylint: disable=raise-missing-from - raise err + raise err from None if "Unable to find action" in str(err): - raise err2 - raise cv.MultipleInvalid([err, err2]) + raise err2 from None + raise cv.MultipleInvalid([err, err2]) from None elif isinstance(value, dict): if CONF_THEN in value: return [schema(value)] diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index b141329e94..444306cec3 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -83,7 +83,7 @@ def angle_to_position(value, min=-360, max=360): value = angle(min=min, max=max)(value) return (RESOLUTION + round(value * ANGLE_TO_POSITION)) % RESOLUTION except cv.Invalid as e: - raise cv.Invalid(f"When using angle, {e.error_message}") + raise cv.Invalid(f"When using angle, {e.error_message}") from e def percent_to_position(value): @@ -164,7 +164,7 @@ def has_valid_range_config(): except cv.Invalid as e: raise cv.Invalid( f"The range between start and end position is invalid. It was was {range} but {e.error_message}" - ) + ) from e return validator diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 3ed6c1cd92..bb1ce257db 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -116,7 +116,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: raise cv.Invalid( f"Unable to determine audio file type of '{path}'. " f"Try re-encoding the file into a supported format. Details: {e}" - ) + ) from e media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type == "wav": diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 0b36c299f6..29ddbab02c 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -332,8 +332,9 @@ def parse_multi_click_timing_str(value): try: state = cv.boolean(parts[0]) except cv.Invalid: - # pylint: disable=raise-missing-from - raise cv.Invalid(f"First word must either be ON or OFF, not {parts[0]}") + raise cv.Invalid( + f"First word must either be ON or OFF, not {parts[0]}" + ) from None if parts[1] != "for": raise cv.Invalid(f"Second word must be 'for', got {parts[1]}") @@ -350,7 +351,9 @@ def parse_multi_click_timing_str(value): try: length = cv.positive_time_period_milliseconds(parts[4]) except cv.Invalid as err: - raise cv.Invalid(f"Multi Click Grammar Parsing length failed: {err}") + raise cv.Invalid( + f"Multi Click Grammar Parsing length failed: {err}" + ) from err return {CONF_STATE: state, key: str(length)} if parts[3] != "to": @@ -359,12 +362,16 @@ def parse_multi_click_timing_str(value): try: min_length = cv.positive_time_period_milliseconds(parts[2]) except cv.Invalid as err: - raise cv.Invalid(f"Multi Click Grammar Parsing minimum length failed: {err}") + raise cv.Invalid( + f"Multi Click Grammar Parsing minimum length failed: {err}" + ) from err try: max_length = cv.positive_time_period_milliseconds(parts[4]) except cv.Invalid as err: - raise cv.Invalid(f"Multi Click Grammar Parsing minimum length failed: {err}") + raise cv.Invalid( + f"Multi Click Grammar Parsing maximum length failed: {err}" + ) from err return { CONF_STATE: state, diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index b63443c5f3..b56217fac1 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -171,7 +171,9 @@ async def to_code_base(config): with open(path, encoding="utf-8") as f: bsec2_iaq_config = f.read() except Exception as e: - raise core.EsphomeError(f"Could not open binary configuration file {path}: {e}") + raise core.EsphomeError( + f"Could not open binary configuration file {path}: {e}" + ) from e # Convert retrieved BSEC2 config to an array of ints rhs = [int(x) for x in bsec2_iaq_config.split(",")] diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index a1339a4bc1..a10c45a9d7 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -325,7 +325,7 @@ def download_gfont(value): 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: raise cv.Invalid( diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 1f20b21a0e..b1d332c1e5 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -283,7 +283,7 @@ async def to_code(config): try: return Image.open(path) except Exception as e: - raise core.EsphomeError(f"Could not load image file {path}: {e}") + raise core.EsphomeError(f"Could not load image file {path}: {e}") from e # make a wide horizontal combined image. images = [load_image(x) for x in config[CONF_COLOR_PALETTE_IMAGES]] diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index 1688f9d6a6..97538e13c9 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -84,7 +84,7 @@ def get_firmware(value): 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}") + raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e h = hashlib.new("sha256") h.update(req.content) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 320e96c897..9b496637da 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -173,7 +173,7 @@ def _read_audio_file_and_type(file_config): raise cv.Invalid( f"Unable to determine audio file type of '{path}'. " f"Try re-encoding the file into a supported format. Details: {e}" - ) + ) from e media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type in ("wav"): diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 8acacc3b49..8e80187662 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -35,8 +35,9 @@ def validate_acceleration(value): try: value = float(value) except ValueError: - # pylint: disable=raise-missing-from - raise cv.Invalid(f"Expected acceleration as floating point number, got {value}") + raise cv.Invalid( + f"Expected acceleration as floating point number, got {value}" + ) from None if value <= 0: raise cv.Invalid("Acceleration must be larger than 0 steps/s^2!") @@ -55,8 +56,9 @@ def validate_speed(value): try: value = float(value) except ValueError: - # pylint: disable=raise-missing-from - raise cv.Invalid(f"Expected speed as floating point number, got {value}") + raise cv.Invalid( + f"Expected speed as floating point number, got {value}" + ) from None if value <= 0: raise cv.Invalid("Speed must be larger than 0 steps/s!") diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index c0bd9d7be9..94aebbbfe3 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -188,7 +188,7 @@ def _expand_substitutions( f"\nRelevant context:\n{err.context_trace_str()}" f"\nSee {'->'.join(str(x) for x in path)}", path, - ) + ) from err else: if isinstance(orig_value, ESPHomeDataBase): value = _restore_data_base(value, orig_value) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 7ac0abeee0..37c08b3a12 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -109,8 +109,7 @@ def _parse_cron_int(value, special_mapping, message): try: return int(value) except ValueError: - # pylint: disable=raise-missing-from - raise cv.Invalid(message.format(value)) + raise cv.Invalid(message.format(value)) from None def _parse_cron_part(part, min_value, max_value, special_mapping): @@ -134,10 +133,9 @@ def _parse_cron_part(part, min_value, max_value, special_mapping): try: repeat_n = int(repeat) except ValueError: - # pylint: disable=raise-missing-from raise cv.Invalid( f"Repeat for '/' time expression must be an integer, got {repeat}" - ) + ) from None return set(range(offset_n, max_value + 1, repeat_n)) if "-" in part: data = part.split("-") diff --git a/esphome/components/wifi/wpa2_eap.py b/esphome/components/wifi/wpa2_eap.py index 9da3494329..51971a1220 100644 --- a/esphome/components/wifi/wpa2_eap.py +++ b/esphome/components/wifi/wpa2_eap.py @@ -67,7 +67,7 @@ def _validate_load_certificate(value): contents = read_relative_config_path(value) return wrapped_load_pem_x509_certificate(contents) except ValueError as err: - raise cv.Invalid(f"Invalid certificate: {err}") + raise cv.Invalid(f"Invalid certificate: {err}") from err def validate_certificate(value): @@ -86,9 +86,9 @@ def _validate_load_private_key(key, cert_pw): except ValueError as e: raise cv.Invalid( f"There was an error with the EAP 'password:' provided for 'key' {e}" - ) + ) from e except TypeError as e: - raise cv.Invalid(f"There was an error with the EAP 'key:' provided: {e}") + raise cv.Invalid(f"There was an error with the EAP 'key:' provided: {e}") from e def _check_private_key_cert_match(key, cert): diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 1b54391376..4fdb256d0c 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -53,7 +53,7 @@ def _cidr_network(value): try: ipaddress.ip_network(value, strict=False) except ValueError as err: - raise cv.Invalid(f"Invalid network in CIDR notation: {err}") + raise cv.Invalid(f"Invalid network in CIDR notation: {err}") from err return value diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 31cfb41a6d..e6b0cb7ee2 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -544,8 +544,9 @@ def int_(value): try: return int(value, base) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid(f"Expected integer, but cannot parse {value} as an integer") + raise Invalid( + f"Expected integer, but cannot parse {value} as an integer" + ) from None def int_range(min=None, max=None, min_included=True, max_included=True): @@ -844,8 +845,7 @@ def time_period_str_colon(value): try: parsed = [int(x) for x in value.split(":")] except ValueError: - # pylint: disable=raise-missing-from - raise Invalid(TIME_PERIOD_ERROR.format(value)) + raise Invalid(TIME_PERIOD_ERROR.format(value)) from None if len(parsed) == 2: hour, minute = parsed @@ -1047,8 +1047,7 @@ def date_time(date: bool, time: bool): try: date_obj = datetime.strptime(value, format) except ValueError as err: - # pylint: disable=raise-missing-from - raise Invalid(f"Invalid {exc_message}: {err}") + raise Invalid(f"Invalid {exc_message}: {err}") from err return_value = {} if date: @@ -1078,8 +1077,9 @@ def mac_address(value): try: parts_int.append(int(part, 16)) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid("MAC Address parts must be hexadecimal values from 00 to FF") + raise Invalid( + "MAC Address parts must be hexadecimal values from 00 to FF" + ) from None return core.MACAddress(*parts_int) @@ -1096,8 +1096,7 @@ def bind_key(value, *, name="Bind key"): try: parts_int.append(int(part, 16)) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid(f"{name} must be hex values from 00 to FF") + raise Invalid(f"{name} must be hex values from 00 to FF") from None return "".join(f"{part:02X}" for part in parts_int) @@ -1425,8 +1424,7 @@ def mqtt_qos(value): try: value = int(value) except (TypeError, ValueError): - # pylint: disable=raise-missing-from - raise Invalid(f"MQTT Quality of Service must be integer, got {value}") + raise Invalid(f"MQTT Quality of Service must be integer, got {value}") from None return one_of(0, 1, 2)(value) @@ -1518,8 +1516,7 @@ def _parse_percentage(value: object) -> float: else: value = float(value) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid("invalid number") + raise Invalid("invalid number") from None try: if not has_percent_sign and (value > 1 or value < -1): raise Invalid( @@ -1527,9 +1524,7 @@ def _parse_percentage(value: object) -> float: "outside -1.0 to 1.0. Please put a percent sign after the number!" ) except TypeError: - raise Invalid( # pylint: disable=raise-missing-from - "Expected percentage or float" - ) + raise Invalid("Expected percentage or float") from None return float(value) @@ -1702,8 +1697,7 @@ def dimensions(value): try: width, height = int(value[0]), int(value[1]) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid("Width and height dimensions must be integers") + raise Invalid("Width and height dimensions must be integers") from None if width <= 0 or height <= 0: raise Invalid("Width and height must at least be 1") return [width, height] diff --git a/esphome/external_files.py b/esphome/external_files.py index 18b68fba08..55711e1b79 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -107,7 +107,7 @@ def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes: e, ) return path.read_bytes() - raise cv.Invalid(f"Could not download from {url}: {e}") + raise cv.Invalid(f"Could not download from {url}: {e}") from e path.parent.mkdir(parents=True, exist_ok=True) data = req.content diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 0703c54a7a..904963ba4e 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -39,8 +39,7 @@ class _Schema(vol.Schema): try: res = extra(res) except vol.Invalid as err: - # pylint: disable=raise-missing-from - raise ensure_multiple_invalid(err) + raise ensure_multiple_invalid(err) from err return res def _compile_mapping(self, schema, invalid_msg=None): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 59d851c02e..e15adff935 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -338,10 +338,9 @@ class ESPHomeLoaderMixin: try: hash(key) except TypeError: - # pylint: disable=raise-missing-from raise yaml.constructor.ConstructorError( f'Invalid key "{key}" (not hashable)', key_node.start_mark - ) + ) from None key = make_data_base(str(key)) key.from_node(key_node) From 81766f5bbe54ec5816aced2887c4f1f61706c988 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:55:34 +0200 Subject: [PATCH 0254/2214] Add partition table update functionality to ota component --- esphome/__main__.py | 19 +- esphome/components/esphome/ota/__init__.py | 11 +- .../components/esphome/ota/ota_esphome.cpp | 58 +++- esphome/components/esphome/ota/ota_esphome.h | 3 + esphome/components/ota/ota_backend.h | 7 + .../components/ota/ota_backend_esp_idf.cpp | 294 ++++++++++++++---- esphome/components/ota/ota_backend_esp_idf.h | 18 +- esphome/core/__init__.py | 4 + esphome/espota2.py | 64 +++- 9 files changed, 396 insertions(+), 82 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 87abd7f796..87682860c8 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1009,15 +1009,19 @@ def upload_program( remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD) - if getattr(args, "file", None) is not None: - binary = Path(args.file) - else: - binary = CORE.firmware_bin # Resolve MQTT magic strings to actual IP addresses network_devices = _resolve_network_devices(devices, config, args) - return espota2.run_ota(network_devices, remote_port, password, binary) + binary = CORE.firmware_bin + ota_type = espota2.OTA_TYPE_UPDATE_APP + if getattr(args, "partition_table", False): + binary = CORE.partition_table_bin + ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE + if getattr(args, "file", None) is not None: + binary = Path(args.file) + + return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: @@ -1646,6 +1650,11 @@ def parse_args(argv): "--file", help="Manually specify the binary file to upload.", ) + parser_upload.add_argument( + "--partition-table", + help="Upload as partition table", + action="store_true", + ) parser_logs = subparsers.add_parser( "logs", diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 337064dd27..7684df880f 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,11 +16,13 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv from esphome.types import ConfigType +CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access" + _LOGGER = logging.getLogger(__name__) @@ -75,6 +77,10 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) + if config[CONF_ALLOW_PARTITION_ACCESS] and not CORE.is_esp32: + raise cv.Invalid( + f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" + ) else: new_ota_conf.append(ota_conf) @@ -117,6 +123,7 @@ CONFIG_SCHEMA = cv.All( ln882x=8820, rtl87xx=8892, ): cv.port, + cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.string, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" @@ -147,6 +154,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_PASSWORD") cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) + if config[CONF_ALLOW_PARTITION_ACCESS]: + cg.add_define("USE_OTA_PARTITIONS") await cg.register_component(var, config) await ota_to_code(var, config) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index af9b8ee19a..5edabdf1f3 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -90,8 +90,11 @@ void ESPHomeOTAComponent::loop() { } } -static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; -static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static const uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static const uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static const uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; +static const uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static const uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. @@ -177,18 +180,40 @@ void ESPHomeOTAComponent::handle_handshake_() { this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); this->transition_ota_state_(OTAState::FEATURE_ACK); - this->handshake_buf_[0] = - ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) - ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION - : ota::OTA_RESPONSE_HEADER_OK; +#ifdef USE_OTA_PARTITIONS + this->extended_proto_ = this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; + if (this->extended_proto_) { + this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; + this->handshake_buf_[1] = 0; + if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) { + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; + } + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; + + } else { +#endif + this->handshake_buf_[0] = + ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) + ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION + : ota::OTA_RESPONSE_HEADER_OK; +#ifdef USE_OTA_PARTITIONS + } +#endif [[fallthrough]]; } case OTAState::FEATURE_ACK: { // Acknowledge header - 1 byte +#ifdef USE_OTA_PARTITIONS + if (!this->try_write_(this->extended_proto_ ? 2 : 1, LOG_STR("ack feature"))) { + return; + } +#else if (!this->try_write_(1, LOG_STR("ack feature"))) { return; } +#endif + #ifdef USE_OTA_PASSWORD // If password is set, move to auth phase if (!this->password_.empty()) { @@ -271,6 +296,9 @@ void ESPHomeOTAComponent::handle_data_() { uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; +#ifdef USE_OTA_PARTITIONS + ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; +#endif #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif @@ -286,6 +314,18 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); +#ifdef USE_OTA_PARTITIONS + if (this->extended_proto_) { + // Read ota type, 1 byte + if (!this->readall_(buf, 1)) { + this->log_read_error_(LOG_STR("OTA type")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + ota_type = static_cast(buf[0]); + } + ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); +#endif + // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { this->log_read_error_(LOG_STR("size")); @@ -306,7 +346,11 @@ void ESPHomeOTAComponent::handle_data_() { #endif // This will block for a few seconds as it locks flash +#ifdef USE_OTA_PARTITIONS + error_code = this->backend_->begin(ota_size, ota_type); +#else error_code = this->backend_->begin(ota_size); +#endif if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) update_started = true; @@ -577,7 +621,7 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); } bool ESPHomeOTAComponent::select_auth_type_() { - bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + bool client_supports_sha256 = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_SHA256_AUTH) != 0; // Require SHA256 if (!client_supports_sha256) { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index f3a5952398..f3513e9603 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -83,6 +83,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD +#ifdef USE_OTA_PARTITIONS + bool extended_proto_{false}; +#endif socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index bd9c481901..18e2fa3802 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -23,6 +23,7 @@ enum OTAResponseTypes { OTA_RESPONSE_UPDATE_END_OK = 0x45, OTA_RESPONSE_SUPPORTS_COMPRESSION = 0x46, OTA_RESPONSE_CHUNK_OK = 0x47, + OTA_RESPONSE_FEATURE_FLAGS = 0x48, OTA_RESPONSE_ERROR_MAGIC = 0x80, OTA_RESPONSE_ERROR_UPDATE_PREPARE = 0x81, @@ -38,6 +39,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_MD5_MISMATCH = 0x8B, OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C, OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D, + OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -49,6 +51,11 @@ enum OTAState { OTA_ERROR, }; +enum OTAType { + OTA_TYPE_UPDATE_APP = 0x00, + OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01, +}; + /** Listener interface for OTA state changes. * * Components can implement this interface to receive OTA state updates diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 598fce1562..c4226e7423 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -9,60 +9,80 @@ #include #include +#ifdef USE_OTA_PARTITIONS +#include +#endif + namespace esphome::ota { static const char *const TAG = "ota.idf"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes IDFOTABackend::begin(size_t image_size) { +OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { + this->ota_type_ = ota_type; + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { #ifdef USE_OTA_ROLLBACK - // If we're starting an OTA, the current boot is good enough - mark it valid - // to prevent rollback and allow the OTA to proceed even if the safe mode - // timer hasn't expired yet. - esp_ota_mark_app_valid_cancel_rollback(); + // If we're starting an OTA, the current boot is good enough - mark it valid + // to prevent rollback and allow the OTA to proceed even if the safe mode + // timer hasn't expired yet. + esp_ota_mark_app_valid_cancel_rollback(); #endif - this->partition_ = esp_ota_get_next_update_partition(nullptr); - if (this->partition_ == nullptr) { - return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; - } + this->partition_ = esp_ota_get_next_update_partition(nullptr); + if (this->partition_ == nullptr) { + return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; + } #if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // The following function takes longer than the 5 seconds timeout of WDT - esp_task_wdt_config_t wdtc; - wdtc.idle_core_mask = 0; + // The following function takes longer than the 5 seconds timeout of WDT + esp_task_wdt_config_t wdtc; + wdtc.idle_core_mask = 0; #if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 - wdtc.idle_core_mask |= (1 << 0); + wdtc.idle_core_mask |= (1 << 0); #endif #if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 - wdtc.idle_core_mask |= (1 << 1); + wdtc.idle_core_mask |= (1 << 1); #endif - wdtc.timeout_ms = 15000; - wdtc.trigger_panic = false; - esp_task_wdt_reconfigure(&wdtc); + wdtc.timeout_ms = 15000; + wdtc.trigger_panic = false; + esp_task_wdt_reconfigure(&wdtc); #endif - esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); + esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); #if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // Set the WDT back to the configured timeout - wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; - esp_task_wdt_reconfigure(&wdtc); + // Set the WDT back to the configured timeout + wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; + esp_task_wdt_reconfigure(&wdtc); #endif - if (err != ESP_OK) { - 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) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; + if (err != ESP_OK) { + 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) { + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + return OTA_RESPONSE_ERROR_UNKNOWN; } - return OTA_RESPONSE_ERROR_UNKNOWN; + this->md5_.init(); + return OTA_RESPONSE_OK; } - this->md5_.init(); - return OTA_RESPONSE_OK; +#ifdef USE_OTA_PARTITIONS + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || image_size > OTA_BUFFER_SIZE) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + } + memset(this->buf_, 0xFF, sizeof this->buf_); + this->buf_written_ = 0; + this->image_size_ = image_size; + this->md5_.init(); + return OTA_RESPONSE_OK; + } +#endif + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } void IDFOTABackend::set_update_md5(const char *expected_md5) { @@ -71,17 +91,31 @@ void IDFOTABackend::set_update_md5(const char *expected_md5) { } OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { - esp_err_t err = esp_ota_write(this->update_handle_, data, len); - this->md5_.add(data, len); - if (err != ESP_OK) { - if (err == ESP_ERR_OTA_VALIDATE_FAILED) { - return OTA_RESPONSE_ERROR_MAGIC; - } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { + esp_err_t err = esp_ota_write(this->update_handle_, data, len); + this->md5_.add(data, len); + if (err != ESP_OK) { + if (err == ESP_ERR_OTA_VALIDATE_FAILED) { + return OTA_RESPONSE_ERROR_MAGIC; + } 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; } - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_OK; } - return OTA_RESPONSE_OK; +#ifdef USE_OTA_PARTITIONS + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + if (len > OTA_BUFFER_SIZE - this->buf_written_) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + } + memcpy(this->buf_ + this->buf_written_, data, len); + this->buf_written_ += len; + this->md5_.add(data, len); + return OTA_RESPONSE_OK; + } +#endif + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } OTAResponseTypes IDFOTABackend::end() { @@ -92,32 +126,176 @@ OTAResponseTypes IDFOTABackend::end() { return OTA_RESPONSE_ERROR_MD5_MISMATCH; } } - esp_err_t err = esp_ota_end(this->update_handle_); - this->update_handle_ = 0; - if (err == ESP_OK) { - err = esp_ota_set_boot_partition(this->partition_); + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { + esp_err_t err = esp_ota_end(this->update_handle_); + this->update_handle_ = 0; if (err == ESP_OK) { - return OTA_RESPONSE_OK; + err = esp_ota_set_boot_partition(this->partition_); + if (err == ESP_OK) { + return OTA_RESPONSE_OK; + } } + if (err == ESP_ERR_OTA_VALIDATE_FAILED) { + #ifdef USE_OTA_SIGNED_VERIFICATION + ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); + return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; + #else + return OTA_RESPONSE_ERROR_UPDATE_END; + #endif + } + if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + return OTA_RESPONSE_ERROR_UNKNOWN; + } +#ifdef USE_OTA_PARTITIONS + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + return this->update_partition_table(); } - if (err == ESP_ERR_OTA_VALIDATE_FAILED) { -#ifdef USE_OTA_SIGNED_VERIFICATION - ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); - return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; -#else - return OTA_RESPONSE_ERROR_UPDATE_END; #endif - } - if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; - } - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } void IDFOTABackend::abort() { - esp_ota_abort(this->update_handle_); - this->update_handle_ = 0; + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + } +#ifdef USE_OTA_PARTITIONS + if (this->partition_table_part_ != nullptr) { + esp_partition_deregister_external(this->partition_table_part_); + this->partition_table_part_ = nullptr; + } +#endif } +#ifdef USE_OTA_PARTITIONS +OTAResponseTypes IDFOTABackend::update_partition_table() { + esp_err_t err; + int num_partitions; + if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { + ESP_LOGE(TAG, "not enough data received (%d/%d bytes)", this->buf_written_, this->image_size_); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + ESP_LOGD(TAG, "partition table size %d", this->image_size_); + + // Get running app partition and used size + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + size_t running_app_size = running_app_part->size; + const esp_partition_pos_t running_app_pos = { + .offset = running_app_part->address, + .size = running_app_part->size, + }; + esp_image_metadata_t image_metadata; + image_metadata.start_addr = running_app_part->address; + err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); + if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { + running_app_size = image_metadata.image_len; + } + + // Get partition table partition + err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + // Verify existing partition table + const esp_partition_info_t *existing_partition_table = NULL; + esp_partition_mmap_handle_t partition_table_map; + err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, (const void**)&existing_partition_table, &partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); + esp_partition_munmap(partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + + // Verify new partition table + const esp_partition_info_t *new_partition_table = (const esp_partition_info_t *)this->buf_; + // esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN bytes of data + err = esp_partition_table_verify(new_partition_table, true, &num_partitions); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + + // Check if the required app and otadata partitions exist in the new partition table + // Check which app slot to boot from in the new partition table + int app_partitions_found = 0; + int app_index = -1; + int app_index_with_copy = -1; + int otadata_index = -1; + bool otadata_no_overlap = false; + for (int i = 0; i < num_partitions; i++) { + const esp_partition_info_t *part = &new_partition_table[i]; + if (part->type == ESP_PARTITION_TYPE_APP) { + app_partitions_found++; + if (part->pos.size >= running_app_size) { + if (part->pos.offset == running_app_part->address) { + app_index = i; + } else if (part->pos.offset >= running_app_part->address + running_app_size || running_app_part->address >= part->pos.offset + part->pos.size) { + // No overlap with running app + app_index_with_copy = i; + } + } + } else if (part->type == ESP_PARTITION_TYPE_DATA && part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { + otadata_index = i; + otadata_no_overlap = part->pos.offset >= running_app_part->address + running_app_size || running_app_part->address >= part->pos.offset + part->pos.size; + } + } + if (app_index == -1 && app_index_with_copy == -1) { + // Can't move running app to new partition layout + ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + if (app_partitions_found < 2 || otadata_index == -1) { + // OTA would be impossible with new partition table + ESP_LOGE(TAG, "New partition table is missing the required partitions for OTA"); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + if (!otadata_no_overlap) { + // Can't write to new otadata partition because it overlaps with the running app + ESP_LOGE(TAG, "New otadata partition overlaps with running app"); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + + ESP_LOGD(TAG, "Checks passed, starting partition table update", err); + + // TODO: Copy the running app partition to new position if needed + if (app_index == -1) { + ESP_LOGE(TAG, "Moving the app partition is required but not implemented"); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + + // Update the partition table + err = esp_ota_begin(this->partition_table_part_, this->image_size_, &this->update_handle_); + if (err != ESP_OK) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + err = esp_ota_write(this->update_handle_, this->buf_, this->image_size_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + err = esp_ota_end(this->update_handle_); + this->update_handle_ = 0; + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + + // TODO: Reload partition table and rewrite otadata + + return OTA_RESPONSE_OK; +} +#endif + } // namespace esphome::ota #endif // USE_ESP32 diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index d007bcd128..0ee9532b48 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -9,21 +9,37 @@ namespace esphome::ota { +#ifdef USE_OTA_PARTITIONS +static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 +#endif + class IDFOTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); bool supports_compression() { return false; } + protected: +#ifdef USE_OTA_PARTITIONS + OTAResponseTypes update_partition_table(); +#endif + private: esp_ota_handle_t update_handle_{0}; const esp_partition_t *partition_; md5::MD5Digest md5_{}; char expected_bin_md5_[32]; bool md5_set_{false}; + ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; +#ifdef USE_OTA_PARTITIONS + uint8_t buf_[OTA_BUFFER_SIZE]; + size_t buf_written_{0}; + size_t image_size_{0}; + const esp_partition_t *partition_table_part_{nullptr}; +#endif }; std::unique_ptr make_ota_backend(); diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f86..4d5b8c5362 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -775,6 +775,10 @@ class EsphomeCore: return self.relative_pioenvs_path(self.name, "firmware.uf2") return self.relative_pioenvs_path(self.name, "firmware.bin") + @property + def partition_table_bin(self): + return self.relative_pioenvs_path(self.name, "partitions.bin") + @property def target_platform(self): return self.data[KEY_CORE][KEY_TARGET_PLATFORM] diff --git a/esphome/espota2.py b/esphome/espota2.py index 39f51e02e9..9767c4da04 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -15,6 +15,9 @@ from typing import Any from esphome.core import EsphomeError from esphome.helpers import ProgressBar, resolve_ip_address +OTA_TYPE_UPDATE_APP = 0x00 +OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01 + RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 RESPONSE_REQUEST_SHA256_AUTH = 0x02 @@ -27,6 +30,7 @@ RESPONSE_RECEIVE_OK = 0x44 RESPONSE_UPDATE_END_OK = 0x45 RESPONSE_SUPPORTS_COMPRESSION = 0x46 RESPONSE_CHUNK_OK = 0x47 +RESPONSE_FEATURE_FLAGS = 0x48 RESPONSE_ERROR_MAGIC = 0x80 RESPONSE_ERROR_UPDATE_PREPARE = 0x81 @@ -49,9 +53,11 @@ OTA_VERSION_2_0 = 2 MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45] -FEATURE_SUPPORTS_COMPRESSION = 0x01 -FEATURE_SUPPORTS_SHA256_AUTH = 0x02 - +CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01 +CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02 +CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04 +SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01 +SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 @@ -232,7 +238,11 @@ def send_check( def perform_ota( - sock: socket.socket, password: str | None, file_handle: io.IOBase, filename: Path + sock: socket.socket, + password: str | None, + file_handle: io.IOBase, + filename: Path, + ota_type: int, ) -> None: file_contents = file_handle.read() file_size = len(file_contents) @@ -251,7 +261,11 @@ def perform_ota( ) # Features - send both compression and SHA256 auth support - features_to_send = FEATURE_SUPPORTS_COMPRESSION | FEATURE_SUPPORTS_SHA256_AUTH + features_to_send = ( + CLIENT_FEATURE_SUPPORTS_COMPRESSION + | CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ) send_check(sock, features_to_send, "features") features = receive_exactly( sock, @@ -260,7 +274,26 @@ def perform_ota( None, # Accept any response )[0] - if features == RESPONSE_SUPPORTS_COMPRESSION: + extended_proto = False + if features == RESPONSE_FEATURE_FLAGS: + extended_proto = True + features = receive_exactly( + sock, + 1, + "feature flags", + None, # Accept any response + )[0] + elif features == RESPONSE_SUPPORTS_COMPRESSION: + features = SERVER_FEATURE_SUPPORTS_COMPRESSION + else: + features = 0 + + if ota_type != 0 and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS: + raise OTAError( + f"Device only supports app updates" + ) + + if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: upload_contents = gzip.compress(file_contents, compresslevel=9) _LOGGER.info("Compressed to %s bytes", len(upload_contents)) else: @@ -315,6 +348,9 @@ def perform_ota( # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) + if extended_proto: + send_check(sock, ota_type, "ota type") + upload_size = len(upload_contents) upload_size_encoded = [ (upload_size >> 24) & 0xFF, @@ -375,7 +411,11 @@ def perform_ota( def run_ota_impl_( - remote_host: str | list[str], remote_port: int, password: str | None, filename: Path + remote_host: str | list[str], + remote_port: int, + password: str | None, + filename: Path, + ota_type: int, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -413,7 +453,7 @@ def run_ota_impl_( _LOGGER.info("Connected to %s", sa[0]) with open(filename, "rb") as file_handle: try: - perform_ota(sock, password, file_handle, filename) + perform_ota(sock, password, file_handle, filename, ota_type) except OTAError as err: _LOGGER.error(str(err)) return 1, None @@ -428,10 +468,14 @@ def run_ota_impl_( def run_ota( - remote_host: str | list[str], remote_port: int, password: str | None, filename: Path + remote_host: str | list[str], + remote_port: int, + password: str | None, + filename: Path, + ota_type: int, ) -> tuple[int, str | None]: try: - return run_ota_impl_(remote_host, remote_port, password, filename) + return run_ota_impl_(remote_host, remote_port, password, filename, ota_type) except OTAError as err: _LOGGER.error(err) return 1, None From 4b4d9a13f625814ee0b65b8f6ef720594e1a631b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:08:54 +0000 Subject: [PATCH 0255/2214] [pre-commit.ci lite] apply automatic fixes --- .../components/esphome/ota/ota_esphome.cpp | 3 +- .../components/ota/ota_backend_esp_idf.cpp | 28 +++++++++++-------- esphome/components/ota/ota_backend_esp_idf.h | 2 +- esphome/espota2.py | 4 +-- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 33fd0b7eed..bf03482451 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -209,7 +209,8 @@ void ESPHomeOTAComponent::handle_handshake_() { if (this->extended_proto_) { this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = 0; - if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) { + if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && + this->backend_->supports_compression()) { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; } this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index c4226e7423..506d48988f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -72,7 +72,8 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || image_size > OTA_BUFFER_SIZE) { + if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || + image_size > OTA_BUFFER_SIZE) { return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; } memset(this->buf_, 0xFF, sizeof this->buf_); @@ -136,12 +137,12 @@ OTAResponseTypes IDFOTABackend::end() { } } if (err == ESP_ERR_OTA_VALIDATE_FAILED) { - #ifdef USE_OTA_SIGNED_VERIFICATION +#ifdef USE_OTA_SIGNED_VERIFICATION ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; - #else +#else return OTA_RESPONSE_ERROR_UPDATE_END; - #endif +#endif } if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { return OTA_RESPONSE_ERROR_WRITING_FLASH; @@ -183,8 +184,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); size_t running_app_size = running_app_part->size; const esp_partition_pos_t running_app_pos = { - .offset = running_app_part->address, - .size = running_app_part->size, + .offset = running_app_part->address, + .size = running_app_part->size, }; esp_image_metadata_t image_metadata; image_metadata.start_addr = running_app_part->address; @@ -194,7 +195,9 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Get partition table partition - err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); + err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, + "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, + ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_UNKNOWN; @@ -202,7 +205,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Verify existing partition table const esp_partition_info_t *existing_partition_table = NULL; esp_partition_mmap_handle_t partition_table_map; - err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, (const void**)&existing_partition_table, &partition_table_map); + err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, + (const void **) &existing_partition_table, &partition_table_map); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_UNKNOWN; @@ -215,7 +219,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Verify new partition table - const esp_partition_info_t *new_partition_table = (const esp_partition_info_t *)this->buf_; + const esp_partition_info_t *new_partition_table = (const esp_partition_info_t *) this->buf_; // esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN bytes of data err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { @@ -237,14 +241,16 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (part->pos.size >= running_app_size) { if (part->pos.offset == running_app_part->address) { app_index = i; - } else if (part->pos.offset >= running_app_part->address + running_app_size || running_app_part->address >= part->pos.offset + part->pos.size) { + } else if (part->pos.offset >= running_app_part->address + running_app_size || + running_app_part->address >= part->pos.offset + part->pos.size) { // No overlap with running app app_index_with_copy = i; } } } else if (part->type == ESP_PARTITION_TYPE_DATA && part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { otadata_index = i; - otadata_no_overlap = part->pos.offset >= running_app_part->address + running_app_size || running_app_part->address >= part->pos.offset + part->pos.size; + otadata_no_overlap = part->pos.offset >= running_app_part->address + running_app_size || + running_app_part->address >= part->pos.offset + part->pos.size; } } if (app_index == -1 && app_index_with_copy == -1) { diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 0ee9532b48..75aa66c29b 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -10,7 +10,7 @@ namespace esphome::ota { #ifdef USE_OTA_PARTITIONS -static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 +static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 #endif class IDFOTABackend final { diff --git a/esphome/espota2.py b/esphome/espota2.py index 9767c4da04..7ee28a6b1c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -289,9 +289,7 @@ def perform_ota( features = 0 if ota_type != 0 and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS: - raise OTAError( - f"Device only supports app updates" - ) + raise OTAError("Device only supports app updates") if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: upload_contents = gzip.compress(file_contents, compresslevel=9) From aca11f17774b069fa3196eee08023d018bc2a55e Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:44:25 +0200 Subject: [PATCH 0256/2214] Fix --- esphome/components/esphome/ota/__init__.py | 2 +- esphome/espota2.py | 6 +++--- tests/unit_tests/test_main.py | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 92e9733f93..44b3997755 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -163,7 +163,7 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_OTA_PASSWORD") cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) - if config[CONF_ALLOW_PARTITION_ACCESS]: + if config.get(CONF_ALLOW_PARTITION_ACCESS, False): cg.add_define("USE_OTA_PARTITIONS") # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. diff --git a/esphome/espota2.py b/esphome/espota2.py index 7ee28a6b1c..f6ecb123b9 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -242,7 +242,7 @@ def perform_ota( password: str | None, file_handle: io.IOBase, filename: Path, - ota_type: int, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> None: file_contents = file_handle.read() file_size = len(file_contents) @@ -413,7 +413,7 @@ def run_ota_impl_( remote_port: int, password: str | None, filename: Path, - ota_type: int, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -470,7 +470,7 @@ def run_ota( remote_port: int, password: str | None, filename: Path, - ota_type: int, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> tuple[int, str | None]: try: return run_ota_impl_(remote_host, remote_port, password, filename, ota_type) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e07b4accf2..09d0ab7512 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1587,7 +1587,7 @@ def test_upload_program_ota_success( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, "secret", expected_firmware + ["192.168.1.100"], 3232, "secret", expected_firmware, 0 ) @@ -1618,7 +1618,7 @@ def test_upload_program_ota_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, Path("custom.bin") + ["192.168.1.100"], 3232, None, Path("custom.bin"), 0 ) @@ -1676,7 +1676,7 @@ def test_upload_program_ota_with_mqtt_resolution( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware + ["192.168.1.100"], 3232, None, expected_firmware, 0 ) @@ -1724,7 +1724,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.50"], 3232, None, expected_firmware + ["192.168.1.50"], 3232, None, expected_firmware, 0 ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -2698,7 +2698,7 @@ def test_upload_program_ota_static_ip_with_mqttip( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware + ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware, 0 ) @@ -2741,7 +2741,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware + ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware, 0 ) @@ -2906,7 +2906,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware + ["192.168.1.100"], 3232, None, expected_firmware, 0 ) From 0af4521660238db1dddb691fd1d83a5df9c8f31c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:45:45 +0000 Subject: [PATCH 0257/2214] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 09d0ab7512..e1ec434ceb 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2741,7 +2741,11 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware, 0 + ["192.168.2.50", "192.168.2.51", "192.168.1.100"], + 3232, + None, + expected_firmware, + 0, ) From 55ce6abf4b8d99e22afb583f5682b5900a8dbd13 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:56:32 +0200 Subject: [PATCH 0258/2214] Fix --- esphome/components/esphome/ota/__init__.py | 2 +- tests/unit_tests/test_espota2.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 44b3997755..0b5cbf96d3 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -77,7 +77,7 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) - if config[CONF_ALLOW_PARTITION_ACCESS] and not CORE.is_esp32: + if config.get(CONF_ALLOW_PARTITION_ACCESS, False) and not CORE.is_esp32: raise cv.Invalid( f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" ) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 20ba4b1f76..b08d4cecf2 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -274,8 +274,8 @@ def test_perform_ota_successful_md5_auth( assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH ] ) ) @@ -644,8 +644,8 @@ def test_perform_ota_successful_sha256_auth( assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH ] ) ) @@ -699,8 +699,8 @@ def test_perform_ota_sha256_fallback_to_md5( assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH ] ) ) From bbaab97f5769c92dc1ec37dd0b7a4ca21b9752b4 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:06:42 +0200 Subject: [PATCH 0259/2214] Fix --- tests/unit_tests/test_espota2.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index b08d4cecf2..ab0ad7034c 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -276,6 +276,7 @@ def test_perform_ota_successful_md5_auth( [ espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -646,6 +647,7 @@ def test_perform_ota_successful_sha256_auth( [ espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -701,6 +703,7 @@ def test_perform_ota_sha256_fallback_to_md5( [ espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) From 6af7a9ed8fe8059f7378c37affbb091140c01165 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:36:06 -0400 Subject: [PATCH 0260/2214] [qmc5883l] Move per-update log line from DEBUG to VERBOSE (#15781) --- esphome/components/qmc5883l/qmc5883l.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index bc2adb5cfe..d0488d0c9f 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -100,7 +100,7 @@ void QMC5883LComponent::update() { // ROL_PNT in setup and reading 7 bytes starting at the status register. // If status and all three axes are desired, using ROL_PNT saves you 3 bytes. // But simply not reading status saves you 4 bytes always and is much simpler. - if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) { + if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) { err = this->read_register(QMC5883L_REGISTER_STATUS, &status, 1); if (err != i2c::ERROR_OK) { char buf[32]; @@ -165,7 +165,7 @@ void QMC5883LComponent::update() { temp = int16_t(raw_temp) * 0.01f; } - ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, + ESP_LOGV(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, temp, status); if (this->x_sensor_ != nullptr) From c6ad23fbc05047745522c5ec0e0996e85f958d00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 08:45:33 -1000 Subject: [PATCH 0261/2214] [bundle] Force-resolve nested IncludeFile during file discovery (#15762) --- esphome/bundle.py | 78 ++++++++- .../fixtures/bundle/bundle_test.yaml | 6 +- .../fixtures/bundle/common/wifi.yaml | 2 + tests/unit_tests/test_bundle.py | 150 +++++++++++++++++- 4 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/fixtures/bundle/common/wifi.yaml diff --git a/esphome/bundle.py b/esphome/bundle.py index b6816c7c95..efa80acc8c 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -151,8 +151,8 @@ class ConfigBundleCreator: def __init__(self, config: dict[str, Any]) -> None: self._config = config - self._config_dir = CORE.config_dir - self._config_path = CORE.config_path + self._config_dir = Path(CORE.config_dir).resolve() + self._config_path = Path(CORE.config_path).resolve() self._files: list[BundleFile] = [] self._seen_paths: set[Path] = set() self._secrets_paths: set[Path] = set() @@ -258,21 +258,36 @@ class ConfigBundleCreator: def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. - We track files by wrapping _load_yaml_internal. The config has already - been loaded at this point (bundle is a POST_CONFIG_ACTION), so we - re-load just to discover the file list. + Deliberately uses a fresh re-parse and force-loads every deferred + ``IncludeFile`` to include *all* potentially-reachable includes, + even branches not selected by the local substitutions. Bundles are + meant to be compiled on another system where command-line + substitution overrides may choose a different branch — e.g. + ``!include network/${eth_model}/config.yaml`` must ship every + candidate so the remote build can pick any one. + + Entries with unresolved substitution variables in the filename + path are skipped with a warning (they cannot be resolved without + the substitution pass). Secrets files are tracked separately so we can filter them to only include the keys this config actually references. """ + # Must be a fresh parse: IncludeFile.load() caches its result in + # _content, and we discover files by listening for loader calls. On + # an already-parsed tree the cache is populated, .load() returns + # without calling the loader, the listener never fires, and the + # referenced files would be silently dropped from the bundle. with yaml_util.track_yaml_loads() as loaded_files: try: - yaml_util.load_yaml(self._config_path) + data = yaml_util.load_yaml(self._config_path) except EsphomeError: _LOGGER.debug( "Bundle: re-loading YAML for include discovery failed, " "proceeding with partial file list" ) + else: + _force_load_include_files(data) for fpath in loaded_files: if fpath == self._config_path.resolve(): @@ -608,6 +623,57 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: tar.addfile(info, io.BytesIO(data)) +def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None: + """Recursively resolve any ``IncludeFile`` instances in a YAML tree. + + Nested ``!include`` returns a deferred ``IncludeFile`` that is only + resolved during the substitution pass. During bundle discovery we need + the referenced files to actually load so the ``track_yaml_loads`` + listener fires for them. + + ``IncludeFile`` instances with unresolved substitution variables in the + filename cannot be loaded — we skip and warn about those. + """ + if _seen is None: + _seen = set() + + if isinstance(obj, yaml_util.IncludeFile): + if id(obj) in _seen: + return + _seen.add(id(obj)) + if obj.has_unresolved_expressions(): + _LOGGER.warning( + "Bundle: cannot resolve !include %s (referenced from %s) " + "with substitutions in path", + obj.file, + obj.parent_file, + ) + return + try: + loaded = obj.load() + except EsphomeError as err: + _LOGGER.warning( + "Bundle: failed to load !include %s (referenced from %s): %s", + obj.file, + obj.parent_file, + err, + ) + return + _force_load_include_files(loaded, _seen) + elif isinstance(obj, dict): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for value in obj.values(): + _force_load_include_files(value, _seen) + elif isinstance(obj, (list, tuple)): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for item in obj: + _force_load_include_files(item, _seen) + + def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): diff --git a/tests/unit_tests/fixtures/bundle/bundle_test.yaml b/tests/unit_tests/fixtures/bundle/bundle_test.yaml index f834a8d867..247f5cc8bb 100644 --- a/tests/unit_tests/fixtures/bundle/bundle_test.yaml +++ b/tests/unit_tests/fixtures/bundle/bundle_test.yaml @@ -11,9 +11,9 @@ esp32: logger: <<: !include common/base.yaml -wifi: - ssid: !secret wifi_ssid - password: !secret wifi_password +# Plain nested !include — deferred as an IncludeFile until the substitution +# pass. The bundle must force-resolve it to pick up common/wifi.yaml. +wifi: !include common/wifi.yaml api: diff --git a/tests/unit_tests/fixtures/bundle/common/wifi.yaml b/tests/unit_tests/fixtures/bundle/common/wifi.yaml new file mode 100644 index 0000000000..d7e7b3cd45 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/common/wifi.yaml @@ -0,0 +1,2 @@ +ssid: !secret wifi_ssid +password: !secret wifi_password diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index b8b2d0ffd1..89bf1a33b3 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -5,8 +5,10 @@ from __future__ import annotations import io import json from pathlib import Path +import shutil import tarfile from typing import Any +from unittest.mock import patch import pytest @@ -20,6 +22,7 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, + _force_load_include_files, extract_bundle, is_bundle_path, prepare_bundle_for_compile, @@ -485,7 +488,7 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: result = read_bundle_manifest(bundle_path) assert result.esphome_version == "unknown" - assert result.files == [] + assert not result.files assert result.has_secrets is False @@ -862,6 +865,117 @@ def test_discover_files_skips_missing_directory(tmp_path: Path) -> None: assert len(files) == 1 +def test_discover_files_nested_include(tmp_path: Path) -> None: + """Nested !include files (e.g. wifi: !include wifi.yaml) are bundled.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include wifi.yaml\n" + ) + (config_dir / "wifi.yaml").write_text('ssid: "a"\npassword: "b"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert "wifi.yaml" in paths + + +def test_discover_files_deeply_nested_include(tmp_path: Path) -> None: + """Chains of !include (a includes b includes c) are fully resolved.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include level1.yaml\n" + ) + (config_dir / "level1.yaml").write_text("nested: !include level2.yaml\n") + (config_dir / "level2.yaml").write_text('value: "leaf"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "level1.yaml" in paths + assert "level2.yaml" in paths + + +def test_discover_files_nested_include_unresolved_substitution( + tmp_path: Path, +) -> None: + """!include with substitution vars in path cannot be resolved; skipped gracefully.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include ${platform}.yaml\n" + ) + + creator = ConfigBundleCreator({}) + # Should not raise + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + + +def test_discover_files_nested_include_load_failure( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A nested !include pointing at a missing file is logged and skipped.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include missing.yaml\n" + ) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert any( + "failed to load !include" in r.message and "missing.yaml" in r.message + for r in caplog.records + ) + + +def test_force_load_skips_duplicate_include_file() -> None: + """The same IncludeFile referenced twice is only loaded once.""" + + class _StubInclude: + """Mimics yaml_util.IncludeFile minimally for _force_load testing.""" + + def __init__(self) -> None: + self.file = Path("dup.yaml") + self.parent_file = Path("root.yaml") + self.load_calls = 0 + + def has_unresolved_expressions(self) -> bool: + return False + + def load(self) -> dict[str, Any]: + self.load_calls += 1 + return {} + + stub = _StubInclude() + # Same instance appears twice — second visit must hit the _seen guard. + tree = {"a": stub, "b": [stub]} + + with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude): + _force_load_include_files(tree) + + assert stub.load_calls == 1 + + +def test_force_load_handles_cyclic_containers() -> None: + """Cyclic dict/list references don't cause infinite recursion.""" + cyclic_dict: dict[str, Any] = {} + cyclic_dict["self"] = cyclic_dict + + cyclic_list: list[Any] = [] + cyclic_list.append(cyclic_list) + + # Should return without recursing forever + _force_load_include_files(cyclic_dict) + _force_load_include_files(cyclic_list) + + def test_discover_files_yaml_reload_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1008,6 +1122,40 @@ def test_discover_files_walk_tuple_values(tmp_path: Path) -> None: assert "a.pem" in paths +# --------------------------------------------------------------------------- +# ConfigBundleCreator - fixture-based end-to-end +# --------------------------------------------------------------------------- + + +def test_discover_files_fixture_config(fixture_path: Path, tmp_path: Path) -> None: + """Use the real ``fixtures/bundle/`` tree as an end-to-end reproducer. + + The fixture config uses ``wifi: !include common/wifi.yaml`` — a plain + nested !include that is returned as a deferred ``IncludeFile`` and only + resolved during the substitution pass. Before this fix, bundle discovery + never ran substitutions, so ``common/wifi.yaml`` was silently missing + from the bundle. + """ + # Copy the fixture tree into a tmp dir so the test doesn't rely on the + # source repo being writable and so we can set CORE.config_path freely. + src = fixture_path / "bundle" + dst = tmp_path / "bundle" + shutil.copytree(src, dst) + + CORE.config_path = dst / "bundle_test.yaml" + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + paths = {f.path for f in files} + + # Root and top-level !secret-referenced files + assert "bundle_test.yaml" in paths + assert "secrets.yaml" in paths + # The nested !include — this is what regressed when IncludeFile became + # deferred (PR #12213). + assert "common/wifi.yaml" in paths + + # --------------------------------------------------------------------------- # ConfigBundleCreator - create_bundle # --------------------------------------------------------------------------- From 90943928702b23b5e0d452eb7d7de1b84a6c70c3 Mon Sep 17 00:00:00 2001 From: rwalker777 <49888088+rwalker777@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:01:32 -0500 Subject: [PATCH 0262/2214] [gpio] Keep interrupts enabled for gpio binary_sensor shared with deep_sleep wakeup pin (#15020) 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 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/gpio/binary_sensor/__init__.py | 88 +++++++++++++------ .../gpio/binary_sensor/gpio_binary_sensor.cpp | 5 -- 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 3c2021d40e..390b26ba1d 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -60,20 +60,35 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): - var = await binary_sensor.new_binary_sensor(config) - await cg.register_component(var, config) +def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: + """Check if pin is shared exclusively with deep_sleep (wakeup pin).""" + pin_key = (CORE.target_platform, CORE.target_platform, pin_num) + pin_users = pins.PIN_SCHEMA_REGISTRY.pins_used.get(pin_key, []) + if len(pin_users) != 2: + return False + return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) - # Check for ESP8266 GPIO16 interrupt limitation - # GPIO16 on ESP8266 is a special pin that doesn't support interrupts through - # the Arduino attachInterrupt() function. This is the only known GPIO pin - # across all supported platforms that has this limitation, so we handle it - # here instead of in the platform-specific code. +def _final_validate(config): use_interrupt = config[CONF_USE_INTERRUPT] - if use_interrupt and CORE.is_esp8266 and config[CONF_PIN][CONF_NUMBER] == 16: + if not use_interrupt: + return config + + pin_num = config[CONF_PIN][CONF_NUMBER] + + # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt + # attachment — only internal/native GPIO pins do. + if pins.PIN_SCHEMA_REGISTRY.get_key(config[CONF_PIN]) != CORE.target_platform: + _LOGGER.info( + "GPIO binary_sensor '%s': Pin is not an internal GPIO, " + "falling back to polling mode.", + config.get(CONF_NAME, config[CONF_ID]), + ) + config[CONF_USE_INTERRUPT] = False + return config + + # GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt(). + if CORE.is_esp8266 and pin_num == 16: _LOGGER.warning( "GPIO binary_sensor '%s': GPIO16 on ESP8266 doesn't support interrupts. " "Falling back to polling mode (same as in ESPHome <2025.7). " @@ -81,22 +96,45 @@ async def to_code(config): "performance with interrupts.", config.get(CONF_NAME, config[CONF_ID]), ) - use_interrupt = False + config[CONF_USE_INTERRUPT] = False + return config - # Check if pin is shared with other components (allow_other_uses) # 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 - if use_interrupt and config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False): - _LOGGER.info( - "GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared with other components. " - "The sensor will use polling mode for compatibility with other pin uses.", - config.get(CONF_NAME, config[CONF_ID]), - config[CONF_PIN][CONF_NUMBER], - ) - use_interrupt = False + # (e.g., duty_cycle sensor) that need to monitor the pin's state changes. + # Exception: deep_sleep wakeup pins are compatible with interrupts when + # the pin is only shared between this sensor and deep_sleep (count == 2). + if config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False): + if not _pin_shared_only_with_deep_sleep(pin_num): + _LOGGER.info( + "GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared " + "with other components. The sensor will use polling mode for " + "compatibility with other pin uses.", + config.get(CONF_NAME, config[CONF_ID]), + pin_num, + ) + config[CONF_USE_INTERRUPT] = False + else: + _LOGGER.debug( + "GPIO binary_sensor '%s': Pin %s is shared with deep_sleep, " + "keeping interrupts enabled.", + config.get(CONF_NAME, config[CONF_ID]), + pin_num, + ) - if use_interrupt: + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + + pin = await cg.gpio_pin_expression(config[CONF_PIN]) + cg.add(var.set_pin(pin)) + + if config[CONF_USE_INTERRUPT]: cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE])) else: - # Only generate call when disabling interrupts (default is true) - cg.add(var.set_use_interrupt(use_interrupt)) + cg.add(var.set_use_interrupt(False)) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 39b1a2f713..1f0154c70b 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -46,11 +46,6 @@ void GPIOBinarySensorStore::setup(InternalGPIOPin *pin, Component *component) { } void GPIOBinarySensor::setup() { - if (this->store_.use_interrupt_ && !this->pin_->is_internal()) { - ESP_LOGD(TAG, "GPIO is not internal, falling back to polling mode"); - this->store_.use_interrupt_ = false; - } - if (this->store_.use_interrupt_) { auto *internal_pin = static_cast(this->pin_); this->store_.setup(internal_pin, this); From 7d8add70a7b86afbbf93e9f65e34d9f72532f1eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 09:01:55 -1000 Subject: [PATCH 0263/2214] [ili9xxx] Guard against null buffer in display_() when allocation fails (#15786) --- esphome/components/ili9xxx/ili9xxx_defines.h | 2 ++ esphome/components/ili9xxx/ili9xxx_display.cpp | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/esphome/components/ili9xxx/ili9xxx_defines.h b/esphome/components/ili9xxx/ili9xxx_defines.h index f4c5aad957..70e0937f79 100644 --- a/esphome/components/ili9xxx/ili9xxx_defines.h +++ b/esphome/components/ili9xxx/ili9xxx_defines.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace esphome { namespace ili9xxx { diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index a3eff901d3..11acb8a73a 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -229,6 +229,10 @@ void ILI9XXXDisplay::update() { } void ILI9XXXDisplay::display_() { + // buffer may be null if allocation failed + if (this->buffer_ == nullptr) { + return; + } // check if something was displayed if ((this->x_high_ < this->x_low_) || (this->y_high_ < this->y_low_)) { return; From 6bb90a126838e8ce5703421e18037e93a2335218 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:07:04 -0400 Subject: [PATCH 0264/2214] [esp32] Accept unquoted minimum_chip_revision values (#15785) --- esphome/components/esp32/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7b3f9da3da..a68614cb43 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1222,7 +1222,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean, cv.Optional(CONF_MINIMUM_CHIP_REVISION): cv.one_of( - *ESP32_CHIP_REVISIONS + *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, # DHCP server is needed for WiFi AP mode. When WiFi component is used, From 627e440bd60212b065ab24cc2fbae0ece8d2abda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 09:38:49 -1000 Subject: [PATCH 0265/2214] [libretiny] Make IRAM_ATTR functional on RTL87xx and LN882H (#15766) --- esphome/components/bk72xx/__init__.py | 5 + esphome/components/libretiny/__init__.py | 17 ++ .../libretiny/generate_components.py | 5 + .../libretiny/patch_linker.py.script | 171 ++++++++++++++++++ esphome/components/ln882x/__init__.py | 5 + esphome/components/rtl87xx/__init__.py | 5 + esphome/core/application.h | 4 +- esphome/core/hal.h | 72 ++++++++ esphome/core/main_task.h | 19 +- esphome/core/wake.cpp | 6 +- esphome/core/wake.h | 28 ++- 11 files changed, 308 insertions(+), 29 deletions(-) create mode 100644 esphome/components/libretiny/patch_linker.py.script diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index 7fed742d2e..3ffab0f3a5 100644 --- a/esphome/components/bk72xx/__init__.py +++ b/esphome/components/bk72xx/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("bk72xx", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 656eee6d7b..4f42f40478 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -1,5 +1,6 @@ import json import logging +from pathlib import Path import esphome.codegen as cg import esphome.config_validation as cv @@ -24,6 +25,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.core.config import BOARD_MAX_LENGTH +from esphome.helpers import copy_file_if_changed from esphome.storage_json import StorageJSON from . import gpio # noqa @@ -465,6 +467,11 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) + # IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops). + # On other families, patch_linker.py routes .sram.text into the right + # RAM-executable output section and prints a post-link placement summary. + if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX: + cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) @@ -549,3 +556,13 @@ async def component_to_code(config): _configure_lwip(config) await cg.register_component(var, config) + + +# Called by writer.py +def copy_files() -> None: + script_dir = Path(__file__).parent + patch_linker_file = script_dir / "patch_linker.py.script" + copy_file_if_changed( + patch_linker_file, + CORE.relative_build_path("patch_linker.py"), + ) diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 41b4389446..d5437895a6 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -79,6 +79,11 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("{COMPONENT_LOWER}", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() ''' BASE_CODE_BOARDS = ''' diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script new file mode 100644 index 0000000000..282a31d3f2 --- /dev/null +++ b/esphome/components/libretiny/patch_linker.py.script @@ -0,0 +1,171 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import os +import re +import subprocess + +# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family +# section routed into RAM-executable memory (see esphome/core/hal.h). +# +# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK +# masks FIQ+IRQ around flash writes). On the remaining families: +# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. +# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - LN882H: stock linker has no glob for ".sram.text", so we inject +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). +# +# All families also get a post-link summary showing where IRAM_ATTR landed. + + +_MARKER = "/* esphome .sram.text */" +# Strong assignments (not PROVIDE) so the symbols are always emitted in the +# ELF; PROVIDE symbols with no references can be garbage-collected. +_KEEP_LINE = ( + " __esphome_sram_text_start = .; " + "KEEP(*(.sram.text*)) " + "__esphome_sram_text_end = .; " + + _MARKER + "\n" +) +_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") + + +def _detect(env): + prefix = "USE_LIBRETINY_VARIANT_" + # CPPDEFINES may hold strings or (name, value) tuples; BUILD_FLAGS holds + # the raw "-DNAME" strings. PlatformIO populates both, but the exact order + # vs. extra_scripts varies, so check both to be robust. + for token in env.get("CPPDEFINES", []): + if isinstance(token, (list, tuple)): + token = token[0] + if isinstance(token, str) and token.startswith(prefix): + return token[len(prefix):] + for flag in env.get("BUILD_FLAGS", []): + if isinstance(flag, str) and "-D" + prefix in flag: + name = flag.split("-D", 1)[1].split("=", 1)[0].strip() + if name.startswith(prefix): + return name[len(prefix):] + return None + + +KNOWN_VARIANTS = frozenset({ + "LN882H", + "RTL8710B", + "RTL8720C", +}) + + +def _inject_keep(host_section): + """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + def patch(content): + if _MARKER in content: + return content + return host_section.sub(r"\1" + _KEEP_LINE, content, count=1) + return patch + + +# Variants not listed here intentionally have no .ld patcher: +# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker +# already routes into .ram_image2.text (> BD_RAM). +# - RTL8720C: stock linker already consumes *(.sram.text*). +# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. +_PATCHERS_BY_VARIANT = { + "LN882H": (_inject_keep(_LN_COPY),), +} + + +def _patchers_for(variant): + return _PATCHERS_BY_VARIANT.get(variant, ()) + + +def _pre_link(target, source, env): + build_dir = env.subst("$BUILD_DIR") + ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] + patched = 0 + for name in ld_files: + path = os.path.join(build_dir, name) + with open(path, "r", encoding="utf-8") as fh: + original = fh.read() + if _MARKER in original: + patched += 1 + continue + content = original + for fn in _patchers: + content = fn(content) + if content != original: + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) + patched += 1 + if not patched: + raise RuntimeError( + "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " + "regex in patch_linker.py.script (_PATCHERS_BY_VARIANT).".format( + build_dir + ) + ) + + +# Substrings matched against demangled names as a fallback on RTL8720C, +# where we cannot inject __esphome_sram_text_start/end markers. +_FALLBACK_SUBSTRINGS = ("wake_loop_any_context", "wake_loop_isrsafe", + "enable_loop_soon_any_context") + + +def _post_link(target, source, env): + """Print where IRAM_ATTR ended up so users can confirm at a glance.""" + elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") + if not os.path.isfile(elf): + return + nm = env.subst("$NM") + try: + out = subprocess.check_output( + [nm, "--defined-only", "--demangle", elf], text=True + ) + except (OSError, subprocess.CalledProcessError) as exc: + print("ESPHome: IRAM_ATTR summary unavailable (nm failed: {})".format(exc)) + return + start = end = None + fallback = [] + for line in out.splitlines(): + parts = line.split(maxsplit=2) + if len(parts) != 3: + continue + addr_str, _kind, name = parts + if name == "__esphome_sram_text_start": + start = int(addr_str, 16) + elif name == "__esphome_sram_text_end": + end = int(addr_str, 16) + elif "veneer" not in name and any(s in name for s in _FALLBACK_SUBSTRINGS): + fallback.append(int(addr_str, 16)) + print("ESPHome: IRAM_ATTR placement summary ({}):".format(_variant)) + if start is not None and end is not None: + print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end)) + elif fallback: + lo, hi = min(fallback), max(fallback) + print(" IRAM symbols at 0x{:08x} - 0x{:08x} (approx {} bytes)".format(lo, hi, hi - lo)) + else: + print(" no IRAM_ATTR symbols found") + + +if (_variant := _detect(env)) is None: + raise RuntimeError( + "ESPHome: could not determine LibreTiny variant from build flags. " + "patch_linker.py needs USE_LIBRETINY_VARIANT_* to route IRAM_ATTR " + "into SRAM; without it, ISR handlers would silently end up in flash." + ) +if _variant not in KNOWN_VARIANTS: + raise RuntimeError( + "ESPHome: unknown LibreTiny variant {!r}; patch_linker.py does not " + "know how to route IRAM_ATTR into SRAM for this family. Update " + "patch_linker.py.script before shipping firmware.".format(_variant) + ) + +if _patchers := _patchers_for(_variant): + # LibreTiny writes the processed .ld templates into $BUILD_DIR during its + # own builder setup, which may run after this script. Register the patch + # as a pre-link action so it executes once the linker scripts exist. + env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) + +# Post-link summary for every family that reaches this script. +env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index 5c637bdf62..9c91827522 100644 --- a/esphome/components/ln882x/__init__.py +++ b/esphome/components/ln882x/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("ln882x", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index 6fd750d51e..a3b1dba4f2 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("rtl87xx", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/core/application.h b/esphome/core/application.h index b4bb8a1eec..7356263c55 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -337,8 +337,8 @@ class Application { /// @see esphome::wake_loop_threadsafe() in wake.h for platform details. void wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } -#ifdef USE_ESP32 - /// Wake from ISR (ESP32 only). +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + /// Wake from ISR (ESP32 and LibreTiny). static void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px) { esphome::wake_loop_isrsafe(px); } #endif diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 03a30b7459..e4083622b9 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -21,6 +21,35 @@ #define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) #define PROGMEM +#elif defined(USE_LIBRETINY) + +// IRAM_ATTR places a function in executable RAM so it is callable from an +// ISR even while flash is busy (XIP stall, OTA, logger flash write). +// Each family uses a section its stock linker already routes to RAM: +// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the +// exception: its stock linker has no matching glob, so patch_linker.py +// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// +// BK72xx (all variants) are left as a no-op: their SDK wraps flash +// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for +// the duration of every write, so no ISR fires while flash is stalled and +// the race IRAM_ATTR guards against cannot occur. The trade-off is that +// interrupts are delayed (not dropped) by up to ~20 ms during a sector +// erase, but that is an SDK-level choice and cannot be changed from this +// layer. +#if defined(USE_BK72XX) +#define IRAM_ATTR +#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) +// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). +#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) +#else +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// LN882H: patch_linker.py.script injects *(.sram.text*) into +// .flash_copysection (> RAM0 AT> FLASH). +#define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) +#endif +#define PROGMEM + #else #define IRAM_ATTR @@ -28,8 +57,51 @@ #endif +#ifdef USE_ESP32 +#include +#include +#endif + +#ifdef USE_BK72XX +// Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so +// it is callable from Thumb code via interworking. The MRS CPSR instruction +// is ARM-only and user code here may be built in Thumb, so in_isr_context() +// defers to this port helper on BK72xx instead of reading CPSR inline. +extern "C" uint32_t platform_is_in_interrupt_context(void); +#endif + namespace esphome { +/// Returns true when executing inside an interrupt handler. +/// always_inline so callers placed in IRAM keep the detection in IRAM. +__attribute__((always_inline)) inline bool in_isr_context() { +#if defined(USE_ESP32) + return xPortInIsrContext() != 0; +#elif defined(USE_ESP8266) + // ESP8266 has no reliable single-register ISR detection: PS.INTLEVEL is + // non-zero both in a real ISR and when user code masks interrupts. The + // ESP8266 wake path is context-agnostic (wake_loop_impl uses esp_schedule + // which is ISR-safe) so this helper is unused on this platform. + return false; +#elif defined(USE_RP2040) + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#elif defined(USE_BK72XX) + // BK72xx is ARM968E-S (ARM9); see extern declaration above. + return platform_is_in_interrupt_context() != 0; +#elif defined(USE_LIBRETINY) + // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; + // non-zero means we're in a handler. + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#else + // Host and any future platform without an ISR concept. + return false; +#endif +} + void yield(); uint32_t millis(); uint64_t millis_64(); diff --git a/esphome/core/main_task.h b/esphome/core/main_task.h index ed2885d2e2..3aa8669e44 100644 --- a/esphome/core/main_task.h +++ b/esphome/core/main_task.h @@ -20,7 +20,8 @@ extern "C" { extern TaskHandle_t esphome_main_task_handle; /// Wake the main loop task from another FreeRTOS task. NOT ISR-safe. -static inline void esphome_main_task_notify() { +/// always_inline so callers placed in IRAM do not reference a flash-resident copy. +__attribute__((always_inline)) static inline void esphome_main_task_notify() { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); @@ -28,26 +29,14 @@ static inline void esphome_main_task_notify() { } /// Wake the main loop task from an ISR. ISR-safe. -static inline void esphome_main_task_notify_from_isr(BaseType_t *px_higher_priority_task_woken) { +__attribute__((always_inline)) static inline void esphome_main_task_notify_from_isr( + BaseType_t *px_higher_priority_task_woken) { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken); } } -#ifdef USE_ESP32 -/// Wake the main loop from any context (ISR or task). ESP32-only (needs xPortInIsrContext). -static inline void esphome_main_task_notify_any_context() { - if (xPortInIsrContext()) { - int px_higher_priority_task_woken = 0; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); - portYIELD_FROM_ISR(px_higher_priority_task_woken); - } else { - esphome_main_task_notify(); - } -} -#endif - #ifdef __cplusplus } #endif diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index b6b59b5990..3709fa88ac 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -12,12 +12,12 @@ namespace esphome { -// === ESP32 — IRAM_ATTR entry points === -#ifdef USE_ESP32 +// === ESP32 / LibreTiny — IRAM_ATTR entry points === +#if defined(USE_ESP32) || defined(USE_LIBRETINY) void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { esphome_main_task_notify_from_isr(px_higher_priority_task_woken); } -void IRAM_ATTR wake_loop_any_context() { esphome_main_task_notify_any_context(); } +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } #endif // === ESP8266 / RP2040 === diff --git a/esphome/core/wake.h b/esphome/core/wake.h index a8c9b7ad08..5733ee65f6 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -28,17 +28,27 @@ extern volatile bool g_main_loop_woke; // === ESP32 / LibreTiny (FreeRTOS) === #if defined(USE_ESP32) || defined(USE_LIBRETINY) -#ifdef USE_ESP32 -/// IRAM_ATTR entry point — defined in wake.cpp. -void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -/// IRAM_ATTR entry point — defined in wake.cpp. -void wake_loop_any_context(); +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR + portYIELD_FROM_ISR(px_higher_priority_task_woken); #else -/// LibreTiny: IRAM_ATTR is not functional and the FreeRTOS port does not -/// provide vTaskNotifyGiveFromISR/portYIELD_FROM_ISR, so ISR-safe wake -/// is not possible. xTaskNotifyGive is used as the best available option. -inline void wake_loop_any_context() { esphome_main_task_notify(); } + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; #endif + } else { + esphome_main_task_notify(); + } +} + +/// IRAM_ATTR entry points — defined in wake.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +void wake_loop_any_context(); inline void wake_loop_threadsafe() { esphome_main_task_notify(); } From ff52bb30299c9edd0a36fbd69b3446ef309c7611 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:16:58 -0400 Subject: [PATCH 0266/2214] [lvgl] Guard lv_image_set_src wrapper with LV_USE_IMAGE (#15789) --- esphome/components/lvgl/lvgl_esphome.h | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3ba258b1a2..3ec1d247d8 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -76,16 +76,17 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { } #endif #if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -// Shortcut / overload, so that the source of an image can easily be updated -// from within a lambda. -inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { lv_image_set_src(obj, image->get_lv_image_dsc()); } +#if LV_USE_IMAGE +// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. +inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } +#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); } inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ANIMIMG From ac50f333887bb1d54cf87d5c43a8fbad02f38a07 Mon Sep 17 00:00:00 2001 From: Yves Fischer Date: Fri, 17 Apr 2026 00:27:50 +0200 Subject: [PATCH 0267/2214] Fix typo in devcontainer.json (#15791) --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5a7a02a266..29f63b54b5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,7 @@ "--privileged", "-e", "GIT_EDITOR=code --wait" - // uncomment and edit the path in order to pass though local USB serial to the conatiner + // uncomment and edit the path in order to pass through local USB serial to the container // , "--device=/dev/ttyACM0" ], "appPort": 6052, From b232fc91aba620736e8678f825f7150369c1c4f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 14:07:26 -1000 Subject: [PATCH 0268/2214] [runtime_stats] Track main loop active time and report overhead (#15743) --- .../runtime_stats/runtime_stats.cpp | 99 +++++++++++++++---- .../components/runtime_stats/runtime_stats.h | 41 ++++++++ esphome/core/application.h | 27 +++++ esphome/core/component.cpp | 4 + esphome/core/component.h | 8 ++ tests/integration/test_runtime_stats.py | 33 +++++++ 6 files changed, 193 insertions(+), 19 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 06714b5a44..9ed141155a 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -32,40 +32,101 @@ void RuntimeStatsCollector::log_stats_() { " Period stats (last %" PRIu32 "ms): %zu active components", this->log_interval_, count); - if (count == 0) { - return; + // Sum component time so we can derive main-loop overhead + // (active loop time minus time attributable to component loop()s). + // Period sum iterates the active-in-period subset; total sum must iterate + // all components since total_active_time_us_ includes iterations where + // currently-idle components previously ran. + uint64_t period_component_sum_us = 0; + for (size_t i = 0; i < count; i++) { + period_component_sum_us += sorted[i]->runtime_stats_.period_time_us; + } + uint64_t total_component_sum_us = 0; + for (auto *component : components) { + total_component_sum_us += component->runtime_stats_.total_time_us; } - // Sort by period runtime (descending) - std::sort(sorted, sorted + count, compare_period_time); + if (count > 0) { + // Sort by period runtime (descending) + std::sort(sorted, sorted + count, compare_period_time); - // Log top components by period runtime - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, - stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, - stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + // Log top components by period runtime + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, + stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, + stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + } + } + + // Main-loop overhead for the period: active wall time minus component time. + // active = sum of per-iteration loop time excluding yield/sleep. + if (this->period_active_count_ > 0) { + uint64_t active = this->period_active_time_us_; + uint64_t overhead = active > period_component_sum_us ? active - period_component_sum_us : 0; + // Use double for µs→ms conversion so multi-day uptimes (where total + // microsecond counters exceed float's ~7-digit mantissa) keep resolution. + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->period_active_count_, + static_cast(active) / static_cast(this->period_active_count_) / 1000.0, + static_cast(this->period_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); + uint64_t before = this->period_before_time_us_; + uint64_t tail = this->period_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Log total stats since boot (only for active components - idle ones haven't changed) ESP_LOGI(TAG, " Total stats (since boot): %zu active components", count); - // Re-sort by total runtime for all-time stats - std::sort(sorted, sorted + count, compare_total_time); + if (count > 0) { + // Re-sort by total runtime for all-time stats + std::sort(sorted, sorted + count, compare_total_time); - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, - stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, - stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, + stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + } + } + + if (this->total_active_count_ > 0) { + uint64_t active = this->total_active_time_us_; + uint64_t overhead = active > total_component_sum_us ? active - total_component_sum_us : 0; + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->total_active_count_, + static_cast(active) / static_cast(this->total_active_count_) / 1000.0, + static_cast(this->total_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); + uint64_t before = this->total_before_time_us_; + uint64_t tail = this->total_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Reset period stats for (auto *component : components) { component->runtime_stats_.reset_period(); } + this->period_active_count_ = 0; + this->period_active_time_us_ = 0; + this->period_active_max_us_ = 0; + this->period_before_time_us_ = 0; + this->period_tail_time_us_ = 0; } bool RuntimeStatsCollector::compare_period_time(Component *a, Component *b) { diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 3c2c9f78ad..82e0fb7c61 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -29,6 +29,31 @@ class RuntimeStatsCollector { // Process any pending stats printing (should be called after component loop) void process_pending_stats(uint32_t current_time); + // Record the wall time of one main loop iteration excluding the yield/sleep. + // Called once per loop from Application::loop(). + // active_us = total time between loop start and just before yield. + // before_us = time spent in before_loop_tasks_ (scheduler + ISR enable_loop). + // tail_us = time spent in after_loop_tasks_ + the trailing record/stats prefix. + // Residual overhead at log time = active − Σ(component) − before − tail, + // which captures per-iteration inter-component bookkeeping (set_current_component, + // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // the for-loop itself). + void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { + this->period_active_count_++; + this->period_active_time_us_ += active_us; + if (active_us > this->period_active_max_us_) + this->period_active_max_us_ = active_us; + this->total_active_count_++; + this->total_active_time_us_ += active_us; + if (active_us > this->total_active_max_us_) + this->total_active_max_us_ = active_us; + + this->period_before_time_us_ += before_us; + this->total_before_time_us_ += before_us; + this->period_tail_time_us_ += tail_us; + this->total_tail_time_us_ += tail_us; + } + protected: void log_stats_(); // Static comparators — member functions have friend access, lambdas do not @@ -37,6 +62,22 @@ class RuntimeStatsCollector { uint32_t log_interval_; uint32_t next_log_time_{0}; + + // Main loop active-time stats (wall time per iteration, excluding yield/sleep). + // Counters are uint64_t — at sub-millisecond loop times a uint32_t can wrap in + // a few weeks of uptime, which is well within ESPHome device lifetimes. + uint64_t period_active_count_{0}; + uint64_t period_active_time_us_{0}; + uint32_t period_active_max_us_{0}; + uint64_t total_active_count_{0}; + uint64_t total_active_time_us_{0}; + uint32_t total_active_max_us_{0}; + + // Split of overhead sections — accumulated per iteration. + uint64_t period_before_time_us_{0}; + uint64_t total_before_time_us_{0}; + uint64_t period_tail_time_us_{0}; + uint64_t total_tail_time_us_{0}; }; } // namespace runtime_stats diff --git a/esphome/core/application.h b/esphome/core/application.h index 7356263c55..bc40fe0c7e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -575,10 +575,25 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ } inline void ESPHOME_ALWAYS_INLINE Application::loop() { +#ifdef USE_RUNTIME_STATS + // Capture the start of the active (non-sleeping) portion of this iteration. + // Used to derive main-loop overhead = active time − Σ(component time) − + // before/tail splits recorded below. + uint32_t loop_active_start_us = micros(); + // Snapshot the cumulative component-recorded time so we can subtract the + // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // (scheduler.cpp) — that time is already counted in per-component stats, + // so charging it again to "before" would double-count. + uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; +#endif // Get the initial loop time at the start uint32_t last_op_end_time = millis(); this->before_loop_tasks_(last_op_end_time); +#ifdef USE_RUNTIME_STATS + uint32_t loop_before_end_us = micros(); + uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; +#endif for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; this->current_loop_index_++) { @@ -597,12 +612,24 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->feed_wdt_with_time(last_op_end_time); } +#ifdef USE_RUNTIME_STATS + uint32_t loop_tail_start_us = micros(); +#endif this->after_loop_tasks_(); #ifdef USE_RUNTIME_STATS // Process any pending runtime stats printing after all components have run // This ensures stats printing doesn't affect component timing measurements if (global_runtime_stats != nullptr) { + uint32_t loop_now_us = micros(); + // Subtract scheduled-component time from the "before" bucket so it is + // not double-counted (it is already attributed to per-component stats). + uint32_t loop_before_wall_us = loop_before_end_us - loop_active_start_us; + uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us + ? loop_before_wall_us - static_cast(loop_before_scheduled_us) + : 0; + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, + loop_now_us - loop_tail_start_us); global_runtime_stats->process_pending_stats(last_op_end_time); } #endif diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 8949b4b76d..e33652482e 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -506,6 +506,10 @@ void PollingComponent::stop_poller() { uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } +#ifdef USE_RUNTIME_STATS +uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + void __attribute__((noinline, cold)) WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { bool should_warn; diff --git a/esphome/core/component.h b/esphome/core/component.h index 3307c5ae76..6fbb0d5c06 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -116,6 +116,13 @@ struct ComponentRuntimeStats { uint64_t total_time_us{0}; uint32_t total_max_time_us{0}; + // Cumulative sum of every record_time() duration since boot, across all + // components. Used by Application::loop() to snapshot time spent inside + // WarnIfComponentBlockingGuard (including guards constructed by the + // scheduler at scheduler.cpp) so main-loop overhead accounting can + // subtract scheduled-callback time from the before_loop_tasks_ wall time. + static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void record_time(uint32_t duration_us) { this->period_count++; this->period_time_us += duration_us; @@ -125,6 +132,7 @@ struct ComponentRuntimeStats { this->total_time_us += duration_us; if (duration_us > this->total_max_time_us) this->total_max_time_us = duration_us; + global_recorded_us += duration_us; } void reset_period() { this->period_count = 0; diff --git a/tests/integration/test_runtime_stats.py b/tests/integration/test_runtime_stats.py index 9e93035d83..bd7f36341d 100644 --- a/tests/integration/test_runtime_stats.py +++ b/tests/integration/test_runtime_stats.py @@ -26,6 +26,7 @@ async def test_runtime_stats( # Track component stats component_stats_found = set() + main_loop_lines: list[dict[str, str]] = [] # Patterns to match - need to handle ANSI color codes and timestamps # The log format is: [HH:MM:SS][color codes][I][tag]: message @@ -34,6 +35,14 @@ async def test_runtime_stats( component_pattern = re.compile( r"^\[[^\]]+\].*?\s+([\w.]+):\s+count=(\d+),\s+avg=([\d.]+)ms" ) + # Main loop overhead line emitted by runtime_stats + main_loop_pattern = re.compile( + r"main_loop:\s+iters=(?P\d+),\s+" + r"active_avg=(?P[\d.]+)ms,\s+" + r"active_max=(?P[\d.]+)ms,\s+" + r"active_total=(?P[\d.]+)ms,\s+" + r"overhead_total=(?P[\d.]+)ms" + ) def check_output(line: str) -> None: """Check log output for runtime stats messages.""" @@ -54,6 +63,11 @@ async def test_runtime_stats( component_name = match.group(1) component_stats_found.add(component_name) + # Check for main_loop overhead line + ml_match = main_loop_pattern.search(line) + if ml_match: + main_loop_lines.append(ml_match.groupdict()) + async with ( run_compiled(yaml_config, line_callback=check_output), api_client_connected() as client, @@ -86,3 +100,22 @@ async def test_runtime_stats( assert "template.switch" in component_stats_found, ( f"Expected template.switch stats, found: {component_stats_found}" ) + + # Verify the main_loop overhead line is emitted (at least once for + # the period section and once for the total section, per log cycle). + assert len(main_loop_lines) >= 2, ( + f"Expected at least 2 main_loop lines, got {len(main_loop_lines)}" + ) + for fields in main_loop_lines: + assert int(fields["iters"]) > 0, f"iters should be > 0: {fields}" + assert float(fields["active_total"]) > 0.0, ( + f"active_total should be > 0: {fields}" + ) + assert float(fields["active_avg"]) >= 0.0, ( + f"active_avg should be >= 0: {fields}" + ) + # overhead_total is derived and may be 0 if components dominate, + # but the field must still be present and parseable as a float. + assert float(fields["overhead_total"]) >= 0.0, ( + f"overhead_total should be >= 0: {fields}" + ) From a0ac226e6c0d6888998cbda2b857c72169e042fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 14:27:39 -1000 Subject: [PATCH 0269/2214] [core] decouple main loop cadence from scheduler wake timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures Application::loop() into two independent phases to stop the scheduler from silently pulling the component loop cadence forward. Before: Application::loop() bounded its sleep by min(loop_interval_ - elapsed, next_schedule_in()) with a delay_time/2 floor. Any scheduler item due sooner than loop_interval_/2 dragged the whole component phase with it. On a typical ESP32 config with default loop_interval_=16ms, combined scheduler activity from api / esp32_ble / esp32_ble_tracker / debug was keeping every component's loop() running at ~128 Hz instead of the documented ~62 Hz. This has become more visible recently as more components convert to PollingComponent (which uses set_interval internally) and more in-tree code uses set_interval / set_timeout directly. Adding or removing any scheduled item silently changed every other component's loop cadence. App.set_loop_interval() for power savings was also silently defeated. After: - Phase A (every tick): drain wake notifications, run scheduler.call(), feed WDT - Phase B (gated by loop_interval_ or HighFrequencyLoopRequester): iterate registered components and update last_loop_ Sleep = min(time-until-next-component-phase, next_schedule_in()). When a scheduler event wakes us early, Phase A services it and the component phase stays gated independently. loop_interval_ is now a true minimum interval between component phases. The delay_time/2 floor is removed. Any legitimate need to wake faster than loop_interval_ has proper mechanisms: - HighFrequencyLoopRequester for sustained fast-loop needs - Application::wake_loop_threadsafe() from any context (new in 2026.4.0) for one-shot wake-on-event Also guards against set_interval(0) misuse — it asks the main loop to spin forever, which was never the intended API. Warns at creation time pointing authors at HighFrequencyLoopRequester. set_timeout(0)/defer() is unaffected; zero-delay one-shots remain legitimate. Runtime stats: process_pending_stats is now called on every tick (not just when the component phase runs) so log_interval_ isn't quantized to the component-phase cadence. Added an inline fast-path gate in runtime_stats.h that early-outs unless now >= next_log_time_, keeping Application::loop() slim; the log_stats_ work stays out-of-line. Ordering constraints preserved: - defer() callbacks still FIFO before components same-tick (Phase A runs before Phase B) - Scheduled items still execute before components when both due - Scheduled callbacks still run on main thread only - loop_component_start_time_ is still set fresh at each component's loop - WDT is still fed at least once per tick --- .../runtime_stats/runtime_stats.cpp | 11 +- .../components/runtime_stats/runtime_stats.h | 18 ++- esphome/core/application.cpp | 7 +- esphome/core/application.h | 117 +++++++++++------- esphome/core/scheduler.cpp | 9 ++ .../fixtures/loop_interval_decoupling.yaml | 60 +++++++++ .../test_loop_interval_decoupling.py | 73 +++++++++++ 7 files changed, 242 insertions(+), 53 deletions(-) create mode 100644 tests/integration/fixtures/loop_interval_decoupling.yaml create mode 100644 tests/integration/test_loop_interval_decoupling.py diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 9ed141155a..d733394b78 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -137,11 +137,12 @@ bool RuntimeStatsCollector::compare_total_time(Component *a, Component *b) { return a->runtime_stats_.total_time_us > b->runtime_stats_.total_time_us; } -void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) { - if ((int32_t) (current_time - this->next_log_time_) >= 0) { - this->log_stats_(); - this->next_log_time_ = current_time + this->log_interval_; - } +// Slow path for process_pending_stats — gate already checked by the inline +// wrapper in runtime_stats.h. Out-of-line keeps the log_stats_ machinery out +// of Application::loop(). +void RuntimeStatsCollector::process_pending_stats_slow_(uint32_t current_time) { + this->log_stats_(); + this->next_log_time_ = current_time + this->log_interval_; } } // namespace runtime_stats diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 82e0fb7c61..58abfa8da1 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -6,6 +6,7 @@ #include #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -26,14 +27,22 @@ class RuntimeStatsCollector { } uint32_t get_log_interval() const { return this->log_interval_; } - // Process any pending stats printing (should be called after component loop) - void process_pending_stats(uint32_t current_time); + // Process any pending stats printing. Called on every Application::loop() + // tick, so the common "not yet time to log" path must be cheap — inline + // the gate check and keep the actual logging work out-of-line. + void ESPHOME_ALWAYS_INLINE process_pending_stats(uint32_t current_time) { + if ((int32_t) (current_time - this->next_log_time_) >= 0) [[unlikely]] { + this->process_pending_stats_slow_(current_time); + } + } // Record the wall time of one main loop iteration excluding the yield/sleep. // Called once per loop from Application::loop(). // active_us = total time between loop start and just before yield. - // before_us = time spent in before_loop_tasks_ (scheduler + ISR enable_loop). - // tail_us = time spent in after_loop_tasks_ + the trailing record/stats prefix. + // before_us = time spent in Phase A (scheduler tick) excluding time + // already attributed to per-component stats. + // tail_us = time spent in after_loop_tasks_ + the trailing record/stats + // prefix. Zero on Phase A-only ticks (component phase gated). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, @@ -55,6 +64,7 @@ class RuntimeStatsCollector { } protected: + void process_pending_stats_slow_(uint32_t current_time); void log_stats_(); // Static comparators — member functions have friend access, lambdas do not static bool compare_period_time(Component *a, Component *b); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 866edebbf6..4fc28e092e 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -93,8 +93,11 @@ void Application::setup() { do { uint32_t now = millis(); - // Process pending loop enables to handle GPIO interrupts during setup - this->before_loop_tasks_(now); + // Service scheduler and process pending loop enables to handle GPIO + // interrupts during setup. During setup we always run the component + // phase (no loop_interval_ gate), so call both helpers unconditionally. + this->scheduler_tick_(now); + this->before_component_phase_(); for (uint32_t j = 0; j <= i; j++) { // Update loop_component_start_time_ right before calling each component diff --git a/esphome/core/application.h b/esphome/core/application.h index bc40fe0c7e..c5b6a87817 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -399,7 +399,8 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline void ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); + inline void ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); + inline void ESPHOME_ALWAYS_INLINE before_component_phase_(); inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; } /// Process dump_config output one component per loop iteration. @@ -543,19 +544,22 @@ inline void Application::drain_wake_notifications_() { } #endif // USE_HOST -inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { +// Phase A: drain wake notifications and run the scheduler. Invoked on every +// Application::loop() tick regardless of whether a component phase runs, so +// scheduler items fire at their requested cadence even when the caller has +// raised loop_interval_ for power savings (see Application::loop()). +inline void ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif + this->scheduler.call(now); +} - // Process scheduled tasks. Scheduler::call now feeds the watchdog itself - // after each scheduled item that actually runs, so we no longer need an - // unconditional feed here — when Scheduler::call has no work to do, the - // only elapsed time is a sleep wake + a few instructions, and when it does - // have work, it fed the wdt as it went. - this->scheduler.call(loop_start_time); - +// Phase B entry: only invoked when a component loop phase is about to run. +// Processes pending enable_loop requests from ISRs and marks in_loop_ so +// reentrant modifications during component.loop() are safe. +inline void ESPHOME_ALWAYS_INLINE Application::before_component_phase_() { // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions if (this->has_pending_enable_loop_requests_) { @@ -586,40 +590,70 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; #endif - // Get the initial loop time at the start - uint32_t last_op_end_time = millis(); + uint32_t now = millis(); + + // Phase A: always service the scheduler. Decouples scheduler cadence from + // loop_interval_ so raised intervals (for power savings) don't drag scheduled + // items forward. A tick that only runs the scheduler is cheap. + this->scheduler_tick_(now); + // Guarantee one WDT feed per tick even when the scheduler had nothing to + // dispatch and the component phase is gated out (setup() has its own + // per-component feed_wdt calls, so only do this here, not in scheduler_tick_). + this->feed_wdt_with_time(now); - this->before_loop_tasks_(last_op_end_time); #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; + // Default tail_start to end-of-before so tail_us == 0 on Phase A-only ticks. + uint32_t loop_tail_start_us = loop_before_end_us; #endif - for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; - this->current_loop_index_++) { - Component *component = this->looping_components_[this->current_loop_index_]; + // Gate the component phase on loop_interval_ or an active high-frequency + // request. When a scheduler wake preempts sleep early, this gate keeps the + // component phase from running more often than loop_interval_. + // We deliberately reuse the pre-scheduler `now` rather than refreshing it + // via another millis() call: empty scheduler.call() is sub-microsecond, and + // any scheduled item that takes long enough to matter is a blocking + // violation that will self-correct on the next tick anyway. The one saved + // millis() call per tick is worth that acceptable one-tick drift. + const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency(); + const uint32_t elapsed = now - this->last_loop_; + const bool do_component_phase = high_frequency || (elapsed >= this->loop_interval_); - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; + if (do_component_phase) { + this->before_component_phase_(); - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; - component->loop(); - // Use the finish method to get the current time as the end time - last_op_end_time = guard.finish(); + uint32_t last_op_end_time = now; + for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; + this->current_loop_index_++) { + Component *component = this->looping_components_[this->current_loop_index_]; + + // Update the cached time before each component runs + this->loop_component_start_time_ = last_op_end_time; + + { + this->set_current_component(component); + WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + component->loop(); + // Use the finish method to get the current time as the end time + last_op_end_time = guard.finish(); + } + this->feed_wdt_with_time(last_op_end_time); } - this->feed_wdt_with_time(last_op_end_time); + +#ifdef USE_RUNTIME_STATS + loop_tail_start_us = micros(); +#endif + this->after_loop_tasks_(); + + this->last_loop_ = last_op_end_time; + now = last_op_end_time; } #ifdef USE_RUNTIME_STATS - uint32_t loop_tail_start_us = micros(); -#endif - this->after_loop_tasks_(); - -#ifdef USE_RUNTIME_STATS - // Process any pending runtime stats printing after all components have run - // This ensures stats printing doesn't affect component timing measurements + // Record per-tick timing on every loop, not just component-phase ticks. + // record_loop_active is a small accumulator; process_pending_stats is an + // inline gate check that early-outs unless now >= next_log_time_. if (global_runtime_stats != nullptr) { uint32_t loop_now_us = micros(); // Subtract scheduled-component time from the "before" bucket so it is @@ -630,23 +664,22 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { : 0; global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, loop_now_us - loop_tail_start_us); - global_runtime_stats->process_pending_stats(last_op_end_time); + global_runtime_stats->process_pending_stats(now); } #endif - // Use the last component's end time instead of calling millis() again + // Compute sleep: bounded by time-until-next-component-phase and the + // scheduler's next deadline. When a scheduler event wakes us early we + // re-enter loop(), Phase A services it, and the component phase stays gated. uint32_t delay_time = 0; - auto elapsed = last_op_end_time - this->last_loop_; - if (elapsed < this->loop_interval_ && !HighFrequencyLoopRequester::is_high_frequency()) { - delay_time = this->loop_interval_ - elapsed; - uint32_t next_schedule = this->scheduler.next_schedule_in(last_op_end_time).value_or(delay_time); - // next_schedule is max 0.5*delay_time - // otherwise interval=0 schedules result in constant looping with almost no sleep - next_schedule = std::max(next_schedule, delay_time / 2); - delay_time = std::min(next_schedule, delay_time); + if (!high_frequency) { + const uint32_t elapsed_since_phase = now - this->last_loop_; + const uint32_t until_phase = + (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); + const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); + delay_time = std::min(until_phase, until_sched); } this->yield_with_select_(delay_time); - this->last_loop_ = last_op_end_time; if (this->dump_config_at_ < this->components_.size()) { this->process_dump_config_(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 3e75a68064..fdb96c7343 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -144,6 +144,15 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } + // An interval of 0 means "fire every tick forever," which is misuse: it + // asks the main loop to spin unbounded. The correct mechanism for running + // fast in the main loop is HighFrequencyLoopRequester, not a zero-delay + // interval. Zero-delay timeouts (defer) remain legitimate one-shots. + if (type == SchedulerItem::INTERVAL && delay == 0) [[unlikely]] { + ESP_LOGW(TAG, "[%s] set_interval(0) is misuse — use HighFrequencyLoopRequester", + component ? LOG_STR_ARG(component->get_component_log_str()) : LOG_STR_LITERAL("?")); + } + // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check LockGuard guard{this->lock_}; diff --git a/tests/integration/fixtures/loop_interval_decoupling.yaml b/tests/integration/fixtures/loop_interval_decoupling.yaml new file mode 100644 index 0000000000..5aedd9aba5 --- /dev/null +++ b/tests/integration/fixtures/loop_interval_decoupling.yaml @@ -0,0 +1,60 @@ +esphome: + name: loop-interval-decouple + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 500ms. With the decoupling fix the + // component phase should run ~twice per second while the 50ms + // scheduler interval below still fires at its requested cadence. + App.set_loop_interval(500); + # Start measurement after 1s so boot transients settle. + - delay: 1000ms + - lambda: |- + id(loop_at_start) = id(loop_counter)->get_loop_count(); + id(sched_at_start) = id(sched_count); + ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d sched=%d", + id(loop_at_start), id(sched_at_start)); + # Observe for 2s. + - delay: 2000ms + - lambda: |- + int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start); + int sched_delta = id(sched_count) - id(sched_at_start); + ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d sched_delta=%d", + loop_delta, sched_delta); + +host: +api: +logger: + level: INFO + logs: + loop_test_component: WARN # Silence per-loop log spam + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +globals: + - id: sched_count + type: int + initial_value: "0" + - id: loop_at_start + type: int + initial_value: "0" + - id: sched_at_start + type: int + initial_value: "0" + +loop_test_component: + components: + - id: loop_counter + name: loop_counter + +interval: + # Fast scheduler interval — with the decoupling fix this should fire at + # its requested 50ms cadence regardless of loop_interval_. + - interval: 50ms + then: + - lambda: |- + id(sched_count) += 1; diff --git a/tests/integration/test_loop_interval_decoupling.py b/tests/integration/test_loop_interval_decoupling.py new file mode 100644 index 0000000000..1cf8bf477d --- /dev/null +++ b/tests/integration/test_loop_interval_decoupling.py @@ -0,0 +1,73 @@ +"""Test that loop_interval_ no longer clamps scheduler cadence. + +Regression test for the decoupling of Application::loop() component-phase +cadence from scheduler wake timing. + +Setup: +- App.set_loop_interval(500) — raised for power-savings style cadence +- Scheduler interval at 50ms — should fire at 50ms regardless of loop_interval_ +- Component loop (LoopTestComponent) — should run at 500ms cadence + +Before the decoupling fix the old `std::max(next_schedule, delay_time / 2)` +floor clamped the sleep to ~250ms, so the 50ms scheduler only fired ~8 times +per 2s (vs the ~40 expected). After the fix the scheduler fires close to its +requested cadence while the component phase stays gated at loop_interval_. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_loop_interval_decoupling( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Raised loop_interval_ must not clamp scheduler item cadence.""" + loop = asyncio.get_running_loop() + measurement_done: asyncio.Future[tuple[int, int]] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)", line) + if match and not measurement_done.done(): + measurement_done.set_result((int(match.group(1)), int(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "loop-interval-decouple" + + try: + loop_delta, sched_delta = await asyncio.wait_for( + measurement_done, timeout=10.0 + ) + except TimeoutError: + pytest.fail("MEASUREMENT_DONE marker never appeared") + + # Observation window = 2s, loop_interval_ = 500ms. + # Component phase should fire ~4 times in 2s. The upper bound must be + # less than 8: the pre-decoupling behavior clamped to ~250ms cadence + # giving ~8 loops/2s, so allowing 8 would let the old behavior pass. + assert 2 <= loop_delta <= 6, ( + f"Component loop should fire ~4 times in 2s at loop_interval=500ms, " + f"got {loop_delta}" + ) + + # Scheduler interval = 50ms → ~40 fires in 2s. Before the decoupling + # fix this clamped to ~8 fires. Assert >= 20 to catch the old clamped + # behavior with comfortable jitter headroom for slow CI hosts. + assert sched_delta >= 20, ( + f"50ms scheduler interval should fire ~40 times in 2s but only " + f"fired {sched_delta}. This indicates loop_interval_ is still " + f"clamping scheduler cadence." + ) From 71636ba8a1ddde159d8610ea746dc742d0fca2af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 17:34:05 -1000 Subject: [PATCH 0270/2214] [core] coerce set_interval(0) to 1ms to prevent scheduler spin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users (notably PollingComponents with update_interval: 0ms) have historically relied on set_interval(0) as a pseudo-loop() mechanism. A literal interval=0 causes Scheduler::call() to spin — the item is always 'due now' after re-scheduling, so the scheduler loop never returns, starves the main loop, and triggers a WDT reset in the field. Coerce interval=0 to 1ms at creation so existing code keeps working at ~1kHz instead of spinning, while still emitting the warning pointing authors at HighFrequencyLoopRequester (the intended mechanism for running fast in the main loop). Zero-delay timeouts (defer/set_timeout) remain legitimate one-shots and are unaffected — defer is a one-shot, not a spin risk. --- esphome/core/scheduler.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index fdb96c7343..168230c274 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -144,13 +144,17 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } - // An interval of 0 means "fire every tick forever," which is misuse: it - // asks the main loop to spin unbounded. The correct mechanism for running - // fast in the main loop is HighFrequencyLoopRequester, not a zero-delay - // interval. Zero-delay timeouts (defer) remain legitimate one-shots. + // An interval of 0 means "fire every tick forever," which is misuse: the + // item would always be due, causing Scheduler::call() to spin and starve + // the main loop (WDT reset in the field). Coerce to 1ms so existing code + // using update_interval=0ms as a pseudo-loop() continues to work at ~1kHz, + // and warn so authors can migrate to HighFrequencyLoopRequester which is + // the intended mechanism for running fast in the main loop. Zero-delay + // timeouts (defer) remain legitimate one-shots and are not affected. if (type == SchedulerItem::INTERVAL && delay == 0) [[unlikely]] { - ESP_LOGW(TAG, "[%s] set_interval(0) is misuse — use HighFrequencyLoopRequester", + ESP_LOGW(TAG, "[%s] set_interval(0) is misuse — coercing to 1ms. Use HighFrequencyLoopRequester instead.", component ? LOG_STR_ARG(component->get_component_log_str()) : LOG_STR_LITERAL("?")); + delay = 1; } // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check From cfe8c0eeee0fa61fa40c1d47a89203b4bdee0f1a Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Fri, 17 Apr 2026 12:20:55 +0200 Subject: [PATCH 0271/2214] [wireguard] Bump esp_wireguard to 0.4.5 for ESP-IDF v6 (#15804) --- .clang-tidy.hash | 2 +- esphome/components/wireguard/__init__.py | 2 +- platformio.ini | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index ab526134f8..72a9967590 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -dc8ad5472d9fb44ce1ca29a0601afd65705642799a2819704dfc8459fbaf9815 +075ed2142432dc59883bb52db8ac11270f952851d6400deae080f5468c7cb592 diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 4fdb256d0c..e128b8476d 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -137,7 +137,7 @@ async def to_code(config): # the '+1' modifier is relative to the device's own address that will # be automatically added to the provided list. cg.add_build_flag(f"-DCONFIG_WIREGUARD_MAX_SRC_IPS={len(allowed_ips) + 1}") - cg.add_library("droscy/esp_wireguard", "0.4.4") + cg.add_library("droscy/esp_wireguard", "0.4.5") await cg.register_component(var, config) diff --git a/platformio.ini b/platformio.ini index 3897db83e1..e2c7e2b097 100644 --- a/platformio.ini +++ b/platformio.ini @@ -118,7 +118,7 @@ lib_deps = ESP8266HTTPClient ; http_request (Arduino built-in) ESP8266mDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard lvgl/lvgl@9.5.0 ; lvgl build_flags = @@ -154,7 +154,7 @@ lib_deps = DNSServer ; captive_portal (Arduino built-in) makuna/NeoPixelBus@2.8.0 ; neopixelbus esphome/ESP32-audioI2S@2.3.0 ; i2s_audio - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word build_flags = @@ -176,7 +176,7 @@ platform_packages = framework = espidf lib_deps = ${common:idf.lib_deps} - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = @@ -221,7 +221,7 @@ lib_compat_mode = soft lib_deps = bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} From 6a46437a5f4f4a74c61943c02759d0209e43f9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 17 Apr 2026 13:58:39 +0300 Subject: [PATCH 0272/2214] [wifi] Guard retry_phase_to_log_string with log level check to fix warning (#15801) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/wifi/wifi_component.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 7b31a22ed5..598aee8f66 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -308,6 +308,7 @@ bool CompactString::operator==(const StringRef &other) const { /// │ - Roaming fail (RECONNECTING on other AP): counter preserved │ /// └──────────────────────────────────────────────────────────────────────┘ +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO // Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { if (phase == WiFiRetryPhase::INITIAL_CONNECT) @@ -326,6 +327,7 @@ static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { return LOG_STR("RESTARTING"); return LOG_STR("UNKNOWN"); } +#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO bool WiFiComponent::went_through_explicit_hidden_phase_() const { // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase From 1a529a62aad1bafd3e658cddf980376b3123dfa1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:17:16 +1000 Subject: [PATCH 0273/2214] [mipi_spi] Drawing fixes for native display (#15802) --- esphome/components/mipi_spi/display.py | 6 +- esphome/components/mipi_spi/mipi_spi.h | 14 +- .../mipi_spi/test_final_validate.py | 185 ++++++++++++++++++ 3 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_final_validate.py diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 42c7ec2224..364ada9046 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -195,7 +195,7 @@ def model_schema(config): "big_endian", "little_endian", lower=True ), model.option(CONF_COLOR_DEPTH, 16): cv.one_of(*color_depth, lower=True), - model.option(CONF_DRAW_ROUNDING, 2): power_of_two, + model.option(CONF_DRAW_ROUNDING, 1): power_of_two, model.option(CONF_PIXEL_MODE, DISPLAY_16BIT): cv.one_of( *pixel_modes, lower=True ), @@ -297,9 +297,9 @@ def _final_validate(config): buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here - fraction = min(20000.0, buffer_size // 16) / buffer_size + fraction = min(20000.0, buffer_size // 4) / buffer_size config[CONF_BUFFER_SIZE] = 1.0 / next( - x for x in range(2, 17) if fraction >= 1 / x + (x for x in range(2, 8) if fraction >= 1 / x), 8 ) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2242be6c17..f292345893 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -546,13 +546,12 @@ class MipiSpiBuffer : public MipiSpistart_line_ = 0; this->start_line_ < this->get_height_internal(); - this->start_line_ += this->get_height_internal() / FRACTION) { + auto increment = (this->get_height_internal() / FRACTION / ROUNDING) * ROUNDING; + for (this->start_line_ = 0; this->start_line_ < this->get_height_internal(); this->start_line_ = this->end_line_) { #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE auto lap = millis(); #endif - this->end_line_ = - clamp_at_most(this->start_line_ + this->get_height_internal() / FRACTION, this->get_height_internal()); + this->end_line_ = clamp_at_most(this->start_line_ + increment, this->get_height_internal()); if (this->auto_clear_enabled_) { this->clear(); } @@ -574,12 +573,13 @@ class MipiSpiBuffer : public MipiSpix_low_ = this->x_low_ / ROUNDING * ROUNDING; this->y_low_ = this->y_low_ / ROUNDING * ROUNDING; - this->x_high_ = (this->x_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; - this->y_high_ = (this->y_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; + this->x_high_ = round_buffer(this->x_high_ + 1) - 1; + this->y_high_ = clamp_at_most(round_buffer(this->y_high_ + 1) - 1, this->end_line_ - 1); int w = this->x_high_ - this->x_low_ + 1; int h = this->y_high_ - this->y_low_ + 1; this->write_to_display_(this->x_low_, this->y_low_, w, h, this->buffer_, this->x_low_, - this->y_low_ - this->start_line_, round_buffer(this->get_width_internal()) - w); + this->y_low_ - this->start_line_, + round_buffer(this->get_width_internal()) - w - this->x_low_); // invalidate watermarks this->x_low_ = this->get_width_internal(); this->y_low_ = this->get_height_internal(); diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py new file mode 100644 index 0000000000..8c45b47752 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -0,0 +1,185 @@ +"""Tests for the _final_validate buffer size calculation in mipi_spi.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.display import CONF_SHOW_TEST_CARD +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def _custom_config( + width: int, + height: int, + color_depth: str | int | None = None, + **extra: Any, +) -> ConfigType: + """Build a minimal valid custom-model config with the given dimensions.""" + config: ConfigType = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": width, "height": height}, + "init_sequence": [[0xA0, 0x01]], + } + if color_depth is not None: + config["color_depth"] = color_depth + config.update(extra) + return config + + +# The auto buffer-size selection inside _final_validate targets ~20 kB of +# pixel buffer. For a buffer of ``depth_bytes * width * height``, it picks the +# smallest integer ``x`` in range(2, 8) such that +# ``min(20000, buffer // 4) / buffer >= 1 / x`` (falling back to ``x = 8``). +# The test cases below cover the full range of possible outcomes (1/4 .. 1/8). +@pytest.mark.parametrize( + ("width", "height", "color_depth", "expected"), + [ + # 16-bit color depth -- buffer = 2 * width * height + # 128*160*2 = 40960 B -> fraction = 10240/40960 = 0.25 -> x = 4 + pytest.param(128, 160, "16bit", 1.0 / 4, id="16bit_tiny"), + # 200*224*2 = 89600 B -> fraction = 20000/89600 ≈ 0.2232 -> x = 5 + pytest.param(200, 224, "16bit", 1.0 / 5, id="16bit_small"), + # 240*224*2 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(240, 224, "16bit", 1.0 / 6, id="16bit_medium"), + # 200*320*2 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(200, 320, "16bit", 1.0 / 7, id="16bit_large"), + # 240*320*2 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(240, 320, "16bit", 1.0 / 8, id="16bit_xlarge"), + # 320*480*2 = 307200 B -> fraction ≈ 0.0651 -> default x = 8 + pytest.param(320, 480, "16bit", 1.0 / 8, id="16bit_huge"), + # 8-bit color depth -- buffer = width * height + # 320*240 = 76800 B -> fraction = 19200/76800 = 0.25 -> x = 4 + pytest.param(320, 240, "8bit", 1.0 / 4, id="8bit_tiny"), + # 400*224 = 89600 B -> fraction ≈ 0.2232 -> x = 5 + pytest.param(400, 224, "8bit", 1.0 / 5, id="8bit_small"), + # 480*224 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(480, 224, "8bit", 1.0 / 6, id="8bit_medium"), + # 400*320 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(400, 320, "8bit", 1.0 / 7, id="8bit_large"), + # 480*320 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(480, 320, "8bit", 1.0 / 8, id="8bit_xlarge"), + ], +) +def test_buffer_size_auto_selected( + width: int, + height: int, + color_depth: str, + expected: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """Without PSRAM or an explicit buffer_size, a fraction is chosen from the display size. + + Without any drawing method and without LVGL, final validation also auto-enables + ``show_test_card``, which in turn makes the component require a buffer and therefore + triggers the buffer-size selection path. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated(_custom_config(width, height, color_depth)) + + # Sanity check: final validation should have enabled the test card for us, + # which is what causes the buffer-size calculation to actually run. + assert config.get(CONF_SHOW_TEST_CARD) is True + assert config[CONF_BUFFER_SIZE] == pytest.approx(expected) + + +@pytest.mark.parametrize( + "buffer_size", + [0.125, 0.25, 0.5, 1.0], + ids=["one_eighth", "one_quarter", "half", "full"], +) +def test_explicit_buffer_size_is_preserved( + buffer_size: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """An explicitly configured buffer_size is never overridden by final validation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated( + _custom_config(240, 320, "16bit", buffer_size=buffer_size), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(buffer_size) + + +def test_buffer_size_not_set_when_psram_enabled( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """When PSRAM is enabled the auto buffer-size selection is skipped.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + # Presence of the psram domain in the full config is what _final_validate checks. + set_component_config("psram", True) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + + +def test_buffer_size_not_set_when_buffer_not_required( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """With LVGL present and no drawing methods, no buffer fraction is chosen. + + LVGL suppresses the automatic show_test_card injection, which means + ``requires_buffer`` is False and the early-return branch fires. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + # And no test card should have been auto-enabled either. + assert not config.get(CONF_SHOW_TEST_CARD) + + +def test_buffer_size_selected_when_lvgl_with_test_card( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """LVGL present + an explicit drawing method still triggers buffer sizing. + + When LVGL is enabled, ``show_test_card`` is not injected automatically, + but users can still request it explicitly -- in that case ``requires_buffer`` + is True and the buffer-size heuristic still runs. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + # 128x160 @ 16bit -> expected 1/4 (see test_buffer_size_auto_selected). + config = _validated( + _custom_config(128, 160, "16bit", show_test_card=True), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) From b018ac67bcfdf8e8f826af385c8dd65aed9d41b0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:11:05 +1000 Subject: [PATCH 0274/2214] [image] Fix byte order handling (#15800) --- esphome/components/image/__init__.py | 17 ++++++++++------- esphome/components/image/image.cpp | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 4a5fcc385e..7db50597e6 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -28,7 +28,6 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.final_validate import full_config _LOGGER = logging.getLogger(__name__) @@ -676,12 +675,16 @@ def _final_validate(config): :param config: :return: """ - fv = full_config.get() - if "lvgl" in fv and not all(CONF_BYTE_ORDER in x for x in config): - config = config.copy() - for c in config: - if not c.get(CONF_BYTE_ORDER): - c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + config = config.copy() + for c in config: + if byte_order := c.get(CONF_BYTE_ORDER): + if byte_order == "BIG_ENDIAN": + _LOGGER.warning( + "The image '%s' is configured with big-endian byte order, little-endian is expected", + c.get(CONF_FILE), + ) + else: + c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" return config diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index a6f9e35e2e..5b4ed6968c 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -189,7 +189,7 @@ Color Image::get_rgb_pixel_(int x, int y) const { } Color Image::get_rgb565_pixel_(int x, int y) const { const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8; - uint16_t rgb565 = encode_uint16(progmem_read_byte(pos), progmem_read_byte(pos + 1)); + uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos)); auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; From 523c6f2376e491fe5f4391041ff9e0039ab3eb7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 02:45:50 -1000 Subject: [PATCH 0275/2214] [core] coerce set_interval(0) / update_interval: 0ms to 1ms (#15799) --- esphome/config_validation.py | 21 +++++- esphome/core/scheduler.cpp | 13 ++++ .../scheduler_interval_zero_coerced.yaml | 27 ++++++++ .../test_scheduler_interval_zero_coerced.py | 67 +++++++++++++++++++ tests/unit_tests/test_config_validation.py | 28 ++++++++ 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/scheduler_interval_zero_coerced.yaml create mode 100644 tests/integration/test_scheduler_interval_zero_coerced.py diff --git a/esphome/config_validation.py b/esphome/config_validation.py index e6b0cb7ee2..fbafc5cb07 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -943,7 +943,26 @@ def time_period_in_minutes_(value): def update_interval(value): if value == "never": return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) - return positive_time_period_milliseconds(value) + result = positive_time_period_milliseconds(value) + # 0ms was historically (mis)used as a pseudo-loop() mechanism for + # PollingComponents. Under the hood it calls set_interval(0), which + # causes Scheduler::call() to spin (WDT reset in the field). Coerce + # to 1ms so existing configs keep working at ~1kHz instead of + # spinning. Don't hard-fail so configs don't break on upgrade; + # authors should migrate to HighFrequencyLoopRequester (C++) for + # true run-every-loop behaviour. + if result.total_milliseconds == 0: + _LOGGER.warning( + "update_interval of 0ms is not supported - coercing to 1ms. " + "A literal 0ms schedule would spin the main loop (the scheduled " + "item would always be due, so the scheduler would never yield " + "back) and trigger a watchdog reset. Set update_interval to a " + "non-zero value such as 1ms or higher. (Custom C++ components " + "that need true run-every-loop behaviour should override loop() " + "with HighFrequencyLoopRequester instead.)" + ) + return TimePeriodMilliseconds(milliseconds=1) + return result time_period = Any(time_period_str_unit, time_period_str_colon, time_period_dict) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 3e75a68064..7e6ad19ac7 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -144,6 +144,19 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } + // An interval of 0 means "fire every tick forever," which is misuse: the + // item would always be due, causing Scheduler::call() to spin and starve + // the main loop (WDT reset in the field). Coerce to 1ms so existing code + // using update_interval=0ms as a pseudo-loop() continues to work at ~1kHz, + // and warn so authors can migrate to HighFrequencyLoopRequester which is + // the intended mechanism for running fast in the main loop. Zero-delay + // timeouts (defer) remain legitimate one-shots and are not affected. + if (type == SchedulerItem::INTERVAL && delay == 0) [[unlikely]] { + ESP_LOGE(TAG, "[%s] set_interval(0) would spin main loop - coercing to 1ms (use HighFrequencyLoopRequester)", + component ? LOG_STR_ARG(component->get_component_log_str()) : LOG_STR_LITERAL("?")); + delay = 1; + } + // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check LockGuard guard{this->lock_}; diff --git a/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml new file mode 100644 index 0000000000..13be55d617 --- /dev/null +++ b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml @@ -0,0 +1,27 @@ +esphome: + name: sched-interval-zero + +host: +api: +logger: + level: DEBUG + +globals: + - id: fire_count + type: int + initial_value: "0" + +interval: + # Deliberately configure 0ms — this path goes through the C++ + # Scheduler::set_timer_common_ coercion (not the Python cv.update_interval + # path, since interval: doesn't call cv.update_interval — it's an intervals + # component schema, not a PollingComponent's update_interval). + # Expected: scheduler coerces to 1ms at registration, emits ESP_LOGE, + # fires at ~1kHz instead of spinning. + - interval: 0ms + then: + - lambda: |- + id(fire_count) += 1; + if (id(fire_count) == 50) { + ESP_LOGI("test", "ZERO_INTERVAL_50_FIRES_REACHED"); + } diff --git a/tests/integration/test_scheduler_interval_zero_coerced.py b/tests/integration/test_scheduler_interval_zero_coerced.py new file mode 100644 index 0000000000..f71c0f7281 --- /dev/null +++ b/tests/integration/test_scheduler_interval_zero_coerced.py @@ -0,0 +1,67 @@ +"""Test that Scheduler::set_timer_common_ coerces interval=0 to 1ms. + +Regression test for the scheduler busy-loop when interval=0 was passed +literally. Without the coercion, Scheduler::call() would spin forever +because the item's next_execution == now_64 after re-scheduling, failing +the loop's `> now_64` break condition. The device would fail to yield +back to the main loop and trigger a WDT reset. + +With the coercion, interval=0 becomes interval=1 and the scheduler +fires at ~1kHz (bounded by the loop), the main loop continues to run, +and the device stays responsive to API calls. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_interval_zero_coerced( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """interval=0ms must be coerced to 1ms and not starve the main loop.""" + loop = asyncio.get_running_loop() + reached_50: asyncio.Future[None] = loop.create_future() + coerce_warning: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "ZERO_INTERVAL_50_FIRES_REACHED" in line and not reached_50.done(): + reached_50.set_result(None) + if "would spin main loop" in line and not coerce_warning.done(): + coerce_warning.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + # The API-client connection itself is evidence that the main loop + # is not starved — if set_interval(0) were spinning we could not + # get here at all. + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "sched-interval-zero" + + # Coerce warning must fire at registration + try: + await asyncio.wait_for(coerce_warning, timeout=5.0) + except TimeoutError: + pytest.fail("Expected coerce warning 'would spin main loop' not seen") + + # The coerced 1ms interval should fire 50 times quickly — this + # confirms the callback actually runs (not just registered) and the + # scheduler yields back to the main loop each time. + try: + await asyncio.wait_for(reached_50, timeout=5.0) + except TimeoutError: + pytest.fail( + "Coerced interval=0→1ms did not reach 50 fires within 5s, " + "which would indicate either the coercion failed or the " + "main loop is still being starved." + ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ac84ce7cc8..f038272d8b 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -24,6 +24,7 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, + SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda @@ -765,3 +766,30 @@ def test_percentage_validators__raw_number_above_one_without_percent_sign( config_validation.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): config_validation.unbounded_possibly_negative_percentage(value) + + +def test_update_interval__coerces_zero_to_one_ms( + caplog: pytest.LogCaptureFixture, +) -> None: + """update_interval: 0ms must be coerced to 1ms (not rejected) because a + literal 0ms schedule causes Scheduler::call() to spin. Coercion keeps + existing configs compiling on upgrade while emitting a user-facing + warning that directs them to set a non-zero value.""" + with caplog.at_level("WARNING"): + result = config_validation.update_interval("0ms") + assert result.total_milliseconds == 1 + assert "update_interval of 0ms is not supported" in caplog.text + assert "1ms" in caplog.text + + +def test_update_interval__preserves_nonzero_values() -> None: + """Non-zero update_interval values must pass through unchanged.""" + assert config_validation.update_interval("1ms").total_milliseconds == 1 + assert config_validation.update_interval("50ms").total_milliseconds == 50 + assert config_validation.update_interval("60s").total_milliseconds == 60000 + + +def test_update_interval__never_passes_through() -> None: + """update_interval: never must still map to SCHEDULER_DONT_RUN.""" + result = config_validation.update_interval("never") + assert result.total_milliseconds == SCHEDULER_DONT_RUN From d4fe46bb24be368e882aa3b78655edc95d82ed6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 02:46:12 -1000 Subject: [PATCH 0276/2214] [core] Expose App.wake_loop_isrsafe() on ESP8266 (#15797) --- esphome/core/application.h | 3 +++ esphome/core/wake.h | 3 +++ 2 files changed, 6 insertions(+) diff --git a/esphome/core/application.h b/esphome/core/application.h index bc40fe0c7e..82f399b2d6 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -340,6 +340,9 @@ class Application { #if defined(USE_ESP32) || defined(USE_LIBRETINY) /// Wake from ISR (ESP32 and LibreTiny). static void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px) { esphome::wake_loop_isrsafe(px); } +#elif defined(USE_ESP8266) + /// Wake from ISR (ESP8266). No task_woken arg — no FreeRTOS. Caller must be IRAM_ATTR. + static void IRAM_ATTR ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { esphome::wake_loop_isrsafe(); } #endif /// Wake from any context (ISR, thread, callback). diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 5733ee65f6..77a38d429e 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -77,6 +77,9 @@ void wake_loop_any_context(); /// Non-ISR: always inline. inline void wake_loop_threadsafe() { wake_loop_impl(); } +/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. +inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } + namespace internal { inline void wakeable_delay(uint32_t ms) { if (ms == 0) { From 7f4654eec4c7d6eb543f4b3aa841ea35539cd974 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 08:25:30 -0500 Subject: [PATCH 0277/2214] [core] Fix DelayAction compile error with non-const reference args The lambda in DelayAction::play_complex must be `mutable` so captured copies of non-const reference arguments (e.g. std::string& from the http_request on_response trigger) can bind to the non-const reference parameters of play_next_(const Ts&...). Without `mutable`, the captured members are const-qualified and cannot bind to `T&`. Regression from #14968 which replaced std::bind with a lambda. Fixes https://github.com/esphome/esphome/issues/15808 --- esphome/core/base_automation.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 11133d3973..17f937d10d 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -205,7 +205,9 @@ template class DelayAction : public Action, public Compon } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay - auto f = [this, x...]() { this->play_next_(x...); }; + // `mutable` is required so captured copies of non-const reference args (e.g. std::string&) + // are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&` + auto f = [this, x...]() mutable { this->play_next_(x...); }; App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(x...), std::move(f), From bcbfc843ae77ec72af2bc2d80b070c07923d8096 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:05:30 -0400 Subject: [PATCH 0278/2214] [ethernet] Fix SPI3_HOST default breaking compile on variants without SPI3 (#15809) Co-authored-by: J. Nick Koston --- .../components/ethernet/ethernet_component.h | 2 +- .../ethernet/test.esp32-c3-idf.yaml | 19 +++++++++++++++++++ ...720.esp32-idf.yaml => test.esp32-idf.yaml} | 0 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/components/ethernet/test.esp32-c3-idf.yaml rename tests/components/ethernet/{test-lan8720.esp32-idf.yaml => test.esp32-idf.yaml} (100%) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 3a87842315..17c84ee954 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -221,7 +221,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; - spi_host_device_t interface_{SPI3_HOST}; + spi_host_device_t interface_{SPI2_HOST}; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT uint32_t polling_interval_{0}; #endif diff --git a/tests/components/ethernet/test.esp32-c3-idf.yaml b/tests/components/ethernet/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b7b95875c6 --- /dev/null +++ b/tests/components/ethernet/test.esp32-c3-idf.yaml @@ -0,0 +1,19 @@ +ethernet: + type: W5500 + clk_pin: 6 + mosi_pin: 7 + miso_pin: 2 + cs_pin: 10 + interrupt_pin: 3 + reset_pin: 4 + 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-lan8720.esp32-idf.yaml b/tests/components/ethernet/test.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-lan8720.esp32-idf.yaml rename to tests/components/ethernet/test.esp32-idf.yaml From 34c35c84d5c0109cb45583a10c0ed9664462013e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 09:31:31 -0500 Subject: [PATCH 0279/2214] [core] Fix DelayAction compile error with non-const reference args (#15814) --- esphome/core/base_automation.h | 4 +++- tests/components/http_request/http_request.yaml | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 11133d3973..17f937d10d 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -205,7 +205,9 @@ template class DelayAction : public Action, public Compon } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay - auto f = [this, x...]() { this->play_next_(x...); }; + // `mutable` is required so captured copies of non-const reference args (e.g. std::string&) + // are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&` + auto f = [this, x...]() mutable { this->play_next_(x...); }; App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(x...), std::move(f), diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 13ca5ceba0..ef67671c91 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -45,6 +45,11 @@ esphome: args: - response->status_code - body.c_str() + - delay: 1s + - logger.log: + format: "After delay, body still: %s" + args: + - body.c_str() http_request: useragent: esphome/tagreader From 70ea52716172444dda1d4102d0857a20370c445d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:17:51 -0500 Subject: [PATCH 0280/2214] Bump ruff from 0.15.10 to 0.15.11 (#15790) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac4f0049f8..e492d35595 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.15.10 + rev: v0.15.11 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 18d0461e83..bb98375cb6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.10 # also change in .pre-commit-config.yaml when updating +ruff==0.15.11 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 291f80263ac7cb4c1dacef7e2843486c4483b519 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 13:41:19 -0500 Subject: [PATCH 0281/2214] [ci] Scope local pylint pre-commit hook to esphome/ to match CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ``pylint`` CI job runs ``pylint esphome`` — esphome/ only. The local pre-commit hook had no ``files:`` restriction, so it also linted ``tests/``, flagging pre-existing protected-access usage and class-size issues that CI never sees. That blocked local commits on warnings CI doesn't gate on. Add ``files: ^esphome/.+\\.py$`` to the local pylint hook so its scope matches the CI job exactly. --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e492d35595..d9b7df6ec5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,6 +58,7 @@ repos: entry: python3 script/run-in-env.py pylint language: system types: [python] + files: ^esphome/.+\.py$ - id: clang-tidy-hash name: Update clang-tidy hash entry: python script/clang_tidy_hash.py --update-if-changed From 6b67224286d16bb2ee55b6fca475faa445dfdea9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 13:41:39 -0500 Subject: [PATCH 0282/2214] [core] Chunk setup() into per-component noinline IIFEs Generated setup() is a single monolithic function whose stack frame scales super-linearly with config size. On a 5,943-line apollo build the frame reached 1,264 B at -Os; extrapolation onto larger configs (e.g. the 16k-line LVGL config in #15796) plausibly overflows the 8 KB loop task stack before safe_mode can increment its boot counter. Emit a ComponentMarker sentinel at the start of each component's to_code output, then have cpp_main_section wrap each component's block (and sub-splits of up to 50 statements within each block) in a noinline IIFE lambda. Each lambda's ENTRY frame is released on return, bounding peak stack to setup() frame + max chunk frame. Measured on apollo-r-pro-1-eth (esp32-s3, -Os): setup() frame 1264 B -> 160 B max chunk frame n/a -> 144 B peak setup stack 1264 B -> 304 B (-76%) total flash 792,471 B -> 791,995 B (-476 B) The brace-depth guard in _wrap_in_noinline_iifes ensures we never split between the RawStatement("{") / RawStatement("}") pair emitted by cg.with_local_variable() (currently only wifi), so scoped locals stay intact. --- esphome/__main__.py | 1 + esphome/codegen.py | 1 + esphome/core/__init__.py | 63 +++++++++++++++++++-- esphome/cpp_generator.py | 15 +++++ tests/unit_tests/test_core.py | 100 ++++++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 6 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7879cdad0c..5d6cf2972a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -569,6 +569,7 @@ def wrap_to_code(name, comp): @functools.wraps(comp.to_code) async def wrapped(conf): + cg.add(cg.ComponentMarker(name)) cg.add(cg.LineComment(f"{name}:")) if comp.config_schema is not None: conf_str = yaml_util.dump(conf) diff --git a/esphome/codegen.py b/esphome/codegen.py index a5b5abe447..ffaf368bd4 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -10,6 +10,7 @@ # pylint: disable=unused-import from esphome.cpp_generator import ( # noqa: F401 ArrayInitializer, + ComponentMarker, Expression, FlashStringLiteral, LineComment, diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f86..43c1ffaed0 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -531,6 +531,39 @@ class Library: return self +def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: + """Wrap ``lines`` in one or more ``[]() [[gnu::noinline]] { ... }();`` IIFEs. + + Splits into IIFEs of up to ``max_statements`` entries each. Never splits + inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that + ``cg.with_local_variable()`` emits around a scoped local), so an IIFE + may exceed ``max_statements`` when a block straddles the boundary. + """ + out: list[str] = [] + chunk: list[str] = [] + depth = 0 + + def flush() -> None: + if not chunk: + return + out.append("[]() [[gnu::noinline]] {") + out.extend(chunk) + out.append("}();") + chunk.clear() + + for line in lines: + chunk.append(line) + stripped = line.strip() + if stripped == "{": + depth += 1 + elif stripped == "}": + depth -= 1 + if depth == 0 and len(chunk) >= max_statements: + flush() + flush() + return out + + # pylint: disable=too-many-public-methods class EsphomeCore: def __init__(self): @@ -1003,14 +1036,32 @@ class EsphomeCore: @property def cpp_main_section(self): - from esphome.cpp_generator import statement + from esphome.cpp_generator import ComponentMarker, statement - main_code = [] + # Split main_statements at ComponentMarker sentinels into a prefix + # (statements emitted before any component) plus per-component groups. + prefix: list[str] = [] + components: list[list[str]] = [] + current = prefix for exp in self.main_statements: - text = str(statement(exp)) - text = text.rstrip() - main_code.append(text) - return "\n".join(main_code) + "\n\n" + if isinstance(exp, ComponentMarker): + current = [str(exp).rstrip()] + components.append(current) + continue + current.append(str(statement(exp)).rstrip()) + + # No components → flat output (host build, tests). + if not components: + return "\n".join(prefix) + "\n\n" + + # Each component's block is wrapped in a noinline IIFE lambda so its + # stack frame is released on return, bounding peak stack during + # setup(). Large blocks are sub-split to cap single heavy components + # (e.g. sensor platforms with many filter registrations). + pieces = list(prefix) + for block in components: + pieces.extend(_wrap_in_noinline_iifes(block, max_statements=50)) + return "\n".join(pieces) + "\n\n" @property def cpp_global_section(self): diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index cf90b878e1..d7205d1d8f 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -434,6 +434,21 @@ class LineComment(Statement): return "\n".join(parts) +class ComponentMarker(Statement): + """Sentinel marker recorded in main_statements when a component's + to_code begins emitting code. Used by cpp_main_section to split + setup() output into per-component chunks, so each component's + stack frame is released on return.""" + + __slots__ = ("name",) + + def __init__(self, name: str): + self.name = name + + def __str__(self): + return f"// === {self.name} ===" + + class ProgmemAssignmentExpression(AssignmentExpression): __slots__ = () diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 22be59653a..1c31f24066 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -7,6 +7,7 @@ import pytest from strategies import mac_addr_strings from esphome import const, core +from esphome.cpp_generator import ComponentMarker, RawStatement class TestHexInt: @@ -867,3 +868,102 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + +def test_wrap_in_noinline_iifes_empty_input() -> None: + assert not core._wrap_in_noinline_iifes([], max_statements=10) + + +def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None: + lines = ["a();", "b();", "c();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=10) == [ + "[]() [[gnu::noinline]] {", + "a();", + "b();", + "c();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_splits_at_max_statements() -> None: + lines = [f"s{i}();" for i in range(5)] + result = core._wrap_in_noinline_iifes(lines, max_statements=2) + # With max=2 and 5 lines: chunks of 2, 2, 1 → 3 IIFEs. + assert sum(1 for line in result if line.startswith("[]()")) == 3 + + +def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None: + # max=2 would naively split after "{" but brace guard keeps block whole. + lines = ["a();", "{", "inner();", "}", "b();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=2) == [ + "[]() [[gnu::noinline]] {", + "a();", + "{", + "inner();", + "}", + "}();", + "[]() [[gnu::noinline]] {", + "b();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_nested_braces() -> None: + lines = ["{", "{", "deep();", "}", "}", "after();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=1) == [ + "[]() [[gnu::noinline]] {", + "{", + "{", + "deep();", + "}", + "}", + "}();", + "[]() [[gnu::noinline]] {", + "after();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_unbalanced_braces_fall_through() -> None: + # Pathological input where "}" appears before "{": don't crash; emit + # a single IIFE with all lines rather than splitting mid-flight. + lines = ["a();", "}", "b();"] + result = core._wrap_in_noinline_iifes(lines, max_statements=1) + assert result[0] == "[]() [[gnu::noinline]] {" + assert result[-1] == "}();" + assert [line for line in result if line in lines] == lines + + +def test_cpp_main_section_no_components_emits_flat() -> None: + target = core.EsphomeCore() + target.main_statements = [RawStatement("a();"), RawStatement("b();")] + out = target.cpp_main_section + assert "[[gnu::noinline]]" not in out + assert "a();" in out + assert "b();" in out + + +def test_cpp_main_section_component_marker_wraps_in_iife() -> None: + target = core.EsphomeCore() + target.main_statements = [ + ComponentMarker("logger"), + RawStatement("new_logger();"), + ComponentMarker("wifi"), + RawStatement("new_wifi();"), + ] + out = target.cpp_main_section + assert out.count("[]() [[gnu::noinline]] {") == 2 + assert out.count("}();") == 2 + assert "// === logger ===" in out + assert "// === wifi ===" in out + + +def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: + target = core.EsphomeCore() + target.main_statements = [ + RawStatement("prefix();"), + ComponentMarker("c"), + RawStatement("body();"), + ] + out = target.cpp_main_section + assert out.index("prefix();") < out.index("[]() [[gnu::noinline]] {") From 29dcf9fc5176a84bcf27e98411c5a665028af7c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 14:13:40 -0500 Subject: [PATCH 0283/2214] [core] Use __attribute__((noinline)) on IIFE lambdas to honor attribute The C++ standard-attribute spelling [[gnu::noinline]] placed between a lambda's parameter list and body binds to the return type, not the call operator. GCC 14 silently ignores it and emits -Wattributes warnings at every chunk site. Switch to GCC's __attribute__((...)) syntax which binds to operator() as intended. Measured impact on apollo-r-pro-1-eth (esp32-s3, -Os) vs the broken [[gnu::noinline]] version: setup() frame 160 B -> 32 B, peak stack 304 B -> 176 B (another -42%). Flash grows by 888 B because all 86 chunks now stay as separate functions instead of GCC inlining the small ones (which it was free to do when the attribute was ignored). Net vs baseline -Os: peak stack 1264 B -> 176 B (-86%); flash +388 B (<0.05% of a typical esp32 partition). --- esphome/core/__init__.py | 9 +++++++-- tests/unit_tests/test_core.py | 16 ++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 43c1ffaed0..8c44964cac 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -532,12 +532,17 @@ class Library: def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: - """Wrap ``lines`` in one or more ``[]() [[gnu::noinline]] { ... }();`` IIFEs. + """Wrap ``lines`` in one or more ``[]() __attribute__((noinline)) {...}();`` IIFEs. Splits into IIFEs of up to ``max_statements`` entries each. Never splits inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that ``cg.with_local_variable()`` emits around a scoped local), so an IIFE may exceed ``max_statements`` when a block straddles the boundary. + + GCC note: ``__attribute__((noinline))`` between the lambda parameter + list and body binds to ``operator()``. The C++ standard-attribute + spelling ``[[gnu::noinline]]`` in that position binds to the return + type instead and produces ``-Wattributes`` warnings. """ out: list[str] = [] chunk: list[str] = [] @@ -546,7 +551,7 @@ def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: def flush() -> None: if not chunk: return - out.append("[]() [[gnu::noinline]] {") + out.append("[]() __attribute__((noinline)) {") out.extend(chunk) out.append("}();") chunk.clear() diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 1c31f24066..1e4f129896 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -877,7 +877,7 @@ def test_wrap_in_noinline_iifes_empty_input() -> None: def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None: lines = ["a();", "b();", "c();"] assert core._wrap_in_noinline_iifes(lines, max_statements=10) == [ - "[]() [[gnu::noinline]] {", + "[]() __attribute__((noinline)) {", "a();", "b();", "c();", @@ -896,13 +896,13 @@ def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None: # max=2 would naively split after "{" but brace guard keeps block whole. lines = ["a();", "{", "inner();", "}", "b();"] assert core._wrap_in_noinline_iifes(lines, max_statements=2) == [ - "[]() [[gnu::noinline]] {", + "[]() __attribute__((noinline)) {", "a();", "{", "inner();", "}", "}();", - "[]() [[gnu::noinline]] {", + "[]() __attribute__((noinline)) {", "b();", "}();", ] @@ -911,14 +911,14 @@ def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None: def test_wrap_in_noinline_iifes_nested_braces() -> None: lines = ["{", "{", "deep();", "}", "}", "after();"] assert core._wrap_in_noinline_iifes(lines, max_statements=1) == [ - "[]() [[gnu::noinline]] {", + "[]() __attribute__((noinline)) {", "{", "{", "deep();", "}", "}", "}();", - "[]() [[gnu::noinline]] {", + "[]() __attribute__((noinline)) {", "after();", "}();", ] @@ -929,7 +929,7 @@ def test_wrap_in_noinline_iifes_unbalanced_braces_fall_through() -> None: # a single IIFE with all lines rather than splitting mid-flight. lines = ["a();", "}", "b();"] result = core._wrap_in_noinline_iifes(lines, max_statements=1) - assert result[0] == "[]() [[gnu::noinline]] {" + assert result[0] == "[]() __attribute__((noinline)) {" assert result[-1] == "}();" assert [line for line in result if line in lines] == lines @@ -952,7 +952,7 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: RawStatement("new_wifi();"), ] out = target.cpp_main_section - assert out.count("[]() [[gnu::noinline]] {") == 2 + assert out.count("[]() __attribute__((noinline)) {") == 2 assert out.count("}();") == 2 assert "// === logger ===" in out assert "// === wifi ===" in out @@ -966,4 +966,4 @@ def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: RawStatement("body();"), ] out = target.cpp_main_section - assert out.index("prefix();") < out.index("[]() [[gnu::noinline]] {") + assert out.index("prefix();") < out.index("[]() __attribute__((noinline)) {") From 6a7c9af870ddebaa21fb9a53c5d1f5cf04221de8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 14:45:46 -0500 Subject: [PATCH 0284/2214] [core] Drop noinline from IIFE chunks and rename helper Additional measurements showed GCC's -Os inliner re-inlines most IIFE chunks back into setup() by choice, and the structural scoping alone captures nearly all of the peak-stack benefit on esp32 without the flash cost of forcing all chunks to stay as real functions. Apollo (esp32-s3, -Os) with vs without noinline: peak setup stack 176 B (noinline) vs 304 B (scope-only) flash delta +388 B (noinline) vs -504 B (scope-only) chunks kept 86 vs 20 Issue #15796 is an LVGL-setup class of bug that has only surfaced on esp32 after years in the field; the extra guarantee that noinline provides is not worth the flash cost in practice. Also rename the helper from _wrap_in_noinline_iifes to _wrap_in_iifes to match. --- esphome/core/__init__.py | 17 ++++++++------- tests/unit_tests/test_core.py | 40 +++++++++++++++++------------------ 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 8c44964cac..e88add3828 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -531,18 +531,19 @@ class Library: return self -def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: - """Wrap ``lines`` in one or more ``[]() __attribute__((noinline)) {...}();`` IIFEs. +def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: + """Wrap ``lines`` in one or more ``[]() {...}();`` IIFEs. Splits into IIFEs of up to ``max_statements`` entries each. Never splits inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that ``cg.with_local_variable()`` emits around a scoped local), so an IIFE may exceed ``max_statements`` when a block straddles the boundary. - GCC note: ``__attribute__((noinline))`` between the lambda parameter - list and body binds to ``operator()``. The C++ standard-attribute - spelling ``[[gnu::noinline]]`` in that position binds to the return - type instead and produces ``-Wattributes`` warnings. + The IIFEs intentionally have no ``noinline`` attribute: GCC's ``-Os`` + inliner makes good decisions about which chunks to keep as functions + and which to re-inline, and forcing all chunks to stay as functions + costs flash without measurably improving peak stack on configs where + the scope structure is itself sufficient to bound live-range lifetimes. """ out: list[str] = [] chunk: list[str] = [] @@ -551,7 +552,7 @@ def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: def flush() -> None: if not chunk: return - out.append("[]() __attribute__((noinline)) {") + out.append("[]() {") out.extend(chunk) out.append("}();") chunk.clear() @@ -1065,7 +1066,7 @@ class EsphomeCore: # (e.g. sensor platforms with many filter registrations). pieces = list(prefix) for block in components: - pieces.extend(_wrap_in_noinline_iifes(block, max_statements=50)) + pieces.extend(_wrap_in_iifes(block, max_statements=50)) return "\n".join(pieces) + "\n\n" @property diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 1e4f129896..02a804e6a1 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -870,14 +870,14 @@ class TestEsphomeCore: assert "Wire" in target.platformio_libraries -def test_wrap_in_noinline_iifes_empty_input() -> None: - assert not core._wrap_in_noinline_iifes([], max_statements=10) +def test_wrap_in_iifes_empty_input() -> None: + assert not core._wrap_in_iifes([], max_statements=10) -def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None: +def test_wrap_in_iifes_fewer_lines_than_limit() -> None: lines = ["a();", "b();", "c();"] - assert core._wrap_in_noinline_iifes(lines, max_statements=10) == [ - "[]() __attribute__((noinline)) {", + assert core._wrap_in_iifes(lines, max_statements=10) == [ + "[]() {", "a();", "b();", "c();", @@ -885,51 +885,51 @@ def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None: ] -def test_wrap_in_noinline_iifes_splits_at_max_statements() -> None: +def test_wrap_in_iifes_splits_at_max_statements() -> None: lines = [f"s{i}();" for i in range(5)] - result = core._wrap_in_noinline_iifes(lines, max_statements=2) + result = core._wrap_in_iifes(lines, max_statements=2) # With max=2 and 5 lines: chunks of 2, 2, 1 → 3 IIFEs. assert sum(1 for line in result if line.startswith("[]()")) == 3 -def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None: +def test_wrap_in_iifes_never_splits_inside_braces() -> None: # max=2 would naively split after "{" but brace guard keeps block whole. lines = ["a();", "{", "inner();", "}", "b();"] - assert core._wrap_in_noinline_iifes(lines, max_statements=2) == [ - "[]() __attribute__((noinline)) {", + assert core._wrap_in_iifes(lines, max_statements=2) == [ + "[]() {", "a();", "{", "inner();", "}", "}();", - "[]() __attribute__((noinline)) {", + "[]() {", "b();", "}();", ] -def test_wrap_in_noinline_iifes_nested_braces() -> None: +def test_wrap_in_iifes_nested_braces() -> None: lines = ["{", "{", "deep();", "}", "}", "after();"] - assert core._wrap_in_noinline_iifes(lines, max_statements=1) == [ - "[]() __attribute__((noinline)) {", + assert core._wrap_in_iifes(lines, max_statements=1) == [ + "[]() {", "{", "{", "deep();", "}", "}", "}();", - "[]() __attribute__((noinline)) {", + "[]() {", "after();", "}();", ] -def test_wrap_in_noinline_iifes_unbalanced_braces_fall_through() -> None: +def test_wrap_in_iifes_unbalanced_braces_fall_through() -> None: # Pathological input where "}" appears before "{": don't crash; emit # a single IIFE with all lines rather than splitting mid-flight. lines = ["a();", "}", "b();"] - result = core._wrap_in_noinline_iifes(lines, max_statements=1) - assert result[0] == "[]() __attribute__((noinline)) {" + result = core._wrap_in_iifes(lines, max_statements=1) + assert result[0] == "[]() {" assert result[-1] == "}();" assert [line for line in result if line in lines] == lines @@ -952,7 +952,7 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: RawStatement("new_wifi();"), ] out = target.cpp_main_section - assert out.count("[]() __attribute__((noinline)) {") == 2 + assert out.count("[]() {") == 2 assert out.count("}();") == 2 assert "// === logger ===" in out assert "// === wifi ===" in out @@ -966,4 +966,4 @@ def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: RawStatement("body();"), ] out = target.cpp_main_section - assert out.index("prefix();") < out.index("[]() __attribute__((noinline)) {") + assert out.index("prefix();") < out.index("[]() {") From 936694af2c801c96fbd126cb21f8f2d2331536db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 14:49:14 -0500 Subject: [PATCH 0285/2214] [core] Don't emit IIFE for comment-only chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some components (sha256, async_tcp, network, empty text_sensor:, etc.) emit only a ComponentMarker plus config-dump comments and no actual C++ statements. Wrapping those in a `[]() { ... }();` IIFE is pure clutter in the generated main.cpp — the IIFE has no body. When _wrap_in_iifes sees a chunk whose lines are all // comments, emit them verbatim instead of wrapping. Peak stack and flash are unchanged on apollo and neargaragedoor since GCC was already eliding the empty IIFEs; this just makes the generated code read cleanly to humans. --- esphome/core/__init__.py | 12 +++++++++--- tests/unit_tests/test_core.py | 7 +++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index e88add3828..b66899e133 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -552,9 +552,15 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: def flush() -> None: if not chunk: return - out.append("[]() {") - out.extend(chunk) - out.append("}();") + # If the chunk is comments-only (e.g. a component that emits a + # header marker and config dump but no C++ statements), emit them + # verbatim without wrapping — an empty IIFE is pure clutter. + if all(line.lstrip().startswith("//") for line in chunk): + out.extend(chunk) + else: + out.append("[]() {") + out.extend(chunk) + out.append("}();") chunk.clear() for line in lines: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 02a804e6a1..cd65e670ed 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -934,6 +934,13 @@ def test_wrap_in_iifes_unbalanced_braces_fall_through() -> None: assert [line for line in result if line in lines] == lines +def test_wrap_in_iifes_skips_comment_only_chunks() -> None: + # Components that emit only a ComponentMarker + config dump (no C++ + # statements) should not be wrapped in an empty IIFE. + lines = ["// === sha256 ===", "// sha256:", "// {}"] + assert core._wrap_in_iifes(lines, max_statements=50) == lines + + def test_cpp_main_section_no_components_emits_flat() -> None: target = core.EsphomeCore() target.main_statements = [RawStatement("a();"), RawStatement("b();")] From 864d31aa65c73dffb3500b83e521fc9bf2b234b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 14:53:59 -0500 Subject: [PATCH 0286/2214] [core] Put ComponentMarker outside the IIFE as a visual bracket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker comment was being emitted as the first line *inside* each IIFE: []() { // === logger === // logger: // ... ... }(); That works but buries the component label inside the lambda body, so scanning generated main.cpp to find "where does component X's setup live" is harder than it needs to be. Emit the marker before and after the IIFE instead: // === logger === []() { // logger: // ... ... }(); // === logger === Comment-only components (e.g. sha256, async_tcp, empty platforms like binary_sensor:) don't grow a useless trailing duplicate marker — when there's no IIFE to bracket, the marker is emitted once. --- esphome/core/__init__.py | 27 ++++++++++++++++++--------- tests/unit_tests/test_core.py | 19 +++++++++++++++++-- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index b66899e133..acf2e197ac 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1052,13 +1052,16 @@ class EsphomeCore: # Split main_statements at ComponentMarker sentinels into a prefix # (statements emitted before any component) plus per-component groups. + # Each group's first entry is its marker comment, kept separate so + # it can bracket the IIFE rather than be buried inside it. prefix: list[str] = [] - components: list[list[str]] = [] + components: list[tuple[str, list[str]]] = [] current = prefix for exp in self.main_statements: if isinstance(exp, ComponentMarker): - current = [str(exp).rstrip()] - components.append(current) + body: list[str] = [] + components.append((str(exp).rstrip(), body)) + current = body continue current.append(str(statement(exp)).rstrip()) @@ -1066,13 +1069,19 @@ class EsphomeCore: if not components: return "\n".join(prefix) + "\n\n" - # Each component's block is wrapped in a noinline IIFE lambda so its - # stack frame is released on return, bounding peak stack during - # setup(). Large blocks are sub-split to cap single heavy components - # (e.g. sensor platforms with many filter registrations). + # Each component's block is wrapped in IIFE lambdas so its stack + # frame is released on return, bounding peak stack during setup(). + # Large blocks are sub-split to cap single heavy components (e.g. + # sensor platforms with many filter registrations). The marker + # comment brackets the IIFE on both sides so the generated + # main.cpp is easy to scan by component. pieces = list(prefix) - for block in components: - pieces.extend(_wrap_in_iifes(block, max_statements=50)) + for marker, body in components: + wrapped = _wrap_in_iifes(body, max_statements=50) + pieces.append(marker) + pieces.extend(wrapped) + if any("[]()" in line for line in wrapped): + pieces.append(marker) return "\n".join(pieces) + "\n\n" @property diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index cd65e670ed..dc16d4cfe9 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -961,8 +961,23 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: out = target.cpp_main_section assert out.count("[]() {") == 2 assert out.count("}();") == 2 - assert "// === logger ===" in out - assert "// === wifi ===" in out + # Each component's marker brackets its IIFE (once before, once after). + assert out.count("// === logger ===") == 2 + assert out.count("// === wifi ===") == 2 + + +def test_cpp_main_section_comment_only_component_emits_single_marker() -> None: + # A component that emits no C++ statements (only a ComponentMarker) + # should not grow a useless trailing duplicate marker. + target = core.EsphomeCore() + target.main_statements = [ + ComponentMarker("sha256"), + ComponentMarker("wifi"), + RawStatement("new_wifi();"), + ] + out = target.cpp_main_section + assert out.count("// === sha256 ===") == 1 + assert out.count("// === wifi ===") == 2 # wifi has an IIFE def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: From 178f23a7aaf9f4b42b33e322eebe1d3b500b5230 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 14:55:40 -0500 Subject: [PATCH 0287/2214] [core] Use begin/end marker pairs around each component's IIFE Rename the bracket markers from "// === X ===" (same on both sides) to "// === begin X ===" and "// === end X ===" so the generated main.cpp reads unambiguously when scanning by component. Comment-only components still get a single "begin X" marker since they have no IIFE to close. --- esphome/core/__init__.py | 22 ++++++++++++---------- esphome/cpp_generator.py | 7 +++---- tests/unit_tests/test_core.py | 18 +++++++++++------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index acf2e197ac..798a797222 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1052,15 +1052,15 @@ class EsphomeCore: # Split main_statements at ComponentMarker sentinels into a prefix # (statements emitted before any component) plus per-component groups. - # Each group's first entry is its marker comment, kept separate so - # it can bracket the IIFE rather than be buried inside it. + # Each group carries its component name so cpp_main_section can emit + # begin/end marker comments bracketing the IIFE. prefix: list[str] = [] components: list[tuple[str, list[str]]] = [] current = prefix for exp in self.main_statements: if isinstance(exp, ComponentMarker): body: list[str] = [] - components.append((str(exp).rstrip(), body)) + components.append((exp.name, body)) current = body continue current.append(str(statement(exp)).rstrip()) @@ -1072,16 +1072,18 @@ class EsphomeCore: # Each component's block is wrapped in IIFE lambdas so its stack # frame is released on return, bounding peak stack during setup(). # Large blocks are sub-split to cap single heavy components (e.g. - # sensor platforms with many filter registrations). The marker - # comment brackets the IIFE on both sides so the generated - # main.cpp is easy to scan by component. + # sensor platforms with many filter registrations). "begin X" and + # "end X" marker comments bracket the IIFE so the generated + # main.cpp is easy to scan by component; a comment-only component + # gets a single "begin X" marker (no IIFE, no end marker). pieces = list(prefix) - for marker, body in components: + for name, body in components: wrapped = _wrap_in_iifes(body, max_statements=50) - pieces.append(marker) + has_iife = any("[]()" in line for line in wrapped) + pieces.append(f"// === begin {name} ===") pieces.extend(wrapped) - if any("[]()" in line for line in wrapped): - pieces.append(marker) + if has_iife: + pieces.append(f"// === end {name} ===") return "\n".join(pieces) + "\n\n" @property diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index d7205d1d8f..608f55aa0c 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -436,9 +436,8 @@ class LineComment(Statement): class ComponentMarker(Statement): """Sentinel marker recorded in main_statements when a component's - to_code begins emitting code. Used by cpp_main_section to split - setup() output into per-component chunks, so each component's - stack frame is released on return.""" + to_code begins emitting code. ``cpp_main_section`` consumes these + to bracket each component's IIFE with begin/end comment markers.""" __slots__ = ("name",) @@ -446,7 +445,7 @@ class ComponentMarker(Statement): self.name = name def __str__(self): - return f"// === {self.name} ===" + return f"// === begin {self.name} ===" class ProgmemAssignmentExpression(AssignmentExpression): diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index dc16d4cfe9..f23955672d 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -937,7 +937,7 @@ def test_wrap_in_iifes_unbalanced_braces_fall_through() -> None: def test_wrap_in_iifes_skips_comment_only_chunks() -> None: # Components that emit only a ComponentMarker + config dump (no C++ # statements) should not be wrapped in an empty IIFE. - lines = ["// === sha256 ===", "// sha256:", "// {}"] + lines = ["// === begin sha256 ===", "// sha256:", "// {}"] assert core._wrap_in_iifes(lines, max_statements=50) == lines @@ -961,14 +961,16 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: out = target.cpp_main_section assert out.count("[]() {") == 2 assert out.count("}();") == 2 - # Each component's marker brackets its IIFE (once before, once after). - assert out.count("// === logger ===") == 2 - assert out.count("// === wifi ===") == 2 + # Each component's IIFE is bracketed by a begin/end marker pair. + assert "// === begin logger ===" in out + assert "// === end logger ===" in out + assert "// === begin wifi ===" in out + assert "// === end wifi ===" in out def test_cpp_main_section_comment_only_component_emits_single_marker() -> None: # A component that emits no C++ statements (only a ComponentMarker) - # should not grow a useless trailing duplicate marker. + # gets only a begin marker — no IIFE, so no end marker. target = core.EsphomeCore() target.main_statements = [ ComponentMarker("sha256"), @@ -976,8 +978,10 @@ def test_cpp_main_section_comment_only_component_emits_single_marker() -> None: RawStatement("new_wifi();"), ] out = target.cpp_main_section - assert out.count("// === sha256 ===") == 1 - assert out.count("// === wifi ===") == 2 # wifi has an IIFE + assert "// === begin sha256 ===" in out + assert "// === end sha256 ===" not in out + assert "// === begin wifi ===" in out + assert "// === end wifi ===" in out def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: From f82401a5044b9fc3b657fb5ddce3bc46777520c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 15:04:07 -0500 Subject: [PATCH 0288/2214] [core] Address Copilot review: robust brace depth, accurate docstrings - Count { and } characters per line instead of matching whole-line tokens. Current codegen only emits scope braces as standalone lines (from cg.with_local_variable()), but the defensive change is robust against future codegen emitting inline control flow like `if (cond) {` or `} else {` on one line. - Add a regression test covering those inline-brace patterns. - Fix stale docstrings on ComponentMarker and cpp_main_section that still claimed "stack frame released on return" and described the IIFEs as "noinline". The IIFEs have no noinline attribute and rely on scope-based lifetime shortening rather than guaranteed frames. --- esphome/core/__init__.py | 35 +++++++++++++++++++++++------------ esphome/cpp_generator.py | 7 ++++++- tests/unit_tests/test_core.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 798a797222..b69cb74a74 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -565,11 +565,18 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: for line in lines: chunk.append(line) - stripped = line.strip() - if stripped == "{": - depth += 1 - elif stripped == "}": - depth -= 1 + # Track brace depth by counting ``{`` and ``}`` characters per line + # rather than matching whole-line tokens. Today the only codegen + # that emits scope braces as separate statements is + # ``cg.with_local_variable()`` (standalone ``{`` / ``}`` lines), + # but counting is robust against future codegen that emits inline + # control-flow like ``if (cond) {`` or ``} else {`` on a single + # line. Multi-line statements (inline lambdas) carry balanced + # braces within one list entry so they contribute no net depth. + # Braces inside string literals would throw the count off, but + # esphome's generated main.cpp does not currently emit strings + # that contain unbalanced braces in main_statements. + depth += line.count("{") - line.count("}") if depth == 0 and len(chunk) >= max_statements: flush() flush() @@ -1069,13 +1076,17 @@ class EsphomeCore: if not components: return "\n".join(prefix) + "\n\n" - # Each component's block is wrapped in IIFE lambdas so its stack - # frame is released on return, bounding peak stack during setup(). - # Large blocks are sub-split to cap single heavy components (e.g. - # sensor platforms with many filter registrations). "begin X" and - # "end X" marker comments bracket the IIFE so the generated - # main.cpp is easy to scan by component; a comment-only component - # gets a single "begin X" marker (no IIFE, no end marker). + # Each component's block is wrapped in an IIFE lambda that + # introduces a nested scope, shortening the lifetimes of + # temporaries so GCC can bound peak setup-time stack usage. + # The IIFE has no noinline attribute, so the compiler is free + # to inline the block when that produces smaller code without + # regressing peak stack. Large blocks are sub-split to cap + # single heavy components (e.g. sensor platforms with many + # filter registrations). "begin X" and "end X" marker comments + # bracket the IIFE so the generated main.cpp is easy to scan by + # component; a comment-only component gets a single "begin X" + # marker (no IIFE, no end marker). pieces = list(prefix) for name, body in components: wrapped = _wrap_in_iifes(body, max_statements=50) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 608f55aa0c..6b33fe3e22 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -437,7 +437,12 @@ class LineComment(Statement): class ComponentMarker(Statement): """Sentinel marker recorded in main_statements when a component's to_code begins emitting code. ``cpp_main_section`` consumes these - to bracket each component's IIFE with begin/end comment markers.""" + to bracket each component's generated block with begin/end comment + markers and to wrap it in an IIFE scope. The IIFE introduces a + nested scope so GCC can shorten temporary lifetimes and help reduce + peak setup-time stack usage; the lambda has no ``noinline`` + attribute, so the compiler may still inline the block when that + produces smaller code without regressing peak stack.""" __slots__ = ("name",) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index f23955672d..e01593bba8 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -934,6 +934,40 @@ def test_wrap_in_iifes_unbalanced_braces_fall_through() -> None: assert [line for line in result if line in lines] == lines +def test_wrap_in_iifes_never_splits_inline_brace_lines() -> None: + # Defensive: if codegen ever emits control flow with braces on the + # same line (if/else/for), the depth tracker should keep the whole + # scoped block together even with aggressive max_statements. + lines = [ + "before();", + "if (cond) {", + "then_branch();", + "} else {", + "for (;;) {", + "loop_body();", + "}", + "}", + "after();", + ] + assert core._wrap_in_iifes(lines, max_statements=1) == [ + "[]() {", + "before();", + "}();", + "[]() {", + "if (cond) {", + "then_branch();", + "} else {", + "for (;;) {", + "loop_body();", + "}", + "}", + "}();", + "[]() {", + "after();", + "}();", + ] + + def test_wrap_in_iifes_skips_comment_only_chunks() -> None: # Components that emit only a ComponentMarker + config dump (no C++ # statements) should not be wrapped in an empty IIFE. From b1b0005574b3a54f8aeead1d9eee96f586d72aa6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:14:54 -0400 Subject: [PATCH 0289/2214] [esp32] Downgrade unneeded `ignore_pin_validation_error` to a warning (#15811) --- esphome/components/esp32/gpio.py | 12 +++- tests/component_tests/esp32/test_esp32.py | 79 ++++++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index a7180cbcd7..36dd44155a 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -172,10 +172,16 @@ def validate_gpio_pin(pin): exc, ) else: - # Throw an exception if used for a pin that would not have resulted - # in a validation error anyway! + # `ignore_pin_validation_error` only suppresses an error raised by the + # variant's pin_validation above (e.g. SPI flash/PSRAM pins, invalid pin + # numbers). If that didn't raise, the option is a no-op -- warn so the + # user can clean it up, but don't block the build. if ignore_pin_validation_warning: - raise cv.Invalid(f"GPIO{pin[CONF_NUMBER]} is not a reserved pin") + _LOGGER.warning( + "GPIO%d has no validation errors to ignore; " + "remove `ignore_pin_validation_error: true` from this pin.", + pin[CONF_NUMBER], + ) return pin diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bd4f9828ce..ac492e2752 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -8,10 +8,16 @@ from typing import Any import pytest -from esphome.components.esp32 import VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS +from esphome.components.esp32 import VARIANT_ESP32, VARIANTS +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv -from esphome.const import CONF_ESPHOME, PlatformFramework +from esphome.const import ( + CONF_ESPHOME, + CONF_IGNORE_PIN_VALIDATION_ERROR, + CONF_NUMBER, + PlatformFramework, +) from esphome.core import CORE from tests.component_tests.types import SetCoreConfigCallable @@ -149,6 +155,73 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_ignore_pin_validation_error_on_clean_pin_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that passes validation but sets `ignore_pin_validation_error: true` + should log a warning nudging the user to remove the flag, and not raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "GPIO4 has no validation errors to ignore" in caplog.text + + +def test_ignore_pin_validation_error_on_dirty_pin_suppresses( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that fails validation with `ignore_pin_validation_error: true` should + log the suppression warning and not raise (existing behavior).""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + # GPIO6 is a flash pin on ESP32 -> pin_validation raises cv.Invalid + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 6 + assert "Ignoring validation error on pin 6" in caplog.text + + +def test_dirty_pin_without_ignore_flag_raises( + set_core_config: SetCoreConfigCallable, +) -> None: + """A pin that fails validation without the ignore flag should still raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with pytest.raises(cv.Invalid, match="flash interface"): + validate_gpio_pin(pin) + + +def test_clean_pin_without_ignore_flag_does_not_warn( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A clean pin without the ignore flag should pass silently.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "has no validation errors to ignore" not in caplog.text + + def test_execute_from_psram_disabled_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From 00f08ba6ed6413b9b4e214dfad56052128721e1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 15:19:48 -0500 Subject: [PATCH 0290/2214] [core] Drop per-component begin/end labels from generated main.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The labels were there to help humans scanning the generated main.cpp find component boundaries, but they were: - Unreliable: CORE.flush_tasks can interleave coroutines on each await, so a component's later statements can land in another component's begin/end block. - Load-bearing for a pile of complexity: a tuple return from _wrap_in_iifes, a has_iife flag, a comment-only detector to suppress trailing end-markers for comment-only components, and a brittle `"[]()" in line` check that could false-positive on YAML dumps containing lambda syntax. - Not actually needed — generated main.cpp is a build artifact rarely read by anyone, and cg.LineComment("name:") already puts the component name at the start of its block. ComponentMarker stays as a pure chunking sentinel — it tells cpp_main_section where component boundaries are (for grouping) but produces no C++ output. _wrap_in_iifes returns a plain list again. Added a regression test for the now-defused case of a comment containing "[]()" that was previously flagged by review. --- esphome/core/__init__.py | 70 ++++++++++++++++------------------- esphome/cpp_generator.py | 25 +++++++++---- tests/unit_tests/test_core.py | 42 ++++++++++++--------- 3 files changed, 73 insertions(+), 64 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index b69cb74a74..2abf15e1c7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -538,6 +538,8 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that ``cg.with_local_variable()`` emits around a scoped local), so an IIFE may exceed ``max_statements`` when a block straddles the boundary. + A comment-only chunk is emitted verbatim with no IIFE, since wrapping + pure comments in a no-op lambda is clutter. The IIFEs intentionally have no ``noinline`` attribute: GCC's ``-Os`` inliner makes good decisions about which chunks to keep as functions @@ -552,9 +554,6 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: def flush() -> None: if not chunk: return - # If the chunk is comments-only (e.g. a component that emits a - # header marker and config dump but no C++ statements), emit them - # verbatim without wrapping — an empty IIFE is pure clutter. if all(line.lstrip().startswith("//") for line in chunk): out.extend(chunk) else: @@ -566,16 +565,22 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: for line in lines: chunk.append(line) # Track brace depth by counting ``{`` and ``}`` characters per line - # rather than matching whole-line tokens. Today the only codegen - # that emits scope braces as separate statements is - # ``cg.with_local_variable()`` (standalone ``{`` / ``}`` lines), - # but counting is robust against future codegen that emits inline - # control-flow like ``if (cond) {`` or ``} else {`` on a single - # line. Multi-line statements (inline lambdas) carry balanced - # braces within one list entry so they contribute no net depth. - # Braces inside string literals would throw the count off, but - # esphome's generated main.cpp does not currently emit strings - # that contain unbalanced braces in main_statements. + # rather than matching whole-line tokens. The current codegen only + # emits scope braces via ``cg.with_local_variable()`` (standalone + # ``{`` / ``}`` lines), but counting is robust against future + # codegen emitting inline control flow like ``if (cond) {`` or + # ``} else {``. Multi-line statements (e.g. inline lambdas) carry + # balanced braces within one list entry and contribute no net + # depth. Braces inside string literals would throw the count off; + # esphome's generated main.cpp does not currently emit such + # strings in main_statements. + # + # If depth ever goes negative (unmatched ``}`` before ``{``), we + # never return to depth 0 for the remainder of the input, so no + # further flushes fire and the rest of the chunk falls through + # into a single IIFE at the final ``flush()``. This is the + # intended safe-harbor behavior — negative depth signals a + # violated assumption about the input, not a normal branch. depth += line.count("{") - line.count("}") if depth == 0 and len(chunk) >= max_statements: flush() @@ -1058,43 +1063,30 @@ class EsphomeCore: from esphome.cpp_generator import ComponentMarker, statement # Split main_statements at ComponentMarker sentinels into a prefix - # (statements emitted before any component) plus per-component groups. - # Each group carries its component name so cpp_main_section can emit - # begin/end marker comments bracketing the IIFE. + # (statements emitted before any component) plus per-component + # groups. Each group is wrapped in an IIFE lambda so GCC can + # shorten temporary lifetimes and bound peak setup-time stack; + # large groups are sub-split to cap single heavy components + # (e.g. sensor platforms with many filter registrations). The + # IIFEs have no noinline attribute, so the compiler is free to + # inline the block when that produces smaller code without + # regressing peak stack. prefix: list[str] = [] - components: list[tuple[str, list[str]]] = [] + components: list[list[str]] = [] current = prefix for exp in self.main_statements: if isinstance(exp, ComponentMarker): - body: list[str] = [] - components.append((exp.name, body)) - current = body + current = [] + components.append(current) continue current.append(str(statement(exp)).rstrip()) - # No components → flat output (host build, tests). if not components: return "\n".join(prefix) + "\n\n" - # Each component's block is wrapped in an IIFE lambda that - # introduces a nested scope, shortening the lifetimes of - # temporaries so GCC can bound peak setup-time stack usage. - # The IIFE has no noinline attribute, so the compiler is free - # to inline the block when that produces smaller code without - # regressing peak stack. Large blocks are sub-split to cap - # single heavy components (e.g. sensor platforms with many - # filter registrations). "begin X" and "end X" marker comments - # bracket the IIFE so the generated main.cpp is easy to scan by - # component; a comment-only component gets a single "begin X" - # marker (no IIFE, no end marker). pieces = list(prefix) - for name, body in components: - wrapped = _wrap_in_iifes(body, max_statements=50) - has_iife = any("[]()" in line for line in wrapped) - pieces.append(f"// === begin {name} ===") - pieces.extend(wrapped) - if has_iife: - pieces.append(f"// === end {name} ===") + for body in components: + pieces.extend(_wrap_in_iifes(body, max_statements=50)) return "\n".join(pieces) + "\n\n" @property diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 6b33fe3e22..3b7b78dc90 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -436,13 +436,22 @@ class LineComment(Statement): class ComponentMarker(Statement): """Sentinel marker recorded in main_statements when a component's - to_code begins emitting code. ``cpp_main_section`` consumes these - to bracket each component's generated block with begin/end comment - markers and to wrap it in an IIFE scope. The IIFE introduces a - nested scope so GCC can shorten temporary lifetimes and help reduce - peak setup-time stack usage; the lambda has no ``noinline`` - attribute, so the compiler may still inline the block when that - produces smaller code without regressing peak stack.""" + ``to_code`` begins emitting code. ``cpp_main_section`` consumes + these as chunking boundaries: the statements between two markers + form a group that gets wrapped in an IIFE so GCC can shorten + temporary lifetimes and bound peak setup-time stack usage. + + The marker produces no C++ output of its own; its ``__str__`` is + only used for debugging (e.g. ``repr`` of ``main_statements``). + The component name is retained so tooling can inspect grouping if + needed, but the generated ``main.cpp`` carries no per-component + labels — partitioning is best-effort anyway because + ``CORE.flush_tasks`` can interleave coroutines on each ``await`` + (e.g. ``cg.get_variable``) and re-schedule by priority, which + means a component's later statements can land between a different + component's earlier statements. This is semantically safe because + every statement placement-news into static storage or mutates a + global already declared at file scope.""" __slots__ = ("name",) @@ -450,7 +459,7 @@ class ComponentMarker(Statement): self.name = name def __str__(self): - return f"// === begin {self.name} ===" + return f"// component-marker: {self.name}" class ProgmemAssignmentExpression(AssignmentExpression): diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index e01593bba8..e4a52a7808 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -871,7 +871,7 @@ class TestEsphomeCore: def test_wrap_in_iifes_empty_input() -> None: - assert not core._wrap_in_iifes([], max_statements=10) + assert core._wrap_in_iifes([], max_statements=10) == [] def test_wrap_in_iifes_fewer_lines_than_limit() -> None: @@ -969,9 +969,20 @@ def test_wrap_in_iifes_never_splits_inline_brace_lines() -> None: def test_wrap_in_iifes_skips_comment_only_chunks() -> None: - # Components that emit only a ComponentMarker + config dump (no C++ - # statements) should not be wrapped in an empty IIFE. - lines = ["// === begin sha256 ===", "// sha256:", "// {}"] + # A chunk with no C++ statements (only comments, e.g. a component's + # config dump) should be emitted verbatim without a no-op IIFE. + lines = ["// sha256:", "// {}"] + assert core._wrap_in_iifes(lines, max_statements=50) == lines + + +def test_wrap_in_iifes_ignores_iife_pattern_in_comment() -> None: + # A comment whose text mentions "[]()" (e.g. a YAML dump of a + # lambda) must not fool the comment-only detector into wrapping. + lines = [ + "// on_value:", + "// - !lambda |-", + "// return []() { return 5; };", + ] assert core._wrap_in_iifes(lines, max_statements=50) == lines @@ -979,7 +990,7 @@ def test_cpp_main_section_no_components_emits_flat() -> None: target = core.EsphomeCore() target.main_statements = [RawStatement("a();"), RawStatement("b();")] out = target.cpp_main_section - assert "[[gnu::noinline]]" not in out + assert "[]() {" not in out assert "a();" in out assert "b();" in out @@ -993,18 +1004,17 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: RawStatement("new_wifi();"), ] out = target.cpp_main_section + # One IIFE per component that emits C++ statements. assert out.count("[]() {") == 2 assert out.count("}();") == 2 - # Each component's IIFE is bracketed by a begin/end marker pair. - assert "// === begin logger ===" in out - assert "// === end logger ===" in out - assert "// === begin wifi ===" in out - assert "// === end wifi ===" in out + # ComponentMarker produces no output of its own. + assert "component-marker" not in out -def test_cpp_main_section_comment_only_component_emits_single_marker() -> None: - # A component that emits no C++ statements (only a ComponentMarker) - # gets only a begin marker — no IIFE, so no end marker. +def test_cpp_main_section_comment_only_component_omits_iife() -> None: + # A component that emits only a ComponentMarker (no statements) adds + # nothing to the generated output. A neighboring component with + # actual code still gets its own IIFE. target = core.EsphomeCore() target.main_statements = [ ComponentMarker("sha256"), @@ -1012,10 +1022,8 @@ def test_cpp_main_section_comment_only_component_emits_single_marker() -> None: RawStatement("new_wifi();"), ] out = target.cpp_main_section - assert "// === begin sha256 ===" in out - assert "// === end sha256 ===" not in out - assert "// === begin wifi ===" in out - assert "// === end wifi ===" in out + assert out.count("[]() {") == 1 + assert "new_wifi();" in out def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: From 91b238aa97736d38f945dc66c23087ee38e772cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 15:34:15 -0500 Subject: [PATCH 0291/2214] [core] Fix grammar in ComponentMarker docstring --- esphome/cpp_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 3b7b78dc90..8abebd8b6e 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -450,8 +450,8 @@ class ComponentMarker(Statement): (e.g. ``cg.get_variable``) and re-schedule by priority, which means a component's later statements can land between a different component's earlier statements. This is semantically safe because - every statement placement-news into static storage or mutates a - global already declared at file scope.""" + every statement either uses placement new into static storage or + mutates a global already declared at file scope.""" __slots__ = ("name",) From 9fa6d224c27ac62dc5a96d17691df8cf76aaa991 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 15:35:53 -0500 Subject: [PATCH 0292/2214] [core] Tighten docstrings and inline comments --- esphome/core/__init__.py | 54 +++++++++++----------------------------- esphome/cpp_generator.py | 23 ++++++----------- 2 files changed, 21 insertions(+), 56 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 2abf15e1c7..8bc4e6e153 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -532,21 +532,13 @@ class Library: def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: - """Wrap ``lines`` in one or more ``[]() {...}();`` IIFEs. + """Wrap ``lines`` in ``[]() {...}();`` IIFEs of up to ``max_statements`` + each. Never splits inside a brace-balanced block (e.g. the ``{`` / ``}`` + pair from ``cg.with_local_variable()``), so an IIFE may exceed the cap + when a block straddles it. Comment-only chunks pass through verbatim. - Splits into IIFEs of up to ``max_statements`` entries each. Never splits - inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that - ``cg.with_local_variable()`` emits around a scoped local), so an IIFE - may exceed ``max_statements`` when a block straddles the boundary. - A comment-only chunk is emitted verbatim with no IIFE, since wrapping - pure comments in a no-op lambda is clutter. - - The IIFEs intentionally have no ``noinline`` attribute: GCC's ``-Os`` - inliner makes good decisions about which chunks to keep as functions - and which to re-inline, and forcing all chunks to stay as functions - costs flash without measurably improving peak stack on configs where - the scope structure is itself sufficient to bound live-range lifetimes. - """ + No ``noinline`` attribute — GCC's inliner re-folds small chunks freely, + keeping flash small without regressing peak stack.""" out: list[str] = [] chunk: list[str] = [] depth = 0 @@ -564,23 +556,10 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: for line in lines: chunk.append(line) - # Track brace depth by counting ``{`` and ``}`` characters per line - # rather than matching whole-line tokens. The current codegen only - # emits scope braces via ``cg.with_local_variable()`` (standalone - # ``{`` / ``}`` lines), but counting is robust against future - # codegen emitting inline control flow like ``if (cond) {`` or - # ``} else {``. Multi-line statements (e.g. inline lambdas) carry - # balanced braces within one list entry and contribute no net - # depth. Braces inside string literals would throw the count off; - # esphome's generated main.cpp does not currently emit such - # strings in main_statements. - # - # If depth ever goes negative (unmatched ``}`` before ``{``), we - # never return to depth 0 for the remainder of the input, so no - # further flushes fire and the rest of the chunk falls through - # into a single IIFE at the final ``flush()``. This is the - # intended safe-harbor behavior — negative depth signals a - # violated assumption about the input, not a normal branch. + # Count { and } per line so inline control flow (e.g. `if (cond) {`) + # and balanced inline lambdas are tracked correctly. If depth ever + # goes negative (unbalanced input) we never return to 0 and the + # rest falls through into a single final IIFE — safe fallback. depth += line.count("{") - line.count("}") if depth == 0 and len(chunk) >= max_statements: flush() @@ -1062,15 +1041,10 @@ class EsphomeCore: def cpp_main_section(self): from esphome.cpp_generator import ComponentMarker, statement - # Split main_statements at ComponentMarker sentinels into a prefix - # (statements emitted before any component) plus per-component - # groups. Each group is wrapped in an IIFE lambda so GCC can - # shorten temporary lifetimes and bound peak setup-time stack; - # large groups are sub-split to cap single heavy components - # (e.g. sensor platforms with many filter registrations). The - # IIFEs have no noinline attribute, so the compiler is free to - # inline the block when that produces smaller code without - # regressing peak stack. + # Split main_statements at ComponentMarker sentinels and wrap each + # component's group in an IIFE, sub-splitting at 50 statements so + # a single heavy component (e.g. a sensor platform with many + # filter registrations) can't blow the peak chunk frame. prefix: list[str] = [] components: list[list[str]] = [] current = prefix diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 8abebd8b6e..853025b4aa 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -435,23 +435,14 @@ class LineComment(Statement): class ComponentMarker(Statement): - """Sentinel marker recorded in main_statements when a component's - ``to_code`` begins emitting code. ``cpp_main_section`` consumes - these as chunking boundaries: the statements between two markers - form a group that gets wrapped in an IIFE so GCC can shorten - temporary lifetimes and bound peak setup-time stack usage. + """Chunking-boundary sentinel. ``cpp_main_section`` wraps the + statements between two markers in an IIFE to shorten temporary + lifetimes and bound peak setup-time stack. Emits no C++ output. - The marker produces no C++ output of its own; its ``__str__`` is - only used for debugging (e.g. ``repr`` of ``main_statements``). - The component name is retained so tooling can inspect grouping if - needed, but the generated ``main.cpp`` carries no per-component - labels — partitioning is best-effort anyway because - ``CORE.flush_tasks`` can interleave coroutines on each ``await`` - (e.g. ``cg.get_variable``) and re-schedule by priority, which - means a component's later statements can land between a different - component's earlier statements. This is semantically safe because - every statement either uses placement new into static storage or - mutates a global already declared at file scope.""" + Grouping is best-effort: ``flush_tasks`` can interleave coroutines + on ``await``, so a component's later statements may land in another + component's chunk. Safe because every statement either placement- + news into static storage or mutates a file-scope global.""" __slots__ = ("name",) From a5cba91729811ea953ba1f3354dba1d2d215e27e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 15:39:30 -0500 Subject: [PATCH 0293/2214] [sensor] Specialize throttle_with_priority NaN-only case Short form `- throttle_with_priority: 1s` and explicit `value: nan` previously generated a `ThrottleWithPriorityFilter<1>` with a lambda returning NAN stored in a `TemplatableFn` array. Detect this common case at codegen time and emit a new `ThrottleWithPriorityNanFilter` that inlines the `std::isnan()` check. Behavior is preserved: NaN sensor readings still bypass the throttle. --- esphome/components/sensor/__init__.py | 18 +++++++++++++++--- esphome/components/sensor/filter.cpp | 12 ++++++++++++ esphome/components/sensor/filter.h | 13 +++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index b658ff7056..7107a7a888 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -275,6 +275,9 @@ ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter) ThrottleWithPriorityFilter = sensor_ns.class_( "ThrottleWithPriorityFilter", ValueListFilter ) +ThrottleWithPriorityNanFilter = sensor_ns.class_( + "ThrottleWithPriorityNanFilter", Filter +) TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) @@ -656,9 +659,18 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( THROTTLE_WITH_PRIORITY_SCHEMA, ) async def throttle_with_priority_filter_to_code(config, filter_id): - if not isinstance(config[CONF_VALUE], list): - config[CONF_VALUE] = [config[CONF_VALUE]] - template_ = [await cg.templatable(x, [], cg.float_) for x in config[CONF_VALUE]] + values = config[CONF_VALUE] + if not isinstance(values, list): + values = [values] + # Specialize the common "NaN-only" case (the schema default when the user + # omits `value:`) to avoid the TemplatableFn array + NaN lambda the + # generic ValueListFilter path requires. Behavior is identical: NaN sensor + # readings always bypass the throttle. + if all(isinstance(v, float) and math.isnan(v) for v in values): + filter_id = filter_id.copy() + filter_id.type = ThrottleWithPriorityNanFilter + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) + template_ = [await cg.templatable(x, [], cg.float_) for x in values] return cg.new_Pvariable( filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ ) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index fbac7d3535..4896757d3f 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -269,6 +269,18 @@ optional throttle_with_priority_new_value(Sensor *parent, float value, co return {}; } +// ThrottleWithPriorityNanFilter +ThrottleWithPriorityNanFilter::ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs) + : min_time_between_inputs_(min_time_between_inputs) {} +optional ThrottleWithPriorityNanFilter::new_value(float value) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || std::isnan(value)) { + this->last_input_ = now; + return value; + } + return {}; +} + // DeltaFilter DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) : min_a0_(min_a0), min_a1_(min_a1), max_a0_(max_a0), max_a1_(max_a1) {} diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 0dbbc33ab3..a91d66a8fb 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -399,6 +399,19 @@ template class ThrottleWithPriorityFilter : public ValueListFilter uint32_t min_time_between_inputs_; }; +/// Specialization of ThrottleWithPriorityFilter for the common "prioritize NaN" +/// case: skips the TemplatableFn array + lambda and inlines the check. +class ThrottleWithPriorityNanFilter : public Filter { + public: + explicit ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs); + + optional new_value(float value) override; + + protected: + uint32_t last_input_{0}; + uint32_t min_time_between_inputs_; +}; + // Base class for timeout filters - contains common loop logic class TimeoutFilterBase : public Filter, public Component { public: From 290e213cd088c34f6156b6d99dc41eaa858e093f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 18 Apr 2026 06:41:33 +1000 Subject: [PATCH 0294/2214] [mipi_spi] Add Sunton ESP32-2424S012 (#15812) --- esphome/components/mipi_spi/models/cyd.py | 11 ++++++++++- esphome/components/mipi_spi/models/ili.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_spi/models/cyd.py b/esphome/components/mipi_spi/models/cyd.py index 7229412f18..0a35cb4fee 100644 --- a/esphome/components/mipi_spi/models/cyd.py +++ b/esphome/components/mipi_spi/models/cyd.py @@ -1,4 +1,6 @@ -from .ili import ILI9341, ILI9342, ST7789V +from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER + +from .ili import GC9A01A, ILI9341, ILI9342, ST7789V ILI9341.extend( # ESP32-2432S028 CYD board with Micro USB, has ILI9341 controller @@ -43,3 +45,10 @@ ILI9342.extend( (0xE1, 0x00, 0x0B, 0x11, 0x05, 0x13, 0x09, 0x33, 0x67, 0x48, 0x07, 0x0E, 0x0B, 0x23, 0x33, 0x0F), # Negative Gamma Correction ) ) + +GC9A01A.extend( + "ESP32-2424S012", + invert_colors=True, + cs_pin=10, + dc_pin={CONF_NUMBER: 2, CONF_IGNORE_STRAPPING_WARNING: True}, +) diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 6b672b0859..ae6accb907 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -555,7 +555,7 @@ ST7789V = DriverChip( ), ), ) -DriverChip( +GC9A01A = DriverChip( "GC9A01A", mirror_x=True, width=240, From 1bf455cfbb0308e3d86ae3d7f9e662f27f32cda1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 18 Apr 2026 06:42:45 +1000 Subject: [PATCH 0295/2214] [runtime_image] Fix RGB order (#15813) --- esphome/components/runtime_image/runtime_image.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index fa42b53496..4c7f1bfb6f 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -127,9 +127,9 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { uint32_t pos = this->get_position_(x, y); Color mapped_color = color; this->map_chroma_key(mapped_color); - this->buffer_[pos + 0] = mapped_color.r; + this->buffer_[pos + 0] = mapped_color.b; this->buffer_[pos + 1] = mapped_color.g; - this->buffer_[pos + 2] = mapped_color.b; + this->buffer_[pos + 2] = mapped_color.r; if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { this->buffer_[pos + 3] = color.w; } From efe7bb1eb9c5d84db895fcd98ba1da3f99cfd300 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 15:52:04 -0500 Subject: [PATCH 0296/2214] [sensor] Guard NaN specialization against explicit empty value list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserves the prior behavior of `value: []` (behaves like plain throttle, no NaN bypass) by requiring a non-empty list before treating the config as NaN-only — `all([])` is vacuously true otherwise. --- esphome/components/sensor/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 7107a7a888..8dcb7165e3 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -666,7 +666,7 @@ async def throttle_with_priority_filter_to_code(config, filter_id): # omits `value:`) to avoid the TemplatableFn array + NaN lambda the # generic ValueListFilter path requires. Behavior is identical: NaN sensor # readings always bypass the throttle. - if all(isinstance(v, float) and math.isnan(v) for v in values): + if values and all(isinstance(v, float) and math.isnan(v) for v in values): filter_id = filter_id.copy() filter_id.type = ThrottleWithPriorityNanFilter return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) From e26ce59797c3209ed64a661a3897c538662489d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 16:18:39 -0500 Subject: [PATCH 0297/2214] for progmem --- esphome/cpp_generator.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 853025b4aa..a9dca970b6 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -477,7 +477,13 @@ def progmem_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") assignment = ProgmemAssignmentExpression(id_.type, id_, rhs) - CORE.add(assignment) + # Emit at file scope, not inside setup(). setup() is split into + # per-component IIFE lambdas; a function-local static declared in one + # lambda is not visible to statements in sibling lambdas that + # reference the same shared table (e.g. two lights sharing a gamma + # lookup). File-scope static constexpr is semantically identical for + # read-only lookup tables. + CORE.add_global(assignment) CORE.register_variable(id_, obj) return obj @@ -486,7 +492,7 @@ def static_const_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") assignment = StaticConstAssignmentExpression(id_.type, id_, rhs) - CORE.add(assignment) + CORE.add_global(assignment) CORE.register_variable(id_, obj) return obj From 998337e3b16551ad2380fbb143846568a2caf668 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 16:50:01 -0500 Subject: [PATCH 0298/2214] [bme680_bsec] [bme68x_bsec2] Mark the two BSEC variants as mutually exclusive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`bme680_bsec\` pulls in the BSEC v1 library (single-instance API: \`bsec_init\`, \`bsec_set_configuration\`, …) and \`bme68x_bsec2\` pulls in the BSEC v2 library (multi-instance API: \`bsec_init_m\`, \`bsec_set_configuration_m\`, …). The two libraries export different symbol sets and can't coexist in one firmware image — linking them together currently fails with undefined references to the \`*_m\` symbols. This manifests in CI when the test-component grouping logic merges \`bme680_bsec\` and \`bme68x_bsec2_i2c\` into a single build. Declare \`CONFLICTS_WITH\` on both components so the config validator rejects the combination at config time with a clear message instead of leaving users to decode a linker failure. --- esphome/components/bme680_bsec/__init__.py | 1 + esphome/components/bme68x_bsec2/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index a86e061cd4..2365f8d107 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Fr CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensor", "text_sensor"] +CONFLICTS_WITH = ["bme68x_bsec2"] MULTI_CONF = True CONF_BME680_BSEC_ID = "bme680_bsec_id" diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index b56217fac1..5083d283ef 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ) CODEOWNERS = ["@neffs", "@kbx81"] +CONFLICTS_WITH = ["bme680_bsec"] DOMAIN = "bme68x_bsec2" From 6ebe1e92eb64cf099eb4f1ae97dba5fc1471905a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 17:54:12 -0500 Subject: [PATCH 0299/2214] [ci] Scope local pylint pre-commit hook to esphome/ (#15818) --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e492d35595..d9b7df6ec5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,6 +58,7 @@ repos: entry: python3 script/run-in-env.py pylint language: system types: [python] + files: ^esphome/.+\.py$ - id: clang-tidy-hash name: Update clang-tidy hash entry: python script/clang_tidy_hash.py --update-if-changed From 562ce541a097c3ab79723553a091db07e1b847ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 17:54:24 -0500 Subject: [PATCH 0300/2214] [bme680_bsec] [bme68x_bsec2] Mark the two BSEC variants as mutually exclusive (#15826) --- esphome/components/bme680_bsec/__init__.py | 1 + esphome/components/bme68x_bsec2/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index a86e061cd4..2365f8d107 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Fr CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensor", "text_sensor"] +CONFLICTS_WITH = ["bme68x_bsec2"] MULTI_CONF = True CONF_BME680_BSEC_ID = "bme680_bsec_id" diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index b56217fac1..5083d283ef 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ) CODEOWNERS = ["@neffs", "@kbx81"] +CONFLICTS_WITH = ["bme680_bsec"] DOMAIN = "bme68x_bsec2" From d3691c7ca5d06beb1ba63be18dee66a6e3c7a47d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:17:28 +1000 Subject: [PATCH 0301/2214] [lvgl] Fix crash with snow on rotated display (#15822) --- esphome/components/lvgl/lvgl_esphome.cpp | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index ce9b013dcf..d8248e4aa4 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -642,26 +642,28 @@ void LvglComponent::write_random_() { int iterations = 6 - lv_display_get_inactive_time(this->disp_) / 60000; if (iterations <= 0) iterations = 1; + int16_t width = lv_display_get_horizontal_resolution(this->disp_); + int16_t height = lv_display_get_vertical_resolution(this->disp_); while (iterations-- != 0) { - int32_t col = random_uint32() % this->width_; + int32_t col = random_uint32() % width; col = col / this->draw_rounding * this->draw_rounding; - int32_t row = random_uint32() % this->height_; + int32_t row = random_uint32() % height; row = row / this->draw_rounding * this->draw_rounding; // size will be between 8 and 32, and a multiple of draw_rounding int32_t size = (random_uint32() % 25 + 8) / this->draw_rounding * this->draw_rounding; - lv_area_t area{col, row, col + size - 1, row + size - 1}; + lv_area_t area{.x1 = col, .y1 = row, .x2 = col + size - 1, .y2 = row + size - 1}; // clip to display bounds just in case - if (area.x2 >= this->width_) - area.x2 = this->width_ - 1; - if (area.y2 >= this->height_) - area.y2 = this->height_ - 1; + if (area.x2 >= width) + area.x2 = width - 1; + if (area.y2 >= height) + area.y2 = height - 1; // line_len can't exceed 1024, and minimum buffer size is 2048, so this won't overflow the buffer size_t line_len = lv_area_get_width(&area) * lv_area_get_height(&area) / 2; for (size_t i = 0; i != line_len; i++) { - ((uint32_t *) (this->draw_buf_))[i] = random_uint32(); + reinterpret_cast(this->draw_buf_)[i] = random_uint32(); } - this->draw_buffer_(&area, (lv_color_data *) this->draw_buf_); + this->draw_buffer_(&area, reinterpret_cast(this->draw_buf_)); } } From 327f03fe4fc68720b941dd3fd23be7cb42573770 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 05:23:22 -0500 Subject: [PATCH 0302/2214] [core] Feed WDT unconditionally in main loop Fixes the task watchdog firing on configs with no looping components and no scheduler work (e.g. a minimal esphome: + logger: config, or a device where every looping component has called disable_loop()). In 2026.4.0, scheduler.call() feeds the WDT per executed item and each component feeds it after its loop() runs. When neither fires on a tick, the main loop task sleeps in yield_with_select_() with nothing ever reaching arch_feed_wdt(), so the task watchdog starves and panics. Add one feed_wdt_with_time() call right after before_loop_tasks_() in Application::loop(). Rate-limited inline fast path, nearly free when the 3 ms floor has not elapsed. To keep the timestamp argument monotonic with last_wdt_feed_ (advanced by Scheduler::execute_item_() as items fire), Scheduler::call() now returns its internal `now` (advanced via `now = this->execute_item_(item, now);`), forwarded through before_loop_tasks_(). No extra millis() call needed; when no items run the returned value equals the input. --- esphome/core/application.h | 22 +++++++++++++--------- esphome/core/scheduler.cpp | 5 ++++- esphome/core/scheduler.h | 3 ++- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 82f399b2d6..9252a47446 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -402,7 +402,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline void ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); + inline uint32_t ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; } /// Process dump_config output one component per loop iteration. @@ -546,18 +546,15 @@ inline void Application::drain_wake_notifications_() { } #endif // USE_HOST -inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { +inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif - // Process scheduled tasks. Scheduler::call now feeds the watchdog itself - // after each scheduled item that actually runs, so we no longer need an - // unconditional feed here — when Scheduler::call has no work to do, the - // only elapsed time is a sleep wake + a few instructions, and when it does - // have work, it fed the wdt as it went. - this->scheduler.call(loop_start_time); + // Scheduler::call feeds the WDT per item and returns the timestamp of the + // last fired item, or the input unchanged when nothing ran. + uint32_t last_op_end_time = this->scheduler.call(loop_start_time); // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions @@ -575,6 +572,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ // Mark that we're in the loop for safe reentrant modifications this->in_loop_ = true; + return last_op_end_time; } inline void ESPHOME_ALWAYS_INLINE Application::loop() { @@ -592,7 +590,13 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Get the initial loop time at the start uint32_t last_op_end_time = millis(); - this->before_loop_tasks_(last_op_end_time); + // Returned timestamp keeps us monotonic with last_wdt_feed_ (advanced by + // the scheduler's per-item feeds) without an extra millis() call. + last_op_end_time = this->before_loop_tasks_(last_op_end_time); + // Guarantee a WDT touch every tick — covers configs with no looping + // components and no scheduler work, where the per-item / per-component + // feeds never fire. Rate-limited inline fast path, ~free when unneeded. + this->feed_wdt_with_time(last_op_end_time); #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 7e6ad19ac7..b0eaa670ac 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -533,7 +533,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -void HOT Scheduler::call(uint32_t now) { +uint32_t HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -703,6 +703,9 @@ void HOT Scheduler::call(uint32_t now) { this->debug_verify_no_leak_(); } #endif + // execute_item_() advances `now` as items fire; return it so the caller + // stays monotonic with last_wdt_feed_. + return now; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7634b3bd08..b0ce365a6f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -129,7 +129,8 @@ class Scheduler { // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - void call(uint32_t now); + // @return Timestamp of the last item that ran, or `now` unchanged if none ran. + uint32_t call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). From fe00a5445e4a5b60c6d09b04c94a4dbdaceb2069 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:15:40 -0500 Subject: [PATCH 0303/2214] [ci] Honor CONFLICTS_WITH when grouping component tests The test-grouping pipeline merged components that share a bus signature into a single config without checking CONFLICTS_WITH. When bme68x_bsec2 declared CONFLICTS_WITH=["bme680_bsec"] (and vice versa), the merged YAML containing both failed validation. Statically parse AUTO_LOAD and CONFLICTS_WITH from every component __init__.py, propagate conflicts through AUTO_LOAD, and split any group that contains a conflicting pair into separate builds. --- script/analyze_component_buses.py | 86 +++++++++++++++++++++++++++++++ script/test_build_components.py | 8 +++ 2 files changed, 94 insertions(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 17af7af577..349588c8d9 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -24,6 +24,7 @@ Example output: from __future__ import annotations import argparse +import ast from functools import lru_cache import json from pathlib import Path @@ -42,6 +43,9 @@ from esphome.config_helpers import Extend, Remove # Path to common bus configs COMMON_BUS_PATH = Path("tests/test_build_components/common") +# Path to esphome component sources (used to parse AUTO_LOAD / CONFLICTS_WITH metadata) +COMPONENTS_SRC_PATH = Path("esphome/components") + # Package dependencies - maps packages to the packages they include # When a component uses a package on the left, it automatically gets # the packages on the right as well @@ -511,6 +515,88 @@ def merge_compatible_bus_groups( return merged_groups +@lru_cache(maxsize=1) +def _component_conflicts() -> dict[str, frozenset[str]]: + """Return {component: components it conflicts with} via static AUTO_LOAD/CONFLICTS_WITH. + + A conflict propagates through AUTO_LOAD: if X declares CONFLICTS_WITH=[Y] + and Z auto-loads Y, then X and Z conflict (e.g. bme680_bsec vs. + bme68x_bsec2_i2c which auto-loads bme68x_bsec2). + """ + auto_load: dict[str, set[str]] = {} + direct: dict[str, set[str]] = {} + for comp_dir in ( + COMPONENTS_SRC_PATH.iterdir() if COMPONENTS_SRC_PATH.exists() else () + ): + init_file = comp_dir / "__init__.py" + if not comp_dir.is_dir() or not init_file.exists(): + continue + try: + tree = ast.parse(init_file.read_text()) + except (OSError, SyntaxError): + continue + for node in tree.body: + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.List): + continue + values = { + e.value + for e in node.value.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str) + } + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "AUTO_LOAD": + auto_load[comp_dir.name] = values + elif isinstance(target, ast.Name) and target.id == "CONFLICTS_WITH": + direct[comp_dir.name] = values + + # For each component, compute the full set of components that end up + # loaded alongside it (itself + everything it transitively auto-loads). + loaded_with: dict[str, set[str]] = {} + for name in auto_load.keys() | direct.keys(): + loaded, stack = set(), [name] + while stack: + n = stack.pop() + if n not in loaded: + loaded.add(n) + stack.extend(auto_load.get(n, ())) + loaded_with[name] = loaded + + result: dict[str, frozenset[str]] = {} + for name, loaded in loaded_with.items(): + rejects = {c for n in loaded for c in direct.get(n, ())} + result[name] = frozenset( + other + for other, other_loaded in loaded_with.items() + if rejects & other_loaded + ) + return result + + +def split_conflicting_groups( + grouped_components: dict[tuple[str, str], list[str]], +) -> dict[tuple[str, str], list[str]]: + """Split groups so that components declaring mutual CONFLICTS_WITH end up in separate builds.""" + conflicts = _component_conflicts() + result: dict[tuple[str, str], list[str]] = {} + for (platform, signature), components in grouped_components.items(): + buckets: list[list[str]] = [] + for comp in components: + conflict_set = conflicts.get(comp, frozenset()) + for bucket in buckets: + if conflict_set.isdisjoint(bucket): + bucket.append(comp) + break + else: + buckets.append([comp]) + if len(buckets) == 1: + result[(platform, signature)] = buckets[0] + continue + for index, bucket in enumerate(buckets): + key = signature if index == 0 else f"{signature}__conflict{index}" + result[(platform, key)] = bucket + return result + + def create_grouping_signature( platform_buses: dict[str, list[str]], platform: str ) -> str: diff --git a/script/test_build_components.py b/script/test_build_components.py index e369b0364e..fa5a02155a 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -37,6 +37,7 @@ from script.analyze_component_buses import ( create_grouping_signature, is_platform_component, merge_compatible_bus_groups, + split_conflicting_groups, uses_local_file_references, ) from script.helpers import get_component_test_files @@ -675,6 +676,13 @@ def run_grouped_component_tests( # as long as they don't have conflicting configurations for the same bus type grouped_components = merge_compatible_bus_groups(grouped_components) + # Split groups that contain components declaring CONFLICTS_WITH each other. + # The bus-level merge above only considers shared bus configs; components + # with the same bus signature (e.g. both I2C) can still be mutually + # incompatible (e.g. bme680_bsec vs. bme68x_bsec2_i2c which auto-loads + # bme68x_bsec2). Those must end up in separate builds. + grouped_components = split_conflicting_groups(grouped_components) + # Print detailed grouping plan print("\nGrouping Plan:") print("-" * 80) From 9e714a39423e12b46dd5626a19b908779e8aff96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:18:02 -0500 Subject: [PATCH 0304/2214] [ci] Clean up AST-target dispatch in _component_conflicts --- script/analyze_component_buses.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 349588c8d9..6108f68c26 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -535,19 +535,18 @@ def _component_conflicts() -> dict[str, frozenset[str]]: tree = ast.parse(init_file.read_text()) except (OSError, SyntaxError): continue + targets = {"AUTO_LOAD": auto_load, "CONFLICTS_WITH": direct} for node in tree.body: if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.List): continue - values = { - e.value - for e in node.value.elts - if isinstance(e, ast.Constant) and isinstance(e.value, str) - } for target in node.targets: - if isinstance(target, ast.Name) and target.id == "AUTO_LOAD": - auto_load[comp_dir.name] = values - elif isinstance(target, ast.Name) and target.id == "CONFLICTS_WITH": - direct[comp_dir.name] = values + if not isinstance(target, ast.Name) or target.id not in targets: + continue + targets[target.id][comp_dir.name] = { + e.value + for e in node.value.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str) + } # For each component, compute the full set of components that end up # loaded alongside it (itself + everything it transitively auto-loads). From 875de948e85a2fbdb56f915750591840c4185341 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:19:35 -0500 Subject: [PATCH 0305/2214] [ci] Scope conflict parsing to batch components only --- script/analyze_component_buses.py | 118 ++++++++++++++++-------------- 1 file changed, 63 insertions(+), 55 deletions(-) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 6108f68c26..cb5e045131 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -25,7 +25,7 @@ from __future__ import annotations import argparse import ast -from functools import lru_cache +from functools import cache, lru_cache import json from pathlib import Path import re @@ -515,67 +515,75 @@ def merge_compatible_bus_groups( return merged_groups -@lru_cache(maxsize=1) -def _component_conflicts() -> dict[str, frozenset[str]]: - """Return {component: components it conflicts with} via static AUTO_LOAD/CONFLICTS_WITH. - - A conflict propagates through AUTO_LOAD: if X declares CONFLICTS_WITH=[Y] - and Z auto-loads Y, then X and Z conflict (e.g. bme680_bsec vs. - bme68x_bsec2_i2c which auto-loads bme68x_bsec2). - """ - auto_load: dict[str, set[str]] = {} - direct: dict[str, set[str]] = {} - for comp_dir in ( - COMPONENTS_SRC_PATH.iterdir() if COMPONENTS_SRC_PATH.exists() else () - ): - init_file = comp_dir / "__init__.py" - if not comp_dir.is_dir() or not init_file.exists(): +@cache +def _parse_component_init(name: str) -> tuple[frozenset[str], frozenset[str]]: + """Return (AUTO_LOAD, CONFLICTS_WITH) list-literal values for a single component.""" + init_file = COMPONENTS_SRC_PATH / name / "__init__.py" + result: dict[str, frozenset[str]] = { + "AUTO_LOAD": frozenset(), + "CONFLICTS_WITH": frozenset(), + } + if not init_file.exists(): + return result["AUTO_LOAD"], result["CONFLICTS_WITH"] + try: + tree = ast.parse(init_file.read_text()) + except (OSError, SyntaxError): + return result["AUTO_LOAD"], result["CONFLICTS_WITH"] + for node in tree.body: + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.List): continue - try: - tree = ast.parse(init_file.read_text()) - except (OSError, SyntaxError): - continue - targets = {"AUTO_LOAD": auto_load, "CONFLICTS_WITH": direct} - for node in tree.body: - if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.List): + for target in node.targets: + if not isinstance(target, ast.Name) or target.id not in result: continue - for target in node.targets: - if not isinstance(target, ast.Name) or target.id not in targets: - continue - targets[target.id][comp_dir.name] = { - e.value - for e in node.value.elts - if isinstance(e, ast.Constant) and isinstance(e.value, str) - } - - # For each component, compute the full set of components that end up - # loaded alongside it (itself + everything it transitively auto-loads). - loaded_with: dict[str, set[str]] = {} - for name in auto_load.keys() | direct.keys(): - loaded, stack = set(), [name] - while stack: - n = stack.pop() - if n not in loaded: - loaded.add(n) - stack.extend(auto_load.get(n, ())) - loaded_with[name] = loaded - - result: dict[str, frozenset[str]] = {} - for name, loaded in loaded_with.items(): - rejects = {c for n in loaded for c in direct.get(n, ())} - result[name] = frozenset( - other - for other, other_loaded in loaded_with.items() - if rejects & other_loaded - ) - return result + result[target.id] = frozenset( + e.value + for e in node.value.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str) + ) + return result["AUTO_LOAD"], result["CONFLICTS_WITH"] def split_conflicting_groups( grouped_components: dict[tuple[str, str], list[str]], ) -> dict[tuple[str, str], list[str]]: - """Split groups so that components declaring mutual CONFLICTS_WITH end up in separate builds.""" - conflicts = _component_conflicts() + """Split groups so that components declaring mutual CONFLICTS_WITH end up in separate builds. + + A conflict propagates through AUTO_LOAD: if X declares CONFLICTS_WITH=[Y] + and Z auto-loads Y, then X and Z conflict (e.g. bme680_bsec vs. + bme68x_bsec2_i2c which auto-loads bme68x_bsec2). Only components that + appear in the batch (and their AUTO_LOAD closures) are parsed. + """ + batch = {c for comps in grouped_components.values() for c in comps} + + # For each batch component, compute the set of components loaded alongside it + # (itself + everything transitively auto-loaded) and the names it rejects. + loaded_with: dict[str, set[str]] = {} + rejects: dict[str, set[str]] = {} + for comp in batch: + loaded, stack = set(), [comp] + comp_rejects: set[str] = set() + while stack: + name = stack.pop() + if name in loaded: + continue + loaded.add(name) + auto_load, conflicts_with = _parse_component_init(name) + comp_rejects.update(conflicts_with) + stack.extend(auto_load) + loaded_with[comp] = loaded + rejects[comp] = comp_rejects + + # Build symmetric conflict map restricted to the batch. The relation must + # be symmetric even if only one side declares CONFLICTS_WITH (e.g. + # ethernet declares it against wifi, wifi does not declare the reverse). + conflicts: dict[str, set[str]] = {comp: set() for comp in batch} + batch_list = list(batch) + for i, a in enumerate(batch_list): + for b in batch_list[i + 1 :]: + if rejects[a] & loaded_with[b] or rejects[b] & loaded_with[a]: + conflicts[a].add(b) + conflicts[b].add(a) + result: dict[tuple[str, str], list[str]] = {} for (platform, signature), components in grouped_components.items(): buckets: list[list[str]] = [] From 698dbbdcb52ffabe23f73ecdf9a22c4800e85ebc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:21:43 -0500 Subject: [PATCH 0306/2214] [ci] Simplify conflict walk --- script/analyze_component_buses.py | 45 +++++++++++++------------------ 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index cb5e045131..ae90da91f3 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -555,42 +555,33 @@ def split_conflicting_groups( """ batch = {c for comps in grouped_components.values() for c in comps} - # For each batch component, compute the set of components loaded alongside it - # (itself + everything transitively auto-loaded) and the names it rejects. - loaded_with: dict[str, set[str]] = {} - rejects: dict[str, set[str]] = {} + # Walk each batch component's AUTO_LOAD chain once, collecting the full + # loaded-alongside set and the names it rejects via CONFLICTS_WITH. Two + # components conflict when either one's rejects intersect the other's + # loaded set (relation must be symmetric -- e.g. ethernet rejects wifi + # but wifi does not reject ethernet). + walks: dict[str, tuple[set[str], set[str]]] = {} for comp in batch: - loaded, stack = set(), [comp] - comp_rejects: set[str] = set() + loaded, rejects, stack = {comp}, set(), [comp] while stack: - name = stack.pop() - if name in loaded: - continue - loaded.add(name) - auto_load, conflicts_with = _parse_component_init(name) - comp_rejects.update(conflicts_with) - stack.extend(auto_load) - loaded_with[comp] = loaded - rejects[comp] = comp_rejects + auto_load, conflicts_with = _parse_component_init(stack.pop()) + rejects |= conflicts_with + new = auto_load - loaded + loaded |= new + stack.extend(new) + walks[comp] = (loaded, rejects) - # Build symmetric conflict map restricted to the batch. The relation must - # be symmetric even if only one side declares CONFLICTS_WITH (e.g. - # ethernet declares it against wifi, wifi does not declare the reverse). - conflicts: dict[str, set[str]] = {comp: set() for comp in batch} - batch_list = list(batch) - for i, a in enumerate(batch_list): - for b in batch_list[i + 1 :]: - if rejects[a] & loaded_with[b] or rejects[b] & loaded_with[a]: - conflicts[a].add(b) - conflicts[b].add(a) + def conflicts(a: str, b: str) -> bool: + loaded_a, rejects_a = walks[a] + loaded_b, rejects_b = walks[b] + return not rejects_a.isdisjoint(loaded_b) or not rejects_b.isdisjoint(loaded_a) result: dict[tuple[str, str], list[str]] = {} for (platform, signature), components in grouped_components.items(): buckets: list[list[str]] = [] for comp in components: - conflict_set = conflicts.get(comp, frozenset()) for bucket in buckets: - if conflict_set.isdisjoint(bucket): + if not any(conflicts(comp, other) for other in bucket): bucket.append(comp) break else: From 897dd7cd5eb5e9152132cb7a3c46a5f7e0e82d3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:26:03 -0500 Subject: [PATCH 0307/2214] [ci] Move conflict-aware grouping helpers into script/helpers.py with tests --- script/analyze_component_buses.py | 86 +--------------- script/helpers.py | 105 ++++++++++++++++++++ script/test_build_components.py | 3 +- tests/script/test_helpers.py | 156 ++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 87 deletions(-) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index ae90da91f3..17af7af577 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -24,8 +24,7 @@ Example output: from __future__ import annotations import argparse -import ast -from functools import cache, lru_cache +from functools import lru_cache import json from pathlib import Path import re @@ -43,9 +42,6 @@ from esphome.config_helpers import Extend, Remove # Path to common bus configs COMMON_BUS_PATH = Path("tests/test_build_components/common") -# Path to esphome component sources (used to parse AUTO_LOAD / CONFLICTS_WITH metadata) -COMPONENTS_SRC_PATH = Path("esphome/components") - # Package dependencies - maps packages to the packages they include # When a component uses a package on the left, it automatically gets # the packages on the right as well @@ -515,86 +511,6 @@ def merge_compatible_bus_groups( return merged_groups -@cache -def _parse_component_init(name: str) -> tuple[frozenset[str], frozenset[str]]: - """Return (AUTO_LOAD, CONFLICTS_WITH) list-literal values for a single component.""" - init_file = COMPONENTS_SRC_PATH / name / "__init__.py" - result: dict[str, frozenset[str]] = { - "AUTO_LOAD": frozenset(), - "CONFLICTS_WITH": frozenset(), - } - if not init_file.exists(): - return result["AUTO_LOAD"], result["CONFLICTS_WITH"] - try: - tree = ast.parse(init_file.read_text()) - except (OSError, SyntaxError): - return result["AUTO_LOAD"], result["CONFLICTS_WITH"] - for node in tree.body: - if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.List): - continue - for target in node.targets: - if not isinstance(target, ast.Name) or target.id not in result: - continue - result[target.id] = frozenset( - e.value - for e in node.value.elts - if isinstance(e, ast.Constant) and isinstance(e.value, str) - ) - return result["AUTO_LOAD"], result["CONFLICTS_WITH"] - - -def split_conflicting_groups( - grouped_components: dict[tuple[str, str], list[str]], -) -> dict[tuple[str, str], list[str]]: - """Split groups so that components declaring mutual CONFLICTS_WITH end up in separate builds. - - A conflict propagates through AUTO_LOAD: if X declares CONFLICTS_WITH=[Y] - and Z auto-loads Y, then X and Z conflict (e.g. bme680_bsec vs. - bme68x_bsec2_i2c which auto-loads bme68x_bsec2). Only components that - appear in the batch (and their AUTO_LOAD closures) are parsed. - """ - batch = {c for comps in grouped_components.values() for c in comps} - - # Walk each batch component's AUTO_LOAD chain once, collecting the full - # loaded-alongside set and the names it rejects via CONFLICTS_WITH. Two - # components conflict when either one's rejects intersect the other's - # loaded set (relation must be symmetric -- e.g. ethernet rejects wifi - # but wifi does not reject ethernet). - walks: dict[str, tuple[set[str], set[str]]] = {} - for comp in batch: - loaded, rejects, stack = {comp}, set(), [comp] - while stack: - auto_load, conflicts_with = _parse_component_init(stack.pop()) - rejects |= conflicts_with - new = auto_load - loaded - loaded |= new - stack.extend(new) - walks[comp] = (loaded, rejects) - - def conflicts(a: str, b: str) -> bool: - loaded_a, rejects_a = walks[a] - loaded_b, rejects_b = walks[b] - return not rejects_a.isdisjoint(loaded_b) or not rejects_b.isdisjoint(loaded_a) - - result: dict[tuple[str, str], list[str]] = {} - for (platform, signature), components in grouped_components.items(): - buckets: list[list[str]] = [] - for comp in components: - for bucket in buckets: - if not any(conflicts(comp, other) for other in bucket): - bucket.append(comp) - break - else: - buckets.append([comp]) - if len(buckets) == 1: - result[(platform, signature)] = buckets[0] - continue - for index, bucket in enumerate(buckets): - key = signature if index == 0 else f"{signature}__conflict{index}" - result[(platform, key)] = bucket - return result - - def create_grouping_signature( platform_buses: dict[str, list[str]], platform: str ) -> str: diff --git a/script/helpers.py b/script/helpers.py index 9f5ea7894c..8eff38ce92 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1,6 +1,8 @@ from __future__ import annotations +import ast from collections.abc import Callable +from dataclasses import dataclass, field from functools import cache import hashlib import json @@ -139,6 +141,109 @@ def get_component_test_files( return list(tests_dir.glob("test.*.yaml")) +@dataclass(frozen=True) +class ComponentMetadata: + """Statically-parsed AUTO_LOAD and CONFLICTS_WITH declarations.""" + + auto_load: frozenset[str] = field(default_factory=frozenset) + conflicts_with: frozenset[str] = field(default_factory=frozenset) + + +@cache +def parse_component_metadata(name: str) -> ComponentMetadata: + """Return the AUTO_LOAD / CONFLICTS_WITH declarations for a component. + + Parses the component's ``esphome/components//__init__.py`` statically. + Callable forms (``def AUTO_LOAD():``) require runtime imports and are + reported as empty -- safe for conflict detection since they cannot be + evaluated without executing the module. + """ + init_file = Path(root_path) / ESPHOME_COMPONENTS_PATH / name / "__init__.py" + if not init_file.exists(): + return ComponentMetadata() + try: + tree = ast.parse(init_file.read_text()) + except (OSError, SyntaxError): + return ComponentMetadata() + fields: dict[str, frozenset[str]] = { + "AUTO_LOAD": frozenset(), + "CONFLICTS_WITH": frozenset(), + } + for node in tree.body: + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.List): + continue + for target in node.targets: + if not isinstance(target, ast.Name) or target.id not in fields: + continue + fields[target.id] = frozenset( + e.value + for e in node.value.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str) + ) + return ComponentMetadata( + auto_load=fields["AUTO_LOAD"], + conflicts_with=fields["CONFLICTS_WITH"], + ) + + +@dataclass +class _ConflictWalk: + loaded: set[str] + rejects: set[str] + + +def split_conflicting_groups( + grouped_components: dict[tuple[str, str], list[str]], +) -> dict[tuple[str, str], list[str]]: + """Split groups so components declaring mutual CONFLICTS_WITH end up in separate builds. + + A conflict propagates through AUTO_LOAD: if X declares CONFLICTS_WITH=[Y] + and Z auto-loads Y, then X and Z conflict (e.g. bme680_bsec vs. + bme68x_bsec2_i2c which auto-loads bme68x_bsec2). Only components that + appear in the batch (and their AUTO_LOAD closures) are parsed. The + conflict relation is treated as symmetric even when only one side + declares it (e.g. ethernet rejects wifi but wifi does not declare the + reverse). + """ + batch = {c for comps in grouped_components.values() for c in comps} + + walks: dict[str, _ConflictWalk] = {} + for comp in batch: + walk = _ConflictWalk(loaded={comp}, rejects=set()) + stack = [comp] + while stack: + metadata = parse_component_metadata(stack.pop()) + walk.rejects |= metadata.conflicts_with + new = metadata.auto_load - walk.loaded + walk.loaded |= new + stack.extend(new) + walks[comp] = walk + + def conflicts(a: str, b: str) -> bool: + wa, wb = walks[a], walks[b] + return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint( + wa.loaded + ) + + result: dict[tuple[str, str], list[str]] = {} + for (platform, signature), components in grouped_components.items(): + buckets: list[list[str]] = [] + for comp in components: + for bucket in buckets: + if not any(conflicts(comp, other) for other in bucket): + bucket.append(comp) + break + else: + buckets.append([comp]) + if len(buckets) == 1: + result[(platform, signature)] = buckets[0] + continue + for index, bucket in enumerate(buckets): + key = signature if index == 0 else f"{signature}__conflict{index}" + result[(platform, key)] = bucket + return result + + def styled(color: str | tuple[str, ...], msg: str, reset: bool = True) -> str: prefix = "".join(color) if isinstance(color, tuple) else color suffix = colorama.Style.RESET_ALL if reset else "" diff --git a/script/test_build_components.py b/script/test_build_components.py index fa5a02155a..82d05f78b2 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -37,10 +37,9 @@ from script.analyze_component_buses import ( create_grouping_signature, is_platform_component, merge_compatible_bus_groups, - split_conflicting_groups, uses_local_file_references, ) -from script.helpers import get_component_test_files +from script.helpers import get_component_test_files, split_conflicting_groups from script.merge_component_configs import merge_component_configs diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 948aabaa66..db0d2908f4 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -1468,3 +1468,159 @@ def test_cache_miss_corrupted_json( result = helpers.create_components_graph() # Should handle corruption gracefully and rebuild assert result == {} + + +# --------------------------------------------------------------------------- +# parse_component_metadata / split_conflicting_groups +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_components(tmp_path: Path) -> Path: + """Create a fake esphome/components/ tree and return the repo root. + + Component layout (tested against split_conflicting_groups): + + alpha -- CONFLICTS_WITH=["beta"] + beta -- CONFLICTS_WITH=["alpha"] + beta_variant -- AUTO_LOAD=["beta"] + gamma -- (no metadata) + one_sided -- CONFLICTS_WITH=["plain"] (plain does not reject back) + plain -- no CONFLICTS_WITH + callable_auto -- AUTO_LOAD is a function (not a list literal) -> ignored + broken -- __init__.py has a SyntaxError + """ + components = tmp_path / "esphome" / "components" + components.mkdir(parents=True) + + def write(name: str, body: str) -> None: + (components / name).mkdir() + (components / name / "__init__.py").write_text(body) + + write("alpha", 'CONFLICTS_WITH = ["beta"]\n') + write("beta", 'CONFLICTS_WITH = ["alpha"]\n') + write("beta_variant", 'AUTO_LOAD = ["beta"]\n') + write("gamma", "") + write("one_sided", 'CONFLICTS_WITH = ["plain"]\n') + write("plain", "") + write("callable_auto", "def AUTO_LOAD():\n return ['beta']\n") + write("broken", "this is not valid python !!!") + helpers.parse_component_metadata.cache_clear() + return tmp_path + + +def test_parse_component_metadata_list_literals( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + meta = helpers.parse_component_metadata("alpha") + assert meta.conflicts_with == frozenset({"beta"}) + assert meta.auto_load == frozenset() + + variant = helpers.parse_component_metadata("beta_variant") + assert variant.auto_load == frozenset({"beta"}) + assert variant.conflicts_with == frozenset() + + +def test_parse_component_metadata_missing_empty_and_callable( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + # Unknown component -> empty metadata, not an error. + unknown = helpers.parse_component_metadata("does_not_exist") + assert unknown == helpers.ComponentMetadata() + + # Empty __init__.py -> empty metadata. + assert helpers.parse_component_metadata("gamma") == helpers.ComponentMetadata() + + # Callable AUTO_LOAD cannot be statically evaluated -> empty. + callable_meta = helpers.parse_component_metadata("callable_auto") + assert callable_meta.auto_load == frozenset() + + # SyntaxError in __init__.py must not raise. + assert helpers.parse_component_metadata("broken") == helpers.ComponentMetadata() + + +def test_split_conflicting_groups_splits_direct_conflict( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["alpha", "beta", "gamma"]} + ) + # alpha and beta must end up in different buckets; gamma has no conflicts. + buckets = list(result.values()) + assert any("alpha" in b for b in buckets) + assert any("beta" in b for b in buckets) + for bucket in buckets: + assert not ({"alpha", "beta"} <= set(bucket)) + # Gamma sticks with whichever bucket it landed in first (alpha's). + all_members = {c for b in buckets for c in b} + assert all_members == {"alpha", "beta", "gamma"} + + +def test_split_conflicting_groups_propagates_through_auto_load( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """A component that AUTO_LOADs a conflicting one must also be split out.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["alpha", "beta_variant"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"alpha", "beta_variant"} <= set(bucket)) + assert sum(len(b) for b in buckets) == 2 + + +def test_split_conflicting_groups_symmetric_one_sided_declaration( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """If only one side declares CONFLICTS_WITH, the pair must still be split.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["one_sided", "plain"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"one_sided", "plain"} <= set(bucket)) + + +def test_split_conflicting_groups_preserves_non_conflicting_group( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + original = {("esp32", "i2c"): ["alpha", "gamma", "plain"]} + result = helpers.split_conflicting_groups(original) + # All three are mutually compatible -- the group must not be split. + assert result == original + + +def test_split_conflicting_groups_preserves_original_signature_for_first_bucket( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """When a group is split, the first bucket keeps the original signature key.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups({("esp32", "i2c"): ["alpha", "beta"]}) + keys = set(result.keys()) + assert ("esp32", "i2c") in keys + # One additional bucket with a disambiguated signature. + extra = keys - {("esp32", "i2c")} + assert len(extra) == 1 + platform, signature = next(iter(extra)) + assert platform == "esp32" + assert signature.startswith("i2c__conflict") From c122fc8c78304f141770f6c68bfc5bc944dd54e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:30:03 -0500 Subject: [PATCH 0308/2214] [ci] Apply conflict split before CI batch distribution Previously split_conflicting_groups ran only inside test_build_components. The CI-side batcher (split_components_for_ci.py / determine-jobs.py) still saw the pre-split group, so its weight budgeting assumed one build where runtime produces two. Apply the split where the groups are formed so batch distribution reflects actual build counts. --- script/split_components_for_ci.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 65d09efb9b..d95cdcbe81 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -28,7 +28,7 @@ from script.analyze_component_buses import ( create_grouping_signature, merge_compatible_bus_groups, ) -from script.helpers import get_component_test_files +from script.helpers import get_component_test_files, split_conflicting_groups # Weighting for batch creation # Isolated components can't be grouped/merged, so they count as 10x @@ -145,6 +145,11 @@ def create_intelligent_batches( # improving the efficiency of test_build_components.py grouping signature_groups = merge_compatible_bus_groups(signature_groups) + # Split groups containing mutually-incompatible components (CONFLICTS_WITH). + # Without this, batch weighting assumes the group is one build when it will + # actually be split into two at build time -- throwing off CI distribution. + signature_groups = split_conflicting_groups(signature_groups) + # Create batches by keeping signature groups together # Components with the same signature stay in the same batches batches = [] From df72aa26c0a11a00b581ef65f5c528c840a20f70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:58:54 -0500 Subject: [PATCH 0309/2214] [core] Feed WDT unconditionally in main loop to fix empty-config panic (#15830) --- esphome/core/application.h | 22 +++++++++++++--------- esphome/core/scheduler.cpp | 5 ++++- esphome/core/scheduler.h | 3 ++- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 82f399b2d6..9252a47446 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -402,7 +402,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline void ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); + inline uint32_t ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; } /// Process dump_config output one component per loop iteration. @@ -546,18 +546,15 @@ inline void Application::drain_wake_notifications_() { } #endif // USE_HOST -inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { +inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif - // Process scheduled tasks. Scheduler::call now feeds the watchdog itself - // after each scheduled item that actually runs, so we no longer need an - // unconditional feed here — when Scheduler::call has no work to do, the - // only elapsed time is a sleep wake + a few instructions, and when it does - // have work, it fed the wdt as it went. - this->scheduler.call(loop_start_time); + // Scheduler::call feeds the WDT per item and returns the timestamp of the + // last fired item, or the input unchanged when nothing ran. + uint32_t last_op_end_time = this->scheduler.call(loop_start_time); // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions @@ -575,6 +572,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ // Mark that we're in the loop for safe reentrant modifications this->in_loop_ = true; + return last_op_end_time; } inline void ESPHOME_ALWAYS_INLINE Application::loop() { @@ -592,7 +590,13 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Get the initial loop time at the start uint32_t last_op_end_time = millis(); - this->before_loop_tasks_(last_op_end_time); + // Returned timestamp keeps us monotonic with last_wdt_feed_ (advanced by + // the scheduler's per-item feeds) without an extra millis() call. + last_op_end_time = this->before_loop_tasks_(last_op_end_time); + // Guarantee a WDT touch every tick — covers configs with no looping + // components and no scheduler work, where the per-item / per-component + // feeds never fire. Rate-limited inline fast path, ~free when unneeded. + this->feed_wdt_with_time(last_op_end_time); #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 7e6ad19ac7..b0eaa670ac 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -533,7 +533,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -void HOT Scheduler::call(uint32_t now) { +uint32_t HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -703,6 +703,9 @@ void HOT Scheduler::call(uint32_t now) { this->debug_verify_no_leak_(); } #endif + // execute_item_() advances `now` as items fire; return it so the caller + // stays monotonic with last_wdt_feed_. + return now; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7634b3bd08..b0ce365a6f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -129,7 +129,8 @@ class Scheduler { // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - void call(uint32_t now); + // @return Timestamp of the last item that ran, or `now` unchanged if none ran. + uint32_t call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). From 4e05d39f4b9c26621b56270e35a3749ad0d906f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 07:09:52 -0500 Subject: [PATCH 0310/2214] [core] use post-scheduler timestamp for gate check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scheduler_tick_ now returns the scheduler's advanced timestamp (free via PR #15830's Scheduler::call return). Previously we still used the pre-scheduler millis() for `elapsed = now - last_loop_`, which underestimated elapsed time by whatever the scheduler dispatch took. Adopt the returned value as `now` so the gate check, WDT feed, runtime stats, and sleep computation all see consistent post-scheduler time. Drops the obsolete "we deliberately reuse pre-scheduler now" comment — that rationale was predicated on saving a millis() call, which no longer applies. --- esphome/core/application.h | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index f480c15ab6..037789ae8b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -596,19 +596,19 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; #endif - uint32_t now = millis(); - // Phase A: always service the scheduler. Decouples scheduler cadence from // loop_interval_ so raised intervals (for power savings) don't drag scheduled // items forward. A tick that only runs the scheduler is cheap. - // Returned timestamp keeps us monotonic with last_wdt_feed_ (advanced by the - // scheduler's per-item feeds) without an extra millis() call. - const uint32_t scheduler_end = this->scheduler_tick_(now); + // scheduler_tick_ returns the timestamp of the last scheduler item that ran + // (advanced by its per-item feeds) or `now` unchanged. We adopt it as `now` + // so the gate check and WDT feed both reflect actual elapsed time after + // scheduler dispatch, without an extra millis() call. + uint32_t now = this->scheduler_tick_(millis()); // Guarantee one WDT feed per tick even when the scheduler had nothing to // dispatch and the component phase is gated out — covers configs with no // looping components and no scheduler work (setup() has its own // per-component feed_wdt calls, so only do this here, not in scheduler_tick_). - this->feed_wdt_with_time(scheduler_end); + this->feed_wdt_with_time(now); #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); @@ -620,11 +620,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Gate the component phase on loop_interval_ or an active high-frequency // request. When a scheduler wake preempts sleep early, this gate keeps the // component phase from running more often than loop_interval_. - // We deliberately reuse the pre-scheduler `now` rather than refreshing it - // via another millis() call: empty scheduler.call() is sub-microsecond, and - // any scheduled item that takes long enough to matter is a blocking - // violation that will self-correct on the next tick anyway. The one saved - // millis() call per tick is worth that acceptable one-tick drift. const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency(); const uint32_t elapsed = now - this->last_loop_; const bool do_component_phase = high_frequency || (elapsed >= this->loop_interval_); From ec9d59f3dc61535c069b8bd769b205d2b9b308d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:32:36 +0000 Subject: [PATCH 0311/2214] Bump aioesphomeapi from 44.16.0 to 44.16.1 (#15836) 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 36c81e25bf..1623876cb5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.16.0 +aioesphomeapi==44.16.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From ccb53e34ca677659e78abdfe0d72ac95be179372 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:04:51 -0500 Subject: [PATCH 0312/2214] [core] Default PollingComponent() to 1ms when codegen is bypassed (#15831) --- esphome/core/component.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 6fbb0d5c06..717ca36257 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -601,7 +601,7 @@ class Component { */ class PollingComponent : public Component { public: - PollingComponent() : PollingComponent(0) {} + PollingComponent() : PollingComponent(1) {} /** Initialize this polling component with the given update interval in ms. * From 63606dd4bb14d1b8275d8cee01f1bb5821df20c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:09:03 -0500 Subject: [PATCH 0313/2214] [esp8266] Pin tool-esptoolpy to 5.x to match ESP32 platform The platform-espressif8266 4.2.1 platform manifest pins tool-esptoolpy to ~1.30000.0, while the pioarduino ESP32 platform uses 5.2.0. Because PlatformIO stores tool-esptoolpy in a single shared package directory, switching between esp8266 and esp32 builds forces PlatformIO to uninstall and reinstall the package every time. Override the pin on the ESP8266 side to the same pioarduino 5.2.0 build so both platforms resolve to the same installed package. ESPHome already invokes its own Python esptool 5.2.0 for uploads, and modern esptool still accepts the legacy underscore CLI flags used by the ESP8266 platform's builder scripts. --- esphome/components/esp8266/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index bef7e36470..6a72bb429e 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -264,7 +264,14 @@ async def to_code(config): cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) cg.add_platformio_option( "platform_packages", - [f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"], + [ + f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}", + # Override the ancient tool-esptoolpy ~1.30000.0 pinned by + # platform-espressif8266 4.2.1 with the same 5.x build the + # pioarduino ESP32 platform uses, so both platforms share the + # same installed package and stop reinstalling on every switch. + "pioarduino/tool-esptoolpy@https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.2.0.zip", + ], ) # Default for platformio is LWIP2_LOW_MEMORY with: From addda4c48fbd832c6410c65354da591bbbff4c7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:10:29 -0500 Subject: [PATCH 0314/2214] [esp8266] Mirror tool-esptoolpy pin in dev platformio.ini Keep local pio run -e esp8266-arduino / esp32-arduino invocations (clang-tidy, IDE) in sync with the ESPHome codegen override so they share the same installed tool-esptoolpy package. --- .clang-tidy.hash | 2 +- platformio.ini | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 72a9967590..ba22a61ede 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -075ed2142432dc59883bb52db8ac11270f952851d6400deae080f5468c7cb592 +b92adf594cf596ec5dd5858406c479ac258eb33f2a7517522ed89d4594a68f56 diff --git a/platformio.ini b/platformio.ini index e2c7e2b097..2bb5f40d09 100644 --- a/platformio.ini +++ b/platformio.ini @@ -105,6 +105,11 @@ extends = common:arduino platform = platformio/espressif8266@4.2.1 platform_packages = platformio/framework-arduinoespressif8266@~3.30102.0 + ; Override the ancient tool-esptoolpy ~1.30000.0 pinned by + ; platform-espressif8266 4.2.1 with the same 5.x build the pioarduino + ; ESP32 platform uses, so switching between esp8266 and esp32 builds + ; does not reinstall tool-esptoolpy every time. + pioarduino/tool-esptoolpy@https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.2.0.zip framework = arduino lib_deps = From b293be23b050a1da4efc5a2652b70c6168477b5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:11:38 -0500 Subject: [PATCH 0315/2214] [ci] Honor CONFLICTS_WITH when grouping component tests (#15834) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- script/helpers.py | 105 ++++++++++++++++++++ script/split_components_for_ci.py | 7 +- script/test_build_components.py | 9 +- tests/script/test_helpers.py | 156 ++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 2 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index 9f5ea7894c..7a6d7ecef6 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1,6 +1,8 @@ from __future__ import annotations +import ast from collections.abc import Callable +from dataclasses import dataclass, field from functools import cache import hashlib import json @@ -139,6 +141,109 @@ def get_component_test_files( return list(tests_dir.glob("test.*.yaml")) +@dataclass(frozen=True) +class ComponentMetadata: + """Statically-parsed AUTO_LOAD and CONFLICTS_WITH declarations.""" + + auto_load: frozenset[str] = field(default_factory=frozenset) + conflicts_with: frozenset[str] = field(default_factory=frozenset) + + +@cache +def parse_component_metadata(name: str) -> ComponentMetadata: + """Return the AUTO_LOAD / CONFLICTS_WITH declarations for a component. + + Parses the component's ``esphome/components//__init__.py`` statically. + Callable forms (``def AUTO_LOAD():``) require runtime imports and are + reported as empty -- safe for conflict detection since they cannot be + evaluated without executing the module. + """ + init_file = Path(root_path) / ESPHOME_COMPONENTS_PATH / name / "__init__.py" + if not init_file.exists(): + return ComponentMetadata() + try: + tree = ast.parse(init_file.read_text(encoding="utf-8")) + except (OSError, SyntaxError, UnicodeError): + return ComponentMetadata() + fields: dict[str, frozenset[str]] = { + "AUTO_LOAD": frozenset(), + "CONFLICTS_WITH": frozenset(), + } + for node in tree.body: + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.List): + continue + for target in node.targets: + if not isinstance(target, ast.Name) or target.id not in fields: + continue + fields[target.id] = frozenset( + e.value + for e in node.value.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str) + ) + return ComponentMetadata( + auto_load=fields["AUTO_LOAD"], + conflicts_with=fields["CONFLICTS_WITH"], + ) + + +@dataclass +class _ConflictWalk: + loaded: set[str] + rejects: set[str] + + +def split_conflicting_groups( + grouped_components: dict[tuple[str, str], list[str]], +) -> dict[tuple[str, str], list[str]]: + """Split groups so components declaring mutual CONFLICTS_WITH end up in separate builds. + + A conflict propagates through AUTO_LOAD: if X declares CONFLICTS_WITH=[Y] + and Z auto-loads Y, then X and Z conflict (e.g. bme680_bsec vs. + bme68x_bsec2_i2c which auto-loads bme68x_bsec2). Only components that + appear in the batch (and their AUTO_LOAD closures) are parsed. The + conflict relation is treated as symmetric even when only one side + declares it (e.g. ethernet rejects wifi but wifi does not declare the + reverse). + """ + batch = {c for comps in grouped_components.values() for c in comps} + + walks: dict[str, _ConflictWalk] = {} + for comp in batch: + walk = _ConflictWalk(loaded={comp}, rejects=set()) + stack = [comp] + while stack: + metadata = parse_component_metadata(stack.pop()) + walk.rejects |= metadata.conflicts_with + new = metadata.auto_load - walk.loaded + walk.loaded |= new + stack.extend(new) + walks[comp] = walk + + def conflicts(a: str, b: str) -> bool: + wa, wb = walks[a], walks[b] + return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint( + wa.loaded + ) + + result: dict[tuple[str, str], list[str]] = {} + for (platform, signature), components in grouped_components.items(): + buckets: list[list[str]] = [] + for comp in components: + for bucket in buckets: + if not any(conflicts(comp, other) for other in bucket): + bucket.append(comp) + break + else: + buckets.append([comp]) + if len(buckets) == 1: + result[(platform, signature)] = buckets[0] + continue + for index, bucket in enumerate(buckets): + key = signature if index == 0 else f"{signature}__conflict{index}" + result[(platform, key)] = bucket + return result + + def styled(color: str | tuple[str, ...], msg: str, reset: bool = True) -> str: prefix = "".join(color) if isinstance(color, tuple) else color suffix = colorama.Style.RESET_ALL if reset else "" diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 65d09efb9b..d95cdcbe81 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -28,7 +28,7 @@ from script.analyze_component_buses import ( create_grouping_signature, merge_compatible_bus_groups, ) -from script.helpers import get_component_test_files +from script.helpers import get_component_test_files, split_conflicting_groups # Weighting for batch creation # Isolated components can't be grouped/merged, so they count as 10x @@ -145,6 +145,11 @@ def create_intelligent_batches( # improving the efficiency of test_build_components.py grouping signature_groups = merge_compatible_bus_groups(signature_groups) + # Split groups containing mutually-incompatible components (CONFLICTS_WITH). + # Without this, batch weighting assumes the group is one build when it will + # actually be split into two at build time -- throwing off CI distribution. + signature_groups = split_conflicting_groups(signature_groups) + # Create batches by keeping signature groups together # Components with the same signature stay in the same batches batches = [] diff --git a/script/test_build_components.py b/script/test_build_components.py index e369b0364e..82d05f78b2 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -39,7 +39,7 @@ from script.analyze_component_buses import ( merge_compatible_bus_groups, uses_local_file_references, ) -from script.helpers import get_component_test_files +from script.helpers import get_component_test_files, split_conflicting_groups from script.merge_component_configs import merge_component_configs @@ -675,6 +675,13 @@ def run_grouped_component_tests( # as long as they don't have conflicting configurations for the same bus type grouped_components = merge_compatible_bus_groups(grouped_components) + # Split groups that contain components declaring CONFLICTS_WITH each other. + # The bus-level merge above only considers shared bus configs; components + # with the same bus signature (e.g. both I2C) can still be mutually + # incompatible (e.g. bme680_bsec vs. bme68x_bsec2_i2c which auto-loads + # bme68x_bsec2). Those must end up in separate builds. + grouped_components = split_conflicting_groups(grouped_components) + # Print detailed grouping plan print("\nGrouping Plan:") print("-" * 80) diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 948aabaa66..db0d2908f4 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -1468,3 +1468,159 @@ def test_cache_miss_corrupted_json( result = helpers.create_components_graph() # Should handle corruption gracefully and rebuild assert result == {} + + +# --------------------------------------------------------------------------- +# parse_component_metadata / split_conflicting_groups +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_components(tmp_path: Path) -> Path: + """Create a fake esphome/components/ tree and return the repo root. + + Component layout (tested against split_conflicting_groups): + + alpha -- CONFLICTS_WITH=["beta"] + beta -- CONFLICTS_WITH=["alpha"] + beta_variant -- AUTO_LOAD=["beta"] + gamma -- (no metadata) + one_sided -- CONFLICTS_WITH=["plain"] (plain does not reject back) + plain -- no CONFLICTS_WITH + callable_auto -- AUTO_LOAD is a function (not a list literal) -> ignored + broken -- __init__.py has a SyntaxError + """ + components = tmp_path / "esphome" / "components" + components.mkdir(parents=True) + + def write(name: str, body: str) -> None: + (components / name).mkdir() + (components / name / "__init__.py").write_text(body) + + write("alpha", 'CONFLICTS_WITH = ["beta"]\n') + write("beta", 'CONFLICTS_WITH = ["alpha"]\n') + write("beta_variant", 'AUTO_LOAD = ["beta"]\n') + write("gamma", "") + write("one_sided", 'CONFLICTS_WITH = ["plain"]\n') + write("plain", "") + write("callable_auto", "def AUTO_LOAD():\n return ['beta']\n") + write("broken", "this is not valid python !!!") + helpers.parse_component_metadata.cache_clear() + return tmp_path + + +def test_parse_component_metadata_list_literals( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + meta = helpers.parse_component_metadata("alpha") + assert meta.conflicts_with == frozenset({"beta"}) + assert meta.auto_load == frozenset() + + variant = helpers.parse_component_metadata("beta_variant") + assert variant.auto_load == frozenset({"beta"}) + assert variant.conflicts_with == frozenset() + + +def test_parse_component_metadata_missing_empty_and_callable( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + # Unknown component -> empty metadata, not an error. + unknown = helpers.parse_component_metadata("does_not_exist") + assert unknown == helpers.ComponentMetadata() + + # Empty __init__.py -> empty metadata. + assert helpers.parse_component_metadata("gamma") == helpers.ComponentMetadata() + + # Callable AUTO_LOAD cannot be statically evaluated -> empty. + callable_meta = helpers.parse_component_metadata("callable_auto") + assert callable_meta.auto_load == frozenset() + + # SyntaxError in __init__.py must not raise. + assert helpers.parse_component_metadata("broken") == helpers.ComponentMetadata() + + +def test_split_conflicting_groups_splits_direct_conflict( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["alpha", "beta", "gamma"]} + ) + # alpha and beta must end up in different buckets; gamma has no conflicts. + buckets = list(result.values()) + assert any("alpha" in b for b in buckets) + assert any("beta" in b for b in buckets) + for bucket in buckets: + assert not ({"alpha", "beta"} <= set(bucket)) + # Gamma sticks with whichever bucket it landed in first (alpha's). + all_members = {c for b in buckets for c in b} + assert all_members == {"alpha", "beta", "gamma"} + + +def test_split_conflicting_groups_propagates_through_auto_load( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """A component that AUTO_LOADs a conflicting one must also be split out.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["alpha", "beta_variant"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"alpha", "beta_variant"} <= set(bucket)) + assert sum(len(b) for b in buckets) == 2 + + +def test_split_conflicting_groups_symmetric_one_sided_declaration( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """If only one side declares CONFLICTS_WITH, the pair must still be split.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["one_sided", "plain"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"one_sided", "plain"} <= set(bucket)) + + +def test_split_conflicting_groups_preserves_non_conflicting_group( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + original = {("esp32", "i2c"): ["alpha", "gamma", "plain"]} + result = helpers.split_conflicting_groups(original) + # All three are mutually compatible -- the group must not be split. + assert result == original + + +def test_split_conflicting_groups_preserves_original_signature_for_first_bucket( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """When a group is split, the first bucket keeps the original signature key.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups({("esp32", "i2c"): ["alpha", "beta"]}) + keys = set(result.keys()) + assert ("esp32", "i2c") in keys + # One additional bucket with a disambiguated signature. + extra = keys - {("esp32", "i2c")} + assert len(extra) == 1 + platform, signature = next(iter(extra)) + assert platform == "esp32" + assert signature.startswith("i2c__conflict") From fe0668424276737dea5f76c1a8a04ba7d86e144b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:12:28 -0500 Subject: [PATCH 0316/2214] [esp8266] Clarify 0.0.1 is pioarduino registry tag, not tool version --- .clang-tidy.hash | 2 +- esphome/components/esp8266/__init__.py | 2 ++ platformio.ini | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index ba22a61ede..bc8cd1dacd 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -b92adf594cf596ec5dd5858406c479ac258eb33f2a7517522ed89d4594a68f56 +88bba93420f1dbc8c64a555c631195a8dc9544e6dc98ae45ac04978206c01bd7 diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 6a72bb429e..9a16d5b90c 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -270,6 +270,8 @@ async def to_code(config): # platform-espressif8266 4.2.1 with the same 5.x build the # pioarduino ESP32 platform uses, so both platforms share the # same installed package and stop reinstalling on every switch. + # The 0.0.1 path component is pioarduino's stable registry + # release tag (not the tool version); the tool itself is 5.2.0. "pioarduino/tool-esptoolpy@https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.2.0.zip", ], ) diff --git a/platformio.ini b/platformio.ini index 2bb5f40d09..d98d584b2e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -109,6 +109,8 @@ platform_packages = ; platform-espressif8266 4.2.1 with the same 5.x build the pioarduino ; ESP32 platform uses, so switching between esp8266 and esp32 builds ; does not reinstall tool-esptoolpy every time. + ; The 0.0.1 path component is pioarduino's stable registry release tag + ; (not the tool version); the tool itself is 5.2.0. pioarduino/tool-esptoolpy@https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.2.0.zip framework = arduino From 38d894dfe7f012fca0545a8fe1356b4b1f4b0325 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:17:22 -0500 Subject: [PATCH 0317/2214] [ld2412] Fix flaky integration test race condition (#15833) --- tests/integration/test_uart_mock_ld2412.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_uart_mock_ld2412.py b/tests/integration/test_uart_mock_ld2412.py index 12aa3f8397..ea2ec38b2a 100644 --- a/tests/integration/test_uart_mock_ld2412.py +++ b/tests/integration/test_uart_mock_ld2412.py @@ -325,9 +325,13 @@ async def test_uart_mock_ld2412_engineering_truncated( ], ) - # Signal when we see Phase 3 recovery values (gate_0_move=50) + # Signal when we see ALL Phase 3 recovery values to avoid race where some + # arrive after the waiter fires but before we index into the lists recovery_received = collector.add_waiter( - lambda: pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"] + lambda: ( + pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"] + and pytest.approx(42.0) in collector.sensor_states["light"] + ) ) async with ( From f29da222fe27993ca350f942b09357d085d2cd70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 17:01:17 -0500 Subject: [PATCH 0318/2214] [core] raise WDT_FEED_INTERVAL_MS from 3 to 300 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3 ms rate limit was tight enough that the outer feed_wdt_with_time() at the top of Application::loop() hit the slow path on nearly every iteration. On-device measurements showed 95-99.9% of iterations triggering esp_task_wdt_reset(), costing ~9-12 us/hit (BT proxies worse due to WDT spinlock contention with the BT task on the other core). Much worse under wake-storm workloads: with loop_interval_ raised for power saving and an external stack (OpenThread in the reported case) posting frequent wake notifications, the main loop can spin at thousands of Hz doing almost nothing but feeding the watchdog — burning ~26% CPU in the extreme case reported on #15792. Raising the threshold to 300 ms: - Normal 62 Hz loop: feeds once every ~19 iterations (~3 Hz) instead of every iteration. - Wake-storm case: feeds ~3 Hz regardless of wake rate. - Any operation exceeding 300 ms still triggers a real feed right after it finishes (the post-component and post-scheduler-item feeds naturally clear the gate). Safety margins vs platform watchdog timeouts remain large: 16x on ESP32 (5 s task WDT), 5x on ESP8266 soft WDT (1.6 s), 20x on ESP8266 HW WDT. --- esphome/core/application.h | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 037789ae8b..65fb8b1e4e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -229,10 +229,17 @@ class Application { void schedule_dump_config() { this->dump_config_at_ = 0; } - /// Minimum interval between real arch_feed_wdt() calls. Chosen to keep the - /// rate of HAL pokes low while still being small enough that any plausible - /// watchdog timeout (seconds) has orders of magnitude of safety margin. - static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3; + /// Minimum interval between real arch_feed_wdt() calls. Sized so the outer + /// feed in Application::loop() is effectively rate-limited across both the + /// normal ~62 Hz cadence and worst-case wake-storm scenarios (e.g. external + /// stacks like OpenThread posting frequent wake notifications). Component + /// loops and scheduler items still feed after every op, so any op exceeding + /// this threshold triggers a real feed naturally. + /// Safety margins vs. platform watchdog timeouts: + /// - ESP32 task WDT default (5 s): ~16x + /// - ESP8266 soft WDT (~1.6 s): ~5x + /// - ESP8266 HW WDT (~6 s): ~20x + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; /// Feed the task watchdog. Cold entry — callers without a millis() /// timestamp in hand. Out of line to keep call sites tiny. From 45344c181aee65d2eb76d1f470e9fed36644b51d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 17:32:11 -0500 Subject: [PATCH 0319/2214] [core] decouple status_led dispatch from WDT feed rate limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising WDT_FEED_INTERVAL_MS to 300 ms in the previous commit also capped the status_led update cadence to ~3 Hz, because status_led::loop() was re-dispatched only from inside feed_wdt_slow_(). That distorts the error blink pattern (ERROR_PERIOD_MS = 250 ms with 150 ms on-window in status_led.cpp) — at a 300 ms dispatch interval, the LED can be sampled entirely inside or entirely outside the on-window on any given period, turning a readable blink into an aliased one. Split the two rate limits so they evolve independently: WDT_FEED_INTERVAL_MS = 300 ms — arch_feed_wdt() rate limit STATUS_LED_DISPATCH_INTERVAL_MS = 100 ms — status_led loop() dispatch The feed_wdt_with_time() hot path now has two independent gate checks (a load + sub + branch each). Fast path on both misses is the common case and remains cheap. feed_wdt_slow_() no longer touches status_led; the status_led re-dispatch moves into a new service_status_led_slow_() that's compiled in only when USE_STATUS_LED is set. No change to behavior on devices without status_led. Devices with status_led get the intended LED cadence restored (100 ms dispatch sits below the 150 ms error on-window and well below the 250 ms warning on- window). Flagged by Copilot review on application.h:242. --- esphome/core/application.cpp | 54 +++++++++++++++++++++++------------- esphome/core/application.h | 50 ++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 27 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 4fc28e092e..8a771a1dd0 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -214,11 +214,16 @@ void Application::process_dump_config_() { void Application::feed_wdt() { // Cold entry: callers without a millis() timestamp in hand. Fetches the - // time and takes the same rate-limit path as feed_wdt_with_time(). + // time and takes the same rate-limit paths as feed_wdt_with_time(). uint32_t now = millis(); if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { this->feed_wdt_slow_(now); } +#ifdef USE_STATUS_LED + if (now - this->last_status_led_service_ > STATUS_LED_DISPATCH_INTERVAL_MS) { + this->service_status_led_slow_(now); + } +#endif } void HOT Application::feed_wdt_slow_(uint32_t time) { @@ -226,27 +231,36 @@ void HOT Application::feed_wdt_slow_(uint32_t time) { // confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded. arch_feed_wdt(); this->last_wdt_feed_ = time; -#ifdef USE_STATUS_LED - if (status_led::global_status_led != nullptr) { - auto *sl = status_led::global_status_led; - uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; - if (sl_state == COMPONENT_STATE_LOOP_DONE) { - // status_led only transitions to LOOP_DONE from inside its own loop() (after the - // first idle-path dispatch), so its pin is already initialized by pre_setup() and - // its setup() has already run. Re-dispatch only if an error or warning bit has been - // set since; otherwise skip entirely. - if ((this->app_state_ & STATUS_LED_MASK) == 0) - return; - sl->enable_loop(); - } else if (sl_state != COMPONENT_STATE_LOOP) { - // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. - return; - } - sl->loop(); - } -#endif } +#ifdef USE_STATUS_LED +void HOT Application::service_status_led_slow_(uint32_t time) { + // Callers (feed_wdt(), feed_wdt_with_time()) have already confirmed the + // STATUS_LED_DISPATCH_INTERVAL_MS rate limit was exceeded. Rate-limited + // separately from arch_feed_wdt() so the LED blink pattern stays readable + // (status_led error blink period is 250 ms) while HAL watchdog pokes can + // still run at the much coarser WDT_FEED_INTERVAL_MS cadence. + this->last_status_led_service_ = time; + if (status_led::global_status_led == nullptr) + return; + auto *sl = status_led::global_status_led; + uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; + if (sl_state == COMPONENT_STATE_LOOP_DONE) { + // status_led only transitions to LOOP_DONE from inside its own loop() (after the + // first idle-path dispatch), so its pin is already initialized by pre_setup() and + // its setup() has already run. Re-dispatch only if an error or warning bit has been + // set since; otherwise skip entirely. + if ((this->app_state_ & STATUS_LED_MASK) == 0) + return; + sl->enable_loop(); + } else if (sl_state != COMPONENT_STATE_LOOP) { + // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. + return; + } + sl->loop(); +} +#endif + bool Application::any_component_has_status_flag_(uint8_t flag) const { // Walk all components (not just looping ones) so non-looping components' // status bits are respected. Only called from the slow-path clear helpers diff --git a/esphome/core/application.h b/esphome/core/application.h index 65fb8b1e4e..5ac6d020ab 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -237,7 +237,8 @@ class Application { /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: /// - ESP32 task WDT default (5 s): ~16x - /// - ESP8266 soft WDT (~1.6 s): ~5x + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here /// - ESP8266 HW WDT (~6 s): ~20x static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; @@ -245,14 +246,33 @@ class Application { /// timestamp in hand. Out of line to keep call sites tiny. void feed_wdt(); +#ifdef USE_STATUS_LED + /// Dispatch interval for the status LED update. Deliberately shorter than + /// WDT_FEED_INTERVAL_MS because the status LED error blink has a 250 ms + /// period (status_led.cpp:ERROR_PERIOD_MS) and a 150 ms on-window; the + /// dispatch cadence must be short enough to render that blink without + /// aliasing. Sampling every 100 ms yields an on/off observation inside + /// every error period with headroom for the 250 ms warning on-window. + static constexpr uint32_t STATUS_LED_DISPATCH_INTERVAL_MS = 100; +#endif + /// Feed the task watchdog, hot entry. Callers that already have a /// millis() timestamp pay only a load + sub + branch on the common - /// (no-op) path. The actual arch feed + status LED update live in - /// feed_wdt_slow_. + /// (no-op) path. The actual arch feed lives in feed_wdt_slow_. + /// When USE_STATUS_LED is compiled in, also gates a separate (shorter) + /// interval for dispatching status_led so the LED blink pattern stays + /// readable even though arch_feed_wdt pokes are now rate-limited at + /// 300 ms. The two rate limits are independent so raising + /// WDT_FEED_INTERVAL_MS does not distort the LED cadence. void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) { if (static_cast(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] { this->feed_wdt_slow_(time); } +#ifdef USE_STATUS_LED + if (static_cast(time - this->last_status_led_service_) > STATUS_LED_DISPATCH_INTERVAL_MS) [[unlikely]] { + this->service_status_led_slow_(time); + } +#endif } void reboot(); @@ -418,11 +438,21 @@ class Application { /// Caller must ensure dump_config_at_ < components_.size(). void __attribute__((noinline)) process_dump_config_(); - /// Slow path for feed_wdt(): actually calls arch_feed_wdt(), updates - /// last_wdt_feed_, and re-dispatches the status LED. Out of line so the - /// inline wrapper stays tiny. + /// Slow path for feed_wdt(): actually calls arch_feed_wdt() and updates + /// last_wdt_feed_. Out of line so the inline wrapper stays tiny. Does NOT + /// touch status_led — that's gated separately via service_status_led_slow_ + /// because the two rate limits have very different safe ranges (~ seconds + /// for WDT, < 250 ms for LED blink rendering). void feed_wdt_slow_(uint32_t time); +#ifdef USE_STATUS_LED + /// Slow path for the status_led dispatch rate limit. Runs the status_led + /// component's loop() based on its state (LOOP / LOOP_DONE with status + /// bits set), and updates last_status_led_service_. Out of line to keep + /// the feed_wdt_with_time hot path a couple of load+branch sequences. + void service_status_led_slow_(uint32_t time); +#endif + /// Perform a delay while also monitoring socket file descriptors for readiness #ifdef USE_HOST // select() fallback path is too complex to inline (host platform) @@ -476,6 +506,10 @@ class Application { uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path +#ifdef USE_STATUS_LED + // millis() of most recent status_led dispatch; rate-limits independently of last_wdt_feed_ + uint32_t last_status_led_service_{0}; +#endif #ifdef USE_HOST int max_fd_{-1}; // Highest file descriptor number for select() @@ -620,7 +654,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; - // Default tail_start to end-of-before so tail_us == 0 on Phase A-only ticks. + // Default tail_start to end-of-before so tail_us on Phase A-only ticks + // captures only the small gate-check + record_loop_active prefix between + // here and the loop_now_us sample below (not strictly zero, but tiny). uint32_t loop_tail_start_us = loop_before_end_us; #endif From 7d12b984a815614bc24dfab698fe69b9e29d87f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 17:32:29 -0500 Subject: [PATCH 0320/2214] [core] address review polish on main-loop decoupling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc and test updates from a code review of this PR: - Correct the `tail_us == 0 on Phase A-only ticks` claim in the Application::loop() comment and the RuntimeStatsCollector::record_loop_active docstring. `loop_tail_start_us` is set to `loop_before_end_us`, and `loop_now_us` is sampled later, so `tail_us` on Phase A-only ticks is the small gate-check + record prefix — tiny but non-zero. (Also flagged by Copilot on application.h:623 and runtime_stats.h:45.) - Call out ESP8266 as the floor case in the WDT_FEED_INTERVAL_MS margin table. Its soft WDT (~1.6 s) is the tightest margin at ~5x, so future changes to the constant need to preserve comfortable headroom there. - Tighten the test lower bound at tests/integration/test_loop_interval_decoupling.py from `2 <= loop_delta <= 6` to `3 <= loop_delta <= 6`. Allowing 2 would let a >50% slowdown from the 4-in-2s nominal pass as CI jitter, which undermines the regression signal. 3 keeps the test honest while still absorbing realistic CI jitter. - Add a second integration test (test_loop_interval_default_not_pulled_forward) that covers the inverse direction: at the default loop_interval_ with a fast scheduler item (5 ms — well under the old delay_time/2 = 8 ms floor), the component phase must still run at ~62 Hz, not the pre-fix ~128 Hz. This locks down the original 128 Hz → 62 Hz regression that motivated the PR. --- .../components/runtime_stats/runtime_stats.h | 4 +- ...p_interval_default_not_pulled_forward.yaml | 51 ++++++++++++++ .../test_loop_interval_decoupling.py | 4 +- ...oop_interval_default_not_pulled_forward.py | 67 +++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml create mode 100644 tests/integration/test_loop_interval_default_not_pulled_forward.py diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 58abfa8da1..24b6807008 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -42,7 +42,9 @@ class RuntimeStatsCollector { // before_us = time spent in Phase A (scheduler tick) excluding time // already attributed to per-component stats. // tail_us = time spent in after_loop_tasks_ + the trailing record/stats - // prefix. Zero on Phase A-only ticks (component phase gated). + // prefix. On Phase A-only ticks (component phase gated) this + // is just the small trailing prefix between loop_before_end_us + // and loop_now_us — non-zero but typically a few µs. // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, diff --git a/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml b/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml new file mode 100644 index 0000000000..fec83865b9 --- /dev/null +++ b/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml @@ -0,0 +1,51 @@ +esphome: + name: loop-default-not-pulled + on_boot: + priority: -100 + then: + # Leave loop_interval_ at its default (16 ms → ~62 Hz). Do NOT call + # set_loop_interval here. The fast scheduler interval below used to + # pull the component phase forward to ~128 Hz via the old + # std::max(next_schedule, delay_time / 2) floor. + # Start measurement after 1s so boot transients settle. + - delay: 1000ms + - lambda: |- + id(loop_at_start) = id(loop_counter)->get_loop_count(); + ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d", id(loop_at_start)); + # Observe for 2s. + - delay: 2000ms + - lambda: |- + int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start); + ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d", loop_delta); + +host: +api: +logger: + level: INFO + logs: + loop_test_component: WARN # Silence per-loop log spam + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +globals: + - id: loop_at_start + type: int + initial_value: "0" + +loop_test_component: + components: + - id: loop_counter + name: loop_counter + +interval: + # Fast scheduler interval (well under loop_interval_/2 = 8ms). In the + # pre-decoupling code this would have pulled the component phase forward + # to ~128 Hz. After the decoupling fix the component phase stays at + # ~62 Hz regardless. + - interval: 5ms + then: + - lambda: |- + // No-op; the presence of a due scheduler item is what matters. diff --git a/tests/integration/test_loop_interval_decoupling.py b/tests/integration/test_loop_interval_decoupling.py index 1cf8bf477d..6c34aed458 100644 --- a/tests/integration/test_loop_interval_decoupling.py +++ b/tests/integration/test_loop_interval_decoupling.py @@ -58,7 +58,9 @@ async def test_loop_interval_decoupling( # Component phase should fire ~4 times in 2s. The upper bound must be # less than 8: the pre-decoupling behavior clamped to ~250ms cadence # giving ~8 loops/2s, so allowing 8 would let the old behavior pass. - assert 2 <= loop_delta <= 6, ( + # Lower bound 3 (not 2) keeps the test honest: a >30% slowdown from + # the ~4 nominal is not normal CI jitter and should fail. + assert 3 <= loop_delta <= 6, ( f"Component loop should fire ~4 times in 2s at loop_interval=500ms, " f"got {loop_delta}" ) diff --git a/tests/integration/test_loop_interval_default_not_pulled_forward.py b/tests/integration/test_loop_interval_default_not_pulled_forward.py new file mode 100644 index 0000000000..17a7070436 --- /dev/null +++ b/tests/integration/test_loop_interval_default_not_pulled_forward.py @@ -0,0 +1,67 @@ +"""Test that a fast scheduler item does not pull the component phase forward. + +Regression test for the original ~128 Hz → ~62 Hz bug fixed by decoupling +Application::loop() component-phase cadence from scheduler wake timing. + +Setup: +- loop_interval_ left at its default (16 ms → ~62 Hz component phase). +- Scheduler interval at 5 ms (well under the old loop_interval_/2 = 8 ms floor). + +Before the decoupling fix the ``std::max(next_schedule, delay_time / 2)`` floor +clamped the sleep to ~8 ms whenever any scheduler item was due sooner than +loop_interval_/2. That pulled the component phase forward to ~128 Hz — twice +what the documented ~62 Hz default promised. After the fix the component +phase stays at ~62 Hz regardless of scheduler activity. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_loop_interval_default_not_pulled_forward( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Fast scheduler item must not pull component phase past default ~62 Hz.""" + loop = asyncio.get_running_loop() + measurement_done: asyncio.Future[int] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+)", line) + if match and not measurement_done.done(): + measurement_done.set_result(int(match.group(1))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "loop-default-not-pulled" + + try: + loop_delta = await asyncio.wait_for(measurement_done, timeout=10.0) + except TimeoutError: + pytest.fail("MEASUREMENT_DONE marker never appeared") + + # Observation window = 2s, loop_interval_ default = 16ms → ~62 Hz → + # ~125 component-phase iterations expected. + # Pre-fix behavior: the 5 ms scheduler interval tripped the old + # delay_time/2 = 8 ms floor, pulling the phase to ~128 Hz → ~256. + # Upper bound 180 is comfortably below the ~256 pre-fix rate but + # above the ~125 nominal with CI jitter. + # Lower bound 80 covers very slow CI hosts without permitting a + # complete regression. + assert 80 <= loop_delta <= 180, ( + f"Component loop at default loop_interval_ should fire ~125 times " + f"in 2s (≈62 Hz × 2s); got {loop_delta}. Values >200 indicate the " + f"scheduler is again pulling the component phase forward." + ) From ffe5db8c7dba5c76cacd9d6d50df00d8bb23ac8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 17:51:13 -0500 Subject: [PATCH 0321/2214] [core] refresh HighFrequencyLoopRequester state at sleep decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase A/B split cached `HighFrequencyLoopRequester::is_high_frequency()` once at the top of Application::loop() and reused that value at the sleep decision after Phase B. If a component calls HighFrequencyLoopRequester::start() from inside its own loop(), the cached value is stale when we pick the sleep length, so the request doesn't take effect until the next tick. Before the decoupling, the sleep decision called the function fresh at the same point in the code, so an in-loop HF request was honored immediately. This caching silently regressed that behavior. The regression matters a lot more now than it would have previously: loop_interval_ is the power-saving knob this PR was written to enable (raised multi-second values are a documented use case), so under the new freedom the worst-case HF-request latency can stretch from ~16 ms to several seconds. Re-read is_high_frequency() fresh at the sleep decision. The gate check above continues to use the cached value — there's no correctness benefit to re-reading it there, and the "one read covers the whole tick" design intent is preserved for the gate. The function is a trivial atomic-read static; a second call is cheaper than the latency it prevents. Flagged by Copilot review on application.h:728. --- esphome/core/application.h | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 5ac6d020ab..3752731e99 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -718,8 +718,18 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Compute sleep: bounded by time-until-next-component-phase and the // scheduler's next deadline. When a scheduler event wakes us early we // re-enter loop(), Phase A services it, and the component phase stays gated. + // + // Re-read HighFrequencyLoopRequester::is_high_frequency() here instead of + // reusing the cached `high_frequency` captured above: a component calling + // HighFrequencyLoopRequester::start() from within its loop() would + // otherwise sit under the stale value and sleep for up to loop_interval_ + // before the request took effect. That was fine pre-decoupling (the old + // main loop also called the function fresh at the sleep point) but now + // matters much more — loop_interval_ is a power-saving knob documented + // to accept multi-second values, so the stale path could add seconds of + // latency on an HF request. The call is a trivial atomic read. uint32_t delay_time = 0; - if (!high_frequency) { + if (!HighFrequencyLoopRequester::is_high_frequency()) { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); From ba8e72944344eb54b389c512cd22dfe02240e58c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 02:25:00 -0500 Subject: [PATCH 0322/2214] [substitutions] [packages] Fix `substitutions: !include file.yaml` regression Resolve a deferred IncludeFile before validating the substitutions shape in do_substitution_pass, and before wrapping it in UserDict in do_packages_pass. Fixes esphome/esphome#15848 --- esphome/components/packages/__init__.py | 9 ++++++++- esphome/components/substitutions/__init__.py | 5 +++++ .../15-substitutions_as_include.approved.yaml | 5 +++++ .../substitutions/15-substitutions_as_include.input.yaml | 5 +++++ .../fixtures/substitutions/15-substitutions_inc.yaml | 1 + ...-substitutions_as_include_with_packages.approved.yaml | 5 +++++ .../16-substitutions_as_include_with_packages.input.yaml | 9 +++++++++ 7 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 3f3df75351..58acc94a41 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -516,7 +516,14 @@ def do_packages_pass( if CONF_PACKAGES not in config: return config - substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {})) + raw_substitutions = config.pop(CONF_SUBSTITUTIONS, {}) + if isinstance(raw_substitutions, yaml_util.IncludeFile): + # Resolve `substitutions: !include file.yaml` before feeding into UserDict. + with cv.prepend_path(CONF_SUBSTITUTIONS): + raw_substitutions, _ = resolve_include( + raw_substitutions, [], ContextVars(), strict_undefined=False + ) + substitutions = UserDict(raw_substitutions) processor = _PackageProcessor( substitutions, command_line_substitutions, skip_update ) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 94aebbbfe3..3cdf79beea 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -429,6 +429,11 @@ def do_substitution_pass( # Use merge_dicts_ordered to preserve OrderedDict type for move_to_end() substitutions = config.pop(CONF_SUBSTITUTIONS, {}) with cv.prepend_path(CONF_SUBSTITUTIONS): + if isinstance(substitutions, IncludeFile): + # Resolve `substitutions: !include file.yaml` before validating the shape. + substitutions, _ = resolve_include( + substitutions, [], ContextVars(), strict_undefined=False + ) if not isinstance(substitutions, dict): raise cv.Invalid( f"Substitutions must be a key to value mapping, got {type(substitutions)}" diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml new file mode 100644 index 0000000000..14aa707def --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml new file mode 100644 index 0000000000..5909e7bf4f --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml @@ -0,0 +1,5 @@ +substitutions: !include 15-substitutions_inc.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml new file mode 100644 index 0000000000..44d9a4b9ef --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml @@ -0,0 +1 @@ +wifi_password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml new file mode 100644 index 0000000000..14aa707def --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml new file mode 100644 index 0000000000..a2e72f33a2 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml @@ -0,0 +1,9 @@ +substitutions: !include 15-substitutions_inc.yaml + +packages: + wifi_pkg: + wifi: + password: $wifi_password + +wifi: + ssid: main_ssid From e67b65b669b8a92d0d10edb38448807dfb13d50d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 02:42:03 -0500 Subject: [PATCH 0323/2214] [substitutions] [packages] Address review feedback - Seed `resolve_include` context with `command_line_substitutions` so `substitutions: !include ${var}.yaml` can reference CLI-provided vars in the include filename (parallels the `packages: !include` path). - Validate shape of resolved substitutions in `do_packages_pass` and raise `cv.Invalid` under `CONF_SUBSTITUTIONS` instead of letting `UserDict()` fail with a low-level exception on a non-mapping. - Fixture 17 exercises the CLI-templated include filename. --- esphome/components/packages/__init__.py | 17 +++++++++++++---- esphome/components/substitutions/__init__.py | 7 ++++++- ...-substitutions_include_cli_var.approved.yaml | 6 ++++++ .../17-substitutions_include_cli_var.input.yaml | 8 ++++++++ 4 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 58acc94a41..a46783bb58 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -517,11 +517,20 @@ def do_packages_pass( return config raw_substitutions = config.pop(CONF_SUBSTITUTIONS, {}) - if isinstance(raw_substitutions, yaml_util.IncludeFile): - # Resolve `substitutions: !include file.yaml` before feeding into UserDict. - with cv.prepend_path(CONF_SUBSTITUTIONS): + with cv.prepend_path(CONF_SUBSTITUTIONS): + if isinstance(raw_substitutions, yaml_util.IncludeFile): + # Resolve `substitutions: !include file.yaml` before feeding into UserDict. + # Seed with command-line substitutions so `!include ${var}.yaml` can + # reference CLI-provided vars in the filename. raw_substitutions, _ = resolve_include( - raw_substitutions, [], ContextVars(), strict_undefined=False + raw_substitutions, + [], + ContextVars(command_line_substitutions or {}), + strict_undefined=False, + ) + if not isinstance(raw_substitutions, dict): + raise cv.Invalid( + f"Substitutions must be a key to value mapping, got {type(raw_substitutions)}" ) substitutions = UserDict(raw_substitutions) processor = _PackageProcessor( diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 3cdf79beea..6f24011210 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -431,8 +431,13 @@ def do_substitution_pass( with cv.prepend_path(CONF_SUBSTITUTIONS): if isinstance(substitutions, IncludeFile): # Resolve `substitutions: !include file.yaml` before validating the shape. + # Seed with command-line substitutions so `!include ${var}.yaml` can + # reference CLI-provided vars in the filename. substitutions, _ = resolve_include( - substitutions, [], ContextVars(), strict_undefined=False + substitutions, + [], + ContextVars(command_line_substitutions or {}), + strict_undefined=False, ) if not isinstance(substitutions, dict): raise cv.Invalid( diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml new file mode 100644 index 0000000000..f1fd5fb078 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml @@ -0,0 +1,6 @@ +substitutions: + subs_file: 15-substitutions_inc + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml new file mode 100644 index 0000000000..3248504b46 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml @@ -0,0 +1,8 @@ +command_line_substitutions: + subs_file: 15-substitutions_inc + +substitutions: !include ${subs_file}.yaml + +wifi: + ssid: main_ssid + password: $wifi_password From f579cf48a0c4e14ef56e0a18ce63d3800378b80c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 02:46:22 -0500 Subject: [PATCH 0324/2214] [substitutions] [packages] Extract resolve_substitutions_block helper Deduplicate the resolve+shape-check block between do_substitution_pass and do_packages_pass, and drop the raw_substitutions/substitutions naming split. --- esphome/components/packages/__init__.py | 20 +++------- esphome/components/substitutions/__init__.py | 42 +++++++++++++------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index a46783bb58..252a24061a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -10,6 +10,7 @@ from esphome.components.substitutions import ( ContextVars, push_context, resolve_include, + resolve_substitutions_block, substitute, ) from esphome.components.substitutions.jinja import has_jinja @@ -516,23 +517,12 @@ def do_packages_pass( if CONF_PACKAGES not in config: return config - raw_substitutions = config.pop(CONF_SUBSTITUTIONS, {}) with cv.prepend_path(CONF_SUBSTITUTIONS): - if isinstance(raw_substitutions, yaml_util.IncludeFile): - # Resolve `substitutions: !include file.yaml` before feeding into UserDict. - # Seed with command-line substitutions so `!include ${var}.yaml` can - # reference CLI-provided vars in the filename. - raw_substitutions, _ = resolve_include( - raw_substitutions, - [], - ContextVars(command_line_substitutions or {}), - strict_undefined=False, + substitutions = UserDict( + resolve_substitutions_block( + config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions ) - if not isinstance(raw_substitutions, dict): - raise cv.Invalid( - f"Substitutions must be a key to value mapping, got {type(raw_substitutions)}" - ) - substitutions = UserDict(raw_substitutions) + ) processor = _PackageProcessor( substitutions, command_line_substitutions, skip_update ) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 6f24011210..dfdf186e5a 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -414,6 +414,31 @@ def _warn_unresolved_variables(errors: ErrList) -> None: ) +def resolve_substitutions_block( + substitutions: Any, + command_line_substitutions: dict[str, Any] | None, +) -> dict[str, Any]: + """Resolve a deferred ``substitutions: !include file.yaml`` and validate the shape. + + The caller is responsible for wrapping the call in + ``cv.prepend_path(CONF_SUBSTITUTIONS)`` for error reporting. + ``command_line_substitutions`` seeds the filename context so + ``substitutions: !include ${var}.yaml`` can reference CLI-provided vars. + """ + if isinstance(substitutions, IncludeFile): + substitutions, _ = resolve_include( + substitutions, + [], + ContextVars(command_line_substitutions or {}), + strict_undefined=False, + ) + if not isinstance(substitutions, dict): + raise cv.Invalid( + f"Substitutions must be a key to value mapping, got {type(substitutions)}" + ) + return substitutions + + def do_substitution_pass( config: OrderedDict, command_line_substitutions: dict[str, Any] | None = None ) -> OrderedDict: @@ -429,20 +454,9 @@ def do_substitution_pass( # Use merge_dicts_ordered to preserve OrderedDict type for move_to_end() substitutions = config.pop(CONF_SUBSTITUTIONS, {}) with cv.prepend_path(CONF_SUBSTITUTIONS): - if isinstance(substitutions, IncludeFile): - # Resolve `substitutions: !include file.yaml` before validating the shape. - # Seed with command-line substitutions so `!include ${var}.yaml` can - # reference CLI-provided vars in the filename. - substitutions, _ = resolve_include( - substitutions, - [], - ContextVars(command_line_substitutions or {}), - strict_undefined=False, - ) - if not isinstance(substitutions, dict): - raise cv.Invalid( - f"Substitutions must be a key to value mapping, got {type(substitutions)}" - ) + substitutions = resolve_substitutions_block( + substitutions, command_line_substitutions + ) substitutions = merge_dicts_ordered( substitutions, command_line_substitutions or {} ) From 78853260a5b9a9fd676dc7515f454ba7fa161d73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 02:50:20 -0500 Subject: [PATCH 0325/2214] [substitutions] Lock in shape check and document single-shot resolve - Add unit tests asserting cv.Invalid when `substitutions: !include list.yaml` resolves to a non-mapping, covering both do_substitution_pass and do_packages_pass. - Note in resolve_substitutions_block that the resolve is single-shot and chained top-level includes are not supported (matches _walk_packages for `packages: !include`). --- esphome/components/substitutions/__init__.py | 3 ++ tests/unit_tests/test_substitutions.py | 51 ++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index dfdf186e5a..e451ad5db8 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -426,6 +426,9 @@ def resolve_substitutions_block( ``substitutions: !include ${var}.yaml`` can reference CLI-provided vars. """ if isinstance(substitutions, IncludeFile): + # Single-shot resolution — matches ``_walk_packages`` for the + # ``packages: !include`` entry point. Chained includes (an include that + # itself loads another ``!include`` at the top level) are not supported. substitutions, _ = resolve_include( substitutions, [], diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 01c669e542..71bbd9db86 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -675,6 +675,57 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_do_substitution_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` where the file holds a list raises cv.Invalid. + + Locks in the shape check that runs after the deferred IncludeFile has been + resolved. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict({CONF_SUBSTITUTIONS: include}) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + substitutions.do_substitution_pass(config) + + +def test_do_packages_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` alongside `packages:` raises cv.Invalid. + + Without the shape check, ``UserDict(...)`` would surface a low-level + ``TypeError``; the explicit ``cv.Invalid`` points at the substitutions path. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict( + { + CONF_SUBSTITUTIONS: include, + "packages": {"noop": {"wifi": {"ssid": "main"}}}, + } + ) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + do_packages_pass(config) + + def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> None: """An undefined substitution in a package include filename raises cv.Invalid. From 7a23a339e9a6f8234f2e8ad3339f047e3b18e27f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 16:00:31 -0500 Subject: [PATCH 0326/2214] [substitutions] Fix `substitutions: !include file.yaml` regression (#15850) --- esphome/components/packages/__init__.py | 8 ++- esphome/components/substitutions/__init__.py | 35 +++++++++++-- .../15-substitutions_as_include.approved.yaml | 5 ++ .../15-substitutions_as_include.input.yaml | 5 ++ .../substitutions/15-substitutions_inc.yaml | 1 + ...ons_as_include_with_packages.approved.yaml | 5 ++ ...utions_as_include_with_packages.input.yaml | 9 ++++ ...ubstitutions_include_cli_var.approved.yaml | 6 +++ ...7-substitutions_include_cli_var.input.yaml | 8 +++ tests/unit_tests/test_substitutions.py | 51 +++++++++++++++++++ 10 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 3f3df75351..252a24061a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -10,6 +10,7 @@ from esphome.components.substitutions import ( ContextVars, push_context, resolve_include, + resolve_substitutions_block, substitute, ) from esphome.components.substitutions.jinja import has_jinja @@ -516,7 +517,12 @@ def do_packages_pass( if CONF_PACKAGES not in config: return config - substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {})) + with cv.prepend_path(CONF_SUBSTITUTIONS): + substitutions = UserDict( + resolve_substitutions_block( + config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions + ) + ) processor = _PackageProcessor( substitutions, command_line_substitutions, skip_update ) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 94aebbbfe3..e451ad5db8 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -414,6 +414,34 @@ def _warn_unresolved_variables(errors: ErrList) -> None: ) +def resolve_substitutions_block( + substitutions: Any, + command_line_substitutions: dict[str, Any] | None, +) -> dict[str, Any]: + """Resolve a deferred ``substitutions: !include file.yaml`` and validate the shape. + + The caller is responsible for wrapping the call in + ``cv.prepend_path(CONF_SUBSTITUTIONS)`` for error reporting. + ``command_line_substitutions`` seeds the filename context so + ``substitutions: !include ${var}.yaml`` can reference CLI-provided vars. + """ + if isinstance(substitutions, IncludeFile): + # Single-shot resolution — matches ``_walk_packages`` for the + # ``packages: !include`` entry point. Chained includes (an include that + # itself loads another ``!include`` at the top level) are not supported. + substitutions, _ = resolve_include( + substitutions, + [], + ContextVars(command_line_substitutions or {}), + strict_undefined=False, + ) + if not isinstance(substitutions, dict): + raise cv.Invalid( + f"Substitutions must be a key to value mapping, got {type(substitutions)}" + ) + return substitutions + + def do_substitution_pass( config: OrderedDict, command_line_substitutions: dict[str, Any] | None = None ) -> OrderedDict: @@ -429,10 +457,9 @@ def do_substitution_pass( # Use merge_dicts_ordered to preserve OrderedDict type for move_to_end() substitutions = config.pop(CONF_SUBSTITUTIONS, {}) with cv.prepend_path(CONF_SUBSTITUTIONS): - if not isinstance(substitutions, dict): - raise cv.Invalid( - f"Substitutions must be a key to value mapping, got {type(substitutions)}" - ) + substitutions = resolve_substitutions_block( + substitutions, command_line_substitutions + ) substitutions = merge_dicts_ordered( substitutions, command_line_substitutions or {} ) diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml new file mode 100644 index 0000000000..14aa707def --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml new file mode 100644 index 0000000000..5909e7bf4f --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml @@ -0,0 +1,5 @@ +substitutions: !include 15-substitutions_inc.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml new file mode 100644 index 0000000000..44d9a4b9ef --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml @@ -0,0 +1 @@ +wifi_password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml new file mode 100644 index 0000000000..14aa707def --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml new file mode 100644 index 0000000000..a2e72f33a2 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml @@ -0,0 +1,9 @@ +substitutions: !include 15-substitutions_inc.yaml + +packages: + wifi_pkg: + wifi: + password: $wifi_password + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml new file mode 100644 index 0000000000..f1fd5fb078 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml @@ -0,0 +1,6 @@ +substitutions: + subs_file: 15-substitutions_inc + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml new file mode 100644 index 0000000000..3248504b46 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml @@ -0,0 +1,8 @@ +command_line_substitutions: + subs_file: 15-substitutions_inc + +substitutions: !include ${subs_file}.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 01c669e542..71bbd9db86 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -675,6 +675,57 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_do_substitution_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` where the file holds a list raises cv.Invalid. + + Locks in the shape check that runs after the deferred IncludeFile has been + resolved. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict({CONF_SUBSTITUTIONS: include}) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + substitutions.do_substitution_pass(config) + + +def test_do_packages_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` alongside `packages:` raises cv.Invalid. + + Without the shape check, ``UserDict(...)`` would surface a low-level + ``TypeError``; the explicit ``cv.Invalid`` points at the substitutions path. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict( + { + CONF_SUBSTITUTIONS: include, + "packages": {"noop": {"wifi": {"ssid": "main"}}}, + } + ) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + do_packages_pass(config) + + def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> None: """An undefined substitution in a package include filename raises cv.Invalid. From aad1318b4a8eceec8c8e16dd3718d26f55790697 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 19 Apr 2026 23:09:14 +0200 Subject: [PATCH 0327/2214] [packages] Improve error messages with include stack and fix missing path propagation (#15844) 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: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/packages/__init__.py | 9 ++ esphome/components/substitutions/__init__.py | 50 +++++++++++ .../component_tests/packages/test_packages.py | 88 ++++++++++++++++++- tests/unit_tests/test_substitutions.py | 34 +++++++ 4 files changed, 179 insertions(+), 2 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 252a24061a..97a5309480 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -8,7 +8,9 @@ from typing import Any from esphome import git, yaml_util from esphome.components.substitutions import ( ContextVars, + ErrList, push_context, + raise_first_undefined, resolve_include, resolve_substitutions_block, substitute, @@ -360,12 +362,19 @@ def _substitute_package_definition( if isinstance(package_config, str) or ( isinstance(package_config, dict) and is_remote_package(package_config) ): + # Collect undefined-variable errors (rather than raising strict) so the + # path walked through a remote-package dict is preserved and the user + # sees which field (url / path / ref / ...) referenced the undefined + # variable. + errors: ErrList = [] package_config = substitute( item=package_config, path=[], parent_context=context_vars or ContextVars(), strict_undefined=False, + errors=errors, ) + raise_first_undefined(errors, package_config, "package definition") return package_config diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index e451ad5db8..8bbccffca1 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -30,6 +30,56 @@ ErrList = list[tuple[UndefinedError, SubstitutionPath, Any]] jinja = Jinja() +def raise_first_undefined( + errors: ErrList, + source: Any, + context_label: str, +) -> None: + """If *errors* is non-empty, raise ``cv.Invalid`` for the first undefined variable. + + The raised error names the missing variable, the path walked into *source* + (for nested dicts, e.g. ``url`` or ``ref``), and the YAML source location + when *source* carries one. Only the first error is surfaced; the user will + re-run after fixing it and any remaining undefined variables will be + reported then. + + ``context_label`` is the noun describing where the undefined variable + appeared (e.g. ``"package definition"``). + """ + if not errors: + return + err, err_path, err_value = errors[0] + if len(errors) > 1: + # Log any further undefined variables so debug-level output covers + # the full set, even though only the first is surfaced to the user. + extras = ", ".join( + f"{e.message} at '{'->'.join(str(p) for p in p_path)}'" + for e, p_path, _ in errors[1:] + ) + _LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras) + # Prefer the location of the offending scalar (e.g. the `url:` value) over + # the enclosing package-definition dict so the message points at the exact + # line/column that carries the undefined variable. + location_node = ( + err_value + if isinstance(err_value, ESPHomeDataBase) and err_value.esp_range is not None + else source + ) + location = "" + if ( + isinstance(location_node, ESPHomeDataBase) + and location_node.esp_range is not None + ): + mark = location_node.esp_range.start_mark + # DocumentLocation.line/column are 0-based (from the YAML Mark). Render + # as 1-based to match config.line_info() and editor line numbering. + location = f" (in {mark.document} {mark.line + 1}:{mark.column + 1})" + field = f" at '{'->'.join(str(p) for p in err_path)}'" if err_path else "" + raise cv.Invalid( + f"Undefined variable in {context_label}{field}: {err.message}{location}" + ) + + def validate_substitution_key(value: Any) -> str: """Validate and normalize a substitution key, stripping a leading ``$`` if present.""" value = cv.string(value) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index cd91c4d8cb..0bd339efa9 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -2,18 +2,20 @@ import logging from pathlib import Path +import re from unittest.mock import MagicMock, patch import pytest from esphome.components.packages import ( CONFIG_SCHEMA, + _substitute_package_definition, _walk_packages, do_packages_pass, is_package_definition, merge_packages, ) -from esphome.components.substitutions import do_substitution_pass +from esphome.components.substitutions import ContextVars, do_substitution_pass import esphome.config as config_module from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove @@ -44,7 +46,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.util import OrderedDict -from esphome.yaml_util import IncludeFile, add_context +from esphome.yaml_util import IncludeFile, add_context, load_yaml # Test strings TEST_DEVICE_NAME = "test_device_name" @@ -1399,3 +1401,85 @@ def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: "CORE.raw_config should contain esphome section after package merge" ) assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME + + +# --------------------------------------------------------------------------- +# _substitute_package_definition +# --------------------------------------------------------------------------- + + +def test_substitute_package_definition_local_dict_returned_unchanged() -> None: + """A plain local config dict is not substituted and is returned as-is.""" + pkg = {CONF_WIFI: {CONF_SSID: "test"}} + result = _substitute_package_definition(pkg, ContextVars()) + assert result is pkg + + +def test_substitute_package_definition_string_resolved_with_context() -> None: + """A string package definition has its variables substituted.""" + ctx = ContextVars({"variant": "esp32"}) + result = _substitute_package_definition("device-${variant}.yaml", ctx) + assert result == "device-esp32.yaml" + + +def test_substitute_package_definition_undefined_in_string() -> None: + """An undefined variable in a package URL string raises cv.Invalid.""" + with pytest.raises(cv.Invalid, match="Undefined variable in package definition"): + _substitute_package_definition( + "github://org/repo/${undefined_var}/pkg.yaml", ContextVars() + ) + + +def test_substitute_package_definition_undefined_in_remote_dict_field() -> None: + """An undefined variable inside a remote-dict field names the offending field.""" + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition( + {CONF_URL: "github://${typo}/repo"}, ContextVars() + ) + err = str(exc_info.value) + assert "'typo' is undefined" in err + assert CONF_URL in err + + +def test_substitute_package_definition_undefined_in_remote_dict_non_first_field() -> ( + None +): + """The field path joins correctly for non-first dict fields (e.g. ``ref``).""" + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition( + { + CONF_URL: "github://org/repo", + CONF_REF: "branch-${branch_typo}", + }, + ContextVars(), + ) + err = str(exc_info.value) + assert "'branch_typo' is undefined" in err + assert CONF_REF in err + + +def test_substitute_package_definition_includes_source_location(tmp_path: Path) -> None: + """A package loaded from YAML surfaces file/line/col in the cv.Invalid message. + + Line/column are rendered 1-based (matching config.line_info() and editor + line numbering) and point at the offending scalar, not the enclosing dict. + """ + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "packages:\n broken: github://org/repo/${undefined_var}/pkg.yaml\n" + ) + config = load_yaml(yaml_file) + package_config = config[CONF_PACKAGES]["broken"] + + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition(package_config, ContextVars()) + + err = str(exc_info.value) + assert "main.yaml" in err + # The offending value lives on line 2 (1-based). Column depends on the YAML + # loader, so we only pin line and check that a 1-based column is present. + match = re.search(r"main\.yaml (\d+):(\d+)", err) + assert match, err + line, col = int(match.group(1)), int(match.group(2)) + assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})" + assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})" diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 71bbd9db86..3599e703d9 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -14,6 +14,7 @@ from esphome.components.packages import ( do_packages_pass, merge_packages, ) +from esphome.components.substitutions.jinja import UndefinedError from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, merge_config import esphome.config_validation as cv @@ -675,6 +676,39 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_raise_first_undefined_logs_extras_at_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """Only the first undefined error is raised; extras are logged at debug.""" + errors: substitutions.ErrList = [ + (UndefinedError("'a' is undefined"), ["url"], None), + (UndefinedError("'b' is undefined"), ["ref"], None), + (UndefinedError("'c' is undefined"), ["path"], None), + ] + + with ( + caplog.at_level(logging.DEBUG, logger="esphome.components.substitutions"), + pytest.raises(cv.Invalid) as exc_info, + ): + substitutions.raise_first_undefined(errors, None, "package definition") + + # First error is surfaced as the cv.Invalid message. + raised = str(exc_info.value) + assert "'a' is undefined" in raised + assert "'b' is undefined" not in raised + assert "'c' is undefined" not in raised + + # Remaining errors are captured via debug logging for troubleshooting. + assert "Additional undefined variables in package definition" in caplog.text + assert "'b' is undefined at 'ref'" in caplog.text + assert "'c' is undefined at 'path'" in caplog.text + + +def test_raise_first_undefined_noop_on_empty() -> None: + """An empty errors list is a no-op — no exception, no log.""" + substitutions.raise_first_undefined([], None, "package definition") + + def test_do_substitution_pass_included_substitutions_must_be_mapping( tmp_path: Path, ) -> None: From 1847666e75950c50eb4be05564e0ca262ff852af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 17:05:27 -0500 Subject: [PATCH 0328/2214] [core] Default PollingComponent() to not run when codegen is bypassed (#15832) --- esphome/core/component.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 717ca36257..67db5423af 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -601,7 +601,7 @@ class Component { */ class PollingComponent : public Component { public: - PollingComponent() : PollingComponent(1) {} + PollingComponent() : PollingComponent(SCHEDULER_DONT_RUN) {} /** Initialize this polling component with the given update interval in ms. * From cb90ac45c35975a882795013a69a6ac613185a58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 07:50:11 -1000 Subject: [PATCH 0329/2214] [core] Fix app_state_ status bits clobbered for non-looping components (#15658) --- esphome/core/application.cpp | 34 ++- esphome/core/application.h | 24 ++- esphome/core/component.cpp | 13 ++ esphome/core/component.h | 5 + tests/integration/fixtures/status_flags.yaml | 141 +++++++++++++ tests/integration/test_status_flags.py | 209 +++++++++++++++++++ 6 files changed, 416 insertions(+), 10 deletions(-) create mode 100644 tests/integration/fixtures/status_flags.yaml create mode 100644 tests/integration/test_status_flags.py diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd75859880..0c17c70161 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -85,8 +85,12 @@ void Application::setup() { if (component->can_proceed()) continue; + // Force the status LED to blink WARNING while we wait for a slow + // component to come up. Cleared after setup() finishes if no real + // component has warning set. + this->app_state_ |= STATUS_LED_WARNING; + do { - uint8_t new_app_state = STATUS_LED_WARNING; uint32_t now = millis(); // Process pending loop enables to handle GPIO interrupts during setup @@ -96,17 +100,26 @@ void Application::setup() { // Update loop_component_start_time_ right before calling each component this->loop_component_start_time_ = millis(); this->components_[j]->call(); - new_app_state |= this->components_[j]->get_component_state(); - this->app_state_ |= new_app_state; this->feed_wdt(); } this->after_loop_tasks_(); - this->app_state_ = new_app_state; yield(); } while (!component->can_proceed() && !component->is_failed()); } + // Setup is complete. Reconcile STATUS_LED_WARNING: the slow-setup path + // above may have forced it on, and any status_clear_warning() calls + // from components during setup were intentional no-ops (gated by + // APP_STATE_SETUP_COMPLETE). Walk components once here to pick up the + // real state. STATUS_LED_ERROR is never artificially forced, so its + // clear path always works and needs no reconciliation. Finally, set + // APP_STATE_SETUP_COMPLETE so subsequent warning clears go through + // the normal walk-and-clear path. + if (!this->any_component_has_status_flag_(STATUS_LED_WARNING)) + this->app_state_ &= ~STATUS_LED_WARNING; + this->app_state_ |= APP_STATE_SETUP_COMPLETE; + ESP_LOGI(TAG, "setup() finished successfully!"); #ifdef USE_SETUP_PRIORITY_OVERRIDE @@ -211,6 +224,19 @@ void HOT Application::feed_wdt(uint32_t time) { #endif } } +bool Application::any_component_has_status_flag_(uint8_t flag) const { + // Walk all components (not just looping ones) so non-looping components' + // status bits are respected. Only called from the slow-path clear helpers + // (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an + // actual set→clear transition, so walking O(N) here is paid once per + // transition — not once per loop iteration. + for (auto *component : this->components_) { + if ((component->get_component_state() & flag) != 0) + return true; + } + return false; +} + void Application::reboot() { ESP_LOGI(TAG, "Forcing a reboot"); for (auto &component : std::ranges::reverse_view(this->components_)) { diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b490..0150bb6646 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -401,7 +401,18 @@ class Application { */ void teardown_components(uint32_t timeout_ms); - uint8_t get_app_state() const { return this->app_state_; } + /// Return the public app state status bits (STATUS_LED_* only). + /// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked + /// out so external readers (status_led components, etc.) never see them. + uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; } + + /// True once Application::setup() has finished walking all components + /// and finalized the initial status flags. Before this point, the + /// slow-setup busy-wait may be forcing STATUS_LED_WARNING on, and + /// status_clear_* intentionally skips its walk-and-clear step so the + /// forced bit doesn't get wiped. Stored as a free bit on app_state_ + /// (bit 6) to avoid costing additional RAM. + bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; } // Helper macro for entity getter method declarations #ifdef USE_DEVICES @@ -577,6 +588,12 @@ class Application { bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } #endif + /// Walk all registered components looking for any whose component_state_ + /// has the given flag set. Used by Component::status_clear_*_slow_path_() + /// (which is a friend) to decide whether to clear the corresponding bit on + /// this->app_state_ (the app-wide "any component has this status" indicator). + bool any_component_has_status_flag_(uint8_t flag) const; + /// Register a component, detecting loop() override at compile time. /// Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance. template void register_component_(T *comp) { @@ -838,8 +855,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ } inline void ESPHOME_ALWAYS_INLINE Application::loop() { - uint8_t new_app_state = 0; - // Get the initial loop time at the start uint32_t last_op_end_time = millis(); @@ -859,13 +874,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); } - new_app_state |= component->get_component_state(); - this->app_state_ |= new_app_state; this->feed_wdt(last_op_end_time); } this->after_loop_tasks_(); - this->app_state_ = new_app_state; #ifdef USE_RUNTIME_STATS // Process any pending runtime stats printing after all components have run diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index deda42b0a7..8949b4b76d 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -411,10 +411,23 @@ void Component::status_set_error(const LogString *message) { } void Component::status_clear_warning_slow_path_() { this->component_state_ &= ~STATUS_LED_WARNING; + // Clear the app-wide STATUS_LED_WARNING bit only if setup has finished + // AND no other component still has it set. During setup the forced + // STATUS_LED_WARNING (from the slow-setup busy-wait) must not be wiped + // by a transient component clear — Application::setup() reconciles + // the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE. + // The set path is unchanged (set_status_flag_ still writes directly). + if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING)) + App.app_state_ &= ~STATUS_LED_WARNING; ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_clear_error_slow_path_() { this->component_state_ &= ~STATUS_LED_ERROR; + // STATUS_LED_ERROR is never artificially forced — it only ever lands + // in app_state_ via a real set_status_flag_ call. So the walk-and-clear + // path is always safe, including during setup. + if (!App.any_component_has_status_flag_(STATUS_LED_ERROR)) + App.app_state_ &= ~STATUS_LED_ERROR; ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_momentary_warning(const char *name, uint32_t length) { diff --git a/esphome/core/component.h b/esphome/core/component.h index e2b7aa85d3..3307c5ae76 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -89,6 +89,11 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08; inline constexpr uint8_t STATUS_LED_ERROR = 0x10; // Component loop override flag uses bit 5 (set at registration time) inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20; +// Bit 6 on Application::app_state_ (ONLY) — set at the end of +// Application::setup(). Component::status_clear_*_slow_path_() uses this to +// decide whether to propagate clears to App.app_state_. Never set on a +// Component's component_state_. +inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40; // Remove before 2026.8.0 enum class RetryResult { DONE, RETRY }; diff --git a/tests/integration/fixtures/status_flags.yaml b/tests/integration/fixtures/status_flags.yaml new file mode 100644 index 0000000000..cb118dcc84 --- /dev/null +++ b/tests/integration/fixtures/status_flags.yaml @@ -0,0 +1,141 @@ +esphome: + name: status-flags-test + +host: +api: + actions: + # Warning flag services for sensor_a + - action: set_warning_a + then: + - lambda: "id(sensor_a)->status_set_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_warning_a + then: + - lambda: "id(sensor_a)->status_clear_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Warning flag services for sensor_b + - action: set_warning_b + then: + - lambda: "id(sensor_b)->status_set_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_warning_b + then: + - lambda: "id(sensor_b)->status_clear_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Error flag services for sensor_a + - action: set_error_a + then: + - lambda: "id(sensor_a)->status_set_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_error_a + then: + - lambda: "id(sensor_a)->status_clear_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Error flag services for sensor_b + - action: set_error_b + then: + - lambda: "id(sensor_b)->status_set_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_error_b + then: + - lambda: "id(sensor_b)->status_clear_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Snapshot of the status_led_light's output state for observation. + - action: snapshot_led + then: + - component.update: status_led_writes + - component.update: status_led_last_state + +logger: + +# Tracks each write to the fake status_led output. +globals: + - id: status_led_write_count + type: uint32_t + restore_value: no + initial_value: "0" + - id: status_led_last_write + type: bool + restore_value: no + initial_value: "false" + +# Fake binary output — status_led_light writes to this instead of a pin. +# Every write bumps a counter and records the last value, both of which +# are exposed below so the test can verify status_led_light's loop is +# actually reading App.get_app_state() and responding. +output: + - platform: template + id: fake_status_led + type: binary + write_action: + - globals.set: + id: status_led_write_count + value: !lambda "return id(status_led_write_count) + 1;" + - globals.set: + id: status_led_last_write + value: !lambda "return state;" + +# Actual status_led_light component under test. +light: + - platform: status_led + name: Status LED + id: status_led_light_id + output: fake_status_led + +sensor: + # Two components that the test will toggle warning/error flags on. + - platform: template + name: Sensor A + id: sensor_a + update_interval: 24h + lambda: return 1.0; + - platform: template + name: Sensor B + id: sensor_b + update_interval: 24h + lambda: return 2.0; + + # Expose App.app_state_'s STATUS_LED_WARNING / STATUS_LED_ERROR bits + # as 0.0 / 1.0. force_update ensures every manual component.update + # publishes even if the value is unchanged. + - platform: template + name: App Warning Bit + id: app_warning_bit + update_interval: 24h + force_update: true + lambda: |- + return (App.get_app_state() & STATUS_LED_WARNING) != 0 ? 1.0 : 0.0; + - platform: template + name: App Error Bit + id: app_error_bit + update_interval: 24h + force_update: true + lambda: |- + return (App.get_app_state() & STATUS_LED_ERROR) != 0 ? 1.0 : 0.0; + + # Observables for the fake status_led output. + - platform: template + name: Status LED Writes + id: status_led_writes + update_interval: 24h + force_update: true + lambda: return id(status_led_write_count); + - platform: template + name: Status LED Last State + id: status_led_last_state + update_interval: 24h + force_update: true + lambda: |- + return id(status_led_last_write) ? 1.0 : 0.0; diff --git a/tests/integration/test_status_flags.py b/tests/integration/test_status_flags.py new file mode 100644 index 0000000000..ffbc7c7f63 --- /dev/null +++ b/tests/integration/test_status_flags.py @@ -0,0 +1,209 @@ +"""Integration tests for Component::status_set/clear_warning/error propagation. + +Verifies that toggling STATUS_LED_WARNING / STATUS_LED_ERROR on individual +components correctly updates the app-wide bits on Application::app_state_, +AND that the status_led_light component actually responds to those bits +by writing to its output (the full chain from component.status_set_warning +→ App.app_state_ → status_led_light.loop() reading get_app_state()). + +Exercises the multi-component OR semantics (the app bit stays set while +any component still has the flag, and only clears when the last component +clears its bit), the independence of warning and error, and the actual +status_led_light read of the bits via a fake template output that counts +writes. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Time to let the host-mode main loop run so status_led_light.loop() can +# execute enough iterations to produce measurable write-count changes on +# the fake template output. 300 ms is well above the minimum needed. +STATUS_LED_SETTLE_S = 0.3 + + +@pytest.mark.asyncio +async def test_status_flags( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + async with run_compiled(yaml_config), api_client_connected() as client: + entities, services = await client.list_entities_services() + + # Map every custom API service by name for the test to execute. + svc = {s.name: s for s in services} + for name in ( + "set_warning_a", + "clear_warning_a", + "set_warning_b", + "clear_warning_b", + "set_error_a", + "clear_error_a", + "set_error_b", + "clear_error_b", + "snapshot_led", + ): + assert name in svc, f"service {name} not registered" + + # Track every sensor we care about. SensorTracker gives us + # expect(value) / expect_any() futures that resolve when a + # matching state arrives; much simpler than manual bookkeeping. + tracker = SensorTracker( + [ + "app_warning_bit", + "app_error_bit", + "status_led_writes", + "status_led_last_state", + ] + ) + tracker.key_to_sensor.update( + build_key_to_entity_mapping(entities, list(tracker.sensor_states.keys())) + ) + + # Swallow initial state broadcasts so the test only reacts to + # state changes triggered by our service calls. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(tracker.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + async def call(name: str) -> None: + await client.execute_service(svc[name], {}) + + async def call_and_expect_bits( + service_name: str, *, warning: float, error: float + ) -> None: + """Execute a service and wait for both app bit sensors to match. + + Each bit-toggling service calls component.update on both + app_warning_bit and app_error_bit, so both sensors publish. + """ + futures = tracker.expect_all( + {"app_warning_bit": warning, "app_error_bit": error} + ) + await call(service_name) + await tracker.await_all(futures) + + async def snapshot_led_writes() -> int: + """Trigger a publish of the fake status_led output counter and return it.""" + future = tracker.expect_any("status_led_writes") + await call("snapshot_led") + await tracker.await_change(future, "status_led_writes") + return int(tracker.sensor_states["status_led_writes"][-1]) + + # ---- Baseline: everything clean ---- + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # ================================================================ + # Part 1 — STATUS_LED_WARNING propagation to App.app_state_ + # ================================================================ + + # Single component set/clear + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # Multi-component OR: both set, clear A, bit stays (B still has it), clear B, gone + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_b", warning=0.0, error=0.0) + + # Opposite clear order + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # ================================================================ + # Part 2 — STATUS_LED_ERROR propagation (same scenarios) + # ================================================================ + + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) + + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("set_error_b", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0) + + # ================================================================ + # Part 3 — warning and error are independent + # ================================================================ + + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_error_b", warning=1.0, error=1.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0) + + # ================================================================ + # Part 4 — status_led_light actually reads App.app_state_ + # ================================================================ + # The fake status_led_light output increments status_led_write_count + # on every write. status_led_light::loop() writes its output on every + # iteration while an error/warning bit is set, so after holding a + # warning for ~300 ms we should see the counter move significantly. + # This is the end-to-end proof that the bits we set above actually + # reach status_led_light and drive its behavior. + + count_before_warning = await snapshot_led_writes() + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + # Let status_led_light's loop run long enough to toggle the pin + # several times (it reads get_app_state() every main loop iteration). + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_warning = await snapshot_led_writes() + assert count_after_warning > count_before_warning, ( + "status_led_light did not respond to STATUS_LED_WARNING being set: " + f"write count stayed at {count_before_warning} → {count_after_warning}. " + "The full chain Component::status_set_warning → App.app_state_ → " + "status_led_light::loop reading get_app_state() is broken." + ) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # Same check for ERROR + count_before_error = await snapshot_led_writes() + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_error = await snapshot_led_writes() + assert count_after_error > count_before_error, ( + "status_led_light did not respond to STATUS_LED_ERROR being set: " + f"write count stayed at {count_before_error} → {count_after_error}. " + ) + await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) + + # ---- Set → clear → re-set round-trip ---- + # After clearing, status_led_light stops writing (steady state). + # Re-setting the flag must make it resume. This guards against a + # future idle optimization (e.g. #15642) where status_led disables + # its own loop when idle: if the re-enable path were broken, the + # second set would not produce writes. + # + # Snapshot AFTER the clear to avoid counting writes that were still + # in-flight from the error-set phase. + count_after_clear = await snapshot_led_writes() + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_idle = await snapshot_led_writes() + assert count_after_idle - count_after_clear <= 5, ( + "status_led_light kept writing after warning/error was cleared: " + f"count grew from {count_after_clear} to {count_after_idle}. " + "Expected it to stop writing once all status bits were clear." + ) + # Re-set warning — writes must resume. + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_reset = await snapshot_led_writes() + assert count_after_reset > count_after_idle + 5, ( + "status_led_light did not resume writing after re-setting " + f"STATUS_LED_WARNING: count went from {count_after_idle} to " + f"{count_after_reset}. If an idle optimization disabled the " + "loop, the re-enable path may be broken." + ) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) From 4479212008f551f38885c3f4a278befb9a400b67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 09:08:30 -1000 Subject: [PATCH 0330/2214] [core] Inline feed_wdt hot path with out-of-line slow path (#15656) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/application.cpp | 34 ++++++++++++++++++++-------------- esphome/core/application.h | 36 +++++++++++++++++++++++++++++------- esphome/core/scheduler.cpp | 8 +++++++- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 0c17c70161..1c73230705 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -209,21 +209,27 @@ void Application::process_dump_config_() { this->dump_config_at_++; } -void HOT Application::feed_wdt(uint32_t time) { - static uint32_t last_feed = 0; - // Use provided time if available, otherwise get current time - uint32_t now = time ? time : millis(); - // Compare in milliseconds (3ms threshold) - if (now - last_feed > 3) { - arch_feed_wdt(); - last_feed = now; -#ifdef USE_STATUS_LED - if (status_led::global_status_led != nullptr) { - status_led::global_status_led->call(); - } -#endif +void Application::feed_wdt() { + // Cold entry: callers without a millis() timestamp in hand. Fetches the + // time and takes the same rate-limit path as feed_wdt_with_time(). + uint32_t now = millis(); + if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { + this->feed_wdt_slow_(now); } } + +void HOT Application::feed_wdt_slow_(uint32_t time) { + // Callers (both feed_wdt() and feed_wdt_with_time()) have already + // confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded. + arch_feed_wdt(); + this->last_wdt_feed_ = time; +#ifdef USE_STATUS_LED + if (status_led::global_status_led != nullptr) { + status_led::global_status_led->call(); + } +#endif +} + bool Application::any_component_has_status_flag_(uint8_t flag) const { // Walk all components (not just looping ones) so non-looping components' // status bits are respected. Only called from the slow-path clear helpers @@ -325,7 +331,7 @@ void Application::teardown_components(uint32_t timeout_ms) { while (pending_count > 0 && (now - start_time) < timeout_ms) { // Feed watchdog during teardown to prevent triggering - this->feed_wdt(now); + this->feed_wdt_with_time(now); // Process components and compact the array, keeping only those still pending size_t still_pending = 0; diff --git a/esphome/core/application.h b/esphome/core/application.h index 0150bb6646..60087d527d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -385,7 +385,24 @@ class Application { void schedule_dump_config() { this->dump_config_at_ = 0; } - void feed_wdt(uint32_t time = 0); + /// Minimum interval between real arch_feed_wdt() calls. Chosen to keep the + /// rate of HAL pokes low while still being small enough that any plausible + /// watchdog timeout (seconds) has orders of magnitude of safety margin. + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3; + + /// Feed the task watchdog. Cold entry — callers without a millis() + /// timestamp in hand. Out of line to keep call sites tiny. + void feed_wdt(); + + /// Feed the task watchdog, hot entry. Callers that already have a + /// millis() timestamp pay only a load + sub + branch on the common + /// (no-op) path. The actual arch feed + status LED update live in + /// feed_wdt_slow_. + void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) { + if (static_cast(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] { + this->feed_wdt_slow_(time); + } + } void reboot(); @@ -632,7 +649,10 @@ class Application { /// Caller must ensure dump_config_at_ < components_.size(). void __attribute__((noinline)) process_dump_config_(); - void feed_wdt_arch_(); + /// Slow path for feed_wdt(): actually calls arch_feed_wdt(), updates + /// last_wdt_feed_, and re-dispatches the status LED. Out of line so the + /// inline wrapper stays tiny. + void feed_wdt_slow_(uint32_t time); /// Perform a delay while also monitoring socket file descriptors for readiness #ifdef USE_HOST @@ -686,6 +706,7 @@ class Application { // 4-byte members uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; + uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path #ifdef USE_HOST int max_fd_{-1}; // Highest file descriptor number for select() @@ -830,12 +851,13 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ this->drain_wake_notifications_(); #endif - // Process scheduled tasks + // Process scheduled tasks. Scheduler::call now feeds the watchdog itself + // after each scheduled item that actually runs, so we no longer need an + // unconditional feed here — when Scheduler::call has no work to do, the + // only elapsed time is a sleep wake + a few instructions, and when it does + // have work, it fed the wdt as it went. this->scheduler.call(loop_start_time); - // Feed the watchdog timer - this->feed_wdt(loop_start_time); - // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions if (this->has_pending_enable_loop_requests_) { @@ -874,7 +896,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); } - this->feed_wdt(last_op_end_time); + this->feed_wdt_with_time(last_op_end_time); } this->after_loop_tasks_(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index dff50b03ef..3e75a68064 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -739,7 +739,13 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); - return guard.finish(); + uint32_t end = guard.finish(); + // Feed the watchdog after each scheduled item (both main heap and defer + // queue paths go through here). A run of back-to-back callbacks cannot + // starve the wdt. The inline fast path is a load + sub + branch — nearly + // free when the 3 ms rate limit hasn't elapsed. + App.feed_wdt_with_time(end); + return end; } // Common implementation for cancel operations - handles locking From 6857e1ceb4533f20a2e07d7cd08aa6e768c030ee Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:32:11 -0400 Subject: [PATCH 0331/2214] [st7789v] Fix swapped offset_width/offset_height in model presets (#15755) --- esphome/components/st7789v/display.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index 85414237cf..745c37f47d 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -45,8 +45,8 @@ MODELS = { presets={ CONF_HEIGHT: 240, CONF_WIDTH: 135, - CONF_OFFSET_HEIGHT: 52, - CONF_OFFSET_WIDTH: 40, + CONF_OFFSET_HEIGHT: 40, + CONF_OFFSET_WIDTH: 52, CONF_CS_PIN: "GPIO5", CONF_DC_PIN: "GPIO16", CONF_RESET_PIN: "GPIO23", @@ -68,8 +68,8 @@ MODELS = { presets={ CONF_HEIGHT: 280, CONF_WIDTH: 240, - CONF_OFFSET_HEIGHT: 0, - CONF_OFFSET_WIDTH: 20, + CONF_OFFSET_HEIGHT: 20, + CONF_OFFSET_WIDTH: 0, } ), "ADAFRUIT_S2_TFT_FEATHER_240X135": model_spec( @@ -77,8 +77,8 @@ MODELS = { presets={ CONF_HEIGHT: 240, CONF_WIDTH: 135, - CONF_OFFSET_HEIGHT: 52, - CONF_OFFSET_WIDTH: 40, + CONF_OFFSET_HEIGHT: 40, + CONF_OFFSET_WIDTH: 52, CONF_CS_PIN: "GPIO7", CONF_DC_PIN: "GPIO39", CONF_RESET_PIN: "GPIO40", @@ -89,8 +89,8 @@ MODELS = { presets={ CONF_HEIGHT: 320, CONF_WIDTH: 170, - CONF_OFFSET_HEIGHT: 35, - CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + CONF_OFFSET_WIDTH: 35, CONF_ROTATION: 270, CONF_CS_PIN: "GPIO10", CONF_DC_PIN: "GPIO13", @@ -102,8 +102,8 @@ MODELS = { presets={ CONF_HEIGHT: 320, CONF_WIDTH: 172, - CONF_OFFSET_HEIGHT: 34, - CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + CONF_OFFSET_WIDTH: 34, CONF_ROTATION: 90, CONF_CS_PIN: "GPIO21", CONF_DC_PIN: "GPIO22", From 4047d5af5fab0956c9ef782947b99c8c30accd6d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:32:33 -0400 Subject: [PATCH 0332/2214] [sx126x][sx127x] Fix frequency precision loss from float32 codegen (#15753) --- esphome/components/sx126x/__init__.py | 4 ++-- esphome/components/sx127x/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 08f4c0fb88..b8696158fe 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -200,11 +200,11 @@ CONFIG_SCHEMA = ( cv.hex_int, cv.Range(min=0, max=0xFFFF) ), cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( - cv.frequency, cv.float_range(min=0, max=100000) + cv.frequency, cv.int_range(min=0, max=100000) ), cv.Required(CONF_DIO1_PIN): pins.gpio_input_pin_schema, cv.Required(CONF_FREQUENCY): cv.All( - cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + cv.frequency, cv.int_range(min=int(137e6), max=int(1020e6)) ), cv.Required(CONF_HW_VERSION): cv.one_of( "sx1261", "sx1262", "sx1268", "llcc68", lower=True diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 7f554fbf84..8fa7247192 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -197,11 +197,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_CODING_RATE, default="CR_4_5"): cv.enum(CODING_RATE), cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( - cv.frequency, cv.float_range(min=0, max=100000) + cv.frequency, cv.int_range(min=0, max=100000) ), cv.Optional(CONF_DIO0_PIN): pins.internal_gpio_input_pin_schema, cv.Required(CONF_FREQUENCY): cv.All( - cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + cv.frequency, cv.int_range(min=int(137e6), max=int(1020e6)) ), cv.Required(CONF_MODULATION): cv.enum(MOD), cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), From 5a2b7546f666abc106d22860c3f2bea5ad1b1a64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:07:49 -1000 Subject: [PATCH 0333/2214] Bump aioesphomeapi from 44.15.0 to 44.16.0 (#15757) 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 726a0a221a..b14d5f5f5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.15.0 +aioesphomeapi==44.16.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From af1aaba547d28639521b07d2bbe763f8c8b9e963 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:19:42 +1000 Subject: [PATCH 0334/2214] [lvgl] Clean the build if lv_conf.h changes (#15777) --- esphome/components/lvgl/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b6421dc43d..ac0363ca69 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -44,6 +44,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.writer import clean_build from esphome.yaml_util import load_yaml from . import defines as df, helpers, lv_validation as lvalid, widgets @@ -451,7 +452,8 @@ async def to_code(configs): df.add_define(f"LV_DRAW_SW_SUPPORT_{fmt}", "1") lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME) - write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()) + if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()): + clean_build(clear_pio_cache=False) cg.add_build_flag("-DLV_CONF_H=1") # handle windows paths in a way that doesn't break the generated C++ lv_conf_h_path = Path(lv_conf_h_file).as_posix() From 92c99a7d41bfd69389519f8628feaf63d95ec957 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Thu, 16 Apr 2026 15:35:24 +0200 Subject: [PATCH 0335/2214] [mitsubishi_cn105] use HEAT_COOL mode to enable temperature slider (#15748) --- .../mitsubishi_cn105_climate.cpp | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 40ddb88a79..284339e57f 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -7,7 +7,7 @@ namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.climate"; static constexpr std::array MODE_MAP{ - std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_AUTO}, + std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_HEAT_COOL}, std::pair{MitsubishiCN105::Mode::HEAT, climate::CLIMATE_MODE_HEAT}, std::pair{MitsubishiCN105::Mode::DRY, climate::CLIMATE_MODE_DRY}, std::pair{MitsubishiCN105::Mode::COOL, climate::CLIMATE_MODE_COOL}, @@ -76,23 +76,13 @@ void MitsubishiCN105Climate::loop() { climate::ClimateTraits MitsubishiCN105Climate::traits() { climate::ClimateTraits traits; - traits.set_supported_modes({ - climate::CLIMATE_MODE_OFF, - climate::CLIMATE_MODE_COOL, - climate::CLIMATE_MODE_HEAT, - climate::CLIMATE_MODE_DRY, - climate::CLIMATE_MODE_FAN_ONLY, - climate::CLIMATE_MODE_AUTO, - }); + for (const auto &p : MODE_MAP) { + traits.add_supported_mode(p.second); + } - traits.set_supported_fan_modes({ - climate::CLIMATE_FAN_AUTO, - climate::CLIMATE_FAN_QUIET, - climate::CLIMATE_FAN_LOW, - climate::CLIMATE_FAN_MEDIUM, - climate::CLIMATE_FAN_MIDDLE, - climate::CLIMATE_FAN_HIGH, - }); + for (const auto &p : FAN_MODE_MAP) { + traits.add_supported_fan_mode(p.second); + } traits.set_visual_min_temperature(16.0f); traits.set_visual_max_temperature(31.0f); From 914ed10bccee4e21b19916c7b10736124337a457 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 03:36:55 -1000 Subject: [PATCH 0336/2214] [core] Diagnose missing cg.templatable in codegen for TEMPLATABLE_VALUE fields (#15758) --- esphome/core/automation.h | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index eb270bfee2..468ea3b382 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -62,6 +62,18 @@ template class TemplatableFn { !std::convertible_to, T> || !std::default_initializable) = delete; + // Reject raw (non-callable) values with a helpful diagnostic pointing at the Python-side fix. + // TemplatableFn stores only a function pointer (4 bytes), so constants must be wrapped in a + // stateless lambda by codegen. External components hitting this error should use + // `cg.templatable(value, args, type)` in their Python __init__.py before passing to the setter. + template TemplatableFn(V) requires(!std::invocable) && (!std::convertible_to) { + static_assert(sizeof(V) == 0, "Missing cg.templatable(...) in Python codegen for this TEMPLATABLE_VALUE " + "field. The wrapper was always required; it worked by accident because the old " + "TemplatableValue implicitly converted raw constants. TemplatableFn cannot. See " + "https://developers.esphome.io/blog/2026/04/09/" + "templatablefn-4-byte-templatable-storage-for-trivially-copyable-types/"); + } + bool has_value() const { return this->f_ != nullptr; } T value(X... x) const { return this->f_ ? this->f_(x...) : T{}; } From aa80bdbbc68ab65c19c05002b57ac630f393892a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 03:40:22 -1000 Subject: [PATCH 0337/2214] [time] Fix RTC is_valid() rejecting valid times after day_of_year cleanup (#15763) --- esphome/components/bm8563/bm8563.cpp | 2 +- esphome/components/ds1307/ds1307.cpp | 2 +- esphome/components/pcf85063/pcf85063.cpp | 2 +- esphome/components/pcf8563/pcf8563.cpp | 2 +- esphome/components/rx8130/rx8130.cpp | 2 +- esphome/core/time.h | 8 ++- tests/components/time/is_valid.cpp | 72 ++++++++++++++++++++++++ 7 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 tests/components/time/is_valid.cpp diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index 062094c036..d911301c9d 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -63,7 +63,7 @@ void BM8563::read_time() { rtc_time.day_of_week, rtc_time.hour, rtc_time.minute, rtc_time.second); rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index 8fff4213b4..ba2ad6032f 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -44,7 +44,7 @@ void DS1307Component::read_time() { .year = uint16_t(ds1307_.reg.year + 10u * ds1307_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 1cf28a4955..000de1433c 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -44,7 +44,7 @@ void PCF85063Component::read_time() { .year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index b748f0156a..50003ca378 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -44,7 +44,7 @@ void PCF8563Component::read_time() { .year = uint16_t(pcf8563_.reg.year + 10u * pcf8563_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 3b704d2551..0aa6e86d31 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -81,7 +81,7 @@ void RX8130Component::read_time() { .year = static_cast(bcd2dec(date[6]) + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/core/time.h b/esphome/core/time.h index ed47432038..0b67b7b3fc 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -76,8 +76,12 @@ struct ESPTime { /// @copydoc strftime(const std::string &format) std::string strftime(const char *format); - /// Check if this ESPTime is valid (all fields in range and year is greater than or equal to 2019) - bool is_valid() const { return this->year >= 2019 && this->fields_in_range(); } + /// Check if this ESPTime is valid (year >= 2019 and the requested fields are in range). + /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) + /// @param check_day_of_year validate day_of_year (not always available when constructing from date/time fields) + bool is_valid(bool check_day_of_week = true, bool check_day_of_year = true) const { + return this->year >= 2019 && this->fields_in_range(check_day_of_week, check_day_of_year); + } /// Check if time fields are in range. /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) diff --git a/tests/components/time/is_valid.cpp b/tests/components/time/is_valid.cpp new file mode 100644 index 0000000000..9148c0e8d6 --- /dev/null +++ b/tests/components/time/is_valid.cpp @@ -0,0 +1,72 @@ +// Regression tests for ESPTime::is_valid() optional checks. +// +// The RTC components (ds1307, bm8563, pcf85063, pcf8563, rx8130) read date/time +// fields from hardware but do not populate day_of_year. They call +// recalc_timestamp_utc(false) -- which skips day_of_year -- and then is_valid(). +// These tests ensure the is_valid() overload can skip day_of_year validation so +// RTCs don't log "Invalid RTC time, not syncing to system clock." for valid times. + +#include +#include "esphome/core/time.h" + +namespace esphome::testing { + +// Build an ESPTime that mirrors what the RTC components construct: all fields +// populated from hardware except day_of_year (left zero-initialized). +static ESPTime make_rtc_like_time() { + ESPTime t{}; + t.second = 30; + t.minute = 15; + t.hour = 12; + t.day_of_week = 4; // thursday + t.day_of_month = 15; + t.month = 4; + t.year = 2026; + // day_of_year intentionally left at 0 -- RTCs don't compute it. + return t; +} + +TEST(ESPTimeIsValid, DefaultRejectsZeroDayOfYear) { + // Default is_valid() checks day_of_year; zero-init is out of range. + ESPTime t = make_rtc_like_time(); + EXPECT_FALSE(t.is_valid()); +} + +TEST(ESPTimeIsValid, SkipDayOfYearAcceptsRTCLikeTime) { + // RTC code path: skip day_of_year validation. + ESPTime t = make_rtc_like_time(); + EXPECT_TRUE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipDayOfYearStillRejectsOutOfRangeFields) { + ESPTime t = make_rtc_like_time(); + t.hour = 25; + EXPECT_FALSE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipDayOfYearStillRejectsYearBefore2019) { + ESPTime t = make_rtc_like_time(); + t.year = 2000; + EXPECT_FALSE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipBothDayChecksAcceptsGPSLikeTime) { + // GPS path (gps_time.cpp) populates neither day_of_week nor day_of_year. + ESPTime t{}; + t.second = 30; + t.minute = 15; + t.hour = 12; + t.day_of_month = 15; + t.month = 4; + t.year = 2026; + EXPECT_TRUE(t.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false)); + EXPECT_FALSE(t.is_valid()); // default still rejects +} + +TEST(ESPTimeIsValid, FullyPopulatedAcceptsWithDefaults) { + ESPTime t = make_rtc_like_time(); + t.day_of_year = 105; + EXPECT_TRUE(t.is_valid()); +} + +} // namespace esphome::testing From 227dfa3730692c2ecf1931cdee1131bb08b4893f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:36:06 -0400 Subject: [PATCH 0338/2214] [qmc5883l] Move per-update log line from DEBUG to VERBOSE (#15781) --- esphome/components/qmc5883l/qmc5883l.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index bc2adb5cfe..d0488d0c9f 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -100,7 +100,7 @@ void QMC5883LComponent::update() { // ROL_PNT in setup and reading 7 bytes starting at the status register. // If status and all three axes are desired, using ROL_PNT saves you 3 bytes. // But simply not reading status saves you 4 bytes always and is much simpler. - if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) { + if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) { err = this->read_register(QMC5883L_REGISTER_STATUS, &status, 1); if (err != i2c::ERROR_OK) { char buf[32]; @@ -165,7 +165,7 @@ void QMC5883LComponent::update() { temp = int16_t(raw_temp) * 0.01f; } - ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, + ESP_LOGV(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, temp, status); if (this->x_sensor_ != nullptr) From 81fb6712fef424a0273237786b2a4e4898f57742 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 08:45:33 -1000 Subject: [PATCH 0339/2214] [bundle] Force-resolve nested IncludeFile during file discovery (#15762) --- esphome/bundle.py | 78 ++++++++- .../fixtures/bundle/bundle_test.yaml | 6 +- .../fixtures/bundle/common/wifi.yaml | 2 + tests/unit_tests/test_bundle.py | 150 +++++++++++++++++- 4 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/fixtures/bundle/common/wifi.yaml diff --git a/esphome/bundle.py b/esphome/bundle.py index b6816c7c95..efa80acc8c 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -151,8 +151,8 @@ class ConfigBundleCreator: def __init__(self, config: dict[str, Any]) -> None: self._config = config - self._config_dir = CORE.config_dir - self._config_path = CORE.config_path + self._config_dir = Path(CORE.config_dir).resolve() + self._config_path = Path(CORE.config_path).resolve() self._files: list[BundleFile] = [] self._seen_paths: set[Path] = set() self._secrets_paths: set[Path] = set() @@ -258,21 +258,36 @@ class ConfigBundleCreator: def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. - We track files by wrapping _load_yaml_internal. The config has already - been loaded at this point (bundle is a POST_CONFIG_ACTION), so we - re-load just to discover the file list. + Deliberately uses a fresh re-parse and force-loads every deferred + ``IncludeFile`` to include *all* potentially-reachable includes, + even branches not selected by the local substitutions. Bundles are + meant to be compiled on another system where command-line + substitution overrides may choose a different branch — e.g. + ``!include network/${eth_model}/config.yaml`` must ship every + candidate so the remote build can pick any one. + + Entries with unresolved substitution variables in the filename + path are skipped with a warning (they cannot be resolved without + the substitution pass). Secrets files are tracked separately so we can filter them to only include the keys this config actually references. """ + # Must be a fresh parse: IncludeFile.load() caches its result in + # _content, and we discover files by listening for loader calls. On + # an already-parsed tree the cache is populated, .load() returns + # without calling the loader, the listener never fires, and the + # referenced files would be silently dropped from the bundle. with yaml_util.track_yaml_loads() as loaded_files: try: - yaml_util.load_yaml(self._config_path) + data = yaml_util.load_yaml(self._config_path) except EsphomeError: _LOGGER.debug( "Bundle: re-loading YAML for include discovery failed, " "proceeding with partial file list" ) + else: + _force_load_include_files(data) for fpath in loaded_files: if fpath == self._config_path.resolve(): @@ -608,6 +623,57 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: tar.addfile(info, io.BytesIO(data)) +def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None: + """Recursively resolve any ``IncludeFile`` instances in a YAML tree. + + Nested ``!include`` returns a deferred ``IncludeFile`` that is only + resolved during the substitution pass. During bundle discovery we need + the referenced files to actually load so the ``track_yaml_loads`` + listener fires for them. + + ``IncludeFile`` instances with unresolved substitution variables in the + filename cannot be loaded — we skip and warn about those. + """ + if _seen is None: + _seen = set() + + if isinstance(obj, yaml_util.IncludeFile): + if id(obj) in _seen: + return + _seen.add(id(obj)) + if obj.has_unresolved_expressions(): + _LOGGER.warning( + "Bundle: cannot resolve !include %s (referenced from %s) " + "with substitutions in path", + obj.file, + obj.parent_file, + ) + return + try: + loaded = obj.load() + except EsphomeError as err: + _LOGGER.warning( + "Bundle: failed to load !include %s (referenced from %s): %s", + obj.file, + obj.parent_file, + err, + ) + return + _force_load_include_files(loaded, _seen) + elif isinstance(obj, dict): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for value in obj.values(): + _force_load_include_files(value, _seen) + elif isinstance(obj, (list, tuple)): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for item in obj: + _force_load_include_files(item, _seen) + + def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): diff --git a/tests/unit_tests/fixtures/bundle/bundle_test.yaml b/tests/unit_tests/fixtures/bundle/bundle_test.yaml index f834a8d867..247f5cc8bb 100644 --- a/tests/unit_tests/fixtures/bundle/bundle_test.yaml +++ b/tests/unit_tests/fixtures/bundle/bundle_test.yaml @@ -11,9 +11,9 @@ esp32: logger: <<: !include common/base.yaml -wifi: - ssid: !secret wifi_ssid - password: !secret wifi_password +# Plain nested !include — deferred as an IncludeFile until the substitution +# pass. The bundle must force-resolve it to pick up common/wifi.yaml. +wifi: !include common/wifi.yaml api: diff --git a/tests/unit_tests/fixtures/bundle/common/wifi.yaml b/tests/unit_tests/fixtures/bundle/common/wifi.yaml new file mode 100644 index 0000000000..d7e7b3cd45 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/common/wifi.yaml @@ -0,0 +1,2 @@ +ssid: !secret wifi_ssid +password: !secret wifi_password diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index b8b2d0ffd1..89bf1a33b3 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -5,8 +5,10 @@ from __future__ import annotations import io import json from pathlib import Path +import shutil import tarfile from typing import Any +from unittest.mock import patch import pytest @@ -20,6 +22,7 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, + _force_load_include_files, extract_bundle, is_bundle_path, prepare_bundle_for_compile, @@ -485,7 +488,7 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: result = read_bundle_manifest(bundle_path) assert result.esphome_version == "unknown" - assert result.files == [] + assert not result.files assert result.has_secrets is False @@ -862,6 +865,117 @@ def test_discover_files_skips_missing_directory(tmp_path: Path) -> None: assert len(files) == 1 +def test_discover_files_nested_include(tmp_path: Path) -> None: + """Nested !include files (e.g. wifi: !include wifi.yaml) are bundled.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include wifi.yaml\n" + ) + (config_dir / "wifi.yaml").write_text('ssid: "a"\npassword: "b"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert "wifi.yaml" in paths + + +def test_discover_files_deeply_nested_include(tmp_path: Path) -> None: + """Chains of !include (a includes b includes c) are fully resolved.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include level1.yaml\n" + ) + (config_dir / "level1.yaml").write_text("nested: !include level2.yaml\n") + (config_dir / "level2.yaml").write_text('value: "leaf"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "level1.yaml" in paths + assert "level2.yaml" in paths + + +def test_discover_files_nested_include_unresolved_substitution( + tmp_path: Path, +) -> None: + """!include with substitution vars in path cannot be resolved; skipped gracefully.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include ${platform}.yaml\n" + ) + + creator = ConfigBundleCreator({}) + # Should not raise + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + + +def test_discover_files_nested_include_load_failure( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A nested !include pointing at a missing file is logged and skipped.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include missing.yaml\n" + ) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert any( + "failed to load !include" in r.message and "missing.yaml" in r.message + for r in caplog.records + ) + + +def test_force_load_skips_duplicate_include_file() -> None: + """The same IncludeFile referenced twice is only loaded once.""" + + class _StubInclude: + """Mimics yaml_util.IncludeFile minimally for _force_load testing.""" + + def __init__(self) -> None: + self.file = Path("dup.yaml") + self.parent_file = Path("root.yaml") + self.load_calls = 0 + + def has_unresolved_expressions(self) -> bool: + return False + + def load(self) -> dict[str, Any]: + self.load_calls += 1 + return {} + + stub = _StubInclude() + # Same instance appears twice — second visit must hit the _seen guard. + tree = {"a": stub, "b": [stub]} + + with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude): + _force_load_include_files(tree) + + assert stub.load_calls == 1 + + +def test_force_load_handles_cyclic_containers() -> None: + """Cyclic dict/list references don't cause infinite recursion.""" + cyclic_dict: dict[str, Any] = {} + cyclic_dict["self"] = cyclic_dict + + cyclic_list: list[Any] = [] + cyclic_list.append(cyclic_list) + + # Should return without recursing forever + _force_load_include_files(cyclic_dict) + _force_load_include_files(cyclic_list) + + def test_discover_files_yaml_reload_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1008,6 +1122,40 @@ def test_discover_files_walk_tuple_values(tmp_path: Path) -> None: assert "a.pem" in paths +# --------------------------------------------------------------------------- +# ConfigBundleCreator - fixture-based end-to-end +# --------------------------------------------------------------------------- + + +def test_discover_files_fixture_config(fixture_path: Path, tmp_path: Path) -> None: + """Use the real ``fixtures/bundle/`` tree as an end-to-end reproducer. + + The fixture config uses ``wifi: !include common/wifi.yaml`` — a plain + nested !include that is returned as a deferred ``IncludeFile`` and only + resolved during the substitution pass. Before this fix, bundle discovery + never ran substitutions, so ``common/wifi.yaml`` was silently missing + from the bundle. + """ + # Copy the fixture tree into a tmp dir so the test doesn't rely on the + # source repo being writable and so we can set CORE.config_path freely. + src = fixture_path / "bundle" + dst = tmp_path / "bundle" + shutil.copytree(src, dst) + + CORE.config_path = dst / "bundle_test.yaml" + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + paths = {f.path for f in files} + + # Root and top-level !secret-referenced files + assert "bundle_test.yaml" in paths + assert "secrets.yaml" in paths + # The nested !include — this is what regressed when IncludeFile became + # deferred (PR #12213). + assert "common/wifi.yaml" in paths + + # --------------------------------------------------------------------------- # ConfigBundleCreator - create_bundle # --------------------------------------------------------------------------- From 9cb2b562b9e6706dc00117a7ac6299730eebc100 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 09:01:55 -1000 Subject: [PATCH 0340/2214] [ili9xxx] Guard against null buffer in display_() when allocation fails (#15786) --- esphome/components/ili9xxx/ili9xxx_defines.h | 2 ++ esphome/components/ili9xxx/ili9xxx_display.cpp | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/esphome/components/ili9xxx/ili9xxx_defines.h b/esphome/components/ili9xxx/ili9xxx_defines.h index f4c5aad957..70e0937f79 100644 --- a/esphome/components/ili9xxx/ili9xxx_defines.h +++ b/esphome/components/ili9xxx/ili9xxx_defines.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace esphome { namespace ili9xxx { diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index a3eff901d3..11acb8a73a 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -229,6 +229,10 @@ void ILI9XXXDisplay::update() { } void ILI9XXXDisplay::display_() { + // buffer may be null if allocation failed + if (this->buffer_ == nullptr) { + return; + } // check if something was displayed if ((this->x_high_ < this->x_low_) || (this->y_high_ < this->y_low_)) { return; From 722cfae04c4ce7745701f783f2f9c75fed2243f7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:07:04 -0400 Subject: [PATCH 0341/2214] [esp32] Accept unquoted minimum_chip_revision values (#15785) --- esphome/components/esp32/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7b3f9da3da..a68614cb43 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1222,7 +1222,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean, cv.Optional(CONF_MINIMUM_CHIP_REVISION): cv.one_of( - *ESP32_CHIP_REVISIONS + *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, # DHCP server is needed for WiFi AP mode. When WiFi component is used, From b167b64f06ea724824ea5601ab024bac42c235a8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:16:58 -0400 Subject: [PATCH 0342/2214] [lvgl] Guard lv_image_set_src wrapper with LV_USE_IMAGE (#15789) --- esphome/components/lvgl/lvgl_esphome.h | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3ba258b1a2..3ec1d247d8 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -76,16 +76,17 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { } #endif #if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -// Shortcut / overload, so that the source of an image can easily be updated -// from within a lambda. -inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { lv_image_set_src(obj, image->get_lv_image_dsc()); } +#if LV_USE_IMAGE +// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. +inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } +#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); } inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ANIMIMG From c3e739eba9c73c34ce843c7b6cc1bdcee11355c4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:17:16 +1000 Subject: [PATCH 0343/2214] [mipi_spi] Drawing fixes for native display (#15802) --- esphome/components/mipi_spi/display.py | 6 +- esphome/components/mipi_spi/mipi_spi.h | 14 +- .../mipi_spi/test_final_validate.py | 185 ++++++++++++++++++ 3 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_final_validate.py diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 42c7ec2224..364ada9046 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -195,7 +195,7 @@ def model_schema(config): "big_endian", "little_endian", lower=True ), model.option(CONF_COLOR_DEPTH, 16): cv.one_of(*color_depth, lower=True), - model.option(CONF_DRAW_ROUNDING, 2): power_of_two, + model.option(CONF_DRAW_ROUNDING, 1): power_of_two, model.option(CONF_PIXEL_MODE, DISPLAY_16BIT): cv.one_of( *pixel_modes, lower=True ), @@ -297,9 +297,9 @@ def _final_validate(config): buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here - fraction = min(20000.0, buffer_size // 16) / buffer_size + fraction = min(20000.0, buffer_size // 4) / buffer_size config[CONF_BUFFER_SIZE] = 1.0 / next( - x for x in range(2, 17) if fraction >= 1 / x + (x for x in range(2, 8) if fraction >= 1 / x), 8 ) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2242be6c17..f292345893 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -546,13 +546,12 @@ class MipiSpiBuffer : public MipiSpistart_line_ = 0; this->start_line_ < this->get_height_internal(); - this->start_line_ += this->get_height_internal() / FRACTION) { + auto increment = (this->get_height_internal() / FRACTION / ROUNDING) * ROUNDING; + for (this->start_line_ = 0; this->start_line_ < this->get_height_internal(); this->start_line_ = this->end_line_) { #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE auto lap = millis(); #endif - this->end_line_ = - clamp_at_most(this->start_line_ + this->get_height_internal() / FRACTION, this->get_height_internal()); + this->end_line_ = clamp_at_most(this->start_line_ + increment, this->get_height_internal()); if (this->auto_clear_enabled_) { this->clear(); } @@ -574,12 +573,13 @@ class MipiSpiBuffer : public MipiSpix_low_ = this->x_low_ / ROUNDING * ROUNDING; this->y_low_ = this->y_low_ / ROUNDING * ROUNDING; - this->x_high_ = (this->x_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; - this->y_high_ = (this->y_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; + this->x_high_ = round_buffer(this->x_high_ + 1) - 1; + this->y_high_ = clamp_at_most(round_buffer(this->y_high_ + 1) - 1, this->end_line_ - 1); int w = this->x_high_ - this->x_low_ + 1; int h = this->y_high_ - this->y_low_ + 1; this->write_to_display_(this->x_low_, this->y_low_, w, h, this->buffer_, this->x_low_, - this->y_low_ - this->start_line_, round_buffer(this->get_width_internal()) - w); + this->y_low_ - this->start_line_, + round_buffer(this->get_width_internal()) - w - this->x_low_); // invalidate watermarks this->x_low_ = this->get_width_internal(); this->y_low_ = this->get_height_internal(); diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py new file mode 100644 index 0000000000..8c45b47752 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -0,0 +1,185 @@ +"""Tests for the _final_validate buffer size calculation in mipi_spi.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.display import CONF_SHOW_TEST_CARD +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def _custom_config( + width: int, + height: int, + color_depth: str | int | None = None, + **extra: Any, +) -> ConfigType: + """Build a minimal valid custom-model config with the given dimensions.""" + config: ConfigType = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": width, "height": height}, + "init_sequence": [[0xA0, 0x01]], + } + if color_depth is not None: + config["color_depth"] = color_depth + config.update(extra) + return config + + +# The auto buffer-size selection inside _final_validate targets ~20 kB of +# pixel buffer. For a buffer of ``depth_bytes * width * height``, it picks the +# smallest integer ``x`` in range(2, 8) such that +# ``min(20000, buffer // 4) / buffer >= 1 / x`` (falling back to ``x = 8``). +# The test cases below cover the full range of possible outcomes (1/4 .. 1/8). +@pytest.mark.parametrize( + ("width", "height", "color_depth", "expected"), + [ + # 16-bit color depth -- buffer = 2 * width * height + # 128*160*2 = 40960 B -> fraction = 10240/40960 = 0.25 -> x = 4 + pytest.param(128, 160, "16bit", 1.0 / 4, id="16bit_tiny"), + # 200*224*2 = 89600 B -> fraction = 20000/89600 ≈ 0.2232 -> x = 5 + pytest.param(200, 224, "16bit", 1.0 / 5, id="16bit_small"), + # 240*224*2 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(240, 224, "16bit", 1.0 / 6, id="16bit_medium"), + # 200*320*2 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(200, 320, "16bit", 1.0 / 7, id="16bit_large"), + # 240*320*2 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(240, 320, "16bit", 1.0 / 8, id="16bit_xlarge"), + # 320*480*2 = 307200 B -> fraction ≈ 0.0651 -> default x = 8 + pytest.param(320, 480, "16bit", 1.0 / 8, id="16bit_huge"), + # 8-bit color depth -- buffer = width * height + # 320*240 = 76800 B -> fraction = 19200/76800 = 0.25 -> x = 4 + pytest.param(320, 240, "8bit", 1.0 / 4, id="8bit_tiny"), + # 400*224 = 89600 B -> fraction ≈ 0.2232 -> x = 5 + pytest.param(400, 224, "8bit", 1.0 / 5, id="8bit_small"), + # 480*224 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(480, 224, "8bit", 1.0 / 6, id="8bit_medium"), + # 400*320 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(400, 320, "8bit", 1.0 / 7, id="8bit_large"), + # 480*320 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(480, 320, "8bit", 1.0 / 8, id="8bit_xlarge"), + ], +) +def test_buffer_size_auto_selected( + width: int, + height: int, + color_depth: str, + expected: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """Without PSRAM or an explicit buffer_size, a fraction is chosen from the display size. + + Without any drawing method and without LVGL, final validation also auto-enables + ``show_test_card``, which in turn makes the component require a buffer and therefore + triggers the buffer-size selection path. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated(_custom_config(width, height, color_depth)) + + # Sanity check: final validation should have enabled the test card for us, + # which is what causes the buffer-size calculation to actually run. + assert config.get(CONF_SHOW_TEST_CARD) is True + assert config[CONF_BUFFER_SIZE] == pytest.approx(expected) + + +@pytest.mark.parametrize( + "buffer_size", + [0.125, 0.25, 0.5, 1.0], + ids=["one_eighth", "one_quarter", "half", "full"], +) +def test_explicit_buffer_size_is_preserved( + buffer_size: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """An explicitly configured buffer_size is never overridden by final validation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated( + _custom_config(240, 320, "16bit", buffer_size=buffer_size), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(buffer_size) + + +def test_buffer_size_not_set_when_psram_enabled( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """When PSRAM is enabled the auto buffer-size selection is skipped.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + # Presence of the psram domain in the full config is what _final_validate checks. + set_component_config("psram", True) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + + +def test_buffer_size_not_set_when_buffer_not_required( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """With LVGL present and no drawing methods, no buffer fraction is chosen. + + LVGL suppresses the automatic show_test_card injection, which means + ``requires_buffer`` is False and the early-return branch fires. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + # And no test card should have been auto-enabled either. + assert not config.get(CONF_SHOW_TEST_CARD) + + +def test_buffer_size_selected_when_lvgl_with_test_card( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """LVGL present + an explicit drawing method still triggers buffer sizing. + + When LVGL is enabled, ``show_test_card`` is not injected automatically, + but users can still request it explicitly -- in that case ``requires_buffer`` + is True and the buffer-size heuristic still runs. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + # 128x160 @ 16bit -> expected 1/4 (see test_buffer_size_auto_selected). + config = _validated( + _custom_config(128, 160, "16bit", show_test_card=True), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) From f5806818cd4b087fa282d1c3b98f618f8c8e3c10 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:11:05 +1000 Subject: [PATCH 0344/2214] [image] Fix byte order handling (#15800) --- esphome/components/image/__init__.py | 17 ++++++++++------- esphome/components/image/image.cpp | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 4a5fcc385e..7db50597e6 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -28,7 +28,6 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.final_validate import full_config _LOGGER = logging.getLogger(__name__) @@ -676,12 +675,16 @@ def _final_validate(config): :param config: :return: """ - fv = full_config.get() - if "lvgl" in fv and not all(CONF_BYTE_ORDER in x for x in config): - config = config.copy() - for c in config: - if not c.get(CONF_BYTE_ORDER): - c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + config = config.copy() + for c in config: + if byte_order := c.get(CONF_BYTE_ORDER): + if byte_order == "BIG_ENDIAN": + _LOGGER.warning( + "The image '%s' is configured with big-endian byte order, little-endian is expected", + c.get(CONF_FILE), + ) + else: + c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" return config diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index a6f9e35e2e..5b4ed6968c 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -189,7 +189,7 @@ Color Image::get_rgb_pixel_(int x, int y) const { } Color Image::get_rgb565_pixel_(int x, int y) const { const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8; - uint16_t rgb565 = encode_uint16(progmem_read_byte(pos), progmem_read_byte(pos + 1)); + uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos)); auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; From b26601a3dcfc284153b6c5405dcfe91f6356d8c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 02:45:50 -1000 Subject: [PATCH 0345/2214] [core] coerce set_interval(0) / update_interval: 0ms to 1ms (#15799) --- esphome/config_validation.py | 21 +++++- esphome/core/scheduler.cpp | 13 ++++ .../scheduler_interval_zero_coerced.yaml | 27 ++++++++ .../test_scheduler_interval_zero_coerced.py | 67 +++++++++++++++++++ tests/unit_tests/test_config_validation.py | 28 ++++++++ 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/scheduler_interval_zero_coerced.yaml create mode 100644 tests/integration/test_scheduler_interval_zero_coerced.py diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 31cfb41a6d..bf53013d9b 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -943,7 +943,26 @@ def time_period_in_minutes_(value): def update_interval(value): if value == "never": return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) - return positive_time_period_milliseconds(value) + result = positive_time_period_milliseconds(value) + # 0ms was historically (mis)used as a pseudo-loop() mechanism for + # PollingComponents. Under the hood it calls set_interval(0), which + # causes Scheduler::call() to spin (WDT reset in the field). Coerce + # to 1ms so existing configs keep working at ~1kHz instead of + # spinning. Don't hard-fail so configs don't break on upgrade; + # authors should migrate to HighFrequencyLoopRequester (C++) for + # true run-every-loop behaviour. + if result.total_milliseconds == 0: + _LOGGER.warning( + "update_interval of 0ms is not supported - coercing to 1ms. " + "A literal 0ms schedule would spin the main loop (the scheduled " + "item would always be due, so the scheduler would never yield " + "back) and trigger a watchdog reset. Set update_interval to a " + "non-zero value such as 1ms or higher. (Custom C++ components " + "that need true run-every-loop behaviour should override loop() " + "with HighFrequencyLoopRequester instead.)" + ) + return TimePeriodMilliseconds(milliseconds=1) + return result time_period = Any(time_period_str_unit, time_period_str_colon, time_period_dict) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 3e75a68064..7e6ad19ac7 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -144,6 +144,19 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } + // An interval of 0 means "fire every tick forever," which is misuse: the + // item would always be due, causing Scheduler::call() to spin and starve + // the main loop (WDT reset in the field). Coerce to 1ms so existing code + // using update_interval=0ms as a pseudo-loop() continues to work at ~1kHz, + // and warn so authors can migrate to HighFrequencyLoopRequester which is + // the intended mechanism for running fast in the main loop. Zero-delay + // timeouts (defer) remain legitimate one-shots and are not affected. + if (type == SchedulerItem::INTERVAL && delay == 0) [[unlikely]] { + ESP_LOGE(TAG, "[%s] set_interval(0) would spin main loop - coercing to 1ms (use HighFrequencyLoopRequester)", + component ? LOG_STR_ARG(component->get_component_log_str()) : LOG_STR_LITERAL("?")); + delay = 1; + } + // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check LockGuard guard{this->lock_}; diff --git a/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml new file mode 100644 index 0000000000..13be55d617 --- /dev/null +++ b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml @@ -0,0 +1,27 @@ +esphome: + name: sched-interval-zero + +host: +api: +logger: + level: DEBUG + +globals: + - id: fire_count + type: int + initial_value: "0" + +interval: + # Deliberately configure 0ms — this path goes through the C++ + # Scheduler::set_timer_common_ coercion (not the Python cv.update_interval + # path, since interval: doesn't call cv.update_interval — it's an intervals + # component schema, not a PollingComponent's update_interval). + # Expected: scheduler coerces to 1ms at registration, emits ESP_LOGE, + # fires at ~1kHz instead of spinning. + - interval: 0ms + then: + - lambda: |- + id(fire_count) += 1; + if (id(fire_count) == 50) { + ESP_LOGI("test", "ZERO_INTERVAL_50_FIRES_REACHED"); + } diff --git a/tests/integration/test_scheduler_interval_zero_coerced.py b/tests/integration/test_scheduler_interval_zero_coerced.py new file mode 100644 index 0000000000..f71c0f7281 --- /dev/null +++ b/tests/integration/test_scheduler_interval_zero_coerced.py @@ -0,0 +1,67 @@ +"""Test that Scheduler::set_timer_common_ coerces interval=0 to 1ms. + +Regression test for the scheduler busy-loop when interval=0 was passed +literally. Without the coercion, Scheduler::call() would spin forever +because the item's next_execution == now_64 after re-scheduling, failing +the loop's `> now_64` break condition. The device would fail to yield +back to the main loop and trigger a WDT reset. + +With the coercion, interval=0 becomes interval=1 and the scheduler +fires at ~1kHz (bounded by the loop), the main loop continues to run, +and the device stays responsive to API calls. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_interval_zero_coerced( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """interval=0ms must be coerced to 1ms and not starve the main loop.""" + loop = asyncio.get_running_loop() + reached_50: asyncio.Future[None] = loop.create_future() + coerce_warning: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "ZERO_INTERVAL_50_FIRES_REACHED" in line and not reached_50.done(): + reached_50.set_result(None) + if "would spin main loop" in line and not coerce_warning.done(): + coerce_warning.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + # The API-client connection itself is evidence that the main loop + # is not starved — if set_interval(0) were spinning we could not + # get here at all. + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "sched-interval-zero" + + # Coerce warning must fire at registration + try: + await asyncio.wait_for(coerce_warning, timeout=5.0) + except TimeoutError: + pytest.fail("Expected coerce warning 'would spin main loop' not seen") + + # The coerced 1ms interval should fire 50 times quickly — this + # confirms the callback actually runs (not just registered) and the + # scheduler yields back to the main loop each time. + try: + await asyncio.wait_for(reached_50, timeout=5.0) + except TimeoutError: + pytest.fail( + "Coerced interval=0→1ms did not reach 50 fires within 5s, " + "which would indicate either the coercion failed or the " + "main loop is still being starved." + ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ac84ce7cc8..f038272d8b 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -24,6 +24,7 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, + SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda @@ -765,3 +766,30 @@ def test_percentage_validators__raw_number_above_one_without_percent_sign( config_validation.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): config_validation.unbounded_possibly_negative_percentage(value) + + +def test_update_interval__coerces_zero_to_one_ms( + caplog: pytest.LogCaptureFixture, +) -> None: + """update_interval: 0ms must be coerced to 1ms (not rejected) because a + literal 0ms schedule causes Scheduler::call() to spin. Coercion keeps + existing configs compiling on upgrade while emitting a user-facing + warning that directs them to set a non-zero value.""" + with caplog.at_level("WARNING"): + result = config_validation.update_interval("0ms") + assert result.total_milliseconds == 1 + assert "update_interval of 0ms is not supported" in caplog.text + assert "1ms" in caplog.text + + +def test_update_interval__preserves_nonzero_values() -> None: + """Non-zero update_interval values must pass through unchanged.""" + assert config_validation.update_interval("1ms").total_milliseconds == 1 + assert config_validation.update_interval("50ms").total_milliseconds == 50 + assert config_validation.update_interval("60s").total_milliseconds == 60000 + + +def test_update_interval__never_passes_through() -> None: + """update_interval: never must still map to SCHEDULER_DONT_RUN.""" + result = config_validation.update_interval("never") + assert result.total_milliseconds == SCHEDULER_DONT_RUN From ed5852c2d6a64b7618bdabb35c67f650afd59fce Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:05:30 -0400 Subject: [PATCH 0346/2214] [ethernet] Fix SPI3_HOST default breaking compile on variants without SPI3 (#15809) Co-authored-by: J. Nick Koston --- .../components/ethernet/ethernet_component.h | 2 +- .../ethernet/test.esp32-c3-idf.yaml | 19 +++++++++++++++++++ ...720.esp32-idf.yaml => test.esp32-idf.yaml} | 0 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/components/ethernet/test.esp32-c3-idf.yaml rename tests/components/ethernet/{test-lan8720.esp32-idf.yaml => test.esp32-idf.yaml} (100%) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 3a87842315..17c84ee954 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -221,7 +221,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; - spi_host_device_t interface_{SPI3_HOST}; + spi_host_device_t interface_{SPI2_HOST}; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT uint32_t polling_interval_{0}; #endif diff --git a/tests/components/ethernet/test.esp32-c3-idf.yaml b/tests/components/ethernet/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b7b95875c6 --- /dev/null +++ b/tests/components/ethernet/test.esp32-c3-idf.yaml @@ -0,0 +1,19 @@ +ethernet: + type: W5500 + clk_pin: 6 + mosi_pin: 7 + miso_pin: 2 + cs_pin: 10 + interrupt_pin: 3 + reset_pin: 4 + 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-lan8720.esp32-idf.yaml b/tests/components/ethernet/test.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-lan8720.esp32-idf.yaml rename to tests/components/ethernet/test.esp32-idf.yaml From 9841deec31cb1d70a19228fb6691ba5b6acfaa16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 09:31:31 -0500 Subject: [PATCH 0347/2214] [core] Fix DelayAction compile error with non-const reference args (#15814) --- esphome/core/base_automation.h | 4 +++- tests/components/http_request/http_request.yaml | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 11133d3973..17f937d10d 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -205,7 +205,9 @@ template class DelayAction : public Action, public Compon } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay - auto f = [this, x...]() { this->play_next_(x...); }; + // `mutable` is required so captured copies of non-const reference args (e.g. std::string&) + // are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&` + auto f = [this, x...]() mutable { this->play_next_(x...); }; App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(x...), std::move(f), diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 13ca5ceba0..ef67671c91 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -45,6 +45,11 @@ esphome: args: - response->status_code - body.c_str() + - delay: 1s + - logger.log: + format: "After delay, body still: %s" + args: + - body.c_str() http_request: useragent: esphome/tagreader From 1d88027618d06662b8b4df1e1d047922ff1330dc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:14:54 -0400 Subject: [PATCH 0348/2214] [esp32] Downgrade unneeded `ignore_pin_validation_error` to a warning (#15811) --- esphome/components/esp32/gpio.py | 12 +++- tests/component_tests/esp32/test_esp32.py | 79 ++++++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index a7180cbcd7..36dd44155a 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -172,10 +172,16 @@ def validate_gpio_pin(pin): exc, ) else: - # Throw an exception if used for a pin that would not have resulted - # in a validation error anyway! + # `ignore_pin_validation_error` only suppresses an error raised by the + # variant's pin_validation above (e.g. SPI flash/PSRAM pins, invalid pin + # numbers). If that didn't raise, the option is a no-op -- warn so the + # user can clean it up, but don't block the build. if ignore_pin_validation_warning: - raise cv.Invalid(f"GPIO{pin[CONF_NUMBER]} is not a reserved pin") + _LOGGER.warning( + "GPIO%d has no validation errors to ignore; " + "remove `ignore_pin_validation_error: true` from this pin.", + pin[CONF_NUMBER], + ) return pin diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bd4f9828ce..ac492e2752 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -8,10 +8,16 @@ from typing import Any import pytest -from esphome.components.esp32 import VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS +from esphome.components.esp32 import VARIANT_ESP32, VARIANTS +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv -from esphome.const import CONF_ESPHOME, PlatformFramework +from esphome.const import ( + CONF_ESPHOME, + CONF_IGNORE_PIN_VALIDATION_ERROR, + CONF_NUMBER, + PlatformFramework, +) from esphome.core import CORE from tests.component_tests.types import SetCoreConfigCallable @@ -149,6 +155,73 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_ignore_pin_validation_error_on_clean_pin_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that passes validation but sets `ignore_pin_validation_error: true` + should log a warning nudging the user to remove the flag, and not raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "GPIO4 has no validation errors to ignore" in caplog.text + + +def test_ignore_pin_validation_error_on_dirty_pin_suppresses( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that fails validation with `ignore_pin_validation_error: true` should + log the suppression warning and not raise (existing behavior).""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + # GPIO6 is a flash pin on ESP32 -> pin_validation raises cv.Invalid + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 6 + assert "Ignoring validation error on pin 6" in caplog.text + + +def test_dirty_pin_without_ignore_flag_raises( + set_core_config: SetCoreConfigCallable, +) -> None: + """A pin that fails validation without the ignore flag should still raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with pytest.raises(cv.Invalid, match="flash interface"): + validate_gpio_pin(pin) + + +def test_clean_pin_without_ignore_flag_does_not_warn( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A clean pin without the ignore flag should pass silently.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "has no validation errors to ignore" not in caplog.text + + def test_execute_from_psram_disabled_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From e2dfef5ddce725e7f3323e43749452c4ed93c324 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 18 Apr 2026 06:42:45 +1000 Subject: [PATCH 0349/2214] [runtime_image] Fix RGB order (#15813) --- esphome/components/runtime_image/runtime_image.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index fa42b53496..4c7f1bfb6f 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -127,9 +127,9 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { uint32_t pos = this->get_position_(x, y); Color mapped_color = color; this->map_chroma_key(mapped_color); - this->buffer_[pos + 0] = mapped_color.r; + this->buffer_[pos + 0] = mapped_color.b; this->buffer_[pos + 1] = mapped_color.g; - this->buffer_[pos + 2] = mapped_color.b; + this->buffer_[pos + 2] = mapped_color.r; if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { this->buffer_[pos + 3] = color.w; } From 6d5340f253009a1421329bcff3c6251ca0e6aa7d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:17:28 +1000 Subject: [PATCH 0350/2214] [lvgl] Fix crash with snow on rotated display (#15822) --- esphome/components/lvgl/lvgl_esphome.cpp | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index ce9b013dcf..d8248e4aa4 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -642,26 +642,28 @@ void LvglComponent::write_random_() { int iterations = 6 - lv_display_get_inactive_time(this->disp_) / 60000; if (iterations <= 0) iterations = 1; + int16_t width = lv_display_get_horizontal_resolution(this->disp_); + int16_t height = lv_display_get_vertical_resolution(this->disp_); while (iterations-- != 0) { - int32_t col = random_uint32() % this->width_; + int32_t col = random_uint32() % width; col = col / this->draw_rounding * this->draw_rounding; - int32_t row = random_uint32() % this->height_; + int32_t row = random_uint32() % height; row = row / this->draw_rounding * this->draw_rounding; // size will be between 8 and 32, and a multiple of draw_rounding int32_t size = (random_uint32() % 25 + 8) / this->draw_rounding * this->draw_rounding; - lv_area_t area{col, row, col + size - 1, row + size - 1}; + lv_area_t area{.x1 = col, .y1 = row, .x2 = col + size - 1, .y2 = row + size - 1}; // clip to display bounds just in case - if (area.x2 >= this->width_) - area.x2 = this->width_ - 1; - if (area.y2 >= this->height_) - area.y2 = this->height_ - 1; + if (area.x2 >= width) + area.x2 = width - 1; + if (area.y2 >= height) + area.y2 = height - 1; // line_len can't exceed 1024, and minimum buffer size is 2048, so this won't overflow the buffer size_t line_len = lv_area_get_width(&area) * lv_area_get_height(&area) / 2; for (size_t i = 0; i != line_len; i++) { - ((uint32_t *) (this->draw_buf_))[i] = random_uint32(); + reinterpret_cast(this->draw_buf_)[i] = random_uint32(); } - this->draw_buffer_(&area, (lv_color_data *) this->draw_buf_); + this->draw_buffer_(&area, reinterpret_cast(this->draw_buf_)); } } From 08ac61ae9424821ac868d817971313d1e8508394 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:58:54 -0500 Subject: [PATCH 0351/2214] [core] Feed WDT unconditionally in main loop to fix empty-config panic (#15830) --- esphome/core/application.h | 26 +++++++++++++++++--------- esphome/core/scheduler.cpp | 5 ++++- esphome/core/scheduler.h | 3 ++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 60087d527d..db8af735bd 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -641,7 +641,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline void ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); + inline uint32_t ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; } /// Process dump_config output one component per loop iteration. @@ -845,18 +845,15 @@ inline void Application::drain_wake_notifications_() { } #endif // USE_HOST -inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { +inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif - // Process scheduled tasks. Scheduler::call now feeds the watchdog itself - // after each scheduled item that actually runs, so we no longer need an - // unconditional feed here — when Scheduler::call has no work to do, the - // only elapsed time is a sleep wake + a few instructions, and when it does - // have work, it fed the wdt as it went. - this->scheduler.call(loop_start_time); + // Scheduler::call feeds the WDT per item and returns the timestamp of the + // last fired item, or the input unchanged when nothing ran. + uint32_t last_op_end_time = this->scheduler.call(loop_start_time); // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions @@ -874,13 +871,24 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ // Mark that we're in the loop for safe reentrant modifications this->in_loop_ = true; + return last_op_end_time; } inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Get the initial loop time at the start uint32_t last_op_end_time = millis(); - this->before_loop_tasks_(last_op_end_time); + // Returned timestamp keeps us monotonic with last_wdt_feed_ (advanced by + // the scheduler's per-item feeds) without an extra millis() call. + last_op_end_time = this->before_loop_tasks_(last_op_end_time); + // Guarantee a WDT touch every tick — covers configs with no looping + // components and no scheduler work, where the per-item / per-component + // feeds never fire. Rate-limited inline fast path, ~free when unneeded. + this->feed_wdt_with_time(last_op_end_time); +#ifdef USE_RUNTIME_STATS + uint32_t loop_before_end_us = micros(); + uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; +#endif for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; this->current_loop_index_++) { diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 7e6ad19ac7..b0eaa670ac 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -533,7 +533,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -void HOT Scheduler::call(uint32_t now) { +uint32_t HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -703,6 +703,9 @@ void HOT Scheduler::call(uint32_t now) { this->debug_verify_no_leak_(); } #endif + // execute_item_() advances `now` as items fire; return it so the caller + // stays monotonic with last_wdt_feed_. + return now; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 43a3ec7049..00a7f26953 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -129,7 +129,8 @@ class Scheduler { // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - void call(uint32_t now); + // @return Timestamp of the last item that ran, or `now` unchanged if none ran. + uint32_t call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). From 78701debec764ae9e0ba6339eb27b8ff96a8ba46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:32:36 +0000 Subject: [PATCH 0352/2214] Bump aioesphomeapi from 44.16.0 to 44.16.1 (#15836) 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 b14d5f5f5a..25638c0e7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.16.0 +aioesphomeapi==44.16.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 602305b20de2ab6da44baa4e113eb3016115365e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:04:51 -0500 Subject: [PATCH 0353/2214] [core] Default PollingComponent() to 1ms when codegen is bypassed (#15831) --- esphome/core/component.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 3307c5ae76..9e339dce64 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -593,7 +593,7 @@ class Component { */ class PollingComponent : public Component { public: - PollingComponent() : PollingComponent(0) {} + PollingComponent() : PollingComponent(1) {} /** Initialize this polling component with the given update interval in ms. * From ef780886c35c019fdbdb9c0e4a1fe343958897e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 16:00:31 -0500 Subject: [PATCH 0354/2214] [substitutions] Fix `substitutions: !include file.yaml` regression (#15850) --- esphome/components/packages/__init__.py | 8 ++- esphome/components/substitutions/__init__.py | 35 +++++++++++-- .../15-substitutions_as_include.approved.yaml | 5 ++ .../15-substitutions_as_include.input.yaml | 5 ++ .../substitutions/15-substitutions_inc.yaml | 1 + ...ons_as_include_with_packages.approved.yaml | 5 ++ ...utions_as_include_with_packages.input.yaml | 9 ++++ ...ubstitutions_include_cli_var.approved.yaml | 6 +++ ...7-substitutions_include_cli_var.input.yaml | 8 +++ tests/unit_tests/test_substitutions.py | 51 +++++++++++++++++++ 10 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 3f3df75351..252a24061a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -10,6 +10,7 @@ from esphome.components.substitutions import ( ContextVars, push_context, resolve_include, + resolve_substitutions_block, substitute, ) from esphome.components.substitutions.jinja import has_jinja @@ -516,7 +517,12 @@ def do_packages_pass( if CONF_PACKAGES not in config: return config - substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {})) + with cv.prepend_path(CONF_SUBSTITUTIONS): + substitutions = UserDict( + resolve_substitutions_block( + config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions + ) + ) processor = _PackageProcessor( substitutions, command_line_substitutions, skip_update ) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index c0bd9d7be9..7aace9bb64 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -414,6 +414,34 @@ def _warn_unresolved_variables(errors: ErrList) -> None: ) +def resolve_substitutions_block( + substitutions: Any, + command_line_substitutions: dict[str, Any] | None, +) -> dict[str, Any]: + """Resolve a deferred ``substitutions: !include file.yaml`` and validate the shape. + + The caller is responsible for wrapping the call in + ``cv.prepend_path(CONF_SUBSTITUTIONS)`` for error reporting. + ``command_line_substitutions`` seeds the filename context so + ``substitutions: !include ${var}.yaml`` can reference CLI-provided vars. + """ + if isinstance(substitutions, IncludeFile): + # Single-shot resolution — matches ``_walk_packages`` for the + # ``packages: !include`` entry point. Chained includes (an include that + # itself loads another ``!include`` at the top level) are not supported. + substitutions, _ = resolve_include( + substitutions, + [], + ContextVars(command_line_substitutions or {}), + strict_undefined=False, + ) + if not isinstance(substitutions, dict): + raise cv.Invalid( + f"Substitutions must be a key to value mapping, got {type(substitutions)}" + ) + return substitutions + + def do_substitution_pass( config: OrderedDict, command_line_substitutions: dict[str, Any] | None = None ) -> OrderedDict: @@ -429,10 +457,9 @@ def do_substitution_pass( # Use merge_dicts_ordered to preserve OrderedDict type for move_to_end() substitutions = config.pop(CONF_SUBSTITUTIONS, {}) with cv.prepend_path(CONF_SUBSTITUTIONS): - if not isinstance(substitutions, dict): - raise cv.Invalid( - f"Substitutions must be a key to value mapping, got {type(substitutions)}" - ) + substitutions = resolve_substitutions_block( + substitutions, command_line_substitutions + ) substitutions = merge_dicts_ordered( substitutions, command_line_substitutions or {} ) diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml new file mode 100644 index 0000000000..14aa707def --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml new file mode 100644 index 0000000000..5909e7bf4f --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml @@ -0,0 +1,5 @@ +substitutions: !include 15-substitutions_inc.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml new file mode 100644 index 0000000000..44d9a4b9ef --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml @@ -0,0 +1 @@ +wifi_password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml new file mode 100644 index 0000000000..14aa707def --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml new file mode 100644 index 0000000000..a2e72f33a2 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml @@ -0,0 +1,9 @@ +substitutions: !include 15-substitutions_inc.yaml + +packages: + wifi_pkg: + wifi: + password: $wifi_password + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml new file mode 100644 index 0000000000..f1fd5fb078 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml @@ -0,0 +1,6 @@ +substitutions: + subs_file: 15-substitutions_inc + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml new file mode 100644 index 0000000000..3248504b46 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml @@ -0,0 +1,8 @@ +command_line_substitutions: + subs_file: 15-substitutions_inc + +substitutions: !include ${subs_file}.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 01c669e542..71bbd9db86 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -675,6 +675,57 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_do_substitution_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` where the file holds a list raises cv.Invalid. + + Locks in the shape check that runs after the deferred IncludeFile has been + resolved. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict({CONF_SUBSTITUTIONS: include}) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + substitutions.do_substitution_pass(config) + + +def test_do_packages_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` alongside `packages:` raises cv.Invalid. + + Without the shape check, ``UserDict(...)`` would surface a low-level + ``TypeError``; the explicit ``cv.Invalid`` points at the substitutions path. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict( + { + CONF_SUBSTITUTIONS: include, + "packages": {"noop": {"wifi": {"ssid": "main"}}}, + } + ) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + do_packages_pass(config) + + def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> None: """An undefined substitution in a package include filename raises cv.Invalid. From 1862c6115f51e89587dd8a1dd9462ae7fac18464 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 19 Apr 2026 23:09:14 +0200 Subject: [PATCH 0355/2214] [packages] Improve error messages with include stack and fix missing path propagation (#15844) 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: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/packages/__init__.py | 9 ++ esphome/components/substitutions/__init__.py | 50 +++++++++++ .../component_tests/packages/test_packages.py | 88 ++++++++++++++++++- tests/unit_tests/test_substitutions.py | 34 +++++++ 4 files changed, 179 insertions(+), 2 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 252a24061a..97a5309480 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -8,7 +8,9 @@ from typing import Any from esphome import git, yaml_util from esphome.components.substitutions import ( ContextVars, + ErrList, push_context, + raise_first_undefined, resolve_include, resolve_substitutions_block, substitute, @@ -360,12 +362,19 @@ def _substitute_package_definition( if isinstance(package_config, str) or ( isinstance(package_config, dict) and is_remote_package(package_config) ): + # Collect undefined-variable errors (rather than raising strict) so the + # path walked through a remote-package dict is preserved and the user + # sees which field (url / path / ref / ...) referenced the undefined + # variable. + errors: ErrList = [] package_config = substitute( item=package_config, path=[], parent_context=context_vars or ContextVars(), strict_undefined=False, + errors=errors, ) + raise_first_undefined(errors, package_config, "package definition") return package_config diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 7aace9bb64..0144c13c01 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -30,6 +30,56 @@ ErrList = list[tuple[UndefinedError, SubstitutionPath, Any]] jinja = Jinja() +def raise_first_undefined( + errors: ErrList, + source: Any, + context_label: str, +) -> None: + """If *errors* is non-empty, raise ``cv.Invalid`` for the first undefined variable. + + The raised error names the missing variable, the path walked into *source* + (for nested dicts, e.g. ``url`` or ``ref``), and the YAML source location + when *source* carries one. Only the first error is surfaced; the user will + re-run after fixing it and any remaining undefined variables will be + reported then. + + ``context_label`` is the noun describing where the undefined variable + appeared (e.g. ``"package definition"``). + """ + if not errors: + return + err, err_path, err_value = errors[0] + if len(errors) > 1: + # Log any further undefined variables so debug-level output covers + # the full set, even though only the first is surfaced to the user. + extras = ", ".join( + f"{e.message} at '{'->'.join(str(p) for p in p_path)}'" + for e, p_path, _ in errors[1:] + ) + _LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras) + # Prefer the location of the offending scalar (e.g. the `url:` value) over + # the enclosing package-definition dict so the message points at the exact + # line/column that carries the undefined variable. + location_node = ( + err_value + if isinstance(err_value, ESPHomeDataBase) and err_value.esp_range is not None + else source + ) + location = "" + if ( + isinstance(location_node, ESPHomeDataBase) + and location_node.esp_range is not None + ): + mark = location_node.esp_range.start_mark + # DocumentLocation.line/column are 0-based (from the YAML Mark). Render + # as 1-based to match config.line_info() and editor line numbering. + location = f" (in {mark.document} {mark.line + 1}:{mark.column + 1})" + field = f" at '{'->'.join(str(p) for p in err_path)}'" if err_path else "" + raise cv.Invalid( + f"Undefined variable in {context_label}{field}: {err.message}{location}" + ) + + def validate_substitution_key(value: Any) -> str: """Validate and normalize a substitution key, stripping a leading ``$`` if present.""" value = cv.string(value) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index cd91c4d8cb..0bd339efa9 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -2,18 +2,20 @@ import logging from pathlib import Path +import re from unittest.mock import MagicMock, patch import pytest from esphome.components.packages import ( CONFIG_SCHEMA, + _substitute_package_definition, _walk_packages, do_packages_pass, is_package_definition, merge_packages, ) -from esphome.components.substitutions import do_substitution_pass +from esphome.components.substitutions import ContextVars, do_substitution_pass import esphome.config as config_module from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove @@ -44,7 +46,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.util import OrderedDict -from esphome.yaml_util import IncludeFile, add_context +from esphome.yaml_util import IncludeFile, add_context, load_yaml # Test strings TEST_DEVICE_NAME = "test_device_name" @@ -1399,3 +1401,85 @@ def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: "CORE.raw_config should contain esphome section after package merge" ) assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME + + +# --------------------------------------------------------------------------- +# _substitute_package_definition +# --------------------------------------------------------------------------- + + +def test_substitute_package_definition_local_dict_returned_unchanged() -> None: + """A plain local config dict is not substituted and is returned as-is.""" + pkg = {CONF_WIFI: {CONF_SSID: "test"}} + result = _substitute_package_definition(pkg, ContextVars()) + assert result is pkg + + +def test_substitute_package_definition_string_resolved_with_context() -> None: + """A string package definition has its variables substituted.""" + ctx = ContextVars({"variant": "esp32"}) + result = _substitute_package_definition("device-${variant}.yaml", ctx) + assert result == "device-esp32.yaml" + + +def test_substitute_package_definition_undefined_in_string() -> None: + """An undefined variable in a package URL string raises cv.Invalid.""" + with pytest.raises(cv.Invalid, match="Undefined variable in package definition"): + _substitute_package_definition( + "github://org/repo/${undefined_var}/pkg.yaml", ContextVars() + ) + + +def test_substitute_package_definition_undefined_in_remote_dict_field() -> None: + """An undefined variable inside a remote-dict field names the offending field.""" + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition( + {CONF_URL: "github://${typo}/repo"}, ContextVars() + ) + err = str(exc_info.value) + assert "'typo' is undefined" in err + assert CONF_URL in err + + +def test_substitute_package_definition_undefined_in_remote_dict_non_first_field() -> ( + None +): + """The field path joins correctly for non-first dict fields (e.g. ``ref``).""" + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition( + { + CONF_URL: "github://org/repo", + CONF_REF: "branch-${branch_typo}", + }, + ContextVars(), + ) + err = str(exc_info.value) + assert "'branch_typo' is undefined" in err + assert CONF_REF in err + + +def test_substitute_package_definition_includes_source_location(tmp_path: Path) -> None: + """A package loaded from YAML surfaces file/line/col in the cv.Invalid message. + + Line/column are rendered 1-based (matching config.line_info() and editor + line numbering) and point at the offending scalar, not the enclosing dict. + """ + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "packages:\n broken: github://org/repo/${undefined_var}/pkg.yaml\n" + ) + config = load_yaml(yaml_file) + package_config = config[CONF_PACKAGES]["broken"] + + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition(package_config, ContextVars()) + + err = str(exc_info.value) + assert "main.yaml" in err + # The offending value lives on line 2 (1-based). Column depends on the YAML + # loader, so we only pin line and check that a 1-based column is present. + match = re.search(r"main\.yaml (\d+):(\d+)", err) + assert match, err + line, col = int(match.group(1)), int(match.group(2)) + assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})" + assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})" diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 71bbd9db86..3599e703d9 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -14,6 +14,7 @@ from esphome.components.packages import ( do_packages_pass, merge_packages, ) +from esphome.components.substitutions.jinja import UndefinedError from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, merge_config import esphome.config_validation as cv @@ -675,6 +676,39 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_raise_first_undefined_logs_extras_at_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """Only the first undefined error is raised; extras are logged at debug.""" + errors: substitutions.ErrList = [ + (UndefinedError("'a' is undefined"), ["url"], None), + (UndefinedError("'b' is undefined"), ["ref"], None), + (UndefinedError("'c' is undefined"), ["path"], None), + ] + + with ( + caplog.at_level(logging.DEBUG, logger="esphome.components.substitutions"), + pytest.raises(cv.Invalid) as exc_info, + ): + substitutions.raise_first_undefined(errors, None, "package definition") + + # First error is surfaced as the cv.Invalid message. + raised = str(exc_info.value) + assert "'a' is undefined" in raised + assert "'b' is undefined" not in raised + assert "'c' is undefined" not in raised + + # Remaining errors are captured via debug logging for troubleshooting. + assert "Additional undefined variables in package definition" in caplog.text + assert "'b' is undefined at 'ref'" in caplog.text + assert "'c' is undefined at 'path'" in caplog.text + + +def test_raise_first_undefined_noop_on_empty() -> None: + """An empty errors list is a no-op — no exception, no log.""" + substitutions.raise_first_undefined([], None, "package definition") + + def test_do_substitution_pass_included_substitutions_must_be_mapping( tmp_path: Path, ) -> None: From 36812591ebf2bc00930618ae5eca8a78ab51bb1d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:20:56 +1200 Subject: [PATCH 0356/2214] Bump version to 2026.4.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 6f1f3d0e7e..deb57df1d3 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.4.0 +PROJECT_NUMBER = 2026.4.1 # 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 95fd488dca..1f5b3b6c57 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.0" +__version__ = "2026.4.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From bab9cd3e7a9b6ebfd2a0ac1ab0a9dd10371d6529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 14:07:26 -1000 Subject: [PATCH 0357/2214] [runtime_stats] Track main loop active time and report overhead (#15743) --- .../runtime_stats/runtime_stats.cpp | 99 +++++++++++++++---- .../components/runtime_stats/runtime_stats.h | 41 ++++++++ esphome/core/application.h | 23 +++++ esphome/core/component.cpp | 4 + esphome/core/component.h | 8 ++ tests/integration/test_runtime_stats.py | 33 +++++++ 6 files changed, 189 insertions(+), 19 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 06714b5a44..9ed141155a 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -32,40 +32,101 @@ void RuntimeStatsCollector::log_stats_() { " Period stats (last %" PRIu32 "ms): %zu active components", this->log_interval_, count); - if (count == 0) { - return; + // Sum component time so we can derive main-loop overhead + // (active loop time minus time attributable to component loop()s). + // Period sum iterates the active-in-period subset; total sum must iterate + // all components since total_active_time_us_ includes iterations where + // currently-idle components previously ran. + uint64_t period_component_sum_us = 0; + for (size_t i = 0; i < count; i++) { + period_component_sum_us += sorted[i]->runtime_stats_.period_time_us; + } + uint64_t total_component_sum_us = 0; + for (auto *component : components) { + total_component_sum_us += component->runtime_stats_.total_time_us; } - // Sort by period runtime (descending) - std::sort(sorted, sorted + count, compare_period_time); + if (count > 0) { + // Sort by period runtime (descending) + std::sort(sorted, sorted + count, compare_period_time); - // Log top components by period runtime - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, - stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, - stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + // Log top components by period runtime + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, + stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, + stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + } + } + + // Main-loop overhead for the period: active wall time minus component time. + // active = sum of per-iteration loop time excluding yield/sleep. + if (this->period_active_count_ > 0) { + uint64_t active = this->period_active_time_us_; + uint64_t overhead = active > period_component_sum_us ? active - period_component_sum_us : 0; + // Use double for µs→ms conversion so multi-day uptimes (where total + // microsecond counters exceed float's ~7-digit mantissa) keep resolution. + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->period_active_count_, + static_cast(active) / static_cast(this->period_active_count_) / 1000.0, + static_cast(this->period_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); + uint64_t before = this->period_before_time_us_; + uint64_t tail = this->period_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Log total stats since boot (only for active components - idle ones haven't changed) ESP_LOGI(TAG, " Total stats (since boot): %zu active components", count); - // Re-sort by total runtime for all-time stats - std::sort(sorted, sorted + count, compare_total_time); + if (count > 0) { + // Re-sort by total runtime for all-time stats + std::sort(sorted, sorted + count, compare_total_time); - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, - stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, - stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, + stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + } + } + + if (this->total_active_count_ > 0) { + uint64_t active = this->total_active_time_us_; + uint64_t overhead = active > total_component_sum_us ? active - total_component_sum_us : 0; + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->total_active_count_, + static_cast(active) / static_cast(this->total_active_count_) / 1000.0, + static_cast(this->total_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); + uint64_t before = this->total_before_time_us_; + uint64_t tail = this->total_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Reset period stats for (auto *component : components) { component->runtime_stats_.reset_period(); } + this->period_active_count_ = 0; + this->period_active_time_us_ = 0; + this->period_active_max_us_ = 0; + this->period_before_time_us_ = 0; + this->period_tail_time_us_ = 0; } bool RuntimeStatsCollector::compare_period_time(Component *a, Component *b) { diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 3c2c9f78ad..82e0fb7c61 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -29,6 +29,31 @@ class RuntimeStatsCollector { // Process any pending stats printing (should be called after component loop) void process_pending_stats(uint32_t current_time); + // Record the wall time of one main loop iteration excluding the yield/sleep. + // Called once per loop from Application::loop(). + // active_us = total time between loop start and just before yield. + // before_us = time spent in before_loop_tasks_ (scheduler + ISR enable_loop). + // tail_us = time spent in after_loop_tasks_ + the trailing record/stats prefix. + // Residual overhead at log time = active − Σ(component) − before − tail, + // which captures per-iteration inter-component bookkeeping (set_current_component, + // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // the for-loop itself). + void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { + this->period_active_count_++; + this->period_active_time_us_ += active_us; + if (active_us > this->period_active_max_us_) + this->period_active_max_us_ = active_us; + this->total_active_count_++; + this->total_active_time_us_ += active_us; + if (active_us > this->total_active_max_us_) + this->total_active_max_us_ = active_us; + + this->period_before_time_us_ += before_us; + this->total_before_time_us_ += before_us; + this->period_tail_time_us_ += tail_us; + this->total_tail_time_us_ += tail_us; + } + protected: void log_stats_(); // Static comparators — member functions have friend access, lambdas do not @@ -37,6 +62,22 @@ class RuntimeStatsCollector { uint32_t log_interval_; uint32_t next_log_time_{0}; + + // Main loop active-time stats (wall time per iteration, excluding yield/sleep). + // Counters are uint64_t — at sub-millisecond loop times a uint32_t can wrap in + // a few weeks of uptime, which is well within ESPHome device lifetimes. + uint64_t period_active_count_{0}; + uint64_t period_active_time_us_{0}; + uint32_t period_active_max_us_{0}; + uint64_t total_active_count_{0}; + uint64_t total_active_time_us_{0}; + uint32_t total_active_max_us_{0}; + + // Split of overhead sections — accumulated per iteration. + uint64_t period_before_time_us_{0}; + uint64_t total_before_time_us_{0}; + uint64_t period_tail_time_us_{0}; + uint64_t total_tail_time_us_{0}; }; } // namespace runtime_stats diff --git a/esphome/core/application.h b/esphome/core/application.h index db8af735bd..19245ab203 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -875,6 +875,17 @@ inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t l } inline void ESPHOME_ALWAYS_INLINE Application::loop() { +#ifdef USE_RUNTIME_STATS + // Capture the start of the active (non-sleeping) portion of this iteration. + // Used to derive main-loop overhead = active time − Σ(component time) − + // before/tail splits recorded below. + uint32_t loop_active_start_us = micros(); + // Snapshot the cumulative component-recorded time so we can subtract the + // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // (scheduler.cpp) — that time is already counted in per-component stats, + // so charging it again to "before" would double-count. + uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; +#endif // Get the initial loop time at the start uint32_t last_op_end_time = millis(); @@ -907,12 +918,24 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->feed_wdt_with_time(last_op_end_time); } +#ifdef USE_RUNTIME_STATS + uint32_t loop_tail_start_us = micros(); +#endif this->after_loop_tasks_(); #ifdef USE_RUNTIME_STATS // Process any pending runtime stats printing after all components have run // This ensures stats printing doesn't affect component timing measurements if (global_runtime_stats != nullptr) { + uint32_t loop_now_us = micros(); + // Subtract scheduled-component time from the "before" bucket so it is + // not double-counted (it is already attributed to per-component stats). + uint32_t loop_before_wall_us = loop_before_end_us - loop_active_start_us; + uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us + ? loop_before_wall_us - static_cast(loop_before_scheduled_us) + : 0; + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, + loop_now_us - loop_tail_start_us); global_runtime_stats->process_pending_stats(last_op_end_time); } #endif diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 8949b4b76d..e33652482e 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -506,6 +506,10 @@ void PollingComponent::stop_poller() { uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } +#ifdef USE_RUNTIME_STATS +uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + void __attribute__((noinline, cold)) WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { bool should_warn; diff --git a/esphome/core/component.h b/esphome/core/component.h index 9e339dce64..717ca36257 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -116,6 +116,13 @@ struct ComponentRuntimeStats { uint64_t total_time_us{0}; uint32_t total_max_time_us{0}; + // Cumulative sum of every record_time() duration since boot, across all + // components. Used by Application::loop() to snapshot time spent inside + // WarnIfComponentBlockingGuard (including guards constructed by the + // scheduler at scheduler.cpp) so main-loop overhead accounting can + // subtract scheduled-callback time from the before_loop_tasks_ wall time. + static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void record_time(uint32_t duration_us) { this->period_count++; this->period_time_us += duration_us; @@ -125,6 +132,7 @@ struct ComponentRuntimeStats { this->total_time_us += duration_us; if (duration_us > this->total_max_time_us) this->total_max_time_us = duration_us; + global_recorded_us += duration_us; } void reset_period() { this->period_count = 0; diff --git a/tests/integration/test_runtime_stats.py b/tests/integration/test_runtime_stats.py index 9e93035d83..bd7f36341d 100644 --- a/tests/integration/test_runtime_stats.py +++ b/tests/integration/test_runtime_stats.py @@ -26,6 +26,7 @@ async def test_runtime_stats( # Track component stats component_stats_found = set() + main_loop_lines: list[dict[str, str]] = [] # Patterns to match - need to handle ANSI color codes and timestamps # The log format is: [HH:MM:SS][color codes][I][tag]: message @@ -34,6 +35,14 @@ async def test_runtime_stats( component_pattern = re.compile( r"^\[[^\]]+\].*?\s+([\w.]+):\s+count=(\d+),\s+avg=([\d.]+)ms" ) + # Main loop overhead line emitted by runtime_stats + main_loop_pattern = re.compile( + r"main_loop:\s+iters=(?P\d+),\s+" + r"active_avg=(?P[\d.]+)ms,\s+" + r"active_max=(?P[\d.]+)ms,\s+" + r"active_total=(?P[\d.]+)ms,\s+" + r"overhead_total=(?P[\d.]+)ms" + ) def check_output(line: str) -> None: """Check log output for runtime stats messages.""" @@ -54,6 +63,11 @@ async def test_runtime_stats( component_name = match.group(1) component_stats_found.add(component_name) + # Check for main_loop overhead line + ml_match = main_loop_pattern.search(line) + if ml_match: + main_loop_lines.append(ml_match.groupdict()) + async with ( run_compiled(yaml_config, line_callback=check_output), api_client_connected() as client, @@ -86,3 +100,22 @@ async def test_runtime_stats( assert "template.switch" in component_stats_found, ( f"Expected template.switch stats, found: {component_stats_found}" ) + + # Verify the main_loop overhead line is emitted (at least once for + # the period section and once for the total section, per log cycle). + assert len(main_loop_lines) >= 2, ( + f"Expected at least 2 main_loop lines, got {len(main_loop_lines)}" + ) + for fields in main_loop_lines: + assert int(fields["iters"]) > 0, f"iters should be > 0: {fields}" + assert float(fields["active_total"]) > 0.0, ( + f"active_total should be > 0: {fields}" + ) + assert float(fields["active_avg"]) >= 0.0, ( + f"active_avg should be >= 0: {fields}" + ) + # overhead_total is derived and may be 0 if components dominate, + # but the field must still be present and parseable as a float. + assert float(fields["overhead_total"]) >= 0.0, ( + f"overhead_total should be >= 0: {fields}" + ) From e5f6a734ba46884cafeed83e6e735cba0c901a2d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:08:07 +1000 Subject: [PATCH 0358/2214] [lvgl] Fix angles for arc (#15860) --- esphome/components/lvgl/widgets/arc.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index 9eaf3dadce..ac993cc382 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -77,8 +77,11 @@ class ArcType(NumberType): # start_angle and end_angle are mapped to bg_start_angle and bg_end_angle prop = str(prop) if prop.endswith("_angle"): - prop = "bg_" + prop - await w.set_property(prop, config, processor=validator) + await w.set_property( + "bg_" + prop, await validator.process(config.get(prop)) + ) + else: + await w.set_property(prop, config, processor=validator) if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) From 0d3db2b6701fb7e87cd41f9f8c60445b06575618 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:08:07 +1000 Subject: [PATCH 0359/2214] [lvgl] Fix angles for arc (#15860) --- esphome/components/lvgl/widgets/arc.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index 9eaf3dadce..ac993cc382 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -77,8 +77,11 @@ class ArcType(NumberType): # start_angle and end_angle are mapped to bg_start_angle and bg_end_angle prop = str(prop) if prop.endswith("_angle"): - prop = "bg_" + prop - await w.set_property(prop, config, processor=validator) + await w.set_property( + "bg_" + prop, await validator.process(config.get(prop)) + ) + else: + await w.set_property(prop, config, processor=validator) if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) From 78aabf257fdb3e6d490fc79715d34471fc99836a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Apr 2026 11:52:56 +0200 Subject: [PATCH 0360/2214] [core] wake_loop_threadsafe() forces a component-phase iteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the Phase A / Phase B split in this PR, an external producer that called wake_loop_threadsafe() (MQTT RX, USB RX, BLE event, espnow, camera, mWW, speakers, USB host/CDC, lwip socket, enable_loop_soon_any_context) only got Phase A — the component phase stayed gated by loop_interval_, so the producer's component loop() could be delayed by up to loop_interval_ ms before draining its queued work. That breaks the long-standing semantic of wake_loop_threadsafe(). Add a wake_request flag set by every wake_loop_* entry point and exchange-cleared at the gate in Application::loop(). When the flag is set, force Phase B regardless of loop_interval_. Storage is conditional on the threading model: - ESPHOME_THREAD_MULTI_ATOMICS: std::atomic (uint8_t, not bool, because GCC on Xtensa generates an indirect call for atomic ops — same workaround as scheduler.h) - ESPHOME_THREAD_SINGLE / ESPHOME_THREAD_MULTI_NO_ATOMICS: volatile uint8_t (8-bit aligned loads/stores are atomic on every supported MCU; the platform signal that follows wake_request_set provides the cross-thread/cross-core memory barrier) Helpers (wake_request_set / wake_request_take) are always_inline so IRAM_ATTR call sites stay in IRAM. Set BEFORE the platform signal so the consumer is guaranteed to see the flag on its next gate check. Adds an integration test that raises loop_interval_ to 2s, snapshots a counting component's loop count, spawns a std::thread that calls App.wake_loop_threadsafe() after 50ms, and asserts the count increments inside a 500ms observation window. Without the fix the count would not move for ~2s. --- esphome/core/application.h | 24 ++++-- esphome/core/wake.cpp | 16 ++++ esphome/core/wake.h | 51 ++++++++++++- .../wake_test_component/__init__.py | 19 +++++ .../wake_test_component.cpp | 19 +++++ .../wake_test_component/wake_test_component.h | 27 +++++++ .../fixtures/wake_loop_forces_phase_b.yaml | 52 +++++++++++++ .../test_wake_loop_forces_phase_b.py | 76 +++++++++++++++++++ 8 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixtures/external_components/wake_test_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp create mode 100644 tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h create mode 100644 tests/integration/fixtures/wake_loop_forces_phase_b.yaml create mode 100644 tests/integration/test_wake_loop_forces_phase_b.py diff --git a/esphome/core/application.h b/esphome/core/application.h index 3752731e99..c87d4eb331 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -660,12 +660,20 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { uint32_t loop_tail_start_us = loop_before_end_us; #endif - // Gate the component phase on loop_interval_ or an active high-frequency - // request. When a scheduler wake preempts sleep early, this gate keeps the - // component phase from running more often than loop_interval_. + // Gate the component phase on loop_interval_, an active high-frequency + // request, or an explicit wake from a background producer. A scheduler-only + // wake (e.g. set_interval firing under a raised loop_interval_) leaves the + // component phase gated; an external producer that called wake_loop_* + // (MQTT RX, USB RX, BLE event, etc.) needs the component phase to actually + // run so its component's loop() can drain the queued work — that is the + // long-standing semantic of wake_loop_threadsafe(), and the wake_request + // flag preserves it. wake_request_take() exchange-clears the flag; wakes + // that arrive during Phase B re-set it and run Phase B again on the next + // iteration. const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency(); const uint32_t elapsed = now - this->last_loop_; - const bool do_component_phase = high_frequency || (elapsed >= this->loop_interval_); + const bool woke = esphome::wake_request_take(); + const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_); if (do_component_phase) { this->before_component_phase_(); @@ -716,8 +724,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #endif // Compute sleep: bounded by time-until-next-component-phase and the - // scheduler's next deadline. When a scheduler event wakes us early we - // re-enter loop(), Phase A services it, and the component phase stays gated. + // scheduler's next deadline. When a scheduler timer fires it re-enters + // loop(), Phase A services it, and the component phase stays gated by + // loop_interval_. When a background producer calls wake_loop_threadsafe() + // it sets the wake_request flag and wakes select() / the task notification; + // the gate above sees the flag and runs Phase B too so the producer's + // component can drain its queued work without waiting up to loop_interval_. // // Re-read HighFrequencyLoopRequester::is_high_frequency() here instead of // reusing the cached `high_frequency` captured above: a component calling diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index 3709fa88ac..cebc4d04b7 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -12,9 +12,22 @@ namespace esphome { +// === Wake-requested flag storage === +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic g_wake_requested{0}; +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +#endif + // === ESP32 / LibreTiny — IRAM_ATTR entry points === #if defined(USE_ESP32) || defined(USE_LIBRETINY) void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + // ISR-safe: set flag before notify so the wake is visible on the next gate + // check. wake_request_set() is just an aligned 8-bit store / atomic store + // and is safe from IRAM. + wake_request_set(); esphome_main_task_notify_from_isr(px_higher_priority_task_woken); } void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } @@ -72,6 +85,9 @@ void wakeable_delay(uint32_t ms) { // === Host (UDP loopback socket) === #ifdef USE_HOST void wake_loop_threadsafe() { + // Set flag before sending so the consumer's gate check on the next loop() + // entry observes the wake regardless of select() scheduling. + wake_request_set(); if (App.wake_socket_fd_ >= 0) { const char dummy = 1; ::send(App.wake_socket_fd_, &dummy, 1, 0); diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 77a38d429e..41b7ab33b5 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -7,6 +7,10 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#include +#endif + #if defined(USE_ESP32) || defined(USE_LIBRETINY) #include "esphome/core/main_task.h" #endif @@ -25,12 +29,48 @@ namespace esphome { extern volatile bool g_main_loop_woke; #endif +// === wake_request flag — signals Application::loop() that a producer queued +// work for some component's loop() to drain (MQTT RX, USB RX, BLE event, etc.) +// and the component phase should run this tick instead of being held off by +// the loop_interval_ gate. Set by every wake_loop_* entry point; consumed +// (via exchange-and-clear) at the gate in Application::loop(). === +// +// std::atomic rather than std::atomic because GCC on Xtensa +// generates an indirect function call for atomic ops instead of inlining +// them — same workaround applied in scheduler.h for the SchedulerItem::remove +// flag. On non-atomic platforms a volatile uint8_t suffices: 8-bit aligned +// loads/stores are atomic on every supported MCU, and the platform signal +// that follows wake_request_set() (FreeRTOS task-notify, esp_schedule, socket +// send) provides the cross-thread/cross-core memory barrier. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern std::atomic g_wake_requested; + +__attribute__((always_inline)) inline void wake_request_set() { g_wake_requested.store(1, std::memory_order_release); } +__attribute__((always_inline)) inline bool wake_request_take() { + return g_wake_requested.exchange(0, std::memory_order_acquire) != 0; +} +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern volatile uint8_t g_wake_requested; + +__attribute__((always_inline)) inline void wake_request_set() { g_wake_requested = 1; } +__attribute__((always_inline)) inline bool wake_request_take() { + uint8_t v = g_wake_requested; + g_wake_requested = 0; + return v != 0; +} +#endif + // === ESP32 / LibreTiny (FreeRTOS) === #if defined(USE_ESP32) || defined(USE_LIBRETINY) /// Wake the main loop from any context (ISR or task). /// always_inline so callers placed in IRAM keep the whole wake path in IRAM. __attribute__((always_inline)) inline void wake_main_task_any_context() { + // Set the wake-requested flag BEFORE the task notification so the consumer + // (Application::loop() gate) is guaranteed to see it on its next gate check. + wake_request_set(); if (in_isr_context()) { BaseType_t px_higher_priority_task_woken = pdFALSE; esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); @@ -50,7 +90,10 @@ __attribute__((always_inline)) inline void wake_main_task_any_context() { void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); void wake_loop_any_context(); -inline void wake_loop_threadsafe() { esphome_main_task_notify(); } +inline void wake_loop_threadsafe() { + wake_request_set(); + esphome_main_task_notify(); +} namespace internal { inline void wakeable_delay(uint32_t ms) { @@ -67,6 +110,9 @@ inline void wakeable_delay(uint32_t ms) { /// Inline implementation — IRAM callers inline this directly. 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(); g_main_loop_woke = true; esp_schedule(); } @@ -98,6 +144,9 @@ inline void wakeable_delay(uint32_t ms) { #elif defined(USE_RP2040) inline void wake_loop_any_context() { + // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed + // to see it on its next gate check. + wake_request_set(); g_main_loop_woke = true; __sev(); } diff --git a/tests/integration/fixtures/external_components/wake_test_component/__init__.py b/tests/integration/fixtures/external_components/wake_test_component/__init__.py new file mode 100644 index 0000000000..ce24167889 --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/__init__.py @@ -0,0 +1,19 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@esphome/tests"] + +wake_test_component_ns = cg.esphome_ns.namespace("wake_test_component") +WakeTestComponent = wake_test_component_ns.class_("WakeTestComponent", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(WakeTestComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp new file mode 100644 index 0000000000..b58f1c9adc --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp @@ -0,0 +1,19 @@ +#include "wake_test_component.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include +#include + +namespace esphome::wake_test_component { + +static const char *const TAG = "wake_test_component"; + +void WakeTestComponent::start_async_wake() { + ESP_LOGI(TAG, "Spawning async wake thread (50ms delay)"); + std::thread([] { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + App.wake_loop_threadsafe(); + }).detach(); +} + +} // namespace esphome::wake_test_component diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h new file mode 100644 index 0000000000..c8e4e0a89f --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/component.h" +#include + +namespace esphome::wake_test_component { + +class WakeTestComponent : public Component { + public: + void setup() override {} + void loop() override { this->loop_count_.fetch_add(1, std::memory_order_relaxed); } + + int get_loop_count() const { return this->loop_count_.load(std::memory_order_relaxed); } + + // Spawn a detached thread that sleeps briefly then calls + // App.wake_loop_threadsafe(). Used by the integration test to verify a + // cross-thread wake forces a component-phase iteration even when + // loop_interval_ has been raised high enough to gate it off otherwise. + void start_async_wake(); + + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + std::atomic loop_count_{0}; +}; + +} // namespace esphome::wake_test_component diff --git a/tests/integration/fixtures/wake_loop_forces_phase_b.yaml b/tests/integration/fixtures/wake_loop_forces_phase_b.yaml new file mode 100644 index 0000000000..d97ab8514f --- /dev/null +++ b/tests/integration/fixtures/wake_loop_forces_phase_b.yaml @@ -0,0 +1,52 @@ +esphome: + name: wake-loop-phase-b + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 2000ms. Without the wake-request flag, + // a wake_loop_threadsafe() call would only run Phase A (scheduler) + // and leave the component phase gated for ~2s. + App.set_loop_interval(2000); + # Let boot transients settle. + - delay: 1000ms + - lambda: |- + // Snapshot the loop counter, then ask the component to spawn a + // background thread that calls App.wake_loop_threadsafe() after + // ~50ms. With the fix, that wake forces Phase B on the next tick + // and the counter increments well within the 500ms observation + // window below. + id(count_at_start) = id(wake_counter)->get_loop_count(); + id(start_time) = millis(); + id(wake_counter)->start_async_wake(); + ESP_LOGI("test", "WAKE_STARTED count=%d", id(count_at_start)); + # Observation window must be much shorter than loop_interval_ (2000ms) + # so a "false pass" isn't possible by simply waiting out the gate. + - delay: 500ms + - lambda: |- + int count_now = id(wake_counter)->get_loop_count(); + int delta = count_now - id(count_at_start); + uint32_t elapsed = millis() - id(start_time); + ESP_LOGI("test", "WAKE_RESULT delta=%d elapsed=%u", delta, elapsed); + +host: +api: +logger: + level: INFO + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [wake_test_component] + +globals: + - id: count_at_start + type: int + initial_value: "0" + - id: start_time + type: uint32_t + initial_value: "0" + +wake_test_component: + id: wake_counter diff --git a/tests/integration/test_wake_loop_forces_phase_b.py b/tests/integration/test_wake_loop_forces_phase_b.py new file mode 100644 index 0000000000..5f05f07dd8 --- /dev/null +++ b/tests/integration/test_wake_loop_forces_phase_b.py @@ -0,0 +1,76 @@ +"""Test that wake_loop_threadsafe() forces a component-phase iteration. + +Regression test for the wake-request flag added to Application::loop()'s +Phase A / Phase B gate. Background producers (MQTT RX, USB RX, BLE event, +etc.) call App.wake_loop_threadsafe() expecting their component's loop() +to drain queued work; if the component phase stays gated by loop_interval_, +the work waits up to loop_interval_ ms instead of running on the next tick. + +Setup: +- App.set_loop_interval(2000) — a wide gate that would clearly mask the bug. +- A test component spawns a detached std::thread that sleeps 50 ms and then + calls App.wake_loop_threadsafe() from a non-main thread. +- The on_boot block snapshots the component's loop counter before/after a + 500 ms observation window. + +Without the fix, delta=0 (the gate holds Phase B for ~2 s). +With the fix, delta>=1 (the wake forces Phase B within one tick of the wake). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wake_loop_forces_phase_b( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A wake_loop_threadsafe() call from a background thread must trigger the + component phase within the next tick, even when loop_interval_ is raised + well above the observation window.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + result: asyncio.Future[tuple[int, int]] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"WAKE_RESULT delta=(\d+) elapsed=(\d+)", line) + if match and not result.done(): + result.set_result((int(match.group(1)), int(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "wake-loop-phase-b" + + try: + delta, elapsed = await asyncio.wait_for(result, timeout=15.0) + except TimeoutError: + pytest.fail("WAKE_RESULT marker never appeared") + + # Without the fix, delta would be 0 — loop_interval_=2000ms held + # Phase B off for the full 500ms observation window. With the fix + # the wake from the background thread (~50ms after start) forces + # Phase B on the next tick, so the counter increments at least once. + assert delta >= 1, ( + f"wake_loop_threadsafe() from a background thread should force " + f"Phase B within the next tick; observed delta={delta} after " + f"{elapsed}ms with loop_interval_=2000ms" + ) From f0c21520aa5644370d1ed56e05702c35a95be721 Mon Sep 17 00:00:00 2001 From: guillempages Date: Mon, 20 Apr 2026 13:56:56 +0200 Subject: [PATCH 0361/2214] [mipi_rgb] Add definitions for sunton displays (#15858) --- esphome/components/mipi_rgb/models/sunton.py | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 esphome/components/mipi_rgb/models/sunton.py diff --git a/esphome/components/mipi_rgb/models/sunton.py b/esphome/components/mipi_rgb/models/sunton.py new file mode 100644 index 0000000000..a33625dfe4 --- /dev/null +++ b/esphome/components/mipi_rgb/models/sunton.py @@ -0,0 +1,51 @@ +from esphome.components.mipi import DriverChip +from esphome.config_validation import UNDEFINED + +# fmt: off +sunton = DriverChip( + "ESP32-8048S070", + swap_xy=UNDEFINED, + initsequence=(), + width=800, + height=480, + pclk_frequency="12.5MHz", + de_pin=41, + hsync_pin=39, + vsync_pin=40, + pclk_pin=42, + hsync_pulse_width=30, + hsync_back_porch=16, + hsync_front_porch=210, + vsync_pulse_width=13, + vsync_back_porch=10, + vsync_front_porch=22, + data_pins={ + "red": [14, 21, 47, 48, 45], + "green": [9, 46, 3, 8, 16, 1], + "blue": [15, 7, 6, 5, 4], + }, +) + +sunton.extend( + "ESP32-8048S050", + swap_xy=UNDEFINED, + initsequence=(), + width=800, + height=480, + pclk_frequency="16MHz", + de_pin=40, + hsync_pin=39, + vsync_pin=41, + pclk_pin=42, + hsync_back_porch=8, + hsync_front_porch=8, + hsync_pulse_width=4, + vsync_back_porch=8, + vsync_front_porch=8, + vsync_pulse_width=4, + data_pins={ + "red": [45, 48, 47, 21, 14], + "green": [5, 6, 7, 15, 16, 4], + "blue": [8, 3, 46, 9, 1], + }, +) From 7321e6e52fc9a19e4c02de406088f04e7ec077ca Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Mon, 20 Apr 2026 15:10:05 +0200 Subject: [PATCH 0362/2214] [rtttl] allow any control parameters order and default value fallback (#14438) Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/rtttl/rtttl.cpp | 98 +++++++++++++++--------------- tests/components/rtttl/common.yaml | 16 +++++ 2 files changed, 66 insertions(+), 48 deletions(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 01f5aad810..08d902b4be 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -294,57 +294,59 @@ void Rtttl::play(std::string rtttl) { } ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); - // Get default duration - this->position_ = this->rtttl_.find("d=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing 'd='"); - return; - } - this->position_ += 2; - num = this->get_integer_(); - if (num == 1 || num == 2 || num == 4 || num == 8 || num == 16 || num == 32) { - this->default_note_denominator_ = num; - } else { - ESP_LOGE(TAG, "Invalid default duration: %d", num); - return; - } - - // Get default octave - this->position_ = this->rtttl_.find("o=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing 'o="); - return; - } - this->position_ += 2; - num = this->get_integer_(); - if (num >= MIN_OCTAVE && num <= MAX_OCTAVE) { - this->default_octave_ = num; - } else { - ESP_LOGE(TAG, "Invalid default octave: %d", num); - return; - } - - // Get BPM - this->position_ = this->rtttl_.find("b=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing b="); - return; - } - this->position_ += 2; - num = this->get_integer_(); - if (num >= 4) { // Below 4 is not realistic and would cause a integer overflow - bpm = num; - } else { - ESP_LOGE(TAG, "Invalid BPM: %d", num); - return; - } - - this->position_ = this->rtttl_.find(':', this->position_); - if (this->position_ == std::string::npos) { + size_t name_end_position = this->position_; + size_t control_end = this->rtttl_.find(':', name_end_position + 1); + if (control_end == std::string::npos) { ESP_LOGE(TAG, "Missing second ':'"); return; } - this->position_++; + + // Get default duration + size_t pos = this->rtttl_.find("d=", name_end_position); + if (pos == std::string::npos || pos >= control_end) { + ESP_LOGW(TAG, "Missing 'd='; use default duration %d", this->default_note_denominator_); + } else { + this->position_ = pos + 2; + num = this->get_integer_(); + if (num == 1 || num == 2 || num == 4 || num == 8 || num == 16 || num == 32) { + this->default_note_denominator_ = num; + } else { + ESP_LOGE(TAG, "Invalid default duration: %d", num); + return; + } + } + + // Get default octave + pos = this->rtttl_.find("o=", name_end_position); + if (pos == std::string::npos || pos >= control_end) { + ESP_LOGW(TAG, "Missing 'o='; use default octave %d", this->default_octave_); + } else { + this->position_ = pos + 2; + num = this->get_integer_(); + if (num >= MIN_OCTAVE && num <= MAX_OCTAVE) { + this->default_octave_ = num; + } else { + ESP_LOGE(TAG, "Invalid default octave: %d", num); + return; + } + } + + // Get BPM + pos = this->rtttl_.find("b=", name_end_position); + if (pos == std::string::npos || pos >= control_end) { + ESP_LOGW(TAG, "Missing 'b='; use default BPM %d", bpm); + } else { + this->position_ = pos + 2; + num = this->get_integer_(); + if (num >= 4) { // Below 4 is not realistic and would cause a integer overflow + bpm = num; + } else { + ESP_LOGE(TAG, "Invalid BPM: %d", num); + return; + } + } + + this->position_ = control_end + 1; // BPM usually expresses the number of quarter notes per minute this->wholenote_duration_ = 60 * 1000L * 4 / bpm; // This is the time for whole note (in milliseconds) diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index 86b52ca3de..529713583b 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -3,6 +3,22 @@ esphome: then: - rtttl.play: 'siren:d=8,o=5,b=100:d,e,d,e,d,e,d,e' - rtttl.stop + # Test all note features: all notes, denominators (1,2,4,8,16,32), sharp (#), octaves (4-7), dotted (.), note gap (c5,c5), pause (p) + - rtttl.play: 'special:d=4,o=5,b=120:1c4,2d#5,4e6.,8f#7,16g4,32a5,8a#5,4b6,8h5,c5,c5,8p,2c4' + # Different orders of control parameters + - rtttl.play: 'test_odb:o=5,d=8,b=100:c' + - rtttl.play: 'test_bod:b=100,o=5,d=8:c' + - rtttl.play: 'test_bdo:b=100,d=8,o=5:c' + - rtttl.play: 'test_obd:o=5,b=100,d=8:c' + - rtttl.play: 'test_dbo:d=8,b=100,o=5:c' + # Missing parameters (use defaults) + - rtttl.play: 'test_no_d:o=5,b=100:c' + - rtttl.play: 'test_no_o:d=8,b=100:c' + - rtttl.play: 'test_no_b:d=8,o=5:c' + - rtttl.play: 'test_only_d:d=8:c' + - rtttl.play: 'test_only_o:o=5:c' + - rtttl.play: 'test_only_b:b=100:c' + - rtttl.play: 'test_empty::c' output: - platform: ${output_platform} From 0dae41aa2209031a78c77387d85e27842ff3b9b9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:13:42 +1000 Subject: [PATCH 0363/2214] [lvgl] Fix format of hello world page (#15868) --- esphome/components/lvgl/hello_world.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/hello_world.yaml b/esphome/components/lvgl/hello_world.yaml index bbbd34e30a..7bf068cc5d 100644 --- a/esphome/components/lvgl/hello_world.yaml +++ b/esphome/components/lvgl/hello_world.yaml @@ -89,10 +89,12 @@ id: hello_world_label_ text: "Hello World!" align: center - - obj: + - container: id: hello_world_qrcode_ outline_width: 0 border_width: 0 + height: 100 + width: 100 hidden: !lambda |- return lv_obj_get_width(lv_screen_active()) < 300 && lv_obj_get_height(lv_screen_active()) < 400; widgets: From 9459f0426dcec99e412287faab1b612c433e01c4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:14:15 +1000 Subject: [PATCH 0364/2214] [lvgl] Fix overloads for setting images on styles (#15864) --- esphome/components/lvgl/lvgl_esphome.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3ec1d247d8..146866f5bd 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -88,6 +88,12 @@ inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { ::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } +inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) { + ::lv_style_set_bg_image_src(style, image->get_lv_image_dsc()); +} +inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { + ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); +} #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ANIMIMG inline void lv_animimg_set_src(lv_obj_t *img, std::vector images) { From 73b8e8ac098955c8cdd3bb1f72e4ce7bf7372f4a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:15:51 +1000 Subject: [PATCH 0365/2214] [lvgl] Fix update of textarea attached to keyboard (#15866) --- esphome/components/lvgl/widgets/keyboard.py | 26 ++++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index 029ca5f684..c5628cee3c 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -52,19 +52,23 @@ class KeyboardType(WidgetType): if mode := config.get(CONF_MODE): await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode)) if textarea := config.get(CONF_TEXTAREA): - # If a textarea is configured, it must be generated before the keyboard can attach it. - # If not yet configured, defer the attachment code. + if not is_widget_completed(textarea): + # Can only happen for an initial config, where the keyboard is configured before the + # textarea, so it's ok to always emit into the global context + async def add_textarea(): + async with LvContext(): + await w.set_property( + CONF_TEXTAREA, + (await get_widgets(config, CONF_TEXTAREA))[0].obj, + ) - async def add_textarea(): - async with LvContext(): - await w.set_property( - CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj - ) - - if is_widget_completed(textarea): - await add_textarea() - else: CORE.add_job(add_textarea) + else: + # Handles updates in automations, and properly ordered initial config. Code is generated + # into the enclosing context (main or lambda) + await w.set_property( + CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj + ) keyboard_spec = KeyboardType() From b72f5447c3fc4a0130ef56a74677b8fea0a25ab8 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 20 Apr 2026 14:24:07 +0100 Subject: [PATCH 0366/2214] [modbus] Simplify payload size validation in modbus_helpers (#15838) --- esphome/components/modbus/modbus_helpers.cpp | 114 +++++++++--------- .../components/modbus/modbus_helpers_test.cpp | 22 ++++ 2 files changed, 79 insertions(+), 57 deletions(-) create mode 100644 tests/components/modbus/modbus_helpers_test.cpp diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 77190b2846..89dc3c08bc 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -5,6 +5,29 @@ namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; +static size_t required_payload_size(SensorValueType sensor_value_type) { + switch (sensor_value_type) { + case SensorValueType::U_WORD: + case SensorValueType::S_WORD: + return 2; + case SensorValueType::U_DWORD: + case SensorValueType::FP32: + case SensorValueType::U_DWORD_R: + case SensorValueType::FP32_R: + case SensorValueType::S_DWORD: + case SensorValueType::S_DWORD_R: + return 4; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + return 8; + case SensorValueType::RAW: + default: + return 0; + } +} + void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { switch (value_type) { case SensorValueType::U_WORD: @@ -47,93 +70,70 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens uint32_t bitmask) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits - if (offset > data.size()) { - ESP_LOGE(TAG, "not enough data for value"); + // Validate offset against the buffer for all types, including RAW/unsupported, so + // a malformed or misconfigured frame still produces an error log. + if (static_cast(offset) > data.size()) { + ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), + static_cast(offset), data.size()); + return value; + } + + const size_t required_size = required_payload_size(sensor_value_type); + if (required_size == 0) { + return value; + } + + if (data.size() - offset < required_size) { + ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", + static_cast(sensor_value_type), static_cast(offset), data.size(), + required_size); return value; } - size_t size = data.size() - offset; - bool error = false; switch (sensor_value_type) { case SensorValueType::U_WORD: - if (size >= 2) { - value = mask_and_shift_by_rightbit(get_data(data, offset), - bitmask); // default is 0xFFFF ; - } else { - error = true; - } + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; break; case SensorValueType::U_DWORD: case SensorValueType::FP32: - if (size >= 4) { - value = get_data(data, offset); - value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); - } else { - error = true; - } + value = get_data(data, offset); + value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); break; case SensorValueType::U_DWORD_R: case SensorValueType::FP32_R: - if (size >= 4) { - value = get_data(data, offset); - value = static_cast(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16; - value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); - } else { - error = true; - } + value = get_data(data, offset); + value = static_cast(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16; + value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); break; case SensorValueType::S_WORD: - if (size >= 2) { - value = mask_and_shift_by_rightbit(get_data(data, offset), - bitmask); // default is 0xFFFF ; - } else { - error = true; - } + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; break; case SensorValueType::S_DWORD: - if (size >= 4) { - value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); - } else { - error = true; - } + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); break; case SensorValueType::S_DWORD_R: { - if (size >= 4) { - value = get_data(data, offset); - // Currently the high word is at the low position - // the sign bit is therefore at low before the switch - uint32_t sign_bit = (value & 0x8000) << 16; - value = mask_and_shift_by_rightbit( - static_cast(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask); - } else { - error = true; - } + value = get_data(data, offset); + // Currently the high word is at the low position + // the sign bit is therefore at low before the switch + uint32_t sign_bit = (value & 0x8000) << 16; + value = mask_and_shift_by_rightbit( + static_cast(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask); } break; case SensorValueType::U_QWORD: case SensorValueType::S_QWORD: // Ignore bitmask for QWORD - if (size >= 8) { - value = get_data(data, offset); - } else { - error = true; - } + value = get_data(data, offset); break; case SensorValueType::U_QWORD_R: case SensorValueType::S_QWORD_R: { // Ignore bitmask for QWORD - if (size >= 8) { - uint64_t tmp = get_data(data, offset); - value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000); - } else { - error = true; - } + uint64_t tmp = get_data(data, offset); + value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000); } break; case SensorValueType::RAW: default: break; } - if (error) - ESP_LOGE(TAG, "not enough data for value"); return value; } } // namespace esphome::modbus::helpers diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp new file mode 100644 index 0000000000..e1b4fb2aa6 --- /dev/null +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -0,0 +1,22 @@ +#include + +#include "esphome/components/modbus/modbus_helpers.h" + +namespace esphome::modbus::helpers { + +TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { + const std::vector data{0x12, 0x34}; + EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); +} + +TEST(ModbusHelpersTest, PayloadToNumberRejectsTruncatedMultiRegisterValue) { + const std::vector data{0x12, 0x34, 0x56}; + EXPECT_EQ(payload_to_number(data, SensorValueType::U_DWORD, 0, 0xFFFFFFFF), 0); +} + +TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { + const std::vector data{0x12, 0x34}; + EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); +} + +} // namespace esphome::modbus::helpers From 82656cb0cf1c051d428438e5f562f8de3541d1ca Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:28:52 +1000 Subject: [PATCH 0367/2214] [mipi_dsi] Add Seeed reTerminal d1001 display (#15867) --- esphome/components/mipi_dsi/models/seeed.py | 29 +++++++++++++++++++ .../mipi_dsi/test_mipi_dsi_config.py | 5 ++++ 2 files changed, 34 insertions(+) create mode 100644 esphome/components/mipi_dsi/models/seeed.py diff --git a/esphome/components/mipi_dsi/models/seeed.py b/esphome/components/mipi_dsi/models/seeed.py new file mode 100644 index 0000000000..290b0e07ee --- /dev/null +++ b/esphome/components/mipi_dsi/models/seeed.py @@ -0,0 +1,29 @@ +from esphome.components.mipi import DriverChip +import esphome.config_validation as cv + +# Standalone display +# Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html +DriverChip( + "SEEED-RETERMINAL-D1001", + height=1280, + width=800, + hsync_back_porch=20, + hsync_pulse_width=20, + hsync_front_porch=40, + vsync_back_porch=12, + vsync_pulse_width=4, + vsync_front_porch=30, + pclk_frequency="80MHz", + lane_bit_rate="1.5Gbps", + swap_xy=cv.UNDEFINED, + color_order="RGB", + enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}], + reset_pin={"xl9535": None, "number": 2}, + initsequence=( + (0xE0, 0x00), + (0xE1, 0x93), + (0xE2, 0x65), + (0xE3, 0xF8), + (0x80, 0x01), + ), +) diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 119bbf7fea..955e945526 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -7,6 +7,11 @@ import pytest from esphome import config_validation as cv from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4 + +# Importing xl9535 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that +# models (e.g. SEEED-RETERMINAL-D1001) that reference xl9535-backed pins in their +# defaults can be validated by the mipi_dsi CONFIG_SCHEMA in this test. +import esphome.components.xl9535 # noqa: F401 from esphome.const import ( CONF_DIMENSIONS, CONF_HEIGHT, From 6af341bb5be6a3850bf16a9c01f49e24b60f5413 Mon Sep 17 00:00:00 2001 From: Elvin Luff Date: Mon, 20 Apr 2026 14:34:31 +0100 Subject: [PATCH 0368/2214] [epaper_spi] Support SSD1683 and GDEY042T81 4.2 inch display (#13910) --- .../epaper_spi/epaper_spi_ssd1683.cpp | 97 +++++++++++++++++++ .../epaper_spi/epaper_spi_ssd1683.h | 22 +++++ .../components/epaper_spi/models/ssd1683.py | 27 ++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 16 +++ 4 files changed, 162 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_spi_ssd1683.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_ssd1683.h create mode 100644 esphome/components/epaper_spi/models/ssd1683.py diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp new file mode 100644 index 0000000000..6fb7e1ac1a --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp @@ -0,0 +1,97 @@ +#include "epaper_spi_ssd1683.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +static constexpr const char *const TAG = "epaper_spi.mono"; + +void EPaperSSD1683::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); + this->cmd_data(0x3C, {partial ? (uint8_t) 0x80 : (uint8_t) 0x01}); + // On partial update, set red RAM to inverse to remove BW ghosting + this->cmd_data(0x21, {partial ? (uint8_t) 0x80 : (uint8_t) 0x40, (uint8_t) 0x00}); + // Set full update to 0xD7 for fast update, 0xF7 for normal + // Fast update flashes less and draws sooner but is in busy state for the same amount of time + // Manufacturer recommends not using fast update all the time, TODO expose this to the user + this->cmd_data(0x22, {partial ? (uint8_t) 0xFC : (uint8_t) 0xF7}); + this->command(0x20); +} + +// Puts the display into deep sleep mode 1, only way to get out is to reset the display +// Mode 1 retains RAM while sleeping, necessary for future partial and window updates +void EPaperSSD1683::deep_sleep() { + if (this->is_using_partial_update_()) { + ESP_LOGV(TAG, "Deep sleep mode 1"); + this->cmd_data(0x10, {0x01}); // deep sleep, retain RAM + } else { + ESP_LOGV(TAG, "Deep sleep mode 2"); + this->cmd_data(0x10, {0x03}); // deep sleep, lose RAM + } +} + +void EPaperSSD1683::set_window() { + // if not using partial update, the display will go into deep sleep mode 2, so must rewrite entire + // buffer since the display RAM will not retain contents + if (!this->is_using_partial_update_()) { + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + } + + // round x-coordinates to byte boundaries + this->x_low_ /= 8; + this->x_high_ += 7; + this->x_high_ /= 8; + + this->cmd_data(0x44, {(uint8_t) this->x_low_, (uint8_t) (this->x_high_ - 1)}); + this->cmd_data(0x45, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256), (uint8_t) (this->y_high_ - 1), + (uint8_t) ((this->y_high_ - 1) / 256)}); + this->cmd_data(0x4E, {(uint8_t) this->x_low_}); + this->cmd_data(0x4F, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256)}); +} + +bool HOT EPaperSSD1683::transfer_data() { + auto start_time = millis(); + if (this->current_data_index_ == 0) { + if (this->send_red_) { + // round to byte boundaries + this->set_window(); + } + // for monochrome, we need to send red on every refresh to prevent dirty pixels + // when doing a partial refresh + this->command(this->send_red_ ? 0x26 : 0x24); + this->current_data_index_ = this->y_low_; // actually current line + } + size_t row_length = this->x_high_ - this->x_low_; + FixedVector bytes_to_send{}; + bytes_to_send.init(row_length); + ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis()); + this->start_data_(); + while (this->current_data_index_ != this->y_high_) { + size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_; + for (size_t i = 0; i != row_length; i++) { + bytes_to_send[i] = this->buffer_[data_idx++]; + } + ++this->current_data_index_; + this->write_array(&bytes_to_send.front(), row_length); // NOLINT + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->disable(); + return false; + } + } + + this->disable(); + this->current_data_index_ = 0; + if (this->send_red_) { + this->send_red_ = false; + return false; + } + this->send_red_ = true; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1683.h b/esphome/components/epaper_spi/epaper_spi_ssd1683.h new file mode 100644 index 0000000000..4532900dd1 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1683.h @@ -0,0 +1,22 @@ +#pragma once + +#include "epaper_spi_mono.h" + +namespace esphome::epaper_spi { +/** + * A class for Solomon SSD1683 epaper displays. + */ +class EPaperSSD1683 : public EPaperMono { + public: + EPaperSSD1683(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperMono(name, width, height, init_sequence, init_sequence_length) {} + + protected: + void refresh_screen(bool partial) override; + void deep_sleep() override; + void set_window() override; + bool transfer_data() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/ssd1683.py b/esphome/components/epaper_spi/models/ssd1683.py new file mode 100644 index 0000000000..983f5bb382 --- /dev/null +++ b/esphome/components/epaper_spi/models/ssd1683.py @@ -0,0 +1,27 @@ +from esphome.const import CONF_DATA_RATE + +from . import EpaperModel + + +class SSD1683(EpaperModel): + def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults): + defaults[CONF_DATA_RATE] = data_rate + super().__init__(name, class_name, **defaults) + + # fmt: off + def get_init_sequence(self, config: dict): + _width, height = self.get_dimensions(config) + return ( + (0x01, (height - 1) % 256, (height - 1) // 256, 0x00), # Set column gate limit + (0x18, 0x80), # Select internal Temp sensor + (0x11, 0x03), # Set transform + ) + + +ssd1683 = SSD1683("ssd1683") + +goodisplay_gdey042t81 = ssd1683.extend( + "goodisplay-gdey042t81-4.2", + width=400, + height=300, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index bf6053c78b..8a420f299a 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -145,3 +145,19 @@ display: it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0)); + + - platform: epaper_spi + spi_id: spi_bus + model: goodisplay-gdey042t81-4.2 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 From 94f30d5950e3d5aa3e49d31879ec394c30e63d45 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 20 Apr 2026 16:26:47 -0400 Subject: [PATCH 0369/2214] [micro_wake_word] Use ESPMicroSpeechFeatures from Espressif registry (v1.2.3) (#15879) --- .clang-tidy.hash | 2 +- esphome/components/micro_wake_word/__init__.py | 4 ++-- esphome/idf_component.yml | 2 ++ platformio.ini | 2 -- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 72a9967590..02aa990809 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -075ed2142432dc59883bb52db8ac11270f952851d6400deae080f5468c7cb592 +c65f1a0804a7765462d570c50891ac719260592df2c9cdfe88233fc346ac59e9 diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 5ab1e4bb80..22d2098de0 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -454,12 +454,12 @@ async def to_code(config): # Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn) esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2") + esp32.add_idf_component(name="esphome/esp-micro-speech-features", ref="1.2.3") + cg.add_build_flag("-DTF_LITE_STATIC_MEMORY") cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON") cg.add_build_flag("-DESP_NN") - cg.add_library("kahrendt/ESPMicroSpeechFeatures", "1.1.0") - if vad_model := config.get(CONF_VAD): cg.add_define("USE_MICRO_WAKE_WORD_VAD") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f4e3e751ec..3637481c92 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -3,6 +3,8 @@ dependencies: version: "7.4.2" esphome/esp-audio-libs: version: 2.0.4 + esphome/esp-micro-speech-features: + version: 1.2.3 esphome/micro-decoder: version: 0.1.1 esphome/micro-flac: diff --git a/platformio.ini b/platformio.ini index e2c7e2b097..d7b14944e4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -155,7 +155,6 @@ lib_deps = makuna/NeoPixelBus@2.8.0 ; neopixelbus esphome/ESP32-audioI2S@2.3.0 ; i2s_audio droscy/esp_wireguard@0.4.5 ; wireguard - kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word build_flags = ${common:arduino.build_flags} @@ -177,7 +176,6 @@ framework = espidf lib_deps = ${common:idf.lib_deps} droscy/esp_wireguard@0.4.5 ; wireguard - kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = ${common:idf.build_flags} From 213ab312d2d4f703056393eadc689fcfe0e42165 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:27:34 -0400 Subject: [PATCH 0370/2214] [image] Fix rodata bloat for multi-frame RGB565+alpha animations (#15873) --- esphome/components/image/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 7db50597e6..8375ab91d3 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -756,7 +756,7 @@ async def write_image(config, all_frames=False): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() rhs = [HexInt(x) for x in encoder.data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) From a43ee15b567896cc932bb45da2d2de3fcdbd48ec Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:33:48 -0400 Subject: [PATCH 0371/2214] [core] Fix Pvariable placement new losing subclass identity (#15881) --- esphome/cpp_generator.py | 50 ++++++++++++------- tests/component_tests/ili9xxx/__init__.py | 0 .../ili9xxx/config/ili9xxx_test.yaml | 20 ++++++++ tests/component_tests/ili9xxx/test_ili9xxx.py | 31 ++++++++++++ 4 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 tests/component_tests/ili9xxx/__init__.py create mode 100644 tests/component_tests/ili9xxx/config/ili9xxx_test.yaml create mode 100644 tests/component_tests/ili9xxx/test_ili9xxx.py diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index cf90b878e1..c622207dac 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -606,33 +606,43 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": if isinstance(rhs, MockObj) and rhs.is_new_expr: # For 'new' allocations, use placement new into static storage # to avoid heap fragmentation on embedded devices. - the_type = id_.type + # + # Storage must be sized and aligned for the actual instantiated class, + # which may be a subclass of id_.type (e.g. `cv.declare_id(BaseClass)` + # combined with `SubClass.new()` — used by ili9xxx, waveshare_epaper, + # etc. to select a model-specific constructor). Using id_.type would + # run the base-class default constructor instead, silently losing any + # subclass initialization. Template args live on the CallExpression + # and are re-emitted below. + call_expr = rhs.base + assert isinstance(call_expr, CallExpression), ( + f"Expected CallExpression for placement new, got {type(call_expr)}" + ) + actual_type = rhs.new_type if rhs.new_type is not None else id_.type + if call_expr.template_args is not None: + actual_type = f"{actual_type}{call_expr.template_args}" + pointer_type = id_.type # Extract component namespace from type for memory analysis attribution - component_ns = _extract_component_ns(str(the_type)) + component_ns = _extract_component_ns(str(actual_type)) storage_name = f"{component_ns}__{id_.id}__pstorage" # Declare aligned byte array for the object storage CORE.add_global( RawStatement( - f"alignas({the_type}) static unsigned char {storage_name}[sizeof({the_type})];" + f"alignas({actual_type}) static unsigned char {storage_name}[sizeof({actual_type})];" ) ) + # Pointer declaration uses id_.type to preserve the declared base-class + # pointer type for downstream callers (polymorphism through base ptr). CORE.add_global( AssignmentExpression( - f"static {the_type}", + f"static {pointer_type}", "*const ", id_, - MockObj(f"reinterpret_cast<{the_type} *>({storage_name})"), + MockObj(f"reinterpret_cast<{pointer_type} *>({storage_name})"), ) ) - # Extract args from the CallExpression and rebuild as placement new. - # Template args are already encoded in the_type (e.g. GlobalsComponent), - # so we only pass the constructor args, not template_args. - call_expr = rhs.base - assert isinstance(call_expr, CallExpression), ( - f"Expected CallExpression for placement new, got {type(call_expr)}" - ) - placement_new = CallExpression(f"new({id_.id}) {the_type}", *call_expr.args) + placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args) CORE.add(ExpressionStatement(placement_new)) else: decl = VariableDeclarationExpression(id_.type, "*", id_, static=True) @@ -869,12 +879,16 @@ class MockObj(Expression): Mostly consists of magic methods that allow ESPHome's codegen syntax. """ - __slots__ = ("base", "op", "is_new_expr") + __slots__ = ("base", "op", "is_new_expr", "new_type") - def __init__(self, base, op=".", is_new_expr=False) -> None: + def __init__(self, base, op=".", is_new_expr=False, new_type=None) -> None: self.base = base self.op = op self.is_new_expr = is_new_expr + # For `is_new_expr=True` objects, `new_type` holds the class name being + # constructed (e.g. "ili9xxx::ILI9XXXST7789V"). Needed by Pvariable so + # placement new uses the actual subclass rather than id_.type. + self.new_type = new_type def __getattr__(self, attr: str) -> "MockObj": # prevent python dunder methods being replaced by mock objects @@ -889,7 +903,9 @@ class MockObj(Expression): def __call__(self, *args: SafeExpType) -> "MockObj": call = CallExpression(self.base, *args) - return MockObj(call, self.op, is_new_expr=self.is_new_expr) + return MockObj( + call, self.op, is_new_expr=self.is_new_expr, new_type=self.new_type + ) def __str__(self): return str(self.base) @@ -903,7 +919,7 @@ class MockObj(Expression): @property def new(self) -> "MockObj": - return MockObj(f"new {self.base}", "->", is_new_expr=True) + return MockObj(f"new {self.base}", "->", is_new_expr=True, new_type=self.base) def template(self, *args: SafeExpType) -> "MockObj": """Apply template parameters to this object.""" diff --git a/tests/component_tests/ili9xxx/__init__.py b/tests/component_tests/ili9xxx/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml b/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml new file mode 100644 index 0000000000..bc6148b8d8 --- /dev/null +++ b/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: arduino + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: ili9xxx + id: tft_display + model: ST7789V + cs_pin: GPIO5 + dc_pin: GPIO17 + reset_pin: GPIO16 + invert_colors: false diff --git a/tests/component_tests/ili9xxx/test_ili9xxx.py b/tests/component_tests/ili9xxx/test_ili9xxx.py new file mode 100644 index 0000000000..3919eb3823 --- /dev/null +++ b/tests/component_tests/ili9xxx/test_ili9xxx.py @@ -0,0 +1,31 @@ +"""Tests for the ili9xxx component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + + +def test_ili9xxx_placement_new_uses_model_subclass( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Regression test for ili9xxx picking the right constructor under placement new. + + ili9xxx declares the ID as the base ``ILI9XXXDisplay`` but constructs a + model-specific subclass (e.g. ``ILI9XXXST7789V``) via ``MODELS[...].new()``. + Pvariable must emit placement new for the subclass — otherwise the base + default constructor runs and the panel is left with a null init sequence + and 0x0 dimensions, producing a silent blank screen. + """ + main_cpp = generate_main(component_config_path("ili9xxx_test.yaml")) + + # Storage is sized for the subclass so the full object fits. + assert "sizeof(ili9xxx::ILI9XXXST7789V)" in main_cpp + assert "alignas(ili9xxx::ILI9XXXST7789V)" in main_cpp + # Pointer is declared as the base type for polymorphism. + assert "static ili9xxx::ILI9XXXDisplay *const tft_display" in main_cpp + # Placement new runs the subclass constructor — this is the actual regression fix. + assert "new(tft_display) ili9xxx::ILI9XXXST7789V()" in main_cpp + # Base-class default constructor must NOT be used. + assert "new(tft_display) ili9xxx::ILI9XXXDisplay()" not in main_cpp From 4cb7ea2584225f5ea919d4a5f3088f075484dc9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:37:56 +0200 Subject: [PATCH 0372/2214] [light] Force-inline LightCall::set_flag_/clear_flag_ (#15729) --- esphome/components/light/light_call.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 88d29bd349..39953d0d20 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -222,7 +222,7 @@ class LightCall { inline bool get_save_() { return (this->flags_ & FLAG_SAVE) != 0; } // Helper to set flag - defaults to true for common case - void set_flag_(FieldFlags flag, bool value = true) { + void set_flag_(FieldFlags flag, bool value = true) ESPHOME_ALWAYS_INLINE { if (value) { this->flags_ |= flag; } else { @@ -231,7 +231,7 @@ class LightCall { } // Helper to clear flag - reduces code size for common case - void clear_flag_(FieldFlags flag) { this->flags_ &= ~flag; } + void clear_flag_(FieldFlags flag) ESPHOME_ALWAYS_INLINE { this->flags_ &= ~flag; } // Helper to log unsupported feature and clear flag - reduces code duplication void log_and_clear_unsupported_(FieldFlags flag, const LogString *feature, bool use_color_mode_log); From 0a0176d600e1b33159ad737df15640ee43cac525 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:38:12 +0200 Subject: [PATCH 0373/2214] [core] raise WDT_FEED_INTERVAL_MS from 3 ms to 300 ms (#15846) --- esphome/core/application.cpp | 54 +++++++++++++++++++++------------ esphome/core/application.h | 59 ++++++++++++++++++++++++++++++------ 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 866edebbf6..e5e1b36a65 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -211,11 +211,16 @@ void Application::process_dump_config_() { void Application::feed_wdt() { // Cold entry: callers without a millis() timestamp in hand. Fetches the - // time and takes the same rate-limit path as feed_wdt_with_time(). + // time and takes the same rate-limit paths as feed_wdt_with_time(). uint32_t now = millis(); if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { this->feed_wdt_slow_(now); } +#ifdef USE_STATUS_LED + if (now - this->last_status_led_service_ > STATUS_LED_DISPATCH_INTERVAL_MS) { + this->service_status_led_slow_(now); + } +#endif } void HOT Application::feed_wdt_slow_(uint32_t time) { @@ -223,27 +228,36 @@ void HOT Application::feed_wdt_slow_(uint32_t time) { // confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded. arch_feed_wdt(); this->last_wdt_feed_ = time; -#ifdef USE_STATUS_LED - if (status_led::global_status_led != nullptr) { - auto *sl = status_led::global_status_led; - uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; - if (sl_state == COMPONENT_STATE_LOOP_DONE) { - // status_led only transitions to LOOP_DONE from inside its own loop() (after the - // first idle-path dispatch), so its pin is already initialized by pre_setup() and - // its setup() has already run. Re-dispatch only if an error or warning bit has been - // set since; otherwise skip entirely. - if ((this->app_state_ & STATUS_LED_MASK) == 0) - return; - sl->enable_loop(); - } else if (sl_state != COMPONENT_STATE_LOOP) { - // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. - return; - } - sl->loop(); - } -#endif } +#ifdef USE_STATUS_LED +void HOT Application::service_status_led_slow_(uint32_t time) { + // Callers (feed_wdt(), feed_wdt_with_time()) have already confirmed the + // STATUS_LED_DISPATCH_INTERVAL_MS rate limit was exceeded. Rate-limited + // separately from arch_feed_wdt() so the LED blink pattern stays readable + // (status_led error blink period is 250 ms) while HAL watchdog pokes can + // still run at the much coarser WDT_FEED_INTERVAL_MS cadence. + this->last_status_led_service_ = time; + if (status_led::global_status_led == nullptr) + return; + auto *sl = status_led::global_status_led; + uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; + if (sl_state == COMPONENT_STATE_LOOP_DONE) { + // status_led only transitions to LOOP_DONE from inside its own loop() (after the + // first idle-path dispatch), so its pin is already initialized by pre_setup() and + // its setup() has already run. Re-dispatch only if an error or warning bit has been + // set since; otherwise skip entirely. + if ((this->app_state_ & STATUS_LED_MASK) == 0) + return; + sl->enable_loop(); + } else if (sl_state != COMPONENT_STATE_LOOP) { + // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. + return; + } + sl->loop(); +} +#endif + bool Application::any_component_has_status_flag_(uint8_t flag) const { // Walk all components (not just looping ones) so non-looping components' // status bits are respected. Only called from the slow-path clear helpers diff --git a/esphome/core/application.h b/esphome/core/application.h index 9252a47446..645caa2404 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -229,23 +229,50 @@ class Application { void schedule_dump_config() { this->dump_config_at_ = 0; } - /// Minimum interval between real arch_feed_wdt() calls. Chosen to keep the - /// rate of HAL pokes low while still being small enough that any plausible - /// watchdog timeout (seconds) has orders of magnitude of safety margin. - static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3; + /// Minimum interval between real arch_feed_wdt() calls. Sized so the outer + /// feed in Application::loop() is effectively rate-limited across both the + /// normal ~62 Hz cadence and worst-case wake-storm scenarios (e.g. external + /// stacks like OpenThread posting frequent wake notifications). Component + /// loops and scheduler items still feed after every op, so any op exceeding + /// this threshold triggers a real feed naturally. + /// Safety margins vs. platform watchdog timeouts: + /// - ESP32 task WDT default (5 s): ~16x + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; /// Feed the task watchdog. Cold entry — callers without a millis() /// timestamp in hand. Out of line to keep call sites tiny. void feed_wdt(); +#ifdef USE_STATUS_LED + /// Dispatch interval for the status LED update. Deliberately shorter than + /// WDT_FEED_INTERVAL_MS because the status LED error blink has a 250 ms + /// period (status_led.cpp:ERROR_PERIOD_MS) and a 150 ms on-window; the + /// dispatch cadence must be short enough to render that blink without + /// aliasing. Sampling every 100 ms yields an on/off observation inside + /// every error period with headroom for the 250 ms warning on-window. + static constexpr uint32_t STATUS_LED_DISPATCH_INTERVAL_MS = 100; +#endif + /// Feed the task watchdog, hot entry. Callers that already have a /// millis() timestamp pay only a load + sub + branch on the common - /// (no-op) path. The actual arch feed + status LED update live in - /// feed_wdt_slow_. + /// (no-op) path. The actual arch feed lives in feed_wdt_slow_. + /// When USE_STATUS_LED is compiled in, also gates a separate (shorter) + /// interval for dispatching status_led so the LED blink pattern stays + /// readable even though arch_feed_wdt pokes are now rate-limited at + /// WDT_FEED_INTERVAL_MS. The two rate limits are independent so raising + /// WDT_FEED_INTERVAL_MS does not distort the LED cadence. void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) { if (static_cast(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] { this->feed_wdt_slow_(time); } +#ifdef USE_STATUS_LED + if (static_cast(time - this->last_status_led_service_) > STATUS_LED_DISPATCH_INTERVAL_MS) [[unlikely]] { + this->service_status_led_slow_(time); + } +#endif } void reboot(); @@ -410,11 +437,21 @@ class Application { /// Caller must ensure dump_config_at_ < components_.size(). void __attribute__((noinline)) process_dump_config_(); - /// Slow path for feed_wdt(): actually calls arch_feed_wdt(), updates - /// last_wdt_feed_, and re-dispatches the status LED. Out of line so the - /// inline wrapper stays tiny. + /// Slow path for feed_wdt(): actually calls arch_feed_wdt() and updates + /// last_wdt_feed_. Out of line so the inline wrapper stays tiny. Does NOT + /// touch status_led — that's gated separately via service_status_led_slow_ + /// because the two rate limits have very different safe ranges (~ seconds + /// for WDT, < 250 ms for LED blink rendering). void feed_wdt_slow_(uint32_t time); +#ifdef USE_STATUS_LED + /// Slow path for the status_led dispatch rate limit. Runs the status_led + /// component's loop() based on its state (LOOP / LOOP_DONE with status + /// bits set), and updates last_status_led_service_. Out of line to keep + /// the feed_wdt_with_time hot path a couple of load+branch sequences. + void service_status_led_slow_(uint32_t time); +#endif + /// Perform a delay while also monitoring socket file descriptors for readiness #ifdef USE_HOST // select() fallback path is too complex to inline (host platform) @@ -468,6 +505,10 @@ class Application { uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path +#ifdef USE_STATUS_LED + // millis() of most recent status_led dispatch; rate-limits independently of last_wdt_feed_ + uint32_t last_status_led_service_{0}; +#endif #ifdef USE_HOST int max_fd_{-1}; // Highest file descriptor number for select() From 0d3a3552dadc74d8770fe9dd2f3e41402eac9c31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:39:49 +0200 Subject: [PATCH 0374/2214] [core] Move heap-allocating helpers to alloc_helpers.h/cpp (#15623) --- esphome/components/anova/anova_base.cpp | 6 +- .../components/http_request/http_request.cpp | 2 +- .../components/http_request/http_request.h | 5 +- .../http_request/http_request_arduino.cpp | 2 +- .../http_request/http_request_host.cpp | 2 +- .../http_request/http_request_idf.cpp | 2 +- esphome/core/alloc_helpers.cpp | 229 +++++++++++++++++ esphome/core/alloc_helpers.h | 128 +++++++++ esphome/core/helpers.cpp | 189 +------------- esphome/core/helpers.h | 242 ++---------------- script/ci-custom.py | 16 +- 11 files changed, 417 insertions(+), 406 deletions(-) create mode 100644 esphome/core/alloc_helpers.cpp create mode 100644 esphome/core/alloc_helpers.h diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp index fef4f1d852..a14dd728a8 100644 --- a/esphome/components/anova/anova_base.cpp +++ b/esphome/components/anova/anova_base.cpp @@ -2,6 +2,8 @@ #include #include +#include "esphome/core/alloc_helpers.h" + namespace esphome { namespace anova { @@ -105,14 +107,14 @@ void AnovaCodec::decode(const uint8_t *data, uint16_t length) { } case READ_TARGET_TEMPERATURE: case SET_TARGET_TEMPERATURE: { - this->target_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); + this->target_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); // NOLINT if (this->fahrenheit_) this->target_temp_ = ftoc(this->target_temp_); this->has_target_temp_ = true; break; } case READ_CURRENT_TEMPERATURE: { - this->current_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); + this->current_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); // NOLINT if (this->fahrenheit_) this->current_temp_ = ftoc(this->current_temp_); this->has_current_temp_ = true; diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index 2c74638f12..d45208ed5d 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -22,7 +22,7 @@ void HttpRequestComponent::dump_config() { } std::string HttpContainer::get_response_header(const std::string &header_name) { - auto lower = str_lower_case(header_name); + auto lower = str_lower_case(header_name); // NOLINT for (const auto &entry : this->response_headers_) { if (entry.name == lower) { ESP_LOGD(TAG, "Header with name %s found with value %s", lower.c_str(), entry.value.c_str()); diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index ae73983bab..f37bf77633 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -11,6 +11,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/alloc_helpers.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -400,7 +401,7 @@ class HttpRequestComponent : public Component { std::vector lower; lower.reserve(collect_headers.size()); for (const auto &h : collect_headers) { - lower.push_back(str_lower_case(h)); + lower.push_back(str_lower_case(h)); // NOLINT } return this->perform(url, method, body, request_headers, lower); } @@ -415,7 +416,7 @@ class HttpRequestComponent : public Component { std::vector lower; lower.reserve(collect_headers.size()); for (const auto &h : collect_headers) { - lower.push_back(str_lower_case(h)); + lower.push_back(str_lower_case(h)); // NOLINT } return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), lower); } diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index f0dd649285..05f9db1c06 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -161,7 +161,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->response_headers_.clear(); auto header_count = container->client_.headers(); for (int i = 0; i < header_count; i++) { - const std::string header_name = str_lower_case(container->client_.headerName(i).c_str()); + const std::string header_name = str_lower_case(container->client_.headerName(i).c_str()); // NOLINT if (should_collect_header(lower_case_collect_headers, header_name)) { std::string header_value = container->client_.header(i).c_str(); ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str()); diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 60ab4d68a0..85c6e8b3c7 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -115,7 +115,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, container->content_length = container->response_body_.size(); for (auto header : response.headers) { ESP_LOGD(TAG, "Header: %s: %s", header.first.c_str(), header.second.c_str()); - auto lower_name = str_lower_case(header.first); + auto lower_name = str_lower_case(header.first); // NOLINT if (should_collect_header(lower_case_collect_headers, lower_name)) { container->response_headers_.push_back({lower_name, header.second}); } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 30f53eecdc..3e341395a4 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -38,7 +38,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { switch (evt->event_id) { case HTTP_EVENT_ON_HEADER: { - const std::string header_name = str_lower_case(evt->header_key); + const std::string header_name = str_lower_case(evt->header_key); // NOLINT if (should_collect_header(user_data->lower_case_collect_headers, header_name)) { const std::string header_value = evt->header_value; ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str()); diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp new file mode 100644 index 0000000000..11c7abe3f7 --- /dev/null +++ b/esphome/core/alloc_helpers.cpp @@ -0,0 +1,229 @@ +#include "esphome/core/alloc_helpers.h" + +#include "esphome/core/helpers.h" + +#include +#include +#include +#include +#include +#include + +namespace esphome { + +// --- String helpers --- + +std::string str_truncate(const std::string &str, size_t length) { + return str.length() > length ? str.substr(0, length) : str; +} + +std::string str_until(const char *str, char ch) { + const char *pos = strchr(str, ch); + return pos == nullptr ? std::string(str) : std::string(str, pos - str); +} +std::string str_until(const std::string &str, char ch) { return str.substr(0, str.find(ch)); } + +// wrapper around std::transform to run safely on functions from the ctype.h header +// see https://en.cppreference.com/w/cpp/string/byte/toupper#Notes +template std::string str_ctype_transform(const std::string &str) { + std::string result; + result.resize(str.length()); + std::transform(str.begin(), str.end(), result.begin(), [](unsigned char ch) { return fn(ch); }); + return result; +} +std::string str_lower_case(const std::string &str) { return str_ctype_transform(str); } + +std::string str_upper_case(const std::string &str) { + std::string result; + result.resize(str.length()); + std::transform(str.begin(), str.end(), result.begin(), [](unsigned char ch) { return std::toupper(ch); }); + return result; +} + +std::string str_snake_case(const std::string &str) { + std::string result = str; + for (char &c : result) { + c = to_snake_case_char(c); + } + return result; +} + +std::string str_sanitize(const std::string &str) { + std::string result; + result.resize(str.size()); + str_sanitize_to(&result[0], str.size() + 1, str.c_str()); + return result; +} + +std::string str_snprintf(const char *fmt, size_t len, ...) { + std::string str; + va_list args; + + str.resize(len); + va_start(args, len); + size_t out_length = vsnprintf(&str[0], len + 1, fmt, args); + va_end(args); + + if (out_length < len) + str.resize(out_length); + + return str; +} + +std::string str_sprintf(const char *fmt, ...) { + std::string str; + va_list args; + + va_start(args, fmt); + size_t length = vsnprintf(nullptr, 0, fmt, args); + va_end(args); + + str.resize(length); + va_start(args, fmt); + vsnprintf(&str[0], length + 1, fmt, args); + va_end(args); + + return str; +} + +// --- Value formatting helpers --- + +std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { + char buf[VALUE_ACCURACY_MAX_LEN]; + value_accuracy_to_buf(buf, value, accuracy_decimals); + return std::string(buf); +} + +// --- Base64 helpers --- + +static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; + +// 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) { + char char_array_4[4]; + char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; + char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); + char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); + 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])]; +} + +std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); } + +std::string base64_encode(const uint8_t *buf, size_t buf_len) { + std::string ret; + int i = 0; + char char_array_3[3]; + + while (buf_len--) { + char_array_3[i++] = *(buf++); + if (i == 3) { + base64_encode_triple(char_array_3, 4, ret); + i = 0; + } + } + + if (i) { + for (int j = i; j < 3; j++) + char_array_3[j] = '\0'; + + base64_encode_triple(char_array_3, i + 1, ret); + + while ((i++ < 3)) + ret += '='; + } + + return ret; +} + +std::vector base64_decode(const std::string &encoded_string) { + // Calculate maximum decoded size: every 4 base64 chars = 3 bytes + size_t max_len = ((encoded_string.size() + 3) / 4) * 3; + std::vector ret(max_len); + size_t actual_len = base64_decode(encoded_string, ret.data(), max_len); + ret.resize(actual_len); + return ret; +} + +// --- Hex/binary formatting helpers --- + +std::string format_mac_address_pretty(const uint8_t *mac) { + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); +} + +std::string format_hex(const uint8_t *data, size_t length) { + std::string ret; + ret.resize(length * 2); + format_hex_to(&ret[0], length * 2 + 1, data, length); + return ret; +} + +std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } + +// Shared implementation for uint8_t and string hex pretty formatting +static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, char separator, bool show_length) { + if (data == nullptr || length == 0) + return ""; + std::string ret; + size_t hex_len = separator ? (length * 3 - 1) : (length * 2); + ret.resize(hex_len); + format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); + if (show_length && length > 4) + return ret + " (" + std::to_string(length) + ")"; + return ret; +} + +std::string format_hex_pretty(const uint8_t *data, size_t length, char separator, bool show_length) { + return format_hex_pretty_uint8(data, length, separator, show_length); +} +std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { + return format_hex_pretty(data.data(), data.size(), separator, show_length); +} + +std::string format_hex_pretty(const uint16_t *data, size_t length, char separator, bool show_length) { + if (data == nullptr || length == 0) + return ""; + std::string ret; + size_t hex_len = separator ? (length * 5 - 1) : (length * 4); + ret.resize(hex_len); + format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); + if (show_length && length > 4) + return ret + " (" + std::to_string(length) + ")"; + return ret; +} +std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { + return format_hex_pretty(data.data(), data.size(), separator, show_length); +} +std::string format_hex_pretty(const std::string &data, char separator, bool show_length) { + return format_hex_pretty_uint8(reinterpret_cast(data.data()), data.length(), separator, show_length); +} + +std::string format_bin(const uint8_t *data, size_t length) { + std::string result; + result.resize(length * 8); + format_bin_to(&result[0], length * 8 + 1, data, length); + return result; +} + +// --- MAC address helpers --- + +std::string get_mac_address() { + uint8_t mac[6]; + get_mac_address_raw(mac); + char buf[13]; + format_mac_addr_lower_no_sep(mac, buf); + return std::string(buf); +} + +std::string get_mac_address_pretty() { + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + return std::string(get_mac_address_pretty_into_buffer(buf)); +} + +} // namespace esphome diff --git a/esphome/core/alloc_helpers.h b/esphome/core/alloc_helpers.h new file mode 100644 index 0000000000..fe350886b7 --- /dev/null +++ b/esphome/core/alloc_helpers.h @@ -0,0 +1,128 @@ +#pragma once + +/// @file alloc_helpers.h +/// @brief Heap-allocating helper functions. +/// +/// These functions return std::string and allocate heap memory on every call. +/// On long-running embedded devices, repeated heap allocations fragment memory +/// over time, eventually causing crashes even with free memory available. +/// +/// Prefer the stack-based alternatives documented on each function instead. +/// New code should avoid using these functions. + +#include +#include +#include +#include +#include + +namespace esphome { + +// --- String helpers (allocating) --- + +/// Truncate a string to a specific length. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_truncate(const std::string &str, size_t length); + +/// Extract the part of the string until either the first occurrence of the specified character, or the end +/// (requires str to be null-terminated). +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_until(const char *str, char ch); +/// Extract the part of the string until either the first occurrence of the specified character, or the end. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_until(const std::string &str, char ch); + +/// Convert the string to lower case. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_lower_case(const std::string &str); + +/// Convert the string to upper case. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_upper_case(const std::string &str); + +/// Convert the string to snake case (lowercase with underscores). +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_snake_case(const std::string &str); + +/// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores. +/// @warning Allocates heap memory. Use str_sanitize_to() with a stack buffer instead. +std::string str_sanitize(const std::string &str); + +/// snprintf-like function returning std::string of maximum length \p len (excluding null terminator). +/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. +std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, size_t len, ...); + +/// sprintf-like function returning std::string. +/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. +std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, ...); + +// --- Hex/binary formatting helpers (allocating) --- + +/// Format the six-byte array \p mac into a MAC address string. +/// @warning Allocates heap memory. Use format_mac_addr_upper() with a stack buffer instead. +std::string format_mac_address_pretty(const uint8_t mac[6]); + +/// Format the byte array \p data of length \p len in lowercased hex. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +std::string format_hex(const uint8_t *data, size_t length); + +/// Format the vector \p data in lowercased hex. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +std::string format_hex(const std::vector &data); + +/// Format a byte array in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const uint8_t *data, size_t length, char separator = '.', bool show_length = true); + +/// Format a 16-bit word array in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const uint16_t *data, size_t length, char separator = '.', bool show_length = true); + +/// Format a byte vector in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); + +/// Format a 16-bit word vector in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); + +/// Format a string's bytes in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const std::string &data, char separator = '.', bool show_length = true); + +/// Format the byte array \p data of length \p len in binary. +/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. +std::string format_bin(const uint8_t *data, size_t length); + +// --- Value formatting helpers (allocating) --- + +/// Format a float value with accuracy decimals to a string. +/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0. +__attribute__((deprecated("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0."))) +std::string +value_accuracy_to_string(float value, int8_t accuracy_decimals); + +// --- Base64 helpers (allocating) --- + +/// Encode a byte buffer to base64 string. +/// @warning Allocates heap memory. +std::string base64_encode(const uint8_t *buf, size_t buf_len); +/// Encode a byte vector to base64 string. +/// @warning Allocates heap memory. +std::string base64_encode(const std::vector &buf); + +/// Decode a base64 string to a byte vector. +/// @warning Allocates heap memory. Use base64_decode(data, len, buf, buf_len) with a pre-allocated buffer instead. +std::vector base64_decode(const std::string &encoded_string); + +// --- MAC address helpers (allocating) --- + +/// Get the device MAC address as a string, in lowercase hex notation. +/// @warning Allocates heap memory. Use get_mac_address_into_buffer() instead. +std::string get_mac_address(); + +/// Get the device MAC address as a string, in colon-separated uppercase hex notation. +/// @warning Allocates heap memory. Use get_mac_address_pretty_into_buffer() instead. +std::string get_mac_address_pretty(); + +} // namespace esphome diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 34ecaf137f..1d0efd01ce 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -221,31 +221,7 @@ 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; } -std::string str_truncate(const std::string &str, size_t length) { - return str.length() > length ? str.substr(0, length) : str; -} -std::string str_until(const char *str, char ch) { - const char *pos = strchr(str, ch); - return pos == nullptr ? std::string(str) : std::string(str, pos - str); -} -std::string str_until(const std::string &str, char ch) { return str.substr(0, str.find(ch)); } -// wrapper around std::transform to run safely on functions from the ctype.h header -// see https://en.cppreference.com/w/cpp/string/byte/toupper#Notes -template std::string str_ctype_transform(const std::string &str) { - std::string result; - result.resize(str.length()); - std::transform(str.begin(), str.end(), result.begin(), [](unsigned char ch) { return fn(ch); }); - return result; -} -std::string str_lower_case(const std::string &str) { return str_ctype_transform(str); } -std::string str_upper_case(const std::string &str) { return str_ctype_transform(str); } -std::string str_snake_case(const std::string &str) { - std::string result = str; - for (char &c : result) { - c = to_snake_case_char(c); - } - return result; -} +// 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) { return buffer; @@ -258,41 +234,7 @@ char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { return buffer; } -std::string str_sanitize(const std::string &str) { - std::string result; - result.resize(str.size()); - str_sanitize_to(&result[0], str.size() + 1, str.c_str()); - return result; -} -std::string str_snprintf(const char *fmt, size_t len, ...) { - std::string str; - va_list args; - - str.resize(len); - va_start(args, len); - size_t out_length = vsnprintf(&str[0], len + 1, fmt, args); - va_end(args); - - if (out_length < len) - str.resize(out_length); - - return str; -} -std::string str_sprintf(const char *fmt, ...) { - std::string str; - va_list args; - - va_start(args, fmt); - size_t length = vsnprintf(nullptr, 0, fmt, args); - va_end(args); - - str.resize(length); - va_start(args, fmt); - vsnprintf(&str[0], length + 1, fmt, args); - va_end(args); - - return str; -} +// str_sanitize, str_snprintf, str_sprintf moved to alloc_helpers.cpp // Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term) static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; @@ -341,11 +283,7 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { return chars; } -std::string format_mac_address_pretty(const uint8_t *mac) { - char buf[18]; - format_mac_addr_upper(mac, buf); - return std::string(buf); -} +// format_mac_address_pretty moved to alloc_helpers.cpp // Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase. // When separator is set, it is written unconditionally after each byte and the last @@ -398,13 +336,7 @@ char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_ return format_hex_internal(buffer, buffer_size, data, length, 0, 'a'); } -std::string format_hex(const uint8_t *data, size_t length) { - std::string ret; - ret.resize(length * 2); - format_hex_to(&ret[0], length * 2 + 1, data, length); - return ret; -} -std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } +// format_hex (std::string returning overloads) moved to alloc_helpers.cpp char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator) { return format_hex_internal(buffer, buffer_size, data, length, separator, 'A'); @@ -441,43 +373,7 @@ char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint16_t *dat return buffer; } -// Shared implementation for uint8_t and string hex formatting -static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, char separator, bool show_length) { - if (data == nullptr || length == 0) - return ""; - std::string ret; - size_t hex_len = separator ? (length * 3 - 1) : (length * 2); - ret.resize(hex_len); - format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); - if (show_length && length > 4) - return ret + " (" + std::to_string(length) + ")"; - return ret; -} - -std::string format_hex_pretty(const uint8_t *data, size_t length, char separator, bool show_length) { - return format_hex_pretty_uint8(data, length, separator, show_length); -} -std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { - return format_hex_pretty(data.data(), data.size(), separator, show_length); -} - -std::string format_hex_pretty(const uint16_t *data, size_t length, char separator, bool show_length) { - if (data == nullptr || length == 0) - return ""; - std::string ret; - size_t hex_len = separator ? (length * 5 - 1) : (length * 4); - ret.resize(hex_len); - format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); - if (show_length && length > 4) - return ret + " (" + std::to_string(length) + ")"; - return ret; -} -std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { - return format_hex_pretty(data.data(), data.size(), separator, show_length); -} -std::string format_hex_pretty(const std::string &data, char separator, bool show_length) { - return format_hex_pretty_uint8(reinterpret_cast(data.data()), data.length(), separator, show_length); -} +// format_hex_pretty (all std::string returning overloads) moved to alloc_helpers.cpp char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) { if (buffer_size == 0) { @@ -500,12 +396,7 @@ char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_ return buffer; } -std::string format_bin(const uint8_t *data, size_t length) { - std::string result; - result.resize(length * 8); - format_bin_to(&result[0], length * 8 + 1, data, length); - return result; -} +// format_bin moved to alloc_helpers.cpp ParseOnOffState parse_on_off(const char *str, const char *on, const char *off) { if (on == nullptr && ESPHOME_strcasecmp_P(str, ESPHOME_PSTR("on")) == 0) @@ -537,11 +428,7 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de } } -std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { - char buf[VALUE_ACCURACY_MAX_LEN]; - value_accuracy_to_buf(buf, value, accuracy_decimals); - return std::string(buf); -} +// value_accuracy_to_string moved to alloc_helpers.cpp size_t value_accuracy_to_buf(std::span buf, float value, int8_t accuracy_decimals) { normalize_accuracy_decimals(value, accuracy_decimals); @@ -606,45 +493,7 @@ static inline uint8_t base64_find_char(char c) { // Check if character is valid base64 or base64url static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == '/') || (c == '-') || (c == '_')); } -std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); } - -// 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) { - char char_array_4[4]; - char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; - char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); - char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); - 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])]; -} - -std::string base64_encode(const uint8_t *buf, size_t buf_len) { - std::string ret; - int i = 0; - char char_array_3[3]; - - while (buf_len--) { - char_array_3[i++] = *(buf++); - if (i == 3) { - base64_encode_triple(char_array_3, 4, ret); - i = 0; - } - } - - if (i) { - for (int j = i; j < 3; j++) - char_array_3[j] = '\0'; - - base64_encode_triple(char_array_3, i + 1, ret); - - while ((i++ < 3)) - ret += '='; - } - - return ret; -} +// base64_encode (both overloads) moved to alloc_helpers.cpp size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { return base64_decode(reinterpret_cast(encoded_string.data()), encoded_string.size(), buf, buf_len); @@ -705,14 +554,7 @@ size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *b return out; } -std::vector base64_decode(const std::string &encoded_string) { - // Calculate maximum decoded size: every 4 base64 chars = 3 bytes - size_t max_len = ((encoded_string.size() + 3) / 4) * 3; - std::vector ret(max_len); - size_t actual_len = base64_decode(encoded_string, ret.data(), max_len); - ret.resize(actual_len); - return ret; -} +// base64_decode (vector-returning overload) moved to alloc_helpers.cpp /// Decode base64/base64url string directly into vector of little-endian int32 values /// @param base64 Base64 or base64url encoded string (both +/ and -_ accepted) @@ -851,18 +693,7 @@ void HighFrequencyLoopRequester::stop() { this->started_ = false; } -std::string get_mac_address() { - uint8_t mac[6]; - get_mac_address_raw(mac); - char buf[13]; - format_mac_addr_lower_no_sep(mac, buf); - return std::string(buf); -} - -std::string get_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(get_mac_address_pretty_into_buffer(buf)); -} +// 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]; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 54bc32a5a5..bb164f5034 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -21,6 +21,12 @@ #include "esphome/core/optional.h" +// Backward compatibility re-export of heap-allocating helpers. +// These functions have moved to alloc_helpers.h. External components should +// update their includes to use #include "esphome/core/alloc_helpers.h" directly. +// This re-export will be removed in 2026.11.0. +#include "esphome/core/alloc_helpers.h" + #ifdef USE_ESP8266 #include #include @@ -979,27 +985,13 @@ 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)); } -/// Truncate a string to a specific length. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -std::string str_truncate(const std::string &str, size_t length); +// str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 -/// Extract the part of the string until either the first occurrence of the specified character, or the end -/// (requires str to be null-terminated). -std::string str_until(const char *str, char ch); -/// Extract the part of the string until either the first occurrence of the specified character, or the end. -std::string str_until(const std::string &str, char ch); - -/// Convert the string to lower case. -std::string str_lower_case(const std::string &str); -/// Convert the string to upper case. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -std::string str_upper_case(const std::string &str); +// str_until, str_lower_case, str_upper_case moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Convert a single char to snake_case: lowercase and space to underscore. constexpr char to_snake_case_char(char c) { return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; } -/// Convert the string to snake case (lowercase with underscores). -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -std::string str_snake_case(const std::string &str); +// str_snake_case moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Sanitize a single char: keep alphanumerics, dashes, underscores; replace others with underscore. constexpr char to_sanitized_char(char c) { @@ -1022,9 +1014,7 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s return str_sanitize_to(buffer, N, str); } -/// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores. -/// @warning Allocates heap memory. Use str_sanitize_to() with a stack buffer instead. -std::string str_sanitize(const std::string &str); +// 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 computes object_id hashes directly from names without creating an intermediate buffer. @@ -1040,13 +1030,7 @@ inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { return hash; } -/// snprintf-like function returning std::string of maximum length \p len (excluding null terminator). -/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. -std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, size_t len, ...); - -/// sprintf-like function returning std::string. -/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. -std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, ...); +// str_snprintf, str_sprintf moved to alloc_helpers.h - remove this comment before 2026.11.0 #ifdef USE_ESP8266 // ESP8266: Use vsnprintf_P to keep format strings in flash (PROGMEM) @@ -1441,189 +1425,26 @@ inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { format_hex_to(output, MAC_ADDRESS_BUFFER_SIZE, mac, MAC_ADDRESS_SIZE); } -/// Format the six-byte array \p mac into a MAC address. -/// @warning Allocates heap memory. Use format_mac_addr_upper() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_mac_address_pretty(const uint8_t mac[6]); -/// Format the byte array \p data of length \p len in lowercased hex. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_hex(const uint8_t *data, size_t length); -/// Format the vector \p data in lowercased hex. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_hex(const std::vector &data); +// format_mac_address_pretty, format_hex (all overloads) moved to alloc_helpers.h +// Remove this comment and the template overloads below before 2026.11.0 + /// Format an unsigned integer in lowercased hex, starting with the most significant byte. /// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. template::value, int> = 0> std::string format_hex(T val) { val = convert_big_endian(val); return format_hex(reinterpret_cast(&val), sizeof(T)); } /// Format the std::array \p data in lowercased hex. /// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. template std::string format_hex(const std::array &data) { return format_hex(data.data(), data.size()); } -/** Format a byte array in pretty-printed, human-readable hex format. - * - * Converts binary data to a hexadecimal string representation with customizable formatting. - * Each byte is displayed as a two-digit uppercase hex value, separated by the specified separator. - * Optionally includes the total byte count in parentheses at the end. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Pointer to the byte array to format. - * @param length Number of bytes in the array. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string, e.g., "A1.B2.C3.D4.E5 (5)" or "A1:B2:C3" depending on parameters. - * - * @note Returns empty string if data is nullptr or length is 0. - * @note The length will only be appended if show_length is true AND the length is greater than 4. - * - * Example: - * @code - * uint8_t data[] = {0xA1, 0xB2, 0xC3}; - * format_hex_pretty(data, 3); // Returns "A1.B2.C3" (no length shown for <= 4 parts) - * uint8_t data2[] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5}; - * format_hex_pretty(data2, 5); // Returns "A1.B2.C3.D4.E5 (5)" - * format_hex_pretty(data2, 5, ':'); // Returns "A1:B2:C3:D4:E5 (5)" - * format_hex_pretty(data2, 5, '.', false); // Returns "A1.B2.C3.D4.E5" - * @endcode - */ -std::string format_hex_pretty(const uint8_t *data, size_t length, char separator = '.', bool show_length = true); +// format_hex_pretty (all overloads) moved to alloc_helpers.h +// Remove this comment and the template overload below before 2026.11.0 -/** Format a 16-bit word array in pretty-printed, human-readable hex format. - * - * Similar to the byte array version, but formats 16-bit words as 4-digit hex values. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Pointer to the 16-bit word array to format. - * @param length Number of 16-bit words in the array. - * @param separator Character to use between hex words (default: '.'). - * @param show_length Whether to append the word count in parentheses (default: true). - * @return Formatted hex string with 4-digit hex values per word. - * - * @note The length will only be appended if show_length is true AND the length is greater than 4. - * - * Example: - * @code - * uint16_t data[] = {0xA1B2, 0xC3D4}; - * format_hex_pretty(data, 2); // Returns "A1B2.C3D4" (no length shown for <= 4 parts) - * uint16_t data2[] = {0xA1B2, 0xC3D4, 0xE5F6}; - * format_hex_pretty(data2, 3); // Returns "A1B2.C3D4.E5F6 (3)" - * @endcode - */ -std::string format_hex_pretty(const uint16_t *data, size_t length, char separator = '.', bool show_length = true); - -/** Format a byte vector in pretty-printed, human-readable hex format. - * - * Convenience overload for std::vector. Formats each byte as a two-digit - * uppercase hex value with customizable separator. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Vector of bytes to format. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string representation of the vector contents. - * - * @note The length will only be appended if show_length is true AND the vector size is greater than 4. - * - * Example: - * @code - * std::vector data = {0xDE, 0xAD, 0xBE, 0xEF}; - * format_hex_pretty(data); // Returns "DE.AD.BE.EF" (no length shown for <= 4 parts) - * std::vector data2 = {0xDE, 0xAD, 0xBE, 0xEF, 0xCA}; - * format_hex_pretty(data2); // Returns "DE.AD.BE.EF.CA (5)" - * format_hex_pretty(data2, '-'); // Returns "DE-AD-BE-EF-CA (5)" - * @endcode - */ -std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); - -/** Format a 16-bit word vector in pretty-printed, human-readable hex format. - * - * Convenience overload for std::vector. Each 16-bit word is formatted - * as a 4-digit uppercase hex value in big-endian order. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Vector of 16-bit words to format. - * @param separator Character to use between hex words (default: '.'). - * @param show_length Whether to append the word count in parentheses (default: true). - * @return Formatted hex string representation of the vector contents. - * - * @note The length will only be appended if show_length is true AND the vector size is greater than 4. - * - * Example: - * @code - * std::vector data = {0x1234, 0x5678}; - * format_hex_pretty(data); // Returns "1234.5678" (no length shown for <= 4 parts) - * std::vector data2 = {0x1234, 0x5678, 0x9ABC}; - * format_hex_pretty(data2); // Returns "1234.5678.9ABC (3)" - * @endcode - */ -std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); - -/** Format a string's bytes in pretty-printed, human-readable hex format. - * - * Treats each character in the string as a byte and formats it in hex. - * Useful for debugging binary data stored in std::string containers. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data String whose bytes should be formatted as hex. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string representation of the string's byte contents. - * - * @note The length will only be appended if show_length is true AND the string length is greater than 4. - * - * Example: - * @code - * std::string data = "ABC"; // ASCII: 0x41, 0x42, 0x43 - * format_hex_pretty(data); // Returns "41.42.43" (no length shown for <= 4 parts) - * std::string data2 = "ABCDE"; - * format_hex_pretty(data2); // Returns "41.42.43.44.45 (5)" - * @endcode - */ -std::string format_hex_pretty(const std::string &data, char separator = '.', bool show_length = true); - -/** Format an unsigned integer in pretty-printed, human-readable hex format. - * - * Converts the integer to big-endian byte order and formats each byte as hex. - * The most significant byte appears first in the output string. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @tparam T Unsigned integer type (uint8_t, uint16_t, uint32_t, uint64_t, etc.). - * @param val The unsigned integer value to format. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string with most significant byte first. - * - * @note The length will only be appended if show_length is true AND sizeof(T) is greater than 4. - * - * Example: - * @code - * uint32_t value = 0x12345678; - * format_hex_pretty(value); // Returns "12.34.56.78" (no length shown for <= 4 parts) - * uint64_t value2 = 0x123456789ABCDEF0; - * format_hex_pretty(value2); // Returns "12.34.56.78.9A.BC.DE.F0 (8)" - * format_hex_pretty(value2, ':'); // Returns "12:34:56:78:9A:BC:DE:F0 (8)" - * format_hex_pretty(0x1234); // Returns "12.34" - * @endcode - */ +/// Format an unsigned integer in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. template::value, int> = 0> std::string format_hex_pretty(T val, char separator = '.', bool show_length = true) { val = convert_big_endian(val); @@ -1683,13 +1504,10 @@ inline char *format_bin_to(char (&buffer)[N], T val) { return format_bin_to(buffer, reinterpret_cast(&val), sizeof(T)); } -/// Format the byte array \p data of length \p len in binary. -/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_bin(const uint8_t *data, size_t length); +// format_bin moved to alloc_helpers.h - remove this comment and template overload before 2026.11.0 + /// Format an unsigned integer in binary, starting with the most significant byte. /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. template::value, int> = 0> std::string format_bin(T val) { val = convert_big_endian(val); return format_bin(reinterpret_cast(&val), sizeof(T)); @@ -1705,9 +1523,7 @@ enum ParseOnOffState : uint8_t { /// Parse a string that contains either on, off or toggle. ParseOnOffState parse_on_off(const char *str, const char *on = nullptr, const char *off = nullptr); -/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0.", "2026.1.0") -std::string value_accuracy_to_string(float value, int8_t accuracy_decimals); +// value_accuracy_to_string moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Maximum buffer size for value_accuracy formatting (float ~15 chars + space + UOM ~40 chars + null) static constexpr size_t VALUE_ACCURACY_MAX_LEN = 64; @@ -1721,10 +1537,8 @@ size_t value_accuracy_with_uom_to_buf(std::span bu /// Derive accuracy in decimals from an increment step. int8_t step_to_accuracy_decimals(float step); -std::string base64_encode(const uint8_t *buf, size_t buf_len); -std::string base64_encode(const std::vector &buf); - -std::vector base64_decode(const std::string &encoded_string); +// base64_encode (both overloads), base64_decode (vector overload) moved to alloc_helpers.h +// Remove this comment before 2026.11.0 size_t base64_decode(std::string const &encoded_string, uint8_t *buf, size_t buf_len); size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len); @@ -2160,15 +1974,7 @@ class HighFrequencyLoopRequester { /// Get the device MAC address as raw bytes, written into the provided byte array (6 bytes). void get_mac_address_raw(uint8_t *mac); // NOLINT(readability-non-const-parameter) -/// Get the device MAC address as a string, in lowercase hex notation. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -/// Use get_mac_address_into_buffer() instead. -std::string get_mac_address(); - -/// Get the device MAC address as a string, in colon-separated uppercase hex notation. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -/// Use get_mac_address_pretty_into_buffer() instead. -std::string get_mac_address_pretty(); +// get_mac_address, get_mac_address_pretty moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Get the device MAC address into the given buffer, in lowercase hex notation. /// Assumes buffer length is MAC_ADDRESS_BUFFER_SIZE (12 digits for hexadecimal representation followed by null diff --git a/script/ci-custom.py b/script/ci-custom.py index 6dce86924e..02ec08bc31 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -722,18 +722,22 @@ def lint_trailing_whitespace(fname, match): # Heap-allocating helpers that cause fragmentation on long-running embedded devices. # These return std::string and should be replaced with stack-based alternatives. HEAP_ALLOCATING_HELPERS = { + "base64_encode": "base64_encode_to() with a pre-allocated buffer", "format_bin": "format_bin_to() with a stack buffer", "format_hex": "format_hex_to() with a stack buffer", "format_hex_pretty": "format_hex_pretty_to() with a stack buffer", "format_mac_address_pretty": "format_mac_addr_upper() with a stack buffer", "get_mac_address": "get_mac_address_into_buffer() with a stack buffer", "get_mac_address_pretty": "get_mac_address_pretty_into_buffer() with a stack buffer", + "str_lower_case": "manual tolower() with a stack buffer", "str_sanitize": "str_sanitize_to() with a stack buffer", "str_truncate": "removal (function is unused)", + "str_until": "manual strchr()/find() with a StringRef or stack buffer", "str_upper_case": "removal (function is unused)", "str_snake_case": "removal (function is unused)", "str_sprintf": "snprintf() with a stack buffer", "str_snprintf": "snprintf() with a stack buffer", + "value_accuracy_to_string": "value_accuracy_to_buf() with a stack buffer", } @@ -743,24 +747,33 @@ HEAP_ALLOCATING_HELPERS = { # get_mac_address(?!_) ensures we don't match get_mac_address_into_buffer, etc. # CPP_RE_EOL captures rest of line so NOLINT comments are detected r"[^\w](" + r"base64_encode(?!_)|" r"format_bin(?!_)|" r"format_hex(?!_)|" r"format_hex_pretty(?!_)|" r"format_mac_address_pretty|" r"get_mac_address_pretty(?!_)|" r"get_mac_address(?!_)|" + r"str_lower_case|" r"str_sanitize(?!_)|" r"str_truncate|" + r"str_until|" r"str_upper_case|" r"str_snake_case|" r"str_sprintf|" - r"str_snprintf" + r"str_snprintf|" + r"value_accuracy_to_string" r")\s*\(" + CPP_RE_EOL, include=cpp_include, exclude=[ # The definitions themselves + "esphome/core/alloc_helpers.h", + "esphome/core/alloc_helpers.cpp", + # Backward compatibility re-exports (remove before 2026.11.0) "esphome/core/helpers.h", "esphome/core/helpers.cpp", + # Vendored third-party library + "esphome/components/http_request/httplib.h", ], ) def lint_no_heap_allocating_helpers(fname, match): @@ -812,6 +825,7 @@ def lint_no_sprintf(fname, match): "esphome/components/http_request/httplib.h", # Deprecated helpers that return std::string "esphome/core/helpers.cpp", + "esphome/core/alloc_helpers.cpp", # The using declaration itself "esphome/core/helpers.h", # Test fixtures - not production embedded code From a5b1f3eece4d1d64e62daaa902259fd87f61f967 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:40:03 +0200 Subject: [PATCH 0375/2214] [core] Remove pre-sleep socket scan from fast select path (#15639) --- .../components/socket/bsd_sockets_impl.cpp | 44 +++++++++---------- esphome/components/socket/bsd_sockets_impl.h | 15 +++++-- .../components/socket/lwip_sockets_impl.cpp | 44 +++++++++---------- esphome/components/socket/lwip_sockets_impl.h | 15 +++++-- esphome/components/socket/socket.h | 21 +++++++-- esphome/core/application.cpp | 27 +----------- esphome/core/application.h | 41 ++++++----------- 7 files changed, 96 insertions(+), 111 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index aea7c776c6..92691b17ab 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -14,38 +14,34 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - // Cache lwip_sock pointer and register for monitoring (hooks callback internally) - this->cached_sock_ = esphome_lwip_get_sock(this->fd_); - this->loop_monitored_ = App.register_socket(this->cached_sock_); + this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif } -BSDSocketImpl::~BSDSocketImpl() { - if (!this->closed_) { - this->close(); - } -} +BSDSocketImpl::~BSDSocketImpl() { this->close(); } int BSDSocketImpl::close() { - if (!this->closed_) { - // Unregister before closing to avoid dangling pointer in monitored set -#ifdef USE_LWIP_FAST_SELECT - if (this->loop_monitored_) { - App.unregister_socket(this->cached_sock_); - this->cached_sock_ = nullptr; - } -#else - if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); - } -#endif - int ret = ::close(this->fd_); - this->closed_ = true; - return ret; + if (this->fd_ < 0) { + // Already closed, or never opened. + return 0; } - return 0; +#ifdef USE_LWIP_FAST_SELECT + // Null the cached lwip_sock pointer before closing. The underlying lwip slot can be + // recycled for a new connection as soon as ::close() returns, so anything that might + // dereference cached_sock_ post-close (e.g. setsockopt(TCP_NODELAY)) would otherwise + // touch an unrelated socket's pcb. No per-socket callback unhook is needed — + // all LwIP sockets share the same static event_callback. + this->cached_sock_ = nullptr; +#else + if (this->loop_monitored_) { + App.unregister_socket_fd(this->fd_); + } +#endif + int ret = ::close(this->fd_); + this->fd_ = -1; // Sentinel for "closed" — prevents double-close and makes use-after-close visible. + return ret; } int BSDSocketImpl::setblocking(bool blocking) { diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index e520784702..57c1a430a2 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -119,12 +119,21 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } protected: + // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This + // replaces a separate closed_ flag: close() sets fd_ = -1 after ::close(), and the + // destructor / double-close path just check fd_ < 0. int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT - struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() -#endif - bool closed_{false}; + // Cached lwip_sock pointer used for direct rcvevent reads in ready() on the + // fast-select path. Replaces loop_monitored_: null means this socket is not being + // monitored for read events — either monitoring was not requested, the fd was + // invalid, or esphome_lwip_get_sock() failed. Non-null means the netconn event + // callback was hooked and notifications are flowing. close() nulls this to prevent + // use-after-free via a recycled lwip slot. + struct lwip_sock *cached_sock_{nullptr}; +#else bool loop_monitored_{false}; +#endif }; } // namespace esphome::socket diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index 2fad429e0f..b4eba3febf 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -14,38 +14,34 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - // Cache lwip_sock pointer and register for monitoring (hooks callback internally) - this->cached_sock_ = esphome_lwip_get_sock(this->fd_); - this->loop_monitored_ = App.register_socket(this->cached_sock_); + this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif } -LwIPSocketImpl::~LwIPSocketImpl() { - if (!this->closed_) { - this->close(); - } -} +LwIPSocketImpl::~LwIPSocketImpl() { this->close(); } int LwIPSocketImpl::close() { - if (!this->closed_) { - // Unregister before closing to avoid dangling pointer in monitored set -#ifdef USE_LWIP_FAST_SELECT - if (this->loop_monitored_) { - App.unregister_socket(this->cached_sock_); - this->cached_sock_ = nullptr; - } -#else - if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); - } -#endif - int ret = lwip_close(this->fd_); - this->closed_ = true; - return ret; + if (this->fd_ < 0) { + // Already closed, or never opened. + return 0; } - return 0; +#ifdef USE_LWIP_FAST_SELECT + // Null the cached lwip_sock pointer before closing. The underlying lwip slot can be + // recycled for a new connection as soon as lwip_close() returns, so anything that + // might dereference cached_sock_ post-close (e.g. setsockopt(TCP_NODELAY)) would + // otherwise touch an unrelated socket's pcb. No per-socket callback unhook is needed — + // all LwIP sockets share the same static event_callback. + this->cached_sock_ = nullptr; +#else + if (this->loop_monitored_) { + App.unregister_socket_fd(this->fd_); + } +#endif + int ret = lwip_close(this->fd_); + this->fd_ = -1; // Sentinel for "closed" — prevents double-close and makes use-after-close visible. + return ret; } int LwIPSocketImpl::setblocking(bool blocking) { diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 942d0ccf85..7f3b706cd8 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -85,12 +85,21 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } protected: + // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This + // replaces a separate closed_ flag: close() sets fd_ = -1 after lwip_close(), and the + // destructor / double-close path just check fd_ < 0. int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT - struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() -#endif - bool closed_{false}; + // Cached lwip_sock pointer used for direct rcvevent reads in ready() on the + // fast-select path. Replaces loop_monitored_: null means this socket is not being + // monitored for read events — either monitoring was not requested, the fd was + // invalid, or esphome_lwip_get_sock() failed. Non-null means the netconn event + // callback was hooked and notifications are flowing. close() nulls this to prevent + // use-after-free via a recycled lwip slot. + struct lwip_sock *cached_sock_{nullptr}; +#else bool loop_monitored_{false}; +#endif }; } // namespace esphome::socket diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index ad55e889e8..204113e4b2 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -42,8 +42,23 @@ using ListenSocket = LWIPRawListenImpl; #ifdef USE_LWIP_FAST_SELECT /// Shared ready() helper using cached lwip_sock pointer for direct rcvevent read. -inline bool socket_ready(struct lwip_sock *cached_sock, bool loop_monitored) { - return !loop_monitored || (cached_sock != nullptr && esphome_lwip_socket_has_data(cached_sock)); +/// cached_sock == nullptr means the socket is not monitored (monitor_loop was false, fd +/// was invalid, or esphome_lwip_get_sock() failed) — in that case return true so the +/// caller attempts the read and handles blocking itself. +inline bool socket_ready(struct lwip_sock *cached_sock) { + return cached_sock == nullptr || esphome_lwip_socket_has_data(cached_sock); +} + +/// Resolve an fd to its lwip_sock and install the netconn event-callback hook so the +/// main loop is woken by FreeRTOS task notifications when data arrives. Shared between +/// BSD and LwIP socket impls on the fast-select path. Returns the cached lwip_sock +/// pointer (or nullptr if the fd does not map to a valid lwip_sock). +inline struct lwip_sock *hook_fd_for_fast_select(int fd) { + struct lwip_sock *sock = esphome_lwip_get_sock(fd); + if (sock != nullptr) { + esphome_lwip_hook_socket(sock); + } + return sock; } #elif defined(USE_HOST) /// Shared ready() helper for fd-based socket implementations. @@ -69,7 +84,7 @@ bool socket_ready_fd(int fd, bool loop_monitored); #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) inline bool Socket::ready() const { #ifdef USE_LWIP_FAST_SELECT - return socket_ready(this->cached_sock_, this->loop_monitored_); + return socket_ready(this->cached_sock_); #else return socket_ready_fd(this->fd_, this->loop_monitored_); #endif diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index e5e1b36a65..affee20066 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -509,32 +509,7 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_LWIP_FAST_SELECT -bool Application::register_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - if (sock == nullptr) - return false; - esphome_lwip_hook_socket(sock); - this->monitored_sockets_.push_back(sock); - return true; -} - -void Application::unregister_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { - if (this->monitored_sockets_[i] != sock) - continue; - - // Swap with last element and pop - O(1) removal since order doesn't matter. - // No need to unhook the netconn callback — all LwIP sockets share the same - // static event_callback, and the socket will be closed by the caller. - if (i < this->monitored_sockets_.size() - 1) - this->monitored_sockets_[i] = this->monitored_sockets_.back(); - this->monitored_sockets_.pop_back(); - return; - } -} -#elif defined(USE_HOST) +#ifdef USE_HOST bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking diff --git a/esphome/core/application.h b/esphome/core/application.h index 645caa2404..a512af9c61 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -345,16 +345,13 @@ class Application { Scheduler scheduler; - /// Register/unregister a socket to be monitored for read events. - /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. -#ifdef USE_LWIP_FAST_SELECT - /// Fast select path: hooks netconn callback and registers for monitoring. - /// @return true if registration was successful, false if sock is null - bool register_socket(struct lwip_sock *sock); - void unregister_socket(struct lwip_sock *sock); -#elif defined(USE_HOST) - /// Fallback select() path: monitors file descriptors. +#ifdef USE_HOST + /// Register/unregister a socket file descriptor with the host select() fallback loop. + /// USE_LWIP_FAST_SELECT builds do not use this API — sockets hook the lwIP netconn + /// event_callback directly (see socket.h hook_fd_for_fast_select) and rely on FreeRTOS + /// task notifications for wake-up. /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. + /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. /// @return true if registration was successful, false if fd exceeds limits bool register_socket_fd(int fd); void unregister_socket_fd(int fd); @@ -488,9 +485,7 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop FixedVector looping_components_{}; -#ifdef USE_LWIP_FAST_SELECT - std::vector monitored_sockets_; // Cached lwip_sock pointers for direct rcvevent read -#elif defined(USE_HOST) +#ifdef USE_HOST std::vector socket_fds_; // Vector of all monitored socket file descriptors #endif #ifdef USE_HOST @@ -705,26 +700,16 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifndef USE_HOST inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { #ifdef USE_LWIP_FAST_SELECT - // Fast path (ESP32/LibreTiny): reads rcvevent directly from cached lwip_sock pointers. - // Safe because this runs on the main loop which owns socket lifetime (create, read, close). + // Fast path (ESP32/LibreTiny): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the delay_ms timeout. if (delay_ms == 0) [[unlikely]] { yield(); return; } - - // Check if any socket already has pending data before sleeping. - // If a socket still has unread data (rcvevent > 0) but the task notification was already - // consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency. - // This scan preserves select() semantics: return immediately when any fd is ready. - for (struct lwip_sock *sock : this->monitored_sockets_) { - if (esphome_lwip_socket_has_data(sock)) { - yield(); - return; - } - } - - // Sleep with instant wake via FreeRTOS task notification. - // Woken by: callback wrapper (socket data), wake_loop_threadsafe() (background tasks), or timeout. #endif esphome::internal::wakeable_delay(delay_ms); } From 37608c26560fe0ba7eefad7a0129074e6b7ccc2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:40:24 +0200 Subject: [PATCH 0376/2214] [ltr390] Reduce data polling delay and timeout (#15507) --- esphome/components/ltr390/ltr390.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index ba4a7ea5cb..033f31a3d1 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -45,6 +45,7 @@ optional LTR390Component::read_sensor_data_(LTR390MODE mode) { uint8_t buffer[num_bytes]; // Wait until data available + constexpr uint32_t max_wait_ms = 25; const uint32_t now = millis(); while (true) { std::bitset<8> status = this->reg(LTR390_MAIN_STATUS).get(); @@ -52,12 +53,12 @@ optional LTR390Component::read_sensor_data_(LTR390MODE mode) { if (available) break; - if (millis() - now > 100) { + if (millis() - now > max_wait_ms) { ESP_LOGW(TAG, "Sensor didn't return any data, aborting"); return {}; } - ESP_LOGD(TAG, "Waiting for data"); - delay(2); + ESP_LOGV(TAG, "Waiting for data"); + delay(1); } if (!this->read_bytes(MODEADDRESSES[mode], buffer, num_bytes)) { From 78875abee4a396b73b36557cbfa58ff5b51b3c1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:40:40 +0200 Subject: [PATCH 0377/2214] [core] Make buf_append_str PROGMEM-aware on ESP8266 (#15738) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/helpers.h | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index bb164f5034..78476bf596 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1079,7 +1079,33 @@ __attribute__((format(printf, 4, 5))) inline size_t buf_append_printf(char *buf, } #endif -/// Safely append a string to buffer without format parsing, returning new position (capped at size). +#ifdef USE_ESP8266 +/// Safely append a PROGMEM string to buffer, returning new position (capped at size). +/// ESP8266 internal implementation — prefer the `buf_append_str` macro which wraps +/// literals with `PSTR()` automatically so they stay in flash instead of eating RAM. +/// @param buf Output buffer +/// @param size Total buffer size +/// @param pos Current position in buffer +/// @param str PROGMEM-resident string to append (must not be null) +/// @return New position after appending; returns `size` if `pos >= size`, otherwise +/// returns at most `size - 1` because one byte is reserved for the null terminator +inline size_t buf_append_str_p(char *buf, size_t size, size_t pos, PGM_P str) { + if (pos >= size) { + return size; + } + size_t remaining = size - pos - 1; // reserve space for null terminator + size_t len = strnlen_P(str, remaining); + memcpy_P(buf + pos, str, len); + pos += len; + buf[pos] = '\0'; + return pos; +} +/// Safely append a string to buffer, returning new position (capped at size). +/// More efficient than buf_append_printf for plain string literals. +/// On ESP8266 the literal is wrapped with PSTR() so it stays in flash. +#define buf_append_str(buf, size, pos, str) buf_append_str_p(buf, size, pos, PSTR(str)) +#else +/// Safely append a string to buffer, returning new position (capped at size). /// More efficient than buf_append_printf for plain string literals. /// @param buf Output buffer /// @param size Total buffer size @@ -1091,15 +1117,13 @@ inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str return size; } size_t remaining = size - pos - 1; // reserve space for null terminator - size_t len = strlen(str); - if (len > remaining) { - len = remaining; - } + size_t len = strnlen(str, remaining); memcpy(buf + pos, str, len); pos += len; buf[pos] = '\0'; return pos; } +#endif /// Concatenate a name with a separator and suffix using an efficient stack-based approach. /// This avoids multiple heap allocations during string construction. From f05fa45747f2ae8f71928f99b161a8b7f0c5fd0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:41:13 +0200 Subject: [PATCH 0378/2214] [sensor] Specialize `throttle_with_priority` NaN-only case (#15823) --- esphome/components/sensor/__init__.py | 18 +++++++++++++++--- esphome/components/sensor/filter.cpp | 12 ++++++++++++ esphome/components/sensor/filter.h | 13 +++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index b658ff7056..8dcb7165e3 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -275,6 +275,9 @@ ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter) ThrottleWithPriorityFilter = sensor_ns.class_( "ThrottleWithPriorityFilter", ValueListFilter ) +ThrottleWithPriorityNanFilter = sensor_ns.class_( + "ThrottleWithPriorityNanFilter", Filter +) TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) @@ -656,9 +659,18 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( THROTTLE_WITH_PRIORITY_SCHEMA, ) async def throttle_with_priority_filter_to_code(config, filter_id): - if not isinstance(config[CONF_VALUE], list): - config[CONF_VALUE] = [config[CONF_VALUE]] - template_ = [await cg.templatable(x, [], cg.float_) for x in config[CONF_VALUE]] + values = config[CONF_VALUE] + if not isinstance(values, list): + values = [values] + # Specialize the common "NaN-only" case (the schema default when the user + # omits `value:`) to avoid the TemplatableFn array + NaN lambda the + # generic ValueListFilter path requires. Behavior is identical: NaN sensor + # readings always bypass the throttle. + if values and all(isinstance(v, float) and math.isnan(v) for v in values): + filter_id = filter_id.copy() + filter_id.type = ThrottleWithPriorityNanFilter + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) + template_ = [await cg.templatable(x, [], cg.float_) for x in values] return cg.new_Pvariable( filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ ) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index fbac7d3535..4896757d3f 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -269,6 +269,18 @@ optional throttle_with_priority_new_value(Sensor *parent, float value, co return {}; } +// ThrottleWithPriorityNanFilter +ThrottleWithPriorityNanFilter::ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs) + : min_time_between_inputs_(min_time_between_inputs) {} +optional ThrottleWithPriorityNanFilter::new_value(float value) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || std::isnan(value)) { + this->last_input_ = now; + return value; + } + return {}; +} + // DeltaFilter DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) : min_a0_(min_a0), min_a1_(min_a1), max_a0_(max_a0), max_a1_(max_a1) {} diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 0dbbc33ab3..a91d66a8fb 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -399,6 +399,19 @@ template class ThrottleWithPriorityFilter : public ValueListFilter uint32_t min_time_between_inputs_; }; +/// Specialization of ThrottleWithPriorityFilter for the common "prioritize NaN" +/// case: skips the TemplatableFn array + lambda and inlines the check. +class ThrottleWithPriorityNanFilter : public Filter { + public: + explicit ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs); + + optional new_value(float value) override; + + protected: + uint32_t last_input_{0}; + uint32_t min_time_between_inputs_; +}; + // Base class for timeout filters - contains common loop logic class TimeoutFilterBase : public Filter, public Component { public: From a8bd035b62b7ab9712e394fafdbdef9cd772bcde Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:44:25 +0200 Subject: [PATCH 0379/2214] Bump CodSpeedHQ/action from 4.13.1 to 4.14.0 (#15880) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aa5b2a547..20c349ac00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -339,7 +339,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4 + uses: CodSpeedHQ/action@658a901452bb54c799643e060733b7afe9121b8d # v4.14.0 with: run: ${{ steps.build.outputs.binary }} mode: simulation From 66e0c866d70c921862f096aecabb075bfef6ec5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 06:08:34 +0200 Subject: [PATCH 0380/2214] =?UTF-8?q?[core]=20rename=20after=5Floop=5Ftask?= =?UTF-8?q?s=5F=20=E2=86=92=20after=5Fcomponent=5Fphase=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the before_component_phase_ / after_component_phase_ symmetry now that Phase A and Phase B are separated. Also move the call to the very end of the `if (do_component_phase) { ... }` block so the helper semantically closes out the phase, rather than sitting before the last_loop_ / now bookkeeping. No functional change: in_loop_ is read only inside the component iteration (disable_looping_component's swap fixup), so setting it false at any point after the for-loop ends is equivalent. Runtime-stats tail timing still captures the same region (the micros() sample happens before any of the moved lines). --- esphome/components/runtime_stats/runtime_stats.h | 2 +- esphome/core/application.cpp | 2 +- esphome/core/application.h | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 24b6807008..7299fd7315 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -41,7 +41,7 @@ class RuntimeStatsCollector { // active_us = total time between loop start and just before yield. // before_us = time spent in Phase A (scheduler tick) excluding time // already attributed to per-component stats. - // tail_us = time spent in after_loop_tasks_ + the trailing record/stats + // tail_us = time spent in after_component_phase_ + the trailing record/stats // prefix. On Phase A-only ticks (component phase gated) this // is just the small trailing prefix between loop_before_end_us // and loop_now_us — non-zero but typically a few µs. diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b06d37ee44..b626eb1de6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -106,7 +106,7 @@ void Application::setup() { this->feed_wdt(); } - this->after_loop_tasks_(); + this->after_component_phase_(); yield(); } while (!component->can_proceed() && !component->is_failed()); } diff --git a/esphome/core/application.h b/esphome/core/application.h index 808a75d880..e1a6109f3e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -428,7 +428,7 @@ class Application { void activate_looping_component_(uint16_t index); inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); inline void ESPHOME_ALWAYS_INLINE before_component_phase_(); - inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; } + inline void ESPHOME_ALWAYS_INLINE after_component_phase_() { this->in_loop_ = false; } /// Process dump_config output one component per loop iteration. /// Extracted from loop() to keep cold startup/reconnect logging out of the hot path. @@ -694,10 +694,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifdef USE_RUNTIME_STATS loop_tail_start_us = micros(); #endif - this->after_loop_tasks_(); - this->last_loop_ = last_op_end_time; now = last_op_end_time; + this->after_component_phase_(); } #ifdef USE_RUNTIME_STATS From ecdbe0f9bb29f01a47fb922731663b8171b3bc15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 06:22:55 +0200 Subject: [PATCH 0381/2214] [runtime_stats] report tail_us=0 on Phase A-only ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously loop_tail_start_us defaulted to loop_before_end_us, so on ticks where Phase B was gated out (loop_interval_ not yet elapsed, no wake, no high-frequency request) tail_us measured "time from end of Phase A to stats recording" — i.e. gate-check + stats-prefix overhead — and was accumulated into the per-tick "tail" bucket even though no component tail had actually run. That mis-attributed gate-check + stats-prefix overhead to the tail metric, which is supposed to represent trailing overhead of the component phase specifically (after_component_phase_ + the little bit before record_loop_active). On a device whose loop_interval_ is raised for power savings, Phase A-only ticks dominate and the mis-attributed tail would skew the stats report. Fix: gate the tail_us computation on do_component_phase. Initialize loop_tail_start_us to 0 (unused on Phase A-only ticks) and only subtract from loop_now_us when Phase B ran. The overhead that used to land in "tail" now falls into "residual" (active − before − components − tail), which is the correct bucket for per-iteration bookkeeping that is not phase-specific. --- esphome/components/runtime_stats/runtime_stats.h | 6 +++--- esphome/core/application.h | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 7299fd7315..888d48e672 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -42,9 +42,9 @@ class RuntimeStatsCollector { // before_us = time spent in Phase A (scheduler tick) excluding time // already attributed to per-component stats. // tail_us = time spent in after_component_phase_ + the trailing record/stats - // prefix. On Phase A-only ticks (component phase gated) this - // is just the small trailing prefix between loop_before_end_us - // and loop_now_us — non-zero but typically a few µs. + // prefix. Only meaningful on component-phase ticks; reported + // as 0 on Phase A-only ticks (no component phase ran, so any + // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, diff --git a/esphome/core/application.h b/esphome/core/application.h index e1a6109f3e..d3851a32da 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -649,10 +649,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; - // Default tail_start to end-of-before so tail_us on Phase A-only ticks - // captures only the small gate-check + record_loop_active prefix between - // here and the loop_now_us sample below (not strictly zero, but tiny). - uint32_t loop_tail_start_us = loop_before_end_us; + // Only meaningful when do_component_phase is true; initialized to 0 so the + // tail bucket receives 0 on Phase A-only ticks (no component tail happened, + // the gate-check / stats-prefix overhead belongs to "residual", not "tail"). + uint32_t loop_tail_start_us = 0; #endif // Gate the component phase on loop_interval_, an active high-frequency @@ -711,8 +711,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us ? loop_before_wall_us - static_cast(loop_before_scheduled_us) : 0; - global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, - loop_now_us - loop_tail_start_us); + // tail_us is only defined when Phase B ran; 0 on Phase A-only ticks so the + // stats bucket keeps its "component-phase trailing overhead" meaning. + uint32_t loop_tail_us = do_component_phase ? (loop_now_us - loop_tail_start_us) : 0; + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, loop_tail_us); global_runtime_stats->process_pending_stats(now); } #endif From 05fd2f9a2f821cfdbe823436981032903a87522f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 07:02:50 +0200 Subject: [PATCH 0382/2214] [debug] Migrate trivial buf_append_printf sites to buf_append_str Convert buf_append_printf calls that use only literal format strings or %s specifiers over to buf_append_str, which avoids pulling in printf machinery on all platforms and keeps literals in flash on ESP8266 via the PSTR()-wrapping macro introduced in #15738. Format strings with numeric specifiers (PRIu32, %u, PRIX32, etc.) are kept on buf_append_printf. Runtime %s calls in debug_esp8266.cpp stay as-is because the ESP8266 buf_append_str macro requires a string literal for PSTR(). --- esphome/components/debug/debug_component.cpp | 2 +- esphome/components/debug/debug_esp32.cpp | 23 +++++++++++------ esphome/components/debug/debug_libretiny.cpp | 9 ++++--- esphome/components/debug/debug_zephyr.cpp | 26 ++++++++++++++------ 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 15f68c3a3b..d97a4aa135 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -30,7 +30,7 @@ void DebugComponent::dump_config() { char device_info_buffer[DEVICE_INFO_BUFFER_SIZE]; ESP_LOGD(TAG, "ESPHome version %s", ESPHOME_VERSION); - size_t pos = buf_append_printf(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, "%s", ESPHOME_VERSION); + size_t pos = buf_append_str(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, ESPHOME_VERSION); this->free_heap_ = get_free_heap_(); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 2e04090749..ea0c635207 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -224,17 +224,21 @@ size_t DebugComponent::get_device_info_(std::span const char *model = ESPHOME_VARIANT; // Build features string - pos = buf_append_printf(buf, size, pos, "|Chip: %s Features:", model); + pos = buf_append_str(buf, size, pos, "|Chip: "); + pos = buf_append_str(buf, size, pos, model); + pos = buf_append_str(buf, size, pos, " Features:"); bool first_feature = true; for (const auto &feature : CHIP_FEATURES) { if (info.features & feature.bit) { - pos = buf_append_printf(buf, size, pos, "%s%s", first_feature ? "" : ", ", feature.name); + pos = buf_append_str(buf, size, pos, first_feature ? "" : ", "); + pos = buf_append_str(buf, size, pos, feature.name); first_feature = false; info.features &= ~feature.bit; } } if (info.features != 0) { - pos = buf_append_printf(buf, size, pos, "%sOther:0x%" PRIx32, first_feature ? "" : ", ", info.features); + pos = buf_append_str(buf, size, pos, first_feature ? "" : ", "); + pos = buf_append_printf(buf, size, pos, "Other:0x%" PRIx32, info.features); } pos = buf_append_printf(buf, size, pos, " Cores:%u Revision:%u", info.cores, info.revision); @@ -267,17 +271,20 @@ size_t DebugComponent::get_device_info_(std::span // Framework detection #ifdef USE_ARDUINO ESP_LOGD(TAG, " Framework: Arduino"); - pos = buf_append_printf(buf, size, pos, "|Framework: Arduino"); + pos = buf_append_str(buf, size, pos, "|Framework: Arduino"); #else ESP_LOGD(TAG, " Framework: ESP-IDF"); - pos = buf_append_printf(buf, size, pos, "|Framework: ESP-IDF"); + pos = buf_append_str(buf, size, pos, "|Framework: ESP-IDF"); #endif - pos = buf_append_printf(buf, size, pos, "|ESP-IDF: %s", esp_get_idf_version()); + pos = buf_append_str(buf, size, pos, "|ESP-IDF: "); + pos = buf_append_str(buf, size, pos, esp_get_idf_version()); pos = buf_append_printf(buf, size, pos, "|EFuse MAC: %02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); - pos = buf_append_printf(buf, size, pos, "|Reset: %s", reset_reason); - pos = buf_append_printf(buf, size, pos, "|Wakeup: %s", wakeup_cause); + pos = buf_append_str(buf, size, pos, "|Reset: "); + pos = buf_append_str(buf, size, pos, reset_reason); + pos = buf_append_str(buf, size, pos, "|Wakeup: "); + pos = buf_append_str(buf, size, pos, wakeup_cause); return pos; } diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 1d458c602a..6f36debb95 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -38,9 +38,12 @@ size_t DebugComponent::get_device_info_(std::span lt_get_version(), lt_cpu_get_model_name(), lt_cpu_get_model(), lt_cpu_get_freq_mhz(), mac_id, lt_get_board_code(), flash_kib, ram_kib, reset_reason); - pos = buf_append_printf(buf, size, pos, "|Version: %s", LT_BANNER_STR + 10); - pos = buf_append_printf(buf, size, pos, "|Reset Reason: %s", reset_reason); - pos = buf_append_printf(buf, size, pos, "|Chip Name: %s", lt_cpu_get_model_name()); + pos = buf_append_str(buf, size, pos, "|Version: "); + pos = buf_append_str(buf, size, pos, LT_BANNER_STR + 10); + pos = buf_append_str(buf, size, pos, "|Reset Reason: "); + pos = buf_append_str(buf, size, pos, reset_reason); + pos = buf_append_str(buf, size, pos, "|Chip Name: "); + pos = buf_append_str(buf, size, pos, lt_cpu_get_model_name()); pos = buf_append_printf(buf, size, pos, "|Chip ID: 0x%06" PRIX32, mac_id); pos = buf_append_printf(buf, size, pos, "|Flash: %" PRIu32 " KiB", flash_kib); pos = buf_append_printf(buf, size, pos, "|RAM: %" PRIu32 " KiB", ram_kib); diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index d1580dae80..49790b5b9a 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -162,14 +162,18 @@ size_t DebugComponent::get_device_info_(std::span const char *supply_status = (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) ? "Normal voltage." : "High voltage."; ESP_LOGD(TAG, "Main supply status: %s", supply_status); - pos = buf_append_printf(buf, size, pos, "|Main supply status: %s", supply_status); + pos = buf_append_str(buf, size, pos, "|Main supply status: "); + pos = buf_append_str(buf, size, pos, supply_status); // Regulator stage 0 if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { const char *reg0_type = nrf_power_dcdcen_vddh_get(NRF_POWER) ? "DC/DC" : "LDO"; const char *reg0_voltage = regout0_to_str((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos); ESP_LOGD(TAG, "Regulator stage 0: %s, %s", reg0_type, reg0_voltage); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: %s, %s", reg0_type, reg0_voltage); + pos = buf_append_str(buf, size, pos, "|Regulator stage 0: "); + pos = buf_append_str(buf, size, pos, reg0_type); + pos = buf_append_str(buf, size, pos, ", "); + pos = buf_append_str(buf, size, pos, reg0_voltage); #ifdef USE_NRF52_REG0_VOUT if ((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos != USE_NRF52_REG0_VOUT) { ESP_LOGE(TAG, "Regulator stage 0: expected %s", regout0_to_str(USE_NRF52_REG0_VOUT)); @@ -177,13 +181,14 @@ size_t DebugComponent::get_device_info_(std::span #endif } else { ESP_LOGD(TAG, "Regulator stage 0: disabled"); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: disabled"); + pos = buf_append_str(buf, size, pos, "|Regulator stage 0: disabled"); } // Regulator stage 1 const char *reg1_type = nrf_power_dcdcen_get(NRF_POWER) ? "DC/DC" : "LDO"; ESP_LOGD(TAG, "Regulator stage 1: %s", reg1_type); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 1: %s", reg1_type); + pos = buf_append_str(buf, size, pos, "|Regulator stage 1: "); + pos = buf_append_str(buf, size, pos, reg1_type); // USB power state const char *usb_state; @@ -197,7 +202,8 @@ size_t DebugComponent::get_device_info_(std::span usb_state = "disconnected"; } ESP_LOGD(TAG, "USB power state: %s", usb_state); - pos = buf_append_printf(buf, size, pos, "|USB power state: %s", usb_state); + pos = buf_append_str(buf, size, pos, "|USB power state: "); + pos = buf_append_str(buf, size, pos, usb_state); // Power-fail comparator bool enabled; @@ -302,14 +308,18 @@ size_t DebugComponent::get_device_info_(std::span break; } ESP_LOGD(TAG, "Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: "); + pos = buf_append_str(buf, size, pos, pof_voltage); + pos = buf_append_str(buf, size, pos, ", VDDH: "); + pos = buf_append_str(buf, size, pos, vddh_voltage); } else { ESP_LOGD(TAG, "Power-fail comparator: %s", pof_voltage); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s", pof_voltage); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: "); + pos = buf_append_str(buf, size, pos, pof_voltage); } } else { ESP_LOGD(TAG, "Power-fail comparator: disabled"); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: disabled"); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: disabled"); } auto package = [](uint32_t value) { From 27dac716703084d1f8c94e1bbfdeb5cb45837d9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 07:37:25 +0200 Subject: [PATCH 0383/2214] no strnlen on zephyr --- esphome/core/helpers.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 78476bf596..6b71916cd2 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1117,7 +1117,10 @@ inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str return size; } size_t remaining = size - pos - 1; // reserve space for null terminator - size_t len = strnlen(str, remaining); + size_t len = 0; + while (len < remaining && str[len] != '\0') { + len++; + } memcpy(buf + pos, str, len); pos += len; buf[pos] = '\0'; From f74c3c0489a27074d26cbcd41bf21e089e238db2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 11:09:57 +0200 Subject: [PATCH 0384/2214] [zwave_proxy] Inline loop() hot-path fast-paths for response_handler_ and process_uart_ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split response_handler_ and process_uart_ into tiny inline wrappers in the header that short-circuit the common case, plus _slow_ bodies in the .cpp. - response_handler_: most loop() ticks have parsing_state_ outside the three SEND_* states; inline the range check and skip the call8 entirely. - process_uart_: most ticks have no UART bytes pending; inline an available() check and skip the out-of-line call + its stack frame. Inside the slow path, switch the while() to do/while() since the caller has already confirmed available() > 0. ESPHOME_ALWAYS_INLINE is required — with -Os gcc otherwise clones the wrapper into a shared \$isra\$ outline and keeps the call8. Measured on ESP32-S3 zwave-proxy-seeedw5500 build: idle-tick out-of-line calls in ZWaveProxy::loop() drop from 4 (response_handler_, api_is_connected, virtual parent_->is_connected, process_uart_ which nested available()) to 3 (api_is_connected, virtual is_connected, available). --- .../components/zwave_proxy/zwave_proxy.cpp | 10 ++++---- esphome/components/zwave_proxy/zwave_proxy.h | 24 +++++++++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index ecb38b25e7..8a24bd57d6 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -101,8 +101,10 @@ void ZWaveProxy::loop() { this->status_clear_warning(); } -void ZWaveProxy::process_uart_() { - while (this->available()) { +void ZWaveProxy::process_uart_slow_() { + // Caller (inline process_uart_) has already confirmed available() > 0, so use do/while to + // drain bytes — available() is still checked at the tail, but not redundantly on entry. + do { uint8_t byte; if (!this->read_byte(&byte)) { this->status_set_warning(LOG_STR("UART read failed")); @@ -137,7 +139,7 @@ void ZWaveProxy::process_uart_() { this->api_connection_->send_message(this->outgoing_proto_msg_); } } - } + } while (this->available()); } void ZWaveProxy::dump_config() { @@ -414,7 +416,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { } } -bool ZWaveProxy::response_handler_() { +bool ZWaveProxy::response_handler_slow_() { switch (this->parsing_state_) { case ZWAVE_PARSING_STATE_SEND_ACK: this->last_response_ = ZWAVE_FRAME_TYPE_ACK; diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 0b810de29f..283275d7b7 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -72,8 +72,28 @@ class ZWaveProxy : public uart::UARTDevice, public Component { void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) void parse_start_(uint8_t byte); - bool response_handler_(); - void process_uart_(); // Process all available UART data + // Inline fast-path: most calls happen with parsing_state_ outside the SEND_* range, so skip the + // out-of-line call entirely in the hot path (e.g. every loop() tick) and only pay for the real + // work when a response is actually pending. ESPHOME_ALWAYS_INLINE is required because with -Os + // gcc otherwise clones the wrapper into a shared $isra$ outline and keeps the call8. + ESPHOME_ALWAYS_INLINE bool response_handler_() { + if (this->parsing_state_ < ZWAVE_PARSING_STATE_SEND_ACK || this->parsing_state_ > ZWAVE_PARSING_STATE_SEND_NAK) { + return false; + } + return this->response_handler_slow_(); + } + bool response_handler_slow_(); + // Inline fast-path: UART::available() is cheap (ring-buffer head/tail compare on most backends). + // On an idle loop tick we want to skip the call to process_uart_ entirely. When bytes are + // pending we fall into the slow path, which drains the UART with a do/while so available() is + // only checked once per byte — no redundant re-check on entry. + ESPHOME_ALWAYS_INLINE void process_uart_() { + if (!this->available()) { + return; + } + this->process_uart_slow_(); + } + void process_uart_slow_(); // Drain all available UART data // Pre-allocated message - always ready to send api::ZWaveProxyFrame outgoing_proto_msg_; From b8de93aabe6396bb0871be42c0c700fce879034b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 11:14:12 +0200 Subject: [PATCH 0385/2214] [core] Inline api_is_connected() for hot-path callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the api_is_connected() definition from util.cpp to util.h and mark it ESPHOME_ALWAYS_INLINE. The body is trivial — a nullptr check on global_api_server plus APIServer::is_connected() (which is !clients_.empty()) — so the out-of-line call8 was pure overhead for hot paths that check connectivity every loop tick (e.g. zwave_proxy::loop, serial_proxy::loop). With USE_API disabled the function collapses to "return false" at compile time and folds away entirely. util.h now pulls in api_server.h under USE_API. Only 25 .cpp files include util.h and all of them are on USE_API builds in practice (wifi, safe_mode, nextion, web_server, etc.), so the additional transitive include is essentially free on the platforms that matter. Measured on ESP32-S3: the call8 in ZWaveProxy::loop() is replaced by 5 inline Xtensa instructions (load global_api_server, deref, null check, clients_ start/finish compare) — no call overhead, no stack frame. --- esphome/core/util.cpp | 13 ------------- esphome/core/util.h | 22 ++++++++++++++++++++-- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/esphome/core/util.cpp b/esphome/core/util.cpp index 996cf8e310..cb6ed671de 100644 --- a/esphome/core/util.cpp +++ b/esphome/core/util.cpp @@ -4,25 +4,12 @@ #include "esphome/core/version.h" #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - #ifdef USE_MQTT #include "esphome/components/mqtt/mqtt_client.h" #endif namespace esphome { -bool api_is_connected() { -#ifdef USE_API - if (api::global_api_server != nullptr) { - return api::global_api_server->is_connected(); - } -#endif - return false; -} - bool mqtt_is_connected() { #ifdef USE_MQTT if (mqtt::global_mqtt_client != nullptr) { diff --git a/esphome/core/util.h b/esphome/core/util.h index 1ca0173eab..8f90aa3411 100644 --- a/esphome/core/util.h +++ b/esphome/core/util.h @@ -1,10 +1,28 @@ #pragma once #include + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +#ifdef USE_API +#include "esphome/components/api/api_server.h" +#endif + namespace esphome { -/// Return whether the node has at least one client connected to the native API -bool api_is_connected(); +/// Return whether the node has at least one client connected to the native API. +/// +/// Inline so that hot-path callers (e.g. component loop() ticks that check connectivity every +/// iteration) can skip the call8/return pair. With USE_API disabled this trivially returns false +/// and collapses at compile time. +#ifdef USE_API +ESPHOME_ALWAYS_INLINE inline bool api_is_connected() { + return api::global_api_server != nullptr && api::global_api_server->is_connected(); +} +#else +ESPHOME_ALWAYS_INLINE inline bool api_is_connected() { return false; } +#endif /// Return whether the node has an active connection to an MQTT broker bool mqtt_is_connected(); From 413b9ca9ad638ee3192c37268a7614167e4b5e9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 11:25:46 +0200 Subject: [PATCH 0386/2214] [zwave_proxy] Address review: pin enum ordering and document slow-path precondition Add static_asserts that the SEND_ACK/CAN/NAK states stay contiguous, so the inline range check in response_handler_() fails at compile time if the enum is ever reordered. Document that process_uart_slow_() requires available() > 0 at the declaration (the .cpp definition already carries the rationale). --- esphome/components/zwave_proxy/zwave_proxy.h | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 283275d7b7..dc5dc46abc 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -38,6 +38,13 @@ enum ZWaveParsingState : uint8_t { ZWAVE_PARSING_STATE_READ_BL_MENU, }; +// response_handler_()'s inline fast-path relies on SEND_ACK/CAN/NAK being contiguous in this +// enum so a single range check (state - SEND_ACK < 3) is equivalent to three equality checks. +static_assert(ZWAVE_PARSING_STATE_SEND_CAN == ZWAVE_PARSING_STATE_SEND_ACK + 1, + "SEND_CAN must immediately follow SEND_ACK for response_handler_ fast-path"); +static_assert(ZWAVE_PARSING_STATE_SEND_NAK == ZWAVE_PARSING_STATE_SEND_ACK + 2, + "SEND_NAK must immediately follow SEND_CAN for response_handler_ fast-path"); + enum ZWaveProxyFeature : uint32_t { FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0, }; @@ -93,7 +100,10 @@ class ZWaveProxy : public uart::UARTDevice, public Component { } this->process_uart_slow_(); } - void process_uart_slow_(); // Drain all available UART data + // Precondition: caller must guarantee available() > 0 before invoking (see inline + // process_uart_ above). The slow path uses do/while and would otherwise set a spurious UART + // warning on entry if called with no bytes pending. + void process_uart_slow_(); // Pre-allocated message - always ready to send api::ZWaveProxyFrame outgoing_proto_msg_; From 3a72deddf925a9397e5db362e3e2502197e748a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 11:26:45 +0200 Subject: [PATCH 0387/2214] [core] Address review: drop redundant defines.h include from util.cpp util.h now includes defines.h unconditionally, so the explicit include in util.cpp is duplicative. --- esphome/core/util.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/core/util.cpp b/esphome/core/util.cpp index cb6ed671de..54a7956163 100644 --- a/esphome/core/util.cpp +++ b/esphome/core/util.cpp @@ -1,5 +1,4 @@ #include "esphome/core/util.h" -#include "esphome/core/defines.h" #include "esphome/core/application.h" #include "esphome/core/version.h" #include "esphome/core/log.h" From 250d28f69c20120b89e44112598076ca6fd862e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 11:54:13 +0200 Subject: [PATCH 0388/2214] [api] Replace clients_ std::vector with compile-time std::array + uint8_t count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert APIServer's client container from `std::vector>` to a compile-time sized `std::array<..., MAX_API_CONNECTIONS>` with an inline `uint8_t api_connection_count_` tracking the active slice. Why: - Eliminates a persistent heap-held buffer (one of APIServer's fragmentation sources). The vector grows via doubling reallocations (0 -> 1 -> 2 -> 4 -> 8) and keeps its capacity for the life of the process. - Kills ~100-150 bytes of `_M_realloc_insert` template instantiation per build (measured: -92 B on ESP32-S3, -152 B on ESP8266). - is_connected() becomes `api_connection_count_ != 0` — a single-byte load, down from two pointer loads + compare. - The count lives in what was already 1 byte of padding after shutting_down_, replacing the removed max_connections_ runtime setter. Zero size overhead in that slot. - Max connections is now a compile-time constant via cg.add_define("MAX_API_CONNECTIONS", ...), so the accept cap check and the array size derive from the same value. Also lower default max_connections on mid/high-RAM platforms from 8 to 6. With a compile-time array, every slot costs 4 bytes of static RAM whether a connection ever uses it or not — 6 covers HA + dashboard + spares without paying the full 8-slot static cost. esp8266 stays at 4, rp2040 stays at 4. Mechanical churn: - 13 `for (auto &c : this->clients_)` -> `for (auto &c : this->active_clients())`, where active_clients() returns a pointer-range view over the active slice. - `.empty()` / `.size()` -> comparisons against api_connection_count_. - `.emplace_back()` / `.pop_back()` / `.back()` rewritten in the swap-and-pop path to index into the array and reset() the freed slot (maintains the invariant that slots [count, N) are always nullptr). Measured on ESP32-S3 (zwave-proxy-seeedw5500, N=6): RAM: 32464 -> 32480 B (+16 B static, but no heap buffer) Flash: 469011 -> 468919 B (-92 B) Measured on ESP8266 (basic8266, N=4): RAM: 29268 -> 29268 B (unchanged at this granularity) Flash: 323847 -> 323695 B (-152 B) --- esphome/components/api/__init__.py | 16 +++---- esphome/components/api/api_server.cpp | 60 ++++++++++++++------------- esphome/components/api/api_server.h | 53 +++++++++++++++++++---- esphome/core/defines.h | 1 + 4 files changed, 88 insertions(+), 42 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 84589d540d..ce2377a3ed 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -291,12 +291,12 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault( CONF_MAX_CONNECTIONS, esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes - esp32=8, # 520KB RAM available + esp32=6, # 520KB RAM — 6 is enough for HA + dashboard + a couple of spares rp2040=4, # 264KB RAM but LWIP constraints - bk72xx=8, # Moderate RAM - rtl87xx=8, # Moderate RAM - host=8, # Abundant resources - ln882x=8, # Moderate RAM + bk72xx=6, # Moderate RAM + rtl87xx=6, # Moderate RAM + host=6, # Abundant resources, but array is compile-time so keep it reasonable + ln882x=6, # Moderate RAM ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size @@ -336,8 +336,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) if CONF_LISTEN_BACKLOG in config: cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) - if CONF_MAX_CONNECTIONS in config: - cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) + # MAX_API_CONNECTIONS sizes the compile-time std::array in APIServer. + # Making this a compile-time constant (rather than a runtime set_max_connections setter) + # eliminates the std::vector heap allocation and its reallocation machinery. + cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) # Set USE_API_USER_DEFINED_ACTIONS if any services are enabled diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index d9c3cc6846..77bd5f5c3c 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -118,7 +118,7 @@ void APIServer::loop() { this->accept_new_connections_(); } - if (this->clients_.empty()) { + if (this->api_connection_count_ == 0) { // Check reboot timeout - done in loop to avoid scheduler heap churn // (cancelled scheduler items sit in heap memory until their scheduled time) if (this->reboot_timeout_ != 0) { @@ -135,15 +135,15 @@ void APIServer::loop() { // Check network connectivity once for all clients if (!network::is_connected()) { // Network is down - disconnect all clients - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { client->on_fatal_error(); client->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Network down; disconnect")); } // Continue to process and clean up the clients below } - size_t client_index = 0; - while (client_index < this->clients_.size()) { + uint8_t client_index = 0; + while (client_index < this->api_connection_count_) { auto &client = this->clients_[client_index]; // Common case: process active client @@ -161,7 +161,7 @@ void APIServer::loop() { } } -void APIServer::remove_client_(size_t client_index) { +void APIServer::remove_client_(uint8_t client_index) { auto &client = this->clients_[client_index]; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -179,14 +179,18 @@ void APIServer::remove_client_(size_t client_index) { // Close socket now (was deferred from on_fatal_error to allow getpeername) client->helper_->close(); - // Swap with the last element and pop (avoids expensive vector shifts) - if (client_index < this->clients_.size() - 1) { - std::swap(this->clients_[client_index], this->clients_.back()); + // Swap with the last populated slot and shrink the active range by one. Resetting the + // now-unused trailing slot preserves the invariant that slots [api_connection_count_, N) + // are always nullptr, which makes debugging and defensive iteration safer. + const uint8_t last_index = this->api_connection_count_ - 1; + if (client_index < last_index) { + std::swap(this->clients_[client_index], this->clients_[last_index]); } - this->clients_.pop_back(); + this->clients_[last_index].reset(); + this->api_connection_count_--; // Last client disconnected - set warning and start tracking for reboot timeout - if (this->clients_.empty() && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { this->status_set_warning(LOG_STR("waiting for client connection")); this->last_connected_ = App.get_loop_component_start_time(); } @@ -210,8 +214,8 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { sock->getpeername_to(peername); // Check if we're at the connection limit - if (this->clients_.size() >= this->max_connections_) { - ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername); + if (this->api_connection_count_ >= MAX_API_CONNECTIONS) { + ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername); // Immediately close - socket destructor will handle cleanup sock.reset(); continue; @@ -220,11 +224,11 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { ESP_LOGD(TAG, "Accept %s", peername); auto *conn = new APIConnection(std::move(sock), this); - this->clients_.emplace_back(conn); + this->clients_[this->api_connection_count_++].reset(conn); conn->start(); // First client connected - clear warning and update timestamp - if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) { this->status_clear_warning(); this->last_connected_ = App.get_loop_component_start_time(); } @@ -237,7 +241,7 @@ void APIServer::dump_config() { " Address: %s:%u\n" " Listen backlog: %u\n" " Max connections: %u", - network::get_use_address(), this->port_, this->listen_backlog_, this->max_connections_); + network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); if (!this->noise_ctx_.has_psk()) { @@ -255,7 +259,7 @@ void APIServer::handle_disconnect(APIConnection *conn) {} void APIServer::on_##entity_name##_update(entity_type *obj) { /* NOLINT(bugprone-macro-parentheses) */ \ if (obj->is_internal()) \ return; \ - for (auto &c : this->clients_) { \ + for (auto &c : this->active_clients()) { \ if (c->flags_.state_subscription) \ c->send_##entity_name##_state(obj); \ } \ @@ -337,7 +341,7 @@ API_DISPATCH_UPDATE(water_heater::WaterHeater, water_heater) void APIServer::on_event(event::Event *obj) { if (obj->is_internal()) return; - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (c->flags_.state_subscription) c->send_event(obj); } @@ -349,7 +353,7 @@ void APIServer::on_event(event::Event *obj) { void APIServer::on_update(update::UpdateEntity *obj) { if (obj->is_internal()) return; - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (c->flags_.state_subscription) c->send_update_state(obj); } @@ -360,7 +364,7 @@ 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->clients_) + for (auto &c : this->active_clients()) c->send_message(msg); } #endif @@ -375,7 +379,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ resp.key = key; resp.timings = timings; - for (auto &c : this->clients_) + for (auto &c : this->active_clients()) c->send_infrared_rf_receive_event(resp); } #endif @@ -392,7 +396,7 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { client->send_homeassistant_action(call); } } @@ -532,7 +536,7 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString return; } ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { DisconnectRequest req; c->send_message(req); } @@ -583,7 +587,7 @@ bool APIServer::clear_noise_psk(bool make_active) { #ifdef USE_HOMEASSISTANT_TIME void APIServer::request_time() { - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { if (!client->flags_.remove && client->is_authenticated()) { client->send_time_request(); return; // Only request from one client to avoid clock conflicts @@ -593,7 +597,7 @@ void APIServer::request_time() { #endif bool APIServer::is_connected_with_state_subscription() const { - for (const auto &client : this->clients_) { + for (const auto &client : this->active_clients()) { if (client->flags_.state_subscription) { return true; } @@ -609,7 +613,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size // we would be filling a buffer we are trying to clear return; } - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (!c->flags_.remove && c->get_log_subscription_level() >= level) c->try_send_log_message(level, tag, message, message_len); } @@ -618,7 +622,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size #ifdef USE_CAMERA void APIServer::on_camera_image(const std::shared_ptr &image) { - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (!c->flags_.remove) c->set_camera_state(image); } @@ -635,7 +639,7 @@ void APIServer::on_shutdown() { this->batch_delay_ = 5; // Send disconnect requests to all connected clients - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { DisconnectRequest req; if (!c->send_message(req)) { // If we can't send the disconnect request directly (tx_buffer full), @@ -653,7 +657,7 @@ bool APIServer::teardown() { this->loop(); // Return true only when all clients have been torn down - return this->clients_.empty(); + return this->api_connection_count_ == 0; } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 65076879a2..4810cd5388 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -21,6 +21,8 @@ #include "esphome/components/camera/camera.h" #endif +#include +#include #include namespace esphome::api { @@ -63,7 +65,8 @@ class APIServer final : public Component, void set_batch_delay(uint16_t batch_delay); uint16_t get_batch_delay() const { return batch_delay_; } void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } - void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; } + // Max connections is a compile-time constant (MAX_API_CONNECTIONS) set from YAML via codegen; + // the array sizing and the accept-time cap both derive from it. // Get reference to shared buffer for API connections APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; } @@ -186,9 +189,38 @@ class APIServer final : public Component, void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif - bool is_connected() const { return !this->clients_.empty(); } + bool is_connected() const { return this->api_connection_count_ != 0; } bool is_connected_with_state_subscription() const; + // View over the active (populated) slice of clients_. Use this for range-for iteration so we + // don't walk past api_connection_count_ into unused slots. Pointer range keeps iterator + // semantics equivalent to std::vector>::iterator. + using APIConnectionPtr = std::unique_ptr; + class ActiveClientsView { + APIConnectionPtr *begin_; + APIConnectionPtr *end_; + + public: + ActiveClientsView(APIConnectionPtr *b, APIConnectionPtr *e) : begin_(b), end_(e) {} + APIConnectionPtr *begin() { return this->begin_; } + APIConnectionPtr *end() { return this->end_; } + }; + class ConstActiveClientsView { + const APIConnectionPtr *begin_; + const APIConnectionPtr *end_; + + public: + ConstActiveClientsView(const APIConnectionPtr *b, const APIConnectionPtr *e) : begin_(b), end_(e) {} + const APIConnectionPtr *begin() const { return this->begin_; } + const APIConnectionPtr *end() const { return this->end_; } + }; + ActiveClientsView active_clients() { + return {this->clients_.data(), this->clients_.data() + this->api_connection_count_}; + } + ConstActiveClientsView active_clients() const { + return {this->clients_.data(), this->clients_.data() + this->api_connection_count_}; + } + #ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { const char *entity_id; // Pointer to flash (internal) or heap (external) @@ -234,8 +266,8 @@ class APIServer final : public Component, protected: // Accept incoming socket connections. Only called when socket has pending connections. void __attribute__((noinline)) accept_new_connections_(); - // Remove a disconnected client by index. Swaps with last element and pops. - void __attribute__((noinline)) remove_client_(size_t client_index); + // Remove a disconnected client by index. Swaps with the last populated slot and resets it. + void __attribute__((noinline)) remove_client_(uint8_t client_index); #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, @@ -273,8 +305,12 @@ class APIServer final : public Component, uint32_t reboot_timeout_{300000}; uint32_t last_connected_{0}; + // Compile-time sized array of active API connections. + // Slots [0, api_connection_count_) are populated; slots [api_connection_count_, N) are kept as + // nullptr so the invariant "touching any live slot is safe" holds. No heap allocation, no + // std::vector reallocation machinery — eliminates a persistent heap-fragmentation source. + std::array, MAX_API_CONNECTIONS> clients_{}; // Vectors and strings (12 bytes each on 32-bit) - std::vector> clients_; // Shared proto write buffer for all connections. // Not pre-allocated: all send paths call prepare_first_message_buffer() which // reserves the exact needed size. Pre-allocating here would cause heap fragmentation @@ -309,10 +345,13 @@ class APIServer final : public Component, uint16_t port_{6053}; uint16_t batch_delay_{100}; // Connection limits - these defaults will be overridden by config values - // from cv.SplitDefault in __init__.py which sets platform-specific defaults + // from cv.SplitDefault in __init__.py which sets platform-specific defaults. uint8_t listen_backlog_{4}; - uint8_t max_connections_{8}; bool shutting_down_ = false; + // Active-slot count for clients_ (populated slots are [0, api_connection_count_)). + // Placed here to fill what used to be the 1-byte padding slot — zero size overhead versus the + // previous layout where this was the removed max_connections_ field. + uint8_t api_connection_count_{0}; // 7 bytes used, 1 byte padding #ifdef USE_API_NOISE diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d8b4faced9..0fb7221571 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -177,6 +177,7 @@ #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 +#define MAX_API_CONNECTIONS 6 #define USE_MD5 #define USE_SHA256 #define USE_MQTT From 82e961363147d7f2f953cb3db0c3f715275298a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 11:58:58 +0200 Subject: [PATCH 0389/2214] =?UTF-8?q?[api]=20Drop=20ConstActiveClientsView?= =?UTF-8?q?=20=E2=80=94=20single=20caller=20reads=20cleaner=20as=20indexed?= =?UTF-8?q?=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_connected_with_state_subscription() was the only const caller of active_clients(). Iterating by index directly in that method lets us drop the entire ConstActiveClientsView class from the header, leaving a single ActiveClientsView for the mutable range-for sites. --- esphome/components/api/api_server.cpp | 6 ++++-- esphome/components/api/api_server.h | 12 ------------ 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 77bd5f5c3c..1120d4c529 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -597,8 +597,10 @@ void APIServer::request_time() { #endif bool APIServer::is_connected_with_state_subscription() const { - for (const auto &client : this->active_clients()) { - if (client->flags_.state_subscription) { + // Indexed iteration (not active_clients()) because this method is const; keeps the view + // struct single-flavor in the header. + for (uint8_t i = 0; i < this->api_connection_count_; i++) { + if (this->clients_[i]->flags_.state_subscription) { return true; } } diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 4810cd5388..b411ed770d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -205,21 +205,9 @@ class APIServer final : public Component, APIConnectionPtr *begin() { return this->begin_; } APIConnectionPtr *end() { return this->end_; } }; - class ConstActiveClientsView { - const APIConnectionPtr *begin_; - const APIConnectionPtr *end_; - - public: - ConstActiveClientsView(const APIConnectionPtr *b, const APIConnectionPtr *e) : begin_(b), end_(e) {} - const APIConnectionPtr *begin() const { return this->begin_; } - const APIConnectionPtr *end() const { return this->end_; } - }; ActiveClientsView active_clients() { return {this->clients_.data(), this->clients_.data() + this->api_connection_count_}; } - ConstActiveClientsView active_clients() const { - return {this->clients_.data(), this->clients_.data() + this->api_connection_count_}; - } #ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { From 101eaa3222bbfa79a6ed6d98bded5c2a2676b606 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 11:59:53 +0200 Subject: [PATCH 0390/2214] [api] Keep host default at 8, only lower esp32/libretiny to 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert host=6 to host=8 — the host platform has no BSS-slot concern (no flash/RAM pressure). The lowering to 6 still applies to esp32/bk72xx/rtl87xx/ln882x where every array slot costs static RAM. Users who need more than 6 concurrent clients can bump max_connections up to the schema's max of 20. --- esphome/components/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ce2377a3ed..a51eeb7f08 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -295,7 +295,7 @@ CONFIG_SCHEMA = cv.All( rp2040=4, # 264KB RAM but LWIP constraints bk72xx=6, # Moderate RAM rtl87xx=6, # Moderate RAM - host=6, # Abundant resources, but array is compile-time so keep it reasonable + host=8, # Abundant resources, no BSS-slot concern ln882x=6, # Moderate RAM ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection From 3302903c39416924712ec0c3ee59d721e2834cc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 12:07:18 +0200 Subject: [PATCH 0391/2214] [api] Lower default max_connections to 5 on esp32/libretiny MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With N=5 the static-RAM trade becomes net-negative at just 1 client (the typical ESP32 deployment — just Home Assistant): +8 B static (5 slots × 4 B − 12 B removed vector object) −12 B heap (8 B header + 4 B slot) at 1 client = −4 B net N=6 had breakeven at 2 clients; N=5 puts breakeven at 1 client so the common case is pure win. 5 slots covers HA + dashboard + 1 reconnecting socket, which is all most deployments ever use. Users needing more can bump explicitly in YAML (schema max is 20). host stays at 8 (no BSS-slot concern). esp8266 stays at 4, rp2040 stays at 4. --- esphome/components/api/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index a51eeb7f08..86a87722f8 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -291,12 +291,12 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault( CONF_MAX_CONNECTIONS, esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes - esp32=6, # 520KB RAM — 6 is enough for HA + dashboard + a couple of spares + esp32=5, # 520KB RAM — 5 slots makes the static-RAM trade true net-negative at 1 client rp2040=4, # 264KB RAM but LWIP constraints - bk72xx=6, # Moderate RAM - rtl87xx=6, # Moderate RAM + bk72xx=5, # Moderate RAM — net-negative at 1 client + rtl87xx=5, # Moderate RAM — net-negative at 1 client host=8, # Abundant resources, no BSS-slot concern - ln882x=6, # Moderate RAM + ln882x=5, # Moderate RAM — net-negative at 1 client ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size From e89836d0d4e45564ab983d361444b5d513613cff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 12:10:52 +0200 Subject: [PATCH 0392/2214] [api] Trim overlong/stale comments from PR --- esphome/components/api/__init__.py | 17 +++++++---------- esphome/components/api/api_server.cpp | 7 ++----- esphome/components/api/api_server.h | 14 ++------------ 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 86a87722f8..88f01620c5 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -290,13 +290,13 @@ CONFIG_SCHEMA = cv.All( ): cv.int_range(min=1, max=10), cv.SplitDefault( CONF_MAX_CONNECTIONS, - esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes - esp32=5, # 520KB RAM — 5 slots makes the static-RAM trade true net-negative at 1 client - rp2040=4, # 264KB RAM but LWIP constraints - bk72xx=5, # Moderate RAM — net-negative at 1 client - rtl87xx=5, # Moderate RAM — net-negative at 1 client - host=8, # Abundant resources, no BSS-slot concern - ln882x=5, # Moderate RAM — net-negative at 1 client + esp8266=4, + esp32=5, + rp2040=4, + bk72xx=5, + rtl87xx=5, + host=8, + ln882x=5, ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size @@ -336,9 +336,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) if CONF_LISTEN_BACKLOG in config: cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) - # MAX_API_CONNECTIONS sizes the compile-time std::array in APIServer. - # Making this a compile-time constant (rather than a runtime set_max_connections setter) - # eliminates the std::vector heap allocation and its reallocation machinery. cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1120d4c529..4559168ece 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -179,9 +179,8 @@ void APIServer::remove_client_(uint8_t client_index) { // Close socket now (was deferred from on_fatal_error to allow getpeername) client->helper_->close(); - // Swap with the last populated slot and shrink the active range by one. Resetting the - // now-unused trailing slot preserves the invariant that slots [api_connection_count_, N) - // are always nullptr, which makes debugging and defensive iteration safer. + // Swap-and-reset: move the removed client to the trailing slot and null it out so slots + // [api_connection_count_, N) remain nullptr. const uint8_t last_index = this->api_connection_count_ - 1; if (client_index < last_index) { std::swap(this->clients_[client_index], this->clients_[last_index]); @@ -597,8 +596,6 @@ void APIServer::request_time() { #endif bool APIServer::is_connected_with_state_subscription() const { - // Indexed iteration (not active_clients()) because this method is const; keeps the view - // struct single-flavor in the header. for (uint8_t i = 0; i < this->api_connection_count_; i++) { if (this->clients_[i]->flags_.state_subscription) { return true; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index b411ed770d..19fcbc531c 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -65,8 +65,6 @@ class APIServer final : public Component, void set_batch_delay(uint16_t batch_delay); uint16_t get_batch_delay() const { return batch_delay_; } void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } - // Max connections is a compile-time constant (MAX_API_CONNECTIONS) set from YAML via codegen; - // the array sizing and the accept-time cap both derive from it. // Get reference to shared buffer for API connections APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; } @@ -192,9 +190,7 @@ class APIServer final : public Component, bool is_connected() const { return this->api_connection_count_ != 0; } bool is_connected_with_state_subscription() const; - // View over the active (populated) slice of clients_. Use this for range-for iteration so we - // don't walk past api_connection_count_ into unused slots. Pointer range keeps iterator - // semantics equivalent to std::vector>::iterator. + // Range-for view over the populated slice [0, api_connection_count_). using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { APIConnectionPtr *begin_; @@ -293,10 +289,7 @@ class APIServer final : public Component, uint32_t reboot_timeout_{300000}; uint32_t last_connected_{0}; - // Compile-time sized array of active API connections. - // Slots [0, api_connection_count_) are populated; slots [api_connection_count_, N) are kept as - // nullptr so the invariant "touching any live slot is safe" holds. No heap allocation, no - // std::vector reallocation machinery — eliminates a persistent heap-fragmentation source. + // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. std::array, MAX_API_CONNECTIONS> clients_{}; // Vectors and strings (12 bytes each on 32-bit) // Shared proto write buffer for all connections. @@ -336,9 +329,6 @@ class APIServer final : public Component, // from cv.SplitDefault in __init__.py which sets platform-specific defaults. uint8_t listen_backlog_{4}; bool shutting_down_ = false; - // Active-slot count for clients_ (populated slots are [0, api_connection_count_)). - // Placed here to fill what used to be the 1-byte padding slot — zero size overhead versus the - // previous layout where this was the removed max_connections_ field. uint8_t api_connection_count_{0}; // 7 bytes used, 1 byte padding From df53dff97a90bba10d5069783a0b43cf4cb4f9fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 12:11:40 +0200 Subject: [PATCH 0393/2214] [api] Restore original per-platform RAM rationale comments --- esphome/components/api/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 88f01620c5..ad778f20ad 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -290,13 +290,13 @@ CONFIG_SCHEMA = cv.All( ): cv.int_range(min=1, max=10), cv.SplitDefault( CONF_MAX_CONNECTIONS, - esp8266=4, - esp32=5, - rp2040=4, - bk72xx=5, - rtl87xx=5, - host=8, - ln882x=5, + esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes + esp32=5, # 520KB RAM available + rp2040=4, # 264KB RAM but LWIP constraints + bk72xx=5, # Moderate RAM + rtl87xx=5, # Moderate RAM + host=8, # Abundant resources + ln882x=5, # Moderate RAM ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size From 69896446c5ca50dca5697e764f295e3e88e126d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 12:13:12 +0200 Subject: [PATCH 0394/2214] [api] Address review: make ActiveClientsView read-only WRT ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the view to hold `const unique_ptr&` so external callers can't reset() or move slots — which would break the api_connection_count_ invariant. Callers still get a non-const APIConnection& on dereference (via `(*ptr).method()`), so all existing call sites work unchanged. active_clients() is now const-qualified since it only returns a view over const unique_ptrs. --- esphome/components/api/api_server.h | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 19fcbc531c..d6ac1a6d5d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -190,18 +190,20 @@ class APIServer final : public Component, bool is_connected() const { return this->api_connection_count_ != 0; } bool is_connected_with_state_subscription() const; - // Range-for view over the populated slice [0, api_connection_count_). + // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect + // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the + // APIConnection but cannot reset/move the slot and break the count invariant. using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { - APIConnectionPtr *begin_; - APIConnectionPtr *end_; + const APIConnectionPtr *begin_; + const APIConnectionPtr *end_; public: - ActiveClientsView(APIConnectionPtr *b, APIConnectionPtr *e) : begin_(b), end_(e) {} - APIConnectionPtr *begin() { return this->begin_; } - APIConnectionPtr *end() { return this->end_; } + ActiveClientsView(const APIConnectionPtr *b, const APIConnectionPtr *e) : begin_(b), end_(e) {} + const APIConnectionPtr *begin() const { return this->begin_; } + const APIConnectionPtr *end() const { return this->end_; } }; - ActiveClientsView active_clients() { + ActiveClientsView active_clients() const { return {this->clients_.data(), this->clients_.data() + this->api_connection_count_}; } From 26a656af296172ef140d7632e19bfe25f6d1873b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:47:07 -0400 Subject: [PATCH 0395/2214] [ld2412] Fix null deref in set_basic_config when entities unconfigured (#15893) --- esphome/components/ld2412/ld2412.cpp | 42 ++++++++++++++++------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index a502ae3c10..093e8c72dc 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -766,32 +766,38 @@ void LD2412Component::get_distance_resolution_() { this->send_command_(CMD_QUERY void LD2412Component::query_light_control_() { this->send_command_(CMD_QUERY_LIGHT_CONTROL, nullptr, 0); } void LD2412Component::set_basic_config() { + uint8_t min_gate = 1; + uint8_t max_gate = TOTAL_GATES; + uint16_t timeout = DEFAULT_PRESENCE_TIMEOUT; + uint8_t out_pin_level = 0x01; + #ifdef USE_NUMBER - if (!this->min_distance_gate_number_->has_state() || !this->max_distance_gate_number_->has_state() || - !this->timeout_number_->has_state()) { - return; + if (this->min_distance_gate_number_ != nullptr) { + if (!this->min_distance_gate_number_->has_state()) + return; + min_gate = static_cast(this->min_distance_gate_number_->state); + } + if (this->max_distance_gate_number_ != nullptr) { + if (!this->max_distance_gate_number_->has_state()) + return; + max_gate = static_cast(this->max_distance_gate_number_->state) + 1; + } + if (this->timeout_number_ != nullptr) { + if (!this->timeout_number_->has_state()) + return; + timeout = static_cast(this->timeout_number_->state); } #endif #ifdef USE_SELECT - if (!this->out_pin_level_select_->has_state()) { - return; + if (this->out_pin_level_select_ != nullptr) { + if (!this->out_pin_level_select_->has_state()) + return; + out_pin_level = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()); } #endif uint8_t value[5] = { -#ifdef USE_NUMBER - lowbyte(static_cast(this->min_distance_gate_number_->state)), - lowbyte(static_cast(this->max_distance_gate_number_->state) + 1), - lowbyte(static_cast(this->timeout_number_->state)), - highbyte(static_cast(this->timeout_number_->state)), -#else - 1, TOTAL_GATES, DEFAULT_PRESENCE_TIMEOUT, 0, -#endif -#ifdef USE_SELECT - find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()), -#else - 0x01, // Default value if not using select -#endif + lowbyte(min_gate), lowbyte(max_gate), lowbyte(timeout), highbyte(timeout), out_pin_level, }; this->set_config_mode_(true); this->send_command_(CMD_BASIC_CONF, value, sizeof(value)); From cb56f9a9bfd3705ab350e2c19d9e5f387b35ca09 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:47:16 -0400 Subject: [PATCH 0396/2214] [qmc5883l] Use GPIO interrupt when DRDY pin is configured (#15876) --- esphome/components/qmc5883l/qmc5883l.cpp | 36 ++++++++++++++++++++---- esphome/components/qmc5883l/qmc5883l.h | 5 ++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index d0488d0c9f..44bd006c1a 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -24,6 +24,8 @@ static const uint8_t QMC5883L_REGISTER_CONTROL_1 = 0x09; static const uint8_t QMC5883L_REGISTER_CONTROL_2 = 0x0A; static const uint8_t QMC5883L_REGISTER_PERIOD = 0x0B; +void IRAM_ATTR QMC5883LComponent::gpio_intr(QMC5883LComponent *arg) { arg->enable_loop_soon_any_context(); } + void QMC5883LComponent::setup() { // Soft Reset if (!this->write_byte(QMC5883L_REGISTER_CONTROL_2, 1 << 7)) { @@ -35,6 +37,12 @@ void QMC5883LComponent::setup() { if (this->drdy_pin_) { this->drdy_pin_->setup(); + if (this->drdy_pin_->is_internal()) { + static_cast(this->drdy_pin_) + ->attach_interrupt(&QMC5883LComponent::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + this->drdy_use_isr_ = true; + this->stop_poller(); + } } uint8_t control_1 = 0; @@ -65,8 +73,8 @@ void QMC5883LComponent::setup() { return; } - if (this->get_update_interval() < App.get_loop_interval()) { - high_freq_.start(); + if (!this->drdy_use_isr_ && this->get_update_interval() < App.get_loop_interval()) { + this->high_freq_.start(); } } @@ -84,16 +92,32 @@ void QMC5883LComponent::dump_config() { LOG_SENSOR(" ", "Heading", this->heading_sensor_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_PIN(" DRDY Pin: ", this->drdy_pin_); + if (this->drdy_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " DRDY mode: %s", + this->drdy_use_isr_ ? LOG_STR_LITERAL("interrupt") : LOG_STR_LITERAL("polling")); + } } void QMC5883LComponent::update() { - i2c::ErrorCode err; - uint8_t status = false; - - // If DRDY pin is configured and the data is not ready return. + // If DRDY is on an external expander we keep the polling path and early-return + // if data is not ready yet. Internal DRDY pins take the ISR path via loop(). if (this->drdy_pin_ && !this->drdy_pin_->digital_read()) { return; } + this->read_sensor_(); +} + +void QMC5883LComponent::loop() { + this->disable_loop(); + if (!this->drdy_use_isr_ || !this->drdy_pin_->digital_read()) { + return; + } + this->read_sensor_(); +} + +void QMC5883LComponent::read_sensor_() { + i2c::ErrorCode err; + uint8_t status = false; // Status byte gets cleared when data is read, so we have to read this first. // If status and two axes are desired, it's possible to save one byte of traffic by enabling diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index 21ef9c2a17..2ab6aa3e9f 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -32,6 +32,7 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { void setup() override; void dump_config() override; void update() override; + void loop() override; void set_drdy_pin(GPIOPin *pin) { drdy_pin_ = pin; } void set_datarate(QMC5883LDatarate datarate) { datarate_ = datarate; } @@ -44,6 +45,9 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } protected: + static void IRAM_ATTR gpio_intr(QMC5883LComponent *arg); + void read_sensor_(); + QMC5883LDatarate datarate_{QMC5883L_DATARATE_10_HZ}; QMC5883LRange range_{QMC5883L_RANGE_200_UT}; QMC5883LOversampling oversampling_{QMC5883L_SAMPLING_512}; @@ -53,6 +57,7 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *heading_sensor_{nullptr}; sensor::Sensor *temperature_sensor_{nullptr}; GPIOPin *drdy_pin_{nullptr}; + bool drdy_use_isr_{false}; enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, From f50409948551875d9626e1aa14a092d3a2738bf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 13:47:37 +0200 Subject: [PATCH 0397/2214] [api] Replace clients_ std::vector with compile-time std::array + uint8_t count (#15889) --- esphome/components/api/__init__.py | 11 +++-- esphome/components/api/api_server.cpp | 61 ++++++++++++++------------- esphome/components/api/api_server.h | 33 ++++++++++++--- esphome/core/defines.h | 1 + 4 files changed, 64 insertions(+), 42 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 84589d540d..ad778f20ad 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -291,12 +291,12 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault( CONF_MAX_CONNECTIONS, esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes - esp32=8, # 520KB RAM available + esp32=5, # 520KB RAM available rp2040=4, # 264KB RAM but LWIP constraints - bk72xx=8, # Moderate RAM - rtl87xx=8, # Moderate RAM + bk72xx=5, # Moderate RAM + rtl87xx=5, # Moderate RAM host=8, # Abundant resources - ln882x=8, # Moderate RAM + ln882x=5, # Moderate RAM ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size @@ -336,8 +336,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) if CONF_LISTEN_BACKLOG in config: cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) - if CONF_MAX_CONNECTIONS in config: - cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) + cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) # Set USE_API_USER_DEFINED_ACTIONS if any services are enabled diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index d9c3cc6846..4559168ece 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -118,7 +118,7 @@ void APIServer::loop() { this->accept_new_connections_(); } - if (this->clients_.empty()) { + if (this->api_connection_count_ == 0) { // Check reboot timeout - done in loop to avoid scheduler heap churn // (cancelled scheduler items sit in heap memory until their scheduled time) if (this->reboot_timeout_ != 0) { @@ -135,15 +135,15 @@ void APIServer::loop() { // Check network connectivity once for all clients if (!network::is_connected()) { // Network is down - disconnect all clients - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { client->on_fatal_error(); client->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Network down; disconnect")); } // Continue to process and clean up the clients below } - size_t client_index = 0; - while (client_index < this->clients_.size()) { + uint8_t client_index = 0; + while (client_index < this->api_connection_count_) { auto &client = this->clients_[client_index]; // Common case: process active client @@ -161,7 +161,7 @@ void APIServer::loop() { } } -void APIServer::remove_client_(size_t client_index) { +void APIServer::remove_client_(uint8_t client_index) { auto &client = this->clients_[client_index]; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -179,14 +179,17 @@ void APIServer::remove_client_(size_t client_index) { // Close socket now (was deferred from on_fatal_error to allow getpeername) client->helper_->close(); - // Swap with the last element and pop (avoids expensive vector shifts) - if (client_index < this->clients_.size() - 1) { - std::swap(this->clients_[client_index], this->clients_.back()); + // Swap-and-reset: move the removed client to the trailing slot and null it out so slots + // [api_connection_count_, N) remain nullptr. + const uint8_t last_index = this->api_connection_count_ - 1; + if (client_index < last_index) { + std::swap(this->clients_[client_index], this->clients_[last_index]); } - this->clients_.pop_back(); + this->clients_[last_index].reset(); + this->api_connection_count_--; // Last client disconnected - set warning and start tracking for reboot timeout - if (this->clients_.empty() && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { this->status_set_warning(LOG_STR("waiting for client connection")); this->last_connected_ = App.get_loop_component_start_time(); } @@ -210,8 +213,8 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { sock->getpeername_to(peername); // Check if we're at the connection limit - if (this->clients_.size() >= this->max_connections_) { - ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername); + if (this->api_connection_count_ >= MAX_API_CONNECTIONS) { + ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername); // Immediately close - socket destructor will handle cleanup sock.reset(); continue; @@ -220,11 +223,11 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { ESP_LOGD(TAG, "Accept %s", peername); auto *conn = new APIConnection(std::move(sock), this); - this->clients_.emplace_back(conn); + this->clients_[this->api_connection_count_++].reset(conn); conn->start(); // First client connected - clear warning and update timestamp - if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) { this->status_clear_warning(); this->last_connected_ = App.get_loop_component_start_time(); } @@ -237,7 +240,7 @@ void APIServer::dump_config() { " Address: %s:%u\n" " Listen backlog: %u\n" " Max connections: %u", - network::get_use_address(), this->port_, this->listen_backlog_, this->max_connections_); + network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); if (!this->noise_ctx_.has_psk()) { @@ -255,7 +258,7 @@ void APIServer::handle_disconnect(APIConnection *conn) {} void APIServer::on_##entity_name##_update(entity_type *obj) { /* NOLINT(bugprone-macro-parentheses) */ \ if (obj->is_internal()) \ return; \ - for (auto &c : this->clients_) { \ + for (auto &c : this->active_clients()) { \ if (c->flags_.state_subscription) \ c->send_##entity_name##_state(obj); \ } \ @@ -337,7 +340,7 @@ API_DISPATCH_UPDATE(water_heater::WaterHeater, water_heater) void APIServer::on_event(event::Event *obj) { if (obj->is_internal()) return; - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (c->flags_.state_subscription) c->send_event(obj); } @@ -349,7 +352,7 @@ void APIServer::on_event(event::Event *obj) { void APIServer::on_update(update::UpdateEntity *obj) { if (obj->is_internal()) return; - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (c->flags_.state_subscription) c->send_update_state(obj); } @@ -360,7 +363,7 @@ 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->clients_) + for (auto &c : this->active_clients()) c->send_message(msg); } #endif @@ -375,7 +378,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ resp.key = key; resp.timings = timings; - for (auto &c : this->clients_) + for (auto &c : this->active_clients()) c->send_infrared_rf_receive_event(resp); } #endif @@ -392,7 +395,7 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { client->send_homeassistant_action(call); } } @@ -532,7 +535,7 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString return; } ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { DisconnectRequest req; c->send_message(req); } @@ -583,7 +586,7 @@ bool APIServer::clear_noise_psk(bool make_active) { #ifdef USE_HOMEASSISTANT_TIME void APIServer::request_time() { - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { if (!client->flags_.remove && client->is_authenticated()) { client->send_time_request(); return; // Only request from one client to avoid clock conflicts @@ -593,8 +596,8 @@ void APIServer::request_time() { #endif bool APIServer::is_connected_with_state_subscription() const { - for (const auto &client : this->clients_) { - if (client->flags_.state_subscription) { + for (uint8_t i = 0; i < this->api_connection_count_; i++) { + if (this->clients_[i]->flags_.state_subscription) { return true; } } @@ -609,7 +612,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size // we would be filling a buffer we are trying to clear return; } - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (!c->flags_.remove && c->get_log_subscription_level() >= level) c->try_send_log_message(level, tag, message, message_len); } @@ -618,7 +621,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size #ifdef USE_CAMERA void APIServer::on_camera_image(const std::shared_ptr &image) { - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (!c->flags_.remove) c->set_camera_state(image); } @@ -635,7 +638,7 @@ void APIServer::on_shutdown() { this->batch_delay_ = 5; // Send disconnect requests to all connected clients - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { DisconnectRequest req; if (!c->send_message(req)) { // If we can't send the disconnect request directly (tx_buffer full), @@ -653,7 +656,7 @@ bool APIServer::teardown() { this->loop(); // Return true only when all clients have been torn down - return this->clients_.empty(); + return this->api_connection_count_ == 0; } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 65076879a2..d6ac1a6d5d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -21,6 +21,8 @@ #include "esphome/components/camera/camera.h" #endif +#include +#include #include namespace esphome::api { @@ -63,7 +65,6 @@ class APIServer final : public Component, void set_batch_delay(uint16_t batch_delay); uint16_t get_batch_delay() const { return batch_delay_; } void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } - void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; } // Get reference to shared buffer for API connections APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; } @@ -186,9 +187,26 @@ class APIServer final : public Component, void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif - bool is_connected() const { return !this->clients_.empty(); } + bool is_connected() const { return this->api_connection_count_ != 0; } bool is_connected_with_state_subscription() const; + // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect + // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the + // APIConnection but cannot reset/move the slot and break the count invariant. + using APIConnectionPtr = std::unique_ptr; + class ActiveClientsView { + const APIConnectionPtr *begin_; + const APIConnectionPtr *end_; + + public: + ActiveClientsView(const APIConnectionPtr *b, const APIConnectionPtr *e) : begin_(b), end_(e) {} + const APIConnectionPtr *begin() const { return this->begin_; } + const APIConnectionPtr *end() const { return this->end_; } + }; + ActiveClientsView active_clients() const { + return {this->clients_.data(), this->clients_.data() + this->api_connection_count_}; + } + #ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { const char *entity_id; // Pointer to flash (internal) or heap (external) @@ -234,8 +252,8 @@ class APIServer final : public Component, protected: // Accept incoming socket connections. Only called when socket has pending connections. void __attribute__((noinline)) accept_new_connections_(); - // Remove a disconnected client by index. Swaps with last element and pops. - void __attribute__((noinline)) remove_client_(size_t client_index); + // Remove a disconnected client by index. Swaps with the last populated slot and resets it. + void __attribute__((noinline)) remove_client_(uint8_t client_index); #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, @@ -273,8 +291,9 @@ class APIServer final : public Component, uint32_t reboot_timeout_{300000}; uint32_t last_connected_{0}; + // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. + std::array, MAX_API_CONNECTIONS> clients_{}; // Vectors and strings (12 bytes each on 32-bit) - std::vector> clients_; // Shared proto write buffer for all connections. // Not pre-allocated: all send paths call prepare_first_message_buffer() which // reserves the exact needed size. Pre-allocating here would cause heap fragmentation @@ -309,10 +328,10 @@ class APIServer final : public Component, uint16_t port_{6053}; uint16_t batch_delay_{100}; // Connection limits - these defaults will be overridden by config values - // from cv.SplitDefault in __init__.py which sets platform-specific defaults + // from cv.SplitDefault in __init__.py which sets platform-specific defaults. uint8_t listen_backlog_{4}; - uint8_t max_connections_{8}; bool shutting_down_ = false; + uint8_t api_connection_count_{0}; // 7 bytes used, 1 byte padding #ifdef USE_API_NOISE diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d8b4faced9..0fb7221571 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -177,6 +177,7 @@ #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 +#define MAX_API_CONNECTIONS 6 #define USE_MD5 #define USE_SHA256 #define USE_MQTT From e4d5886383eff13b0c86139102f8e04d49e2f993 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 13:48:16 +0200 Subject: [PATCH 0398/2214] [zwave_proxy] Inline loop() hot-path fast-paths for response_handler_ and process_uart_ (#15887) --- .../components/zwave_proxy/zwave_proxy.cpp | 10 +++--- esphome/components/zwave_proxy/zwave_proxy.h | 34 +++++++++++++++++-- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index ecb38b25e7..8a24bd57d6 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -101,8 +101,10 @@ void ZWaveProxy::loop() { this->status_clear_warning(); } -void ZWaveProxy::process_uart_() { - while (this->available()) { +void ZWaveProxy::process_uart_slow_() { + // Caller (inline process_uart_) has already confirmed available() > 0, so use do/while to + // drain bytes — available() is still checked at the tail, but not redundantly on entry. + do { uint8_t byte; if (!this->read_byte(&byte)) { this->status_set_warning(LOG_STR("UART read failed")); @@ -137,7 +139,7 @@ void ZWaveProxy::process_uart_() { this->api_connection_->send_message(this->outgoing_proto_msg_); } } - } + } while (this->available()); } void ZWaveProxy::dump_config() { @@ -414,7 +416,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { } } -bool ZWaveProxy::response_handler_() { +bool ZWaveProxy::response_handler_slow_() { switch (this->parsing_state_) { case ZWAVE_PARSING_STATE_SEND_ACK: this->last_response_ = ZWAVE_FRAME_TYPE_ACK; diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 0b810de29f..dc5dc46abc 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -38,6 +38,13 @@ enum ZWaveParsingState : uint8_t { ZWAVE_PARSING_STATE_READ_BL_MENU, }; +// response_handler_()'s inline fast-path relies on SEND_ACK/CAN/NAK being contiguous in this +// enum so a single range check (state - SEND_ACK < 3) is equivalent to three equality checks. +static_assert(ZWAVE_PARSING_STATE_SEND_CAN == ZWAVE_PARSING_STATE_SEND_ACK + 1, + "SEND_CAN must immediately follow SEND_ACK for response_handler_ fast-path"); +static_assert(ZWAVE_PARSING_STATE_SEND_NAK == ZWAVE_PARSING_STATE_SEND_ACK + 2, + "SEND_NAK must immediately follow SEND_CAN for response_handler_ fast-path"); + enum ZWaveProxyFeature : uint32_t { FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0, }; @@ -72,8 +79,31 @@ class ZWaveProxy : public uart::UARTDevice, public Component { void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) void parse_start_(uint8_t byte); - bool response_handler_(); - void process_uart_(); // Process all available UART data + // Inline fast-path: most calls happen with parsing_state_ outside the SEND_* range, so skip the + // out-of-line call entirely in the hot path (e.g. every loop() tick) and only pay for the real + // work when a response is actually pending. ESPHOME_ALWAYS_INLINE is required because with -Os + // gcc otherwise clones the wrapper into a shared $isra$ outline and keeps the call8. + ESPHOME_ALWAYS_INLINE bool response_handler_() { + if (this->parsing_state_ < ZWAVE_PARSING_STATE_SEND_ACK || this->parsing_state_ > ZWAVE_PARSING_STATE_SEND_NAK) { + return false; + } + return this->response_handler_slow_(); + } + bool response_handler_slow_(); + // Inline fast-path: UART::available() is cheap (ring-buffer head/tail compare on most backends). + // On an idle loop tick we want to skip the call to process_uart_ entirely. When bytes are + // pending we fall into the slow path, which drains the UART with a do/while so available() is + // only checked once per byte — no redundant re-check on entry. + ESPHOME_ALWAYS_INLINE void process_uart_() { + if (!this->available()) { + return; + } + this->process_uart_slow_(); + } + // Precondition: caller must guarantee available() > 0 before invoking (see inline + // process_uart_ above). The slow path uses do/while and would otherwise set a spurious UART + // warning on entry if called with no bytes pending. + void process_uart_slow_(); // Pre-allocated message - always ready to send api::ZWaveProxyFrame outgoing_proto_msg_; From 947c714f8936ffad5355a3bf9c72a2fbff3a5be2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 13:48:33 +0200 Subject: [PATCH 0399/2214] [core] Inline api_is_connected() for hot-path callers (#15888) --- esphome/core/util.cpp | 14 -------------- esphome/core/util.h | 22 ++++++++++++++++++++-- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/esphome/core/util.cpp b/esphome/core/util.cpp index 996cf8e310..54a7956163 100644 --- a/esphome/core/util.cpp +++ b/esphome/core/util.cpp @@ -1,28 +1,14 @@ #include "esphome/core/util.h" -#include "esphome/core/defines.h" #include "esphome/core/application.h" #include "esphome/core/version.h" #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - #ifdef USE_MQTT #include "esphome/components/mqtt/mqtt_client.h" #endif namespace esphome { -bool api_is_connected() { -#ifdef USE_API - if (api::global_api_server != nullptr) { - return api::global_api_server->is_connected(); - } -#endif - return false; -} - bool mqtt_is_connected() { #ifdef USE_MQTT if (mqtt::global_mqtt_client != nullptr) { diff --git a/esphome/core/util.h b/esphome/core/util.h index 1ca0173eab..8f90aa3411 100644 --- a/esphome/core/util.h +++ b/esphome/core/util.h @@ -1,10 +1,28 @@ #pragma once #include + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +#ifdef USE_API +#include "esphome/components/api/api_server.h" +#endif + namespace esphome { -/// Return whether the node has at least one client connected to the native API -bool api_is_connected(); +/// Return whether the node has at least one client connected to the native API. +/// +/// Inline so that hot-path callers (e.g. component loop() ticks that check connectivity every +/// iteration) can skip the call8/return pair. With USE_API disabled this trivially returns false +/// and collapses at compile time. +#ifdef USE_API +ESPHOME_ALWAYS_INLINE inline bool api_is_connected() { + return api::global_api_server != nullptr && api::global_api_server->is_connected(); +} +#else +ESPHOME_ALWAYS_INLINE inline bool api_is_connected() { return false; } +#endif /// Return whether the node has an active connection to an MQTT broker bool mqtt_is_connected(); From 1504ac3d19e2225f7713eabc4c20c8d543a91bfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 14:32:29 +0200 Subject: [PATCH 0400/2214] [core] Replace strnlen in buf_append_str for Zephyr compatibility (#15892) --- esphome/core/helpers.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 78476bf596..6b71916cd2 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1117,7 +1117,10 @@ inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str return size; } size_t remaining = size - pos - 1; // reserve space for null terminator - size_t len = strnlen(str, remaining); + size_t len = 0; + while (len < remaining && str[len] != '\0') { + len++; + } memcpy(buf + pos, str, len); pos += len; buf[pos] = '\0'; From e4f413adad74735d7dc61332c06376dd6904e9ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 14:48:21 +0200 Subject: [PATCH 0401/2214] [core] decouple main loop cadence from scheduler wake timing (#15792) --- .../runtime_stats/runtime_stats.cpp | 11 +- .../components/runtime_stats/runtime_stats.h | 20 ++- esphome/core/application.cpp | 9 +- esphome/core/application.h | 153 ++++++++++++------ esphome/core/wake.cpp | 16 ++ esphome/core/wake.h | 51 +++++- .../wake_test_component/__init__.py | 19 +++ .../wake_test_component.cpp | 19 +++ .../wake_test_component/wake_test_component.h | 27 ++++ .../fixtures/loop_interval_decoupling.yaml | 60 +++++++ ...p_interval_default_not_pulled_forward.yaml | 51 ++++++ .../fixtures/wake_loop_forces_phase_b.yaml | 52 ++++++ .../test_loop_interval_decoupling.py | 75 +++++++++ ...oop_interval_default_not_pulled_forward.py | 67 ++++++++ .../test_wake_loop_forces_phase_b.py | 76 +++++++++ 15 files changed, 644 insertions(+), 62 deletions(-) create mode 100644 tests/integration/fixtures/external_components/wake_test_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp create mode 100644 tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h create mode 100644 tests/integration/fixtures/loop_interval_decoupling.yaml create mode 100644 tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml create mode 100644 tests/integration/fixtures/wake_loop_forces_phase_b.yaml create mode 100644 tests/integration/test_loop_interval_decoupling.py create mode 100644 tests/integration/test_loop_interval_default_not_pulled_forward.py create mode 100644 tests/integration/test_wake_loop_forces_phase_b.py diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 9ed141155a..d733394b78 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -137,11 +137,12 @@ bool RuntimeStatsCollector::compare_total_time(Component *a, Component *b) { return a->runtime_stats_.total_time_us > b->runtime_stats_.total_time_us; } -void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) { - if ((int32_t) (current_time - this->next_log_time_) >= 0) { - this->log_stats_(); - this->next_log_time_ = current_time + this->log_interval_; - } +// Slow path for process_pending_stats — gate already checked by the inline +// wrapper in runtime_stats.h. Out-of-line keeps the log_stats_ machinery out +// of Application::loop(). +void RuntimeStatsCollector::process_pending_stats_slow_(uint32_t current_time) { + this->log_stats_(); + this->next_log_time_ = current_time + this->log_interval_; } } // namespace runtime_stats diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 82e0fb7c61..888d48e672 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -6,6 +6,7 @@ #include #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -26,14 +27,24 @@ class RuntimeStatsCollector { } uint32_t get_log_interval() const { return this->log_interval_; } - // Process any pending stats printing (should be called after component loop) - void process_pending_stats(uint32_t current_time); + // Process any pending stats printing. Called on every Application::loop() + // tick, so the common "not yet time to log" path must be cheap — inline + // the gate check and keep the actual logging work out-of-line. + void ESPHOME_ALWAYS_INLINE process_pending_stats(uint32_t current_time) { + if ((int32_t) (current_time - this->next_log_time_) >= 0) [[unlikely]] { + this->process_pending_stats_slow_(current_time); + } + } // Record the wall time of one main loop iteration excluding the yield/sleep. // Called once per loop from Application::loop(). // active_us = total time between loop start and just before yield. - // before_us = time spent in before_loop_tasks_ (scheduler + ISR enable_loop). - // tail_us = time spent in after_loop_tasks_ + the trailing record/stats prefix. + // before_us = time spent in Phase A (scheduler tick) excluding time + // already attributed to per-component stats. + // tail_us = time spent in after_component_phase_ + the trailing record/stats + // prefix. Only meaningful on component-phase ticks; reported + // as 0 on Phase A-only ticks (no component phase ran, so any + // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, @@ -55,6 +66,7 @@ class RuntimeStatsCollector { } protected: + void process_pending_stats_slow_(uint32_t current_time); void log_stats_(); // Static comparators — member functions have friend access, lambdas do not static bool compare_period_time(Component *a, Component *b); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index affee20066..b626eb1de6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -93,8 +93,11 @@ void Application::setup() { do { uint32_t now = millis(); - // Process pending loop enables to handle GPIO interrupts during setup - this->before_loop_tasks_(now); + // Service scheduler and process pending loop enables to handle GPIO + // interrupts during setup. During setup we always run the component + // phase (no loop_interval_ gate), so call both helpers unconditionally. + this->scheduler_tick_(now); + this->before_component_phase_(); for (uint32_t j = 0; j <= i; j++) { // Update loop_component_start_time_ right before calling each component @@ -103,7 +106,7 @@ void Application::setup() { this->feed_wdt(); } - this->after_loop_tasks_(); + this->after_component_phase_(); yield(); } while (!component->can_proceed() && !component->is_failed()); } diff --git a/esphome/core/application.h b/esphome/core/application.h index a512af9c61..d3851a32da 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -426,8 +426,9 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline uint32_t ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); - inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; } + inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); + inline void ESPHOME_ALWAYS_INLINE before_component_phase_(); + inline void ESPHOME_ALWAYS_INLINE after_component_phase_() { this->in_loop_ = false; } /// Process dump_config output one component per loop iteration. /// Extracted from loop() to keep cold startup/reconnect logging out of the hot path. @@ -582,16 +583,25 @@ inline void Application::drain_wake_notifications_() { } #endif // USE_HOST -inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { +// Phase A: drain wake notifications and run the scheduler. Invoked on every +// Application::loop() tick regardless of whether a component phase runs, so +// scheduler items fire at their requested cadence even when the caller has +// raised loop_interval_ for power savings (see Application::loop()). +// Returns the timestamp of the last scheduler item that ran (or `now` +// unchanged if none ran), so the caller's WDT feed stays monotonic with the +// per-item feeds inside scheduler.call() without an extra millis(). +inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif + return this->scheduler.call(now); +} - // Scheduler::call feeds the WDT per item and returns the timestamp of the - // last fired item, or the input unchanged when nothing ran. - uint32_t last_op_end_time = this->scheduler.call(loop_start_time); - +// Phase B entry: only invoked when a component loop phase is about to run. +// Processes pending enable_loop requests from ISRs and marks in_loop_ so +// reentrant modifications during component.loop() are safe. +inline void ESPHOME_ALWAYS_INLINE Application::before_component_phase_() { // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions if (this->has_pending_enable_loop_requests_) { @@ -608,7 +618,6 @@ inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t l // Mark that we're in the loop for safe reentrant modifications this->in_loop_ = true; - return last_op_end_time; } inline void ESPHOME_ALWAYS_INLINE Application::loop() { @@ -623,46 +632,77 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; #endif - // Get the initial loop time at the start - uint32_t last_op_end_time = millis(); + // Phase A: always service the scheduler. Decouples scheduler cadence from + // loop_interval_ so raised intervals (for power savings) don't drag scheduled + // items forward. A tick that only runs the scheduler is cheap. + // scheduler_tick_ returns the timestamp of the last scheduler item that ran + // (advanced by its per-item feeds) or `now` unchanged. We adopt it as `now` + // so the gate check and WDT feed both reflect actual elapsed time after + // scheduler dispatch, without an extra millis() call. + uint32_t now = this->scheduler_tick_(millis()); + // Guarantee one WDT feed per tick even when the scheduler had nothing to + // dispatch and the component phase is gated out — covers configs with no + // looping components and no scheduler work (setup() has its own + // per-component feed_wdt calls, so only do this here, not in scheduler_tick_). + this->feed_wdt_with_time(now); - // Returned timestamp keeps us monotonic with last_wdt_feed_ (advanced by - // the scheduler's per-item feeds) without an extra millis() call. - last_op_end_time = this->before_loop_tasks_(last_op_end_time); - // Guarantee a WDT touch every tick — covers configs with no looping - // components and no scheduler work, where the per-item / per-component - // feeds never fire. Rate-limited inline fast path, ~free when unneeded. - this->feed_wdt_with_time(last_op_end_time); #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; + // Only meaningful when do_component_phase is true; initialized to 0 so the + // tail bucket receives 0 on Phase A-only ticks (no component tail happened, + // the gate-check / stats-prefix overhead belongs to "residual", not "tail"). + uint32_t loop_tail_start_us = 0; #endif - for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; - this->current_loop_index_++) { - Component *component = this->looping_components_[this->current_loop_index_]; + // Gate the component phase on loop_interval_, an active high-frequency + // request, or an explicit wake from a background producer. A scheduler-only + // wake (e.g. set_interval firing under a raised loop_interval_) leaves the + // component phase gated; an external producer that called wake_loop_* + // (MQTT RX, USB RX, BLE event, etc.) needs the component phase to actually + // run so its component's loop() can drain the queued work — that is the + // long-standing semantic of wake_loop_threadsafe(), and the wake_request + // flag preserves it. wake_request_take() exchange-clears the flag; wakes + // that arrive during Phase B re-set it and run Phase B again on the next + // iteration. + const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency(); + const uint32_t elapsed = now - this->last_loop_; + const bool woke = esphome::wake_request_take(); + const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_); - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; + if (do_component_phase) { + this->before_component_phase_(); - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; - component->loop(); - // Use the finish method to get the current time as the end time - last_op_end_time = guard.finish(); + uint32_t last_op_end_time = now; + for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; + this->current_loop_index_++) { + Component *component = this->looping_components_[this->current_loop_index_]; + + // Update the cached time before each component runs + this->loop_component_start_time_ = last_op_end_time; + + { + this->set_current_component(component); + WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + component->loop(); + // Use the finish method to get the current time as the end time + last_op_end_time = guard.finish(); + } + this->feed_wdt_with_time(last_op_end_time); } - this->feed_wdt_with_time(last_op_end_time); + +#ifdef USE_RUNTIME_STATS + loop_tail_start_us = micros(); +#endif + this->last_loop_ = last_op_end_time; + now = last_op_end_time; + this->after_component_phase_(); } #ifdef USE_RUNTIME_STATS - uint32_t loop_tail_start_us = micros(); -#endif - this->after_loop_tasks_(); - -#ifdef USE_RUNTIME_STATS - // Process any pending runtime stats printing after all components have run - // This ensures stats printing doesn't affect component timing measurements + // Record per-tick timing on every loop, not just component-phase ticks. + // record_loop_active is a small accumulator; process_pending_stats is an + // inline gate check that early-outs unless now >= next_log_time_. if (global_runtime_stats != nullptr) { uint32_t loop_now_us = micros(); // Subtract scheduled-component time from the "before" bucket so it is @@ -671,25 +711,40 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us ? loop_before_wall_us - static_cast(loop_before_scheduled_us) : 0; - global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, - loop_now_us - loop_tail_start_us); - global_runtime_stats->process_pending_stats(last_op_end_time); + // tail_us is only defined when Phase B ran; 0 on Phase A-only ticks so the + // stats bucket keeps its "component-phase trailing overhead" meaning. + uint32_t loop_tail_us = do_component_phase ? (loop_now_us - loop_tail_start_us) : 0; + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, loop_tail_us); + global_runtime_stats->process_pending_stats(now); } #endif - // Use the last component's end time instead of calling millis() again + // Compute sleep: bounded by time-until-next-component-phase and the + // scheduler's next deadline. When a scheduler timer fires it re-enters + // loop(), Phase A services it, and the component phase stays gated by + // loop_interval_. When a background producer calls wake_loop_threadsafe() + // it sets the wake_request flag and wakes select() / the task notification; + // the gate above sees the flag and runs Phase B too so the producer's + // component can drain its queued work without waiting up to loop_interval_. + // + // Re-read HighFrequencyLoopRequester::is_high_frequency() here instead of + // reusing the cached `high_frequency` captured above: a component calling + // HighFrequencyLoopRequester::start() from within its loop() would + // otherwise sit under the stale value and sleep for up to loop_interval_ + // before the request took effect. That was fine pre-decoupling (the old + // main loop also called the function fresh at the sleep point) but now + // matters much more — loop_interval_ is a power-saving knob documented + // to accept multi-second values, so the stale path could add seconds of + // latency on an HF request. The call is a trivial atomic read. uint32_t delay_time = 0; - auto elapsed = last_op_end_time - this->last_loop_; - if (elapsed < this->loop_interval_ && !HighFrequencyLoopRequester::is_high_frequency()) { - delay_time = this->loop_interval_ - elapsed; - uint32_t next_schedule = this->scheduler.next_schedule_in(last_op_end_time).value_or(delay_time); - // next_schedule is max 0.5*delay_time - // otherwise interval=0 schedules result in constant looping with almost no sleep - next_schedule = std::max(next_schedule, delay_time / 2); - delay_time = std::min(next_schedule, delay_time); + if (!HighFrequencyLoopRequester::is_high_frequency()) { + const uint32_t elapsed_since_phase = now - this->last_loop_; + const uint32_t until_phase = + (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); + const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); + delay_time = std::min(until_phase, until_sched); } this->yield_with_select_(delay_time); - this->last_loop_ = last_op_end_time; if (this->dump_config_at_ < this->components_.size()) { this->process_dump_config_(); diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index 3709fa88ac..cebc4d04b7 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -12,9 +12,22 @@ namespace esphome { +// === Wake-requested flag storage === +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic g_wake_requested{0}; +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +#endif + // === ESP32 / LibreTiny — IRAM_ATTR entry points === #if defined(USE_ESP32) || defined(USE_LIBRETINY) void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + // ISR-safe: set flag before notify so the wake is visible on the next gate + // check. wake_request_set() is just an aligned 8-bit store / atomic store + // and is safe from IRAM. + wake_request_set(); esphome_main_task_notify_from_isr(px_higher_priority_task_woken); } void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } @@ -72,6 +85,9 @@ void wakeable_delay(uint32_t ms) { // === Host (UDP loopback socket) === #ifdef USE_HOST void wake_loop_threadsafe() { + // Set flag before sending so the consumer's gate check on the next loop() + // entry observes the wake regardless of select() scheduling. + wake_request_set(); if (App.wake_socket_fd_ >= 0) { const char dummy = 1; ::send(App.wake_socket_fd_, &dummy, 1, 0); diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 77a38d429e..41b7ab33b5 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -7,6 +7,10 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#include +#endif + #if defined(USE_ESP32) || defined(USE_LIBRETINY) #include "esphome/core/main_task.h" #endif @@ -25,12 +29,48 @@ namespace esphome { extern volatile bool g_main_loop_woke; #endif +// === wake_request flag — signals Application::loop() that a producer queued +// work for some component's loop() to drain (MQTT RX, USB RX, BLE event, etc.) +// and the component phase should run this tick instead of being held off by +// the loop_interval_ gate. Set by every wake_loop_* entry point; consumed +// (via exchange-and-clear) at the gate in Application::loop(). === +// +// std::atomic rather than std::atomic because GCC on Xtensa +// generates an indirect function call for atomic ops instead of inlining +// them — same workaround applied in scheduler.h for the SchedulerItem::remove +// flag. On non-atomic platforms a volatile uint8_t suffices: 8-bit aligned +// loads/stores are atomic on every supported MCU, and the platform signal +// that follows wake_request_set() (FreeRTOS task-notify, esp_schedule, socket +// send) provides the cross-thread/cross-core memory barrier. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern std::atomic g_wake_requested; + +__attribute__((always_inline)) inline void wake_request_set() { g_wake_requested.store(1, std::memory_order_release); } +__attribute__((always_inline)) inline bool wake_request_take() { + return g_wake_requested.exchange(0, std::memory_order_acquire) != 0; +} +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern volatile uint8_t g_wake_requested; + +__attribute__((always_inline)) inline void wake_request_set() { g_wake_requested = 1; } +__attribute__((always_inline)) inline bool wake_request_take() { + uint8_t v = g_wake_requested; + g_wake_requested = 0; + return v != 0; +} +#endif + // === ESP32 / LibreTiny (FreeRTOS) === #if defined(USE_ESP32) || defined(USE_LIBRETINY) /// Wake the main loop from any context (ISR or task). /// always_inline so callers placed in IRAM keep the whole wake path in IRAM. __attribute__((always_inline)) inline void wake_main_task_any_context() { + // Set the wake-requested flag BEFORE the task notification so the consumer + // (Application::loop() gate) is guaranteed to see it on its next gate check. + wake_request_set(); if (in_isr_context()) { BaseType_t px_higher_priority_task_woken = pdFALSE; esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); @@ -50,7 +90,10 @@ __attribute__((always_inline)) inline void wake_main_task_any_context() { void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); void wake_loop_any_context(); -inline void wake_loop_threadsafe() { esphome_main_task_notify(); } +inline void wake_loop_threadsafe() { + wake_request_set(); + esphome_main_task_notify(); +} namespace internal { inline void wakeable_delay(uint32_t ms) { @@ -67,6 +110,9 @@ inline void wakeable_delay(uint32_t ms) { /// Inline implementation — IRAM callers inline this directly. 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(); g_main_loop_woke = true; esp_schedule(); } @@ -98,6 +144,9 @@ inline void wakeable_delay(uint32_t ms) { #elif defined(USE_RP2040) inline void wake_loop_any_context() { + // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed + // to see it on its next gate check. + wake_request_set(); g_main_loop_woke = true; __sev(); } diff --git a/tests/integration/fixtures/external_components/wake_test_component/__init__.py b/tests/integration/fixtures/external_components/wake_test_component/__init__.py new file mode 100644 index 0000000000..ce24167889 --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/__init__.py @@ -0,0 +1,19 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@esphome/tests"] + +wake_test_component_ns = cg.esphome_ns.namespace("wake_test_component") +WakeTestComponent = wake_test_component_ns.class_("WakeTestComponent", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(WakeTestComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp new file mode 100644 index 0000000000..b58f1c9adc --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp @@ -0,0 +1,19 @@ +#include "wake_test_component.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include +#include + +namespace esphome::wake_test_component { + +static const char *const TAG = "wake_test_component"; + +void WakeTestComponent::start_async_wake() { + ESP_LOGI(TAG, "Spawning async wake thread (50ms delay)"); + std::thread([] { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + App.wake_loop_threadsafe(); + }).detach(); +} + +} // namespace esphome::wake_test_component diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h new file mode 100644 index 0000000000..c8e4e0a89f --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/component.h" +#include + +namespace esphome::wake_test_component { + +class WakeTestComponent : public Component { + public: + void setup() override {} + void loop() override { this->loop_count_.fetch_add(1, std::memory_order_relaxed); } + + int get_loop_count() const { return this->loop_count_.load(std::memory_order_relaxed); } + + // Spawn a detached thread that sleeps briefly then calls + // App.wake_loop_threadsafe(). Used by the integration test to verify a + // cross-thread wake forces a component-phase iteration even when + // loop_interval_ has been raised high enough to gate it off otherwise. + void start_async_wake(); + + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + std::atomic loop_count_{0}; +}; + +} // namespace esphome::wake_test_component diff --git a/tests/integration/fixtures/loop_interval_decoupling.yaml b/tests/integration/fixtures/loop_interval_decoupling.yaml new file mode 100644 index 0000000000..5aedd9aba5 --- /dev/null +++ b/tests/integration/fixtures/loop_interval_decoupling.yaml @@ -0,0 +1,60 @@ +esphome: + name: loop-interval-decouple + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 500ms. With the decoupling fix the + // component phase should run ~twice per second while the 50ms + // scheduler interval below still fires at its requested cadence. + App.set_loop_interval(500); + # Start measurement after 1s so boot transients settle. + - delay: 1000ms + - lambda: |- + id(loop_at_start) = id(loop_counter)->get_loop_count(); + id(sched_at_start) = id(sched_count); + ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d sched=%d", + id(loop_at_start), id(sched_at_start)); + # Observe for 2s. + - delay: 2000ms + - lambda: |- + int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start); + int sched_delta = id(sched_count) - id(sched_at_start); + ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d sched_delta=%d", + loop_delta, sched_delta); + +host: +api: +logger: + level: INFO + logs: + loop_test_component: WARN # Silence per-loop log spam + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +globals: + - id: sched_count + type: int + initial_value: "0" + - id: loop_at_start + type: int + initial_value: "0" + - id: sched_at_start + type: int + initial_value: "0" + +loop_test_component: + components: + - id: loop_counter + name: loop_counter + +interval: + # Fast scheduler interval — with the decoupling fix this should fire at + # its requested 50ms cadence regardless of loop_interval_. + - interval: 50ms + then: + - lambda: |- + id(sched_count) += 1; diff --git a/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml b/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml new file mode 100644 index 0000000000..fec83865b9 --- /dev/null +++ b/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml @@ -0,0 +1,51 @@ +esphome: + name: loop-default-not-pulled + on_boot: + priority: -100 + then: + # Leave loop_interval_ at its default (16 ms → ~62 Hz). Do NOT call + # set_loop_interval here. The fast scheduler interval below used to + # pull the component phase forward to ~128 Hz via the old + # std::max(next_schedule, delay_time / 2) floor. + # Start measurement after 1s so boot transients settle. + - delay: 1000ms + - lambda: |- + id(loop_at_start) = id(loop_counter)->get_loop_count(); + ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d", id(loop_at_start)); + # Observe for 2s. + - delay: 2000ms + - lambda: |- + int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start); + ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d", loop_delta); + +host: +api: +logger: + level: INFO + logs: + loop_test_component: WARN # Silence per-loop log spam + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +globals: + - id: loop_at_start + type: int + initial_value: "0" + +loop_test_component: + components: + - id: loop_counter + name: loop_counter + +interval: + # Fast scheduler interval (well under loop_interval_/2 = 8ms). In the + # pre-decoupling code this would have pulled the component phase forward + # to ~128 Hz. After the decoupling fix the component phase stays at + # ~62 Hz regardless. + - interval: 5ms + then: + - lambda: |- + // No-op; the presence of a due scheduler item is what matters. diff --git a/tests/integration/fixtures/wake_loop_forces_phase_b.yaml b/tests/integration/fixtures/wake_loop_forces_phase_b.yaml new file mode 100644 index 0000000000..d97ab8514f --- /dev/null +++ b/tests/integration/fixtures/wake_loop_forces_phase_b.yaml @@ -0,0 +1,52 @@ +esphome: + name: wake-loop-phase-b + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 2000ms. Without the wake-request flag, + // a wake_loop_threadsafe() call would only run Phase A (scheduler) + // and leave the component phase gated for ~2s. + App.set_loop_interval(2000); + # Let boot transients settle. + - delay: 1000ms + - lambda: |- + // Snapshot the loop counter, then ask the component to spawn a + // background thread that calls App.wake_loop_threadsafe() after + // ~50ms. With the fix, that wake forces Phase B on the next tick + // and the counter increments well within the 500ms observation + // window below. + id(count_at_start) = id(wake_counter)->get_loop_count(); + id(start_time) = millis(); + id(wake_counter)->start_async_wake(); + ESP_LOGI("test", "WAKE_STARTED count=%d", id(count_at_start)); + # Observation window must be much shorter than loop_interval_ (2000ms) + # so a "false pass" isn't possible by simply waiting out the gate. + - delay: 500ms + - lambda: |- + int count_now = id(wake_counter)->get_loop_count(); + int delta = count_now - id(count_at_start); + uint32_t elapsed = millis() - id(start_time); + ESP_LOGI("test", "WAKE_RESULT delta=%d elapsed=%u", delta, elapsed); + +host: +api: +logger: + level: INFO + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [wake_test_component] + +globals: + - id: count_at_start + type: int + initial_value: "0" + - id: start_time + type: uint32_t + initial_value: "0" + +wake_test_component: + id: wake_counter diff --git a/tests/integration/test_loop_interval_decoupling.py b/tests/integration/test_loop_interval_decoupling.py new file mode 100644 index 0000000000..6c34aed458 --- /dev/null +++ b/tests/integration/test_loop_interval_decoupling.py @@ -0,0 +1,75 @@ +"""Test that loop_interval_ no longer clamps scheduler cadence. + +Regression test for the decoupling of Application::loop() component-phase +cadence from scheduler wake timing. + +Setup: +- App.set_loop_interval(500) — raised for power-savings style cadence +- Scheduler interval at 50ms — should fire at 50ms regardless of loop_interval_ +- Component loop (LoopTestComponent) — should run at 500ms cadence + +Before the decoupling fix the old `std::max(next_schedule, delay_time / 2)` +floor clamped the sleep to ~250ms, so the 50ms scheduler only fired ~8 times +per 2s (vs the ~40 expected). After the fix the scheduler fires close to its +requested cadence while the component phase stays gated at loop_interval_. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_loop_interval_decoupling( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Raised loop_interval_ must not clamp scheduler item cadence.""" + loop = asyncio.get_running_loop() + measurement_done: asyncio.Future[tuple[int, int]] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)", line) + if match and not measurement_done.done(): + measurement_done.set_result((int(match.group(1)), int(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "loop-interval-decouple" + + try: + loop_delta, sched_delta = await asyncio.wait_for( + measurement_done, timeout=10.0 + ) + except TimeoutError: + pytest.fail("MEASUREMENT_DONE marker never appeared") + + # Observation window = 2s, loop_interval_ = 500ms. + # Component phase should fire ~4 times in 2s. The upper bound must be + # less than 8: the pre-decoupling behavior clamped to ~250ms cadence + # giving ~8 loops/2s, so allowing 8 would let the old behavior pass. + # Lower bound 3 (not 2) keeps the test honest: a >30% slowdown from + # the ~4 nominal is not normal CI jitter and should fail. + assert 3 <= loop_delta <= 6, ( + f"Component loop should fire ~4 times in 2s at loop_interval=500ms, " + f"got {loop_delta}" + ) + + # Scheduler interval = 50ms → ~40 fires in 2s. Before the decoupling + # fix this clamped to ~8 fires. Assert >= 20 to catch the old clamped + # behavior with comfortable jitter headroom for slow CI hosts. + assert sched_delta >= 20, ( + f"50ms scheduler interval should fire ~40 times in 2s but only " + f"fired {sched_delta}. This indicates loop_interval_ is still " + f"clamping scheduler cadence." + ) diff --git a/tests/integration/test_loop_interval_default_not_pulled_forward.py b/tests/integration/test_loop_interval_default_not_pulled_forward.py new file mode 100644 index 0000000000..17a7070436 --- /dev/null +++ b/tests/integration/test_loop_interval_default_not_pulled_forward.py @@ -0,0 +1,67 @@ +"""Test that a fast scheduler item does not pull the component phase forward. + +Regression test for the original ~128 Hz → ~62 Hz bug fixed by decoupling +Application::loop() component-phase cadence from scheduler wake timing. + +Setup: +- loop_interval_ left at its default (16 ms → ~62 Hz component phase). +- Scheduler interval at 5 ms (well under the old loop_interval_/2 = 8 ms floor). + +Before the decoupling fix the ``std::max(next_schedule, delay_time / 2)`` floor +clamped the sleep to ~8 ms whenever any scheduler item was due sooner than +loop_interval_/2. That pulled the component phase forward to ~128 Hz — twice +what the documented ~62 Hz default promised. After the fix the component +phase stays at ~62 Hz regardless of scheduler activity. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_loop_interval_default_not_pulled_forward( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Fast scheduler item must not pull component phase past default ~62 Hz.""" + loop = asyncio.get_running_loop() + measurement_done: asyncio.Future[int] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+)", line) + if match and not measurement_done.done(): + measurement_done.set_result(int(match.group(1))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "loop-default-not-pulled" + + try: + loop_delta = await asyncio.wait_for(measurement_done, timeout=10.0) + except TimeoutError: + pytest.fail("MEASUREMENT_DONE marker never appeared") + + # Observation window = 2s, loop_interval_ default = 16ms → ~62 Hz → + # ~125 component-phase iterations expected. + # Pre-fix behavior: the 5 ms scheduler interval tripped the old + # delay_time/2 = 8 ms floor, pulling the phase to ~128 Hz → ~256. + # Upper bound 180 is comfortably below the ~256 pre-fix rate but + # above the ~125 nominal with CI jitter. + # Lower bound 80 covers very slow CI hosts without permitting a + # complete regression. + assert 80 <= loop_delta <= 180, ( + f"Component loop at default loop_interval_ should fire ~125 times " + f"in 2s (≈62 Hz × 2s); got {loop_delta}. Values >200 indicate the " + f"scheduler is again pulling the component phase forward." + ) diff --git a/tests/integration/test_wake_loop_forces_phase_b.py b/tests/integration/test_wake_loop_forces_phase_b.py new file mode 100644 index 0000000000..5f05f07dd8 --- /dev/null +++ b/tests/integration/test_wake_loop_forces_phase_b.py @@ -0,0 +1,76 @@ +"""Test that wake_loop_threadsafe() forces a component-phase iteration. + +Regression test for the wake-request flag added to Application::loop()'s +Phase A / Phase B gate. Background producers (MQTT RX, USB RX, BLE event, +etc.) call App.wake_loop_threadsafe() expecting their component's loop() +to drain queued work; if the component phase stays gated by loop_interval_, +the work waits up to loop_interval_ ms instead of running on the next tick. + +Setup: +- App.set_loop_interval(2000) — a wide gate that would clearly mask the bug. +- A test component spawns a detached std::thread that sleeps 50 ms and then + calls App.wake_loop_threadsafe() from a non-main thread. +- The on_boot block snapshots the component's loop counter before/after a + 500 ms observation window. + +Without the fix, delta=0 (the gate holds Phase B for ~2 s). +With the fix, delta>=1 (the wake forces Phase B within one tick of the wake). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wake_loop_forces_phase_b( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A wake_loop_threadsafe() call from a background thread must trigger the + component phase within the next tick, even when loop_interval_ is raised + well above the observation window.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + result: asyncio.Future[tuple[int, int]] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"WAKE_RESULT delta=(\d+) elapsed=(\d+)", line) + if match and not result.done(): + result.set_result((int(match.group(1)), int(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "wake-loop-phase-b" + + try: + delta, elapsed = await asyncio.wait_for(result, timeout=15.0) + except TimeoutError: + pytest.fail("WAKE_RESULT marker never appeared") + + # Without the fix, delta would be 0 — loop_interval_=2000ms held + # Phase B off for the full 500ms observation window. With the fix + # the wake from the background thread (~50ms after start) forces + # Phase B on the next tick, so the counter increments at least once. + assert delta >= 1, ( + f"wake_loop_threadsafe() from a background thread should force " + f"Phase B within the next tick; observed delta={delta} after " + f"{elapsed}ms with loop_interval_=2000ms" + ) From 7bd36e0c8dc6c2b968e858087e77bacb376b84bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 14:51:45 +0200 Subject: [PATCH 0402/2214] [debug] Migrate trivial buf_append_printf sites to buf_append_str (#15885) --- esphome/components/debug/debug_component.cpp | 2 +- esphome/components/debug/debug_esp32.cpp | 23 +++++++++++------ esphome/components/debug/debug_libretiny.cpp | 9 ++++--- esphome/components/debug/debug_zephyr.cpp | 26 ++++++++++++++------ 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 15f68c3a3b..d97a4aa135 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -30,7 +30,7 @@ void DebugComponent::dump_config() { char device_info_buffer[DEVICE_INFO_BUFFER_SIZE]; ESP_LOGD(TAG, "ESPHome version %s", ESPHOME_VERSION); - size_t pos = buf_append_printf(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, "%s", ESPHOME_VERSION); + size_t pos = buf_append_str(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, ESPHOME_VERSION); this->free_heap_ = get_free_heap_(); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 2e04090749..ea0c635207 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -224,17 +224,21 @@ size_t DebugComponent::get_device_info_(std::span const char *model = ESPHOME_VARIANT; // Build features string - pos = buf_append_printf(buf, size, pos, "|Chip: %s Features:", model); + pos = buf_append_str(buf, size, pos, "|Chip: "); + pos = buf_append_str(buf, size, pos, model); + pos = buf_append_str(buf, size, pos, " Features:"); bool first_feature = true; for (const auto &feature : CHIP_FEATURES) { if (info.features & feature.bit) { - pos = buf_append_printf(buf, size, pos, "%s%s", first_feature ? "" : ", ", feature.name); + pos = buf_append_str(buf, size, pos, first_feature ? "" : ", "); + pos = buf_append_str(buf, size, pos, feature.name); first_feature = false; info.features &= ~feature.bit; } } if (info.features != 0) { - pos = buf_append_printf(buf, size, pos, "%sOther:0x%" PRIx32, first_feature ? "" : ", ", info.features); + pos = buf_append_str(buf, size, pos, first_feature ? "" : ", "); + pos = buf_append_printf(buf, size, pos, "Other:0x%" PRIx32, info.features); } pos = buf_append_printf(buf, size, pos, " Cores:%u Revision:%u", info.cores, info.revision); @@ -267,17 +271,20 @@ size_t DebugComponent::get_device_info_(std::span // Framework detection #ifdef USE_ARDUINO ESP_LOGD(TAG, " Framework: Arduino"); - pos = buf_append_printf(buf, size, pos, "|Framework: Arduino"); + pos = buf_append_str(buf, size, pos, "|Framework: Arduino"); #else ESP_LOGD(TAG, " Framework: ESP-IDF"); - pos = buf_append_printf(buf, size, pos, "|Framework: ESP-IDF"); + pos = buf_append_str(buf, size, pos, "|Framework: ESP-IDF"); #endif - pos = buf_append_printf(buf, size, pos, "|ESP-IDF: %s", esp_get_idf_version()); + pos = buf_append_str(buf, size, pos, "|ESP-IDF: "); + pos = buf_append_str(buf, size, pos, esp_get_idf_version()); pos = buf_append_printf(buf, size, pos, "|EFuse MAC: %02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); - pos = buf_append_printf(buf, size, pos, "|Reset: %s", reset_reason); - pos = buf_append_printf(buf, size, pos, "|Wakeup: %s", wakeup_cause); + pos = buf_append_str(buf, size, pos, "|Reset: "); + pos = buf_append_str(buf, size, pos, reset_reason); + pos = buf_append_str(buf, size, pos, "|Wakeup: "); + pos = buf_append_str(buf, size, pos, wakeup_cause); return pos; } diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 1d458c602a..6f36debb95 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -38,9 +38,12 @@ size_t DebugComponent::get_device_info_(std::span lt_get_version(), lt_cpu_get_model_name(), lt_cpu_get_model(), lt_cpu_get_freq_mhz(), mac_id, lt_get_board_code(), flash_kib, ram_kib, reset_reason); - pos = buf_append_printf(buf, size, pos, "|Version: %s", LT_BANNER_STR + 10); - pos = buf_append_printf(buf, size, pos, "|Reset Reason: %s", reset_reason); - pos = buf_append_printf(buf, size, pos, "|Chip Name: %s", lt_cpu_get_model_name()); + pos = buf_append_str(buf, size, pos, "|Version: "); + pos = buf_append_str(buf, size, pos, LT_BANNER_STR + 10); + pos = buf_append_str(buf, size, pos, "|Reset Reason: "); + pos = buf_append_str(buf, size, pos, reset_reason); + pos = buf_append_str(buf, size, pos, "|Chip Name: "); + pos = buf_append_str(buf, size, pos, lt_cpu_get_model_name()); pos = buf_append_printf(buf, size, pos, "|Chip ID: 0x%06" PRIX32, mac_id); pos = buf_append_printf(buf, size, pos, "|Flash: %" PRIu32 " KiB", flash_kib); pos = buf_append_printf(buf, size, pos, "|RAM: %" PRIu32 " KiB", ram_kib); diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index d1580dae80..49790b5b9a 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -162,14 +162,18 @@ size_t DebugComponent::get_device_info_(std::span const char *supply_status = (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) ? "Normal voltage." : "High voltage."; ESP_LOGD(TAG, "Main supply status: %s", supply_status); - pos = buf_append_printf(buf, size, pos, "|Main supply status: %s", supply_status); + pos = buf_append_str(buf, size, pos, "|Main supply status: "); + pos = buf_append_str(buf, size, pos, supply_status); // Regulator stage 0 if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { const char *reg0_type = nrf_power_dcdcen_vddh_get(NRF_POWER) ? "DC/DC" : "LDO"; const char *reg0_voltage = regout0_to_str((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos); ESP_LOGD(TAG, "Regulator stage 0: %s, %s", reg0_type, reg0_voltage); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: %s, %s", reg0_type, reg0_voltage); + pos = buf_append_str(buf, size, pos, "|Regulator stage 0: "); + pos = buf_append_str(buf, size, pos, reg0_type); + pos = buf_append_str(buf, size, pos, ", "); + pos = buf_append_str(buf, size, pos, reg0_voltage); #ifdef USE_NRF52_REG0_VOUT if ((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos != USE_NRF52_REG0_VOUT) { ESP_LOGE(TAG, "Regulator stage 0: expected %s", regout0_to_str(USE_NRF52_REG0_VOUT)); @@ -177,13 +181,14 @@ size_t DebugComponent::get_device_info_(std::span #endif } else { ESP_LOGD(TAG, "Regulator stage 0: disabled"); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: disabled"); + pos = buf_append_str(buf, size, pos, "|Regulator stage 0: disabled"); } // Regulator stage 1 const char *reg1_type = nrf_power_dcdcen_get(NRF_POWER) ? "DC/DC" : "LDO"; ESP_LOGD(TAG, "Regulator stage 1: %s", reg1_type); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 1: %s", reg1_type); + pos = buf_append_str(buf, size, pos, "|Regulator stage 1: "); + pos = buf_append_str(buf, size, pos, reg1_type); // USB power state const char *usb_state; @@ -197,7 +202,8 @@ size_t DebugComponent::get_device_info_(std::span usb_state = "disconnected"; } ESP_LOGD(TAG, "USB power state: %s", usb_state); - pos = buf_append_printf(buf, size, pos, "|USB power state: %s", usb_state); + pos = buf_append_str(buf, size, pos, "|USB power state: "); + pos = buf_append_str(buf, size, pos, usb_state); // Power-fail comparator bool enabled; @@ -302,14 +308,18 @@ size_t DebugComponent::get_device_info_(std::span break; } ESP_LOGD(TAG, "Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: "); + pos = buf_append_str(buf, size, pos, pof_voltage); + pos = buf_append_str(buf, size, pos, ", VDDH: "); + pos = buf_append_str(buf, size, pos, vddh_voltage); } else { ESP_LOGD(TAG, "Power-fail comparator: %s", pof_voltage); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s", pof_voltage); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: "); + pos = buf_append_str(buf, size, pos, pof_voltage); } } else { ESP_LOGD(TAG, "Power-fail comparator: disabled"); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: disabled"); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: disabled"); } auto package = [](uint32_t value) { From 3a6f3dfb9468d1de3be68616a5bef95feba215dc Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Tue, 21 Apr 2026 16:03:07 +0300 Subject: [PATCH 0403/2214] [lock] Implemented open states support (#15120) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/lock/__init__.py | 2 ++ esphome/components/lock/lock.cpp | 11 ++++++++--- esphome/components/lock/lock.h | 4 +++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 1a45896ac1..a36d52a5d8 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -35,9 +35,11 @@ LockStateForwarder = lock_ns.class_("LockStateForwarder") LockState = lock_ns.enum("LockState") LOCK_STATES = { + "OPEN": LockState.LOCK_STATE_OPEN, "LOCKED": LockState.LOCK_STATE_LOCKED, "UNLOCKED": LockState.LOCK_STATE_UNLOCKED, "JAMMED": LockState.LOCK_STATE_JAMMED, + "OPENING": LockState.LOCK_STATE_OPENING, "LOCKING": LockState.LOCK_STATE_LOCKING, "UNLOCKING": LockState.LOCK_STATE_UNLOCKING, } diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index 3ff131af3d..66eb692bd5 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -8,9 +8,10 @@ namespace esphome::lock { static const char *const TAG = "lock"; -// Lock state strings indexed by LockState enum (0-5): NONE(UNKNOWN), LOCKED, UNLOCKED, JAMMED, LOCKING, UNLOCKING +// Lock state strings indexed by LockState enum. // Index 0 is UNKNOWN (for LOCK_STATE_NONE), also used as fallback for out-of-range -PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING"); +PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING", "OPENING", + "OPEN"); const LogString *lock_state_to_string(LockState state) { return LockStateStrings::get_log_str(static_cast(state), 0); @@ -74,12 +75,16 @@ LockCall &LockCall::set_state(optional state) { return *this; } LockCall &LockCall::set_state(const char *state) { - if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKED")) == 0) { + if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("OPEN")) == 0) { + this->set_state(LOCK_STATE_OPEN); + } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKED")) == 0) { this->set_state(LOCK_STATE_LOCKED); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("UNLOCKED")) == 0) { this->set_state(LOCK_STATE_UNLOCKED); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("JAMMED")) == 0) { this->set_state(LOCK_STATE_JAMMED); + } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("OPENING")) == 0) { + this->set_state(LOCK_STATE_OPENING); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKING")) == 0) { this->set_state(LOCK_STATE_LOCKING); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("UNLOCKING")) == 0) { diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 543a4b51a8..86a9cdd3fb 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -26,7 +26,9 @@ enum LockState : uint8_t { LOCK_STATE_UNLOCKED = 2, LOCK_STATE_JAMMED = 3, LOCK_STATE_LOCKING = 4, - LOCK_STATE_UNLOCKING = 5 + LOCK_STATE_UNLOCKING = 5, + LOCK_STATE_OPENING = 6, + LOCK_STATE_OPEN = 7, }; const LogString *lock_state_to_string(LockState state); From 14defb69b64f3fb365a4b9a68ef5e791963c5add Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 15:04:13 +0200 Subject: [PATCH 0404/2214] [template] Use placement new for template text restore saver (#15883) --- esphome/components/template/text/__init__.py | 13 +++++- tests/component_tests/template/__init__.py | 0 .../config/template_text_restore.yaml | 14 ++++++ .../template/test_template_text.py | 44 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/template/__init__.py create mode 100644 tests/component_tests/template/config/template_text_restore.yaml create mode 100644 tests/component_tests/template/test_template_text.py diff --git a/esphome/components/template/text/__init__.py b/esphome/components/template/text/__init__.py index 572b5ba0f4..1266370cb2 100644 --- a/esphome/components/template/text/__init__.py +++ b/esphome/components/template/text/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import text import esphome.config_validation as cv from esphome.const import ( + CONF_ID, CONF_INITIAL_VALUE, CONF_LAMBDA, CONF_MAX_LENGTH, @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RESTORE_VALUE, CONF_SET_ACTION, ) +from esphome.core import ID from .. import template_ns @@ -84,8 +86,15 @@ async def to_code(config): if initial_value_config := config.get(CONF_INITIAL_VALUE): cg.add(var.set_initial_value(initial_value_config)) if config[CONF_RESTORE_VALUE]: - args = cg.TemplateArguments(config[CONF_MAX_LENGTH]) - saver = TextSaverTemplate.template(args).new() + saver_id = ID( + f"{config[CONF_ID].id}_value_saver", + is_declaration=True, + type=TextSaverBase, + ) + saver_type = TextSaverTemplate.template( + cg.TemplateArguments(config[CONF_MAX_LENGTH]) + ) + saver = cg.Pvariable(saver_id, saver_type.new()) cg.add(var.set_value_saver(saver)) if CONF_SET_ACTION in config: diff --git a/tests/component_tests/template/__init__.py b/tests/component_tests/template/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/template/config/template_text_restore.yaml b/tests/component_tests/template/config/template_text_restore.yaml new file mode 100644 index 0000000000..4574470eab --- /dev/null +++ b/tests/component_tests/template/config/template_text_restore.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +host: + +text: + - platform: template + name: "Test Text Restore" + id: test_text_restore + optimistic: true + max_length: 10 + mode: text + initial_value: "hello" + restore_value: true diff --git a/tests/component_tests/template/test_template_text.py b/tests/component_tests/template/test_template_text.py new file mode 100644 index 0000000000..2ce9a88d67 --- /dev/null +++ b/tests/component_tests/template/test_template_text.py @@ -0,0 +1,44 @@ +"""Tests for the template text component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + + +def test_template_text_saver_uses_placement_new_with_templated_subclass( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Regression test for template text restore saver using placement new. + + When ``restore_value: true``, the saver is its own Pvariable with + placement new: storage is sized for ``TextSaver``, the + declared pointer stays at ``TemplateTextSaverBase *`` for polymorphism, + and the templated subclass constructor runs. A regression would either + reintroduce the heap ``new TextSaver<...>()`` expression or size the + storage for the base class and silently skip the subclass ctor. + """ + main_cpp = generate_main(component_config_path("template_text_restore.yaml")) + + # Storage is sized and aligned for the templated subclass. + assert "sizeof(template_::TextSaver<10>)" in main_cpp + assert "alignas(template_::TextSaver<10>)" in main_cpp + # Pointer declared as base type for polymorphism. + assert ( + "static template_::TemplateTextSaverBase *const test_text_restore_value_saver" + in main_cpp + ) + # Placement new runs the templated subclass constructor. + assert "new(test_text_restore_value_saver) template_::TextSaver<10>()" in main_cpp + # Base-class default ctor must NOT be used. + assert ( + "new(test_text_restore_value_saver) template_::TemplateTextSaverBase()" + not in main_cpp + ) + # No heap `new TextSaver<...>()` left over — the pre-fix pattern. + assert "new template_::TextSaver<" not in main_cpp + # Saver is wired into the text component. + assert ( + "test_text_restore->set_value_saver(test_text_restore_value_saver)" in main_cpp + ) From 5b912e0c7cf97b5955ae9025afc32efbdf11a0e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 15:11:46 +0200 Subject: [PATCH 0405/2214] [core] RAII guard for component loop phase Replace paired before_component_phase_() / after_component_phase_() calls with a ComponentPhaseGuard whose constructor processes pending enable_loop requests and sets in_loop_ = true, and whose destructor clears in_loop_ at scope exit. The guard methods are ESPHOME_ALWAYS_INLINE. Verified byte-identical .text/.data/.bss output on an ESP32 IDF build, and identical disassembly for both esphome::loop_task (where Application::loop is inlined) and Application::setup. Benefit: symmetry is now enforced by the type system. Future early returns or refactors inside the component phase can't accidentally leave in_loop_ set without a matching reset. --- esphome/core/application.cpp | 16 ++++++++-------- esphome/core/application.h | 30 +++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b626eb1de6..c4737254e6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -97,16 +97,16 @@ void Application::setup() { // interrupts during setup. During setup we always run the component // phase (no loop_interval_ gate), so call both helpers unconditionally. this->scheduler_tick_(now); - this->before_component_phase_(); + { + ComponentPhaseGuard phase_guard{*this}; - for (uint32_t j = 0; j <= i; j++) { - // Update loop_component_start_time_ right before calling each component - this->loop_component_start_time_ = millis(); - this->components_[j]->call(); - this->feed_wdt(); + for (uint32_t j = 0; j <= i; j++) { + // Update loop_component_start_time_ right before calling each component + this->loop_component_start_time_ = millis(); + this->components_[j]->call(); + this->feed_wdt(); + } } - - this->after_component_phase_(); yield(); } while (!component->can_proceed() && !component->is_failed()); } diff --git a/esphome/core/application.h b/esphome/core/application.h index d3851a32da..124659d0b4 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -427,8 +427,20 @@ class Application { void enable_pending_loops_(); void activate_looping_component_(uint16_t index); inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); - inline void ESPHOME_ALWAYS_INLINE before_component_phase_(); - inline void ESPHOME_ALWAYS_INLINE after_component_phase_() { this->in_loop_ = false; } + + // RAII guard for a component loop phase. Constructor processes any pending + // enable_loop requests from ISRs and marks in_loop_ so reentrant + // modifications during component.loop() are safe; destructor clears in_loop_. + class ComponentPhaseGuard { + public: + inline ESPHOME_ALWAYS_INLINE explicit ComponentPhaseGuard(Application &app); + inline ESPHOME_ALWAYS_INLINE ~ComponentPhaseGuard() { this->app_.in_loop_ = false; } + ComponentPhaseGuard(const ComponentPhaseGuard &) = delete; + ComponentPhaseGuard &operator=(const ComponentPhaseGuard &) = delete; + + private: + Application &app_; + }; /// Process dump_config output one component per loop iteration. /// Extracted from loop() to keep cold startup/reconnect logging out of the hot path. @@ -601,10 +613,10 @@ inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) // Phase B entry: only invoked when a component loop phase is about to run. // Processes pending enable_loop requests from ISRs and marks in_loop_ so // reentrant modifications during component.loop() are safe. -inline void ESPHOME_ALWAYS_INLINE Application::before_component_phase_() { +inline ESPHOME_ALWAYS_INLINE Application::ComponentPhaseGuard::ComponentPhaseGuard(Application &app) : app_(app) { // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions - if (this->has_pending_enable_loop_requests_) { + if (this->app_.has_pending_enable_loop_requests_) { // Clear flag BEFORE processing to avoid race condition // If ISR sets it during processing, we'll catch it next loop iteration // This is safe because: @@ -612,12 +624,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_component_phase_() { // 2. If we can't process a component (wrong state), enable_pending_loops_() // will set this flag back to true // 3. Any new ISR requests during processing will set the flag again - this->has_pending_enable_loop_requests_ = false; - this->enable_pending_loops_(); + this->app_.has_pending_enable_loop_requests_ = false; + this->app_.enable_pending_loops_(); } // Mark that we're in the loop for safe reentrant modifications - this->in_loop_ = true; + this->app_.in_loop_ = true; } inline void ESPHOME_ALWAYS_INLINE Application::loop() { @@ -671,7 +683,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_); if (do_component_phase) { - this->before_component_phase_(); + ComponentPhaseGuard phase_guard{*this}; uint32_t last_op_end_time = now; for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; @@ -696,7 +708,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #endif this->last_loop_ = last_op_end_time; now = last_op_end_time; - this->after_component_phase_(); + // phase_guard destructor clears in_loop_ at scope exit } #ifdef USE_RUNTIME_STATS From 0c9d443a5c041910f4df5cbba3a136d151c92c8e Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:18:46 +0200 Subject: [PATCH 0406/2214] [esp32_ble] Add `use_psram` option to offload BT memory allocation to SPIRAM (#15644) --- esphome/components/esp32_ble/__init__.py | 20 +++++++++++++++++++ esphome/components/esp32_ble/ble.cpp | 3 +++ esphome/core/defines.h | 1 + .../esp32_ble/common_use_psram.yaml | 4 ++++ .../components/esp32_ble/test.esp32-ard.yaml | 1 + .../components/esp32_ble/test.esp32-idf.yaml | 1 + .../esp32_ble/test.esp32-p4-idf.yaml | 1 + 7 files changed, 31 insertions(+) create mode 100644 tests/components/esp32_ble/common_use_psram.yaml diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 79d05049bf..c7b6b40394 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -7,6 +7,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_USE_PSRAM from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant from esphome.components.esp32.const import VARIANT_ESP32C2 import esphome.config_validation as cv @@ -342,6 +343,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_MAX_CONNECTIONS, default=DEFAULT_MAX_CONNECTIONS): cv.All( cv.positive_int, cv.Range(min=1, max=IDF_MAX_CONNECTIONS) ), + cv.Optional(CONF_USE_PSRAM): cv.All( + cv.only_on_esp32, cv.requires_component("psram"), cv.boolean + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -598,6 +602,22 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for + # heap allocations and use dynamic (heap-based) environment memory tables + # instead of large static DRAM arrays. This frees ~40 kB of internal RAM. + # Reference: Espressif ADF Design Considerations + # https://espressif-docs.readthedocs-hosted.com/projects/esp-adf/en/latest/ + # design-guide/design-considerations.html + if config.get(CONF_USE_PSRAM, False): + cg.add_define("USE_ESP32_BLE_PSRAM") + # CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST is only available on ESP32 + # (BTDM dual-mode controller). BLE-only SoCs (C3, S3, C2, H2) do not + # expose this Kconfig symbol; applying it there would cause a build error. + if get_esp32_variant() == const.VARIANT_ESP32: + add_idf_sdkconfig_option("CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST", True) + # CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY applies to all Bluedroid-enabled variants. + add_idf_sdkconfig_option("CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY", True) + # Register the core BLE loggers that are always needed register_bt_logger(BTLoggers.GAP, BTLoggers.BTM, BTLoggers.HCI) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 0280439731..ebb44c7d91 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -667,6 +667,9 @@ void ESP32BLE::dump_config() { " MAC address: %s\n" " IO Capability: %s", mac_s, io_capability_s); +#ifdef USE_ESP32_BLE_PSRAM + ESP_LOGCONFIG(TAG, " PSRAM BLE allocation: enabled"); +#endif #ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS const char *auth_req_mode_s = ""; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0fb7221571..07cac97e17 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -48,6 +48,7 @@ #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT +#define USE_ESP32_BLE_PSRAM #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_IMPROV_STATE_CALLBACK diff --git a/tests/components/esp32_ble/common_use_psram.yaml b/tests/components/esp32_ble/common_use_psram.yaml new file mode 100644 index 0000000000..cce6cf547f --- /dev/null +++ b/tests/components/esp32_ble/common_use_psram.yaml @@ -0,0 +1,4 @@ +esp32_ble: + use_psram: true + +psram: diff --git a/tests/components/esp32_ble/test.esp32-ard.yaml b/tests/components/esp32_ble/test.esp32-ard.yaml index dade44d145..fa7b9befc7 100644 --- a/tests/components/esp32_ble/test.esp32-ard.yaml +++ b/tests/components/esp32_ble/test.esp32-ard.yaml @@ -1 +1,2 @@ <<: !include common.yaml +<<: !include common_use_psram.yaml diff --git a/tests/components/esp32_ble/test.esp32-idf.yaml b/tests/components/esp32_ble/test.esp32-idf.yaml index f8defaf28f..0b2a920c60 100644 --- a/tests/components/esp32_ble/test.esp32-idf.yaml +++ b/tests/components/esp32_ble/test.esp32-idf.yaml @@ -1,4 +1,5 @@ <<: !include common.yaml +<<: !include common_use_psram.yaml esp32_ble: io_capability: keyboard_only diff --git a/tests/components/esp32_ble/test.esp32-p4-idf.yaml b/tests/components/esp32_ble/test.esp32-p4-idf.yaml index 4eeb7c2f18..170220bf48 100644 --- a/tests/components/esp32_ble/test.esp32-p4-idf.yaml +++ b/tests/components/esp32_ble/test.esp32-p4-idf.yaml @@ -2,6 +2,7 @@ packages: ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml <<: !include common.yaml +<<: !include common_use_psram.yaml esp32_ble: io_capability: keyboard_only From 54086057e7ee35acb7d6e1d0abb35e7f0173a84a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 15:28:03 +0200 Subject: [PATCH 0407/2214] fix merge --- esphome/core/application.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index a6874aa697..c07a1b82f2 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -102,7 +102,7 @@ void Application::setup() { for (uint32_t j = 0; j <= i; j++) { // Update loop_component_start_time_ right before calling each component - this->loop_component_start_time_ = millis(); + this->loop_component_start_time_ = MillisInternal::get(); this->components_[j]->call(); this->feed_wdt(); } From 9ad1bc259f7359720a21fb1b7cffccd39e8dc12a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 15:31:04 +0200 Subject: [PATCH 0408/2214] cleanup --- esphome/core/application.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 51966dfbec..ea1912d645 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -91,12 +91,10 @@ void Application::setup() { this->app_state_ |= STATUS_LED_WARNING; do { - uint32_t now = MillisInternal::get(); - // Service scheduler and process pending loop enables to handle GPIO // interrupts during setup. During setup we always run the component // phase (no loop_interval_ gate), so call both helpers unconditionally. - this->scheduler_tick_(now); + this->scheduler_tick_(MillisInternal::get()); this->before_component_phase_(); for (uint32_t j = 0; j <= i; j++) { From 43c6b839cd70e3c2430964ec441c3815ac203d91 Mon Sep 17 00:00:00 2001 From: Geoff Date: Tue, 21 Apr 2026 07:00:03 -0700 Subject: [PATCH 0409/2214] [sensor] Filter to round to significant digits (#11157) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sensor/__init__.py | 14 ++++++ esphome/components/sensor/filter.h | 13 +++++ esphome/core/helpers.cpp | 17 +++++++ esphome/core/helpers.h | 5 ++ tests/components/core/helpers_test.cpp | 58 ++++++++++++++++++++++ tests/components/template/common-base.yaml | 1 + 6 files changed, 108 insertions(+) create mode 100644 tests/components/core/helpers_test.cpp diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 8dcb7165e3..43fbc98953 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -118,6 +118,7 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.util import Registry CODEOWNERS = ["@esphome/core"] + DEVICE_CLASSES = [ DEVICE_CLASS_ABSOLUTE_HUMIDITY, DEVICE_CLASS_APPARENT_POWER, @@ -293,6 +294,7 @@ SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter) ClampFilter = sensor_ns.class_("ClampFilter", Filter) RoundFilter = sensor_ns.class_("RoundFilter", Filter) RoundMultipleFilter = sensor_ns.class_("RoundMultipleFilter", Filter) +RoundSignificantDigitsFilter = sensor_ns.class_("RoundSignificantDigitsFilter", Filter) validate_unit_of_measurement = cv.All( cv.string_strict, @@ -900,6 +902,18 @@ async def round_multiple_filter_to_code(config, filter_id): ) +@FILTER_REGISTRY.register( + "round_to_significant_digits", + RoundSignificantDigitsFilter, + cv.int_range(min=1, max=6), +) +async def round_significant_digits_filter_to_code(config, filter_id): + return cg.new_Pvariable( + filter_id, + cg.TemplateArguments(config), + ) + + async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index a91d66a8fb..917a1ce7d5 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -604,6 +604,19 @@ class RoundMultipleFilter : public Filter { float multiple_; }; +template class RoundSignificantDigitsFilter : public Filter { + public: + optional new_value(float value) override { + if (std::isfinite(value)) { + if (value == 0.0f) + return 0.0f; + float factor = pow10_int(Digits - 1 - ilog10(value)); + return roundf(value * factor) / factor; + } + return value; + } +}; + class ToNTCResistanceFilter : public Filter { public: ToNTCResistanceFilter(double a, double b, double c) : a_(a), b_(b), c_(c) {} diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1d0efd01ce..113b6f6187 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -413,6 +413,23 @@ ParseOnOffState parse_on_off(const char *str, const char *on, const char *off) { return PARSE_NONE; } +int8_t ilog10(float value) { + float abs_val = fabsf(value); + int8_t exp = 0; + if (abs_val >= 10.0f) { + while (abs_val >= 10.0f) { + abs_val /= 10.0f; + exp++; + } + } else if (abs_val < 1.0f) { + while (abs_val < 1.0f) { + abs_val *= 10.0f; + exp--; + } + } + return exp; +} + static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_decimals) { if (accuracy_decimals < 0) { float divisor; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 6b71916cd2..939852bfcb 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -740,6 +740,11 @@ template class SmallBufferWithHeapFallb /// @name Mathematics ///@{ +/// Compute floor(log10(fabs(value))) using iterative comparison. +/// Avoids pulling in __ieee754_logf/log10f (~1KB flash). +/// Only valid for finite, non-zero values. +int8_t ilog10(float value); + /// Compute 10^exp using iterative multiplication/division. /// Avoids pulling in powf/__ieee754_powf (~2.3KB flash) for small integer exponents. // NOLINT /// Matches powf(10, exp) for the int8_t exponent range used by sensor accuracy_decimals. // NOLINT diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp new file mode 100644 index 0000000000..468185787f --- /dev/null +++ b/tests/components/core/helpers_test.cpp @@ -0,0 +1,58 @@ +#include +#include +#include "esphome/core/helpers.h" + +namespace esphome { + +TEST(HelpersTest, Ilog10PowersOfTen) { + EXPECT_EQ(ilog10(1.0f), 0); + EXPECT_EQ(ilog10(10.0f), 1); + EXPECT_EQ(ilog10(100.0f), 2); + EXPECT_EQ(ilog10(1000.0f), 3); + EXPECT_EQ(ilog10(10000.0f), 4); + EXPECT_EQ(ilog10(100000.0f), 5); + EXPECT_EQ(ilog10(0.1f), -1); + EXPECT_EQ(ilog10(0.001f), -3); +} + +TEST(HelpersTest, Ilog10General) { + EXPECT_EQ(ilog10(5.0f), 0); + EXPECT_EQ(ilog10(9.99f), 0); + EXPECT_EQ(ilog10(50.0f), 1); + EXPECT_EQ(ilog10(99.0f), 1); + EXPECT_EQ(ilog10(999.0f), 2); + EXPECT_EQ(ilog10(0.5f), -1); + EXPECT_EQ(ilog10(0.0072f), -3); + EXPECT_EQ(ilog10(120000.0f), 5); + EXPECT_EQ(ilog10(123456.789f), 5); +} + +TEST(HelpersTest, Ilog10Negative) { + EXPECT_EQ(ilog10(-1.0f), 0); + EXPECT_EQ(ilog10(-10.0f), 1); + EXPECT_EQ(ilog10(-0.1f), -1); + EXPECT_EQ(ilog10(-123.456f), 2); +} + +// Verify that ilog10 + pow10_int produces the same rounding result as log10/pow. +// ilog10 may differ from floor(log10f()) for values not exactly representable in float +// (e.g. 0.01f is 0.00999...), but the full round-trip must match. +TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { + float values[] = {0.0072f, 0.05f, 0.1f, 0.5f, 1.0f, 3.14f, 9.99f, 10.0f, 42.0f, 100.0f, + 1234.5f, 9999.0f, 10000.0f, 99999.0f, 120000.0f, 999999.0f, -1.0f, -0.1f, -123.456f, -10000.0f}; + for (uint8_t digits = 1; digits <= 6; digits++) { + for (float v : values) { + // New implementation using ilog10 + pow10_int + float factor_new = pow10_int(digits - 1 - ilog10(v)); + float result_new = roundf(v * factor_new) / factor_new; + + // Reference using log10/pow + double factor_ref = pow(10.0, digits - std::ceil(std::log10(std::fabs(v)))); + float result_ref = static_cast(round(v * factor_ref) / factor_ref); + + EXPECT_FLOAT_EQ(result_new, result_ref) << "mismatch for value=" << v << " digits=" << (int) digits; + } + } +} + +} // namespace esphome diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index ed398b0abd..ecc65de66c 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -171,6 +171,7 @@ sensor: quantile: .9 - round: 1 - round_to_multiple_of: 0.25 + - round_to_significant_digits: 3 - skip_initial: 3 - sliding_window_moving_average: window_size: 15 From aa1d7f853285365ca0030c7ea4bfd5d49b53e208 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:58:34 +0200 Subject: [PATCH 0410/2214] More conditional compilation, copy app partition, rewrite otadata --- .../components/ota/ota_backend_esp_idf.cpp | 328 +++++++++++------- esphome/components/ota/ota_backend_esp_idf.h | 6 +- 2 files changed, 210 insertions(+), 124 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 506d48988f..b23a2247e2 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -17,60 +17,16 @@ namespace esphome::ota { static const char *const TAG = "ota.idf"; +#ifdef USE_OTA_PARTITIONS +static uint32_t running_app_offset = 0; +static size_t running_app_size = 0; +#endif + std::unique_ptr make_ota_backend() { return make_unique(); } +#ifdef USE_OTA_PARTITIONS OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { this->ota_type_ = ota_type; - if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { -#ifdef USE_OTA_ROLLBACK - // If we're starting an OTA, the current boot is good enough - mark it valid - // to prevent rollback and allow the OTA to proceed even if the safe mode - // timer hasn't expired yet. - esp_ota_mark_app_valid_cancel_rollback(); -#endif - - this->partition_ = esp_ota_get_next_update_partition(nullptr); - if (this->partition_ == nullptr) { - return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; - } - -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // The following function takes longer than the 5 seconds timeout of WDT - esp_task_wdt_config_t wdtc; - wdtc.idle_core_mask = 0; -#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 - wdtc.idle_core_mask |= (1 << 0); -#endif -#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 - wdtc.idle_core_mask |= (1 << 1); -#endif - wdtc.timeout_ms = 15000; - wdtc.trigger_panic = false; - esp_task_wdt_reconfigure(&wdtc); -#endif - - esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); - -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // Set the WDT back to the configured timeout - wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; - esp_task_wdt_reconfigure(&wdtc); -#endif - - if (err != ESP_OK) { - 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) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; - } - return OTA_RESPONSE_ERROR_UNKNOWN; - } - this->md5_.init(); - return OTA_RESPONSE_OK; - } -#ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || image_size > OTA_BUFFER_SIZE) { @@ -82,8 +38,59 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) this->md5_.init(); return OTA_RESPONSE_OK; } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } +#else +OTAResponseTypes IDFOTABackend::begin(size_t image_size) { #endif - return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; +#ifdef USE_OTA_ROLLBACK + // If we're starting an OTA, the current boot is good enough - mark it valid + // to prevent rollback and allow the OTA to proceed even if the safe mode + // timer hasn't expired yet. + esp_ota_mark_app_valid_cancel_rollback(); +#endif + + this->partition_ = esp_ota_get_next_update_partition(nullptr); + if (this->partition_ == nullptr) { + return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; + } + +#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 + // The following function takes longer than the 5 seconds timeout of WDT + esp_task_wdt_config_t wdtc; + wdtc.idle_core_mask = 0; +#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 + wdtc.idle_core_mask |= (1 << 0); +#endif +#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 + wdtc.idle_core_mask |= (1 << 1); +#endif + wdtc.timeout_ms = 15000; + wdtc.trigger_panic = false; + esp_task_wdt_reconfigure(&wdtc); +#endif + + esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); + +#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 + // Set the WDT back to the configured timeout + wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; + esp_task_wdt_reconfigure(&wdtc); +#endif + + if (err != ESP_OK) { + 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) { + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + return OTA_RESPONSE_ERROR_UNKNOWN; + } + this->md5_.init(); + return OTA_RESPONSE_OK; } void IDFOTABackend::set_update_md5(const char *expected_md5) { @@ -92,19 +99,6 @@ void IDFOTABackend::set_update_md5(const char *expected_md5) { } OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { - if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { - esp_err_t err = esp_ota_write(this->update_handle_, data, len); - this->md5_.add(data, len); - if (err != ESP_OK) { - if (err == ESP_ERR_OTA_VALIDATE_FAILED) { - return OTA_RESPONSE_ERROR_MAGIC; - } 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; - } - return OTA_RESPONSE_OK; - } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { if (len > OTA_BUFFER_SIZE - this->buf_written_) { @@ -115,8 +109,21 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { this->md5_.add(data, len); return OTA_RESPONSE_OK; } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } #endif - return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + esp_err_t err = esp_ota_write(this->update_handle_, data, len); + this->md5_.add(data, len); + if (err != ESP_OK) { + if (err == ESP_ERR_OTA_VALIDATE_FAILED) { + return OTA_RESPONSE_ERROR_MAGIC; + } 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; + } + return OTA_RESPONSE_OK; } OTAResponseTypes IDFOTABackend::end() { @@ -127,47 +134,48 @@ OTAResponseTypes IDFOTABackend::end() { return OTA_RESPONSE_ERROR_MD5_MISMATCH; } } - if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { - esp_err_t err = esp_ota_end(this->update_handle_); - this->update_handle_ = 0; - if (err == ESP_OK) { - err = esp_ota_set_boot_partition(this->partition_); - if (err == ESP_OK) { - return OTA_RESPONSE_OK; - } - } - if (err == ESP_ERR_OTA_VALIDATE_FAILED) { -#ifdef USE_OTA_SIGNED_VERIFICATION - ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); - return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; -#else - return OTA_RESPONSE_ERROR_UPDATE_END; -#endif - } - if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; - } - return OTA_RESPONSE_ERROR_UNKNOWN; - } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { return this->update_partition_table(); } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } #endif - return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + esp_err_t err = esp_ota_end(this->update_handle_); + this->update_handle_ = 0; + if (err == ESP_OK) { + err = esp_ota_set_boot_partition(this->partition_); + if (err == ESP_OK) { + return OTA_RESPONSE_OK; + } + } + if (err == ESP_ERR_OTA_VALIDATE_FAILED) { +#ifdef USE_OTA_SIGNED_VERIFICATION + ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); + return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; +#else + return OTA_RESPONSE_ERROR_UPDATE_END; +#endif + } + if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + return OTA_RESPONSE_ERROR_UNKNOWN; } void IDFOTABackend::abort() { - if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { - esp_ota_abort(this->update_handle_); - this->update_handle_ = 0; - } #ifdef USE_OTA_PARTITIONS if (this->partition_table_part_ != nullptr) { esp_partition_deregister_external(this->partition_table_part_); this->partition_table_part_ = nullptr; } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return; + } #endif + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; } #ifdef USE_OTA_PARTITIONS @@ -178,20 +186,28 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "not enough data received (%d/%d bytes)", this->buf_written_, this->image_size_); return OTA_RESPONSE_ERROR_UNKNOWN; } - ESP_LOGD(TAG, "partition table size %d", this->image_size_); // Get running app partition and used size - const esp_partition_t *running_app_part = esp_ota_get_running_partition(); - size_t running_app_size = running_app_part->size; - const esp_partition_pos_t running_app_pos = { - .offset = running_app_part->address, - .size = running_app_part->size, - }; - esp_image_metadata_t image_metadata; - image_metadata.start_addr = running_app_part->address; - err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); - if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { - running_app_size = image_metadata.image_len; + const esp_partition_t *running_app_part = nullptr; + if (running_app_size == 0) { + running_app_part = esp_ota_get_running_partition(); + // esp_ota_get_running_partition() returns a pointer to invalid data after esp_partition_unload_all() was called on a previous run. + // Cache the running app offset and size. + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; + running_app_offset = running_app_part->address; + const esp_partition_pos_t running_app_pos = { + .offset = running_app_part->address, + .size = running_app_part->size, + }; + esp_image_metadata_t image_metadata; + image_metadata.start_addr = running_app_part->address; + err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); + if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { + running_app_size = image_metadata.image_len; + } + // Align running_app_size to flash sectors + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; + ESP_LOGD(TAG, "Running app: address=0x%X partition_size=0x%X used_size=0x%X, aligned_size=0x%X", running_app_part->address, running_app_part->size, image_metadata.image_len, running_app_size); } // Get partition table partition @@ -234,47 +250,94 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { int app_index_with_copy = -1; int otadata_index = -1; bool otadata_no_overlap = false; + const esp_partition_t *app_copy_target_part{nullptr}; for (int i = 0; i < num_partitions; i++) { - const esp_partition_info_t *part = &new_partition_table[i]; - if (part->type == ESP_PARTITION_TYPE_APP) { + const esp_partition_info_t *new_part = &new_partition_table[i]; + if (new_part->type == ESP_PARTITION_TYPE_APP) { app_partitions_found++; - if (part->pos.size >= running_app_size) { - if (part->pos.offset == running_app_part->address) { + if (new_part->pos.size >= running_app_size) { + if (new_part->pos.offset == running_app_offset) { app_index = i; - } else if (part->pos.offset >= running_app_part->address + running_app_size || - running_app_part->address >= part->pos.offset + part->pos.size) { - // No overlap with running app - app_index_with_copy = i; + } else if (new_part->pos.offset >= running_app_offset + running_app_size || + running_app_offset >= new_part->pos.offset + new_part->pos.size) { + // New app partition has no overlap with running app + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); + if (p->address == new_part->pos.offset && p->size >= running_app_size) { + // App partition exists in old and new partition table + app_index_with_copy = i; + app_copy_target_part = p; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); } } - } else if (part->type == ESP_PARTITION_TYPE_DATA && part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { + } else if (new_part->type == ESP_PARTITION_TYPE_DATA && new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { otadata_index = i; - otadata_no_overlap = part->pos.offset >= running_app_part->address + running_app_size || - running_app_part->address >= part->pos.offset + part->pos.size; + otadata_no_overlap = new_part->pos.offset >= running_app_offset + running_app_size || + running_app_offset >= new_part->pos.offset + new_part->pos.size; } } if (app_index == -1 && app_index_with_copy == -1) { - // Can't move running app to new partition layout ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); return OTA_RESPONSE_ERROR_UNKNOWN; } if (app_partitions_found < 2 || otadata_index == -1) { - // OTA would be impossible with new partition table - ESP_LOGE(TAG, "New partition table is missing the required partitions for OTA"); + ESP_LOGE(TAG, "New partition table is missing the required app or otadata partitions"); return OTA_RESPONSE_ERROR_UNKNOWN; } if (!otadata_no_overlap) { - // Can't write to new otadata partition because it overlaps with the running app ESP_LOGE(TAG, "New otadata partition overlaps with running app"); return OTA_RESPONSE_ERROR_UNKNOWN; } ESP_LOGD(TAG, "Checks passed, starting partition table update", err); - // TODO: Copy the running app partition to new position if needed + // Copy the running app partition to new position if needed if (app_index == -1) { - ESP_LOGE(TAG, "Moving the app partition is required but not implemented"); - return OTA_RESPONSE_ERROR_UNKNOWN; + if (running_app_part == nullptr) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); + const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; + if (p->address == running_app_offset && p->size >= running_app_size) { + running_app_part = p; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + } + ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); + +#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 + // The following function takes longer than the 5 seconds timeout of WDT + esp_task_wdt_config_t wdtc; + wdtc.idle_core_mask = 0; +#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 + wdtc.idle_core_mask |= (1 << 0); +#endif +#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 + wdtc.idle_core_mask |= (1 << 1); +#endif + wdtc.timeout_ms = 15000; + wdtc.trigger_panic = false; + esp_task_wdt_reconfigure(&wdtc); +#endif + + err = esp_partition_copy(app_copy_target_part, 0, running_app_part, 0, running_app_size); + +#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 + // Set the WDT back to the configured timeout + wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; + esp_task_wdt_reconfigure(&wdtc); +#endif + + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } } // Update the partition table @@ -296,9 +359,28 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_UNKNOWN; } + esp_partition_unload_all(); - // TODO: Reload partition table and rewrite otadata + // Write otadata to set the new boot partition + const esp_partition_t *new_boot_partition = nullptr; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); + const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; + if (p->address == new_part->pos.offset) { + new_boot_partition = p; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); + err = esp_ota_set_boot_partition(new_boot_partition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + ESP_LOGD(TAG, "Partition table updated successfully", err); return OTA_RESPONSE_OK; } #endif diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 75aa66c29b..9d38d0d8d8 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -15,7 +15,11 @@ static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 class IDFOTABackend final { public: +#ifdef USE_OTA_PARTITIONS OTAResponseTypes begin(size_t image_size, ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP); +#else + OTAResponseTypes begin(size_t image_size); +#endif void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); @@ -33,8 +37,8 @@ class IDFOTABackend final { md5::MD5Digest md5_{}; char expected_bin_md5_[32]; bool md5_set_{false}; - ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; #ifdef USE_OTA_PARTITIONS + ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; uint8_t buf_[OTA_BUFFER_SIZE]; size_t buf_written_{0}; size_t image_size_{0}; From bf8604257f3efd0929e6ab7bcf2f2290f2759ab0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:00:18 +0000 Subject: [PATCH 0411/2214] [pre-commit.ci lite] apply automatic fixes --- esphome/components/ota/ota_backend_esp_idf.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index b23a2247e2..ca3042003c 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -191,9 +191,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_t *running_app_part = nullptr; if (running_app_size == 0) { running_app_part = esp_ota_get_running_partition(); - // esp_ota_get_running_partition() returns a pointer to invalid data after esp_partition_unload_all() was called on a previous run. - // Cache the running app offset and size. - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; + // esp_ota_get_running_partition() returns a pointer to invalid data after esp_partition_unload_all() was called on + // a previous run. Cache the running app offset and size. + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; running_app_offset = running_app_part->address; const esp_partition_pos_t running_app_pos = { .offset = running_app_part->address, @@ -206,8 +207,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { running_app_size = image_metadata.image_len; } // Align running_app_size to flash sectors - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; - ESP_LOGD(TAG, "Running app: address=0x%X partition_size=0x%X used_size=0x%X, aligned_size=0x%X", running_app_part->address, running_app_part->size, image_metadata.image_len, running_app_size); + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + ESP_LOGD(TAG, "Running app: address=0x%X partition_size=0x%X used_size=0x%X, aligned_size=0x%X", + running_app_part->address, running_app_part->size, image_metadata.image_len, running_app_size); } // Get partition table partition @@ -309,7 +312,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } esp_partition_iterator_release(it); } - ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); + ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, + app_copy_target_part->address, running_app_size); #if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 // The following function takes longer than the 5 seconds timeout of WDT From 7560112144bf9e586d6ab54854294442ab7b6352 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:08:41 +0200 Subject: [PATCH 0412/2214] Bump aioesphomeapi from 44.16.1 to 44.17.0 (#15906) 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 1623876cb5..95d7c8c032 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.16.1 +aioesphomeapi==44.17.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From ee91ad8f068391a213cd1aa04b13aeed7e9a5dee Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 21 Apr 2026 18:25:05 -0500 Subject: [PATCH 0413/2214] [esp32] Add Secure Boot V1 ECDSA signing scheme for pre-rev-3.0 ESP32 (#15882) --- esphome/components/esp32/__init__.py | 95 +++++++++++--- esphome/components/esp32/post_build.py.script | 123 +++++++++++++++++- .../esp32/dummy_signing_key_v1_ecdsa.pem | 7 + .../esp32/test-signed_ota_v1.esp32-idf.yaml | 10 ++ 4 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 tests/components/esp32/dummy_signing_key_v1_ecdsa.pem create mode 100644 tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a68614cb43..77b405a449 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -128,23 +128,30 @@ ASSERTION_LEVELS = { SIGNING_SCHEMES = { "rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME", "ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME", + "ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME", } -# Chip variants that only support one signing scheme for Secure Boot V2. +# Chip variants that only support one V2 signing scheme. # Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h. -# Variants not listed in either set support both RSA and ECDSA +# Variants not listed in either set support both RSA and ECDSA V2 # (e.g. C5, C6, H2, P4). New variants should be added to the # appropriate set if they only support one scheme. -SIGNED_OTA_RSA_ONLY_VARIANTS = { - VARIANT_ESP32, +# Note: VARIANT_ESP32 is not listed here because it supports V2 RSA only +# when minimum_chip_revision >= 3.0, which requires special handling. +SIGNED_OTA_V2_RSA_ONLY_VARIANTS = { VARIANT_ESP32S2, VARIANT_ESP32S3, VARIANT_ESP32C3, } -SIGNED_OTA_ECC_ONLY_VARIANTS = { +SIGNED_OTA_V2_ECC_ONLY_VARIANTS = { VARIANT_ESP32C2, VARIANT_ESP32C61, } +# V1 ECDSA (Secure Boot V1) is only supported on the original ESP32. +# Based on SOC_SECURE_BOOT_V1 in soc_caps.h. +SIGNED_OTA_V1_ECDSA_VARIANTS = { + VARIANT_ESP32, +} COMPILER_OPTIMIZATIONS = { "DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG", @@ -991,25 +998,73 @@ def final_validate(config): if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): scheme = signed_ota[CONF_SIGNING_SCHEME] variant = config[CONF_VARIANT] - scheme_variant_conflicts = { - "ecdsa256": (SIGNED_OTA_RSA_ONLY_VARIANTS, "rsa3072"), - "rsa3072": (SIGNED_OTA_ECC_ONLY_VARIANTS, "ecdsa256"), - } - if (conflict := scheme_variant_conflicts.get(scheme)) and variant in conflict[ - 0 - ]: + min_rev = advanced.get(CONF_MINIMUM_CHIP_REVISION) + scheme_path = [ + CONF_FRAMEWORK, + CONF_ADVANCED, + CONF_SIGNED_OTA_VERIFICATION, + CONF_SIGNING_SCHEME, + ] + + # V1 ECDSA is only available on the original ESP32 + if scheme == "ecdsa_v1" and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS: errs.append( cv.Invalid( - f"Signing scheme '{scheme}' is not supported on " - f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.", - path=[ - CONF_FRAMEWORK, - CONF_ADVANCED, - CONF_SIGNED_OTA_VERIFICATION, - CONF_SIGNING_SCHEME, - ], + f"Signing scheme 'ecdsa_v1' is only supported on " + f"{VARIANT_FRIENDLY[VARIANT_ESP32]}. " + f"Use 'rsa3072' or 'ecdsa256' instead.", + path=scheme_path, ) ) + elif variant == VARIANT_ESP32: + # On ESP32, V2 RSA requires minimum_chip_revision >= 3.0 + # Note: string comparison works here because cv.one_of constrains + # min_rev to known ESP32_CHIP_REVISIONS values ("0.0".."3.1"). + if scheme == "rsa3072" and (min_rev is None or min_rev < "3.0"): + errs.append( + cv.Invalid( + f"Signing scheme 'rsa3072' on {VARIANT_FRIENDLY[variant]} " + f"requires minimum_chip_revision: '3.0' or higher " + f"(Secure Boot V2 RSA needs chip revision 3.0+). " + f"For older chip revisions, use 'ecdsa_v1' instead.", + path=scheme_path, + ) + ) + # ESP32 does not support V2 ECDSA (no SOC_SECURE_BOOT_V2_ECC) + elif scheme == "ecdsa256": + errs.append( + cv.Invalid( + f"Signing scheme 'ecdsa256' is not supported on " + f"{VARIANT_FRIENDLY[variant]}. Use 'rsa3072' (with " + f"minimum_chip_revision: '3.0') or 'ecdsa_v1' instead.", + path=scheme_path, + ) + ) + # V1 on rev 3.0+ -- suggest V2 RSA for stronger security + elif scheme == "ecdsa_v1" and min_rev is not None and min_rev >= "3.0": + _LOGGER.info( + "Using Secure Boot V1 ECDSA on %s rev %s. " + "Consider using 'rsa3072' (Secure Boot V2 RSA) for " + "stronger security on chip revision 3.0+.", + VARIANT_FRIENDLY[variant], + min_rev, + ) + else: + # Non-ESP32 variants: check V2 scheme-variant compatibility + scheme_variant_conflicts = { + "ecdsa256": (SIGNED_OTA_V2_RSA_ONLY_VARIANTS, "rsa3072"), + "rsa3072": (SIGNED_OTA_V2_ECC_ONLY_VARIANTS, "ecdsa256"), + } + if ( + conflict := scheme_variant_conflicts.get(scheme) + ) and variant in conflict[0]: + errs.append( + cv.Invalid( + f"Signing scheme '{scheme}' is not supported on " + f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.", + path=scheme_path, + ) + ) if CONF_OTA not in full_config: _LOGGER.warning( "Signed OTA verification is enabled but no OTA component is configured. " diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index 8d13214259..b329f6b82b 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -5,6 +5,7 @@ import json # noqa: E402 import os # noqa: E402 import pathlib # noqa: E402 import shutil # noqa: E402 +import subprocess # noqa: E402 from glob import glob # noqa: E402 @@ -25,6 +26,114 @@ def _parse_sdkconfig(sdkconfig_path): return options +def _generate_v1_verification_key(env): + """Generate the V1 ECDSA verification key binary and assembly source file. + + Secure Boot V1 embeds the public verification key directly in the app binary + as a compiled object (via a .S assembly file). The ESP-IDF CMake build generates + these files via custom commands, but PlatformIO's SCons bridge does not execute + them. This function replicates that logic: + 1. Extracts the raw public key from the PEM signing key using espsecure. + 2. Generates the .S assembly source that embeds the key bytes. + """ + build_dir = pathlib.Path(env.subst("$BUILD_DIR")) + project_dir = pathlib.Path(env.subst("$PROJECT_DIR")) + pioenv = env.subst("$PIOENV") + sdkconfig = _parse_sdkconfig(project_dir / f"sdkconfig.{pioenv}") + + if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME") != "y": + return + + bin_path = build_dir / "signature_verification_key.bin" + asm_path = build_dir / "signature_verification_key.bin.S" + + # Determine the source of the verification key + if sdkconfig.get("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES") == "y": + # Extract public key from the signing key + signing_key = sdkconfig.get("CONFIG_SECURE_BOOT_SIGNING_KEY") + if not signing_key: + return + signing_key_path = pathlib.Path(signing_key) + if not signing_key_path.exists(): + print(f"Error: V1 ECDSA signing key not found: {signing_key_path}") + env.Exit(1) + return + + if not bin_path.exists() or bin_path.stat().st_mtime < signing_key_path.stat().st_mtime: + python_exe = env.subst("$PYTHONEXE") + result = subprocess.run( + [python_exe, "-m", "espsecure", "extract_public_key", + "--keyfile", str(signing_key_path), str(bin_path)], + capture_output=True, text=True, + ) + if result.returncode != 0: + print(f"Error extracting V1 verification key: {result.stderr}") + env.Exit(1) + return + print(f"Extracted V1 ECDSA verification key from {signing_key_path.name}") + else: + # User-provided verification key -- should already be a raw binary file + verification_key = sdkconfig.get("CONFIG_SECURE_BOOT_VERIFICATION_KEY") + if not verification_key: + return + verification_key_path = pathlib.Path(verification_key) + if not verification_key_path.exists(): + print(f"Error: Verification key not found: {verification_key_path}") + env.Exit(1) + return + shutil.copyfile(str(verification_key_path), str(bin_path)) + + if not bin_path.exists(): + return + + # Generate the .S assembly file from the binary key data. + # Replicates ESP-IDF's data_file_embed_asm.cmake with RENAME_TO=signature_verification_key_bin. + # The file is needed in both the app build dir and the bootloader build dir, since + # the bootloader also embeds the verification key when CONFIG_SECURE_SIGNED_ON_BOOT_NO_SECURE_BOOT + # is enabled. PlatformIO's SCons bridge does not execute the CMake custom commands that + # normally generate these files. + data = bin_path.read_bytes() + varname = "signature_verification_key_bin" + + lines = [] + lines.append(f"/* Data converted from {bin_path.name} */") + lines.append(".data") + lines.append("#if !defined (__APPLE__) && !defined (__linux__)") + lines.append(".section .rodata.embedded") + lines.append("#endif") + lines.append(f"\n.global {varname}") + lines.append(f"{varname}:") + lines.append(f"\n.global _binary_{varname}_start") + lines.append(f"_binary_{varname}_start: /* for objcopy compatibility */") + + # Format binary data as .byte lines (16 bytes per line) + for i in range(0, len(data), 16): + chunk = data[i:i + 16] + hex_bytes = ", ".join(f"0x{b:02x}" for b in chunk) + lines.append(f".byte {hex_bytes}") + + lines.append(f"\n.global _binary_{varname}_end") + lines.append(f"_binary_{varname}_end: /* for objcopy compatibility */") + lines.append(f"\n.global {varname}_length") + lines.append(f"{varname}_length:") + lines.append(f".long {len(data)}") + lines.append("") + lines.append('#if defined (__linux__)') + lines.append('.section .note.GNU-stack,"",@progbits') + lines.append("#endif") + + asm_content = "\n".join(lines) + "\n" + + # Write to app build dir and bootloader build dir + asm_path.write_text(asm_content) + bootloader_dir = build_dir / "bootloader" + if bootloader_dir.is_dir(): + bootloader_bin = bootloader_dir / "signature_verification_key.bin" + bootloader_asm = bootloader_dir / "signature_verification_key.bin.S" + shutil.copyfile(str(bin_path), str(bootloader_bin)) + bootloader_asm.write_text(asm_content) + + def sign_firmware(source, target, env): """ Sign the firmware binary using espsecure.py if signed OTA verification is enabled. @@ -55,9 +164,12 @@ def sign_firmware(source, target, env): env.Exit(1) return - # ESPHome only exposes RSA3072 and ECDSA256 (both Secure Boot V2 schemes), - # so the espsecure signature version is always 2. - sign_version = "2" + # Determine espsecure signature version from the signing scheme: + # V1 ECDSA (Secure Boot V1) uses --version 1, V2 RSA/ECDSA use --version 2. + if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME") == "y": + sign_version = "1" + else: + sign_version = "2" firmware_name = os.path.basename(env.subst("$PROGNAME")) + ".bin" firmware_path = build_dir / firmware_name @@ -217,6 +329,11 @@ def esp32_copy_ota_bin(source, target, env): print(f"Copied firmware to {new_file_name}") +# Generate V1 ECDSA verification key files before build starts. +# Workaround for PlatformIO not executing CMake custom commands that extract +# the public key and generate the .S assembly file for Secure Boot V1. +_generate_v1_verification_key(env) # noqa: F821 + # Run signing first, then merge, then ota copy env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", sign_firmware) # noqa: F821 env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) # noqa: F821 diff --git a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem new file mode 100644 index 0000000000..bd09205606 --- /dev/null +++ b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem @@ -0,0 +1,7 @@ +*** DO NOT USE THIS KEY...EVER *** +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIEZIp96p7Z7QN6vxOFE5FdRNm535vW81Ax07KnGxVjiMoAoGCCqGSM49 +AwEHoUQDQgAEK+fBQDn1Q+r5lGwcDoMUgeg2Aq16LLrLUz7xWI6mS0PUClzolDIo +eaV/Pfjl7zAvkbQQsZq3rTNnr1eGAk5P+A== +-----END EC PRIVATE KEY----- +*** DO NOT USE THIS KEY...EVER *** diff --git a/tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml b/tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml new file mode 100644 index 0000000000..b32e157daf --- /dev/null +++ b/tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml @@ -0,0 +1,10 @@ +esp32: + variant: esp32 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_key: ../../components/esp32/dummy_signing_key_v1_ecdsa.pem + signing_scheme: ecdsa_v1 + +<<: !include common.yaml From b20fedd806d44ad3b3241347e39595cdea4b9089 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:18:21 +1200 Subject: [PATCH 0414/2214] [bl0906] Disable loop when idle and introduce BL0906Stage enum (#15884) Co-authored-by: J. Nick Koston --- esphome/components/bl0906/bl0906.cpp | 67 +++++++++++++++++++--------- esphome/components/bl0906/bl0906.h | 19 +++++++- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 70db235a37..d554057f7b 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,58 +20,77 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) } void BL0906::loop() { - if (this->current_channel_ == UINT8_MAX) { - return; - } - while (this->available()) this->flush(); - if (this->current_channel_ == 0) { + if (this->current_stage_ == STAGE_IDLE) { + // Woken up between cycles to drain the action queue. Go back to sleep. + this->handle_actions_(); + this->disable_loop(); + return; + } + + if (this->current_stage_ == STAGE_TEMP) { // Temperature this->read_data_(BL0906_TEMPERATURE, BL0906_TREF, this->temperature_sensor_); - } else if (this->current_channel_ == 1) { + } else if (this->current_stage_ == STAGE_CHANNEL_1) { this->read_data_(BL0906_I_1_RMS, BL0906_IREF, this->current_1_sensor_); this->read_data_(BL0906_WATT_1, BL0906_PREF, this->power_1_sensor_); this->read_data_(BL0906_CF_1_CNT, BL0906_EREF, this->energy_1_sensor_); - } else if (this->current_channel_ == 2) { + } else if (this->current_stage_ == STAGE_CHANNEL_2) { this->read_data_(BL0906_I_2_RMS, BL0906_IREF, this->current_2_sensor_); this->read_data_(BL0906_WATT_2, BL0906_PREF, this->power_2_sensor_); this->read_data_(BL0906_CF_2_CNT, BL0906_EREF, this->energy_2_sensor_); - } else if (this->current_channel_ == 3) { + } else if (this->current_stage_ == STAGE_CHANNEL_3) { this->read_data_(BL0906_I_3_RMS, BL0906_IREF, this->current_3_sensor_); this->read_data_(BL0906_WATT_3, BL0906_PREF, this->power_3_sensor_); this->read_data_(BL0906_CF_3_CNT, BL0906_EREF, this->energy_3_sensor_); - } else if (this->current_channel_ == 4) { + } else if (this->current_stage_ == STAGE_CHANNEL_4) { this->read_data_(BL0906_I_4_RMS, BL0906_IREF, this->current_4_sensor_); this->read_data_(BL0906_WATT_4, BL0906_PREF, this->power_4_sensor_); this->read_data_(BL0906_CF_4_CNT, BL0906_EREF, this->energy_4_sensor_); - } else if (this->current_channel_ == 5) { + } else if (this->current_stage_ == STAGE_CHANNEL_5) { this->read_data_(BL0906_I_5_RMS, BL0906_IREF, this->current_5_sensor_); this->read_data_(BL0906_WATT_5, BL0906_PREF, this->power_5_sensor_); this->read_data_(BL0906_CF_5_CNT, BL0906_EREF, this->energy_5_sensor_); - } else if (this->current_channel_ == 6) { + } else if (this->current_stage_ == STAGE_CHANNEL_6) { this->read_data_(BL0906_I_6_RMS, BL0906_IREF, this->current_6_sensor_); this->read_data_(BL0906_WATT_6, BL0906_PREF, this->power_6_sensor_); this->read_data_(BL0906_CF_6_CNT, BL0906_EREF, this->energy_6_sensor_); - } else if (this->current_channel_ == UINT8_MAX - 2) { + } else if (this->current_stage_ == STAGE_FREQ) { // Frequency - this->read_data_(BL0906_FREQUENCY, BL0906_FREF, frequency_sensor_); + this->read_data_(BL0906_FREQUENCY, BL0906_FREF, this->frequency_sensor_); // Voltage - this->read_data_(BL0906_V_RMS, BL0906_UREF, voltage_sensor_); - } else if (this->current_channel_ == UINT8_MAX - 1) { + this->read_data_(BL0906_V_RMS, BL0906_UREF, this->voltage_sensor_); + } else if (this->current_stage_ == STAGE_POWER) { // Total power this->read_data_(BL0906_WATT_SUM, BL0906_WATT, this->total_power_sensor_); // Total Energy this->read_data_(BL0906_CF_SUM_CNT, BL0906_CF, this->total_energy_sensor_); - } else { - this->current_channel_ = UINT8_MAX - 2; // Go to frequency and voltage - return; } - this->current_channel_++; + this->advance_stage_(); this->handle_actions_(); } +void BL0906::advance_stage_() { + switch (this->current_stage_) { + case STAGE_CHANNEL_6: + this->current_stage_ = STAGE_FREQ; + break; + case STAGE_FREQ: + this->current_stage_ = STAGE_POWER; + break; + case STAGE_POWER: + // Cycle complete; sleep until the next update(). + this->current_stage_ = STAGE_IDLE; + this->disable_loop(); + break; + default: + this->current_stage_ = static_cast(this->current_stage_ + 1); + break; + } +} + void BL0906::setup() { while (this->available()) this->flush(); @@ -85,12 +104,20 @@ void BL0906::setup() { this->bias_correction_(BL0906_RMSOS_6, 0.01200, 0); // Calibration current_6 this->write_array(USR_WRPROT_ONLYREAD, sizeof(USR_WRPROT_ONLYREAD)); + + // Loop stays idle until the first update() or enqueued action. + this->disable_loop(); } -void BL0906::update() { this->current_channel_ = 0; } +void BL0906::update() { + this->current_stage_ = STAGE_TEMP; + this->enable_loop(); +} size_t BL0906::enqueue_action_(ActionCallbackFuncPtr function) { this->action_queue_.push_back(function); + // Ensure the queue is serviced even if the read cycle has already completed. + this->enable_loop(); return this->action_queue_.size(); } diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index 493b645c89..f7ba5423d2 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -12,6 +12,22 @@ namespace esphome { namespace bl0906 { +// Stage values for the read state machine. After STAGE_CHANNEL_6 the state machine +// jumps to the two sentinel stages below, then to STAGE_IDLE which marks the cycle +// as complete and disables the loop. +enum BL0906Stage : uint8_t { + STAGE_TEMP = 0, // chip temperature + STAGE_CHANNEL_1 = 1, // per-phase current + power + energy + STAGE_CHANNEL_2 = 2, + STAGE_CHANNEL_3 = 3, + STAGE_CHANNEL_4 = 4, + STAGE_CHANNEL_5 = 5, + STAGE_CHANNEL_6 = 6, + STAGE_FREQ = UINT8_MAX - 2, // frequency + voltage + STAGE_POWER = UINT8_MAX - 1, // total power + total energy + STAGE_IDLE = UINT8_MAX, // cycle complete +}; + struct DataPacket { // NOLINT(altera-struct-pack-align) uint8_t l{0}; uint8_t m{0}; @@ -79,7 +95,8 @@ class BL0906 : public PollingComponent, public uart::UARTDevice { void bias_correction_(uint8_t address, float measurements, float correction); - uint8_t current_channel_{0}; + BL0906Stage current_stage_{STAGE_IDLE}; + void advance_stage_(); size_t enqueue_action_(ActionCallbackFuncPtr function); void handle_actions_(); From 9cebce1b6ecefcdb6d2c8fa3b6b854f08c24484e Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Wed, 22 Apr 2026 03:19:01 +0200 Subject: [PATCH 0415/2214] [substitutions] Improve error messages with include stack trace (#15874) Co-authored-by: J. Nick Koston --- esphome/components/packages/__init__.py | 64 +++++-- esphome/components/substitutions/__init__.py | 83 +++----- esphome/yaml_util.py | 119 ++++++++++++ .../component_tests/packages/test_packages.py | 24 ++- tests/unit_tests/test_substitutions.py | 47 ++++- tests/unit_tests/test_yaml_util.py | 181 +++++++++++++++++- 6 files changed, 432 insertions(+), 86 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 97a5309480..b6ec0067c9 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -42,6 +42,11 @@ DOMAIN = CONF_PACKAGES # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 +PackageCallback = Callable[ + [dict | str | yaml_util.IncludeFile, ContextVars | None, yaml_util.DocumentPath], + dict, +] + def is_remote_package(package_config: dict) -> bool: """Returns True if the package_config is a remote package definition.""" @@ -281,8 +286,9 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict: def _walk_package_dict( packages: dict, - callback: Callable[[dict, ContextVars | None], dict], + callback: PackageCallback, context: ContextVars | None, + path: yaml_util.DocumentPath, ) -> cv.Invalid | None: """Iterate a packages dict in reverse priority order, invoking callback on each entry. @@ -291,7 +297,9 @@ def _walk_package_dict( for package_name, package_config in reversed(packages.items()): with cv.prepend_path(package_name): try: - packages[package_name] = callback(package_config, context) + packages[package_name] = callback( + package_config, context, path + [package_name] + ) except cv.Invalid as err: return err return None @@ -299,20 +307,22 @@ def _walk_package_dict( def _walk_package_list( packages: list, - callback: Callable[[dict, ContextVars | None], dict], + callback: PackageCallback, context: ContextVars | None, + path: yaml_util.DocumentPath, ) -> None: """Iterate a packages list in reverse priority order, invoking callback on each entry.""" for idx in reversed(range(len(packages))): with cv.prepend_path(idx): - packages[idx] = callback(packages[idx], context) + packages[idx] = callback(packages[idx], context, path + [idx]) def _walk_packages( config: dict, - callback: Callable[[dict, ContextVars | None], dict], + callback: PackageCallback, context: ContextVars | None = None, validate_deprecated: bool = True, + path: yaml_util.DocumentPath | None = None, ) -> dict: """Walks the packages structure in priority order, invoking ``callback`` on each package definition found. @@ -323,19 +333,24 @@ def _walk_packages( if CONF_PACKAGES not in config: return config packages = config[CONF_PACKAGES] + packages_path = (path or []) + [CONF_PACKAGES] with cv.prepend_path(CONF_PACKAGES): if isinstance(packages, yaml_util.IncludeFile): # If the packages key is an IncludeFile, resolve it first before processing. - packages, _ = resolve_include(packages, [], context, strict_undefined=False) + packages = resolve_include( + packages, packages_path, context, strict_undefined=False + ) if not isinstance(packages, (dict, list)): raise cv.Invalid( f"Packages must be a key to value mapping or list, got {type(packages)} instead" ) if not isinstance(packages, dict): - _walk_package_list(packages, callback, context) - elif (result := _walk_package_dict(packages, callback, context)) is not None: + _walk_package_list(packages, callback, context, packages_path) + elif ( + result := _walk_package_dict(packages, callback, context, packages_path) + ) is not None: if not validate_deprecated or any( is_package_definition(v) for v in packages.values() ): @@ -344,14 +359,18 @@ def _walk_packages( # This block can be removed once the single-package # deprecation period (2026.7.0) is over. config[CONF_PACKAGES] = [packages] - return _walk_packages(deprecate_single_package(config), callback, context) + return _walk_packages( + deprecate_single_package(config), callback, context, path=path + ) config[CONF_PACKAGES] = packages return config def _substitute_package_definition( - package_config: dict | str, context_vars: ContextVars | None + package_config: dict | str, + context_vars: ContextVars | None, + path: yaml_util.DocumentPath | None = None, ) -> dict | str: """Substitute variables in a package definition string or remote package dict. @@ -369,12 +388,12 @@ def _substitute_package_definition( errors: ErrList = [] package_config = substitute( item=package_config, - path=[], + path=path or [], parent_context=context_vars or ContextVars(), strict_undefined=False, errors=errors, ) - raise_first_undefined(errors, package_config, "package definition") + raise_first_undefined(errors, "package definition") return package_config @@ -432,6 +451,7 @@ class _PackageProcessor: self, package_config: dict | str | yaml_util.IncludeFile, context_vars: ContextVars | None, + path: yaml_util.DocumentPath, ) -> dict: """Resolve a package definition to a concrete ``dict`` and fetch remote packages. @@ -454,15 +474,15 @@ class _PackageProcessor: """ for _ in range(MAX_INCLUDE_DEPTH): if isinstance(package_config, yaml_util.IncludeFile): - package_config, _ = resolve_include( + package_config = resolve_include( package_config, - [], + path, context_vars or ContextVars(), strict_undefined=False, ) package_config = _substitute_package_definition( - package_config, context_vars + package_config, context_vars, path ) package_config = PACKAGE_SCHEMA(package_config) if isinstance(package_config, dict): @@ -483,13 +503,16 @@ class _PackageProcessor: _update_substitutions_context(self.parent_context, subs) def process_package( - self, package_config: dict | str, context_vars: ContextVars | None + self, + package_config: dict | str, + context_vars: ContextVars | None, + path: yaml_util.DocumentPath, ) -> dict: """Resolve a single package and recurse into any nested packages.""" from_remote = isinstance(package_config, dict) and is_remote_package( package_config ) - package_config = self.resolve_package(package_config, context_vars) + package_config = self.resolve_package(package_config, context_vars, path) self.collect_substitutions(package_config) if CONF_PACKAGES not in package_config: @@ -509,6 +532,7 @@ class _PackageProcessor: self.process_package, context_vars, validate_deprecated=not from_remote, + path=path, ) @@ -565,11 +589,13 @@ def merge_packages(config: dict) -> dict: merge_list: list[dict] = [] def process_package_callback( - package_config: dict, context: ContextVars | None + package_config: dict, + context: ContextVars | None, + path: yaml_util.DocumentPath | None = None, ) -> dict: """This will be called for each package found in the config.""" merge_list.append(package_config) - return _walk_packages(package_config, process_package_callback) + return _walk_packages(package_config, process_package_callback, path=path) _walk_packages(config, process_package_callback, validate_deprecated=False) # Merge all packages into the main config: diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 8bbccffca1..fb7cd7c51b 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -11,9 +11,11 @@ from esphome.types import ConfigType from esphome.util import OrderedDict from esphome.yaml_util import ( ConfigContext, + DocumentPath, ESPHomeDataBase, ESPLiteralValue, IncludeFile, + format_path, make_data_base, ) @@ -23,8 +25,8 @@ CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) ContextVars = ChainMap[str, Any] -SubstitutionPath = list[int | str] -ErrList = list[tuple[UndefinedError, SubstitutionPath, Any]] +ErrList = list[tuple[UndefinedError, DocumentPath, Any]] + # Module-level instance is safe: context_vars is passed per-call, and context_trace # is stack-saved/restored within expand(). Not thread-safe — only use from one thread. jinja = Jinja() @@ -32,16 +34,13 @@ jinja = Jinja() def raise_first_undefined( errors: ErrList, - source: Any, context_label: str, ) -> None: """If *errors* is non-empty, raise ``cv.Invalid`` for the first undefined variable. - The raised error names the missing variable, the path walked into *source* - (for nested dicts, e.g. ``url`` or ``ref``), and the YAML source location - when *source* carries one. Only the first error is surfaced; the user will - re-run after fixing it and any remaining undefined variables will be - reported then. + The raised error names the missing variable and its location in the include + stack. Only the first error is surfaced; the user will re-run after fixing it + and any remaining undefined variables will be reported then. ``context_label`` is the noun describing where the undefined variable appeared (e.g. ``"package definition"``). @@ -57,26 +56,8 @@ def raise_first_undefined( for e, p_path, _ in errors[1:] ) _LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras) - # Prefer the location of the offending scalar (e.g. the `url:` value) over - # the enclosing package-definition dict so the message points at the exact - # line/column that carries the undefined variable. - location_node = ( - err_value - if isinstance(err_value, ESPHomeDataBase) and err_value.esp_range is not None - else source - ) - location = "" - if ( - isinstance(location_node, ESPHomeDataBase) - and location_node.esp_range is not None - ): - mark = location_node.esp_range.start_mark - # DocumentLocation.line/column are 0-based (from the YAML Mark). Render - # as 1-based to match config.line_info() and editor line numbering. - location = f" (in {mark.document} {mark.line + 1}:{mark.column + 1})" - field = f" at '{'->'.join(str(p) for p in err_path)}'" if err_path else "" raise cv.Invalid( - f"Undefined variable in {context_label}{field}: {err.message}{location}" + f"Undefined variable in {context_label}: {err.message}\n{format_path(err_path, err_value)}" ) @@ -145,7 +126,7 @@ def _resolve_var(name: str, context_vars: ContextVars) -> Any: def _handle_undefined( err: UndefinedError, - path: SubstitutionPath, + path: DocumentPath, value: Any, strict_undefined: bool, errors: ErrList | None, @@ -163,7 +144,7 @@ def _handle_undefined( def _expand_substitutions( value: str, - path: SubstitutionPath, + path: DocumentPath, context_vars: ContextVars, strict_undefined: bool, errors: ErrList | None, @@ -236,7 +217,7 @@ def _expand_substitutions( f"\nEvaluation stack: (most recent evaluation last)" f"\n{err.stack_trace_str()}" f"\nRelevant context:\n{err.context_trace_str()}" - f"\nSee {'->'.join(str(x) for x in path)}", + f"\n{format_path(path, orig_value)}", path, ) from err else: @@ -345,15 +326,13 @@ def push_context( def resolve_include( include: IncludeFile, - path: list[int | str], + path: DocumentPath, context_vars: ContextVars, strict_undefined: bool = True, errors: ErrList | None = None, -) -> tuple[Any, str]: +) -> Any: """Resolve an include, substituting the filename if needed. - Returns the loaded content and the resolved filename. - Note: no path-traversal validation is performed on the resolved filename. A substitution that resolves to an absolute path will bypass the parent directory (Path.__truediv__ ignores the left operand for absolute paths). @@ -361,44 +340,44 @@ def resolve_include( values (including command-line substitutions), so path restrictions are an explicit non-goal here. """ - original = str(include.file) + original = include.file + original_str = str(original) filename = str( _expand_substitutions( - original, path + ["file"], context_vars, strict_undefined, errors + original_str, path + ["file"], context_vars, strict_undefined, errors ) ) - if filename != original: + substituted = filename != original_str + if substituted: include = IncludeFile( include.parent_file, filename, include.vars, include.yaml_loader ) try: - return include.load(), filename + return include.load() except esphome.core.EsphomeError as err: + resolved = f" (expanded from '{original}')" if substituted else "" raise cv.Invalid( - f"Error including file '{filename}': {err}", + f"Error including file '{filename}'{resolved}: {err}" + f"\n{format_path(path, original)}", path + [f"<{filename}>"], ) from err def _substitute_include( include: IncludeFile, - path: list[int | str], + path: DocumentPath, context_vars: ContextVars, strict_undefined: bool, errors: ErrList | None, ) -> Any: """Resolve an include and substitute its content.""" - content, filename = resolve_include( - include, path, context_vars, strict_undefined, errors - ) - return substitute( - content, path + [f"<{filename}>"], context_vars, strict_undefined, errors - ) + content = resolve_include(include, path, context_vars, strict_undefined, errors) + return substitute(content, path, context_vars, strict_undefined, errors) def substitute( item: Any, - path: SubstitutionPath, + path: DocumentPath, parent_context: ContextVars, strict_undefined: bool, errors: ErrList | None = None, @@ -451,16 +430,12 @@ def _warn_unresolved_variables(errors: ErrList) -> None: for err, path, expression in errors: if "password" in path: continue - location: str = "->".join(str(x) for x in path) - if isinstance(expression, ESPHomeDataBase) and expression.esp_range is not None: - location += f" in {str(expression.esp_range.start_mark)}" - _LOGGER.warning( "The string '%s' looks like an expression," - " but could not resolve all the variables: %s (see %s)", + " but could not resolve all the variables: %s\n%s", expression, err.message, - location, + format_path(path, expression), ) @@ -479,7 +454,7 @@ def resolve_substitutions_block( # Single-shot resolution — matches ``_walk_packages`` for the # ``packages: !include`` entry point. Chained includes (an include that # itself loads another ``!include`` at the top level) are not supported. - substitutions, _ = resolve_include( + substitutions = resolve_include( substitutions, [], ContextVars(command_line_substitutions or {}), diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index e15adff935..42da27ec14 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -48,6 +48,8 @@ _SECRET_VALUES = {} # Not thread-safe — config processing is single-threaded today. _load_listeners: list[Callable[[Path], None]] = [] +DocumentPath = list[str | int] + @contextmanager def track_yaml_loads() -> Generator[list[Path]]: @@ -679,6 +681,123 @@ def is_secret(value): return None +def _path_doc(item: Any) -> str | None: + """Return the source document name if *item* carries location info.""" + if isinstance(item, ESPHomeDataBase) and (r := item.esp_range) is not None: + return r.start_mark.document + return None + + +def _fmt_mark(loc: Any) -> str: + """Render a DocumentLocation as a 1-based 'file line:col' string.""" + return f"{loc.document} {loc.line + 1}:{loc.column + 1}" + + +def _obj_loc(obj: Any) -> str: + """Return formatted source location for *obj*, or '' if it has none.""" + if isinstance(obj, ESPHomeDataBase) and (r := obj.esp_range) is not None: + return _fmt_mark(r.start_mark) + return "" + + +def _fmt_segment(seg: list) -> str: + """Format a path segment, rendering integers as [n] subscripts.""" + parts: list[str] = [] + for item in seg: + if isinstance(item, int): + if parts: + parts[-1] = f"{parts[-1]}[{item}]" + else: + parts.append(f"[{item}]") + else: + parts.append(str(item)) + return "->".join(parts) + + +def _split_into_frames( + path: DocumentPath, +) -> list[tuple[list, str]]: + """Group *path* into per-file frames at include boundaries. + + A "frame" is the slice of the path that belongs to one source document. + Each path item is either: + + * a **located key** — has an ``ESPHomeDataBase`` source mark; this is + what tells us which document owns the surrounding keys. + * an **integer** — a list subscript; always attaches to the open frame + (renders as ``foo[3]`` on the previous name). + * an **unlocated string** — a key with no source mark (e.g. constants + like ``CONF_PACKAGES``); it describes the parent of the *next* file, + so it migrates to the next frame when the document changes. + + Returns a list of ``(items, "file line:col")`` tuples in walk order + (outermost frame first). + """ + frames: list[tuple[list, str]] = [] + open_frame: list = [] + next_frame_keys: list = [] # unlocated strings buffered for the next frame + open_doc: str | None = None + open_loc = "" + + for item in path: + doc = _path_doc(item) + if doc is None: + # Ints subscript the open frame's last name; everything else + # (strings, or leading ints with no open frame) is buffered for + # the next frame. + if isinstance(item, int) and open_doc is not None: + open_frame.append(item) + else: + next_frame_keys.append(item) + continue + if open_doc is not None and doc != open_doc: + # Crossed an include boundary: close the open frame. + frames.append((open_frame, open_loc)) + open_frame = [] + open_frame.extend(next_frame_keys) + next_frame_keys.clear() + open_frame.append(item) + open_doc = doc + open_loc = _fmt_mark(item.esp_range.start_mark) + + if open_doc is not None: + # Trailing buffered keys belong to the innermost (last) frame. + open_frame.extend(next_frame_keys) + frames.append((open_frame, open_loc)) + return frames + + +def format_path(path: DocumentPath, current_obj: Any) -> str: + """Build a human-readable include stack from a config path. + + Each YAML key in *path* that carries an ``ESPHomeDataBase`` ``esp_range`` + reveals which file it came from. When the source document changes between + consecutive such keys, that is an include boundary. The path is split + into per-file frames and formatted innermost-first, e.g.:: + + In: packages->roam in common/package/wifi.yaml 26:10 + Included from packages->net in common/hardware.yaml 44:2 + Included from packages->device in my_project.yaml 11:2 + + The innermost ``In:`` line uses the location from *current_obj* when + available (the value that triggered the error) for extra precision. + """ + frames = _split_into_frames(path) + obj_loc = _obj_loc(current_obj) + + if not frames: + # No source info anywhere in the path: render as a flat path, + # using current_obj's location if it happens to have one. + suffix = f" in {obj_loc}" if obj_loc else "" + return f"In: {_fmt_segment(path)}{suffix}" + + inner_seg, inner_loc = frames[-1] + lines = [f"In: {_fmt_segment(inner_seg)} in {obj_loc or inner_loc}"] + for seg, loc in reversed(frames[:-1]): + lines.append(f" Included from {_fmt_segment(seg)} in {loc}") + return "\n".join(lines) + + class ESPHomeDumper(yaml.SafeDumper): def represent_mapping(self, tag, mapping, flow_style=None): value = [] diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 0bd339efa9..af4b6db796 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -46,7 +46,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.util import OrderedDict -from esphome.yaml_util import IncludeFile, add_context, load_yaml +from esphome.yaml_util import DocumentPath, IncludeFile, add_context, load_yaml # Test strings TEST_DEVICE_NAME = "test_device_name" @@ -1113,7 +1113,7 @@ def test_packages_include_file_resolves_to_list(mock_resolve_include) -> None: """When packages: is an IncludeFile that resolves to a list, it is processed correctly.""" include_file = MagicMock(spec=IncludeFile) package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} - mock_resolve_include.return_value = ([package_content], None) + mock_resolve_include.return_value = [package_content] config = {CONF_PACKAGES: include_file} result = do_packages_pass(config) @@ -1127,7 +1127,7 @@ def test_packages_include_file_resolves_to_dict(mock_resolve_include) -> None: """When packages: is an IncludeFile that resolves to a dict, it is processed correctly.""" include_file = MagicMock(spec=IncludeFile) package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} - mock_resolve_include.return_value = ({"network": package_content}, None) + mock_resolve_include.return_value = {"network": package_content} config = {CONF_PACKAGES: include_file} result = do_packages_pass(config) @@ -1142,7 +1142,7 @@ def test_packages_include_file_resolves_to_invalid_type_raises( ) -> None: """When packages: is an IncludeFile that resolves to an invalid type, cv.Invalid is raised.""" include_file = MagicMock(spec=IncludeFile) - mock_resolve_include.return_value = ("not_a_dict_or_list", None) + mock_resolve_include.return_value = "not_a_dict_or_list" config = {CONF_PACKAGES: include_file} with pytest.raises( @@ -1215,7 +1215,9 @@ def test_named_dict_with_include_files_no_false_deprecation_warning( call_count = 0 - def failing_callback(package_config: dict, context: object) -> dict: + def failing_callback( + package_config: dict, context: object, path: DocumentPath | None = None + ) -> dict: nonlocal call_count call_count += 1 if call_count == 1: @@ -1251,7 +1253,9 @@ def test_validate_deprecated_false_raises_directly( call_count = 0 - def failing_callback(package_config: dict, context: object) -> dict: + def failing_callback( + package_config: dict, context: object, path: DocumentPath | None = None + ) -> dict: nonlocal call_count call_count += 1 if call_count == 1: @@ -1283,7 +1287,9 @@ def test_error_on_first_declared_package_still_detected() -> None: call_count = 0 - def fail_on_last(package_config: dict, context: object) -> dict: + def fail_on_last( + package_config: dict, context: object, path: DocumentPath | None = None + ) -> dict: nonlocal call_count call_count += 1 # Reverse iteration: third_pkg (1), second_pkg (2), first_pkg (3) @@ -1312,7 +1318,9 @@ def test_deprecated_single_package_fallback_still_works( attempt = 0 - def fail_then_succeed(package_config: dict, context: object) -> dict: + def fail_then_succeed( + package_config: dict, context: object, path: DocumentPath | None = None + ) -> dict: nonlocal attempt attempt += 1 if attempt == 1: diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 3599e703d9..215ec291f9 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -659,7 +659,7 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: cv.Invalid, match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded", ): - processor.resolve_package(package_config, substitutions.ContextVars()) + processor.resolve_package(package_config, substitutions.ContextVars(), []) def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: @@ -690,7 +690,7 @@ def test_raise_first_undefined_logs_extras_at_debug( caplog.at_level(logging.DEBUG, logger="esphome.components.substitutions"), pytest.raises(cv.Invalid) as exc_info, ): - substitutions.raise_first_undefined(errors, None, "package definition") + substitutions.raise_first_undefined(errors, "package definition") # First error is surfaced as the cv.Invalid message. raised = str(exc_info.value) @@ -706,7 +706,7 @@ def test_raise_first_undefined_logs_extras_at_debug( def test_raise_first_undefined_noop_on_empty() -> None: """An empty errors list is a no-op — no exception, no log.""" - substitutions.raise_first_undefined([], None, "package definition") + substitutions.raise_first_undefined([], "package definition") def test_do_substitution_pass_included_substitutions_must_be_mapping( @@ -778,4 +778,43 @@ def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> No ) processor = _PackageProcessor({}, None, False) with pytest.raises(cv.Invalid, match="unresolved substitutions"): - processor.resolve_package(package_config, substitutions.ContextVars()) + processor.resolve_package(package_config, substitutions.ContextVars(), []) + + +def test_resolve_include_error_shows_expanded_from_when_substituted( + tmp_path: Path, +) -> None: + """When a substituted filename fails to load, the error includes '(expanded from ...)'.""" + parent = tmp_path / "main.yaml" + parent.write_text("") + + def failing_loader(_path: Path) -> None: + raise EsphomeError("File not found") + + include = yaml_util.IncludeFile(parent, "${device}.yaml", None, failing_loader) + context = substitutions.ContextVars({"device": "my_device"}) + + with pytest.raises(cv.Invalid) as exc_info: + substitutions.resolve_include(include, [], context) + + msg = str(exc_info.value) + assert "my_device.yaml" in msg + assert "expanded from '${device}.yaml'" in msg + + +def test_resolve_include_error_no_expanded_from_for_literal_filename( + tmp_path: Path, +) -> None: + """When a literal filename fails to load, the error has no 'expanded from' clause.""" + parent = tmp_path / "main.yaml" + parent.write_text("") + + def failing_loader(_path: Path) -> None: + raise EsphomeError("File not found") + + include = yaml_util.IncludeFile(parent, "literal.yaml", None, failing_loader) + + with pytest.raises(cv.Invalid) as exc_info: + substitutions.resolve_include(include, [], substitutions.ContextVars()) + + assert "expanded from" not in str(exc_info.value) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index bfd60de44d..e3aa2a16f5 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -9,8 +9,9 @@ from esphome import core, yaml_util from esphome.components import substitutions from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv -from esphome.core import EsphomeError +from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.util import OrderedDict +from esphome.yaml_util import ESPHomeDataBase, format_path, make_data_base @pytest.fixture(autouse=True) @@ -712,3 +713,181 @@ def test_yaml_merge_chain_include_depth_exceeded() -> None: yaml_text = "base:\n <<: !include loop.yaml\n" with pytest.raises(EsphomeError, match="Maximum include chain depth"): yaml_util.parse_yaml(parent, io.StringIO(yaml_text), self_referencing_loader) + + +def _located(value, doc: str, line: int, col: int): + """Return *value* wrapped with a fake ESPHomeDataBase source location.""" + loc = DocumentLocation(doc, line, col) + obj = make_data_base(value) + if isinstance(obj, ESPHomeDataBase): + obj._esp_range = DocumentRange(loc, loc) + return obj + + +def test_format_path_no_location_info_returns_flat_path(): + """Plain path items with no esp_range produce a simple flat 'In:' line.""" + result = format_path(["wifi", "ssid"], None) + assert result == "In: wifi->ssid" + + +def test_format_path_no_location_info_current_obj_adds_file(): + """When path has no location but current_obj does, its location is shown.""" + obj = _located("${var}", "main.yaml", 5, 10) + result = format_path(["wifi", "ssid"], obj) + assert result == "In: wifi->ssid in main.yaml 6:11" + + +def test_format_path_single_frame_no_include_boundary(): + """All located keys from the same document → single 'In:' line, no 'Included from'.""" + path = ["packages", _located("pkg1", "root.yaml", 5, 2)] + result = format_path(path, None) + assert result.startswith("In: packages->pkg1 in root.yaml 6:3") + assert "Included from" not in result + + +def test_format_path_two_frames_shows_included_from(): + """Keys from two different documents produce 'In:' + one 'Included from' line.""" + path = [ + "packages", + _located("device", "root.yaml", 10, 2), + "packages", + _located("inner", "hardware.yaml", 3, 2), + ] + result = format_path(path, None) + assert "In: packages->inner in hardware.yaml 4:3" in result + assert "Included from packages->device in root.yaml 11:3" in result + + +def test_format_path_three_frames_full_include_stack(): + """Three document levels produce two 'Included from' lines in correct order.""" + path = [ + "packages", + _located("device", "root.yaml", 10, 2), + "packages", + _located("_wifi_", "hardware.yaml", 43, 2), + "packages", + _located("_roam_", "wifi.yaml", 25, 2), + ] + result = format_path(path, None) + lines = result.splitlines() + assert lines[0].startswith("In: packages->_roam_ in wifi.yaml") + assert lines[1].startswith(" Included from packages->_wifi_ in hardware.yaml") + assert lines[2].startswith(" Included from packages->device in root.yaml") + + +def test_format_path_current_obj_overrides_innermost_location(): + """current_obj's esp_range replaces the key's column for the 'In:' line.""" + path = ["packages", _located("pkg1", "root.yaml", 5, 2)] + # Value (the expression) sits at column 10, not column 2 like the key + value = _located("${undefined}", "root.yaml", 5, 10) + result = format_path(path, value) + assert "6:11" in result + assert "6:3" not in result + + +def test_format_path_empty_path_with_no_location(): + """Empty path with no location info returns 'In: '.""" + result = format_path([], None) + assert result == "In: " + + +def test_format_path_integer_path_items_formatted_as_subscript(): + """Integer indices are rendered as [n] subscripts in the flat fallback.""" + result = format_path(["packages", 0], None) + assert result == "In: packages[0]" + + +def test_format_path_integer_list_index_attached_to_previous_frame(): + """A list index between two include boundaries attaches to the outer frame.""" + path = [ + "packages", + _located("packages", "main.yaml", 5, 0), + 0, + _located("packages", "level1.yaml", 2, 0), + 0, + _located("esphome", "level2.yaml", 0, 0), + _located("name", "level2.yaml", 1, 8), + ] + result = format_path(path, None) + lines = result.splitlines() + assert lines[0].startswith("In: esphome->name in level2.yaml") + assert "packages[0]" in lines[1] and "level1.yaml" in lines[1] + assert "packages[0]" in lines[2] and "main.yaml" in lines[2] + + +def test_format_path_trailing_unlocated_string_after_located_key(): + """Plain string keys after the last located key must still appear in output.""" + path = [_located("packages", "main.yaml", 5, 0), "sub", "key"] + result = format_path(path, None) + assert result == "In: packages->sub->key in main.yaml 6:1" + + +def test_format_path_trailing_unlocated_int_attaches_to_current_frame(): + """Trailing ints attach to the open frame's last key (subscript), strings + buffer until end-of-path and then flush behind.""" + path = [_located("packages", "main.yaml", 5, 0), 0, "sub"] + result = format_path(path, None) + # Int attaches to 'packages' as [0] subscript; trailing 'sub' is flushed + # at end and appears after. + assert result == "In: packages[0]->sub in main.yaml 6:1" + + +def test_format_path_only_trailing_unlocated_strings_are_preserved(): + """Trailing pending items must not be silently dropped after the last frame.""" + path = [ + _located("packages", "main.yaml", 5, 0), + _located("inner", "hardware.yaml", 3, 0), + "tail1", + "tail2", + ] + result = format_path(path, None) + lines = result.splitlines() + assert lines[0] == "In: inner->tail1->tail2 in hardware.yaml 4:1" + assert lines[1] == " Included from packages in main.yaml 6:1" + + +def test_format_path_leading_int_with_no_current_doc_goes_to_pending(): + """An int before any located key is buffered and shown in the first frame.""" + path = [0, _located("name", "main.yaml", 1, 0)] + result = format_path(path, None) + # Leading ints have no preceding name to subscript onto, so they render + # as bare [n] in the formatted segment. + assert result == "In: [0]->name in main.yaml 2:1" + + +def test_format_path_only_unlocated_int_returns_flat_fallback(): + """Path with only an int and no location info renders via the flat fallback.""" + result = format_path([0], None) + assert result == "In: [0]" + + +def test_format_path_current_obj_in_different_doc_than_innermost_frame(): + """current_obj's location is preferred even when its document differs from the frame's.""" + path = [_located("packages", "root.yaml", 1, 0)] + value = _located("${var}", "other.yaml", 9, 4) + result = format_path(path, value) + # Innermost line uses current_obj's mark (other.yaml 10:5), not the key's. + assert result == "In: packages in other.yaml 10:5" + + +def test_format_path_current_obj_without_location_falls_back_to_key(): + """An ESPHomeDataBase current_obj with no esp_range falls back to the key's location.""" + + class _NoRange(ESPHomeDataBase, str): + pass + + obj = _NoRange.__new__(_NoRange, "value") + str.__init__(obj) + # No _esp_range set on this instance. + assert obj.esp_range is None + + path = [_located("packages", "main.yaml", 5, 2)] + result = format_path(path, obj) + assert result == "In: packages in main.yaml 6:3" + + +def test_format_path_empty_path_with_located_current_obj(): + """An empty path with a located current_obj still surfaces the location.""" + obj = _located("${var}", "main.yaml", 0, 0) + result = format_path([], obj) + assert result == "In: in main.yaml 1:1" From da44d43981cff7b79765652dbcfdc39fd481935a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 05:07:48 +0200 Subject: [PATCH 0416/2214] Update pyparsing requirement from >=3.0 to >=3.3.2 (#15910) 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 95d7c8c032..482ea92da7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ smpclient==6.0.0 requests==2.33.1 # esp-idf >= 5.0 requires this -pyparsing >= 3.0 +pyparsing >= 3.3.2 # For autocompletion argcomplete>=2.0.0 From 78f1467be46956a0a5f621a04f0ad0967cf92f44 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 03:08:42 +0000 Subject: [PATCH 0417/2214] Bump aioesphomeapi from 44.17.0 to 44.18.0 (#15912) 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 482ea92da7..b49777beaa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.17.0 +aioesphomeapi==44.18.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From bb81c91d0c9ed21b367fba7cabbb05ef5b3bab26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 03:08:58 +0000 Subject: [PATCH 0418/2214] Update tzdata requirement from >=2021.1 to >=2026.1 (#15911) 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 b49777beaa..90f06eff98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ colorama==0.4.6 icmplib==3.0.4 tornado==6.5.5 tzlocal==5.3.1 # from time -tzdata>=2021.1 # from time +tzdata>=2026.1 # from time pyserial==3.5 platformio==6.1.19 esptool==5.2.0 From 92ed291411098dcafa21e62b6ff9b41dbe6e6667 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 05:47:34 +0200 Subject: [PATCH 0419/2214] Use MAC_ADDRESS_BUFFER_SIZE constant instead of duplicated literal --- esphome/components/esp32_ble/ble.cpp | 4 +--- esphome/core/application.h | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index ebb44c7d91..6bbf0d6a26 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -257,11 +257,9 @@ bool ESP32BLE::ble_setup_() { if (this->name_ != nullptr) { if (App.is_name_add_mac_suffix_enabled()) { - // MAC address length: 12 hex chars + null terminator - constexpr size_t mac_address_len = 13; // MAC address suffix length (last 6 characters of 12-char MAC address string) constexpr size_t mac_address_suffix_len = 6; - char mac_addr[mac_address_len]; + char mac_addr[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr); const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len; make_name_with_suffix_to(name_buffer, sizeof(name_buffer), this->name_, strlen(this->name_), '-', mac_suffix_ptr, diff --git a/esphome/core/application.h b/esphome/core/application.h index d3851a32da..e579080c97 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -82,11 +82,9 @@ class Application { void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); this->name_add_mac_suffix_ = true; - // MAC address length: 12 hex chars + null terminator - constexpr size_t mac_address_len = 13; // MAC address suffix length (last 6 characters of 12-char MAC address string) constexpr size_t mac_address_suffix_len = 6; - char mac_addr[mac_address_len]; + char mac_addr[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr); // Overwrite the placeholder suffix in the mutable static buffers with actual MAC // name is always non-empty (validated by validate_hostname in Python config) From d2c40ad8ebcbe32fe4c759ff1ac7bd792e6b9612 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 05:50:20 +0200 Subject: [PATCH 0420/2214] [core] Collapse adjacent USE_HOST ifdef blocks in Application --- esphome/core/application.h | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index d3851a32da..971eb92109 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -489,9 +489,6 @@ class Application { #ifdef USE_HOST std::vector socket_fds_; // Vector of all monitored socket file descriptors #endif -#ifdef USE_HOST - int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks -#endif // StringRef members (8 bytes each: pointer + size) StringRef name_; @@ -507,7 +504,8 @@ class Application { #endif #ifdef USE_HOST - int max_fd_{-1}; // Highest file descriptor number for select() + int max_fd_{-1}; // Highest file descriptor number for select() + int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks #endif // 2-byte members (grouped together for alignment) @@ -524,9 +522,7 @@ class Application { #ifdef USE_HOST bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes -#endif -#ifdef USE_HOST // Variable-sized members (not needed with fast select — is_socket_ready_ reads rcvevent directly) fd_set read_fds_{}; // Working fd_set: populated by select() fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes From a7f128ff318da743ef9e8fe8ef1c56fafb652a15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 05:57:27 +0200 Subject: [PATCH 0421/2214] [core] Dedupe yield() fast path in wakeable_delay and always-inline --- esphome/core/application.h | 12 ------------ esphome/core/wake.cpp | 2 +- esphome/core/wake.h | 18 ++++++++++++------ 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index d3851a32da..8ca735bb4f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -754,18 +754,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Inline yield_with_select_ for all paths except the select() fallback #ifndef USE_HOST inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { -#ifdef USE_LWIP_FAST_SELECT - // Fast path (ESP32/LibreTiny): FreeRTOS task notifications posted by the lwip - // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: - // wake_loop_threadsafe() from background tasks, and the delay_ms timeout. - if (delay_ms == 0) [[unlikely]] { - yield(); - return; - } -#endif esphome::internal::wakeable_delay(delay_ms); } #endif // !USE_HOST diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index cebc4d04b7..00b08b7b91 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -58,7 +58,7 @@ static int64_t alarm_callback_(alarm_id_t id, void *user_data) { namespace internal { void wakeable_delay(uint32_t ms) { - if (ms == 0) { + if (ms == 0) [[unlikely]] { yield(); return; } diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 41b7ab33b5..15b882b306 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -96,8 +96,14 @@ inline void wake_loop_threadsafe() { } namespace internal { -inline void wakeable_delay(uint32_t ms) { - if (ms == 0) { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the ms timeout. + if (ms == 0) [[unlikely]] { yield(); return; } @@ -127,8 +133,8 @@ inline void wake_loop_threadsafe() { wake_loop_impl(); } inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } namespace internal { -inline void wakeable_delay(uint32_t ms) { - if (ms == 0) { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { delay(0); return; } @@ -174,8 +180,8 @@ inline void wake_loop_threadsafe() {} inline void wake_loop_any_context() { wake_loop_threadsafe(); } namespace internal { -inline void wakeable_delay(uint32_t ms) { - if (ms == 0) { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { yield(); return; } From edcf96d0575232c153e8d4d7bb6de2aab0410c9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 06:24:09 +0200 Subject: [PATCH 0422/2214] [wifi] Use queue abstraction for LibreTiny WiFi events (#15343) --- esphome/components/libretiny/__init__.py | 7 ++ .../libretiny/freertos_static_alloc.c | 52 ++++++++++ esphome/components/wifi/wifi_component.cpp | 11 ++- esphome/components/wifi/wifi_component.h | 20 +++- .../wifi/wifi_component_esp8266.cpp | 5 +- .../wifi/wifi_component_esp_idf.cpp | 22 +++-- .../wifi/wifi_component_libretiny.cpp | 61 ++++-------- .../components/wifi/wifi_component_pico_w.cpp | 3 +- esphome/core/freertos_queue.h | 99 +++++++++++++++++++ 9 files changed, 227 insertions(+), 53 deletions(-) create mode 100644 esphome/components/libretiny/freertos_static_alloc.c create mode 100644 esphome/core/freertos_queue.h diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 4f42f40478..40b8c8dc6c 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -443,6 +443,13 @@ async def component_to_code(config): # 4-8KB flash). Even if linked, it would use locks, so explicit FreeRTOS # mutexes are simpler and equivalent. cg.add_define(ThreadModel.MULTI_NO_ATOMICS) + # Enable FreeRTOS static allocation so FreeRTOSQueue can use + # xQueueCreateStatic (queue storage in BSS, no heap allocation). + # Also moves FreeRTOS internal structures (timer command queue) to BSS. + # BK72xx's FreeRTOSConfig.h doesn't define this, defaulting to 0. + # The -D wins over the #ifndef default in FreeRTOS.h. + # Not enabled on RTL87xx/LN882x — costs more heap than it saves there. + cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1") # RTL8710B needs FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake # required by AsyncTCP 3.4.3+ (https://github.com/esphome/esphome/issues/10220) diff --git a/esphome/components/libretiny/freertos_static_alloc.c b/esphome/components/libretiny/freertos_static_alloc.c new file mode 100644 index 0000000000..62b0524230 --- /dev/null +++ b/esphome/components/libretiny/freertos_static_alloc.c @@ -0,0 +1,52 @@ +/* + * FreeRTOS static allocation callbacks for LibreTiny platforms. + * + * Required when configSUPPORT_STATIC_ALLOCATION is enabled. These callbacks + * provide memory for the idle and timer tasks. Following ESP-IDF's approach, + * we allocate from the FreeRTOS heap (pvPortMalloc) rather than using truly + * static buffers, to avoid assumptions about memory layout. + * + * This enables xQueueCreateStatic, xTaskCreateStatic, etc. throughout ESPHome, + * allowing queue storage to live in BSS with zero runtime heap allocation. + */ + +#ifdef USE_BK72XX + +#include +#include + +#if (configSUPPORT_STATIC_ALLOCATION == 1) + +void vApplicationGetIdleTaskMemory(StaticTask_t **ppxIdleTaskTCBBuffer, StackType_t **ppxIdleTaskStackBuffer, + uint32_t *pulIdleTaskStackSize) { + /* Stack grows down on ARM — allocate stack first, then TCB, + * so the stack does not grow into the TCB. */ + StackType_t *stack = (StackType_t *) pvPortMalloc(configMINIMAL_STACK_SIZE * sizeof(StackType_t)); + StaticTask_t *tcb = (StaticTask_t *) pvPortMalloc(sizeof(StaticTask_t)); + configASSERT(stack != NULL); + configASSERT(tcb != NULL); + + *ppxIdleTaskTCBBuffer = tcb; + *ppxIdleTaskStackBuffer = stack; + *pulIdleTaskStackSize = configMINIMAL_STACK_SIZE; +} + +#if (configUSE_TIMERS == 1) + +void vApplicationGetTimerTaskMemory(StaticTask_t **ppxTimerTaskTCBBuffer, StackType_t **ppxTimerTaskStackBuffer, + uint32_t *pulTimerTaskStackSize) { + StackType_t *stack = (StackType_t *) pvPortMalloc(configTIMER_TASK_STACK_DEPTH * sizeof(StackType_t)); + StaticTask_t *tcb = (StaticTask_t *) pvPortMalloc(sizeof(StaticTask_t)); + configASSERT(stack != NULL); + configASSERT(tcb != NULL); + + *ppxTimerTaskTCBBuffer = tcb; + *ppxTimerTaskStackBuffer = stack; + *pulTimerTaskStackSize = configTIMER_TASK_STACK_DEPTH; +} + +#endif /* configUSE_TIMERS */ + +#endif /* configSUPPORT_STATIC_ALLOCATION */ + +#endif /* USE_BK72XX */ diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 598aee8f66..481846085c 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -732,9 +732,16 @@ void WiFiComponent::restart_adapter() { } void WiFiComponent::loop() { - this->wifi_loop_(); + bool events_processed = this->wifi_loop_(); const uint32_t now = App.get_loop_component_start_time(); - this->update_connected_state_(); + // Connection state can only change when events are processed (ESP-IDF/LibreTiny) + // or polled (ESP8266/Pico W). Skip the expensive wifi_sta_connect_status_() call + // when no events arrived and we're already in steady state. + // Must also run when connected_ is false — after state transitions to STA_CONNECTED, + // connected_ won't be set until update_connected_state_() runs. + if (events_processed || !this->connected_) { + this->update_connected_state_(); + } if (this->has_sta()) { #if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 665dec37d5..53fb0728fb 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -9,6 +9,11 @@ #ifdef USE_ESP32 #include "esphome/core/lock_free_queue.h" #endif +#if defined(USE_LIBRETINY) && defined(ESPHOME_THREAD_MULTI_ATOMICS) +#include "esphome/core/lock_free_queue.h" +#elif defined(USE_LIBRETINY) && defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) +#include "esphome/core/freertos_queue.h" +#endif #include "esphome/core/string_ref.h" #include @@ -657,7 +662,7 @@ class WiFiComponent final : public Component { void connect_soon_(); - void wifi_loop_(); + bool wifi_loop_(); #ifdef USE_ESP8266 void process_pending_callbacks_(); #endif @@ -882,6 +887,19 @@ class WiFiComponent final : public Component { LockFreeQueue event_queue_; #endif +#ifdef USE_LIBRETINY + // Thread-safe queue for WiFi events from LibreTiny callback thread. + // LockFreeQueue on platforms with hardware atomics (RTL87xx, LN882x), + // FreeRTOSQueue on platforms without (BK72xx). + static constexpr uint8_t LT_EVENT_QUEUE_SIZE = 16; +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Ring buffer reserves one slot, so +1 for 16 usable slots + LockFreeQueue event_queue_; +#else + FreeRTOSQueue event_queue_; +#endif +#endif + private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index cb53d3ac1b..e56a8df350 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -938,7 +938,10 @@ network::IPAddress WiFiComponent::wifi_gateway_ip_() { return network::IPAddress(&ip.gw); } network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_getserver(num)); } -void WiFiComponent::wifi_loop_() { this->process_pending_callbacks_(); } +bool WiFiComponent::wifi_loop_() { + this->process_pending_callbacks_(); + return true; +} void WiFiComponent::process_pending_callbacks_() { // Process callbacks deferred from ESP8266 SDK system context (~2KB stack) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 4097df80af..c790742c79 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -715,17 +715,25 @@ const char *get_disconnect_reason_str(uint8_t reason) { } } -void WiFiComponent::wifi_loop_() { +bool WiFiComponent::wifi_loop_() { + // Use pop() directly instead of empty() — pop() costs 1 memw (acquire on tail_), + // while empty() costs 2 memw (acquire on both head_ and tail_) on Xtensa. + IDFWiFiEvent *data = this->event_queue_.pop(); + if (data == nullptr) + return false; + + do { + wifi_process_event_(data); + delete data; // NOLINT(cppcoreguidelines-owning-memory) + } while ((data = this->event_queue_.pop()) != nullptr); + + // Drops only occur when the queue is full, and only this loop drains it, + // so if pop() returned nullptr above we can skip this check. uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u WiFi events due to buffer overflow", dropped); } - - IDFWiFiEvent *data; - while ((data = this->event_queue_.pop()) != nullptr) { - wifi_process_event_(data); - delete data; // NOLINT(cppcoreguidelines-owning-memory) - } + return true; } // Events are processed from queue in main loop context, but listener notifications // must be deferred until after the state machine transitions (in check_connecting_finished) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 9565ffa747..cdd11ceaef 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -10,9 +10,6 @@ #include "lwip/err.h" #include "lwip/dns.h" -#include -#include - #ifdef USE_BK72XX extern "C" { #include @@ -43,16 +40,13 @@ static const char *const TAG = "wifi_lt"; // (like connection status flags) from the callback causes race conditions: // - The main loop may never see state changes (values cached in registers) // - State changes may be visible in inconsistent order -// - LibreTiny targets (BK7231, RTL8720) lack atomic instructions (no LDREX/STREX) // // Solution: Queue events in the callback and process them in the main loop. // This is the same approach used by ESP32 IDF's wifi_process_event_(). // All state modifications happen in the main loop context, eliminating races. - -static constexpr size_t EVENT_QUEUE_SIZE = 16; // Max pending WiFi events before overflow -static QueueHandle_t s_event_queue = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static volatile uint32_t s_event_queue_overflow_count = - 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// +// On platforms with hardware atomics (RTL87xx, LN882x): LockFreeQueue (SPSC ring buffer) +// On platforms without (BK72xx): FreeRTOSQueue (xQueue wrapper with critical sections) // Event structure for queued WiFi events - contains a copy of event data // to avoid lifetime issues with the original event data from the callback @@ -352,10 +346,6 @@ using esphome_wifi_event_info_t = arduino_event_info_t; // Event callback - runs in WiFi driver thread context // Only queues events for processing in main loop, no logging or state changes here void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_wifi_event_info_t info) { - if (s_event_queue == nullptr) { - return; - } - // Allocate on heap and fill directly to avoid extra memcpy auto *to_send = new LTWiFiEvent{}; // NOLINT(cppcoreguidelines-owning-memory) to_send->event_id = event; @@ -428,9 +418,8 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } // Queue event (don't block if queue is full) - if (xQueueSend(s_event_queue, &to_send, 0) != pdPASS) { + if (!this->event_queue_.push(to_send)) { delete to_send; // NOLINT(cppcoreguidelines-owning-memory) - s_event_queue_overflow_count++; } } @@ -620,14 +609,6 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { } } void WiFiComponent::wifi_pre_setup_() { - // Create event queue for thread-safe event handling - // Events are pushed from WiFi callback thread and processed in main loop - s_event_queue = xQueueCreate(EVENT_QUEUE_SIZE, sizeof(LTWiFiEvent *)); - if (s_event_queue == nullptr) { - ESP_LOGE(TAG, "Failed to create event queue"); - return; - } - WiFi.onEvent( [this](arduino_event_id_t event, arduino_event_info_t info) { this->wifi_event_callback_(event, info); }); // Make sure WiFi is in clean state before anything starts @@ -796,28 +777,26 @@ int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {WiFi.subnetMask()}; } network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {WiFi.gatewayIP()}; } network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return {WiFi.dnsIP(num)}; } -void WiFiComponent::wifi_loop_() { - // Process all pending events from the queue - if (s_event_queue == nullptr) { - return; - } - - // Check for dropped events due to queue overflow - if (s_event_queue_overflow_count > 0) { - ESP_LOGW(TAG, "Event queue overflow, %" PRIu32 " events dropped", s_event_queue_overflow_count); - s_event_queue_overflow_count = 0; - } - - while (true) { - LTWiFiEvent *event; - if (xQueueReceive(s_event_queue, &event, 0) != pdTRUE) { - // No more events - break; - } +bool WiFiComponent::wifi_loop_() { + // Use pop() directly instead of empty() — avoids redundant synchronization. + // LockFreeQueue: pop() costs 1 memw vs empty()'s 2 memw on Xtensa. + // FreeRTOSQueue: pop() is 1 critical section vs empty() + pop() = 2. + LTWiFiEvent *event = this->event_queue_.pop(); + if (event == nullptr) + return false; + do { wifi_process_event_(event); delete event; // NOLINT(cppcoreguidelines-owning-memory) + } while ((event = this->event_queue_.pop()) != nullptr); + + // Drops only occur when the queue is full, and only this loop drains it, + // so if pop() returned nullptr above we can skip this check. + uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %" PRIu16 " WiFi events due to buffer overflow", dropped); } + return true; } } // namespace esphome::wifi diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1cfeee3c1b..4e1e0395c0 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -303,7 +303,7 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { // Connect state listener notifications are deferred until after the state machine // transitions (in check_connecting_finished) so that conditions like wifi.connected // return correct values in automations. -void WiFiComponent::wifi_loop_() { +bool WiFiComponent::wifi_loop_() { // Handle scan completion if (this->state_ == WIFI_COMPONENT_STATE_STA_SCANNING && !cyw43_wifi_scan_active(&cyw43_state)) { this->scan_done_ = true; @@ -365,6 +365,7 @@ void WiFiComponent::wifi_loop_() { #endif } } + return true; } void WiFiComponent::wifi_pre_setup_() {} diff --git a/esphome/core/freertos_queue.h b/esphome/core/freertos_queue.h new file mode 100644 index 0000000000..2f3faf818a --- /dev/null +++ b/esphome/core/freertos_queue.h @@ -0,0 +1,99 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + +#include +#include + +#include +#include + +/* + * FreeRTOS queue wrapper for single-producer single-consumer scenarios on + * platforms without hardware atomic support (e.g. BK72xx ARM968E-S). + * + * Provides the same API as LockFreeQueue (push, pop, get_and_reset_dropped_count, + * empty, full, size) but uses xQueue internally, which synchronizes via + * FreeRTOS critical sections. Uses xQueueCreateStatic so the queue storage + * lives in BSS with zero runtime heap allocation. + * + * @tparam T The type of elements stored in the queue (stored as pointers) + * @tparam SIZE The maximum number of elements + */ + +namespace esphome { + +template class FreeRTOSQueue { + public: + FreeRTOSQueue() : dropped_count_(0) { + this->handle_ = xQueueCreateStatic(SIZE, sizeof(T *), this->storage_, &this->queue_buf_); + } + + // No destructor — ESPHome components are never destroyed. Intentionally + // omitted to avoid pulling in vQueueDelete code on resource-constrained targets. + + // Non-copyable, non-movable — queue handle is not transferable + FreeRTOSQueue(const FreeRTOSQueue &) = delete; + FreeRTOSQueue &operator=(const FreeRTOSQueue &) = delete; + FreeRTOSQueue(FreeRTOSQueue &&) = delete; + FreeRTOSQueue &operator=(FreeRTOSQueue &&) = delete; + + bool push(T *element) { + if (element == nullptr) + return false; + + if (xQueueSend(this->handle_, &element, 0) != pdPASS) { + this->increment_dropped_count(); + return false; + } + return true; + } + + T *pop() { + T *element; + if (xQueueReceive(this->handle_, &element, 0) != pdTRUE) { + return nullptr; + } + return element; + } + + uint16_t get_and_reset_dropped_count() { + // Fast path: plain read of aligned uint16_t is a single ARM load instruction. + // Worst case is reading a stale zero and reporting drops one iteration later. + // Avoids critical section overhead on every loop() call since drops are rare. + if (this->dropped_count_ == 0) + return 0; + // Declare outside critical section — BK72xx portENTER_CRITICAL may introduce a scope + uint16_t count; + portENTER_CRITICAL(); + count = this->dropped_count_; + this->dropped_count_ = 0; + portEXIT_CRITICAL(); + return count; + } + + void increment_dropped_count() { + portENTER_CRITICAL(); + this->dropped_count_++; + portEXIT_CRITICAL(); + } + + bool empty() const { return uxQueueMessagesWaiting(this->handle_) == 0; } + + bool full() const { return uxQueueSpacesAvailable(this->handle_) == 0; } + + size_t size() const { return uxQueueMessagesWaiting(this->handle_); } + + protected: + // Static storage for the queue — lives in BSS, no heap allocation + uint8_t storage_[SIZE * sizeof(T *)]; + StaticQueue_t queue_buf_; + QueueHandle_t handle_; + uint16_t dropped_count_; +}; + +} // namespace esphome + +#endif // ESPHOME_THREAD_MULTI_NO_ATOMICS From 67576d4879e252d4b765cef4ca92c580377d68b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 06:29:13 +0200 Subject: [PATCH 0423/2214] [rp2040] Tune oversized lwIP defaults for ESPHome (#14843) --- MANIFEST.in | 1 + esphome/components/rp2040/__init__.py | 162 +++++++++++++++++- esphome/components/rp2040/const.py | 1 + .../rp2040/inject_lwip_include.py.script | 18 ++ esphome/components/rp2040/lwipopts.h.jinja | 46 +++++ esphome/components/wifi/__init__.py | 10 +- script/stress_test_connect.py | 84 +++++++++ 7 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 esphome/components/rp2040/inject_lwip_include.py.script create mode 100644 esphome/components/rp2040/lwipopts.h.jinja create mode 100644 script/stress_test_connect.py diff --git a/MANIFEST.in b/MANIFEST.in index ed65edc656..e426627e8d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,4 +4,5 @@ include requirements.txt recursive-include esphome *.yaml recursive-include esphome *.cpp *.h *.tcc *.c recursive-include esphome *.py.script +recursive-include esphome *.jinja recursive-include esphome LICENSE.txt diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index e452780d41..ed246416c9 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -26,7 +26,7 @@ from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed from . import boards -from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns +from .const import KEY_BOARD, KEY_LWIP_OPTS, KEY_PIO_FILES, KEY_RP2040, rp2040_ns # force import gpio to register pin schema from .gpio import rp2040_pin_to_code # noqa @@ -240,6 +240,160 @@ async def to_code(config): cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) cg.add_define("USE_RP2040_CRASH_HANDLER") + _configure_lwip() + + +def _configure_lwip() -> None: + """Configure lwIP options for RP2040 by generating a custom lwipopts.h. + + Arduino-pico's lwipopts.h has no #ifndef guards, so -D flags cannot override + its settings. Instead, we generate a replacement lwipopts.h and place it in an + include directory that shadows the framework's version. + + lwIP is compiled from source on RP2040 (not pre-built), so our replacement + header fully controls the compiled lwIP behavior. + + RP2040 uses NO_SYS=1 (polling, no RTOS thread), LWIP_SOCKET=0, LWIP_NETCONN=0. + DHCP/DNS use raw udp_new() which allocates from MEMP_NUM_UDP_PCB. + + Comparison of arduino-pico defaults vs ESPHome targets (TCP_MSS=1460): + + Setting ESP8266 ESP32 arduino-pico New + ──────────────────────────────────────────────────────────────── + TCP_SND_BUF 2×MSS 4×MSS 8×MSS 4×MSS + TCP_WND 4×MSS 4×MSS 8×MSS 4×MSS + MEM_LIBC_MALLOC 1 1 0 0* + MEMP_MEM_MALLOC 1 1 0 0** + MEM_SIZE N/A*** N/A*** 16KB 16KB + PBUF_POOL_SIZE 10 16 24 16 + MEMP_NUM_TCP_SEG 10 16 32 17 + MEMP_NUM_TCP_PCB 5 16 5 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. + "dynamic" = auto-calculated from component socket registrations via + socket.get_socket_counts() with minimums of 8 TCP / 6 UDP / 2 TCP_LISTEN. + """ + from esphome.components.socket import ( + MIN_TCP_LISTEN_SOCKETS, + MIN_TCP_SOCKETS, + MIN_UDP_SOCKETS, + get_socket_counts, + ) + + sc = get_socket_counts() + # Apply platform minimums — ensure headroom for ESPHome's needs + tcp_sockets = max(MIN_TCP_SOCKETS, sc.tcp) + udp_sockets = max(MIN_UDP_SOCKETS, sc.udp) + # RP2040 has more RAM (264KB) than most LibreTiny boards, so DHCP/DNS + # 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), + } + + # Store for copy_files() to generate the header + CORE.data[KEY_RP2040][KEY_LWIP_OPTS] = lwip_defines + + # Add a pre-build extra script that injects our lwip_override directory + # into CCFLAGS so our lwipopts.h shadows the framework's version. + # Regular build_flags (-I/-isystem) come after -iwithprefixbefore in GCC's + # search order, so we must prepend via an extra_scripts hook. + cg.add_platformio_option("extra_scripts", ["pre:inject_lwip_include.py"]) + + tcp_min = " (min)" if tcp_sockets > sc.tcp else "" + 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]", + tcp_sockets, + tcp_min, + sc.tcp_details, + udp_sockets, + udp_min, + sc.udp_details, + listening_tcp, + listen_min, + sc.tcp_listen_details, + ) + + +def _generate_lwipopts_h() -> None: + """Generate a custom lwipopts.h that shadows the framework's version. + + Uses Jinja2 to render the template with the lwIP defines calculated + during code generation. The generated header is placed in lwip_override/ + 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, FileSystemLoader + + lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) + if not lwip_defines: + return + + template_dir = Path(__file__).parent + jinja_env = Environment( + loader=FileSystemLoader(str(template_dir)), + keep_trailing_newline=True, + ) + template = jinja_env.get_template("lwipopts.h.jinja") + content = template.render(**lwip_defines) + + lwip_dir = CORE.relative_build_path("lwip_override") + lwip_dir.mkdir(parents=True, exist_ok=True) + write_file_if_changed(lwip_dir / "lwipopts.h", content) + def add_pio_file(component: str, key: str, data: str): try: @@ -289,6 +443,12 @@ def copy_files(): post_build_file, CORE.relative_build_path("post_build.py"), ) + inject_lwip_file = dir / "inject_lwip_include.py.script" + copy_file_if_changed( + inject_lwip_file, + CORE.relative_build_path("inject_lwip_include.py"), + ) + _generate_lwipopts_h() if generate_pio_files(): path = CORE.relative_src_path("esphome.h") content = read_file(path).rstrip("\n") diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2040/const.py index ab5f42d757..e381d0482d 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2040/const.py @@ -1,6 +1,7 @@ import esphome.codegen as cg KEY_BOARD = "board" +KEY_LWIP_OPTS = "lwip_opts" KEY_RP2040 = "rp2040" KEY_PIO_FILES = "pio_files" diff --git a/esphome/components/rp2040/inject_lwip_include.py.script b/esphome/components/rp2040/inject_lwip_include.py.script new file mode 100644 index 0000000000..4ae9863e37 --- /dev/null +++ b/esphome/components/rp2040/inject_lwip_include.py.script @@ -0,0 +1,18 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import os + +# PlatformIO pre-build script: inject lwip_override include path so our +# lwipopts.h shadows the framework's version during lwIP compilation. +# +# The arduino-pico builder uses -iprefix + -iwithprefixbefore for includes, +# which takes priority over CPPPATH (-I). We must inject our path into the +# CCFLAGS BEFORE the -iprefix flag to ensure our lwipopts.h is found first. + +lwip_dir = os.path.join(env["PROJECT_DIR"], "lwip_override") + +if os.path.isdir(lwip_dir): + # Insert -I at the beginning of CCFLAGS, before the framework's + # -iprefix/-iwithprefixbefore flags which would otherwise take priority. + env.Prepend(CCFLAGS=["-I", lwip_dir]) diff --git a/esphome/components/rp2040/lwipopts.h.jinja b/esphome/components/rp2040/lwipopts.h.jinja new file mode 100644 index 0000000000..36d7d4da14 --- /dev/null +++ b/esphome/components/rp2040/lwipopts.h.jinja @@ -0,0 +1,46 @@ +// ESPHome lwIP configuration override for RP2040. +// Includes the framework's original lwipopts.h, then overrides specific +// settings to tune lwIP for ESPHome's IoT use case. +// +// This file is found first via -I injection (see inject_lwip_include.py.script). +// #include_next chains to the framework's original in include/lwipopts.h. +// Since the original uses #pragma once, it won't be included again later +// (e.g. via tusb_config.h), avoiding duplicate definition warnings. + +// Include the framework's original lwipopts.h first +#include_next "lwipopts.h" + +// --- ESPHome overrides below --- +// Only #undef and redefine values that differ from the framework defaults. + +// TCP send/receive buffers: 4xMSS matches ESP32 (down from 8xMSS) +#undef TCP_SND_BUF +#define TCP_SND_BUF {{ TCP_SND_BUF }} + +#undef TCP_WND +#define TCP_WND {{ TCP_WND }} + +// Queued segment limits: derived from 4xMSS buffer size, matching ESP32 +#undef TCP_SND_QUEUELEN +#define TCP_SND_QUEUELEN {{ TCP_SND_QUEUELEN }} + +#undef MEMP_NUM_TCP_SEG +#define MEMP_NUM_TCP_SEG {{ MEMP_NUM_TCP_SEG }} + +// Packet buffer pool: 16 matches ESP32 (down from 24) +#undef PBUF_POOL_SIZE +#define PBUF_POOL_SIZE {{ PBUF_POOL_SIZE }} + +// PCB pools: sized to actual component needs via socket.get_socket_counts() +#undef MEMP_NUM_TCP_PCB +#define MEMP_NUM_TCP_PCB {{ MEMP_NUM_TCP_PCB }} + +#undef MEMP_NUM_TCP_PCB_LISTEN +#define MEMP_NUM_TCP_PCB_LISTEN {{ MEMP_NUM_TCP_PCB_LISTEN }} + +#undef MEMP_NUM_UDP_PCB +#define MEMP_NUM_UDP_PCB {{ MEMP_NUM_UDP_PCB }} + +// Listen backlog: match component needs +#undef TCP_DEFAULT_LISTEN_BACKLOG +#define TCP_DEFAULT_LISTEN_BACKLOG {{ MEMP_NUM_TCP_PCB_LISTEN }} diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 33557f03c7..bc4e177219 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -289,12 +289,12 @@ def final_validate(config): def _consume_wifi_sockets(config: ConfigType) -> ConfigType: """Register UDP PCBs used internally by lwIP for DHCP and DNS. - Only needed on LibreTiny where we directly set MEMP_NUM_UDP_PCB (the raw - PCB pool shared by both application sockets and lwIP internals like DHCP/DNS). - On ESP32, CONFIG_LWIP_MAX_SOCKETS only controls the POSIX socket layer — - DHCP/DNS use raw udp_new() which bypasses it entirely. + Needed on LibreTiny and RP2040 where we directly set MEMP_NUM_UDP_PCB (the + raw PCB pool shared by both application sockets and lwIP internals like + DHCP/DNS). On ESP32, CONFIG_LWIP_MAX_SOCKETS only controls the POSIX socket + layer — DHCP/DNS use raw udp_new() which bypasses it entirely. """ - if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x): + if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x or CORE.is_rp2040): return config from esphome.components import socket diff --git a/script/stress_test_connect.py b/script/stress_test_connect.py new file mode 100644 index 0000000000..f91a7e8f99 --- /dev/null +++ b/script/stress_test_connect.py @@ -0,0 +1,84 @@ +"""Rapid connect/disconnect stress test for ESPHome native API.""" + +import asyncio +import sys +import time + +from aioesphomeapi import APIClient + +HOST = "192.168.1.100" +PORT = 6053 +PASSWORD = "" +NOISE_PSK = None +ITERATIONS = 500 +CONCURRENCY = 4 # simultaneous connection attempts + + +async def connect_disconnect(client_id: int, iteration: int) -> tuple[int, bool, str]: + """Connect and immediately disconnect.""" + cli = APIClient(HOST, PORT, PASSWORD, noise_psk=NOISE_PSK) + try: + await asyncio.wait_for(cli.connect(login=True), timeout=10) + await cli.disconnect() + return iteration, True, "" + except Exception as e: + return ( + iteration, + False, + f"client{client_id} iter{iteration}: {type(e).__name__}: {e}", + ) + finally: + await cli.disconnect(force=True) + + +async def main() -> None: + iterations = int(sys.argv[1]) if len(sys.argv) > 1 else ITERATIONS + concurrency = int(sys.argv[2]) if len(sys.argv) > 2 else CONCURRENCY + + print(f"Stress testing {HOST}:{PORT}") + print(f"Iterations: {iterations}, Concurrency: {concurrency}") + print() + + success = 0 + fail = 0 + errors: list[str] = [] + start = time.monotonic() + + sem = asyncio.Semaphore(concurrency) + + async def run(client_id: int, iteration: int) -> tuple[int, bool, str]: + async with sem: + return await connect_disconnect(client_id, iteration) + + tasks = [asyncio.create_task(run(i % concurrency, i)) for i in range(iterations)] + + for coro in asyncio.as_completed(tasks): + iteration, ok, err = await coro + if ok: + success += 1 + else: + fail += 1 + errors.append(err) + total = success + fail + if total % 10 == 0 or not ok: + elapsed = time.monotonic() - start + rate = total / elapsed if elapsed > 0 else 0 + print(f"[{total}/{iterations}] ok={success} fail={fail} ({rate:.1f}/s)") + if err: + print(f" ERROR: {err}") + + elapsed = time.monotonic() - start + print() + print(f"Done in {elapsed:.1f}s") + print(f"Success: {success}, Failed: {fail}, Rate: {iterations / elapsed:.1f}/s") + + if errors: + print("\nLast 10 errors:") + for e in errors[-10:]: + print(f" {e}") + + sys.exit(1 if fail > 0 else 0) + + +if __name__ == "__main__": + asyncio.run(main()) From 699cf9690ab32d374e63d8828440893bb62bc96a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 06:31:34 +0200 Subject: [PATCH 0424/2214] [core] Optimize value_accuracy_to_buf to avoid snprintf (#15596) --- esphome/core/helpers.cpp | 54 +++- esphome/core/helpers.h | 35 +++ tests/components/core/test_helpers.cpp | 96 +++++++ tests/components/core/test_value_accuracy.cpp | 237 ++++++++++++++++++ 4 files changed, 410 insertions(+), 12 deletions(-) create mode 100644 tests/components/core/test_value_accuracy.cpp diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 113b6f6187..e71da95e6b 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -447,28 +447,58 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de // value_accuracy_to_string moved to alloc_helpers.cpp +// Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). +// Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. +// Caller must guarantee value is finite and |value| * mult fits in uint32_t. +static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy_decimals, uint32_t mult) { + char *p = buf; + if (std::signbit(value)) { + *p++ = '-'; + value = -value; + } + // Cast to double for the multiply to match snprintf's rounding precision. + // float*int loses bits at exact-half boundaries (e.g. 23.45f*10 = 234.5 in float, + // but snprintf sees 234.500007... via double promotion and rounds differently). + // llrint returns long long so the result fits even on 32-bit targets where + // long is 32-bit; caller has already bounded |value * mult| to UINT32_MAX. + uint32_t scaled = static_cast(llrint(static_cast(value) * mult)); + p = uint32_to_str_unchecked(p, scaled / mult); + if (accuracy_decimals > 0) { + *p++ = '.'; + p = frac_to_str_unchecked(p, scaled % mult, mult / 10); + } + *p = '\0'; + return static_cast(p - buf); +} + size_t value_accuracy_to_buf(std::span buf, float value, int8_t accuracy_decimals) { normalize_accuracy_decimals(value, accuracy_decimals); - // snprintf returns chars that would be written (excluding null), or negative on error + + // Fast path for accuracy 0-3, finite values whose scaled magnitude fits in uint32_t. + // For 3 decimals that's |value| < ~4.29e6; larger totals fall through to snprintf. + if (accuracy_decimals <= 3 && std::isfinite(value)) { + const uint32_t mult = small_pow10(accuracy_decimals); + if (std::fabs(value) < static_cast(UINT32_MAX) / mult) { + return value_accuracy_to_buf_fast(buf.data(), value, accuracy_decimals, mult); + } + } + + // Fallback for NaN/Inf/high accuracy/out-of-range int len = snprintf(buf.data(), buf.size(), "%.*f", accuracy_decimals, value); if (len < 0) - return 0; // encoding error - // On truncation, snprintf returns would-be length; actual written is buf.size() - 1 + return 0; return static_cast(len) >= buf.size() ? buf.size() - 1 : static_cast(len); } size_t value_accuracy_with_uom_to_buf(std::span buf, float value, int8_t accuracy_decimals, StringRef unit_of_measurement) { - if (unit_of_measurement.empty()) { - return value_accuracy_to_buf(buf, value, accuracy_decimals); + size_t len = value_accuracy_to_buf(buf, value, accuracy_decimals); + if (len == 0 || unit_of_measurement.empty()) { + return len; } - normalize_accuracy_decimals(value, accuracy_decimals); - // snprintf returns chars that would be written (excluding null), or negative on error - int len = snprintf(buf.data(), buf.size(), "%.*f %s", accuracy_decimals, value, unit_of_measurement.c_str()); - if (len < 0) - return 0; // encoding error - // On truncation, snprintf returns would-be length; actual written is buf.size() - 1 - return static_cast(len) >= buf.size() ? buf.size() - 1 : static_cast(len); + char *end = buf_append_sep_str(buf.data() + len, buf.size() - len, ' ', unit_of_measurement.c_str(), + unit_of_measurement.size()); + return static_cast(end - buf.data()); } int8_t step_to_accuracy_decimals(float step) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 939852bfcb..4a91c46074 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1311,6 +1311,29 @@ inline char *int8_to_str(char *buf, int8_t val) { return buf; } +/// Append a separator char and a string to a buffer, respecting remaining space. +/// Returns pointer past last char written. The buffer is always null-terminated +/// when remaining >= 1 (even on the no-room early-return), so callers always get +/// a valid C string. +inline char *buf_append_sep_str(char *buf, size_t remaining, char separator, const char *str, size_t str_len) { + if (remaining < 2) { + if (remaining >= 1) { + *buf = '\0'; + } + return buf; + } + *buf++ = separator; + remaining--; + size_t copy_len = std::min(str_len, remaining - 1); + memcpy(buf, str, copy_len); + buf += copy_len; + *buf = '\0'; + return buf; +} + +/// Return 10^n for small non-negative n (0-3) as uint32_t, avoiding float. +inline uint32_t small_pow10(int8_t n) { return n == 3 ? 1000 : n == 2 ? 100 : n == 1 ? 10 : 1; } + /// Minimum buffer size for uint32_to_str: 10 digits + null terminator. static constexpr size_t UINT32_MAX_STR_SIZE = 11; @@ -1326,6 +1349,18 @@ inline size_t uint32_to_str(std::span buf, uint32_t v return static_cast(end - buf.data()); } +/// Write fractional digits with leading zeros to buffer (internal, no size check). +/// frac is the fractional value, divisor is the highest place value (e.g. 100 for 3 digits). +/// Returns pointer past last char written. +inline char *frac_to_str_unchecked(char *buf, uint32_t frac, uint32_t divisor) { + while (divisor > 0) { + *buf++ = '0' + static_cast(frac / divisor); + frac %= divisor; + divisor /= 10; + } + return buf; +} + /// Format byte array as lowercase hex to buffer (base implementation). char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length); diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 00169621c3..5fb77ef753 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -117,4 +117,100 @@ TEST(FormatHexChar, UppercaseDigits) { EXPECT_EQ(format_hex_pretty_char(15), 'F'); } +// --- small_pow10() --- + +TEST(SmallPow10, Zero) { EXPECT_EQ(small_pow10(0), 1u); } +TEST(SmallPow10, One) { EXPECT_EQ(small_pow10(1), 10u); } +TEST(SmallPow10, Two) { EXPECT_EQ(small_pow10(2), 100u); } +TEST(SmallPow10, Three) { EXPECT_EQ(small_pow10(3), 1000u); } + +// --- frac_to_str_unchecked() --- + +TEST(FracToStr, OneDigit) { + char buf[8]; + char *end = frac_to_str_unchecked(buf, 5, 1); + *end = '\0'; + EXPECT_STREQ(buf, "5"); + EXPECT_EQ(end - buf, 1); +} + +TEST(FracToStr, TwoDigits) { + char buf[8]; + char *end = frac_to_str_unchecked(buf, 46, 10); + *end = '\0'; + EXPECT_STREQ(buf, "46"); +} + +TEST(FracToStr, ThreeDigits) { + char buf[8]; + char *end = frac_to_str_unchecked(buf, 456, 100); + *end = '\0'; + EXPECT_STREQ(buf, "456"); + EXPECT_EQ(end - buf, 3); +} + +TEST(FracToStr, LeadingZeros) { + char buf[8]; + char *end = frac_to_str_unchecked(buf, 1, 100); + *end = '\0'; + EXPECT_STREQ(buf, "001"); + + end = frac_to_str_unchecked(buf, 5, 10); + *end = '\0'; + EXPECT_STREQ(buf, "05"); +} + +TEST(FracToStr, AllZeros) { + char buf[8]; + char *end = frac_to_str_unchecked(buf, 0, 100); + *end = '\0'; + EXPECT_STREQ(buf, "000"); + + end = frac_to_str_unchecked(buf, 0, 1); + *end = '\0'; + EXPECT_STREQ(buf, "0"); +} + +TEST(FracToStr, ZeroDivisor) { + char buf[8]; + buf[0] = 'X'; + char *end = frac_to_str_unchecked(buf, 0, 0); + EXPECT_EQ(end, buf); // writes nothing +} + +// --- buf_append_sep_str() --- + +TEST(BufAppendSepStr, Basic) { + char buf[32] = "23.46"; + char *start = buf + 5; + char *end = buf_append_sep_str(start, sizeof(buf) - 5, ' ', "°C", 3); + EXPECT_STREQ(buf, "23.46 °C"); + EXPECT_EQ(end - buf, 9); // "°C" is 3 bytes (UTF-8) +} + +TEST(BufAppendSepStr, EmptyString) { + char buf[32] = "100"; + char *start = buf + 3; + char *end = buf_append_sep_str(start, sizeof(buf) - 3, ' ', "", 0); + EXPECT_STREQ(buf, "100 "); + EXPECT_EQ(end - start, 1); // just the separator +} + +TEST(BufAppendSepStr, NoRoom) { + char buf[8] = "1234567"; + char *start = buf + 7; + char *end = buf_append_sep_str(start, 1, ' ', "unit", 4); + EXPECT_EQ(end, start); // nothing written +} + +TEST(BufAppendSepStr, Truncation) { + char buf[8] = "val"; + char *start = buf + 3; + // remaining = 5, separator takes 1, so 3 chars of string fit + null + char *end = buf_append_sep_str(start, 5, ' ', "longunit", 8); + *end = '\0'; + EXPECT_STREQ(buf, "val lon"); + EXPECT_EQ(end - buf, 7); +} + } // namespace esphome::core::testing diff --git a/tests/components/core/test_value_accuracy.cpp b/tests/components/core/test_value_accuracy.cpp new file mode 100644 index 0000000000..381a742a9c --- /dev/null +++ b/tests/components/core/test_value_accuracy.cpp @@ -0,0 +1,237 @@ +#include +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" + +namespace esphome::core::testing { + +// Helper to call value_accuracy_to_buf and return as string +static std::string va_to_string(float value, int8_t accuracy_decimals) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + size_t len = value_accuracy_to_buf(sp, value, accuracy_decimals); + return std::string(buf, len); +} + +// Helper: reference implementation using snprintf for comparison +static std::string va_reference(float value, int8_t accuracy_decimals) { + // Replicate normalize_accuracy_decimals logic + if (accuracy_decimals < 0) { + float divisor; + if (accuracy_decimals == -1) { + divisor = 10.0f; + } else if (accuracy_decimals == -2) { + divisor = 100.0f; + } else { + divisor = pow10_int(-accuracy_decimals); + } + value = roundf(value / divisor) * divisor; + accuracy_decimals = 0; + } + char buf[VALUE_ACCURACY_MAX_LEN]; + snprintf(buf, sizeof(buf), "%.*f", accuracy_decimals, value); + return std::string(buf); +} + +// --- Basic formatting --- + +TEST(ValueAccuracyToBuf, ZeroDecimals) { + EXPECT_EQ(va_to_string(23.456f, 0), "23"); + EXPECT_EQ(va_to_string(0.0f, 0), "0"); + EXPECT_EQ(va_to_string(100.0f, 0), "100"); + EXPECT_EQ(va_to_string(1.0f, 0), "1"); +} + +TEST(ValueAccuracyToBuf, OneDecimal) { + EXPECT_EQ(va_to_string(23.456f, 1), "23.5"); + EXPECT_EQ(va_to_string(0.0f, 1), "0.0"); + EXPECT_EQ(va_to_string(1.05f, 1), va_reference(1.05f, 1)); +} + +TEST(ValueAccuracyToBuf, TwoDecimals) { + EXPECT_EQ(va_to_string(23.456f, 2), "23.46"); + EXPECT_EQ(va_to_string(0.0f, 2), "0.00"); + EXPECT_EQ(va_to_string(1.005f, 2), va_reference(1.005f, 2)); +} + +TEST(ValueAccuracyToBuf, ThreeDecimals) { + EXPECT_EQ(va_to_string(23.456f, 3), "23.456"); + EXPECT_EQ(va_to_string(0.0f, 3), "0.000"); +} + +// --- Negative values --- + +TEST(ValueAccuracyToBuf, NegativeValues) { + EXPECT_EQ(va_to_string(-23.456f, 2), "-23.46"); + EXPECT_EQ(va_to_string(-0.5f, 1), "-0.5"); + EXPECT_EQ(va_to_string(-100.0f, 0), "-100"); +} + +// --- Negative accuracy_decimals (rounding to tens/hundreds) --- + +TEST(ValueAccuracyToBuf, NegativeAccuracy) { + EXPECT_EQ(va_to_string(1234.0f, -1), va_reference(1234.0f, -1)); + EXPECT_EQ(va_to_string(1234.0f, -2), va_reference(1234.0f, -2)); + EXPECT_EQ(va_to_string(56.0f, -1), va_reference(56.0f, -1)); +} + +// --- Special float values --- + +TEST(ValueAccuracyToBuf, NaN) { + std::string result = va_to_string(NAN, 2); + EXPECT_EQ(result, va_reference(NAN, 2)); +} + +TEST(ValueAccuracyToBuf, Infinity) { + std::string result = va_to_string(INFINITY, 2); + EXPECT_EQ(result, va_reference(INFINITY, 2)); +} + +TEST(ValueAccuracyToBuf, NegativeInfinity) { + std::string result = va_to_string(-INFINITY, 2); + EXPECT_EQ(result, va_reference(-INFINITY, 2)); +} + +// --- Edge cases --- + +TEST(ValueAccuracyToBuf, VerySmallValues) { + EXPECT_EQ(va_to_string(0.001f, 3), "0.001"); + EXPECT_EQ(va_to_string(0.001f, 2), "0.00"); + EXPECT_EQ(va_to_string(0.009f, 2), "0.01"); +} + +TEST(ValueAccuracyToBuf, LargeValues) { + EXPECT_EQ(va_to_string(999999.0f, 0), va_reference(999999.0f, 0)); + EXPECT_EQ(va_to_string(1013.25f, 2), "1013.25"); +} + +TEST(ValueAccuracyToBuf, Rounding) { + // 0.5 rounds up + EXPECT_EQ(va_to_string(23.5f, 0), "24"); + EXPECT_EQ(va_to_string(23.45f, 1), "23.5"); // float: 23.45 -> 23.4 or 23.5 + EXPECT_EQ(va_to_string(23.45f, 1), va_reference(23.45f, 1)); +} + +// --- Match snprintf for a range of typical sensor values --- + +TEST(ValueAccuracyToBuf, MatchesSnprintf) { + float test_values[] = {0.0f, 1.0f, -1.0f, 23.456f, -23.456f, 100.0f, 0.1f, 0.01f, 99.99f, 1013.25f, -40.0f}; + int8_t test_accuracies[] = {0, 1, 2, 3}; + + for (float value : test_values) { + for (int8_t acc : test_accuracies) { + EXPECT_EQ(va_to_string(value, acc), va_reference(value, acc)) + << "Mismatch for value=" << value << " accuracy=" << static_cast(acc); + } + } +} + +// --- Return value (length) --- + +TEST(ValueAccuracyToBuf, ReturnsCorrectLength) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + + size_t len = value_accuracy_to_buf(sp, 23.456f, 2); + EXPECT_EQ(len, 5u); // "23.46" + EXPECT_EQ(strlen(buf), len); + + len = value_accuracy_to_buf(sp, 0.0f, 0); + EXPECT_EQ(len, 1u); // "0" + EXPECT_EQ(strlen(buf), len); + + len = value_accuracy_to_buf(sp, -100.0f, 1); + EXPECT_EQ(len, 6u); // "-100.0" + EXPECT_EQ(strlen(buf), len); +} + +TEST(ValueAccuracyToBuf, NegativeZero) { + // Hand-rolled formatter must preserve snprintf's sign-of-zero behavior. + EXPECT_EQ(va_to_string(-0.0f, 2), va_reference(-0.0f, 2)); + EXPECT_EQ(va_to_string(-0.0f, 0), va_reference(-0.0f, 0)); + // Tiny negative that rounds to zero at this precision must still render as "-0.00". + EXPECT_EQ(va_to_string(-0.001f, 2), va_reference(-0.001f, 2)); +} + +TEST(ValueAccuracyToBuf, OverflowFallsBackToSnprintf) { + // |value| * 10^acc must exceed UINT32_MAX to exercise the snprintf fallback path. + EXPECT_EQ(va_to_string(1.0e7f, 3), va_reference(1.0e7f, 3)); + EXPECT_EQ(va_to_string(-1.0e7f, 3), va_reference(-1.0e7f, 3)); + EXPECT_EQ(va_to_string(5.0e9f, 0), va_reference(5.0e9f, 0)); +} + +// --- value_accuracy_with_uom_to_buf --- + +static std::string va_uom_to_string(float value, int8_t accuracy_decimals, const char *uom) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + StringRef ref(uom); + size_t len = value_accuracy_with_uom_to_buf(sp, value, accuracy_decimals, ref); + return std::string(buf, len); +} + +static std::string va_uom_reference(float value, int8_t accuracy_decimals, const char *uom) { + char buf[VALUE_ACCURACY_MAX_LEN]; + if (!uom || *uom == '\0') { + snprintf(buf, sizeof(buf), "%.*f", accuracy_decimals, value); + } else { + snprintf(buf, sizeof(buf), "%.*f %s", accuracy_decimals, value, uom); + } + return std::string(buf); +} + +TEST(ValueAccuracyWithUomToBuf, BasicWithUnit) { + EXPECT_EQ(va_uom_to_string(23.456f, 2, "°C"), va_uom_reference(23.456f, 2, "°C")); + EXPECT_EQ(va_uom_to_string(1013.25f, 2, "hPa"), va_uom_reference(1013.25f, 2, "hPa")); + EXPECT_EQ(va_uom_to_string(-40.0f, 1, "°F"), va_uom_reference(-40.0f, 1, "°F")); + EXPECT_EQ(va_uom_to_string(100.0f, 0, "%"), va_uom_reference(100.0f, 0, "%")); +} + +TEST(ValueAccuracyWithUomToBuf, EmptyUnit) { + EXPECT_EQ(va_uom_to_string(23.456f, 2, ""), "23.46"); + EXPECT_EQ(va_uom_to_string(0.0f, 1, ""), "0.0"); +} + +TEST(ValueAccuracyWithUomToBuf, ReturnsCorrectLength) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + StringRef ref("°C"); + size_t len = value_accuracy_with_uom_to_buf(sp, 23.46f, 2, ref); + EXPECT_EQ(strlen(buf), len); + EXPECT_EQ(len, strlen("23.46 °C")); +} + +TEST(ValueAccuracyWithUomToBuf, NearBufferLimitTruncates) { + // Build a unit long enough that value + " " + unit exceeds VALUE_ACCURACY_MAX_LEN. + // "23.46" (5) + " " (1) + unit -> must cap at buf.size()-1 and stay null-terminated. + std::string long_unit(VALUE_ACCURACY_MAX_LEN, 'U'); + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + StringRef ref(long_unit.c_str()); + size_t len = value_accuracy_with_uom_to_buf(sp, 23.46f, 2, ref); + EXPECT_LT(len, VALUE_ACCURACY_MAX_LEN); + EXPECT_EQ(strlen(buf), len); + // Should begin with the formatted value and a separator. + EXPECT_EQ(std::string(buf, 6), "23.46 "); +} + +TEST(ValueAccuracyWithUomToBuf, MatchesSnprintf) { + const char *units[] = {"°C", "hPa", "%", "W", "kWh", "m/s"}; + float values[] = {0.0f, 23.456f, -40.0f, 1013.25f, 100.0f}; + int8_t accs[] = {0, 1, 2, 3}; + for (const char *u : units) { + for (float v : values) { + for (int8_t a : accs) { + EXPECT_EQ(va_uom_to_string(v, a, u), va_uom_reference(v, a, u)) + << "value=" << v << " acc=" << static_cast(a) << " uom=" << u; + } + } + } +} + +} // namespace esphome::core::testing From 9c80cbf19c6d604d1906d8e83bced3d67dd5f6dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 06:34:26 +0200 Subject: [PATCH 0425/2214] [light] Reduce validate_ clamp code size and speed up unit-range clamps (#15728) --- esphome/components/light/light_call.cpp | 63 +++++++++------ esphome/components/light/light_call.h | 39 ++++----- esphome/components/light/light_color_values.h | 80 ++++++++++++++----- 3 files changed, 117 insertions(+), 65 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index a749cd7305..7b28065e4e 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -10,13 +10,10 @@ namespace esphome::light { static const char *const TAG = "light"; -// Helper functions to reduce code size for logging -static void clamp_and_log_if_invalid(const char *name, float &value, const LogString *param_name, float min = 0.0f, - float max = 1.0f) { - if (value < min || value > max) { - ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); - value = clamp(value, min, max); - } +// Cold-path logger; caller handles the clamp so the in-range hot path avoids +// the spill/reload around the call. +static void log_value_out_of_range(const char *name, float value, const LogString *param_name, float min, float max) { + ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN @@ -57,6 +54,12 @@ static void log_invalid_parameter(const char *name, const LogString *message) { PROGMEM_STRING_TABLE(ColorModeHumanStrings, "Unknown", "On/Off", "Brightness", "White", "Color temperature", "Cold/warm white", "RGB", "RGBW", "RGB + color temperature", "RGB + cold/warm white"); +// Indices 0-7 match FieldFlags bits 0-7; index 8 is color_temperature. +// PROGMEM_STRING_TABLE is constexpr-init (no RAM guard variable). +PROGMEM_STRING_TABLE(ValidateFieldNames, "Brightness", "Color brightness", "Red", "Green", "Blue", "White", + "Cold white", "Warm white", "Color temperature"); +static constexpr uint8_t VALIDATE_CT_INDEX = 8; + static const LogString *color_mode_to_human(ColorMode color_mode) { return ColorModeHumanStrings::get_log_str(ColorModeBitPolicy::to_bit(color_mode), 0); } @@ -277,25 +280,37 @@ LightColorValues LightCall::validate_() { if (this->has_state()) v.set_state(this->state_); - // clamp_and_log_if_invalid already clamps in-place, so assign directly - // to avoid redundant clamp code from the setter being inlined. -#define VALIDATE_AND_APPLY(field, name_str, ...) \ - if (this->has_##field()) { \ - clamp_and_log_if_invalid(name, this->field##_, LOG_STR(name_str), ##__VA_ARGS__); \ - v.field##_ = this->field##_; \ + // FieldFlags bits 0-7 must match unit_fields_ array indices. + static_assert(FLAG_HAS_BRIGHTNESS == 1u << 0 && FLAG_HAS_COLOR_BRIGHTNESS == 1u << 1 && FLAG_HAS_RED == 1u << 2 && + FLAG_HAS_GREEN == 1u << 3 && FLAG_HAS_BLUE == 1u << 4 && FLAG_HAS_WHITE == 1u << 5 && + FLAG_HAS_COLD_WHITE == 1u << 6 && FLAG_HAS_WARM_WHITE == 1u << 7, + "FieldFlags bits 0-7 must match unit_fields_ indices"); + + // Iterate set bits only (ctz + clear-lowest) — HA can drive perform() + // at high frequency so the hot path is O(popcount). + unsigned active = this->flags_ & CLAMP_FLAGS_MASK; + while (active != 0) { + unsigned bit = __builtin_ctz(active); + active &= active - 1; // clear lowest set bit + float &value = this->unit_fields_[bit]; + if (float_out_of_unit_range(value)) { + log_value_out_of_range(name, value, ValidateFieldNames::get_log_str(bit, 0), 0.0f, 1.0f); + value = clamp_unit_float(value); + } + v.unit_fields_[bit] = value; } - VALIDATE_AND_APPLY(brightness, "Brightness") - VALIDATE_AND_APPLY(color_brightness, "Color brightness") - VALIDATE_AND_APPLY(red, "Red") - VALIDATE_AND_APPLY(green, "Green") - VALIDATE_AND_APPLY(blue, "Blue") - VALIDATE_AND_APPLY(white, "White") - VALIDATE_AND_APPLY(cold_white, "Cold white") - VALIDATE_AND_APPLY(warm_white, "Warm white") - VALIDATE_AND_APPLY(color_temperature, "Color temperature", traits.get_min_mireds(), traits.get_max_mireds()) - -#undef VALIDATE_AND_APPLY + // color_temperature: runtime range from traits. + if (this->has_color_temperature()) { + const float ct_min = traits.get_min_mireds(); + const float ct_max = traits.get_max_mireds(); + if (this->color_temperature_ < ct_min || this->color_temperature_ > ct_max) { + log_value_out_of_range(name, this->color_temperature_, ValidateFieldNames::get_log_str(VALIDATE_CT_INDEX, 0), + ct_min, ct_max); + this->color_temperature_ = clamp(this->color_temperature_, ct_min, ct_max); + } + v.color_temperature_ = this->color_temperature_; + } v.normalize_color(); diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 39953d0d20..e3352de727 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -195,25 +195,26 @@ class LightCall { /// Some color modes also can be set using non-native parameters, transform those calls. void transform_parameters_(const LightTraits &traits); - // Bitfield flags - each flag indicates whether a corresponding value has been set. + // Bits 0-7 index unit_fields_[] in validate_(); don't reorder (asserts in light_call.cpp). enum FieldFlags : uint16_t { - FLAG_HAS_STATE = 1 << 0, - FLAG_HAS_TRANSITION = 1 << 1, - FLAG_HAS_FLASH = 1 << 2, - FLAG_HAS_EFFECT = 1 << 3, - FLAG_HAS_BRIGHTNESS = 1 << 4, - FLAG_HAS_COLOR_BRIGHTNESS = 1 << 5, - FLAG_HAS_RED = 1 << 6, - FLAG_HAS_GREEN = 1 << 7, - FLAG_HAS_BLUE = 1 << 8, - FLAG_HAS_WHITE = 1 << 9, - FLAG_HAS_COLOR_TEMPERATURE = 1 << 10, - FLAG_HAS_COLD_WHITE = 1 << 11, - FLAG_HAS_WARM_WHITE = 1 << 12, + FLAG_HAS_BRIGHTNESS = 1 << 0, + FLAG_HAS_COLOR_BRIGHTNESS = 1 << 1, + FLAG_HAS_RED = 1 << 2, + FLAG_HAS_GREEN = 1 << 3, + FLAG_HAS_BLUE = 1 << 4, + FLAG_HAS_WHITE = 1 << 5, + FLAG_HAS_COLD_WHITE = 1 << 6, + FLAG_HAS_WARM_WHITE = 1 << 7, + FLAG_HAS_COLOR_TEMPERATURE = 1 << 8, + FLAG_HAS_STATE = 1 << 9, + FLAG_HAS_TRANSITION = 1 << 10, + FLAG_HAS_FLASH = 1 << 11, + FLAG_HAS_EFFECT = 1 << 12, FLAG_HAS_COLOR_MODE = 1 << 13, FLAG_PUBLISH = 1 << 14, FLAG_SAVE = 1 << 15, }; + static constexpr uint16_t CLAMP_FLAGS_MASK = 0x00FFu; // bits 0-7 inline bool has_transition_() { return (this->flags_ & FLAG_HAS_TRANSITION) != 0; } inline bool has_flash_() { return (this->flags_ & FLAG_HAS_FLASH) != 0; } @@ -239,19 +240,11 @@ class LightCall { LightState *parent_; // Light state values - use flags_ to check if a value has been set. - // Group 4-byte aligned members first uint32_t transition_length_; uint32_t flash_length_; uint32_t effect_; - float brightness_; - float color_brightness_; - float red_; - float green_; - float blue_; - float white_; + ESPHOME_LIGHT_UNIT_FIELDS_UNION(); float color_temperature_; - float cold_white_; - float warm_white_; // Smaller members at the end for better packing uint16_t flags_{FLAG_PUBLISH | FLAG_SAVE}; // Tracks which values are set diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index fa286a3941..5cafa9fe82 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -3,11 +3,62 @@ #include "esphome/core/helpers.h" #include "color_mode.h" #include +#include +#include namespace esphome::light { inline static uint8_t to_uint8_scale(float x) { return static_cast(roundf(x * 255.0f)); } +// IEEE 754 bit patterns. Values in [0.0f, 1.0f] have bits <= ONE_F_BITS; +// negatives have the sign bit set (→ huge unsigned). A single unsigned compare +// replaces two soft-float __ltsf2/__gtsf2 calls on ESP8266. +static constexpr uint32_t ONE_F_BITS = 0x3F800000u; // 1.0f +static constexpr uint32_t NEG_ZERO_F_BITS = 0x80000000u; // -0.0f / sign-bit mask +static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit"); +static_assert(std::numeric_limits::is_iec559, "IEEE 754 float required"); + +// Union pun — memcpy/bit_cast don't fold on xtensa-gcc (see api/proto.h). +// -0.0f is numerically zero so it's reported in range (no warning, no clamp). +inline bool float_out_of_unit_range(float x) { + union { + float f; + uint32_t u; + } pun; + pun.f = x; + return pun.u > ONE_F_BITS && pun.u != NEG_ZERO_F_BITS; +} + +// Clamps to [0.0f, 1.0f] without float compares. Out of range: sign bit set +// (negatives, -NaN, -Inf) → 0.0f; sign bit clear (>1, +NaN, +Inf) → 1.0f. +inline float clamp_unit_float(float x) { + union { + float f; + uint32_t u; + } pun; + pun.f = x; + if (pun.u <= ONE_F_BITS) + return x; + return (pun.u & NEG_ZERO_F_BITS) ? 0.0f : 1.0f; // sign bit → negative → clamp to 0 +} + +// Shared anonymous union: eight unit-range floats alias unit_fields_[8] so +// LightCall::validate_() can iterate them as a real array. GCC/Clang ext. +#define ESPHOME_LIGHT_UNIT_FIELDS_UNION() \ + union { \ + struct { \ + float brightness_; \ + float color_brightness_; \ + float red_; \ + float green_; \ + float blue_; \ + float white_; \ + float cold_white_; \ + float warm_white_; \ + }; \ + float unit_fields_[8]; \ + } + /** This class represents the color state for a light object. * * The representation of the color state is dependent on the active color mode. A color mode consists of multiple @@ -52,9 +103,9 @@ class LightColorValues { green_(1.0f), blue_(1.0f), white_(1.0f), - color_temperature_{0.0f}, cold_white_{1.0f}, warm_white_{1.0f}, + color_temperature_{0.0f}, color_mode_(ColorMode::UNKNOWN) {} LightColorValues(ColorMode color_mode, float state, float brightness, float color_brightness, float red, float green, @@ -220,39 +271,39 @@ class LightColorValues { /// Get the binary true/false state of these light color values. bool is_on() const { return this->get_state() != 0.0f; } /// Set the state of these light color values. In range from 0.0 (off) to 1.0 (on) - void set_state(float state) { this->state_ = clamp(state, 0.0f, 1.0f); } + void set_state(float state) { this->state_ = clamp_unit_float(state); } /// Set the state of these light color values as a binary true/false. void set_state(bool state) { this->state_ = state ? 1.0f : 0.0f; } /// Get the brightness property of these light color values. In range 0.0 to 1.0 float get_brightness() const { return this->brightness_; } /// Set the brightness property of these light color values. In range 0.0 to 1.0 - void set_brightness(float brightness) { this->brightness_ = clamp(brightness, 0.0f, 1.0f); } + void set_brightness(float brightness) { this->brightness_ = clamp_unit_float(brightness); } /// Get the color brightness property of these light color values. In range 0.0 to 1.0 float get_color_brightness() const { return this->color_brightness_; } /// Set the color brightness property of these light color values. In range 0.0 to 1.0 - void set_color_brightness(float brightness) { this->color_brightness_ = clamp(brightness, 0.0f, 1.0f); } + void set_color_brightness(float brightness) { this->color_brightness_ = clamp_unit_float(brightness); } /// Get the red property of these light color values. In range 0.0 to 1.0 float get_red() const { return this->red_; } /// Set the red property of these light color values. In range 0.0 to 1.0 - void set_red(float red) { this->red_ = clamp(red, 0.0f, 1.0f); } + void set_red(float red) { this->red_ = clamp_unit_float(red); } /// Get the green property of these light color values. In range 0.0 to 1.0 float get_green() const { return this->green_; } /// Set the green property of these light color values. In range 0.0 to 1.0 - void set_green(float green) { this->green_ = clamp(green, 0.0f, 1.0f); } + void set_green(float green) { this->green_ = clamp_unit_float(green); } /// Get the blue property of these light color values. In range 0.0 to 1.0 float get_blue() const { return this->blue_; } /// Set the blue property of these light color values. In range 0.0 to 1.0 - void set_blue(float blue) { this->blue_ = clamp(blue, 0.0f, 1.0f); } + void set_blue(float blue) { this->blue_ = clamp_unit_float(blue); } /// Get the white property of these light color values. In range 0.0 to 1.0 float get_white() const { return white_; } /// Set the white property of these light color values. In range 0.0 to 1.0 - void set_white(float white) { this->white_ = clamp(white, 0.0f, 1.0f); } + void set_white(float white) { this->white_ = clamp_unit_float(white); } /// Get the color temperature property of these light color values in mired. float get_color_temperature() const { return this->color_temperature_; } @@ -277,26 +328,19 @@ class LightColorValues { /// Get the cold white property of these light color values. In range 0.0 to 1.0. float get_cold_white() const { return this->cold_white_; } /// Set the cold white property of these light color values. In range 0.0 to 1.0. - void set_cold_white(float cold_white) { this->cold_white_ = clamp(cold_white, 0.0f, 1.0f); } + void set_cold_white(float cold_white) { this->cold_white_ = clamp_unit_float(cold_white); } /// Get the warm white property of these light color values. In range 0.0 to 1.0. float get_warm_white() const { return this->warm_white_; } /// Set the warm white property of these light color values. In range 0.0 to 1.0. - void set_warm_white(float warm_white) { this->warm_white_ = clamp(warm_white, 0.0f, 1.0f); } + void set_warm_white(float warm_white) { this->warm_white_ = clamp_unit_float(warm_white); } friend class LightCall; protected: float state_; ///< ON / OFF, float for transition - float brightness_; - float color_brightness_; - float red_; - float green_; - float blue_; - float white_; + ESPHOME_LIGHT_UNIT_FIELDS_UNION(); float color_temperature_; ///< Color Temperature in Mired - float cold_white_; - float warm_white_; ColorMode color_mode_; }; From a3b49d1ed9f2ebdffce9ac1af73b5f6a67660a43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 06:43:33 +0200 Subject: [PATCH 0426/2214] [core] Use MAC_ADDRESS_BUFFER_SIZE constant instead of duplicated literal (#15913) --- esphome/components/esp32_ble/ble.cpp | 4 +--- esphome/core/application.h | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index ebb44c7d91..6bbf0d6a26 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -257,11 +257,9 @@ bool ESP32BLE::ble_setup_() { if (this->name_ != nullptr) { if (App.is_name_add_mac_suffix_enabled()) { - // MAC address length: 12 hex chars + null terminator - constexpr size_t mac_address_len = 13; // MAC address suffix length (last 6 characters of 12-char MAC address string) constexpr size_t mac_address_suffix_len = 6; - char mac_addr[mac_address_len]; + char mac_addr[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr); const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len; make_name_with_suffix_to(name_buffer, sizeof(name_buffer), this->name_, strlen(this->name_), '-', mac_suffix_ptr, diff --git a/esphome/core/application.h b/esphome/core/application.h index d3851a32da..e579080c97 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -82,11 +82,9 @@ class Application { void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); this->name_add_mac_suffix_ = true; - // MAC address length: 12 hex chars + null terminator - constexpr size_t mac_address_len = 13; // MAC address suffix length (last 6 characters of 12-char MAC address string) constexpr size_t mac_address_suffix_len = 6; - char mac_addr[mac_address_len]; + char mac_addr[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr); // Overwrite the placeholder suffix in the mutable static buffers with actual MAC // name is always non-empty (validated by validate_hostname in Python config) From 23ad30cb4cbb81aca313cf49b32473a375e5cc56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 06:44:53 +0200 Subject: [PATCH 0427/2214] [esp32] Use xTaskGetTickCount() for millis() when tick rate is 1kHz (#15661) --- esphome/components/esp32/core.cpp | 21 +++++++++++++++- esphome/core/application.cpp | 16 ++++++------ esphome/core/application.h | 2 +- esphome/core/component.h | 3 ++- esphome/core/millis_internal.h | 42 +++++++++++++++++++++++++++++++ esphome/core/scheduler.h | 10 ++++++-- 6 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 esphome/core/millis_internal.h diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index add50dcf4d..1c63137183 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -23,7 +23,26 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { void HOT yield() { vPortYield(); } -uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); } +// Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig), +// falling back to esp_timer for non-standard rates. IRAM_ATTR is required because +// Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32. +// xTaskGetTickCountFromISR() is used in ISR context to satisfy the FreeRTOS API contract. +uint32_t IRAM_ATTR HOT millis() { +#if CONFIG_FREERTOS_HZ == 1000 + if (xPortInIsrContext()) [[unlikely]] { + return xTaskGetTickCountFromISR(); + } + return xTaskGetTickCount(); +#else + return micros_to_millis(static_cast(esp_timer_get_time())); +#endif +} +// millis_64() stays on esp_timer — a different clock from xTaskGetTickCount(). This is +// safe because the two are never cross-compared: millis() values are only used for +// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), while +// millis_64() is used by the Scheduler and uptime sensors. On ESP32 (USE_NATIVE_64BIT_TIME), +// Scheduler::millis_64_from_(now) discards the 32-bit now and calls millis_64() directly, +// so the Scheduler is internally consistent on the esp_timer clock. uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b626eb1de6..ea1912d645 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -78,7 +78,7 @@ void Application::setup() { Component *component = this->components_[i]; // Update loop_component_start_time_ before calling each component during setup - this->loop_component_start_time_ = millis(); + this->loop_component_start_time_ = MillisInternal::get(); component->call(); this->scheduler.process_to_add(); this->feed_wdt(); @@ -91,17 +91,15 @@ void Application::setup() { this->app_state_ |= STATUS_LED_WARNING; do { - uint32_t now = millis(); - // Service scheduler and process pending loop enables to handle GPIO // interrupts during setup. During setup we always run the component // phase (no loop_interval_ gate), so call both helpers unconditionally. - this->scheduler_tick_(now); + this->scheduler_tick_(MillisInternal::get()); this->before_component_phase_(); for (uint32_t j = 0; j <= i; j++) { // Update loop_component_start_time_ right before calling each component - this->loop_component_start_time_ = millis(); + this->loop_component_start_time_ = MillisInternal::get(); this->components_[j]->call(); this->feed_wdt(); } @@ -215,7 +213,7 @@ void Application::process_dump_config_() { void Application::feed_wdt() { // Cold entry: callers without a millis() timestamp in hand. Fetches the // time and takes the same rate-limit paths as feed_wdt_with_time(). - uint32_t now = millis(); + uint32_t now = MillisInternal::get(); if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { this->feed_wdt_slow_(now); } @@ -305,7 +303,7 @@ void Application::run_powerdown_hooks() { } void Application::teardown_components(uint32_t timeout_ms) { - uint32_t start_time = millis(); + uint32_t start_time = MillisInternal::get(); // Use a StaticVector instead of std::vector to avoid heap allocation // since we know the actual size at compile time @@ -384,7 +382,7 @@ void Application::teardown_components(uint32_t timeout_ms) { } // Update time for next iteration - now = millis(); + now = MillisInternal::get(); } if (pending_count > 0) { @@ -427,7 +425,7 @@ void Application::disable_component_loop_(Component *component) { // This prevents integer underflow in timing calculations by ensuring // the swapped component starts with a fresh timing reference, avoiding // errors caused by stale or wrapped timing values. - this->loop_component_start_time_ = millis(); + this->loop_component_start_time_ = MillisInternal::get(); } } return; diff --git a/esphome/core/application.h b/esphome/core/application.h index e579080c97..b480e52b2d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -637,7 +637,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // (advanced by its per-item feeds) or `now` unchanged. We adopt it as `now` // so the gate check and WDT feed both reflect actual elapsed time after // scheduler dispatch, without an extra millis() call. - uint32_t now = this->scheduler_tick_(millis()); + uint32_t now = this->scheduler_tick_(MillisInternal::get()); // Guarantee one WDT feed per tick even when the scheduler had nothing to // dispatch and the component phase is gated out — covers configs with no // looping components and no scheduler work (setup() has its own diff --git a/esphome/core/component.h b/esphome/core/component.h index 67db5423af..6afcfda41d 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -9,6 +9,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/millis_internal.h" #include "esphome/core/optional.h" // Forward declarations for friend access from codegen-generated setup() @@ -656,7 +657,7 @@ class WarnIfComponentBlockingGuard { #ifdef USE_RUNTIME_STATS this->component_->runtime_stats_.record_time(micros() - this->started_us_); #endif - uint32_t curr_time = millis(); + uint32_t curr_time = MillisInternal::get(); #ifndef USE_BENCHMARK // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h new file mode 100644 index 0000000000..6b73476680 --- /dev/null +++ b/esphome/core/millis_internal.h @@ -0,0 +1,42 @@ +#pragma once + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +#if defined(USE_ESP32) +#include +#include +#include +#endif + +namespace esphome { + +// Friend-gated accessor for a fast millis() variant intended only for +// known task-context callers on the main loop hot path (Application::loop() +// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// dispatch that the public esphome::millis() pays on ESP32. +// +// MUST NOT be called from ISR context: on ESP32 it calls the non-FromISR +// FreeRTOS API directly, which is undefined behavior in ISR context. +// +// Adding new callers requires adding a friend declaration here — that +// is the review point. Do not relax the access (e.g. by making get() +// public) without considering the ISR-safety contract. +// +// Other platforms currently delegate to the public millis(); the friend +// gate still enforces the intent so platform-specific fast paths can be +// added later without changing call sites. +class MillisInternal { + private: + static ESPHOME_ALWAYS_INLINE uint32_t get() { +#if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000 + return xTaskGetTickCount(); +#else + return millis(); +#endif + } + friend class Application; + friend class WarnIfComponentBlockingGuard; +}; + +} // namespace esphome diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b0ce365a6f..b7e99d4603 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -285,8 +285,14 @@ class Scheduler { bool cancel_retry_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); // Extend a 32-bit millis() value to 64-bit. Use when the caller already has a fresh now. - // On platforms with native 64-bit time, ignores now and uses millis_64() directly. - // On other platforms, extends now to 64-bit using rollover tracking. + // On platforms with native 64-bit time (ESP32, Host, Zephyr, RP2040 — see + // USE_NATIVE_64BIT_TIME in defines.h), ignores now and uses millis_64() directly, so the + // Scheduler always works in 64-bit time regardless of what the caller's 32-bit now came + // from. On ESP32 specifically, millis() comes from xTaskGetTickCount while millis_64() + // comes from esp_timer — two different clocks — but that is safe because scheduling + // compares millis_64 values against millis_64 only, never against millis(). + // On platforms without native 64-bit time (e.g. ESP8266), extends now to 64-bit using + // rollover tracking, so both millis() and scheduling use the same underlying clock. uint64_t ESPHOME_ALWAYS_INLINE millis_64_from_(uint32_t now) { #ifdef USE_NATIVE_64BIT_TIME (void) now; From c0e1b7e255a43e49517eb324e20a86d7efd48d7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 07:33:42 +0200 Subject: [PATCH 0428/2214] [libretiny] Use xTaskGetTickCount() directly for millis() on 1 kHz chips --- esphome/components/libretiny/core.cpp | 23 ++++++++++++++++++++++- esphome/core/millis_internal.h | 21 ++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 1cfe68e924..1d7cb6ff60 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -16,8 +16,29 @@ void loop(); namespace esphome { void HOT yield() { ::yield(); } +// Skip the Arduino core's ::millis() wrapper so the public esphome::millis() is +// inlinable at its call sites and matches MillisInternal::get()'s fast path. +// IRAM_ATTR is kept because components (e.g. rotary_encoder) call millis() from +// ISR handlers; xTaskGetTickCountFromISR() is used in ISR context to satisfy the +// FreeRTOS API contract. +// +// RTL87xx and LN882x run FreeRTOS at 1 kHz, so xTaskGetTickCount() is already in +// milliseconds. BK72xx runs at 500 Hz — multiply by portTICK_PERIOD_MS (== 2) to +// convert ticks to milliseconds, matching the Arduino core's wiring.c. +#if defined(USE_RTL87XX) || defined(USE_LN882X) +uint32_t IRAM_ATTR HOT millis() { + static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); + return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount(); +} +#elif defined(USE_BK72XX) +uint32_t IRAM_ATTR HOT millis() { + // BK72xx's Arduino millis() does not dispatch on ISR context; match that. + return xTaskGetTickCount() * portTICK_PERIOD_MS; +} +#else uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -uint64_t millis_64() { return Millis64Impl::compute(::millis()); } +#endif +uint64_t millis_64() { return Millis64Impl::compute(millis()); } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void HOT delay(uint32_t ms) { ::delay(ms); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index 6b73476680..ded49f9cd2 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -7,6 +7,9 @@ #include #include #include +#elif defined(USE_LIBRETINY) +#include +#include #endif namespace esphome { @@ -14,10 +17,11 @@ namespace esphome { // Friend-gated accessor for a fast millis() variant intended only for // known task-context callers on the main loop hot path (Application::loop() // and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context -// dispatch that the public esphome::millis() pays on ESP32. +// dispatch that the public esphome::millis() pays on ESP32 and libretiny. // -// MUST NOT be called from ISR context: on ESP32 it calls the non-FromISR -// FreeRTOS API directly, which is undefined behavior in ISR context. +// MUST NOT be called from ISR context: on ESP32 and libretiny it calls the +// non-FromISR FreeRTOS API directly, which is undefined behavior in ISR +// context. // // Adding new callers requires adding a friend declaration here — that // is the review point. Do not relax the access (e.g. by making get() @@ -31,6 +35,17 @@ class MillisInternal { static ESPHOME_ALWAYS_INLINE uint32_t get() { #if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000 return xTaskGetTickCount(); +#elif defined(USE_LIBRETINY) && (defined(USE_RTL87XX) || defined(USE_LN882X)) + // RTL87xx and LN882x run FreeRTOS at 1 kHz, so xTaskGetTickCount() is + // already in milliseconds. + static_assert(configTICK_RATE_HZ == 1000, "MillisInternal fast path requires 1 kHz FreeRTOS tick"); + return xTaskGetTickCount(); +#elif defined(USE_BK72XX) + // BK72xx runs FreeRTOS at 500 Hz; scale ticks by portTICK_PERIOD_MS (== 2). + // Inlined here because esphome::millis() on BK72xx has no-op IRAM_ATTR but + // is still out-of-line, so calling it would cost a real function call. + static_assert(configTICK_RATE_HZ == 500, "BK72xx MillisInternal assumes 500 Hz FreeRTOS tick"); + return xTaskGetTickCount() * portTICK_PERIOD_MS; #else return millis(); #endif From 9cff70de2e3c1d738c501963df169f4dffa87366 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 07:41:37 +0200 Subject: [PATCH 0429/2214] Address Copilot review: add BK72xx static_assert, clarify ISR dispatch comment --- esphome/components/libretiny/core.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 1d7cb6ff60..eb749a828c 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -19,8 +19,9 @@ void HOT yield() { ::yield(); } // Skip the Arduino core's ::millis() wrapper so the public esphome::millis() is // inlinable at its call sites and matches MillisInternal::get()'s fast path. // IRAM_ATTR is kept because components (e.g. rotary_encoder) call millis() from -// ISR handlers; xTaskGetTickCountFromISR() is used in ISR context to satisfy the -// FreeRTOS API contract. +// ISR handlers. On RTL87xx / LN882x, millis() dispatches to xTaskGetTickCountFromISR() +// in ISR context to satisfy the FreeRTOS API contract; on BK72xx it intentionally +// matches the Arduino core's wiring.c and always uses xTaskGetTickCount(). // // RTL87xx and LN882x run FreeRTOS at 1 kHz, so xTaskGetTickCount() is already in // milliseconds. BK72xx runs at 500 Hz — multiply by portTICK_PERIOD_MS (== 2) to @@ -32,7 +33,7 @@ uint32_t IRAM_ATTR HOT millis() { } #elif defined(USE_BK72XX) uint32_t IRAM_ATTR HOT millis() { - // BK72xx's Arduino millis() does not dispatch on ISR context; match that. + static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick"); return xTaskGetTickCount() * portTICK_PERIOD_MS; } #else From 2e31b19d4b69aeeb486d34bfaf1a7a8e4782d8bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 07:42:29 +0200 Subject: [PATCH 0430/2214] Drop vestigial IRAM_ATTR from BK72xx millis(); SDK masks IRQ during flash ops --- esphome/components/libretiny/core.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index eb749a828c..54d5d68fe7 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -18,21 +18,24 @@ namespace esphome { void HOT yield() { ::yield(); } // Skip the Arduino core's ::millis() wrapper so the public esphome::millis() is // inlinable at its call sites and matches MillisInternal::get()'s fast path. -// IRAM_ATTR is kept because components (e.g. rotary_encoder) call millis() from -// ISR handlers. On RTL87xx / LN882x, millis() dispatches to xTaskGetTickCountFromISR() -// in ISR context to satisfy the FreeRTOS API contract; on BK72xx it intentionally -// matches the Arduino core's wiring.c and always uses xTaskGetTickCount(). // // RTL87xx and LN882x run FreeRTOS at 1 kHz, so xTaskGetTickCount() is already in -// milliseconds. BK72xx runs at 500 Hz — multiply by portTICK_PERIOD_MS (== 2) to -// convert ticks to milliseconds, matching the Arduino core's wiring.c. +// milliseconds. IRAM_ATTR is kept on those families because components (e.g. +// rotary_encoder) call millis() from ISR handlers, and in ISR context millis() +// dispatches to xTaskGetTickCountFromISR() to satisfy the FreeRTOS API contract. +// +// BK72xx runs FreeRTOS at 500 Hz — multiply by portTICK_PERIOD_MS (== 2) to convert +// ticks to milliseconds, matching the Arduino core's wiring.c. IRAM_ATTR is a no-op +// on BK72xx (see hal.h): the SDK masks FIQ + IRQ at the CPU around every flash +// operation, so no ISR runs while flash is stalled and IRAM placement is unnecessary. +// BK72xx therefore also skips the ISR dispatch, matching the Arduino core. #if defined(USE_RTL87XX) || defined(USE_LN882X) uint32_t IRAM_ATTR HOT millis() { static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount(); } #elif defined(USE_BK72XX) -uint32_t IRAM_ATTR HOT millis() { +uint32_t HOT millis() { static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick"); return xTaskGetTickCount() * portTICK_PERIOD_MS; } From dd42d31e3fda5294ff0fff609babc4b93ec18d66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 07:44:34 +0200 Subject: [PATCH 0431/2214] Explain BK72xx GLOBAL_INT_DISABLE rationale in MillisInternal --- esphome/core/millis_internal.h | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index ded49f9cd2..367b1a3d96 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -42,8 +42,13 @@ class MillisInternal { return xTaskGetTickCount(); #elif defined(USE_BK72XX) // BK72xx runs FreeRTOS at 500 Hz; scale ticks by portTICK_PERIOD_MS (== 2). - // Inlined here because esphome::millis() on BK72xx has no-op IRAM_ATTR but - // is still out-of-line, so calling it would cost a real function call. + // Inlined here because esphome::millis() on BK72xx is out-of-line (its + // IRAM_ATTR is a no-op — see hal.h — because the BK72xx SDK wraps flash + // operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for + // the duration of every write, so no ISR runs while flash is stalled and + // IRAM placement / FromISR dispatch are both unnecessary). Calling the + // out-of-line esphome::millis() would still cost a real function call, + // which this inlined path avoids. static_assert(configTICK_RATE_HZ == 500, "BK72xx MillisInternal assumes 500 Hz FreeRTOS tick"); return xTaskGetTickCount() * portTICK_PERIOD_MS; #else From 161cb622f1cf6e97ea7f751493ab330066d1fce4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 07:45:34 +0200 Subject: [PATCH 0432/2214] Tighten millis() fast-path comments --- esphome/components/libretiny/core.cpp | 18 +++++++----------- esphome/core/millis_internal.h | 14 ++++---------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 54d5d68fe7..1b74e3addb 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -16,19 +16,15 @@ void loop(); namespace esphome { void HOT yield() { ::yield(); } -// Skip the Arduino core's ::millis() wrapper so the public esphome::millis() is -// inlinable at its call sites and matches MillisInternal::get()'s fast path. +// Inline the tick read so esphome::millis() matches MillisInternal::get()'s fast +// path instead of going through the Arduino core's out-of-line ::millis() wrapper. // -// RTL87xx and LN882x run FreeRTOS at 1 kHz, so xTaskGetTickCount() is already in -// milliseconds. IRAM_ATTR is kept on those families because components (e.g. -// rotary_encoder) call millis() from ISR handlers, and in ISR context millis() -// dispatches to xTaskGetTickCountFromISR() to satisfy the FreeRTOS API contract. +// RTL87xx / LN882x (1 kHz): xTaskGetTickCount() is already ms. IRAM_ATTR + ISR +// dispatch are needed because ISR handlers (e.g. rotary_encoder) call millis(). // -// BK72xx runs FreeRTOS at 500 Hz — multiply by portTICK_PERIOD_MS (== 2) to convert -// ticks to milliseconds, matching the Arduino core's wiring.c. IRAM_ATTR is a no-op -// on BK72xx (see hal.h): the SDK masks FIQ + IRQ at the CPU around every flash -// operation, so no ISR runs while flash is stalled and IRAM placement is unnecessary. -// BK72xx therefore also skips the ISR dispatch, matching the Arduino core. +// BK72xx (500 Hz): ticks * portTICK_PERIOD_MS (== 2). IRAM_ATTR and ISR dispatch +// are both unnecessary — the SDK masks FIQ + IRQ during flash writes (see hal.h), +// so no ISR runs while flash is stalled. #if defined(USE_RTL87XX) || defined(USE_LN882X) uint32_t IRAM_ATTR HOT millis() { static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index 367b1a3d96..bc1d55a1c4 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -36,19 +36,13 @@ class MillisInternal { #if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000 return xTaskGetTickCount(); #elif defined(USE_LIBRETINY) && (defined(USE_RTL87XX) || defined(USE_LN882X)) - // RTL87xx and LN882x run FreeRTOS at 1 kHz, so xTaskGetTickCount() is - // already in milliseconds. + // 1 kHz: xTaskGetTickCount() is already ms. static_assert(configTICK_RATE_HZ == 1000, "MillisInternal fast path requires 1 kHz FreeRTOS tick"); return xTaskGetTickCount(); #elif defined(USE_BK72XX) - // BK72xx runs FreeRTOS at 500 Hz; scale ticks by portTICK_PERIOD_MS (== 2). - // Inlined here because esphome::millis() on BK72xx is out-of-line (its - // IRAM_ATTR is a no-op — see hal.h — because the BK72xx SDK wraps flash - // operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for - // the duration of every write, so no ISR runs while flash is stalled and - // IRAM placement / FromISR dispatch are both unnecessary). Calling the - // out-of-line esphome::millis() would still cost a real function call, - // which this inlined path avoids. + // 500 Hz: scale by portTICK_PERIOD_MS (== 2). Inlined to avoid the + // out-of-line call to esphome::millis() (IRAM_ATTR is a no-op on BK72xx — + // SDK masks FIQ + IRQ during flash writes, see hal.h). static_assert(configTICK_RATE_HZ == 500, "BK72xx MillisInternal assumes 500 Hz FreeRTOS tick"); return xTaskGetTickCount() * portTICK_PERIOD_MS; #else From 1a23d01511b0832b2c922554d232b6aa5c84cdf5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 07:52:51 +0200 Subject: [PATCH 0433/2214] Address review: tighten millis() / delay() comments, fix off-by-one wording --- esphome/components/esp8266/core.cpp | 57 +++++++++++------------------ 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 7551ecc3a7..b25e9f7ef8 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -16,30 +16,19 @@ extern "C" { namespace esphome { void HOT yield() { ::yield(); } -// Arduino ESP8266's millis() uses 4× 64-bit multiplies with magic constants to -// convert system_get_time() → ms while tracking overflow (~3.3 μs per call on -// the LX106 which has no hardware multiply-high instruction). We replace it with -// a simple accumulator that tracks a running millis counter from μs deltas using -// pure 32-bit ops on the common path (subtract, add, compare-and-subtract). -// Large gaps (>10 ms) fall back to a constant-time /1000 conversion. +// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit +// multiplies on the LX106). Tracks a running ms counter from 32-bit +// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis +// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. +// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static +// state against ISR re-entry; the critical section is bounded (≤9 while-loop +// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on +// the rare path — well under WiFi's ~10 μs ISR latency budget). // -// Overflow safety: system_get_time() is a uint32_t that wraps every ~71.6 minutes. -// Unsigned subtraction (now - last) handles one wrap correctly. ESPHome calls -// millis() thousands of times per second (1+N per loop iteration at 60+ Hz), so -// missing a full 71-minute wrap period is not a realistic concern. At boot, -// state.last_us starts at 0 and system_get_time() counts from 0, so the first call's -// delta equals the real elapsed time — no special initialization needed. -// -// This function is also installed as __wrap_millis (via -Wl,--wrap=millis) so -// that Arduino library code and ISR handlers (e.g. Wiegand, ZyAura) calling -// ::millis() directly also get the fast version. Interrupts are briefly disabled -// to protect the static state from concurrent ISR access. The critical section -// is bounded: the common path (delta < 10 ms) runs at most 10 subtract-and- -// compare iterations (~100 ns). Large gaps (WiFi scan, boot) fall back to a -// constant-time multiply-by-reciprocal (~2.5 μs, rare). -// Threshold above which we use constant-time /1000 instead of the while loop. -// 10 ms means the while loop runs at most 10 iterations (~100 ns) on the -// common path, well within the WiFi stack's ~10 μs interrupt latency budget. +// Overflow: system_get_time() wraps every ~71.6 min; unsigned now_us - last_us +// handles one wrap. Both the ESPHome main loop (1+N millis() calls per +// iteration at 60+ Hz) and esphome::delay() (which polls millis() in its wait +// loop) keep state.last_us fresh well inside the 71-minute window. static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; static constexpr uint32_t US_PER_MS = 1000; @@ -63,8 +52,7 @@ uint32_t IRAM_ATTR HOT millis() { state.cache += ms; state.remainder -= ms * US_PER_MS; } else { - // Common path: small gap. Loop runs at most - // MILLIS_RARE_PATH_THRESHOLD_US / US_PER_MS iterations. + // Common path: small gap. while (state.remainder >= US_PER_MS) { state.cache++; state.remainder -= US_PER_MS; @@ -75,17 +63,14 @@ uint32_t IRAM_ATTR HOT millis() { return result; } uint64_t millis_64() { return Millis64Impl::compute(millis()); } -// Avoid calling ::delay() which pulls in __delay from core_esp8266_wiring.cpp. -// __delay has an intra-object call to the original millis() that --wrap=millis -// can't intercept, preventing the linker from garbage-collecting the expensive -// original millis body (~80 bytes IRAM). -// -// Semantic difference from Arduino's delay(): Arduino sets up a one-shot -// os_timer and calls esp_suspend() to suspend the continuation once for the -// full duration. Our loop polls millis() + optimistic_yield(1000) which still -// calls esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks -// and WiFi run correctly. Less power-efficient for long delays but -// functionally equivalent. +// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object +// call to the original millis() that --wrap can't intercept, so calling ::delay() +// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still +// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and +// WiFi run correctly. Theoretically less power-efficient than Arduino's +// os_timer-based delay() for long waits, but nearly all ESPHome delays are short +// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is +// negligible. void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); From 12ad6637232d667cc652265abbc7af2baf711956 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 07:54:11 +0200 Subject: [PATCH 0434/2214] Address review: NMI note + static_assert hard-encoding remainder+delta overflow invariant --- esphome/components/esp8266/core.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index b25e9f7ef8..62907ac6aa 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -23,7 +23,8 @@ void HOT yield() { ::yield(); } // Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static // state against ISR re-entry; the critical section is bounded (≤9 while-loop // iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on -// the rare path — well under WiFi's ~10 μs ISR latency budget). +// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level +// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). // // Overflow: system_get_time() wraps every ~71.6 min; unsigned now_us - last_us // handles one wrap. Both the ESPHome main loop (1+N millis() calls per @@ -31,6 +32,10 @@ void HOT yield() { ::yield(); } // loop) keep state.last_us fresh well inside the 71-minute window. static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; static constexpr uint32_t US_PER_MS = 1000; +// Guarantees state.remainder + delta cannot wrap uint32_t: the threshold caps +// remainder on entry, and delta ≤ UINT32_MAX (one system_get_time() wrap). +static_assert(MILLIS_RARE_PATH_THRESHOLD_US < UINT32_MAX / 2, + "threshold must leave headroom so remainder + delta cannot overflow uint32_t"); uint32_t IRAM_ATTR HOT millis() { // Struct packs the three statics so the compiler loads one base address From 5218bbd7919225a946cdfa1b5409d999f5f163a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:19:47 +0200 Subject: [PATCH 0435/2214] Update argcomplete requirement from >=2.0.0 to >=3.6.3 (#15921) 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 90f06eff98..68557614d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,4 +30,4 @@ requests==2.33.1 pyparsing >= 3.3.2 # For autocompletion -argcomplete>=2.0.0 +argcomplete>=3.6.3 From 73714dc489a04ae5e17ea546cacc0c64c07face1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:26:25 +0200 Subject: [PATCH 0436/2214] Bump aioesphomeapi from 44.18.0 to 44.19.0 (#15920) 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 68557614d9..9e59bb59d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.18.0 +aioesphomeapi==44.19.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 886cd7ab725538dadd46a973b7c97594b45b8a6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 13:47:01 +0200 Subject: [PATCH 0437/2214] [core] Collapse adjacent USE_HOST ifdef blocks in Application (#15914) --- esphome/core/application.h | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index b480e52b2d..813f1ca8ed 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -487,9 +487,6 @@ class Application { #ifdef USE_HOST std::vector socket_fds_; // Vector of all monitored socket file descriptors #endif -#ifdef USE_HOST - int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks -#endif // StringRef members (8 bytes each: pointer + size) StringRef name_; @@ -505,7 +502,8 @@ class Application { #endif #ifdef USE_HOST - int max_fd_{-1}; // Highest file descriptor number for select() + int max_fd_{-1}; // Highest file descriptor number for select() + int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks #endif // 2-byte members (grouped together for alignment) @@ -522,9 +520,7 @@ class Application { #ifdef USE_HOST bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes -#endif -#ifdef USE_HOST // Variable-sized members (not needed with fast select — is_socket_ready_ reads rcvevent directly) fd_set read_fds_{}; // Working fd_set: populated by select() fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes From e35b435f027784f0a848066f2946b08c871db746 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 13:52:27 +0200 Subject: [PATCH 0438/2214] [libretiny] Inline xTaskGetTickCount() for millis() fast path (#15918) --- esphome/components/libretiny/core.cpp | 23 ++++++++++++++++++++++- esphome/core/millis_internal.h | 20 +++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 1cfe68e924..1b74e3addb 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -16,8 +16,29 @@ void loop(); namespace esphome { void HOT yield() { ::yield(); } +// Inline the tick read so esphome::millis() matches MillisInternal::get()'s fast +// path instead of going through the Arduino core's out-of-line ::millis() wrapper. +// +// RTL87xx / LN882x (1 kHz): xTaskGetTickCount() is already ms. IRAM_ATTR + ISR +// dispatch are needed because ISR handlers (e.g. rotary_encoder) call millis(). +// +// BK72xx (500 Hz): ticks * portTICK_PERIOD_MS (== 2). IRAM_ATTR and ISR dispatch +// are both unnecessary — the SDK masks FIQ + IRQ during flash writes (see hal.h), +// so no ISR runs while flash is stalled. +#if defined(USE_RTL87XX) || defined(USE_LN882X) +uint32_t IRAM_ATTR HOT millis() { + static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); + return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount(); +} +#elif defined(USE_BK72XX) +uint32_t HOT millis() { + static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick"); + return xTaskGetTickCount() * portTICK_PERIOD_MS; +} +#else uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -uint64_t millis_64() { return Millis64Impl::compute(::millis()); } +#endif +uint64_t millis_64() { return Millis64Impl::compute(millis()); } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void HOT delay(uint32_t ms) { ::delay(ms); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index 6b73476680..bc1d55a1c4 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -7,6 +7,9 @@ #include #include #include +#elif defined(USE_LIBRETINY) +#include +#include #endif namespace esphome { @@ -14,10 +17,11 @@ namespace esphome { // Friend-gated accessor for a fast millis() variant intended only for // known task-context callers on the main loop hot path (Application::loop() // and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context -// dispatch that the public esphome::millis() pays on ESP32. +// dispatch that the public esphome::millis() pays on ESP32 and libretiny. // -// MUST NOT be called from ISR context: on ESP32 it calls the non-FromISR -// FreeRTOS API directly, which is undefined behavior in ISR context. +// MUST NOT be called from ISR context: on ESP32 and libretiny it calls the +// non-FromISR FreeRTOS API directly, which is undefined behavior in ISR +// context. // // Adding new callers requires adding a friend declaration here — that // is the review point. Do not relax the access (e.g. by making get() @@ -31,6 +35,16 @@ class MillisInternal { static ESPHOME_ALWAYS_INLINE uint32_t get() { #if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000 return xTaskGetTickCount(); +#elif defined(USE_LIBRETINY) && (defined(USE_RTL87XX) || defined(USE_LN882X)) + // 1 kHz: xTaskGetTickCount() is already ms. + static_assert(configTICK_RATE_HZ == 1000, "MillisInternal fast path requires 1 kHz FreeRTOS tick"); + return xTaskGetTickCount(); +#elif defined(USE_BK72XX) + // 500 Hz: scale by portTICK_PERIOD_MS (== 2). Inlined to avoid the + // out-of-line call to esphome::millis() (IRAM_ATTR is a no-op on BK72xx — + // SDK masks FIQ + IRQ during flash writes, see hal.h). + static_assert(configTICK_RATE_HZ == 500, "BK72xx MillisInternal assumes 500 Hz FreeRTOS tick"); + return xTaskGetTickCount() * portTICK_PERIOD_MS; #else return millis(); #endif From f6bf6dc8e5ceb33a1acc40a320d9370894244985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 13:52:40 +0200 Subject: [PATCH 0439/2214] [core] Dedupe yield() fast path in wakeable_delay and always-inline (#15915) --- esphome/core/application.h | 12 ------------ esphome/core/wake.cpp | 2 +- esphome/core/wake.h | 18 ++++++++++++------ 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 813f1ca8ed..aad25c7530 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -748,18 +748,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Inline yield_with_select_ for all paths except the select() fallback #ifndef USE_HOST inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { -#ifdef USE_LWIP_FAST_SELECT - // Fast path (ESP32/LibreTiny): FreeRTOS task notifications posted by the lwip - // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: - // wake_loop_threadsafe() from background tasks, and the delay_ms timeout. - if (delay_ms == 0) [[unlikely]] { - yield(); - return; - } -#endif esphome::internal::wakeable_delay(delay_ms); } #endif // !USE_HOST diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index cebc4d04b7..00b08b7b91 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -58,7 +58,7 @@ static int64_t alarm_callback_(alarm_id_t id, void *user_data) { namespace internal { void wakeable_delay(uint32_t ms) { - if (ms == 0) { + if (ms == 0) [[unlikely]] { yield(); return; } diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 41b7ab33b5..15b882b306 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -96,8 +96,14 @@ inline void wake_loop_threadsafe() { } namespace internal { -inline void wakeable_delay(uint32_t ms) { - if (ms == 0) { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the ms timeout. + if (ms == 0) [[unlikely]] { yield(); return; } @@ -127,8 +133,8 @@ inline void wake_loop_threadsafe() { wake_loop_impl(); } inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } namespace internal { -inline void wakeable_delay(uint32_t ms) { - if (ms == 0) { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { delay(0); return; } @@ -174,8 +180,8 @@ inline void wake_loop_threadsafe() {} inline void wake_loop_any_context() { wake_loop_threadsafe(); } namespace internal { -inline void wakeable_delay(uint32_t ms) { - if (ms == 0) { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { yield(); return; } From c399cd2fa29c2ab7a14f4fd19e0d93e9243c7948 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 14:04:29 +0200 Subject: [PATCH 0440/2214] [core] RAII guard for component loop phase (#15897) --- esphome/core/application.cpp | 16 ++++++++-------- esphome/core/application.h | 30 +++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index ea1912d645..8612782d95 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -95,16 +95,16 @@ void Application::setup() { // interrupts during setup. During setup we always run the component // phase (no loop_interval_ gate), so call both helpers unconditionally. this->scheduler_tick_(MillisInternal::get()); - this->before_component_phase_(); + { + ComponentPhaseGuard phase_guard{*this}; - for (uint32_t j = 0; j <= i; j++) { - // Update loop_component_start_time_ right before calling each component - this->loop_component_start_time_ = MillisInternal::get(); - this->components_[j]->call(); - this->feed_wdt(); + for (uint32_t j = 0; j <= i; j++) { + // Update loop_component_start_time_ right before calling each component + this->loop_component_start_time_ = MillisInternal::get(); + this->components_[j]->call(); + this->feed_wdt(); + } } - - this->after_component_phase_(); yield(); } while (!component->can_proceed() && !component->is_failed()); } diff --git a/esphome/core/application.h b/esphome/core/application.h index aad25c7530..3d8df88d2a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -425,8 +425,20 @@ class Application { void enable_pending_loops_(); void activate_looping_component_(uint16_t index); inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); - inline void ESPHOME_ALWAYS_INLINE before_component_phase_(); - inline void ESPHOME_ALWAYS_INLINE after_component_phase_() { this->in_loop_ = false; } + + // RAII guard for a component loop phase. Constructor processes any pending + // enable_loop requests from ISRs and marks in_loop_ so reentrant + // modifications during component.loop() are safe; destructor clears in_loop_. + class ComponentPhaseGuard { + public: + inline ESPHOME_ALWAYS_INLINE explicit ComponentPhaseGuard(Application &app); + inline ESPHOME_ALWAYS_INLINE ~ComponentPhaseGuard() { this->app_.in_loop_ = false; } + ComponentPhaseGuard(const ComponentPhaseGuard &) = delete; + ComponentPhaseGuard &operator=(const ComponentPhaseGuard &) = delete; + + private: + Application &app_; + }; /// Process dump_config output one component per loop iteration. /// Extracted from loop() to keep cold startup/reconnect logging out of the hot path. @@ -595,10 +607,10 @@ inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) // Phase B entry: only invoked when a component loop phase is about to run. // Processes pending enable_loop requests from ISRs and marks in_loop_ so // reentrant modifications during component.loop() are safe. -inline void ESPHOME_ALWAYS_INLINE Application::before_component_phase_() { +inline ESPHOME_ALWAYS_INLINE Application::ComponentPhaseGuard::ComponentPhaseGuard(Application &app) : app_(app) { // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions - if (this->has_pending_enable_loop_requests_) { + if (this->app_.has_pending_enable_loop_requests_) { // Clear flag BEFORE processing to avoid race condition // If ISR sets it during processing, we'll catch it next loop iteration // This is safe because: @@ -606,12 +618,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_component_phase_() { // 2. If we can't process a component (wrong state), enable_pending_loops_() // will set this flag back to true // 3. Any new ISR requests during processing will set the flag again - this->has_pending_enable_loop_requests_ = false; - this->enable_pending_loops_(); + this->app_.has_pending_enable_loop_requests_ = false; + this->app_.enable_pending_loops_(); } // Mark that we're in the loop for safe reentrant modifications - this->in_loop_ = true; + this->app_.in_loop_ = true; } inline void ESPHOME_ALWAYS_INLINE Application::loop() { @@ -665,7 +677,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_); if (do_component_phase) { - this->before_component_phase_(); + ComponentPhaseGuard phase_guard{*this}; uint32_t last_op_end_time = now; for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; @@ -690,7 +702,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #endif this->last_loop_ = last_op_end_time; now = last_op_end_time; - this->after_component_phase_(); + // phase_guard destructor clears in_loop_ at scope exit } #ifdef USE_RUNTIME_STATS From d5263cd46e9ef2d1e33bf926ba74dea55b5edcca Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 22 Apr 2026 09:01:23 -0400 Subject: [PATCH 0441/2214] [esp32] add watchdog_timeout configuration variable (#15908) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 9 +++++++++ tests/components/esp32/test.esp32-idf.yaml | 1 + 2 files changed, 10 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 77b405a449..1a7ae700c7 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -33,6 +33,7 @@ from esphome.const import ( CONF_TYPE, CONF_VARIANT, CONF_VERSION, + CONF_WATCHDOG_TIMEOUT, KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_NAME, @@ -1507,6 +1508,10 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA, + cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All( + cv.positive_time_period_seconds, + cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)), + ), } ), _detect_variant, @@ -1874,6 +1879,10 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False) + add_idf_sdkconfig_option( + "CONFIG_ESP_TASK_WDT_TIMEOUT_S", + config[CONF_WATCHDOG_TIMEOUT].total_seconds, + ) # Disable dynamic log level control to save memory add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False) diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index b999f23e1c..6b77a4e171 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -20,6 +20,7 @@ esp32: disable_regi2c_in_iram: true disable_fatfs: true sram1_as_iram: true + watchdog_timeout: 7s wifi: ssid: MySSID From 5a0cc68f6974e4f4fb499ca187112662570835b6 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:37:34 +0200 Subject: [PATCH 0442/2214] Fix size calculation, deinit nvs --- esphome/components/ota/ota_backend_esp_idf.cpp | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ca3042003c..6074e63486 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -11,17 +11,13 @@ #ifdef USE_OTA_PARTITIONS #include +#include #endif namespace esphome::ota { static const char *const TAG = "ota.idf"; -#ifdef USE_OTA_PARTITIONS -static uint32_t running_app_offset = 0; -static size_t running_app_size = 0; -#endif - std::unique_ptr make_ota_backend() { return make_unique(); } #ifdef USE_OTA_PARTITIONS @@ -188,6 +184,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Get running app partition and used size + static uint32_t running_app_offset = 0; + static size_t running_app_size = 0; const esp_partition_t *running_app_part = nullptr; if (running_app_size == 0) { running_app_part = esp_ota_get_running_partition(); @@ -262,7 +260,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (new_part->pos.offset == running_app_offset) { app_index = i; } else if (new_part->pos.offset >= running_app_offset + running_app_size || - running_app_offset >= new_part->pos.offset + new_part->pos.size) { + running_app_offset >= new_part->pos.offset + running_app_size) { // New app partition has no overlap with running app esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { @@ -296,7 +294,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_UNKNOWN; } - ESP_LOGD(TAG, "Checks passed, starting partition table update", err); + ESP_LOGD(TAG, "Checks passed, starting partition table update"); + + // Deinitialize NVS to prevent unwanted flash writes + nvs_flash_deinit(); // Copy the running app partition to new position if needed if (app_index == -1) { @@ -383,8 +384,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_UNKNOWN; } - - ESP_LOGD(TAG, "Partition table updated successfully", err); return OTA_RESPONSE_OK; } #endif From 5e715692d600a3d3a67f104901aea691026b2fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 22 Apr 2026 19:01:20 +0200 Subject: [PATCH 0443/2214] [network] Reorder IPv6 configuration for network components (#11694) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/network/__init__.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1f75b12178..811e7c875a 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -109,21 +109,21 @@ CONFIG_SCHEMA = cv.Schema( { cv.SplitDefault( CONF_ENABLE_IPV6, - esp8266=False, - esp32=False, - rp2040=False, bk72xx=False, + esp32=False, + esp8266=False, host=False, + rp2040=False, ): cv.All( cv.boolean, cv.Any( cv.require_framework_version( + bk72xx_arduino=cv.Version(1, 7, 0), esp_idf=cv.Version(0, 0, 0), esp32_arduino=cv.Version(0, 0, 0), esp8266_arduino=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), - bk72xx_arduino=cv.Version(1, 7, 0), host=cv.Version(0, 0, 0), + rp2040_arduino=cv.Version(0, 0, 0), ), cv.boolean_false, ), @@ -218,9 +218,9 @@ async def to_code(config): elif enable_ipv6: cg.add_build_flag("-DCONFIG_LWIP_IPV6") cg.add_build_flag("-DCONFIG_LWIP_IPV6_AUTOCONFIG") - if CORE.is_rp2040: - cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") - if CORE.is_esp8266: - cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") if CORE.is_bk72xx: cg.add_build_flag("-DCONFIG_IPV6") + if CORE.is_esp8266: + cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") + if CORE.is_rp2040: + cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") From dcd103cec0e3dd95bda7beb794e1439553291d27 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:11:18 +0200 Subject: [PATCH 0444/2214] [cse7761] bidirectional active power (#15162) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/cse7761/cse7761.cpp | 13 ++++++++----- esphome/components/cse7761/cse7761.h | 4 +--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 7525b901f8..0ecaaced7f 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -204,24 +204,27 @@ void CSE7761Component::get_data_() { value = this->read_(CSE7761_REG_RMSIA, 3); this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA value = this->read_(CSE7761_REG_POWERPA, 4); - this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : ((uint32_t) abs((int) value)); + // PowerPA is two's complement signed 32-bit per datasheet + this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : static_cast(value); value = this->read_(CSE7761_REG_RMSIB, 3); this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA value = this->read_(CSE7761_REG_POWERPB, 4); - this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : ((uint32_t) abs((int) value)); + // PowerPB is two's complement signed 32-bit per datasheet + this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : static_cast(value); // convert values and publish to sensors - float voltage = (float) this->data_.voltage_rms / this->coefficient_by_unit_(RMS_UC); + float voltage = static_cast(this->data_.voltage_rms) / this->coefficient_by_unit_(RMS_UC); if (this->voltage_sensor_ != nullptr) { this->voltage_sensor_->publish_state(voltage); } for (uint8_t channel = 0; channel < 2; channel++) { // Active power = PowerPA * PowerPAC * 1000 / 0x80000000 - float active_power = (float) this->data_.active_power[channel] / this->coefficient_by_unit_(POWER_PAC); // W - float amps = (float) this->data_.current_rms[channel] / this->coefficient_by_unit_(RMS_IAC); // A + float active_power = + static_cast(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W + float amps = static_cast(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps); if (channel == 0) { if (this->power_sensor_1_ != nullptr) { diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index 289c5e7e19..0e03171956 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -11,10 +11,8 @@ struct CSE7761DataStruct { uint32_t frequency = 0; uint32_t voltage_rms = 0; uint32_t current_rms[2] = {0}; - uint32_t energy[2] = {0}; - uint32_t active_power[2] = {0}; + int32_t active_power[2] = {0}; uint16_t coefficient[8] = {0}; - uint8_t energy_update = 0; bool ready = false; }; From fcbc4d64fe059b8ffc7872c7cbea2285aac20c89 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 22 Apr 2026 10:20:02 -0700 Subject: [PATCH 0445/2214] [one_wire] Reset bus before SKIP ROM command (#14669) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/one_wire/one_wire_bus.cpp | 5 ++++- esphome/components/one_wire/one_wire_bus.h | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/one_wire/one_wire_bus.cpp b/esphome/components/one_wire/one_wire_bus.cpp index 27b7d58a0f..99e1f352fb 100644 --- a/esphome/components/one_wire/one_wire_bus.cpp +++ b/esphome/components/one_wire/one_wire_bus.cpp @@ -57,8 +57,11 @@ void OneWireBus::search() { } } -void OneWireBus::skip() { +bool OneWireBus::skip() { + if (!this->reset_()) + return false; this->write8(0xCC); // skip ROM + return true; } const LogString *OneWireBus::get_model_str(uint8_t model) { diff --git a/esphome/components/one_wire/one_wire_bus.h b/esphome/components/one_wire/one_wire_bus.h index c88532046f..6302fcee7b 100644 --- a/esphome/components/one_wire/one_wire_bus.h +++ b/esphome/components/one_wire/one_wire_bus.h @@ -16,7 +16,8 @@ class OneWireBus { virtual void write64(uint64_t val) = 0; /// Write a command to the bus that addresses all devices by skipping the ROM. - void skip(); + /// Returns true if a device presence pulse is detected. + bool skip(); /// Read an 8 bit word from the bus. virtual uint8_t read8() = 0; From ea2e36e55a732253355d02b782e423ab60c39cf7 Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:49:14 +0200 Subject: [PATCH 0446/2214] [dsmr] Improve performance. Add missing sensors. Remove Crypto-no-arduino. (#15875) --- .clang-tidy.hash | 2 +- esphome/components/dsmr/__init__.py | 65 +++- esphome/components/dsmr/dsmr.cpp | 407 +++++++------------- esphome/components/dsmr/dsmr.h | 129 ++++--- esphome/components/dsmr/sensor.py | 81 ++++ esphome/components/dsmr/text_sensor.py | 3 + platformio.ini | 3 +- tests/components/dsmr/test.esp32-ard.yaml | 7 + tests/components/dsmr/test.esp32-idf.yaml | 14 + tests/components/dsmr/test.esp8266-ard.yaml | 7 + 10 files changed, 369 insertions(+), 349 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 02aa990809..9b6b817633 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -c65f1a0804a7765462d570c50891ac719260592df2c9cdfe88233fc346ac59e9 +256216e144a626c8c9d1a458920a9db3de7dfc8c6a1b44b87946b9752e81026c diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 9c493bfcff..31ec1ce5b5 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -1,8 +1,19 @@ +import logging + 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_ID, CONF_RECEIVE_TIMEOUT, CONF_UART_ID +from esphome.const import ( + CONF_ID, + CONF_RECEIVE_TIMEOUT, + CONF_RX_BUFFER_SIZE, + CONF_UART_ID, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@PolarGoose"] @@ -21,8 +32,7 @@ CONF_MAX_TELEGRAM_LENGTH = "max_telegram_length" CONF_REQUEST_INTERVAL = "request_interval" CONF_REQUEST_PIN = "request_pin" -# Hack to prevent compile error due to ambiguity with lib namespace -dsmr_ns = cg.esphome_ns.namespace("esphome::dsmr") +dsmr_ns = cg.esphome_ns.namespace("dsmr") Dsmr = dsmr_ns.class_("Dsmr", cg.Component, uart.UARTDevice) @@ -54,24 +64,47 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): uart_component = await cg.get_variable(config[CONF_UART_ID]) - var = cg.new_Pvariable(config[CONF_ID], uart_component, config[CONF_CRC_CHECK]) - cg.add(var.set_max_telegram_length(config[CONF_MAX_TELEGRAM_LENGTH])) - if CONF_DECRYPTION_KEY in config: - cg.add(var.set_decryption_key(config[CONF_DECRYPTION_KEY])) - await cg.register_component(var, config) - if CONF_REQUEST_PIN in config: request_pin = await cg.gpio_pin_expression(config[CONF_REQUEST_PIN]) - cg.add(var.set_request_pin(request_pin)) - cg.add(var.set_request_interval(config[CONF_REQUEST_INTERVAL].total_milliseconds)) - cg.add(var.set_receive_timeout(config[CONF_RECEIVE_TIMEOUT].total_milliseconds)) + else: + request_pin = cg.nullptr + decryption_key = config.get(CONF_DECRYPTION_KEY) + if decryption_key is None: + decryption_key = cg.nullptr + var = cg.new_Pvariable( + config[CONF_ID], + uart_component, + config[CONF_CRC_CHECK], + config[CONF_MAX_TELEGRAM_LENGTH], + config[CONF_REQUEST_INTERVAL].total_milliseconds, + config[CONF_RECEIVE_TIMEOUT].total_milliseconds, + request_pin, + decryption_key, + ) + await cg.register_component(var, config) cg.add_build_flag("-DDSMR_GAS_MBUS_ID=" + str(config[CONF_GAS_MBUS_ID])) cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - # DSMR Parser - cg.add_library("esphome/dsmr_parser", "1.1.0") + cg.add_library("esphome/dsmr_parser", "1.4.0") - # Crypto - cg.add_library("polargoose/Crypto-no-arduino", "0.4.0") + +def final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + + for uart_conf in full_config["uart"]: + if uart_conf[CONF_ID] == config[CONF_UART_ID]: + rx_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE] + if rx_buffer_size < 1500: + _LOGGER.warning( + "UART '%s' rx_buffer_size should be bigger than 1500 bytes to avoid packet losses (currently %d bytes).", + config[CONF_UART_ID], + rx_buffer_size, + ) + break + + return config + + +FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/dsmr/dsmr.cpp b/esphome/components/dsmr/dsmr.cpp index baf7f59314..2fa51f73af 100644 --- a/esphome/components/dsmr/dsmr.cpp +++ b/esphome/components/dsmr/dsmr.cpp @@ -1,315 +1,183 @@ -#include "dsmr.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" +// Ignore Zephyr. It doesn't have any encryption library. +#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST) -#include -#include -#include +#include "dsmr.h" +#include "esphome/core/log.h" +#include namespace esphome::dsmr { -static const char *const TAG = "dsmr"; +static constexpr auto &TAG = "dsmr"; + +static void log_callback(dsmr_parser::LogLevel level, const char *fmt, va_list args) { + std::array buf; + vsnprintf(buf.data(), buf.size(), fmt, args); + switch (level) { + case dsmr_parser::LogLevel::ERROR: + ESP_LOGE(TAG, "%s", buf.data()); + break; + case dsmr_parser::LogLevel::WARNING: + ESP_LOGW(TAG, "%s", buf.data()); + break; + case dsmr_parser::LogLevel::INFO: + ESP_LOGI(TAG, "%s", buf.data()); + break; + case dsmr_parser::LogLevel::VERBOSE: + ESP_LOGV(TAG, "%s", buf.data()); + break; + case dsmr_parser::LogLevel::VERY_VERBOSE: + ESP_LOGVV(TAG, "%s", buf.data()); + break; + case dsmr_parser::LogLevel::DEBUG: + ESP_LOGD(TAG, "%s", buf.data()); + break; + } +} void Dsmr::setup() { - this->telegram_ = new char[this->max_telegram_len_]; // NOLINT + dsmr_parser::Logger::set_log_function(log_callback); if (this->request_pin_ != nullptr) { this->request_pin_->setup(); } } void Dsmr::loop() { - if (this->ready_to_request_data_()) { - if (this->decryption_key_.empty()) { - this->receive_telegram_(); - } else { - this->receive_encrypted_telegram_(); - } + if (!this->ready_to_request_data_()) { + return; + } + + if (this->encryption_enabled_) { + this->receive_encrypted_telegram_(); + } else { + this->receive_telegram_(); } } bool Dsmr::ready_to_request_data_() { - // When using a request pin, then wait for the next request interval. - if (this->request_pin_ != nullptr) { - if (!this->requesting_data_ && this->request_interval_reached_()) { - this->start_requesting_data_(); - } - } - // Otherwise, sink serial data until next request interval. - else { - if (this->request_interval_reached_()) { - this->start_requesting_data_(); - } - if (!this->requesting_data_) { - this->drain_rx_buffer_(); - } + if (!this->requesting_data_ && this->request_interval_reached_()) { + this->start_requesting_data_(); } return this->requesting_data_; } -bool Dsmr::request_interval_reached_() { +bool Dsmr::request_interval_reached_() const { if (this->last_request_time_ == 0) { return true; } return millis() - this->last_request_time_ > this->request_interval_; } -bool Dsmr::receive_timeout_reached_() { return millis() - this->last_read_time_ > this->receive_timeout_; } - -bool Dsmr::available_within_timeout_() { - // Data are available for reading on the UART bus? - // Then we can start reading right away. - if (this->available()) { - this->last_read_time_ = millis(); - return true; - } - // When we're not in the process of reading a telegram, then there is - // no need to actively wait for new data to come in. - if (!header_found_) { - return false; - } - // A telegram is being read. The smart meter might not deliver a telegram - // in one go, but instead send it in chunks with small pauses in between. - // When the UART RX buffer cannot hold a full telegram, then make sure - // that the UART read buffer does not overflow while other components - // perform their work in their loop. Do this by not returning control to - // the main loop, until the read timeout is reached. - if (this->parent_->get_rx_buffer_size() < this->max_telegram_len_) { - while (!this->receive_timeout_reached_()) { - delay(5); - if (this->available()) { - this->last_read_time_ = millis(); - return true; - } - } - } - // No new data has come in during the read timeout? Then stop reading the - // telegram and start waiting for the next one to arrive. - if (this->receive_timeout_reached_()) { - ESP_LOGW(TAG, "Timeout while reading data for telegram"); - this->reset_telegram_(); - } - - return false; -} - void Dsmr::start_requesting_data_() { - if (!this->requesting_data_) { - if (this->request_pin_ != nullptr) { - ESP_LOGV(TAG, "Start requesting data from P1 port"); - this->request_pin_->digital_write(true); - } else { - ESP_LOGV(TAG, "Start reading data from P1 port"); - } - this->requesting_data_ = true; - this->last_request_time_ = millis(); + if (this->requesting_data_) { + return; } + + ESP_LOGV(TAG, "Start reading data from P1 port"); + this->flush_rx_buffer_(); + + if (this->request_pin_ != nullptr) { + ESP_LOGV(TAG, "Set request pin to 1"); + this->request_pin_->digital_write(true); + } + + this->requesting_data_ = true; + this->last_request_time_ = millis(); } void Dsmr::stop_requesting_data_() { - if (this->requesting_data_) { - if (this->request_pin_ != nullptr) { - ESP_LOGV(TAG, "Stop requesting data from P1 port"); - this->request_pin_->digital_write(false); - } else { - ESP_LOGV(TAG, "Stop reading data from P1 port"); - } - this->drain_rx_buffer_(); - this->requesting_data_ = false; + if (!this->requesting_data_) { + return; } + + ESP_LOGV(TAG, "Stop reading data from P1 port"); + if (this->request_pin_ != nullptr) { + ESP_LOGV(TAG, "Set request pin to 0"); + this->request_pin_->digital_write(false); + } + this->requesting_data_ = false; } -void Dsmr::drain_rx_buffer_() { - uint8_t buf[64]; - size_t avail; - while ((avail = this->available()) > 0) { - if (!this->read_array(buf, std::min(avail, sizeof(buf)))) { - break; - } +void Dsmr::flush_rx_buffer_() { + ESP_LOGV(TAG, "Flush UART RX buffer"); + while (!this->uart_read_chunk_().empty()) { } } -void Dsmr::reset_telegram_() { - this->header_found_ = false; - this->footer_found_ = false; - this->bytes_read_ = 0; - this->crypt_bytes_read_ = 0; - this->crypt_telegram_len_ = 0; -} - void Dsmr::receive_telegram_() { - while (this->available_within_timeout_()) { - // Read all available bytes in batches to reduce UART call overhead. - uint8_t buf[64]; - size_t avail = this->available(); - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) + for (auto data = this->uart_read_chunk_(); !data.empty(); data = this->uart_read_chunk_()) { + for (uint8_t byte : data) { + const auto telegram = this->packet_accumulator_.process_byte(byte); + if (!telegram) { // No full packet received yet + continue; + } + if (this->parse_telegram_(telegram.value())) { return; - avail -= to_read; - - for (size_t i = 0; i < to_read; i++) { - const char c = static_cast(buf[i]); - - // Find a new telegram header, i.e. forward slash. - if (c == '/') { - ESP_LOGV(TAG, "Header of telegram found"); - this->reset_telegram_(); - this->header_found_ = true; - } - if (!this->header_found_) - continue; - - // Check for buffer overflow. - if (this->bytes_read_ >= this->max_telegram_len_) { - this->reset_telegram_(); - ESP_LOGE(TAG, "Error: telegram larger than buffer (%d bytes)", this->max_telegram_len_); - return; - } - - // Some v2.2 or v3 meters will send a new value which starts with '(' - // in a new line, while the value belongs to the previous ObisId. For - // proper parsing, remove these new line characters. - if (c == '(') { - while (true) { - auto previous_char = this->telegram_[this->bytes_read_ - 1]; - if (previous_char == '\n' || previous_char == '\r') { - this->bytes_read_--; - } else { - break; - } - } - } - - // Store the byte in the buffer. - this->telegram_[this->bytes_read_] = c; - this->bytes_read_++; - - // Check for a footer, i.e. exclamation mark, followed by a hex checksum. - if (c == '!') { - ESP_LOGV(TAG, "Footer of telegram found"); - this->footer_found_ = true; - continue; - } - // Check for the end of the hex checksum, i.e. a newline. - if (this->footer_found_ && c == '\n') { - // Parse the telegram and publish sensor values. - this->parse_telegram(); - this->reset_telegram_(); - return; - } } } } } void Dsmr::receive_encrypted_telegram_() { - while (this->available_within_timeout_()) { - // Read all available bytes in batches to reduce UART call overhead. - uint8_t buf[64]; - size_t avail = this->available(); - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) - return; - avail -= to_read; - - for (size_t i = 0; i < to_read; i++) { - const char c = static_cast(buf[i]); - - // Find a new telegram start byte. - if (!this->header_found_) { - if ((uint8_t) c != 0xDB) { - continue; - } - ESP_LOGV(TAG, "Start byte 0xDB of encrypted telegram found"); - this->reset_telegram_(); - this->header_found_ = true; - } - - // Check for buffer overflow. - if (this->crypt_bytes_read_ >= this->max_telegram_len_) { - this->reset_telegram_(); - ESP_LOGE(TAG, "Error: encrypted telegram larger than buffer (%d bytes)", this->max_telegram_len_); - return; - } - - // Store the byte in the buffer. - this->crypt_telegram_[this->crypt_bytes_read_] = c; - this->crypt_bytes_read_++; - - // Read the length of the incoming encrypted telegram. - if (this->crypt_telegram_len_ == 0 && this->crypt_bytes_read_ > 20) { - // Complete header + data bytes - this->crypt_telegram_len_ = 13 + (this->crypt_telegram_[11] << 8 | this->crypt_telegram_[12]); - ESP_LOGV(TAG, "Encrypted telegram length: %d bytes", this->crypt_telegram_len_); - } - - // Check for the end of the encrypted telegram. - if (this->crypt_telegram_len_ == 0 || this->crypt_bytes_read_ != this->crypt_telegram_len_) { - continue; - } - ESP_LOGV(TAG, "End of encrypted telegram found"); - - // Decrypt the encrypted telegram. - GCM *gcmaes128{new GCM()}; - gcmaes128->setKey(this->decryption_key_.data(), gcmaes128->keySize()); - // the iv is 8 bytes of the system title + 4 bytes frame counter - // system title is at byte 2 and frame counter at byte 15 - for (int i = 10; i < 14; i++) - this->crypt_telegram_[i] = this->crypt_telegram_[i + 4]; - constexpr uint16_t iv_size{12}; - gcmaes128->setIV(&this->crypt_telegram_[2], iv_size); - gcmaes128->decrypt(reinterpret_cast(this->telegram_), - // the ciphertext start at byte 18 - &this->crypt_telegram_[18], - // cipher size - this->crypt_bytes_read_ - 17); - delete gcmaes128; // NOLINT(cppcoreguidelines-owning-memory) - - this->bytes_read_ = strnlen(this->telegram_, this->max_telegram_len_); - ESP_LOGV(TAG, "Decrypted telegram size: %d bytes", this->bytes_read_); - ESP_LOGVV(TAG, "Decrypted telegram: %s", this->telegram_); - - // Parse the decrypted telegram and publish sensor values. - this->parse_telegram(); - this->reset_telegram_(); - return; + for (auto data = this->uart_read_chunk_(); !data.empty(); data = this->uart_read_chunk_()) { + for (uint8_t byte : data) { + if (this->buffer_pos_ >= this->buffer_.size()) { // Reset buffer if overflow + ESP_LOGW(TAG, "Encrypted buffer overflow, resetting"); + this->buffer_pos_ = 0; } + + this->buffer_[this->buffer_pos_] = byte; + this->buffer_pos_++; } + this->last_read_time_ = millis(); + } + + // Detect inter-frame delay. If no byte is received for more than receive_timeout, then the packet is complete. + if (millis() - this->last_read_time_ > this->receive_timeout_ && this->buffer_pos_ > 0) { + ESP_LOGV(TAG, "Encrypted telegram received (%zu bytes)", this->buffer_pos_); + + const auto telegram = this->dlms_decryptor_.decrypt_inplace({this->buffer_.data(), this->buffer_pos_}); + + // Reset buffer position for the next packet + this->buffer_pos_ = 0; + this->last_read_time_ = 0; + + if (!telegram) { // decryption failed + return; + } + + // Parse and publish the telegram + this->parse_telegram_(telegram.value()); } } -bool Dsmr::parse_telegram() { - MyData data; - ESP_LOGV(TAG, "Trying to parse telegram"); +bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) { this->stop_requesting_data_(); - const auto &res = dsmr_parser::P1Parser::parse( - data, this->telegram_, this->bytes_read_, false, - this->crc_check_); // Parse telegram according to data definition. Ignore unknown values. - if (res.err) { - // Parsing error, show it - auto err_str = res.fullError(this->telegram_, this->telegram_ + this->bytes_read_); - ESP_LOGE(TAG, "%s", err_str.c_str()); - return false; - } else { - this->status_clear_warning(); - this->publish_sensors(data); + ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size()); + ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.content().size()), telegram.content().data()); - // publish the telegram, after publishing the sensors so it can also trigger action based on latest values - if (this->s_telegram_ != nullptr) { - this->s_telegram_->publish_state(this->telegram_, this->bytes_read_); - } - return true; + MyData data; + if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) { + ESP_LOGE(TAG, "Failed to parse telegram"); + return false; } + + this->status_clear_warning(); + this->publish_sensors(data); + + // Publish the telegram, after publishing the sensors so it can also trigger action based on latest values + if (this->s_telegram_ != nullptr) { + this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size()); + } + return true; } void Dsmr::dump_config() { ESP_LOGCONFIG(TAG, "DSMR:\n" - " Max telegram length: %d\n" + " Max telegram length: %zu\n" " Receive timeout: %.1fs", - this->max_telegram_len_, this->receive_timeout_ / 1e3f); + this->buffer_.size(), this->receive_timeout_ / 1e3f); if (this->request_pin_ != nullptr) { LOG_PIN(" Request Pin: ", this->request_pin_); } @@ -324,30 +192,37 @@ void Dsmr::dump_config() { DSMR_TEXT_SENSOR_LIST(DSMR_LOG_TEXT_SENSOR, ) } -void Dsmr::set_decryption_key(const char *decryption_key) { +void Dsmr::set_decryption_key_(const char *decryption_key) { if (decryption_key == nullptr || decryption_key[0] == '\0') { - ESP_LOGI(TAG, "Disabling decryption"); - this->decryption_key_.clear(); - if (this->crypt_telegram_ != nullptr) { - delete[] this->crypt_telegram_; - this->crypt_telegram_ = nullptr; - } + this->encryption_enabled_ = false; return; } - if (!parse_hex(decryption_key, this->decryption_key_, 16)) { - ESP_LOGE(TAG, "Error, decryption key must be 32 hex characters"); - this->decryption_key_.clear(); + auto key = dsmr_parser::Aes128GcmDecryptionKey::from_hex(decryption_key); + if (!key) { + ESP_LOGE(TAG, "Error, decryption key has incorrect format"); + this->encryption_enabled_ = false; return; } ESP_LOGI(TAG, "Decryption key is set"); - // Verbose level prints decryption key - ESP_LOGV(TAG, "Using decryption key: %s", decryption_key); - if (this->crypt_telegram_ == nullptr) { - this->crypt_telegram_ = new uint8_t[this->max_telegram_len_]; // NOLINT + this->gcm_decryptor_.set_encryption_key(key.value()); + this->encryption_enabled_ = true; +} + +std::span Dsmr::uart_read_chunk_() { + const auto avail = this->available(); + if (avail == 0) { + return {}; } + size_t to_read = std::min(avail, uart_chunk_reading_buf_.size()); + if (!this->read_array(uart_chunk_reading_buf_.data(), to_read)) { + return {}; + } + return {uart_chunk_reading_buf_.data(), to_read}; } } // namespace esphome::dsmr + +#endif diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index dc81ba9b2a..c76a23fde4 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -1,31 +1,41 @@ #pragma once +// Ignore Zephyr. It doesn't have any encryption library. +#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST) + #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/uart/uart.h" #include "esphome/core/log.h" +#include #include +#include #include +#include +#include #include +#if __has_include() +#include +using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa; +#elif __has_include() +#if __has_include() +#include +#endif +#include +using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls; +#elif __has_include() +#include +using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl; +#else +#error "The platform doesn't provide a compatible encryption library for dsmr_parser" +#endif + namespace esphome::dsmr { using namespace dsmr_parser::fields; -// DSMR_**_LIST generated by ESPHome and written in esphome/core/defines - -#if !defined(DSMR_SENSOR_LIST) && !defined(DSMR_TEXT_SENSOR_LIST) -// Neither set, set it to a dummy value to not break build -#define DSMR_TEXT_SENSOR_LIST(F, SEP) F(identification) -#endif - -#if defined(DSMR_SENSOR_LIST) && defined(DSMR_TEXT_SENSOR_LIST) -#define DSMR_BOTH , -#else -#define DSMR_BOTH -#endif - #ifndef DSMR_SENSOR_LIST #define DSMR_SENSOR_LIST(F, SEP) #endif @@ -34,21 +44,33 @@ using namespace dsmr_parser::fields; #define DSMR_TEXT_SENSOR_LIST(F, SEP) #endif -#define DSMR_DATA_SENSOR(s) s +#define DSMR_IDENTITY(s) s #define DSMR_COMMA , +#define DSMR_PREPEND_COMMA(...) __VA_OPT__(, ) __VA_ARGS__ -using MyData = dsmr_parser::ParsedData; +#ifdef DSMR_TEXT_SENSOR_LIST_DEFINED +using MyData = dsmr_parser::ParsedData; +#else +using MyData = dsmr_parser::ParsedData; +#endif class Dsmr : public Component, public uart::UARTDevice { public: - Dsmr(uart::UARTComponent *uart, bool crc_check) : uart::UARTDevice(uart), crc_check_(crc_check) {} + Dsmr(uart::UARTComponent *uart, bool crc_check, size_t max_telegram_length, uint32_t request_interval, + uint32_t receive_timeout, GPIOPin *request_pin, const char *decryption_key) + : uart::UARTDevice(uart), + request_interval_(request_interval), + receive_timeout_(receive_timeout), + request_pin_(request_pin), + buffer_(max_telegram_length), + packet_accumulator_(buffer_, crc_check) { + this->set_decryption_key_(decryption_key); + } void setup() override; void loop() override; - bool parse_telegram(); - void publish_sensors(MyData &data) { #define DSMR_PUBLISH_SENSOR(s) \ if (data.s##_present && this->s_##s##_ != nullptr) \ @@ -57,20 +79,15 @@ class Dsmr : public Component, public uart::UARTDevice { #define DSMR_PUBLISH_TEXT_SENSOR(s) \ if (data.s##_present && this->s_##s##_ != nullptr) \ - s_##s##_->publish_state(data.s.c_str()); + s_##s##_->publish_state(data.s.data(), data.s.size()); DSMR_TEXT_SENSOR_LIST(DSMR_PUBLISH_TEXT_SENSOR, ) }; void dump_config() override; - void set_decryption_key(const char *decryption_key); // Remove before 2026.8.0 - ESPDEPRECATED("Pass .c_str() - e.g. set_decryption_key(key.c_str()). Removed in 2026.8.0", "2026.2.0") - void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key(decryption_key.c_str()); } - void set_max_telegram_length(size_t length) { this->max_telegram_len_ = length; } - void set_request_pin(GPIOPin *request_pin) { this->request_pin_ = request_pin; } - void set_request_interval(uint32_t interval) { this->request_interval_ = interval; } - void set_receive_timeout(uint32_t timeout) { this->receive_timeout_ = timeout; } + ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0") + void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); } // Sensor setters #define DSMR_SET_SENSOR(s) \ @@ -85,56 +102,40 @@ class Dsmr : public Component, public uart::UARTDevice { void set_telegram(text_sensor::TextSensor *sensor) { s_telegram_ = sensor; } protected: + void set_decryption_key_(const char *decryption_key); void receive_telegram_(); void receive_encrypted_telegram_(); - void reset_telegram_(); - void drain_rx_buffer_(); + void flush_rx_buffer_(); - /// Wait for UART data to become available within the read timeout. - /// - /// The smart meter might provide data in chunks, causing available() to - /// return 0. When we're already reading a telegram, then we don't return - /// right away (to handle further data in an upcoming loop) but wait a - /// little while using this method to see if more data are incoming. - /// By not returning, we prevent other components from taking so much - /// time that the UART RX buffer overflows and bytes of the telegram get - /// lost in the process. - bool available_within_timeout_(); - - // Request telegram - uint32_t request_interval_; - bool request_interval_reached_(); - GPIOPin *request_pin_{nullptr}; - uint32_t last_request_time_{0}; - bool requesting_data_{false}; + bool parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram); + bool request_interval_reached_() const; bool ready_to_request_data_(); void start_requesting_data_(); void stop_requesting_data_(); + std::span uart_read_chunk_(); - // Read telegram + // Config + uint32_t request_interval_; uint32_t receive_timeout_; - bool receive_timeout_reached_(); - size_t max_telegram_len_; - char *telegram_{nullptr}; - size_t bytes_read_{0}; - uint8_t *crypt_telegram_{nullptr}; - size_t crypt_telegram_len_{0}; - size_t crypt_bytes_read_{0}; - uint32_t last_read_time_{0}; - bool header_found_{false}; - bool footer_found_{false}; - - // handled outside dsmr + GPIOPin *request_pin_{nullptr}; text_sensor::TextSensor *s_telegram_{nullptr}; - -// Sensor member pointers #define DSMR_DECLARE_SENSOR(s) sensor::Sensor *s_##s##_{nullptr}; DSMR_SENSOR_LIST(DSMR_DECLARE_SENSOR, ) - #define DSMR_DECLARE_TEXT_SENSOR(s) text_sensor::TextSensor *s_##s##_{nullptr}; DSMR_TEXT_SENSOR_LIST(DSMR_DECLARE_TEXT_SENSOR, ) - std::vector decryption_key_{}; - bool crc_check_; + // State + uint32_t last_request_time_{0}; + uint32_t last_read_time_{0}; + bool requesting_data_{false}; + bool encryption_enabled_{false}; + size_t buffer_pos_{0}; + std::vector buffer_; + dsmr_parser::PacketAccumulator packet_accumulator_; + Aes128GcmDecryptorImpl gcm_decryptor_; + dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_}; + std::array uart_chunk_reading_buf_; }; } // namespace esphome::dsmr + +#endif diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index c49614eaa9..292e5a1156 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_GAS, DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_VOLTAGE, DEVICE_CLASS_WATER, @@ -119,6 +120,42 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional("energy_delivered_tariff1_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("energy_delivered_tariff2_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("energy_delivered_tariff3_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("energy_returned_tariff1_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("energy_returned_tariff2_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("energy_returned_tariff3_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("total_imported_energy"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, @@ -511,6 +548,12 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_GAS, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional("gas_delivered_gj"): sensor.sensor_schema( + unit_of_measurement=UNIT_GIGA_JOULE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("water_delivered"): sensor.sensor_schema( unit_of_measurement=UNIT_CUBIC_METER, accuracy_decimals=3, @@ -614,6 +657,12 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional("active_demand_net"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), cv.Optional("active_demand_abs"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT, accuracy_decimals=3, @@ -728,6 +777,37 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional("power_factor"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_factor_l1"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_factor_l2"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_factor_l3"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("min_power_factor"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("period_3_for_instantaneous_values"): sensor.sensor_schema( + unit_of_measurement=UNIT_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -746,6 +826,7 @@ async def to_code(config): sensors.append(f"F({key})") if sensors: + cg.add_define("DSMR_SENSOR_LIST_DEFINED") cg.add_define( "DSMR_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors)) ) diff --git a/esphome/components/dsmr/text_sensor.py b/esphome/components/dsmr/text_sensor.py index 203c9c997e..a8f29c7ca8 100644 --- a/esphome/components/dsmr/text_sensor.py +++ b/esphome/components/dsmr/text_sensor.py @@ -15,7 +15,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(), cv.Optional("timestamp"): text_sensor.text_sensor_schema(), cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(), + cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(), cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(), + cv.Optional("electricity_failure_log_il"): text_sensor.text_sensor_schema(), cv.Optional("message_short"): text_sensor.text_sensor_schema(), cv.Optional("message_long"): text_sensor.text_sensor_schema(), cv.Optional("equipment_id"): text_sensor.text_sensor_schema(), @@ -52,6 +54,7 @@ async def to_code(config): text_sensors.append(f"F({key})") if text_sensors: + cg.add_define("DSMR_TEXT_SENSOR_LIST_DEFINED") cg.add_define( "DSMR_TEXT_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(text_sensors)), diff --git a/platformio.ini b/platformio.ini index d7b14944e4..3023a15732 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,8 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.1.0 ; dsmr - polargoose/Crypto-no-arduino@0.4.0 ; dsmr + esphome/dsmr_parser@1.4.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library diff --git a/tests/components/dsmr/test.esp32-ard.yaml b/tests/components/dsmr/test.esp32-ard.yaml index f218b297aa..41ea1e8d89 100644 --- a/tests/components/dsmr/test.esp32-ard.yaml +++ b/tests/components/dsmr/test.esp32-ard.yaml @@ -5,3 +5,10 @@ packages: uart: !include ../../test_build_components/common/uart/esp32-ard.yaml <<: !include common.yaml + +sensor: + - platform: dsmr + energy_delivered_lux: + name: "Energy Consumed Luxembourg. OBIS: 1-0:1.8.0" + energy_delivered_tariff1: + name: "Energy Consumed Tariff 1. OBIS: 1-0:1.8.1" diff --git a/tests/components/dsmr/test.esp32-idf.yaml b/tests/components/dsmr/test.esp32-idf.yaml index 522f60db49..9eb7d3e178 100644 --- a/tests/components/dsmr/test.esp32-idf.yaml +++ b/tests/components/dsmr/test.esp32-idf.yaml @@ -5,3 +5,17 @@ packages: uart: !include ../../test_build_components/common/uart/esp32-idf.yaml <<: !include common.yaml + +sensor: + - platform: dsmr + energy_delivered_lux: + name: "Energy Consumed Luxembourg. OBIS: 1-0:1.8.0" + energy_delivered_tariff1: + name: "Energy Consumed Tariff 1. OBIS: 1-0:1.8.1" + +text_sensor: + - platform: dsmr + identification: + name: "DSMR Identification" + p1_version: + name: "DSMR Version. OBIS: 1-3:0.2.8" diff --git a/tests/components/dsmr/test.esp8266-ard.yaml b/tests/components/dsmr/test.esp8266-ard.yaml index 08bcf16fc9..d318076edb 100644 --- a/tests/components/dsmr/test.esp8266-ard.yaml +++ b/tests/components/dsmr/test.esp8266-ard.yaml @@ -5,3 +5,10 @@ packages: uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml <<: !include common.yaml + +text_sensor: + - platform: dsmr + identification: + name: "DSMR Identification" + p1_version: + name: "DSMR Version. OBIS: 1-3:0.2.8" From 4e84611ae7b1fd58b46d566f9caa14e7936b9668 Mon Sep 17 00:00:00 2001 From: Rishab Mehta <45841886+rishabmehta7@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:20:59 +0530 Subject: [PATCH 0447/2214] [internal_temperature] Fix internal Temperature discrepancy on BK7231T (#15771) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../internal_temperature/internal_temperature_bk72xx.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp index 31a92f90a5..b7332ee81f 100644 --- a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -20,8 +20,6 @@ void InternalTemperatureSensor::update() { success = (result == 0); #if defined(USE_LIBRETINY_VARIANT_BK7231N) temperature = raw * -0.38f + 156.0f; -#elif defined(USE_LIBRETINY_VARIANT_BK7231T) - temperature = raw * 0.04f; #else // USE_LIBRETINY_VARIANT temperature = raw * 0.128f; #endif // USE_LIBRETINY_VARIANT From a73bac0b5f251d62f17f6f0d1a8c51595b4d6da2 Mon Sep 17 00:00:00 2001 From: Asela Fernando <25498128+aselafernando@users.noreply.github.com> Date: Thu, 23 Apr 2026 04:57:53 +1000 Subject: [PATCH 0448/2214] [ac_dimmer] Zero-crossing interrupt type (#15862) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ac_dimmer/ac_dimmer.cpp | 24 ++++++++++++++-------- esphome/components/ac_dimmer/ac_dimmer.h | 2 ++ esphome/components/ac_dimmer/output.py | 14 +++++++++++++ tests/components/ac_dimmer/common.yaml | 1 + 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp index f731a8c753..3e21d6981d 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.cpp +++ b/esphome/components/ac_dimmer/ac_dimmer.cpp @@ -190,7 +190,7 @@ void AcDimmer::setup() { this->zero_cross_pin_->setup(); this->store_.zero_cross_pin = this->zero_cross_pin_->to_isr(); this->zero_cross_pin_->attach_interrupt(&AcDimmerDataStore::s_gpio_intr, &this->store_, - gpio::INTERRUPT_FALLING_EDGE); + this->zero_cross_interrupt_type_); } #ifdef USE_ESP8266 @@ -226,19 +226,25 @@ void AcDimmer::write_state(float state) { void AcDimmer::dump_config() { ESP_LOGCONFIG(TAG, "AcDimmer:\n" - " Min Power: %.1f%%\n" - " Init with half cycle: %s", + " Min Power: %.1f%%\n" + " Init with half cycle: %s", this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_)); LOG_PIN(" Output Pin: ", this->gate_pin_); LOG_PIN(" Zero-Cross Pin: ", this->zero_cross_pin_); - if (method_ == DIM_METHOD_LEADING_PULSE) { - ESP_LOGCONFIG(TAG, " Method: leading pulse"); - } else if (method_ == DIM_METHOD_LEADING) { - ESP_LOGCONFIG(TAG, " Method: leading"); + if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_RISING_EDGE) { + ESP_LOGCONFIG(TAG, " Interrupt Type: rising"); + } else if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_FALLING_EDGE) { + ESP_LOGCONFIG(TAG, " Interrupt Type: falling"); } else { - ESP_LOGCONFIG(TAG, " Method: trailing"); + ESP_LOGCONFIG(TAG, " Interrupt Type: any"); + } + if (method_ == DIM_METHOD_LEADING_PULSE) { + ESP_LOGCONFIG(TAG, " Method: leading pulse"); + } else if (method_ == DIM_METHOD_LEADING) { + ESP_LOGCONFIG(TAG, " Method: leading"); + } else { + ESP_LOGCONFIG(TAG, " Method: trailing"); } - LOG_FLOAT_OUTPUT(this); ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2); } diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h index ca2a19210a..6bfcf0bdb5 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.h +++ b/esphome/components/ac_dimmer/ac_dimmer.h @@ -48,6 +48,7 @@ class AcDimmer : public output::FloatOutput, public Component { void dump_config() override; void set_gate_pin(InternalGPIOPin *gate_pin) { gate_pin_ = gate_pin; } void set_zero_cross_pin(InternalGPIOPin *zero_cross_pin) { zero_cross_pin_ = zero_cross_pin; } + void set_zero_cross_interrupt_type(gpio::InterruptType type) { zero_cross_interrupt_type_ = type; } void set_init_with_half_cycle(bool init_with_half_cycle) { init_with_half_cycle_ = init_with_half_cycle; } void set_method(DimMethod method) { method_ = method; } @@ -56,6 +57,7 @@ class AcDimmer : public output::FloatOutput, public Component { InternalGPIOPin *gate_pin_; InternalGPIOPin *zero_cross_pin_; + gpio::InterruptType zero_cross_interrupt_type_; AcDimmerDataStore store_; bool init_with_half_cycle_; DimMethod method_; diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index efc24b65e7..1f35095e0e 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -7,6 +7,8 @@ from esphome.core import CORE CODEOWNERS = ["@glmnet"] +gpio_ns = cg.esphome_ns.namespace("gpio") + ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer") AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component) @@ -17,15 +19,26 @@ DIM_METHODS = { "TRAILING": DimMethod.DIM_METHOD_TRAILING, } +ZC_INTERRUPT_TYPES = { + "RISING": gpio_ns.INTERRUPT_RISING_EDGE, + "FALLING": gpio_ns.INTERRUPT_FALLING_EDGE, + "ANY": gpio_ns.INTERRUPT_ANY_EDGE, +} + CONF_GATE_PIN = "gate_pin" CONF_ZERO_CROSS_PIN = "zero_cross_pin" CONF_INIT_WITH_HALF_CYCLE = "init_with_half_cycle" +CONF_ZERO_CROSS_INTERRUPT_TYPE = "zero_cross_interrupt_type" + CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( { cv.Required(CONF_ID): cv.declare_id(AcDimmer), cv.Required(CONF_GATE_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_ZERO_CROSS_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_ZERO_CROSS_INTERRUPT_TYPE, default="FALLING"): cv.enum( + ZC_INTERRUPT_TYPES, upper=True, space="_" + ), cv.Optional(CONF_INIT_WITH_HALF_CYCLE, default=True): cv.boolean, cv.Optional(CONF_METHOD, default="leading pulse"): cv.enum( DIM_METHODS, upper=True, space="_" @@ -54,5 +67,6 @@ async def to_code(config): cg.add(var.set_gate_pin(pin)) pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN]) cg.add(var.set_zero_cross_pin(pin)) + cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE])) cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE])) cg.add(var.set_method(config[CONF_METHOD])) diff --git a/tests/components/ac_dimmer/common.yaml b/tests/components/ac_dimmer/common.yaml index 8f93066838..c16e2e834a 100644 --- a/tests/components/ac_dimmer/common.yaml +++ b/tests/components/ac_dimmer/common.yaml @@ -3,3 +3,4 @@ output: id: ac_dimmer_1 gate_pin: ${gate_pin} zero_cross_pin: ${zero_cross_pin} + zero_cross_interrupt_type: ANY From 162ee2ecaf8a5b1e7cfe18937100f9b2933e2f7a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 22 Apr 2026 14:40:18 -0500 Subject: [PATCH 0449/2214] [i2s_audio] Split speaker into base class and standard subclass (#15404) --- .../components/i2s_audio/speaker/__init__.py | 13 +- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 456 ++++-------------- .../i2s_audio/speaker/i2s_audio_speaker.h | 89 +++- .../speaker/i2s_audio_speaker_standard.cpp | 307 ++++++++++++ .../speaker/i2s_audio_speaker_standard.h | 32 ++ 5 files changed, 515 insertions(+), 382 deletions(-) create mode 100644 esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp create mode 100644 esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index d1d1bc3ee3..99aa712c68 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -33,13 +33,16 @@ AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] DEPENDENCIES = ["i2s_audio"] -I2SAudioSpeaker = i2s_audio_ns.class_( - "I2SAudioSpeaker", cg.Component, speaker.Speaker, I2SAudioOut +I2SAudioSpeakerBase = i2s_audio_ns.class_( + "I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut ) +I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) CONF_DAC_TYPE = "dac_type" CONF_I2S_COMM_FMT = "i2s_comm_fmt" +I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True) + i2s_dac_mode_t = cg.global_ns.enum("i2s_dac_mode_t") INTERNAL_DAC_OPTIONS = { CONF_LEFT: i2s_dac_mode_t.I2S_DAC_CHANNEL_LEFT_EN, @@ -183,11 +186,11 @@ async def to_code(config): await speaker.register_speaker(var, config) cg.add(var.set_dout_pin(config[CONF_I2S_DOUT_PIN])) - fmt = "std" # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long + fmt = I2SCommFmt.STANDARD # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long if config[CONF_I2S_COMM_FMT] in ["stand_msb", "i2s_lsb"]: - fmt = "msb" + fmt = I2SCommFmt.MSB elif config[CONF_I2S_COMM_FMT] in ["stand_pcm_short", "pcm_short", "pcm"]: - fmt = "pcm" + fmt = I2SCommFmt.PCM cg.add(var.set_i2s_comm_fmt(fmt)) if config[CONF_TIMEOUT] != CONF_NEVER: cg.add(var.set_timeout(config[CONF_TIMEOUT])) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index dde1f70bc5..836221e38a 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -13,36 +13,10 @@ #include "esp_timer.h" -namespace esphome { -namespace i2s_audio { - -static const uint32_t DMA_BUFFER_DURATION_MS = 15; -static const size_t DMA_BUFFERS_COUNT = 4; - -static const size_t TASK_STACK_SIZE = 4096; -static const ssize_t TASK_PRIORITY = 19; - -static const size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1; +namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker"; -enum SpeakerEventGroupBits : uint32_t { - COMMAND_START = (1 << 0), // indicates loop should start speaker task - COMMAND_STOP = (1 << 1), // stops the speaker task - COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written - - TASK_STARTING = (1 << 10), - TASK_RUNNING = (1 << 11), - TASK_STOPPING = (1 << 12), - TASK_STOPPED = (1 << 13), - - ERR_ESP_NO_MEM = (1 << 19), - - WARN_DROPPED_EVENT = (1 << 20), - - ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits -}; - // Lists the Q15 fixed point scaling factor for volume reduction. // Has 100 values representing silence and a reduction [49, 48.5, ... 0.5, 0] dB. // dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014) @@ -56,17 +30,21 @@ static const std::vector Q15_VOLUME_SCALING_FACTORS = { 8218, 8706, 9222, 9770, 10349, 10963, 11613, 12302, 13032, 13805, 14624, 15491, 16410, 17384, 18415, 19508, 20665, 21891, 23189, 24565, 26022, 27566, 29201, 30933, 32767}; -void I2SAudioSpeaker::setup() { +void I2SAudioSpeakerBase::setup() { this->event_group_ = xEventGroupCreate(); if (this->event_group_ == nullptr) { - ESP_LOGE(TAG, "Failed to create event group"); + ESP_LOGE(TAG, "Event group creation failed"); this->mark_failed(); return; } + + // Initialize volume control. When audio_dac is configured, this sets the DAC volume. + // When no audio_dac is configured, this initializes software volume control. + this->set_volume(this->volume_); } -void I2SAudioSpeaker::dump_config() { +void I2SAudioSpeakerBase::dump_config() { ESP_LOGCONFIG(TAG, "Speaker:\n" " Pin: %d\n" @@ -75,10 +53,9 @@ void I2SAudioSpeaker::dump_config() { if (this->timeout_.has_value()) { ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value()); } - ESP_LOGCONFIG(TAG, " Communication format: %s", this->i2s_comm_fmt_.c_str()); } -void I2SAudioSpeaker::loop() { +void I2SAudioSpeakerBase::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { @@ -92,12 +69,12 @@ void I2SAudioSpeaker::loop() { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); } if (event_group_bits & SpeakerEventGroupBits::TASK_RUNNING) { - ESP_LOGD(TAG, "Started"); + ESP_LOGV(TAG, "Started"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); this->state_ = speaker::STATE_RUNNING; } if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { - ESP_LOGD(TAG, "Stopping"); + ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); this->state_ = speaker::STATE_STOPPING; } @@ -111,10 +88,12 @@ void I2SAudioSpeaker::loop() { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); this->status_clear_error(); + this->on_task_stopped(); + this->state_ = speaker::STATE_STOPPED; } - // Log any errors encounted by the task + // Log any errors encountered by the task if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) { ESP_LOGE(TAG, "Not enough memory"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); @@ -133,14 +112,14 @@ void I2SAudioSpeaker::loop() { break; } - if (this->start_i2s_driver_(this->audio_stream_info_) != ESP_OK) { + if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); - this->status_momentary_error("driver-faiure", 1000); + this->status_momentary_error("driver-failure", 1000); break; } if (this->speaker_task_handle_ == nullptr) { - xTaskCreate(I2SAudioSpeaker::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, &this->speaker_task_handle_); if (this->speaker_task_handle_ == nullptr) { @@ -157,7 +136,7 @@ void I2SAudioSpeaker::loop() { } } -void I2SAudioSpeaker::set_volume(float volume) { +void I2SAudioSpeakerBase::set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { @@ -166,15 +145,21 @@ void I2SAudioSpeaker::set_volume(float volume) { } this->audio_dac_->set_volume(volume); } else -#endif +#endif // USE_AUDIO_DAC { - // Fallback to software volume control by using a Q15 fixed point scaling factor - ssize_t decibel_index = remap(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1); - this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index]; + // Fallback to software volume control by using a Q15 fixed point scaling factor. + // At maximum volume (1.0), set to INT16_MAX to completely bypass volume processing + // and avoid any floating-point precision issues that could cause slight volume reduction. + if (volume >= 1.0f) { + this->q15_volume_factor_ = INT16_MAX; + } else { + ssize_t decibel_index = remap(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1); + this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index]; + } } } -void I2SAudioSpeaker::set_mute_state(bool mute_state) { +void I2SAudioSpeakerBase::set_mute_state(bool mute_state) { this->mute_state_ = mute_state; #ifdef USE_AUDIO_DAC if (this->audio_dac_) { @@ -184,7 +169,7 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) { this->audio_dac_->set_mute_off(); } } else -#endif +#endif // USE_AUDIO_DAC { if (mute_state) { // Fallback to software volume control and scale by 0 @@ -196,11 +181,12 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) { } } -size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { +size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { if (this->is_failed()) { ESP_LOGE(TAG, "Setup failed; cannot play audio"); return 0; } + if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) { this->start(); } @@ -214,8 +200,8 @@ size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t tick size_t bytes_written = 0; if (this->state_ == speaker::STATE_RUNNING) { std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); - if (temp_ring_buffer.use_count() == 2) { - // Only the speaker task and this temp_ring_buffer own the ring buffer, so its safe to write to + if (temp_ring_buffer != nullptr) { + // The weak_ptr locks successfully only while the speaker task owns the ring buffer, so it is safe to write bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, ticks_to_wait); } } @@ -223,7 +209,7 @@ size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t tick return bytes_written; } -bool I2SAudioSpeaker::has_buffered_data() const { +bool I2SAudioSpeakerBase::has_buffered_data() const { if (this->audio_ring_buffer_.use_count() > 0) { std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); return temp_ring_buffer->available() > 0; @@ -231,216 +217,27 @@ bool I2SAudioSpeaker::has_buffered_data() const { return false; } -void I2SAudioSpeaker::speaker_task(void *params) { - I2SAudioSpeaker *this_speaker = (I2SAudioSpeaker *) params; - - xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STARTING); - - const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT; - // Ensure ring buffer duration is at least the duration of all DMA buffers - const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this_speaker->buffer_duration_ms_); - - // The DMA buffers may have more bits per sample, so calculate buffer sizes based in the input audio stream info - const size_t ring_buffer_size = this_speaker->current_stream_info_.ms_to_bytes(ring_buffer_duration); - - const uint32_t frames_to_fill_single_dma_buffer = - this_speaker->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); - const size_t bytes_to_fill_single_dma_buffer = - this_speaker->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); - - bool successful_setup = false; - std::unique_ptr transfer_buffer = - audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer); - - if (transfer_buffer != nullptr) { - std::shared_ptr temp_ring_buffer = RingBuffer::create(ring_buffer_size); - if (temp_ring_buffer.use_count() == 1) { - transfer_buffer->set_source(temp_ring_buffer); - this_speaker->audio_ring_buffer_ = temp_ring_buffer; - successful_setup = true; - } - } - - if (!successful_setup) { - xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } else { - bool stop_gracefully = false; - bool tx_dma_underflow = true; - - uint32_t frames_written = 0; - uint32_t last_data_received_time = millis(); - - xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_RUNNING); - - while (this_speaker->pause_state_ || !this_speaker->timeout_.has_value() || - (millis() - last_data_received_time) <= this_speaker->timeout_.value()) { - uint32_t event_group_bits = xEventGroupGetBits(this_speaker->event_group_); - - if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { - xEventGroupClearBits(this_speaker->event_group_, SpeakerEventGroupBits::COMMAND_STOP); - break; - } - if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { - xEventGroupClearBits(this_speaker->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); - stop_gracefully = true; - } - - if (this_speaker->audio_stream_info_ != this_speaker->current_stream_info_) { - // Audio stream info changed, stop the speaker task so it will restart with the proper settings. - break; - } - int64_t write_timestamp; - while (xQueueReceive(this_speaker->i2s_event_queue_, &write_timestamp, 0)) { - // Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes - // on the timing info via the audio_output_callback. - uint32_t frames_sent = frames_to_fill_single_dma_buffer; - if (frames_to_fill_single_dma_buffer > frames_written) { - tx_dma_underflow = true; - frames_sent = frames_written; - const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written; - write_timestamp -= this_speaker->current_stream_info_.frames_to_microseconds(frames_zeroed); - } else { - tx_dma_underflow = false; - } - frames_written -= frames_sent; - if (frames_sent > 0) { - this_speaker->audio_output_callback_(frames_sent, write_timestamp); - } - } - - if (this_speaker->pause_state_) { - // Pause state is accessed atomically, so thread safe - // Delay so the task yields, then skip transferring audio data - vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); - continue; - } - - // Wait half the duration of the data already written to the DMA buffers for new audio data - // The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000 - const uint32_t read_delay = - (this_speaker->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2; - - size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay)); - uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read; - - if (bytes_read > 0) { - if (this_speaker->q15_volume_factor_ < INT16_MAX) { - // Apply the software volume adjustment by unpacking the sample into a Q31 fixed-point number, shifting it, - // multiplying by the volume factor, and packing the sample back into the original bytes per sample. - - const size_t bytes_per_sample = this_speaker->current_stream_info_.samples_to_bytes(1); - const uint32_t len = bytes_read / bytes_per_sample; - - // Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31 - int32_t shift = 15; // Q31 -> Q16 - int32_t gain_factor = this_speaker->q15_volume_factor_; // Q15 - - if (bytes_per_sample >= 3) { - // Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31 - - shift = 8; // Q31 -> Q23 - gain_factor >>= 7; // Q15 -> Q8 - } - - for (uint32_t i = 0; i < len; ++i) { - int32_t sample = - audio::unpack_audio_sample_to_q31(&new_data[i * bytes_per_sample], bytes_per_sample); // Q31 - sample >>= shift; - sample *= gain_factor; // Q31 - audio::pack_q31_as_audio_sample(sample, &new_data[i * bytes_per_sample], bytes_per_sample); - } - } - -#ifdef USE_ESP32_VARIANT_ESP32 - // For ESP32 16-bit mono mode, adjacent samples need to be swapped. - if (this_speaker->current_stream_info_.get_channels() == 1 && - this_speaker->current_stream_info_.get_bits_per_sample() == 16) { - int16_t *samples = reinterpret_cast(new_data); - size_t sample_count = bytes_read / sizeof(int16_t); - for (size_t i = 0; i + 1 < sample_count; i += 2) { - int16_t tmp = samples[i]; - samples[i] = samples[i + 1]; - samples[i + 1] = tmp; - } - } -#endif - } - - if (transfer_buffer->available() == 0) { - if (stop_gracefully && tx_dma_underflow) { - break; - } - vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2)); - } else { - size_t bytes_written = 0; - if (tx_dma_underflow) { - // Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue so timing - // callbacks are accurate. Preload the data. - i2s_channel_disable(this_speaker->tx_handle_); - const i2s_event_callbacks_t callbacks = { - .on_sent = nullptr, - }; - - i2s_channel_register_event_callback(this_speaker->tx_handle_, &callbacks, this_speaker); - i2s_channel_preload_data(this_speaker->tx_handle_, transfer_buffer->get_buffer_start(), - transfer_buffer->available(), &bytes_written); - } else { - // Audio is already playing, use regular I2S write to add to the DMA buffers - i2s_channel_write(this_speaker->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(), - &bytes_written, DMA_BUFFER_DURATION_MS); - } - if (bytes_written > 0) { - last_data_received_time = millis(); - frames_written += this_speaker->current_stream_info_.bytes_to_frames(bytes_written); - transfer_buffer->decrease_buffer_length(bytes_written); - if (tx_dma_underflow) { - tx_dma_underflow = false; - // Reset the event queue timestamps - // Enable the on_sent callback to accurately track the timestamps of played audio - // Enable the I2S channel to start sending the preloaded audio - - xQueueReset(this_speaker->i2s_event_queue_); - - const i2s_event_callbacks_t callbacks = { - .on_sent = i2s_on_sent_cb, - }; - i2s_channel_register_event_callback(this_speaker->tx_handle_, &callbacks, this_speaker); - - i2s_channel_enable(this_speaker->tx_handle_); - } - } - } - } - } - - xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STOPPING); - - if (transfer_buffer != nullptr) { - transfer_buffer.reset(); - } - - xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STOPPED); - - while (true) { - // Continuously delay until the loop method deletes the task - vTaskDelay(pdMS_TO_TICKS(10)); - } +void I2SAudioSpeakerBase::speaker_task(void *params) { + I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) params; + this_speaker->run_speaker_task(); } -void I2SAudioSpeaker::start() { +void I2SAudioSpeakerBase::start() { if (!this->is_ready() || this->is_failed() || this->status_has_error()) return; if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; + // Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START. + this->state_ = speaker::STATE_STARTING; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } -void I2SAudioSpeaker::stop() { this->stop_(false); } +void I2SAudioSpeakerBase::stop() { this->stop_(false); } -void I2SAudioSpeaker::finish() { this->stop_(true); } +void I2SAudioSpeakerBase::finish() { this->stop_(true); } -void I2SAudioSpeaker::stop_(bool wait_on_empty) { +void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { if (this->is_failed()) return; if (this->state_ == speaker::STATE_STOPPED) @@ -453,105 +250,16 @@ void I2SAudioSpeaker::stop_(bool wait_on_empty) { } } -esp_err_t I2SAudioSpeaker::start_i2s_driver_(audio::AudioStreamInfo &audio_stream_info) { - this->current_stream_info_ = audio_stream_info; // store the stream info settings the driver will use - - if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT - // Can't reconfigure I2S bus, so the sample rate must match the configured value - ESP_LOGE(TAG, "Audio stream settings are not compatible with this I2S configuration"); - return ESP_ERR_NOT_SUPPORTED; - } - - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - // Currently can't handle the case when the incoming audio has more bits per sample than the configured value - ESP_LOGE(TAG, "Audio streams with more bits per sample than the I2S speaker's configuration is not supported"); - return ESP_ERR_NOT_SUPPORTED; - } - - if (!this->parent_->try_lock()) { - ESP_LOGE(TAG, "Parent I2S bus not free"); - return ESP_ERR_INVALID_STATE; - } - - uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); - - i2s_chan_config_t chan_cfg = { - .id = this->parent_->get_port(), - .role = this->i2s_role_, - .dma_desc_num = DMA_BUFFERS_COUNT, - .dma_frame_num = dma_buffer_length, - .auto_clear = true, - .intr_priority = 3, - }; - /* Allocate a new TX channel and get the handle of this channel */ +esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + size_t event_queue_size) { esp_err_t err = i2s_new_channel(&chan_cfg, &this->tx_handle_, NULL); if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to allocate new I2S channel"); + ESP_LOGE(TAG, "I2S channel allocation failed: %s", esp_err_to_name(err)); this->parent_->unlock(); return err; } - i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; -#ifdef I2S_CLK_SRC_APLL - if (this->use_apll_) { - clk_src = I2S_CLK_SRC_APLL; - } -#endif - i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config(); - - i2s_std_clk_config_t clk_cfg = { - .sample_rate_hz = audio_stream_info.get_sample_rate(), - .clk_src = clk_src, - .mclk_multiple = this->mclk_multiple_, - }; - - i2s_slot_mode_t slot_mode = this->slot_mode_; - i2s_std_slot_mask_t slot_mask = this->std_slot_mask_; - if (audio_stream_info.get_channels() == 1) { - slot_mode = I2S_SLOT_MODE_MONO; - } else if (audio_stream_info.get_channels() == 2) { - slot_mode = I2S_SLOT_MODE_STEREO; - slot_mask = I2S_STD_SLOT_BOTH; - } - - i2s_std_slot_config_t std_slot_cfg; - if (this->i2s_comm_fmt_ == "std") { - std_slot_cfg = - I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); - } else if (this->i2s_comm_fmt_ == "pcm") { - std_slot_cfg = - I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); - } else { - std_slot_cfg = - I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); - } -#ifdef USE_ESP32_VARIANT_ESP32 - // There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher then the bits - // per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems to - // make it play at the correct speed while sending more bits per slot. - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { - uint32_t configured_bit_width = static_cast(this->slot_bit_width_); - std_slot_cfg.ws_width = configured_bit_width; - if (configured_bit_width > 16) { - std_slot_cfg.msb_right = false; - } - } -#else - std_slot_cfg.slot_bit_width = this->slot_bit_width_; -#endif - std_slot_cfg.slot_mask = slot_mask; - - pin_config.dout = this->dout_pin_; - - i2s_std_config_t std_cfg = { - .clk_cfg = clk_cfg, - .slot_cfg = std_slot_cfg, - .gpio_cfg = pin_config, - }; - /* Initialize the channel */ err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg); - if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to initialize channel"); i2s_del_channel(this->tx_handle_); @@ -559,23 +267,34 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver_(audio::AudioStreamInfo &audio_strea this->parent_->unlock(); return err; } + if (this->i2s_event_queue_ == nullptr) { - this->i2s_event_queue_ = xQueueCreate(I2S_EVENT_QUEUE_COUNT, sizeof(int64_t)); + this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t)); + } else { + // Reset queue to clear any stale events from previous task + xQueueReset(this->i2s_event_queue_); } - i2s_channel_enable(this->tx_handle_); - - return err; + return ESP_OK; } -bool IRAM_ATTR I2SAudioSpeaker::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { +void I2SAudioSpeakerBase::stop_i2s_driver_() { + if (this->tx_handle_ != nullptr) { + i2s_channel_disable(this->tx_handle_); + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + } + this->parent_->unlock(); +} + +bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { int64_t now = esp_timer_get_time(); BaseType_t need_yield1 = pdFALSE; BaseType_t need_yield2 = pdFALSE; BaseType_t need_yield3 = pdFALSE; - I2SAudioSpeaker *this_speaker = (I2SAudioSpeaker *) user_ctx; + I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { // Queue is full, so discard the oldest event and set the warning flag to inform the user @@ -589,14 +308,47 @@ bool IRAM_ATTR I2SAudioSpeaker::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_eve return need_yield1 | need_yield2 | need_yield3; } -void I2SAudioSpeaker::stop_i2s_driver_() { - i2s_channel_disable(this->tx_handle_); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; - this->parent_->unlock(); +void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { + if (this->q15_volume_factor_ >= INT16_MAX) { + return; // Max volume, no processing needed + } + + const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1); + const uint32_t len = bytes_read / bytes_per_sample; + + // Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31 + int32_t shift = 15; // Q31 -> Q16 + int32_t gain_factor = this->q15_volume_factor_; // Q15 + + if (bytes_per_sample >= 3) { + // Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31 + shift = 8; // Q31 -> Q23 + gain_factor >>= 7; // Q15 -> Q8 + } + + for (uint32_t i = 0; i < len; ++i) { + int32_t sample = audio::unpack_audio_sample_to_q31(&data[i * bytes_per_sample], bytes_per_sample); // Q31 + sample >>= shift; + sample *= gain_factor; // Q31 + audio::pack_q31_as_audio_sample(sample, &data[i * bytes_per_sample], bytes_per_sample); + } } -} // namespace i2s_audio -} // namespace esphome +void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { +#ifdef USE_ESP32_VARIANT_ESP32 + // For ESP32 16-bit mono mode, adjacent samples need to be swapped. + if (this->current_stream_info_.get_channels() == 1 && this->current_stream_info_.get_bits_per_sample() == 16) { + int16_t *samples = reinterpret_cast(data); + size_t sample_count = bytes_read / sizeof(int16_t); + for (size_t i = 0; i + 1 < sample_count; i += 2) { + int16_t tmp = samples[i]; + samples[i] = samples[i + 1]; + samples[i + 1] = tmp; + } + } +#endif // USE_ESP32_VARIANT_ESP32 +} + +} // namespace esphome::i2s_audio #endif // USE_ESP32 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 76b6692209..b2644efd05 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -16,10 +16,34 @@ #include "esphome/core/helpers.h" #include "esphome/core/ring_buffer.h" -namespace esphome { -namespace i2s_audio { +namespace esphome::i2s_audio { -class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Component { +// Shared constants for I2S audio speaker implementations +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; +static constexpr size_t TASK_STACK_SIZE = 4096; +static constexpr ssize_t TASK_PRIORITY = 19; + +enum SpeakerEventGroupBits : uint32_t { + COMMAND_START = (1 << 0), // indicates loop should start speaker task + COMMAND_STOP = (1 << 1), // stops the speaker task + COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written + + TASK_STARTING = (1 << 10), + TASK_RUNNING = (1 << 11), + TASK_STOPPING = (1 << 12), + TASK_STOPPED = (1 << 13), + + ERR_ESP_NO_MEM = (1 << 19), + + WARN_DROPPED_EVENT = (1 << 20), + + ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits +}; + +/// @brief Abstract base class for I2S audio speaker implementations. +/// Provides shared infrastructure (event groups, ring buffer, volume control, task lifecycle) +/// for derived I2S speaker classes. +class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public Component { public: float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; } @@ -30,7 +54,9 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; } void set_timeout(uint32_t ms) { this->timeout_ = ms; } void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; } - void set_i2s_comm_fmt(std::string mode) { this->i2s_comm_fmt_ = std::move(mode); } + + /// @brief Get the I2S TX channel handle + i2s_chan_handle_t get_tx_handle() const { return this->tx_handle_; } void start() override; void stop() override; @@ -63,40 +89,55 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp void set_mute_state(bool mute_state) override; protected: - /// @brief Function for the FreeRTOS task handling audio output. - /// Allocates space for the buffers, reads audio from the ring buffer and writes audio to the I2S port. Stops - /// immmiately after receiving the COMMAND_STOP signal and stops only after the ring buffer is empty after receiving - /// the COMMAND_STOP_GRACEFULLY signal. Stops if the ring buffer hasn't read data for more than timeout_ milliseconds. - /// When stopping, it deallocates the buffers. It communicates its state and any errors via ``event_group_``. - /// @param params I2SAudioSpeaker component + /// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_(). + /// @param params I2SAudioSpeakerBase component pointer static void speaker_task(void *params); + /// @brief The main speaker task loop. Implemented by derived classes for mode-specific behavior. + virtual void run_speaker_task() = 0; + /// @brief Sends a stop command to the speaker task via ``event_group_``. /// @param wait_on_empty If false, sends the COMMAND_STOP signal. If true, sends the COMMAND_STOP_GRACEFULLY signal. void stop_(bool wait_on_empty); - /// @brief Callback function used to send playback timestamps the to the speaker task. + /// @brief Callback function used to send playback timestamps to the speaker task. /// @param handle (i2s_chan_handle_t) /// @param event (i2s_event_data_t) /// @param user_ctx (void*) User context pointer that the callback accesses /// @return True if a higher priority task was interrupted static bool i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); - /// @brief Starts the ESP32 I2S driver. - /// Attempts to lock the I2S port, starts the I2S driver using the passed in stream information, and sets the data out - /// pin. If it fails, it will unlock the I2S port and uninstalls the driver, if necessary. + /// @brief Starts the ESP32 I2S driver. Implemented by derived classes for mode-specific configuration. /// @param audio_stream_info Stream information for the I2S driver. - /// @return ESP_ERR_NOT_ALLOWED if the I2S port can't play the incoming audio stream. - /// ESP_ERR_INVALID_STATE if the I2S port is already locked. - /// ESP_ERR_INVALID_ARG if installing the driver or setting the data outpin fails due to a parameter error. - /// ESP_ERR_NO_MEM if the driver fails to install due to a memory allocation error. - /// ESP_FAIL if setting the data out pin fails due to an IO error - /// ESP_OK if successful - esp_err_t start_i2s_driver_(audio::AudioStreamInfo &audio_stream_info); + /// @return ESP_OK if successful, or an error code + virtual esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) = 0; + + /// @brief Shared I2S channel allocation, initialization, and event queue setup. + /// Called by derived start_i2s_driver_() implementations after building mode-specific configs. + /// @param chan_cfg I2S channel configuration + /// @param std_cfg I2S standard mode configuration (clock, slot, GPIO) + /// @param event_queue_size Size of the event queue + /// @return ESP_OK if successful, or an error code. On failure, cleans up channel and unlocks parent. + esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + size_t event_queue_size); /// @brief Stops the I2S driver and unlocks the I2S port void stop_i2s_driver_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. + virtual void on_task_stopped() {} + + /// @brief Apply software volume control using Q15 fixed-point scaling. + /// @param data Pointer to audio sample data (modified in place) + /// @param bytes_read Number of bytes of audio data + void apply_software_volume_(uint8_t *data, size_t bytes_read); + + /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. + /// Only applies when running on original ESP32 with 16-bit mono audio. + /// @param data Pointer to audio sample data (modified in place) + /// @param bytes_read Number of bytes of audio data + void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); + TaskHandle_t speaker_task_handle_{nullptr}; EventGroupHandle_t event_group_{nullptr}; @@ -115,11 +156,9 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info gpio_num_t dout_pin_; - std::string i2s_comm_fmt_; - i2s_chan_handle_t tx_handle_; + i2s_chan_handle_t tx_handle_{nullptr}; }; -} // namespace i2s_audio -} // namespace esphome +} // namespace esphome::i2s_audio #endif // USE_ESP32 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp new file mode 100644 index 0000000000..0203464034 --- /dev/null +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -0,0 +1,307 @@ +#include "i2s_audio_speaker_standard.h" + +#ifdef USE_ESP32 + +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/audio/audio_transfer_buffer.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esp_timer.h" + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.speaker.std"; + +static constexpr size_t DMA_BUFFERS_COUNT = 4; +static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1; + +void I2SAudioSpeaker::dump_config() { + I2SAudioSpeakerBase::dump_config(); + const char *fmt_str; + switch (this->i2s_comm_fmt_) { + case I2SCommFmt::PCM: + fmt_str = "pcm"; + break; + case I2SCommFmt::MSB: + fmt_str = "msb"; + break; + default: + fmt_str = "std"; + break; + } + ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str); +} + +void I2SAudioSpeaker::run_speaker_task() { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + + const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT; + // Ensure ring buffer duration is at least the duration of all DMA buffers + const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); + + // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info + const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration); + const uint32_t frames_to_fill_single_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); + const size_t bytes_to_fill_single_dma_buffer = + this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + + bool successful_setup = false; + std::unique_ptr transfer_buffer = + audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer); + + if (transfer_buffer != nullptr) { + std::shared_ptr temp_ring_buffer = RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer.use_count() == 1) { + transfer_buffer->set_source(temp_ring_buffer); + this->audio_ring_buffer_ = temp_ring_buffer; + successful_setup = true; + } + } + + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { + bool stop_gracefully = false; + bool tx_dma_underflow = true; + + uint32_t frames_written = 0; + uint32_t last_data_received_time = millis(); + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); + + // Main speaker task loop. Continues while: + // - Paused, OR + // - No timeout configured, OR + // - Timeout hasn't elapsed since last data + while (this->pause_state_ || !this->timeout_.has_value() || + (millis() - last_data_received_time) <= this->timeout_.value()) { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); + ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); + break; + } + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); + stop_gracefully = true; + } + + if (this->audio_stream_info_ != this->current_stream_info_) { + // Audio stream info changed, stop the speaker task so it will restart with the proper settings. + ESP_LOGV(TAG, "Exiting: stream info changed"); + break; + } + + int64_t write_timestamp; + while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) { + // Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes + // on the timing info via the audio_output_callback. + uint32_t frames_sent = frames_to_fill_single_dma_buffer; + if (frames_to_fill_single_dma_buffer > frames_written) { + tx_dma_underflow = true; + frames_sent = frames_written; + const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written; + write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed); + } else { + tx_dma_underflow = false; + } + frames_written -= frames_sent; + + // Standard I2S mode: fire callback immediately for each event + if (frames_sent > 0) { + this->audio_output_callback_(frames_sent, write_timestamp); + } + } + + if (this->pause_state_) { + // Pause state is accessed atomically, so thread safe + // Delay so the task yields, then skip transferring audio data + vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); + continue; + } + + // Wait half the duration of the data already written to the DMA buffers for new audio data + // The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000 + uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2; + + size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay)); + uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read; + + if (bytes_read > 0) { + this->apply_software_volume_(new_data, bytes_read); + this->swap_esp32_mono_samples_(new_data, bytes_read); + } + + if (transfer_buffer->available() == 0) { + if (stop_gracefully && tx_dma_underflow) { + break; + } + vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2)); + } else { + size_t bytes_written = 0; + + if (tx_dma_underflow) { + // Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue + i2s_channel_disable(this->tx_handle_); + const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr}; + i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this); + i2s_channel_preload_data(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(), + &bytes_written); + } else { + // Audio is already playing, use regular write to add to the DMA buffers + i2s_channel_write(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(), + &bytes_written, DMA_BUFFER_DURATION_MS); + } + + if (bytes_written > 0) { + last_data_received_time = millis(); + frames_written += this->current_stream_info_.bytes_to_frames(bytes_written); + transfer_buffer->decrease_buffer_length(bytes_written); + + if (tx_dma_underflow) { + tx_dma_underflow = false; + // Enable the on_sent callback and channel after preload + xQueueReset(this->i2s_event_queue_); + const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + i2s_channel_enable(this->tx_handle_); + } + } + } + } + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); + + if (transfer_buffer != nullptr) { + transfer_buffer.reset(); + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED); + + while (true) { + // Continuously delay until the loop method deletes the task + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { + this->current_stream_info_ = audio_stream_info; + + if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT + // Can't reconfigure I2S bus, so the sample rate must match the configured value + ESP_LOGE(TAG, "Incompatible stream settings"); + return ESP_ERR_NOT_SUPPORTED; + } + + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && + (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { + // Currently can't handle the case when the incoming audio has more bits per sample than the configured value + ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); + return ESP_ERR_NOT_SUPPORTED; + } + + if (!this->parent_->try_lock()) { + ESP_LOGE(TAG, "Parent bus is busy"); + return ESP_ERR_INVALID_STATE; + } + + uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + + i2s_role_t i2s_role = this->i2s_role_; + i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; + +#if SOC_CLK_APLL_SUPPORTED + if (this->use_apll_) { + clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; + } +#endif // SOC_CLK_APLL_SUPPORTED + + // Log DMA configuration for debugging + ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames", (size_t) DMA_BUFFERS_COUNT, + (unsigned long) dma_buffer_length); + + i2s_chan_config_t chan_cfg = { + .id = this->parent_->get_port(), + .role = i2s_role, + .dma_desc_num = DMA_BUFFERS_COUNT, + .dma_frame_num = dma_buffer_length, + .auto_clear = true, + .intr_priority = 3, + }; + + // Build standard I2S clock/slot/gpio configuration + i2s_std_clk_config_t clk_cfg = { + .sample_rate_hz = audio_stream_info.get_sample_rate(), + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + }; + + i2s_slot_mode_t slot_mode = this->slot_mode_; + i2s_std_slot_mask_t slot_mask = this->std_slot_mask_; + if (audio_stream_info.get_channels() == 1) { + slot_mode = I2S_SLOT_MODE_MONO; + } else if (audio_stream_info.get_channels() == 2) { + slot_mode = I2S_SLOT_MODE_STEREO; + slot_mask = I2S_STD_SLOT_BOTH; + } + + i2s_std_slot_config_t slot_cfg; + switch (this->i2s_comm_fmt_) { + case I2SCommFmt::PCM: + slot_cfg = + I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + break; + case I2SCommFmt::MSB: + slot_cfg = + I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + break; + default: + slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), + slot_mode); + break; + } + +#ifdef USE_ESP32_VARIANT_ESP32 + // There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher than the + // bits per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems + // to make it play at the correct speed while sending more bits per slot. + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + uint32_t configured_bit_width = static_cast(this->slot_bit_width_); + slot_cfg.ws_width = configured_bit_width; + if (configured_bit_width > 16) { + slot_cfg.msb_right = false; + } + } +#else + slot_cfg.slot_bit_width = this->slot_bit_width_; +#endif // USE_ESP32_VARIANT_ESP32 + slot_cfg.slot_mask = slot_mask; + + i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config(); + gpio_cfg.dout = this->dout_pin_; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = slot_cfg, + .gpio_cfg = gpio_cfg, + }; + + esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT); + if (err != ESP_OK) { + return err; + } + + i2s_channel_enable(this->tx_handle_); + + return ESP_OK; +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h new file mode 100644 index 0000000000..7b7f8b647d --- /dev/null +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -0,0 +1,32 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "i2s_audio_speaker.h" + +namespace esphome::i2s_audio { + +enum class I2SCommFmt : uint8_t { + STANDARD, // Philips / I2S standard + PCM, // PCM short + MSB, // MSB / left-justified +}; + +/// @brief Standard I2S speaker implementation. +/// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol. +class I2SAudioSpeaker : public I2SAudioSpeakerBase { + public: + void dump_config() override; + + void set_i2s_comm_fmt(I2SCommFmt fmt) { this->i2s_comm_fmt_ = fmt; } + + protected: + void run_speaker_task() override; + esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + + I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 From c48ab2ef923ce0e7679ee3a76621ce39670cf034 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:05:15 -0400 Subject: [PATCH 0450/2214] [io_expanders] Self-heal interrupt-driven expanders when INT stays asserted across the read (#15923) --- esphome/components/mcp23016/mcp23016.cpp | 5 ++++- esphome/components/mcp23xxx_base/mcp23xxx_base.h | 5 ++++- esphome/components/pca6416a/pca6416a.cpp | 5 ++++- esphome/components/pca9554/pca9554.cpp | 6 ++++-- esphome/components/pcf8574/pcf8574.cpp | 6 ++++-- esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp | 5 ++++- esphome/components/tca9555/tca9555.cpp | 5 ++++- 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 118a77ce37..b7a9cfd0ce 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -37,7 +37,10 @@ void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_co void MCP23016::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 6efd04e246..8a87dac143 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -21,7 +21,10 @@ template class MCP23XXXBase : public Component, public gpio_expander: void loop() override { this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index dc7463b01b..d617336e7e 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -62,7 +62,10 @@ void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enabl void PCA6416AComponent::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index ac4f119dfe..393bbfd61e 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -50,8 +50,10 @@ void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_ void PCA9554Component::loop() { // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { - // Interrupt-driven: disable loop until next interrupt fires + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index bf4a9442a2..8fe8526797 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -31,8 +31,10 @@ void IRAM_ATTR PCF8574Component::gpio_intr(PCF8574Component *arg) { arg->enable_ void PCF8574Component::loop() { // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { - // Interrupt-driven: disable loop until next interrupt fires + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 6e8631022a..00f29983be 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -82,7 +82,10 @@ void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) { void PI4IOE5V6408Component::loop() { this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 3eb794df44..2fefe08c0d 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -57,7 +57,10 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { } void TCA9555Component::loop() { this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } From 36720c8495e3428cce7caa922d2f94aad2a8c704 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 22 Apr 2026 16:16:14 -0500 Subject: [PATCH 0451/2214] [usb_uart] Derive TX output chunk count from `buffer_size` config (#15909) --- esphome/components/usb_uart/__init__.py | 13 ++++++++++++- esphome/components/usb_uart/usb_uart.h | 5 +++-- esphome/core/defines.h | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 0e8994a3ed..d542788fb9 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -116,12 +116,23 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config): + # 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. Each chunk is 64 bytes (USB FS MPS); add one extra slot + # because LockFreeQueue is a ring buffer that wastes one entry. + max_buffer_size = max( + channel[CONF_BUFFER_SIZE] + for device in config + for channel in device[CONF_CHANNELS] + ) + output_chunk_count = max_buffer_size // 64 + 1 + cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count) + for device in config: var = await register_usb_client(device) for index, channel in enumerate(device[CONF_CHANNELS]): chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE]) await cg.register_parented(chvar, var) - cg.add(chvar.set_rx_buffer_size(channel[CONF_BUFFER_SIZE])) cg.add(chvar.set_stop_bits(channel[CONF_STOP_BITS])) cg.add(chvar.set_data_bits(channel[CONF_DATA_BITS])) cg.add(chvar.set_parity(channel[CONF_PARITY])) diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 8e8e65032d..f9648b795b 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -132,8 +132,9 @@ class USBUartChannel : public uart::UARTComponent, public Parented Date: Thu, 23 Apr 2026 00:33:04 +0200 Subject: [PATCH 0452/2214] [core] Move host socket-select wake mechanism into wake.h/wake.cpp The host platform's wake plumbing was split between wake.cpp (which had wake_loop_threadsafe()) and application.{h,cpp} (which owned the fd registry, wake socket lifecycle, select() loop, and readiness query). The split required application.h to friend wake_loop_threadsafe() and expose the wake socket fd, and forced a host-only out-of-line override of yield_with_select_() that was equivalent to wakeable_delay() on every other platform. Consolidate the host wake mechanism in wake.h/wake.cpp: - New host-only API: wake_register_fd, wake_unregister_fd, wake_fd_ready, wake_setup, wake_drain_notifications. - Host implementation of internal::wakeable_delay now performs the select() over registered fds, mirroring the role of xTaskNotifyTake on ESP32 and esp_delay on ESP8266. - Application drops the host-specific socket_fds_, max_fd_, wake_socket_fd_, socket_fds_changed_, read_fds_, base_read_fds_ members, the host include block (sys/select.h, sys/socket.h, etc.), and the friend declarations for wake_loop_threadsafe() and socket::socket_ready_fd(). - yield_with_select_() collapses to a single inline definition for all platforms that delegates to wakeable_delay(). - Socket factories (bsd_sockets_impl.cpp, lwip_sockets_impl.cpp) and socket::socket_ready_fd() now call the wake.h API directly instead of going through App. --- .../components/socket/bsd_sockets_impl.cpp | 7 +- .../components/socket/lwip_sockets_impl.cpp | 7 +- esphome/components/socket/socket.cpp | 7 +- esphome/core/application.cpp | 167 +--------------- esphome/core/application.h | 100 +--------- esphome/core/wake.cpp | 188 +++++++++++++++++- esphome/core/wake.h | 56 ++++++ 7 files changed, 263 insertions(+), 269 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 92691b17ab..8e9968e05c 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -6,6 +6,9 @@ #include #include "esphome/core/application.h" +#ifdef USE_HOST +#include "esphome/core/wake.h" +#endif namespace esphome::socket { @@ -16,7 +19,7 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else - this->loop_monitored_ = App.register_socket_fd(this->fd_); + this->loop_monitored_ = wake_register_fd(this->fd_); #endif } @@ -36,7 +39,7 @@ int BSDSocketImpl::close() { this->cached_sock_ = nullptr; #else if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); + wake_unregister_fd(this->fd_); } #endif int ret = ::close(this->fd_); diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index b4eba3febf..a6bd639c10 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -6,6 +6,9 @@ #include #include "esphome/core/application.h" +#ifdef USE_HOST +#include "esphome/core/wake.h" +#endif namespace esphome::socket { @@ -16,7 +19,7 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else - this->loop_monitored_ = App.register_socket_fd(this->fd_); + this->loop_monitored_ = wake_register_fd(this->fd_); #endif } @@ -36,7 +39,7 @@ int LwIPSocketImpl::close() { this->cached_sock_ = nullptr; #else if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); + wake_unregister_fd(this->fd_); } #endif int ret = lwip_close(this->fd_); diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index bc43b2746e..f14ac1e2d5 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -5,13 +5,16 @@ #include #include "esphome/core/log.h" #include "esphome/core/application.h" +#ifdef USE_HOST +#include "esphome/core/wake.h" +#endif namespace esphome::socket { #ifdef USE_HOST // Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets). -// Checks if the Application's select() loop has marked this fd as ready. -bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || App.is_socket_ready_(fd); } +// Checks if the host wake select() loop has marked this fd as ready. +bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || wake_fd_ready(fd); } #endif // Platform-specific inet_ntop wrappers diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 8612782d95..3105ff2e8b 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -28,10 +28,6 @@ #include "esphome/components/socket/socket.h" #endif -#ifdef USE_HOST -#include -#endif - namespace esphome { static const char *const TAG = "app"; @@ -133,8 +129,8 @@ void Application::setup() { esphome_main_task_handle = xTaskGetCurrentTaskHandle(); #endif #ifdef USE_HOST - // Set up wake socket for waking main loop from tasks (platforms without fast select only) - this->setup_wake_loop_threadsafe_(); + // Set up wake socket for waking main loop from tasks (host platform select() loop). + wake_setup(); #endif // Ensure all active looping components are in LOOP state. @@ -510,105 +506,6 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_HOST -bool Application::register_socket_fd(int fd) { - // WARNING: This function is NOT thread-safe and must only be called from the main loop - // It modifies socket_fds_ and related variables without locking - if (fd < 0) - return false; - - if (fd >= FD_SETSIZE) { - ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE); - return false; - } - - this->socket_fds_.push_back(fd); - this->socket_fds_changed_ = true; - if (fd > this->max_fd_) { - this->max_fd_ = fd; - } - - return true; -} - -void Application::unregister_socket_fd(int fd) { - // WARNING: This function is NOT thread-safe and must only be called from the main loop - // It modifies socket_fds_ and related variables without locking - if (fd < 0) - return; - - for (size_t i = 0; i < this->socket_fds_.size(); i++) { - if (this->socket_fds_[i] != fd) - continue; - - // Swap with last element and pop - O(1) removal since order doesn't matter. - if (i < this->socket_fds_.size() - 1) - this->socket_fds_[i] = this->socket_fds_.back(); - this->socket_fds_.pop_back(); - this->socket_fds_changed_ = true; - // Only recalculate max_fd if we removed the current max - if (fd == this->max_fd_) { - this->max_fd_ = -1; - for (int sock_fd : this->socket_fds_) { - if (sock_fd > this->max_fd_) - this->max_fd_ = sock_fd; - } - } - return; - } -} - -#endif - -// Only the select() fallback path remains in the .cpp — all other paths are inlined in application.h -#ifdef USE_HOST -void Application::yield_with_select_(uint32_t delay_ms) { - // Fallback select() path (host platform and any future platforms without fast select). - if (!this->socket_fds_.empty()) [[likely]] { - // Update fd_set if socket list has changed - if (this->socket_fds_changed_) [[unlikely]] { - FD_ZERO(&this->base_read_fds_); - // fd bounds are validated in register_socket_fd() - for (int fd : this->socket_fds_) { - FD_SET(fd, &this->base_read_fds_); - } - this->socket_fds_changed_ = false; - } - - // Copy base fd_set before each select - this->read_fds_ = this->base_read_fds_; - - // Convert delay_ms to timeval - struct timeval tv; - tv.tv_sec = delay_ms / 1000; - tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000; - - // Call select with timeout - int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); - - // Process select() result: - // ret > 0: socket(s) have data ready - normal and expected - // ret == 0: timeout occurred - normal and expected - if (ret >= 0) [[likely]] { - // Yield if zero timeout since select(0) only polls without yielding - if (delay_ms == 0) [[unlikely]] { - yield(); - } - return; - } - // ret < 0: error (EINTR is normal, anything else is unexpected) - const int err = errno; - if (err == EINTR) { - return; - } - // select() error - log and fall through to delay() - ESP_LOGW(TAG, "select() failed with errno %d", err); - } - // No sockets registered or select() failed - use regular delay - delay(delay_ms); -} -#endif // USE_HOST - // App storage — asm label shares the linker symbol with "extern Application App". // char[] is trivially destructible, so no __cxa_atexit or destructor chain is emitted. // Constructed via placement new in the generated setup(). @@ -628,66 +525,6 @@ alignas(Application) char app_storage[sizeof(Application)] asm( #undef ESPHOME_STRINGIFY_ #undef ESPHOME_STRINGIFY_IMPL_ -// Host platform wake_loop_threadsafe() and setup — needs wake_socket_fd_ -// ESP32/LibreTiny/ESP8266/RP2040 implementations are in wake.cpp -#ifdef USE_HOST - -void Application::setup_wake_loop_threadsafe_() { - // Create UDP socket for wake notifications - this->wake_socket_fd_ = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - if (this->wake_socket_fd_ < 0) { - ESP_LOGW(TAG, "Wake socket create failed: %d", errno); - return; - } - - // Bind to loopback with auto-assigned port - struct sockaddr_in addr = {}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - addr.sin_port = 0; // Auto-assign port - - if (::bind(this->wake_socket_fd_, (struct sockaddr *) &addr, sizeof(addr)) < 0) { - ESP_LOGW(TAG, "Wake socket bind failed: %d", errno); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } - - // Get the assigned address and connect to it - // Connecting a UDP socket allows using send() instead of sendto() for better performance - struct sockaddr_in wake_addr; - socklen_t len = sizeof(wake_addr); - if (::getsockname(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, &len) < 0) { - ESP_LOGW(TAG, "Wake socket address failed: %d", errno); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } - - // Connect to self (loopback) - allows using send() instead of sendto() - // After connect(), no need to store wake_addr - the socket remembers it - if (::connect(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, sizeof(wake_addr)) < 0) { - ESP_LOGW(TAG, "Wake socket connect failed: %d", errno); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } - - // Set non-blocking mode - int flags = ::fcntl(this->wake_socket_fd_, F_GETFL, 0); - ::fcntl(this->wake_socket_fd_, F_SETFL, flags | O_NONBLOCK); - - // Register with application's select() loop - if (!this->register_socket_fd(this->wake_socket_fd_)) { - ESP_LOGW(TAG, "Wake socket register failed"); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } -} - -#endif // USE_HOST - void Application::get_build_time_string(std::span buffer) { ESPHOME_strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); buffer[buffer.size() - 1] = '\0'; diff --git a/esphome/core/application.h b/esphome/core/application.h index 3d8df88d2a..8280b3bd4b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -27,27 +27,12 @@ #ifdef USE_LWIP_FAST_SELECT #include "esphome/core/lwip_fast_select.h" #endif -#ifdef USE_HOST -#include -#include -#include -#include -#include -#include -#endif #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif #include "esphome/core/wake.h" #include "esphome/core/entity_includes.h" -namespace esphome::socket { -#ifdef USE_HOST -/// Shared ready() helper for fd-based socket implementations. -bool socket_ready_fd(int fd, bool loop_monitored); // NOLINT(readability-redundant-declaration) -#endif -} // namespace esphome::socket - #ifdef USE_RUNTIME_STATS namespace esphome::runtime_stats { class RuntimeStatsCollector; @@ -343,18 +328,6 @@ class Application { Scheduler scheduler; -#ifdef USE_HOST - /// Register/unregister a socket file descriptor with the host select() fallback loop. - /// USE_LWIP_FAST_SELECT builds do not use this API — sockets hook the lwIP netconn - /// event_callback directly (see socket.h hook_fd_for_fast_select) and rely on FreeRTOS - /// task notifications for wake-up. - /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. - /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. - /// @return true if registration was successful, false if fd exceeds limits - bool register_socket_fd(int fd); - void unregister_socket_fd(int fd); -#endif - /// Wake the main event loop from another thread or callback. /// @see esphome::wake_loop_threadsafe() in wake.h for platform details. void wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } @@ -372,21 +345,11 @@ class Application { protected: friend Component; -#ifdef USE_HOST - friend bool socket::socket_ready_fd(int fd, bool loop_monitored); -#endif #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif friend void ::setup(); friend void ::original_setup(); -#ifdef USE_HOST - friend void wake_loop_threadsafe(); // Host platform accesses wake_socket_fd_ -#endif - -#ifdef USE_HOST - bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } -#endif /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() @@ -460,18 +423,9 @@ class Application { void service_status_led_slow_(uint32_t time); #endif - /// Perform a delay while also monitoring socket file descriptors for readiness -#ifdef USE_HOST - // select() fallback path is too complex to inline (host platform) - void yield_with_select_(uint32_t delay_ms); -#else + /// Sleep for up to delay_ms, returning early if a wake event arrives. + /// Thin wrapper over the platform wake primitive in wake.h. inline void ESPHOME_ALWAYS_INLINE yield_with_select_(uint32_t delay_ms); -#endif - -#ifdef USE_HOST - void setup_wake_loop_threadsafe_(); // Create wake notification socket - inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined) -#endif // === Member variables ordered by size to minimize padding === @@ -496,9 +450,6 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop FixedVector looping_components_{}; -#ifdef USE_HOST - std::vector socket_fds_; // Vector of all monitored socket file descriptors -#endif // StringRef members (8 bytes each: pointer + size) StringRef name_; @@ -513,11 +464,6 @@ class Application { uint32_t last_status_led_service_{0}; #endif -#ifdef USE_HOST - int max_fd_{-1}; // Highest file descriptor number for select() - int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks -#endif - // 2-byte members (grouped together for alignment) uint16_t dump_config_at_{std::numeric_limits::max()}; // Index into components_ for dump_config progress uint16_t loop_interval_{16}; // Loop interval in ms (max 65535ms = 65.5 seconds) @@ -530,14 +476,6 @@ class Application { bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; -#ifdef USE_HOST - bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes - - // Variable-sized members (not needed with fast select — is_socket_ready_ reads rcvevent directly) - fd_set read_fds_{}; // Working fd_set: populated by select() - fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes -#endif - // StaticVectors (largest members - contain actual array data inline) StaticVector components_{}; @@ -565,30 +503,6 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#ifdef USE_HOST -// Inline implementations for hot-path functions -// drain_wake_notifications_() is called on every loop iteration - -// Small buffer for draining wake notification bytes (1 byte sent per wake) -// Size allows draining multiple notifications per recvfrom() without wasting stack -static constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; - -inline void Application::drain_wake_notifications_() { - // Called from main loop to drain any pending wake notifications - // Must check is_socket_ready_() to avoid blocking on empty socket - if (this->wake_socket_fd_ >= 0 && this->is_socket_ready_(this->wake_socket_fd_)) { - char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; - // Drain all pending notifications with non-blocking reads - // Multiple wake events may have triggered multiple writes, so drain until EWOULDBLOCK - // We control both ends of this loopback socket (always write 1 byte per wake), - // so no error checking needed - any errors indicate catastrophic system failure - while (::recvfrom(this->wake_socket_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - // Just draining, no action needed - wake has already occurred - } - } -} -#endif // USE_HOST - // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -598,8 +512,8 @@ inline void Application::drain_wake_notifications_() { // per-item feeds inside scheduler.call() without an extra millis(). inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST - // Drain wake notifications first to clear socket for next wake - this->drain_wake_notifications_(); + // Drain wake notifications first to clear socket for next wake. + wake_drain_notifications(); #endif return this->scheduler.call(now); } @@ -757,11 +671,11 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { } } -// Inline yield_with_select_ for all paths except the select() fallback -#ifndef USE_HOST +// All platforms route loop yields through the platform wake primitive. +// On host this drains the loopback wake socket via select(); on FreeRTOS +// targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { esphome::internal::wakeable_delay(delay_ms); } -#endif // !USE_HOST } // namespace esphome diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index 00b08b7b91..cac88ae91e 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -1,13 +1,20 @@ #include "esphome/core/wake.h" #include "esphome/core/hal.h" +#include "esphome/core/log.h" #ifdef USE_ESP8266 #include #endif #ifdef USE_HOST -#include "esphome/core/application.h" +#include +#include +#include +#include +#include #include +#include +#include #endif namespace esphome { @@ -82,17 +89,188 @@ void wakeable_delay(uint32_t ms) { } // namespace internal #endif // USE_RP2040 -// === Host (UDP loopback socket) === +// === Host (UDP loopback socket + select() based fd watcher) === #ifdef USE_HOST + +static const char *const TAG = "wake"; + +namespace internal { +// File-scope state — referenced inline by wake_drain_notifications() and +// wake_fd_ready() in wake.h, and by the bodies in this file. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +int g_wake_socket_fd = -1; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +fd_set g_read_fds{}; +} // namespace internal + +namespace { +// File-local state owned entirely by the select() loop. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +std::vector s_socket_fds; +int s_max_fd = -1; +bool s_socket_fds_changed = false; +fd_set s_base_read_fds{}; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) +} // namespace + +bool wake_register_fd(int fd) { + // WARNING: not thread-safe — must be called only from the main loop. + if (fd < 0) + return false; + + if (fd >= FD_SETSIZE) { + ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE); + return false; + } + + s_socket_fds.push_back(fd); + s_socket_fds_changed = true; + if (fd > s_max_fd) { + s_max_fd = fd; + } + + return true; +} + +void wake_unregister_fd(int fd) { + // WARNING: not thread-safe — must be called only from the main loop. + if (fd < 0) + return; + + for (size_t i = 0; i < s_socket_fds.size(); i++) { + if (s_socket_fds[i] != fd) + continue; + + // Swap with last element and pop — O(1) removal since order doesn't matter. + if (i < s_socket_fds.size() - 1) + s_socket_fds[i] = s_socket_fds.back(); + s_socket_fds.pop_back(); + s_socket_fds_changed = true; + // Only recalculate max_fd if we removed the current max. + if (fd == s_max_fd) { + s_max_fd = -1; + for (int sock_fd : s_socket_fds) { + if (sock_fd > s_max_fd) + s_max_fd = sock_fd; + } + } + return; + } +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + // Fallback select() path for the host platform (and any future platform + // without fast select). select() is the host equivalent of FreeRTOS task + // notify / esp_delay / WFE used on the embedded targets. + if (!s_socket_fds.empty()) [[likely]] { + // Update fd_set if socket list has changed. + if (s_socket_fds_changed) [[unlikely]] { + FD_ZERO(&s_base_read_fds); + // fd bounds are validated in wake_register_fd(). + for (int fd : s_socket_fds) { + FD_SET(fd, &s_base_read_fds); + } + s_socket_fds_changed = false; + } + + // Copy base fd_set before each select. + g_read_fds = s_base_read_fds; + + // Convert ms to timeval. + struct timeval tv; + tv.tv_sec = ms / 1000; + tv.tv_usec = (ms - tv.tv_sec * 1000) * 1000; + + // Call select with timeout. + int ret = ::select(s_max_fd + 1, &g_read_fds, nullptr, nullptr, &tv); + + // Process select() result: + // ret > 0: socket(s) have data ready - normal and expected + // ret == 0: timeout occurred - normal and expected + if (ret >= 0) [[likely]] { + // Yield if zero timeout since select(0) only polls without yielding. + if (ms == 0) [[unlikely]] { + yield(); + } + return; + } + // ret < 0: error (EINTR is normal, anything else is unexpected). + const int err = errno; + if (err == EINTR) { + return; + } + // select() error - log and fall through to delay(). + ESP_LOGW(TAG, "select() failed with errno %d", err); + } + // No sockets registered or select() failed - use regular delay. + delay(ms); +} +} // namespace internal + void wake_loop_threadsafe() { // Set flag before sending so the consumer's gate check on the next loop() // entry observes the wake regardless of select() scheduling. wake_request_set(); - if (App.wake_socket_fd_ >= 0) { + if (internal::g_wake_socket_fd >= 0) { const char dummy = 1; - ::send(App.wake_socket_fd_, &dummy, 1, 0); + ::send(internal::g_wake_socket_fd, &dummy, 1, 0); } } -#endif + +void wake_setup() { + // Create UDP socket for wake notifications. + internal::g_wake_socket_fd = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (internal::g_wake_socket_fd < 0) { + ESP_LOGW(TAG, "Wake socket create failed: %d", errno); + return; + } + + // Bind to loopback with auto-assigned port. + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // Auto-assign port + + if (::bind(internal::g_wake_socket_fd, (struct sockaddr *) &addr, sizeof(addr)) < 0) { + ESP_LOGW(TAG, "Wake socket bind failed: %d", errno); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } + + // Get the assigned address and connect to it. + // Connecting a UDP socket allows using send() instead of sendto() for better performance. + struct sockaddr_in wake_addr; + socklen_t len = sizeof(wake_addr); + if (::getsockname(internal::g_wake_socket_fd, (struct sockaddr *) &wake_addr, &len) < 0) { + ESP_LOGW(TAG, "Wake socket address failed: %d", errno); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } + + // Connect to self (loopback) — allows using send() instead of sendto(). + // After connect(), no need to store wake_addr — the socket remembers it. + if (::connect(internal::g_wake_socket_fd, (struct sockaddr *) &wake_addr, sizeof(wake_addr)) < 0) { + ESP_LOGW(TAG, "Wake socket connect failed: %d", errno); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } + + // Set non-blocking mode. + int flags = ::fcntl(internal::g_wake_socket_fd, F_GETFL, 0); + ::fcntl(internal::g_wake_socket_fd, F_SETFL, flags | O_NONBLOCK); + + // Register with the select() loop. + if (!wake_register_fd(internal::g_wake_socket_fd)) { + ESP_LOGW(TAG, "Wake socket register failed"); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } +} +#endif // USE_HOST } // namespace esphome diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 15b882b306..9aa5728023 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -21,6 +21,11 @@ #include #endif +#ifdef USE_HOST +#include +#include +#endif + namespace esphome { // === Wake flag for ESP8266/RP2040 === @@ -170,6 +175,21 @@ void wakeable_delay(uint32_t ms); #ifdef USE_HOST /// Host: wakes select() via UDP loopback socket. Defined in wake.cpp. void wake_loop_threadsafe(); + +/// Register a socket file descriptor with the host select() loop. Not +/// thread-safe — main loop only. Returns false if fd is invalid or +/// >= FD_SETSIZE. +bool wake_register_fd(int fd); + +/// Unregister a socket file descriptor. Not thread-safe — main loop only. +void wake_unregister_fd(int fd); + +/// One-time setup of the loopback wake socket. Called from Application::setup(). +void wake_setup(); + +// wake_fd_ready() and wake_drain_notifications() are defined inline at the +// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in +// scope, which depend on USE_HOST-only includes pulled in above. #else /// Zephyr is currently the only platform without a wake mechanism. /// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). @@ -180,6 +200,10 @@ inline void wake_loop_threadsafe() {} inline void wake_loop_any_context() { wake_loop_threadsafe(); } namespace internal { +#ifdef USE_HOST +/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp. +void wakeable_delay(uint32_t ms); +#else inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { if (ms == 0) [[unlikely]] { yield(); @@ -187,8 +211,40 @@ inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { } delay(ms); } +#endif } // namespace internal #endif +#ifdef USE_HOST +namespace internal { +// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications() +// and wake_fd_ready() so the hot path stays in the header. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern int g_wake_socket_fd; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern fd_set g_read_fds; +} // namespace internal + +inline bool wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } + +// Small buffer for draining wake notification bytes (1 byte sent per wake). +// Sized to drain multiple notifications per recvfrom() without wasting stack. +inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; + +inline void wake_drain_notifications() { + // Called from main loop to drain any pending wake notifications. + // Must check wake_fd_ready() to avoid blocking on empty socket. + if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { + char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads. Multiple wake events + // may have triggered multiple writes, so drain until EWOULDBLOCK. We control + // both ends of this loopback socket (always 1 byte per wake), so no error + // checking — any error indicates catastrophic system failure. + while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + } + } +} +#endif // USE_HOST + } // namespace esphome From ab8af7f736439da87e0ff393aa4d7c79f3b0b955 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 00:52:18 +0200 Subject: [PATCH 0453/2214] [core] Force inline host wake_fd_ready() and wake_drain_notifications() --- esphome/core/wake.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 9aa5728023..0cfca94a78 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -226,13 +226,13 @@ extern int g_wake_socket_fd; extern fd_set g_read_fds; } // namespace internal -inline bool wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } +inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } // Small buffer for draining wake notification bytes (1 byte sent per wake). // Sized to drain multiple notifications per recvfrom() without wasting stack. inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; -inline void wake_drain_notifications() { +inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { // Called from main loop to drain any pending wake notifications. // Must check wake_fd_ready() to avoid blocking on empty socket. if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { From 70b1d9a087055dc1f0fc256b8a951056640bd579 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 22 Apr 2026 17:57:15 -0500 Subject: [PATCH 0454/2214] [api_protobuf] Support compound `ifdef` conditions in proto generator (#15930) --- script/api_protobuf/api_protobuf.py | 42 +++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 73e0859d5e..c10479a726 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -65,11 +65,31 @@ _enum_max_values: dict[str, int] = {} _message_desc_map: dict[str, Any] = {} +def _make_ifdef_line(condition: str) -> str: + """Return the correct preprocessor open-guard line for a condition string. + + Simple identifiers use ``#ifdef IDENTIFIER``. + Compound expressions (containing ``||`` or ``&&``) use + ``#if defined(A) || defined(B)`` so that the preprocessor + evaluates them correctly. + """ + if any(op in condition for op in ("||", "&&", "!")): + # Replace each bare identifier token with defined(token) + expr = re.sub(r"\b([A-Za-z_]\w*)\b", r"defined(\1)", condition) + return f"#if {expr}" + return f"#ifdef {condition}" + + def indent_list(text: str, padding: str = " ") -> list[str]: """Indent each line of the given text with the specified padding.""" lines = [] for line in text.splitlines(): - if line == "" or line.startswith("#ifdef") or line.startswith("#endif"): + if ( + line == "" + or line.startswith("#ifdef") + or line.startswith("#if ") + or line.startswith("#endif") + ): p = "" else: p = padding @@ -82,7 +102,7 @@ def indent(text: str, padding: str = " ") -> str: def wrap_with_ifdef(content: str | list[str], ifdef: str | None) -> list[str]: - """Wrap content with #ifdef directives if ifdef is provided. + """Wrap content with #ifdef / #if directives if ifdef is provided. Args: content: Single string or list of strings to wrap @@ -96,7 +116,7 @@ def wrap_with_ifdef(content: str | list[str], ifdef: str | None) -> list[str]: return [content] return content - result = [f"#ifdef {ifdef}"] + result = [_make_ifdef_line(ifdef)] if isinstance(content, str): result.append(content) else: @@ -3021,7 +3041,7 @@ def build_service_message_type( if source in (SOURCE_BOTH, SOURCE_CLIENT): # Only add ifdef when we're actually generating content if ifdef is not None: - hout += f"#ifdef {ifdef}\n" + hout += _make_ifdef_line(ifdef) + "\n" # Generate receive handler and switch case func = f"on_{snake}" has_fields = any(not field.options.deprecated for field in mt.field) @@ -3302,8 +3322,8 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint content += "#endif\n" dump_cpp += "#endif\n" if enum_ifdef is not None: - content += f"#ifdef {enum_ifdef}\n" - dump_cpp += f"#ifdef {enum_ifdef}\n" + content += _make_ifdef_line(enum_ifdef) + "\n" + dump_cpp += _make_ifdef_line(enum_ifdef) + "\n" current_ifdef = enum_ifdef content += s @@ -3378,9 +3398,9 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint if dump_cpp: dump_cpp += "#endif\n" if msg_ifdef is not None: - content += f"#ifdef {msg_ifdef}\n" - cpp += f"#ifdef {msg_ifdef}\n" - dump_cpp += f"#ifdef {msg_ifdef}\n" + content += _make_ifdef_line(msg_ifdef) + "\n" + cpp += _make_ifdef_line(msg_ifdef) + "\n" + dump_cpp += _make_ifdef_line(msg_ifdef) + "\n" current_ifdef = msg_ifdef content += s @@ -3529,7 +3549,7 @@ static const char *const TAG = "api.service"; for id_ in sorted(ids): _, ifdef, case_label = RECEIVE_CASES[id_] if ifdef: - result += f"#ifdef {ifdef}\n" + result += _make_ifdef_line(ifdef) + "\n" result += f" case {case_label}: {comment}\n" if ifdef: result += "#endif\n" @@ -3572,7 +3592,7 @@ static const char *const TAG = "api.service"; out += " switch (msg_type) {\n" for i, (case, ifdef, case_label) in cases: if ifdef is not None: - out += f"#ifdef {ifdef}\n" + out += _make_ifdef_line(ifdef) + "\n" c = f" case {case_label}: {{\n" c += indent(case, " ") + "\n" From 22f6791dea1f0d8602ead29cabe1360d09fd3ba8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:13:42 +1000 Subject: [PATCH 0455/2214] [lvgl] Fix format of hello world page (#15868) --- esphome/components/lvgl/hello_world.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/hello_world.yaml b/esphome/components/lvgl/hello_world.yaml index bbbd34e30a..7bf068cc5d 100644 --- a/esphome/components/lvgl/hello_world.yaml +++ b/esphome/components/lvgl/hello_world.yaml @@ -89,10 +89,12 @@ id: hello_world_label_ text: "Hello World!" align: center - - obj: + - container: id: hello_world_qrcode_ outline_width: 0 border_width: 0 + height: 100 + width: 100 hidden: !lambda |- return lv_obj_get_width(lv_screen_active()) < 300 && lv_obj_get_height(lv_screen_active()) < 400; widgets: From 3d0a2421a65a55f71b565feacc0ff6d5147ebc2a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:14:15 +1000 Subject: [PATCH 0456/2214] [lvgl] Fix overloads for setting images on styles (#15864) --- esphome/components/lvgl/lvgl_esphome.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3ec1d247d8..146866f5bd 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -88,6 +88,12 @@ inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { ::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } +inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) { + ::lv_style_set_bg_image_src(style, image->get_lv_image_dsc()); +} +inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { + ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); +} #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ANIMIMG inline void lv_animimg_set_src(lv_obj_t *img, std::vector images) { From dc5b06285d84a311b1b103db97756ebe9f662912 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:15:51 +1000 Subject: [PATCH 0457/2214] [lvgl] Fix update of textarea attached to keyboard (#15866) --- esphome/components/lvgl/widgets/keyboard.py | 26 ++++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index 029ca5f684..c5628cee3c 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -52,19 +52,23 @@ class KeyboardType(WidgetType): if mode := config.get(CONF_MODE): await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode)) if textarea := config.get(CONF_TEXTAREA): - # If a textarea is configured, it must be generated before the keyboard can attach it. - # If not yet configured, defer the attachment code. + if not is_widget_completed(textarea): + # Can only happen for an initial config, where the keyboard is configured before the + # textarea, so it's ok to always emit into the global context + async def add_textarea(): + async with LvContext(): + await w.set_property( + CONF_TEXTAREA, + (await get_widgets(config, CONF_TEXTAREA))[0].obj, + ) - async def add_textarea(): - async with LvContext(): - await w.set_property( - CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj - ) - - if is_widget_completed(textarea): - await add_textarea() - else: CORE.add_job(add_textarea) + else: + # Handles updates in automations, and properly ordered initial config. Code is generated + # into the enclosing context (main or lambda) + await w.set_property( + CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj + ) keyboard_spec = KeyboardType() From 06e5931ad736abf32f71140f4943f7d90eff3f11 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:27:34 -0400 Subject: [PATCH 0458/2214] [image] Fix rodata bloat for multi-frame RGB565+alpha animations (#15873) --- esphome/components/image/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 7db50597e6..8375ab91d3 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -756,7 +756,7 @@ async def write_image(config, all_frames=False): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() rhs = [HexInt(x) for x in encoder.data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) From 92cb6dd7fd3c15452643ef5b58fd327ecba0dc77 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:33:48 -0400 Subject: [PATCH 0459/2214] [core] Fix Pvariable placement new losing subclass identity (#15881) --- esphome/cpp_generator.py | 50 ++++++++++++------- tests/component_tests/ili9xxx/__init__.py | 0 .../ili9xxx/config/ili9xxx_test.yaml | 20 ++++++++ tests/component_tests/ili9xxx/test_ili9xxx.py | 31 ++++++++++++ 4 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 tests/component_tests/ili9xxx/__init__.py create mode 100644 tests/component_tests/ili9xxx/config/ili9xxx_test.yaml create mode 100644 tests/component_tests/ili9xxx/test_ili9xxx.py diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index cf90b878e1..c622207dac 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -606,33 +606,43 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": if isinstance(rhs, MockObj) and rhs.is_new_expr: # For 'new' allocations, use placement new into static storage # to avoid heap fragmentation on embedded devices. - the_type = id_.type + # + # Storage must be sized and aligned for the actual instantiated class, + # which may be a subclass of id_.type (e.g. `cv.declare_id(BaseClass)` + # combined with `SubClass.new()` — used by ili9xxx, waveshare_epaper, + # etc. to select a model-specific constructor). Using id_.type would + # run the base-class default constructor instead, silently losing any + # subclass initialization. Template args live on the CallExpression + # and are re-emitted below. + call_expr = rhs.base + assert isinstance(call_expr, CallExpression), ( + f"Expected CallExpression for placement new, got {type(call_expr)}" + ) + actual_type = rhs.new_type if rhs.new_type is not None else id_.type + if call_expr.template_args is not None: + actual_type = f"{actual_type}{call_expr.template_args}" + pointer_type = id_.type # Extract component namespace from type for memory analysis attribution - component_ns = _extract_component_ns(str(the_type)) + component_ns = _extract_component_ns(str(actual_type)) storage_name = f"{component_ns}__{id_.id}__pstorage" # Declare aligned byte array for the object storage CORE.add_global( RawStatement( - f"alignas({the_type}) static unsigned char {storage_name}[sizeof({the_type})];" + f"alignas({actual_type}) static unsigned char {storage_name}[sizeof({actual_type})];" ) ) + # Pointer declaration uses id_.type to preserve the declared base-class + # pointer type for downstream callers (polymorphism through base ptr). CORE.add_global( AssignmentExpression( - f"static {the_type}", + f"static {pointer_type}", "*const ", id_, - MockObj(f"reinterpret_cast<{the_type} *>({storage_name})"), + MockObj(f"reinterpret_cast<{pointer_type} *>({storage_name})"), ) ) - # Extract args from the CallExpression and rebuild as placement new. - # Template args are already encoded in the_type (e.g. GlobalsComponent), - # so we only pass the constructor args, not template_args. - call_expr = rhs.base - assert isinstance(call_expr, CallExpression), ( - f"Expected CallExpression for placement new, got {type(call_expr)}" - ) - placement_new = CallExpression(f"new({id_.id}) {the_type}", *call_expr.args) + placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args) CORE.add(ExpressionStatement(placement_new)) else: decl = VariableDeclarationExpression(id_.type, "*", id_, static=True) @@ -869,12 +879,16 @@ class MockObj(Expression): Mostly consists of magic methods that allow ESPHome's codegen syntax. """ - __slots__ = ("base", "op", "is_new_expr") + __slots__ = ("base", "op", "is_new_expr", "new_type") - def __init__(self, base, op=".", is_new_expr=False) -> None: + def __init__(self, base, op=".", is_new_expr=False, new_type=None) -> None: self.base = base self.op = op self.is_new_expr = is_new_expr + # For `is_new_expr=True` objects, `new_type` holds the class name being + # constructed (e.g. "ili9xxx::ILI9XXXST7789V"). Needed by Pvariable so + # placement new uses the actual subclass rather than id_.type. + self.new_type = new_type def __getattr__(self, attr: str) -> "MockObj": # prevent python dunder methods being replaced by mock objects @@ -889,7 +903,9 @@ class MockObj(Expression): def __call__(self, *args: SafeExpType) -> "MockObj": call = CallExpression(self.base, *args) - return MockObj(call, self.op, is_new_expr=self.is_new_expr) + return MockObj( + call, self.op, is_new_expr=self.is_new_expr, new_type=self.new_type + ) def __str__(self): return str(self.base) @@ -903,7 +919,7 @@ class MockObj(Expression): @property def new(self) -> "MockObj": - return MockObj(f"new {self.base}", "->", is_new_expr=True) + return MockObj(f"new {self.base}", "->", is_new_expr=True, new_type=self.base) def template(self, *args: SafeExpType) -> "MockObj": """Apply template parameters to this object.""" diff --git a/tests/component_tests/ili9xxx/__init__.py b/tests/component_tests/ili9xxx/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml b/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml new file mode 100644 index 0000000000..bc6148b8d8 --- /dev/null +++ b/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: arduino + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: ili9xxx + id: tft_display + model: ST7789V + cs_pin: GPIO5 + dc_pin: GPIO17 + reset_pin: GPIO16 + invert_colors: false diff --git a/tests/component_tests/ili9xxx/test_ili9xxx.py b/tests/component_tests/ili9xxx/test_ili9xxx.py new file mode 100644 index 0000000000..3919eb3823 --- /dev/null +++ b/tests/component_tests/ili9xxx/test_ili9xxx.py @@ -0,0 +1,31 @@ +"""Tests for the ili9xxx component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + + +def test_ili9xxx_placement_new_uses_model_subclass( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Regression test for ili9xxx picking the right constructor under placement new. + + ili9xxx declares the ID as the base ``ILI9XXXDisplay`` but constructs a + model-specific subclass (e.g. ``ILI9XXXST7789V``) via ``MODELS[...].new()``. + Pvariable must emit placement new for the subclass — otherwise the base + default constructor runs and the panel is left with a null init sequence + and 0x0 dimensions, producing a silent blank screen. + """ + main_cpp = generate_main(component_config_path("ili9xxx_test.yaml")) + + # Storage is sized for the subclass so the full object fits. + assert "sizeof(ili9xxx::ILI9XXXST7789V)" in main_cpp + assert "alignas(ili9xxx::ILI9XXXST7789V)" in main_cpp + # Pointer is declared as the base type for polymorphism. + assert "static ili9xxx::ILI9XXXDisplay *const tft_display" in main_cpp + # Placement new runs the subclass constructor — this is the actual regression fix. + assert "new(tft_display) ili9xxx::ILI9XXXST7789V()" in main_cpp + # Base-class default constructor must NOT be used. + assert "new(tft_display) ili9xxx::ILI9XXXDisplay()" not in main_cpp From 5c2ceb63e030c567933a80cda8102da844355206 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:47:07 -0400 Subject: [PATCH 0460/2214] [ld2412] Fix null deref in set_basic_config when entities unconfigured (#15893) --- esphome/components/ld2412/ld2412.cpp | 42 ++++++++++++++++------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 38e1a59aba..2955852200 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -766,32 +766,38 @@ void LD2412Component::get_distance_resolution_() { this->send_command_(CMD_QUERY void LD2412Component::query_light_control_() { this->send_command_(CMD_QUERY_LIGHT_CONTROL, nullptr, 0); } void LD2412Component::set_basic_config() { + uint8_t min_gate = 1; + uint8_t max_gate = TOTAL_GATES; + uint16_t timeout = DEFAULT_PRESENCE_TIMEOUT; + uint8_t out_pin_level = 0x01; + #ifdef USE_NUMBER - if (!this->min_distance_gate_number_->has_state() || !this->max_distance_gate_number_->has_state() || - !this->timeout_number_->has_state()) { - return; + if (this->min_distance_gate_number_ != nullptr) { + if (!this->min_distance_gate_number_->has_state()) + return; + min_gate = static_cast(this->min_distance_gate_number_->state); + } + if (this->max_distance_gate_number_ != nullptr) { + if (!this->max_distance_gate_number_->has_state()) + return; + max_gate = static_cast(this->max_distance_gate_number_->state) + 1; + } + if (this->timeout_number_ != nullptr) { + if (!this->timeout_number_->has_state()) + return; + timeout = static_cast(this->timeout_number_->state); } #endif #ifdef USE_SELECT - if (!this->out_pin_level_select_->has_state()) { - return; + if (this->out_pin_level_select_ != nullptr) { + if (!this->out_pin_level_select_->has_state()) + return; + out_pin_level = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()); } #endif uint8_t value[5] = { -#ifdef USE_NUMBER - lowbyte(static_cast(this->min_distance_gate_number_->state)), - lowbyte(static_cast(this->max_distance_gate_number_->state) + 1), - lowbyte(static_cast(this->timeout_number_->state)), - highbyte(static_cast(this->timeout_number_->state)), -#else - 1, TOTAL_GATES, DEFAULT_PRESENCE_TIMEOUT, 0, -#endif -#ifdef USE_SELECT - find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()), -#else - 0x01, // Default value if not using select -#endif + lowbyte(min_gate), lowbyte(max_gate), lowbyte(timeout), highbyte(timeout), out_pin_level, }; this->set_config_mode_(true); this->send_command_(CMD_BASIC_CONF, value, sizeof(value)); From 629da4d878789f0f0da91ea5a633635738731c7d Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 21 Apr 2026 18:25:05 -0500 Subject: [PATCH 0461/2214] [esp32] Add Secure Boot V1 ECDSA signing scheme for pre-rev-3.0 ESP32 (#15882) --- esphome/components/esp32/__init__.py | 95 +++++++++++--- esphome/components/esp32/post_build.py.script | 123 +++++++++++++++++- .../esp32/dummy_signing_key_v1_ecdsa.pem | 7 + .../esp32/test-signed_ota_v1.esp32-idf.yaml | 10 ++ 4 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 tests/components/esp32/dummy_signing_key_v1_ecdsa.pem create mode 100644 tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a68614cb43..77b405a449 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -128,23 +128,30 @@ ASSERTION_LEVELS = { SIGNING_SCHEMES = { "rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME", "ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME", + "ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME", } -# Chip variants that only support one signing scheme for Secure Boot V2. +# Chip variants that only support one V2 signing scheme. # Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h. -# Variants not listed in either set support both RSA and ECDSA +# Variants not listed in either set support both RSA and ECDSA V2 # (e.g. C5, C6, H2, P4). New variants should be added to the # appropriate set if they only support one scheme. -SIGNED_OTA_RSA_ONLY_VARIANTS = { - VARIANT_ESP32, +# Note: VARIANT_ESP32 is not listed here because it supports V2 RSA only +# when minimum_chip_revision >= 3.0, which requires special handling. +SIGNED_OTA_V2_RSA_ONLY_VARIANTS = { VARIANT_ESP32S2, VARIANT_ESP32S3, VARIANT_ESP32C3, } -SIGNED_OTA_ECC_ONLY_VARIANTS = { +SIGNED_OTA_V2_ECC_ONLY_VARIANTS = { VARIANT_ESP32C2, VARIANT_ESP32C61, } +# V1 ECDSA (Secure Boot V1) is only supported on the original ESP32. +# Based on SOC_SECURE_BOOT_V1 in soc_caps.h. +SIGNED_OTA_V1_ECDSA_VARIANTS = { + VARIANT_ESP32, +} COMPILER_OPTIMIZATIONS = { "DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG", @@ -991,25 +998,73 @@ def final_validate(config): if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): scheme = signed_ota[CONF_SIGNING_SCHEME] variant = config[CONF_VARIANT] - scheme_variant_conflicts = { - "ecdsa256": (SIGNED_OTA_RSA_ONLY_VARIANTS, "rsa3072"), - "rsa3072": (SIGNED_OTA_ECC_ONLY_VARIANTS, "ecdsa256"), - } - if (conflict := scheme_variant_conflicts.get(scheme)) and variant in conflict[ - 0 - ]: + min_rev = advanced.get(CONF_MINIMUM_CHIP_REVISION) + scheme_path = [ + CONF_FRAMEWORK, + CONF_ADVANCED, + CONF_SIGNED_OTA_VERIFICATION, + CONF_SIGNING_SCHEME, + ] + + # V1 ECDSA is only available on the original ESP32 + if scheme == "ecdsa_v1" and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS: errs.append( cv.Invalid( - f"Signing scheme '{scheme}' is not supported on " - f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.", - path=[ - CONF_FRAMEWORK, - CONF_ADVANCED, - CONF_SIGNED_OTA_VERIFICATION, - CONF_SIGNING_SCHEME, - ], + f"Signing scheme 'ecdsa_v1' is only supported on " + f"{VARIANT_FRIENDLY[VARIANT_ESP32]}. " + f"Use 'rsa3072' or 'ecdsa256' instead.", + path=scheme_path, ) ) + elif variant == VARIANT_ESP32: + # On ESP32, V2 RSA requires minimum_chip_revision >= 3.0 + # Note: string comparison works here because cv.one_of constrains + # min_rev to known ESP32_CHIP_REVISIONS values ("0.0".."3.1"). + if scheme == "rsa3072" and (min_rev is None or min_rev < "3.0"): + errs.append( + cv.Invalid( + f"Signing scheme 'rsa3072' on {VARIANT_FRIENDLY[variant]} " + f"requires minimum_chip_revision: '3.0' or higher " + f"(Secure Boot V2 RSA needs chip revision 3.0+). " + f"For older chip revisions, use 'ecdsa_v1' instead.", + path=scheme_path, + ) + ) + # ESP32 does not support V2 ECDSA (no SOC_SECURE_BOOT_V2_ECC) + elif scheme == "ecdsa256": + errs.append( + cv.Invalid( + f"Signing scheme 'ecdsa256' is not supported on " + f"{VARIANT_FRIENDLY[variant]}. Use 'rsa3072' (with " + f"minimum_chip_revision: '3.0') or 'ecdsa_v1' instead.", + path=scheme_path, + ) + ) + # V1 on rev 3.0+ -- suggest V2 RSA for stronger security + elif scheme == "ecdsa_v1" and min_rev is not None and min_rev >= "3.0": + _LOGGER.info( + "Using Secure Boot V1 ECDSA on %s rev %s. " + "Consider using 'rsa3072' (Secure Boot V2 RSA) for " + "stronger security on chip revision 3.0+.", + VARIANT_FRIENDLY[variant], + min_rev, + ) + else: + # Non-ESP32 variants: check V2 scheme-variant compatibility + scheme_variant_conflicts = { + "ecdsa256": (SIGNED_OTA_V2_RSA_ONLY_VARIANTS, "rsa3072"), + "rsa3072": (SIGNED_OTA_V2_ECC_ONLY_VARIANTS, "ecdsa256"), + } + if ( + conflict := scheme_variant_conflicts.get(scheme) + ) and variant in conflict[0]: + errs.append( + cv.Invalid( + f"Signing scheme '{scheme}' is not supported on " + f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.", + path=scheme_path, + ) + ) if CONF_OTA not in full_config: _LOGGER.warning( "Signed OTA verification is enabled but no OTA component is configured. " diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index 8d13214259..b329f6b82b 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -5,6 +5,7 @@ import json # noqa: E402 import os # noqa: E402 import pathlib # noqa: E402 import shutil # noqa: E402 +import subprocess # noqa: E402 from glob import glob # noqa: E402 @@ -25,6 +26,114 @@ def _parse_sdkconfig(sdkconfig_path): return options +def _generate_v1_verification_key(env): + """Generate the V1 ECDSA verification key binary and assembly source file. + + Secure Boot V1 embeds the public verification key directly in the app binary + as a compiled object (via a .S assembly file). The ESP-IDF CMake build generates + these files via custom commands, but PlatformIO's SCons bridge does not execute + them. This function replicates that logic: + 1. Extracts the raw public key from the PEM signing key using espsecure. + 2. Generates the .S assembly source that embeds the key bytes. + """ + build_dir = pathlib.Path(env.subst("$BUILD_DIR")) + project_dir = pathlib.Path(env.subst("$PROJECT_DIR")) + pioenv = env.subst("$PIOENV") + sdkconfig = _parse_sdkconfig(project_dir / f"sdkconfig.{pioenv}") + + if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME") != "y": + return + + bin_path = build_dir / "signature_verification_key.bin" + asm_path = build_dir / "signature_verification_key.bin.S" + + # Determine the source of the verification key + if sdkconfig.get("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES") == "y": + # Extract public key from the signing key + signing_key = sdkconfig.get("CONFIG_SECURE_BOOT_SIGNING_KEY") + if not signing_key: + return + signing_key_path = pathlib.Path(signing_key) + if not signing_key_path.exists(): + print(f"Error: V1 ECDSA signing key not found: {signing_key_path}") + env.Exit(1) + return + + if not bin_path.exists() or bin_path.stat().st_mtime < signing_key_path.stat().st_mtime: + python_exe = env.subst("$PYTHONEXE") + result = subprocess.run( + [python_exe, "-m", "espsecure", "extract_public_key", + "--keyfile", str(signing_key_path), str(bin_path)], + capture_output=True, text=True, + ) + if result.returncode != 0: + print(f"Error extracting V1 verification key: {result.stderr}") + env.Exit(1) + return + print(f"Extracted V1 ECDSA verification key from {signing_key_path.name}") + else: + # User-provided verification key -- should already be a raw binary file + verification_key = sdkconfig.get("CONFIG_SECURE_BOOT_VERIFICATION_KEY") + if not verification_key: + return + verification_key_path = pathlib.Path(verification_key) + if not verification_key_path.exists(): + print(f"Error: Verification key not found: {verification_key_path}") + env.Exit(1) + return + shutil.copyfile(str(verification_key_path), str(bin_path)) + + if not bin_path.exists(): + return + + # Generate the .S assembly file from the binary key data. + # Replicates ESP-IDF's data_file_embed_asm.cmake with RENAME_TO=signature_verification_key_bin. + # The file is needed in both the app build dir and the bootloader build dir, since + # the bootloader also embeds the verification key when CONFIG_SECURE_SIGNED_ON_BOOT_NO_SECURE_BOOT + # is enabled. PlatformIO's SCons bridge does not execute the CMake custom commands that + # normally generate these files. + data = bin_path.read_bytes() + varname = "signature_verification_key_bin" + + lines = [] + lines.append(f"/* Data converted from {bin_path.name} */") + lines.append(".data") + lines.append("#if !defined (__APPLE__) && !defined (__linux__)") + lines.append(".section .rodata.embedded") + lines.append("#endif") + lines.append(f"\n.global {varname}") + lines.append(f"{varname}:") + lines.append(f"\n.global _binary_{varname}_start") + lines.append(f"_binary_{varname}_start: /* for objcopy compatibility */") + + # Format binary data as .byte lines (16 bytes per line) + for i in range(0, len(data), 16): + chunk = data[i:i + 16] + hex_bytes = ", ".join(f"0x{b:02x}" for b in chunk) + lines.append(f".byte {hex_bytes}") + + lines.append(f"\n.global _binary_{varname}_end") + lines.append(f"_binary_{varname}_end: /* for objcopy compatibility */") + lines.append(f"\n.global {varname}_length") + lines.append(f"{varname}_length:") + lines.append(f".long {len(data)}") + lines.append("") + lines.append('#if defined (__linux__)') + lines.append('.section .note.GNU-stack,"",@progbits') + lines.append("#endif") + + asm_content = "\n".join(lines) + "\n" + + # Write to app build dir and bootloader build dir + asm_path.write_text(asm_content) + bootloader_dir = build_dir / "bootloader" + if bootloader_dir.is_dir(): + bootloader_bin = bootloader_dir / "signature_verification_key.bin" + bootloader_asm = bootloader_dir / "signature_verification_key.bin.S" + shutil.copyfile(str(bin_path), str(bootloader_bin)) + bootloader_asm.write_text(asm_content) + + def sign_firmware(source, target, env): """ Sign the firmware binary using espsecure.py if signed OTA verification is enabled. @@ -55,9 +164,12 @@ def sign_firmware(source, target, env): env.Exit(1) return - # ESPHome only exposes RSA3072 and ECDSA256 (both Secure Boot V2 schemes), - # so the espsecure signature version is always 2. - sign_version = "2" + # Determine espsecure signature version from the signing scheme: + # V1 ECDSA (Secure Boot V1) uses --version 1, V2 RSA/ECDSA use --version 2. + if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME") == "y": + sign_version = "1" + else: + sign_version = "2" firmware_name = os.path.basename(env.subst("$PROGNAME")) + ".bin" firmware_path = build_dir / firmware_name @@ -217,6 +329,11 @@ def esp32_copy_ota_bin(source, target, env): print(f"Copied firmware to {new_file_name}") +# Generate V1 ECDSA verification key files before build starts. +# Workaround for PlatformIO not executing CMake custom commands that extract +# the public key and generate the .S assembly file for Secure Boot V1. +_generate_v1_verification_key(env) # noqa: F821 + # Run signing first, then merge, then ota copy env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", sign_firmware) # noqa: F821 env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) # noqa: F821 diff --git a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem new file mode 100644 index 0000000000..bd09205606 --- /dev/null +++ b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem @@ -0,0 +1,7 @@ +*** DO NOT USE THIS KEY...EVER *** +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIEZIp96p7Z7QN6vxOFE5FdRNm535vW81Ax07KnGxVjiMoAoGCCqGSM49 +AwEHoUQDQgAEK+fBQDn1Q+r5lGwcDoMUgeg2Aq16LLrLUz7xWI6mS0PUClzolDIo +eaV/Pfjl7zAvkbQQsZq3rTNnr1eGAk5P+A== +-----END EC PRIVATE KEY----- +*** DO NOT USE THIS KEY...EVER *** diff --git a/tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml b/tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml new file mode 100644 index 0000000000..b32e157daf --- /dev/null +++ b/tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml @@ -0,0 +1,10 @@ +esp32: + variant: esp32 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_key: ../../components/esp32/dummy_signing_key_v1_ecdsa.pem + signing_scheme: ecdsa_v1 + +<<: !include common.yaml From 2a3bd8bc85d8fc63ec4f6e7b52c07af93873b1d1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:05:15 -0400 Subject: [PATCH 0462/2214] [io_expanders] Self-heal interrupt-driven expanders when INT stays asserted across the read (#15923) --- esphome/components/mcp23016/mcp23016.cpp | 5 ++++- esphome/components/mcp23xxx_base/mcp23xxx_base.h | 5 ++++- esphome/components/pca6416a/pca6416a.cpp | 5 ++++- esphome/components/pca9554/pca9554.cpp | 6 ++++-- esphome/components/pcf8574/pcf8574.cpp | 6 ++++-- esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp | 5 ++++- esphome/components/tca9555/tca9555.cpp | 5 ++++- 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 118a77ce37..b7a9cfd0ce 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -37,7 +37,10 @@ void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_co void MCP23016::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 6efd04e246..8a87dac143 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -21,7 +21,10 @@ template class MCP23XXXBase : public Component, public gpio_expander: void loop() override { this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index dc7463b01b..d617336e7e 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -62,7 +62,10 @@ void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enabl void PCA6416AComponent::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index ac4f119dfe..393bbfd61e 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -50,8 +50,10 @@ void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_ void PCA9554Component::loop() { // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { - // Interrupt-driven: disable loop until next interrupt fires + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index bf4a9442a2..8fe8526797 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -31,8 +31,10 @@ void IRAM_ATTR PCF8574Component::gpio_intr(PCF8574Component *arg) { arg->enable_ void PCF8574Component::loop() { // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { - // Interrupt-driven: disable loop until next interrupt fires + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 6e8631022a..00f29983be 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -82,7 +82,10 @@ void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) { void PI4IOE5V6408Component::loop() { this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 3eb794df44..2fefe08c0d 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -57,7 +57,10 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { } void TCA9555Component::loop() { this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } From 76eb8f697f43b4777dffa66a327024b027a46d0e Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 22 Apr 2026 16:16:14 -0500 Subject: [PATCH 0463/2214] [usb_uart] Derive TX output chunk count from `buffer_size` config (#15909) --- esphome/components/usb_uart/__init__.py | 13 ++++++++++++- esphome/components/usb_uart/usb_uart.h | 5 +++-- esphome/core/defines.h | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 0e8994a3ed..d542788fb9 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -116,12 +116,23 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config): + # 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. Each chunk is 64 bytes (USB FS MPS); add one extra slot + # because LockFreeQueue is a ring buffer that wastes one entry. + max_buffer_size = max( + channel[CONF_BUFFER_SIZE] + for device in config + for channel in device[CONF_CHANNELS] + ) + output_chunk_count = max_buffer_size // 64 + 1 + cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count) + for device in config: var = await register_usb_client(device) for index, channel in enumerate(device[CONF_CHANNELS]): chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE]) await cg.register_parented(chvar, var) - cg.add(chvar.set_rx_buffer_size(channel[CONF_BUFFER_SIZE])) cg.add(chvar.set_stop_bits(channel[CONF_STOP_BITS])) cg.add(chvar.set_data_bits(channel[CONF_DATA_BITS])) cg.add(chvar.set_parity(channel[CONF_PARITY])) diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 8e8e65032d..f9648b795b 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -132,8 +132,9 @@ class USBUartChannel : public uart::UARTComponent, public Parented Date: Thu, 23 Apr 2026 11:18:39 +1200 Subject: [PATCH 0464/2214] Bump version to 2026.4.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index deb57df1d3..1cd12551dd 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.4.1 +PROJECT_NUMBER = 2026.4.2 # 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 1f5b3b6c57..ef37cb2df6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.1" +__version__ = "2026.4.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 74abd1cee533717b94318be9016e6b1205421e7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 18:37:00 -0500 Subject: [PATCH 0465/2214] [core] feed_wdt wraps feed_wdt_with_time --- esphome/core/application.cpp | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 8612782d95..412fd21c6c 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -212,16 +212,8 @@ void Application::process_dump_config_() { void Application::feed_wdt() { // Cold entry: callers without a millis() timestamp in hand. Fetches the - // time and takes the same rate-limit paths as feed_wdt_with_time(). - uint32_t now = MillisInternal::get(); - if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { - this->feed_wdt_slow_(now); - } -#ifdef USE_STATUS_LED - if (now - this->last_status_led_service_ > STATUS_LED_DISPATCH_INTERVAL_MS) { - this->service_status_led_slow_(now); - } -#endif + // time and defers to the hot path. + this->feed_wdt_with_time(MillisInternal::get()); } void HOT Application::feed_wdt_slow_(uint32_t time) { From 6253947311c112ef24de67ca785aab622b974139 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:12:02 -0500 Subject: [PATCH 0466/2214] Bump click from 8.3.2 to 8.3.3 (#15927) 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 9e59bb59d0..821ca1927a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ tzdata>=2026.1 # from time pyserial==3.5 platformio==6.1.19 esptool==5.2.0 -click==8.3.2 +click==8.3.3 esphome-dashboard==20260408.1 aioesphomeapi==44.19.0 zeroconf==0.148.0 From 17f92698410301ee21a7546b45f945f7270406dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:12:15 -0500 Subject: [PATCH 0467/2214] Update wheel requirement from <0.47,>=0.43 to >=0.43,<0.48 (#15926) 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 a744286e88..dc6785001d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==82.0.1", "wheel>=0.43,<0.47"] +requires = ["setuptools==82.0.1", "wheel>=0.43,<0.48"] build-backend = "setuptools.build_meta" [project] From 224cc7b4199a01d5c365996c602379b7fc078135 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:35:00 +1000 Subject: [PATCH 0468/2214] [lvgl] Triggers on tabview tabs fix (#15935) --- esphome/components/lvgl/widgets/tabview.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 108bb38df5..5e9e0494dd 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -22,7 +22,7 @@ from ..defines import ( literal, ) from ..lv_validation import animated, lv_int, size -from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj +from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj, lv_Pvariable from ..schemas import container_schema, part_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties @@ -83,8 +83,8 @@ class TabviewType(WidgetType): await w.set_property("tab_bar_size", await size.process(config[CONF_SIZE])) for tab_conf in config[CONF_TABS]: w_id = tab_conf[CONF_ID] - tab_obj = cg.Pvariable(w_id, cg.nullptr, type_=lv_tab_t) - tab_widget = Widget.create(w_id, tab_obj, obj_spec) + tab_obj = lv_Pvariable(lv_tab_t, w_id) + tab_widget = Widget.create(w_id, tab_obj, obj_spec, tab_conf) lv_assign(tab_obj, lv_expr.tabview_add_tab(w.obj, tab_conf[CONF_NAME])) await set_obj_properties(tab_widget, tab_conf) await add_widgets(tab_widget, tab_conf) From e1d629f0d2f739de4b04f4c7d1b10ef90c2aa513 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:35:13 +1200 Subject: [PATCH 0469/2214] [time] Handle Windows EINVAL when validating POSIX TZ strings (#15934) --- esphome/components/time/__init__.py | 7 +++ tests/unit_tests/components/test_time.py | 67 +++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 37c08b3a12..3295366fea 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,3 +1,4 @@ +import errno from importlib import resources import logging @@ -74,6 +75,12 @@ def _load_tzdata(iana_key: str) -> bytes | None: return (resources.files(package) / resource).read_bytes() except (FileNotFoundError, ModuleNotFoundError, IsADirectoryError): return None + except OSError as e: + # Windows raises EINVAL for paths with NTFS-illegal chars (e.g. '<'/'>' + # in POSIX TZ strings like "<+08>-8" that validate_tz feeds back here). + if e.errno == errno.EINVAL: + return None + raise def _extract_tz_string(tzfile: bytes) -> str: diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 48988fb03f..6325bfbe75 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -1,6 +1,11 @@ """Tests for time component cron expression parsing.""" -from esphome.components.time import _parse_cron_part +import errno +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.time import _load_tzdata, _parse_cron_part, validate_tz def test_star_slash_seconds() -> None: @@ -78,3 +83,63 @@ def test_range() -> None: def test_single_value() -> None: assert _parse_cron_part("30", 0, 59, {}) == {30} + + +def _mock_resources_with_error(error: Exception) -> MagicMock: + """Return a mock of importlib.resources.files where read_bytes raises error.""" + leaf = MagicMock() + leaf.read_bytes.side_effect = error + package = MagicMock() + package.__truediv__.return_value = leaf + return MagicMock(return_value=package) + + +def test_load_tzdata_returns_none_on_windows_einval() -> None: + """On Windows, opening a tzdata path with NTFS-illegal chars raises OSError(EINVAL). + + Regression test for crash when the system TZ resolves to a POSIX string like + "<+08>-8" (Asia/Shanghai, IST, etc.) and is fed back into _load_tzdata by + validate_tz to check whether it is also a valid IANA key. + """ + err = OSError(errno.EINVAL, "Invalid argument") + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(err), + ): + assert _load_tzdata("<+08>-8") is None + + +def test_load_tzdata_propagates_unexpected_oserror() -> None: + """Unrelated OSErrors (e.g. PermissionError) must not be swallowed.""" + with ( + patch( + "esphome.components.time.resources.files", + _mock_resources_with_error( + PermissionError(errno.EACCES, "Permission denied") + ), + ), + pytest.raises(PermissionError), + ): + _load_tzdata("Some/Zone") + + +def test_load_tzdata_returns_none_on_file_not_found() -> None: + """Existing behavior: missing tz file returns None rather than raising.""" + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(FileNotFoundError()), + ): + assert _load_tzdata("Not/A/Zone") is None + + +def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> None: + """validate_tz must not crash when _load_tzdata hits the Windows EINVAL path. + + Simulates the Windows case where the auto-detected POSIX TZ string is fed + back through _load_tzdata and the underlying read_bytes raises errno 22. + """ + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")), + ): + assert validate_tz("<+08>-8") == "<+08>-8" From f8167c9a70d129e2a1c1ca9cf9cea5878fe1ec30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:40:19 +0000 Subject: [PATCH 0470/2214] Bump aioesphomeapi from 44.19.0 to 44.20.0 (#15936) 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 821ca1927a..e7ab9bc2ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260408.1 -aioesphomeapi==44.19.0 +aioesphomeapi==44.20.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From a881121110111ba829ab830b338dfb7675b9a979 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:06:31 -0400 Subject: [PATCH 0471/2214] [ota] Make set_auth_password() lambda-callable via empty-password opt-in (#15928) --- esphome/components/esphome/ota/__init__.py | 10 +++++++--- esphome/components/esphome/ota/ota_esphome.h | 8 ++++++++ .../ota/test-empty_password.esp8266-ard.yaml | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 tests/components/ota/test-empty_password.esp8266-ard.yaml diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 5d35910fbd..bfa5ffb55c 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -150,10 +150,14 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_port(config[CONF_PORT])) - # Password could be set to an empty string and we can assume that means no password - if config.get(CONF_PASSWORD): - cg.add(var.set_auth_password(config[CONF_PASSWORD])) + # Compile the auth path whenever `password:` is present in YAML, even if empty. + # An empty password opts in to the auth code path so set_auth_password() can be + # called at runtime (e.g. to rotate the password from a lambda). When `password:` + # is omitted entirely, the auth path is excluded to save flash on small devices. + if CONF_PASSWORD in config: cg.add_define("USE_OTA_PASSWORD") + if config[CONF_PASSWORD]: + cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index f3a5952398..53288fc000 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -28,6 +28,14 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { }; #ifdef USE_OTA_PASSWORD void set_auth_password(const std::string &password) { password_ = password; } +#else + // Stub so lambdas referencing set_auth_password() produce a clear error instead of + // a cryptic "no member" diagnostic. Only fires if the stub is actually instantiated. + template void set_auth_password(const std::string &) { + static_assert(B, "set_auth_password() requires the OTA auth path to be compiled. " + "Add 'password: \"\"' (empty string) to your 'ota: - platform: esphome' " + "config to enable runtime password rotation."); + } #endif // USE_OTA_PASSWORD /// Manually set the port OTA should listen on diff --git a/tests/components/ota/test-empty_password.esp8266-ard.yaml b/tests/components/ota/test-empty_password.esp8266-ard.yaml new file mode 100644 index 0000000000..e48f67e47e --- /dev/null +++ b/tests/components/ota/test-empty_password.esp8266-ard.yaml @@ -0,0 +1,14 @@ +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome + id: my_ota + port: 3287 + password: "" + +esphome: + on_boot: + then: + - lambda: id(my_ota).set_auth_password("runtime_password"); From 6f00ea1457f5fc9216c2cd3f3b251073250486ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 05:53:10 +0200 Subject: [PATCH 0472/2214] [core] Move host socket-select wake mechanism into wake.h/wake.cpp (#15931) --- .../components/socket/bsd_sockets_impl.cpp | 7 +- .../components/socket/lwip_sockets_impl.cpp | 7 +- esphome/components/socket/socket.cpp | 7 +- esphome/core/application.cpp | 167 +--------------- esphome/core/application.h | 100 +--------- esphome/core/wake.cpp | 188 +++++++++++++++++- esphome/core/wake.h | 56 ++++++ 7 files changed, 263 insertions(+), 269 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 92691b17ab..8e9968e05c 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -6,6 +6,9 @@ #include #include "esphome/core/application.h" +#ifdef USE_HOST +#include "esphome/core/wake.h" +#endif namespace esphome::socket { @@ -16,7 +19,7 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else - this->loop_monitored_ = App.register_socket_fd(this->fd_); + this->loop_monitored_ = wake_register_fd(this->fd_); #endif } @@ -36,7 +39,7 @@ int BSDSocketImpl::close() { this->cached_sock_ = nullptr; #else if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); + wake_unregister_fd(this->fd_); } #endif int ret = ::close(this->fd_); diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index b4eba3febf..a6bd639c10 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -6,6 +6,9 @@ #include #include "esphome/core/application.h" +#ifdef USE_HOST +#include "esphome/core/wake.h" +#endif namespace esphome::socket { @@ -16,7 +19,7 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else - this->loop_monitored_ = App.register_socket_fd(this->fd_); + this->loop_monitored_ = wake_register_fd(this->fd_); #endif } @@ -36,7 +39,7 @@ int LwIPSocketImpl::close() { this->cached_sock_ = nullptr; #else if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); + wake_unregister_fd(this->fd_); } #endif int ret = lwip_close(this->fd_); diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index bc43b2746e..f14ac1e2d5 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -5,13 +5,16 @@ #include #include "esphome/core/log.h" #include "esphome/core/application.h" +#ifdef USE_HOST +#include "esphome/core/wake.h" +#endif namespace esphome::socket { #ifdef USE_HOST // Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets). -// Checks if the Application's select() loop has marked this fd as ready. -bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || App.is_socket_ready_(fd); } +// Checks if the host wake select() loop has marked this fd as ready. +bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || wake_fd_ready(fd); } #endif // Platform-specific inet_ntop wrappers diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 8612782d95..3105ff2e8b 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -28,10 +28,6 @@ #include "esphome/components/socket/socket.h" #endif -#ifdef USE_HOST -#include -#endif - namespace esphome { static const char *const TAG = "app"; @@ -133,8 +129,8 @@ void Application::setup() { esphome_main_task_handle = xTaskGetCurrentTaskHandle(); #endif #ifdef USE_HOST - // Set up wake socket for waking main loop from tasks (platforms without fast select only) - this->setup_wake_loop_threadsafe_(); + // Set up wake socket for waking main loop from tasks (host platform select() loop). + wake_setup(); #endif // Ensure all active looping components are in LOOP state. @@ -510,105 +506,6 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_HOST -bool Application::register_socket_fd(int fd) { - // WARNING: This function is NOT thread-safe and must only be called from the main loop - // It modifies socket_fds_ and related variables without locking - if (fd < 0) - return false; - - if (fd >= FD_SETSIZE) { - ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE); - return false; - } - - this->socket_fds_.push_back(fd); - this->socket_fds_changed_ = true; - if (fd > this->max_fd_) { - this->max_fd_ = fd; - } - - return true; -} - -void Application::unregister_socket_fd(int fd) { - // WARNING: This function is NOT thread-safe and must only be called from the main loop - // It modifies socket_fds_ and related variables without locking - if (fd < 0) - return; - - for (size_t i = 0; i < this->socket_fds_.size(); i++) { - if (this->socket_fds_[i] != fd) - continue; - - // Swap with last element and pop - O(1) removal since order doesn't matter. - if (i < this->socket_fds_.size() - 1) - this->socket_fds_[i] = this->socket_fds_.back(); - this->socket_fds_.pop_back(); - this->socket_fds_changed_ = true; - // Only recalculate max_fd if we removed the current max - if (fd == this->max_fd_) { - this->max_fd_ = -1; - for (int sock_fd : this->socket_fds_) { - if (sock_fd > this->max_fd_) - this->max_fd_ = sock_fd; - } - } - return; - } -} - -#endif - -// Only the select() fallback path remains in the .cpp — all other paths are inlined in application.h -#ifdef USE_HOST -void Application::yield_with_select_(uint32_t delay_ms) { - // Fallback select() path (host platform and any future platforms without fast select). - if (!this->socket_fds_.empty()) [[likely]] { - // Update fd_set if socket list has changed - if (this->socket_fds_changed_) [[unlikely]] { - FD_ZERO(&this->base_read_fds_); - // fd bounds are validated in register_socket_fd() - for (int fd : this->socket_fds_) { - FD_SET(fd, &this->base_read_fds_); - } - this->socket_fds_changed_ = false; - } - - // Copy base fd_set before each select - this->read_fds_ = this->base_read_fds_; - - // Convert delay_ms to timeval - struct timeval tv; - tv.tv_sec = delay_ms / 1000; - tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000; - - // Call select with timeout - int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); - - // Process select() result: - // ret > 0: socket(s) have data ready - normal and expected - // ret == 0: timeout occurred - normal and expected - if (ret >= 0) [[likely]] { - // Yield if zero timeout since select(0) only polls without yielding - if (delay_ms == 0) [[unlikely]] { - yield(); - } - return; - } - // ret < 0: error (EINTR is normal, anything else is unexpected) - const int err = errno; - if (err == EINTR) { - return; - } - // select() error - log and fall through to delay() - ESP_LOGW(TAG, "select() failed with errno %d", err); - } - // No sockets registered or select() failed - use regular delay - delay(delay_ms); -} -#endif // USE_HOST - // App storage — asm label shares the linker symbol with "extern Application App". // char[] is trivially destructible, so no __cxa_atexit or destructor chain is emitted. // Constructed via placement new in the generated setup(). @@ -628,66 +525,6 @@ alignas(Application) char app_storage[sizeof(Application)] asm( #undef ESPHOME_STRINGIFY_ #undef ESPHOME_STRINGIFY_IMPL_ -// Host platform wake_loop_threadsafe() and setup — needs wake_socket_fd_ -// ESP32/LibreTiny/ESP8266/RP2040 implementations are in wake.cpp -#ifdef USE_HOST - -void Application::setup_wake_loop_threadsafe_() { - // Create UDP socket for wake notifications - this->wake_socket_fd_ = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - if (this->wake_socket_fd_ < 0) { - ESP_LOGW(TAG, "Wake socket create failed: %d", errno); - return; - } - - // Bind to loopback with auto-assigned port - struct sockaddr_in addr = {}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - addr.sin_port = 0; // Auto-assign port - - if (::bind(this->wake_socket_fd_, (struct sockaddr *) &addr, sizeof(addr)) < 0) { - ESP_LOGW(TAG, "Wake socket bind failed: %d", errno); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } - - // Get the assigned address and connect to it - // Connecting a UDP socket allows using send() instead of sendto() for better performance - struct sockaddr_in wake_addr; - socklen_t len = sizeof(wake_addr); - if (::getsockname(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, &len) < 0) { - ESP_LOGW(TAG, "Wake socket address failed: %d", errno); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } - - // Connect to self (loopback) - allows using send() instead of sendto() - // After connect(), no need to store wake_addr - the socket remembers it - if (::connect(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, sizeof(wake_addr)) < 0) { - ESP_LOGW(TAG, "Wake socket connect failed: %d", errno); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } - - // Set non-blocking mode - int flags = ::fcntl(this->wake_socket_fd_, F_GETFL, 0); - ::fcntl(this->wake_socket_fd_, F_SETFL, flags | O_NONBLOCK); - - // Register with application's select() loop - if (!this->register_socket_fd(this->wake_socket_fd_)) { - ESP_LOGW(TAG, "Wake socket register failed"); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } -} - -#endif // USE_HOST - void Application::get_build_time_string(std::span buffer) { ESPHOME_strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); buffer[buffer.size() - 1] = '\0'; diff --git a/esphome/core/application.h b/esphome/core/application.h index 3d8df88d2a..8280b3bd4b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -27,27 +27,12 @@ #ifdef USE_LWIP_FAST_SELECT #include "esphome/core/lwip_fast_select.h" #endif -#ifdef USE_HOST -#include -#include -#include -#include -#include -#include -#endif #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif #include "esphome/core/wake.h" #include "esphome/core/entity_includes.h" -namespace esphome::socket { -#ifdef USE_HOST -/// Shared ready() helper for fd-based socket implementations. -bool socket_ready_fd(int fd, bool loop_monitored); // NOLINT(readability-redundant-declaration) -#endif -} // namespace esphome::socket - #ifdef USE_RUNTIME_STATS namespace esphome::runtime_stats { class RuntimeStatsCollector; @@ -343,18 +328,6 @@ class Application { Scheduler scheduler; -#ifdef USE_HOST - /// Register/unregister a socket file descriptor with the host select() fallback loop. - /// USE_LWIP_FAST_SELECT builds do not use this API — sockets hook the lwIP netconn - /// event_callback directly (see socket.h hook_fd_for_fast_select) and rely on FreeRTOS - /// task notifications for wake-up. - /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. - /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. - /// @return true if registration was successful, false if fd exceeds limits - bool register_socket_fd(int fd); - void unregister_socket_fd(int fd); -#endif - /// Wake the main event loop from another thread or callback. /// @see esphome::wake_loop_threadsafe() in wake.h for platform details. void wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } @@ -372,21 +345,11 @@ class Application { protected: friend Component; -#ifdef USE_HOST - friend bool socket::socket_ready_fd(int fd, bool loop_monitored); -#endif #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif friend void ::setup(); friend void ::original_setup(); -#ifdef USE_HOST - friend void wake_loop_threadsafe(); // Host platform accesses wake_socket_fd_ -#endif - -#ifdef USE_HOST - bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } -#endif /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() @@ -460,18 +423,9 @@ class Application { void service_status_led_slow_(uint32_t time); #endif - /// Perform a delay while also monitoring socket file descriptors for readiness -#ifdef USE_HOST - // select() fallback path is too complex to inline (host platform) - void yield_with_select_(uint32_t delay_ms); -#else + /// Sleep for up to delay_ms, returning early if a wake event arrives. + /// Thin wrapper over the platform wake primitive in wake.h. inline void ESPHOME_ALWAYS_INLINE yield_with_select_(uint32_t delay_ms); -#endif - -#ifdef USE_HOST - void setup_wake_loop_threadsafe_(); // Create wake notification socket - inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined) -#endif // === Member variables ordered by size to minimize padding === @@ -496,9 +450,6 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop FixedVector looping_components_{}; -#ifdef USE_HOST - std::vector socket_fds_; // Vector of all monitored socket file descriptors -#endif // StringRef members (8 bytes each: pointer + size) StringRef name_; @@ -513,11 +464,6 @@ class Application { uint32_t last_status_led_service_{0}; #endif -#ifdef USE_HOST - int max_fd_{-1}; // Highest file descriptor number for select() - int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks -#endif - // 2-byte members (grouped together for alignment) uint16_t dump_config_at_{std::numeric_limits::max()}; // Index into components_ for dump_config progress uint16_t loop_interval_{16}; // Loop interval in ms (max 65535ms = 65.5 seconds) @@ -530,14 +476,6 @@ class Application { bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; -#ifdef USE_HOST - bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes - - // Variable-sized members (not needed with fast select — is_socket_ready_ reads rcvevent directly) - fd_set read_fds_{}; // Working fd_set: populated by select() - fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes -#endif - // StaticVectors (largest members - contain actual array data inline) StaticVector components_{}; @@ -565,30 +503,6 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#ifdef USE_HOST -// Inline implementations for hot-path functions -// drain_wake_notifications_() is called on every loop iteration - -// Small buffer for draining wake notification bytes (1 byte sent per wake) -// Size allows draining multiple notifications per recvfrom() without wasting stack -static constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; - -inline void Application::drain_wake_notifications_() { - // Called from main loop to drain any pending wake notifications - // Must check is_socket_ready_() to avoid blocking on empty socket - if (this->wake_socket_fd_ >= 0 && this->is_socket_ready_(this->wake_socket_fd_)) { - char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; - // Drain all pending notifications with non-blocking reads - // Multiple wake events may have triggered multiple writes, so drain until EWOULDBLOCK - // We control both ends of this loopback socket (always write 1 byte per wake), - // so no error checking needed - any errors indicate catastrophic system failure - while (::recvfrom(this->wake_socket_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - // Just draining, no action needed - wake has already occurred - } - } -} -#endif // USE_HOST - // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -598,8 +512,8 @@ inline void Application::drain_wake_notifications_() { // per-item feeds inside scheduler.call() without an extra millis(). inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST - // Drain wake notifications first to clear socket for next wake - this->drain_wake_notifications_(); + // Drain wake notifications first to clear socket for next wake. + wake_drain_notifications(); #endif return this->scheduler.call(now); } @@ -757,11 +671,11 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { } } -// Inline yield_with_select_ for all paths except the select() fallback -#ifndef USE_HOST +// All platforms route loop yields through the platform wake primitive. +// On host this drains the loopback wake socket via select(); on FreeRTOS +// targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { esphome::internal::wakeable_delay(delay_ms); } -#endif // !USE_HOST } // namespace esphome diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index 00b08b7b91..cac88ae91e 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -1,13 +1,20 @@ #include "esphome/core/wake.h" #include "esphome/core/hal.h" +#include "esphome/core/log.h" #ifdef USE_ESP8266 #include #endif #ifdef USE_HOST -#include "esphome/core/application.h" +#include +#include +#include +#include +#include #include +#include +#include #endif namespace esphome { @@ -82,17 +89,188 @@ void wakeable_delay(uint32_t ms) { } // namespace internal #endif // USE_RP2040 -// === Host (UDP loopback socket) === +// === Host (UDP loopback socket + select() based fd watcher) === #ifdef USE_HOST + +static const char *const TAG = "wake"; + +namespace internal { +// File-scope state — referenced inline by wake_drain_notifications() and +// wake_fd_ready() in wake.h, and by the bodies in this file. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +int g_wake_socket_fd = -1; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +fd_set g_read_fds{}; +} // namespace internal + +namespace { +// File-local state owned entirely by the select() loop. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +std::vector s_socket_fds; +int s_max_fd = -1; +bool s_socket_fds_changed = false; +fd_set s_base_read_fds{}; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) +} // namespace + +bool wake_register_fd(int fd) { + // WARNING: not thread-safe — must be called only from the main loop. + if (fd < 0) + return false; + + if (fd >= FD_SETSIZE) { + ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE); + return false; + } + + s_socket_fds.push_back(fd); + s_socket_fds_changed = true; + if (fd > s_max_fd) { + s_max_fd = fd; + } + + return true; +} + +void wake_unregister_fd(int fd) { + // WARNING: not thread-safe — must be called only from the main loop. + if (fd < 0) + return; + + for (size_t i = 0; i < s_socket_fds.size(); i++) { + if (s_socket_fds[i] != fd) + continue; + + // Swap with last element and pop — O(1) removal since order doesn't matter. + if (i < s_socket_fds.size() - 1) + s_socket_fds[i] = s_socket_fds.back(); + s_socket_fds.pop_back(); + s_socket_fds_changed = true; + // Only recalculate max_fd if we removed the current max. + if (fd == s_max_fd) { + s_max_fd = -1; + for (int sock_fd : s_socket_fds) { + if (sock_fd > s_max_fd) + s_max_fd = sock_fd; + } + } + return; + } +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + // Fallback select() path for the host platform (and any future platform + // without fast select). select() is the host equivalent of FreeRTOS task + // notify / esp_delay / WFE used on the embedded targets. + if (!s_socket_fds.empty()) [[likely]] { + // Update fd_set if socket list has changed. + if (s_socket_fds_changed) [[unlikely]] { + FD_ZERO(&s_base_read_fds); + // fd bounds are validated in wake_register_fd(). + for (int fd : s_socket_fds) { + FD_SET(fd, &s_base_read_fds); + } + s_socket_fds_changed = false; + } + + // Copy base fd_set before each select. + g_read_fds = s_base_read_fds; + + // Convert ms to timeval. + struct timeval tv; + tv.tv_sec = ms / 1000; + tv.tv_usec = (ms - tv.tv_sec * 1000) * 1000; + + // Call select with timeout. + int ret = ::select(s_max_fd + 1, &g_read_fds, nullptr, nullptr, &tv); + + // Process select() result: + // ret > 0: socket(s) have data ready - normal and expected + // ret == 0: timeout occurred - normal and expected + if (ret >= 0) [[likely]] { + // Yield if zero timeout since select(0) only polls without yielding. + if (ms == 0) [[unlikely]] { + yield(); + } + return; + } + // ret < 0: error (EINTR is normal, anything else is unexpected). + const int err = errno; + if (err == EINTR) { + return; + } + // select() error - log and fall through to delay(). + ESP_LOGW(TAG, "select() failed with errno %d", err); + } + // No sockets registered or select() failed - use regular delay. + delay(ms); +} +} // namespace internal + void wake_loop_threadsafe() { // Set flag before sending so the consumer's gate check on the next loop() // entry observes the wake regardless of select() scheduling. wake_request_set(); - if (App.wake_socket_fd_ >= 0) { + if (internal::g_wake_socket_fd >= 0) { const char dummy = 1; - ::send(App.wake_socket_fd_, &dummy, 1, 0); + ::send(internal::g_wake_socket_fd, &dummy, 1, 0); } } -#endif + +void wake_setup() { + // Create UDP socket for wake notifications. + internal::g_wake_socket_fd = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (internal::g_wake_socket_fd < 0) { + ESP_LOGW(TAG, "Wake socket create failed: %d", errno); + return; + } + + // Bind to loopback with auto-assigned port. + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // Auto-assign port + + if (::bind(internal::g_wake_socket_fd, (struct sockaddr *) &addr, sizeof(addr)) < 0) { + ESP_LOGW(TAG, "Wake socket bind failed: %d", errno); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } + + // Get the assigned address and connect to it. + // Connecting a UDP socket allows using send() instead of sendto() for better performance. + struct sockaddr_in wake_addr; + socklen_t len = sizeof(wake_addr); + if (::getsockname(internal::g_wake_socket_fd, (struct sockaddr *) &wake_addr, &len) < 0) { + ESP_LOGW(TAG, "Wake socket address failed: %d", errno); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } + + // Connect to self (loopback) — allows using send() instead of sendto(). + // After connect(), no need to store wake_addr — the socket remembers it. + if (::connect(internal::g_wake_socket_fd, (struct sockaddr *) &wake_addr, sizeof(wake_addr)) < 0) { + ESP_LOGW(TAG, "Wake socket connect failed: %d", errno); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } + + // Set non-blocking mode. + int flags = ::fcntl(internal::g_wake_socket_fd, F_GETFL, 0); + ::fcntl(internal::g_wake_socket_fd, F_SETFL, flags | O_NONBLOCK); + + // Register with the select() loop. + if (!wake_register_fd(internal::g_wake_socket_fd)) { + ESP_LOGW(TAG, "Wake socket register failed"); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } +} +#endif // USE_HOST } // namespace esphome diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 15b882b306..0cfca94a78 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -21,6 +21,11 @@ #include #endif +#ifdef USE_HOST +#include +#include +#endif + namespace esphome { // === Wake flag for ESP8266/RP2040 === @@ -170,6 +175,21 @@ void wakeable_delay(uint32_t ms); #ifdef USE_HOST /// Host: wakes select() via UDP loopback socket. Defined in wake.cpp. void wake_loop_threadsafe(); + +/// Register a socket file descriptor with the host select() loop. Not +/// thread-safe — main loop only. Returns false if fd is invalid or +/// >= FD_SETSIZE. +bool wake_register_fd(int fd); + +/// Unregister a socket file descriptor. Not thread-safe — main loop only. +void wake_unregister_fd(int fd); + +/// One-time setup of the loopback wake socket. Called from Application::setup(). +void wake_setup(); + +// wake_fd_ready() and wake_drain_notifications() are defined inline at the +// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in +// scope, which depend on USE_HOST-only includes pulled in above. #else /// Zephyr is currently the only platform without a wake mechanism. /// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). @@ -180,6 +200,10 @@ inline void wake_loop_threadsafe() {} inline void wake_loop_any_context() { wake_loop_threadsafe(); } namespace internal { +#ifdef USE_HOST +/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp. +void wakeable_delay(uint32_t ms); +#else inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { if (ms == 0) [[unlikely]] { yield(); @@ -187,8 +211,40 @@ inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { } delay(ms); } +#endif } // namespace internal #endif +#ifdef USE_HOST +namespace internal { +// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications() +// and wake_fd_ready() so the hot path stays in the header. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern int g_wake_socket_fd; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern fd_set g_read_fds; +} // namespace internal + +inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } + +// Small buffer for draining wake notification bytes (1 byte sent per wake). +// Sized to drain multiple notifications per recvfrom() without wasting stack. +inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; + +inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { + // Called from main loop to drain any pending wake notifications. + // Must check wake_fd_ready() to avoid blocking on empty socket. + if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { + char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads. Multiple wake events + // may have triggered multiple writes, so drain until EWOULDBLOCK. We control + // both ends of this loopback socket (always 1 byte per wake), so no error + // checking — any error indicates catastrophic system failure. + while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + } + } +} +#endif // USE_HOST + } // namespace esphome From 4c2efd41651cf2bd78a961eed42a6f9f35829f21 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 23 Apr 2026 01:15:25 -0500 Subject: [PATCH 0473/2214] [radio_frequency] Add experimental `radio_frequency` entity type (base component + API) (#15556) --- CODEOWNERS | 1 + esphome/components/api/api.proto | 35 +++- esphome/components/api/api_connection.cpp | 64 +++++- esphome/components/api/api_connection.h | 5 +- esphome/components/api/api_pb2.cpp | 45 ++++- esphome/components/api/api_pb2.h | 26 ++- esphome/components/api/api_pb2_dump.cpp | 24 ++- esphome/components/api/api_pb2_service.cpp | 2 +- esphome/components/api/api_pb2_service.h | 2 +- esphome/components/api/api_server.cpp | 2 +- esphome/components/api/api_server.h | 2 +- esphome/components/api/list_entities.cpp | 3 + esphome/components/api/list_entities.h | 3 + esphome/components/api/subscribe_state.h | 3 + .../components/radio_frequency/__init__.py | 77 ++++++++ .../radio_frequency/radio_frequency.cpp | 109 ++++++++++ .../radio_frequency/radio_frequency.h | 187 ++++++++++++++++++ .../components/web_server/list_entities.cpp | 6 + esphome/components/web_server/list_entities.h | 3 + esphome/components/web_server/web_server.cpp | 110 +++++++++++ esphome/components/web_server/web_server.h | 9 + esphome/core/component_iterator.h | 5 + esphome/core/defines.h | 2 + esphome/core/entity_includes.h | 3 + esphome/core/entity_types.h | 4 + tests/components/web_server/common.yaml | 1 + 26 files changed, 710 insertions(+), 23 deletions(-) create mode 100644 esphome/components/radio_frequency/__init__.py create mode 100644 esphome/components/radio_frequency/radio_frequency.cpp create mode 100644 esphome/components/radio_frequency/radio_frequency.h diff --git a/CODEOWNERS b/CODEOWNERS index 5b1ae65f1b..92efe4da4e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -403,6 +403,7 @@ esphome/components/qmp6988/* @andrewpc esphome/components/qr_code/* @wjtje esphome/components/qspi_dbi/* @clydebarrow esphome/components/qwiic_pir/* @kahrendt +esphome/components/radio_frequency/* @kbx81 esphome/components/radon_eye_ble/* @jeffeb3 esphome/components/radon_eye_rd200/* @jeffeb3 esphome/components/rc522/* @glmnet diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f906cfb8d7..c3e4c38633 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2544,27 +2544,50 @@ message ListEntitiesInfraredResponse { message InfraredRFTransmitRawTimingsRequest { option (id) = 136; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_IR_RF"; + option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; - fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance - uint32 carrier_frequency = 3; // Carrier frequency in Hz - uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.) + fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance + uint32 carrier_frequency = 3; // Carrier frequency in Hz + uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.) repeated sint32 timings = 5 [packed = true, (packed_buffer) = true]; // Raw timings in microseconds (zigzag-encoded): positive = mark (LED/TX on), negative = space (LED/TX off) + uint32 modulation = 6; // RadioFrequencyModulation enum value (0 = OOK; ignored for IR entities) } // Event message for received infrared/RF data message InfraredRFReceiveEvent { option (id) = 137; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_IR_RF"; + option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; option (no_delay) = true; uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; - fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance + fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods } +// ==================== RADIO FREQUENCY ==================== + +// Lists available radio frequency entity instances +message ListEntitiesRadioFrequencyResponse { + option (id) = 148; + option (base_class) = "InfoResponseProtoMessage"; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_RADIO_FREQUENCY"; + + string object_id = 1 [(max_data_length) = 120, (force) = true]; + fixed32 key = 2 [(force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true]; + string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + bool disabled_by_default = 5; + EntityCategory entity_category = 6; + uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; + uint32 capabilities = 8; // Bitmask of RadioFrequencyCapabilityFlags: bit 0 = transmitter, bit 1 = receiver + uint32 frequency_min = 9; // Minimum tunable frequency in Hz; if min == max (non-zero): fixed frequency; 0 = unspecified + uint32 frequency_max = 10; // Maximum tunable frequency in Hz; 0 = unspecified + uint32 supported_modulations = 11; // Bitmask of supported RadioFrequencyModulation values (bit N = modulation N supported) +} + // ==================== SERIAL PROXY ==================== enum SerialProxyParity { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4663456da6..b6f4aa2141 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -49,6 +49,9 @@ #ifdef USE_INFRARED #include "esphome/components/infrared/infrared.h" #endif +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif namespace esphome::api { @@ -100,6 +103,12 @@ static const int CAMERA_STOP_STREAM = 5000; entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id); \ if ((entity_var) == nullptr) \ return; + +// Helper macro for multi-entity dispatch: looks up an entity by key and device_id without early return or make_call(). +// Use when multiple entity types must be checked in sequence (at most one will match). +#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \ + entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id) + #else // No device support, use simpler macros // Helper macro for entity command handlers - gets entity by key, returns if not found, and creates call // object @@ -115,6 +124,12 @@ static const int CAMERA_STOP_STREAM = 5000; entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \ if ((entity_var) == nullptr) \ return; + +// Helper macro for multi-entity dispatch: looks up an entity by key without early return or make_call(). +// Use when multiple entity types must be checked in sequence (at most one will match). +#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \ + entity_type *entity_var = App.get_##getter_name##_by_key(msg.key) + #endif // USE_DEVICES APIConnection::APIConnection(std::unique_ptr sock, APIServer *parent) : parent_(parent) { @@ -1471,19 +1486,36 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c } #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) { - // TODO: When RF is implemented, add a field to the message to distinguish IR vs RF - // and dispatch to the appropriate entity type based on that field. + // Dispatch by key: infrared entities are checked first, then radio frequency entities. + // The key is unique across all entity instances on a device, so at most one lookup will succeed. #ifdef USE_INFRARED - ENTITY_COMMAND_MAKE_CALL(infrared::Infrared, infrared, infrared) - call.set_carrier_frequency(msg.carrier_frequency); - call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); - call.set_repeat_count(msg.repeat_count); - call.perform(); + ENTITY_COMMAND_LOOKUP(infrared::Infrared, infrared, infrared); + if (infrared != nullptr) { + auto call = infrared->make_call(); + call.set_carrier_frequency(msg.carrier_frequency); + call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); + call.set_repeat_count(msg.repeat_count); + call.perform(); + return; + } +#endif +#ifdef USE_RADIO_FREQUENCY + ENTITY_COMMAND_LOOKUP(radio_frequency::RadioFrequency, radio_frequency, radio_frequency); + if (radio_frequency != nullptr) { + auto call = radio_frequency->make_call(); + call.set_frequency(msg.carrier_frequency); + call.set_modulation(static_cast(msg.modulation)); + call.set_repeat_count(msg.repeat_count); + call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); + call.perform(); + } #endif } +#endif +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); } #endif @@ -1580,6 +1612,19 @@ uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection } #endif +#ifdef USE_RADIO_FREQUENCY +uint16_t APIConnection::try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn, + uint32_t remaining_size) { + auto *rf = static_cast(entity); + ListEntitiesRadioFrequencyResponse msg; + msg.capabilities = rf->get_capability_flags(); + msg.frequency_min = rf->get_traits().get_frequency_min_hz(); + msg.frequency_max = rf->get_traits().get_frequency_max_hz(); + msg.supported_modulations = rf->get_traits().get_supported_modulations(); + return fill_and_encode_entity_info(rf, msg, conn, remaining_size); +} +#endif + #ifdef USE_UPDATE bool APIConnection::send_update_state(update::UpdateEntity *update) { return this->send_message_smart_(update, UpdateStateResponse::MESSAGE_TYPE, UpdateStateResponse::ESTIMATED_SIZE); @@ -2341,6 +2386,9 @@ uint16_t APIConnection::dispatch_message_(const DeferredBatch::BatchItem &item, #ifdef USE_INFRARED CASE_INFO_ONLY(infrared, ListEntitiesInfraredResponse) #endif +#ifdef USE_RADIO_FREQUENCY + CASE_INFO_ONLY(radio_frequency, ListEntitiesRadioFrequencyResponse) +#endif #ifdef USE_EVENT CASE_INFO_ONLY(event, ListEntitiesEventResponse) #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7d08797090..4165b7f3a2 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -223,7 +223,7 @@ class APIConnection final : public APIServerConnectionBase { void on_water_heater_command_request(const WaterHeaterCommandRequest &msg); #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg); void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); #endif @@ -612,6 +612,9 @@ class APIConnection final : public APIServerConnectionBase { #ifdef USE_INFRARED static uint16_t try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size); #endif +#ifdef USE_RADIO_FREQUENCY + static uint16_t try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size); +#endif #ifdef USE_EVENT static uint16_t try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn, uint32_t remaining_size); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f304c85282..3d12453939 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3861,7 +3861,7 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const { return size; } #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES @@ -3875,6 +3875,9 @@ bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto case 4: this->repeat_count = value; break; + case 6: + this->modulation = value; + break; default: return false; } @@ -3928,6 +3931,46 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const { return size; } #endif +#ifdef USE_RADIO_FREQUENCY +uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +#ifdef USE_ENTITY_ICON + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); +#endif + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); +#ifdef USE_DEVICES + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); +#endif + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations); + return pos; +} +uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const { + uint32_t size = 0; + size += 2 + this->object_id.size(); + size += 5; + size += 2 + this->name.size(); +#ifdef USE_ENTITY_ICON + size += !this->icon.empty() ? 2 + this->icon.size() : 0; +#endif + size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += this->entity_category ? 2 : 0; +#ifdef USE_DEVICES + size += ProtoSize::calc_uint32(1, this->device_id); +#endif + size += ProtoSize::calc_uint32(1, this->capabilities); + size += ProtoSize::calc_uint32(1, this->frequency_min); + size += ProtoSize::calc_uint32(1, this->frequency_max); + size += ProtoSize::calc_uint32(1, this->supported_modulations); + return size; +} +#endif #ifdef USE_SERIAL_PROXY bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5827a8728e..5aa592e4fa 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3054,11 +3054,11 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { protected: }; #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 136; - static constexpr uint8_t ESTIMATED_SIZE = 220; + static constexpr uint8_t ESTIMATED_SIZE = 224; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_raw_timings_request"); } #endif @@ -3071,6 +3071,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { const uint8_t *timings_data_{nullptr}; uint16_t timings_length_{0}; uint16_t timings_count_{0}; + uint32_t modulation{0}; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3101,6 +3102,27 @@ class InfraredRFReceiveEvent final : public ProtoMessage { protected: }; #endif +#ifdef USE_RADIO_FREQUENCY +class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 148; + static constexpr uint8_t ESTIMATED_SIZE = 56; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("list_entities_radio_frequency_response"); } +#endif + uint32_t capabilities{0}; + uint32_t frequency_min{0}; + uint32_t frequency_max{0}; + uint32_t supported_modulations{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_SERIAL_PROXY class SerialProxyConfigureRequest final : public ProtoDecodableMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 640c347371..bdcb6d4146 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2576,7 +2576,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest")); #ifdef USE_DEVICES @@ -2591,6 +2591,7 @@ const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const out.append_p(ESPHOME_PSTR(" values, ")); append_uint(out, this->timings_length_); out.append_p(ESPHOME_PSTR(" bytes]\n")); + dump_field(out, ESPHOME_PSTR("modulation"), this->modulation); return out.c_str(); } const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const { @@ -2605,6 +2606,27 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_RADIO_FREQUENCY +const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesRadioFrequencyResponse")); + dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); + dump_field(out, ESPHOME_PSTR("key"), this->key); + dump_field(out, ESPHOME_PSTR("name"), this->name); +#ifdef USE_ENTITY_ICON + dump_field(out, ESPHOME_PSTR("icon"), this->icon); +#endif + dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); + dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); +#ifdef USE_DEVICES + dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); +#endif + dump_field(out, ESPHOME_PSTR("capabilities"), this->capabilities); + dump_field(out, ESPHOME_PSTR("frequency_min"), this->frequency_min); + dump_field(out, ESPHOME_PSTR("frequency_max"), this->frequency_max); + dump_field(out, ESPHOME_PSTR("supported_modulations"), this->supported_modulations); + return out.c_str(); +} +#endif #ifdef USE_SERIAL_PROXY const char *SerialProxyConfigureRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyConfigureRequest")); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index b41233eddd..6ae2a3e369 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -625,7 +625,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: { InfraredRFTransmitRawTimingsRequest 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 6ff988902f..aca42ca303 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -211,7 +211,7 @@ class APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){}; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 4559168ece..c30bd2e612 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -368,7 +368,7 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { } #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key, const std::vector *timings) { InfraredRFReceiveEvent resp{}; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index d6ac1a6d5d..e662d78eba 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -183,7 +183,7 @@ class APIServer final : public Component, #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const ZWaveProxyRequest &msg); #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index 0a94c1699b..f9e645b506 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -79,6 +79,9 @@ LIST_ENTITIES_HANDLER(water_heater, water_heater::WaterHeater, ListEntitiesWater #ifdef USE_INFRARED LIST_ENTITIES_HANDLER(infrared, infrared::Infrared, ListEntitiesInfraredResponse) #endif +#ifdef USE_RADIO_FREQUENCY +LIST_ENTITIES_HANDLER(radio_frequency, radio_frequency::RadioFrequency, ListEntitiesRadioFrequencyResponse) +#endif #ifdef USE_EVENT LIST_ENTITIES_HANDLER(event, event::Event, ListEntitiesEventResponse) #endif diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 7d0eb5bb13..95c626feb1 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -87,6 +87,9 @@ class ListEntitiesIterator final : public ComponentIterator { #ifdef USE_INFRARED bool on_infrared(infrared::Infrared *entity) override; #endif +#ifdef USE_RADIO_FREQUENCY + bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *entity) override; #endif diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 9edf0f0f0c..f20611e06a 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -82,6 +82,9 @@ class InitialStateIterator final : public ComponentIterator { #ifdef USE_INFRARED bool on_infrared(infrared::Infrared *infrared) override { return true; }; #endif +#ifdef USE_RADIO_FREQUENCY + bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; }; +#endif #ifdef USE_EVENT bool on_event(event::Event *event) override { return true; }; #endif diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py new file mode 100644 index 0000000000..b00590ceb5 --- /dev/null +++ b/esphome/components/radio_frequency/__init__.py @@ -0,0 +1,77 @@ +""" +Radio Frequency component for ESPHome. + +WARNING: This component is EXPERIMENTAL. The API (both Python configuration +and C++ interfaces) may change at any time without following the normal +breaking changes policy. Use at your own risk. + +Once the API is considered stable, this warning will be removed. +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import setup_entity +from esphome.coroutine import CoroPriority +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +AUTO_LOAD = ["remote_base"] + +IS_PLATFORM_COMPONENT = True + +radio_frequency_ns = cg.esphome_ns.namespace("radio_frequency") +RadioFrequency = radio_frequency_ns.class_( + "RadioFrequency", cg.EntityBase, cg.Component +) +RadioFrequencyCall = radio_frequency_ns.class_("RadioFrequencyCall") +RadioFrequencyTraits = radio_frequency_ns.class_("RadioFrequencyTraits") +RadioFrequencyModulation = radio_frequency_ns.enum("RadioFrequencyModulation") + +CONF_RADIO_FREQUENCY_ID = "radio_frequency_id" + + +def radio_frequency_schema(class_: type[cg.MockObjClass]) -> cv.Schema: + """Create a schema for a radio frequency platform. + + :param class_: The radio frequency class to use for this schema. + :return: An extended schema for radio frequency configuration. + """ + entity_schema = cv.ENTITY_BASE_SCHEMA.extend(cv.COMPONENT_SCHEMA) + return entity_schema.extend( + { + cv.GenerateID(): cv.declare_id(class_), + } + ) + + +@setup_entity("radio_frequency") +async def setup_radio_frequency_core_(var: cg.Pvariable, config: ConfigType) -> None: + """Set up core radio frequency configuration.""" + + +async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> None: + """Register a radio frequency device with the core.""" + cg.add_define("USE_RADIO_FREQUENCY") + await cg.register_component(var, config) + await setup_radio_frequency_core_(var, config) + cg.add(cg.App.register_radio_frequency(var)) + CORE.register_platform_component("radio_frequency", var) + + +async def new_radio_frequency(config: ConfigType, *args) -> cg.Pvariable: + """Create a new RadioFrequency instance. + + :param config: Configuration dictionary. + :param args: Additional arguments to pass to new_Pvariable. + :return: The created RadioFrequency instance. + """ + var = cg.new_Pvariable(config[CONF_ID], *args) + await register_radio_frequency(var, config) + return var + + +@coroutine_with_priority(CoroPriority.CORE) +async def to_code(config: ConfigType) -> None: + cg.add_global(radio_frequency_ns.using) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp new file mode 100644 index 0000000000..3c000ae1ca --- /dev/null +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -0,0 +1,109 @@ +#include "radio_frequency.h" + +#include + +#include "esphome/core/log.h" + +#ifdef USE_API +#include "esphome/components/api/api_server.h" +#endif + +namespace esphome::radio_frequency { + +static const char *const TAG = "radio_frequency"; + +// ========== RadioFrequencyCall ========== + +RadioFrequencyCall &RadioFrequencyCall::set_frequency(uint32_t frequency_hz) { + this->frequency_hz_ = frequency_hz; + return *this; +} + +RadioFrequencyCall &RadioFrequencyCall::set_modulation(RadioFrequencyModulation modulation) { + this->modulation_ = modulation; + return *this; +} + +RadioFrequencyCall &RadioFrequencyCall::set_raw_timings(const std::vector &timings) { + this->raw_timings_ = &timings; + this->packed_data_ = nullptr; + this->base64url_ptr_ = nullptr; + return *this; +} + +RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_base64url(const std::string &base64url) { + this->base64url_ptr_ = &base64url; + this->raw_timings_ = nullptr; + this->packed_data_ = nullptr; + return *this; +} + +RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { + this->packed_data_ = data; + this->packed_length_ = length; + this->packed_count_ = count; + this->raw_timings_ = nullptr; + this->base64url_ptr_ = nullptr; + return *this; +} + +RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) { + this->repeat_count_ = count; + return *this; +} + +void RadioFrequencyCall::perform() { + if (this->parent_ != nullptr) { + this->parent_->control(*this); + } +} + +// ========== RadioFrequency ========== + +void RadioFrequency::dump_config() { + ESP_LOGCONFIG(TAG, + "Radio Frequency '%s'\n" + " Supports Transmitter: %s\n" + " Supports Receiver: %s", + this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), + YESNO(this->traits_.get_supports_receiver())); + if (this->traits_.get_frequency_min_hz() > 0) { + if (this->traits_.get_frequency_min_hz() == this->traits_.get_frequency_max_hz()) { + ESP_LOGCONFIG(TAG, " Frequency: %" PRIu32 " Hz (fixed)", this->traits_.get_frequency_min_hz()); + } else { + ESP_LOGCONFIG(TAG, " Frequency Range: %" PRIu32 " - %" PRIu32 " Hz", this->traits_.get_frequency_min_hz(), + this->traits_.get_frequency_max_hz()); + } + } +} + +RadioFrequencyCall RadioFrequency::make_call() { return RadioFrequencyCall(this); } + +uint32_t RadioFrequency::get_capability_flags() const { + uint32_t flags = 0; + if (this->traits_.get_supports_transmitter()) + flags |= RadioFrequencyCapability::CAPABILITY_TRANSMITTER; + if (this->traits_.get_supports_receiver()) + flags |= RadioFrequencyCapability::CAPABILITY_RECEIVER; + return flags; +} + +bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { + // Invoke local callbacks + this->receive_callback_.call(data); + + // Forward received RF data to API server +#if defined(USE_API) && defined(USE_RADIO_FREQUENCY) + if (api::global_api_server != nullptr) { +#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 +} + +} // namespace esphome::radio_frequency diff --git a/esphome/components/radio_frequency/radio_frequency.h b/esphome/components/radio_frequency/radio_frequency.h new file mode 100644 index 0000000000..db73a844ed --- /dev/null +++ b/esphome/components/radio_frequency/radio_frequency.h @@ -0,0 +1,187 @@ +#pragma once + +// WARNING: This component is EXPERIMENTAL. The API may change at any time +// without following the normal breaking changes policy. Use at your own risk. +// Once the API is considered stable, this warning will be removed. + +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" +#include "esphome/core/helpers.h" +#include "esphome/components/remote_base/remote_base.h" + +#include + +namespace esphome::radio_frequency { + +/// Capability flags for individual radio frequency instances +enum RadioFrequencyCapability : uint32_t { + CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals + CAPABILITY_RECEIVER = 1 << 1, // Can receive signals +}; + +/// Modulation types supported by radio frequency implementations +enum RadioFrequencyModulation : uint8_t { + RADIO_FREQUENCY_MODULATION_OOK = 0, // On-Off Keying / Amplitude Shift Keying + // Future: RADIO_FREQUENCY_MODULATION_FSK, RADIO_FREQUENCY_MODULATION_GFSK, etc. +}; + +/// Forward declarations +class RadioFrequency; + +/// RadioFrequencyCall - Builder pattern for transmitting radio frequency signals +class RadioFrequencyCall { + public: + explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {} + + /// Set the carrier frequency in Hz (e.g. 433920000 for 433.92 MHz) + RadioFrequencyCall &set_frequency(uint32_t frequency_hz); + + /// Set the modulation type (defaults to OOK) + RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation); + + // ===== Raw Timings Methods ===== + // All set_raw_timings_* methods store pointers/references to external data. + // The referenced data must remain valid until perform() completes. + // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous + // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! + + /// Set the raw timings from a vector (positive = mark, negative = space) + /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). + /// @note Usage: Primarily for lambdas/automations where the vector is in scope. + RadioFrequencyCall &set_raw_timings(const std::vector &timings); + + /// Set the raw timings from base64url-encoded little-endian int32 data + /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). + /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). + /// @note Decoding happens at perform() time, directly into the transmit buffer. + RadioFrequencyCall &set_raw_timings_base64url(const std::string &base64url); + + /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) + /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). + /// @note Usage: For API component where data comes directly from the protobuf message. + RadioFrequencyCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count); + + /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) + RadioFrequencyCall &set_repeat_count(uint32_t count); + + /// Perform the transmission + void perform(); + + /// Get the frequency in Hz + const optional &get_frequency() const { return this->frequency_hz_; } + /// Get the modulation type + RadioFrequencyModulation get_modulation() const { return this->modulation_; } + /// Get the raw timings (only valid if set via set_raw_timings) + const std::vector &get_raw_timings() const { return *this->raw_timings_; } + /// Check if raw timings have been set (any format) + bool has_raw_timings() const { + return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; + } + /// Check if using packed data format + bool is_packed() const { return this->packed_data_ != nullptr; } + /// Check if using base64url data format + bool is_base64url() const { return this->base64url_ptr_ != nullptr; } + /// Get the base64url data string + const std::string &get_base64url_data() const { return *this->base64url_ptr_; } + /// Get packed data (only valid if set via set_raw_timings_packed) + const uint8_t *get_packed_data() const { return this->packed_data_; } + uint16_t get_packed_length() const { return this->packed_length_; } + uint16_t get_packed_count() const { return this->packed_count_; } + /// Get the repeat count + uint32_t get_repeat_count() const { return this->repeat_count_; } + + protected: + optional frequency_hz_{}; + uint32_t repeat_count_{1}; + RadioFrequency *parent_; + // Pointer to vector-based timings (caller-owned, must outlive perform()) + const std::vector *raw_timings_{nullptr}; + // Pointer to base64url-encoded string (caller-owned, must outlive perform()) + const std::string *base64url_ptr_{nullptr}; + // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) + const uint8_t *packed_data_{nullptr}; + uint16_t packed_length_{0}; + uint16_t packed_count_{0}; + RadioFrequencyModulation modulation_{RADIO_FREQUENCY_MODULATION_OOK}; +}; + +/// RadioFrequencyTraits - Describes the capabilities of a radio frequency implementation +class RadioFrequencyTraits { + public: + bool get_supports_transmitter() const { return this->supports_transmitter_; } + void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } + + bool get_supports_receiver() const { return this->supports_receiver_; } + void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } + + /// Hardware-supported tunable frequency range in Hz. + /// If min == max (and both non-zero): fixed-frequency hardware. + /// If both 0: range unspecified. + uint32_t get_frequency_min_hz() const { return this->frequency_min_hz_; } + void set_frequency_min_hz(uint32_t freq) { this->frequency_min_hz_ = freq; } + + uint32_t get_frequency_max_hz() const { return this->frequency_max_hz_; } + void set_frequency_max_hz(uint32_t freq) { this->frequency_max_hz_ = freq; } + + /// Convenience setter for fixed-frequency hardware (sets min == max). + void set_fixed_frequency_hz(uint32_t freq) { + this->frequency_min_hz_ = freq; + this->frequency_max_hz_ = freq; + } + + /// Bitmask of supported RadioFrequencyModulation values (bit N = modulation value N supported). + uint32_t get_supported_modulations() const { return this->supported_modulations_; } + void set_supported_modulations(uint32_t mask) { this->supported_modulations_ = mask; } + void add_supported_modulation(RadioFrequencyModulation mod) { + this->supported_modulations_ |= (1u << static_cast(mod)); + } + + protected: + uint32_t frequency_min_hz_{0}; // Minimum tunable frequency in Hz (0 = unspecified) + uint32_t frequency_max_hz_{0}; // Maximum tunable frequency in Hz (0 = unspecified) + uint32_t supported_modulations_{0}; // Bitmask of supported RadioFrequencyModulation values + bool supports_transmitter_{false}; + bool supports_receiver_{false}; +}; + +/// RadioFrequency - Base class for radio frequency implementations +class RadioFrequency : public Component, public EntityBase, public remote_base::RemoteReceiverListener { + public: + RadioFrequency() = default; + + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + /// Get the traits for this radio frequency implementation + RadioFrequencyTraits &get_traits() { return this->traits_; } + const RadioFrequencyTraits &get_traits() const { return this->traits_; } + + /// Create a call object for transmitting + RadioFrequencyCall make_call(); + + /// Get capability flags for this radio frequency instance + uint32_t get_capability_flags() const; + + /// Called when RF data is received (from RemoteReceiverListener) + bool on_receive(remote_base::RemoteReceiveData data) override; + + /// Add a callback to invoke when RF data is received + template void add_on_receive_callback(F &&callback) { + this->receive_callback_.add(std::forward(callback)); + } + + protected: + friend class RadioFrequencyCall; + + /// Perform the actual transmission (called by RadioFrequencyCall::perform()) + /// Platforms must override this to implement hardware-specific transmission. + virtual void control(const RadioFrequencyCall &call) = 0; + + // Traits describing capabilities + RadioFrequencyTraits traits_; + + // Callback manager for receive events (lazy: saves memory when no callbacks registered) + LazyCallbackManager receive_callback_; +}; + +} // namespace esphome::radio_frequency diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index ebe7bf4450..c1e7599c7e 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -145,6 +145,12 @@ bool ListEntitiesIterator::on_infrared(infrared::Infrared *obj) { return true; } #endif +#ifdef USE_RADIO_FREQUENCY +bool ListEntitiesIterator::on_radio_frequency(radio_frequency::RadioFrequency *obj) { + this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::radio_frequency_all_json_generator); + return true; +} +#endif #ifdef USE_EVENT bool ListEntitiesIterator::on_event(event::Event *obj) { diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index 8c22d757b6..9cfc6c7e33 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -87,6 +87,9 @@ class ListEntitiesIterator final : public ComponentIterator { #ifdef USE_INFRARED bool on_infrared(infrared::Infrared *obj) override; #endif +#ifdef USE_RADIO_FREQUENCY + bool on_radio_frequency(radio_frequency::RadioFrequency *obj) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *obj) override; #endif diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1daec1786d..198267204d 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -40,6 +40,9 @@ #ifdef USE_INFRARED #include "esphome/components/infrared/infrared.h" #endif +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif #ifdef USE_WEBSERVER_LOCAL #if USE_WEBSERVER_VERSION == 2 @@ -2102,6 +2105,104 @@ json::SerializationBuffer<> WebServer::infrared_json_(infrared::Infrared *obj, J } #endif +#ifdef USE_RADIO_FREQUENCY +void WebServer::handle_radio_frequency_request(AsyncWebServerRequest *request, const UrlMatch &match) { + for (radio_frequency::RadioFrequency *obj : App.get_radio_frequencies()) { + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) + continue; + + if (request->method() == HTTP_GET && entity_match.action_is_empty) { + auto detail = get_request_detail(request); + auto data = this->radio_frequency_json_(obj, detail); + request->send(200, ESPHOME_F("application/json"), data.c_str()); + return; + } + if (!match.method_equals(ESPHOME_F("transmit"))) { + request->send(404); + return; + } + + // Only allow transmit if the device supports it + if (!(obj->get_capability_flags() & radio_frequency::CAPABILITY_TRANSMITTER)) { + request->send(400, ESPHOME_F("text/plain"), ESPHOME_F("Device does not support transmission")); + return; + } + + auto call = obj->make_call(); + + // Parse carrier frequency (optional — overrides IC default) + { + auto value = parse_number(request->arg(ESPHOME_F("frequency")).c_str()); + if (value.has_value()) { + call.set_frequency(*value); + } + } + + // Parse repeat count (optional, defaults to 1) + { + auto value = parse_number(request->arg(ESPHOME_F("repeat_count")).c_str()); + if (value.has_value()) { + call.set_repeat_count(*value); + } + } + + // Parse base64url-encoded raw timings (required) + // Base64url is URL-safe: uses A-Za-z0-9-_ (no special characters needing escaping) + const auto &data_arg = request->arg(ESPHOME_F("data")); + + // Validate base64url is not empty (also catches missing parameter since arg() returns empty string) + // Arduino String has isEmpty() not empty(), use length() for cross-platform compatibility + if (data_arg.length() == 0) { // NOLINT(readability-container-size-empty) + request->send(400, ESPHOME_F("text/plain"), ESPHOME_F("Missing or empty 'data' parameter")); + return; + } + + // Defer to main loop for thread safety. Move encoded string into lambda to ensure + // it outlives the call - set_raw_timings_base64url stores a pointer, so the string + // must remain valid until perform() completes. + // ESP8266 also needs this because ESPAsyncWebServer callbacks run in "sys" context. + this->defer([call, encoded = std::string(data_arg.c_str(), data_arg.length())]() mutable { + call.set_raw_timings_base64url(encoded); + call.perform(); + }); + + request->send(200); + return; + } + request->send(404); +} + +json::SerializationBuffer<> WebServer::radio_frequency_all_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + return web_server->radio_frequency_json_(static_cast(source), DETAIL_ALL); +} + +json::SerializationBuffer<> WebServer::radio_frequency_json_(radio_frequency::RadioFrequency *obj, + JsonDetail start_config) { + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "radio_frequency", "", 0, start_config); + + const auto &traits = obj->get_traits(); + auto caps = obj->get_capability_flags(); + + root[ESPHOME_F("supports_transmitter")] = bool(caps & radio_frequency::CAPABILITY_TRANSMITTER); + root[ESPHOME_F("supports_receiver")] = bool(caps & radio_frequency::CAPABILITY_RECEIVER); + if (traits.get_frequency_min_hz() != 0) { + root[ESPHOME_F("frequency_min")] = traits.get_frequency_min_hz(); + root[ESPHOME_F("frequency_max")] = traits.get_frequency_max_hz(); + } + + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); +} +#endif + #ifdef USE_EVENT void WebServer::on_event(event::Event *obj) { if (!this->include_internal_ && obj->is_internal()) @@ -2357,6 +2458,10 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { #ifdef USE_INFRARED if (match.domain_equals(ESPHOME_F("infrared"))) return true; +#endif +#ifdef USE_RADIO_FREQUENCY + if (match.domain_equals(ESPHOME_F("radio_frequency"))) + return true; #endif } @@ -2516,6 +2621,11 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { else if (match.domain_equals(ESPHOME_F("infrared"))) { this->handle_infrared_request(request, match); } +#endif +#ifdef USE_RADIO_FREQUENCY + else if (match.domain_equals(ESPHOME_F("radio_frequency"))) { + this->handle_radio_frequency_request(request, match); + } #endif else { // No matching handler found - send 404 diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 8e8b1de8c4..25f8f8212d 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -462,6 +462,12 @@ class WebServer final : public Controller, public Component, public AsyncWebHand static json::SerializationBuffer<> infrared_all_json_generator(WebServer *web_server, void *source); #endif +#ifdef USE_RADIO_FREQUENCY + /// Handle a radio frequency request under '/radio_frequency//transmit'. + void handle_radio_frequency_request(AsyncWebServerRequest *request, const UrlMatch &match); + + static json::SerializationBuffer<> radio_frequency_all_json_generator(WebServer *web_server, void *source); +#endif #ifdef USE_EVENT void on_event(event::Event *obj) override; @@ -654,6 +660,9 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #ifdef USE_INFRARED json::SerializationBuffer<> infrared_json_(infrared::Infrared *obj, JsonDetail start_config); #endif +#ifdef USE_RADIO_FREQUENCY + json::SerializationBuffer<> radio_frequency_json_(radio_frequency::RadioFrequency *obj, JsonDetail start_config); +#endif #ifdef USE_UPDATE json::SerializationBuffer<> update_json_(update::UpdateEntity *obj, JsonDetail start_config); #endif diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 9a1e5da351..d271fcfed0 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -21,6 +21,11 @@ namespace infrared { class Infrared; } // namespace infrared #endif +#ifdef USE_RADIO_FREQUENCY +namespace radio_frequency { +class RadioFrequency; +} // namespace radio_frequency +#endif class ComponentIterator { public: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0978437039..63fe4e677e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -65,6 +65,7 @@ #define USE_INFRARED #define USE_IR_RF #define USE_JSON +#define USE_RADIO_FREQUENCY #define USE_LIGHT #define USE_LIGHT_GAMMA_LUT #define USE_LOCK @@ -448,6 +449,7 @@ #define ESPHOME_ENTITY_LOCK_COUNT 1 #define ESPHOME_ENTITY_MEDIA_PLAYER_COUNT 1 #define ESPHOME_ENTITY_NUMBER_COUNT 1 +#define ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT 1 #define ESPHOME_ENTITY_SELECT_COUNT 1 #define ESPHOME_ENTITY_SENSOR_COUNT 1 #define ESPHOME_ENTITY_SWITCH_COUNT 1 diff --git a/esphome/core/entity_includes.h b/esphome/core/entity_includes.h index f67887b30b..b1310e1142 100644 --- a/esphome/core/entity_includes.h +++ b/esphome/core/entity_includes.h @@ -68,6 +68,9 @@ #ifdef USE_INFRARED #include "esphome/components/infrared/infrared.h" #endif +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif #ifdef USE_SERIAL_PROXY #include "esphome/components/serial_proxy/serial_proxy.h" #endif diff --git a/esphome/core/entity_types.h b/esphome/core/entity_types.h index 04b490e10e..f830911c07 100644 --- a/esphome/core/entity_types.h +++ b/esphome/core/entity_types.h @@ -90,6 +90,10 @@ ENTITY_CONTROLLER_TYPE_(water_heater::WaterHeater, water_heater, water_heaters, #ifdef USE_INFRARED ENTITY_TYPE_(infrared::Infrared, infrared, infrareds, ESPHOME_ENTITY_INFRARED_COUNT, INFRARED) #endif +#ifdef USE_RADIO_FREQUENCY +ENTITY_TYPE_(radio_frequency::RadioFrequency, radio_frequency, radio_frequencies, ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT, + RADIO_FREQUENCY) +#endif #ifdef USE_EVENT ENTITY_CONTROLLER_TYPE_(event::Event, event, events, ESPHOME_ENTITY_EVENT_COUNT, EVENT, event) #endif diff --git a/tests/components/web_server/common.yaml b/tests/components/web_server/common.yaml index 35a605484c..5a05a58c2d 100644 --- a/tests/components/web_server/common.yaml +++ b/tests/components/web_server/common.yaml @@ -38,3 +38,4 @@ event: update: water_heater: infrared: +radio_frequency: From 9685d4eb0b7e6f28ebff3d47d77f0ede58c1ffbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:15:44 +0200 Subject: [PATCH 0474/2214] [core] feed_wdt wraps feed_wdt_with_time (#15932) --- esphome/core/application.cpp | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 3105ff2e8b..11381030a3 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -208,16 +208,8 @@ void Application::process_dump_config_() { void Application::feed_wdt() { // Cold entry: callers without a millis() timestamp in hand. Fetches the - // time and takes the same rate-limit paths as feed_wdt_with_time(). - uint32_t now = MillisInternal::get(); - if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { - this->feed_wdt_slow_(now); - } -#ifdef USE_STATUS_LED - if (now - this->last_status_led_service_ > STATUS_LED_DISPATCH_INTERVAL_MS) { - this->service_status_led_slow_(now); - } -#endif + // time and defers to the hot path. + this->feed_wdt_with_time(MillisInternal::get()); } void HOT Application::feed_wdt_slow_(uint32_t time) { From 64290d32a1dd8289b04174905ec8e3c81f75fb94 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 03:32:12 -0500 Subject: [PATCH 0475/2214] Bump aioesphomeapi from 44.20.0 to 44.21.0 (#15941) 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 e7ab9bc2ad..90b0693840 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260408.1 -aioesphomeapi==44.20.0 +aioesphomeapi==44.21.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From e16566e4af12453b781f0dd1aa8c118a128c28fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 02:42:51 -0500 Subject: [PATCH 0476/2214] [api] Fall back to plaintext for logger connections --- esphome/components/api/client.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 0c6c569c7d..19c034dcad 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -93,7 +93,20 @@ async def async_run_logs( config, raw_line, backtrace_state=backtrace_state ) - stop = await async_run(cli, on_log, name=name, subscribe_states=subscribe_states) + # Safe to fall back to plaintext here: the log stream is strictly + # one-way from device to client, and this code never accepts commands + # or acts on any message the device sends. The worst an on-path + # attacker can do is show fabricated log lines, which is why + # aioesphomeapi logs a warning that the device's identity cannot be + # verified. Never mirror this opt-in for any connection that sends + # data to the device or uses Home Assistant actions. + stop = await async_run( + cli, + on_log, + name=name, + subscribe_states=subscribe_states, + allow_plaintext_fallback=True, + ) try: await asyncio.Event().wait() finally: From 43a371caab80f7a3ce64edd3be951acc84270d18 Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:08:49 +0200 Subject: [PATCH 0477/2214] [dsmr] Small refactoring: Move `Aes128GcmDecryptorImpl` type inside `esphome::dsmr` namespace. (#15940) --- esphome/components/dsmr/dsmr.h | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index c76a23fde4..626a389c1f 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -18,22 +18,27 @@ #if __has_include() #include -using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa; #elif __has_include() #if __has_include() #include #endif #include -using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls; #elif __has_include() #include -using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl; #else #error "The platform doesn't provide a compatible encryption library for dsmr_parser" #endif namespace esphome::dsmr { +#if __has_include() +using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa; +#elif __has_include() +using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls; +#else +using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl; +#endif + using namespace dsmr_parser::fields; #ifndef DSMR_SENSOR_LIST From b4206f94bc9eb9fc58f2dd7dd5efdacd525c8bde Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 04:22:16 -0500 Subject: [PATCH 0478/2214] [wifi] Avoid BDK 3.0.78 wifi_event_sta_disconnected_t collision on BK72xx BDK 3.0.78 (required by LibreTiny for BK7238 support, see libretiny-eu/libretiny#360) declares wifi_event_sta_disconnected_t in wlan_defs_pub.h, which collides with the identically named typedef in LibreTiny's Arduino WiFi API (WiFiEvents.h). Rename the BDK version across the include so both headers can coexist. ESPHome only uses bk_wlan_get_link_status from this header and doesn't reference the renamed type. The rename is a no-op on BDK 3.0.33 (BK7231T/N) since that version doesn't declare the typedef. --- esphome/components/wifi/wifi_component_libretiny.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index cdd11ceaef..6588e93e16 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -12,7 +12,12 @@ #ifdef USE_BK72XX extern "C" { +// BDK 3.0.78 (required for BK7238) redeclares wifi_event_sta_disconnected_t, +// which LibreTiny's Arduino WiFi API already defines. ESPHome doesn't use the +// BDK version, so rename it across this include to avoid the collision. +#define wifi_event_sta_disconnected_t bdk_wifi_event_sta_disconnected_t #include +#undef wifi_event_sta_disconnected_t } #endif From c7fdd534bcdee71a448276088a5099d9d2abfcca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 04:44:05 -0500 Subject: [PATCH 0479/2214] [core] Raise WDT_FEED_INTERVAL_MS to 2000ms on BK72xx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BK72xx silicon requires a ~200us busy-wait (sctrl_dpll_delay200us) between two watchdog register key writes on every reload, making each arch_feed_wdt() call ~300us on BK7231T/N/BK7238. This is hardware errata in the BDK's wdt_ctrl (beken378/driver/wdt/wdt.c:WCMD_RELOAD_PERIOD) and cannot be worked around at the SDK level. LibreTiny initialises the BK72xx HW watchdog at 10000ms, but ESPHome's generic WDT_FEED_INTERVAL_MS of 300ms was sized for ESP8266's 1.6s soft watchdog. That left BK72xx over-servicing the watchdog ~33x per timeout window, paying ~60ms/min of main-loop overhead to no benefit. Raise the interval to 2000ms on BK72xx only, which keeps a 5x safety margin on the 10s HW WDT — matching the ESP8266 ratio that originally motivated the 300ms value — while cutting feed frequency ~6x. Measured on a NiceMCU XH-WB3S (BK7238) while testing libretiny-eu/libretiny#360: Before (300ms interval): wdt_slow_path: hits=195 (5.9% of iters, avg=315.97us/hit) main_loop_before_breakdown: sched=73.2ms, wdt=61.6ms, residual=0.0ms Other platforms retain the existing 300ms value. --- esphome/core/application.h | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 8280b3bd4b..9dc397f526 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -219,11 +219,22 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT default (5 s): ~16x - /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change - /// must keep comfortable margin here - /// - ESP8266 HW WDT (~6 s): ~20x + /// - ESP32 task WDT default (5 s): ~16x + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below +#ifdef USE_BK72XX + /// BK72xx silicon requires a ~200 µs busy-wait between two watchdog register + /// key writes on every reload (sctrl_dpll_delay200us() in BDK wdt.c), making + /// each arch_feed_wdt() ~300 µs. LibreTiny initialises the HW WDT at 10 s, + /// so 2000 ms keeps a 5x safety margin — matching the ESP8266 ratio that + /// motivated the generic 300 ms value — while cutting feed frequency ~6x + /// and recovering ~50 ms/min of main-loop overhead on typical configs. + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; +#else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; +#endif /// Feed the task watchdog. Cold entry — callers without a millis() /// timestamp in hand. Out of line to keep call sites tiny. From 3b212b9944f4b0d4a8078b6f9e7c1548bcac173e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 04:47:52 -0500 Subject: [PATCH 0480/2214] [core] Add permalink to BDK sctrl_dpll_delay200us in WDT interval comment So future readers can verify the errata claim without chasing the PR. --- esphome/core/application.h | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 3af02a1861..77f6b1c74d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -226,11 +226,13 @@ class Application { /// - BK72xx HW WDT (10 s): ~5x <-- platform override below #ifdef USE_BK72XX /// BK72xx silicon requires a ~200 µs busy-wait between two watchdog register - /// key writes on every reload (sctrl_dpll_delay200us() in BDK wdt.c), making - /// each arch_feed_wdt() ~300 µs. LibreTiny initialises the HW WDT at 10 s, - /// so 2000 ms keeps a 5x safety margin — matching the ESP8266 ratio that - /// motivated the generic 300 ms value — while cutting feed frequency ~6x - /// and recovering ~50 ms/min of main-loop overhead on typical configs. + /// key writes on every reload, making each arch_feed_wdt() ~300 µs. The + /// sctrl_dpll_delay200us() call lives in BDK's wdt_ctrl (WCMD_RELOAD_PERIOD): + /// https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 + /// LibreTiny initialises the HW WDT at 10 s, so 2000 ms keeps a 5x safety + /// margin — matching the ESP8266 ratio that motivated the generic 300 ms + /// value — while cutting feed frequency ~6x and recovering ~50 ms/min of + /// main-loop overhead on typical configs. static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; #else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; @@ -474,6 +476,11 @@ class Application { // millis() of most recent status_led dispatch; rate-limits independently of last_wdt_feed_ uint32_t last_status_led_service_{0}; #endif +#ifdef USE_RUNTIME_STATS + // Count of feed_wdt_slow_() invocations since last runtime_stats log. Lets + // stats output report how often the slow path (arch_feed_wdt) actually runs. + uint32_t wdt_slow_count_{0}; +#endif // 2-byte members (grouped together for alignment) uint16_t dump_config_at_{std::numeric_limits::max()}; // Index into components_ for dump_config progress @@ -572,6 +579,12 @@ inline void ESPHOME_ALWAYS_INLINE __attribute__((optimize("O2"))) Application::l // so the gate check and WDT feed both reflect actual elapsed time after // scheduler dispatch, without an extra millis() call. uint32_t now = this->scheduler_tick_(MillisInternal::get()); +#ifdef USE_RUNTIME_STATS + // Capture immediately after scheduler_tick_ returns so the post-scheduler + // feed_wdt_with_time slice can be separated from the scheduler slice in + // the `before` bucket. + uint32_t loop_after_sched_us = micros(); +#endif // Guarantee one WDT feed per tick even when the scheduler had nothing to // dispatch and the component phase is gated out — covers configs with no // looping components and no scheduler work (setup() has its own @@ -643,10 +656,22 @@ inline void ESPHOME_ALWAYS_INLINE __attribute__((optimize("O2"))) Application::l uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us ? loop_before_wall_us - static_cast(loop_before_scheduled_us) : 0; + // Sub-split of the `before` bucket. `sched_us` covers MillisInternal::get() + // plus scheduler_tick_ (everything before the post-scheduler feed_wdt_with_time), + // with scheduled-component time subtracted out so it reflects scheduler + // infrastructure overhead only. `wdt_us` covers the post-scheduler + // feed_wdt_with_time() call (including any status_led dispatch when the + // STATUS_LED_DISPATCH_INTERVAL_MS gate also elapses on the same call). + uint32_t loop_before_sched_wall_us = loop_after_sched_us - loop_active_start_us; + uint32_t loop_before_sched_us = loop_before_sched_wall_us > loop_before_scheduled_us + ? loop_before_sched_wall_us - static_cast(loop_before_scheduled_us) + : 0; + uint32_t loop_before_wdt_us = loop_before_end_us - loop_after_sched_us; // tail_us is only defined when Phase B ran; 0 on Phase A-only ticks so the // stats bucket keeps its "component-phase trailing overhead" meaning. uint32_t loop_tail_us = do_component_phase ? (loop_now_us - loop_tail_start_us) : 0; - global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, loop_tail_us); + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, + loop_before_sched_us, loop_before_wdt_us, loop_tail_us); global_runtime_stats->process_pending_stats(now); } #endif From 14bd2f5a52c300771a33d22725a02d2d01c7e1ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 05:12:12 -0500 Subject: [PATCH 0481/2214] [core] Remove stale includes from application.cpp socket.h is unused after #15931 moved the host wake mechanism to wake.cpp. lwip_fast_select.h is already included via application.h under the same guard. --- esphome/core/application.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 11381030a3..9d9bd024c6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -12,9 +12,6 @@ #include #include #endif -#ifdef USE_LWIP_FAST_SELECT -#include "esphome/core/lwip_fast_select.h" -#endif // USE_LWIP_FAST_SELECT #include "esphome/core/version.h" #include "esphome/core/hal.h" #include @@ -24,10 +21,6 @@ #include "esphome/components/status_led/status_led.h" #endif -#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) -#include "esphome/components/socket/socket.h" -#endif - namespace esphome { static const char *const TAG = "app"; From 8fe40f33a6466ac07a81555d63ca2d0f08912d1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 05:13:12 -0500 Subject: [PATCH 0482/2214] [core] Drop unused lwip_fast_select.h include from application.h No symbols from lwip_fast_select.h are referenced in application.h. --- esphome/core/application.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 8280b3bd4b..cf26809d1a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -24,9 +24,6 @@ #include "esphome/core/area.h" #endif -#ifdef USE_LWIP_FAST_SELECT -#include "esphome/core/lwip_fast_select.h" -#endif #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif From 8da2b8225aba8fb2d40827246240b706c8dc4e98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 05:14:25 -0500 Subject: [PATCH 0483/2214] [core] Inline yield_with_select_ as direct wakeable_delay call yield_with_select_ was a trivial one-line passthrough to esphome::internal::wakeable_delay(). Remove the wrapper and call wakeable_delay() directly at the two call sites. --- esphome/components/libretiny/core.cpp | 2 +- esphome/core/application.cpp | 2 +- esphome/core/application.h | 16 ++++------------ 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 1b74e3addb..ca46bcb899 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -56,7 +56,7 @@ void arch_init() { // // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. - // This is safe because ESPHome yields voluntarily via yield_with_select_() and + // This is safe because ESPHome yields voluntarily via wakeable_delay() and // the Arduino mainTask yield() after each loop() iteration. static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 9d9bd024c6..d03696fbb6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -359,7 +359,7 @@ void Application::teardown_components(uint32_t timeout_ms) { // Give some time for I/O operations if components are still pending if (pending_count > 0) { - this->yield_with_select_(1); + esphome::internal::wakeable_delay(1); } // Update time for next iteration diff --git a/esphome/core/application.h b/esphome/core/application.h index cf26809d1a..b700415681 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -420,10 +420,6 @@ class Application { void service_status_led_slow_(uint32_t time); #endif - /// Sleep for up to delay_ms, returning early if a wake event arrives. - /// Thin wrapper over the platform wake primitive in wake.h. - inline void ESPHOME_ALWAYS_INLINE yield_with_select_(uint32_t delay_ms); - // === Member variables ordered by size to minimize padding === // Pointer-sized members first @@ -661,18 +657,14 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } - this->yield_with_select_(delay_time); + // All platforms route loop yields through the platform wake primitive. + // On host this drains the loopback wake socket via select(); on FreeRTOS + // targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. + esphome::internal::wakeable_delay(delay_time); if (this->dump_config_at_ < this->components_.size()) { this->process_dump_config_(); } } -// All platforms route loop yields through the platform wake primitive. -// On host this drains the loopback wake socket via select(); on FreeRTOS -// targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. -inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { - esphome::internal::wakeable_delay(delay_ms); -} - } // namespace esphome From c414cc393f8da97e0042058c3458202a07e5e18d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 05:47:52 -0500 Subject: [PATCH 0484/2214] [scheduler] Enable lock-free fast-path on ESPHOME_THREAD_MULTI_NO_ATOMICS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _empty_() helpers (to_add_empty_, defer_empty_, to_remove_empty_) forced the lock path on ESPHOME_THREAD_MULTI_NO_ATOMICS by hardcoding `return false`. That made Scheduler::call() pay a FreeRTOS mutex round-trip for each of process_defer_queue_ / process_to_add / cleanup_ on every idle tick just to confirm "nothing to do". On the only NO_ATOMICS target (BK72xx — ARMv5TE, single-core), an aligned 32-bit load is atomic at the hardware level. Mark the three skip-work counters volatile so the compiler cannot cache or elide the read, and let _empty_() compare against zero directly. Writers still hold lock_ for any RMW — that invariant is unchanged. A stale 0 is benign: the counter is checked on every Scheduler::call() iteration, so a missed update is caught next tick. Same pattern as the NO_ATOMICS reads in time_64.cpp. On BK72xx at ~3100 iter/min with ~8us/mutex this reclaims roughly 75ms/min of main-loop overhead. Measured on BK7238/BK7231N while profiling alongside libretiny-eu/libretiny#360. ATOMICS and SINGLE paths are unchanged (SINGLE keeps plain uint32_t, no volatile-read overhead). --- esphome/core/scheduler.h | 46 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b7e99d4603..096291426f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -524,30 +524,30 @@ class Scheduler { std::vector to_add_; #ifndef ESPHOME_THREAD_SINGLE - // Fast-path counter for process_to_add() to skip taking the lock when there is - // nothing to add. Uses std::atomic on platforms that support it, plain uint32_t - // otherwise. On non-atomic platforms, callers must hold the scheduler lock when - // mutating this counter. Not needed on single-threaded platforms where we can - // check to_add_.empty() directly. + // Fast-path counter for process_to_add() to skip taking the lock when there + // is nothing to add. std::atomic on ATOMICS; volatile uint32_t on NO_ATOMICS + // (aligned 32-bit reads are atomic on ARMv5TE — BK72xx — and volatile + // prevents the compiler caching/eliding the read). On NO_ATOMICS, callers + // must hold lock_ for any RMW mutation. Not needed on SINGLE. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic to_add_count_{0}; #else - uint32_t to_add_count_{0}; + volatile uint32_t to_add_count_{0}; #endif #endif /* ESPHOME_THREAD_SINGLE */ - // Fast-path helper for process_to_add() to decide if it can try the lock-free path. - // - On ESPHOME_THREAD_SINGLE: direct container check is safe (no concurrent writers). - // - On ESPHOME_THREAD_MULTI_ATOMICS: performs a lock-free check via to_add_count_. - // - On ESPHOME_THREAD_MULTI_NO_ATOMICS: always returns false to force the caller - // down the locked path; this is NOT a lock-free emptiness check on that platform. + // Fast-path helper for process_to_add() to decide if it can skip the lock. + // - SINGLE: direct container check (no concurrent writers). + // - ATOMICS: lock-free load of to_add_count_. + // - NO_ATOMICS: volatile read. A stale 0 is benign — next call() iteration + // observes the update; RMW mutation is still under lock_. bool to_add_empty_() const { #ifdef ESPHOME_THREAD_SINGLE return this->to_add_.empty(); #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_add_count_.load(std::memory_order_relaxed) == 0; #else - return false; + return this->to_add_count_ == 0; #endif } @@ -580,20 +580,20 @@ class Scheduler { std::vector defer_queue_; // FIFO queue for defer() calls size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) - // Fast-path counter for process_defer_queue_() to skip lock when nothing to process. + // Fast-path counter for process_defer_queue_() to skip lock when nothing to + // process. See to_add_count_ above for the volatile rationale on NO_ATOMICS. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic defer_count_{0}; #else - uint32_t defer_count_{0}; + volatile uint32_t defer_count_{0}; #endif bool defer_empty_() const { // defer_queue_ only exists on multi-threaded platforms, so no ESPHOME_THREAD_SINGLE path - // ESPHOME_THREAD_MULTI_NO_ATOMICS: always take the lock #ifdef ESPHOME_THREAD_MULTI_ATOMICS return this->defer_count_.load(std::memory_order_relaxed) == 0; #else - return false; + return this->defer_count_ == 0; #endif } @@ -615,23 +615,23 @@ class Scheduler { #endif /* ESPHOME_THREAD_SINGLE */ - // Counter for items marked for removal. Incremented cross-thread in cancel_item_locked_(). - // On ESPHOME_THREAD_MULTI_ATOMICS this is read without a lock in the cleanup_() fast path; - // on ESPHOME_THREAD_MULTI_NO_ATOMICS the fast path is disabled so cleanup_() always takes the lock. + // Counter for items marked for removal. Incremented cross-thread in + // cancel_item_locked_(). See to_add_count_ above for the volatile rationale + // on NO_ATOMICS. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic to_remove_{0}; +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + volatile uint32_t to_remove_{0}; #else - uint32_t to_remove_{0}; +uint32_t to_remove_{0}; #endif // Lock-free check if there are items to remove (for fast-path in cleanup_) bool to_remove_empty_() const { #ifdef ESPHOME_THREAD_MULTI_ATOMICS return this->to_remove_.load(std::memory_order_relaxed) == 0; -#elif defined(ESPHOME_THREAD_SINGLE) - return this->to_remove_ == 0; #else - return false; // Always take the lock path + return this->to_remove_ == 0; #endif } From 4ec2e42d4d2d8b50245b4ee603e1ed113471f3bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 05:49:49 -0500 Subject: [PATCH 0485/2214] [scheduler] Rename counter mutators with _locked_ suffix Rename the seven counter RMW mutators to carry the `_locked_` suffix that matches the existing convention (pop_raw_locked_, is_item_removed_locked_, cancel_item_locked_, etc.): to_add_count_increment_ -> to_add_count_increment_locked_ to_add_count_clear_ -> to_add_count_clear_locked_ defer_count_increment_ -> defer_count_increment_locked_ defer_count_clear_ -> defer_count_clear_locked_ to_remove_add_ -> to_remove_add_locked_ to_remove_decrement_ -> to_remove_decrement_locked_ to_remove_clear_ -> to_remove_clear_locked_ The caller-must-hold-lock contract became load-bearing when the underlying counters became volatile on NO_ATOMICS: ++/+=/-- compile to a three-instruction LDR/OP/STR sequence that is not atomic against a concurrent RMW from another task, so the lock is what keeps the counter consistent. The new suffix makes the requirement explicit at every call site, matching how the rest of the scheduler documents the same invariant. No behavioural change; all call sites already hold lock_. --- esphome/core/scheduler.cpp | 20 ++++++++++---------- esphome/core/scheduler.h | 14 +++++++------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index b0eaa670ac..a6f1558e4a 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -235,11 +235,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } target->push_back(item); if (target == &this->to_add_) { - this->to_add_count_increment_(); + this->to_add_count_increment_locked_(); } #ifndef ESPHOME_THREAD_SINGLE else { - this->defer_count_increment_(); + this->defer_count_increment_locked_(); } #endif } @@ -452,7 +452,7 @@ void Scheduler::full_cleanup_removed_items_() { this->items_.erase(this->items_.begin() + write, this->items_.end()); // Rebuild the heap structure since items are no longer in heap order std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - this->to_remove_clear_(); + this->to_remove_clear_locked_(); } #ifndef ESPHOME_THREAD_SINGLE @@ -501,7 +501,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { this->lock_.lock(); // Reset counter and snapshot queue end under lock - this->defer_count_clear_(); + this->defer_count_clear_locked_(); size_t defer_queue_end = this->defer_queue_.size(); if (this->defer_queue_front_ >= defer_queue_end) { this->lock_.unlock(); @@ -621,7 +621,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { LockGuard guard{this->lock_}; if (is_item_removed_locked_(item)) { this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); continue; } } @@ -630,7 +630,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { if (is_item_removed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); continue; } #endif @@ -658,7 +658,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { if (this->is_item_removed_locked_(executed_item)) { // We were removed/cancelled in the function call, recycle and continue - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(executed_item); continue; } @@ -721,7 +721,7 @@ void HOT Scheduler::process_to_add_slow_path_() { std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); } this->to_add_.clear(); - this->to_add_count_clear_(); + this->to_add_count_clear_locked_(); } bool HOT Scheduler::cleanup_slow_path_() { // We must hold the lock for the entire cleanup operation because: @@ -737,7 +737,7 @@ bool HOT Scheduler::cleanup_slow_path_() { SchedulerItem *item = this->items_[0]; if (!this->is_item_removed_locked_(item)) break; - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(this->pop_raw_locked_()); } return !this->items_.empty(); @@ -825,7 +825,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, hash_or_id, type, match_retry, find_first); total_cancelled += heap_cancelled; - this->to_remove_add_(heap_cancelled); + this->to_remove_add_locked_(heap_cancelled); if (find_first && total_cancelled > 0) return true; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 096291426f..946ec8ca78 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -552,7 +552,7 @@ class Scheduler { } // Increment to_add_count_ (no-op on single-threaded platforms) - void to_add_count_increment_() { + void to_add_count_increment_locked_() { #ifdef ESPHOME_THREAD_SINGLE // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) @@ -563,7 +563,7 @@ class Scheduler { } // Reset to_add_count_ (no-op on single-threaded platforms) - void to_add_count_clear_() { + void to_add_count_clear_locked_() { #ifdef ESPHOME_THREAD_SINGLE // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) @@ -597,7 +597,7 @@ class Scheduler { #endif } - void defer_count_increment_() { + void defer_count_increment_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.fetch_add(1, std::memory_order_relaxed); #else @@ -605,7 +605,7 @@ class Scheduler { #endif } - void defer_count_clear_() { + void defer_count_clear_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.store(0, std::memory_order_relaxed); #else @@ -635,7 +635,7 @@ uint32_t to_remove_{0}; #endif } - void to_remove_add_(uint32_t count) { + void to_remove_add_locked_(uint32_t count) { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->to_remove_.fetch_add(count, std::memory_order_relaxed); #else @@ -643,7 +643,7 @@ uint32_t to_remove_{0}; #endif } - void to_remove_decrement_() { + void to_remove_decrement_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->to_remove_.fetch_sub(1, std::memory_order_relaxed); #else @@ -651,7 +651,7 @@ uint32_t to_remove_{0}; #endif } - void to_remove_clear_() { + void to_remove_clear_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->to_remove_.store(0, std::memory_order_relaxed); #else From 89fca4f446aea83c1de4b9eac6750ecb3bdd1ab0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:01:08 -0500 Subject: [PATCH 0486/2214] merge --- .../runtime_stats/runtime_stats.cpp | 21 +++++++++++++++++++ .../components/runtime_stats/runtime_stats.h | 12 ++++++++++- esphome/core/application.cpp | 3 +++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index d733394b78..26571a4054 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -81,6 +81,19 @@ void RuntimeStatsCollector::log_stats_() { ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", static_cast(before) / 1000.0, static_cast(tail) / 1000.0, static_cast(inter) / 1000.0); + uint64_t sched = this->period_before_sched_time_us_; + uint64_t wdt = this->period_before_wdt_time_us_; + uint64_t before_residual = before > sched + wdt ? before - sched - wdt : 0; + ESP_LOGI(TAG, " main_loop_before_breakdown: sched=%.1fms, wdt=%.1fms, residual=%.1fms", + static_cast(sched) / 1000.0, static_cast(wdt) / 1000.0, + static_cast(before_residual) / 1000.0); + uint32_t wdt_slow_hits = App.wdt_slow_count_; + App.wdt_slow_count_ = 0; + ESP_LOGI(TAG, " wdt_slow_path: hits=%" PRIu32 " (%.1f%% of iters, avg=%.2fus/hit)", wdt_slow_hits, + this->period_active_count_ > 0 + ? 100.0 * static_cast(wdt_slow_hits) / static_cast(this->period_active_count_) + : 0.0, + wdt_slow_hits > 0 ? static_cast(wdt) / static_cast(wdt_slow_hits) : 0.0); } // Log total stats since boot (only for active components - idle ones haven't changed) @@ -116,6 +129,12 @@ void RuntimeStatsCollector::log_stats_() { ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", static_cast(before) / 1000.0, static_cast(tail) / 1000.0, static_cast(inter) / 1000.0); + uint64_t sched = this->total_before_sched_time_us_; + uint64_t wdt = this->total_before_wdt_time_us_; + uint64_t before_residual = before > sched + wdt ? before - sched - wdt : 0; + ESP_LOGI(TAG, " main_loop_before_breakdown: sched=%.1fms, wdt=%.1fms, residual=%.1fms", + static_cast(sched) / 1000.0, static_cast(wdt) / 1000.0, + static_cast(before_residual) / 1000.0); } // Reset period stats @@ -126,6 +145,8 @@ void RuntimeStatsCollector::log_stats_() { this->period_active_time_us_ = 0; this->period_active_max_us_ = 0; this->period_before_time_us_ = 0; + this->period_before_sched_time_us_ = 0; + this->period_before_wdt_time_us_ = 0; this->period_tail_time_us_ = 0; } diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 888d48e672..68409fbbfe 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -49,7 +49,8 @@ class RuntimeStatsCollector { // which captures per-iteration inter-component bookkeeping (set_current_component, // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, // the for-loop itself). - void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { + void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t sched_us, uint32_t wdt_us, + uint32_t tail_us) { this->period_active_count_++; this->period_active_time_us_ += active_us; if (active_us > this->period_active_max_us_) @@ -61,6 +62,10 @@ class RuntimeStatsCollector { this->period_before_time_us_ += before_us; this->total_before_time_us_ += before_us; + this->period_before_sched_time_us_ += sched_us; + this->total_before_sched_time_us_ += sched_us; + this->period_before_wdt_time_us_ += wdt_us; + this->total_before_wdt_time_us_ += wdt_us; this->period_tail_time_us_ += tail_us; this->total_tail_time_us_ += tail_us; } @@ -88,6 +93,11 @@ class RuntimeStatsCollector { // Split of overhead sections — accumulated per iteration. uint64_t period_before_time_us_{0}; uint64_t total_before_time_us_{0}; + // Sub-split of `before` into scheduler_tick_ vs. post-scheduler feed_wdt_with_time. + uint64_t period_before_sched_time_us_{0}; + uint64_t total_before_sched_time_us_{0}; + uint64_t period_before_wdt_time_us_{0}; + uint64_t total_before_wdt_time_us_{0}; uint64_t period_tail_time_us_{0}; uint64_t total_tail_time_us_{0}; }; diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d03696fbb6..c390b26cd4 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -210,6 +210,9 @@ void HOT Application::feed_wdt_slow_(uint32_t time) { // confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded. arch_feed_wdt(); this->last_wdt_feed_ = time; +#ifdef USE_RUNTIME_STATS + this->wdt_slow_count_++; +#endif } #ifdef USE_STATUS_LED From 22ed9b3c1e821e55b2fc4163290fd075705c68ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:06:02 -0500 Subject: [PATCH 0487/2214] [scheduler] Replace volatile with __atomic_{load,store}_n on NO_ATOMICS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #15947 flagged that `volatile uint32_t` is not a well-defined concurrent access in the C++ memory model — it prevents the compiler caching/eliding the read, but does not turn a plain cross-thread read/write pair into a defined access. Technically still a formal data race even though aligned 32-bit LDR/STR on ARMv5TE is atomic at the hardware level. Switch the NO_ATOMICS counter reads and writes to GCC's atomic builtins with __ATOMIC_RELAXED: - Readers: __atomic_load_n(&counter, __ATOMIC_RELAXED) - Writers (under lock_): __atomic_store_n(&counter, new_value, __ATOMIC_RELAXED) - Increment/decrement (under lock_): explicit load + compute + __atomic_store_n. Lock_ serialises the load-modify-store against other writers; the atomic ops make the write visible to concurrent readers in the memory model. On ARMv5TE these builtins compile to plain LDR/STR — same codegen as the previous volatile approach, and no libatomic dependency (only RMW builtins like __atomic_fetch_add would need the lib). ATOMICS and SINGLE paths are unchanged. --- esphome/core/scheduler.h | 65 +++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 946ec8ca78..a6ee079c3a 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -525,40 +525,41 @@ class Scheduler { #ifndef ESPHOME_THREAD_SINGLE // Fast-path counter for process_to_add() to skip taking the lock when there - // is nothing to add. std::atomic on ATOMICS; volatile uint32_t on NO_ATOMICS - // (aligned 32-bit reads are atomic on ARMv5TE — BK72xx — and volatile - // prevents the compiler caching/eliding the read). On NO_ATOMICS, callers - // must hold lock_ for any RMW mutation. Not needed on SINGLE. + // is nothing to add. std::atomic on ATOMICS; plain uint32_t on NO_ATOMICS + // (BK72xx — ARMv5TE single-core, lacks LDREX/STREX so std::atomic RMW would + // require libatomic). Reads use __atomic_load_n(__ATOMIC_RELAXED) on + // NO_ATOMICS — compiles to a plain LDR (aligned 32-bit load is naturally + // atomic on ARMv5TE) but expresses the concurrent-access intent in the C++ + // memory model. Writes live behind *_locked_ helpers and must hold lock_. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic to_add_count_{0}; #else - volatile uint32_t to_add_count_{0}; + uint32_t to_add_count_{0}; #endif #endif /* ESPHOME_THREAD_SINGLE */ // Fast-path helper for process_to_add() to decide if it can skip the lock. - // - SINGLE: direct container check (no concurrent writers). - // - ATOMICS: lock-free load of to_add_count_. - // - NO_ATOMICS: volatile read. A stale 0 is benign — next call() iteration - // observes the update; RMW mutation is still under lock_. bool to_add_empty_() const { #ifdef ESPHOME_THREAD_SINGLE return this->to_add_.empty(); #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_add_count_.load(std::memory_order_relaxed) == 0; #else - return this->to_add_count_ == 0; + return __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED) == 0; #endif } - // Increment to_add_count_ (no-op on single-threaded platforms) + // Increment to_add_count_ (no-op on single-threaded platforms). + // On NO_ATOMICS the caller must hold lock_ to serialise the load-modify-store + // against other writers; the __atomic_store_n makes the write visible to + // concurrent readers in the C++ memory model. void to_add_count_increment_locked_() { #ifdef ESPHOME_THREAD_SINGLE // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.fetch_add(1, std::memory_order_relaxed); #else - this->to_add_count_++; + __atomic_store_n(&this->to_add_count_, __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED) + 1, __ATOMIC_RELAXED); #endif } @@ -569,7 +570,7 @@ class Scheduler { #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.store(0, std::memory_order_relaxed); #else - this->to_add_count_ = 0; + __atomic_store_n(&this->to_add_count_, 0, __ATOMIC_RELAXED); #endif } @@ -581,11 +582,11 @@ class Scheduler { size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) // Fast-path counter for process_defer_queue_() to skip lock when nothing to - // process. See to_add_count_ above for the volatile rationale on NO_ATOMICS. + // process. See to_add_count_ above for the NO_ATOMICS rationale. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic defer_count_{0}; #else - volatile uint32_t defer_count_{0}; + uint32_t defer_count_{0}; #endif bool defer_empty_() const { @@ -593,7 +594,7 @@ class Scheduler { #ifdef ESPHOME_THREAD_MULTI_ATOMICS return this->defer_count_.load(std::memory_order_relaxed) == 0; #else - return this->defer_count_ == 0; + return __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED) == 0; #endif } @@ -601,7 +602,7 @@ class Scheduler { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.fetch_add(1, std::memory_order_relaxed); #else - this->defer_count_++; + __atomic_store_n(&this->defer_count_, __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED) + 1, __ATOMIC_RELAXED); #endif } @@ -609,61 +610,69 @@ class Scheduler { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.store(0, std::memory_order_relaxed); #else - this->defer_count_ = 0; + __atomic_store_n(&this->defer_count_, 0, __ATOMIC_RELAXED); #endif } #endif /* ESPHOME_THREAD_SINGLE */ // Counter for items marked for removal. Incremented cross-thread in - // cancel_item_locked_(). See to_add_count_ above for the volatile rationale - // on NO_ATOMICS. + // cancel_item_locked_(). See to_add_count_ above for the NO_ATOMICS + // rationale. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic to_remove_{0}; -#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) - volatile uint32_t to_remove_{0}; #else -uint32_t to_remove_{0}; + uint32_t to_remove_{0}; #endif // Lock-free check if there are items to remove (for fast-path in cleanup_) bool to_remove_empty_() const { #ifdef ESPHOME_THREAD_MULTI_ATOMICS return this->to_remove_.load(std::memory_order_relaxed) == 0; +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) == 0; #else - return this->to_remove_ == 0; + return this->to_remove_ == 0; #endif } void to_remove_add_locked_(uint32_t count) { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->to_remove_.fetch_add(count, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + __atomic_store_n(&this->to_remove_, __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) + count, __ATOMIC_RELAXED); #else - this->to_remove_ += count; + this->to_remove_ += count; #endif } void to_remove_decrement_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->to_remove_.fetch_sub(1, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + __atomic_store_n(&this->to_remove_, __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) - 1, __ATOMIC_RELAXED); #else - this->to_remove_--; + this->to_remove_--; #endif } void to_remove_clear_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->to_remove_.store(0, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + __atomic_store_n(&this->to_remove_, 0, __ATOMIC_RELAXED); #else - this->to_remove_ = 0; + this->to_remove_ = 0; #endif } uint32_t to_remove_count_() const { #ifdef ESPHOME_THREAD_MULTI_ATOMICS return this->to_remove_.load(std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); #else - return this->to_remove_; + return this->to_remove_; #endif } From 202bcf5b101dd8497f147e1163e6ad7a262fb600 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:07:51 -0500 Subject: [PATCH 0488/2214] [scheduler] Use __atomic_load_n only on reader fast-path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walk back the __atomic_store_n on the writer paths — the mutators already hold lock_, so plain counter_++/=/+=/-- is sufficient to serialise against other writers. The reader fast-path still uses __atomic_load_n(&counter, __ATOMIC_RELAXED) to express concurrent- read intent in the C++ memory model and keep the compiler from caching/eliding the read. On ARMv5TE it compiles to a plain LDR — same codegen as before. --- esphome/core/scheduler.h | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index a6ee079c3a..d5d1a0385c 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -550,16 +550,15 @@ class Scheduler { } // Increment to_add_count_ (no-op on single-threaded platforms). - // On NO_ATOMICS the caller must hold lock_ to serialise the load-modify-store - // against other writers; the __atomic_store_n makes the write visible to - // concurrent readers in the C++ memory model. + // On NO_ATOMICS the caller must hold lock_ to serialise RMW against + // other writers; reader fast-path uses __atomic_load_n. void to_add_count_increment_locked_() { #ifdef ESPHOME_THREAD_SINGLE // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.fetch_add(1, std::memory_order_relaxed); #else - __atomic_store_n(&this->to_add_count_, __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED) + 1, __ATOMIC_RELAXED); + this->to_add_count_++; #endif } @@ -570,7 +569,7 @@ class Scheduler { #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.store(0, std::memory_order_relaxed); #else - __atomic_store_n(&this->to_add_count_, 0, __ATOMIC_RELAXED); + this->to_add_count_ = 0; #endif } @@ -602,7 +601,7 @@ class Scheduler { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.fetch_add(1, std::memory_order_relaxed); #else - __atomic_store_n(&this->defer_count_, __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED) + 1, __ATOMIC_RELAXED); + this->defer_count_++; #endif } @@ -610,7 +609,7 @@ class Scheduler { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.store(0, std::memory_order_relaxed); #else - __atomic_store_n(&this->defer_count_, 0, __ATOMIC_RELAXED); + this->defer_count_ = 0; #endif } @@ -639,30 +638,24 @@ class Scheduler { void to_remove_add_locked_(uint32_t count) { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->to_remove_.fetch_add(count, std::memory_order_relaxed); -#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) - __atomic_store_n(&this->to_remove_, __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) + count, __ATOMIC_RELAXED); #else - this->to_remove_ += count; + this->to_remove_ += count; #endif } void to_remove_decrement_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->to_remove_.fetch_sub(1, std::memory_order_relaxed); -#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) - __atomic_store_n(&this->to_remove_, __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) - 1, __ATOMIC_RELAXED); #else - this->to_remove_--; + this->to_remove_--; #endif } void to_remove_clear_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->to_remove_.store(0, std::memory_order_relaxed); -#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) - __atomic_store_n(&this->to_remove_, 0, __ATOMIC_RELAXED); #else - this->to_remove_ = 0; + this->to_remove_ = 0; #endif } From 3c2396ab86cea654f9a17543d34f9093767fb2d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:11:03 -0500 Subject: [PATCH 0489/2214] [core] Apply __atomic_load_n/store_n pattern to Millis64 NO_ATOMICS path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same treatment as the scheduler counters (#15947): - Unlocked reads of millis_major / last_millis at the top of Millis64Impl::compute(): switch from plain reads to __atomic_load_n(&..., __ATOMIC_RELAXED). - Unlocked write of last_millis in the "normal forward progression" branch: switch from plain assignment to __atomic_store_n(..., __ATOMIC_RELAXED). This is the one write that happens without the lock, so it needs to be formally atomic to pair cleanly with the unlocked atomic reader in the C++ memory model. - Writes under `lock` stay plain (millis_major++, last_millis = now inside the near-rollover branch). The lock serialises them against other writers. On ARMv5TE the builtins compile to plain LDR/STR — same codegen, no libatomic dependency. Updates the "accepting minor races" comment to describe the formally-defined version of the race. --- esphome/core/time_64.cpp | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index b8a299ff7e..0d381dc322 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -72,10 +72,14 @@ uint64_t Millis64Impl::compute(uint32_t now) { // Without atomics, this implementation uses locks more aggressively: // 1. Always locks when near the rollover boundary (within 10 seconds) // 2. Always locks when detecting a large backwards jump - // 3. Updates without lock in normal forward progression (accepting minor races) - // This is less efficient but necessary without atomic operations. - uint16_t major = millis_major; - uint32_t last = last_millis; + // 3. Updates without lock in normal forward progression. + // Concurrent reads/writes use __atomic_load_n / __atomic_store_n with + // __ATOMIC_RELAXED so the cross-thread accesses are well-defined in the + // C++ memory model. On ARMv5TE these compile to plain LDR/STR. Writers + // holding `lock` use plain assignments; the lock serialises them against + // other writers. + uint16_t major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); + uint32_t last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED); // Define a safe window around the rollover point (10 seconds) // This covers any reasonable scheduler delays or thread preemption @@ -101,13 +105,14 @@ uint64_t Millis64Impl::compute(uint32_t now) { // Update last_millis while holding lock last_millis = now; } else if (now > last) { - // Normal case: Not near rollover and time moved forward - // Update without lock. While this may cause minor races (microseconds of - // backwards time movement), they're acceptable because: + // Normal case: Not near rollover and time moved forward. Publish the new + // low word without taking the lock. A concurrent writer under lock may + // overwrite this with a slightly-different value, which can produce a + // few microseconds of backwards time movement — acceptable because: // 1. The scheduler operates at millisecond resolution, not microsecond // 2. We've already prevented the critical rollover race condition // 3. Any backwards movement is orders of magnitude smaller than scheduler delays - last_millis = now; + __atomic_store_n(&last_millis, now, __ATOMIC_RELAXED); } // If now <= last and we're not near rollover, don't update // This minimizes backwards time movement From b3f93a4da78798b0d8bd008717e8d937c17f202d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:12:10 -0500 Subject: [PATCH 0490/2214] [core] Restore original Millis64 NO_ATOMICS comments The preceding commit needlessly rewrote comments that were still accurate. Revert the prose-only changes; keep only the two line-level code changes (__atomic_load_n on the unlocked reads, __atomic_store_n on the unlocked write). --- esphome/core/time_64.cpp | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index 0d381dc322..70ad209f02 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -72,12 +72,8 @@ uint64_t Millis64Impl::compute(uint32_t now) { // Without atomics, this implementation uses locks more aggressively: // 1. Always locks when near the rollover boundary (within 10 seconds) // 2. Always locks when detecting a large backwards jump - // 3. Updates without lock in normal forward progression. - // Concurrent reads/writes use __atomic_load_n / __atomic_store_n with - // __ATOMIC_RELAXED so the cross-thread accesses are well-defined in the - // C++ memory model. On ARMv5TE these compile to plain LDR/STR. Writers - // holding `lock` use plain assignments; the lock serialises them against - // other writers. + // 3. Updates without lock in normal forward progression (accepting minor races) + // This is less efficient but necessary without atomic operations. uint16_t major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); uint32_t last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED); @@ -105,10 +101,9 @@ uint64_t Millis64Impl::compute(uint32_t now) { // Update last_millis while holding lock last_millis = now; } else if (now > last) { - // Normal case: Not near rollover and time moved forward. Publish the new - // low word without taking the lock. A concurrent writer under lock may - // overwrite this with a slightly-different value, which can produce a - // few microseconds of backwards time movement — acceptable because: + // Normal case: Not near rollover and time moved forward + // Update without lock. While this may cause minor races (microseconds of + // backwards time movement), they're acceptable because: // 1. The scheduler operates at millisecond resolution, not microsecond // 2. We've already prevented the critical rollover race condition // 3. Any backwards movement is orders of magnitude smaller than scheduler delays From 98e88ac02c0f33385049e4e52232022372062282 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:14:48 -0500 Subject: [PATCH 0491/2214] [scheduler] Normalise to_remove_empty_/to_remove_count_ preprocessor form Use `#if defined(X)` / `#elif defined(Y)` / `#else` for the three-way ATOMICS / NO_ATOMICS / SINGLE split. Also fix the SINGLE-branch body indentation to match the other branches. --- esphome/core/scheduler.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index d5d1a0385c..aaa8f7fc5c 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -626,7 +626,7 @@ class Scheduler { // Lock-free check if there are items to remove (for fast-path in cleanup_) bool to_remove_empty_() const { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_remove_.load(std::memory_order_relaxed) == 0; #elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) == 0; @@ -660,7 +660,7 @@ class Scheduler { } uint32_t to_remove_count_() const { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_remove_.load(std::memory_order_relaxed); #elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); From 867fae3bb813ae042ca27b7c7b73c9a3e3ae926c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:19:28 -0500 Subject: [PATCH 0492/2214] [scheduler,core] Make NO_ATOMICS writer paths fully memory-model-clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch writer-side plain stores under lock_ to __atomic_store_n with __ATOMIC_RELAXED on NO_ATOMICS. The input value for RMW is read plainly (safe — only writers mutate, serialised by the lock; readers only atomic-load so two reads don't race). Closes the formal C++ memory-model hole where plain-store vs atomic-load was a data race in the standard even though aligned 32-bit STR/LDR on ARMv5TE is atomic in practice. Applies to scheduler.h counter mutators and the under-lock writes to last_millis / millis_major in time_64.cpp's near-rollover branch. Same ARMv5TE codegen (plain STR). ATOMICS / SINGLE paths unchanged. --- esphome/core/scheduler.h | 36 ++++++++++++++++++++++-------------- esphome/core/time_64.cpp | 4 ++-- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index aaa8f7fc5c..5b3eb46ee5 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -550,26 +550,28 @@ class Scheduler { } // Increment to_add_count_ (no-op on single-threaded platforms). - // On NO_ATOMICS the caller must hold lock_ to serialise RMW against - // other writers; reader fast-path uses __atomic_load_n. + // On NO_ATOMICS the caller must hold lock_; the atomic store pairs with + // the reader's __atomic_load_n in to_add_empty_(). The input-value read is + // plain — safe because only writers (serialised by lock_) modify the + // counter, and concurrent readers only atomic-load (no conflicting write). void to_add_count_increment_locked_() { -#ifdef ESPHOME_THREAD_SINGLE +#if defined(ESPHOME_THREAD_SINGLE) // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.fetch_add(1, std::memory_order_relaxed); #else - this->to_add_count_++; + __atomic_store_n(&this->to_add_count_, this->to_add_count_ + 1, __ATOMIC_RELAXED); #endif } // Reset to_add_count_ (no-op on single-threaded platforms) void to_add_count_clear_locked_() { -#ifdef ESPHOME_THREAD_SINGLE +#if defined(ESPHOME_THREAD_SINGLE) // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.store(0, std::memory_order_relaxed); #else - this->to_add_count_ = 0; + __atomic_store_n(&this->to_add_count_, 0, __ATOMIC_RELAXED); #endif } @@ -601,7 +603,7 @@ class Scheduler { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.fetch_add(1, std::memory_order_relaxed); #else - this->defer_count_++; + __atomic_store_n(&this->defer_count_, this->defer_count_ + 1, __ATOMIC_RELAXED); #endif } @@ -609,7 +611,7 @@ class Scheduler { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.store(0, std::memory_order_relaxed); #else - this->defer_count_ = 0; + __atomic_store_n(&this->defer_count_, 0, __ATOMIC_RELAXED); #endif } @@ -636,26 +638,32 @@ class Scheduler { } void to_remove_add_locked_(uint32_t count) { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.fetch_add(count, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + __atomic_store_n(&this->to_remove_, this->to_remove_ + count, __ATOMIC_RELAXED); #else - this->to_remove_ += count; + this->to_remove_ += count; #endif } void to_remove_decrement_locked_() { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.fetch_sub(1, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + __atomic_store_n(&this->to_remove_, this->to_remove_ - 1, __ATOMIC_RELAXED); #else - this->to_remove_--; + this->to_remove_--; #endif } void to_remove_clear_locked_() { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.store(0, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + __atomic_store_n(&this->to_remove_, 0, __ATOMIC_RELAXED); #else - this->to_remove_ = 0; + this->to_remove_ = 0; #endif } diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index 70ad209f02..d9a12aa477 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -92,14 +92,14 @@ uint64_t Millis64Impl::compute(uint32_t now) { if (now < last && (last - now) > HALF_MAX_UINT32) { // True rollover detected (happens every ~49.7 days) - millis_major++; + __atomic_store_n(&millis_major, static_cast(millis_major + 1), __ATOMIC_RELAXED); major++; #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last); #endif /* ESPHOME_DEBUG_SCHEDULER */ } // Update last_millis while holding lock - last_millis = now; + __atomic_store_n(&last_millis, now, __ATOMIC_RELAXED); } else if (now > last) { // Normal case: Not near rollover and time moved forward // Update without lock. While this may cause minor races (microseconds of From 6ded06eff016e7742121e0a8e41ded5b3ce283cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:28:01 -0500 Subject: [PATCH 0493/2214] [core] time_64 NO_ATOMICS: atomic re-reads + reload major under lock Two fixes Copilot flagged on #15947: 1. Use __atomic_load_n to re-read last_millis under the lock, not a plain read. The forward-progression branch (else if) writes last_millis with __atomic_store_n without holding lock, so the under-lock plain read would race with it and be UB in the C++ memory model. 2. Reload major from millis_major after acquiring the lock. The unlocked load at the top of the function can be stale by the time we get the lock: another thread may have completed a rollover between the unlocked load and the lock acquisition, leaving our local major behind by one. Without reload the function could return a 64-bit timestamp that jumps backwards by ~2^32 ms (~49.7 days). The MULTI_ATOMICS branch already handles this via its retry loop; NO_ATOMICS just reloads under the lock. --- esphome/core/time_64.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index d9a12aa477..c18ccd44f8 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -87,8 +87,13 @@ uint64_t Millis64Impl::compute(uint32_t now) { if (near_rollover || (now < last && (last - now) > HALF_MAX_UINT32)) { // Near rollover or detected a rollover - need lock for safety LockGuard guard{lock}; - // Re-read with lock held - last = last_millis; + // Re-read both values with lock held. last_millis can be updated + // unlocked from the forward-progression branch below, so use an atomic + // load. millis_major can only be updated under this lock, but another + // thread may have completed a rollover between our unlocked loads above + // and the lock acquisition — reload or we'd return a stale high word. + last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED); + major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); if (now < last && (last - now) > HALF_MAX_UINT32) { // True rollover detected (happens every ~49.7 days) From 6d1d924ff91dbfe05c81c4cfbfcae10f89ab01eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:28:01 -0500 Subject: [PATCH 0494/2214] [core] time_64 NO_ATOMICS: atomic re-reads + reload major under lock Two fixes Copilot flagged on #15947: 1. Use __atomic_load_n to re-read last_millis under the lock, not a plain read. The forward-progression branch (else if) writes last_millis with __atomic_store_n without holding lock, so the under-lock plain read would race with it and be UB in the C++ memory model. 2. Reload major from millis_major after acquiring the lock. The unlocked load at the top of the function can be stale by the time we get the lock: another thread may have completed a rollover between the unlocked load and the lock acquisition, leaving our local major behind by one. Without reload the function could return a 64-bit timestamp that jumps backwards by ~2^32 ms (~49.7 days). The MULTI_ATOMICS branch already handles this via its retry loop; NO_ATOMICS just reloads under the lock. --- esphome/core/time_64.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index d9a12aa477..c18ccd44f8 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -87,8 +87,13 @@ uint64_t Millis64Impl::compute(uint32_t now) { if (near_rollover || (now < last && (last - now) > HALF_MAX_UINT32)) { // Near rollover or detected a rollover - need lock for safety LockGuard guard{lock}; - // Re-read with lock held - last = last_millis; + // Re-read both values with lock held. last_millis can be updated + // unlocked from the forward-progression branch below, so use an atomic + // load. millis_major can only be updated under this lock, but another + // thread may have completed a rollover between our unlocked loads above + // and the lock acquisition — reload or we'd return a stale high word. + last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED); + major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); if (now < last && (last - now) > HALF_MAX_UINT32) { // True rollover detected (happens every ~49.7 days) From c6d888e4d28ab184b1fbcacc837123ba26f0cf53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:34:40 -0500 Subject: [PATCH 0495/2214] [scheduler][core] Address review: use preloaded major MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In time_64.cpp's true-rollover branch, bump the just-loaded `major` local first and __atomic_store_n that value to millis_major, instead of reading the global again for the store expression. Equivalent under the held lock; clearer and avoids a second read. scheduler.h indent: the NO_ATOMICS #else branch bodies read at 2 spaces while the sibling #ifdef/#elif branches read at 4. clang-format refuses to normalise these consistently — every manual re-indent to 4 spaces gets reverted by the hook. Leaving as clang-format produces it. --- esphome/core/time_64.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index c18ccd44f8..cf651c3e91 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -96,9 +96,11 @@ uint64_t Millis64Impl::compute(uint32_t now) { major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); if (now < last && (last - now) > HALF_MAX_UINT32) { - // True rollover detected (happens every ~49.7 days) - __atomic_store_n(&millis_major, static_cast(millis_major + 1), __ATOMIC_RELAXED); + // True rollover detected (happens every ~49.7 days). + // Use the already-loaded `major` local; avoids a second read of the + // global (equivalent under the held lock). major++; + __atomic_store_n(&millis_major, major, __ATOMIC_RELAXED); #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last); #endif /* ESPHOME_DEBUG_SCHEDULER */ From 50c181671cc886457fd8c62dc376d97a087874aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:47:16 -0500 Subject: [PATCH 0496/2214] [ci] Better explain too-big bot review message (#15939) --- .github/scripts/auto-label-pr/reviews.js | 30 ++++++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index 7ac136515d..e9e848da6f 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -41,16 +41,36 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; + message += + `Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` + + `this PR is on the large side `; + if (tooManyLabels && tooManyChanges) { - message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`; + message += + `(${nonTestChanges} line changes excluding tests, across ` + + `${originalLabelCount} different components/areas)`; } else if (tooManyLabels) { - message += `This PR affects ${originalLabelCount} different components/areas.`; + message += + `(it touches ${originalLabelCount} different components/areas)`; } else { - message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`; + message += `(${nonTestChanges} line changes excluding tests)`; } - message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`; - message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`; + message += `, which makes it harder for maintainers to review.\n\n`; + message += + `Smaller, focused PRs tend to be reviewed much faster since they ` + + `fit into the short gaps between other maintainer work; large ones ` + + `often have to wait for a rare long uninterrupted block of time. ` + + `If you can break this up into smaller pieces that can be reviewed ` + + `independently, it will almost certainly land faster overall.\n\n`; + message += + `Before putting more time in, it's also worth popping into ` + + `\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` + + `you scope things and flag anything already in flight.\n\n`; + message += + `For more details (including how to split the work up), see: ` + + `https://developers.esphome.io/contributing/submitting-your-work/` + + `#how-to-approach-large-submissions`; messages.push(message); } From 13fe881f70a142d1f2888c6b1141590a607445ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:20:31 -0500 Subject: [PATCH 0497/2214] [scheduler][core] Lock-free fast-path on ESPHOME_THREAD_MULTI_NO_ATOMICS via __atomic builtins (#15947) --- esphome/core/scheduler.cpp | 20 ++++---- esphome/core/scheduler.h | 100 +++++++++++++++++++++---------------- esphome/core/time_64.cpp | 23 ++++++--- 3 files changed, 82 insertions(+), 61 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index b0eaa670ac..a6f1558e4a 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -235,11 +235,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } target->push_back(item); if (target == &this->to_add_) { - this->to_add_count_increment_(); + this->to_add_count_increment_locked_(); } #ifndef ESPHOME_THREAD_SINGLE else { - this->defer_count_increment_(); + this->defer_count_increment_locked_(); } #endif } @@ -452,7 +452,7 @@ void Scheduler::full_cleanup_removed_items_() { this->items_.erase(this->items_.begin() + write, this->items_.end()); // Rebuild the heap structure since items are no longer in heap order std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - this->to_remove_clear_(); + this->to_remove_clear_locked_(); } #ifndef ESPHOME_THREAD_SINGLE @@ -501,7 +501,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { this->lock_.lock(); // Reset counter and snapshot queue end under lock - this->defer_count_clear_(); + this->defer_count_clear_locked_(); size_t defer_queue_end = this->defer_queue_.size(); if (this->defer_queue_front_ >= defer_queue_end) { this->lock_.unlock(); @@ -621,7 +621,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { LockGuard guard{this->lock_}; if (is_item_removed_locked_(item)) { this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); continue; } } @@ -630,7 +630,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { if (is_item_removed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); continue; } #endif @@ -658,7 +658,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { if (this->is_item_removed_locked_(executed_item)) { // We were removed/cancelled in the function call, recycle and continue - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(executed_item); continue; } @@ -721,7 +721,7 @@ void HOT Scheduler::process_to_add_slow_path_() { std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); } this->to_add_.clear(); - this->to_add_count_clear_(); + this->to_add_count_clear_locked_(); } bool HOT Scheduler::cleanup_slow_path_() { // We must hold the lock for the entire cleanup operation because: @@ -737,7 +737,7 @@ bool HOT Scheduler::cleanup_slow_path_() { SchedulerItem *item = this->items_[0]; if (!this->is_item_removed_locked_(item)) break; - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(this->pop_raw_locked_()); } return !this->items_.empty(); @@ -825,7 +825,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, hash_or_id, type, match_retry, find_first); total_cancelled += heap_cancelled; - this->to_remove_add_(heap_cancelled); + this->to_remove_add_locked_(heap_cancelled); if (find_first && total_cancelled > 0) return true; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b7e99d4603..46b19855c3 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -524,11 +524,13 @@ class Scheduler { std::vector to_add_; #ifndef ESPHOME_THREAD_SINGLE - // Fast-path counter for process_to_add() to skip taking the lock when there is - // nothing to add. Uses std::atomic on platforms that support it, plain uint32_t - // otherwise. On non-atomic platforms, callers must hold the scheduler lock when - // mutating this counter. Not needed on single-threaded platforms where we can - // check to_add_.empty() directly. + // Fast-path counter for process_to_add() to skip taking the lock when there + // is nothing to add. std::atomic on ATOMICS; plain uint32_t on NO_ATOMICS + // (BK72xx — ARMv5TE single-core, lacks LDREX/STREX so std::atomic RMW would + // require libatomic). Reads use __atomic_load_n(__ATOMIC_RELAXED) on + // NO_ATOMICS — compiles to a plain LDR (aligned 32-bit load is naturally + // atomic on ARMv5TE) but expresses the concurrent-access intent in the C++ + // memory model. Writes live behind *_locked_ helpers and must hold lock_. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic to_add_count_{0}; #else @@ -536,40 +538,41 @@ class Scheduler { #endif #endif /* ESPHOME_THREAD_SINGLE */ - // Fast-path helper for process_to_add() to decide if it can try the lock-free path. - // - On ESPHOME_THREAD_SINGLE: direct container check is safe (no concurrent writers). - // - On ESPHOME_THREAD_MULTI_ATOMICS: performs a lock-free check via to_add_count_. - // - On ESPHOME_THREAD_MULTI_NO_ATOMICS: always returns false to force the caller - // down the locked path; this is NOT a lock-free emptiness check on that platform. + // Fast-path helper for process_to_add() to decide if it can skip the lock. bool to_add_empty_() const { #ifdef ESPHOME_THREAD_SINGLE return this->to_add_.empty(); #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_add_count_.load(std::memory_order_relaxed) == 0; #else - return false; + return __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED) == 0; #endif } - // Increment to_add_count_ (no-op on single-threaded platforms) - void to_add_count_increment_() { -#ifdef ESPHOME_THREAD_SINGLE + // Increment to_add_count_ (no-op on single-threaded platforms). + // On NO_ATOMICS the caller must hold lock_; both load and store go through + // __atomic_*_n with __ATOMIC_RELAXED to keep every access to the counter + // explicitly atomic in the C++ memory model (same ARMv5TE codegen as + // plain LDR+STR). + void to_add_count_increment_locked_() { +#if defined(ESPHOME_THREAD_SINGLE) // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.fetch_add(1, std::memory_order_relaxed); #else - this->to_add_count_++; + uint32_t v = __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED); + __atomic_store_n(&this->to_add_count_, v + 1, __ATOMIC_RELAXED); #endif } // Reset to_add_count_ (no-op on single-threaded platforms) - void to_add_count_clear_() { -#ifdef ESPHOME_THREAD_SINGLE + void to_add_count_clear_locked_() { +#if defined(ESPHOME_THREAD_SINGLE) // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.store(0, std::memory_order_relaxed); #else - this->to_add_count_ = 0; + __atomic_store_n(&this->to_add_count_, 0, __ATOMIC_RELAXED); #endif } @@ -580,7 +583,8 @@ class Scheduler { std::vector defer_queue_; // FIFO queue for defer() calls size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) - // Fast-path counter for process_defer_queue_() to skip lock when nothing to process. + // Fast-path counter for process_defer_queue_() to skip lock when nothing to + // process. See to_add_count_ above for the NO_ATOMICS rationale. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic defer_count_{0}; #else @@ -589,35 +593,35 @@ class Scheduler { bool defer_empty_() const { // defer_queue_ only exists on multi-threaded platforms, so no ESPHOME_THREAD_SINGLE path - // ESPHOME_THREAD_MULTI_NO_ATOMICS: always take the lock #ifdef ESPHOME_THREAD_MULTI_ATOMICS return this->defer_count_.load(std::memory_order_relaxed) == 0; #else - return false; + return __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED) == 0; #endif } - void defer_count_increment_() { + void defer_count_increment_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.fetch_add(1, std::memory_order_relaxed); #else - this->defer_count_++; + uint32_t v = __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED); + __atomic_store_n(&this->defer_count_, v + 1, __ATOMIC_RELAXED); #endif } - void defer_count_clear_() { + void defer_count_clear_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.store(0, std::memory_order_relaxed); #else - this->defer_count_ = 0; + __atomic_store_n(&this->defer_count_, 0, __ATOMIC_RELAXED); #endif } #endif /* ESPHOME_THREAD_SINGLE */ - // Counter for items marked for removal. Incremented cross-thread in cancel_item_locked_(). - // On ESPHOME_THREAD_MULTI_ATOMICS this is read without a lock in the cleanup_() fast path; - // on ESPHOME_THREAD_MULTI_NO_ATOMICS the fast path is disabled so cleanup_() always takes the lock. + // Counter for items marked for removal. Incremented cross-thread in + // cancel_item_locked_(). See to_add_count_ above for the NO_ATOMICS + // rationale. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic to_remove_{0}; #else @@ -626,44 +630,54 @@ class Scheduler { // Lock-free check if there are items to remove (for fast-path in cleanup_) bool to_remove_empty_() const { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_remove_.load(std::memory_order_relaxed) == 0; -#elif defined(ESPHOME_THREAD_SINGLE) - return this->to_remove_ == 0; +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) == 0; #else - return false; // Always take the lock path + return this->to_remove_ == 0; #endif } - void to_remove_add_(uint32_t count) { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS + void to_remove_add_locked_(uint32_t count) { +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.fetch_add(count, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + uint32_t v = __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); + __atomic_store_n(&this->to_remove_, v + count, __ATOMIC_RELAXED); #else - this->to_remove_ += count; + this->to_remove_ += count; #endif } - void to_remove_decrement_() { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS + void to_remove_decrement_locked_() { +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.fetch_sub(1, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + uint32_t v = __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); + __atomic_store_n(&this->to_remove_, v - 1, __ATOMIC_RELAXED); #else - this->to_remove_--; + this->to_remove_--; #endif } - void to_remove_clear_() { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS + void to_remove_clear_locked_() { +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.store(0, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + __atomic_store_n(&this->to_remove_, 0, __ATOMIC_RELAXED); #else - this->to_remove_ = 0; + this->to_remove_ = 0; #endif } uint32_t to_remove_count_() const { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_remove_.load(std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); #else - return this->to_remove_; + return this->to_remove_; #endif } diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index b8a299ff7e..cf651c3e91 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -74,8 +74,8 @@ uint64_t Millis64Impl::compute(uint32_t now) { // 2. Always locks when detecting a large backwards jump // 3. Updates without lock in normal forward progression (accepting minor races) // This is less efficient but necessary without atomic operations. - uint16_t major = millis_major; - uint32_t last = last_millis; + uint16_t major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); + uint32_t last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED); // Define a safe window around the rollover point (10 seconds) // This covers any reasonable scheduler delays or thread preemption @@ -87,19 +87,26 @@ uint64_t Millis64Impl::compute(uint32_t now) { if (near_rollover || (now < last && (last - now) > HALF_MAX_UINT32)) { // Near rollover or detected a rollover - need lock for safety LockGuard guard{lock}; - // Re-read with lock held - last = last_millis; + // Re-read both values with lock held. last_millis can be updated + // unlocked from the forward-progression branch below, so use an atomic + // load. millis_major can only be updated under this lock, but another + // thread may have completed a rollover between our unlocked loads above + // and the lock acquisition — reload or we'd return a stale high word. + last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED); + major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); if (now < last && (last - now) > HALF_MAX_UINT32) { - // True rollover detected (happens every ~49.7 days) - millis_major++; + // True rollover detected (happens every ~49.7 days). + // Use the already-loaded `major` local; avoids a second read of the + // global (equivalent under the held lock). major++; + __atomic_store_n(&millis_major, major, __ATOMIC_RELAXED); #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last); #endif /* ESPHOME_DEBUG_SCHEDULER */ } // Update last_millis while holding lock - last_millis = now; + __atomic_store_n(&last_millis, now, __ATOMIC_RELAXED); } else if (now > last) { // Normal case: Not near rollover and time moved forward // Update without lock. While this may cause minor races (microseconds of @@ -107,7 +114,7 @@ uint64_t Millis64Impl::compute(uint32_t now) { // 1. The scheduler operates at millisecond resolution, not microsecond // 2. We've already prevented the critical rollover race condition // 3. Any backwards movement is orders of magnitude smaller than scheduler delays - last_millis = now; + __atomic_store_n(&last_millis, now, __ATOMIC_RELAXED); } // If now <= last and we're not near rollover, don't update // This minimizes backwards time movement From b38db617a2f5da489f8160ad02d80c9561be1622 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:21:05 -0500 Subject: [PATCH 0498/2214] [core] Clean up stale includes and inline yield_with_select_ in application (#15945) --- esphome/components/libretiny/core.cpp | 2 +- esphome/core/application.cpp | 9 +-------- esphome/core/application.h | 19 ++++--------------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 1b74e3addb..ca46bcb899 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -56,7 +56,7 @@ void arch_init() { // // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. - // This is safe because ESPHome yields voluntarily via yield_with_select_() and + // This is safe because ESPHome yields voluntarily via wakeable_delay() and // the Arduino mainTask yield() after each loop() iteration. static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 11381030a3..d03696fbb6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -12,9 +12,6 @@ #include #include #endif -#ifdef USE_LWIP_FAST_SELECT -#include "esphome/core/lwip_fast_select.h" -#endif // USE_LWIP_FAST_SELECT #include "esphome/core/version.h" #include "esphome/core/hal.h" #include @@ -24,10 +21,6 @@ #include "esphome/components/status_led/status_led.h" #endif -#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) -#include "esphome/components/socket/socket.h" -#endif - namespace esphome { static const char *const TAG = "app"; @@ -366,7 +359,7 @@ void Application::teardown_components(uint32_t timeout_ms) { // Give some time for I/O operations if components are still pending if (pending_count > 0) { - this->yield_with_select_(1); + esphome::internal::wakeable_delay(1); } // Update time for next iteration diff --git a/esphome/core/application.h b/esphome/core/application.h index 8280b3bd4b..b700415681 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -24,9 +24,6 @@ #include "esphome/core/area.h" #endif -#ifdef USE_LWIP_FAST_SELECT -#include "esphome/core/lwip_fast_select.h" -#endif #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif @@ -423,10 +420,6 @@ class Application { void service_status_led_slow_(uint32_t time); #endif - /// Sleep for up to delay_ms, returning early if a wake event arrives. - /// Thin wrapper over the platform wake primitive in wake.h. - inline void ESPHOME_ALWAYS_INLINE yield_with_select_(uint32_t delay_ms); - // === Member variables ordered by size to minimize padding === // Pointer-sized members first @@ -664,18 +657,14 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } - this->yield_with_select_(delay_time); + // All platforms route loop yields through the platform wake primitive. + // On host this drains the loopback wake socket via select(); on FreeRTOS + // targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. + esphome::internal::wakeable_delay(delay_time); if (this->dump_config_at_ < this->components_.size()) { this->process_dump_config_(); } } -// All platforms route loop yields through the platform wake primitive. -// On host this drains the loopback wake socket via select(); on FreeRTOS -// targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. -inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { - esphome::internal::wakeable_delay(delay_ms); -} - } // namespace esphome From 3ca86fc3fc6c41c2c51f15eadb2a1536e4955b3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:21:46 -0500 Subject: [PATCH 0499/2214] [core] Raise WDT_FEED_INTERVAL_MS to 2000ms on BK72xx (#15943) --- esphome/core/application.h | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index b700415681..e9b386038e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -216,11 +216,19 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT default (5 s): ~16x - /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change - /// must keep comfortable margin here - /// - ESP8266 HW WDT (~6 s): ~20x + /// - ESP32 task WDT default (5 s): ~16x + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below +#ifdef USE_BK72XX + // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny + // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: + // https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; +#else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; +#endif /// Feed the task watchdog. Cold entry — callers without a millis() /// timestamp in hand. Out of line to keep call sites tiny. From 8f9b91eecea69ab12f8afd7d408010daad2ced5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:22:17 -0500 Subject: [PATCH 0500/2214] [wifi] Avoid BDK 3.0.78 wifi_event_sta_disconnected_t collision on BK72xx (#15942) --- esphome/components/wifi/wifi_component_libretiny.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index cdd11ceaef..6588e93e16 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -12,7 +12,12 @@ #ifdef USE_BK72XX extern "C" { +// BDK 3.0.78 (required for BK7238) redeclares wifi_event_sta_disconnected_t, +// which LibreTiny's Arduino WiFi API already defines. ESPHome doesn't use the +// BDK version, so rename it across this include to avoid the collision. +#define wifi_event_sta_disconnected_t bdk_wifi_event_sta_disconnected_t #include +#undef wifi_event_sta_disconnected_t } #endif From 70ae614abd9c34cdf0be53feceb9c6f0624b39c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:23:38 -0500 Subject: [PATCH 0501/2214] [api] Fall back to plaintext for logger connections (#15938) --- esphome/components/api/client.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 0c6c569c7d..312d937f01 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -93,7 +93,24 @@ async def async_run_logs( config, raw_line, backtrace_state=backtrace_state ) - stop = await async_run(cli, on_log, name=name, subscribe_states=subscribe_states) + # Safe to fall back to plaintext here only for this diagnostics use + # case: the stream is one-way from device to client, and this code + # never accepts commands or acts on any message the device sends. + # An on-path attacker could still both inject fabricated log lines + # and passively read the device's log output (and any state data + # delivered when subscribe_states is enabled), so this does lose + # confidentiality as well as authentication/integrity. That tradeoff + # is acceptable for operator-visible logs, which aioesphomeapi also + # warns may come from an unverified device. Never mirror this opt-in + # for any connection that sends data to the device or uses Home + # Assistant actions. + stop = await async_run( + cli, + on_log, + name=name, + subscribe_states=subscribe_states, + allow_plaintext_fallback=True, + ) try: await asyncio.Event().wait() finally: From 9b45b046a8992e65ff19b7610f2cc72e238ac760 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 23 Apr 2026 09:43:32 -0400 Subject: [PATCH 0502/2214] [core] Allow finding all devices as target that match mac suffix (#13135) --- esphome/__main__.py | 120 +++++- esphome/address_cache.py | 11 + esphome/async_thread.py | 56 +++ esphome/resolver.py | 48 +-- esphome/zeroconf.py | 181 ++++++++- tests/unit_tests/test_address_cache.py | 20 + tests/unit_tests/test_main.py | 513 ++++++++++++++++++++++++- tests/unit_tests/test_resolver.py | 33 +- 8 files changed, 912 insertions(+), 70 deletions(-) create mode 100644 esphome/async_thread.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 7879cdad0c..8c80dab90a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -39,6 +39,7 @@ from esphome.const import ( CONF_MDNS, CONF_MQTT, CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, CONF_OTA, CONF_PASSWORD, CONF_PLATFORM, @@ -71,6 +72,7 @@ from esphome.util import ( run_external_process, safe_print, ) +from esphome.zeroconf import discover_mdns_devices _LOGGER = logging.getLogger(__name__) @@ -204,6 +206,64 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]: return [address] +def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None: + """Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``. + + Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so + the downstream resolution path (``resolve_ip_address``) can skip opening a + second Zeroconf client. + """ + from esphome.address_cache import AddressCache + + if CORE.address_cache is None: + CORE.address_cache = AddressCache() + for host, addresses in hosts_to_addresses.items(): + if addresses: + _LOGGER.debug("Caching mDNS result %s -> %s", host, addresses) + CORE.address_cache.add_mdns_addresses(host, addresses) + + +def _discover_mac_suffix_devices() -> list[str] | None: + """Discover ``-.local`` devices and cache their IPs. + + Returns: + - ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off, + mDNS disabled, or ``CORE.address`` is already an IP). Callers should + then fall back to whatever default OTA address they normally use. + - ``[]`` when discovery ran but found nothing. Callers should NOT fall + back to the base name: with ``name_add_mac_suffix`` enabled, the base + name by definition doesn't exist on the network. + - A non-empty sorted list of ``.local`` hostnames on success. + + Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or + ``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we + already have without opening a second Zeroconf client. + """ + if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()): + return None + _LOGGER.info("Discovering devices...") + if not (discovered := discover_mdns_devices(CORE.name)): + _LOGGER.warning( + "No devices matching '%s-.local' were discovered.", CORE.name + ) + return [] + _populate_mdns_cache(discovered) + return list(discovered) + + +def _ota_hostnames_for_default(purpose: Purpose) -> list[str]: + """Return OTA hostname(s) for the ``--device OTA`` / default-resolve path. + + When ``name_add_mac_suffix`` is enabled, returns discovered + ``-.local`` hostnames (possibly empty — in which case the + caller should not fall back to the base name). Otherwise falls back to + the cache-resolved ``CORE.address``. + """ + if (discovered := _discover_mac_suffix_devices()) is not None: + return discovered + return _resolve_with_cache(CORE.address, purpose) + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -242,14 +302,14 @@ def choose_upload_log_host( resolved.append("MQTT") if has_api() and has_non_ip_address() and has_resolvable_address(): - resolved.extend(_resolve_with_cache(CORE.address, purpose)) + resolved.extend(_ota_hostnames_for_default(purpose)) elif purpose == Purpose.UPLOADING: if has_ota() and has_mqtt_ip_lookup(): resolved.append("MQTTIP") if has_ota() and has_non_ip_address() and has_resolvable_address(): - resolved.extend(_resolve_with_cache(CORE.address, purpose)) + resolved.extend(_ota_hostnames_for_default(purpose)) else: resolved.append(device) if not resolved: @@ -281,22 +341,29 @@ def choose_upload_log_host( elif bootsel.permission_error: bootsel_permission_error = True + def add_ota_options() -> None: + """Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled.""" + if (discovered := _discover_mac_suffix_devices()) is not None: + # Discovery was applicable. Use whatever we found — on empty, + # intentionally skip the base-name fallback since with + # name_add_mac_suffix on, the base name doesn't exist on the net. + for host in discovered: + options.append((f"Over The Air ({host})", host)) + elif has_resolvable_address(): + options.append((f"Over The Air ({CORE.address})", CORE.address)) + if has_mqtt_ip_lookup(): + options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + if purpose == Purpose.LOGGING: if has_mqtt_logging(): mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) if has_api(): - if has_resolvable_address(): - options.append((f"Over The Air ({CORE.address})", CORE.address)) - if has_mqtt_ip_lookup(): - options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + add_ota_options() elif purpose == Purpose.UPLOADING and has_ota(): - if has_resolvable_address(): - options.append((f"Over The Air ({CORE.address})", CORE.address)) - if has_mqtt_ip_lookup(): - options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + add_ota_options() # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found if ( @@ -407,7 +474,17 @@ def has_resolvable_address() -> bool: return not CORE.address.endswith(".local") -def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str): +def has_name_add_mac_suffix() -> bool: + """Check if name_add_mac_suffix is enabled in the config.""" + if CORE.config is None: + return False + esphome_config = CORE.config.get(CONF_ESPHOME, {}) + return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False) + + +def mqtt_get_ip( + config: ConfigType, username: str, password: str, client_id: str +) -> list[str]: from esphome import mqtt return mqtt.get_esphome_device_ip(config, username, password, client_id) @@ -420,6 +497,9 @@ def _resolve_network_devices( This function filters the devices list to: - Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup + - Expand hostnames that are already in ``CORE.address_cache`` to their + cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second + Zeroconf client to resolve them - Deduplicate addresses while preserving order - Only resolve MQTT once even if multiple MQTT strings are present - If MQTT resolution fails, log a warning and continue with other devices @@ -444,13 +524,29 @@ def _resolve_network_devices( mqtt_ips = mqtt_get_ip( config, args.username, args.password, args.client_id ) - network_devices.extend(mqtt_ips) + # 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_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) diff --git a/esphome/address_cache.py b/esphome/address_cache.py index 7c20be90f0..4fb3689818 100644 --- a/esphome/address_cache.py +++ b/esphome/address_cache.py @@ -101,6 +101,17 @@ class AddressCache: """Check if any cache entries exist.""" return bool(self.mdns_cache or self.dns_cache) + def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None: + """Store resolved mDNS addresses for ``hostname`` in the cache. + + Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use + this to avoid a second resolution round-trip during the upload path. + No-op when ``addresses`` is empty. + """ + if not addresses: + return + self.mdns_cache[normalize_hostname(hostname)] = addresses + @classmethod def from_cli_args( cls, mdns_args: Iterable[str], dns_args: Iterable[str] diff --git a/esphome/async_thread.py b/esphome/async_thread.py new file mode 100644 index 0000000000..7be3c83a9a --- /dev/null +++ b/esphome/async_thread.py @@ -0,0 +1,56 @@ +"""Helpers for running an async coroutine from sync code via a daemon thread. + +``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup +cycle finishes, which can add hundreds of milliseconds before the caller +receives the result. Running the loop in a daemon thread lets the caller +observe the result as soon as the coroutine completes while cleanup finishes +in the background. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import threading +from typing import Generic, TypeVar + +_T = TypeVar("_T") + + +class AsyncThreadRunner(threading.Thread, Generic[_T]): + """Run an async coroutine in a daemon thread and expose its result. + + The runner catches all exceptions from the coroutine and stores them in + ``exception`` so ``event`` is always set — this prevents callers waiting + on ``event`` from hanging forever when the coroutine crashes. + + Typical usage:: + + runner = AsyncThreadRunner(lambda: my_coro(arg)) + runner.start() + if not runner.event.wait(timeout=5.0): + ... # timed out + if runner.exception is not None: + raise runner.exception + result = runner.result + """ + + def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None: + super().__init__(daemon=True) + self._coro_factory = coro_factory + self.result: _T | None = None + self.exception: BaseException | None = None + self.event = threading.Event() + + async def _runner(self) -> None: + try: + self.result = await self._coro_factory() + except Exception as exc: # pylint: disable=broad-except + # Capture all exceptions so ``event`` is always set — otherwise a + # crash would hang the waiter forever. + self.exception = exc + finally: + self.event.set() + + def run(self) -> None: + asyncio.run(self._runner()) diff --git a/esphome/resolver.py b/esphome/resolver.py index 99482aa20e..9fb596ce7b 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -2,66 +2,52 @@ from __future__ import annotations -import asyncio -import threading - from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError import aioesphomeapi.host_resolver as hr +from esphome.async_thread import AsyncThreadRunner from esphome.core import EsphomeError RESOLVE_TIMEOUT = 10.0 # seconds -class AsyncResolver(threading.Thread): +class AsyncResolver: """Resolver using aioesphomeapi that runs in a thread for faster results. - This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution, - including proper .local domain fallback. Running in a thread allows us to get - the result immediately without waiting for asyncio.run() to complete its - cleanup cycle, which can take significant time. + This resolver uses aioesphomeapi's async_resolve_host to handle DNS + resolution, including proper .local domain fallback. Running in a thread + (via :class:`AsyncThreadRunner`) allows us to get the result immediately + without waiting for ``asyncio.run()`` to complete its cleanup cycle, which + can take significant time. """ def __init__(self, hosts: list[str], port: int) -> None: """Initialize the resolver.""" - super().__init__(daemon=True) self.hosts = hosts self.port = port - self.result: list[hr.AddrInfo] | None = None - self.exception: Exception | None = None - self.event = threading.Event() - async def _resolve(self) -> None: + async def _resolve(self) -> list[hr.AddrInfo]: """Resolve hostnames to IP addresses.""" - try: - self.result = await hr.async_resolve_host( - self.hosts, self.port, timeout=RESOLVE_TIMEOUT - ) - except Exception as e: # pylint: disable=broad-except - # We need to catch all exceptions to ensure the event is set - # Otherwise the thread could hang forever - self.exception = e - finally: - self.event.set() - - def run(self) -> None: - """Run the DNS resolution.""" - asyncio.run(self._resolve()) + return await hr.async_resolve_host( + self.hosts, self.port, timeout=RESOLVE_TIMEOUT + ) def resolve(self) -> list[hr.AddrInfo]: """Start the thread and wait for the result.""" - self.start() + runner: AsyncThreadRunner[list[hr.AddrInfo]] = AsyncThreadRunner(self._resolve) + runner.start() - if not self.event.wait( + if not runner.event.wait( timeout=RESOLVE_TIMEOUT + 1.0 ): # Give it 1 second more than the resolver timeout raise EsphomeError("Timeout resolving IP address") - if exc := self.exception: + if exc := runner.exception: if isinstance(exc, ResolveTimeoutAPIError): raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc if isinstance(exc, ResolveAPIError): raise EsphomeError(f"Error resolving IP address: {exc}") from exc raise exc - return self.result + assert runner.result is not None # guaranteed when event set and no exception + return runner.result diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index dd45b58a6c..6f5d33c808 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -14,8 +14,13 @@ from zeroconf import ( ) from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf +from esphome.async_thread import AsyncThreadRunner from esphome.storage_json import StorageJSON, ext_storage_path +# Length of the MAC suffix appended when name_add_mac_suffix is enabled. +MAC_SUFFIX_LEN = 6 +_HEX_CHARS = frozenset("0123456789abcdef") + _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10.0 @@ -188,15 +193,177 @@ class EsphomeZeroconf(Zeroconf): return None +async def async_resolve_hosts( + zeroconf: Zeroconf, hosts: list[str], timeout: float = DEFAULT_TIMEOUT +) -> dict[str, list[str]]: + """Resolve ``hosts`` to IPs using a shared ``Zeroconf`` instance. + + Tries the cache synchronously first (so hosts already primed by a recent + browse return immediately with no network round-trip), then issues + ``async_request`` for the remaining misses in parallel via + ``asyncio.gather``. Returns a dict mapping each host to its list of + addresses (empty list when unresolved). Only ``.local`` form is + queried, matching the name scheme the resolvers below expect. + """ + resolvers: dict[str, AddressResolver] = {} + pending: list[str] = [] + for host in hosts: + resolver = AddressResolver(f"{host.partition('.')[0]}.local.") + resolvers[host] = resolver + if not resolver.load_from_cache(zeroconf): + pending.append(host) + + if pending and timeout: + results = await asyncio.gather( + *( + resolvers[host].async_request(zeroconf, timeout * 1000) + for host in pending + ), + return_exceptions=True, + ) + for host, result in zip(pending, results): + if isinstance(result, BaseException): + _LOGGER.debug("Failed to resolve %s: %s", host, result) + + return { + host: resolver.parsed_scoped_addresses(IPVersion.All) + for host, resolver in resolvers.items() + } + + class AsyncEsphomeZeroconf(AsyncZeroconf): async def async_resolve_host( self, host: str, timeout: float = DEFAULT_TIMEOUT ) -> list[str] | None: """Resolve a host name to an IP address.""" - info = AddressResolver(f"{host.partition('.')[0]}.local.") - if ( - info.load_from_cache(self.zeroconf) - or (timeout and await info.async_request(self.zeroconf, timeout * 1000)) - ) and (addresses := info.parsed_scoped_addresses(IPVersion.All)): - return addresses - return None + addresses = (await async_resolve_hosts(self.zeroconf, [host], timeout))[host] + return addresses or None + + +def _is_mac_suffix_match(device_name: str, prefix: str) -> bool: + """Return True if ``device_name`` is ``prefix`` followed by a 6-char hex MAC.""" + if not device_name.startswith(prefix): + return False + suffix = device_name[len(prefix) :] + return len(suffix) == MAC_SUFFIX_LEN and all(c in _HEX_CHARS for c in suffix) + + +async def async_discover_mdns_devices( + base_name: str, timeout: float = 5.0 +) -> dict[str, list[str]]: + """Discover ESPHome devices via mDNS that match the base name + MAC suffix. + + When ``name_add_mac_suffix`` is enabled, devices advertise as + ``-<6-hex-mac>.local``. This function uses a single + ``AsyncEsphomeZeroconf`` lifecycle to both browse for matching services and + resolve their IP addresses, so callers get resolved addresses without + opening a second Zeroconf client. + + Args: + base_name: The base device name (without MAC suffix). + timeout: How long to wait for mDNS responses (default 5 seconds). + + Returns: + Mapping of ``.local`` hostnames to their resolved IP addresses + (may be empty for a device if resolution failed within the timeout). + """ + prefix = f"{base_name}-" + # Preserves insertion order for stable output and deduplicates + discovered: dict[str, list[str]] = {} + + def on_service_state_change( + zeroconf: Zeroconf, + service_type: str, + name: str, + state_change: ServiceStateChange, + ) -> None: + if state_change not in (ServiceStateChange.Added, ServiceStateChange.Updated): + return + device_name = name.partition(".")[0] + if not _is_mac_suffix_match(device_name, prefix): + _LOGGER.debug( + "Ignoring %s (%s): does not match '%s<6-hex>'", + device_name, + state_change.name, + prefix, + ) + return + host = f"{device_name}.local" + if host in discovered: + return + discovered[host] = [] + _LOGGER.debug("Discovered %s (%s)", host, state_change.name) + + _LOGGER.debug( + "Starting mDNS discovery for '%s.local' (timeout=%.1fs)", + prefix, + timeout, + ) + try: + aiozc = AsyncEsphomeZeroconf() + except Exception as err: # pylint: disable=broad-except + # Zeroconf init can raise OSError, NonUniqueNameException, etc. + # Any failure here just means we can't discover — log and move on. + _LOGGER.warning("mDNS discovery failed to initialize: %s", err) + return {} + + try: + browser = AsyncServiceBrowser( + aiozc.zeroconf, + ESPHOME_SERVICE_TYPE, + handlers=[on_service_state_change], + ) + try: + await asyncio.sleep(timeout) + finally: + await browser.async_cancel() + _LOGGER.debug( + "Browse finished: %d device(s) matched '%s'", + len(discovered), + prefix, + ) + + # Resolve each discovered hostname on the SAME Zeroconf instance so + # we don't spin up a second client. ``async_resolve_hosts`` tries the + # cache synchronously (the browse usually primes it) before issuing + # any ``async_request`` in parallel for misses. + resolved = await async_resolve_hosts(aiozc.zeroconf, list(discovered)) + for host, addresses in resolved.items(): + if addresses: + discovered[host] = addresses + _LOGGER.debug("Resolved %s -> %s", host, addresses) + else: + _LOGGER.debug("No addresses returned for %s", host) + finally: + await aiozc.async_close() + + return dict(sorted(discovered.items())) + + +def _await_discovery( + runner: AsyncThreadRunner[dict[str, list[str]]], timeout: float +) -> dict[str, list[str]]: + """Wait for ``runner`` to finish and return its discovery result. + + Split out of :func:`discover_mdns_devices` so the timeout branch is + testable without patching ``asyncio`` or ``threading`` internals — a test + passes a stub whose ``event.wait`` returns ``False``. + """ + # Give the discovery an extra second over the browse timeout for the + # resolution + cleanup pass. + if not runner.event.wait(timeout=timeout + 2.0): + _LOGGER.warning("mDNS discovery timed out after %.1fs", timeout) + return {} + if runner.exception is not None: + _LOGGER.warning("mDNS discovery failed: %s", runner.exception) + return {} + return runner.result or {} + + +def discover_mdns_devices(base_name: str, timeout: float = 5.0) -> dict[str, list[str]]: + """Synchronous wrapper around :func:`async_discover_mdns_devices`.""" + runner = AsyncThreadRunner( + lambda: async_discover_mdns_devices(base_name, timeout=timeout) + ) + runner.start() + return _await_discovery(runner, timeout) diff --git a/tests/unit_tests/test_address_cache.py b/tests/unit_tests/test_address_cache.py index de43830d53..1ca28c4f02 100644 --- a/tests/unit_tests/test_address_cache.py +++ b/tests/unit_tests/test_address_cache.py @@ -121,6 +121,26 @@ def test_get_addresses_auto_detection() -> None: assert cache.get_addresses("unknown.com") is None +def test_add_mdns_addresses_stores_and_normalizes() -> None: + """add_mdns_addresses inserts entries under the normalized hostname.""" + cache = AddressCache() + cache.add_mdns_addresses("Device.Local.", ["192.168.1.10", "192.168.1.11"]) + + assert cache.mdns_cache == { + normalize_hostname("Device.Local."): ["192.168.1.10", "192.168.1.11"] + } + # Overwrites on subsequent calls for the same host + cache.add_mdns_addresses("device.local", ["10.0.0.1"]) + assert cache.mdns_cache[normalize_hostname("device.local")] == ["10.0.0.1"] + + +def test_add_mdns_addresses_empty_is_noop() -> None: + """Passing an empty address list must not create an entry.""" + cache = AddressCache() + cache.add_mdns_addresses("device.local", []) + assert cache.mdns_cache == {} + + def test_has_cache() -> None: """Test checking if cache has entries.""" # Empty cache diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e07b4accf2..8ec9e70cf8 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Generator +from collections.abc import Callable, Generator from dataclasses import dataclass import json import logging @@ -12,16 +12,18 @@ import re import sys import time from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from pytest import CaptureFixture +from zeroconf import ServiceStateChange from esphome import platformio_api from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, + _resolve_network_devices, choose_upload_log_host, command_analyze_memory, command_bundle, @@ -36,6 +38,7 @@ from esphome.__main__ import ( has_mqtt, has_mqtt_ip_lookup, has_mqtt_logging, + has_name_add_mac_suffix, has_non_ip_address, has_ota, has_resolvable_address, @@ -48,6 +51,7 @@ from esphome.__main__ import ( upload_using_picotool, upload_using_platformio, ) +from esphome.address_cache import AddressCache from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( @@ -62,6 +66,7 @@ from esphome.const import ( CONF_MDNS, CONF_MQTT, CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, CONF_OTA, CONF_PASSWORD, CONF_PLATFORM, @@ -79,6 +84,7 @@ from esphome.const import ( ) from esphome.core import CORE, EsphomeError from esphome.util import BootselResult +from esphome.zeroconf import _await_discovery, discover_mdns_devices def strip_ansi_codes(text: str) -> str: @@ -2218,6 +2224,509 @@ def test_has_resolvable_address() -> None: assert has_resolvable_address() is False +def test_has_name_add_mac_suffix() -> None: + """Test has_name_add_mac_suffix function.""" + + # Test with name_add_mac_suffix enabled + setup_core(config={CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}}) + assert has_name_add_mac_suffix() is True + + # Test with name_add_mac_suffix disabled + setup_core(config={CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: False}}) + assert has_name_add_mac_suffix() is False + + # Test with name_add_mac_suffix not set (defaults to False) + setup_core(config={CONF_ESPHOME: {}}) + assert has_name_add_mac_suffix() is False + + # Test with no esphome config + setup_core(config={}) + assert has_name_add_mac_suffix() is False + + # Test with no config at all + CORE.config = None + assert has_name_add_mac_suffix() is False + + +@pytest.fixture +def mock_mdns_discovery() -> Generator[MagicMock]: + """Fixture to mock the async mDNS discovery infrastructure. + + Patches ``AsyncEsphomeZeroconf``, ``AsyncServiceBrowser`` and + ``AddressResolver`` in ``esphome.zeroconf`` and exposes hooks for tests to + stage browser events and control resolution results. The default + ``AddressResolver`` stub simulates a cache hit returning no addresses, so + matched hosts appear in the discovery output with empty address lists + unless the test overrides ``_resolver_setup``. + """ + with ( + patch("esphome.zeroconf.AsyncEsphomeZeroconf") as mock_aiozc_class, + patch("esphome.zeroconf.AsyncServiceBrowser") as mock_browser_class, + patch("esphome.zeroconf.AddressResolver") as mock_resolver_class, + ): + mock_aiozc = MagicMock() + mock_aiozc.zeroconf = MagicMock() + mock_aiozc.async_close = AsyncMock(return_value=None) + mock_aiozc_class.return_value = mock_aiozc + + mock_browser = MagicMock() + mock_browser.async_cancel = AsyncMock(return_value=None) + + # Default: each host gets a fresh resolver that hits the cache and + # returns no addresses. Tests can override via ``_resolver_setup``. + def default_resolver_factory(name: str) -> MagicMock: + resolver = MagicMock() + resolver._name = name + resolver.load_from_cache.return_value = True + resolver.async_request = AsyncMock(return_value=True) + resolver.parsed_scoped_addresses.return_value = [] + return resolver + + mock_resolver_class.side_effect = default_resolver_factory + + # Store references for test access + mock_aiozc._mock_browser_class = mock_browser_class + mock_aiozc._mock_browser = mock_browser + mock_aiozc._mock_class = mock_aiozc_class + mock_aiozc._mock_resolver_class = mock_resolver_class + yield mock_aiozc + + +@pytest.mark.parametrize( + ("discovered_services", "base_name", "expected_hosts"), + [ + # Matching devices; different-prefix device is filtered out + ( + [ + ("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Added), + ("mydevice-def456._esphomelib._tcp.local.", ServiceStateChange.Added), + ( + "otherdevice-abcdef._esphomelib._tcp.local.", + ServiceStateChange.Added, + ), + ], + "mydevice", + ["mydevice-abc123.local", "mydevice-def456.local"], + ), + # No matches at all + ( + [ + ( + "otherdevice-abcdef._esphomelib._tcp.local.", + ServiceStateChange.Added, + ), + ], + "mydevice", + [], + ), + # Deduplication (same device Added then Updated) + ( + [ + ("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Added), + ("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Updated), + ], + "mydevice", + ["mydevice-abc123.local"], + ), + # Suffix must be exactly 6 hex chars: wrong length and non-hex are rejected + ( + [ + # too short + ("mydevice-abcd._esphomelib._tcp.local.", ServiceStateChange.Added), + # too long + ( + "mydevice-abcdef1._esphomelib._tcp.local.", + ServiceStateChange.Added, + ), + # non-hex + ("mydevice-xyz123._esphomelib._tcp.local.", ServiceStateChange.Added), + # valid + ("mydevice-012345._esphomelib._tcp.local.", ServiceStateChange.Added), + ], + "mydevice", + ["mydevice-012345.local"], + ), + # Prefix-collision: base "foo" must not match "foo-bar-abc123" + ( + [ + ("foo-abcdef._esphomelib._tcp.local.", ServiceStateChange.Added), + ("foo-bar-abcdef._esphomelib._tcp.local.", ServiceStateChange.Added), + ], + "foo", + ["foo-abcdef.local"], + ), + ], + ids=[ + "matching_with_filter", + "no_matches", + "deduplication", + "hex_suffix_filter", + "prefix_collision", + ], +) +def test_discover_mdns_devices( + mock_mdns_discovery: MagicMock, + discovered_services: list[tuple[str, ServiceStateChange]], + base_name: str, + expected_hosts: list[str], +) -> None: + """Test discover_mdns_devices filtering and deduplication.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + callback = handlers[0] + for service_name, state_change in discovered_services: + callback( + mock_mdns_discovery.zeroconf, service_type, service_name, state_change + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + + # Each discovered host gets a resolver that returns a unique IP string + # derived from its server name so we can assert per-host. + def resolver_factory(name: str) -> MagicMock: + resolver = MagicMock() + resolver._name = name + resolver.load_from_cache.return_value = True + resolver.async_request = AsyncMock(return_value=True) + resolver.parsed_scoped_addresses.return_value = [f"10.0.0.1#{name}"] + return resolver + + mock_mdns_discovery._mock_resolver_class.side_effect = resolver_factory + + result = discover_mdns_devices(base_name, timeout=0) + + assert sorted(result) == expected_hosts + # Resolved addresses should be stored for matched hosts. AddressResolver + # receives the fully-qualified name (``.local.``). + for host in expected_hosts: + short = host.partition(".")[0] + assert result[host] == [f"10.0.0.1#{short}.local."] + mock_browser.async_cancel.assert_awaited_once() + mock_mdns_discovery.async_close.assert_awaited_once() + + +def test_discover_mdns_devices_init_failure(caplog: pytest.LogCaptureFixture) -> None: + """If AsyncEsphomeZeroconf fails to init, return empty dict and log warning.""" + with ( + patch( + "esphome.zeroconf.AsyncEsphomeZeroconf", + side_effect=OSError("no network"), + ), + caplog.at_level(logging.WARNING, logger="esphome.zeroconf"), + ): + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {} + assert "mDNS discovery failed to initialize" in caplog.text + + +def test_discover_mdns_devices_resolution_failure( + mock_mdns_discovery: MagicMock, +) -> None: + """If resolution raises, the host is still listed with an empty address list.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + handlers[0]( + mock_mdns_discovery.zeroconf, + service_type, + "mydevice-abc123._esphomelib._tcp.local.", + ServiceStateChange.Added, + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + + # Resolver misses the cache, then async_request raises. + def failing_resolver_factory(name: str) -> MagicMock: + resolver = MagicMock() + resolver.load_from_cache.return_value = False + resolver.async_request = AsyncMock(side_effect=OSError("boom")) + resolver.parsed_scoped_addresses.return_value = [] + return resolver + + mock_mdns_discovery._mock_resolver_class.side_effect = failing_resolver_factory + + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {"mydevice-abc123.local": []} + + +def test_discover_mdns_devices_ignores_removed_state( + mock_mdns_discovery: MagicMock, +) -> None: + """``Removed`` state changes are ignored and do not appear in the result.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + handlers[0]( + mock_mdns_discovery.zeroconf, + service_type, + "mydevice-abc123._esphomelib._tcp.local.", + ServiceStateChange.Removed, + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {} + # No AddressResolver should have been constructed since no host matched. + mock_mdns_discovery._mock_resolver_class.assert_not_called() + + +def test_discover_mdns_devices_empty_resolution( + mock_mdns_discovery: MagicMock, +) -> None: + """Host is listed with empty addresses when resolver returns no addresses.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + handlers[0]( + mock_mdns_discovery.zeroconf, + service_type, + "mydevice-abc123._esphomelib._tcp.local.", + ServiceStateChange.Added, + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + # Default fixture resolver is a cache-hit with no addresses — simulates + # the "browse found it but no A/AAAA records are available" case. + + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {"mydevice-abc123.local": []} + + +def test_resolve_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None: + """Hostnames in ``CORE.address_cache`` are expanded to their cached IPs.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache( + mdns_cache={ + "device-abc123.local": ["10.0.0.1", "10.0.0.2"], + } + ) + + result = _resolve_network_devices( + ["device-abc123.local", "192.168.1.50", "device-abc123.local"], + CORE.config, + MockArgs(), + ) + + # Cached hostname is replaced with its IPs (deduplicated across repeats) + # and the literal IP is preserved after. + assert result == ["10.0.0.1", "10.0.0.2", "192.168.1.50"] + + +def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None: + """Hostnames not in the cache pass through unchanged.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache() + + result = _resolve_network_devices( + ["unknown.local", "192.168.1.50"], + CORE.config, + MockArgs(), + ) + + assert result == ["unknown.local", "192.168.1.50"] + + +def test_await_discovery_timeout_returns_empty( + caplog: pytest.LogCaptureFixture, +) -> None: + """If the discovery runner never sets its event, return {} and warn.""" + stub = MagicMock() + stub.event.wait.return_value = False + stub.exception = None + stub.result = {"should_not_be_read": ["1.2.3.4"]} + + with caplog.at_level(logging.WARNING, logger="esphome.zeroconf"): + result = _await_discovery(stub, timeout=0.01) + + assert result == {} + assert "mDNS discovery timed out after 0.0s" in caplog.text + stub.event.wait.assert_called_once_with(timeout=pytest.approx(2.01)) + + +def test_await_discovery_propagates_exception_as_empty( + caplog: pytest.LogCaptureFixture, +) -> None: + """If the coroutine raised, log and return {} rather than re-raise.""" + stub = MagicMock() + stub.event.wait.return_value = True + stub.exception = RuntimeError("boom") + stub.result = None + + with caplog.at_level(logging.WARNING, logger="esphome.zeroconf"): + result = _await_discovery(stub, timeout=5.0) + + assert result == {} + assert "mDNS discovery failed: boom" in caplog.text + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_discovers_mac_suffix_devices(tmp_path: Path) -> None: + """Interactive mode discovers MAC-suffixed devices and populates the cache.""" + setup_core( + config={ + CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + }, + address="mydevice.local", + tmp_path=tmp_path, + name="mydevice", + ) + CORE.address_cache = None + + discovered = { + "mydevice-abc123.local": ["10.0.0.1"], + "mydevice-def456.local": ["10.0.0.2"], + } + with ( + patch( + "esphome.__main__.discover_mdns_devices", return_value=discovered + ) as mock_discover, + patch( + "esphome.__main__.choose_prompt", return_value="mydevice-abc123.local" + ) as mock_prompt, + ): + result = choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert result == ["mydevice-abc123.local"] + mock_discover.assert_called_once_with("mydevice") + mock_prompt.assert_called_once_with( + [ + ("Over The Air (mydevice-abc123.local)", "mydevice-abc123.local"), + ("Over The Air (mydevice-def456.local)", "mydevice-def456.local"), + ], + purpose=Purpose.UPLOADING, + ) + # Resolved IPs should be cached so downstream resolution skips a second + # Zeroconf lookup. + assert CORE.address_cache is not None + assert CORE.address_cache.get_mdns_addresses("mydevice-abc123.local") == [ + "10.0.0.1" + ] + assert CORE.address_cache.get_mdns_addresses("mydevice-def456.local") == [ + "10.0.0.2" + ] + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_mac_suffix_no_devices_found( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """When discovery finds nothing, no OTA option is offered and a warning logs.""" + setup_core( + config={ + CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + }, + address="mydevice.local", + tmp_path=tmp_path, + name="mydevice", + ) + + with ( + patch("esphome.__main__.discover_mdns_devices", return_value={}), + caplog.at_level(logging.WARNING, logger="esphome.__main__"), + pytest.raises(EsphomeError), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert "No devices matching 'mydevice-.local'" in caplog.text + + +def test_choose_upload_log_host_default_ota_discovers_mac_suffix( + tmp_path: Path, +) -> None: + """``--device OTA`` also runs mDNS discovery when name_add_mac_suffix is on.""" + setup_core( + config={ + CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + }, + address="mydevice.local", + tmp_path=tmp_path, + name="mydevice", + ) + CORE.address_cache = None + + discovered = { + "mydevice-abc123.local": ["10.0.0.1"], + "mydevice-def456.local": ["10.0.0.2"], + } + with patch( + "esphome.__main__.discover_mdns_devices", return_value=discovered + ) as mock_discover: + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + + # Both discovered hostnames are returned so aioesphomeapi / espota2 can + # try each in turn with the cached IPs. + assert result == ["mydevice-abc123.local", "mydevice-def456.local"] + mock_discover.assert_called_once_with("mydevice") + assert CORE.address_cache is not None + assert CORE.address_cache.get_mdns_addresses("mydevice-abc123.local") == [ + "10.0.0.1" + ] + + +def test_choose_upload_log_host_default_ota_no_suffix_discovery( + tmp_path: Path, +) -> None: + """``--device OTA`` without name_add_mac_suffix uses CORE.address as-is.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, + address="192.168.1.100", + tmp_path=tmp_path, + name="mydevice", + ) + + with patch("esphome.__main__.discover_mdns_devices") as mock_discover: + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert result == ["192.168.1.100"] + # Discovery must NOT run when name_add_mac_suffix is disabled. + mock_discover.assert_not_called() + + def test_command_wizard(tmp_path: Path) -> None: """Test command_wizard function.""" config_file = tmp_path / "test.yaml" diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index b4cca05d9f..7862c268ca 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -4,7 +4,7 @@ from __future__ import annotations import re import socket -from unittest.mock import patch +from unittest.mock import MagicMock, patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr @@ -115,24 +115,21 @@ def test_async_resolver_generic_exception() -> None: def test_async_resolver_thread_timeout() -> None: - """Test timeout when thread doesn't complete in time.""" - # Mock the start method to prevent actual thread execution - with ( - patch.object(AsyncResolver, "start"), - patch("esphome.resolver.hr.async_resolve_host"), - ): - resolver = AsyncResolver(["test.local"], 6053) - # Override event.wait to simulate timeout (return False = timeout occurred) - with ( - patch.object(resolver.event, "wait", return_value=False), - pytest.raises( - EsphomeError, match=re.escape("Timeout resolving IP address") - ), - ): - resolver.resolve() + """Test timeout when the runner thread doesn't complete in time.""" + # Patch AsyncThreadRunner inside esphome.resolver so we never actually + # start a thread and can control the wait return value directly. + fake_runner = MagicMock() + fake_runner.start = MagicMock() + fake_runner.event.wait.return_value = False # simulate timeout - # Verify thread start was called - resolver.start.assert_called_once() + with ( + patch("esphome.resolver.AsyncThreadRunner", return_value=fake_runner), + patch("esphome.resolver.hr.async_resolve_host"), + pytest.raises(EsphomeError, match=re.escape("Timeout resolving IP address")), + ): + AsyncResolver(["test.local"], 6053).resolve() + + fake_runner.start.assert_called_once() def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: From f757cd1210447b6145bdd87cf50bdb4bcab164fd Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:46:56 +0200 Subject: [PATCH 0503/2214] [zigbee][core] Add support for Zigbee binary sensors on ESP32 H2 and C6 (#11553) 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 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .clang-tidy.hash | 2 +- CODEOWNERS | 2 +- esphome/components/zigbee/__init__.py | 109 +++++- esphome/components/zigbee/automation.h | 3 + esphome/components/zigbee/const.py | 32 ++ esphome/components/zigbee/const_esp32.py | 35 ++ esphome/components/zigbee/const_zephyr.py | 21 -- esphome/components/zigbee/time/__init__.py | 3 +- .../zigbee/zigbee_attribute_esp32.cpp | 89 +++++ .../zigbee/zigbee_attribute_esp32.h | 90 +++++ esphome/components/zigbee/zigbee_ep_esp32.py | 70 ++++ esphome/components/zigbee/zigbee_esp32.cpp | 313 ++++++++++++++++++ esphome/components/zigbee/zigbee_esp32.h | 134 ++++++++ esphome/components/zigbee/zigbee_esp32.py | 274 +++++++++++++++ .../components/zigbee/zigbee_helpers_esp32.c | 74 +++++ .../components/zigbee/zigbee_helpers_esp32.h | 27 ++ esphome/components/zigbee/zigbee_zephyr.py | 27 +- esphome/core/defines.h | 1 + esphome/idf_component.yml | 8 + sdkconfig.defaults | 5 + tests/components/zigbee/common.yaml | 10 - tests/components/zigbee/common_esp32.yaml | 14 + tests/components/zigbee/common_nrf52.yaml | 12 + .../components/zigbee/test.esp32-c6-idf.yaml | 1 + .../zigbee/test.nrf52-adafruit.yaml | 2 +- .../components/zigbee/test.nrf52-mcumgr.yaml | 2 +- .../zigbee/test.nrf52-xiao-ble.yaml | 2 +- 27 files changed, 1295 insertions(+), 67 deletions(-) create mode 100644 esphome/components/zigbee/const.py create mode 100644 esphome/components/zigbee/const_esp32.py create mode 100644 esphome/components/zigbee/zigbee_attribute_esp32.cpp create mode 100644 esphome/components/zigbee/zigbee_attribute_esp32.h create mode 100644 esphome/components/zigbee/zigbee_ep_esp32.py create mode 100644 esphome/components/zigbee/zigbee_esp32.cpp create mode 100644 esphome/components/zigbee/zigbee_esp32.h create mode 100644 esphome/components/zigbee/zigbee_esp32.py create mode 100644 esphome/components/zigbee/zigbee_helpers_esp32.c create mode 100644 esphome/components/zigbee/zigbee_helpers_esp32.h create mode 100644 tests/components/zigbee/common_esp32.yaml create mode 100644 tests/components/zigbee/common_nrf52.yaml create mode 100644 tests/components/zigbee/test.esp32-c6-idf.yaml diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 9b6b817633..41e1b7bd2f 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -256216e144a626c8c9d1a458920a9db3de7dfc8c6a1b44b87946b9752e81026c +1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324 diff --git a/CODEOWNERS b/CODEOWNERS index 92efe4da4e..69f2cb1d17 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -600,6 +600,6 @@ esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 esphome/components/zhlt01/* @cfeenstra1024 -esphome/components/zigbee/* @tomaszduda23 +esphome/components/zigbee/* @luar123 @tomaszduda23 esphome/components/zio_ultrasonic/* @kahrendt esphome/components/zwave_proxy/* @kbx81 diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 280ff6b50c..126e3aa2cd 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -3,26 +3,42 @@ from typing import Any from esphome import automation, core import esphome.codegen as cg +from esphome.components.esp32 import only_on_variant +from esphome.components.esp32.const import ( + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, +) from esphome.components.nrf52.boards import BOOTLOADER_CONFIG, Section from esphome.components.zephyr import zephyr_add_pm_static, zephyr_data from esphome.components.zephyr.const import KEY_BOOTLOADER import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERNAL, CONF_NAME +from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType +from .const import ( + CONF_ON_JOIN, + CONF_POWER_SOURCE, + CONF_REPORT, + CONF_ROUTER, + CONF_WIPE_ON_BOOT, + KEY_ZIGBEE, + POWER_SOURCE, + REPORT, + ZigbeeComponent, + zigbee_ns, +) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, CONF_MAX_EP_NUMBER, - CONF_ON_JOIN, - CONF_POWER_SOURCE, - CONF_WIPE_ON_BOOT, CONF_ZIGBEE_ID, KEY_EP_NUMBER, - KEY_ZIGBEE, - POWER_SOURCE, - ZigbeeComponent, - zigbee_ns, +) +from .zigbee_esp32 import ( + final_validate_esp32, + validate_binary_sensor_esp32, + zigbee_require_vfs_select, ) from .zigbee_zephyr import ( zephyr_binary_sensor, @@ -33,11 +49,11 @@ from .zigbee_zephyr import ( _LOGGER = logging.getLogger(__name__) -CODEOWNERS = ["@tomaszduda23"] +CODEOWNERS = ["@luar123", "@tomaszduda23"] def zigbee_set_core_data(config: ConfigType) -> ConfigType: - if zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: + if CORE.is_nrf52 and zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: zephyr_add_pm_static( [Section("empty_after_zboss_offset", 0xF4000, 0xC000, "flash_primary")] ) @@ -45,7 +61,15 @@ def zigbee_set_core_data(config: ConfigType) -> ConfigType: return config -BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_binary_sensor) +BINARY_SENSOR_SCHEMA = cv.Schema( + { + cv.Optional(CONF_REPORT): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.enum(REPORT, lower=True), + ) + } +).extend(zephyr_binary_sensor) SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor) SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) @@ -54,16 +78,27 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent), - cv.Optional(CONF_ON_JOIN): automation.validate_automation(single=True), - cv.Optional(CONF_WIPE_ON_BOOT, default=False): cv.All( + cv.Optional(CONF_MODEL, default=CORE.name): cv.All( + cv.string, cv.Length(max=31) + ), + cv.OnlyWith(CONF_ROUTER, "esp32", default=False): cv.All( + cv.requires_component("esp32"), + cv.boolean, + ), + cv.Optional(CONF_ON_JOIN): cv.All( + cv.requires_component("nrf52"), + automation.validate_automation(single=True), + ), + cv.OnlyWith(CONF_WIPE_ON_BOOT, "nrf52", default=False): cv.All( cv.Any( cv.boolean, cv.one_of(*["once"], lower=True), ), cv.requires_component("nrf52"), ), - cv.Optional(CONF_POWER_SOURCE, default="DC_SOURCE"): cv.enum( - POWER_SOURCE, upper=True + cv.OnlyWith(CONF_POWER_SOURCE, "nrf52", default="DC_SOURCE"): cv.All( + cv.enum(POWER_SOURCE, upper=True), + cv.requires_component("nrf52"), ), cv.Optional(CONF_IEEE802154_VENDOR_OUI): cv.All( cv.Any( @@ -74,12 +109,27 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), + zigbee_require_vfs_select, zigbee_set_core_data, - cv.only_with_framework("zephyr"), + cv.Any( + cv.All( + cv.only_on_esp32, + only_on_variant( + supported=[ + VARIANT_ESP32H2, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + ] + ), + ), + cv.only_with_framework("zephyr"), + ), ) -def validate_number_of_ep(config: ConfigType) -> None: +def validate_number_of_ep(config: ConfigType) -> ConfigType: + if not CORE.is_nrf52: + return config if KEY_ZIGBEE not in CORE.data: raise cv.Invalid("At least one zigbee device need to be included") count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER]) @@ -90,9 +140,12 @@ def validate_number_of_ep(config: ConfigType) -> None: if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode: raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}") + return config + FINAL_VALIDATE_SCHEMA = cv.All( validate_number_of_ep, + final_validate_esp32, ) @@ -103,6 +156,10 @@ async def to_code(config: ConfigType) -> None: from .zigbee_zephyr import zephyr_to_code await zephyr_to_code(config) + if CORE.is_esp32: + from .zigbee_esp32 import esp32_to_code + + await esp32_to_code(config) async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: @@ -148,7 +205,7 @@ async def setup_number( def consume_endpoint(config: ConfigType) -> ConfigType: - if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + if not config.get(CONF_ZIGBEE_ID): return config if CONF_NAME in config and " " in config[CONF_NAME]: _LOGGER.warning( @@ -163,18 +220,34 @@ def consume_endpoint(config: ConfigType) -> ConfigType: def validate_binary_sensor(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return validate_binary_sensor_esp32(config) return consume_endpoint(config) def validate_sensor(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return config return consume_endpoint(config) def validate_switch(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return config return consume_endpoint(config) def validate_number(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return config return consume_endpoint(config) diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h index 1822e6a029..55ee9746ea 100644 --- a/esphome/components/zigbee/automation.h +++ b/esphome/components/zigbee/automation.h @@ -1,6 +1,9 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_ZIGBEE +#ifdef USE_ESP32 +#include "zigbee_esp32.h" +#endif #ifdef USE_NRF52 #include "zigbee_zephyr.h" #endif diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py new file mode 100644 index 0000000000..26ae2cc0ec --- /dev/null +++ b/esphome/components/zigbee/const.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg + +zigbee_ns = cg.esphome_ns.namespace("zigbee") +ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component) +ZigbeeAttribute = zigbee_ns.class_("ZigbeeAttribute", cg.Component) +BinaryAttrs = zigbee_ns.struct("BinaryAttrs") +AnalogAttrs = zigbee_ns.struct("AnalogAttrs") +AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput") + +report = zigbee_ns.enum("ZigbeeReportT") +REPORT = { + "coordinator": report.ZIGBEE_REPORT_COORDINATOR, + "enable": report.ZIGBEE_REPORT_ENABLE, + "force": report.ZIGBEE_REPORT_FORCE, +} + +CONF_ON_JOIN = "on_join" +CONF_WIPE_ON_BOOT = "wipe_on_boot" +CONF_REPORT = "report" +CONF_ROUTER = "router" +CONF_POWER_SOURCE = "power_source" +POWER_SOURCE = { + "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", + "MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE", + "MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE", + "BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY", + "DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE", + "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", + "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", +} + +KEY_ZIGBEE = "zigbee" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py new file mode 100644 index 0000000000..682638439e --- /dev/null +++ b/esphome/components/zigbee/const_esp32.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg + +DEVICE_TYPE = "device_type" +ROLE = "role" +CONF_MAX_EP_NUMBER = 239 +CONF_NUM = "num" +CONF_CLUSTERS = "clusters" +CONF_ATTRIBUTES = "attributes" +CONF_ENDPOINT = "endpoint" +CONF_CLUSTER = "cluster" +SCALE = "scale" +CONF_ATTRIBUTE_ID = "attribute_id" +KEY_BS_EP = "binary_sensor_ep" + +ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e") +DEVICE_ID = { + "RANGE_EXTENDER": ha_standard_devices.ZB_HA_RANGE_EXTENDER_DEVICE_ID, + "SIMPLE_SENSOR": ha_standard_devices.ZB_HA_SIMPLE_SENSOR_DEVICE_ID, + "CUSTOM_ATTR": ha_standard_devices.ZB_HA_CUSTOM_ATTR_DEVICE_ID, +} +cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t") +CLUSTER_ID = { + "BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC, + "BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, +} +cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t") +CLUSTER_ROLE = { + "SERVER": cluster_role.ESP_ZB_ZCL_CLUSTER_SERVER_ROLE, +} +attr_type = cg.esphome_ns.enum("esp_zb_zcl_attr_type_t") +ATTR_TYPE = { + "BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL, + "8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP, + "CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING, +} diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 2d233755ac..103ef01a3d 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -1,33 +1,12 @@ -import esphome.codegen as cg - -zigbee_ns = cg.esphome_ns.namespace("zigbee") -ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component) -BinaryAttrs = zigbee_ns.struct("BinaryAttrs") -AnalogAttrs = zigbee_ns.struct("AnalogAttrs") -AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput") - CONF_MAX_EP_NUMBER = 8 CONF_ZIGBEE_ID = "zigbee_id" -CONF_ON_JOIN = "on_join" -CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" -CONF_POWER_SOURCE = "power_source" -POWER_SOURCE = { - "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", - "MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE", - "MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE", - "BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY", - "DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE", - "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", - "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", -} CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" # Keys for CORE.data storage -KEY_ZIGBEE = "zigbee" KEY_EP_NUMBER = "ep_number" # External ZBOSS SDK types (just strings for codegen) diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 82f94c8372..3acab0076f 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -6,7 +6,8 @@ from esphome.core import CORE from esphome.types import ConfigType from .. import consume_endpoint -from ..const_zephyr import CONF_ZIGBEE_ID, zigbee_ns +from ..const import zigbee_ns +from ..const_zephyr import CONF_ZIGBEE_ID from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp new file mode 100644 index 0000000000..4d73600171 --- /dev/null +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -0,0 +1,89 @@ +#include "zigbee_attribute_esp32.h" +#include "esphome/core/log.h" +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +namespace esphome::zigbee { + +static const char *const TAG = "zigbee.attribute"; + +void ZigbeeAttribute::set_attr_() { + if (!this->zb_->is_connected()) { + return; + } + if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + esp_zb_zcl_status_t state = esp_zb_zcl_set_attribute_val(this->endpoint_id_, this->cluster_id_, this->role_, + this->attr_id_, this->value_p_, false); + if (this->force_report_) { + this->report_(true); + } + this->set_attr_requested_ = false; + // Check for error + if (state != ESP_ZB_ZCL_STATUS_SUCCESS) { + ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); + } + esp_zb_lock_release(); + } +} + +void ZigbeeAttribute::report_(bool has_lock) { + if (!this->zb_->is_connected()) { + return; + } + if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + esp_zb_zcl_report_attr_cmd_t cmd = { + .address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT, + .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI, + }; + cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000; + cmd.zcl_basic_cmd.dst_endpoint = 1; + cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_; + cmd.clusterID = this->cluster_id_; + cmd.attributeID = this->attr_id_; + + esp_zb_zcl_report_attr_cmd_req(&cmd); + if (!has_lock) { + esp_zb_lock_release(); + } + } +} + +esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() { + esp_zb_zcl_reporting_info_t reporting_info = { + .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV, + .ep = this->endpoint_id_, + .cluster_id = this->cluster_id_, + .cluster_role = this->role_, + .attr_id = this->attr_id_, + .manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC, + }; + reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID; + reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */ + reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */ + reporting_info.u.send_info.def_min_interval = 10; /*!< Default minimum reporting interval */ + reporting_info.u.send_info.def_max_interval = 0; /*!< Default maximum reporting interval */ + reporting_info.u.send_info.delta.s16 = 0; /*!< Actual reportable change */ + + return reporting_info; +} + +void ZigbeeAttribute::set_report(bool force) { + this->report_enabled = true; + this->force_report_ = force; +} + +void ZigbeeAttribute::loop() { + if (this->set_attr_requested_) { + this->set_attr_(); + } + + if (!this->set_attr_requested_) { + this->disable_loop(); + } +} + +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h new file mode 100644 index 0000000000..5a0cfc4fbd --- /dev/null +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -0,0 +1,90 @@ +#pragma once + +#include + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include "esp_zigbee_core.h" +#include "zigbee_esp32.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::zigbee { + +enum ZigbeeReportT { + ZIGBEE_REPORT_COORDINATOR, + ZIGBEE_REPORT_ENABLE, + ZIGBEE_REPORT_FORCE, +}; + +class ZigbeeAttribute : public Component { + public: + ZigbeeAttribute(ZigbeeComponent *parent, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + uint8_t attr_type, float scale, uint8_t max_size) + : zb_(parent), + endpoint_id_(endpoint_id), + cluster_id_(cluster_id), + role_(role), + attr_id_(attr_id), + attr_type_(attr_type), + scale_(scale), + max_size_(max_size) {} + void loop() override; + template void add_attr(T value); + esp_zb_zcl_reporting_info_t get_reporting_info(); + template void set_attr(const T &value); + uint8_t attr_type() { return attr_type_; } + void set_report(bool force); +#ifdef USE_BINARY_SENSOR + template void connect(binary_sensor::BinarySensor *sensor); +#endif + bool report_enabled = false; + + protected: + void set_attr_(); + void report_(bool has_lock); + ZigbeeComponent *zb_; + uint8_t endpoint_id_; + uint16_t cluster_id_; + uint8_t role_; + uint16_t attr_id_; + uint8_t attr_type_; + uint8_t max_size_; + float scale_; + void *value_p_{nullptr}; + bool set_attr_requested_{false}; + bool force_report_{false}; +}; + +template void ZigbeeAttribute::add_attr(T value) { + // Attribute type does never change and add_attr is only called once during startup, so this is safe. + // For now we need to support only simple numeric/bool types for (binary) sensors. + // For strings and arrays we would need to allocate a buffer of the maximum size. + this->value_p_ = (void *) (new T); + this->zb_->add_attr(this, this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, this->max_size_, + std::move(value)); +} + +template void ZigbeeAttribute::set_attr(const T &value) { + *static_cast(this->value_p_) = value; + this->set_attr_requested_ = true; + this->enable_loop(); +} + +#ifdef USE_BINARY_SENSOR +template void ZigbeeAttribute::connect(binary_sensor::BinarySensor *sensor) { + sensor->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); }); +} +#endif + +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py new file mode 100644 index 0000000000..791232d463 --- /dev/null +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -0,0 +1,70 @@ +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE + +from .const import CONF_REPORT, REPORT +from .const_esp32 import ( + CLUSTER_ROLE, + CONF_ATTRIBUTE_ID, + CONF_ATTRIBUTES, + CONF_CLUSTERS, + CONF_MAX_EP_NUMBER, + CONF_NUM, + DEVICE_TYPE, + ROLE, +) + +# endpoint configs: +ep_configs: dict[str, dict[str, Any]] = { + "binary_input": { + DEVICE_TYPE: "SIMPLE_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "BINARY_INPUT", + ROLE: CLUSTER_ROLE["SERVER"], + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "BOOL", + CONF_REPORT: REPORT["enable"], + CONF_DEVICE: None, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "8BITMAP", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "CHAR_STRING", + }, + ], + }, + ], + }, +} + + +def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]: + # create dummy endpoint if list is empty + if not ep_list: + ep_type = "CUSTOM_ATTR" + if router: + ep_type = "RANGE_EXTENDER" + ep_list = [ + { + DEVICE_TYPE: ep_type, + } + ] + # enumerate endpoints + for i, ep in enumerate(ep_list, 1): + ep[CONF_NUM] = i + if len(ep_list) > CONF_MAX_EP_NUMBER: + raise cv.Invalid( + f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." + ) + return ep_list diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp new file mode 100644 index 0000000000..c16736236a --- /dev/null +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -0,0 +1,313 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_check.h" +#include "nvs_flash.h" +#include "zigbee_attribute_esp32.h" +#include "zigbee_esp32.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include "zigbee_helpers_esp32.h" +#ifdef USE_WIFI +#include "esp_coexist.h" +#endif + +namespace esphome::zigbee { + +static const char *const TAG = "zigbee"; + +static ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { + uint8_t str_len = static_cast(strlen(str)); + uint8_t zcl_str_size = use_max_size ? max_size : std::min(max_size, str_len); + uint8_t *zcl_str = new uint8_t[zcl_str_size + 1]; // string + length octet + zcl_str[0] = zcl_str_size; + + // Initialize payload to avoid leaking uninitialized heap contents and clamp copy length + memset(zcl_str + 1, 0, zcl_str_size); + uint8_t copy_len = std::min(zcl_str_size, str_len); + if (copy_len > 0) { + memcpy(zcl_str + 1, str, copy_len); + } + return zcl_str; +} + +static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) { + if (esp_zb_bdb_start_top_level_commissioning(mode_mask) != ESP_OK) { + ESP_LOGE(TAG, "Start network steering failed!"); + } +} + +void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { + static uint8_t steering_retry_count = 0; + uint32_t *p_sg_p = signal_struct->p_app_signal; + esp_err_t err_status = signal_struct->esp_err_status; + esp_zb_app_signal_type_t sig_type = (esp_zb_app_signal_type_t) *p_sg_p; + esp_zb_zdo_signal_leave_params_t *leave_params = NULL; + switch (sig_type) { + case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP: + ESP_LOGD(TAG, "Zigbee stack initialized"); + esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION); + break; + case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START: + case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT: + if (err_status == ESP_OK) { + ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non "); + global_zigbee->started = true; + if (esp_zb_bdb_is_factory_new()) { + ESP_LOGD(TAG, "Start network steering"); + esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING); + } else { + ESP_LOGD(TAG, "Device rebooted"); + global_zigbee->connected = true; + } + } else { + ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)", + esp_zb_bdb_is_factory_new() ? "" : "non ", err_status, esp_err_to_name(err_status)); + ESP_LOGW(TAG, "Failed to initialize Zigbee stack (status: %s)", esp_err_to_name(err_status)); + esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, ESP_ZB_BDB_MODE_INITIALIZATION, + 1000); + } + break; + case ESP_ZB_BDB_SIGNAL_STEERING: + if (err_status == ESP_OK) { + steering_retry_count = 0; + ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(), + esp_zb_get_current_channel()); + global_zigbee->connected = true; + } else { + ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status)); + if (steering_retry_count < 10) { + steering_retry_count++; + esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, + ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000); + } else { + esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, + ESP_ZB_BDB_MODE_NETWORK_STEERING, 600 * 1000); + } + } + break; + case ESP_ZB_ZDO_SIGNAL_LEAVE: + leave_params = (esp_zb_zdo_signal_leave_params_t *) esp_zb_app_signal_get_params(p_sg_p); + if (leave_params->leave_type == ESP_ZB_NWK_LEAVE_TYPE_RESET) { + esp_zb_factory_reset(); + } + break; + default: + ESP_LOGD(TAG, "ZDO signal: %s (0x%x), status: %s", esp_zb_zdo_signal_to_string(sig_type), sig_type, + esp_err_to_name(err_status)); + break; + } +} + +static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message) { + esp_err_t ret = ESP_OK; + ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message"); + ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG, + "Received message: error status(%d)", message->info.status); + ESP_LOGD(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)", + message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size); + return ret; +} + +static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) { + esp_err_t ret = ESP_OK; + switch (callback_id) { + case ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID: + ret = zb_attribute_handler((esp_zb_zcl_set_attr_value_message_t *) message); + break; + default: + ESP_LOGD(TAG, "Receive Zigbee action(0x%x) callback", callback_id); + break; + } + return ret; +} + +void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id) { + esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create(); + this->endpoint_list_[endpoint_id] = + std::tuple(device_id, cluster_list); + // Add basic cluster + this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_BASIC, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); + // Add identify cluster if not already present + if (esp_zb_cluster_list_get_cluster(cluster_list, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE) == + nullptr) { + this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); + } +} + +void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role) { + esp_zb_attribute_list_t *attr_list; + if (cluster_id == 0) { + attr_list = create_basic_cluster_(); + } else { + attr_list = esphome_zb_default_attr_list_create(cluster_id); + } + this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list; +} + +void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer) { + char date_buf[16]; + time_t time_val = App.get_build_time(); + struct tm *timeinfo = localtime(&time_val); + strftime(date_buf, sizeof(date_buf), "%Y%m%d %H%M%S", timeinfo); + this->basic_cluster_data_ = { + .model = get_zcl_string(model, 31), + .manufacturer = get_zcl_string(manufacturer, 31), + .date = get_zcl_string(date_buf, 15), + }; +} + +esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() { + esp_zb_basic_cluster_cfg_t basic_cluster_cfg = { + .zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, + .power_source = 0, + }; + esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg); + esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, + this->basic_cluster_data_.manufacturer); + esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); + esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + return attr_list; +} + +esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, + esp_zb_cluster_list_t *esp_zb_cluster_list) { + esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id, + .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, + .app_device_id = device_id, + .app_device_version = 0}; + return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config); +} + +static void esp_zb_task_(void *pvParameters) { + if (esp_zb_start(false) != ESP_OK) { + ESP_LOGE(TAG, "Could not setup Zigbee"); + vTaskDelete(NULL); + } + esp_zb_set_node_descriptor_power_source(1); + esp_zb_stack_main_loop(); +} + +void ZigbeeComponent::setup() { + global_zigbee = this; + esp_zb_platform_config_t config = { + .radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(), + .host_config = ESP_ZB_DEFAULT_HOST_CONFIG(), + }; +#ifdef USE_WIFI + if (esp_coex_wifi_i154_enable() != ESP_OK) { + this->mark_failed(); + return; + } +#endif + if (esp_zb_platform_config(&config) != ESP_OK) { + this->mark_failed(); + return; + } + + esp_zb_zed_cfg_t zb_zed_cfg = { + .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, + .keep_alive = ED_KEEP_ALIVE, + }; + esp_zb_zczr_cfg_t zb_zczr_cfg = { + .max_children = MAX_CHILDREN, + }; + esp_zb_cfg_t zb_nwk_cfg = { + .esp_zb_role = this->device_role_, + .install_code_policy = false, + }; +#ifdef ZB_ROUTER_ROLE + zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg; +#else + zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg; +#endif + esp_zb_init(&zb_nwk_cfg); + + esp_err_t ret; + for (auto const &[key, val] : this->attribute_list_) { + esp_zb_cluster_list_t *esp_zb_cluster_list = std::get<1>(this->endpoint_list_[std::get<0>(key)]); + ret = esphome_zb_cluster_list_add_or_update_cluster(std::get<1>(key), esp_zb_cluster_list, val, std::get<2>(key)); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Could not create cluster 0x%04X with role %u: %s", std::get<1>(key), std::get<2>(key), + esp_err_to_name(ret)); + } else { + ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", std::get<0>(key), std::get<1>(key), + std::get<2>(key)); +#ifdef ESPHOME_LOG_HAS_VERBOSE + // Dump cluster attributes in verbose log + ESP_LOGV(TAG, "Cluster 0x%04X attributes:", std::get<1>(key)); + esp_zb_attribute_list_t *attr_list = val; + while (attr_list) { + esp_zb_zcl_attr_t *attr = &attr_list->attribute; + ESP_LOGV(TAG, " Attr ID: 0x%04X, Type: 0x%02X, Access: 0x%02X", attr->id, attr->type, attr->access); + attr_list = attr_list->next; + } +#endif + } + } + this->attribute_list_.clear(); + + for (auto const &[ep_id, dev_id] : this->endpoint_list_) { + if (create_endpoint(ep_id, std::get<0>(dev_id), std::get<1>(dev_id)) != ESP_OK) { + ESP_LOGE(TAG, "Could not create endpoint %u", ep_id); + } + } + this->endpoint_list_.clear(); + + if (esp_zb_device_register(this->esp_zb_ep_list_) != ESP_OK) { + ESP_LOGE(TAG, "Could not register the endpoint list"); + this->mark_failed(); + return; + } + + esp_zb_core_action_handler_register(zb_action_handler); + + if (esp_zb_set_primary_network_channel_set(ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK) != ESP_OK) { + ESP_LOGE(TAG, "Could not setup Zigbee"); + this->mark_failed(); + return; + } + for (auto &[_, attribute] : this->attributes_) { + if (attribute->report_enabled) { + esp_zb_zcl_reporting_info_t reporting_info = attribute->get_reporting_info(); + ESP_LOGD(TAG, "set reporting for cluster: %u", reporting_info.cluster_id); + if (esp_zb_zcl_update_reporting_info(&reporting_info) != ESP_OK) { + ESP_LOGE(TAG, "Could not configure reporting for attribute 0x%04X in cluster 0x%04X in endpoint %u", + reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep); + } + } + } + xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL); +} + +void ZigbeeComponent::dump_config() { + if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %s\n" + " Router: %s\n" + " Device is joined to the network: %s\n" + " Current channel: %d\n" + " Short addr: 0x%04X\n" + " Short pan id: 0x%04X", + this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER), + YESNO(esp_zb_bdb_dev_joined()), esp_zb_get_current_channel(), esp_zb_get_short_address(), + esp_zb_get_pan_id()); + esp_zb_lock_release(); + } else { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %s\n" + " Router: %s\n", + this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER)); + } +} +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h new file mode 100644 index 0000000000..80ecbfd639 --- /dev/null +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -0,0 +1,134 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include +#include +#include + +#include "esp_zigbee_core.h" +#include "zboss_api.h" +#include "ha/esp_zigbee_ha_standard.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "zigbee_helpers_esp32.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::zigbee { + +/* Zigbee configuration */ +static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ +static const uint8_t MAX_CHILDREN = 10; + +#define ESP_ZB_DEFAULT_RADIO_CONFIG() \ + { .radio_mode = ZB_RADIO_MODE_NATIVE, } + +#define ESP_ZB_DEFAULT_HOST_CONFIG() \ + { .host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, } + +uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); + +class ZigbeeAttribute; + +class ZigbeeComponent : public Component { + public: + void setup() override; + void dump_config() override; + esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, + esp_zb_cluster_list_t *esp_zb_cluster_list); + void set_basic_cluster(const char *model, const char *manufacturer); + void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); + void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id); + + template + void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + uint8_t max_size, T value); + + template + void add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value); + + void factory_reset() { + esp_zb_lock_acquire(portMAX_DELAY); + esp_zb_factory_reset(); // triggers a reboot + esp_zb_lock_release(); + } + + bool is_started() { return this->started; } + bool is_connected() { return this->connected; } + std::atomic connected = false; + std::atomic started = false; + + protected: + struct { + uint8_t *model; + uint8_t *manufacturer; + uint8_t *date; + } basic_cluster_data_; +#ifdef ZB_ED_ROLE + esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED; +#else + esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ROUTER; +#endif + esp_zb_attribute_list_t *create_basic_cluster_(); + template + void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + T *value_p); + // endpoint_list_ and attribute_list_ are only used during setup and are cleared afterwards + // value tuple could be replaced by struct + std::map> endpoint_list_; + // key tuple could be replaced by single 32 bit int with bit fields for endpoint, cluster and role + std::map, esp_zb_attribute_list_t *> attribute_list_; + // attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger + // automations + // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id + std::map, ZigbeeAttribute *> attributes_; + esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create(); +}; + +extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct); + +template +void ZigbeeComponent::add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + uint8_t max_size, T value) { + this->add_attr(nullptr, endpoint_id, cluster_id, role, attr_id, max_size, value); +} + +template +void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, + uint16_t attr_id, uint8_t max_size, T value) { + // The size byte of the zcl_str must be set to the maximum value, + // even though the initial string may be shorter. + if constexpr (std::is_same::value) { + auto zcl_str = get_zcl_string(value.c_str(), max_size, true); + add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); + delete[] zcl_str; + } else if constexpr (std::is_convertible::value) { + auto zcl_str = get_zcl_string(value, max_size, true); + add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); + delete[] zcl_str; + } else { + add_attr_(attr, endpoint_id, cluster_id, role, attr_id, &value); + } +} + +template +void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, + uint16_t attr_id, T *value_p) { + esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}]; + esp_err_t ret = esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); + + if (attr != nullptr) { + this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; + } +} + +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py new file mode 100644 index 0000000000..1b98df6c0a --- /dev/null +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -0,0 +1,274 @@ +import copy +import logging +import re +from typing import Any + +import esphome.codegen as cg +from esphome.components.esp32 import ( + CONF_PARTITIONS, + add_idf_component, + add_idf_sdkconfig_option, + add_partition, + require_vfs_select, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_AP, + CONF_DEVICE, + CONF_ID, + CONF_MAX_LENGTH, + CONF_MODEL, + CONF_NAME, + CONF_TYPE, + CONF_VALUE, + CONF_WIFI, +) +from esphome.core import CORE +from esphome.coroutine import CoroPriority, coroutine_with_priority +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .const import CONF_REPORT, CONF_ROUTER, KEY_ZIGBEE, REPORT, ZigbeeAttribute +from .const_esp32 import ( + ATTR_TYPE, + CLUSTER_ID, + CONF_ATTRIBUTE_ID, + CONF_ATTRIBUTES, + CONF_CLUSTERS, + CONF_NUM, + DEVICE_ID, + DEVICE_TYPE, + KEY_BS_EP, + ROLE, + SCALE, +) +from .zigbee_ep_esp32 import create_ep, ep_configs + +_LOGGER = logging.getLogger(__name__) + + +def get_c_size(bits: str, options: list[int]) -> str: + return str([n for n in options if n >= int(bits)][0]) + + +def get_c_type(attr_type: str) -> Any | None: + if attr_type == "BOOL": + return cg.bool_ + if "STRING" in attr_type: + return cg.std_string + test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + if test and test.group(2): + return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64])) + return None + + +def get_cv_by_type(attr_type: str) -> Any | None: + if attr_type == "BOOL": + return cv.boolean + if "STRING" in attr_type: + return cv.string + test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + if test and test.group(2): + return cv.positive_int + return None + + +def get_default_by_type(attr_type: str) -> str | bool | int: + if attr_type == "CHAR_STRING": + return "" + if attr_type == "BOOL": + return False + return 0 + + +def validate_attributes(config: ConfigType) -> ConfigType: + if CONF_VALUE not in config: + config[CONF_VALUE] = get_default_by_type(config[CONF_TYPE]) + config[CONF_VALUE] = get_cv_by_type(config[CONF_TYPE])(config[CONF_VALUE]) + + return config + + +def final_validate_esp32(config: ConfigType) -> ConfigType: + if not CORE.is_esp32: + return config + if CONF_WIFI in fv.full_config.get(): + if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]: + raise cv.Invalid( + "Only Zigbee End Device can be used together with a Wifi Access Point." + ) + if CONF_AP in fv.full_config.get()[CONF_WIFI]: + _LOGGER.warning( + "Wifi Access Point might be unstable while Zigbee is active, use only as fallback." + ) + elif config[CONF_ROUTER]: + _LOGGER.warning( + "The Zigbee Router might miss packets while Wifi is active and could destabilize " + "your network. Use only if Wifi is off most of the time." + ) + if CONF_PARTITIONS in fv.full_config.get() and not isinstance( + fv.full_config.get()[CONF_PARTITIONS], list + ): + with open( + CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]), + encoding="utf8", + ) as f: + partitions_tab = f.read() + for partition, types in [ + ("zb_storage", {"type": "data", "subtype": "fat", "size": 0x4000}), + ("zb_fct", {"type": "data", "subtype": "fat", "size": 0x1000}), + ]: + if partition not in partitions_tab: + raise cv.Invalid( + f"Add '{partition}, {types['type']}, {types['subtype']}, , {types['size']},' to your custom partition table." + ) + if not re.search( + rf"^{partition},\s*{types['type']},\s*{types['subtype']}", + partitions_tab, + re.MULTILINE, + ): + raise cv.Invalid( + f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'" + ) + return config + + +def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: + ep = copy.deepcopy(ep_configs["binary_input"]) + for cl in ep.get(CONF_CLUSTERS, []): + for attr in cl[CONF_ATTRIBUTES]: + if ( + attr[CONF_ATTRIBUTE_ID] == 0x1C + and CONF_VALUE not in attr + and CONF_NAME in config + ): # set name + name = ( + config[CONF_NAME].encode("ascii", "ignore").decode() + ) # or use unidecode + attr[CONF_VALUE] = str(name) + attr[CONF_MAX_LENGTH] = len(str(name)) + if CONF_DEVICE in attr: # connect device + attr[CONF_DEVICE] = config[CONF_ID] + if CONF_REPORT in config: + attr[CONF_REPORT] = config[CONF_REPORT] + attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None) + if "zb_attr_ids" not in config: + config["zb_attr_ids"] = [] + config["zb_attr_ids"].append(attr[CONF_ID]) + else: + attr[CONF_ID] = None + validate_attributes(attr) + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, []) + binary_sensor_ep.append(ep) + return config + + +def zigbee_require_vfs_select(config: ConfigType) -> ConfigType: + """Register VFS select requirement during config validation.""" + # Zigbee uses esp_vfs_eventfd which requires VFS select support + if CORE.is_esp32: + require_vfs_select() + return config + + +@coroutine_with_priority(CoroPriority.WORKAROUNDS) +async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: + """Add sdkconfigs late so they can overwrite esp32 defaults""" + add_idf_sdkconfig_option("CONFIG_ZB_ENABLED", True) + if config.get(CONF_ROUTER): + add_idf_sdkconfig_option("CONFIG_ZB_ZCZR", True) + else: + add_idf_sdkconfig_option("CONFIG_ZB_ZED", True) + add_idf_sdkconfig_option("CONFIG_ZB_RADIO_NATIVE", True) + if CONF_WIFI in CORE.config: + add_idf_sdkconfig_option("CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE", 4096) + # The pre-built Zigbee library uses esp_log_default_level which requires + # dynamic log level control to be enabled + add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) + + +async def attributes_to_code( + var: cg.Pvariable, ep_num: int, cl: dict[str, Any] +) -> None: + for attr in cl.get(CONF_ATTRIBUTES, []): + if attr.get(CONF_ID) is None: + cg.add( + var.add_attr( + ep_num, + CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), + cl[ROLE], + attr[CONF_ATTRIBUTE_ID], + attr.get(CONF_MAX_LENGTH, 0), + attr[CONF_VALUE], + ) + ) + continue + attr_var = cg.new_Pvariable( + attr[CONF_ID], + var, + ep_num, + CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), + cl[ROLE], + attr[CONF_ATTRIBUTE_ID], + ATTR_TYPE[attr[CONF_TYPE]], + attr.get(SCALE, 1), + attr.get(CONF_MAX_LENGTH, 0), + ) + await cg.register_component(attr_var, attr) + + cg.add(attr_var.add_attr(attr[CONF_VALUE])) + if CONF_REPORT in attr and attr[CONF_REPORT] in [ + REPORT["enable"], + REPORT["force"], + ]: + cg.add(attr_var.set_report(attr[CONF_REPORT] == REPORT["force"])) + + if CONF_DEVICE in attr: + device = await cg.get_variable(attr[CONF_DEVICE]) + template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE])) + cg.add(attr_var.connect(template_arg, device)) + + +async def esp32_to_code(config: ConfigType) -> None: + add_idf_component( + name="espressif/esp-zboss-lib", + ref="1.6.4", + ) + add_idf_component( + name="espressif/esp-zigbee-lib", + ref="1.6.8", + ) + + # add sdkconfigs later so they can overwrite esp32 defaults + CORE.add_job(_zigbee_add_sdkconfigs, config) + + # add partitions for zigbee + add_partition("zb_storage", "data", "fat", 0x4000) # 16KB + add_partition("zb_fct", "data", "fat", 0x1000) # 4KB, minimum size + + # create endpoints + zb_data = CORE.data.get(KEY_ZIGBEE, {}) + binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, []) + ep_list = create_ep(binary_sensor_ep, config.get(CONF_ROUTER)) + + # setup zigbee components + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add( + var.set_basic_cluster( + config[CONF_MODEL], + "esphome", + ) + ) + for ep in ep_list: + cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]])) + for cl in ep.get(CONF_CLUSTERS, []): + cg.add( + var.add_cluster( + ep[CONF_NUM], + CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), + cl[ROLE], + ) + ) + await attributes_to_code(var, ep[CONF_NUM], cl) diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c new file mode 100644 index 0000000000..4ba71ec609 --- /dev/null +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -0,0 +1,74 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include "ha/esp_zigbee_ha_standard.h" +#include "zigbee_helpers_esp32.h" + +esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, + uint16_t attr_id, void *value_p) { + esp_err_t ret; + ret = esp_zb_cluster_update_attr(attr_list, attr_id, value_p); + if (ret != ESP_OK) { + ESP_LOGE("zigbee_helper", "Ignore previous attribute not found error"); + ret = esphome_zb_cluster_add_attr(cluster_id, attr_list, attr_id, value_p); + } + if (ret != ESP_OK) { + ESP_LOGE("zigbee_helper", "Could not add attribute 0x%04X to cluster 0x%04X: %s", attr_id, cluster_id, + esp_err_to_name(ret)); + } + return ret; +} + +esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, + esp_zb_attribute_list_t *attr_list, uint8_t role_mask) { + esp_err_t ret; + ret = esp_zb_cluster_list_update_cluster(cluster_list, attr_list, cluster_id, role_mask); + if (ret != ESP_OK) { + ESP_LOGE("zigbee_helper", "Ignore previous cluster not found error"); + switch (cluster_id) { + case ESP_ZB_ZCL_CLUSTER_ID_BASIC: + ret = esp_zb_cluster_list_add_basic_cluster(cluster_list, attr_list, role_mask); + break; + case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: + ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask); + break; + case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: + ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask); + break; + default: + ret = esp_zb_cluster_list_add_custom_cluster(cluster_list, attr_list, role_mask); + } + } + return ret; +} + +esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id) { + switch (cluster_id) { + case ESP_ZB_ZCL_CLUSTER_ID_BASIC: + return esp_zb_basic_cluster_create(NULL); + case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: + return esp_zb_identify_cluster_create(NULL); + case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return esp_zb_binary_input_cluster_create(NULL); + default: + return esp_zb_zcl_attr_list_create(cluster_id); + } +} + +esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, + void *value_p) { + switch (cluster_id) { + case ESP_ZB_ZCL_CLUSTER_ID_BASIC: + return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p); + case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: + return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p); + case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p); + default: + return ESP_FAIL; + } +} + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.h b/esphome/components/zigbee/zigbee_helpers_esp32.h new file mode 100644 index 0000000000..0650c1689f --- /dev/null +++ b/esphome/components/zigbee/zigbee_helpers_esp32.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#ifdef __cplusplus +extern "C" { +#endif + +#include "esp_zigbee_core.h" + +esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, + esp_zb_attribute_list_t *attr_list, uint8_t role_mask); +esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id); +esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, + void *value_p); +esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, + uint16_t attr_id, void *value_p); + +#ifdef __cplusplus +} +namespace esphome::zigbee {} // namespace esphome::zigbee +#endif + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 3288d92483..f6e3e88c63 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -1,4 +1,4 @@ -from datetime import datetime +import datetime import random from esphome import automation @@ -7,6 +7,7 @@ from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_MODEL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, UNIT_AMPERE, @@ -48,19 +49,26 @@ from esphome.cpp_generator import ( ) from esphome.types import ConfigType -from .const_zephyr import ( - CONF_IEEE802154_VENDOR_OUI, +from .const import ( CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_WIPE_ON_BOOT, + KEY_ZIGBEE, + POWER_SOURCE, + AnalogAttrs, + AnalogAttrsOutput, + BinaryAttrs, + ZigbeeComponent, + zigbee_ns, +) +from .const_zephyr import ( + CONF_IEEE802154_VENDOR_OUI, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_NUMBER, CONF_ZIGBEE_SENSOR, CONF_ZIGBEE_SWITCH, KEY_EP_NUMBER, - KEY_ZIGBEE, - POWER_SOURCE, ZB_ZCL_BASIC_ATTRS_EXT_T, ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, @@ -69,11 +77,6 @@ from .const_zephyr import ( ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, ZB_ZCL_CLUSTER_ID_IDENTIFY, ZB_ZCL_IDENTIFY_ATTRS_T, - AnalogAttrs, - AnalogAttrsOutput, - BinaryAttrs, - ZigbeeComponent, - zigbee_ns, ) ZigbeeBinarySensor = zigbee_ns.class_("ZigbeeBinarySensor", cg.Component) @@ -209,9 +212,9 @@ async def _attr_to_code(config: ConfigType) -> None: zigbee_assign(basic_attrs.stack_version, 0), zigbee_assign(basic_attrs.hw_version, 0), zigbee_set_string(basic_attrs.mf_name, "esphome"), - zigbee_set_string(basic_attrs.model_id, CORE.name), + zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( - basic_attrs.date_code, datetime.now().strftime("%d/%m/%y %H:%M") + basic_attrs.date_code, datetime.datetime.now().strftime("%Y%m%d %H%M%S") ), zigbee_assign( basic_attrs.power_source, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 63fe4e677e..9b751dd8c0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -322,6 +322,7 @@ #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD +#define USE_ZIGBEE #endif #endif diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3637481c92..c590f73642 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -37,6 +37,14 @@ dependencies: version: "2.0.0" rules: - if: "target in [esp32, esp32p4]" + espressif/esp-zboss-lib: + version: 1.6.4 + rules: + - if: "target in [esp32h2, esp32c5, esp32c6]" + espressif/esp-zigbee-lib: + version: 1.6.8 + rules: + - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: version: "1.0.0" rules: diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 72ca3f6e9c..2996490295 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -20,3 +20,8 @@ CONFIG_BT_ENABLED=y # esp32_camera CONFIG_RTCIO_SUPPORT_RTC_GPIO_DESC=y CONFIG_ESP32_SPIRAM_SUPPORT=y + +# zigbee +CONFIG_ZB_ENABLED=y +CONFIG_ZB_ZED=y +CONFIG_ZB_RADIO_NATIVE=y diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index 2af35ff148..c689d07f6b 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -1,4 +1,3 @@ ---- binary_sensor: - platform: template name: "Garage Door Open 1" @@ -22,12 +21,6 @@ sensor: lambda: return 12.0; internal: True -zigbee: - wipe_on_boot: true - on_join: - then: - - logger.log: "Joined network" - output: - platform: template id: output_factory @@ -35,9 +28,6 @@ output: write_action: - zigbee.factory_reset -time: - - platform: zigbee - switch: - platform: template name: "Template Switch" diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml new file mode 100644 index 0000000000..4494b4081d --- /dev/null +++ b/tests/components/zigbee/common_esp32.yaml @@ -0,0 +1,14 @@ +binary_sensor: + - platform: template + name: "Garage Door Open 10" + report: "enable" + - platform: template + name: "Garage Door Open 11" + report: "coordinator" + - platform: template + name: "Garage Door Open 12" + report: "force" + +zigbee: + model: zigbee_test + router: true diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml new file mode 100644 index 0000000000..bc39b371f5 --- /dev/null +++ b/tests/components/zigbee/common_nrf52.yaml @@ -0,0 +1,12 @@ +packages: + - !include common.yaml + +zigbee: + model: zigbee_test + wipe_on_boot: true + on_join: + then: + - logger.log: "Joined network" + +time: + - platform: zigbee diff --git a/tests/components/zigbee/test.esp32-c6-idf.yaml b/tests/components/zigbee/test.esp32-c6-idf.yaml new file mode 100644 index 0000000000..8e4796a073 --- /dev/null +++ b/tests/components/zigbee/test.esp32-c6-idf.yaml @@ -0,0 +1 @@ +<<: !include common_esp32.yaml diff --git a/tests/components/zigbee/test.nrf52-adafruit.yaml b/tests/components/zigbee/test.nrf52-adafruit.yaml index dade44d145..bf3cb9cdd9 100644 --- a/tests/components/zigbee/test.nrf52-adafruit.yaml +++ b/tests/components/zigbee/test.nrf52-adafruit.yaml @@ -1 +1 @@ -<<: !include common.yaml +<<: !include common_nrf52.yaml diff --git a/tests/components/zigbee/test.nrf52-mcumgr.yaml b/tests/components/zigbee/test.nrf52-mcumgr.yaml index dade44d145..bf3cb9cdd9 100644 --- a/tests/components/zigbee/test.nrf52-mcumgr.yaml +++ b/tests/components/zigbee/test.nrf52-mcumgr.yaml @@ -1 +1 @@ -<<: !include common.yaml +<<: !include common_nrf52.yaml diff --git a/tests/components/zigbee/test.nrf52-xiao-ble.yaml b/tests/components/zigbee/test.nrf52-xiao-ble.yaml index 254f370ca7..83d949b4dd 100644 --- a/tests/components/zigbee/test.nrf52-xiao-ble.yaml +++ b/tests/components/zigbee/test.nrf52-xiao-ble.yaml @@ -1,4 +1,4 @@ -<<: !include common.yaml +<<: !include common_nrf52.yaml zigbee: wipe_on_boot: once From 0dd2a57ba4811307f2623c805efe5a16b2f11ea7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:13:01 -0500 Subject: [PATCH 0504/2214] [ci] Add import-time regression check for esphome.__main__ Adds a CI gate that runs `python -X importtime -c "import esphome.__main__"` via importtime-waterfall's best-of-N harness, compares the root cumulative time against a checked-in budget (script/import_time_budget.json, seeded at 75.2ms with a 15% margin), and fails the build when top-level imports regress. The CLI pays this cost on every invocation before the requested command even runs, so silently adding a top-level dep chain (the recent zeroconf move in #13135 being the motivating case) hurts every user. The check gives us a signal without waiting for user reports. - script/check_import_time.py: --check / --update / --har PATH modes. On regression, prints a ranked top-15 offenders table by self-time. - script/import_time_budget.json: baseline + margin_pct. - script/determine-jobs.py: should_run_import_time() gates the job on esphome/**/*.py, requirements.txt, requirements_dev.txt, pyproject.toml, or changes to the check itself. - .github/workflows/ci.yml: new import-time job, runs when gated and uploads a waterfall HAR artifact (14-day retention) for inspection. --- .github/workflows/ci.yml | 38 +++++ script/check_import_time.py | 260 +++++++++++++++++++++++++++++++++ script/determine-jobs.py | 37 +++++ script/import_time_budget.json | 5 + 4 files changed, 340 insertions(+) create mode 100755 script/check_import_time.py create mode 100644 script/import_time_budget.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20c349ac00..03d0822e66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,42 @@ jobs: script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check + import-time: + name: Check import esphome.__main__ time + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.import-time == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Install importtime-waterfall + run: | + . venv/bin/activate + pip install importtime-waterfall==1.0.0 + - name: Check import time against budget + run: | + . venv/bin/activate + script/check_import_time.py --check + - name: Generate waterfall HAR + if: always() + run: | + . venv/bin/activate + script/check_import_time.py --har importtime.har + - name: Upload waterfall HAR + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: import-time-waterfall + path: importtime.har + retention-days: 14 + pytest: name: Run pytest strategy: @@ -176,6 +212,7 @@ jobs: clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} python-linters: ${{ steps.determine.outputs.python-linters }} + import-time: ${{ steps.determine.outputs.import-time }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -219,6 +256,7 @@ jobs: echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT + echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT diff --git a/script/check_import_time.py b/script/check_import_time.py new file mode 100755 index 0000000000..b170806fc8 --- /dev/null +++ b/script/check_import_time.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Regression check for `import esphome.__main__` cost. + +Runs `python -X importtime -c "import esphome.__main__"` in fresh subprocesses +and compares the root cumulative import time against a checked-in budget +(`script/import_time_budget.json`). + +The CLI pays this cost on every invocation before the requested command even +runs, so a regression here hurts every user. Pair this with +`python -m importtime_waterfall --har` for human-readable waterfalls. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys +import time +from typing import Any, TextIO + +SCRIPT_DIR = Path(__file__).parent +BUDGET_PATH = SCRIPT_DIR / "import_time_budget.json" + +TARGET_MODULE = "esphome.__main__" +DEFAULT_RUNS = 3 +DEFAULT_MARGIN_PCT = 15 +OFFENDERS_TOP_N = 15 +IMPORT_TIME_PREFIX = "import time:" + + +def _run_importtime(module: str) -> tuple[float, str]: + """Run `python -X importtime -c 'import '` once. + + Returns (wall_seconds, stderr_text). + """ + before = time.monotonic() + result = subprocess.run( + [sys.executable, "-X", "importtime", "-c", f"import {module}"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + return time.monotonic() - before, result.stderr + + +def measure(module: str, runs: int) -> str: + """Run `module` import `runs` times; return the fastest run's stderr.""" + best_wall, best_stderr = _run_importtime(module) + for _ in range(runs - 1): + wall, stderr = _run_importtime(module) + if wall < best_wall: + best_wall, best_stderr = wall, stderr + return best_stderr + + +def parse_trace(stderr: str) -> list[tuple[int, int, int, str]]: + """Parse `-X importtime` stderr into (depth, self_us, cumulative_us, name).""" + entries: list[tuple[int, int, int, str]] = [] + for line in stderr.splitlines(): + if not line.startswith(IMPORT_TIME_PREFIX): + continue + body = line[len(IMPORT_TIME_PREFIX) :] + parts = body.split("|") + if len(parts) != 3: + continue + try: + self_us = int(parts[0].strip()) + cumulative_us = int(parts[1].strip()) + except ValueError: + continue # header row + name_field = parts[2].rstrip("\n") + name = name_field.lstrip(" ") + depth = (len(name_field) - len(name)) // 2 + entries.append((depth, self_us, cumulative_us, name)) + return entries + + +def root_cumulative_us(entries: list[tuple[int, int, int, str]], module: str) -> int: + """Return the cumulative import time of `module` (the root probe).""" + for depth, _self_us, cumulative_us, name in reversed(entries): + if depth == 0 and name == module: + return cumulative_us + raise RuntimeError( + f"Did not find a root-level trace entry for {module!r}. Is it importable?" + ) + + +def top_offenders( + entries: list[tuple[int, int, int, str]], n: int +) -> list[tuple[str, int, int]]: + """Return up to `n` modules with the highest self-time, deduped by name. + + Returns list of (name, self_us, cumulative_us). A module imported from + multiple places is counted once, at its deepest-stacktrace occurrence + (same as what the user sees on the `--graph` output). + """ + seen: dict[str, tuple[int, int]] = {} + for _depth, self_us, cumulative_us, name in entries: + if name in seen: + continue + seen[name] = (self_us, cumulative_us) + ranked = sorted( + ((name, self_us, cum_us) for name, (self_us, cum_us) in seen.items()), + key=lambda row: row[1], + reverse=True, + ) + return ranked[:n] + + +def read_budget() -> dict[str, Any]: + if not BUDGET_PATH.exists(): + return {} + with BUDGET_PATH.open() as f: + return json.load(f) + + +def write_budget(cumulative_us: int, margin_pct: int) -> None: + payload = { + "target_module": TARGET_MODULE, + "margin_pct": margin_pct, + "cumulative_us": cumulative_us, + } + with BUDGET_PATH.open("w") as f: + json.dump(payload, f, indent=2) + f.write("\n") + + +def _format_us(us: int) -> str: + if us >= 1000: + return f"{us / 1000:.1f}ms" + return f"{us}us" + + +def _print_offenders_table( + offenders: list[tuple[str, int, int]], stream: TextIO +) -> None: + name_w = max(len(name) for name, _, _ in offenders) + print(f"\n{'module':<{name_w}} {'self':>10} {'cumulative':>12}", file=stream) + print(f"{'-' * name_w} {'-' * 10} {'-' * 12}", file=stream) + for name, self_us, cum_us in offenders: + print( + f"{name:<{name_w}} {_format_us(self_us):>10} {_format_us(cum_us):>12}", + file=stream, + ) + + +def cmd_check(args: argparse.Namespace) -> int: + budget = read_budget() + if not budget: + print( + f"ERROR: {BUDGET_PATH.name} missing. Run with --update first.", + file=sys.stderr, + ) + return 2 + + stderr = measure(TARGET_MODULE, args.runs) + entries = parse_trace(stderr) + measured = root_cumulative_us(entries, TARGET_MODULE) + + baseline = budget["cumulative_us"] + margin_pct = budget.get("margin_pct", DEFAULT_MARGIN_PCT) + ceiling = int(baseline * (1 + margin_pct / 100)) + + summary = ( + f"measured {TARGET_MODULE}: {_format_us(measured)} " + f"(budget {_format_us(baseline)} + {margin_pct}% = {_format_us(ceiling)})" + ) + + if measured <= ceiling: + print(summary) + return 0 + + print( + f"REGRESSION: `import {TARGET_MODULE}` took {_format_us(measured)}, " + f"exceeding the budget of {_format_us(baseline)} + {margin_pct}% " + f"({_format_us(ceiling)}).\n" + f"Top import-time offenders (by self time):", + file=sys.stderr, + ) + _print_offenders_table(top_offenders(entries, OFFENDERS_TOP_N), sys.stderr) + print( + "\nIf this regression is intentional, regenerate the budget with:\n" + " script/check_import_time.py --update\n" + "Otherwise, consider making the new import lazy " + "(import inside the function that uses it).", + file=sys.stderr, + ) + return 1 + + +def cmd_update(args: argparse.Namespace) -> int: + stderr = measure(TARGET_MODULE, args.runs) + entries = parse_trace(stderr) + measured = root_cumulative_us(entries, TARGET_MODULE) + write_budget(measured, args.margin_pct) + print( + f"Wrote {BUDGET_PATH.name}: " + f"{TARGET_MODULE}={_format_us(measured)} " + f"(margin {args.margin_pct}%)" + ) + return 0 + + +def cmd_har(args: argparse.Namespace) -> int: + out_path = Path(args.har) + result = subprocess.run( + [sys.executable, "-m", "importtime_waterfall", "--har", TARGET_MODULE], + check=True, + stdout=subprocess.PIPE, + text=True, + ) + out_path.write_text(result.stdout) + print(f"Wrote waterfall HAR to {out_path}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--runs", + type=int, + default=DEFAULT_RUNS, + help=f"Number of measurement runs (default: {DEFAULT_RUNS}, best-of-N).", + ) + parser.add_argument( + "--margin-pct", + type=int, + default=DEFAULT_MARGIN_PCT, + help=(f"Margin over baseline for --update (default: {DEFAULT_MARGIN_PCT}%%)."), + ) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument( + "--check", action="store_true", help="Fail if measured time exceeds budget." + ) + mode.add_argument( + "--update", + action="store_true", + help="Rewrite the budget from a fresh measurement.", + ) + mode.add_argument( + "--har", + metavar="PATH", + help="Write a waterfall HAR file via `importtime_waterfall --har`.", + ) + args = parser.parse_args() + + if args.check: + return cmd_check(args) + if args.update: + return cmd_update(args) + if args.har: + return cmd_har(args) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index d94d472c9e..6c2ab95826 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -349,6 +349,41 @@ def should_run_python_linters(branch: str | None = None) -> bool: return _any_changed_file_endswith(branch, PYTHON_FILE_EXTENSIONS) +# Files outside esphome/**/*.py whose changes can affect `import esphome.__main__` +# cost. requirements.txt / pyproject.toml change the dependency graph pulled in +# by top-level imports; check_import_time.py itself changes the check's behavior. +IMPORT_TIME_TRIGGER_FILES = frozenset( + { + "requirements.txt", + "requirements_dev.txt", + "pyproject.toml", + "script/check_import_time.py", + "script/import_time_budget.json", + } +) + + +def should_run_import_time(branch: str | None = None) -> bool: + """Determine if the `import esphome.__main__` time regression check should run. + + Runs when any Python file under `esphome/` changes (those modules are + loaded transitively from `esphome.__main__`), when dependency + declarations change, or when the check script/budget itself changes. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if the import-time check should run, False otherwise. + """ + for file in changed_files(branch): + if file.startswith("esphome/") and file.endswith(PYTHON_FILE_EXTENSIONS): + return True + if file in IMPORT_TIME_TRIGGER_FILES: + return True + return False + + def determine_cpp_unit_tests( branch: str | None = None, ) -> tuple[bool, list[str]]: @@ -773,6 +808,7 @@ def main() -> None: run_clang_tidy = should_run_clang_tidy(args.branch) run_clang_format = should_run_clang_format(args.branch) run_python_linters = should_run_python_linters(args.branch) + run_import_time = should_run_import_time(args.branch) changed_cpp_file_count = count_changed_cpp_files(args.branch) # Get changed components @@ -906,6 +942,7 @@ def main() -> None: "clang_tidy_mode": clang_tidy_mode, "clang_format": run_clang_format, "python_linters": run_python_linters, + "import_time": run_import_time, "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/script/import_time_budget.json b/script/import_time_budget.json new file mode 100644 index 0000000000..6b084e2620 --- /dev/null +++ b/script/import_time_budget.json @@ -0,0 +1,5 @@ +{ + "target_module": "esphome.__main__", + "margin_pct": 15, + "cumulative_us": 75199 +} From 900232be2134a691ed29ad4702546310843ab1e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:20:31 -0500 Subject: [PATCH 0505/2214] [ci] Parse importtime-waterfall HAR JSON; raise budget for CI runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous parser walked `-X importtime` stderr by hand (string-splitting on `|`, indent-width math). Replace it with a load of the HAR JSON that importtime-waterfall already produces: each entry carries the module name, self-time, and cumulative — no tree reconstruction needed. Drops the hand-rolled retry loop too, since importtime-waterfall does best-of-6 internally. The committed budget (75.2ms) was seeded on a fast local machine; GHA runners measured 122.8ms, so the first CI run tripped the check. Reset the baseline to 123ms with a 25% margin (ceiling ~154ms) to absorb GHA variance. Tighten later once we have several data points. Adds unit tests for should_run_import_time across the trigger matrix and wires the new mock into the existing test_main_* suite. --- script/check_import_time.py | 124 ++++++++++------------------ script/import_time_budget.json | 4 +- tests/script/test_determine_jobs.py | 60 ++++++++++++++ 3 files changed, 106 insertions(+), 82 deletions(-) diff --git a/script/check_import_time.py b/script/check_import_time.py index b170806fc8..7b405475c3 100755 --- a/script/check_import_time.py +++ b/script/check_import_time.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 """Regression check for `import esphome.__main__` cost. -Runs `python -X importtime -c "import esphome.__main__"` in fresh subprocesses -and compares the root cumulative import time against a checked-in budget +Runs `python -m importtime_waterfall --har esphome.__main__` (which invokes +`-X importtime` in fresh subprocesses, best-of-N) and compares the root +cumulative import time against a checked-in budget (`script/import_time_budget.json`). The CLI pays this cost on every invocation before the requested command even -runs, so a regression here hurts every user. Pair this with -`python -m importtime_waterfall --har` for human-readable waterfalls. +runs, so a regression here hurts every user. """ from __future__ import annotations @@ -17,93 +17,72 @@ import json from pathlib import Path import subprocess import sys -import time from typing import Any, TextIO SCRIPT_DIR = Path(__file__).parent BUDGET_PATH = SCRIPT_DIR / "import_time_budget.json" TARGET_MODULE = "esphome.__main__" -DEFAULT_RUNS = 3 DEFAULT_MARGIN_PCT = 15 OFFENDERS_TOP_N = 15 -IMPORT_TIME_PREFIX = "import time:" -def _run_importtime(module: str) -> tuple[float, str]: - """Run `python -X importtime -c 'import '` once. +def run_waterfall(module: str) -> str: + """Run `importtime_waterfall --har ` and return the HAR JSON text. - Returns (wall_seconds, stderr_text). + `importtime_waterfall` itself runs the target in 6 fresh subprocesses + under `-X importtime` and emits the HAR of the fastest run. """ - before = time.monotonic() result = subprocess.run( - [sys.executable, "-X", "importtime", "-c", f"import {module}"], + [sys.executable, "-m", "importtime_waterfall", "--har", module], check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, + stdout=subprocess.PIPE, text=True, ) - return time.monotonic() - before, result.stderr + return result.stdout -def measure(module: str, runs: int) -> str: - """Run `module` import `runs` times; return the fastest run's stderr.""" - best_wall, best_stderr = _run_importtime(module) - for _ in range(runs - 1): - wall, stderr = _run_importtime(module) - if wall < best_wall: - best_wall, best_stderr = wall, stderr - return best_stderr +def measure(module: str) -> dict[str, Any]: + """Return the parsed HAR for importing `module`.""" + return json.loads(run_waterfall(module)) -def parse_trace(stderr: str) -> list[tuple[int, int, int, str]]: - """Parse `-X importtime` stderr into (depth, self_us, cumulative_us, name).""" - entries: list[tuple[int, int, int, str]] = [] - for line in stderr.splitlines(): - if not line.startswith(IMPORT_TIME_PREFIX): - continue - body = line[len(IMPORT_TIME_PREFIX) :] - parts = body.split("|") - if len(parts) != 3: - continue - try: - self_us = int(parts[0].strip()) - cumulative_us = int(parts[1].strip()) - except ValueError: - continue # header row - name_field = parts[2].rstrip("\n") - name = name_field.lstrip(" ") - depth = (len(name_field) - len(name)) // 2 - entries.append((depth, self_us, cumulative_us, name)) - return entries +def _entries(har: dict[str, Any]) -> list[dict[str, Any]]: + return har["log"]["entries"] -def root_cumulative_us(entries: list[tuple[int, int, int, str]], module: str) -> int: - """Return the cumulative import time of `module` (the root probe).""" - for depth, _self_us, cumulative_us, name in reversed(entries): - if depth == 0 and name == module: - return cumulative_us +def root_cumulative_us(har: dict[str, Any], module: str) -> int: + """Return the cumulative import time (µs) of `module` from a HAR. + + The HAR `time` field is authored by importtime_waterfall using µs values + fed through `timedelta(milliseconds=...)`, so the number read back is the + original self/cumulative time in microseconds (labelled "ms" in HAR). + """ + for entry in _entries(har): + if entry["request"]["url"] == module: + return entry["time"] raise RuntimeError( - f"Did not find a root-level trace entry for {module!r}. Is it importable?" + f"No HAR entry for {module!r}. Is it importable with " + f"`python -c 'import {module}'`?" ) -def top_offenders( - entries: list[tuple[int, int, int, str]], n: int -) -> list[tuple[str, int, int]]: - """Return up to `n` modules with the highest self-time, deduped by name. +def top_offenders(har: dict[str, Any], n: int) -> list[tuple[str, int, int]]: + """Return up to `n` (name, self_us, cumulative_us), ranked by self_us desc. - Returns list of (name, self_us, cumulative_us). A module imported from - multiple places is counted once, at its deepest-stacktrace occurrence - (same as what the user sees on the `--graph` output). + A module imported from multiple places is counted once (first entry wins, + matching importtime's own de-duplication). """ seen: dict[str, tuple[int, int]] = {} - for _depth, self_us, cumulative_us, name in entries: + for entry in _entries(har): + name = entry["request"]["url"] if name in seen: continue + self_us = entry["timings"]["receive"] + cumulative_us = entry["time"] seen[name] = (self_us, cumulative_us) ranked = sorted( - ((name, self_us, cum_us) for name, (self_us, cum_us) in seen.items()), + ((name, s, c) for name, (s, c) in seen.items()), key=lambda row: row[1], reverse=True, ) @@ -156,9 +135,8 @@ def cmd_check(args: argparse.Namespace) -> int: ) return 2 - stderr = measure(TARGET_MODULE, args.runs) - entries = parse_trace(stderr) - measured = root_cumulative_us(entries, TARGET_MODULE) + har = measure(TARGET_MODULE) + measured = root_cumulative_us(har, TARGET_MODULE) baseline = budget["cumulative_us"] margin_pct = budget.get("margin_pct", DEFAULT_MARGIN_PCT) @@ -180,7 +158,7 @@ def cmd_check(args: argparse.Namespace) -> int: f"Top import-time offenders (by self time):", file=sys.stderr, ) - _print_offenders_table(top_offenders(entries, OFFENDERS_TOP_N), sys.stderr) + _print_offenders_table(top_offenders(har, OFFENDERS_TOP_N), sys.stderr) print( "\nIf this regression is intentional, regenerate the budget with:\n" " script/check_import_time.py --update\n" @@ -192,9 +170,8 @@ def cmd_check(args: argparse.Namespace) -> int: def cmd_update(args: argparse.Namespace) -> int: - stderr = measure(TARGET_MODULE, args.runs) - entries = parse_trace(stderr) - measured = root_cumulative_us(entries, TARGET_MODULE) + har = measure(TARGET_MODULE) + measured = root_cumulative_us(har, TARGET_MODULE) write_budget(measured, args.margin_pct) print( f"Wrote {BUDGET_PATH.name}: " @@ -205,26 +182,13 @@ def cmd_update(args: argparse.Namespace) -> int: def cmd_har(args: argparse.Namespace) -> int: - out_path = Path(args.har) - result = subprocess.run( - [sys.executable, "-m", "importtime_waterfall", "--har", TARGET_MODULE], - check=True, - stdout=subprocess.PIPE, - text=True, - ) - out_path.write_text(result.stdout) - print(f"Wrote waterfall HAR to {out_path}") + Path(args.har).write_text(run_waterfall(TARGET_MODULE)) + print(f"Wrote waterfall HAR to {args.har}") return 0 def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--runs", - type=int, - default=DEFAULT_RUNS, - help=f"Number of measurement runs (default: {DEFAULT_RUNS}, best-of-N).", - ) parser.add_argument( "--margin-pct", type=int, diff --git a/script/import_time_budget.json b/script/import_time_budget.json index 6b084e2620..97b4f7456c 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", - "margin_pct": 15, - "cumulative_us": 75199 + "margin_pct": 25, + "cumulative_us": 123000 } diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index de239ee0b5..bef6625456 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -56,6 +56,13 @@ def mock_should_run_python_linters() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_should_run_import_time() -> Generator[Mock, None, None]: + """Mock should_run_import_time from determine_jobs.""" + with patch.object(determine_jobs, "should_run_import_time") as mock: + yield mock + + @pytest.fixture def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]: """Mock determine_cpp_unit_tests from helpers.""" @@ -91,6 +98,7 @@ def test_main_all_tests_should_run( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -104,6 +112,7 @@ def test_main_all_tests_should_run( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True + mock_should_run_import_time.return_value = True mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -158,6 +167,7 @@ def test_main_all_tests_should_run( assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is True assert output["python_linters"] is True + assert output["import_time"] is True assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -189,6 +199,7 @@ def test_main_no_tests_should_run( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -202,6 +213,7 @@ def test_main_no_tests_should_run( mock_should_run_clang_tidy.return_value = False mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -241,6 +253,7 @@ def test_main_no_tests_should_run( assert output["clang_tidy_mode"] == "disabled" assert output["clang_format"] is False assert output["python_linters"] is False + assert output["import_time"] is False assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -261,6 +274,7 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -274,6 +288,7 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = True + mock_should_run_import_time.return_value = True mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -310,6 +325,7 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy.assert_called_once_with("main") mock_should_run_clang_format.assert_called_once_with("main") mock_should_run_python_linters.assert_called_once_with("main") + mock_should_run_import_time.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -322,6 +338,7 @@ def test_main_with_branch_argument( assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is False assert output["python_linters"] is True + assert output["import_time"] is True assert output["changed_components"] == ["mqtt"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -597,6 +614,49 @@ def test_should_run_python_linters_with_branch() -> None: mock_changed.assert_called_once_with("release") +@pytest.mark.parametrize( + ("changed_files", "expected_result"), + [ + # esphome Python files trigger the check + (["esphome/__main__.py"], True), + (["esphome/components/wifi/__init__.py"], True), + (["esphome/core/config.py"], True), + (["esphome/types.pyi"], True), + # Dependency declarations and the check's own files trigger + (["requirements.txt"], True), + (["requirements_dev.txt"], True), + (["pyproject.toml"], True), + (["script/check_import_time.py"], True), + (["script/import_time_budget.json"], True), + # Mixed: any triggering file is enough + (["docs/README.md", "esphome/config.py"], True), + # Python files outside esphome/ don't trigger + (["script/some_other_script.py"], False), + (["tests/script/test_determine_jobs.py"], False), + # Non-Python changes don't trigger + (["esphome/core/component.cpp"], False), + (["tests/components/wifi/test.esp32-idf.yaml"], False), + (["README.md"], False), + ([], False), + ], +) +def test_should_run_import_time( + changed_files: list[str], expected_result: bool +) -> None: + """Test should_run_import_time function.""" + with patch.object(determine_jobs, "changed_files", return_value=changed_files): + result = determine_jobs.should_run_import_time() + assert result == expected_result + + +def test_should_run_import_time_with_branch() -> None: + """Test should_run_import_time with branch argument.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + determine_jobs.should_run_import_time("release") + mock_changed.assert_called_once_with("release") + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ From 64e000de13aae7b91eed083de5c4ca49b2d83fc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:22:33 -0500 Subject: [PATCH 0506/2214] [ci] Tighten import-time margin to 15% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI measured 126.2ms against 123ms baseline + 25% ceiling (153.8ms). That's 27.6ms of headroom — enough slack that a real regression could hide under it. Drop to 15% (ceiling 141.5ms, ~15ms headroom over observed). Still well above GHA runner variance, tight enough to catch the class of regression we care about (zeroconf-style top-level import additions). --- script/import_time_budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/import_time_budget.json b/script/import_time_budget.json index 97b4f7456c..1e656dc977 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", - "margin_pct": 25, + "margin_pct": 15, "cumulative_us": 123000 } From 6b7e8341748811ceb77d63bd6d5b2ad100bdb2b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:23:27 -0500 Subject: [PATCH 0507/2214] [ci] Always print top import-time offenders, not just on regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeing the top contributors every run — regression or not — gives reviewers and future-PR authors a running reference for which imports are currently most expensive. That's the data we need to prioritize lazy-import refactors, so there's no reason to hide it on pass. --- script/check_import_time.py | 41 +++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/script/check_import_time.py b/script/check_import_time.py index 7b405475c3..1c25a3cdd6 100755 --- a/script/check_import_time.py +++ b/script/check_import_time.py @@ -146,27 +146,32 @@ def cmd_check(args: argparse.Namespace) -> int: f"measured {TARGET_MODULE}: {_format_us(measured)} " f"(budget {_format_us(baseline)} + {margin_pct}% = {_format_us(ceiling)})" ) + passed = measured <= ceiling + stream = sys.stdout if passed else sys.stderr - if measured <= ceiling: + if passed: print(summary) - return 0 + else: + print( + f"REGRESSION: `import {TARGET_MODULE}` took {_format_us(measured)}, " + f"exceeding the budget of {_format_us(baseline)} + {margin_pct}% " + f"({_format_us(ceiling)}).", + file=stream, + ) - print( - f"REGRESSION: `import {TARGET_MODULE}` took {_format_us(measured)}, " - f"exceeding the budget of {_format_us(baseline)} + {margin_pct}% " - f"({_format_us(ceiling)}).\n" - f"Top import-time offenders (by self time):", - file=sys.stderr, - ) - _print_offenders_table(top_offenders(har, OFFENDERS_TOP_N), sys.stderr) - print( - "\nIf this regression is intentional, regenerate the budget with:\n" - " script/check_import_time.py --update\n" - "Otherwise, consider making the new import lazy " - "(import inside the function that uses it).", - file=sys.stderr, - ) - return 1 + print("\nTop import-time offenders (by self time):", file=stream) + _print_offenders_table(top_offenders(har, OFFENDERS_TOP_N), stream) + + if not passed: + print( + "\nIf this regression is intentional, regenerate the budget with:\n" + " script/check_import_time.py --update\n" + "Otherwise, consider making the new import lazy " + "(import inside the function that uses it).", + file=stream, + ) + return 1 + return 0 def cmd_update(args: argparse.Namespace) -> int: From b8c6bd69c9a7387e356e72dfd39e904f3d20c3b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:42:18 -0500 Subject: [PATCH 0508/2214] [ci] Cache importtime-waterfall in the shared venv The import-time job previously did a `pip install` on every run (~1s and some noisy output). Moving the pin to `requirements_test.txt` bakes it into the venv that `common` already builds and caches, so the job restores it from the cache and skips the install step. --- .github/workflows/ci.yml | 4 ---- requirements_test.txt | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03d0822e66..49767de0b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,10 +123,6 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Install importtime-waterfall - run: | - . venv/bin/activate - pip install importtime-waterfall==1.0.0 - name: Check import time against budget run: | . venv/bin/activate diff --git a/requirements_test.txt b/requirements_test.txt index bb98375cb6..e0eb4396c5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,3 +12,6 @@ pytest-asyncio==1.3.0 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 + +# Used by the import-time regression check (.github/workflows/ci.yml → import-time job) +importtime-waterfall==1.0.0 From 99c80b7dfa5858110cbb1ee91da4aecf7b29ce3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:47:03 -0500 Subject: [PATCH 0509/2214] [ci] Address copilot review feedback on import-time check - determine-jobs: trigger on changes to requirements_test.txt too; that file is hashed into the venv cache key and installed during restore-python, so a change there can alter the import environment. - ci.yml: merge the --check and --har steps so we only run importtime-waterfall once per job (was measuring twice: ~7-8s of wasted CI time per run). Uses the new script/check_import_time.py --check --har combination; the HAR reflects the same measurement that produced the pass/fail decision. - script/check_import_time.py: refactor the CLI so --har is a standalone option rather than a mutually-exclusive mode. --check and --update each accept an optional --har PATH that writes the HAR from the same subprocess invocation. Plain --har is still supported for local use. - tests: add tests/script/test_check_import_time.py covering HAR parsing, root lookup, offender ranking/dedup, budget round-trip, and the three --check exit paths (pass, regression, missing budget) plus the new --check --har combined write. Add requirements_test.txt case to the should_run_import_time parametrized test. --- .github/workflows/ci.yml | 10 +- script/check_import_time.py | 40 ++++-- script/determine-jobs.py | 1 + tests/script/test_check_import_time.py | 191 +++++++++++++++++++++++++ tests/script/test_determine_jobs.py | 1 + 5 files changed, 222 insertions(+), 21 deletions(-) create mode 100644 tests/script/test_check_import_time.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49767de0b7..d60bd6edc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,21 +123,17 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Check import time against budget + - name: Check import time against budget and write waterfall HAR run: | . venv/bin/activate - script/check_import_time.py --check - - name: Generate waterfall HAR - if: always() - run: | - . venv/bin/activate - script/check_import_time.py --har importtime.har + script/check_import_time.py --check --har importtime.har - name: Upload waterfall HAR if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: import-time-waterfall path: importtime.har + if-no-files-found: ignore retention-days: 14 pytest: diff --git a/script/check_import_time.py b/script/check_import_time.py index 1c25a3cdd6..0d5362c968 100755 --- a/script/check_import_time.py +++ b/script/check_import_time.py @@ -42,9 +42,16 @@ def run_waterfall(module: str) -> str: return result.stdout -def measure(module: str) -> dict[str, Any]: - """Return the parsed HAR for importing `module`.""" - return json.loads(run_waterfall(module)) +def measure(module: str, har_path: Path | None = None) -> dict[str, Any]: + """Return the parsed HAR for importing `module`. + + When `har_path` is given, also write the raw HAR JSON to that path so + callers can combine `--check` with `--har` without measuring twice. + """ + har_text = run_waterfall(module) + if har_path is not None: + har_path.write_text(har_text) + return json.loads(har_text) def _entries(har: dict[str, Any]) -> list[dict[str, Any]]: @@ -135,7 +142,7 @@ def cmd_check(args: argparse.Namespace) -> int: ) return 2 - har = measure(TARGET_MODULE) + har = measure(TARGET_MODULE, har_path=Path(args.har) if args.har else None) measured = root_cumulative_us(har, TARGET_MODULE) baseline = budget["cumulative_us"] @@ -175,7 +182,7 @@ def cmd_check(args: argparse.Namespace) -> int: def cmd_update(args: argparse.Namespace) -> int: - har = measure(TARGET_MODULE) + har = measure(TARGET_MODULE, har_path=Path(args.har) if args.har else None) measured = root_cumulative_us(har, TARGET_MODULE) write_budget(measured, args.margin_pct) print( @@ -186,7 +193,7 @@ def cmd_update(args: argparse.Namespace) -> int: return 0 -def cmd_har(args: argparse.Namespace) -> int: +def cmd_har_only(args: argparse.Namespace) -> int: Path(args.har).write_text(run_waterfall(TARGET_MODULE)) print(f"Wrote waterfall HAR to {args.har}") return 0 @@ -200,7 +207,16 @@ def main() -> int: default=DEFAULT_MARGIN_PCT, help=(f"Margin over baseline for --update (default: {DEFAULT_MARGIN_PCT}%%)."), ) - mode = parser.add_mutually_exclusive_group(required=True) + parser.add_argument( + "--har", + metavar="PATH", + help=( + "Write a waterfall HAR file at PATH. Can be combined with " + "--check or --update to reuse that run's measurement (avoids " + "measuring twice)." + ), + ) + mode = parser.add_mutually_exclusive_group() mode.add_argument( "--check", action="store_true", help="Fail if measured time exceeds budget." ) @@ -209,11 +225,6 @@ def main() -> int: action="store_true", help="Rewrite the budget from a fresh measurement.", ) - mode.add_argument( - "--har", - metavar="PATH", - help="Write a waterfall HAR file via `importtime_waterfall --har`.", - ) args = parser.parse_args() if args.check: @@ -221,8 +232,9 @@ def main() -> int: if args.update: return cmd_update(args) if args.har: - return cmd_har(args) - return 2 + return cmd_har_only(args) + parser.error("Specify at least one of --check, --update, or --har PATH.") + return 2 # unreachable; parser.error exits. Here to satisfy ruff RET503. if __name__ == "__main__": diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 6c2ab95826..1842aba3a0 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -356,6 +356,7 @@ IMPORT_TIME_TRIGGER_FILES = frozenset( { "requirements.txt", "requirements_dev.txt", + "requirements_test.txt", "pyproject.toml", "script/check_import_time.py", "script/import_time_budget.json", diff --git a/tests/script/test_check_import_time.py b/tests/script/test_check_import_time.py new file mode 100644 index 0000000000..223c58002c --- /dev/null +++ b/tests/script/test_check_import_time.py @@ -0,0 +1,191 @@ +"""Unit tests for script/check_import_time.py.""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +# Load the script-under-test as `check_import_time` (it's a hyphenated path +# inside `script/` that mirrors the existing `determine_jobs` pattern). +script_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "script") +) +sys.path.insert(0, script_dir) +spec = importlib.util.spec_from_file_location( + "check_import_time", os.path.join(script_dir, "check_import_time.py") +) +check_import_time = importlib.util.module_from_spec(spec) +spec.loader.exec_module(check_import_time) + + +def _entry(name: str, self_us: int, cumulative_us: int) -> dict: + """Build a minimal HAR entry matching `importtime_waterfall --har`.""" + return { + "request": {"url": name}, + "time": cumulative_us, + "timings": {"receive": self_us, "wait": cumulative_us - self_us}, + } + + +def _har(*entries: dict) -> dict: + return {"log": {"entries": list(entries)}} + + +def test_root_cumulative_us_returns_time_for_root_module() -> None: + har = _har( + _entry("dep_a", 500, 500), + _entry("dep_b", 300, 300), + _entry("esphome.__main__", 100, 1000), + ) + assert check_import_time.root_cumulative_us(har, "esphome.__main__") == 1000 + + +def test_root_cumulative_us_missing_module_raises() -> None: + har = _har(_entry("something.else", 100, 100)) + with pytest.raises(RuntimeError, match="No HAR entry for 'esphome.__main__'"): + check_import_time.root_cumulative_us(har, "esphome.__main__") + + +def test_top_offenders_ranks_by_self_time_descending() -> None: + har = _har( + _entry("small", 100, 100), + _entry("big", 5000, 5000), + _entry("medium", 2000, 2500), + ) + result = check_import_time.top_offenders(har, n=10) + assert [name for name, _, _ in result] == ["big", "medium", "small"] + assert result[0] == ("big", 5000, 5000) + + +def test_top_offenders_respects_n_limit() -> None: + har = _har(*[_entry(f"m{i}", i * 100, i * 100) for i in range(1, 20)]) + assert len(check_import_time.top_offenders(har, n=5)) == 5 + + +def test_top_offenders_dedupes_repeat_names_keeping_first() -> None: + har = _har( + _entry("pkg", 5000, 5000), + _entry("pkg", 100, 100), # reimport later in trace + _entry("other", 1000, 1000), + ) + result = check_import_time.top_offenders(har, n=10) + assert [name for name, _, _ in result] == ["pkg", "other"] + # First occurrence wins + assert ("pkg", 5000, 5000) in result + + +def test_format_us_switches_to_ms_at_threshold() -> None: + assert check_import_time._format_us(500) == "500us" + assert check_import_time._format_us(999) == "999us" + assert check_import_time._format_us(1000) == "1.0ms" + assert check_import_time._format_us(12345) == "12.3ms" + + +def test_read_write_budget_roundtrip(tmp_path: Path) -> None: + budget_path = tmp_path / "budget.json" + with patch.object(check_import_time, "BUDGET_PATH", budget_path): + assert check_import_time.read_budget() == {} + check_import_time.write_budget(cumulative_us=12345, margin_pct=20) + loaded = check_import_time.read_budget() + assert loaded["cumulative_us"] == 12345 + assert loaded["margin_pct"] == 20 + assert loaded["target_module"] == check_import_time.TARGET_MODULE + + +def test_cmd_check_passes_when_measured_within_ceiling( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + budget_path = tmp_path / "budget.json" + budget_path.write_text( + json.dumps( + { + "target_module": check_import_time.TARGET_MODULE, + "margin_pct": 15, + "cumulative_us": 100000, # 100ms + } + ) + ) + # Measured 90ms: inside 100ms + 15% = 115ms ceiling + har = _har(_entry(check_import_time.TARGET_MODULE, 1000, 90000)) + args = type("A", (), {"har": None})() + with ( + patch.object(check_import_time, "BUDGET_PATH", budget_path), + patch.object(check_import_time, "measure", return_value=har), + ): + rc = check_import_time.cmd_check(args) + assert rc == 0 + out = capsys.readouterr().out + assert "measured esphome.__main__:" in out + assert "budget 100.0ms" in out + + +def test_cmd_check_fails_when_measured_exceeds_ceiling( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + budget_path = tmp_path / "budget.json" + budget_path.write_text( + json.dumps( + { + "target_module": check_import_time.TARGET_MODULE, + "margin_pct": 15, + "cumulative_us": 100000, + } + ) + ) + # Measured 120ms: over 100ms + 15% = 115ms ceiling + har = _har( + _entry("offender_a", 10000, 10000), + _entry(check_import_time.TARGET_MODULE, 1000, 120000), + ) + args = type("A", (), {"har": None})() + with ( + patch.object(check_import_time, "BUDGET_PATH", budget_path), + patch.object(check_import_time, "measure", return_value=har), + ): + rc = check_import_time.cmd_check(args) + assert rc == 1 + err = capsys.readouterr().err + assert "REGRESSION" in err + assert "120.0ms" in err + assert "offender_a" in err # top offender table + + +def test_cmd_check_returns_2_when_budget_missing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + budget_path = tmp_path / "nonexistent.json" + args = type("A", (), {"har": None})() + with patch.object(check_import_time, "BUDGET_PATH", budget_path): + rc = check_import_time.cmd_check(args) + assert rc == 2 + assert "missing" in capsys.readouterr().err + + +def test_cmd_check_writes_har_when_path_given(tmp_path: Path) -> None: + budget_path = tmp_path / "budget.json" + budget_path.write_text( + json.dumps( + { + "target_module": check_import_time.TARGET_MODULE, + "margin_pct": 15, + "cumulative_us": 100000, + } + ) + ) + har_path = tmp_path / "out.har" + har_text = json.dumps(_har(_entry(check_import_time.TARGET_MODULE, 1000, 80000))) + args = type("A", (), {"har": str(har_path)})() + with ( + patch.object(check_import_time, "BUDGET_PATH", budget_path), + patch.object(check_import_time, "run_waterfall", return_value=har_text), + ): + rc = check_import_time.cmd_check(args) + assert rc == 0 + assert har_path.exists() + assert json.loads(har_path.read_text()) == json.loads(har_text) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index bef6625456..d44fecb95d 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -625,6 +625,7 @@ def test_should_run_python_linters_with_branch() -> None: # Dependency declarations and the check's own files trigger (["requirements.txt"], True), (["requirements_dev.txt"], True), + (["requirements_test.txt"], True), (["pyproject.toml"], True), (["script/check_import_time.py"], True), (["script/import_time_budget.json"], True), From fa055b9e490fa0408fa1293a642d2ec8ddf094ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:30:03 -0500 Subject: [PATCH 0510/2214] [core] Lazy-load zeroconf/writer/yaml_util in __main__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every \`esphome\` CLI invocation pays the cost of whatever \`esphome/__main__.py\` imports at module scope before the requested command even runs. This moves three heavy imports into the functions that actually use them: - \`esphome.zeroconf\` (discover_mdns_devices): only needed for the name_add_mac_suffix OTA discovery path. - \`esphome.writer\`: only needed by compile/clean paths. - \`esphome.yaml_util\`: only needed by codegen, config dump, and rename. Local measurement drops from ~75ms to ~47ms (-37%) for a cold \`python -c 'import esphome.__main__'\`. The zeroconf chain alone accounts for most of the gain — the case that motivated the CI budget check. Also adds a module-level note explaining the intent so future PRs don't innocently promote these back to the top. Retargets four test patches from \`esphome.__main__.discover_mdns_devices\` to \`esphome.zeroconf.discover_mdns_devices\` now that the symbol is only bound inside the function that calls it. --- esphome/__main__.py | 25 +++++++++++++++++++++++-- tests/unit_tests/test_main.py | 8 ++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 8c80dab90a..7556773a01 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -21,7 +21,7 @@ import argcomplete # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. -from esphome import const, writer, yaml_util +from esphome import const import esphome.codegen as cg from esphome.config import iter_component_configs, read_config, strip_default_ids from esphome.const import ( @@ -72,7 +72,12 @@ from esphome.util import ( run_external_process, safe_print, ) -from esphome.zeroconf import discover_mdns_devices + +# 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 +# any command runs. Import inside the function that needs it instead. +# `script/check_import_time.py` enforces a budget in CI. _LOGGER = logging.getLogger(__name__) @@ -241,6 +246,8 @@ def _discover_mac_suffix_devices() -> list[str] | None: """ if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()): return None + from esphome.zeroconf import discover_mdns_devices + _LOGGER.info("Discovering devices...") if not (discovered := discover_mdns_devices(CORE.name)): _LOGGER.warning( @@ -661,6 +668,8 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: def wrap_to_code(name, comp): + from esphome import yaml_util + coro = coroutine(comp.to_code) @functools.wraps(comp.to_code) @@ -680,6 +689,8 @@ def wrap_to_code(name, comp): def write_cpp(config: ConfigType, native_idf: bool = False) -> int: + from esphome import writer + if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() @@ -702,6 +713,8 @@ def generate_cpp_contents(config: ConfigType) -> None: def write_cpp_file(native_idf: bool = False) -> int: + from esphome import writer + code_s = indent(CORE.cpp_main_section) writer.write_cpp(code_s) @@ -1180,6 +1193,8 @@ def command_wizard(args: ArgsProtocol) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: + from esphome import yaml_util + if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) @@ -1321,6 +1336,8 @@ def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: def command_clean_all(args: ArgsProtocol) -> int | None: + from esphome import writer + try: writer.clean_all(args.configuration) except OSError as err: @@ -1336,6 +1353,8 @@ def command_version(args: ArgsProtocol) -> int | None: def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: + from esphome import writer + try: writer.clean_build() except OSError as err: @@ -1538,6 +1557,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: + from esphome import yaml_util + new_name = args.name for c in new_name: if c not in ALLOWED_NAME_CHARS: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8ec9e70cf8..fb8f206a1d 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2605,7 +2605,7 @@ def test_choose_upload_log_host_discovers_mac_suffix_devices(tmp_path: Path) -> } with ( patch( - "esphome.__main__.discover_mdns_devices", return_value=discovered + "esphome.zeroconf.discover_mdns_devices", return_value=discovered ) as mock_discover, patch( "esphome.__main__.choose_prompt", return_value="mydevice-abc123.local" @@ -2653,7 +2653,7 @@ def test_choose_upload_log_host_mac_suffix_no_devices_found( ) with ( - patch("esphome.__main__.discover_mdns_devices", return_value={}), + patch("esphome.zeroconf.discover_mdns_devices", return_value={}), caplog.at_level(logging.WARNING, logger="esphome.__main__"), pytest.raises(EsphomeError), ): @@ -2686,7 +2686,7 @@ def test_choose_upload_log_host_default_ota_discovers_mac_suffix( "mydevice-def456.local": ["10.0.0.2"], } with patch( - "esphome.__main__.discover_mdns_devices", return_value=discovered + "esphome.zeroconf.discover_mdns_devices", return_value=discovered ) as mock_discover: result = choose_upload_log_host( default="OTA", @@ -2715,7 +2715,7 @@ def test_choose_upload_log_host_default_ota_no_suffix_discovery( name="mydevice", ) - with patch("esphome.__main__.discover_mdns_devices") as mock_discover: + with patch("esphome.zeroconf.discover_mdns_devices") as mock_discover: result = choose_upload_log_host( default="OTA", check_default=None, From fd63a67ad8485b42c7eb23239e7abc17340e1150 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:34:17 -0500 Subject: [PATCH 0511/2214] [ci] Lower import-time budget after lazy-import refactor CI now measures 81.2ms after the zeroconf/writer/yaml_util lazy loads. Reset baseline to 82ms with the 15% margin (ceiling 94.3ms, ~13ms headroom for GHA variance). --- script/import_time_budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/import_time_budget.json b/script/import_time_budget.json index 1e656dc977..2f9cac038c 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", "margin_pct": 15, - "cumulative_us": 123000 + "cumulative_us": 82000 } From 5d624c7f76224a3f8581b80037759006480f55ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:38:54 -0500 Subject: [PATCH 0512/2214] [core] Lazy-load esphome.core.config from loader and config The top-level `import esphome.core.config` in `esphome/loader.py` (used only to register the "esphome" pseudo-component in `_COMPONENT_CACHE`) was pulling `esphome.automation` + `esphome.config_validation` eagerly into every `esphome` CLI invocation, even fast paths like `esphome version`. Move the cache registration into `_lookup_module`, triggered on first `get_component("esphome")`. Also defers the two `esphome.core.config` call sites inside `esphome/config.py` (CoreFinalValidateStep and validate_config), which were the other eager entry point. Local `import esphome.__main__` drops from ~49ms to ~46ms; more importantly, `esphome.core.config` / `esphome.automation` no longer appear in the top offenders table, and `esphome.config_validation` drops from 4.7ms self to 2.0ms self on CI. --- esphome/config.py | 9 ++++++++- esphome/loader.py | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 641b6ec1b4..e2f1094611 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -25,7 +25,10 @@ from esphome.const import ( CONF_SUBSTITUTIONS, ) from esphome.core import CORE, DocumentRange, EsphomeError -import esphome.core.config as core_config + +# `esphome.core.config` is imported lazily at its two use sites below. +# It pulls in `esphome.automation` and `esphome.config_validation`, which +# dominate `esphome.__main__` startup cost when loaded eagerly here. import esphome.final_validate as fv from esphome.helpers import indent from esphome.loader import ComponentManifest, get_component, get_platform @@ -968,6 +971,8 @@ class CoreFinalValidateStep(ConfigValidationStep): if result.errors: return + import esphome.core.config as core_config + token = fv.full_config.set(result) with result.catch_error([CONF_ESPHOME]): if CONF_ESPHOME in result: @@ -1072,6 +1077,8 @@ def validate_config( return result # 2. Load partial core config + import esphome.core.config as core_config + result[CONF_ESPHOME] = config[CONF_ESPHOME] result.add_output_path([CONF_ESPHOME], CONF_ESPHOME) try: diff --git a/esphome/loader.py b/esphome/loader.py index 68664aaa26..839f75fa29 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -13,9 +13,13 @@ from typing import Any from esphome.const import SOURCE_FILE_EXTENSIONS from esphome.core import CORE -import esphome.core.config from esphome.types import ConfigType +# `esphome.core.config` is imported lazily in `_lookup_module` when the +# "esphome" pseudo-component is first resolved. It pulls in +# `esphome.automation` and `esphome.config_validation`, which together +# dominate `esphome.__main__` startup cost when loaded eagerly. + _LOGGER = logging.getLogger(__name__) @@ -202,6 +206,13 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: if domain in _COMPONENT_CACHE: return _COMPONENT_CACHE[domain] + if domain == "esphome": + import esphome.core.config + + manif = ComponentManifest(esphome.core.config) + _COMPONENT_CACHE[domain] = manif + return manif + try: module = importlib.import_module(f"esphome.components.{domain}") except ImportError as e: @@ -237,7 +248,6 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None: _COMPONENT_CACHE: dict[str, ComponentManifest] = {} CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() -_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None: From 87fa5a2d48ef10f35bfd441d35f2830f6ae63967 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:47:50 -0500 Subject: [PATCH 0513/2214] [ci] Reset import-time baseline to CI-observed 91ms CI measured 90.6ms after the lazy-import refactor lands. Set baseline to 91ms with 15% margin (ceiling 104.65ms, ~14ms headroom for GHA variance over the observed measurement). --- script/import_time_budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/import_time_budget.json b/script/import_time_budget.json index 2f9cac038c..af3aa83511 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", "margin_pct": 15, - "cumulative_us": 82000 + "cumulative_us": 91000 } From 52f47d0b91be2f8ea8fdfcfa9e0f6bbd933f6b68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 14:57:31 -0500 Subject: [PATCH 0514/2214] [core] Import yaml_util once per build in generate_cpp_contents Address copilot review: wrap_to_code is called once per component, so the lazy `from esphome import yaml_util` inside it was doing a (cached) sys.modules lookup per component. Move the import up to generate_cpp_contents, pass the module down, rename the helper to _wrap_to_code to mark it private. --- esphome/__main__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7556773a01..781bcd6288 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -667,9 +667,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: return 0 -def wrap_to_code(name, comp): - from esphome import yaml_util - +def _wrap_to_code(name, comp, yaml_util): coro = coroutine(comp.to_code) @functools.wraps(comp.to_code) @@ -702,11 +700,13 @@ def write_cpp(config: ConfigType, native_idf: bool = False) -> int: def generate_cpp_contents(config: ConfigType) -> None: + from esphome import yaml_util + _LOGGER.info("Generating C++ source...") for name, component, conf in iter_component_configs(CORE.config): if component.to_code is not None: - coro = wrap_to_code(name, component) + coro = _wrap_to_code(name, component, yaml_util) CORE.add_job(coro, conf) CORE.flush_tasks() From d759f1a56751207689f5db024181739c96d94646 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 16:53:52 -0400 Subject: [PATCH 0515/2214] [audio_http] Add a media source for playing audio from HTTP URLs (#15741) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/audio_http/__init__.py | 0 .../audio_http/audio_http_media_source.cpp | 163 ++++++++++++++++++ .../audio_http/audio_http_media_source.h | 59 +++++++ esphome/components/audio_http/media_source.py | 59 +++++++ tests/components/audio_http/common.yaml | 7 + .../components/audio_http/test.esp32-idf.yaml | 1 + 7 files changed, 290 insertions(+) create mode 100644 esphome/components/audio_http/__init__.py create mode 100644 esphome/components/audio_http/audio_http_media_source.cpp create mode 100644 esphome/components/audio_http/audio_http_media_source.h create mode 100644 esphome/components/audio_http/media_source.py create mode 100644 tests/components/audio_http/common.yaml create mode 100644 tests/components/audio_http/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 69f2cb1d17..be835aae3d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,6 +56,7 @@ esphome/components/audio_adc/* @kbx81 esphome/components/audio_dac/* @kbx81 esphome/components/audio_file/* @kahrendt esphome/components/audio_file/media_source/* @kahrendt +esphome/components/audio_http/* @kahrendt esphome/components/axs15231/* @clydebarrow esphome/components/b_parasite/* @rbaron esphome/components/ballu/* @bazuchan diff --git a/esphome/components/audio_http/__init__.py b/esphome/components/audio_http/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/audio_http/audio_http_media_source.cpp b/esphome/components/audio_http/audio_http_media_source.cpp new file mode 100644 index 0000000000..04b7d046e6 --- /dev/null +++ b/esphome/components/audio_http/audio_http_media_source.cpp @@ -0,0 +1,163 @@ +#include "audio_http_media_source.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include +#include + +#include + +namespace esphome::audio_http { + +static const char *const TAG = "audio_http_media_source"; + +// Decoder task / buffer tuning. Kept here as constants so the header stays free of magic numbers. +static constexpr size_t DEFAULT_TRANSFER_BUFFER_SIZE = 8 * 1024; // Staging buffer between HTTP reader and decoder +static constexpr uint32_t HTTP_TIMEOUT_MS = 5000; // HTTP connect/read timeout +static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; // Max blocking time per on_audio_write() call +static constexpr uint32_t READER_WRITE_TIMEOUT_MS = 50; // Max blocking time when writing into the ring buffer +static constexpr uint8_t READER_TASK_PRIORITY = 2; +static constexpr uint8_t DECODER_TASK_PRIORITY = 2; +static constexpr size_t READER_TASK_STACK_SIZE = 4096; +static constexpr size_t DECODER_TASK_STACK_SIZE = 5120; +static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20; +static constexpr const char *const HTTP_URI_PREFIX = "http://"; +static constexpr const char *const HTTPS_URI_PREFIX = "https://"; + +void AudioHTTPMediaSource::dump_config() { + ESP_LOGCONFIG(TAG, + "Audio HTTP Media Source:\n" + " Buffer Size: %zu bytes\n" + " Decoder Task Stack in PSRAM: %s", + this->buffer_size_, YESNO(this->decoder_task_stack_in_psram_)); +} + +void AudioHTTPMediaSource::setup() { + this->disable_loop(); + + micro_decoder::DecoderConfig config; + config.ring_buffer_size = this->buffer_size_; + // Keep the transfer buffer smaller than the ring buffer so the reader can top up the ring + // while the decoder is still draining it, instead of oscillating between empty and full. + config.transfer_buffer_size = std::min(DEFAULT_TRANSFER_BUFFER_SIZE, this->buffer_size_ / 2); + config.http_timeout_ms = HTTP_TIMEOUT_MS; + config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS; + config.reader_write_timeout_ms = READER_WRITE_TIMEOUT_MS; + config.reader_priority = READER_TASK_PRIORITY; + config.decoder_priority = DECODER_TASK_PRIORITY; + config.reader_stack_size = READER_TASK_STACK_SIZE; + config.decoder_stack_size = DECODER_TASK_STACK_SIZE; + config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_; + + this->decoder_ = std::make_unique(config); + if (this->decoder_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate decoder"); + this->mark_failed(); + return; + } + this->decoder_->set_listener(this); // We inherit from micro_decoder::DecoderListener +} + +void AudioHTTPMediaSource::loop() { this->decoder_->loop(); } + +bool AudioHTTPMediaSource::can_handle(const std::string &uri) const { + return uri.starts_with(HTTP_URI_PREFIX) || uri.starts_with(HTTPS_URI_PREFIX); +} + +// Called from the orchestrator's main loop, so no synchronization needed with loop() +bool AudioHTTPMediaSource::play_uri(const std::string &uri) { + if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) { + return false; + } + + // Check if source is already playing + if (this->get_state() != media_source::MediaSourceState::IDLE) { + ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str()); + return false; + } + + // Validate URI starts with "http://" or "https://" + if (!uri.starts_with(HTTP_URI_PREFIX) && !uri.starts_with(HTTPS_URI_PREFIX)) { + ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); + return false; + } + + if (this->decoder_->play_url(uri)) { + this->pause_.store(false, std::memory_order_relaxed); + this->enable_loop(); + return true; + } + + ESP_LOGE(TAG, "Failed to start playback of '%s'", uri.c_str()); + return false; +} + +// Called from the orchestrator's main loop, so no synchronization needed with loop() +void AudioHTTPMediaSource::handle_command(media_source::MediaSourceCommand command) { + switch (command) { + case media_source::MediaSourceCommand::STOP: + this->decoder_->stop(); + break; + case media_source::MediaSourceCommand::PAUSE: + // Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state + // machine from getting stuck in PAUSED when no playback is active (which would block the + // next play_uri() call via its IDLE-state precondition). + if (this->get_state() != media_source::MediaSourceState::PLAYING) + break; + // PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily + // yields, which fills the ring buffer and applies back pressure that effectively pauses both + // the decoder and HTTP reader tasks. + this->set_state_(media_source::MediaSourceState::PAUSED); + this->pause_.store(true, std::memory_order_relaxed); + break; + case media_source::MediaSourceCommand::PLAY: + // Only resume from PAUSED; don't fabricate a PLAYING state from IDLE/ERROR. + if (this->get_state() != media_source::MediaSourceState::PAUSED) + break; + this->set_state_(media_source::MediaSourceState::PLAYING); + this->pause_.store(false, std::memory_order_relaxed); + break; + default: + break; + } +} + +// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for +// being thread-safe with respect to its own audio writer. +size_t AudioHTTPMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) { + if (this->pause_.load(std::memory_order_relaxed)) { + vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS)); + return 0; + } + return this->write_output(data, length, timeout_ms, this->stream_info_); +} + +// Called from the decoder task before the first on_audio_write(). +void AudioHTTPMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) { + this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate()); +} + +// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main +// loop thread and it's safe to call set_state_() directly. +void AudioHTTPMediaSource::on_state_change(micro_decoder::DecoderState state) { + switch (state) { + case micro_decoder::DecoderState::IDLE: + this->set_state_(media_source::MediaSourceState::IDLE); + this->disable_loop(); + break; + case micro_decoder::DecoderState::PLAYING: + this->set_state_(media_source::MediaSourceState::PLAYING); + break; + case micro_decoder::DecoderState::FAILED: + this->set_state_(media_source::MediaSourceState::ERROR); + break; + default: + break; + } +} + +} // namespace esphome::audio_http + +#endif // USE_ESP32 diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h new file mode 100644 index 0000000000..e4bd69e9e6 --- /dev/null +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/audio/audio.h" +#include "esphome/components/media_source/media_source.h" +#include "esphome/core/component.h" + +#include +#include + +#include +#include +#include + +namespace esphome::audio_http { + +// Inherits from two unrelated listener-style interfaces: +// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator +// (the orchestrator calls set_listener() on us with a MediaSourceListener*). +// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded +// audio and state changes (we call decoder_->set_listener(this) in setup()). +// The two set_listener() methods live on different base classes and serve opposite directions. +class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { + public: + void setup() override; + void loop() override; + void dump_config() override; + + void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; } + + // MediaSource interface implementation + bool play_uri(const std::string &uri) override; + void handle_command(media_source::MediaSourceCommand command) override; + bool can_handle(const std::string &uri) const override; + + // DecoderListener interface implementation + size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override; + void on_stream_info(const micro_decoder::AudioStreamInfo &info) override; + void on_state_change(micro_decoder::DecoderState state) override; + + protected: + std::unique_ptr decoder_; + audio::AudioStreamInfo stream_info_; + + size_t buffer_size_{50000}; + + // Written from the main loop in handle_command(), read from the decoder task in + // on_audio_write(). Must be atomic to avoid a data race. + std::atomic pause_{false}; + bool decoder_task_stack_in_psram_{false}; +}; + +} // namespace esphome::audio_http + +#endif // USE_ESP32 diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py new file mode 100644 index 0000000000..519d8df698 --- /dev/null +++ b/esphome/components/audio_http/media_source.py @@ -0,0 +1,59 @@ +from typing import Any + +import esphome.codegen as cg +from esphome.components import audio, esp32, media_source, psram +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM +from esphome.types import ConfigType + +CODEOWNERS = ["@kahrendt"] +AUTO_LOAD = ["audio"] + +audio_http_ns = cg.esphome_ns.namespace("audio_http") +AudioHTTPMediaSource = audio_http_ns.class_( + "AudioHTTPMediaSource", cg.Component, media_source.MediaSource +) + + +def _request_micro_decoder(config: ConfigType) -> ConfigType: + audio.request_micro_decoder_support() + return config + + +def _validate_task_stack_in_psram(value: Any) -> bool: + # Only require the psram component when actually enabling PSRAM stacks; validating + # the boolean first means `false` doesn't trigger the requires_component check. + if value := cv.boolean(value): + return cv.requires_component(psram.DOMAIN)(value) + return value + + +CONFIG_SCHEMA = cv.All( + media_source.media_source_schema( + AudioHTTPMediaSource, + ) + .extend( + { + cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range( + min=5000, max=1000000 + ), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + } + ) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, + _request_micro_decoder, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await media_source.register_media_source(var, config) + + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) + cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) diff --git a/tests/components/audio_http/common.yaml b/tests/components/audio_http/common.yaml new file mode 100644 index 0000000000..b7457165a5 --- /dev/null +++ b/tests/components/audio_http/common.yaml @@ -0,0 +1,7 @@ +psram: + +media_source: + - platform: audio_http + id: audio_http_source + buffer_size: 100000 + task_stack_in_psram: true diff --git a/tests/components/audio_http/test.esp32-idf.yaml b/tests/components/audio_http/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/audio_http/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 90d7bfe02ea4f5fa61fdcec4a0645aa95da88b53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 16:36:32 -0500 Subject: [PATCH 0516/2214] [ci] Auto-close PRs opened from a fork's default branch (#15957) --- .../close-pr-from-fork-default-branch.yml | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/workflows/close-pr-from-fork-default-branch.yml diff --git a/.github/workflows/close-pr-from-fork-default-branch.yml b/.github/workflows/close-pr-from-fork-default-branch.yml new file mode 100644 index 0000000000..1cd70f5efc --- /dev/null +++ b/.github/workflows/close-pr-from-fork-default-branch.yml @@ -0,0 +1,72 @@ +name: Close PR From Fork Default Branch + +on: + # pull_request_target is required so we have permission to comment and close PRs from forks. + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + issues: write + +jobs: + close: + name: Close PR opened from fork's default branch + runs-on: ubuntu-latest + if: >- + github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + && github.event.pull_request.head.ref == github.event.repository.default_branch + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const author = context.payload.pull_request.user.login; + const defaultBranch = context.payload.repository.default_branch; + const headRepo = context.payload.pull_request.head.repo.full_name; + + const body = [ + `Hi @${author}, thanks for opening a pull request! :tada:`, + ``, + `It looks like this PR was opened from the \`${defaultBranch}\` branch of your fork (\`${headRepo}\`), which is the same name as this repository's default branch. Working directly on \`${defaultBranch}\` in your fork causes a few problems:`, + ``, + `- Your fork's \`${defaultBranch}\` branch will permanently diverge from \`esphome/esphome:${defaultBranch}\`, making it hard to keep your fork up to date.`, + `- Any additional commits you push to \`${defaultBranch}\` will be added to this PR, so you can't easily work on multiple changes at once.`, + `- Pushing maintainer fixes to your branch is awkward, since it means committing directly to your fork's default branch.`, + `- It makes local collaboration painful — \`${defaultBranch}\` in a checkout becomes ambiguous between upstream and your fork, and maintainers end up with naming collisions when fetching your branch.`, + ``, + `Please re-open this as a new PR from a dedicated feature branch. The usual flow looks like:`, + ``, + `\`\`\`bash`, + `# Make sure your fork's ${defaultBranch} is up to date with upstream`, + `git remote add upstream https://github.com/${owner}/${repo}.git # if you haven't already`, + `git fetch upstream`, + `git checkout ${defaultBranch}`, + `git reset --hard upstream/${defaultBranch}`, + `git push --force-with-lease origin ${defaultBranch}`, + ``, + `# Create a new branch for your change and cherry-pick / re-apply your commits there`, + `git checkout -b my-feature-branch upstream/${defaultBranch}`, + `# ...re-apply your changes, then:`, + `git push origin my-feature-branch`, + `\`\`\``, + ``, + `Then open a new pull request from \`my-feature-branch\` into \`${owner}/${repo}:${defaultBranch}\`.`, + ``, + `Closing this PR for now — sorry for the friction, and thanks again for contributing! :heart:`, + ].join('\n'); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'closed', + }); From ddf1426f8622daf68e4be187d243fcdf410bda28 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 18:09:36 -0400 Subject: [PATCH 0517/2214] [sendspin] Add initial Sendspin hub component (PR1) (#15924) Co-authored-by: Copilot --- CODEOWNERS | 1 + esphome/components/sendspin/__init__.py | 146 ++++++++++++++++++ esphome/components/sendspin/sendspin_hub.cpp | 143 +++++++++++++++++ esphome/components/sendspin/sendspin_hub.h | 138 +++++++++++++++++ esphome/core/defines.h | 5 + esphome/idf_component.yml | 2 + tests/components/sendspin/common.yaml | 9 ++ tests/components/sendspin/test.esp32-idf.yaml | 1 + 8 files changed, 445 insertions(+) create mode 100644 esphome/components/sendspin/__init__.py create mode 100644 esphome/components/sendspin/sendspin_hub.cpp create mode 100644 esphome/components/sendspin/sendspin_hub.h create mode 100644 tests/components/sendspin/common.yaml create mode 100644 tests/components/sendspin/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index be835aae3d..facfdb1705 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -440,6 +440,7 @@ esphome/components/sen0321/* @notjj esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct +esphome/components/sendspin/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py new file mode 100644 index 0000000000..d86c5d6dab --- /dev/null +++ b/esphome/components/sendspin/__init__.py @@ -0,0 +1,146 @@ +from dataclasses import dataclass + +import esphome.codegen as cg +from esphome.components import esp32, network, psram, socket, wifi +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM +from esphome.core import CORE +from esphome.types import ConfigType + +# mdns for autodiscovery +AUTO_LOAD = ["mdns"] +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["network"] +DOMAIN = "sendspin" + +# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace. +# Analysis tools strip the trailing underscore (same pattern as `template_`). +sendspin_ns = cg.esphome_ns.namespace("sendspin_") +SendspinHub = sendspin_ns.class_( + "SendspinHub", + cg.Component, +) + + +@dataclass +class SendspinConfiguration: + artwork_support: bool = False + controller_support: bool = False + metadata_support: bool = False + player_support: bool = False + visualizer_support: bool = False + + +def _get_data() -> SendspinConfiguration: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = SendspinConfiguration() + return CORE.data[DOMAIN] + + +def request_artwork_support() -> None: + """Request artwork role support for Sendspin.""" + _get_data().artwork_support = True + + +def request_controller_support() -> None: + """Request controller role support for Sendspin.""" + _get_data().controller_support = True + + +def request_metadata_support() -> None: + """Request metadata role support for Sendspin.""" + _get_data().metadata_support = True + + +def request_player_support() -> None: + """Request player role support for Sendspin.""" + _get_data().player_support = True + + +def request_visualizer_support() -> None: + """Request visualizer role support for Sendspin.""" + _get_data().visualizer_support = True + + +def _validate_task_stack_in_psram(value): + value = cv.boolean(value) + if value: + return cv.requires_component(psram.DOMAIN)(value) + return value + + +def _request_high_performance_networking(config: ConfigType) -> ConfigType: + """Request high performance networking for Sendspin streaming. + + Also enables wake_loop_threadsafe support for fast defer() callbacks + from background threads (WebSocket handler, image decoder). + """ + network.require_high_performance_networking() + # Socket consumption varies by mode: + # - Server mode: 1 listening socket + 2 client connections (for handoff) + # - Client mode: 1 outbound connection + socket.consume_sockets( + 1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN + )(config) + socket.consume_sockets(2, "sendspin_websocket_server")(config) + socket.consume_sockets(1, "sendspin_websocket_client")(config) + + wifi.enable_runtime_power_save_control() + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(SendspinHub), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + } + ), + cv.only_on_esp32, + _request_high_performance_networking, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) + + # sendspin-cpp library + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.0") + + cg.add_define("USE_SENDSPIN", True) # for MDNS + + data = _get_data() + + # Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*) + # 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) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False) + + if data.controller_support: + cg.add_define("USE_SENDSPIN_CONTROLLER", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_CONTROLLER", False) + + if data.metadata_support: + cg.add_define("USE_SENDSPIN_METADATA", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_METADATA", False) + + if data.player_support: + cg.add_define("USE_SENDSPIN_PLAYER", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False) + + if data.visualizer_support: + cg.add_define("USE_SENDSPIN_VISUALIZER", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_VISUALIZER", False) diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp new file mode 100644 index 0000000000..9433888794 --- /dev/null +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -0,0 +1,143 @@ +#include "sendspin_hub.h" + +#ifdef USE_ESP32 + +#include "esphome/components/network/util.h" +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif + +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/version.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.hub"; + +void SendspinHub::setup() { + auto config = this->build_client_config_(); + this->client_ = std::make_unique(std::move(config)); + + // Set up persistence (preferences must be initialized before providers are added to the client) + this->last_played_server_pref_ = + global_preferences->make_preference(fnv1a_hash("sendspin_last_played")); + + // Wire providers and client listener + this->client_->set_listener(this); + this->client_->set_network_provider(this); + this->client_->set_persistence_provider(this); + + if (!this->client_->start_server()) { + ESP_LOGE(TAG, "Failed to start Sendspin server"); + this->mark_failed(); + return; + } +} + +void SendspinHub::loop() { this->client_->loop(); } + +void SendspinHub::dump_config() { + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGCONFIG(TAG, + "Sendspin Hub:\n" + " Client ID: %s\n" + " Task stack in PSRAM: %s", + get_mac_address_pretty_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); +} + +// --- Delegating methods --- + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::connect_to_server(const std::string &url) { + if (this->is_ready()) { + this->client_->connect_to(url); + } +} + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) { + if (this->is_ready()) { + this->client_->disconnect(reason); + } +} + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::update_state(sendspin::SendspinClientState state) { + if (this->is_ready()) { + this->client_->update_state(state); + } +} + +sendspin::SendspinClientConfig SendspinHub::build_client_config_() { + sendspin::SendspinClientConfig config; + + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + config.client_id = get_mac_address_pretty_into_buffer(mac_buf); + config.name = App.get_friendly_name(); + config.product_name = App.get_name(); + config.manufacturer = "ESPHome"; + config.software_version = ESPHOME_VERSION; + config.httpd_psram_stack = this->task_stack_in_psram_; + + return config; +} + +// --- SendspinClientListener overrides --- +// THREAD CONTEXT: Main loop (fired from client_->loop()) + +void SendspinHub::on_group_update(const sendspin::GroupUpdateObject &group) { + this->group_update_callbacks_.call(group); +} + +void SendspinHub::on_request_high_performance() { +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->request_high_performance(); + } +#endif +} + +void SendspinHub::on_release_high_performance() { +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->release_high_performance(); + } +#endif +} + +// --- SendspinNetworkProvider override --- + +// THREAD CONTEXT: Main loop (polled by client_->loop()) +bool SendspinHub::is_network_ready() { return network::is_connected(); } + +// --- SendspinPersistenceProvider overrides --- + +// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) +bool SendspinHub::save_last_server_hash(uint32_t hash) { + LastPlayedServerPref pref{.server_id_hash = hash}; + bool ok = this->last_played_server_pref_.save(&pref); + if (ok) { + ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash); + } else { + ESP_LOGW(TAG, "Failed to persist last played server hash"); + } + return ok; +} + +// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) +std::optional SendspinHub::load_last_server_hash() { + LastPlayedServerPref pref{}; + if (this->last_played_server_pref_.load(&pref)) { + ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash); + return pref.server_id_hash; + } + return std::nullopt; +} + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h new file mode 100644 index 0000000000..4402d25fbd --- /dev/null +++ b/esphome/components/sendspin/sendspin_hub.h @@ -0,0 +1,138 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +#include +#include +#include + +#include +#include +#include + +namespace esphome::sendspin_ { + +/// @brief Setup priorities for the sendspin hub and its child components. +/// +/// Centralized here so every sendspin component orders itself relative to the hub +/// without each subcomponent having to pick a priority independently. Children run +/// one step later than hub so they can assume hub's setup() has already completed. +namespace sendspin_priority { +inline constexpr float HUB = esphome::setup_priority::PROCESSOR; +inline constexpr float CHILD = HUB - 1.0f; +} // namespace sendspin_priority + +/// @brief Persistent storage structure for last played server hash. +struct LastPlayedServerPref { + uint32_t server_id_hash; +}; + +/// @brief Thin adapter over sendspin::SendspinClient. +/// +/// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for +/// fan-out to child components. +/// - Provides persistence via ESPPreferenceObject and WiFi power management integration. +/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events +/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the +/// child thereafter, since no fan-out is needed. +/// +/// The sendspin-cpp library follows this design: +/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`. +/// - Library -> user code communication happens via two interface types the user implements and registers in our +/// `setup()`: listener interfaces (for events the library pushes; e.g., group updates) and provider interfaces +/// (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, + public sendspin::SendspinClientListener, + public sendspin::SendspinNetworkProvider, + public sendspin::SendspinPersistenceProvider { + public: + float get_setup_priority() const override { return sendspin_priority::HUB; } + void setup() override; + void loop() override; + void dump_config() override; + + /// @brief Connects the underlying client to the given Sendspin server. + /// + /// No-op if the hub's client is not ready (e.g. setup() has not completed). + /// Must be called from the main loop thread. + /// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`). + void connect_to_server(const std::string &url); + + /// @brief Disconnects the underlying client from the current server. + /// + /// Sends a `client/goodbye` message with the given reason before closing the connection. + /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// @param reason Reason reported to the server: + /// - `ANOTHER_SERVER`: client is switching to another server. + /// - `SHUTDOWN`: client is shutting down. + /// - `RESTART`: client is restarting. + /// - `USER_REQUEST`: user explicitly requested disconnect. + void disconnect_from_server(sendspin::SendspinGoodbyeReason reason); + + /// @brief Updates the client's reported playback state on the server. + /// + /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// @param state New client state: + /// - `SYNCHRONIZED`: client is synchronized and playing from the server. + /// - `ERROR`: client encountered a playback error. + /// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source. + void update_state(sendspin::SendspinClientState state); + + // --- Configuration setters (called from codegen) --- + + template void add_group_update_callback(F &&callback) { + this->group_update_callbacks_.add(std::forward(callback)); + } + + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + + protected: + /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. + sendspin::SendspinClientConfig build_client_config_(); + + // --- SendspinClientListener overrides --- + void on_group_update(const sendspin::GroupUpdateObject &group) override; + + void on_request_high_performance() override; + + void on_release_high_performance() override; + + // --- SendspinNetworkProvider override --- + bool is_network_ready() override; + + // --- SendspinPersistenceProvider overrides --- + bool save_last_server_hash(uint32_t hash) override; + std::optional load_last_server_hash() override; + + ESPPreferenceObject last_played_server_pref_; + + std::unique_ptr client_; + + // Callback fan-out to child components + CallbackManager group_update_callbacks_{}; + + bool task_stack_in_psram_{false}; +}; + +/// @brief Base class for all sendspin subcomponents. +/// +/// Consolidates the Component + Parented inheritance and pins the setup +/// priority so the hub's setup() always runs before any child. Subcomponents should +/// inherit from this instead of listing Component/Parented individually and must not +/// override get_setup_priority(). +class SendspinChild : public Component, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } +}; + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9b751dd8c0..80247f69da 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -257,6 +257,11 @@ #define USE_MICROPHONE #define USE_PSRAM #define USE_SENDSPIN +#define USE_SENDSPIN_ARTWORK +#define USE_SENDSPIN_CONTROLLER +#define USE_SENDSPIN_METADATA +#define USE_SENDSPIN_PLAYER +#define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_LWIP_FAST_SELECT diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c590f73642..f422d94097 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -91,5 +91,7 @@ dependencies: - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]" esp32async/asynctcp: version: 3.4.91 + sendspin/sendspin-cpp: + version: 0.3.0 lvgl/lvgl: version: 9.5.0 diff --git a/tests/components/sendspin/common.yaml b/tests/components/sendspin/common.yaml new file mode 100644 index 0000000000..9d7da76758 --- /dev/null +++ b/tests/components/sendspin/common.yaml @@ -0,0 +1,9 @@ +wifi: + ap: + +psram: + mode: quad + +sendspin: + id: sendspin_hub_id + task_stack_in_psram: true diff --git a/tests/components/sendspin/test.esp32-idf.yaml b/tests/components/sendspin/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sendspin/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 83b76f616b20c6c44d80d0d42de16ce4094f2645 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 18:40:28 -0500 Subject: [PATCH 0518/2214] Address review: fix misleading static_assert, drop %= on LX106 --- esphome/components/esp8266/core.cpp | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 62907ac6aa..eaaf3438bd 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -21,21 +21,23 @@ void HOT yield() { ::yield(); } // system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis // (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. // Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static -// state against ISR re-entry; the critical section is bounded (≤9 while-loop +// state against ISR re-entry; the critical section is bounded (≤10 while-loop // iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on // the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level // >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). // -// Overflow: system_get_time() wraps every ~71.6 min; unsigned now_us - last_us -// handles one wrap. Both the ESPHome main loop (1+N millis() calls per -// iteration at 60+ Hz) and esphome::delay() (which polls millis() in its wait -// loop) keep state.last_us fresh well inside the 71-minute window. +// Correctness rests on a frequency invariant, not arithmetic bounds: +// system_get_time() wraps every ~71.6 min, and unsigned (now_us - last_us) +// handles exactly one wrap. The ESPHome main loop calls millis() 1+N times +// per iteration at 60+ Hz, and esphome::delay() polls millis() in its wait +// loop — last_us is refreshed thousands of times per second, so delta stays +// tiny (typically <1 ms). No ESPHome caller blocks >71 min without yielding +// through the main loop or delay(). If that invariant were ever violated, a +// near-UINT32_MAX delta could overflow remainder += delta; the clock would +// glitch for one call and self-correct on the next. Not worth guarding +// against in the hot path. static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; static constexpr uint32_t US_PER_MS = 1000; -// Guarantees state.remainder + delta cannot wrap uint32_t: the threshold caps -// remainder on entry, and delta ≤ UINT32_MAX (one system_get_time() wrap). -static_assert(MILLIS_RARE_PATH_THRESHOLD_US < UINT32_MAX / 2, - "threshold must leave headroom so remainder + delta cannot overflow uint32_t"); uint32_t IRAM_ATTR HOT millis() { // Struct packs the three statics so the compiler loads one base address @@ -55,9 +57,13 @@ uint32_t IRAM_ATTR HOT millis() { // conversion keeps the critical section bounded. uint32_t ms = state.remainder / US_PER_MS; state.cache += ms; + // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a + // second __umodsi3 call on the LX106 (no hardware divide). state.remainder -= ms * US_PER_MS; } else { - // Common path: small gap. + // Common path: small gap. At most ~10 iterations since remainder was + // < threshold (10 ms) on entry and delta adds at most one more threshold + // before exiting this branch. while (state.remainder >= US_PER_MS) { state.cache++; state.remainder -= US_PER_MS; From d632e00e7dd1cf547401595a95f70d581dc30f75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 18:41:21 -0500 Subject: [PATCH 0519/2214] Trim overflow comment --- esphome/components/esp8266/core.cpp | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index eaaf3438bd..c9bedb61be 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -26,16 +26,9 @@ void HOT yield() { ::yield(); } // the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level // >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). // -// Correctness rests on a frequency invariant, not arithmetic bounds: -// system_get_time() wraps every ~71.6 min, and unsigned (now_us - last_us) -// handles exactly one wrap. The ESPHome main loop calls millis() 1+N times -// per iteration at 60+ Hz, and esphome::delay() polls millis() in its wait -// loop — last_us is refreshed thousands of times per second, so delta stays -// tiny (typically <1 ms). No ESPHome caller blocks >71 min without yielding -// through the main loop or delay(). If that invariant were ever violated, a -// near-UINT32_MAX delta could overflow remainder += delta; the clock would -// glitch for one call and self-correct on the next. Not worth guarding -// against in the hot path. +// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles +// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a +// >71 min block would trip the watchdog long before it could matter here. static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; static constexpr uint32_t US_PER_MS = 1000; From 7bb3d7e4a089a06996c166d6025472c40fe857ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 18:51:47 -0500 Subject: [PATCH 0520/2214] revert time --- esphome/components/esp8266/__init__.py | 16 +----- esphome/components/esp8266/core.cpp | 80 +------------------------- 2 files changed, 4 insertions(+), 92 deletions(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 7e593bf61a..bef7e36470 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -264,16 +264,7 @@ async def to_code(config): cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) cg.add_platformio_option( "platform_packages", - [ - f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}", - # Override the ancient tool-esptoolpy ~1.30000.0 pinned by - # platform-espressif8266 4.2.1 with the same 5.x build the - # pioarduino ESP32 platform uses, so both platforms share the - # same installed package and stop reinstalling on every switch. - # The 0.0.1 path component is pioarduino's stable registry - # release tag (not the tool version); the tool itself is 5.2.0. - "pioarduino/tool-esptoolpy@https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.2.0.zip", - ], + [f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"], ) # Default for platformio is LWIP2_LOW_MEMORY with: @@ -323,11 +314,6 @@ async def to_code(config): for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") - # Wrap Arduino's millis() so all callers (including Arduino libraries and ISR - # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply - # implementation in the Arduino ESP8266 core. - cg.add_build_flag("-Wl,--wrap=millis") - cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index c9bedb61be..159ec20e77 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -16,75 +16,9 @@ extern "C" { namespace esphome { void HOT yield() { ::yield(); } -// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit -// multiplies on the LX106). Tracks a running ms counter from 32-bit -// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis -// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. -// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static -// state against ISR re-entry; the critical section is bounded (≤10 while-loop -// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on -// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level -// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). -// -// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles -// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a -// >71 min block would trip the watchdog long before it could matter here. -static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; -static constexpr uint32_t US_PER_MS = 1000; - -uint32_t IRAM_ATTR HOT millis() { - // Struct packs the three statics so the compiler loads one base address - // instead of three separate literal pool entries (saves ~8 bytes IRAM). - static struct { - uint32_t cache; - uint32_t remainder; - uint32_t last_us; - } state = {0, 0, 0}; - uint32_t ps = xt_rsil(15); - uint32_t now_us = system_get_time(); - uint32_t delta = now_us - state.last_us; - state.last_us = now_us; - state.remainder += delta; - if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { - // Rare path: large gap (WiFi scan, boot, long block). Constant-time - // conversion keeps the critical section bounded. - uint32_t ms = state.remainder / US_PER_MS; - state.cache += ms; - // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a - // second __umodsi3 call on the LX106 (no hardware divide). - state.remainder -= ms * US_PER_MS; - } else { - // Common path: small gap. At most ~10 iterations since remainder was - // < threshold (10 ms) on entry and delta adds at most one more threshold - // before exiting this branch. - while (state.remainder >= US_PER_MS) { - state.cache++; - state.remainder -= US_PER_MS; - } - } - uint32_t result = state.cache; - xt_wsr_ps(ps); - return result; -} -uint64_t millis_64() { return Millis64Impl::compute(millis()); } -// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object -// call to the original millis() that --wrap can't intercept, so calling ::delay() -// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still -// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and -// WiFi run correctly. Theoretically less power-efficient than Arduino's -// os_timer-based delay() for long waits, but nearly all ESPHome delays are short -// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is -// negligible. -void HOT delay(uint32_t ms) { - if (ms == 0) { - optimistic_yield(1000); - return; - } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } -} +uint32_t IRAM_ATTR HOT millis() { return ::millis(); } +uint64_t millis_64() { return Millis64Impl::compute(::millis()); } +void HOT delay(uint32_t ms) { ::delay(ms); } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { @@ -144,12 +78,4 @@ extern "C" void resetPins() { // NOLINT } // namespace esphome -// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. -// Requires -Wl,--wrap=millis in build flags (added by __init__.py). -// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } -// Note: Arduino's init() registers a 60-second overflow timer for micros64(). -// We leave it running — wrapping init() as a no-op would break micros64()'s -// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). - #endif // USE_ESP8266 From 64592c0a4afca6fc2f2f97a3f06cc697c2941284 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 18:56:22 -0500 Subject: [PATCH 0521/2214] Revert "revert time" This reverts commit 7bb3d7e4a089a06996c166d6025472c40fe857ac. --- esphome/components/esp8266/__init__.py | 16 +++++- esphome/components/esp8266/core.cpp | 80 +++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index bef7e36470..7e593bf61a 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -264,7 +264,16 @@ async def to_code(config): cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) cg.add_platformio_option( "platform_packages", - [f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"], + [ + f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}", + # Override the ancient tool-esptoolpy ~1.30000.0 pinned by + # platform-espressif8266 4.2.1 with the same 5.x build the + # pioarduino ESP32 platform uses, so both platforms share the + # same installed package and stop reinstalling on every switch. + # The 0.0.1 path component is pioarduino's stable registry + # release tag (not the tool version); the tool itself is 5.2.0. + "pioarduino/tool-esptoolpy@https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.2.0.zip", + ], ) # Default for platformio is LWIP2_LOW_MEMORY with: @@ -314,6 +323,11 @@ async def to_code(config): for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap Arduino's millis() so all callers (including Arduino libraries and ISR + # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply + # implementation in the Arduino ESP8266 core. + cg.add_build_flag("-Wl,--wrap=millis") + cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 159ec20e77..c9bedb61be 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -16,9 +16,75 @@ extern "C" { namespace esphome { void HOT yield() { ::yield(); } -uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -uint64_t millis_64() { return Millis64Impl::compute(::millis()); } -void HOT delay(uint32_t ms) { ::delay(ms); } +// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit +// multiplies on the LX106). Tracks a running ms counter from 32-bit +// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis +// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. +// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static +// state against ISR re-entry; the critical section is bounded (≤10 while-loop +// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on +// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level +// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). +// +// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles +// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a +// >71 min block would trip the watchdog long before it could matter here. +static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; +static constexpr uint32_t US_PER_MS = 1000; + +uint32_t IRAM_ATTR HOT millis() { + // Struct packs the three statics so the compiler loads one base address + // instead of three separate literal pool entries (saves ~8 bytes IRAM). + static struct { + uint32_t cache; + uint32_t remainder; + uint32_t last_us; + } state = {0, 0, 0}; + uint32_t ps = xt_rsil(15); + uint32_t now_us = system_get_time(); + uint32_t delta = now_us - state.last_us; + state.last_us = now_us; + state.remainder += delta; + if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { + // Rare path: large gap (WiFi scan, boot, long block). Constant-time + // conversion keeps the critical section bounded. + uint32_t ms = state.remainder / US_PER_MS; + state.cache += ms; + // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a + // second __umodsi3 call on the LX106 (no hardware divide). + state.remainder -= ms * US_PER_MS; + } else { + // Common path: small gap. At most ~10 iterations since remainder was + // < threshold (10 ms) on entry and delta adds at most one more threshold + // before exiting this branch. + while (state.remainder >= US_PER_MS) { + state.cache++; + state.remainder -= US_PER_MS; + } + } + uint32_t result = state.cache; + xt_wsr_ps(ps); + return result; +} +uint64_t millis_64() { return Millis64Impl::compute(millis()); } +// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object +// call to the original millis() that --wrap can't intercept, so calling ::delay() +// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still +// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and +// WiFi run correctly. Theoretically less power-efficient than Arduino's +// os_timer-based delay() for long waits, but nearly all ESPHome delays are short +// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is +// negligible. +void HOT delay(uint32_t ms) { + if (ms == 0) { + optimistic_yield(1000); + return; + } + uint32_t start = millis(); + while (millis() - start < ms) { + optimistic_yield(1000); + } +} uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { @@ -78,4 +144,12 @@ extern "C" void resetPins() { // NOLINT } // namespace esphome +// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. +// Requires -Wl,--wrap=millis in build flags (added by __init__.py). +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } +// Note: Arduino's init() registers a 60-second overflow timer for micros64(). +// We leave it running — wrapping init() as a no-op would break micros64()'s +// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). + #endif // USE_ESP8266 From bf7083c50100b2b1ba83d980e7be6d34fb549b8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:19:29 -0500 Subject: [PATCH 0522/2214] [mdns] Drive MDNS.update() polling from WiFi IP state events on ESP8266/RP2040 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Arduino LEAmDNS library only has meaningful timer-driven work during the ~9 s probe+announce phase following MDNS.begin() or _restart(): 3 probes at 250 ms + 8 announcements at 1000 ms, then all internal timeouts are set to resetToNeverExpires(). Incoming packets are handled via the lwIP UDP RX callback independently of update(). ESPHome does not issue service queries, so the query cache path is always a no-op. The previous implementation ran set_interval(50) forever — ~20 dispatches/sec, 1200+ scheduler calls per minute of pure overhead once probing completed. This PR arms a bounded MDNS_POLL_WINDOW_MS (12 s) polling window driven by WiFiIPStateListener events. A fresh window covers each probe/announce cycle (boot, wifi reconnect, or internal _restart() triggered by netif changes); outside the window there are zero scheduler dispatches and the scheduler heap contains no mDNS items. ESP8266 is WiFi-only in the Arduino build so the path is unconditional. RP2040 supports W5500 ethernet without WiFi, so the listener is requested only when WiFi is in the config; ethernet-only RP2040 builds keep the legacy polling loop. Scheduler IDs use uint32_t (MDNS_POLL_ID / MDNS_POLL_STOP_ID) to avoid the name-hash/strcmp cost of string-named timers on the cancel + re-arm paths. --- esphome/components/mdns/__init__.py | 14 ++++ esphome/components/mdns/mdns_component.cpp | 14 ++++ esphome/components/mdns/mdns_component.h | 82 ++++++++++++++++------ esphome/components/mdns/mdns_esp8266.cpp | 28 ++++++-- esphome/components/mdns/mdns_rp2040.cpp | 44 +++++++++++- 5 files changed, 153 insertions(+), 29 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 7c36295e8d..3e07c91d53 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -169,6 +169,20 @@ async def to_code(config): elif CORE.is_rp2040: cg.add_library("LEAmDNS", None) + # ESP8266 and RP2040 use a WiFi IP state listener to arm a bounded MDNS.update() + # polling window only while the library is in its probe+announce phase. This + # eliminates the steady-state 50ms interval that ran forever (1200+ dispatches + # per minute) and its scheduler overhead. + # + # ESP8266 has no ethernet driver in the Arduino build, so it's always a WiFi + # device — the listener is unconditional. RP2040 supports the W5500 ethernet + # shield without WiFi, so the listener is requested only when WiFi is present; + # ethernet-only RP2040 builds fall back to the legacy polling loop. + if CORE.is_esp8266 or (CORE.is_rp2040 and "wifi" in CORE.config): + from esphome.components import wifi + + wifi.request_wifi_ip_state_listener() + if CORE.is_esp32: add_idf_component(name="espressif/mdns", ref="1.11.0") diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index e05373ac5d..88d0b1be31 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -190,6 +190,20 @@ void MDNSComponent::compile_records_(StaticVectorcancel_polling_window_(); + this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, mdns_pump_update); + this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); +} + +void MDNSComponent::cancel_polling_window_() { + this->cancel_interval(MDNS_POLL_ID); + this->cancel_timeout(MDNS_POLL_STOP_ID); +} +#endif + void MDNSComponent::dump_config() { ESP_LOGCONFIG(TAG, "mDNS:\n" diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index adf88a9cf1..6f6e22ee62 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,9 +5,27 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +// Event-driven polling is used whenever the scheduler-backed MDNS.update() interval +// needs to be gated on network state (ESP8266, or RP2040 with WiFi). ESP8266 mDNS +// always runs over WiFi — there is no ethernet driver for ESP8266 in the Arduino +// build — so this path is unconditional on ESP8266. RP2040 can run mDNS over the +// W5500 ethernet shield without WiFi, so it falls back to the legacy polling loop +// when WiFi is absent. +#if defined(USE_ESP8266) || (defined(USE_RP2040) && defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)) +#include "esphome/components/network/ip_address.h" +#include "esphome/components/wifi/wifi_component.h" +#define USE_MDNS_EVENT_DRIVEN_POLLING +#endif namespace esphome::mdns { +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +/// Call MDNS.update() on the target platform. Defined in the per-platform cpp file so +/// the shared component code can drive the polling window without pulling in +/// platform-specific mDNS headers. +void mdns_pump_update(); +#endif + // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) struct MDNSString; @@ -40,33 +58,40 @@ struct MDNSService { FixedVector txt_records; }; -class MDNSComponent final : public Component { +class MDNSComponent final : public Component +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + , + public wifi::WiFiIPStateListener +#endif +{ public: void setup() override; void dump_config() override; - // Polling interval for MDNS.update() on platforms that require it (ESP8266, RP2040). +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + // On ESP8266 and RP2040, MDNS.update() calls _process(true) which only manages + // timer-driven state machines (probe/announce timeouts and service query cache TTLs). + // Incoming mDNS packets are handled independently via the lwIP onRx UDP callback and + // are NOT affected by how often update() is called. // - // On these platforms, MDNS.update() calls _process(true) which only manages timer-driven - // state machines (probe/announce timeouts and service query cache TTLs). Incoming mDNS - // packets are handled independently via the lwIP onRx UDP callback and are NOT affected - // by how often update() is called. + // The work has a bounded lifetime: after MDNS.begin() (or _restart() triggered by a + // network interface change) the library sends 3 probes 250ms apart followed by 8 + // announcements 1000ms apart, after which all internal timeouts are set to + // resetToNeverExpires(). ESPHome does not issue mDNS service queries, so the service + // query cache is always empty. Every subsequent update() call is pure overhead. // - // The shortest internal timer is the 250ms probe interval (RFC 6762 Section 8.1). - // Announcement intervals are 1000ms and cache TTL checks are on the order of seconds - // to minutes. A 50ms polling interval provides sufficient resolution for all timers - // while completely removing mDNS from the per-iteration loop list. - // - // In steady state (after the ~8 second boot probe/announce phase completes), update() - // checks timers that are set to never expire, making every call pure overhead. - // - // Tasmota uses a 50ms main loop cycle with mDNS working correctly, confirming this - // interval is safe in production. - // - // By using set_interval() instead of overriding loop(), the component is excluded from - // the main loop list via has_overridden_loop(), eliminating all per-iteration overhead - // including virtual dispatch. + // Instead of polling forever, we arm a bounded polling window driven by + // WiFiIPStateListener events. A fresh window covers each probe/announce cycle that + // follows initial connect or reconnect; outside the window no update() calls occur. static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; + // Boot probe+announce phase is ~9.0s (3*250ms probes + 8*1000ms announces). Window + // includes margin for the initial `rand() % MDNS_PROBE_DELAY` jitter and for the + // debounced internal restart triggered by netif status changes on ESP8266. + static constexpr uint32_t MDNS_POLL_WINDOW_MS = 12000; + // Scheduler IDs (uint32_t variants avoid name hashing/strcmp on cancel paths) + static constexpr uint32_t MDNS_POLL_ID = 0; + static constexpr uint32_t MDNS_POLL_STOP_ID = 1; +#endif float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } #ifdef USE_MDNS_EXTRA_SERVICES @@ -87,7 +112,20 @@ class MDNSComponent final : public Component { } #endif +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; +#endif + protected: +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + /// Arm a bounded polling window so MDNS.update() runs at MDNS_UPDATE_INTERVAL_MS + /// for MDNS_POLL_WINDOW_MS. A subsequent call replaces the previous window. + void start_polling_window_(); + /// Cancel any active polling window. + void cancel_polling_window_(); + bool ip_was_up_{false}; +#endif /// Helper to set up services and MAC buffers, then call platform-specific registration using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector &); @@ -131,8 +169,10 @@ class MDNSComponent final : public Component { StaticVector services_{}; #endif #ifdef USE_RP2040 - bool was_connected_{false}; bool initialized_{false}; +#if !defined(USE_MDNS_EVENT_DRIVEN_POLLING) + bool was_connected_{false}; +#endif #endif void compile_records_(StaticVector &services, char *mac_address_buf); }; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 70c614f8d3..aa8558dac1 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -7,6 +7,7 @@ #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/components/wifi/wifi_component.h" #include "mdns_component.h" namespace esphome::mdns { @@ -36,13 +37,30 @@ static void register_esp8266(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_esp8266); - // Schedule MDNS.update() via set_interval() instead of overriding loop(). - // This removes the component from the per-iteration loop list entirely, - // eliminating virtual dispatch overhead on every main loop cycle. - // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. - this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + // Arduino LEAmDNS registers its own LwipIntf::statusChangeCB that calls _restart() + // on every netif status change (link up, IP up, etc.), so we don't trigger begin() + // or restart here — we just cover the probe+announce window with a bounded polling + // schedule. The listener catches subsequent reconnects and re-arms the window. + wifi::global_wifi_component->add_ip_state_listener(this); + this->start_polling_window_(); +} + +void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, + const network::IPAddress &) { + const bool has_ip = ips[0].is_set(); + if (has_ip && !this->ip_was_up_) { + // IP came up. LEAmDNS's internal lwIP callback will call _restart() shortly after + // (if it hasn't already) — arm the polling window so the probe/announce phase is + // serviced regardless of our relative timing vs the library's callback. + this->start_polling_window_(); + } else if (!has_ip && this->ip_was_up_) { + this->cancel_polling_window_(); + } + this->ip_was_up_ = has_ip; } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 64b603030c..44db320dbb 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -6,6 +6,9 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "mdns_component.h" +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +#include "esphome/components/wifi/wifi_component.h" +#endif // Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty. // Save and restore our definition around the include to avoid a redefinition warning. @@ -40,6 +43,10 @@ static void register_rp2040(MDNSComponent *, StaticVectoradd_ip_state_listener(this); + // AFTER_CONNECTION priority means the network may already be up when setup() runs; + // the listener only fires on subsequent state changes, so seed the current state. + const auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses(); + if (ips[0].is_set()) { + this->on_ip_state(ips, wifi::global_wifi_component->get_dns_address(0), + wifi::global_wifi_component->get_dns_address(1)); + } +#else + // Fallback (non-WiFi build): poll forever, checking connection state each tick. this->set_interval(MDNS_UPDATE_INTERVAL_MS, [this]() { bool connected = network::is_connected(); if (connected && !this->was_connected_) { @@ -67,8 +85,28 @@ void MDNSComponent::setup() { MDNS.update(); } }); +#endif } +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, + const network::IPAddress &) { + const bool has_ip = ips[0].is_set(); + if (has_ip && !this->ip_was_up_) { + if (!this->initialized_) { + this->setup_buffers_and_register_(register_rp2040); + this->initialized_ = true; + } else { + MDNS.notifyAPChange(); + } + this->start_polling_window_(); + } else if (!has_ip && this->ip_was_up_) { + this->cancel_polling_window_(); + } + this->ip_was_up_ = has_ip; +} +#endif + void MDNSComponent::on_shutdown() { MDNS.close(); delay(40); From 25601434d9739f8eb8101b5ad6775d789105914c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:26:01 -0500 Subject: [PATCH 0523/2214] [mdns] Simplify listener logic: always re-arm on IP notify, drop transition tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESPHome's WiFiIPStateListener only notifies on IP acquisition (GOT_IP events), not on IP loss — on disconnect, only the WiFiConnectStateListener's disconnect path fires (see wifi_component_esp8266.cpp:952-962 and wifi_component_pico_w.cpp:340). The previous commit's `ip_was_up_` transition tracking was broken: after the first IP-up event, `ip_was_up_` latched to true and never reset, so subsequent disconnect+reconnect cycles would see has_ip=true && ip_was_up_=true and skip re-arming the polling window. Fix: always re-arm on any IP notification. The scheduler's set_interval/set_timeout with a uint32_t ID already performs atomic cancel-and-add for matching IDs (Scheduler::set_timer_common_ line 232-234), so start_polling_window_ is idempotent and needs no explicit cancel. Drop the ip_was_up_ field and cancel_polling_window_ helper entirely. The !has_ip branch (cancel on disconnect) was dead code: it would never fire because the listener doesn't receive disconnect events. Removing it; the polling window will naturally expire on its own (at most 12s of harmless MDNS.update() calls during a disconnect that isn't followed by reconnect within the window). --- esphome/components/mdns/mdns_component.cpp | 10 +++------ esphome/components/mdns/mdns_component.h | 3 --- esphome/components/mdns/mdns_esp8266.cpp | 14 +++++------- esphome/components/mdns/mdns_rp2040.cpp | 26 ++++++++++++---------- 4 files changed, 23 insertions(+), 30 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 88d0b1be31..c80b9224f4 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -192,16 +192,12 @@ void MDNSComponent::compile_records_(StaticVectorcancel_polling_window_(); + // Re-arming replaces the previous window. The scheduler's set_interval/set_timeout + // with a uint32_t ID already does atomic cancel-and-add for items sharing that ID + // (see Scheduler::set_timer_common_), so no explicit cancel is needed. this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, mdns_pump_update); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } - -void MDNSComponent::cancel_polling_window_() { - this->cancel_interval(MDNS_POLL_ID); - this->cancel_timeout(MDNS_POLL_STOP_ID); -} #endif void MDNSComponent::dump_config() { diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 6f6e22ee62..7be2f4665e 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -122,9 +122,6 @@ class MDNSComponent final : public Component /// Arm a bounded polling window so MDNS.update() runs at MDNS_UPDATE_INTERVAL_MS /// for MDNS_POLL_WINDOW_MS. A subsequent call replaces the previous window. void start_polling_window_(); - /// Cancel any active polling window. - void cancel_polling_window_(); - bool ip_was_up_{false}; #endif /// Helper to set up services and MAC buffers, then call platform-specific registration using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector &); diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index aa8558dac1..d6254de568 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -51,16 +51,14 @@ void MDNSComponent::setup() { void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { - const bool has_ip = ips[0].is_set(); - if (has_ip && !this->ip_was_up_) { - // IP came up. LEAmDNS's internal lwIP callback will call _restart() shortly after - // (if it hasn't already) — arm the polling window so the probe/announce phase is - // serviced regardless of our relative timing vs the library's callback. + // ESPHome's WiFiIPStateListener only notifies on IP acquisition (GOT_IP events on + // ESP8266 — see wifi_component_esp8266.cpp), not on IP loss, so every notification + // represents a fresh IP that the LEAmDNS library's lwIP callback will trigger a + // _restart() for. Always re-arm the polling window — start_polling_window_() is + // idempotent (scheduler does atomic cancel-and-add on matching IDs). + if (ips[0].is_set()) { this->start_polling_window_(); - } else if (!has_ip && this->ip_was_up_) { - this->cancel_polling_window_(); } - this->ip_was_up_ = has_ip; } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 44db320dbb..cc2d81fe24 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -91,19 +91,21 @@ void MDNSComponent::setup() { #ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { - const bool has_ip = ips[0].is_set(); - if (has_ip && !this->ip_was_up_) { - if (!this->initialized_) { - this->setup_buffers_and_register_(register_rp2040); - this->initialized_ = true; - } else { - MDNS.notifyAPChange(); - } - this->start_polling_window_(); - } else if (!has_ip && this->ip_was_up_) { - this->cancel_polling_window_(); + // ESPHome's WiFiIPStateListener only notifies on IP acquisition (see + // wifi_component_pico_w.cpp), not on IP loss, so every notification represents a + // fresh IP that needs a probe/announce cycle. The library's internal + // LwipIntf::stateUpCB is stubbed out on arduino-pico (see setup()), so we drive + // begin/restart ourselves from this callback. + if (!ips[0].is_set()) { + return; } - this->ip_was_up_ = has_ip; + if (!this->initialized_) { + this->setup_buffers_and_register_(register_rp2040); + this->initialized_ = true; + } else { + MDNS.notifyAPChange(); + } + this->start_polling_window_(); } #endif From 5cb258034bc8cc9b683954e1cbb4fe67435272d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:36:11 -0500 Subject: [PATCH 0524/2214] [mdns] Fall back to legacy polling when WiFi IP state listener isn't available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clang-tidy CI compiles the source with the esp8266-arduino-tidy env's raw build flags (-DUSE_ESP8266 only) without running the Python codegen that adds USE_WIFI_IP_STATE_LISTENERS. The previous guard assumed USE_WIFI_IP_STATE_LISTENERS would always be defined on ESP8266, so clang-tidy failed with 'no member named add_ip_state_listener in wifi::WiFiComponent'. Gate USE_MDNS_EVENT_DRIVEN_POLLING on USE_WIFI + USE_WIFI_IP_STATE_LISTENERS for both ESP8266 and RP2040. When either is absent, fall back to the pre-PR behaviour: set_interval(MDNS_UPDATE_INTERVAL_MS, MDNS.update) running forever. Python side already only requests the listener slot when WiFi is in the config, so real production builds on ESP8266 (which always have WiFi) continue to use the event-driven path — only the clang-tidy static-analysis build takes the fallback. --- esphome/components/mdns/__init__.py | 11 ++++------- esphome/components/mdns/mdns_component.h | 22 +++++++++++----------- esphome/components/mdns/mdns_esp8266.cpp | 12 ++++++++++++ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 3e07c91d53..dc86a314da 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -172,13 +172,10 @@ async def to_code(config): # ESP8266 and RP2040 use a WiFi IP state listener to arm a bounded MDNS.update() # polling window only while the library is in its probe+announce phase. This # eliminates the steady-state 50ms interval that ran forever (1200+ dispatches - # per minute) and its scheduler overhead. - # - # ESP8266 has no ethernet driver in the Arduino build, so it's always a WiFi - # device — the listener is unconditional. RP2040 supports the W5500 ethernet - # shield without WiFi, so the listener is requested only when WiFi is present; - # ethernet-only RP2040 builds fall back to the legacy polling loop. - if CORE.is_esp8266 or (CORE.is_rp2040 and "wifi" in CORE.config): + # per minute) and its scheduler overhead. When WiFi is absent (e.g. an + # ethernet-only RP2040 build) the component falls back to the legacy polling + # loop at MDNS_UPDATE_INTERVAL_MS. + if (CORE.is_esp8266 or CORE.is_rp2040) and "wifi" in CORE.config: from esphome.components import wifi wifi.request_wifi_ip_state_listener() diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 7be2f4665e..32a365bb62 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,13 +5,12 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" -// Event-driven polling is used whenever the scheduler-backed MDNS.update() interval -// needs to be gated on network state (ESP8266, or RP2040 with WiFi). ESP8266 mDNS -// always runs over WiFi — there is no ethernet driver for ESP8266 in the Arduino -// build — so this path is unconditional on ESP8266. RP2040 can run mDNS over the -// W5500 ethernet shield without WiFi, so it falls back to the legacy polling loop -// when WiFi is absent. -#if defined(USE_ESP8266) || (defined(USE_RP2040) && defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)) +// Event-driven polling replaces the legacy set_interval() loop on platforms that need +// scheduler-backed MDNS.update() (ESP8266, RP2040). It's enabled when a WiFi IP state +// listener slot is available — the mdns Python to_code() requests one when WiFi is in +// the config. If it's not (e.g. clang-tidy running without full codegen, or an +// ethernet-only RP2040 build), the component falls back to the legacy polling loop. +#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS) #include "esphome/components/network/ip_address.h" #include "esphome/components/wifi/wifi_component.h" #define USE_MDNS_EVENT_DRIVEN_POLLING @@ -68,7 +67,6 @@ class MDNSComponent final : public Component void setup() override; void dump_config() override; -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // On ESP8266 and RP2040, MDNS.update() calls _process(true) which only manages // timer-driven state machines (probe/announce timeouts and service query cache TTLs). // Incoming mDNS packets are handled independently via the lwIP onRx UDP callback and @@ -80,10 +78,12 @@ class MDNSComponent final : public Component // resetToNeverExpires(). ESPHome does not issue mDNS service queries, so the service // query cache is always empty. Every subsequent update() call is pure overhead. // - // Instead of polling forever, we arm a bounded polling window driven by - // WiFiIPStateListener events. A fresh window covers each probe/announce cycle that - // follows initial connect or reconnect; outside the window no update() calls occur. + // When USE_MDNS_EVENT_DRIVEN_POLLING is defined we arm a bounded polling window from + // WiFiIPStateListener events so update() only runs during the probe+announce phase; + // outside that window no update() calls occur. Otherwise (fallback), we poll at + // MDNS_UPDATE_INTERVAL_MS forever. static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // Boot probe+announce phase is ~9.0s (3*250ms probes + 8*1000ms announces). Window // includes margin for the initial `rand() % MDNS_PROBE_DELAY` jitter and for the // debounced internal restart triggered by netif status changes on ESP8266. diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index d6254de568..072be497db 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -7,7 +7,9 @@ #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING #include "esphome/components/wifi/wifi_component.h" +#endif #include "mdns_component.h" namespace esphome::mdns { @@ -37,18 +39,27 @@ static void register_esp8266(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_esp8266); +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // Arduino LEAmDNS registers its own LwipIntf::statusChangeCB that calls _restart() // on every netif status change (link up, IP up, etc.), so we don't trigger begin() // or restart here — we just cover the probe+announce window with a bounded polling // schedule. The listener catches subsequent reconnects and re-arms the window. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); +#else + // Fallback for builds without a WiFi IP state listener (e.g. clang-tidy without + // codegen defines). Matches the pre-PR behaviour: poll forever at 50ms. + this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); +#endif } +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // ESPHome's WiFiIPStateListener only notifies on IP acquisition (GOT_IP events on @@ -60,6 +71,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: this->start_polling_window_(); } } +#endif void MDNSComponent::on_shutdown() { MDNS.close(); From ceada8632553398018656a273a8a8f7a3e598b2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:41:23 -0500 Subject: [PATCH 0525/2214] [mdns] Drive event-driven polling from Ethernet IP state events on RP2040 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the WiFi-only listener pattern from the previous commit to also subscribe to EthernetIPStateListener when Ethernet is configured. RP2040 can run mDNS over a W5500 ethernet shield without WiFi, and mDNS and WiFi are mutually exclusive on RP2040 (the framework doesn't support both simultaneously on the CYW43/PIO paths), so this adds the ethernet-only path without touching the WiFi path. ESPHome's wifi and ethernet components already publish compatible IP state listener APIs (`WiFiIPStateListener::on_ip_state` and `EthernetIPStateListener::on_ip_state` with identical signatures). MDNSComponent multiply-inherits both when available; a single on_ip_state() override satisfies both vtable entries. - New `USE_MDNS_WIFI_LISTENER` / `USE_MDNS_ETHERNET_LISTENER` gates control per- interface subscription. `USE_MDNS_EVENT_DRIVEN_POLLING` fires if either is available. - Python side now calls `ethernet.request_ethernet_ip_state_listener()` when ethernet is in the config (RP2040 only — ESP8266 has no ethernet driver). - setup() seeds current state for each registered listener so an already-up interface still triggers MDNS.begin() + polling window under AFTER_CONNECTION priority. Tests: adds `test-enabled-ethernet.rp2040-ard.yaml` covering the ethernet-only path. Existing `test-enabled.rp2040-ard.yaml` (WiFi-only) and ESP8266 tests continue to pass. --- esphome/components/mdns/__init__.py | 26 +++++++---- esphome/components/mdns/mdns_component.h | 28 ++++++++--- esphome/components/mdns/mdns_esp8266.cpp | 6 ++- esphome/components/mdns/mdns_rp2040.cpp | 46 +++++++++++++------ .../mdns/common-enabled-ethernet.yaml | 23 ++++++++++ .../test-enabled-ethernet.rp2040-ard.yaml | 1 + 6 files changed, 99 insertions(+), 31 deletions(-) create mode 100644 tests/components/mdns/common-enabled-ethernet.yaml create mode 100644 tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index dc86a314da..db915e5f89 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -169,16 +169,24 @@ async def to_code(config): elif CORE.is_rp2040: cg.add_library("LEAmDNS", None) - # ESP8266 and RP2040 use a WiFi IP state listener to arm a bounded MDNS.update() - # polling window only while the library is in its probe+announce phase. This - # eliminates the steady-state 50ms interval that ran forever (1200+ dispatches - # per minute) and its scheduler overhead. When WiFi is absent (e.g. an - # ethernet-only RP2040 build) the component falls back to the legacy polling - # loop at MDNS_UPDATE_INTERVAL_MS. - if (CORE.is_esp8266 or CORE.is_rp2040) and "wifi" in CORE.config: - from esphome.components import wifi + # ESP8266 and RP2040 use a network IP state listener to arm a bounded + # MDNS.update() polling window only while the library is in its probe+announce + # phase. This eliminates the steady-state 50ms interval that ran forever + # (1200+ dispatches per minute) and its scheduler overhead. + # + # We subscribe to any listener interface that's configured (WiFi and/or + # Ethernet); the same on_ip_state() override satisfies both because the + # listener signatures match. If neither is available the component falls + # back to the legacy polling loop at MDNS_UPDATE_INTERVAL_MS. + if CORE.is_esp8266 or CORE.is_rp2040: + if "wifi" in CORE.config: + from esphome.components import wifi - wifi.request_wifi_ip_state_listener() + wifi.request_wifi_ip_state_listener() + if CORE.is_rp2040 and "ethernet" in CORE.config: + from esphome.components import ethernet + + ethernet.request_ethernet_ip_state_listener() if CORE.is_esp32: add_idf_component(name="espressif/mdns", ref="1.11.0") diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 32a365bb62..ea74e22818 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -6,14 +6,24 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" // Event-driven polling replaces the legacy set_interval() loop on platforms that need -// scheduler-backed MDNS.update() (ESP8266, RP2040). It's enabled when a WiFi IP state -// listener slot is available — the mdns Python to_code() requests one when WiFi is in -// the config. If it's not (e.g. clang-tidy running without full codegen, or an -// ethernet-only RP2040 build), the component falls back to the legacy polling loop. -#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS) +// scheduler-backed MDNS.update() (ESP8266, RP2040). It's enabled when at least one +// compatible network IP state listener slot is available — the mdns Python to_code() +// requests a WiFi slot when WiFi is in the config and an Ethernet slot when Ethernet +// is in the config. If neither is available (e.g. clang-tidy running without full +// codegen), the component falls back to the legacy polling loop. +#if (defined(USE_ESP8266) || defined(USE_RP2040)) && \ + ((defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)) || \ + (defined(USE_ETHERNET) && defined(USE_ETHERNET_IP_STATE_LISTENERS))) #include "esphome/components/network/ip_address.h" -#include "esphome/components/wifi/wifi_component.h" #define USE_MDNS_EVENT_DRIVEN_POLLING +#if defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS) +#include "esphome/components/wifi/wifi_component.h" +#define USE_MDNS_WIFI_LISTENER +#endif +#if defined(USE_ETHERNET) && defined(USE_ETHERNET_IP_STATE_LISTENERS) +#include "esphome/components/ethernet/ethernet_component.h" +#define USE_MDNS_ETHERNET_LISTENER +#endif #endif namespace esphome::mdns { @@ -58,10 +68,14 @@ struct MDNSService { }; class MDNSComponent final : public Component -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +#ifdef USE_MDNS_WIFI_LISTENER , public wifi::WiFiIPStateListener #endif +#ifdef USE_MDNS_ETHERNET_LISTENER + , + public ethernet::EthernetIPStateListener +#endif { public: void setup() override; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 072be497db..c7737bcb14 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -7,10 +7,10 @@ #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +#include "mdns_component.h" +#ifdef USE_MDNS_WIFI_LISTENER #include "esphome/components/wifi/wifi_component.h" #endif -#include "mdns_component.h" namespace esphome::mdns { @@ -50,6 +50,8 @@ void MDNSComponent::setup() { // on every netif status change (link up, IP up, etc.), so we don't trigger begin() // or restart here — we just cover the probe+announce window with a bounded polling // schedule. The listener catches subsequent reconnects and re-arms the window. + // ESP8266 has no ethernet driver in the Arduino build so only the WiFi listener + // branch is reachable here. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); #else diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index cc2d81fe24..8a6a36096f 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -6,9 +6,12 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "mdns_component.h" -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +#ifdef USE_MDNS_WIFI_LISTENER #include "esphome/components/wifi/wifi_component.h" #endif +#ifdef USE_MDNS_ETHERNET_LISTENER +#include "esphome/components/ethernet/ethernet_component.h" +#endif // Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty. // Save and restore our definition around the include to avoid a redefinition warning. @@ -57,19 +60,36 @@ void MDNSComponent::setup() { // // Workaround: defer MDNS.begin() and service registration until the network has an IP, // then call notifyAPChange() on subsequent reconnects to restart mDNS probing and - // announcing — all from main loop context via the WiFiIPStateListener callback so it's - // thread-safe. + // announcing — all from main loop context via the IP state listener callback(s) so + // it's thread-safe. We subscribe to any listener interface that's active (WiFi and/or + // Ethernet); the same on_ip_state() override serves both because the signatures match. #ifdef USE_MDNS_EVENT_DRIVEN_POLLING +#ifdef USE_MDNS_WIFI_LISTENER wifi::global_wifi_component->add_ip_state_listener(this); // AFTER_CONNECTION priority means the network may already be up when setup() runs; // the listener only fires on subsequent state changes, so seed the current state. - const auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses(); - if (ips[0].is_set()) { - this->on_ip_state(ips, wifi::global_wifi_component->get_dns_address(0), - wifi::global_wifi_component->get_dns_address(1)); + { + const auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses(); + if (ips[0].is_set()) { + this->on_ip_state(ips, wifi::global_wifi_component->get_dns_address(0), + wifi::global_wifi_component->get_dns_address(1)); + } } +#endif +#ifdef USE_MDNS_ETHERNET_LISTENER + ethernet::global_eth_component->add_ip_state_listener(this); + // Seed current Ethernet state for the same reason — if the interface is already up + // when mdns setup() runs, we need to kick off begin() + polling here. + if (ethernet::global_eth_component->is_connected()) { + const auto ips = ethernet::global_eth_component->get_ip_addresses(); + if (ips[0].is_set()) { + this->on_ip_state(ips, network::IPAddress{}, network::IPAddress{}); + } + } +#endif #else - // Fallback (non-WiFi build): poll forever, checking connection state each tick. + // Fallback (no IP state listener available): poll forever, checking connection state + // each tick. this->set_interval(MDNS_UPDATE_INTERVAL_MS, [this]() { bool connected = network::is_connected(); if (connected && !this->was_connected_) { @@ -91,11 +111,11 @@ void MDNSComponent::setup() { #ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { - // ESPHome's WiFiIPStateListener only notifies on IP acquisition (see - // wifi_component_pico_w.cpp), not on IP loss, so every notification represents a - // fresh IP that needs a probe/announce cycle. The library's internal - // LwipIntf::stateUpCB is stubbed out on arduino-pico (see setup()), so we drive - // begin/restart ourselves from this callback. + // Both WiFi and Ethernet IP state listeners only notify on IP acquisition (see + // wifi_component_pico_w.cpp and ethernet_component.cpp), not on IP loss, so every + // notification represents a fresh IP that needs a probe/announce cycle. The library's + // internal LwipIntf::stateUpCB is stubbed out on arduino-pico (see setup()), so we + // drive begin/restart ourselves from this callback. if (!ips[0].is_set()) { return; } diff --git a/tests/components/mdns/common-enabled-ethernet.yaml b/tests/components/mdns/common-enabled-ethernet.yaml new file mode 100644 index 0000000000..bfa9321d43 --- /dev/null +++ b/tests/components/mdns/common-enabled-ethernet.yaml @@ -0,0 +1,23 @@ +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + 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" + +mdns: + disabled: false + services: + - service: _test_service + protocol: _tcp + port: 8888 + txt: + static_string: Anything diff --git a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml new file mode 100644 index 0000000000..f84a0bc276 --- /dev/null +++ b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common-enabled-ethernet.yaml From 09fe59c5ccdb724838273ce2242d27b220bceea2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:57:48 -0500 Subject: [PATCH 0526/2214] [mdns] Drop fallback paths, collapse everything under USE_MDNS_EVENT_DRIVEN_POLLING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mDNS on ESP8266/RP2040 always runs over a network interface (WiFi on ESP8266; WiFi or W5500/etc. ethernet on RP2040), and every such interface already publishes an IP state listener in tree. USE_MDNS_EVENT_DRIVEN_POLLING is therefore always defined in production, so the fallback set_interval() paths in both platform files and the was_connected_ bookkeeping are dead code. Also drop the ethernet-specific test — the existing wifi and ethernet+mdns combos are already covered by the mdns test fixtures paired with their network component tests. Trim redundant comments throughout: the header now documents the ~9s probe/announce window and why update() can stop afterward in a few lines instead of a full essay; platform files keep only the non-obvious bits (why RP2040 needs to drive begin()/notifyAPChange() itself, why re-arming on any listener notification is correct). --- esphome/components/mdns/__init__.py | 12 +--- esphome/components/mdns/mdns_component.cpp | 5 +- esphome/components/mdns/mdns_component.h | 47 ++++----------- esphome/components/mdns/mdns_esp8266.cpp | 27 ++------- esphome/components/mdns/mdns_rp2040.cpp | 58 +++---------------- .../mdns/common-enabled-ethernet.yaml | 23 -------- .../test-enabled-ethernet.rp2040-ard.yaml | 1 - 7 files changed, 29 insertions(+), 144 deletions(-) delete mode 100644 tests/components/mdns/common-enabled-ethernet.yaml delete mode 100644 tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index db915e5f89..b15a778b59 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -169,15 +169,9 @@ async def to_code(config): elif CORE.is_rp2040: cg.add_library("LEAmDNS", None) - # ESP8266 and RP2040 use a network IP state listener to arm a bounded - # MDNS.update() polling window only while the library is in its probe+announce - # phase. This eliminates the steady-state 50ms interval that ran forever - # (1200+ dispatches per minute) and its scheduler overhead. - # - # We subscribe to any listener interface that's configured (WiFi and/or - # Ethernet); the same on_ip_state() override satisfies both because the - # listener signatures match. If neither is available the component falls - # back to the legacy polling loop at MDNS_UPDATE_INTERVAL_MS. + # Subscribe to the network IP state listener(s) so MDNS.update() is only + # scheduled during the probe+announce phase. Same on_ip_state() override + # serves both WiFi and Ethernet (signatures match). if CORE.is_esp8266 or CORE.is_rp2040: if "wifi" in CORE.config: from esphome.components import wifi diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index c80b9224f4..80e12ade71 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -192,9 +192,8 @@ void MDNSComponent::compile_records_(StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, mdns_pump_update); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index ea74e22818..0e848ba975 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,12 +5,8 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" -// Event-driven polling replaces the legacy set_interval() loop on platforms that need -// scheduler-backed MDNS.update() (ESP8266, RP2040). It's enabled when at least one -// compatible network IP state listener slot is available — the mdns Python to_code() -// requests a WiFi slot when WiFi is in the config and an Ethernet slot when Ethernet -// is in the config. If neither is available (e.g. clang-tidy running without full -// codegen), the component falls back to the legacy polling loop. +// On ESP8266 and RP2040 the scheduler-backed MDNS.update() polling window is armed by +// IP state listener events on whichever network interface is configured. #if (defined(USE_ESP8266) || defined(USE_RP2040)) && \ ((defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)) || \ (defined(USE_ETHERNET) && defined(USE_ETHERNET_IP_STATE_LISTENERS))) @@ -29,9 +25,8 @@ namespace esphome::mdns { #ifdef USE_MDNS_EVENT_DRIVEN_POLLING -/// Call MDNS.update() on the target platform. Defined in the per-platform cpp file so -/// the shared component code can drive the polling window without pulling in -/// platform-specific mDNS headers. +/// Platform-specific MDNS.update() trampoline. Defined in mdns_.cpp so the +/// shared code can schedule it without including the platform's mDNS header. void mdns_pump_update(); #endif @@ -81,28 +76,13 @@ class MDNSComponent final : public Component void setup() override; void dump_config() override; - // On ESP8266 and RP2040, MDNS.update() calls _process(true) which only manages - // timer-driven state machines (probe/announce timeouts and service query cache TTLs). - // Incoming mDNS packets are handled independently via the lwIP onRx UDP callback and - // are NOT affected by how often update() is called. - // - // The work has a bounded lifetime: after MDNS.begin() (or _restart() triggered by a - // network interface change) the library sends 3 probes 250ms apart followed by 8 - // announcements 1000ms apart, after which all internal timeouts are set to - // resetToNeverExpires(). ESPHome does not issue mDNS service queries, so the service - // query cache is always empty. Every subsequent update() call is pure overhead. - // - // When USE_MDNS_EVENT_DRIVEN_POLLING is defined we arm a bounded polling window from - // WiFiIPStateListener events so update() only runs during the probe+announce phase; - // outside that window no update() calls occur. Otherwise (fallback), we poll at - // MDNS_UPDATE_INTERVAL_MS forever. - static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; #ifdef USE_MDNS_EVENT_DRIVEN_POLLING - // Boot probe+announce phase is ~9.0s (3*250ms probes + 8*1000ms announces). Window - // includes margin for the initial `rand() % MDNS_PROBE_DELAY` jitter and for the - // debounced internal restart triggered by netif status changes on ESP8266. - static constexpr uint32_t MDNS_POLL_WINDOW_MS = 12000; - // Scheduler IDs (uint32_t variants avoid name hashing/strcmp on cancel paths) + // LEAmDNS has meaningful work only during the probe+announce phase (3×250ms probes + + // 8×1000ms announces, ~9s). Afterwards every internal timer is resetToNeverExpires() + // and update() becomes pure overhead. We arm a bounded polling window from IP state + // listener events so update() runs only during that phase. + static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; + static constexpr uint32_t MDNS_POLL_WINDOW_MS = 12000; // ~9s phase + jitter/restart margin static constexpr uint32_t MDNS_POLL_ID = 0; static constexpr uint32_t MDNS_POLL_STOP_ID = 1; #endif @@ -133,8 +113,8 @@ class MDNSComponent final : public Component protected: #ifdef USE_MDNS_EVENT_DRIVEN_POLLING - /// Arm a bounded polling window so MDNS.update() runs at MDNS_UPDATE_INTERVAL_MS - /// for MDNS_POLL_WINDOW_MS. A subsequent call replaces the previous window. + /// Arm a fresh MDNS_POLL_WINDOW_MS polling window. Idempotent — re-arming replaces + /// the previous window via the scheduler's atomic cancel-and-add on matching IDs. void start_polling_window_(); #endif /// Helper to set up services and MAC buffers, then call platform-specific registration @@ -181,9 +161,6 @@ class MDNSComponent final : public Component #endif #ifdef USE_RP2040 bool initialized_{false}; -#if !defined(USE_MDNS_EVENT_DRIVEN_POLLING) - bool was_connected_{false}; -#endif #endif void compile_records_(StaticVector &services, char *mac_address_buf); }; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index c7737bcb14..d1ffee6e61 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -8,9 +8,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "mdns_component.h" -#ifdef USE_MDNS_WIFI_LISTENER #include "esphome/components/wifi/wifi_component.h" -#endif namespace esphome::mdns { @@ -39,41 +37,24 @@ static void register_esp8266(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_esp8266); -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING - // Arduino LEAmDNS registers its own LwipIntf::statusChangeCB that calls _restart() - // on every netif status change (link up, IP up, etc.), so we don't trigger begin() - // or restart here — we just cover the probe+announce window with a bounded polling - // schedule. The listener catches subsequent reconnects and re-arms the window. - // ESP8266 has no ethernet driver in the Arduino build so only the WiFi listener - // branch is reachable here. + // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we only + // need to arm the polling window around the initial probe/announce and each reconnect. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); -#else - // Fallback for builds without a WiFi IP state listener (e.g. clang-tidy without - // codegen defines). Matches the pre-PR behaviour: poll forever at 50ms. - this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); -#endif } -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { - // ESPHome's WiFiIPStateListener only notifies on IP acquisition (GOT_IP events on - // ESP8266 — see wifi_component_esp8266.cpp), not on IP loss, so every notification - // represents a fresh IP that the LEAmDNS library's lwIP callback will trigger a - // _restart() for. Always re-arm the polling window — start_polling_window_() is - // idempotent (scheduler does atomic cancel-and-add on matching IDs). + // IP listener only fires on acquisition (not loss), so any notification is a fresh + // IP worth re-arming for. start_polling_window_() is idempotent. if (ips[0].is_set()) { this->start_polling_window_(); } } -#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 8a6a36096f..1ecadea36e 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -14,7 +14,6 @@ #endif // Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty. -// Save and restore our definition around the include to avoid a redefinition warning. #pragma push_macro("IRAM_ATTR") #undef IRAM_ATTR #include @@ -26,10 +25,7 @@ static void register_rp2040(MDNSComponent *, StaticVectoradd_ip_state_listener(this); - // AFTER_CONNECTION priority means the network may already be up when setup() runs; - // the listener only fires on subsequent state changes, so seed the current state. + // AFTER_CONNECTION priority means the network may already be up; the listener only + // fires on subsequent changes, so seed the current state. { const auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses(); if (ips[0].is_set()) { @@ -78,8 +63,6 @@ void MDNSComponent::setup() { #endif #ifdef USE_MDNS_ETHERNET_LISTENER ethernet::global_eth_component->add_ip_state_listener(this); - // Seed current Ethernet state for the same reason — if the interface is already up - // when mdns setup() runs, we need to kick off begin() + polling here. if (ethernet::global_eth_component->is_connected()) { const auto ips = ethernet::global_eth_component->get_ip_addresses(); if (ips[0].is_set()) { @@ -87,35 +70,11 @@ void MDNSComponent::setup() { } } #endif -#else - // Fallback (no IP state listener available): poll forever, checking connection state - // each tick. - this->set_interval(MDNS_UPDATE_INTERVAL_MS, [this]() { - bool connected = network::is_connected(); - if (connected && !this->was_connected_) { - if (!this->initialized_) { - this->setup_buffers_and_register_(register_rp2040); - this->initialized_ = true; - } else { - MDNS.notifyAPChange(); - } - } - this->was_connected_ = connected; - if (this->initialized_) { - MDNS.update(); - } - }); -#endif } -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { - // Both WiFi and Ethernet IP state listeners only notify on IP acquisition (see - // wifi_component_pico_w.cpp and ethernet_component.cpp), not on IP loss, so every - // notification represents a fresh IP that needs a probe/announce cycle. The library's - // internal LwipIntf::stateUpCB is stubbed out on arduino-pico (see setup()), so we - // drive begin/restart ourselves from this callback. + // Listener only fires on IP acquisition (not loss); every event is a fresh IP. if (!ips[0].is_set()) { return; } @@ -127,7 +86,6 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: } this->start_polling_window_(); } -#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/tests/components/mdns/common-enabled-ethernet.yaml b/tests/components/mdns/common-enabled-ethernet.yaml deleted file mode 100644 index bfa9321d43..0000000000 --- a/tests/components/mdns/common-enabled-ethernet.yaml +++ /dev/null @@ -1,23 +0,0 @@ -ethernet: - type: W5500 - clk_pin: 18 - mosi_pin: 19 - miso_pin: 16 - cs_pin: 17 - interrupt_pin: 21 - reset_pin: 20 - 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" - -mdns: - disabled: false - services: - - service: _test_service - protocol: _tcp - port: 8888 - txt: - static_string: Anything diff --git a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml deleted file mode 100644 index f84a0bc276..0000000000 --- a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-enabled-ethernet.yaml From 6938f2b40008c27328b3e4369ebb6d4f28bd3885 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:06:14 -0500 Subject: [PATCH 0527/2214] [mdns] Gate listener code under USE_MDNS_EVENT_DRIVEN_POLLING for clang-tidy clang-tidy compiles mdns_esp8266.cpp / mdns_rp2040.cpp with only the tidy env's raw build flags, without the Python codegen defines (USE_WIFI_IP_STATE_LISTENERS, USE_ETHERNET_IP_STATE_LISTENERS, USE_MDNS_EVENT_DRIVEN_POLLING). Wrap all listener-specific code paths under USE_MDNS_EVENT_DRIVEN_POLLING so tidy sees a compilable translation unit with an empty setup() instead of unresolved members on WiFiComponent / MDNSComponent. Production builds always have the listener defines via the mdns Python to_code()'s wifi.request_wifi_ip_state_listener() / ethernet.request_ethernet_ip_state_listener() calls, so this is tidy-only dead code at runtime. --- esphome/components/mdns/mdns_esp8266.cpp | 8 ++++++++ esphome/components/mdns/mdns_rp2040.cpp | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index d1ffee6e61..bc12154258 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -8,7 +8,9 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "mdns_component.h" +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING #include "esphome/components/wifi/wifi_component.h" +#endif namespace esphome::mdns { @@ -37,16 +39,21 @@ static void register_esp8266(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_esp8266); +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we only // need to arm the polling window around the initial probe/announce and each reconnect. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); +#endif } +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // IP listener only fires on acquisition (not loss), so any notification is a fresh @@ -55,6 +62,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: this->start_polling_window_(); } } +#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 1ecadea36e..1b3c13178d 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -42,7 +42,9 @@ static void register_rp2040(MDNSComponent *, StaticVectorstart_polling_window_(); } +#endif void MDNSComponent::on_shutdown() { MDNS.close(); From ae414275237ec29f6783900954ff44a430c5cf51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:11:48 -0500 Subject: [PATCH 0528/2214] [mdns] Enforce network-interface availability at config time on ESP8266/RP2040 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review feedback: if someone enables mdns on ESP8266 or RP2040 without wifi (or ethernet on RP2040), the listener-based setup() is a no-op and the user sees a silent failure rather than a helpful error. FINAL_VALIDATE_SCHEMA rejects mdns on these platforms when no compatible network component is present, naming the specific options that would satisfy the requirement. The existing DEPENDENCIES = ["network"] covers most misconfigurations indirectly, but an explicit network: alone (without wifi or ethernet) slips past that check — now it fails with a clear message. --- esphome/components/mdns/__init__.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index b15a778b59..e7e1323f40 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -14,6 +14,7 @@ from esphome.const import ( from esphome.core import CORE, Lambda, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.cpp_generator import LambdaExpression +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -61,6 +62,29 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType: return config +def _require_network_interface(config: ConfigType) -> ConfigType: + """Require a network interface for mDNS on Arduino/LEAmDNS platforms. + + On ESP8266 and RP2040 the C++ implementation needs at least one IP state + listener (WiFi on ESP8266; WiFi or Ethernet on RP2040) to arm its polling + window. Reject at config time rather than silently producing a component + that never initializes. + """ + if config.get(CONF_DISABLED): + return config + if not CORE.using_arduino or not (CORE.is_esp8266 or CORE.is_rp2040): + return config + full_config = fv.full_config.get() + has_wifi = "wifi" in full_config + has_ethernet = CORE.is_rp2040 and "ethernet" in full_config + if not (has_wifi or has_ethernet): + options = "'wifi'" if CORE.is_esp8266 else "'wifi' or 'ethernet'" + raise cv.Invalid( + f"mdns on this platform requires a network interface — add a {options} component to your configuration." + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -74,6 +98,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = _require_network_interface + + def mdns_txt_record(key: str, value: str) -> cg.RawExpression: """Create a mDNS TXT record. From b4a86e46b256020be129a67c59b48ccf3e5c3311 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 21:22:47 -0400 Subject: [PATCH 0529/2214] [sendspin] Add controller role and sendspin.switch action (PR2) (#15929) Co-authored-by: Copilot --- esphome/components/sendspin/__init__.py | 46 ++++++++++++++++++- esphome/components/sendspin/automation.h | 25 ++++++++++ esphome/components/sendspin/sendspin_hub.cpp | 22 +++++++++ esphome/components/sendspin/sendspin_hub.h | 31 +++++++++++++ tests/components/sendspin/common-action.yaml | 8 ++++ .../sendspin/test-action.esp32-idf.yaml | 1 + 6 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/automation.h create mode 100644 tests/components/sendspin/common-action.yaml create mode 100644 tests/components/sendspin/test-action.esp32-idf.yaml diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index d86c5d6dab..166d3fd70d 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,10 +1,12 @@ from dataclasses import dataclass +from esphome import automation import esphome.codegen as cg from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import TemplateArgsType from esphome.types import ConfigType # mdns for autodiscovery @@ -22,6 +24,13 @@ SendspinHub = sendspin_ns.class_( ) +SendspinSwitchCommandAction = sendspin_ns.class_( + "SendspinSwitchCommandAction", + automation.Action, + cg.Parented.template(SendspinHub), +) + + @dataclass class SendspinConfiguration: artwork_support: bool = False @@ -101,6 +110,41 @@ CONFIG_SCHEMA = cv.All( ) +def _request_controller_role(config: ConfigType) -> ConfigType: + """Request the controller role for the sendspin.switch action.""" + request_controller_support() + return config + + +SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinHub), + } + ) + ), + _request_controller_role, +) + + +@automation.register_action( + "sendspin.switch", + SendspinSwitchCommandAction, + SENDSPIN_SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +async def sendspin_switch_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + 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/sendspin/automation.h b/esphome/components/sendspin/automation.h new file mode 100644 index 0000000000..be3b1eb39d --- /dev/null +++ b/esphome/components/sendspin/automation.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/automation.h" +#include "sendspin_hub.h" + +namespace esphome::sendspin_ { + +#ifdef USE_SENDSPIN_CONTROLLER +template class SendspinSwitchCommandAction : public Action, public Parented { + public: + void play(const Ts &...x) override { + // Clear any EXTERNAL_SOURCE state so the switch command is followed + this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); + this->parent_->send_client_command(sendspin::SendspinControllerCommand::SWITCH); + } +}; +#endif // USE_SENDSPIN_CONTROLLER + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 9433888794..ec419f7741 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -31,6 +31,11 @@ void SendspinHub::setup() { this->client_->set_network_provider(this); this->client_->set_persistence_provider(this); +#ifdef USE_SENDSPIN_CONTROLLER + this->controller_role_ = &this->client_->add_controller(); + this->controller_role_->set_listener(this); +#endif + if (!this->client_->start_server()) { ESP_LOGE(TAG, "Failed to start Sendspin server"); this->mark_failed(); @@ -138,6 +143,23 @@ std::optional SendspinHub::load_last_server_hash() { return std::nullopt; } +// --- Sendspin role specific methods/overrides --- + +#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, + std::optional mute) { + if (this->is_ready()) { + this->controller_role_->send_command(command, volume, mute); + } +} + +// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) +void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { + this->controller_state_callbacks_.call(state); +} +#endif + } // namespace esphome::sendspin_ #endif // USE_ESP32 diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 4402d25fbd..1e217e0ea2 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -13,6 +13,10 @@ #include #include +#ifdef USE_SENDSPIN_CONTROLLER +#include +#endif + #include #include #include @@ -50,6 +54,9 @@ struct LastPlayedServerPref { /// (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_CONTROLLER + public sendspin::ControllerRoleListener, +#endif public sendspin::SendspinClientListener, public sendspin::SendspinNetworkProvider, public sendspin::SendspinPersistenceProvider { @@ -94,6 +101,17 @@ class SendspinHub final : public Component, void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + // --- Sendspin role specific methods --- + +#ifdef USE_SENDSPIN_CONTROLLER + void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, + std::optional mute = std::nullopt); + + template void add_controller_state_callback(F &&callback) { + this->controller_state_callbacks_.add(std::forward(callback)); + } +#endif + protected: /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); @@ -112,6 +130,19 @@ class SendspinHub final : public Component, bool save_last_server_hash(uint32_t hash) override; std::optional load_last_server_hash() override; + // --- Sendspin role specific methods/overrides/member variables --- + +#ifdef USE_SENDSPIN_CONTROLLER + sendspin::ControllerRole *controller_role_{nullptr}; + + void on_controller_state(const sendspin::ServerStateControllerObject &state) override; + + // Callback fan-out to child components; they filter as needed + CallbackManager controller_state_callbacks_{}; +#endif + + // --- Core member variables --- + ESPPreferenceObject last_played_server_pref_; std::unique_ptr client_; diff --git a/tests/components/sendspin/common-action.yaml b/tests/components/sendspin/common-action.yaml new file mode 100644 index 0000000000..16f19ad7d1 --- /dev/null +++ b/tests/components/sendspin/common-action.yaml @@ -0,0 +1,8 @@ +# `sendspin.switch` action enables the controller role, so we use a standalone test +packages: + base: !include common.yaml + +wifi: + on_connect: + then: + - sendspin.switch: diff --git a/tests/components/sendspin/test-action.esp32-idf.yaml b/tests/components/sendspin/test-action.esp32-idf.yaml new file mode 100644 index 0000000000..70a7ee1bad --- /dev/null +++ b/tests/components/sendspin/test-action.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-action.yaml From 4f8cfff4accdf8724693728932ede3170c9e6b84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:34:29 -0500 Subject: [PATCH 0530/2214] [mdns] Address review feedback: tighten guards, comments, validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop redundant wifi_component.h / ethernet_component.h includes from the platform .cpp files — mdns_component.h already pulls them in transitively under their listener defines. - Guard initialized_ with USE_RP2040 && USE_MDNS_EVENT_DRIVEN_POLLING instead of USE_RP2040 alone, making the coupling with the listener-driven path explicit. - Short comment on mdns_pump_update noting ODR is preserved by FILTER_SOURCE_FILES compiling exactly one platform cpp per build. - Inline comment on ESP8266 setup() noting AFTER_CONNECTION priority is why the unconditional start_polling_window_() is safe. - Collapse the disabled/platform early-return in _require_network_interface; drop the redundant CORE.using_arduino check (ESP8266/RP2040 are always Arduino). - Add USE_MDNS_EVENT_DRIVEN_POLLING and USE_MDNS_WIFI_LISTENER to defines.h for static-analysis discoverability (ethernet listener is mutually exclusive with wifi on these platforms, so one representative is enough). --- esphome/components/mdns/__init__.py | 4 +--- esphome/components/mdns/mdns_component.h | 7 ++++--- esphome/components/mdns/mdns_esp8266.cpp | 10 +++++----- esphome/components/mdns/mdns_rp2040.cpp | 8 ++------ esphome/core/defines.h | 2 ++ 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e7e1323f40..741669280c 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -70,9 +70,7 @@ def _require_network_interface(config: ConfigType) -> ConfigType: window. Reject at config time rather than silently producing a component that never initializes. """ - if config.get(CONF_DISABLED): - return config - if not CORE.using_arduino or not (CORE.is_esp8266 or CORE.is_rp2040): + if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2040): return config full_config = fv.full_config.get() has_wifi = "wifi" in full_config diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 0e848ba975..95bf2bf27f 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -25,8 +25,8 @@ namespace esphome::mdns { #ifdef USE_MDNS_EVENT_DRIVEN_POLLING -/// Platform-specific MDNS.update() trampoline. Defined in mdns_.cpp so the -/// shared code can schedule it without including the platform's mDNS header. +/// MDNS.update() trampoline. Defined in exactly one mdns_.cpp per build +/// (FILTER_SOURCE_FILES in __init__.py enforces this), so ODR is preserved. void mdns_pump_update(); #endif @@ -159,7 +159,8 @@ class MDNSComponent final : public Component #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; #endif -#ifdef USE_RP2040 +#if defined(USE_RP2040) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) + // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif void compile_records_(StaticVector &services, char *mac_address_buf); diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index bc12154258..d77d9685dd 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -8,9 +8,8 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "mdns_component.h" -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING -#include "esphome/components/wifi/wifi_component.h" -#endif +// wifi_component.h is pulled in transitively by mdns_component.h when +// USE_MDNS_WIFI_LISTENER is defined. namespace esphome::mdns { @@ -46,8 +45,9 @@ void mdns_pump_update() { MDNS.update(); } void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); #ifdef USE_MDNS_EVENT_DRIVEN_POLLING - // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we only - // need to arm the polling window around the initial probe/announce and each reconnect. + // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we just + // arm the window around the initial probe/announce and each reconnect. Unconditional + // here is safe: setup_priority::AFTER_CONNECTION guarantees the network is up. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); #endif diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 1b3c13178d..a8b0af4ad6 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -6,12 +6,8 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "mdns_component.h" -#ifdef USE_MDNS_WIFI_LISTENER -#include "esphome/components/wifi/wifi_component.h" -#endif -#ifdef USE_MDNS_ETHERNET_LISTENER -#include "esphome/components/ethernet/ethernet_component.h" -#endif +// wifi_component.h / ethernet_component.h are pulled in transitively by +// mdns_component.h when their respective listener defines are active. // Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty. #pragma push_macro("IRAM_ATTR") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da..53b78a97df 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -111,6 +111,8 @@ #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 +#define USE_MDNS_EVENT_DRIVEN_POLLING +#define USE_MDNS_WIFI_LISTENER #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define SERIAL_PROXY_COUNT 2 From d32db7904a9f53dd697d5e8504dee1d2da968cef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:40:38 -0500 Subject: [PATCH 0531/2214] [mdns] Inline MDNS.update() as a lambda in each platform's start_polling_window_ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the mdns_pump_update() trampoline. It existed because start_polling_window_ lived in mdns_component.cpp which can't see the platform's MDNS global. Moving start_polling_window_ into each platform cpp lets the set_interval lambda call MDNS.update() directly — no forward decl, no ODR comment, no indirection. --- esphome/components/mdns/mdns_component.cpp | 9 --------- esphome/components/mdns/mdns_component.h | 6 ------ esphome/components/mdns/mdns_esp8266.cpp | 6 +++++- esphome/components/mdns/mdns_rp2040.cpp | 6 +++++- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 80e12ade71..e05373ac5d 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -190,15 +190,6 @@ void MDNSComponent::compile_records_(StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, mdns_pump_update); - this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); -} -#endif - void MDNSComponent::dump_config() { ESP_LOGCONFIG(TAG, "mDNS:\n" diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 95bf2bf27f..4f651f7d84 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -24,12 +24,6 @@ namespace esphome::mdns { -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING -/// MDNS.update() trampoline. Defined in exactly one mdns_.cpp per build -/// (FILTER_SOURCE_FILES in __init__.py enforces this), so ODR is preserved. -void mdns_pump_update(); -#endif - // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) struct MDNSString; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index d77d9685dd..95137ea116 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -39,7 +39,11 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); +} #endif void MDNSComponent::setup() { diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index a8b0af4ad6..f5848893a3 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -39,7 +39,11 @@ static void register_rp2040(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); +} #endif void MDNSComponent::setup() { From b9a9067d98f9141149641fc1396abb1b904280a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:41:15 -0500 Subject: [PATCH 0532/2214] [mdns] Wrap validator error string at 80 cols --- esphome/components/mdns/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 741669280c..2b25cf243d 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -78,7 +78,8 @@ def _require_network_interface(config: ConfigType) -> ConfigType: if not (has_wifi or has_ethernet): options = "'wifi'" if CORE.is_esp8266 else "'wifi' or 'ethernet'" raise cv.Invalid( - f"mdns on this platform requires a network interface — add a {options} component to your configuration." + "mdns on this platform requires a network interface — " + f"add a {options} component to your configuration." ) return config From fad9210cb981172d1aa1f34f2c352446f8471a7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:43:04 -0500 Subject: [PATCH 0533/2214] [mdns] Drop redundant USE_MDNS_EVENT_DRIVEN_POLLING guards in platform .cpp files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python validator enforces that wifi or ethernet is configured on ESP8266/ RP2040, so USE_WIFI_IP_STATE_LISTENERS (or the ethernet equivalent) is always requested in production, which always defines USE_MDNS_EVENT_DRIVEN_POLLING via mdns_component.h's derivation. defines.h also declares it unconditionally for static analysis. The #ifdef guards around start_polling_window_, setup()'s listener subscription, and on_ip_state were dead code — a misconfiguration should surface as a compile error, not silently strip out the method bodies. --- esphome/components/mdns/mdns_esp8266.cpp | 6 ------ esphome/components/mdns/mdns_rp2040.cpp | 4 ---- 2 files changed, 10 deletions(-) diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 95137ea116..3b9cfc65ff 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -38,26 +38,21 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } -#endif void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we just // arm the window around the initial probe/announce and each reconnect. Unconditional // here is safe: setup_priority::AFTER_CONNECTION guarantees the network is up. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); -#endif } -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // IP listener only fires on acquisition (not loss), so any notification is a fresh @@ -66,7 +61,6 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: this->start_polling_window_(); } } -#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index f5848893a3..ece2bc5ace 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -38,13 +38,11 @@ static void register_rp2040(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } -#endif void MDNSComponent::setup() { // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses @@ -74,7 +72,6 @@ void MDNSComponent::setup() { #endif } -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // Listener only fires on IP acquisition (not loss); every event is a fresh IP. @@ -89,7 +86,6 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: } this->start_polling_window_(); } -#endif void MDNSComponent::on_shutdown() { MDNS.close(); From 3ccaa771a7423f95cc1bd4cb1b5a77d5b7f04324 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 21:46:25 -0400 Subject: [PATCH 0534/2214] [sendspin] Add a group media player controller (PR3) (#15948) Co-authored-by: Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/sendspin/__init__.py | 2 + .../sendspin/media_player/__init__.py | 45 +++++ .../media_player/sendspin_media_player.cpp | 165 ++++++++++++++++++ .../media_player/sendspin_media_player.h | 33 ++++ .../sendspin/common-media_player.yaml | 5 + .../sendspin/test-media_player.esp32-idf.yaml | 1 + 7 files changed, 252 insertions(+) create mode 100644 esphome/components/sendspin/media_player/__init__.py create mode 100644 esphome/components/sendspin/media_player/sendspin_media_player.cpp create mode 100644 esphome/components/sendspin/media_player/sendspin_media_player.h create mode 100644 tests/components/sendspin/common-media_player.yaml create mode 100644 tests/components/sendspin/test-media_player.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index facfdb1705..65db6ca25e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -441,6 +441,7 @@ esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt +esphome/components/sendspin/media_player/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 166d3fd70d..2d05390378 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -15,6 +15,8 @@ CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["network"] DOMAIN = "sendspin" +CONF_SENDSPIN_ID = "sendspin_id" + # Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace. # Analysis tools strip the trailing underscore (same pattern as `template_`). sendspin_ns = cg.esphome_ns.namespace("sendspin_") diff --git a/esphome/components/sendspin/media_player/__init__.py b/esphome/components/sendspin/media_player/__init__.py new file mode 100644 index 0000000000..4aaee8cd89 --- /dev/null +++ b/esphome/components/sendspin/media_player/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import media_player +from esphome.components.const import CONF_VOLUME_INCREMENT +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_controller_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinMediaPlayer = sendspin_ns.class_( + "SendspinMediaPlayer", + media_player.MediaPlayer, + cg.Component, +) + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the media player.""" + request_controller_support() + + return config + + +CONFIG_SCHEMA = cv.All( + media_player.media_player_schema(SendspinMediaPlayer).extend( + { + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, + } + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await media_player.register_media_player(var, config) + + cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT])) diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp new file mode 100644 index 0000000000..beb2028689 --- /dev/null +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -0,0 +1,165 @@ +#include "sendspin_media_player.h" + +#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include + +#include +#include +#include +#include + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.media_player"; + +// THREAD CONTEXT: Main loop. The callbacks registered here also fire on the main loop, +// since SendspinHub dispatches group updates and controller state from client_->loop(). +void SendspinMediaPlayer::setup() { + // Register for group updates to sync playback state + this->parent_->add_group_update_callback([this](const sendspin::GroupUpdateObject &group_obj) { + if (group_obj.playback_state.has_value()) { + media_player::MediaPlayerState new_state; + switch (group_obj.playback_state.value()) { + case sendspin::SendspinPlaybackState::PLAYING: + new_state = media_player::MEDIA_PLAYER_STATE_PLAYING; + break; + case sendspin::SendspinPlaybackState::STOPPED: + default: + 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->parent_->add_controller_state_callback([this](const sendspin::ServerStateControllerObject &state) { + float new_volume = static_cast(state.volume) / 100.0f; + bool new_muted = state.muted; + if ((new_volume != this->volume) || (new_muted != this->muted_)) { + this->volume = new_volume; + this->muted_ = new_muted; + this->publish_state(); + } + }); + + // Publish an initial state + this->state = media_player::MEDIA_PLAYER_STATE_IDLE; + this->publish_state(); +} + +// THREAD CONTEXT: Main loop (invoked by the media_player framework) +media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() { + auto traits = media_player::MediaPlayerTraits(); + + // By default, the base media player always enables these traits, but they are not actually supported by this media + // player + traits.clear_feature_flags(media_player::MediaPlayerEntityFeature::PLAY_MEDIA | + media_player::MediaPlayerEntityFeature::BROWSE_MEDIA | + media_player::MediaPlayerEntityFeature::MEDIA_ANNOUNCE); + + traits.add_feature_flags( + media_player::MediaPlayerEntityFeature::PLAY | media_player::MediaPlayerEntityFeature::PAUSE | + media_player::MediaPlayerEntityFeature::STOP | media_player::MediaPlayerEntityFeature::VOLUME_STEP | + media_player::MediaPlayerEntityFeature::VOLUME_SET | media_player::MediaPlayerEntityFeature::VOLUME_MUTE); + + // NEXT_TRACK, PREVIOUS_TRACK, SHUFFLE_SET, and REPEAT_SET are intentionally not advertised: the ESPHome native API + // does not implement the corresponding media player commands, so Home Assistant cannot actually send them even if + // we expose the capability. They remain accessible via ESPHome YAML automations. + + return traits; +} + +// THREAD CONTEXT: Main loop (invoked by the media_player framework) +void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) { + if (!this->is_ready()) { + // Ignore any commands sent before the media player is setup + return; + } + + auto volume = call.get_volume(); + if (volume.has_value()) { + uint8_t new_volume = static_cast(std::roundf(volume.value() * 100.0f)); + this->parent_->send_client_command(sendspin::SendspinControllerCommand::VOLUME, new_volume, std::nullopt); + } + + auto command = call.get_command(); + if (!command.has_value()) { + return; + } + switch (command.value()) { + case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: + if (this->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) { + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE); + } else { + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY); + } + break; + case media_player::MEDIA_PLAYER_COMMAND_PLAY: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY); + break; + case media_player::MEDIA_PLAYER_COMMAND_PAUSE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE); + break; + case media_player::MEDIA_PLAYER_COMMAND_STOP: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::STOP); + break; + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF); + break; + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE); + break; + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL); + break; + case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE); + break; + case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE); + break; + case media_player::MEDIA_PLAYER_COMMAND_NEXT: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT); + break; + case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS); + break; + case media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP: + this->parent_->send_client_command( + sendspin::SendspinControllerCommand::VOLUME, + static_cast(std::roundf(std::min(1.0f, this->volume + this->volume_increment_) * 100.0f)), + std::nullopt); + break; + case media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN: + this->parent_->send_client_command( + sendspin::SendspinControllerCommand::VOLUME, + static_cast(std::roundf(std::max(0.0f, this->volume - this->volume_increment_) * 100.0f)), + std::nullopt); + break; + case media_player::MEDIA_PLAYER_COMMAND_MUTE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, true); + break; + case media_player::MEDIA_PLAYER_COMMAND_UNMUTE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, false); + break; + default: + break; + } +} + +void SendspinMediaPlayer::dump_config() { + ESP_LOGCONFIG(TAG, "Sendspin Media Player: volume_increment=%.2f", this->volume_increment_); +} + +} // namespace esphome::sendspin_ +#endif diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h new file mode 100644 index 0000000000..52786d6d7b --- /dev/null +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -0,0 +1,33 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) + +#include "esphome/components/media_player/media_player.h" +#include "esphome/components/sendspin/sendspin_hub.h" + +namespace esphome::sendspin_ { + +class SendspinMediaPlayer : public SendspinChild, public media_player::MediaPlayer { + public: + void setup() override; + void dump_config() override; + + // MediaPlayer implementations + media_player::MediaPlayerTraits get_traits() override; + + void set_volume_increment(float volume_increment) { this->volume_increment_ = volume_increment; } + + bool is_muted() const override { return this->muted_; } + + protected: + // Receives commands from HA + void control(const media_player::MediaPlayerCall &call) override; + + float volume_increment_{0.05f}; + bool muted_{false}; +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/tests/components/sendspin/common-media_player.yaml b/tests/components/sendspin/common-media_player.yaml new file mode 100644 index 0000000000..d3792cf470 --- /dev/null +++ b/tests/components/sendspin/common-media_player.yaml @@ -0,0 +1,5 @@ +<<: !include common.yaml + +media_player: + - platform: sendspin + id: media_player_id diff --git a/tests/components/sendspin/test-media_player.esp32-idf.yaml b/tests/components/sendspin/test-media_player.esp32-idf.yaml new file mode 100644 index 0000000000..cbbdb07c77 --- /dev/null +++ b/tests/components/sendspin/test-media_player.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-media_player.yaml From c74931aecb6d4bd9b2bfade15c619c67622eb27d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:55:22 -0500 Subject: [PATCH 0535/2214] [mdns] Revert defines.h USE_MDNS_EVENT_DRIVEN_POLLING / USE_MDNS_WIFI_LISTENER adds Copilot flagged that defining these unconditionally in defines.h breaks static analysis on platforms that don't meet the derivation's platform+listener guard: USE_MDNS_WIFI_LISTENER enables 'public wifi::WiFiIPStateListener' inheritance in the class declaration, but wifi_component.h is only #include'd inside the mdns_component.h derivation block. On analysis envs where the derivation guard is false (non-ESP8266/RP2040, or missing listener feature flags), the inherit fires without the header in scope, causing 'use of undeclared identifier' and related errors. Letting the derivation in mdns_component.h be the sole source of truth keeps the define and its corresponding include atomically linked. --- esphome/core/defines.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 53b78a97df..80247f69da 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -111,8 +111,6 @@ #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 -#define USE_MDNS_EVENT_DRIVEN_POLLING -#define USE_MDNS_WIFI_LISTENER #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define SERIAL_PROXY_COUNT 2 From fad39129975c70329d9f0cb18238d6df5c7061b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 21:20:03 -0500 Subject: [PATCH 0536/2214] [mdns] Restore USE_MDNS_EVENT_DRIVEN_POLLING guards around listener definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clang-tidy CI compiles with the raw esp8266-arduino-tidy env's flags — no Python codegen, and it doesn't pick up the feature defines from defines.h in the same way a real IDE does. USE_WIFI_IP_STATE_LISTENERS ends up undefined, the derivation in mdns_component.h doesn't fire, and the class doesn't declare start_polling_window_ / on_ip_state / the MDNS_POLL_* constants. Removing the guards in the platform cpps made those definitions dangle. Keep the listener-specific definitions under #ifdef USE_MDNS_EVENT_DRIVEN_POLLING. The Python validator still enforces the runtime invariant (wifi or ethernet must be present on ESP8266/RP2040), so production builds always fire through the listener path; this is purely about what the tidy compiler sees. --- esphome/components/mdns/mdns_esp8266.cpp | 6 ++++++ esphome/components/mdns/mdns_rp2040.cpp | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 3b9cfc65ff..95137ea116 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -38,21 +38,26 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } +#endif void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we just // arm the window around the initial probe/announce and each reconnect. Unconditional // here is safe: setup_priority::AFTER_CONNECTION guarantees the network is up. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); +#endif } +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // IP listener only fires on acquisition (not loss), so any notification is a fresh @@ -61,6 +66,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: this->start_polling_window_(); } } +#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index ece2bc5ace..f5848893a3 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -38,11 +38,13 @@ static void register_rp2040(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } +#endif void MDNSComponent::setup() { // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses @@ -72,6 +74,7 @@ void MDNSComponent::setup() { #endif } +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // Listener only fires on IP acquisition (not loss); every event is a fresh IP. @@ -86,6 +89,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: } this->start_polling_window_(); } +#endif void MDNSComponent::on_shutdown() { MDNS.close(); From 404620b99cc805225c328ce49d81a0fe4e07dff1 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 24 Apr 2026 04:31:46 +0200 Subject: [PATCH 0537/2214] [deep_sleep][logger][zephyr][zigbee] add deep sleep support with zigbee wakeup (#13950) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/deep_sleep/__init__.py | 6 +- .../deep_sleep/deep_sleep_bk72xx.cpp | 2 + .../deep_sleep/deep_sleep_component.cpp | 27 +++++++-- .../deep_sleep/deep_sleep_component.h | 16 +++++ .../deep_sleep/deep_sleep_esp32.cpp | 2 + .../deep_sleep/deep_sleep_esp8266.cpp | 2 + .../deep_sleep/deep_sleep_zephyr.cpp | 60 +++++++++++++++++++ esphome/components/logger/__init__.py | 17 +++--- esphome/components/logger/logger_zephyr.cpp | 2 + esphome/components/zephyr/__init__.py | 26 +++++++- esphome/components/zephyr/const.py | 1 + esphome/components/zigbee/__init__.py | 4 ++ esphome/components/zigbee/const_zephyr.py | 1 + esphome/components/zigbee/zigbee_zephyr.cpp | 25 +++++++- esphome/components/zigbee/zigbee_zephyr.h | 4 ++ esphome/components/zigbee/zigbee_zephyr.py | 8 +++ .../deep_sleep/test.nrf52-adafruit.yaml | 12 ++++ .../zigbee/test.nrf52-xiao-ble.yaml | 1 + 18 files changed, 196 insertions(+), 20 deletions(-) create mode 100644 esphome/components/deep_sleep/deep_sleep_zephyr.cpp create mode 100644 tests/components/deep_sleep/test.nrf52-adafruit.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 16329bb0fa..8184f954c7 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S3, get_esp32_variant, ) +from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -33,6 +34,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_NRF52, PlatformFramework, ) from esphome.core import CORE @@ -304,7 +306,7 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_NRF52]), validate_config, ) @@ -369,6 +371,8 @@ async def to_code(config): if CONF_TOUCH_WAKEUP in config: cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) + if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations: + zephyr_add_prj_conf("POWEROFF", True) cg.add_define("USE_DEEP_SLEEP") diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index b5fadd7230..8dca32689b 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -59,6 +59,8 @@ void DeepSleepComponent::deep_sleep_() { lt_deep_sleep_enter(); } +bool DeepSleepComponent::should_teardown_() { return true; } + } // namespace esphome::deep_sleep #endif // USE_BK72XX diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index 3dd1b70930..d2c5db54b3 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -9,11 +9,22 @@ static const char *const TAG = "deep_sleep"; // 5 seconds for deep sleep to ensure clean disconnect from Home Assistant static const uint32_t TEARDOWN_TIMEOUT_DEEP_SLEEP_MS = 5000; -bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void DeepSleepComponent::setup() { +#ifdef USE_ZEPHYR + k_sem_init(&this->wakeup_sem_, 0, 1); +#endif global_has_deep_sleep = true; + this->schedule_sleep_(); + // It can be used from another thread for waking up the device. + // It should be called as last item in setup. + global_deep_sleep.store(this); +} +void DeepSleepComponent::schedule_sleep_() { + this->next_enter_deep_sleep_ = false; const optional run_duration = get_run_duration_(); if (run_duration.has_value()) { ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration); @@ -58,13 +69,17 @@ void DeepSleepComponent::begin_sleep(bool manual) { if (this->sleep_duration_.has_value()) { ESP_LOGI(TAG, "Sleeping for %" PRId64 "us", *this->sleep_duration_); } - App.run_safe_shutdown_hooks(); - // It's critical to teardown components cleanly for deep sleep to ensure - // Home Assistant sees a clean disconnect instead of marking the device unavailable - App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS); - App.run_powerdown_hooks(); + + if (this->should_teardown_()) { + App.run_safe_shutdown_hooks(); + // It's critical to teardown components cleanly for deep sleep to ensure + // Home Assistant sees a clean disconnect instead of marking the device unavailable + App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS); + App.run_powerdown_hooks(); + } this->deep_sleep_(); + this->schedule_sleep_(); } float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 9090f91876..854ab152a1 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -4,6 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include #ifdef USE_ESP32 #include @@ -14,6 +15,10 @@ #include "esphome/core/time.h" #endif +#ifdef USE_ZEPHYR +#include +#endif + #include namespace esphome { @@ -120,6 +125,9 @@ class DeepSleepComponent : public Component { void prevent_deep_sleep(); void allow_deep_sleep(); +#ifdef USE_ZEPHYR + void wakeup(); +#endif protected: // Returns nullopt if no run duration is set. Otherwise, returns the run @@ -129,6 +137,8 @@ class DeepSleepComponent : public Component { void dump_config_platform_(); bool prepare_to_sleep_(); void deep_sleep_(); + void schedule_sleep_(); + bool should_teardown_(); #ifdef USE_BK72XX bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const; @@ -157,6 +167,9 @@ class DeepSleepComponent : public Component { optional run_duration_; bool next_enter_deep_sleep_{false}; bool prevent_{false}; +#ifdef USE_ZEPHYR + k_sem wakeup_sem_; +#endif }; extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -243,5 +256,8 @@ template class AllowDeepSleepAction : public Action, publ void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } }; +extern std::atomic + global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + } // namespace deep_sleep } // namespace esphome diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 4f4d262d30..80a218e913 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -165,6 +165,8 @@ void DeepSleepComponent::deep_sleep_() { esp_deep_sleep_start(); } +bool DeepSleepComponent::should_teardown_() { return true; } + } // namespace deep_sleep } // namespace esphome #endif // USE_ESP32 diff --git a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index efbd45c34e..42c153c2f3 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -18,6 +18,8 @@ void DeepSleepComponent::deep_sleep_() { ESP.deepSleep(this->sleep_duration_.value_or(0)); // NOLINT(readability-static-accessed-through-instance) } +bool DeepSleepComponent::should_teardown_() { return true; } + } // namespace deep_sleep } // namespace esphome #endif diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp new file mode 100644 index 0000000000..82d6d8c7de --- /dev/null +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -0,0 +1,60 @@ +#include "deep_sleep_component.h" +#ifdef USE_ZEPHYR +#include "esphome/core/log.h" +#include +#include +#include +#include + +namespace esphome::deep_sleep { + +static const char *const TAG = "deep_sleep"; + +void DeepSleepComponent::wakeup() { k_sem_give(&this->wakeup_sem_); } + +optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } + +void DeepSleepComponent::dump_config_platform_() {} + +bool DeepSleepComponent::prepare_to_sleep_() { return true; } + +void DeepSleepComponent::deep_sleep_() { + k_timeout_t sleep_duration = K_FOREVER; + if (this->sleep_duration_.has_value()) { + sleep_duration = K_USEC(*this->sleep_duration_); + } else { +#ifndef USE_ZIGBEE + // the device can be woken up through one of the following signals: + // - The DETECT signal, optionally generated by the GPIO peripheral. + // - The ANADETECT signal, optionally generated by the LPCOMP module. + // - The SENSE signal, optionally generated by the NFC module to wake-on-field. + // - Detecting a valid USB voltage on the VBUS pin (VBUS,DETECT). + // - A reset. + // + // The system is reset when it wakes up from System OFF mode. + sys_poweroff(); +#endif + } + // It might wake up immediately if k_sem_give was called again after wake up + int ret = k_sem_take(&this->wakeup_sem_, sleep_duration); + if (ret == 0) { + ESP_LOGD(TAG, "Woken up by another thread"); + } else { + ESP_LOGD(TAG, "Timeout expired (normal sleep)"); + } +} + +bool DeepSleepComponent::should_teardown_() { + if (this->sleep_duration_.has_value()) { + return false; + } +#ifdef USE_ZIGBEE + return false; +#else + return true; +#endif +} + +} // namespace esphome::deep_sleep + +#endif diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 4144543b89..9d7dc8d92c 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -472,14 +472,15 @@ async def _late_logger_init(config: ConfigType) -> None: # esphome implement own fatal error handler which save PC/LR before reset zephyr_add_prj_conf("RESET_ON_FATAL_ERROR", False) zephyr_add_prj_conf("THREAD_LOCAL_STORAGE", True) - if config[CONF_HARDWARE_UART] == UART0: - zephyr_add_overlay("""&uart0 { status = "okay";};""") - if config[CONF_HARDWARE_UART] == UART1: - zephyr_add_overlay("""&uart1 { status = "okay";};""") - if config[CONF_HARDWARE_UART] == USB_CDC: - cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") - zephyr_add_prj_conf("UART_LINE_CTRL", True) - zephyr_add_cdc_acm(config, 0) + if has_serial_logging: + if config[CONF_HARDWARE_UART] == UART0: + zephyr_add_overlay("""&uart0 { status = "okay";};""") + if config[CONF_HARDWARE_UART] == UART1: + zephyr_add_overlay("""&uart1 { status = "okay";};""") + if config[CONF_HARDWARE_UART] == USB_CDC: + cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") + zephyr_add_prj_conf("UART_LINE_CTRL", True) + zephyr_add_cdc_acm(config, 0) # Register at end for safe mode await cg.register_component(log, config) diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 6b46b93c61..7fa9e42c6a 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -65,10 +65,12 @@ void Logger::pre_setup() { break; #ifdef USE_LOGGER_USB_CDC case UART_SELECTION_USB_CDC: +#ifdef CONFIG_USB_DEVICE_STACK uart_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(cdc_acm_uart0)); if (device_is_ready(uart_dev)) { usb_enable(nullptr); } +#endif break; #endif } diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index d3cc6b2cf4..5dccecc097 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -15,6 +15,7 @@ from .const import ( KEY_BOARD, KEY_BOOTLOADER, KEY_EXTRA_BUILD_FILES, + KEY_KCONFIG, KEY_OVERLAY, KEY_PM_STATIC, KEY_PRJ_CONF, @@ -54,6 +55,7 @@ class ZephyrData(TypedDict): extra_build_files: dict[str, Path] pm_static: list[Section] user: dict[str, list[str]] + kconfig: str def zephyr_set_core_data(config: ConfigType) -> None: @@ -65,6 +67,7 @@ def zephyr_set_core_data(config: ConfigType) -> None: extra_build_files={}, pm_static=[], user={}, + kconfig="", ) @@ -185,8 +188,12 @@ def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: ) -def zephyr_add_pm_static(section: Section): - CORE.data[KEY_ZEPHYR][KEY_PM_STATIC].extend(section) +def zephyr_add_kconfig(kconfig: str) -> None: + zephyr_data()[KEY_KCONFIG] += textwrap.dedent(kconfig) + "\n" + + +def zephyr_add_pm_static(sections: list[Section]) -> None: + zephyr_data()[KEY_PM_STATIC].extend(sections) def zephyr_add_user(key, value): @@ -273,3 +280,18 @@ def copy_files(): write_file_if_changed( CORE.relative_build_path("zephyr/pm_static.yml"), pm_static ) + + kconfig = zephyr_data()[KEY_KCONFIG] + if kconfig: + kconfig = ( + textwrap.dedent( + """ + menu "Zephyr" + source "Kconfig.zephyr" + endmenu + """ + ) + + "\n" + + kconfig + ) + write_file_if_changed(CORE.relative_build_path("zephyr/Kconfig"), kconfig) diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index f67b058ed7..f2de861e31 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -8,6 +8,7 @@ KEY_BOOTLOADER: Final = "bootloader" KEY_EXTRA_BUILD_FILES: Final = "extra_build_files" KEY_OVERLAY: Final = "overlay" KEY_PM_STATIC: Final = "pm_static" +KEY_KCONFIG: Final = "kconfig" KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" KEY_BOARD: Final = "board" diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 126e3aa2cd..0bb5f95bb6 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -32,6 +32,7 @@ from .const import ( from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, CONF_MAX_EP_NUMBER, + CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, ) @@ -107,6 +108,9 @@ CONFIG_SCHEMA = cv.All( ), cv.requires_component("nrf52"), ), + cv.OnlyWith(CONF_SLEEPY, "nrf52", default=False): cv.All( + cv.boolean, + ), } ).extend(cv.COMPONENT_SCHEMA), zigbee_require_vfs_select, diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 103ef01a3d..63d03c7952 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -4,6 +4,7 @@ CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" +CONF_SLEEPY = "sleepy" CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" # Keys for CORE.data storage diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 047c30300e..90bb66c91d 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -4,6 +4,9 @@ #include #include #include "esphome/core/hal.h" +#ifdef USE_DEEP_SLEEP +#include "esphome/components/deep_sleep/deep_sleep_component.h" +#endif extern "C" { #include @@ -116,6 +119,12 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; +#ifdef USE_DEEP_SLEEP + if (auto *ds = deep_sleep::global_deep_sleep.load()) { + ds->wakeup(); + } +#endif + // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { const auto &cb = global_zigbee->callbacks_[endpoint - 1]; @@ -181,9 +190,11 @@ void ZigbeeComponent::setup() { ESP_LOGE(TAG, "Cannot load settings, err: %d", err); return; } + zigbee_configure_sleepy_behavior(this->sleepy_); zigbee_enable(); } +#ifdef ESPHOME_LOG_HAS_CONFIG static const char *role() { switch (zb_get_network_role()) { case ZB_NWK_DEVICE_TYPE_COORDINATOR: @@ -207,6 +218,7 @@ static const char *get_wipe_on_boot() { return "NO"; #endif } +#endif void ZigbeeComponent::dump_config() { char ieee_addr_buf[IEEE_ADDR_BUF_SIZE] = {0}; @@ -222,6 +234,7 @@ void ZigbeeComponent::dump_config() { " Wipe on boot: %s\n" " Device is joined to the network: %s\n" " Sleep time: %us\n" + " RX ON when idle: %s\n" " Current channel: %d\n" " Current page: %d\n" " Sleep threshold: %ums\n" @@ -230,9 +243,9 @@ void ZigbeeComponent::dump_config() { " Short addr: 0x%04X\n" " Long pan id: 0x%s\n" " Short pan id: 0x%04X", - get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, zb_get_current_channel(), - zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf, zb_get_short_address(), - extended_pan_id_buf, zb_get_pan_id()); + get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, YESNO(zb_get_rx_on_when_idle()), + zb_get_current_channel(), zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf, + zb_get_short_address(), extended_pan_id_buf, zb_get_pan_id()); dump_reporting_(); } @@ -302,6 +315,12 @@ void ZigbeeComponent::after_reporting_info(zb_zcl_configure_reporting_req_t *con extern "C" { void zboss_signal_handler(zb_uint8_t param) { esphome::zigbee::global_zigbee->zboss_signal_handler_esphome(param); } +void zb_osif_serial_put_bytes(const zb_uint8_t *buf, zb_short_t len) { + (void) buf; + (void) len; +} +void zb_osif_serial_flush() {} +void zb_osif_serial_init() {} // NOLINTBEGIN(readability-identifier-naming,bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) extern zb_ret_t __real_zb_zcl_put_reporting_info_from_req(zb_zcl_configure_reporting_req_t *config_rep_req, diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index eeb142eff1..0a189ac1e0 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -81,6 +81,7 @@ class ZigbeeComponent : public Component { Trigger<> *get_join_trigger() { return &this->join_trigger_; }; void force_report(); void loop() override; + void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } protected: static void zcl_device_cb(zb_bufid_t bufid); @@ -95,6 +96,7 @@ class ZigbeeComponent : public Component { bool force_report_{false}; uint32_t sleep_time_{}; uint32_t sleep_remainder_{}; + bool sleepy_{}; }; class ZigbeeEntity { @@ -107,5 +109,7 @@ class ZigbeeEntity { ZigbeeComponent *parent_{nullptr}; }; +extern ZigbeeComponent *global_zigbee; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + } // namespace esphome::zigbee #endif diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index f6e3e88c63..7d904b6081 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -63,6 +63,7 @@ from .const import ( ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, + CONF_SLEEPY, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_NUMBER, @@ -169,6 +170,11 @@ async def zephyr_to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NET_IP_ADDR_CHECK", False) zephyr_add_prj_conf("NET_UDP", False) + # disable all extra to reduce power and save flash + zephyr_add_prj_conf("ZIGBEE_HAVE_SERIAL", False) + zephyr_add_prj_conf("ZBOSS_ERROR_PRINT_TO_LOG", False) + zephyr_add_prj_conf("DK_LIBRARY", False) + cg.add_build_flag("-Wl,--wrap=zb_zcl_put_reporting_info_from_req") if CONF_IEEE802154_VENDOR_OUI in config: @@ -200,6 +206,8 @@ async def zephyr_to_code(config: ConfigType) -> None: CORE.add_job(_ctx_to_code, config) + cg.add(var.set_sleepy(config[CONF_SLEEPY])) + async def _attr_to_code(config: ConfigType) -> None: # Create the basic attributes structure and attribute list diff --git a/tests/components/deep_sleep/test.nrf52-adafruit.yaml b/tests/components/deep_sleep/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..6362142be2 --- /dev/null +++ b/tests/components/deep_sleep/test.nrf52-adafruit.yaml @@ -0,0 +1,12 @@ +deep_sleep: + run_duration: 10s + sleep_duration: 50s + +<<: !include common.yaml + +zigbee: + +sensor: + - platform: template + name: "Temperature" + id: temperature_sensor diff --git a/tests/components/zigbee/test.nrf52-xiao-ble.yaml b/tests/components/zigbee/test.nrf52-xiao-ble.yaml index 83d949b4dd..acfbc9e996 100644 --- a/tests/components/zigbee/test.nrf52-xiao-ble.yaml +++ b/tests/components/zigbee/test.nrf52-xiao-ble.yaml @@ -4,3 +4,4 @@ zigbee: wipe_on_boot: once power_source: battery ieee802154_vendor_oui: 0x231 + sleepy: true From 811d4ddfbdaea2b2fa43676bd53190bdacf60ab6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 21:48:18 -0500 Subject: [PATCH 0538/2214] [mdns] Tighten ESP8266 guards, expand window to 15s, document derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mdns_esp8266.cpp: narrow setup()'s listener block and on_ip_state from USE_MDNS_EVENT_DRIVEN_POLLING to USE_MDNS_WIFI_LISTENER. ESP8266 has no ethernet so the practical effect is identical, but the tighter guard is symmetric with mdns_rp2040.cpp and more defensible if the listener macros ever drift. - MDNS_POLL_WINDOW_MS 12000 → 15000. The RFC-compliant LEAmDNS phase is ~9s worst case, but a blocked main loop (long component setup, WiFi scan, flash writes) can stretch the deadlines between our polls. 6s of margin absorbs that without materially extending the steady-state quiet period. - Header comment for MDNS_POLL_WINDOW_MS now breaks down the 9s derivation with the exact LEAmDNS constants (MDNS_PROBE_DELAY × MDNS_PROBE_COUNT + MDNS_ANNOUNCE_DELAY × MDNS_ANNOUNCE_COUNT + jitter + debounce hop) so future library bumps can be justified against the same math. --- esphome/components/mdns/mdns_component.h | 12 +++++++++++- esphome/components/mdns/mdns_esp8266.cpp | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 4f651f7d84..798af0e0bf 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -76,7 +76,17 @@ class MDNSComponent final : public Component // and update() becomes pure overhead. We arm a bounded polling window from IP state // listener events so update() runs only during that phase. static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; - static constexpr uint32_t MDNS_POLL_WINDOW_MS = 12000; // ~9s phase + jitter/restart margin + // Must exceed LEAmDNS's longest restart-to-announce-complete path: + // MDNS_PROBE_DELAY (250ms) × MDNS_PROBE_COUNT (3) = 750ms probing + // + MDNS_ANNOUNCE_DELAY (1000ms) × MDNS_ANNOUNCE_COUNT (8) = 8000ms announcing + // + rand() % MDNS_PROBE_DELAY jitter on first probe (0–250ms) + // + debounced schedule_function() hop when statusChangeCB fires on ESP8266 + // ≈ 9s nominal. 15s gives ~6s margin to absorb main-loop blocking (long + // component setup, WiFi scan, flash writes) that could stretch the deadlines + // between our polls. If LEAmDNS ever extends its phase (upstream library + // update) this constant needs to grow. Constants defined in LEAmDNS_Priv.h + // (ESP8266 core 3.1.2 / arduino-pico 5.5.1). + static constexpr uint32_t MDNS_POLL_WINDOW_MS = 15000; static constexpr uint32_t MDNS_POLL_ID = 0; static constexpr uint32_t MDNS_POLL_STOP_ID = 1; #endif diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 95137ea116..f6d5786675 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -48,7 +48,7 @@ void MDNSComponent::start_polling_window_() { void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +#ifdef USE_MDNS_WIFI_LISTENER // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we just // arm the window around the initial probe/announce and each reconnect. Unconditional // here is safe: setup_priority::AFTER_CONNECTION guarantees the network is up. @@ -57,7 +57,7 @@ void MDNSComponent::setup() { #endif } -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +#ifdef USE_MDNS_WIFI_LISTENER void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // IP listener only fires on acquisition (not loss), so any notification is a fresh From eceb534895dcaa1c9c77c906500799fefeb4f6de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 02:19:59 -0500 Subject: [PATCH 0539/2214] [deep_sleep] Fix sleep_duration codegen type to uint32_t (#15965) --- esphome/components/deep_sleep/__init__.py | 2 +- tests/components/deep_sleep/common.yaml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 8184f954c7..0ca557bd6d 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -417,7 +417,7 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_SLEEP_DURATION in config: - template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.int32) + template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.uint32) cg.add(var.set_sleep_duration(template_)) if CONF_UNTIL in config: diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index c090cb83e2..7a1a709965 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -4,3 +4,9 @@ esphome: - deep_sleep.prevent - delay: 1s - deep_sleep.allow + - if: + condition: + lambda: 'return false;' + then: + - deep_sleep.enter: + sleep_duration: 60min From f4a055f342435dfbba5913efad74bf408e36f1c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 02:55:23 -0500 Subject: [PATCH 0540/2214] [web_server_idf] Defer SSE session adoption/priming to the main loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AsyncEventSource::handleRequest() runs on the ESP-IDF httpd task. Prior to this change it mutated sessions_, event_buffer_, deferred_queue_, and entities_iterator_ directly: appending the new response to sessions_, sending the initial ping/config/sorting_groups through try_send_nodefer() (which writes event_buffer_), and calling entities_iterator_.begin(). The main loop reads/writes the same fields from WebServer::loop() on the main task, so every new SSE connect raced with any in-flight loop tick. The httpd path now only performs the HTTP-level setup that requires the live httpd_req_t (headers, initial chunk, sess_ctx/free_ctx, fd_, send override) and parks the response on pending_sessions_ under a mutex. The main loop checks a std::atomic fast-path gate per tick; when set it swaps the pending list out under the lock and then — outside the lock — pushes into sessions_, invokes on_connect_, and calls prime_() which performs the initial sends and starts entities_iterator_. sessions_, event_buffer_, deferred_queue_, and entities_iterator_ are now mutated exclusively from the main loop. fd_ remains the only cross-thread signal (std::atomic cleared by destroy() on the httpd/tcpip task). --- .../web_server_idf/web_server_idf.cpp | 55 +++++++++++++++---- .../web_server_idf/web_server_idf.h | 11 ++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 8f464ae912..3610f4cdfa 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -475,21 +475,54 @@ AsyncEventSource::~AsyncEventSource() { for (auto *ses : this->sessions_) { delete ses; // NOLINT(cppcoreguidelines-owning-memory) } + LockGuard guard{this->pending_mutex_}; + for (auto *ses : this->pending_sessions_) { + delete ses; // NOLINT(cppcoreguidelines-owning-memory) + } } void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) { + // Runs on the httpd task. Do only the HTTP-level setup that needs the live httpd_req_t, + // then hand the session off to the main loop for adoption and priming. This keeps + // sessions_, event_buffer_, and deferred_queue_ mutated exclusively from the main loop. // NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks) auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_); - if (this->on_connect_) { - this->on_connect_(rsp); + { + LockGuard guard{this->pending_mutex_}; + this->pending_sessions_.push_back(rsp); + // Release-store so the main loop's acquire-load sees the push_back above. + this->has_pending_sessions_.store(true, std::memory_order_release); } - this->sessions_.push_back(rsp); - // Wake up WebServer::loop() to drain deferred event queues for this client. + // Wake up WebServer::loop() to adopt and prime this client. // Safe from httpd task context via the pending_enable_loop_ flag. this->web_server_->enable_loop_soon_any_context(); } bool AsyncEventSource::loop() { + // Adopt sessions handed off from the httpd task. Fast path: one atomic load per tick + // when nothing is pending. Only take the lock / touch the vector on a real connect. + // Swap under the lock and do the heavy work (on_connect_ callback, initial sends, + // entity iterator start) outside it so they cannot race with httpd handlers. + if (this->has_pending_sessions_.load(std::memory_order_acquire)) { + std::vector incoming; + { + LockGuard guard{this->pending_mutex_}; + incoming.swap(this->pending_sessions_); + this->has_pending_sessions_.store(false, std::memory_order_relaxed); + } + for (auto *rsp : incoming) { + this->sessions_.push_back(rsp); + if (this->on_connect_) { + this->on_connect_(rsp); + } + // Skip priming if the client disconnected before we got here; the cleanup pass below + // will delete it. + if (rsp->fd_.load() != 0) { + rsp->prime_(); + } + } + } + // Clean up dead sessions safely // This follows the ESP-IDF pattern where free_ctx marks resources as dead // and the main loop handles the actual cleanup to avoid race conditions @@ -534,6 +567,9 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws) : server_(server), web_server_(ws), entities_iterator_(ws, server) { + // Runs on the httpd task. Only touch state tied to the live httpd_req_t here; the + // main loop will call prime_() later to do the initial sends and start the iterator. + // Writing to event_buffer_ / deferred_queue_ from this task would race with the main loop. httpd_req_t *req = *request; httpd_resp_set_status(req, HTTPD_200); @@ -555,6 +591,11 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * // Use non-blocking send to prevent watchdog timeouts when TCP buffers are full httpd_sess_set_send_override(this->hd_, this->fd_.load(), nonblocking_send); +} + +void AsyncEventSourceResponse::prime_() { + // Runs on the main loop after AsyncEventSource::loop() adopts this session. + auto *ws = this->web_server_; // Configure reconnect timeout and send config // this should always go through since the tcp send buffer is empty on connect @@ -578,12 +619,6 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * #endif this->entities_iterator_.begin(ws->include_internal_); - - // just dump them all up-front and take advantage of the deferred queue - // on second thought that takes too long, but leaving the commented code here for debug purposes - // while(!this->entities_iterator_.completed()) { - // this->entities_iterator_.advance(); - //} } void AsyncEventSourceResponse::destroy(void *ptr) { diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index f2931fb507..04a856ae04 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -299,6 +299,10 @@ class AsyncEventSourceResponse { AsyncEventSourceResponse(const AsyncWebServerRequest *request, esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws); + // Sends the initial ping/config/sorting_groups and starts the entity iterator. + // Must be called from the main loop (writes event_buffer_ and touches entities_iterator_). + void prime_(); + void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator); void process_deferred_queue_(); void process_buffer_(); @@ -351,7 +355,14 @@ class AsyncEventSource : public AsyncWebHandler { // Use vector instead of set: SSE sessions are typically 1-5 connections (browsers, dashboards). // Linear search is faster than red-black tree overhead for this small dataset. // Only operations needed: add session, remove session, iterate sessions - no need for sorted order. + // Mutated only from the main loop. std::vector sessions_; + // Sessions constructed on the httpd task wait here until the main loop adopts them. + // All mutations are guarded by pending_mutex_. has_pending_sessions_ is a fast-path + // gate so the per-tick cost in loop() when no connect is pending is one atomic load. + std::vector pending_sessions_; + Mutex pending_mutex_; + std::atomic has_pending_sessions_{false}; connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; }; From 5fab869286302ec8d29cf97471922a97e652aa40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 02:57:21 -0500 Subject: [PATCH 0541/2214] [web_server_idf] Move has_pending_sessions_ to end to tighten layout Keep the single-byte std::atomic as the last member of AsyncEventSource so it consumes what would otherwise be trailing padding instead of introducing 3 bytes of interior padding between pending_mutex_ and on_connect_. --- esphome/components/web_server_idf/web_server_idf.h | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 04a856ae04..60d07534b9 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -351,6 +351,9 @@ class AsyncEventSource : public AsyncWebHandler { size_t count() const { return this->sessions_.size(); } protected: + // Members are ordered to minimize padding on 32-bit: all 4-byte-aligned members + // first, then the single-byte atomic at the end to consume what would otherwise + // be trailing padding. std::string url_; // Use vector instead of set: SSE sessions are typically 1-5 connections (browsers, dashboards). // Linear search is faster than red-black tree overhead for this small dataset. @@ -358,13 +361,14 @@ class AsyncEventSource : public AsyncWebHandler { // Mutated only from the main loop. std::vector sessions_; // Sessions constructed on the httpd task wait here until the main loop adopts them. - // All mutations are guarded by pending_mutex_. has_pending_sessions_ is a fast-path - // gate so the per-tick cost in loop() when no connect is pending is one atomic load. + // All mutations are guarded by pending_mutex_. has_pending_sessions_ (below) is a + // fast-path gate so the per-tick cost in loop() when no connect is pending is one + // atomic load. std::vector pending_sessions_; Mutex pending_mutex_; - std::atomic has_pending_sessions_{false}; connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; + std::atomic has_pending_sessions_{false}; }; #endif // USE_WEBSERVER From 82081fc56713f6a5b39f41b0674ed86d90c7f895 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 02:58:45 -0500 Subject: [PATCH 0542/2214] [web_server_idf] Trim comments --- .../web_server_idf/web_server_idf.cpp | 23 ++++--------------- .../web_server_idf/web_server_idf.h | 17 ++++---------- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 3610f4cdfa..bb090bad40 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -482,27 +482,19 @@ AsyncEventSource::~AsyncEventSource() { } void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) { - // Runs on the httpd task. Do only the HTTP-level setup that needs the live httpd_req_t, - // then hand the session off to the main loop for adoption and priming. This keeps - // sessions_, event_buffer_, and deferred_queue_ mutated exclusively from the main loop. + // Httpd task: set up the live httpd_req_t and park the session; main loop does the rest. // NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks) auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_); { LockGuard guard{this->pending_mutex_}; this->pending_sessions_.push_back(rsp); - // Release-store so the main loop's acquire-load sees the push_back above. this->has_pending_sessions_.store(true, std::memory_order_release); } - // Wake up WebServer::loop() to adopt and prime this client. - // Safe from httpd task context via the pending_enable_loop_ flag. this->web_server_->enable_loop_soon_any_context(); } bool AsyncEventSource::loop() { - // Adopt sessions handed off from the httpd task. Fast path: one atomic load per tick - // when nothing is pending. Only take the lock / touch the vector on a real connect. - // Swap under the lock and do the heavy work (on_connect_ callback, initial sends, - // entity iterator start) outside it so they cannot race with httpd handlers. + // Fast path: one atomic load per tick. Lock only on a real connect. if (this->has_pending_sessions_.load(std::memory_order_acquire)) { std::vector incoming; { @@ -515,8 +507,7 @@ bool AsyncEventSource::loop() { if (this->on_connect_) { this->on_connect_(rsp); } - // Skip priming if the client disconnected before we got here; the cleanup pass below - // will delete it. + // Already disconnected? Cleanup pass below will delete it. if (rsp->fd_.load() != 0) { rsp->prime_(); } @@ -567,9 +558,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws) : server_(server), web_server_(ws), entities_iterator_(ws, server) { - // Runs on the httpd task. Only touch state tied to the live httpd_req_t here; the - // main loop will call prime_() later to do the initial sends and start the iterator. - // Writing to event_buffer_ / deferred_queue_ from this task would race with the main loop. + // Httpd task only. prime_() on the main loop handles event_buffer_ / iterator setup. httpd_req_t *req = *request; httpd_resp_set_status(req, HTTPD_200); @@ -594,11 +583,9 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * } void AsyncEventSourceResponse::prime_() { - // Runs on the main loop after AsyncEventSource::loop() adopts this session. auto *ws = this->web_server_; - // Configure reconnect timeout and send config - // this should always go through since the tcp send buffer is empty on connect + // tcp send buffer is empty on connect, so these should always go through auto message = ws->get_config_json(); this->try_send_nodefer(message.c_str(), "ping", millis(), 30000); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 60d07534b9..dc9f5ea16b 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -299,8 +299,7 @@ class AsyncEventSourceResponse { AsyncEventSourceResponse(const AsyncWebServerRequest *request, esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws); - // Sends the initial ping/config/sorting_groups and starts the entity iterator. - // Must be called from the main loop (writes event_buffer_ and touches entities_iterator_). + // Main-loop only: sends initial ping/config/sorting_groups, starts entity iterator. void prime_(); void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator); @@ -351,19 +350,11 @@ class AsyncEventSource : public AsyncWebHandler { size_t count() const { return this->sessions_.size(); } protected: - // Members are ordered to minimize padding on 32-bit: all 4-byte-aligned members - // first, then the single-byte atomic at the end to consume what would otherwise - // be trailing padding. + // Ordered to minimize padding on 32-bit: atomic last consumes trailing pad. std::string url_; - // Use vector instead of set: SSE sessions are typically 1-5 connections (browsers, dashboards). - // Linear search is faster than red-black tree overhead for this small dataset. - // Only operations needed: add session, remove session, iterate sessions - no need for sorted order. - // Mutated only from the main loop. + // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; - // Sessions constructed on the httpd task wait here until the main loop adopts them. - // All mutations are guarded by pending_mutex_. has_pending_sessions_ (below) is a - // fast-path gate so the per-tick cost in loop() when no connect is pending is one - // atomic load. + // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. std::vector pending_sessions_; Mutex pending_mutex_; connect_handler_t on_connect_{}; From b1390f1679c99f42f55dc0a92b109fe307425a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 02:59:03 -0500 Subject: [PATCH 0543/2214] [web_server_idf] Drop pre-disconnected sessions before on_connect_ If fd_ is already 0 when the main loop adopts an incoming session, the client closed before we got here. Delete the session immediately instead of pushing it into sessions_ and firing on_connect_ on a dead socket. --- esphome/components/web_server_idf/web_server_idf.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index bb090bad40..bbaca95324 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -503,14 +503,16 @@ bool AsyncEventSource::loop() { this->has_pending_sessions_.store(false, std::memory_order_relaxed); } for (auto *rsp : incoming) { + // Already disconnected? Drop it; skip on_connect_/prime on a dead session. + if (rsp->fd_.load() == 0) { + delete rsp; // NOLINT(cppcoreguidelines-owning-memory) + continue; + } this->sessions_.push_back(rsp); if (this->on_connect_) { this->on_connect_(rsp); } - // Already disconnected? Cleanup pass below will delete it. - if (rsp->fd_.load() != 0) { - rsp->prime_(); - } + rsp->prime_(); } } From 44b4a20abe22d42dd57ed43a5c7286193b7aaa1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:00:04 -0500 Subject: [PATCH 0544/2214] [web_server_idf] Hold pending_mutex_ for both deletes in destructor --- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index bbaca95324..84386b227f 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -472,10 +472,10 @@ void AsyncResponseStream::printf(const char *fmt, ...) { #ifdef USE_WEBSERVER AsyncEventSource::~AsyncEventSource() { + LockGuard guard{this->pending_mutex_}; for (auto *ses : this->sessions_) { delete ses; // NOLINT(cppcoreguidelines-owning-memory) } - LockGuard guard{this->pending_mutex_}; for (auto *ses : this->pending_sessions_) { delete ses; // NOLINT(cppcoreguidelines-owning-memory) } From 9b28edd23e3cca917a705a1a688e4559ff650b2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:00:29 -0500 Subject: [PATCH 0545/2214] [web_server_idf] De-dupe destructor delete loops --- esphome/components/web_server_idf/web_server_idf.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 84386b227f..9986196221 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -473,11 +473,10 @@ void AsyncResponseStream::printf(const char *fmt, ...) { #ifdef USE_WEBSERVER AsyncEventSource::~AsyncEventSource() { LockGuard guard{this->pending_mutex_}; - for (auto *ses : this->sessions_) { - delete ses; // NOLINT(cppcoreguidelines-owning-memory) - } - for (auto *ses : this->pending_sessions_) { - delete ses; // NOLINT(cppcoreguidelines-owning-memory) + for (auto *vec : {&this->sessions_, &this->pending_sessions_}) { + for (auto *ses : *vec) { + delete ses; // NOLINT(cppcoreguidelines-owning-memory) + } } } From 1df529cbca427f5fd34a7129429e4d6248127e9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:01:48 -0500 Subject: [PATCH 0546/2214] [web_server_idf] Drop padding comment --- esphome/components/web_server_idf/web_server_idf.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index dc9f5ea16b..4f754846ab 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -350,7 +350,6 @@ class AsyncEventSource : public AsyncWebHandler { size_t count() const { return this->sessions_.size(); } protected: - // Ordered to minimize padding on 32-bit: atomic last consumes trailing pad. std::string url_; // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; From c7a67ac41d555ccf17745a995bca3584ac69d447 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:04:50 -0500 Subject: [PATCH 0547/2214] [web_server_idf] Rename prime_() to start_session_main_loop_() --- esphome/components/web_server_idf/web_server_idf.cpp | 6 +++--- esphome/components/web_server_idf/web_server_idf.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9986196221..66942463e6 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -511,7 +511,7 @@ bool AsyncEventSource::loop() { if (this->on_connect_) { this->on_connect_(rsp); } - rsp->prime_(); + rsp->start_session_main_loop_(); } } @@ -559,7 +559,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws) : server_(server), web_server_(ws), entities_iterator_(ws, server) { - // Httpd task only. prime_() on the main loop handles event_buffer_ / iterator setup. + // Httpd task only. start_session_main_loop_() handles event_buffer_ / iterator setup. httpd_req_t *req = *request; httpd_resp_set_status(req, HTTPD_200); @@ -583,7 +583,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * httpd_sess_set_send_override(this->hd_, this->fd_.load(), nonblocking_send); } -void AsyncEventSourceResponse::prime_() { +void AsyncEventSourceResponse::start_session_main_loop_() { auto *ws = this->web_server_; // tcp send buffer is empty on connect, so these should always go through diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 4f754846ab..89e1c5fada 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -300,7 +300,7 @@ class AsyncEventSourceResponse { esphome::web_server::WebServer *ws); // Main-loop only: sends initial ping/config/sorting_groups, starts entity iterator. - void prime_(); + void start_session_main_loop_(); void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator); void process_deferred_queue_(); From 121b4d6916e73b716d6f6fecf8a08a8df778e6ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:08:01 -0500 Subject: [PATCH 0548/2214] [web_server_idf] Move session adoption to out-of-line cold path Keep the hot path in AsyncEventSource::loop() to the atomic load, branch, and dead-session cleanup. On a real connect, call a noinline+cold helper (adopt_pending_sessions_main_loop_) to swap the pending list out, drop any already-disconnected sessions, and invoke on_connect_ / start_session_main_loop_ on the rest. --- .../web_server_idf/web_server_idf.cpp | 42 ++++++++++--------- .../web_server_idf/web_server_idf.h | 3 ++ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 66942463e6..22a8094424 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -493,26 +493,9 @@ void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) { } bool AsyncEventSource::loop() { - // Fast path: one atomic load per tick. Lock only on a real connect. + // Fast path: one atomic load per tick. Slow path is out-of-line on connect. if (this->has_pending_sessions_.load(std::memory_order_acquire)) { - std::vector incoming; - { - LockGuard guard{this->pending_mutex_}; - incoming.swap(this->pending_sessions_); - this->has_pending_sessions_.store(false, std::memory_order_relaxed); - } - for (auto *rsp : incoming) { - // Already disconnected? Drop it; skip on_connect_/prime on a dead session. - if (rsp->fd_.load() == 0) { - delete rsp; // NOLINT(cppcoreguidelines-owning-memory) - continue; - } - this->sessions_.push_back(rsp); - if (this->on_connect_) { - this->on_connect_(rsp); - } - rsp->start_session_main_loop_(); - } + this->adopt_pending_sessions_main_loop_(); } // Clean up dead sessions safely @@ -535,6 +518,27 @@ bool AsyncEventSource::loop() { return !this->sessions_.empty(); } +void AsyncEventSource::adopt_pending_sessions_main_loop_() { + std::vector incoming; + { + LockGuard guard{this->pending_mutex_}; + incoming.swap(this->pending_sessions_); + this->has_pending_sessions_.store(false, std::memory_order_relaxed); + } + for (auto *rsp : incoming) { + // Already disconnected? Drop it; skip on_connect_/session start on a dead session. + if (rsp->fd_.load() == 0) { + delete rsp; // NOLINT(cppcoreguidelines-owning-memory) + continue; + } + this->sessions_.push_back(rsp); + if (this->on_connect_) { + this->on_connect_(rsp); + } + rsp->start_session_main_loop_(); + } +} + void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) { for (auto *ses : this->sessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 89e1c5fada..742a544695 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -350,6 +350,9 @@ class AsyncEventSource : public AsyncWebHandler { size_t count() const { return this->sessions_.size(); } protected: + // Cold path: move sessions from pending_sessions_ into sessions_ and greet each one. + void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_(); + std::string url_; // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; From 3507d567cc539edc84062b4efa943e4f7e3ce1ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:10:17 -0500 Subject: [PATCH 0549/2214] [web_server_idf] Preserve on_connect_ ordering and document main-loop context Pre-refactor, on_connect_ ran after the ctor had already sent the initial ping/config/sorting_groups. Call start_session_main_loop_() before on_connect_ so the callback still observes a primed session. Also document at onConnect() that the callback now runs on the main loop instead of the httpd task. --- esphome/components/web_server_idf/web_server_idf.cpp | 4 +++- esphome/components/web_server_idf/web_server_idf.h | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 22a8094424..b43196ec48 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -532,10 +532,12 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { continue; } this->sessions_.push_back(rsp); + // Prime first so on_connect_ observes a session that has already sent its + // initial ping/config/sorting_groups, matching the pre-refactor ordering. + rsp->start_session_main_loop_(); if (this->on_connect_) { this->on_connect_(rsp); } - rsp->start_session_main_loop_(); } } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 742a544695..579383b50d 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -339,6 +339,8 @@ class AsyncEventSource : public AsyncWebHandler { // NOLINTNEXTLINE(readability-identifier-naming) void handleRequest(AsyncWebServerRequest *request) override; // NOLINTNEXTLINE(readability-identifier-naming) + // Callback runs on the main loop (not the httpd task) after the session's + // initial ping/config/sorting_groups have been sent. void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); From b46ccb5ae0eb90766ce78ffb35b95993526950b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:16:42 -0500 Subject: [PATCH 0550/2214] [web_server_idf] Move NOLINTNEXTLINE adjacent to onConnect declaration --- esphome/components/web_server_idf/web_server_idf.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 579383b50d..cdb58c2f04 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -338,9 +338,9 @@ class AsyncEventSource : public AsyncWebHandler { } // NOLINTNEXTLINE(readability-identifier-naming) void handleRequest(AsyncWebServerRequest *request) override; - // NOLINTNEXTLINE(readability-identifier-naming) // Callback runs on the main loop (not the httpd task) after the session's // initial ping/config/sorting_groups have been sent. + // NOLINTNEXTLINE(readability-identifier-naming) void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); From 4fca594871afe0dae050449fa2321c4c4023c9ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:18:58 -0500 Subject: [PATCH 0551/2214] [web_server_idf] Drop redundant 'Removing dead event source session' log destroy() already logs the close with the fd on the httpd task, so the follow-up log when the main loop reaps the session adds no information and doubles log volume during reconnect storms. --- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index b43196ec48..77b1e745ec 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -505,7 +505,7 @@ bool AsyncEventSource::loop() { auto *ses = this->sessions_[i]; // If the session has a dead socket (marked by destroy callback) if (ses->fd_.load() == 0) { - ESP_LOGD(TAG, "Removing dead event source session"); + // destroy() already logged the close with the fd; don't double-log here. delete ses; // NOLINT(cppcoreguidelines-owning-memory) // Remove by swapping with last element (O(1) removal, order doesn't matter for sessions) this->sessions_[i] = this->sessions_.back(); From da5c31cb2729d5fe548959c27e2789b93c7edd03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:26:21 -0500 Subject: [PATCH 0552/2214] [core] Scheduler::next_schedule_in: return 0 when defer queue is non-empty On multi-thread platforms (ESP32/LibreTiny/host), defer() items go directly into a separate defer_queue_ instead of the main items_ heap (scheduler.cpp:200). next_schedule_in previously only looked at items_[0], so a pending defer posted from a background task was invisible to the loop's sleep-duration calculation: if items_[0] fired seconds away, the main loop would sleep that long on ulTaskNotifyTake while the defer sat unexecuted. In practice this rarely bit anyone because concurrent socket activity usually woke the loop via the lwip fast-select path, but a defer with no accompanying network traffic could stall for seconds. Check defer_empty_() (cheap atomic/volatile load, scheduler.h:594) before the items_ check and return 0 if pending. process_defer_queue_ runs at the top of every scheduler.call(), so "defer pending" == "run immediately." Single-threaded platforms funnel defers into to_add_ like any other timeout, so the fix is gated on !ESPHOME_THREAD_SINGLE. --- esphome/core/scheduler.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a6f1558e4a..3e576ecdcc 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -417,6 +417,14 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { // It performs cleanup and accesses items_[0] without holding a lock, which is only // safe when called from the main thread. Other threads must not call this method. +#ifndef ESPHOME_THREAD_SINGLE + // defer() items live in a separate queue that is drained at the top of every + // loop tick via process_defer_queue_(). If any are pending, the next loop + // iteration has work to do right now -- don't let the caller sleep. + if (!this->defer_empty_()) + return 0; +#endif + // If no items, return empty optional if (!this->cleanup_()) return {}; From b0ac9477df2a92044cf08a08a709c4549270cbd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:28:24 -0500 Subject: [PATCH 0553/2214] [web_server_idf] Widen NewDeleteLeaks NOLINT over start_session_main_loop_ The previous narrow NOLINT only wrapped the sorting_groups JSON loop. Now that the function is invoked through an extra call layer (loop() -> adopt_pending_sessions_main_loop_ -> start_session_main_loop_), clang-analyzer traces a leak path through ws->get_config_json() as well -- also a false positive inside ArduinoJson's VariantData. Move the NOLINTBEGIN/NOLINTEND to bracket the entire function body. --- esphome/components/web_server_idf/web_server_idf.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 77b1e745ec..5abb9db627 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -589,6 +589,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * httpd_sess_set_send_override(this->hd_, this->fd_.load(), nonblocking_send); } +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson void AsyncEventSourceResponse::start_session_main_loop_() { auto *ws = this->web_server_; @@ -598,13 +599,11 @@ void AsyncEventSourceResponse::start_session_main_loop_() { #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson json::JsonBuilder builder; JsonObject root = builder.root(); root["name"] = group.second.name; root["sorting_weight"] = group.second.weight; message = builder.serialize(); - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) // a (very) large number of these should be able to be queued initially without defer // since the only thing in the send buffer at this point is the initial ping/config @@ -614,6 +613,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { this->entities_iterator_.begin(ws->include_internal_); } +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) void AsyncEventSourceResponse::destroy(void *ptr) { auto *rsp = static_cast(ptr); From d507feaf8106fe8ead595d97a84bb05c60d0df85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:37:11 -0500 Subject: [PATCH 0554/2214] [core] Scheduler::next_schedule_in: also handle single-threaded defer path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On ESPHOME_THREAD_SINGLE builds (ESP8266, RP2040), defer() does not route through defer_queue_ — set_timer_common_ treats it as an ordinary 0-delay set_timeout that stages in to_add_ (scheduler.cpp:197). Since next_schedule_in only inspects items_[0], any defer posted from a component's loop() sits in to_add_ invisible to the sleep calculation until the next scheduler.call() runs process_to_add(). The symmetric fix checks to_add_empty_() on single-threaded and short-circuits sleep the same way. This also costs one "skipped sleep" for any non-zero-delay timer added at runtime, but the next tick produces a correct next_schedule_in reading and the cost is a single yield — negligible vs. the stall this closes. Suggested by Copilot review on #15968. --- esphome/core/scheduler.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 3e576ecdcc..fc891fac6f 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -423,6 +423,12 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { // iteration has work to do right now -- don't let the caller sleep. if (!this->defer_empty_()) return 0; +#else + // On single-threaded builds, defer() routes through set_timeout(..., 0) which + // stages in to_add_. process_to_add() runs at the top of every scheduler.call(), + // so anything in to_add_ becomes runnable on the next iteration; don't sleep. + if (!this->to_add_empty_()) + return 0; #endif // If no items, return empty optional From 8bd8be2ac2c182c1ca01fc46cd6e080b8b7505a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:41:48 -0500 Subject: [PATCH 0555/2214] [web_server_idf] Add NOLINT at call site for cross-function analyzer path The function-level NOLINTBEGIN/NOLINTEND on start_session_main_loop_() only covers bug paths whose primary or note locations fall inside that function. clang-analyzer now traces the leak through the caller chain (AsyncEventSource::loop -> adopt_pending_sessions_main_loop_ -> start_session_main_loop_ -> ArduinoJson), with notes at lines 497/498 and 530/537 that sit outside the wrapped range. Add a NOLINTNEXTLINE at the call site in adopt_pending_sessions_main_loop_ so the caller's bug-path location is covered too. --- esphome/components/web_server_idf/web_server_idf.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 5abb9db627..a88419a84b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -534,6 +534,7 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { this->sessions_.push_back(rsp); // Prime first so on_connect_ observes a session that has already sent its // initial ping/config/sorting_groups, matching the pre-refactor ordering. + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson rsp->start_session_main_loop_(); if (this->on_connect_) { this->on_connect_(rsp); From ae02ab38656f484e55468302015016a9a59440a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:42:36 -0500 Subject: [PATCH 0556/2214] [wifi] Fix stale wifi.connected after state transition (#15966) --- esphome/components/wifi/wifi_component.cpp | 2 ++ esphome/components/wifi/wifi_component_esp8266.cpp | 2 ++ esphome/components/wifi/wifi_component_esp_idf.cpp | 2 ++ esphome/components/wifi/wifi_component_libretiny.cpp | 2 ++ esphome/components/wifi/wifi_component_pico_w.cpp | 2 ++ 5 files changed, 10 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 481846085c..f7c70b1147 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1579,6 +1579,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { #endif this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; + // Refresh is_connected() cache; loop()'s refresh ran before this transition. + this->update_connected_state_(); this->num_retried_ = 0; this->print_connect_params_(); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index e56a8df350..bf3a0d2949 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -951,6 +951,8 @@ void WiFiComponent::process_pending_callbacks_() { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS if (this->pending_.disconnect) { this->pending_.disconnect = false; + // Refresh is_connected() cache here, not in the SDK callback (sys context). + this->update_connected_state_(); this->notify_disconnect_state_listeners_(); } #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index c790742c79..29d135ce90 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -804,6 +804,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { s_sta_connected = false; s_sta_connecting = false; error_from_callback_ = true; + // Refresh is_connected() cache; error_from_callback_ makes it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 6588e93e16..59efa4f842 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -530,6 +530,8 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { this->error_from_callback_ = true; } + // Refresh is_connected() cache; sta_state_/error_from_callback_ make it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 4e1e0395c0..596fd2729b 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -342,6 +342,8 @@ bool WiFiComponent::wifi_loop_() { s_sta_was_connected = false; s_sta_had_ip = false; ESP_LOGV(TAG, "Disconnected"); + // Refresh is_connected() cache; driver link status reports disconnected. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif From bc7f35b569c0dcec0364f7bf5f53fa19857ce572 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 06:00:22 -0400 Subject: [PATCH 0557/2214] [sendspin] Add a Sendspin media source component for playing audio (PR4) (#15950) Co-authored-by: Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/sendspin/__init__.py | 78 ++++++- .../sendspin/media_source/__init__.py | 134 ++++++++++++ .../sendspin/media_source/automations.h | 26 +++ .../media_source/sendspin_media_source.cpp | 207 ++++++++++++++++++ .../media_source/sendspin_media_source.h | 72 ++++++ esphome/components/sendspin/sendspin_hub.cpp | 40 ++++ esphome/components/sendspin/sendspin_hub.h | 28 +++ .../sendspin/common-media_source.yaml | 9 + .../sendspin/test-media_source.esp32-idf.yaml | 1 + 10 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/media_source/__init__.py create mode 100644 esphome/components/sendspin/media_source/automations.h create mode 100644 esphome/components/sendspin/media_source/sendspin_media_source.cpp create mode 100644 esphome/components/sendspin/media_source/sendspin_media_source.h create mode 100644 tests/components/sendspin/common-media_source.yaml create mode 100644 tests/components/sendspin/test-media_source.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 65db6ca25e..822b0e973c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -442,6 +442,7 @@ esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt +esphome/components/sendspin/media_source/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 2d05390378..6f5ccddb86 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -4,7 +4,12 @@ from esphome import automation import esphome.codegen as cg from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM +from esphome.const import ( + CONF_BUFFER_SIZE, + CONF_ID, + CONF_SAMPLE_RATE, + CONF_TASK_STACK_IN_PSRAM, +) from esphome.core import CORE, ID from esphome.cpp_generator import TemplateArgsType from esphome.types import ConfigType @@ -17,6 +22,23 @@ DOMAIN = "sendspin" CONF_SENDSPIN_ID = "sendspin_id" +CONF_INITIAL_STATIC_DELAY = "initial_static_delay" +CONF_FIXED_DELAY = "fixed_delay" + +# sendspin-cpp library lives in the global `sendspin` namespace. +sendspin_library_ns = cg.global_ns.namespace("sendspin") + +# Library Enums +SendspinCodecFormat = sendspin_library_ns.enum("SendspinCodecFormat", is_class=True) +CODEC_FORMAT_FLAC = SendspinCodecFormat.enum("FLAC") +CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") +CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") +CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") + +# Library Structs +AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject") +PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig") + # Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace. # Analysis tools strip the trailing underscore (same pattern as `template_`). sendspin_ns = cg.esphome_ns.namespace("sendspin_") @@ -41,6 +63,8 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False + player_config: ConfigType | None = None + def _get_data() -> SendspinConfiguration: if DOMAIN not in CORE.data: @@ -73,6 +97,17 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def register_player_config(config: ConfigType) -> None: + """Register the player role config from the media source subcomponent.""" + data = _get_data() + request_player_support() + if data.player_config is not None: + raise cv.Invalid( + "Only one sendspin media_source player configuration is supported" + ) + data.player_config = config + + def _validate_task_stack_in_psram(value): value = cv.boolean(value) if value: @@ -183,6 +218,47 @@ async def to_code(config: ConfigType) -> None: if data.player_support: cg.add_define("USE_SENDSPIN_PLAYER", True) + + # Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate + # (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server + player_cfg = data.player_config + sample_rate = player_cfg[CONF_SAMPLE_RATE] + + # OPUS only supports 48 kHz audio + codecs = [CODEC_FORMAT_FLAC] + if sample_rate == 48000: + codecs.append(CODEC_FORMAT_OPUS) + codecs.append(CODEC_FORMAT_PCM) + + def _audio_format(codec, channels): + return cg.StructInitializer( + AudioSupportedFormatObject, + ("codec", codec), + ("channels", channels), + ("sample_rate", sample_rate), + ("bit_depth", 16), + ) + + audio_format_structs = [ + _audio_format(codec, channels) for codec in codecs for channels in (2, 1) + ] + + psram_stack = player_cfg.get(CONF_TASK_STACK_IN_PSRAM, False) + if psram_stack: + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) + + player_config_struct = cg.StructInitializer( + PlayerRoleConfig, + ("audio_formats", audio_format_structs), + ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), + ("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]), + ("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), + ("psram_stack", psram_stack), + ("priority", 2), + ) + cg.add(var.set_player_config(player_config_struct)) else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False) diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py new file mode 100644 index 0000000000..6d61a8a636 --- /dev/null +++ b/esphome/components/sendspin/media_source/__init__.py @@ -0,0 +1,134 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import media_source +import esphome.config_validation as cv +from esphome.const import ( + CONF_BUFFER_SIZE, + CONF_ID, + CONF_SAMPLE_RATE, + CONF_TASK_STACK_IN_PSRAM, +) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType + +from .. import ( + CONF_FIXED_DELAY, + CONF_INITIAL_STATIC_DELAY, + CONF_SENDSPIN_ID, + SendspinHub, + _validate_task_stack_in_psram, + register_player_config, + request_controller_support, + sendspin_ns, +) + +AUTO_LOAD = ["audio"] +CODEOWNERS = ["@kahrendt"] + +CONF_STATIC_DELAY_ADJUSTABLE = "static_delay_adjustable" + + +SendspinMediaSource = sendspin_ns.class_( + "SendspinMediaSource", + cg.Component, + media_source.MediaSource, +) + +EnableStaticDelayAdjustmentAction = sendspin_ns.class_( + "EnableStaticDelayAdjustmentAction", + automation.Action, + cg.Parented.template(SendspinMediaSource), +) + +DisableStaticDelayAdjustmentAction = sendspin_ns.class_( + "DisableStaticDelayAdjustmentAction", + automation.Action, + cg.Parented.template(SendspinMediaSource), +) + + +def _register(config: ConfigType) -> ConfigType: + request_controller_support() + register_player_config( + { + CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], + CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], + CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], + CONF_FIXED_DELAY: config[CONF_FIXED_DELAY], + CONF_TASK_STACK_IN_PSRAM: config.get(CONF_TASK_STACK_IN_PSRAM, False), + } + ) + return config + + +CONFIG_SCHEMA = cv.All( + media_source.media_source_schema( + SendspinMediaSource, + ).extend( + { + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(min=25000), + cv.Optional(CONF_INITIAL_STATIC_DELAY, default="0ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(milliseconds=5000)), + ), + cv.Optional(CONF_STATIC_DELAY_ADJUSTABLE, default=False): cv.boolean, + cv.Optional(CONF_FIXED_DELAY, default="0us"): cv.All( + cv.positive_time_period_microseconds, + cv.Range(max=cv.TimePeriod(microseconds=10000)), + ), + cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.int_range( + min=16000, max=96000 + ), + } + ), + cv.only_on_esp32, + _register, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await media_source.register_media_source(var, config) + + sendspin_hub = await cg.get_variable(config[CONF_SENDSPIN_ID]) + await cg.register_parented(var, sendspin_hub) + + cg.add(sendspin_hub.set_listener(var)) + + cg.add(var.set_static_delay_adjustable(config[CONF_STATIC_DELAY_ADJUSTABLE])) + + +SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinMediaSource), + } + ) +) + + +@automation.register_action( + "sendspin.media_source.enable_static_delay_adjustment", + EnableStaticDelayAdjustmentAction, + SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "sendspin.media_source.disable_static_delay_adjustment", + DisableStaticDelayAdjustmentAction, + SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, + synchronous=True, +) +async def sendspin_static_delay_adjustment_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/sendspin/media_source/automations.h b/esphome/components/sendspin/media_source/automations.h new file mode 100644 index 0000000000..08d2b2004b --- /dev/null +++ b/esphome/components/sendspin/media_source/automations.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) + +#include "esphome/core/automation.h" +#include "sendspin_media_source.h" + +namespace esphome::sendspin_ { + +template +class EnableStaticDelayAdjustmentAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } +}; + +template +class DisableStaticDelayAdjustmentAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp new file mode 100644 index 0000000000..0fdfb01c55 --- /dev/null +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -0,0 +1,207 @@ +#include "sendspin_media_source.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) + +#include "esphome/components/audio/audio.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.media_source"; + +static constexpr char URI_PREFIX[] = "sendspin://"; + +void SendspinMediaSource::setup() { + this->player_role_ = this->parent_->get_player_role(); + if (!this->player_role_) { + ESP_LOGE(TAG, "Failed to get player role from hub"); + this->mark_failed(); + return; + } + + // Push cached states to player role. They may have been set before setup() ran. + this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f)); + this->player_role_->update_muted(this->cached_muted_); + this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_); +} + +void SendspinMediaSource::dump_config() { + ESP_LOGCONFIG(TAG, "Sendspin Media Source: static_delay_adjustable=%s", YESNO(this->static_delay_adjustable_)); +} + +// THREAD CONTEXT: Main loop (invoked from ESPHome actions / config) +void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) { + this->static_delay_adjustable_ = adjustable; + if (this->player_role_) { + this->player_role_->set_static_delay_adjustable(adjustable); + } +} + +// --- MediaSource interface --- + +bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); } + +// THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only) +bool SendspinMediaSource::play_uri(const std::string &uri) { + if (!this->is_ready() || this->is_failed() || !this->has_listener()) { + return false; + } + + if (this->get_state() != media_source::MediaSourceState::IDLE) { + ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str()); + return false; + } + + if (!uri.starts_with(URI_PREFIX)) { + ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); + return false; + } + + std::string sendspin_id = uri.substr(sizeof(URI_PREFIX) - 1); + + if (sendspin_id.empty()) { + ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); + return false; + } + + ESP_LOGD(TAG, "sendspin_id: %s", sendspin_id.c_str()); + + if (sendspin_id != "current") { + // Connect to a new server as a websocket client + this->parent_->connect_to_server("ws://" + sendspin_id); + } + + // Tell the orchestrator we're now playing so it routes audio output from us + this->pending_start_ = false; + this->set_state_(media_source::MediaSourceState::PLAYING); + + return true; +} + +// THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only) +void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) { + switch (command) { + case media_source::MediaSourceCommand::STOP: { + if (!this->pending_start_) { + // Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before + // play_uri + ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE"); + this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE); + } + break; + } + case media_source::MediaSourceCommand::PLAY: // NOLINT(bugprone-branch-clone) + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::PAUSE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::NEXT: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::PREVIOUS: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::REPEAT_ALL: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::REPEAT_ONE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::REPEAT_OFF: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::SHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::UNSHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE, std::nullopt, std::nullopt); + break; + default: + break; + } +} + +// THREAD CONTEXT: Main loop (orchestrator -> source notification) +void SendspinMediaSource::notify_volume_changed(float volume) { + this->cached_volume_ = volume; + if (this->player_role_) { + this->player_role_->update_volume(std::roundf(volume * 100.0f)); + } +} + +// THREAD CONTEXT: Main loop (orchestrator -> source notification) +void SendspinMediaSource::notify_mute_changed(bool is_muted) { + this->cached_muted_ = is_muted; + if (this->player_role_) { + this->player_role_->update_muted(is_muted); + } +} + +// THREAD CONTEXT: Speaker playback callback thread (forwarded from the speaker). +// PlayerRole::notify_audio_played() is documented as thread-safe for this use. +void SendspinMediaSource::notify_audio_played(uint32_t frames, int64_t timestamp) { + if (this->player_role_) { + this->player_role_->notify_audio_played(frames, timestamp); + } +} + +// --- Sendspin PlayerRoleListener overrides --- + +// THREAD CONTEXT: Sendspin sync task background thread. May block up to timeout_ms. +size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) { + if (!this->has_listener() || (this->get_state() != media_source::MediaSourceState::PLAYING)) { + vTaskDelay(pdMS_TO_TICKS(timeout_ms)); + return 0; + } + + // PlayerRole::get_current_stream_params() is safe to call from the sync task. + auto ¶ms = this->player_role_->get_current_stream_params(); + if (!params.bit_depth.has_value() || !params.channels.has_value() || !params.sample_rate.has_value()) { + vTaskDelay(pdMS_TO_TICKS(timeout_ms)); + return 0; + } + audio::AudioStreamInfo stream_info(*params.bit_depth, *params.channels, *params.sample_rate); + + return this->write_output(data, length, timeout_ms, stream_info); +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) +void SendspinMediaSource::on_stream_start() { + this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); + + if (!this->pending_start_) { + // Dedup rapid on_stream_start() calls + this->pending_start_ = true; + // Request the orchestrator to start this source + this->request_play_uri_("sendspin://current"); + } +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) +void SendspinMediaSource::on_stream_end() { + if (this->get_state() != media_source::MediaSourceState::IDLE) { + // Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes + this->set_state_(media_source::MediaSourceState::IDLE); + } +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) +void SendspinMediaSource::on_stream_clear() { + if (this->get_state() != media_source::MediaSourceState::IDLE) { + // Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes + this->set_state_(media_source::MediaSourceState::IDLE); + } +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener callback) +void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); } + +// THREAD CONTEXT: Main loop (PlayerRoleListener callback) +void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(muted); } + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h new file mode 100644 index 0000000000..3b31716127 --- /dev/null +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) + +#include "esphome/components/sendspin/sendspin_hub.h" + +#include "esphome/components/media_source/media_source.h" + +#include + +namespace esphome::sendspin_ { + +/// @brief Thin adapter media source for Sendspin. +/// +/// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's +/// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards +/// transport commands to the hub's controller role. +class SendspinMediaSource : public SendspinChild, + public media_source::MediaSource, + public sendspin::PlayerRoleListener { + public: + void setup() override; + void dump_config() override; + + void set_static_delay_adjustable(bool adjustable); + + // MediaSource interface implementation + bool play_uri(const std::string &uri) override; + void handle_command(media_source::MediaSourceCommand command) override; + bool can_handle(const std::string &uri) const override; + bool has_internal_playlist() const override { return true; } + + void notify_volume_changed(float volume) override; + void notify_mute_changed(bool is_muted) override; + void notify_audio_played(uint32_t frames, int64_t timestamp) override; + + protected: + // --- Sendspin PlayerRoleListener overrides --- + + /// @brief Writes decoded PCM audio to ESPHome's media source output pipeline. + /// Called from the sync task's background thread. + size_t on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) override; + + /// @brief Called when a new audio stream starts (main loop thread). + void on_stream_start() override; + + /// @brief Called when the audio stream ends (main loop thread). + void on_stream_end() override; + + /// @brief Called when the audio stream is cleared (main loop thread). + void on_stream_clear() override; + + /// @brief Called when volume changes (main loop thread). + void on_volume_changed(uint8_t volume) override; + + /// @brief Called when mute state changes (main loop thread). + void on_mute_changed(bool muted) override; + + sendspin::PlayerRole *player_role_{nullptr}; + + float cached_volume_{0.0f}; + + bool cached_muted_{false}; + bool pending_start_{false}; + bool static_delay_adjustable_{false}; +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index ec419f7741..25e541a493 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -25,6 +25,9 @@ void SendspinHub::setup() { // Set up persistence (preferences must be initialized before providers are added to the client) this->last_played_server_pref_ = global_preferences->make_preference(fnv1a_hash("sendspin_last_played")); +#ifdef USE_SENDSPIN_PLAYER + this->static_delay_pref_ = global_preferences->make_preference(fnv1a_hash("sendspin_static_delay")); +#endif // Wire providers and client listener this->client_->set_listener(this); @@ -36,6 +39,10 @@ void SendspinHub::setup() { this->controller_role_->set_listener(this); #endif +#ifdef USE_SENDSPIN_PLAYER + this->client_->add_player(this->player_config_).set_listener(this->player_listener_); +#endif + if (!this->client_->start_server()) { ESP_LOGE(TAG, "Failed to start Sendspin server"); this->mark_failed(); @@ -160,6 +167,39 @@ void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObjec } #endif +#ifdef USE_SENDSPIN_PLAYER +// THREAD CONTEXT: Main loop, called from child component setup() after player role is created and configured +sendspin::PlayerRole *SendspinHub::get_player_role() { + if (this->is_ready()) { + return this->client_->player(); + } + return nullptr; +} + +// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) +bool SendspinHub::save_static_delay(uint16_t delay_ms) { + StaticDelayPref pref{.delay_ms = delay_ms}; + bool ok = this->static_delay_pref_.save(&pref); + if (ok) { + ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms); + } else { + ESP_LOGW(TAG, "Failed to persist static delay"); + } + return ok; +} + +// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) +std::optional SendspinHub::load_static_delay() { + StaticDelayPref pref{}; + if (this->static_delay_pref_.load(&pref)) { + ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms); + return pref.delay_ms; + } + return std::nullopt; +} + +#endif + } // namespace esphome::sendspin_ #endif // USE_ESP32 diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 1e217e0ea2..c9266bd4d1 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -16,6 +16,9 @@ #ifdef USE_SENDSPIN_CONTROLLER #include #endif +#ifdef USE_SENDSPIN_PLAYER +#include +#endif #include #include @@ -38,6 +41,13 @@ struct LastPlayedServerPref { uint32_t server_id_hash; }; +#ifdef USE_SENDSPIN_PLAYER +/// @brief Persistent storage structure for player static delay. +struct StaticDelayPref { + uint16_t delay_ms; +}; +#endif + /// @brief Thin adapter over sendspin::SendspinClient. /// /// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for @@ -112,6 +122,14 @@ class SendspinHub final : public Component, } #endif +#ifdef USE_SENDSPIN_PLAYER + void set_listener(sendspin::PlayerRoleListener *listener) { this->player_listener_ = listener; } + void set_player_config(const sendspin::PlayerRoleConfig &config) { this->player_config_ = config; } + + /// @brief Child components call this to get the PlayerRole instance after setup, so they can push updates to it. + sendspin::PlayerRole *get_player_role(); +#endif + protected: /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); @@ -141,6 +159,16 @@ class SendspinHub final : public Component, CallbackManager controller_state_callbacks_{}; #endif +#ifdef USE_SENDSPIN_PLAYER + sendspin::PlayerRoleListener *player_listener_{nullptr}; + sendspin::PlayerRoleConfig player_config_{}; + + // Part of SendspinPersistenceProvider overrides + ESPPreferenceObject static_delay_pref_; + std::optional load_static_delay() override; + bool save_static_delay(uint16_t delay_ms) override; +#endif + // --- Core member variables --- ESPPreferenceObject last_played_server_pref_; diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml new file mode 100644 index 0000000000..4a7cd79c67 --- /dev/null +++ b/tests/components/sendspin/common-media_source.yaml @@ -0,0 +1,9 @@ +<<: !include common.yaml + +media_source: + - platform: sendspin + id: media_source_id + buffer_size: 500000 + initial_static_delay: 5ms + static_delay_adjustable: true + fixed_delay: 480us diff --git a/tests/components/sendspin/test-media_source.esp32-idf.yaml b/tests/components/sendspin/test-media_source.esp32-idf.yaml new file mode 100644 index 0000000000..47aeb2257c --- /dev/null +++ b/tests/components/sendspin/test-media_source.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-media_source.yaml From ac7f0f0b74549d4add4f97cf53186f289b01cbe9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 07:07:00 -0400 Subject: [PATCH 0558/2214] [sendspin] Add a metadata text sensor component (#15969) --- CODEOWNERS | 1 + esphome/components/sendspin/sendspin_hub.cpp | 11 +++ esphome/components/sendspin/sendspin_hub.h | 19 +++++ .../sendspin/text_sensor/__init__.py | 55 ++++++++++++ .../text_sensor/sendspin_text_sensor.cpp | 85 +++++++++++++++++++ .../text_sensor/sendspin_text_sensor.h | 35 ++++++++ .../sendspin/common-text_sensor.yaml | 21 +++++ .../sendspin/test-text_sensor.esp32-idf.yaml | 1 + 8 files changed, 228 insertions(+) create mode 100644 esphome/components/sendspin/text_sensor/__init__.py create mode 100644 esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp create mode 100644 esphome/components/sendspin/text_sensor/sendspin_text_sensor.h create mode 100644 tests/components/sendspin/common-text_sensor.yaml create mode 100644 tests/components/sendspin/test-text_sensor.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 822b0e973c..f4b288b23d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -443,6 +443,7 @@ esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt +esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 25e541a493..da298feb86 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -39,6 +39,10 @@ void SendspinHub::setup() { this->controller_role_->set_listener(this); #endif +#ifdef USE_SENDSPIN_METADATA + this->client_->add_metadata().set_listener(this); +#endif + #ifdef USE_SENDSPIN_PLAYER this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif @@ -167,6 +171,13 @@ void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObjec } #endif +#ifdef USE_SENDSPIN_METADATA +// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop()) +void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadata) { + this->metadata_update_callbacks_.call(metadata); +} +#endif + #ifdef USE_SENDSPIN_PLAYER // THREAD CONTEXT: Main loop, called from child component setup() after player role is created and configured sendspin::PlayerRole *SendspinHub::get_player_role() { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c9266bd4d1..8d9c58a3ab 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -16,6 +16,9 @@ #ifdef USE_SENDSPIN_CONTROLLER #include #endif +#ifdef USE_SENDSPIN_METADATA +#include +#endif #ifdef USE_SENDSPIN_PLAYER #include #endif @@ -66,6 +69,9 @@ struct StaticDelayPref { class SendspinHub final : public Component, #ifdef USE_SENDSPIN_CONTROLLER public sendspin::ControllerRoleListener, +#endif +#ifdef USE_SENDSPIN_METADATA + public sendspin::MetadataRoleListener, #endif public sendspin::SendspinClientListener, public sendspin::SendspinNetworkProvider, @@ -122,6 +128,12 @@ class SendspinHub final : public Component, } #endif +#ifdef USE_SENDSPIN_METADATA + template void add_metadata_update_callback(F &&callback) { + this->metadata_update_callbacks_.add(std::forward(callback)); + } +#endif + #ifdef USE_SENDSPIN_PLAYER void set_listener(sendspin::PlayerRoleListener *listener) { this->player_listener_ = listener; } void set_player_config(const sendspin::PlayerRoleConfig &config) { this->player_config_ = config; } @@ -159,6 +171,13 @@ class SendspinHub final : public Component, CallbackManager controller_state_callbacks_{}; #endif +#ifdef USE_SENDSPIN_METADATA + void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; + + // Callback fan-out to child components; they filter as needed + CallbackManager metadata_update_callbacks_{}; +#endif + #ifdef USE_SENDSPIN_PLAYER sendspin::PlayerRoleListener *player_listener_{nullptr}; sendspin::PlayerRoleConfig player_config_{}; diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py new file mode 100644 index 0000000000..b7f216ca0c --- /dev/null +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TYPE +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinTextSensor = sendspin_ns.class_( + "SendspinTextSensor", + text_sensor.TextSensor, + cg.Component, +) + +SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) +SENDSPIN_TEXT_METADATA_TYPES = { + "title": SendspinTextMetadataTypes.TITLE, + "artist": SendspinTextMetadataTypes.ARTIST, + "album": SendspinTextMetadataTypes.ALBUM, + "album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST, + "year": SendspinTextMetadataTypes.YEAR, + "track": SendspinTextMetadataTypes.TRACK, +} + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the text sensor.""" + request_metadata_support() + + return config + + +CONFIG_SCHEMA = cv.All( + text_sensor.text_sensor_schema().extend( + { + cv.GenerateID(): cv.declare_id(SendspinTextSensor), + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES), + } + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await text_sensor.register_text_sensor(var, config) + + cg.add(var.set_metadata_type(config[CONF_TYPE])) diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp new file mode 100644 index 0000000000..d16d51f63c --- /dev/null +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -0,0 +1,85 @@ +#include "sendspin_text_sensor.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) + +#include "esphome/core/helpers.h" + +#include + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.text_sensor"; + +void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTextSensor::setup() { + switch (this->metadata_type_) { + case SendspinTextMetadataTypes::TITLE: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.title.has_value()) { + this->publish_if_changed_(metadata.title.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::ARTIST: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.artist.has_value()) { + this->publish_if_changed_(metadata.artist.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::ALBUM: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.album.has_value()) { + this->publish_if_changed_(metadata.album.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::ALBUM_ARTIST: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.album_artist.has_value()) { + this->publish_if_changed_(metadata.album_artist.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::YEAR: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.year.has_value() && metadata.year.value() <= 9999) { + char buf[UINT32_MAX_STR_SIZE]; + uint32_to_str(buf, metadata.year.value()); + this->publish_if_changed_(buf); + } + }); + break; + } + case SendspinTextMetadataTypes::TRACK: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.track.has_value() && metadata.track.value() <= 9999) { + char buf[UINT32_MAX_STR_SIZE]; + uint32_to_str(buf, metadata.track.value()); + this->publish_if_changed_(buf); + } + }); + break; + } + } +} + +// Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. +void SendspinTextSensor::publish_if_changed_(const char *value) { + if (this->get_raw_state() != value) { + this->publish_state(value); + } +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h new file mode 100644 index 0000000000..d9ef49c938 --- /dev/null +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::sendspin_ { + +enum class SendspinTextMetadataTypes { + TITLE, + ARTIST, + ALBUM, + ALBUM_ARTIST, + YEAR, + TRACK, +}; + +class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor { + public: + void dump_config() override; + void setup() override; + + void set_metadata_type(SendspinTextMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } + + protected: + void publish_if_changed_(const char *value); + + SendspinTextMetadataTypes metadata_type_; +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml new file mode 100644 index 0000000000..0bfbf45757 --- /dev/null +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -0,0 +1,21 @@ +<<: !include common.yaml + +text_sensor: + - platform: sendspin + name: "Title" + type: title + - platform: sendspin + name: "Artist" + type: artist + - platform: sendspin + name: "Album" + type: album + - platform: sendspin + name: "Album Artist" + type: album_artist + - platform: sendspin + name: "Year" + type: year + - platform: sendspin + name: "Track Number" + type: track diff --git a/tests/components/sendspin/test-text_sensor.esp32-idf.yaml b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml new file mode 100644 index 0000000000..8998b8896e --- /dev/null +++ b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-text_sensor.yaml From 42f0b1a7e229564f9ba53cfcf93f2ec2cc89f995 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:21:29 -0400 Subject: [PATCH 0559/2214] add Sendspin sensor component --- CODEOWNERS | 1 + esphome/components/sendspin/sendspin_hub.cpp | 11 +++- esphome/components/sendspin/sendspin_hub.h | 5 ++ .../components/sendspin/sensor/__init__.py | 62 +++++++++++++++++++ .../sendspin/sensor/sendspin_sensor.cpp | 62 +++++++++++++++++++ .../sendspin/sensor/sendspin_sensor.h | 31 ++++++++++ tests/components/sendspin/common-sensor.yaml | 9 +++ .../sendspin/test-sensor.esp32-idf.yaml | 1 + 8 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/sensor/__init__.py create mode 100644 esphome/components/sendspin/sensor/sendspin_sensor.cpp create mode 100644 esphome/components/sendspin/sensor/sendspin_sensor.h create mode 100644 tests/components/sendspin/common-sensor.yaml create mode 100644 tests/components/sendspin/test-sensor.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f4b288b23d..20c19a7dfa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -443,6 +443,7 @@ esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt +esphome/components/sendspin/sensor/* @kahrendt esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index da298feb86..c2fcd520ba 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -40,7 +40,8 @@ void SendspinHub::setup() { #endif #ifdef USE_SENDSPIN_METADATA - this->client_->add_metadata().set_listener(this); + this->metadata_role_ = &this->client_->add_metadata(); + this->metadata_role_->set_listener(this); #endif #ifdef USE_SENDSPIN_PLAYER @@ -176,6 +177,14 @@ void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObjec void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadata) { this->metadata_update_callbacks_.call(metadata); } + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +uint32_t SendspinHub::get_track_progress_ms() { + if (this->is_ready()) { + return this->metadata_role_->get_track_progress_ms(); + } + return 0; +} #endif #ifdef USE_SENDSPIN_PLAYER diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 8d9c58a3ab..79b7018645 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -132,6 +132,9 @@ class SendspinHub final : public Component, template void add_metadata_update_callback(F &&callback) { this->metadata_update_callbacks_.add(std::forward(callback)); } + + /// @brief Returns the interpolated track progress in milliseconds, or 0 if the hub is not yet ready. + uint32_t get_track_progress_ms(); #endif #ifdef USE_SENDSPIN_PLAYER @@ -172,6 +175,8 @@ class SendspinHub final : public Component, #endif #ifdef USE_SENDSPIN_METADATA + sendspin::MetadataRole *metadata_role_{nullptr}; + void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; // Callback fan-out to child components; they filter as needed diff --git a/esphome/components/sendspin/sensor/__init__.py b/esphome/components/sendspin/sensor/__init__.py new file mode 100644 index 0000000000..cdf1c0da35 --- /dev/null +++ b/esphome/components/sendspin/sensor/__init__.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TYPE, STATE_CLASS_MEASUREMENT, UNIT_MILLISECOND +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinTrackProgressSensor = sendspin_ns.class_( + "SendspinTrackProgressSensor", + sensor.Sensor, + cg.PollingComponent, +) +SendspinTrackDurationSensor = sendspin_ns.class_( + "SendspinTrackDurationSensor", + sensor.Sensor, + cg.Component, +) + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the sensor.""" + request_metadata_support() + + return config + + +_SENSOR_SCHEMA = sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, +).extend( + { + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + } +) + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + "track_progress": _SENSOR_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(SendspinTrackProgressSensor)} + ).extend(cv.polling_component_schema("1s")), + "track_duration": _SENSOR_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(SendspinTrackDurationSensor)} + ).extend(cv.COMPONENT_SCHEMA), + }, + key=CONF_TYPE, + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await sensor.register_sensor(var, config) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp new file mode 100644 index 0000000000..5d0ab21340 --- /dev/null +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -0,0 +1,62 @@ +#include "sendspin_sensor.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.sensor"; + +// --- SendspinTrackProgressSensor --- + +void SendspinTrackProgressSensor::dump_config() { LOG_SENSOR("", "Sendspin Track Progress", this); } + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTrackProgressSensor::setup() { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (!metadata.progress.has_value()) { + return; + } + const auto &progress = metadata.progress.value(); + if (progress.playback_speed == 0) { + // Paused: freeze progress at the reported position and stop polling to save cycles. + this->stop_poller(); + this->publish_state(progress.track_progress); + } else { + this->start_poller(); + } + }); +} + +// THREAD CONTEXT: Main loop. +// Sendspin only pushes progress on state changes (play/pause/seek/speed change), not continuously during +// playback. The hub helper interpolates the current position from the last server update and the playback +// speed, giving us a fresh value on every poll. +void SendspinTrackProgressSensor::update() { this->publish_state(this->parent_->get_track_progress_ms()); } + +// --- SendspinTrackDurationSensor --- + +void SendspinTrackDurationSensor::dump_config() { LOG_SENSOR("", "Sendspin Track Duration", this); } + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTrackDurationSensor::setup() { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.progress.has_value()) { + this->publish_if_changed_(metadata.progress.value().track_duration); + } + }); +} + +// Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes. +void SendspinTrackDurationSensor::publish_if_changed_(float value) { + if (this->get_raw_state() != value) { + this->publish_state(value); + } +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h new file mode 100644 index 0000000000..a13ce5e039 --- /dev/null +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::sendspin_ { + +class SendspinTrackProgressSensor : public sensor::Sensor, public PollingComponent, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } + void dump_config() override; + void setup() override; + void update() override; +}; + +class SendspinTrackDurationSensor : public sensor::Sensor, public Component, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } + void dump_config() override; + void setup() override; + + protected: + void publish_if_changed_(float value); +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/tests/components/sendspin/common-sensor.yaml b/tests/components/sendspin/common-sensor.yaml new file mode 100644 index 0000000000..dc335b6842 --- /dev/null +++ b/tests/components/sendspin/common-sensor.yaml @@ -0,0 +1,9 @@ +<<: !include common.yaml + +sensor: + - platform: sendspin + name: "Sendspin Track Progress" + type: track_progress + - platform: sendspin + name: "Sendspin Track Duration" + type: track_duration diff --git a/tests/components/sendspin/test-sensor.esp32-idf.yaml b/tests/components/sendspin/test-sensor.esp32-idf.yaml new file mode 100644 index 0000000000..f9127d47bc --- /dev/null +++ b/tests/components/sendspin/test-sensor.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-sensor.yaml From 52eeb52d0e5e6af32183f89dd744445c5f43561a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:43:06 -0400 Subject: [PATCH 0560/2214] mark get_track_progress_ms as const --- esphome/components/sendspin/sendspin_hub.cpp | 2 +- esphome/components/sendspin/sendspin_hub.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index c2fcd520ba..d27c5672eb 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -179,7 +179,7 @@ void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadat } // THREAD CONTEXT: Main loop (invoked from Sendspin components) -uint32_t SendspinHub::get_track_progress_ms() { +uint32_t SendspinHub::get_track_progress_ms() const { if (this->is_ready()) { return this->metadata_role_->get_track_progress_ms(); } diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 79b7018645..42c4425d62 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -134,7 +134,7 @@ class SendspinHub final : public Component, } /// @brief Returns the interpolated track progress in milliseconds, or 0 if the hub is not yet ready. - uint32_t get_track_progress_ms(); + uint32_t get_track_progress_ms() const; #endif #ifdef USE_SENDSPIN_PLAYER From 8f3a2691fa4a6679cdd78872f498c8ef9fb36700 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:43:42 -0400 Subject: [PATCH 0561/2214] Add a SendspinPolloingChild class to enforce uniform setup priority --- esphome/components/sendspin/sendspin_hub.h | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 42c4425d62..12fbf156ea 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -216,6 +216,16 @@ class SendspinChild : public Component, public Parented { float get_setup_priority() const override { return sendspin_priority::CHILD; } }; +/// @brief Base class for sendspin subcomponents that need polling behavior. +/// +/// Same purpose as SendspinChild but inherits from PollingComponent for subcomponents +/// that poll on a fixed interval. Subcomponents should inherit from this instead of +/// listing PollingComponent/Parented individually and must not override get_setup_priority(). +class SendspinPollingChild : public PollingComponent, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } +}; + } // namespace esphome::sendspin_ #endif // USE_ESP32 From 6b278e3eb4532390c9433ca6644a88a8ddc5e006 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:44:24 -0400 Subject: [PATCH 0562/2214] make both sensors inherit from the hub's defined child classes to enforce setup priority --- esphome/components/sendspin/sensor/sendspin_sensor.h | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index a13ce5e039..0a9c60dee3 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -9,17 +9,15 @@ namespace esphome::sendspin_ { -class SendspinTrackProgressSensor : public sensor::Sensor, public PollingComponent, public Parented { +class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild { public: - float get_setup_priority() const override { return sendspin_priority::CHILD; } void dump_config() override; void setup() override; void update() override; }; -class SendspinTrackDurationSensor : public sensor::Sensor, public Component, public Parented { +class SendspinTrackDurationSensor : public sensor::Sensor, public SendspinChild { public: - float get_setup_priority() const override { return sendspin_priority::CHILD; } void dump_config() override; void setup() override; From f395e29c59d966d47dfb4de0dcc39c4880a3de2f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:44:41 -0400 Subject: [PATCH 0563/2214] publish immediately on resume --- esphome/components/sendspin/sensor/sendspin_sensor.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 5d0ab21340..4f201f95ef 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -25,6 +25,9 @@ void SendspinTrackProgressSensor::setup() { this->stop_poller(); this->publish_state(progress.track_progress); } else { + // Resumed: publish the fresh interpolated position immediately so the frontend doesn't show a stale + // paused value until the next poll tick. + this->publish_state(this->parent_->get_track_progress_ms()); this->start_poller(); } }); From a179a0de22cf03e309bbe08000ef5d23931b9bee Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:46:11 -0400 Subject: [PATCH 0564/2214] log the update interval for the track progress sensor --- esphome/components/sendspin/sensor/sendspin_sensor.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 4f201f95ef..8710d6dbe6 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -10,7 +10,10 @@ static const char *const TAG = "sendspin.sensor"; // --- SendspinTrackProgressSensor --- -void SendspinTrackProgressSensor::dump_config() { LOG_SENSOR("", "Sendspin Track Progress", this); } +void SendspinTrackProgressSensor::dump_config() { + LOG_SENSOR("", "Sendspin Track Progress", this); + LOG_UPDATE_INTERVAL(this); +} // THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop // (SendspinHub dispatches metadata from client_->loop()). From 773b4d887bf25d8b564aab10f1c3ddd27ee65676 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 08:11:29 -0500 Subject: [PATCH 0565/2214] [core] Scheduler: don't sleep while defer queue is non-empty (#15968) --- esphome/core/scheduler.cpp | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a6f1558e4a..d83d67d6e4 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -414,8 +414,27 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { optional HOT Scheduler::next_schedule_in(uint32_t now) { // IMPORTANT: This method should only be called from the main thread (loop task). - // It performs cleanup and accesses items_[0] without holding a lock, which is only - // safe when called from the main thread. Other threads must not call this method. + // Accesses items_[0] and the fast-path empty checks without holding a lock, which + // is only safe from the main thread. Other threads must not call this method. + // + // Note: cleanup_() is only invoked on the items_[0] path below. The early returns + // skip it because they don't read items_[0], and Scheduler::call() at the top of + // every loop iteration already performs its own cleanup before the next sleep- + // duration computation happens. + +#ifndef ESPHOME_THREAD_SINGLE + // defer() items live in a separate queue that is drained at the top of every + // loop tick via process_defer_queue_(). If any are pending, the next loop + // iteration has work to do right now -- don't let the caller sleep. + if (!this->defer_empty_()) + return 0; +#else + // On single-threaded builds, defer() routes through set_timeout(..., 0) which + // stages in to_add_. process_to_add() runs at the top of every scheduler.call(), + // so anything in to_add_ becomes runnable on the next iteration; don't sleep. + if (!this->to_add_empty_()) + return 0; +#endif // If no items, return empty optional if (!this->cleanup_()) From baa6d5f96b85ff28f34af1a718e5bbe71bef3e2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 08:11:47 -0500 Subject: [PATCH 0566/2214] [web_server_idf] Fix cross-thread race on SSE session state (#15967) --- .../web_server_idf/web_server_idf.cpp | 70 ++++++++++++++----- .../web_server_idf/web_server_idf.h | 16 ++++- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 8f464ae912..e1d3e4bf34 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -472,24 +472,36 @@ void AsyncResponseStream::printf(const char *fmt, ...) { #ifdef USE_WEBSERVER AsyncEventSource::~AsyncEventSource() { - for (auto *ses : this->sessions_) { - delete ses; // NOLINT(cppcoreguidelines-owning-memory) + LockGuard guard{this->pending_mutex_}; + for (auto *vec : {&this->sessions_, &this->pending_sessions_}) { + for (auto *ses : *vec) { + delete ses; // NOLINT(cppcoreguidelines-owning-memory) + } } } void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) { + // Httpd task: set up the live httpd_req_t and park the session; main loop does the rest. // NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks) auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_); - if (this->on_connect_) { - this->on_connect_(rsp); + { + LockGuard guard{this->pending_mutex_}; + this->pending_sessions_.push_back(rsp); + this->has_pending_sessions_.store(true, std::memory_order_release); } - this->sessions_.push_back(rsp); - // Wake up WebServer::loop() to drain deferred event queues for this client. - // Safe from httpd task context via the pending_enable_loop_ flag. this->web_server_->enable_loop_soon_any_context(); } +// clang-analyzer traces a false-positive leak path from loop() through +// adopt_pending_sessions_main_loop_() into start_session_main_loop_() and +// finally ArduinoJson. Suppress along the entire in-our-code call chain. +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) bool AsyncEventSource::loop() { + // Fast path: one atomic load per tick. Slow path is out-of-line on connect. + if (this->has_pending_sessions_.load(std::memory_order_acquire)) { + this->adopt_pending_sessions_main_loop_(); + } + // Clean up dead sessions safely // This follows the ESP-IDF pattern where free_ctx marks resources as dead // and the main loop handles the actual cleanup to avoid race conditions @@ -497,7 +509,7 @@ bool AsyncEventSource::loop() { auto *ses = this->sessions_[i]; // If the session has a dead socket (marked by destroy callback) if (ses->fd_.load() == 0) { - ESP_LOGD(TAG, "Removing dead event source session"); + // destroy() already logged the close with the fd; don't double-log here. delete ses; // NOLINT(cppcoreguidelines-owning-memory) // Remove by swapping with last element (O(1) removal, order doesn't matter for sessions) this->sessions_[i] = this->sessions_.back(); @@ -510,6 +522,30 @@ bool AsyncEventSource::loop() { return !this->sessions_.empty(); } +void AsyncEventSource::adopt_pending_sessions_main_loop_() { + std::vector incoming; + { + LockGuard guard{this->pending_mutex_}; + incoming.swap(this->pending_sessions_); + this->has_pending_sessions_.store(false, std::memory_order_relaxed); + } + for (auto *rsp : incoming) { + // Already disconnected? Drop it; skip on_connect_/session start on a dead session. + if (rsp->fd_.load() == 0) { + delete rsp; // NOLINT(cppcoreguidelines-owning-memory) + continue; + } + this->sessions_.push_back(rsp); + // Prime first so on_connect_ observes a session that has already sent its + // initial ping/config/sorting_groups, matching the pre-refactor ordering. + rsp->start_session_main_loop_(); + if (this->on_connect_) { + this->on_connect_(rsp); + } + } +} +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) + void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) { for (auto *ses : this->sessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions @@ -534,6 +570,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws) : server_(server), web_server_(ws), entities_iterator_(ws, server) { + // Httpd task only. start_session_main_loop_() handles event_buffer_ / iterator setup. httpd_req_t *req = *request; httpd_resp_set_status(req, HTTPD_200); @@ -555,21 +592,23 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * // Use non-blocking send to prevent watchdog timeouts when TCP buffers are full httpd_sess_set_send_override(this->hd_, this->fd_.load(), nonblocking_send); +} - // Configure reconnect timeout and send config - // this should always go through since the tcp send buffer is empty on connect +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson +void AsyncEventSourceResponse::start_session_main_loop_() { + auto *ws = this->web_server_; + + // tcp send buffer is empty on connect, so these should always go through auto message = ws->get_config_json(); this->try_send_nodefer(message.c_str(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson json::JsonBuilder builder; JsonObject root = builder.root(); root["name"] = group.second.name; root["sorting_weight"] = group.second.weight; message = builder.serialize(); - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) // a (very) large number of these should be able to be queued initially without defer // since the only thing in the send buffer at this point is the initial ping/config @@ -578,13 +617,8 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * #endif this->entities_iterator_.begin(ws->include_internal_); - - // just dump them all up-front and take advantage of the deferred queue - // on second thought that takes too long, but leaving the commented code here for debug purposes - // while(!this->entities_iterator_.completed()) { - // this->entities_iterator_.advance(); - //} } +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) void AsyncEventSourceResponse::destroy(void *ptr) { auto *rsp = static_cast(ptr); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index f2931fb507..cdb58c2f04 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -299,6 +299,9 @@ class AsyncEventSourceResponse { AsyncEventSourceResponse(const AsyncWebServerRequest *request, esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws); + // Main-loop only: sends initial ping/config/sorting_groups, starts entity iterator. + void start_session_main_loop_(); + void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator); void process_deferred_queue_(); void process_buffer_(); @@ -335,6 +338,8 @@ class AsyncEventSource : public AsyncWebHandler { } // NOLINTNEXTLINE(readability-identifier-naming) void handleRequest(AsyncWebServerRequest *request) override; + // Callback runs on the main loop (not the httpd task) after the session's + // initial ping/config/sorting_groups have been sent. // NOLINTNEXTLINE(readability-identifier-naming) void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } @@ -347,13 +352,18 @@ class AsyncEventSource : public AsyncWebHandler { size_t count() const { return this->sessions_.size(); } protected: + // Cold path: move sessions from pending_sessions_ into sessions_ and greet each one. + void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_(); + std::string url_; - // Use vector instead of set: SSE sessions are typically 1-5 connections (browsers, dashboards). - // Linear search is faster than red-black tree overhead for this small dataset. - // Only operations needed: add session, remove session, iterate sessions - no need for sorted order. + // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; + // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. + std::vector pending_sessions_; + Mutex pending_mutex_; connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; + std::atomic has_pending_sessions_{false}; }; #endif // USE_WEBSERVER From 66019feb8f7e9d4e0e82d4d9dc08b29452a62bd1 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 09:27:37 -0400 Subject: [PATCH 0567/2214] add year and track number sensors (will remove them from text_sensor in a future PR) --- .../components/sendspin/sensor/__init__.py | 72 ++++++++++++++----- .../sendspin/sensor/sendspin_sensor.cpp | 51 ++++++++++--- .../sendspin/sensor/sendspin_sensor.h | 12 +++- tests/components/sendspin/common-sensor.yaml | 6 ++ 4 files changed, 113 insertions(+), 28 deletions(-) diff --git a/esphome/components/sendspin/sensor/__init__.py b/esphome/components/sendspin/sensor/__init__.py index cdf1c0da35..dc9b86c2a3 100644 --- a/esphome/components/sendspin/sensor/__init__.py +++ b/esphome/components/sendspin/sensor/__init__.py @@ -1,7 +1,13 @@ import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TYPE, STATE_CLASS_MEASUREMENT, UNIT_MILLISECOND +from esphome.const import ( + CONF_ID, + CONF_TYPE, + CONF_YEAR, + STATE_CLASS_MEASUREMENT, + UNIT_MILLISECOND, +) from esphome.types import ConfigType from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns @@ -9,17 +15,30 @@ from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] +CONF_TRACK = "track" +CONF_TRACK_PROGRESS = "track_progress" +CONF_TRACK_DURATION = "track_duration" + SendspinTrackProgressSensor = sendspin_ns.class_( "SendspinTrackProgressSensor", sensor.Sensor, cg.PollingComponent, ) -SendspinTrackDurationSensor = sendspin_ns.class_( - "SendspinTrackDurationSensor", +SendspinMetadataSensor = sendspin_ns.class_( + "SendspinMetadataSensor", sensor.Sensor, cg.Component, ) +SendspinNumericMetadataTypes = sendspin_ns.enum( + "SendspinNumericMetadataTypes", is_class=True +) +_METADATA_TYPE_ENUM = { + CONF_TRACK_DURATION: SendspinNumericMetadataTypes.TRACK_DURATION, + CONF_YEAR: SendspinNumericMetadataTypes.YEAR, + CONF_TRACK: SendspinNumericMetadataTypes.TRACK, +} + def _request_roles(config: ConfigType) -> ConfigType: """Request the necessary Sendspin roles for the sensor.""" @@ -28,25 +47,39 @@ def _request_roles(config: ConfigType) -> ConfigType: return config -_SENSOR_SCHEMA = sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=UNIT_MILLISECOND, -).extend( - { - cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), - } -) +_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + + +def _metadata_schema(**sensor_kwargs): + """Schema for event-driven numeric metadata sensors (duration/year/track).""" + return ( + sensor.sensor_schema( + SendspinMetadataSensor, + accuracy_decimals=0, + **sensor_kwargs, + ) + .extend(_HUB_ID_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) + ) + CONFIG_SCHEMA = cv.All( cv.typed_schema( { - "track_progress": _SENSOR_SCHEMA.extend( - {cv.GenerateID(): cv.declare_id(SendspinTrackProgressSensor)} - ).extend(cv.polling_component_schema("1s")), - "track_duration": _SENSOR_SCHEMA.extend( - {cv.GenerateID(): cv.declare_id(SendspinTrackDurationSensor)} - ).extend(cv.COMPONENT_SCHEMA), + CONF_TRACK_PROGRESS: sensor.sensor_schema( + SendspinTrackProgressSensor, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, + ) + .extend(_HUB_ID_SCHEMA) + .extend(cv.polling_component_schema("1s")), + CONF_TRACK_DURATION: _metadata_schema( + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, + ), + CONF_YEAR: _metadata_schema(), + CONF_TRACK: _metadata_schema(), }, key=CONF_TYPE, ), @@ -60,3 +93,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await cg.register_parented(var, config[CONF_SENDSPIN_ID]) await sensor.register_sensor(var, config) + + if (metadata_type := _METADATA_TYPE_ENUM.get(config[CONF_TYPE])) is not None: + cg.add(var.set_metadata_type(metadata_type)) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 8710d6dbe6..4efbd72a38 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -11,7 +11,7 @@ static const char *const TAG = "sendspin.sensor"; // --- SendspinTrackProgressSensor --- void SendspinTrackProgressSensor::dump_config() { - LOG_SENSOR("", "Sendspin Track Progress", this); + LOG_SENSOR("", "Track Progress", this); LOG_UPDATE_INTERVAL(this); } @@ -42,22 +42,55 @@ void SendspinTrackProgressSensor::setup() { // speed, giving us a fresh value on every poll. void SendspinTrackProgressSensor::update() { this->publish_state(this->parent_->get_track_progress_ms()); } -// --- SendspinTrackDurationSensor --- +// --- SendspinMetadataSensor --- -void SendspinTrackDurationSensor::dump_config() { LOG_SENSOR("", "Sendspin Track Duration", this); } +void SendspinMetadataSensor::dump_config() { + switch (this->metadata_type_) { + case SendspinNumericMetadataTypes::TRACK_DURATION: + LOG_SENSOR("", "Track Duration", this); + break; + case SendspinNumericMetadataTypes::YEAR: + LOG_SENSOR("", "Year", this); + break; + case SendspinNumericMetadataTypes::TRACK: + LOG_SENSOR("", "Track", this); + break; + } +} // THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop // (SendspinHub dispatches metadata from client_->loop()). -void SendspinTrackDurationSensor::setup() { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.progress.has_value()) { - this->publish_if_changed_(metadata.progress.value().track_duration); +void SendspinMetadataSensor::setup() { + switch (this->metadata_type_) { + case SendspinNumericMetadataTypes::TRACK_DURATION: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.progress.has_value()) { + this->publish_if_changed_(metadata.progress.value().track_duration); + } + }); + break; } - }); + case SendspinNumericMetadataTypes::YEAR: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.year.has_value() && metadata.year.value() <= 9999) { + this->publish_if_changed_(metadata.year.value()); + } + }); + break; + } + case SendspinNumericMetadataTypes::TRACK: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.track.has_value() && metadata.track.value() <= 9999) { + this->publish_if_changed_(metadata.track.value()); + } + }); + break; + } + } } // Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes. -void SendspinTrackDurationSensor::publish_if_changed_(float value) { +void SendspinMetadataSensor::publish_if_changed_(float value) { if (this->get_raw_state() != value) { this->publish_state(value); } diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index 0a9c60dee3..969c127283 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -16,13 +16,23 @@ class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollin void update() override; }; -class SendspinTrackDurationSensor : public sensor::Sensor, public SendspinChild { +enum class SendspinNumericMetadataTypes { + TRACK_DURATION, + YEAR, + TRACK, +}; + +class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild { public: void dump_config() override; void setup() override; + void set_metadata_type(SendspinNumericMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } + protected: void publish_if_changed_(float value); + + SendspinNumericMetadataTypes metadata_type_; }; } // namespace esphome::sendspin_ diff --git a/tests/components/sendspin/common-sensor.yaml b/tests/components/sendspin/common-sensor.yaml index dc335b6842..6d9745cff9 100644 --- a/tests/components/sendspin/common-sensor.yaml +++ b/tests/components/sendspin/common-sensor.yaml @@ -7,3 +7,9 @@ sensor: - platform: sendspin name: "Sendspin Track Duration" type: track_duration + - platform: sendspin + name: "Sendspin Year" + type: year + - platform: sendspin + name: "Sendspin Track" + type: track From 61de24a2796d9678e253fe4e192adbdd5f27cdc5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 09:56:09 -0400 Subject: [PATCH 0568/2214] drop bound check on year and track number as they aren't relevant (we only checked text sensors so we wouldn't overflow the character buffer) --- esphome/components/sendspin/sensor/sendspin_sensor.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 4efbd72a38..5359bd9da8 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -72,7 +72,7 @@ void SendspinMetadataSensor::setup() { } case SendspinNumericMetadataTypes::YEAR: { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.year.has_value() && metadata.year.value() <= 9999) { + if (metadata.year.has_value()) { this->publish_if_changed_(metadata.year.value()); } }); @@ -80,7 +80,7 @@ void SendspinMetadataSensor::setup() { } case SendspinNumericMetadataTypes::TRACK: { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.track.has_value() && metadata.track.value() <= 9999) { + if (metadata.track.has_value()) { this->publish_if_changed_(metadata.track.value()); } }); From f132b7dc07f2f402eab87a8ee445a64c5b403e22 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 10:09:03 -0400 Subject: [PATCH 0569/2214] [media_player][speaker][speaker_source] Centralize preferred format codegen (#14771) --- esphome/components/media_player/__init__.py | 111 +++++++++++- .../speaker/media_player/__init__.py | 160 ++++-------------- .../components/speaker_source/media_player.py | 83 +-------- .../speaker/common-media_player.yaml | 2 +- 4 files changed, 156 insertions(+), 200 deletions(-) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 1c2c474645..d1db868ace 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -1,20 +1,31 @@ +from collections.abc import Callable + from esphome import automation import esphome.codegen as cg +from esphome.components import audio import esphome.config_validation as cv from esphome.const import ( CONF_ENTITY_CATEGORY, + CONF_FORMAT, CONF_ICON, CONF_ID, + CONF_NUM_CHANNELS, CONF_ON_IDLE, CONF_ON_STATE, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, + CONF_SAMPLE_RATE, CONF_VOLUME, ) from esphome.core import CORE -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + inherit_property_from, + setup_entity, +) from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -34,6 +45,102 @@ MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = { "announcement": MediaPlayerFormatPurpose.PURPOSE_ANNOUNCEMENT, } +# Public API for external components. Do not remove. +FORMAT_MAPPING = { + "FLAC": "flac", + "MP3": "mp3", + "OPUS": "opus", + "WAV": "wav", +} + + +def build_supported_format_struct( + format_config: ConfigType, purpose: MockObj +) -> cg.StructInitializer: + """Build a MediaPlayerSupportedFormat struct from a format config and purpose. + + Public API for external components. Do not remove. + """ + args = [ + MediaPlayerSupportedFormat, + ("format", FORMAT_MAPPING[format_config[CONF_FORMAT]]), + ("sample_rate", format_config[CONF_SAMPLE_RATE]), + ("num_channels", format_config[CONF_NUM_CHANNELS]), + ("purpose", purpose), + ] + + # Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails + # if the number of bytes per sample is specified for MP3. + if format_config[CONF_FORMAT] != "MP3": + args.append(("sample_bytes", 2)) + + return cg.StructInitializer(*args) + + +def validate_preferred_format( + component_name: str, audio_device_key: str +) -> Callable[[ConfigType], ConfigType]: + """Return a validator that inherits audio device settings and validates format constraints. + + Public API for external components. Do not remove. + """ + + def validator(config: ConfigType) -> ConfigType: + # Inherit settings from audio device if not manually set + inherit_property_from(CONF_NUM_CHANNELS, audio_device_key)(config) + inherit_property_from(CONF_SAMPLE_RATE, audio_device_key)(config) + + # Opus only supports 48 kHz + if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: + raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") + + # Validate the settings are compatible with the audio device + audio.final_validate_audio_schema( + component_name, + audio_device=audio_device_key, + bits_per_sample=16, + channels=config.get(CONF_NUM_CHANNELS), + sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + return validator + + +def request_codecs_for_format_configs( + config: ConfigType, format_config_keys: list[str] +) -> None: + """Scan format configs for configured formats and request the needed codec support. + + If any config uses "NONE" (accepts any format), all codecs are requested. + + Public API for external components. Do not remove. + """ + needed_formats: set[str] = set() + need_all = False + + for key in format_config_keys: + if format_config := config.get(key): + fmt = format_config[CONF_FORMAT] + if fmt == "NONE": + need_all = True + else: + needed_formats.add(fmt) + + if need_all: + audio.request_flac_support() + audio.request_mp3_support() + audio.request_opus_support() + else: + if "FLAC" in needed_formats: + audio.request_flac_support() + if "MP3" in needed_formats: + audio.request_mp3_support() + if "OPUS" in needed_formats: + audio.request_opus_support() + + # Local config key constants CONF_ANNOUNCEMENT = "announcement" CONF_ON_PLAY = "on_play" diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 9b496637da..abfd599808 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -32,7 +32,6 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.core.entity_helpers import inherit_property_from from esphome.external_files import download_content _LOGGER = logging.getLogger(__name__) @@ -44,16 +43,12 @@ DEPENDENCIES = ["network"] CODEOWNERS = ["@kahrendt", "@synesthesiam"] DOMAIN = "media_player" -CODEC_SUPPORT_ALL = "all" -CODEC_SUPPORT_NEEDED = "needed" -CODEC_SUPPORT_NONE = "none" - TYPE_LOCAL = "local" TYPE_WEB = "web" CONF_ANNOUNCEMENT = "announcement" CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" -CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" +CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0 CONF_ENQUEUE = "enqueue" CONF_MEDIA_FILE = "media_file" CONF_MEDIA_PIPELINE = "media_pipeline" @@ -106,43 +101,10 @@ def _download_web_file(value): return value -# Returns a media_player.MediaPlayerSupportedFormat struct with the configured -# format, sample rate, number of channels, purpose, and bytes per sample -def _get_supported_format_struct(pipeline, type): - args = [ - media_player.MediaPlayerSupportedFormat, - ] - - if pipeline[CONF_FORMAT] == "FLAC": - args.append(("format", "flac")) - elif pipeline[CONF_FORMAT] == "MP3": - args.append(("format", "mp3")) - elif pipeline[CONF_FORMAT] == "OPUS": - args.append(("format", "opus")) - elif pipeline[CONF_FORMAT] == "WAV": - args.append(("format", "wav")) - - args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE])) - args.append(("num_channels", pipeline[CONF_NUM_CHANNELS])) - - if type == "MEDIA": - args.append( - ( - "purpose", - media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], - ) - ) - elif type == "ANNOUNCEMENT": - args.append( - ( - "purpose", - media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], - ) - ) - if pipeline[CONF_FORMAT] != "MP3": - args.append(("sample_bytes", 2)) - - return cg.StructInitializer(*args) +_PURPOSE_MAP = { + "MEDIA": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], + "ANNOUNCEMENT": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], +} def _file_schema(value): @@ -210,25 +172,9 @@ def _validate_file_shorthand(value): ) -def _validate_pipeline(config): - # Inherit transcoder settings from speaker if not manually set - inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config) - inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config) - - # Opus only supports 48 kHz - if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: - raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") - - # Validate the transcoder settings is compatible with the speaker - audio.final_validate_audio_schema( - "speaker media_player", - audio_device=CONF_SPEAKER, - bits_per_sample=16, - channels=config.get(CONF_NUM_CHANNELS), - sample_rate=config.get(CONF_SAMPLE_RATE), - )(config) - - return config +_validate_pipeline = media_player.validate_preferred_format( + "speaker media_player", CONF_SPEAKER +) def _validate_repeated_speaker(config): @@ -245,59 +191,34 @@ def _validate_repeated_speaker(config): def _final_validate(config): - # Normalize boolean values to string equivalents - codec_mode = config[CONF_CODEC_SUPPORT_ENABLED] - if codec_mode is True: - codec_mode = CODEC_SUPPORT_ALL - elif codec_mode is False: - codec_mode = CODEC_SUPPORT_NONE + # Remove before 2026.10.0 + if CONF_CODEC_SUPPORT_ENABLED in config: + _LOGGER.warning( + "'%s' is deprecated and will be removed in 2026.10.0. " + "Codec support is now automatically determined from the pipeline " + "'format' setting. Set format to 'NONE' to enable all codecs.", + CONF_CODEC_SUPPORT_ENABLED, + ) - use_codec = codec_mode != CODEC_SUPPORT_NONE - - # In "needed" mode, collect formats from pipelines and files - needed_formats = set() - need_all = False - if codec_mode == CODEC_SUPPORT_NEEDED: - for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): - if pipeline := config.get(pipeline_key): - fmt = pipeline[CONF_FORMAT] - if fmt == "NONE": - # No preferred format means any format could arrive - need_all = True - else: - needed_formats.add(fmt) + # Request codecs based on pipeline formats + media_player.request_codecs_for_format_configs( + config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE] + ) + # Validate local files and request any additional codecs they need for file_config in config.get(CONF_FILES, []): _, media_file_type = _read_audio_file_and_type(file_config) if str(media_file_type) == str(audio.AUDIO_FILE_TYPE_ENUM["NONE"]): raise cv.Invalid("Unsupported local media file") - if not use_codec and str(media_file_type) != str( - audio.AUDIO_FILE_TYPE_ENUM["WAV"] - ): - # Only wav files are supported - raise cv.Invalid( - f"Unsupported local media file type, set {CONF_CODEC_SUPPORT_ENABLED} to true or convert the media file to wav" - ) - # In "needed" mode, add file format to needed codecs - if codec_mode == CODEC_SUPPORT_NEEDED: - for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items(): - if str(media_file_type) == str(fmt_enum): - if fmt_name not in ("WAV", "NONE"): - needed_formats.add(fmt_name) - break - - # Request codec support - if codec_mode == CODEC_SUPPORT_ALL or need_all: - audio.request_flac_support() - audio.request_mp3_support() - audio.request_opus_support() - elif codec_mode == CODEC_SUPPORT_NEEDED: - if "FLAC" in needed_formats: - audio.request_flac_support() - if "MP3" in needed_formats: - audio.request_mp3_support() - if "OPUS" in needed_formats: - audio.request_opus_support() + for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items(): + if str(media_file_type) == str(fmt_enum): + if fmt_name == "FLAC": + audio.request_flac_support() + elif fmt_name == "MP3": + audio.request_mp3_support() + elif fmt_name == "OPUS": + audio.request_opus_support() + break return config @@ -362,17 +283,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range( min=4000, max=4000000 ), - cv.Optional( - CONF_CODEC_SUPPORT_ENABLED, default=CODEC_SUPPORT_NEEDED - ): cv.Any( - cv.boolean, - cv.one_of( - CODEC_SUPPORT_ALL, - CODEC_SUPPORT_NEEDED, - CODEC_SUPPORT_NONE, - lower=True, - ), - ), + # Remove before 2026.10.0 + cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), cv.Optional(CONF_FILES): cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( cv.boolean, cv.requires_component(psram.DOMAIN) @@ -432,8 +344,8 @@ async def to_code(config): if announcement_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_announcement_format( - _get_supported_format_struct( - announcement_pipeline_config, "ANNOUNCEMENT" + media_player.build_supported_format_struct( + announcement_pipeline_config, _PURPOSE_MAP["ANNOUNCEMENT"] ) ) ) @@ -444,7 +356,9 @@ async def to_code(config): if media_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_media_format( - _get_supported_format_struct(media_pipeline_config, "MEDIA") + media_player.build_supported_format_struct( + media_pipeline_config, _PURPOSE_MAP["MEDIA"] + ) ) ) diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index 70feeac318..b6653fe543 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -17,7 +17,6 @@ from esphome.const import ( CONF_SPEAKER, ) 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 @@ -65,53 +64,9 @@ SetPlaylistDelayAction = speaker_source_ns.class_( ) -FORMAT_MAPPING = { - "FLAC": "flac", - "MP3": "mp3", - "OPUS": "opus", - "WAV": "wav", -} - - -# Returns a media_player.MediaPlayerSupportedFormat struct with the configured -# format, sample rate, number of channels, purpose, and bytes per sample -def _get_supported_format_struct(pipeline: ConfigType, purpose: MockObj): - args = [ - media_player.MediaPlayerSupportedFormat, - ] - - args.append(("format", FORMAT_MAPPING[pipeline[CONF_FORMAT]])) - - args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE])) - args.append(("num_channels", pipeline[CONF_NUM_CHANNELS])) - args.append(("purpose", purpose)) - - # Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails - # if the number of bytes per sample is specified for MP3. - if pipeline[CONF_FORMAT] != "MP3": - args.append(("sample_bytes", 2)) - - return cg.StructInitializer(*args) - - -def _validate_pipeline(config: ConfigType) -> ConfigType: - # Inherit settings from speaker if not manually set - inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config) - inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config) - - # Opus only supports 48 kHz - if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: - raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") - - audio.final_validate_audio_schema( - "speaker_source media_player", - audio_device=CONF_SPEAKER, - bits_per_sample=16, - channels=config.get(CONF_NUM_CHANNELS), - sample_rate=config.get(CONF_SAMPLE_RATE), - )(config) - - return config +_validate_pipeline = media_player.validate_preferred_format( + "speaker_source media_player", CONF_SPEAKER +) PIPELINE_SCHEMA = cv.Schema( @@ -198,31 +153,9 @@ CONFIG_SCHEMA = cv.All( def _final_validate_codecs(config: ConfigType) -> ConfigType: - # "NONE" means the pipeline accepts any format at runtime, so all optional codecs must be available. - # When a specific format is set, only that codec is requested. - needed_formats: set[str] = set() - need_all = False - - for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): - if pipeline := config.get(pipeline_key): - fmt = pipeline[CONF_FORMAT] - if fmt == "NONE": - need_all = True - else: - needed_formats.add(fmt) - - if need_all: - audio.request_flac_support() - audio.request_mp3_support() - audio.request_opus_support() - else: - if "FLAC" in needed_formats: - audio.request_flac_support() - if "MP3" in needed_formats: - audio.request_mp3_support() - if "OPUS" in needed_formats: - audio.request_opus_support() - + media_player.request_codecs_for_format_configs( + config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE] + ) return config @@ -264,7 +197,9 @@ async def to_code(config: ConfigType) -> None: cg.add( var.set_format( pipeline_enum, - _get_supported_format_struct(pipeline_config, purpose), + media_player.build_supported_format_struct( + pipeline_config, purpose + ), ) ) diff --git a/tests/components/speaker/common-media_player.yaml b/tests/components/speaker/common-media_player.yaml index c958c0d912..a849e04b33 100644 --- a/tests/components/speaker/common-media_player.yaml +++ b/tests/components/speaker/common-media_player.yaml @@ -11,9 +11,9 @@ media_player: id: speaker_media_player_id announcement_pipeline: speaker: speaker_id + format: NONE buffer_size: 1000000 volume_increment: 0.02 volume_max: 0.95 volume_min: 0.0 task_stack_in_psram: true - codec_support_enabled: all From 55bcf33446dfc8c001482c57fcdbc2754d332058 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 10:32:47 -0400 Subject: [PATCH 0570/2214] [sendspin] Add metadata sensor component (#15971) --- CODEOWNERS | 1 + esphome/components/sendspin/sendspin_hub.cpp | 11 ++- esphome/components/sendspin/sendspin_hub.h | 15 +++ .../components/sendspin/sensor/__init__.py | 98 +++++++++++++++++++ .../sendspin/sensor/sendspin_sensor.cpp | 98 +++++++++++++++++++ .../sendspin/sensor/sendspin_sensor.h | 42 ++++++++ tests/components/sendspin/common-sensor.yaml | 15 +++ .../sendspin/test-sensor.esp32-idf.yaml | 1 + 8 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/sensor/__init__.py create mode 100644 esphome/components/sendspin/sensor/sendspin_sensor.cpp create mode 100644 esphome/components/sendspin/sensor/sendspin_sensor.h create mode 100644 tests/components/sendspin/common-sensor.yaml create mode 100644 tests/components/sendspin/test-sensor.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f4b288b23d..20c19a7dfa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -443,6 +443,7 @@ esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt +esphome/components/sendspin/sensor/* @kahrendt esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index da298feb86..d27c5672eb 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -40,7 +40,8 @@ void SendspinHub::setup() { #endif #ifdef USE_SENDSPIN_METADATA - this->client_->add_metadata().set_listener(this); + this->metadata_role_ = &this->client_->add_metadata(); + this->metadata_role_->set_listener(this); #endif #ifdef USE_SENDSPIN_PLAYER @@ -176,6 +177,14 @@ void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObjec void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadata) { this->metadata_update_callbacks_.call(metadata); } + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +uint32_t SendspinHub::get_track_progress_ms() const { + if (this->is_ready()) { + return this->metadata_role_->get_track_progress_ms(); + } + return 0; +} #endif #ifdef USE_SENDSPIN_PLAYER diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 8d9c58a3ab..12fbf156ea 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -132,6 +132,9 @@ class SendspinHub final : public Component, template void add_metadata_update_callback(F &&callback) { this->metadata_update_callbacks_.add(std::forward(callback)); } + + /// @brief Returns the interpolated track progress in milliseconds, or 0 if the hub is not yet ready. + uint32_t get_track_progress_ms() const; #endif #ifdef USE_SENDSPIN_PLAYER @@ -172,6 +175,8 @@ class SendspinHub final : public Component, #endif #ifdef USE_SENDSPIN_METADATA + sendspin::MetadataRole *metadata_role_{nullptr}; + void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; // Callback fan-out to child components; they filter as needed @@ -211,6 +216,16 @@ class SendspinChild : public Component, public Parented { float get_setup_priority() const override { return sendspin_priority::CHILD; } }; +/// @brief Base class for sendspin subcomponents that need polling behavior. +/// +/// Same purpose as SendspinChild but inherits from PollingComponent for subcomponents +/// that poll on a fixed interval. Subcomponents should inherit from this instead of +/// listing PollingComponent/Parented individually and must not override get_setup_priority(). +class SendspinPollingChild : public PollingComponent, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } +}; + } // namespace esphome::sendspin_ #endif // USE_ESP32 diff --git a/esphome/components/sendspin/sensor/__init__.py b/esphome/components/sendspin/sensor/__init__.py new file mode 100644 index 0000000000..dc9b86c2a3 --- /dev/null +++ b/esphome/components/sendspin/sensor/__init__.py @@ -0,0 +1,98 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_TYPE, + CONF_YEAR, + STATE_CLASS_MEASUREMENT, + UNIT_MILLISECOND, +) +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +CONF_TRACK = "track" +CONF_TRACK_PROGRESS = "track_progress" +CONF_TRACK_DURATION = "track_duration" + +SendspinTrackProgressSensor = sendspin_ns.class_( + "SendspinTrackProgressSensor", + sensor.Sensor, + cg.PollingComponent, +) +SendspinMetadataSensor = sendspin_ns.class_( + "SendspinMetadataSensor", + sensor.Sensor, + cg.Component, +) + +SendspinNumericMetadataTypes = sendspin_ns.enum( + "SendspinNumericMetadataTypes", is_class=True +) +_METADATA_TYPE_ENUM = { + CONF_TRACK_DURATION: SendspinNumericMetadataTypes.TRACK_DURATION, + CONF_YEAR: SendspinNumericMetadataTypes.YEAR, + CONF_TRACK: SendspinNumericMetadataTypes.TRACK, +} + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the sensor.""" + request_metadata_support() + + return config + + +_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + + +def _metadata_schema(**sensor_kwargs): + """Schema for event-driven numeric metadata sensors (duration/year/track).""" + return ( + sensor.sensor_schema( + SendspinMetadataSensor, + accuracy_decimals=0, + **sensor_kwargs, + ) + .extend(_HUB_ID_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) + ) + + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + CONF_TRACK_PROGRESS: sensor.sensor_schema( + SendspinTrackProgressSensor, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, + ) + .extend(_HUB_ID_SCHEMA) + .extend(cv.polling_component_schema("1s")), + CONF_TRACK_DURATION: _metadata_schema( + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, + ), + CONF_YEAR: _metadata_schema(), + CONF_TRACK: _metadata_schema(), + }, + key=CONF_TYPE, + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await sensor.register_sensor(var, config) + + if (metadata_type := _METADATA_TYPE_ENUM.get(config[CONF_TYPE])) is not None: + cg.add(var.set_metadata_type(metadata_type)) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp new file mode 100644 index 0000000000..68848a6f3e --- /dev/null +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -0,0 +1,98 @@ +#include "sendspin_sensor.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.sensor"; + +// --- SendspinTrackProgressSensor --- + +void SendspinTrackProgressSensor::dump_config() { + LOG_SENSOR("", "Track Progress", this); + LOG_UPDATE_INTERVAL(this); +} + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTrackProgressSensor::setup() { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (!metadata.progress.has_value()) { + return; + } + const auto &progress = metadata.progress.value(); + if (progress.playback_speed == 0) { + // Paused: freeze progress at the reported position and stop polling to save cycles. + this->stop_poller(); + this->publish_state(progress.track_progress); + } else { + // Resumed: publish the fresh interpolated position immediately so the frontend doesn't show a stale + // paused value until the next poll tick. + this->publish_state(this->parent_->get_track_progress_ms()); + this->start_poller(); + } + }); +} + +// THREAD CONTEXT: Main loop. +// Sendspin only pushes progress on state changes (play/pause/seek/speed change), not continuously during +// playback. The hub helper interpolates the current position from the last server update and the playback +// speed, giving us a fresh value on every poll. +void SendspinTrackProgressSensor::update() { this->publish_state(this->parent_->get_track_progress_ms()); } + +// --- SendspinMetadataSensor --- + +void SendspinMetadataSensor::dump_config() { + switch (this->metadata_type_) { + case SendspinNumericMetadataTypes::TRACK_DURATION: + LOG_SENSOR("", "Track Duration", this); + break; + case SendspinNumericMetadataTypes::YEAR: + LOG_SENSOR("", "Year", this); + break; + case SendspinNumericMetadataTypes::TRACK: + LOG_SENSOR("", "Track", this); + break; + } +} + +std::optional SendspinMetadataSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const { + switch (this->metadata_type_) { + case SendspinNumericMetadataTypes::TRACK_DURATION: + if (metadata.progress.has_value()) + return metadata.progress.value().track_duration; + return std::nullopt; + case SendspinNumericMetadataTypes::YEAR: + if (metadata.year.has_value()) + return metadata.year.value(); + return std::nullopt; + case SendspinNumericMetadataTypes::TRACK: + if (metadata.track.has_value()) + return metadata.track.value(); + return std::nullopt; + } + return std::nullopt; +} + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (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); + } + }); +} + +// 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) { + this->publish_state(value); + } +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h new file mode 100644 index 0000000000..cbfe1742c9 --- /dev/null +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -0,0 +1,42 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/sensor/sensor.h" + +#include + +namespace esphome::sendspin_ { + +class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild { + public: + void dump_config() override; + void setup() override; + void update() override; +}; + +enum class SendspinNumericMetadataTypes { + TRACK_DURATION, + YEAR, + TRACK, +}; + +class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild { + public: + void dump_config() override; + void setup() override; + + void set_metadata_type(SendspinNumericMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } + + protected: + std::optional extract_value_(const sendspin::ServerMetadataStateObject &metadata) const; + void publish_if_changed_(float value); + + SendspinNumericMetadataTypes metadata_type_; +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/tests/components/sendspin/common-sensor.yaml b/tests/components/sendspin/common-sensor.yaml new file mode 100644 index 0000000000..6d9745cff9 --- /dev/null +++ b/tests/components/sendspin/common-sensor.yaml @@ -0,0 +1,15 @@ +<<: !include common.yaml + +sensor: + - platform: sendspin + name: "Sendspin Track Progress" + type: track_progress + - platform: sendspin + name: "Sendspin Track Duration" + type: track_duration + - platform: sendspin + name: "Sendspin Year" + type: year + - platform: sendspin + name: "Sendspin Track" + type: track diff --git a/tests/components/sendspin/test-sensor.esp32-idf.yaml b/tests/components/sendspin/test-sensor.esp32-idf.yaml new file mode 100644 index 0000000000..f9127d47bc --- /dev/null +++ b/tests/components/sendspin/test-sensor.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-sensor.yaml From 94e300389c8accb7387d342e6d9ce75cad694fa7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 11:35:32 -0400 Subject: [PATCH 0571/2214] [sendspin] remove year and track number text sensors and refactor (#15975) --- .../sendspin/text_sensor/__init__.py | 2 - .../text_sensor/sendspin_text_sensor.cpp | 81 ++++++------------- .../text_sensor/sendspin_text_sensor.h | 5 +- .../sendspin/common-text_sensor.yaml | 6 -- 4 files changed, 29 insertions(+), 65 deletions(-) diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py index b7f216ca0c..87f6c9b936 100644 --- a/esphome/components/sendspin/text_sensor/__init__.py +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -21,8 +21,6 @@ SENDSPIN_TEXT_METADATA_TYPES = { "artist": SendspinTextMetadataTypes.ARTIST, "album": SendspinTextMetadataTypes.ALBUM, "album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST, - "year": SendspinTextMetadataTypes.YEAR, - "track": SendspinTextMetadataTypes.TRACK, } diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index d16d51f63c..9843fb966e 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -2,8 +2,6 @@ #if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) -#include "esphome/core/helpers.h" - #include #include @@ -14,63 +12,36 @@ static const char *const TAG = "sendspin.text_sensor"; void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } +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; + case SendspinTextMetadataTypes::ARTIST: + if (metadata.artist.has_value()) + return metadata.artist.value().c_str(); + return nullptr; + case SendspinTextMetadataTypes::ALBUM: + if (metadata.album.has_value()) + return metadata.album.value().c_str(); + return nullptr; + case SendspinTextMetadataTypes::ALBUM_ARTIST: + if (metadata.album_artist.has_value()) + return metadata.album_artist.value().c_str(); + return nullptr; + } + return nullptr; +} + // THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop // (SendspinHub dispatches metadata from client_->loop()). void SendspinTextSensor::setup() { - switch (this->metadata_type_) { - case SendspinTextMetadataTypes::TITLE: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.title.has_value()) { - this->publish_if_changed_(metadata.title.value().c_str()); - } - }); - break; + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (const char *value = this->extract_value_(metadata)) { + this->publish_if_changed_(value); } - case SendspinTextMetadataTypes::ARTIST: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.artist.has_value()) { - this->publish_if_changed_(metadata.artist.value().c_str()); - } - }); - break; - } - case SendspinTextMetadataTypes::ALBUM: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.album.has_value()) { - this->publish_if_changed_(metadata.album.value().c_str()); - } - }); - break; - } - case SendspinTextMetadataTypes::ALBUM_ARTIST: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.album_artist.has_value()) { - this->publish_if_changed_(metadata.album_artist.value().c_str()); - } - }); - break; - } - case SendspinTextMetadataTypes::YEAR: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.year.has_value() && metadata.year.value() <= 9999) { - char buf[UINT32_MAX_STR_SIZE]; - uint32_to_str(buf, metadata.year.value()); - this->publish_if_changed_(buf); - } - }); - break; - } - case SendspinTextMetadataTypes::TRACK: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.track.has_value() && metadata.track.value() <= 9999) { - char buf[UINT32_MAX_STR_SIZE]; - uint32_to_str(buf, metadata.track.value()); - this->publish_if_changed_(buf); - } - }); - break; - } - } + }); } // Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index d9ef49c938..203b01d024 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -7,6 +7,8 @@ #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/text_sensor/text_sensor.h" +#include + namespace esphome::sendspin_ { enum class SendspinTextMetadataTypes { @@ -14,8 +16,6 @@ enum class SendspinTextMetadataTypes { ARTIST, ALBUM, ALBUM_ARTIST, - YEAR, - TRACK, }; class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor { @@ -26,6 +26,7 @@ class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor void set_metadata_type(SendspinTextMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } protected: + const char *extract_value_(const sendspin::ServerMetadataStateObject &metadata) const; void publish_if_changed_(const char *value); SendspinTextMetadataTypes metadata_type_; diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml index 0bfbf45757..fc6a56a21a 100644 --- a/tests/components/sendspin/common-text_sensor.yaml +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -13,9 +13,3 @@ text_sensor: - platform: sendspin name: "Album Artist" type: album_artist - - platform: sendspin - name: "Year" - type: year - - platform: sendspin - name: "Track Number" - type: track From e23a6bf59f28b896b45da319d98086010e8ab683 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 11:23:16 -0500 Subject: [PATCH 0572/2214] [core] Inline micros()/millis_64() at the HAL layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-function ``__attribute__((optimize("O2")))`` approach in #15693 with a direct inline definition of micros() / millis_64() in hal.h for ESP32, plus inline definitions for ESP8266 micros() and RP2040 millis()/millis_64(). The original goal of #15693 was to inline micros() into the main loop so that ``call esphome::micros() → call esp_timer_get_time()`` collapses to a single ``call esp_timer_get_time``. That wrapper-collapse benefits every micros() call site, not just loop_task — so the real fix is to mark the wrapper inline, not to bump the loop's optimization level. Doing this at the HAL layer also makes runtime_stats measurements more accurate: each timing read no longer hides a wrapper call/return between the component end-time capture and the underlying clock read. Refactor: move ``micros_to_millis<>()`` from helpers.h to a new lightweight ``time_conversion.h`` so hal.h can include it without pulling the rest of helpers.h into every TU that includes hal.h. --- esphome/components/esp32/core.cpp | 17 +++++----- esphome/components/esp8266/core.cpp | 3 +- esphome/components/rp2040/core.cpp | 4 +-- esphome/core/hal.h | 43 +++++++++++++++++++++++++ esphome/core/helpers.h | 41 +++-------------------- esphome/core/time_conversion.h | 50 +++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 48 deletions(-) create mode 100644 esphome/core/time_conversion.h diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 1c63137183..9df358f2f9 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -37,15 +37,16 @@ uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); #endif } -// millis_64() stays on esp_timer — a different clock from xTaskGetTickCount(). This is -// safe because the two are never cross-compared: millis() values are only used for -// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), while -// millis_64() is used by the Scheduler and uptime sensors. On ESP32 (USE_NATIVE_64BIT_TIME), -// Scheduler::millis_64_from_(now) discards the 32-bit now and calls millis_64() directly, -// so the Scheduler is internally consistent on the esp_timer clock. -uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } +// millis_64() and micros() are defined inline in hal.h on ESP32. Both sit on +// esp_timer — a different clock from xTaskGetTickCount(). That is safe because +// the two are never cross-compared: millis() values are only used for +// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), +// while millis_64() is used by the Scheduler and uptime sensors. On ESP32 +// (USE_NATIVE_64BIT_TIME), Scheduler::millis_64_from_(now) discards the 32-bit +// now and calls millis_64() directly, so the Scheduler is internally consistent +// on the esp_timer clock. Inlining both collapses the wrapper call/return that +// runtime_stats and the main-loop hot path would otherwise incur every iteration. void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } -uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { esp_restart(); diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 159ec20e77..c08cce718f 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -19,7 +19,8 @@ void HOT yield() { ::yield(); } uint32_t IRAM_ATTR HOT millis() { return ::millis(); } uint64_t millis_64() { return Millis64Impl::compute(::millis()); } void HOT delay(uint32_t ms) { ::delay(ms); } -uint32_t IRAM_ATTR HOT micros() { return ::micros(); } +// micros() is defined inline in hal.h on ESP8266 so callers collapse the +// wrapper down to a direct ::micros() call. void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { system_restart(); diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index b7a9000612..74d33ec049 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -14,8 +14,8 @@ namespace esphome { void HOT yield() { ::yield(); } -uint64_t millis_64() { return micros_to_millis(time_us_64()); } -uint32_t HOT millis() { return micros_to_millis(time_us_64()); } +// millis() and millis_64() are defined inline in hal.h on RP2040 so callers +// collapse the wrapper down to a direct time_us_64() call. void HOT delay(uint32_t ms) { ::delay(ms); } uint32_t HOT micros() { return ::micros(); } void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index e4083622b9..bd8792e0ed 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -2,6 +2,7 @@ #include #include #include "gpio.h" +#include "esphome/core/time_conversion.h" #if defined(USE_ESP32) #include @@ -70,6 +71,18 @@ extern "C" uint32_t platform_is_in_interrupt_context(void); #endif +#ifdef USE_ESP8266 +// Forward-declared from (esp8266 core) so the inline esphome::micros() +// wrapper below can call it without pulling Arduino.h into every TU. +extern "C" unsigned long micros(void); // NOLINT(google-runtime-int,readability-identifier-naming) +#endif + +#ifdef USE_RP2040 +// Forward-declared from so the inline esphome::millis()/millis_64() +// wrappers below can call it without pulling pico/time.h into every TU. +extern "C" uint64_t time_us_64(void); +#endif + namespace esphome { /// Returns true when executing inside an interrupt handler. @@ -103,9 +116,39 @@ __attribute__((always_inline)) inline bool in_isr_context() { } void yield(); +#if defined(USE_ESP32) +// Forward-declared from to avoid pulling the full header (and its +// transitive IDF deps) into every translation unit that includes hal.h. Signature +// is stable IDF public API. +extern "C" int64_t esp_timer_get_time(void); + +uint32_t millis(); +// Inlined so callers (especially the main loop via runtime_stats) collapse the +// wrapper: ``call micros → call esp_timer_get_time`` becomes a single +// ``call esp_timer_get_time``. Inlining into an IRAM_ATTR ISR is safe because +// ``esp_timer_get_time`` itself lives in IRAM (IDF marks it so). +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(esp_timer_get_time()); } +__attribute__((always_inline)) inline uint64_t millis_64() { + return micros_to_millis(static_cast(esp_timer_get_time())); +} +#elif defined(USE_ESP8266) +// Arduino's ::micros() (esp8266 core) is itself in IRAM, so inlining our wrapper +// into IRAM_ATTR ISRs is safe. Forward-declared at global scope below to avoid +// pulling Arduino.h into every TU that includes hal.h. +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } uint32_t millis(); uint64_t millis_64(); +#elif defined(USE_RP2040) uint32_t micros(); +// Pico SDK clock used by ::millis()/time_us_64() under the hood; forward-declared +// at global scope below. +__attribute__((always_inline)) inline uint32_t millis() { return micros_to_millis(::time_us_64()); } +__attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis(::time_us_64()); } +#else +uint32_t millis(); +uint32_t micros(); +uint64_t millis_64(); +#endif void delay(uint32_t ms); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void __attribute__((noreturn)) arch_restart(); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 4a91c46074..366ae6da9e 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -20,6 +20,7 @@ #include #include "esphome/core/optional.h" +#include "esphome/core/time_conversion.h" // Backward compatibility re-export of heap-allocating helpers. // These functions have moved to alloc_helpers.h. External components should @@ -833,43 +834,9 @@ template constexpr uint32_t fnv1a_hash_extend(uint32_t hash, T constexpr uint32_t fnv1a_hash(const char *str) { return fnv1a_hash_extend(FNV1_OFFSET_BASIS, str); } inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str()); } -/// Convert a 64-bit microsecond count to milliseconds without calling -/// __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz). -/// -/// Returns uint32_t by default (for millis()), or uint64_t when requested -/// (for millis_64()). The only difference is whether hi * Q is truncated -/// to 32 bits or widened to 64. -/// -/// On 32-bit targets, GCC does not optimize 64-bit constant division into a -/// multiply-by-reciprocal. Since 1000 = 8 * 125, we first right-shift by 3 -/// (free divide-by-8), then use the Euclidean division identity to decompose -/// the remaining 64-bit divide-by-125 into a single 32-bit division: -/// -/// floor(us / 1000) = floor(floor(us / 8) / 125) [exact for integers] -/// 2^32 = Q * 125 + R (34359738 * 125 + 46) -/// (hi * 2^32 + lo) / 125 = hi * Q + (hi * R + lo) / 125 -/// -/// GCC optimizes the remaining 32-bit "/ 125U" into a multiply-by-reciprocal -/// (mulhu + shift), so no division instruction is emitted. -/// -/// Safe for us up to ~3.2e18 (~101,700 years of microseconds). -/// -/// See: https://en.wikipedia.org/wiki/Euclidean_division -/// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html -template inline constexpr ESPHOME_ALWAYS_INLINE ReturnT micros_to_millis(uint64_t us) { - constexpr uint32_t d = 125U; - constexpr uint32_t q = static_cast((1ULL << 32) / d); // 34359738 - constexpr uint32_t r = static_cast((1ULL << 32) % d); // 46 - // 1000 = 8 * 125; divide-by-8 is a free shift - uint64_t x = us >> 3; - uint32_t lo = static_cast(x); - uint32_t hi = static_cast(x >> 32); - // Combine remainder term: hi * (2^32 % 125) + lo - uint32_t adj = hi * r + lo; - // If adj overflowed, the true value is 2^32 + adj; apply the identity again - // static_cast(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q - return static_cast(hi) * q + (adj < lo ? (adj + r) / d + q : adj / d); -} +// micros_to_millis<>() lives in its own lightweight header so hal.h can pull it +// in for inline millis_64() without forcing every TU that includes hal.h to +// also include the rest of helpers.h. /// Return a random 32-bit unsigned integer. /// Not thread-safe. Must only be called from the main loop. diff --git a/esphome/core/time_conversion.h b/esphome/core/time_conversion.h new file mode 100644 index 0000000000..d9bee0912b --- /dev/null +++ b/esphome/core/time_conversion.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +namespace esphome { + +/// Convert a 64-bit microsecond count to milliseconds without calling +/// __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz). +/// +/// Returns uint32_t by default (for millis()), or uint64_t when requested +/// (for millis_64()). The only difference is whether hi * Q is truncated +/// to 32 bits or widened to 64. +/// +/// On 32-bit targets, GCC does not optimize 64-bit constant division into a +/// multiply-by-reciprocal. Since 1000 = 8 * 125, we first right-shift by 3 +/// (free divide-by-8), then use the Euclidean division identity to decompose +/// the remaining 64-bit divide-by-125 into a single 32-bit division: +/// +/// floor(us / 1000) = floor(floor(us / 8) / 125) [exact for integers] +/// 2^32 = Q * 125 + R (34359738 * 125 + 46) +/// (hi * 2^32 + lo) / 125 = hi * Q + (hi * R + lo) / 125 +/// +/// GCC optimizes the remaining 32-bit "/ 125U" into a multiply-by-reciprocal +/// (mulhu + shift), so no division instruction is emitted. +/// +/// Safe for us up to ~3.2e18 (~101,700 years of microseconds). +/// +/// Lives in its own header (rather than helpers.h) so hal.h can include it +/// for inline millis_64() definitions without dragging the rest of helpers.h +/// into every translation unit. +/// +/// See: https://en.wikipedia.org/wiki/Euclidean_division +/// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html +template +__attribute__((always_inline)) inline constexpr ReturnT micros_to_millis(uint64_t us) { + constexpr uint32_t d = 125U; + constexpr uint32_t q = static_cast((1ULL << 32) / d); // 34359738 + constexpr uint32_t r = static_cast((1ULL << 32) % d); // 46 + // 1000 = 8 * 125; divide-by-8 is a free shift + uint64_t x = us >> 3; + uint32_t lo = static_cast(x); + uint32_t hi = static_cast(x >> 32); + // Combine remainder term: hi * (2^32 % 125) + lo + uint32_t adj = hi * r + lo; + // If adj overflowed, the true value is 2^32 + adj; apply the identity again + // static_cast(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q + return static_cast(hi) * q + (adj < lo ? (adj + r) / d + q : adj / d); +} + +} // namespace esphome From 9d138e73c93b38208bf20ea755db7b54f60e8c9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 11:35:03 -0500 Subject: [PATCH 0573/2214] [core] Suppress redundant-declaration warning for ESP8266 micros() forward decl --- esphome/core/hal.h | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index bd8792e0ed..fab7461ed1 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -73,8 +73,10 @@ extern "C" uint32_t platform_is_in_interrupt_context(void); #ifdef USE_ESP8266 // Forward-declared from (esp8266 core) so the inline esphome::micros() -// wrapper below can call it without pulling Arduino.h into every TU. -extern "C" unsigned long micros(void); // NOLINT(google-runtime-int,readability-identifier-naming) +// wrapper below can call it without pulling Arduino.h into every TU. The +// redundant-declaration NOLINT covers TUs that also include Arduino.h directly. +// NOLINTNEXTLINE(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +extern "C" unsigned long micros(void); #endif #ifdef USE_RP2040 From d3bae21d1341eeaf801c8ee1b23789548ceef8d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 11:51:23 -0500 Subject: [PATCH 0574/2214] [core] Extend HAL inlining to yield/delay/millis_64 + libretiny + rp2040 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the prior commit to cover more wrappers and platforms: - ESP32: also inline yield() and delay() - ESP8266: also inline yield(), delay(), millis(), millis_64() - LibreTiny: inline yield(), delay(), micros(), per-variant millis() fast paths, and millis_64() (via Millis64Impl::compute, now reachable from hal.h since time_64.h dropped its helpers.h dep) - RP2040: also inline yield(), delay(), micros() Consolidates the ESP8266/LibreTiny/RP2040 Arduino-flavored ::yield / ::delay / ::micros wrappers into a single shared block in hal.h. LibreTiny note: the prior IRAM_ATTR on the wrapper was decorative — ::micros(), ::yield(), ::delay() and xTaskGetTickCount all live in flash on every libretiny family (realtek-amb, beken-72xx, lightning-ln882h all checked), so an IRAM ISR call would have crashed the same way an inlined direct call does. Also drops the helpers.h include from time_64.h (only used for the ESPHOME_ALWAYS_INLINE macro, replaced with the raw attribute) so time_64.h is light enough for hal.h to include. --- esphome/components/esp32/core.cpp | 12 +--- esphome/components/esp8266/core.cpp | 7 +-- esphome/components/libretiny/core.cpp | 28 +--------- esphome/components/rp2040/core.cpp | 6 +- esphome/core/hal.h | 79 +++++++++++++++++---------- esphome/core/time_64.h | 6 +- esphome/core/time_conversion.h | 4 -- 7 files changed, 57 insertions(+), 85 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 9df358f2f9..4886745c06 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -22,7 +22,7 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { -void HOT yield() { vPortYield(); } +// yield(), delay(), micros(), millis_64() inlined in hal.h. // Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig), // falling back to esp_timer for non-standard rates. IRAM_ATTR is required because // Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32. @@ -37,16 +37,6 @@ uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); #endif } -// millis_64() and micros() are defined inline in hal.h on ESP32. Both sit on -// esp_timer — a different clock from xTaskGetTickCount(). That is safe because -// the two are never cross-compared: millis() values are only used for -// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), -// while millis_64() is used by the Scheduler and uptime sensors. On ESP32 -// (USE_NATIVE_64BIT_TIME), Scheduler::millis_64_from_(now) discards the 32-bit -// now and calls millis_64() directly, so the Scheduler is internally consistent -// on the esp_timer clock. Inlining both collapses the wrapper call/return that -// runtime_stats and the main-loop hot path would otherwise incur every iteration. -void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { esp_restart(); diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index c08cce718f..27f9c2393f 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -15,12 +15,7 @@ extern "C" { namespace esphome { -void HOT yield() { ::yield(); } -uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -uint64_t millis_64() { return Millis64Impl::compute(::millis()); } -void HOT delay(uint32_t ms) { ::delay(ms); } -// micros() is defined inline in hal.h on ESP8266 so callers collapse the -// wrapper down to a direct ::micros() call. +// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { system_restart(); diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index ca46bcb899..f46abe3b81 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -3,7 +3,6 @@ #include "core.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#include "esphome/core/time_64.h" #include "esphome/core/helpers.h" #include "preferences.h" @@ -15,32 +14,7 @@ void loop(); namespace esphome { -void HOT yield() { ::yield(); } -// Inline the tick read so esphome::millis() matches MillisInternal::get()'s fast -// path instead of going through the Arduino core's out-of-line ::millis() wrapper. -// -// RTL87xx / LN882x (1 kHz): xTaskGetTickCount() is already ms. IRAM_ATTR + ISR -// dispatch are needed because ISR handlers (e.g. rotary_encoder) call millis(). -// -// BK72xx (500 Hz): ticks * portTICK_PERIOD_MS (== 2). IRAM_ATTR and ISR dispatch -// are both unnecessary — the SDK masks FIQ + IRQ during flash writes (see hal.h), -// so no ISR runs while flash is stalled. -#if defined(USE_RTL87XX) || defined(USE_LN882X) -uint32_t IRAM_ATTR HOT millis() { - static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); - return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount(); -} -#elif defined(USE_BK72XX) -uint32_t HOT millis() { - static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick"); - return xTaskGetTickCount() * portTICK_PERIOD_MS; -} -#else -uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -#endif -uint64_t millis_64() { return Millis64Impl::compute(millis()); } -uint32_t IRAM_ATTR HOT micros() { return ::micros(); } -void HOT delay(uint32_t ms) { ::delay(ms); } +// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } void arch_init() { diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 74d33ec049..d3dc1cf2bb 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -13,11 +13,7 @@ namespace esphome { -void HOT yield() { ::yield(); } -// millis() and millis_64() are defined inline in hal.h on RP2040 so callers -// collapse the wrapper down to a direct time_us_64() call. -void HOT delay(uint32_t ms) { ::delay(ms); } -uint32_t HOT micros() { return ::micros(); } +// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { watchdog_reboot(0, 0, 10); diff --git a/esphome/core/hal.h b/esphome/core/hal.h index fab7461ed1..c039eba19a 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -2,6 +2,8 @@ #include #include #include "gpio.h" +#include "esphome/core/defines.h" +#include "esphome/core/time_64.h" #include "esphome/core/time_conversion.h" #if defined(USE_ESP32) @@ -63,6 +65,12 @@ #include #endif +#ifdef USE_LIBRETINY +// For the inline millis() fast paths (xTaskGetTickCount, portTICK_PERIOD_MS). +#include +#include +#endif + #ifdef USE_BK72XX // Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so // it is callable from Thumb code via interworking. The MRS CPSR instruction @@ -71,17 +79,19 @@ extern "C" uint32_t platform_is_in_interrupt_context(void); #endif -#ifdef USE_ESP8266 -// Forward-declared from (esp8266 core) so the inline esphome::micros() -// wrapper below can call it without pulling Arduino.h into every TU. The -// redundant-declaration NOLINT covers TUs that also include Arduino.h directly. -// NOLINTNEXTLINE(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +// Forward decls from Arduino's for the inline wrappers below. +// NOLINT covers TUs that also include Arduino.h. +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) +// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +extern "C" void yield(void); +extern "C" void delay(unsigned long ms); extern "C" unsigned long micros(void); +extern "C" unsigned long millis(void); +// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) #endif #ifdef USE_RP2040 -// Forward-declared from so the inline esphome::millis()/millis_64() -// wrappers below can call it without pulling pico/time.h into every TU. +// Forward decl from . extern "C" uint64_t time_us_64(void); #endif @@ -117,41 +127,52 @@ __attribute__((always_inline)) inline bool in_isr_context() { #endif } -void yield(); +// yield()/delay()/micros()/millis()/millis_64() are inlined per platform to +// drop the wrapper call/return — most relevant to runtime_stats and the main +// loop. ESP8266/LibreTiny/RP2040 share Arduino's ::yield/::delay/::micros. #if defined(USE_ESP32) -// Forward-declared from to avoid pulling the full header (and its -// transitive IDF deps) into every translation unit that includes hal.h. Signature -// is stable IDF public API. +// Forward decl from . extern "C" int64_t esp_timer_get_time(void); - -uint32_t millis(); -// Inlined so callers (especially the main loop via runtime_stats) collapse the -// wrapper: ``call micros → call esp_timer_get_time`` becomes a single -// ``call esp_timer_get_time``. Inlining into an IRAM_ATTR ISR is safe because -// ``esp_timer_get_time`` itself lives in IRAM (IDF marks it so). +__attribute__((always_inline)) inline void yield() { vPortYield(); } +__attribute__((always_inline)) inline void delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(esp_timer_get_time()); } +uint32_t millis(); __attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } -#elif defined(USE_ESP8266) -// Arduino's ::micros() (esp8266 core) is itself in IRAM, so inlining our wrapper -// into IRAM_ATTR ISRs is safe. Forward-declared at global scope below to avoid -// pulling Arduino.h into every TU that includes hal.h. +#elif defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) +__attribute__((always_inline)) inline void yield() { ::yield(); } +__attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } -uint32_t millis(); -uint64_t millis_64(); -#elif defined(USE_RP2040) -uint32_t micros(); -// Pico SDK clock used by ::millis()/time_us_64() under the hood; forward-declared -// at global scope below. +#if defined(USE_ESP8266) +__attribute__((always_inline)) inline uint32_t millis() { return static_cast(::millis()); } +__attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); } +#elif defined(USE_LIBRETINY) +// Per-variant millis() fast path — matches MillisInternal::get(). +#if defined(USE_RTL87XX) || defined(USE_LN882X) +static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); +__attribute__((always_inline)) inline uint32_t millis() { + // xTaskGetTickCountFromISR is mandatory in interrupt context per the FreeRTOS API contract. + return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount(); +} +#elif defined(USE_BK72XX) +static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick"); +__attribute__((always_inline)) inline uint32_t millis() { return xTaskGetTickCount() * portTICK_PERIOD_MS; } +#else +__attribute__((always_inline)) inline uint32_t millis() { return static_cast(::millis()); } +#endif +__attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); } +#else // USE_RP2040 __attribute__((always_inline)) inline uint32_t millis() { return micros_to_millis(::time_us_64()); } __attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis(::time_us_64()); } +#endif #else -uint32_t millis(); +void yield(); +void delay(uint32_t ms); uint32_t micros(); +uint32_t millis(); uint64_t millis_64(); #endif -void delay(uint32_t ms); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void __attribute__((noreturn)) arch_restart(); void arch_init(); diff --git a/esphome/core/time_64.h b/esphome/core/time_64.h index 592e645d41..b86070cc76 100644 --- a/esphome/core/time_64.h +++ b/esphome/core/time_64.h @@ -6,8 +6,6 @@ #include #include -#include "esphome/core/helpers.h" - namespace esphome { class Scheduler; @@ -24,7 +22,9 @@ class Millis64Impl { static uint32_t last_millis_; static uint16_t millis_major_; - static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) { + // Raw __attribute__((always_inline)) (not ESPHOME_ALWAYS_INLINE) so this + // header does not need to pull helpers.h. + static inline uint64_t __attribute__((always_inline)) compute(uint32_t now) { // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; diff --git a/esphome/core/time_conversion.h b/esphome/core/time_conversion.h index d9bee0912b..e9060c0626 100644 --- a/esphome/core/time_conversion.h +++ b/esphome/core/time_conversion.h @@ -25,10 +25,6 @@ namespace esphome { /// /// Safe for us up to ~3.2e18 (~101,700 years of microseconds). /// -/// Lives in its own header (rather than helpers.h) so hal.h can include it -/// for inline millis_64() definitions without dragging the rest of helpers.h -/// into every translation unit. -/// /// See: https://en.wikipedia.org/wiki/Euclidean_division /// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html template From 9caf9ee02336fb7754ead08a11fd2da10c77d74a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 12:53:03 -0400 Subject: [PATCH 0575/2214] [sendspin] Bumps sendspin-cpp library for a bugfix (#15976) --- 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 6f5ccddb86..58687ae838 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -193,7 +193,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f422d94097..11531e6d7b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -92,6 +92,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.3.0 + version: 0.3.1 lvgl/lvgl: version: 9.5.0 From 47b998d7f52383123e2b7850564602564b416eda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 11:55:14 -0500 Subject: [PATCH 0576/2214] [core] Drop -O2 attribute hack in favor of inline HAL wrappers (will merge inline-micros-esp32 next) --- esphome/components/esp32/core.cpp | 3 +-- esphome/core/application.h | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 0e114d0933..1c63137183 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -84,8 +84,7 @@ static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non- static StackType_t loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -void __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -loop_task(void *pv_params) { +void loop_task(void *pv_params) { setup(); while (true) { App.loop(); diff --git a/esphome/core/application.h b/esphome/core/application.h index e55e86f69f..5557889786 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -546,8 +546,7 @@ inline ESPHOME_ALWAYS_INLINE Application::ComponentPhaseGuard::ComponentPhaseGua this->app_.in_loop_ = true; } -// NOLINTNEXTLINE(clang-diagnostic-unknown-attributes) -inline void ESPHOME_ALWAYS_INLINE __attribute__((optimize("O2"))) Application::loop() { +inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifdef USE_RUNTIME_STATS // Capture the start of the active (non-sleeping) portion of this iteration. // Used to derive main-loop overhead = active time − Σ(component time) − From a1e3ec7118d0017eec7efa62e778173a758a25d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:02:57 -0500 Subject: [PATCH 0577/2214] [core] Keep esp8266 millis/delay out-of-line for #15662 compatibility --- esphome/components/esp8266/core.cpp | 4 +++- esphome/core/hal.h | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 27f9c2393f..389560cb1a 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -15,7 +15,9 @@ extern "C" { namespace esphome { -// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. +// yield(), micros(), millis_64() inlined in hal.h. +uint32_t IRAM_ATTR HOT millis() { return ::millis(); } +void HOT delay(uint32_t ms) { ::delay(ms); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { system_restart(); diff --git a/esphome/core/hal.h b/esphome/core/hal.h index c039eba19a..66b4f72d4b 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -142,12 +142,13 @@ __attribute__((always_inline)) inline uint64_t millis_64() { } #elif defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) __attribute__((always_inline)) inline void yield() { ::yield(); } -__attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } #if defined(USE_ESP8266) -__attribute__((always_inline)) inline uint32_t millis() { return static_cast(::millis()); } +void delay(uint32_t ms); +uint32_t millis(); __attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); } #elif defined(USE_LIBRETINY) +__attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } // Per-variant millis() fast path — matches MillisInternal::get(). #if defined(USE_RTL87XX) || defined(USE_LN882X) static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); From bca5bc08a76536b48f522e408d2e9a8530967a4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:08:11 -0500 Subject: [PATCH 0578/2214] [core] Remove leftover -O2 attributes (writer.py loop, host/zephyr main) --- esphome/components/host/core.cpp | 3 +-- esphome/components/zephyr/core.cpp | 3 +-- esphome/writer.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 4217804516..0ade4274fe 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -77,8 +77,7 @@ uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } void setup(); void loop(); -int __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -main() { +int main() { // Install signal handlers for graceful shutdown (flushes preferences to disk) std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index 6da282866d..93a9a1ae8e 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -95,8 +95,7 @@ void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parame void setup(); void loop(); -int __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) -main() { +int main() { setup(); while (true) { loop(); diff --git a/esphome/writer.py b/esphome/writer.py index 277b65e46d..787ecac6f6 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -45,7 +45,7 @@ void setup() { App.setup(); } -void __attribute__((optimize("O2"))) loop() { +void loop() { App.loop(); } """, From f446c0e699fc3e69b2d193f823443aadedde7871 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:32:55 -0500 Subject: [PATCH 0579/2214] [core] Split wake.{h,cpp} into per-platform files under esphome/core/wake/ Move each platform branch out of wake.h/wake.cpp into its own esphome/core/wake/wake_.{h,cpp} pair (wake_freertos for ESP32/LibreTiny, plus wake_esp8266, wake_rp2040, wake_host, and wake_generic for the Zephyr/NRF52 fallback). wake.h becomes a thin dispatcher that defines only the cross-platform wake_request_set/ wake_request_take helpers and then #include's the right platform header based on USE_*. wake.cpp is removed; each platform's g_wake_requested (and g_main_loop_woke where applicable) now lives in the translation unit that actually uses it, guarded by the same USE_* check. Because esphome/core/ source discovery was previously flat, add an opt-in recursive_sources flag to ComponentManifest and enable it only on the core ("esphome") manifest. Subdirectories that are themselves Python subpackages are still skipped so components (which already register platform subpackages independently) are unaffected. --- esphome/core/wake.h | 214 ++---------------- esphome/core/wake/wake_esp8266.cpp | 21 ++ esphome/core/wake/wake_esp8266.h | 47 ++++ esphome/core/wake/wake_freertos.cpp | 33 +++ esphome/core/wake/wake_freertos.h | 60 +++++ esphome/core/wake/wake_generic.cpp | 17 ++ esphome/core/wake/wake_generic.h | 31 +++ esphome/core/{wake.cpp => wake/wake_host.cpp} | 89 +------- esphome/core/wake/wake_host.h | 64 ++++++ esphome/core/wake/wake_rp2040.cpp | 58 +++++ esphome/core/wake/wake_rp2040.h | 31 +++ esphome/loader.py | 50 ++-- 12 files changed, 422 insertions(+), 293 deletions(-) create mode 100644 esphome/core/wake/wake_esp8266.cpp create mode 100644 esphome/core/wake/wake_esp8266.h create mode 100644 esphome/core/wake/wake_freertos.cpp create mode 100644 esphome/core/wake/wake_freertos.h create mode 100644 esphome/core/wake/wake_generic.cpp create mode 100644 esphome/core/wake/wake_generic.h rename esphome/core/{wake.cpp => wake/wake_host.cpp} (74%) create mode 100644 esphome/core/wake/wake_host.h create mode 100644 esphome/core/wake/wake_rp2040.cpp create mode 100644 esphome/core/wake/wake_rp2040.h diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 0cfca94a78..a2f732fcdb 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -3,6 +3,10 @@ /// @file wake.h /// Platform-specific main loop wake primitives. /// Always available on all platforms — no opt-in needed. +/// +/// The public API for callers lives here; the per-platform implementations +/// live under esphome/core/wake/ and are included at the bottom of this file +/// based on the active USE_* platform define. #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -11,21 +15,6 @@ #include #endif -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -#include "esphome/core/main_task.h" -#endif -#ifdef USE_ESP8266 -#include -#elif defined(USE_RP2040) -#include -#include -#endif - -#ifdef USE_HOST -#include -#include -#endif - namespace esphome { // === Wake flag for ESP8266/RP2040 === @@ -67,184 +56,19 @@ __attribute__((always_inline)) inline bool wake_request_take() { } #endif -// === ESP32 / LibreTiny (FreeRTOS) === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) - -/// Wake the main loop from any context (ISR or task). -/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. -__attribute__((always_inline)) inline void wake_main_task_any_context() { - // Set the wake-requested flag BEFORE the task notification so the consumer - // (Application::loop() gate) is guaranteed to see it on its next gate check. - wake_request_set(); - if (in_isr_context()) { - BaseType_t px_higher_priority_task_woken = pdFALSE; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); -#ifdef portYIELD_FROM_ISR - portYIELD_FROM_ISR(px_higher_priority_task_woken); -#else - // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ - // exit sequence performs the context switch if one was requested. - (void) px_higher_priority_task_woken; -#endif - } else { - esphome_main_task_notify(); - } -} - -/// IRAM_ATTR entry points — defined in wake.cpp. -void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -void wake_loop_any_context(); - -inline void wake_loop_threadsafe() { - wake_request_set(); - esphome_main_task_notify(); -} - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip - // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: - // wake_loop_threadsafe() from background tasks, and the ms timeout. - if (ms == 0) [[unlikely]] { - yield(); - return; - } - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); -} -} // namespace internal - -// === ESP8266 === -#elif defined(USE_ESP8266) - -/// Inline implementation — IRAM callers inline this directly. -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(); - g_main_loop_woke = true; - esp_schedule(); -} - -/// IRAM_ATTR entry point for ISR callers — defined in wake.cpp. -void wake_loop_any_context(); - -/// Non-ISR: always inline. -inline void wake_loop_threadsafe() { wake_loop_impl(); } - -/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. -inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - delay(0); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - esp_delay(ms, []() { return !g_main_loop_woke; }); -} -} // namespace internal - -// === RP2040 === -#elif defined(USE_RP2040) - -inline void wake_loop_any_context() { - // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed - // to see it on its next gate check. - wake_request_set(); - g_main_loop_woke = true; - __sev(); -} - -inline void wake_loop_threadsafe() { wake_loop_any_context(); } - -/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake.cpp. -namespace internal { -void wakeable_delay(uint32_t ms); -} // namespace internal - -// === Host / Zephyr / other === -#else - -#ifdef USE_HOST -/// Host: wakes select() via UDP loopback socket. Defined in wake.cpp. -void wake_loop_threadsafe(); - -/// Register a socket file descriptor with the host select() loop. Not -/// thread-safe — main loop only. Returns false if fd is invalid or -/// >= FD_SETSIZE. -bool wake_register_fd(int fd); - -/// Unregister a socket file descriptor. Not thread-safe — main loop only. -void wake_unregister_fd(int fd); - -/// One-time setup of the loopback wake socket. Called from Application::setup(). -void wake_setup(); - -// wake_fd_ready() and wake_drain_notifications() are defined inline at the -// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in -// scope, which depend on USE_HOST-only includes pulled in above. -#else -/// Zephyr is currently the only platform without a wake mechanism. -/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). -/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. -inline void wake_loop_threadsafe() {} -#endif - -inline void wake_loop_any_context() { wake_loop_threadsafe(); } - -namespace internal { -#ifdef USE_HOST -/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp. -void wakeable_delay(uint32_t ms); -#else -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - delay(ms); -} -#endif -} // namespace internal - -#endif - -#ifdef USE_HOST -namespace internal { -// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications() -// and wake_fd_ready() so the hot path stays in the header. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern int g_wake_socket_fd; -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern fd_set g_read_fds; -} // namespace internal - -inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } - -// Small buffer for draining wake notification bytes (1 byte sent per wake). -// Sized to drain multiple notifications per recvfrom() without wasting stack. -inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; - -inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { - // Called from main loop to drain any pending wake notifications. - // Must check wake_fd_ready() to avoid blocking on empty socket. - if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { - char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; - // Drain all pending notifications with non-blocking reads. Multiple wake events - // may have triggered multiple writes, so drain until EWOULDBLOCK. We control - // both ends of this loopback socket (always 1 byte per wake), so no error - // checking — any error indicates catastrophic system failure. - while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - } - } -} -#endif // USE_HOST - } // namespace esphome + +// Per-platform implementations. Each header re-enters namespace esphome {} and +// guards its body with the matching USE_* check, so only one contributes code +// for the active target. +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "esphome/core/wake/wake_freertos.h" +#elif defined(USE_ESP8266) +#include "esphome/core/wake/wake_esp8266.h" +#elif defined(USE_RP2040) +#include "esphome/core/wake/wake_rp2040.h" +#elif defined(USE_HOST) +#include "esphome/core/wake/wake_host.h" +#else +#include "esphome/core/wake/wake_generic.h" +#endif diff --git a/esphome/core/wake/wake_esp8266.cpp b/esphome/core/wake/wake_esp8266.cpp new file mode 100644 index 0000000000..9ced43c6df --- /dev/null +++ b/esphome/core/wake/wake_esp8266.cpp @@ -0,0 +1,21 @@ +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// ESP8266 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h new file mode 100644 index 0000000000..80cd61035b --- /dev/null +++ b/esphome/core/wake/wake_esp8266.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" + +#include + +namespace esphome { + +/// Inline implementation — IRAM callers inline this directly. +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(); + g_main_loop_woke = true; + esp_schedule(); +} + +/// IRAM_ATTR entry point for ISR callers — defined in wake_esp8266.cpp. +void wake_loop_any_context(); + +/// Non-ISR: always inline. +inline void wake_loop_threadsafe() { wake_loop_impl(); } + +/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. +inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + delay(0); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + esp_delay(ms, []() { return !g_main_loop_woke; }); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp new file mode 100644 index 0000000000..0bf700daa8 --- /dev/null +++ b/esphome/core/wake/wake_freertos.cpp @@ -0,0 +1,33 @@ +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// ESP32 is always MULTI_ATOMICS; LibreTiny is MULTI_ATOMICS on chips with +// proper atomics (e.g. RTL8720) and MULTI_NO_ATOMICS on others (e.g. BK72XX). +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic g_wake_requested{0}; +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +#endif + +void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + // ISR-safe: set flag before notify so the wake is visible on the next gate + // check. wake_request_set() is just an aligned 8-bit store / atomic store + // and is safe from IRAM. + wake_request_set(); + esphome_main_task_notify_from_isr(px_higher_priority_task_woken); +} + +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h new file mode 100644 index 0000000000..167a422c61 --- /dev/null +++ b/esphome/core/wake/wake_freertos.h @@ -0,0 +1,60 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/main_task.h" + +namespace esphome { + +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + // Set the wake-requested flag BEFORE the task notification so the consumer + // (Application::loop() gate) is guaranteed to see it on its next gate check. + wake_request_set(); + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR + portYIELD_FROM_ISR(px_higher_priority_task_woken); +#else + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; +#endif + } else { + esphome_main_task_notify(); + } +} + +/// IRAM_ATTR entry points — defined in wake_freertos.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +void wake_loop_any_context(); + +inline void wake_loop_threadsafe() { + wake_request_set(); + esphome_main_task_notify(); +} + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the ms timeout. + if (ms == 0) [[unlikely]] { + yield(); + return; + } + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_generic.cpp b/esphome/core/wake/wake_generic.cpp new file mode 100644 index 0000000000..40044e4311 --- /dev/null +++ b/esphome/core/wake/wake_generic.cpp @@ -0,0 +1,17 @@ +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ + !defined(USE_HOST) + +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; + +} // namespace esphome + +#endif // fallback guard diff --git a/esphome/core/wake/wake_generic.h b/esphome/core/wake/wake_generic.h new file mode 100644 index 0000000000..85424b6138 --- /dev/null +++ b/esphome/core/wake/wake_generic.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ + !defined(USE_HOST) + +#include "esphome/core/hal.h" + +namespace esphome { + +/// Zephyr is currently the only platform without a wake mechanism. +/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). +/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. +inline void wake_loop_threadsafe() {} + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + delay(ms); +} +} // namespace internal + +} // namespace esphome + +#endif // fallback guard diff --git a/esphome/core/wake.cpp b/esphome/core/wake/wake_host.cpp similarity index 74% rename from esphome/core/wake.cpp rename to esphome/core/wake/wake_host.cpp index cac88ae91e..9d2a650ca2 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -1,12 +1,11 @@ -#include "esphome/core/wake.h" -#include "esphome/core/hal.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP8266 -#include -#endif +#include "esphome/core/defines.h" #ifdef USE_HOST + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/wake.h" + #include #include #include @@ -15,88 +14,19 @@ #include #include #include -#endif namespace esphome { // === Wake-requested flag storage === -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// Host is always ESPHOME_THREAD_MULTI_ATOMICS. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -#else -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile uint8_t g_wake_requested = 0; -#endif - -// === ESP32 / LibreTiny — IRAM_ATTR entry points === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { - // ISR-safe: set flag before notify so the wake is visible on the next gate - // check. wake_request_set() is just an aligned 8-bit store / atomic store - // and is safe from IRAM. - wake_request_set(); - esphome_main_task_notify_from_isr(px_higher_priority_task_woken); -} -void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } -#endif - -// === ESP8266 / RP2040 === -#if defined(USE_ESP8266) || defined(USE_RP2040) -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile bool g_main_loop_woke = false; -#endif - -#ifdef USE_ESP8266 -void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } -#endif - -// === RP2040 — wakeable_delay (needs file-scope state for alarm callback) === -#ifdef USE_RP2040 -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -static volatile bool s_delay_expired = false; - -static int64_t alarm_callback_(alarm_id_t id, void *user_data) { - (void) id; - (void) user_data; - s_delay_expired = true; - __sev(); - return 0; -} - -namespace internal { -void wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - s_delay_expired = false; - alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); - if (alarm <= 0) { - delay(ms); - return; - } - while (!g_main_loop_woke && !s_delay_expired) { - __wfe(); - } - if (!s_delay_expired) - cancel_alarm(alarm); - g_main_loop_woke = false; -} -} // namespace internal -#endif // USE_RP2040 - -// === Host (UDP loopback socket + select() based fd watcher) === -#ifdef USE_HOST static const char *const TAG = "wake"; namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and -// wake_fd_ready() in wake.h, and by the bodies in this file. +// wake_fd_ready() in wake_host.h, and by the bodies in this file. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) int g_wake_socket_fd = -1; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) @@ -271,6 +201,7 @@ void wake_setup() { return; } } -#endif // USE_HOST } // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_host.h b/esphome/core/wake/wake_host.h new file mode 100644 index 0000000000..9756ed4c39 --- /dev/null +++ b/esphome/core/wake/wake_host.h @@ -0,0 +1,64 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_HOST + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +/// Host: wakes select() via UDP loopback socket. Defined in wake_host.cpp. +void wake_loop_threadsafe(); + +/// Register a socket file descriptor with the host select() loop. Not +/// thread-safe — main loop only. Returns false if fd is invalid or +/// >= FD_SETSIZE. +bool wake_register_fd(int fd); + +/// Unregister a socket file descriptor. Not thread-safe — main loop only. +void wake_unregister_fd(int fd); + +/// One-time setup of the loopback wake socket. Called from Application::setup(). +void wake_setup(); + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +/// Host wakeable_delay uses select() over the registered fds — defined in wake_host.cpp. +void wakeable_delay(uint32_t ms); + +// File-scope state owned by wake_host.cpp. Accessed inline by +// wake_drain_notifications() and wake_fd_ready() so the hot path stays in the header. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern int g_wake_socket_fd; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern fd_set g_read_fds; +} // namespace internal + +inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } + +// Small buffer for draining wake notification bytes (1 byte sent per wake). +// Sized to drain multiple notifications per recvfrom() without wasting stack. +inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; + +inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { + // Called from main loop to drain any pending wake notifications. + // Must check wake_fd_ready() to avoid blocking on empty socket. + if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { + char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads. Multiple wake events + // may have triggered multiple writes, so drain until EWOULDBLOCK. We control + // both ends of this loopback socket (always 1 byte per wake), so no error + // checking — any error indicates catastrophic system failure. + while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + } + } +} + +} // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp new file mode 100644 index 0000000000..b18248dbd2 --- /dev/null +++ b/esphome/core/wake/wake_rp2040.cpp @@ -0,0 +1,58 @@ +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +#include +#include + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// RP2040 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_delay_expired = false; + +static int64_t alarm_callback_(alarm_id_t id, void *user_data) { + (void) id; + (void) user_data; + s_delay_expired = true; + __sev(); + return 0; +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + s_delay_expired = false; + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); + if (alarm <= 0) { + delay(ms); + return; + } + while (!g_main_loop_woke && !s_delay_expired) { + __wfe(); + } + if (!s_delay_expired) + cancel_alarm(alarm); + g_main_loop_woke = false; +} +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/wake/wake_rp2040.h b/esphome/core/wake/wake_rp2040.h new file mode 100644 index 0000000000..ea1242f535 --- /dev/null +++ b/esphome/core/wake/wake_rp2040.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +inline void wake_loop_any_context() { + // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed + // to see it on its next gate check. + wake_request_set(); + g_main_loop_woke = true; + __sev(); +} + +inline void wake_loop_threadsafe() { wake_loop_any_context(); } + +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp. +namespace internal { +void wakeable_delay(uint32_t ms); +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/loader.py b/esphome/loader.py index 68664aaa26..9390b8094b 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -31,8 +31,9 @@ class FileResource: class ComponentManifest: - def __init__(self, module: ModuleType): + def __init__(self, module: ModuleType, recursive_sources: bool = False): self.module = module + self.recursive_sources = recursive_sources @property def package(self) -> str: @@ -108,8 +109,10 @@ class ComponentManifest: def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. - This will return all cpp source files that are located in the same folder as the - loaded .py file (does not look through subdirectories) + By default only files directly in the package directory are returned. Manifests + constructed with ``recursive_sources=True`` also descend into non-subpackage + subdirectories (subdirectories without an ``__init__.py``), so core code can + live under ``esphome/core//`` without every component paying the cost. """ ret: list[FileResource] = [] @@ -121,23 +124,30 @@ class ComponentManifest: set(filter_source_files_func()) if filter_source_files_func else set() ) - # Process all resources - for resource in ( - r.name - for r in importlib.resources.files(self.package).iterdir() - if r.is_file() - ): - if Path(resource).suffix not in SOURCE_FILE_EXTENSIONS: - continue - if not importlib.resources.files(self.package).joinpath(resource).is_file(): - # Not a resource = this is a directory (yeah this is confusing) - continue + root = importlib.resources.files(self.package) - # Skip excluded files - if resource in excluded_files: - continue + for child in root.iterdir(): + name = child.name + if child.is_file(): + if Path(name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + if name in excluded_files: + continue + ret.append(FileResource(self.package, name)) + elif self.recursive_sources and child.is_dir() and name != "__pycache__": + # Skip Python subpackages — they load as their own components. + if child.joinpath("__init__.py").is_file(): + continue + for sub in child.iterdir(): + if not sub.is_file(): + continue + if Path(sub.name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + resource = f"{name}/{sub.name}" + if resource in excluded_files: + continue + ret.append(FileResource(self.package, resource)) - ret.append(FileResource(self.package, resource)) return ret @@ -237,7 +247,9 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None: _COMPONENT_CACHE: dict[str, ComponentManifest] = {} CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() -_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) +_COMPONENT_CACHE["esphome"] = ComponentManifest( + esphome.core.config, recursive_sources=True +) def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None: From 9f5121e27138143c7a3f32e617808106db3dd8ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:33:41 -0500 Subject: [PATCH 0580/2214] [core] Suppress redundant-declaration warning for ESP32 esp_timer_get_time forward decl --- esphome/core/hal.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 66b4f72d4b..65e0053236 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -132,6 +132,7 @@ __attribute__((always_inline)) inline bool in_isr_context() { // loop. ESP8266/LibreTiny/RP2040 share Arduino's ::yield/::delay/::micros. #if defined(USE_ESP32) // Forward decl from . +// NOLINTNEXTLINE(readability-redundant-declaration) extern "C" int64_t esp_timer_get_time(void); __attribute__((always_inline)) inline void yield() { vPortYield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } From 773e74ba8e0170b59ec7d9b3834b46e8527149dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:35:36 -0500 Subject: [PATCH 0581/2214] [core] Add loader tests for recursive_sources walk Cover the four paths introduced by the wake split: - default manifests (recursive_sources=False) skip subdirs entirely, - opt-in manifests walk non-subpackage subdirs with /-joined paths, - subdirs containing __init__.py are skipped to avoid double-counting existing component subpackages, and __pycache__ is always skipped, - FILTER_SOURCE_FILES entries accept /-joined subpaths. --- tests/unit_tests/test_loader.py | 164 ++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index a42cc5cca7..3fb0eca4a0 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -158,3 +158,167 @@ def test_component_manifest_resources_with_filter_source_files() -> None: # Verify the correct number of resources assert len(resources) == 3 # test.cpp, test.h, common.cpp + + +# --------------------------------------------------------------------------- +# recursive_sources — used only by the core "esphome" manifest so that files +# in esphome/core//*.cpp (e.g. esphome/core/wake/wake_host.cpp) are +# discovered without promoting / to a Python subpackage. +# --------------------------------------------------------------------------- + + +def _mock_file(filename: str) -> MagicMock: + m = MagicMock() + m.name = filename + m.is_file.return_value = True + m.is_dir.return_value = False + return m + + +def _mock_dir(dirname: str, children: list, has_init: bool = False) -> MagicMock: + """Mock a directory entry with an iterdir() and joinpath('__init__.py').""" + d = MagicMock() + d.name = dirname + d.is_file.return_value = False + d.is_dir.return_value = True + d.iterdir.return_value = children + init_marker = MagicMock() + init_marker.is_file.return_value = has_init + d.joinpath.return_value = init_marker + return d + + +def test_component_manifest_resources_non_recursive_skips_subdirs() -> None: + """Default (recursive_sources=False) does not descend into subdirectories.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.test_component" + # No FILTER_SOURCE_FILES. + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module) # recursive_sources defaults to False + + top_level = [ + _mock_file("top.cpp"), + _mock_dir("subdir", [_mock_file("nested.cpp")]), + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["top.cpp"] + + +def test_component_manifest_resources_recursive_walks_non_subpackage_subdirs() -> None: + """With recursive_sources=True, a subdir without __init__.py is walked.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), + _mock_file("wake_host.h"), + _mock_file("README.md"), # wrong suffix, excluded + ], + has_init=False, + ) + top_level = [ + _mock_file("wake.h"), + wake_dir, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = sorted(r.resource for r in manifest.resources) + + assert names == ["wake.h", "wake/wake_host.cpp", "wake/wake_host.h"] + + +def test_component_manifest_resources_recursive_skips_subpackages() -> None: + """Subdirectories that ARE Python subpackages (contain __init__.py) are + skipped even with recursive_sources=True — those load as their own + ComponentManifest and would otherwise be double-counted.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.haier" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + button_pkg = _mock_dir( + "button", + [_mock_file("self_cleaning.cpp")], + has_init=True, # Python subpackage — must be skipped. + ) + top_level = [ + _mock_file("haier.cpp"), + button_pkg, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["haier.cpp"] + + +def test_component_manifest_resources_recursive_skips_pycache() -> None: + """__pycache__ inside a recursive walk must never be descended into.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + # __pycache__ is_dir=True but must be skipped without checking __init__.py + # or calling iterdir (would yield compiled artifacts). + pycache = _mock_dir("__pycache__", [_mock_file("wake.cpython-314.pyc")]) + top_level = [ + _mock_file("wake.h"), + pycache, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake.h"] + + +def test_component_manifest_resources_recursive_filter_source_files_supports_subpaths() -> ( + None +): + """FILTER_SOURCE_FILES entries using '/'-joined subpaths exclude files + inside a recursively-walked subdir.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + mock_module.FILTER_SOURCE_FILES = lambda: ["wake/wake_host.cpp"] + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), # excluded + _mock_file("wake_freertos.cpp"), # kept + ], + ) + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = [wake_dir] + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake/wake_freertos.cpp"] From 881c74febcbe5368554898bfb9b91c7482bc6103 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:37:58 -0500 Subject: [PATCH 0582/2214] [core] Filter wake/wake_*.cpp to only compile on the active platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wake.h's platform #include already ensures only one header contributes code, but all five wake/*.cpp files were still being copied and compiled (as empty TUs) on every target. Extend FILTER_SOURCE_FILES to map each wake_.cpp to the exact set of PlatformFramework values that need it — same pattern ring_buffer.cpp already uses. --- esphome/core/config.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index bf210876df..131c94b7d6 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -786,6 +786,29 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + # Per-platform wake implementations — wake.h dispatches to exactly one of + # these based on USE_*, so the others can be skipped at the source level + # too. Header files next to each .cpp are always copied (the dispatcher + # #include's them) but compile to empty TUs on the wrong platform anyway. + "wake/wake_freertos.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + "wake/wake_esp8266.cpp": { + PlatformFramework.ESP8266_ARDUINO, + }, + "wake/wake_rp2040.cpp": { + PlatformFramework.RP2040_ARDUINO, + }, + "wake/wake_host.cpp": { + PlatformFramework.HOST_NATIVE, + }, + "wake/wake_generic.cpp": { + PlatformFramework.NRF52_ZEPHYR, + }, # Note: lock_free_queue.h and event_pool.h are header files and don't need to be filtered # as they are only included when needed by the preprocessor } From 522395f93b6accbefb7388a75e216843dcbcb135 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:50:23 -0500 Subject: [PATCH 0583/2214] [core] Match wake storage conditionals across platform TUs ESPHome's defines.h unconditionally #defines every feature flag for static analysis, so clang-tidy sees ESPHOME_THREAD_MULTI_ATOMICS even when building wake_esp8266.cpp / wake_rp2040.cpp / wake_generic.cpp. In that mode wake.h picks the std::atomic extern, and the plain-volatile definitions in those TUs trip a redefinition error. Guard each TU's g_wake_requested with the same ESPHOME_THREAD_MULTI_ATOMICS check wake.h uses. Real builds still pick volatile on every SINGLE platform. --- esphome/core/wake/wake_esp8266.cpp | 8 +++++++- esphome/core/wake/wake_generic.cpp | 12 ++++++++++-- esphome/core/wake/wake_rp2040.cpp | 8 +++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/esphome/core/wake/wake_esp8266.cpp b/esphome/core/wake/wake_esp8266.cpp index 9ced43c6df..4c12470148 100644 --- a/esphome/core/wake/wake_esp8266.cpp +++ b/esphome/core/wake/wake_esp8266.cpp @@ -8,9 +8,15 @@ namespace esphome { // === Wake-requested flag + main-loop woke flag storage === -// ESP8266 is always ESPHOME_THREAD_SINGLE. +// ESP8266 is always ESPHOME_THREAD_SINGLE in real builds, but defines.h (used +// for static analysis / IDE) can define ESPHOME_THREAD_MULTI_ATOMICS alongside +// USE_ESP8266, so the storage type here has to match wake.h's conditional. // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +std::atomic g_wake_requested{0}; +#else volatile uint8_t g_wake_requested = 0; +#endif volatile bool g_main_loop_woke = false; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/wake/wake_generic.cpp b/esphome/core/wake/wake_generic.cpp index 40044e4311..b90aa593b3 100644 --- a/esphome/core/wake/wake_generic.cpp +++ b/esphome/core/wake/wake_generic.cpp @@ -8,9 +8,17 @@ namespace esphome { // === Wake-requested flag storage === -// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE in +// real builds, but defines.h (used for static analysis / IDE) can define +// ESPHOME_THREAD_MULTI_ATOMICS here too, so the storage type has to match +// wake.h's conditional. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +std::atomic g_wake_requested{0}; +#else volatile uint8_t g_wake_requested = 0; +#endif +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp index b18248dbd2..3fa47d4dcc 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2040.cpp @@ -11,9 +11,15 @@ namespace esphome { // === Wake-requested flag + main-loop woke flag storage === -// RP2040 is always ESPHOME_THREAD_SINGLE. +// RP2040 is always ESPHOME_THREAD_SINGLE in real builds, but defines.h (used +// for static analysis / IDE) can define ESPHOME_THREAD_MULTI_ATOMICS alongside +// USE_RP2040, so the storage type here has to match wake.h's conditional. // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +std::atomic g_wake_requested{0}; +#else volatile uint8_t g_wake_requested = 0; +#endif volatile bool g_main_loop_woke = false; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) From 68442f067484f2364904e3f6c3c4938e151a87de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:54:50 -0500 Subject: [PATCH 0584/2214] [core] Let defines.h pick the right thread model per platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit defines.h previously hardcoded ESPHOME_THREAD_MULTI_ATOMICS regardless of the active USE_, so the clang-tidy envs ended up compiling e.g. wake_esp8266.cpp with USE_ESP8266 + MULTI_ATOMICS both set — a combination that cannot occur in a real build and that mismatched the extern in wake.h. Pick the model that real codegen uses: USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS With that, the single-only wake TUs (esp8266, rp2040, generic) can drop the defensive conditional and just define g_wake_requested as plain volatile again. --- esphome/core/defines.h | 15 ++++++++++++++- esphome/core/wake/wake_esp8266.cpp | 8 +------- esphome/core/wake/wake_generic.cpp | 12 ++---------- esphome/core/wake/wake_rp2040.cpp | 8 +------- 4 files changed, 18 insertions(+), 25 deletions(-) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da..5a14c47231 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -17,8 +17,21 @@ #define ESPHOME_DEBUG_SCHEDULER #define ESPHOME_DEBUG_API -// Default threading model for static analysis (ESP32 is multi-threaded with atomics) +// Threading model for static analysis. Match what the real codegen picks per +// platform (see esphome/components//__init__.py ThreadModel.*): +// USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE +// USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS +// everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS +// Without this the clang-tidy envs end up with USE_ +// + MULTI_ATOMICS simultaneously, a combination that can never occur in a +// real build. +#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_NRF52) +#define ESPHOME_THREAD_SINGLE +#elif defined(USE_BK72XX) +#define ESPHOME_THREAD_MULTI_NO_ATOMICS +#else #define ESPHOME_THREAD_MULTI_ATOMICS +#endif // logger #define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE diff --git a/esphome/core/wake/wake_esp8266.cpp b/esphome/core/wake/wake_esp8266.cpp index 4c12470148..9ced43c6df 100644 --- a/esphome/core/wake/wake_esp8266.cpp +++ b/esphome/core/wake/wake_esp8266.cpp @@ -8,15 +8,9 @@ namespace esphome { // === Wake-requested flag + main-loop woke flag storage === -// ESP8266 is always ESPHOME_THREAD_SINGLE in real builds, but defines.h (used -// for static analysis / IDE) can define ESPHOME_THREAD_MULTI_ATOMICS alongside -// USE_ESP8266, so the storage type here has to match wake.h's conditional. +// ESP8266 is always ESPHOME_THREAD_SINGLE. // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) -#ifdef ESPHOME_THREAD_MULTI_ATOMICS -std::atomic g_wake_requested{0}; -#else volatile uint8_t g_wake_requested = 0; -#endif volatile bool g_main_loop_woke = false; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/wake/wake_generic.cpp b/esphome/core/wake/wake_generic.cpp index b90aa593b3..40044e4311 100644 --- a/esphome/core/wake/wake_generic.cpp +++ b/esphome/core/wake/wake_generic.cpp @@ -8,17 +8,9 @@ namespace esphome { // === Wake-requested flag storage === -// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE in -// real builds, but defines.h (used for static analysis / IDE) can define -// ESPHOME_THREAD_MULTI_ATOMICS here too, so the storage type has to match -// wake.h's conditional. -// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) -#ifdef ESPHOME_THREAD_MULTI_ATOMICS -std::atomic g_wake_requested{0}; -#else +// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) volatile uint8_t g_wake_requested = 0; -#endif -// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp index 3fa47d4dcc..b18248dbd2 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2040.cpp @@ -11,15 +11,9 @@ namespace esphome { // === Wake-requested flag + main-loop woke flag storage === -// RP2040 is always ESPHOME_THREAD_SINGLE in real builds, but defines.h (used -// for static analysis / IDE) can define ESPHOME_THREAD_MULTI_ATOMICS alongside -// USE_RP2040, so the storage type here has to match wake.h's conditional. +// RP2040 is always ESPHOME_THREAD_SINGLE. // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) -#ifdef ESPHOME_THREAD_MULTI_ATOMICS -std::atomic g_wake_requested{0}; -#else volatile uint8_t g_wake_requested = 0; -#endif volatile bool g_main_loop_woke = false; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) From 83625d2c0e6b3e2f3df63bb8932f5c4b25e98f23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 13:06:57 -0500 Subject: [PATCH 0585/2214] [core] Fix clang-tidy naming in time_64.h SINGLE branch The static class members in Millis64Impl's ESPHOME_THREAD_SINGLE branch kept the trailing-underscore convention used for instance members, but the project's clang-tidy config (readability-identifier-naming. ClassMemberCase = lower_case) wants static class members without the suffix. This violation was latent because defines.h previously hardcoded MULTI_ATOMICS, so the SINGLE branch was never analyzed; now that defines.h picks the model per platform, the ESP8266 tidy env analyzes this branch. Drop the trailing _ from last_millis / millis_major; their types and initial values are unchanged. --- esphome/core/time_64.cpp | 4 ++-- esphome/core/time_64.h | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index cf651c3e91..25076228d5 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -22,8 +22,8 @@ static const char *const TAG = "time_64"; #ifdef ESPHOME_THREAD_SINGLE // Storage for Millis64Impl inline compute() — defined here so all TUs share one copy. -uint32_t Millis64Impl::last_millis_{0}; -uint16_t Millis64Impl::millis_major_{0}; +uint32_t Millis64Impl::last_millis{0}; +uint16_t Millis64Impl::millis_major{0}; #else uint64_t Millis64Impl::compute(uint32_t now) { diff --git a/esphome/core/time_64.h b/esphome/core/time_64.h index 592e645d41..d82373dbfe 100644 --- a/esphome/core/time_64.h +++ b/esphome/core/time_64.h @@ -21,8 +21,8 @@ class Millis64Impl { #ifdef ESPHOME_THREAD_SINGLE // Storage defined in time_64.cpp — declared here so the inline body can access them. - static uint32_t last_millis_; - static uint16_t millis_major_; + static uint32_t last_millis; + static uint16_t millis_major; static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) { // Half the 32-bit range - used to detect rollovers vs normal time progression @@ -30,17 +30,17 @@ class Millis64Impl { // Single-core platforms have no concurrency, so this is a simple implementation // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics. - uint16_t major = millis_major_; - uint32_t last = last_millis_; + uint16_t major = millis_major; + uint32_t last = last_millis; // Check for rollover if (now < last && (last - now) > HALF_MAX_UINT32) { - millis_major_++; + millis_major++; major++; - last_millis_ = now; + last_millis = now; } else if (now > last) { // Only update if time moved forward - last_millis_ = now; + last_millis = now; } // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time From f36efbc762b08b51cf4766a2ac441c9ceee3abec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:27:12 +0000 Subject: [PATCH 0586/2214] Update tzdata requirement from >=2026.1 to >=2026.2 (#15980) 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 90b0693840..71db6d3444 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ colorama==0.4.6 icmplib==3.0.4 tornado==6.5.5 tzlocal==5.3.1 # from time -tzdata>=2026.1 # from time +tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 esptool==5.2.0 From f62972c2c6aaa78c2f9a798b30bc52609f959d6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:34:00 +0000 Subject: [PATCH 0587/2214] Bump ruff from 0.15.11 to 0.15.12 (#15981) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9b7df6ec5..ad82bd8e5d 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.15.11 + rev: v0.15.12 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index bb98375cb6..b35025fa04 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.11 # also change in .pre-commit-config.yaml when updating +ruff==0.15.12 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 1dcf0bed5681dc057b23a47abe87ce6b91126d2b Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:58:48 +0200 Subject: [PATCH 0588/2214] Add dump_config and error messages --- .../components/esphome/ota/ota_esphome.cpp | 20 ++- esphome/components/ota/ota_backend.h | 2 + .../components/ota/ota_backend_esp_idf.cpp | 116 ++++++++++-------- esphome/components/ota/ota_backend_esp_idf.h | 4 + esphome/espota2.py | 18 +++ 5 files changed, 105 insertions(+), 55 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index bf03482451..e8626be9fb 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -100,6 +100,25 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Password configured"); } #endif +#ifdef USE_OTA_PARTITIONS + ESP_LOGCONFIG(TAG, " Partition access allowed"); + uint32_t running_app_offset; + size_t running_app_size; + ota::get_running_app_position(running_app_offset, running_app_size); + ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %d bytes", + running_app_offset, running_app_size); +#ifdef USE_ESP32 + ESP_LOGCONFIG(TAG, " Partition table:"); + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); + ESP_LOGCONFIG(TAG, " %s: type=0x%X, subtype=0x%X, address=0x%X, size=0x%X", + p->label, p->type, p->subtype, p->address, p->size); + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); +#endif +#endif } void ESPHomeOTAComponent::loop() { @@ -214,7 +233,6 @@ void ESPHomeOTAComponent::handle_handshake_() { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; } this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; - } else { #endif this->handshake_buf_[0] = diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 18e2fa3802..41dbe5fcda 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -40,6 +40,8 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C, OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D, OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E, + OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F, + OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 6074e63486..36a4741613 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -26,7 +26,8 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || image_size > OTA_BUFFER_SIZE) { - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + ESP_LOGE(TAG, "Wrong partition table size"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } memset(this->buf_, 0xFF, sizeof this->buf_); this->buf_written_ = 0; @@ -98,7 +99,8 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { if (len > OTA_BUFFER_SIZE - this->buf_written_) { - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + ESP_LOGE(TAG, "Wrong partition table size"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } memcpy(this->buf_ + this->buf_written_, data, len); this->buf_written_ += len; @@ -176,49 +178,26 @@ void IDFOTABackend::abort() { #ifdef USE_OTA_PARTITIONS OTAResponseTypes IDFOTABackend::update_partition_table() { - esp_err_t err; int num_partitions; if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { - ESP_LOGE(TAG, "not enough data received (%d/%d bytes)", this->buf_written_, this->image_size_); - return OTA_RESPONSE_ERROR_UNKNOWN; + ESP_LOGE(TAG, "Not enough data received"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } // Get running app partition and used size - static uint32_t running_app_offset = 0; - static size_t running_app_size = 0; - const esp_partition_t *running_app_part = nullptr; - if (running_app_size == 0) { - running_app_part = esp_ota_get_running_partition(); - // esp_ota_get_running_partition() returns a pointer to invalid data after esp_partition_unload_all() was called on - // a previous run. Cache the running app offset and size. - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * - running_app_part->erase_size; - running_app_offset = running_app_part->address; - const esp_partition_pos_t running_app_pos = { - .offset = running_app_part->address, - .size = running_app_part->size, - }; - esp_image_metadata_t image_metadata; - image_metadata.start_addr = running_app_part->address; - err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); - if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { - running_app_size = image_metadata.image_len; - } - // Align running_app_size to flash sectors - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * - running_app_part->erase_size; - ESP_LOGD(TAG, "Running app: address=0x%X partition_size=0x%X used_size=0x%X, aligned_size=0x%X", - running_app_part->address, running_app_part->size, image_metadata.image_len, running_app_size); - } + uint32_t running_app_offset; + size_t running_app_size; + get_running_app_position(running_app_offset, running_app_size); // Get partition table partition - err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, + esp_err_t err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } + // Verify existing partition table const esp_partition_info_t *existing_partition_table = NULL; esp_partition_mmap_handle_t partition_table_map; @@ -226,13 +205,13 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { (const void **) &existing_partition_table, &partition_table_map); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); esp_partition_munmap(partition_table_map); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } // Verify new partition table @@ -241,7 +220,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } // Check if the required app and otadata partitions exist in the new partition table @@ -283,15 +262,15 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } if (app_index == -1 && app_index_with_copy == -1) { ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (app_partitions_found < 2 || otadata_index == -1) { ESP_LOGE(TAG, "New partition table is missing the required app or otadata partitions"); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (!otadata_no_overlap) { ESP_LOGE(TAG, "New otadata partition overlaps with running app"); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } ESP_LOGD(TAG, "Checks passed, starting partition table update"); @@ -301,18 +280,17 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Copy the running app partition to new position if needed if (app_index == -1) { - if (running_app_part == nullptr) { - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { - const esp_partition_t *p = esp_partition_get(it); - const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; - if (p->address == running_app_offset && p->size >= running_app_size) { - running_app_part = p; - } - it = esp_partition_next(it); + const esp_partition_t *running_app_part = nullptr; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); + const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; + if (p->address == running_app_offset && p->size >= running_app_size) { + running_app_part = p; } - esp_partition_iterator_release(it); + it = esp_partition_next(it); } + esp_partition_iterator_release(it); ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); @@ -341,7 +319,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } } @@ -351,18 +329,18 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_write(this->update_handle_, this->buf_, this->image_size_); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } esp_partition_unload_all(); @@ -382,10 +360,40 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } return OTA_RESPONSE_OK; } + +void get_running_app_position(uint32_t &offset, size_t &size) { + // Gets the start address and the used length aligned to sectors of the running app. + // This function needs to be called once before calling esp_partition_unload_all(). + // The results are stored using static variables because esp_ota_get_running_partition() + // does not return valid data after calling esp_partition_unload_all(). + static uint32_t running_app_offset = 0; + static size_t running_app_size = 0; + if (running_app_size == 0) { + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + running_app_offset = running_app_part->address; + const esp_partition_pos_t running_app_pos = { + .offset = running_app_part->address, + .size = running_app_part->size, + }; + esp_image_metadata_t image_metadata; + image_metadata.start_addr = running_app_part->address; + esp_err_t err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); + if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { + running_app_size = image_metadata.image_len; + } + // Align running_app_size to flash sectors + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + } + offset = running_app_offset; + size = running_app_size; +} #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 9d38d0d8d8..b1c49c427a 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -13,6 +13,10 @@ namespace esphome::ota { static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 #endif +#ifdef USE_OTA_PARTITIONS + void get_running_app_position(uint32_t &offset, size_t &size); +#endif + class IDFOTABackend final { public: #ifdef USE_OTA_PARTITIONS diff --git a/esphome/espota2.py b/esphome/espota2.py index f6ecb123b9..8b5138789e 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -46,6 +46,9 @@ RESPONSE_ERROR_NO_UPDATE_PARTITION = 0x8A RESPONSE_ERROR_MD5_MISMATCH = 0x8B RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D +RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E +RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F +RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -206,6 +209,21 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "with the correct key. Ensure the signing key matches the one used to build " "the firmware currently running on the device." ) + if dat == RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: + raise OTAError( + "Error: The requested OTA type is not supported by the device." + ) + if dat == RESPONSE_ERROR_PARTITION_TABLE_VERIFY: + raise OTAError( + "Error: The partition table update could not be verified. No changes were " + "made to the flash content. Check the logs for more information and retry." + ) + if dat == RESPONSE_ERROR_PARTITION_TABLE_UPDATE: + raise OTAError( + "Error: An error occurred while updating the partition table. The device may not " + "be able to reboot to a working application. Check the logs and retry the update " + "without rebooting the device." + ) if dat == RESPONSE_ERROR_UNKNOWN: raise OTAError("Unknown error from ESP") if not isinstance(expect, (list, tuple)): From 2365431ebccd0748a620210749a3f77b409fe66c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:01:28 +0000 Subject: [PATCH 0589/2214] [pre-commit.ci lite] apply automatic fixes --- esphome/components/esphome/ota/ota_esphome.cpp | 8 ++++---- esphome/components/ota/ota_backend_esp_idf.cpp | 6 +++--- esphome/components/ota/ota_backend_esp_idf.h | 2 +- esphome/espota2.py | 4 +--- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e8626be9fb..045aa133d6 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -105,15 +105,15 @@ void ESPHomeOTAComponent::dump_config() { uint32_t running_app_offset; size_t running_app_size; ota::get_running_app_position(running_app_offset, running_app_size); - ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %d bytes", - running_app_offset, running_app_size); + ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %d bytes", running_app_offset, + running_app_size); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); - ESP_LOGCONFIG(TAG, " %s: type=0x%X, subtype=0x%X, address=0x%X, size=0x%X", - p->label, p->type, p->subtype, p->address, p->size); + ESP_LOGCONFIG(TAG, " %s: type=0x%X, subtype=0x%X, address=0x%X, size=0x%X", p->label, p->type, p->subtype, + p->address, p->size); it = esp_partition_next(it); } esp_partition_iterator_release(it); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 36a4741613..f5b8f14cfe 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -190,9 +190,9 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { get_running_app_position(running_app_offset, running_app_size); // Get partition table partition - esp_err_t err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, - "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, - ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); + esp_err_t err = esp_partition_register_external( + nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", + ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index b1c49c427a..6192f5f969 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -14,7 +14,7 @@ static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 #endif #ifdef USE_OTA_PARTITIONS - void get_running_app_position(uint32_t &offset, size_t &size); +void get_running_app_position(uint32_t &offset, size_t &size); #endif class IDFOTABackend final { diff --git a/esphome/espota2.py b/esphome/espota2.py index 8b5138789e..e8d27d69fa 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -210,9 +210,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "the firmware currently running on the device." ) if dat == RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: - raise OTAError( - "Error: The requested OTA type is not supported by the device." - ) + raise OTAError("Error: The requested OTA type is not supported by the device.") if dat == RESPONSE_ERROR_PARTITION_TABLE_VERIFY: raise OTAError( "Error: The partition table update could not be verified. No changes were " From c2e6787e23854de00bce7194cce93ee1e4100bc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 15:16:03 -0500 Subject: [PATCH 0590/2214] [core] Trim Scheduler::call fast path Two small wins on the per-loop Scheduler::call path: 1. Mark the scheduler's `millis_64()` wrapper `ESPHOME_ALWAYS_INLINE`. The inner `esphome::millis_64()` is already always_inline, but the wrapper was not, so GCC emitted an out-of-line `$isra$0` clone with its own `entry`/`retw` register-window prologue/epilogue at every call site. Inlining removes the wrapper's call overhead and the register-window thunk on ESP32. 2. Collapse the two atomic reads of `to_remove_` in `Scheduler::call` into one. The previous sequence cleanup_(); // loads to_remove_ if (to_remove_count_() >= MAX_...) ... // reloads to_remove_ produced `memw; l32i; beqz; memw; l32i; bltui` on the fast path because the compiler cannot CSE across the `memw` barriers that std::atomic::load emits on Xtensa. Reading the counter once and branching on the result leaves a single `memw; l32i; beqz` on the common zero-case; the slow path (cleanup_slow_path_ + re-read + optional full_cleanup) pays an extra read but already holds the scheduler mutex. --- esphome/core/scheduler.cpp | 21 ++++++++++++++------- esphome/core/scheduler.h | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d83d67d6e4..5a96b529e8 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -601,14 +601,21 @@ uint32_t HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Cleanup removed items before processing - // First try to clean items from the top of the heap (fast path) - this->cleanup_(); + // Cleanup removed items before processing. Read the counter once so the + // common zero-case is a single atomic load + branch; the old code called + // cleanup_() (which loads to_remove_) and then to_remove_count_() again for + // the MAX check, producing a redundant memw+load pair on the fast path. + if (this->to_remove_count_() != 0) { + // First try to clean items from the top of the heap (fast path). + this->cleanup_slow_path_(); - // If we still have too many cancelled items, do a full cleanup - // This only happens if cancelled items are stuck in the middle/bottom of the heap - if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { - this->full_cleanup_removed_items_(); + // If we still have too many cancelled items, do a full cleanup. + // This only happens if cancelled items are stuck in the middle/bottom + // of the heap. Re-read to_remove_ because cleanup_slow_path_ may have + // decremented it. + if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { + this->full_cleanup_removed_items_(); + } } // IMPORTANT: This loop uses index-based access (items_[0]), NOT iterators. // This is intentional — fired intervals are pushed back into items_ via diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 46b19855c3..590c23503b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -118,7 +118,7 @@ class Scheduler { bool cancel_retry(Component *component, uint32_t id); /// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover) - uint64_t millis_64() { return esphome::millis_64(); } + uint64_t ESPHOME_ALWAYS_INLINE millis_64() { return esphome::millis_64(); } // Calculate when the next scheduled item should run. // @param now On ESP32, unused for 64-bit extension (native); on other platforms, extended to 64-bit via rollover. From 75f5b179373c49be22222c3faff931371aa73f28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 15:32:09 -0500 Subject: [PATCH 0591/2214] [core] Thread Scheduler now_64 into next_schedule_in to skip duplicate clock read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduler::call() and Scheduler::next_schedule_in() both computed now_64 via millis_64_from_(now) at the top of every main-loop iteration. On ESP32 with USE_NATIVE_64BIT_TIME this is an esp_timer_get_time() MMIO read (~1us); doing it twice per iteration is wasted work since the two calls happen microseconds apart. Thread the value through: - Scheduler::call() now returns a CallResult { now, now_64 } struct instead of just the advanced uint32_t now. When items fired, now_64 is set to 0 (sentinel) because execute_item_() only advances the uint32 and the local now_64 is stale by the time we return. - Scheduler::next_schedule_in() takes an optional now_64 parameter (default 0). Non-zero values are trusted and used directly; the 0 sentinel triggers a fresh millis_64_from_(now) read. - Application::scheduler_tick_() forwards the CallResult through. - Application::loop() passes sched_result.now_64 to next_schedule_in(). Verified in the disassembly: on the fast path (no items fired), next_schedule_in branches over its esp_timer_get_time call entirely via `or a8, a4, a5; bnez a8, ...` on the two halves of now_64, jumping straight to the next_exec comparison. When call() returned the 0 sentinel, next_schedule_in falls through to the existing inlined micros_to_millis(esp_timer_get_time()) sequence as before. Bench callers (tests/benchmarks/core/bench_scheduler.cpp) and the external test component (tests/integration/fixtures/.../scheduler_bulk_cleanup_component) ignore the return value — no changes needed there. --- esphome/core/application.h | 24 +++++++++++++----------- esphome/core/scheduler.cpp | 21 +++++++++++++++++---- esphome/core/scheduler.h | 23 ++++++++++++++++++++--- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index e9b386038e..a6f626fc83 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -392,7 +392,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); + inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); // RAII guard for a component loop phase. Constructor processes any pending // enable_loop requests from ISRs and marks in_loop_ so reentrant @@ -508,10 +508,11 @@ extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-vari // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has // raised loop_interval_ for power savings (see Application::loop()). -// Returns the timestamp of the last scheduler item that ran (or `now` -// unchanged if none ran), so the caller's WDT feed stays monotonic with the -// per-item feeds inside scheduler.call() without an extra millis(). -inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { +// Returns the advanced `now` (for monotonic wdt feed) alongside the Scheduler's +// 64-bit `now_64` (or 0 if any item fired — then stale). The main loop forwards +// now_64 to next_schedule_in() to avoid a second esp_timer_get_time() MMIO read +// per iteration; 0 means "compute fresh". +inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake. wake_drain_notifications(); @@ -556,11 +557,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Phase A: always service the scheduler. Decouples scheduler cadence from // loop_interval_ so raised intervals (for power savings) don't drag scheduled // items forward. A tick that only runs the scheduler is cheap. - // scheduler_tick_ returns the timestamp of the last scheduler item that ran - // (advanced by its per-item feeds) or `now` unchanged. We adopt it as `now` - // so the gate check and WDT feed both reflect actual elapsed time after - // scheduler dispatch, without an extra millis() call. - uint32_t now = this->scheduler_tick_(MillisInternal::get()); + // scheduler_tick_ returns the advanced `now` (for wdt monotonicity) plus the + // 64-bit `now_64` Scheduler::call() already computed — forward that to the + // next_schedule_in() call below so we don't re-read esp_timer_get_time() a + // second time per iteration. 0 means "stale, recompute". + const Scheduler::CallResult sched_result = this->scheduler_tick_(MillisInternal::get()); + uint32_t now = sched_result.now; // Guarantee one WDT feed per tick even when the scheduler had nothing to // dispatch and the component phase is gated out — covers configs with no // looping components and no scheduler work (setup() has its own @@ -662,7 +664,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); - const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); + const uint32_t until_sched = this->scheduler.next_schedule_in(now, sched_result.now_64).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } // All platforms route loop yields through the platform wake primitive. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5a96b529e8..f0fd77c5b2 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -412,7 +412,7 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { #pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings -optional HOT Scheduler::next_schedule_in(uint32_t now) { +optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64) { // IMPORTANT: This method should only be called from the main thread (loop task). // Accesses items_[0] and the fast-path empty checks without holding a lock, which // is only safe from the main thread. Other threads must not call this method. @@ -441,7 +441,12 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { return {}; SchedulerItem *item = this->items_[0]; - const auto now_64 = this->millis_64_from_(now); + // If the caller did not pass a pre-computed now_64 (or passed the 0 + // sentinel, meaning call() advanced past it by firing items), read the + // clock fresh. Otherwise reuse the passed value to avoid a second + // esp_timer_get_time() MMIO read per main-loop iteration. + if (now_64 == 0) + now_64 = this->millis_64_from_(now); const uint64_t next_exec = item->get_next_execution(); if (next_exec < now_64) return 0; @@ -552,7 +557,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -uint32_t HOT Scheduler::call(uint32_t now) { +Scheduler::CallResult HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -563,6 +568,10 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Track if any items were added to to_add_ during callbacks bool has_added_items = false; + // Track whether we advanced `now` via execute_item_(). If we did, `now_64` + // is stale by the time we return and next_schedule_in() must recompute — + // signal that by writing 0 to *now_64_out. + bool executed_any_item = false; #ifdef ESPHOME_DEBUG_SCHEDULER static uint64_t last_print = 0; @@ -675,6 +684,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { // - timeouts/intervals get added, potentially invalidating vector pointers // - timeouts/intervals get cancelled now = this->execute_item_(item, now); + executed_any_item = true; LockGuard guard{this->lock_}; @@ -731,7 +741,10 @@ uint32_t HOT Scheduler::call(uint32_t now) { #endif // execute_item_() advances `now` as items fire; return it so the caller // stays monotonic with last_wdt_feed_. - return now; + // The returned now_64 is only usable if NO items fired — execute_item_ only + // advances the uint32 `now`, leaving our local now_64 stale. Signal stale + // with the 0 sentinel so next_schedule_in() reads the clock fresh. + return CallResult{now, executed_any_item ? 0 : now_64}; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 590c23503b..7073507a99 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -122,15 +122,32 @@ class Scheduler { // Calculate when the next scheduled item should run. // @param now On ESP32, unused for 64-bit extension (native); on other platforms, extended to 64-bit via rollover. + // @param now_64 Optional pre-computed 64-bit timestamp from a recent call() (see CallResult). + // Pass 0 (sentinel) to have this method compute now_64 freshly. Non-zero values are trusted and + // used directly, saving a millis_64() / esp_timer_get_time() MMIO read when the caller already + // has a fresh value from the same loop iteration. // Returns the time in milliseconds until the next scheduled item, or nullopt if no items. // This method performs cleanup of removed items before checking the schedule. // IMPORTANT: This method should only be called from the main thread (loop task). - optional next_schedule_in(uint32_t now); + optional next_schedule_in(uint32_t now, uint64_t now_64 = 0); + + // Result of Scheduler::call(). + // now – advanced uint32 millis timestamp (monotonic with the caller's wdt feed). + // now_64 – 64-bit millis value Scheduler used for item dispatch, OR 0 if any item + // fired (in that case now_64 is stale because execute_item_ only advances + // the uint32 `now`). A subsequent next_schedule_in() treats 0 as "compute + // fresh" and non-zero as "reuse this value", saving a second millis_64() + // / esp_timer_get_time() call per main-loop iteration. + struct CallResult { + uint32_t now; + uint64_t now_64; + }; // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - // @return Timestamp of the last item that ran, or `now` unchanged if none ran. - uint32_t call(uint32_t now); + // @return Both advanced `now` (for wdt monotonicity) and `now_64` (for pipe-through + // to next_schedule_in()). See CallResult. + CallResult call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). From dfe591c5eb6ed271540ad36a4249b530a7fd65d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 15:45:15 -0500 Subject: [PATCH 0592/2214] [core] Hoist Scheduler::next_schedule_in zero-check to caller Follow-up to the CallResult threading: move the 0-sentinel fallback (if now_64 == 0, read the clock fresh) out of next_schedule_in and into the single main-loop caller. Effect on next_schedule_in: drops the uint32_t now parameter entirely and removes the cold millis_64_from_(now) inlined clock read from the function body. Verified in disassembly: Scheduler::next_schedule_in 159 B -> 86 B (-46%) The hot path is now just defer_empty + cleanup_ + top-of-heap compare + return. No clock read anywhere in the function. Application::loop handles the 0-sentinel with a single inline branch right before the call; when call() fired items it invokes the public Scheduler::millis_64_from() wrapper (new, exposes the existing protected millis_64_from_ so callers can do 64-bit extension without platform knowledge). Fast path (no items fired) stays branch-free. Net code size: loop_task +64 B, next_schedule_in -73 B, Scheduler::call unchanged -> -9 B total. --- esphome/core/application.h | 6 +++++- esphome/core/scheduler.cpp | 11 ++++------- esphome/core/scheduler.h | 17 +++++++++++------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 581b536853..c2f52bf9f1 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -687,7 +687,11 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); - const uint32_t until_sched = this->scheduler.next_schedule_in(now, sched_result.now_64).value_or(until_phase); + // Resolve the sched_result.now_64 sentinel here so next_schedule_in() can assume a valid + // non-zero input and keep the cold clock-read path out of its body. When call() fired items + // sched_result.now_64 is 0 (stale) and we read the clock fresh; otherwise we reuse it. + const uint64_t sched_now_64 = sched_result.now_64 != 0 ? sched_result.now_64 : this->scheduler.millis_64_from(now); + const uint32_t until_sched = this->scheduler.next_schedule_in(sched_now_64).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } // All platforms route loop yields through the platform wake primitive. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d6d3ef2d2d..46c798eee0 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -412,7 +412,7 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { #pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings -optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64) { +optional HOT Scheduler::next_schedule_in(uint64_t now_64) { // IMPORTANT: This method should only be called from the main thread (loop task). // Accesses items_[0] and the fast-path empty checks without holding a lock, which // is only safe from the main thread. Other threads must not call this method. @@ -455,12 +455,9 @@ optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64 return {}; SchedulerItem *item = this->items_[0]; - // If the caller did not pass a pre-computed now_64 (or passed the 0 - // sentinel, meaning call() advanced past it by firing items), read the - // clock fresh. Otherwise reuse the passed value to avoid a second - // esp_timer_get_time() MMIO read per main-loop iteration. - if (now_64 == 0) - now_64 = this->millis_64_from_(now); + // Caller is responsible for passing a fresh non-zero now_64 (see header doc). + // Hoisting the 0-sentinel resolution to the caller keeps the cold fallback + // clock-read out of this function's fast path. const uint64_t next_exec = item->get_next_execution(); if (next_exec < now_64) return 0; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7073507a99..c169d458d7 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -120,16 +120,21 @@ class Scheduler { /// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover) uint64_t ESPHOME_ALWAYS_INLINE millis_64() { return esphome::millis_64(); } + // Extend a 32-bit `now` timestamp to a 64-bit one. Public wrapper so callers that need to + // materialize a valid now_64 for next_schedule_in() (after call() returned the 0 sentinel) + // can do so without knowing the platform-specific 64-bit extension strategy. + // See millis_64_from_ for platform notes. + uint64_t ESPHOME_ALWAYS_INLINE millis_64_from(uint32_t now) { return this->millis_64_from_(now); } + // Calculate when the next scheduled item should run. - // @param now On ESP32, unused for 64-bit extension (native); on other platforms, extended to 64-bit via rollover. - // @param now_64 Optional pre-computed 64-bit timestamp from a recent call() (see CallResult). - // Pass 0 (sentinel) to have this method compute now_64 freshly. Non-zero values are trusted and - // used directly, saving a millis_64() / esp_timer_get_time() MMIO read when the caller already - // has a fresh value from the same loop iteration. + // @param now_64 64-bit millisecond timestamp. Must be non-zero and fresh — the caller is responsible + // for resolving the 0 sentinel that Scheduler::call() may return (via millis_64_from above). + // Hoisting the zero-check out of this function keeps the cold clock-read path out of the + // hot-path's icache line and simplifies register allocation. // Returns the time in milliseconds until the next scheduled item, or nullopt if no items. // This method performs cleanup of removed items before checking the schedule. // IMPORTANT: This method should only be called from the main thread (loop task). - optional next_schedule_in(uint32_t now, uint64_t now_64 = 0); + optional next_schedule_in(uint64_t now_64); // Result of Scheduler::call(). // now – advanced uint32 millis timestamp (monotonic with the caller's wdt feed). From b659eaf494aeb057f8d66f9ddce65007fbc4d863 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 15:45:15 -0500 Subject: [PATCH 0593/2214] [core] Hoist Scheduler::next_schedule_in zero-check to caller Follow-up to the CallResult threading: move the 0-sentinel fallback (if now_64 == 0, read the clock fresh) out of next_schedule_in and into the single main-loop caller. Effect on next_schedule_in: drops the uint32_t now parameter entirely and removes the cold millis_64_from_(now) inlined clock read from the function body. Verified in disassembly: Scheduler::next_schedule_in 159 B -> 86 B (-46%) The hot path is now just defer_empty + cleanup_ + top-of-heap compare + return. No clock read anywhere in the function. Application::loop handles the 0-sentinel with a single inline branch right before the call; when call() fired items it invokes the public Scheduler::millis_64_from() wrapper (new, exposes the existing protected millis_64_from_ so callers can do 64-bit extension without platform knowledge). Fast path (no items fired) stays branch-free. Net code size: loop_task +64 B, next_schedule_in -73 B, Scheduler::call unchanged -> -9 B total. --- esphome/core/application.h | 6 +++++- esphome/core/scheduler.cpp | 11 ++++------- esphome/core/scheduler.h | 17 +++++++++++------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index a6f626fc83..4f06bc3ebb 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -664,7 +664,11 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); - const uint32_t until_sched = this->scheduler.next_schedule_in(now, sched_result.now_64).value_or(until_phase); + // Resolve the sched_result.now_64 sentinel here so next_schedule_in() can assume a valid + // non-zero input and keep the cold clock-read path out of its body. When call() fired items + // sched_result.now_64 is 0 (stale) and we read the clock fresh; otherwise we reuse it. + const uint64_t sched_now_64 = sched_result.now_64 != 0 ? sched_result.now_64 : this->scheduler.millis_64_from(now); + const uint32_t until_sched = this->scheduler.next_schedule_in(sched_now_64).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } // All platforms route loop yields through the platform wake primitive. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index f0fd77c5b2..6656ba76fc 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -412,7 +412,7 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { #pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings -optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64) { +optional HOT Scheduler::next_schedule_in(uint64_t now_64) { // IMPORTANT: This method should only be called from the main thread (loop task). // Accesses items_[0] and the fast-path empty checks without holding a lock, which // is only safe from the main thread. Other threads must not call this method. @@ -441,12 +441,9 @@ optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64 return {}; SchedulerItem *item = this->items_[0]; - // If the caller did not pass a pre-computed now_64 (or passed the 0 - // sentinel, meaning call() advanced past it by firing items), read the - // clock fresh. Otherwise reuse the passed value to avoid a second - // esp_timer_get_time() MMIO read per main-loop iteration. - if (now_64 == 0) - now_64 = this->millis_64_from_(now); + // Caller is responsible for passing a fresh non-zero now_64 (see header doc). + // Hoisting the 0-sentinel resolution to the caller keeps the cold fallback + // clock-read out of this function's fast path. const uint64_t next_exec = item->get_next_execution(); if (next_exec < now_64) return 0; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7073507a99..c169d458d7 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -120,16 +120,21 @@ class Scheduler { /// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover) uint64_t ESPHOME_ALWAYS_INLINE millis_64() { return esphome::millis_64(); } + // Extend a 32-bit `now` timestamp to a 64-bit one. Public wrapper so callers that need to + // materialize a valid now_64 for next_schedule_in() (after call() returned the 0 sentinel) + // can do so without knowing the platform-specific 64-bit extension strategy. + // See millis_64_from_ for platform notes. + uint64_t ESPHOME_ALWAYS_INLINE millis_64_from(uint32_t now) { return this->millis_64_from_(now); } + // Calculate when the next scheduled item should run. - // @param now On ESP32, unused for 64-bit extension (native); on other platforms, extended to 64-bit via rollover. - // @param now_64 Optional pre-computed 64-bit timestamp from a recent call() (see CallResult). - // Pass 0 (sentinel) to have this method compute now_64 freshly. Non-zero values are trusted and - // used directly, saving a millis_64() / esp_timer_get_time() MMIO read when the caller already - // has a fresh value from the same loop iteration. + // @param now_64 64-bit millisecond timestamp. Must be non-zero and fresh — the caller is responsible + // for resolving the 0 sentinel that Scheduler::call() may return (via millis_64_from above). + // Hoisting the zero-check out of this function keeps the cold clock-read path out of the + // hot-path's icache line and simplifies register allocation. // Returns the time in milliseconds until the next scheduled item, or nullopt if no items. // This method performs cleanup of removed items before checking the schedule. // IMPORTANT: This method should only be called from the main thread (loop task). - optional next_schedule_in(uint32_t now, uint64_t now_64 = 0); + optional next_schedule_in(uint64_t now_64); // Result of Scheduler::call(). // now – advanced uint32 millis timestamp (monotonic with the caller's wdt feed). From 4d7033df4f1abecd0b811ed80a337a070f9303ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 15:59:38 -0500 Subject: [PATCH 0594/2214] [core] Switch Scheduler::call now_64 plumbing from struct to out-param Replace the CallResult struct return with a uint64_t &now_64_out reference parameter. The struct return forced GCC on Xtensa to emit 8 redundant s32i stores after every call() (once into the sched_result local plus once into a compiler-chosen temp slot), and the extra ABI shuffling around the 16-byte return value was tight enough with the esp_timer_get_time() MMIOs bracketing the wdt bucket that the wdt measurement regressed from ~21 us/hit to ~36 us/hit on ESP32. Out-param keeps the scheduler's now_64 write local to Scheduler::call (written once to *now_64_out) and leaves the return path a single uint32 in a10. The caller passes &sched_now_64_raw directly; no struct copy. loop_task shrinks 988 B -> 960 B. Disassembly verified: after Scheduler::call returns the only instructions before the next micros() capture are: mov.n a6, a10 ; save now call8 esp_timer_get_time ; loop_after_sched_us No duplicate struct stores. --- esphome/core/application.cpp | 3 ++- esphome/core/application.h | 26 ++++++++++++++------------ esphome/core/scheduler.cpp | 11 ++++++----- esphome/core/scheduler.h | 26 ++++++++++---------------- 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index c390b26cd4..b35db45e26 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -83,7 +83,8 @@ void Application::setup() { // Service scheduler and process pending loop enables to handle GPIO // interrupts during setup. During setup we always run the component // phase (no loop_interval_ gate), so call both helpers unconditionally. - this->scheduler_tick_(MillisInternal::get()); + uint64_t setup_sched_now_64_unused; + this->scheduler_tick_(MillisInternal::get(), setup_sched_now_64_unused); { ComponentPhaseGuard phase_guard{*this}; diff --git a/esphome/core/application.h b/esphome/core/application.h index c2f52bf9f1..6ff8b152e4 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -392,7 +392,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); + inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now, uint64_t &now_64_out); // RAII guard for a component loop phase. Constructor processes any pending // enable_loop requests from ISRs and marks in_loop_ so reentrant @@ -517,12 +517,12 @@ extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-vari // 64-bit `now_64` (or 0 if any item fired — then stale). The main loop forwards // now_64 to next_schedule_in() to avoid a second esp_timer_get_time() MMIO read // per iteration; 0 means "compute fresh". -inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { +inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now, uint64_t &now_64_out) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake. wake_drain_notifications(); #endif - return this->scheduler.call(now); + return this->scheduler.call(now, now_64_out); } // Phase B entry: only invoked when a component loop phase is about to run. @@ -562,12 +562,14 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Phase A: always service the scheduler. Decouples scheduler cadence from // loop_interval_ so raised intervals (for power savings) don't drag scheduled // items forward. A tick that only runs the scheduler is cheap. - // scheduler_tick_ returns the advanced `now` (for wdt monotonicity) plus the - // 64-bit `now_64` Scheduler::call() already computed — forward that to the - // next_schedule_in() call below so we don't re-read esp_timer_get_time() a - // second time per iteration. 0 means "stale, recompute". - const Scheduler::CallResult sched_result = this->scheduler_tick_(MillisInternal::get()); - uint32_t now = sched_result.now; + // scheduler_tick_ returns the advanced `now` (for wdt monotonicity) and fills + // sched_now_64 with the 64-bit timestamp Scheduler::call() already computed — + // forward that to the next_schedule_in() call below so we don't re-read + // esp_timer_get_time() a second time per iteration. 0 means "stale, recompute". + // Out-param keeps the return path in a single register and avoids a 16-byte + // struct-copy on the caller's stack. + uint64_t sched_now_64_raw; + uint32_t now = this->scheduler_tick_(MillisInternal::get(), sched_now_64_raw); #ifdef USE_RUNTIME_STATS // Capture immediately after scheduler_tick_ returns so the post-scheduler // feed_wdt_with_time slice can be separated from the scheduler slice in @@ -687,10 +689,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); - // Resolve the sched_result.now_64 sentinel here so next_schedule_in() can assume a valid + // Resolve the sched_now_64_raw sentinel here so next_schedule_in() can assume a valid // non-zero input and keep the cold clock-read path out of its body. When call() fired items - // sched_result.now_64 is 0 (stale) and we read the clock fresh; otherwise we reuse it. - const uint64_t sched_now_64 = sched_result.now_64 != 0 ? sched_result.now_64 : this->scheduler.millis_64_from(now); + // sched_now_64_raw is 0 (stale) and we read the clock fresh; otherwise we reuse it. + const uint64_t sched_now_64 = sched_now_64_raw != 0 ? sched_now_64_raw : this->scheduler.millis_64_from(now); const uint32_t until_sched = this->scheduler.next_schedule_in(sched_now_64).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 46c798eee0..803807d707 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -568,7 +568,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -Scheduler::CallResult HOT Scheduler::call(uint32_t now) { +uint32_t HOT Scheduler::call(uint32_t now, uint64_t &now_64_out) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -752,10 +752,11 @@ Scheduler::CallResult HOT Scheduler::call(uint32_t now) { #endif // execute_item_() advances `now` as items fire; return it so the caller // stays monotonic with last_wdt_feed_. - // The returned now_64 is only usable if NO items fired — execute_item_ only - // advances the uint32 `now`, leaving our local now_64 stale. Signal stale - // with the 0 sentinel so next_schedule_in() reads the clock fresh. - return CallResult{now, executed_any_item ? 0 : now_64}; + // The now_64 we pass back is only usable if NO items fired — execute_item_ + // only advances the uint32 `now`, leaving our local now_64 stale. Signal + // stale with the 0 sentinel so next_schedule_in() reads the clock fresh. + now_64_out = executed_any_item ? 0 : now_64; + return now; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index c169d458d7..2cf559cc9c 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -136,23 +136,17 @@ class Scheduler { // IMPORTANT: This method should only be called from the main thread (loop task). optional next_schedule_in(uint64_t now_64); - // Result of Scheduler::call(). - // now – advanced uint32 millis timestamp (monotonic with the caller's wdt feed). - // now_64 – 64-bit millis value Scheduler used for item dispatch, OR 0 if any item - // fired (in that case now_64 is stale because execute_item_ only advances - // the uint32 `now`). A subsequent next_schedule_in() treats 0 as "compute - // fresh" and non-zero as "reuse this value", saving a second millis_64() - // / esp_timer_get_time() call per main-loop iteration. - struct CallResult { - uint32_t now; - uint64_t now_64; - }; - - // Execute all scheduled items that are ready + // Execute all scheduled items that are ready. // @param now Fresh timestamp from millis() - must not be stale/cached - // @return Both advanced `now` (for wdt monotonicity) and `now_64` (for pipe-through - // to next_schedule_in()). See CallResult. - CallResult call(uint32_t now); + // @param now_64_out Out-param set to the 64-bit `now` that Scheduler used for item + // dispatch, OR 0 if any item fired (in that case the value is stale because + // execute_item_ only advances the uint32 `now`). A subsequent next_schedule_in() + // treats 0 as "compute fresh" and non-zero as "reuse this value", saving a + // second millis_64() / esp_timer_get_time() call per main-loop iteration. + // Out-param (rather than struct return) keeps the return path in a single + // register and avoids a 16-byte struct-copy on the loop caller's stack. + // @return Advanced `now` (monotonic with the caller's wdt feed). + uint32_t call(uint32_t now, uint64_t &now_64_out); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). From c5e2f15889b8dfd75341b289394d388d25060751 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 16:16:11 -0500 Subject: [PATCH 0595/2214] [core] Raise ESP32 WDT feed interval to 1000ms Mirrors the bk72xx override: the ESP32 task WDT default timeout is 5s (CONFIG_ESP_TASK_WDT_TIMEOUT_S), so feeding every 1000ms keeps a ~5x safety margin while cutting per-iteration feed overhead substantially. esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so every call costs tens of microseconds. At the normal ~62 Hz main loop the old 300ms cadence produced ~200 hits per 60s period; 1000ms cuts that to ~60 hits per 60s (-70%). Measured on a live ESP32 IDF build (gatetrigger.yaml) via runtime_stats: wdt bucket dropped from 2.24 us/iter to 1.56 us/iter - about 2.5 ms saved per 60s window, or ~0.7us/iter average. Component-level feeds inside component loop() and scheduler items are unaffected; they continue to call arch_feed_wdt after every operation, so any operation that exceeds this rate-limit triggers a real feed naturally. The rate-limit only applies to the outer guard in Application::loop() that fires when nothing else fed recently. --- esphome/core/application.h | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 6ff8b152e4..c3ec61c5d5 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -216,7 +216,7 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT default (5 s): ~16x + /// - ESP32 task WDT default (5 s): ~5x <-- platform override below /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change /// must keep comfortable margin here /// - ESP8266 HW WDT (~6 s): ~20x @@ -226,6 +226,14 @@ class Application { // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: // https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; +#elif defined(USE_ESP32) + // ESP32 task WDT default timeout is 5 s (CONFIG_ESP_TASK_WDT_TIMEOUT_S). + // 1000ms keeps a comfortable ~5x margin — matching the bk72xx safety factor — + // while cutting the per-hit feed overhead to ~1/3 of the old 300ms cadence. + // esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so + // each call costs tens of microseconds; feeding less often materially + // reduces the main-loop's wdt bucket. + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 1000; #else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; #endif From 864f096df3526bff3fb6bd6b3307ad316d97d4d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 16:16:11 -0500 Subject: [PATCH 0596/2214] [core] Raise ESP32 WDT feed interval to 1000ms Mirrors the bk72xx override: the ESP32 task WDT default timeout is 5s (CONFIG_ESP_TASK_WDT_TIMEOUT_S), so feeding every 1000ms keeps a ~5x safety margin while cutting per-iteration feed overhead substantially. esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so every call costs tens of microseconds. At the normal ~62 Hz main loop the old 300ms cadence produced ~200 hits per 60s period; 1000ms cuts that to ~60 hits per 60s (-70%). Measured on a live ESP32 IDF build (gatetrigger.yaml) via runtime_stats: wdt bucket dropped from 2.24 us/iter to 1.56 us/iter - about 2.5 ms saved per 60s window, or ~0.7us/iter average. Component-level feeds inside component loop() and scheduler items are unaffected; they continue to call arch_feed_wdt after every operation, so any operation that exceeds this rate-limit triggers a real feed naturally. The rate-limit only applies to the outer guard in Application::loop() that fires when nothing else fed recently. --- esphome/core/application.h | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index e9b386038e..b23ef8d963 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -216,7 +216,7 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT default (5 s): ~16x + /// - ESP32 task WDT default (5 s): ~5x <-- platform override below /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change /// must keep comfortable margin here /// - ESP8266 HW WDT (~6 s): ~20x @@ -226,6 +226,14 @@ class Application { // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: // https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; +#elif defined(USE_ESP32) + // ESP32 task WDT default timeout is 5 s (CONFIG_ESP_TASK_WDT_TIMEOUT_S). + // 1000ms keeps a comfortable ~5x margin — matching the bk72xx safety factor — + // while cutting the per-hit feed overhead to ~1/3 of the old 300ms cadence. + // esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so + // each call costs tens of microseconds; feeding less often materially + // reduces the main-loop's wdt bucket. + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 1000; #else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; #endif From 0709ba613f525d21c5dbf381cf74914821706c9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 16:24:07 -0500 Subject: [PATCH 0597/2214] [core] Raise ESP32 WDT feed interval to 1/5 of configured timeout Mirrors the existing bk72xx platform override, but auto-scales to the user-configurable esp32.watchdog_timeout (CONFIG_ESP_TASK_WDT_TIMEOUT_S) instead of hard-coding a constant: the feed interval is always 1/5 of the configured task WDT timeout so the safety margin stays constant across user configurations. - default 5 s WDT -> 1000 ms feed interval (was 300 ms, -70% hits) - 10 s WDT -> 2000 ms feed interval - 60 s WDT (max) -> 12000 ms feed interval esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so every call costs tens of microseconds. At the normal ~62 Hz main loop the old 300 ms cadence produced ~200 feed_wdt_slow_ hits per 60 s period; the default 5s -> 1000 ms cuts that to ~60 hits (-70%). Component-level feeds inside Component::loop() and scheduler items are unaffected; they continue to call arch_feed_wdt after every operation, so any op exceeding this rate-limit triggers a real feed naturally. The rate-limit only applies to the outer guard in Application::loop() that fires when nothing else fed recently. esp32/__init__.py already constrains watchdog_timeout to >= 5 s (range 5-60 s), and a static_assert guards against anyone who tweaks sdkconfig below that floor, ensuring the feed interval never drops below the prior hardcoded 1000 ms value. Measured on a live ESP32 IDF build (gatetrigger.yaml, default 5 s WDT) via runtime_stats: wdt bucket dropped from 2.24 us/iter to 1.56 us/iter - about 2.5 ms saved per 60 s window. --- esphome/core/application.h | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index b23ef8d963..bc09f7d38c 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -17,6 +17,10 @@ #include "esphome/core/string_ref.h" #include "esphome/core/version.h" +#ifdef USE_ESP32 +#include // for CONFIG_ESP_TASK_WDT_TIMEOUT_S (drives WDT_FEED_INTERVAL_MS) +#endif + #ifdef USE_DEVICES #include "esphome/core/device.h" #endif @@ -216,24 +220,30 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT default (5 s): ~5x <-- platform override below - /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change - /// must keep comfortable margin here - /// - ESP8266 HW WDT (~6 s): ~20x - /// - BK72xx HW WDT (10 s): ~5x <-- platform override below + /// - ESP32 task WDT (user-configurable): ~5x <-- auto-scaled below + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below #ifdef USE_BK72XX // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: // https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; #elif defined(USE_ESP32) - // ESP32 task WDT default timeout is 5 s (CONFIG_ESP_TASK_WDT_TIMEOUT_S). - // 1000ms keeps a comfortable ~5x margin — matching the bk72xx safety factor — - // while cutting the per-hit feed overhead to ~1/3 of the old 300ms cadence. + // Auto-scale to 1/5 of the configured ESP32 task WDT timeout so the safety + // margin stays constant when the user raises esp32.watchdog_timeout (default + // 5 s → 1000 ms feed; 10 s → 2000 ms; 60 s → 12000 ms). The esp32 component + // writes CONFIG_ESP_TASK_WDT_TIMEOUT_S into sdkconfig (range is validated + // to ≥ 5 s in esp32/__init__.py), giving us the value at compile time. // esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so - // each call costs tens of microseconds; feeding less often materially - // reduces the main-loop's wdt bucket. - static constexpr uint32_t WDT_FEED_INTERVAL_MS = 1000; + // each call costs tens of microseconds; longer intervals materially reduce + // the main-loop's wdt bucket. Component loops and scheduler items still + // feed after every op, so any op exceeding this threshold triggers a real + // feed naturally regardless of the rate-limit. + static_assert(CONFIG_ESP_TASK_WDT_TIMEOUT_S >= 5, + "CONFIG_ESP_TASK_WDT_TIMEOUT_S must be at least 5s for a safe WDT feed interval"); + static constexpr uint32_t WDT_FEED_INTERVAL_MS = (CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000U) / 5U; #else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; #endif From c71f8b5ba994ba2ca94679b54e40bbfbe1c55d88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 16:24:07 -0500 Subject: [PATCH 0598/2214] [core] Raise ESP32 WDT feed interval to 1/5 of configured timeout Mirrors the existing bk72xx platform override, but auto-scales to the user-configurable esp32.watchdog_timeout (CONFIG_ESP_TASK_WDT_TIMEOUT_S) instead of hard-coding a constant: the feed interval is always 1/5 of the configured task WDT timeout so the safety margin stays constant across user configurations. - default 5 s WDT -> 1000 ms feed interval (was 300 ms, -70% hits) - 10 s WDT -> 2000 ms feed interval - 60 s WDT (max) -> 12000 ms feed interval esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so every call costs tens of microseconds. At the normal ~62 Hz main loop the old 300 ms cadence produced ~200 feed_wdt_slow_ hits per 60 s period; the default 5s -> 1000 ms cuts that to ~60 hits (-70%). Component-level feeds inside Component::loop() and scheduler items are unaffected; they continue to call arch_feed_wdt after every operation, so any op exceeding this rate-limit triggers a real feed naturally. The rate-limit only applies to the outer guard in Application::loop() that fires when nothing else fed recently. esp32/__init__.py already constrains watchdog_timeout to >= 5 s (range 5-60 s), and a static_assert guards against anyone who tweaks sdkconfig below that floor, ensuring the feed interval never drops below the prior hardcoded 1000 ms value. Measured on a live ESP32 IDF build (gatetrigger.yaml, default 5 s WDT) via runtime_stats: wdt bucket dropped from 2.24 us/iter to 1.56 us/iter - about 2.5 ms saved per 60 s window. --- esphome/core/application.h | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index c3ec61c5d5..20a4ffa9a3 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -17,6 +17,10 @@ #include "esphome/core/string_ref.h" #include "esphome/core/version.h" +#ifdef USE_ESP32 +#include // for CONFIG_ESP_TASK_WDT_TIMEOUT_S (drives WDT_FEED_INTERVAL_MS) +#endif + #ifdef USE_DEVICES #include "esphome/core/device.h" #endif @@ -216,24 +220,30 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT default (5 s): ~5x <-- platform override below - /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change - /// must keep comfortable margin here - /// - ESP8266 HW WDT (~6 s): ~20x - /// - BK72xx HW WDT (10 s): ~5x <-- platform override below + /// - ESP32 task WDT (user-configurable): ~5x <-- auto-scaled below + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below #ifdef USE_BK72XX // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: // https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; #elif defined(USE_ESP32) - // ESP32 task WDT default timeout is 5 s (CONFIG_ESP_TASK_WDT_TIMEOUT_S). - // 1000ms keeps a comfortable ~5x margin — matching the bk72xx safety factor — - // while cutting the per-hit feed overhead to ~1/3 of the old 300ms cadence. + // Auto-scale to 1/5 of the configured ESP32 task WDT timeout so the safety + // margin stays constant when the user raises esp32.watchdog_timeout (default + // 5 s → 1000 ms feed; 10 s → 2000 ms; 60 s → 12000 ms). The esp32 component + // writes CONFIG_ESP_TASK_WDT_TIMEOUT_S into sdkconfig (range is validated + // to ≥ 5 s in esp32/__init__.py), giving us the value at compile time. // esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so - // each call costs tens of microseconds; feeding less often materially - // reduces the main-loop's wdt bucket. - static constexpr uint32_t WDT_FEED_INTERVAL_MS = 1000; + // each call costs tens of microseconds; longer intervals materially reduce + // the main-loop's wdt bucket. Component loops and scheduler items still + // feed after every op, so any op exceeding this threshold triggers a real + // feed naturally regardless of the rate-limit. + static_assert(CONFIG_ESP_TASK_WDT_TIMEOUT_S >= 5, + "CONFIG_ESP_TASK_WDT_TIMEOUT_S must be at least 5s for a safe WDT feed interval"); + static constexpr uint32_t WDT_FEED_INTERVAL_MS = (CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000U) / 5U; #else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; #endif From e4669fefd5a84a293d0c844d2f940e55630dedc7 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:29:50 +0200 Subject: [PATCH 0599/2214] Update test --- tests/unit_tests/test_espota2.py | 46 ++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index ab0ad7034c..bab3ef492c 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -185,6 +185,10 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: "Error: The OTA partition on the ESP couldn't be found", ), (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), + (espota2.RESPONSE_ERROR_SIGNATURE_INVALID, "Error: Firmware signature verification failed"), + (espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, "Error: The requested OTA type is not supported by the device"), + (espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY, "Error: The partition table update could not be verified"), + (espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE, "Error: An error occurred while updating the partition table"), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) @@ -270,7 +274,7 @@ def test_perform_ota_successful_md5_auth( # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent (compression + SHA256 support) + # Verify features were sent (compression + SHA256 support + extended protocol) assert mock_socket.sendall.call_args_list[1] == call( bytes( [ @@ -641,7 +645,7 @@ def test_perform_ota_successful_sha256_auth( # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent (compression + SHA256 support) + # Verify features were sent (compression + SHA256 support + extended protocol) assert mock_socket.sendall.call_args_list[1] == call( bytes( [ @@ -768,3 +772,41 @@ def test_perform_ota_version_differences( # For v2.0, verify more recv calls due to chunk acknowledgments assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_successful_partition_table(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA partition table update.""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol + bytes([espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS]), # Device feature flags + 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 + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota(mock_socket, "testpass", mock_file, "partitions.bin", espota2.OTA_TYPE_UPDATE_PARTITION_TABLE) + + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) + + # Verify features were sent (compression + SHA256 support + extended protocol) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ] + ) + ) + + # Verify ota type was sent + assert mock_socket.sendall.call_args_list[2] == call(bytes(espota2.OTA_TYPE_UPDATE_PARTITION_TABLE)) From 81294b4c8058ff113c871fe17ae44f47b9cd76ec Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:31:08 +0000 Subject: [PATCH 0600/2214] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_espota2.py | 43 ++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index bab3ef492c..19b4d85b99 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -185,10 +185,22 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: "Error: The OTA partition on the ESP couldn't be found", ), (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), - (espota2.RESPONSE_ERROR_SIGNATURE_INVALID, "Error: Firmware signature verification failed"), - (espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, "Error: The requested OTA type is not supported by the device"), - (espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY, "Error: The partition table update could not be verified"), - (espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE, "Error: An error occurred while updating the partition table"), + ( + espota2.RESPONSE_ERROR_SIGNATURE_INVALID, + "Error: Firmware signature verification failed", + ), + ( + espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, + "Error: The requested OTA type is not supported by the device", + ), + ( + espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY, + "Error: The partition table update could not be verified", + ), + ( + espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE, + "Error: An error occurred while updating the partition table", + ), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) @@ -775,13 +787,20 @@ def test_perform_ota_version_differences( @pytest.mark.usefixtures("mock_time") -def test_perform_ota_successful_partition_table(mock_socket: Mock, mock_file: io.BytesIO) -> None: +def test_perform_ota_successful_partition_table( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: """Test OTA partition table update.""" recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response bytes([espota2.OTA_VERSION_2_0]), # Version number bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol - bytes([espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS]), # Device feature flags + bytes( + [ + espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION + | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ] + ), # Device feature flags 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 @@ -792,7 +811,13 @@ def test_perform_ota_successful_partition_table(mock_socket: Mock, mock_file: io mock_socket.recv.side_effect = recv_responses - espota2.perform_ota(mock_socket, "testpass", mock_file, "partitions.bin", espota2.OTA_TYPE_UPDATE_PARTITION_TABLE) + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "partitions.bin", + espota2.OTA_TYPE_UPDATE_PARTITION_TABLE, + ) # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) @@ -809,4 +834,6 @@ def test_perform_ota_successful_partition_table(mock_socket: Mock, mock_file: io ) # Verify ota type was sent - assert mock_socket.sendall.call_args_list[2] == call(bytes(espota2.OTA_TYPE_UPDATE_PARTITION_TABLE)) + assert mock_socket.sendall.call_args_list[2] == call( + bytes(espota2.OTA_TYPE_UPDATE_PARTITION_TABLE) + ) From 044d42d395dde299143fdce51597e8c9ef3aca79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 16:32:15 -0500 Subject: [PATCH 0601/2214] Revert "[core] Switch Scheduler::call now_64 plumbing from struct to out-param" This reverts commit 4d7033df4f1abecd0b811ed80a337a070f9303ce. --- esphome/core/application.cpp | 3 +-- esphome/core/application.h | 26 ++++++++++++-------------- esphome/core/scheduler.cpp | 11 +++++------ esphome/core/scheduler.h | 26 ++++++++++++++++---------- 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b35db45e26..c390b26cd4 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -83,8 +83,7 @@ void Application::setup() { // Service scheduler and process pending loop enables to handle GPIO // interrupts during setup. During setup we always run the component // phase (no loop_interval_ gate), so call both helpers unconditionally. - uint64_t setup_sched_now_64_unused; - this->scheduler_tick_(MillisInternal::get(), setup_sched_now_64_unused); + this->scheduler_tick_(MillisInternal::get()); { ComponentPhaseGuard phase_guard{*this}; diff --git a/esphome/core/application.h b/esphome/core/application.h index 20a4ffa9a3..af68d0e051 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -410,7 +410,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now, uint64_t &now_64_out); + inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); // RAII guard for a component loop phase. Constructor processes any pending // enable_loop requests from ISRs and marks in_loop_ so reentrant @@ -535,12 +535,12 @@ extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-vari // 64-bit `now_64` (or 0 if any item fired — then stale). The main loop forwards // now_64 to next_schedule_in() to avoid a second esp_timer_get_time() MMIO read // per iteration; 0 means "compute fresh". -inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now, uint64_t &now_64_out) { +inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake. wake_drain_notifications(); #endif - return this->scheduler.call(now, now_64_out); + return this->scheduler.call(now); } // Phase B entry: only invoked when a component loop phase is about to run. @@ -580,14 +580,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Phase A: always service the scheduler. Decouples scheduler cadence from // loop_interval_ so raised intervals (for power savings) don't drag scheduled // items forward. A tick that only runs the scheduler is cheap. - // scheduler_tick_ returns the advanced `now` (for wdt monotonicity) and fills - // sched_now_64 with the 64-bit timestamp Scheduler::call() already computed — - // forward that to the next_schedule_in() call below so we don't re-read - // esp_timer_get_time() a second time per iteration. 0 means "stale, recompute". - // Out-param keeps the return path in a single register and avoids a 16-byte - // struct-copy on the caller's stack. - uint64_t sched_now_64_raw; - uint32_t now = this->scheduler_tick_(MillisInternal::get(), sched_now_64_raw); + // scheduler_tick_ returns the advanced `now` (for wdt monotonicity) plus the + // 64-bit `now_64` Scheduler::call() already computed — forward that to the + // next_schedule_in() call below so we don't re-read esp_timer_get_time() a + // second time per iteration. 0 means "stale, recompute". + const Scheduler::CallResult sched_result = this->scheduler_tick_(MillisInternal::get()); + uint32_t now = sched_result.now; #ifdef USE_RUNTIME_STATS // Capture immediately after scheduler_tick_ returns so the post-scheduler // feed_wdt_with_time slice can be separated from the scheduler slice in @@ -707,10 +705,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); - // Resolve the sched_now_64_raw sentinel here so next_schedule_in() can assume a valid + // Resolve the sched_result.now_64 sentinel here so next_schedule_in() can assume a valid // non-zero input and keep the cold clock-read path out of its body. When call() fired items - // sched_now_64_raw is 0 (stale) and we read the clock fresh; otherwise we reuse it. - const uint64_t sched_now_64 = sched_now_64_raw != 0 ? sched_now_64_raw : this->scheduler.millis_64_from(now); + // sched_result.now_64 is 0 (stale) and we read the clock fresh; otherwise we reuse it. + const uint64_t sched_now_64 = sched_result.now_64 != 0 ? sched_result.now_64 : this->scheduler.millis_64_from(now); const uint32_t until_sched = this->scheduler.next_schedule_in(sched_now_64).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 803807d707..46c798eee0 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -568,7 +568,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -uint32_t HOT Scheduler::call(uint32_t now, uint64_t &now_64_out) { +Scheduler::CallResult HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -752,11 +752,10 @@ uint32_t HOT Scheduler::call(uint32_t now, uint64_t &now_64_out) { #endif // execute_item_() advances `now` as items fire; return it so the caller // stays monotonic with last_wdt_feed_. - // The now_64 we pass back is only usable if NO items fired — execute_item_ - // only advances the uint32 `now`, leaving our local now_64 stale. Signal - // stale with the 0 sentinel so next_schedule_in() reads the clock fresh. - now_64_out = executed_any_item ? 0 : now_64; - return now; + // The returned now_64 is only usable if NO items fired — execute_item_ only + // advances the uint32 `now`, leaving our local now_64 stale. Signal stale + // with the 0 sentinel so next_schedule_in() reads the clock fresh. + return CallResult{now, executed_any_item ? 0 : now_64}; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 2cf559cc9c..c169d458d7 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -136,17 +136,23 @@ class Scheduler { // IMPORTANT: This method should only be called from the main thread (loop task). optional next_schedule_in(uint64_t now_64); - // Execute all scheduled items that are ready. + // Result of Scheduler::call(). + // now – advanced uint32 millis timestamp (monotonic with the caller's wdt feed). + // now_64 – 64-bit millis value Scheduler used for item dispatch, OR 0 if any item + // fired (in that case now_64 is stale because execute_item_ only advances + // the uint32 `now`). A subsequent next_schedule_in() treats 0 as "compute + // fresh" and non-zero as "reuse this value", saving a second millis_64() + // / esp_timer_get_time() call per main-loop iteration. + struct CallResult { + uint32_t now; + uint64_t now_64; + }; + + // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - // @param now_64_out Out-param set to the 64-bit `now` that Scheduler used for item - // dispatch, OR 0 if any item fired (in that case the value is stale because - // execute_item_ only advances the uint32 `now`). A subsequent next_schedule_in() - // treats 0 as "compute fresh" and non-zero as "reuse this value", saving a - // second millis_64() / esp_timer_get_time() call per main-loop iteration. - // Out-param (rather than struct return) keeps the return path in a single - // register and avoids a 16-byte struct-copy on the loop caller's stack. - // @return Advanced `now` (monotonic with the caller's wdt feed). - uint32_t call(uint32_t now, uint64_t &now_64_out); + // @return Both advanced `now` (for wdt monotonicity) and `now_64` (for pipe-through + // to next_schedule_in()). See CallResult. + CallResult call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). From d7e755fb833e9d4b549845642e8e81e750d5db2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 16:32:15 -0500 Subject: [PATCH 0602/2214] Revert "[core] Hoist Scheduler::next_schedule_in zero-check to caller" This reverts commit dfe591c5eb6ed271540ad36a4249b530a7fd65d3. --- esphome/core/application.h | 6 +----- esphome/core/scheduler.cpp | 11 +++++++---- esphome/core/scheduler.h | 17 ++++++----------- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index af68d0e051..a907c97035 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -705,11 +705,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); - // Resolve the sched_result.now_64 sentinel here so next_schedule_in() can assume a valid - // non-zero input and keep the cold clock-read path out of its body. When call() fired items - // sched_result.now_64 is 0 (stale) and we read the clock fresh; otherwise we reuse it. - const uint64_t sched_now_64 = sched_result.now_64 != 0 ? sched_result.now_64 : this->scheduler.millis_64_from(now); - const uint32_t until_sched = this->scheduler.next_schedule_in(sched_now_64).value_or(until_phase); + const uint32_t until_sched = this->scheduler.next_schedule_in(now, sched_result.now_64).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } // All platforms route loop yields through the platform wake primitive. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 46c798eee0..d6d3ef2d2d 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -412,7 +412,7 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { #pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings -optional HOT Scheduler::next_schedule_in(uint64_t now_64) { +optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64) { // IMPORTANT: This method should only be called from the main thread (loop task). // Accesses items_[0] and the fast-path empty checks without holding a lock, which // is only safe from the main thread. Other threads must not call this method. @@ -455,9 +455,12 @@ optional HOT Scheduler::next_schedule_in(uint64_t now_64) { return {}; SchedulerItem *item = this->items_[0]; - // Caller is responsible for passing a fresh non-zero now_64 (see header doc). - // Hoisting the 0-sentinel resolution to the caller keeps the cold fallback - // clock-read out of this function's fast path. + // If the caller did not pass a pre-computed now_64 (or passed the 0 + // sentinel, meaning call() advanced past it by firing items), read the + // clock fresh. Otherwise reuse the passed value to avoid a second + // esp_timer_get_time() MMIO read per main-loop iteration. + if (now_64 == 0) + now_64 = this->millis_64_from_(now); const uint64_t next_exec = item->get_next_execution(); if (next_exec < now_64) return 0; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index c169d458d7..7073507a99 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -120,21 +120,16 @@ class Scheduler { /// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover) uint64_t ESPHOME_ALWAYS_INLINE millis_64() { return esphome::millis_64(); } - // Extend a 32-bit `now` timestamp to a 64-bit one. Public wrapper so callers that need to - // materialize a valid now_64 for next_schedule_in() (after call() returned the 0 sentinel) - // can do so without knowing the platform-specific 64-bit extension strategy. - // See millis_64_from_ for platform notes. - uint64_t ESPHOME_ALWAYS_INLINE millis_64_from(uint32_t now) { return this->millis_64_from_(now); } - // Calculate when the next scheduled item should run. - // @param now_64 64-bit millisecond timestamp. Must be non-zero and fresh — the caller is responsible - // for resolving the 0 sentinel that Scheduler::call() may return (via millis_64_from above). - // Hoisting the zero-check out of this function keeps the cold clock-read path out of the - // hot-path's icache line and simplifies register allocation. + // @param now On ESP32, unused for 64-bit extension (native); on other platforms, extended to 64-bit via rollover. + // @param now_64 Optional pre-computed 64-bit timestamp from a recent call() (see CallResult). + // Pass 0 (sentinel) to have this method compute now_64 freshly. Non-zero values are trusted and + // used directly, saving a millis_64() / esp_timer_get_time() MMIO read when the caller already + // has a fresh value from the same loop iteration. // Returns the time in milliseconds until the next scheduled item, or nullopt if no items. // This method performs cleanup of removed items before checking the schedule. // IMPORTANT: This method should only be called from the main thread (loop task). - optional next_schedule_in(uint64_t now_64); + optional next_schedule_in(uint32_t now, uint64_t now_64 = 0); // Result of Scheduler::call(). // now – advanced uint32 millis timestamp (monotonic with the caller's wdt feed). From 54fdc063229f44f5f1312cbeaeb395c10727be9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 16:32:45 -0500 Subject: [PATCH 0603/2214] Revert "[core] Thread Scheduler now_64 into next_schedule_in to skip duplicate clock read" This reverts commit 75f5b179373c49be22222c3faff931371aa73f28. --- esphome/core/application.h | 24 +++++++++++------------- esphome/core/scheduler.cpp | 21 ++++----------------- esphome/core/scheduler.h | 23 +++-------------------- 3 files changed, 18 insertions(+), 50 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index a907c97035..d195a07d95 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -410,7 +410,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); + inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); // RAII guard for a component loop phase. Constructor processes any pending // enable_loop requests from ISRs and marks in_loop_ so reentrant @@ -531,11 +531,10 @@ extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-vari // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has // raised loop_interval_ for power savings (see Application::loop()). -// Returns the advanced `now` (for monotonic wdt feed) alongside the Scheduler's -// 64-bit `now_64` (or 0 if any item fired — then stale). The main loop forwards -// now_64 to next_schedule_in() to avoid a second esp_timer_get_time() MMIO read -// per iteration; 0 means "compute fresh". -inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { +// Returns the timestamp of the last scheduler item that ran (or `now` +// unchanged if none ran), so the caller's WDT feed stays monotonic with the +// per-item feeds inside scheduler.call() without an extra millis(). +inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake. wake_drain_notifications(); @@ -580,12 +579,11 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Phase A: always service the scheduler. Decouples scheduler cadence from // loop_interval_ so raised intervals (for power savings) don't drag scheduled // items forward. A tick that only runs the scheduler is cheap. - // scheduler_tick_ returns the advanced `now` (for wdt monotonicity) plus the - // 64-bit `now_64` Scheduler::call() already computed — forward that to the - // next_schedule_in() call below so we don't re-read esp_timer_get_time() a - // second time per iteration. 0 means "stale, recompute". - const Scheduler::CallResult sched_result = this->scheduler_tick_(MillisInternal::get()); - uint32_t now = sched_result.now; + // scheduler_tick_ returns the timestamp of the last scheduler item that ran + // (advanced by its per-item feeds) or `now` unchanged. We adopt it as `now` + // so the gate check and WDT feed both reflect actual elapsed time after + // scheduler dispatch, without an extra millis() call. + uint32_t now = this->scheduler_tick_(MillisInternal::get()); #ifdef USE_RUNTIME_STATS // Capture immediately after scheduler_tick_ returns so the post-scheduler // feed_wdt_with_time slice can be separated from the scheduler slice in @@ -705,7 +703,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); - const uint32_t until_sched = this->scheduler.next_schedule_in(now, sched_result.now_64).value_or(until_phase); + const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } // All platforms route loop yields through the platform wake primitive. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d6d3ef2d2d..0dbd2ca465 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -412,7 +412,7 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { #pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings -optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64) { +optional HOT Scheduler::next_schedule_in(uint32_t now) { // IMPORTANT: This method should only be called from the main thread (loop task). // Accesses items_[0] and the fast-path empty checks without holding a lock, which // is only safe from the main thread. Other threads must not call this method. @@ -455,12 +455,7 @@ optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64 return {}; SchedulerItem *item = this->items_[0]; - // If the caller did not pass a pre-computed now_64 (or passed the 0 - // sentinel, meaning call() advanced past it by firing items), read the - // clock fresh. Otherwise reuse the passed value to avoid a second - // esp_timer_get_time() MMIO read per main-loop iteration. - if (now_64 == 0) - now_64 = this->millis_64_from_(now); + const auto now_64 = this->millis_64_from_(now); const uint64_t next_exec = item->get_next_execution(); if (next_exec < now_64) return 0; @@ -571,7 +566,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -Scheduler::CallResult HOT Scheduler::call(uint32_t now) { +uint32_t HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -582,10 +577,6 @@ Scheduler::CallResult HOT Scheduler::call(uint32_t now) { // Track if any items were added to to_add_ during callbacks bool has_added_items = false; - // Track whether we advanced `now` via execute_item_(). If we did, `now_64` - // is stale by the time we return and next_schedule_in() must recompute — - // signal that by writing 0 to *now_64_out. - bool executed_any_item = false; #ifdef ESPHOME_DEBUG_SCHEDULER static uint64_t last_print = 0; @@ -698,7 +689,6 @@ Scheduler::CallResult HOT Scheduler::call(uint32_t now) { // - timeouts/intervals get added, potentially invalidating vector pointers // - timeouts/intervals get cancelled now = this->execute_item_(item, now); - executed_any_item = true; LockGuard guard{this->lock_}; @@ -755,10 +745,7 @@ Scheduler::CallResult HOT Scheduler::call(uint32_t now) { #endif // execute_item_() advances `now` as items fire; return it so the caller // stays monotonic with last_wdt_feed_. - // The returned now_64 is only usable if NO items fired — execute_item_ only - // advances the uint32 `now`, leaving our local now_64 stale. Signal stale - // with the 0 sentinel so next_schedule_in() reads the clock fresh. - return CallResult{now, executed_any_item ? 0 : now_64}; + return now; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7073507a99..590c23503b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -122,32 +122,15 @@ class Scheduler { // Calculate when the next scheduled item should run. // @param now On ESP32, unused for 64-bit extension (native); on other platforms, extended to 64-bit via rollover. - // @param now_64 Optional pre-computed 64-bit timestamp from a recent call() (see CallResult). - // Pass 0 (sentinel) to have this method compute now_64 freshly. Non-zero values are trusted and - // used directly, saving a millis_64() / esp_timer_get_time() MMIO read when the caller already - // has a fresh value from the same loop iteration. // Returns the time in milliseconds until the next scheduled item, or nullopt if no items. // This method performs cleanup of removed items before checking the schedule. // IMPORTANT: This method should only be called from the main thread (loop task). - optional next_schedule_in(uint32_t now, uint64_t now_64 = 0); - - // Result of Scheduler::call(). - // now – advanced uint32 millis timestamp (monotonic with the caller's wdt feed). - // now_64 – 64-bit millis value Scheduler used for item dispatch, OR 0 if any item - // fired (in that case now_64 is stale because execute_item_ only advances - // the uint32 `now`). A subsequent next_schedule_in() treats 0 as "compute - // fresh" and non-zero as "reuse this value", saving a second millis_64() - // / esp_timer_get_time() call per main-loop iteration. - struct CallResult { - uint32_t now; - uint64_t now_64; - }; + optional next_schedule_in(uint32_t now); // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - // @return Both advanced `now` (for wdt monotonicity) and `now_64` (for pipe-through - // to next_schedule_in()). See CallResult. - CallResult call(uint32_t now); + // @return Timestamp of the last item that ran, or `now` unchanged if none ran. + uint32_t call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). From c7dc55ea3392d55a2ddcb83da9e7f7f5f68efa02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 16:39:27 -0500 Subject: [PATCH 0604/2214] Revert "[core] Trim Scheduler::call fast path" This reverts commit c2e6787e23854de00bce7194cce93ee1e4100bc7. --- esphome/core/scheduler.cpp | 21 +++++++-------------- esphome/core/scheduler.h | 2 +- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 0dbd2ca465..17f1331d18 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -615,21 +615,14 @@ uint32_t HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Cleanup removed items before processing. Read the counter once so the - // common zero-case is a single atomic load + branch; the old code called - // cleanup_() (which loads to_remove_) and then to_remove_count_() again for - // the MAX check, producing a redundant memw+load pair on the fast path. - if (this->to_remove_count_() != 0) { - // First try to clean items from the top of the heap (fast path). - this->cleanup_slow_path_(); + // Cleanup removed items before processing + // First try to clean items from the top of the heap (fast path) + this->cleanup_(); - // If we still have too many cancelled items, do a full cleanup. - // This only happens if cancelled items are stuck in the middle/bottom - // of the heap. Re-read to_remove_ because cleanup_slow_path_ may have - // decremented it. - if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { - this->full_cleanup_removed_items_(); - } + // If we still have too many cancelled items, do a full cleanup + // This only happens if cancelled items are stuck in the middle/bottom of the heap + if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { + this->full_cleanup_removed_items_(); } // IMPORTANT: This loop uses index-based access (items_[0]), NOT iterators. // This is intentional — fired intervals are pushed back into items_ via diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 590c23503b..46b19855c3 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -118,7 +118,7 @@ class Scheduler { bool cancel_retry(Component *component, uint32_t id); /// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover) - uint64_t ESPHOME_ALWAYS_INLINE millis_64() { return esphome::millis_64(); } + uint64_t millis_64() { return esphome::millis_64(); } // Calculate when the next scheduled item should run. // @param now On ESP32, unused for 64-bit extension (native); on other platforms, extended to 64-bit via rollover. From 346333dcf973bfd7a0a616a4d34da6949123dd22 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:48:33 +0200 Subject: [PATCH 0605/2214] Update test --- tests/unit_tests/test_espota2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 19b4d85b99..7d6f629195 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -835,5 +835,5 @@ def test_perform_ota_successful_partition_table( # Verify ota type was sent assert mock_socket.sendall.call_args_list[2] == call( - bytes(espota2.OTA_TYPE_UPDATE_PARTITION_TABLE) + bytes([espota2.OTA_TYPE_UPDATE_PARTITION_TABLE]) ) From 614d018e4b29e4b1ea321e7430c99da4c3d89d4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 16:56:02 -0500 Subject: [PATCH 0606/2214] [core] Collapse duplicate to_remove_ read in Scheduler::call fast path Read to_remove_ once at the top of the cleanup block instead of loading it twice (once via cleanup_() -> to_remove_empty_(), once again for the MAX_LOGICALLY_DELETED_ITEMS check). GCC cannot CSE across the memw barriers that std::atomic::load emits on Xtensa, so both the fast-path zero check and the max check were generating independent memw + l32i sequences: memw; l32i a8, [to_remove_]; beqz ... ; to_remove_empty_() memw; l32i a8, [to_remove_]; bltui 5 ; to_remove_count_() Reading the counter once into a register and branching on the result collapses the common zero-case to a single memw + l32i + beqz. The non-zero path pays one extra read after cleanup_slow_path_ (which may have decremented the counter), but that path already takes lock_ so the extra load is negligible. Scheduler::call stays at 344 B (unchanged); no flash layout shift, so adjacent code (feed_wdt_slow_ etc.) stays in the same cache lines and the measurement can't be confounded by MMIO timing drift. An earlier version of this change also marked Scheduler::millis_64() ESPHOME_ALWAYS_INLINE to drop its out-of-line $isra$0 clone; that grew Scheduler::call by 56 B, shifted feed_wdt_slow_ in flash, and measurably regressed the wdt bucket on a busier winefridge.yaml config, so the inlining is dropped. This change keeps only the memw collapse. --- esphome/core/scheduler.cpp | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d83d67d6e4..246bbb2957 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -601,14 +601,24 @@ uint32_t HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Cleanup removed items before processing - // First try to clean items from the top of the heap (fast path) - this->cleanup_(); + // Cleanup removed items before processing. Read the counter once so the + // common zero-case is a single atomic load + branch; the old code called + // cleanup_() (which loads to_remove_) and then to_remove_count_() again for + // the MAX check, producing a redundant memw+load pair on the fast path. + // GCC cannot CSE across the memw barriers that std::atomic::load + // emits on Xtensa, so the duplicate load was unavoidable without manual + // restructuring. + if (this->to_remove_count_() != 0) { + // First try to clean items from the top of the heap (fast path). + this->cleanup_slow_path_(); - // If we still have too many cancelled items, do a full cleanup - // This only happens if cancelled items are stuck in the middle/bottom of the heap - if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { - this->full_cleanup_removed_items_(); + // If we still have too many cancelled items, do a full cleanup. + // This only happens if cancelled items are stuck in the middle/bottom + // of the heap. Re-read to_remove_ because cleanup_slow_path_ may have + // decremented it. + if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { + this->full_cleanup_removed_items_(); + } } // IMPORTANT: This loop uses index-based access (items_[0]), NOT iterators. // This is intentional — fired intervals are pushed back into items_ via From d1a73c119d2ce93fe0034e6b1b0e1855725cd9e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 16:56:02 -0500 Subject: [PATCH 0607/2214] [core] Collapse duplicate to_remove_ read in Scheduler::call fast path Read to_remove_ once at the top of the cleanup block instead of loading it twice (once via cleanup_() -> to_remove_empty_(), once again for the MAX_LOGICALLY_DELETED_ITEMS check). GCC cannot CSE across the memw barriers that std::atomic::load emits on Xtensa, so both the fast-path zero check and the max check were generating independent memw + l32i sequences: memw; l32i a8, [to_remove_]; beqz ... ; to_remove_empty_() memw; l32i a8, [to_remove_]; bltui 5 ; to_remove_count_() Reading the counter once into a register and branching on the result collapses the common zero-case to a single memw + l32i + beqz. The non-zero path pays one extra read after cleanup_slow_path_ (which may have decremented the counter), but that path already takes lock_ so the extra load is negligible. Scheduler::call stays at 344 B (unchanged); no flash layout shift, so adjacent code (feed_wdt_slow_ etc.) stays in the same cache lines and the measurement can't be confounded by MMIO timing drift. An earlier version of this change also marked Scheduler::millis_64() ESPHOME_ALWAYS_INLINE to drop its out-of-line $isra$0 clone; that grew Scheduler::call by 56 B, shifted feed_wdt_slow_ in flash, and measurably regressed the wdt bucket on a busier winefridge.yaml config, so the inlining is dropped. This change keeps only the memw collapse. --- esphome/core/scheduler.cpp | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 17f1331d18..4f0bf0f425 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -615,14 +615,24 @@ uint32_t HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Cleanup removed items before processing - // First try to clean items from the top of the heap (fast path) - this->cleanup_(); + // Cleanup removed items before processing. Read the counter once so the + // common zero-case is a single atomic load + branch; the old code called + // cleanup_() (which loads to_remove_) and then to_remove_count_() again for + // the MAX check, producing a redundant memw+load pair on the fast path. + // GCC cannot CSE across the memw barriers that std::atomic::load + // emits on Xtensa, so the duplicate load was unavoidable without manual + // restructuring. + if (this->to_remove_count_() != 0) { + // First try to clean items from the top of the heap (fast path). + this->cleanup_slow_path_(); - // If we still have too many cancelled items, do a full cleanup - // This only happens if cancelled items are stuck in the middle/bottom of the heap - if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { - this->full_cleanup_removed_items_(); + // If we still have too many cancelled items, do a full cleanup. + // This only happens if cancelled items are stuck in the middle/bottom + // of the heap. Re-read to_remove_ because cleanup_slow_path_ may have + // decremented it. + if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { + this->full_cleanup_removed_items_(); + } } // IMPORTANT: This loop uses index-based access (items_[0]), NOT iterators. // This is intentional — fired intervals are pushed back into items_ via From f3b828242c270d6fb2130c69100238b42ef665bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 17:05:11 -0500 Subject: [PATCH 0608/2214] Revert "[core] Collapse duplicate to_remove_ read in Scheduler::call fast path" This reverts commit d1a73c119d2ce93fe0034e6b1b0e1855725cd9e5. --- esphome/core/scheduler.cpp | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 4f0bf0f425..17f1331d18 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -615,24 +615,14 @@ uint32_t HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Cleanup removed items before processing. Read the counter once so the - // common zero-case is a single atomic load + branch; the old code called - // cleanup_() (which loads to_remove_) and then to_remove_count_() again for - // the MAX check, producing a redundant memw+load pair on the fast path. - // GCC cannot CSE across the memw barriers that std::atomic::load - // emits on Xtensa, so the duplicate load was unavoidable without manual - // restructuring. - if (this->to_remove_count_() != 0) { - // First try to clean items from the top of the heap (fast path). - this->cleanup_slow_path_(); + // Cleanup removed items before processing + // First try to clean items from the top of the heap (fast path) + this->cleanup_(); - // If we still have too many cancelled items, do a full cleanup. - // This only happens if cancelled items are stuck in the middle/bottom - // of the heap. Re-read to_remove_ because cleanup_slow_path_ may have - // decremented it. - if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { - this->full_cleanup_removed_items_(); - } + // If we still have too many cancelled items, do a full cleanup + // This only happens if cancelled items are stuck in the middle/bottom of the heap + if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { + this->full_cleanup_removed_items_(); } // IMPORTANT: This loop uses index-based access (items_[0]), NOT iterators. // This is intentional — fired intervals are pushed back into items_ via From b110f453f88002757b7ab0b6635adfc90a526572 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 17:11:36 -0500 Subject: [PATCH 0609/2214] [core] Outline Scheduler::call cleanup slow path into cold combined helper Fold the to_remove_empty check, cleanup_slow_path_ call, and MAX_LOGICALLY_DELETED_ITEMS threshold check into a single hot-path branch + cold outlined helper. Before this change, the cleanup block in Scheduler::call compiled to two independent memw + l32i sequences (one for to_remove_empty_() inside cleanup_(), one for the separate to_remove_count_() check) because GCC cannot CSE across the memw barriers that std::atomic::load emits on Xtensa. A previous attempt at collapsing these into a single inline branch (#15985) had the right assembly for the memw count but grew Scheduler::call by a handful of bytes and rearranged the control flow enough to nudge sched up ~0.5 us/iter on gatetrigger in practice. This version goes further: cleanup_slow_combined_ is annotated noinline + cold so the entire slow path (both reads, both calls) is pulled out of Scheduler::call entirely. The hot path becomes: memw; l32i a8, [to_remove_]; beqz skip call8 cleanup_slow_combined_ ; unlikely, cold and Scheduler::call's body shrinks 344 B -> 332 B (-12 B, below the dev baseline). Adjacent code (feed_wdt_slow_, etc.) stays in the same flash region, avoiding the cache-layout side-effects that made earlier attempts a net loss on busier configs. The [[unlikely]] attribute on the branch plus the cold attribute on the helper give the compiler permission to keep the skip path straight and push the call out-of-line. --- esphome/core/scheduler.cpp | 33 +++++++++++++++++++++++++-------- esphome/core/scheduler.h | 5 +++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 17f1331d18..b1c46a14bd 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -615,14 +615,16 @@ uint32_t HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Cleanup removed items before processing - // First try to clean items from the top of the heap (fast path) - this->cleanup_(); - - // If we still have too many cancelled items, do a full cleanup - // This only happens if cancelled items are stuck in the middle/bottom of the heap - if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { - this->full_cleanup_removed_items_(); + // Cleanup removed items before processing. Fast path: one atomic load + + // branch; when nothing is pending, skip both slow-path calls entirely. + // Previously the equivalent logic was split across cleanup_() (inline, + // loads to_remove_) and a separate to_remove_count_() check for the MAX + // threshold, which produced two memw+l32i pairs on Xtensa (GCC can't CSE + // across std::atomic's memw barriers). Folding the slow work into one + // outlined cold function keeps Scheduler::call's hot-path footprint small + // and the I-cache happy. + if (this->to_remove_count_() != 0) [[unlikely]] { + this->cleanup_slow_combined_(); } // IMPORTANT: This loop uses index-based access (items_[0]), NOT iterators. // This is intentional — fired intervals are pushed back into items_ via @@ -775,6 +777,21 @@ bool HOT Scheduler::cleanup_slow_path_() { } return !this->items_.empty(); } +// Combined cold path for Scheduler::call. Only called when to_remove_ is +// non-zero. Noinline + cold keeps the two calls and the re-read of +// to_remove_ out of the main loop's hot path; the attribute also lets the +// compiler push this code out to a rarely-touched flash region so the +// scheduler's hot instructions stay in cache. +void Scheduler::cleanup_slow_combined_() { + // First sweep: drop cancelled items from the heap top. + this->cleanup_slow_path_(); + // Re-read to_remove_ because cleanup_slow_path_ may have decremented it. + // If cancelled items remain stuck below the top and the count crossed + // the threshold, do a full sweep. + if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { + this->full_cleanup_removed_items_(); + } +} Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 46b19855c3..53ce87ccf2 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -316,6 +316,11 @@ class Scheduler { } // Slow path for cleanup_() when there are items to remove - defined in scheduler.cpp bool cleanup_slow_path_(); + // Combined slow path for Scheduler::call: runs cleanup_slow_path_ then, if + // cancelled items are still stuck below the heap top, full_cleanup_removed_items_. + // Outlined (noinline, cold) so the Scheduler::call fast path stays one atomic + // load + branch — keeping the I-cache hot for the common zero-to-remove case. + void __attribute__((noinline, cold)) cleanup_slow_combined_(); // Slow path for process_to_add() when there are items to merge - defined in scheduler.cpp void process_to_add_slow_path_(); // Remove and return the front item from the heap as a raw pointer. From f08ea48cc3a38493eab104d25b335feb5c328c64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 17:24:30 -0500 Subject: [PATCH 0610/2214] Revert "[core] Outline Scheduler::call cleanup slow path into cold combined helper" This reverts commit b110f453f88002757b7ab0b6635adfc90a526572. --- esphome/core/scheduler.cpp | 33 ++++++++------------------------- esphome/core/scheduler.h | 5 ----- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index b1c46a14bd..17f1331d18 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -615,16 +615,14 @@ uint32_t HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Cleanup removed items before processing. Fast path: one atomic load + - // branch; when nothing is pending, skip both slow-path calls entirely. - // Previously the equivalent logic was split across cleanup_() (inline, - // loads to_remove_) and a separate to_remove_count_() check for the MAX - // threshold, which produced two memw+l32i pairs on Xtensa (GCC can't CSE - // across std::atomic's memw barriers). Folding the slow work into one - // outlined cold function keeps Scheduler::call's hot-path footprint small - // and the I-cache happy. - if (this->to_remove_count_() != 0) [[unlikely]] { - this->cleanup_slow_combined_(); + // Cleanup removed items before processing + // First try to clean items from the top of the heap (fast path) + this->cleanup_(); + + // If we still have too many cancelled items, do a full cleanup + // This only happens if cancelled items are stuck in the middle/bottom of the heap + if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { + this->full_cleanup_removed_items_(); } // IMPORTANT: This loop uses index-based access (items_[0]), NOT iterators. // This is intentional — fired intervals are pushed back into items_ via @@ -777,21 +775,6 @@ bool HOT Scheduler::cleanup_slow_path_() { } return !this->items_.empty(); } -// Combined cold path for Scheduler::call. Only called when to_remove_ is -// non-zero. Noinline + cold keeps the two calls and the re-read of -// to_remove_ out of the main loop's hot path; the attribute also lets the -// compiler push this code out to a rarely-touched flash region so the -// scheduler's hot instructions stay in cache. -void Scheduler::cleanup_slow_combined_() { - // First sweep: drop cancelled items from the heap top. - this->cleanup_slow_path_(); - // Re-read to_remove_ because cleanup_slow_path_ may have decremented it. - // If cancelled items remain stuck below the top and the count crossed - // the threshold, do a full sweep. - if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { - this->full_cleanup_removed_items_(); - } -} Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 53ce87ccf2..46b19855c3 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -316,11 +316,6 @@ class Scheduler { } // Slow path for cleanup_() when there are items to remove - defined in scheduler.cpp bool cleanup_slow_path_(); - // Combined slow path for Scheduler::call: runs cleanup_slow_path_ then, if - // cancelled items are still stuck below the heap top, full_cleanup_removed_items_. - // Outlined (noinline, cold) so the Scheduler::call fast path stays one atomic - // load + branch — keeping the I-cache hot for the common zero-to-remove case. - void __attribute__((noinline, cold)) cleanup_slow_combined_(); // Slow path for process_to_add() when there are items to merge - defined in scheduler.cpp void process_to_add_slow_path_(); // Remove and return the front item from the heap as a raw pointer. From dc37bd2ab4937ebf69117a4822b190c130dd36bf Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:30:54 +0200 Subject: [PATCH 0611/2214] Update test --- tests/components/ota/test.esp32-idf.yaml | 4 ++ tests/unit_tests/test_espota2.py | 24 ++++++++++++ tests/unit_tests/test_main.py | 47 ++++++++++++++++++++---- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/tests/components/ota/test.esp32-idf.yaml b/tests/components/ota/test.esp32-idf.yaml index dade44d145..0cbf854952 100644 --- a/tests/components/ota/test.esp32-idf.yaml +++ b/tests/components/ota/test.esp32-idf.yaml @@ -1 +1,5 @@ +ota: + - platform: esphome + allow_partition_access: true + <<: !include common.yaml diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 7d6f629195..9ccc459a42 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -837,3 +837,27 @@ def test_perform_ota_successful_partition_table( assert mock_socket.sendall.call_args_list[2] == call( bytes([espota2.OTA_TYPE_UPDATE_PARTITION_TABLE]) ) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_extended_protocol_unsupported( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test OTA fails when extended protocol is required but unsupported.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Device only supports app updates"): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "partitions.bin", + espota2.OTA_TYPE_UPDATE_PARTITION_TABLE, + ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e71c735f2e..ece668b618 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -83,6 +83,7 @@ from esphome.const import ( PLATFORM_RP2040, ) from esphome.core import CORE, EsphomeError +from esphome.espota2 import OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE from esphome.util import BootselResult from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1111,6 +1112,7 @@ class MockArgs: reset: bool = False list_only: bool = False output: str | None = None + partition_table: bool = False def test_upload_program_serial_esp32( @@ -1593,7 +1595,7 @@ def test_upload_program_ota_success( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, "secret", expected_firmware, 0 + ["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -1624,7 +1626,38 @@ def test_upload_program_ota_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, Path("custom.bin"), 0 + ["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP + ) + + +def test_upload_program_ota_partition_table_with_file_arg( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA and partition table.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ] + } + args = MockArgs(file="partitions.bin", partition_table=True) + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], 3232, None, Path("partitions.bin"), OTA_TYPE_UPDATE_PARTITION_TABLE ) @@ -1682,7 +1715,7 @@ def test_upload_program_ota_with_mqtt_resolution( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware, 0 + ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -1730,7 +1763,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.50"], 3232, None, expected_firmware, 0 + ["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -3207,7 +3240,7 @@ def test_upload_program_ota_static_ip_with_mqttip( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware, 0 + ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -3254,7 +3287,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( 3232, None, expected_firmware, - 0, + OTA_TYPE_UPDATE_APP, ) @@ -3419,7 +3452,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware, 0 + ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) From a6c27dcb4b14fd38ed5eefae722dcb72b77140e6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:32:15 +0000 Subject: [PATCH 0612/2214] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_main.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index ece668b618..bfecd03ea4 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1657,7 +1657,11 @@ def test_upload_program_ota_partition_table_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, Path("partitions.bin"), OTA_TYPE_UPDATE_PARTITION_TABLE + ["192.168.1.100"], + 3232, + None, + Path("partitions.bin"), + OTA_TYPE_UPDATE_PARTITION_TABLE, ) @@ -3240,7 +3244,11 @@ def test_upload_program_ota_static_ip_with_mqttip( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP + ["192.168.1.100", "192.168.2.50"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, ) From 65f68a412ae268771435f055709296cc585aba51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 19:58:39 -0500 Subject: [PATCH 0613/2214] [api] Add 48-bit MAC address varint fast path for BLE advertisements Adds a (mac_address) field option that switches uint64 fields holding 48-bit MAC addresses to a specialized varint encoder. The fast path emits exactly 7 bytes when bits [42..47] are non-zero (the common case for real MACs, since OUIs occupy the top 24 bits) -- one bounds check and 7 independent stores instead of the 7-iteration shift+branch loop. calc_uint64_48bit_force mirrors the same fast path in size calculation. Applied to BluetoothLERawAdvertisement.address, the per-advertisement address encode in BluetoothLERawAdvertisementsResponse drops from a serialized per-byte loop to straight-line code. --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_options.proto | 6 ++++++ esphome/components/api/api_pb2.cpp | 4 ++-- esphome/components/api/proto.h | 27 ++++++++++++++++++++++++ script/api_protobuf/api_protobuf.py | 19 +++++++++++++++++ 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c3e4c38633..e385aeee35 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1628,7 +1628,7 @@ message BluetoothLEAdvertisementResponse { message BluetoothLERawAdvertisement { option (inline_encode) = true; - uint64 address = 1 [(force) = true]; + uint64 address = 1 [(force) = true, (mac_address) = true]; sint32 rssi = 2 [(force) = true]; uint32 address_type = 3 [(max_value) = 4]; diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index d5d0b37e8d..ac9c4e59cc 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -110,4 +110,10 @@ extend google.protobuf.FieldOptions { // length varint calculations and direct byte writes, since the length // varint is guaranteed to be 1 byte. optional uint32 max_data_length = 50018; + + // mac_address: Field is a 48-bit MAC address stored in a uint64. + // Emits encode_varint_raw_48bit which has a 7-byte fast path that avoids + // the per-byte loop when the upper bits are non-zero (the common case + // for real MAC addresses, since OUIs occupy the top 24 bits). + optional bool mac_address = 50019 [default=false]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 3d12453939..1866937858 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2348,7 +2348,7 @@ BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO uint8_t *len_pos = pos; ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); if (sub_msg.address_type) { @@ -2369,7 +2369,7 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; size += 2; - size += ProtoSize::calc_uint64_force(1, sub_msg.address); + size += ProtoSize::calc_uint64_48bit_force(1, sub_msg.address); size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); size += sub_msg.address_type ? 2 : 0; size += 2 + sub_msg.data_len; diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 8cac7fff3b..d274c89537 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -342,6 +342,26 @@ class ProtoEncode { } encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); } + /// Encode a 48-bit MAC address (stored in a uint64) as varint. + /// Real MAC addresses occupy the full 48 bits (OUI in upper 24), so the + /// fast path -- any non-zero bit in bits [42..47] -- emits exactly 7 bytes + /// with no per-byte branch. Falls back to the general loop otherwise. + static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_48bit(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + uint64_t value) { + if (value >= (1ULL << 42)) [[likely]] { + PROTO_ENCODE_CHECK_BOUNDS(pos, 7); + pos[0] = static_cast(value | 0x80); + pos[1] = static_cast((value >> 7) | 0x80); + pos[2] = static_cast((value >> 14) | 0x80); + pos[3] = static_cast((value >> 21) | 0x80); + pos[4] = static_cast((value >> 28) | 0x80); + pos[5] = static_cast((value >> 35) | 0x80); + pos[6] = static_cast(value >> 42); + pos += 7; + return; + } + encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); + } static inline void ESPHOME_ALWAYS_INLINE encode_field_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t type) { encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, (field_id << 3) | type); @@ -817,6 +837,13 @@ class ProtoSize { static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_force(uint32_t field_id_size, uint64_t value) { return field_id_size + varint(value); } + /// 48-bit MAC address variant: matches encode_varint_raw_48bit's fast path. + /// When value uses any of bits [42..47] the encoded varint is 7 bytes; + /// otherwise fall back to the general size calculation. + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_48bit_force(uint32_t field_id_size, + uint64_t value) { + return field_id_size + (value >= (1ULL << 42) ? 7 : varint(value)); + } static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) { return len ? field_id_size + varint(static_cast(len)) + static_cast(len) : 0; } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index c10479a726..a0dd26b1fe 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -184,6 +184,11 @@ class TypeInfo(ABC): """Check if this field should always be encoded (skip zero/empty check).""" return get_field_opt(self._field, pb.force, False) + @property + def mac_address(self) -> bool: + """Check if this uint64 field is a 48-bit MAC address (use 7-byte fast path).""" + return get_field_opt(self._field, pb.mac_address, False) + @property def max_value(self) -> int | None: """Get the max_value option for this field, or None if not set.""" @@ -665,8 +670,22 @@ class UInt64Type(VarintTypeMixin, TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: + if self.mac_address and force: + field_id_size = self.calculate_field_id_size() + return ( + f"size += ProtoSize::calc_uint64_48bit_force({field_id_size}, {name});" + ) return self._get_simple_size_calculation(name, force, "uint64") + @property + def RAW_ENCODE_MAP(self) -> dict[str, str]: # noqa: N802 + if self.mac_address: + return { + **TypeInfo.RAW_ENCODE_MAP, + "encode_uint64": "ProtoEncode::encode_varint_raw_48bit(pos, {value});", + } + return TypeInfo.RAW_ENCODE_MAP + def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint From c27f9e512b63ced298e3634eb2cc17be92d521b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:28:04 +0000 Subject: [PATCH 0614/2214] Bump aioesphomeapi from 44.21.0 to 44.22.0 (#15989) 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 71db6d3444..c1838d3b51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260408.1 -aioesphomeapi==44.21.0 +aioesphomeapi==44.22.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From a437b3086bed5e7dae2c448c09ff4d0df6320bf6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:30:10 +0000 Subject: [PATCH 0615/2214] Bump cryptography from 46.0.7 to 47.0.0 (#15990) 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 c1838d3b51..ba7adaa747 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==46.0.7 +cryptography==47.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From ba362a7c95e9cffce5b6a39da32b95f1c9f3bd3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 21:43:13 -0500 Subject: [PATCH 0616/2214] [api] Narrow 48-bit MAC varint fast path to values < 2^48 Per Copilot review: encode_varint_raw_48bit and calc_uint64_48bit_force would silently truncate bits 48..63 if ever called with a uint64 that doesn't fit in 48 bits. Real MAC addresses always fit, but since the helpers are exposed and the (mac_address) option is generic, narrow the fast path to [1<<42, 1<<48) so values outside that range fall back to the general encoder/size helpers. Re-verified byte-identical output and decoder round-trip across the 1<<48 boundary and all bit positions up to 63. --- esphome/components/api/proto.h | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index d274c89537..30efdfbb61 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -344,11 +344,13 @@ class ProtoEncode { } /// Encode a 48-bit MAC address (stored in a uint64) as varint. /// Real MAC addresses occupy the full 48 bits (OUI in upper 24), so the - /// fast path -- any non-zero bit in bits [42..47] -- emits exactly 7 bytes - /// with no per-byte branch. Falls back to the general loop otherwise. + /// fast path -- any non-zero bit in bits [42..47] with value fitting in + /// 48 bits -- emits exactly 7 bytes with no per-byte branch. Values outside + /// [1<<42, 1<<48) fall back to the general encoder to preserve correctness + /// if this helper is ever misapplied to a non-MAC uint64. static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_48bit(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint64_t value) { - if (value >= (1ULL << 42)) [[likely]] { + if (value >= (1ULL << 42) && value < (1ULL << 48)) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 7); pos[0] = static_cast(value | 0x80); pos[1] = static_cast((value >> 7) | 0x80); @@ -838,11 +840,12 @@ class ProtoSize { return field_id_size + varint(value); } /// 48-bit MAC address variant: matches encode_varint_raw_48bit's fast path. - /// When value uses any of bits [42..47] the encoded varint is 7 bytes; - /// otherwise fall back to the general size calculation. + /// When value is in [1<<42, 1<<48) the encoded varint is 7 bytes; values + /// outside that range fall back to the general size calculation so the + /// result stays correct if this helper is ever misapplied. static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_48bit_force(uint32_t field_id_size, uint64_t value) { - return field_id_size + (value >= (1ULL << 42) ? 7 : varint(value)); + return field_id_size + ((value >= (1ULL << 42) && value < (1ULL << 48)) ? 7 : varint(value)); } static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) { return len ? field_id_size + varint(static_cast(len)) + static_cast(len) : 0; From 7ab701e16232abe1c272a4b86192cda6e8933922 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 04:13:30 -0500 Subject: [PATCH 0617/2214] [api] Gate 48-bit MAC range check behind ESPHOME_DEBUG_API Copilot flagged that encode_varint_raw_48bit/calc_uint64_48bit_force would silently truncate for uint64 values >= 2^48. In practice the (mac_address) option is only applied to fields populated by the BLE stack, which always fits in 48 bits -- so the runtime upper-bound check added in ba362a7c95 regressed CodSpeed by up to 8.7pp on CalculateSize_BLERawAdvs12 for a scenario that can't happen. Move the value-fits-in-48-bits check to a debug assert guarded by ESPHOME_DEBUG_API, and express 48 via MAC_ADDRESS_SIZE * 8 so the threshold tracks the existing MAC size constant. Release builds are back to the original fast path; debug builds catch misuse. --- esphome/components/api/proto.h | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 30efdfbb61..3ff65029e1 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -344,13 +344,17 @@ class ProtoEncode { } /// Encode a 48-bit MAC address (stored in a uint64) as varint. /// Real MAC addresses occupy the full 48 bits (OUI in upper 24), so the - /// fast path -- any non-zero bit in bits [42..47] with value fitting in - /// 48 bits -- emits exactly 7 bytes with no per-byte branch. Values outside - /// [1<<42, 1<<48) fall back to the general encoder to preserve correctness - /// if this helper is ever misapplied to a non-MAC uint64. + /// fast path -- any non-zero bit in the top 6 of 48 -- emits exactly 7 bytes + /// with no per-byte branch. Falls back to the general loop otherwise. + /// Caller must guarantee value fits in 48 bits (checked in debug builds). static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_48bit(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint64_t value) { - if (value >= (1ULL << 42) && value < (1ULL << 48)) [[likely]] { +#ifdef ESPHOME_DEBUG_API + assert(value < (1ULL << (MAC_ADDRESS_SIZE * 8)) && "encode_varint_raw_48bit: value exceeds 48 bits"); +#endif + // 7-byte varint holds 49 bits (7 * 7), so a 48-bit value needs all 7 bytes + // whenever bit 42 or higher is set (i.e. value >= 1 << (48 - 6)). + if (value >= (1ULL << (MAC_ADDRESS_SIZE * 8 - 6))) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 7); pos[0] = static_cast(value | 0x80); pos[1] = static_cast((value >> 7) | 0x80); @@ -840,12 +844,12 @@ class ProtoSize { return field_id_size + varint(value); } /// 48-bit MAC address variant: matches encode_varint_raw_48bit's fast path. - /// When value is in [1<<42, 1<<48) the encoded varint is 7 bytes; values - /// outside that range fall back to the general size calculation so the - /// result stays correct if this helper is ever misapplied. + /// When any of the top 6 of 48 bits is set the encoded varint is 7 bytes; + /// otherwise fall back to the general size calculation. + /// Caller must guarantee value fits in 48 bits (encoder asserts in debug). static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_48bit_force(uint32_t field_id_size, uint64_t value) { - return field_id_size + ((value >= (1ULL << 42) && value < (1ULL << 48)) ? 7 : varint(value)); + return field_id_size + (value >= (1ULL << (MAC_ADDRESS_SIZE * 8 - 6)) ? 7 : varint(value)); } static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) { return len ? field_id_size + varint(static_cast(len)) + static_cast(len) : 0; From 946af918214da2cac8988ffc441bd18d103ccd6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 04:22:39 -0500 Subject: [PATCH 0618/2214] [api] Add unit tests for 48-bit MAC varint encoder Verifies encode_varint_raw_48bit and calc_uint64_48bit_force for the 8 corner-case MAC addresses requested in review: 00:00:00:00:00:00, 11:00:00:00:00:00, 00:AA:00:00:00:00, 00:00:BB:00:00:00, 00:00:00:CC:00:00, 00:00:00:00:DD:00, 00:00:00:00:00:EE, FF:FF:FF:FF:FF:FF For each value the test asserts byte-identical output to the reference encode_varint_raw_64 loop, the expected encoded byte length, agreement with calc_uint64_48bit_force, and round-trip through a generic varint decoder. Adds a 100-value deterministic-random sample across the full 48-bit space for additional coverage. --- .../components/api/test_proto_mac_varint.cpp | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/components/api/test_proto_mac_varint.cpp diff --git a/tests/components/api/test_proto_mac_varint.cpp b/tests/components/api/test_proto_mac_varint.cpp new file mode 100644 index 0000000000..7597c89806 --- /dev/null +++ b/tests/components/api/test_proto_mac_varint.cpp @@ -0,0 +1,119 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// Generic varint decoder, used to verify the encoded bytes round-trip back to +// the original 48-bit MAC value, independent of the specialized encoder under +// test. +static uint64_t decode_varint(const uint8_t *buf, size_t len, size_t *consumed) { + uint64_t value = 0; + int shift = 0; + for (size_t i = 0; i < len; i++) { + value |= static_cast(buf[i] & 0x7F) << shift; + if ((buf[i] & 0x80) == 0) { + *consumed = i + 1; + return value; + } + shift += 7; + } + *consumed = 0; + return 0; +} + +// Reference encoder mirroring ProtoEncode::encode_varint_raw_64. +static size_t reference_encode(uint64_t value, uint8_t *out) { + uint8_t *p = out; + if (value < 128) { + *p++ = static_cast(value); + return p - out; + } + do { + *p++ = static_cast(value | 0x80); + value >>= 7; + } while (value > 0x7F); + *p++ = static_cast(value); + return p - out; +} + +// Encode `mac` via the 48-bit fast path and verify: +// - byte-identical output to the reference loop +// - encoded byte length matches `expected_bytes` +// - calc_uint64_48bit_force agrees on the size +// - the bytes round-trip through a generic varint decoder +static void verify_mac(uint64_t mac, size_t expected_bytes) { + ASSERT_LT(mac, 1ULL << 48) << "test fixture mac exceeds 48 bits"; + + uint8_t ref_buf[16] = {0}; + size_t ref_len = reference_encode(mac, ref_buf); + + APIBuffer api_buf; + api_buf.resize(16); + uint8_t *pos = api_buf.data(); + PROTO_ENCODE_DEBUG_INIT(&api_buf); + ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac); + size_t new_len = pos - api_buf.data(); + + EXPECT_EQ(new_len, expected_bytes) << "mac=0x" << std::hex << mac << std::dec; + EXPECT_EQ(ref_len, expected_bytes) << "reference disagrees on length for mac=0x" << std::hex << mac << std::dec; + + for (size_t i = 0; i < new_len; i++) { + EXPECT_EQ(api_buf.data()[i], ref_buf[i]) + << "byte " << i << " differs for mac=0x" << std::hex << mac << " (got 0x" << static_cast(api_buf.data()[i]) + << ", expected 0x" << static_cast(ref_buf[i]) << ")" << std::dec; + } + + size_t consumed = 0; + uint64_t decoded = decode_varint(api_buf.data(), new_len, &consumed); + EXPECT_EQ(consumed, new_len) << "decoder did not consume all bytes for mac=0x" << std::hex << mac << std::dec; + EXPECT_EQ(decoded, mac) << "round-trip mismatch for mac=0x" << std::hex << mac << std::dec; + + // Verify the size helper agrees. field_id_size = 1 (typical 1-byte tag). + uint32_t calc_size = ProtoSize::calc_uint64_48bit_force(1, mac); + EXPECT_EQ(calc_size, 1 + expected_bytes) + << "calc_uint64_48bit_force size mismatch for mac=0x" << std::hex << mac << std::dec; +} + +// Compute the canonical varint byte length for a value < 1<<48. +static size_t expected_varint_len(uint64_t v) { + if (v < (1ULL << 7)) + return 1; + if (v < (1ULL << 14)) + return 2; + if (v < (1ULL << 21)) + return 3; + if (v < (1ULL << 28)) + return 4; + if (v < (1ULL << 35)) + return 5; + if (v < (1ULL << 42)) + return 6; + return 7; +} + +// --- Specific MACs requested for verification --- + +TEST(ProtoMacVarint, AllZeros) { verify_mac(0x000000000000ULL, 1); } // 00:00:00:00:00:00 +TEST(ProtoMacVarint, FirstByteOnly) { verify_mac(0x110000000000ULL, 7); } // 11:00:00:00:00:00 +TEST(ProtoMacVarint, SecondByteOnly) { verify_mac(0x00AA00000000ULL, 6); } // 00:AA:00:00:00:00 +TEST(ProtoMacVarint, ThirdByteOnly) { verify_mac(0x0000BB000000ULL, 5); } // 00:00:BB:00:00:00 +TEST(ProtoMacVarint, FourthByteOnly) { verify_mac(0x000000CC0000ULL, 4); } // 00:00:00:CC:00:00 +TEST(ProtoMacVarint, FifthByteOnly) { verify_mac(0x00000000DD00ULL, 3); } // 00:00:00:00:DD:00 +TEST(ProtoMacVarint, SixthByteOnly) { verify_mac(0x0000000000EEULL, 2); } // 00:00:00:00:00:EE +TEST(ProtoMacVarint, AllOnes) { verify_mac(0xFFFFFFFFFFFFULL, 7); } // FF:FF:FF:FF:FF:FF + +// 100 deterministic-random 48-bit MACs to catch regressions across the space. +TEST(ProtoMacVarint, RandomSample) { + std::mt19937_64 rng(0xC0FFEE); + for (int i = 0; i < 100; i++) { + uint64_t mac = rng() & 0xFFFFFFFFFFFFULL; + verify_mac(mac, expected_varint_len(mac)); + } +} + +} // namespace esphome::api::testing From 68ffd3b22125ab0c9ed75e14893bf633dbeff0c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 04:36:32 -0500 Subject: [PATCH 0619/2214] [api] Fix CI errors in MAC varint unit tests - Test file: declare proto_debug_end_ locally instead of misusing PROTO_ENCODE_DEBUG_INIT (which expands to a comma+expression for appending to a function call, not a standalone statement). Add NOLINTNEXTLINE on the deterministic mt19937_64 seed so clang-tidy cert-msc32-c stops failing the build (the seed is intentional for reproducible test runs). - socket FILTER_SOURCE_FILES: tolerate non-dict CORE.config['socket'] (e.g. C++ unit-test builds where socket isn't validated as a mapping). Returning [] is safe -- all impl files are guarded by USE_SOCKET_IMPL_* defines so only the selected one contributes code. --- esphome/components/socket/__init__.py | 9 ++++++++- tests/components/api/test_proto_mac_varint.cpp | 6 +++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index abbbb0f056..c7b2fec35f 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -177,7 +177,14 @@ async def to_code(config): def FILTER_SOURCE_FILES() -> list[str]: """Return list of socket implementation files that aren't selected by the user.""" - impl = CORE.config["socket"][CONF_IMPLEMENTATION] + socket_config = CORE.config.get("socket") + if not isinstance(socket_config, dict): + # Config has not been validated yet (or socket isn't configured as a + # mapping for this build, e.g. a C++ unit test build that skips config + # validation). Don't filter -- all impl files are guarded by USE_* + # defines so only the selected implementation contributes code anyway. + return [] + impl = socket_config[CONF_IMPLEMENTATION] # Build list of files to exclude based on selected implementation excluded = [] diff --git a/tests/components/api/test_proto_mac_varint.cpp b/tests/components/api/test_proto_mac_varint.cpp index 7597c89806..317a6fb9d6 100644 --- a/tests/components/api/test_proto_mac_varint.cpp +++ b/tests/components/api/test_proto_mac_varint.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include "esphome/components/api/api_buffer.h" @@ -55,7 +56,9 @@ static void verify_mac(uint64_t mac, size_t expected_bytes) { APIBuffer api_buf; api_buf.resize(16); uint8_t *pos = api_buf.data(); - PROTO_ENCODE_DEBUG_INIT(&api_buf); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size(); +#endif ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac); size_t new_len = pos - api_buf.data(); @@ -109,6 +112,7 @@ TEST(ProtoMacVarint, AllOnes) { verify_mac(0xFFFFFFFFFFFFULL, 7); } // F // 100 deterministic-random 48-bit MACs to catch regressions across the space. TEST(ProtoMacVarint, RandomSample) { + // NOLINTNEXTLINE(cert-msc32-c,cert-msc51-cpp) -- intentional fixed seed for reproducibility. std::mt19937_64 rng(0xC0FFEE); for (int i = 0; i < 100; i++) { uint64_t mac = rng() & 0xFFFFFFFFFFFFULL; From 45525c8a82c7ad1ee7f7e5bcfc6d27e7dde72481 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 04:41:17 -0500 Subject: [PATCH 0620/2214] [api] Make api a buildable cpp unit test target Three fixes so 'script/cpp_unit_test.py api' actually compiles instead of crashing in build setup: 1. script/build_helpers.py: when adding transitive component dependencies to the post-validation config, use {} (dict) instead of [] (list) for non-MULTI_CONF components. socket's FILTER_SOURCE_FILES (and any other code that subscripts CORE.config[component] with a string key) was crashing because socket got config = [] from setdefault. 2. esphome/components/api/api_pb2_service.cpp + the codegen in script/api_protobuf/api_protobuf.py: wrap the generated APIConnection::read_message_ definition in #ifdef USE_API. The class itself is only declared inside #ifdef USE_API in api_connection.h, so without the guard the .cpp fails to compile in any build that pulls in the api source files without setting USE_API (e.g. cpp unit tests of api dependencies). --- esphome/components/api/api_pb2_service.cpp | 2 ++ esphome/components/socket/__init__.py | 9 +-------- script/api_protobuf/api_protobuf.py | 8 +++++++- script/build_helpers.py | 9 +++++++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 6ae2a3e369..0ba2961a13 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -21,6 +21,7 @@ void APIServerConnectionBase::log_receive_message_(const LogString *name) { } #endif +#ifdef USE_API void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { // Check authentication/connection requirements switch (msg_type) { @@ -706,5 +707,6 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } } +#endif // USE_API } // namespace esphome::api diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index c7b2fec35f..abbbb0f056 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -177,14 +177,7 @@ async def to_code(config): def FILTER_SOURCE_FILES() -> list[str]: """Return list of socket implementation files that aren't selected by the user.""" - socket_config = CORE.config.get("socket") - if not isinstance(socket_config, dict): - # Config has not been validated yet (or socket isn't configured as a - # mapping for this build, e.g. a C++ unit test build that skips config - # validation). Don't filter -- all impl files are guarded by USE_* - # defines so only the selected implementation contributes code anyway. - return [] - impl = socket_config[CONF_IMPLEMENTATION] + impl = CORE.config["socket"][CONF_IMPLEMENTATION] # Build list of files to exclude based on selected implementation excluded = [] diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index a0dd26b1fe..bf672d0567 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3577,8 +3577,13 @@ static const char *const TAG = "api.service"; # Generate read_message_ as APIConnection method (not base class) so the compiler # can devirtualize and inline the on_* handler calls within the same class. # APIConnection declares this method in api_connection.h. + # Guard with #ifdef USE_API since APIConnection itself is only defined when + # USE_API is set; without this, builds that compile this .cpp without + # USE_API (e.g. C++ unit tests for api dependencies) fail to find the + # class declaration. - out = "void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {\n" + out = "#ifdef USE_API\n" + out += "void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {\n" # Auth check block before dispatch switch out += " // Check authentication/connection requirements\n" @@ -3623,6 +3628,7 @@ static const char *const TAG = "api.service"; out += " break;\n" out += " }\n" out += "}\n" + out += "#endif // USE_API\n" cpp += out hpp += "};\n" diff --git a/script/build_helpers.py b/script/build_helpers.py index 1cfae51fca..59dded66dd 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -324,8 +324,13 @@ def compile_and_get_binary( domain_list.append({CONF_PLATFORM: component}) # Skip "core" — it's a pseudo-component handled by the build # system, not a real loadable component (get_component returns None) - elif get_component(component_name) is not None: - config.setdefault(component_name, []) + elif (component := get_component(component_name)) is not None: + # MULTI_CONF components store their config as a list of dicts, + # everything else stores a single dict. Using the wrong shape + # breaks code paths that subscript CORE.config[component] with + # a string key (e.g. socket.FILTER_SOURCE_FILES). + default = [] if component.multi_conf else {} + config.setdefault(component_name, default) # Register platforms from the extra config (benchmark.yaml) so # USE_SENSOR, USE_LIGHT, etc. defines are emitted without needing From 5f8e991ed8cc4725a3b64ec958bdf0a0885d4e66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 04:44:35 -0500 Subject: [PATCH 0621/2214] [api] Populate schema defaults for transitive cpp test deps; add json override - script/build_helpers.py: when injecting a non-MULTI_CONF component into the post-validation config, run its CONFIG_SCHEMA with {} so defaults are populated. Without this, socket got config = {} and socket.FILTER_SOURCE_FILES crashed with KeyError on 'implementation' (the schema's defaulted key was never filled in). Falls back to {} if the schema can't validate empty input. - tests/components/json/__init__.py: enable codegen for json so its to_code runs during cpp unit test builds, registering the ArduinoJson library. Required for any api dep test, since json_util.cpp #includes . Locally verified 'script/cpp_unit_test.py api' now compiles and runs; ProtoMacVarint test suite (9 cases) passes. --- script/build_helpers.py | 20 +++++++++++++++----- tests/components/json/__init__.py | 9 +++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 tests/components/json/__init__.py diff --git a/script/build_helpers.py b/script/build_helpers.py index 59dded66dd..4cf2f93fbb 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -326,11 +326,21 @@ def compile_and_get_binary( # system, not a real loadable component (get_component returns None) elif (component := get_component(component_name)) is not None: # MULTI_CONF components store their config as a list of dicts, - # everything else stores a single dict. Using the wrong shape - # breaks code paths that subscript CORE.config[component] with - # a string key (e.g. socket.FILTER_SOURCE_FILES). - default = [] if component.multi_conf else {} - config.setdefault(component_name, default) + # everything else stores a single dict. Run the component's + # schema with {} so defaults get populated -- code paths like + # socket.FILTER_SOURCE_FILES expect a fully-populated mapping. + if component.multi_conf: + config.setdefault(component_name, []) + elif component_name not in config: + schema = component.config_schema + try: + config[component_name] = schema({}) if schema is not None else {} + except Exception: # noqa: BLE001 + # Schema requires explicit input we can't synthesize; fall + # back to an empty mapping so subscripting at least returns + # KeyError on missing keys rather than crashing on the + # wrong type. + config[component_name] = {} # Register platforms from the extra config (benchmark.yaml) so # USE_SENSOR, USE_LIGHT, etc. defines are emitted without needing diff --git a/tests/components/json/__init__.py b/tests/components/json/__init__.py new file mode 100644 index 0000000000..40ec1f996e --- /dev/null +++ b/tests/components/json/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # json's to_code calls cg.add_library("bblanchon/ArduinoJson", ...). C++ + # unit test builds that pull json in transitively (e.g. api) need that + # library registration to happen, otherwise json_util.cpp fails to find + # ArduinoJson.h. + manifest.enable_codegen() From 1416574b0487feb2612fa48354474fddde1a8739 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 04:55:25 -0500 Subject: [PATCH 0622/2214] Revert "[bluetooth_proxy] Replace loop() with set_interval for advertisement flushing (#15347)" This reverts commit 27c662e73faf6583179084d6a179816751d8d170. --- .../bluetooth_proxy/bluetooth_proxy.cpp | 48 +++++++++++++------ .../bluetooth_proxy/bluetooth_proxy.h | 17 ++----- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c69163b1f7..87206996b2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -30,19 +30,6 @@ void BluetoothProxy::setup() { this->configured_scan_active_ = this->parent_->get_scan_active(); this->parent_->add_scanner_state_listener(this); - - this->set_interval(100, [this]() { - 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(); - } - } - }); } void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { @@ -114,15 +101,25 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements_(); + this->flush_pending_advertisements(); } } return true; } -void BluetoothProxy::log_advertisement_flush_() { +void BluetoothProxy::flush_pending_advertisements() { + if (this->response_.advertisements_len == 0 || !api::global_api_server->is_connected() || + this->api_connection_ == nullptr) + return; + + // Send the message + this->api_connection_->send_message(this->response_); + ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + + // Reset the length for the next batch + this->response_.advertisements_len = 0; } void BluetoothProxy::dump_config() { @@ -133,6 +130,27 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connection_count_); } +void BluetoothProxy::loop() { + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { + 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(); + } + } + return; + } + + // Flush any pending BLE advertisements that have been accumulated but not yet sent + uint32_t now = App.get_loop_component_start_time(); + + // Flush accumulated advertisements every 100ms + if (now - this->last_advertisement_flush_time_ >= 100) { + this->flush_pending_advertisements(); + this->last_advertisement_flush_time_ = now; + } +} + esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 6680ab0e84..f1b723e719 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -65,6 +65,8 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; void dump_config() override; void setup() override; + void loop() override; + void flush_pending_advertisements(); esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { @@ -148,18 +150,6 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, protected: void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); - /// Caller must ensure api_connection_ is non-null and API server is connected. - void flush_pending_advertisements_() { - if (this->response_.advertisements_len == 0) - return; - this->api_connection_->send_message(this->response_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - this->log_advertisement_flush_(); -#endif - this->response_.advertisements_len = 0; - } - void log_advertisement_flush_(); - BluetoothConnection *get_connection_(uint64_t address, bool reserve); void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); @@ -176,6 +166,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, // 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 95d7335b61db6e11cb1cc17e4be661a461733d66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 05:01:04 -0500 Subject: [PATCH 0623/2214] Revert "[bluetooth_proxy] Replace loop() with set_interval for advertisement flushing (#15347)" This reverts commit 27c662e73faf6583179084d6a179816751d8d170. --- .../bluetooth_proxy/bluetooth_proxy.cpp | 48 +++++++++++++------ .../bluetooth_proxy/bluetooth_proxy.h | 17 ++----- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c69163b1f7..87206996b2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -30,19 +30,6 @@ void BluetoothProxy::setup() { this->configured_scan_active_ = this->parent_->get_scan_active(); this->parent_->add_scanner_state_listener(this); - - this->set_interval(100, [this]() { - 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(); - } - } - }); } void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { @@ -114,15 +101,25 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements_(); + this->flush_pending_advertisements(); } } return true; } -void BluetoothProxy::log_advertisement_flush_() { +void BluetoothProxy::flush_pending_advertisements() { + if (this->response_.advertisements_len == 0 || !api::global_api_server->is_connected() || + this->api_connection_ == nullptr) + return; + + // Send the message + this->api_connection_->send_message(this->response_); + ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + + // Reset the length for the next batch + this->response_.advertisements_len = 0; } void BluetoothProxy::dump_config() { @@ -133,6 +130,27 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connection_count_); } +void BluetoothProxy::loop() { + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { + 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(); + } + } + return; + } + + // Flush any pending BLE advertisements that have been accumulated but not yet sent + uint32_t now = App.get_loop_component_start_time(); + + // Flush accumulated advertisements every 100ms + if (now - this->last_advertisement_flush_time_ >= 100) { + this->flush_pending_advertisements(); + this->last_advertisement_flush_time_ = now; + } +} + esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 6680ab0e84..f1b723e719 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -65,6 +65,8 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; void dump_config() override; void setup() override; + void loop() override; + void flush_pending_advertisements(); esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { @@ -148,18 +150,6 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, protected: void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); - /// Caller must ensure api_connection_ is non-null and API server is connected. - void flush_pending_advertisements_() { - if (this->response_.advertisements_len == 0) - return; - this->api_connection_->send_message(this->response_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - this->log_advertisement_flush_(); -#endif - this->response_.advertisements_len = 0; - } - void log_advertisement_flush_(); - BluetoothConnection *get_connection_(uint64_t address, bool reserve); void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); @@ -176,6 +166,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, // 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 b33878f71e0a9d16dd76cc521895260096fca60f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 05:18:08 -0500 Subject: [PATCH 0624/2214] [bluetooth_proxy] Restore inlined flush helper, verbose-log gating, and 100ms-gated cleanup Keeps the structural improvements from #15347 while reverting only the loop()->set_interval move that became a pessimization after #15792. - flush_pending_advertisements_() inlined in header (was: out-of-line .cpp) - log_advertisement_flush_() out-of-line, gated by ESPHOME_LOG_LEVEL_VERBOSE - loop() gates both halves (flush + connection cleanup) at 100ms cadence rather than running cleanup at every Phase B tick This is now a partial revert of #15347 rather than a full revert. --- .../bluetooth_proxy/bluetooth_proxy.cpp | 42 +++++++------------ .../bluetooth_proxy/bluetooth_proxy.h | 13 +++++- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 87206996b2..45f848a286 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -101,25 +101,15 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements(); + this->flush_pending_advertisements_(); } } return true; } -void BluetoothProxy::flush_pending_advertisements() { - if (this->response_.advertisements_len == 0 || !api::global_api_server->is_connected() || - this->api_connection_ == nullptr) - return; - - // Send the message - this->api_connection_->send_message(this->response_); - +void BluetoothProxy::log_advertisement_flush_() { ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); - - // Reset the length for the next batch - this->response_.advertisements_len = 0; } void BluetoothProxy::dump_config() { @@ -131,23 +121,21 @@ void BluetoothProxy::dump_config() { } void BluetoothProxy::loop() { - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { - 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(); - } - } + // 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; } - - // Flush any pending BLE advertisements that have been accumulated but not yet sent - uint32_t now = App.get_loop_component_start_time(); - - // Flush accumulated advertisements every 100ms - if (now - this->last_advertisement_flush_time_ >= 100) { - this->flush_pending_advertisements(); - this->last_advertisement_flush_time_ = now; + 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(); + } } } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index f1b723e719..10449f21f1 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -66,7 +66,6 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, void dump_config() override; void setup() override; void loop() override; - void flush_pending_advertisements(); esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { @@ -150,6 +149,18 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, protected: void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); + /// Caller must ensure api_connection_ is non-null and API server is connected. + void flush_pending_advertisements_() { + if (this->response_.advertisements_len == 0) + return; + this->api_connection_->send_message(this->response_); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + this->log_advertisement_flush_(); +#endif + this->response_.advertisements_len = 0; + } + void log_advertisement_flush_(); + BluetoothConnection *get_connection_(uint64_t address, bool reserve); void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); From 1574cf62566f6ae63f0209a28b85f2b3551226c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 07:52:50 -0500 Subject: [PATCH 0625/2214] [dashboard] Add --no-states support to logs WebSocket handler EsphomeLogsHandler.build_command now appends --no-states to the spawned `esphome logs` argv when the WebSocket spawn message includes `no_states: true`. This lets the dashboard frontend suppress entity-state log lines for OTA log sessions without requiring users to drop to the CLI. Adds three unit tests covering the truthy, missing, and explicit-False cases. --- esphome/dashboard/web_server.py | 5 ++- tests/dashboard/test_web_server.py | 58 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index b8e17244e5..648991ab83 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -437,7 +437,10 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): class EsphomeLogsHandler(EsphomePortCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: """Build the command to run.""" - return await self.build_device_command(["logs"], json_message) + cmd = await self.build_device_command(["logs"], json_message) + if json_message.get("no_states"): + cmd.append("--no-states") + return cmd class EsphomeRenameHandler(EsphomeCommandWebSocket): diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index daff384515..1a62cfda90 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1744,6 +1744,64 @@ def test_proc_on_exit_skips_when_already_closed() -> None: handler.close.assert_not_called() +@pytest.mark.asyncio +async def test_esphome_logs_handler_appends_no_states_when_set() -> None: + """Test --no-states is appended when no_states is truthy in the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + json_message = { + "configuration": "device.yaml", + "port": "OTA", + "no_states": True, + } + cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message) + + assert cmd == [ + "esphome", + "logs", + "device.yaml", + "--device", + "OTA", + "--no-states", + ] + handler.build_device_command.assert_awaited_once_with(["logs"], json_message) + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_missing() -> None: + """Test --no-states is not added when no_states is absent from the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, {"configuration": "device.yaml", "port": "OTA"} + ) + + assert "--no-states" not in cmd + assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"] + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_false() -> None: + """Test --no-states is not added when no_states is explicitly False.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, + {"configuration": "device.yaml", "port": "OTA", "no_states": False}, + ) + + assert "--no-states" not in cmd + + def _make_auth_handler(auth_header: str | None = None) -> Mock: """Create a mock handler with the given Authorization header.""" handler = Mock() From 1b059f7fb294945a6745a47e4d435a79f99718e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 08:07:55 -0500 Subject: [PATCH 0626/2214] Log post-append cmd in EsphomeLogsHandler so debug shows --no-states --- esphome/dashboard/web_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 648991ab83..d67245967c 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -440,6 +440,7 @@ class EsphomeLogsHandler(EsphomePortCommandWebSocket): cmd = await self.build_device_command(["logs"], json_message) if json_message.get("no_states"): cmd.append("--no-states") + _LOGGER.debug("Built command: %s", cmd) return cmd From c096e9bfee0822bf21ebc61d6c98f2e95bfb5a6c Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:23:02 +0200 Subject: [PATCH 0627/2214] Use WatchdogManager, check for missing checksum in partition table --- esphome/components/ota/__init__.py | 2 ++ .../components/ota/ota_backend_esp_idf.cpp | 36 ++++++++----------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 8f31eb5cdd..579491fe1a 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -24,6 +24,8 @@ def AUTO_LOAD() -> list[str]: components = ["safe_mode"] if not CORE.using_zephyr: components.extend(["md5"]) + if CORE.is_esp32: + components.extend(["watchdog"]) return components diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index f5b8f14cfe..7200eb752e 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -10,6 +10,7 @@ #include #ifdef USE_OTA_PARTITIONS +#include "esphome/components/watchdog/watchdog.h" #include #include #endif @@ -222,6 +223,18 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } + // Check for missing checksum + // esp_partition_table_verify does not fail in this case and the ESP would not boot after the update + bool checksum_found = false; + for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { + if (((const esp_partition_info_t *)&new_partition_table[i])->magic == ESP_PARTITION_MAGIC_MD5) { + checksum_found = true; + } + } + if (!checksum_found) { + ESP_LOGE(TAG, "New partition table has no checksum", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } // Check if the required app and otadata partitions exist in the new partition table // Check which app slot to boot from in the new partition table @@ -284,7 +297,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); - const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; if (p->address == running_app_offset && p->size >= running_app_size) { running_app_part = p; } @@ -294,29 +306,9 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // The following function takes longer than the 5 seconds timeout of WDT - esp_task_wdt_config_t wdtc; - wdtc.idle_core_mask = 0; -#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 - wdtc.idle_core_mask |= (1 << 0); -#endif -#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 - wdtc.idle_core_mask |= (1 << 1); -#endif - wdtc.timeout_ms = 15000; - wdtc.trigger_panic = false; - esp_task_wdt_reconfigure(&wdtc); -#endif - + watchdog::WatchdogManager watchdog(15000); err = esp_partition_copy(app_copy_target_part, 0, running_app_part, 0, running_app_size); -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // Set the WDT back to the configured timeout - wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; - esp_task_wdt_reconfigure(&wdtc); -#endif - if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; From d03f0bccf0ee1ffd4af24526c7bc870acd387584 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:24:15 +0000 Subject: [PATCH 0628/2214] [pre-commit.ci lite] apply automatic fixes --- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 7200eb752e..269d9153b2 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -227,7 +227,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // esp_partition_table_verify does not fail in this case and the ESP would not boot after the update bool checksum_found = false; for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { - if (((const esp_partition_info_t *)&new_partition_table[i])->magic == ESP_PARTITION_MAGIC_MD5) { + if (((const esp_partition_info_t *) &new_partition_table[i])->magic == ESP_PARTITION_MAGIC_MD5) { checksum_found = true; } } From f9ad090bf9c56678b6f71351f1c026db03e96306 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 10:47:41 -0500 Subject: [PATCH 0629/2214] [api] Hoist memw out of socket ready check to once per main-loop iter On Xtensa under default -mserialize-volatile, GCC emits a memw before every volatile load. esphome_lwip_socket_has_data() is called once per socket per main-loop iteration (one for the listening socket in APIServer::loop and one per connected client in APIConnection::loop), making per-call memw a measurable cost on the idle path. Replace the per-call memw with a single std::atomic_thread_fence (memory_order_acquire) at the top of Application::loop. The fence pairs with the TCP/IP thread's existing SYS_ARCH_UNPROTECT release on rcvevent. The wake path (xTaskNotifyGive in the lwip event_callback hook, ulTaskNotifyTake at the bottom of the loop) is independent of rcvevent visibility and is non-losing, so writes that land between the fence and sleep are picked up by the next iteration. Gated on ESPHOME_THREAD_MULTI_ATOMICS so that BK72xx (which lacks LDREX/STREX and is built without libatomic) keeps the original volatile load path. ESP32, RTL87xx and LN882x get the optimization; ESP8266, RP2040 and host use the socket_ready_fd fallback and are unaffected. Disassembly (ESP32, gatetrigger): APIServer::loop -8 B, APIConnection ::loop -5 B; loop_task gains one memw for the fence. Net per idle iteration with N clients: save N memw on ready paths, add 1 for the fence (savings scale with client count and other Socket::ready callers like AsyncClient and CaptivePortal DNS). --- esphome/core/application.h | 12 ++++++++++++ esphome/core/lwip_fast_select.h | 30 ++++++++++++++---------------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index e9b386038e..68f1c92531 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -9,6 +9,10 @@ #include #include "esphome/core/component.h" #include "esphome/core/defines.h" + +#if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS) +#include // for std::atomic_thread_fence in Application::loop() +#endif #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" @@ -542,6 +546,14 @@ inline ESPHOME_ALWAYS_INLINE Application::ComponentPhaseGuard::ComponentPhaseGua } inline void ESPHOME_ALWAYS_INLINE Application::loop() { +#if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS) + // Pairs with the TCP/IP thread's SYS_ARCH_UNPROTECT release on rcvevent so + // every Socket::ready() in this iter uses a relaxed load (no per-call memw). + // Wake is independent (xTaskNotifyGive/ulTaskNotifyTake), so non-losing. + // Skipped on MULTI_NO_ATOMICS (e.g. BK72xx) — that path keeps `volatile` in + // esphome_lwip_socket_has_data() instead. + std::atomic_thread_fence(std::memory_order_acquire); +#endif #ifdef USE_RUNTIME_STATS // Capture the start of the active (non-sleeping) portion of this iteration. // Used to derive main-loop overhead = active time − Σ(component time) − diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 3b5e449148..4ba2606d76 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -26,25 +26,23 @@ extern "C" { struct lwip_sock *esphome_lwip_get_sock(int fd); /// Check if a cached LwIP socket has data ready via unlocked hint read of rcvevent. -/// This avoids lwIP core lock contention between the main loop (CPU0) and -/// streaming/networking work (CPU1). Correctness is preserved because callers -/// already handle EWOULDBLOCK on nonblocking sockets — a stale hint simply causes -/// a harmless retry on the next loop iteration. In practice, stale reads have not -/// been observed across multi-day testing, but the design does not depend on that. -/// -/// The sock pointer must have been obtained from esphome_lwip_get_sock() and must -/// remain valid (caller owns socket lifetime — no concurrent close). -/// Hot path: inlined volatile 16-bit load — no function call overhead. -/// Uses offset-based access because lwip/priv/sockets_priv.h conflicts with C++. +/// On ESPHOME_THREAD_MULTI_ATOMICS builds, the caller must run on the main +/// loop task after Application::loop's per-iter std::atomic_thread_fence +/// (memory_order_acquire); that fence pairs with the TCP/IP thread's +/// SYS_ARCH_UNPROTECT release, so a plain load suffices and avoids the +/// per-call `memw` that volatile would emit on Xtensa under default +/// -mserialize-volatile. Without atomics (e.g. BK72xx), the fence is skipped +/// and the volatile load provides ordering on its own. +/// Stale reads are harmless either way: the hooked event_callback +/// xTaskNotifyGives on RCVPLUS, so the next iteration re-snapshots and +/// ulTaskNotifyTake never loses a wake. /// The offset and size are verified at compile time in lwip_fast_select.c. static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { - // Unlocked hint read — no lwIP core lock needed. - // volatile prevents the compiler from caching/reordering this cross-thread read. - // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a - // FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value - // is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on - // Xtensa/RISC-V/ARM and cannot produce torn values. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + return *(int16_t *) ((char *) sock + (int) ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET) > 0; +#else return *(volatile int16_t *) ((char *) sock + (int) ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET) > 0; +#endif } /// Hook a socket's netconn callback to notify the main loop task on receive events. From 4f51057fc207979dfff72249ccf0bbf1b9536286 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 11:00:18 -0500 Subject: [PATCH 0630/2214] [api] Clarify fence comment per review Plain non-atomic load on the fast path is not a 'relaxed load' in the C++ atomic sense; describe what the fence accomplishes instead. --- esphome/core/application.h | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 415bc30c50..fa777188e7 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -571,10 +571,11 @@ inline ESPHOME_ALWAYS_INLINE Application::ComponentPhaseGuard::ComponentPhaseGua inline void ESPHOME_ALWAYS_INLINE Application::loop() { #if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS) // Pairs with the TCP/IP thread's SYS_ARCH_UNPROTECT release on rcvevent so - // every Socket::ready() in this iter uses a relaxed load (no per-call memw). - // Wake is independent (xTaskNotifyGive/ulTaskNotifyTake), so non-losing. - // Skipped on MULTI_NO_ATOMICS (e.g. BK72xx) — that path keeps `volatile` in - // esphome_lwip_socket_has_data() instead. + // subsequent Socket::ready() checks in this iter observe the published state + // without a per-call memw. Wake is independent (xTaskNotifyGive/ + // ulTaskNotifyTake), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g. + // BK72xx) — that path keeps `volatile` in esphome_lwip_socket_has_data() + // instead. std::atomic_thread_fence(std::memory_order_acquire); #endif #ifdef USE_RUNTIME_STATS From b5ccd55f4ed8cf9c756505a0b2c1767f5d957be5 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sat, 25 Apr 2026 19:06:58 +0200 Subject: [PATCH 0631/2214] [packages] Fix premature substitution of vars in remote package files (#15997) Co-authored-by: J. Nick Koston Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/packages/__init__.py | 21 ++- esphome/yaml_util.py | 11 +- .../component_tests/packages/test_packages.py | 130 ++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 62 ++++++++- 4 files changed, 219 insertions(+), 5 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index b6ec0067c9..1b9e03d88f 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -378,9 +378,8 @@ def _substitute_package_definition( Local package contents are left untouched — they will be substituted later during the main substitution pass. """ - if isinstance(package_config, str) or ( - isinstance(package_config, dict) and is_remote_package(package_config) - ): + + def do_substitute(package_config: dict | str) -> dict | str: # Collect undefined-variable errors (rather than raising strict) so the # path walked through a remote-package dict is preserved and the user # sees which field (url / path / ref / ...) referenced the undefined @@ -394,6 +393,22 @@ def _substitute_package_definition( errors=errors, ) raise_first_undefined(errors, "package definition") + return package_config + + if isinstance(package_config, str): + return do_substitute(package_config) + + if isinstance(package_config, dict) and is_remote_package(package_config): + # Mark vars as literal to avoid substituting variables in the vars block itself, since they are meant to be + # passed as-is to the package YAML and may contain their own substitution expressions that should not + # be prematurely evaluated here. + if CONF_FILES in package_config: + for file_def in package_config[CONF_FILES]: + if isinstance(file_def, dict) and CONF_VARS in file_def: + file_def[CONF_VARS] = yaml_util.make_literal(file_def[CONF_VARS]) + + package_config = do_substitute(package_config) + return package_config diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 42da27ec14..3cfc9c4b15 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -113,6 +113,15 @@ def make_data_base( return value +def make_literal(value: Any) -> ESPLiteralValue | Any: + """Wrap a value in an ESPLiteralValue object.""" + try: + return add_class_to_obj(value, ESPLiteralValue) + except TypeError: + # Adding class failed, ignore error + return value + + def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: """Tags a list/string/dict value with context vars that must be applied to it and its children during the substitution pass. If no vars are given, no tagging is done. @@ -525,7 +534,7 @@ class ESPHomeLoaderMixin: obj = self.construct_sequence(node) elif isinstance(node, yaml.MappingNode): obj = self.construct_mapping(node) - return add_class_to_obj(obj, ESPLiteralValue) + return make_literal(obj) @_add_data_ref def construct_extend(self, node: yaml.Node) -> Extend: diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index af4b6db796..13a6da9f2c 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -1491,3 +1491,133 @@ def test_substitute_package_definition_includes_source_location(tmp_path: Path) line, col = int(match.group(1)), int(match.group(2)) assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})" assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})" + + +def test_substitute_package_definition_vars_preserved_literally() -> None: + """``vars:`` blocks in remote-package files are not substituted prematurely. + + Variable references inside ``vars:`` may resolve to substitutions + contributed by sibling packages that have not yet been loaded, so they + must be passed through untouched and resolved later by the package YAML. + """ + pkg = { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_REF: "main", + CONF_FILES: [ + { + CONF_PATH: "common/somefile.yaml", + CONF_VARS: {"pin": "${PIN}"}, + }, + ], + } + # Note: PIN is intentionally NOT in the context — it is meant to + # be resolved later, when the package YAML is processed. + result = _substitute_package_definition(pkg, ContextVars()) + + assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"} + + +def test_substitute_package_definition_other_fields_still_substituted() -> None: + """Marking ``vars:`` literal does not stop substitution of url/ref/path.""" + ctx = ContextVars({"branch": "release", "org": "esphome"}) + pkg = { + CONF_URL: "https://github.com/${org}/firmware", + CONF_REF: "${branch}", + CONF_FILES: [ + { + CONF_PATH: "common/sensor.yaml", + CONF_VARS: {"pin": "${PIN}"}, + }, + ], + } + result = _substitute_package_definition(pkg, ctx) + + assert result[CONF_URL] == "https://github.com/esphome/firmware" + assert result[CONF_REF] == "release" + # vars passed through unchanged + assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"} + + +def test_substitute_package_definition_without_vars_unaffected() -> None: + """Files entries without a ``vars:`` block continue to work.""" + ctx = ContextVars({"branch": "main"}) + pkg = { + CONF_URL: "https://github.com/esphome/firmware", + CONF_REF: "${branch}", + CONF_FILES: [ + {CONF_PATH: "file1.yaml"}, + "file2.yaml", + ], + } + result = _substitute_package_definition(pkg, ctx) + + assert result[CONF_REF] == "main" + assert result[CONF_FILES][0] == {CONF_PATH: "file1.yaml"} + assert result[CONF_FILES][1] == "file2.yaml" + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_remote_package_vars_resolved_against_sibling_package_substitutions( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """A ``vars:`` reference in one remote package can resolve to a + substitution defined in a sibling remote package. + + A higher-priority package declares ``substitutions:`` (e.g. ``SENSOR_PIN: 5``) and a + lower-priority package's ``files: -> vars:`` references that substitution. + Because packages are processed highest-priority first and ``vars:`` is now + preserved literally during package-definition processing, the substitution + is resolved correctly when the package YAML itself is loaded. + """ + mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock()) + mock_is_file.return_value = True + + # Two YAML files mocked from the "remote" repo: + # - platform.yaml exports a substitution ``SENSOR_PIN`` + # - sensor.yaml uses ``${pin}`` (which is bound from ``vars:`` to + # ``${SENSOR_PIN}`` and resolved against the merged substitutions). + mock_load_yaml.side_effect = [ + # Order matches reverse-priority traversal (highest priority first). + OrderedDict( + { + CONF_SUBSTITUTIONS: {"SENSOR_PIN": "GPIO5"}, + } + ), + OrderedDict( + { + CONF_SENSOR: [ + { + CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, + CONF_NAME: TEST_SENSOR_NAME_1, + "pin": "${pin}", + } + ], + } + ), + ] + + config = { + CONF_PACKAGES: { + "special_sensor": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_FILES: [ + { + CONF_PATH: "sensor.yaml", + CONF_VARS: {"pin": "${SENSOR_PIN}"}, + }, + ], + CONF_REFRESH: "1d", + }, + "platform": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_FILES: ["platform.yaml"], + CONF_REFRESH: "1d", + }, + } + } + + actual = packages_pass(config) + + assert actual[CONF_SENSOR][0]["pin"] == "GPIO5" diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e3aa2a16f5..3815ac1d75 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -11,7 +11,13 @@ from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.util import OrderedDict -from esphome.yaml_util import ESPHomeDataBase, format_path, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + ESPLiteralValue, + format_path, + make_data_base, + make_literal, +) @pytest.fixture(autouse=True) @@ -891,3 +897,57 @@ def test_format_path_empty_path_with_located_current_obj(): obj = _located("${var}", "main.yaml", 0, 0) result = format_path([], obj) assert result == "In: in main.yaml 1:1" + + +def test_make_literal_wraps_dict() -> None: + """A dict is wrapped so it becomes an ESPLiteralValue instance.""" + value = {"key": "${var}"} + result = make_literal(value) + assert isinstance(result, ESPLiteralValue) + assert isinstance(result, dict) + assert result == {"key": "${var}"} + + +def test_make_literal_wraps_list() -> None: + """A list is wrapped so it becomes an ESPLiteralValue instance.""" + value = ["${var}", "plain"] + result = make_literal(value) + assert isinstance(result, ESPLiteralValue) + assert isinstance(result, list) + assert result == ["${var}", "plain"] + + +def test_make_literal_wraps_string() -> None: + """A string is wrapped so it becomes an ESPLiteralValue instance.""" + result = make_literal("${var}") + assert isinstance(result, ESPLiteralValue) + assert result == "${var}" + + +def test_make_literal_returns_already_wrapped_value_unchanged() -> None: + """Wrapping a value that is already an ESPLiteralValue returns it as-is.""" + value = make_literal({"key": "value"}) + assert isinstance(value, ESPLiteralValue) + result = make_literal(value) + assert result is value + + +def test_make_literal_returns_none_unchanged() -> None: + """Values whose class cannot be augmented (e.g. ``None``) are returned as-is.""" + result = make_literal(None) + assert result is None + + +def test_make_literal_blocks_substitution() -> None: + """A value wrapped with make_literal is skipped by the substitution pass.""" + value = make_literal({"pin": "${PIN}"}) + result = substitutions.substitute( + value, + path=[], + parent_context=substitutions.ContextVars(), + strict_undefined=False, + ) + # The literal block must remain untouched, even though the variable is + # undefined in the context. + assert result == {"pin": "${PIN}"} + assert isinstance(result, ESPLiteralValue) From 15cb2c0580a4bd985f4e19b809bdb0eda6c0af9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 12:39:32 -0500 Subject: [PATCH 0632/2214] [output] Gate FloatOutput power scaling fields behind USE_OUTPUT_FLOAT_POWER_SCALING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The min_power / max_power / zero_means_zero scaling support on FloatOutput costs 12 bytes per instance (max_power_, min_power_, zero_means_zero_ + alignment padding) on every PWM channel, DAC channel, LEDC output, and dimmer-chip channel — even on configs that never touch the feature. Repo-wide usage is ~17 YAML lines, mostly in test fixtures and a couple of LED-driver chip tests; the runtime set_min_power / set_max_power actions added in #8934 have no usage outside the action's own test. Add USE_OUTPUT_FLOAT_POWER_SCALING and gate the fields and scaling math in FloatOutput::set_level() behind it, mirroring the USE_POWER_SUPPLY pattern already used in BinaryOutput. Python codegen flips the define on whenever: - a min_power / max_power / zero_means_zero key is set on any output, or - a non-default zero_means_zero value is provided, or - an output.set_min_power / output.set_max_power action is registered The action class templates (SetMinPowerAction, SetMaxPowerAction) are also gated on the same define so their non-dependent member access on FloatOutput::set_min_power doesn't fail to parse when the methods aren't compiled in. zero_means_zero_ now has a default initializer (was UB before — it was always written from setup, but only because the schema default forced it). For configs without scaling: 12 B .bss saved per FloatOutput instance, plus a small flash saving from the elided multiply/subtract in set_level(). For configs with scaling: behavior is unchanged. Verified on tests/components/esp8266_pwm (no scaling): pstorage 0x28 → 0x1c per output (40 B → 28 B). Verified on tests/components/output (uses set_min_power/set_max_power actions): builds correctly with the define on. --- esphome/components/output/__init__.py | 9 ++++++++- esphome/components/output/automation.h | 3 +++ esphome/components/output/float_output.cpp | 4 ++++ esphome/components/output/float_output.h | 19 ++++++++++++++++++- esphome/core/defines.h | 1 + 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 36798f2d7f..dd2b5e9372 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -54,10 +54,15 @@ async def setup_output_platform_(obj, config): power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) cg.add(obj.set_power_supply(power_supply_)) if CONF_MAX_POWER in config: + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_max_power(config[CONF_MAX_POWER])) if CONF_MIN_POWER in config: + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_min_power(config[CONF_MIN_POWER])) - if CONF_ZERO_MEANS_ZERO in config: + # Only emit (and pull in the scaling field) when explicitly enabled — the + # C++ default initializer covers the False case, saving 4 B per instance. + if config.get(CONF_ZERO_MEANS_ZERO): + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_zero_means_zero(config[CONF_ZERO_MEANS_ZERO])) @@ -121,6 +126,7 @@ async def output_set_level_to_code(config, action_id, template_arg, args): synchronous=True, ) async def output_set_min_power_to_code(config, action_id, template_arg, args): + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_MIN_POWER], args, cg.float_) @@ -140,6 +146,7 @@ async def output_set_min_power_to_code(config, action_id, template_arg, args): synchronous=True, ) async def output_set_max_power_to_code(config, action_id, template_arg, args): + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_MAX_POWER], args, cg.float_) diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h index 3279378129..537226a143 100644 --- a/esphome/components/output/automation.h +++ b/esphome/components/output/automation.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" +#include "esphome/core/defines.h" #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" @@ -40,6 +41,7 @@ template class SetLevelAction : public Action { FloatOutput *output_; }; +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING template class SetMinPowerAction : public Action { public: SetMinPowerAction(FloatOutput *output) : output_(output) {} @@ -63,6 +65,7 @@ template class SetMaxPowerAction : public Action { protected: FloatOutput *output_; }; +#endif // USE_OUTPUT_FLOAT_POWER_SCALING } // namespace output } // namespace esphome diff --git a/esphome/components/output/float_output.cpp b/esphome/components/output/float_output.cpp index 46014e0903..f6c75fbef6 100644 --- a/esphome/components/output/float_output.cpp +++ b/esphome/components/output/float_output.cpp @@ -7,6 +7,7 @@ namespace output { static const char *const TAG = "output.float"; +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING void FloatOutput::set_max_power(float max_power) { this->max_power_ = clamp(max_power, this->min_power_, 1.0f); // Clamp to MIN>=MAX>=1.0 } @@ -14,6 +15,7 @@ void FloatOutput::set_max_power(float max_power) { void FloatOutput::set_min_power(float min_power) { this->min_power_ = clamp(min_power, 0.0f, this->max_power_); // Clamp to 0.0>=MIN>=MAX } +#endif void FloatOutput::set_level(float state) { state = clamp(state, 0.0f, 1.0f); @@ -26,8 +28,10 @@ void FloatOutput::set_level(float state) { } #endif +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING if (state != 0.0f || !this->zero_means_zero_) // regardless of min_power_, 0.0 means off state = (state * (this->max_power_ - this->min_power_)) + this->min_power_; +#endif if (this->is_inverted()) state = 1.0f - state; diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 5225f88c66..e0a7a9530f 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -1,11 +1,13 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "binary_output.h" namespace esphome { namespace output { +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING #define LOG_FLOAT_OUTPUT(this) \ LOG_BINARY_OUTPUT(this) \ if (this->max_power_ != 1.0f) { \ @@ -14,6 +16,9 @@ namespace output { if (this->min_power_ != 0.0f) { \ ESP_LOGCONFIG(TAG, " Min Power: %.1f%%", this->min_power_ * 100.0f); \ } +#else +#define LOG_FLOAT_OUTPUT(this) LOG_BINARY_OUTPUT(this) +#endif /** Base class for all output components that can output a variable level, like PWM. * @@ -30,6 +35,7 @@ namespace output { */ class FloatOutput : public BinaryOutput { public: +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING /** Set the maximum power output of this component. * * All values are multiplied by max_power - min_power and offset to min_power to get the adjusted value. @@ -51,6 +57,7 @@ class FloatOutput : public BinaryOutput { * @param zero_means_zero True if a 0 state should mean 0 and not min_power. */ void set_zero_means_zero(bool zero_means_zero) { this->zero_means_zero_ = zero_means_zero; } +#endif /** Set the level of this float output, this is called from the front-end. * @@ -69,20 +76,30 @@ class FloatOutput : public BinaryOutput { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING /// Get the maximum power output. float get_max_power() const { return this->max_power_; } /// Get the minimum power output. float get_min_power() const { return this->min_power_; } +#else + /// Get the maximum power output. + float get_max_power() const { return 1.0f; } + + /// Get the minimum power output. + float get_min_power() const { return 0.0f; } +#endif protected: /// Implement BinarySensor's write_enabled; this should never be called. void write_state(bool state) override; virtual void write_state(float state) = 0; +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING float max_power_{1.0f}; float min_power_{0.0f}; - bool zero_means_zero_; + bool zero_means_zero_{false}; +#endif }; } // namespace output diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da..154211d825 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -132,6 +132,7 @@ #define USE_NEXTION_WAVEFORM #define USE_NUMBER #define USE_OUTPUT +#define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP #define USE_QR_CODE From 44325166145b98eb34ea4fa01259c9d601944af9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 13:07:12 -0500 Subject: [PATCH 0633/2214] [output] Address Copilot review + add static_assert stubs for documented lambda API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace inverted comments in set_min_power/set_max_power clamp lines (MIN>=MAX>=1.0 → min_power <= max <= 1.0) — pre-existing bug. - Update FloatOutput class docstring to describe the conditional scaling behavior under USE_OUTPUT_FLOAT_POWER_SCALING. - Reword the zero_means_zero codegen comment to explain why we gate on the value (schema default=False would otherwise force the define on). - Add templated static_assert stubs for set_min_power/set_max_power/ set_zero_means_zero in the #else branch so calls from lambdas (documented at esphome.io/components/output/#output-set_min_power_action) produce a clear compile error pointing at the user's lambda site, with the migration instruction inline (add 'min_power: 0%' / 'max_power: 100%' / 'zero_means_zero: true' to one output entry to enable scaling). Templating on a default-false bool means the assert only fires on instantiation (i.e. when the user actually calls the method), not on every parse — so unused stubs in TUs that include the header (e.g. output/automation.cpp when scaling actions aren't registered) don't break the build. Verified: a lambda calling id(out).set_min_power(0.2) without min_power in YAML now fails compilation with a pointer at the lambda line and the inline migration message; adding min_power: 0% to the output entry makes the same config build clean. --- esphome/components/output/__init__.py | 5 ++-- esphome/components/output/float_output.cpp | 4 +-- esphome/components/output/float_output.h | 32 ++++++++++++++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index dd2b5e9372..4f6c8943f5 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -59,8 +59,9 @@ async def setup_output_platform_(obj, config): if CONF_MIN_POWER in config: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_min_power(config[CONF_MIN_POWER])) - # Only emit (and pull in the scaling field) when explicitly enabled — the - # C++ default initializer covers the False case, saving 4 B per instance. + # Only emit when zero_means_zero is actually enabled. The schema defaults to False + # so this key is always present; emitting unconditionally would force + # USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate. if config.get(CONF_ZERO_MEANS_ZERO): cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_zero_means_zero(config[CONF_ZERO_MEANS_ZERO])) diff --git a/esphome/components/output/float_output.cpp b/esphome/components/output/float_output.cpp index f6c75fbef6..35629c828a 100644 --- a/esphome/components/output/float_output.cpp +++ b/esphome/components/output/float_output.cpp @@ -9,11 +9,11 @@ static const char *const TAG = "output.float"; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING void FloatOutput::set_max_power(float max_power) { - this->max_power_ = clamp(max_power, this->min_power_, 1.0f); // Clamp to MIN>=MAX>=1.0 + this->max_power_ = clamp(max_power, this->min_power_, 1.0f); // Clamp to min_power <= max <= 1.0 } void FloatOutput::set_min_power(float min_power) { - this->min_power_ = clamp(min_power, 0.0f, this->max_power_); // Clamp to 0.0>=MIN>=MAX + this->min_power_ = clamp(min_power, 0.0f, this->max_power_); // Clamp to 0.0 <= min <= max_power } #endif diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index e0a7a9530f..3e1bd83968 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -27,11 +27,14 @@ namespace output { * makes using maths much easier and (in theory) supports all possible bit depths. * * If you want to create a FloatOutput yourself, you essentially just have to override write_state(float). - * That method will be called for you with inversion and max-min power and offset to min power already applied. + * That method will be called for you with inversion already applied. When USE_OUTPUT_FLOAT_POWER_SCALING is + * enabled (set automatically by Python codegen if any output uses min_power/max_power/zero_means_zero or the + * matching runtime actions), the value will additionally have max-min power scaling and offset to min_power + * applied; otherwise only inversion is applied. * * This interface is compatible with BinaryOutput (and will automatically convert the binary states to floating * point states for you). Additionally, this class provides a way for users to set a minimum and/or maximum power - * output + * output (gated on USE_OUTPUT_FLOAT_POWER_SCALING). */ class FloatOutput : public BinaryOutput { public: @@ -57,6 +60,31 @@ class FloatOutput : public BinaryOutput { * @param zero_means_zero True if a 0 state should mean 0 and not min_power. */ void set_zero_means_zero(bool zero_means_zero) { this->zero_means_zero_ = zero_means_zero; } +#else + // Compile-time guards for users calling these methods from lambdas (documented usage at + // https://esphome.io/components/output/#output-set_min_power_action). When power scaling + // is compiled out, these template stubs fail to compile with an actionable error pointing + // at the user's lambda. Templating on a default-false bool means static_assert only fires + // on instantiation (i.e. when the user actually calls the method), not on every parse. + template void set_max_power(float max_power) { + static_assert(_use_output_float_power_scaling, + "set_max_power() requires USE_OUTPUT_FLOAT_POWER_SCALING. " + "To enable it, add 'max_power: 100%' (or any value) to one output entry in your YAML — " + "the codegen will then keep the scaling fields. " + "See https://esphome.io/components/output/ for details."); + } + template void set_min_power(float min_power) { + static_assert(_use_output_float_power_scaling, + "set_min_power() requires USE_OUTPUT_FLOAT_POWER_SCALING. " + "To enable it, add 'min_power: 0%' (or any value) to one output entry in your YAML — " + "the codegen will then keep the scaling fields. " + "See https://esphome.io/components/output/ for details."); + } + template void set_zero_means_zero(bool zero_means_zero) { + static_assert(_use_output_float_power_scaling, + "set_zero_means_zero() requires USE_OUTPUT_FLOAT_POWER_SCALING. " + "To enable it, add 'zero_means_zero: true' to one output entry in your YAML."); + } #endif /** Set the level of this float output, this is called from the front-end. From be849f0a1aee99b67dcbf6f4988654267e38131e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 13:25:04 -0500 Subject: [PATCH 0634/2214] merge --- esphome/components/api/proto.h | 5 ++--- script/api_protobuf/api_protobuf.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 3ff65029e1..808758aa35 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -844,12 +844,11 @@ class ProtoSize { return field_id_size + varint(value); } /// 48-bit MAC address variant: matches encode_varint_raw_48bit's fast path. - /// When any of the top 6 of 48 bits is set the encoded varint is 7 bytes; + /// When value uses any of bits [42..47] the encoded varint is 7 bytes; /// otherwise fall back to the general size calculation. - /// Caller must guarantee value fits in 48 bits (encoder asserts in debug). static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_48bit_force(uint32_t field_id_size, uint64_t value) { - return field_id_size + (value >= (1ULL << (MAC_ADDRESS_SIZE * 8 - 6)) ? 7 : varint(value)); + return field_id_size + (value >= (1ULL << 42) ? 7 : varint(value)); } static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) { return len ? field_id_size + varint(static_cast(len)) + static_cast(len) : 0; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index bf672d0567..5c4a75c64a 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -681,10 +681,10 @@ class UInt64Type(VarintTypeMixin, TypeInfo): def RAW_ENCODE_MAP(self) -> dict[str, str]: # noqa: N802 if self.mac_address: return { - **TypeInfo.RAW_ENCODE_MAP, + **super().RAW_ENCODE_MAP, "encode_uint64": "ProtoEncode::encode_varint_raw_48bit(pos, {value});", } - return TypeInfo.RAW_ENCODE_MAP + return super().RAW_ENCODE_MAP def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint From a2a9145a874367754ae7581189e4747853a60b23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 13:47:52 -0500 Subject: [PATCH 0635/2214] [bluetooth_proxy] Drop redundant remote_bda_ write in connect handler get_connection_(msg.address, true) already populates remote_bda_ via set_address(): either the slot was found because get_address() == msg.address (so address_ and remote_bda_ already encode it), or a free slot was reserved and set_address(msg.address) was called, which writes remote_bda_[0..5] from the same bytes. The follow-up uint64_to_bd_addr(msg.address, connection->remote_bda_) just rewrites the identical bytes. --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c69163b1f7..a85bd31a0d 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -201,7 +201,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - uint64_to_bd_addr(msg.address, connection->remote_bda_); connection->set_remote_addr_type(static_cast(msg.address_type)); connection->set_state(espbt::ClientState::DISCOVERED); this->send_connections_free(); From d7a69979e78293a73872e0708524ea9c2db76df1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 14:23:31 -0500 Subject: [PATCH 0636/2214] [safe_mode] Use StaticCallbackManager for on_safe_mode safe_mode is a single-instance component, so a compile-time-sized StaticVector backing for the callback list is a clean fit. Drop the std::vector reallocation machinery (_M_realloc_insert) and size the storage to the exact number of on_safe_mode automations via ESPHOME_SAFE_MODE_CALLBACK_COUNT. --- esphome/components/safe_mode/__init__.py | 3 ++- esphome/components/safe_mode/safe_mode.h | 2 +- esphome/core/defines.h | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 6df0ba78b1..578376258a 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -76,8 +76,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if config.get(CONF_ON_SAFE_MODE): + if on_safe_mode := config.get(CONF_ON_SAFE_MODE): cg.add_define("USE_SAFE_MODE_CALLBACK") + cg.add_define("ESPHOME_SAFE_MODE_CALLBACK_COUNT", len(on_safe_mode)) await automation.build_callback_automations( var, config, _CALLBACK_AUTOMATIONS ) diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 2733054962..b458a9a302 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -57,7 +57,7 @@ class SafeModeComponent final : public Component { // Larger objects at the end ESPPreferenceObject rtc_; #ifdef USE_SAFE_MODE_CALLBACK - CallbackManager safe_mode_callback_{}; + StaticCallbackManager safe_mode_callback_{}; #endif static const uint32_t ENTER_SAFE_MODE_MAGIC = diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da..297bf081c5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -136,6 +136,7 @@ #define USE_PREFERENCES_SYNC_EVERY_LOOP #define USE_QR_CODE #define USE_SAFE_MODE_CALLBACK +#define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT #define USE_SENSOR #define USE_SENSOR_FILTER From 12f744bbbe4bf89c84751f2bdfb1bd5ab70392b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 14:26:23 -0500 Subject: [PATCH 0637/2214] [rtttl] Gate on_finished_playback callback storage behind define MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the on_finished_playback CallbackManager storage, registration method and call site behind USE_RTTTL_FINISHED_PLAYBACK_CALLBACK so configs without an on_finished_playback automation pay zero RAM and zero flash for it. Keeps CallbackManager rather than switching to StaticCallbackManager because rtttl is MULTI_CONF — a per-class compile-time N would force every instance to reserve max(callbacks_per_instance) slots. --- esphome/components/rtttl/__init__.py | 4 +++- esphome/components/rtttl/rtttl.cpp | 2 ++ esphome/components/rtttl/rtttl.h | 6 ++++++ esphome/core/defines.h | 1 + tests/components/rtttl/common.yaml | 5 +++++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index c661aad972..4880f9ac41 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -93,7 +93,9 @@ async def to_code(config): cg.add(var.set_gain(config[CONF_GAIN])) - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config.get(CONF_ON_FINISHED_PLAYBACK): + cg.add_define("USE_RTTTL_FINISHED_PLAYBACK_CALLBACK") + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 08d902b4be..a5f8567c9d 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -424,7 +424,9 @@ void Rtttl::set_state_(State state) { // Clear loop_done when transitioning from `State::STOPPED` to any other state if (state == State::STOPPED) { this->disable_loop(); +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK this->on_finished_playback_callback_.call(); +#endif ESP_LOGD(TAG, "Playback finished"); } else if (old_state == State::STOPPED) { this->enable_loop(); diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 98ed9ba1bf..9dac92be2a 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -2,6 +2,8 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -45,9 +47,11 @@ class Rtttl : public Component { bool is_playing() { return this->state_ != State::STOPPED; } +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK template void add_on_finished_playback_callback(F &&callback) { this->on_finished_playback_callback_.add(std::forward(callback)); } +#endif protected: inline uint16_t get_integer_() { @@ -106,8 +110,10 @@ class Rtttl : public Component { uint32_t samples_gap_{0}; #endif // USE_SPEAKER +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; +#endif }; template class PlayAction : public Action { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da..4ee5419079 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -185,6 +185,7 @@ #define USE_MQTT #define USE_MQTT_COVER_JSON #define USE_NETWORK +#define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG #define USE_RUNTIME_IMAGE_JPEG diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index 529713583b..a4d8f951f4 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -29,3 +29,8 @@ output: rtttl: output: rtttl_output + on_finished_playback: + - then: + - logger.log: "Playback finished 1" + - then: + - logger.log: "Playback finished 2" From 827163d502e455fde87a3610f50a008f38f6fd87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 15:25:41 -0500 Subject: [PATCH 0638/2214] [bh1750] Downgrade per-reading Illuminance log to verbose The base sensor already logs the published state via the [S] publish line, so the component-level [D] line was redundant log spam on every update. Drop it to ESP_LOGV. --- esphome/components/bh1750/bh1750.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp index 045fb7cf45..ab952895a8 100644 --- a/esphome/components/bh1750/bh1750.cpp +++ b/esphome/components/bh1750/bh1750.cpp @@ -154,7 +154,7 @@ void BH1750Sensor::loop() { break; } - ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); + ESP_LOGV(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); this->status_clear_warning(); this->publish_state(lx); this->state_ = IDLE; From d2baa0437a98823ddae8adbe54c1781f53c1f66c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 15:26:26 -0500 Subject: [PATCH 0639/2214] m --- esphome/components/bh1750/bh1750.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp index 045fb7cf45..ab952895a8 100644 --- a/esphome/components/bh1750/bh1750.cpp +++ b/esphome/components/bh1750/bh1750.cpp @@ -154,7 +154,7 @@ void BH1750Sensor::loop() { break; } - ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); + ESP_LOGV(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); this->status_clear_warning(); this->publish_state(lx); this->state_ = IDLE; From 4f8feb86f0fdea15ff09dbfee20b10ee899ae2e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 15:43:05 -0500 Subject: [PATCH 0640/2214] [dashboard] Add --no-states support to logs WebSocket handler (#15993) --- esphome/dashboard/web_server.py | 6 +++- tests/dashboard/test_web_server.py | 58 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index b8e17244e5..d67245967c 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -437,7 +437,11 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): class EsphomeLogsHandler(EsphomePortCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: """Build the command to run.""" - return await self.build_device_command(["logs"], json_message) + cmd = await self.build_device_command(["logs"], json_message) + if json_message.get("no_states"): + cmd.append("--no-states") + _LOGGER.debug("Built command: %s", cmd) + return cmd class EsphomeRenameHandler(EsphomeCommandWebSocket): diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index daff384515..1a62cfda90 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1744,6 +1744,64 @@ def test_proc_on_exit_skips_when_already_closed() -> None: handler.close.assert_not_called() +@pytest.mark.asyncio +async def test_esphome_logs_handler_appends_no_states_when_set() -> None: + """Test --no-states is appended when no_states is truthy in the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + json_message = { + "configuration": "device.yaml", + "port": "OTA", + "no_states": True, + } + cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message) + + assert cmd == [ + "esphome", + "logs", + "device.yaml", + "--device", + "OTA", + "--no-states", + ] + handler.build_device_command.assert_awaited_once_with(["logs"], json_message) + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_missing() -> None: + """Test --no-states is not added when no_states is absent from the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, {"configuration": "device.yaml", "port": "OTA"} + ) + + assert "--no-states" not in cmd + assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"] + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_false() -> None: + """Test --no-states is not added when no_states is explicitly False.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, + {"configuration": "device.yaml", "port": "OTA", "no_states": False}, + ) + + assert "--no-states" not in cmd + + def _make_auth_handler(auth_header: str | None = None) -> Mock: """Create a mock handler with the given Authorization header.""" handler = Mock() From 9ad820c9214a3b7d6e2d93b82e6d88d270d4ffd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:59:01 +0000 Subject: [PATCH 0641/2214] Bump esphome-dashboard from 20260408.1 to 20260425.0 (#16006) 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 ba7adaa747..abc8ac5dbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.2.0 click==8.3.3 -esphome-dashboard==20260408.1 +esphome-dashboard==20260425.0 aioesphomeapi==44.22.0 zeroconf==0.148.0 puremagic==1.30 From 4cab262ef8bec892ea274e58a17e4d15a8784e4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 16:18:21 -0500 Subject: [PATCH 0642/2214] [ci] Trigger CodSpeed benchmarks on host platform changes (#15995) --- script/determine-jobs.py | 11 +++++++++-- tests/script/test_determine_jobs.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index d94d472c9e..f036447542 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -402,8 +402,11 @@ def should_run_benchmarks(branch: str | None = None) -> bool: Benchmarks run when any of the following conditions are met: 1. Core C++ files changed (esphome/core/*) - 2. A directly changed component has benchmark files (no dependency expansion) - 3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, + 2. The host platform changed (esphome/components/host/*) — benchmarks + are built and run on the host platform, so its implementations of + ``millis()``/``micros()``/etc. affect every benchmark + 3. A directly changed component has benchmark files (no dependency expansion) + 4. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, script/build_helpers.py, script/setup_codspeed_lib.py) Unlike unit tests, benchmarks do NOT expand to dependent components. @@ -420,6 +423,10 @@ def should_run_benchmarks(branch: str | None = None) -> bool: if core_changed(files): return True + # Host platform supplies the runtime that benchmarks execute on + if any(f.startswith("esphome/components/host/") for f in files): + return True + # Check if benchmark infrastructure changed if any( f.startswith("tests/benchmarks/") or f in BENCHMARK_INFRASTRUCTURE_FILES diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index de239ee0b5..2c726734fe 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1842,6 +1842,22 @@ def test_should_run_benchmarks_core_header_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_should_run_benchmarks_host_platform_change() -> None: + """Test benchmarks trigger on host platform changes. + + Benchmarks build and run on the host platform, so changes to its + millis()/micros()/etc. implementations affect every benchmark. + """ + for host_file in [ + "esphome/components/host/core.cpp", + "esphome/components/host/__init__.py", + ]: + with patch.object(determine_jobs, "changed_files", return_value=[host_file]): + assert determine_jobs.should_run_benchmarks() is True, ( + f"Expected benchmarks to run for {host_file}" + ) + + def test_should_run_benchmarks_benchmark_infra_change() -> None: """Test benchmarks trigger on benchmark infrastructure changes.""" for infra_file in [ From eec9e907322de94af34de240c4ca56222ac5c268 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:34:38 +0200 Subject: [PATCH 0643/2214] Make code more readable --- .../components/esphome/ota/ota_esphome.cpp | 16 ++++--- .../components/ota/ota_backend_esp_idf.cpp | 48 +++++++++++-------- .../ota/test-partition_access.esp32-idf.yaml | 5 ++ tests/components/ota/test.esp32-idf.yaml | 4 -- 4 files changed, 42 insertions(+), 31 deletions(-) create mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 045aa133d6..f9133a515f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -223,20 +223,22 @@ void ESPHomeOTAComponent::handle_handshake_() { this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); this->transition_ota_state_(OTAState::FEATURE_ACK); + + const bool supports_compression = ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && + this->backend_->supports_compression()); #ifdef USE_OTA_PARTITIONS this->extended_proto_ = this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; if (this->extended_proto_) { - this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; - this->handshake_buf_[1] = 0; - if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && - this->backend_->supports_compression()) { + // If the client supports the extended protocol, send 2 bytes: response type and server feature flags + this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; // indicates the following byte contains feature flags + this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; // supported if USE_OTA_PARTITIONS + if (supports_compression) { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; } - this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; } else { #endif - this->handshake_buf_[0] = - ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) + // Standard protocol without server feature flags + this->handshake_buf_[0] = (supports_compression) ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; #ifdef USE_OTA_PARTITIONS diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 269d9153b2..3e79b69e67 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -178,6 +178,10 @@ void IDFOTABackend::abort() { } #ifdef USE_OTA_PARTITIONS +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); +} + OTAResponseTypes IDFOTABackend::update_partition_table() { int num_partitions; if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { @@ -239,28 +243,32 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Check if the required app and otadata partitions exist in the new partition table // Check which app slot to boot from in the new partition table int app_partitions_found = 0; - int app_index = -1; - int app_index_with_copy = -1; - int otadata_index = -1; - bool otadata_no_overlap = false; + int new_app_part_index = -1; + int new_app_part_index_with_copy = -1; + int new_otadata_part_index = -1; + bool otadata_overlap = true; const esp_partition_t *app_copy_target_part{nullptr}; - for (int i = 0; i < num_partitions; i++) { + for (int i = 0; i < num_partitions; i++) { // Iterate over new partition table const esp_partition_info_t *new_part = &new_partition_table[i]; if (new_part->type == ESP_PARTITION_TYPE_APP) { + // Found an app partition in the new partition table app_partitions_found++; if (new_part->pos.size >= running_app_size) { + // Running app can fit inside this partition if (new_part->pos.offset == running_app_offset) { - app_index = i; - } else if (new_part->pos.offset >= running_app_offset + running_app_size || - running_app_offset >= new_part->pos.offset + running_app_size) { - // New app partition has no overlap with running app + // This new app partition can be used for the running app without copying because the offsets are the same + new_app_part_index = i; + } else if (!check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { + // This new app partition can be used for the running app after copying the app into it + // Check if there is an app partition in the old partition table at the right offset + // This is for esp_partition_copy and won't be needed after implementing a better copy function in the future esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); if (p->address == new_part->pos.offset && p->size >= running_app_size) { - // App partition exists in old and new partition table - app_index_with_copy = i; - app_copy_target_part = p; + // Found a suitable pair of partitions in the old and new partition table to copy the running app to + new_app_part_index_with_copy = i; // The partition index in the new partition table + app_copy_target_part = p; // The partition in the old partition table } it = esp_partition_next(it); } @@ -268,20 +276,20 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } } } else if (new_part->type == ESP_PARTITION_TYPE_DATA && new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { - otadata_index = i; - otadata_no_overlap = new_part->pos.offset >= running_app_offset + running_app_size || - running_app_offset >= new_part->pos.offset + new_part->pos.size; + // Found the otadata partition in the new partition table + new_otadata_part_index = i; + otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); } } - if (app_index == -1 && app_index_with_copy == -1) { + if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - if (app_partitions_found < 2 || otadata_index == -1) { + if (app_partitions_found < 2 || new_otadata_part_index == -1) { ESP_LOGE(TAG, "New partition table is missing the required app or otadata partitions"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - if (!otadata_no_overlap) { + if (otadata_overlap) { ESP_LOGE(TAG, "New otadata partition overlaps with running app"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -292,7 +300,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { nvs_flash_deinit(); // Copy the running app partition to new position if needed - if (app_index == -1) { + if (new_app_part_index == -1) { const esp_partition_t *running_app_part = nullptr; esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { @@ -341,7 +349,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); - const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; + const esp_partition_info_t *new_part = &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; if (p->address == new_part->pos.offset) { new_boot_partition = p; } diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml new file mode 100644 index 0000000000..0cbf854952 --- /dev/null +++ b/tests/components/ota/test-partition_access.esp32-idf.yaml @@ -0,0 +1,5 @@ +ota: + - platform: esphome + allow_partition_access: true + +<<: !include common.yaml diff --git a/tests/components/ota/test.esp32-idf.yaml b/tests/components/ota/test.esp32-idf.yaml index 0cbf854952..dade44d145 100644 --- a/tests/components/ota/test.esp32-idf.yaml +++ b/tests/components/ota/test.esp32-idf.yaml @@ -1,5 +1 @@ -ota: - - platform: esphome - allow_partition_access: true - <<: !include common.yaml From 2af5c4d82ce0d4fbad738f5e99807656e4ee9b1e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:35:46 +0000 Subject: [PATCH 0644/2214] [pre-commit.ci lite] apply automatic fixes --- esphome/components/esphome/ota/ota_esphome.cpp | 14 +++++++------- esphome/components/ota/ota_backend_esp_idf.cpp | 7 ++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f9133a515f..e5db0df462 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -224,23 +224,23 @@ void ESPHomeOTAComponent::handle_handshake_() { ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); this->transition_ota_state_(OTAState::FEATURE_ACK); - const bool supports_compression = ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && - this->backend_->supports_compression()); + const bool supports_compression = + ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()); #ifdef USE_OTA_PARTITIONS this->extended_proto_ = this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; if (this->extended_proto_) { // If the client supports the extended protocol, send 2 bytes: response type and server feature flags - this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; // indicates the following byte contains feature flags - this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; // supported if USE_OTA_PARTITIONS + this->handshake_buf_[0] = + ota::OTA_RESPONSE_FEATURE_FLAGS; // indicates the following byte contains feature flags + this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; // supported if USE_OTA_PARTITIONS if (supports_compression) { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; } } else { #endif // Standard protocol without server feature flags - this->handshake_buf_[0] = (supports_compression) - ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION - : ota::OTA_RESPONSE_HEADER_OK; + this->handshake_buf_[0] = + (supports_compression) ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; #ifdef USE_OTA_PARTITIONS } #endif diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 3e79b69e67..6a2afac978 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -179,7 +179,7 @@ void IDFOTABackend::abort() { #ifdef USE_OTA_PARTITIONS 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); + return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); } OTAResponseTypes IDFOTABackend::update_partition_table() { @@ -268,7 +268,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (p->address == new_part->pos.offset && p->size >= running_app_size) { // Found a suitable pair of partitions in the old and new partition table to copy the running app to new_app_part_index_with_copy = i; // The partition index in the new partition table - app_copy_target_part = p; // The partition in the old partition table + app_copy_target_part = p; // The partition in the old partition table } it = esp_partition_next(it); } @@ -349,7 +349,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); - const esp_partition_info_t *new_part = &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; + const esp_partition_info_t *new_part = + &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; if (p->address == new_part->pos.offset) { new_boot_partition = p; } From bc33260c61f2f635f7240ea7e5e1380fee504e95 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 25 Apr 2026 22:33:02 -0500 Subject: [PATCH 0645/2214] [ir_rf_proxy] Extend for RF (#15744) Co-authored-by: J. Nick Koston --- .../components/ir_rf_proxy/ir_rf_proxy.cpp | 112 +++++++++++++++++- esphome/components/ir_rf_proxy/ir_rf_proxy.h | 37 ++++++ .../components/ir_rf_proxy/radio_frequency.py | 68 +++++++++++ .../components/radio_frequency/common-rx.yaml | 18 +++ .../components/radio_frequency/common-tx.yaml | 19 +++ tests/components/radio_frequency/common.yaml | 7 ++ .../radio_frequency/test.bk72xx-ard.yaml | 8 ++ .../radio_frequency/test.esp32-idf.yaml | 8 ++ .../radio_frequency/test.esp8266-ard.yaml | 8 ++ .../radio_frequency/test.rp2040-ard.yaml | 8 ++ 10 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 esphome/components/ir_rf_proxy/radio_frequency.py create mode 100644 tests/components/radio_frequency/common-rx.yaml create mode 100644 tests/components/radio_frequency/common-tx.yaml create mode 100644 tests/components/radio_frequency/common.yaml create mode 100644 tests/components/radio_frequency/test.bk72xx-ard.yaml create mode 100644 tests/components/radio_frequency/test.esp32-idf.yaml create mode 100644 tests/components/radio_frequency/test.esp8266-ard.yaml create mode 100644 tests/components/radio_frequency/test.rp2040-ard.yaml diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index 5239a4667c..60b0cd513b 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -1,13 +1,73 @@ #include "ir_rf_proxy.h" + +#include + #include "esphome/core/log.h" namespace esphome::ir_rf_proxy { static const char *const TAG = "ir_rf_proxy"; +// ========== Shared transmit helper ========== +// Static template: all instantiations occur in this translation unit. + +template +static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency, + const CallT &call) { + if (transmitter == nullptr) { + ESP_LOGW(TAG, "No transmitter configured"); + return; + } + + if (!call.has_raw_timings()) { + ESP_LOGE(TAG, "No raw timings provided"); + return; + } + + auto transmit_call = transmitter->transmit(); + auto *transmit_data = transmit_call.get_data(); + transmit_data->set_carrier_frequency(carrier_frequency); + + if (call.is_packed()) { + transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), + call.get_packed_count()); + ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), + call.get_repeat_count()); + } else if (call.is_base64url()) { + if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { + ESP_LOGE(TAG, "Invalid base64url data"); + return; + } + constexpr int32_t max_timing_us = 500000; + for (int32_t timing : transmit_data->get_data()) { + int32_t abs_timing = timing < 0 ? -timing : timing; + if (abs_timing > max_timing_us) { + ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); + return; + } + } + ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), + call.get_repeat_count()); + } else { + transmit_data->set_data(call.get_raw_timings()); + ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), + call.get_repeat_count()); + } + + if (call.get_repeat_count() > 0) { + transmit_call.set_send_times(call.get_repeat_count()); + } + + transmit_call.perform(); +} + +// ========== IrRfProxy (Infrared platform) ========== + +#ifdef USE_IR_RF + void IrRfProxy::dump_config() { ESP_LOGCONFIG(TAG, - "IR/RF Proxy '%s'\n" + "IR Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), @@ -20,4 +80,54 @@ void IrRfProxy::dump_config() { } } +void IrRfProxy::control(const infrared::InfraredCall &call) { + uint32_t carrier = call.get_carrier_frequency().value_or(0); + transmit_raw_timings(this->transmitter_, carrier, call); +} + +#endif // USE_IR_RF + +// ========== RfProxy (Radio Frequency platform) ========== + +#ifdef USE_RADIO_FREQUENCY + +void RfProxy::setup() { + this->traits_.set_supports_transmitter(this->transmitter_ != nullptr); + this->traits_.set_supports_receiver(this->receiver_ != nullptr); + + // remote_transmitter/receiver always uses OOK (on-off keying) + this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); + + if (this->receiver_ != nullptr) { + this->receiver_->register_listener(this); + } +} + +void RfProxy::dump_config() { + ESP_LOGCONFIG(TAG, + "RF Proxy '%s'\n" + " Backend: remote_transmitter/receiver\n" + " Supports Transmitter: %s\n" + " Supports Receiver: %s", + this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), + YESNO(this->traits_.get_supports_receiver())); + + const auto &traits = this->traits_; + if (traits.get_frequency_min_hz() > 0) { + if (traits.get_frequency_min_hz() == traits.get_frequency_max_hz()) { + ESP_LOGCONFIG(TAG, " Frequency: %.3f MHz (fixed)", traits.get_frequency_min_hz() / 1e6f); + } else { + ESP_LOGCONFIG(TAG, " Frequency Range: %.3f - %.3f MHz", traits.get_frequency_min_hz() / 1e6f, + traits.get_frequency_max_hz() / 1e6f); + } + } +} + +void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { + // RF: no IR carrier modulation + transmit_raw_timings(this->transmitter_, 0, call); +} + +#endif // USE_RADIO_FREQUENCY + } // namespace esphome::ir_rf_proxy diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 05b988f287..973e9e2051 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -4,10 +4,19 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. +#include "esphome/components/remote_base/remote_base.h" + +#ifdef USE_IR_RF #include "esphome/components/infrared/infrared.h" +#endif + +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif namespace esphome::ir_rf_proxy { +#ifdef USE_IR_RF /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend class IrRfProxy : public infrared::Infrared { public: @@ -26,8 +35,36 @@ class IrRfProxy : public infrared::Infrared { void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); } protected: + void control(const infrared::InfraredCall &call) override; + // RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF uint32_t frequency_khz_{0}; }; +#endif // USE_IR_RF + +#ifdef USE_RADIO_FREQUENCY +/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend +class RfProxy : public radio_frequency::RadioFrequency { + public: + RfProxy() = default; + + void setup() override; + void dump_config() override; + + /// Set the remote transmitter component + void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } + /// Set the remote receiver component + void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } + + /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) + void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); } + + protected: + void control(const radio_frequency::RadioFrequencyCall &call) override; + + remote_base::RemoteTransmitterBase *transmitter_{nullptr}; + remote_base::RemoteReceiverBase *receiver_{nullptr}; +}; +#endif // USE_RADIO_FREQUENCY } // namespace esphome::ir_rf_proxy diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py new file mode 100644 index 0000000000..9982f5e4d1 --- /dev/null +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -0,0 +1,68 @@ +"""Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" + +import esphome.codegen as cg +from esphome.components import radio_frequency, remote_receiver, remote_transmitter +import esphome.config_validation as cv +from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID, ir_rf_proxy_ns + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["radio_frequency"] + +RfProxy = ir_rf_proxy_ns.class_("RfProxy", radio_frequency.RadioFrequency) + +CONFIG_SCHEMA = cv.All( + radio_frequency.radio_frequency_schema(RfProxy).extend( + { + cv.Optional(CONF_FREQUENCY): cv.frequency, + cv.Optional(CONF_REMOTE_RECEIVER_ID): cv.use_id( + remote_receiver.RemoteReceiverComponent + ), + cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( + remote_transmitter.RemoteTransmitterComponent + ), + } + ), + cv.has_exactly_one_key(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID), +) + + +def _final_validate(config: ConfigType) -> None: + """Validate that RF transmitters have carrier duty set to 100%.""" + if CONF_REMOTE_TRANSMITTER_ID not in config: + return + + transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID] + full_config = fv.full_config.get() + transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1] + transmitter_config = full_config.get_config_for_path(transmitter_path) + + duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT) + if duty_percent is not None and duty_percent != 100: + raise cv.Invalid( + f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' " + "set to 100% for RF transmission. Dedicated RF hardware handles modulation; " + "applying a carrier duty cycle would corrupt the signal" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + """Code generation for remote_base radio frequency platform.""" + var = await radio_frequency.new_radio_frequency(config) + + if CONF_FREQUENCY in config: + cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY]))) + + if CONF_REMOTE_TRANSMITTER_ID in config: + transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) + cg.add(var.set_transmitter(transmitter)) + + if CONF_REMOTE_RECEIVER_ID in config: + receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) + cg.add(var.set_receiver(receiver)) diff --git a/tests/components/radio_frequency/common-rx.yaml b/tests/components/radio_frequency/common-rx.yaml new file mode 100644 index 0000000000..bcfa1f10c7 --- /dev/null +++ b/tests/components/radio_frequency/common-rx.yaml @@ -0,0 +1,18 @@ +remote_receiver: + id: rf_receiver + pin: ${rx_pin} + +# Test radio_frequency platform with receiver +radio_frequency: + # RF 900MHz receiver + - platform: ir_rf_proxy + id: rf_900_rx + name: "RF 900 Receiver" + frequency: 900 MHz + remote_receiver_id: rf_receiver + + # RF receiver (no frequency specified) + - platform: ir_rf_proxy + id: rf_rx + name: "RF Receiver" + remote_receiver_id: rf_receiver diff --git a/tests/components/radio_frequency/common-tx.yaml b/tests/components/radio_frequency/common-tx.yaml new file mode 100644 index 0000000000..778dd68d1e --- /dev/null +++ b/tests/components/radio_frequency/common-tx.yaml @@ -0,0 +1,19 @@ +remote_transmitter: + id: rf_transmitter + pin: ${tx_pin} + carrier_duty_percent: 100% + +# Test radio_frequency platform with transmitter +radio_frequency: + # RF 433MHz transmitter + - platform: ir_rf_proxy + id: rf_433_tx + name: "RF 433 Transmitter" + frequency: 433 MHz + remote_transmitter_id: rf_transmitter + + # RF transmitter (no frequency specified) + - platform: ir_rf_proxy + id: rf_tx + name: "RF Transmitter" + remote_transmitter_id: rf_transmitter diff --git a/tests/components/radio_frequency/common.yaml b/tests/components/radio_frequency/common.yaml new file mode 100644 index 0000000000..53a0cd379a --- /dev/null +++ b/tests/components/radio_frequency/common.yaml @@ -0,0 +1,7 @@ +network: + +wifi: + ssid: MySSID + password: password1 + +api: diff --git a/tests/components/radio_frequency/test.bk72xx-ard.yaml b/tests/components/radio_frequency/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..a0e145f476 --- /dev/null +++ b/tests/components/radio_frequency/test.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.esp32-idf.yaml b/tests/components/radio_frequency/test.esp32-idf.yaml new file mode 100644 index 0000000000..a0e145f476 --- /dev/null +++ b/tests/components/radio_frequency/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.esp8266-ard.yaml b/tests/components/radio_frequency/test.esp8266-ard.yaml new file mode 100644 index 0000000000..a0e145f476 --- /dev/null +++ b/tests/components/radio_frequency/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.rp2040-ard.yaml b/tests/components/radio_frequency/test.rp2040-ard.yaml new file mode 100644 index 0000000000..a0e145f476 --- /dev/null +++ b/tests/components/radio_frequency/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml From 58f6ad2d0ce727ae6df08d6a57eadedbfdc496a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 00:01:21 -0500 Subject: [PATCH 0646/2214] [safe_mode] Use StaticCallbackManager for on_safe_mode (#16002) --- esphome/components/safe_mode/__init__.py | 3 ++- esphome/components/safe_mode/safe_mode.h | 2 +- esphome/core/defines.h | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 6df0ba78b1..578376258a 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -76,8 +76,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if config.get(CONF_ON_SAFE_MODE): + if on_safe_mode := config.get(CONF_ON_SAFE_MODE): cg.add_define("USE_SAFE_MODE_CALLBACK") + cg.add_define("ESPHOME_SAFE_MODE_CALLBACK_COUNT", len(on_safe_mode)) await automation.build_callback_automations( var, config, _CALLBACK_AUTOMATIONS ) diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 2733054962..b458a9a302 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -57,7 +57,7 @@ class SafeModeComponent final : public Component { // Larger objects at the end ESPPreferenceObject rtc_; #ifdef USE_SAFE_MODE_CALLBACK - CallbackManager safe_mode_callback_{}; + StaticCallbackManager safe_mode_callback_{}; #endif static const uint32_t ENTER_SAFE_MODE_MAGIC = diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da..297bf081c5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -136,6 +136,7 @@ #define USE_PREFERENCES_SYNC_EVERY_LOOP #define USE_QR_CODE #define USE_SAFE_MODE_CALLBACK +#define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT #define USE_SENSOR #define USE_SENSOR_FILTER From f092e619d8b37e71054636ba1b63848159c31438 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 00:03:59 -0500 Subject: [PATCH 0647/2214] [rtttl] Gate on_finished_playback callback storage behind define (#16003) --- esphome/components/rtttl/__init__.py | 4 +++- esphome/components/rtttl/rtttl.cpp | 2 ++ esphome/components/rtttl/rtttl.h | 6 ++++++ esphome/core/defines.h | 1 + tests/components/rtttl/common.yaml | 5 +++++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index c661aad972..4880f9ac41 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -93,7 +93,9 @@ async def to_code(config): cg.add(var.set_gain(config[CONF_GAIN])) - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config.get(CONF_ON_FINISHED_PLAYBACK): + cg.add_define("USE_RTTTL_FINISHED_PLAYBACK_CALLBACK") + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 08d902b4be..a5f8567c9d 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -424,7 +424,9 @@ void Rtttl::set_state_(State state) { // Clear loop_done when transitioning from `State::STOPPED` to any other state if (state == State::STOPPED) { this->disable_loop(); +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK this->on_finished_playback_callback_.call(); +#endif ESP_LOGD(TAG, "Playback finished"); } else if (old_state == State::STOPPED) { this->enable_loop(); diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 98ed9ba1bf..9dac92be2a 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -2,6 +2,8 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -45,9 +47,11 @@ class Rtttl : public Component { bool is_playing() { return this->state_ != State::STOPPED; } +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK template void add_on_finished_playback_callback(F &&callback) { this->on_finished_playback_callback_.add(std::forward(callback)); } +#endif protected: inline uint16_t get_integer_() { @@ -106,8 +110,10 @@ class Rtttl : public Component { uint32_t samples_gap_{0}; #endif // USE_SPEAKER +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; +#endif }; template class PlayAction : public Action { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 297bf081c5..f929b224ca 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -186,6 +186,7 @@ #define USE_MQTT #define USE_MQTT_COVER_JSON #define USE_NETWORK +#define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG #define USE_RUNTIME_IMAGE_JPEG diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index 529713583b..a4d8f951f4 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -29,3 +29,8 @@ output: rtttl: output: rtttl_output + on_finished_playback: + - then: + - logger.log: "Playback finished 1" + - then: + - logger.log: "Playback finished 2" From 5be9766809e933d5aeb64435001512efe70de231 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 26 Apr 2026 10:56:39 +0400 Subject: [PATCH 0648/2214] perf(build): enable reproducible PlatformIO ESP-IDF builds --- esphome/components/esp32/__init__.py | 4 ++++ .../esp32/config/reproducible_build.yaml | 8 ++++++++ tests/component_tests/esp32/test_esp32.py | 11 +++++++++++ 3 files changed, 23 insertions(+) create mode 100644 tests/component_tests/esp32/config/reproducible_build.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1a7ae700c7..78a1715ccf 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1729,6 +1729,10 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") if use_platformio: cg.add_platformio_option("framework", "espidf") + # Strip volatile build path/time metadata from PlatformIO-managed + # ESP-IDF builds so equivalent projects can produce reproducible + # outputs and downstream tooling can safely reuse artifacts. + add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) # Wrap std::__throw_* functions to abort immediately, eliminating ~3KB of # exception class overhead. See throw_stubs.cpp for implementation. diff --git a/tests/component_tests/esp32/config/reproducible_build.yaml b/tests/component_tests/esp32/config/reproducible_build.yaml new file mode 100644 index 0000000000..eb9721b432 --- /dev/null +++ b/tests/component_tests/esp32/config/reproducible_build.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + variant: esp32 + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index ac492e2752..d2cb21a81a 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -232,3 +232,14 @@ def test_execute_from_psram_disabled_sdkconfig( assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig assert "CONFIG_SPIRAM_RODATA" not in sdkconfig assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig + + +def test_platformio_idf_enables_reproducible_build( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """PlatformIO ESP-IDF builds should enable reproducible app metadata.""" + generate_main(component_config_path("reproducible_build.yaml")) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True From dc57969afdc96dd546ceaca27474200f090e6c6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 03:39:24 -0500 Subject: [PATCH 0649/2214] [host] Use integer math in millis()/micros() (#15994) --- esphome/components/host/core.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 0ade4274fe..b067ebbf6e 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include namespace { @@ -22,9 +21,7 @@ void HOT yield() { ::sched_yield(); } uint32_t IRAM_ATTR HOT millis() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t ms = round(spec.tv_nsec / 1e6); - return ((uint32_t) seconds) * 1000U + ms; + return static_cast(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000); } uint64_t millis_64() { struct timespec spec; @@ -43,9 +40,7 @@ void HOT delay(uint32_t ms) { uint32_t IRAM_ATTR HOT micros() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t us = round(spec.tv_nsec / 1e3); - return ((uint32_t) seconds) * 1000000U + us; + return static_cast(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { struct timespec ts; From 8ae4b3a771af29d04cd920f7c04c7cd840ed1aad Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 26 Apr 2026 12:47:59 +0400 Subject: [PATCH 0650/2214] test: fix reproducible build docstring style --- tests/component_tests/esp32/test_esp32.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index d2cb21a81a..c39a4aafc8 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -238,7 +238,7 @@ def test_platformio_idf_enables_reproducible_build( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """PlatformIO ESP-IDF builds should enable reproducible app metadata.""" + """Test PlatformIO ESP-IDF builds enable reproducible app metadata.""" generate_main(component_config_path("reproducible_build.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] From b3a57d139575a46ed1ab47f8d0e32b1b7d423724 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 03:57:35 -0500 Subject: [PATCH 0651/2214] [git] Make ref fetches and submodule updates shallow Fixes esphome/esphome#11550 When clone_or_update is called with a ref or submodules, the initial clone uses --depth=1 but the subsequent git fetch and git submodule update commands run deep, pulling the full history on every refresh. This causes excess network traffic and disk usage for external_components, packages, and dashboard imports. Add --depth=1 to all four call sites (initial fetch, initial submodule init, refresh fetch, refresh submodule update). Shallow fetches still advance the local clone to the current remote tip when it moves, so the refresh path keeps working as before. --- esphome/git.py | 17 +++- tests/unit_tests/test_git.py | 186 +++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 5 deletions(-) diff --git a/esphome/git.py b/esphome/git.py index 096ff483a7..941c6ffcd2 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -128,7 +128,10 @@ def clone_or_update( # We need to fetch the PR branch first, otherwise git will complain # about missing objects _LOGGER.info("Fetching %s", ref) - run_git_command(["git", "fetch", "--", "origin", ref], git_dir=repo_dir) + run_git_command( + ["git", "fetch", "--depth=1", "--", "origin", ref], + git_dir=repo_dir, + ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir ) @@ -138,7 +141,7 @@ def clone_or_update( "Initializing submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1"] + submodules, git_dir=repo_dir, ) except GitException: @@ -181,8 +184,12 @@ def clone_or_update( git_dir=repo_dir, ) - # Fetch remote ref - cmd = ["git", "fetch", "--", "origin"] + # Fetch remote ref. --depth=1 keeps the clone shallow while + # still picking up new commits when the remote tip moves: a + # shallow fetch always retrieves the current tip of the + # requested ref, then reset --hard FETCH_HEAD updates the + # working tree to it. + cmd = ["git", "fetch", "--depth=1", "--", "origin"] if ref is not None: cmd.append(ref) run_git_command(cmd, git_dir=repo_dir) @@ -231,7 +238,7 @@ def clone_or_update( "Updating submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1"] + submodules, git_dir=repo_dir, ) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 745dfad487..447956ed13 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -782,3 +782,189 @@ def test_clone_or_update_stale_clone_is_retried_after_cleanup( assert repo_dir.exists() assert call_count["clone"] == 2 assert call_count["fetch"] == 2 + + +def test_clone_with_ref_uses_shallow_fetch( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Clone with a ref should use --depth=1 on both clone and fetch.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "pull/123/head" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / ".git").mkdir(exist_ok=True) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update(url=url, ref=ref, refresh=None, domain=domain) + + call_list = mock_run_git_command.call_args_list + + clone_calls = [c for c in call_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert "--depth=1" in clone_calls[0][0][0] + + fetch_calls = [c for c in call_list if "fetch" in c[0][0]] + assert len(fetch_calls) == 1 + assert "--depth=1" in fetch_calls[0][0][0] + # Ref must still be passed so the requested commit/branch is fetched. + assert ref in fetch_calls[0][0][0] + + +def test_clone_with_submodules_uses_shallow_submodule_update( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Submodule init on a fresh clone should use --depth=1.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / ".git").mkdir(exist_ok=True) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update( + url=url, + ref=None, + refresh=None, + domain=domain, + submodules=["components/foo"], + ) + + submodule_calls = [ + c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0] + ] + assert len(submodule_calls) == 1 + cmd = submodule_calls[0][0][0] + assert "--depth=1" in cmd + assert "components/foo" in cmd + + +def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> None: + """The refresh-path fetch should use --depth=1.""" + 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) + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + fetch_calls = [c for c in mock_run_git_command.call_args_list if "fetch" in c[0][0]] + assert len(fetch_calls) == 1 + cmd = fetch_calls[0][0][0] + assert "--depth=1" in cmd + # Ref must still be in the refresh fetch so the right tip is updated. + assert cmd[-1] == ref + + +def test_refresh_submodule_update_is_shallow( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The refresh-path submodule update should use --depth=1.""" + 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) + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=None, + refresh=TimePeriodSeconds(days=1), + domain=domain, + submodules=["components/foo"], + ) + + submodule_calls = [ + c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0] + ] + assert len(submodule_calls) == 1 + cmd = submodule_calls[0][0][0] + assert "--depth=1" in cmd + assert "components/foo" in cmd + + +def test_refresh_picks_up_new_remote_commits( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Shallow fetch must still pull new commits when the remote tip moves. + + Simulates a stale local repo at SHA "old" while the remote has advanced + to SHA "new". The refresh path must run fetch (with --depth=1) followed + by reset --hard FETCH_HEAD so the working tree advances to the new tip. + """ + 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) + + # rev-parse is called once before fetch to record the pre-update SHA. + rev_parse_calls = {"count": 0} + + 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": + rev_parse_calls["count"] += 1 + return "old_sha" + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + _, revert = git.clone_or_update( + url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + # Verify the refresh sequence: rev-parse -> stash -> fetch (depth=1) -> reset + call_list = mock_run_git_command.call_args_list + cmd_sequence = [_get_git_command_type(c[0][0]) for c in call_list] + assert cmd_sequence == ["rev-parse", "stash", "fetch", "reset"] + + fetch_cmd = call_list[2][0][0] + assert "--depth=1" in fetch_cmd + assert fetch_cmd[-1] == ref + + reset_cmd = call_list[3][0][0] + assert reset_cmd[-1] == "FETCH_HEAD" + + # revert callback should reset back to the recorded pre-update SHA. + assert revert is not None + revert() + assert mock_run_git_command.call_args_list[-1][0][0] == [ + "git", + "reset", + "--hard", + "old_sha", + ] From e907e711011034fa98b098e8c80ea0e1f923c10e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 04:08:43 -0500 Subject: [PATCH 0652/2214] [git] Address PR review feedback - Add `--` terminator before submodule paths (both clone and refresh paths) so a path beginning with `-` cannot be parsed as a git option. - Reword the refresh-fetch comment: this fetch also runs when ref is None, in which case it pulls the remote default branch. --- esphome/git.py | 15 +++++++++------ tests/unit_tests/test_git.py | 4 ++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/esphome/git.py b/esphome/git.py index 941c6ffcd2..5ee6625227 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -141,7 +141,8 @@ def clone_or_update( "Initializing submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init", "--depth=1"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1", "--"] + + submodules, git_dir=repo_dir, ) except GitException: @@ -184,10 +185,11 @@ def clone_or_update( git_dir=repo_dir, ) - # Fetch remote ref. --depth=1 keeps the clone shallow while - # still picking up new commits when the remote tip moves: a - # shallow fetch always retrieves the current tip of the - # requested ref, then reset --hard FETCH_HEAD updates the + # Fetch from the remote. --depth=1 keeps the clone shallow + # while still picking up new commits when the remote tip + # moves: a shallow fetch retrieves the current tip being + # fetched, whether that's an explicit ref or the remote's + # default branch, then reset --hard FETCH_HEAD updates the # working tree to it. cmd = ["git", "fetch", "--depth=1", "--", "origin"] if ref is not None: @@ -238,7 +240,8 @@ def clone_or_update( "Updating submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init", "--depth=1"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1", "--"] + + submodules, git_dir=repo_dir, ) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 447956ed13..2429365006 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -855,6 +855,9 @@ def test_clone_with_submodules_uses_shallow_submodule_update( cmd = submodule_calls[0][0][0] assert "--depth=1" in cmd assert "components/foo" in cmd + # The `--` terminator must precede the submodule paths so a path + # beginning with `-` cannot be parsed as an option. + assert cmd.index("--") < cmd.index("components/foo") def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> None: @@ -909,6 +912,7 @@ def test_refresh_submodule_update_is_shallow( cmd = submodule_calls[0][0][0] assert "--depth=1" in cmd assert "components/foo" in cmd + assert cmd.index("--") < cmd.index("components/foo") def test_refresh_picks_up_new_remote_commits( From 68625a1b76aafaa5a859cea3c090333dfa81ce40 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 26 Apr 2026 13:11:09 +0400 Subject: [PATCH 0653/2214] [core] Isolate generated build metadata (#16007) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/core/config.py | 8 ++- esphome/writer.py | 101 ++++++++++++++++++++------- tests/unit_tests/test_writer.py | 119 ++++++++++++++++++++++---------- 3 files changed, 167 insertions(+), 61 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index bf210876df..018e05f17b 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -242,6 +242,10 @@ PROJECT_MAX_LENGTH = 127 # Max board/model string length (must fit in single-byte varint for proto encoding) BOARD_MAX_LENGTH = 127 +# Keep in sync with ESPHOME_COMMENT_SIZE_MAX in esphome/core/application.h +# (C++ side includes the null terminator). +COMMENT_MAX_LEN = 255 + AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), @@ -275,7 +279,9 @@ CONFIG_SCHEMA = cv.All( cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA): validate_area_config, - cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)), + cv.Optional(CONF_COMMENT): cv.All( + cv.string, cv.ByteLength(max=COMMENT_MAX_LEN) + ), cv.Required(CONF_BUILD_PATH): cv.string, cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema( { diff --git a/esphome/writer.py b/esphome/writer.py index 787ecac6f6..816c57a0bc 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -22,7 +22,6 @@ from esphome.helpers import ( read_file, rmtree, walk_files, - write_file, write_file_if_changed, ) from esphome.storage_json import StorageJSON, storage_path @@ -171,6 +170,7 @@ VERSION_H_FORMAT = """\ DEFINES_H_TARGET = "esphome/core/defines.h" VERSION_H_TARGET = "esphome/core/version.h" BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" +BUILD_INFO_DATA_CPP_TARGET = "esphome/core/build_info_data.cpp" ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h" ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -209,13 +209,22 @@ def copy_src_tree(): source_files_copy = source_files_map.copy() ignore_targets = [ - Path(x) for x in (DEFINES_H_TARGET, VERSION_H_TARGET, BUILD_INFO_DATA_H_TARGET) + Path(x) + for x in ( + DEFINES_H_TARGET, + VERSION_H_TARGET, + BUILD_INFO_DATA_H_TARGET, + BUILD_INFO_DATA_CPP_TARGET, + ) ] for t in ignore_targets: source_files_copy.pop(t, None) # Files to exclude from sources_changed tracking (generated files) - generated_files = {Path("esphome/core/build_info_data.h")} + generated_files = { + Path("esphome/core/build_info_data.h"), + Path("esphome/core/build_info_data.cpp"), + } sources_changed = False for fname in walk_files(CORE.relative_src_path("esphome")): @@ -268,12 +277,15 @@ def copy_src_tree(): build_info_data_h_path = CORE.relative_src_path( "esphome", "core", "build_info_data.h" ) + build_info_data_cpp_path = CORE.relative_src_path( + "esphome", "core", "build_info_data.cpp" + ) build_info_json_path = CORE.relative_build_path("build_info.json") config_hash, build_time, build_time_str, comment = get_build_info() # Defensively force a rebuild if the build_info files don't exist, or if # there was a config change which didn't actually cause a source change - if not build_info_data_h_path.exists(): + if not build_info_data_h_path.exists() or not build_info_data_cpp_path.exists(): sources_changed = True else: try: @@ -288,13 +300,19 @@ def copy_src_tree(): # Write build_info header and JSON metadata if sources_changed: - write_file( + # write_file_if_changed avoids bumping mtime on identical content, + # which is what makes the stable header actually isolate metadata churn. + write_file_if_changed( build_info_data_h_path, - generate_build_info_data_h( + generate_build_info_data_h(), + ) + write_file_if_changed( + build_info_data_cpp_path, + generate_build_info_data_cpp( config_hash, build_time, build_time_str, comment ), ) - write_file( + write_file_if_changed( build_info_json_path, json.dumps( { @@ -345,27 +363,60 @@ def get_build_info() -> tuple[int, int, str, str]: return config_hash, build_time, build_time_str, comment -def generate_build_info_data_h( - config_hash: int, build_time: int, build_time_str: str, comment: str -) -> str: - """Generate build_info_data.h header with config hash, build time, and comment.""" - # cpp_string_escape returns '"escaped"', slice off the quotes since template has them - escaped_comment = cpp_string_escape(comment)[1:-1] - # +1 for null terminator - comment_size = len(comment) + 1 - return f"""#pragma once -// Auto-generated build_info data -#define ESPHOME_CONFIG_HASH 0x{config_hash:08x}U // NOLINT -#define ESPHOME_BUILD_TIME {build_time} // NOLINT -#define ESPHOME_COMMENT_SIZE {comment_size} // NOLINT +def generate_build_info_data_h() -> str: + """Generate stable declarations for build info provided by generated C++.""" + return """#pragma once +// Auto-generated build_info declarations +#include +#include +#include #ifdef USE_ESP8266 #include -static const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}"; -static const char ESPHOME_COMMENT_STR[] PROGMEM = "{escaped_comment}"; -#else -static const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}"; -static const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; #endif + +namespace esphome { +extern const uint32_t ESPHOME_CONFIG_HASH; +extern const time_t ESPHOME_BUILD_TIME; +extern const size_t ESPHOME_COMMENT_SIZE; +#ifdef USE_ESP8266 +extern const char ESPHOME_BUILD_TIME_STR[] PROGMEM; +extern const char ESPHOME_COMMENT_STR[] PROGMEM; +#else +extern const char ESPHOME_BUILD_TIME_STR[]; +extern const char ESPHOME_COMMENT_STR[]; +#endif +} // namespace esphome +""" + + +def generate_build_info_data_cpp( + config_hash: int, build_time: int, build_time_str: str, comment: str +) -> str: + """Generate build_info_data.cpp with config hash, build time, and comment.""" + from esphome.core.config import COMMENT_MAX_LEN + + # Defense-in-depth clamp; errors="ignore" drops a partial trailing UTF-8 + # sequence so the literal never decodes to a truncated codepoint. + encoded = comment.encode("utf-8")[:COMMENT_MAX_LEN] + comment = encoded.decode("utf-8", errors="ignore") + # cpp_string_escape wraps in quotes; strip them since the template has them. + escaped_comment = cpp_string_escape(comment)[1:-1] + comment_size = len(comment.encode("utf-8")) + 1 # +1 for NUL + return f"""// Auto-generated build_info data +#include "esphome/core/build_info_data.h" + +namespace esphome {{ +const uint32_t ESPHOME_CONFIG_HASH = 0x{config_hash:08x}U; // NOLINT +const time_t ESPHOME_BUILD_TIME = {build_time}; // NOLINT +const size_t ESPHOME_COMMENT_SIZE = {comment_size}; // NOLINT +#ifdef USE_ESP8266 +const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}"; +const char ESPHOME_COMMENT_STR[] PROGMEM = "{escaped_comment}"; +#else +const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}"; +const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; +#endif +}} // namespace esphome """ diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 940a394c08..e76769e6a8 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -7,6 +7,7 @@ from datetime import datetime import json import os from pathlib import Path +import re import stat from typing import Any from unittest.mock import MagicMock, patch @@ -32,6 +33,7 @@ from esphome.writer import ( clean_build, clean_cmake_cache, copy_src_tree, + generate_build_info_data_cpp, generate_build_info_data_h, get_build_info, storage_should_clean, @@ -1615,49 +1617,62 @@ def test_get_build_info_build_time_str_format( def test_generate_build_info_data_h_format() -> None: """Test generate_build_info_data_h produces correct header content.""" - config_hash = 0x12345678 - build_time = 1700000000 - build_time_str = "2023-11-14 22:13:20 +0000" - comment = "Test comment" - - result = generate_build_info_data_h( - config_hash, build_time, build_time_str, comment - ) + result = generate_build_info_data_h() assert "#pragma once" in result - assert "#define ESPHOME_CONFIG_HASH 0x12345678U" in result - assert "#define ESPHOME_BUILD_TIME 1700000000" in result - assert "#define ESPHOME_COMMENT_SIZE 13" in result # len("Test comment") + 1 - assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result - assert 'ESPHOME_COMMENT_STR[] = "Test comment"' in result + assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in result + assert "extern const time_t ESPHOME_BUILD_TIME;" in result + assert "extern const size_t ESPHOME_COMMENT_SIZE;" in result + assert "extern const char ESPHOME_BUILD_TIME_STR[]" in result + assert "extern const char ESPHOME_COMMENT_STR[]" in result def test_generate_build_info_data_h_esp8266_progmem() -> None: """Test generate_build_info_data_h includes PROGMEM for ESP8266.""" - result = generate_build_info_data_h(0xABCDEF01, 1700000000, "test", "comment") + result = generate_build_info_data_h() # Should have ESP8266 PROGMEM conditional assert "#ifdef USE_ESP8266" in result assert "#include " in result assert "PROGMEM" in result - # Both build time and comment should have PROGMEM versions + + +def test_generate_build_info_data_cpp_format() -> None: + """Test generate_build_info_data_cpp produces correct data definitions.""" + result = generate_build_info_data_cpp( + 0x12345678, 1700000000, "2023-11-14 22:13:20 +0000", "Test comment" + ) + + assert '#include "esphome/core/build_info_data.h"' in result + assert "const uint32_t ESPHOME_CONFIG_HASH = 0x12345678U;" in result + assert "const time_t ESPHOME_BUILD_TIME = 1700000000;" in result + assert "const size_t ESPHOME_COMMENT_SIZE = 13;" in result + assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result + assert 'ESPHOME_COMMENT_STR[] = "Test comment"' in result + + +def test_generate_build_info_data_cpp_esp8266_progmem() -> None: + """Test generate_build_info_data_cpp includes PROGMEM definitions.""" + result = generate_build_info_data_cpp(0xABCDEF01, 1700000000, "test", "comment") + + assert "#ifdef USE_ESP8266" in result assert 'ESPHOME_BUILD_TIME_STR[] PROGMEM = "test"' in result assert 'ESPHOME_COMMENT_STR[] PROGMEM = "comment"' in result -def test_generate_build_info_data_h_hash_formatting() -> None: - """Test generate_build_info_data_h formats hash with leading zeros.""" +def test_generate_build_info_data_cpp_hash_formatting() -> None: + """Test generate_build_info_data_cpp formats hash with leading zeros.""" # Test with small hash value that needs leading zeros - result = generate_build_info_data_h(0x00000001, 0, "test", "") - assert "#define ESPHOME_CONFIG_HASH 0x00000001U" in result + result = generate_build_info_data_cpp(0x00000001, 0, "test", "") + assert "const uint32_t ESPHOME_CONFIG_HASH = 0x00000001U;" in result # Test with larger hash value - result = generate_build_info_data_h(0xFFFFFFFF, 0, "test", "") - assert "#define ESPHOME_CONFIG_HASH 0xffffffffU" in result + result = generate_build_info_data_cpp(0xFFFFFFFF, 0, "test", "") + assert "const uint32_t ESPHOME_CONFIG_HASH = 0xffffffffU;" in result -def test_generate_build_info_data_h_comment_escaping() -> None: - r"""Test generate_build_info_data_h properly escapes special characters in comment. +def test_generate_build_info_data_cpp_comment_escaping() -> None: + r"""Test generate_build_info_data_cpp properly escapes special characters in comment. Uses cpp_string_escape which outputs octal escapes for special characters: - backslash (ASCII 92) -> \134 @@ -1665,26 +1680,52 @@ def test_generate_build_info_data_h_comment_escaping() -> None: - newline (ASCII 10) -> \012 """ # Test backslash escaping (ASCII 92 = octal 134) - result = generate_build_info_data_h(0, 0, "test", "backslash\\here") + result = generate_build_info_data_cpp(0, 0, "test", "backslash\\here") assert 'ESPHOME_COMMENT_STR[] = "backslash\\134here"' in result # Test quote escaping (ASCII 34 = octal 042) - result = generate_build_info_data_h(0, 0, "test", 'has "quotes"') + result = generate_build_info_data_cpp(0, 0, "test", 'has "quotes"') assert 'ESPHOME_COMMENT_STR[] = "has \\042quotes\\042"' in result # Test newline escaping (ASCII 10 = octal 012) - result = generate_build_info_data_h(0, 0, "test", "line1\nline2") + result = generate_build_info_data_cpp(0, 0, "test", "line1\nline2") assert 'ESPHOME_COMMENT_STR[] = "line1\\012line2"' in result -def test_generate_build_info_data_h_empty_comment() -> None: - """Test generate_build_info_data_h handles empty comment.""" - result = generate_build_info_data_h(0, 0, "test", "") +def test_generate_build_info_data_cpp_empty_comment() -> None: + """Test generate_build_info_data_cpp handles empty comment.""" + result = generate_build_info_data_cpp(0, 0, "test", "") - assert "#define ESPHOME_COMMENT_SIZE 1" in result # Just null terminator + assert "const size_t ESPHOME_COMMENT_SIZE = 1;" in result # Just null terminator assert 'ESPHOME_COMMENT_STR[] = ""' in result +def test_generate_build_info_data_cpp_comment_size_counts_utf8_bytes() -> None: + """Comment size is in encoded UTF-8 bytes, not characters.""" + # "héllo" = 6 UTF-8 bytes + NUL. + result = generate_build_info_data_cpp(0, 0, "test", "héllo") + assert "const size_t ESPHOME_COMMENT_SIZE = 7;" in result + + +def test_generate_build_info_data_cpp_comment_clamped_to_buffer() -> None: + """Generator clamps at byte level and never truncates mid-codepoint.""" + # 100 thermometer-with-VS-16 sequences = 700 bytes, past the 256 buffer. + result = generate_build_info_data_cpp(0, 0, "test", "🌡️" * 100) + + match = re.search(r"ESPHOME_COMMENT_SIZE = (\d+);", result) + assert match is not None + size = int(match.group(1)) + assert 1 < size <= 256 + + lit_match = re.search(r'ESPHOME_COMMENT_STR\[\] = "([^"]*)"', result) + assert lit_match is not None + raw = re.sub( + r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), lit_match.group(1) + ).encode("latin-1") + raw.decode("utf-8") # raises if truncation left a partial UTF-8 sequence + assert len(raw) == size - 1 + + @patch("esphome.writer.CORE") @patch("esphome.writer.iter_components") @patch("esphome.writer.walk_files") @@ -1758,15 +1799,21 @@ def test_copy_src_tree_writes_build_info_files( ): copy_src_tree() - # Verify build_info_data.h was written + # Verify build_info_data.h declarations and build_info_data.cpp values were written build_info_h_path = esphome_core_path / "build_info_data.h" assert build_info_h_path.exists() build_info_h_content = build_info_h_path.read_text() - assert "#define ESPHOME_CONFIG_HASH 0xdeadbeefU" in build_info_h_content - assert "#define ESPHOME_BUILD_TIME" in build_info_h_content + assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in build_info_h_content assert "ESPHOME_BUILD_TIME_STR" in build_info_h_content - assert "#define ESPHOME_COMMENT_SIZE" in build_info_h_content + assert "extern const size_t ESPHOME_COMMENT_SIZE;" in build_info_h_content assert "ESPHOME_COMMENT_STR" in build_info_h_content + build_info_cpp_path = esphome_core_path / "build_info_data.cpp" + assert build_info_cpp_path.exists() + build_info_cpp_content = build_info_cpp_path.read_text() + assert "const uint32_t ESPHOME_CONFIG_HASH = 0xdeadbeefU;" in build_info_cpp_content + assert "const time_t ESPHOME_BUILD_TIME" in build_info_cpp_content + assert "const size_t ESPHOME_COMMENT_SIZE" in build_info_cpp_content + assert "ESPHOME_COMMENT_STR" in build_info_cpp_content # Verify build_info.json was written build_info_json_path = build_path / "build_info.json" @@ -1833,7 +1880,9 @@ def test_copy_src_tree_detects_config_hash_change( # Verify build_info files were updated due to config_hash change assert build_info_h_path.exists() - new_content = build_info_h_path.read_text() + build_info_cpp_path = esphome_core_path / "build_info_data.cpp" + assert build_info_cpp_path.exists() + new_content = build_info_cpp_path.read_text() assert "0xdeadbeef" in new_content.lower() new_json = json.loads(build_info_json_path.read_text()) From bd1dd68d6bb9ccfc8305ae9313f53aadc5afab6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 04:24:59 -0500 Subject: [PATCH 0654/2214] [rotary_encoder][at581x] Fix templatable int field types The TEMPLATABLE_VALUE C++ fields are typed `int`, but Python codegen was passing `cg.int32` (`int32_t`). On ESP-IDF/xtensa toolchains where `int32_t` is `long`, the codegen-generated lambda's function-pointer type `int32_t (*)(...)` does not match `int (*)(...)`, causing `TemplatableFn` to select its deprecated casting-trampoline overload and emit a `-Wdeprecated-declarations` warning at every call site. Use `cg.int_` so the lambda's return type matches the field exactly. --- esphome/components/at581x/__init__.py | 8 ++++---- esphome/components/rotary_encoder/sensor.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 94b68db4b3..5031b72cce 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_sensing_distance(template_)) if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, cg.int32) + template_ = await cg.templatable(selfcheck, args, cg.int_) cg.add(var.set_poweron_selfcheck_time(template_)) if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, cg.int32) + template_ = await cg.templatable(protect, args, cg.int_) cg.add(var.set_protect_time(template_)) if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, cg.int32) + template_ = await cg.templatable(trig_base, args, cg.int_) cg.add(var.set_trigger_base(template_)) if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, cg.int32) + template_ = await cg.templatable(trig_keep, args, cg.int_) cg.add(var.set_trigger_keep(template_)) if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 21239863e4..0e5a03523d 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -129,6 +129,6 @@ async def to_code(config): async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.int32) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) cg.add(var.set_value(template_)) return var From 26ba4850e4fad42421e3b9c4400b2efb830efc3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 04:37:35 -0500 Subject: [PATCH 0655/2214] [external_files] Skip remote freshness checks during esphome logs Avoid per-file HTTP HEAD requests during config validation when running esphome logs against a previously-cached project. The skip_external_update flag was already plumbed for git operations, but external_files.download_content ignored it. Thread it through CORE so audio_file, micro_wake_word, speaker/media_player, image, font, and bme68x_bsec2 reuse cached files without per-URL network round-trips when the file already exists locally. --- esphome/config.py | 2 ++ esphome/core/__init__.py | 4 +++ esphome/external_files.py | 3 ++ tests/unit_tests/test_external_files.py | 48 +++++++++++++++++++++++++ 4 files changed, 57 insertions(+) diff --git a/esphome/config.py b/esphome/config.py index 641b6ec1b4..431559ce9c 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -997,6 +997,8 @@ def validate_config( ) -> Config: result = Config() + CORE.skip_external_update = skip_external_update + loader.clear_component_meta_finders() loader.install_custom_components_meta_finder() diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f86..4fecebcd8d 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -615,6 +615,9 @@ class EsphomeCore: self.address_cache: AddressCache | None = None # Cached config hash (computed lazily) self._config_hash: int | None = None + # When True, skip network freshness checks for cached external files + # (e.g. for `esphome logs`, where remote downloads aren't needed) + self.skip_external_update: bool = False def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -644,6 +647,7 @@ class EsphomeCore: self.current_component = None self.address_cache = None self._config_hash = None + self.skip_external_update = False PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/external_files.py b/esphome/external_files.py index 55711e1b79..af207589f6 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -82,6 +82,9 @@ def compute_local_file_dir(domain: str) -> Path: def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes: + if CORE.skip_external_update and path.exists(): + _LOGGER.debug("Skipping update for %s (refresh disabled)", url) + return path.read_bytes() if not has_remote_file_changed(url, path): _LOGGER.debug("Remote file has not changed %s", url) return path.read_bytes() diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index a319fae83d..50c90059d5 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -236,3 +236,51 @@ def test_download_content_with_network_error_no_cache_fails( with pytest.raises(Invalid, match="Could not download from.*Network error"): external_files.download_content(url, test_file) + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_skip_external_update_uses_cache( + mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path +) -> None: + """Test download_content skips network checks when CORE.skip_external_update is set.""" + test_file = setup_core / "cached.txt" + cached_content = b"cached content" + test_file.write_bytes(cached_content) + + CORE.skip_external_update = True + try: + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) + finally: + CORE.skip_external_update = False + + assert result == cached_content + mock_has_changed.assert_not_called() + mock_get.assert_not_called() + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_skip_external_update_downloads_when_missing( + mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path +) -> None: + """Test download_content still downloads when file is missing, even with skip_external_update.""" + test_file = setup_core / "missing.txt" + new_content = b"fresh content" + + mock_has_changed.return_value = True + mock_response = MagicMock() + mock_response.content = new_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + CORE.skip_external_update = True + try: + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) + finally: + CORE.skip_external_update = False + + assert result == new_content + assert test_file.read_bytes() == new_content From 5150a32224749525c948f91453c671ff66eec86e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 04:43:53 -0500 Subject: [PATCH 0656/2214] [external_files] Unify skip_external_update via CORE flag for git as well --- .../external_components/__init__.py | 21 +-- esphome/components/packages/__init__.py | 24 ++-- esphome/config.py | 7 +- esphome/external_files.py | 2 +- esphome/git.py | 5 +- .../external_components/test_init.py | 128 ++++++------------ tests/component_tests/packages/test_init.py | 122 ++++++----------- tests/unit_tests/test_git.py | 32 +++++ tests/unit_tests/test_substitutions.py | 4 +- 9 files changed, 146 insertions(+), 199 deletions(-) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index ceb402c5b7..b69c9d24d3 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +from typing import Any from esphome import git, loader import esphome.config_validation as cv @@ -17,7 +18,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, ) -from esphome.core import CORE +from esphome.core import CORE, TimePeriodSeconds _LOGGER = logging.getLogger(__name__) @@ -35,17 +36,17 @@ CONFIG_SCHEMA = cv.ensure_list( ) -async def to_code(config): +async def to_code(config: dict[str, Any]) -> None: pass -def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str: - # When skip_update is True, use NEVER_REFRESH to prevent updates - actual_refresh = git.NEVER_REFRESH if skip_update else refresh +def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> Path: + # CORE.skip_external_update (set by `esphome logs`) is honored inside + # git.clone_or_update; no need to translate to NEVER_REFRESH here. repo_dir, _ = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=actual_refresh, + refresh=refresh, domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -72,12 +73,12 @@ def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str return components_dir -def _process_single_config(config: dict, skip_update: bool = False): +def _process_single_config(config: dict[str, Any]) -> None: conf = config[CONF_SOURCE] if conf[CONF_TYPE] == TYPE_GIT: with cv.prepend_path([CONF_SOURCE]): components_dir = _process_git_config( - config[CONF_SOURCE], config[CONF_REFRESH], skip_update + config[CONF_SOURCE], config[CONF_REFRESH] ) elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) @@ -107,7 +108,7 @@ def _process_single_config(config: dict, skip_update: bool = False): loader.install_meta_finder(components_dir, allowed_components=allowed_components) -def do_external_components_pass(config: dict, skip_update: bool = False) -> None: +def do_external_components_pass(config: dict[str, Any]) -> None: conf = config.get(DOMAIN) if conf is None: return @@ -115,4 +116,4 @@ def do_external_components_pass(config: dict, skip_update: bool = False) -> None conf = CONFIG_SCHEMA(conf) for i, c in enumerate(conf): with cv.prepend_path(i): - _process_single_config(c, skip_update) + _process_single_config(c) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 1b9e03d88f..ae6af34dd8 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -205,7 +205,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either: ) -def _process_remote_package(config: dict, skip_update: bool = False) -> dict: +def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: """Clone/update a git repo and load the YAML files listed in the package definition. Returns ``{"packages": {: , ...}}`` so the caller @@ -214,12 +214,14 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict: If loading fails after cloning, attempts a revert and retry in case a prior cached checkout is stale. + + CORE.skip_external_update (set by `esphome logs`) is honored inside + git.clone_or_update; no need to translate to NEVER_REFRESH here. """ - actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH] repo_dir, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=actual_refresh, + refresh=config[CONF_REFRESH], domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -456,11 +458,9 @@ class _PackageProcessor: self, substitutions: UserDict, command_line_substitutions: dict[str, Any] | None, - skip_update: bool, ) -> None: self.substitutions = substitutions self.parent_context = UserDict(command_line_substitutions or {}) - self.skip_update = skip_update def resolve_package( self, @@ -508,7 +508,7 @@ class _PackageProcessor: ) if is_remote_package(package_config): - package_config = _process_remote_package(package_config, self.skip_update) + package_config = _process_remote_package(package_config) return package_config def collect_substitutions(self, package_config: dict) -> None: @@ -552,15 +552,17 @@ class _PackageProcessor: def do_packages_pass( - config: dict, + config: dict[str, Any], *, command_line_substitutions: dict[str, Any] | None = None, - skip_update: bool = False, -) -> dict: +) -> dict[str, Any]: """Load, validate, and flatten all packages in the config. Returns the config with all packages loaded in-place (but not yet merged) and a consolidated ``substitutions:`` block restored at the front. + + CORE.skip_external_update (set by `esphome logs`) is honored inside + git.clone_or_update via _process_remote_package. """ if CONF_PACKAGES not in config: return config @@ -571,9 +573,7 @@ def do_packages_pass( config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions ) ) - processor = _PackageProcessor( - substitutions, command_line_substitutions, skip_update - ) + processor = _PackageProcessor(substitutions, command_line_substitutions) _update_substitutions_context(processor.parent_context, substitutions) context_vars = push_context( diff --git a/esphome/config.py b/esphome/config.py index 431559ce9c..6eb67af58b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1011,7 +1011,6 @@ def validate_config( config = do_packages_pass( config, command_line_substitutions=command_line_substitutions, - skip_update=skip_external_update, ) except vol.Invalid as err: result.update(config) @@ -1052,7 +1051,7 @@ def validate_config( result.add_output_path([CONF_EXTERNAL_COMPONENTS], CONF_EXTERNAL_COMPONENTS) try: - do_external_components_pass(config, skip_update=skip_external_update) + do_external_components_pass(config) except vol.Invalid as err: result.update(config) result.add_error(err) @@ -1343,7 +1342,9 @@ def strip_default_ids(config): return config -def read_config(command_line_substitutions, skip_external_update=False): +def read_config( + command_line_substitutions: dict[str, Any], skip_external_update: bool = False +) -> Config | None: _LOGGER.info("Reading configuration %s...", CORE.config_path) try: res = load_config(command_line_substitutions, skip_external_update) diff --git a/esphome/external_files.py b/esphome/external_files.py index af207589f6..b6f6149ebb 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -81,7 +81,7 @@ def compute_local_file_dir(domain: str) -> Path: return base_directory -def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes: +def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) return path.read_bytes() diff --git a/esphome/git.py b/esphome/git.py index 096ff483a7..a69b91bf26 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -151,8 +151,9 @@ def clone_or_update( else: # Check refresh needed - # Skip refresh if NEVER_REFRESH is specified - if refresh == NEVER_REFRESH: + # Skip refresh if NEVER_REFRESH is specified or external updates are + # globally disabled for this run (e.g. `esphome logs`). + if refresh == NEVER_REFRESH or CORE.skip_external_update: _LOGGER.debug("Skipping update for %s (refresh disabled)", key) return repo_dir, None diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index 905c0afa8b..1d09552bdf 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -1,9 +1,11 @@ -"""Tests for the external_components skip_update functionality.""" +"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update.""" from pathlib import Path from typing import Any from unittest.mock import MagicMock +import pytest + from esphome.components.external_components import do_external_components_pass from esphome.const import ( CONF_EXTERNAL_COMPONENTS, @@ -12,25 +14,17 @@ from esphome.const import ( CONF_URL, TYPE_GIT, ) +from esphome.core import CORE, TimePeriodSeconds -def test_external_components_skip_update_true( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock -) -> None: - """Test that external components don't update when skip_update=True.""" - # Create a components directory structure +def _make_config(tmp_path: Path) -> dict[str, Any]: components_dir = tmp_path / "components" components_dir.mkdir() - - # Create a test component test_component_dir = components_dir / "test_component" test_component_dir.mkdir() (test_component_dir / "__init__.py").write_text("# Test component") - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - config: dict[str, Any] = { + return { CONF_EXTERNAL_COMPONENTS: [ { CONF_SOURCE: { @@ -43,92 +37,46 @@ def test_external_components_skip_update_true( ] } - # Call with skip_update=True - do_external_components_pass(config, skip_update=True) - # Verify clone_or_update was called with NEVER_REFRESH - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome import git - - assert call_args.kwargs["refresh"] == git.NEVER_REFRESH +@pytest.fixture +def reset_skip_external_update() -> None: + """Ensure CORE.skip_external_update is restored between tests.""" + yield + CORE.skip_external_update = False -def test_external_components_skip_update_false( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock +def test_external_components_skip_update_via_core_flag( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, + reset_skip_external_update: None, ) -> None: - """Test that external components update when skip_update=False.""" - # Create a components directory structure - components_dir = tmp_path / "components" - components_dir.mkdir() - - # Create a test component - test_component_dir = components_dir / "test_component" - test_component_dir.mkdir() - (test_component_dir / "__init__.py").write_text("# Test component") - - # Set up mock to return our tmp_path + """When CORE.skip_external_update is True, refresh is still passed through; + git.clone_or_update itself short-circuits the actual fetch.""" mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) + + CORE.skip_external_update = True + do_external_components_pass(config) + + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + # Refresh is passed through verbatim — the global flag is enforced inside git.clone_or_update. + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_external_components_normal_refresh( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, + reset_skip_external_update: None, +) -> None: + """When CORE.skip_external_update is False, the configured refresh value is used.""" + mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } - - # Call with skip_update=False - do_external_components_pass(config, skip_update=False) - - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) - - -def test_external_components_default_no_skip( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock -) -> None: - """Test that external components update by default when skip_update not specified.""" - # Create a components directory structure - components_dir = tmp_path / "components" - components_dir.mkdir() - - # Create a test component - test_component_dir = components_dir / "test_component" - test_component_dir.mkdir() - (test_component_dir / "__init__.py").write_text("# Test component") - - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } - - # Call without skip_update parameter do_external_components_pass(config) - # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py index fd30c2433f..91c1ffcb96 100644 --- a/tests/component_tests/packages/test_init.py +++ b/tests/component_tests/packages/test_init.py @@ -1,29 +1,19 @@ -"""Tests for the packages component skip_update functionality.""" +"""Tests for the packages skip-update behavior driven by CORE.skip_external_update.""" from pathlib import Path from typing import Any from unittest.mock import MagicMock +import pytest + from esphome.components.packages import do_packages_pass from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL +from esphome.core import CORE, TimePeriodSeconds from esphome.util import OrderedDict -def test_packages_skip_update_true( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that packages don't update when skip_update=True.""" - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - # Create the test yaml file - test_file = tmp_path / "test.yaml" - test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config: dict[str, Any] = { +def _make_config() -> dict[str, Any]: + return { CONF_PACKAGES: { "test_package": { CONF_URL: "https://github.com/test/repo", @@ -33,82 +23,56 @@ def test_packages_skip_update_true( } } - # Call with skip_update=True - do_packages_pass(config, skip_update=True) - # Verify clone_or_update was called with NEVER_REFRESH - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome import git - - assert call_args.kwargs["refresh"] == git.NEVER_REFRESH +@pytest.fixture +def reset_skip_external_update() -> None: + """Ensure CORE.skip_external_update is restored between tests.""" + yield + CORE.skip_external_update = False -def test_packages_skip_update_false( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock +def test_packages_skip_update_via_core_flag( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_load_yaml: MagicMock, + reset_skip_external_update: None, ) -> None: - """Test that packages update when skip_update=False.""" - # Set up mock to return our tmp_path + """When CORE.skip_external_update is True, refresh is still passed through; + git.clone_or_update itself short-circuits the actual fetch.""" mock_clone_or_update.return_value = (tmp_path, None) - # Create the test yaml file test_file = tmp_path / "test.yaml" test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config mock_load_yaml.return_value = OrderedDict({"sensor": []}) - config: dict[str, Any] = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } + config = _make_config() + + CORE.skip_external_update = True + do_packages_pass(config, command_line_substitutions={}) + + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + # Refresh is passed through verbatim — the global flag is enforced inside git.clone_or_update. + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_packages_normal_refresh( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_load_yaml: MagicMock, + reset_skip_external_update: None, +) -> None: + """When CORE.skip_external_update is False, the configured refresh value is used.""" + mock_clone_or_update.return_value = (tmp_path, None) + + test_file = tmp_path / "test.yaml" + test_file.write_text("sensor: []") + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config = _make_config() - # Call with skip_update=False (default) - do_packages_pass(config, command_line_substitutions={}, skip_update=False) - - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) - - -def test_packages_default_no_skip( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that packages update by default when skip_update not specified.""" - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - # Create the test yaml file - test_file = tmp_path / "test.yaml" - test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config: dict[str, Any] = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } - - # Call without skip_update parameter do_packages_pass(config, command_line_substitutions={}) - # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 745dfad487..34523fce77 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -236,6 +236,38 @@ def test_clone_or_update_with_never_refresh( assert revert is None +def test_clone_or_update_skips_when_core_skip_external_update( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """CORE.skip_external_update short-circuits the refresh for existing repos.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = None + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + (git_dir / "FETCH_HEAD").write_text("test") + + CORE.skip_external_update = True + try: + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=TimePeriodSeconds(days=1), + domain=domain, + ) + finally: + CORE.skip_external_update = False + + mock_run_git_command.assert_not_called() + assert result_dir == repo_dir + assert revert is None + + def test_clone_or_update_with_refresh_updates_old_repo( tmp_path: Path, mock_run_git_command: Mock ) -> None: diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 215ec291f9..cf6d4adbf5 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -654,7 +654,7 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: package_config = yaml_util.IncludeFile( parent, "test.yaml", None, always_returns_include ) - processor = _PackageProcessor({}, None, False) + processor = _PackageProcessor({}, None) with pytest.raises( cv.Invalid, match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded", @@ -776,7 +776,7 @@ def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> No package_config = yaml_util.IncludeFile( parent, "${undefined_var}.yaml", None, loader ) - processor = _PackageProcessor({}, None, False) + processor = _PackageProcessor({}, None) with pytest.raises(cv.Invalid, match="unresolved substitutions"): processor.resolve_package(package_config, substitutions.ContextVars(), []) From 3438ff817a076eb591640e23d8655a0943ec6032 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 04:49:04 -0500 Subject: [PATCH 0657/2214] Address review: drop redundant comments and rely on existing CORE.reset() between tests --- .../external_components/__init__.py | 2 -- esphome/components/packages/__init__.py | 6 ----- esphome/git.py | 3 --- .../external_components/test_init.py | 11 ---------- tests/component_tests/packages/test_init.py | 11 ---------- tests/unit_tests/test_external_files.py | 22 +++++++++---------- tests/unit_tests/test_git.py | 15 +++++-------- 7 files changed, 16 insertions(+), 54 deletions(-) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index b69c9d24d3..6eb577e5ad 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -41,8 +41,6 @@ async def to_code(config: dict[str, Any]) -> None: def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> Path: - # CORE.skip_external_update (set by `esphome logs`) is honored inside - # git.clone_or_update; no need to translate to NEVER_REFRESH here. repo_dir, _ = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index ae6af34dd8..47a1fd20a7 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -214,9 +214,6 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: If loading fails after cloning, attempts a revert and retry in case a prior cached checkout is stale. - - CORE.skip_external_update (set by `esphome logs`) is honored inside - git.clone_or_update; no need to translate to NEVER_REFRESH here. """ repo_dir, revert = git.clone_or_update( url=config[CONF_URL], @@ -560,9 +557,6 @@ def do_packages_pass( Returns the config with all packages loaded in-place (but not yet merged) and a consolidated ``substitutions:`` block restored at the front. - - CORE.skip_external_update (set by `esphome logs`) is honored inside - git.clone_or_update via _process_remote_package. """ if CONF_PACKAGES not in config: return config diff --git a/esphome/git.py b/esphome/git.py index a69b91bf26..4d6e14001a 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -150,9 +150,6 @@ def clone_or_update( raise else: - # Check refresh needed - # Skip refresh if NEVER_REFRESH is specified or external updates are - # globally disabled for this run (e.g. `esphome logs`). if refresh == NEVER_REFRESH or CORE.skip_external_update: _LOGGER.debug("Skipping update for %s (refresh disabled)", key) return repo_dir, None diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index 1d09552bdf..d3813ecc75 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -4,8 +4,6 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock -import pytest - from esphome.components.external_components import do_external_components_pass from esphome.const import ( CONF_EXTERNAL_COMPONENTS, @@ -38,18 +36,10 @@ def _make_config(tmp_path: Path) -> dict[str, Any]: } -@pytest.fixture -def reset_skip_external_update() -> None: - """Ensure CORE.skip_external_update is restored between tests.""" - yield - CORE.skip_external_update = False - - def test_external_components_skip_update_via_core_flag( tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock, - reset_skip_external_update: None, ) -> None: """When CORE.skip_external_update is True, refresh is still passed through; git.clone_or_update itself short-circuits the actual fetch.""" @@ -69,7 +59,6 @@ def test_external_components_normal_refresh( tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock, - reset_skip_external_update: None, ) -> None: """When CORE.skip_external_update is False, the configured refresh value is used.""" mock_clone_or_update.return_value = (tmp_path, None) diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py index 91c1ffcb96..19c7bd3669 100644 --- a/tests/component_tests/packages/test_init.py +++ b/tests/component_tests/packages/test_init.py @@ -4,8 +4,6 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock -import pytest - from esphome.components.packages import do_packages_pass from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL from esphome.core import CORE, TimePeriodSeconds @@ -24,18 +22,10 @@ def _make_config() -> dict[str, Any]: } -@pytest.fixture -def reset_skip_external_update() -> None: - """Ensure CORE.skip_external_update is restored between tests.""" - yield - CORE.skip_external_update = False - - def test_packages_skip_update_via_core_flag( tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock, - reset_skip_external_update: None, ) -> None: """When CORE.skip_external_update is True, refresh is still passed through; git.clone_or_update itself short-circuits the actual fetch.""" @@ -60,7 +50,6 @@ def test_packages_normal_refresh( tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock, - reset_skip_external_update: None, ) -> None: """When CORE.skip_external_update is False, the configured refresh value is used.""" mock_clone_or_update.return_value = (tmp_path, None) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 50c90059d5..4b0826db04 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -241,7 +241,9 @@ def test_download_content_with_network_error_no_cache_fails( @patch("esphome.external_files.requests.get") @patch("esphome.external_files.has_remote_file_changed") def test_download_content_skip_external_update_uses_cache( - mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path + mock_has_changed: MagicMock, + mock_get: MagicMock, + setup_core: Path, ) -> None: """Test download_content skips network checks when CORE.skip_external_update is set.""" test_file = setup_core / "cached.txt" @@ -249,11 +251,8 @@ def test_download_content_skip_external_update_uses_cache( test_file.write_bytes(cached_content) CORE.skip_external_update = True - try: - url = "https://example.com/file.txt" - result = external_files.download_content(url, test_file) - finally: - CORE.skip_external_update = False + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) assert result == cached_content mock_has_changed.assert_not_called() @@ -263,7 +262,9 @@ def test_download_content_skip_external_update_uses_cache( @patch("esphome.external_files.requests.get") @patch("esphome.external_files.has_remote_file_changed") def test_download_content_skip_external_update_downloads_when_missing( - mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path + mock_has_changed: MagicMock, + mock_get: MagicMock, + setup_core: Path, ) -> None: """Test download_content still downloads when file is missing, even with skip_external_update.""" test_file = setup_core / "missing.txt" @@ -276,11 +277,8 @@ def test_download_content_skip_external_update_downloads_when_missing( mock_get.return_value = mock_response CORE.skip_external_update = True - try: - url = "https://example.com/file.txt" - result = external_files.download_content(url, test_file) - finally: - CORE.skip_external_update = False + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) assert result == new_content assert test_file.read_bytes() == new_content diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 34523fce77..dd7d26cb71 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -253,15 +253,12 @@ def test_clone_or_update_skips_when_core_skip_external_update( (git_dir / "FETCH_HEAD").write_text("test") CORE.skip_external_update = True - try: - result_dir, revert = git.clone_or_update( - url=url, - ref=ref, - refresh=TimePeriodSeconds(days=1), - domain=domain, - ) - finally: - CORE.skip_external_update = False + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=TimePeriodSeconds(days=1), + domain=domain, + ) mock_run_git_command.assert_not_called() assert result_dir == repo_dir From b084fa449008b4dbf4162d2e702d280cb25a9433 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 26 Apr 2026 15:31:32 +0400 Subject: [PATCH 0658/2214] [esp32] Make ESP-IDF builds reproducible (#16008) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 4 ++++ .../esp32/config/reproducible_build.yaml | 8 ++++++++ tests/component_tests/esp32/test_esp32.py | 11 +++++++++++ 3 files changed, 23 insertions(+) create mode 100644 tests/component_tests/esp32/config/reproducible_build.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1a7ae700c7..78a1715ccf 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1729,6 +1729,10 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") if use_platformio: cg.add_platformio_option("framework", "espidf") + # Strip volatile build path/time metadata from PlatformIO-managed + # ESP-IDF builds so equivalent projects can produce reproducible + # outputs and downstream tooling can safely reuse artifacts. + add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) # Wrap std::__throw_* functions to abort immediately, eliminating ~3KB of # exception class overhead. See throw_stubs.cpp for implementation. diff --git a/tests/component_tests/esp32/config/reproducible_build.yaml b/tests/component_tests/esp32/config/reproducible_build.yaml new file mode 100644 index 0000000000..eb9721b432 --- /dev/null +++ b/tests/component_tests/esp32/config/reproducible_build.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + variant: esp32 + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index ac492e2752..c39a4aafc8 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -232,3 +232,14 @@ def test_execute_from_psram_disabled_sdkconfig( assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig assert "CONFIG_SPIRAM_RODATA" not in sdkconfig assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig + + +def test_platformio_idf_enables_reproducible_build( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test PlatformIO ESP-IDF builds enable reproducible app metadata.""" + generate_main(component_config_path("reproducible_build.yaml")) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True From 1a0642546b9313a735148fb9b59359cf10d6b602 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 06:47:51 -0500 Subject: [PATCH 0659/2214] [bk72xx] Add BK7238 boards in preparation for upstream support --- esphome/components/bk72xx/boards.py | 500 +++++++++++++++++++++++++- esphome/components/libretiny/const.py | 4 + 2 files changed, 502 insertions(+), 2 deletions(-) diff --git a/esphome/components/bk72xx/boards.py b/esphome/components/bk72xx/boards.py index 4bee69fe6d..f8bedce329 100644 --- a/esphome/components/bk72xx/boards.py +++ b/esphome/components/bk72xx/boards.py @@ -16,6 +16,7 @@ from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_BK7251, ) @@ -24,16 +25,32 @@ BK72XX_BOARDS = { "name": "WB2L_M1 Wi-Fi Module", "family": FAMILY_BK7231N, }, + "xh-wb3s": { + "name": "NiceMCU XH-WB3S", + "family": FAMILY_BK7238, + }, "cbu": { "name": "CBU Wi-Fi Module", "family": FAMILY_BK7231N, }, + "t1-u": { + "name": "T1-U Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "generic-bk7238-tuya": { + "name": "Generic - BK7238 (Tuya T1)", + "family": FAMILY_BK7238, + }, + "t1-m": { + "name": "T1-M Wi-Fi Module", + "family": FAMILY_BK7238, + }, "generic-bk7231t-qfn32-tuya": { - "name": "Generic - BK7231T (Tuya QFN32)", + "name": "Generic - BK7231T (Tuya)", "family": FAMILY_BK7231T, }, "generic-bk7231n-qfn32-tuya": { - "name": "Generic - BK7231N (Tuya QFN32)", + "name": "Generic - BK7231N (Tuya)", "family": FAMILY_BK7231N, }, "cb1s": { @@ -64,6 +81,10 @@ BK72XX_BOARDS = { "name": "Generic - BK7252", "family": FAMILY_BK7251, }, + "t1-3s": { + "name": "T1-3S Wi-Fi Module", + "family": FAMILY_BK7238, + }, "wb2l": { "name": "WB2L Wi-Fi Module", "family": FAMILY_BK7231T, @@ -80,6 +101,10 @@ BK72XX_BOARDS = { "name": "CB2S Wi-Fi Module", "family": FAMILY_BK7231N, }, + "generic-bk7238": { + "name": "Generic - BK7238", + "family": FAMILY_BK7238, + }, "wa2": { "name": "WA2 Wi-Fi Module", "family": FAMILY_BK7231Q, @@ -100,6 +125,10 @@ BK72XX_BOARDS = { "name": "WB3L Wi-Fi Module", "family": FAMILY_BK7231T, }, + "t1-2s": { + "name": "T1-2S Wi-Fi Module", + "family": FAMILY_BK7238, + }, "wb2s": { "name": "WB2S Wi-Fi Module", "family": FAMILY_BK7231T, @@ -158,6 +187,83 @@ BK72XX_BOARD_PINS = { "D12": 22, "A0": 23, }, + "xh-wb3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 7, + "D1": 23, + "D2": 14, + "D3": 26, + "D4": 24, + "D5": 6, + "D6": 9, + "D7": 0, + "D8": 1, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 16, + "D13": 20, + "D14": 21, + "D15": 22, + "D16": 15, + "D17": 17, + "A0": 28, + "A1": 26, + "A2": 24, + "A3": 1, + "A4": 10, + "A5": 20, + }, "cbu": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -230,6 +336,204 @@ BK72XX_BOARD_PINS = { "D18": 21, "A0": 23, }, + "t1-u": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 14, + "D1": 16, + "D2": 23, + "D3": 22, + "D4": 20, + "D5": 1, + "D6": 0, + "D7": 24, + "D8": 9, + "D9": 26, + "D10": 6, + "D11": 8, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 21, + "D16": 17, + "D17": 15, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + "A5": 28, + }, + "generic-bk7238-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, + "t1-m": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "generic-bk7231t-qfn32-tuya": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -781,6 +1085,75 @@ BK72XX_BOARD_PINS = { "A6": 12, "A7": 13, }, + "t1-3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 20, + "D1": 22, + "D2": 6, + "D3": 8, + "D4": 9, + "D5": 23, + "D6": 0, + "D7": 1, + "D8": 24, + "D9": 26, + "D10": 10, + "D11": 11, + "D12": 17, + "D13": 16, + "D14": 15, + "D15": 14, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + }, "wb2l": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -965,6 +1338,84 @@ BK72XX_BOARD_PINS = { "D10": 21, "A0": 23, }, + "generic-bk7238": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, "wa2": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1235,6 +1686,51 @@ BK72XX_BOARD_PINS = { "D15": 1, "A0": 23, }, + "t1-2s": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "wb2s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, diff --git a/esphome/components/libretiny/const.py b/esphome/components/libretiny/const.py index 332be0de1d..5de4a164b5 100644 --- a/esphome/components/libretiny/const.py +++ b/esphome/components/libretiny/const.py @@ -58,6 +58,7 @@ COMPONENT_RTL87XX = "rtl87xx" FAMILY_BK7231N = "BK7231N" FAMILY_BK7231Q = "BK7231Q" FAMILY_BK7231T = "BK7231T" +FAMILY_BK7238 = "BK7238" FAMILY_BK7251 = "BK7251" FAMILY_LN882H = "LN882H" FAMILY_RTL8710B = "RTL8710B" @@ -66,6 +67,7 @@ FAMILIES = [ FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_BK7251, FAMILY_LN882H, FAMILY_RTL8710B, @@ -75,6 +77,7 @@ FAMILY_FRIENDLY = { FAMILY_BK7231N: "BK7231N", FAMILY_BK7231Q: "BK7231Q", FAMILY_BK7231T: "BK7231T", + FAMILY_BK7238: "BK7238", FAMILY_BK7251: "BK7251", FAMILY_LN882H: "LN882H", FAMILY_RTL8710B: "RTL8710B", @@ -84,6 +87,7 @@ FAMILY_COMPONENT = { FAMILY_BK7231N: COMPONENT_BK72XX, FAMILY_BK7231Q: COMPONENT_BK72XX, FAMILY_BK7231T: COMPONENT_BK72XX, + FAMILY_BK7238: COMPONENT_BK72XX, FAMILY_BK7251: COMPONENT_BK72XX, FAMILY_LN882H: COMPONENT_LN882X, FAMILY_RTL8710B: COMPONENT_RTL87XX, From c8d4420408c33680ab47c4576129fc4ad3dad1d7 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Sun, 26 Apr 2026 14:19:49 +0200 Subject: [PATCH 0660/2214] [mitsubishi_cn105] add support for half-degree temperature setpoint (#15919) --- .../components/mitsubishi_cn105/mitsubishi_cn105.cpp | 6 +++--- .../climate/mitsubishi_cn105_tests.cpp | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 1a35495618..f04a5906c1 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -352,7 +352,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { ESP_LOGD(TAG, "Setting temperature out-of-range: %.1f", target_temperature); return; } - this->status_.target_temperature = std::round(target_temperature); + this->status_.target_temperature = target_temperature; this->pending_updates_.set(UpdateFlag::TEMPERATURE); } @@ -387,9 +387,9 @@ void MitsubishiCN105::apply_settings_() { if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) { payload[1] |= 0x04; if (this->use_temperature_encoding_b_) { - payload[14] = static_cast(this->status_.target_temperature * 2.0f + 128.0f); + payload[14] = static_cast(std::round(this->status_.target_temperature * 2.0f) + 128); } else { - payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - this->status_.target_temperature); + payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature)); } } diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 7846a31193..86faaeac78 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -341,6 +341,17 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB4, 0x00, 0xC5)); } +TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { + auto ctx = TestContext{}; + + ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.set_target_temperature(26.5f); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB5, 0x00, 0xC4)); +} + TEST(MitsubishiCN105Tests, ApplyModeCool) { auto ctx = TestContext{}; From df987a7ffb4580dd90f07a99e1544b447f9104b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:22:34 -0500 Subject: [PATCH 0661/2214] [ci-custom] Suggest uint32_to_str/int8_to_str for integer formatting (#15970) --- script/ci-custom.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/script/ci-custom.py b/script/ci-custom.py index 02ec08bc31..4d71df74cf 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -837,7 +837,16 @@ def lint_no_std_to_string(fname, match): f"{highlight('std::to_string()')} (including unqualified {highlight('to_string()')}) " f"allocates heap memory. On long-running embedded devices, repeated heap allocations " f"fragment memory over time.\n" - f"Please use {highlight('snprintf()')} with a stack buffer instead.\n" + f"\n" + f"For plain integer formatting, prefer the dedicated helpers in helpers.h over " + f"{highlight('snprintf()')} — they avoid pulling in printf formatting code and are " + f"smaller and faster:\n" + f" int8_t: {highlight('int8_to_str(buf, val)')} (buf >= 5 bytes)\n" + f" uint8_t/uint16_t/uint32_t: {highlight('uint32_to_str(buf, val)')} (buf = UINT32_MAX_STR_SIZE; smaller types auto-widen)\n" + f"Example: {highlight('char buf[UINT32_MAX_STR_SIZE]; uint32_to_str(buf, value);')}\n" + f"For sensor values, use {highlight('value_accuracy_to_buf()')} from helpers.h.\n" + f"\n" + f"Otherwise use {highlight('snprintf()')} with a stack buffer.\n" f"\n" f"Buffer sizes and format specifiers (sizes include sign and null terminator):\n" f" uint8_t: 4 chars - %u (or PRIu8)\n" @@ -851,7 +860,6 @@ def lint_no_std_to_string(fname, match): f" float/double: 24 chars - %.8g (15 digits + sign + decimal + e+XXX)\n" f" 317 chars - %f (for DBL_MAX: 309 int digits + decimal + 6 frac + sign)\n" f"\n" - f"For sensor values, use value_accuracy_to_buf() from helpers.h.\n" f'Example: char buf[11]; snprintf(buf, sizeof(buf), "%" PRIu32, value);\n' f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" ) From 4c0dfb0e0d302373786ccbffbd07bb43b673fe3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:22:50 -0500 Subject: [PATCH 0662/2214] [core] Raise ESP32 WDT feed interval to 1/5 of configured timeout (#15984) --- esphome/core/application.h | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index e9b386038e..bc09f7d38c 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -17,6 +17,10 @@ #include "esphome/core/string_ref.h" #include "esphome/core/version.h" +#ifdef USE_ESP32 +#include // for CONFIG_ESP_TASK_WDT_TIMEOUT_S (drives WDT_FEED_INTERVAL_MS) +#endif + #ifdef USE_DEVICES #include "esphome/core/device.h" #endif @@ -216,16 +220,30 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT default (5 s): ~16x - /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change - /// must keep comfortable margin here - /// - ESP8266 HW WDT (~6 s): ~20x - /// - BK72xx HW WDT (10 s): ~5x <-- platform override below + /// - ESP32 task WDT (user-configurable): ~5x <-- auto-scaled below + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below #ifdef USE_BK72XX // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: // https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; +#elif defined(USE_ESP32) + // Auto-scale to 1/5 of the configured ESP32 task WDT timeout so the safety + // margin stays constant when the user raises esp32.watchdog_timeout (default + // 5 s → 1000 ms feed; 10 s → 2000 ms; 60 s → 12000 ms). The esp32 component + // writes CONFIG_ESP_TASK_WDT_TIMEOUT_S into sdkconfig (range is validated + // to ≥ 5 s in esp32/__init__.py), giving us the value at compile time. + // esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so + // each call costs tens of microseconds; longer intervals materially reduce + // the main-loop's wdt bucket. Component loops and scheduler items still + // feed after every op, so any op exceeding this threshold triggers a real + // feed naturally regardless of the rate-limit. + static_assert(CONFIG_ESP_TASK_WDT_TIMEOUT_S >= 5, + "CONFIG_ESP_TASK_WDT_TIMEOUT_S must be at least 5s for a safe WDT feed interval"); + static constexpr uint32_t WDT_FEED_INTERVAL_MS = (CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000U) / 5U; #else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; #endif From 180105bb4b794fd9767714d4426a8a6c4402ce6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:08 -0500 Subject: [PATCH 0663/2214] =?UTF-8?q?[bluetooth=5Fproxy]=20Partial=20rever?= =?UTF-8?q?t=20of=20loop()=20=E2=86=92=20set=5Finterval=20migration=20(#15?= =?UTF-8?q?992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bluetooth_proxy/bluetooth_proxy.cpp | 32 +++++++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 4 +++ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c69163b1f7..45f848a286 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -30,19 +30,6 @@ void BluetoothProxy::setup() { this->configured_scan_active_ = this->parent_->get_scan_active(); this->parent_->add_scanner_state_listener(this); - - this->set_interval(100, [this]() { - 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(); - } - } - }); } void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { @@ -133,6 +120,25 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connection_count_); } +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(); + } + } +} + esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 6680ab0e84..10449f21f1 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -65,6 +65,7 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; void dump_config() override; void setup() override; + void loop() override; esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { @@ -176,6 +177,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, // 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 502c0104650e2f2d165896528069a635d22f95df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:24 -0500 Subject: [PATCH 0664/2214] [bh1750] Downgrade per-reading Illuminance log to verbose (#16005) --- esphome/components/bh1750/bh1750.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp index 045fb7cf45..ab952895a8 100644 --- a/esphome/components/bh1750/bh1750.cpp +++ b/esphome/components/bh1750/bh1750.cpp @@ -154,7 +154,7 @@ void BH1750Sensor::loop() { break; } - ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); + ESP_LOGV(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); this->status_clear_warning(); this->publish_state(lx); this->state_ = IDLE; From 04d067196d05737ea66bf4b121fbfc02f2c12a18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:41 -0500 Subject: [PATCH 0665/2214] [rotary_encoder][at581x] Fix templatable int field types (#16015) --- esphome/components/at581x/__init__.py | 8 ++++---- esphome/components/rotary_encoder/sensor.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 94b68db4b3..5031b72cce 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_sensing_distance(template_)) if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, cg.int32) + template_ = await cg.templatable(selfcheck, args, cg.int_) cg.add(var.set_poweron_selfcheck_time(template_)) if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, cg.int32) + template_ = await cg.templatable(protect, args, cg.int_) cg.add(var.set_protect_time(template_)) if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, cg.int32) + template_ = await cg.templatable(trig_base, args, cg.int_) cg.add(var.set_trigger_base(template_)) if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, cg.int32) + template_ = await cg.templatable(trig_keep, args, cg.int_) cg.add(var.set_trigger_keep(template_)) if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 21239863e4..0e5a03523d 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -129,6 +129,6 @@ async def to_code(config): async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.int32) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) cg.add(var.set_value(template_)) return var From 8950afc3c4c654eb77e45015f46883def1b37178 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:53 -0500 Subject: [PATCH 0666/2214] [bluetooth_proxy] Drop redundant remote_bda_ write in connect handler (#16000) --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 45f848a286..c3461f9c51 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -207,7 +207,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - uint64_to_bd_addr(msg.address, connection->remote_bda_); connection->set_remote_addr_type(static_cast(msg.address_type)); connection->set_state(espbt::ClientState::DISCOVERED); this->send_connections_free(); From 8dbdcfc1284864cbc2321530794938fda908a93a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:24:07 -0500 Subject: [PATCH 0667/2214] [bk72xx] Prepare for BK7238 support (#16018) --- esphome/components/bk72xx/boards.py | 500 +++++++++++++++++++++++++- esphome/components/libretiny/const.py | 4 + 2 files changed, 502 insertions(+), 2 deletions(-) diff --git a/esphome/components/bk72xx/boards.py b/esphome/components/bk72xx/boards.py index 4bee69fe6d..f8bedce329 100644 --- a/esphome/components/bk72xx/boards.py +++ b/esphome/components/bk72xx/boards.py @@ -16,6 +16,7 @@ from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_BK7251, ) @@ -24,16 +25,32 @@ BK72XX_BOARDS = { "name": "WB2L_M1 Wi-Fi Module", "family": FAMILY_BK7231N, }, + "xh-wb3s": { + "name": "NiceMCU XH-WB3S", + "family": FAMILY_BK7238, + }, "cbu": { "name": "CBU Wi-Fi Module", "family": FAMILY_BK7231N, }, + "t1-u": { + "name": "T1-U Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "generic-bk7238-tuya": { + "name": "Generic - BK7238 (Tuya T1)", + "family": FAMILY_BK7238, + }, + "t1-m": { + "name": "T1-M Wi-Fi Module", + "family": FAMILY_BK7238, + }, "generic-bk7231t-qfn32-tuya": { - "name": "Generic - BK7231T (Tuya QFN32)", + "name": "Generic - BK7231T (Tuya)", "family": FAMILY_BK7231T, }, "generic-bk7231n-qfn32-tuya": { - "name": "Generic - BK7231N (Tuya QFN32)", + "name": "Generic - BK7231N (Tuya)", "family": FAMILY_BK7231N, }, "cb1s": { @@ -64,6 +81,10 @@ BK72XX_BOARDS = { "name": "Generic - BK7252", "family": FAMILY_BK7251, }, + "t1-3s": { + "name": "T1-3S Wi-Fi Module", + "family": FAMILY_BK7238, + }, "wb2l": { "name": "WB2L Wi-Fi Module", "family": FAMILY_BK7231T, @@ -80,6 +101,10 @@ BK72XX_BOARDS = { "name": "CB2S Wi-Fi Module", "family": FAMILY_BK7231N, }, + "generic-bk7238": { + "name": "Generic - BK7238", + "family": FAMILY_BK7238, + }, "wa2": { "name": "WA2 Wi-Fi Module", "family": FAMILY_BK7231Q, @@ -100,6 +125,10 @@ BK72XX_BOARDS = { "name": "WB3L Wi-Fi Module", "family": FAMILY_BK7231T, }, + "t1-2s": { + "name": "T1-2S Wi-Fi Module", + "family": FAMILY_BK7238, + }, "wb2s": { "name": "WB2S Wi-Fi Module", "family": FAMILY_BK7231T, @@ -158,6 +187,83 @@ BK72XX_BOARD_PINS = { "D12": 22, "A0": 23, }, + "xh-wb3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 7, + "D1": 23, + "D2": 14, + "D3": 26, + "D4": 24, + "D5": 6, + "D6": 9, + "D7": 0, + "D8": 1, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 16, + "D13": 20, + "D14": 21, + "D15": 22, + "D16": 15, + "D17": 17, + "A0": 28, + "A1": 26, + "A2": 24, + "A3": 1, + "A4": 10, + "A5": 20, + }, "cbu": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -230,6 +336,204 @@ BK72XX_BOARD_PINS = { "D18": 21, "A0": 23, }, + "t1-u": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 14, + "D1": 16, + "D2": 23, + "D3": 22, + "D4": 20, + "D5": 1, + "D6": 0, + "D7": 24, + "D8": 9, + "D9": 26, + "D10": 6, + "D11": 8, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 21, + "D16": 17, + "D17": 15, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + "A5": 28, + }, + "generic-bk7238-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, + "t1-m": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "generic-bk7231t-qfn32-tuya": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -781,6 +1085,75 @@ BK72XX_BOARD_PINS = { "A6": 12, "A7": 13, }, + "t1-3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 20, + "D1": 22, + "D2": 6, + "D3": 8, + "D4": 9, + "D5": 23, + "D6": 0, + "D7": 1, + "D8": 24, + "D9": 26, + "D10": 10, + "D11": 11, + "D12": 17, + "D13": 16, + "D14": 15, + "D15": 14, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + }, "wb2l": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -965,6 +1338,84 @@ BK72XX_BOARD_PINS = { "D10": 21, "A0": 23, }, + "generic-bk7238": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, "wa2": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1235,6 +1686,51 @@ BK72XX_BOARD_PINS = { "D15": 1, "A0": 23, }, + "t1-2s": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "wb2s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, diff --git a/esphome/components/libretiny/const.py b/esphome/components/libretiny/const.py index 332be0de1d..5de4a164b5 100644 --- a/esphome/components/libretiny/const.py +++ b/esphome/components/libretiny/const.py @@ -58,6 +58,7 @@ COMPONENT_RTL87XX = "rtl87xx" FAMILY_BK7231N = "BK7231N" FAMILY_BK7231Q = "BK7231Q" FAMILY_BK7231T = "BK7231T" +FAMILY_BK7238 = "BK7238" FAMILY_BK7251 = "BK7251" FAMILY_LN882H = "LN882H" FAMILY_RTL8710B = "RTL8710B" @@ -66,6 +67,7 @@ FAMILIES = [ FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_BK7251, FAMILY_LN882H, FAMILY_RTL8710B, @@ -75,6 +77,7 @@ FAMILY_FRIENDLY = { FAMILY_BK7231N: "BK7231N", FAMILY_BK7231Q: "BK7231Q", FAMILY_BK7231T: "BK7231T", + FAMILY_BK7238: "BK7238", FAMILY_BK7251: "BK7251", FAMILY_LN882H: "LN882H", FAMILY_RTL8710B: "RTL8710B", @@ -84,6 +87,7 @@ FAMILY_COMPONENT = { FAMILY_BK7231N: COMPONENT_BK72XX, FAMILY_BK7231Q: COMPONENT_BK72XX, FAMILY_BK7231T: COMPONENT_BK72XX, + FAMILY_BK7238: COMPONENT_BK72XX, FAMILY_BK7251: COMPONENT_BK72XX, FAMILY_LN882H: COMPONENT_LN882X, FAMILY_RTL8710B: COMPONENT_RTL87XX, From 0f25d91e68578878283c914c8cd9b6b9aa2b9978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:24:33 -0500 Subject: [PATCH 0668/2214] [core] Unify `skip_external_update` and honor it in external_files for faster `esphome logs` (#16016) --- .../external_components/__init__.py | 19 ++- esphome/components/packages/__init__.py | 18 +-- esphome/config.py | 9 +- esphome/core/__init__.py | 4 + esphome/external_files.py | 5 +- esphome/git.py | 4 +- .../external_components/test_init.py | 121 +++++------------- tests/component_tests/packages/test_init.py | 115 +++++------------ tests/unit_tests/test_external_files.py | 46 +++++++ tests/unit_tests/test_git.py | 29 +++++ tests/unit_tests/test_substitutions.py | 4 +- 11 files changed, 170 insertions(+), 204 deletions(-) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index ceb402c5b7..6eb577e5ad 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +from typing import Any from esphome import git, loader import esphome.config_validation as cv @@ -17,7 +18,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, ) -from esphome.core import CORE +from esphome.core import CORE, TimePeriodSeconds _LOGGER = logging.getLogger(__name__) @@ -35,17 +36,15 @@ CONFIG_SCHEMA = cv.ensure_list( ) -async def to_code(config): +async def to_code(config: dict[str, Any]) -> None: pass -def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str: - # When skip_update is True, use NEVER_REFRESH to prevent updates - actual_refresh = git.NEVER_REFRESH if skip_update else refresh +def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> Path: repo_dir, _ = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=actual_refresh, + refresh=refresh, domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -72,12 +71,12 @@ def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str return components_dir -def _process_single_config(config: dict, skip_update: bool = False): +def _process_single_config(config: dict[str, Any]) -> None: conf = config[CONF_SOURCE] if conf[CONF_TYPE] == TYPE_GIT: with cv.prepend_path([CONF_SOURCE]): components_dir = _process_git_config( - config[CONF_SOURCE], config[CONF_REFRESH], skip_update + config[CONF_SOURCE], config[CONF_REFRESH] ) elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) @@ -107,7 +106,7 @@ def _process_single_config(config: dict, skip_update: bool = False): loader.install_meta_finder(components_dir, allowed_components=allowed_components) -def do_external_components_pass(config: dict, skip_update: bool = False) -> None: +def do_external_components_pass(config: dict[str, Any]) -> None: conf = config.get(DOMAIN) if conf is None: return @@ -115,4 +114,4 @@ def do_external_components_pass(config: dict, skip_update: bool = False) -> None conf = CONFIG_SCHEMA(conf) for i, c in enumerate(conf): with cv.prepend_path(i): - _process_single_config(c, skip_update) + _process_single_config(c) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 1b9e03d88f..47a1fd20a7 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -205,7 +205,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either: ) -def _process_remote_package(config: dict, skip_update: bool = False) -> dict: +def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: """Clone/update a git repo and load the YAML files listed in the package definition. Returns ``{"packages": {: , ...}}`` so the caller @@ -215,11 +215,10 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict: If loading fails after cloning, attempts a revert and retry in case a prior cached checkout is stale. """ - actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH] repo_dir, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=actual_refresh, + refresh=config[CONF_REFRESH], domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -456,11 +455,9 @@ class _PackageProcessor: self, substitutions: UserDict, command_line_substitutions: dict[str, Any] | None, - skip_update: bool, ) -> None: self.substitutions = substitutions self.parent_context = UserDict(command_line_substitutions or {}) - self.skip_update = skip_update def resolve_package( self, @@ -508,7 +505,7 @@ class _PackageProcessor: ) if is_remote_package(package_config): - package_config = _process_remote_package(package_config, self.skip_update) + package_config = _process_remote_package(package_config) return package_config def collect_substitutions(self, package_config: dict) -> None: @@ -552,11 +549,10 @@ class _PackageProcessor: def do_packages_pass( - config: dict, + config: dict[str, Any], *, command_line_substitutions: dict[str, Any] | None = None, - skip_update: bool = False, -) -> dict: +) -> dict[str, Any]: """Load, validate, and flatten all packages in the config. Returns the config with all packages loaded in-place (but not yet merged) @@ -571,9 +567,7 @@ def do_packages_pass( config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions ) ) - processor = _PackageProcessor( - substitutions, command_line_substitutions, skip_update - ) + processor = _PackageProcessor(substitutions, command_line_substitutions) _update_substitutions_context(processor.parent_context, substitutions) context_vars = push_context( diff --git a/esphome/config.py b/esphome/config.py index 641b6ec1b4..6eb67af58b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -997,6 +997,8 @@ def validate_config( ) -> Config: result = Config() + CORE.skip_external_update = skip_external_update + loader.clear_component_meta_finders() loader.install_custom_components_meta_finder() @@ -1009,7 +1011,6 @@ def validate_config( config = do_packages_pass( config, command_line_substitutions=command_line_substitutions, - skip_update=skip_external_update, ) except vol.Invalid as err: result.update(config) @@ -1050,7 +1051,7 @@ def validate_config( result.add_output_path([CONF_EXTERNAL_COMPONENTS], CONF_EXTERNAL_COMPONENTS) try: - do_external_components_pass(config, skip_update=skip_external_update) + do_external_components_pass(config) except vol.Invalid as err: result.update(config) result.add_error(err) @@ -1341,7 +1342,9 @@ def strip_default_ids(config): return config -def read_config(command_line_substitutions, skip_external_update=False): +def read_config( + command_line_substitutions: dict[str, Any], skip_external_update: bool = False +) -> Config | None: _LOGGER.info("Reading configuration %s...", CORE.config_path) try: res = load_config(command_line_substitutions, skip_external_update) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f86..4fecebcd8d 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -615,6 +615,9 @@ class EsphomeCore: self.address_cache: AddressCache | None = None # Cached config hash (computed lazily) self._config_hash: int | None = None + # When True, skip network freshness checks for cached external files + # (e.g. for `esphome logs`, where remote downloads aren't needed) + self.skip_external_update: bool = False def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -644,6 +647,7 @@ class EsphomeCore: self.current_component = None self.address_cache = None self._config_hash = None + self.skip_external_update = False PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/external_files.py b/esphome/external_files.py index 55711e1b79..b6f6149ebb 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -81,7 +81,10 @@ def compute_local_file_dir(domain: str) -> Path: return base_directory -def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes: +def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: + if CORE.skip_external_update and path.exists(): + _LOGGER.debug("Skipping update for %s (refresh disabled)", url) + return path.read_bytes() if not has_remote_file_changed(url, path): _LOGGER.debug("Remote file has not changed %s", url) return path.read_bytes() diff --git a/esphome/git.py b/esphome/git.py index 096ff483a7..4d6e14001a 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -150,9 +150,7 @@ def clone_or_update( raise else: - # Check refresh needed - # Skip refresh if NEVER_REFRESH is specified - if refresh == NEVER_REFRESH: + if refresh == NEVER_REFRESH or CORE.skip_external_update: _LOGGER.debug("Skipping update for %s (refresh disabled)", key) return repo_dir, None diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index 905c0afa8b..d3813ecc75 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -1,4 +1,4 @@ -"""Tests for the external_components skip_update functionality.""" +"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update.""" from pathlib import Path from typing import Any @@ -12,25 +12,17 @@ from esphome.const import ( CONF_URL, TYPE_GIT, ) +from esphome.core import CORE, TimePeriodSeconds -def test_external_components_skip_update_true( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock -) -> None: - """Test that external components don't update when skip_update=True.""" - # Create a components directory structure +def _make_config(tmp_path: Path) -> dict[str, Any]: components_dir = tmp_path / "components" components_dir.mkdir() - - # Create a test component test_component_dir = components_dir / "test_component" test_component_dir.mkdir() (test_component_dir / "__init__.py").write_text("# Test component") - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - config: dict[str, Any] = { + return { CONF_EXTERNAL_COMPONENTS: [ { CONF_SOURCE: { @@ -43,92 +35,37 @@ def test_external_components_skip_update_true( ] } - # Call with skip_update=True - do_external_components_pass(config, skip_update=True) - # Verify clone_or_update was called with NEVER_REFRESH - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome import git - - assert call_args.kwargs["refresh"] == git.NEVER_REFRESH - - -def test_external_components_skip_update_false( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock +def test_external_components_skip_update_via_core_flag( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, ) -> None: - """Test that external components update when skip_update=False.""" - # Create a components directory structure - components_dir = tmp_path / "components" - components_dir.mkdir() - - # Create a test component - test_component_dir = components_dir / "test_component" - test_component_dir.mkdir() - (test_component_dir / "__init__.py").write_text("# Test component") - - # Set up mock to return our tmp_path + """When CORE.skip_external_update is True, refresh is still passed through; + git.clone_or_update itself short-circuits the actual fetch.""" mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) + + CORE.skip_external_update = True + do_external_components_pass(config) + + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + # Refresh is passed through verbatim — the global flag is enforced inside git.clone_or_update. + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_external_components_normal_refresh( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, +) -> None: + """When CORE.skip_external_update is False, the configured refresh value is used.""" + mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } - - # Call with skip_update=False - do_external_components_pass(config, skip_update=False) - - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) - - -def test_external_components_default_no_skip( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock -) -> None: - """Test that external components update by default when skip_update not specified.""" - # Create a components directory structure - components_dir = tmp_path / "components" - components_dir.mkdir() - - # Create a test component - test_component_dir = components_dir / "test_component" - test_component_dir.mkdir() - (test_component_dir / "__init__.py").write_text("# Test component") - - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } - - # Call without skip_update parameter do_external_components_pass(config) - # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py index fd30c2433f..19c7bd3669 100644 --- a/tests/component_tests/packages/test_init.py +++ b/tests/component_tests/packages/test_init.py @@ -1,4 +1,4 @@ -"""Tests for the packages component skip_update functionality.""" +"""Tests for the packages skip-update behavior driven by CORE.skip_external_update.""" from pathlib import Path from typing import Any @@ -6,24 +6,12 @@ from unittest.mock import MagicMock from esphome.components.packages import do_packages_pass from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL +from esphome.core import CORE, TimePeriodSeconds from esphome.util import OrderedDict -def test_packages_skip_update_true( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that packages don't update when skip_update=True.""" - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - # Create the test yaml file - test_file = tmp_path / "test.yaml" - test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config: dict[str, Any] = { +def _make_config() -> dict[str, Any]: + return { CONF_PACKAGES: { "test_package": { CONF_URL: "https://github.com/test/repo", @@ -33,82 +21,47 @@ def test_packages_skip_update_true( } } - # Call with skip_update=True - do_packages_pass(config, skip_update=True) - # Verify clone_or_update was called with NEVER_REFRESH - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome import git - - assert call_args.kwargs["refresh"] == git.NEVER_REFRESH - - -def test_packages_skip_update_false( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock +def test_packages_skip_update_via_core_flag( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_load_yaml: MagicMock, ) -> None: - """Test that packages update when skip_update=False.""" - # Set up mock to return our tmp_path + """When CORE.skip_external_update is True, refresh is still passed through; + git.clone_or_update itself short-circuits the actual fetch.""" mock_clone_or_update.return_value = (tmp_path, None) - # Create the test yaml file test_file = tmp_path / "test.yaml" test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config mock_load_yaml.return_value = OrderedDict({"sensor": []}) - config: dict[str, Any] = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } + config = _make_config() + + CORE.skip_external_update = True + do_packages_pass(config, command_line_substitutions={}) + + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + # Refresh is passed through verbatim — the global flag is enforced inside git.clone_or_update. + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_packages_normal_refresh( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_load_yaml: MagicMock, +) -> None: + """When CORE.skip_external_update is False, the configured refresh value is used.""" + mock_clone_or_update.return_value = (tmp_path, None) + + test_file = tmp_path / "test.yaml" + test_file.write_text("sensor: []") + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config = _make_config() - # Call with skip_update=False (default) - do_packages_pass(config, command_line_substitutions={}, skip_update=False) - - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) - - -def test_packages_default_no_skip( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that packages update by default when skip_update not specified.""" - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - # Create the test yaml file - test_file = tmp_path / "test.yaml" - test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config: dict[str, Any] = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } - - # Call without skip_update parameter do_packages_pass(config, command_line_substitutions={}) - # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index a319fae83d..4b0826db04 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -236,3 +236,49 @@ def test_download_content_with_network_error_no_cache_fails( with pytest.raises(Invalid, match="Could not download from.*Network error"): external_files.download_content(url, test_file) + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_skip_external_update_uses_cache( + mock_has_changed: MagicMock, + mock_get: MagicMock, + setup_core: Path, +) -> None: + """Test download_content skips network checks when CORE.skip_external_update is set.""" + test_file = setup_core / "cached.txt" + cached_content = b"cached content" + test_file.write_bytes(cached_content) + + CORE.skip_external_update = True + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) + + assert result == cached_content + mock_has_changed.assert_not_called() + mock_get.assert_not_called() + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_skip_external_update_downloads_when_missing( + mock_has_changed: MagicMock, + mock_get: MagicMock, + setup_core: Path, +) -> None: + """Test download_content still downloads when file is missing, even with skip_external_update.""" + test_file = setup_core / "missing.txt" + new_content = b"fresh content" + + mock_has_changed.return_value = True + mock_response = MagicMock() + mock_response.content = new_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + CORE.skip_external_update = True + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) + + assert result == new_content + assert test_file.read_bytes() == new_content diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 745dfad487..dd7d26cb71 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -236,6 +236,35 @@ def test_clone_or_update_with_never_refresh( assert revert is None +def test_clone_or_update_skips_when_core_skip_external_update( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """CORE.skip_external_update short-circuits the refresh for existing repos.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = None + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + (git_dir / "FETCH_HEAD").write_text("test") + + CORE.skip_external_update = True + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=TimePeriodSeconds(days=1), + domain=domain, + ) + + mock_run_git_command.assert_not_called() + assert result_dir == repo_dir + assert revert is None + + def test_clone_or_update_with_refresh_updates_old_repo( tmp_path: Path, mock_run_git_command: Mock ) -> None: diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 215ec291f9..cf6d4adbf5 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -654,7 +654,7 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: package_config = yaml_util.IncludeFile( parent, "test.yaml", None, always_returns_include ) - processor = _PackageProcessor({}, None, False) + processor = _PackageProcessor({}, None) with pytest.raises( cv.Invalid, match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded", @@ -776,7 +776,7 @@ def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> No package_config = yaml_util.IncludeFile( parent, "${undefined_var}.yaml", None, loader ) - processor = _PackageProcessor({}, None, False) + processor = _PackageProcessor({}, None) with pytest.raises(cv.Invalid, match="unresolved substitutions"): processor.resolve_package(package_config, substitutions.ContextVars(), []) From e87e78c5446f3bf256e2d9b5b53c0b3d31d6eebf Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Sun, 26 Apr 2026 05:58:14 -0700 Subject: [PATCH 0669/2214] [api] Expose TemperatureUnit in water heater and climate api (#15815) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 9 +++++++++ esphome/components/api/api_pb2.cpp | 4 ++++ esphome/components/api/api_pb2.h | 11 +++++++++-- esphome/components/api/api_pb2_dump.cpp | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c3e4c38633..1c33d92bea 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1025,6 +1025,13 @@ message CameraImageRequest { bool stream = 2; } +// ==================== TEMPERATURE UNIT ==================== +enum TemperatureUnit { + TEMPERATURE_UNIT_CELSIUS = 0; + TEMPERATURE_UNIT_FAHRENHEIT = 1; + TEMPERATURE_UNIT_KELVIN = 2; +} + // ==================== CLIMATE ==================== enum ClimateMode { CLIMATE_MODE_OFF = 0; @@ -1110,6 +1117,7 @@ message ListEntitiesClimateResponse { float visual_max_humidity = 25; uint32 device_id = 26 [(field_ifdef) = "USE_DEVICES"]; uint32 feature_flags = 27; + TemperatureUnit temperature_unit = 28; } message ClimateStateResponse { option (id) = 47; @@ -1203,6 +1211,7 @@ message ListEntitiesWaterHeaterResponse { repeated WaterHeaterMode supported_modes = 11 [(container_pointer_no_template) = "water_heater::WaterHeaterModeMask"]; // Bitmask of WaterHeaterFeature flags uint32 supported_features = 12; + TemperatureUnit temperature_unit = 13; } message WaterHeaterStateResponse { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 3d12453939..f6ceee2296 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1439,6 +1439,7 @@ uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id); #endif ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(this->temperature_unit)); return pos; } uint32_t ListEntitiesClimateResponse::calculate_size() const { @@ -1488,6 +1489,7 @@ uint32_t ListEntitiesClimateResponse::calculate_size() const { size += ProtoSize::calc_uint32(2, this->device_id); #endif size += ProtoSize::calc_uint32(2, this->feature_flags); + size += this->temperature_unit ? 3 : 0; return size; } uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1645,6 +1647,7 @@ uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it), true); } ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->temperature_unit)); return pos; } uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { @@ -1667,6 +1670,7 @@ uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { size += this->supported_modes->size() * 2; } size += ProtoSize::calc_uint32(1, this->supported_features); + size += this->temperature_unit ? 2 : 0; return size; } uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5aa592e4fa..a8e01c017f 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -92,6 +92,11 @@ enum SupportsResponseType : uint32_t { SUPPORTS_RESPONSE_STATUS = 100, }; #endif +enum TemperatureUnit : uint32_t { + TEMPERATURE_UNIT_CELSIUS = 0, + TEMPERATURE_UNIT_FAHRENHEIT = 1, + TEMPERATURE_UNIT_KELVIN = 2, +}; #ifdef USE_CLIMATE enum ClimateMode : uint32_t { CLIMATE_MODE_OFF = 0, @@ -1372,7 +1377,7 @@ class CameraImageRequest final : public ProtoDecodableMessage { class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 46; - static constexpr uint8_t ESTIMATED_SIZE = 150; + static constexpr uint8_t ESTIMATED_SIZE = 153; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_climate_response"); } #endif @@ -1394,6 +1399,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; uint32_t feature_flags{0}; + enums::TemperatureUnit temperature_unit{}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1471,7 +1477,7 @@ class ClimateCommandRequest final : public CommandProtoMessage { class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 132; - static constexpr uint8_t ESTIMATED_SIZE = 63; + static constexpr uint8_t ESTIMATED_SIZE = 65; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_water_heater_response"); } #endif @@ -1480,6 +1486,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { float target_temperature_step{0.0f}; const water_heater::WaterHeaterModeMask *supported_modes{}; uint32_t supported_features{0}; + enums::TemperatureUnit temperature_unit{}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index bdcb6d4146..541f5d4d11 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -297,6 +297,18 @@ template<> const char *proto_enum_to_string(enums:: } } #endif +template<> const char *proto_enum_to_string(enums::TemperatureUnit value) { + switch (value) { + case enums::TEMPERATURE_UNIT_CELSIUS: + return ESPHOME_PSTR("TEMPERATURE_UNIT_CELSIUS"); + case enums::TEMPERATURE_UNIT_FAHRENHEIT: + return ESPHOME_PSTR("TEMPERATURE_UNIT_FAHRENHEIT"); + case enums::TEMPERATURE_UNIT_KELVIN: + return ESPHOME_PSTR("TEMPERATURE_UNIT_KELVIN"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #ifdef USE_CLIMATE template<> const char *proto_enum_to_string(enums::ClimateMode value) { switch (value) { @@ -1539,6 +1551,7 @@ const char *ListEntitiesClimateResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast(this->temperature_unit)); return out.c_str(); } const char *ClimateStateResponse::dump_to(DumpBuffer &out) const { @@ -1612,6 +1625,7 @@ const char *ListEntitiesWaterHeaterResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("supported_modes"), static_cast(it), 4); } dump_field(out, ESPHOME_PSTR("supported_features"), this->supported_features); + dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast(this->temperature_unit)); return out.c_str(); } const char *WaterHeaterStateResponse::dump_to(DumpBuffer &out) const { From ab233e6d836db77518539b60293de3045401ddd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 09:16:16 -0500 Subject: [PATCH 0670/2214] [improv_serial] Reduce per-loop overhead - Cache UART selection at setup time so each loop iteration no longer dereferences global_logger and pays for a non-inlined Logger::get_uart() call before the read switch. - Use App.get_loop_component_start_time() once per loop instead of two millis() calls (especially relevant on ESP8266 where millis() involves interrupt-locked 64-bit timer access). - Move read_byte_() to the header as ESPHOME_ALWAYS_INLINE so the call/ret pair and optional staging are elided at the call sites in loop(). --- .../improv_serial/improv_serial_component.cpp | 57 ++----------------- .../improv_serial/improv_serial_component.h | 50 +++++++++++++++- 2 files changed, 54 insertions(+), 53 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 003328d535..9b8cf75f3a 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -17,6 +17,7 @@ void ImprovSerialComponent::setup() { global_improv_serial_component = this; #ifdef USE_ESP32 this->uart_num_ = logger::global_logger->get_uart_num(); + this->uart_selection_ = logger::global_logger->get_uart(); #elif defined(USE_ARDUINO) this->hw_serial_ = logger::global_logger->get_hw_serial(); #endif @@ -29,7 +30,8 @@ void ImprovSerialComponent::setup() { } void ImprovSerialComponent::loop() { - if (this->last_read_byte_ && (millis() - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->last_read_byte_ && (now - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) { this->last_read_byte_ = 0; this->rx_buffer_.clear(); ESP_LOGV(TAG, "Timeout"); @@ -38,7 +40,7 @@ void ImprovSerialComponent::loop() { auto byte = this->read_byte_(); while (byte.has_value()) { if (this->parse_improv_serial_byte_(byte.value())) { - this->last_read_byte_ = millis(); + this->last_read_byte_ = now; } else { this->last_read_byte_ = 0; this->rx_buffer_.clear(); @@ -62,55 +64,6 @@ void ImprovSerialComponent::loop() { void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:"); } -optional ImprovSerialComponent::read_byte_() { - optional byte; - uint8_t data = 0; -#ifdef USE_ESP32 - switch (logger::global_logger->get_uart()) { - case logger::UART_SELECTION_UART0: - case logger::UART_SELECTION_UART1: -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) - case logger::UART_SELECTION_UART2: -#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32C6 && !USE_ESP32_VARIANT_ESP32C61 && - // !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 - if (this->uart_num_ >= 0) { - size_t available; - uart_get_buffered_data_len(this->uart_num_, &available); - if (available) { - uart_read_bytes(this->uart_num_, &data, 1, 0); - byte = data; - } - } - break; -#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC) - case logger::UART_SELECTION_USB_CDC: - if (esp_usb_console_available_for_read()) { - esp_usb_console_read_buf((char *) &data, 1); - byte = data; - } - break; -#endif // USE_LOGGER_USB_CDC -#ifdef USE_LOGGER_USB_SERIAL_JTAG - case logger::UART_SELECTION_USB_SERIAL_JTAG: { - if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) { - byte = data; - } - break; - } -#endif // USE_LOGGER_USB_SERIAL_JTAG - default: - break; - } -#elif defined(USE_ARDUINO) - if (this->hw_serial_->available()) { - this->hw_serial_->readBytes(&data, 1); - byte = data; - } -#endif - return byte; -} - void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) { // First, set length field this->tx_header_[TX_LENGTH_IDX] = this->tx_header_[TX_TYPE_IDX] == TYPE_RPC_RESPONSE ? size : 1; @@ -134,7 +87,7 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) this->tx_header_[TX_CHECKSUM_IDX] = checksum; #ifdef USE_ESP32 - switch (logger::global_logger->get_uart()) { + switch (this->uart_selection_) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: #if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index dd8f5e4719..4b09c662aa 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/improv_base/improv_base.h" +#include "esphome/components/logger/logger.h" #include "esphome/components/wifi/wifi_component.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" @@ -66,7 +67,53 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { std::vector build_rpc_settings_response_(improv::Command command); std::vector build_version_info_(); - optional read_byte_(); + ESPHOME_ALWAYS_INLINE optional read_byte_() { + optional byte; + uint8_t data = 0; +#ifdef USE_ESP32 + switch (this->uart_selection_) { + case logger::UART_SELECTION_UART0: + case logger::UART_SELECTION_UART1: +#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) + case logger::UART_SELECTION_UART2: +#endif + if (this->uart_num_ >= 0) { + size_t available; + uart_get_buffered_data_len(this->uart_num_, &available); + if (available) { + uart_read_bytes(this->uart_num_, &data, 1, 0); + byte = data; + } + } + break; +#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC) + case logger::UART_SELECTION_USB_CDC: + if (esp_usb_console_available_for_read()) { + esp_usb_console_read_buf((char *) &data, 1); + byte = data; + } + break; +#endif +#ifdef USE_LOGGER_USB_SERIAL_JTAG + case logger::UART_SELECTION_USB_SERIAL_JTAG: { + if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) { + byte = data; + } + break; + } +#endif + default: + break; + } +#elif defined(USE_ARDUINO) + if (this->hw_serial_->available()) { + this->hw_serial_->readBytes(&data, 1); + byte = data; + } +#endif + return byte; + } void write_data_(const uint8_t *data = nullptr, size_t size = 0); uint8_t tx_header_[TX_BUFFER_SIZE] = { @@ -86,6 +133,7 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { #ifdef USE_ESP32 uart_port_t uart_num_; + logger::UARTSelection uart_selection_{logger::UART_SELECTION_UART0}; #elif defined(USE_ARDUINO) Stream *hw_serial_{nullptr}; #endif From 9f058ac1a6bd0cc5e15e15ebb4e23b45574b3ece Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 09:26:20 -0500 Subject: [PATCH 0671/2214] [core] Use ETag in external_files cache to fix re-downloads from raw.githubusercontent.com raw.githubusercontent.com ignores If-Modified-Since (always returns 200), but honors If-None-Match with ETag (returns 304). This caused every esphome compile/config run to re-download every cached external file (audio_file, micro_wake_word, image, font, bme68x_bsec2, etc.) sourced from a /raw/ URL. - Send If-None-Match with the cached ETag when present - Persist the ETag from each download in a hidden sidecar file (.{name}.etag) and refresh it when a 304 carries a new ETag - Replace path.write_bytes() with helpers.write_file() so downloads are written atomically and can no longer leave partially-written cache files behind on crash --- esphome/external_files.py | 43 ++++++++- tests/unit_tests/test_external_files.py | 122 ++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 4 deletions(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index b6f6149ebb..2dd1cf9af6 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib from datetime import UTC, datetime import logging from pathlib import Path @@ -8,7 +9,8 @@ import requests import esphome.config_validation as cv from esphome.const import __version__ -from esphome.core import CORE, TimePeriodSeconds +from esphome.core import CORE, EsphomeError, TimePeriodSeconds +from esphome.helpers import write_file _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] @@ -16,12 +18,38 @@ CODEOWNERS = ["@landonr"] NETWORK_TIMEOUT = 30 IF_MODIFIED_SINCE = "If-Modified-Since" +IF_NONE_MATCH = "If-None-Match" +ETAG = "ETag" CACHE_CONTROL = "Cache-Control" CACHE_CONTROL_MAX_AGE = "max-age=" CONTENT_DISPOSITION = "content-disposition" TEMP_DIR = "temp" +def _etag_sidecar_path(local_file_path: Path) -> Path: + return local_file_path.parent / f".{local_file_path.name}.etag" + + +def _read_etag(local_file_path: Path) -> str | None: + try: + etag = _etag_sidecar_path(local_file_path).read_text().strip() + except OSError: + return None + return etag or None + + +def _write_etag(local_file_path: Path, etag: str | None) -> None: + etag_path = _etag_sidecar_path(local_file_path) + if not etag: + with contextlib.suppress(FileNotFoundError): + etag_path.unlink() + return + try: + write_file(etag_path, etag) + except EsphomeError as e: + _LOGGER.debug("Could not save ETag for %s: %s", local_file_path, e) + + def has_remote_file_changed(url: str, local_file_path: Path) -> bool: if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) @@ -35,14 +63,18 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: IF_MODIFIED_SINCE: local_modification_time_str, CACHE_CONTROL: CACHE_CONTROL_MAX_AGE + "3600", } + etag = _read_etag(local_file_path) + if etag: + headers[IF_NONE_MATCH] = etag response = requests.head( url, headers=headers, timeout=NETWORK_TIMEOUT, allow_redirects=True ) _LOGGER.debug( - "has_remote_file_changed: File %s, Local modified %s, response code %d", + "has_remote_file_changed: File %s, Local modified %s, ETag %s, response code %d", local_file_path, local_modification_time_str, + etag or "", response.status_code, ) @@ -51,6 +83,9 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: "has_remote_file_changed: File not modified since %s", local_modification_time_str, ) + new_etag = response.headers.get(ETAG) + if new_etag and new_etag != etag: + _write_etag(local_file_path, new_etag) return False _LOGGER.debug("has_remote_file_changed: File modified") return True @@ -112,7 +147,7 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by return path.read_bytes() raise cv.Invalid(f"Could not download from {url}: {e}") from e - path.parent.mkdir(parents=True, exist_ok=True) data = req.content - path.write_bytes(data) + write_file(path, data) + _write_etag(path, req.headers.get(ETAG)) return data diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 4b0826db04..ade1e66c53 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -98,6 +98,7 @@ def test_has_remote_file_changed_not_modified( mock_response = MagicMock() mock_response.status_code = 304 + mock_response.headers = {} mock_head.return_value = mock_response url = "https://example.com/file.txt" @@ -122,6 +123,7 @@ def test_has_remote_file_changed_modified( mock_response = MagicMock() mock_response.status_code = 200 + mock_response.headers = {} mock_head.return_value = mock_response url = "https://example.com/file.txt" @@ -166,6 +168,7 @@ def test_has_remote_file_changed_timeout( mock_response = MagicMock() mock_response.status_code = 304 + mock_response.headers = {} mock_head.return_value = mock_response url = "https://example.com/file.txt" @@ -175,6 +178,68 @@ def test_has_remote_file_changed_timeout( assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_uses_etag( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed sends If-None-Match when ETag is cached.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + external_files._etag_sidecar_path(test_file).write_text('"abc123"') + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {} + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, test_file) + + assert result is False + headers = mock_head.call_args[1]["headers"] + assert headers[external_files.IF_NONE_MATCH] == '"abc123"' + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_no_etag_no_if_none_match( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed omits If-None-Match when no ETag is cached.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {} + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.has_remote_file_changed(url, test_file) + + headers = mock_head.call_args[1]["headers"] + assert external_files.IF_NONE_MATCH not in headers + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_refreshes_etag_on_304( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed updates the cached ETag when the 304 sends a new one.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + external_files._etag_sidecar_path(test_file).write_text('"old"') + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {external_files.ETAG: '"new"'} + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.has_remote_file_changed(url, test_file) + + assert external_files._etag_sidecar_path(test_file).read_text() == '"new"' + + def test_compute_local_file_dir_creates_parent_dirs(setup_core: Path) -> None: """Test compute_local_file_dir creates parent directories.""" domain = "level1/level2/level3/level4" @@ -273,6 +338,7 @@ def test_download_content_skip_external_update_downloads_when_missing( mock_has_changed.return_value = True mock_response = MagicMock() mock_response.content = new_content + mock_response.headers = {} mock_response.raise_for_status = MagicMock() mock_get.return_value = mock_response @@ -282,3 +348,59 @@ def test_download_content_skip_external_update_downloads_when_missing( assert result == new_content assert test_file.read_bytes() == new_content + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_saves_etag( + mock_has_changed: MagicMock, + mock_get: MagicMock, + setup_core: Path, +) -> None: + """Test download_content writes the ETag sidecar after a successful download.""" + test_file = setup_core / "fresh.txt" + new_content = b"fresh content" + + mock_has_changed.return_value = True + mock_response = MagicMock() + mock_response.content = new_content + mock_response.headers = {external_files.ETAG: '"deadbeef"'} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.download_content(url, test_file) + + assert external_files._etag_sidecar_path(test_file).read_text() == '"deadbeef"' + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_atomic_write_no_partial_on_failure( + mock_has_changed: MagicMock, + mock_get: MagicMock, + setup_core: Path, +) -> None: + """Test download_content does not corrupt the existing file if the write step fails.""" + test_file = setup_core / "cached.txt" + original_content = b"original content" + test_file.write_bytes(original_content) + + mock_has_changed.return_value = True + mock_response = MagicMock() + # Accessing .content raises, simulating a streaming/decode failure + type(mock_response).content = property( + lambda self: (_ for _ in ()).throw(OSError("disk full")) + ) + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + url = "https://example.com/file.txt" + with pytest.raises(OSError, match="disk full"): + external_files.download_content(url, test_file) + + # Original file is untouched (atomic rename never happened) + assert test_file.read_bytes() == original_content + # No leftover temp files from tempfile.NamedTemporaryFile + leftover_tmps = list(setup_core.glob("tmp*")) + assert leftover_tmps == [] From 2d9d1eabfa1ea45e1b52fbb3fc97893eeab45da4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 09:36:13 -0500 Subject: [PATCH 0672/2214] [core] Download external_files in parallel Each component that uses external_files (audio_file, speaker media_player, ...) currently calls download_content once per file inline inside a per-item config validator. With ~24 cached audio files in a Home Assistant Voice PE config, that means ~24 sequential HEAD round-trips, even when every response is a 304. This adds download_content_many(items, ...) which fans the per-file checks out across a ThreadPoolExecutor (capped at 16 workers so configs with hundreds of files don't open hundreds of sockets), then refactors audio_file and speaker.media_player to collect URLs at the list level and call the batch helper once instead of downloading inside each per-item validator. Wall time for the validation phase drops from sum(latency) to roughly max(latency) when the cache is warm. --- esphome/components/audio_file/__init__.py | 30 +++++---- .../speaker/media_player/__init__.py | 34 ++++++---- esphome/external_files.py | 33 ++++++++++ tests/unit_tests/test_external_files.py | 66 +++++++++++++++++++ 4 files changed, 140 insertions(+), 23 deletions(-) diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index bb1ce257db..4db8f11432 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -19,7 +19,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, HexInt from esphome.cpp_generator import MockObj -from esphome.external_files import download_content +from esphome.external_files import download_content_many from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -63,13 +63,21 @@ def _compute_local_file_path(value: ConfigType) -> Path: return base_dir / key -def _download_web_file(value: ConfigType) -> ConfigType: - url = value[CONF_URL] - path = _compute_local_file_path(value) - - download_content(url, path) - _LOGGER.debug("download_web_file: path=%s", path) - return value +def _download_all_web_files(config: list[ConfigType]) -> list[ConfigType]: + """Validate that all web-sourced files are cached, fetching missing/changed + ones in parallel before per-item validators read them off disk. + """ + items: list[tuple[str, Path]] = [] + for file_config in config: + conf_file = file_config.get(CONF_FILE, {}) + if conf_file.get(CONF_TYPE) != TYPE_WEB: + continue + url = conf_file[CONF_URL] + path = _compute_local_file_path(conf_file) + items.append((url, path)) + _LOGGER.debug("download_web_file: path=%s", path) + download_content_many(items) + return config def _file_schema(value: ConfigType | str) -> ConfigType: @@ -142,11 +150,10 @@ LOCAL_SCHEMA = cv.Schema( } ) -WEB_SCHEMA = cv.All( +WEB_SCHEMA = cv.Schema( { cv.Required(CONF_URL): cv.url, - }, - _download_web_file, + } ) @@ -209,6 +216,7 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType] CONFIG_SCHEMA = cv.All( cv.only_on_esp32, cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), + _download_all_web_files, _validate_supported_local_file, ) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index abfd599808..7c4b366947 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -32,7 +32,7 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.external_files import download_content +from esphome.external_files import download_content_many _LOGGER = logging.getLogger(__name__) @@ -92,13 +92,21 @@ def _compute_local_file_path(value: dict) -> Path: return base_dir / key -def _download_web_file(value): - url = value[CONF_URL] - path = _compute_local_file_path(value) - - download_content(url, path) - _LOGGER.debug("download_web_file: path=%s", path) - return value +def _download_all_web_files(config): + """Validate that all web-sourced files are cached, fetching missing/changed + ones in parallel before per-item validators read them off disk. + """ + items: list[tuple[str, Path]] = [] + for file_config in config: + conf_file = file_config.get(CONF_FILE, {}) + if conf_file.get(CONF_TYPE) != TYPE_WEB: + continue + url = conf_file[CONF_URL] + path = _compute_local_file_path(conf_file) + items.append((url, path)) + _LOGGER.debug("download_web_file: path=%s", path) + download_content_many(items) + return config _PURPOSE_MAP = { @@ -229,11 +237,10 @@ LOCAL_SCHEMA = cv.Schema( } ) -WEB_SCHEMA = cv.All( +WEB_SCHEMA = cv.Schema( { cv.Required(CONF_URL): cv.url, - }, - _download_web_file, + } ) @@ -285,7 +292,10 @@ CONFIG_SCHEMA = cv.All( ), # Remove before 2026.10.0 cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), - cv.Optional(CONF_FILES): cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), + cv.Optional(CONF_FILES): cv.All( + cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), + _download_all_web_files, + ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( cv.boolean, cv.requires_component(psram.DOMAIN) ), diff --git a/esphome/external_files.py b/esphome/external_files.py index b6f6149ebb..0cf6ab7006 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Iterable +from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime import logging from pathlib import Path @@ -116,3 +118,34 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by data = req.content path.write_bytes(data) return data + + +# Cap concurrent connections so a config with hundreds of remote files doesn't +# open hundreds of sockets at once. 16 is wide enough that wall time is +# dominated by the slowest single request for normal configs (a couple dozen +# files), and tight enough to be polite to the upstream host. +DEFAULT_DOWNLOAD_WORKERS = 16 + + +def download_content_many( + items: Iterable[tuple[str, Path]], + timeout: int = NETWORK_TIMEOUT, + max_workers: int = DEFAULT_DOWNLOAD_WORKERS, +) -> None: + """Run `download_content` for each (url, path) pair concurrently. + + Wall time drops from `sum(latency)` to roughly `max(latency)` for cached + files where the HEAD round-trip dominates. The first exception raised by + any worker is propagated; remaining workers complete before this returns. + """ + items = list(items) + if not items: + return + if len(items) == 1: + url, path = items[0] + download_content(url, path, timeout) + return + workers = min(max_workers, len(items)) + with ThreadPoolExecutor(max_workers=workers) as ex: + # list() forces iteration so exceptions surface here, not silently. + list(ex.map(lambda item: download_content(item[0], item[1], timeout), items)) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 4b0826db04..182e424adb 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -282,3 +282,69 @@ def test_download_content_skip_external_update_downloads_when_missing( assert result == new_content assert test_file.read_bytes() == new_content + + +@patch("esphome.external_files.download_content") +def test_download_content_many_empty_is_noop( + mock_download: MagicMock, setup_core: Path +) -> None: + """Empty input shouldn't spin up a thread pool or call download_content.""" + external_files.download_content_many([]) + mock_download.assert_not_called() + + +@patch("esphome.external_files.download_content") +def test_download_content_many_single_item_avoids_pool( + mock_download: 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") + external_files.download_content_many([item]) + mock_download.assert_called_once_with( + item[0], item[1], external_files.NETWORK_TIMEOUT + ) + + +@patch("esphome.external_files.download_content") +def test_download_content_many_runs_in_parallel( + mock_download: MagicMock, setup_core: Path +) -> None: + """Multiple items should run concurrently — total wall time ≈ max latency.""" + import threading + + barrier = threading.Barrier(3) + + def slow_download(url: str, path: Path, timeout: int) -> 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) + return b"" + + mock_download.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.download_content_many(items, max_workers=4) + assert mock_download.call_count == 3 + + +@patch("esphome.external_files.download_content") +def test_download_content_many_propagates_errors( + mock_download: MagicMock, setup_core: Path +) -> None: + """An exception from any worker must propagate out of download_content_many.""" + + def fake_download(url: str, path: Path, timeout: int) -> bytes: + if url.endswith("bad"): + raise Invalid(f"could not download {url}") + return b"" + + mock_download.side_effect = fake_download + items = [ + ("https://example.com/ok", setup_core / "ok"), + ("https://example.com/bad", setup_core / "bad"), + ] + with pytest.raises(Invalid, match="could not download"): + external_files.download_content_many(items) From 5ae259ccba3bfb998aedaaac8bf57a8a8bb63fc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 09:41:20 -0500 Subject: [PATCH 0673/2214] Sync ETag sidecar mtime with cache file; treat mismatched sidecars as stale The ETag sidecar describes a specific snapshot of the cache file. If the cache file is replaced or edited out-of-band (manual edit, restore from backup, another tool overwriting it), the sidecar's recorded ETag no longer matches the bytes on disk -- using it would cause the server to return 304 and we'd serve the wrong content from cache. - _write_etag now os.utime()'s the sidecar to share the cache file's st_mtime_ns immediately after writing it. - _read_etag compares the two mtimes; if they don't match it returns None and removes the sidecar so subsequent calls don't re-check it. --- esphome/external_files.py | 38 ++++++++++++- tests/unit_tests/test_external_files.py | 73 ++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index 2dd1cf9af6..42f332df66 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib from datetime import UTC, datetime import logging +import os from pathlib import Path import requests @@ -31,8 +32,33 @@ def _etag_sidecar_path(local_file_path: Path) -> Path: def _read_etag(local_file_path: Path) -> str | None: + """Return the cached ETag if it still describes the current cached file. + + The sidecar's mtime is kept in sync with the cache file's mtime by + `_write_etag`. If they disagree, the cache file was modified out-of-band + (manual edit, restored from backup, replaced by another tool) and the + ETag no longer corresponds to its contents, so treat the sidecar as + stale and delete it. + """ + etag_path = _etag_sidecar_path(local_file_path) try: - etag = _etag_sidecar_path(local_file_path).read_text().strip() + etag_mtime_ns = etag_path.stat().st_mtime_ns + file_mtime_ns = local_file_path.stat().st_mtime_ns + except OSError: + return None + if etag_mtime_ns != file_mtime_ns: + _LOGGER.debug( + "ETag sidecar mtime (%d) does not match cached file mtime (%d) at %s; " + "treating ETag as stale", + etag_mtime_ns, + file_mtime_ns, + local_file_path, + ) + with contextlib.suppress(OSError): + etag_path.unlink() + return None + try: + etag = etag_path.read_text().strip() except OSError: return None return etag or None @@ -48,6 +74,16 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: write_file(etag_path, etag) except EsphomeError as e: _LOGGER.debug("Could not save ETag for %s: %s", local_file_path, e) + return + # Pin the sidecar's mtime to the cache file's mtime. _read_etag relies on + # this match to detect out-of-band edits to the cache file. + try: + file_mtime_ns = local_file_path.stat().st_mtime_ns + os.utime(etag_path, ns=(file_mtime_ns, file_mtime_ns)) + except OSError as e: + _LOGGER.debug( + "Could not sync ETag sidecar mtime for %s: %s", local_file_path, e + ) def has_remote_file_changed(url: str, local_file_path: Path) -> bool: diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index ade1e66c53..7136db1624 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -1,5 +1,6 @@ """Tests for external_files.py functions.""" +import os from pathlib import Path import time from unittest.mock import MagicMock, patch @@ -12,6 +13,17 @@ from esphome.config_validation import Invalid from esphome.core import CORE, TimePeriod +def _seed_etag(cache_file: Path, etag: str) -> Path: + """Write an ETag sidecar with its mtime synced to the cache file's mtime, + matching the invariant that `_write_etag` enforces in production. + """ + sidecar = external_files._etag_sidecar_path(cache_file) + sidecar.write_text(etag) + file_mtime_ns = cache_file.stat().st_mtime_ns + os.utime(sidecar, ns=(file_mtime_ns, file_mtime_ns)) + return sidecar + + def test_compute_local_file_dir(setup_core: Path) -> None: """Test compute_local_file_dir creates and returns correct path.""" domain = "font" @@ -185,7 +197,7 @@ def test_has_remote_file_changed_uses_etag( """Test has_remote_file_changed sends If-None-Match when ETag is cached.""" test_file = setup_core / "cached.txt" test_file.write_text("cached content") - external_files._etag_sidecar_path(test_file).write_text('"abc123"') + _seed_etag(test_file, '"abc123"') mock_response = MagicMock() mock_response.status_code = 304 @@ -227,7 +239,7 @@ def test_has_remote_file_changed_refreshes_etag_on_304( """Test has_remote_file_changed updates the cached ETag when the 304 sends a new one.""" test_file = setup_core / "cached.txt" test_file.write_text("cached content") - external_files._etag_sidecar_path(test_file).write_text('"old"') + _seed_etag(test_file, '"old"') mock_response = MagicMock() mock_response.status_code = 304 @@ -240,6 +252,63 @@ def test_has_remote_file_changed_refreshes_etag_on_304( assert external_files._etag_sidecar_path(test_file).read_text() == '"new"' +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_ignores_etag_when_mtime_diverges( + mock_head: MagicMock, setup_core: Path +) -> None: + """If the cache file was edited out-of-band (mtime no longer matches the + sidecar's), the cached ETag must not be used -- it no longer describes the + bytes on disk. + """ + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + sidecar = _seed_etag(test_file, '"abc123"') + + # Simulate an out-of-band edit to the cache file -- mtime advances but the + # sidecar is left untouched, so the recorded ETag is now stale. + file_stat = test_file.stat() + os.utime( + test_file, ns=(file_stat.st_atime_ns, file_stat.st_mtime_ns + 1_000_000_000) + ) + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {} + mock_head.return_value = mock_response + + external_files.has_remote_file_changed("https://example.com/file.txt", test_file) + + headers = mock_head.call_args[1]["headers"] + assert external_files.IF_NONE_MATCH not in headers + # Stale sidecar should be removed so future calls don't keep paying the + # mtime-comparison cost on a known-bad sidecar. + assert not sidecar.exists() + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_pins_etag_mtime_to_file_mtime( + mock_has_changed: MagicMock, + mock_get: MagicMock, + setup_core: Path, +) -> None: + """After a successful download, the sidecar's mtime must equal the cache + file's mtime so `_read_etag` accepts it on the next call. + """ + test_file = setup_core / "fresh.txt" + mock_has_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {external_files.ETAG: '"deadbeef"'} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + external_files.download_content("https://example.com/file.txt", test_file) + + sidecar = external_files._etag_sidecar_path(test_file) + assert sidecar.stat().st_mtime_ns == test_file.stat().st_mtime_ns + + def test_compute_local_file_dir_creates_parent_dirs(setup_core: Path) -> None: """Test compute_local_file_dir creates parent directories.""" domain = "level1/level2/level3/level4" From 5b28ab1c4931036441d46771173d70a95683bc73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 09:44:57 -0500 Subject: [PATCH 0674/2214] Cap download_content_many at 8 workers (was 16) --- esphome/external_files.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index 0cf6ab7006..d4f2649a10 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -121,10 +121,11 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by # Cap concurrent connections so a config with hundreds of remote files doesn't -# open hundreds of sockets at once. 16 is wide enough that wall time is -# dominated by the slowest single request for normal configs (a couple dozen -# files), and tight enough to be polite to the upstream host. -DEFAULT_DOWNLOAD_WORKERS = 16 +# open hundreds of sockets at once. 8 matches the requests connection-pool +# default and the per-host connection limit browsers use, which keeps us +# polite to the upstream host while still cutting wall time roughly 8x for +# typical configs (a couple dozen files). +DEFAULT_DOWNLOAD_WORKERS = 8 def download_content_many( From 540696282b3cd32cafc430d197978a8b6ebdcc0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 09:52:29 -0500 Subject: [PATCH 0675/2214] Hoist duplicated _download_all_web_files into external_files.download_web_files_in_config audio_file and speaker.media_player had byte-identical helpers that collected web URLs from a list-of-file-configs and handed them to download_content_many. Move that logic into external_files as `download_web_files_in_config(config, path_for)` -- callers only have to pass the per-component cache-path callback now. --- esphome/components/audio_file/__init__.py | 21 +-------- .../speaker/media_player/__init__.py | 21 +-------- esphome/external_files.py | 30 ++++++++++++- tests/unit_tests/test_external_files.py | 43 +++++++++++++++++++ 4 files changed, 75 insertions(+), 40 deletions(-) diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 4db8f11432..c2f1378f9d 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -19,7 +19,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, HexInt from esphome.cpp_generator import MockObj -from esphome.external_files import download_content_many +from esphome.external_files import download_web_files_in_config from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -63,23 +63,6 @@ def _compute_local_file_path(value: ConfigType) -> Path: return base_dir / key -def _download_all_web_files(config: list[ConfigType]) -> list[ConfigType]: - """Validate that all web-sourced files are cached, fetching missing/changed - ones in parallel before per-item validators read them off disk. - """ - items: list[tuple[str, Path]] = [] - for file_config in config: - conf_file = file_config.get(CONF_FILE, {}) - if conf_file.get(CONF_TYPE) != TYPE_WEB: - continue - url = conf_file[CONF_URL] - path = _compute_local_file_path(conf_file) - items.append((url, path)) - _LOGGER.debug("download_web_file: path=%s", path) - download_content_many(items) - return config - - def _file_schema(value: ConfigType | str) -> ConfigType: if isinstance(value, str): return _validate_file_shorthand(value) @@ -216,7 +199,7 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType] CONFIG_SCHEMA = cv.All( cv.only_on_esp32, cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), - _download_all_web_files, + lambda c: download_web_files_in_config(c, _compute_local_file_path), _validate_supported_local_file, ) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 7c4b366947..bbb51c03de 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -32,7 +32,7 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.external_files import download_content_many +from esphome.external_files import download_web_files_in_config _LOGGER = logging.getLogger(__name__) @@ -92,23 +92,6 @@ def _compute_local_file_path(value: dict) -> Path: return base_dir / key -def _download_all_web_files(config): - """Validate that all web-sourced files are cached, fetching missing/changed - ones in parallel before per-item validators read them off disk. - """ - items: list[tuple[str, Path]] = [] - for file_config in config: - conf_file = file_config.get(CONF_FILE, {}) - if conf_file.get(CONF_TYPE) != TYPE_WEB: - continue - url = conf_file[CONF_URL] - path = _compute_local_file_path(conf_file) - items.append((url, path)) - _LOGGER.debug("download_web_file: path=%s", path) - download_content_many(items) - return config - - _PURPOSE_MAP = { "MEDIA": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], "ANNOUNCEMENT": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], @@ -294,7 +277,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), cv.Optional(CONF_FILES): cv.All( cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), - _download_all_web_files, + lambda c: download_web_files_in_config(c, _compute_local_file_path), ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( cv.boolean, cv.requires_component(psram.DOMAIN) diff --git a/esphome/external_files.py b/esphome/external_files.py index d4f2649a10..7d27b05a56 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Callable, Iterable from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime import logging @@ -9,8 +9,9 @@ from pathlib import Path import requests import esphome.config_validation as cv -from esphome.const import __version__ +from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, TimePeriodSeconds +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] @@ -150,3 +151,28 @@ def download_content_many( with ThreadPoolExecutor(max_workers=workers) as ex: # list() forces iteration so exceptions surface here, not silently. list(ex.map(lambda item: download_content(item[0], item[1], timeout), items)) + + +# String constant rather than `from .const import TYPE_WEB` because each +# component defines its own `TYPE_WEB = "web"` literal in its module scope. +WEB_TYPE = "web" + + +def download_web_files_in_config( + config: list[ConfigType], + path_for: Callable[[ConfigType], Path], +) -> list[ConfigType]: + """Voluptuous-friendly validator that downloads any web-sourced files in + `config` in parallel. + + Each entry is expected to contain a `file` key whose value is a dict + that may be `{type: "web", url: ...}`; `path_for(file_dict)` returns + the cache path for that file. Returns `config` unchanged so it can be + slotted directly into a `cv.All(...)` chain. + """ + download_content_many( + (conf_file[CONF_URL], path_for(conf_file)) + for entry in config + if (conf_file := entry.get(CONF_FILE, {})).get(CONF_TYPE) == WEB_TYPE + ) + return config diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 182e424adb..22e7c02043 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -348,3 +348,46 @@ def test_download_content_many_propagates_errors( ] with pytest.raises(Invalid, match="could not download"): external_files.download_content_many(items) + + +@patch("esphome.external_files.download_content_many") +def test_download_web_files_in_config_filters_and_dispatches( + mock_many: MagicMock, setup_core: Path +) -> None: + """Only `file.type == "web"` entries should be forwarded to + download_content_many, and the unmodified config should be returned so + the helper can sit in a `cv.All(...)` chain. + """ + + def path_for(file_dict: dict) -> Path: + return setup_core / file_dict["url"].rsplit("/", 1)[-1] + + config = [ + {"file": {"type": "web", "url": "https://example.com/a"}}, + {"file": {"type": "local", "path": "/tmp/b"}}, + {"file": {"type": "web", "url": "https://example.com/c"}}, + {}, # no `file` key at all + ] + result = external_files.download_web_files_in_config(config, path_for) + + assert result is config + mock_many.assert_called_once() + items = mock_many.call_args[0][0] + assert items == [ + ("https://example.com/a", setup_core / "a"), + ("https://example.com/c", setup_core / "c"), + ] + + +@patch("esphome.external_files.download_content_many") +def test_download_web_files_in_config_no_web_entries( + mock_many: MagicMock, setup_core: Path +) -> None: + """A config with no web entries should still call through to + download_content_many (which is itself a no-op for empty input) so the + behavior stays consistent. + """ + config = [{"file": {"type": "local", "path": "/tmp/a"}}] + external_files.download_web_files_in_config(config, lambda _: setup_core / "x") + mock_many.assert_called_once() + assert mock_many.call_args[0][0] == [] From 54493faafcf5bd02326bcb4e976db1d31de23916 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 09:53:07 -0500 Subject: [PATCH 0676/2214] Fix download_web_files_in_config tests for generator argument --- tests/unit_tests/test_external_files.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 22e7c02043..a7d7f74895 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -372,8 +372,7 @@ def test_download_web_files_in_config_filters_and_dispatches( assert result is config mock_many.assert_called_once() - items = mock_many.call_args[0][0] - assert items == [ + assert list(mock_many.call_args[0][0]) == [ ("https://example.com/a", setup_core / "a"), ("https://example.com/c", setup_core / "c"), ] @@ -390,4 +389,4 @@ def test_download_web_files_in_config_no_web_entries( config = [{"file": {"type": "local", "path": "/tmp/a"}}] external_files.download_web_files_in_config(config, lambda _: setup_core / "x") mock_many.assert_called_once() - assert mock_many.call_args[0][0] == [] + assert list(mock_many.call_args[0][0]) == [] From da322356e1f1ed7aa4c9f50c07e3bb29c603511e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 09:54:38 -0500 Subject: [PATCH 0677/2214] Address Copilot review on PR #16020 - _write_etag: suppress OSError (not just FileNotFoundError) when removing a stale sidecar -- ETag persistence is best-effort and a permission error there must not abort an otherwise-successful download. - Atomic-write test: patch write_file directly so the failure happens inside the helper that does the rename, instead of before download_content even calls it. --- esphome/external_files.py | 3 ++- tests/unit_tests/test_external_files.py | 28 ++++++++++++++++--------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index 42f332df66..2fa2bea6e5 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -67,7 +67,8 @@ def _read_etag(local_file_path: Path) -> str | None: def _write_etag(local_file_path: Path, etag: str | None) -> None: etag_path = _etag_sidecar_path(local_file_path) if not etag: - with contextlib.suppress(FileNotFoundError): + # ETag persistence is best-effort; matches `_read_etag`'s tolerance. + with contextlib.suppress(OSError): etag_path.unlink() return try: diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 7136db1624..301fa51272 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -443,33 +443,41 @@ def test_download_content_saves_etag( assert external_files._etag_sidecar_path(test_file).read_text() == '"deadbeef"' +@patch("esphome.external_files.write_file") @patch("esphome.external_files.requests.get") @patch("esphome.external_files.has_remote_file_changed") def test_download_content_atomic_write_no_partial_on_failure( mock_has_changed: MagicMock, mock_get: MagicMock, + mock_write_file: MagicMock, setup_core: Path, ) -> None: - """Test download_content does not corrupt the existing file if the write step fails.""" + """If `write_file` (the atomic-write helper) fails, the existing cache + file must remain untouched and no temp files may be left behind. Patching + `write_file` directly exercises the atomic-rename path -- a failure inside + `write_file` is the only reason the rename wouldn't have happened. + """ + from esphome.core import EsphomeError + test_file = setup_core / "cached.txt" original_content = b"original content" test_file.write_bytes(original_content) mock_has_changed.return_value = True mock_response = MagicMock() - # Accessing .content raises, simulating a streaming/decode failure - type(mock_response).content = property( - lambda self: (_ for _ in ()).throw(OSError("disk full")) - ) + mock_response.content = b"new content" + mock_response.headers = {} mock_response.raise_for_status = MagicMock() mock_get.return_value = mock_response - url = "https://example.com/file.txt" - with pytest.raises(OSError, match="disk full"): - external_files.download_content(url, test_file) + mock_write_file.side_effect = EsphomeError("disk full") - # Original file is untouched (atomic rename never happened) + with pytest.raises(EsphomeError, match="disk full"): + external_files.download_content("https://example.com/file.txt", test_file) + + # Original file is untouched -- write_file aborted before its rename step. assert test_file.read_bytes() == original_content - # No leftover temp files from tempfile.NamedTemporaryFile + # write_file is responsible for cleaning its own temp files; nothing leaks + # into the cache directory either way. leftover_tmps = list(setup_core.glob("tmp*")) assert leftover_tmps == [] From a877acf1d14e89509a50eaaee56ece62c4a35254 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 09:55:27 -0500 Subject: [PATCH 0678/2214] Address Copilot review on PR #16021 - download_content_many now de-duplicates by `path` so two callers asking for the same cache file (e.g. the same URL referenced twice in a config) can't race on download_content's non-atomic write. When duplicates are present, the first-seen URL for that path wins. - Clamp `max_workers` to at least 1 so an invalid caller value can't raise ValueError out of ThreadPoolExecutor. --- esphome/external_files.py | 25 ++++++++++++----- tests/unit_tests/test_external_files.py | 36 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index 7d27b05a56..9618757529 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -139,18 +139,31 @@ def download_content_many( Wall time drops from `sum(latency)` to roughly `max(latency)` for cached files where the HEAD round-trip dominates. The first exception raised by any worker is propagated; remaining workers complete before this returns. + + 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. """ - items = list(items) - if not items: + seen: dict[Path, str] = {} + for url, path in items: + if path in seen: + continue + seen[path] = url + if not seen: return - if len(items) == 1: - url, path = items[0] + if len(seen) == 1: + path, url = next(iter(seen.items())) download_content(url, path, timeout) return - workers = min(max_workers, len(items)) + workers = max(1, min(max_workers, len(seen))) with ThreadPoolExecutor(max_workers=workers) as ex: # list() forces iteration so exceptions surface here, not silently. - list(ex.map(lambda item: download_content(item[0], item[1], timeout), items)) + list( + ex.map( + lambda item: download_content(item[1], item[0], timeout), + seen.items(), + ) + ) # String constant rather than `from .const import TYPE_WEB` because each diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index a7d7f74895..69e1c6cdde 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -350,6 +350,42 @@ def test_download_content_many_propagates_errors( external_files.download_content_many(items) +@patch("esphome.external_files.download_content") +def test_download_content_many_dedupes_by_path( + mock_download: MagicMock, setup_core: Path +) -> None: + """Two items pointing at the same cache path must collapse to one + download -- otherwise concurrent writes race on the same file. + """ + path = setup_core / "shared" + items = [ + ("https://example.com/a", path), + ("https://example.com/b", path), + ("https://example.com/a", path), + ] + external_files.download_content_many(items) + assert mock_download.call_count == 1 + # First-seen URL wins, matching dict-insertion order. + args, _ = mock_download.call_args + assert args[0] == "https://example.com/a" + assert args[1] == path + + +@patch("esphome.external_files.download_content") +def test_download_content_many_clamps_invalid_max_workers( + mock_download: MagicMock, setup_core: Path +) -> None: + """`max_workers <= 0` must not raise from ThreadPoolExecutor; it should + 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.download_content_many(items, max_workers=0) + assert mock_download.call_count == 2 + + @patch("esphome.external_files.download_content_many") def test_download_web_files_in_config_filters_and_dispatches( mock_many: MagicMock, setup_core: Path From 267590fc36925f1115263bb8dd88020521da2ef5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 09:57:23 -0500 Subject: [PATCH 0679/2214] Extract mock_download_content[_many] fixtures in test_external_files --- tests/unit_tests/test_external_files.py | 66 ++++++++++++++----------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 69e1c6cdde..fd27f70315 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -12,6 +12,24 @@ from esphome.config_validation import Invalid from esphome.core import CORE, TimePeriod +@pytest.fixture +def mock_download_content() -> MagicMock: + """Patch `external_files.download_content` for tests that exercise the + parallel batch helper without doing real I/O. + """ + with patch("esphome.external_files.download_content") as m: + yield m + + +@pytest.fixture +def mock_download_content_many() -> MagicMock: + """Patch `external_files.download_content_many` for tests that exercise + the URL-collection helper without dispatching to the thread pool. + """ + with patch("esphome.external_files.download_content_many") as m: + yield m + + def test_compute_local_file_dir(setup_core: Path) -> None: """Test compute_local_file_dir creates and returns correct path.""" domain = "font" @@ -284,30 +302,27 @@ def test_download_content_skip_external_update_downloads_when_missing( assert test_file.read_bytes() == new_content -@patch("esphome.external_files.download_content") def test_download_content_many_empty_is_noop( - mock_download: MagicMock, setup_core: Path + mock_download_content: MagicMock, setup_core: Path ) -> None: """Empty input shouldn't spin up a thread pool or call download_content.""" external_files.download_content_many([]) - mock_download.assert_not_called() + mock_download_content.assert_not_called() -@patch("esphome.external_files.download_content") def test_download_content_many_single_item_avoids_pool( - mock_download: MagicMock, setup_core: Path + 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") external_files.download_content_many([item]) - mock_download.assert_called_once_with( + mock_download_content.assert_called_once_with( item[0], item[1], external_files.NETWORK_TIMEOUT ) -@patch("esphome.external_files.download_content") def test_download_content_many_runs_in_parallel( - mock_download: MagicMock, setup_core: Path + mock_download_content: MagicMock, setup_core: Path ) -> None: """Multiple items should run concurrently — total wall time ≈ max latency.""" import threading @@ -320,19 +335,18 @@ def test_download_content_many_runs_in_parallel( barrier.wait(timeout=2.0) return b"" - mock_download.side_effect = slow_download + 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.download_content_many(items, max_workers=4) - assert mock_download.call_count == 3 + assert mock_download_content.call_count == 3 -@patch("esphome.external_files.download_content") def test_download_content_many_propagates_errors( - mock_download: MagicMock, setup_core: Path + mock_download_content: MagicMock, setup_core: Path ) -> None: """An exception from any worker must propagate out of download_content_many.""" @@ -341,7 +355,7 @@ def test_download_content_many_propagates_errors( raise Invalid(f"could not download {url}") return b"" - mock_download.side_effect = fake_download + mock_download_content.side_effect = fake_download items = [ ("https://example.com/ok", setup_core / "ok"), ("https://example.com/bad", setup_core / "bad"), @@ -350,9 +364,8 @@ def test_download_content_many_propagates_errors( external_files.download_content_many(items) -@patch("esphome.external_files.download_content") def test_download_content_many_dedupes_by_path( - mock_download: MagicMock, setup_core: Path + mock_download_content: MagicMock, setup_core: Path ) -> None: """Two items pointing at the same cache path must collapse to one download -- otherwise concurrent writes race on the same file. @@ -364,16 +377,15 @@ def test_download_content_many_dedupes_by_path( ("https://example.com/a", path), ] external_files.download_content_many(items) - assert mock_download.call_count == 1 + assert mock_download_content.call_count == 1 # First-seen URL wins, matching dict-insertion order. - args, _ = mock_download.call_args + args, _ = mock_download_content.call_args assert args[0] == "https://example.com/a" assert args[1] == path -@patch("esphome.external_files.download_content") def test_download_content_many_clamps_invalid_max_workers( - mock_download: MagicMock, setup_core: Path + mock_download_content: MagicMock, setup_core: Path ) -> None: """`max_workers <= 0` must not raise from ThreadPoolExecutor; it should be clamped up to at least 1 worker. @@ -383,12 +395,11 @@ def test_download_content_many_clamps_invalid_max_workers( ("https://example.com/b", setup_core / "b"), ] external_files.download_content_many(items, max_workers=0) - assert mock_download.call_count == 2 + assert mock_download_content.call_count == 2 -@patch("esphome.external_files.download_content_many") def test_download_web_files_in_config_filters_and_dispatches( - mock_many: MagicMock, setup_core: Path + mock_download_content_many: MagicMock, setup_core: Path ) -> None: """Only `file.type == "web"` entries should be forwarded to download_content_many, and the unmodified config should be returned so @@ -407,16 +418,15 @@ def test_download_web_files_in_config_filters_and_dispatches( result = external_files.download_web_files_in_config(config, path_for) assert result is config - mock_many.assert_called_once() - assert list(mock_many.call_args[0][0]) == [ + 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"), ] -@patch("esphome.external_files.download_content_many") def test_download_web_files_in_config_no_web_entries( - mock_many: MagicMock, setup_core: Path + mock_download_content_many: MagicMock, setup_core: Path ) -> None: """A config with no web entries should still call through to download_content_many (which is itself a no-op for empty input) so the @@ -424,5 +434,5 @@ def test_download_web_files_in_config_no_web_entries( """ config = [{"file": {"type": "local", "path": "/tmp/a"}}] external_files.download_web_files_in_config(config, lambda _: setup_core / "x") - mock_many.assert_called_once() - assert list(mock_many.call_args[0][0]) == [] + mock_download_content_many.assert_called_once() + assert list(mock_download_content_many.call_args[0][0]) == [] From 2e037308de13966b66811367b0b30e4960295bf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 10:00:44 -0500 Subject: [PATCH 0680/2214] Address Copilot follow-up on PR #16021 - download_content_many docstring corrected: ex.map yields in input order, so worker exceptions propagate when iteration reaches their position, not necessarily as the first failure. - WEB_TYPE comment no longer claims TYPE_WEB exists in esphome.const -- it doesn't; each component just defines its own local literal. --- esphome/external_files.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index 9618757529..22ce617aff 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -137,8 +137,10 @@ def download_content_many( """Run `download_content` for each (url, path) pair concurrently. Wall time drops from `sum(latency)` to roughly `max(latency)` for cached - files where the HEAD round-trip dominates. The first exception raised by - any worker is propagated; remaining workers complete before this returns. + files where the HEAD round-trip dominates. Worker exceptions propagate + when iteration reaches the corresponding input item (`ex.map` yields + results in input order), and remaining workers complete before this + returns. 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 @@ -166,8 +168,9 @@ def download_content_many( ) -# String constant rather than `from .const import TYPE_WEB` because each -# component defines its own `TYPE_WEB = "web"` literal in its module scope. +# 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. WEB_TYPE = "web" From 2def20c646bb90f4173ea88977d3c4f88b467f8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 10:02:44 -0500 Subject: [PATCH 0681/2214] Use partial() for download_web_files_in_config call sites; collapse dedup loop into a dict comp - Both audio_file and speaker.media_player now use functools.partial to bind path_for=_compute_local_file_path instead of an inline lambda. - download_content_many's dedup loop becomes a dict comprehension. Last-URL wins now (dict-comp semantics) instead of first-URL; in practice duplicate paths only arise when the URL itself is duplicated, so the choice is meaningless. Test updated accordingly. --- esphome/components/audio_file/__init__.py | 3 ++- esphome/components/speaker/media_player/__init__.py | 5 ++++- esphome/external_files.py | 11 +++++------ tests/unit_tests/test_external_files.py | 6 +++--- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index c2f1378f9d..88be6db168 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from functools import partial import hashlib import logging from pathlib import Path @@ -199,7 +200,7 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType] CONFIG_SCHEMA = cv.All( cv.only_on_esp32, cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), - lambda c: download_web_files_in_config(c, _compute_local_file_path), + partial(download_web_files_in_config, path_for=_compute_local_file_path), _validate_supported_local_file, ) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index bbb51c03de..fbc83ef12f 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -1,5 +1,6 @@ """Speaker Media Player Setup.""" +from functools import partial import hashlib import logging from pathlib import Path @@ -277,7 +278,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), cv.Optional(CONF_FILES): cv.All( cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), - lambda c: download_web_files_in_config(c, _compute_local_file_path), + partial( + download_web_files_in_config, path_for=_compute_local_file_path + ), ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( cv.boolean, cv.requires_component(psram.DOMAIN) diff --git a/esphome/external_files.py b/esphome/external_files.py index 22ce617aff..132fe76a9e 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -144,13 +144,12 @@ def download_content_many( 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. + 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. """ - seen: dict[Path, str] = {} - for url, path in items: - if path in seen: - continue - seen[path] = url + seen: dict[Path, str] = {path: url for url, path in items} if not seen: return if len(seen) == 1: diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index fd27f70315..8965c04b30 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -368,7 +368,9 @@ def test_download_content_many_dedupes_by_path( mock_download_content: MagicMock, setup_core: Path ) -> None: """Two items pointing at the same cache path must collapse to one - download -- otherwise concurrent writes race on the same file. + download -- otherwise concurrent writes race on the same file. Which + URL wins doesn't matter (in practice duplicate paths only arise when + the URL is duplicated), so we only assert the call count and path. """ path = setup_core / "shared" items = [ @@ -378,9 +380,7 @@ def test_download_content_many_dedupes_by_path( ] external_files.download_content_many(items) assert mock_download_content.call_count == 1 - # First-seen URL wins, matching dict-insertion order. args, _ = mock_download_content.call_args - assert args[0] == "https://example.com/a" assert args[1] == path From b78bca0dc74871da8ea62db84818a22c65ca06c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 10:09:38 -0500 Subject: [PATCH 0682/2214] Simplify _read_etag, walrus the etag/new_etag binds, fixturize requests + helper patches - _read_etag collapses into a single try/except that handles both stat and read failures (and the unlink) uniformly. - _mtime_seconds() helper compares mtimes at whole-second resolution instead of nanoseconds. Sub-second precision varies by filesystem (FAT does 2s, NTFS 100ns, APFS/ext4 ns); whole seconds is the only resolution every supported filesystem can preserve through a set+read round-trip. - has_remote_file_changed: walrus-assign `etag` and `new_etag` instead of an explicit binding + immediate check. - New test fixtures `mock_requests_head`, `mock_requests_get`, `mock_has_remote_file_changed`, `mock_write_file` replace the per-test `@patch(...)` decorator stacks. Tests are simpler and the fixtures can be reused by future tests in the file. --- esphome/external_files.py | 56 ++++---- tests/unit_tests/test_external_files.py | 180 +++++++++++++----------- 2 files changed, 127 insertions(+), 109 deletions(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index 2fa2bea6e5..bd29dc93b1 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -31,37 +31,35 @@ def _etag_sidecar_path(local_file_path: Path) -> Path: return local_file_path.parent / f".{local_file_path.name}.etag" -def _read_etag(local_file_path: Path) -> str | None: - """Return the cached ETag if it still describes the current cached file. +def _mtime_seconds(path: Path) -> int: + """Return `path`'s mtime as integer seconds. - The sidecar's mtime is kept in sync with the cache file's mtime by - `_write_etag`. If they disagree, the cache file was modified out-of-band - (manual edit, restored from backup, replaced by another tool) and the - ETag no longer corresponds to its contents, so treat the sidecar as - stale and delete it. + Whole seconds is the common-denominator resolution across all + filesystems we run on (FAT/exFAT 2s, NTFS 100ns, APFS/ext4 ns), so + comparisons survive setting+reading round-trips that would lose + sub-second precision on lower-resolution filesystems. + """ + return int(path.stat().st_mtime) + + +def _read_etag(local_file_path: Path) -> str | None: + """Return the cached ETag if its sidecar's mtime still matches the cache + file's. A mismatch means the cache file was modified out-of-band, so the + ETag no longer describes its contents -- delete the stale sidecar and + return None. """ etag_path = _etag_sidecar_path(local_file_path) try: - etag_mtime_ns = etag_path.stat().st_mtime_ns - file_mtime_ns = local_file_path.stat().st_mtime_ns - except OSError: - return None - if etag_mtime_ns != file_mtime_ns: - _LOGGER.debug( - "ETag sidecar mtime (%d) does not match cached file mtime (%d) at %s; " - "treating ETag as stale", - etag_mtime_ns, - file_mtime_ns, - local_file_path, - ) - with contextlib.suppress(OSError): + if _mtime_seconds(etag_path) != _mtime_seconds(local_file_path): + _LOGGER.debug( + "ETag sidecar mtime mismatch at %s; treating as stale", + local_file_path, + ) etag_path.unlink() - return None - try: - etag = etag_path.read_text().strip() + return None + return etag_path.read_text().strip() or None except OSError: return None - return etag or None def _write_etag(local_file_path: Path, etag: str | None) -> None: @@ -79,8 +77,8 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: # Pin the sidecar's mtime to the cache file's mtime. _read_etag relies on # this match to detect out-of-band edits to the cache file. try: - file_mtime_ns = local_file_path.stat().st_mtime_ns - os.utime(etag_path, ns=(file_mtime_ns, file_mtime_ns)) + file_mtime = _mtime_seconds(local_file_path) + os.utime(etag_path, (file_mtime, file_mtime)) except OSError as e: _LOGGER.debug( "Could not sync ETag sidecar mtime for %s: %s", local_file_path, e @@ -100,8 +98,7 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: IF_MODIFIED_SINCE: local_modification_time_str, CACHE_CONTROL: CACHE_CONTROL_MAX_AGE + "3600", } - etag = _read_etag(local_file_path) - if etag: + if etag := _read_etag(local_file_path): headers[IF_NONE_MATCH] = etag response = requests.head( url, headers=headers, timeout=NETWORK_TIMEOUT, allow_redirects=True @@ -120,8 +117,7 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: "has_remote_file_changed: File not modified since %s", local_modification_time_str, ) - new_etag = response.headers.get(ETAG) - if new_etag and new_etag != etag: + if (new_etag := response.headers.get(ETAG)) and new_etag != etag: _write_etag(local_file_path, new_etag) return False _LOGGER.debug("has_remote_file_changed: File modified") diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 301fa51272..bd00e7e14b 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -19,11 +19,47 @@ def _seed_etag(cache_file: Path, etag: str) -> Path: """ sidecar = external_files._etag_sidecar_path(cache_file) sidecar.write_text(etag) - file_mtime_ns = cache_file.stat().st_mtime_ns - os.utime(sidecar, ns=(file_mtime_ns, file_mtime_ns)) + file_mtime = int(cache_file.stat().st_mtime) + os.utime(sidecar, (file_mtime, file_mtime)) return sidecar +@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. + """ + with patch("esphome.external_files.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. + """ + with patch("esphome.external_files.requests.get") as m: + yield m + + +@pytest.fixture +def mock_has_remote_file_changed() -> MagicMock: + """Patch `external_files.has_remote_file_changed` so download tests can + control the conditional check independently from the GET path. + """ + with patch("esphome.external_files.has_remote_file_changed") as m: + yield m + + +@pytest.fixture +def mock_write_file() -> MagicMock: + """Patch `external_files.write_file` so atomic-write failures can be + injected without involving the real filesystem helper. + """ + with patch("esphome.external_files.write_file") as m: + yield m + + def test_compute_local_file_dir(setup_core: Path) -> None: """Test compute_local_file_dir creates and returns correct path.""" domain = "font" @@ -100,9 +136,8 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None: assert result is False -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_not_modified( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed returns False when file not modified.""" test_file = setup_core / "cached.txt" @@ -111,23 +146,22 @@ def test_has_remote_file_changed_not_modified( mock_response = MagicMock() mock_response.status_code = 304 mock_response.headers = {} - mock_head.return_value = mock_response + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" result = external_files.has_remote_file_changed(url, test_file) assert result is False - mock_head.assert_called_once() + mock_requests_head.assert_called_once() - call_args = mock_head.call_args + call_args = mock_requests_head.call_args headers = call_args[1]["headers"] assert external_files.IF_MODIFIED_SINCE in headers assert external_files.CACHE_CONTROL in headers -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_modified( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed returns True when file modified.""" test_file = setup_core / "cached.txt" @@ -136,7 +170,7 @@ def test_has_remote_file_changed_modified( mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {} - mock_head.return_value = mock_response + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" result = external_files.has_remote_file_changed(url, test_file) @@ -154,15 +188,16 @@ def test_has_remote_file_changed_no_local_file(setup_core: Path) -> None: assert result is True -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_network_error( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed returns False on network error when file is cached.""" test_file = setup_core / "cached.txt" test_file.write_text("cached content") - mock_head.side_effect = requests.exceptions.RequestException("Network error") + mock_requests_head.side_effect = requests.exceptions.RequestException( + "Network error" + ) url = "https://example.com/file.txt" result = external_files.has_remote_file_changed(url, test_file) @@ -170,9 +205,8 @@ def test_has_remote_file_changed_network_error( assert result is False -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_timeout( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed respects timeout.""" test_file = setup_core / "cached.txt" @@ -181,18 +215,17 @@ def test_has_remote_file_changed_timeout( mock_response = MagicMock() mock_response.status_code = 304 mock_response.headers = {} - mock_head.return_value = mock_response + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" external_files.has_remote_file_changed(url, test_file) - call_args = mock_head.call_args + call_args = mock_requests_head.call_args assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_uses_etag( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed sends If-None-Match when ETag is cached.""" test_file = setup_core / "cached.txt" @@ -202,19 +235,18 @@ def test_has_remote_file_changed_uses_etag( mock_response = MagicMock() mock_response.status_code = 304 mock_response.headers = {} - mock_head.return_value = mock_response + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" result = external_files.has_remote_file_changed(url, test_file) assert result is False - headers = mock_head.call_args[1]["headers"] + headers = mock_requests_head.call_args[1]["headers"] assert headers[external_files.IF_NONE_MATCH] == '"abc123"' -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_no_etag_no_if_none_match( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed omits If-None-Match when no ETag is cached.""" test_file = setup_core / "cached.txt" @@ -223,18 +255,17 @@ def test_has_remote_file_changed_no_etag_no_if_none_match( mock_response = MagicMock() mock_response.status_code = 304 mock_response.headers = {} - mock_head.return_value = mock_response + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" external_files.has_remote_file_changed(url, test_file) - headers = mock_head.call_args[1]["headers"] + headers = mock_requests_head.call_args[1]["headers"] assert external_files.IF_NONE_MATCH not in headers -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_refreshes_etag_on_304( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed updates the cached ETag when the 304 sends a new one.""" test_file = setup_core / "cached.txt" @@ -244,7 +275,7 @@ def test_has_remote_file_changed_refreshes_etag_on_304( mock_response = MagicMock() mock_response.status_code = 304 mock_response.headers = {external_files.ETAG: '"new"'} - mock_head.return_value = mock_response + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" external_files.has_remote_file_changed(url, test_file) @@ -252,9 +283,8 @@ def test_has_remote_file_changed_refreshes_etag_on_304( assert external_files._etag_sidecar_path(test_file).read_text() == '"new"' -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_ignores_etag_when_mtime_diverges( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """If the cache file was edited out-of-band (mtime no longer matches the sidecar's), the cached ETag must not be used -- it no longer describes the @@ -264,49 +294,46 @@ def test_has_remote_file_changed_ignores_etag_when_mtime_diverges( test_file.write_text("cached content") sidecar = _seed_etag(test_file, '"abc123"') - # Simulate an out-of-band edit to the cache file -- mtime advances but the - # sidecar is left untouched, so the recorded ETag is now stale. + # Simulate an out-of-band edit to the cache file -- mtime advances by a + # full second (so it diverges at whole-second resolution) but the sidecar + # is left untouched, so the recorded ETag is now stale. file_stat = test_file.stat() - os.utime( - test_file, ns=(file_stat.st_atime_ns, file_stat.st_mtime_ns + 1_000_000_000) - ) + os.utime(test_file, (file_stat.st_atime, file_stat.st_mtime + 1)) mock_response = MagicMock() mock_response.status_code = 304 mock_response.headers = {} - mock_head.return_value = mock_response + mock_requests_head.return_value = mock_response external_files.has_remote_file_changed("https://example.com/file.txt", test_file) - headers = mock_head.call_args[1]["headers"] + headers = mock_requests_head.call_args[1]["headers"] assert external_files.IF_NONE_MATCH not in headers # Stale sidecar should be removed so future calls don't keep paying the # mtime-comparison cost on a known-bad sidecar. assert not sidecar.exists() -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_pins_etag_mtime_to_file_mtime( - mock_has_changed: MagicMock, - mock_get: MagicMock, + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, setup_core: Path, ) -> None: """After a successful download, the sidecar's mtime must equal the cache file's mtime so `_read_etag` accepts it on the next call. """ test_file = setup_core / "fresh.txt" - mock_has_changed.return_value = True + mock_has_remote_file_changed.return_value = True mock_response = MagicMock() mock_response.content = b"fresh content" mock_response.headers = {external_files.ETAG: '"deadbeef"'} mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response + mock_requests_get.return_value = mock_response external_files.download_content("https://example.com/file.txt", test_file) sidecar = external_files._etag_sidecar_path(test_file) - assert sidecar.stat().st_mtime_ns == test_file.stat().st_mtime_ns + assert int(sidecar.stat().st_mtime) == int(test_file.stat().st_mtime) def test_compute_local_file_dir_creates_parent_dirs(setup_core: Path) -> None: @@ -334,10 +361,10 @@ def test_is_file_recent_handles_float_seconds(setup_core: Path) -> None: assert result is True -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_with_network_error_uses_cache( - mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, ) -> None: """Test download_content uses cached file when network fails.""" test_file = setup_core / "cached.txt" @@ -345,8 +372,10 @@ def test_download_content_with_network_error_uses_cache( test_file.write_bytes(cached_content) # Simulate file has changed, so it tries to download - mock_has_changed.return_value = True - mock_get.side_effect = requests.exceptions.RequestException("Network error") + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException( + "Network error" + ) url = "https://example.com/file.txt" result = external_files.download_content(url, test_file) @@ -354,17 +383,19 @@ def test_download_content_with_network_error_uses_cache( assert result == cached_content -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_with_network_error_no_cache_fails( - mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, ) -> None: """Test download_content raises error when network fails and no cache exists.""" test_file = setup_core / "nonexistent.txt" # Simulate file has changed (doesn't exist), so it tries to download - mock_has_changed.return_value = True - mock_get.side_effect = requests.exceptions.RequestException("Network error") + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException( + "Network error" + ) url = "https://example.com/file.txt" @@ -372,11 +403,9 @@ def test_download_content_with_network_error_no_cache_fails( external_files.download_content(url, test_file) -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_skip_external_update_uses_cache( - mock_has_changed: MagicMock, - mock_get: MagicMock, + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, setup_core: Path, ) -> None: """Test download_content skips network checks when CORE.skip_external_update is set.""" @@ -389,27 +418,25 @@ def test_download_content_skip_external_update_uses_cache( result = external_files.download_content(url, test_file) assert result == cached_content - mock_has_changed.assert_not_called() - mock_get.assert_not_called() + mock_has_remote_file_changed.assert_not_called() + mock_requests_get.assert_not_called() -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_skip_external_update_downloads_when_missing( - mock_has_changed: MagicMock, - mock_get: MagicMock, + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, setup_core: Path, ) -> None: """Test download_content still downloads when file is missing, even with skip_external_update.""" test_file = setup_core / "missing.txt" new_content = b"fresh content" - mock_has_changed.return_value = True + mock_has_remote_file_changed.return_value = True mock_response = MagicMock() mock_response.content = new_content mock_response.headers = {} mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response + mock_requests_get.return_value = mock_response CORE.skip_external_update = True url = "https://example.com/file.txt" @@ -419,23 +446,21 @@ def test_download_content_skip_external_update_downloads_when_missing( assert test_file.read_bytes() == new_content -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_saves_etag( - mock_has_changed: MagicMock, - mock_get: MagicMock, + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, setup_core: Path, ) -> None: """Test download_content writes the ETag sidecar after a successful download.""" test_file = setup_core / "fresh.txt" new_content = b"fresh content" - mock_has_changed.return_value = True + mock_has_remote_file_changed.return_value = True mock_response = MagicMock() mock_response.content = new_content mock_response.headers = {external_files.ETAG: '"deadbeef"'} mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response + mock_requests_get.return_value = mock_response url = "https://example.com/file.txt" external_files.download_content(url, test_file) @@ -443,12 +468,9 @@ def test_download_content_saves_etag( assert external_files._etag_sidecar_path(test_file).read_text() == '"deadbeef"' -@patch("esphome.external_files.write_file") -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_atomic_write_no_partial_on_failure( - mock_has_changed: MagicMock, - mock_get: MagicMock, + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, mock_write_file: MagicMock, setup_core: Path, ) -> None: @@ -463,12 +485,12 @@ def test_download_content_atomic_write_no_partial_on_failure( original_content = b"original content" test_file.write_bytes(original_content) - mock_has_changed.return_value = True + mock_has_remote_file_changed.return_value = True mock_response = MagicMock() mock_response.content = b"new content" mock_response.headers = {} mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response + mock_requests_get.return_value = mock_response mock_write_file.side_effect = EsphomeError("disk full") From a90d840c26f38b6916974cf61c7b407b814be06d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 10:11:52 -0500 Subject: [PATCH 0683/2214] Address Copilot follow-up on PR #16021 - has_remote_file_changed now accepts a `timeout` argument and passes it to requests.head; download_content forwards its own timeout in. Without this, callers couldn't actually control the end-to-end timeout -- the GET respected it but the conditional HEAD didn't. - Replace the (path, url) -> (url, path) lambda inside ex.map with a named local helper that unpacks the tuple. Easier to read; the awkward (item[1], item[0]) indexing was a maintenance hazard. --- esphome/external_files.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index 132fe76a9e..80644e3fec 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -25,7 +25,9 @@ CONTENT_DISPOSITION = "content-disposition" TEMP_DIR = "temp" -def has_remote_file_changed(url: str, local_file_path: Path) -> bool: +def has_remote_file_changed( + url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT +) -> bool: if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) try: @@ -39,7 +41,7 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: CACHE_CONTROL: CACHE_CONTROL_MAX_AGE + "3600", } response = requests.head( - url, headers=headers, timeout=NETWORK_TIMEOUT, allow_redirects=True + url, headers=headers, timeout=timeout, allow_redirects=True ) _LOGGER.debug( @@ -88,7 +90,7 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) return path.read_bytes() - if not has_remote_file_changed(url, path): + if not has_remote_file_changed(url, path, timeout): _LOGGER.debug("Remote file has not changed %s", url) return path.read_bytes() @@ -156,15 +158,17 @@ def download_content_many( path, url = next(iter(seen.items())) download_content(url, path, timeout) 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))) with ThreadPoolExecutor(max_workers=workers) as ex: # list() forces iteration so exceptions surface here, not silently. - list( - ex.map( - lambda item: download_content(item[1], item[0], timeout), - seen.items(), - ) - ) + list(ex.map(_download_one, seen.items())) # Each component that uses external_files defines its own local From b303cc68f6078b5ff7f08b04c1e1fac029a991a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 10:15:54 -0500 Subject: [PATCH 0684/2214] Cover the two _write_etag error branches EsphomeError from write_file and OSError from os.utime are both swallowed and logged at DEBUG; add tests so neither branch can regress silently. --- tests/unit_tests/test_external_files.py | 50 ++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index bd00e7e14b..f4d268abe0 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -10,7 +10,7 @@ import requests from esphome import external_files from esphome.config_validation import Invalid -from esphome.core import CORE, TimePeriod +from esphome.core import CORE, EsphomeError, TimePeriod def _seed_etag(cache_file: Path, etag: str) -> Path: @@ -336,6 +336,54 @@ def test_download_content_pins_etag_mtime_to_file_mtime( assert int(sidecar.stat().st_mtime) == int(test_file.stat().st_mtime) +def test_write_etag_swallows_write_file_failure( + mock_write_file: MagicMock, setup_core: Path, caplog: pytest.LogCaptureFixture +) -> None: + """If `write_file` raises, _write_etag must not propagate -- ETag + persistence is best-effort and a failure here must not abort the + surrounding download. + """ + cache_file = setup_core / "cached.txt" + cache_file.write_text("cached content") + mock_write_file.side_effect = EsphomeError("disk full") + + with caplog.at_level("DEBUG", logger="esphome.external_files"): + external_files._write_etag(cache_file, '"abc123"') + + assert "Could not save ETag" in caplog.text + # Sidecar wasn't created, since write_file was mocked to fail before + # reaching the os.utime step. + assert not external_files._etag_sidecar_path(cache_file).exists() + + +def test_write_etag_swallows_utime_failure( + setup_core: Path, caplog: pytest.LogCaptureFixture +) -> None: + """If `os.utime` raises while pinning the sidecar's mtime, _write_etag + must not propagate. The sidecar is still written; if its mtime later + fails to match the cache file, `_read_etag` will discard it on next + read. + """ + cache_file = setup_core / "cached.txt" + cache_file.write_text("cached content") + + with ( + patch( + "esphome.external_files.os.utime", + side_effect=PermissionError("nope"), + ), + caplog.at_level("DEBUG", logger="esphome.external_files"), + ): + external_files._write_etag(cache_file, '"abc123"') + + assert "Could not sync ETag sidecar mtime" in caplog.text + # write_file succeeded, so the sidecar exists with the new value even + # though we couldn't pin its mtime. + sidecar = external_files._etag_sidecar_path(cache_file) + assert sidecar.exists() + assert sidecar.read_text() == '"abc123"' + + def test_compute_local_file_dir_creates_parent_dirs(setup_core: Path) -> None: """Test compute_local_file_dir creates parent directories.""" domain = "level1/level2/level3/level4" From 24a3c2202ff60ccaae8367bbb5e6b4e55368bfef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 10:35:06 -0500 Subject: [PATCH 0685/2214] [core] Catch body-read errors in download_content `requests.Response.content` reads the body lazily, so chunked-decode, gzip-decode, and mid-stream connection drops all surface as RequestException subclasses on first access -- not from requests.get itself. The previous code accessed `.content` outside the surrounding try/except, so any of those (rare but real) errors would propagate out of download_content instead of falling back to the cached file or raising the user-friendly cv.Invalid. Move `data = req.content` inside the try block so the existing error path handles it. Two new tests cover the with-cache and no-cache branches using a `ChunkedEncodingError` injected on `.content`. --- esphome/external_files.py | 6 ++- tests/unit_tests/test_external_files.py | 53 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index b6f6149ebb..1dd7ce16b8 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -102,6 +102,11 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by headers={"User-agent": f"ESPHome/{__version__} (https://esphome.io)"}, ) req.raise_for_status() + # `.content` reads the body lazily; chunked-decode, gzip-decode, + # and mid-stream connection errors all surface here as + # RequestException subclasses, so this needs the same fall-back + # treatment as the request itself. + data = req.content except requests.exceptions.RequestException as e: if path.exists(): _LOGGER.warning( @@ -113,6 +118,5 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by raise cv.Invalid(f"Could not download from {url}: {e}") from e path.parent.mkdir(parents=True, exist_ok=True) - data = req.content path.write_bytes(data) return data diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 4b0826db04..f485316097 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -238,6 +238,59 @@ def test_download_content_with_network_error_no_cache_fails( external_files.download_content(url, test_file) +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_with_body_read_error_uses_cache( + mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path +) -> None: + """Body-read errors (chunked-decode/gzip-decode/mid-stream connection + drop) raise RequestException subclasses on `.content` access, not from + `requests.get` itself. They must follow the same fall-back-to-cache + path as a connect-time failure. + """ + test_file = setup_core / "cached.txt" + cached_content = b"cached content" + test_file.write_bytes(cached_content) + + mock_has_changed.return_value = True + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + type(mock_response).content = property( + lambda self: (_ for _ in ()).throw( + requests.exceptions.ChunkedEncodingError("body truncated") + ) + ) + mock_get.return_value = mock_response + + result = external_files.download_content("https://example.com/file.txt", test_file) + + assert result == cached_content + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_with_body_read_error_no_cache_fails( + mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path +) -> None: + """A body-read failure with no cache available must surface as a + cv.Invalid, same as a connect-time failure with no cache. + """ + test_file = setup_core / "nonexistent.txt" + + mock_has_changed.return_value = True + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + type(mock_response).content = property( + lambda self: (_ for _ in ()).throw( + requests.exceptions.ChunkedEncodingError("body truncated") + ) + ) + mock_get.return_value = mock_response + + with pytest.raises(Invalid, match="Could not download from.*body truncated"): + external_files.download_content("https://example.com/file.txt", test_file) + + @patch("esphome.external_files.requests.get") @patch("esphome.external_files.has_remote_file_changed") def test_download_content_skip_external_update_uses_cache( From 6e131821208d4a4129d9c61d6ad466c2cf9762d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 10:39:29 -0500 Subject: [PATCH 0686/2214] Address Copilot review on PR #16023 Replace `type(mock_response).content = property(...)` with a tiny _BodyReadErrorResponse stub class. The previous form mutates MagicMock's class globally, which can leak into every other MagicMock-using test in this file. --- tests/unit_tests/test_external_files.py | 37 +++++++++++++++---------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index f485316097..e81a8349db 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -238,6 +238,25 @@ def test_download_content_with_network_error_no_cache_fails( external_files.download_content(url, test_file) +class _BodyReadErrorResponse: + """Stand-in for `requests.Response` whose `.content` raises on access. + + A small dedicated stub avoids mutating `MagicMock`'s class with a + `property` (which would leak across every other MagicMock-based test + in this file). + """ + + def __init__(self, exc: Exception) -> None: + self._exc = exc + + def raise_for_status(self) -> None: + return None + + @property + def content(self) -> bytes: + raise self._exc + + @patch("esphome.external_files.requests.get") @patch("esphome.external_files.has_remote_file_changed") def test_download_content_with_body_read_error_uses_cache( @@ -253,14 +272,9 @@ def test_download_content_with_body_read_error_uses_cache( test_file.write_bytes(cached_content) mock_has_changed.return_value = True - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - type(mock_response).content = property( - lambda self: (_ for _ in ()).throw( - requests.exceptions.ChunkedEncodingError("body truncated") - ) + mock_get.return_value = _BodyReadErrorResponse( + requests.exceptions.ChunkedEncodingError("body truncated") ) - mock_get.return_value = mock_response result = external_files.download_content("https://example.com/file.txt", test_file) @@ -278,14 +292,9 @@ def test_download_content_with_body_read_error_no_cache_fails( test_file = setup_core / "nonexistent.txt" mock_has_changed.return_value = True - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - type(mock_response).content = property( - lambda self: (_ for _ in ()).throw( - requests.exceptions.ChunkedEncodingError("body truncated") - ) + mock_get.return_value = _BodyReadErrorResponse( + requests.exceptions.ChunkedEncodingError("body truncated") ) - mock_get.return_value = mock_response with pytest.raises(Invalid, match="Could not download from.*body truncated"): external_files.download_content("https://example.com/file.txt", test_file) From e1fbb6ac5a2d61fb9e6b263b1771f36f076539a6 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:40:00 +0200 Subject: [PATCH 0687/2214] Validate nvs partition, only allow partition-table option for OTA updates --- esphome/__main__.py | 7 +++++- .../components/esphome/ota/ota_esphome.cpp | 3 +-- .../components/ota/ota_backend_esp_idf.cpp | 24 ++++++++++++------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d0d748a1ec..411108e554 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1078,6 +1078,11 @@ def upload_program( port_type = get_port_type(host) + if port_type != PortType.NETWORK and getattr(args, "partition_table", False): + raise EsphomeError( + f"The option --partition-table can only be used for Over The Air updates." + ) + if port_type == PortType.BOOTSEL: exit_code = upload_using_picotool(config) # Return None for device - BOOTSEL can't be used for logging, @@ -1788,7 +1793,7 @@ def parse_args(argv): ) parser_upload.add_argument( "--partition-table", - help="Upload as partition table", + help="Upload as partition table (OTA).", action="store_true", ) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e5db0df462..91aee0cea6 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -230,8 +230,7 @@ void ESPHomeOTAComponent::handle_handshake_() { this->extended_proto_ = this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; if (this->extended_proto_) { // If the client supports the extended protocol, send 2 bytes: response type and server feature flags - this->handshake_buf_[0] = - ota::OTA_RESPONSE_FEATURE_FLAGS; // indicates the following byte contains feature flags + this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; // supported if USE_OTA_PARTITIONS if (supports_compression) { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 6a2afac978..2118234ae8 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -245,9 +245,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { int app_partitions_found = 0; int new_app_part_index = -1; int new_app_part_index_with_copy = -1; - int new_otadata_part_index = -1; - bool otadata_overlap = true; - const esp_partition_t *app_copy_target_part{nullptr}; + const esp_partition_t *app_copy_target_part = nullptr; + bool otadata_partition_found = false; + bool otadata_overlap = false; + bool nvs_partition_found = false; for (int i = 0; i < num_partitions; i++) { // Iterate over new partition table const esp_partition_info_t *new_part = &new_partition_table[i]; if (new_part->type == ESP_PARTITION_TYPE_APP) { @@ -275,18 +276,23 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_partition_iterator_release(it); } } - } else if (new_part->type == ESP_PARTITION_TYPE_DATA && new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { - // Found the otadata partition in the new partition table - new_otadata_part_index = i; - otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); + } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { + if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { + // Found the otadata partition in the new partition table + otadata_partition_found = true; + otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); + } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && strcmp((char*)new_part->label, "nvs") == 0) { + // Found the nvs partition in the new partition table + nvs_partition_found = true; + } } } if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - if (app_partitions_found < 2 || new_otadata_part_index == -1) { - ESP_LOGE(TAG, "New partition table is missing the required app or otadata partitions"); + if (app_partitions_found < 2 || !otadata_partition_found || !nvs_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required app, otadata or nvs partitions"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (otadata_overlap) { From ef422304127ffa52b6e9a57d7f4cabb3fcbc5d3b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:41:16 +0000 Subject: [PATCH 0688/2214] [pre-commit.ci lite] apply automatic fixes --- esphome/__main__.py | 2 +- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 411108e554..c931eb179d 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1080,7 +1080,7 @@ def upload_program( if port_type != PortType.NETWORK and getattr(args, "partition_table", False): raise EsphomeError( - f"The option --partition-table can only be used for Over The Air updates." + "The option --partition-table can only be used for Over The Air updates." ) if port_type == PortType.BOOTSEL: diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2118234ae8..85c430cac6 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -281,7 +281,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Found the otadata partition in the new partition table otadata_partition_found = true; otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); - } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && strcmp((char*)new_part->label, "nvs") == 0) { + } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && strcmp((char *) new_part->label, "nvs") == 0) { // Found the nvs partition in the new partition table nvs_partition_found = true; } From 8b3e4dcc475b16c59287322123fef1ae83585608 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:07:00 +0200 Subject: [PATCH 0689/2214] Update test --- tests/unit_tests/test_main.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bfecd03ea4..fc526745f6 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1665,6 +1665,23 @@ def test_upload_program_ota_partition_table_with_file_arg( ) +def test_upload_program_serial_partition_table( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, +) -> None: + """Test serial upload with partition table option (unsupported).""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 0 + + config = {} + args = MockArgs(partition_table=True) + devices = ["/dev/ttyUSB0"] + + with pytest.raises(EsphomeError, match="The option --partition-table can only be used for Over The Air updates"): + upload_program(config, args, devices) + + def test_upload_program_ota_no_config( mock_get_port_type: Mock, ) -> None: From 51baefcd24dbb53f18da6890ab3e06a1435853d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:08:49 +0000 Subject: [PATCH 0690/2214] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fc526745f6..fdfa0f5d5f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1678,7 +1678,10 @@ def test_upload_program_serial_partition_table( args = MockArgs(partition_table=True) devices = ["/dev/ttyUSB0"] - with pytest.raises(EsphomeError, match="The option --partition-table can only be used for Over The Air updates"): + with pytest.raises( + EsphomeError, + match="The option --partition-table can only be used for Over The Air updates", + ): upload_program(config, args, devices) From 00d600f8b8c65923b5e4b55f74ffbd40952997c1 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:24:30 +0200 Subject: [PATCH 0691/2214] Fix bootloop if nvs partition is missing or has non-default label --- esphome/components/esp32/preferences.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 925c4e7662..d2c7e26515 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -70,8 +70,13 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } void ESP32Preferences::open() { - nvs_flash_init(); - esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); + esp_err_t err = nvs_flash_init(); + if (err != 0) { + this->nvs_handle = 0; + return; + } + + err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) return; From e1a5e908753f23dd7c3b2219b6197f9db8f1dcba Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:58:00 +0200 Subject: [PATCH 0692/2214] Remove ESP_LOGW --- esphome/components/esp32/preferences.cpp | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index d2c7e26515..95948d7b3b 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -70,17 +70,13 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } void ESP32Preferences::open() { - esp_err_t err = nvs_flash_init(); - if (err != 0) { - this->nvs_handle = 0; - return; - } - - err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); + // Can't use ESP_LOG... in this function because it is called before the logger is initialized + nvs_flash_init(); + esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) return; - ESP_LOGW(TAG, "nvs_open failed: %s - erasing NVS", esp_err_to_name(err)); + // nvs_open failed, erasing NVS nvs_flash_deinit(); nvs_flash_erase(); nvs_flash_init(); From e819c1c57829f674698e029566adcd409caf12f4 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:38:18 +0200 Subject: [PATCH 0693/2214] Fix WiFi if NVS failed to initialize --- esphome/components/wifi/wifi_component_esp_idf.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 29d135ce90..d91bc4ae6e 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,7 +179,10 @@ void WiFiComponent::wifi_pre_setup_() { #endif // USE_WIFI_AP wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - // cfg.nvs_enable = false; + if (global_preferences->nvs_handle == 0) { + ESP_LOGW(TAG, "NVS failed to initialize, starting WiFi with NVS disabled"); + cfg.nvs_enable = false; + } err = esp_wifi_init(&cfg); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); From 7770190f55e01468f0de6e5524f059de4ef93cb6 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:50:07 +0200 Subject: [PATCH 0694/2214] Use shorter warning message to save memory --- esphome/components/wifi/wifi_component_esp_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index d91bc4ae6e..82ecc80811 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -180,7 +180,7 @@ void WiFiComponent::wifi_pre_setup_() { wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); if (global_preferences->nvs_handle == 0) { - ESP_LOGW(TAG, "NVS failed to initialize, starting WiFi with NVS disabled"); + ESP_LOGW(TAG, "starting wifi without nvs"); cfg.nvs_enable = false; } err = esp_wifi_init(&cfg); From 7aaef02eeda69d69acf314a7dbd3bdca0c4d66fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 14:15:06 -0500 Subject: [PATCH 0695/2214] [core] Combine set_component_source_ + register_component_ into one call Add an optional `source_index` parameter to `App.register_component_()` so codegen can emit a single registration line per component instead of two. On a typical large config (~120 components) this saves ~120 lines in generated main.cpp and one virtual-method dispatch per component during setup(). --- esphome/core/application.h | 6 +++++- esphome/cpp_helpers.py | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index bc09f7d38c..c0b2639bd1 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -382,7 +382,11 @@ class Application { /// Register a component, detecting loop() override at compile time. /// Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance. - template void register_component_(T *comp) { + /// Optionally sets the component source index in the same call to avoid emitting + /// a separate set_component_source_() line in generated code. + template void register_component_(T *comp, uint8_t source_index = 0) { + if (source_index != 0) + comp->set_component_source_(source_index); this->register_component_impl_(comp, HasLoopOverride::value); } diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index f2bd3b92a3..b035e28a7a 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -197,9 +197,9 @@ async def register_component(var, config): ) if name is not None: idx = register_component_source(name) - add(var.set_component_source_(idx)) - - add(App.register_component_(var)) + add(App.register_component_(var, idx)) + else: + add(App.register_component_(var)) # Collect C++ type for compile-time looping component count comp_entries = CORE.data.setdefault("looping_component_entries", []) From e1ca77e025e5217c2f51d78e2b9966bbe1182db2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 14:19:43 -0500 Subject: [PATCH 0696/2214] Update tests for combined register_component_ call --- tests/component_tests/deep_sleep/test_deep_sleep.py | 2 +- tests/component_tests/ota/test_web_server_ota.py | 2 +- tests/unit_tests/test_cpp_helpers.py | 10 ++++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index 8c1278a332..84128d75d7 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -12,7 +12,7 @@ def test_deep_sleep_setup(generate_main): in main_cpp ) assert "new(deepsleep) deep_sleep::DeepSleepComponent();" in main_cpp - assert "App.register_component_(deepsleep);" in main_cpp + assert "App.register_component_(deepsleep, " in main_cpp def test_deep_sleep_sleep_duration(generate_main): diff --git a/tests/component_tests/ota/test_web_server_ota.py b/tests/component_tests/ota/test_web_server_ota.py index 4b3a4c705c..4b8b7540e8 100644 --- a/tests/component_tests/ota/test_web_server_ota.py +++ b/tests/component_tests/ota/test_web_server_ota.py @@ -27,7 +27,7 @@ def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None: assert "global_web_server_base" in main_cpp # Check component is registered - assert "App.register_component_(web_server_webserverotacomponent_id)" in main_cpp + assert "App.register_component_(web_server_webserverotacomponent_id" in main_cpp def test_web_server_ota_with_callbacks(generate_main: Callable[[str], str]) -> None: diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index a76ea21c23..e389b56ada 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -34,8 +34,9 @@ async def test_register_component(monkeypatch): actual = await ch.register_component(var, {}) assert actual is var - assert add_mock.call_count == 2 - app_mock.register_component_.assert_called_with(var) + assert add_mock.call_count == 1 + app_mock.register_component_.assert_called_once() + assert app_mock.register_component_.call_args.args[0] is var assert core_mock.component_ids == [] @@ -77,8 +78,9 @@ async def test_register_component__with_setup_priority(monkeypatch): assert actual is var add_mock.assert_called() - assert add_mock.call_count == 4 - app_mock.register_component_.assert_called_with(var) + assert add_mock.call_count == 3 + app_mock.register_component_.assert_called_once() + assert app_mock.register_component_.call_args.args[0] is var assert core_mock.component_ids == [] From 01e0cda3c0a67510be1071225c26dcd08c3d95c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 14:36:56 -0500 Subject: [PATCH 0697/2214] [core] Combine entity register + configure_entity_ into one call Add 4-arg overloads to App.register_() that call configure_entity_() and push_back in a single function. Codegen defers App.register_(var) emission until finalize_entity_strings(), which then emits a single combined App.register_(var, name, hash, packed) call instead of the previous two-statement pair (App.register_X(var); var->configure_entity_(...)). Apollo R-PRO-1 (ESP32-S3 IDF, 122 components, 164 entities), same toolchain: text: -1248 bytes main.cpp: -164 lines No behavior change. configure_entity_ remains protected on EntityBase; the Application class is now a friend so the new overloads can call it. --- .../alarm_control_panel/__init__.py | 8 +++-- esphome/components/binary_sensor/__init__.py | 3 +- esphome/components/button/__init__.py | 3 +- esphome/components/climate/__init__.py | 8 +++-- esphome/components/cover/__init__.py | 3 +- esphome/components/datetime/__init__.py | 8 +++-- esphome/components/event/__init__.py | 3 +- esphome/components/fan/__init__.py | 8 +++-- esphome/components/infrared/__init__.py | 4 +-- esphome/components/light/__init__.py | 8 +++-- esphome/components/lock/__init__.py | 8 +++-- esphome/components/media_player/__init__.py | 3 +- esphome/components/number/__init__.py | 3 +- .../components/radio_frequency/__init__.py | 4 +-- esphome/components/select/__init__.py | 8 +++-- esphome/components/sensor/__init__.py | 3 +- esphome/components/switch/__init__.py | 3 +- esphome/components/text/__init__.py | 8 +++-- esphome/components/text_sensor/__init__.py | 3 +- esphome/components/update/__init__.py | 3 +- esphome/components/valve/__init__.py | 3 +- esphome/components/water_heater/__init__.py | 8 +++-- esphome/core/application.h | 13 ++++++-- esphome/core/entity_base.h | 3 ++ esphome/core/entity_helpers.py | 32 +++++++++++++++++-- .../binary_sensor/test_binary_sensor.py | 2 +- tests/component_tests/button/test_button.py | 2 +- tests/component_tests/helpers.py | 20 +++++++++--- tests/component_tests/text/test_text.py | 2 +- .../text_sensor/test_text_sensor.py | 6 ++-- 30 files changed, 145 insertions(+), 48 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 9fcdf42ecb..2f5d4c7c2b 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -13,7 +13,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@grahambrown11", "@hwstar"] @@ -181,7 +185,7 @@ async def setup_alarm_control_panel_core_(var, config): async def register_alarm_control_panel(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_alarm_control_panel(var)) + queue_entity_register("alarm_control_panel", config) CORE.register_platform_component("alarm_control_panel", var) await setup_alarm_control_panel_core_(var, config) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 29ddbab02c..1456e5bc66 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -62,6 +62,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -624,7 +625,7 @@ async def setup_binary_sensor_core_(var, config): async def register_binary_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_binary_sensor(var)) + queue_entity_register("binary_sensor", config) CORE.register_platform_component("binary_sensor", var) await setup_binary_sensor_core_(var, config) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 2c19ea69b1..dd4fde5705 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -101,7 +102,7 @@ async def setup_button_core_(var, config): async def register_button(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_button(var)) + queue_entity_register("button", config) CORE.register_platform_component("button", var) await setup_button_core_(var, config) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index df77fa5c1c..0fdb18a92c 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -49,7 +49,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True @@ -442,7 +446,7 @@ async def setup_climate_core_(var, config): async def register_climate(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_climate(var)) + queue_entity_register("climate", config) CORE.register_platform_component("climate", var) await setup_climate_core_(var, config) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index fdfca55f0f..41efd2ba7a 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -39,6 +39,7 @@ from esphome.const import ( 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, ) @@ -232,7 +233,7 @@ async def setup_cover_core_(var, config): async def register_cover(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_cover(var)) + queue_entity_register("cover", config) CORE.register_platform_component("cover", var) await setup_cover_core_(var, config) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 895ac4e243..87997daa3d 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -22,7 +22,11 @@ from esphome.const import ( CONF_YEAR, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -160,7 +164,7 @@ async def register_datetime(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) entity_type = config[CONF_TYPE].lower() - cg.add(getattr(cg.App, f"register_{entity_type}")(var)) + queue_entity_register(entity_type, config) CORE.register_platform_component(entity_type, var) await setup_datetime_core_(var, config) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 9c9dd025b1..4cab1bff9b 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -108,7 +109,7 @@ async def setup_event_core_(var, config, *, event_types: list[str]): async def register_event(var, config, *, event_types: list[str]): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_event(var)) + queue_entity_register("event", config) CORE.register_platform_component("event", var) await setup_event_core_(var, config, event_types=event_types) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index ce1e55d36b..713f20fb95 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -32,7 +32,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) IS_PLATFORM_COMPONENT = True @@ -292,7 +296,7 @@ async def setup_fan_core_(var, config): async def register_fan(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_fan(var)) + queue_entity_register("fan", config) CORE.register_platform_component("fan", var) await setup_fan_core_(var, config) diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index 6a2a72fa5d..288d919920 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -12,7 +12,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import setup_entity +from esphome.core.entity_helpers import queue_entity_register, setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -55,7 +55,7 @@ async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: cg.add_define("USE_IR_RF") await cg.register_component(var, config) await setup_infrared_core_(var, config) - cg.add(cg.App.register_infrared(var)) + queue_entity_register("infrared", config) CORE.register_platform_component("infrared", var) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 5925afb472..9540c64486 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -40,7 +40,11 @@ from esphome.const import ( CONF_WHITE, ) from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass import esphome.final_validate as fv from esphome.types import ConfigType @@ -405,7 +409,7 @@ async def setup_light_core_(light_var, config, output_var): async def register_light(output_var, config): light_var = cg.new_Pvariable(config[CONF_ID], output_var) - cg.add(cg.App.register_light(light_var)) + queue_entity_register("light", config) CORE.register_platform_component("light", light_var) await cg.register_component(light_var, config) await setup_light_core_(light_var, config, output_var) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index a36d52a5d8..0a8ad58bc2 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -13,7 +13,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -112,7 +116,7 @@ async def _setup_lock_core(var, config): async def register_lock(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_lock(var)) + queue_entity_register("lock", config) CORE.register_platform_component("lock", var) await _setup_lock_core(var, config) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index d1db868ace..0024e3b965 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -21,6 +21,7 @@ from esphome.core import CORE from esphome.core.entity_helpers import ( entity_duplicate_validator, inherit_property_from, + queue_entity_register, setup_entity, ) from esphome.coroutine import CoroPriority, coroutine_with_priority @@ -262,7 +263,7 @@ async def setup_media_player_core_(var, config): async def register_media_player(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_media_player(var)) + queue_entity_register("media_player", config) CORE.register_platform_component("media_player", var) await setup_media_player_core_(var, config) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index f13ccc4c36..ee2d53c65a 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -82,6 +82,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, @@ -301,7 +302,7 @@ async def register_number( ): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_number(var)) + queue_entity_register("number", config) CORE.register_platform_component("number", var) await setup_number_core_( var, config, min_value=min_value, max_value=max_value, step=step diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index b00590ceb5..2d8a467366 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -12,7 +12,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import setup_entity +from esphome.core.entity_helpers import queue_entity_register, setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -56,7 +56,7 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non cg.add_define("USE_RADIO_FREQUENCY") await cg.register_component(var, config) await setup_radio_frequency_core_(var, config) - cg.add(cg.App.register_radio_frequency(var)) + queue_entity_register("radio_frequency", config) CORE.register_platform_component("radio_frequency", var) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index ba5214e550..f561c030a4 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -19,7 +19,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass, TemplateArguments from esphome.cpp_types import global_ns @@ -113,7 +117,7 @@ async def setup_select_core_(var, config, *, options: list[str]): async def register_select(var, config, *, options: list[str]): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_select(var)) + queue_entity_register("select", config) CORE.register_platform_component("select", var) await setup_select_core_(var, config, options=options) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 43fbc98953..48b7d25d4d 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -109,6 +109,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, @@ -982,7 +983,7 @@ async def setup_sensor_core_(var, config): async def register_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_sensor(var)) + queue_entity_register("sensor", config) CORE.register_platform_component("sensor", var) await setup_sensor_core_(var, config) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 9fa4a013ff..1108652e99 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -23,6 +23,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -166,7 +167,7 @@ async def setup_switch_core_(var, config): async def register_switch(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_switch(var)) + queue_entity_register("switch", config) CORE.register_platform_component("switch", var) await setup_switch_core_(var, config) diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 224f4580d4..06b5a10892 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -14,7 +14,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@mauritskorse"] @@ -122,7 +126,7 @@ async def register_text( ): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_text(var)) + queue_entity_register("text", config) CORE.register_platform_component("text", var) await setup_text_core_( var, config, min_length=min_length, max_length=max_length, pattern=pattern diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 94014e8d20..01a57cbaa1 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -22,6 +22,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -221,7 +222,7 @@ async def setup_text_sensor_core_(var, config): async def register_text_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_text_sensor(var)) + queue_entity_register("text_sensor", config) CORE.register_platform_component("text_sensor", var) await setup_text_sensor_core_(var, config) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index db6c1445e3..ddb471be18 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -17,6 +17,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -113,7 +114,7 @@ async def setup_update_core_(var, config): async def register_update(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_update(var)) + queue_entity_register("update", config) CORE.register_platform_component("update", var) await setup_update_core_(var, config) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 1930a7ad0c..a6808c9da7 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -162,7 +163,7 @@ async def _setup_valve_core(var, config): async def register_valve(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_valve(var)) + queue_entity_register("valve", config) CORE.register_platform_component("valve", var) await _setup_valve_core(var, config) diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index 58cf5a4054..f3eec16a40 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -9,7 +9,11 @@ from esphome.const import ( CONF_VISUAL, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType @@ -90,7 +94,7 @@ async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pva cg.add_define("USE_WATER_HEATER") - cg.add(cg.App.register_water_heater(var)) + queue_entity_register("water_heater", config) CORE.register_platform_component("water_heater", var) await setup_water_heater_core_(var, config) diff --git a/esphome/core/application.h b/esphome/core/application.h index bc09f7d38c..8abf300307 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -103,10 +103,19 @@ class Application { void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } -// Entity register methods (generated from entity_types.h) +// Entity register methods (generated from entity_types.h). +// Each entity type gets two overloads: +// - register_(obj) — bare push_back +// - register_(obj, name, hash, fields) — configure_entity_ + push_back +// The 4-arg form lets codegen collapse `App.register_(obj); obj->configure_entity_(...);` +// into a single call site, saving flash and a `main.cpp` line per entity. // 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) { this->plural##_.push_back(obj); } \ + 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) \ ENTITY_TYPE_(type, singular, plural, count, upper) #include "esphome/core/entity_types.h" diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 5a69c9dd09..2726a92c97 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -238,6 +238,9 @@ class EntityBase { protected: friend void ::setup(); friend void ::original_setup(); + // Application's register_(obj, name, hash, fields) overloads call configure_entity_ + // before push_back, so codegen can emit a single combined call per entity. + friend class Application; /// 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. diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index f09dd013fe..ff60260280 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -23,6 +23,7 @@ from esphome.core.config import ( UNIT_OF_MEASUREMENT_MAX_LENGTH, ) 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_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata @@ -52,6 +53,12 @@ _KEY_INTERNAL = "_entity_internal" _KEY_DISABLED_BY_DEFAULT = "_entity_disabled_by_default" _KEY_ENTITY_CATEGORY = "_entity_category" +# Private config key for the App.register_ entry point. +# When set, finalize_entity_strings() emits a single combined call +# `App.register_(var, name, hash, packed)` instead of separate +# `App.register_(var)` and `var->configure_entity_(...)` calls. +_KEY_REGISTER_METHOD = "_entity_register_method" + # Maximum unique strings per category (8-bit index, 0 = not set) _MAX_DEVICE_CLASSES = 0xFF # 255 _MAX_UNITS = 0xFF # 255 @@ -271,11 +278,26 @@ def _describe_packed_flags(config: ConfigType, entity_category: int) -> str: return ", ".join(parts) +def queue_entity_register(method_name: str, config: ConfigType) -> None: + """Defer ``App.register_(var)`` emission to ``finalize_entity_strings``. + + When the deferred call is emitted, it is folded with ``configure_entity_`` into + a single ``App.register_(var, name, hash, packed)`` call site, + which removes one statement and one method dispatch per entity from the + generated ``main.cpp``. + """ + config[_KEY_REGISTER_METHOD] = method_name + + def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: - """Emit a single configure_entity_() call with name, hash, packed string indices, and flags. + """Emit the entity-registration / configure_entity_ tail. Call this at the end of each component's setup function, after setup_entity() and any register_device_class/register_unit_of_measurement calls. + + If queue_entity_register() was called for this entity, emits one combined call + ``App.register_(var, name, hash, packed)``. Otherwise falls back to a + standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] object_id_hash = config[_KEY_OBJECT_ID_HASH] @@ -295,7 +317,13 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: ) # Build inline comment describing the packed flags for readability comment = _describe_packed_flags(config, entity_category) - expr = var.configure_entity_(entity_name, object_id_hash, packed) + register_method = config.get(_KEY_REGISTER_METHOD) + if register_method is not None: + expr = getattr(App, f"register_{register_method}")( + var, entity_name, object_id_hash, packed + ) + else: + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index 4f41f2cc70..e1d999abc7 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -31,7 +31,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'bs_1->configure_entity_("test bs1",' in main_cpp + assert 'App.register_binary_sensor(bs_1, "test bs1",' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index 544e748f91..f8881a832c 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -29,7 +29,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'wol_1->configure_entity_("wol_test_1",' in main_cpp + assert 'App.register_button(wol_1, "wol_test_1",' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/helpers.py b/tests/component_tests/helpers.py index 568d1639d0..2eb588c0ca 100644 --- a/tests/component_tests/helpers.py +++ b/tests/component_tests/helpers.py @@ -8,12 +8,22 @@ INTERNAL_BIT = 1 << 24 def extract_packed_value(main_cpp: str, var_name: str) -> int: - """Extract the third (packed) argument from a configure_entity_ call.""" - pattern = ( - rf"{re.escape(var_name)}->configure_entity_\(" + """Extract the packed-fields argument from the entity's configure call. + + Matches both legacy form ``var->configure_entity_(name, hash, packed)`` and the + combined form ``App.register_(var, name, hash, packed)``. + """ + escaped_var = re.escape(var_name) + legacy_pattern = ( + rf"{escaped_var}->configure_entity_\(" r'"(?:\\.|[^"\\])*"' r",\s*\w+,\s*(\d+)\)" ) - match = re.search(pattern, main_cpp) - assert match, f"configure_entity_ call not found for {var_name}" + combined_pattern = ( + rf"App\.register_\w+\(\s*{escaped_var}\s*,\s*" + r'"(?:\\.|[^"\\])*"' + r",\s*\w+,\s*(\d+)\)" + ) + match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp) + assert match, f"configure call not found for {var_name}" return int(match.group(1)) diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 63eb4f1951..f5ac07c1cd 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -28,7 +28,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'it_1->configure_entity_("test 1 text",' in main_cpp + assert 'App.register_text(it_1, "test 1 text",' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index ae094fadf8..eb25af3095 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -28,9 +28,9 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'ts_1->configure_entity_("Template Text Sensor 1",' in main_cpp - assert 'ts_2->configure_entity_("Template Text Sensor 2",' in main_cpp - assert 'ts_3->configure_entity_("Template Text Sensor 3",' in main_cpp + assert 'App.register_text_sensor(ts_1, "Template Text Sensor 1",' in main_cpp + assert 'App.register_text_sensor(ts_2, "Template Text Sensor 2",' in main_cpp + assert 'App.register_text_sensor(ts_3, "Template Text Sensor 3",' in main_cpp def test_text_sensor_config_value_internal_set(generate_main): From c32f6f629b535f007095218b2ce6369fffc23f9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 14:54:15 -0500 Subject: [PATCH 0698/2214] [infrared,radio_frequency] queue_entity_register before setup_core_ The setup__core_ functions are decorated with @setup_entity, which calls finalize_entity_strings() before returning. queue_entity_register() must run before that so the combined App.register_(var, name, hash, packed) emission is selected; otherwise finalize falls back to configure_entity_ and no register call is emitted. Reported by Copilot review on PR #16030. --- esphome/components/infrared/__init__.py | 2 +- esphome/components/radio_frequency/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index 288d919920..f8e77209b2 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -54,8 +54,8 @@ async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: """Register an infrared device with the core.""" cg.add_define("USE_IR_RF") await cg.register_component(var, config) - await setup_infrared_core_(var, config) queue_entity_register("infrared", config) + await setup_infrared_core_(var, config) CORE.register_platform_component("infrared", var) diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index 2d8a467366..a54ab6e249 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -55,8 +55,8 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non """Register a radio frequency device with the core.""" cg.add_define("USE_RADIO_FREQUENCY") await cg.register_component(var, config) - await setup_radio_frequency_core_(var, config) queue_entity_register("radio_frequency", config) + await setup_radio_frequency_core_(var, config) CORE.register_platform_component("radio_frequency", var) From 82ba873ea728f6a6282f8f9e16c48ce7cfaa3f51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 15:10:48 -0500 Subject: [PATCH 0699/2214] [analyze_memory] Attribute main.cpp setup()/loop() to esphome core The generated src/main.cpp.o was being skipped by _scan_source_symbols (which only walked src/esphome/) and _source_file_to_component had no rule for it. As a result setup()/loop() fell through to the "app_framework" heuristic in const.py instead of being counted as [esphome]core, both via the nm scan path and the linker map path. Scan all of src/ and recognize .../src/main.cpp.o as core. --- esphome/analyze_memory/__init__.py | 19 +++++--- .../test_source_file_attribution.py | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 tests/unit_tests/analyze_memory/test_source_file_attribution.py diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index f56d720ec2..33854ac289 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -793,8 +793,11 @@ class MemoryAnalyzer: """Scan ESPHome source object files to map extern "C" symbols to components. When no linker map file is available, this uses ``nm`` to scan ``.o`` files - under ``src/esphome/`` and build a symbol-to-component mapping. This catches - ``extern "C"`` functions and other symbols that lack C++ namespace prefixes. + under ``src/`` (including ``src/main.cpp.o`` and everything beneath + ``src/esphome/``) and build a symbol-to-component mapping. This catches + ``extern "C"`` functions, the ESPHome-generated ``setup()``/``loop()`` + entry points in ``main.cpp``, and other symbols that lack C++ namespace + prefixes. Skips scanning if ``_source_symbol_map`` was already populated by ``_parse_map_file()``. @@ -806,12 +809,12 @@ class MemoryAnalyzer: if obj_dir is None: return - # Find ESPHome source object files - esphome_src_dir = obj_dir / "src" / "esphome" - if not esphome_src_dir.is_dir(): + # Scan all ESPHome-owned source object files: src/main.cpp.o and src/esphome/... + src_dir = obj_dir / "src" + if not src_dir.is_dir(): return - obj_files = sorted(esphome_src_dir.rglob("*.o")) + obj_files = sorted(src_dir.rglob("*.o")) if not obj_files: return @@ -1064,6 +1067,10 @@ class MemoryAnalyzer: if component_name in self.external_components: return f"{_COMPONENT_PREFIX_EXTERNAL}{component_name}" + # ESPHome-generated entry point: src/main.cpp.o (contains setup()/loop()) + if len(parts) >= 2 and parts[-2:] == ("src", "main.cpp.o"): + return _COMPONENT_CORE + # ESPHome core: src/esphome/core/... or src/esphome/... if "core" in parts and "esphome" in parts: return _COMPONENT_CORE diff --git a/tests/unit_tests/analyze_memory/test_source_file_attribution.py b/tests/unit_tests/analyze_memory/test_source_file_attribution.py new file mode 100644 index 0000000000..2793f41bd0 --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_source_file_attribution.py @@ -0,0 +1,43 @@ +"""Tests for source-file-to-component attribution in memory analyzer.""" + +from unittest.mock import patch + +from esphome.analyze_memory import MemoryAnalyzer + + +def _make_analyzer(external_components: set[str] | None = None) -> MemoryAnalyzer: + """Create a MemoryAnalyzer with mocked dependencies.""" + with patch.object(MemoryAnalyzer, "__init__", lambda self, *a, **kw: None): + analyzer = MemoryAnalyzer.__new__(MemoryAnalyzer) + analyzer.external_components = external_components or set() + analyzer._lib_hash_to_name = {} + return analyzer + + +def test_source_file_to_component_main_cpp_relative() -> None: + """ESPHome-generated src/main.cpp.o (nm path form) attributes to core.""" + analyzer = _make_analyzer() + assert analyzer._source_file_to_component("src/main.cpp.o") == "[esphome]core" + + +def test_source_file_to_component_main_cpp_pioenvs_path() -> None: + """Linker map paths like .pioenvs//src/main.cpp.o attribute to core.""" + analyzer = _make_analyzer() + result = analyzer._source_file_to_component(".pioenvs/drivewaygate/src/main.cpp.o") + assert result == "[esphome]core" + + +def test_source_file_to_component_esphome_core() -> None: + """Sources under src/esphome/core/ attribute to core.""" + analyzer = _make_analyzer() + result = analyzer._source_file_to_component("src/esphome/core/application.cpp.o") + assert result == "[esphome]core" + + +def test_source_file_to_component_known_component() -> None: + """Known ESPHome components attribute to their component name.""" + analyzer = _make_analyzer() + result = analyzer._source_file_to_component( + "src/esphome/components/wifi/wifi_component.cpp.o" + ) + assert result == "[esphome]wifi" From 8c0b0b1302c418f121b39724d96c87bd71c6ed9f Mon Sep 17 00:00:00 2001 From: Paul Lazarre Date: Sun, 26 Apr 2026 19:22:57 -0500 Subject: [PATCH 0700/2214] [esp32_ble_tracker] Hold COEX_PREFER_BT for the lifetime of any active connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit count_client_states_() only tracks transient states (CONNECTING, DISCOVERED, DISCONNECTING). When a connection settles into CONNECTED/ESTABLISHED, all counted states go to zero, and loop() reverts coex from PREFER_BT back to BALANCE while the connection is still live. Sustained WiFi traffic then crowds out the BT radio's ability to receive the peer's GATT response, causing esp_ble_gattc_write_char_descr to time out at ~20s with a host-synthesized status=0x85 (commonly reported as status=133). Fix: count CONNECTED + ESTABLISHED clients as "active" and gate the coex revert on the active count. Coex now stays at PREFER_BT for the entire lifetime of any active connection and only returns to BALANCE when all connections are torn down. Validated end-to-end: aioesphomeapi-driven write_descriptor on a Yale BETA211123 lock at LOG_LEVEL_VERBOSE (heavy WiFi traffic, the bug-triggering condition) — pre-patch: 19s hang then status=133. Post-patch: 228ms SUCCESS. Verified live on an M5 Atom Lite running as a bluetooth_proxy with host BlueZ stopped (M5 as the only path): lock ops succeed cleanly with zero status=133 errors. A multi-day production soak window will be added to the PR body before merge. --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 18 +++++++++++++----- .../esp32_ble_tracker/esp32_ble_tracker.h | 10 +++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index c7f2319d69..8abe6065ad 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -166,8 +166,9 @@ void ESP32BLETracker::loop() { ClientStateCounts counts = this->count_client_states_(); if (counts != this->client_state_counts_) { this->client_state_counts_ = counts; - ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d", this->client_state_counts_.connecting, - this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); + ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d, active: %d", this->client_state_counts_.connecting, + this->client_state_counts_.discovered, this->client_state_counts_.disconnecting, + this->client_state_counts_.active); } // Scanner failure: reached when set_scanner_state_(FAILED) or scan_set_param_failed_ set @@ -193,7 +194,13 @@ void ESP32BLETracker::loop() { // all clients are idle (their state changes increment version when they finish) if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !counts.discovered) { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - this->update_coex_preference_(false); + // Only revert to BALANCE when no connections are active. Established connections + // continue to need PREFER_BT so the lock's GATT Write Response can reach us while + // WiFi traffic (advertisement upload, log streaming) competes for the shared radio. + // Reverting too early causes Bluedroid to time out at ~20s and synthesize status=133. + if (!counts.active) { + this->update_coex_preference_(false); + } #endif if (this->scan_continuous_) { this->start_scan_(false); // first = false @@ -701,9 +708,10 @@ void ESP32BLETracker::dump_config() { this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); ESP_LOGCONFIG(TAG, " Scanner State: %s\n" - " Connecting: %d, discovered: %d, disconnecting: %d", + " Connecting: %d, discovered: %d, disconnecting: %d, active: %d", this->scanner_state_to_string_(this->scanner_state_), this->client_state_counts_.connecting, - this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); + this->client_state_counts_.discovered, this->client_state_counts_.disconnecting, + this->client_state_counts_.active); if (this->scan_start_fail_count_) { ESP_LOGCONFIG(TAG, " Scan Start Fail Count: %d", this->scan_start_fail_count_); } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 43405b02b7..78ff60f374 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -160,9 +160,13 @@ struct ClientStateCounts { uint8_t connecting = 0; uint8_t discovered = 0; uint8_t disconnecting = 0; + // CONNECTED + ESTABLISHED clients. Tracked so coex stays at PREFER_BT + // while active connections may still need to send/receive GATT traffic. + uint8_t active = 0; bool operator==(const ClientStateCounts &other) const { - return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting; + return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting && + active == other.active; } bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } @@ -381,6 +385,10 @@ class ESP32BLETracker : public Component, case ClientState::CONNECTING: counts.connecting++; break; + case ClientState::CONNECTED: + case ClientState::ESTABLISHED: + counts.active++; + break; default: break; } From debddac0fe2633af73762f1d71a281ffc6771b71 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:26:25 +0000 Subject: [PATCH 0701/2214] [pre-commit.ci lite] apply automatic fixes --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8abe6065ad..04eaaa3a26 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -166,9 +166,9 @@ void ESP32BLETracker::loop() { ClientStateCounts counts = this->count_client_states_(); if (counts != this->client_state_counts_) { this->client_state_counts_ = counts; - ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d, active: %d", this->client_state_counts_.connecting, - this->client_state_counts_.discovered, this->client_state_counts_.disconnecting, - this->client_state_counts_.active); + ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d, active: %d", + this->client_state_counts_.connecting, this->client_state_counts_.discovered, + this->client_state_counts_.disconnecting, this->client_state_counts_.active); } // Scanner failure: reached when set_scanner_state_(FAILED) or scan_set_param_failed_ set From 02674199dab54cf31b7c987dea5cfc948bee6d5f Mon Sep 17 00:00:00 2001 From: Paul Lazarre Date: Sun, 26 Apr 2026 19:32:01 -0500 Subject: [PATCH 0702/2214] [esp32_ble_tracker] Address review: clarify gate comments - 'all clients are idle' was accurate pre-patch; now this branch can run while CONNECTED/ESTABLISHED clients exist. Clarify which states actually block, and note that the inner coex revert has its own gate. - Generalize the coex-revert rationale: 'lock's GATT Write Response' was Yale-specific; the bug is generic to any peer GATT response that needs to reach us while WiFi competes for the shared radio. --- .../components/esp32_ble_tracker/esp32_ble_tracker.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 04eaaa3a26..f57cb7f5dc 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -191,13 +191,15 @@ void ESP32BLETracker::loop() { */ // Start scan: reached when scanner_state_ becomes IDLE (via set_scanner_state_()) and - // all clients are idle (their state changes increment version when they finish) + // no clients are in the transient CONNECTING / DISCOVERED / DISCONNECTING states + // (their state changes increment version when they finish). CONNECTED / ESTABLISHED + // clients do NOT block this branch — the coex revert below has its own active-count gate. if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !counts.discovered) { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE // Only revert to BALANCE when no connections are active. Established connections - // continue to need PREFER_BT so the lock's GATT Write Response can reach us while - // WiFi traffic (advertisement upload, log streaming) competes for the shared radio. - // Reverting too early causes Bluedroid to time out at ~20s and synthesize status=133. + // continue to need PREFER_BT so peer GATT responses can reach us while WiFi traffic + // (advertisement upload, log streaming) competes for the shared radio. Reverting too + // early causes Bluedroid to time out at ~20s and synthesize status=133. if (!counts.active) { this->update_coex_preference_(false); } From 0cebb9014f7286e512b964fb9d678d2fa7d0c94c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:13:58 -0500 Subject: [PATCH 0703/2214] [light] Use constexpr template parameter for ToggleAction transition_length Parameterize ToggleAction on a HasTransitionLength bool, mirroring the IfAction pattern. When transition_length is not configured in YAML, the TemplatableFn field is elided via [[no_unique_address]] and LightCall::set_transition_length is skipped via if constexpr. Saves 4 bytes RAM per toggle action instance and shrinks play() from 79 to 28 bytes when transition_length is unused. --- esphome/components/light/automation.h | 13 ++++++++++--- esphome/components/light/automation.py | 6 ++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index f6a2ca52d4..90cd8b9026 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -8,20 +8,27 @@ namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; -template class ToggleAction : public Action { +template class ToggleAction : public Action { public: explicit ToggleAction(LightState *state) : state_(state) {} - TEMPLATABLE_VALUE(uint32_t, transition_length) + template void set_transition_length(V value) requires(HasTransitionLength) { + this->transition_length_ = value; + } void play(const Ts &...x) override { auto call = this->state_->toggle(); - call.set_transition_length(this->transition_length_.optional_value(x...)); + if constexpr (HasTransitionLength) { + call.set_transition_length(this->transition_length_.optional_value(x...)); + } call.perform(); } protected: LightState *state_; + struct NoTransition {}; + [[no_unique_address]] std::conditional_t, NoTransition> + transition_length_{}; }; template class LightControlAction : public Action { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 46d37239e5..bcf058e8b2 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -60,8 +60,10 @@ from .types import ( ) async def light_toggle_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_TRANSITION_LENGTH in config: + has_transition_length = CONF_TRANSITION_LENGTH in config + toggle_template_arg = cg.TemplateArguments(has_transition_length, *template_arg) + var = cg.new_Pvariable(action_id, toggle_template_arg, paren) + if has_transition_length: template_ = await cg.templatable( config[CONF_TRANSITION_LENGTH], args, cg.uint32 ) From 19e0d052193217f89a042e64ba7b86e732262e44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:23:26 -0500 Subject: [PATCH 0704/2214] [light] Use constexpr template parameter for DimRelativeAction transition_length Parameterize DimRelativeAction on a HasTransitionLength bool, mirroring the same trick applied to ToggleAction. When transition_length is not configured in YAML, the TemplatableFn field is elided via [[no_unique_address]] and LightCall::set_transition_length is skipped via if constexpr. Saves 4 bytes RAM per dim_relative action instance and shrinks play() by ~48 bytes when transition_length is unused. --- esphome/components/light/automation.h | 14 +++++++++++--- esphome/components/light/automation.py | 6 ++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index f6a2ca52d4..4abb9e743c 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -80,12 +80,15 @@ template class LightControlAction : public Action { LightState *parent_; }; -template class DimRelativeAction : public Action { +template class DimRelativeAction : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, relative_brightness) - TEMPLATABLE_VALUE(uint32_t, transition_length) + + template void set_transition_length(V value) requires(HasTransitionLength) { + this->transition_length_ = value; + } void play(const Ts &...x) override { auto call = this->parent_->make_call(); @@ -99,7 +102,9 @@ template class DimRelativeAction : public Action { call.set_state(new_brightness != 0.0f); call.set_brightness(new_brightness); - call.set_transition_length(this->transition_length_.optional_value(x...)); + if constexpr (HasTransitionLength) { + call.set_transition_length(this->transition_length_.optional_value(x...)); + } call.perform(); } @@ -115,6 +120,9 @@ template class DimRelativeAction : public Action { float min_brightness_{0.0}; float max_brightness_{1.0}; LimitMode limit_mode_{LimitMode::CLAMP}; + struct NoTransition {}; + [[no_unique_address]] std::conditional_t, NoTransition> + transition_length_{}; }; template class LightIsOnCondition : public Condition { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 46d37239e5..5aac587f76 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -261,10 +261,12 @@ LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema( ) async def light_dim_relative_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) + has_transition_length = CONF_TRANSITION_LENGTH in config + dim_template_arg = cg.TemplateArguments(has_transition_length, *template_arg) + var = cg.new_Pvariable(action_id, dim_template_arg, paren) templ = await cg.templatable(config[CONF_RELATIVE_BRIGHTNESS], args, cg.float_) cg.add(var.set_relative_brightness(templ)) - if CONF_TRANSITION_LENGTH in config: + if has_transition_length: templ = await cg.templatable(config[CONF_TRANSITION_LENGTH], args, cg.uint32) cg.add(var.set_transition_length(templ)) if conf := config.get(CONF_BRIGHTNESS_LIMITS): From 19df4527713e32b02aaf56d19d3e4e1a4c7e5032 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:33:57 -0500 Subject: [PATCH 0705/2214] [light] Use bitmask template parameter for LightControlAction unused fields Parameterize LightControlAction on a uint16_t Fields bitmask encoding which of its 14 templatable fields are configured. Unset fields are elided from the instance via [[no_unique_address]] and skipped at compile time in play() via if constexpr. Real-world configs typically use only 1-5 of the 14 fields. Codegen computes the bitmask from the YAML and passes it as the leading template argument, so each unique field combination produces its own type with only the storage and play() branches it actually needs. Measured on apollo-pump-1-5d9bdc.yaml (7 instances, 3 unique masks): - state-only (2 instances): 72 B -> 20 B = 52 B saved each - state+RGB (2 instances): 72 B -> 32 B = 40 B saved each - state+brightness+RGB (3 instances): 72 B -> 36 B = 36 B saved each - Total: ~292 B RAM saved - Flash cost: ~30-70 B for 3 play() variants + vtables --- esphome/components/light/automation.h | 111 ++++++++++++++++++------- esphome/components/light/automation.py | 43 ++++++---- 2 files changed, 109 insertions(+), 45 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index f6a2ca52d4..2511612b57 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -24,60 +24,113 @@ template class ToggleAction : public Action { LightState *state_; }; -template class LightControlAction : public Action { +// Bitmask of fields configured on a LightControlAction. Used as a non-type +// template parameter so unset fields can be elided via [[no_unique_address]] +// and skipped at compile time via if constexpr in play(). +namespace LightControlField { +constexpr uint16_t COLOR_MODE = 1 << 0; +constexpr uint16_t STATE = 1 << 1; +constexpr uint16_t TRANSITION_LENGTH = 1 << 2; +constexpr uint16_t FLASH_LENGTH = 1 << 3; +constexpr uint16_t BRIGHTNESS = 1 << 4; +constexpr uint16_t COLOR_BRIGHTNESS = 1 << 5; +constexpr uint16_t RED = 1 << 6; +constexpr uint16_t GREEN = 1 << 7; +constexpr uint16_t BLUE = 1 << 8; +constexpr uint16_t WHITE = 1 << 9; +constexpr uint16_t COLOR_TEMPERATURE = 1 << 10; +constexpr uint16_t COLD_WHITE = 1 << 11; +constexpr uint16_t WARM_WHITE = 1 << 12; +constexpr uint16_t EFFECT = 1 << 13; +} // namespace LightControlField + +namespace light_control_detail { +// Distinct empty types per field so [[no_unique_address]] always coalesces +// (same empty type used multiple times is not guaranteed to share an address). +template struct Empty {}; +} // namespace light_control_detail + +template class LightControlAction : public Action { public: explicit LightControlAction(LightState *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(ColorMode, color_mode) - TEMPLATABLE_VALUE(bool, state) - TEMPLATABLE_VALUE(uint32_t, transition_length) - TEMPLATABLE_VALUE(uint32_t, flash_length) - TEMPLATABLE_VALUE(float, brightness) - TEMPLATABLE_VALUE(float, color_brightness) - TEMPLATABLE_VALUE(float, red) - TEMPLATABLE_VALUE(float, green) - TEMPLATABLE_VALUE(float, blue) - TEMPLATABLE_VALUE(float, white) - TEMPLATABLE_VALUE(float, color_temperature) - TEMPLATABLE_VALUE(float, cold_white) - TEMPLATABLE_VALUE(float, warm_white) - TEMPLATABLE_VALUE(uint32_t, effect) +#define LIGHT_CONTROL_FIELD(field_bit, type, name, tag) \ + template void set_##name(V value) requires((Fields & LightControlField::field_bit) != 0) { \ + this->name##_ = value; \ + } + + LIGHT_CONTROL_FIELD(COLOR_MODE, ColorMode, color_mode, 0) + LIGHT_CONTROL_FIELD(STATE, bool, state, 1) + LIGHT_CONTROL_FIELD(TRANSITION_LENGTH, uint32_t, transition_length, 2) + LIGHT_CONTROL_FIELD(FLASH_LENGTH, uint32_t, flash_length, 3) + LIGHT_CONTROL_FIELD(BRIGHTNESS, float, brightness, 4) + LIGHT_CONTROL_FIELD(COLOR_BRIGHTNESS, float, color_brightness, 5) + LIGHT_CONTROL_FIELD(RED, float, red, 6) + LIGHT_CONTROL_FIELD(GREEN, float, green, 7) + LIGHT_CONTROL_FIELD(BLUE, float, blue, 8) + LIGHT_CONTROL_FIELD(WHITE, float, white, 9) + LIGHT_CONTROL_FIELD(COLOR_TEMPERATURE, float, color_temperature, 10) + LIGHT_CONTROL_FIELD(COLD_WHITE, float, cold_white, 11) + LIGHT_CONTROL_FIELD(WARM_WHITE, float, warm_white, 12) + LIGHT_CONTROL_FIELD(EFFECT, uint32_t, effect, 13) +#undef LIGHT_CONTROL_FIELD void play(const Ts &...x) override { auto call = this->parent_->make_call(); - if (this->color_mode_.has_value()) + if constexpr ((Fields & LightControlField::COLOR_MODE) != 0) call.set_color_mode(this->color_mode_.value(x...)); - if (this->state_.has_value()) + if constexpr ((Fields & LightControlField::STATE) != 0) call.set_state(this->state_.value(x...)); - if (this->transition_length_.has_value()) + if constexpr ((Fields & LightControlField::TRANSITION_LENGTH) != 0) call.set_transition_length(this->transition_length_.value(x...)); - if (this->flash_length_.has_value()) + if constexpr ((Fields & LightControlField::FLASH_LENGTH) != 0) call.set_flash_length(this->flash_length_.value(x...)); - if (this->brightness_.has_value()) + if constexpr ((Fields & LightControlField::BRIGHTNESS) != 0) call.set_brightness(this->brightness_.value(x...)); - if (this->color_brightness_.has_value()) + if constexpr ((Fields & LightControlField::COLOR_BRIGHTNESS) != 0) call.set_color_brightness(this->color_brightness_.value(x...)); - if (this->red_.has_value()) + if constexpr ((Fields & LightControlField::RED) != 0) call.set_red(this->red_.value(x...)); - if (this->green_.has_value()) + if constexpr ((Fields & LightControlField::GREEN) != 0) call.set_green(this->green_.value(x...)); - if (this->blue_.has_value()) + if constexpr ((Fields & LightControlField::BLUE) != 0) call.set_blue(this->blue_.value(x...)); - if (this->white_.has_value()) + if constexpr ((Fields & LightControlField::WHITE) != 0) call.set_white(this->white_.value(x...)); - if (this->color_temperature_.has_value()) + if constexpr ((Fields & LightControlField::COLOR_TEMPERATURE) != 0) call.set_color_temperature(this->color_temperature_.value(x...)); - if (this->cold_white_.has_value()) + if constexpr ((Fields & LightControlField::COLD_WHITE) != 0) call.set_cold_white(this->cold_white_.value(x...)); - if (this->warm_white_.has_value()) + if constexpr ((Fields & LightControlField::WARM_WHITE) != 0) call.set_warm_white(this->warm_white_.value(x...)); - if (this->effect_.has_value()) + if constexpr ((Fields & LightControlField::EFFECT) != 0) call.set_effect(this->effect_.value(x...)); call.perform(); } protected: LightState *parent_; + +#define LIGHT_CONTROL_STORAGE(field_bit, type, name, tag) \ + [[no_unique_address]] std::conditional_t<(Fields & LightControlField::field_bit) != 0, TemplatableFn, \ + light_control_detail::Empty> \ + name##_{}; + + LIGHT_CONTROL_STORAGE(COLOR_MODE, ColorMode, color_mode, 0) + LIGHT_CONTROL_STORAGE(STATE, bool, state, 1) + LIGHT_CONTROL_STORAGE(TRANSITION_LENGTH, uint32_t, transition_length, 2) + LIGHT_CONTROL_STORAGE(FLASH_LENGTH, uint32_t, flash_length, 3) + LIGHT_CONTROL_STORAGE(BRIGHTNESS, float, brightness, 4) + LIGHT_CONTROL_STORAGE(COLOR_BRIGHTNESS, float, color_brightness, 5) + LIGHT_CONTROL_STORAGE(RED, float, red, 6) + LIGHT_CONTROL_STORAGE(GREEN, float, green, 7) + LIGHT_CONTROL_STORAGE(BLUE, float, blue, 8) + LIGHT_CONTROL_STORAGE(WHITE, float, white, 9) + LIGHT_CONTROL_STORAGE(COLOR_TEMPERATURE, float, color_temperature, 10) + LIGHT_CONTROL_STORAGE(COLD_WHITE, float, cold_white, 11) + LIGHT_CONTROL_STORAGE(WARM_WHITE, float, warm_white, 12) + LIGHT_CONTROL_STORAGE(EFFECT, uint32_t, effect, 13) +#undef LIGHT_CONTROL_STORAGE }; template class DimRelativeAction : public Action { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 46d37239e5..ddd8948a64 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -178,25 +178,36 @@ def _resolve_effect_index(config: ConfigType) -> int: ) async def light_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - # (config_key, setter_name, c++ type) + # (config_key, setter_name, c++ type, field bit) + # Bit values must match LightControlField in automation.h FIELDS = ( - (CONF_COLOR_MODE, "set_color_mode", ColorMode), - (CONF_STATE, "set_state", cg.bool_), - (CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), - (CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), - (CONF_BRIGHTNESS, "set_brightness", cg.float_), - (CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), - (CONF_RED, "set_red", cg.float_), - (CONF_GREEN, "set_green", cg.float_), - (CONF_BLUE, "set_blue", cg.float_), - (CONF_WHITE, "set_white", cg.float_), - (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), - (CONF_COLD_WHITE, "set_cold_white", cg.float_), - (CONF_WARM_WHITE, "set_warm_white", cg.float_), + (CONF_COLOR_MODE, "set_color_mode", ColorMode, 1 << 0), + (CONF_STATE, "set_state", cg.bool_, 1 << 1), + (CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32, 1 << 2), + (CONF_FLASH_LENGTH, "set_flash_length", cg.uint32, 1 << 3), + (CONF_BRIGHTNESS, "set_brightness", cg.float_, 1 << 4), + (CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_, 1 << 5), + (CONF_RED, "set_red", cg.float_, 1 << 6), + (CONF_GREEN, "set_green", cg.float_, 1 << 7), + (CONF_BLUE, "set_blue", cg.float_, 1 << 8), + (CONF_WHITE, "set_white", cg.float_, 1 << 9), + (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_, 1 << 10), + (CONF_COLD_WHITE, "set_cold_white", cg.float_, 1 << 11), + (CONF_WARM_WHITE, "set_warm_white", cg.float_, 1 << 12), ) - for conf_key, setter, type_ in FIELDS: + field_mask = 0 + for conf_key, _, _, bit in FIELDS: + if conf_key in config: + field_mask |= bit + if CONF_EFFECT in config: + field_mask |= 1 << 13 # EFFECT bit + + fields_arg = cg.RawExpression(f"static_cast({field_mask})") + control_template_arg = cg.TemplateArguments(fields_arg, *template_arg) + var = cg.new_Pvariable(action_id, control_template_arg, paren) + + for conf_key, setter, type_, _ in FIELDS: if conf_key in config: template_ = await cg.templatable(config[conf_key], args, type_) cg.add(getattr(var, setter)(template_)) From 3f0c669b4cedc7377d447586123bfe91d2c9f686 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:37:55 -0500 Subject: [PATCH 0706/2214] [light] Reduce LOC via X-macro pattern, drop unused tag/BIT args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the X-macro pattern from #15132 so the field list is declared once and expanded into setters, play(), and storage. Drop the unused BIT_NAME and tag arguments — only (type, name, idx) is needed since idx doubles as both the bit position and the Empty<> tag. Net change vs upstream is +10 LOC instead of +64. --- esphome/components/light/automation.h | 124 +++++++------------------ esphome/components/light/automation.py | 48 +++++----- 2 files changed, 59 insertions(+), 113 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 2511612b57..2dc8c08ee0 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -24,113 +24,59 @@ template class ToggleAction : public Action { LightState *state_; }; -// Bitmask of fields configured on a LightControlAction. Used as a non-type -// template parameter so unset fields can be elided via [[no_unique_address]] -// and skipped at compile time via if constexpr in play(). -namespace LightControlField { -constexpr uint16_t COLOR_MODE = 1 << 0; -constexpr uint16_t STATE = 1 << 1; -constexpr uint16_t TRANSITION_LENGTH = 1 << 2; -constexpr uint16_t FLASH_LENGTH = 1 << 3; -constexpr uint16_t BRIGHTNESS = 1 << 4; -constexpr uint16_t COLOR_BRIGHTNESS = 1 << 5; -constexpr uint16_t RED = 1 << 6; -constexpr uint16_t GREEN = 1 << 7; -constexpr uint16_t BLUE = 1 << 8; -constexpr uint16_t WHITE = 1 << 9; -constexpr uint16_t COLOR_TEMPERATURE = 1 << 10; -constexpr uint16_t COLD_WHITE = 1 << 11; -constexpr uint16_t WARM_WHITE = 1 << 12; -constexpr uint16_t EFFECT = 1 << 13; -} // namespace LightControlField - +// Distinct empty types per field tag so [[no_unique_address]] always coalesces +// (the same empty type used multiple times is not guaranteed to share an address). namespace light_control_detail { -// Distinct empty types per field so [[no_unique_address]] always coalesces -// (same empty type used multiple times is not guaranteed to share an address). template struct Empty {}; } // namespace light_control_detail +// X-macro: (type, field_name, bit_index). Order and bit values must match +// the FIELDS table in automation.py. +#define LIGHT_CONTROL_FIELDS(X) \ + X(ColorMode, color_mode, 0) \ + X(bool, state, 1) \ + X(uint32_t, transition_length, 2) \ + X(uint32_t, flash_length, 3) \ + X(float, brightness, 4) \ + X(float, color_brightness, 5) \ + X(float, red, 6) \ + X(float, green, 7) \ + X(float, blue, 8) \ + X(float, white, 9) \ + X(float, color_temperature, 10) \ + X(float, cold_white, 11) \ + X(float, warm_white, 12) \ + X(uint32_t, effect, 13) + template class LightControlAction : public Action { public: explicit LightControlAction(LightState *parent) : parent_(parent) {} -#define LIGHT_CONTROL_FIELD(field_bit, type, name, tag) \ - template void set_##name(V value) requires((Fields & LightControlField::field_bit) != 0) { \ - this->name##_ = value; \ - } +#define LIGHT_FIELD_SETTER_(type, name, idx) \ + template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } +#define LIGHT_FIELD_APPLY_(type, name, idx) \ + if constexpr ((Fields & (1 << (idx))) != 0) \ + call.set_##name(this->name##_.value(x...)); +#define LIGHT_FIELD_DECL_(type, name, idx) \ + [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableFn, \ + light_control_detail::Empty<(idx)>> \ + name##_{}; - LIGHT_CONTROL_FIELD(COLOR_MODE, ColorMode, color_mode, 0) - LIGHT_CONTROL_FIELD(STATE, bool, state, 1) - LIGHT_CONTROL_FIELD(TRANSITION_LENGTH, uint32_t, transition_length, 2) - LIGHT_CONTROL_FIELD(FLASH_LENGTH, uint32_t, flash_length, 3) - LIGHT_CONTROL_FIELD(BRIGHTNESS, float, brightness, 4) - LIGHT_CONTROL_FIELD(COLOR_BRIGHTNESS, float, color_brightness, 5) - LIGHT_CONTROL_FIELD(RED, float, red, 6) - LIGHT_CONTROL_FIELD(GREEN, float, green, 7) - LIGHT_CONTROL_FIELD(BLUE, float, blue, 8) - LIGHT_CONTROL_FIELD(WHITE, float, white, 9) - LIGHT_CONTROL_FIELD(COLOR_TEMPERATURE, float, color_temperature, 10) - LIGHT_CONTROL_FIELD(COLD_WHITE, float, cold_white, 11) - LIGHT_CONTROL_FIELD(WARM_WHITE, float, warm_white, 12) - LIGHT_CONTROL_FIELD(EFFECT, uint32_t, effect, 13) -#undef LIGHT_CONTROL_FIELD + LIGHT_CONTROL_FIELDS(LIGHT_FIELD_SETTER_) void play(const Ts &...x) override { auto call = this->parent_->make_call(); - if constexpr ((Fields & LightControlField::COLOR_MODE) != 0) - call.set_color_mode(this->color_mode_.value(x...)); - if constexpr ((Fields & LightControlField::STATE) != 0) - call.set_state(this->state_.value(x...)); - if constexpr ((Fields & LightControlField::TRANSITION_LENGTH) != 0) - call.set_transition_length(this->transition_length_.value(x...)); - if constexpr ((Fields & LightControlField::FLASH_LENGTH) != 0) - call.set_flash_length(this->flash_length_.value(x...)); - if constexpr ((Fields & LightControlField::BRIGHTNESS) != 0) - call.set_brightness(this->brightness_.value(x...)); - if constexpr ((Fields & LightControlField::COLOR_BRIGHTNESS) != 0) - call.set_color_brightness(this->color_brightness_.value(x...)); - if constexpr ((Fields & LightControlField::RED) != 0) - call.set_red(this->red_.value(x...)); - if constexpr ((Fields & LightControlField::GREEN) != 0) - call.set_green(this->green_.value(x...)); - if constexpr ((Fields & LightControlField::BLUE) != 0) - call.set_blue(this->blue_.value(x...)); - if constexpr ((Fields & LightControlField::WHITE) != 0) - call.set_white(this->white_.value(x...)); - if constexpr ((Fields & LightControlField::COLOR_TEMPERATURE) != 0) - call.set_color_temperature(this->color_temperature_.value(x...)); - if constexpr ((Fields & LightControlField::COLD_WHITE) != 0) - call.set_cold_white(this->cold_white_.value(x...)); - if constexpr ((Fields & LightControlField::WARM_WHITE) != 0) - call.set_warm_white(this->warm_white_.value(x...)); - if constexpr ((Fields & LightControlField::EFFECT) != 0) - call.set_effect(this->effect_.value(x...)); + LIGHT_CONTROL_FIELDS(LIGHT_FIELD_APPLY_) call.perform(); } protected: LightState *parent_; + LIGHT_CONTROL_FIELDS(LIGHT_FIELD_DECL_) -#define LIGHT_CONTROL_STORAGE(field_bit, type, name, tag) \ - [[no_unique_address]] std::conditional_t<(Fields & LightControlField::field_bit) != 0, TemplatableFn, \ - light_control_detail::Empty> \ - name##_{}; - - LIGHT_CONTROL_STORAGE(COLOR_MODE, ColorMode, color_mode, 0) - LIGHT_CONTROL_STORAGE(STATE, bool, state, 1) - LIGHT_CONTROL_STORAGE(TRANSITION_LENGTH, uint32_t, transition_length, 2) - LIGHT_CONTROL_STORAGE(FLASH_LENGTH, uint32_t, flash_length, 3) - LIGHT_CONTROL_STORAGE(BRIGHTNESS, float, brightness, 4) - LIGHT_CONTROL_STORAGE(COLOR_BRIGHTNESS, float, color_brightness, 5) - LIGHT_CONTROL_STORAGE(RED, float, red, 6) - LIGHT_CONTROL_STORAGE(GREEN, float, green, 7) - LIGHT_CONTROL_STORAGE(BLUE, float, blue, 8) - LIGHT_CONTROL_STORAGE(WHITE, float, white, 9) - LIGHT_CONTROL_STORAGE(COLOR_TEMPERATURE, float, color_temperature, 10) - LIGHT_CONTROL_STORAGE(COLD_WHITE, float, cold_white, 11) - LIGHT_CONTROL_STORAGE(WARM_WHITE, float, warm_white, 12) - LIGHT_CONTROL_STORAGE(EFFECT, uint32_t, effect, 13) -#undef LIGHT_CONTROL_STORAGE +#undef LIGHT_FIELD_DECL_ +#undef LIGHT_FIELD_APPLY_ +#undef LIGHT_FIELD_SETTER_ }; template class DimRelativeAction : public Action { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index ddd8948a64..a655cecc44 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -179,35 +179,35 @@ def _resolve_effect_index(config: ConfigType) -> int: async def light_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - # (config_key, setter_name, c++ type, field bit) - # Bit values must match LightControlField in automation.h + # (config_key, setter_name, c++ type) — order and bit position must match + # LIGHT_CONTROL_FIELDS in automation.h. CONF_EFFECT is the last bit. FIELDS = ( - (CONF_COLOR_MODE, "set_color_mode", ColorMode, 1 << 0), - (CONF_STATE, "set_state", cg.bool_, 1 << 1), - (CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32, 1 << 2), - (CONF_FLASH_LENGTH, "set_flash_length", cg.uint32, 1 << 3), - (CONF_BRIGHTNESS, "set_brightness", cg.float_, 1 << 4), - (CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_, 1 << 5), - (CONF_RED, "set_red", cg.float_, 1 << 6), - (CONF_GREEN, "set_green", cg.float_, 1 << 7), - (CONF_BLUE, "set_blue", cg.float_, 1 << 8), - (CONF_WHITE, "set_white", cg.float_, 1 << 9), - (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_, 1 << 10), - (CONF_COLD_WHITE, "set_cold_white", cg.float_, 1 << 11), - (CONF_WARM_WHITE, "set_warm_white", cg.float_, 1 << 12), + (CONF_COLOR_MODE, "set_color_mode", ColorMode), + (CONF_STATE, "set_state", cg.bool_), + (CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + (CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), + (CONF_BRIGHTNESS, "set_brightness", cg.float_), + (CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), + (CONF_RED, "set_red", cg.float_), + (CONF_GREEN, "set_green", cg.float_), + (CONF_BLUE, "set_blue", cg.float_), + (CONF_WHITE, "set_white", cg.float_), + (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), + (CONF_COLD_WHITE, "set_cold_white", cg.float_), + (CONF_WARM_WHITE, "set_warm_white", cg.float_), ) - field_mask = 0 - for conf_key, _, _, bit in FIELDS: - if conf_key in config: - field_mask |= bit - if CONF_EFFECT in config: - field_mask |= 1 << 13 # EFFECT bit + EFFECT_BIT = len(FIELDS) - fields_arg = cg.RawExpression(f"static_cast({field_mask})") - control_template_arg = cg.TemplateArguments(fields_arg, *template_arg) + field_mask = sum(1 << i for i, (k, _, _) in enumerate(FIELDS) if k in config) + if CONF_EFFECT in config: + field_mask |= 1 << EFFECT_BIT + + control_template_arg = cg.TemplateArguments( + cg.RawExpression(f"static_cast({field_mask})"), *template_arg + ) var = cg.new_Pvariable(action_id, control_template_arg, paren) - for conf_key, setter, type_, _ in FIELDS: + for conf_key, setter, type_ in FIELDS: if conf_key in config: template_ = await cg.templatable(config[conf_key], args, type_) cg.add(getattr(var, setter)(template_)) From 0b8c04983ef33b1ec2ed2bc793b1530b1c8cc9c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:40:05 -0500 Subject: [PATCH 0707/2214] [light] Fold CONF_EFFECT into FIELDS table --- esphome/components/light/automation.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index a655cecc44..04a79f1148 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -180,7 +180,8 @@ async def light_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) # (config_key, setter_name, c++ type) — order and bit position must match - # LIGHT_CONTROL_FIELDS in automation.h. CONF_EFFECT is the last bit. + # LIGHT_CONTROL_FIELDS in automation.h. CONF_EFFECT has special-case + # handling below (lambda or static name resolution), so its setter is None. FIELDS = ( (CONF_COLOR_MODE, "set_color_mode", ColorMode), (CONF_STATE, "set_state", cg.bool_), @@ -195,20 +196,17 @@ async def light_control_to_code(config, action_id, template_arg, args): (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), (CONF_COLD_WHITE, "set_cold_white", cg.float_), (CONF_WARM_WHITE, "set_warm_white", cg.float_), + (CONF_EFFECT, None, cg.uint32), ) - EFFECT_BIT = len(FIELDS) field_mask = sum(1 << i for i, (k, _, _) in enumerate(FIELDS) if k in config) - if CONF_EFFECT in config: - field_mask |= 1 << EFFECT_BIT - control_template_arg = cg.TemplateArguments( cg.RawExpression(f"static_cast({field_mask})"), *template_arg ) var = cg.new_Pvariable(action_id, control_template_arg, paren) for conf_key, setter, type_ in FIELDS: - if conf_key in config: + if conf_key in config and setter is not None: template_ = await cg.templatable(config[conf_key], args, type_) cg.add(getattr(var, setter)(template_)) From 9f776eb517797ae3c61bbc4a67bba08347c688d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:40:52 -0500 Subject: [PATCH 0708/2214] [light] Shorten FIELDS comment --- esphome/components/light/automation.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 04a79f1148..1062d6de3a 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -179,9 +179,8 @@ def _resolve_effect_index(config: ConfigType) -> int: async def light_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - # (config_key, setter_name, c++ type) — order and bit position must match - # LIGHT_CONTROL_FIELDS in automation.h. CONF_EFFECT has special-case - # handling below (lambda or static name resolution), so its setter is None. + # Order/bits must match LIGHT_CONTROL_FIELDS in automation.h. + # EFFECT has special handling below; setter=None skips the generic loop. FIELDS = ( (CONF_COLOR_MODE, "set_color_mode", ColorMode), (CONF_STATE, "set_state", cg.bool_), From dae15301e15e218c076f2731c506bf01266dff1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:41:24 -0500 Subject: [PATCH 0709/2214] [light] Shorten Empty comment to one line --- esphome/components/light/automation.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 2dc8c08ee0..817c0e3296 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -24,8 +24,7 @@ template class ToggleAction : public Action { LightState *state_; }; -// Distinct empty types per field tag so [[no_unique_address]] always coalesces -// (the same empty type used multiple times is not guaranteed to share an address). +// Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. namespace light_control_detail { template struct Empty {}; } // namespace light_control_detail From 13250897ad847902f5f4670b60864dcf1ed0c34c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:47:52 -0500 Subject: [PATCH 0710/2214] [light] Add dim_relative test case with transition_length --- tests/components/light/common.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index e1216e7b60..e58f7baee4 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -108,6 +108,10 @@ esphome: relative_brightness: 5% brightness_limits: max_brightness: 90% + - light.dim_relative: + id: test_monochromatic_light + relative_brightness: -5% + transition_length: 250ms - light.turn_on: id: test_addressable_transition brightness: 50% From 2e096bb036abfefa0e86d3830a55b9e5aeecd270 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:54:15 -0500 Subject: [PATCH 0711/2214] [core] Combine set_component_source_ + register_component_ into one call (#16029) --- esphome/core/application.h | 6 +++++- esphome/cpp_helpers.py | 6 +++--- tests/component_tests/deep_sleep/test_deep_sleep.py | 2 +- tests/component_tests/ota/test_web_server_ota.py | 2 +- tests/unit_tests/test_cpp_helpers.py | 10 ++++++---- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index bc09f7d38c..c0b2639bd1 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -382,7 +382,11 @@ class Application { /// Register a component, detecting loop() override at compile time. /// Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance. - template void register_component_(T *comp) { + /// Optionally sets the component source index in the same call to avoid emitting + /// a separate set_component_source_() line in generated code. + template void register_component_(T *comp, uint8_t source_index = 0) { + if (source_index != 0) + comp->set_component_source_(source_index); this->register_component_impl_(comp, HasLoopOverride::value); } diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index f2bd3b92a3..b035e28a7a 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -197,9 +197,9 @@ async def register_component(var, config): ) if name is not None: idx = register_component_source(name) - add(var.set_component_source_(idx)) - - add(App.register_component_(var)) + add(App.register_component_(var, idx)) + else: + add(App.register_component_(var)) # Collect C++ type for compile-time looping component count comp_entries = CORE.data.setdefault("looping_component_entries", []) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index 8c1278a332..84128d75d7 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -12,7 +12,7 @@ def test_deep_sleep_setup(generate_main): in main_cpp ) assert "new(deepsleep) deep_sleep::DeepSleepComponent();" in main_cpp - assert "App.register_component_(deepsleep);" in main_cpp + assert "App.register_component_(deepsleep, " in main_cpp def test_deep_sleep_sleep_duration(generate_main): diff --git a/tests/component_tests/ota/test_web_server_ota.py b/tests/component_tests/ota/test_web_server_ota.py index 4b3a4c705c..4b8b7540e8 100644 --- a/tests/component_tests/ota/test_web_server_ota.py +++ b/tests/component_tests/ota/test_web_server_ota.py @@ -27,7 +27,7 @@ def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None: assert "global_web_server_base" in main_cpp # Check component is registered - assert "App.register_component_(web_server_webserverotacomponent_id)" in main_cpp + assert "App.register_component_(web_server_webserverotacomponent_id" in main_cpp def test_web_server_ota_with_callbacks(generate_main: Callable[[str], str]) -> None: diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index a76ea21c23..e389b56ada 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -34,8 +34,9 @@ async def test_register_component(monkeypatch): actual = await ch.register_component(var, {}) assert actual is var - assert add_mock.call_count == 2 - app_mock.register_component_.assert_called_with(var) + assert add_mock.call_count == 1 + app_mock.register_component_.assert_called_once() + assert app_mock.register_component_.call_args.args[0] is var assert core_mock.component_ids == [] @@ -77,8 +78,9 @@ async def test_register_component__with_setup_priority(monkeypatch): assert actual is var add_mock.assert_called() - assert add_mock.call_count == 4 - app_mock.register_component_.assert_called_with(var) + assert add_mock.call_count == 3 + app_mock.register_component_.assert_called_once() + assert app_mock.register_component_.call_args.args[0] is var assert core_mock.component_ids == [] From 9a468eef8c206f09271e9ccdd997de4bc26e86f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:54:33 -0500 Subject: [PATCH 0712/2214] [light] Undef LIGHT_CONTROL_FIELDS macro and assert <=16 fields --- esphome/components/light/automation.h | 1 + esphome/components/light/automation.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 817c0e3296..eda30bd786 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -77,6 +77,7 @@ template class LightControlAction : public Acti #undef LIGHT_FIELD_APPLY_ #undef LIGHT_FIELD_SETTER_ }; +#undef LIGHT_CONTROL_FIELDS template class DimRelativeAction : public Action { public: diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 1062d6de3a..ea953e3199 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -197,6 +197,8 @@ async def light_control_to_code(config, action_id, template_arg, args): (CONF_WARM_WHITE, "set_warm_white", cg.float_), (CONF_EFFECT, None, cg.uint32), ) + # Bitmask is passed as uint16_t in C++ — must stay within 16 bits. + assert len(FIELDS) <= 16, "LightControlAction Fields bitmask exceeds uint16_t" field_mask = sum(1 << i for i, (k, _, _) in enumerate(FIELDS) if k in config) control_template_arg = cg.TemplateArguments( From 112646a9c4ba92f49c3589e664d064f56193dee3 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 27 Apr 2026 05:02:09 +0200 Subject: [PATCH 0713/2214] [zigbee] add router for nrf52 (#16034) --- esphome/components/zigbee/__init__.py | 13 +++++++++---- esphome/components/zigbee/zigbee_zephyr.cpp | 2 ++ esphome/components/zigbee/zigbee_zephyr.py | 6 +++++- tests/components/zigbee/test.nrf52-mcumgr.yaml | 3 +++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 0bb5f95bb6..018dab7348 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -75,6 +75,13 @@ SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor) SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) + +def _validate_router_sleepy(config: ConfigType) -> ConfigType: + if config.get(CONF_ROUTER) and config.get(CONF_SLEEPY): + raise cv.Invalid("router and sleepy are mutually exclusive") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -82,10 +89,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MODEL, default=CORE.name): cv.All( cv.string, cv.Length(max=31) ), - cv.OnlyWith(CONF_ROUTER, "esp32", default=False): cv.All( - cv.requires_component("esp32"), - cv.boolean, - ), + cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): cv.All( cv.requires_component("nrf52"), automation.validate_automation(single=True), @@ -113,6 +117,7 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), + _validate_router_sleepy, zigbee_require_vfs_select, zigbee_set_core_data, cv.Any( diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 90bb66c91d..dfffd1c91f 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -190,7 +190,9 @@ void ZigbeeComponent::setup() { ESP_LOGE(TAG, "Cannot load settings, err: %d", err); return; } +#ifdef CONFIG_ZIGBEE_ROLE_END_DEVICE zigbee_configure_sleepy_behavior(this->sleepy_); +#endif zigbee_enable(); } diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 7d904b6081..b74074e50f 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -52,6 +52,7 @@ from esphome.types import ConfigType from .const import ( CONF_ON_JOIN, CONF_POWER_SOURCE, + CONF_ROUTER, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, POWER_SOURCE, @@ -160,7 +161,10 @@ zephyr_number = cv.Schema( async def zephyr_to_code(config: ConfigType) -> None: zephyr_add_prj_conf("ZIGBEE", True) zephyr_add_prj_conf("ZIGBEE_APP_UTILS", True) - zephyr_add_prj_conf("ZIGBEE_ROLE_END_DEVICE", True) + if config[CONF_ROUTER]: + zephyr_add_prj_conf("ZIGBEE_ROLE_ROUTER", True) + else: + zephyr_add_prj_conf("ZIGBEE_ROLE_END_DEVICE", True) zephyr_add_prj_conf("ZIGBEE_CHANNEL_SELECTION_MODE_MULTI", True) diff --git a/tests/components/zigbee/test.nrf52-mcumgr.yaml b/tests/components/zigbee/test.nrf52-mcumgr.yaml index bf3cb9cdd9..a81feea069 100644 --- a/tests/components/zigbee/test.nrf52-mcumgr.yaml +++ b/tests/components/zigbee/test.nrf52-mcumgr.yaml @@ -1 +1,4 @@ <<: !include common_nrf52.yaml + +zigbee: + router: true From 79b741b8dc452de8430de732ae7ba316747456fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 22:03:39 -0500 Subject: [PATCH 0714/2214] [core] Combine entity register + configure_entity_ into one call (#16030) --- .../alarm_control_panel/__init__.py | 8 +++-- esphome/components/binary_sensor/__init__.py | 3 +- esphome/components/button/__init__.py | 3 +- esphome/components/climate/__init__.py | 8 +++-- esphome/components/cover/__init__.py | 3 +- esphome/components/datetime/__init__.py | 8 +++-- esphome/components/event/__init__.py | 3 +- esphome/components/fan/__init__.py | 8 +++-- esphome/components/infrared/__init__.py | 4 +-- esphome/components/light/__init__.py | 8 +++-- esphome/components/lock/__init__.py | 8 +++-- esphome/components/media_player/__init__.py | 3 +- esphome/components/number/__init__.py | 3 +- .../components/radio_frequency/__init__.py | 4 +-- esphome/components/select/__init__.py | 8 +++-- esphome/components/sensor/__init__.py | 3 +- esphome/components/switch/__init__.py | 3 +- esphome/components/text/__init__.py | 8 +++-- esphome/components/text_sensor/__init__.py | 3 +- esphome/components/update/__init__.py | 3 +- esphome/components/valve/__init__.py | 3 +- esphome/components/water_heater/__init__.py | 8 +++-- esphome/core/application.h | 13 ++++++-- esphome/core/entity_base.h | 3 ++ esphome/core/entity_helpers.py | 32 +++++++++++++++++-- .../binary_sensor/test_binary_sensor.py | 2 +- tests/component_tests/button/test_button.py | 2 +- tests/component_tests/helpers.py | 20 +++++++++--- tests/component_tests/text/test_text.py | 2 +- .../text_sensor/test_text_sensor.py | 6 ++-- 30 files changed, 145 insertions(+), 48 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 9fcdf42ecb..2f5d4c7c2b 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -13,7 +13,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@grahambrown11", "@hwstar"] @@ -181,7 +185,7 @@ async def setup_alarm_control_panel_core_(var, config): async def register_alarm_control_panel(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_alarm_control_panel(var)) + queue_entity_register("alarm_control_panel", config) CORE.register_platform_component("alarm_control_panel", var) await setup_alarm_control_panel_core_(var, config) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 29ddbab02c..1456e5bc66 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -62,6 +62,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -624,7 +625,7 @@ async def setup_binary_sensor_core_(var, config): async def register_binary_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_binary_sensor(var)) + queue_entity_register("binary_sensor", config) CORE.register_platform_component("binary_sensor", var) await setup_binary_sensor_core_(var, config) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 2c19ea69b1..dd4fde5705 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -101,7 +102,7 @@ async def setup_button_core_(var, config): async def register_button(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_button(var)) + queue_entity_register("button", config) CORE.register_platform_component("button", var) await setup_button_core_(var, config) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index df77fa5c1c..0fdb18a92c 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -49,7 +49,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True @@ -442,7 +446,7 @@ async def setup_climate_core_(var, config): async def register_climate(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_climate(var)) + queue_entity_register("climate", config) CORE.register_platform_component("climate", var) await setup_climate_core_(var, config) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index fdfca55f0f..41efd2ba7a 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -39,6 +39,7 @@ from esphome.const import ( 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, ) @@ -232,7 +233,7 @@ async def setup_cover_core_(var, config): async def register_cover(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_cover(var)) + queue_entity_register("cover", config) CORE.register_platform_component("cover", var) await setup_cover_core_(var, config) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 895ac4e243..87997daa3d 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -22,7 +22,11 @@ from esphome.const import ( CONF_YEAR, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -160,7 +164,7 @@ async def register_datetime(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) entity_type = config[CONF_TYPE].lower() - cg.add(getattr(cg.App, f"register_{entity_type}")(var)) + queue_entity_register(entity_type, config) CORE.register_platform_component(entity_type, var) await setup_datetime_core_(var, config) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 9c9dd025b1..4cab1bff9b 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -108,7 +109,7 @@ async def setup_event_core_(var, config, *, event_types: list[str]): async def register_event(var, config, *, event_types: list[str]): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_event(var)) + queue_entity_register("event", config) CORE.register_platform_component("event", var) await setup_event_core_(var, config, event_types=event_types) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index ce1e55d36b..713f20fb95 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -32,7 +32,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) IS_PLATFORM_COMPONENT = True @@ -292,7 +296,7 @@ async def setup_fan_core_(var, config): async def register_fan(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_fan(var)) + queue_entity_register("fan", config) CORE.register_platform_component("fan", var) await setup_fan_core_(var, config) diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index 6a2a72fa5d..f8e77209b2 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -12,7 +12,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import setup_entity +from esphome.core.entity_helpers import queue_entity_register, setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -54,8 +54,8 @@ async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: """Register an infrared device with the core.""" cg.add_define("USE_IR_RF") await cg.register_component(var, config) + queue_entity_register("infrared", config) await setup_infrared_core_(var, config) - cg.add(cg.App.register_infrared(var)) CORE.register_platform_component("infrared", var) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 5925afb472..9540c64486 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -40,7 +40,11 @@ from esphome.const import ( CONF_WHITE, ) from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass import esphome.final_validate as fv from esphome.types import ConfigType @@ -405,7 +409,7 @@ async def setup_light_core_(light_var, config, output_var): async def register_light(output_var, config): light_var = cg.new_Pvariable(config[CONF_ID], output_var) - cg.add(cg.App.register_light(light_var)) + queue_entity_register("light", config) CORE.register_platform_component("light", light_var) await cg.register_component(light_var, config) await setup_light_core_(light_var, config, output_var) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index a36d52a5d8..0a8ad58bc2 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -13,7 +13,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -112,7 +116,7 @@ async def _setup_lock_core(var, config): async def register_lock(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_lock(var)) + queue_entity_register("lock", config) CORE.register_platform_component("lock", var) await _setup_lock_core(var, config) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index d1db868ace..0024e3b965 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -21,6 +21,7 @@ from esphome.core import CORE from esphome.core.entity_helpers import ( entity_duplicate_validator, inherit_property_from, + queue_entity_register, setup_entity, ) from esphome.coroutine import CoroPriority, coroutine_with_priority @@ -262,7 +263,7 @@ async def setup_media_player_core_(var, config): async def register_media_player(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_media_player(var)) + queue_entity_register("media_player", config) CORE.register_platform_component("media_player", var) await setup_media_player_core_(var, config) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index f13ccc4c36..ee2d53c65a 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -82,6 +82,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, @@ -301,7 +302,7 @@ async def register_number( ): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_number(var)) + queue_entity_register("number", config) CORE.register_platform_component("number", var) await setup_number_core_( var, config, min_value=min_value, max_value=max_value, step=step diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index b00590ceb5..a54ab6e249 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -12,7 +12,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import setup_entity +from esphome.core.entity_helpers import queue_entity_register, setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -55,8 +55,8 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non """Register a radio frequency device with the core.""" cg.add_define("USE_RADIO_FREQUENCY") await cg.register_component(var, config) + queue_entity_register("radio_frequency", config) await setup_radio_frequency_core_(var, config) - cg.add(cg.App.register_radio_frequency(var)) CORE.register_platform_component("radio_frequency", var) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index ba5214e550..f561c030a4 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -19,7 +19,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass, TemplateArguments from esphome.cpp_types import global_ns @@ -113,7 +117,7 @@ async def setup_select_core_(var, config, *, options: list[str]): async def register_select(var, config, *, options: list[str]): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_select(var)) + queue_entity_register("select", config) CORE.register_platform_component("select", var) await setup_select_core_(var, config, options=options) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 43fbc98953..48b7d25d4d 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -109,6 +109,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, @@ -982,7 +983,7 @@ async def setup_sensor_core_(var, config): async def register_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_sensor(var)) + queue_entity_register("sensor", config) CORE.register_platform_component("sensor", var) await setup_sensor_core_(var, config) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 9fa4a013ff..1108652e99 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -23,6 +23,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -166,7 +167,7 @@ async def setup_switch_core_(var, config): async def register_switch(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_switch(var)) + queue_entity_register("switch", config) CORE.register_platform_component("switch", var) await setup_switch_core_(var, config) diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 224f4580d4..06b5a10892 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -14,7 +14,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@mauritskorse"] @@ -122,7 +126,7 @@ async def register_text( ): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_text(var)) + queue_entity_register("text", config) CORE.register_platform_component("text", var) await setup_text_core_( var, config, min_length=min_length, max_length=max_length, pattern=pattern diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 94014e8d20..01a57cbaa1 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -22,6 +22,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -221,7 +222,7 @@ async def setup_text_sensor_core_(var, config): async def register_text_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_text_sensor(var)) + queue_entity_register("text_sensor", config) CORE.register_platform_component("text_sensor", var) await setup_text_sensor_core_(var, config) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index db6c1445e3..ddb471be18 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -17,6 +17,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -113,7 +114,7 @@ async def setup_update_core_(var, config): async def register_update(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_update(var)) + queue_entity_register("update", config) CORE.register_platform_component("update", var) await setup_update_core_(var, config) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 1930a7ad0c..a6808c9da7 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -162,7 +163,7 @@ async def _setup_valve_core(var, config): async def register_valve(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_valve(var)) + queue_entity_register("valve", config) CORE.register_platform_component("valve", var) await _setup_valve_core(var, config) diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index 58cf5a4054..f3eec16a40 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -9,7 +9,11 @@ from esphome.const import ( CONF_VISUAL, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType @@ -90,7 +94,7 @@ async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pva cg.add_define("USE_WATER_HEATER") - cg.add(cg.App.register_water_heater(var)) + queue_entity_register("water_heater", config) CORE.register_platform_component("water_heater", var) await setup_water_heater_core_(var, config) diff --git a/esphome/core/application.h b/esphome/core/application.h index c0b2639bd1..185ee4163b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -103,10 +103,19 @@ class Application { void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } -// Entity register methods (generated from entity_types.h) +// Entity register methods (generated from entity_types.h). +// Each entity type gets two overloads: +// - register_(obj) — bare push_back +// - register_(obj, name, hash, fields) — configure_entity_ + push_back +// The 4-arg form lets codegen collapse `App.register_(obj); obj->configure_entity_(...);` +// into a single call site, saving flash and a `main.cpp` line per entity. // 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) { this->plural##_.push_back(obj); } \ + 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) \ ENTITY_TYPE_(type, singular, plural, count, upper) #include "esphome/core/entity_types.h" diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 5a69c9dd09..2726a92c97 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -238,6 +238,9 @@ class EntityBase { protected: friend void ::setup(); friend void ::original_setup(); + // Application's register_(obj, name, hash, fields) overloads call configure_entity_ + // before push_back, so codegen can emit a single combined call per entity. + friend class Application; /// 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. diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index f09dd013fe..ff60260280 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -23,6 +23,7 @@ from esphome.core.config import ( UNIT_OF_MEASUREMENT_MAX_LENGTH, ) 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_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata @@ -52,6 +53,12 @@ _KEY_INTERNAL = "_entity_internal" _KEY_DISABLED_BY_DEFAULT = "_entity_disabled_by_default" _KEY_ENTITY_CATEGORY = "_entity_category" +# Private config key for the App.register_ entry point. +# When set, finalize_entity_strings() emits a single combined call +# `App.register_(var, name, hash, packed)` instead of separate +# `App.register_(var)` and `var->configure_entity_(...)` calls. +_KEY_REGISTER_METHOD = "_entity_register_method" + # Maximum unique strings per category (8-bit index, 0 = not set) _MAX_DEVICE_CLASSES = 0xFF # 255 _MAX_UNITS = 0xFF # 255 @@ -271,11 +278,26 @@ def _describe_packed_flags(config: ConfigType, entity_category: int) -> str: return ", ".join(parts) +def queue_entity_register(method_name: str, config: ConfigType) -> None: + """Defer ``App.register_(var)`` emission to ``finalize_entity_strings``. + + When the deferred call is emitted, it is folded with ``configure_entity_`` into + a single ``App.register_(var, name, hash, packed)`` call site, + which removes one statement and one method dispatch per entity from the + generated ``main.cpp``. + """ + config[_KEY_REGISTER_METHOD] = method_name + + def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: - """Emit a single configure_entity_() call with name, hash, packed string indices, and flags. + """Emit the entity-registration / configure_entity_ tail. Call this at the end of each component's setup function, after setup_entity() and any register_device_class/register_unit_of_measurement calls. + + If queue_entity_register() was called for this entity, emits one combined call + ``App.register_(var, name, hash, packed)``. Otherwise falls back to a + standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] object_id_hash = config[_KEY_OBJECT_ID_HASH] @@ -295,7 +317,13 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: ) # Build inline comment describing the packed flags for readability comment = _describe_packed_flags(config, entity_category) - expr = var.configure_entity_(entity_name, object_id_hash, packed) + register_method = config.get(_KEY_REGISTER_METHOD) + if register_method is not None: + expr = getattr(App, f"register_{register_method}")( + var, entity_name, object_id_hash, packed + ) + else: + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index 4f41f2cc70..e1d999abc7 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -31,7 +31,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'bs_1->configure_entity_("test bs1",' in main_cpp + assert 'App.register_binary_sensor(bs_1, "test bs1",' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index 544e748f91..f8881a832c 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -29,7 +29,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'wol_1->configure_entity_("wol_test_1",' in main_cpp + assert 'App.register_button(wol_1, "wol_test_1",' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/helpers.py b/tests/component_tests/helpers.py index 568d1639d0..2eb588c0ca 100644 --- a/tests/component_tests/helpers.py +++ b/tests/component_tests/helpers.py @@ -8,12 +8,22 @@ INTERNAL_BIT = 1 << 24 def extract_packed_value(main_cpp: str, var_name: str) -> int: - """Extract the third (packed) argument from a configure_entity_ call.""" - pattern = ( - rf"{re.escape(var_name)}->configure_entity_\(" + """Extract the packed-fields argument from the entity's configure call. + + Matches both legacy form ``var->configure_entity_(name, hash, packed)`` and the + combined form ``App.register_(var, name, hash, packed)``. + """ + escaped_var = re.escape(var_name) + legacy_pattern = ( + rf"{escaped_var}->configure_entity_\(" r'"(?:\\.|[^"\\])*"' r",\s*\w+,\s*(\d+)\)" ) - match = re.search(pattern, main_cpp) - assert match, f"configure_entity_ call not found for {var_name}" + combined_pattern = ( + rf"App\.register_\w+\(\s*{escaped_var}\s*,\s*" + r'"(?:\\.|[^"\\])*"' + r",\s*\w+,\s*(\d+)\)" + ) + match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp) + assert match, f"configure call not found for {var_name}" return int(match.group(1)) diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 63eb4f1951..f5ac07c1cd 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -28,7 +28,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'it_1->configure_entity_("test 1 text",' in main_cpp + assert 'App.register_text(it_1, "test 1 text",' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index ae094fadf8..eb25af3095 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -28,9 +28,9 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'ts_1->configure_entity_("Template Text Sensor 1",' in main_cpp - assert 'ts_2->configure_entity_("Template Text Sensor 2",' in main_cpp - assert 'ts_3->configure_entity_("Template Text Sensor 3",' in main_cpp + assert 'App.register_text_sensor(ts_1, "Template Text Sensor 1",' in main_cpp + assert 'App.register_text_sensor(ts_2, "Template Text Sensor 2",' in main_cpp + assert 'App.register_text_sensor(ts_3, "Template Text Sensor 3",' in main_cpp def test_text_sensor_config_value_internal_set(generate_main): From f40eb9b78067f591cc3dd54108157979714ea3b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 22:17:26 -0500 Subject: [PATCH 0715/2214] [core] Inline ContinuationAction in IfAction/WhileAction/RepeatAction Replace heap-allocated ContinuationAction/WhileLoopContinuation/ RepeatLoopContinuation instances with inline members, eliminating one heap allocation per IfAction/WhileAction/RepeatAction at setup. Each parent already needs exactly one continuation as the chain terminator that hands control back. Heap-allocating it costs the ~16-byte object plus an ~8-byte heap header per instance, plus heap fragmentation. Inlining moves the same 16 bytes from heap to BSS and drops the heap header overhead. Per-parent net change: - BSS: +16 B (the inline continuation; +32 B for IfAction) - Heap: -24 B per heap allocation eliminated (16 B object + ~8 B header) - Net RAM saved: ~8 B per simple parent, ~16 B for IfAction - Plus: one fewer heap allocation per parent at setup, less fragmentation For IfAction, the else continuation is elided via [[no_unique_address]] + an empty wrapper struct, so it costs 0 B. Note: CI memory analysis only measures static RAM (BSS), not heap. This change moves bytes from heap to BSS, so the report will show BSS increasing while the actual heap savings (and fragmentation reduction) are not directly visible. --- esphome/core/base_automation.h | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 17f937d10d..9418170b6d 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -273,18 +273,28 @@ template class WhileLoopContinuation : public Action { WhileAction *parent_; }; +// Wraps a ContinuationAction when Enabled, empty otherwise. +// Lets IfAction elide the else continuation when HasElse is false. +template struct OptionalContinuation { + ContinuationAction action; + explicit OptionalContinuation(Action *parent) : action(parent) {} +}; +template struct OptionalContinuation { + explicit OptionalContinuation(Action * /*parent*/) {} +}; + template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new ContinuationAction(this)); + this->then_.add_action(&this->then_continuation_); } void add_else(const std::initializer_list *> &actions) requires(HasElse) { this->else_.add_actions(actions); - this->else_.add_action(new ContinuationAction(this)); + this->else_.add_action(&this->else_continuation_.action); } void play_complex(const Ts &...x) override { @@ -316,8 +326,10 @@ template class IfAction : public Action { protected: Condition *condition_; ActionList then_; + ContinuationAction then_continuation_{this}; struct NoElse {}; [[no_unique_address]] std::conditional_t, NoElse> else_; + [[no_unique_address]] OptionalContinuation else_continuation_{this}; }; template class WhileAction : public Action { @@ -326,7 +338,7 @@ template class WhileAction : public Action { void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new WhileLoopContinuation(this)); + this->then_.add_action(&this->loop_continuation_); } friend class WhileLoopContinuation; @@ -354,6 +366,7 @@ template class WhileAction : public Action { protected: Condition *condition_; ActionList then_; + WhileLoopContinuation loop_continuation_{this}; }; // Implementation of WhileLoopContinuation::play @@ -388,7 +401,7 @@ template class RepeatAction : public Action { void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new RepeatLoopContinuation(this)); + this->then_.add_action(&this->loop_continuation_); } friend class RepeatLoopContinuation; @@ -409,6 +422,7 @@ template class RepeatAction : public Action { protected: ActionList then_; + RepeatLoopContinuation loop_continuation_{this}; }; // Implementation of RepeatLoopContinuation::play From e85de4d34a2d80ada1bd6853c365e6871e559478 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 22:32:26 -0500 Subject: [PATCH 0716/2214] [core] Document add_then/add_else single-call precondition --- esphome/core/base_automation.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 9418170b6d..afd11c6867 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -287,6 +287,10 @@ template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} + // Precondition: add_then/add_else must be called at most once per instance. + // Codegen always batches the full action list into a single call. Calling + // twice would re-append the same inline continuation pointer and form a + // self-loop in the next_ chain. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->then_continuation_); @@ -336,6 +340,7 @@ template class WhileAction : public Action { public: WhileAction(Condition *condition) : condition_(condition) {} + // Precondition: must be called at most once per instance (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); @@ -399,6 +404,7 @@ template class RepeatAction : public Action { public: TEMPLATABLE_VALUE(uint32_t, count) + // Precondition: must be called at most once per instance (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); From 83d879265e8a2ffbff2866c549cfcb7e15d3da66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 22:32:26 -0500 Subject: [PATCH 0717/2214] [core] Document add_then/add_else single-call precondition --- esphome/core/base_automation.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 9418170b6d..afd11c6867 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -287,6 +287,10 @@ template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} + // Precondition: add_then/add_else must be called at most once per instance. + // Codegen always batches the full action list into a single call. Calling + // twice would re-append the same inline continuation pointer and form a + // self-loop in the next_ chain. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->then_continuation_); @@ -336,6 +340,7 @@ template class WhileAction : public Action { public: WhileAction(Condition *condition) : condition_(condition) {} + // Precondition: must be called at most once per instance (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); @@ -399,6 +404,7 @@ template class RepeatAction : public Action { public: TEMPLATABLE_VALUE(uint32_t, count) + // Precondition: must be called at most once per instance (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); From 1a02f7e41f1a495b67291be4135e83121c774497 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 04:36:44 -0500 Subject: [PATCH 0718/2214] [climate] Use bitmask template parameter for ControlAction unused fields Apply the bitmask pattern from LightControlAction (#16039) to climate::ControlAction. Parameterize on a uint16_t Fields bitmask encoding which of the 10 templatable fields are configured. Unused fields are elided via [[no_unique_address]] and skipped in play() via if constexpr. Also drop the unused `away` TEMPLATABLE_VALUE -- its YAML key is cv.invalid("Use preset instead") and no caller of set_away exists in the repo. Per-instance: 20 B (mode only) to 28 B (mode + 2 temps), down from ~64-72 B baseline. --- esphome/components/climate/__init__.py | 65 +++++++++++++------------ esphome/components/climate/automation.h | 65 +++++++++++++++---------- 2 files changed, 74 insertions(+), 56 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 0fdb18a92c..413a2a5b56 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -487,37 +487,40 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( ) async def climate_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (mode := config.get(CONF_MODE)) is not None: - template_ = await cg.templatable(mode, args, ClimateMode) - cg.add(var.set_mode(template_)) - if (target_temp := config.get(CONF_TARGET_TEMPERATURE)) is not None: - template_ = await cg.templatable(target_temp, args, cg.float_) - cg.add(var.set_target_temperature(template_)) - if (target_temp_low := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None: - template_ = await cg.templatable(target_temp_low, args, cg.float_) - cg.add(var.set_target_temperature_low(template_)) - if (target_temp_high := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None: - template_ = await cg.templatable(target_temp_high, args, cg.float_) - cg.add(var.set_target_temperature_high(template_)) - if (target_humidity := config.get(CONF_TARGET_HUMIDITY)) is not None: - template_ = await cg.templatable(target_humidity, args, cg.float_) - cg.add(var.set_target_humidity(template_)) - if (fan_mode := config.get(CONF_FAN_MODE)) is not None: - template_ = await cg.templatable(fan_mode, args, ClimateFanMode) - cg.add(var.set_fan_mode(template_)) - if (custom_fan_mode := config.get(CONF_CUSTOM_FAN_MODE)) is not None: - template_ = await cg.templatable(custom_fan_mode, args, cg.std_string) - cg.add(var.set_custom_fan_mode(template_)) - if (preset := config.get(CONF_PRESET)) is not None: - template_ = await cg.templatable(preset, args, ClimatePreset) - cg.add(var.set_preset(template_)) - if (custom_preset := config.get(CONF_CUSTOM_PRESET)) is not None: - template_ = await cg.templatable(custom_preset, args, cg.std_string) - cg.add(var.set_custom_preset(template_)) - if (swing_mode := config.get(CONF_SWING_MODE)) is not None: - template_ = await cg.templatable(swing_mode, args, ClimateSwingMode) - cg.add(var.set_swing_mode(template_)) + + # Order/bits must match CLIMATE_CONTROL_FIELDS in automation.h. + FIELDS = ( + (CONF_MODE, "set_mode", ClimateMode), + (CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), + (CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_), + (CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_), + (CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), + (CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), + ( + CONF_CUSTOM_FAN_MODE, + "set_custom_fan_mode", + cg.std_string, + ), # internal setter name + (CONF_PRESET, "set_preset", ClimatePreset), + ( + CONF_CUSTOM_PRESET, + "set_custom_preset", + cg.std_string, + ), # internal setter name + (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), + ) + assert len(FIELDS) <= 16, "ControlAction Fields bitmask exceeds uint16_t" + + field_mask = sum(1 << i for i, (k, _, _) in enumerate(FIELDS) if k in config) + control_template_arg = cg.TemplateArguments( + cg.RawExpression(f"static_cast({field_mask})"), *template_arg + ) + var = cg.new_Pvariable(action_id, control_template_arg, paren) + + for conf_key, setter, type_ in FIELDS: + if (value := config.get(conf_key)) is not None: + template_ = await cg.templatable(value, args, type_) + cg.add(getattr(var, setter)(template_)) return var diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index fac56d9d9e..9e38ef6407 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -5,43 +5,58 @@ namespace esphome::climate { -template class ControlAction : public Action { +// Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. +namespace climate_control_detail { +template struct Empty {}; +} // namespace climate_control_detail + +// X-macro: (type, field_name, call_setter, bit_index). Order and bit values must +// match the FIELDS table in __init__.py. call_setter is the ClimateCall method +// invoked in play() — for custom_fan_mode/custom_preset this dispatches to the +// std::string overload of set_fan_mode/set_preset respectively. +#define CLIMATE_CONTROL_FIELDS(X) \ + X(ClimateMode, mode, set_mode, 0) \ + X(float, target_temperature, set_target_temperature, 1) \ + X(float, target_temperature_low, set_target_temperature_low, 2) \ + X(float, target_temperature_high, set_target_temperature_high, 3) \ + X(float, target_humidity, set_target_humidity, 4) \ + X(ClimateFanMode, fan_mode, set_fan_mode, 5) \ + X(std::string, custom_fan_mode, set_fan_mode, 6) \ + X(ClimatePreset, preset, set_preset, 7) \ + X(std::string, custom_preset, set_preset, 8) \ + X(ClimateSwingMode, swing_mode, set_swing_mode, 9) + +template class ControlAction : public Action { public: explicit ControlAction(Climate *climate) : climate_(climate) {} - TEMPLATABLE_VALUE(ClimateMode, mode) - TEMPLATABLE_VALUE(float, target_temperature) - TEMPLATABLE_VALUE(float, target_temperature_low) - TEMPLATABLE_VALUE(float, target_temperature_high) - TEMPLATABLE_VALUE(float, target_humidity) - TEMPLATABLE_VALUE(bool, away) - TEMPLATABLE_VALUE(ClimateFanMode, fan_mode) - TEMPLATABLE_VALUE(std::string, custom_fan_mode) - TEMPLATABLE_VALUE(ClimatePreset, preset) - TEMPLATABLE_VALUE(std::string, custom_preset) - TEMPLATABLE_VALUE(ClimateSwingMode, swing_mode) +#define CLIMATE_FIELD_SETTER_(type, name, call_setter, idx) \ + template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } +#define CLIMATE_FIELD_APPLY_(type, name, call_setter, idx) \ + if constexpr ((Fields & (1 << (idx))) != 0) \ + call.call_setter(this->name##_.value(x...)); +#define CLIMATE_FIELD_DECL_(type, name, call_setter, idx) \ + [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableStorage, \ + climate_control_detail::Empty<(idx)>> \ + name##_{}; + + CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_SETTER_) void play(const Ts &...x) override { auto call = this->climate_->make_call(); - call.set_mode(this->mode_.optional_value(x...)); - call.set_target_temperature(this->target_temperature_.optional_value(x...)); - call.set_target_temperature_low(this->target_temperature_low_.optional_value(x...)); - call.set_target_temperature_high(this->target_temperature_high_.optional_value(x...)); - call.set_target_humidity(this->target_humidity_.optional_value(x...)); - if (away_.has_value()) { - call.set_preset(away_.value(x...) ? CLIMATE_PRESET_AWAY : CLIMATE_PRESET_HOME); - } - call.set_fan_mode(this->fan_mode_.optional_value(x...)); - call.set_fan_mode(this->custom_fan_mode_.optional_value(x...)); - call.set_preset(this->preset_.optional_value(x...)); - call.set_preset(this->custom_preset_.optional_value(x...)); - call.set_swing_mode(this->swing_mode_.optional_value(x...)); + CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_APPLY_) call.perform(); } protected: Climate *climate_; + CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_DECL_) + +#undef CLIMATE_FIELD_DECL_ +#undef CLIMATE_FIELD_APPLY_ +#undef CLIMATE_FIELD_SETTER_ }; +#undef CLIMATE_CONTROL_FIELDS class ControlTrigger : public Trigger { public: From c30aa4aad4c34df7226d4dc5002e101aeca000c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 04:55:43 -0500 Subject: [PATCH 0719/2214] [core] wakeable_delay: yield on already-woken fast path (ESP8266, RP2040) When `g_main_loop_woke` is already set on entry to `wakeable_delay()`, both the ESP8266 and RP2040 paths consume the flag and return without yielding. That's safe in isolation, but if a caller loops on `wakeable_delay()` (e.g. `LWIPRawImpl::wait_for_data_()` waiting for SO_RCVTIMEO), and ISR sources (GPIO, timer, WiFi RX on ESP8266; alarm / async on RP2040) keep re-setting the flag between iterations, every iteration takes the fast path and the loop never yields. That can starve the SDK / async context, blocking actual TCP delivery to our socket and causing OTA reads to time out on busy devices even though there is data in flight. The PR that introduced `wait_for_data_()` (#14675) relied on `wakeable_delay()` to yield on every iteration; this restores that property on the fast path. Adds `delay(0)` on ESP8266 and `yield()` on RP2040 to the fast path, matching the yield behaviour those platforms already use for the `ms == 0` poll case. --- esphome/core/wake/wake_esp8266.h | 4 ++++ esphome/core/wake/wake_rp2040.cpp | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 80cd61035b..7eaaae5293 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -36,6 +36,10 @@ inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { } if (g_main_loop_woke) { g_main_loop_woke = false; + // Yield even on the already-woken fast path so callers in tight loops + // (e.g. lwIP raw TCP wait_for_data_) make forward progress when ISRs + // keep re-setting g_main_loop_woke between iterations. + delay(0); return; } esp_delay(ms, []() { return !g_main_loop_woke; }); diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp index b18248dbd2..bdcbb1ad00 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2040.cpp @@ -36,6 +36,10 @@ void wakeable_delay(uint32_t ms) { } if (g_main_loop_woke) { g_main_loop_woke = false; + // Yield even on the already-woken fast path so callers in tight loops + // (e.g. lwIP raw TCP wait_for_data_) make forward progress when async + // wakes keep re-setting g_main_loop_woke between iterations. + yield(); return; } s_delay_expired = false; From feaa903056d17382c93618538157d3f50344a6c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 04:58:35 -0500 Subject: [PATCH 0720/2214] [cover] Use bitmask template parameter for ControlAction/CoverPublishAction Apply the bitmask pattern from LightControlAction (#16039) to cover::ControlAction (3 fields: stop, position, tilt) and cover::CoverPublishAction (3 fields: position, tilt, current_operation). Unused fields are elided via [[no_unique_address]] and skipped at compile time in play() via if constexpr. Codegen for cover.control: and cover.template.publish: builds the bitmask from the YAML keys present. CONF_STATE and CONF_POSITION both map to the same position bit (they are mutually exclusive YAML keys for the same C++ field). Per-instance: 16-28 B depending on which fields are set, down from ~28 B baseline. --- esphome/components/cover/__init__.py | 16 +++- esphome/components/cover/automation.h | 77 ++++++++++++++----- esphome/components/template/cover/__init__.py | 16 +++- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 41efd2ba7a..25e92e5c22 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -299,7 +299,21 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema( ) async def cover_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) + + # Bit positions must match COVER_CONTROL_FIELDS in automation.h. + # CONF_STATE and CONF_POSITION both map to set_position (bit 1). + field_mask = 0 + if CONF_STOP in config: + field_mask |= 1 << 0 + if CONF_STATE in config or CONF_POSITION in config: + field_mask |= 1 << 1 + if CONF_TILT in config: + field_mask |= 1 << 2 + + control_template_arg = cg.TemplateArguments( + cg.RawExpression(f"static_cast({field_mask})"), *template_arg + ) + var = cg.new_Pvariable(action_id, control_template_arg, paren) if (stop := config.get(CONF_STOP)) is not None: template_ = await cg.templatable(stop, args, cg.bool_) cg.add(var.set_stop(template_)) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index f121e5c2d6..223c7644ea 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -46,49 +46,86 @@ template class ToggleAction : public Action { Cover *cover_; }; -template class ControlAction : public Action { +// Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. +namespace cover_action_detail { +template struct Empty {}; +} // namespace cover_action_detail + +// X-macro: (type, field_name, bit_index). Order/bits must match +// cover_control_to_code's FIELDS table in __init__.py. +#define COVER_CONTROL_FIELDS(X) \ + X(bool, stop, 0) \ + X(float, position, 1) \ + X(float, tilt, 2) + +template class ControlAction : public Action { public: explicit ControlAction(Cover *cover) : cover_(cover) {} - TEMPLATABLE_VALUE(bool, stop) - TEMPLATABLE_VALUE(float, position) - TEMPLATABLE_VALUE(float, tilt) +#define COVER_FIELD_SETTER_(type, name, idx) \ + template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } +#define COVER_FIELD_APPLY_(type, name, idx) \ + if constexpr ((Fields & (1 << (idx))) != 0) \ + call.set_##name(this->name##_.value(x...)); +#define COVER_FIELD_DECL_(type, name, idx) \ + [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableFn, \ + cover_action_detail::Empty<(idx)>> \ + name##_{}; + + COVER_CONTROL_FIELDS(COVER_FIELD_SETTER_) void play(const Ts &...x) override { auto call = this->cover_->make_call(); - if (this->stop_.has_value()) - call.set_stop(this->stop_.value(x...)); - if (this->position_.has_value()) - call.set_position(this->position_.value(x...)); - if (this->tilt_.has_value()) - call.set_tilt(this->tilt_.value(x...)); + COVER_CONTROL_FIELDS(COVER_FIELD_APPLY_) call.perform(); } protected: Cover *cover_; + COVER_CONTROL_FIELDS(COVER_FIELD_DECL_) }; +#undef COVER_CONTROL_FIELDS -template class CoverPublishAction : public Action { +// X-macro: (type, field_name, bit_index). Order/bits must match +// cover_template_publish_to_code's FIELDS table in template/cover/__init__.py. +#define COVER_PUBLISH_FIELDS(X) \ + X(float, position, 0) \ + X(float, tilt, 1) \ + X(CoverOperation, current_operation, 2) + +template class CoverPublishAction : public Action { public: CoverPublishAction(Cover *cover) : cover_(cover) {} - TEMPLATABLE_VALUE(float, position) - TEMPLATABLE_VALUE(float, tilt) - TEMPLATABLE_VALUE(CoverOperation, current_operation) + +#define COVER_PUBLISH_SETTER_(type, name, idx) \ + template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } +#define COVER_PUBLISH_APPLY_(type, name, idx) \ + if constexpr ((Fields & (1 << (idx))) != 0) \ + this->cover_->name = this->name##_.value(x...); +#define COVER_PUBLISH_DECL_(type, name, idx) \ + [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableFn, \ + cover_action_detail::Empty<(idx) + 8>> \ + name##_{}; + + COVER_PUBLISH_FIELDS(COVER_PUBLISH_SETTER_) void play(const Ts &...x) override { - if (this->position_.has_value()) - this->cover_->position = this->position_.value(x...); - if (this->tilt_.has_value()) - this->cover_->tilt = this->tilt_.value(x...); - if (this->current_operation_.has_value()) - this->cover_->current_operation = this->current_operation_.value(x...); + COVER_PUBLISH_FIELDS(COVER_PUBLISH_APPLY_) this->cover_->publish_state(); } protected: Cover *cover_; + COVER_PUBLISH_FIELDS(COVER_PUBLISH_DECL_) + +#undef COVER_PUBLISH_DECL_ +#undef COVER_PUBLISH_APPLY_ +#undef COVER_PUBLISH_SETTER_ +#undef COVER_FIELD_DECL_ +#undef COVER_FIELD_APPLY_ +#undef COVER_FIELD_SETTER_ }; +#undef COVER_PUBLISH_FIELDS template class CoverPositionCondition : public Condition { public: diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index a30c0af313..7dd3cddf47 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -128,7 +128,21 @@ async def to_code(config): ) async def cover_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) + + # Bit positions must match COVER_PUBLISH_FIELDS in cover/automation.h. + # CONF_STATE and CONF_POSITION both map to set_position (bit 0). + field_mask = 0 + if CONF_STATE in config or CONF_POSITION in config: + field_mask |= 1 << 0 + if CONF_TILT in config: + field_mask |= 1 << 1 + if CONF_CURRENT_OPERATION in config: + field_mask |= 1 << 2 + + publish_template_arg = cg.TemplateArguments( + cg.RawExpression(f"static_cast({field_mask})"), *template_arg + ) + var = cg.new_Pvariable(action_id, publish_template_arg, paren) if CONF_STATE in config: template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) cg.add(var.set_position(template_)) From 3522eef8ee4fd7f2a466b5e560544574e7a8bca9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:02:58 -0500 Subject: [PATCH 0721/2214] [cover] Add integration test for ControlAction/CoverPublishAction --- .../fixtures/cover_control_action.yaml | 110 ++++++++++++++++++ .../integration/test_cover_control_action.py | 84 +++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 tests/integration/fixtures/cover_control_action.yaml create mode 100644 tests/integration/test_cover_control_action.py diff --git a/tests/integration/fixtures/cover_control_action.yaml b/tests/integration/fixtures/cover_control_action.yaml new file mode 100644 index 0000000000..da67e98f5a --- /dev/null +++ b/tests/integration/fixtures/cover_control_action.yaml @@ -0,0 +1,110 @@ +esphome: + name: cover-control-action-test +host: +api: +logger: + level: DEBUG + +globals: + - id: test_position + type: float + initial_value: "0.42" + +cover: + - platform: template + name: "Test Cover" + id: test_cover + has_position: true + optimistic: true + assumed_state: true + open_action: + - cover.template.publish: + id: test_cover + position: 1.0 + close_action: + - cover.template.publish: + id: test_cover + position: 0.0 + stop_action: + - cover.template.publish: + id: test_cover + current_operation: IDLE + tilt_action: + - lambda: |- + // Manually set tilt and publish + id(test_cover).tilt = tilt; + id(test_cover).publish_state(); + +button: + # Test 1: cover.control with position only (mask 0b010 = 2) + - platform: template + id: btn_position + name: "Set Position" + on_press: + - cover.control: + id: test_cover + position: 50% + + # Test 2: cover.control with tilt only (mask 0b100 = 4) + - platform: template + id: btn_tilt + name: "Set Tilt" + on_press: + - cover.control: + id: test_cover + tilt: 75% + + # Test 3: cover.control with position + tilt (mask 0b110 = 6) + - platform: template + id: btn_pos_tilt + name: "Set Pos Tilt" + on_press: + - cover.control: + id: test_cover + position: 25% + tilt: 30% + + # Test 4: cover.control with stop only (mask 0b001 = 1) + - platform: template + id: btn_stop + name: "Stop Cover" + on_press: + - cover.control: + id: test_cover + stop: true + + # Test 5: cover.control with state alias (sets position bit via CONF_STATE) + - platform: template + id: btn_open_state + name: "Open State" + on_press: + - cover.control: + id: test_cover + state: OPEN + + # Test 6: cover.control with lambda position (exercises lambda path) + - platform: template + id: btn_lambda_position + name: "Lambda Position" + on_press: + - cover.control: + id: test_cover + position: !lambda "return id(test_position);" + + # Test 7: cover.template.publish position only (mask 0b001) + - platform: template + id: btn_publish_pos + name: "Publish Pos" + on_press: + - cover.template.publish: + id: test_cover + position: 0.6 + + # Test 8: cover.template.publish current_operation only (mask 0b100) + - platform: template + id: btn_publish_op + name: "Publish Op" + on_press: + - cover.template.publish: + id: test_cover + current_operation: OPENING diff --git a/tests/integration/test_cover_control_action.py b/tests/integration/test_cover_control_action.py new file mode 100644 index 0000000000..fee89555fa --- /dev/null +++ b/tests/integration/test_cover_control_action.py @@ -0,0 +1,84 @@ +"""Integration test for cover ControlAction and CoverPublishAction. + +Tests that cover.control and cover.template.publish automation actions +work correctly with the per-instance bitmask field storage. Exercises +multiple field combinations to cover the bitmask variants. +""" + +import asyncio +from typing import Any + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_cover_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test cover ControlAction/CoverPublishAction with constants and lambdas.""" + async with run_compiled(yaml_config), api_client_connected() as client: + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + client.subscribe_states(on_state) + + entities = await client.list_entities_services() + cover = next(e for e in entities[0] if e.object_id == "test_cover") + buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} + + async def wait_for_state(key: int, timeout: float = 5.0) -> Any: + loop = asyncio.get_running_loop() + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + async def press_and_wait(button_name: str) -> Any: + btn = buttons[button_name] + client.button_command(btn.key) + return await wait_for_state(cover.key) + + # Test 1: position only (mask 2) + state = await press_and_wait("Set Position") + assert state.position == pytest.approx(0.5, abs=0.01) + + # Test 2: tilt only (mask 4) + state = await press_and_wait("Set Tilt") + assert state.tilt == pytest.approx(0.75, abs=0.01) + + # Test 3: position + tilt (mask 6) + state = await press_and_wait("Set Pos Tilt") + assert state.position == pytest.approx(0.25, abs=0.01) + assert state.tilt == pytest.approx(0.30, abs=0.01) + + # Test 4: stop (mask 1) — stop on a non-moving cover should not change state + # We just verify the action runs without error; no state change expected. + btn = buttons["Stop Cover"] + client.button_command(btn.key) + # Give the action a moment to execute + await asyncio.sleep(0.1) + + # Test 5: state: OPEN (CONF_STATE alias for position 1.0) + state = await press_and_wait("Open State") + assert state.position == pytest.approx(1.0, abs=0.01) + + # Test 6: lambda position + state = await press_and_wait("Lambda Position") + assert state.position == pytest.approx(0.42, abs=0.01) + + # Test 7: cover.template.publish position only + state = await press_and_wait("Publish Pos") + assert state.position == pytest.approx(0.6, abs=0.01) + + # Test 8: cover.template.publish current_operation only + state = await press_and_wait("Publish Op") + # Operation 1 = OPENING + assert state.current_operation == 1 From 66115c117a4ba0a9efe097e673c7fe753e4bec1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:03:57 -0500 Subject: [PATCH 0722/2214] [climate] Add integration test for ControlAction --- .../fixtures/climate_control_action.yaml | 90 +++++++++++++++++++ .../test_climate_control_action.py | 72 +++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/integration/fixtures/climate_control_action.yaml create mode 100644 tests/integration/test_climate_control_action.py diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml new file mode 100644 index 0000000000..f462465240 --- /dev/null +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -0,0 +1,90 @@ +esphome: + name: climate-control-action-test +host: +api: +logger: + level: DEBUG + +globals: + - id: test_target_temp + type: float + initial_value: "21.5" + +sensor: + - platform: template + id: temp_sensor + name: "Temp" + lambda: 'return 20.0;' + update_interval: 60s + +climate: + - platform: thermostat + id: test_climate + name: "Test Climate" + sensor: temp_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + min_cooling_run_time: 300s + heat_action: + - logger.log: heating + idle_action: + - logger.log: idle + cool_action: + - logger.log: cooling + preset: + - name: Default + default_target_temperature_low: 18 °C + default_target_temperature_high: 22 °C + visual: + min_temperature: 10 °C + max_temperature: 30 °C + +button: + # Test 1: mode only (mask 1) + - platform: template + id: btn_mode + name: "Set Mode Heat" + on_press: + - climate.control: + id: test_climate + mode: HEAT + + # Test 2: mode + low + high (mask 0b1101 = 13) + - platform: template + id: btn_mode_temps + name: "Set Mode Temps" + on_press: + - climate.control: + id: test_climate + mode: HEAT_COOL + target_temperature_low: 19.0 °C + target_temperature_high: 23.0 °C + + # Test 3: just target_temp_low (mask 0b0100 = 4) + - platform: template + id: btn_low_only + name: "Set Low Only" + on_press: + - climate.control: + id: test_climate + target_temperature_low: 17.5 °C + + # Test 4: lambda for target_temperature_high (exercises lambda path) + - platform: template + id: btn_lambda_high + name: "Lambda High" + on_press: + - climate.control: + id: test_climate + target_temperature_high: !lambda "return id(test_target_temp);" + + # Test 5: turn off via mode + - platform: template + id: btn_off + name: "Set Off" + on_press: + - climate.control: + id: test_climate + mode: "OFF" diff --git a/tests/integration/test_climate_control_action.py b/tests/integration/test_climate_control_action.py new file mode 100644 index 0000000000..505fcf0f3d --- /dev/null +++ b/tests/integration/test_climate_control_action.py @@ -0,0 +1,72 @@ +"""Integration test for climate ControlAction. + +Tests that climate.control automation actions work correctly with the +per-instance bitmask field storage. Exercises multiple field combinations +to cover different bitmask variants and the lambda path. +""" + +import asyncio +from typing import Any + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_climate_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test climate ControlAction with constants and lambdas.""" + async with run_compiled(yaml_config), api_client_connected() as client: + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + client.subscribe_states(on_state) + + entities = await client.list_entities_services() + climate = next(e for e in entities[0] if e.object_id == "test_climate") + buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} + + async def wait_for_state(key: int, timeout: float = 5.0) -> Any: + loop = asyncio.get_running_loop() + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + async def press_and_wait(button_name: str) -> Any: + btn = buttons[button_name] + client.button_command(btn.key) + return await wait_for_state(climate.key) + + # Test 1: mode only (mask 1) — set HEAT + state = await press_and_wait("Set Mode Heat") + # ClimateMode.CLIMATE_MODE_HEAT == 3 + assert state.mode == 3 + + # Test 2: mode + low + high (mask 13) — HEAT_COOL with both temps + state = await press_and_wait("Set Mode Temps") + # CLIMATE_MODE_HEAT_COOL == 1 + assert state.mode == 1 + assert state.target_temperature_low == pytest.approx(19.0, abs=0.5) + assert state.target_temperature_high == pytest.approx(23.0, abs=0.5) + + # Test 3: low only (mask 4) + state = await press_and_wait("Set Low Only") + assert state.target_temperature_low == pytest.approx(17.5, abs=0.5) + + # Test 4: lambda high — global is 21.5 + state = await press_and_wait("Lambda High") + assert state.target_temperature_high == pytest.approx(21.5, abs=0.5) + + # Test 5: turn off via mode (mask 1) + state = await press_and_wait("Set Off") + # CLIMATE_MODE_OFF == 0 + assert state.mode == 0 From e9ced485ea94d325dbe9130e7bd441c4a3446602 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:04:28 -0500 Subject: [PATCH 0723/2214] [light] Add integration test for ToggleAction --- .../fixtures/light_toggle_action.yaml | 37 ++++++++++++ tests/integration/test_light_toggle_action.py | 58 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/integration/fixtures/light_toggle_action.yaml create mode 100644 tests/integration/test_light_toggle_action.py diff --git a/tests/integration/fixtures/light_toggle_action.yaml b/tests/integration/fixtures/light_toggle_action.yaml new file mode 100644 index 0000000000..265d8ba1ac --- /dev/null +++ b/tests/integration/fixtures/light_toggle_action.yaml @@ -0,0 +1,37 @@ +esphome: + name: light-toggle-action-test +host: +api: +logger: + level: DEBUG + +output: + - platform: template + id: test_out + type: float + write_action: + - lambda: "" + +light: + - platform: monochromatic + name: "Test Light" + id: test_light + output: test_out + default_transition_length: 0s + +button: + # Test 1: light.toggle without transition_length (HasTransitionLength=false) + - platform: template + id: btn_toggle + name: "Toggle" + on_press: + - light.toggle: test_light + + # Test 2: light.toggle with transition_length (HasTransitionLength=true) + - platform: template + id: btn_toggle_with_trans + name: "Toggle With Trans" + on_press: + - light.toggle: + id: test_light + transition_length: 0s diff --git a/tests/integration/test_light_toggle_action.py b/tests/integration/test_light_toggle_action.py new file mode 100644 index 0000000000..5715caed9c --- /dev/null +++ b/tests/integration/test_light_toggle_action.py @@ -0,0 +1,58 @@ +"""Integration test for light::ToggleAction. + +Tests both ToggleAction and +ToggleAction instantiations. +""" + +import asyncio +from typing import Any + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_toggle_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test light.toggle with and without transition_length.""" + async with run_compiled(yaml_config), api_client_connected() as client: + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + client.subscribe_states(on_state) + + entities = await client.list_entities_services() + light = next(e for e in entities[0] if e.object_id == "test_light") + buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} + + async def wait_for_state(key: int, timeout: float = 5.0) -> Any: + loop = asyncio.get_running_loop() + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + async def press_and_wait(button_name: str) -> Any: + btn = buttons[button_name] + client.button_command(btn.key) + return await wait_for_state(light.key) + + # Test 1: toggle without transition_length flips off->on + state = await press_and_wait("Toggle") + assert state.state is True + + # Test 2: toggle with transition_length flips on->off + state = await press_and_wait("Toggle With Trans") + assert state.state is False + + # Test 3: toggle without transition_length flips off->on again + state = await press_and_wait("Toggle") + assert state.state is True From 2eac9f5121cd08e20aad0d1d1fd38aab3ee6c674 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:05:00 -0500 Subject: [PATCH 0724/2214] [light] Add integration test for DimRelativeAction --- .../fixtures/light_dim_relative_action.yaml | 60 ++++++++++++++++++ .../test_light_dim_relative_action.py | 63 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 tests/integration/fixtures/light_dim_relative_action.yaml create mode 100644 tests/integration/test_light_dim_relative_action.py diff --git a/tests/integration/fixtures/light_dim_relative_action.yaml b/tests/integration/fixtures/light_dim_relative_action.yaml new file mode 100644 index 0000000000..b52cf65b89 --- /dev/null +++ b/tests/integration/fixtures/light_dim_relative_action.yaml @@ -0,0 +1,60 @@ +esphome: + name: light-dim-relative-action-test +host: +api: +logger: + level: DEBUG + +output: + - platform: template + id: test_out + type: float + write_action: + - lambda: "" + +light: + - platform: monochromatic + name: "Test Light" + id: test_light + output: test_out + default_transition_length: 0s + +button: + # Set up: turn on at 50% brightness + - platform: template + id: btn_setup + name: "Setup" + on_press: + - light.turn_on: + id: test_light + brightness: 50% + + # Test 1: dim_relative without transition_length (HasTransitionLength=false) + - platform: template + id: btn_dim_up + name: "Dim Up" + on_press: + - light.dim_relative: + id: test_light + relative_brightness: 25% + + # Test 2: dim_relative with transition_length (HasTransitionLength=true) + - platform: template + id: btn_dim_down + name: "Dim Down" + on_press: + - light.dim_relative: + id: test_light + relative_brightness: -10% + transition_length: 0s + + # Test 3: dim_relative with brightness limits + - platform: template + id: btn_dim_clamp + name: "Dim Clamp" + on_press: + - light.dim_relative: + id: test_light + relative_brightness: 50% + brightness_limits: + max_brightness: 80% diff --git a/tests/integration/test_light_dim_relative_action.py b/tests/integration/test_light_dim_relative_action.py new file mode 100644 index 0000000000..a1848f1287 --- /dev/null +++ b/tests/integration/test_light_dim_relative_action.py @@ -0,0 +1,63 @@ +"""Integration test for light::DimRelativeAction. + +Tests both DimRelativeAction and +DimRelativeAction instantiations. +""" + +import asyncio +from typing import Any + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_dim_relative_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test light.dim_relative with and without transition_length.""" + async with run_compiled(yaml_config), api_client_connected() as client: + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + client.subscribe_states(on_state) + + entities = await client.list_entities_services() + light = next(e for e in entities[0] if e.object_id == "test_light") + buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} + + async def wait_for_state(key: int, timeout: float = 5.0) -> Any: + loop = asyncio.get_running_loop() + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + async def press_and_wait(button_name: str) -> Any: + btn = buttons[button_name] + client.button_command(btn.key) + return await wait_for_state(light.key) + + # Setup: turn on at 50% + state = await press_and_wait("Setup") + assert state.state is True + assert state.brightness == pytest.approx(0.5, abs=0.05) + + # Test 1: dim_relative without transition_length: 50% + 25% = 75% + state = await press_and_wait("Dim Up") + assert state.brightness == pytest.approx(0.75, abs=0.05) + + # Test 2: dim_relative with transition_length: 75% - 10% = 65% + state = await press_and_wait("Dim Down") + assert state.brightness == pytest.approx(0.65, abs=0.05) + + # Test 3: dim_relative with max_brightness limit: 65% + 50% clamped to 80% + state = await press_and_wait("Dim Clamp") + assert state.brightness == pytest.approx(0.80, abs=0.05) From 23e64de392500b99082d1fbe9d2288666990040c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:07:14 -0500 Subject: [PATCH 0725/2214] [core] Add integration test for inline ContinuationAction --- .../fixtures/inline_continuation_actions.yaml | 91 ++++++++++++++++++ .../test_inline_continuation_actions.py | 92 +++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 tests/integration/fixtures/inline_continuation_actions.yaml create mode 100644 tests/integration/test_inline_continuation_actions.py diff --git a/tests/integration/fixtures/inline_continuation_actions.yaml b/tests/integration/fixtures/inline_continuation_actions.yaml new file mode 100644 index 0000000000..0b656386e1 --- /dev/null +++ b/tests/integration/fixtures/inline_continuation_actions.yaml @@ -0,0 +1,91 @@ +esphome: + name: inline-continuation-test +host: +api: +logger: + level: DEBUG + +globals: + - id: counter + type: int + initial_value: "0" + - id: enabled + type: bool + initial_value: "true" + +button: + # Test 1: if/then (HasElse=false) — increments counter when enabled is true + - platform: template + id: btn_if_then + name: "If Then" + on_press: + - if: + condition: + lambda: 'return id(enabled);' + then: + - lambda: 'id(counter) += 1;' + + # Test 2: if/then/else (HasElse=true) — increments by 10 when enabled, else by 100 + - platform: template + id: btn_if_then_else + name: "If Then Else" + on_press: + - if: + condition: + lambda: 'return id(enabled);' + then: + - lambda: 'id(counter) += 10;' + else: + - lambda: 'id(counter) += 100;' + + # Test 3: while loop — increments counter 5 times via inline check + - platform: template + id: btn_while + name: "While" + on_press: + - while: + condition: + lambda: 'return id(counter) % 5 != 0;' + then: + - lambda: 'id(counter) += 1;' + + # Test 4: repeat loop — increments by 7 over 3 iterations + - platform: template + id: btn_repeat + name: "Repeat" + on_press: + - repeat: + count: 3 + then: + - lambda: 'id(counter) += 7;' + + # Test 5: toggle enabled flag (used to test the else branch later) + - platform: template + id: btn_disable + name: "Disable" + on_press: + - lambda: 'id(enabled) = false;' + + # Test 6: reset counter + - platform: template + id: btn_reset + name: "Reset" + on_press: + - lambda: 'id(counter) = 0;' + + # Helper: trigger an update of the counter_value text sensor + - platform: template + id: btn_update_counter + name: "Update Counter" + on_press: + - component.update: counter_value + +text_sensor: + - platform: template + id: counter_value + name: "Counter" + update_interval: never + lambda: |- + char buf[16]; + snprintf(buf, sizeof(buf), "%d", id(counter)); + return std::string(buf); diff --git a/tests/integration/test_inline_continuation_actions.py b/tests/integration/test_inline_continuation_actions.py new file mode 100644 index 0000000000..84d2bcda28 --- /dev/null +++ b/tests/integration/test_inline_continuation_actions.py @@ -0,0 +1,92 @@ +"""Integration test for inline ContinuationAction in If/While/RepeatAction. + +Verifies that if/then, if/then/else, while, and repeat actions all execute +correctly with the inline-member ContinuationAction storage (replacing the +previous heap allocation). +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, TextSensorInfo, TextSensorState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_inline_continuation_actions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test if/while/repeat all execute correctly with inline continuations.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + text_state_future: asyncio.Future[TextSensorState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, TextSensorState) + and text_state_future is not None + and not text_state_future.done() + ): + text_state_future.set_result(state) + + async def wait_for_text_state(timeout: float = 5.0) -> TextSensorState: + nonlocal text_state_future + text_state_future = loop.create_future() + try: + return await asyncio.wait_for(text_state_future, timeout) + finally: + text_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + # Verify the counter text sensor exists (used implicitly by get_counter) + require_entity(entities, "counter_value", TextSensorInfo) + + async def press(name: str) -> None: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + # Brief settle delay so the action chain runs to completion + await asyncio.sleep(0.05) + + async def get_counter() -> int: + """Force the template text sensor to update and read the value.""" + update_btn = require_entity(entities, "update_counter", ButtonInfo) + client.button_command(update_btn.key) + state = await wait_for_text_state() + return int(state.state) + + # Test 1: if/then with HasElse=false — counter starts 0, enabled=true → +1 + await press("Reset") + await press("If Then") + assert await get_counter() == 1 + + # Test 2: if/then/else (HasElse=true) with enabled=true → +10 (then branch) + await press("Reset") + await press("If Then Else") + assert await get_counter() == 10 + + # Test 3: while loop — counter=1 (1%5!=0) → loops to 5, exits + await press("Reset") + await press("If Then") # counter=1 + await press("While") + assert await get_counter() == 5 + + # Test 4: repeat 3x +7 = 21 + await press("Reset") + await press("Repeat") + assert await get_counter() == 21 + + # Test 5: if/then/else with enabled=false → +100 (else branch) + await press("Reset") + await press("Disable") + await press("If Then Else") + assert await get_counter() == 100 From a532f0adc914ff68739b52f328703efa55504f9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:08:08 -0500 Subject: [PATCH 0726/2214] [cover] Use InitialStateHelper in integration test --- .../integration/test_cover_control_action.py | 66 ++++++++++--------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/tests/integration/test_cover_control_action.py b/tests/integration/test_cover_control_action.py index fee89555fa..206079ca89 100644 --- a/tests/integration/test_cover_control_action.py +++ b/tests/integration/test_cover_control_action.py @@ -5,11 +5,14 @@ work correctly with the per-instance bitmask field storage. Exercises multiple field combinations to cover the bitmask variants. """ -import asyncio -from typing import Any +from __future__ import annotations +import asyncio + +from aioesphomeapi import ButtonInfo, CoverInfo, CoverState, EntityState import pytest +from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -20,31 +23,37 @@ async def test_cover_control_action( api_client_connected: APIClientConnectedFactory, ) -> None: """Test cover ControlAction/CoverPublishAction with constants and lambdas.""" + loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: - state_futures: dict[int, asyncio.Future[Any]] = {} + cover_state_future: asyncio.Future[CoverState] | None = None - def on_state(state: Any) -> None: - if state.key in state_futures and not state_futures[state.key].done(): - state_futures[state.key].set_result(state) + def on_state(state: EntityState) -> None: + if ( + isinstance(state, CoverState) + and cover_state_future is not None + and not cover_state_future.done() + ): + cover_state_future.set_result(state) - client.subscribe_states(on_state) - - entities = await client.list_entities_services() - cover = next(e for e in entities[0] if e.object_id == "test_cover") - buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} - - async def wait_for_state(key: int, timeout: float = 5.0) -> Any: - loop = asyncio.get_running_loop() - state_futures[key] = loop.create_future() + async def wait_for_cover_state(timeout: float = 5.0) -> CoverState: + nonlocal cover_state_future + cover_state_future = loop.create_future() try: - return await asyncio.wait_for(state_futures[key], timeout) + return await asyncio.wait_for(cover_state_future, timeout) finally: - state_futures.pop(key, None) + cover_state_future = None - async def press_and_wait(button_name: str) -> Any: - btn = buttons[button_name] + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_cover", CoverInfo) + + async def press_and_wait(name: str) -> CoverState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) client.button_command(btn.key) - return await wait_for_state(cover.key) + return await wait_for_cover_state() # Test 1: position only (mask 2) state = await press_and_wait("Set Position") @@ -59,26 +68,19 @@ async def test_cover_control_action( assert state.position == pytest.approx(0.25, abs=0.01) assert state.tilt == pytest.approx(0.30, abs=0.01) - # Test 4: stop (mask 1) — stop on a non-moving cover should not change state - # We just verify the action runs without error; no state change expected. - btn = buttons["Stop Cover"] - client.button_command(btn.key) - # Give the action a moment to execute - await asyncio.sleep(0.1) - - # Test 5: state: OPEN (CONF_STATE alias for position 1.0) + # Test 4: state: OPEN (CONF_STATE alias for position 1.0) state = await press_and_wait("Open State") assert state.position == pytest.approx(1.0, abs=0.01) - # Test 6: lambda position + # Test 5: lambda position (test_position global = 0.42) state = await press_and_wait("Lambda Position") assert state.position == pytest.approx(0.42, abs=0.01) - # Test 7: cover.template.publish position only + # Test 6: cover.template.publish position only state = await press_and_wait("Publish Pos") assert state.position == pytest.approx(0.6, abs=0.01) - # Test 8: cover.template.publish current_operation only + # Test 7: cover.template.publish current_operation only state = await press_and_wait("Publish Op") - # Operation 1 = OPENING + # CoverOperation.OPENING == 1 assert state.current_operation == 1 From 497665f59f1053077e5fea88d125803f4f52f92b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:08:35 -0500 Subject: [PATCH 0727/2214] [climate] Use InitialStateHelper in integration test --- .../test_climate_control_action.py | 64 +++++++++++-------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/tests/integration/test_climate_control_action.py b/tests/integration/test_climate_control_action.py index 505fcf0f3d..0070f9829e 100644 --- a/tests/integration/test_climate_control_action.py +++ b/tests/integration/test_climate_control_action.py @@ -5,11 +5,20 @@ per-instance bitmask field storage. Exercises multiple field combinations to cover different bitmask variants and the lambda path. """ -import asyncio -from typing import Any +from __future__ import annotations +import asyncio + +from aioesphomeapi import ( + ButtonInfo, + ClimateInfo, + ClimateMode, + ClimateState, + EntityState, +) import pytest +from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -20,41 +29,45 @@ async def test_climate_control_action( api_client_connected: APIClientConnectedFactory, ) -> None: """Test climate ControlAction with constants and lambdas.""" + loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: - state_futures: dict[int, asyncio.Future[Any]] = {} + climate_state_future: asyncio.Future[ClimateState] | None = None - def on_state(state: Any) -> None: - if state.key in state_futures and not state_futures[state.key].done(): - state_futures[state.key].set_result(state) + def on_state(state: EntityState) -> None: + if ( + isinstance(state, ClimateState) + and climate_state_future is not None + and not climate_state_future.done() + ): + climate_state_future.set_result(state) - client.subscribe_states(on_state) - - entities = await client.list_entities_services() - climate = next(e for e in entities[0] if e.object_id == "test_climate") - buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} - - async def wait_for_state(key: int, timeout: float = 5.0) -> Any: - loop = asyncio.get_running_loop() - state_futures[key] = loop.create_future() + async def wait_for_climate_state(timeout: float = 5.0) -> ClimateState: + nonlocal climate_state_future + climate_state_future = loop.create_future() try: - return await asyncio.wait_for(state_futures[key], timeout) + return await asyncio.wait_for(climate_state_future, timeout) finally: - state_futures.pop(key, None) + climate_state_future = None - async def press_and_wait(button_name: str) -> Any: - btn = buttons[button_name] + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_climate", ClimateInfo) + + async def press_and_wait(name: str) -> ClimateState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) client.button_command(btn.key) - return await wait_for_state(climate.key) + return await wait_for_climate_state() # Test 1: mode only (mask 1) — set HEAT state = await press_and_wait("Set Mode Heat") - # ClimateMode.CLIMATE_MODE_HEAT == 3 - assert state.mode == 3 + assert state.mode == ClimateMode.HEAT # Test 2: mode + low + high (mask 13) — HEAT_COOL with both temps state = await press_and_wait("Set Mode Temps") - # CLIMATE_MODE_HEAT_COOL == 1 - assert state.mode == 1 + assert state.mode == ClimateMode.HEAT_COOL assert state.target_temperature_low == pytest.approx(19.0, abs=0.5) assert state.target_temperature_high == pytest.approx(23.0, abs=0.5) @@ -68,5 +81,4 @@ async def test_climate_control_action( # Test 5: turn off via mode (mask 1) state = await press_and_wait("Set Off") - # CLIMATE_MODE_OFF == 0 - assert state.mode == 0 + assert state.mode == ClimateMode.OFF From 83f83c96a8fec94348c1a43b50b57b1f7f4ee9a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:09:27 -0500 Subject: [PATCH 0728/2214] [light] Use InitialStateHelper in ToggleAction integration test --- tests/integration/test_light_toggle_action.py | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/tests/integration/test_light_toggle_action.py b/tests/integration/test_light_toggle_action.py index 5715caed9c..ffbadabb5b 100644 --- a/tests/integration/test_light_toggle_action.py +++ b/tests/integration/test_light_toggle_action.py @@ -4,11 +4,14 @@ Tests both ToggleAction and ToggleAction instantiations. """ -import asyncio -from typing import Any +from __future__ import annotations +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, LightInfo, LightState import pytest +from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -19,31 +22,37 @@ async def test_light_toggle_action( api_client_connected: APIClientConnectedFactory, ) -> None: """Test light.toggle with and without transition_length.""" + loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: - state_futures: dict[int, asyncio.Future[Any]] = {} + light_state_future: asyncio.Future[LightState] | None = None - def on_state(state: Any) -> None: - if state.key in state_futures and not state_futures[state.key].done(): - state_futures[state.key].set_result(state) + def on_state(state: EntityState) -> None: + if ( + isinstance(state, LightState) + and light_state_future is not None + and not light_state_future.done() + ): + light_state_future.set_result(state) - client.subscribe_states(on_state) - - entities = await client.list_entities_services() - light = next(e for e in entities[0] if e.object_id == "test_light") - buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} - - async def wait_for_state(key: int, timeout: float = 5.0) -> Any: - loop = asyncio.get_running_loop() - state_futures[key] = loop.create_future() + async def wait_for_light_state(timeout: float = 5.0) -> LightState: + nonlocal light_state_future + light_state_future = loop.create_future() try: - return await asyncio.wait_for(state_futures[key], timeout) + return await asyncio.wait_for(light_state_future, timeout) finally: - state_futures.pop(key, None) + light_state_future = None - async def press_and_wait(button_name: str) -> Any: - btn = buttons[button_name] + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_light", LightInfo) + + async def press_and_wait(name: str) -> LightState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) client.button_command(btn.key) - return await wait_for_state(light.key) + return await wait_for_light_state() # Test 1: toggle without transition_length flips off->on state = await press_and_wait("Toggle") From 65b5615a31d5eb018dc1dddce29a90512c00becd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:10:02 -0500 Subject: [PATCH 0729/2214] [light] Use InitialStateHelper in DimRelativeAction integration test --- .../test_light_dim_relative_action.py | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/tests/integration/test_light_dim_relative_action.py b/tests/integration/test_light_dim_relative_action.py index a1848f1287..d5078f4409 100644 --- a/tests/integration/test_light_dim_relative_action.py +++ b/tests/integration/test_light_dim_relative_action.py @@ -4,11 +4,14 @@ Tests both DimRelativeAction and DimRelativeAction instantiations. """ -import asyncio -from typing import Any +from __future__ import annotations +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, LightInfo, LightState import pytest +from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -19,31 +22,37 @@ async def test_light_dim_relative_action( api_client_connected: APIClientConnectedFactory, ) -> None: """Test light.dim_relative with and without transition_length.""" + loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: - state_futures: dict[int, asyncio.Future[Any]] = {} + light_state_future: asyncio.Future[LightState] | None = None - def on_state(state: Any) -> None: - if state.key in state_futures and not state_futures[state.key].done(): - state_futures[state.key].set_result(state) + def on_state(state: EntityState) -> None: + if ( + isinstance(state, LightState) + and light_state_future is not None + and not light_state_future.done() + ): + light_state_future.set_result(state) - client.subscribe_states(on_state) - - entities = await client.list_entities_services() - light = next(e for e in entities[0] if e.object_id == "test_light") - buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} - - async def wait_for_state(key: int, timeout: float = 5.0) -> Any: - loop = asyncio.get_running_loop() - state_futures[key] = loop.create_future() + async def wait_for_light_state(timeout: float = 5.0) -> LightState: + nonlocal light_state_future + light_state_future = loop.create_future() try: - return await asyncio.wait_for(state_futures[key], timeout) + return await asyncio.wait_for(light_state_future, timeout) finally: - state_futures.pop(key, None) + light_state_future = None - async def press_and_wait(button_name: str) -> Any: - btn = buttons[button_name] + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_light", LightInfo) + + async def press_and_wait(name: str) -> LightState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) client.button_command(btn.key) - return await wait_for_state(light.key) + return await wait_for_light_state() # Setup: turn on at 50% state = await press_and_wait("Setup") From 1ab588f10e34498e71ec26d7f3f25c0bd823aa2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:11:11 -0500 Subject: [PATCH 0730/2214] [core] Drop redundant inline_continuation test (covered by test_continuation_actions) --- .../fixtures/inline_continuation_actions.yaml | 91 ------------------ .../test_inline_continuation_actions.py | 92 ------------------- 2 files changed, 183 deletions(-) delete mode 100644 tests/integration/fixtures/inline_continuation_actions.yaml delete mode 100644 tests/integration/test_inline_continuation_actions.py diff --git a/tests/integration/fixtures/inline_continuation_actions.yaml b/tests/integration/fixtures/inline_continuation_actions.yaml deleted file mode 100644 index 0b656386e1..0000000000 --- a/tests/integration/fixtures/inline_continuation_actions.yaml +++ /dev/null @@ -1,91 +0,0 @@ -esphome: - name: inline-continuation-test -host: -api: -logger: - level: DEBUG - -globals: - - id: counter - type: int - initial_value: "0" - - id: enabled - type: bool - initial_value: "true" - -button: - # Test 1: if/then (HasElse=false) — increments counter when enabled is true - - platform: template - id: btn_if_then - name: "If Then" - on_press: - - if: - condition: - lambda: 'return id(enabled);' - then: - - lambda: 'id(counter) += 1;' - - # Test 2: if/then/else (HasElse=true) — increments by 10 when enabled, else by 100 - - platform: template - id: btn_if_then_else - name: "If Then Else" - on_press: - - if: - condition: - lambda: 'return id(enabled);' - then: - - lambda: 'id(counter) += 10;' - else: - - lambda: 'id(counter) += 100;' - - # Test 3: while loop — increments counter 5 times via inline check - - platform: template - id: btn_while - name: "While" - on_press: - - while: - condition: - lambda: 'return id(counter) % 5 != 0;' - then: - - lambda: 'id(counter) += 1;' - - # Test 4: repeat loop — increments by 7 over 3 iterations - - platform: template - id: btn_repeat - name: "Repeat" - on_press: - - repeat: - count: 3 - then: - - lambda: 'id(counter) += 7;' - - # Test 5: toggle enabled flag (used to test the else branch later) - - platform: template - id: btn_disable - name: "Disable" - on_press: - - lambda: 'id(enabled) = false;' - - # Test 6: reset counter - - platform: template - id: btn_reset - name: "Reset" - on_press: - - lambda: 'id(counter) = 0;' - - # Helper: trigger an update of the counter_value text sensor - - platform: template - id: btn_update_counter - name: "Update Counter" - on_press: - - component.update: counter_value - -text_sensor: - - platform: template - id: counter_value - name: "Counter" - update_interval: never - lambda: |- - char buf[16]; - snprintf(buf, sizeof(buf), "%d", id(counter)); - return std::string(buf); diff --git a/tests/integration/test_inline_continuation_actions.py b/tests/integration/test_inline_continuation_actions.py deleted file mode 100644 index 84d2bcda28..0000000000 --- a/tests/integration/test_inline_continuation_actions.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Integration test for inline ContinuationAction in If/While/RepeatAction. - -Verifies that if/then, if/then/else, while, and repeat actions all execute -correctly with the inline-member ContinuationAction storage (replacing the -previous heap allocation). -""" - -from __future__ import annotations - -import asyncio - -from aioesphomeapi import ButtonInfo, EntityState, TextSensorInfo, TextSensorState -import pytest - -from .state_utils import InitialStateHelper, require_entity -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_inline_continuation_actions( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test if/while/repeat all execute correctly with inline continuations.""" - loop = asyncio.get_running_loop() - async with run_compiled(yaml_config), api_client_connected() as client: - text_state_future: asyncio.Future[TextSensorState] | None = None - - def on_state(state: EntityState) -> None: - if ( - isinstance(state, TextSensorState) - and text_state_future is not None - and not text_state_future.done() - ): - text_state_future.set_result(state) - - async def wait_for_text_state(timeout: float = 5.0) -> TextSensorState: - nonlocal text_state_future - text_state_future = loop.create_future() - try: - return await asyncio.wait_for(text_state_future, timeout) - finally: - text_state_future = None - - entities, _ = await client.list_entities_services() - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) - await initial_state_helper.wait_for_initial_states() - - # Verify the counter text sensor exists (used implicitly by get_counter) - require_entity(entities, "counter_value", TextSensorInfo) - - async def press(name: str) -> None: - btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) - client.button_command(btn.key) - # Brief settle delay so the action chain runs to completion - await asyncio.sleep(0.05) - - async def get_counter() -> int: - """Force the template text sensor to update and read the value.""" - update_btn = require_entity(entities, "update_counter", ButtonInfo) - client.button_command(update_btn.key) - state = await wait_for_text_state() - return int(state.state) - - # Test 1: if/then with HasElse=false — counter starts 0, enabled=true → +1 - await press("Reset") - await press("If Then") - assert await get_counter() == 1 - - # Test 2: if/then/else (HasElse=true) with enabled=true → +10 (then branch) - await press("Reset") - await press("If Then Else") - assert await get_counter() == 10 - - # Test 3: while loop — counter=1 (1%5!=0) → loops to 5, exits - await press("Reset") - await press("If Then") # counter=1 - await press("While") - assert await get_counter() == 5 - - # Test 4: repeat 3x +7 = 21 - await press("Reset") - await press("Repeat") - assert await get_counter() == 21 - - # Test 5: if/then/else with enabled=false → +100 (else branch) - await press("Reset") - await press("Disable") - await press("If Then Else") - assert await get_counter() == 100 From fdb183f7728d3be445a770747c6f0c7aa2b8fa5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:21:27 -0500 Subject: [PATCH 0731/2214] [climate] Add auto_mode to thermostat fixture so HEAT_COOL is supported --- tests/integration/fixtures/climate_control_action.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml index f462465240..0fba8e2693 100644 --- a/tests/integration/fixtures/climate_control_action.yaml +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -33,6 +33,8 @@ climate: - logger.log: idle cool_action: - logger.log: cooling + auto_mode: + - logger.log: auto preset: - name: Default default_target_temperature_low: 18 °C From fbafade78c74d17bb4dcd4262a650961d6b63d19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:29:40 -0500 Subject: [PATCH 0732/2214] [ota] Add wall-clock timeout to OTA data transfer loop The main `while (total < ota_size)` loop in `ESPHomeOTAComponent::handle_data_()` had a `// TODO: timeout check` and no wall-clock guard. If the uploader side dropped the TCP connection without a FIN/RST being delivered to the device (uploader process killed mid-transfer, NAT/router state dropped, packet loss eating the RST), `recv_fn` would never see a close and `s_err_fn` would never fire, so: - `pcb_` stays non-null - `rx_closed_` stays false - `waiting_for_data_()` stays true forever - the loop spins on `EWOULDBLOCK`, fed by `App.feed_wdt(); continue;`, indefinitely LwIP TCP keepalive is not enabled on the OTA socket, and even when enabled the default keepalive timer is on the order of hours, so the device is effectively unresponsive until power cycle. This matches the "device unresponsive until I do a hard power-reset" symptom in issue #15953. Track the timestamp of the last successful read and abort if no data arrives for `OTA_SOCKET_TIMEOUT_DATA` (90s, same constant the handshake, `readall_()`, and `writeall_()` already use). The timeout resets on every successful read so a slow but live link does not false-trigger. --- esphome/components/esphome/ota/ota_esphome.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 47f661a8ea..028338ff78 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -350,8 +350,17 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge MD5 OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); + // Track when we last received data so a silently-vanished peer (no FIN/RST + // delivered, e.g. uploader killed mid-transfer or NAT/router dropped state) + // can't wedge the device indefinitely. Without this, the loop only exits + // on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP + // TCP keepalive isn't enabled here. + uint32_t last_data_ms = millis(); while (total < ota_size) { - // TODO: timeout check + if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) { + ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } size_t remaining = ota_size - total; size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; ssize_t read = this->client_->read(buf, requested); @@ -369,6 +378,7 @@ void ESPHomeOTAComponent::handle_data_() { goto error; // NOLINT(cppcoreguidelines-avoid-goto) } + last_data_ms = millis(); error_code = this->backend_->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Flash write err %d", error_code); From 6770a6b87f4a0526d12bc0e0bb413b1f5ff31bf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:39:21 -0500 Subject: [PATCH 0733/2214] [cover] Address Copilot review on integration test fixture and macro comments --- esphome/components/cover/automation.h | 11 +++++--- .../fixtures/cover_control_action.yaml | 27 ++++++++++--------- .../integration/test_cover_control_action.py | 6 +++++ 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 223c7644ea..e056a11ea5 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -51,8 +51,9 @@ namespace cover_action_detail { template struct Empty {}; } // namespace cover_action_detail -// X-macro: (type, field_name, bit_index). Order/bits must match -// cover_control_to_code's FIELDS table in __init__.py. +// X-macro: (type, field_name, bit_index). Order/bits must match the +// inline field-mask computation in cover_control_to_code in __init__.py: +// stop=bit 0, position=bit 1 (also set by CONF_STATE), tilt=bit 2. #define COVER_CONTROL_FIELDS(X) \ X(bool, stop, 0) \ X(float, position, 1) \ @@ -86,8 +87,10 @@ template class ControlAction : public Action IDLE + - platform: template + id: btn_stop + name: "Stop Cover" + on_press: + - cover.control: + id: test_cover + stop: true diff --git a/tests/integration/test_cover_control_action.py b/tests/integration/test_cover_control_action.py index 206079ca89..29ece37ce5 100644 --- a/tests/integration/test_cover_control_action.py +++ b/tests/integration/test_cover_control_action.py @@ -84,3 +84,9 @@ async def test_cover_control_action( state = await press_and_wait("Publish Op") # CoverOperation.OPENING == 1 assert state.current_operation == 1 + + # Test 8: cover.control stop only (mask 1) + # The template cover's stop_action publishes current_operation: IDLE + state = await press_and_wait("Stop Cover") + # CoverOperation.IDLE == 0 + assert state.current_operation == 0 From 9089cc968ebb0148b4d10c58b318870526e70937 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:42:18 -0500 Subject: [PATCH 0734/2214] [climate] Use heat_cool_mode (not auto_mode) to enable HEAT_COOL in test fixture --- tests/integration/fixtures/climate_control_action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml index 0fba8e2693..596151ea51 100644 --- a/tests/integration/fixtures/climate_control_action.yaml +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -33,8 +33,8 @@ climate: - logger.log: idle cool_action: - logger.log: cooling - auto_mode: - - logger.log: auto + heat_cool_mode: + - logger.log: heat_cool preset: - name: Default default_target_temperature_low: 18 °C From 801cfe621b29808529177e7d361d0d163b5c299f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:54:32 -0500 Subject: [PATCH 0735/2214] [ota] Fix clang-tidy goto-jumps-init and set error_code on timeout Address two issues on the new data-loop timeout: - clang-tidy ESP32 Arduino flagged "cannot jump from this goto statement to its label" at the existing pre-loop `goto error` sites because the new `uint32_t last_data_ms = millis();` declaration sat between them and the `error:` label, so the jumps would skip past its initialization. Move the declaration up next to the other handle_data_ locals (`total`, `last_progress`) and just assign it before the loop entry. The earlier gotos no longer cross an init. - Copilot pointed out the new timeout `goto error` left `error_code` at `OTA_RESPONSE_OK` (set by `backend_->begin()`), so the device would write OK back to the uploader even though it was aborting. Set `error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN` before the goto so the uploader receives a correct failure code. --- esphome/components/esphome/ota/ota_esphome.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 028338ff78..be771eb689 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -292,6 +292,7 @@ void ESPHomeOTAComponent::handle_data_() { bool update_started = false; size_t total = 0; uint32_t last_progress = 0; + uint32_t last_data_ms = 0; uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; @@ -355,10 +356,11 @@ void ESPHomeOTAComponent::handle_data_() { // can't wedge the device indefinitely. Without this, the loop only exits // on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP // TCP keepalive isn't enabled here. - uint32_t last_data_ms = millis(); + last_data_ms = millis(); while (total < ota_size) { if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) { ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } size_t remaining = ota_size - total; From acc15ff49578013e2f89b7a1979fd19151270fc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:59:21 -0500 Subject: [PATCH 0736/2214] [api] Use custom deleter to fix incomplete-type error on macOS libc++ libc++ eagerly instantiates the unique_ptr destructor when std::array, N> is parsed, requiring sizeof(APIConnection). api_server.h only forward-declares APIConnection (via list_entities.h), so the destructor instantiation fails for any translation unit that includes api_server.h without also including api_connection.h first. Wrap the unique_ptr in a custom deleter (APIConnectionDeleter) whose operator() is defined out-of-line in api_server.cpp where APIConnection is complete. The default_delete path is never instantiated, so libc++'s incomplete-type assertion is avoided. GCC/libstdc++ already deferred this instantiation, so this only affects macOS host-platform builds (used by integration tests). --- esphome/components/api/api_server.cpp | 5 +++++ esphome/components/api/api_server.h | 10 ++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index c30bd2e612..6c26c4e187 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -30,6 +30,11 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c APIServer::APIServer() { global_api_server = this; } +// Custom deleter defined here so `delete` sees the complete APIConnection type. +// This prevents libc++ from emitting an "incomplete type" error when other +// translation units only have the forward declaration of APIConnection. +void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; } + void APIServer::socket_failed_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); this->destroy_socket_(); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index e662d78eba..6b575e536d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -193,7 +193,13 @@ class APIServer final : public Component, // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the // APIConnection but cannot reset/move the slot and break the count invariant. - using APIConnectionPtr = std::unique_ptr; + // Custom deleter is defined out-of-line in api_server.cpp so libc++ does not + // eagerly instantiate `delete static_cast(p)` here, where + // only the forward declaration of APIConnection is visible (incomplete type). + struct APIConnectionDeleter { + void operator()(APIConnection *p) const; + }; + using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { const APIConnectionPtr *begin_; const APIConnectionPtr *end_; @@ -292,7 +298,7 @@ class APIServer final : public Component, uint32_t last_connected_{0}; // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. - std::array, MAX_API_CONNECTIONS> clients_{}; + std::array clients_{}; // Vectors and strings (12 bytes each on 32-bit) // Shared proto write buffer for all connections. // Not pre-allocated: all send paths call prepare_first_message_buffer() which From 53454adc40a4dea9536d390a137fbeb08297eac4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 06:19:39 -0500 Subject: [PATCH 0737/2214] [cover] Add cover.control / cover.template.publish coverage to template tests --- tests/components/template/common-base.yaml | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index ecc65de66c..5f9c7f8ecc 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -293,6 +293,56 @@ cover: cover.is_closed: template_cover_with_triggers then: logger.log: Cover is closed + # Exercise cover.control / cover.template.publish action variants so the + # ControlAction / CoverPublishAction templates are instantiated for the + # bitmask combinations seen in real configs. + - platform: template + name: "Template Cover Bitmask" + id: template_cover_bitmask + has_position: true + optimistic: true + open_action: + - cover.template.publish: + id: template_cover_bitmask + position: 1.0 + - cover.template.publish: + id: template_cover_bitmask + current_operation: IDLE + close_action: + - cover.template.publish: + id: template_cover_bitmask + position: 0.0 + tilt: 0.0 + stop_action: + - cover.template.publish: + id: template_cover_bitmask + current_operation: IDLE + tilt_action: + - lambda: |- + id(template_cover_bitmask).tilt = tilt; + id(template_cover_bitmask).publish_state(); + on_idle: + # mask = position only + - cover.control: + id: template_cover_bitmask + position: 50% + # mask = tilt only + - cover.control: + id: template_cover_bitmask + tilt: 75% + # mask = position + tilt + - cover.control: + id: template_cover_bitmask + position: 25% + tilt: 30% + # mask = stop + - cover.control: + id: template_cover_bitmask + stop: true + # CONF_STATE alias for position bit + - cover.control: + id: template_cover_bitmask + state: OPEN number: - platform: template From 50b0474d60ab5e2ab72e162c70d73b1000740fb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 06:20:43 -0500 Subject: [PATCH 0738/2214] [cover] Reword: generic coverage, not bitmask-specific --- tests/components/template/common-base.yaml | 42 +++++++++++----------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 5f9c7f8ecc..9d4e5f5d6e 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -293,55 +293,55 @@ cover: cover.is_closed: template_cover_with_triggers then: logger.log: Cover is closed - # Exercise cover.control / cover.template.publish action variants so the - # ControlAction / CoverPublishAction templates are instantiated for the - # bitmask combinations seen in real configs. + # Exercise cover.control / cover.template.publish action variants so they + # get build coverage in CI (and so memory-impact analysis on PRs that + # touch ControlAction / CoverPublishAction sees real instances). - platform: template - name: "Template Cover Bitmask" - id: template_cover_bitmask + name: "Template Cover Actions" + id: template_cover_actions has_position: true optimistic: true open_action: - cover.template.publish: - id: template_cover_bitmask + id: template_cover_actions position: 1.0 - cover.template.publish: - id: template_cover_bitmask + id: template_cover_actions current_operation: IDLE close_action: - cover.template.publish: - id: template_cover_bitmask + id: template_cover_actions position: 0.0 tilt: 0.0 stop_action: - cover.template.publish: - id: template_cover_bitmask + id: template_cover_actions current_operation: IDLE tilt_action: - lambda: |- - id(template_cover_bitmask).tilt = tilt; - id(template_cover_bitmask).publish_state(); + id(template_cover_actions).tilt = tilt; + id(template_cover_actions).publish_state(); on_idle: - # mask = position only + # position only - cover.control: - id: template_cover_bitmask + id: template_cover_actions position: 50% - # mask = tilt only + # tilt only - cover.control: - id: template_cover_bitmask + id: template_cover_actions tilt: 75% - # mask = position + tilt + # position + tilt - cover.control: - id: template_cover_bitmask + id: template_cover_actions position: 25% tilt: 30% - # mask = stop + # stop - cover.control: - id: template_cover_bitmask + id: template_cover_actions stop: true - # CONF_STATE alias for position bit + # CONF_STATE alias for position - cover.control: - id: template_cover_bitmask + id: template_cover_actions state: OPEN number: From c60cf7f5b73e5248434e5e090ad6369582e648b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 06:21:56 -0500 Subject: [PATCH 0739/2214] [climate] Add climate.control coverage to component tests via thermostat --- tests/components/climate/common.yaml | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/components/climate/common.yaml b/tests/components/climate/common.yaml index ff405b68e2..2d35438afd 100644 --- a/tests/components/climate/common.yaml +++ b/tests/components/climate/common.yaml @@ -29,3 +29,59 @@ climate: heat_action: - switch.turn_on: climate_heater_switch - switch.turn_off: climate_cooler_switch + # Thermostat-based climate so climate.control: action variants get build + # coverage (bang_bang doesn't support fan modes, presets, etc.). Climate + # has no template platform, so thermostat is the right vehicle. + - platform: thermostat + id: climate_test_thermostat + name: Test Thermostat + sensor: climate_temperature_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + min_cooling_run_time: 300s + heat_action: + - logger.log: heating + idle_action: + - logger.log: idle + cool_action: + - logger.log: cooling + auto_mode: + - logger.log: auto + heat_cool_mode: + - logger.log: heat_cool + preset: + - name: Default + default_target_temperature_low: 18°C + default_target_temperature_high: 22°C + +button: + # Exercise the climate.control: action so ControlAction templates get + # build coverage. Various field combinations are tested. + - platform: template + name: "Climate Control Mode" + on_press: + - climate.control: + id: climate_test_thermostat + mode: HEAT + - platform: template + name: "Climate Control Mode And Temps" + on_press: + - climate.control: + id: climate_test_thermostat + mode: HEAT_COOL + target_temperature_low: 19.0°C + target_temperature_high: 23.0°C + - platform: template + name: "Climate Control Lambda Temp" + on_press: + - climate.control: + id: climate_test_thermostat + target_temperature_high: !lambda "return 21.5;" + - platform: template + name: "Climate Control Off" + on_press: + - climate.control: + id: climate_test_thermostat + mode: "OFF" From a46d7e0a6a7ebc396ca0f6a4e40f66fa63925e84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 06:42:15 -0500 Subject: [PATCH 0740/2214] [cover] Also exercise cover.template.publish with state: alias --- tests/components/template/common-base.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 9d4e5f5d6e..daa6f53d42 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -302,6 +302,10 @@ cover: has_position: true optimistic: true open_action: + # CONF_STATE alias for the position bit + - cover.template.publish: + id: template_cover_actions + state: OPEN - cover.template.publish: id: template_cover_actions position: 1.0 From 0728b5284efa048e4dfaa58fd356de5807f98f08 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:47:01 +0200 Subject: [PATCH 0741/2214] Apply suggestions --- esphome/components/esphome/ota/ota_esphome.cpp | 10 +++++----- esphome/components/esphome/ota/ota_esphome.h | 2 ++ esphome/components/ota/ota_backend_esp_idf.cpp | 15 +++++++-------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 91aee0cea6..0e242f5e74 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -87,6 +87,9 @@ void ESPHomeOTAComponent::setup() { // no wakes fire and loop() falls back to the self-disable safety net. esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif +#ifdef USE_OTA_PARTITIONS + ota::get_running_app_position(this->running_app_offset_, this->running_app_size_); +#endif } void ESPHomeOTAComponent::dump_config() { @@ -102,11 +105,8 @@ void ESPHomeOTAComponent::dump_config() { #endif #ifdef USE_OTA_PARTITIONS ESP_LOGCONFIG(TAG, " Partition access allowed"); - uint32_t running_app_offset; - size_t running_app_size; - ota::get_running_app_position(running_app_offset, running_app_size); - ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %d bytes", running_app_offset, - running_app_size); + ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %zu bytes", this->running_app_offset_, + this->running_app_size_); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 9bed9240aa..c09ad843f8 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -93,6 +93,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #endif // USE_OTA_PASSWORD #ifdef USE_OTA_PARTITIONS bool extended_proto_{false}; + uint32_t running_app_offset_{0}; + size_t running_app_size_{0}; #endif socket::ListenSocket *server_{nullptr}; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 85c430cac6..d09f4c48d4 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -25,8 +25,7 @@ std::unique_ptr make_ota_backend() { return make_uniqueota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || - image_size > OTA_BUFFER_SIZE) { + if (image_size > ESP_PARTITION_TABLE_MAX_LEN) { ESP_LOGE(TAG, "Wrong partition table size"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -236,7 +235,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } } if (!checksum_found) { - ESP_LOGE(TAG, "New partition table has no checksum", err); + ESP_LOGE(TAG, "New partition table has no checksum"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -300,7 +299,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - ESP_LOGD(TAG, "Checks passed, starting partition table update"); + ESP_LOGW(TAG, "Checks passed, starting partition table update. Don't remove power until it is completed!"); // Deinitialize NVS to prevent unwanted flash writes nvs_flash_deinit(); @@ -330,14 +329,14 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Update the partition table - err = esp_ota_begin(this->partition_table_part_, this->image_size_, &this->update_handle_); + err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); if (err != ESP_OK) { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - err = esp_ota_write(this->update_handle_, this->buf_, this->image_size_); + err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; @@ -348,6 +347,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } + this->partition_table_part_ = nullptr; esp_partition_unload_all(); // Write otadata to set the new boot partition @@ -381,8 +381,7 @@ void get_running_app_position(uint32_t &offset, size_t &size) { static size_t running_app_size = 0; if (running_app_size == 0) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * - running_app_part->erase_size; + running_app_size = running_app_part->size; running_app_offset = running_app_part->address; const esp_partition_pos_t running_app_pos = { .offset = running_app_part->address, From dec5d0449bd1c844ba89f9c5900d48db8b17a466 Mon Sep 17 00:00:00 2001 From: plazarre Date: Mon, 27 Apr 2026 06:51:54 -0500 Subject: [PATCH 0742/2214] [esp32_ble_tracker] Hold COEX_PREFER_BT for the lifetime of any active connection (#16036) Co-authored-by: Paul Lazarre Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 22 ++++++++++++++----- .../esp32_ble_tracker/esp32_ble_tracker.h | 10 ++++++++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index c7f2319d69..f57cb7f5dc 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -166,8 +166,9 @@ void ESP32BLETracker::loop() { ClientStateCounts counts = this->count_client_states_(); if (counts != this->client_state_counts_) { this->client_state_counts_ = counts; - ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d", this->client_state_counts_.connecting, - this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); + ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d, active: %d", + this->client_state_counts_.connecting, this->client_state_counts_.discovered, + this->client_state_counts_.disconnecting, this->client_state_counts_.active); } // Scanner failure: reached when set_scanner_state_(FAILED) or scan_set_param_failed_ set @@ -190,10 +191,18 @@ void ESP32BLETracker::loop() { */ // Start scan: reached when scanner_state_ becomes IDLE (via set_scanner_state_()) and - // all clients are idle (their state changes increment version when they finish) + // no clients are in the transient CONNECTING / DISCOVERED / DISCONNECTING states + // (their state changes increment version when they finish). CONNECTED / ESTABLISHED + // clients do NOT block this branch — the coex revert below has its own active-count gate. if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !counts.discovered) { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - this->update_coex_preference_(false); + // Only revert to BALANCE when no connections are active. Established connections + // continue to need PREFER_BT so peer GATT responses can reach us while WiFi traffic + // (advertisement upload, log streaming) competes for the shared radio. Reverting too + // early causes Bluedroid to time out at ~20s and synthesize status=133. + if (!counts.active) { + this->update_coex_preference_(false); + } #endif if (this->scan_continuous_) { this->start_scan_(false); // first = false @@ -701,9 +710,10 @@ void ESP32BLETracker::dump_config() { this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); ESP_LOGCONFIG(TAG, " Scanner State: %s\n" - " Connecting: %d, discovered: %d, disconnecting: %d", + " Connecting: %d, discovered: %d, disconnecting: %d, active: %d", this->scanner_state_to_string_(this->scanner_state_), this->client_state_counts_.connecting, - this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); + this->client_state_counts_.discovered, this->client_state_counts_.disconnecting, + this->client_state_counts_.active); if (this->scan_start_fail_count_) { ESP_LOGCONFIG(TAG, " Scan Start Fail Count: %d", this->scan_start_fail_count_); } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 43405b02b7..78ff60f374 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -160,9 +160,13 @@ struct ClientStateCounts { uint8_t connecting = 0; uint8_t discovered = 0; uint8_t disconnecting = 0; + // CONNECTED + ESTABLISHED clients. Tracked so coex stays at PREFER_BT + // while active connections may still need to send/receive GATT traffic. + uint8_t active = 0; bool operator==(const ClientStateCounts &other) const { - return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting; + return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting && + active == other.active; } bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } @@ -381,6 +385,10 @@ class ESP32BLETracker : public Component, case ClientState::CONNECTING: counts.connecting++; break; + case ClientState::CONNECTED: + case ClientState::ESTABLISHED: + counts.active++; + break; default: break; } From 24c6a0d711ea99aee1bb57c9f8336a8597e89a1c Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 27 Apr 2026 08:17:02 -0400 Subject: [PATCH 0743/2214] [audio] Bump microDecoder library to v0.2.0 (#16054) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index fee582ca25..fe111be31e 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -220,7 +220,7 @@ async def to_code(config): data = _get_data() if data.micro_decoder_support: - add_idf_component(name="esphome/micro-decoder", ref="0.1.1") + add_idf_component(name="esphome/micro-decoder", ref="0.2.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: diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 11531e6d7b..cb7f5903cf 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -6,7 +6,7 @@ dependencies: esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: - version: 0.1.1 + version: 0.2.0 esphome/micro-flac: version: 0.1.1 esphome/micro-opus: From e232325a33a986fc2c9ced917760f0863cb8583b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 07:43:21 -0500 Subject: [PATCH 0744/2214] [wifi] Add phy_mode option for ESP8266 --- esphome/components/wifi/__init__.py | 15 +++++++++++++++ esphome/components/wifi/wifi_component.h | 18 ++++++++++++++++++ .../components/wifi/wifi_component_esp8266.cpp | 13 +++++++++++++ esphome/core/defines.h | 1 + tests/components/wifi/test.esp8266-ard.yaml | 1 + 5 files changed, 48 insertions(+) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index bc4e177219..65bd5f775c 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -73,6 +73,7 @@ NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] CONF_SAVE = "save" CONF_BAND_MODE = "band_mode" CONF_MIN_AUTH_MODE = "min_auth_mode" +CONF_PHY_MODE = "phy_mode" CONF_POST_CONNECT_ROAMING = "post_connect_roaming" # Maximum number of WiFi networks that can be configured @@ -112,6 +113,13 @@ WIFI_MIN_AUTH_MODES = { "WPA3": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA3, } VALIDATE_WIFI_MIN_AUTH_MODE = cv.enum(WIFI_MIN_AUTH_MODES, upper=True) + +WiFi8266PhyMode = wifi_ns.enum("WiFi8266PhyMode") +WIFI_8266_PHY_MODES = { + "11B": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11B, + "11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G, + "11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N, +} WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) @@ -406,6 +414,10 @@ CONFIG_SCHEMA = cv.All( cv.only_on_esp32, only_on_variant(supported=[const.VARIANT_ESP32C5]), ), + cv.Optional(CONF_PHY_MODE): cv.All( + cv.enum(WIFI_8266_PHY_MODES, upper=True), + cv.only_on_esp8266, + ), cv.Optional(CONF_PASSIVE_SCAN, default=False): cv.boolean, cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_POST_CONNECT_ROAMING, default=True): cv.boolean, @@ -569,6 +581,9 @@ async def to_code(config): if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) + if CONF_PHY_MODE in config: + cg.add_define("USE_WIFI_PHY_MODE") + cg.add(var.set_phy_mode(config[CONF_PHY_MODE])) elif CORE.is_rp2040: cg.add_library("WiFi", None) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 53fb0728fb..ef301e3d4f 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -345,6 +345,15 @@ enum WifiMinAuthMode : uint8_t { WIFI_MIN_AUTH_MODE_WPA3, }; +#ifdef USE_WIFI_PHY_MODE +// Values match ESP8266 SDK phy_mode_t (PHY_MODE_11B=1, PHY_MODE_11G=2, PHY_MODE_11N=3). +enum WiFi8266PhyMode : uint8_t { + WIFI_8266_PHY_MODE_11B = 1, + WIFI_8266_PHY_MODE_11G = 2, + WIFI_8266_PHY_MODE_11N = 3, +}; +#endif + #ifdef USE_ESP32 struct IDFWiFiEvent; #endif @@ -455,6 +464,9 @@ class WiFiComponent final : public Component { #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) void set_band_mode(wifi_band_mode_t band_mode) { this->band_mode_ = band_mode; } #endif +#ifdef USE_WIFI_PHY_MODE + void set_phy_mode(WiFi8266PhyMode phy_mode) { this->phy_mode_ = phy_mode; } +#endif void set_passive_scan(bool passive); @@ -672,6 +684,9 @@ class WiFiComponent final : public Component { bool wifi_apply_power_save_(); #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) bool wifi_apply_band_mode_(); +#endif +#ifdef USE_WIFI_PHY_MODE + bool wifi_apply_phy_mode_(); #endif bool wifi_sta_ip_config_(const optional &manual_ip); bool wifi_apply_hostname_(); @@ -810,6 +825,9 @@ class WiFiComponent final : public Component { WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE}; #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) wifi_band_mode_t band_mode_{WIFI_BAND_MODE_AUTO}; +#endif +#ifdef USE_WIFI_PHY_MODE + WiFi8266PhyMode phy_mode_{WIFI_8266_PHY_MODE_11N}; #endif WifiMinAuthMode min_auth_mode_{WIFI_MIN_AUTH_MODE_WPA2}; WiFiRetryPhase retry_phase_{WiFiRetryPhase::INITIAL_CONNECT}; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index bf3a0d2949..f2ae69f596 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -621,10 +621,23 @@ bool WiFiComponent::wifi_sta_pre_setup_() { ESP_LOGV(TAG, "Disabling Auto-Connect failed"); } +#ifdef USE_WIFI_PHY_MODE + if (!this->wifi_apply_phy_mode_()) { + ESP_LOGV(TAG, "Setting PHY Mode failed"); + } +#endif + delay(10); return true; } +#ifdef USE_WIFI_PHY_MODE +bool WiFiComponent::wifi_apply_phy_mode_() { + // Values of WiFi8266PhyMode are aligned with the SDK's phy_mode_t enum. + return wifi_set_phy_mode(static_cast(this->phy_mode_)); +} +#endif + void WiFiComponent::wifi_pre_setup_() { wifi_set_event_handler_cb(&WiFiComponent::wifi_event_callback); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f929b224ca..c1025e3cb2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -282,6 +282,7 @@ #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT +#define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS #define USE_WIFI_CONNECT_STATE_LISTENERS diff --git a/tests/components/wifi/test.esp8266-ard.yaml b/tests/components/wifi/test.esp8266-ard.yaml index 709a639ad6..ffeec136d3 100644 --- a/tests/components/wifi/test.esp8266-ard.yaml +++ b/tests/components/wifi/test.esp8266-ard.yaml @@ -1,6 +1,7 @@ wifi: min_auth_mode: WPA2 post_connect_roaming: true + phy_mode: 11G packages: - !include common.yaml From 4f4ad64f19bd0e50315ee19299a32878d59cb9b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 07:49:29 -0500 Subject: [PATCH 0745/2214] [wifi] Add explicit AUTO option to phy_mode --- esphome/components/wifi/__init__.py | 1 + esphome/components/wifi/wifi_component.h | 6 ++++-- esphome/components/wifi/wifi_component_esp8266.cpp | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 65bd5f775c..69544f3636 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -116,6 +116,7 @@ VALIDATE_WIFI_MIN_AUTH_MODE = cv.enum(WIFI_MIN_AUTH_MODES, upper=True) WiFi8266PhyMode = wifi_ns.enum("WiFi8266PhyMode") WIFI_8266_PHY_MODES = { + "AUTO": WiFi8266PhyMode.WIFI_8266_PHY_MODE_AUTO, "11B": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11B, "11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G, "11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N, diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ef301e3d4f..0437267a1f 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -346,8 +346,10 @@ enum WifiMinAuthMode : uint8_t { }; #ifdef USE_WIFI_PHY_MODE -// Values match ESP8266 SDK phy_mode_t (PHY_MODE_11B=1, PHY_MODE_11G=2, PHY_MODE_11N=3). +// Values 1-3 match ESP8266 SDK phy_mode_t (PHY_MODE_11B=1, PHY_MODE_11G=2, PHY_MODE_11N=3). +// AUTO leaves the SDK at its default (no wifi_set_phy_mode() call). enum WiFi8266PhyMode : uint8_t { + WIFI_8266_PHY_MODE_AUTO = 0, WIFI_8266_PHY_MODE_11B = 1, WIFI_8266_PHY_MODE_11G = 2, WIFI_8266_PHY_MODE_11N = 3, @@ -827,7 +829,7 @@ class WiFiComponent final : public Component { wifi_band_mode_t band_mode_{WIFI_BAND_MODE_AUTO}; #endif #ifdef USE_WIFI_PHY_MODE - WiFi8266PhyMode phy_mode_{WIFI_8266_PHY_MODE_11N}; + WiFi8266PhyMode phy_mode_{WIFI_8266_PHY_MODE_AUTO}; #endif WifiMinAuthMode min_auth_mode_{WIFI_MIN_AUTH_MODE_WPA2}; WiFiRetryPhase retry_phase_{WiFiRetryPhase::INITIAL_CONNECT}; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index f2ae69f596..9d2c1bb1be 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -633,6 +633,8 @@ bool WiFiComponent::wifi_sta_pre_setup_() { #ifdef USE_WIFI_PHY_MODE bool WiFiComponent::wifi_apply_phy_mode_() { + if (this->phy_mode_ == WIFI_8266_PHY_MODE_AUTO) + return true; // Values of WiFi8266PhyMode are aligned with the SDK's phy_mode_t enum. return wifi_set_phy_mode(static_cast(this->phy_mode_)); } From 16bc1517588c83ba3cf253549c63664ed3eaf9cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 08:01:30 -0500 Subject: [PATCH 0746/2214] [wifi] Log phy_mode in dump_config --- esphome/components/wifi/wifi_component.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f7c70b1147..0f62b038b8 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -309,6 +309,18 @@ bool CompactString::operator==(const StringRef &other) const { /// └──────────────────────────────────────────────────────────────────────┘ #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO +#ifdef USE_WIFI_PHY_MODE +// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) +static const LogString *phy_mode_to_log_string(WiFi8266PhyMode mode) { + if (mode == WIFI_8266_PHY_MODE_11B) + return LOG_STR("11B"); + if (mode == WIFI_8266_PHY_MODE_11G) + return LOG_STR("11G"); + if (mode == WIFI_8266_PHY_MODE_11N) + return LOG_STR("11N"); + return LOG_STR("Auto"); +} +#endif // Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { if (phase == WiFiRetryPhase::INITIAL_CONNECT) @@ -1535,6 +1547,9 @@ void WiFiComponent::dump_config() { break; } ESP_LOGCONFIG(TAG, " Band Mode: %s", band_mode_s); +#endif +#ifdef USE_WIFI_PHY_MODE + ESP_LOGCONFIG(TAG, " PHY Mode: %s", LOG_STR_ARG(phy_mode_to_log_string(this->phy_mode_))); #endif if (this->is_connected()) { this->print_connect_params_(); From 0f69504dd0ba015fa4121ee8a3c8bf204bbbf739 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 09:30:07 -0500 Subject: [PATCH 0747/2214] Revert "[bk72xx] Add BK7238 boards in preparation for upstream support" This reverts commit 1a0642546b9313a735148fb9b59359cf10d6b602. --- esphome/components/bk72xx/boards.py | 500 +------------------------- esphome/components/libretiny/const.py | 4 - 2 files changed, 2 insertions(+), 502 deletions(-) diff --git a/esphome/components/bk72xx/boards.py b/esphome/components/bk72xx/boards.py index f8bedce329..4bee69fe6d 100644 --- a/esphome/components/bk72xx/boards.py +++ b/esphome/components/bk72xx/boards.py @@ -16,7 +16,6 @@ from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, - FAMILY_BK7238, FAMILY_BK7251, ) @@ -25,32 +24,16 @@ BK72XX_BOARDS = { "name": "WB2L_M1 Wi-Fi Module", "family": FAMILY_BK7231N, }, - "xh-wb3s": { - "name": "NiceMCU XH-WB3S", - "family": FAMILY_BK7238, - }, "cbu": { "name": "CBU Wi-Fi Module", "family": FAMILY_BK7231N, }, - "t1-u": { - "name": "T1-U Wi-Fi Module", - "family": FAMILY_BK7238, - }, - "generic-bk7238-tuya": { - "name": "Generic - BK7238 (Tuya T1)", - "family": FAMILY_BK7238, - }, - "t1-m": { - "name": "T1-M Wi-Fi Module", - "family": FAMILY_BK7238, - }, "generic-bk7231t-qfn32-tuya": { - "name": "Generic - BK7231T (Tuya)", + "name": "Generic - BK7231T (Tuya QFN32)", "family": FAMILY_BK7231T, }, "generic-bk7231n-qfn32-tuya": { - "name": "Generic - BK7231N (Tuya)", + "name": "Generic - BK7231N (Tuya QFN32)", "family": FAMILY_BK7231N, }, "cb1s": { @@ -81,10 +64,6 @@ BK72XX_BOARDS = { "name": "Generic - BK7252", "family": FAMILY_BK7251, }, - "t1-3s": { - "name": "T1-3S Wi-Fi Module", - "family": FAMILY_BK7238, - }, "wb2l": { "name": "WB2L Wi-Fi Module", "family": FAMILY_BK7231T, @@ -101,10 +80,6 @@ BK72XX_BOARDS = { "name": "CB2S Wi-Fi Module", "family": FAMILY_BK7231N, }, - "generic-bk7238": { - "name": "Generic - BK7238", - "family": FAMILY_BK7238, - }, "wa2": { "name": "WA2 Wi-Fi Module", "family": FAMILY_BK7231Q, @@ -125,10 +100,6 @@ BK72XX_BOARDS = { "name": "WB3L Wi-Fi Module", "family": FAMILY_BK7231T, }, - "t1-2s": { - "name": "T1-2S Wi-Fi Module", - "family": FAMILY_BK7238, - }, "wb2s": { "name": "WB2S Wi-Fi Module", "family": FAMILY_BK7231T, @@ -187,83 +158,6 @@ BK72XX_BOARD_PINS = { "D12": 22, "A0": 23, }, - "xh-wb3s": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 7, - "D1": 23, - "D2": 14, - "D3": 26, - "D4": 24, - "D5": 6, - "D6": 9, - "D7": 0, - "D8": 1, - "D9": 8, - "D10": 10, - "D11": 11, - "D12": 16, - "D13": 20, - "D14": 21, - "D15": 22, - "D16": 15, - "D17": 17, - "A0": 28, - "A1": 26, - "A2": 24, - "A3": 1, - "A4": 10, - "A5": 20, - }, "cbu": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -336,204 +230,6 @@ BK72XX_BOARD_PINS = { "D18": 21, "A0": 23, }, - "t1-u": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 14, - "D1": 16, - "D2": 23, - "D3": 22, - "D4": 20, - "D5": 1, - "D6": 0, - "D7": 24, - "D8": 9, - "D9": 26, - "D10": 6, - "D11": 8, - "D12": 11, - "D13": 10, - "D14": 28, - "D15": 21, - "D16": 17, - "D17": 15, - "A0": 20, - "A1": 1, - "A2": 24, - "A3": 26, - "A4": 10, - "A5": 28, - }, - "generic-bk7238-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 1, - "A1": 10, - "A2": 20, - "A3": 24, - "A4": 26, - "A5": 28, - }, - "t1-m": { - "WIRE2_SCL": 24, - "WIRE2_SDA": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC5": 1, - "ADC6": 10, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 24, - "SDA2": 26, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 6, - "D2": 8, - "D3": 1, - "D4": 10, - "D5": 11, - "D6": 9, - "D7": 24, - "D11": 0, - "A0": 26, - "A1": 10, - "A2": 1, - "A3": 24, - }, "generic-bk7231t-qfn32-tuya": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -1085,75 +781,6 @@ BK72XX_BOARD_PINS = { "A6": 12, "A7": 13, }, - "t1-3s": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 20, - "D1": 22, - "D2": 6, - "D3": 8, - "D4": 9, - "D5": 23, - "D6": 0, - "D7": 1, - "D8": 24, - "D9": 26, - "D10": 10, - "D11": 11, - "D12": 17, - "D13": 16, - "D14": 15, - "D15": 14, - "A0": 20, - "A1": 1, - "A2": 24, - "A3": 26, - "A4": 10, - }, "wb2l": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1338,84 +965,6 @@ BK72XX_BOARD_PINS = { "D10": 21, "A0": 23, }, - "generic-bk7238": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 1, - "A1": 10, - "A2": 20, - "A3": 24, - "A4": 26, - "A5": 28, - }, "wa2": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1686,51 +1235,6 @@ BK72XX_BOARD_PINS = { "D15": 1, "A0": 23, }, - "t1-2s": { - "WIRE2_SCL": 24, - "WIRE2_SDA": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC5": 1, - "ADC6": 10, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 24, - "SDA2": 26, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 6, - "D2": 8, - "D3": 1, - "D4": 10, - "D5": 11, - "D6": 9, - "D7": 24, - "D11": 0, - "A0": 26, - "A1": 10, - "A2": 1, - "A3": 24, - }, "wb2s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, diff --git a/esphome/components/libretiny/const.py b/esphome/components/libretiny/const.py index 5de4a164b5..332be0de1d 100644 --- a/esphome/components/libretiny/const.py +++ b/esphome/components/libretiny/const.py @@ -58,7 +58,6 @@ COMPONENT_RTL87XX = "rtl87xx" FAMILY_BK7231N = "BK7231N" FAMILY_BK7231Q = "BK7231Q" FAMILY_BK7231T = "BK7231T" -FAMILY_BK7238 = "BK7238" FAMILY_BK7251 = "BK7251" FAMILY_LN882H = "LN882H" FAMILY_RTL8710B = "RTL8710B" @@ -67,7 +66,6 @@ FAMILIES = [ FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, - FAMILY_BK7238, FAMILY_BK7251, FAMILY_LN882H, FAMILY_RTL8710B, @@ -77,7 +75,6 @@ FAMILY_FRIENDLY = { FAMILY_BK7231N: "BK7231N", FAMILY_BK7231Q: "BK7231Q", FAMILY_BK7231T: "BK7231T", - FAMILY_BK7238: "BK7238", FAMILY_BK7251: "BK7251", FAMILY_LN882H: "LN882H", FAMILY_RTL8710B: "RTL8710B", @@ -87,7 +84,6 @@ FAMILY_COMPONENT = { FAMILY_BK7231N: COMPONENT_BK72XX, FAMILY_BK7231Q: COMPONENT_BK72XX, FAMILY_BK7231T: COMPONENT_BK72XX, - FAMILY_BK7238: COMPONENT_BK72XX, FAMILY_BK7251: COMPONENT_BK72XX, FAMILY_LN882H: COMPONENT_LN882X, FAMILY_RTL8710B: COMPONENT_RTL87XX, From 6f0793962494c0b9ecdab41f6735a682a198ef5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 09:33:51 -0500 Subject: [PATCH 0748/2214] Revert "Revert "[bk72xx] Add BK7238 boards in preparation for upstream support"" This reverts commit 0f69504dd0ba015fa4121ee8a3c8bf204bbbf739. --- esphome/components/bk72xx/boards.py | 500 +++++++++++++++++++++++++- esphome/components/libretiny/const.py | 4 + 2 files changed, 502 insertions(+), 2 deletions(-) diff --git a/esphome/components/bk72xx/boards.py b/esphome/components/bk72xx/boards.py index 4bee69fe6d..f8bedce329 100644 --- a/esphome/components/bk72xx/boards.py +++ b/esphome/components/bk72xx/boards.py @@ -16,6 +16,7 @@ from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_BK7251, ) @@ -24,16 +25,32 @@ BK72XX_BOARDS = { "name": "WB2L_M1 Wi-Fi Module", "family": FAMILY_BK7231N, }, + "xh-wb3s": { + "name": "NiceMCU XH-WB3S", + "family": FAMILY_BK7238, + }, "cbu": { "name": "CBU Wi-Fi Module", "family": FAMILY_BK7231N, }, + "t1-u": { + "name": "T1-U Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "generic-bk7238-tuya": { + "name": "Generic - BK7238 (Tuya T1)", + "family": FAMILY_BK7238, + }, + "t1-m": { + "name": "T1-M Wi-Fi Module", + "family": FAMILY_BK7238, + }, "generic-bk7231t-qfn32-tuya": { - "name": "Generic - BK7231T (Tuya QFN32)", + "name": "Generic - BK7231T (Tuya)", "family": FAMILY_BK7231T, }, "generic-bk7231n-qfn32-tuya": { - "name": "Generic - BK7231N (Tuya QFN32)", + "name": "Generic - BK7231N (Tuya)", "family": FAMILY_BK7231N, }, "cb1s": { @@ -64,6 +81,10 @@ BK72XX_BOARDS = { "name": "Generic - BK7252", "family": FAMILY_BK7251, }, + "t1-3s": { + "name": "T1-3S Wi-Fi Module", + "family": FAMILY_BK7238, + }, "wb2l": { "name": "WB2L Wi-Fi Module", "family": FAMILY_BK7231T, @@ -80,6 +101,10 @@ BK72XX_BOARDS = { "name": "CB2S Wi-Fi Module", "family": FAMILY_BK7231N, }, + "generic-bk7238": { + "name": "Generic - BK7238", + "family": FAMILY_BK7238, + }, "wa2": { "name": "WA2 Wi-Fi Module", "family": FAMILY_BK7231Q, @@ -100,6 +125,10 @@ BK72XX_BOARDS = { "name": "WB3L Wi-Fi Module", "family": FAMILY_BK7231T, }, + "t1-2s": { + "name": "T1-2S Wi-Fi Module", + "family": FAMILY_BK7238, + }, "wb2s": { "name": "WB2S Wi-Fi Module", "family": FAMILY_BK7231T, @@ -158,6 +187,83 @@ BK72XX_BOARD_PINS = { "D12": 22, "A0": 23, }, + "xh-wb3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 7, + "D1": 23, + "D2": 14, + "D3": 26, + "D4": 24, + "D5": 6, + "D6": 9, + "D7": 0, + "D8": 1, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 16, + "D13": 20, + "D14": 21, + "D15": 22, + "D16": 15, + "D17": 17, + "A0": 28, + "A1": 26, + "A2": 24, + "A3": 1, + "A4": 10, + "A5": 20, + }, "cbu": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -230,6 +336,204 @@ BK72XX_BOARD_PINS = { "D18": 21, "A0": 23, }, + "t1-u": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 14, + "D1": 16, + "D2": 23, + "D3": 22, + "D4": 20, + "D5": 1, + "D6": 0, + "D7": 24, + "D8": 9, + "D9": 26, + "D10": 6, + "D11": 8, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 21, + "D16": 17, + "D17": 15, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + "A5": 28, + }, + "generic-bk7238-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, + "t1-m": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "generic-bk7231t-qfn32-tuya": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -781,6 +1085,75 @@ BK72XX_BOARD_PINS = { "A6": 12, "A7": 13, }, + "t1-3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 20, + "D1": 22, + "D2": 6, + "D3": 8, + "D4": 9, + "D5": 23, + "D6": 0, + "D7": 1, + "D8": 24, + "D9": 26, + "D10": 10, + "D11": 11, + "D12": 17, + "D13": 16, + "D14": 15, + "D15": 14, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + }, "wb2l": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -965,6 +1338,84 @@ BK72XX_BOARD_PINS = { "D10": 21, "A0": 23, }, + "generic-bk7238": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, "wa2": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1235,6 +1686,51 @@ BK72XX_BOARD_PINS = { "D15": 1, "A0": 23, }, + "t1-2s": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "wb2s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, diff --git a/esphome/components/libretiny/const.py b/esphome/components/libretiny/const.py index 332be0de1d..5de4a164b5 100644 --- a/esphome/components/libretiny/const.py +++ b/esphome/components/libretiny/const.py @@ -58,6 +58,7 @@ COMPONENT_RTL87XX = "rtl87xx" FAMILY_BK7231N = "BK7231N" FAMILY_BK7231Q = "BK7231Q" FAMILY_BK7231T = "BK7231T" +FAMILY_BK7238 = "BK7238" FAMILY_BK7251 = "BK7251" FAMILY_LN882H = "LN882H" FAMILY_RTL8710B = "RTL8710B" @@ -66,6 +67,7 @@ FAMILIES = [ FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_BK7251, FAMILY_LN882H, FAMILY_RTL8710B, @@ -75,6 +77,7 @@ FAMILY_FRIENDLY = { FAMILY_BK7231N: "BK7231N", FAMILY_BK7231Q: "BK7231Q", FAMILY_BK7231T: "BK7231T", + FAMILY_BK7238: "BK7238", FAMILY_BK7251: "BK7251", FAMILY_LN882H: "LN882H", FAMILY_RTL8710B: "RTL8710B", @@ -84,6 +87,7 @@ FAMILY_COMPONENT = { FAMILY_BK7231N: COMPONENT_BK72XX, FAMILY_BK7231Q: COMPONENT_BK72XX, FAMILY_BK7231T: COMPONENT_BK72XX, + FAMILY_BK7238: COMPONENT_BK72XX, FAMILY_BK7251: COMPONENT_BK72XX, FAMILY_LN882H: COMPONENT_LN882X, FAMILY_RTL8710B: COMPONENT_RTL87XX, From 98f250b62c403863b1d5362631abac0188ad6207 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 09:53:40 -0500 Subject: [PATCH 0749/2214] Defer nvs_open warning instead of dropping it Stash the err code in a static and emit ESP_LOGW lazily from the first make_preference() call (which runs after Logger::pre_setup() at EARLY_INIT priority), so the diagnostic stays visible without dereferencing global_logger before it is set. --- esphome/components/esp32/preferences.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 95948d7b3b..e49b688638 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -18,6 +18,12 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// open() runs from app_main() before the logger is initialized, so any failure +// must be deferred until after global_logger is set. This is emitted from the +// first make_preference() call, which runs from the generated setup() after +// log->pre_setup() has run at EARLY_INIT priority. +static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { // try find in pending saves and update that for (auto &obj : s_pending_save) { @@ -70,13 +76,14 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } void ESP32Preferences::open() { - // Can't use ESP_LOG... in this function because it is called before the logger is initialized + // Runs from app_main() before the logger is initialized; any logging here + // must be deferred. See s_open_err and make_preference() below. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) return; - // nvs_open failed, erasing NVS + s_open_err = err; nvs_flash_deinit(); nvs_flash_erase(); nvs_flash_init(); @@ -88,6 +95,10 @@ void ESP32Preferences::open() { } ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { + if (s_open_err != ESP_OK) { + ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err)); + s_open_err = ESP_OK; + } auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; From 8af10c0c976e36d4f4ed73f6cd6c674a8dca5a4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 10:06:05 -0500 Subject: [PATCH 0750/2214] Distinguish erased-NVS from NVS-unavailable in deferred warning If the erase+reopen recovery also fails (e.g. missing/mislabeled NVS partition), nvs_handle stays 0. Report that state explicitly instead of claiming NVS was erased. --- esphome/components/esp32/preferences.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index e49b688638..09835385ac 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -96,7 +96,11 @@ void ESP32Preferences::open() { ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { if (s_open_err != ESP_OK) { - ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err)); + if (this->nvs_handle == 0) { + ESP_LOGW(TAG, "nvs_open failed: %s - NVS unavailable", esp_err_to_name(s_open_err)); + } else { + ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err)); + } s_open_err = ESP_OK; } auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) From 6b5930e6cb7e89ff0985fd98ea659bf55b2d8eab Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:18:33 +0200 Subject: [PATCH 0751/2214] Apply suggestions --- esphome/core/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index c37b3be585..d97944fd2c 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -780,7 +780,8 @@ class EsphomeCore: return self.relative_pioenvs_path(self.name, "firmware.bin") @property - def partition_table_bin(self): + def partition_table_bin(self) -> Path: + # native ESP-IDF: self.relative_build_path("build", "partition_table", "partition-table.bin") return self.relative_pioenvs_path(self.name, "partitions.bin") @property From 7198c912c7095c0e33a3c237dc25aca02ec334c4 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:41:28 +0000 Subject: [PATCH 0752/2214] [esp32][wifi] Fix bootloop and WiFi connection issue if nvs partition is missing or has non-default label (#16025) Co-authored-by: J. Nick Koston --- esphome/components/esp32/preferences.cpp | 18 +++++++++++++++++- .../components/wifi/wifi_component_esp_idf.cpp | 5 ++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 925c4e7662..09835385ac 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -18,6 +18,12 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// open() runs from app_main() before the logger is initialized, so any failure +// must be deferred until after global_logger is set. This is emitted from the +// first make_preference() call, which runs from the generated setup() after +// log->pre_setup() has run at EARLY_INIT priority. +static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { // try find in pending saves and update that for (auto &obj : s_pending_save) { @@ -70,12 +76,14 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } void ESP32Preferences::open() { + // Runs from app_main() before the logger is initialized; any logging here + // must be deferred. See s_open_err and make_preference() below. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) return; - ESP_LOGW(TAG, "nvs_open failed: %s - erasing NVS", esp_err_to_name(err)); + s_open_err = err; nvs_flash_deinit(); nvs_flash_erase(); nvs_flash_init(); @@ -87,6 +95,14 @@ void ESP32Preferences::open() { } ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { + if (s_open_err != ESP_OK) { + if (this->nvs_handle == 0) { + ESP_LOGW(TAG, "nvs_open failed: %s - NVS unavailable", esp_err_to_name(s_open_err)); + } else { + ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err)); + } + s_open_err = ESP_OK; + } auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 29d135ce90..82ecc80811 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,7 +179,10 @@ void WiFiComponent::wifi_pre_setup_() { #endif // USE_WIFI_AP wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - // cfg.nvs_enable = false; + if (global_preferences->nvs_handle == 0) { + ESP_LOGW(TAG, "starting wifi without nvs"); + cfg.nvs_enable = false; + } err = esp_wifi_init(&cfg); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); From fea26dab2083cfd0cb3a9d43829919925a24454c Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:24:51 +0200 Subject: [PATCH 0753/2214] Apply suggestions --- esphome/components/esphome/ota/__init__.py | 2 +- esphome/components/ota/ota_backend_esp_idf.cpp | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index b55d66ba0a..0a9a76e771 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -77,7 +77,7 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) - if config.get(CONF_ALLOW_PARTITION_ACCESS, False) and not CORE.is_esp32: + if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS, False) and not CORE.is_esp32: raise cv.Invalid( f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" ) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index d09f4c48d4..a378adb0e3 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -347,6 +347,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } + esp_partition_deregister_external(this->partition_table_part_); this->partition_table_part_ = nullptr; esp_partition_unload_all(); @@ -358,12 +359,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_info_t *new_part = &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; if (p->address == new_part->pos.offset) { + ESP_LOGD(TAG, "Setting next boot partition to 0x%X", p->address); new_boot_partition = p; } it = esp_partition_next(it); } esp_partition_iterator_release(it); - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); From 6f0612be13ce66be79d79b60b0d4f812b663ed88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 15:26:43 -0500 Subject: [PATCH 0754/2214] [feedback] Use App.get_loop_component_start_time() and constexpr timeout id --- .../components/feedback/feedback_cover.cpp | 20 +++++++++---------- esphome/components/feedback/feedback_cover.h | 6 ++---- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index 1dff210cd6..672e99949b 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -3,11 +3,12 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace feedback { +namespace esphome::feedback { static const char *const TAG = "feedback.cover"; +static constexpr uint32_t DIRECTION_CHANGE_TIMEOUT_ID = 1; + using namespace esphome::cover; void FeedbackCover::setup() { @@ -37,7 +38,7 @@ void FeedbackCover::setup() { } #endif - this->last_recompute_time_ = this->start_dir_time_ = millis(); + this->last_recompute_time_ = this->start_dir_time_ = App.get_loop_component_start_time(); } CoverTraits FeedbackCover::get_traits() { @@ -135,7 +136,7 @@ void FeedbackCover::set_close_endstop(binary_sensor::BinarySensor *close_endstop #endif void FeedbackCover::endstop_reached_(bool open_endstop) { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->position = open_endstop ? COVER_OPEN : COVER_CLOSED; @@ -174,7 +175,7 @@ void FeedbackCover::set_current_operation_(cover::CoverOperation operation, bool if (!is_triggered || (this->open_feedback_ == nullptr || this->close_feedback_ == nullptr)) #endif { - auto now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->current_operation = operation; this->start_dir_time_ = this->last_recompute_time_ = now; this->publish_state(); @@ -306,7 +307,7 @@ void FeedbackCover::control(const CoverCall &call) { void FeedbackCover::stop_prev_trigger_() { if (this->direction_change_waittime_.has_value()) { - this->cancel_timeout("direction_change"); + this->cancel_timeout(DIRECTION_CHANGE_TIMEOUT_ID); } if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); @@ -377,7 +378,7 @@ void FeedbackCover::start_direction_(CoverOperation dir) { ESP_LOGD(TAG, "'%s' - Reversing direction.", this->name_.c_str()); this->start_direction_(COVER_OPERATION_IDLE); - this->set_timeout("direction_change", *this->direction_change_waittime_, + this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, *this->direction_change_waittime_, [this, dir]() { this->start_direction_(dir); }); } else { @@ -395,7 +396,7 @@ void FeedbackCover::recompute_position_() { if (this->current_operation == COVER_OPERATION_IDLE) return; - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); float dir; float action_dur; float min_pos; @@ -451,5 +452,4 @@ void FeedbackCover::recompute_position_() { this->last_recompute_time_ = now; } -} // namespace feedback -} // namespace esphome +} // namespace esphome::feedback diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index 6be8939413..ed6f7490f8 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -8,8 +8,7 @@ #endif #include "esphome/components/cover/cover.h" -namespace esphome { -namespace feedback { +namespace esphome::feedback { class FeedbackCover : public cover::Cover, public Component { public: @@ -85,5 +84,4 @@ class FeedbackCover : public cover::Cover, public Component { uint32_t update_interval_{1000}; }; -} // namespace feedback -} // namespace esphome +} // namespace esphome::feedback From 95977bcccb96a24c855db6634b1c5aafab5a75d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 15:28:23 -0500 Subject: [PATCH 0755/2214] [core] Freshen loop_component_start_time_ before scheduler dispatch --- esphome/core/application.h | 6 ++++++ esphome/core/scheduler.cpp | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/esphome/core/application.h b/esphome/core/application.h index 185ee4163b..86aa461fbb 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -103,6 +103,12 @@ class Application { void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } + /// Update the cached loop component start time. Used by the scheduler before + /// dispatching a queued callback so callers reading + /// get_loop_component_start_time() inside the callback observe a fresh value + /// instead of one inherited from the prior loop iteration's last component. + void set_loop_component_start_time(uint32_t now) { this->loop_component_start_time_ = now; } + // Entity register methods (generated from entity_types.h). // Each entity type gets two overloads: // - register_(obj) — bare push_back diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d83d67d6e4..fc02ba61f8 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -772,6 +772,11 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); + // Freshen the cached loop component start time so callbacks reading + // App.get_loop_component_start_time() observe the dispatch time of this + // item rather than a stale value from the prior loop iteration's last + // component phase. + App.set_loop_component_start_time(now); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); uint32_t end = guard.finish(); From 0ba825a8f0d2628f4d09656108eafbe1becce16e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 15:29:10 -0500 Subject: [PATCH 0756/2214] Make set_loop_component_start_time_ protected with Scheduler friend --- esphome/core/application.h | 13 +++++++------ esphome/core/scheduler.cpp | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 86aa461fbb..b30821923e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -103,12 +103,6 @@ class Application { void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } - /// Update the cached loop component start time. Used by the scheduler before - /// dispatching a queued callback so callers reading - /// get_loop_component_start_time() inside the callback observe a fresh value - /// instead of one inherited from the prior loop iteration's last component. - void set_loop_component_start_time(uint32_t now) { this->loop_component_start_time_ = now; } - // Entity register methods (generated from entity_types.h). // Each entity type gets two overloads: // - register_(obj) — bare push_back @@ -383,12 +377,19 @@ class Application { protected: friend Component; + friend class Scheduler; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif friend void ::setup(); friend void ::original_setup(); + /// Update the cached loop component start time. Used by the scheduler before + /// dispatching a queued callback so callers reading + /// get_loop_component_start_time() inside the callback observe a fresh value + /// instead of one inherited from the prior loop iteration's last component. + void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// (which is a friend) to decide whether to clear the corresponding bit on diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index fc02ba61f8..838bf952af 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -776,7 +776,7 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { // App.get_loop_component_start_time() observe the dispatch time of this // item rather than a stale value from the prior loop iteration's last // component phase. - App.set_loop_component_start_time(now); + App.set_loop_component_start_time_(now); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); uint32_t end = guard.finish(); From 01ac22391348410d4a806b5d7b45a23fe8236bbc Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:30:40 +0200 Subject: [PATCH 0757/2214] [nextion] Unify TFT upload ack timeout to 5000ms (#15960) --- esphome/components/nextion/nextion_upload_arduino.cpp | 11 +++++++++-- esphome/components/nextion/nextion_upload_esp32.cpp | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index c79c68552e..e0d18352ff 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -16,6 +16,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.arduino"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -80,14 +87,14 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); this->upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 40a284dc46..db4558e2fe 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -19,6 +19,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.esp32"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -96,7 +103,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; #ifdef USE_PSRAM @@ -109,7 +116,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r #endif upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; From 3ffbc4ef5433c2a37b2c977a16748f27dee07f87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 15:31:24 -0500 Subject: [PATCH 0758/2214] Shorten comments --- esphome/core/application.h | 5 +---- esphome/core/scheduler.cpp | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index b30821923e..221081a0e4 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -384,10 +384,7 @@ class Application { friend void ::setup(); friend void ::original_setup(); - /// Update the cached loop component start time. Used by the scheduler before - /// dispatching a queued callback so callers reading - /// get_loop_component_start_time() inside the callback observe a fresh value - /// instead of one inherited from the prior loop iteration's last component. + /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } /// Walk all registered components looking for any whose component_state_ diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 838bf952af..11884ce4ba 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -772,10 +772,7 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); - // Freshen the cached loop component start time so callbacks reading - // App.get_loop_component_start_time() observe the dispatch time of this - // item rather than a stale value from the prior loop iteration's last - // component phase. + // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. App.set_loop_component_start_time_(now); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); From a34836c2906bd5a1454bca01a135ed9cb1d9e2ea Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:27:08 +1200 Subject: [PATCH 0759/2214] [esp32_touch] Feed wdt (#16066) --- esphome/components/esp32_touch/esp32_touch.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index e44bc807e9..54bbbe52ed 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -216,6 +216,7 @@ void ESP32TouchComponent::setup() { // Do initial oneshot scans to populate baseline values for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) { err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS); + App.feed_wdt(); // 3 scans with 2s timeout might exceed WDT, so feed it here to be safe if (err != ESP_OK) { ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err)); } From 39a69385fba1ea825906e5ef8759f9a8c3a2351c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 19:57:42 -0500 Subject: [PATCH 0760/2214] [image] Fix RGB565+alpha rendering for multi-frame animations (#16017) Co-authored-by: Claude --- esphome/components/animation/animation.cpp | 7 ++- esphome/components/image/__init__.py | 21 ++++--- tests/component_tests/image/test_init.py | 69 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index c2ae3b2f76..2f59a7fa5a 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -62,7 +62,12 @@ void Animation::set_frame(int frame) { } void Animation::update_data_start_() { - const uint32_t image_size = this->get_width_stride() * this->height_; + uint32_t image_size = this->get_width_stride() * this->height_; + // RGB565 with an alpha channel stores the alpha plane immediately after the RGB + // plane within each frame, so the per-frame stride includes the alpha bytes. + if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { + image_size += static_cast(this->width_) * this->height_; + } this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_; } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 8375ab91d3..365554f7d2 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -744,21 +744,28 @@ async def write_image(config, all_frames=False): if frame_count <= 1: _LOGGER.warning("Image file %s has no animation frames", path) - total_rows = height * frame_count - encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) - if byte_order := config.get(CONF_BYTE_ORDER): - # Check for valid type has already been done in validate_settings - encoder.set_big_endian(byte_order == "BIG_ENDIAN") + # Encode each frame with its own encoder and concatenate. This keeps every + # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] + # per frame) so animation frame stepping in image.cpp / animation.cpp stays + # correct without needing to know the total frame count. + byte_order = config.get(CONF_BYTE_ORDER) + combined_data: list[int] = [] + encoder: ImageEncoder | None = None for frame_index in range(frame_count): image.seek(frame_index) + encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) + if byte_order is not None: + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") pixels = encoder.convert(image.resize((width, height)), path).getdata() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() + combined_data.extend(encoder.data) - rhs = [HexInt(x) for x in encoder.data] + rhs = [HexInt(x) for x in combined_data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) image_type = get_image_type_enum(type) trans_value = get_transparency_enum(encoder.transparency) diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 6f73888c7d..f7f60a1f4d 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -7,10 +7,12 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch +from PIL import Image as PILImage import pytest from esphome import config_validation as cv from esphome.components.image import ( + CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, @@ -411,3 +413,70 @@ async def test_svg_with_mm_dimensions_succeeds( assert 30 < height < 50, ( f"Height should be around 39 pixels for 10mm at 100dpi, got {height}" ) + + +@pytest.mark.asyncio +async def test_rgb565_alpha_animation_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, +) -> None: + """RGB565+alpha animations must store each frame as a self-contained + [RGB plane | alpha plane] block. Animation::update_data_start_ steps frames + with a single per-frame stride, so any cross-frame layout (all RGB then all + alpha) makes the C++ alpha read land in the next frame's RGB bytes — that + was the regression behind issue #15999. + """ + # Build a 2-frame APNG where each frame is a solid color with a known + # alpha. APNG preserves full RGBA per pixel (GIF only has 1-bit alpha so + # round-tripping mid-range alpha values does not work). Frame 0 is fully + # opaque red, frame 1 is fully transparent blue. + width = 4 + height = 3 + frame0 = PILImage.new("RGBA", (width, height), (255, 0, 0, 0xFF)) + frame1 = PILImage.new("RGBA", (width, height), (0, 0, 255, 0x00)) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB565", + CONF_TRANSPARENCY: CONF_ALPHA_CHANNEL, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + # Recover the bytes handed to progmem_array. Signature is (id_, rhs). + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + rgb_size = width * height * 2 + alpha_size = width * height + frame_size = rgb_size + alpha_size + assert len(data) == frame_size * frame_count, ( + "RGB565+alpha animation buffer must be (RGB + alpha) per frame, not " + "all RGB followed by all alpha" + ) + + # Frame 0: RGB plane is red, alpha plane is 0xFF. Frame 1: alpha plane is + # 0x00. If the layout regresses to [all RGB | all alpha], the alpha bytes + # would all land at the tail of the buffer and the per-frame slices below + # would point at RGB565 noise instead. + frame0_alpha = data[rgb_size : rgb_size + alpha_size] + frame1_alpha = data[frame_size + rgb_size : frame_size + rgb_size + alpha_size] + assert all(a == 0xFF for a in frame0_alpha), ( + f"Frame 0 alpha plane should be opaque, got {frame0_alpha}" + ) + assert all(a == 0x00 for a in frame1_alpha), ( + f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" + ) From c26ea52620a5f3a88a2eb7a2e4b5446dedf77195 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:35:00 +1000 Subject: [PATCH 0761/2214] [lvgl] Triggers on tabview tabs fix (#15935) --- esphome/components/lvgl/widgets/tabview.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 108bb38df5..5e9e0494dd 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -22,7 +22,7 @@ from ..defines import ( literal, ) from ..lv_validation import animated, lv_int, size -from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj +from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj, lv_Pvariable from ..schemas import container_schema, part_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties @@ -83,8 +83,8 @@ class TabviewType(WidgetType): await w.set_property("tab_bar_size", await size.process(config[CONF_SIZE])) for tab_conf in config[CONF_TABS]: w_id = tab_conf[CONF_ID] - tab_obj = cg.Pvariable(w_id, cg.nullptr, type_=lv_tab_t) - tab_widget = Widget.create(w_id, tab_obj, obj_spec) + tab_obj = lv_Pvariable(lv_tab_t, w_id) + tab_widget = Widget.create(w_id, tab_obj, obj_spec, tab_conf) lv_assign(tab_obj, lv_expr.tabview_add_tab(w.obj, tab_conf[CONF_NAME])) await set_obj_properties(tab_widget, tab_conf) await add_widgets(tab_widget, tab_conf) From b753ee4e94d060a55464c55e1a94a96b135aeed1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:35:13 +1200 Subject: [PATCH 0762/2214] [time] Handle Windows EINVAL when validating POSIX TZ strings (#15934) --- esphome/components/time/__init__.py | 7 +++ tests/unit_tests/components/test_time.py | 67 +++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 7ac0abeee0..9e79c8e6c2 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,3 +1,4 @@ +import errno from importlib import resources import logging @@ -74,6 +75,12 @@ def _load_tzdata(iana_key: str) -> bytes | None: return (resources.files(package) / resource).read_bytes() except (FileNotFoundError, ModuleNotFoundError, IsADirectoryError): return None + except OSError as e: + # Windows raises EINVAL for paths with NTFS-illegal chars (e.g. '<'/'>' + # in POSIX TZ strings like "<+08>-8" that validate_tz feeds back here). + if e.errno == errno.EINVAL: + return None + raise def _extract_tz_string(tzfile: bytes) -> str: diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 48988fb03f..6325bfbe75 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -1,6 +1,11 @@ """Tests for time component cron expression parsing.""" -from esphome.components.time import _parse_cron_part +import errno +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.time import _load_tzdata, _parse_cron_part, validate_tz def test_star_slash_seconds() -> None: @@ -78,3 +83,63 @@ def test_range() -> None: def test_single_value() -> None: assert _parse_cron_part("30", 0, 59, {}) == {30} + + +def _mock_resources_with_error(error: Exception) -> MagicMock: + """Return a mock of importlib.resources.files where read_bytes raises error.""" + leaf = MagicMock() + leaf.read_bytes.side_effect = error + package = MagicMock() + package.__truediv__.return_value = leaf + return MagicMock(return_value=package) + + +def test_load_tzdata_returns_none_on_windows_einval() -> None: + """On Windows, opening a tzdata path with NTFS-illegal chars raises OSError(EINVAL). + + Regression test for crash when the system TZ resolves to a POSIX string like + "<+08>-8" (Asia/Shanghai, IST, etc.) and is fed back into _load_tzdata by + validate_tz to check whether it is also a valid IANA key. + """ + err = OSError(errno.EINVAL, "Invalid argument") + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(err), + ): + assert _load_tzdata("<+08>-8") is None + + +def test_load_tzdata_propagates_unexpected_oserror() -> None: + """Unrelated OSErrors (e.g. PermissionError) must not be swallowed.""" + with ( + patch( + "esphome.components.time.resources.files", + _mock_resources_with_error( + PermissionError(errno.EACCES, "Permission denied") + ), + ), + pytest.raises(PermissionError), + ): + _load_tzdata("Some/Zone") + + +def test_load_tzdata_returns_none_on_file_not_found() -> None: + """Existing behavior: missing tz file returns None rather than raising.""" + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(FileNotFoundError()), + ): + assert _load_tzdata("Not/A/Zone") is None + + +def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> None: + """validate_tz must not crash when _load_tzdata hits the Windows EINVAL path. + + Simulates the Windows case where the auto-detected POSIX TZ string is fed + back through _load_tzdata and the underlying read_bytes raises errno 22. + """ + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")), + ): + assert validate_tz("<+08>-8") == "<+08>-8" From 6a5919ee8764571ce4dff8c3d939d1230531e112 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 02:19:59 -0500 Subject: [PATCH 0763/2214] [deep_sleep] Fix sleep_duration codegen type to uint32_t (#15965) --- esphome/components/deep_sleep/__init__.py | 2 +- tests/components/deep_sleep/common.yaml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 16329bb0fa..a98b7e60ef 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -413,7 +413,7 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_SLEEP_DURATION in config: - template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.int32) + template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.uint32) cg.add(var.set_sleep_duration(template_)) if CONF_UNTIL in config: diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index c090cb83e2..7a1a709965 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -4,3 +4,9 @@ esphome: - deep_sleep.prevent - delay: 1s - deep_sleep.allow + - if: + condition: + lambda: 'return false;' + then: + - deep_sleep.enter: + sleep_duration: 60min From 4137d93cbfc34778fed7b8b4697a8eb04c7e6777 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:42:36 -0500 Subject: [PATCH 0764/2214] [wifi] Fix stale wifi.connected after state transition (#15966) --- esphome/components/wifi/wifi_component.cpp | 2 ++ esphome/components/wifi/wifi_component_esp8266.cpp | 2 ++ esphome/components/wifi/wifi_component_esp_idf.cpp | 2 ++ esphome/components/wifi/wifi_component_libretiny.cpp | 2 ++ esphome/components/wifi/wifi_component_pico_w.cpp | 2 ++ 5 files changed, 10 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 7b31a22ed5..6b49368933 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1570,6 +1570,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { #endif this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; + // Refresh is_connected() cache; loop()'s refresh ran before this transition. + this->update_connected_state_(); this->num_retried_ = 0; this->print_connect_params_(); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index cb53d3ac1b..d1a31cdfc9 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -948,6 +948,8 @@ void WiFiComponent::process_pending_callbacks_() { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS if (this->pending_.disconnect) { this->pending_.disconnect = false; + // Refresh is_connected() cache here, not in the SDK callback (sys context). + this->update_connected_state_(); this->notify_disconnect_state_listeners_(); } #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 4097df80af..e166fadb27 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -796,6 +796,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { s_sta_connected = false; s_sta_connecting = false; error_from_callback_ = true; + // Refresh is_connected() cache; error_from_callback_ makes it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 9565ffa747..b721364631 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -536,6 +536,8 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { this->error_from_callback_ = true; } + // Refresh is_connected() cache; sta_state_/error_from_callback_ make it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1cfeee3c1b..a50dfd8c80 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -342,6 +342,8 @@ void WiFiComponent::wifi_loop_() { s_sta_was_connected = false; s_sta_had_ip = false; ESP_LOGV(TAG, "Disconnected"); + // Refresh is_connected() cache; driver link status reports disconnected. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif From 433bbdb0163dabcbf31afb3d93d500a6a406020c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:41 -0500 Subject: [PATCH 0765/2214] [rotary_encoder][at581x] Fix templatable int field types (#16015) --- esphome/components/at581x/__init__.py | 8 ++++---- esphome/components/rotary_encoder/sensor.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 94b68db4b3..5031b72cce 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_sensing_distance(template_)) if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, cg.int32) + template_ = await cg.templatable(selfcheck, args, cg.int_) cg.add(var.set_poweron_selfcheck_time(template_)) if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, cg.int32) + template_ = await cg.templatable(protect, args, cg.int_) cg.add(var.set_protect_time(template_)) if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, cg.int32) + template_ = await cg.templatable(trig_base, args, cg.int_) cg.add(var.set_trigger_base(template_)) if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, cg.int32) + template_ = await cg.templatable(trig_keep, args, cg.int_) cg.add(var.set_trigger_keep(template_)) if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 21239863e4..0e5a03523d 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -129,6 +129,6 @@ async def to_code(config): async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.int32) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) cg.add(var.set_value(template_)) return var From aea88aef5e9fdd053ed61007e2a17ccbd4b6982e Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:41:28 +0000 Subject: [PATCH 0766/2214] [esp32][wifi] Fix bootloop and WiFi connection issue if nvs partition is missing or has non-default label (#16025) Co-authored-by: J. Nick Koston --- esphome/components/esp32/preferences.cpp | 18 +++++++++++++++++- .../components/wifi/wifi_component_esp_idf.cpp | 5 ++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index bc0a34ebe8..72a0d979d9 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -22,6 +22,12 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// open() runs from app_main() before the logger is initialized, so any failure +// must be deferred until after global_logger is set. This is emitted from the +// first make_preference() call, which runs from the generated setup() after +// log->pre_setup() has run at EARLY_INIT priority. +static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { // try find in pending saves and update that for (auto &obj : s_pending_save) { @@ -74,12 +80,14 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } void ESP32Preferences::open() { + // Runs from app_main() before the logger is initialized; any logging here + // must be deferred. See s_open_err and make_preference() below. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) return; - ESP_LOGW(TAG, "nvs_open failed: %s - erasing NVS", esp_err_to_name(err)); + s_open_err = err; nvs_flash_deinit(); nvs_flash_erase(); nvs_flash_init(); @@ -91,6 +99,14 @@ void ESP32Preferences::open() { } ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { + if (s_open_err != ESP_OK) { + if (this->nvs_handle == 0) { + ESP_LOGW(TAG, "nvs_open failed: %s - NVS unavailable", esp_err_to_name(s_open_err)); + } else { + ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err)); + } + s_open_err = ESP_OK; + } auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index e166fadb27..a6a48409bc 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,7 +179,10 @@ void WiFiComponent::wifi_pre_setup_() { #endif // USE_WIFI_AP wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - // cfg.nvs_enable = false; + if (global_preferences->nvs_handle == 0) { + ESP_LOGW(TAG, "starting wifi without nvs"); + cfg.nvs_enable = false; + } err = esp_wifi_init(&cfg); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); From a186f6fea9663d7daaf937021ea801073313f5d4 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:30:40 +0200 Subject: [PATCH 0767/2214] [nextion] Unify TFT upload ack timeout to 5000ms (#15960) --- esphome/components/nextion/nextion_upload_arduino.cpp | 11 +++++++++-- esphome/components/nextion/nextion_upload_esp32.cpp | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index c79c68552e..e0d18352ff 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -16,6 +16,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.arduino"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -80,14 +87,14 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); this->upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 40a284dc46..db4558e2fe 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -19,6 +19,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.esp32"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -96,7 +103,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; #ifdef USE_PSRAM @@ -109,7 +116,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r #endif upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; From 191d3bc7e400ddfe228587c1a512b60f7c03706a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:27:08 +1200 Subject: [PATCH 0768/2214] [esp32_touch] Feed wdt (#16066) --- esphome/components/esp32_touch/esp32_touch.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index e44bc807e9..54bbbe52ed 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -216,6 +216,7 @@ void ESP32TouchComponent::setup() { // Do initial oneshot scans to populate baseline values for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) { err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS); + App.feed_wdt(); // 3 scans with 2s timeout might exceed WDT, so feed it here to be safe if (err != ESP_OK) { ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err)); } From 3ac0939f55a79655f52648696473806d84109b22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 19:57:42 -0500 Subject: [PATCH 0769/2214] [image] Fix RGB565+alpha rendering for multi-frame animations (#16017) Co-authored-by: Claude --- esphome/components/animation/animation.cpp | 7 ++- esphome/components/image/__init__.py | 21 ++++--- tests/component_tests/image/test_init.py | 69 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index c2ae3b2f76..2f59a7fa5a 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -62,7 +62,12 @@ void Animation::set_frame(int frame) { } void Animation::update_data_start_() { - const uint32_t image_size = this->get_width_stride() * this->height_; + uint32_t image_size = this->get_width_stride() * this->height_; + // RGB565 with an alpha channel stores the alpha plane immediately after the RGB + // plane within each frame, so the per-frame stride includes the alpha bytes. + if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { + image_size += static_cast(this->width_) * this->height_; + } this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_; } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 8375ab91d3..365554f7d2 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -744,21 +744,28 @@ async def write_image(config, all_frames=False): if frame_count <= 1: _LOGGER.warning("Image file %s has no animation frames", path) - total_rows = height * frame_count - encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) - if byte_order := config.get(CONF_BYTE_ORDER): - # Check for valid type has already been done in validate_settings - encoder.set_big_endian(byte_order == "BIG_ENDIAN") + # Encode each frame with its own encoder and concatenate. This keeps every + # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] + # per frame) so animation frame stepping in image.cpp / animation.cpp stays + # correct without needing to know the total frame count. + byte_order = config.get(CONF_BYTE_ORDER) + combined_data: list[int] = [] + encoder: ImageEncoder | None = None for frame_index in range(frame_count): image.seek(frame_index) + encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) + if byte_order is not None: + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") pixels = encoder.convert(image.resize((width, height)), path).getdata() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() + combined_data.extend(encoder.data) - rhs = [HexInt(x) for x in encoder.data] + rhs = [HexInt(x) for x in combined_data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) image_type = get_image_type_enum(type) trans_value = get_transparency_enum(encoder.transparency) diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 6f73888c7d..f7f60a1f4d 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -7,10 +7,12 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch +from PIL import Image as PILImage import pytest from esphome import config_validation as cv from esphome.components.image import ( + CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, @@ -411,3 +413,70 @@ async def test_svg_with_mm_dimensions_succeeds( assert 30 < height < 50, ( f"Height should be around 39 pixels for 10mm at 100dpi, got {height}" ) + + +@pytest.mark.asyncio +async def test_rgb565_alpha_animation_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, +) -> None: + """RGB565+alpha animations must store each frame as a self-contained + [RGB plane | alpha plane] block. Animation::update_data_start_ steps frames + with a single per-frame stride, so any cross-frame layout (all RGB then all + alpha) makes the C++ alpha read land in the next frame's RGB bytes — that + was the regression behind issue #15999. + """ + # Build a 2-frame APNG where each frame is a solid color with a known + # alpha. APNG preserves full RGBA per pixel (GIF only has 1-bit alpha so + # round-tripping mid-range alpha values does not work). Frame 0 is fully + # opaque red, frame 1 is fully transparent blue. + width = 4 + height = 3 + frame0 = PILImage.new("RGBA", (width, height), (255, 0, 0, 0xFF)) + frame1 = PILImage.new("RGBA", (width, height), (0, 0, 255, 0x00)) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB565", + CONF_TRANSPARENCY: CONF_ALPHA_CHANNEL, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + # Recover the bytes handed to progmem_array. Signature is (id_, rhs). + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + rgb_size = width * height * 2 + alpha_size = width * height + frame_size = rgb_size + alpha_size + assert len(data) == frame_size * frame_count, ( + "RGB565+alpha animation buffer must be (RGB + alpha) per frame, not " + "all RGB followed by all alpha" + ) + + # Frame 0: RGB plane is red, alpha plane is 0xFF. Frame 1: alpha plane is + # 0x00. If the layout regresses to [all RGB | all alpha], the alpha bytes + # would all land at the tail of the buffer and the per-frame slices below + # would point at RGB565 noise instead. + frame0_alpha = data[rgb_size : rgb_size + alpha_size] + frame1_alpha = data[frame_size + rgb_size : frame_size + rgb_size + alpha_size] + assert all(a == 0xFF for a in frame0_alpha), ( + f"Frame 0 alpha plane should be opaque, got {frame0_alpha}" + ) + assert all(a == 0x00 for a in frame1_alpha), ( + f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" + ) From 95b5ab7e78fabb794c69eb04669279fdfe9d767b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:58:29 +1200 Subject: [PATCH 0770/2214] Bump version to 2026.4.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1cd12551dd..0e6a845ed8 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.4.2 +PROJECT_NUMBER = 2026.4.3 # 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 ef37cb2df6..89b6ff15ee 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.2" +__version__ = "2026.4.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From a03de7cea2fa0747a59fe7502f4d072fa68cf25e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 20:23:08 -0500 Subject: [PATCH 0771/2214] [core] Freshen loop_component_start_time_ before scheduler dispatch (#16064) --- esphome/core/application.h | 4 ++++ esphome/core/scheduler.cpp | 2 ++ 2 files changed, 6 insertions(+) diff --git a/esphome/core/application.h b/esphome/core/application.h index 185ee4163b..221081a0e4 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -377,12 +377,16 @@ class Application { protected: friend Component; + friend class Scheduler; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif friend void ::setup(); friend void ::original_setup(); + /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. + void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// (which is a friend) to decide whether to clear the corresponding bit on diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d83d67d6e4..11884ce4ba 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -772,6 +772,8 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); + // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. + App.set_loop_component_start_time_(now); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); uint32_t end = guard.finish(); From 42c9fdc87ef42eb7e5f894abe587d2f321915d8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 23:39:08 -0500 Subject: [PATCH 0772/2214] [feedback] Use App.get_loop_component_start_time() and constexpr timeout id (#16063) --- .../components/feedback/feedback_cover.cpp | 20 +++++++++---------- esphome/components/feedback/feedback_cover.h | 6 ++---- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index 1dff210cd6..672e99949b 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -3,11 +3,12 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace feedback { +namespace esphome::feedback { static const char *const TAG = "feedback.cover"; +static constexpr uint32_t DIRECTION_CHANGE_TIMEOUT_ID = 1; + using namespace esphome::cover; void FeedbackCover::setup() { @@ -37,7 +38,7 @@ void FeedbackCover::setup() { } #endif - this->last_recompute_time_ = this->start_dir_time_ = millis(); + this->last_recompute_time_ = this->start_dir_time_ = App.get_loop_component_start_time(); } CoverTraits FeedbackCover::get_traits() { @@ -135,7 +136,7 @@ void FeedbackCover::set_close_endstop(binary_sensor::BinarySensor *close_endstop #endif void FeedbackCover::endstop_reached_(bool open_endstop) { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->position = open_endstop ? COVER_OPEN : COVER_CLOSED; @@ -174,7 +175,7 @@ void FeedbackCover::set_current_operation_(cover::CoverOperation operation, bool if (!is_triggered || (this->open_feedback_ == nullptr || this->close_feedback_ == nullptr)) #endif { - auto now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->current_operation = operation; this->start_dir_time_ = this->last_recompute_time_ = now; this->publish_state(); @@ -306,7 +307,7 @@ void FeedbackCover::control(const CoverCall &call) { void FeedbackCover::stop_prev_trigger_() { if (this->direction_change_waittime_.has_value()) { - this->cancel_timeout("direction_change"); + this->cancel_timeout(DIRECTION_CHANGE_TIMEOUT_ID); } if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); @@ -377,7 +378,7 @@ void FeedbackCover::start_direction_(CoverOperation dir) { ESP_LOGD(TAG, "'%s' - Reversing direction.", this->name_.c_str()); this->start_direction_(COVER_OPERATION_IDLE); - this->set_timeout("direction_change", *this->direction_change_waittime_, + this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, *this->direction_change_waittime_, [this, dir]() { this->start_direction_(dir); }); } else { @@ -395,7 +396,7 @@ void FeedbackCover::recompute_position_() { if (this->current_operation == COVER_OPERATION_IDLE) return; - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); float dir; float action_dur; float min_pos; @@ -451,5 +452,4 @@ void FeedbackCover::recompute_position_() { this->last_recompute_time_ = now; } -} // namespace feedback -} // namespace esphome +} // namespace esphome::feedback diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index 6be8939413..ed6f7490f8 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -8,8 +8,7 @@ #endif #include "esphome/components/cover/cover.h" -namespace esphome { -namespace feedback { +namespace esphome::feedback { class FeedbackCover : public cover::Cover, public Component { public: @@ -85,5 +84,4 @@ class FeedbackCover : public cover::Cover, public Component { uint32_t update_interval_{1000}; }; -} // namespace feedback -} // namespace esphome +} // namespace esphome::feedback From 792f2e83630088536f33bf6d79d07fb75a15078b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 00:29:42 -0500 Subject: [PATCH 0773/2214] [ota] Add wall-clock timeout to OTA data transfer loop (#16047) --- esphome/components/esphome/ota/ota_esphome.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 47f661a8ea..be771eb689 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -292,6 +292,7 @@ void ESPHomeOTAComponent::handle_data_() { bool update_started = false; size_t total = 0; uint32_t last_progress = 0; + uint32_t last_data_ms = 0; uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; @@ -350,8 +351,18 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge MD5 OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); + // Track when we last received data so a silently-vanished peer (no FIN/RST + // delivered, e.g. uploader killed mid-transfer or NAT/router dropped state) + // can't wedge the device indefinitely. Without this, the loop only exits + // on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP + // TCP keepalive isn't enabled here. + last_data_ms = millis(); while (total < ota_size) { - // TODO: timeout check + if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) { + ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } size_t remaining = ota_size - total; size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; ssize_t read = this->client_->read(buf, requested); @@ -369,6 +380,7 @@ void ESPHomeOTAComponent::handle_data_() { goto error; // NOLINT(cppcoreguidelines-avoid-goto) } + last_data_ms = millis(); error_code = this->backend_->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Flash write err %d", error_code); From 49d3df2698b91f42cdeb8d723a5cc01c18467000 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 28 Apr 2026 05:27:20 -0500 Subject: [PATCH 0774/2214] [automation] Fix codegen type for component.resume update_interval (#16069) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/automation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/automation.py b/esphome/automation.py index 97d9a0a47a..20eb9358ca 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -597,7 +597,7 @@ async def component_resume_action_to_code( comp = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, comp) if CONF_UPDATE_INTERVAL in config: - template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, int) + template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, cg.uint32) cg.add(var.set_update_interval(template_)) return var From 41458d72e00f56f3a9850a2191b47bc4384c5d3b Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Tue, 28 Apr 2026 14:58:34 +0400 Subject: [PATCH 0775/2214] [esp32] Make Arduino app metadata reproducible (#16053) --- esphome/components/esp32/__init__.py | 9 +++--- .../config/reproducible_build_arduino.yaml | 8 ++++++ tests/component_tests/esp32/test_esp32.py | 28 +++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/esp32/config/reproducible_build_arduino.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 78a1715ccf..eb023ce32c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1724,15 +1724,16 @@ async def to_code(config): CORE.relative_internal_path(".espressif") ) + # Both ESP-IDF and ESP32 Arduino builds generate IDF app metadata. Keep + # volatile build path/time data out of the binary so equivalent projects can + # produce reproducible outputs and downstream tooling can reuse artifacts. + add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") if use_platformio: cg.add_platformio_option("framework", "espidf") - # Strip volatile build path/time metadata from PlatformIO-managed - # ESP-IDF builds so equivalent projects can produce reproducible - # outputs and downstream tooling can safely reuse artifacts. - add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) # Wrap std::__throw_* functions to abort immediately, eliminating ~3KB of # exception class overhead. See throw_stubs.cpp for implementation. diff --git a/tests/component_tests/esp32/config/reproducible_build_arduino.yaml b/tests/component_tests/esp32/config/reproducible_build_arduino.yaml new file mode 100644 index 0000000000..a5433a441d --- /dev/null +++ b/tests/component_tests/esp32/config/reproducible_build_arduino.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + variant: esp32 + framework: + type: arduino diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index c39a4aafc8..203f484107 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_ESPHOME, CONF_IGNORE_PIN_VALIDATION_ERROR, CONF_NUMBER, + KEY_NATIVE_IDF, PlatformFramework, ) from esphome.core import CORE @@ -243,3 +244,30 @@ def test_platformio_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_platformio_arduino_enables_reproducible_build( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test PlatformIO Arduino builds enable reproducible app metadata.""" + generate_main(component_config_path("reproducible_build_arduino.yaml")) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_native_idf_enables_reproducible_build( + component_config_path: Callable[[str], Path], +) -> None: + """Test native ESP-IDF builds enable reproducible app metadata.""" + from esphome.__main__ import generate_cpp_contents + from esphome.config import read_config + + CORE.config_path = component_config_path("reproducible_build.yaml") + CORE.config = read_config({}) + CORE.data[KEY_NATIVE_IDF] = True + generate_cpp_contents(CORE.config) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True From 876c8c4c2a160f0aa7cd558c1ea452cad0fb59a3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:59:02 +1200 Subject: [PATCH 0776/2214] [ci-custom] Lint imports of esphome.components.const outside components (#16068) Co-authored-by: Claude Opus 4.7 (1M context) --- script/ci-custom.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/script/ci-custom.py b/script/ci-custom.py index 4d71df74cf..b257a3818b 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -511,6 +511,40 @@ def lint_no_std_string_view(fname, match): ) +@lint_re_check( + r"(?:" + # `from esphome.components.const import ...` + r"from\s+esphome\.components\.const\s+import" + r"|" + # `import esphome.components.const` (with optional `as` alias) + r"import\s+esphome\.components\.const\b" + r"|" + # `from esphome.components import [(] ... const ... [)]` + # Handles parenthesized + multiline import lists by allowing newlines inside + # the parens via [^)]*. Single-line form falls back to the [^#\n]* branch. + r"from\s+esphome\.components\s+import\s*" + r"(?:\([^)]*\bconst\b[^)]*\)|(?:[^#\n]*[\s,])?\bconst\b)" + r")", + include=["*.py"], + exclude=[ + "esphome/components/*", + "tests/*", + "script/ci-custom.py", + ], +) +def lint_no_components_const_outside_components(fname, match): + return ( + f"Constants in {highlight('esphome/components/const/__init__.py')} are intended " + f"to be shared only between components in {highlight('esphome/components/')}. " + f"Code outside this folder must not import from " + f"{highlight('esphome.components.const')}.\n" + f"For core code (used outside {highlight('esphome/components/')}), define the " + f"constant in {highlight('esphome/const.py')} instead. When adding a new " + f"{highlight('CONF_')} constant there, bump {highlight('CONST_PY_MAX_CONF')} " + f"in this file accordingly (see {highlight('lint_const_py_frozen')})." + ) + + @lint_post_check def lint_constants_usage(): errs = [] From 52f80618d4b8e1b8f206bd360ca4f76116a69b7d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:00:29 +1000 Subject: [PATCH 0777/2214] [lvgl] Allow a binary sensor to report checked or pressed state (#16073) Co-authored-by: J. Nick Koston --- .../components/lvgl/binary_sensor/__init__.py | 35 ++++++++++++++----- tests/components/lvgl/lvgl-package.yaml | 15 ++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index f9df7d23fa..aa68e76421 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -4,15 +4,25 @@ from esphome.components.binary_sensor import ( new_binary_sensor, ) import esphome.config_validation as cv +from esphome.const import CONF_STATE -from ..defines import CONF_WIDGET -from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lvgl_static -from ..types import LV_EVENT, lv_pseudo_button_t +from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant +from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static +from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t from ..widgets import Widget, get_widgets, wait_for_widgets +STATE_PRESSED = "PRESSED" +STATE_CHECKED = "CHECKED" + +BS_STATE = LvConstant( + "LV_STATE_", + STATE_PRESSED, + STATE_CHECKED, +) CONFIG_SCHEMA = binary_sensor_schema(BinarySensor).extend( { cv.Required(CONF_WIDGET): cv.use_id(lv_pseudo_button_t), + cv.Optional(CONF_STATE, default=STATE_PRESSED): BS_STATE.one_of, } ) @@ -22,16 +32,23 @@ async def to_code(config): widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] assert isinstance(widget, Widget) + state = await BS_STATE.process(config[CONF_STATE]) await wait_for_widgets() - async with LambdaContext(EVENT_ARG) as pressed_ctx: - pressed_ctx.add(sensor.publish_state(widget.is_pressed())) + is_pressed = str(state) == str(LV_STATE.PRESSED) + test_expr = widget.is_pressed() if is_pressed else widget.is_checked() + async with LambdaContext(EVENT_ARG) as test_ctx: + test_ctx.add(sensor.publish_state(test_expr)) async with LvContext() as ctx: - ctx.add(sensor.publish_initial_state(widget.is_pressed())) + ctx.add(sensor.publish_initial_state(test_expr)) + if is_pressed: + events = [LV_EVENT.PRESSED, LV_EVENT.RELEASED] + widget.add_flag(LV_OBJ_FLAG.CLICKABLE) + else: + events = [LV_EVENT.VALUE_CHANGED, UPDATE_EVENT] ctx.add( lvgl_static.add_event_cb( widget.obj, - await pressed_ctx.get_lambda(), - LV_EVENT.PRESSED, - LV_EVENT.RELEASED, + await test_ctx.get_lambda(), + *events, ) ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d3565c6c59..d6e237199a 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -16,10 +16,19 @@ binary_sensor: platform: template - id: left_sensor platform: template + - platform: lvgl + name: Button A pressed + widget: button_a + state: pressed + - platform: lvgl + name: Button A checked + widget: button_a + state: checked - platform: lvgl id: button_checker name: LVGL button widget: button_button + state: checked on_state: then: - lvgl.checkbox.update: @@ -29,6 +38,12 @@ binary_sensor: auto y = x; // block inlining of one line return return y; + - platform: lvgl + id: button_presser + name: Button pressed + widget: button_button + state: pressed + lvgl: id: lvgl_id rotation: 90 From 8921e3bb3f821fff077300f04d7d52bc86f41997 Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Tue, 28 Apr 2026 15:49:16 +0300 Subject: [PATCH 0778/2214] [api] add open states for `lock` to `api.proto` (#15901) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 2 ++ esphome/components/api/api_pb2.h | 2 ++ esphome/components/api/api_pb2_dump.cpp | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 1c33d92bea..c0fd990eca 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1419,6 +1419,8 @@ enum LockState { LOCK_STATE_JAMMED = 3; LOCK_STATE_LOCKING = 4; LOCK_STATE_UNLOCKING = 5; + LOCK_STATE_OPENING = 6; + LOCK_STATE_OPEN = 7; } enum LockCommand { LOCK_UNLOCK = 0; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index a8e01c017f..7b82f1884d 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -181,6 +181,8 @@ enum LockState : uint32_t { LOCK_STATE_JAMMED = 3, LOCK_STATE_LOCKING = 4, LOCK_STATE_UNLOCKING = 5, + LOCK_STATE_OPENING = 6, + LOCK_STATE_OPEN = 7, }; enum LockCommand : uint32_t { LOCK_UNLOCK = 0, diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 541f5d4d11..5258b355ce 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -487,6 +487,10 @@ template<> const char *proto_enum_to_string(enums::LockState v return ESPHOME_PSTR("LOCK_STATE_LOCKING"); case enums::LOCK_STATE_UNLOCKING: return ESPHOME_PSTR("LOCK_STATE_UNLOCKING"); + case enums::LOCK_STATE_OPENING: + return ESPHOME_PSTR("LOCK_STATE_OPENING"); + case enums::LOCK_STATE_OPEN: + return ESPHOME_PSTR("LOCK_STATE_OPEN"); default: return ESPHOME_PSTR("UNKNOWN"); } From 9c4fe1afe88301d791381a5f1602c4baa574ba9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 08:42:20 -0500 Subject: [PATCH 0779/2214] [api] Use entity_types.h X-macro for InitialStateIterator declarations --- esphome/components/api/subscribe_state.cpp | 5 +- esphome/components/api/subscribe_state.h | 86 ++++------------------ 2 files changed, 18 insertions(+), 73 deletions(-) diff --git a/esphome/components/api/subscribe_state.cpp b/esphome/components/api/subscribe_state.cpp index 4bbc17018e..09b5640d8a 100644 --- a/esphome/components/api/subscribe_state.cpp +++ b/esphome/components/api/subscribe_state.cpp @@ -67,7 +67,10 @@ INITIAL_STATE_HANDLER(water_heater, water_heater::WaterHeater) INITIAL_STATE_HANDLER(update, update::UpdateEntity) #endif -// Special cases (button and event) are already defined inline in subscribe_state.h +// event is an ENTITY_CONTROLLER_TYPE_ but has no state to send. +#ifdef USE_EVENT +bool InitialStateIterator::on_event(event::Event *entity) { return true; } +#endif InitialStateIterator::InitialStateIterator(APIConnection *client) : client_(client) {} diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index f20611e06a..6b1ae9651d 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -19,78 +19,20 @@ class APIConnection; class InitialStateIterator final : public ComponentIterator { public: InitialStateIterator(APIConnection *client); -#ifdef USE_BINARY_SENSOR - bool on_binary_sensor(binary_sensor::BinarySensor *entity) override; -#endif -#ifdef USE_COVER - bool on_cover(cover::Cover *entity) override; -#endif -#ifdef USE_FAN - bool on_fan(fan::Fan *entity) override; -#endif -#ifdef USE_LIGHT - bool on_light(light::LightState *entity) override; -#endif -#ifdef USE_SENSOR - bool on_sensor(sensor::Sensor *entity) override; -#endif -#ifdef USE_SWITCH - bool on_switch(switch_::Switch *entity) override; -#endif -#ifdef USE_BUTTON - bool on_button(button::Button *button) override { return true; }; -#endif -#ifdef USE_TEXT_SENSOR - bool on_text_sensor(text_sensor::TextSensor *entity) override; -#endif -#ifdef USE_CLIMATE - bool on_climate(climate::Climate *entity) override; -#endif -#ifdef USE_NUMBER - bool on_number(number::Number *entity) override; -#endif -#ifdef USE_DATETIME_DATE - bool on_date(datetime::DateEntity *entity) override; -#endif -#ifdef USE_DATETIME_TIME - bool on_time(datetime::TimeEntity *entity) override; -#endif -#ifdef USE_DATETIME_DATETIME - bool on_datetime(datetime::DateTimeEntity *entity) override; -#endif -#ifdef USE_TEXT - bool on_text(text::Text *entity) override; -#endif -#ifdef USE_SELECT - bool on_select(select::Select *entity) override; -#endif -#ifdef USE_LOCK - bool on_lock(lock::Lock *entity) override; -#endif -#ifdef USE_VALVE - bool on_valve(valve::Valve *entity) override; -#endif -#ifdef USE_MEDIA_PLAYER - bool on_media_player(media_player::MediaPlayer *entity) override; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override; -#endif -#ifdef USE_WATER_HEATER - bool on_water_heater(water_heater::WaterHeater *entity) override; -#endif -#ifdef USE_INFRARED - bool on_infrared(infrared::Infrared *infrared) override { return true; }; -#endif -#ifdef USE_RADIO_FREQUENCY - bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; }; -#endif -#ifdef USE_EVENT - bool on_event(event::Event *event) override { return true; }; -#endif -#ifdef USE_UPDATE - bool on_update(update::UpdateEntity *entity) override; -#endif + +// Entity overrides (generated from entity_types.h). +// ENTITY_TYPE_ entities have no state to send and default to a no-op. +// ENTITY_CONTROLLER_TYPE_ entities are implemented in subscribe_state.cpp via INITIAL_STATE_HANDLER, +// except on_event which has no state (defined out-of-line in subscribe_state.cpp). +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + bool on_##singular(type *entity) override { return true; } +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + bool on_##singular(type *entity) override; +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) protected: APIConnection *client_; From 42064ab37d2cccaac55aaa48084917a3c93d77ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 08:44:10 -0500 Subject: [PATCH 0780/2214] [api] Use entity_types.h X-macro for ListEntitiesIterator declarations --- esphome/components/api/list_entities.h | 83 ++++---------------------- 1 file changed, 11 insertions(+), 72 deletions(-) diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 95c626feb1..88fbdb77c8 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -18,83 +18,22 @@ class APIConnection; class ListEntitiesIterator final : public ComponentIterator { public: ListEntitiesIterator(APIConnection *client); -#ifdef USE_BINARY_SENSOR - bool on_binary_sensor(binary_sensor::BinarySensor *entity) override; -#endif -#ifdef USE_COVER - bool on_cover(cover::Cover *entity) override; -#endif -#ifdef USE_FAN - bool on_fan(fan::Fan *entity) override; -#endif -#ifdef USE_LIGHT - bool on_light(light::LightState *entity) override; -#endif -#ifdef USE_SENSOR - bool on_sensor(sensor::Sensor *entity) override; -#endif -#ifdef USE_SWITCH - bool on_switch(switch_::Switch *entity) override; -#endif -#ifdef USE_BUTTON - bool on_button(button::Button *entity) override; -#endif -#ifdef USE_TEXT_SENSOR - bool on_text_sensor(text_sensor::TextSensor *entity) override; -#endif + +// Entity overrides (generated from entity_types.h). +// All implementations live in list_entities.cpp via LIST_ENTITIES_HANDLER. +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) bool on_##singular(type *entity) override; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_API_USER_DEFINED_ACTIONS bool on_service(UserServiceDescriptor *service) override; #endif #ifdef USE_CAMERA bool on_camera(camera::Camera *entity) override; -#endif -#ifdef USE_CLIMATE - bool on_climate(climate::Climate *entity) override; -#endif -#ifdef USE_NUMBER - bool on_number(number::Number *entity) override; -#endif -#ifdef USE_DATETIME_DATE - bool on_date(datetime::DateEntity *entity) override; -#endif -#ifdef USE_DATETIME_TIME - bool on_time(datetime::TimeEntity *entity) override; -#endif -#ifdef USE_DATETIME_DATETIME - bool on_datetime(datetime::DateTimeEntity *entity) override; -#endif -#ifdef USE_TEXT - bool on_text(text::Text *entity) override; -#endif -#ifdef USE_SELECT - bool on_select(select::Select *entity) override; -#endif -#ifdef USE_LOCK - bool on_lock(lock::Lock *entity) override; -#endif -#ifdef USE_VALVE - bool on_valve(valve::Valve *entity) override; -#endif -#ifdef USE_MEDIA_PLAYER - bool on_media_player(media_player::MediaPlayer *entity) override; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override; -#endif -#ifdef USE_WATER_HEATER - bool on_water_heater(water_heater::WaterHeater *entity) override; -#endif -#ifdef USE_INFRARED - bool on_infrared(infrared::Infrared *entity) override; -#endif -#ifdef USE_RADIO_FREQUENCY - bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override; -#endif -#ifdef USE_EVENT - bool on_event(event::Event *entity) override; -#endif -#ifdef USE_UPDATE - bool on_update(update::UpdateEntity *entity) override; #endif bool on_end() override; From e1eda48d40f0342078b7d92217ff2212a8e48058 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 08:45:38 -0500 Subject: [PATCH 0781/2214] [web_server] Use entity_types.h X-macro for ListEntitiesIterator declarations --- .../components/web_server/list_entities.cpp | 4 + esphome/components/web_server/list_entities.h | 83 +++---------------- 2 files changed, 15 insertions(+), 72 deletions(-) diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index c1e7599c7e..869ed3ea17 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -167,5 +167,9 @@ bool ListEntitiesIterator::on_update(update::UpdateEntity *obj) { } #endif +#ifdef USE_MEDIA_PLAYER +bool ListEntitiesIterator::on_media_player(media_player::MediaPlayer *obj) { return true; } +#endif + } // namespace esphome::web_server #endif diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index 9cfc6c7e33..3edb84f555 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -24,78 +24,17 @@ class ListEntitiesIterator final : public ComponentIterator { #elif defined(USE_ARDUINO) ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es); #endif -#ifdef USE_BINARY_SENSOR - bool on_binary_sensor(binary_sensor::BinarySensor *obj) override; -#endif -#ifdef USE_COVER - bool on_cover(cover::Cover *obj) override; -#endif -#ifdef USE_FAN - bool on_fan(fan::Fan *obj) override; -#endif -#ifdef USE_LIGHT - bool on_light(light::LightState *obj) override; -#endif -#ifdef USE_SENSOR - bool on_sensor(sensor::Sensor *obj) override; -#endif -#ifdef USE_SWITCH - bool on_switch(switch_::Switch *obj) override; -#endif -#ifdef USE_BUTTON - bool on_button(button::Button *obj) override; -#endif -#ifdef USE_TEXT_SENSOR - bool on_text_sensor(text_sensor::TextSensor *obj) override; -#endif -#ifdef USE_CLIMATE - bool on_climate(climate::Climate *obj) override; -#endif -#ifdef USE_NUMBER - bool on_number(number::Number *obj) override; -#endif -#ifdef USE_DATETIME_DATE - bool on_date(datetime::DateEntity *obj) override; -#endif -#ifdef USE_DATETIME_TIME - bool on_time(datetime::TimeEntity *obj) override; -#endif -#ifdef USE_DATETIME_DATETIME - bool on_datetime(datetime::DateTimeEntity *obj) override; -#endif -#ifdef USE_TEXT - bool on_text(text::Text *obj) override; -#endif -#ifdef USE_SELECT - bool on_select(select::Select *obj) override; -#endif -#ifdef USE_LOCK - bool on_lock(lock::Lock *obj) override; -#endif -#ifdef USE_VALVE - bool on_valve(valve::Valve *obj) override; -#endif -#ifdef USE_MEDIA_PLAYER - bool on_media_player(media_player::MediaPlayer *obj) override { return true; } -#endif -#ifdef USE_ALARM_CONTROL_PANEL - bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) override; -#endif -#ifdef USE_WATER_HEATER - bool on_water_heater(water_heater::WaterHeater *obj) override; -#endif -#ifdef USE_INFRARED - bool on_infrared(infrared::Infrared *obj) override; -#endif -#ifdef USE_RADIO_FREQUENCY - bool on_radio_frequency(radio_frequency::RadioFrequency *obj) override; -#endif -#ifdef USE_EVENT - bool on_event(event::Event *obj) override; -#endif -#ifdef USE_UPDATE - bool on_update(update::UpdateEntity *obj) override; -#endif + +// Entity overrides (generated from entity_types.h). +// Implementations live in list_entities.cpp. +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) bool on_##singular(type *obj) override; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) bool completed() { return this->state_ == IteratorState::NONE; } protected: From 0759a3c6815e88fd333fef2ed843027ce24d445a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 08:48:13 -0500 Subject: [PATCH 0782/2214] [core] Split wake.{h,cpp} into per-platform files (#15978) --- esphome/core/config.py | 23 ++ esphome/core/defines.h | 15 +- esphome/core/time_64.cpp | 4 +- esphome/core/time_64.h | 14 +- esphome/core/wake.h | 214 ++---------------- esphome/core/wake/wake_esp8266.cpp | 21 ++ esphome/core/wake/wake_esp8266.h | 47 ++++ esphome/core/wake/wake_freertos.cpp | 33 +++ esphome/core/wake/wake_freertos.h | 60 +++++ esphome/core/wake/wake_generic.cpp | 17 ++ esphome/core/wake/wake_generic.h | 31 +++ esphome/core/{wake.cpp => wake/wake_host.cpp} | 89 +------- esphome/core/wake/wake_host.h | 64 ++++++ esphome/core/wake/wake_rp2040.cpp | 58 +++++ esphome/core/wake/wake_rp2040.h | 31 +++ esphome/loader.py | 50 ++-- tests/unit_tests/test_loader.py | 164 ++++++++++++++ 17 files changed, 632 insertions(+), 303 deletions(-) create mode 100644 esphome/core/wake/wake_esp8266.cpp create mode 100644 esphome/core/wake/wake_esp8266.h create mode 100644 esphome/core/wake/wake_freertos.cpp create mode 100644 esphome/core/wake/wake_freertos.h create mode 100644 esphome/core/wake/wake_generic.cpp create mode 100644 esphome/core/wake/wake_generic.h rename esphome/core/{wake.cpp => wake/wake_host.cpp} (74%) create mode 100644 esphome/core/wake/wake_host.h create mode 100644 esphome/core/wake/wake_rp2040.cpp create mode 100644 esphome/core/wake/wake_rp2040.h diff --git a/esphome/core/config.py b/esphome/core/config.py index 018e05f17b..14161a7c8b 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -792,6 +792,29 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + # Per-platform wake implementations — wake.h dispatches to exactly one of + # these based on USE_*, so the others can be skipped at the source level + # too. Header files next to each .cpp are always copied (the dispatcher + # #include's them) but compile to empty TUs on the wrong platform anyway. + "wake/wake_freertos.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + "wake/wake_esp8266.cpp": { + PlatformFramework.ESP8266_ARDUINO, + }, + "wake/wake_rp2040.cpp": { + PlatformFramework.RP2040_ARDUINO, + }, + "wake/wake_host.cpp": { + PlatformFramework.HOST_NATIVE, + }, + "wake/wake_generic.cpp": { + PlatformFramework.NRF52_ZEPHYR, + }, # Note: lock_free_queue.h and event_pool.h are header files and don't need to be filtered # as they are only included when needed by the preprocessor } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f929b224ca..daca55d68a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -17,8 +17,21 @@ #define ESPHOME_DEBUG_SCHEDULER #define ESPHOME_DEBUG_API -// Default threading model for static analysis (ESP32 is multi-threaded with atomics) +// Threading model for static analysis. Match what the real codegen picks per +// platform (see esphome/components//__init__.py ThreadModel.*): +// USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE +// USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS +// everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS +// Without this the clang-tidy envs end up with USE_ +// + MULTI_ATOMICS simultaneously, a combination that can never occur in a +// real build. +#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_NRF52) +#define ESPHOME_THREAD_SINGLE +#elif defined(USE_BK72XX) +#define ESPHOME_THREAD_MULTI_NO_ATOMICS +#else #define ESPHOME_THREAD_MULTI_ATOMICS +#endif // logger #define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index cf651c3e91..25076228d5 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -22,8 +22,8 @@ static const char *const TAG = "time_64"; #ifdef ESPHOME_THREAD_SINGLE // Storage for Millis64Impl inline compute() — defined here so all TUs share one copy. -uint32_t Millis64Impl::last_millis_{0}; -uint16_t Millis64Impl::millis_major_{0}; +uint32_t Millis64Impl::last_millis{0}; +uint16_t Millis64Impl::millis_major{0}; #else uint64_t Millis64Impl::compute(uint32_t now) { diff --git a/esphome/core/time_64.h b/esphome/core/time_64.h index 592e645d41..d82373dbfe 100644 --- a/esphome/core/time_64.h +++ b/esphome/core/time_64.h @@ -21,8 +21,8 @@ class Millis64Impl { #ifdef ESPHOME_THREAD_SINGLE // Storage defined in time_64.cpp — declared here so the inline body can access them. - static uint32_t last_millis_; - static uint16_t millis_major_; + static uint32_t last_millis; + static uint16_t millis_major; static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) { // Half the 32-bit range - used to detect rollovers vs normal time progression @@ -30,17 +30,17 @@ class Millis64Impl { // Single-core platforms have no concurrency, so this is a simple implementation // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics. - uint16_t major = millis_major_; - uint32_t last = last_millis_; + uint16_t major = millis_major; + uint32_t last = last_millis; // Check for rollover if (now < last && (last - now) > HALF_MAX_UINT32) { - millis_major_++; + millis_major++; major++; - last_millis_ = now; + last_millis = now; } else if (now > last) { // Only update if time moved forward - last_millis_ = now; + last_millis = now; } // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 0cfca94a78..a2f732fcdb 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -3,6 +3,10 @@ /// @file wake.h /// Platform-specific main loop wake primitives. /// Always available on all platforms — no opt-in needed. +/// +/// The public API for callers lives here; the per-platform implementations +/// live under esphome/core/wake/ and are included at the bottom of this file +/// based on the active USE_* platform define. #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -11,21 +15,6 @@ #include #endif -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -#include "esphome/core/main_task.h" -#endif -#ifdef USE_ESP8266 -#include -#elif defined(USE_RP2040) -#include -#include -#endif - -#ifdef USE_HOST -#include -#include -#endif - namespace esphome { // === Wake flag for ESP8266/RP2040 === @@ -67,184 +56,19 @@ __attribute__((always_inline)) inline bool wake_request_take() { } #endif -// === ESP32 / LibreTiny (FreeRTOS) === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) - -/// Wake the main loop from any context (ISR or task). -/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. -__attribute__((always_inline)) inline void wake_main_task_any_context() { - // Set the wake-requested flag BEFORE the task notification so the consumer - // (Application::loop() gate) is guaranteed to see it on its next gate check. - wake_request_set(); - if (in_isr_context()) { - BaseType_t px_higher_priority_task_woken = pdFALSE; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); -#ifdef portYIELD_FROM_ISR - portYIELD_FROM_ISR(px_higher_priority_task_woken); -#else - // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ - // exit sequence performs the context switch if one was requested. - (void) px_higher_priority_task_woken; -#endif - } else { - esphome_main_task_notify(); - } -} - -/// IRAM_ATTR entry points — defined in wake.cpp. -void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -void wake_loop_any_context(); - -inline void wake_loop_threadsafe() { - wake_request_set(); - esphome_main_task_notify(); -} - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip - // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: - // wake_loop_threadsafe() from background tasks, and the ms timeout. - if (ms == 0) [[unlikely]] { - yield(); - return; - } - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); -} -} // namespace internal - -// === ESP8266 === -#elif defined(USE_ESP8266) - -/// Inline implementation — IRAM callers inline this directly. -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(); - g_main_loop_woke = true; - esp_schedule(); -} - -/// IRAM_ATTR entry point for ISR callers — defined in wake.cpp. -void wake_loop_any_context(); - -/// Non-ISR: always inline. -inline void wake_loop_threadsafe() { wake_loop_impl(); } - -/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. -inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - delay(0); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - esp_delay(ms, []() { return !g_main_loop_woke; }); -} -} // namespace internal - -// === RP2040 === -#elif defined(USE_RP2040) - -inline void wake_loop_any_context() { - // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed - // to see it on its next gate check. - wake_request_set(); - g_main_loop_woke = true; - __sev(); -} - -inline void wake_loop_threadsafe() { wake_loop_any_context(); } - -/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake.cpp. -namespace internal { -void wakeable_delay(uint32_t ms); -} // namespace internal - -// === Host / Zephyr / other === -#else - -#ifdef USE_HOST -/// Host: wakes select() via UDP loopback socket. Defined in wake.cpp. -void wake_loop_threadsafe(); - -/// Register a socket file descriptor with the host select() loop. Not -/// thread-safe — main loop only. Returns false if fd is invalid or -/// >= FD_SETSIZE. -bool wake_register_fd(int fd); - -/// Unregister a socket file descriptor. Not thread-safe — main loop only. -void wake_unregister_fd(int fd); - -/// One-time setup of the loopback wake socket. Called from Application::setup(). -void wake_setup(); - -// wake_fd_ready() and wake_drain_notifications() are defined inline at the -// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in -// scope, which depend on USE_HOST-only includes pulled in above. -#else -/// Zephyr is currently the only platform without a wake mechanism. -/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). -/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. -inline void wake_loop_threadsafe() {} -#endif - -inline void wake_loop_any_context() { wake_loop_threadsafe(); } - -namespace internal { -#ifdef USE_HOST -/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp. -void wakeable_delay(uint32_t ms); -#else -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - delay(ms); -} -#endif -} // namespace internal - -#endif - -#ifdef USE_HOST -namespace internal { -// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications() -// and wake_fd_ready() so the hot path stays in the header. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern int g_wake_socket_fd; -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern fd_set g_read_fds; -} // namespace internal - -inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } - -// Small buffer for draining wake notification bytes (1 byte sent per wake). -// Sized to drain multiple notifications per recvfrom() without wasting stack. -inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; - -inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { - // Called from main loop to drain any pending wake notifications. - // Must check wake_fd_ready() to avoid blocking on empty socket. - if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { - char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; - // Drain all pending notifications with non-blocking reads. Multiple wake events - // may have triggered multiple writes, so drain until EWOULDBLOCK. We control - // both ends of this loopback socket (always 1 byte per wake), so no error - // checking — any error indicates catastrophic system failure. - while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - } - } -} -#endif // USE_HOST - } // namespace esphome + +// Per-platform implementations. Each header re-enters namespace esphome {} and +// guards its body with the matching USE_* check, so only one contributes code +// for the active target. +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "esphome/core/wake/wake_freertos.h" +#elif defined(USE_ESP8266) +#include "esphome/core/wake/wake_esp8266.h" +#elif defined(USE_RP2040) +#include "esphome/core/wake/wake_rp2040.h" +#elif defined(USE_HOST) +#include "esphome/core/wake/wake_host.h" +#else +#include "esphome/core/wake/wake_generic.h" +#endif diff --git a/esphome/core/wake/wake_esp8266.cpp b/esphome/core/wake/wake_esp8266.cpp new file mode 100644 index 0000000000..9ced43c6df --- /dev/null +++ b/esphome/core/wake/wake_esp8266.cpp @@ -0,0 +1,21 @@ +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// ESP8266 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h new file mode 100644 index 0000000000..80cd61035b --- /dev/null +++ b/esphome/core/wake/wake_esp8266.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" + +#include + +namespace esphome { + +/// Inline implementation — IRAM callers inline this directly. +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(); + g_main_loop_woke = true; + esp_schedule(); +} + +/// IRAM_ATTR entry point for ISR callers — defined in wake_esp8266.cpp. +void wake_loop_any_context(); + +/// Non-ISR: always inline. +inline void wake_loop_threadsafe() { wake_loop_impl(); } + +/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. +inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + delay(0); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + esp_delay(ms, []() { return !g_main_loop_woke; }); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp new file mode 100644 index 0000000000..0bf700daa8 --- /dev/null +++ b/esphome/core/wake/wake_freertos.cpp @@ -0,0 +1,33 @@ +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// ESP32 is always MULTI_ATOMICS; LibreTiny is MULTI_ATOMICS on chips with +// proper atomics (e.g. RTL8720) and MULTI_NO_ATOMICS on others (e.g. BK72XX). +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic g_wake_requested{0}; +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +#endif + +void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + // ISR-safe: set flag before notify so the wake is visible on the next gate + // check. wake_request_set() is just an aligned 8-bit store / atomic store + // and is safe from IRAM. + wake_request_set(); + esphome_main_task_notify_from_isr(px_higher_priority_task_woken); +} + +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h new file mode 100644 index 0000000000..167a422c61 --- /dev/null +++ b/esphome/core/wake/wake_freertos.h @@ -0,0 +1,60 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/main_task.h" + +namespace esphome { + +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + // Set the wake-requested flag BEFORE the task notification so the consumer + // (Application::loop() gate) is guaranteed to see it on its next gate check. + wake_request_set(); + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR + portYIELD_FROM_ISR(px_higher_priority_task_woken); +#else + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; +#endif + } else { + esphome_main_task_notify(); + } +} + +/// IRAM_ATTR entry points — defined in wake_freertos.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +void wake_loop_any_context(); + +inline void wake_loop_threadsafe() { + wake_request_set(); + esphome_main_task_notify(); +} + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the ms timeout. + if (ms == 0) [[unlikely]] { + yield(); + return; + } + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_generic.cpp b/esphome/core/wake/wake_generic.cpp new file mode 100644 index 0000000000..40044e4311 --- /dev/null +++ b/esphome/core/wake/wake_generic.cpp @@ -0,0 +1,17 @@ +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ + !defined(USE_HOST) + +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; + +} // namespace esphome + +#endif // fallback guard diff --git a/esphome/core/wake/wake_generic.h b/esphome/core/wake/wake_generic.h new file mode 100644 index 0000000000..85424b6138 --- /dev/null +++ b/esphome/core/wake/wake_generic.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ + !defined(USE_HOST) + +#include "esphome/core/hal.h" + +namespace esphome { + +/// Zephyr is currently the only platform without a wake mechanism. +/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). +/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. +inline void wake_loop_threadsafe() {} + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + delay(ms); +} +} // namespace internal + +} // namespace esphome + +#endif // fallback guard diff --git a/esphome/core/wake.cpp b/esphome/core/wake/wake_host.cpp similarity index 74% rename from esphome/core/wake.cpp rename to esphome/core/wake/wake_host.cpp index cac88ae91e..9d2a650ca2 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -1,12 +1,11 @@ -#include "esphome/core/wake.h" -#include "esphome/core/hal.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP8266 -#include -#endif +#include "esphome/core/defines.h" #ifdef USE_HOST + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/wake.h" + #include #include #include @@ -15,88 +14,19 @@ #include #include #include -#endif namespace esphome { // === Wake-requested flag storage === -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// Host is always ESPHOME_THREAD_MULTI_ATOMICS. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -#else -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile uint8_t g_wake_requested = 0; -#endif - -// === ESP32 / LibreTiny — IRAM_ATTR entry points === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { - // ISR-safe: set flag before notify so the wake is visible on the next gate - // check. wake_request_set() is just an aligned 8-bit store / atomic store - // and is safe from IRAM. - wake_request_set(); - esphome_main_task_notify_from_isr(px_higher_priority_task_woken); -} -void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } -#endif - -// === ESP8266 / RP2040 === -#if defined(USE_ESP8266) || defined(USE_RP2040) -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile bool g_main_loop_woke = false; -#endif - -#ifdef USE_ESP8266 -void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } -#endif - -// === RP2040 — wakeable_delay (needs file-scope state for alarm callback) === -#ifdef USE_RP2040 -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -static volatile bool s_delay_expired = false; - -static int64_t alarm_callback_(alarm_id_t id, void *user_data) { - (void) id; - (void) user_data; - s_delay_expired = true; - __sev(); - return 0; -} - -namespace internal { -void wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - s_delay_expired = false; - alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); - if (alarm <= 0) { - delay(ms); - return; - } - while (!g_main_loop_woke && !s_delay_expired) { - __wfe(); - } - if (!s_delay_expired) - cancel_alarm(alarm); - g_main_loop_woke = false; -} -} // namespace internal -#endif // USE_RP2040 - -// === Host (UDP loopback socket + select() based fd watcher) === -#ifdef USE_HOST static const char *const TAG = "wake"; namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and -// wake_fd_ready() in wake.h, and by the bodies in this file. +// wake_fd_ready() in wake_host.h, and by the bodies in this file. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) int g_wake_socket_fd = -1; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) @@ -271,6 +201,7 @@ void wake_setup() { return; } } -#endif // USE_HOST } // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_host.h b/esphome/core/wake/wake_host.h new file mode 100644 index 0000000000..9756ed4c39 --- /dev/null +++ b/esphome/core/wake/wake_host.h @@ -0,0 +1,64 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_HOST + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +/// Host: wakes select() via UDP loopback socket. Defined in wake_host.cpp. +void wake_loop_threadsafe(); + +/// Register a socket file descriptor with the host select() loop. Not +/// thread-safe — main loop only. Returns false if fd is invalid or +/// >= FD_SETSIZE. +bool wake_register_fd(int fd); + +/// Unregister a socket file descriptor. Not thread-safe — main loop only. +void wake_unregister_fd(int fd); + +/// One-time setup of the loopback wake socket. Called from Application::setup(). +void wake_setup(); + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +/// Host wakeable_delay uses select() over the registered fds — defined in wake_host.cpp. +void wakeable_delay(uint32_t ms); + +// File-scope state owned by wake_host.cpp. Accessed inline by +// wake_drain_notifications() and wake_fd_ready() so the hot path stays in the header. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern int g_wake_socket_fd; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern fd_set g_read_fds; +} // namespace internal + +inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } + +// Small buffer for draining wake notification bytes (1 byte sent per wake). +// Sized to drain multiple notifications per recvfrom() without wasting stack. +inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; + +inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { + // Called from main loop to drain any pending wake notifications. + // Must check wake_fd_ready() to avoid blocking on empty socket. + if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { + char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads. Multiple wake events + // may have triggered multiple writes, so drain until EWOULDBLOCK. We control + // both ends of this loopback socket (always 1 byte per wake), so no error + // checking — any error indicates catastrophic system failure. + while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + } + } +} + +} // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp new file mode 100644 index 0000000000..b18248dbd2 --- /dev/null +++ b/esphome/core/wake/wake_rp2040.cpp @@ -0,0 +1,58 @@ +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +#include +#include + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// RP2040 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_delay_expired = false; + +static int64_t alarm_callback_(alarm_id_t id, void *user_data) { + (void) id; + (void) user_data; + s_delay_expired = true; + __sev(); + return 0; +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + s_delay_expired = false; + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); + if (alarm <= 0) { + delay(ms); + return; + } + while (!g_main_loop_woke && !s_delay_expired) { + __wfe(); + } + if (!s_delay_expired) + cancel_alarm(alarm); + g_main_loop_woke = false; +} +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/wake/wake_rp2040.h b/esphome/core/wake/wake_rp2040.h new file mode 100644 index 0000000000..ea1242f535 --- /dev/null +++ b/esphome/core/wake/wake_rp2040.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +inline void wake_loop_any_context() { + // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed + // to see it on its next gate check. + wake_request_set(); + g_main_loop_woke = true; + __sev(); +} + +inline void wake_loop_threadsafe() { wake_loop_any_context(); } + +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp. +namespace internal { +void wakeable_delay(uint32_t ms); +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/loader.py b/esphome/loader.py index 68664aaa26..9390b8094b 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -31,8 +31,9 @@ class FileResource: class ComponentManifest: - def __init__(self, module: ModuleType): + def __init__(self, module: ModuleType, recursive_sources: bool = False): self.module = module + self.recursive_sources = recursive_sources @property def package(self) -> str: @@ -108,8 +109,10 @@ class ComponentManifest: def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. - This will return all cpp source files that are located in the same folder as the - loaded .py file (does not look through subdirectories) + By default only files directly in the package directory are returned. Manifests + constructed with ``recursive_sources=True`` also descend into non-subpackage + subdirectories (subdirectories without an ``__init__.py``), so core code can + live under ``esphome/core//`` without every component paying the cost. """ ret: list[FileResource] = [] @@ -121,23 +124,30 @@ class ComponentManifest: set(filter_source_files_func()) if filter_source_files_func else set() ) - # Process all resources - for resource in ( - r.name - for r in importlib.resources.files(self.package).iterdir() - if r.is_file() - ): - if Path(resource).suffix not in SOURCE_FILE_EXTENSIONS: - continue - if not importlib.resources.files(self.package).joinpath(resource).is_file(): - # Not a resource = this is a directory (yeah this is confusing) - continue + root = importlib.resources.files(self.package) - # Skip excluded files - if resource in excluded_files: - continue + for child in root.iterdir(): + name = child.name + if child.is_file(): + if Path(name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + if name in excluded_files: + continue + ret.append(FileResource(self.package, name)) + elif self.recursive_sources and child.is_dir() and name != "__pycache__": + # Skip Python subpackages — they load as their own components. + if child.joinpath("__init__.py").is_file(): + continue + for sub in child.iterdir(): + if not sub.is_file(): + continue + if Path(sub.name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + resource = f"{name}/{sub.name}" + if resource in excluded_files: + continue + ret.append(FileResource(self.package, resource)) - ret.append(FileResource(self.package, resource)) return ret @@ -237,7 +247,9 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None: _COMPONENT_CACHE: dict[str, ComponentManifest] = {} CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() -_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) +_COMPONENT_CACHE["esphome"] = ComponentManifest( + esphome.core.config, recursive_sources=True +) def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None: diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index a42cc5cca7..3fb0eca4a0 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -158,3 +158,167 @@ def test_component_manifest_resources_with_filter_source_files() -> None: # Verify the correct number of resources assert len(resources) == 3 # test.cpp, test.h, common.cpp + + +# --------------------------------------------------------------------------- +# recursive_sources — used only by the core "esphome" manifest so that files +# in esphome/core//*.cpp (e.g. esphome/core/wake/wake_host.cpp) are +# discovered without promoting / to a Python subpackage. +# --------------------------------------------------------------------------- + + +def _mock_file(filename: str) -> MagicMock: + m = MagicMock() + m.name = filename + m.is_file.return_value = True + m.is_dir.return_value = False + return m + + +def _mock_dir(dirname: str, children: list, has_init: bool = False) -> MagicMock: + """Mock a directory entry with an iterdir() and joinpath('__init__.py').""" + d = MagicMock() + d.name = dirname + d.is_file.return_value = False + d.is_dir.return_value = True + d.iterdir.return_value = children + init_marker = MagicMock() + init_marker.is_file.return_value = has_init + d.joinpath.return_value = init_marker + return d + + +def test_component_manifest_resources_non_recursive_skips_subdirs() -> None: + """Default (recursive_sources=False) does not descend into subdirectories.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.test_component" + # No FILTER_SOURCE_FILES. + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module) # recursive_sources defaults to False + + top_level = [ + _mock_file("top.cpp"), + _mock_dir("subdir", [_mock_file("nested.cpp")]), + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["top.cpp"] + + +def test_component_manifest_resources_recursive_walks_non_subpackage_subdirs() -> None: + """With recursive_sources=True, a subdir without __init__.py is walked.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), + _mock_file("wake_host.h"), + _mock_file("README.md"), # wrong suffix, excluded + ], + has_init=False, + ) + top_level = [ + _mock_file("wake.h"), + wake_dir, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = sorted(r.resource for r in manifest.resources) + + assert names == ["wake.h", "wake/wake_host.cpp", "wake/wake_host.h"] + + +def test_component_manifest_resources_recursive_skips_subpackages() -> None: + """Subdirectories that ARE Python subpackages (contain __init__.py) are + skipped even with recursive_sources=True — those load as their own + ComponentManifest and would otherwise be double-counted.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.haier" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + button_pkg = _mock_dir( + "button", + [_mock_file("self_cleaning.cpp")], + has_init=True, # Python subpackage — must be skipped. + ) + top_level = [ + _mock_file("haier.cpp"), + button_pkg, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["haier.cpp"] + + +def test_component_manifest_resources_recursive_skips_pycache() -> None: + """__pycache__ inside a recursive walk must never be descended into.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + # __pycache__ is_dir=True but must be skipped without checking __init__.py + # or calling iterdir (would yield compiled artifacts). + pycache = _mock_dir("__pycache__", [_mock_file("wake.cpython-314.pyc")]) + top_level = [ + _mock_file("wake.h"), + pycache, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake.h"] + + +def test_component_manifest_resources_recursive_filter_source_files_supports_subpaths() -> ( + None +): + """FILTER_SOURCE_FILES entries using '/'-joined subpaths exclude files + inside a recursively-walked subdir.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + mock_module.FILTER_SOURCE_FILES = lambda: ["wake/wake_host.cpp"] + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), # excluded + _mock_file("wake_freertos.cpp"), # kept + ], + ) + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = [wake_dir] + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake/wake_freertos.cpp"] From 0a4d9b430f1b6c98522fce0d1eb23edd3eeaf857 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 09:05:12 -0500 Subject: [PATCH 0783/2214] [ci] Add import-time regression check for esphome.__main__ (#15954) --- .github/workflows/ci.yml | 30 +++ requirements_test.txt | 3 + script/check_import_time.py | 241 +++++++++++++++++++++++++ script/determine-jobs.py | 38 ++++ script/import_time_budget.json | 5 + tests/script/test_check_import_time.py | 191 ++++++++++++++++++++ tests/script/test_determine_jobs.py | 61 +++++++ 7 files changed, 569 insertions(+) create mode 100755 script/check_import_time.py create mode 100644 script/import_time_budget.json create mode 100644 tests/script/test_check_import_time.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20c349ac00..d60bd6edc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,34 @@ jobs: script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check + import-time: + name: Check import esphome.__main__ time + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.import-time == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Check import time against budget and write waterfall HAR + run: | + . venv/bin/activate + script/check_import_time.py --check --har importtime.har + - name: Upload waterfall HAR + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: import-time-waterfall + path: importtime.har + if-no-files-found: ignore + retention-days: 14 + pytest: name: Run pytest strategy: @@ -176,6 +204,7 @@ jobs: clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} python-linters: ${{ steps.determine.outputs.python-linters }} + import-time: ${{ steps.determine.outputs.import-time }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -219,6 +248,7 @@ jobs: echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT + echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT diff --git a/requirements_test.txt b/requirements_test.txt index b35025fa04..568d79d676 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,3 +12,6 @@ pytest-asyncio==1.3.0 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 + +# Used by the import-time regression check (.github/workflows/ci.yml → import-time job) +importtime-waterfall==1.0.0 diff --git a/script/check_import_time.py b/script/check_import_time.py new file mode 100755 index 0000000000..0d5362c968 --- /dev/null +++ b/script/check_import_time.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Regression check for `import esphome.__main__` cost. + +Runs `python -m importtime_waterfall --har esphome.__main__` (which invokes +`-X importtime` in fresh subprocesses, best-of-N) and compares the root +cumulative import time against a checked-in budget +(`script/import_time_budget.json`). + +The CLI pays this cost on every invocation before the requested command even +runs, so a regression here hurts every user. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys +from typing import Any, TextIO + +SCRIPT_DIR = Path(__file__).parent +BUDGET_PATH = SCRIPT_DIR / "import_time_budget.json" + +TARGET_MODULE = "esphome.__main__" +DEFAULT_MARGIN_PCT = 15 +OFFENDERS_TOP_N = 15 + + +def run_waterfall(module: str) -> str: + """Run `importtime_waterfall --har ` and return the HAR JSON text. + + `importtime_waterfall` itself runs the target in 6 fresh subprocesses + under `-X importtime` and emits the HAR of the fastest run. + """ + result = subprocess.run( + [sys.executable, "-m", "importtime_waterfall", "--har", module], + check=True, + stdout=subprocess.PIPE, + text=True, + ) + return result.stdout + + +def measure(module: str, har_path: Path | None = None) -> dict[str, Any]: + """Return the parsed HAR for importing `module`. + + When `har_path` is given, also write the raw HAR JSON to that path so + callers can combine `--check` with `--har` without measuring twice. + """ + har_text = run_waterfall(module) + if har_path is not None: + har_path.write_text(har_text) + return json.loads(har_text) + + +def _entries(har: dict[str, Any]) -> list[dict[str, Any]]: + return har["log"]["entries"] + + +def root_cumulative_us(har: dict[str, Any], module: str) -> int: + """Return the cumulative import time (µs) of `module` from a HAR. + + The HAR `time` field is authored by importtime_waterfall using µs values + fed through `timedelta(milliseconds=...)`, so the number read back is the + original self/cumulative time in microseconds (labelled "ms" in HAR). + """ + for entry in _entries(har): + if entry["request"]["url"] == module: + return entry["time"] + raise RuntimeError( + f"No HAR entry for {module!r}. Is it importable with " + f"`python -c 'import {module}'`?" + ) + + +def top_offenders(har: dict[str, Any], n: int) -> list[tuple[str, int, int]]: + """Return up to `n` (name, self_us, cumulative_us), ranked by self_us desc. + + A module imported from multiple places is counted once (first entry wins, + matching importtime's own de-duplication). + """ + seen: dict[str, tuple[int, int]] = {} + for entry in _entries(har): + name = entry["request"]["url"] + if name in seen: + continue + self_us = entry["timings"]["receive"] + cumulative_us = entry["time"] + seen[name] = (self_us, cumulative_us) + ranked = sorted( + ((name, s, c) for name, (s, c) in seen.items()), + key=lambda row: row[1], + reverse=True, + ) + return ranked[:n] + + +def read_budget() -> dict[str, Any]: + if not BUDGET_PATH.exists(): + return {} + with BUDGET_PATH.open() as f: + return json.load(f) + + +def write_budget(cumulative_us: int, margin_pct: int) -> None: + payload = { + "target_module": TARGET_MODULE, + "margin_pct": margin_pct, + "cumulative_us": cumulative_us, + } + with BUDGET_PATH.open("w") as f: + json.dump(payload, f, indent=2) + f.write("\n") + + +def _format_us(us: int) -> str: + if us >= 1000: + return f"{us / 1000:.1f}ms" + return f"{us}us" + + +def _print_offenders_table( + offenders: list[tuple[str, int, int]], stream: TextIO +) -> None: + name_w = max(len(name) for name, _, _ in offenders) + print(f"\n{'module':<{name_w}} {'self':>10} {'cumulative':>12}", file=stream) + print(f"{'-' * name_w} {'-' * 10} {'-' * 12}", file=stream) + for name, self_us, cum_us in offenders: + print( + f"{name:<{name_w}} {_format_us(self_us):>10} {_format_us(cum_us):>12}", + file=stream, + ) + + +def cmd_check(args: argparse.Namespace) -> int: + budget = read_budget() + if not budget: + print( + f"ERROR: {BUDGET_PATH.name} missing. Run with --update first.", + file=sys.stderr, + ) + return 2 + + har = measure(TARGET_MODULE, har_path=Path(args.har) if args.har else None) + measured = root_cumulative_us(har, TARGET_MODULE) + + baseline = budget["cumulative_us"] + margin_pct = budget.get("margin_pct", DEFAULT_MARGIN_PCT) + ceiling = int(baseline * (1 + margin_pct / 100)) + + summary = ( + f"measured {TARGET_MODULE}: {_format_us(measured)} " + f"(budget {_format_us(baseline)} + {margin_pct}% = {_format_us(ceiling)})" + ) + passed = measured <= ceiling + stream = sys.stdout if passed else sys.stderr + + if passed: + print(summary) + else: + print( + f"REGRESSION: `import {TARGET_MODULE}` took {_format_us(measured)}, " + f"exceeding the budget of {_format_us(baseline)} + {margin_pct}% " + f"({_format_us(ceiling)}).", + file=stream, + ) + + print("\nTop import-time offenders (by self time):", file=stream) + _print_offenders_table(top_offenders(har, OFFENDERS_TOP_N), stream) + + if not passed: + print( + "\nIf this regression is intentional, regenerate the budget with:\n" + " script/check_import_time.py --update\n" + "Otherwise, consider making the new import lazy " + "(import inside the function that uses it).", + file=stream, + ) + return 1 + return 0 + + +def cmd_update(args: argparse.Namespace) -> int: + har = measure(TARGET_MODULE, har_path=Path(args.har) if args.har else None) + measured = root_cumulative_us(har, TARGET_MODULE) + write_budget(measured, args.margin_pct) + print( + f"Wrote {BUDGET_PATH.name}: " + f"{TARGET_MODULE}={_format_us(measured)} " + f"(margin {args.margin_pct}%)" + ) + return 0 + + +def cmd_har_only(args: argparse.Namespace) -> int: + Path(args.har).write_text(run_waterfall(TARGET_MODULE)) + print(f"Wrote waterfall HAR to {args.har}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--margin-pct", + type=int, + default=DEFAULT_MARGIN_PCT, + help=(f"Margin over baseline for --update (default: {DEFAULT_MARGIN_PCT}%%)."), + ) + parser.add_argument( + "--har", + metavar="PATH", + help=( + "Write a waterfall HAR file at PATH. Can be combined with " + "--check or --update to reuse that run's measurement (avoids " + "measuring twice)." + ), + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--check", action="store_true", help="Fail if measured time exceeds budget." + ) + mode.add_argument( + "--update", + action="store_true", + help="Rewrite the budget from a fresh measurement.", + ) + args = parser.parse_args() + + if args.check: + return cmd_check(args) + if args.update: + return cmd_update(args) + if args.har: + return cmd_har_only(args) + parser.error("Specify at least one of --check, --update, or --har PATH.") + return 2 # unreachable; parser.error exits. Here to satisfy ruff RET503. + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index f036447542..6fd7ab297c 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -349,6 +349,42 @@ def should_run_python_linters(branch: str | None = None) -> bool: return _any_changed_file_endswith(branch, PYTHON_FILE_EXTENSIONS) +# Files outside esphome/**/*.py whose changes can affect `import esphome.__main__` +# cost. requirements.txt / pyproject.toml change the dependency graph pulled in +# by top-level imports; check_import_time.py itself changes the check's behavior. +IMPORT_TIME_TRIGGER_FILES = frozenset( + { + "requirements.txt", + "requirements_dev.txt", + "requirements_test.txt", + "pyproject.toml", + "script/check_import_time.py", + "script/import_time_budget.json", + } +) + + +def should_run_import_time(branch: str | None = None) -> bool: + """Determine if the `import esphome.__main__` time regression check should run. + + Runs when any Python file under `esphome/` changes (those modules are + loaded transitively from `esphome.__main__`), when dependency + declarations change, or when the check script/budget itself changes. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if the import-time check should run, False otherwise. + """ + for file in changed_files(branch): + if file.startswith("esphome/") and file.endswith(PYTHON_FILE_EXTENSIONS): + return True + if file in IMPORT_TIME_TRIGGER_FILES: + return True + return False + + def determine_cpp_unit_tests( branch: str | None = None, ) -> tuple[bool, list[str]]: @@ -780,6 +816,7 @@ def main() -> None: run_clang_tidy = should_run_clang_tidy(args.branch) run_clang_format = should_run_clang_format(args.branch) run_python_linters = should_run_python_linters(args.branch) + run_import_time = should_run_import_time(args.branch) changed_cpp_file_count = count_changed_cpp_files(args.branch) # Get changed components @@ -913,6 +950,7 @@ def main() -> None: "clang_tidy_mode": clang_tidy_mode, "clang_format": run_clang_format, "python_linters": run_python_linters, + "import_time": run_import_time, "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/script/import_time_budget.json b/script/import_time_budget.json new file mode 100644 index 0000000000..1e656dc977 --- /dev/null +++ b/script/import_time_budget.json @@ -0,0 +1,5 @@ +{ + "target_module": "esphome.__main__", + "margin_pct": 15, + "cumulative_us": 123000 +} diff --git a/tests/script/test_check_import_time.py b/tests/script/test_check_import_time.py new file mode 100644 index 0000000000..223c58002c --- /dev/null +++ b/tests/script/test_check_import_time.py @@ -0,0 +1,191 @@ +"""Unit tests for script/check_import_time.py.""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +# Load the script-under-test as `check_import_time` (it's a hyphenated path +# inside `script/` that mirrors the existing `determine_jobs` pattern). +script_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "script") +) +sys.path.insert(0, script_dir) +spec = importlib.util.spec_from_file_location( + "check_import_time", os.path.join(script_dir, "check_import_time.py") +) +check_import_time = importlib.util.module_from_spec(spec) +spec.loader.exec_module(check_import_time) + + +def _entry(name: str, self_us: int, cumulative_us: int) -> dict: + """Build a minimal HAR entry matching `importtime_waterfall --har`.""" + return { + "request": {"url": name}, + "time": cumulative_us, + "timings": {"receive": self_us, "wait": cumulative_us - self_us}, + } + + +def _har(*entries: dict) -> dict: + return {"log": {"entries": list(entries)}} + + +def test_root_cumulative_us_returns_time_for_root_module() -> None: + har = _har( + _entry("dep_a", 500, 500), + _entry("dep_b", 300, 300), + _entry("esphome.__main__", 100, 1000), + ) + assert check_import_time.root_cumulative_us(har, "esphome.__main__") == 1000 + + +def test_root_cumulative_us_missing_module_raises() -> None: + har = _har(_entry("something.else", 100, 100)) + with pytest.raises(RuntimeError, match="No HAR entry for 'esphome.__main__'"): + check_import_time.root_cumulative_us(har, "esphome.__main__") + + +def test_top_offenders_ranks_by_self_time_descending() -> None: + har = _har( + _entry("small", 100, 100), + _entry("big", 5000, 5000), + _entry("medium", 2000, 2500), + ) + result = check_import_time.top_offenders(har, n=10) + assert [name for name, _, _ in result] == ["big", "medium", "small"] + assert result[0] == ("big", 5000, 5000) + + +def test_top_offenders_respects_n_limit() -> None: + har = _har(*[_entry(f"m{i}", i * 100, i * 100) for i in range(1, 20)]) + assert len(check_import_time.top_offenders(har, n=5)) == 5 + + +def test_top_offenders_dedupes_repeat_names_keeping_first() -> None: + har = _har( + _entry("pkg", 5000, 5000), + _entry("pkg", 100, 100), # reimport later in trace + _entry("other", 1000, 1000), + ) + result = check_import_time.top_offenders(har, n=10) + assert [name for name, _, _ in result] == ["pkg", "other"] + # First occurrence wins + assert ("pkg", 5000, 5000) in result + + +def test_format_us_switches_to_ms_at_threshold() -> None: + assert check_import_time._format_us(500) == "500us" + assert check_import_time._format_us(999) == "999us" + assert check_import_time._format_us(1000) == "1.0ms" + assert check_import_time._format_us(12345) == "12.3ms" + + +def test_read_write_budget_roundtrip(tmp_path: Path) -> None: + budget_path = tmp_path / "budget.json" + with patch.object(check_import_time, "BUDGET_PATH", budget_path): + assert check_import_time.read_budget() == {} + check_import_time.write_budget(cumulative_us=12345, margin_pct=20) + loaded = check_import_time.read_budget() + assert loaded["cumulative_us"] == 12345 + assert loaded["margin_pct"] == 20 + assert loaded["target_module"] == check_import_time.TARGET_MODULE + + +def test_cmd_check_passes_when_measured_within_ceiling( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + budget_path = tmp_path / "budget.json" + budget_path.write_text( + json.dumps( + { + "target_module": check_import_time.TARGET_MODULE, + "margin_pct": 15, + "cumulative_us": 100000, # 100ms + } + ) + ) + # Measured 90ms: inside 100ms + 15% = 115ms ceiling + har = _har(_entry(check_import_time.TARGET_MODULE, 1000, 90000)) + args = type("A", (), {"har": None})() + with ( + patch.object(check_import_time, "BUDGET_PATH", budget_path), + patch.object(check_import_time, "measure", return_value=har), + ): + rc = check_import_time.cmd_check(args) + assert rc == 0 + out = capsys.readouterr().out + assert "measured esphome.__main__:" in out + assert "budget 100.0ms" in out + + +def test_cmd_check_fails_when_measured_exceeds_ceiling( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + budget_path = tmp_path / "budget.json" + budget_path.write_text( + json.dumps( + { + "target_module": check_import_time.TARGET_MODULE, + "margin_pct": 15, + "cumulative_us": 100000, + } + ) + ) + # Measured 120ms: over 100ms + 15% = 115ms ceiling + har = _har( + _entry("offender_a", 10000, 10000), + _entry(check_import_time.TARGET_MODULE, 1000, 120000), + ) + args = type("A", (), {"har": None})() + with ( + patch.object(check_import_time, "BUDGET_PATH", budget_path), + patch.object(check_import_time, "measure", return_value=har), + ): + rc = check_import_time.cmd_check(args) + assert rc == 1 + err = capsys.readouterr().err + assert "REGRESSION" in err + assert "120.0ms" in err + assert "offender_a" in err # top offender table + + +def test_cmd_check_returns_2_when_budget_missing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + budget_path = tmp_path / "nonexistent.json" + args = type("A", (), {"har": None})() + with patch.object(check_import_time, "BUDGET_PATH", budget_path): + rc = check_import_time.cmd_check(args) + assert rc == 2 + assert "missing" in capsys.readouterr().err + + +def test_cmd_check_writes_har_when_path_given(tmp_path: Path) -> None: + budget_path = tmp_path / "budget.json" + budget_path.write_text( + json.dumps( + { + "target_module": check_import_time.TARGET_MODULE, + "margin_pct": 15, + "cumulative_us": 100000, + } + ) + ) + har_path = tmp_path / "out.har" + har_text = json.dumps(_har(_entry(check_import_time.TARGET_MODULE, 1000, 80000))) + args = type("A", (), {"har": str(har_path)})() + with ( + patch.object(check_import_time, "BUDGET_PATH", budget_path), + patch.object(check_import_time, "run_waterfall", return_value=har_text), + ): + rc = check_import_time.cmd_check(args) + assert rc == 0 + assert har_path.exists() + assert json.loads(har_path.read_text()) == json.loads(har_text) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 2c726734fe..44c110b689 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -56,6 +56,13 @@ def mock_should_run_python_linters() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_should_run_import_time() -> Generator[Mock, None, None]: + """Mock should_run_import_time from determine_jobs.""" + with patch.object(determine_jobs, "should_run_import_time") as mock: + yield mock + + @pytest.fixture def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]: """Mock determine_cpp_unit_tests from helpers.""" @@ -91,6 +98,7 @@ def test_main_all_tests_should_run( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -104,6 +112,7 @@ def test_main_all_tests_should_run( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True + mock_should_run_import_time.return_value = True mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -158,6 +167,7 @@ def test_main_all_tests_should_run( assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is True assert output["python_linters"] is True + assert output["import_time"] is True assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -189,6 +199,7 @@ def test_main_no_tests_should_run( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -202,6 +213,7 @@ def test_main_no_tests_should_run( mock_should_run_clang_tidy.return_value = False mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -241,6 +253,7 @@ def test_main_no_tests_should_run( assert output["clang_tidy_mode"] == "disabled" assert output["clang_format"] is False assert output["python_linters"] is False + assert output["import_time"] is False assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -261,6 +274,7 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -274,6 +288,7 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = True + mock_should_run_import_time.return_value = True mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -310,6 +325,7 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy.assert_called_once_with("main") mock_should_run_clang_format.assert_called_once_with("main") mock_should_run_python_linters.assert_called_once_with("main") + mock_should_run_import_time.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -322,6 +338,7 @@ def test_main_with_branch_argument( assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is False assert output["python_linters"] is True + assert output["import_time"] is True assert output["changed_components"] == ["mqtt"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -597,6 +614,50 @@ def test_should_run_python_linters_with_branch() -> None: mock_changed.assert_called_once_with("release") +@pytest.mark.parametrize( + ("changed_files", "expected_result"), + [ + # esphome Python files trigger the check + (["esphome/__main__.py"], True), + (["esphome/components/wifi/__init__.py"], True), + (["esphome/core/config.py"], True), + (["esphome/types.pyi"], True), + # Dependency declarations and the check's own files trigger + (["requirements.txt"], True), + (["requirements_dev.txt"], True), + (["requirements_test.txt"], True), + (["pyproject.toml"], True), + (["script/check_import_time.py"], True), + (["script/import_time_budget.json"], True), + # Mixed: any triggering file is enough + (["docs/README.md", "esphome/config.py"], True), + # Python files outside esphome/ don't trigger + (["script/some_other_script.py"], False), + (["tests/script/test_determine_jobs.py"], False), + # Non-Python changes don't trigger + (["esphome/core/component.cpp"], False), + (["tests/components/wifi/test.esp32-idf.yaml"], False), + (["README.md"], False), + ([], False), + ], +) +def test_should_run_import_time( + changed_files: list[str], expected_result: bool +) -> None: + """Test should_run_import_time function.""" + with patch.object(determine_jobs, "changed_files", return_value=changed_files): + result = determine_jobs.should_run_import_time() + assert result == expected_result + + +def test_should_run_import_time_with_branch() -> None: + """Test should_run_import_time with branch argument.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + determine_jobs.should_run_import_time("release") + mock_changed.assert_called_once_with("release") + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ From 52e8c50f45a035362d62a1d47e967240ec6b65f6 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 28 Apr 2026 08:21:25 -0700 Subject: [PATCH 0784/2214] [modbus] Split modbus_server from modbus_controller (#15509) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/modbus_controller/__init__.py | 91 +-------- esphome/components/modbus_controller/const.py | 1 + .../modbus_controller/modbus_controller.cpp | 176 +--------------- .../modbus_controller/modbus_controller.h | 93 --------- esphome/components/modbus_server/__init__.py | 124 +++++++++++ esphome/components/modbus_server/const.py | 7 + .../modbus_server/modbus_server.cpp | 192 ++++++++++++++++++ .../components/modbus_server/modbus_server.h | 119 +++++++++++ .../components/modbus_controller/common.yaml | 44 +--- tests/components/modbus_server/common.yaml | 41 ++++ .../modbus_server/test.esp32-idf.yaml | 4 + .../modbus_server/test.esp8266-ard.yaml | 4 + .../modbus_server/test.rp2040-ard.yaml | 4 + .../fixtures/uart_mock_modbus_server.yaml | 4 +- .../uart_mock_modbus_server_controller.yaml | 5 +- ...ock_modbus_server_controller_multiple.yaml | 9 +- ...t_mock_modbus_server_controller_write.yaml | 5 +- 18 files changed, 523 insertions(+), 401 deletions(-) create mode 100644 esphome/components/modbus_server/__init__.py create mode 100644 esphome/components/modbus_server/const.py create mode 100644 esphome/components/modbus_server/modbus_server.cpp create mode 100644 esphome/components/modbus_server/modbus_server.h create mode 100644 tests/components/modbus_server/common.yaml create mode 100644 tests/components/modbus_server/test.esp32-idf.yaml create mode 100644 tests/components/modbus_server/test.esp8266-ard.yaml create mode 100644 tests/components/modbus_server/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 20c19a7dfa..471def542b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -347,6 +347,7 @@ esphome/components/modbus_controller/select/* @martgras @stegm esphome/components/modbus_controller/sensor/* @martgras esphome/components/modbus_controller/switch/* @martgras esphome/components/modbus_controller/text_sensor/* @martgras +esphome/components/modbus_server/* @exciton esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan esphome/components/mopeka_pro_check/* @spbrogan esphome/components/mopeka_std_check/* @Fabian-Schmidt diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 2af58a96be..67e5757397 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -3,11 +3,8 @@ import binascii from esphome import automation import esphome.codegen as cg from esphome.components import modbus -from esphome.components.const import CONF_ENABLED from esphome.components.modbus.helpers import ( - CPP_TYPE_REGISTER_MAP, MODBUS_REGISTER_TYPE, - SENSOR_VALUE_TYPE, TYPE_REGISTER_MAP, ModbusRegisterType, ) @@ -29,11 +26,10 @@ from .const import ( CONF_ON_OFFLINE, CONF_ON_ONLINE, CONF_REGISTER_COUNT, - CONF_REGISTER_LAST_ADDRESS, CONF_REGISTER_TYPE, - CONF_REGISTER_VALUE, CONF_RESPONSE_SIZE, CONF_SERVER_COURTESY_RESPONSE, + CONF_SERVER_REGISTERS, CONF_SKIP_UPDATES, CONF_VALUE_TYPE, ) @@ -42,9 +38,6 @@ CODEOWNERS = ["@martgras"] AUTO_LOAD = ["modbus"] -CONF_READ_LAMBDA = "read_lambda" -CONF_WRITE_LAMBDA = "write_lambda" -CONF_SERVER_REGISTERS = "server_registers" MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") @@ -53,30 +46,9 @@ ModbusController = modbus_controller_ns.class_( ) SensorItem = modbus_controller_ns.struct("SensorItem") -ServerCourtesyResponse = modbus_controller_ns.struct("ServerCourtesyResponse") -ServerRegister = modbus_controller_ns.struct("ServerRegister") _LOGGER = logging.getLogger(__name__) -SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( - { - cv.Optional(CONF_ENABLED, default=False): cv.boolean, - cv.Optional(CONF_REGISTER_LAST_ADDRESS, default=0xFFFF): cv.hex_uint16_t, - cv.Optional(CONF_REGISTER_VALUE, default=0): cv.hex_uint16_t, - } -) - -ModbusServerRegisterSchema = cv.Schema( - { - cv.GenerateID(): cv.declare_id(ServerRegister), - cv.Required(CONF_ADDRESS): cv.positive_int, - cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), - cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, - cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, - } -) - - CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -85,12 +57,16 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_COMMAND_THROTTLE, default="0ms" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_SERVER_COURTESY_RESPONSE): SERVER_COURTESY_RESPONSE_SCHEMA, + cv.Optional(CONF_SERVER_COURTESY_RESPONSE): cv.invalid( + "This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/" + ), cv.Optional(CONF_MAX_CMD_RETRIES, default=4): cv.positive_int, cv.Optional(CONF_OFFLINE_SKIP_UPDATES, default=0): cv.positive_int, cv.Optional( CONF_SERVER_REGISTERS, - ): cv.ensure_list(ModbusServerRegisterSchema), + ): cv.invalid( + "This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/" + ), cv.Optional(CONF_ON_COMMAND_SENT): automation.validate_automation({}), cv.Optional(CONF_ON_ONLINE): automation.validate_automation({}), cv.Optional(CONF_ON_OFFLINE): automation.validate_automation({}), @@ -142,11 +118,9 @@ def validate_modbus_register(config): def _final_validate(config): - if CONF_SERVER_COURTESY_RESPONSE in config or CONF_SERVER_REGISTERS in config: - return modbus.final_validate_modbus_device("modbus_controller", role="server")( - config - ) - return config + return modbus.final_validate_modbus_device("modbus_controller", role="client")( + config + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -228,53 +202,8 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_allow_duplicate_commands(config[CONF_ALLOW_DUPLICATE_COMMANDS])) cg.add(var.set_command_throttle(config[CONF_COMMAND_THROTTLE])) - if server_courtesy_response := config.get(CONF_SERVER_COURTESY_RESPONSE): - cg.add( - var.set_server_courtesy_response( - cg.StructInitializer( - ServerCourtesyResponse, - ("enabled", server_courtesy_response[CONF_ENABLED]), - ( - "register_last_address", - server_courtesy_response[CONF_REGISTER_LAST_ADDRESS], - ), - ("register_value", server_courtesy_response[CONF_REGISTER_VALUE]), - ) - ) - ) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) - if CONF_SERVER_REGISTERS in config: - for server_register in config[CONF_SERVER_REGISTERS]: - server_register_var = cg.new_Pvariable( - server_register[CONF_ID], - server_register[CONF_ADDRESS], - server_register[CONF_VALUE_TYPE], - TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]], - ) - cpp_type = CPP_TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]] - cg.add( - server_register_var.set_read_lambda( - cg.TemplateArguments(cpp_type), - await cg.process_lambda( - server_register[CONF_READ_LAMBDA], - [(cg.uint16, "address")], - return_type=cpp_type, - ), - ) - ) - if CONF_WRITE_LAMBDA in server_register: - cg.add( - server_register_var.set_write_lambda( - cg.TemplateArguments(cpp_type), - await cg.process_lambda( - server_register[CONF_WRITE_LAMBDA], - parameters=[(cg.uint16, "address"), (cpp_type, "x")], - return_type=cg.bool_, - ), - ) - ) - cg.add(var.add_server_register(server_register_var)) await register_modbus_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/modbus_controller/const.py b/esphome/components/modbus_controller/const.py index c689d84576..0149a3cc49 100644 --- a/esphome/components/modbus_controller/const.py +++ b/esphome/components/modbus_controller/const.py @@ -18,6 +18,7 @@ CONF_REGISTER_TYPE = "register_type" CONF_REGISTER_VALUE = "register_value" CONF_RESPONSE_SIZE = "response_size" CONF_SERVER_COURTESY_RESPONSE = "server_courtesy_response" +CONF_SERVER_REGISTERS = "server_registers" CONF_SKIP_UPDATES = "skip_updates" CONF_USE_WRITE_MULTIPLE = "use_write_multiple" CONF_VALUE_TYPE = "value_type" diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 5c3b39c954..dabed7136b 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -112,167 +112,6 @@ void ModbusController::on_modbus_error(uint8_t function_code, uint8_t exception_ } } -void ModbusController::on_modbus_read_registers(uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers) { - ESP_LOGD(TAG, - "Received read holding/input registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); - - if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - - std::vector sixteen_bit_response; - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { - bool found = false; - for (auto *server_register : this->server_registers_) { - if (server_register->address == current_address) { - if (!server_register->read_lambda) { - break; - } - int64_t value = server_register->read_lambda(); - ESP_LOGD(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", - server_register->address, static_cast(server_register->value_type), - server_register->register_count, server_register->format_value(value).c_str()); - - std::vector payload; - payload.reserve(server_register->register_count * 2); - modbus::helpers::number_to_payload(payload, value, server_register->value_type); - sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); - current_address += server_register->register_count; - found = true; - break; - } - } - - if (!found) { - if (this->server_courtesy_response_.enabled && - (current_address <= this->server_courtesy_response_.register_last_address)) { - ESP_LOGD(TAG, - "Could not match any register to address 0x%02X, but default allowed. " - "Returning default value: %d.", - current_address, this->server_courtesy_response_.register_value); - sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); - current_address += 1; // Just increment by 1, as the default response is a single register - } else { - ESP_LOGW(TAG, - "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", - current_address); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - } - } - - std::vector response; - for (auto v : sixteen_bit_response) { - auto decoded_value = decode_value(v); - response.push_back(decoded_value[0]); - response.push_back(decoded_value[1]); - } - - this->send(function_code, start_address, number_of_registers, response.size(), response.data()); -} - -void ModbusController::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { - uint16_t number_of_registers; - uint16_t payload_offset; - - if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - if (data.size() < 5) { - ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); - if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - uint16_t payload_size = data[4]; - if (payload_size != number_of_registers * 2) { - ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", - payload_size, number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - if (data.size() < 5 + payload_size) { - ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(), - 5 + payload_size); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - payload_offset = 5; - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - if (data.size() < 4) { - ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - number_of_registers = 1; - payload_offset = 2; - } else { - ESP_LOGW(TAG, "Invalid function code 0x%X. Sending exception response.", function_code); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - return; - } - - uint16_t start_address = uint16_t(data[1]) | (uint16_t(data[0]) << 8); - ESP_LOGD(TAG, - "Received write holding registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); - - auto for_each_register = [this, start_address, number_of_registers, payload_offset]( - const std::function &callback) -> bool { - uint16_t offset = payload_offset; - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { - bool ok = false; - for (auto *server_register : this->server_registers_) { - if (server_register->address == current_address) { - ok = callback(server_register, offset); - current_address += server_register->register_count; - offset += server_register->register_count * sizeof(uint16_t); - break; - } - } - - if (!ok) { - return false; - } - } - return true; - }; - - // check all registers are writable before writing to any of them: - if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { - return server_register->write_lambda != nullptr; - })) { - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - return; - } - - // Actually write to the registers: - if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); - return server_register->write_lambda(number); - })) { - this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); - return; - } - - std::vector response; - response.reserve(6); - response.push_back(this->address_); - response.push_back(function_code); - response.insert(response.end(), data.begin(), data.begin() + 4); - this->send_raw(response); -} - SensorSet ModbusController::find_sensors_(ModbusRegisterType register_type, uint16_t start_address) const { auto reg_it = std::find_if( std::begin(this->register_ranges_), std::end(this->register_ranges_), @@ -472,14 +311,8 @@ void ModbusController::dump_config() { "ModbusController:\n" " Address: 0x%02X\n" " Max Command Retries: %d\n" - " Offline Skip Updates: %d\n" - " Server Courtesy Response:\n" - " Enabled: %s\n" - " Register Last Address: 0x%02X\n" - " Register Value: %d", - this->address_, this->max_cmd_retries_, this->offline_skip_updates_, - this->server_courtesy_response_.enabled ? "true" : "false", - this->server_courtesy_response_.register_last_address, this->server_courtesy_response_.register_value); + " Offline Skip Updates: %d\n", + this->address_, this->max_cmd_retries_, this->offline_skip_updates_); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGCONFIG(TAG, "sensormap"); @@ -493,11 +326,6 @@ void ModbusController::dump_config() { ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), it.start_address, it.register_count, it.skip_updates); } - ESP_LOGCONFIG(TAG, "server registers"); - for (auto &r : this->server_registers_) { - ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, - static_cast(r->value_type), r->register_count); - } #endif } diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 6c6c748b73..40139f055b 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -120,82 +120,6 @@ class SensorItem { bool force_new_range{false}; }; -struct ServerCourtesyResponse { - bool enabled{false}; - uint16_t register_last_address{0xFFFF}; - uint16_t register_value{0}; -}; - -class ServerRegister { - using ReadLambda = std::function; - using WriteLambda = std::function; - - public: - ServerRegister(uint16_t address, SensorValueType value_type, uint8_t register_count) { - this->address = address; - this->value_type = value_type; - 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); - if constexpr (std::is_same_v) { - return bit_cast(user_value); - } else { - return static_cast(user_value); - } - }; - } - - template - void set_write_lambda(const std::function &&user_write_lambda) { - this->write_lambda = [this, user_write_lambda](int64_t number) { - if constexpr (std::is_same_v) { - float float_value = bit_cast(static_cast(number)); - return user_write_lambda(this->address, float_value); - } - return user_write_lambda(this->address, static_cast(number)); - }; - } - - // Formats a raw value into a string representation based on the value type for debugging - std::string format_value(int64_t value) const { - // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) - // plus null terminator = 43, rounded to 44 for 4-byte alignment - char buf[44]; - switch (this->value_type) { - case SensorValueType::U_WORD: - case SensorValueType::U_DWORD: - case SensorValueType::U_DWORD_R: - case SensorValueType::U_QWORD: - case SensorValueType::U_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(value)); - return buf; - case SensorValueType::S_WORD: - case SensorValueType::S_DWORD: - case SensorValueType::S_DWORD_R: - case SensorValueType::S_QWORD: - case SensorValueType::S_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); - return buf; - case SensorValueType::FP32_R: - case SensorValueType::FP32: - buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast(static_cast(value))); - return buf; - default: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); - return buf; - } - } - - uint16_t address{0}; - SensorValueType value_type{SensorValueType::RAW}; - uint8_t register_count{0}; - ReadLambda read_lambda; - WriteLambda write_lambda; -}; - // ModbusController::create_register_ranges_ tries to optimize register range // for this the sensors must be ordered by register_type, start_address and bitmask class SensorItemsComparator { @@ -367,16 +291,10 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { void queue_command(const ModbusCommandItem &command); /// Registers a sensor with the controller. Called by esphomes code generator void add_sensor_item(SensorItem *item) { sensorset_.insert(item); } - /// Registers a server register with the controller. Called by esphomes code generator - void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } /// called when a modbus response was parsed without errors void on_modbus_data(const std::vector &data) override; /// called when a modbus error response was received void on_modbus_error(uint8_t function_code, uint8_t exception_code) override; - /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors - void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers) final; - /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors - void on_modbus_write_registers(uint8_t function_code, const std::vector &data) final; /// default delegate called by process_modbus_data when a response has retrieved from the incoming queue void on_register_data(ModbusRegisterType register_type, uint16_t start_address, const std::vector &data); /// default delegate called by process_modbus_data when a response for a write response has retrieved from the @@ -413,12 +331,6 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { void set_max_cmd_retries(uint8_t max_cmd_retries) { this->max_cmd_retries_ = max_cmd_retries; } /// get how many times a command will be (re)sent if no response is received uint8_t get_max_cmd_retries() { return this->max_cmd_retries_; } - /// 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; - } - /// Get the server courtesy response object - ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; } protected: /// parse sensormap_ and create range of sequential addresses @@ -435,8 +347,6 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { void dump_sensors_(); /// Collection of all sensors for this component SensorSet sensorset_; - /// Collection of all server registers for this component - std::vector server_registers_{}; /// Continuous range of modbus registers std::vector register_ranges_{}; /// Hold the pending requests to be sent @@ -461,9 +371,6 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { CallbackManager online_callback_{}; /// Server offline callback CallbackManager offline_callback_{}; - /// Server courtesy response - ServerCourtesyResponse server_courtesy_response_{ - .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; }; /** Convert vector response payload to float. diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py new file mode 100644 index 0000000000..5182bc05d1 --- /dev/null +++ b/esphome/components/modbus_server/__init__.py @@ -0,0 +1,124 @@ +import esphome.codegen as cg +from esphome.components import modbus +from esphome.components.const import CONF_ENABLED +from esphome.components.modbus.helpers import ( + CPP_TYPE_REGISTER_MAP, + SENSOR_VALUE_TYPE, + TYPE_REGISTER_MAP, +) +import esphome.config_validation as cv +from esphome.const import CONF_ADDRESS, CONF_ID + +from .const import ( + CONF_COURTESY_RESPONSE, + CONF_READ_LAMBDA, + CONF_REGISTER_LAST_ADDRESS, + CONF_REGISTER_VALUE, + CONF_REGISTERS, + CONF_VALUE_TYPE, + CONF_WRITE_LAMBDA, +) + +CODEOWNERS = ["@exciton"] + +AUTO_LOAD = ["modbus"] + +MULTI_CONF = True + +modbus_server_ns = cg.esphome_ns.namespace("modbus_server") +ModbusServer = modbus_server_ns.class_( + "ModbusServer", cg.Component, modbus.ModbusDevice +) + +ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") +ServerRegister = modbus_server_ns.struct("ServerRegister") + +SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=False): cv.boolean, + cv.Optional(CONF_REGISTER_LAST_ADDRESS, default=0xFFFF): cv.hex_uint16_t, + cv.Optional(CONF_REGISTER_VALUE, default=0): cv.hex_uint16_t, + } +) + +ModbusServerRegisterSchema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ServerRegister), + cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), + cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + } +) + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusServer), + cv.Optional(CONF_COURTESY_RESPONSE): SERVER_COURTESY_RESPONSE_SCHEMA, + cv.Optional( + CONF_REGISTERS, + ): cv.ensure_list(ModbusServerRegisterSchema), + } + ).extend(modbus.modbus_device_schema(0x01)), +) + + +def _final_validate(config): + return modbus.final_validate_modbus_device("modbus_server", role="server")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + if server_courtesy_response := config.get(CONF_COURTESY_RESPONSE): + cg.add( + var.set_server_courtesy_response( + cg.StructInitializer( + ServerCourtesyResponse, + ("enabled", server_courtesy_response[CONF_ENABLED]), + ( + "register_last_address", + server_courtesy_response[CONF_REGISTER_LAST_ADDRESS], + ), + ("register_value", server_courtesy_response[CONF_REGISTER_VALUE]), + ) + ) + ) + if CONF_REGISTERS in config: + for server_register in config[CONF_REGISTERS]: + server_register_var = cg.new_Pvariable( + server_register[CONF_ID], + server_register[CONF_ADDRESS], + server_register[CONF_VALUE_TYPE], + TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]], + ) + cpp_type = CPP_TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]] + cg.add( + server_register_var.set_read_lambda( + cg.TemplateArguments(cpp_type), + await cg.process_lambda( + server_register[CONF_READ_LAMBDA], + [(cg.uint16, "address")], + return_type=cpp_type, + ), + ) + ) + if CONF_WRITE_LAMBDA in server_register: + cg.add( + server_register_var.set_write_lambda( + cg.TemplateArguments(cpp_type), + await cg.process_lambda( + server_register[CONF_WRITE_LAMBDA], + parameters=[(cg.uint16, "address"), (cpp_type, "x")], + return_type=cg.bool_, + ), + ) + ) + cg.add(var.add_server_register(server_register_var)) + cg.add(var.set_address(config[CONF_ADDRESS])) + await cg.register_component(var, config) + return await modbus.register_modbus_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py new file mode 100644 index 0000000000..f83211c207 --- /dev/null +++ b/esphome/components/modbus_server/const.py @@ -0,0 +1,7 @@ +CONF_REGISTER_LAST_ADDRESS = "register_last_address" +CONF_REGISTER_VALUE = "register_value" +CONF_VALUE_TYPE = "value_type" +CONF_COURTESY_RESPONSE = "courtesy_response" +CONF_READ_LAMBDA = "read_lambda" +CONF_WRITE_LAMBDA = "write_lambda" +CONF_REGISTERS = "registers" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp new file mode 100644 index 0000000000..0063da3a1d --- /dev/null +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -0,0 +1,192 @@ +#include "modbus_server.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::modbus_server { +using modbus::ModbusFunctionCode; +using modbus::ModbusExceptionCode; + +static const char *const TAG = "modbus_server"; + +void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers) { + ESP_LOGD(TAG, + "Received read holding/input registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " + "0x%X.", + this->address_, function_code, start_address, number_of_registers); + + if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + return; + } + + std::vector sixteen_bit_response; + for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { + bool found = false; + for (auto *server_register : this->server_registers_) { + if (server_register->address == current_address) { + if (!server_register->read_lambda) { + break; + } + int64_t value = server_register->read_lambda(); + ESP_LOGD(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", + server_register->address, static_cast(server_register->value_type), + server_register->register_count, server_register->format_value(value).c_str()); + + std::vector payload; + payload.reserve(server_register->register_count * 2); + modbus::helpers::number_to_payload(payload, value, server_register->value_type); + sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); + current_address += server_register->register_count; + found = true; + break; + } + } + + if (!found) { + if (this->server_courtesy_response_.enabled && + (current_address <= this->server_courtesy_response_.register_last_address)) { + ESP_LOGD(TAG, + "Could not match any register to address 0x%02X, but default allowed. " + "Returning default value: %d.", + current_address, this->server_courtesy_response_.register_value); + sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); + current_address += 1; // Just increment by 1, as the default response is a single register + } else { + ESP_LOGW(TAG, + "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", + current_address); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + return; + } + } + } + + std::vector response; + for (auto v : sixteen_bit_response) { + auto decoded_value = decode_value(v); + response.push_back(decoded_value[0]); + response.push_back(decoded_value[1]); + } + + this->send(function_code, start_address, number_of_registers, response.size(), response.data()); +} + +void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { + uint16_t number_of_registers; + uint16_t payload_offset; + + if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + if (data.size() < 5) { + ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); + if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { + ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + uint16_t payload_size = data[4]; + if (payload_size != number_of_registers * 2) { + ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", + payload_size, number_of_registers); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (data.size() < 5 + payload_size) { + ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(), + 5 + payload_size); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + payload_offset = 5; + } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + if (data.size() < 4) { + ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + number_of_registers = 1; + payload_offset = 2; + } else { + ESP_LOGW(TAG, "Invalid function code 0x%X. Sending exception response.", function_code); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + return; + } + + uint16_t start_address = uint16_t(data[1]) | (uint16_t(data[0]) << 8); + ESP_LOGD(TAG, + "Received write holding registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " + "0x%X.", + this->address_, function_code, start_address, number_of_registers); + + auto for_each_register = [this, start_address, number_of_registers, payload_offset]( + const std::function &callback) -> bool { + uint16_t offset = payload_offset; + for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { + bool ok = false; + for (auto *server_register : this->server_registers_) { + if (server_register->address == current_address) { + ok = callback(server_register, offset); + current_address += server_register->register_count; + offset += server_register->register_count * sizeof(uint16_t); + break; + } + } + + if (!ok) { + return false; + } + } + return true; + }; + + // check all registers are writable before writing to any of them: + if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { + return server_register->write_lambda != nullptr; + })) { + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + return; + } + + // Actually write to the registers: + if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { + int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); + return server_register->write_lambda(number); + })) { + this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + return; + } + + std::vector response; + response.reserve(6); + response.push_back(this->address_); + response.push_back(function_code); + response.insert(response.end(), data.begin(), data.begin() + 4); + this->send_raw(response); +} + +void ModbusServer::dump_config() { + ESP_LOGCONFIG(TAG, + "ModbusServer:\n" + " Address: 0x%02X\n" + " Server Courtesy Response:\n" + " Enabled: %s\n" + " Register Last Address: 0x%02X\n" + " Register Value: %" PRIu16, + this->address_, this->server_courtesy_response_.enabled ? "true" : "false", + this->server_courtesy_response_.register_last_address, this->server_courtesy_response_.register_value); + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGCONFIG(TAG, "server registers"); + for (auto &r : this->server_registers_) { + ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, + static_cast(r->value_type), r->register_count); + } +#endif +} + +} // namespace esphome::modbus_server diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h new file mode 100644 index 0000000000..0fc2e0bef5 --- /dev/null +++ b/esphome/components/modbus_server/modbus_server.h @@ -0,0 +1,119 @@ +#pragma once + +#include "esphome/core/component.h" + +#include "esphome/components/modbus/modbus.h" +#include "esphome/components/modbus/modbus_helpers.h" +#include "esphome/core/automation.h" + +#include +#include + +namespace esphome::modbus_server { + +using modbus::helpers::SensorValueType; + +struct ServerCourtesyResponse { + bool enabled{false}; + uint16_t register_last_address{0xFFFF}; + uint16_t register_value{0}; +}; + +class ServerRegister { + using ReadLambda = std::function; + using WriteLambda = std::function; + + public: + ServerRegister(uint16_t address, SensorValueType value_type, uint8_t register_count) { + this->address = address; + this->value_type = value_type; + 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); + if constexpr (std::is_same_v) { + return bit_cast(user_value); + } else { + return static_cast(user_value); + } + }; + } + + template + void set_write_lambda(const std::function &&user_write_lambda) { + this->write_lambda = [this, user_write_lambda](int64_t number) { + if constexpr (std::is_same_v) { + float float_value = bit_cast(static_cast(number)); + return user_write_lambda(this->address, float_value); + } + return user_write_lambda(this->address, static_cast(number)); + }; + } + + // Formats a raw value into a string representation based on the value type for debugging + std::string format_value(int64_t value) const { + // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) + // plus null terminator = 43, rounded to 44 for 4-byte alignment + char buf[44]; + switch (this->value_type) { + case SensorValueType::U_WORD: + case SensorValueType::U_DWORD: + case SensorValueType::U_DWORD_R: + case SensorValueType::U_QWORD: + case SensorValueType::U_QWORD_R: + buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(value)); + return buf; + case SensorValueType::S_WORD: + case SensorValueType::S_DWORD: + case SensorValueType::S_DWORD_R: + case SensorValueType::S_QWORD: + case SensorValueType::S_QWORD_R: + buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + return buf; + case SensorValueType::FP32_R: + case SensorValueType::FP32: + buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast(static_cast(value))); + return buf; + default: + buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + return buf; + } + } + + uint16_t address{0}; + SensorValueType value_type{SensorValueType::RAW}; + uint8_t register_count{0}; + ReadLambda read_lambda; + WriteLambda write_lambda; +}; + +class ModbusServer : public Component, public modbus::ModbusDevice { + public: + void dump_config() override; + + /// Not used for ModbusServer. + void on_modbus_data(const std::vector &data) override{}; + /// Registers a server register with the controller. Called by esphomes code generator + void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } + /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors + void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers) final; + /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors + void on_modbus_write_registers(uint8_t function_code, const std::vector &data) final; + /// 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; + } + /// Get the server courtesy response object + ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; } + + protected: + /// Collection of all server registers for this component + std::vector server_registers_{}; + /// Server courtesy response + ServerCourtesyResponse server_courtesy_response_{ + .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; +}; + +} // namespace esphome::modbus_server diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index ffaa1491c5..51951a4528 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -1,53 +1,11 @@ -modbus: - - id: mod_bus2 - uart_id: uart_bus - role: server - modbus_controller: - id: modbus_controller1 address: 0x2 modbus_id: modbus_bus - allow_duplicate_commands: false on_online: then: logger.log: "Module Online" - - id: modbus_controller2 - address: 0x2 - modbus_id: mod_bus2 - server_registers: - - address: 0x0000 - value_type: S_DWORD_R - read_lambda: |- - return 42.3; - max_cmd_retries: 0 - - id: modbus_controller3 - address: 0x3 - modbus_id: mod_bus2 - server_registers: - - address: 0x0009 - value_type: S_DWORD - read_lambda: |- - return 31; - write_lambda: |- - printf("address=%d, value=%d", x); - return true; - max_cmd_retries: 0 - - id: modbus_controller4 - modbus_id: mod_bus2 - address: 0x4 - server_courtesy_response: - enabled: true - register_last_address: 100 - register_value: 0 - server_registers: - - address: 0x0001 - value_type: U_WORD - read_lambda: |- - return 0x8; - - address: 0x0005 - value_type: U_WORD - read_lambda: |- - return (random_uint32() % 100); + binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml new file mode 100644 index 0000000000..3522c9248c --- /dev/null +++ b/tests/components/modbus_server/common.yaml @@ -0,0 +1,41 @@ +modbus: + - id: mod_bus2 + uart_id: uart_bus + role: server + +modbus_server: + - id: modbus_server2 + address: 0x2 + modbus_id: mod_bus2 + registers: + - address: 0x0 + value_type: S_DWORD_R + read_lambda: |- + return 42.3; + - id: modbus_server3 + address: 0x3 + modbus_id: mod_bus2 + registers: + - address: 0x9 + value_type: S_DWORD + read_lambda: |- + return 31; + write_lambda: |- + printf("address=%d, value=%d", x); + return true; + - id: modbus_server4 + modbus_id: mod_bus2 + address: 0x4 + courtesy_response: + enabled: true + register_last_address: 100 + register_value: 0 + registers: + - address: 0x1 + value_type: U_WORD + read_lambda: |- + return 0x8; + - address: 0x5 + value_type: U_WORD + read_lambda: |- + return (random_uint32() % 100); diff --git a/tests/components/modbus_server/test.esp32-idf.yaml b/tests/components/modbus_server/test.esp32-idf.yaml new file mode 100644 index 0000000000..ace2d95a0b --- /dev/null +++ b/tests/components/modbus_server/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/modbus_server/test.esp8266-ard.yaml b/tests/components/modbus_server/test.esp8266-ard.yaml new file mode 100644 index 0000000000..560629b0cd --- /dev/null +++ b/tests/components/modbus_server/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/modbus_server/test.rp2040-ard.yaml b/tests/components/modbus_server/test.rp2040-ard.yaml new file mode 100644 index 0000000000..eeebbd2a8a --- /dev/null +++ b/tests/components/modbus_server/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_modbus_server.yaml b/tests/integration/fixtures/uart_mock_modbus_server.yaml index b657a6fd21..cc5a59e242 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server.yaml @@ -86,9 +86,9 @@ modbus: uart_id: virtual_uart_dev role: server -modbus_controller: +modbus_server: - address: 1 - server_registers: + registers: - address: 0x03 value_type: U_WORD read_lambda: |- diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml index f0f2c56a36..1e5f5a3389 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -33,7 +33,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_controller baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -56,10 +56,11 @@ modbus_controller: update_interval: 1s id: modbus_controller_1 +modbus_server: - address: 1 modbus_id: virtual_modbus_server id: modbus_server_1 - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return 99; diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml index 7ec67b03db..e68edd2271 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml @@ -36,7 +36,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_server_2 baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -48,7 +48,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_controller baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -81,15 +81,16 @@ modbus_controller: update_interval: 1s id: modbus_controller_2 +modbus_server: - address: 1 modbus_id: virtual_modbus_server - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return 919; - address: 2 modbus_id: virtual_modbus_server_2 - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return 929; diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml index 3edcc73f07..94890e90de 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -33,7 +33,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_controller baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -94,10 +94,11 @@ modbus_controller: update_interval: 2s id: modbus_controller_1 +modbus_server: - address: 1 modbus_id: virtual_modbus_server id: modbus_server_1 - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return id(stored_u_word); From daf3f4d2f1b840c1ac9032e7b995b77dbb107b89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 10:41:17 -0500 Subject: [PATCH 0785/2214] [core] wakeable_delay: yield on already-woken fast path (ESP8266, RP2040) (#16045) --- esphome/core/wake/wake_esp8266.h | 4 ++++ esphome/core/wake/wake_rp2040.cpp | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 80cd61035b..7eaaae5293 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -36,6 +36,10 @@ inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { } if (g_main_loop_woke) { g_main_loop_woke = false; + // Yield even on the already-woken fast path so callers in tight loops + // (e.g. lwIP raw TCP wait_for_data_) make forward progress when ISRs + // keep re-setting g_main_loop_woke between iterations. + delay(0); return; } esp_delay(ms, []() { return !g_main_loop_woke; }); diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp index b18248dbd2..bdcbb1ad00 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2040.cpp @@ -36,6 +36,10 @@ void wakeable_delay(uint32_t ms) { } if (g_main_loop_woke) { g_main_loop_woke = false; + // Yield even on the already-woken fast path so callers in tight loops + // (e.g. lwIP raw TCP wait_for_data_) make forward progress when async + // wakes keep re-setting g_main_loop_woke between iterations. + yield(); return; } s_delay_expired = false; From 968878a62d758d97cda953148c42f84cb039fe36 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 28 Apr 2026 18:35:12 +0200 Subject: [PATCH 0786/2214] [nrf52] implement wake_loop_threadsafe/wakeable_delay (#16032) Co-authored-by: J. Nick Koston Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/deep_sleep/__init__.py | 13 +++--- .../deep_sleep/deep_sleep_component.cpp | 9 +--- .../deep_sleep/deep_sleep_component.h | 15 ------- .../deep_sleep/deep_sleep_zephyr.cpp | 16 +++----- esphome/components/zigbee/zigbee_zephyr.cpp | 10 +---- esphome/core/application.h | 3 ++ esphome/core/config.py | 2 +- esphome/core/wake.h | 4 +- esphome/core/wake/wake_generic.cpp | 17 -------- esphome/core/wake/wake_generic.h | 31 -------------- esphome/core/wake/wake_zephyr.cpp | 41 +++++++++++++++++++ esphome/core/wake/wake_zephyr.h | 28 +++++++++++++ 12 files changed, 93 insertions(+), 96 deletions(-) delete mode 100644 esphome/core/wake/wake_generic.cpp delete mode 100644 esphome/core/wake/wake_generic.h create mode 100644 esphome/core/wake/wake_zephyr.cpp create mode 100644 esphome/core/wake/wake_zephyr.h diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 0ca557bd6d..9666c8e507 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -193,11 +193,14 @@ def _validate_ex1_wakeup_mode(value): def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: - if not CORE.is_bk72xx: - return value - max_duration = core.TimePeriod(hours=36) - if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + if CORE.is_bk72xx: + max_duration = core.TimePeriod(hours=36) + if value > max_duration: + raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + elif CORE.using_zephyr: + max_duration = core.TimePeriod(days=49) + if value > max_duration: + raise cv.Invalid("sleep duration cannot be more than 49 days on Zephyr") return value diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index d2c5db54b3..d5e34b1f1c 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -9,18 +9,11 @@ static const char *const TAG = "deep_sleep"; // 5 seconds for deep sleep to ensure clean disconnect from Home Assistant static const uint32_t TEARDOWN_TIMEOUT_DEEP_SLEEP_MS = 5000; -bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -std::atomic global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void DeepSleepComponent::setup() { -#ifdef USE_ZEPHYR - k_sem_init(&this->wakeup_sem_, 0, 1); -#endif global_has_deep_sleep = true; this->schedule_sleep_(); - // It can be used from another thread for waking up the device. - // It should be called as last item in setup. - global_deep_sleep.store(this); } void DeepSleepComponent::schedule_sleep_() { diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 854ab152a1..59381eeabe 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -4,8 +4,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" -#include - #ifdef USE_ESP32 #include #endif @@ -15,10 +13,6 @@ #include "esphome/core/time.h" #endif -#ifdef USE_ZEPHYR -#include -#endif - #include namespace esphome { @@ -125,9 +119,6 @@ class DeepSleepComponent : public Component { void prevent_deep_sleep(); void allow_deep_sleep(); -#ifdef USE_ZEPHYR - void wakeup(); -#endif protected: // Returns nullopt if no run duration is set. Otherwise, returns the run @@ -167,9 +158,6 @@ class DeepSleepComponent : public Component { optional run_duration_; bool next_enter_deep_sleep_{false}; bool prevent_{false}; -#ifdef USE_ZEPHYR - k_sem wakeup_sem_; -#endif }; extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -256,8 +244,5 @@ template class AllowDeepSleepAction : public Action, publ void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } }; -extern std::atomic - global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - } // namespace deep_sleep } // namespace esphome diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp index 82d6d8c7de..f77b73cd58 100644 --- a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -1,17 +1,13 @@ #include "deep_sleep_component.h" #ifdef USE_ZEPHYR #include "esphome/core/log.h" +#include "esphome/core/wake.h" #include -#include -#include -#include namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; -void DeepSleepComponent::wakeup() { k_sem_give(&this->wakeup_sem_); } - optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} @@ -19,9 +15,8 @@ void DeepSleepComponent::dump_config_platform_() {} bool DeepSleepComponent::prepare_to_sleep_() { return true; } void DeepSleepComponent::deep_sleep_() { - k_timeout_t sleep_duration = K_FOREVER; if (this->sleep_duration_.has_value()) { - sleep_duration = K_USEC(*this->sleep_duration_); + esphome::internal::wakeable_delay(static_cast(*this->sleep_duration_ / 1000)); } else { #ifndef USE_ZIGBEE // the device can be woken up through one of the following signals: @@ -33,11 +28,12 @@ void DeepSleepComponent::deep_sleep_() { // // The system is reset when it wakes up from System OFF mode. sys_poweroff(); +#else + esphome::internal::wakeable_delay(UINT32_MAX); #endif } - // It might wake up immediately if k_sem_give was called again after wake up - int ret = k_sem_take(&this->wakeup_sem_, sleep_duration); - if (ret == 0) { + const bool woke = esphome::wake_request_take(); + if (woke) { ESP_LOGD(TAG, "Woken up by another thread"); } else { ESP_LOGD(TAG, "Timeout expired (normal sleep)"); diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index dfffd1c91f..26bef8fb17 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -4,9 +4,7 @@ #include #include #include "esphome/core/hal.h" -#ifdef USE_DEEP_SLEEP -#include "esphome/components/deep_sleep/deep_sleep_component.h" -#endif +#include "esphome/core/wake.h" extern "C" { #include @@ -119,11 +117,7 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; -#ifdef USE_DEEP_SLEEP - if (auto *ds = deep_sleep::global_deep_sleep.load()) { - ds->wakeup(); - } -#endif + esphome::wake_loop_threadsafe(); // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { diff --git a/esphome/core/application.h b/esphome/core/application.h index 221081a0e4..04e0f1138e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -370,6 +370,9 @@ class Application { #elif defined(USE_ESP8266) /// Wake from ISR (ESP8266). No task_woken arg — no FreeRTOS. Caller must be IRAM_ATTR. static void IRAM_ATTR ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { esphome::wake_loop_isrsafe(); } +#elif defined(USE_ZEPHYR) + /// Wake from ISR (Zephyr). No task_woken arg — k_sem_give() handles ISR scheduling internally. + static void wake_loop_isrsafe() { esphome::wake_loop_isrsafe(); } #endif /// Wake from any context (ISR, thread, callback). diff --git a/esphome/core/config.py b/esphome/core/config.py index 14161a7c8b..b4e81ce49f 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -812,7 +812,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wake/wake_host.cpp": { PlatformFramework.HOST_NATIVE, }, - "wake/wake_generic.cpp": { + "wake/wake_zephyr.cpp": { PlatformFramework.NRF52_ZEPHYR, }, # Note: lock_free_queue.h and event_pool.h are header files and don't need to be filtered diff --git a/esphome/core/wake.h b/esphome/core/wake.h index a2f732fcdb..5a5d27ceff 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -69,6 +69,8 @@ __attribute__((always_inline)) inline bool wake_request_take() { #include "esphome/core/wake/wake_rp2040.h" #elif defined(USE_HOST) #include "esphome/core/wake/wake_host.h" +#elif defined(USE_ZEPHYR) +#include "esphome/core/wake/wake_zephyr.h" #else -#include "esphome/core/wake/wake_generic.h" +#error "wake.h: wake_loop_threadsafe() is not implemented for this platform" #endif diff --git a/esphome/core/wake/wake_generic.cpp b/esphome/core/wake/wake_generic.cpp deleted file mode 100644 index 40044e4311..0000000000 --- a/esphome/core/wake/wake_generic.cpp +++ /dev/null @@ -1,17 +0,0 @@ -#include "esphome/core/defines.h" - -#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ - !defined(USE_HOST) - -#include "esphome/core/wake.h" - -namespace esphome { - -// === Wake-requested flag storage === -// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile uint8_t g_wake_requested = 0; - -} // namespace esphome - -#endif // fallback guard diff --git a/esphome/core/wake/wake_generic.h b/esphome/core/wake/wake_generic.h deleted file mode 100644 index 85424b6138..0000000000 --- a/esphome/core/wake/wake_generic.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ - !defined(USE_HOST) - -#include "esphome/core/hal.h" - -namespace esphome { - -/// Zephyr is currently the only platform without a wake mechanism. -/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). -/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. -inline void wake_loop_threadsafe() {} - -inline void wake_loop_any_context() { wake_loop_threadsafe(); } - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - delay(ms); -} -} // namespace internal - -} // namespace esphome - -#endif // fallback guard diff --git a/esphome/core/wake/wake_zephyr.cpp b/esphome/core/wake/wake_zephyr.cpp new file mode 100644 index 0000000000..577d53f5d9 --- /dev/null +++ b/esphome/core/wake/wake_zephyr.cpp @@ -0,0 +1,41 @@ +#include "esphome/core/defines.h" + +#ifdef USE_ZEPHYR + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +#include + +namespace esphome { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +K_SEM_DEFINE(esphome_wake_sem, 0, 1); + +// === Wake-requested flag storage === +// Zephyr has preemptive threads and ISRs, so wake_loop_threadsafe() is genuinely +// called cross-context. volatile uint8_t is sufficient because: (1) Cortex-M +// 8-bit aligned store/load is a single non-tearing instruction, and (2) every +// producer pairs the store with k_sem_give() (release barrier) and the consumer +// pairs the load with k_sem_take() (acquire barrier). +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; + +void wake_loop_threadsafe() { + wake_request_set(); + k_sem_give(&esphome_wake_sem); +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + k_sem_take(&esphome_wake_sem, ms == UINT32_MAX ? K_FOREVER : K_MSEC(ms)); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ZEPHYR diff --git a/esphome/core/wake/wake_zephyr.h b/esphome/core/wake/wake_zephyr.h new file mode 100644 index 0000000000..c89cfc68e9 --- /dev/null +++ b/esphome/core/wake/wake_zephyr.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ZEPHYR + +#include "esphome/core/hal.h" + +namespace esphome { + +/// Zephyr: wakes the main loop via k_sem_give(). Thread- and ISR-safe. +/// Defined in wake_zephyr.cpp. +void wake_loop_threadsafe(); + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +/// ISR-safe: no task_woken arg because Zephyr's k_sem_give() does its own ISR +/// scheduling. Forwards to wake_loop_threadsafe(). +inline void wake_loop_isrsafe() { wake_loop_threadsafe(); } + +namespace internal { +/// Zephyr wakeable_delay uses k_sem_take() with a timeout — defined in wake_zephyr.cpp. +void wakeable_delay(uint32_t ms); +} // namespace internal + +} // namespace esphome + +#endif // USE_ZEPHYR From 6b3df66bdc14e6cfb6cb45bafdf5d118bed1717d Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 28 Apr 2026 19:20:38 +0200 Subject: [PATCH 0787/2214] [nrf52] make reset pin optional (#11684) Co-authored-by: J. Nick Koston --- esphome/components/nrf52/__init__.py | 28 +++++++++++++------ esphome/components/nrf52/dfu.cpp | 27 +++++++++++++----- esphome/components/nrf52/dfu.h | 8 ++---- .../nrf52/test-dfu-pin.nrf52-xiao-ble.yaml | 9 ++++++ .../components/nrf52/test.nrf52-xiao-ble.yaml | 7 +---- 5 files changed, 53 insertions(+), 26 deletions(-) create mode 100644 tests/components/nrf52/test-dfu-pin.nrf52-xiao-ble.yaml diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 5d92a4fa80..d2ed3b15e9 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -141,6 +141,22 @@ CONF_UICR_ERASE = "uicr_erase" VOLTAGE_LEVELS = [1.8, 2.1, 2.4, 2.7, 3.0, 3.3] +_DFU_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(DeviceFirmwareUpdate), + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } +) + + +def _dfu_schema(value: bool | ConfigType) -> ConfigType: + if isinstance(value, bool): + if not value: + raise cv.Invalid("Use 'dfu: true' or specify a configuration dict") + return _DFU_SCHEMA({}) + return _DFU_SCHEMA(value) + + CONFIG_SCHEMA = cv.All( _detect_bootloader, set_core_data, @@ -150,12 +166,7 @@ CONFIG_SCHEMA = cv.All( cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) ), cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), - cv.Optional(CONF_DFU): cv.Schema( - { - cv.GenerateID(): cv.declare_id(DeviceFirmwareUpdate), - cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema, - } - ), + cv.Optional(CONF_DFU): _dfu_schema, cv.Optional(CONF_DCDC, default=True): cv.boolean, cv.Optional(CONF_REG0): cv.Schema( { @@ -321,8 +332,9 @@ async def to_code(config: ConfigType) -> None: async def _dfu_to_code(dfu_config): cg.add_define("USE_NRF52_DFU") var = cg.new_Pvariable(dfu_config[CONF_ID]) - pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(pin)) + if CONF_RESET_PIN in dfu_config: + pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) + cg.add(var.set_reset_pin(pin)) zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True) await cg.register_component(var, dfu_config) diff --git a/esphome/components/nrf52/dfu.cpp b/esphome/components/nrf52/dfu.cpp index c2017248d2..24dee99726 100644 --- a/esphome/components/nrf52/dfu.cpp +++ b/esphome/components/nrf52/dfu.cpp @@ -2,24 +2,34 @@ #ifdef USE_NRF52_DFU +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/components/zephyr/cdc_acm.h" -namespace esphome { -namespace nrf52 { +#include + +namespace esphome::nrf52 { static const char *const TAG = "dfu"; static const uint32_t DFU_DBL_RESET_MAGIC = 0x5A1AD5; // SALADS +static const uint8_t DFU_MAGIC_UF2_RESET = 0x57; // Adafruit nRF52 bootloader UF2 magic void DeviceFirmwareUpdate::setup() { - this->reset_pin_->setup(); + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + } #if defined(CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT) zephyr::global_cdc_acm->add_on_rate_callback([this](const device *, uint32_t rate) { if (rate == 1200) { volatile uint32_t *dbl_reset_mem = (volatile uint32_t *) 0x20007F7C; (*dbl_reset_mem) = DFU_DBL_RESET_MAGIC; - this->reset_pin_->digital_write(true); + if (this->reset_pin_ != nullptr) { + this->reset_pin_->digital_write(true); + } else { + NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; + App.reboot(); + } } }); #endif @@ -27,10 +37,13 @@ void DeviceFirmwareUpdate::setup() { void DeviceFirmwareUpdate::dump_config() { ESP_LOGCONFIG(TAG, "DFU:"); - LOG_PIN(" RESET Pin: ", this->reset_pin_); + if (this->reset_pin_ != nullptr) { + LOG_PIN(" RESET Pin: ", this->reset_pin_); + } else { + ESP_LOGCONFIG(TAG, " Method: GPREGRET"); + } } -} // namespace nrf52 -} // namespace esphome +} // namespace esphome::nrf52 #endif diff --git a/esphome/components/nrf52/dfu.h b/esphome/components/nrf52/dfu.h index 71060e43c1..82c7d9f54e 100644 --- a/esphome/components/nrf52/dfu.h +++ b/esphome/components/nrf52/dfu.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/gpio.h" -namespace esphome { -namespace nrf52 { +namespace esphome::nrf52 { class DeviceFirmwareUpdate : public Component { public: void setup() override; @@ -14,10 +13,9 @@ class DeviceFirmwareUpdate : public Component { void dump_config() override; protected: - GPIOPin *reset_pin_; + GPIOPin *reset_pin_{nullptr}; }; -} // namespace nrf52 -} // namespace esphome +} // namespace esphome::nrf52 #endif diff --git a/tests/components/nrf52/test-dfu-pin.nrf52-xiao-ble.yaml b/tests/components/nrf52/test-dfu-pin.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..d53c692001 --- /dev/null +++ b/tests/components/nrf52/test-dfu-pin.nrf52-xiao-ble.yaml @@ -0,0 +1,9 @@ +nrf52: + dfu: + reset_pin: + number: 14 + inverted: true + mode: + output: true + reg0: + voltage: 1.8V diff --git a/tests/components/nrf52/test.nrf52-xiao-ble.yaml b/tests/components/nrf52/test.nrf52-xiao-ble.yaml index d53c692001..de4c0c6e00 100644 --- a/tests/components/nrf52/test.nrf52-xiao-ble.yaml +++ b/tests/components/nrf52/test.nrf52-xiao-ble.yaml @@ -1,9 +1,4 @@ nrf52: - dfu: - reset_pin: - number: 14 - inverted: true - mode: - output: true + dfu: true reg0: voltage: 1.8V From 42ff10afe59e95028f6ac25dd0f3e04569251e3a Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:32:44 +0000 Subject: [PATCH 0788/2214] [watchdog] Fix WatchdogManager on single core apps (#16074) --- esphome/components/watchdog/watchdog.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index 2ce46756e4..545d83a679 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -6,7 +6,6 @@ #include #include #ifdef USE_ESP32 -#include #include "esp_idf_version.h" #include "esp_task_wdt.h" #endif @@ -40,7 +39,7 @@ void WatchdogManager::set_timeout_(uint32_t timeout_ms) { #ifdef USE_ESP32 esp_task_wdt_config_t wdt_config = { .timeout_ms = timeout_ms, - .idle_core_mask = (1 << SOC_CPU_CORES_NUM) - 1, + .idle_core_mask = (1U << CONFIG_FREERTOS_NUMBER_OF_CORES) - 1U, .trigger_panic = true, }; esp_task_wdt_reconfigure(&wdt_config); From 4ee9cc432b8ae755c0ef6801b014f6858305fb0b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:37:46 -0400 Subject: [PATCH 0789/2214] [ci] Install requirements_dev.txt in the cached venv (#16082) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d60bd6edc3..6ff3736a8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate cache-key id: cache-key - run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT + run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -58,7 +58,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install -r requirements.txt -r requirements_test.txt pre-commit + pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit pip install -e . pylint: From 7891fd5cf16509ce3e2944c925349d994f0c4322 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:38:31 -0400 Subject: [PATCH 0790/2214] Add dependencies.lock to .gitignore (#16081) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index da568d9b83..4a4a88fd48 100644 --- a/.gitignore +++ b/.gitignore @@ -146,5 +146,6 @@ sdkconfig.* /components /managed_components +/dependencies.lock api-docs/ From eb01d43feb80d9052cb4b01d845429c75bc8836c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:09:35 -0400 Subject: [PATCH 0791/2214] [spi][http_request][demo] Fix latent clang-tidy issues in headers (#16080) --- esphome/components/demo/demo_alarm_control_panel.h | 2 +- esphome/components/http_request/http_request.h | 2 +- esphome/components/spi/spi.h | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index 9976e5c7f0..5f0725dd4b 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -29,7 +29,7 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component { protected: void control(const AlarmControlPanelCall &call) override { auto state = call.get_state().value_or(ACP_STATE_DISARMED); - auto code = call.get_code(); + const auto &code = call.get_code(); switch (state) { case ACP_STATE_ARMED_AWAY: if (this->get_requires_code_to_arm()) { diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index f37bf77633..2477e26bc1 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -462,7 +462,7 @@ template class HttpRequestSendAction : public Action { this->request_headers_.push_back({key, value}); } - void add_collect_header(const char *value) { this->lower_case_collect_headers_.push_back(value); } + void add_collect_header(const char *value) { this->lower_case_collect_headers_.emplace_back(value); } void init_json(size_t count) { this->json_.init(count); } void add_json(const char *key, TemplatableValue value) { this->json_.push_back({key, value}); } diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index dc538f4c41..e6f592c6e4 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -451,7 +451,7 @@ class SPIDevice : public SPIClient { uint8_t read_byte() { return this->delegate_->transfer(0); } - void read_array(uint8_t *data, size_t length) { return this->delegate_->read_array(data, length); } + void read_array(uint8_t *data, size_t length) { this->delegate_->read_array(data, length); } /** * Write a single data item, up to 32 bits. From 44fbb7f5a9cb6a131dec72bd2328ee86e5c2f8e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:10:21 -0500 Subject: [PATCH 0792/2214] Bump CodSpeedHQ/action from 4.14.0 to 4.15.0 (#16084) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ff3736a8c..57053c3645 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -369,7 +369,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@658a901452bb54c799643e060733b7afe9121b8d # v4.14.0 + uses: CodSpeedHQ/action@c381be0bfd20e844fb45594f6aa182ffcd94545c # v4.15.0 with: run: ${{ steps.build.outputs.binary }} mode: simulation From c8dffcc9b875c95c7b7d162326c5de10d755880a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:28:33 -0400 Subject: [PATCH 0793/2214] [tlc5971] Remove dead bit-banging delay code (#16086) --- esphome/components/tlc5971/tlc5971.cpp | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/esphome/components/tlc5971/tlc5971.cpp b/esphome/components/tlc5971/tlc5971.cpp index be17780f8c..8128dd9046 100644 --- a/esphome/components/tlc5971/tlc5971.cpp +++ b/esphome/components/tlc5971/tlc5971.cpp @@ -68,13 +68,8 @@ void TLC5971::transfer_(uint8_t send) { uint8_t startbit = 0x80; bool towrite, lastmosi = !(send & startbit); - uint8_t bitdelay_us = (1000000 / 1000000) / 2; for (uint8_t b = startbit; b != 0; b = b >> 1) { - if (bitdelay_us) { - delayMicroseconds(bitdelay_us); - } - towrite = send & b; if ((lastmosi != towrite)) { this->data_pin_->digital_write(towrite); @@ -82,11 +77,6 @@ void TLC5971::transfer_(uint8_t send) { } this->clock_pin_->digital_write(true); - - if (bitdelay_us) { - delayMicroseconds(bitdelay_us); - } - this->clock_pin_->digital_write(false); } } From 1f4136e76f2d414d0ec0b20f83d9e7333070db06 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:29:09 -0400 Subject: [PATCH 0794/2214] [pipsolar] Guard handle_qmod_ against empty message (#16085) --- esphome/components/pipsolar/pipsolar.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index c304d206c0..5123d8d9d3 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -433,13 +433,17 @@ void Pipsolar::handle_qpigs_(const char *message) { } void Pipsolar::handle_qmod_(const char *message) { - std::string mode; - char device_mode = char(message[1]); if (this->last_qmod_) { this->last_qmod_->publish_state(message); } + // QMOD response is "(M" where M is the device-mode character. Bail out if the + // message is shorter than 2 chars (e.g. empty error response from + // handle_poll_error_) — reading message[1] would otherwise be out of bounds. + if (message[0] == '\0' || message[1] == '\0') + return; if (this->device_mode_) { - mode = device_mode; + std::string mode; + mode = char(message[1]); this->device_mode_->publish_state(mode); } } From 9af557de6dd5a0f88ccc556e7bfa3687ff496db3 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:29:38 +1000 Subject: [PATCH 0795/2214] [lvgl] Add utility gradient function (#16048) --- esphome/components/lvgl/gradient.py | 6 +++++- esphome/components/lvgl/lvgl_esphome.cpp | 26 ++++++++++++++++++++++++ esphome/components/lvgl/lvgl_esphome.h | 10 +++++++++ esphome/core/defines.h | 1 + 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index c4a3c8f2cb..e075433d03 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -1,3 +1,5 @@ +from operator import itemgetter + from esphome import config_validation as cv import esphome.codegen as cg from esphome.const import ( @@ -11,6 +13,7 @@ from esphome.core import ID from esphome.cpp_generator import MockObj from .defines import CONF_GRADIENTS, CONF_OPA, LV_DITHER, add_define, add_warning +from .helpers import add_lv_use from .lv_validation import lv_color, lv_percentage, opacity from .lvcode import lv from .types import lv_color_t, lv_gradient_t, lv_opa_t @@ -50,6 +53,7 @@ GRADIENT_SCHEMA = cv.ensure_list( async def gradients_to_code(config): + add_lv_use("gradient") max_stops = 2 if any(CONF_DITHER in x for x in config.get(CONF_GRADIENTS, ())): add_warning( @@ -58,7 +62,7 @@ async def gradients_to_code(config): for gradient in config.get(CONF_GRADIENTS, ()): var = MockObj(cg.new_Pvariable(gradient[CONF_ID]), "->") idbase = gradient[CONF_ID].id - stops = gradient[CONF_STOPS] + stops = sorted(gradient[CONF_STOPS], key=itemgetter(CONF_POSITION)) max_stops = max(max_stops, len(stops)) if gradient[CONF_DIRECTION].startswith("VER"): lv.grad_vertical_init(var) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index d8248e4aa4..0308e6b783 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -864,6 +864,32 @@ void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_en } #endif // USE_LVGL_SCALE +#ifdef USE_LVGL_GRADIENT +/** + * + * @param dsc The gradient descriptor containing the color stops + * @param pos The current position to calculate the color for + * @return The color for the given position + */ + +lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos) { + if (dsc->stops_count == 0) + return lv_color_black(); + if (dsc->stops_count == 1 || pos <= dsc->stops[0].frac) + return dsc->stops[0].color; + if (pos >= dsc->stops[dsc->stops_count - 1].frac) + return dsc->stops[dsc->stops_count - 1].color; + int i = 1; + while (i < dsc->stops_count && dsc->stops[i].frac < pos) + i++; + auto *stop1 = &dsc->stops[i - 1]; + auto *stop2 = &dsc->stops[i]; + int32_t range = stop2->frac - stop1->frac; + int32_t offset = pos - stop1->frac; + return lv_color_mix(stop2->color, stop1->color, range == 0 ? 0 : (offset * 255) / range); +} +#endif + static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { LV_TRACE_OBJ_CREATE("begin"); LV_UNUSED(class_p); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 146866f5bd..83cf9cc099 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -115,6 +115,16 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value); #endif +#ifdef USE_LVGL_GRADIENT +/** + * + * @param dsc The gradient descriptor containing the color stops + * @param pos The current position to calculate the color for + * @return The color for the given position + */ + +lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos); +#endif // Parent class for things that wrap an LVGL object class LvCompound { public: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index daca55d68a..592c8c46a2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -96,6 +96,7 @@ #define USE_LVGL_CHECKBOX #define USE_LVGL_DROPDOWN #define USE_LVGL_FONT +#define USE_LVGL_GRADIENT #define USE_LVGL_IMAGE #define USE_LVGL_IMAGEBUTTON #define USE_LVGL_KEY_LISTENER From 8157c721a59c9ca90c7b076f15873b2ef5d799ec Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:31:37 +1000 Subject: [PATCH 0796/2214] [mapping] Implement default value (#15861) --- esphome/components/mapping/__init__.py | 113 +++++++++++++----- esphome/components/mapping/mapping.h | 9 ++ esphome/loader.py | 3 +- tests/components/mapping/common.yaml | 2 + tests/components/mapping/test.esp32-idf.yaml | 2 +- .../components/mapping/test.esp8266-ard.yaml | 2 +- tests/components/mapping/test.rp2040-ard.yaml | 2 +- 7 files changed, 97 insertions(+), 36 deletions(-) diff --git a/esphome/components/mapping/__init__.py b/esphome/components/mapping/__init__.py index a36b414fd5..3c7d78a27b 100644 --- a/esphome/components/mapping/__init__.py +++ b/esphome/components/mapping/__init__.py @@ -1,18 +1,27 @@ +from collections.abc import Callable import difflib import esphome.codegen as cg +from esphome.components.const import KEY_METADATA import esphome.config_validation as cv from esphome.const import CONF_FROM, CONF_ID, CONF_TO -from esphome.core import CORE -from esphome.cpp_generator import MockObj, VariableDeclarationExpression, add_global +from esphome.core import CORE, ID +from esphome.cpp_generator import ( + MockObj, + MockObjClass, + VariableDeclarationExpression, + add_global, +) from esphome.loader import get_component CODEOWNERS = ["@clydebarrow"] MULTI_CONF = True +DOMAIN = "mapping" mapping_ns = cg.esphome_ns.namespace("mapping") mapping_class = mapping_ns.class_("Mapping") +CONF_DEFAULT_VALUE = "default_value" CONF_ENTRIES = "entries" CONF_CLASS = "class" @@ -22,11 +31,18 @@ class IndexType: Represents a type of index in a map. """ - def __init__(self, validator, data_type, conversion): + def __init__( + self, validator: Callable, data_type: MockObj, conversion: Callable = None + ) -> None: self.validator = validator self.data_type = data_type self.conversion = conversion + async def convert_value(self, value): + if self.conversion: + return self.conversion(value) + return await cg.get_variable(value) + INDEX_TYPES = { "int": IndexType(cv.int_, cg.int_, int), @@ -38,6 +54,12 @@ INDEX_TYPES = { } +class MappingMetaData: + def __init__(self, from_: IndexType, to_: IndexType) -> None: + self.from_ = from_ + self.to_ = to_ + + def to_schema(value): """ Generate a schema for the 'to' field of a map. This can be either one of the index types or a class name. @@ -60,7 +82,7 @@ BASE_SCHEMA = cv.Schema( ) -def get_object_type(to_): +def get_object_type(to_) -> MockObjClass | None: """ Get the object type from a string. Possible formats: xxx The name of a component which defines INSTANCE_TYPE @@ -81,25 +103,60 @@ def get_object_type(to_): return None +def get_all_mapping_metadata() -> dict[str, MappingMetaData]: + """Get all mapping metadata.""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) + + +def get_mapping_metadata(mapping_id: str) -> MappingMetaData: + """Get mapping metadata by ID for use by other components.""" + return get_all_mapping_metadata()[mapping_id] + + +def add_metadata( + mapping_id: ID, + from_: IndexType, + to_: IndexType, +) -> None: + get_all_mapping_metadata()[mapping_id.id] = MappingMetaData(from_, to_) + + def map_schema(config): config = BASE_SCHEMA(config) if CONF_ENTRIES not in config or not isinstance(config[CONF_ENTRIES], dict): - raise cv.Invalid("an entries list is required for a map") + raise cv.Invalid("an entries dictionary is required for a mapping") entries = config[CONF_ENTRIES] if len(entries) == 0: - raise cv.Invalid("Map must have at least one entry") + raise cv.Invalid("A mapping must have at least one entry") to_ = config[CONF_TO] if to_ in INDEX_TYPES: - value_type = INDEX_TYPES[to_].validator + value_type = INDEX_TYPES[to_] else: - value_type = get_object_type(to_) - if value_type is None: + object_type = get_object_type(to_) + if object_type is None: matches = difflib.get_close_matches(to_, CORE.id_classes) raise cv.Invalid( f"No known mappable class name matches '{to_}'; did you mean one of {', '.join(matches)}?" ) - value_type = cv.use_id(value_type) - config[CONF_ENTRIES] = {k: value_type(v) for k, v in entries.items()} + validator = cv.use_id(object_type) + value_type = IndexType(validator, object_type) + config[CONF_ENTRIES] = {k: value_type.validator(v) for k, v in entries.items()} + if (default_value := config.get(CONF_DEFAULT_VALUE)) is not None: + config[CONF_DEFAULT_VALUE] = value_type.validator(default_value) + unexpected_keys = config.keys() - { + CONF_ENTRIES, + CONF_TO, + CONF_FROM, + CONF_ID, + CONF_DEFAULT_VALUE, + } + if unexpected_keys: + errors = [ + cv.Invalid(f"Unexpected key '{k}'", path=[k]) for k in unexpected_keys + ] + raise cv.MultipleInvalid(errors) + + add_metadata(config[CONF_ID], INDEX_TYPES[config[CONF_FROM]], value_type) return config @@ -107,29 +164,19 @@ CONFIG_SCHEMA = map_schema async def to_code(config): - entries = config[CONF_ENTRIES] - from_ = config[CONF_FROM] - to_ = config[CONF_TO] - index_conversion = INDEX_TYPES[from_].conversion - index_type = INDEX_TYPES[from_].data_type - if to_ in INDEX_TYPES: - value_conversion = INDEX_TYPES[to_].conversion - value_type = INDEX_TYPES[to_].data_type - entries = { - index_conversion(key): value_conversion(value) - for key, value in entries.items() - } - else: - entries = { - index_conversion(key): await cg.get_variable(value) - for key, value in entries.items() - } - value_type = get_object_type(to_) - if list(entries.values())[0].op != ".": - value_type = value_type.operator("ptr") varid = config[CONF_ID] + metadata = get_mapping_metadata(varid.id) + entries = { + metadata.from_.conversion(key): await metadata.to_.convert_value(value) + for key, value in config[CONF_ENTRIES].items() + } + value_type = metadata.to_.data_type + # entries guaranteed to be non-empty here. + value_0 = list(entries.values())[0] + if isinstance(value_0, MockObj) and value_0.op != ".": + value_type = value_type.operator("ptr") varid.type = mapping_class.template( - index_type, + metadata.from_.data_type, value_type, ) var = MockObj(varid, ".") @@ -139,4 +186,6 @@ async def to_code(config): for key, value in entries.items(): cg.add(var.set(key, value)) + if (default_value := config.get(CONF_DEFAULT_VALUE)) is not None: + cg.add(var.set_default_value(await metadata.to_.convert_value(default_value))) return var diff --git a/esphome/components/mapping/mapping.h b/esphome/components/mapping/mapping.h index 2b8f0d39b2..d6790caa35 100644 --- a/esphome/components/mapping/mapping.h +++ b/esphome/components/mapping/mapping.h @@ -40,6 +40,9 @@ template class Mapping { if (it != this->map_.end()) { return V{it->second}; } + if (this->default_value_.has_value()) { + return this->default_value_.value(); + } if constexpr (std::is_pointer_v) { esph_log_e(TAG, "Key '%p' not found in mapping", key); } else if constexpr (std::is_same_v) { @@ -69,11 +72,17 @@ template class Mapping { if (it != this->map_.end()) { return it->second.c_str(); // safe since value remains in map } + if (this->default_value_.has_value()) { + return this->default_value_.value(); + } return ""; } + void set_default_value(const V &default_value) { this->default_value_ = default_value; } + protected: std::map, RAMAllocator>> map_; + std::optional default_value_{}; }; } // namespace esphome::mapping diff --git a/esphome/loader.py b/esphome/loader.py index 9390b8094b..2405fa6f88 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -14,6 +14,7 @@ from typing import Any from esphome.const import SOURCE_FILE_EXTENSIONS from esphome.core import CORE import esphome.core.config +from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -93,7 +94,7 @@ class ComponentManifest: return getattr(self.module, "CODEOWNERS", []) @property - def instance_type(self) -> list[str]: + def instance_type(self) -> MockObjClass | None: return getattr(self.module, "INSTANCE_TYPE", None) @property diff --git a/tests/components/mapping/common.yaml b/tests/components/mapping/common.yaml index 7ffcfa4f67..b3db9d54eb 100644 --- a/tests/components/mapping/common.yaml +++ b/tests/components/mapping/common.yaml @@ -21,6 +21,7 @@ mapping: entries: clear-night: image_1 sunny: image_2 + default_value: image_1 - id: weather_map_2 from: string to: image @@ -35,6 +36,7 @@ mapping: 2: "two" 3: "three" 77: "seventy-seven" + default_value: unknown - id: string_map from: string to: int diff --git a/tests/components/mapping/test.esp32-idf.yaml b/tests/components/mapping/test.esp32-idf.yaml index a35b6940c7..93adcf9988 100644 --- a/tests/components/mapping/test.esp32-idf.yaml +++ b/tests/components/mapping/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: spi_id: spi_bus - platform: ili9xxx + platform: mipi_spi id: main_lcd model: ili9342 cs_pin: 12 diff --git a/tests/components/mapping/test.esp8266-ard.yaml b/tests/components/mapping/test.esp8266-ard.yaml index c59821a211..6a308b67dd 100644 --- a/tests/components/mapping/test.esp8266-ard.yaml +++ b/tests/components/mapping/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: spi_id: spi_bus - platform: ili9xxx + platform: mipi_spi id: main_lcd model: ili9342 cs_pin: 5 diff --git a/tests/components/mapping/test.rp2040-ard.yaml b/tests/components/mapping/test.rp2040-ard.yaml index fdfed5f6ab..01b83c4ab8 100644 --- a/tests/components/mapping/test.rp2040-ard.yaml +++ b/tests/components/mapping/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: spi_id: spi_bus - platform: ili9xxx + platform: mipi_spi id: main_lcd model: ili9342 data_rate: 31.25MHz From 594b269dba2e383e9f6cc7808af4b1bbb14c7a5d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:33:57 -0400 Subject: [PATCH 0797/2214] [bme680] Rename cal1/cal2 to coeff1/coeff2 (#16087) --- esphome/components/bme680/bme680.cpp | 54 ++++++++++++++-------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index e3cd80de00..b599d64c0d 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -78,43 +78,43 @@ void BME680Component::setup() { } // Read calibration - uint8_t cal1[25]; - if (!this->read_bytes(BME680_REGISTER_COEFF1, cal1, 25)) { + uint8_t coeff1[25]; + if (!this->read_bytes(BME680_REGISTER_COEFF1, coeff1, 25)) { this->mark_failed(); return; } - uint8_t cal2[16]; - if (!this->read_bytes(BME680_REGISTER_COEFF2, cal2, 16)) { + uint8_t coeff2[16]; + if (!this->read_bytes(BME680_REGISTER_COEFF2, coeff2, 16)) { this->mark_failed(); return; } - this->calibration_.t1 = cal2[9] << 8 | cal2[8]; - this->calibration_.t2 = cal1[2] << 8 | cal1[1]; - this->calibration_.t3 = cal1[3]; + this->calibration_.t1 = coeff2[9] << 8 | coeff2[8]; + this->calibration_.t2 = coeff1[2] << 8 | coeff1[1]; + this->calibration_.t3 = coeff1[3]; - this->calibration_.h1 = cal2[2] << 4 | (cal2[1] & 0x0F); - this->calibration_.h2 = cal2[0] << 4 | cal2[1] >> 4; - this->calibration_.h3 = cal2[3]; - this->calibration_.h4 = cal2[4]; - this->calibration_.h5 = cal2[5]; - this->calibration_.h6 = cal2[6]; - this->calibration_.h7 = cal2[7]; + this->calibration_.h1 = coeff2[2] << 4 | (coeff2[1] & 0x0F); + this->calibration_.h2 = coeff2[0] << 4 | coeff2[1] >> 4; + this->calibration_.h3 = coeff2[3]; + this->calibration_.h4 = coeff2[4]; + this->calibration_.h5 = coeff2[5]; + this->calibration_.h6 = coeff2[6]; + this->calibration_.h7 = coeff2[7]; - this->calibration_.p1 = cal1[6] << 8 | cal1[5]; - this->calibration_.p2 = cal1[8] << 8 | cal1[7]; - this->calibration_.p3 = cal1[9]; - this->calibration_.p4 = cal1[12] << 8 | cal1[11]; - this->calibration_.p5 = cal1[14] << 8 | cal1[13]; - this->calibration_.p6 = cal1[16]; - this->calibration_.p7 = cal1[15]; - this->calibration_.p8 = cal1[20] << 8 | cal1[19]; - this->calibration_.p9 = cal1[22] << 8 | cal1[21]; - this->calibration_.p10 = cal1[23]; + this->calibration_.p1 = coeff1[6] << 8 | coeff1[5]; + this->calibration_.p2 = coeff1[8] << 8 | coeff1[7]; + this->calibration_.p3 = coeff1[9]; + this->calibration_.p4 = coeff1[12] << 8 | coeff1[11]; + this->calibration_.p5 = coeff1[14] << 8 | coeff1[13]; + this->calibration_.p6 = coeff1[16]; + this->calibration_.p7 = coeff1[15]; + this->calibration_.p8 = coeff1[20] << 8 | coeff1[19]; + this->calibration_.p9 = coeff1[22] << 8 | coeff1[21]; + this->calibration_.p10 = coeff1[23]; - this->calibration_.gh1 = cal2[14]; - this->calibration_.gh2 = cal2[12] << 8 | cal2[13]; - this->calibration_.gh3 = cal2[15]; + this->calibration_.gh1 = coeff2[14]; + this->calibration_.gh2 = coeff2[12] << 8 | coeff2[13]; + this->calibration_.gh3 = coeff2[15]; uint8_t temp_var = 0; if (!this->read_byte(0x02, &temp_var)) { From 70503442f4e743793222c529ef56a03c08a8c4d2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:37:29 -0400 Subject: [PATCH 0798/2214] [dfrobot_sen0395] Brace single-statement else-if in enqueue() (#16089) --- esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp index f47025698b..98901bd353 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp @@ -104,8 +104,9 @@ int8_t CircularCommandQueue::enqueue(std::unique_ptr cmd) { if (this->is_full()) { ESP_LOGE(TAG, "Command queue is full"); return -1; - } else if (this->is_empty()) + } else if (this->is_empty()) { front_++; + } rear_ = (rear_ + 1) % COMMAND_QUEUE_SIZE; commands_[rear_] = std::move(cmd); // Transfer ownership using std::move return 1; From 16cf4fb5e8818933de0f833170ad4acde7a8f105 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:47:20 -0400 Subject: [PATCH 0799/2214] [nextion] Use std::string::starts_with for HTTPS URL check (#16090) --- esphome/components/nextion/nextion_upload_arduino.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index e0d18352ff..399f217a19 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -331,7 +331,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { #ifdef USE_ESP8266 WiFiClient *Nextion::get_wifi_client_() { - if (this->tft_url_.compare(0, 6, "https:") == 0) { + if (this->tft_url_.starts_with("https:")) { if (this->wifi_client_secure_ == nullptr) { // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) this->wifi_client_secure_ = new BearSSL::WiFiClientSecure(); From 3d195d748c258236532dcc843dd24487956d06b6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:50:15 -0400 Subject: [PATCH 0800/2214] [ezo] Use make_unique to construct EzoCommand (#16092) --- esphome/components/ezo/ezo.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ezo/ezo.cpp b/esphome/components/ezo/ezo.cpp index 2dc65b7d14..bb8fb92f21 100644 --- a/esphome/components/ezo/ezo.cpp +++ b/esphome/components/ezo/ezo.cpp @@ -35,7 +35,7 @@ void EZOSensor::update() { } if (!found) { - std::unique_ptr ezo_command(new EzoCommand); + auto ezo_command = make_unique(); ezo_command->command = "R"; ezo_command->command_type = EzoCommandType::EZO_READ; ezo_command->delay_ms = 900; @@ -162,7 +162,7 @@ void EZOSensor::loop() { } void EZOSensor::add_command_(const char *command, EzoCommandType command_type, uint16_t delay_ms) { - std::unique_ptr ezo_command(new EzoCommand); + auto ezo_command = make_unique(); ezo_command->command = command; ezo_command->command_type = command_type; ezo_command->delay_ms = delay_ms; From ab6bda50e46eab26bc229618bdc9375fe8cd52dc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:58:40 -0400 Subject: [PATCH 0801/2214] [esp32_ble] Widen loop variable in as_128bit() to match uuid_.len type (#16088) --- esphome/components/esp32_ble/ble_uuid.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index 334780e3b8..886f8237ad 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -104,7 +104,7 @@ ESPBTUUID ESPBTUUID::as_128bit() const { } else { uuid32 = this->uuid_.uuid.uuid16; } - for (uint8_t i = 0; i < this->uuid_.len; i++) { + for (uint16_t i = 0; i < this->uuid_.len; i++) { data[12 + i] = ((uuid32 >> i * 8) & 0xFF); } return ESPBTUUID::from_raw(data); From 7d6b9bee19ffe49e0fab2c9c30252368c61a3cc0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:22:29 -0400 Subject: [PATCH 0802/2214] [wifi] Avoid copying EAP config in three connect handlers (#16094) --- esphome/components/wifi/wifi_component.cpp | 4 ++-- esphome/components/wifi/wifi_component_esp8266.cpp | 4 ++-- esphome/components/wifi/wifi_component_esp_idf.cpp | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f7c70b1147..1da2d630c1 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1099,9 +1099,9 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { } #ifdef USE_WIFI_WPA2_EAP - auto eap_opt = ap.get_eap(); + const auto &eap_opt = ap.get_eap(); if (eap_opt.has_value()) { - EAPAuth eap_config = *eap_opt; + const EAPAuth &eap_config = *eap_opt; // clang-format off ESP_LOGV( TAG, diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index bf3a0d2949..402ca051cd 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -313,10 +313,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // setup enterprise authentication if required #ifdef USE_WIFI_WPA2_EAP - auto eap_opt = ap.get_eap(); + const auto &eap_opt = ap.get_eap(); if (eap_opt.has_value()) { // note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0. - EAPAuth eap = *eap_opt; + const EAPAuth &eap = *eap_opt; ret = wifi_station_set_enterprise_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); if (ret) { ESP_LOGV(TAG, "esp_wifi_sta_wpa2_ent_set_identity failed: %d", ret); diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 82ecc80811..4f39a3a4b1 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -407,10 +407,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // setup enterprise authentication if required #ifdef USE_WIFI_WPA2_EAP - auto eap_opt = ap.get_eap(); + const auto &eap_opt = ap.get_eap(); if (eap_opt.has_value()) { // note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0. - EAPAuth eap = *eap_opt; + const EAPAuth &eap = *eap_opt; #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); #else From a62e3fe4fcd13d135cd5710d4dd0189fd7d56cff Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:35:40 -0400 Subject: [PATCH 0803/2214] [json] NOLINT StackAddressEscape false positive in parse_json (#16091) --- esphome/components/json/json_util.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index edcd23f922..ec1490be1f 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -39,7 +39,8 @@ bool parse_json(const uint8_t *data, size_t len, const json_parse_t &f) { } JsonDocument parse_json(const uint8_t *data, size_t len) { - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) false positives with + // ArduinoJson if (data == nullptr || len == 0) { ESP_LOGE(TAG, "No data to parse"); return JsonObject(); // return unbound object @@ -63,7 +64,7 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { } ESP_LOGE(TAG, "Parse error: %s", err.c_str()); return JsonObject(); // return unbound object - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) + // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } SerializationBuffer<> JsonBuilder::serialize() { From e39c474577e73c5467b0e6828af4460f844e25b7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:13:35 -0400 Subject: [PATCH 0804/2214] [binary_sensor] Bind at_index_ once in MultiClick on_state_ (#16095) --- esphome/components/binary_sensor/automation.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index eb68abce3b..b13e4a88dd 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -50,29 +50,31 @@ void MultiClickTriggerBase::on_state_(bool state) { return; } - if (*this->at_index_ == this->timing_count_) { + // at_index_ has a value here (the !has_value() branch above returns). + size_t at_index = *this->at_index_; + if (at_index == this->timing_count_) { this->trigger_(); return; } - MultiClickTriggerEvent evt = this->timing_[*this->at_index_]; + MultiClickTriggerEvent evt = this->timing_[at_index]; if (evt.max_length != 4294967294UL) { - ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, *this->at_index_, evt.min_length, evt.max_length); // NOLINT + ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); // NOLINT this->schedule_is_valid_(evt.min_length); this->schedule_is_not_valid_(evt.max_length); - } else if (*this->at_index_ + 1 != this->timing_count_) { - ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT + } else if (at_index + 1 != this->timing_count_) { + ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->schedule_is_valid_(evt.min_length); } else { - ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT + ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT this->is_valid_ = false; this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); } - *this->at_index_ = *this->at_index_ + 1; + this->at_index_ = at_index + 1; } void MultiClickTriggerBase::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); From 2f433c78bd1294748b5c3d0c57dbb10a27ed0ef2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:56:36 -0400 Subject: [PATCH 0805/2214] [haier] Brace single-statement else-if in smartair2_climate (#16098) --- esphome/components/haier/smartair2_climate.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index 2be5d13050..200cac2557 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -210,8 +210,9 @@ void Smartair2Climate::process_phase(std::chrono::steady_clock::time_point now) #ifdef USE_WIFI else if (this->send_wifi_signal_ && (std::chrono::duration_cast(now - this->last_signal_request_).count() > - SIGNAL_LEVEL_UPDATE_INTERVAL_MS)) + SIGNAL_LEVEL_UPDATE_INTERVAL_MS)) { this->set_phase(ProtocolPhases::SENDING_UPDATE_SIGNAL_REQUEST); + } #endif } break; default: From a241c9e622e623d5d0c20cfcc02ce44877de7428 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:02:39 -0400 Subject: [PATCH 0806/2214] [online_image][sim800l] Use std::string::starts_with for prefix checks (#16097) --- .../components/online_image/online_image.cpp | 2 +- esphome/components/sim800l/sim800l.cpp | 21 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 24926aa4dc..a5a3ea5104 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -28,7 +28,7 @@ bool OnlineImage::validate_url_(const std::string &url) { ESP_LOGE(TAG, "URL is too long"); return false; } - if (url.compare(0, 7, "http://") != 0 && url.compare(0, 8, "https://") != 0) { + if (!url.starts_with("http://") && !url.starts_with("https://")) { ESP_LOGE(TAG, "URL must start with http:// or https://"); return false; } diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index 913d920c94..001ec77454 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -110,7 +110,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { case STATE_INIT: { // While we were waiting for update to check for messages, this notifies a message // is available. - bool message_available = message.compare(0, 6, "+CMTI:") == 0; + bool message_available = message.starts_with("+CMTI:"); if (!message_available) { if (message == "RING") { // Incoming call... @@ -120,7 +120,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->call_state_ = 6; this->call_disconnected_callback_.call(); } - } else if (message.compare(0, 6, "+CUSD:") == 0) { + } else if (message.starts_with("+CUSD:")) { // Incoming USSD MESSAGE this->state_ = STATE_CHECK_USSD; } @@ -175,7 +175,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { break; case STATE_CHECK_USSD: ESP_LOGD(TAG, "Check ussd code: '%s'", message.c_str()); - if (message.compare(0, 6, "+CUSD:") == 0) { + if (message.starts_with("+CUSD:")) { this->state_ = STATE_RECEIVED_USSD; this->ussd_ = ""; size_t start = 10; @@ -196,8 +196,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { case STATE_CREG_WAIT: { // Response: "+CREG: 0,1" -- the one there means registered ok // "+CREG: -,-" means not registered ok - bool registered = - message.size() > 9 && message.compare(0, 6, "+CREG:") == 0 && (message[9] == '1' || message[9] == '5'); + bool registered = message.size() > 9 && message.starts_with("+CREG:") && (message[9] == '1' || message[9] == '5'); if (registered) { if (!this->registered_) { ESP_LOGD(TAG, "Registered OK"); @@ -223,7 +222,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->state_ = STATE_CSQ_RESPONSE; break; case STATE_CSQ_RESPONSE: - if (message.compare(0, 5, "+CSQ:") == 0) { + if (message.starts_with("+CSQ:")) { size_t comma = message.find(',', 6); if (comma != 6) { int rssi = parse_number(message.substr(6, comma - 6)).value_or(0); @@ -243,7 +242,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->state_ = STATE_CHECK_SMS; break; case STATE_PARSE_SMS_RESPONSE: - if (message.compare(0, 6, "+CMGL:") == 0 && this->parse_index_ == 0) { + if (message.starts_with("+CMGL:") && this->parse_index_ == 0) { size_t start = 7; size_t end = message.find(',', start); uint8_t item = 0; @@ -278,7 +277,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { } break; case STATE_CHECK_CALL: - if (message.compare(0, 6, "+CLCC:") == 0 && this->parse_index_ == 0) { + if (message.starts_with("+CLCC:") && this->parse_index_ == 0) { this->expect_ack_ = true; size_t start = 7; size_t end = message.find(',', start); @@ -324,7 +323,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { /* Our recipient is set and the message body is in message kick ESPHome callback now */ - if (ok || message.compare(0, 6, "+CMGL:") == 0) { + if (ok || message.starts_with("+CMGL:")) { ESP_LOGD(TAG, "Received SMS from: %s\n" " %s", @@ -360,7 +359,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { } break; case STATE_SENDING_SMS_3: - if (message.compare(0, 6, "+CMGS:") == 0) { + if (message.starts_with("+CMGS:")) { ESP_LOGD(TAG, "SMS Sent OK: %s", message.c_str()); this->send_pending_ = false; this->state_ = STATE_CHECK_SMS; @@ -383,7 +382,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->state_ = STATE_INIT; break; case STATE_PARSE_CLIP: - if (message.compare(0, 6, "+CLIP:") == 0) { + if (message.starts_with("+CLIP:")) { std::string caller_id; size_t start = 7; size_t end = message.find(',', start); From be0ee738474d6535bcb48e7c5e5c274f07577ae2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:22:42 -0400 Subject: [PATCH 0807/2214] [i2c] NOLINT readability-identifier-naming on Zephyr struct forward-decl (#16099) --- esphome/components/i2c/i2c_bus_zephyr.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/i2c/i2c_bus_zephyr.h b/esphome/components/i2c/i2c_bus_zephyr.h index 49cac5b992..3c4aa9ed1d 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.h +++ b/esphome/components/i2c/i2c_bus_zephyr.h @@ -5,7 +5,7 @@ #include "i2c_bus.h" #include "esphome/core/component.h" -struct device; +struct device; // NOLINT(readability-identifier-naming) - forward decl of Zephyr's device type namespace esphome::i2c { From 15df47747267cb6e287738a7acb12edd8ad04a5a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:41:28 -0400 Subject: [PATCH 0808/2214] [core] Reduce copies in Callback/CallbackManager call paths (#16093) Co-authored-by: J. Nick Koston --- esphome/core/helpers.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 4a91c46074..b2b07c57a0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1666,7 +1666,7 @@ template struct Callback { void *ctx_{nullptr}; /// Invoke the callback. Only valid on Callbacks created via create(), never on default-constructed instances. - void call(Ts... args) const { this->fn_(this->ctx_, args...); } + void call(Ts... args) const { this->fn_(this->ctx_, std::forward(args)...); } /// Create from any callable. Small trivially-copyable callables (like [this] lambdas) /// are stored inline in the ctx pointer without heap allocation. @@ -1742,7 +1742,7 @@ template class CallbackManager { template void add(F &&callback) { this->add_(CbType::create(std::forward(callback))); } /// Call all callbacks in this manager. - inline void ESPHOME_ALWAYS_INLINE call(Ts... args) { + inline void ESPHOME_ALWAYS_INLINE call(const Ts &...args) { if (this->size_ != 0) { for (auto *it = this->data_, *end = it + this->size_; it != end; ++it) { it->call(args...); @@ -1752,7 +1752,7 @@ template class CallbackManager { uint16_t size() const { return this->size_; } /// Call all callbacks in this manager. - void operator()(Ts... args) { this->call(args...); } + void operator()(const Ts &...args) { this->call(args...); } protected: template friend class LazyCallbackManager; From 0b5835284aaab1c77810ec8c0dea4723f7858f48 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:35:24 +1000 Subject: [PATCH 0809/2214] [lvgl] Additional layout features (#16041) --- esphome/components/lvgl/layout.py | 119 +++++++-- tests/component_tests/lvgl/__init__.py | 0 .../component_tests/lvgl/test_grid_layout.py | 239 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 83 ++++++ 4 files changed, 419 insertions(+), 22 deletions(-) create mode 100644 tests/component_tests/lvgl/__init__.py create mode 100644 tests/component_tests/lvgl/test_grid_layout.py diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index 46026852af..32304276d3 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -1,3 +1,4 @@ +import math import re import textwrap @@ -85,6 +86,22 @@ def grid_free_space(value): grid_spec = cv.Any(size, LvConstant("LV_GRID_", "CONTENT").one_of, grid_free_space) + +def grid_dimension(value): + """ + Validator for a grid `rows` or `columns` value. + Accepts either a positive integer (interpreted as that many cells of equal + `LV_GRID_FR(1)` size) or a non-empty list of grid specs. + """ + if isinstance(value, int): + value = cv.int_range(min=1)(value) + return ["LV_GRID_FR(1)"] * value + result = cv.Schema([grid_spec])(value) + if not result: + raise cv.Invalid("Grid dimension list must contain at least one entry") + return result + + GRID_CELL_SCHEMA = { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int, @@ -184,7 +201,16 @@ class DirectionalLayout(FlexLayout): class GridLayout(Layout): - _GRID_LAYOUT_REGEX = re.compile(r"^\s*(\d+)\s*x\s*(\d+)\s*$") + # Match shorthand grid layout strings: "NxM", "Nx" or "xM". + # At least one of the two numbers must be present; this is enforced after matching. + _GRID_LAYOUT_REGEX = re.compile(r"^\s*(\d+)?\s*x\s*(\d+)?\s*$") + + @staticmethod + def _match_shorthand(layout): + match = GridLayout._GRID_LAYOUT_REGEX.match(layout) + if match is None or (match.group(1) is None and match.group(2) is None): + return None + return match def get_type(self): return TYPE_GRID @@ -192,7 +218,7 @@ class GridLayout(Layout): def get_layout_schemas(self, config: dict) -> tuple: layout = config.get(CONF_LAYOUT) if isinstance(layout, str): - if GridLayout._GRID_LAYOUT_REGEX.match(layout): + if GridLayout._match_shorthand(layout): return ( cv.string, { @@ -213,59 +239,107 @@ class GridLayout(Layout): if not isinstance(layout, dict) or layout.get(CONF_TYPE).lower() != TYPE_GRID: return None, {} + x_default = ( + "center" if isinstance(layout.get(CONF_GRID_ROWS), int) else cv.UNDEFINED + ) + y_default = ( + "center" if isinstance(layout.get(CONF_GRID_COLUMNS), int) else cv.UNDEFINED + ) + x_align = layout.get(CONF_GRID_CELL_X_ALIGN, x_default) + y_align = layout.get(CONF_GRID_CELL_Y_ALIGN, y_default) return ( { cv.Required(CONF_TYPE): cv.one_of(TYPE_GRID, lower=True), - cv.Required(CONF_GRID_ROWS): [grid_spec], - cv.Required(CONF_GRID_COLUMNS): [grid_spec], + cv.Optional(CONF_GRID_ROWS): grid_dimension, + cv.Optional(CONF_GRID_COLUMNS): grid_dimension, cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments, cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments, cv.Optional(CONF_PAD_ROW): padding, cv.Optional(CONF_PAD_COLUMN): padding, cv.Optional(CONF_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean, + cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments, + cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments, }, { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int, cv.Optional(CONF_GRID_CELL_ROW_SPAN): cv.int_range(min=1), cv.Optional(CONF_GRID_CELL_COLUMN_SPAN): cv.int_range(min=1), - cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments, - cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments, + cv.Optional(CONF_GRID_CELL_X_ALIGN, default=x_align): grid_alignments, + cv.Optional(CONF_GRID_CELL_Y_ALIGN, default=y_align): grid_alignments, }, ) def validate(self, config: dict): """ Validate the grid layout. - The `layout:` key may be a dictionary with `rows` and `columns` keys, or a string in the format "rows x columns". + The `layout:` key may be a dictionary with `rows` and/or `columns` keys, or a + shorthand string in the format "x", "x" or "x". + Either dimension may be omitted, in which case it will be calculated from the + other dimension and the number of configured widgets. Either all cells must have a row and column, or none, in which case the grid layout is auto-generated. :param config: :return: The config updated with auto-generated values """ layout = config.get(CONF_LAYOUT) + widgets = config.get(CONF_WIDGETS, []) + num_widgets = len(widgets) if isinstance(layout, str): - # If the layout is a string, assume it is in the format "rows x columns", implying - # a grid layout with the specified number of rows and columns each with CONTENT sizing. + # Shorthand string: "x", "x" or "x". + # Each dimension defaults to LV_GRID_FR(1). A missing dimension is + # calculated from the other dimension and the number of widgets. layout = layout.strip() - match = GridLayout._GRID_LAYOUT_REGEX.match(layout) - if match: - rows = int(match.group(1)) - cols = int(match.group(2)) - layout = { - CONF_TYPE: TYPE_GRID, - CONF_GRID_ROWS: ["LV_GRID_FR(1)"] * rows, - CONF_GRID_COLUMNS: ["LV_GRID_FR(1)"] * cols, - } - config[CONF_LAYOUT] = layout - else: + match = GridLayout._match_shorthand(layout) + if not match: raise cv.Invalid( - f"Invalid grid layout format: {config}, expected 'rows x columns'", + f"Invalid grid layout format: {layout!r}, expected " + "'x', 'x' or 'x'", [CONF_LAYOUT], ) + rows_int = int(match.group(1)) if match.group(1) is not None else None + cols_int = int(match.group(2)) if match.group(2) is not None else None + for label, val in (("row", rows_int), ("column", cols_int)): + if val is not None and val < 1: + raise cv.Invalid( + f"Invalid grid layout {layout!r}: {label} count must be " + "at least 1", + [CONF_LAYOUT], + ) + if rows_int is not None and cols_int is not None: + rows = rows_int + cols = cols_int + elif rows_int is not None: + rows = rows_int + cols = max(1, math.ceil(num_widgets / rows)) if num_widgets else 1 + else: + cols = cols_int + rows = max(1, math.ceil(num_widgets / cols)) if num_widgets else 1 + layout = { + CONF_TYPE: TYPE_GRID, + CONF_GRID_ROWS: ["LV_GRID_FR(1)"] * rows, + CONF_GRID_COLUMNS: ["LV_GRID_FR(1)"] * cols, + } + config[CONF_LAYOUT] = layout # should be guaranteed to be a dict at this point assert isinstance(layout, dict) assert layout.get(CONF_TYPE).lower() == TYPE_GRID + rows_list = layout.get(CONF_GRID_ROWS) + cols_list = layout.get(CONF_GRID_COLUMNS) + if rows_list is None and cols_list is None: + raise cv.Invalid( + "Grid layout requires at least one of 'rows' or 'columns' to be " + "specified", + [CONF_LAYOUT], + ) + if rows_list is None: + cols = len(cols_list) + rows = max(1, math.ceil(num_widgets / cols)) if num_widgets else 1 + layout[CONF_GRID_ROWS] = ["LV_GRID_FR(1)"] * rows + elif cols_list is None: + rows = len(rows_list) + cols = max(1, math.ceil(num_widgets / rows)) if num_widgets else 1 + layout[CONF_GRID_COLUMNS] = ["LV_GRID_FR(1)"] * cols allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False) rows = len(layout[CONF_GRID_ROWS]) columns = len(layout[CONF_GRID_COLUMNS]) @@ -379,7 +453,8 @@ def append_layout_schema(schema, config: dict): textwrap.dedent( """ Invalid 'layout' value - layout choices are 'horizontal', 'vertical', 'x', + layout choices are 'horizontal', 'vertical', + 'x', 'x', 'x', or a dictionary with a 'type' key """ ), diff --git a/tests/component_tests/lvgl/__init__.py b/tests/component_tests/lvgl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/lvgl/test_grid_layout.py b/tests/component_tests/lvgl/test_grid_layout.py new file mode 100644 index 0000000000..dfd4b2460c --- /dev/null +++ b/tests/component_tests/lvgl/test_grid_layout.py @@ -0,0 +1,239 @@ +"""Unit tests for the LVGL grid layout shorthand and rows/columns auto-sizing.""" + +from __future__ import annotations + +import pytest +from voluptuous import Invalid + +from esphome.components.lvgl.defines import ( + CONF_GRID_COLUMNS, + CONF_GRID_ROWS, + CONF_LAYOUT, + CONF_WIDGETS, + TYPE_GRID, +) +from esphome.components.lvgl.layout import GridLayout, grid_dimension +from esphome.const import CONF_TYPE + +FR1 = "LV_GRID_FR(1)" + + +def _widgets(n: int) -> list[dict]: + """Build a list of `n` placeholder widgets for the validate() input.""" + return [{"label": {}} for _ in range(n)] + + +# --------------------------------------------------------------------------- +# grid_dimension validator +# --------------------------------------------------------------------------- + + +def test_grid_dimension_int_expands_to_fr1_list() -> None: + """A positive integer should expand to a list of LV_GRID_FR(1) entries.""" + assert grid_dimension(1) == [FR1] + assert grid_dimension(3) == [FR1, FR1, FR1] + + +def test_grid_dimension_zero_or_negative_rejected() -> None: + """Non-positive integers must be rejected.""" + with pytest.raises(Invalid): + grid_dimension(0) + with pytest.raises(Invalid): + grid_dimension(-2) + + +def test_grid_dimension_list_passes_through() -> None: + """A list should be validated through the existing grid_spec list schema.""" + result = grid_dimension(["100px", "content", "fr(2)"]) + # `grid_spec` normalises each entry: pixel sizes become ints, the + # CONTENT keyword is uppercased and prefixed, and FR(n) is normalised. + assert result == [100, "LV_GRID_CONTENT", "LV_GRID_FR(2)"] + + +def test_grid_dimension_invalid_string_rejected() -> None: + """A string is not a valid grid dimension and should be rejected.""" + with pytest.raises(Invalid): + grid_dimension("not a list") + + +def test_grid_dimension_empty_list_rejected() -> None: + """An empty list of grid specs must be rejected.""" + with pytest.raises(Invalid, match="at least one entry"): + grid_dimension([]) + + +# --------------------------------------------------------------------------- +# Shorthand string layouts +# --------------------------------------------------------------------------- + + +def test_shorthand_full_form_unchanged() -> None: + """`x` continues to work and yields the exact dimensions.""" + config = {CONF_LAYOUT: "2x3", CONF_WIDGETS: _widgets(0)} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + assert layout[CONF_TYPE] == TYPE_GRID + assert layout[CONF_GRID_ROWS] == [FR1, FR1] + assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1] + + +def test_shorthand_rows_only_calculates_columns_from_widgets() -> None: + """`x` derives the column count from the number of widgets.""" + config = {CONF_LAYOUT: "3x", CONF_WIDGETS: _widgets(7)} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + # 7 widgets / 3 rows -> ceil = 3 columns. + assert len(layout[CONF_GRID_ROWS]) == 3 + assert len(layout[CONF_GRID_COLUMNS]) == 3 + + +def test_shorthand_columns_only_calculates_rows_from_widgets() -> None: + """`x` derives the row count from the number of widgets.""" + config = {CONF_LAYOUT: "x4", CONF_WIDGETS: _widgets(5)} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + # 5 widgets / 4 cols -> ceil = 2 rows. + assert len(layout[CONF_GRID_ROWS]) == 2 + assert len(layout[CONF_GRID_COLUMNS]) == 4 + + +def test_shorthand_rows_only_no_widgets_defaults_columns_to_one() -> None: + """With no widgets and only rows specified, the column count defaults to 1.""" + config = {CONF_LAYOUT: "3x", CONF_WIDGETS: []} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + assert len(layout[CONF_GRID_ROWS]) == 3 + assert len(layout[CONF_GRID_COLUMNS]) == 1 + + +def test_shorthand_columns_only_no_widgets_defaults_rows_to_one() -> None: + """With no widgets and only columns specified, the row count defaults to 1.""" + config = {CONF_LAYOUT: "x4", CONF_WIDGETS: []} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + assert len(layout[CONF_GRID_ROWS]) == 1 + assert len(layout[CONF_GRID_COLUMNS]) == 4 + + +def test_shorthand_with_whitespace_accepted() -> None: + """The shorthand parser should tolerate whitespace around the components.""" + config = {CONF_LAYOUT: " 3 x ", CONF_WIDGETS: _widgets(6)} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + # 6 widgets / 3 rows -> 2 columns. + assert len(layout[CONF_GRID_ROWS]) == 3 + assert len(layout[CONF_GRID_COLUMNS]) == 2 + + +def test_shorthand_bare_x_rejected() -> None: + """Pure `x` (no digits at all) is not a valid shorthand.""" + config = {CONF_LAYOUT: "x", CONF_WIDGETS: _widgets(2)} + with pytest.raises(Invalid): + GridLayout().validate(config) + + +@pytest.mark.parametrize( + "layout,bad_label", + [ + ("0x3", "row"), + ("3x0", "column"), + ("0x", "row"), + ("x0", "column"), + ("0x0", "row"), + ], +) +def test_shorthand_zero_dimension_rejected(layout: str, bad_label: str) -> None: + """Shorthand row/column counts must be >= 1.""" + config = {CONF_LAYOUT: layout, CONF_WIDGETS: _widgets(2)} + with pytest.raises(Invalid, match=f"{bad_label} count must be at least 1"): + GridLayout().validate(config) + + +def test_shorthand_get_layout_schemas_recognizes_partial_forms() -> None: + """`x` and `x` should be picked up by GridLayout.get_layout_schemas.""" + grid = GridLayout() + for layout in ("3x", "x4", "2x3"): + layout_schema, _ = grid.get_layout_schemas({CONF_LAYOUT: layout}) + assert layout_schema is not None, f"{layout!r} should be recognised" + # Pure `x` and unrelated strings should not be picked up as a grid layout. + for layout in ("x", "horizontal"): + layout_schema, _ = grid.get_layout_schemas({CONF_LAYOUT: layout}) + assert layout_schema is None, f"{layout!r} should not be recognised" + + +# --------------------------------------------------------------------------- +# Dict-form layouts with rows/columns auto-sizing +# --------------------------------------------------------------------------- + + +def test_dict_rows_only_calculates_columns_from_widgets() -> None: + """A dict layout with only rows fills in the column count from widget count.""" + config = { + CONF_LAYOUT: { + CONF_TYPE: TYPE_GRID, + CONF_GRID_ROWS: [FR1, FR1], + }, + CONF_WIDGETS: _widgets(5), + } + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + # 5 widgets / 2 rows -> ceil = 3 columns. + assert len(layout[CONF_GRID_ROWS]) == 2 + assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1] + + +def test_dict_columns_only_calculates_rows_from_widgets() -> None: + """A dict layout with only columns fills in the row count from widget count.""" + config = { + CONF_LAYOUT: { + CONF_TYPE: TYPE_GRID, + CONF_GRID_COLUMNS: [FR1, FR1, FR1], + }, + CONF_WIDGETS: _widgets(7), + } + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + # 7 widgets / 3 cols -> ceil = 3 rows. + assert layout[CONF_GRID_ROWS] == [FR1, FR1, FR1] + assert len(layout[CONF_GRID_COLUMNS]) == 3 + + +def test_dict_rows_only_no_widgets_defaults_columns_to_one() -> None: + """A dict layout with rows but no widgets defaults columns to 1.""" + config = { + CONF_LAYOUT: { + CONF_TYPE: TYPE_GRID, + CONF_GRID_ROWS: [FR1, FR1, FR1], + }, + CONF_WIDGETS: [], + } + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + assert len(layout[CONF_GRID_ROWS]) == 3 + assert layout[CONF_GRID_COLUMNS] == [FR1] + + +def test_dict_neither_rows_nor_columns_rejected() -> None: + """A grid layout dict without rows AND without columns must be rejected.""" + config = { + CONF_LAYOUT: {CONF_TYPE: TYPE_GRID}, + CONF_WIDGETS: _widgets(3), + } + with pytest.raises(Invalid): + GridLayout().validate(config) + + +def test_dict_both_rows_and_columns_unchanged() -> None: + """When both dimensions are present they are preserved as-is.""" + config = { + CONF_LAYOUT: { + CONF_TYPE: TYPE_GRID, + CONF_GRID_ROWS: [FR1, FR1], + CONF_GRID_COLUMNS: [FR1, FR1, FR1], + }, + CONF_WIDGETS: _widgets(0), + } + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + assert layout[CONF_GRID_ROWS] == [FR1, FR1] + assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1] diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d6e237199a..9c4ad4bbf8 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1113,6 +1113,8 @@ lvgl: pad_row: 6px pad_column: 0 multiple_widgets_per_cell: true + grid_cell_x_align: center + grid_cell_y_align: center widgets: - image: grid_cell_row_pos: 0 @@ -1305,6 +1307,87 @@ lvgl: hidden: true mode: text_lower + # Grid shorthand "x": 3 rows specified, columns derived + # from widget count (4 widgets / 3 rows -> 2 columns) + - obj: + id: grid_rows_only_shorthand + layout: 3x + widgets: + - label: + text: "r1" + - label: + text: "r2" + - label: + text: "r3" + - label: + text: "r4" + + # Grid shorthand "x": 4 columns specified, rows derived + # from widget count (5 widgets / 4 cols -> 2 rows) + - obj: + id: grid_cols_only_shorthand + layout: x4 + widgets: + - label: + text: "a" + - label: + text: "b" + - label: + text: "c" + - label: + text: "d" + - label: + text: "e" + + # Grid dict form with grid_rows as a plain integer; columns derived + - obj: + id: grid_rows_int + layout: + type: grid + grid_rows: 2 + widgets: + - label: + text: "1" + - label: + text: "2" + - label: + text: "3" + + # Grid dict form with grid_columns as a plain integer; rows derived + - obj: + id: grid_cols_int + layout: + type: grid + grid_columns: 3 + widgets: + - label: + text: "x" + - label: + text: "y" + - label: + text: "z" + - label: + text: "w" + - label: + text: "v" + + # Grid dict form with both grid_rows and grid_columns as plain integers + - obj: + id: grid_both_int + layout: + type: grid + grid_rows: 2 + grid_columns: 2 + widgets: + - label: + text: "1,1" + - label: + text: "1,2" + - label: + text: "2,1" + - label: + text: "2,2" + font: - file: "gfonts://Roboto" id: space16 From 77b76ac48a41dcfda992199a0343ad06b8c5956a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:56:03 -0400 Subject: [PATCH 0810/2214] [inkbird_ibsth1_mini][speaker][speaker_source] Fix performance-unnecessary-copy-initialization (#16101) --- .../components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp | 4 ++-- .../components/speaker/media_player/speaker_media_player.cpp | 2 +- .../components/speaker_source/speaker_source_media_player.cpp | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp index 94c22ae84d..c53d8e5029 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp @@ -41,12 +41,12 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic ESP_LOGVV(TAG, "parse_device(): service_data is expected to be empty"); return false; } - auto mnf_datas = device.get_manufacturer_datas(); + const auto &mnf_datas = device.get_manufacturer_datas(); if (mnf_datas.size() != 1) { ESP_LOGVV(TAG, "parse_device(): manufacturer_datas is expected to have a single element"); return false; } - auto mnf_data = mnf_datas[0]; + const auto &mnf_data = mnf_datas[0]; if (mnf_data.uuid.get_uuid().len != ESP_UUID_LEN_16) { ESP_LOGVV(TAG, "parse_device(): manufacturer data element is expected to have uuid of length 16"); return false; diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 930373c6fc..ab11a89c3f 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -502,7 +502,7 @@ void SpeakerMediaPlayer::control(const media_player::MediaPlayerCall &call) { media_command.announce = false; } - auto media_url = call.get_media_url(); + const auto &media_url = call.get_media_url(); if (media_url.has_value()) { media_command.url = new std::string(*media_url); // Must be manually deleted after receiving media_command from a queue diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 2caab828fb..87fd4fe9ed 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -698,7 +698,7 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call } } - auto media_url = call.get_media_url(); + const auto &media_url = call.get_media_url(); if (media_url.has_value()) { auto command = call.get_command(); bool enqueue = command.has_value() && command.value() == media_player::MEDIA_PLAYER_COMMAND_ENQUEUE; From 29d3a3a4984b09d709e72b74252b1c2001ec68dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 19:58:00 -0500 Subject: [PATCH 0811/2214] [esp8266] Replace millis() with fast accumulator, wrap Arduino callers (#15662) --- esphome/components/esp8266/__init__.py | 5 ++ esphome/components/esp8266/core.cpp | 80 +++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index bef7e36470..34540bd48d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -314,6 +314,11 @@ async def to_code(config): for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap Arduino's millis() so all callers (including Arduino libraries and ISR + # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply + # implementation in the Arduino ESP8266 core. + cg.add_build_flag("-Wl,--wrap=millis") + cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 159ec20e77..c9bedb61be 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -16,9 +16,75 @@ extern "C" { namespace esphome { void HOT yield() { ::yield(); } -uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -uint64_t millis_64() { return Millis64Impl::compute(::millis()); } -void HOT delay(uint32_t ms) { ::delay(ms); } +// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit +// multiplies on the LX106). Tracks a running ms counter from 32-bit +// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis +// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. +// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static +// state against ISR re-entry; the critical section is bounded (≤10 while-loop +// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on +// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level +// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). +// +// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles +// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a +// >71 min block would trip the watchdog long before it could matter here. +static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; +static constexpr uint32_t US_PER_MS = 1000; + +uint32_t IRAM_ATTR HOT millis() { + // Struct packs the three statics so the compiler loads one base address + // instead of three separate literal pool entries (saves ~8 bytes IRAM). + static struct { + uint32_t cache; + uint32_t remainder; + uint32_t last_us; + } state = {0, 0, 0}; + uint32_t ps = xt_rsil(15); + uint32_t now_us = system_get_time(); + uint32_t delta = now_us - state.last_us; + state.last_us = now_us; + state.remainder += delta; + if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { + // Rare path: large gap (WiFi scan, boot, long block). Constant-time + // conversion keeps the critical section bounded. + uint32_t ms = state.remainder / US_PER_MS; + state.cache += ms; + // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a + // second __umodsi3 call on the LX106 (no hardware divide). + state.remainder -= ms * US_PER_MS; + } else { + // Common path: small gap. At most ~10 iterations since remainder was + // < threshold (10 ms) on entry and delta adds at most one more threshold + // before exiting this branch. + while (state.remainder >= US_PER_MS) { + state.cache++; + state.remainder -= US_PER_MS; + } + } + uint32_t result = state.cache; + xt_wsr_ps(ps); + return result; +} +uint64_t millis_64() { return Millis64Impl::compute(millis()); } +// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object +// call to the original millis() that --wrap can't intercept, so calling ::delay() +// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still +// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and +// WiFi run correctly. Theoretically less power-efficient than Arduino's +// os_timer-based delay() for long waits, but nearly all ESPHome delays are short +// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is +// negligible. +void HOT delay(uint32_t ms) { + if (ms == 0) { + optimistic_yield(1000); + return; + } + uint32_t start = millis(); + while (millis() - start < ms) { + optimistic_yield(1000); + } +} uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { @@ -78,4 +144,12 @@ extern "C" void resetPins() { // NOLINT } // namespace esphome +// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. +// Requires -Wl,--wrap=millis in build flags (added by __init__.py). +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } +// Note: Arduino's init() registers a 60-second overflow timer for micros64(). +// We leave it running — wrapping init() as a no-op would break micros64()'s +// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). + #endif // USE_ESP8266 From 676f26919ec088cf0f9f6fafc498cc05d5d8cc26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 20:02:21 -0500 Subject: [PATCH 0812/2214] [mdns] Drive MDNS.update() polling from IP state events on ESP8266/RP2040 (#15961) --- esphome/components/mdns/__init__.py | 39 +++++++++ esphome/components/mdns/mdns_component.h | 81 +++++++++++------ esphome/components/mdns/mdns_esp8266.cpp | 33 +++++-- esphome/components/mdns/mdns_rp2040.cpp | 86 ++++++++++++------- .../mdns/common-enabled-ethernet.yaml | 23 +++++ .../test-enabled-ethernet.rp2040-ard.yaml | 1 + 6 files changed, 202 insertions(+), 61 deletions(-) create mode 100644 tests/components/mdns/common-enabled-ethernet.yaml create mode 100644 tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 7c36295e8d..2b25cf243d 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -14,6 +14,7 @@ from esphome.const import ( from esphome.core import CORE, Lambda, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.cpp_generator import LambdaExpression +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -61,6 +62,28 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType: return config +def _require_network_interface(config: ConfigType) -> ConfigType: + """Require a network interface for mDNS on Arduino/LEAmDNS platforms. + + On ESP8266 and RP2040 the C++ implementation needs at least one IP state + listener (WiFi on ESP8266; WiFi or Ethernet on RP2040) to arm its polling + window. Reject at config time rather than silently producing a component + that never initializes. + """ + if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2040): + return config + full_config = fv.full_config.get() + has_wifi = "wifi" in full_config + has_ethernet = CORE.is_rp2040 and "ethernet" in full_config + if not (has_wifi or has_ethernet): + options = "'wifi'" if CORE.is_esp8266 else "'wifi' or 'ethernet'" + raise cv.Invalid( + "mdns on this platform requires a network interface — " + f"add a {options} component to your configuration." + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -74,6 +97,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = _require_network_interface + + def mdns_txt_record(key: str, value: str) -> cg.RawExpression: """Create a mDNS TXT record. @@ -169,6 +195,19 @@ async def to_code(config): elif CORE.is_rp2040: cg.add_library("LEAmDNS", None) + # Subscribe to the network IP state listener(s) so MDNS.update() is only + # scheduled during the probe+announce phase. Same on_ip_state() override + # serves both WiFi and Ethernet (signatures match). + if CORE.is_esp8266 or CORE.is_rp2040: + if "wifi" in CORE.config: + from esphome.components import wifi + + wifi.request_wifi_ip_state_listener() + if CORE.is_rp2040 and "ethernet" in CORE.config: + from esphome.components import ethernet + + ethernet.request_ethernet_ip_state_listener() + if CORE.is_esp32: add_idf_component(name="espressif/mdns", ref="1.11.0") diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index adf88a9cf1..798af0e0bf 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,6 +5,22 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +// On ESP8266 and RP2040 the scheduler-backed MDNS.update() polling window is armed by +// IP state listener events on whichever network interface is configured. +#if (defined(USE_ESP8266) || defined(USE_RP2040)) && \ + ((defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)) || \ + (defined(USE_ETHERNET) && defined(USE_ETHERNET_IP_STATE_LISTENERS))) +#include "esphome/components/network/ip_address.h" +#define USE_MDNS_EVENT_DRIVEN_POLLING +#if defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS) +#include "esphome/components/wifi/wifi_component.h" +#define USE_MDNS_WIFI_LISTENER +#endif +#if defined(USE_ETHERNET) && defined(USE_ETHERNET_IP_STATE_LISTENERS) +#include "esphome/components/ethernet/ethernet_component.h" +#define USE_MDNS_ETHERNET_LISTENER +#endif +#endif namespace esphome::mdns { @@ -40,33 +56,40 @@ struct MDNSService { FixedVector txt_records; }; -class MDNSComponent final : public Component { +class MDNSComponent final : public Component +#ifdef USE_MDNS_WIFI_LISTENER + , + public wifi::WiFiIPStateListener +#endif +#ifdef USE_MDNS_ETHERNET_LISTENER + , + public ethernet::EthernetIPStateListener +#endif +{ public: void setup() override; void dump_config() override; - // Polling interval for MDNS.update() on platforms that require it (ESP8266, RP2040). - // - // On these platforms, MDNS.update() calls _process(true) which only manages timer-driven - // state machines (probe/announce timeouts and service query cache TTLs). Incoming mDNS - // packets are handled independently via the lwIP onRx UDP callback and are NOT affected - // by how often update() is called. - // - // The shortest internal timer is the 250ms probe interval (RFC 6762 Section 8.1). - // Announcement intervals are 1000ms and cache TTL checks are on the order of seconds - // to minutes. A 50ms polling interval provides sufficient resolution for all timers - // while completely removing mDNS from the per-iteration loop list. - // - // In steady state (after the ~8 second boot probe/announce phase completes), update() - // checks timers that are set to never expire, making every call pure overhead. - // - // Tasmota uses a 50ms main loop cycle with mDNS working correctly, confirming this - // interval is safe in production. - // - // By using set_interval() instead of overriding loop(), the component is excluded from - // the main loop list via has_overridden_loop(), eliminating all per-iteration overhead - // including virtual dispatch. +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + // LEAmDNS has meaningful work only during the probe+announce phase (3×250ms probes + + // 8×1000ms announces, ~9s). Afterwards every internal timer is resetToNeverExpires() + // and update() becomes pure overhead. We arm a bounded polling window from IP state + // listener events so update() runs only during that phase. static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; + // Must exceed LEAmDNS's longest restart-to-announce-complete path: + // MDNS_PROBE_DELAY (250ms) × MDNS_PROBE_COUNT (3) = 750ms probing + // + MDNS_ANNOUNCE_DELAY (1000ms) × MDNS_ANNOUNCE_COUNT (8) = 8000ms announcing + // + rand() % MDNS_PROBE_DELAY jitter on first probe (0–250ms) + // + debounced schedule_function() hop when statusChangeCB fires on ESP8266 + // ≈ 9s nominal. 15s gives ~6s margin to absorb main-loop blocking (long + // component setup, WiFi scan, flash writes) that could stretch the deadlines + // between our polls. If LEAmDNS ever extends its phase (upstream library + // update) this constant needs to grow. Constants defined in LEAmDNS_Priv.h + // (ESP8266 core 3.1.2 / arduino-pico 5.5.1). + static constexpr uint32_t MDNS_POLL_WINDOW_MS = 15000; + static constexpr uint32_t MDNS_POLL_ID = 0; + static constexpr uint32_t MDNS_POLL_STOP_ID = 1; +#endif float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } #ifdef USE_MDNS_EXTRA_SERVICES @@ -87,7 +110,17 @@ class MDNSComponent final : public Component { } #endif +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; +#endif + protected: +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + /// Arm a fresh MDNS_POLL_WINDOW_MS polling window. Idempotent — re-arming replaces + /// the previous window via the scheduler's atomic cancel-and-add on matching IDs. + void start_polling_window_(); +#endif /// Helper to set up services and MAC buffers, then call platform-specific registration using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector &); @@ -130,8 +163,8 @@ class MDNSComponent final : public Component { #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; #endif -#ifdef USE_RP2040 - bool was_connected_{false}; +#if defined(USE_RP2040) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) + // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif void compile_records_(StaticVector &services, char *mac_address_buf); diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 70c614f8d3..f6d5786675 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -8,6 +8,8 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "mdns_component.h" +// wifi_component.h is pulled in transitively by mdns_component.h when +// USE_MDNS_WIFI_LISTENER is defined. namespace esphome::mdns { @@ -36,15 +38,36 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); +} +#endif + void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); - // Schedule MDNS.update() via set_interval() instead of overriding loop(). - // This removes the component from the per-iteration loop list entirely, - // eliminating virtual dispatch overhead on every main loop cycle. - // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. - this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); +#ifdef USE_MDNS_WIFI_LISTENER + // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we just + // arm the window around the initial probe/announce and each reconnect. Unconditional + // here is safe: setup_priority::AFTER_CONNECTION guarantees the network is up. + wifi::global_wifi_component->add_ip_state_listener(this); + this->start_polling_window_(); +#endif } +#ifdef USE_MDNS_WIFI_LISTENER +void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, + const network::IPAddress &) { + // IP listener only fires on acquisition (not loss), so any notification is a fresh + // IP worth re-arming for. start_polling_window_() is idempotent. + if (ips[0].is_set()) { + this->start_polling_window_(); + } +} +#endif + void MDNSComponent::on_shutdown() { MDNS.close(); delay(10); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 64b603030c..f5848893a3 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -6,9 +6,10 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "mdns_component.h" +// wifi_component.h / ethernet_component.h are pulled in transitively by +// mdns_component.h when their respective listener defines are active. // Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty. -// Save and restore our definition around the include to avoid a redefinition warning. #pragma push_macro("IRAM_ATTR") #undef IRAM_ATTR #include @@ -20,10 +21,7 @@ static void register_rp2040(MDNSComponent *, StaticVectorset_interval(MDNS_UPDATE_INTERVAL_MS, [this]() { - bool connected = network::is_connected(); - if (connected && !this->was_connected_) { - if (!this->initialized_) { - this->setup_buffers_and_register_(register_rp2040); - this->initialized_ = true; - } else { - MDNS.notifyAPChange(); - } - } - this->was_connected_ = connected; - if (this->initialized_) { - MDNS.update(); - } - }); +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +void MDNSComponent::start_polling_window_() { + // uint32_t-ID set_interval/set_timeout already does atomic cancel-and-add. + this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } +#endif + +void MDNSComponent::setup() { + // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses + // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our + // own IP state listener. Both WiFi and Ethernet have the same listener signature — + // one on_ip_state() override serves both. +#ifdef USE_MDNS_WIFI_LISTENER + wifi::global_wifi_component->add_ip_state_listener(this); + // AFTER_CONNECTION priority means the network may already be up; the listener only + // fires on subsequent changes, so seed the current state. + { + const auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses(); + if (ips[0].is_set()) { + this->on_ip_state(ips, wifi::global_wifi_component->get_dns_address(0), + wifi::global_wifi_component->get_dns_address(1)); + } + } +#endif +#ifdef USE_MDNS_ETHERNET_LISTENER + ethernet::global_eth_component->add_ip_state_listener(this); + if (ethernet::global_eth_component->is_connected()) { + const auto ips = ethernet::global_eth_component->get_ip_addresses(); + if (ips[0].is_set()) { + this->on_ip_state(ips, network::IPAddress{}, network::IPAddress{}); + } + } +#endif +} + +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, + const network::IPAddress &) { + // Listener only fires on IP acquisition (not loss); every event is a fresh IP. + if (!ips[0].is_set()) { + return; + } + if (!this->initialized_) { + this->setup_buffers_and_register_(register_rp2040); + this->initialized_ = true; + } else { + MDNS.notifyAPChange(); + } + this->start_polling_window_(); +} +#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/tests/components/mdns/common-enabled-ethernet.yaml b/tests/components/mdns/common-enabled-ethernet.yaml new file mode 100644 index 0000000000..bfa9321d43 --- /dev/null +++ b/tests/components/mdns/common-enabled-ethernet.yaml @@ -0,0 +1,23 @@ +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + 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" + +mdns: + disabled: false + services: + - service: _test_service + protocol: _tcp + port: 8888 + txt: + static_string: Anything diff --git a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml new file mode 100644 index 0000000000..f84a0bc276 --- /dev/null +++ b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common-enabled-ethernet.yaml From 9768380856ed1700155e44269d7394d9461c736c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 20:04:10 -0500 Subject: [PATCH 0813/2214] [api] Hoist memw out of socket ready check to once per main-loop iter (#15996) --- esphome/core/application.h | 13 +++++++++++++ esphome/core/lwip_fast_select.h | 30 ++++++++++++++---------------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 04e0f1138e..4a18714d0d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -9,6 +9,10 @@ #include #include "esphome/core/component.h" #include "esphome/core/defines.h" + +#if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS) +#include // for std::atomic_thread_fence in Application::loop() +#endif #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" @@ -580,6 +584,15 @@ inline ESPHOME_ALWAYS_INLINE Application::ComponentPhaseGuard::ComponentPhaseGua } inline void ESPHOME_ALWAYS_INLINE Application::loop() { +#if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS) + // Pairs with the TCP/IP thread's SYS_ARCH_UNPROTECT release on rcvevent so + // subsequent Socket::ready() checks in this iter observe the published state + // without a per-call memw. Wake is independent (xTaskNotifyGive/ + // ulTaskNotifyTake), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g. + // BK72xx) — that path keeps `volatile` in esphome_lwip_socket_has_data() + // instead. + std::atomic_thread_fence(std::memory_order_acquire); +#endif #ifdef USE_RUNTIME_STATS // Capture the start of the active (non-sleeping) portion of this iteration. // Used to derive main-loop overhead = active time − Σ(component time) − diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 3b5e449148..4ba2606d76 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -26,25 +26,23 @@ extern "C" { struct lwip_sock *esphome_lwip_get_sock(int fd); /// Check if a cached LwIP socket has data ready via unlocked hint read of rcvevent. -/// This avoids lwIP core lock contention between the main loop (CPU0) and -/// streaming/networking work (CPU1). Correctness is preserved because callers -/// already handle EWOULDBLOCK on nonblocking sockets — a stale hint simply causes -/// a harmless retry on the next loop iteration. In practice, stale reads have not -/// been observed across multi-day testing, but the design does not depend on that. -/// -/// The sock pointer must have been obtained from esphome_lwip_get_sock() and must -/// remain valid (caller owns socket lifetime — no concurrent close). -/// Hot path: inlined volatile 16-bit load — no function call overhead. -/// Uses offset-based access because lwip/priv/sockets_priv.h conflicts with C++. +/// On ESPHOME_THREAD_MULTI_ATOMICS builds, the caller must run on the main +/// loop task after Application::loop's per-iter std::atomic_thread_fence +/// (memory_order_acquire); that fence pairs with the TCP/IP thread's +/// SYS_ARCH_UNPROTECT release, so a plain load suffices and avoids the +/// per-call `memw` that volatile would emit on Xtensa under default +/// -mserialize-volatile. Without atomics (e.g. BK72xx), the fence is skipped +/// and the volatile load provides ordering on its own. +/// Stale reads are harmless either way: the hooked event_callback +/// xTaskNotifyGives on RCVPLUS, so the next iteration re-snapshots and +/// ulTaskNotifyTake never loses a wake. /// The offset and size are verified at compile time in lwip_fast_select.c. static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { - // Unlocked hint read — no lwIP core lock needed. - // volatile prevents the compiler from caching/reordering this cross-thread read. - // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a - // FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value - // is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on - // Xtensa/RISC-V/ARM and cannot produce torn values. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + return *(int16_t *) ((char *) sock + (int) ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET) > 0; +#else return *(volatile int16_t *) ((char *) sock + (int) ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET) > 0; +#endif } /// Hook a socket's netconn callback to notify the main loop task on receive events. From 1a57d9bc2fe16ad67342fefd5d7aa20e113858a0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:04:19 -0400 Subject: [PATCH 0814/2214] [sprinkler][pn532] Fix bugprone-unchecked-optional-access (#16102) --- esphome/components/pn532/pn532.cpp | 3 ++- esphome/components/sprinkler/sprinkler.cpp | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 199a44dacc..3017b78414 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -317,6 +317,7 @@ enum PN532ReadReady PN532::read_ready_(bool block) { if (!this->rd_start_time_.has_value()) { this->rd_start_time_ = millis(); } + const uint32_t rd_start_time = *this->rd_start_time_; while (true) { if (this->is_read_ready()) { @@ -324,7 +325,7 @@ enum PN532ReadReady PN532::read_ready_(bool block) { break; } - if (millis() - *this->rd_start_time_ > 100) { + if (millis() - rd_start_time > 100) { ESP_LOGV(TAG, "Timed out waiting for readiness from PN532!"); this->rd_ready_ = TIMEOUT; break; diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 0802cdec8e..e977c05c48 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -897,11 +897,12 @@ void Sprinkler::resume() { } if (this->paused_valve_.has_value() && (this->resume_duration_.has_value())) { + const size_t paused_valve = *this->paused_valve_; + const uint32_t resume_duration = *this->resume_duration_; // Resume only if valve has not been completed yet - if (!this->valve_cycle_complete_(this->paused_valve_.value())) { - ESP_LOGD(TAG, "Resuming valve %zu with %" PRIu32 " seconds remaining", this->paused_valve_.value_or(0), - this->resume_duration_.value_or(0)); - this->fsm_request_(this->paused_valve_.value(), this->resume_duration_.value()); + if (!this->valve_cycle_complete_(paused_valve)) { + ESP_LOGD(TAG, "Resuming valve %zu with %" PRIu32 " seconds remaining", paused_valve, resume_duration); + this->fsm_request_(paused_valve, resume_duration); } this->reset_resume(); } else { From 8af499b591fa3ed3a7693940ff6b59db0dbc3872 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 20:26:21 -0500 Subject: [PATCH 0815/2214] [api] Use custom deleter to fix incomplete-type error on macOS libc++ (#16050) --- esphome/components/api/api_server.cpp | 5 +++++ esphome/components/api/api_server.h | 10 ++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index c30bd2e612..6c26c4e187 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -30,6 +30,11 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c APIServer::APIServer() { global_api_server = this; } +// Custom deleter defined here so `delete` sees the complete APIConnection type. +// This prevents libc++ from emitting an "incomplete type" error when other +// translation units only have the forward declaration of APIConnection. +void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; } + void APIServer::socket_failed_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); this->destroy_socket_(); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index e662d78eba..6b575e536d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -193,7 +193,13 @@ class APIServer final : public Component, // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the // APIConnection but cannot reset/move the slot and break the count invariant. - using APIConnectionPtr = std::unique_ptr; + // Custom deleter is defined out-of-line in api_server.cpp so libc++ does not + // eagerly instantiate `delete static_cast(p)` here, where + // only the forward declaration of APIConnection is visible (incomplete type). + struct APIConnectionDeleter { + void operator()(APIConnection *p) const; + }; + using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { const APIConnectionPtr *begin_; const APIConnectionPtr *end_; @@ -292,7 +298,7 @@ class APIServer final : public Component, uint32_t last_connected_{0}; // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. - std::array, MAX_API_CONNECTIONS> clients_{}; + std::array clients_{}; // Vectors and strings (12 bytes each on 32-bit) // Shared proto write buffer for all connections. // Not pre-allocated: all send paths call prepare_first_message_buffer() which From 1363f661e6b3b1a8325e096b0e60172a863e104e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 20:26:25 -0500 Subject: [PATCH 0816/2214] [core] Inline ContinuationAction in If/While/RepeatAction (#16040) --- esphome/core/base_automation.h | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 17f937d10d..afd11c6867 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -273,18 +273,32 @@ template class WhileLoopContinuation : public Action { WhileAction *parent_; }; +// Wraps a ContinuationAction when Enabled, empty otherwise. +// Lets IfAction elide the else continuation when HasElse is false. +template struct OptionalContinuation { + ContinuationAction action; + explicit OptionalContinuation(Action *parent) : action(parent) {} +}; +template struct OptionalContinuation { + explicit OptionalContinuation(Action * /*parent*/) {} +}; + template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} + // Precondition: add_then/add_else must be called at most once per instance. + // Codegen always batches the full action list into a single call. Calling + // twice would re-append the same inline continuation pointer and form a + // self-loop in the next_ chain. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new ContinuationAction(this)); + this->then_.add_action(&this->then_continuation_); } void add_else(const std::initializer_list *> &actions) requires(HasElse) { this->else_.add_actions(actions); - this->else_.add_action(new ContinuationAction(this)); + this->else_.add_action(&this->else_continuation_.action); } void play_complex(const Ts &...x) override { @@ -316,17 +330,20 @@ template class IfAction : public Action { protected: Condition *condition_; ActionList then_; + ContinuationAction then_continuation_{this}; struct NoElse {}; [[no_unique_address]] std::conditional_t, NoElse> else_; + [[no_unique_address]] OptionalContinuation else_continuation_{this}; }; template class WhileAction : public Action { public: WhileAction(Condition *condition) : condition_(condition) {} + // Precondition: must be called at most once per instance (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new WhileLoopContinuation(this)); + this->then_.add_action(&this->loop_continuation_); } friend class WhileLoopContinuation; @@ -354,6 +371,7 @@ template class WhileAction : public Action { protected: Condition *condition_; ActionList then_; + WhileLoopContinuation loop_continuation_{this}; }; // Implementation of WhileLoopContinuation::play @@ -386,9 +404,10 @@ template class RepeatAction : public Action { public: TEMPLATABLE_VALUE(uint32_t, count) + // Precondition: must be called at most once per instance (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new RepeatLoopContinuation(this)); + this->then_.add_action(&this->loop_continuation_); } friend class RepeatLoopContinuation; @@ -409,6 +428,7 @@ template class RepeatAction : public Action { protected: ActionList then_; + RepeatLoopContinuation loop_continuation_{this}; }; // Implementation of RepeatLoopContinuation::play From 35cb28edfe86a407c877ea133745862a1130062d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 20:27:22 -0500 Subject: [PATCH 0817/2214] [output] Gate FloatOutput power scaling fields behind USE_OUTPUT_FLOAT_POWER_SCALING (#15998) --- esphome/components/output/__init__.py | 10 ++++- esphome/components/output/automation.h | 3 ++ esphome/components/output/float_output.cpp | 8 +++- esphome/components/output/float_output.h | 51 ++++++++++++++++++++-- esphome/core/defines.h | 1 + 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 36798f2d7f..4f6c8943f5 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -54,10 +54,16 @@ async def setup_output_platform_(obj, config): power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) cg.add(obj.set_power_supply(power_supply_)) if CONF_MAX_POWER in config: + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_max_power(config[CONF_MAX_POWER])) if CONF_MIN_POWER in config: + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_min_power(config[CONF_MIN_POWER])) - if CONF_ZERO_MEANS_ZERO in config: + # Only emit when zero_means_zero is actually enabled. The schema defaults to False + # so this key is always present; emitting unconditionally would force + # USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate. + if config.get(CONF_ZERO_MEANS_ZERO): + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_zero_means_zero(config[CONF_ZERO_MEANS_ZERO])) @@ -121,6 +127,7 @@ async def output_set_level_to_code(config, action_id, template_arg, args): synchronous=True, ) async def output_set_min_power_to_code(config, action_id, template_arg, args): + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_MIN_POWER], args, cg.float_) @@ -140,6 +147,7 @@ async def output_set_min_power_to_code(config, action_id, template_arg, args): synchronous=True, ) async def output_set_max_power_to_code(config, action_id, template_arg, args): + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_MAX_POWER], args, cg.float_) diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h index 3279378129..537226a143 100644 --- a/esphome/components/output/automation.h +++ b/esphome/components/output/automation.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" +#include "esphome/core/defines.h" #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" @@ -40,6 +41,7 @@ template class SetLevelAction : public Action { FloatOutput *output_; }; +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING template class SetMinPowerAction : public Action { public: SetMinPowerAction(FloatOutput *output) : output_(output) {} @@ -63,6 +65,7 @@ template class SetMaxPowerAction : public Action { protected: FloatOutput *output_; }; +#endif // USE_OUTPUT_FLOAT_POWER_SCALING } // namespace output } // namespace esphome diff --git a/esphome/components/output/float_output.cpp b/esphome/components/output/float_output.cpp index 46014e0903..35629c828a 100644 --- a/esphome/components/output/float_output.cpp +++ b/esphome/components/output/float_output.cpp @@ -7,13 +7,15 @@ namespace output { static const char *const TAG = "output.float"; +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING void FloatOutput::set_max_power(float max_power) { - this->max_power_ = clamp(max_power, this->min_power_, 1.0f); // Clamp to MIN>=MAX>=1.0 + this->max_power_ = clamp(max_power, this->min_power_, 1.0f); // Clamp to min_power <= max <= 1.0 } void FloatOutput::set_min_power(float min_power) { - this->min_power_ = clamp(min_power, 0.0f, this->max_power_); // Clamp to 0.0>=MIN>=MAX + this->min_power_ = clamp(min_power, 0.0f, this->max_power_); // Clamp to 0.0 <= min <= max_power } +#endif void FloatOutput::set_level(float state) { state = clamp(state, 0.0f, 1.0f); @@ -26,8 +28,10 @@ void FloatOutput::set_level(float state) { } #endif +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING if (state != 0.0f || !this->zero_means_zero_) // regardless of min_power_, 0.0 means off state = (state * (this->max_power_ - this->min_power_)) + this->min_power_; +#endif if (this->is_inverted()) state = 1.0f - state; diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 5225f88c66..3e1bd83968 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -1,11 +1,13 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "binary_output.h" namespace esphome { namespace output { +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING #define LOG_FLOAT_OUTPUT(this) \ LOG_BINARY_OUTPUT(this) \ if (this->max_power_ != 1.0f) { \ @@ -14,6 +16,9 @@ namespace output { if (this->min_power_ != 0.0f) { \ ESP_LOGCONFIG(TAG, " Min Power: %.1f%%", this->min_power_ * 100.0f); \ } +#else +#define LOG_FLOAT_OUTPUT(this) LOG_BINARY_OUTPUT(this) +#endif /** Base class for all output components that can output a variable level, like PWM. * @@ -22,14 +27,18 @@ namespace output { * makes using maths much easier and (in theory) supports all possible bit depths. * * If you want to create a FloatOutput yourself, you essentially just have to override write_state(float). - * That method will be called for you with inversion and max-min power and offset to min power already applied. + * That method will be called for you with inversion already applied. When USE_OUTPUT_FLOAT_POWER_SCALING is + * enabled (set automatically by Python codegen if any output uses min_power/max_power/zero_means_zero or the + * matching runtime actions), the value will additionally have max-min power scaling and offset to min_power + * applied; otherwise only inversion is applied. * * This interface is compatible with BinaryOutput (and will automatically convert the binary states to floating * point states for you). Additionally, this class provides a way for users to set a minimum and/or maximum power - * output + * output (gated on USE_OUTPUT_FLOAT_POWER_SCALING). */ class FloatOutput : public BinaryOutput { public: +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING /** Set the maximum power output of this component. * * All values are multiplied by max_power - min_power and offset to min_power to get the adjusted value. @@ -51,6 +60,32 @@ class FloatOutput : public BinaryOutput { * @param zero_means_zero True if a 0 state should mean 0 and not min_power. */ void set_zero_means_zero(bool zero_means_zero) { this->zero_means_zero_ = zero_means_zero; } +#else + // Compile-time guards for users calling these methods from lambdas (documented usage at + // https://esphome.io/components/output/#output-set_min_power_action). When power scaling + // is compiled out, these template stubs fail to compile with an actionable error pointing + // at the user's lambda. Templating on a default-false bool means static_assert only fires + // on instantiation (i.e. when the user actually calls the method), not on every parse. + template void set_max_power(float max_power) { + static_assert(_use_output_float_power_scaling, + "set_max_power() requires USE_OUTPUT_FLOAT_POWER_SCALING. " + "To enable it, add 'max_power: 100%' (or any value) to one output entry in your YAML — " + "the codegen will then keep the scaling fields. " + "See https://esphome.io/components/output/ for details."); + } + template void set_min_power(float min_power) { + static_assert(_use_output_float_power_scaling, + "set_min_power() requires USE_OUTPUT_FLOAT_POWER_SCALING. " + "To enable it, add 'min_power: 0%' (or any value) to one output entry in your YAML — " + "the codegen will then keep the scaling fields. " + "See https://esphome.io/components/output/ for details."); + } + template void set_zero_means_zero(bool zero_means_zero) { + static_assert(_use_output_float_power_scaling, + "set_zero_means_zero() requires USE_OUTPUT_FLOAT_POWER_SCALING. " + "To enable it, add 'zero_means_zero: true' to one output entry in your YAML."); + } +#endif /** Set the level of this float output, this is called from the front-end. * @@ -69,20 +104,30 @@ class FloatOutput : public BinaryOutput { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING /// Get the maximum power output. float get_max_power() const { return this->max_power_; } /// Get the minimum power output. float get_min_power() const { return this->min_power_; } +#else + /// Get the maximum power output. + float get_max_power() const { return 1.0f; } + + /// Get the minimum power output. + float get_min_power() const { return 0.0f; } +#endif protected: /// Implement BinarySensor's write_enabled; this should never be called. void write_state(bool state) override; virtual void write_state(float state) = 0; +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING float max_power_{1.0f}; float min_power_{0.0f}; - bool zero_means_zero_; + bool zero_means_zero_{false}; +#endif }; } // namespace output diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 592c8c46a2..99ec936c12 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -146,6 +146,7 @@ #define USE_NEXTION_WAVEFORM #define USE_NUMBER #define USE_OUTPUT +#define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP #define USE_QR_CODE From f05243bd9defaf5d1e665c8829af60f2512b4aa2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 20:48:35 -0500 Subject: [PATCH 0818/2214] [api] Add 48-bit MAC address varint fast path for BLE advertisements (#15988) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_options.proto | 6 + esphome/components/api/api_pb2.cpp | 4 +- esphome/components/api/api_pb2_service.cpp | 2 + esphome/components/api/proto.h | 34 +++++ script/api_protobuf/api_protobuf.py | 27 +++- script/build_helpers.py | 19 ++- .../components/api/test_proto_mac_varint.cpp | 123 ++++++++++++++++++ tests/components/json/__init__.py | 9 ++ 9 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 tests/components/api/test_proto_mac_varint.cpp create mode 100644 tests/components/json/__init__.py diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c0fd990eca..391efbd6eb 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1639,7 +1639,7 @@ message BluetoothLEAdvertisementResponse { message BluetoothLERawAdvertisement { option (inline_encode) = true; - uint64 address = 1 [(force) = true]; + uint64 address = 1 [(force) = true, (mac_address) = true]; sint32 rssi = 2 [(force) = true]; uint32 address_type = 3 [(max_value) = 4]; diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index d5d0b37e8d..ac9c4e59cc 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -110,4 +110,10 @@ extend google.protobuf.FieldOptions { // length varint calculations and direct byte writes, since the length // varint is guaranteed to be 1 byte. optional uint32 max_data_length = 50018; + + // mac_address: Field is a 48-bit MAC address stored in a uint64. + // Emits encode_varint_raw_48bit which has a 7-byte fast path that avoids + // the per-byte loop when the upper bits are non-zero (the common case + // for real MAC addresses, since OUIs occupy the top 24 bits). + optional bool mac_address = 50019 [default=false]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f6ceee2296..eb25bf7461 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2352,7 +2352,7 @@ BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO uint8_t *len_pos = pos; ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); if (sub_msg.address_type) { @@ -2373,7 +2373,7 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; size += 2; - size += ProtoSize::calc_uint64_force(1, sub_msg.address); + size += ProtoSize::calc_uint64_48bit_force(1, sub_msg.address); size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); size += sub_msg.address_type ? 2 : 0; size += 2 + sub_msg.data_len; diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 6ae2a3e369..0ba2961a13 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -21,6 +21,7 @@ void APIServerConnectionBase::log_receive_message_(const LogString *name) { } #endif +#ifdef USE_API void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { // Check authentication/connection requirements switch (msg_type) { @@ -706,5 +707,6 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } } +#endif // USE_API } // namespace esphome::api diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 8cac7fff3b..3ff65029e1 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -342,6 +342,32 @@ class ProtoEncode { } encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); } + /// Encode a 48-bit MAC address (stored in a uint64) as varint. + /// Real MAC addresses occupy the full 48 bits (OUI in upper 24), so the + /// fast path -- any non-zero bit in the top 6 of 48 -- emits exactly 7 bytes + /// with no per-byte branch. Falls back to the general loop otherwise. + /// Caller must guarantee value fits in 48 bits (checked in debug builds). + static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_48bit(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + uint64_t value) { +#ifdef ESPHOME_DEBUG_API + assert(value < (1ULL << (MAC_ADDRESS_SIZE * 8)) && "encode_varint_raw_48bit: value exceeds 48 bits"); +#endif + // 7-byte varint holds 49 bits (7 * 7), so a 48-bit value needs all 7 bytes + // whenever bit 42 or higher is set (i.e. value >= 1 << (48 - 6)). + if (value >= (1ULL << (MAC_ADDRESS_SIZE * 8 - 6))) [[likely]] { + PROTO_ENCODE_CHECK_BOUNDS(pos, 7); + pos[0] = static_cast(value | 0x80); + pos[1] = static_cast((value >> 7) | 0x80); + pos[2] = static_cast((value >> 14) | 0x80); + pos[3] = static_cast((value >> 21) | 0x80); + pos[4] = static_cast((value >> 28) | 0x80); + pos[5] = static_cast((value >> 35) | 0x80); + pos[6] = static_cast(value >> 42); + pos += 7; + return; + } + encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); + } static inline void ESPHOME_ALWAYS_INLINE encode_field_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t type) { encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, (field_id << 3) | type); @@ -817,6 +843,14 @@ class ProtoSize { static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_force(uint32_t field_id_size, uint64_t value) { return field_id_size + varint(value); } + /// 48-bit MAC address variant: matches encode_varint_raw_48bit's fast path. + /// When any of the top 6 of 48 bits is set the encoded varint is 7 bytes; + /// otherwise fall back to the general size calculation. + /// Caller must guarantee value fits in 48 bits (encoder asserts in debug). + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_48bit_force(uint32_t field_id_size, + uint64_t value) { + return field_id_size + (value >= (1ULL << (MAC_ADDRESS_SIZE * 8 - 6)) ? 7 : varint(value)); + } static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) { return len ? field_id_size + varint(static_cast(len)) + static_cast(len) : 0; } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index c10479a726..bf672d0567 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -184,6 +184,11 @@ class TypeInfo(ABC): """Check if this field should always be encoded (skip zero/empty check).""" return get_field_opt(self._field, pb.force, False) + @property + def mac_address(self) -> bool: + """Check if this uint64 field is a 48-bit MAC address (use 7-byte fast path).""" + return get_field_opt(self._field, pb.mac_address, False) + @property def max_value(self) -> int | None: """Get the max_value option for this field, or None if not set.""" @@ -665,8 +670,22 @@ class UInt64Type(VarintTypeMixin, TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: + if self.mac_address and force: + field_id_size = self.calculate_field_id_size() + return ( + f"size += ProtoSize::calc_uint64_48bit_force({field_id_size}, {name});" + ) return self._get_simple_size_calculation(name, force, "uint64") + @property + def RAW_ENCODE_MAP(self) -> dict[str, str]: # noqa: N802 + if self.mac_address: + return { + **TypeInfo.RAW_ENCODE_MAP, + "encode_uint64": "ProtoEncode::encode_varint_raw_48bit(pos, {value});", + } + return TypeInfo.RAW_ENCODE_MAP + def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -3558,8 +3577,13 @@ static const char *const TAG = "api.service"; # Generate read_message_ as APIConnection method (not base class) so the compiler # can devirtualize and inline the on_* handler calls within the same class. # APIConnection declares this method in api_connection.h. + # Guard with #ifdef USE_API since APIConnection itself is only defined when + # USE_API is set; without this, builds that compile this .cpp without + # USE_API (e.g. C++ unit tests for api dependencies) fail to find the + # class declaration. - out = "void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {\n" + out = "#ifdef USE_API\n" + out += "void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {\n" # Auth check block before dispatch switch out += " // Check authentication/connection requirements\n" @@ -3604,6 +3628,7 @@ static const char *const TAG = "api.service"; out += " break;\n" out += " }\n" out += "}\n" + out += "#endif // USE_API\n" cpp += out hpp += "};\n" diff --git a/script/build_helpers.py b/script/build_helpers.py index 1cfae51fca..4cf2f93fbb 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -324,8 +324,23 @@ def compile_and_get_binary( domain_list.append({CONF_PLATFORM: component}) # Skip "core" — it's a pseudo-component handled by the build # system, not a real loadable component (get_component returns None) - elif get_component(component_name) is not None: - config.setdefault(component_name, []) + elif (component := get_component(component_name)) is not None: + # MULTI_CONF components store their config as a list of dicts, + # everything else stores a single dict. Run the component's + # schema with {} so defaults get populated -- code paths like + # socket.FILTER_SOURCE_FILES expect a fully-populated mapping. + if component.multi_conf: + config.setdefault(component_name, []) + elif component_name not in config: + schema = component.config_schema + try: + config[component_name] = schema({}) if schema is not None else {} + except Exception: # noqa: BLE001 + # Schema requires explicit input we can't synthesize; fall + # back to an empty mapping so subscripting at least returns + # KeyError on missing keys rather than crashing on the + # wrong type. + config[component_name] = {} # Register platforms from the extra config (benchmark.yaml) so # USE_SENSOR, USE_LIGHT, etc. defines are emitted without needing diff --git a/tests/components/api/test_proto_mac_varint.cpp b/tests/components/api/test_proto_mac_varint.cpp new file mode 100644 index 0000000000..317a6fb9d6 --- /dev/null +++ b/tests/components/api/test_proto_mac_varint.cpp @@ -0,0 +1,123 @@ +#include + +#include +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// Generic varint decoder, used to verify the encoded bytes round-trip back to +// the original 48-bit MAC value, independent of the specialized encoder under +// test. +static uint64_t decode_varint(const uint8_t *buf, size_t len, size_t *consumed) { + uint64_t value = 0; + int shift = 0; + for (size_t i = 0; i < len; i++) { + value |= static_cast(buf[i] & 0x7F) << shift; + if ((buf[i] & 0x80) == 0) { + *consumed = i + 1; + return value; + } + shift += 7; + } + *consumed = 0; + return 0; +} + +// Reference encoder mirroring ProtoEncode::encode_varint_raw_64. +static size_t reference_encode(uint64_t value, uint8_t *out) { + uint8_t *p = out; + if (value < 128) { + *p++ = static_cast(value); + return p - out; + } + do { + *p++ = static_cast(value | 0x80); + value >>= 7; + } while (value > 0x7F); + *p++ = static_cast(value); + return p - out; +} + +// Encode `mac` via the 48-bit fast path and verify: +// - byte-identical output to the reference loop +// - encoded byte length matches `expected_bytes` +// - calc_uint64_48bit_force agrees on the size +// - the bytes round-trip through a generic varint decoder +static void verify_mac(uint64_t mac, size_t expected_bytes) { + ASSERT_LT(mac, 1ULL << 48) << "test fixture mac exceeds 48 bits"; + + uint8_t ref_buf[16] = {0}; + size_t ref_len = reference_encode(mac, ref_buf); + + APIBuffer api_buf; + api_buf.resize(16); + uint8_t *pos = api_buf.data(); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size(); +#endif + ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac); + size_t new_len = pos - api_buf.data(); + + EXPECT_EQ(new_len, expected_bytes) << "mac=0x" << std::hex << mac << std::dec; + EXPECT_EQ(ref_len, expected_bytes) << "reference disagrees on length for mac=0x" << std::hex << mac << std::dec; + + for (size_t i = 0; i < new_len; i++) { + EXPECT_EQ(api_buf.data()[i], ref_buf[i]) + << "byte " << i << " differs for mac=0x" << std::hex << mac << " (got 0x" << static_cast(api_buf.data()[i]) + << ", expected 0x" << static_cast(ref_buf[i]) << ")" << std::dec; + } + + size_t consumed = 0; + uint64_t decoded = decode_varint(api_buf.data(), new_len, &consumed); + EXPECT_EQ(consumed, new_len) << "decoder did not consume all bytes for mac=0x" << std::hex << mac << std::dec; + EXPECT_EQ(decoded, mac) << "round-trip mismatch for mac=0x" << std::hex << mac << std::dec; + + // Verify the size helper agrees. field_id_size = 1 (typical 1-byte tag). + uint32_t calc_size = ProtoSize::calc_uint64_48bit_force(1, mac); + EXPECT_EQ(calc_size, 1 + expected_bytes) + << "calc_uint64_48bit_force size mismatch for mac=0x" << std::hex << mac << std::dec; +} + +// Compute the canonical varint byte length for a value < 1<<48. +static size_t expected_varint_len(uint64_t v) { + if (v < (1ULL << 7)) + return 1; + if (v < (1ULL << 14)) + return 2; + if (v < (1ULL << 21)) + return 3; + if (v < (1ULL << 28)) + return 4; + if (v < (1ULL << 35)) + return 5; + if (v < (1ULL << 42)) + return 6; + return 7; +} + +// --- Specific MACs requested for verification --- + +TEST(ProtoMacVarint, AllZeros) { verify_mac(0x000000000000ULL, 1); } // 00:00:00:00:00:00 +TEST(ProtoMacVarint, FirstByteOnly) { verify_mac(0x110000000000ULL, 7); } // 11:00:00:00:00:00 +TEST(ProtoMacVarint, SecondByteOnly) { verify_mac(0x00AA00000000ULL, 6); } // 00:AA:00:00:00:00 +TEST(ProtoMacVarint, ThirdByteOnly) { verify_mac(0x0000BB000000ULL, 5); } // 00:00:BB:00:00:00 +TEST(ProtoMacVarint, FourthByteOnly) { verify_mac(0x000000CC0000ULL, 4); } // 00:00:00:CC:00:00 +TEST(ProtoMacVarint, FifthByteOnly) { verify_mac(0x00000000DD00ULL, 3); } // 00:00:00:00:DD:00 +TEST(ProtoMacVarint, SixthByteOnly) { verify_mac(0x0000000000EEULL, 2); } // 00:00:00:00:00:EE +TEST(ProtoMacVarint, AllOnes) { verify_mac(0xFFFFFFFFFFFFULL, 7); } // FF:FF:FF:FF:FF:FF + +// 100 deterministic-random 48-bit MACs to catch regressions across the space. +TEST(ProtoMacVarint, RandomSample) { + // NOLINTNEXTLINE(cert-msc32-c,cert-msc51-cpp) -- intentional fixed seed for reproducibility. + std::mt19937_64 rng(0xC0FFEE); + for (int i = 0; i < 100; i++) { + uint64_t mac = rng() & 0xFFFFFFFFFFFFULL; + verify_mac(mac, expected_varint_len(mac)); + } +} + +} // namespace esphome::api::testing diff --git a/tests/components/json/__init__.py b/tests/components/json/__init__.py new file mode 100644 index 0000000000..40ec1f996e --- /dev/null +++ b/tests/components/json/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # json's to_code calls cg.add_library("bblanchon/ArduinoJson", ...). C++ + # unit test builds that pull json in transitively (e.g. api) need that + # library registration to happen, otherwise json_util.cpp fails to find + # ArduinoJson.h. + manifest.enable_codegen() From d7b21a84a33a4cdae6db6f38dc4b0a06915c1373 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 20:49:51 -0500 Subject: [PATCH 0819/2214] [git] Make ref fetches and submodule updates shallow (#16014) --- esphome/git.py | 20 +++- tests/unit_tests/test_git.py | 190 +++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 5 deletions(-) diff --git a/esphome/git.py b/esphome/git.py index 4d6e14001a..0106f24845 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -128,7 +128,10 @@ def clone_or_update( # We need to fetch the PR branch first, otherwise git will complain # about missing objects _LOGGER.info("Fetching %s", ref) - run_git_command(["git", "fetch", "--", "origin", ref], git_dir=repo_dir) + run_git_command( + ["git", "fetch", "--depth=1", "--", "origin", ref], + git_dir=repo_dir, + ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir ) @@ -138,7 +141,8 @@ def clone_or_update( "Initializing submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1", "--"] + + submodules, git_dir=repo_dir, ) except GitException: @@ -179,8 +183,13 @@ def clone_or_update( git_dir=repo_dir, ) - # Fetch remote ref - cmd = ["git", "fetch", "--", "origin"] + # Fetch from the remote. --depth=1 keeps the clone shallow + # while still picking up new commits when the remote tip + # moves: a shallow fetch retrieves the current tip being + # fetched, whether that's an explicit ref or the remote's + # default branch, then reset --hard FETCH_HEAD updates the + # working tree to it. + cmd = ["git", "fetch", "--depth=1", "--", "origin"] if ref is not None: cmd.append(ref) run_git_command(cmd, git_dir=repo_dir) @@ -229,7 +238,8 @@ def clone_or_update( "Updating submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1", "--"] + + submodules, git_dir=repo_dir, ) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index dd7d26cb71..eab6bfc2cb 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -811,3 +811,193 @@ def test_clone_or_update_stale_clone_is_retried_after_cleanup( assert repo_dir.exists() assert call_count["clone"] == 2 assert call_count["fetch"] == 2 + + +def test_clone_with_ref_uses_shallow_fetch( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Clone with a ref should use --depth=1 on both clone and fetch.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "pull/123/head" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / ".git").mkdir(exist_ok=True) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update(url=url, ref=ref, refresh=None, domain=domain) + + call_list = mock_run_git_command.call_args_list + + clone_calls = [c for c in call_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert "--depth=1" in clone_calls[0][0][0] + + fetch_calls = [c for c in call_list if "fetch" in c[0][0]] + assert len(fetch_calls) == 1 + assert "--depth=1" in fetch_calls[0][0][0] + # Ref must still be passed so the requested commit/branch is fetched. + assert ref in fetch_calls[0][0][0] + + +def test_clone_with_submodules_uses_shallow_submodule_update( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Submodule init on a fresh clone should use --depth=1.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / ".git").mkdir(exist_ok=True) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update( + url=url, + ref=None, + refresh=None, + domain=domain, + submodules=["components/foo"], + ) + + submodule_calls = [ + c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0] + ] + assert len(submodule_calls) == 1 + cmd = submodule_calls[0][0][0] + assert "--depth=1" in cmd + assert "components/foo" in cmd + # The `--` terminator must precede the submodule paths so a path + # beginning with `-` cannot be parsed as an option. + assert cmd.index("--") < cmd.index("components/foo") + + +def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> None: + """The refresh-path fetch should use --depth=1.""" + 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) + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + fetch_calls = [c for c in mock_run_git_command.call_args_list if "fetch" in c[0][0]] + assert len(fetch_calls) == 1 + cmd = fetch_calls[0][0][0] + assert "--depth=1" in cmd + # Ref must still be in the refresh fetch so the right tip is updated. + assert cmd[-1] == ref + + +def test_refresh_submodule_update_is_shallow( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The refresh-path submodule update should use --depth=1.""" + 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) + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=None, + refresh=TimePeriodSeconds(days=1), + domain=domain, + submodules=["components/foo"], + ) + + submodule_calls = [ + c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0] + ] + assert len(submodule_calls) == 1 + cmd = submodule_calls[0][0][0] + assert "--depth=1" in cmd + assert "components/foo" in cmd + assert cmd.index("--") < cmd.index("components/foo") + + +def test_refresh_picks_up_new_remote_commits( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Shallow fetch must still pull new commits when the remote tip moves. + + Simulates a stale local repo at SHA "old" while the remote has advanced + to SHA "new". The refresh path must run fetch (with --depth=1) followed + by reset --hard FETCH_HEAD so the working tree advances to the new tip. + """ + 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) + + # rev-parse is called once before fetch to record the pre-update SHA. + rev_parse_calls = {"count": 0} + + 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": + rev_parse_calls["count"] += 1 + return "old_sha" + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + _, revert = git.clone_or_update( + url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + # Verify the refresh sequence: rev-parse -> stash -> fetch (depth=1) -> reset + call_list = mock_run_git_command.call_args_list + cmd_sequence = [_get_git_command_type(c[0][0]) for c in call_list] + assert cmd_sequence == ["rev-parse", "stash", "fetch", "reset"] + + fetch_cmd = call_list[2][0][0] + assert "--depth=1" in fetch_cmd + assert fetch_cmd[-1] == ref + + reset_cmd = call_list[3][0][0] + assert reset_cmd[-1] == "FETCH_HEAD" + + # revert callback should reset back to the recorded pre-update SHA. + assert revert is not None + revert() + assert mock_run_git_command.call_args_list[-1][0][0] == [ + "git", + "reset", + "--hard", + "old_sha", + ] From eec770d622571ed9f625f3a0e6b0457be0542d55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 20:52:09 -0500 Subject: [PATCH 0820/2214] [core] Use ETag in external_files cache to fix re-downloads from raw.githubusercontent.com (#16020) --- esphome/external_files.py | 76 +++++- tests/unit_tests/test_external_files.py | 345 +++++++++++++++++++++--- 2 files changed, 379 insertions(+), 42 deletions(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index b6f6149ebb..bd29dc93b1 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,14 +1,17 @@ from __future__ import annotations +import contextlib from datetime import UTC, datetime import logging +import os from pathlib import Path import requests import esphome.config_validation as cv from esphome.const import __version__ -from esphome.core import CORE, TimePeriodSeconds +from esphome.core import CORE, EsphomeError, TimePeriodSeconds +from esphome.helpers import write_file _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] @@ -16,12 +19,72 @@ CODEOWNERS = ["@landonr"] NETWORK_TIMEOUT = 30 IF_MODIFIED_SINCE = "If-Modified-Since" +IF_NONE_MATCH = "If-None-Match" +ETAG = "ETag" CACHE_CONTROL = "Cache-Control" CACHE_CONTROL_MAX_AGE = "max-age=" CONTENT_DISPOSITION = "content-disposition" TEMP_DIR = "temp" +def _etag_sidecar_path(local_file_path: Path) -> Path: + return local_file_path.parent / f".{local_file_path.name}.etag" + + +def _mtime_seconds(path: Path) -> int: + """Return `path`'s mtime as integer seconds. + + Whole seconds is the common-denominator resolution across all + filesystems we run on (FAT/exFAT 2s, NTFS 100ns, APFS/ext4 ns), so + comparisons survive setting+reading round-trips that would lose + sub-second precision on lower-resolution filesystems. + """ + return int(path.stat().st_mtime) + + +def _read_etag(local_file_path: Path) -> str | None: + """Return the cached ETag if its sidecar's mtime still matches the cache + file's. A mismatch means the cache file was modified out-of-band, so the + ETag no longer describes its contents -- delete the stale sidecar and + return None. + """ + etag_path = _etag_sidecar_path(local_file_path) + try: + if _mtime_seconds(etag_path) != _mtime_seconds(local_file_path): + _LOGGER.debug( + "ETag sidecar mtime mismatch at %s; treating as stale", + local_file_path, + ) + etag_path.unlink() + return None + return etag_path.read_text().strip() or None + except OSError: + return None + + +def _write_etag(local_file_path: Path, etag: str | None) -> None: + etag_path = _etag_sidecar_path(local_file_path) + if not etag: + # ETag persistence is best-effort; matches `_read_etag`'s tolerance. + with contextlib.suppress(OSError): + etag_path.unlink() + return + try: + write_file(etag_path, etag) + except EsphomeError as e: + _LOGGER.debug("Could not save ETag for %s: %s", local_file_path, e) + return + # Pin the sidecar's mtime to the cache file's mtime. _read_etag relies on + # this match to detect out-of-band edits to the cache file. + try: + file_mtime = _mtime_seconds(local_file_path) + os.utime(etag_path, (file_mtime, file_mtime)) + except OSError as e: + _LOGGER.debug( + "Could not sync ETag sidecar mtime for %s: %s", local_file_path, e + ) + + def has_remote_file_changed(url: str, local_file_path: Path) -> bool: if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) @@ -35,14 +98,17 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: IF_MODIFIED_SINCE: local_modification_time_str, CACHE_CONTROL: CACHE_CONTROL_MAX_AGE + "3600", } + if etag := _read_etag(local_file_path): + headers[IF_NONE_MATCH] = etag response = requests.head( url, headers=headers, timeout=NETWORK_TIMEOUT, allow_redirects=True ) _LOGGER.debug( - "has_remote_file_changed: File %s, Local modified %s, response code %d", + "has_remote_file_changed: File %s, Local modified %s, ETag %s, response code %d", local_file_path, local_modification_time_str, + etag or "", response.status_code, ) @@ -51,6 +117,8 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: "has_remote_file_changed: File not modified since %s", local_modification_time_str, ) + if (new_etag := response.headers.get(ETAG)) and new_etag != etag: + _write_etag(local_file_path, new_etag) return False _LOGGER.debug("has_remote_file_changed: File modified") return True @@ -112,7 +180,7 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by return path.read_bytes() raise cv.Invalid(f"Could not download from {url}: {e}") from e - path.parent.mkdir(parents=True, exist_ok=True) data = req.content - path.write_bytes(data) + write_file(path, data) + _write_etag(path, req.headers.get(ETAG)) return data diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 4b0826db04..f4d268abe0 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -1,5 +1,6 @@ """Tests for external_files.py functions.""" +import os from pathlib import Path import time from unittest.mock import MagicMock, patch @@ -9,7 +10,54 @@ import requests from esphome import external_files from esphome.config_validation import Invalid -from esphome.core import CORE, TimePeriod +from esphome.core import CORE, EsphomeError, TimePeriod + + +def _seed_etag(cache_file: Path, etag: str) -> Path: + """Write an ETag sidecar with its mtime synced to the cache file's mtime, + matching the invariant that `_write_etag` enforces in production. + """ + sidecar = external_files._etag_sidecar_path(cache_file) + sidecar.write_text(etag) + file_mtime = int(cache_file.stat().st_mtime) + os.utime(sidecar, (file_mtime, file_mtime)) + return sidecar + + +@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. + """ + with patch("esphome.external_files.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. + """ + with patch("esphome.external_files.requests.get") as m: + yield m + + +@pytest.fixture +def mock_has_remote_file_changed() -> MagicMock: + """Patch `external_files.has_remote_file_changed` so download tests can + control the conditional check independently from the GET path. + """ + with patch("esphome.external_files.has_remote_file_changed") as m: + yield m + + +@pytest.fixture +def mock_write_file() -> MagicMock: + """Patch `external_files.write_file` so atomic-write failures can be + injected without involving the real filesystem helper. + """ + with patch("esphome.external_files.write_file") as m: + yield m def test_compute_local_file_dir(setup_core: Path) -> None: @@ -88,9 +136,8 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None: assert result is False -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_not_modified( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed returns False when file not modified.""" test_file = setup_core / "cached.txt" @@ -98,23 +145,23 @@ def test_has_remote_file_changed_not_modified( mock_response = MagicMock() mock_response.status_code = 304 - mock_head.return_value = mock_response + mock_response.headers = {} + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" result = external_files.has_remote_file_changed(url, test_file) assert result is False - mock_head.assert_called_once() + mock_requests_head.assert_called_once() - call_args = mock_head.call_args + call_args = mock_requests_head.call_args headers = call_args[1]["headers"] assert external_files.IF_MODIFIED_SINCE in headers assert external_files.CACHE_CONTROL in headers -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_modified( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed returns True when file modified.""" test_file = setup_core / "cached.txt" @@ -122,7 +169,8 @@ def test_has_remote_file_changed_modified( mock_response = MagicMock() mock_response.status_code = 200 - mock_head.return_value = mock_response + mock_response.headers = {} + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" result = external_files.has_remote_file_changed(url, test_file) @@ -140,15 +188,16 @@ def test_has_remote_file_changed_no_local_file(setup_core: Path) -> None: assert result is True -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_network_error( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed returns False on network error when file is cached.""" test_file = setup_core / "cached.txt" test_file.write_text("cached content") - mock_head.side_effect = requests.exceptions.RequestException("Network error") + mock_requests_head.side_effect = requests.exceptions.RequestException( + "Network error" + ) url = "https://example.com/file.txt" result = external_files.has_remote_file_changed(url, test_file) @@ -156,9 +205,8 @@ def test_has_remote_file_changed_network_error( assert result is False -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_timeout( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed respects timeout.""" test_file = setup_core / "cached.txt" @@ -166,15 +214,176 @@ def test_has_remote_file_changed_timeout( mock_response = MagicMock() mock_response.status_code = 304 - mock_head.return_value = mock_response + mock_response.headers = {} + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" external_files.has_remote_file_changed(url, test_file) - call_args = mock_head.call_args + call_args = mock_requests_head.call_args assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT +def test_has_remote_file_changed_uses_etag( + mock_requests_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed sends If-None-Match when ETag is cached.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + _seed_etag(test_file, '"abc123"') + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {} + mock_requests_head.return_value = mock_response + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, test_file) + + assert result is False + headers = mock_requests_head.call_args[1]["headers"] + assert headers[external_files.IF_NONE_MATCH] == '"abc123"' + + +def test_has_remote_file_changed_no_etag_no_if_none_match( + mock_requests_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed omits If-None-Match when no ETag is cached.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {} + mock_requests_head.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.has_remote_file_changed(url, test_file) + + headers = mock_requests_head.call_args[1]["headers"] + assert external_files.IF_NONE_MATCH not in headers + + +def test_has_remote_file_changed_refreshes_etag_on_304( + mock_requests_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed updates the cached ETag when the 304 sends a new one.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + _seed_etag(test_file, '"old"') + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {external_files.ETAG: '"new"'} + mock_requests_head.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.has_remote_file_changed(url, test_file) + + assert external_files._etag_sidecar_path(test_file).read_text() == '"new"' + + +def test_has_remote_file_changed_ignores_etag_when_mtime_diverges( + mock_requests_head: MagicMock, setup_core: Path +) -> None: + """If the cache file was edited out-of-band (mtime no longer matches the + sidecar's), the cached ETag must not be used -- it no longer describes the + bytes on disk. + """ + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + sidecar = _seed_etag(test_file, '"abc123"') + + # Simulate an out-of-band edit to the cache file -- mtime advances by a + # full second (so it diverges at whole-second resolution) but the sidecar + # is left untouched, so the recorded ETag is now stale. + file_stat = test_file.stat() + os.utime(test_file, (file_stat.st_atime, file_stat.st_mtime + 1)) + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {} + mock_requests_head.return_value = mock_response + + external_files.has_remote_file_changed("https://example.com/file.txt", test_file) + + headers = mock_requests_head.call_args[1]["headers"] + assert external_files.IF_NONE_MATCH not in headers + # Stale sidecar should be removed so future calls don't keep paying the + # mtime-comparison cost on a known-bad sidecar. + assert not sidecar.exists() + + +def test_download_content_pins_etag_mtime_to_file_mtime( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """After a successful download, the sidecar's mtime must equal the cache + file's mtime so `_read_etag` accepts it on the next call. + """ + test_file = setup_core / "fresh.txt" + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {external_files.ETAG: '"deadbeef"'} + mock_response.raise_for_status = MagicMock() + mock_requests_get.return_value = mock_response + + external_files.download_content("https://example.com/file.txt", test_file) + + sidecar = external_files._etag_sidecar_path(test_file) + assert int(sidecar.stat().st_mtime) == int(test_file.stat().st_mtime) + + +def test_write_etag_swallows_write_file_failure( + mock_write_file: MagicMock, setup_core: Path, caplog: pytest.LogCaptureFixture +) -> None: + """If `write_file` raises, _write_etag must not propagate -- ETag + persistence is best-effort and a failure here must not abort the + surrounding download. + """ + cache_file = setup_core / "cached.txt" + cache_file.write_text("cached content") + mock_write_file.side_effect = EsphomeError("disk full") + + with caplog.at_level("DEBUG", logger="esphome.external_files"): + external_files._write_etag(cache_file, '"abc123"') + + assert "Could not save ETag" in caplog.text + # Sidecar wasn't created, since write_file was mocked to fail before + # reaching the os.utime step. + assert not external_files._etag_sidecar_path(cache_file).exists() + + +def test_write_etag_swallows_utime_failure( + setup_core: Path, caplog: pytest.LogCaptureFixture +) -> None: + """If `os.utime` raises while pinning the sidecar's mtime, _write_etag + must not propagate. The sidecar is still written; if its mtime later + fails to match the cache file, `_read_etag` will discard it on next + read. + """ + cache_file = setup_core / "cached.txt" + cache_file.write_text("cached content") + + with ( + patch( + "esphome.external_files.os.utime", + side_effect=PermissionError("nope"), + ), + caplog.at_level("DEBUG", logger="esphome.external_files"), + ): + external_files._write_etag(cache_file, '"abc123"') + + assert "Could not sync ETag sidecar mtime" in caplog.text + # write_file succeeded, so the sidecar exists with the new value even + # though we couldn't pin its mtime. + sidecar = external_files._etag_sidecar_path(cache_file) + assert sidecar.exists() + assert sidecar.read_text() == '"abc123"' + + def test_compute_local_file_dir_creates_parent_dirs(setup_core: Path) -> None: """Test compute_local_file_dir creates parent directories.""" domain = "level1/level2/level3/level4" @@ -200,10 +409,10 @@ def test_is_file_recent_handles_float_seconds(setup_core: Path) -> None: assert result is True -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_with_network_error_uses_cache( - mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, ) -> None: """Test download_content uses cached file when network fails.""" test_file = setup_core / "cached.txt" @@ -211,8 +420,10 @@ def test_download_content_with_network_error_uses_cache( test_file.write_bytes(cached_content) # Simulate file has changed, so it tries to download - mock_has_changed.return_value = True - mock_get.side_effect = requests.exceptions.RequestException("Network error") + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException( + "Network error" + ) url = "https://example.com/file.txt" result = external_files.download_content(url, test_file) @@ -220,17 +431,19 @@ def test_download_content_with_network_error_uses_cache( assert result == cached_content -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_with_network_error_no_cache_fails( - mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, ) -> None: """Test download_content raises error when network fails and no cache exists.""" test_file = setup_core / "nonexistent.txt" # Simulate file has changed (doesn't exist), so it tries to download - mock_has_changed.return_value = True - mock_get.side_effect = requests.exceptions.RequestException("Network error") + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException( + "Network error" + ) url = "https://example.com/file.txt" @@ -238,11 +451,9 @@ def test_download_content_with_network_error_no_cache_fails( external_files.download_content(url, test_file) -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_skip_external_update_uses_cache( - mock_has_changed: MagicMock, - mock_get: MagicMock, + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, setup_core: Path, ) -> None: """Test download_content skips network checks when CORE.skip_external_update is set.""" @@ -255,26 +466,25 @@ def test_download_content_skip_external_update_uses_cache( result = external_files.download_content(url, test_file) assert result == cached_content - mock_has_changed.assert_not_called() - mock_get.assert_not_called() + mock_has_remote_file_changed.assert_not_called() + mock_requests_get.assert_not_called() -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_skip_external_update_downloads_when_missing( - mock_has_changed: MagicMock, - mock_get: MagicMock, + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, setup_core: Path, ) -> None: """Test download_content still downloads when file is missing, even with skip_external_update.""" test_file = setup_core / "missing.txt" new_content = b"fresh content" - mock_has_changed.return_value = True + mock_has_remote_file_changed.return_value = True mock_response = MagicMock() mock_response.content = new_content + mock_response.headers = {} mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response + mock_requests_get.return_value = mock_response CORE.skip_external_update = True url = "https://example.com/file.txt" @@ -282,3 +492,62 @@ def test_download_content_skip_external_update_downloads_when_missing( assert result == new_content assert test_file.read_bytes() == new_content + + +def test_download_content_saves_etag( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Test download_content writes the ETag sidecar after a successful download.""" + test_file = setup_core / "fresh.txt" + new_content = b"fresh content" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = new_content + mock_response.headers = {external_files.ETAG: '"deadbeef"'} + mock_response.raise_for_status = MagicMock() + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.download_content(url, test_file) + + assert external_files._etag_sidecar_path(test_file).read_text() == '"deadbeef"' + + +def test_download_content_atomic_write_no_partial_on_failure( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + mock_write_file: MagicMock, + setup_core: Path, +) -> None: + """If `write_file` (the atomic-write helper) fails, the existing cache + file must remain untouched and no temp files may be left behind. Patching + `write_file` directly exercises the atomic-rename path -- a failure inside + `write_file` is the only reason the rename wouldn't have happened. + """ + from esphome.core import EsphomeError + + test_file = setup_core / "cached.txt" + original_content = b"original content" + test_file.write_bytes(original_content) + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"new content" + mock_response.headers = {} + mock_response.raise_for_status = MagicMock() + mock_requests_get.return_value = mock_response + + mock_write_file.side_effect = EsphomeError("disk full") + + with pytest.raises(EsphomeError, match="disk full"): + external_files.download_content("https://example.com/file.txt", test_file) + + # Original file is untouched -- write_file aborted before its rename step. + assert test_file.read_bytes() == original_content + # write_file is responsible for cleaning its own temp files; nothing leaks + # into the cache directory either way. + leftover_tmps = list(setup_core.glob("tmp*")) + assert leftover_tmps == [] From c3bd38af77ee103d7883a124e200c4fc14192ae6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:54:15 -0400 Subject: [PATCH 0821/2214] [feedback] Fix bugprone-unchecked-optional-access in start_direction_ (#16103) --- esphome/components/feedback/feedback_cover.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index 672e99949b..1139e6fa18 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -375,12 +375,10 @@ void FeedbackCover::start_direction_(CoverOperation dir) { // check if we have a wait time if (this->direction_change_waittime_.has_value() && dir != COVER_OPERATION_IDLE && this->current_operation != COVER_OPERATION_IDLE && dir != this->current_operation) { + const uint32_t waittime = *this->direction_change_waittime_; ESP_LOGD(TAG, "'%s' - Reversing direction.", this->name_.c_str()); this->start_direction_(COVER_OPERATION_IDLE); - - this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, *this->direction_change_waittime_, - [this, dir]() { this->start_direction_(dir); }); - + this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, waittime, [this, dir]() { this->start_direction_(dir); }); } else { this->set_current_operation_(dir, true); this->prev_command_trigger_ = trig; From 592486ae9aef284062b552cc2995691fedc72f46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 21:06:54 -0500 Subject: [PATCH 0822/2214] [analyze_memory] Attribute main.cpp setup()/loop() to esphome core (#16033) --- esphome/analyze_memory/__init__.py | 19 +++++--- .../test_source_file_attribution.py | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 tests/unit_tests/analyze_memory/test_source_file_attribution.py diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index f56d720ec2..33854ac289 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -793,8 +793,11 @@ class MemoryAnalyzer: """Scan ESPHome source object files to map extern "C" symbols to components. When no linker map file is available, this uses ``nm`` to scan ``.o`` files - under ``src/esphome/`` and build a symbol-to-component mapping. This catches - ``extern "C"`` functions and other symbols that lack C++ namespace prefixes. + under ``src/`` (including ``src/main.cpp.o`` and everything beneath + ``src/esphome/``) and build a symbol-to-component mapping. This catches + ``extern "C"`` functions, the ESPHome-generated ``setup()``/``loop()`` + entry points in ``main.cpp``, and other symbols that lack C++ namespace + prefixes. Skips scanning if ``_source_symbol_map`` was already populated by ``_parse_map_file()``. @@ -806,12 +809,12 @@ class MemoryAnalyzer: if obj_dir is None: return - # Find ESPHome source object files - esphome_src_dir = obj_dir / "src" / "esphome" - if not esphome_src_dir.is_dir(): + # Scan all ESPHome-owned source object files: src/main.cpp.o and src/esphome/... + src_dir = obj_dir / "src" + if not src_dir.is_dir(): return - obj_files = sorted(esphome_src_dir.rglob("*.o")) + obj_files = sorted(src_dir.rglob("*.o")) if not obj_files: return @@ -1064,6 +1067,10 @@ class MemoryAnalyzer: if component_name in self.external_components: return f"{_COMPONENT_PREFIX_EXTERNAL}{component_name}" + # ESPHome-generated entry point: src/main.cpp.o (contains setup()/loop()) + if len(parts) >= 2 and parts[-2:] == ("src", "main.cpp.o"): + return _COMPONENT_CORE + # ESPHome core: src/esphome/core/... or src/esphome/... if "core" in parts and "esphome" in parts: return _COMPONENT_CORE diff --git a/tests/unit_tests/analyze_memory/test_source_file_attribution.py b/tests/unit_tests/analyze_memory/test_source_file_attribution.py new file mode 100644 index 0000000000..2793f41bd0 --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_source_file_attribution.py @@ -0,0 +1,43 @@ +"""Tests for source-file-to-component attribution in memory analyzer.""" + +from unittest.mock import patch + +from esphome.analyze_memory import MemoryAnalyzer + + +def _make_analyzer(external_components: set[str] | None = None) -> MemoryAnalyzer: + """Create a MemoryAnalyzer with mocked dependencies.""" + with patch.object(MemoryAnalyzer, "__init__", lambda self, *a, **kw: None): + analyzer = MemoryAnalyzer.__new__(MemoryAnalyzer) + analyzer.external_components = external_components or set() + analyzer._lib_hash_to_name = {} + return analyzer + + +def test_source_file_to_component_main_cpp_relative() -> None: + """ESPHome-generated src/main.cpp.o (nm path form) attributes to core.""" + analyzer = _make_analyzer() + assert analyzer._source_file_to_component("src/main.cpp.o") == "[esphome]core" + + +def test_source_file_to_component_main_cpp_pioenvs_path() -> None: + """Linker map paths like .pioenvs//src/main.cpp.o attribute to core.""" + analyzer = _make_analyzer() + result = analyzer._source_file_to_component(".pioenvs/drivewaygate/src/main.cpp.o") + assert result == "[esphome]core" + + +def test_source_file_to_component_esphome_core() -> None: + """Sources under src/esphome/core/ attribute to core.""" + analyzer = _make_analyzer() + result = analyzer._source_file_to_component("src/esphome/core/application.cpp.o") + assert result == "[esphome]core" + + +def test_source_file_to_component_known_component() -> None: + """Known ESPHome components attribute to their component name.""" + analyzer = _make_analyzer() + result = analyzer._source_file_to_component( + "src/esphome/components/wifi/wifi_component.cpp.o" + ) + assert result == "[esphome]wifi" From d287876d8d94a56b1d53c22090e6358762a1b89a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 21:20:37 -0500 Subject: [PATCH 0823/2214] [light] Use bitmask template for LightControlAction unused fields (#16039) --- esphome/components/light/automation.h | 85 +++++++++++++------------- esphome/components/light/automation.py | 16 ++++- 2 files changed, 55 insertions(+), 46 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index f6a2ca52d4..eda30bd786 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -24,61 +24,60 @@ template class ToggleAction : public Action { LightState *state_; }; -template class LightControlAction : public Action { +// Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. +namespace light_control_detail { +template struct Empty {}; +} // namespace light_control_detail + +// X-macro: (type, field_name, bit_index). Order and bit values must match +// the FIELDS table in automation.py. +#define LIGHT_CONTROL_FIELDS(X) \ + X(ColorMode, color_mode, 0) \ + X(bool, state, 1) \ + X(uint32_t, transition_length, 2) \ + X(uint32_t, flash_length, 3) \ + X(float, brightness, 4) \ + X(float, color_brightness, 5) \ + X(float, red, 6) \ + X(float, green, 7) \ + X(float, blue, 8) \ + X(float, white, 9) \ + X(float, color_temperature, 10) \ + X(float, cold_white, 11) \ + X(float, warm_white, 12) \ + X(uint32_t, effect, 13) + +template class LightControlAction : public Action { public: explicit LightControlAction(LightState *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(ColorMode, color_mode) - TEMPLATABLE_VALUE(bool, state) - TEMPLATABLE_VALUE(uint32_t, transition_length) - TEMPLATABLE_VALUE(uint32_t, flash_length) - TEMPLATABLE_VALUE(float, brightness) - TEMPLATABLE_VALUE(float, color_brightness) - TEMPLATABLE_VALUE(float, red) - TEMPLATABLE_VALUE(float, green) - TEMPLATABLE_VALUE(float, blue) - TEMPLATABLE_VALUE(float, white) - TEMPLATABLE_VALUE(float, color_temperature) - TEMPLATABLE_VALUE(float, cold_white) - TEMPLATABLE_VALUE(float, warm_white) - TEMPLATABLE_VALUE(uint32_t, effect) +#define LIGHT_FIELD_SETTER_(type, name, idx) \ + template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } +#define LIGHT_FIELD_APPLY_(type, name, idx) \ + if constexpr ((Fields & (1 << (idx))) != 0) \ + call.set_##name(this->name##_.value(x...)); +#define LIGHT_FIELD_DECL_(type, name, idx) \ + [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableFn, \ + light_control_detail::Empty<(idx)>> \ + name##_{}; + + LIGHT_CONTROL_FIELDS(LIGHT_FIELD_SETTER_) void play(const Ts &...x) override { auto call = this->parent_->make_call(); - if (this->color_mode_.has_value()) - call.set_color_mode(this->color_mode_.value(x...)); - if (this->state_.has_value()) - call.set_state(this->state_.value(x...)); - if (this->transition_length_.has_value()) - call.set_transition_length(this->transition_length_.value(x...)); - if (this->flash_length_.has_value()) - call.set_flash_length(this->flash_length_.value(x...)); - if (this->brightness_.has_value()) - call.set_brightness(this->brightness_.value(x...)); - if (this->color_brightness_.has_value()) - call.set_color_brightness(this->color_brightness_.value(x...)); - if (this->red_.has_value()) - call.set_red(this->red_.value(x...)); - if (this->green_.has_value()) - call.set_green(this->green_.value(x...)); - if (this->blue_.has_value()) - call.set_blue(this->blue_.value(x...)); - if (this->white_.has_value()) - call.set_white(this->white_.value(x...)); - if (this->color_temperature_.has_value()) - call.set_color_temperature(this->color_temperature_.value(x...)); - if (this->cold_white_.has_value()) - call.set_cold_white(this->cold_white_.value(x...)); - if (this->warm_white_.has_value()) - call.set_warm_white(this->warm_white_.value(x...)); - if (this->effect_.has_value()) - call.set_effect(this->effect_.value(x...)); + LIGHT_CONTROL_FIELDS(LIGHT_FIELD_APPLY_) call.perform(); } protected: LightState *parent_; + LIGHT_CONTROL_FIELDS(LIGHT_FIELD_DECL_) + +#undef LIGHT_FIELD_DECL_ +#undef LIGHT_FIELD_APPLY_ +#undef LIGHT_FIELD_SETTER_ }; +#undef LIGHT_CONTROL_FIELDS template class DimRelativeAction : public Action { public: diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 46d37239e5..ea953e3199 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -178,9 +178,9 @@ def _resolve_effect_index(config: ConfigType) -> int: ) async def light_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - # (config_key, setter_name, c++ type) + # Order/bits must match LIGHT_CONTROL_FIELDS in automation.h. + # EFFECT has special handling below; setter=None skips the generic loop. FIELDS = ( (CONF_COLOR_MODE, "set_color_mode", ColorMode), (CONF_STATE, "set_state", cg.bool_), @@ -195,9 +195,19 @@ async def light_control_to_code(config, action_id, template_arg, args): (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), (CONF_COLD_WHITE, "set_cold_white", cg.float_), (CONF_WARM_WHITE, "set_warm_white", cg.float_), + (CONF_EFFECT, None, cg.uint32), ) + # Bitmask is passed as uint16_t in C++ — must stay within 16 bits. + assert len(FIELDS) <= 16, "LightControlAction Fields bitmask exceeds uint16_t" + + field_mask = sum(1 << i for i, (k, _, _) in enumerate(FIELDS) if k in config) + control_template_arg = cg.TemplateArguments( + cg.RawExpression(f"static_cast({field_mask})"), *template_arg + ) + var = cg.new_Pvariable(action_id, control_template_arg, paren) + for conf_key, setter, type_ in FIELDS: - if conf_key in config: + if conf_key in config and setter is not None: template_ = await cg.templatable(config[conf_key], args, type_) cg.add(getattr(var, setter)(template_)) From 0d150dc57e53363bd21fe15da0da785f019ff584 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 21:25:18 -0500 Subject: [PATCH 0824/2214] [light] Use constexpr template for ToggleAction transition_length (#16037) --- esphome/components/light/automation.h | 13 +++- esphome/components/light/automation.py | 6 +- .../fixtures/light_toggle_action.yaml | 37 ++++++++++ tests/integration/test_light_toggle_action.py | 67 +++++++++++++++++++ 4 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/light_toggle_action.yaml create mode 100644 tests/integration/test_light_toggle_action.py diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index eda30bd786..8aee9b5dad 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -8,20 +8,27 @@ namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; -template class ToggleAction : public Action { +template class ToggleAction : public Action { public: explicit ToggleAction(LightState *state) : state_(state) {} - TEMPLATABLE_VALUE(uint32_t, transition_length) + template void set_transition_length(V value) requires(HasTransitionLength) { + this->transition_length_ = value; + } void play(const Ts &...x) override { auto call = this->state_->toggle(); - call.set_transition_length(this->transition_length_.optional_value(x...)); + if constexpr (HasTransitionLength) { + call.set_transition_length(this->transition_length_.optional_value(x...)); + } call.perform(); } protected: LightState *state_; + struct NoTransition {}; + [[no_unique_address]] std::conditional_t, NoTransition> + transition_length_{}; }; // Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index ea953e3199..389a6c4f58 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -60,8 +60,10 @@ from .types import ( ) async def light_toggle_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_TRANSITION_LENGTH in config: + has_transition_length = CONF_TRANSITION_LENGTH in config + toggle_template_arg = cg.TemplateArguments(has_transition_length, *template_arg) + var = cg.new_Pvariable(action_id, toggle_template_arg, paren) + if has_transition_length: template_ = await cg.templatable( config[CONF_TRANSITION_LENGTH], args, cg.uint32 ) diff --git a/tests/integration/fixtures/light_toggle_action.yaml b/tests/integration/fixtures/light_toggle_action.yaml new file mode 100644 index 0000000000..265d8ba1ac --- /dev/null +++ b/tests/integration/fixtures/light_toggle_action.yaml @@ -0,0 +1,37 @@ +esphome: + name: light-toggle-action-test +host: +api: +logger: + level: DEBUG + +output: + - platform: template + id: test_out + type: float + write_action: + - lambda: "" + +light: + - platform: monochromatic + name: "Test Light" + id: test_light + output: test_out + default_transition_length: 0s + +button: + # Test 1: light.toggle without transition_length (HasTransitionLength=false) + - platform: template + id: btn_toggle + name: "Toggle" + on_press: + - light.toggle: test_light + + # Test 2: light.toggle with transition_length (HasTransitionLength=true) + - platform: template + id: btn_toggle_with_trans + name: "Toggle With Trans" + on_press: + - light.toggle: + id: test_light + transition_length: 0s diff --git a/tests/integration/test_light_toggle_action.py b/tests/integration/test_light_toggle_action.py new file mode 100644 index 0000000000..ffbadabb5b --- /dev/null +++ b/tests/integration/test_light_toggle_action.py @@ -0,0 +1,67 @@ +"""Integration test for light::ToggleAction. + +Tests both ToggleAction and +ToggleAction instantiations. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, LightInfo, LightState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_toggle_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test light.toggle with and without transition_length.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + light_state_future: asyncio.Future[LightState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, LightState) + and light_state_future is not None + and not light_state_future.done() + ): + light_state_future.set_result(state) + + async def wait_for_light_state(timeout: float = 5.0) -> LightState: + nonlocal light_state_future + light_state_future = loop.create_future() + try: + return await asyncio.wait_for(light_state_future, timeout) + finally: + light_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_light", LightInfo) + + async def press_and_wait(name: str) -> LightState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_light_state() + + # Test 1: toggle without transition_length flips off->on + state = await press_and_wait("Toggle") + assert state.state is True + + # Test 2: toggle with transition_length flips on->off + state = await press_and_wait("Toggle With Trans") + assert state.state is False + + # Test 3: toggle without transition_length flips off->on again + state = await press_and_wait("Toggle") + assert state.state is True From 5a33c5001555875d77b7fa228d0dc5785ce9e53f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 21:26:38 -0500 Subject: [PATCH 0825/2214] [light] Use constexpr template for DimRelativeAction transition_length (#16038) --- esphome/components/light/automation.h | 14 +++- esphome/components/light/automation.py | 6 +- tests/components/light/common.yaml | 4 ++ .../fixtures/light_dim_relative_action.yaml | 60 ++++++++++++++++ .../test_light_dim_relative_action.py | 72 +++++++++++++++++++ 5 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/light_dim_relative_action.yaml create mode 100644 tests/integration/test_light_dim_relative_action.py diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 8aee9b5dad..bc6fd84709 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -86,12 +86,15 @@ template class LightControlAction : public Acti }; #undef LIGHT_CONTROL_FIELDS -template class DimRelativeAction : public Action { +template class DimRelativeAction : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, relative_brightness) - TEMPLATABLE_VALUE(uint32_t, transition_length) + + template void set_transition_length(V value) requires(HasTransitionLength) { + this->transition_length_ = value; + } void play(const Ts &...x) override { auto call = this->parent_->make_call(); @@ -105,7 +108,9 @@ template class DimRelativeAction : public Action { call.set_state(new_brightness != 0.0f); call.set_brightness(new_brightness); - call.set_transition_length(this->transition_length_.optional_value(x...)); + if constexpr (HasTransitionLength) { + call.set_transition_length(this->transition_length_.optional_value(x...)); + } call.perform(); } @@ -121,6 +126,9 @@ template class DimRelativeAction : public Action { float min_brightness_{0.0}; float max_brightness_{1.0}; LimitMode limit_mode_{LimitMode::CLAMP}; + struct NoTransition {}; + [[no_unique_address]] std::conditional_t, NoTransition> + transition_length_{}; }; template class LightIsOnCondition : public Condition { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 389a6c4f58..c666c98e42 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -273,10 +273,12 @@ LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema( ) async def light_dim_relative_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) + has_transition_length = CONF_TRANSITION_LENGTH in config + dim_template_arg = cg.TemplateArguments(has_transition_length, *template_arg) + var = cg.new_Pvariable(action_id, dim_template_arg, paren) templ = await cg.templatable(config[CONF_RELATIVE_BRIGHTNESS], args, cg.float_) cg.add(var.set_relative_brightness(templ)) - if CONF_TRANSITION_LENGTH in config: + if has_transition_length: templ = await cg.templatable(config[CONF_TRANSITION_LENGTH], args, cg.uint32) cg.add(var.set_transition_length(templ)) if conf := config.get(CONF_BRIGHTNESS_LIMITS): diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index e1216e7b60..e58f7baee4 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -108,6 +108,10 @@ esphome: relative_brightness: 5% brightness_limits: max_brightness: 90% + - light.dim_relative: + id: test_monochromatic_light + relative_brightness: -5% + transition_length: 250ms - light.turn_on: id: test_addressable_transition brightness: 50% diff --git a/tests/integration/fixtures/light_dim_relative_action.yaml b/tests/integration/fixtures/light_dim_relative_action.yaml new file mode 100644 index 0000000000..b52cf65b89 --- /dev/null +++ b/tests/integration/fixtures/light_dim_relative_action.yaml @@ -0,0 +1,60 @@ +esphome: + name: light-dim-relative-action-test +host: +api: +logger: + level: DEBUG + +output: + - platform: template + id: test_out + type: float + write_action: + - lambda: "" + +light: + - platform: monochromatic + name: "Test Light" + id: test_light + output: test_out + default_transition_length: 0s + +button: + # Set up: turn on at 50% brightness + - platform: template + id: btn_setup + name: "Setup" + on_press: + - light.turn_on: + id: test_light + brightness: 50% + + # Test 1: dim_relative without transition_length (HasTransitionLength=false) + - platform: template + id: btn_dim_up + name: "Dim Up" + on_press: + - light.dim_relative: + id: test_light + relative_brightness: 25% + + # Test 2: dim_relative with transition_length (HasTransitionLength=true) + - platform: template + id: btn_dim_down + name: "Dim Down" + on_press: + - light.dim_relative: + id: test_light + relative_brightness: -10% + transition_length: 0s + + # Test 3: dim_relative with brightness limits + - platform: template + id: btn_dim_clamp + name: "Dim Clamp" + on_press: + - light.dim_relative: + id: test_light + relative_brightness: 50% + brightness_limits: + max_brightness: 80% diff --git a/tests/integration/test_light_dim_relative_action.py b/tests/integration/test_light_dim_relative_action.py new file mode 100644 index 0000000000..d5078f4409 --- /dev/null +++ b/tests/integration/test_light_dim_relative_action.py @@ -0,0 +1,72 @@ +"""Integration test for light::DimRelativeAction. + +Tests both DimRelativeAction and +DimRelativeAction instantiations. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, LightInfo, LightState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_dim_relative_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test light.dim_relative with and without transition_length.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + light_state_future: asyncio.Future[LightState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, LightState) + and light_state_future is not None + and not light_state_future.done() + ): + light_state_future.set_result(state) + + async def wait_for_light_state(timeout: float = 5.0) -> LightState: + nonlocal light_state_future + light_state_future = loop.create_future() + try: + return await asyncio.wait_for(light_state_future, timeout) + finally: + light_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_light", LightInfo) + + async def press_and_wait(name: str) -> LightState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_light_state() + + # Setup: turn on at 50% + state = await press_and_wait("Setup") + assert state.state is True + assert state.brightness == pytest.approx(0.5, abs=0.05) + + # Test 1: dim_relative without transition_length: 50% + 25% = 75% + state = await press_and_wait("Dim Up") + assert state.brightness == pytest.approx(0.75, abs=0.05) + + # Test 2: dim_relative with transition_length: 75% - 10% = 65% + state = await press_and_wait("Dim Down") + assert state.brightness == pytest.approx(0.65, abs=0.05) + + # Test 3: dim_relative with max_brightness limit: 65% + 50% clamped to 80% + state = await press_and_wait("Dim Clamp") + assert state.brightness == pytest.approx(0.80, abs=0.05) From 0d51a122d05c77156fcc4f6c9bd025aaf3cf4205 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 21:27:40 -0500 Subject: [PATCH 0826/2214] [cover] Add cover.control / cover.template.publish coverage to template tests (#16051) --- tests/components/template/common-base.yaml | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index ecc65de66c..daa6f53d42 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -293,6 +293,60 @@ cover: cover.is_closed: template_cover_with_triggers then: logger.log: Cover is closed + # Exercise cover.control / cover.template.publish action variants so they + # get build coverage in CI (and so memory-impact analysis on PRs that + # touch ControlAction / CoverPublishAction sees real instances). + - platform: template + name: "Template Cover Actions" + id: template_cover_actions + has_position: true + optimistic: true + open_action: + # CONF_STATE alias for the position bit + - cover.template.publish: + id: template_cover_actions + state: OPEN + - cover.template.publish: + id: template_cover_actions + position: 1.0 + - cover.template.publish: + id: template_cover_actions + current_operation: IDLE + close_action: + - cover.template.publish: + id: template_cover_actions + position: 0.0 + tilt: 0.0 + stop_action: + - cover.template.publish: + id: template_cover_actions + current_operation: IDLE + tilt_action: + - lambda: |- + id(template_cover_actions).tilt = tilt; + id(template_cover_actions).publish_state(); + on_idle: + # position only + - cover.control: + id: template_cover_actions + position: 50% + # tilt only + - cover.control: + id: template_cover_actions + tilt: 75% + # position + tilt + - cover.control: + id: template_cover_actions + position: 25% + tilt: 30% + # stop + - cover.control: + id: template_cover_actions + stop: true + # CONF_STATE alias for position + - cover.control: + id: template_cover_actions + state: OPEN number: - platform: template From 80251c54bedec89c5966972582aa09cd45ac2402 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 21:27:56 -0500 Subject: [PATCH 0827/2214] [climate] Add climate.control coverage to component tests via thermostat (#16052) --- tests/components/climate/common.yaml | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/components/climate/common.yaml b/tests/components/climate/common.yaml index ff405b68e2..2d35438afd 100644 --- a/tests/components/climate/common.yaml +++ b/tests/components/climate/common.yaml @@ -29,3 +29,59 @@ climate: heat_action: - switch.turn_on: climate_heater_switch - switch.turn_off: climate_cooler_switch + # Thermostat-based climate so climate.control: action variants get build + # coverage (bang_bang doesn't support fan modes, presets, etc.). Climate + # has no template platform, so thermostat is the right vehicle. + - platform: thermostat + id: climate_test_thermostat + name: Test Thermostat + sensor: climate_temperature_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + min_cooling_run_time: 300s + heat_action: + - logger.log: heating + idle_action: + - logger.log: idle + cool_action: + - logger.log: cooling + auto_mode: + - logger.log: auto + heat_cool_mode: + - logger.log: heat_cool + preset: + - name: Default + default_target_temperature_low: 18°C + default_target_temperature_high: 22°C + +button: + # Exercise the climate.control: action so ControlAction templates get + # build coverage. Various field combinations are tested. + - platform: template + name: "Climate Control Mode" + on_press: + - climate.control: + id: climate_test_thermostat + mode: HEAT + - platform: template + name: "Climate Control Mode And Temps" + on_press: + - climate.control: + id: climate_test_thermostat + mode: HEAT_COOL + target_temperature_low: 19.0°C + target_temperature_high: 23.0°C + - platform: template + name: "Climate Control Lambda Temp" + on_press: + - climate.control: + id: climate_test_thermostat + target_temperature_high: !lambda "return 21.5;" + - platform: template + name: "Climate Control Off" + on_press: + - climate.control: + id: climate_test_thermostat + mode: "OFF" From 2fce71e0d4004bb01f0c92581c60da70acd22dc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 21:31:07 -0500 Subject: [PATCH 0828/2214] [wifi] Add phy_mode option for ESP8266 (#16055) --- esphome/components/wifi/__init__.py | 16 +++++++++++++++ esphome/components/wifi/wifi_component.cpp | 15 ++++++++++++++ esphome/components/wifi/wifi_component.h | 20 +++++++++++++++++++ .../wifi/wifi_component_esp8266.cpp | 15 ++++++++++++++ esphome/core/defines.h | 1 + tests/components/wifi/test.esp8266-ard.yaml | 1 + 6 files changed, 68 insertions(+) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index bc4e177219..69544f3636 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -73,6 +73,7 @@ NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] CONF_SAVE = "save" CONF_BAND_MODE = "band_mode" CONF_MIN_AUTH_MODE = "min_auth_mode" +CONF_PHY_MODE = "phy_mode" CONF_POST_CONNECT_ROAMING = "post_connect_roaming" # Maximum number of WiFi networks that can be configured @@ -112,6 +113,14 @@ WIFI_MIN_AUTH_MODES = { "WPA3": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA3, } VALIDATE_WIFI_MIN_AUTH_MODE = cv.enum(WIFI_MIN_AUTH_MODES, upper=True) + +WiFi8266PhyMode = wifi_ns.enum("WiFi8266PhyMode") +WIFI_8266_PHY_MODES = { + "AUTO": WiFi8266PhyMode.WIFI_8266_PHY_MODE_AUTO, + "11B": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11B, + "11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G, + "11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N, +} WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) @@ -406,6 +415,10 @@ CONFIG_SCHEMA = cv.All( cv.only_on_esp32, only_on_variant(supported=[const.VARIANT_ESP32C5]), ), + cv.Optional(CONF_PHY_MODE): cv.All( + cv.enum(WIFI_8266_PHY_MODES, upper=True), + cv.only_on_esp8266, + ), cv.Optional(CONF_PASSIVE_SCAN, default=False): cv.boolean, cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_POST_CONNECT_ROAMING, default=True): cv.boolean, @@ -569,6 +582,9 @@ async def to_code(config): if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) + if CONF_PHY_MODE in config: + cg.add_define("USE_WIFI_PHY_MODE") + cg.add(var.set_phy_mode(config[CONF_PHY_MODE])) elif CORE.is_rp2040: cg.add_library("WiFi", None) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 1da2d630c1..edfb93bba2 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -309,6 +309,18 @@ bool CompactString::operator==(const StringRef &other) const { /// └──────────────────────────────────────────────────────────────────────┘ #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO +#ifdef USE_WIFI_PHY_MODE +// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) +static const LogString *phy_mode_to_log_string(WiFi8266PhyMode mode) { + if (mode == WIFI_8266_PHY_MODE_11B) + return LOG_STR("11B"); + if (mode == WIFI_8266_PHY_MODE_11G) + return LOG_STR("11G"); + if (mode == WIFI_8266_PHY_MODE_11N) + return LOG_STR("11N"); + return LOG_STR("Auto"); +} +#endif // Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { if (phase == WiFiRetryPhase::INITIAL_CONNECT) @@ -1535,6 +1547,9 @@ void WiFiComponent::dump_config() { break; } ESP_LOGCONFIG(TAG, " Band Mode: %s", band_mode_s); +#endif +#ifdef USE_WIFI_PHY_MODE + ESP_LOGCONFIG(TAG, " PHY Mode: %s", LOG_STR_ARG(phy_mode_to_log_string(this->phy_mode_))); #endif if (this->is_connected()) { this->print_connect_params_(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 53fb0728fb..0437267a1f 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -345,6 +345,17 @@ enum WifiMinAuthMode : uint8_t { WIFI_MIN_AUTH_MODE_WPA3, }; +#ifdef USE_WIFI_PHY_MODE +// Values 1-3 match ESP8266 SDK phy_mode_t (PHY_MODE_11B=1, PHY_MODE_11G=2, PHY_MODE_11N=3). +// AUTO leaves the SDK at its default (no wifi_set_phy_mode() call). +enum WiFi8266PhyMode : uint8_t { + WIFI_8266_PHY_MODE_AUTO = 0, + WIFI_8266_PHY_MODE_11B = 1, + WIFI_8266_PHY_MODE_11G = 2, + WIFI_8266_PHY_MODE_11N = 3, +}; +#endif + #ifdef USE_ESP32 struct IDFWiFiEvent; #endif @@ -455,6 +466,9 @@ class WiFiComponent final : public Component { #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) void set_band_mode(wifi_band_mode_t band_mode) { this->band_mode_ = band_mode; } #endif +#ifdef USE_WIFI_PHY_MODE + void set_phy_mode(WiFi8266PhyMode phy_mode) { this->phy_mode_ = phy_mode; } +#endif void set_passive_scan(bool passive); @@ -672,6 +686,9 @@ class WiFiComponent final : public Component { bool wifi_apply_power_save_(); #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) bool wifi_apply_band_mode_(); +#endif +#ifdef USE_WIFI_PHY_MODE + bool wifi_apply_phy_mode_(); #endif bool wifi_sta_ip_config_(const optional &manual_ip); bool wifi_apply_hostname_(); @@ -810,6 +827,9 @@ class WiFiComponent final : public Component { WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE}; #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) wifi_band_mode_t band_mode_{WIFI_BAND_MODE_AUTO}; +#endif +#ifdef USE_WIFI_PHY_MODE + WiFi8266PhyMode phy_mode_{WIFI_8266_PHY_MODE_AUTO}; #endif WifiMinAuthMode min_auth_mode_{WIFI_MIN_AUTH_MODE_WPA2}; WiFiRetryPhase retry_phase_{WiFiRetryPhase::INITIAL_CONNECT}; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 402ca051cd..717d542fbe 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -621,10 +621,25 @@ bool WiFiComponent::wifi_sta_pre_setup_() { ESP_LOGV(TAG, "Disabling Auto-Connect failed"); } +#ifdef USE_WIFI_PHY_MODE + if (!this->wifi_apply_phy_mode_()) { + ESP_LOGV(TAG, "Setting PHY Mode failed"); + } +#endif + delay(10); return true; } +#ifdef USE_WIFI_PHY_MODE +bool WiFiComponent::wifi_apply_phy_mode_() { + if (this->phy_mode_ == WIFI_8266_PHY_MODE_AUTO) + return true; + // Values of WiFi8266PhyMode are aligned with the SDK's phy_mode_t enum. + return wifi_set_phy_mode(static_cast(this->phy_mode_)); +} +#endif + void WiFiComponent::wifi_pre_setup_() { wifi_set_event_handler_cb(&WiFiComponent::wifi_event_callback); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 99ec936c12..93f4307e12 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -297,6 +297,7 @@ #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT +#define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS #define USE_WIFI_CONNECT_STATE_LISTENERS diff --git a/tests/components/wifi/test.esp8266-ard.yaml b/tests/components/wifi/test.esp8266-ard.yaml index 709a639ad6..ffeec136d3 100644 --- a/tests/components/wifi/test.esp8266-ard.yaml +++ b/tests/components/wifi/test.esp8266-ard.yaml @@ -1,6 +1,7 @@ wifi: min_auth_mode: WPA2 post_connect_roaming: true + phy_mode: 11G packages: - !include common.yaml From 49c7a6928e8a80566d7dfac957b868578a7bca73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 21:32:13 -0500 Subject: [PATCH 0829/2214] [script] Fix cpp_unit_test crash for non-MULTI_CONF platform components (#16104) --- script/build_helpers.py | 79 ++++++++++----- tests/script/test_test_helpers.py | 158 ++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 25 deletions(-) diff --git a/script/build_helpers.py b/script/build_helpers.py index 4cf2f93fbb..0e0e8170a0 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -57,6 +57,59 @@ def hash_components(components: list[str]) -> str: return hashlib.sha256(key.encode()).hexdigest()[:16] +def populate_dependency_config( + config: dict, + component_names: list[str], + *, + get_component_fn: Callable[[str], object | None] = get_component, + register_platform_fn: Callable[[str], None] | None = None, +) -> None: + """Populate ``config`` with empty entries for transitive dependencies. + + For every name in ``component_names``: + + * ``domain.platform`` form (e.g. ``sensor.gpio``) appends + ``{platform: }`` to ``config[domain]``, creating the list if needed. + * Bare components are looked up via ``get_component_fn``. Platform + components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are + initialised as ``[]`` so the sibling ``domain.platform`` branch can + ``append`` into them. Everything else is populated by running the + component's schema with ``{}`` so defaults exist; if the schema requires + explicit input, an empty ``{}`` is used as a fallback. + + Platform components must always be a list here even when no + ``domain.platform`` entry follows, because the ``domain.platform`` branch + does ``config.setdefault(domain, []).append(...)`` and would crash on a + leftover dict. + """ + if register_platform_fn is None: + register_platform_fn = CORE.testing_ensure_platform_registered + for component_name in component_names: + if "." in component_name: + domain, component = component_name.split(".", maxsplit=1) + domain_list = config.setdefault(domain, []) + register_platform_fn(domain) + domain_list.append({CONF_PLATFORM: component}) + continue + # Skip "core" — it's a pseudo-component handled by the build + # system, not a real loadable component (get_component returns None) + component = get_component_fn(component_name) + if component is None: + continue + if component.multi_conf or component.is_platform_component: + config.setdefault(component_name, []) + elif component_name not in config: + schema = component.config_schema + try: + config[component_name] = schema({}) if schema is not None else {} + except Exception: # noqa: BLE001 + # Schema requires explicit input we can't synthesize; fall + # back to an empty mapping so subscripting at least returns + # KeyError on missing keys rather than crashing on the + # wrong type. + config[component_name] = {} + + def filter_components_with_files(components: list[str], tests_dir: Path) -> list[str]: """Filter out components that do not have .cpp or .h files in the tests dir. @@ -316,31 +369,7 @@ def compile_and_get_binary( # Add remaining components and dependencies to the configuration after # validation, so their source files are included in the build. - for component_name in components_with_dependencies: - if "." in component_name: - domain, component = component_name.split(".", maxsplit=1) - domain_list = config.setdefault(domain, []) - CORE.testing_ensure_platform_registered(domain) - domain_list.append({CONF_PLATFORM: component}) - # Skip "core" — it's a pseudo-component handled by the build - # system, not a real loadable component (get_component returns None) - elif (component := get_component(component_name)) is not None: - # MULTI_CONF components store their config as a list of dicts, - # everything else stores a single dict. Run the component's - # schema with {} so defaults get populated -- code paths like - # socket.FILTER_SOURCE_FILES expect a fully-populated mapping. - if component.multi_conf: - config.setdefault(component_name, []) - elif component_name not in config: - schema = component.config_schema - try: - config[component_name] = schema({}) if schema is not None else {} - except Exception: # noqa: BLE001 - # Schema requires explicit input we can't synthesize; fall - # back to an empty mapping so subscripting at least returns - # KeyError on missing keys rather than crashing on the - # wrong type. - config[component_name] = {} + populate_dependency_config(config, components_with_dependencies) # Register platforms from the extra config (benchmark.yaml) so # USE_SENSOR, USE_LIGHT, etc. defines are emitted without needing diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index 467940fc33..3149712563 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -258,3 +258,161 @@ def test_load_wraps_platform_component(tmp_path: Path) -> None: assert key == "bthome.sensor" assert isinstance(installed, ComponentManifestOverride) assert installed.to_code is None + + +# --------------------------------------------------------------------------- +# populate_dependency_config +# --------------------------------------------------------------------------- + + +def _make_component_stub( + *, + multi_conf: bool = False, + is_platform_component: bool = False, + config_schema=None, +) -> MagicMock: + stub = MagicMock() + stub.multi_conf = multi_conf + stub.is_platform_component = is_platform_component + stub.config_schema = config_schema + return stub + + +def test_populate_platform_component_listed_alone_uses_list() -> None: + """Regression: a platform component (sensor) with no `sensor.x` siblings + must land as `[]` in config. Previously it was populated as a dict via + `schema({})`, which then crashed the sibling `domain.platform` branch + when later dependencies tried `config.setdefault('sensor', []).append(...)`. + """ + sensor = _make_component_stub(is_platform_component=True) + config: dict = {} + + build_helpers.populate_dependency_config( + config, + ["sensor"], + get_component_fn=lambda name: sensor if name == "sensor" else None, + register_platform_fn=lambda _: None, + ) + + assert config["sensor"] == [] + + +def test_populate_platform_component_then_platform_entry() -> None: + """When `sensor` is processed before `sensor.gpio` (sorted order), + the bare-component branch must leave `config['sensor']` as a list so + the platform-entry branch can append into it. + """ + sensor = _make_component_stub(is_platform_component=True) + gpio = _make_component_stub() # the bare `gpio` component + components: dict[str, object] = {"sensor": sensor, "gpio": gpio} + config: dict = {} + + build_helpers.populate_dependency_config( + config, + ["gpio", "sensor", "sensor.gpio"], + get_component_fn=components.get, + register_platform_fn=lambda _: None, + ) + + assert config["sensor"] == [{"platform": "gpio"}] + + +def test_populate_multi_conf_component_uses_list() -> None: + multi = _make_component_stub(multi_conf=True) + config: dict = {} + + build_helpers.populate_dependency_config( + config, + ["multi"], + get_component_fn=lambda name: multi if name == "multi" else None, + register_platform_fn=lambda _: None, + ) + + assert config["multi"] == [] + + +def test_populate_plain_component_uses_schema_defaults() -> None: + schema = MagicMock(return_value={"default_key": 42}) + plain = _make_component_stub(config_schema=schema) + config: dict = {} + + build_helpers.populate_dependency_config( + config, + ["plain"], + get_component_fn=lambda name: plain if name == "plain" else None, + register_platform_fn=lambda _: None, + ) + + schema.assert_called_once_with({}) + assert config["plain"] == {"default_key": 42} + + +def test_populate_plain_component_falls_back_when_schema_raises() -> None: + def picky_schema(_): + raise ValueError("required field missing") + + plain = _make_component_stub(config_schema=picky_schema) + config: dict = {} + + build_helpers.populate_dependency_config( + config, + ["plain"], + get_component_fn=lambda name: plain if name == "plain" else None, + register_platform_fn=lambda _: None, + ) + + assert config["plain"] == {} + + +def test_populate_skips_unresolvable_pseudo_components() -> None: + """`core` and other names that get_component returns None for are skipped + silently without inserting anything into the config. + """ + config: dict = {} + + build_helpers.populate_dependency_config( + config, + ["core"], + get_component_fn=lambda _: None, + register_platform_fn=lambda _: None, + ) + + assert config == {} + + +def test_populate_preserves_existing_plain_component_config() -> None: + """If a plain component already has a config entry (e.g. from the user's + YAML), the schema-defaults branch must not overwrite it. + """ + schema = MagicMock() + plain = _make_component_stub(config_schema=schema) + config: dict = {"plain": {"user_key": "set_by_user"}} + + build_helpers.populate_dependency_config( + config, + ["plain"], + get_component_fn=lambda name: plain if name == "plain" else None, + register_platform_fn=lambda _: None, + ) + + schema.assert_not_called() + assert config["plain"] == {"user_key": "set_by_user"} + + +def test_populate_registers_platform_for_platform_entry() -> None: + """Each `domain.platform` entry triggers register_platform_fn(domain) so + USE_ defines get emitted later in the build pipeline. + """ + registered: list[str] = [] + config: dict = {} + + build_helpers.populate_dependency_config( + config, + ["sensor.gpio", "binary_sensor.gpio"], + get_component_fn=lambda _: None, + register_platform_fn=registered.append, + ) + + assert registered == ["sensor", "binary_sensor"] + assert config["sensor"] == [{"platform": "gpio"}] + assert config["binary_sensor"] == [{"platform": "gpio"}] From 8ceada8d04a3b2fa9a428ce3ad5c7e68e7311404 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 21:32:30 -0500 Subject: [PATCH 0830/2214] [core] Download external_files in parallel (#16021) --- esphome/components/audio_file/__init__.py | 18 +- .../speaker/media_player/__init__.py | 24 +-- esphome/external_files.py | 98 ++++++++- tests/unit_tests/test_external_files.py | 187 +++++++++++++++++- 4 files changed, 295 insertions(+), 32 deletions(-) diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index bb1ce257db..88be6db168 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from functools import partial import hashlib import logging from pathlib import Path @@ -19,7 +20,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, HexInt from esphome.cpp_generator import MockObj -from esphome.external_files import download_content +from esphome.external_files import download_web_files_in_config from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -63,15 +64,6 @@ def _compute_local_file_path(value: ConfigType) -> Path: return base_dir / key -def _download_web_file(value: ConfigType) -> ConfigType: - url = value[CONF_URL] - path = _compute_local_file_path(value) - - download_content(url, path) - _LOGGER.debug("download_web_file: path=%s", path) - return value - - def _file_schema(value: ConfigType | str) -> ConfigType: if isinstance(value, str): return _validate_file_shorthand(value) @@ -142,11 +134,10 @@ LOCAL_SCHEMA = cv.Schema( } ) -WEB_SCHEMA = cv.All( +WEB_SCHEMA = cv.Schema( { cv.Required(CONF_URL): cv.url, - }, - _download_web_file, + } ) @@ -209,6 +200,7 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType] CONFIG_SCHEMA = cv.All( cv.only_on_esp32, cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), + partial(download_web_files_in_config, path_for=_compute_local_file_path), _validate_supported_local_file, ) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index abfd599808..fbc83ef12f 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -1,5 +1,6 @@ """Speaker Media Player Setup.""" +from functools import partial import hashlib import logging from pathlib import Path @@ -32,7 +33,7 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.external_files import download_content +from esphome.external_files import download_web_files_in_config _LOGGER = logging.getLogger(__name__) @@ -92,15 +93,6 @@ def _compute_local_file_path(value: dict) -> Path: return base_dir / key -def _download_web_file(value): - url = value[CONF_URL] - path = _compute_local_file_path(value) - - download_content(url, path) - _LOGGER.debug("download_web_file: path=%s", path) - return value - - _PURPOSE_MAP = { "MEDIA": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], "ANNOUNCEMENT": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], @@ -229,11 +221,10 @@ LOCAL_SCHEMA = cv.Schema( } ) -WEB_SCHEMA = cv.All( +WEB_SCHEMA = cv.Schema( { cv.Required(CONF_URL): cv.url, - }, - _download_web_file, + } ) @@ -285,7 +276,12 @@ CONFIG_SCHEMA = cv.All( ), # Remove before 2026.10.0 cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), - cv.Optional(CONF_FILES): cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), + cv.Optional(CONF_FILES): cv.All( + cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), + partial( + download_web_files_in_config, path_for=_compute_local_file_path + ), + ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( cv.boolean, cv.requires_component(psram.DOMAIN) ), diff --git a/esphome/external_files.py b/esphome/external_files.py index bd29dc93b1..fbc261f8e0 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Callable, Iterable +from concurrent.futures import ThreadPoolExecutor import contextlib from datetime import UTC, datetime import logging @@ -9,9 +11,10 @@ from pathlib import Path import requests import esphome.config_validation as cv -from esphome.const import __version__ +from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds from esphome.helpers import write_file +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] @@ -85,7 +88,9 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: ) -def has_remote_file_changed(url: str, local_file_path: Path) -> bool: +def has_remote_file_changed( + url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT +) -> bool: if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) try: @@ -101,7 +106,7 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: if etag := _read_etag(local_file_path): headers[IF_NONE_MATCH] = etag response = requests.head( - url, headers=headers, timeout=NETWORK_TIMEOUT, allow_redirects=True + url, headers=headers, timeout=timeout, allow_redirects=True ) _LOGGER.debug( @@ -153,7 +158,7 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) return path.read_bytes() - if not has_remote_file_changed(url, path): + if not has_remote_file_changed(url, path, timeout): _LOGGER.debug("Remote file has not changed %s", url) return path.read_bytes() @@ -184,3 +189,88 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by write_file(path, data) _write_etag(path, req.headers.get(ETAG)) return data + + +# Cap concurrent connections so a config with hundreds of remote files doesn't +# open hundreds of sockets at once. 8 matches the requests connection-pool +# default and the per-host connection limit browsers use, which keeps us +# polite to the upstream host while still cutting wall time roughly 8x for +# typical configs (a couple dozen files). +DEFAULT_DOWNLOAD_WORKERS = 8 + + +def download_content_many( + items: Iterable[tuple[str, Path]], + timeout: int = NETWORK_TIMEOUT, + max_workers: int = DEFAULT_DOWNLOAD_WORKERS, +) -> None: + """Run `download_content` for each (url, path) pair concurrently. + + 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. + """ + seen: dict[Path, str] = {path: url for url, path in items} + if not seen: + return + if len(seen) == 1: + path, url = next(iter(seen.items())) + download_content(url, path, timeout) + 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))) + errors: list[cv.Invalid] = [] + with ThreadPoolExecutor(max_workers=workers) as ex: + futures = [ex.submit(_download_one, item) for item in seen.items()] + for future in futures: + try: + future.result() + except cv.Invalid as e: + errors.append(e) + if not errors: + return + if len(errors) == 1: + raise errors[0] + raise cv.MultipleInvalid(errors) + + +# 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. +WEB_TYPE = "web" + + +def download_web_files_in_config( + config: list[ConfigType], + path_for: Callable[[ConfigType], Path], +) -> list[ConfigType]: + """Voluptuous-friendly validator that downloads any web-sourced files in + `config` in parallel. + + Each entry is expected to contain a `file` key whose value is a dict + that may be `{type: "web", url: ...}`; `path_for(file_dict)` returns + the cache path for that file. Returns `config` unchanged so it can be + slotted directly into a `cv.All(...)` chain. + """ + download_content_many( + (conf_file[CONF_URL], path_for(conf_file)) + for entry in config + if (conf_file := entry.get(CONF_FILE, {})).get(CONF_TYPE) == WEB_TYPE + ) + return config diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index f4d268abe0..c894f90666 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -9,7 +9,7 @@ import pytest import requests from esphome import external_files -from esphome.config_validation import Invalid +from esphome.config_validation import Invalid, MultipleInvalid from esphome.core import CORE, EsphomeError, TimePeriod @@ -60,6 +60,24 @@ def mock_write_file() -> MagicMock: yield m +@pytest.fixture +def mock_download_content() -> MagicMock: + """Patch `external_files.download_content` for tests that exercise the + parallel batch helper without doing real I/O. + """ + with patch("esphome.external_files.download_content") as m: + yield m + + +@pytest.fixture +def mock_download_content_many() -> MagicMock: + """Patch `external_files.download_content_many` for tests that exercise + the URL-collection helper without dispatching to the thread pool. + """ + with patch("esphome.external_files.download_content_many") as m: + yield m + + def test_compute_local_file_dir(setup_core: Path) -> None: """Test compute_local_file_dir creates and returns correct path.""" domain = "font" @@ -494,6 +512,173 @@ def test_download_content_skip_external_update_downloads_when_missing( assert test_file.read_bytes() == new_content +def test_download_content_many_empty_is_noop( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """Empty input shouldn't spin up a thread pool or call download_content.""" + external_files.download_content_many([]) + mock_download_content.assert_not_called() + + +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") + external_files.download_content_many([item]) + mock_download_content.assert_called_once_with( + item[0], item[1], external_files.NETWORK_TIMEOUT + ) + + +def test_download_content_many_runs_in_parallel( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """Multiple items should run concurrently — total wall time ≈ max latency.""" + import threading + + barrier = threading.Barrier(3) + + def slow_download(url: str, path: Path, timeout: int) -> 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) + return b"" + + 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.download_content_many(items, max_workers=4) + assert mock_download_content.call_count == 3 + + +def test_download_content_many_propagates_single_error( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """A single failing worker should raise its `Invalid` directly, not wrap + it in a `MultipleInvalid` that the caller would have to unpack. + """ + + def fake_download(url: str, path: Path, timeout: int) -> 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"), + ] + with pytest.raises(Invalid, match="could not download") as exc_info: + external_files.download_content_many(items) + assert not isinstance(exc_info.value, MultipleInvalid) + + +def test_download_content_many_aggregates_multiple_errors( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """Every failing worker should be reported in a single MultipleInvalid so + the user sees all broken URLs in one validation pass instead of fixing + them one network round-trip at a time. + """ + + def fake_download(url: str, path: Path, timeout: int) -> 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"), + ] + with pytest.raises(MultipleInvalid) as exc_info: + external_files.download_content_many(items) + messages = {str(e) for e in exc_info.value.errors} + assert messages == { + "could not download https://example.com/bad1", + "could not download https://example.com/bad2", + } + + +def test_download_content_many_dedupes_by_path( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """Two items pointing at the same cache path must collapse to one + download -- otherwise concurrent writes race on the same file. Which + URL wins doesn't matter (in practice duplicate paths only arise when + the URL is duplicated), so we only assert the call count and path. + """ + path = setup_core / "shared" + items = [ + ("https://example.com/a", path), + ("https://example.com/b", path), + ("https://example.com/a", path), + ] + external_files.download_content_many(items) + assert mock_download_content.call_count == 1 + args, _ = mock_download_content.call_args + assert args[1] == path + + +def test_download_content_many_clamps_invalid_max_workers( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """`max_workers <= 0` must not raise from ThreadPoolExecutor; it should + 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.download_content_many(items, max_workers=0) + assert mock_download_content.call_count == 2 + + +def test_download_web_files_in_config_filters_and_dispatches( + mock_download_content_many: MagicMock, setup_core: Path +) -> None: + """Only `file.type == "web"` entries should be forwarded to + download_content_many, and the unmodified config should be returned so + the helper can sit in a `cv.All(...)` chain. + """ + + def path_for(file_dict: dict) -> Path: + return setup_core / file_dict["url"].rsplit("/", 1)[-1] + + config = [ + {"file": {"type": "web", "url": "https://example.com/a"}}, + {"file": {"type": "local", "path": "/tmp/b"}}, + {"file": {"type": "web", "url": "https://example.com/c"}}, + {}, # no `file` key at all + ] + result = external_files.download_web_files_in_config(config, path_for) + + 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"), + ] + + +def test_download_web_files_in_config_no_web_entries( + mock_download_content_many: MagicMock, setup_core: Path +) -> None: + """A config with no web entries should still call through to + download_content_many (which is itself a no-op for empty input) so the + behavior stays consistent. + """ + config = [{"file": {"type": "local", "path": "/tmp/a"}}] + external_files.download_web_files_in_config(config, lambda _: setup_core / "x") + mock_download_content_many.assert_called_once() + assert list(mock_download_content_many.call_args[0][0]) == [] + + def test_download_content_saves_etag( mock_has_remote_file_changed: MagicMock, mock_requests_get: MagicMock, From 83252df908d66e3f62a83af1ed96232127f12b9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 21:19:21 -0500 Subject: [PATCH 0831/2214] [core] Split hal.h into per-platform headers under core/hal/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the wake.{h,cpp} → wake/wake_.{h,cpp} decomposition that PR #15978 did. After this change esphome/core/hal.h is a thin dispatcher and each platform's HAL bits (IRAM_ATTR / PROGMEM macros, in_isr_context(), the inline yield/delay/micros/millis/millis_64 wrappers, plus ESP8266's progmem_read_*) live in their own header under esphome/core/hal/. Scope is headers only — there is no esphome/core/hal.cpp today (every out-of-line implementation lives in esphome/components//core.cpp alongside platform-specific concerns) so no new .cpp files are added and no FILTER_SOURCE_FILES entries are needed in core/config.py. recursive_sources=True on the core manifest already picks up the new .h files automatically. No public API moves, no symbol renames, no behavior change. Pure code motion. The only observable difference is the dispatcher #errors when no USE_* is set (today an unknown platform silently fell through to the else branch with empty IRAM_ATTR/PROGMEM); this matches wake.h's behavior. --- esphome/core/hal.h | 187 ++++--------------------------- esphome/core/hal/hal_esp32.h | 35 ++++++ esphome/core/hal/hal_esp8266.h | 44 ++++++++ esphome/core/hal/hal_host.h | 24 ++++ esphome/core/hal/hal_libretiny.h | 93 +++++++++++++++ esphome/core/hal/hal_rp2040.h | 40 +++++++ esphome/core/hal/hal_zephyr.h | 24 ++++ 7 files changed, 279 insertions(+), 168 deletions(-) create mode 100644 esphome/core/hal/hal_esp32.h create mode 100644 esphome/core/hal/hal_esp8266.h create mode 100644 esphome/core/hal/hal_host.h create mode 100644 esphome/core/hal/hal_libretiny.h create mode 100644 esphome/core/hal/hal_rp2040.h create mode 100644 esphome/core/hal/hal_zephyr.h diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 65e0053236..b8bdc783ab 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -6,175 +6,30 @@ #include "esphome/core/time_64.h" #include "esphome/core/time_conversion.h" +// Per-platform HAL bits (IRAM_ATTR / PROGMEM macros, in_isr_context(), +// inline yield/delay/micros/millis/millis_64 wrappers, ESP8266 progmem +// helpers) live under esphome/core/hal/ and are dispatched here based on +// the active USE_* platform define. Each header guards its body with the +// matching #ifdef USE_ and re-enters namespace esphome {} so it +// is safe to be re-included. #if defined(USE_ESP32) -#include -#ifndef PROGMEM -#define PROGMEM -#endif - +#include "esphome/core/hal/hal_esp32.h" #elif defined(USE_ESP8266) - -#include -#ifndef PROGMEM -#define PROGMEM ICACHE_RODATA_ATTR -#endif - -#elif defined(USE_RP2040) - -#define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) -#define PROGMEM - +#include "esphome/core/hal/hal_esp8266.h" #elif defined(USE_LIBRETINY) - -// IRAM_ATTR places a function in executable RAM so it is callable from an -// ISR even while flash is busy (XIP stall, OTA, logger flash write). -// Each family uses a section its stock linker already routes to RAM: -// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the -// exception: its stock linker has no matching glob, so patch_linker.py -// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. -// -// BK72xx (all variants) are left as a no-op: their SDK wraps flash -// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for -// the duration of every write, so no ISR fires while flash is stalled and -// the race IRAM_ATTR guards against cannot occur. The trade-off is that -// interrupts are delayed (not dropped) by up to ~20 ms during a sector -// erase, but that is an SDK-level choice and cannot be changed from this -// layer. -#if defined(USE_BK72XX) -#define IRAM_ATTR -#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) -// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). -#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) +#include "esphome/core/hal/hal_libretiny.h" +#elif defined(USE_RP2040) +#include "esphome/core/hal/hal_rp2040.h" +#elif defined(USE_HOST) +#include "esphome/core/hal/hal_host.h" +#elif defined(USE_ZEPHYR) +#include "esphome/core/hal/hal_zephyr.h" #else -// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. -// LN882H: patch_linker.py.script injects *(.sram.text*) into -// .flash_copysection (> RAM0 AT> FLASH). -#define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) -#endif -#define PROGMEM - -#else - -#define IRAM_ATTR -#define PROGMEM - -#endif - -#ifdef USE_ESP32 -#include -#include -#endif - -#ifdef USE_LIBRETINY -// For the inline millis() fast paths (xTaskGetTickCount, portTICK_PERIOD_MS). -#include -#include -#endif - -#ifdef USE_BK72XX -// Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so -// it is callable from Thumb code via interworking. The MRS CPSR instruction -// is ARM-only and user code here may be built in Thumb, so in_isr_context() -// defers to this port helper on BK72xx instead of reading CPSR inline. -extern "C" uint32_t platform_is_in_interrupt_context(void); -#endif - -// Forward decls from Arduino's for the inline wrappers below. -// NOLINT covers TUs that also include Arduino.h. -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) -// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) -extern "C" void yield(void); -extern "C" void delay(unsigned long ms); -extern "C" unsigned long micros(void); -extern "C" unsigned long millis(void); -// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) -#endif - -#ifdef USE_RP2040 -// Forward decl from . -extern "C" uint64_t time_us_64(void); +#error "hal.h: not implemented for this platform" #endif namespace esphome { -/// Returns true when executing inside an interrupt handler. -/// always_inline so callers placed in IRAM keep the detection in IRAM. -__attribute__((always_inline)) inline bool in_isr_context() { -#if defined(USE_ESP32) - return xPortInIsrContext() != 0; -#elif defined(USE_ESP8266) - // ESP8266 has no reliable single-register ISR detection: PS.INTLEVEL is - // non-zero both in a real ISR and when user code masks interrupts. The - // ESP8266 wake path is context-agnostic (wake_loop_impl uses esp_schedule - // which is ISR-safe) so this helper is unused on this platform. - return false; -#elif defined(USE_RP2040) - uint32_t ipsr; - __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); - return ipsr != 0; -#elif defined(USE_BK72XX) - // BK72xx is ARM968E-S (ARM9); see extern declaration above. - return platform_is_in_interrupt_context() != 0; -#elif defined(USE_LIBRETINY) - // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; - // non-zero means we're in a handler. - uint32_t ipsr; - __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); - return ipsr != 0; -#else - // Host and any future platform without an ISR concept. - return false; -#endif -} - -// yield()/delay()/micros()/millis()/millis_64() are inlined per platform to -// drop the wrapper call/return — most relevant to runtime_stats and the main -// loop. ESP8266/LibreTiny/RP2040 share Arduino's ::yield/::delay/::micros. -#if defined(USE_ESP32) -// Forward decl from . -// NOLINTNEXTLINE(readability-redundant-declaration) -extern "C" int64_t esp_timer_get_time(void); -__attribute__((always_inline)) inline void yield() { vPortYield(); } -__attribute__((always_inline)) inline void delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } -__attribute__((always_inline)) inline uint32_t micros() { return static_cast(esp_timer_get_time()); } -uint32_t millis(); -__attribute__((always_inline)) inline uint64_t millis_64() { - return micros_to_millis(static_cast(esp_timer_get_time())); -} -#elif defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) -__attribute__((always_inline)) inline void yield() { ::yield(); } -__attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } -#if defined(USE_ESP8266) -void delay(uint32_t ms); -uint32_t millis(); -__attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); } -#elif defined(USE_LIBRETINY) -__attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } -// Per-variant millis() fast path — matches MillisInternal::get(). -#if defined(USE_RTL87XX) || defined(USE_LN882X) -static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); -__attribute__((always_inline)) inline uint32_t millis() { - // xTaskGetTickCountFromISR is mandatory in interrupt context per the FreeRTOS API contract. - return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount(); -} -#elif defined(USE_BK72XX) -static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick"); -__attribute__((always_inline)) inline uint32_t millis() { return xTaskGetTickCount() * portTICK_PERIOD_MS; } -#else -__attribute__((always_inline)) inline uint32_t millis() { return static_cast(::millis()); } -#endif -__attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); } -#else // USE_RP2040 -__attribute__((always_inline)) inline uint32_t millis() { return micros_to_millis(::time_us_64()); } -__attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis(::time_us_64()); } -#endif -#else -void yield(); -void delay(uint32_t ms); -uint32_t micros(); -uint32_t millis(); -uint64_t millis_64(); -#endif void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void __attribute__((noreturn)) arch_restart(); void arch_init(); @@ -182,13 +37,9 @@ void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); -#ifdef USE_ESP8266 -// ESP8266: pgm_read_* does real flash reads on Harvard architecture -uint8_t progmem_read_byte(const uint8_t *addr); -const char *progmem_read_ptr(const char *const *addr); -uint16_t progmem_read_uint16(const uint16_t *addr); -#else -// All other platforms: PROGMEM is a no-op, so these are direct dereferences +#ifndef USE_ESP8266 +// All non-ESP8266 platforms: PROGMEM is a no-op, so these are direct dereferences. +// ESP8266's out-of-line declarations live in hal/hal_esp8266.h. inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } inline const char *progmem_read_ptr(const char *const *addr) { return *addr; } inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } diff --git a/esphome/core/hal/hal_esp32.h b/esphome/core/hal/hal_esp32.h new file mode 100644 index 0000000000..e755337540 --- /dev/null +++ b/esphome/core/hal/hal_esp32.h @@ -0,0 +1,35 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include +#include + +#include "esphome/core/time_conversion.h" + +#ifndef PROGMEM +#define PROGMEM +#endif + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +__attribute__((always_inline)) inline bool in_isr_context() { return xPortInIsrContext() != 0; } + +// Forward decl from . +// NOLINTNEXTLINE(readability-redundant-declaration) +extern "C" int64_t esp_timer_get_time(void); + +__attribute__((always_inline)) inline void yield() { vPortYield(); } +__attribute__((always_inline)) inline void delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(esp_timer_get_time()); } +uint32_t millis(); +__attribute__((always_inline)) inline uint64_t millis_64() { + return micros_to_millis(static_cast(esp_timer_get_time())); +} + +} // namespace esphome + +#endif // USE_ESP32 diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h new file mode 100644 index 0000000000..b4476f1ab3 --- /dev/null +++ b/esphome/core/hal/hal_esp8266.h @@ -0,0 +1,44 @@ +#pragma once + +#ifdef USE_ESP8266 + +#include +#include + +#include "esphome/core/time_64.h" + +#ifndef PROGMEM +#define PROGMEM ICACHE_RODATA_ATTR +#endif + +// Forward decls from Arduino's for the inline wrappers below. +// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +extern "C" void yield(void); +extern "C" void delay(unsigned long ms); +extern "C" unsigned long micros(void); +extern "C" unsigned long millis(void); +// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +/// ESP8266 has no reliable single-register ISR detection: PS.INTLEVEL is +/// non-zero both in a real ISR and when user code masks interrupts. The +/// ESP8266 wake path is context-agnostic (wake_loop_impl uses esp_schedule +/// which is ISR-safe) so this helper is unused on this platform. +__attribute__((always_inline)) inline bool in_isr_context() { return false; } + +__attribute__((always_inline)) inline void yield() { ::yield(); } +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } +void delay(uint32_t ms); +uint32_t millis(); +__attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); } + +// ESP8266: pgm_read_* does real flash reads on Harvard architecture +uint8_t progmem_read_byte(const uint8_t *addr); +const char *progmem_read_ptr(const char *const *addr); +uint16_t progmem_read_uint16(const uint16_t *addr); + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/hal/hal_host.h b/esphome/core/hal/hal_host.h new file mode 100644 index 0000000000..145fe4ea9c --- /dev/null +++ b/esphome/core/hal/hal_host.h @@ -0,0 +1,24 @@ +#pragma once + +#ifdef USE_HOST + +#include + +#define IRAM_ATTR +#define PROGMEM + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +/// Host has no ISR concept. +__attribute__((always_inline)) inline bool in_isr_context() { return false; } + +void yield(); +void delay(uint32_t ms); +uint32_t micros(); +uint32_t millis(); +uint64_t millis_64(); + +} // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h new file mode 100644 index 0000000000..e0d92735bb --- /dev/null +++ b/esphome/core/hal/hal_libretiny.h @@ -0,0 +1,93 @@ +#pragma once + +#ifdef USE_LIBRETINY + +#include + +// For the inline millis() fast paths (xTaskGetTickCount, portTICK_PERIOD_MS). +#include +#include + +#include "esphome/core/time_64.h" + +// IRAM_ATTR places a function in executable RAM so it is callable from an +// ISR even while flash is busy (XIP stall, OTA, logger flash write). +// Each family uses a section its stock linker already routes to RAM: +// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the +// exception: its stock linker has no matching glob, so patch_linker.py +// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// +// BK72xx (all variants) are left as a no-op: their SDK wraps flash +// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for +// the duration of every write, so no ISR fires while flash is stalled and +// the race IRAM_ATTR guards against cannot occur. The trade-off is that +// interrupts are delayed (not dropped) by up to ~20 ms during a sector +// erase, but that is an SDK-level choice and cannot be changed from this +// layer. +#if defined(USE_BK72XX) +#define IRAM_ATTR +#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) +// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). +#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) +#else +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// LN882H: patch_linker.py.script injects *(.sram.text*) into +// .flash_copysection (> RAM0 AT> FLASH). +#define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) +#endif +#define PROGMEM + +#ifdef USE_BK72XX +// Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so +// it is callable from Thumb code via interworking. The MRS CPSR instruction +// is ARM-only and user code here may be built in Thumb, so in_isr_context() +// defers to this port helper on BK72xx instead of reading CPSR inline. +extern "C" uint32_t platform_is_in_interrupt_context(void); +#endif + +// Forward decls from Arduino's for the inline wrappers below. +// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +extern "C" void yield(void); +extern "C" void delay(unsigned long ms); +extern "C" unsigned long micros(void); +extern "C" unsigned long millis(void); +// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +__attribute__((always_inline)) inline bool in_isr_context() { +#if defined(USE_BK72XX) + // BK72xx is ARM968E-S (ARM9); see extern declaration above. + return platform_is_in_interrupt_context() != 0; +#else + // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; + // non-zero means we're in a handler. + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#endif +} + +__attribute__((always_inline)) inline void yield() { ::yield(); } +__attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } + +// Per-variant millis() fast path — matches MillisInternal::get(). +#if defined(USE_RTL87XX) || defined(USE_LN882X) +static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); +__attribute__((always_inline)) inline uint32_t millis() { + // xTaskGetTickCountFromISR is mandatory in interrupt context per the FreeRTOS API contract. + return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount(); +} +#elif defined(USE_BK72XX) +static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick"); +__attribute__((always_inline)) inline uint32_t millis() { return xTaskGetTickCount() * portTICK_PERIOD_MS; } +#else +__attribute__((always_inline)) inline uint32_t millis() { return static_cast(::millis()); } +#endif +__attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); } + +} // namespace esphome + +#endif // USE_LIBRETINY diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/core/hal/hal_rp2040.h new file mode 100644 index 0000000000..156ff33b86 --- /dev/null +++ b/esphome/core/hal/hal_rp2040.h @@ -0,0 +1,40 @@ +#pragma once + +#ifdef USE_RP2040 + +#include + +#include "esphome/core/time_conversion.h" + +#define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) +#define PROGMEM + +// Forward decls from Arduino's for the inline wrappers below. +// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +extern "C" void yield(void); +extern "C" void delay(unsigned long ms); +extern "C" unsigned long micros(void); +extern "C" unsigned long millis(void); +// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) + +// Forward decl from . +extern "C" uint64_t time_us_64(void); + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +__attribute__((always_inline)) inline bool in_isr_context() { + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +} + +__attribute__((always_inline)) inline void yield() { ::yield(); } +__attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } +__attribute__((always_inline)) inline uint32_t millis() { return micros_to_millis(::time_us_64()); } +__attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis(::time_us_64()); } + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/core/hal/hal_zephyr.h new file mode 100644 index 0000000000..e28be5c775 --- /dev/null +++ b/esphome/core/hal/hal_zephyr.h @@ -0,0 +1,24 @@ +#pragma once + +#ifdef USE_ZEPHYR + +#include + +#define IRAM_ATTR +#define PROGMEM + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +/// Zephyr/nRF52: not currently consulted — wake path is platform-specific. +__attribute__((always_inline)) inline bool in_isr_context() { return false; } + +void yield(); +void delay(uint32_t ms); +uint32_t micros(); +uint32_t millis(); +uint64_t millis_64(); + +} // namespace esphome + +#endif // USE_ZEPHYR From 0f128d2f15ed9a1f0f1f2970a421fa0c9b5e47f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 05:37:48 -0500 Subject: [PATCH 0832/2214] [esp8266] Inline delayMicroseconds/arch_feed_wdt/progmem_read_* in hal_esp8266.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These wrappers were one-line forwarders to platform primitives: delayMicroseconds(us) -> delay_microseconds_safe(us) arch_feed_wdt() -> system_soft_wdt_feed() progmem_read_byte(p) -> pgm_read_byte(p) progmem_read_ptr(p) -> pgm_read_ptr(p) cast progmem_read_uint16(p) -> pgm_read_word(p) Mark them __attribute__((always_inline)) inline in hal_esp8266.h so the wrapper call/return is eliminated at every call site, and remove the out-of-line definitions from components/esp8266/core.cpp. The IRAM_ATTR previously on delayMicroseconds() was decorative — its body calls delay_microseconds_safe() which lives in flash, so an IRAM ISR caller already jumped from SRAM into flash. Inlining is no worse than the prior code (same reasoning as the libretiny IRAM_ATTR note in the parent commit's PR description). The dispatcher hal.h now gates its delayMicroseconds/arch_feed_wdt declarations behind #ifndef USE_ESP8266 so clang-tidy does not flag them as redundant on top of the inline definitions. --- esphome/components/esp8266/core.cpp | 13 +------------ esphome/core/hal.h | 6 +++++- esphome/core/hal/hal_esp8266.h | 28 ++++++++++++++++++++++++---- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index ed28027445..9161ca6aaf 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -84,7 +84,7 @@ void HOT delay(uint32_t ms) { optimistic_yield(1000); } } -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } +// delayMicroseconds(), arch_feed_wdt(), and progmem_read_*() are inlined in hal/hal_esp8266.h. void arch_restart() { system_restart(); // restart() doesn't always end execution @@ -93,17 +93,6 @@ void arch_restart() { } } void arch_init() {} -void HOT arch_feed_wdt() { system_soft_wdt_feed(); } - -uint8_t progmem_read_byte(const uint8_t *addr) { - return pgm_read_byte(addr); // NOLINT -} -const char *progmem_read_ptr(const char *const *addr) { - return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT -} -uint16_t progmem_read_uint16(const uint16_t *addr) { - return pgm_read_word(addr); // NOLINT -} uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { return F_CPU; } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index b8bdc783ab..e20797cf95 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -30,10 +30,14 @@ namespace esphome { +// ESP8266 inlines delayMicroseconds() and arch_feed_wdt() in hal/hal_esp8266.h; +// every other platform keeps them out-of-line in components//core.cpp. +#ifndef USE_ESP8266 void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) +void arch_feed_wdt(); +#endif void __attribute__((noreturn)) arch_restart(); void arch_init(); -void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h index b4476f1ab3..d947612bcc 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/core/hal/hal_esp8266.h @@ -4,6 +4,7 @@ #include #include +#include #include "esphome/core/time_64.h" @@ -19,8 +20,15 @@ extern "C" unsigned long micros(void); extern "C" unsigned long millis(void); // NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +// Forward decl from for arch_feed_wdt() inline below. +extern "C" void system_soft_wdt_feed(void); + namespace esphome { +// Forward decl from helpers.h so this header stays cheap. +// NOLINTNEXTLINE(readability-redundant-declaration) +void delay_microseconds_safe(uint32_t us); + /// Returns true when executing inside an interrupt handler. /// ESP8266 has no reliable single-register ISR detection: PS.INTLEVEL is /// non-zero both in a real ISR and when user code masks interrupts. The @@ -34,10 +42,22 @@ void delay(uint32_t ms); uint32_t millis(); __attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); } -// ESP8266: pgm_read_* does real flash reads on Harvard architecture -uint8_t progmem_read_byte(const uint8_t *addr); -const char *progmem_read_ptr(const char *const *addr); -uint16_t progmem_read_uint16(const uint16_t *addr); +// ESP8266: pgm_read_* does aligned 32-bit flash reads on Harvard architecture. +// Inline-forward to the platform macros so the wrappers themselves don't +// occupy IRAM/flash on every call site. +__attribute__((always_inline)) inline uint8_t progmem_read_byte(const uint8_t *addr) { + return pgm_read_byte(addr); // NOLINT +} +__attribute__((always_inline)) inline const char *progmem_read_ptr(const char *const *addr) { + return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT +} +__attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) { + return pgm_read_word(addr); // NOLINT +} + +// NOLINTNEXTLINE(readability-identifier-naming) +__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } +__attribute__((always_inline)) inline void arch_feed_wdt() { system_soft_wdt_feed(); } } // namespace esphome From 5be4f746ba999cba55a8389ac8ef126c9f5f83cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 05:46:56 -0500 Subject: [PATCH 0833/2214] [esp32] Move HAL bodies from components/esp32/core.cpp into core/hal/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-platform follow-up to #15977. Move out-of-line HAL bodies (millis(), arch_restart(), arch_get_cpu_freq_hz()) from components/esp32/core.cpp into a new esphome/core/hal/hal_esp32.cpp so HAL implementation finally lives next to the HAL header. Inline the trivial one-liners directly in hal_esp32.h: delayMicroseconds(us) -> delay_microseconds_safe(us) arch_feed_wdt() -> esp_task_wdt_reset() arch_get_cpu_cycle_count() -> esp_cpu_get_cycle_count() arch_init() stays in components/esp32/core.cpp because it uses esp32::crash_handler_read_and_clear() from the component-private crash_handler.h header — moving it would require an awkward layering inversion of core/ -> components/esp32/. config.py FILTER_SOURCE_FILES gains a hal/hal_esp32.cpp entry so only ESP32 builds compile it (same pattern wake/wake_*.cpp uses). Cross-platform decls of delayMicroseconds(), arch_feed_wdt(), and arch_get_cpu_cycle_count() are dropped from the dispatcher hal.h and moved into each per-platform header — that way subsequent per-platform follow-up PRs (libretiny, rp2040, host, zephyr) only need to touch their own platform header instead of accumulating gates in hal.h. --- esphome/components/esp32/core.cpp | 37 ++------------------------ esphome/core/config.py | 10 +++++++ esphome/core/hal.h | 10 +++---- esphome/core/hal/hal_esp32.cpp | 44 +++++++++++++++++++++++++++++++ esphome/core/hal/hal_esp32.h | 12 +++++++++ esphome/core/hal/hal_host.h | 4 +++ esphome/core/hal/hal_libretiny.h | 4 +++ esphome/core/hal/hal_rp2040.h | 4 +++ esphome/core/hal/hal_zephyr.h | 4 +++ 9 files changed, 87 insertions(+), 42 deletions(-) create mode 100644 esphome/core/hal/hal_esp32.cpp diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 4886745c06..267052ea2e 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -6,12 +6,8 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "preferences.h" -#include -#include -#include #include #include -#include #include #include @@ -23,29 +19,8 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { // yield(), delay(), micros(), millis_64() inlined in hal.h. -// Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig), -// falling back to esp_timer for non-standard rates. IRAM_ATTR is required because -// Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32. -// xTaskGetTickCountFromISR() is used in ISR context to satisfy the FreeRTOS API contract. -uint32_t IRAM_ATTR HOT millis() { -#if CONFIG_FREERTOS_HZ == 1000 - if (xPortInIsrContext()) [[unlikely]] { - return xTaskGetTickCountFromISR(); - } - return xTaskGetTickCount(); -#else - return micros_to_millis(static_cast(esp_timer_get_time())); -#endif -} -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } -void arch_restart() { - esp_restart(); - // restart() doesn't always end execution - while (true) { // NOLINT(clang-diagnostic-unreachable-code) - yield(); - } -} - +// millis(), arch_restart(), arch_get_cpu_freq_hz() out-of-line in hal/hal_esp32.cpp. +// delayMicroseconds(), arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in hal/hal_esp32.h. void arch_init() { #ifdef USE_ESP32_CRASH_HANDLER // Read crash data from previous boot before anything else @@ -61,14 +36,6 @@ void arch_init() { esp_ota_mark_app_valid_cancel_rollback(); #endif } -void HOT arch_feed_wdt() { esp_task_wdt_reset(); } - -uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } -uint32_t arch_get_cpu_freq_hz() { - uint32_t freq = 0; - esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &freq); - return freq; -} TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/config.py b/esphome/core/config.py index b4e81ce49f..ae2a40d036 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -815,6 +815,16 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wake/wake_zephyr.cpp": { PlatformFramework.NRF52_ZEPHYR, }, + # Per-platform HAL out-of-line implementations — hal.h dispatches to + # exactly one platform header based on USE_*, and the matching .cpp + # provides the few HAL functions that stay out-of-line. Other + # platforms still keep their out-of-line bodies in + # components//core.cpp until their per-platform follow-up + # PR moves them here. + "hal/hal_esp32.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, # Note: lock_free_queue.h and event_pool.h are header files and don't need to be filtered # as they are only included when needed by the preprocessor } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index e20797cf95..312effa7b0 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -30,15 +30,11 @@ namespace esphome { -// ESP8266 inlines delayMicroseconds() and arch_feed_wdt() in hal/hal_esp8266.h; -// every other platform keeps them out-of-line in components//core.cpp. -#ifndef USE_ESP8266 -void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) -void arch_feed_wdt(); -#endif +// Cross-platform declarations. delayMicroseconds(), arch_feed_wdt(), +// arch_get_cpu_cycle_count() vary per platform (some inline, some +// out-of-line) so they live in hal/hal_.h. void __attribute__((noreturn)) arch_restart(); void arch_init(); -uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); #ifndef USE_ESP8266 diff --git a/esphome/core/hal/hal_esp32.cpp b/esphome/core/hal/hal_esp32.cpp new file mode 100644 index 0000000000..a881953208 --- /dev/null +++ b/esphome/core/hal/hal_esp32.cpp @@ -0,0 +1,44 @@ +#ifdef USE_ESP32 + +#include "esphome/core/hal.h" + +#include +#include +#include +#include +#include + +namespace esphome { + +// Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig), +// falling back to esp_timer for non-standard rates. IRAM_ATTR is required because +// Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32. +// xTaskGetTickCountFromISR() is used in ISR context to satisfy the FreeRTOS API contract. +uint32_t IRAM_ATTR HOT millis() { +#if CONFIG_FREERTOS_HZ == 1000 + if (xPortInIsrContext()) [[unlikely]] { + return xTaskGetTickCountFromISR(); + } + return xTaskGetTickCount(); +#else + return micros_to_millis(static_cast(esp_timer_get_time())); +#endif +} + +void arch_restart() { + esp_restart(); + // restart() doesn't always end execution + while (true) { // NOLINT(clang-diagnostic-unreachable-code) + yield(); + } +} + +uint32_t arch_get_cpu_freq_hz() { + uint32_t freq = 0; + esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &freq); + return freq; +} + +} // namespace esphome + +#endif // USE_ESP32 diff --git a/esphome/core/hal/hal_esp32.h b/esphome/core/hal/hal_esp32.h index e755337540..2bff424441 100644 --- a/esphome/core/hal/hal_esp32.h +++ b/esphome/core/hal/hal_esp32.h @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include @@ -15,6 +17,11 @@ namespace esphome { +// Forward decl from helpers.h (esphome/core/helpers.h) — kept here so this +// header does not need to pull the rest of helpers.h. +// NOLINTNEXTLINE(readability-redundant-declaration) +void delay_microseconds_safe(uint32_t us); + /// Returns true when executing inside an interrupt handler. __attribute__((always_inline)) inline bool in_isr_context() { return xPortInIsrContext() != 0; } @@ -30,6 +37,11 @@ __attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } +// NOLINTNEXTLINE(readability-identifier-naming) +__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } +__attribute__((always_inline)) inline void arch_feed_wdt() { esp_task_wdt_reset(); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } + } // namespace esphome #endif // USE_ESP32 diff --git a/esphome/core/hal/hal_host.h b/esphome/core/hal/hal_host.h index 145fe4ea9c..682a1a422b 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/core/hal/hal_host.h @@ -19,6 +19,10 @@ uint32_t micros(); uint32_t millis(); uint64_t millis_64(); +void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) +void arch_feed_wdt(); +uint32_t arch_get_cpu_cycle_count(); + } // namespace esphome #endif // USE_HOST diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index e0d92735bb..e9d33b7753 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -88,6 +88,10 @@ __attribute__((always_inline)) inline uint32_t millis() { return static_cast(::time_us_64()); } +void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) +void arch_feed_wdt(); +uint32_t arch_get_cpu_cycle_count(); + } // namespace esphome #endif // USE_RP2040 diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/core/hal/hal_zephyr.h index e28be5c775..6707c85b2c 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/core/hal/hal_zephyr.h @@ -19,6 +19,10 @@ uint32_t micros(); uint32_t millis(); uint64_t millis_64(); +void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) +void arch_feed_wdt(); +uint32_t arch_get_cpu_cycle_count(); + } // namespace esphome #endif // USE_ZEPHYR From 174a0b7631c36a6f8dde3699678c5c585a61c021 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 05:53:18 -0500 Subject: [PATCH 0834/2214] [esp32] Use components/esp32/hal.cpp instead of core/hal/hal_esp32.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit placed the out-of-line ESP32 HAL bodies under core/hal/hal_esp32.cpp and gated compilation via FILTER_SOURCE_FILES, mirroring the wake/wake_.cpp pattern. Switch to the simpler components/esp32/hal.cpp location instead: - A component-internal .cpp only compiles when USE_ is set, so no FILTER_SOURCE_FILES entry is needed (revert that addition). - The file can include the component's private crash_handler.h header directly without a layering inversion or forward decl, so arch_init() also moves into hal.cpp now (previously stuck in core.cpp because of that header dependency). - A future consolidation PR will move the existing core/wake/wake_*.cpp files into components//wake.cpp the same way and drop their FILTER_SOURCE_FILES entries. ci-custom's lint_namespace check is satisfied with an empty namespace esphome::esp32 {} block at the top of the file — the HAL functions themselves live in namespace esphome (root) since they are not part of the esp32 component's API. --- esphome/components/esp32/core.cpp | 27 ++----------------- .../esp32/hal.cpp} | 25 +++++++++++++++++ esphome/core/config.py | 10 ------- 3 files changed, 27 insertions(+), 35 deletions(-) rename esphome/{core/hal/hal_esp32.cpp => components/esp32/hal.cpp} (57%) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 267052ea2e..5249f4a59e 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -1,13 +1,8 @@ #ifdef USE_ESP32 -#include "esphome/core/defines.h" -#include "crash_handler.h" #include "esphome/core/application.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" +#include "esphome/core/defines.h" #include "preferences.h" -#include -#include #include #include @@ -18,25 +13,7 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { -// yield(), delay(), micros(), millis_64() inlined in hal.h. -// millis(), arch_restart(), arch_get_cpu_freq_hz() out-of-line in hal/hal_esp32.cpp. -// delayMicroseconds(), arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in hal/hal_esp32.h. -void arch_init() { -#ifdef USE_ESP32_CRASH_HANDLER - // Read crash data from previous boot before anything else - esp32::crash_handler_read_and_clear(); -#endif - - // Enable the task watchdog only on the loop task (from which we're currently running) - esp_task_wdt_add(nullptr); - - // Handle OTA rollback: mark partition valid immediately unless USE_OTA_ROLLBACK is enabled, - // in which case safe_mode will mark it valid after confirming successful boot. -#ifndef USE_OTA_ROLLBACK - esp_ota_mark_app_valid_cancel_rollback(); -#endif -} - +// HAL functions live in hal.cpp. This file keeps only the loop task setup. TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static StackType_t diff --git a/esphome/core/hal/hal_esp32.cpp b/esphome/components/esp32/hal.cpp similarity index 57% rename from esphome/core/hal/hal_esp32.cpp rename to esphome/components/esp32/hal.cpp index a881953208..3a0836a507 100644 --- a/esphome/core/hal/hal_esp32.cpp +++ b/esphome/components/esp32/hal.cpp @@ -1,13 +1,22 @@ #ifdef USE_ESP32 +#include "crash_handler.h" +#include "esphome/core/defines.h" #include "esphome/core/hal.h" #include +#include #include +#include #include #include #include +// Empty esp32 namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// esp32 component's API. +namespace esphome::esp32 {} // namespace esphome::esp32 + namespace esphome { // Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig), @@ -33,6 +42,22 @@ void arch_restart() { } } +void arch_init() { +#ifdef USE_ESP32_CRASH_HANDLER + // Read crash data from previous boot before anything else + esp32::crash_handler_read_and_clear(); +#endif + + // Enable the task watchdog only on the loop task (from which we're currently running) + esp_task_wdt_add(nullptr); + + // Handle OTA rollback: mark partition valid immediately unless USE_OTA_ROLLBACK is enabled, + // in which case safe_mode will mark it valid after confirming successful boot. +#ifndef USE_OTA_ROLLBACK + esp_ota_mark_app_valid_cancel_rollback(); +#endif +} + uint32_t arch_get_cpu_freq_hz() { uint32_t freq = 0; esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &freq); diff --git a/esphome/core/config.py b/esphome/core/config.py index ae2a40d036..b4e81ce49f 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -815,16 +815,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wake/wake_zephyr.cpp": { PlatformFramework.NRF52_ZEPHYR, }, - # Per-platform HAL out-of-line implementations — hal.h dispatches to - # exactly one platform header based on USE_*, and the matching .cpp - # provides the few HAL functions that stay out-of-line. Other - # platforms still keep their out-of-line bodies in - # components//core.cpp until their per-platform follow-up - # PR moves them here. - "hal/hal_esp32.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, # Note: lock_free_queue.h and event_pool.h are header files and don't need to be filtered # as they are only included when needed by the preprocessor } From c0e2d443f0a7d71e3cb3ba4c8ce29c28cfef940d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 05:56:42 -0500 Subject: [PATCH 0835/2214] [esp8266] Move HAL bodies into components/esp8266/hal.cpp + inline arch_init() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-platform follow-up to #15977, chained on top of #16111. Move out-of-line HAL bodies (millis() fast accumulator, delay() poll, arch_restart, arch_get_cpu_cycle_count, arch_get_cpu_freq_hz, plus the __wrap_millis linker trampoline) from components/esp8266/core.cpp into a new components/esp8266/hal.cpp. core.cpp keeps only the ESP8266 firmware bootstrap (Tasmota OTA magic bytes, optional GPIO pre-init via resetPins) — single-purpose files. arch_init() is empty on ESP8266 so it is now inlined directly in core/hal/hal_esp8266.h alongside the other already-inlined wrappers (yield, micros, millis_64, delayMicroseconds, arch_feed_wdt, progmem_read_*). The dispatcher hal.h drops the cross-platform arch_init() declaration since at least one platform (ESP8266) inlines it now; bare declarations are added to the other per-platform headers (hal_esp32.h, hal_libretiny.h, hal_rp2040.h, hal_host.h, hal_zephyr.h) so future per-platform PRs only touch their own file. --- esphome/components/esp8266/core.cpp | 98 +----------------------- esphome/components/esp8266/hal.cpp | 113 ++++++++++++++++++++++++++++ esphome/core/hal.h | 5 +- esphome/core/hal/hal_esp32.h | 2 + esphome/core/hal/hal_esp8266.h | 3 + esphome/core/hal/hal_host.h | 1 + esphome/core/hal/hal_libretiny.h | 1 + esphome/core/hal/hal_rp2040.h | 1 + esphome/core/hal/hal_zephyr.h | 1 + 9 files changed, 126 insertions(+), 99 deletions(-) create mode 100644 esphome/components/esp8266/hal.cpp diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 9161ca6aaf..b9ad4082e9 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -3,98 +3,12 @@ #include "core.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#include "esphome/core/time_64.h" -#include "esphome/core/helpers.h" -#include "preferences.h" #include -#include - -extern "C" { -#include -} namespace esphome { -// yield(), micros(), millis_64() inlined in hal.h. -// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit -// multiplies on the LX106). Tracks a running ms counter from 32-bit -// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis -// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. -// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static -// state against ISR re-entry; the critical section is bounded (≤10 while-loop -// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on -// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level -// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). -// -// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles -// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a -// >71 min block would trip the watchdog long before it could matter here. -static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; -static constexpr uint32_t US_PER_MS = 1000; - -uint32_t IRAM_ATTR HOT millis() { - // Struct packs the three statics so the compiler loads one base address - // instead of three separate literal pool entries (saves ~8 bytes IRAM). - static struct { - uint32_t cache; - uint32_t remainder; - uint32_t last_us; - } state = {0, 0, 0}; - uint32_t ps = xt_rsil(15); - uint32_t now_us = system_get_time(); - uint32_t delta = now_us - state.last_us; - state.last_us = now_us; - state.remainder += delta; - if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { - // Rare path: large gap (WiFi scan, boot, long block). Constant-time - // conversion keeps the critical section bounded. - uint32_t ms = state.remainder / US_PER_MS; - state.cache += ms; - // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a - // second __umodsi3 call on the LX106 (no hardware divide). - state.remainder -= ms * US_PER_MS; - } else { - // Common path: small gap. At most ~10 iterations since remainder was - // < threshold (10 ms) on entry and delta adds at most one more threshold - // before exiting this branch. - while (state.remainder >= US_PER_MS) { - state.cache++; - state.remainder -= US_PER_MS; - } - } - uint32_t result = state.cache; - xt_wsr_ps(ps); - return result; -} -// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object -// call to the original millis() that --wrap can't intercept, so calling ::delay() -// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still -// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and -// WiFi run correctly. Theoretically less power-efficient than Arduino's -// os_timer-based delay() for long waits, but nearly all ESPHome delays are short -// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is -// negligible. -void HOT delay(uint32_t ms) { - if (ms == 0) { - optimistic_yield(1000); - return; - } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } -} -// delayMicroseconds(), arch_feed_wdt(), and progmem_read_*() are inlined in hal/hal_esp8266.h. -void arch_restart() { - system_restart(); - // restart() doesn't always end execution - while (true) { // NOLINT(clang-diagnostic-unreachable-code) - yield(); - } -} -void arch_init() {} -uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } -uint32_t arch_get_cpu_freq_hz() { return F_CPU; } +// HAL functions live in hal.cpp. This file keeps only the ESP8266-specific +// firmware bootstrap (Tasmota OTA magic bytes, optional GPIO pre-init). void force_link_symbols() { // Tasmota uses magic bytes in the binary to check if an OTA firmware is compatible @@ -131,12 +45,4 @@ extern "C" void resetPins() { // NOLINT } // namespace esphome -// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. -// Requires -Wl,--wrap=millis in build flags (added by __init__.py). -// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } -// Note: Arduino's init() registers a 60-second overflow timer for micros64(). -// We leave it running — wrapping init() as a no-op would break micros64()'s -// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). - #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp new file mode 100644 index 0000000000..12e4d7159d --- /dev/null +++ b/esphome/components/esp8266/hal.cpp @@ -0,0 +1,113 @@ +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" + +#include +#include + +extern "C" { +#include +} + +// Empty esp8266 namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// esp8266 component's API. +namespace esphome::esp8266 {} // namespace esphome::esp8266 + +namespace esphome { + +// yield(), micros(), millis_64(), delayMicroseconds(), arch_feed_wdt(), +// progmem_read_*() are inlined in core/hal/hal_esp8266.h. +// +// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit +// multiplies on the LX106). Tracks a running ms counter from 32-bit +// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis +// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. +// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static +// state against ISR re-entry; the critical section is bounded (≤10 while-loop +// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on +// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level +// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). +// +// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles +// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a +// >71 min block would trip the watchdog long before it could matter here. +static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; +static constexpr uint32_t US_PER_MS = 1000; + +uint32_t IRAM_ATTR HOT millis() { + // Struct packs the three statics so the compiler loads one base address + // instead of three separate literal pool entries (saves ~8 bytes IRAM). + static struct { + uint32_t cache; + uint32_t remainder; + uint32_t last_us; + } state = {0, 0, 0}; + uint32_t ps = xt_rsil(15); + uint32_t now_us = system_get_time(); + uint32_t delta = now_us - state.last_us; + state.last_us = now_us; + state.remainder += delta; + if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { + // Rare path: large gap (WiFi scan, boot, long block). Constant-time + // conversion keeps the critical section bounded. + uint32_t ms = state.remainder / US_PER_MS; + state.cache += ms; + // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a + // second __umodsi3 call on the LX106 (no hardware divide). + state.remainder -= ms * US_PER_MS; + } else { + // Common path: small gap. At most ~10 iterations since remainder was + // < threshold (10 ms) on entry and delta adds at most one more threshold + // before exiting this branch. + while (state.remainder >= US_PER_MS) { + state.cache++; + state.remainder -= US_PER_MS; + } + } + uint32_t result = state.cache; + xt_wsr_ps(ps); + return result; +} + +// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object +// call to the original millis() that --wrap can't intercept, so calling ::delay() +// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still +// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and +// WiFi run correctly. Theoretically less power-efficient than Arduino's +// os_timer-based delay() for long waits, but nearly all ESPHome delays are short +// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is +// negligible. +void HOT delay(uint32_t ms) { + if (ms == 0) { + optimistic_yield(1000); + return; + } + uint32_t start = millis(); + while (millis() - start < ms) { + optimistic_yield(1000); + } +} + +void arch_restart() { + system_restart(); + // restart() doesn't always end execution + while (true) { // NOLINT(clang-diagnostic-unreachable-code) + yield(); + } +} + +uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } +uint32_t arch_get_cpu_freq_hz() { return F_CPU; } + +} // namespace esphome + +// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. +// Requires -Wl,--wrap=millis in build flags (added by __init__.py). +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } +// Note: Arduino's init() registers a 60-second overflow timer for micros64(). +// We leave it running — wrapping init() as a no-op would break micros64()'s +// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). + +#endif // USE_ESP8266 diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 312effa7b0..0498afe18c 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -31,10 +31,9 @@ namespace esphome { // Cross-platform declarations. delayMicroseconds(), arch_feed_wdt(), -// arch_get_cpu_cycle_count() vary per platform (some inline, some -// out-of-line) so they live in hal/hal_.h. +// arch_get_cpu_cycle_count(), arch_init() vary per platform (some inline, +// some out-of-line) so they live in hal/hal_.h. void __attribute__((noreturn)) arch_restart(); -void arch_init(); uint32_t arch_get_cpu_freq_hz(); #ifndef USE_ESP8266 diff --git a/esphome/core/hal/hal_esp32.h b/esphome/core/hal/hal_esp32.h index 2bff424441..7bbd3406ee 100644 --- a/esphome/core/hal/hal_esp32.h +++ b/esphome/core/hal/hal_esp32.h @@ -42,6 +42,8 @@ __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { dela __attribute__((always_inline)) inline void arch_feed_wdt() { esp_task_wdt_reset(); } __attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } +void arch_init(); + } // namespace esphome #endif // USE_ESP32 diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h index d947612bcc..84b3f5cc68 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/core/hal/hal_esp8266.h @@ -58,6 +58,9 @@ __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_ // NOLINTNEXTLINE(readability-identifier-naming) __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } __attribute__((always_inline)) inline void arch_feed_wdt() { system_soft_wdt_feed(); } +__attribute__((always_inline)) inline void arch_init() {} + +uint32_t arch_get_cpu_cycle_count(); } // namespace esphome diff --git a/esphome/core/hal/hal_host.h b/esphome/core/hal/hal_host.h index 682a1a422b..5d378ac8a8 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/core/hal/hal_host.h @@ -22,6 +22,7 @@ uint64_t millis_64(); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); } // namespace esphome diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index e9d33b7753..af9f38da01 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -91,6 +91,7 @@ __attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); } // namespace esphome diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/core/hal/hal_rp2040.h index 2a1d67b4a3..7a2d21ae93 100644 --- a/esphome/core/hal/hal_rp2040.h +++ b/esphome/core/hal/hal_rp2040.h @@ -38,6 +38,7 @@ __attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_mi void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); } // namespace esphome diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/core/hal/hal_zephyr.h index 6707c85b2c..5f39d6d3af 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/core/hal/hal_zephyr.h @@ -22,6 +22,7 @@ uint64_t millis_64(); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); } // namespace esphome From bb76ee76ec4762eeb0bdda321ff5b1fe74c86252 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 05:59:38 -0500 Subject: [PATCH 0836/2214] [esp8266] Inline arch_get_cpu_cycle_count() and arch_get_cpu_freq_hz() Both bodies are one-liners: arch_get_cpu_cycle_count() -> esp_get_cycle_count() arch_get_cpu_freq_hz() -> F_CPU Move them inline in core/hal/hal_esp8266.h: - esp_get_cycle_count() comes from (now pulled into hal_esp8266.h, small ESP8266-only header). - F_CPU is a -DF_CPU=80000000L compiler flag from the ESP8266 Arduino board defs, available without any include. Drop the cross-platform arch_get_cpu_freq_hz() declaration from the dispatcher hal.h and add bare decls to hal_esp32.h / hal_libretiny.h / hal_rp2040.h / hal_host.h / hal_zephyr.h so subsequent per-platform follow-ups only touch their own platform header. --- esphome/components/esp8266/hal.cpp | 3 --- esphome/core/hal.h | 6 +++--- esphome/core/hal/hal_esp32.h | 1 + esphome/core/hal/hal_esp8266.h | 7 +++++-- esphome/core/hal/hal_host.h | 1 + esphome/core/hal/hal_libretiny.h | 1 + esphome/core/hal/hal_rp2040.h | 1 + esphome/core/hal/hal_zephyr.h | 1 + 8 files changed, 13 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index 12e4d7159d..ef93a95273 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -97,9 +97,6 @@ void arch_restart() { } } -uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } -uint32_t arch_get_cpu_freq_hz() { return F_CPU; } - } // namespace esphome // Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 0498afe18c..a53296979c 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -31,10 +31,10 @@ namespace esphome { // Cross-platform declarations. delayMicroseconds(), arch_feed_wdt(), -// arch_get_cpu_cycle_count(), arch_init() vary per platform (some inline, -// some out-of-line) so they live in hal/hal_.h. +// arch_get_cpu_cycle_count(), arch_init(), arch_get_cpu_freq_hz() vary +// per platform (some inline, some out-of-line) so they live in +// hal/hal_.h. void __attribute__((noreturn)) arch_restart(); -uint32_t arch_get_cpu_freq_hz(); #ifndef USE_ESP8266 // All non-ESP8266 platforms: PROGMEM is a no-op, so these are direct dereferences. diff --git a/esphome/core/hal/hal_esp32.h b/esphome/core/hal/hal_esp32.h index 7bbd3406ee..d5d7752bf6 100644 --- a/esphome/core/hal/hal_esp32.h +++ b/esphome/core/hal/hal_esp32.h @@ -43,6 +43,7 @@ __attribute__((always_inline)) inline void arch_feed_wdt() { esp_task_wdt_reset( __attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h index 84b3f5cc68..0b8fca6aa5 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/core/hal/hal_esp8266.h @@ -3,6 +3,7 @@ #ifdef USE_ESP8266 #include +#include #include #include @@ -59,8 +60,10 @@ __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_ __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } __attribute__((always_inline)) inline void arch_feed_wdt() { system_soft_wdt_feed(); } __attribute__((always_inline)) inline void arch_init() {} - -uint32_t arch_get_cpu_cycle_count(); +// esp_get_cycle_count() declared in ; F_CPU is a +// compiler-driven macro from the ESP8266 Arduino board defs (-DF_CPU=...). +__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return F_CPU; } } // namespace esphome diff --git a/esphome/core/hal/hal_host.h b/esphome/core/hal/hal_host.h index 5d378ac8a8..a8896fdf63 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/core/hal/hal_host.h @@ -23,6 +23,7 @@ void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index af9f38da01..ecfe830fe3 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -92,6 +92,7 @@ void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/core/hal/hal_rp2040.h index 7a2d21ae93..46f6e421cd 100644 --- a/esphome/core/hal/hal_rp2040.h +++ b/esphome/core/hal/hal_rp2040.h @@ -39,6 +39,7 @@ void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/core/hal/hal_zephyr.h index 5f39d6d3af..d4b37b5eb6 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/core/hal/hal_zephyr.h @@ -23,6 +23,7 @@ void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome From 1ce6364892ff9d12fcf271e717dfaf46bade9b6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:01:11 -0500 Subject: [PATCH 0837/2214] [libretiny] Move HAL bodies into components/libretiny/hal.cpp + inline trivial dispatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-platform follow-up to #15977. Move out-of-line HAL bodies (delayMicroseconds, arch_init, arch_restart) from components/libretiny/core.cpp into a new components/libretiny/hal.cpp. core.cpp is now empty (no extra component bootstrap to keep) — left as a stub for symmetry. Inline the trivial one-liners directly in hal_libretiny.h with forward decls for the LibreTiny C API: arch_feed_wdt() -> lt_wdt_feed() arch_get_cpu_cycle_count() -> lt_cpu_get_cycle_count() arch_get_cpu_freq_hz() -> lt_cpu_get_freq() arch_init stays out-of-line because of the libretiny::setup_preferences() call (component-private header) plus the BK72xx priority-raise + GPIO recovery logic. delayMicroseconds() also stays out-of-line — Arduino's ::delayMicroseconds signature is unsigned int and forward-declaring it across the C/C++ boundary in a hot header is fragile. --- esphome/components/libretiny/core.cpp | 53 +-------------------------- esphome/components/libretiny/hal.cpp | 53 +++++++++++++++++++++++++++ esphome/core/hal/hal_libretiny.h | 14 +++++-- 3 files changed, 66 insertions(+), 54 deletions(-) create mode 100644 esphome/components/libretiny/hal.cpp diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index f46abe3b81..8686a41e64 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -1,55 +1,6 @@ #ifdef USE_LIBRETINY -#include "core.h" -#include "esphome/core/defines.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" -#include "preferences.h" - -#include -#include - -void setup(); -void loop(); - -namespace esphome { - -// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } - -void arch_init() { - libretiny::setup_preferences(); - lt_wdt_enable(10000L); -#ifdef USE_BK72XX - // BK72xx SDK creates the main Arduino task at priority 3, which is lower than - // all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop - // stalls whenever WiFi background processing runs, because the main task - // cannot resume until every higher-priority task finishes. - // - // By contrast, RTL87xx creates the main task at osPriorityRealtime (highest). - // - // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the - // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. - // This is safe because ESPHome yields voluntarily via wakeable_delay() and - // the Arduino mainTask yield() after each loop() iteration. - static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; - static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); - vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY); -#endif -#if LT_GPIO_RECOVER - lt_gpio_recover(); -#endif -} - -void arch_restart() { - lt_reboot(); - while (1) { - } -} -void HOT arch_feed_wdt() { lt_wdt_feed(); } -uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); } -uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); } - -} // namespace esphome +// HAL functions live in hal.cpp. core.cpp is intentionally empty for +// libretiny — there is no extra component bootstrap to keep here. #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp new file mode 100644 index 0000000000..64b8de8f86 --- /dev/null +++ b/esphome/components/libretiny/hal.cpp @@ -0,0 +1,53 @@ +#ifdef USE_LIBRETINY + +#include "esphome/core/hal.h" +#include "preferences.h" + +#include +#include + +// Empty libretiny namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// libretiny component's API. +namespace esphome::libretiny {} // namespace esphome::libretiny + +namespace esphome { + +// yield(), delay(), micros(), millis(), millis_64(), arch_feed_wdt(), +// arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() inlined in +// core/hal/hal_libretiny.h. +void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } + +void arch_init() { + libretiny::setup_preferences(); + lt_wdt_enable(10000L); +#ifdef USE_BK72XX + // BK72xx SDK creates the main Arduino task at priority 3, which is lower than + // all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop + // stalls whenever WiFi background processing runs, because the main task + // cannot resume until every higher-priority task finishes. + // + // By contrast, RTL87xx creates the main task at osPriorityRealtime (highest). + // + // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the + // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. + // This is safe because ESPHome yields voluntarily via wakeable_delay() and + // the Arduino mainTask yield() after each loop() iteration. + static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; + static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); + vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY); +#endif +#if LT_GPIO_RECOVER + lt_gpio_recover(); +#endif +} + +void arch_restart() { + lt_reboot(); + while (1) { + } +} + +} // namespace esphome + +#endif // USE_LIBRETINY diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index ecfe830fe3..2c8b45812a 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -53,6 +53,13 @@ extern "C" unsigned long micros(void); extern "C" unsigned long millis(void); // NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +// Forward decls from libretiny's family for the inline arch_* +// wrappers below. Pulling the full header would drag in the rest of the +// LibreTiny C API. +extern "C" void lt_wdt_feed(void); +extern "C" uint32_t lt_cpu_get_cycle_count(void); +extern "C" uint32_t lt_cpu_get_freq(void); + namespace esphome { /// Returns true when executing inside an interrupt handler. @@ -88,11 +95,12 @@ __attribute__((always_inline)) inline uint32_t millis() { return static_cast Date: Wed, 29 Apr 2026 06:08:04 -0500 Subject: [PATCH 0838/2214] [esp8266] NOLINT redundant-declaration on system_soft_wdt_feed forward decl clang-tidy flagged the forward decl in hal_esp8266.h because also declares the function (when included via SDK headers). Both decls are identical `extern "C"` so the redundancy is harmless; suppress the warning on the hal_esp8266.h side. --- esphome/core/hal/hal_esp8266.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h index d947612bcc..04326a3579 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/core/hal/hal_esp8266.h @@ -21,6 +21,7 @@ extern "C" unsigned long millis(void); // NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) // Forward decl from for arch_feed_wdt() inline below. +// NOLINTNEXTLINE(readability-redundant-declaration) extern "C" void system_soft_wdt_feed(void); namespace esphome { From 040f4874b0635b4f70989d0449c37d95c8f6e407 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:26:20 -0500 Subject: [PATCH 0839/2214] [light] Fold LightControlAction fields into a single stateless lambda --- esphome/components/light/automation.h | 54 ++++----------------- esphome/components/light/automation.py | 66 +++++++++++++------------- esphome/components/light/types.py | 1 + 3 files changed, 45 insertions(+), 76 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index bc6fd84709..296042fe42 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -31,60 +31,26 @@ template class ToggleAction : public A transition_length_{}; }; -// Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. -namespace light_control_detail { -template struct Empty {}; -} // namespace light_control_detail - -// X-macro: (type, field_name, bit_index). Order and bit values must match -// the FIELDS table in automation.py. -#define LIGHT_CONTROL_FIELDS(X) \ - X(ColorMode, color_mode, 0) \ - X(bool, state, 1) \ - X(uint32_t, transition_length, 2) \ - X(uint32_t, flash_length, 3) \ - X(float, brightness, 4) \ - X(float, color_brightness, 5) \ - X(float, red, 6) \ - X(float, green, 7) \ - X(float, blue, 8) \ - X(float, white, 9) \ - X(float, color_temperature, 10) \ - X(float, cold_white, 11) \ - X(float, warm_white, 12) \ - X(uint32_t, effect, 13) - -template class LightControlAction : public Action { +// All configured fields are baked into a single stateless lambda whose +// constants live in flash. The action only stores a function pointer +// (4 bytes) plus the parent (4 bytes), regardless of how many fields the +// user set. Trigger args are forwarded to the apply function so user +// lambdas (e.g. `brightness: !lambda "return x;"`) keep working. +template class LightControlAction : public Action { public: - explicit LightControlAction(LightState *parent) : parent_(parent) {} - -#define LIGHT_FIELD_SETTER_(type, name, idx) \ - template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } -#define LIGHT_FIELD_APPLY_(type, name, idx) \ - if constexpr ((Fields & (1 << (idx))) != 0) \ - call.set_##name(this->name##_.value(x...)); -#define LIGHT_FIELD_DECL_(type, name, idx) \ - [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableFn, \ - light_control_detail::Empty<(idx)>> \ - name##_{}; - - LIGHT_CONTROL_FIELDS(LIGHT_FIELD_SETTER_) + using ApplyFn = void (*)(LightState *, LightCall &, Ts...); + LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} void play(const Ts &...x) override { auto call = this->parent_->make_call(); - LIGHT_CONTROL_FIELDS(LIGHT_FIELD_APPLY_) + this->apply_(this->parent_, call, x...); call.perform(); } protected: LightState *parent_; - LIGHT_CONTROL_FIELDS(LIGHT_FIELD_DECL_) - -#undef LIGHT_FIELD_DECL_ -#undef LIGHT_FIELD_APPLY_ -#undef LIGHT_FIELD_SETTER_ + ApplyFn apply_; }; -#undef LIGHT_CONTROL_FIELDS template class DimRelativeAction : public Action { public: diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index c666c98e42..18de37150b 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -37,6 +37,7 @@ from .types import ( AddressableSet, ColorMode, DimRelativeAction, + LightCall, LightControlAction, LightIsOffCondition, LightIsOnCondition, @@ -181,8 +182,8 @@ def _resolve_effect_index(config: ConfigType) -> int: async def light_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - # Order/bits must match LIGHT_CONTROL_FIELDS in automation.h. - # EFFECT has special handling below; setter=None skips the generic loop. + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. FIELDS = ( (CONF_COLOR_MODE, "set_color_mode", ColorMode), (CONF_STATE, "set_state", cg.bool_), @@ -197,49 +198,50 @@ async def light_control_to_code(config, action_id, template_arg, args): (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), (CONF_COLD_WHITE, "set_cold_white", cg.float_), (CONF_WARM_WHITE, "set_warm_white", cg.float_), - (CONF_EFFECT, None, cg.uint32), ) - # Bitmask is passed as uint16_t in C++ — must stay within 16 bits. - assert len(FIELDS) <= 16, "LightControlAction Fields bitmask exceeds uint16_t" - field_mask = sum(1 << i for i, (k, _, _) in enumerate(FIELDS) if k in config) - control_template_arg = cg.TemplateArguments( - cg.RawExpression(f"static_cast({field_mask})"), *template_arg - ) - var = cg.new_Pvariable(action_id, control_template_arg, paren) + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] for conf_key, setter, type_ in FIELDS: - if conf_key in config and setter is not None: - template_ = await cg.templatable(config[conf_key], args, type_) - cg.add(getattr(var, setter)(template_)) + if conf_key not in config: + continue + value = config[conf_key] + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=type_) + body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") if CONF_EFFECT in config: if isinstance(config[CONF_EFFECT], Lambda): - # Lambda returns a string — wrap in a C++ lambda that resolves - # the effect name to its uint32_t index at runtime inner_lambda = await cg.process_lambda( config[CONF_EFFECT], args, return_type=cg.std_string ) - fwd_args = ", ".join(n for _, n in args) - # capture="" is correct: paren is a global variable name - # string-interpolated into the body at codegen time, not a - # C++ runtime capture. - wrapper = LambdaExpression( - f"auto __effect_s = ({inner_lambda})({fwd_args});\n" - f"return {paren}->get_effect_index(" - f"__effect_s.c_str(), __effect_s.size());", - args, - capture="", - return_type=cg.uint32, + body_lines.append( + f"{{ auto __effect_s = ({inner_lambda})({fwd_args});\n" + f"call.set_effect(parent->get_effect_index(" + f"__effect_s.c_str(), __effect_s.size())); }}" ) - cg.add(var.set_effect(wrapper)) else: - # Static string — resolve effect name to index at codegen time - template_ = await cg.templatable( - _resolve_effect_index(config), args, cg.uint32 + # Cast disambiguates between set_effect(uint32_t) and + # set_effect(optional) when the literal is an int. + body_lines.append( + f"call.set_effect(static_cast({_resolve_effect_index(config)}));" ) - cg.add(var.set_effect(template_)) - return var + + apply_args = [ + (LightState.operator("ptr"), "parent"), + (LightCall.operator("ref"), "call"), + *args, + ] + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + apply_args, + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) CONF_RELATIVE_BRIGHTNESS = "relative_brightness" diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index a586bcbd13..534dcd2194 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -13,6 +13,7 @@ Color = cg.esphome_ns.class_("Color") LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") +LightCall = light_ns.class_("LightCall") # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) From a35aecefeed0ad5941ef9e31a8f098bf50b52073 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:30:46 -0500 Subject: [PATCH 0840/2214] [climate] Fold ControlAction fields into a single stateless lambda --- esphome/components/climate/__init__.py | 53 ++++++++++++++----------- esphome/components/climate/automation.h | 52 +++++------------------- 2 files changed, 40 insertions(+), 65 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 413a2a5b56..b033776f27 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -48,13 +48,13 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, Lambda, 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 LambdaExpression, MockObjClass IS_PLATFORM_COMPONENT = True @@ -488,7 +488,11 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( async def climate_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - # Order/bits must match CLIMATE_CONTROL_FIELDS in automation.h. + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. + # `call_setter` is the ClimateCall method invoked in the lambda body — + # for custom_fan_mode/custom_preset this dispatches to the std::string + # overload of set_fan_mode/set_preset respectively. FIELDS = ( (CONF_MODE, "set_mode", ClimateMode), (CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), @@ -496,32 +500,35 @@ async def climate_control_to_code(config, action_id, template_arg, args): (CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_), (CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), (CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), - ( - CONF_CUSTOM_FAN_MODE, - "set_custom_fan_mode", - cg.std_string, - ), # internal setter name + (CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string), (CONF_PRESET, "set_preset", ClimatePreset), - ( - CONF_CUSTOM_PRESET, - "set_custom_preset", - cg.std_string, - ), # internal setter name + (CONF_CUSTOM_PRESET, "set_preset", cg.std_string), (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), ) - assert len(FIELDS) <= 16, "ControlAction Fields bitmask exceeds uint16_t" - field_mask = sum(1 << i for i, (k, _, _) in enumerate(FIELDS) if k in config) - control_template_arg = cg.TemplateArguments( - cg.RawExpression(f"static_cast({field_mask})"), *template_arg - ) - var = cg.new_Pvariable(action_id, control_template_arg, paren) + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] for conf_key, setter, type_ in FIELDS: - if (value := config.get(conf_key)) is not None: - template_ = await cg.templatable(value, args, type_) - cg.add(getattr(var, setter)(template_)) - return var + if (value := config.get(conf_key)) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=type_) + body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + apply_args = [ + (ClimateCall.operator("ref"), "call"), + *args, + ] + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + apply_args, + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index 9e38ef6407..b0353a41f9 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -5,58 +5,26 @@ namespace esphome::climate { -// Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. -namespace climate_control_detail { -template struct Empty {}; -} // namespace climate_control_detail - -// X-macro: (type, field_name, call_setter, bit_index). Order and bit values must -// match the FIELDS table in __init__.py. call_setter is the ClimateCall method -// invoked in play() — for custom_fan_mode/custom_preset this dispatches to the -// std::string overload of set_fan_mode/set_preset respectively. -#define CLIMATE_CONTROL_FIELDS(X) \ - X(ClimateMode, mode, set_mode, 0) \ - X(float, target_temperature, set_target_temperature, 1) \ - X(float, target_temperature_low, set_target_temperature_low, 2) \ - X(float, target_temperature_high, set_target_temperature_high, 3) \ - X(float, target_humidity, set_target_humidity, 4) \ - X(ClimateFanMode, fan_mode, set_fan_mode, 5) \ - X(std::string, custom_fan_mode, set_fan_mode, 6) \ - X(ClimatePreset, preset, set_preset, 7) \ - X(std::string, custom_preset, set_preset, 8) \ - X(ClimateSwingMode, swing_mode, set_swing_mode, 9) - -template class ControlAction : public Action { +// All configured fields are baked into a single stateless lambda whose +// constants live in flash. The action only stores a function pointer +// (4 bytes) plus the parent (4 bytes), regardless of how many fields the +// user set. Trigger args are forwarded to the apply function so user +// lambdas (e.g. `target_temperature: !lambda "return x;"`) keep working. +template class ControlAction : public Action { public: - explicit ControlAction(Climate *climate) : climate_(climate) {} - -#define CLIMATE_FIELD_SETTER_(type, name, call_setter, idx) \ - template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } -#define CLIMATE_FIELD_APPLY_(type, name, call_setter, idx) \ - if constexpr ((Fields & (1 << (idx))) != 0) \ - call.call_setter(this->name##_.value(x...)); -#define CLIMATE_FIELD_DECL_(type, name, call_setter, idx) \ - [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableStorage, \ - climate_control_detail::Empty<(idx)>> \ - name##_{}; - - CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_SETTER_) + using ApplyFn = void (*)(ClimateCall &, Ts...); + ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { auto call = this->climate_->make_call(); - CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_APPLY_) + this->apply_(call, x...); call.perform(); } protected: Climate *climate_; - CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_DECL_) - -#undef CLIMATE_FIELD_DECL_ -#undef CLIMATE_FIELD_APPLY_ -#undef CLIMATE_FIELD_SETTER_ + ApplyFn apply_; }; -#undef CLIMATE_CONTROL_FIELDS class ControlTrigger : public Trigger { public: From c9db3048878c68735476eb2f56c32d5e8e16eebb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:32:11 -0500 Subject: [PATCH 0841/2214] [climate] Pass codegen-known length for static custom_fan_mode/custom_preset --- esphome/components/climate/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index b033776f27..0f8bdb8ab0 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -515,6 +515,11 @@ async def climate_control_to_code(config, action_id, template_arg, args): if isinstance(value, Lambda): inner = await cg.process_lambda(value, args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + elif type_ is cg.std_string: + # Static custom strings: emit a flash literal and pass the codegen-known + # length to skip the runtime strlen inside set_fan_mode/set_preset. + literal = cg.safe_exp(value) + body_lines.append(f"call.{setter}({literal}, {len(value)});") else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") From d0f4e89ef3a97e93e2ca20b307ddb0e356238280 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:37:52 -0500 Subject: [PATCH 0842/2214] [cover] Fold ControlAction/CoverPublishAction fields into stateless lambdas --- esphome/components/cover/__init__.py | 57 +++++++------- esphome/components/cover/automation.h | 77 ++++--------------- esphome/components/template/cover/__init__.py | 58 +++++++------- 3 files changed, 77 insertions(+), 115 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 25e92e5c22..30d3e23bf6 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -36,14 +36,14 @@ from esphome.const import ( DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, ID, CoroPriority, 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_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass +from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass from esphome.types import ConfigType, TemplateArgsType IS_PLATFORM_COMPONENT = True @@ -68,6 +68,7 @@ _LOGGER = logging.getLogger(__name__) cover_ns = cg.esphome_ns.namespace("cover") Cover = cover_ns.class_("Cover", cg.EntityBase) +CoverCall = cover_ns.class_("CoverCall") COVER_OPEN = cover_ns.COVER_OPEN COVER_CLOSED = cover_ns.COVER_CLOSED @@ -300,33 +301,35 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema( async def cover_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - # Bit positions must match COVER_CONTROL_FIELDS in automation.h. - # CONF_STATE and CONF_POSITION both map to set_position (bit 1). - field_mask = 0 - if CONF_STOP in config: - field_mask |= 1 << 0 - if CONF_STATE in config or CONF_POSITION in config: - field_mask |= 1 << 1 - if CONF_TILT in config: - field_mask |= 1 << 2 - - control_template_arg = cg.TemplateArguments( - cg.RawExpression(f"static_cast({field_mask})"), *template_arg - ) - var = cg.new_Pvariable(action_id, control_template_arg, paren) + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. + # CONF_STATE and CONF_POSITION are mutually exclusive in the schema and + # both map to set_position. + fields: list[tuple[object, str, object]] = [] if (stop := config.get(CONF_STOP)) is not None: - template_ = await cg.templatable(stop, args, cg.bool_) - cg.add(var.set_stop(template_)) - if (state := config.get(CONF_STATE)) is not None: - template_ = await cg.templatable(state, args, cg.float_) - cg.add(var.set_position(template_)) - if (position := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position, args, cg.float_) - cg.add(var.set_position(template_)) + fields.append((stop, "set_stop", cg.bool_)) + if (position := config.get(CONF_STATE, config.get(CONF_POSITION))) is not None: + fields.append((position, "set_position", cg.float_)) if (tilt := config.get(CONF_TILT)) is not None: - template_ = await cg.templatable(tilt, args, cg.float_) - cg.add(var.set_tilt(template_)) - return var + fields.append((tilt, "set_tilt", cg.float_)) + + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + for value, setter, type_ in fields: + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=type_) + body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + apply_args = [(CoverCall.operator("ref"), "call"), *args] + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + apply_args, + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) COVER_CONDITION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index e056a11ea5..42ca892142 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -46,89 +46,42 @@ template class ToggleAction : public Action { Cover *cover_; }; -// Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. -namespace cover_action_detail { -template struct Empty {}; -} // namespace cover_action_detail +// All configured fields are baked into a single stateless lambda whose +// constants live in flash. Each action stores only a function pointer +// (4 bytes) plus the parent (4 bytes), regardless of how many fields the +// user set. Trigger args are forwarded to the apply function so user +// lambdas (e.g. `position: !lambda "return x;"`) keep working. -// X-macro: (type, field_name, bit_index). Order/bits must match the -// inline field-mask computation in cover_control_to_code in __init__.py: -// stop=bit 0, position=bit 1 (also set by CONF_STATE), tilt=bit 2. -#define COVER_CONTROL_FIELDS(X) \ - X(bool, stop, 0) \ - X(float, position, 1) \ - X(float, tilt, 2) - -template class ControlAction : public Action { +template class ControlAction : public Action { public: - explicit ControlAction(Cover *cover) : cover_(cover) {} - -#define COVER_FIELD_SETTER_(type, name, idx) \ - template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } -#define COVER_FIELD_APPLY_(type, name, idx) \ - if constexpr ((Fields & (1 << (idx))) != 0) \ - call.set_##name(this->name##_.value(x...)); -#define COVER_FIELD_DECL_(type, name, idx) \ - [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableFn, \ - cover_action_detail::Empty<(idx)>> \ - name##_{}; - - COVER_CONTROL_FIELDS(COVER_FIELD_SETTER_) + using ApplyFn = void (*)(CoverCall &, Ts...); + ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} void play(const Ts &...x) override { auto call = this->cover_->make_call(); - COVER_CONTROL_FIELDS(COVER_FIELD_APPLY_) + this->apply_(call, x...); call.perform(); } protected: Cover *cover_; - COVER_CONTROL_FIELDS(COVER_FIELD_DECL_) + ApplyFn apply_; }; -#undef COVER_CONTROL_FIELDS -// X-macro: (type, field_name, bit_index). Order/bits must match the -// inline bitmask built in cover_template_publish_to_code in -// template/cover/__init__.py: position=bit 0 (also set by CONF_STATE), -// tilt=bit 1, current_operation=bit 2. -#define COVER_PUBLISH_FIELDS(X) \ - X(float, position, 0) \ - X(float, tilt, 1) \ - X(CoverOperation, current_operation, 2) - -template class CoverPublishAction : public Action { +template class CoverPublishAction : public Action { public: - CoverPublishAction(Cover *cover) : cover_(cover) {} - -#define COVER_PUBLISH_SETTER_(type, name, idx) \ - template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } -#define COVER_PUBLISH_APPLY_(type, name, idx) \ - if constexpr ((Fields & (1 << (idx))) != 0) \ - this->cover_->name = this->name##_.value(x...); -#define COVER_PUBLISH_DECL_(type, name, idx) \ - [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableFn, \ - cover_action_detail::Empty<(idx) + 8>> \ - name##_{}; - - COVER_PUBLISH_FIELDS(COVER_PUBLISH_SETTER_) + using ApplyFn = void (*)(Cover *, Ts...); + CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} void play(const Ts &...x) override { - COVER_PUBLISH_FIELDS(COVER_PUBLISH_APPLY_) + this->apply_(this->cover_, x...); this->cover_->publish_state(); } protected: Cover *cover_; - COVER_PUBLISH_FIELDS(COVER_PUBLISH_DECL_) - -#undef COVER_PUBLISH_DECL_ -#undef COVER_PUBLISH_APPLY_ -#undef COVER_PUBLISH_SETTER_ -#undef COVER_FIELD_DECL_ -#undef COVER_FIELD_APPLY_ -#undef COVER_FIELD_SETTER_ + ApplyFn apply_; }; -#undef COVER_PUBLISH_FIELDS template class CoverPositionCondition : public Condition { public: diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 7dd3cddf47..8b39dc5b63 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -19,6 +19,8 @@ from esphome.const import ( CONF_TILT_ACTION, CONF_TILT_LAMBDA, ) +from esphome.core import Lambda +from esphome.cpp_generator import LambdaExpression from .. import template_ns @@ -129,32 +131,36 @@ async def to_code(config): async def cover_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - # Bit positions must match COVER_PUBLISH_FIELDS in cover/automation.h. - # CONF_STATE and CONF_POSITION both map to set_position (bit 0). - field_mask = 0 - if CONF_STATE in config or CONF_POSITION in config: - field_mask |= 1 << 0 + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. + # The lambda mutates Cover fields directly (no CoverCall) since publish + # is a state push, not a control request. + # CONF_STATE and CONF_POSITION both map to position. + fields: list[tuple[object, str, object]] = [] + position_value = config.get(CONF_STATE, config.get(CONF_POSITION)) + if position_value is not None: + fields.append((position_value, "position", cg.float_)) if CONF_TILT in config: - field_mask |= 1 << 1 + fields.append((config[CONF_TILT], "tilt", cg.float_)) if CONF_CURRENT_OPERATION in config: - field_mask |= 1 << 2 - - publish_template_arg = cg.TemplateArguments( - cg.RawExpression(f"static_cast({field_mask})"), *template_arg - ) - var = cg.new_Pvariable(action_id, publish_template_arg, paren) - if CONF_STATE in config: - template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) - cg.add(var.set_position(template_)) - if CONF_POSITION in config: - template_ = await cg.templatable(config[CONF_POSITION], args, cg.float_) - cg.add(var.set_position(template_)) - if CONF_TILT in config: - template_ = await cg.templatable(config[CONF_TILT], args, cg.float_) - cg.add(var.set_tilt(template_)) - if CONF_CURRENT_OPERATION in config: - template_ = await cg.templatable( - config[CONF_CURRENT_OPERATION], args, cover.CoverOperation + fields.append( + (config[CONF_CURRENT_OPERATION], "current_operation", cover.CoverOperation) ) - cg.add(var.set_current_operation(template_)) - return var + + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + for value, field, type_ in fields: + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=type_) + body_lines.append(f"cover->{field} = ({inner})({fwd_args});") + else: + body_lines.append(f"cover->{field} = {cg.safe_exp(value)};") + + apply_args = [(cover.Cover.operator("ptr"), "cover"), *args] + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + apply_args, + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) From 22a70e3139da695c98bec86f0b87cc3641011c6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:40:09 -0500 Subject: [PATCH 0843/2214] [cover] Use FIELDS table to match light/climate codegen pattern --- esphome/components/cover/__init__.py | 21 +++++++++-------- esphome/components/template/cover/__init__.py | 23 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 30d3e23bf6..9d5b6ad49c 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -303,19 +303,20 @@ async def cover_control_to_code(config, action_id, template_arg, args): # All configured fields are folded into a single stateless lambda whose # constants live in flash; the action stores only a function pointer. - # CONF_STATE and CONF_POSITION are mutually exclusive in the schema and - # both map to set_position. - fields: list[tuple[object, str, object]] = [] - if (stop := config.get(CONF_STOP)) is not None: - fields.append((stop, "set_stop", cg.bool_)) - if (position := config.get(CONF_STATE, config.get(CONF_POSITION))) is not None: - fields.append((position, "set_position", cg.float_)) - if (tilt := config.get(CONF_TILT)) is not None: - fields.append((tilt, "set_tilt", cg.float_)) + # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most + # one is present and both dispatch to set_position. + FIELDS = ( + (CONF_STOP, "set_stop", cg.bool_), + (CONF_STATE, "set_position", cg.float_), + (CONF_POSITION, "set_position", cg.float_), + (CONF_TILT, "set_tilt", cg.float_), + ) fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] - for value, setter, type_ in fields: + for conf_key, setter, type_ in FIELDS: + if (value := config.get(conf_key)) is None: + continue if isinstance(value, Lambda): inner = await cg.process_lambda(value, args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 8b39dc5b63..1af003d75d 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -135,21 +135,20 @@ async def cover_template_publish_to_code(config, action_id, template_arg, args): # constants live in flash; the action stores only a function pointer. # The lambda mutates Cover fields directly (no CoverCall) since publish # is a state push, not a control request. - # CONF_STATE and CONF_POSITION both map to position. - fields: list[tuple[object, str, object]] = [] - position_value = config.get(CONF_STATE, config.get(CONF_POSITION)) - if position_value is not None: - fields.append((position_value, "position", cg.float_)) - if CONF_TILT in config: - fields.append((config[CONF_TILT], "tilt", cg.float_)) - if CONF_CURRENT_OPERATION in config: - fields.append( - (config[CONF_CURRENT_OPERATION], "current_operation", cover.CoverOperation) - ) + # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most + # one is present and both map to the position field. + FIELDS = ( + (CONF_STATE, "position", cg.float_), + (CONF_POSITION, "position", cg.float_), + (CONF_TILT, "tilt", cg.float_), + (CONF_CURRENT_OPERATION, "current_operation", cover.CoverOperation), + ) fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] - for value, field, type_ in fields: + for conf_key, field, type_ in FIELDS: + if (value := config.get(conf_key)) is None: + continue if isinstance(value, Lambda): inner = await cg.process_lambda(value, args, return_type=type_) body_lines.append(f"cover->{field} = ({inner})({fwd_args});") From 4009c498d956b17dd742406f1cdabaa7b4dc5700 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:50:59 -0500 Subject: [PATCH 0844/2214] [esp32] Fix hal.cpp include order: defines.h before crash_handler.h crash_handler.h is itself guarded by #ifdef USE_ESP32_CRASH_HANDLER, so when hal.cpp included it before defines.h, the namespace block was empty at parse time and arch_init()'s USE_ESP32_CRASH_HANDLER branch failed with: error: 'crash_handler_read_and_clear' is not a member of 'esphome::esp32' Pull defines.h first so USE_ESP32_CRASH_HANDLER is defined before crash_handler.h is parsed. --- esphome/components/esp32/hal.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/hal.cpp b/esphome/components/esp32/hal.cpp index 3a0836a507..f6199d557f 100644 --- a/esphome/components/esp32/hal.cpp +++ b/esphome/components/esp32/hal.cpp @@ -1,7 +1,9 @@ #ifdef USE_ESP32 -#include "crash_handler.h" +// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set +// before crash_handler.h's #ifdef-guarded namespace block is parsed. #include "esphome/core/defines.h" +#include "crash_handler.h" #include "esphome/core/hal.h" #include From 83a634b58d66c515ff6a0ecfa2a214f69931abf9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:52:29 -0500 Subject: [PATCH 0845/2214] [light] Take trigger args as const ref in ApplyFn; drop hard-coded byte sizes --- esphome/components/light/automation.h | 10 +++++----- esphome/components/light/automation.py | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 296042fe42..a5c73997b0 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -32,13 +32,13 @@ template class ToggleAction : public A }; // All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores a function pointer -// (4 bytes) plus the parent (4 bytes), regardless of how many fields the -// user set. Trigger args are forwarded to the apply function so user -// lambdas (e.g. `brightness: !lambda "return x;"`) keep working. +// constants live in flash. The action only stores one function pointer +// plus one parent pointer, regardless of how many fields the user set. +// Trigger args are forwarded to the apply function so user lambdas +// (e.g. `brightness: !lambda "return x;"`) keep working. template class LightControlAction : public Action { public: - using ApplyFn = void (*)(LightState *, LightCall &, Ts...); + using ApplyFn = void (*)(LightState *, LightCall &, const Ts &...); LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} void play(const Ts &...x) override { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 18de37150b..ca4018a975 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -230,10 +230,11 @@ async def light_control_to_code(config, action_id, template_arg, args): f"call.set_effect(static_cast({_resolve_effect_index(config)}));" ) + # Match LightControlAction::ApplyFn signature: const Ts &... for trigger args. apply_args = [ (LightState.operator("ptr"), "parent"), (LightCall.operator("ref"), "call"), - *args, + *((t.operator("const").operator("ref"), n) for t, n in args), ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], From a7a45a556bb434a11e8543415147b70331775e66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:54:23 -0500 Subject: [PATCH 0846/2214] [climate] Address review feedback (const ref args, utf-8 byte length, comments) --- esphome/components/climate/__init__.py | 18 +++++++++++------- esphome/components/climate/automation.h | 10 +++++----- .../fixtures/climate_control_action.yaml | 10 +++++----- .../integration/test_climate_control_action.py | 14 +++++++------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 0f8bdb8ab0..7c9002d6dc 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -490,9 +490,9 @@ async def climate_control_to_code(config, action_id, template_arg, args): # All configured fields are folded into a single stateless lambda whose # constants live in flash; the action stores only a function pointer. - # `call_setter` is the ClimateCall method invoked in the lambda body — - # for custom_fan_mode/custom_preset this dispatches to the std::string - # overload of set_fan_mode/set_preset respectively. + # For custom_fan_mode/custom_preset the static-string path emits the + # (const char *, size_t) overload of set_fan_mode/set_preset to avoid + # constructing a std::string and calling runtime strlen. FIELDS = ( (CONF_MODE, "set_mode", ClimateMode), (CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), @@ -516,16 +516,20 @@ async def climate_control_to_code(config, action_id, template_arg, args): inner = await cg.process_lambda(value, args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") elif type_ is cg.std_string: - # Static custom strings: emit a flash literal and pass the codegen-known - # length to skip the runtime strlen inside set_fan_mode/set_preset. + # Static custom strings: emit a flash literal and pass the + # UTF-8 byte length to skip the runtime strlen inside + # set_fan_mode/set_preset. literal = cg.safe_exp(value) - body_lines.append(f"call.{setter}({literal}, {len(value)});") + body_lines.append( + f"call.{setter}({literal}, {len(value.encode('utf-8'))});" + ) else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. apply_args = [ (ClimateCall.operator("ref"), "call"), - *args, + *((t.operator("const").operator("ref"), n) for t, n in args), ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index b0353a41f9..71d23fd6b6 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -6,13 +6,13 @@ namespace esphome::climate { // All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores a function pointer -// (4 bytes) plus the parent (4 bytes), regardless of how many fields the -// user set. Trigger args are forwarded to the apply function so user -// lambdas (e.g. `target_temperature: !lambda "return x;"`) keep working. +// constants live in flash. The action only stores one function pointer +// plus one parent pointer, regardless of how many fields the user set. +// Trigger args are forwarded to the apply function so user lambdas +// (e.g. `target_temperature: !lambda "return x;"`) keep working. template class ControlAction : public Action { public: - using ApplyFn = void (*)(ClimateCall &, Ts...); + using ApplyFn = void (*)(ClimateCall &, const Ts &...); ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml index 596151ea51..1dd300fcc2 100644 --- a/tests/integration/fixtures/climate_control_action.yaml +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -44,7 +44,7 @@ climate: max_temperature: 30 °C button: - # Test 1: mode only (mask 1) + # mode only - platform: template id: btn_mode name: "Set Mode Heat" @@ -53,7 +53,7 @@ button: id: test_climate mode: HEAT - # Test 2: mode + low + high (mask 0b1101 = 13) + # mode + target_temperature_low + target_temperature_high - platform: template id: btn_mode_temps name: "Set Mode Temps" @@ -64,7 +64,7 @@ button: target_temperature_low: 19.0 °C target_temperature_high: 23.0 °C - # Test 3: just target_temp_low (mask 0b0100 = 4) + # target_temperature_low only - platform: template id: btn_low_only name: "Set Low Only" @@ -73,7 +73,7 @@ button: id: test_climate target_temperature_low: 17.5 °C - # Test 4: lambda for target_temperature_high (exercises lambda path) + # Lambda path: target_temperature_high computed at runtime - platform: template id: btn_lambda_high name: "Lambda High" @@ -82,7 +82,7 @@ button: id: test_climate target_temperature_high: !lambda "return id(test_target_temp);" - # Test 5: turn off via mode + # mode only — turn off via mode - platform: template id: btn_off name: "Set Off" diff --git a/tests/integration/test_climate_control_action.py b/tests/integration/test_climate_control_action.py index 0070f9829e..2b0293b209 100644 --- a/tests/integration/test_climate_control_action.py +++ b/tests/integration/test_climate_control_action.py @@ -1,8 +1,8 @@ """Integration test for climate ControlAction. Tests that climate.control automation actions work correctly with the -per-instance bitmask field storage. Exercises multiple field combinations -to cover different bitmask variants and the lambda path. +single stateless apply lambda/function pointer implementation. Exercises +multiple field combinations and the lambda path. """ from __future__ import annotations @@ -61,24 +61,24 @@ async def test_climate_control_action( client.button_command(btn.key) return await wait_for_climate_state() - # Test 1: mode only (mask 1) — set HEAT + # mode only — set HEAT state = await press_and_wait("Set Mode Heat") assert state.mode == ClimateMode.HEAT - # Test 2: mode + low + high (mask 13) — HEAT_COOL with both temps + # mode + target_temperature_low + target_temperature_high state = await press_and_wait("Set Mode Temps") assert state.mode == ClimateMode.HEAT_COOL assert state.target_temperature_low == pytest.approx(19.0, abs=0.5) assert state.target_temperature_high == pytest.approx(23.0, abs=0.5) - # Test 3: low only (mask 4) + # target_temperature_low only state = await press_and_wait("Set Low Only") assert state.target_temperature_low == pytest.approx(17.5, abs=0.5) - # Test 4: lambda high — global is 21.5 + # lambda path: target_temperature_high computed at runtime state = await press_and_wait("Lambda High") assert state.target_temperature_high == pytest.approx(21.5, abs=0.5) - # Test 5: turn off via mode (mask 1) + # mode only — turn off via mode state = await press_and_wait("Set Off") assert state.mode == ClimateMode.OFF From f7c8df823458f693c2452a33cb66344e53d049a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:56:18 -0500 Subject: [PATCH 0847/2214] [cover] Address review feedback (const ref args, drop mask numbering) --- esphome/components/cover/__init__.py | 6 ++++- esphome/components/cover/automation.h | 12 +++++----- esphome/components/template/cover/__init__.py | 6 ++++- .../fixtures/cover_control_action.yaml | 18 +++++++-------- .../integration/test_cover_control_action.py | 22 +++++++++---------- 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 9d5b6ad49c..5a27ceb8fa 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -323,7 +323,11 @@ async def cover_control_to_code(config, action_id, template_arg, args): else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - apply_args = [(CoverCall.operator("ref"), "call"), *args] + # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. + apply_args = [ + (CoverCall.operator("ref"), "call"), + *((t.operator("const").operator("ref"), n) for t, n in args), + ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], apply_args, diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 42ca892142..e2384c2359 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -47,14 +47,14 @@ template class ToggleAction : public Action { }; // All configured fields are baked into a single stateless lambda whose -// constants live in flash. Each action stores only a function pointer -// (4 bytes) plus the parent (4 bytes), regardless of how many fields the -// user set. Trigger args are forwarded to the apply function so user -// lambdas (e.g. `position: !lambda "return x;"`) keep working. +// constants live in flash. Each action stores only one function pointer +// plus one parent pointer, regardless of how many fields the user set. +// Trigger args are forwarded to the apply function so user lambdas +// (e.g. `position: !lambda "return x;"`) keep working. template class ControlAction : public Action { public: - using ApplyFn = void (*)(CoverCall &, Ts...); + using ApplyFn = void (*)(CoverCall &, const Ts &...); ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} void play(const Ts &...x) override { @@ -70,7 +70,7 @@ template class ControlAction : public Action { template class CoverPublishAction : public Action { public: - using ApplyFn = void (*)(Cover *, Ts...); + using ApplyFn = void (*)(Cover *, const Ts &...); CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} void play(const Ts &...x) override { diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 1af003d75d..bcf0c54c7f 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -155,7 +155,11 @@ async def cover_template_publish_to_code(config, action_id, template_arg, args): else: body_lines.append(f"cover->{field} = {cg.safe_exp(value)};") - apply_args = [(cover.Cover.operator("ptr"), "cover"), *args] + # Match CoverPublishAction::ApplyFn: const Ts &... for trigger args. + apply_args = [ + (cover.Cover.operator("ptr"), "cover"), + *((t.operator("const").operator("ref"), n) for t, n in args), + ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], apply_args, diff --git a/tests/integration/fixtures/cover_control_action.yaml b/tests/integration/fixtures/cover_control_action.yaml index f9eaa2ceee..085d632796 100644 --- a/tests/integration/fixtures/cover_control_action.yaml +++ b/tests/integration/fixtures/cover_control_action.yaml @@ -36,7 +36,7 @@ cover: id(test_cover).publish_state(); button: - # Test 1: cover.control with position only (mask 0b010 = 2) + # cover.control: position only - platform: template id: btn_position name: "Set Position" @@ -45,7 +45,7 @@ button: id: test_cover position: 50% - # Test 2: cover.control with tilt only (mask 0b100 = 4) + # cover.control: tilt only - platform: template id: btn_tilt name: "Set Tilt" @@ -54,7 +54,7 @@ button: id: test_cover tilt: 75% - # Test 3: cover.control with position + tilt (mask 0b110 = 6) + # cover.control: position + tilt - platform: template id: btn_pos_tilt name: "Set Pos Tilt" @@ -64,7 +64,7 @@ button: position: 25% tilt: 30% - # Test 4: cover.control with state alias (sets position bit via CONF_STATE) + # cover.control: state alias for position - platform: template id: btn_open_state name: "Open State" @@ -73,7 +73,7 @@ button: id: test_cover state: OPEN - # Test 5: cover.control with lambda position (exercises lambda path) + # cover.control: lambda position (exercises lambda path) - platform: template id: btn_lambda_position name: "Lambda Position" @@ -82,7 +82,7 @@ button: id: test_cover position: !lambda "return id(test_position);" - # Test 6: cover.template.publish position only (mask 0b001) + # cover.template.publish: position only - platform: template id: btn_publish_pos name: "Publish Pos" @@ -91,7 +91,7 @@ button: id: test_cover position: 0.6 - # Test 7: cover.template.publish current_operation only (mask 0b100) + # cover.template.publish: current_operation only - platform: template id: btn_publish_op name: "Publish Op" @@ -100,8 +100,8 @@ button: id: test_cover current_operation: OPENING - # Test 8: cover.control with stop only (mask 0b001 = 1) — runs after - # Publish Op so we can verify current_operation transitions OPENING -> IDLE + # cover.control: stop only — runs after Publish Op so the test can + # verify current_operation transitions OPENING -> IDLE. - platform: template id: btn_stop name: "Stop Cover" diff --git a/tests/integration/test_cover_control_action.py b/tests/integration/test_cover_control_action.py index 29ece37ce5..9c7395371b 100644 --- a/tests/integration/test_cover_control_action.py +++ b/tests/integration/test_cover_control_action.py @@ -1,8 +1,8 @@ """Integration test for cover ControlAction and CoverPublishAction. Tests that cover.control and cover.template.publish automation actions -work correctly with the per-instance bitmask field storage. Exercises -multiple field combinations to cover the bitmask variants. +work correctly with the single stateless apply lambda/function pointer +implementation. Exercises multiple field combinations and the lambda path. """ from __future__ import annotations @@ -55,38 +55,38 @@ async def test_cover_control_action( client.button_command(btn.key) return await wait_for_cover_state() - # Test 1: position only (mask 2) + # cover.control: position only state = await press_and_wait("Set Position") assert state.position == pytest.approx(0.5, abs=0.01) - # Test 2: tilt only (mask 4) + # cover.control: tilt only state = await press_and_wait("Set Tilt") assert state.tilt == pytest.approx(0.75, abs=0.01) - # Test 3: position + tilt (mask 6) + # cover.control: position + tilt state = await press_and_wait("Set Pos Tilt") assert state.position == pytest.approx(0.25, abs=0.01) assert state.tilt == pytest.approx(0.30, abs=0.01) - # Test 4: state: OPEN (CONF_STATE alias for position 1.0) + # cover.control: state alias for position 1.0 state = await press_and_wait("Open State") assert state.position == pytest.approx(1.0, abs=0.01) - # Test 5: lambda position (test_position global = 0.42) + # cover.control: lambda position (test_position global = 0.42) state = await press_and_wait("Lambda Position") assert state.position == pytest.approx(0.42, abs=0.01) - # Test 6: cover.template.publish position only + # cover.template.publish: position only state = await press_and_wait("Publish Pos") assert state.position == pytest.approx(0.6, abs=0.01) - # Test 7: cover.template.publish current_operation only + # cover.template.publish: current_operation only state = await press_and_wait("Publish Op") # CoverOperation.OPENING == 1 assert state.current_operation == 1 - # Test 8: cover.control stop only (mask 1) - # The template cover's stop_action publishes current_operation: IDLE + # cover.control: stop only — template cover's stop_action publishes + # current_operation: IDLE. state = await press_and_wait("Stop Cover") # CoverOperation.IDLE == 0 assert state.current_operation == 0 From cbd54e47eb8ff5351b97f05cd8fbfde9dd850c65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 07:03:56 -0500 Subject: [PATCH 0848/2214] [libretiny] Address Copilot review on #16113 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hal.cpp: include components/libretiny/core.h so the lt_* C API (lt_wdt_enable, lt_reboot, lt_gpio_recover) and the LT_GPIO_RECOVER macro are properly declared rather than relying on transitive includes. core.h pulls which is libretiny's umbrella header. - hal_libretiny.h: preserve the HOT attribute on arch_feed_wdt() — the out-of-line wrapper had it; the inline now uses __attribute__((hot, always_inline)) to keep the placement hint consistent with the other platforms. The arch_restart() noreturn concern is already covered by the dispatcher hal.h declaration ('void __attribute__((noreturn)) arch_restart();' is visible when the body is parsed). --- esphome/components/libretiny/hal.cpp | 1 + esphome/core/hal/hal_libretiny.h | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp index 64b8de8f86..4e94d5242b 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -1,5 +1,6 @@ #ifdef USE_LIBRETINY +#include "core.h" #include "esphome/core/hal.h" #include "preferences.h" diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index 2c8b45812a..e519698ce0 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -95,7 +95,7 @@ __attribute__((always_inline)) inline uint32_t millis() { return static_cast Date: Wed, 29 Apr 2026 07:06:55 -0500 Subject: [PATCH 0849/2214] [cover] Extract build_apply_lambda_action helper, add typing, use kwargs --- esphome/components/cover/__init__.py | 73 +++++++++++++------ esphome/components/template/cover/__init__.py | 69 ++++++++---------- 2 files changed, 81 insertions(+), 61 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 5a27ceb8fa..e6a3c29c1d 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -295,37 +295,38 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA, synchronous=True -) -async def cover_control_to_code(config, action_id, template_arg, args): +async def build_apply_lambda_action( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + fields: tuple[tuple[str, str, object], ...], + prefix_args: list[tuple[object, str]], + statement_fn, +) -> MockObj: + """Fold configured fields into a single stateless apply lambda action. + + Used by both `cover.control` and `cover.template.publish` (and shared + with the template/cover platform). Constants are emitted as flash + immediates; user lambdas are invoked inline so trigger args still flow. + The trigger arg types are wrapped as `const T &` to match the + `void (*)(..., const Ts &...)` ApplyFn signature. + """ paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most - # one is present and both dispatch to set_position. - FIELDS = ( - (CONF_STOP, "set_stop", cg.bool_), - (CONF_STATE, "set_position", cg.float_), - (CONF_POSITION, "set_position", cg.float_), - (CONF_TILT, "set_tilt", cg.float_), - ) - fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] - for conf_key, setter, type_ in FIELDS: + for conf_key, target, type_ in fields: if (value := config.get(conf_key)) is None: continue if isinstance(value, Lambda): inner = await cg.process_lambda(value, args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + value_expr = f"({inner})({fwd_args})" else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + value_expr = str(cg.safe_exp(value)) + body_lines.append(statement_fn(target, value_expr)) - # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. apply_args = [ - (CoverCall.operator("ref"), "call"), + *prefix_args, *((t.operator("const").operator("ref"), n) for t, n in args), ] apply_lambda = LambdaExpression( @@ -337,6 +338,36 @@ async def cover_control_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both dispatch to set_position. +_COVER_CONTROL_FIELDS = ( + (CONF_STOP, "set_stop", cg.bool_), + (CONF_STATE, "set_position", cg.float_), + (CONF_POSITION, "set_position", cg.float_), + (CONF_TILT, "set_tilt", cg.float_), +) + + +@automation.register_action( + "cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA, synchronous=True +) +async def cover_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await build_apply_lambda_action( + config=config, + action_id=action_id, + template_arg=template_arg, + args=args, + fields=_COVER_CONTROL_FIELDS, + prefix_args=[(CoverCall.operator("ref"), "call")], + statement_fn=lambda setter, expr: f"call.{setter}({expr});", + ) + + COVER_CONDITION_SCHEMA = cv.maybe_simple_value( {cv.Required(CONF_ID): cv.use_id(Cover)}, key=CONF_ID ) diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index bcf0c54c7f..3cf8779847 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -19,8 +19,9 @@ from esphome.const import ( CONF_TILT_ACTION, CONF_TILT_LAMBDA, ) -from esphome.core import Lambda -from esphome.cpp_generator import LambdaExpression +from esphome.core import ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType from .. import template_ns @@ -112,6 +113,16 @@ async def to_code(config): cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both map to the position field. +_COVER_PUBLISH_FIELDS = ( + (CONF_STATE, "position", cg.float_), + (CONF_POSITION, "position", cg.float_), + (CONF_TILT, "tilt", cg.float_), + (CONF_CURRENT_OPERATION, "current_operation", cover.CoverOperation), +) + + @automation.register_action( "cover.template.publish", cover.CoverPublishAction, @@ -128,42 +139,20 @@ async def to_code(config): ), synchronous=True, ) -async def cover_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - # The lambda mutates Cover fields directly (no CoverCall) since publish - # is a state push, not a control request. - # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most - # one is present and both map to the position field. - FIELDS = ( - (CONF_STATE, "position", cg.float_), - (CONF_POSITION, "position", cg.float_), - (CONF_TILT, "tilt", cg.float_), - (CONF_CURRENT_OPERATION, "current_operation", cover.CoverOperation), +async def cover_template_publish_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + # Mutates Cover fields directly (no CoverCall) since publish is a state + # push, not a control request. + return await cover.build_apply_lambda_action( + config=config, + action_id=action_id, + template_arg=template_arg, + args=args, + fields=_COVER_PUBLISH_FIELDS, + prefix_args=[(cover.Cover.operator("ptr"), "cover")], + statement_fn=lambda field, expr: f"cover->{field} = {expr};", ) - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - for conf_key, field, type_ in FIELDS: - if (value := config.get(conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=type_) - body_lines.append(f"cover->{field} = ({inner})({fwd_args});") - else: - body_lines.append(f"cover->{field} = {cg.safe_exp(value)};") - - # Match CoverPublishAction::ApplyFn: const Ts &... for trigger args. - apply_args = [ - (cover.Cover.operator("ptr"), "cover"), - *((t.operator("const").operator("ref"), n) for t, n in args), - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) From 4e921f8127fa60bd1810c3e9ae5cdd5c79f16104 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 07:09:55 -0500 Subject: [PATCH 0850/2214] [cover] Use ApplyField dataclass for the apply-lambda field list --- esphome/components/cover/__init__.py | 41 ++++++++++++++----- esphome/components/template/cover/__init__.py | 10 ++--- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index e6a3c29c1d..954ad7a345 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable +from dataclasses import dataclass import logging from esphome import automation @@ -295,14 +297,31 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema( ) +@dataclass(frozen=True) +class ApplyField: + """One field in a folded-lambda action. + + `conf_key` is the YAML key looked up in `config`. When present, the + helper emits `statement_fn(target, value_expr)` into the lambda body. + `target` is whatever the statement function needs to identify the + field (typically a setter name like `"set_position"` or a struct + member like `"position"`). `type_` is the C++ return type for + `cg.process_lambda` when the value is a user lambda. + """ + + conf_key: str + target: str + type_: object + + async def build_apply_lambda_action( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, - fields: tuple[tuple[str, str, object], ...], + fields: tuple[ApplyField, ...], prefix_args: list[tuple[object, str]], - statement_fn, + statement_fn: Callable[[str, str], str], ) -> MockObj: """Fold configured fields into a single stateless apply lambda action. @@ -315,15 +334,15 @@ async def build_apply_lambda_action( paren = await cg.get_variable(config[CONF_ID]) fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] - for conf_key, target, type_ in fields: - if (value := config.get(conf_key)) is None: + for field in fields: + if (value := config.get(field.conf_key)) is None: continue if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=type_) + inner = await cg.process_lambda(value, args, return_type=field.type_) value_expr = f"({inner})({fwd_args})" else: value_expr = str(cg.safe_exp(value)) - body_lines.append(statement_fn(target, value_expr)) + body_lines.append(statement_fn(field.target, value_expr)) apply_args = [ *prefix_args, @@ -340,11 +359,11 @@ async def build_apply_lambda_action( # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most # one is present and both dispatch to set_position. -_COVER_CONTROL_FIELDS = ( - (CONF_STOP, "set_stop", cg.bool_), - (CONF_STATE, "set_position", cg.float_), - (CONF_POSITION, "set_position", cg.float_), - (CONF_TILT, "set_tilt", cg.float_), +_COVER_CONTROL_FIELDS: tuple[ApplyField, ...] = ( + ApplyField(CONF_STOP, "set_stop", cg.bool_), + ApplyField(CONF_STATE, "set_position", cg.float_), + ApplyField(CONF_POSITION, "set_position", cg.float_), + ApplyField(CONF_TILT, "set_tilt", cg.float_), ) diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 3cf8779847..7cb50df84c 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -115,11 +115,11 @@ async def to_code(config): # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most # one is present and both map to the position field. -_COVER_PUBLISH_FIELDS = ( - (CONF_STATE, "position", cg.float_), - (CONF_POSITION, "position", cg.float_), - (CONF_TILT, "tilt", cg.float_), - (CONF_CURRENT_OPERATION, "current_operation", cover.CoverOperation), +_COVER_PUBLISH_FIELDS: tuple[cover.ApplyField, ...] = ( + cover.ApplyField(CONF_STATE, "position", cg.float_), + cover.ApplyField(CONF_POSITION, "position", cg.float_), + cover.ApplyField(CONF_TILT, "tilt", cg.float_), + cover.ApplyField(CONF_CURRENT_OPERATION, "current_operation", cover.CoverOperation), ) From 9c5d1a7fed048e4d4be3171c92dffaab8d217fdc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 07:17:37 -0500 Subject: [PATCH 0851/2214] [libretiny] Inline delayMicroseconds() in hal_libretiny.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper is a one-liner forwarding to Arduino's ::delayMicroseconds. Add the forward decl to the existing NOLINTBEGIN block (alongside yield, delay, micros, millis) and inline the body — same pattern as the other Arduino-flavored wrappers in this header. Drops the out-of-line copy from components/libretiny/hal.cpp. --- esphome/components/libretiny/hal.cpp | 7 +++---- esphome/core/hal/hal_libretiny.h | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp index 4e94d5242b..e6dbb7296c 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -14,10 +14,9 @@ namespace esphome::libretiny {} // namespace esphome::libretiny namespace esphome { -// yield(), delay(), micros(), millis(), millis_64(), arch_feed_wdt(), -// arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() inlined in -// core/hal/hal_libretiny.h. -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } +// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), +// arch_feed_wdt(), arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() +// inlined in core/hal/hal_libretiny.h. void arch_init() { libretiny::setup_preferences(); diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index e519698ce0..db0fc11bfb 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -51,6 +51,7 @@ extern "C" void yield(void); extern "C" void delay(unsigned long ms); extern "C" unsigned long micros(void); extern "C" unsigned long millis(void); +extern "C" void delayMicroseconds(unsigned int us); // NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) // Forward decls from libretiny's family for the inline arch_* @@ -95,11 +96,12 @@ __attribute__((always_inline)) inline uint32_t millis() { return static_cast Date: Wed, 29 Apr 2026 07:24:24 -0500 Subject: [PATCH 0852/2214] [esp8266] Add bare arch_get_cpu_cycle_count() decl in hal_esp8266.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Followup #16111 dropped the cross-platform arch_get_cpu_cycle_count() declaration from hal.h dispatcher and added bare decls to libretiny, rp2040, host, and zephyr per-platform headers — but missed hal_esp8266.h. ESP8266 callers (spi.h, uart_component_esp8266.cpp) now fail clang-tidy with 'use of undeclared identifier'. The body is still out-of-line in components/esp8266/core.cpp on this PR; the inline def is added on the chained #16112 PR. Add a bare decl here for now so #16111 builds in isolation. --- esphome/core/hal/hal_esp8266.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h index 04326a3579..484118f1f5 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/core/hal/hal_esp8266.h @@ -60,6 +60,8 @@ __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_ __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } __attribute__((always_inline)) inline void arch_feed_wdt() { system_soft_wdt_feed(); } +uint32_t arch_get_cpu_cycle_count(); + } // namespace esphome #endif // USE_ESP8266 From c5661c72dc5c63c5c3407ef92e40a406c5196426 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 07:25:07 -0500 Subject: [PATCH 0853/2214] [esp8266] Drop redundant arch_get_cpu_cycle_count() decl The inline def above was already in place; the bare decl was inherited via the merge from followup/hal-esp32 and is now redundant on this PR. --- esphome/core/hal/hal_esp8266.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h index 976b697686..b6e3b1ee3c 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/core/hal/hal_esp8266.h @@ -66,8 +66,6 @@ __attribute__((always_inline)) inline void arch_init() {} __attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } __attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return F_CPU; } -uint32_t arch_get_cpu_cycle_count(); - } // namespace esphome #endif // USE_ESP8266 From ae5b211c8938f294bf1cbd0811b8c5eb0eab8a4e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:30:35 -0400 Subject: [PATCH 0854/2214] [api] Avoid JsonDocument copy-and-swap operator= in ActionResponse ctor (#16106) --- esphome/components/api/homeassistant_service.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 9d14061d07..aef046fbb0 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -78,7 +78,8 @@ class ActionResponse { : success_(success), error_message_(error_message) { if (data == nullptr || data_len == 0) return; - this->json_document_ = json::parse_json(data, data_len); + JsonDocument tmp = json::parse_json(data, data_len); + swap(this->json_document_, tmp); } #endif From 79da2b9704cb115624697bded6c0294f3b9db528 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:30:46 -0400 Subject: [PATCH 0855/2214] [time] Fix bugprone-unchecked-optional-access in CronTrigger::check_time_ (#16107) --- esphome/components/time/automation.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/time/automation.cpp b/esphome/components/time/automation.cpp index 7eb99cfe74..3242669343 100644 --- a/esphome/components/time/automation.cpp +++ b/esphome/components/time/automation.cpp @@ -31,13 +31,14 @@ void CronTrigger::check_time_() { return; if (this->last_check_.has_value()) { - if (*this->last_check_ > time && this->last_check_->timestamp - time.timestamp > MAX_TIMESTAMP_DRIFT) { + auto &last_check = *this->last_check_; + if (last_check > time && last_check.timestamp - time.timestamp > MAX_TIMESTAMP_DRIFT) { // We went back in time (a lot), probably caused by time synchronization ESP_LOGW(TAG, "Time has jumped back!"); - } else if (*this->last_check_ >= time) { + } else if (last_check >= time) { // already handled this one return; - } else if (time > *this->last_check_ && time.timestamp - this->last_check_->timestamp > MAX_TIMESTAMP_DRIFT) { + } else if (time > last_check && time.timestamp - last_check.timestamp > MAX_TIMESTAMP_DRIFT) { // We went ahead in time (a lot), probably caused by time synchronization ESP_LOGW(TAG, "Time has jumped ahead!"); this->last_check_ = time; @@ -45,11 +46,11 @@ void CronTrigger::check_time_() { } while (true) { - this->last_check_->increment_second(); - if (*this->last_check_ >= time) + last_check.increment_second(); + if (last_check >= time) break; - if (this->matches(*this->last_check_)) + if (this->matches(last_check)) this->trigger(); } } From 0a497d3c22be8c4c83466178181f4c6b00179994 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 08:35:17 -0500 Subject: [PATCH 0856/2214] [light] Fold LightControlAction fields into a single stateless lambda (#16118) --- esphome/components/light/automation.h | 54 ++++----------------- esphome/components/light/automation.py | 67 ++++++++++++++------------ esphome/components/light/types.py | 1 + 3 files changed, 46 insertions(+), 76 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index bc6fd84709..a5c73997b0 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -31,60 +31,26 @@ template class ToggleAction : public A transition_length_{}; }; -// Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. -namespace light_control_detail { -template struct Empty {}; -} // namespace light_control_detail - -// X-macro: (type, field_name, bit_index). Order and bit values must match -// the FIELDS table in automation.py. -#define LIGHT_CONTROL_FIELDS(X) \ - X(ColorMode, color_mode, 0) \ - X(bool, state, 1) \ - X(uint32_t, transition_length, 2) \ - X(uint32_t, flash_length, 3) \ - X(float, brightness, 4) \ - X(float, color_brightness, 5) \ - X(float, red, 6) \ - X(float, green, 7) \ - X(float, blue, 8) \ - X(float, white, 9) \ - X(float, color_temperature, 10) \ - X(float, cold_white, 11) \ - X(float, warm_white, 12) \ - X(uint32_t, effect, 13) - -template class LightControlAction : public Action { +// All configured fields are baked into a single stateless lambda whose +// constants live in flash. The action only stores one function pointer +// plus one parent pointer, regardless of how many fields the user set. +// Trigger args are forwarded to the apply function so user lambdas +// (e.g. `brightness: !lambda "return x;"`) keep working. +template class LightControlAction : public Action { public: - explicit LightControlAction(LightState *parent) : parent_(parent) {} - -#define LIGHT_FIELD_SETTER_(type, name, idx) \ - template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } -#define LIGHT_FIELD_APPLY_(type, name, idx) \ - if constexpr ((Fields & (1 << (idx))) != 0) \ - call.set_##name(this->name##_.value(x...)); -#define LIGHT_FIELD_DECL_(type, name, idx) \ - [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableFn, \ - light_control_detail::Empty<(idx)>> \ - name##_{}; - - LIGHT_CONTROL_FIELDS(LIGHT_FIELD_SETTER_) + using ApplyFn = void (*)(LightState *, LightCall &, const Ts &...); + LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} void play(const Ts &...x) override { auto call = this->parent_->make_call(); - LIGHT_CONTROL_FIELDS(LIGHT_FIELD_APPLY_) + this->apply_(this->parent_, call, x...); call.perform(); } protected: LightState *parent_; - LIGHT_CONTROL_FIELDS(LIGHT_FIELD_DECL_) - -#undef LIGHT_FIELD_DECL_ -#undef LIGHT_FIELD_APPLY_ -#undef LIGHT_FIELD_SETTER_ + ApplyFn apply_; }; -#undef LIGHT_CONTROL_FIELDS template class DimRelativeAction : public Action { public: diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index c666c98e42..ca4018a975 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -37,6 +37,7 @@ from .types import ( AddressableSet, ColorMode, DimRelativeAction, + LightCall, LightControlAction, LightIsOffCondition, LightIsOnCondition, @@ -181,8 +182,8 @@ def _resolve_effect_index(config: ConfigType) -> int: async def light_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - # Order/bits must match LIGHT_CONTROL_FIELDS in automation.h. - # EFFECT has special handling below; setter=None skips the generic loop. + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. FIELDS = ( (CONF_COLOR_MODE, "set_color_mode", ColorMode), (CONF_STATE, "set_state", cg.bool_), @@ -197,49 +198,51 @@ async def light_control_to_code(config, action_id, template_arg, args): (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), (CONF_COLD_WHITE, "set_cold_white", cg.float_), (CONF_WARM_WHITE, "set_warm_white", cg.float_), - (CONF_EFFECT, None, cg.uint32), ) - # Bitmask is passed as uint16_t in C++ — must stay within 16 bits. - assert len(FIELDS) <= 16, "LightControlAction Fields bitmask exceeds uint16_t" - field_mask = sum(1 << i for i, (k, _, _) in enumerate(FIELDS) if k in config) - control_template_arg = cg.TemplateArguments( - cg.RawExpression(f"static_cast({field_mask})"), *template_arg - ) - var = cg.new_Pvariable(action_id, control_template_arg, paren) + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] for conf_key, setter, type_ in FIELDS: - if conf_key in config and setter is not None: - template_ = await cg.templatable(config[conf_key], args, type_) - cg.add(getattr(var, setter)(template_)) + if conf_key not in config: + continue + value = config[conf_key] + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=type_) + body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") if CONF_EFFECT in config: if isinstance(config[CONF_EFFECT], Lambda): - # Lambda returns a string — wrap in a C++ lambda that resolves - # the effect name to its uint32_t index at runtime inner_lambda = await cg.process_lambda( config[CONF_EFFECT], args, return_type=cg.std_string ) - fwd_args = ", ".join(n for _, n in args) - # capture="" is correct: paren is a global variable name - # string-interpolated into the body at codegen time, not a - # C++ runtime capture. - wrapper = LambdaExpression( - f"auto __effect_s = ({inner_lambda})({fwd_args});\n" - f"return {paren}->get_effect_index(" - f"__effect_s.c_str(), __effect_s.size());", - args, - capture="", - return_type=cg.uint32, + body_lines.append( + f"{{ auto __effect_s = ({inner_lambda})({fwd_args});\n" + f"call.set_effect(parent->get_effect_index(" + f"__effect_s.c_str(), __effect_s.size())); }}" ) - cg.add(var.set_effect(wrapper)) else: - # Static string — resolve effect name to index at codegen time - template_ = await cg.templatable( - _resolve_effect_index(config), args, cg.uint32 + # Cast disambiguates between set_effect(uint32_t) and + # set_effect(optional) when the literal is an int. + body_lines.append( + f"call.set_effect(static_cast({_resolve_effect_index(config)}));" ) - cg.add(var.set_effect(template_)) - return var + + # Match LightControlAction::ApplyFn signature: const Ts &... for trigger args. + apply_args = [ + (LightState.operator("ptr"), "parent"), + (LightCall.operator("ref"), "call"), + *((t.operator("const").operator("ref"), n) for t, n in args), + ] + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + apply_args, + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) CONF_RELATIVE_BRIGHTNESS = "relative_brightness" diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index a586bcbd13..534dcd2194 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -13,6 +13,7 @@ Color = cg.esphome_ns.class_("Color") LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") +LightCall = light_ns.class_("LightCall") # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) From 2bd28eee9d25b30eec64bd71c09c354df9630e65 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:51:31 -0400 Subject: [PATCH 0857/2214] [tormatic] Use .value() for checked optional access in read_gate_status_ (#16121) --- esphome/components/tormatic/tormatic_cover.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index a58228a219..cca7b2bba0 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -282,12 +282,13 @@ optional Tormatic::read_gate_status_() { } } + auto hdr = this->pending_hdr_.value(); + // Wait for all payload bytes to arrive before processing. - if (this->available() < this->pending_hdr_->payload_size()) { + if (this->available() < hdr.payload_size()) { return {}; } - auto hdr = *this->pending_hdr_; this->pending_hdr_.reset(); switch (hdr.type) { From 42b8597719f186f8bb5b2469260b1c62f67285a1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:58:19 -0400 Subject: [PATCH 0858/2214] [api] Extend NOLINT to cover bugprone-random-generator-seed in MAC varint test (#16120) --- tests/components/api/test_proto_mac_varint.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/api/test_proto_mac_varint.cpp b/tests/components/api/test_proto_mac_varint.cpp index 317a6fb9d6..f2a63e96f6 100644 --- a/tests/components/api/test_proto_mac_varint.cpp +++ b/tests/components/api/test_proto_mac_varint.cpp @@ -112,7 +112,7 @@ TEST(ProtoMacVarint, AllOnes) { verify_mac(0xFFFFFFFFFFFFULL, 7); } // F // 100 deterministic-random 48-bit MACs to catch regressions across the space. TEST(ProtoMacVarint, RandomSample) { - // NOLINTNEXTLINE(cert-msc32-c,cert-msc51-cpp) -- intentional fixed seed for reproducibility. + // NOLINTNEXTLINE(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) -- fixed seed for reproducibility std::mt19937_64 rng(0xC0FFEE); for (int i = 0; i < 100; i++) { uint64_t mac = rng() & 0xFFFFFFFFFFFFULL; From 241d7797e36a2ec2de37d7f7fe5ed2857a0d98aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 09:18:09 -0500 Subject: [PATCH 0859/2214] [scheduler] Add self-keyed timer API for callers without a Component --- esphome/core/scheduler.cpp | 27 ++++- esphome/core/scheduler.h | 71 ++++++++--- .../fixtures/scheduler_self_keyed.yaml | 110 ++++++++++++++++++ .../integration/test_scheduler_self_keyed.py | 95 +++++++++++++++ 4 files changed, 283 insertions(+), 20 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_self_keyed.yaml create mode 100644 tests/integration/test_scheduler_self_keyed.py diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 11884ce4ba..4feb0811c9 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -53,9 +53,13 @@ struct SchedulerNameLog { } else if (name_type == NameType::NUMERIC_ID) { ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("id:%" PRIu32), hash_or_id); return buffer; - } else { // NUMERIC_ID_INTERNAL + } else if (name_type == NameType::NUMERIC_ID_INTERNAL) { ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("iid:%" PRIu32), hash_or_id); return buffer; + } else { // SELF_POINTER + // static_name carries the void* key for SELF_POINTER (pointer-width union slot). + ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("self:%p"), static_name); + return buffer; } } }; @@ -293,6 +297,27 @@ bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL); } +// Self-keyed scheduler API. The cancellation key is `self` (typically the caller's `this`), +// passed through the existing static_name pointer slot. Matching is by raw pointer equality +// (see matches_item_locked_'s SELF_POINTER branch). No Component pointer is stored, so +// is_failed() skip and component-based log attribution don't apply. +void HOT Scheduler::set_timeout(void *self, uint32_t timeout, std::function &&func) { + this->set_timer_common_(nullptr, SchedulerItem::TIMEOUT, NameType::SELF_POINTER, static_cast(self), 0, + timeout, std::move(func)); +} +void HOT Scheduler::set_interval(void *self, uint32_t interval, std::function &&func) { + this->set_timer_common_(nullptr, SchedulerItem::INTERVAL, NameType::SELF_POINTER, static_cast(self), 0, + interval, std::move(func)); +} +bool HOT Scheduler::cancel_timeout(void *self) { + return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast(self), 0, + SchedulerItem::TIMEOUT); +} +bool HOT Scheduler::cancel_interval(void *self) { + return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast(self), 0, + SchedulerItem::INTERVAL); +} + // Suppress deprecation warnings for RetryResult usage in the still-present (but deprecated) retry implementation. // Remove before 2026.8.0 along with all retry code. #pragma GCC diagnostic push diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 46b19855c3..cb75dce38a 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -148,12 +148,32 @@ class Scheduler { // Name storage type discriminator for SchedulerItem // Used to distinguish between static strings, hashed strings, numeric IDs, and internal numeric IDs enum class NameType : uint8_t { - STATIC_STRING = 0, // const char* pointer to static/flash storage - HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string - NUMERIC_ID = 2, // uint32_t numeric identifier (component-level) - NUMERIC_ID_INTERNAL = 3 // uint32_t numeric identifier (core/internal, separate namespace) + STATIC_STRING = 0, // const char* pointer to static/flash storage + HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string + NUMERIC_ID = 2, // uint32_t numeric identifier (component-level) + NUMERIC_ID_INTERNAL = 3, // uint32_t numeric identifier (core/internal, separate namespace) + SELF_POINTER = 4 // void* caller-supplied key (typically `this`); pointer equality }; + /** Self-keyed timeout. The cancellation key is `self` (typically the caller's `this`). + * + * Use this when the caller schedules at most one timer of a single purpose at a time and + * does not need a `Component` for `is_failed()` skip or log source attribution. Lets + * small classes drop `Component` inheritance entirely when their only Component dependency + * was the per-instance scheduler key. + * + * NOT applied for self-keyed items: + * - `is_failed()` skip — callbacks always fire (no Component to consult). + * - Log source attribution — logs use a generic "self:0x…" label. + * + * If you need either of those, use the existing `(Component *, id)` overloads. + */ + void set_timeout(void *self, uint32_t timeout, std::function &&func); + /// Self-keyed interval. See set_timeout(void *, ...) for semantics. + void set_interval(void *self, uint32_t interval, std::function &&func); + bool cancel_timeout(void *self); + bool cancel_interval(void *self); + protected: struct SchedulerItem { // Ordered by size to minimize padding @@ -182,19 +202,19 @@ class Scheduler { // std::atomic inlines correctly on all platforms. std::atomic remove{0}; - // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) - enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; - NameType name_type_ : 2; // Discriminator for name_ union (0–3, see NameType enum) - bool is_retry : 1; // True if this is a retry timeout - // 4 bits padding -#else - // Single-threaded or multi-threaded without atomics: can pack all fields together // Bit-packed fields (5 bits used, 3 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; - bool remove : 1; - NameType name_type_ : 2; // Discriminator for name_ union (0–3, see NameType enum) + NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum) bool is_retry : 1; // True if this is a retry timeout // 3 bits padding +#else + // Single-threaded or multi-threaded without atomics: can pack all fields together + // Bit-packed fields (6 bits used, 2 bits padding in 1 byte) + enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; + bool remove : 1; + NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum) + bool is_retry : 1; // True if this is a retry timeout + // 2 bits padding #endif // Constructor @@ -231,16 +251,25 @@ class Scheduler { // Helper to get the static name (only valid for STATIC_STRING type) const char *get_name() const { return (name_type_ == NameType::STATIC_STRING) ? name_.static_name : nullptr; } - // Helper to get the hash or numeric ID (only valid for HASHED_STRING or NUMERIC_ID types) - uint32_t get_name_hash_or_id() const { return (name_type_ != NameType::STATIC_STRING) ? name_.hash_or_id : 0; } + // Helper to get the hash or numeric ID (only valid for HASHED_STRING / NUMERIC_ID / NUMERIC_ID_INTERNAL types) + uint32_t get_name_hash_or_id() const { + return (name_type_ != NameType::STATIC_STRING && name_type_ != NameType::SELF_POINTER) ? name_.hash_or_id : 0; + } + + // Helper to get the self pointer (only valid for SELF_POINTER type). + // The pointer is stored in the same union slot as `static_name` since both are pointer-width. + const void *get_self() const { + return (name_type_ == NameType::SELF_POINTER) ? static_cast(name_.static_name) : nullptr; + } // Helper to get the name type NameType get_name_type() const { return name_type_; } - // Set name storage: for STATIC_STRING stores the pointer, for all other types stores hash_or_id. - // Both union members occupy the same offset, so only one store is needed. + // Set name storage. STATIC_STRING/SELF_POINTER use the static_name pointer slot + // (both are pointer-width); other types use hash_or_id. Both union members occupy + // the same offset, so only one store is needed. void set_name(NameType type, const char *static_name, uint32_t hash_or_id) { - if (type == NameType::STATIC_STRING) { + if (type == NameType::STATIC_STRING || type == NameType::SELF_POINTER) { name_.static_name = static_name; } else { name_.hash_or_id = hash_or_id; @@ -367,10 +396,14 @@ class Scheduler { // Name type must match if (item->get_name_type() != name_type) return false; - // For static strings, compare the string content; for hash/ID, compare the value + // STATIC_STRING: compare string content. SELF_POINTER: raw pointer equality (no strcmp). + // Other types: compare hash/ID value. if (name_type == NameType::STATIC_STRING) { return this->names_match_static_(item->get_name(), static_name); } + if (name_type == NameType::SELF_POINTER) { + return item->name_.static_name == static_name; + } return item->get_name_hash_or_id() == hash_or_id; } diff --git a/tests/integration/fixtures/scheduler_self_keyed.yaml b/tests/integration/fixtures/scheduler_self_keyed.yaml new file mode 100644 index 0000000000..bee8a488b1 --- /dev/null +++ b/tests/integration/fixtures/scheduler_self_keyed.yaml @@ -0,0 +1,110 @@ +esphome: + debug_scheduler: true # Enable scheduler leak detection + name: scheduler-self-keyed-test + on_boot: + priority: -100 + then: + - logger.log: "Starting scheduler self-keyed tests" + +host: +api: +logger: + level: VERBOSE + +globals: + - id: tests_done + type: bool + initial_value: 'false' + +script: + - id: test_self_keyed + then: + - logger.log: "Testing self-keyed scheduler API" + - lambda: |- + // Two distinct heap-allocated keys - they must not collide + // even though both are self-keyed and share no Component pointer. + static int key_a_marker = 0; + static int key_b_marker = 0; + void *key_a = &key_a_marker; + void *key_b = &key_b_marker; + + // ---- Test 1: Self-keyed timeout fires ---- + App.scheduler.set_timeout(key_a, 50, []() { + ESP_LOGI("test", "Self timeout A fired"); + }); + + // ---- Test 2: Self-keyed cancel cancels only that key ---- + App.scheduler.set_timeout(key_b, 100, []() { + ESP_LOGE("test", "ERROR: Self timeout B should have been cancelled"); + }); + App.scheduler.cancel_timeout(key_b); + + // ---- Test 3: Two independent self keys don't collide ---- + // Using fresh static markers so neither matches key_a / key_b. + static int key_c_marker = 0; + static int key_d_marker = 0; + void *key_c = &key_c_marker; + void *key_d = &key_d_marker; + App.scheduler.set_timeout(key_c, 150, []() { + ESP_LOGI("test", "Self timeout C fired"); + }); + App.scheduler.set_timeout(key_d, 150, []() { + ESP_LOGI("test", "Self timeout D fired"); + }); + + // ---- Test 4: Self-keyed and component-keyed don't collide ---- + // Use a self pointer that happens to look like a Component-attached id. + // The scheduler must treat them as separate namespaces. + static int shared_marker = 0; + void *self_shared = &shared_marker; + App.scheduler.set_timeout(self_shared, 200, []() { + ESP_LOGI("test", "Self timeout shared fired"); + }); + App.scheduler.set_timeout(id(test_sensor), 7777U, 200, []() { + ESP_LOGI("test", "Component timeout 7777 fired"); + }); + + // ---- Test 5: Self-keyed interval fires multiple times then cancels ---- + static int interval_count = 0; + static int key_e_marker = 0; + void *key_e = &key_e_marker; + App.scheduler.set_interval(key_e, 80, [key_e]() { + interval_count++; + if (interval_count == 2) { + ESP_LOGI("test", "Self interval E fired twice"); + App.scheduler.cancel_interval(key_e); + } + }); + + // ---- Test 6: Re-registering same self-key replaces the timer ---- + // The old timer must NOT fire; only the new one does. + static int key_f_marker = 0; + void *key_f = &key_f_marker; + App.scheduler.set_timeout(key_f, 250, []() { + ESP_LOGE("test", "ERROR: Self timeout F first registration should have been replaced"); + }); + App.scheduler.set_timeout(key_f, 300, []() { + ESP_LOGI("test", "Self timeout F replacement fired"); + }); + + // Log completion after all timers should have fired + App.scheduler.set_timeout(id(test_sensor), 9999U, 1500, []() { + ESP_LOGI("test", "All self-keyed tests complete"); + }); + +sensor: + - platform: template + name: Test Sensor + id: test_sensor + lambda: return 1.0; + update_interval: never + +interval: + - interval: 0.1s + then: + - if: + condition: + lambda: 'return id(tests_done) == false;' + then: + - lambda: 'id(tests_done) = true;' + - script.execute: test_self_keyed diff --git a/tests/integration/test_scheduler_self_keyed.py b/tests/integration/test_scheduler_self_keyed.py new file mode 100644 index 0000000000..48c9d98ad6 --- /dev/null +++ b/tests/integration/test_scheduler_self_keyed.py @@ -0,0 +1,95 @@ +"""Test the self-keyed scheduler API. + +Verifies that `Scheduler::set_timeout(void *, ...)` / `set_interval(void *, ...)` and the +matching `cancel_*(void *)` overloads behave correctly: callbacks fire, distinct keys +don't collide, self-keyed and component-keyed namespaces are independent, and +re-registering the same key replaces the existing timer. +""" + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_self_keyed( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test self-keyed scheduler API.""" + self_a_fired = asyncio.Event() + self_b_error = asyncio.Event() + self_c_fired = asyncio.Event() + self_d_fired = asyncio.Event() + self_shared_fired = asyncio.Event() + component_7777_fired = asyncio.Event() + self_interval_done = asyncio.Event() + self_f_first_error = asyncio.Event() + self_f_replacement_fired = asyncio.Event() + all_tests_complete = asyncio.Event() + + def on_log_line(line: str) -> None: + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + + if "Self timeout A fired" in clean_line: + self_a_fired.set() + elif "ERROR: Self timeout B" in clean_line: + self_b_error.set() + elif "Self timeout C fired" in clean_line: + self_c_fired.set() + elif "Self timeout D fired" in clean_line: + self_d_fired.set() + elif "Self timeout shared fired" in clean_line: + self_shared_fired.set() + elif "Component timeout 7777 fired" in clean_line: + component_7777_fired.set() + elif "Self interval E fired twice" in clean_line: + self_interval_done.set() + elif "ERROR: Self timeout F first registration" in clean_line: + self_f_first_error.set() + elif "Self timeout F replacement fired" in clean_line: + self_f_replacement_fired.set() + elif "All self-keyed tests complete" in clean_line: + all_tests_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-self-keyed-test" + + try: + await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) + except TimeoutError: + pytest.fail("Not all self-keyed tests completed within 5 seconds") + + # Test 1: self-keyed timeout fires + assert self_a_fired.is_set(), "Self timeout A should have fired" + + # Test 2: cancel_timeout(self) actually cancels + assert not self_b_error.is_set(), "Self timeout B should have been cancelled" + + # Test 3: distinct self keys don't collide + assert self_c_fired.is_set(), "Self timeout C should have fired" + assert self_d_fired.is_set(), "Self timeout D should have fired" + + # Test 4: self-keyed and component-keyed namespaces are independent + assert self_shared_fired.is_set(), "Self timeout shared should have fired" + assert component_7777_fired.is_set(), "Component timeout 7777 should have fired" + + # Test 5: self-keyed interval fires repeatedly and cancels cleanly + assert self_interval_done.is_set(), "Self interval E should have fired twice" + + # Test 6: re-registering same self-key replaces the previous timer + assert not self_f_first_error.is_set(), ( + "Self timeout F first registration should have been replaced" + ) + assert self_f_replacement_fired.is_set(), ( + "Self timeout F replacement should have fired" + ) From 2157d1191372b74bed4df5b8a371d5fb1dfeebe7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:26:53 -0400 Subject: [PATCH 0860/2214] [haier] Fix bugprone-unchecked-optional-access; switch HardwareInfo to char[9] (#16124) --- esphome/components/haier/hon_climate.cpp | 48 ++++++++----------- esphome/components/haier/hon_climate.h | 13 ++--- .../components/haier/smartair2_climate.cpp | 2 +- 3 files changed, 28 insertions(+), 35 deletions(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 1e9cb42f38..87b8add2a3 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -85,7 +85,7 @@ void HonClimate::set_horizontal_airflow(hon_protocol::HorizontalSwingMode direct this->force_send_control_ = true; } -std::string HonClimate::get_cleaning_status_text() const { +const char *HonClimate::get_cleaning_status_text() const { switch (this->cleaning_status_) { case CleaningState::SELF_CLEAN: return "Self clean"; @@ -134,29 +134,22 @@ haier_protocol::HandlerError HonClimate::get_device_version_answer_handler_(haie } // All OK hon_protocol::DeviceVersionAnswer *answr = (hon_protocol::DeviceVersionAnswer *) data; - char tmp[9]; - tmp[8] = 0; - strncpy(tmp, answr->protocol_version, 8); - this->hvac_hardware_info_ = HardwareInfo(); - this->hvac_hardware_info_.value().protocol_version_ = std::string(tmp); - strncpy(tmp, answr->software_version, 8); - this->hvac_hardware_info_.value().software_version_ = std::string(tmp); - strncpy(tmp, answr->hardware_version, 8); - this->hvac_hardware_info_.value().hardware_version_ = std::string(tmp); - strncpy(tmp, answr->device_name, 8); - this->hvac_hardware_info_.value().device_name_ = std::string(tmp); + HardwareInfo info{}; // zero-init guarantees null-termination + strncpy(info.protocol_version_, answr->protocol_version, HARDWARE_INFO_STR_SIZE - 1); + strncpy(info.software_version_, answr->software_version, HARDWARE_INFO_STR_SIZE - 1); + strncpy(info.hardware_version_, answr->hardware_version, HARDWARE_INFO_STR_SIZE - 1); + strncpy(info.device_name_, answr->device_name, HARDWARE_INFO_STR_SIZE - 1); + info.functions_[0] = (answr->functions[1] & 0x01) != 0; // interactive mode support + info.functions_[1] = (answr->functions[1] & 0x02) != 0; // controller-device mode support + info.functions_[2] = (answr->functions[1] & 0x04) != 0; // crc support + info.functions_[3] = (answr->functions[1] & 0x08) != 0; // multiple AC support + info.functions_[4] = (answr->functions[1] & 0x20) != 0; // roles support + this->use_crc_ = info.functions_[2]; #ifdef USE_TEXT_SENSOR - this->update_sub_text_sensor_(SubTextSensorType::APPLIANCE_NAME, this->hvac_hardware_info_.value().device_name_); - this->update_sub_text_sensor_(SubTextSensorType::PROTOCOL_VERSION, - this->hvac_hardware_info_.value().protocol_version_); + this->update_sub_text_sensor_(SubTextSensorType::APPLIANCE_NAME, info.device_name_); + this->update_sub_text_sensor_(SubTextSensorType::PROTOCOL_VERSION, info.protocol_version_); #endif - this->hvac_hardware_info_.value().functions_[0] = (answr->functions[1] & 0x01) != 0; // interactive mode support - this->hvac_hardware_info_.value().functions_[1] = - (answr->functions[1] & 0x02) != 0; // controller-device mode support - this->hvac_hardware_info_.value().functions_[2] = (answr->functions[1] & 0x04) != 0; // crc support - this->hvac_hardware_info_.value().functions_[3] = (answr->functions[1] & 0x08) != 0; // multiple AC support - this->hvac_hardware_info_.value().functions_[4] = (answr->functions[1] & 0x20) != 0; // roles support - this->use_crc_ = this->hvac_hardware_info_.value().functions_[2]; + this->hvac_hardware_info_ = info; this->set_phase(ProtocolPhases::SENDING_INIT_2); return result; } else { @@ -347,10 +340,9 @@ void HonClimate::dump_config() { " Device software version: %s\n" " Device hardware version: %s\n" " Device name: %s", - this->hvac_hardware_info_.value().protocol_version_.c_str(), - this->hvac_hardware_info_.value().software_version_.c_str(), - this->hvac_hardware_info_.value().hardware_version_.c_str(), - this->hvac_hardware_info_.value().device_name_.c_str()); + this->hvac_hardware_info_.value().protocol_version_, + this->hvac_hardware_info_.value().software_version_, + this->hvac_hardware_info_.value().hardware_version_, this->hvac_hardware_info_.value().device_name_); ESP_LOGCONFIG(TAG, " Device features:%s%s%s%s%s", (this->hvac_hardware_info_.value().functions_[0] ? " interactive" : ""), (this->hvac_hardware_info_.value().functions_[1] ? " controller-device" : ""), @@ -460,7 +452,7 @@ void HonClimate::process_phase(std::chrono::steady_clock::time_point now) { if (this->action_request_.has_value()) { if (this->action_request_.value().message.has_value()) { this->send_message_(this->action_request_.value().message.value(), this->use_crc_); - this->action_request_.value().message.reset(); + this->action_request_.value().message.reset(); // NOLINT(bugprone-unchecked-optional-access) } else { // Message already sent, reseting request and return to idle this->action_request_.reset(); @@ -796,7 +788,7 @@ void HonClimate::set_sub_text_sensor(SubTextSensorType type, text_sensor::TextSe } } -void HonClimate::update_sub_text_sensor_(SubTextSensorType type, const std::string &value) { +void HonClimate::update_sub_text_sensor_(SubTextSensorType type, const char *value) { size_t index = (size_t) type; if (this->sub_text_sensors_[index] != nullptr) this->sub_text_sensors_[index]->publish_state(value); diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index 7a87f27b66..a0bcdfb548 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -90,7 +90,7 @@ class HonClimate : public HaierClimateBase { void set_sub_text_sensor(SubTextSensorType type, text_sensor::TextSensor *sens); protected: - void update_sub_text_sensor_(SubTextSensorType type, const std::string &value); + void update_sub_text_sensor_(SubTextSensorType type, const char *value); text_sensor::TextSensor *sub_text_sensors_[(size_t) SubTextSensorType::SUB_TEXT_SENSOR_TYPE_COUNT]{nullptr}; #endif #ifdef USE_SWITCH @@ -116,7 +116,7 @@ class HonClimate : public HaierClimateBase { void set_vertical_airflow(hon_protocol::VerticalSwingMode direction); esphome::optional get_horizontal_airflow() const; void set_horizontal_airflow(hon_protocol::HorizontalSwingMode direction); - std::string get_cleaning_status_text() const; + const char *get_cleaning_status_text() const; CleaningState get_cleaning_status() const; void start_self_cleaning(); void start_steri_cleaning(); @@ -166,11 +166,12 @@ class HonClimate : public HaierClimateBase { void fill_control_messages_queue_(); void clear_control_messages_queue_(); + static constexpr size_t HARDWARE_INFO_STR_SIZE = 9; struct HardwareInfo { - std::string protocol_version_; - std::string software_version_; - std::string hardware_version_; - std::string device_name_; + char protocol_version_[HARDWARE_INFO_STR_SIZE]; + char software_version_[HARDWARE_INFO_STR_SIZE]; + char hardware_version_[HARDWARE_INFO_STR_SIZE]; + char device_name_[HARDWARE_INFO_STR_SIZE]; bool functions_[5]; }; diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index 200cac2557..752f4d4f1c 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -191,7 +191,7 @@ void Smartair2Climate::process_phase(std::chrono::steady_clock::time_point now) if (this->action_request_.has_value()) { if (this->action_request_.value().message.has_value()) { this->send_message_(this->action_request_.value().message.value(), this->use_crc_); - this->action_request_.value().message.reset(); + this->action_request_.value().message.reset(); // NOLINT(bugprone-unchecked-optional-access) } else { // Message already sent, reseting request and return to idle this->action_request_.reset(); From 9745a3299eeeffbdc9b966429c39c5b9282e3fa4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 09:34:45 -0500 Subject: [PATCH 0861/2214] [scheduler] Address review feedback (const void *, %p cast, buffer size) --- esphome/core/scheduler.cpp | 15 +++++++++------ esphome/core/scheduler.h | 10 +++++----- .../fixtures/scheduler_self_keyed.yaml | 6 ++++-- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 4feb0811c9..e8b8332902 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -35,7 +35,9 @@ static constexpr uint32_t MAX_INTERVAL_DELAY = 5000; // Uses a stack buffer to avoid heap allocation // Uses ESPHOME_snprintf_P/ESPHOME_PSTR for ESP8266 to keep format strings in flash struct SchedulerNameLog { - char buffer[20]; // Enough for "id:4294967295" or "hash:0xFFFFFFFF" or "(null)" + // Sized for the widest formatted output: "self:0x" + 16 hex digits (64-bit pointer) + nul. + // Also covers "id:4294967295", "hash:0xFFFFFFFF", "iid:4294967295", "(null)". + char buffer[28]; // Format a scheduler item name for logging // Returns pointer to formatted string (either static_name or internal buffer) @@ -58,7 +60,8 @@ struct SchedulerNameLog { return buffer; } else { // SELF_POINTER // static_name carries the void* key for SELF_POINTER (pointer-width union slot). - ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("self:%p"), static_name); + // Cast to const void* — %p requires a void* argument. + ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("self:%p"), static_cast(static_name)); return buffer; } } @@ -301,19 +304,19 @@ bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) { // passed through the existing static_name pointer slot. Matching is by raw pointer equality // (see matches_item_locked_'s SELF_POINTER branch). No Component pointer is stored, so // is_failed() skip and component-based log attribution don't apply. -void HOT Scheduler::set_timeout(void *self, uint32_t timeout, std::function &&func) { +void HOT Scheduler::set_timeout(const void *self, uint32_t timeout, std::function &&func) { this->set_timer_common_(nullptr, SchedulerItem::TIMEOUT, NameType::SELF_POINTER, static_cast(self), 0, timeout, std::move(func)); } -void HOT Scheduler::set_interval(void *self, uint32_t interval, std::function &&func) { +void HOT Scheduler::set_interval(const void *self, uint32_t interval, std::function &&func) { this->set_timer_common_(nullptr, SchedulerItem::INTERVAL, NameType::SELF_POINTER, static_cast(self), 0, interval, std::move(func)); } -bool HOT Scheduler::cancel_timeout(void *self) { +bool HOT Scheduler::cancel_timeout(const void *self) { return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast(self), 0, SchedulerItem::TIMEOUT); } -bool HOT Scheduler::cancel_interval(void *self) { +bool HOT Scheduler::cancel_interval(const void *self) { return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast(self), 0, SchedulerItem::INTERVAL); } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index cb75dce38a..671665f4c0 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -168,11 +168,11 @@ class Scheduler { * * If you need either of those, use the existing `(Component *, id)` overloads. */ - void set_timeout(void *self, uint32_t timeout, std::function &&func); - /// Self-keyed interval. See set_timeout(void *, ...) for semantics. - void set_interval(void *self, uint32_t interval, std::function &&func); - bool cancel_timeout(void *self); - bool cancel_interval(void *self); + void set_timeout(const void *self, uint32_t timeout, std::function &&func); + /// Self-keyed interval. See set_timeout(const void *, ...) for semantics. + void set_interval(const void *self, uint32_t interval, std::function &&func); + bool cancel_timeout(const void *self); + bool cancel_interval(const void *self); protected: struct SchedulerItem { diff --git a/tests/integration/fixtures/scheduler_self_keyed.yaml b/tests/integration/fixtures/scheduler_self_keyed.yaml index bee8a488b1..9a691136f3 100644 --- a/tests/integration/fixtures/scheduler_self_keyed.yaml +++ b/tests/integration/fixtures/scheduler_self_keyed.yaml @@ -21,8 +21,10 @@ script: then: - logger.log: "Testing self-keyed scheduler API" - lambda: |- - // Two distinct heap-allocated keys - they must not collide - // even though both are self-keyed and share no Component pointer. + // Two distinct keys backed by addresses of static markers — they + // must not collide even though both are self-keyed and share no + // Component pointer. Static storage gives them stable, unique + // addresses for the lifetime of the program. static int key_a_marker = 0; static int key_b_marker = 0; void *key_a = &key_a_marker; From 248b7c7cbf1b6d78e742d9b1ee4004f557194229 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 09:21:54 -0500 Subject: [PATCH 0862/2214] [core] DelayAction: drop Component inheritance, use self-keyed scheduler --- esphome/automation.py | 3 +-- esphome/core/base_automation.h | 18 +++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 20eb9358ca..1689d29c42 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -127,7 +127,7 @@ def validate_potentially_or_condition(value): return validate_condition(value) -DelayAction = cg.esphome_ns.class_("DelayAction", Action, cg.Component) +DelayAction = cg.esphome_ns.class_("DelayAction", Action) LambdaAction = cg.esphome_ns.class_("LambdaAction", Action) StatelessLambdaAction = cg.esphome_ns.class_("StatelessLambdaAction", Action) IfAction = cg.esphome_ns.class_("IfAction", Action) @@ -396,7 +396,6 @@ async def delay_action_to_code( args: TemplateArgsType, ) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) - await cg.register_component(var, {}) template_ = await cg.templatable(config, args, cg.uint32) cg.add(var.set_delay(template_)) return var diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index afd11c6867..dcad7c9d2e 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -178,7 +178,7 @@ class ProjectUpdateTrigger : public Trigger, public Component { }; #endif -template class DelayAction : public Action, public Component { +template class DelayAction : public Action { public: explicit DelayAction() = default; @@ -198,8 +198,8 @@ template class DelayAction : public Action, public Compon // to avoid overhead from capturing arguments by value if constexpr (sizeof...(Ts) == 0) { App.scheduler.set_timer_common_( - this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, - static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(), + /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, + /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } else { @@ -208,18 +208,18 @@ template class DelayAction : public Action, public Compon // `mutable` is required so captured copies of non-const reference args (e.g. std::string&) // are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&` auto f = [this, x...]() mutable { this->play_next_(x...); }; - App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, - nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), - this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + App.scheduler.set_timer_common_( + /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, + /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), + std::move(f), + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } } - float get_setup_priority() const override { return setup_priority::HARDWARE; } void play(const Ts &...x) override { /* ignore - see play_complex */ } - void stop() override { this->cancel_timeout(InternalSchedulerID::DELAY_ACTION); } + void stop() override { App.scheduler.cancel_timeout(this); } }; template class LambdaAction : public Action { From cebdecdcf4bd377d4fefd816235c70b6cddb1f5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 09:27:53 -0500 Subject: [PATCH 0863/2214] [binary_sensor] Drop Component from filter classes, use self-keyed scheduler --- esphome/components/binary_sensor/__init__.py | 15 +++------ esphome/components/binary_sensor/filter.cpp | 34 +++++++------------- esphome/components/binary_sensor/filter.h | 18 +++-------- 3 files changed, 22 insertions(+), 45 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1456e5bc66..db82290750 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -143,15 +143,15 @@ BinarySensorCondition = binary_sensor_ns.class_("BinarySensorCondition", Conditi # Filters Filter = binary_sensor_ns.class_("Filter") -TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter, cg.Component) -DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter, cg.Component) -DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter, cg.Component) -DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter, cg.Component) +TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter) +DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter) +DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter) +DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter) InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter) AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter, cg.Component) LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter) StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter) -SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter, cg.Component) +SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter) _LOGGER = getLogger(__name__) @@ -175,7 +175,6 @@ async def invert_filter_to_code(config, filter_id): ) async def timeout_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_timeout_value(template_)) return var @@ -203,7 +202,6 @@ async def timeout_filter_to_code(config, filter_id): ) async def delayed_on_off_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) if isinstance(config, dict): template_ = await cg.templatable(config[CONF_TIME_ON], [], cg.uint32) cg.add(var.set_on_delay(template_)) @@ -221,7 +219,6 @@ async def delayed_on_off_filter_to_code(config, filter_id): ) async def delayed_on_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_delay(template_)) return var @@ -234,7 +231,6 @@ async def delayed_on_filter_to_code(config, filter_id): ) async def delayed_off_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_delay(template_)) return var @@ -306,7 +302,6 @@ async def lambda_filter_to_code(config, filter_id): ) async def settle_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_delay(template_)) return var diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 914060ce13..0a463ee9a9 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -4,16 +4,14 @@ #include "filter.h" #include "binary_sensor.h" +#include "esphome/core/application.h" namespace esphome::binary_sensor { static const char *const TAG = "sensor.filter"; -// Timeout IDs for filter classes. -// Each filter is its own Component instance, so the scheduler scopes -// IDs by component pointer — no risk of collisions between instances. -constexpr uint32_t FILTER_TIMEOUT_ID = 0; -// AutorepeatFilter needs two distinct IDs (both timeouts on the same component) +// AutorepeatFilter still inherits Component (it schedules two distinct timer +// purposes), so it keeps the (Component *, id) scheduler API. constexpr uint32_t AUTOREPEAT_TIMING_ID = 0; constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1; @@ -34,46 +32,40 @@ void Filter::input(bool value) { } void TimeoutFilter::input(bool value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); }); + App.scheduler.set_timeout(this, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); }); // we do not de-dup here otherwise changes from invalid to valid state will not be output this->output(value); } optional DelayedOnOffFilter::new_value(bool value) { if (value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->on_delay_.value(), [this]() { this->output(true); }); + App.scheduler.set_timeout(this, this->on_delay_.value(), [this]() { this->output(true); }); } else { - this->set_timeout(FILTER_TIMEOUT_ID, this->off_delay_.value(), [this]() { this->output(false); }); + App.scheduler.set_timeout(this, this->off_delay_.value(), [this]() { this->output(false); }); } return {}; } -float DelayedOnOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional DelayedOnFilter::new_value(bool value) { if (value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(true); }); + App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(true); }); return {}; } else { - this->cancel_timeout(FILTER_TIMEOUT_ID); + App.scheduler.cancel_timeout(this); return false; } } -float DelayedOnFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional DelayedOffFilter::new_value(bool value) { if (!value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(false); }); + App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(false); }); return {}; } else { - this->cancel_timeout(FILTER_TIMEOUT_ID); + App.scheduler.cancel_timeout(this); return true; } } -float DelayedOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional InvertFilter::new_value(bool value) { return !value; } // AutorepeatFilterBase @@ -118,20 +110,18 @@ optional LambdaFilter::new_value(bool value) { return this->f_(value); } optional SettleFilter::new_value(bool value) { if (!this->steady_) { - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this, value]() { + App.scheduler.set_timeout(this, this->delay_.value(), [this, value]() { this->steady_ = true; this->output(value); }); return {}; } else { this->steady_ = false; - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; }); + App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->steady_ = true; }); return value; } } -float SettleFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - } // namespace esphome::binary_sensor #endif // USE_BINARY_SENSOR_FILTER diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 2e45554f81..8ff57cab0c 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -29,7 +29,7 @@ class Filter { Deduplicator dedup_; }; -class TimeoutFilter : public Filter, public Component { +class TimeoutFilter : public Filter { public: optional new_value(bool value) override { return value; } void input(bool value) override; @@ -39,12 +39,10 @@ class TimeoutFilter : public Filter, public Component { TemplatableFn timeout_delay_{}; }; -class DelayedOnOffFilter final : public Filter, public Component { +class DelayedOnOffFilter final : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_on_delay(T delay) { this->on_delay_ = delay; } template void set_off_delay(T delay) { this->off_delay_ = delay; } @@ -53,24 +51,20 @@ class DelayedOnOffFilter final : public Filter, public Component { TemplatableFn off_delay_{}; }; -class DelayedOnFilter : public Filter, public Component { +class DelayedOnFilter : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_delay(T delay) { this->delay_ = delay; } protected: TemplatableFn delay_{}; }; -class DelayedOffFilter : public Filter, public Component { +class DelayedOffFilter : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_delay(T delay) { this->delay_ = delay; } protected: @@ -146,12 +140,10 @@ class StatelessLambdaFilter : public Filter { optional (*f_)(bool); }; -class SettleFilter : public Filter, public Component { +class SettleFilter : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_delay(T delay) { this->delay_ = delay; } protected: From 439d66f0f7bc98c07216b4a2d1de6b0312ff9a2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 09:32:06 -0500 Subject: [PATCH 0864/2214] [sensor] Drop Component from filter classes, use self-keyed scheduler --- esphome/components/sensor/__init__.py | 19 ++++++------------- esphome/components/sensor/filter.cpp | 21 +++++++-------------- esphome/components/sensor/filter.h | 15 +++++---------- 3 files changed, 18 insertions(+), 37 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 48b7d25d4d..c18aa32f37 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -266,7 +266,7 @@ StreamingMovingAverageFilter = sensor_ns.class_("StreamingMovingAverageFilter", ExponentialMovingAverageFilter = sensor_ns.class_( "ExponentialMovingAverageFilter", Filter ) -ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter, cg.Component) +ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter) LambdaFilter = sensor_ns.class_("LambdaFilter", Filter) StatelessLambdaFilter = sensor_ns.class_("StatelessLambdaFilter", Filter) OffsetFilter = sensor_ns.class_("OffsetFilter", Filter) @@ -283,8 +283,8 @@ ThrottleWithPriorityNanFilter = sensor_ns.class_( TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) -DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component) -HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component) +DebounceFilter = sensor_ns.class_("DebounceFilter", Filter) +HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter) DeltaFilter = sensor_ns.class_("DeltaFilter", Filter) OrFilter = sensor_ns.class_("OrFilter", Filter) CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter) @@ -567,9 +567,7 @@ async def exponential_moving_average_filter_to_code(config, filter_id): "throttle_average", ThrottleAverageFilter, cv.positive_time_period_milliseconds ) async def throttle_average_filter_to_code(config, filter_id): - var = cg.new_Pvariable(filter_id, config) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, config) @FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda) @@ -698,13 +696,10 @@ HEARTBEAT_SCHEMA = cv.Schema( async def heartbeat_filter_to_code(config, filter_id): if isinstance(config, dict): var = cg.new_Pvariable(filter_id, config[CONF_PERIOD]) - await cg.register_component(var, {}) cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) return var - var = cg.new_Pvariable(filter_id, config) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, config) TIMEOUT_SCHEMA = cv.maybe_simple_value( @@ -738,9 +733,7 @@ async def timeout_filter_to_code(config, filter_id): "debounce", DebounceFilter, cv.positive_time_period_milliseconds ) async def debounce_filter_to_code(config, filter_id): - var = cg.new_Pvariable(filter_id, config) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, config) CONF_DATAPOINTS = "datapoints" diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 4896757d3f..5f7f19769a 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -13,11 +13,6 @@ namespace esphome::sensor { static const char *const TAG = "sensor.filter"; -// Filter scheduler IDs. -// Each filter is its own Component instance, so the scheduler scopes -// IDs by component pointer — no risk of collisions between instances. -constexpr uint32_t FILTER_ID = 0; - // Filter void Filter::input(float value) { ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value); @@ -185,8 +180,9 @@ optional ThrottleAverageFilter::new_value(float value) { } return {}; } -void ThrottleAverageFilter::setup() { - this->set_interval(FILTER_ID, this->time_period_, [this]() { +void ThrottleAverageFilter::initialize(Sensor *parent, Filter *next) { + Filter::initialize(parent, next); + App.scheduler.set_interval(this, this->time_period_, [this]() { ESP_LOGVV(TAG, "ThrottleAverageFilter(%p)::interval(sum=%f, n=%i)", this, this->sum_, this->n_); if (this->n_ == 0) { if (this->have_nan_) @@ -199,7 +195,6 @@ void ThrottleAverageFilter::setup() { this->have_nan_ = false; }); } -float ThrottleAverageFilter::get_setup_priority() const { return setup_priority::HARDWARE; } // LambdaFilter LambdaFilter::LambdaFilter(lambda_filter_t lambda_filter) : lambda_filter_(std::move(lambda_filter)) {} @@ -362,13 +357,12 @@ optional TimeoutFilterConfigured::new_value(float value) { // DebounceFilter optional DebounceFilter::new_value(float value) { - this->set_timeout(FILTER_ID, this->time_period_, [this, value]() { this->output(value); }); + App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); }); return {}; } DebounceFilter::DebounceFilter(uint32_t time_period) : time_period_(time_period) {} -float DebounceFilter::get_setup_priority() const { return setup_priority::HARDWARE; } // HeartbeatFilter HeartbeatFilter::HeartbeatFilter(uint32_t time_period) : time_period_(time_period), last_input_(NAN) {} @@ -384,8 +378,9 @@ optional HeartbeatFilter::new_value(float value) { return {}; } -void HeartbeatFilter::setup() { - this->set_interval(FILTER_ID, this->time_period_, [this]() { +void HeartbeatFilter::initialize(Sensor *parent, Filter *next) { + Filter::initialize(parent, next); + App.scheduler.set_interval(this, this->time_period_, [this]() { ESP_LOGVV(TAG, "HeartbeatFilter(%p)::interval(has_value=%s, last_input=%f)", this, YESNO(this->has_value_), this->last_input_); if (!this->has_value_) @@ -395,8 +390,6 @@ void HeartbeatFilter::setup() { }); } -float HeartbeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { for (size_t i = 0; i < count; i++) { if (!std::isfinite(functions[i][2]) || value < functions[i][2]) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 917a1ce7d5..d61df11d9b 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -254,16 +254,14 @@ class ExponentialMovingAverageFilter : public Filter { * * It takes the average of all the values received in a period of time. */ -class ThrottleAverageFilter : public Filter, public Component { +class ThrottleAverageFilter : public Filter { public: explicit ThrottleAverageFilter(uint32_t time_period); - void setup() override; + void initialize(Sensor *parent, Filter *next) override; optional new_value(float value) override; - float get_setup_priority() const override; - protected: float sum_{0.0f}; unsigned int n_{0}; @@ -454,25 +452,22 @@ class TimeoutFilterConfigured : public TimeoutFilterBase { // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead }; -class DebounceFilter : public Filter, public Component { +class DebounceFilter : public Filter { public: explicit DebounceFilter(uint32_t time_period); optional new_value(float value) override; - float get_setup_priority() const override; - protected: uint32_t time_period_; }; -class HeartbeatFilter : public Filter, public Component { +class HeartbeatFilter : public Filter { public: explicit HeartbeatFilter(uint32_t time_period); - void setup() override; + void initialize(Sensor *parent, Filter *next) override; optional new_value(float value) override; - float get_setup_priority() const override; void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } From bacee89bca9ebde55687d746d75985516cc43c0d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:56:13 -0400 Subject: [PATCH 0865/2214] [mixer_speaker] NOLINT bugprone-unchecked-optional-access in audio_mixer_task (#16130) --- esphome/components/mixer/speaker/mixer_speaker.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 741239a2dd..0d16bce330 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -588,6 +588,7 @@ void MixerSpeaker::mix_audio_samples(const int16_t *primary_buffer, audio::Audio } } +// NOLINTBEGIN(bugprone-unchecked-optional-access) -- audio_stream_info_ always set before this task is created void MixerSpeaker::audio_mixer_task(void *params) { MixerSpeaker *this_mixer = static_cast(params); @@ -764,6 +765,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } +// NOLINTEND(bugprone-unchecked-optional-access) } // namespace esphome::mixer_speaker From 17173ba3a31fc2f3adb936c14206d53bad9b57da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 10:15:30 -0500 Subject: [PATCH 0866/2214] [scheduler] Make get_name() return SELF_POINTER slot too So debug-only iteration logs in Scheduler::call() (the periodic items dump and the per-item Running line) print the actual self pointer instead of 'self:(nil)'. Both STATIC_STRING and SELF_POINTER use the same pointer union member, so a single accessor is the natural shape; drop the unused get_self() helper that was added for this and update the union member comments to mention SELF_POINTER and NUMERIC_ID_INTERNAL. --- esphome/core/scheduler.h | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 671665f4c0..a97f45b15f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -180,8 +180,8 @@ class Scheduler { Component *component; // Optimized name storage using tagged union - zero heap allocation union { - const char *static_name; // For STATIC_STRING (string literals, no allocation) - uint32_t hash_or_id; // For HASHED_STRING or NUMERIC_ID + const char *static_name; // For STATIC_STRING (string literals) and SELF_POINTER (caller's `this`) + uint32_t hash_or_id; // For HASHED_STRING, NUMERIC_ID, and NUMERIC_ID_INTERNAL } name_; uint32_t interval; // Split time to handle millis() rollover. The scheduler combines the 32-bit millis() @@ -248,20 +248,18 @@ class Scheduler { SchedulerItem(SchedulerItem &&) = delete; SchedulerItem &operator=(SchedulerItem &&) = delete; - // Helper to get the static name (only valid for STATIC_STRING type) - const char *get_name() const { return (name_type_ == NameType::STATIC_STRING) ? name_.static_name : nullptr; } + // Helper to get the pointer-slot value (valid for STATIC_STRING and SELF_POINTER types). + // Both share the same union member, so callers (e.g. log formatters) can read either uniformly. + const char *get_name() const { + return (name_type_ == NameType::STATIC_STRING || name_type_ == NameType::SELF_POINTER) ? name_.static_name + : nullptr; + } // Helper to get the hash or numeric ID (only valid for HASHED_STRING / NUMERIC_ID / NUMERIC_ID_INTERNAL types) uint32_t get_name_hash_or_id() const { return (name_type_ != NameType::STATIC_STRING && name_type_ != NameType::SELF_POINTER) ? name_.hash_or_id : 0; } - // Helper to get the self pointer (only valid for SELF_POINTER type). - // The pointer is stored in the same union slot as `static_name` since both are pointer-width. - const void *get_self() const { - return (name_type_ == NameType::SELF_POINTER) ? static_cast(name_.static_name) : nullptr; - } - // Helper to get the name type NameType get_name_type() const { return name_type_; } From 557c3d443611eb8443959ebf1b15206a2030070f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:33:29 -0400 Subject: [PATCH 0867/2214] [aqi] Use std::max initializer-list for non-negative AQI clamp (#16134) --- esphome/components/aqi/aqi_calculator.h | 6 +----- esphome/components/aqi/caqi_calculator.h | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/components/aqi/aqi_calculator.h b/esphome/components/aqi/aqi_calculator.h index d624af0432..bb8e402280 100644 --- a/esphome/components/aqi/aqi_calculator.h +++ b/esphome/components/aqi/aqi_calculator.h @@ -14,11 +14,7 @@ class AQICalculator : public AbstractAQICalculator { uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override { float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); - - float aqi = std::max(pm2_5_index, pm10_0_index); - if (aqi < 0.0f) { - aqi = 0.0f; - } + float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f}); return static_cast(std::lround(aqi)); } diff --git a/esphome/components/aqi/caqi_calculator.h b/esphome/components/aqi/caqi_calculator.h index fe2efe7059..3f6da45aa9 100644 --- a/esphome/components/aqi/caqi_calculator.h +++ b/esphome/components/aqi/caqi_calculator.h @@ -12,11 +12,7 @@ class CAQICalculator : public AbstractAQICalculator { uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override { float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); - - float aqi = std::max(pm2_5_index, pm10_0_index); - if (aqi < 0.0f) { - aqi = 0.0f; - } + float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f}); return static_cast(std::lround(aqi)); } From bae6b516523a4814ae49f1fbeb3a964b9b192a5b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:33:57 -0400 Subject: [PATCH 0868/2214] [kamstrup_kmp][toshiba] Fix signed/unsigned comparisons against sizeof (#16135) --- esphome/components/kamstrup_kmp/kamstrup_kmp.cpp | 6 +++--- esphome/components/toshiba/toshiba.cpp | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 9f2557243c..9bebd4cd56 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -139,12 +139,12 @@ void KamstrupKMPComponent::clear_uart_rx_buffer_() { void KamstrupKMPComponent::read_command_(uint16_t command) { uint8_t buffer[20] = {0}; - int buffer_len = 0; + size_t buffer_len = 0; int data; int timeout = 250; // ms // Read the data from the UART - while (timeout > 0 && buffer_len < static_cast(sizeof(buffer))) { + while (timeout > 0 && buffer_len < sizeof(buffer)) { if (this->available()) { data = this->read(); if (data > -1) { @@ -183,7 +183,7 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { // Decode uint8_t msg[20] = {0}; int msg_len = 0; - for (int i = 1; i < buffer_len - 1; i++) { + for (size_t i = 1; i < buffer_len - 1; i++) { if (buffer[i] == 0x1B) { msg[msg_len++] = buffer[i + 1] ^ 0xFF; i++; diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 53114cc50f..a23b4c7cc3 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -275,7 +275,7 @@ static Ras2819tSecondPacketCodes get_ras_2819t_second_packet_codes(climate::Clim */ static uint8_t get_ras_2819t_temp_code(float temperature) { int temp_index = static_cast(temperature) - 18; - if (temp_index < 0 || temp_index >= static_cast(sizeof(RAS_2819T_TEMP_CODES))) { + if (temp_index < 0 || static_cast(temp_index) >= sizeof(RAS_2819T_TEMP_CODES)) { ESP_LOGW(TAG, "Temperature %.1f°C out of range [18-30°C], defaulting to 24°C", temperature); return 0x40; // Default to 24°C } From e3fdea8ce1d49f5236da4e7637dc70c3061d8272 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 11:06:04 -0500 Subject: [PATCH 0869/2214] [scheduler] Address Copilot feedback: %p type, NameType doc, test docstring - Strip const for the %p varargs call (the format spec takes void*, not const void*; same representation everywhere but pedantically correct). - Mention SELF_POINTER in the NameType discriminator comment. - Update test docstring to match the actual const void * signatures. --- esphome/core/scheduler.cpp | 5 +++-- esphome/core/scheduler.h | 3 ++- tests/integration/test_scheduler_self_keyed.py | 9 +++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index e8b8332902..57deeab0da 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -60,8 +60,9 @@ struct SchedulerNameLog { return buffer; } else { // SELF_POINTER // static_name carries the void* key for SELF_POINTER (pointer-width union slot). - // Cast to const void* — %p requires a void* argument. - ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("self:%p"), static_cast(static_name)); + // %p is specified as void* (not const void*), so strip const for the varargs call. + ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("self:%p"), + const_cast(static_cast(static_name))); return buffer; } } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index a97f45b15f..7a6be6bea9 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -146,7 +146,8 @@ class Scheduler { } // Name storage type discriminator for SchedulerItem - // Used to distinguish between static strings, hashed strings, numeric IDs, and internal numeric IDs + // Used to distinguish between static strings, hashed strings, numeric IDs, internal numeric IDs, + // and self-keyed pointers (caller-supplied `void *`, typically `this`). enum class NameType : uint8_t { STATIC_STRING = 0, // const char* pointer to static/flash storage HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string diff --git a/tests/integration/test_scheduler_self_keyed.py b/tests/integration/test_scheduler_self_keyed.py index 48c9d98ad6..e0825ea825 100644 --- a/tests/integration/test_scheduler_self_keyed.py +++ b/tests/integration/test_scheduler_self_keyed.py @@ -1,9 +1,10 @@ """Test the self-keyed scheduler API. -Verifies that `Scheduler::set_timeout(void *, ...)` / `set_interval(void *, ...)` and the -matching `cancel_*(void *)` overloads behave correctly: callbacks fire, distinct keys -don't collide, self-keyed and component-keyed namespaces are independent, and -re-registering the same key replaces the existing timer. +Verifies that `Scheduler::set_timeout(const void *, ...)` / +`set_interval(const void *, ...)` and the matching `cancel_*(const void *)` +overloads behave correctly: callbacks fire, distinct keys don't collide, +self-keyed and component-keyed namespaces are independent, and re-registering +the same key replaces the existing timer. """ import asyncio From ddc445ea710bd6284c14fc5c034d385f308878c4 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:19:07 +0200 Subject: [PATCH 0870/2214] Use WatchdogManager for OTA on ESP32 --- esphome/components/ota/__init__.py | 2 ++ .../components/ota/ota_backend_esp_idf.cpp | 23 ++----------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 8f31eb5cdd..579491fe1a 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -24,6 +24,8 @@ def AUTO_LOAD() -> list[str]: components = ["safe_mode"] if not CORE.using_zephyr: components.extend(["md5"]) + if CORE.is_esp32: + components.extend(["watchdog"]) return components diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 598fce1562..b4b38a192f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -2,6 +2,7 @@ #include "ota_backend_esp_idf.h" #include "esphome/components/md5/md5.h" +#include "esphome/components/watchdog/watchdog.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" @@ -28,29 +29,9 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // The following function takes longer than the 5 seconds timeout of WDT - esp_task_wdt_config_t wdtc; - wdtc.idle_core_mask = 0; -#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 - wdtc.idle_core_mask |= (1 << 0); -#endif -#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 - wdtc.idle_core_mask |= (1 << 1); -#endif - wdtc.timeout_ms = 15000; - wdtc.trigger_panic = false; - esp_task_wdt_reconfigure(&wdtc); -#endif - + watchdog::WatchdogManager watchdog(15000); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // Set the WDT back to the configured timeout - wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; - esp_task_wdt_reconfigure(&wdtc); -#endif - if (err != ESP_OK) { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; From ce61dcf3873b7fe7a7e8063cc44168222a3aee4a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:54:17 -0400 Subject: [PATCH 0871/2214] [remote_base][core] Drop redundant typename in dependent type contexts (#16137) --- esphome/components/remote_base/remote_base.h | 8 ++++---- esphome/core/finite_set_mask.h | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index d73fff2b0a..e5e923d780 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -164,7 +164,7 @@ class RemoteTransmitterBase : public RemoteComponentBase { return TransmitCall(this); } template - void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + void transmit(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { auto call = this->transmit(); Protocol().encode(call.get_data(), data); call.set_send_times(send_times); @@ -250,10 +250,10 @@ template class RemoteReceiverBinarySensor : public RemoteReceiverBin } public: - void set_data(typename T::ProtocolData data) { data_ = data; } + void set_data(T::ProtocolData data) { data_ = data; } protected: - typename T::ProtocolData data_; + T::ProtocolData data_; }; template @@ -278,7 +278,7 @@ class RemoteTransmittable { protected: template - void transmit_(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + void transmit_(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { this->transmitter_->transmit(data, send_times, send_wait); } RemoteTransmitterBase *transmitter_; diff --git a/esphome/core/finite_set_mask.h b/esphome/core/finite_set_mask.h index 616c69353d..272337ff76 100644 --- a/esphome/core/finite_set_mask.h +++ b/esphome/core/finite_set_mask.h @@ -55,7 +55,7 @@ template struct DefaultBitPolicy { /// template> class FiniteSetMask { public: - using bitmask_t = typename BitPolicy::mask_t; + using bitmask_t = BitPolicy::mask_t; constexpr FiniteSetMask() = default; From 990d28fe0e6622abe7c492e6802572851b5d7fd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:29:09 -0500 Subject: [PATCH 0872/2214] safety --- esphome/components/ota/ota_backend_esp_idf.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index a378adb0e3..141cfcdeab 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -316,6 +316,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { it = esp_partition_next(it); } esp_partition_iterator_release(it); + if (running_app_part == nullptr) { + ESP_LOGE(TAG, "Running app partition not found in current partition table"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); @@ -365,6 +369,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { it = esp_partition_next(it); } esp_partition_iterator_release(it); + if (new_boot_partition == nullptr) { + ESP_LOGE(TAG, "Selected app partition not found after partition table update"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); From d733b0b516aecbef895f643fd30a256629c8801a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:29:38 -0500 Subject: [PATCH 0873/2214] replace c-casts --- esphome/components/ota/ota_backend_esp_idf.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 141cfcdeab..70a2d14aae 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -280,7 +280,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Found the otadata partition in the new partition table otadata_partition_found = true; otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); - } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && strcmp((char *) new_part->label, "nvs") == 0) { + } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && + strncmp(reinterpret_cast(new_part->label), "nvs", sizeof(new_part->label)) == 0) { // Found the nvs partition in the new partition table nvs_partition_found = true; } From 1f432d9d8a8820d2e2ad368ae7c6302e2b9d2292 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:30:51 -0500 Subject: [PATCH 0874/2214] hold watchdog for part write, could timeout and brick --- esphome/components/ota/ota_backend_esp_idf.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 70a2d14aae..503a1fbbe6 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -305,6 +305,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Deinitialize NVS to prevent unwanted flash writes nvs_flash_deinit(); + // Hold the watchdog open for the entire critical section: optional app copy, partition-table + // erase/write, and boot partition selection. None of the steps below should yield long enough + // to require a refresh, but bundling them under a single guard avoids spurious resets if the + // underlying ESP-IDF calls take longer than expected on a given chip variant. + watchdog::WatchdogManager watchdog(15000); + // Copy the running app partition to new position if needed if (new_app_part_index == -1) { const esp_partition_t *running_app_part = nullptr; @@ -324,7 +330,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); - watchdog::WatchdogManager watchdog(15000); err = esp_partition_copy(app_copy_target_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { From 69a33d8ac0c879646923767b7cc2164b69c838c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:31:55 -0500 Subject: [PATCH 0875/2214] [core] Inline HAL clock wrappers and split hal.h into per-platform headers (#15977) --- esphome/components/esp32/core.cpp | 11 +-- esphome/components/esp8266/core.cpp | 17 +--- esphome/components/libretiny/core.cpp | 28 +----- esphome/components/rp2040/core.cpp | 6 +- esphome/core/hal.h | 131 ++++++-------------------- esphome/core/hal/hal_esp32.h | 35 +++++++ esphome/core/hal/hal_esp8266.h | 65 +++++++++++++ esphome/core/hal/hal_host.h | 24 +++++ esphome/core/hal/hal_libretiny.h | 93 ++++++++++++++++++ esphome/core/hal/hal_rp2040.h | 40 ++++++++ esphome/core/hal/hal_zephyr.h | 24 +++++ esphome/core/helpers.h | 41 +------- esphome/core/time_64.h | 6 +- esphome/core/time_conversion.h | 46 +++++++++ 14 files changed, 366 insertions(+), 201 deletions(-) create mode 100644 esphome/core/hal/hal_esp32.h create mode 100644 esphome/core/hal/hal_esp8266.h create mode 100644 esphome/core/hal/hal_host.h create mode 100644 esphome/core/hal/hal_libretiny.h create mode 100644 esphome/core/hal/hal_rp2040.h create mode 100644 esphome/core/hal/hal_zephyr.h create mode 100644 esphome/core/time_conversion.h diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 1c63137183..4886745c06 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -22,7 +22,7 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { -void HOT yield() { vPortYield(); } +// yield(), delay(), micros(), millis_64() inlined in hal.h. // Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig), // falling back to esp_timer for non-standard rates. IRAM_ATTR is required because // Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32. @@ -37,15 +37,6 @@ uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); #endif } -// millis_64() stays on esp_timer — a different clock from xTaskGetTickCount(). This is -// safe because the two are never cross-compared: millis() values are only used for -// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), while -// millis_64() is used by the Scheduler and uptime sensors. On ESP32 (USE_NATIVE_64BIT_TIME), -// Scheduler::millis_64_from_(now) discards the 32-bit now and calls millis_64() directly, -// so the Scheduler is internally consistent on the esp_timer clock. -uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } -void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } -uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { esp_restart(); diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index c9bedb61be..9161ca6aaf 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -15,7 +15,7 @@ extern "C" { namespace esphome { -void HOT yield() { ::yield(); } +// yield(), micros(), millis_64() inlined in hal.h. // Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit // multiplies on the LX106). Tracks a running ms counter from 32-bit // system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis @@ -66,7 +66,6 @@ uint32_t IRAM_ATTR HOT millis() { xt_wsr_ps(ps); return result; } -uint64_t millis_64() { return Millis64Impl::compute(millis()); } // Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object // call to the original millis() that --wrap can't intercept, so calling ::delay() // would keep the slow Arduino millis body alive in IRAM. optimistic_yield still @@ -85,8 +84,7 @@ void HOT delay(uint32_t ms) { optimistic_yield(1000); } } -uint32_t IRAM_ATTR HOT micros() { return ::micros(); } -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } +// delayMicroseconds(), arch_feed_wdt(), and progmem_read_*() are inlined in hal/hal_esp8266.h. void arch_restart() { system_restart(); // restart() doesn't always end execution @@ -95,17 +93,6 @@ void arch_restart() { } } void arch_init() {} -void HOT arch_feed_wdt() { system_soft_wdt_feed(); } - -uint8_t progmem_read_byte(const uint8_t *addr) { - return pgm_read_byte(addr); // NOLINT -} -const char *progmem_read_ptr(const char *const *addr) { - return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT -} -uint16_t progmem_read_uint16(const uint16_t *addr) { - return pgm_read_word(addr); // NOLINT -} uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { return F_CPU; } diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index ca46bcb899..f46abe3b81 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -3,7 +3,6 @@ #include "core.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#include "esphome/core/time_64.h" #include "esphome/core/helpers.h" #include "preferences.h" @@ -15,32 +14,7 @@ void loop(); namespace esphome { -void HOT yield() { ::yield(); } -// Inline the tick read so esphome::millis() matches MillisInternal::get()'s fast -// path instead of going through the Arduino core's out-of-line ::millis() wrapper. -// -// RTL87xx / LN882x (1 kHz): xTaskGetTickCount() is already ms. IRAM_ATTR + ISR -// dispatch are needed because ISR handlers (e.g. rotary_encoder) call millis(). -// -// BK72xx (500 Hz): ticks * portTICK_PERIOD_MS (== 2). IRAM_ATTR and ISR dispatch -// are both unnecessary — the SDK masks FIQ + IRQ during flash writes (see hal.h), -// so no ISR runs while flash is stalled. -#if defined(USE_RTL87XX) || defined(USE_LN882X) -uint32_t IRAM_ATTR HOT millis() { - static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); - return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount(); -} -#elif defined(USE_BK72XX) -uint32_t HOT millis() { - static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick"); - return xTaskGetTickCount() * portTICK_PERIOD_MS; -} -#else -uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -#endif -uint64_t millis_64() { return Millis64Impl::compute(millis()); } -uint32_t IRAM_ATTR HOT micros() { return ::micros(); } -void HOT delay(uint32_t ms) { ::delay(ms); } +// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } void arch_init() { diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index b7a9000612..d3dc1cf2bb 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -13,11 +13,7 @@ namespace esphome { -void HOT yield() { ::yield(); } -uint64_t millis_64() { return micros_to_millis(time_us_64()); } -uint32_t HOT millis() { return micros_to_millis(time_us_64()); } -void HOT delay(uint32_t ms) { ::delay(ms); } -uint32_t HOT micros() { return ::micros(); } +// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { watchdog_reboot(0, 0, 10); diff --git a/esphome/core/hal.h b/esphome/core/hal.h index e4083622b9..e20797cf95 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -2,125 +2,48 @@ #include #include #include "gpio.h" +#include "esphome/core/defines.h" +#include "esphome/core/time_64.h" +#include "esphome/core/time_conversion.h" +// Per-platform HAL bits (IRAM_ATTR / PROGMEM macros, in_isr_context(), +// inline yield/delay/micros/millis/millis_64 wrappers, ESP8266 progmem +// helpers) live under esphome/core/hal/ and are dispatched here based on +// the active USE_* platform define. Each header guards its body with the +// matching #ifdef USE_ and re-enters namespace esphome {} so it +// is safe to be re-included. #if defined(USE_ESP32) -#include -#ifndef PROGMEM -#define PROGMEM -#endif - +#include "esphome/core/hal/hal_esp32.h" #elif defined(USE_ESP8266) - -#include -#ifndef PROGMEM -#define PROGMEM ICACHE_RODATA_ATTR -#endif - -#elif defined(USE_RP2040) - -#define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) -#define PROGMEM - +#include "esphome/core/hal/hal_esp8266.h" #elif defined(USE_LIBRETINY) - -// IRAM_ATTR places a function in executable RAM so it is callable from an -// ISR even while flash is busy (XIP stall, OTA, logger flash write). -// Each family uses a section its stock linker already routes to RAM: -// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the -// exception: its stock linker has no matching glob, so patch_linker.py -// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. -// -// BK72xx (all variants) are left as a no-op: their SDK wraps flash -// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for -// the duration of every write, so no ISR fires while flash is stalled and -// the race IRAM_ATTR guards against cannot occur. The trade-off is that -// interrupts are delayed (not dropped) by up to ~20 ms during a sector -// erase, but that is an SDK-level choice and cannot be changed from this -// layer. -#if defined(USE_BK72XX) -#define IRAM_ATTR -#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) -// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). -#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) +#include "esphome/core/hal/hal_libretiny.h" +#elif defined(USE_RP2040) +#include "esphome/core/hal/hal_rp2040.h" +#elif defined(USE_HOST) +#include "esphome/core/hal/hal_host.h" +#elif defined(USE_ZEPHYR) +#include "esphome/core/hal/hal_zephyr.h" #else -// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. -// LN882H: patch_linker.py.script injects *(.sram.text*) into -// .flash_copysection (> RAM0 AT> FLASH). -#define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) -#endif -#define PROGMEM - -#else - -#define IRAM_ATTR -#define PROGMEM - -#endif - -#ifdef USE_ESP32 -#include -#include -#endif - -#ifdef USE_BK72XX -// Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so -// it is callable from Thumb code via interworking. The MRS CPSR instruction -// is ARM-only and user code here may be built in Thumb, so in_isr_context() -// defers to this port helper on BK72xx instead of reading CPSR inline. -extern "C" uint32_t platform_is_in_interrupt_context(void); +#error "hal.h: not implemented for this platform" #endif namespace esphome { -/// Returns true when executing inside an interrupt handler. -/// always_inline so callers placed in IRAM keep the detection in IRAM. -__attribute__((always_inline)) inline bool in_isr_context() { -#if defined(USE_ESP32) - return xPortInIsrContext() != 0; -#elif defined(USE_ESP8266) - // ESP8266 has no reliable single-register ISR detection: PS.INTLEVEL is - // non-zero both in a real ISR and when user code masks interrupts. The - // ESP8266 wake path is context-agnostic (wake_loop_impl uses esp_schedule - // which is ISR-safe) so this helper is unused on this platform. - return false; -#elif defined(USE_RP2040) - uint32_t ipsr; - __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); - return ipsr != 0; -#elif defined(USE_BK72XX) - // BK72xx is ARM968E-S (ARM9); see extern declaration above. - return platform_is_in_interrupt_context() != 0; -#elif defined(USE_LIBRETINY) - // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; - // non-zero means we're in a handler. - uint32_t ipsr; - __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); - return ipsr != 0; -#else - // Host and any future platform without an ISR concept. - return false; -#endif -} - -void yield(); -uint32_t millis(); -uint64_t millis_64(); -uint32_t micros(); -void delay(uint32_t ms); +// ESP8266 inlines delayMicroseconds() and arch_feed_wdt() in hal/hal_esp8266.h; +// every other platform keeps them out-of-line in components//core.cpp. +#ifndef USE_ESP8266 void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) +void arch_feed_wdt(); +#endif void __attribute__((noreturn)) arch_restart(); void arch_init(); -void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); -#ifdef USE_ESP8266 -// ESP8266: pgm_read_* does real flash reads on Harvard architecture -uint8_t progmem_read_byte(const uint8_t *addr); -const char *progmem_read_ptr(const char *const *addr); -uint16_t progmem_read_uint16(const uint16_t *addr); -#else -// All other platforms: PROGMEM is a no-op, so these are direct dereferences +#ifndef USE_ESP8266 +// All non-ESP8266 platforms: PROGMEM is a no-op, so these are direct dereferences. +// ESP8266's out-of-line declarations live in hal/hal_esp8266.h. inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } inline const char *progmem_read_ptr(const char *const *addr) { return *addr; } inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } diff --git a/esphome/core/hal/hal_esp32.h b/esphome/core/hal/hal_esp32.h new file mode 100644 index 0000000000..e755337540 --- /dev/null +++ b/esphome/core/hal/hal_esp32.h @@ -0,0 +1,35 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include +#include + +#include "esphome/core/time_conversion.h" + +#ifndef PROGMEM +#define PROGMEM +#endif + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +__attribute__((always_inline)) inline bool in_isr_context() { return xPortInIsrContext() != 0; } + +// Forward decl from . +// NOLINTNEXTLINE(readability-redundant-declaration) +extern "C" int64_t esp_timer_get_time(void); + +__attribute__((always_inline)) inline void yield() { vPortYield(); } +__attribute__((always_inline)) inline void delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(esp_timer_get_time()); } +uint32_t millis(); +__attribute__((always_inline)) inline uint64_t millis_64() { + return micros_to_millis(static_cast(esp_timer_get_time())); +} + +} // namespace esphome + +#endif // USE_ESP32 diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h new file mode 100644 index 0000000000..04326a3579 --- /dev/null +++ b/esphome/core/hal/hal_esp8266.h @@ -0,0 +1,65 @@ +#pragma once + +#ifdef USE_ESP8266 + +#include +#include +#include + +#include "esphome/core/time_64.h" + +#ifndef PROGMEM +#define PROGMEM ICACHE_RODATA_ATTR +#endif + +// Forward decls from Arduino's for the inline wrappers below. +// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +extern "C" void yield(void); +extern "C" void delay(unsigned long ms); +extern "C" unsigned long micros(void); +extern "C" unsigned long millis(void); +// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) + +// Forward decl from for arch_feed_wdt() inline below. +// NOLINTNEXTLINE(readability-redundant-declaration) +extern "C" void system_soft_wdt_feed(void); + +namespace esphome { + +// Forward decl from helpers.h so this header stays cheap. +// NOLINTNEXTLINE(readability-redundant-declaration) +void delay_microseconds_safe(uint32_t us); + +/// Returns true when executing inside an interrupt handler. +/// ESP8266 has no reliable single-register ISR detection: PS.INTLEVEL is +/// non-zero both in a real ISR and when user code masks interrupts. The +/// ESP8266 wake path is context-agnostic (wake_loop_impl uses esp_schedule +/// which is ISR-safe) so this helper is unused on this platform. +__attribute__((always_inline)) inline bool in_isr_context() { return false; } + +__attribute__((always_inline)) inline void yield() { ::yield(); } +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } +void delay(uint32_t ms); +uint32_t millis(); +__attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); } + +// ESP8266: pgm_read_* does aligned 32-bit flash reads on Harvard architecture. +// Inline-forward to the platform macros so the wrappers themselves don't +// occupy IRAM/flash on every call site. +__attribute__((always_inline)) inline uint8_t progmem_read_byte(const uint8_t *addr) { + return pgm_read_byte(addr); // NOLINT +} +__attribute__((always_inline)) inline const char *progmem_read_ptr(const char *const *addr) { + return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT +} +__attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) { + return pgm_read_word(addr); // NOLINT +} + +// NOLINTNEXTLINE(readability-identifier-naming) +__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } +__attribute__((always_inline)) inline void arch_feed_wdt() { system_soft_wdt_feed(); } + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/hal/hal_host.h b/esphome/core/hal/hal_host.h new file mode 100644 index 0000000000..145fe4ea9c --- /dev/null +++ b/esphome/core/hal/hal_host.h @@ -0,0 +1,24 @@ +#pragma once + +#ifdef USE_HOST + +#include + +#define IRAM_ATTR +#define PROGMEM + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +/// Host has no ISR concept. +__attribute__((always_inline)) inline bool in_isr_context() { return false; } + +void yield(); +void delay(uint32_t ms); +uint32_t micros(); +uint32_t millis(); +uint64_t millis_64(); + +} // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h new file mode 100644 index 0000000000..e0d92735bb --- /dev/null +++ b/esphome/core/hal/hal_libretiny.h @@ -0,0 +1,93 @@ +#pragma once + +#ifdef USE_LIBRETINY + +#include + +// For the inline millis() fast paths (xTaskGetTickCount, portTICK_PERIOD_MS). +#include +#include + +#include "esphome/core/time_64.h" + +// IRAM_ATTR places a function in executable RAM so it is callable from an +// ISR even while flash is busy (XIP stall, OTA, logger flash write). +// Each family uses a section its stock linker already routes to RAM: +// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the +// exception: its stock linker has no matching glob, so patch_linker.py +// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// +// BK72xx (all variants) are left as a no-op: their SDK wraps flash +// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for +// the duration of every write, so no ISR fires while flash is stalled and +// the race IRAM_ATTR guards against cannot occur. The trade-off is that +// interrupts are delayed (not dropped) by up to ~20 ms during a sector +// erase, but that is an SDK-level choice and cannot be changed from this +// layer. +#if defined(USE_BK72XX) +#define IRAM_ATTR +#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) +// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). +#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) +#else +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// LN882H: patch_linker.py.script injects *(.sram.text*) into +// .flash_copysection (> RAM0 AT> FLASH). +#define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) +#endif +#define PROGMEM + +#ifdef USE_BK72XX +// Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so +// it is callable from Thumb code via interworking. The MRS CPSR instruction +// is ARM-only and user code here may be built in Thumb, so in_isr_context() +// defers to this port helper on BK72xx instead of reading CPSR inline. +extern "C" uint32_t platform_is_in_interrupt_context(void); +#endif + +// Forward decls from Arduino's for the inline wrappers below. +// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +extern "C" void yield(void); +extern "C" void delay(unsigned long ms); +extern "C" unsigned long micros(void); +extern "C" unsigned long millis(void); +// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +__attribute__((always_inline)) inline bool in_isr_context() { +#if defined(USE_BK72XX) + // BK72xx is ARM968E-S (ARM9); see extern declaration above. + return platform_is_in_interrupt_context() != 0; +#else + // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; + // non-zero means we're in a handler. + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#endif +} + +__attribute__((always_inline)) inline void yield() { ::yield(); } +__attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } + +// Per-variant millis() fast path — matches MillisInternal::get(). +#if defined(USE_RTL87XX) || defined(USE_LN882X) +static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); +__attribute__((always_inline)) inline uint32_t millis() { + // xTaskGetTickCountFromISR is mandatory in interrupt context per the FreeRTOS API contract. + return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount(); +} +#elif defined(USE_BK72XX) +static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick"); +__attribute__((always_inline)) inline uint32_t millis() { return xTaskGetTickCount() * portTICK_PERIOD_MS; } +#else +__attribute__((always_inline)) inline uint32_t millis() { return static_cast(::millis()); } +#endif +__attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); } + +} // namespace esphome + +#endif // USE_LIBRETINY diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/core/hal/hal_rp2040.h new file mode 100644 index 0000000000..156ff33b86 --- /dev/null +++ b/esphome/core/hal/hal_rp2040.h @@ -0,0 +1,40 @@ +#pragma once + +#ifdef USE_RP2040 + +#include + +#include "esphome/core/time_conversion.h" + +#define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) +#define PROGMEM + +// Forward decls from Arduino's for the inline wrappers below. +// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +extern "C" void yield(void); +extern "C" void delay(unsigned long ms); +extern "C" unsigned long micros(void); +extern "C" unsigned long millis(void); +// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) + +// Forward decl from . +extern "C" uint64_t time_us_64(void); + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +__attribute__((always_inline)) inline bool in_isr_context() { + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +} + +__attribute__((always_inline)) inline void yield() { ::yield(); } +__attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } +__attribute__((always_inline)) inline uint32_t millis() { return micros_to_millis(::time_us_64()); } +__attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis(::time_us_64()); } + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/core/hal/hal_zephyr.h new file mode 100644 index 0000000000..e28be5c775 --- /dev/null +++ b/esphome/core/hal/hal_zephyr.h @@ -0,0 +1,24 @@ +#pragma once + +#ifdef USE_ZEPHYR + +#include + +#define IRAM_ATTR +#define PROGMEM + +namespace esphome { + +/// Returns true when executing inside an interrupt handler. +/// Zephyr/nRF52: not currently consulted — wake path is platform-specific. +__attribute__((always_inline)) inline bool in_isr_context() { return false; } + +void yield(); +void delay(uint32_t ms); +uint32_t micros(); +uint32_t millis(); +uint64_t millis_64(); + +} // namespace esphome + +#endif // USE_ZEPHYR diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b2b07c57a0..355db6c7f4 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -20,6 +20,7 @@ #include #include "esphome/core/optional.h" +#include "esphome/core/time_conversion.h" // Backward compatibility re-export of heap-allocating helpers. // These functions have moved to alloc_helpers.h. External components should @@ -833,43 +834,9 @@ template constexpr uint32_t fnv1a_hash_extend(uint32_t hash, T constexpr uint32_t fnv1a_hash(const char *str) { return fnv1a_hash_extend(FNV1_OFFSET_BASIS, str); } inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str()); } -/// Convert a 64-bit microsecond count to milliseconds without calling -/// __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz). -/// -/// Returns uint32_t by default (for millis()), or uint64_t when requested -/// (for millis_64()). The only difference is whether hi * Q is truncated -/// to 32 bits or widened to 64. -/// -/// On 32-bit targets, GCC does not optimize 64-bit constant division into a -/// multiply-by-reciprocal. Since 1000 = 8 * 125, we first right-shift by 3 -/// (free divide-by-8), then use the Euclidean division identity to decompose -/// the remaining 64-bit divide-by-125 into a single 32-bit division: -/// -/// floor(us / 1000) = floor(floor(us / 8) / 125) [exact for integers] -/// 2^32 = Q * 125 + R (34359738 * 125 + 46) -/// (hi * 2^32 + lo) / 125 = hi * Q + (hi * R + lo) / 125 -/// -/// GCC optimizes the remaining 32-bit "/ 125U" into a multiply-by-reciprocal -/// (mulhu + shift), so no division instruction is emitted. -/// -/// Safe for us up to ~3.2e18 (~101,700 years of microseconds). -/// -/// See: https://en.wikipedia.org/wiki/Euclidean_division -/// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html -template inline constexpr ESPHOME_ALWAYS_INLINE ReturnT micros_to_millis(uint64_t us) { - constexpr uint32_t d = 125U; - constexpr uint32_t q = static_cast((1ULL << 32) / d); // 34359738 - constexpr uint32_t r = static_cast((1ULL << 32) % d); // 46 - // 1000 = 8 * 125; divide-by-8 is a free shift - uint64_t x = us >> 3; - uint32_t lo = static_cast(x); - uint32_t hi = static_cast(x >> 32); - // Combine remainder term: hi * (2^32 % 125) + lo - uint32_t adj = hi * r + lo; - // If adj overflowed, the true value is 2^32 + adj; apply the identity again - // static_cast(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q - return static_cast(hi) * q + (adj < lo ? (adj + r) / d + q : adj / d); -} +// micros_to_millis<>() lives in its own lightweight header so hal.h can pull it +// in for inline millis_64() without forcing every TU that includes hal.h to +// also include the rest of helpers.h. /// Return a random 32-bit unsigned integer. /// Not thread-safe. Must only be called from the main loop. diff --git a/esphome/core/time_64.h b/esphome/core/time_64.h index d82373dbfe..f66f9afddb 100644 --- a/esphome/core/time_64.h +++ b/esphome/core/time_64.h @@ -6,8 +6,6 @@ #include #include -#include "esphome/core/helpers.h" - namespace esphome { class Scheduler; @@ -24,7 +22,9 @@ class Millis64Impl { static uint32_t last_millis; static uint16_t millis_major; - static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) { + // Raw __attribute__((always_inline)) (not ESPHOME_ALWAYS_INLINE) so this + // header does not need to pull helpers.h. + static inline uint64_t __attribute__((always_inline)) compute(uint32_t now) { // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; diff --git a/esphome/core/time_conversion.h b/esphome/core/time_conversion.h new file mode 100644 index 0000000000..e9060c0626 --- /dev/null +++ b/esphome/core/time_conversion.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +namespace esphome { + +/// Convert a 64-bit microsecond count to milliseconds without calling +/// __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz). +/// +/// Returns uint32_t by default (for millis()), or uint64_t when requested +/// (for millis_64()). The only difference is whether hi * Q is truncated +/// to 32 bits or widened to 64. +/// +/// On 32-bit targets, GCC does not optimize 64-bit constant division into a +/// multiply-by-reciprocal. Since 1000 = 8 * 125, we first right-shift by 3 +/// (free divide-by-8), then use the Euclidean division identity to decompose +/// the remaining 64-bit divide-by-125 into a single 32-bit division: +/// +/// floor(us / 1000) = floor(floor(us / 8) / 125) [exact for integers] +/// 2^32 = Q * 125 + R (34359738 * 125 + 46) +/// (hi * 2^32 + lo) / 125 = hi * Q + (hi * R + lo) / 125 +/// +/// GCC optimizes the remaining 32-bit "/ 125U" into a multiply-by-reciprocal +/// (mulhu + shift), so no division instruction is emitted. +/// +/// Safe for us up to ~3.2e18 (~101,700 years of microseconds). +/// +/// See: https://en.wikipedia.org/wiki/Euclidean_division +/// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html +template +__attribute__((always_inline)) inline constexpr ReturnT micros_to_millis(uint64_t us) { + constexpr uint32_t d = 125U; + constexpr uint32_t q = static_cast((1ULL << 32) / d); // 34359738 + constexpr uint32_t r = static_cast((1ULL << 32) % d); // 46 + // 1000 = 8 * 125; divide-by-8 is a free shift + uint64_t x = us >> 3; + uint32_t lo = static_cast(x); + uint32_t hi = static_cast(x >> 32); + // Combine remainder term: hi * (2^32 % 125) + lo + uint32_t adj = hi * r + lo; + // If adj overflowed, the true value is 2^32 + adj; apply the identity again + // static_cast(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q + return static_cast(hi) * q + (adj < lo ? (adj + r) / d + q : adj / d); +} + +} // namespace esphome From 8b98079fad4847f315769ccbc45e592c4cfd23c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:33:11 -0500 Subject: [PATCH 0876/2214] first match wins --- esphome/components/ota/ota_backend_esp_idf.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 503a1fbbe6..15b83e400e 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -248,6 +248,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { bool otadata_partition_found = false; bool otadata_overlap = false; bool nvs_partition_found = false; + // Selection policy when multiple app slots in the new partition table can host the running app: + // pick the FIRST eligible slot in table order. The no-copy path (offsets already match) is + // preferred over the copy path; within each path we lock in the first match and stop searching. + // This keeps the choice deterministic and table-ordering-stable instead of "last writer wins". for (int i = 0; i < num_partitions; i++) { // Iterate over new partition table const esp_partition_info_t *new_part = &new_partition_table[i]; if (new_part->type == ESP_PARTITION_TYPE_APP) { @@ -256,12 +260,17 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (new_part->pos.size >= running_app_size) { // Running app can fit inside this partition if (new_part->pos.offset == running_app_offset) { - // This new app partition can be used for the running app without copying because the offsets are the same - new_app_part_index = i; - } else if (!check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { + // This new app partition can be used for the running app without copying because the offsets are the same. + // First match wins; once locked in, the no-copy path is preferred and won't be overwritten. + if (new_app_part_index == -1) { + new_app_part_index = i; + } + } else if (new_app_part_index_with_copy == -1 && + !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { // This new app partition can be used for the running app after copying the app into it // Check if there is an app partition in the old partition table at the right offset - // This is for esp_partition_copy and won't be needed after implementing a better copy function in the future + // This is for esp_partition_copy and won't be needed after implementing a better copy function in the future. + // First match wins for determinism; stop searching as soon as a suitable pair is found. esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); @@ -269,6 +278,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Found a suitable pair of partitions in the old and new partition table to copy the running app to new_app_part_index_with_copy = i; // The partition index in the new partition table app_copy_target_part = p; // The partition in the old partition table + break; } it = esp_partition_next(it); } From 9b3f8f6f6e66501ebc967b788705dcf5b83d7ba5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:34:25 -0500 Subject: [PATCH 0877/2214] explict name --- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- esphome/components/ota/ota_backend_esp_idf.h | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 15b83e400e..2a08bece9a 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -98,7 +98,7 @@ void IDFOTABackend::set_update_md5(const char *expected_md5) { OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - if (len > OTA_BUFFER_SIZE - this->buf_written_) { + if (len > PARTITION_TABLE_BUFFER_SIZE - this->buf_written_) { ESP_LOGE(TAG, "Wrong partition table size"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 6192f5f969..d9de8a5eed 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -10,7 +10,10 @@ namespace esphome::ota { #ifdef USE_OTA_PARTITIONS -static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 +// Dedicated staging buffer size for the new partition table image. Must be at least +// ESP_PARTITION_TABLE_MAX_LEN (0xC00) so the entire partition table fits before verification. +// Kept separate from any OTA chunk-transfer buffer to avoid coupling unrelated sizes. +static constexpr size_t PARTITION_TABLE_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 #endif #ifdef USE_OTA_PARTITIONS @@ -43,7 +46,7 @@ class IDFOTABackend final { bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; - uint8_t buf_[OTA_BUFFER_SIZE]; + uint8_t buf_[PARTITION_TABLE_BUFFER_SIZE]; size_t buf_written_{0}; size_t image_size_{0}; const esp_partition_t *partition_table_part_{nullptr}; From 694f1947ee499f996e949c5749207115067b6c00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:34:39 -0500 Subject: [PATCH 0878/2214] convention is nulltr --- esphome/components/esphome/ota/ota_esphome.cpp | 4 ++-- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1d0b8713a4..245bdc9a09 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -109,8 +109,8 @@ void ESPHomeOTAComponent::dump_config() { this->running_app_size_); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { const esp_partition_t *p = esp_partition_get(it); ESP_LOGCONFIG(TAG, " %s: type=0x%X, subtype=0x%X, address=0x%X, size=0x%X", p->label, p->type, p->subtype, p->address, p->size); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2a08bece9a..bfd469bd73 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -203,7 +203,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Verify existing partition table - const esp_partition_info_t *existing_partition_table = NULL; + const esp_partition_info_t *existing_partition_table = nullptr; esp_partition_mmap_handle_t partition_table_map; err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, (const void **) &existing_partition_table, &partition_table_map); From eff13bedd5da52fb0e356ac6f2b4b88fa20ddb39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:34:46 -0500 Subject: [PATCH 0879/2214] convention is nulltr --- esphome/components/ota/ota_backend_esp_idf.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index bfd469bd73..8cf788da4d 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -271,8 +271,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Check if there is an app partition in the old partition table at the right offset // This is for esp_partition_copy and won't be needed after implementing a better copy function in the future. // First match wins for determinism; stop searching as soon as a suitable pair is found. - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { const esp_partition_t *p = esp_partition_get(it); if (p->address == new_part->pos.offset && p->size >= running_app_size) { // Found a suitable pair of partitions in the old and new partition table to copy the running app to From b649850e806ae1e0d8870177671565bcd6aa2217 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:34:51 -0500 Subject: [PATCH 0880/2214] convention is nulltr --- esphome/components/ota/ota_backend_esp_idf.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 8cf788da4d..441953497f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -324,8 +324,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Copy the running app partition to new position if needed if (new_app_part_index == -1) { const esp_partition_t *running_app_part = nullptr; - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { const esp_partition_t *p = esp_partition_get(it); if (p->address == running_app_offset && p->size >= running_app_size) { running_app_part = p; From 582ccf1cd1e24b0d9064fa891b2245e6c145ab4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:34:56 -0500 Subject: [PATCH 0881/2214] convention is nulltr --- esphome/components/ota/ota_backend_esp_idf.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 441953497f..5c048f9add 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -373,8 +373,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Write otadata to set the new boot partition const esp_partition_t *new_boot_partition = nullptr; - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { const esp_partition_t *p = esp_partition_get(it); const esp_partition_info_t *new_part = &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; From 306edfcd8aaca5895df1c2c70d61c28ebf18bd8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:36:40 -0500 Subject: [PATCH 0882/2214] follow https://developers.esphome.io/architecture/logging/#configuration-logging-esp_logconfig --- esphome/components/esphome/ota/ota_esphome.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 245bdc9a09..ceea617c58 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -104,9 +104,12 @@ void ESPHomeOTAComponent::dump_config() { } #endif #ifdef USE_OTA_PARTITIONS - ESP_LOGCONFIG(TAG, " Partition access allowed"); - ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %zu bytes", this->running_app_offset_, - this->running_app_size_); + ESP_LOGCONFIG(TAG, + " Partition access allowed\n" + " Running app:\n" + " Partition address: 0x%X\n" + " Used size: %zu bytes", + this->running_app_offset_, this->running_app_size_); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); From 863ecde7236cdb8c36b6bb25f5781bc1ac3e609c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:37:13 -0500 Subject: [PATCH 0883/2214] use cpp casts --- esphome/components/ota/ota_backend_esp_idf.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 5c048f9add..d5e95bd111 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -206,7 +206,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_info_t *existing_partition_table = nullptr; esp_partition_mmap_handle_t partition_table_map; err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, - (const void **) &existing_partition_table, &partition_table_map); + reinterpret_cast(&existing_partition_table), &partition_table_map); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; @@ -219,7 +219,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Verify new partition table - const esp_partition_info_t *new_partition_table = (const esp_partition_info_t *) this->buf_; + const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); // esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN bytes of data err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { @@ -230,7 +230,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // esp_partition_table_verify does not fail in this case and the ESP would not boot after the update bool checksum_found = false; for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { - if (((const esp_partition_info_t *) &new_partition_table[i])->magic == ESP_PARTITION_MAGIC_MD5) { + if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { checksum_found = true; } } From 869d900ba6456fdffc0f32d491d8b851d783d434 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:37:57 -0500 Subject: [PATCH 0884/2214] constexpr --- esphome/components/esphome/ota/ota_esphome.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ceea617c58..b8484bfce7 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -136,11 +136,11 @@ void ESPHomeOTAComponent::loop() { this->handle_handshake_(); } -static const uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; -static const uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; -static const uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; -static const uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; -static const uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. From a97b591c8c2f63c1dd82006f4eb55615478756d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:38:53 -0500 Subject: [PATCH 0885/2214] align --- esphome/components/esphome/ota/ota_esphome.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c09ad843f8..2da6e7dcd6 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -92,9 +92,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_PARTITIONS - bool extended_proto_{false}; + // 4-byte members first, 1-byte member last to minimize padding. uint32_t running_app_offset_{0}; size_t running_app_size_{0}; + bool extended_proto_{false}; #endif socket::ListenSocket *server_{nullptr}; From 204f52a2e68b95260617cb224ad96298ab631855 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:39:49 -0500 Subject: [PATCH 0886/2214] dry --- .../components/ota/ota_backend_esp_idf.cpp | 66 ++++++++----------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index d5e95bd111..9a97fd8d8b 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -181,6 +181,24 @@ static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_of return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); } +// Find the first registered APP partition whose address matches `address` and whose size is at least +// `min_size`. Returns nullptr when no match exists. Encapsulates the iterator + release pattern so +// callers don't have to repeat (and correctly handle) the find/get/next/release dance. +static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min_size) { + const esp_partition_t *found = nullptr; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { + const esp_partition_t *p = esp_partition_get(it); + if (p->address == address && p->size >= min_size) { + found = p; + break; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + return found; +} + OTAResponseTypes IDFOTABackend::update_partition_table() { int num_partitions; if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { @@ -267,22 +285,14 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } } else if (new_app_part_index_with_copy == -1 && !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { - // This new app partition can be used for the running app after copying the app into it + // This new app partition can be used for the running app after copying the app into it. // Check if there is an app partition in the old partition table at the right offset - // This is for esp_partition_copy and won't be needed after implementing a better copy function in the future. - // First match wins for determinism; stop searching as soon as a suitable pair is found. - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); - while (it != nullptr) { - const esp_partition_t *p = esp_partition_get(it); - if (p->address == new_part->pos.offset && p->size >= running_app_size) { - // Found a suitable pair of partitions in the old and new partition table to copy the running app to - new_app_part_index_with_copy = i; // The partition index in the new partition table - app_copy_target_part = p; // The partition in the old partition table - break; - } - it = esp_partition_next(it); + // (esp_partition_copy needs a registered source partition; first match wins for determinism). + const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); + if (p != nullptr) { + new_app_part_index_with_copy = i; // The partition index in the new partition table + app_copy_target_part = p; // The partition in the old partition table } - esp_partition_iterator_release(it); } } } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { @@ -323,16 +333,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Copy the running app partition to new position if needed if (new_app_part_index == -1) { - const esp_partition_t *running_app_part = nullptr; - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); - while (it != nullptr) { - const esp_partition_t *p = esp_partition_get(it); - if (p->address == running_app_offset && p->size >= running_app_size) { - running_app_part = p; - } - it = esp_partition_next(it); - } - esp_partition_iterator_release(it); + const esp_partition_t *running_app_part = find_app_partition_at(running_app_offset, running_app_size); if (running_app_part == nullptr) { ESP_LOGE(TAG, "Running app partition not found in current partition table"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; @@ -372,23 +373,14 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_partition_unload_all(); // Write otadata to set the new boot partition - const esp_partition_t *new_boot_partition = nullptr; - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); - while (it != nullptr) { - const esp_partition_t *p = esp_partition_get(it); - const esp_partition_info_t *new_part = - &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; - if (p->address == new_part->pos.offset) { - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", p->address); - new_boot_partition = p; - } - it = esp_partition_next(it); - } - esp_partition_iterator_release(it); + const esp_partition_info_t *new_part = + &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; + const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); if (new_boot_partition == nullptr) { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } + ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); From 734fc6187993d991ff73cc05b8a077cd41a73a76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:41:27 -0500 Subject: [PATCH 0887/2214] cleaups --- esphome/components/ota/ota_backend_esp_idf.cpp | 11 +++++------ esphome/components/ota/ota_backend_esp_idf.h | 5 ++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 9a97fd8d8b..3eff46dc0f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -331,13 +331,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // underlying ESP-IDF calls take longer than expected on a given chip variant. watchdog::WatchdogManager watchdog(15000); - // Copy the running app partition to new position if needed + // Copy the running app partition to new position if needed. + // esp_ota_get_running_partition() is still valid here (we have not yet called + // esp_partition_unload_all()) and returns the same partition that find_app_partition_at would + // have located, without an extra iterator walk. if (new_app_part_index == -1) { - const esp_partition_t *running_app_part = find_app_partition_at(running_app_offset, running_app_size); - if (running_app_part == nullptr) { - ESP_LOGE(TAG, "Running app partition not found in current partition table"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index d9de8a5eed..08d4ac2abe 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -45,11 +45,14 @@ class IDFOTABackend final { char expected_bin_md5_[32]; bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS - ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; + // Place the byte buffer first so it sits immediately after the preceding `bool md5_set_`, + // eliminating the 3-byte alignment padding that an int-sized member would otherwise force. + // Remaining members are 4-byte-aligned and pack tightly after the buffer. uint8_t buf_[PARTITION_TABLE_BUFFER_SIZE]; size_t buf_written_{0}; size_t image_size_{0}; const esp_partition_t *partition_table_part_{nullptr}; + ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; #endif }; From 7fba57ce51e5babfb77eff76603b5008236c4f55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:45:30 -0500 Subject: [PATCH 0888/2214] [valve] Add tests for valve.control action field combinations (#16126) --- tests/components/template/common-base.yaml | 14 ++++ .../fixtures/valve_control_action.yaml | 69 ++++++++++++++++++ .../integration/test_valve_control_action.py | 72 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 tests/integration/fixtures/valve_control_action.yaml create mode 100644 tests/integration/test_valve_control_action.py diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index daa6f53d42..819eaa8bbf 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -442,6 +442,20 @@ valve: state: CLOSED stop_action: - logger.log: stop_action + # Exercise valve.control with various field combinations so the + # ControlAction codegen paths get build coverage. + - valve.control: + id: template_valve + stop: true + - valve.control: + id: template_valve + position: 50% + - valve.control: + id: template_valve + state: OPEN + - valve.control: + id: template_valve + position: !lambda 'return 0.25f;' optimistic: true text: diff --git a/tests/integration/fixtures/valve_control_action.yaml b/tests/integration/fixtures/valve_control_action.yaml new file mode 100644 index 0000000000..4f43d16289 --- /dev/null +++ b/tests/integration/fixtures/valve_control_action.yaml @@ -0,0 +1,69 @@ +esphome: + name: valve-control-action-test +host: +api: +logger: + level: DEBUG + +globals: + - id: test_position + type: float + initial_value: "0.42" + +valve: + - platform: template + name: "Test Valve" + id: test_valve + has_position: true + optimistic: true + assumed_state: true + open_action: + - valve.template.publish: + id: test_valve + position: 1.0 + close_action: + - valve.template.publish: + id: test_valve + position: 0.0 + stop_action: + - valve.template.publish: + id: test_valve + current_operation: IDLE + +button: + # valve.control: position only + - platform: template + id: btn_position + name: "Set Position" + on_press: + - valve.control: + id: test_valve + position: 50% + + # valve.control: state alias for position 1.0 + - platform: template + id: btn_open_state + name: "Open State" + on_press: + - valve.control: + id: test_valve + state: OPEN + + # valve.control: lambda position (exercises lambda path) + - platform: template + id: btn_lambda_position + name: "Lambda Position" + on_press: + - valve.control: + id: test_valve + position: !lambda "return id(test_position);" + + # valve.control: stop only — template valve's stop_action publishes + # current_operation: IDLE. + - platform: template + id: btn_stop + name: "Stop Valve" + on_press: + - valve.control: + id: test_valve + stop: true diff --git a/tests/integration/test_valve_control_action.py b/tests/integration/test_valve_control_action.py new file mode 100644 index 0000000000..d6515b8960 --- /dev/null +++ b/tests/integration/test_valve_control_action.py @@ -0,0 +1,72 @@ +"""Integration test for valve ControlAction. + +Tests that valve.control automation actions work correctly across multiple +field combinations and the lambda path. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, ValveInfo, ValveOperation, ValveState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_valve_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test valve ControlAction with constants and a lambda.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + valve_state_future: asyncio.Future[ValveState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, ValveState) + and valve_state_future is not None + and not valve_state_future.done() + ): + valve_state_future.set_result(state) + + async def wait_for_valve_state(timeout: float = 5.0) -> ValveState: + nonlocal valve_state_future + valve_state_future = loop.create_future() + try: + return await asyncio.wait_for(valve_state_future, timeout) + finally: + valve_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_valve", ValveInfo) + + async def press_and_wait(name: str) -> ValveState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_valve_state() + + # valve.control: position only + state = await press_and_wait("Set Position") + assert state.position == pytest.approx(0.5, abs=0.01) + + # valve.control: state alias for position 1.0 + state = await press_and_wait("Open State") + assert state.position == pytest.approx(1.0, abs=0.01) + + # valve.control: lambda position (test_position global = 0.42) + state = await press_and_wait("Lambda Position") + assert state.position == pytest.approx(0.42, abs=0.01) + + # valve.control: stop only — template valve's stop_action publishes + # current_operation: IDLE. + state = await press_and_wait("Stop Valve") + assert state.current_operation == ValveOperation.IDLE From e5b1991cf79943534f2eaa9cadc06470fa5be2f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:46:06 -0500 Subject: [PATCH 0889/2214] [fan] Add tests for fan.turn_on action field combinations (#16125) --- tests/components/fan/common.yaml | 31 ++++++++ .../fixtures/fan_turn_on_action.yaml | 59 +++++++++++++++ tests/integration/test_fan_turn_on_action.py | 75 +++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 tests/integration/fixtures/fan_turn_on_action.yaml create mode 100644 tests/integration/test_fan_turn_on_action.py diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 099bbfef08..6cabbd24f8 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -57,3 +57,34 @@ binary_sensor: return true; } return false; + +# Exercise fan.turn_on with various field combinations so the +# TurnOnAction codegen paths get build coverage. +button: + - platform: template + name: "Fan Speed Only" + on_press: + - fan.turn_on: + id: test_fan + speed: 2 + - platform: template + name: "Fan Oscillating + Direction" + on_press: + - fan.turn_on: + id: test_fan + oscillating: true + direction: REVERSE + - platform: template + name: "Fan All Fields" + on_press: + - fan.turn_on: + id: test_fan + oscillating: false + speed: 3 + direction: FORWARD + - platform: template + name: "Fan Lambda Speed" + on_press: + - fan.turn_on: + id: test_fan + speed: !lambda 'return 1;' diff --git a/tests/integration/fixtures/fan_turn_on_action.yaml b/tests/integration/fixtures/fan_turn_on_action.yaml new file mode 100644 index 0000000000..11bf033e48 --- /dev/null +++ b/tests/integration/fixtures/fan_turn_on_action.yaml @@ -0,0 +1,59 @@ +esphome: + name: fan-turn-on-action-test +host: +api: +logger: + level: DEBUG + +globals: + - id: test_speed + type: int + initial_value: "2" + +fan: + - platform: template + id: test_fan + name: "Test Fan" + has_oscillating: true + has_direction: true + speed_count: 5 + +button: + # fan.turn_on: speed only + - platform: template + id: btn_speed + name: "Set Speed" + on_press: + - fan.turn_on: + id: test_fan + speed: 3 + + # fan.turn_on: oscillating + direction (no speed) + - platform: template + id: btn_oscillate_direction + name: "Set Oscillate Direction" + on_press: + - fan.turn_on: + id: test_fan + oscillating: true + direction: REVERSE + + # fan.turn_on: all three fields + - platform: template + id: btn_all_fields + name: "Set All Fields" + on_press: + - fan.turn_on: + id: test_fan + oscillating: false + speed: 4 + direction: FORWARD + + # fan.turn_on: lambda for speed (exercises lambda path) + - platform: template + id: btn_lambda_speed + name: "Lambda Speed" + on_press: + - fan.turn_on: + id: test_fan + speed: !lambda "return id(test_speed);" diff --git a/tests/integration/test_fan_turn_on_action.py b/tests/integration/test_fan_turn_on_action.py new file mode 100644 index 0000000000..bce258cb5c --- /dev/null +++ b/tests/integration/test_fan_turn_on_action.py @@ -0,0 +1,75 @@ +"""Integration test for fan TurnOnAction. + +Tests that fan.turn_on automation actions work correctly across multiple +field combinations and the lambda path. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, FanDirection, FanInfo, FanState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_fan_turn_on_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test fan TurnOnAction with constants and a lambda.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + fan_state_future: asyncio.Future[FanState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, FanState) + and fan_state_future is not None + and not fan_state_future.done() + ): + fan_state_future.set_result(state) + + async def wait_for_fan_state(timeout: float = 5.0) -> FanState: + nonlocal fan_state_future + fan_state_future = loop.create_future() + try: + return await asyncio.wait_for(fan_state_future, timeout) + finally: + fan_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_fan", FanInfo) + + async def press_and_wait(name: str) -> FanState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_fan_state() + + # speed only + state = await press_and_wait("Set Speed") + assert state.state is True + assert state.speed_level == 3 + + # oscillating + direction + state = await press_and_wait("Set Oscillate Direction") + assert state.oscillating is True + assert state.direction == FanDirection.REVERSE + + # all three fields + state = await press_and_wait("Set All Fields") + assert state.oscillating is False + assert state.speed_level == 4 + assert state.direction == FanDirection.FORWARD + + # lambda path: speed computed at runtime (test_speed global = 2) + state = await press_and_wait("Lambda Speed") + assert state.speed_level == 2 From ab6eb247fa535476a250f96630efe48ea26111f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:46:51 -0500 Subject: [PATCH 0890/2214] fix handle leak on fail --- esphome/components/ota/ota_backend_esp_idf.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 3eff46dc0f..52dc4756a8 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -168,10 +168,11 @@ void IDFOTABackend::abort() { esp_partition_deregister_external(this->partition_table_part_); this->partition_table_part_ = nullptr; } - if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { - return; - } #endif + // Always tear down any open OTA handle. update_partition_table() opens a handle internally to + // write the new partition table; if esp_ota_write/esp_ota_end fail mid-flight, the handle must + // be released here so it isn't leaked. esp_ota_abort with handle 0 returns ESP_ERR_INVALID_ARG + // harmlessly, so the unconditional call is safe whether or not we're mid-update. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; } @@ -358,11 +359,15 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); if (err != ESP_OK) { + // Release the handle eagerly; abort() would also do this, but cleaning up locally keeps the + // partial-write failure path self-contained. + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); - this->update_handle_ = 0; + this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; From e5f3a91e42e11e3eb4a4f32330947acc79dc0a43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:48:45 -0500 Subject: [PATCH 0891/2214] falsey is default --- esphome/components/esphome/ota/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 0a9a76e771..ee3b7f0c20 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -77,7 +77,7 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) - if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS, False) and not CORE.is_esp32: + if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS) and not CORE.is_esp32: raise cv.Invalid( f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" ) @@ -167,7 +167,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) - if config.get(CONF_ALLOW_PARTITION_ACCESS, False): + if config.get(CONF_ALLOW_PARTITION_ACCESS): cg.add_define("USE_OTA_PARTITIONS") # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. From 16ff803a3c3da3e19abde657643b726d0575867e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:49:41 -0500 Subject: [PATCH 0892/2214] improve readability --- .../components/esphome/ota/ota_esphome.cpp | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index b8484bfce7..6897165f1d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -228,38 +228,36 @@ void ESPHomeOTAComponent::handle_handshake_() { this->transition_ota_state_(OTAState::FEATURE_ACK); const bool supports_compression = - ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()); + (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); + + // Compose the feature-ack response. When USE_OTA_PARTITIONS is enabled and the client + // negotiates the extended protocol we emit a 2-byte response (marker + server feature flags); + // otherwise we emit the single-byte legacy response. The #ifdef wraps only the extended-proto + // branch so the legacy branch reads as unconditional code in either build configuration. #ifdef USE_OTA_PARTITIONS - this->extended_proto_ = this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; + this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { - // If the client supports the extended protocol, send 2 bytes: response type and server feature flags this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; - this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; // supported if USE_OTA_PARTITIONS - if (supports_compression) { - this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; - } - } else { + this->handshake_buf_[1] = + SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS | (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); + } else #endif - // Standard protocol without server feature flags + { this->handshake_buf_[0] = - (supports_compression) ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; -#ifdef USE_OTA_PARTITIONS + supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; } -#endif [[fallthrough]]; } case OTAState::FEATURE_ACK: { - // Acknowledge header - 1 byte #ifdef USE_OTA_PARTITIONS - if (!this->try_write_(this->extended_proto_ ? 2 : 1, LOG_STR("ack feature"))) { - return; - } + const size_t ack_size = this->extended_proto_ ? 2 : 1; #else - if (!this->try_write_(1, LOG_STR("ack feature"))) { + const size_t ack_size = 1; +#endif + if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } -#endif #ifdef USE_OTA_PASSWORD // If password is set, move to auth phase From e55b7b54cb6f4cb0212f4c4f899604c9cb48bdba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:51:08 -0500 Subject: [PATCH 0893/2214] better handle failure case --- .../components/ota/ota_backend_esp_idf.cpp | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 52dc4756a8..91e3fe412a 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -394,32 +394,55 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } void get_running_app_position(uint32_t &offset, size_t &size) { - // Gets the start address and the used length aligned to sectors of the running app. - // This function needs to be called once before calling esp_partition_unload_all(). - // The results are stored using static variables because esp_ota_get_running_partition() - // does not return valid data after calling esp_partition_unload_all(). - static uint32_t running_app_offset = 0; - static size_t running_app_size = 0; - if (running_app_size == 0) { + // Gets the start address and the used length (rounded up to flash sectors) of the running app. + // + // The result is cached because esp_ota_get_running_partition() does not return valid data after + // esp_partition_unload_all() has been called during a partition-table OTA. The running app does + // not move within a boot, so the first successful query is valid for the lifetime of the process. + // + // Caching is gated by an explicit `initialized` flag (rather than checking for size == 0) so a + // failed first call (e.g., esp_ota_get_running_partition() returning nullptr after a previously + // aborted partition-table OTA already called esp_partition_unload_all()) does not poison the + // cache; the next caller will retry. Values are written into the cache atomically only after the + // full computation succeeds. + static bool initialized = false; + static uint32_t cached_offset = 0; + static size_t cached_size = 0; + + if (!initialized) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); - running_app_size = running_app_part->size; - running_app_offset = running_app_part->address; + if (running_app_part == nullptr || running_app_part->erase_size == 0) { + // Cannot determine the running app right now; surface zeros without committing to the cache + // so a later call has a chance to succeed. + offset = 0; + size = 0; + return; + } + + uint32_t pending_offset = running_app_part->address; + size_t pending_size = running_app_part->size; + const esp_partition_pos_t running_app_pos = { .offset = running_app_part->address, .size = running_app_part->size, }; - esp_image_metadata_t image_metadata; + esp_image_metadata_t image_metadata = {}; image_metadata.start_addr = running_app_part->address; - esp_err_t err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); - if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { - running_app_size = image_metadata.image_len; + if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata) == ESP_OK && + image_metadata.image_len < running_app_part->size) { + pending_size = image_metadata.image_len; } - // Align running_app_size to flash sectors - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * - running_app_part->erase_size; + // Round up to flash sector size so the copy spans complete erase blocks. + pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + + cached_offset = pending_offset; + cached_size = pending_size; + initialized = true; } - offset = running_app_offset; - size = running_app_size; + + offset = cached_offset; + size = cached_size; } #endif From 185fa7e302c56760acdc6b0d9ae577d540156cba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:52:29 -0500 Subject: [PATCH 0894/2214] close dangling pointer risk --- esphome/components/ota/ota_backend_esp_idf.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 91e3fe412a..d44e2d0b27 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -372,9 +372,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - esp_partition_deregister_external(this->partition_table_part_); - this->partition_table_part_ = nullptr; + // esp_partition_unload_all() invalidates every cached partition entry, including the externally + // registered `partition_table_part_`, so the explicit deregister call is redundant. Do the + // unload first, then null the member pointer so it never dangles past invalidation; if abort() + // were ever to observe an in-between state, it would see a non-null but freed pointer and crash. esp_partition_unload_all(); + this->partition_table_part_ = nullptr; // Write otadata to set the new boot partition const esp_partition_info_t *new_part = From 6de08ea563e5e5b3517d3dd0a956dcef5cea30d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:57:18 -0500 Subject: [PATCH 0895/2214] more cleanups --- .../components/esphome/ota/ota_esphome.cpp | 11 +- esphome/components/esphome/ota/ota_esphome.h | 3 - esphome/components/ota/ota_backend.h | 4 +- .../components/ota/ota_backend_esp_idf.cpp | 7 +- esphome/espota2.py | 144 +++++++++--------- 5 files changed, 83 insertions(+), 86 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6897165f1d..ac4906780e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -87,9 +87,6 @@ void ESPHomeOTAComponent::setup() { // no wakes fire and loop() falls back to the self-disable safety net. esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif -#ifdef USE_OTA_PARTITIONS - ota::get_running_app_position(this->running_app_offset_, this->running_app_size_); -#endif } void ESPHomeOTAComponent::dump_config() { @@ -104,12 +101,16 @@ void ESPHomeOTAComponent::dump_config() { } #endif #ifdef USE_OTA_PARTITIONS + // Avoid running esp_image_verify here: it reads and checksums the entire app image, which is too + // expensive for a config dump. The address comes from a cached lookup; the precise used size is + // computed lazily by update_partition_table() the first time a partition-table OTA is requested. + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" " Partition address: 0x%X\n" - " Used size: %zu bytes", - this->running_app_offset_, this->running_app_size_); + " Partition size: 0x%X bytes", + running_app_part->address, running_app_part->size); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 2da6e7dcd6..9bed9240aa 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -92,9 +92,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_PARTITIONS - // 4-byte members first, 1-byte member last to minimize padding. - uint32_t running_app_offset_{0}; - size_t running_app_size_{0}; bool extended_proto_{false}; #endif diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 41dbe5fcda..5888a8e12d 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 + #ifdef USE_OTA_STATE_LISTENER #include #endif @@ -53,7 +55,7 @@ enum OTAState { OTA_ERROR, }; -enum OTAType { +enum OTAType : uint8_t { OTA_TYPE_UPDATE_APP = 0x00, OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01, }; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index d44e2d0b27..2f75b35a8a 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -321,7 +321,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - ESP_LOGW(TAG, "Checks passed, starting partition table update. Don't remove power until it is completed!"); + // Past this point any failure (power loss, watchdog reset, write error after the table has been + // partially erased) can leave the device unable to boot. Logged at ERROR severity so the message + // is visible in default log filters. + ESP_LOGE(TAG, "Starting partition table update.\n" + " DO NOT REMOVE POWER until the device reboots successfully.\n" + " Loss of power during this operation may permanently brick the device."); // Deinitialize NVS to prevent unwanted flash writes nvs_flash_deinit(); diff --git a/esphome/espota2.py b/esphome/espota2.py index e8d27d69fa..9cab354514 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -73,6 +73,71 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } +# Error response code -> human-readable message (without the "Error: " prefix; check_error() +# prepends it uniformly). Looked up by check_error() to translate a single byte from the device +# into an OTAError. Add new error codes here rather than extending the if-chain in check_error(). +_ERROR_MESSAGES: dict[int, str] = { + RESPONSE_ERROR_MAGIC: "Invalid magic byte", + RESPONSE_ERROR_UPDATE_PREPARE: ( + "Couldn't prepare flash memory for update. Is the binary too big? " + "Please try restarting the ESP." + ), + RESPONSE_ERROR_AUTH_INVALID: "Authentication invalid. Is the password correct?", + RESPONSE_ERROR_WRITING_FLASH: ( + "Writing OTA data to flash memory failed. See USB logs for more information." + ), + RESPONSE_ERROR_UPDATE_END: ( + "Finishing update failed. See the MQTT/USB logs for more information." + ), + RESPONSE_ERROR_INVALID_BOOTSTRAPPING: ( + "Please press the reset button on the ESP. A manual reset is " + "required on the first OTA-Update after flashing via USB." + ), + RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: ( + "ESP has been flashed with wrong flash size. Please choose the " + "correct 'board' option (esp01_1m always works) and then flash over USB." + ), + RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: ( + "ESP does not have the requested flash size (wrong board). Please " + "choose the correct 'board' option (esp01_1m always works) and try " + "uploading again." + ), + RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: ( + "ESP does not have enough space to store OTA file. Please try " + "flashing a minimal firmware (remove everything except ota)" + ), + RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: ( + "The OTA partition on the ESP is too small. ESPHome needs to resize " + "this partition, please flash over USB." + ), + RESPONSE_ERROR_NO_UPDATE_PARTITION: ( + "The OTA partition on the ESP couldn't be found. ESPHome needs to " + "create this partition, please flash over USB." + ), + RESPONSE_ERROR_MD5_MISMATCH: ( + "Application MD5 code mismatch. Please try again " + "or flash over USB with a good quality cable." + ), + RESPONSE_ERROR_SIGNATURE_INVALID: ( + "Firmware signature verification failed. The firmware was not signed " + "with the correct key. Ensure the signing key matches the one used to build " + "the firmware currently running on the device." + ), + RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: ( + "The requested OTA type is not supported by the device." + ), + RESPONSE_ERROR_PARTITION_TABLE_VERIFY: ( + "The partition table update could not be verified. No changes were " + "made to the flash content. Check the logs for more information and retry." + ), + RESPONSE_ERROR_PARTITION_TABLE_UPDATE: ( + "An error occurred while updating the partition table. The device may " + "not be able to reboot to a working application. Check the logs and retry " + "the update without rebooting the device." + ), + RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", +} + class OTAError(EsphomeError): pass @@ -148,82 +213,9 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "a network issue, or the connection was interrupted." ) dat = data[0] - if dat == RESPONSE_ERROR_MAGIC: - raise OTAError("Error: Invalid magic byte") - if dat == RESPONSE_ERROR_UPDATE_PREPARE: - raise OTAError( - "Error: Couldn't prepare flash memory for update. Is the binary too big? " - "Please try restarting the ESP." - ) - if dat == RESPONSE_ERROR_AUTH_INVALID: - raise OTAError("Error: Authentication invalid. Is the password correct?") - if dat == RESPONSE_ERROR_WRITING_FLASH: - raise OTAError( - "Error: Writing OTA data to flash memory failed. See USB logs for more " - "information." - ) - if dat == RESPONSE_ERROR_UPDATE_END: - raise OTAError( - "Error: Finishing update failed. See the MQTT/USB logs for more " - "information." - ) - if dat == RESPONSE_ERROR_INVALID_BOOTSTRAPPING: - raise OTAError( - "Error: Please press the reset button on the ESP. A manual reset is " - "required on the first OTA-Update after flashing via USB." - ) - if dat == RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: - raise OTAError( - "Error: ESP has been flashed with wrong flash size. Please choose the " - "correct 'board' option (esp01_1m always works) and then flash over USB." - ) - if dat == RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: - raise OTAError( - "Error: ESP does not have the requested flash size (wrong board). Please " - "choose the correct 'board' option (esp01_1m always works) and try " - "uploading again." - ) - if dat == RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: - raise OTAError( - "Error: ESP does not have enough space to store OTA file. Please try " - "flashing a minimal firmware (remove everything except ota)" - ) - if dat == RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: - raise OTAError( - "Error: The OTA partition on the ESP is too small. ESPHome needs to resize " - "this partition, please flash over USB." - ) - if dat == RESPONSE_ERROR_NO_UPDATE_PARTITION: - raise OTAError( - "Error: The OTA partition on the ESP couldn't be found. ESPHome needs to create " - "this partition, please flash over USB." - ) - if dat == RESPONSE_ERROR_MD5_MISMATCH: - raise OTAError( - "Error: Application MD5 code mismatch. Please try again " - "or flash over USB with a good quality cable." - ) - if dat == RESPONSE_ERROR_SIGNATURE_INVALID: - raise OTAError( - "Error: Firmware signature verification failed. The firmware was not signed " - "with the correct key. Ensure the signing key matches the one used to build " - "the firmware currently running on the device." - ) - if dat == RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: - raise OTAError("Error: The requested OTA type is not supported by the device.") - if dat == RESPONSE_ERROR_PARTITION_TABLE_VERIFY: - raise OTAError( - "Error: The partition table update could not be verified. No changes were " - "made to the flash content. Check the logs for more information and retry." - ) - if dat == RESPONSE_ERROR_PARTITION_TABLE_UPDATE: - raise OTAError( - "Error: An error occurred while updating the partition table. The device may not " - "be able to reboot to a working application. Check the logs and retry the update " - "without rebooting the device." - ) - if dat == RESPONSE_ERROR_UNKNOWN: - raise OTAError("Unknown error from ESP") + error_msg = _ERROR_MESSAGES.get(dat) + if error_msg is not None: + raise OTAError(f"Error: {error_msg}") if not isinstance(expect, (list, tuple)): expect = [expect] if dat not in expect: From 44cabc191d65b7fe18a4dd95714e252a5a3a5597 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 13:06:41 -0500 Subject: [PATCH 0896/2214] [core] Catch body-read errors in download_content (#16023) --- esphome/external_files.py | 6 ++- tests/unit_tests/test_external_files.py | 63 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/esphome/external_files.py b/esphome/external_files.py index fbc261f8e0..dfabc54f47 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -175,6 +175,11 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by headers={"User-agent": f"ESPHome/{__version__} (https://esphome.io)"}, ) req.raise_for_status() + # `.content` reads the body lazily; chunked-decode, gzip-decode, + # and mid-stream connection errors all surface here as + # RequestException subclasses, so this needs the same fall-back + # treatment as the request itself. + data = req.content except requests.exceptions.RequestException as e: if path.exists(): _LOGGER.warning( @@ -185,7 +190,6 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by return path.read_bytes() raise cv.Invalid(f"Could not download from {url}: {e}") from e - data = req.content write_file(path, data) _write_etag(path, req.headers.get(ETAG)) return data diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index c894f90666..64ef149581 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -469,6 +469,69 @@ def test_download_content_with_network_error_no_cache_fails( external_files.download_content(url, test_file) +class _BodyReadErrorResponse: + """Stand-in for `requests.Response` whose `.content` raises on access. + + A small dedicated stub avoids mutating `MagicMock`'s class with a + `property` (which would leak across every other MagicMock-based test + in this file). + """ + + def __init__(self, exc: Exception) -> None: + self._exc = exc + self.headers: dict[str, str] = {} + + def raise_for_status(self) -> None: + return None + + @property + def content(self) -> bytes: + raise self._exc + + +def test_download_content_with_body_read_error_uses_cache( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Body-read errors (chunked-decode/gzip-decode/mid-stream connection + drop) raise RequestException subclasses on `.content` access, not from + `requests.get` itself. They must follow the same fall-back-to-cache + path as a connect-time failure. + """ + test_file = setup_core / "cached.txt" + cached_content = b"cached content" + test_file.write_bytes(cached_content) + + mock_has_remote_file_changed.return_value = True + mock_requests_get.return_value = _BodyReadErrorResponse( + requests.exceptions.ChunkedEncodingError("body truncated") + ) + + result = external_files.download_content("https://example.com/file.txt", test_file) + + assert result == cached_content + + +def test_download_content_with_body_read_error_no_cache_fails( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A body-read failure with no cache available must surface as a + cv.Invalid, same as a connect-time failure with no cache. + """ + test_file = setup_core / "nonexistent.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.return_value = _BodyReadErrorResponse( + requests.exceptions.ChunkedEncodingError("body truncated") + ) + + with pytest.raises(Invalid, match="Could not download from.*body truncated"): + external_files.download_content("https://example.com/file.txt", test_file) + + def test_download_content_skip_external_update_uses_cache( mock_has_remote_file_changed: MagicMock, mock_requests_get: MagicMock, From ca3f7251d42b857ee55abcf456a20af89e7b5940 Mon Sep 17 00:00:00 2001 From: GelidusResearch <120155735+GelidusResearch@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:07:28 -0500 Subject: [PATCH 0897/2214] [ens160] Fix sensor initialization timing (#16024) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/ens160_base/ens160_base.cpp | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/esphome/components/ens160_base/ens160_base.cpp b/esphome/components/ens160_base/ens160_base.cpp index e1cee5005c..42baa68b35 100644 --- a/esphome/components/ens160_base/ens160_base.cpp +++ b/esphome/components/ens160_base/ens160_base.cpp @@ -5,6 +5,15 @@ // Implementation based on: // https://github.com/sciosense/ENS160_driver +// For best performance, the sensor shall be operated in normal indoor air in the range -5 to 60°C +// (typical: 25°C); relative humidity: 20 to 80%RH (typical: 50%RH), non-condensing with no aggressive +// or poisonous gases present. Prolonged exposure to environments outside these conditions can affect +// performance and lifetime of the sensor. +// The sensor is designed for indoor use and is not waterproof or dustproof. It should be protected from +// water, condensation, dust, and aggressive gases. Note that the status will only be stored in non-volatile +// memory after an initial 24 h of continuous operation. If unpowered before the conclusion of that period, +// the ENS160 will resume "Initial Start-up" mode after re-powering. + #include "ens160_base.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -14,7 +23,9 @@ namespace ens160_base { static const char *const TAG = "ens160"; -static const uint8_t ENS160_BOOTING = 10; +// Datasheet specifies 10ms, but some users report that 10ms is not sufficient for the +// sensor to boot and be ready for commands. 11ms seems to be a safe value. +static const uint8_t ENS160_BOOTING = 11; static const uint16_t ENS160_PART_ID = 0x0160; @@ -91,6 +102,8 @@ void ENS160Component::setup() { this->mark_failed(); return; } + delay(ENS160_BOOTING); + // clear command if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_NOP)) { this->error_code_ = WRITE_FAILED; @@ -102,6 +115,7 @@ void ENS160Component::setup() { this->mark_failed(); return; } + delay(ENS160_BOOTING); // read firmware version if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_GET_APPVER)) { @@ -109,6 +123,8 @@ void ENS160Component::setup() { this->mark_failed(); return; } + delay(ENS160_BOOTING); + uint8_t version_data[3]; if (!this->read_bytes(ENS160_REG_GPR_READ_4, version_data, 3)) { this->error_code_ = READ_FAILED; @@ -223,7 +239,6 @@ void ENS160Component::update() { if (this->aqi_ != nullptr) { // remove reserved bits, just in case they are used in future data_aqi = ENS160_DATA_AQI & data_aqi; - this->aqi_->publish_state(data_aqi); } From 985dba933241ed8224f004ce19f2878cce4dbb54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 13:17:59 -0500 Subject: [PATCH 0898/2214] [core] Defer heavy module-scope imports in __main__, loader, and config (#15955) --- esphome/__main__.py | 29 +++++++++++++++++++++++++---- esphome/config.py | 9 ++++++++- esphome/loader.py | 27 ++++++++++++++++++++------- script/import_time_budget.json | 2 +- tests/unit_tests/test_main.py | 8 ++++---- 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 8c80dab90a..781bcd6288 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -21,7 +21,7 @@ import argcomplete # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. -from esphome import const, writer, yaml_util +from esphome import const import esphome.codegen as cg from esphome.config import iter_component_configs, read_config, strip_default_ids from esphome.const import ( @@ -72,7 +72,12 @@ from esphome.util import ( run_external_process, safe_print, ) -from esphome.zeroconf import discover_mdns_devices + +# 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 +# any command runs. Import inside the function that needs it instead. +# `script/check_import_time.py` enforces a budget in CI. _LOGGER = logging.getLogger(__name__) @@ -241,6 +246,8 @@ def _discover_mac_suffix_devices() -> list[str] | None: """ if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()): return None + from esphome.zeroconf import discover_mdns_devices + _LOGGER.info("Discovering devices...") if not (discovered := discover_mdns_devices(CORE.name)): _LOGGER.warning( @@ -660,7 +667,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: return 0 -def wrap_to_code(name, comp): +def _wrap_to_code(name, comp, yaml_util): coro = coroutine(comp.to_code) @functools.wraps(comp.to_code) @@ -680,6 +687,8 @@ def wrap_to_code(name, comp): def write_cpp(config: ConfigType, native_idf: bool = False) -> int: + from esphome import writer + if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() @@ -691,17 +700,21 @@ def write_cpp(config: ConfigType, native_idf: bool = False) -> int: def generate_cpp_contents(config: ConfigType) -> None: + from esphome import yaml_util + _LOGGER.info("Generating C++ source...") for name, component, conf in iter_component_configs(CORE.config): if component.to_code is not None: - coro = wrap_to_code(name, component) + coro = _wrap_to_code(name, component, yaml_util) CORE.add_job(coro, conf) CORE.flush_tasks() def write_cpp_file(native_idf: bool = False) -> int: + from esphome import writer + code_s = indent(CORE.cpp_main_section) writer.write_cpp(code_s) @@ -1180,6 +1193,8 @@ def command_wizard(args: ArgsProtocol) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: + from esphome import yaml_util + if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) @@ -1321,6 +1336,8 @@ def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: def command_clean_all(args: ArgsProtocol) -> int | None: + from esphome import writer + try: writer.clean_all(args.configuration) except OSError as err: @@ -1336,6 +1353,8 @@ def command_version(args: ArgsProtocol) -> int | None: def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: + from esphome import writer + try: writer.clean_build() except OSError as err: @@ -1538,6 +1557,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: + from esphome import yaml_util + new_name = args.name for c in new_name: if c not in ALLOWED_NAME_CHARS: diff --git a/esphome/config.py b/esphome/config.py index 6eb67af58b..79d0d2b02b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -25,7 +25,10 @@ from esphome.const import ( CONF_SUBSTITUTIONS, ) from esphome.core import CORE, DocumentRange, EsphomeError -import esphome.core.config as core_config + +# `esphome.core.config` is imported lazily at its two use sites below. +# It pulls in `esphome.automation` and `esphome.config_validation`, which +# dominate `esphome.__main__` startup cost when loaded eagerly here. import esphome.final_validate as fv from esphome.helpers import indent from esphome.loader import ComponentManifest, get_component, get_platform @@ -968,6 +971,8 @@ class CoreFinalValidateStep(ConfigValidationStep): if result.errors: return + import esphome.core.config as core_config + token = fv.full_config.set(result) with result.catch_error([CONF_ESPHOME]): if CONF_ESPHOME in result: @@ -1073,6 +1078,8 @@ def validate_config( return result # 2. Load partial core config + import esphome.core.config as core_config + result[CONF_ESPHOME] = config[CONF_ESPHOME] result.add_output_path([CONF_ESPHOME], CONF_ESPHOME) try: diff --git a/esphome/loader.py b/esphome/loader.py index 2405fa6f88..d50554f8c9 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -9,14 +9,23 @@ import logging from pathlib import Path import sys from types import ModuleType -from typing import Any +from typing import TYPE_CHECKING, Any from esphome.const import SOURCE_FILE_EXTENSIONS from esphome.core import CORE -import esphome.core.config -from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType +if TYPE_CHECKING: + from esphome.cpp_generator import MockObjClass + +# `esphome.core.config` is imported lazily in `_lookup_module` when the +# "esphome" pseudo-component is first resolved. It pulls in +# `esphome.automation` and `esphome.config_validation`, which together +# dominate `esphome.__main__` startup cost when loaded eagerly. +# `esphome.cpp_generator` is similarly avoided at module scope; it pulls +# in `esphome.yaml_util` and is only needed for the `MockObjClass` type +# annotation, which is resolved lazily via `TYPE_CHECKING`. + _LOGGER = logging.getLogger(__name__) @@ -94,7 +103,7 @@ class ComponentManifest: return getattr(self.module, "CODEOWNERS", []) @property - def instance_type(self) -> MockObjClass | None: + def instance_type(self) -> "MockObjClass | None": return getattr(self.module, "INSTANCE_TYPE", None) @property @@ -213,6 +222,13 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: if domain in _COMPONENT_CACHE: return _COMPONENT_CACHE[domain] + if domain == "esphome": + import esphome.core.config + + manif = ComponentManifest(esphome.core.config, recursive_sources=True) + _COMPONENT_CACHE[domain] = manif + return manif + try: module = importlib.import_module(f"esphome.components.{domain}") except ImportError as e: @@ -248,9 +264,6 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None: _COMPONENT_CACHE: dict[str, ComponentManifest] = {} CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() -_COMPONENT_CACHE["esphome"] = ComponentManifest( - esphome.core.config, recursive_sources=True -) def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None: diff --git a/script/import_time_budget.json b/script/import_time_budget.json index 1e656dc977..af3aa83511 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", "margin_pct": 15, - "cumulative_us": 123000 + "cumulative_us": 91000 } diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8ec9e70cf8..fb8f206a1d 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2605,7 +2605,7 @@ def test_choose_upload_log_host_discovers_mac_suffix_devices(tmp_path: Path) -> } with ( patch( - "esphome.__main__.discover_mdns_devices", return_value=discovered + "esphome.zeroconf.discover_mdns_devices", return_value=discovered ) as mock_discover, patch( "esphome.__main__.choose_prompt", return_value="mydevice-abc123.local" @@ -2653,7 +2653,7 @@ def test_choose_upload_log_host_mac_suffix_no_devices_found( ) with ( - patch("esphome.__main__.discover_mdns_devices", return_value={}), + patch("esphome.zeroconf.discover_mdns_devices", return_value={}), caplog.at_level(logging.WARNING, logger="esphome.__main__"), pytest.raises(EsphomeError), ): @@ -2686,7 +2686,7 @@ def test_choose_upload_log_host_default_ota_discovers_mac_suffix( "mydevice-def456.local": ["10.0.0.2"], } with patch( - "esphome.__main__.discover_mdns_devices", return_value=discovered + "esphome.zeroconf.discover_mdns_devices", return_value=discovered ) as mock_discover: result = choose_upload_log_host( default="OTA", @@ -2715,7 +2715,7 @@ def test_choose_upload_log_host_default_ota_no_suffix_discovery( name="mydevice", ) - with patch("esphome.__main__.discover_mdns_devices") as mock_discover: + with patch("esphome.zeroconf.discover_mdns_devices") as mock_discover: result = choose_upload_log_host( default="OTA", check_default=None, From 0ad8a071a706d6b83d70b6ee15074c5b6c9ca736 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:18:21 +1000 Subject: [PATCH 0899/2214] [espnow] Cleanup method visibility and naming (#16109) --- esphome/components/espnow/__init__.py | 16 +++++------ esphome/components/espnow/automation.h | 27 ++++++++++--------- .../components/espnow/espnow_component.cpp | 8 +++--- esphome/components/espnow/espnow_component.h | 24 ++++++++--------- .../packet_transport/espnow_transport.cpp | 10 +++---- .../packet_transport/espnow_transport.h | 6 ++--- 6 files changed, 45 insertions(+), 46 deletions(-) diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index a9624734d0..7861c0affa 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -26,9 +26,9 @@ espnow_ns = cg.esphome_ns.namespace("espnow") ESPNowComponent = espnow_ns.class_("ESPNowComponent", cg.Component) # Handler interfaces that other components can use to register callbacks -ESPNowReceivedPacketHandler = espnow_ns.class_("ESPNowReceivedPacketHandler") +ESPNowReceivePacketHandler = espnow_ns.class_("ESPNowReceivePacketHandler") ESPNowUnknownPeerHandler = espnow_ns.class_("ESPNowUnknownPeerHandler") -ESPNowBroadcastedHandler = espnow_ns.class_("ESPNowBroadcastedHandler") +ESPNowBroadcastHandler = espnow_ns.class_("ESPNowBroadcastHandler") ESPNowRecvInfo = espnow_ns.class_("ESPNowRecvInfo") ESPNowRecvInfoConstRef = ESPNowRecvInfo.operator("const").operator("ref") @@ -48,10 +48,10 @@ OnUnknownPeerTrigger = espnow_ns.class_( "OnUnknownPeerTrigger", ESPNowHandlerTrigger, ESPNowUnknownPeerHandler ) OnReceiveTrigger = espnow_ns.class_( - "OnReceiveTrigger", ESPNowHandlerTrigger, ESPNowReceivedPacketHandler + "OnReceiveTrigger", ESPNowHandlerTrigger, ESPNowReceivePacketHandler ) -OnBroadcastedTrigger = espnow_ns.class_( - "OnBroadcastedTrigger", ESPNowHandlerTrigger, ESPNowBroadcastedHandler +OnBroadcastTrigger = espnow_ns.class_( + "OnBroadcastTrigger", ESPNowHandlerTrigger, ESPNowBroadcastHandler ) @@ -94,7 +94,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_ON_BROADCAST): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastedTrigger), + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastTrigger), cv.Optional(CONF_ADDRESS): cv.mac_address, } ), @@ -140,11 +140,11 @@ async def to_code(config): for on_receive in config.get(CONF_ON_RECEIVE, []): trigger = await _trigger_to_code(on_receive) - cg.add(var.register_received_handler(trigger)) + cg.add(var.register_receive_handler(trigger)) for on_receive in config.get(CONF_ON_BROADCAST, []): trigger = await _trigger_to_code(on_receive) - cg.add(var.register_broadcasted_handler(trigger)) + cg.add(var.register_broadcast_handler(trigger)) # ========================================== A C T I O N S ================================================ diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 0fbb14e388..9c3c55e4ef 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -67,6 +67,7 @@ template class SendAction : public Action, public Parente } } + protected: void play(const Ts &...x) override { /* ignore - see play_complex */ } @@ -75,7 +76,6 @@ template class SendAction : public Action, public Parente this->error_.stop(); } - protected: ActionList sent_; ActionList error_; @@ -89,7 +89,7 @@ template class SendAction : public Action, public Parente template class AddPeerAction : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); - public: + protected: void play(const Ts &...x) override { peer_address_t address = this->address_.value(x...); this->parent_->add_peer(address.data()); @@ -99,7 +99,7 @@ template class AddPeerAction : public Action, public Pare template class DeletePeerAction : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); - public: + protected: void play(const Ts &...x) override { peer_address_t address = this->address_.value(x...); this->parent_->del_peer(address.data()); @@ -107,8 +107,9 @@ template class DeletePeerAction : public Action, public P }; template class SetChannelAction : public Action, public Parented { - public: TEMPLATABLE_VALUE(uint8_t, channel) + + protected: void play(const Ts &...x) override { if (this->parent_->is_wifi_enabled()) { return; @@ -125,9 +126,9 @@ class OnReceiveTrigger : public Triggeraddress_, address.data(), ESP_NOW_ETH_ALEN); } - explicit OnReceiveTrigger() : has_address_(false) {} + explicit OnReceiveTrigger() {} - bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; @@ -138,7 +139,7 @@ class OnReceiveTrigger : public Trigger, public ESPNowUnknownPeerHandler { @@ -148,15 +149,15 @@ class OnUnknownPeerTrigger : public Trigger, - public ESPNowBroadcastedHandler { +class OnBroadcastTrigger : public Trigger, + public ESPNowBroadcastHandler { public: - explicit OnBroadcastedTrigger(std::array address) : has_address_(true) { + explicit OnBroadcastTrigger(std::array address) : has_address_(true) { memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); } - explicit OnBroadcastedTrigger() : has_address_(false) {} + explicit OnBroadcastTrigger() {} - bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; @@ -167,7 +168,7 @@ class OnBroadcastedTrigger : public Triggerpacket_.receive.data, packet->packet_.receive.size)); #endif if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { - for (auto *handler : this->broadcasted_handlers_) { - if (handler->on_broadcasted(info, packet->packet_.receive.data, packet->packet_.receive.size)) + for (auto *handler : this->broadcast_handlers_) { + if (handler->on_broadcast(info, packet->packet_.receive.data, packet->packet_.receive.size)) break; // If a handler returns true, stop processing further handlers } } else { - for (auto *handler : this->received_handlers_) { - if (handler->on_received(info, packet->packet_.receive.data, packet->packet_.receive.size)) + for (auto *handler : this->receive_handlers_) { + if (handler->on_receive(info, packet->packet_.receive.data, packet->packet_.receive.size)) break; // If a handler returns true, stop processing further handlers } } diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index ee4adc1b4d..ff9581ec2f 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -31,8 +31,8 @@ using peer_address_t = std::array; enum class ESPNowTriggers : uint8_t { TRIGGER_NONE = 0, ON_NEW_PEER = 1, - ON_RECEIVED = 2, - ON_BROADCASTED = 3, + ON_RECEIVE = 2, + ON_BROADCAST = 3, ON_SUCCEED = 10, ON_FAILED = 11, }; @@ -74,18 +74,18 @@ class ESPNowReceivedPacketHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; }; -/// Handler interface for receiving broadcasted ESPNow packets +/// Handler interface for receiving ESPNow broadcast packets /// Components should inherit from this class to handle incoming ESPNow data -class ESPNowBroadcastedHandler { +class ESPNowBroadcastHandler { public: - /// Called when a broadcasted ESPNow packet is received + /// Called when an ESPNow broadcast packet is received /// @param info Information about the received packet (sender MAC, etc.) /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; }; class ESPNowComponent : public Component { @@ -136,13 +136,11 @@ class ESPNowComponent : public Component { esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &callback = nullptr); - void register_received_handler(ESPNowReceivedPacketHandler *handler) { this->received_handlers_.push_back(handler); } + void register_receive_handler(ESPNowReceivedPacketHandler *handler) { this->receive_handlers_.push_back(handler); } void register_unknown_peer_handler(ESPNowUnknownPeerHandler *handler) { this->unknown_peer_handlers_.push_back(handler); } - void register_broadcasted_handler(ESPNowBroadcastedHandler *handler) { - this->broadcasted_handlers_.push_back(handler); - } + void register_broadcast_handler(ESPNowBroadcastHandler *handler) { this->broadcast_handlers_.push_back(handler); } protected: friend void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size); @@ -156,8 +154,8 @@ class ESPNowComponent : public Component { void send_(); std::vector unknown_peer_handlers_; - std::vector received_handlers_; - std::vector broadcasted_handlers_; + std::vector receive_handlers_; + std::vector broadcast_handlers_; std::vector peers_{}; diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index 6e4f606466..384e3fe2a9 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -26,10 +26,10 @@ void ESPNowTransport::setup() { this->peer_address_[5]); // Register received handler - this->parent_->register_received_handler(this); + this->parent_->register_receive_handler(this); - // Register broadcasted handler - this->parent_->register_broadcasted_handler(this); + // Register broadcast handler + this->parent_->register_broadcast_handler(this); } void ESPNowTransport::send_packet(const std::vector &buf) const { @@ -56,7 +56,7 @@ void ESPNowTransport::send_packet(const std::vector &buf) const { }); } -bool ESPNowTransport::on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { +bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); @@ -71,7 +71,7 @@ bool ESPNowTransport::on_received(const ESPNowRecvInfo &info, const uint8_t *dat return false; // Allow other handlers to run } -bool ESPNowTransport::on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { +bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index d85119db7d..98c33f01fd 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -15,7 +15,7 @@ namespace espnow { class ESPNowTransport : public packet_transport::PacketTransport, public Parented, public ESPNowReceivedPacketHandler, - public ESPNowBroadcastedHandler { + public ESPNowBroadcastHandler { public: void setup() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } @@ -25,8 +25,8 @@ class ESPNowTransport : public packet_transport::PacketTransport, } // ESPNow handler interface - bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; - bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; + bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; + bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; protected: void send_packet(const std::vector &buf) const override; From bf1c0228a65a09cc3b1a299d0d778b650ba7603a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 13:22:53 -0500 Subject: [PATCH 0900/2214] copilot comments --- esphome/__main__.py | 9 +++++++ .../components/esphome/ota/ota_esphome.cpp | 8 +++++- .../components/ota/ota_backend_esp_idf.cpp | 25 ++++++++++++++----- esphome/core/__init__.py | 8 +++++- esphome/espota2.py | 8 +++++- 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c931eb179d..deb4dbb63f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1124,6 +1124,15 @@ def upload_program( binary = CORE.firmware_bin ota_type = espota2.OTA_TYPE_UPDATE_APP if getattr(args, "partition_table", False): + # Fail fast if the resolved ESPHome OTA config does not enable allow_partition_access. + # The device-side handshake also rejects this with "Device only supports app updates", + # but checking here surfaces the misconfiguration before opening a network connection. + if not ota_conf.get("allow_partition_access"): + raise EsphomeError( + "The option --partition-table requires 'allow_partition_access: true' on the " + "esphome OTA platform in the device's YAML configuration. Add it, recompile, " + "flash a build with the option enabled, and then retry --partition-table." + ) binary = CORE.partition_table_bin ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE if getattr(args, "file", None) is not None: diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ac4906780e..3ae7be7e8f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -104,13 +104,19 @@ void ESPHomeOTAComponent::dump_config() { // Avoid running esp_image_verify here: it reads and checksums the entire app image, which is too // expensive for a config dump. The address comes from a cached lookup; the precise used size is // computed lazily by update_partition_table() the first time a partition-table OTA is requested. + // Guard against esp_ota_get_running_partition() returning nullptr (can happen after the partition + // cache has been unloaded) so dump_config never crashes. + // Single ESP_LOGCONFIG call so the lines stay together as one log message; on the (rare) + // nullptr path we surface zeros rather than dereferencing. const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" " Partition address: 0x%X\n" " Partition size: 0x%X bytes", - running_app_part->address, running_app_part->size); + running_app_part != nullptr ? running_app_part->address : 0u, + running_app_part != nullptr ? running_app_part->size : 0u); + #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2f75b35a8a..fe28cb6288 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -25,8 +25,12 @@ std::unique_ptr make_ota_backend() { return make_uniqueota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - if (image_size > ESP_PARTITION_TABLE_MAX_LEN) { - ESP_LOGE(TAG, "Wrong partition table size"); + // Partition table images produced by gen_esp32part.py are padded with 0xFF and an MD5 entry to + // exactly ESP_PARTITION_TABLE_MAX_LEN bytes. Reject anything else: an undersized image would + // leave trailing bytes from the previous table in place after the partial write, and an + // oversized image cannot fit in the reserved region. This is stricter than verify alone. + if (image_size != ESP_PARTITION_TABLE_MAX_LEN) { + ESP_LOGE(TAG, "Wrong partition table size: expected %u bytes, got %zu", ESP_PARTITION_TABLE_MAX_LEN, image_size); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } memset(this->buf_, 0xFF, sizeof this->buf_); @@ -207,10 +211,17 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Get running app partition and used size + // Get running app partition and used size. A zero size means we couldn't determine the running + // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition + // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute + // overlap or copy bounds, so fail before any flash operation. uint32_t running_app_offset; size_t running_app_size; get_running_app_position(running_app_offset, running_app_size); + if (running_app_size == 0) { + ESP_LOGE(TAG, "Failed to determine running app position"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } // Get partition table partition esp_err_t err = esp_partition_register_external( @@ -328,9 +339,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { " DO NOT REMOVE POWER until the device reboots successfully.\n" " Loss of power during this operation may permanently brick the device."); - // Deinitialize NVS to prevent unwanted flash writes - nvs_flash_deinit(); - // Hold the watchdog open for the entire critical section: optional app copy, partition-table // erase/write, and boot partition selection. None of the steps below should yield long enough // to require a refresh, but bundling them under a single guard avoids spurious resets if the @@ -354,6 +362,11 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } } + // Deinitialize NVS just before the first destructive write to the partition-table region. Doing + // this here (instead of earlier) means that any failure path in the verify or copy phases above + // returns with NVS still functional, so other components on the device aren't broken until reboot. + nvs_flash_deinit(); + // Update the partition table err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); if (err != ESP_OK) { diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index d97944fd2c..94a48dd31b 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -781,7 +781,13 @@ class EsphomeCore: @property def partition_table_bin(self) -> Path: - # native ESP-IDF: self.relative_build_path("build", "partition_table", "partition-table.bin") + # Native ESP-IDF (--native-idf): the partition table image is emitted under + # build/partition_table/partition-table.bin alongside firmware.bin. PlatformIO writes the + # equivalent file as partitions.bin in the env-specific .pioenvs directory. + if self.data.get(KEY_NATIVE_IDF): + return self.relative_build_path( + "build", "partition_table", "partition-table.bin" + ) return self.relative_pioenvs_path(self.name, "partitions.bin") @property diff --git a/esphome/espota2.py b/esphome/espota2.py index 9cab354514..891ed5b29c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -296,7 +296,13 @@ def perform_ota( else: features = 0 - if ota_type != 0 and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS: + if ota_type not in (OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE): + raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") + + if ( + ota_type == OTA_TYPE_UPDATE_PARTITION_TABLE + and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ): raise OTAError("Device only supports app updates") if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: From c41f38e16d2a3f179f02cfb56a99fbbd7ca76217 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 13:24:37 -0500 Subject: [PATCH 0901/2214] [scheduler] Add self-keyed timer API for callers without a Component (#16127) --- esphome/core/scheduler.cpp | 33 +++++- esphome/core/scheduler.h | 80 +++++++++---- .../fixtures/scheduler_self_keyed.yaml | 112 ++++++++++++++++++ .../integration/test_scheduler_self_keyed.py | 96 +++++++++++++++ 4 files changed, 295 insertions(+), 26 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_self_keyed.yaml create mode 100644 tests/integration/test_scheduler_self_keyed.py diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 11884ce4ba..57deeab0da 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -35,7 +35,9 @@ static constexpr uint32_t MAX_INTERVAL_DELAY = 5000; // Uses a stack buffer to avoid heap allocation // Uses ESPHOME_snprintf_P/ESPHOME_PSTR for ESP8266 to keep format strings in flash struct SchedulerNameLog { - char buffer[20]; // Enough for "id:4294967295" or "hash:0xFFFFFFFF" or "(null)" + // Sized for the widest formatted output: "self:0x" + 16 hex digits (64-bit pointer) + nul. + // Also covers "id:4294967295", "hash:0xFFFFFFFF", "iid:4294967295", "(null)". + char buffer[28]; // Format a scheduler item name for logging // Returns pointer to formatted string (either static_name or internal buffer) @@ -53,9 +55,15 @@ struct SchedulerNameLog { } else if (name_type == NameType::NUMERIC_ID) { ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("id:%" PRIu32), hash_or_id); return buffer; - } else { // NUMERIC_ID_INTERNAL + } else if (name_type == NameType::NUMERIC_ID_INTERNAL) { ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("iid:%" PRIu32), hash_or_id); return buffer; + } else { // SELF_POINTER + // static_name carries the void* key for SELF_POINTER (pointer-width union slot). + // %p is specified as void* (not const void*), so strip const for the varargs call. + ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("self:%p"), + const_cast(static_cast(static_name))); + return buffer; } } }; @@ -293,6 +301,27 @@ bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL); } +// Self-keyed scheduler API. The cancellation key is `self` (typically the caller's `this`), +// passed through the existing static_name pointer slot. Matching is by raw pointer equality +// (see matches_item_locked_'s SELF_POINTER branch). No Component pointer is stored, so +// is_failed() skip and component-based log attribution don't apply. +void HOT Scheduler::set_timeout(const void *self, uint32_t timeout, std::function &&func) { + this->set_timer_common_(nullptr, SchedulerItem::TIMEOUT, NameType::SELF_POINTER, static_cast(self), 0, + timeout, std::move(func)); +} +void HOT Scheduler::set_interval(const void *self, uint32_t interval, std::function &&func) { + this->set_timer_common_(nullptr, SchedulerItem::INTERVAL, NameType::SELF_POINTER, static_cast(self), 0, + interval, std::move(func)); +} +bool HOT Scheduler::cancel_timeout(const void *self) { + return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast(self), 0, + SchedulerItem::TIMEOUT); +} +bool HOT Scheduler::cancel_interval(const void *self) { + return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast(self), 0, + SchedulerItem::INTERVAL); +} + // Suppress deprecation warnings for RetryResult usage in the still-present (but deprecated) retry implementation. // Remove before 2026.8.0 along with all retry code. #pragma GCC diagnostic push diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 46b19855c3..7a6be6bea9 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -146,22 +146,43 @@ class Scheduler { } // Name storage type discriminator for SchedulerItem - // Used to distinguish between static strings, hashed strings, numeric IDs, and internal numeric IDs + // Used to distinguish between static strings, hashed strings, numeric IDs, internal numeric IDs, + // and self-keyed pointers (caller-supplied `void *`, typically `this`). enum class NameType : uint8_t { - STATIC_STRING = 0, // const char* pointer to static/flash storage - HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string - NUMERIC_ID = 2, // uint32_t numeric identifier (component-level) - NUMERIC_ID_INTERNAL = 3 // uint32_t numeric identifier (core/internal, separate namespace) + STATIC_STRING = 0, // const char* pointer to static/flash storage + HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string + NUMERIC_ID = 2, // uint32_t numeric identifier (component-level) + NUMERIC_ID_INTERNAL = 3, // uint32_t numeric identifier (core/internal, separate namespace) + SELF_POINTER = 4 // void* caller-supplied key (typically `this`); pointer equality }; + /** Self-keyed timeout. The cancellation key is `self` (typically the caller's `this`). + * + * Use this when the caller schedules at most one timer of a single purpose at a time and + * does not need a `Component` for `is_failed()` skip or log source attribution. Lets + * small classes drop `Component` inheritance entirely when their only Component dependency + * was the per-instance scheduler key. + * + * NOT applied for self-keyed items: + * - `is_failed()` skip — callbacks always fire (no Component to consult). + * - Log source attribution — logs use a generic "self:0x…" label. + * + * If you need either of those, use the existing `(Component *, id)` overloads. + */ + void set_timeout(const void *self, uint32_t timeout, std::function &&func); + /// Self-keyed interval. See set_timeout(const void *, ...) for semantics. + void set_interval(const void *self, uint32_t interval, std::function &&func); + bool cancel_timeout(const void *self); + bool cancel_interval(const void *self); + protected: struct SchedulerItem { // Ordered by size to minimize padding Component *component; // Optimized name storage using tagged union - zero heap allocation union { - const char *static_name; // For STATIC_STRING (string literals, no allocation) - uint32_t hash_or_id; // For HASHED_STRING or NUMERIC_ID + const char *static_name; // For STATIC_STRING (string literals) and SELF_POINTER (caller's `this`) + uint32_t hash_or_id; // For HASHED_STRING, NUMERIC_ID, and NUMERIC_ID_INTERNAL } name_; uint32_t interval; // Split time to handle millis() rollover. The scheduler combines the 32-bit millis() @@ -182,19 +203,19 @@ class Scheduler { // std::atomic inlines correctly on all platforms. std::atomic remove{0}; - // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) - enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; - NameType name_type_ : 2; // Discriminator for name_ union (0–3, see NameType enum) - bool is_retry : 1; // True if this is a retry timeout - // 4 bits padding -#else - // Single-threaded or multi-threaded without atomics: can pack all fields together // Bit-packed fields (5 bits used, 3 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; - bool remove : 1; - NameType name_type_ : 2; // Discriminator for name_ union (0–3, see NameType enum) + NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum) bool is_retry : 1; // True if this is a retry timeout // 3 bits padding +#else + // Single-threaded or multi-threaded without atomics: can pack all fields together + // Bit-packed fields (6 bits used, 2 bits padding in 1 byte) + enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; + bool remove : 1; + NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum) + bool is_retry : 1; // True if this is a retry timeout + // 2 bits padding #endif // Constructor @@ -228,19 +249,26 @@ class Scheduler { SchedulerItem(SchedulerItem &&) = delete; SchedulerItem &operator=(SchedulerItem &&) = delete; - // Helper to get the static name (only valid for STATIC_STRING type) - const char *get_name() const { return (name_type_ == NameType::STATIC_STRING) ? name_.static_name : nullptr; } + // Helper to get the pointer-slot value (valid for STATIC_STRING and SELF_POINTER types). + // Both share the same union member, so callers (e.g. log formatters) can read either uniformly. + const char *get_name() const { + return (name_type_ == NameType::STATIC_STRING || name_type_ == NameType::SELF_POINTER) ? name_.static_name + : nullptr; + } - // Helper to get the hash or numeric ID (only valid for HASHED_STRING or NUMERIC_ID types) - uint32_t get_name_hash_or_id() const { return (name_type_ != NameType::STATIC_STRING) ? name_.hash_or_id : 0; } + // Helper to get the hash or numeric ID (only valid for HASHED_STRING / NUMERIC_ID / NUMERIC_ID_INTERNAL types) + uint32_t get_name_hash_or_id() const { + return (name_type_ != NameType::STATIC_STRING && name_type_ != NameType::SELF_POINTER) ? name_.hash_or_id : 0; + } // Helper to get the name type NameType get_name_type() const { return name_type_; } - // Set name storage: for STATIC_STRING stores the pointer, for all other types stores hash_or_id. - // Both union members occupy the same offset, so only one store is needed. + // Set name storage. STATIC_STRING/SELF_POINTER use the static_name pointer slot + // (both are pointer-width); other types use hash_or_id. Both union members occupy + // the same offset, so only one store is needed. void set_name(NameType type, const char *static_name, uint32_t hash_or_id) { - if (type == NameType::STATIC_STRING) { + if (type == NameType::STATIC_STRING || type == NameType::SELF_POINTER) { name_.static_name = static_name; } else { name_.hash_or_id = hash_or_id; @@ -367,10 +395,14 @@ class Scheduler { // Name type must match if (item->get_name_type() != name_type) return false; - // For static strings, compare the string content; for hash/ID, compare the value + // STATIC_STRING: compare string content. SELF_POINTER: raw pointer equality (no strcmp). + // Other types: compare hash/ID value. if (name_type == NameType::STATIC_STRING) { return this->names_match_static_(item->get_name(), static_name); } + if (name_type == NameType::SELF_POINTER) { + return item->name_.static_name == static_name; + } return item->get_name_hash_or_id() == hash_or_id; } diff --git a/tests/integration/fixtures/scheduler_self_keyed.yaml b/tests/integration/fixtures/scheduler_self_keyed.yaml new file mode 100644 index 0000000000..9a691136f3 --- /dev/null +++ b/tests/integration/fixtures/scheduler_self_keyed.yaml @@ -0,0 +1,112 @@ +esphome: + debug_scheduler: true # Enable scheduler leak detection + name: scheduler-self-keyed-test + on_boot: + priority: -100 + then: + - logger.log: "Starting scheduler self-keyed tests" + +host: +api: +logger: + level: VERBOSE + +globals: + - id: tests_done + type: bool + initial_value: 'false' + +script: + - id: test_self_keyed + then: + - logger.log: "Testing self-keyed scheduler API" + - lambda: |- + // Two distinct keys backed by addresses of static markers — they + // must not collide even though both are self-keyed and share no + // Component pointer. Static storage gives them stable, unique + // addresses for the lifetime of the program. + static int key_a_marker = 0; + static int key_b_marker = 0; + void *key_a = &key_a_marker; + void *key_b = &key_b_marker; + + // ---- Test 1: Self-keyed timeout fires ---- + App.scheduler.set_timeout(key_a, 50, []() { + ESP_LOGI("test", "Self timeout A fired"); + }); + + // ---- Test 2: Self-keyed cancel cancels only that key ---- + App.scheduler.set_timeout(key_b, 100, []() { + ESP_LOGE("test", "ERROR: Self timeout B should have been cancelled"); + }); + App.scheduler.cancel_timeout(key_b); + + // ---- Test 3: Two independent self keys don't collide ---- + // Using fresh static markers so neither matches key_a / key_b. + static int key_c_marker = 0; + static int key_d_marker = 0; + void *key_c = &key_c_marker; + void *key_d = &key_d_marker; + App.scheduler.set_timeout(key_c, 150, []() { + ESP_LOGI("test", "Self timeout C fired"); + }); + App.scheduler.set_timeout(key_d, 150, []() { + ESP_LOGI("test", "Self timeout D fired"); + }); + + // ---- Test 4: Self-keyed and component-keyed don't collide ---- + // Use a self pointer that happens to look like a Component-attached id. + // The scheduler must treat them as separate namespaces. + static int shared_marker = 0; + void *self_shared = &shared_marker; + App.scheduler.set_timeout(self_shared, 200, []() { + ESP_LOGI("test", "Self timeout shared fired"); + }); + App.scheduler.set_timeout(id(test_sensor), 7777U, 200, []() { + ESP_LOGI("test", "Component timeout 7777 fired"); + }); + + // ---- Test 5: Self-keyed interval fires multiple times then cancels ---- + static int interval_count = 0; + static int key_e_marker = 0; + void *key_e = &key_e_marker; + App.scheduler.set_interval(key_e, 80, [key_e]() { + interval_count++; + if (interval_count == 2) { + ESP_LOGI("test", "Self interval E fired twice"); + App.scheduler.cancel_interval(key_e); + } + }); + + // ---- Test 6: Re-registering same self-key replaces the timer ---- + // The old timer must NOT fire; only the new one does. + static int key_f_marker = 0; + void *key_f = &key_f_marker; + App.scheduler.set_timeout(key_f, 250, []() { + ESP_LOGE("test", "ERROR: Self timeout F first registration should have been replaced"); + }); + App.scheduler.set_timeout(key_f, 300, []() { + ESP_LOGI("test", "Self timeout F replacement fired"); + }); + + // Log completion after all timers should have fired + App.scheduler.set_timeout(id(test_sensor), 9999U, 1500, []() { + ESP_LOGI("test", "All self-keyed tests complete"); + }); + +sensor: + - platform: template + name: Test Sensor + id: test_sensor + lambda: return 1.0; + update_interval: never + +interval: + - interval: 0.1s + then: + - if: + condition: + lambda: 'return id(tests_done) == false;' + then: + - lambda: 'id(tests_done) = true;' + - script.execute: test_self_keyed diff --git a/tests/integration/test_scheduler_self_keyed.py b/tests/integration/test_scheduler_self_keyed.py new file mode 100644 index 0000000000..e0825ea825 --- /dev/null +++ b/tests/integration/test_scheduler_self_keyed.py @@ -0,0 +1,96 @@ +"""Test the self-keyed scheduler API. + +Verifies that `Scheduler::set_timeout(const void *, ...)` / +`set_interval(const void *, ...)` and the matching `cancel_*(const void *)` +overloads behave correctly: callbacks fire, distinct keys don't collide, +self-keyed and component-keyed namespaces are independent, and re-registering +the same key replaces the existing timer. +""" + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_self_keyed( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test self-keyed scheduler API.""" + self_a_fired = asyncio.Event() + self_b_error = asyncio.Event() + self_c_fired = asyncio.Event() + self_d_fired = asyncio.Event() + self_shared_fired = asyncio.Event() + component_7777_fired = asyncio.Event() + self_interval_done = asyncio.Event() + self_f_first_error = asyncio.Event() + self_f_replacement_fired = asyncio.Event() + all_tests_complete = asyncio.Event() + + def on_log_line(line: str) -> None: + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + + if "Self timeout A fired" in clean_line: + self_a_fired.set() + elif "ERROR: Self timeout B" in clean_line: + self_b_error.set() + elif "Self timeout C fired" in clean_line: + self_c_fired.set() + elif "Self timeout D fired" in clean_line: + self_d_fired.set() + elif "Self timeout shared fired" in clean_line: + self_shared_fired.set() + elif "Component timeout 7777 fired" in clean_line: + component_7777_fired.set() + elif "Self interval E fired twice" in clean_line: + self_interval_done.set() + elif "ERROR: Self timeout F first registration" in clean_line: + self_f_first_error.set() + elif "Self timeout F replacement fired" in clean_line: + self_f_replacement_fired.set() + elif "All self-keyed tests complete" in clean_line: + all_tests_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-self-keyed-test" + + try: + await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) + except TimeoutError: + pytest.fail("Not all self-keyed tests completed within 5 seconds") + + # Test 1: self-keyed timeout fires + assert self_a_fired.is_set(), "Self timeout A should have fired" + + # Test 2: cancel_timeout(self) actually cancels + assert not self_b_error.is_set(), "Self timeout B should have been cancelled" + + # Test 3: distinct self keys don't collide + assert self_c_fired.is_set(), "Self timeout C should have fired" + assert self_d_fired.is_set(), "Self timeout D should have fired" + + # Test 4: self-keyed and component-keyed namespaces are independent + assert self_shared_fired.is_set(), "Self timeout shared should have fired" + assert component_7777_fired.is_set(), "Component timeout 7777 should have fired" + + # Test 5: self-keyed interval fires repeatedly and cancels cleanly + assert self_interval_done.is_set(), "Self interval E should have fired twice" + + # Test 6: re-registering same self-key replaces the previous timer + assert not self_f_first_error.is_set(), ( + "Self timeout F first registration should have been replaced" + ) + assert self_f_replacement_fired.is_set(), ( + "Self timeout F replacement should have fired" + ) From 59b4cfd07ca158e4fc6c778f49e38fc7f863e658 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:41:12 +0000 Subject: [PATCH 0902/2214] [watchdog] Use default CHECK_IDLE_TASK and PANIC when configuring the watchdog (#16142) --- esphome/components/watchdog/watchdog.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index 545d83a679..edf113b0b4 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -39,9 +39,18 @@ void WatchdogManager::set_timeout_(uint32_t timeout_ms) { #ifdef USE_ESP32 esp_task_wdt_config_t wdt_config = { .timeout_ms = timeout_ms, - .idle_core_mask = (1U << CONFIG_FREERTOS_NUMBER_OF_CORES) - 1U, - .trigger_panic = true, + .idle_core_mask = 0, + .trigger_panic = false, }; +#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 + wdt_config.idle_core_mask |= (1U << 0U); +#endif +#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 + wdt_config.idle_core_mask |= (1U << 1U); +#endif +#if CONFIG_ESP_TASK_WDT_PANIC + wdt_config.trigger_panic = true; +#endif esp_task_wdt_reconfigure(&wdt_config); #endif // USE_ESP32 From 61a41402df1b371c63146e732321461125e7d3f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 14:16:05 -0500 Subject: [PATCH 0903/2214] [fan] Fold TurnOnAction fields into a single stateless lambda (#16122) --- esphome/components/fan/__init__.py | 47 +++++++++++++++++++++-------- esphome/components/fan/automation.h | 23 ++++++-------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 713f20fb95..f47fc06b3d 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -31,17 +31,19 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, 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 IS_PLATFORM_COMPONENT = True fan_ns = cg.esphome_ns.namespace("fan") Fan = fan_ns.class_("Fan", cg.EntityBase) +FanCall = fan_ns.class_("FanCall") FanDirection = fan_ns.enum("FanDirection", is_class=True) FAN_DIRECTION_ENUM = { @@ -347,17 +349,38 @@ async def fan_turn_off_to_code(config, action_id, template_arg, args): ) async def fan_turn_on_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (oscillating := config.get(CONF_OSCILLATING)) is not None: - template_ = await cg.templatable(oscillating, args, cg.bool_) - cg.add(var.set_oscillating(template_)) - if (speed := config.get(CONF_SPEED)) is not None: - template_ = await cg.templatable(speed, args, cg.int_) - cg.add(var.set_speed(template_)) - if (direction := config.get(CONF_DIRECTION)) is not None: - template_ = await cg.templatable(direction, args, FanDirection) - cg.add(var.set_direction(template_)) - return var + + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. + FIELDS = ( + (CONF_OSCILLATING, "set_oscillating", cg.bool_), + (CONF_SPEED, "set_speed", cg.int_), + (CONF_DIRECTION, "set_direction", FanDirection), + ) + + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + for conf_key, setter, type_ in FIELDS: + if (value := config.get(conf_key)) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=type_) + body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + # Match TurnOnAction::ApplyFn signature: const Ts &... for trigger args. + apply_args = [ + (FanCall.operator("ref"), "call"), + *((t.operator("const").operator("ref"), n) for t, n in args), + ] + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + apply_args, + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @automation.register_action( diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 3ee6f89e55..d8eda41b27 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -7,29 +7,24 @@ namespace esphome { namespace fan { +// All configured fields are baked into a single stateless lambda whose +// constants live in flash. The action only stores one function pointer +// plus one parent pointer, regardless of how many fields the user set. +// Trigger args are forwarded to the apply function so user lambdas +// (e.g. `speed: !lambda "return x;"`) keep working. template class TurnOnAction : public Action { public: - explicit TurnOnAction(Fan *state) : state_(state) {} - - TEMPLATABLE_VALUE(bool, oscillating) - TEMPLATABLE_VALUE(int, speed) - TEMPLATABLE_VALUE(FanDirection, direction) + using ApplyFn = void (*)(FanCall &, const Ts &...); + TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} void play(const Ts &...x) override { auto call = this->state_->turn_on(); - if (this->oscillating_.has_value()) { - call.set_oscillating(this->oscillating_.value(x...)); - } - if (this->speed_.has_value()) { - call.set_speed(this->speed_.value(x...)); - } - if (this->direction_.has_value()) { - call.set_direction(this->direction_.value(x...)); - } + this->apply_(call, x...); call.perform(); } Fan *state_; + ApplyFn apply_; }; template class TurnOffAction : public Action { From 5a146ab6b75958fd855ccbbe965a95fe7e1f15ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 14:20:15 -0500 Subject: [PATCH 0904/2214] [valve] Fold ControlAction fields into a single stateless lambda (#16123) --- esphome/components/valve/__init__.py | 50 ++++++++++++++++++++------- esphome/components/valve/automation.h | 17 ++++----- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index a6808c9da7..7377aea1ed 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -21,14 +21,14 @@ from esphome.const import ( DEVICE_CLASS_GAS, DEVICE_CLASS_WATER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, Lambda, 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 LambdaExpression, MockObjClass IS_PLATFORM_COMPONENT = True @@ -43,6 +43,7 @@ DEVICE_CLASSES = [ valve_ns = cg.esphome_ns.namespace("valve") Valve = valve_ns.class_("Valve", cg.EntityBase) +ValveCall = valve_ns.class_("ValveCall") VALVE_OPEN = valve_ns.VALVE_OPEN VALVE_CLOSED = valve_ns.VALVE_CLOSED @@ -228,17 +229,40 @@ VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( ) async def valve_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if stop_config := config.get(CONF_STOP): - template_ = await cg.templatable(stop_config, args, cg.bool_) - cg.add(var.set_stop(template_)) - if state_config := config.get(CONF_STATE): - template_ = await cg.templatable(state_config, args, cg.float_) - cg.add(var.set_position(template_)) - if (position_config := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position_config, args, cg.float_) - cg.add(var.set_position(template_)) - return var + + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. + # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most + # one is present and both dispatch to set_position. + FIELDS = ( + (CONF_STOP, "set_stop", cg.bool_), + (CONF_STATE, "set_position", cg.float_), + (CONF_POSITION, "set_position", cg.float_), + ) + + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + for conf_key, setter, type_ in FIELDS: + if (value := config.get(conf_key)) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=type_) + body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. + apply_args = [ + (ValveCall.operator("ref"), "call"), + *((t.operator("const").operator("ref"), n) for t, n in args), + ] + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + apply_args, + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index a064f375f7..ae9ac0db76 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -47,24 +47,25 @@ template class ToggleAction : public Action { Valve *valve_; }; +// All configured fields are baked into a single stateless lambda whose +// constants live in flash. The action only stores one function pointer +// plus one parent pointer, regardless of how many fields the user set. +// Trigger args are forwarded to the apply function so user lambdas +// (e.g. `position: !lambda "return x;"`) keep working. template class ControlAction : public Action { public: - explicit ControlAction(Valve *valve) : valve_(valve) {} - - TEMPLATABLE_VALUE(bool, stop) - TEMPLATABLE_VALUE(float, position) + using ApplyFn = void (*)(ValveCall &, const Ts &...); + ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} void play(const Ts &...x) override { auto call = this->valve_->make_call(); - if (this->stop_.has_value()) - call.set_stop(this->stop_.value(x...)); - if (this->position_.has_value()) - call.set_position(this->position_.value(x...)); + this->apply_(call, x...); call.perform(); } protected: Valve *valve_; + ApplyFn apply_; }; template class ValveIsOpenCondition : public Condition { From 8fd9af7f2ab67e5752fb505e0d92555e66e967e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 14:32:59 -0500 Subject: [PATCH 0905/2214] fix tests --- tests/unit_tests/test_main.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fdfa0f5d5f..fec044a593 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1646,6 +1646,7 @@ def test_upload_program_ota_partition_table_with_file_arg( { CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, + "allow_partition_access": True, } ] } @@ -1685,6 +1686,35 @@ def test_upload_program_serial_partition_table( upload_program(config, args, devices) +def test_upload_program_ota_partition_table_without_allow_flag( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--partition-table must fail fast when allow_partition_access is not enabled in YAML.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ] + } + args = MockArgs(file="partitions.bin", partition_table=True) + devices = ["192.168.1.100"] + + with pytest.raises( + EsphomeError, + match="requires 'allow_partition_access: true'", + ): + upload_program(config, args, devices) + mock_run_ota.assert_not_called() + + def test_upload_program_ota_no_config( mock_get_port_type: Mock, ) -> None: From 813964714ce881b85b0730347e739591124cf89a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 15:09:08 -0500 Subject: [PATCH 0906/2214] [esp32] Move HAL bodies into components/esp32/hal.cpp + inline trivial dispatches (#16111) --- esphome/components/esp32/core.cpp | 60 +------------------------- esphome/components/esp32/hal.cpp | 71 +++++++++++++++++++++++++++++++ esphome/core/hal.h | 10 ++--- esphome/core/hal/hal_esp32.h | 12 ++++++ esphome/core/hal/hal_esp8266.h | 2 + esphome/core/hal/hal_host.h | 4 ++ esphome/core/hal/hal_libretiny.h | 4 ++ esphome/core/hal/hal_rp2040.h | 4 ++ esphome/core/hal/hal_zephyr.h | 4 ++ 9 files changed, 106 insertions(+), 65 deletions(-) create mode 100644 esphome/components/esp32/hal.cpp diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 4886745c06..5249f4a59e 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -1,17 +1,8 @@ #ifdef USE_ESP32 -#include "esphome/core/defines.h" -#include "crash_handler.h" #include "esphome/core/application.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" +#include "esphome/core/defines.h" #include "preferences.h" -#include -#include -#include -#include -#include -#include #include #include @@ -22,54 +13,7 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { -// yield(), delay(), micros(), millis_64() inlined in hal.h. -// Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig), -// falling back to esp_timer for non-standard rates. IRAM_ATTR is required because -// Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32. -// xTaskGetTickCountFromISR() is used in ISR context to satisfy the FreeRTOS API contract. -uint32_t IRAM_ATTR HOT millis() { -#if CONFIG_FREERTOS_HZ == 1000 - if (xPortInIsrContext()) [[unlikely]] { - return xTaskGetTickCountFromISR(); - } - return xTaskGetTickCount(); -#else - return micros_to_millis(static_cast(esp_timer_get_time())); -#endif -} -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } -void arch_restart() { - esp_restart(); - // restart() doesn't always end execution - while (true) { // NOLINT(clang-diagnostic-unreachable-code) - yield(); - } -} - -void arch_init() { -#ifdef USE_ESP32_CRASH_HANDLER - // Read crash data from previous boot before anything else - esp32::crash_handler_read_and_clear(); -#endif - - // Enable the task watchdog only on the loop task (from which we're currently running) - esp_task_wdt_add(nullptr); - - // Handle OTA rollback: mark partition valid immediately unless USE_OTA_ROLLBACK is enabled, - // in which case safe_mode will mark it valid after confirming successful boot. -#ifndef USE_OTA_ROLLBACK - esp_ota_mark_app_valid_cancel_rollback(); -#endif -} -void HOT arch_feed_wdt() { esp_task_wdt_reset(); } - -uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } -uint32_t arch_get_cpu_freq_hz() { - uint32_t freq = 0; - esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &freq); - return freq; -} - +// HAL functions live in hal.cpp. This file keeps only the loop task setup. TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static StackType_t diff --git a/esphome/components/esp32/hal.cpp b/esphome/components/esp32/hal.cpp new file mode 100644 index 0000000000..f6199d557f --- /dev/null +++ b/esphome/components/esp32/hal.cpp @@ -0,0 +1,71 @@ +#ifdef USE_ESP32 + +// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set +// before crash_handler.h's #ifdef-guarded namespace block is parsed. +#include "esphome/core/defines.h" +#include "crash_handler.h" +#include "esphome/core/hal.h" + +#include +#include +#include +#include +#include +#include +#include + +// Empty esp32 namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// esp32 component's API. +namespace esphome::esp32 {} // namespace esphome::esp32 + +namespace esphome { + +// Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig), +// falling back to esp_timer for non-standard rates. IRAM_ATTR is required because +// Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32. +// xTaskGetTickCountFromISR() is used in ISR context to satisfy the FreeRTOS API contract. +uint32_t IRAM_ATTR HOT millis() { +#if CONFIG_FREERTOS_HZ == 1000 + if (xPortInIsrContext()) [[unlikely]] { + return xTaskGetTickCountFromISR(); + } + return xTaskGetTickCount(); +#else + return micros_to_millis(static_cast(esp_timer_get_time())); +#endif +} + +void arch_restart() { + esp_restart(); + // restart() doesn't always end execution + while (true) { // NOLINT(clang-diagnostic-unreachable-code) + yield(); + } +} + +void arch_init() { +#ifdef USE_ESP32_CRASH_HANDLER + // Read crash data from previous boot before anything else + esp32::crash_handler_read_and_clear(); +#endif + + // Enable the task watchdog only on the loop task (from which we're currently running) + esp_task_wdt_add(nullptr); + + // Handle OTA rollback: mark partition valid immediately unless USE_OTA_ROLLBACK is enabled, + // in which case safe_mode will mark it valid after confirming successful boot. +#ifndef USE_OTA_ROLLBACK + esp_ota_mark_app_valid_cancel_rollback(); +#endif +} + +uint32_t arch_get_cpu_freq_hz() { + uint32_t freq = 0; + esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &freq); + return freq; +} + +} // namespace esphome + +#endif // USE_ESP32 diff --git a/esphome/core/hal.h b/esphome/core/hal.h index e20797cf95..312effa7b0 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -30,15 +30,11 @@ namespace esphome { -// ESP8266 inlines delayMicroseconds() and arch_feed_wdt() in hal/hal_esp8266.h; -// every other platform keeps them out-of-line in components//core.cpp. -#ifndef USE_ESP8266 -void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) -void arch_feed_wdt(); -#endif +// Cross-platform declarations. delayMicroseconds(), arch_feed_wdt(), +// arch_get_cpu_cycle_count() vary per platform (some inline, some +// out-of-line) so they live in hal/hal_.h. void __attribute__((noreturn)) arch_restart(); void arch_init(); -uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); #ifndef USE_ESP8266 diff --git a/esphome/core/hal/hal_esp32.h b/esphome/core/hal/hal_esp32.h index e755337540..2bff424441 100644 --- a/esphome/core/hal/hal_esp32.h +++ b/esphome/core/hal/hal_esp32.h @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include @@ -15,6 +17,11 @@ namespace esphome { +// Forward decl from helpers.h (esphome/core/helpers.h) — kept here so this +// header does not need to pull the rest of helpers.h. +// NOLINTNEXTLINE(readability-redundant-declaration) +void delay_microseconds_safe(uint32_t us); + /// Returns true when executing inside an interrupt handler. __attribute__((always_inline)) inline bool in_isr_context() { return xPortInIsrContext() != 0; } @@ -30,6 +37,11 @@ __attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } +// NOLINTNEXTLINE(readability-identifier-naming) +__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } +__attribute__((always_inline)) inline void arch_feed_wdt() { esp_task_wdt_reset(); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } + } // namespace esphome #endif // USE_ESP32 diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h index 04326a3579..484118f1f5 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/core/hal/hal_esp8266.h @@ -60,6 +60,8 @@ __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_ __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } __attribute__((always_inline)) inline void arch_feed_wdt() { system_soft_wdt_feed(); } +uint32_t arch_get_cpu_cycle_count(); + } // namespace esphome #endif // USE_ESP8266 diff --git a/esphome/core/hal/hal_host.h b/esphome/core/hal/hal_host.h index 145fe4ea9c..682a1a422b 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/core/hal/hal_host.h @@ -19,6 +19,10 @@ uint32_t micros(); uint32_t millis(); uint64_t millis_64(); +void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) +void arch_feed_wdt(); +uint32_t arch_get_cpu_cycle_count(); + } // namespace esphome #endif // USE_HOST diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index e0d92735bb..e9d33b7753 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -88,6 +88,10 @@ __attribute__((always_inline)) inline uint32_t millis() { return static_cast(::time_us_64()); } +void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) +void arch_feed_wdt(); +uint32_t arch_get_cpu_cycle_count(); + } // namespace esphome #endif // USE_RP2040 diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/core/hal/hal_zephyr.h index e28be5c775..6707c85b2c 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/core/hal/hal_zephyr.h @@ -19,6 +19,10 @@ uint32_t micros(); uint32_t millis(); uint64_t millis_64(); +void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) +void arch_feed_wdt(); +uint32_t arch_get_cpu_cycle_count(); + } // namespace esphome #endif // USE_ZEPHYR From 14910e65d97ceff9d42f0b383f056b555a2aa6f4 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:15:21 +0000 Subject: [PATCH 0907/2214] [ota] Use WatchdogManager for OTA on ESP32 (#16138) Co-authored-by: J. Nick Koston --- esphome/components/ota/__init__.py | 2 ++ .../components/ota/ota_backend_esp_idf.cpp | 23 ++----------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 8f31eb5cdd..579491fe1a 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -24,6 +24,8 @@ def AUTO_LOAD() -> list[str]: components = ["safe_mode"] if not CORE.using_zephyr: components.extend(["md5"]) + if CORE.is_esp32: + components.extend(["watchdog"]) return components diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 598fce1562..b4b38a192f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -2,6 +2,7 @@ #include "ota_backend_esp_idf.h" #include "esphome/components/md5/md5.h" +#include "esphome/components/watchdog/watchdog.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" @@ -28,29 +29,9 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // The following function takes longer than the 5 seconds timeout of WDT - esp_task_wdt_config_t wdtc; - wdtc.idle_core_mask = 0; -#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 - wdtc.idle_core_mask |= (1 << 0); -#endif -#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 - wdtc.idle_core_mask |= (1 << 1); -#endif - wdtc.timeout_ms = 15000; - wdtc.trigger_panic = false; - esp_task_wdt_reconfigure(&wdtc); -#endif - + watchdog::WatchdogManager watchdog(15000); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // Set the WDT back to the configured timeout - wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; - esp_task_wdt_reconfigure(&wdtc); -#endif - if (err != ESP_OK) { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; From 53b682e48fce681685d0e19d62d0a52db0441043 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:19:33 -0400 Subject: [PATCH 0908/2214] [ci] Bump clang-tidy from 18.1.8 to 22.1.0.1 (#16078) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .clang-tidy | 33 ++++++++++++++++--- .clang-tidy.hash | 2 +- .../adalight/adalight_light_effect.cpp | 2 +- esphome/components/api/proto.h | 1 + esphome/components/climate/climate.cpp | 3 +- esphome/components/debug/debug_zephyr.cpp | 13 +++++--- .../e131/e131_addressable_light_effect.cpp | 3 +- esphome/components/esp8266/gpio.cpp | 1 + esphome/components/esp8266/preferences.cpp | 4 +-- .../http_request/http_request_arduino.cpp | 2 +- esphome/components/nrf52/uicr.cpp | 2 ++ .../components/ota/ota_backend_esp8266.cpp | 1 + esphome/components/sprinkler/sprinkler.cpp | 2 +- esphome/core/color.h | 2 +- esphome/core/helpers.cpp | 4 +-- esphome/core/preference_backend.h | 4 +++ requirements_dev.txt | 2 +- script/clang-tidy | 6 ++-- 18 files changed, 63 insertions(+), 24 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index 5878028f48..4e128a1945 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -5,24 +5,30 @@ Checks: >- -altera-*, -android-*, -boost-*, + -bugprone-derived-method-shadowing-base-method, -bugprone-easily-swappable-parameters, -bugprone-implicit-widening-of-multiplication-result, + -bugprone-invalid-enum-default-initialization, -bugprone-multi-level-implicit-pointer-conversion, -bugprone-narrowing-conversions, + -bugprone-tagged-union-member-count, -bugprone-signed-char-misuse, -bugprone-switch-missing-default-case, -cert-dcl50-cpp, -cert-err33-c, -cert-err58-cpp, + -cert-int09-c, -cert-oop57-cpp, -cert-str34-c, -clang-analyzer-optin.core.EnumCastOutOfRange, -clang-analyzer-optin.cplusplus.UninitializedObject, -clang-analyzer-osx.*, + -clang-analyzer-security.ArrayBound, -clang-diagnostic-delete-abstract-non-virtual-dtor, -clang-diagnostic-delete-non-abstract-non-virtual-dtor, -clang-diagnostic-deprecated-declarations, -clang-diagnostic-ignored-optimization-argument, + -clang-diagnostic-missing-designated-field-initializers, -clang-diagnostic-missing-field-initializers, -clang-diagnostic-shadow-field, -clang-diagnostic-unused-const-variable, @@ -42,6 +48,7 @@ Checks: >- -cppcoreguidelines-owning-memory, -cppcoreguidelines-prefer-member-initializer, -cppcoreguidelines-pro-bounds-array-to-pointer-decay, + -cppcoreguidelines-pro-bounds-avoid-unchecked-container-access, -cppcoreguidelines-pro-bounds-constant-array-index, -cppcoreguidelines-pro-bounds-pointer-arithmetic, -cppcoreguidelines-pro-type-const-cast, @@ -54,12 +61,13 @@ Checks: >- -cppcoreguidelines-rvalue-reference-param-not-moved, -cppcoreguidelines-special-member-functions, -cppcoreguidelines-use-default-member-init, + -cppcoreguidelines-use-enum-class, -cppcoreguidelines-virtual-class-destructor, + -fuchsia-default-arguments-calls, + -fuchsia-default-arguments-declarations, -fuchsia-multiple-inheritance, -fuchsia-overloaded-operator, -fuchsia-statically-constructed-objects, - -fuchsia-default-arguments-declarations, - -fuchsia-default-arguments-calls, -google-build-using-namespace, -google-explicit-constructor, -google-readability-braces-around-statements, @@ -71,16 +79,23 @@ Checks: >- -llvm-else-after-return, -llvm-header-guard, -llvm-include-order, + -llvm-prefer-static-over-anonymous-namespace, -llvm-qualified-auto, + -llvm-use-ranges, -llvmlibc-*, -misc-const-correctness, -misc-include-cleaner, + -misc-multiple-inheritance, -misc-no-recursion, -misc-non-private-member-variables-in-classes, + -misc-override-with-different-visibility, -misc-unused-parameters, -misc-use-anonymous-namespace, + -misc-use-internal-linkage, -modernize-avoid-bind, + -modernize-avoid-variadic-functions, -modernize-avoid-c-arrays, + -modernize-avoid-c-style-cast, -modernize-concat-nested-namespaces, -modernize-macro-to-enum, -modernize-return-braced-init-list, @@ -88,32 +103,42 @@ Checks: >- -modernize-use-auto, -modernize-use-constraints, -modernize-use-default-member-init, + -modernize-use-designated-initializers, -modernize-use-equals-default, + -modernize-use-integer-sign-comparison, -modernize-use-nodiscard, -modernize-use-nullptr, - -modernize-use-nodiscard, - -modernize-use-nullptr, + -modernize-use-ranges, -modernize-use-trailing-return-type, -mpi-*, -objc-*, -performance-enum-size, + -portability-avoid-pragma-once, + -portability-template-virtual-member-function, + -readability-ambiguous-smartptr-reset-call, -readability-avoid-nested-conditional-operator, -readability-container-contains, -readability-container-data-pointer, -readability-convert-member-functions-to-static, -readability-else-after-return, + -readability-enum-initial-value, -readability-function-cognitive-complexity, -readability-implicit-bool-conversion, -readability-isolate-declaration, -readability-magic-numbers, -readability-make-member-function-const, + -readability-math-missing-parentheses, -readability-named-parameter, -readability-redundant-casting, -readability-redundant-inline-specifier, -readability-redundant-member-init, + -readability-redundant-parentheses, -readability-redundant-string-init, + -readability-redundant-typename, -readability-uppercase-literal-suffix, -readability-use-anyofallof, + -readability-use-std-min-max, + -readability-use-concise-preprocessor-directives, WarningsAsErrors: '*' FormatStyle: google CheckOptions: diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 41e1b7bd2f..582e0c1eaa 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324 +0c7f309d70eca8e3efd510092ddb23c530f3934c49371717efa124b788d761f8 diff --git a/esphome/components/adalight/adalight_light_effect.cpp b/esphome/components/adalight/adalight_light_effect.cpp index 4cf639a01f..06d7e0e897 100644 --- a/esphome/components/adalight/adalight_light_effect.cpp +++ b/esphome/components/adalight/adalight_light_effect.cpp @@ -129,7 +129,7 @@ AdalightLightEffect::Frame AdalightLightEffect::parse_frame_(light::AddressableL uint8_t *led_data = &frame_[6]; for (int led = 0; led < accepted_led_count; led++, led_data += 3) { - auto white = std::min(std::min(led_data[0], led_data[1]), led_data[2]); + auto white = std::min({led_data[0], led_data[1], led_data[2]}); it[led].set(Color(led_data[0], led_data[1], led_data[2], white)); } diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 3ff65029e1..f058f6af22 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -424,6 +424,7 @@ class ProtoEncode { if (len == 0 && !force) return; encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited string + // NOLINTNEXTLINE(readability-inconsistent-ifelse-braces) -- false positive on [[likely]] attribute if (len < VARINT_MAX_1_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 1 + len); *pos++ = static_cast(len); diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index e132497140..b41ca4a540 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -374,7 +374,8 @@ void Climate::save_state_(const ClimateTraits &traits) { #define TEMP_IGNORE_MEMACCESS #endif ClimateDeviceRestoreState state{}; - // initialize as zero to prevent random data on stack triggering erase + // initialize as zero (including padding) to prevent random data on stack triggering erase + // NOLINTNEXTLINE(bugprone-raw-memory-call-on-non-trivial-type) -- intentional bytewise zero for RTC save memset(&state, 0, sizeof(ClimateDeviceRestoreState)); #ifdef TEMP_IGNORE_MEMACCESS #pragma GCC diagnostic pop diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index 49790b5b9a..81c8612784 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -17,11 +17,13 @@ constexpr std::uintptr_t MBR_PARAM_PAGE_ADDR = 0xFFC; constexpr std::uintptr_t MBR_BOOTLOADER_ADDR = 0xFF8; static inline uint32_t read_mem_u32(uintptr_t addr) { - return *reinterpret_cast(addr); // NOLINT(performance-no-int-to-ptr) + // NOLINTNEXTLINE(performance-no-int-to-ptr,clang-analyzer-core.FixedAddressDereference) + return *reinterpret_cast(addr); } static inline uint8_t read_mem_u8(uintptr_t addr) { - return *reinterpret_cast(addr); // NOLINT(performance-no-int-to-ptr) + // NOLINTNEXTLINE(performance-no-int-to-ptr,clang-analyzer-core.FixedAddressDereference) + return *reinterpret_cast(addr); } // defines from https://github.com/adafruit/Adafruit_nRF52_Bootloader which prints those information @@ -98,6 +100,7 @@ void DebugComponent::log_partition_info_() { #define NRF_PERIPH_ENABLED(periph, reg) \ YESNO(((reg)->ENABLE & periph##_ENABLE_ENABLE_Msk) == (periph##_ENABLE_ENABLE_Enabled << periph##_ENABLE_ENABLE_Pos)) +// NOLINTBEGIN(clang-analyzer-core.FixedAddressDereference) -- nRF peripheral registers are MMIO at fixed addresses static void log_peripherals_info() { // most peripherals are enabled only when in use so ESP_LOGV is enough ESP_LOGV(TAG, "Peripherals status:"); @@ -131,6 +134,7 @@ static void log_peripherals_info() { YESNO((NRF_CRYPTOCELL->ENABLE & CRYPTOCELL_ENABLE_ENABLE_Msk) == (CRYPTOCELL_ENABLE_ENABLE_Enabled << CRYPTOCELL_ENABLE_ENABLE_Pos))); } +// NOLINTEND(clang-analyzer-core.FixedAddressDereference) #undef NRF_PERIPH_ENABLED #endif @@ -159,8 +163,9 @@ size_t DebugComponent::get_device_info_(std::span char *buf = buffer.data(); // Main supply status - const char *supply_status = - (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) ? "Normal voltage." : "High voltage."; + // NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- NRF_POWER is MMIO at a fixed address + auto regstatus = nrf_power_mainregstatus_get(NRF_POWER); + const char *supply_status = (regstatus == NRF_POWER_MAINREGSTATUS_NORMAL) ? "Normal voltage." : "High voltage."; ESP_LOGD(TAG, "Main supply status: %s", supply_status); pos = buf_append_str(buf, size, pos, "|Main supply status: "); pos = buf_append_str(buf, size, pos, supply_status); diff --git a/esphome/components/e131/e131_addressable_light_effect.cpp b/esphome/components/e131/e131_addressable_light_effect.cpp index f6010a7cc9..f6300874ac 100644 --- a/esphome/components/e131/e131_addressable_light_effect.cpp +++ b/esphome/components/e131/e131_addressable_light_effect.cpp @@ -56,8 +56,7 @@ bool E131AddressableLightEffect::process_(int universe, const E131Packet &packet // limit amount of lights per universe and received // packet.count is the number of DMX bytes including start code; divide by channels to get the number of lights int lights_in_packet = (packet.count > 0) ? (packet.count - 1) / channels_ : 0; - int output_end = - std::min(it->size(), std::min(output_offset + get_lights_per_universe(), output_offset + lights_in_packet)); + int output_end = std::min({it->size(), output_offset + get_lights_per_universe(), output_offset + lights_in_packet}); auto *input_data = packet.values + 1; auto effect_name = get_name(); diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index 659233443e..a85f054dfe 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -140,6 +140,7 @@ void IRAM_ATTR ISRInternalGPIOPin::digital_write(bool value) { void IRAM_ATTR ISRInternalGPIOPin::clear_interrupt() { auto *arg = reinterpret_cast(arg_); + // NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- GPIO_REG_WRITE is MMIO at a fixed address GPIO_REG_WRITE(GPIO_STATUS_W1TC_ADDRESS, 1UL << arg->pin); } diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index f444f03555..696f83bce1 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -51,7 +51,7 @@ static inline bool esp_rtc_user_mem_read(uint32_t index, uint32_t *dest) { if (index >= ESP_RTC_USER_MEM_SIZE_WORDS) { return false; } - *dest = ESP_RTC_USER_MEM[index]; // NOLINT(performance-no-int-to-ptr) + *dest = ESP_RTC_USER_MEM[index]; // NOLINT(performance-no-int-to-ptr,clang-analyzer-core.FixedAddressDereference) return true; } @@ -64,7 +64,7 @@ static inline bool esp_rtc_user_mem_write(uint32_t index, uint32_t value) { } auto *ptr = &ESP_RTC_USER_MEM[index]; // NOLINT(performance-no-int-to-ptr) - *ptr = value; + *ptr = value; // NOLINT(clang-analyzer-core.FixedAddressDereference) return true; } diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 05f9db1c06..217ad0064d 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -243,7 +243,7 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) { // Non-chunked path int available_data = stream_ptr->available(); size_t remaining = (this->content_length > 0) ? (this->content_length - this->bytes_read_) : max_len; - int bufsize = std::min(max_len, std::min(remaining, (size_t) available_data)); + int bufsize = std::min({max_len, remaining, (size_t) available_data}); if (bufsize == 0) { this->duration_ms += (millis() - start); diff --git a/esphome/components/nrf52/uicr.cpp b/esphome/components/nrf52/uicr.cpp index 4c0beeb503..03b07f8fe3 100644 --- a/esphome/components/nrf52/uicr.cpp +++ b/esphome/components/nrf52/uicr.cpp @@ -11,6 +11,7 @@ void nvmc_wait(); nrfx_err_t nrfx_nvmc_uicr_erase(); } +// NOLINTBEGIN(clang-analyzer-core.FixedAddressDereference) -- NRF_UICR / NRF_TIMER2 are MMIO at fixed addresses namespace esphome::nrf52 { enum class StatusFlags : uint8_t { @@ -113,6 +114,7 @@ static int board_esphome_init() { return 0; } } // namespace esphome::nrf52 +// NOLINTEND(clang-analyzer-core.FixedAddressDereference) static int board_esphome_init() { return esphome::nrf52::board_esphome_init(); } diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 93e6249fb3..7c9d392532 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -60,6 +60,7 @@ OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) { // Check boot mode - if boot mode is UART download mode, // we will not be able to reset into normal mode once update is done + // NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- GPI is MMIO at a fixed address int boot_mode = (GPI >> BOOT_MODE_SHIFT) & BOOT_MODE_MASK; if (boot_mode == BOOT_MODE_UART_DOWNLOAD) { return OTA_RESPONSE_ERROR_INVALID_BOOTSTRAPPING; diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index e977c05c48..336123a472 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -669,7 +669,7 @@ uint32_t Sprinkler::valve_run_duration_adjusted(const size_t valve_number) { // run_duration must not be less than any of these if ((run_duration < this->start_delay_) || (run_duration < this->stop_delay_) || (run_duration < this->switching_delay_.value_or(0) * 2)) { - return std::max(this->switching_delay_.value_or(0) * 2, std::max(this->start_delay_, this->stop_delay_)); + return std::max({this->switching_delay_.value_or(0) * 2, this->start_delay_, this->stop_delay_}); } return run_duration; } diff --git a/esphome/core/color.h b/esphome/core/color.h index 32d63b1856..442470623d 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -169,7 +169,7 @@ struct Color { uint8_t r = rand >> 16; uint8_t g = rand >> 8; uint8_t b = rand >> 0; - const uint16_t max_rgb = std::max(r, std::max(g, b)); + const uint16_t max_rgb = std::max({r, g, b}); return Color(uint8_t((uint16_t(r) * 255U / max_rgb)), uint8_t((uint16_t(g) * 255U / max_rgb)), uint8_t((uint16_t(b) * 255U / max_rgb)), w); } diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index e71da95e6b..1eb3345491 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -663,8 +663,8 @@ float gamma_uncorrect(float value, float gamma) { } void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value) { - float max_color_value = std::max(std::max(red, green), blue); - float min_color_value = std::min(std::min(red, green), blue); + float max_color_value = std::max({red, green, blue}); + float min_color_value = std::min({red, green, blue}); float delta = max_color_value - min_color_value; if (delta == 0) { diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 3766934da4..431de205af 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -69,6 +69,10 @@ template class PreferencesMixin { ESPPreferenceObject make_preference(uint32_t type) { return static_cast(this)->make_preference(sizeof(T), type); } + + private: + PreferencesMixin() = default; + friend Derived; }; // Macro for platform preferences.h headers to declare the standard aliases. diff --git a/requirements_dev.txt b/requirements_dev.txt index 0884e5b5e4..31463e07c3 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating -clang-tidy==18.1.8 # When updating clang-tidy, also update Dockerfile +clang-tidy==22.1.0.1 yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating diff --git a/script/clang-tidy b/script/clang-tidy index f2834b44ac..1c413ffa23 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -295,7 +295,7 @@ def main(): failed_files = [] try: - executable = get_binary("clang-tidy", 18) + executable = get_binary("clang-tidy", 22) task_queue = queue.Queue(args.jobs) lock = threading.Lock() for _ in range(args.jobs): @@ -341,13 +341,13 @@ def main(): try: try: subprocess.call( - ["clang-apply-replacements-18", tmpdir], close_fds=False + ["clang-apply-replacements-22", tmpdir], close_fds=False ) except FileNotFoundError: subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False) except FileNotFoundError: print( - "Error please install clang-apply-replacements-18 or clang-apply-replacements.\n", + "Error please install clang-apply-replacements-22 or clang-apply-replacements.\n", file=sys.stderr, ) except: From cecccebc64a3838f7bb2bec40d0c82c7150620d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 15:35:04 -0500 Subject: [PATCH 0909/2214] [core] DelayAction: drop Component inheritance, use self-keyed scheduler (#16129) --- esphome/automation.py | 3 +-- esphome/core/base_automation.h | 18 +++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 20eb9358ca..1689d29c42 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -127,7 +127,7 @@ def validate_potentially_or_condition(value): return validate_condition(value) -DelayAction = cg.esphome_ns.class_("DelayAction", Action, cg.Component) +DelayAction = cg.esphome_ns.class_("DelayAction", Action) LambdaAction = cg.esphome_ns.class_("LambdaAction", Action) StatelessLambdaAction = cg.esphome_ns.class_("StatelessLambdaAction", Action) IfAction = cg.esphome_ns.class_("IfAction", Action) @@ -396,7 +396,6 @@ async def delay_action_to_code( args: TemplateArgsType, ) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) - await cg.register_component(var, {}) template_ = await cg.templatable(config, args, cg.uint32) cg.add(var.set_delay(template_)) return var diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index afd11c6867..dcad7c9d2e 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -178,7 +178,7 @@ class ProjectUpdateTrigger : public Trigger, public Component { }; #endif -template class DelayAction : public Action, public Component { +template class DelayAction : public Action { public: explicit DelayAction() = default; @@ -198,8 +198,8 @@ template class DelayAction : public Action, public Compon // to avoid overhead from capturing arguments by value if constexpr (sizeof...(Ts) == 0) { App.scheduler.set_timer_common_( - this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, - static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(), + /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, + /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } else { @@ -208,18 +208,18 @@ template class DelayAction : public Action, public Compon // `mutable` is required so captured copies of non-const reference args (e.g. std::string&) // are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&` auto f = [this, x...]() mutable { this->play_next_(x...); }; - App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, - nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), - this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + App.scheduler.set_timer_common_( + /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, + /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), + std::move(f), + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } } - float get_setup_priority() const override { return setup_priority::HARDWARE; } void play(const Ts &...x) override { /* ignore - see play_complex */ } - void stop() override { this->cancel_timeout(InternalSchedulerID::DELAY_ACTION); } + void stop() override { App.scheduler.cancel_timeout(this); } }; template class LambdaAction : public Action { From 87b9a9612a41d57782652391749b6ece1088dd82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 15:37:35 -0500 Subject: [PATCH 0910/2214] [mdns] Broadcast config_hash TXT record on _esphomelib._tcp --- esphome/components/mdns/mdns_component.cpp | 9 +++++++-- esphome/components/mdns/mdns_component.h | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index e05373ac5d..cdd24015f5 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -39,7 +39,8 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); -void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf) { +void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf, + char *config_hash_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. @@ -47,6 +48,7 @@ void MDNSComponent::compile_records_(StaticVector +#include #include +#include "esphome/core/application.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -70,6 +73,9 @@ class MDNSComponent final : public Component void setup() override; void dump_config() override; + /// Size of buffer required for config hash hex string (8 hex chars + null terminator) + static constexpr size_t CONFIG_HASH_STR_SIZE = 9; + #ifdef USE_MDNS_EVENT_DRIVEN_POLLING // LEAmDNS has meaningful work only during the probe+announce phase (3×250ms probes + // 8×1000ms announces, ~9s). Afterwards every internal timer is resetToNeverExpires() @@ -136,16 +142,21 @@ class MDNSComponent final : public Component #ifdef USE_MDNS_STORE_SERVICES get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; + char *cfg_ptr = this->config_hash_str_; #else char mac_address[MAC_ADDRESS_BUFFER_SIZE]; + char config_hash_str[CONFIG_HASH_STR_SIZE]; get_mac_address_into_buffer(mac_address); char *mac_ptr = mac_address; + char *cfg_ptr = config_hash_str; #endif + snprintf(cfg_ptr, CONFIG_HASH_STR_SIZE, "%08" PRIx32, App.get_config_hash()); #else char *mac_ptr = nullptr; + char *cfg_ptr = nullptr; #endif - this->compile_records_(services, mac_ptr); + this->compile_records_(services, mac_ptr, cfg_ptr); platform_register(this, services); } @@ -159,6 +170,8 @@ class MDNSComponent final : public Component #if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) /// Fixed buffer for MAC address (only needed when services are stored) char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; + /// Fixed buffer for config hash hex string (only needed when services are stored) + char config_hash_str_[CONFIG_HASH_STR_SIZE]; #endif #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; @@ -167,7 +180,8 @@ class MDNSComponent final : public Component // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif - void compile_records_(StaticVector &services, char *mac_address_buf); + void compile_records_(StaticVector &services, char *mac_address_buf, + char *config_hash_buf); }; } // namespace esphome::mdns From 09542430f492365f1ac6bd5ed4b872984cb70cd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 15:37:47 -0500 Subject: [PATCH 0911/2214] [esp8266] Address Copilot review on #16112 - hal.cpp: include esphome/core/helpers.h for the HOT macro (millis/delay use it; without the include the TU fails to compile). - hal_esp8266.h: drop the redundant arch_get_cpu_cycle_count() bare declaration that came back via the merge from dev (the inline def above already declares the function). --- esphome/components/esp8266/hal.cpp | 1 + esphome/core/hal/hal_esp8266.h | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index ef93a95273..56910e5b39 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP8266 #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include #include diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h index 976b697686..b6e3b1ee3c 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/core/hal/hal_esp8266.h @@ -66,8 +66,6 @@ __attribute__((always_inline)) inline void arch_init() {} __attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } __attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return F_CPU; } -uint32_t arch_get_cpu_cycle_count(); - } // namespace esphome #endif // USE_ESP8266 From 77da64a367c62a123d01559d344255a2c338bebe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:05:51 -0400 Subject: [PATCH 0912/2214] [sx126x] Add cold sleep option and drop unused RTC wakeup bit (#16144) --- esphome/components/sx126x/__init__.py | 58 +++++++++++++++++++++----- esphome/components/sx126x/automation.h | 3 +- esphome/components/sx126x/sx126x.cpp | 5 ++- esphome/components/sx126x/sx126x.h | 2 +- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index b8696158fe..a4ba5c34f3 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__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_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID, TimePeriod +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType MULTI_CONF = True CODEOWNERS = ["@swoboda1337"] @@ -15,6 +19,7 @@ CONF_SX126X_ID = "sx126x_id" CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_CODING_RATE = "coding_rate" +CONF_COLD = "cold" CONF_CRC_INVERTED = "crc_inverted" CONF_CRC_SIZE = "crc_size" CONF_CRC_POLYNOMIAL = "crc_polynomial" @@ -144,7 +149,7 @@ SetModeStandbyAction = sx126x_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): @@ -154,7 +159,7 @@ def validate_raw_data(value): ) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: lora_bws = [ "7_8kHz", "10_4kHz", @@ -235,7 +240,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) @@ -307,24 +312,50 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( - "sx126x.set_mode_sleep", - SetModeSleepAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) @automation.register_action( "sx126x.set_mode_standby", SetModeStandbyAction, 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 +SET_MODE_SLEEP_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(SX126x), + cv.Optional(CONF_COLD, default=False): cv.templatable(cv.boolean), + } +) + + +@automation.register_action( + "sx126x.set_mode_sleep", + SetModeSleepAction, + SET_MODE_SLEEP_ACTION_SCHEMA, + synchronous=True, +) +async def set_mode_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]) + template_ = await cg.templatable(config[CONF_COLD], args, bool) + cg.add(var.set_cold(template_)) + return var + + SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SX126x), @@ -340,7 +371,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/sx126x/automation.h b/esphome/components/sx126x/automation.h index 2282c583cb..ed5986e097 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -56,7 +56,8 @@ template class SetModeRxAction : public Action, public Pa template class SetModeSleepAction : public Action, public Parented { public: - void play(const Ts &...x) override { this->parent_->set_mode_sleep(); } + TEMPLATABLE_VALUE(bool, cold) + void play(const Ts &...x) override { this->parent_->set_mode_sleep(this->cold_.value(x...)); } }; template class SetModeStandbyAction : public Action, public Parented { diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6ea09e3a9e..02f7d972a9 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -459,9 +459,10 @@ void SX126x::set_mode_tx() { this->write_opcode_(RADIO_SET_TX, buf, 3); } -void SX126x::set_mode_sleep() { +void SX126x::set_mode_sleep(bool cold) { + // 0x04 = warm start (config retained), 0x00 = cold start (config lost, lowest power) uint8_t buf[1]; - buf[0] = 0x05; + buf[0] = cold ? 0x00 : 0x04; this->write_opcode_(RADIO_SET_SLEEP, buf, 1); } diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index edc00e3727..87bbf18c79 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -79,7 +79,7 @@ class SX126x : public Component, void set_mode_rx(); void set_mode_tx(); void set_mode_standby(SX126xStandbyMode mode); - void set_mode_sleep(); + void set_mode_sleep(bool cold = false); void set_modulation(uint8_t modulation) { this->modulation_ = modulation; } void set_pa_power(int8_t power) { this->pa_power_ = power; } void set_pa_ramp(uint8_t ramp) { this->pa_ramp_ = ramp; } From 5a59e99016f93f1dbee16d8801f133183a35f42e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 16:43:55 -0500 Subject: [PATCH 0913/2214] [mdns] Move setup helper to .cpp and use format_hex_to --- esphome/components/mdns/mdns_component.cpp | 31 +++++++++++++++++++ esphome/components/mdns/mdns_component.h | 35 ++-------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index cdd24015f5..9bf27e71e4 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -39,6 +39,37 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); +void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { +#ifdef USE_MDNS_STORE_SERVICES + auto &services = this->services_; +#else + StaticVector services_storage; + auto &services = services_storage; +#endif + +#ifdef USE_API +#ifdef USE_MDNS_STORE_SERVICES + get_mac_address_into_buffer(this->mac_address_); + char *mac_ptr = this->mac_address_; + format_hex_to(this->config_hash_str_, App.get_config_hash()); + char *cfg_ptr = this->config_hash_str_; +#else + char mac_address[MAC_ADDRESS_BUFFER_SIZE]; + char config_hash_str[CONFIG_HASH_STR_SIZE]; + get_mac_address_into_buffer(mac_address); + format_hex_to(config_hash_str, App.get_config_hash()); + char *mac_ptr = mac_address; + char *cfg_ptr = config_hash_str; +#endif +#else + char *mac_ptr = nullptr; + char *cfg_ptr = nullptr; +#endif + + this->compile_records_(services, mac_ptr, cfg_ptr); + platform_register(this, services); +} + void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf, char *config_hash_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index a4aedab5fe..e981a65941 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -1,10 +1,7 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_MDNS -#include -#include #include -#include "esphome/core/application.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -74,7 +71,7 @@ class MDNSComponent final : public Component void dump_config() override; /// Size of buffer required for config hash hex string (8 hex chars + null terminator) - static constexpr size_t CONFIG_HASH_STR_SIZE = 9; + static constexpr size_t CONFIG_HASH_STR_SIZE = format_hex_size(sizeof(uint32_t)); #ifdef USE_MDNS_EVENT_DRIVEN_POLLING // LEAmDNS has meaningful work only during the probe+announce phase (3×250ms probes + @@ -130,35 +127,7 @@ class MDNSComponent final : public Component /// Helper to set up services and MAC buffers, then call platform-specific registration using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector &); - void setup_buffers_and_register_(PlatformRegisterFn platform_register) { -#ifdef USE_MDNS_STORE_SERVICES - auto &services = this->services_; -#else - StaticVector services_storage; - auto &services = services_storage; -#endif - -#ifdef USE_API -#ifdef USE_MDNS_STORE_SERVICES - get_mac_address_into_buffer(this->mac_address_); - char *mac_ptr = this->mac_address_; - char *cfg_ptr = this->config_hash_str_; -#else - char mac_address[MAC_ADDRESS_BUFFER_SIZE]; - char config_hash_str[CONFIG_HASH_STR_SIZE]; - get_mac_address_into_buffer(mac_address); - char *mac_ptr = mac_address; - char *cfg_ptr = config_hash_str; -#endif - snprintf(cfg_ptr, CONFIG_HASH_STR_SIZE, "%08" PRIx32, App.get_config_hash()); -#else - char *mac_ptr = nullptr; - char *cfg_ptr = nullptr; -#endif - - this->compile_records_(services, mac_ptr, cfg_ptr); - platform_register(this, services); - } + void setup_buffers_and_register_(PlatformRegisterFn platform_register); #ifdef USE_MDNS_DYNAMIC_TXT /// Storage for runtime-generated TXT values from user lambdas From 295e8563eb23bd59564675cf30ac9339dfb37550 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 17:27:25 -0500 Subject: [PATCH 0914/2214] [core] Fix null deref in WarnIfComponentBlockingGuard for self-keyed timers Self-keyed scheduler items (Scheduler::set_timeout(self, ...) and set_interval(self, ...)) intentionally store component == nullptr. When USE_RUNTIME_STATS is enabled, WarnIfComponentBlockingGuard::finish() dereferenced component_ to call runtime_stats_.record_time(), causing a load access fault on RISC-V (e.g. ESP32-C3) when these timers fire. Skip the per-component bookkeeping when component_ is null, but still accumulate into ComponentRuntimeStats::global_recorded_us so Application::loop() overhead accounting (which subtracts scheduled callback time from before_loop_tasks_) stays accurate. --- esphome/core/component.h | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 6afcfda41d..185d51ab37 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -655,7 +655,15 @@ class WarnIfComponentBlockingGuard { // Inlined: the fast path is just millis() + subtract + compare inline uint32_t HOT finish() { #ifdef USE_RUNTIME_STATS - this->component_->runtime_stats_.record_time(micros() - this->started_us_); + uint32_t elapsed_us = micros() - this->started_us_; + // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) + if (this->component_ != nullptr) { + this->component_->runtime_stats_.record_time(elapsed_us); + } else { + // Still accumulate into the global counter so Application::loop() can subtract + // this time from before_loop_tasks_ wall time. + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } #endif uint32_t curr_time = MillisInternal::get(); #ifndef USE_BENCHMARK From e4b33fddf5556aa3a7d806a7d6b1ee2caa522d8c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:43:15 -0400 Subject: [PATCH 0915/2214] [esp32] Add ESP-IDF 6.0.1 platform entry (#16146) --- esphome/components/esp32/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index eb023ce32c..bd299d71f0 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -729,6 +729,9 @@ ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { "dev": cv.Version(5, 5, 4), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { + cv.Version( + 6, 0, 1 + ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", From 8dea8135faa0d4ec2db95e058e945ce6f43523ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 17:49:06 -0500 Subject: [PATCH 0916/2214] [core] Drop unused DELAY_ACTION from InternalSchedulerID enum Followup to #16129. DelayAction now keys its scheduler entry by self-pointer (NameType::SELF_POINTER), so the DELAY_ACTION enum value is no longer referenced anywhere. --- esphome/core/component.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 6afcfda41d..d0defef139 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -65,7 +65,6 @@ inline constexpr uint32_t SCHEDULER_DONT_RUN = 4294967295UL; /// with component-level NUMERIC_ID values, even if the uint32_t values overlap. enum class InternalSchedulerID : uint32_t { POLLING_UPDATE = 0, // PollingComponent interval - DELAY_ACTION = 1, // DelayAction timeout }; // Forward declaration From 62599350657414a960b50c102d2dd63640358dfc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 17:53:14 -0500 Subject: [PATCH 0917/2214] update host stub --- esphome/components/mdns/mdns_host.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 4d902319b8..1e66a10df0 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -3,6 +3,8 @@ #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "mdns_component.h" @@ -13,10 +15,13 @@ void MDNSComponent::setup() { #ifdef USE_API get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; + format_hex_to(this->config_hash_str_, App.get_config_hash()); + char *cfg_ptr = this->config_hash_str_; #else char *mac_ptr = nullptr; + char *cfg_ptr = nullptr; #endif - this->compile_records_(this->services_, mac_ptr); + this->compile_records_(this->services_, mac_ptr, cfg_ptr); #endif // Host platform doesn't have actual mDNS implementation } From 9b1f5c59bb6ea7417174a8751d458c4f9bdb2722 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 18:05:38 -0500 Subject: [PATCH 0918/2214] [core] Fix null deref in WarnIfComponentBlockingGuard for self-keyed scheduler timers (#16150) --- esphome/core/component.h | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 6afcfda41d..185d51ab37 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -655,7 +655,15 @@ class WarnIfComponentBlockingGuard { // Inlined: the fast path is just millis() + subtract + compare inline uint32_t HOT finish() { #ifdef USE_RUNTIME_STATS - this->component_->runtime_stats_.record_time(micros() - this->started_us_); + uint32_t elapsed_us = micros() - this->started_us_; + // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) + if (this->component_ != nullptr) { + this->component_->runtime_stats_.record_time(elapsed_us); + } else { + // Still accumulate into the global counter so Application::loop() can subtract + // this time from before_loop_tasks_ wall time. + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } #endif uint32_t curr_time = MillisInternal::get(); #ifndef USE_BENCHMARK From b8d24c9e494f4abfd24e700ad52b1430c25334fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 18:14:07 -0500 Subject: [PATCH 0919/2214] [mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#16149) --- esphome/components/mcp23xxx_base/__init__.py | 22 +++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index cd952099c0..76a3aabe3f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -2,6 +2,7 @@ from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( + CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -30,10 +31,29 @@ MCP23XXX_INTERRUPT_MODES = { "FALLING": MCP23XXXInterruptMode.MCP23XXX_FALLING, } + +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): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) From 8066325e0b89ac4b0c08d54a95a5828f5235aefb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:52:25 +1200 Subject: [PATCH 0920/2214] Bump esphome/workflows/.github/workflows/lock.yml from 2026.4.0 to 2026.4.1 (#16143) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 20f9a74ea9..8d1dfe857d 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -8,4 +8,4 @@ on: jobs: lock: - uses: esphome/workflows/.github/workflows/lock.yml@3c4e8446aa1029f1c346a482034b3ee1489077ca # 2026.4.0 + uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1 From 47765bd2d07901e233ad56b1d7dfb272a932d16a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:08:56 +1200 Subject: [PATCH 0921/2214] [ci] Correct version comment on create-github-app-token pin (#16156) --- .github/workflows/auto-label-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 0e5ceb9346..5e685ce0aa 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -27,7 +27,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} From 1a871e231dd658e99e44c27d34ffe3f6358541cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:09:37 +1200 Subject: [PATCH 0922/2214] [ci] Use client-id for GitHub App token generation (#16155) --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/release.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 5e685ce0aa..ea22f75ef0 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -29,7 +29,7 @@ jobs: id: generate-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - name: Auto Label PR diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35b9e065e1..a16af92b6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,7 +223,7 @@ jobs: id: generate-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: home-assistant-addon @@ -258,7 +258,7 @@ jobs: id: generate-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: esphome-schema @@ -289,7 +289,7 @@ jobs: id: generate-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: version-notifier From 4c027e87ba65730409fa1ed6e232f7892835732c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 21:07:39 -0500 Subject: [PATCH 0923/2214] [api] Add encode/decode benchmarks for Z-Wave, IR/RF, and serial proxy messages Mirrors the existing BluetoothLERawAdvertisementsResponse benchmarks for the remaining proxy message families: ZWaveProxyFrame/ZWaveProxyRequest, SerialProxyDataReceived/SerialProxyWriteRequest, and InfraredRFReceiveEvent/InfraredRFTransmitRawTimingsRequest. Adds minimal stub headers under tests/benchmarks/stubs/ for the zwave_proxy, infrared, radio_frequency, and serial_proxy components so api_connection.cpp compiles without dragging in their UART/RMT/BLE hardware dependencies. --- tests/benchmarks/components/api/__init__.py | 14 +- .../components/api/bench_proto_decode.cpp | 175 ++++++++++++++++++ .../components/api/bench_proto_encode.cpp | 124 +++++++++++++ .../esphome/components/infrared/infrared.h | 45 +++++ .../radio_frequency/radio_frequency.h | 51 +++++ .../components/serial_proxy/serial_proxy.h | 46 +++++ .../components/zwave_proxy/zwave_proxy.h | 29 +++ 7 files changed, 481 insertions(+), 3 deletions(-) create mode 100644 tests/benchmarks/stubs/esphome/components/infrared/infrared.h create mode 100644 tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h create mode 100644 tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h create mode 100644 tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index eb86492964..0d02e0b054 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -11,11 +11,19 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code(config): await original_to_code(config) - # Enable BLE proto message types for benchmarks. The real - # bluetooth_proxy component is ESP32-only; a lightweight stub - # header in tests/benchmarks/stubs/ satisfies the include. + # Enable proxy proto message types for benchmarks. The real + # 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("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + cg.add_define("USE_ZWAVE_PROXY") + cg.add_define("USE_INFRARED") + cg.add_define("USE_IR_RF") + cg.add_define("USE_RADIO_FREQUENCY") + cg.add_define("USE_SERIAL_PROXY") + cg.add_define("SERIAL_PROXY_COUNT", 0) + cg.add_define("ESPHOME_ENTITY_INFRARED_COUNT", 0) + cg.add_define("ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT", 0) manifest.to_code = to_code diff --git a/tests/benchmarks/components/api/bench_proto_decode.cpp b/tests/benchmarks/components/api/bench_proto_decode.cpp index 961c629f2a..54ba00efce 100644 --- a/tests/benchmarks/components/api/bench_proto_decode.cpp +++ b/tests/benchmarks/components/api/bench_proto_decode.cpp @@ -77,6 +77,181 @@ static void Decode_SwitchCommandRequest(benchmark::State &state) { } BENCHMARK(Decode_SwitchCommandRequest); +// --- ZWaveProxyFrame decode (~16-byte data buffer) --- + +#ifdef USE_ZWAVE_PROXY + +static void Decode_ZWaveProxyFrame(benchmark::State &state) { + static const uint8_t frame_data[] = {0x01, 0x09, 0x00, 0x13, 0x01, 0x02, 0x00, 0x00, + 0x25, 0x00, 0x05, 0xC4, 0x00, 0x00, 0x00, 0x00}; + ZWaveProxyFrame source; + source.data = frame_data; + source.data_len = sizeof(frame_data); + auto encoded = encode_message(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ZWaveProxyFrame msg; + escape(&msg); + msg.decode(data, size); + escape(&msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_ZWaveProxyFrame); + +static void Decode_ZWaveProxyRequest(benchmark::State &state) { + static const uint8_t req_data[] = {0xDE, 0xAD, 0xBE, 0xEF}; + ZWaveProxyRequest source; + source.type = enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE; + source.data = req_data; + source.data_len = sizeof(req_data); + auto encoded = encode_message(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ZWaveProxyRequest msg; + escape(&msg); + msg.decode(data, size); + escape(&msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_ZWaveProxyRequest); + +#endif // USE_ZWAVE_PROXY + +// --- SerialProxyWriteRequest decode (instance + 64-byte data) --- +// +// SerialProxyWriteRequest is decode-only (SOURCE_CLIENT), so we encode via +// SerialProxyDataReceived which has identical wire format +// (uint32 instance = 1; bytes data = 2;). + +#ifdef USE_SERIAL_PROXY + +static void Decode_SerialProxyWriteRequest(benchmark::State &state) { + static constexpr size_t kPayloadSize = 64; + static uint8_t payload[kPayloadSize]; + for (size_t i = 0; i < kPayloadSize; i++) + payload[i] = static_cast(i); + + SerialProxyDataReceived source; + source.instance = 0; + source.set_data(payload, kPayloadSize); + auto encoded = encode_message(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + SerialProxyWriteRequest msg; + escape(&msg); + msg.decode(data, size); + escape(&msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_SerialProxyWriteRequest); + +#endif // USE_SERIAL_PROXY + +// --- InfraredRFTransmitRawTimingsRequest decode (100 zigzag-encoded timings) --- +// +// Hand-built wire bytes since this message is decode-only and has no sister +// type with an identical layout. Wire format: +// field 2 (key, fixed32): tag=0x15, 4 LE bytes +// field 3 (carrier_frequency): tag=0x18, varint +// field 4 (repeat_count): tag=0x20, varint +// field 5 (timings, packed sint32): tag=0x2A, length varint, packed payload +// field 6 (modulation): tag=0x30, varint + +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) + +static APIBuffer build_infrared_rf_transmit_wire() { + APIBuffer buf; + + auto put_byte = [&](uint8_t b) { + size_t s = buf.size(); + buf.resize(s + 1); + buf.data()[s] = b; + }; + auto put_varint = [&](uint32_t v) { + while (v >= 0x80) { + put_byte(static_cast((v & 0x7F) | 0x80)); + v >>= 7; + } + put_byte(static_cast(v)); + }; + auto encode_zigzag = [](int32_t v) -> uint32_t { + return (static_cast(v) << 1) ^ static_cast(v >> 31); + }; + + // field 2: key (fixed32) = 0xDEADBEEF + put_byte(0x15); + put_byte(0xEF); + put_byte(0xBE); + put_byte(0xAD); + put_byte(0xDE); + // field 3: carrier_frequency = 38000 + put_byte(0x18); + put_varint(38000); + // field 4: repeat_count = 2 + put_byte(0x20); + put_varint(2); + // field 5: timings (packed sint32) — 100 entries alternating mark/space. + uint8_t packed[400]; + size_t packed_len = 0; + for (int i = 0; i < 100; i++) { + int32_t value = (i % 2 == 0) ? 560 : -560; + uint32_t zz = encode_zigzag(value); + while (zz >= 0x80) { + packed[packed_len++] = static_cast((zz & 0x7F) | 0x80); + zz >>= 7; + } + packed[packed_len++] = static_cast(zz); + } + put_byte(0x2A); + put_varint(static_cast(packed_len)); + for (size_t i = 0; i < packed_len; i++) + put_byte(packed[i]); + // field 6: modulation = 0 — skip (default value, not encoded by senders) + return buf; +} + +static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state) { + auto encoded = build_infrared_rf_transmit_wire(); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + InfraredRFTransmitRawTimingsRequest msg; + escape(&msg); + msg.decode(data, size); + escape(&msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_InfraredRFTransmitRawTimingsRequest); + +#endif // USE_IR_RF || USE_RADIO_FREQUENCY + // --- LightCommandRequest decode (complex command with many fields) --- static void Decode_LightCommandRequest(benchmark::State &state) { diff --git a/tests/benchmarks/components/api/bench_proto_encode.cpp b/tests/benchmarks/components/api/bench_proto_encode.cpp index 1e2efcd281..c5f2312481 100644 --- a/tests/benchmarks/components/api/bench_proto_encode.cpp +++ b/tests/benchmarks/components/api/bench_proto_encode.cpp @@ -384,4 +384,128 @@ BENCHMARK(CalcAndEncode_BLERawAdvs12_Fresh); #endif // USE_BLUETOOTH_PROXY +// --- ZWaveProxyFrame (Z-Wave frame, ~16 bytes payload) --- + +#ifdef USE_ZWAVE_PROXY + +static constexpr uint8_t kZWaveFrameData[] = {0x01, 0x09, 0x00, 0x13, 0x01, 0x02, 0x00, 0x00, + 0x25, 0x00, 0x05, 0xC4, 0x00, 0x00, 0x00, 0x00}; + +static ZWaveProxyFrame make_zwave_proxy_frame() { + ZWaveProxyFrame msg; + msg.data = kZWaveFrameData; + msg.data_len = sizeof(kZWaveFrameData); + return msg; +} + +static void Encode_ZWaveProxyFrame(benchmark::State &state) { + auto msg = make_zwave_proxy_frame(); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_ZWaveProxyFrame); + +#endif // USE_ZWAVE_PROXY + +// --- SerialProxyDataReceived (serial passthrough, 64-byte payload) --- + +#ifdef USE_SERIAL_PROXY + +static constexpr size_t kSerialPayloadSize = 64; +static const uint8_t kSerialPayload[kSerialPayloadSize] = { + 0x55, 0xAA, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, + 0xCD, 0xEF, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, + 0xFF, 0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0, + 0xF0, 0x0F, 0x1F, 0x2F, 0x3F, 0x4F, 0x5F, 0x6F, 0x7F, 0x8F, 0x9F, 0xAF, 0xBF, 0xCF, 0xDF, 0xEF}; + +static SerialProxyDataReceived make_serial_proxy_data_received() { + SerialProxyDataReceived msg; + msg.instance = 0; + msg.set_data(kSerialPayload, kSerialPayloadSize); + return msg; +} + +static void Encode_SerialProxyDataReceived(benchmark::State &state) { + auto msg = make_serial_proxy_data_received(); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_SerialProxyDataReceived); + +#endif // USE_SERIAL_PROXY + +// --- InfraredRFReceiveEvent (100 timings, typical IR/RF capture) --- + +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) + +static const std::vector &get_ir_timings_100() { + static const std::vector timings = [] { + std::vector v; + v.reserve(100); + // Mark/space pairs simulating a typical RC-5 / NEC capture. + for (int i = 0; i < 100; i++) { + v.push_back((i % 2 == 0) ? 560 : -560); + } + return v; + }(); + return timings; +} + +static InfraredRFReceiveEvent make_infrared_rf_receive_event() { + InfraredRFReceiveEvent msg; + msg.key = 0xDEADBEEF; + msg.timings = &get_ir_timings_100(); + return msg; +} + +static void Encode_InfraredRFReceiveEvent(benchmark::State &state) { + auto msg = make_infrared_rf_receive_event(); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_InfraredRFReceiveEvent); + +static void CalculateSize_InfraredRFReceiveEvent(benchmark::State &state) { + auto msg = make_infrared_rf_receive_event(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_InfraredRFReceiveEvent); + +#endif // USE_IR_RF || USE_RADIO_FREQUENCY + } // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/stubs/esphome/components/infrared/infrared.h b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h new file mode 100644 index 0000000000..874e7a270b --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h @@ -0,0 +1,45 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp and Application need when USE_INFRARED is defined, +// without pulling in the real remote_base/RMT dependencies. +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" + +namespace esphome::infrared { + +class Infrared; + +class InfraredCall { + public: + explicit InfraredCall(Infrared *parent) : parent_(parent) {} + InfraredCall &set_carrier_frequency(uint32_t /*frequency*/) { return *this; } + InfraredCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) { + return *this; + } + InfraredCall &set_repeat_count(uint32_t /*count*/) { return *this; } + void perform() {} + + protected: + Infrared *parent_; +}; + +class InfraredTraits { + public: + uint32_t get_receiver_frequency_hz() const { return 0; } +}; + +class Infrared : public Component, public EntityBase { + public: + Infrared() = default; + InfraredTraits &get_traits() { return this->traits_; } + const InfraredTraits &get_traits() const { return this->traits_; } + InfraredCall make_call() { return InfraredCall(this); } + uint32_t get_capability_flags() const { return 0; } + + protected: + InfraredTraits traits_; +}; + +} // namespace esphome::infrared diff --git a/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h b/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h new file mode 100644 index 0000000000..72fc08034b --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h @@ -0,0 +1,51 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp and Application need when USE_RADIO_FREQUENCY is defined. +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" + +namespace esphome::radio_frequency { + +enum RadioFrequencyModulation : uint32_t { + RADIO_FREQUENCY_MODULATION_OOK = 0, +}; + +class RadioFrequency; + +class RadioFrequencyCall { + public: + explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {} + RadioFrequencyCall &set_frequency(uint32_t /*frequency*/) { return *this; } + RadioFrequencyCall &set_modulation(RadioFrequencyModulation /*mod*/) { return *this; } + RadioFrequencyCall &set_repeat_count(uint32_t /*count*/) { return *this; } + RadioFrequencyCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) { + return *this; + } + void perform() {} + + protected: + RadioFrequency *parent_; +}; + +class RadioFrequencyTraits { + public: + uint32_t get_frequency_min_hz() const { return 0; } + uint32_t get_frequency_max_hz() const { return 0; } + uint32_t get_supported_modulations() const { return 0; } +}; + +class RadioFrequency : public Component, public EntityBase { + public: + RadioFrequency() = default; + RadioFrequencyTraits &get_traits() { return this->traits_; } + const RadioFrequencyTraits &get_traits() const { return this->traits_; } + RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } + uint32_t get_capability_flags() const { return 0; } + + protected: + RadioFrequencyTraits traits_; +}; + +} // namespace esphome::radio_frequency diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h new file mode 100644 index 0000000000..d967ad58b1 --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -0,0 +1,46 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp and Application need when USE_SERIAL_PROXY is defined, +// without pulling in the real UART implementation. +#pragma once + +#include +#include +#include "esphome/components/api/api_pb2.h" + +namespace esphome { + +namespace api { +class APIConnection; +} // namespace api + +namespace uart { +enum UARTFlushResult : uint8_t { + UART_FLUSH_RESULT_SUCCESS, + UART_FLUSH_RESULT_ASSUMED_SUCCESS, + UART_FLUSH_RESULT_TIMEOUT, + UART_FLUSH_RESULT_FAILED, +}; +} // namespace uart + +namespace serial_proxy { + +class SerialProxy { + public: + void set_instance_index(uint32_t index) { this->instance_index_ = index; } + uint32_t get_instance_index() const { return this->instance_index_; } + const char *get_name() const { return ""; } + api::enums::SerialProxyPortType get_port_type() const { return {}; } + api::APIConnection *get_api_connection() { return nullptr; } + void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {} + void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint32_t stop_bits, uint32_t data_size) {} + void write_from_client(const uint8_t *data, size_t len) {} + void set_modem_pins(uint32_t line_states) {} + uint32_t get_modem_pins() const { return 0; } + uart::UARTFlushResult flush_port() { return uart::UART_FLUSH_RESULT_SUCCESS; } + + protected: + uint32_t instance_index_{0}; +}; + +} // namespace serial_proxy +} // namespace esphome diff --git a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h new file mode 100644 index 0000000000..ba97e81236 --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h @@ -0,0 +1,29 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp needs when USE_ZWAVE_PROXY is defined, +// without pulling in the real UART-based ZWaveProxy implementation. +#pragma once + +#include "esphome/components/api/api_pb2.h" + +namespace esphome { +namespace api { +class APIConnection; +} // namespace api + +namespace zwave_proxy { + +class ZWaveProxy { + public: + api::APIConnection *get_api_connection() { return nullptr; } + void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {} + void send_frame(const uint8_t *data, size_t length) {} + void api_connection_authenticated(api::APIConnection *conn) {} + uint32_t get_feature_flags() const { return 0; } + uint32_t get_home_id() { return 0; } +}; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern ZWaveProxy *global_zwave_proxy; + +} // namespace zwave_proxy +} // namespace esphome From f841de0664035892771c3c4807dabeabc16d2add Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 21:25:46 -0500 Subject: [PATCH 0924/2214] [api] Avoid lambda IIFE and per-byte APIBuffer growth in proxy benchmarks The InfraredRFReceiveEvent encode benchmark used a C++17 lambda IIFE (`[]{...}()`) to seed a function-static vector, and the InfraredRFTransmitRawTimingsRequest decode benchmark grew its APIBuffer one byte at a time (~210 grow_() calls), each allocating a fresh exact-fit buffer and memcpy'ing the prior contents. Both patterns are fine under direct execution but appear to hit a CodSpeed/valgrind edge case during the simulated benchmark run. Switch to a plain heap-init pattern for the vector and build the wire bytes into a stack array first, then resize+memcpy into the APIBuffer once. --- .../components/api/bench_proto_decode.cpp | 31 ++++++++++++------- .../components/api/bench_proto_encode.cpp | 16 +++++----- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/tests/benchmarks/components/api/bench_proto_decode.cpp b/tests/benchmarks/components/api/bench_proto_decode.cpp index 54ba00efce..0951c521be 100644 --- a/tests/benchmarks/components/api/bench_proto_decode.cpp +++ b/tests/benchmarks/components/api/bench_proto_decode.cpp @@ -1,5 +1,7 @@ #include +#include + #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_buffer.h" @@ -181,19 +183,19 @@ BENCHMARK(Decode_SerialProxyWriteRequest); #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) static APIBuffer build_infrared_rf_transmit_wire() { - APIBuffer buf; + // Build the entire wire payload into a stack buffer, then copy into the + // returned APIBuffer in a single resize+memcpy. Keeps allocation count + // low so callgrind/valgrind doesn't churn through hundreds of grow_()s. + uint8_t bytes[256]; + size_t len = 0; - auto put_byte = [&](uint8_t b) { - size_t s = buf.size(); - buf.resize(s + 1); - buf.data()[s] = b; - }; + auto put_byte = [&](uint8_t b) { bytes[len++] = b; }; auto put_varint = [&](uint32_t v) { while (v >= 0x80) { - put_byte(static_cast((v & 0x7F) | 0x80)); + bytes[len++] = static_cast((v & 0x7F) | 0x80); v >>= 7; } - put_byte(static_cast(v)); + bytes[len++] = static_cast(v); }; auto encode_zigzag = [](int32_t v) -> uint32_t { return (static_cast(v) << 1) ^ static_cast(v >> 31); @@ -212,7 +214,10 @@ static APIBuffer build_infrared_rf_transmit_wire() { put_byte(0x20); put_varint(2); // field 5: timings (packed sint32) — 100 entries alternating mark/space. - uint8_t packed[400]; + // Each entry encodes to 2 bytes (zigzag(560)=1120 → varint 0xE0 0x08), so + // packed payload is 200 bytes; with tag (1) + length varint (2) it fits in + // the 256-byte stack buffer. + uint8_t packed[200]; size_t packed_len = 0; for (int i = 0; i < 100; i++) { int32_t value = (i % 2 == 0) ? 560 : -560; @@ -225,9 +230,13 @@ static APIBuffer build_infrared_rf_transmit_wire() { } put_byte(0x2A); put_varint(static_cast(packed_len)); - for (size_t i = 0; i < packed_len; i++) - put_byte(packed[i]); + std::memcpy(bytes + len, packed, packed_len); + len += packed_len; // field 6: modulation = 0 — skip (default value, not encoded by senders) + + APIBuffer buf; + buf.resize(len); + std::memcpy(buf.data(), bytes, len); return buf; } diff --git a/tests/benchmarks/components/api/bench_proto_encode.cpp b/tests/benchmarks/components/api/bench_proto_encode.cpp index c5f2312481..d1f85574f9 100644 --- a/tests/benchmarks/components/api/bench_proto_encode.cpp +++ b/tests/benchmarks/components/api/bench_proto_encode.cpp @@ -456,17 +456,17 @@ BENCHMARK(Encode_SerialProxyDataReceived); #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +// Mark/space pairs simulating a typical RC-5 / NEC capture (100 timings). static const std::vector &get_ir_timings_100() { - static const std::vector timings = [] { - std::vector v; - v.reserve(100); - // Mark/space pairs simulating a typical RC-5 / NEC capture. + static std::vector *timings = nullptr; + if (timings == nullptr) { + timings = new std::vector(); + timings->reserve(100); for (int i = 0; i < 100; i++) { - v.push_back((i % 2 == 0) ? 560 : -560); + timings->push_back((i % 2 == 0) ? 560 : -560); } - return v; - }(); - return timings; + } + return *timings; } static InfraredRFReceiveEvent make_infrared_rf_receive_event() { From 483d294ef6f7d08eaa3a3349c89a204b628b68e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 21:38:01 -0500 Subject: [PATCH 0925/2214] [api] Move proxy message benchmarks into bench_proto_proxy.cpp Splitting these out from bench_proto_encode.cpp and bench_proto_decode.cpp moves them to the end of the linker's static-init order. CodSpeed's callgrind runner has been segfaulting immediately after measuring the last existing decode benchmark (Decode_SwitchCommandRequest), and isolating the new code into its own translation unit lets us see whether the crash is triggered by one of the new benchmarks or by something about the new USE_*_PROXY/USE_INFRARED/USE_RADIO_FREQUENCY defines changing how api_pb2.cpp compiles. --- .../components/api/bench_proto_decode.cpp | 184 ----------- .../components/api/bench_proto_encode.cpp | 124 -------- .../components/api/bench_proto_proxy.cpp | 287 ++++++++++++++++++ 3 files changed, 287 insertions(+), 308 deletions(-) create mode 100644 tests/benchmarks/components/api/bench_proto_proxy.cpp diff --git a/tests/benchmarks/components/api/bench_proto_decode.cpp b/tests/benchmarks/components/api/bench_proto_decode.cpp index 0951c521be..961c629f2a 100644 --- a/tests/benchmarks/components/api/bench_proto_decode.cpp +++ b/tests/benchmarks/components/api/bench_proto_decode.cpp @@ -1,7 +1,5 @@ #include -#include - #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_buffer.h" @@ -79,188 +77,6 @@ static void Decode_SwitchCommandRequest(benchmark::State &state) { } BENCHMARK(Decode_SwitchCommandRequest); -// --- ZWaveProxyFrame decode (~16-byte data buffer) --- - -#ifdef USE_ZWAVE_PROXY - -static void Decode_ZWaveProxyFrame(benchmark::State &state) { - static const uint8_t frame_data[] = {0x01, 0x09, 0x00, 0x13, 0x01, 0x02, 0x00, 0x00, - 0x25, 0x00, 0x05, 0xC4, 0x00, 0x00, 0x00, 0x00}; - ZWaveProxyFrame source; - source.data = frame_data; - source.data_len = sizeof(frame_data); - auto encoded = encode_message(source); - auto *data = encoded.data(); - auto size = encoded.size(); - benchmark::DoNotOptimize(data); - benchmark::DoNotOptimize(size); - - for (auto _ : state) { - for (int i = 0; i < kInnerIterations; i++) { - ZWaveProxyFrame msg; - escape(&msg); - msg.decode(data, size); - escape(&msg); - } - } - state.SetItemsProcessed(state.iterations() * kInnerIterations); -} -BENCHMARK(Decode_ZWaveProxyFrame); - -static void Decode_ZWaveProxyRequest(benchmark::State &state) { - static const uint8_t req_data[] = {0xDE, 0xAD, 0xBE, 0xEF}; - ZWaveProxyRequest source; - source.type = enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE; - source.data = req_data; - source.data_len = sizeof(req_data); - auto encoded = encode_message(source); - auto *data = encoded.data(); - auto size = encoded.size(); - benchmark::DoNotOptimize(data); - benchmark::DoNotOptimize(size); - - for (auto _ : state) { - for (int i = 0; i < kInnerIterations; i++) { - ZWaveProxyRequest msg; - escape(&msg); - msg.decode(data, size); - escape(&msg); - } - } - state.SetItemsProcessed(state.iterations() * kInnerIterations); -} -BENCHMARK(Decode_ZWaveProxyRequest); - -#endif // USE_ZWAVE_PROXY - -// --- SerialProxyWriteRequest decode (instance + 64-byte data) --- -// -// SerialProxyWriteRequest is decode-only (SOURCE_CLIENT), so we encode via -// SerialProxyDataReceived which has identical wire format -// (uint32 instance = 1; bytes data = 2;). - -#ifdef USE_SERIAL_PROXY - -static void Decode_SerialProxyWriteRequest(benchmark::State &state) { - static constexpr size_t kPayloadSize = 64; - static uint8_t payload[kPayloadSize]; - for (size_t i = 0; i < kPayloadSize; i++) - payload[i] = static_cast(i); - - SerialProxyDataReceived source; - source.instance = 0; - source.set_data(payload, kPayloadSize); - auto encoded = encode_message(source); - auto *data = encoded.data(); - auto size = encoded.size(); - benchmark::DoNotOptimize(data); - benchmark::DoNotOptimize(size); - - for (auto _ : state) { - for (int i = 0; i < kInnerIterations; i++) { - SerialProxyWriteRequest msg; - escape(&msg); - msg.decode(data, size); - escape(&msg); - } - } - state.SetItemsProcessed(state.iterations() * kInnerIterations); -} -BENCHMARK(Decode_SerialProxyWriteRequest); - -#endif // USE_SERIAL_PROXY - -// --- InfraredRFTransmitRawTimingsRequest decode (100 zigzag-encoded timings) --- -// -// Hand-built wire bytes since this message is decode-only and has no sister -// type with an identical layout. Wire format: -// field 2 (key, fixed32): tag=0x15, 4 LE bytes -// field 3 (carrier_frequency): tag=0x18, varint -// field 4 (repeat_count): tag=0x20, varint -// field 5 (timings, packed sint32): tag=0x2A, length varint, packed payload -// field 6 (modulation): tag=0x30, varint - -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) - -static APIBuffer build_infrared_rf_transmit_wire() { - // Build the entire wire payload into a stack buffer, then copy into the - // returned APIBuffer in a single resize+memcpy. Keeps allocation count - // low so callgrind/valgrind doesn't churn through hundreds of grow_()s. - uint8_t bytes[256]; - size_t len = 0; - - auto put_byte = [&](uint8_t b) { bytes[len++] = b; }; - auto put_varint = [&](uint32_t v) { - while (v >= 0x80) { - bytes[len++] = static_cast((v & 0x7F) | 0x80); - v >>= 7; - } - bytes[len++] = static_cast(v); - }; - auto encode_zigzag = [](int32_t v) -> uint32_t { - return (static_cast(v) << 1) ^ static_cast(v >> 31); - }; - - // field 2: key (fixed32) = 0xDEADBEEF - put_byte(0x15); - put_byte(0xEF); - put_byte(0xBE); - put_byte(0xAD); - put_byte(0xDE); - // field 3: carrier_frequency = 38000 - put_byte(0x18); - put_varint(38000); - // field 4: repeat_count = 2 - put_byte(0x20); - put_varint(2); - // field 5: timings (packed sint32) — 100 entries alternating mark/space. - // Each entry encodes to 2 bytes (zigzag(560)=1120 → varint 0xE0 0x08), so - // packed payload is 200 bytes; with tag (1) + length varint (2) it fits in - // the 256-byte stack buffer. - uint8_t packed[200]; - size_t packed_len = 0; - for (int i = 0; i < 100; i++) { - int32_t value = (i % 2 == 0) ? 560 : -560; - uint32_t zz = encode_zigzag(value); - while (zz >= 0x80) { - packed[packed_len++] = static_cast((zz & 0x7F) | 0x80); - zz >>= 7; - } - packed[packed_len++] = static_cast(zz); - } - put_byte(0x2A); - put_varint(static_cast(packed_len)); - std::memcpy(bytes + len, packed, packed_len); - len += packed_len; - // field 6: modulation = 0 — skip (default value, not encoded by senders) - - APIBuffer buf; - buf.resize(len); - std::memcpy(buf.data(), bytes, len); - return buf; -} - -static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state) { - auto encoded = build_infrared_rf_transmit_wire(); - auto *data = encoded.data(); - auto size = encoded.size(); - benchmark::DoNotOptimize(data); - benchmark::DoNotOptimize(size); - - for (auto _ : state) { - for (int i = 0; i < kInnerIterations; i++) { - InfraredRFTransmitRawTimingsRequest msg; - escape(&msg); - msg.decode(data, size); - escape(&msg); - } - } - state.SetItemsProcessed(state.iterations() * kInnerIterations); -} -BENCHMARK(Decode_InfraredRFTransmitRawTimingsRequest); - -#endif // USE_IR_RF || USE_RADIO_FREQUENCY - // --- LightCommandRequest decode (complex command with many fields) --- static void Decode_LightCommandRequest(benchmark::State &state) { diff --git a/tests/benchmarks/components/api/bench_proto_encode.cpp b/tests/benchmarks/components/api/bench_proto_encode.cpp index d1f85574f9..1e2efcd281 100644 --- a/tests/benchmarks/components/api/bench_proto_encode.cpp +++ b/tests/benchmarks/components/api/bench_proto_encode.cpp @@ -384,128 +384,4 @@ BENCHMARK(CalcAndEncode_BLERawAdvs12_Fresh); #endif // USE_BLUETOOTH_PROXY -// --- ZWaveProxyFrame (Z-Wave frame, ~16 bytes payload) --- - -#ifdef USE_ZWAVE_PROXY - -static constexpr uint8_t kZWaveFrameData[] = {0x01, 0x09, 0x00, 0x13, 0x01, 0x02, 0x00, 0x00, - 0x25, 0x00, 0x05, 0xC4, 0x00, 0x00, 0x00, 0x00}; - -static ZWaveProxyFrame make_zwave_proxy_frame() { - ZWaveProxyFrame msg; - msg.data = kZWaveFrameData; - msg.data_len = sizeof(kZWaveFrameData); - return msg; -} - -static void Encode_ZWaveProxyFrame(benchmark::State &state) { - auto msg = make_zwave_proxy_frame(); - APIBuffer buffer; - buffer.resize(msg.calculate_size()); - - for (auto _ : state) { - for (int i = 0; i < kInnerIterations; i++) { - ProtoWriteBuffer writer(&buffer, 0); - msg.encode(writer); - } - benchmark::DoNotOptimize(buffer.data()); - } - state.SetItemsProcessed(state.iterations() * kInnerIterations); -} -BENCHMARK(Encode_ZWaveProxyFrame); - -#endif // USE_ZWAVE_PROXY - -// --- SerialProxyDataReceived (serial passthrough, 64-byte payload) --- - -#ifdef USE_SERIAL_PROXY - -static constexpr size_t kSerialPayloadSize = 64; -static const uint8_t kSerialPayload[kSerialPayloadSize] = { - 0x55, 0xAA, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, - 0xCD, 0xEF, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, - 0xFF, 0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0, - 0xF0, 0x0F, 0x1F, 0x2F, 0x3F, 0x4F, 0x5F, 0x6F, 0x7F, 0x8F, 0x9F, 0xAF, 0xBF, 0xCF, 0xDF, 0xEF}; - -static SerialProxyDataReceived make_serial_proxy_data_received() { - SerialProxyDataReceived msg; - msg.instance = 0; - msg.set_data(kSerialPayload, kSerialPayloadSize); - return msg; -} - -static void Encode_SerialProxyDataReceived(benchmark::State &state) { - auto msg = make_serial_proxy_data_received(); - APIBuffer buffer; - buffer.resize(msg.calculate_size()); - - for (auto _ : state) { - for (int i = 0; i < kInnerIterations; i++) { - ProtoWriteBuffer writer(&buffer, 0); - msg.encode(writer); - } - benchmark::DoNotOptimize(buffer.data()); - } - state.SetItemsProcessed(state.iterations() * kInnerIterations); -} -BENCHMARK(Encode_SerialProxyDataReceived); - -#endif // USE_SERIAL_PROXY - -// --- InfraredRFReceiveEvent (100 timings, typical IR/RF capture) --- - -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) - -// Mark/space pairs simulating a typical RC-5 / NEC capture (100 timings). -static const std::vector &get_ir_timings_100() { - static std::vector *timings = nullptr; - if (timings == nullptr) { - timings = new std::vector(); - timings->reserve(100); - for (int i = 0; i < 100; i++) { - timings->push_back((i % 2 == 0) ? 560 : -560); - } - } - return *timings; -} - -static InfraredRFReceiveEvent make_infrared_rf_receive_event() { - InfraredRFReceiveEvent msg; - msg.key = 0xDEADBEEF; - msg.timings = &get_ir_timings_100(); - return msg; -} - -static void Encode_InfraredRFReceiveEvent(benchmark::State &state) { - auto msg = make_infrared_rf_receive_event(); - APIBuffer buffer; - buffer.resize(msg.calculate_size()); - - for (auto _ : state) { - for (int i = 0; i < kInnerIterations; i++) { - ProtoWriteBuffer writer(&buffer, 0); - msg.encode(writer); - } - benchmark::DoNotOptimize(buffer.data()); - } - state.SetItemsProcessed(state.iterations() * kInnerIterations); -} -BENCHMARK(Encode_InfraredRFReceiveEvent); - -static void CalculateSize_InfraredRFReceiveEvent(benchmark::State &state) { - auto msg = make_infrared_rf_receive_event(); - - for (auto _ : state) { - uint32_t result = 0; - for (int i = 0; i < kInnerIterations; i++) { - result += msg.calculate_size(); - } - benchmark::DoNotOptimize(result); - } - state.SetItemsProcessed(state.iterations() * kInnerIterations); -} -BENCHMARK(CalculateSize_InfraredRFReceiveEvent); - -#endif // USE_IR_RF || USE_RADIO_FREQUENCY - } // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp new file mode 100644 index 0000000000..612d8e69f3 --- /dev/null +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -0,0 +1,287 @@ +// Encode/decode microbenchmarks for proxy message families that carry +// high-volume traffic (Z-Wave, IR/RF, serial). Mirrors the existing +// BluetoothLERawAdvertisementsResponse benchmarks in bench_proto_encode.cpp. + +#include + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::benchmarks { + +static constexpr int kInnerIterations = 2000; + +template static APIBuffer encode_message_for_proxy(const T &msg) { + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + return buffer; +} + +static void escape_proxy(void *p) { asm volatile("" : : "g"(p) : "memory"); } + +// --- ZWaveProxyFrame (Z-Wave frame, ~16 bytes payload) --- + +#ifdef USE_ZWAVE_PROXY + +static const uint8_t kZWaveFrameData[] = {0x01, 0x09, 0x00, 0x13, 0x01, 0x02, 0x00, 0x00, + 0x25, 0x00, 0x05, 0xC4, 0x00, 0x00, 0x00, 0x00}; + +static void Encode_ZWaveProxyFrame(benchmark::State &state) { + ZWaveProxyFrame msg; + msg.data = kZWaveFrameData; + msg.data_len = sizeof(kZWaveFrameData); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_ZWaveProxyFrame); + +static void Decode_ZWaveProxyFrame(benchmark::State &state) { + ZWaveProxyFrame source; + source.data = kZWaveFrameData; + source.data_len = sizeof(kZWaveFrameData); + auto encoded = encode_message_for_proxy(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ZWaveProxyFrame msg; + escape_proxy(&msg); + msg.decode(data, size); + escape_proxy(&msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_ZWaveProxyFrame); + +static const uint8_t kZWaveRequestData[] = {0xDE, 0xAD, 0xBE, 0xEF}; + +static void Decode_ZWaveProxyRequest(benchmark::State &state) { + ZWaveProxyRequest source; + source.type = enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE; + source.data = kZWaveRequestData; + source.data_len = sizeof(kZWaveRequestData); + auto encoded = encode_message_for_proxy(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ZWaveProxyRequest msg; + escape_proxy(&msg); + msg.decode(data, size); + escape_proxy(&msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_ZWaveProxyRequest); + +#endif // USE_ZWAVE_PROXY + +// --- SerialProxyDataReceived encode + SerialProxyWriteRequest decode --- +// +// SerialProxyWriteRequest is decode-only (SOURCE_CLIENT) but has the same +// wire layout as SerialProxyDataReceived, so we encode via the latter and +// decode as the former. + +#ifdef USE_SERIAL_PROXY + +static constexpr size_t kSerialPayloadSize = 64; +static const uint8_t kSerialPayload[kSerialPayloadSize] = { + 0x55, 0xAA, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, + 0xCD, 0xEF, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, + 0xFF, 0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0, + 0xF0, 0x0F, 0x1F, 0x2F, 0x3F, 0x4F, 0x5F, 0x6F, 0x7F, 0x8F, 0x9F, 0xAF, 0xBF, 0xCF, 0xDF, 0xEF}; + +static void Encode_SerialProxyDataReceived(benchmark::State &state) { + SerialProxyDataReceived msg; + msg.instance = 0; + msg.set_data(kSerialPayload, kSerialPayloadSize); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_SerialProxyDataReceived); + +static void Decode_SerialProxyWriteRequest(benchmark::State &state) { + SerialProxyDataReceived source; + source.instance = 0; + source.set_data(kSerialPayload, kSerialPayloadSize); + auto encoded = encode_message_for_proxy(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + SerialProxyWriteRequest msg; + escape_proxy(&msg); + msg.decode(data, size); + escape_proxy(&msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_SerialProxyWriteRequest); + +#endif // USE_SERIAL_PROXY + +// --- InfraredRFReceiveEvent encode (100 sint32 timings) + +// InfraredRFTransmitRawTimingsRequest decode (hand-built wire bytes) --- + +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) + +// Heap-allocated on first use to avoid C++17 lambda IIFE patterns that some +// callgrind/valgrind versions handle awkwardly during benchmark init. +static const std::vector &get_ir_timings_100() { + static std::vector *timings = nullptr; + if (timings == nullptr) { + timings = new std::vector(); + timings->reserve(100); + for (int i = 0; i < 100; i++) { + timings->push_back((i % 2 == 0) ? 560 : -560); + } + } + return *timings; +} + +static void Encode_InfraredRFReceiveEvent(benchmark::State &state) { + InfraredRFReceiveEvent msg; + msg.key = 0xDEADBEEF; + msg.timings = &get_ir_timings_100(); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_InfraredRFReceiveEvent); + +static void CalculateSize_InfraredRFReceiveEvent(benchmark::State &state) { + InfraredRFReceiveEvent msg; + msg.key = 0xDEADBEEF; + msg.timings = &get_ir_timings_100(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_InfraredRFReceiveEvent); + +// Hand-built wire bytes for InfraredRFTransmitRawTimingsRequest (decode-only, +// no sister message with identical wire layout). +// field 2 (key, fixed32): tag=0x15, 4 LE bytes +// field 3 (carrier_frequency): tag=0x18, varint +// field 4 (repeat_count): tag=0x20, varint +// field 5 (timings, packed sint32): tag=0x2A, length varint, packed payload +// field 6 (modulation): tag=0x30, varint +static APIBuffer build_infrared_rf_transmit_wire() { + uint8_t bytes[256]; + size_t len = 0; + + auto put_byte = [&](uint8_t b) { bytes[len++] = b; }; + auto put_varint = [&](uint32_t v) { + while (v >= 0x80) { + bytes[len++] = static_cast((v & 0x7F) | 0x80); + v >>= 7; + } + bytes[len++] = static_cast(v); + }; + auto encode_zigzag = [](int32_t v) -> uint32_t { + return (static_cast(v) << 1) ^ static_cast(v >> 31); + }; + + put_byte(0x15); + put_byte(0xEF); + put_byte(0xBE); + put_byte(0xAD); + put_byte(0xDE); + put_byte(0x18); + put_varint(38000); + put_byte(0x20); + put_varint(2); + + uint8_t packed[200]; + size_t packed_len = 0; + for (int i = 0; i < 100; i++) { + int32_t value = (i % 2 == 0) ? 560 : -560; + uint32_t zz = encode_zigzag(value); + while (zz >= 0x80) { + packed[packed_len++] = static_cast((zz & 0x7F) | 0x80); + zz >>= 7; + } + packed[packed_len++] = static_cast(zz); + } + put_byte(0x2A); + put_varint(static_cast(packed_len)); + std::memcpy(bytes + len, packed, packed_len); + len += packed_len; + + APIBuffer buf; + buf.resize(len); + std::memcpy(buf.data(), bytes, len); + return buf; +} + +static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state) { + auto encoded = build_infrared_rf_transmit_wire(); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + InfraredRFTransmitRawTimingsRequest msg; + escape_proxy(&msg); + msg.decode(data, size); + escape_proxy(&msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_InfraredRFTransmitRawTimingsRequest); + +#endif // USE_IR_RF || USE_RADIO_FREQUENCY + +} // namespace esphome::api::benchmarks From a0532d657f02f276ed247f2b3944fa04dcbfcd71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 21:46:32 -0500 Subject: [PATCH 0926/2214] [api] Drop escape() helper and return-by-value APIBuffer in proxy decode benchmarks Simplifies the decode benchmarks to mirror the encode pattern more closely: no per-iteration asm volatile barrier, no return-by-value of APIBuffer through encode_message_for_proxy. CodSpeed callgrind has been crashing inside Decode_ZWaveProxyFrame and the previous setup was the main thing it had that the (passing) Encode_ZWaveProxyFrame did not. --- .../components/api/bench_proto_proxy.cpp | 60 ++++++++----------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp index 612d8e69f3..ae7fc0722c 100644 --- a/tests/benchmarks/components/api/bench_proto_proxy.cpp +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -13,17 +13,14 @@ namespace esphome::api::benchmarks { static constexpr int kInnerIterations = 2000; -template static APIBuffer encode_message_for_proxy(const T &msg) { - APIBuffer buffer; - uint32_t size = msg.calculate_size(); - buffer.resize(size); - ProtoWriteBuffer writer(&buffer, 0); - msg.encode(writer); - return buffer; +// Encodes `src` into `out`. Caller owns `out` and must keep it alive across +// the decode loop (decoded messages may store pointers back into its bytes). +template static void encode_into(APIBuffer &out, const T &src) { + out.resize(src.calculate_size()); + ProtoWriteBuffer writer(&out, 0); + src.encode(writer); } -static void escape_proxy(void *p) { asm volatile("" : : "g"(p) : "memory"); } - // --- ZWaveProxyFrame (Z-Wave frame, ~16 bytes payload) --- #ifdef USE_ZWAVE_PROXY @@ -53,18 +50,16 @@ static void Decode_ZWaveProxyFrame(benchmark::State &state) { ZWaveProxyFrame source; source.data = kZWaveFrameData; source.data_len = sizeof(kZWaveFrameData); - auto encoded = encode_message_for_proxy(source); - auto *data = encoded.data(); - auto size = encoded.size(); - benchmark::DoNotOptimize(data); - benchmark::DoNotOptimize(size); + APIBuffer encoded; + encode_into(encoded, source); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { ZWaveProxyFrame msg; - escape_proxy(&msg); msg.decode(data, size); - escape_proxy(&msg); + benchmark::DoNotOptimize(msg); } } state.SetItemsProcessed(state.iterations() * kInnerIterations); @@ -78,18 +73,16 @@ static void Decode_ZWaveProxyRequest(benchmark::State &state) { source.type = enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE; source.data = kZWaveRequestData; source.data_len = sizeof(kZWaveRequestData); - auto encoded = encode_message_for_proxy(source); - auto *data = encoded.data(); - auto size = encoded.size(); - benchmark::DoNotOptimize(data); - benchmark::DoNotOptimize(size); + APIBuffer encoded; + encode_into(encoded, source); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { ZWaveProxyRequest msg; - escape_proxy(&msg); msg.decode(data, size); - escape_proxy(&msg); + benchmark::DoNotOptimize(msg); } } state.SetItemsProcessed(state.iterations() * kInnerIterations); @@ -135,18 +128,16 @@ static void Decode_SerialProxyWriteRequest(benchmark::State &state) { SerialProxyDataReceived source; source.instance = 0; source.set_data(kSerialPayload, kSerialPayloadSize); - auto encoded = encode_message_for_proxy(source); - auto *data = encoded.data(); - auto size = encoded.size(); - benchmark::DoNotOptimize(data); - benchmark::DoNotOptimize(size); + APIBuffer encoded; + encode_into(encoded, source); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { SerialProxyWriteRequest msg; - escape_proxy(&msg); msg.decode(data, size); - escape_proxy(&msg); + benchmark::DoNotOptimize(msg); } } state.SetItemsProcessed(state.iterations() * kInnerIterations); @@ -265,17 +256,14 @@ static APIBuffer build_infrared_rf_transmit_wire() { static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state) { auto encoded = build_infrared_rf_transmit_wire(); - auto *data = encoded.data(); - auto size = encoded.size(); - benchmark::DoNotOptimize(data); - benchmark::DoNotOptimize(size); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { InfraredRFTransmitRawTimingsRequest msg; - escape_proxy(&msg); msg.decode(data, size); - escape_proxy(&msg); + benchmark::DoNotOptimize(msg); } } state.SetItemsProcessed(state.iterations() * kInnerIterations); From 8c0e5e9d9a180f8331dd04eb310fa9e8563e18b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 22:40:45 -0500 Subject: [PATCH 0927/2214] [api] Address Copilot review on proxy benchmarks PR - Make UARTFlushResult in the serial_proxy stub a scoped enum class with matching scoped enumerator return in flush_port(), so the stub signature lines up with the real esphome::uart::UARTFlushResult. - Replace heap-leaking lazy-init in get_ir_timings_100() with a function-local static const std::vector populated by a regular helper function. Same lazy-init behavior, no leak in valgrind/ASan, no lambda IIFE. - Emit field 6 (modulation = 1) in build_infrared_rf_transmit_wire() so the bytes match the documented field list and the decode benchmark also exercises the field-6 decode_varint path. --- .../components/api/bench_proto_proxy.cpp | 27 +++++++++++-------- .../components/serial_proxy/serial_proxy.h | 4 +-- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp index ae7fc0722c..fa3191a969 100644 --- a/tests/benchmarks/components/api/bench_proto_proxy.cpp +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -151,18 +151,19 @@ BENCHMARK(Decode_SerialProxyWriteRequest); #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) -// Heap-allocated on first use to avoid C++17 lambda IIFE patterns that some -// callgrind/valgrind versions handle awkwardly during benchmark init. -static const std::vector &get_ir_timings_100() { - static std::vector *timings = nullptr; - if (timings == nullptr) { - timings = new std::vector(); - timings->reserve(100); - for (int i = 0; i < 100; i++) { - timings->push_back((i % 2 == 0) ? 560 : -560); - } +// Mark/space pairs simulating a typical RC-5 / NEC capture (100 timings). +static std::vector make_ir_timings_100() { + std::vector v; + v.reserve(100); + for (int i = 0; i < 100; i++) { + v.push_back((i % 2 == 0) ? 560 : -560); } - return *timings; + return v; +} + +static const std::vector &get_ir_timings_100() { + static const std::vector timings = make_ir_timings_100(); + return timings; } static void Encode_InfraredRFReceiveEvent(benchmark::State &state) { @@ -247,6 +248,10 @@ static APIBuffer build_infrared_rf_transmit_wire() { put_varint(static_cast(packed_len)); std::memcpy(bytes + len, packed, packed_len); len += packed_len; + // field 6: modulation = 1 (non-zero so it's actually emitted and exercises + // decode_varint for this field, matching the documented layout above). + put_byte(0x30); + put_varint(1); APIBuffer buf; buf.resize(len); diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h index d967ad58b1..bab27549e7 100644 --- a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -14,7 +14,7 @@ class APIConnection; } // namespace api namespace uart { -enum UARTFlushResult : uint8_t { +enum class UARTFlushResult : uint8_t { UART_FLUSH_RESULT_SUCCESS, UART_FLUSH_RESULT_ASSUMED_SUCCESS, UART_FLUSH_RESULT_TIMEOUT, @@ -36,7 +36,7 @@ class SerialProxy { void write_from_client(const uint8_t *data, size_t len) {} void set_modem_pins(uint32_t line_states) {} uint32_t get_modem_pins() const { return 0; } - uart::UARTFlushResult flush_port() { return uart::UART_FLUSH_RESULT_SUCCESS; } + uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } protected: uint32_t instance_index_{0}; From 1d4dbf5476c56dd4740a7bb5ac405a13550968f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 22:05:45 -0500 Subject: [PATCH 0928/2214] [api] Mark InfraredRFReceiveEvent encode/calculate_size as speed_optimized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This message is emitted on every IR/RF receive event with a packed sint32 timings array (typically ~50-200 entries). The encode walks the vector calling ProtoEncode::encode_sint32 per element; under -Os GCC does not inline those helpers, so each element pays the call overhead. Adding option (speed_optimized) = true to the proto definition causes the codegen to emit __attribute__((optimize("O2"))) on encode and calculate_size, matching what already exists on SensorStateResponse, SubscribeLogsResponse, and BluetoothLERawAdvertisementsResponse — the other high-volume server-emitted messages. The CodSpeed Encode_InfraredRFReceiveEvent / CalculateSize_InfraredRFReceiveEvent benchmarks added in the parent PR will quantify the improvement. --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.cpp | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 391efbd6eb..6705a30526 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2571,6 +2571,7 @@ message InfraredRFReceiveEvent { option (source) = SOURCE_SERVER; option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; option (no_delay) = true; + option (speed_optimized) = true; uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index eb25bf7461..95bdb994d3 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3910,7 +3910,9 @@ bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto3 } return true; } -uint8_t *InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_DEVICES ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); @@ -3921,7 +3923,9 @@ uint8_t *InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DE } return pos; } -uint32_t InfraredRFReceiveEvent::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +InfraredRFReceiveEvent::calculate_size() const { uint32_t size = 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); From 663f8560763ca17ed405c0b0ecec15c1381eb3bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 22:15:09 -0500 Subject: [PATCH 0929/2214] [api] Mark ZWaveProxyFrame and SerialProxyDataReceived as speed_optimized Both messages are unconditionally exercised in the proxy hot paths (every Z-Wave frame received from the controller; every UART read on a configured serial proxy), so the modest flash cost of forcing -O2 on their encode/calculate_size pays off on every device that has the proxy enabled. Same treatment as InfraredRFReceiveEvent in the prior commit and the other high-volume server-emitted messages. --- esphome/components/api/api.proto | 2 ++ esphome/components/api/api_pb2.cpp | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 6705a30526..4d72be5407 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2511,6 +2511,7 @@ message ZWaveProxyFrame { option (source) = SOURCE_BOTH; option (ifdef) = "USE_ZWAVE_PROXY"; option (no_delay) = true; + option (speed_optimized) = true; bytes data = 1; } @@ -2628,6 +2629,7 @@ message SerialProxyDataReceived { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SERIAL_PROXY"; option (no_delay) = true; + option (speed_optimized) = true; uint32 instance = 1; // Instance index (0-based) bytes data = 2; // Raw data received from the serial device diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 95bdb994d3..68be7550ee 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3784,12 +3784,16 @@ bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited valu } return true; } -uint8_t *ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); return pos; } -uint32_t ZWaveProxyFrame::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +ZWaveProxyFrame::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->data_len); return size; @@ -4005,13 +4009,17 @@ bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_ } return true; } -uint8_t *SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +SerialProxyDataReceived::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->instance); ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); return pos; } -uint32_t SerialProxyDataReceived::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +SerialProxyDataReceived::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->instance); size += ProtoSize::calc_length(1, this->data_len_); From f0bffed3c04c75f65513a4405914e093340d5782 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 22:42:17 -0500 Subject: [PATCH 0930/2214] [esp8266] Move HAL bodies into components/esp8266/hal.cpp + inline arch_init (#16112) --- esphome/components/esp8266/core.cpp | 98 +----------------------- esphome/components/esp8266/hal.cpp | 111 ++++++++++++++++++++++++++++ esphome/core/hal.h | 7 +- esphome/core/hal/hal_esp32.h | 3 + esphome/core/hal/hal_esp8266.h | 8 +- esphome/core/hal/hal_host.h | 2 + esphome/core/hal/hal_libretiny.h | 2 + esphome/core/hal/hal_rp2040.h | 2 + esphome/core/hal/hal_zephyr.h | 2 + 9 files changed, 133 insertions(+), 102 deletions(-) create mode 100644 esphome/components/esp8266/hal.cpp diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 9161ca6aaf..b9ad4082e9 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -3,98 +3,12 @@ #include "core.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#include "esphome/core/time_64.h" -#include "esphome/core/helpers.h" -#include "preferences.h" #include -#include - -extern "C" { -#include -} namespace esphome { -// yield(), micros(), millis_64() inlined in hal.h. -// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit -// multiplies on the LX106). Tracks a running ms counter from 32-bit -// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis -// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. -// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static -// state against ISR re-entry; the critical section is bounded (≤10 while-loop -// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on -// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level -// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). -// -// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles -// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a -// >71 min block would trip the watchdog long before it could matter here. -static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; -static constexpr uint32_t US_PER_MS = 1000; - -uint32_t IRAM_ATTR HOT millis() { - // Struct packs the three statics so the compiler loads one base address - // instead of three separate literal pool entries (saves ~8 bytes IRAM). - static struct { - uint32_t cache; - uint32_t remainder; - uint32_t last_us; - } state = {0, 0, 0}; - uint32_t ps = xt_rsil(15); - uint32_t now_us = system_get_time(); - uint32_t delta = now_us - state.last_us; - state.last_us = now_us; - state.remainder += delta; - if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { - // Rare path: large gap (WiFi scan, boot, long block). Constant-time - // conversion keeps the critical section bounded. - uint32_t ms = state.remainder / US_PER_MS; - state.cache += ms; - // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a - // second __umodsi3 call on the LX106 (no hardware divide). - state.remainder -= ms * US_PER_MS; - } else { - // Common path: small gap. At most ~10 iterations since remainder was - // < threshold (10 ms) on entry and delta adds at most one more threshold - // before exiting this branch. - while (state.remainder >= US_PER_MS) { - state.cache++; - state.remainder -= US_PER_MS; - } - } - uint32_t result = state.cache; - xt_wsr_ps(ps); - return result; -} -// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object -// call to the original millis() that --wrap can't intercept, so calling ::delay() -// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still -// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and -// WiFi run correctly. Theoretically less power-efficient than Arduino's -// os_timer-based delay() for long waits, but nearly all ESPHome delays are short -// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is -// negligible. -void HOT delay(uint32_t ms) { - if (ms == 0) { - optimistic_yield(1000); - return; - } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } -} -// delayMicroseconds(), arch_feed_wdt(), and progmem_read_*() are inlined in hal/hal_esp8266.h. -void arch_restart() { - system_restart(); - // restart() doesn't always end execution - while (true) { // NOLINT(clang-diagnostic-unreachable-code) - yield(); - } -} -void arch_init() {} -uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } -uint32_t arch_get_cpu_freq_hz() { return F_CPU; } +// HAL functions live in hal.cpp. This file keeps only the ESP8266-specific +// firmware bootstrap (Tasmota OTA magic bytes, optional GPIO pre-init). void force_link_symbols() { // Tasmota uses magic bytes in the binary to check if an OTA firmware is compatible @@ -131,12 +45,4 @@ extern "C" void resetPins() { // NOLINT } // namespace esphome -// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. -// Requires -Wl,--wrap=millis in build flags (added by __init__.py). -// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } -// Note: Arduino's init() registers a 60-second overflow timer for micros64(). -// We leave it running — wrapping init() as a no-op would break micros64()'s -// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). - #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp new file mode 100644 index 0000000000..56910e5b39 --- /dev/null +++ b/esphome/components/esp8266/hal.cpp @@ -0,0 +1,111 @@ +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +#include +#include + +extern "C" { +#include +} + +// Empty esp8266 namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// esp8266 component's API. +namespace esphome::esp8266 {} // namespace esphome::esp8266 + +namespace esphome { + +// yield(), micros(), millis_64(), delayMicroseconds(), arch_feed_wdt(), +// progmem_read_*() are inlined in core/hal/hal_esp8266.h. +// +// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit +// multiplies on the LX106). Tracks a running ms counter from 32-bit +// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis +// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. +// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static +// state against ISR re-entry; the critical section is bounded (≤10 while-loop +// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on +// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level +// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). +// +// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles +// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a +// >71 min block would trip the watchdog long before it could matter here. +static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; +static constexpr uint32_t US_PER_MS = 1000; + +uint32_t IRAM_ATTR HOT millis() { + // Struct packs the three statics so the compiler loads one base address + // instead of three separate literal pool entries (saves ~8 bytes IRAM). + static struct { + uint32_t cache; + uint32_t remainder; + uint32_t last_us; + } state = {0, 0, 0}; + uint32_t ps = xt_rsil(15); + uint32_t now_us = system_get_time(); + uint32_t delta = now_us - state.last_us; + state.last_us = now_us; + state.remainder += delta; + if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { + // Rare path: large gap (WiFi scan, boot, long block). Constant-time + // conversion keeps the critical section bounded. + uint32_t ms = state.remainder / US_PER_MS; + state.cache += ms; + // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a + // second __umodsi3 call on the LX106 (no hardware divide). + state.remainder -= ms * US_PER_MS; + } else { + // Common path: small gap. At most ~10 iterations since remainder was + // < threshold (10 ms) on entry and delta adds at most one more threshold + // before exiting this branch. + while (state.remainder >= US_PER_MS) { + state.cache++; + state.remainder -= US_PER_MS; + } + } + uint32_t result = state.cache; + xt_wsr_ps(ps); + return result; +} + +// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object +// call to the original millis() that --wrap can't intercept, so calling ::delay() +// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still +// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and +// WiFi run correctly. Theoretically less power-efficient than Arduino's +// os_timer-based delay() for long waits, but nearly all ESPHome delays are short +// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is +// negligible. +void HOT delay(uint32_t ms) { + if (ms == 0) { + optimistic_yield(1000); + return; + } + uint32_t start = millis(); + while (millis() - start < ms) { + optimistic_yield(1000); + } +} + +void arch_restart() { + system_restart(); + // restart() doesn't always end execution + while (true) { // NOLINT(clang-diagnostic-unreachable-code) + yield(); + } +} + +} // namespace esphome + +// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. +// Requires -Wl,--wrap=millis in build flags (added by __init__.py). +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } +// Note: Arduino's init() registers a 60-second overflow timer for micros64(). +// We leave it running — wrapping init() as a no-op would break micros64()'s +// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). + +#endif // USE_ESP8266 diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 312effa7b0..a53296979c 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -31,11 +31,10 @@ namespace esphome { // Cross-platform declarations. delayMicroseconds(), arch_feed_wdt(), -// arch_get_cpu_cycle_count() vary per platform (some inline, some -// out-of-line) so they live in hal/hal_.h. +// arch_get_cpu_cycle_count(), arch_init(), arch_get_cpu_freq_hz() vary +// per platform (some inline, some out-of-line) so they live in +// hal/hal_.h. void __attribute__((noreturn)) arch_restart(); -void arch_init(); -uint32_t arch_get_cpu_freq_hz(); #ifndef USE_ESP8266 // All non-ESP8266 platforms: PROGMEM is a no-op, so these are direct dereferences. diff --git a/esphome/core/hal/hal_esp32.h b/esphome/core/hal/hal_esp32.h index 2bff424441..d5d7752bf6 100644 --- a/esphome/core/hal/hal_esp32.h +++ b/esphome/core/hal/hal_esp32.h @@ -42,6 +42,9 @@ __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { dela __attribute__((always_inline)) inline void arch_feed_wdt() { esp_task_wdt_reset(); } __attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } +void arch_init(); +uint32_t arch_get_cpu_freq_hz(); + } // namespace esphome #endif // USE_ESP32 diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h index 484118f1f5..b6e3b1ee3c 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/core/hal/hal_esp8266.h @@ -3,6 +3,7 @@ #ifdef USE_ESP8266 #include +#include #include #include @@ -59,8 +60,11 @@ __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_ // NOLINTNEXTLINE(readability-identifier-naming) __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } __attribute__((always_inline)) inline void arch_feed_wdt() { system_soft_wdt_feed(); } - -uint32_t arch_get_cpu_cycle_count(); +__attribute__((always_inline)) inline void arch_init() {} +// esp_get_cycle_count() declared in ; F_CPU is a +// compiler-driven macro from the ESP8266 Arduino board defs (-DF_CPU=...). +__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return F_CPU; } } // namespace esphome diff --git a/esphome/core/hal/hal_host.h b/esphome/core/hal/hal_host.h index 682a1a422b..a8896fdf63 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/core/hal/hal_host.h @@ -22,6 +22,8 @@ uint64_t millis_64(); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index e9d33b7753..ecfe830fe3 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -91,6 +91,8 @@ __attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/core/hal/hal_rp2040.h index 2a1d67b4a3..46f6e421cd 100644 --- a/esphome/core/hal/hal_rp2040.h +++ b/esphome/core/hal/hal_rp2040.h @@ -38,6 +38,8 @@ __attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_mi void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/core/hal/hal_zephyr.h index 6707c85b2c..d4b37b5eb6 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/core/hal/hal_zephyr.h @@ -22,6 +22,8 @@ uint64_t millis_64(); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome From e127268dac9b2072b9abd8174f2ba804dfad0b90 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:04:52 +1200 Subject: [PATCH 0931/2214] [core] Strip \\?\ prefix from sys.executable for PlatformIO subprocess (#16158) --- esphome/platformio_api.py | 44 ++++++++++- tests/unit_tests/test_platformio_api.py | 99 +++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index dec541985f..c0cd048890 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -14,6 +14,37 @@ from esphome.util import run_external_process _LOGGER = logging.getLogger(__name__) +def _strip_win_long_path_prefix(path: str) -> str: + r"""Strip the Windows extended-length path prefix from ``path``. + + Handles both forms documented at + https://learn.microsoft.com/windows/win32/fileio/naming-a-file: + + * ``\\?\C:\path\to\file`` -> ``C:\path\to\file`` + * ``\\?\UNC\server\share\path`` -> ``\\server\share\path`` + + The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with + ``sys.executable`` already prefixed with ``\\?\``. That prefix propagates + into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from + the environment, falling back to ``os.path.normpath(sys.executable)``) + and ends up baked into SCons-emitted command lines for build steps such + as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand + the ``\\?\`` prefix, so the build fails with + "The system cannot find the path specified." Stripping the prefix early + keeps the path shell-quotable. + + No-op on non-Windows platforms. + """ + if sys.platform != "win32": + return path + if path.startswith("\\\\?\\UNC\\"): + # \\?\UNC\server\share\... -> \\server\share\... + return "\\\\" + path[len("\\\\?\\UNC\\") :] + if path.startswith("\\\\?\\"): + return path[len("\\\\?\\") :] + return path + + def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) @@ -24,7 +55,18 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") - cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args) + # Strip the Windows extended-length path prefix from sys.executable so it + # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted + # command lines run through cmd.exe. + python_exe = _strip_win_long_path_prefix(sys.executable) + if python_exe != sys.executable: + # Only override PYTHONEXEPATH when we actually stripped a prefix. + # PlatformIO's get_pythonexe_path() reads this and falls back to + # sys.executable otherwise; setting it unconditionally would clobber + # a user-provided value (or the unmodified path on platforms that + # don't need the strip). + os.environ["PYTHONEXEPATH"] = python_exe + cmd = [python_exe, "-m", "esphome.platformio_runner"] + list(args) return run_external_process(*cmd, **kwargs) diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 67e64e5f61..b241622f89 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -311,6 +311,105 @@ def test_run_platformio_cli_sets_environment_variables( assert "arg" in args +@pytest.mark.parametrize( + ("platform", "input_path", "expected"), + [ + # win32: drive-letter extended-length prefix is stripped + ( + "win32", + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + ), + # win32: UNC extended-length prefix is translated to a regular UNC path + ( + "win32", + "\\\\?\\UNC\\server\\share\\python.exe", + "\\\\server\\share\\python.exe", + ), + # win32: paths without the prefix are returned unchanged + ( + "win32", + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + ), + # non-win32: prefix is left alone (no-op) + ("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"), + ("darwin", "/usr/bin/python3", "/usr/bin/python3"), + ], +) +def test_strip_win_long_path_prefix( + platform: str, input_path: str, expected: str +) -> None: + r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32.""" + with patch("esphome.platformio_api.sys.platform", platform): + assert platformio_api._strip_win_long_path_prefix(input_path) == expected + + +def test_run_platformio_cli_strips_win_long_path_prefix( + setup_core: Path, mock_run_external_process: Mock +) -> None: + r"""Windows ``\\?\`` prefix on sys.executable does not leak into the subprocess. + + The NSIS-installed esphome.exe launcher starts Python with + ``sys.executable`` already prefixed by the extended-length path marker. + That prefix would otherwise propagate into PlatformIO's ``PYTHONEXE`` and + break SCons-emitted command lines run through ``cmd.exe``. + """ + CORE.build_path = str(setup_core / "build" / "test") + prefixed_exe = ( + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe" + ) + stripped_exe = ( + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe" + ) + + with ( + patch.dict(os.environ, {}, clear=False), + patch("esphome.platformio_api.sys.platform", "win32"), + patch("esphome.platformio_api.sys.executable", prefixed_exe), + ): + # Pop any pre-existing PYTHONEXEPATH so the assertion below reflects + # what run_platformio_cli set, not whatever the test runner's + # environment happened to contain. + os.environ.pop("PYTHONEXEPATH", None) + mock_run_external_process.return_value = 0 + platformio_api.run_platformio_cli("test", "arg") + + # The subprocess is invoked with the stripped executable path. + mock_run_external_process.assert_called_once() + args = mock_run_external_process.call_args[0] + assert args[0] == stripped_exe + # PYTHONEXEPATH is exported with the stripped path so PlatformIO's + # get_pythonexe_path() picks it up in the subprocess. + assert os.environ["PYTHONEXEPATH"] == stripped_exe + + +def test_run_platformio_cli_does_not_set_pythonexepath_without_strip( + setup_core: Path, mock_run_external_process: Mock +) -> None: + r"""PYTHONEXEPATH is not touched when sys.executable has no ``\\?\`` prefix. + + Setting it unconditionally would clobber a user-provided value (or + interfere with non-Windows tooling that has no prefix to strip). + """ + CORE.build_path = str(setup_core / "build" / "test") + plain_exe = "/usr/bin/python3" + + with ( + patch.dict(os.environ, {}, clear=False), + patch("esphome.platformio_api.sys.platform", "linux"), + patch("esphome.platformio_api.sys.executable", plain_exe), + ): + os.environ.pop("PYTHONEXEPATH", None) + mock_run_external_process.return_value = 0 + platformio_api.run_platformio_cli("test", "arg") + + mock_run_external_process.assert_called_once() + args = mock_run_external_process.call_args[0] + assert args[0] == plain_exe + assert "PYTHONEXEPATH" not in os.environ + + def test_run_platformio_cli_run_builds_command( setup_core: Path, mock_run_platformio_cli: Mock ) -> None: From 096d0c4279bea52776fdbb3d1ab004d2cd86f6a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:45:19 +0000 Subject: [PATCH 0932/2214] Bump aioesphomeapi from 44.22.0 to 44.23.0 (#16161) 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 abc8ac5dbb..789a3f7995 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==44.22.0 +aioesphomeapi==44.23.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 1398dcebb459c0c974a3986028a112184ce7eb59 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:53:37 +1000 Subject: [PATCH 0933/2214] [st7789v] Add deprecation warnings (#16162) --- esphome/components/st7789v/__init__.py | 5 +++++ esphome/components/st7789v/display.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/esphome/components/st7789v/__init__.py b/esphome/components/st7789v/__init__.py index 3e64d09c57..7915cf119c 100644 --- a/esphome/components/st7789v/__init__.py +++ b/esphome/components/st7789v/__init__.py @@ -1,3 +1,8 @@ import esphome.codegen as cg st7789v_ns = cg.esphome_ns.namespace("st7789v") + +DEPRECATED_COMPONENT = """ +The 'st7789v' component is deprecated and no new functionality will be added to it. +PRs should target the newer and more performant 'mipi_spi' component. +""" diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index 745c37f47d..3b4d6d99ea 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import display, power_supply, spi @@ -26,6 +28,8 @@ CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["spi"] +LOGGER = logging.getLogger(__name__) + ST7789V = st7789v_ns.class_( "ST7789V", cg.PollingComponent, spi.SPIDevice, display.DisplayBuffer ) @@ -175,6 +179,9 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): + LOGGER.warning( + "The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead." + ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) From c99e60be7dca29361cb64e45f689fb3646994231 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:51:12 +0200 Subject: [PATCH 0934/2214] Add extended OTA protocol (split from PR #15780) --- esphome/__main__.py | 11 +- esphome/components/esphome/ota/__init__.py | 13 +- .../components/esphome/ota/ota_esphome.cpp | 65 +++++- esphome/components/esphome/ota/ota_esphome.h | 3 + esphome/components/ota/ota_backend.h | 8 + esphome/espota2.py | 190 +++++++++++------- .../ota/test-partition_access.esp32-idf.yaml | 5 + tests/unit_tests/test_espota2.py | 27 ++- tests/unit_tests/test_main.py | 23 ++- 9 files changed, 242 insertions(+), 103 deletions(-) create mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml diff --git a/esphome/__main__.py b/esphome/__main__.py index 781bcd6288..e7ce36ae2d 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1125,15 +1125,16 @@ def upload_program( remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD) - if getattr(args, "file", None) is not None: - binary = Path(args.file) - else: - binary = CORE.firmware_bin # Resolve MQTT magic strings to actual IP addresses network_devices = _resolve_network_devices(devices, config, args) - return espota2.run_ota(network_devices, remote_port, password, binary) + binary = CORE.firmware_bin + ota_type = espota2.OTA_TYPE_UPDATE_APP + if getattr(args, "file", None) is not None: + binary = Path(args.file) + + return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bfa5ffb55c..ee3b7f0c20 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,11 +16,13 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv from esphome.types import ConfigType +CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access" + _LOGGER = logging.getLogger(__name__) @@ -75,6 +77,10 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) + if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS) and not CORE.is_esp32: + raise cv.Invalid( + f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" + ) else: new_ota_conf.append(ota_conf) @@ -125,6 +131,7 @@ CONFIG_SCHEMA = cv.All( ln882x=8820, rtl87xx=8892, ): cv.port, + cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.string, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" @@ -159,6 +166,10 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PASSWORD]: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) + + if config.get(CONF_ALLOW_PARTITION_ACCESS): + cg.add_define("USE_OTA_PARTITIONS") + # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index be771eb689..0796a97677 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -100,6 +100,9 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Password configured"); } #endif +#ifdef USE_OTA_PARTITIONS + ESP_LOGCONFIG(TAG, " Partition access allowed"); +#endif } void ESPHomeOTAComponent::loop() { @@ -114,8 +117,11 @@ void ESPHomeOTAComponent::loop() { this->handle_handshake_(); } -static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; -static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. @@ -201,16 +207,36 @@ void ESPHomeOTAComponent::handle_handshake_() { this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); this->transition_ota_state_(OTAState::FEATURE_ACK); - this->handshake_buf_[0] = - ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) - ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION - : ota::OTA_RESPONSE_HEADER_OK; + + const bool supports_compression = + (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); + + // Compose the feature-ack response. When USE_OTA_PARTITIONS is enabled and the client + // negotiates the extended protocol we emit a 2-byte response (marker + server feature flags); + // otherwise we emit the single-byte legacy response. The #ifdef wraps only the extended-proto + // branch so the legacy branch reads as unconditional code in either build configuration. +#ifdef USE_OTA_PARTITIONS + this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; + if (this->extended_proto_) { + this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; + this->handshake_buf_[1] = + SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS | (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); + } else +#endif + { + this->handshake_buf_[0] = + supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; + } [[fallthrough]]; } case OTAState::FEATURE_ACK: { - // Acknowledge header - 1 byte - if (!this->try_write_(1, LOG_STR("ack feature"))) { +#ifdef USE_OTA_PARTITIONS + const size_t ack_size = this->extended_proto_ ? 2 : 1; +#else + const size_t ack_size = 1; +#endif + if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } #ifdef USE_OTA_PASSWORD @@ -296,6 +322,9 @@ void ESPHomeOTAComponent::handle_data_() { uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; +#ifdef USE_OTA_PARTITIONS + ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; +#endif #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif @@ -311,6 +340,18 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); +#ifdef USE_OTA_PARTITIONS + if (this->extended_proto_) { + // Read ota type, 1 byte + if (!this->readall_(buf, 1)) { + this->log_read_error_(LOG_STR("OTA type")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + ota_type = static_cast(buf[0]); + } + ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); +#endif + // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { this->log_read_error_(LOG_STR("size")); @@ -331,6 +372,12 @@ void ESPHomeOTAComponent::handle_data_() { #endif // This will block for a few seconds as it locks flash +#ifdef USE_OTA_PARTITIONS + if (ota_type != ota::OTA_TYPE_UPDATE_APP) { + error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } +#endif error_code = this->backend_->begin(ota_size); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) @@ -616,7 +663,7 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); } bool ESPHomeOTAComponent::select_auth_type_() { - bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + bool client_supports_sha256 = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_SHA256_AUTH) != 0; // Require SHA256 if (!client_supports_sha256) { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 53288fc000..9bed9240aa 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -91,6 +91,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD +#ifdef USE_OTA_PARTITIONS + bool extended_proto_{false}; +#endif socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index bd9c481901..7e7b0f6523 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 + #ifdef USE_OTA_STATE_LISTENER #include #endif @@ -23,6 +25,7 @@ enum OTAResponseTypes { OTA_RESPONSE_UPDATE_END_OK = 0x45, OTA_RESPONSE_SUPPORTS_COMPRESSION = 0x46, OTA_RESPONSE_CHUNK_OK = 0x47, + OTA_RESPONSE_FEATURE_FLAGS = 0x48, OTA_RESPONSE_ERROR_MAGIC = 0x80, OTA_RESPONSE_ERROR_UPDATE_PREPARE = 0x81, @@ -38,6 +41,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_MD5_MISMATCH = 0x8B, OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C, OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D, + OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -49,6 +53,10 @@ enum OTAState { OTA_ERROR, }; +enum OTAType : uint8_t { + OTA_TYPE_UPDATE_APP = 0x00, +}; + /** Listener interface for OTA state changes. * * Components can implement this interface to receive OTA state updates diff --git a/esphome/espota2.py b/esphome/espota2.py index 39f51e02e9..d81ea8454e 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -15,6 +15,8 @@ from typing import Any from esphome.core import EsphomeError from esphome.helpers import ProgressBar, resolve_ip_address +OTA_TYPE_UPDATE_APP = 0x00 + RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 RESPONSE_REQUEST_SHA256_AUTH = 0x02 @@ -27,6 +29,7 @@ RESPONSE_RECEIVE_OK = 0x44 RESPONSE_UPDATE_END_OK = 0x45 RESPONSE_SUPPORTS_COMPRESSION = 0x46 RESPONSE_CHUNK_OK = 0x47 +RESPONSE_FEATURE_FLAGS = 0x48 RESPONSE_ERROR_MAGIC = 0x80 RESPONSE_ERROR_UPDATE_PREPARE = 0x81 @@ -42,6 +45,7 @@ RESPONSE_ERROR_NO_UPDATE_PARTITION = 0x8A RESPONSE_ERROR_MD5_MISMATCH = 0x8B RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D +RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -49,9 +53,11 @@ OTA_VERSION_2_0 = 2 MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45] -FEATURE_SUPPORTS_COMPRESSION = 0x01 -FEATURE_SUPPORTS_SHA256_AUTH = 0x02 - +CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01 +CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02 +CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04 +SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01 +SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 @@ -64,6 +70,62 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } +# Error response code -> human-readable message (without the "Error: " prefix; check_error() +# prepends it uniformly). Looked up by check_error() to translate a single byte from the device +# into an OTAError. Add new error codes here rather than extending the if-chain in check_error(). +_ERROR_MESSAGES: dict[int, str] = { + RESPONSE_ERROR_MAGIC: "Invalid magic byte", + RESPONSE_ERROR_UPDATE_PREPARE: ( + "Couldn't prepare flash memory for update. Is the binary too big? " + "Please try restarting the ESP." + ), + RESPONSE_ERROR_AUTH_INVALID: "Authentication invalid. Is the password correct?", + RESPONSE_ERROR_WRITING_FLASH: ( + "Writing OTA data to flash memory failed. See USB logs for more information." + ), + RESPONSE_ERROR_UPDATE_END: ( + "Finishing update failed. See the MQTT/USB logs for more information." + ), + RESPONSE_ERROR_INVALID_BOOTSTRAPPING: ( + "Please press the reset button on the ESP. A manual reset is " + "required on the first OTA-Update after flashing via USB." + ), + RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: ( + "ESP has been flashed with wrong flash size. Please choose the " + "correct 'board' option (esp01_1m always works) and then flash over USB." + ), + RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: ( + "ESP does not have the requested flash size (wrong board). Please " + "choose the correct 'board' option (esp01_1m always works) and try " + "uploading again." + ), + RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: ( + "ESP does not have enough space to store OTA file. Please try " + "flashing a minimal firmware (remove everything except ota)" + ), + RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: ( + "The OTA partition on the ESP is too small. ESPHome needs to resize " + "this partition, please flash over USB." + ), + RESPONSE_ERROR_NO_UPDATE_PARTITION: ( + "The OTA partition on the ESP couldn't be found. ESPHome needs to " + "create this partition, please flash over USB." + ), + RESPONSE_ERROR_MD5_MISMATCH: ( + "Application MD5 code mismatch. Please try again " + "or flash over USB with a good quality cable." + ), + RESPONSE_ERROR_SIGNATURE_INVALID: ( + "Firmware signature verification failed. The firmware was not signed " + "with the correct key. Ensure the signing key matches the one used to build " + "the firmware currently running on the device." + ), + RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: ( + "The requested OTA type is not supported by the device." + ), + RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", +} + class OTAError(EsphomeError): pass @@ -139,69 +201,9 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "a network issue, or the connection was interrupted." ) dat = data[0] - if dat == RESPONSE_ERROR_MAGIC: - raise OTAError("Error: Invalid magic byte") - if dat == RESPONSE_ERROR_UPDATE_PREPARE: - raise OTAError( - "Error: Couldn't prepare flash memory for update. Is the binary too big? " - "Please try restarting the ESP." - ) - if dat == RESPONSE_ERROR_AUTH_INVALID: - raise OTAError("Error: Authentication invalid. Is the password correct?") - if dat == RESPONSE_ERROR_WRITING_FLASH: - raise OTAError( - "Error: Writing OTA data to flash memory failed. See USB logs for more " - "information." - ) - if dat == RESPONSE_ERROR_UPDATE_END: - raise OTAError( - "Error: Finishing update failed. See the MQTT/USB logs for more " - "information." - ) - if dat == RESPONSE_ERROR_INVALID_BOOTSTRAPPING: - raise OTAError( - "Error: Please press the reset button on the ESP. A manual reset is " - "required on the first OTA-Update after flashing via USB." - ) - if dat == RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: - raise OTAError( - "Error: ESP has been flashed with wrong flash size. Please choose the " - "correct 'board' option (esp01_1m always works) and then flash over USB." - ) - if dat == RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: - raise OTAError( - "Error: ESP does not have the requested flash size (wrong board). Please " - "choose the correct 'board' option (esp01_1m always works) and try " - "uploading again." - ) - if dat == RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: - raise OTAError( - "Error: ESP does not have enough space to store OTA file. Please try " - "flashing a minimal firmware (remove everything except ota)" - ) - if dat == RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: - raise OTAError( - "Error: The OTA partition on the ESP is too small. ESPHome needs to resize " - "this partition, please flash over USB." - ) - if dat == RESPONSE_ERROR_NO_UPDATE_PARTITION: - raise OTAError( - "Error: The OTA partition on the ESP couldn't be found. ESPHome needs to create " - "this partition, please flash over USB." - ) - if dat == RESPONSE_ERROR_MD5_MISMATCH: - raise OTAError( - "Error: Application MD5 code mismatch. Please try again " - "or flash over USB with a good quality cable." - ) - if dat == RESPONSE_ERROR_SIGNATURE_INVALID: - raise OTAError( - "Error: Firmware signature verification failed. The firmware was not signed " - "with the correct key. Ensure the signing key matches the one used to build " - "the firmware currently running on the device." - ) - if dat == RESPONSE_ERROR_UNKNOWN: - raise OTAError("Unknown error from ESP") + error_msg = _ERROR_MESSAGES.get(dat) + if error_msg is not None: + raise OTAError(f"Error: {error_msg}") if not isinstance(expect, (list, tuple)): expect = [expect] if dat not in expect: @@ -232,7 +234,11 @@ def send_check( def perform_ota( - sock: socket.socket, password: str | None, file_handle: io.IOBase, filename: Path + sock: socket.socket, + password: str | None, + file_handle: io.IOBase, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> None: file_contents = file_handle.read() file_size = len(file_contents) @@ -251,7 +257,11 @@ def perform_ota( ) # Features - send both compression and SHA256 auth support - features_to_send = FEATURE_SUPPORTS_COMPRESSION | FEATURE_SUPPORTS_SHA256_AUTH + features_to_send = ( + CLIENT_FEATURE_SUPPORTS_COMPRESSION + | CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ) send_check(sock, features_to_send, "features") features = receive_exactly( sock, @@ -260,7 +270,30 @@ def perform_ota( None, # Accept any response )[0] - if features == RESPONSE_SUPPORTS_COMPRESSION: + extended_proto = False + if features == RESPONSE_FEATURE_FLAGS: + extended_proto = True + features = receive_exactly( + sock, + 1, + "feature flags", + None, # Accept any response + )[0] + elif features == RESPONSE_SUPPORTS_COMPRESSION: + features = SERVER_FEATURE_SUPPORTS_COMPRESSION + else: + features = 0 + + if ota_type not in (OTA_TYPE_UPDATE_APP): + raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") + + if ( + ota_type != OTA_TYPE_UPDATE_APP + and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ): + raise OTAError("Device only supports app updates") + + if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: upload_contents = gzip.compress(file_contents, compresslevel=9) _LOGGER.info("Compressed to %s bytes", len(upload_contents)) else: @@ -315,6 +348,9 @@ def perform_ota( # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) + if extended_proto: + send_check(sock, ota_type, "ota type") + upload_size = len(upload_contents) upload_size_encoded = [ (upload_size >> 24) & 0xFF, @@ -375,7 +411,11 @@ def perform_ota( def run_ota_impl_( - remote_host: str | list[str], remote_port: int, password: str | None, filename: Path + remote_host: str | list[str], + remote_port: int, + password: str | None, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -413,7 +453,7 @@ def run_ota_impl_( _LOGGER.info("Connected to %s", sa[0]) with open(filename, "rb") as file_handle: try: - perform_ota(sock, password, file_handle, filename) + perform_ota(sock, password, file_handle, filename, ota_type) except OTAError as err: _LOGGER.error(str(err)) return 1, None @@ -428,10 +468,14 @@ def run_ota_impl_( def run_ota( - remote_host: str | list[str], remote_port: int, password: str | None, filename: Path + remote_host: str | list[str], + remote_port: int, + password: str | None, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> tuple[int, str | None]: try: - return run_ota_impl_(remote_host, remote_port, password, filename) + return run_ota_impl_(remote_host, remote_port, password, filename, ota_type) except OTAError as err: _LOGGER.error(err) return 1, None diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml new file mode 100644 index 0000000000..0cbf854952 --- /dev/null +++ b/tests/components/ota/test-partition_access.esp32-idf.yaml @@ -0,0 +1,5 @@ +ota: + - platform: esphome + allow_partition_access: true + +<<: !include common.yaml diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 20ba4b1f76..7ac88ee129 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -185,6 +185,14 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: "Error: The OTA partition on the ESP couldn't be found", ), (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), + ( + espota2.RESPONSE_ERROR_SIGNATURE_INVALID, + "Error: Firmware signature verification failed", + ), + ( + espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, + "Error: The requested OTA type is not supported by the device", + ), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) @@ -270,12 +278,13 @@ def test_perform_ota_successful_md5_auth( # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent (compression + SHA256 support) + # Verify features were sent (compression + SHA256 support + extended protocol) assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -640,12 +649,13 @@ def test_perform_ota_successful_sha256_auth( # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent (compression + SHA256 support) + # Verify features were sent (compression + SHA256 support + extended protocol) assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -699,8 +709,9 @@ def test_perform_ota_sha256_fallback_to_md5( assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fb8f206a1d..186d8a9573 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -83,6 +83,7 @@ from esphome.const import ( PLATFORM_RP2040, ) from esphome.core import CORE, EsphomeError +from esphome.espota2 import OTA_TYPE_UPDATE_APP from esphome.util import BootselResult from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1593,7 +1594,7 @@ def test_upload_program_ota_success( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, "secret", expected_firmware + ["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -1624,7 +1625,7 @@ def test_upload_program_ota_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, Path("custom.bin") + ["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP ) @@ -1682,7 +1683,7 @@ def test_upload_program_ota_with_mqtt_resolution( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware + ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -1730,7 +1731,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.50"], 3232, None, expected_firmware + ["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -3207,7 +3208,11 @@ def test_upload_program_ota_static_ip_with_mqttip( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware + ["192.168.1.100", "192.168.2.50"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, ) @@ -3250,7 +3255,11 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware + ["192.168.2.50", "192.168.2.51", "192.168.1.100"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, ) @@ -3415,7 +3424,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware + ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) From 3b6c5a5aa5e68223a895f7d25f12dcb1c431cf81 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:01:30 +0200 Subject: [PATCH 0935/2214] Small changes --- esphome/components/ota/ota_backend_esp_idf.cpp | 1 - esphome/espota2.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 4f9c90f006..5843a010f3 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -11,7 +11,6 @@ #include #ifdef USE_OTA_PARTITIONS -#include "esphome/components/watchdog/watchdog.h" #include #include #endif diff --git a/esphome/espota2.py b/esphome/espota2.py index 891ed5b29c..578ea92ce6 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -300,7 +300,7 @@ def perform_ota( raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") if ( - ota_type == OTA_TYPE_UPDATE_PARTITION_TABLE + ota_type != OTA_TYPE_UPDATE_APP and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS ): raise OTAError("Device only supports app updates") From da4c4332084bf9b27a8f4028fba9199be21b8193 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:06:55 +0200 Subject: [PATCH 0936/2214] Fix --- esphome/espota2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index d81ea8454e..ec9cc0f90a 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -284,7 +284,7 @@ def perform_ota( else: features = 0 - if ota_type not in (OTA_TYPE_UPDATE_APP): + if ota_type != OTA_TYPE_UPDATE_APP: raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") if ( From 59a8057e9ad95f70d433f8a407eb540b86332797 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 06:33:49 -0500 Subject: [PATCH 0937/2214] [core] Inline loop gate expression to avoid stale local reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous gate captured each subexpression in a named local (high_frequency, elapsed, woke). Locals are easy to reuse later in the function, and we have hit bugs from stale reuse before — most recently is_high_frequency being read again instead of re-checked. Inline the expression directly into do_component_phase so there is nothing tempting to reuse downstream. --- esphome/core/application.h | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 4a18714d0d..5baf570e62 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -637,10 +637,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // flag preserves it. wake_request_take() exchange-clears the flag; wakes // that arrive during Phase B re-set it and run Phase B again on the next // iteration. - const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency(); - const uint32_t elapsed = now - this->last_loop_; - const bool woke = esphome::wake_request_take(); - const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_); + // + // wake_request_take() must always be called first since it does an + // atomic exchange to clear the flag, and we want to run the component phase + // if either the flag was set or the scheduler requested a high-frequency loop. + const bool do_component_phase = esphome::wake_request_take() || HighFrequencyLoopRequester::is_high_frequency() || + (now - this->last_loop_ >= this->loop_interval_); if (do_component_phase) { ComponentPhaseGuard phase_guard{*this}; From a8b0133ec115984ed05b8129a6e85dbc56e93166 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 30 Apr 2026 08:49:28 -0400 Subject: [PATCH 0938/2214] [audio] Enable specific codecs and configure advanced features (#16166) --- esphome/components/audio/__init__.py | 196 ++++++++++++++++++++- tests/components/audio/common.yaml | 14 ++ tests/components/audio/test.esp32-idf.yaml | 1 + 3 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 tests/components/audio/common.yaml create mode 100644 tests/components/audio/test.esp32-idf.yaml diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index fe111be31e..db8f69e6a5 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field import esphome.codegen as cg from esphome.components.esp32 import ( @@ -7,7 +7,12 @@ from esphome.components.esp32 import ( include_builtin_idf_component, ) import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_NUM_CHANNELS, + CONF_SAMPLE_RATE, + CONF_SIZE, +) from esphome.core import CORE import esphome.final_validate as fv @@ -25,13 +30,46 @@ AUDIO_FILE_TYPE_ENUM = { "OPUS": AudioFileType.OPUS, } +MEMORY_PSRAM = "psram" +MEMORY_INTERNAL = "internal" +MEMORY_LOCATIONS = [MEMORY_PSRAM, MEMORY_INTERNAL] + + +@dataclass +class FlacOptions: + buffer_memory: str | None = None + + +@dataclass +class Mp3Options: + buffer_memory: str | None = None + + +@dataclass +class OpusPseudostackOptions: + threadsafe: bool | None = None + buffer_memory: str | None = None + size: int | None = None + + +@dataclass +class OpusOptions: + floating_point: bool | None = None + state_memory: str | None = None + pseudostack: OpusPseudostackOptions = field(default_factory=OpusPseudostackOptions) + @dataclass class AudioData: flac_support: bool = False mp3_support: bool = False opus_support: bool = False + # WAV defaults to True for backward compatibility; will become opt-in in a future release + wav_support: bool = True micro_decoder_support: bool = False + flac: FlacOptions = field(default_factory=FlacOptions) + mp3: Mp3Options = field(default_factory=Mp3Options) + opus: OpusOptions = field(default_factory=OpusOptions) def _get_data() -> AudioData: @@ -55,6 +93,11 @@ def request_opus_support() -> None: _get_data().opus_support = True +def request_wav_support() -> None: + """Request WAV codec support for audio decoding.""" + _get_data().wav_support = True + + def request_micro_decoder_support() -> None: """Request micro-decoder library support for audio decoding.""" _get_data().micro_decoder_support = True @@ -67,9 +110,78 @@ CONF_MAX_CHANNELS = "max_channels" CONF_MIN_SAMPLE_RATE = "min_sample_rate" CONF_MAX_SAMPLE_RATE = "max_sample_rate" +CONF_CODECS = "codecs" +CONF_WAV = "wav" +CONF_FLAC = "flac" +CONF_MP3 = "mp3" +CONF_OPUS = "opus" +CONF_BUFFER_MEMORY = "buffer_memory" +CONF_FLOATING_POINT = "floating_point" +CONF_STATE_MEMORY = "state_memory" +CONF_PSEUDOSTACK = "pseudostack" +CONF_THREADSAFE = "threadsafe" + + +_MEMORY_LOCATION_VALIDATOR = cv.one_of(*MEMORY_LOCATIONS, lower=True) + + +def _maybe_empty_codec(schema): + """Wrap a codec dict schema so that a bare key (None value) is treated as an empty dict.""" + + def validator(value): + if value is None: + value = {} + return schema(value) + + return validator + + +CODEC_FLAC_SCHEMA = cv.Schema( + { + cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR, + } +) + +CODEC_MP3_SCHEMA = cv.Schema( + { + cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR, + } +) + +OPUS_PSEUDOSTACK_SCHEMA = cv.Schema( + { + cv.Optional(CONF_THREADSAFE): cv.boolean, + cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR, + cv.Optional(CONF_SIZE): cv.int_range(60000, 240000), + } +) + +CODEC_OPUS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_FLOATING_POINT): cv.boolean, + cv.Optional(CONF_STATE_MEMORY): _MEMORY_LOCATION_VALIDATOR, + cv.Optional(CONF_PSEUDOSTACK): _maybe_empty_codec(OPUS_PSEUDOSTACK_SCHEMA), + } +) + +CODEC_WAV_SCHEMA = cv.Schema({}) + +CODECS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_FLAC): _maybe_empty_codec(CODEC_FLAC_SCHEMA), + cv.Optional(CONF_MP3): _maybe_empty_codec(CODEC_MP3_SCHEMA), + cv.Optional(CONF_OPUS): _maybe_empty_codec(CODEC_OPUS_SCHEMA), + cv.Optional(CONF_WAV): _maybe_empty_codec(CODEC_WAV_SCHEMA), + } +) CONFIG_SCHEMA = cv.All( - cv.Schema({}), + cv.Schema( + { + cv.Optional(CONF_CODECS): _maybe_empty_codec(CODECS_SCHEMA), + } + ), + cv.only_on_esp32, ) AUDIO_COMPONENT_SCHEMA = cv.Schema( @@ -208,6 +320,15 @@ def final_validate_audio_schema( ) +def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> None: + if value == MEMORY_PSRAM: + add_idf_sdkconfig_option(psram_key, True) + add_idf_sdkconfig_option(internal_key, False) + elif value == MEMORY_INTERNAL: + add_idf_sdkconfig_option(psram_key, False) + add_idf_sdkconfig_option(internal_key, True) + + async def to_code(config): # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) include_builtin_idf_component("esp_http_client") @@ -219,6 +340,36 @@ async def to_code(config): data = _get_data() + # Merge user-supplied codec configuration (additive: presence enables the codec) + if codecs_config := config.get(CONF_CODECS): + if (flac_config := codecs_config.get(CONF_FLAC)) is not None: + data.flac_support = True + if (buffer_memory := flac_config.get(CONF_BUFFER_MEMORY)) is not None: + data.flac.buffer_memory = buffer_memory + if (mp3_config := codecs_config.get(CONF_MP3)) is not None: + data.mp3_support = True + if (buffer_memory := mp3_config.get(CONF_BUFFER_MEMORY)) is not None: + data.mp3.buffer_memory = buffer_memory + if (opus_config := codecs_config.get(CONF_OPUS)) is not None: + data.opus_support = True + floating_point = opus_config.get(CONF_FLOATING_POINT) + if floating_point is not None: + data.opus.floating_point = floating_point + if (state_memory := opus_config.get(CONF_STATE_MEMORY)) is not None: + data.opus.state_memory = state_memory + if (pseudostack_config := opus_config.get(CONF_PSEUDOSTACK)) is not None: + threadsafe = pseudostack_config.get(CONF_THREADSAFE) + if threadsafe is not None: + data.opus.pseudostack.threadsafe = threadsafe + if ( + buffer_memory := pseudostack_config.get(CONF_BUFFER_MEMORY) + ) is not None: + data.opus.pseudostack.buffer_memory = buffer_memory + if (size := pseudostack_config.get(CONF_SIZE)) is not None: + data.opus.pseudostack.size = size + if CONF_WAV in codecs_config: + data.wav_support = True + if data.micro_decoder_support: add_idf_component(name="esphome/micro-decoder", ref="0.2.0") @@ -229,13 +380,50 @@ 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) + if not data.wav_support: + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False) - # Legacy audio_decoder.cpp support defines and components + # Configure each codec library. + # Adds a define and IDF component for legacy `audio_decoder.cpp`. if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") add_idf_component(name="esphome/micro-flac", ref="0.1.1") + _emit_memory_pair( + data.flac.buffer_memory, + "CONFIG_MICRO_FLAC_PREFER_PSRAM", + "CONFIG_MICRO_FLAC_PREFER_INTERNAL", + ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") + _emit_memory_pair( + data.mp3.buffer_memory, + "CONFIG_MP3_DECODER_PREFER_PSRAM", + "CONFIG_MP3_DECODER_PREFER_INTERNAL", + ) if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") add_idf_component(name="esphome/micro-opus", ref="0.3.6") + if data.opus.floating_point is not None: + add_idf_sdkconfig_option( + "CONFIG_OPUS_FLOATING_POINT", data.opus.floating_point + ) + _emit_memory_pair( + data.opus.state_memory, + "CONFIG_OPUS_STATE_PREFER_PSRAM", + "CONFIG_OPUS_STATE_PREFER_INTERNAL", + ) + if data.opus.pseudostack.threadsafe is True: + add_idf_sdkconfig_option("CONFIG_OPUS_THREADSAFE_PSEUDOSTACK", True) + add_idf_sdkconfig_option("CONFIG_OPUS_NONTHREADSAFE_PSEUDOSTACK", False) + elif data.opus.pseudostack.threadsafe is False: + add_idf_sdkconfig_option("CONFIG_OPUS_THREADSAFE_PSEUDOSTACK", False) + add_idf_sdkconfig_option("CONFIG_OPUS_NONTHREADSAFE_PSEUDOSTACK", True) + _emit_memory_pair( + data.opus.pseudostack.buffer_memory, + "CONFIG_OPUS_PSEUDOSTACK_PREFER_PSRAM", + "CONFIG_OPUS_PSEUDOSTACK_PREFER_INTERNAL", + ) + if data.opus.pseudostack.size is not None: + add_idf_sdkconfig_option( + "CONFIG_OPUS_PSEUDOSTACK_SIZE", data.opus.pseudostack.size + ) diff --git a/tests/components/audio/common.yaml b/tests/components/audio/common.yaml new file mode 100644 index 0000000000..3cde9b8449 --- /dev/null +++ b/tests/components/audio/common.yaml @@ -0,0 +1,14 @@ +audio: + codecs: + flac: + buffer_memory: internal + mp3: + buffer_memory: psram + opus: + floating_point: false + state_memory: psram + pseudostack: + threadsafe: false + buffer_memory: internal + size: 80000 + wav: diff --git a/tests/components/audio/test.esp32-idf.yaml b/tests/components/audio/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/audio/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From bf917144c4719b96ce09cda0ac2becd800451f01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 08:11:31 -0500 Subject: [PATCH 0939/2214] [sensor] Pack ThrottleAverageFilter have_nan_ into n_ bitfield MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saves 4 B per ThrottleAverageFilter instance (28 B → 24 B on 32-bit). have_nan_ is a single boolean that previously cost 4 B due to padding; fold it into the high bit of n_ as a 31-bit + 1-bit bitfield. To guarantee n_ cannot overflow under realistic configurations, cap the YAML time_period at 24 h. At a pessimistic 1 kHz source rate the counter peaks at 86.4M, leaving 25x headroom against 2^31. Anyone needing multi-day rolling averages should be using a different filtering strategy anyway. Breaking change: configurations with throttle_average periods longer than 24 h will now fail validation. No real-world configs are expected to use such values. --- esphome/components/sensor/__init__.py | 7 ++++++- esphome/components/sensor/filter.h | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 48b7d25d4d..82fa3ebc0d 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -564,7 +564,12 @@ async def exponential_moving_average_filter_to_code(config, filter_id): @FILTER_REGISTRY.register( - "throttle_average", ThrottleAverageFilter, cv.positive_time_period_milliseconds + "throttle_average", + ThrottleAverageFilter, + cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(hours=24)), + ), ) async def throttle_average_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id, config) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 917a1ce7d5..ed5a31c361 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -266,9 +266,12 @@ class ThrottleAverageFilter : public Filter, public Component { protected: float sum_{0.0f}; - unsigned int n_{0}; uint32_t time_period_; - bool have_nan_{false}; + // Sample count packed with NaN-seen flag in a single 32-bit word. + // n_ is bounded by YAML cap on time_period_ (24 h) × max plausible source + // rate (1 kHz) = 86.4M ≪ 2^31, so 31 bits has 25x headroom. + uint32_t n_ : 31 {0}; + uint32_t have_nan_ : 1 {0}; }; using lambda_filter_t = std::function(float)>; From 2758aa551782464ed34d1a1f7affe91e7d9120e8 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 30 Apr 2026 09:12:39 -0400 Subject: [PATCH 0940/2214] [audio] bump microOpus to v0.4.0 to use fixed-point by default on ESP32 (#16168) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index db8f69e6a5..8528e77ae7 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -402,7 +402,7 @@ async def to_code(config): ) if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") - add_idf_component(name="esphome/micro-opus", ref="0.3.6") + add_idf_component(name="esphome/micro-opus", ref="0.4.0") if data.opus.floating_point is not None: add_idf_sdkconfig_option( "CONFIG_OPUS_FLOATING_POINT", data.opus.floating_point diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index cb7f5903cf..757fcf9dd7 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-flac: version: 0.1.1 esphome/micro-opus: - version: 0.3.6 + version: 0.4.0 espressif/esp-dsp: version: "1.7.1" espressif/esp-tflite-micro: From f9b87d0edeb9cc7b3c55e8edd8a08b497adc6e42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 10:42:46 -0500 Subject: [PATCH 0941/2214] [core] Replace scheduler pool vector with unbounded intrusive freelist The fixed MAX_POOL_SIZE=5 cap was the source of the heap churn the pool was meant to prevent: any device with more than 5 concurrent timers (e.g. a board with 30+ LD2450 sensors) hit a steady-state oscillation of recycle->delete and acquire->new on every loop iteration. Replace std::vector with a singly-linked freelist threaded through SchedulerItem::next_free, which shares storage with `component` via an anonymous union (zero per-item overhead -- the component pointer is dead while pooled). Drop the cap entirely: the freelist quiesces at the application's natural concurrent-timer high-water mark, which is the working set the device already needs while those timers are active. No std::vector means no growth-doubling slack and no realloc copies during warm-up. Caller of get_item_from_pool_locked_() must overwrite item->component before unlocking (already true at the sole call site); nullptr remains a valid live `component` value for SELF_POINTER items, so we cannot pre-clear it. --- esphome/core/scheduler.cpp | 58 ++++++------------- esphome/core/scheduler.h | 24 ++++---- tests/benchmarks/core/bench_scheduler.cpp | 10 ++-- .../integration/fixtures/scheduler_pool.yaml | 10 +--- tests/integration/test_scheduler_pool.py | 14 ++--- 5 files changed, 43 insertions(+), 73 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 57deeab0da..c3cbfc8438 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -14,18 +14,8 @@ namespace esphome { static const char *const TAG = "scheduler"; -// Memory pool configuration constants -// Pool size of 5 matches typical usage patterns (2-4 active timers) -// - Minimal memory overhead (~250 bytes on ESP32) -// - Sufficient for most configs with a couple sensors/components -// - Still prevents heap fragmentation and allocation stalls -// - Complex setups with many timers will just allocate beyond the pool -// See https://github.com/esphome/backlog/issues/52 -static constexpr size_t MAX_POOL_SIZE = 5; - // Maximum number of logically deleted (cancelled) items before forcing cleanup. -// Set to 5 to match the pool size - when we have as many cancelled items as our -// pool can hold, it's time to clean up and recycle them. +// Empirically chosen to balance cleanup overhead against tombstone accumulation in items_. static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5; // max delay to start an interval sequence static constexpr uint32_t MAX_INTERVAL_DELAY = 5000; @@ -165,7 +155,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type delay = 1; } - // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check + // Take lock early to protect scheduler_item_pool_head_ access and retry-cancelled check LockGuard guard{this->lock_}; // For retries, check if there's a cancelled timeout first - before allocating an item. @@ -599,7 +589,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { if (now_64 - last_print > 2000) { last_print = now_64; std::vector old_items; - ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64, this->items_.size(), this->scheduler_item_pool_.size(), + ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64, this->items_.size(), this->scheduler_item_pool_size_, now_64); // Cleanup before debug output this->cleanup_(); @@ -894,30 +884,19 @@ bool HOT Scheduler::SchedulerItem::cmp(SchedulerItem *a, SchedulerItem *b) { : (a->next_execution_high_ > b->next_execution_high_); } -// Recycle a SchedulerItem back to the pool for reuse. -// IMPORTANT: Caller must hold the scheduler lock before calling this function. -// This protects scheduler_item_pool_ from concurrent access by other threads -// that may be acquiring items from the pool in set_timer_common_(). +// Recycle a SchedulerItem back to the freelist for reuse. +// IMPORTANT: Caller must hold the scheduler lock. void Scheduler::recycle_item_main_loop_(SchedulerItem *item) { if (item == nullptr) return; - if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { - // Clear callback to release captured resources - item->callback = nullptr; - this->scheduler_item_pool_.push_back(item); + item->callback = nullptr; // release captured resources + item->next_free = this->scheduler_item_pool_head_; + this->scheduler_item_pool_head_ = item; + this->scheduler_item_pool_size_++; #ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size()); + ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_size_); #endif - } else { -#ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size()); -#endif - delete item; -#ifdef ESPHOME_DEBUG_SCHEDULER - this->debug_live_items_--; -#endif - } } #ifdef ESPHOME_DEBUG_SCHEDULER @@ -942,14 +921,15 @@ void Scheduler::debug_log_timer_(const SchedulerItem *item, NameType name_type, } #endif /* ESPHOME_DEBUG_SCHEDULER */ -// Helper to get or create a scheduler item from the pool -// IMPORTANT: Caller must hold the scheduler lock before calling this function. +// Pop from freelist or allocate. IMPORTANT: caller must hold the lock and must overwrite +// `item->component` before releasing it -- the popped slot still holds the freelist link. Scheduler::SchedulerItem *Scheduler::get_item_from_pool_locked_() { - if (!this->scheduler_item_pool_.empty()) { - SchedulerItem *item = this->scheduler_item_pool_.back(); - this->scheduler_item_pool_.pop_back(); + if (this->scheduler_item_pool_head_ != nullptr) { + SchedulerItem *item = this->scheduler_item_pool_head_; + this->scheduler_item_pool_head_ = item->next_free; + this->scheduler_item_pool_size_--; #ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size()); + ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_size_); #endif return item; } @@ -967,7 +947,7 @@ Scheduler::SchedulerItem *Scheduler::get_item_from_pool_locked_() { bool Scheduler::debug_verify_no_leak_() const { // Invariant: every live SchedulerItem must be in exactly one container. // debug_live_items_ tracks allocations minus deletions. - size_t accounted = this->items_.size() + this->to_add_.size() + this->scheduler_item_pool_.size(); + size_t accounted = this->items_.size() + this->to_add_.size() + this->scheduler_item_pool_size_; #ifndef ESPHOME_THREAD_SINGLE accounted += this->defer_queue_.size(); #endif @@ -981,7 +961,7 @@ bool Scheduler::debug_verify_no_leak_() const { ")", static_cast(this->debug_live_items_), static_cast(accounted), static_cast(this->items_.size()), static_cast(this->to_add_.size()), - static_cast(this->scheduler_item_pool_.size()) + static_cast(this->scheduler_item_pool_size_) #ifndef ESPHOME_THREAD_SINGLE , static_cast(this->defer_queue_.size()) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7a6be6bea9..aa41a161be 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -177,8 +177,12 @@ class Scheduler { protected: struct SchedulerItem { - // Ordered by size to minimize padding - Component *component; + // Ordered by size to minimize padding. + // `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive). + union { + Component *component; + SchedulerItem *next_free; + }; // Optimized name storage using tagged union - zero heap allocation union { const char *static_name; // For STATIC_STRING (string literals) and SELF_POINTER (caller's `this`) @@ -713,19 +717,15 @@ class Scheduler { #endif } - // Memory pool for recycling SchedulerItem objects to reduce heap churn. - // Design decisions: - // - std::vector is used instead of a fixed array because many systems only need 1-2 scheduler items - // - The vector grows dynamically up to MAX_POOL_SIZE (5) only when needed, saving memory on simple setups - // - Pool size of 5 matches typical usage (2-4 timers) while keeping memory overhead low (~250 bytes on ESP32) - // - The pool significantly reduces heap fragmentation which is critical because heap allocation/deallocation - // can stall the entire system, causing timing issues and dropped events for any components that need - // to synchronize between tasks (see https://github.com/esphome/backlog/issues/52) - std::vector scheduler_item_pool_; + // Intrusive freelist threaded through SchedulerItem::next_free. Unbounded so it quiesces at the + // app's concurrent-timer high-water mark; the previous fixed cap caused steady-state new/delete + // churn on devices with many timers (see https://github.com/esphome/backlog/issues/52). + SchedulerItem *scheduler_item_pool_head_{nullptr}; + size_t scheduler_item_pool_size_{0}; #ifdef ESPHOME_DEBUG_SCHEDULER // Leak detection: tracks total live SchedulerItem allocations. - // Invariant: debug_live_items_ == items_.size() + to_add_.size() + defer_queue_.size() + scheduler_item_pool_.size() + // Invariant: debug_live_items_ == items_.size() + to_add_.size() + defer_queue_.size() + scheduler_item_pool_size_ // Verified periodically in call() to catch leaks early. size_t debug_live_items_{0}; diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index 214fe0e4b8..32bbc2de88 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -101,8 +101,8 @@ static void Scheduler_SetTimeout(benchmark::State &state) { Component dummy_component; // Register 3 timeouts then call() — realistic worst case where multiple - // components schedule in the same loop iteration. Keeps item count within - // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. + // components schedule in the same loop iteration. warm_pool fills the + // freelist so acquire/recycle never falls back to malloc. static constexpr int kBatchSize = 3; static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); warm_pool(scheduler, &dummy_component, kBatchSize, 1000); @@ -209,9 +209,9 @@ static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) { Scheduler scheduler; Component dummy_component; - // Register 10 timeouts then call() — exceeds MAX_POOL_SIZE=5 to measure - // the performance cliff when the recycling pool is exhausted and items - // must be malloc'd/freed. + // Register 10 timeouts then call() — larger working set than the 3-item + // batches above. With the unbounded freelist, warm_pool preallocates 10 + // items so this measures steady-state, not malloc cliff. static constexpr int kBatchSize = 10; static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); warm_pool(scheduler, &dummy_component, kBatchSize, 1000); diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index 5389125188..989c1535b0 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -221,14 +221,10 @@ script: - id: test_full_pool_reuse then: - lambda: |- - ESP_LOGI("test", "Phase 6: Testing pool size limits after Phase 5 items complete"); + ESP_LOGI("test", "Phase 6: Testing pool reuse after Phase 5 items complete"); - // At this point, all Phase 5 timeouts should have completed and been recycled. - // The pool should be at its maximum size (5). - // Creating 10 new items tests that: - // - First 5 items reuse from the pool - // - Remaining 5 items allocate new (pool empty) - // - Pool doesn't grow beyond MAX_POOL_SIZE of 5 + // Phase 5 timeouts have completed and been recycled. The freelist is unbounded; + // creating 10 new items reuses from it and only allocates fresh when empty. auto *component = id(test_sensor); int full_reuse_count = 10; diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index 021917cc25..c697eacea2 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -180,16 +180,10 @@ async def test_scheduler_pool( # Verify pool behavior assert pool_recycle_count > 0, "Should have recycled items to pool" - # Check pool metrics - if pool_recycle_count > 0: - max_pool_size = 0 - for line in log_lines: - if match := recycle_pattern.search(line): - size = int(match.group(1)) - max_pool_size = max(max_pool_size, size) - - # Pool can grow up to its maximum of 5 - assert max_pool_size <= 5, f"Pool grew beyond maximum ({max_pool_size})" + # Pool is unbounded; the cap was the source of the churn it was meant to prevent. + assert pool_full_count == 0, ( + f"Pool should never report full (got {pool_full_count})" + ) # Log summary for debugging print("\nScheduler Pool Test Summary (Python Orchestrated):") From 6ccc2b23b5fcd20d477c2ec51522be8dc6dca673 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 10:49:18 -0500 Subject: [PATCH 0942/2214] [sensor] Drop Component from timeout filters, use self-keyed scheduler Migrates TimeoutFilterBase / TimeoutFilterLast / TimeoutFilterConfigured off Component, mirroring the migration #16132 did for the other Component-based sensor filters. They now arm/re-arm via App.scheduler.set_timeout(this, ...) keyed on the filter pointer; the scheduler cancels and replaces any pending arm in O(1) on each new_value(). Filters live for the program's lifetime, so the self-key never dangles. Net effect: this reverts the design from #11922, which moved timeout filters off the scheduler specifically because the bounded SchedulerItem pool churned on devices with many timers (LD2450 etc.). With #16172 replacing that pool with an unbounded intrusive freelist, the original churn problem is gone and the scheduler is once again the right primitive for this workload. Per-instance footprint shrinks: lose Component (second vptr, packed bookkeeping bytes, ComponentRuntimeStats block when runtime_stats: is enabled) and drop the now-unused timeout_start_time_ field. get_setup_priority() and the loop() poll go with it. Sensor timeout-filter syntax is unchanged. No user-facing change. --- esphome/components/sensor/__init__.py | 3 +-- esphome/components/sensor/filter.cpp | 36 ++++++--------------------- esphome/components/sensor/filter.h | 29 +++++++-------------- 3 files changed, 17 insertions(+), 51 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 48b7d25d4d..822fe33924 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -280,7 +280,7 @@ ThrottleWithPriorityFilter = sensor_ns.class_( ThrottleWithPriorityNanFilter = sensor_ns.class_( "ThrottleWithPriorityNanFilter", Filter ) -TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) +TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component) @@ -730,7 +730,6 @@ async def timeout_filter_to_code(config, filter_id): filter_id.type = TimeoutFilterConfigured template_ = await cg.templatable(config[CONF_VALUE], [], cg.float_) var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) - await cg.register_component(var, {}) return var diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 4896757d3f..44556f6ed7 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -322,41 +322,19 @@ optional or_filter_new_value(Filter **filters, size_t count, float value, return {}; } -// TimeoutFilterBase - shared loop logic -void TimeoutFilterBase::loop() { - // Check if timeout period has elapsed - // Use cached loop start time to avoid repeated millis() calls - const uint32_t now = App.get_loop_component_start_time(); - if (now - this->timeout_start_time_ >= this->time_period_) { - // Timeout fired - get output value from derived class and output it - this->output(this->get_output_value()); - - // Disable loop until next value arrives - this->disable_loop(); - } -} - -float TimeoutFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; } - -// TimeoutFilterLast - "last" mode implementation +// TimeoutFilterLast - "last" mode: re-arm on every input; output the latest value if no further +// input arrives within time_period_. Self-keyed scheduler.set_timeout(this, ...) cancels and +// replaces any pending arm in O(1). optional TimeoutFilterLast::new_value(float value) { - // Store the value to output when timeout fires this->pending_value_ = value; - - // Record when timeout started and enable loop - this->timeout_start_time_ = millis(); - this->enable_loop(); - + App.scheduler.set_timeout(this, this->time_period_, [this]() { this->output(this->pending_value_); }); return value; } -// TimeoutFilterConfigured - configured value mode implementation +// TimeoutFilterConfigured - configured-value mode: re-arm on every input; output the configured +// value (static or lambda) if no further input arrives within time_period_. optional TimeoutFilterConfigured::new_value(float value) { - // Record when timeout started and enable loop - // Note: we don't store the incoming value since we have a configured value - this->timeout_start_time_ = millis(); - this->enable_loop(); - + App.scheduler.set_timeout(this, this->time_period_, [this]() { this->output(this->value_.value()); }); return value; } diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 917a1ce7d5..612c6aab3b 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -412,22 +412,15 @@ class ThrottleWithPriorityNanFilter : public Filter { uint32_t min_time_between_inputs_; }; -// Base class for timeout filters - contains common loop logic -class TimeoutFilterBase : public Filter, public Component { - public: - void loop() override; - float get_setup_priority() const override; - +// Base class for timeout filters. Self-keyed scheduler timeout (`this` as key) re-arms on each +// new_value(). Filter instances live for the program's lifetime, so the scheduler key never dangles. +class TimeoutFilterBase : public Filter { protected: - explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) { this->disable_loop(); } - virtual float get_output_value() = 0; - - uint32_t time_period_; // 4 bytes (timeout duration in ms) - uint32_t timeout_start_time_{0}; // 4 bytes (when the timeout was started) - // Total base: 8 bytes + explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) {} + uint32_t time_period_; }; -// Timeout filter for "last" mode - outputs the last received value after timeout +// "last" mode — outputs the most recent input after time_period_ ms of silence. class TimeoutFilterLast : public TimeoutFilterBase { public: explicit TimeoutFilterLast(uint32_t time_period) : TimeoutFilterBase(time_period) {} @@ -435,12 +428,10 @@ class TimeoutFilterLast : public TimeoutFilterBase { optional new_value(float value) override; protected: - float get_output_value() override { return this->pending_value_; } - float pending_value_{0}; // 4 bytes (value to output when timeout fires) - // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead + float pending_value_{0}; }; -// Timeout filter with configured value - evaluates TemplatableValue after timeout +// Configured-value mode — outputs a static or lambda value after time_period_ ms of silence. class TimeoutFilterConfigured : public TimeoutFilterBase { public: explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableFn &new_value) @@ -449,9 +440,7 @@ class TimeoutFilterConfigured : public TimeoutFilterBase { optional new_value(float value) override; protected: - float get_output_value() override { return this->value_.value(); } - TemplatableFn value_; // 4 bytes (configured output value, can be lambda) - // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead + TemplatableFn value_; }; class DebounceFilter : public Filter, public Component { From 2a451cb8704e8cb1a78491730a2ea0f790d290d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 10:57:11 -0500 Subject: [PATCH 0943/2214] [core] Strengthen scheduler-pool integration test with lower-bound check Adds an assertion that the observed peak pool size exceeds the old MAX_POOL_SIZE=5 cap. Without this, a silent regression that re-introduced a small cap could pass the existing pool_full_count == 0 invariant. Phase 5 + 6 of the fixture schedule 8 + 10 same-component timeouts, so the peak should comfortably exceed 5. Address Copilot review feedback on PR. --- tests/integration/test_scheduler_pool.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index c697eacea2..cc25190e30 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -185,6 +185,18 @@ async def test_scheduler_pool( f"Pool should never report full (got {pool_full_count})" ) + # Verify the pool actually grew past the old MAX_POOL_SIZE=5 cap. + # Phase 5 + Phase 6 schedule 8 + 10 same-component timeouts respectively, so the + # observed peak should comfortably exceed 5. Without this lower-bound check, a + # silent regression that re-introduced a small cap could pass the test above. + max_pool_size = 0 + for line in log_lines: + if match := recycle_pattern.search(line): + max_pool_size = max(max_pool_size, int(match.group(1))) + assert max_pool_size > 5, ( + f"Pool should grow past the old cap of 5; observed peak {max_pool_size}" + ) + # Log summary for debugging print("\nScheduler Pool Test Summary (Python Orchestrated):") print(f" Items recycled to pool: {pool_recycle_count}") From 87a2b623f36d6aa0e8e325acdf4cad4af084da77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 11:01:24 -0500 Subject: [PATCH 0944/2214] [sensor] Drop incorrect O(1) complexity claim from timeout filter comment set_timer_common_'s self-key cancel path scans items_ and to_add_ linearly, so the cancel-and-replace is O(N) in the global scheduler item count, not O(1). Reword to describe the behavior without the complexity claim. Address Copilot review feedback. --- esphome/components/sensor/filter.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 44556f6ed7..a4d42c5eea 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -323,8 +323,8 @@ optional or_filter_new_value(Filter **filters, size_t count, float value, } // TimeoutFilterLast - "last" mode: re-arm on every input; output the latest value if no further -// input arrives within time_period_. Self-keyed scheduler.set_timeout(this, ...) cancels and -// replaces any pending arm in O(1). +// input arrives within time_period_. Self-keyed scheduler.set_timeout(this, ...) cancels any +// pending arm with the same self-key and installs a new one. optional TimeoutFilterLast::new_value(float value) { this->pending_value_ = value; App.scheduler.set_timeout(this, this->time_period_, [this]() { this->output(this->pending_value_); }); From f1d3be4bdaaa1d6fcf7119d075b41cdc57ea31eb Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 30 Apr 2026 12:03:40 -0400 Subject: [PATCH 0945/2214] [core] Simplify RAMAllocator and add internal fallback to external mode (#16171) --- esphome/core/helpers.h | 61 ++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 355db6c7f4..07bcb7a74f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -2045,7 +2045,8 @@ void delay_microseconds_safe(uint32_t us); * Returns `nullptr` in case no memory is available. * * By setting flags, it can be configured to: - * - perform external allocation falling back to main memory if SPI RAM is full or unavailable + * - perform external allocation falling back to internal memory if SPI RAM is full or unavailable (default) + * - perform internal allocation falling back to external memory (with PREFER_INTERNAL) * - perform external allocation only * - perform internal allocation only */ @@ -2054,16 +2055,26 @@ template class RAMAllocator { using value_type = T; enum Flags { - NONE = 0, // Perform external allocation and fall back to internal memory - ALLOC_EXTERNAL = 1 << 0, // Perform external allocation only. - ALLOC_INTERNAL = 1 << 1, // Perform internal allocation only. - ALLOW_FAILURE = 1 << 2, // Does nothing. Kept for compatibility. + NONE = 0, // Perform external allocation and fall back to internal memory + ALLOC_EXTERNAL = 1 << 0, // Perform external allocation only. + ALLOC_INTERNAL = 1 << 1, // Perform internal allocation only. + ALLOW_FAILURE = 1 << 2, // Does nothing. Kept for compatibility. + PREFER_INTERNAL = 1 << 3, // Perform internal allocation and fall back to external memory }; constexpr RAMAllocator() = default; - constexpr RAMAllocator(uint8_t flags) - : flags_((flags & (ALLOC_INTERNAL | ALLOC_EXTERNAL)) != 0 ? (flags & (ALLOC_INTERNAL | ALLOC_EXTERNAL)) - : (ALLOC_INTERNAL | ALLOC_EXTERNAL)) {} + constexpr RAMAllocator(uint8_t flags) { + if (flags & PREFER_INTERNAL) { + this->flags_ = ALLOC_INTERNAL | ALLOC_EXTERNAL | PREFER_INTERNAL; + return; + } + const uint8_t alloc_bits = flags & (ALLOC_INTERNAL | ALLOC_EXTERNAL); + if (alloc_bits != 0) { + this->flags_ = alloc_bits; + return; + } + this->flags_ = ALLOC_INTERNAL | ALLOC_EXTERNAL; + } template constexpr RAMAllocator(const RAMAllocator &other) : flags_{other.flags_} {} T *allocate(size_t n) { return this->allocate(n, sizeof(T)); } @@ -2072,12 +2083,8 @@ template class RAMAllocator { size_t size = n * manual_size; T *ptr = nullptr; #ifdef USE_ESP32 - if (this->flags_ & Flags::ALLOC_EXTERNAL) { - ptr = static_cast(heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); - } - if (ptr == nullptr && this->flags_ & Flags::ALLOC_INTERNAL) { - ptr = static_cast(heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); - } + const auto caps = this->get_caps_(); + ptr = static_cast(heap_caps_malloc_prefer(size, 2, caps[0], caps[1])); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(malloc(size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2091,12 +2098,8 @@ template class RAMAllocator { size_t size = n * manual_size; T *ptr = nullptr; #ifdef USE_ESP32 - if (this->flags_ & Flags::ALLOC_EXTERNAL) { - ptr = static_cast(heap_caps_realloc(p, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); - } - if (ptr == nullptr && this->flags_ & Flags::ALLOC_INTERNAL) { - ptr = static_cast(heap_caps_realloc(p, size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); - } + const auto caps = this->get_caps_(); + ptr = static_cast(heap_caps_realloc_prefer(p, size, 2, caps[0], caps[1])); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(realloc(p, size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2147,6 +2150,24 @@ template class RAMAllocator { } private: +#ifdef USE_ESP32 + /// Returns {primary_caps, fallback_caps} for heap_caps_*_prefer based on the configured flags. + /// PREFER_INTERNAL implies both regions are enabled (enforced by the constructor), so when it is set + /// the primary is internal and the fallback is external. Otherwise the primary is whichever region + /// is enabled (external preferred when both are enabled), and the fallback is the other region (or + /// the same region when only one is enabled, making the second attempt a no-op). + std::array get_caps_() const { + constexpr uint32_t external_caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; + constexpr uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; + if (this->flags_ & PREFER_INTERNAL) { + return {internal_caps, external_caps}; + } + const uint32_t primary = (this->flags_ & ALLOC_EXTERNAL) ? external_caps : internal_caps; + const uint32_t fallback = (this->flags_ & ALLOC_INTERNAL) ? internal_caps : external_caps; + return {primary, fallback}; + } +#endif + uint8_t flags_{ALLOC_INTERNAL | ALLOC_EXTERNAL}; }; From 7d529bff1235cc89f58a1a065f8f0d077880e39f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 11:15:09 -0500 Subject: [PATCH 0946/2214] Revert "[sensor] Drop incorrect O(1) complexity claim from timeout filter comment" This reverts commit 87a2b623f36d6aa0e8e325acdf4cad4af084da77. --- esphome/components/sensor/filter.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 3adc80b94e..485868b8a3 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -318,8 +318,8 @@ optional or_filter_new_value(Filter **filters, size_t count, float value, } // TimeoutFilterLast - "last" mode: re-arm on every input; output the latest value if no further -// input arrives within time_period_. Self-keyed scheduler.set_timeout(this, ...) cancels any -// pending arm with the same self-key and installs a new one. +// input arrives within time_period_. Self-keyed scheduler.set_timeout(this, ...) cancels and +// replaces any pending arm in O(1). optional TimeoutFilterLast::new_value(float value) { this->pending_value_ = value; App.scheduler.set_timeout(this, this->time_period_, [this]() { this->output(this->pending_value_); }); From e9bd84cb43f111d3fe4b68016d1d453667fd2923 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 11:15:10 -0500 Subject: [PATCH 0947/2214] Revert "[sensor] Drop Component from timeout filters, use self-keyed scheduler" This reverts commit 6ccc2b23b5fcd20d477c2ec51522be8dc6dca673. --- esphome/components/sensor/__init__.py | 3 ++- esphome/components/sensor/filter.cpp | 36 +++++++++++++++++++++------ esphome/components/sensor/filter.h | 29 ++++++++++++++------- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index e4e7b97d43..ed02cc2543 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -280,7 +280,7 @@ ThrottleWithPriorityFilter = sensor_ns.class_( ThrottleWithPriorityNanFilter = sensor_ns.class_( "ThrottleWithPriorityNanFilter", Filter ) -TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter) +TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) DebounceFilter = sensor_ns.class_("DebounceFilter", Filter) @@ -730,6 +730,7 @@ async def timeout_filter_to_code(config, filter_id): filter_id.type = TimeoutFilterConfigured template_ = await cg.templatable(config[CONF_VALUE], [], cg.float_) var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) + await cg.register_component(var, {}) return var diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 485868b8a3..5f7f19769a 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -317,19 +317,41 @@ optional or_filter_new_value(Filter **filters, size_t count, float value, return {}; } -// TimeoutFilterLast - "last" mode: re-arm on every input; output the latest value if no further -// input arrives within time_period_. Self-keyed scheduler.set_timeout(this, ...) cancels and -// replaces any pending arm in O(1). +// TimeoutFilterBase - shared loop logic +void TimeoutFilterBase::loop() { + // Check if timeout period has elapsed + // Use cached loop start time to avoid repeated millis() calls + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->timeout_start_time_ >= this->time_period_) { + // Timeout fired - get output value from derived class and output it + this->output(this->get_output_value()); + + // Disable loop until next value arrives + this->disable_loop(); + } +} + +float TimeoutFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; } + +// TimeoutFilterLast - "last" mode implementation optional TimeoutFilterLast::new_value(float value) { + // Store the value to output when timeout fires this->pending_value_ = value; - App.scheduler.set_timeout(this, this->time_period_, [this]() { this->output(this->pending_value_); }); + + // Record when timeout started and enable loop + this->timeout_start_time_ = millis(); + this->enable_loop(); + return value; } -// TimeoutFilterConfigured - configured-value mode: re-arm on every input; output the configured -// value (static or lambda) if no further input arrives within time_period_. +// TimeoutFilterConfigured - configured value mode implementation optional TimeoutFilterConfigured::new_value(float value) { - App.scheduler.set_timeout(this, this->time_period_, [this]() { this->output(this->value_.value()); }); + // Record when timeout started and enable loop + // Note: we don't store the incoming value since we have a configured value + this->timeout_start_time_ = millis(); + this->enable_loop(); + return value; } diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 43fe4e3be7..57a2386a7f 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -413,15 +413,22 @@ class ThrottleWithPriorityNanFilter : public Filter { uint32_t min_time_between_inputs_; }; -// Base class for timeout filters. Self-keyed scheduler timeout (`this` as key) re-arms on each -// new_value(). Filter instances live for the program's lifetime, so the scheduler key never dangles. -class TimeoutFilterBase : public Filter { +// Base class for timeout filters - contains common loop logic +class TimeoutFilterBase : public Filter, public Component { + public: + void loop() override; + float get_setup_priority() const override; + protected: - explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) {} - uint32_t time_period_; + explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) { this->disable_loop(); } + virtual float get_output_value() = 0; + + uint32_t time_period_; // 4 bytes (timeout duration in ms) + uint32_t timeout_start_time_{0}; // 4 bytes (when the timeout was started) + // Total base: 8 bytes }; -// "last" mode — outputs the most recent input after time_period_ ms of silence. +// Timeout filter for "last" mode - outputs the last received value after timeout class TimeoutFilterLast : public TimeoutFilterBase { public: explicit TimeoutFilterLast(uint32_t time_period) : TimeoutFilterBase(time_period) {} @@ -429,10 +436,12 @@ class TimeoutFilterLast : public TimeoutFilterBase { optional new_value(float value) override; protected: - float pending_value_{0}; + float get_output_value() override { return this->pending_value_; } + float pending_value_{0}; // 4 bytes (value to output when timeout fires) + // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead }; -// Configured-value mode — outputs a static or lambda value after time_period_ ms of silence. +// Timeout filter with configured value - evaluates TemplatableValue after timeout class TimeoutFilterConfigured : public TimeoutFilterBase { public: explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableFn &new_value) @@ -441,7 +450,9 @@ class TimeoutFilterConfigured : public TimeoutFilterBase { optional new_value(float value) override; protected: - TemplatableFn value_; + float get_output_value() override { return this->value_.value(); } + TemplatableFn value_; // 4 bytes (configured output value, can be lambda) + // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead }; class DebounceFilter : public Filter { From d36c56d3f5cb165323b563e17d65e4c0c64f16d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 11:29:32 -0500 Subject: [PATCH 0948/2214] [core] Trim scheduler freelist of post-boot peak 10s after setup Boot churn (component init, first sensor reads, retries) inflates the freelist beyond the steady-state high-water mark. Without a one-shot trim that peak would be retained forever. Adds Scheduler::trim_freelist() and schedules it from Application::setup() to fire SCHEDULER_FREELIST_TRIM_DELAY_MS (10s) after setup completes -- well past the bulk of post-setup async work. Items currently in items_/to_add_/defer_queue_ are untouched; only the freelist's recycled items are deleted. Post-trim, the freelist regrows to the new (post-startup) high-water mark. --- esphome/core/application.cpp | 7 +++++++ esphome/core/scheduler.cpp | 22 ++++++++++++++++++++++ esphome/core/scheduler.h | 7 +++++++ 3 files changed, 36 insertions(+) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d03696fbb6..38d3503c2c 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -25,6 +25,10 @@ namespace esphome { static const char *const TAG = "app"; +// Delay after setup() finishes before trimming the scheduler freelist of its post-boot peak. +// 10 s is well past the bulk of post-setup async work (Wi-Fi/MQTT connects, first-read latency). +static constexpr uint32_t SCHEDULER_FREELIST_TRIM_DELAY_MS = 10000; + // Helper function for insertion sort of components by priority // Using insertion sort instead of std::stable_sort saves ~1.3KB of flash // by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) @@ -112,6 +116,9 @@ void Application::setup() { ESP_LOGI(TAG, "setup() finished successfully!"); + // Trim the scheduler freelist of its post-boot peak once startup churn settles. + this->scheduler.set_timeout(this, SCHEDULER_FREELIST_TRIM_DELAY_MS, [this]() { this->scheduler.trim_freelist(); }); + #ifdef USE_SETUP_PRIORITY_OVERRIDE // Clear setup priority overrides to free memory clear_setup_priority_overrides(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index c3cbfc8438..c9ed69a80c 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -899,6 +899,28 @@ void Scheduler::recycle_item_main_loop_(SchedulerItem *item) { #endif } +void Scheduler::trim_freelist() { + LockGuard guard{this->lock_}; + SchedulerItem *item = this->scheduler_item_pool_head_; + size_t freed = 0; + while (item != nullptr) { + SchedulerItem *next = item->next_free; + delete item; +#ifdef ESPHOME_DEBUG_SCHEDULER + this->debug_live_items_--; +#endif + item = next; + freed++; + } + this->scheduler_item_pool_head_ = nullptr; + this->scheduler_item_pool_size_ = 0; +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Freelist trimmed (%zu items freed)", freed); +#else + (void) freed; +#endif +} + #ifdef ESPHOME_DEBUG_SCHEDULER void Scheduler::debug_log_timer_(const SchedulerItem *item, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, uint32_t delay, uint64_t now) { diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index aa41a161be..da15279ae9 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -132,6 +132,13 @@ class Scheduler { // @return Timestamp of the last item that ran, or `now` unchanged if none ran. uint32_t call(uint32_t now); + // Free every SchedulerItem currently sitting in the recycle freelist. Items in + // items_/to_add_/defer_queue_ are untouched. Intended to claim the post-boot + // peak: boot churn (component setup, first sensor reads, retries) inflates the + // freelist beyond what steady-state needs, and without a one-shot trim that + // peak would be retained forever. + void trim_freelist(); + // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). // Inlined: the fast path (nothing to add) is just an atomic load / empty check. From 8b840e8517ec2a60b35818782f2a3dd140f7a5bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 11:31:13 -0500 Subject: [PATCH 0949/2214] [core] Also shrink items_/to_add_/defer_queue_ vector capacity in trim_freelist() The freelist holds the boot-peak count of recycled SchedulerItem*; items_, to_add_, and defer_queue_ hold the boot-peak vector *capacity* of live SchedulerItem*. std::vector grows by doubling and retains capacity even when items drain, so post-boot the vector slack can be larger than the freelist itself. Swap each with a same-content copy to size them exactly to their current contents. Live items are preserved -- the swap-copy idiom builds the new vector from the existing pointers, then the old (over-capacity) vector is destroyed. --- esphome/core/scheduler.cpp | 10 ++++++++++ esphome/core/scheduler.h | 9 ++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index c9ed69a80c..2cf6644c3e 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -914,6 +914,16 @@ void Scheduler::trim_freelist() { } this->scheduler_item_pool_head_ = nullptr; this->scheduler_item_pool_size_ = 0; + + // The vectors that back items_/to_add_/defer_queue_ also retain their boot-peak + // capacity (std::vector grows by doubling). Swap each with a same-content copy to + // reclaim the slack -- the new vector is sized exactly to its current contents. + std::vector(this->items_).swap(this->items_); + std::vector(this->to_add_).swap(this->to_add_); +#ifndef ESPHOME_THREAD_SINGLE + std::vector(this->defer_queue_).swap(this->defer_queue_); +#endif + #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Freelist trimmed (%zu items freed)", freed); #else diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index da15279ae9..7cdb29aae0 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -132,11 +132,10 @@ class Scheduler { // @return Timestamp of the last item that ran, or `now` unchanged if none ran. uint32_t call(uint32_t now); - // Free every SchedulerItem currently sitting in the recycle freelist. Items in - // items_/to_add_/defer_queue_ are untouched. Intended to claim the post-boot - // peak: boot churn (component setup, first sensor reads, retries) inflates the - // freelist beyond what steady-state needs, and without a one-shot trim that - // peak would be retained forever. + // Reclaim memory held by the post-boot peak. Frees every SchedulerItem in the + // recycle freelist and shrinks items_/to_add_/defer_queue_ vector capacity to + // their current sizes (std::vector grows by doubling and otherwise retains the + // peak). Live items in those vectors are preserved. void trim_freelist(); // Move items from to_add_ into the main heap. From d7594db6fdc69a0a96598b2de5d5ffd6d3ab9ed5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 11:38:17 -0500 Subject: [PATCH 0950/2214] [core] Factor trim_freelist() vector shrink into out-of-line helper The three swap-with-copy lines in trim_freelist() inlined the construct-swap-destruct dance per call site, costing ~440 B flash on ESP32. Move the swap-shrink into a noinline private static helper so all three callers share one body. Net flash cost of vector shrinking drops to ~296 B (from ~444 B) while preserving the same RAM-reclaim behaviour. shrink_to_fit() is a non-binding hint that the toolchain ignores, so the swap-with-copy idiom is still required. --- esphome/core/scheduler.cpp | 19 +++++++++++++------ esphome/core/scheduler.h | 4 ++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 2cf6644c3e..ccbdf3dc05 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -899,6 +899,14 @@ void Scheduler::recycle_item_main_loop_(SchedulerItem *item) { #endif } +// Shrink a SchedulerItem* vector's capacity to its current size via the swap-with-copy +// idiom (std::vector::shrink_to_fit() is non-binding and our toolchain ignores it). +// Out-of-line + noinline so the callers in trim_freelist() share one body instead of +// inlining the construct-swap-destruct dance per call site (~440 B flash on ESP32). +void __attribute__((noinline)) Scheduler::shrink_scheduler_vector_(std::vector *v) { + std::vector(*v).swap(*v); +} + void Scheduler::trim_freelist() { LockGuard guard{this->lock_}; SchedulerItem *item = this->scheduler_item_pool_head_; @@ -915,13 +923,12 @@ void Scheduler::trim_freelist() { this->scheduler_item_pool_head_ = nullptr; this->scheduler_item_pool_size_ = 0; - // The vectors that back items_/to_add_/defer_queue_ also retain their boot-peak - // capacity (std::vector grows by doubling). Swap each with a same-content copy to - // reclaim the slack -- the new vector is sized exactly to its current contents. - std::vector(this->items_).swap(this->items_); - std::vector(this->to_add_).swap(this->to_add_); + // items_/to_add_/defer_queue_ retain their boot-peak vector capacity (vector grows + // by doubling and otherwise keeps the peak). Reclaim that slack as well. + shrink_scheduler_vector_(&this->items_); + shrink_scheduler_vector_(&this->to_add_); #ifndef ESPHOME_THREAD_SINGLE - std::vector(this->defer_queue_).swap(this->defer_queue_); + shrink_scheduler_vector_(&this->defer_queue_); #endif #ifdef ESPHOME_DEBUG_SCHEDULER diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7cdb29aae0..ac8ef724c2 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -365,6 +365,10 @@ class Scheduler { SchedulerItem *get_item_from_pool_locked_(); private: + // Out-of-line helper that shrinks a SchedulerItem* vector's capacity to its current + // size. Centralised so trim_freelist() doesn't pay flash cost per call site. + static void shrink_scheduler_vector_(std::vector *v); + // Helper to cancel matching items - must be called with lock held. // When find_first=true, stops after the first match (used by set_timer_common_ where // the cancel-before-add invariant guarantees at most one match). From ce116a69e7ea535b69178b8fc18779aa881e7b23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 11:58:44 -0500 Subject: [PATCH 0951/2214] [core] Drop static on shrink_scheduler_vector_ for clang-tidy compliance Project's .clang-tidy applies different naming rules to static methods (ClassMethodCase, no suffix) vs instance methods (PrivateMethodCase / ProtectedMethodCase, _ suffix required). The helper had a trailing _ but was static, so clang-tidy flagged it. Drop static -- the helper is already noinline'd and called only at trim time, so the hidden this arg is free in any meaningful sense. --- esphome/core/scheduler.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index ac8ef724c2..b640aa86fe 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -367,7 +367,7 @@ class Scheduler { private: // Out-of-line helper that shrinks a SchedulerItem* vector's capacity to its current // size. Centralised so trim_freelist() doesn't pay flash cost per call site. - static void shrink_scheduler_vector_(std::vector *v); + void shrink_scheduler_vector_(std::vector *v); // Helper to cancel matching items - must be called with lock held. // When find_first=true, stops after the first match (used by set_timer_common_ where From 27d7b2caec9a9e79779c9c637ffe013ab4745952 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 11:58:44 -0500 Subject: [PATCH 0952/2214] [core] Drop static on shrink_scheduler_vector_ for clang-tidy compliance Project's .clang-tidy applies different naming rules to static methods (ClassMethodCase, no suffix) vs instance methods (PrivateMethodCase / ProtectedMethodCase, _ suffix required). The helper had a trailing _ but was static, so clang-tidy flagged it. Drop static -- the helper is already noinline'd and called only at trim time, so the hidden this arg is free in any meaningful sense. --- esphome/core/scheduler.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index ac8ef724c2..b640aa86fe 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -367,7 +367,7 @@ class Scheduler { private: // Out-of-line helper that shrinks a SchedulerItem* vector's capacity to its current // size. Centralised so trim_freelist() doesn't pay flash cost per call site. - static void shrink_scheduler_vector_(std::vector *v); + void shrink_scheduler_vector_(std::vector *v); // Helper to cancel matching items - must be called with lock held. // When find_first=true, stops after the first match (used by set_timer_common_ where From 0651a905bcb25e0dcc2926899b4bdac747b61229 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 12:02:55 -0500 Subject: [PATCH 0953/2214] [core] Avoid std::bad_array_new_length pull in shrink_scheduler_vector_ The classic std::vector swap-with-copy idiom (vector(other).swap(other)) instantiates the iterator-range copy constructor, which pulls in std::__throw_bad_array_new_length and the related typeinfo + vtable + dtor + what() method (~118 B of stdlib RTTI). Build into a temp via reserve + push_back instead, then move-assign: - reserve uses ::operator new (throws bad_alloc, already linked). - push_back without growth is the noexcept tail path. - move-assign just swaps pointers, no allocation. Same shrink semantics, saves ~128 B flash on ESP32. Also adds a fast-path early return for the case where capacity already equals size (common after a quiet period, since vector capacity only grows when crossing the doubling threshold). --- esphome/core/scheduler.cpp | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index ccbdf3dc05..a7c624486d 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -899,12 +899,22 @@ void Scheduler::recycle_item_main_loop_(SchedulerItem *item) { #endif } -// Shrink a SchedulerItem* vector's capacity to its current size via the swap-with-copy -// idiom (std::vector::shrink_to_fit() is non-binding and our toolchain ignores it). -// Out-of-line + noinline so the callers in trim_freelist() share one body instead of -// inlining the construct-swap-destruct dance per call site (~440 B flash on ESP32). +// Shrink a SchedulerItem* vector's capacity to its current size. +// std::vector::shrink_to_fit() is non-binding and our toolchain ignores it; the classic +// swap-with-copy idiom (std::vector(other).swap(other)) instantiates the iterator-range +// constructor which pulls in std::__throw_bad_array_new_length and ~120 B of related +// stdlib RTTI/typeinfo. Build into a temp via reserve + push_back instead, then move-assign: +// reserve uses operator new (throws bad_alloc, already linked) and push_back without growth +// is the noexcept tail path. Move-assign just swaps pointers. +// Out-of-line + noinline so the callers in trim_freelist() share one body. void __attribute__((noinline)) Scheduler::shrink_scheduler_vector_(std::vector *v) { - std::vector(*v).swap(*v); + if (v->capacity() == v->size()) + return; // already exact, common after a quiet period + std::vector tmp; + tmp.reserve(v->size()); + for (SchedulerItem *p : *v) + tmp.push_back(p); + *v = std::move(tmp); } void Scheduler::trim_freelist() { From 5535b05bf57c720429e714559be953934bf4c78c Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:13:40 +0200 Subject: [PATCH 0954/2214] Fix tests --- esphome/espota2.py | 6 --- tests/unit_tests/test_espota2.py | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index ec9cc0f90a..e1cc52e7ce 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -287,12 +287,6 @@ def perform_ota( if ota_type != OTA_TYPE_UPDATE_APP: raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") - if ( - ota_type != OTA_TYPE_UPDATE_APP - and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS - ): - raise OTAError("Device only supports app updates") - if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: upload_contents = gzip.compress(file_contents, compresslevel=9) _LOGGER.info("Compressed to %s bytes", len(upload_contents)) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 7ac88ee129..37b9ef173f 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -776,3 +776,80 @@ def test_perform_ota_version_differences( # For v2.0, verify more recv calls due to chunk acknowledgments assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_extended_protocol_app( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test OTA partition table update.""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol + bytes( + [ + espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION + | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ] + ), # Device feature flags + 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 + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "partitions.bin", + espota2.OTA_TYPE_UPDATE_APP, + ) + + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) + + # Verify features were sent (compression + SHA256 support + extended protocol) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ] + ) + ) + + # Verify ota type was sent + assert mock_socket.sendall.call_args_list[2] == call( + bytes([espota2.OTA_TYPE_UPDATE_APP]) + ) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_extended_protocol_unsupported_type( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test OTA fails when OTA type is unsupported by the client.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Unsupported OTA type"): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "partitions.bin", + 255, + ) From d48aad8c4d42431beb4f7f89f35bf42628128a04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 12:27:54 -0500 Subject: [PATCH 0955/2214] [esp32] Replace 512B stack buffer in printf wraps with picolibc cookie FILE (#16170) --- esphome/components/esp32/printf_stubs.cpp | 77 +++++++++++++++++++---- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index 386fbbd79d..908b4023ea 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -13,14 +13,21 @@ * and printf() calls in SDK components are only in debug/assert paths * (gpio_dump_io_configuration, ringbuf diagnostics) that are either * GC'd or never called. Crash backtraces and panic output are - * unaffected — they use esp_rom_printf() which is a ROM function + * unaffected; they use esp_rom_printf() which is a ROM function * and does not go through libc. * - * These stubs redirect through vsnprintf() (which uses _svfprintf_r - * already in the binary) and fwrite(), allowing the linker to - * dead-code eliminate _vfprintf_r. + * On picolibc (default for IDF >= 5 on RISC-V, IDF >= 6 everywhere) we + * route output through a stack-allocated cookie FILE that forwards each + * byte to the real target stream via fputc(). Picolibc's tinystdio + * vfprintf walks the FILE::put callback one character at a time, so this + * costs ~32 bytes of stack for the cookie struct vs. a 512-byte format + * buffer. The buffered path overflows the loopTask stack on IDF 6. * - * Saves ~11 KB of flash. + * On newlib (IDF <= 5 on Xtensa) we keep the original snprintf-then-fwrite + * path because that loopTask stack budget has plenty of headroom for the + * 512-byte buffer; the picolibc-only crash above does not affect it. + * + * Saves ~11 KB of flash on newlib, ~2.8 KB on picolibc. * * To disable these wraps, set enable_full_printf: true in the esp32 * advanced config section. @@ -30,10 +37,55 @@ #include #include +#ifndef __PICOLIBC__ #include "esp_system.h" +#endif namespace esphome::esp32 {} +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +#ifdef __PICOLIBC__ + +#include +#include + +extern int __real_vfprintf(FILE *stream, const char *fmt, va_list ap); + +namespace { + +struct CookieFile { + FILE base; + FILE *target; +}; + +// cookie_put() recovers CookieFile* from FILE* via reinterpret_cast, which is +// only well-defined when FILE is the first member at offset 0 and CookieFile +// is standard-layout. +static_assert(offsetof(CookieFile, base) == 0, "FILE must be the first member of CookieFile"); +static_assert(std::is_standard_layout::value, "CookieFile must be standard-layout"); + +int cookie_put(char c, FILE *stream) { + auto *cookie = reinterpret_cast(stream); + return fputc(static_cast(c), cookie->target); +} + +const FILE COOKIE_FILE_TEMPLATE = FDEV_SETUP_STREAM(cookie_put, nullptr, nullptr, _FDEV_SETUP_WRITE); + +} // namespace + +int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { + CookieFile cookie; + cookie.base = COOKIE_FILE_TEMPLATE; + cookie.target = stream; + return __real_vfprintf(&cookie.base, fmt, ap); +} + +int __wrap_vprintf(const char *fmt, va_list ap) { return __wrap_vfprintf(stdout, fmt, ap); } + +#else // !__PICOLIBC__ + static constexpr size_t PRINTF_BUFFER_SIZE = 512; // These stubs are essentially dead code at runtime — ESPHome replaces the @@ -55,14 +107,18 @@ static int write_printf_buffer(FILE *stream, char *buf, int len) { return len; } -// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -extern "C" { - int __wrap_vprintf(const char *fmt, va_list ap) { char buf[PRINTF_BUFFER_SIZE]; return write_printf_buffer(stdout, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); } +int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { + char buf[PRINTF_BUFFER_SIZE]; + return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); +} + +#endif // __PICOLIBC__ + int __wrap_printf(const char *fmt, ...) { va_list ap; va_start(ap, fmt); @@ -71,11 +127,6 @@ int __wrap_printf(const char *fmt, ...) { return len; } -int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { - char buf[PRINTF_BUFFER_SIZE]; - return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); -} - int __wrap_fprintf(FILE *stream, const char *fmt, ...) { va_list ap; va_start(ap, fmt); From 61261b4a592fa449a1e820a6392c4696fbc3d49a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 12:33:22 -0500 Subject: [PATCH 0956/2214] [libretiny] Move HAL bodies into components/libretiny/hal.cpp + inline trivial dispatches (#16113) --- esphome/components/libretiny/core.cpp | 53 +-------------------------- esphome/components/libretiny/hal.cpp | 53 +++++++++++++++++++++++++++ esphome/core/hal/hal_libretiny.h | 18 +++++++-- 3 files changed, 69 insertions(+), 55 deletions(-) create mode 100644 esphome/components/libretiny/hal.cpp diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index f46abe3b81..8686a41e64 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -1,55 +1,6 @@ #ifdef USE_LIBRETINY -#include "core.h" -#include "esphome/core/defines.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" -#include "preferences.h" - -#include -#include - -void setup(); -void loop(); - -namespace esphome { - -// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } - -void arch_init() { - libretiny::setup_preferences(); - lt_wdt_enable(10000L); -#ifdef USE_BK72XX - // BK72xx SDK creates the main Arduino task at priority 3, which is lower than - // all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop - // stalls whenever WiFi background processing runs, because the main task - // cannot resume until every higher-priority task finishes. - // - // By contrast, RTL87xx creates the main task at osPriorityRealtime (highest). - // - // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the - // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. - // This is safe because ESPHome yields voluntarily via wakeable_delay() and - // the Arduino mainTask yield() after each loop() iteration. - static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; - static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); - vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY); -#endif -#if LT_GPIO_RECOVER - lt_gpio_recover(); -#endif -} - -void arch_restart() { - lt_reboot(); - while (1) { - } -} -void HOT arch_feed_wdt() { lt_wdt_feed(); } -uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); } -uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); } - -} // namespace esphome +// HAL functions live in hal.cpp. core.cpp is intentionally empty for +// libretiny — there is no extra component bootstrap to keep here. #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp new file mode 100644 index 0000000000..e6dbb7296c --- /dev/null +++ b/esphome/components/libretiny/hal.cpp @@ -0,0 +1,53 @@ +#ifdef USE_LIBRETINY + +#include "core.h" +#include "esphome/core/hal.h" +#include "preferences.h" + +#include +#include + +// Empty libretiny namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// libretiny component's API. +namespace esphome::libretiny {} // namespace esphome::libretiny + +namespace esphome { + +// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), +// arch_feed_wdt(), arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() +// inlined in core/hal/hal_libretiny.h. + +void arch_init() { + libretiny::setup_preferences(); + lt_wdt_enable(10000L); +#ifdef USE_BK72XX + // BK72xx SDK creates the main Arduino task at priority 3, which is lower than + // all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop + // stalls whenever WiFi background processing runs, because the main task + // cannot resume until every higher-priority task finishes. + // + // By contrast, RTL87xx creates the main task at osPriorityRealtime (highest). + // + // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the + // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. + // This is safe because ESPHome yields voluntarily via wakeable_delay() and + // the Arduino mainTask yield() after each loop() iteration. + static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; + static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); + vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY); +#endif +#if LT_GPIO_RECOVER + lt_gpio_recover(); +#endif +} + +void arch_restart() { + lt_reboot(); + while (1) { + } +} + +} // namespace esphome + +#endif // USE_LIBRETINY diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index ecfe830fe3..db0fc11bfb 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -51,8 +51,16 @@ extern "C" void yield(void); extern "C" void delay(unsigned long ms); extern "C" unsigned long micros(void); extern "C" unsigned long millis(void); +extern "C" void delayMicroseconds(unsigned int us); // NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +// Forward decls from libretiny's family for the inline arch_* +// wrappers below. Pulling the full header would drag in the rest of the +// LibreTiny C API. +extern "C" void lt_wdt_feed(void); +extern "C" uint32_t lt_cpu_get_cycle_count(void); +extern "C" uint32_t lt_cpu_get_freq(void); + namespace esphome { /// Returns true when executing inside an interrupt handler. @@ -88,11 +96,13 @@ __attribute__((always_inline)) inline uint32_t millis() { return static_cast Date: Thu, 30 Apr 2026 20:02:07 +0200 Subject: [PATCH 0957/2214] Remove conditional compilation --- esphome/components/esphome/ota/__init__.py | 13 +---------- .../components/esphome/ota/ota_esphome.cpp | 22 +++---------------- esphome/components/esphome/ota/ota_esphome.h | 2 -- tests/unit_tests/test_espota2.py | 6 ++--- 4 files changed, 7 insertions(+), 36 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index ee3b7f0c20..bfa5ffb55c 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,13 +16,11 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv from esphome.types import ConfigType -CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access" - _LOGGER = logging.getLogger(__name__) @@ -77,10 +75,6 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) - if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS) and not CORE.is_esp32: - raise cv.Invalid( - f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" - ) else: new_ota_conf.append(ota_conf) @@ -131,7 +125,6 @@ CONFIG_SCHEMA = cv.All( ln882x=8820, rtl87xx=8892, ): cv.port, - cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.string, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" @@ -166,10 +159,6 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PASSWORD]: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) - - if config.get(CONF_ALLOW_PARTITION_ACCESS): - cg.add_define("USE_OTA_PARTITIONS") - # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 0796a97677..f7395cbbfd 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -100,9 +100,6 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Password configured"); } #endif -#ifdef USE_OTA_PARTITIONS - ESP_LOGCONFIG(TAG, " Partition access allowed"); -#endif } void ESPHomeOTAComponent::loop() { @@ -211,19 +208,16 @@ void ESPHomeOTAComponent::handle_handshake_() { const bool supports_compression = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); - // Compose the feature-ack response. When USE_OTA_PARTITIONS is enabled and the client + // Compose the feature-ack response. When the client // negotiates the extended protocol we emit a 2-byte response (marker + server feature flags); // otherwise we emit the single-byte legacy response. The #ifdef wraps only the extended-proto // branch so the legacy branch reads as unconditional code in either build configuration. -#ifdef USE_OTA_PARTITIONS this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS | (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); - } else -#endif - { + } else { this->handshake_buf_[0] = supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; } @@ -231,11 +225,7 @@ void ESPHomeOTAComponent::handle_handshake_() { } case OTAState::FEATURE_ACK: { -#ifdef USE_OTA_PARTITIONS const size_t ack_size = this->extended_proto_ ? 2 : 1; -#else - const size_t ack_size = 1; -#endif if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } @@ -322,9 +312,7 @@ void ESPHomeOTAComponent::handle_data_() { uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; -#ifdef USE_OTA_PARTITIONS ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; -#endif #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif @@ -340,7 +328,6 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); -#ifdef USE_OTA_PARTITIONS if (this->extended_proto_) { // Read ota type, 1 byte if (!this->readall_(buf, 1)) { @@ -350,7 +337,6 @@ void ESPHomeOTAComponent::handle_data_() { ota_type = static_cast(buf[0]); } ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); -#endif // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { @@ -371,13 +357,11 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif - // This will block for a few seconds as it locks flash -#ifdef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } -#endif + // This will block for a few seconds as it locks flash error_code = this->backend_->begin(ota_size); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 9bed9240aa..f612451ab0 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -91,9 +91,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD -#ifdef USE_OTA_PARTITIONS bool extended_proto_{false}; -#endif socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 37b9ef173f..1baf3f5db4 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -782,7 +782,7 @@ def test_perform_ota_version_differences( def test_perform_ota_extended_protocol_app( mock_socket: Mock, mock_file: io.BytesIO ) -> None: - """Test OTA partition table update.""" + """Test OTA extended protocol app update.""" recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response bytes([espota2.OTA_VERSION_2_0]), # Version number @@ -807,7 +807,7 @@ def test_perform_ota_extended_protocol_app( mock_socket, "testpass", mock_file, - "partitions.bin", + "test.bin", espota2.OTA_TYPE_UPDATE_APP, ) @@ -850,6 +850,6 @@ def test_perform_ota_extended_protocol_unsupported_type( mock_socket, "testpass", mock_file, - "partitions.bin", + "test.bin", 255, ) From 5aef5df18b1e6ec41b0ee55c010f797448364b9c Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:16:52 +0200 Subject: [PATCH 0958/2214] Apply suggestions --- esphome/components/esphome/ota/ota_esphome.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f7395cbbfd..ae83678f3b 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -215,8 +215,7 @@ void ESPHomeOTAComponent::handle_handshake_() { this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; - this->handshake_buf_[1] = - SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS | (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); + this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); } else { this->handshake_buf_[0] = supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; @@ -225,7 +224,9 @@ void ESPHomeOTAComponent::handle_handshake_() { } case OTAState::FEATURE_ACK: { - const size_t ack_size = this->extended_proto_ ? 2 : 1; + static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1; + static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2; + const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE; if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } From cba3fc51be9c52bd85524a2652d375ea8421ddc1 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:18:30 +0200 Subject: [PATCH 0959/2214] Delete partition_access test --- tests/components/ota/test-partition_access.esp32-idf.yaml | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml deleted file mode 100644 index 0cbf854952..0000000000 --- a/tests/components/ota/test-partition_access.esp32-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -ota: - - platform: esphome - allow_partition_access: true - -<<: !include common.yaml From 2fbfb4c385506070b2ab23d4114ad221d48da544 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:10:53 -0500 Subject: [PATCH 0960/2214] [ci] Split integration tests into 3 buckets when count is more than 10 (#16152) --- .github/workflows/ci.yml | 29 +++++----- script/determine-jobs.py | 66 ++++++++++++++++++++-- tests/script/test_determine_jobs.py | 88 +++++++++++++++++++++++++++-- 3 files changed, 156 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57053c3645..3af1709774 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,8 +199,7 @@ jobs: - common outputs: integration-tests: ${{ steps.determine.outputs.integration-tests }} - integration-tests-run-all: ${{ steps.determine.outputs.integration-tests-run-all }} - integration-test-files: ${{ steps.determine.outputs.integration-test-files }} + integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} python-linters: ${{ steps.determine.outputs.python-linters }} @@ -243,8 +242,7 @@ jobs: # Extract individual fields echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT - echo "integration-tests-run-all=$(echo "$output" | jq -r '.integration_tests_run_all')" >> $GITHUB_OUTPUT - echo "integration-test-files=$(echo "$output" | jq -c '.integration_test_files')" >> $GITHUB_OUTPUT + echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT @@ -267,12 +265,16 @@ jobs: key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} integration-tests: - name: Run integration tests + name: Run integration tests (${{ matrix.bucket.name }}) runs-on: ubuntu-latest needs: - common - determine-jobs if: needs.determine-jobs.outputs.integration-tests == 'true' + strategy: + fail-fast: false + matrix: + bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -299,19 +301,14 @@ jobs: run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - name: Run integration tests env: - INTEGRATION_TEST_FILES: ${{ needs.determine-jobs.outputs.integration-test-files }} - INTEGRATION_TESTS_RUN_ALL: ${{ needs.determine-jobs.outputs.integration-tests-run-all }} + # JSON array of test paths; parsed into a bash array below to avoid + # shell word-splitting / glob hazards. + BUCKET_TESTS: ${{ toJson(matrix.bucket.tests) }} run: | . venv/bin/activate - if [[ "$INTEGRATION_TESTS_RUN_ALL" == "true" ]]; then - echo "Running all integration tests" - pytest -vv --no-cov --tb=native -n auto tests/integration/ - else - # Parse JSON array into bash array to avoid shell expansion issues - mapfile -t test_files < <(echo "$INTEGRATION_TEST_FILES" | jq -r '.[]') - echo "Running ${#test_files[@]} specific integration tests" - pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" - fi + mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') + echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" + pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" cpp-unit-tests: name: Run C++ unit tests diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 6fd7ab297c..c0cf8ecbdc 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -6,8 +6,7 @@ what files have changed. It outputs JSON with the following structure: { "integration_tests": true/false, - "integration_tests_run_all": true/false, - "integration_test_files": ["tests/integration/test_foo.py", ...], + "integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...], "clang_tidy": true/false, "clang_format": true/false, "python_linters": true/false, @@ -81,6 +80,62 @@ CLANG_TIDY_SPLIT_THRESHOLD = 65 # Isolated components count as 10x, groupable components count as 1x COMPONENT_TEST_BATCH_SIZE = 40 +# Integration test bucketing: when more than the threshold tests are scheduled, +# fan out across this many parallel jobs. Below the threshold, a single job runs. +INTEGRATION_TESTS_SPLIT_THRESHOLD = 10 +INTEGRATION_TESTS_SPLIT_BUCKETS = 3 + + +def _split_list(items: list[str], n: int) -> list[list[str]]: + """Split a list into n roughly-equal contiguous parts (matches script/clang-tidy).""" + k, m = divmod(len(items), n) + return [items[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)] + + +def _all_integration_test_files() -> list[str]: + """Return all integration test file paths, sorted, relative to repo root.""" + return sorted( + str(p.relative_to(root_path)) + for p in (Path(root_path) / "tests" / "integration").glob("test_*.py") + ) + + +def _compute_integration_test_buckets( + integration_run_all: bool, + integration_test_files: list[str], +) -> tuple[bool, list[dict[str, Any]]]: + """Compute (run_integration, buckets) from the determine_integration_tests result. + + Pure function for unit testing — no I/O beyond `_all_integration_test_files` + when `integration_run_all` is set. + + `buckets` is a list of `{name, tests}` dicts where `tests` is a JSON-friendly + list of file paths so the workflow can build a bash array via jq, avoiding + shell word-splitting / glob hazards. + """ + if integration_run_all: + files = _all_integration_test_files() + else: + files = sorted(integration_test_files) + + # Empty list (e.g. run_all expansion with no files on disk) would otherwise + # cause the workflow to invoke pytest with no path argument and collect + # tests outside tests/integration/. Suppress the run instead. + if not files: + return False, [] + + if len(files) > INTEGRATION_TESTS_SPLIT_THRESHOLD: + parts = [ + part for part in _split_list(files, INTEGRATION_TESTS_SPLIT_BUCKETS) if part + ] + buckets = [ + {"name": f"{i + 1}/{len(parts)}", "tests": part} + for i, part in enumerate(parts) + ] + else: + buckets = [{"name": "1/1", "tests": files}] + return True, buckets + class Platform(StrEnum): """Platform identifiers for memory impact analysis.""" @@ -812,7 +867,9 @@ def main() -> None: integration_run_all, integration_test_files = determine_integration_tests( args.branch ) - run_integration = integration_run_all or bool(integration_test_files) + run_integration, integration_test_buckets = _compute_integration_test_buckets( + integration_run_all, integration_test_files + ) run_clang_tidy = should_run_clang_tidy(args.branch) run_clang_format = should_run_clang_format(args.branch) run_python_linters = should_run_python_linters(args.branch) @@ -944,8 +1001,7 @@ def main() -> None: output: dict[str, Any] = { "integration_tests": run_integration, - "integration_tests_run_all": integration_run_all, - "integration_test_files": integration_test_files, + "integration_test_buckets": integration_test_buckets, "clang_tidy": run_clang_tidy, "clang_tidy_mode": clang_tidy_mode, "clang_format": run_clang_format, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 44c110b689..e85f1757b0 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -122,10 +122,19 @@ def test_main_all_tests_should_run( "esphome/helpers.py", ] + # Stable, deterministic stand-in for the tests/integration/ glob so the + # bucket assertions don't drift with the real test count. + fake_test_files = [f"tests/integration/test_{i:03d}.py" for i in range(15)] + # Run main function with mocked argv with ( patch("sys.argv", ["determine-jobs.py"]), patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), + patch.object( + determine_jobs, + "_all_integration_test_files", + return_value=fake_test_files, + ), patch.object( determine_jobs, "get_changed_components", @@ -161,8 +170,24 @@ def test_main_all_tests_should_run( output = json.loads(captured.out) assert output["integration_tests"] is True - assert output["integration_tests_run_all"] is True - assert output["integration_test_files"] == [] + # run_all=True expands to the full glob and pre-buckets into 3 parts. + # Each bucket's `tests` is a JSON list of file paths. + assert isinstance(output["integration_test_buckets"], list) + assert len(output["integration_test_buckets"]) == 3 + assert [b["name"] for b in output["integration_test_buckets"]] == [ + "1/3", + "2/3", + "3/3", + ] + for bucket in output["integration_test_buckets"]: + assert isinstance(bucket["tests"], list) + for path in bucket["tests"]: + assert isinstance(path, str) + bucket_files = [f for b in output["integration_test_buckets"] for f in b["tests"]] + assert bucket_files == fake_test_files + # Bucket sizes are balanced (max-min difference at most 1). + sizes = [len(b["tests"]) for b in output["integration_test_buckets"]] + assert max(sizes) - min(sizes) <= 1 assert output["clang_tidy"] is True assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is True @@ -247,8 +272,7 @@ def test_main_no_tests_should_run( output = json.loads(captured.out) assert output["integration_tests"] is False - assert output["integration_tests_run_all"] is False - assert output["integration_test_files"] == [] + assert output["integration_test_buckets"] == [] assert output["clang_tidy"] is False assert output["clang_tidy_mode"] == "disabled" assert output["clang_format"] is False @@ -332,8 +356,7 @@ def test_main_with_branch_argument( output = json.loads(captured.out) assert output["integration_tests"] is False - assert output["integration_tests_run_all"] is False - assert output["integration_test_files"] == [] + assert output["integration_test_buckets"] == [] assert output["clang_tidy"] is True assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is False @@ -357,6 +380,59 @@ def test_main_with_branch_argument( assert output["cpp_unit_tests_components"] == ["mqtt"] +def test_compute_integration_test_buckets_empty() -> None: + """No integration tests scheduled => (False, []).""" + run, buckets = determine_jobs._compute_integration_test_buckets(False, []) + assert run is False + assert buckets == [] + + +def test_compute_integration_test_buckets_below_threshold() -> None: + """A small explicit list (<= threshold) => single 1/1 bucket with that list.""" + files = [f"tests/integration/test_{name}.py" for name in ("c", "a", "b")] + run, buckets = determine_jobs._compute_integration_test_buckets(False, files) + assert run is True + assert buckets == [{"name": "1/1", "tests": sorted(files)}] + + +def test_compute_integration_test_buckets_at_threshold_stays_single() -> None: + """Exactly INTEGRATION_TESTS_SPLIT_THRESHOLD files => still one bucket + (the split kicks in only when count is strictly greater than threshold).""" + files = [ + f"tests/integration/test_{i:02d}.py" + for i in range(determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD) + ] + run, buckets = determine_jobs._compute_integration_test_buckets(False, files) + assert run is True + assert len(buckets) == 1 + assert buckets[0]["name"] == "1/1" + assert buckets[0]["tests"] == sorted(files) + + +def test_compute_integration_test_buckets_just_over_threshold_splits() -> None: + """One file over the threshold triggers the 3-bucket fan-out, balanced.""" + n = determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD + 1 + files = [f"tests/integration/test_{i:02d}.py" for i in range(n)] + run, buckets = determine_jobs._compute_integration_test_buckets(False, files) + assert run is True + assert [b["name"] for b in buckets] == ["1/3", "2/3", "3/3"] + union = [path for b in buckets for path in b["tests"]] + assert union == sorted(files) + sizes = [len(b["tests"]) for b in buckets] + assert max(sizes) - min(sizes) <= 1 + + +def test_compute_integration_test_buckets_run_all_with_empty_glob_disables_run() -> ( + None +): + """run_all=True but glob returns no files => run suppressed (otherwise + pytest would collect tests outside tests/integration/).""" + with patch.object(determine_jobs, "_all_integration_test_files", return_value=[]): + run, buckets = determine_jobs._compute_integration_test_buckets(True, []) + assert run is False + assert buckets == [] + + def test_determine_integration_tests( monkeypatch: pytest.MonkeyPatch, ) -> None: From e085cb50d98a7a784050d5e2948754247d07fb9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:11:30 -0500 Subject: [PATCH 0961/2214] [sensor] Drop Component from filter classes, use self-keyed scheduler (#16132) --- esphome/components/sensor/__init__.py | 19 ++++++------------- esphome/components/sensor/filter.cpp | 21 +++++++-------------- esphome/components/sensor/filter.h | 15 +++++---------- 3 files changed, 18 insertions(+), 37 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 48b7d25d4d..c18aa32f37 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -266,7 +266,7 @@ StreamingMovingAverageFilter = sensor_ns.class_("StreamingMovingAverageFilter", ExponentialMovingAverageFilter = sensor_ns.class_( "ExponentialMovingAverageFilter", Filter ) -ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter, cg.Component) +ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter) LambdaFilter = sensor_ns.class_("LambdaFilter", Filter) StatelessLambdaFilter = sensor_ns.class_("StatelessLambdaFilter", Filter) OffsetFilter = sensor_ns.class_("OffsetFilter", Filter) @@ -283,8 +283,8 @@ ThrottleWithPriorityNanFilter = sensor_ns.class_( TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) -DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component) -HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component) +DebounceFilter = sensor_ns.class_("DebounceFilter", Filter) +HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter) DeltaFilter = sensor_ns.class_("DeltaFilter", Filter) OrFilter = sensor_ns.class_("OrFilter", Filter) CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter) @@ -567,9 +567,7 @@ async def exponential_moving_average_filter_to_code(config, filter_id): "throttle_average", ThrottleAverageFilter, cv.positive_time_period_milliseconds ) async def throttle_average_filter_to_code(config, filter_id): - var = cg.new_Pvariable(filter_id, config) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, config) @FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda) @@ -698,13 +696,10 @@ HEARTBEAT_SCHEMA = cv.Schema( async def heartbeat_filter_to_code(config, filter_id): if isinstance(config, dict): var = cg.new_Pvariable(filter_id, config[CONF_PERIOD]) - await cg.register_component(var, {}) cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) return var - var = cg.new_Pvariable(filter_id, config) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, config) TIMEOUT_SCHEMA = cv.maybe_simple_value( @@ -738,9 +733,7 @@ async def timeout_filter_to_code(config, filter_id): "debounce", DebounceFilter, cv.positive_time_period_milliseconds ) async def debounce_filter_to_code(config, filter_id): - var = cg.new_Pvariable(filter_id, config) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, config) CONF_DATAPOINTS = "datapoints" diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 4896757d3f..5f7f19769a 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -13,11 +13,6 @@ namespace esphome::sensor { static const char *const TAG = "sensor.filter"; -// Filter scheduler IDs. -// Each filter is its own Component instance, so the scheduler scopes -// IDs by component pointer — no risk of collisions between instances. -constexpr uint32_t FILTER_ID = 0; - // Filter void Filter::input(float value) { ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value); @@ -185,8 +180,9 @@ optional ThrottleAverageFilter::new_value(float value) { } return {}; } -void ThrottleAverageFilter::setup() { - this->set_interval(FILTER_ID, this->time_period_, [this]() { +void ThrottleAverageFilter::initialize(Sensor *parent, Filter *next) { + Filter::initialize(parent, next); + App.scheduler.set_interval(this, this->time_period_, [this]() { ESP_LOGVV(TAG, "ThrottleAverageFilter(%p)::interval(sum=%f, n=%i)", this, this->sum_, this->n_); if (this->n_ == 0) { if (this->have_nan_) @@ -199,7 +195,6 @@ void ThrottleAverageFilter::setup() { this->have_nan_ = false; }); } -float ThrottleAverageFilter::get_setup_priority() const { return setup_priority::HARDWARE; } // LambdaFilter LambdaFilter::LambdaFilter(lambda_filter_t lambda_filter) : lambda_filter_(std::move(lambda_filter)) {} @@ -362,13 +357,12 @@ optional TimeoutFilterConfigured::new_value(float value) { // DebounceFilter optional DebounceFilter::new_value(float value) { - this->set_timeout(FILTER_ID, this->time_period_, [this, value]() { this->output(value); }); + App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); }); return {}; } DebounceFilter::DebounceFilter(uint32_t time_period) : time_period_(time_period) {} -float DebounceFilter::get_setup_priority() const { return setup_priority::HARDWARE; } // HeartbeatFilter HeartbeatFilter::HeartbeatFilter(uint32_t time_period) : time_period_(time_period), last_input_(NAN) {} @@ -384,8 +378,9 @@ optional HeartbeatFilter::new_value(float value) { return {}; } -void HeartbeatFilter::setup() { - this->set_interval(FILTER_ID, this->time_period_, [this]() { +void HeartbeatFilter::initialize(Sensor *parent, Filter *next) { + Filter::initialize(parent, next); + App.scheduler.set_interval(this, this->time_period_, [this]() { ESP_LOGVV(TAG, "HeartbeatFilter(%p)::interval(has_value=%s, last_input=%f)", this, YESNO(this->has_value_), this->last_input_); if (!this->has_value_) @@ -395,8 +390,6 @@ void HeartbeatFilter::setup() { }); } -float HeartbeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { for (size_t i = 0; i < count; i++) { if (!std::isfinite(functions[i][2]) || value < functions[i][2]) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 917a1ce7d5..d61df11d9b 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -254,16 +254,14 @@ class ExponentialMovingAverageFilter : public Filter { * * It takes the average of all the values received in a period of time. */ -class ThrottleAverageFilter : public Filter, public Component { +class ThrottleAverageFilter : public Filter { public: explicit ThrottleAverageFilter(uint32_t time_period); - void setup() override; + void initialize(Sensor *parent, Filter *next) override; optional new_value(float value) override; - float get_setup_priority() const override; - protected: float sum_{0.0f}; unsigned int n_{0}; @@ -454,25 +452,22 @@ class TimeoutFilterConfigured : public TimeoutFilterBase { // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead }; -class DebounceFilter : public Filter, public Component { +class DebounceFilter : public Filter { public: explicit DebounceFilter(uint32_t time_period); optional new_value(float value) override; - float get_setup_priority() const override; - protected: uint32_t time_period_; }; -class HeartbeatFilter : public Filter, public Component { +class HeartbeatFilter : public Filter { public: explicit HeartbeatFilter(uint32_t time_period); - void setup() override; + void initialize(Sensor *parent, Filter *next) override; optional new_value(float value) override; - float get_setup_priority() const override; void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } From 2f3e16b4823870cbf701c9273529d185b37a34eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:12:06 -0500 Subject: [PATCH 0962/2214] [bk72xx] Apply CFG_SUPPORT_BLE=0 SDK option to BK7238 (#16181) --- .../components/beken_spi_led_strip/light.py | 1 + esphome/components/libretiny/__init__.py | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py index 31572cd800..9093b08b62 100644 --- a/esphome/components/beken_spi_led_strip/light.py +++ b/esphome/components/beken_spi_led_strip/light.py @@ -62,6 +62,7 @@ CONF_IS_WRGB = "is_wrgb" SUPPORTED_PINS = { libretiny.const.FAMILY_BK7231N: [16], libretiny.const.FAMILY_BK7231T: [16], + libretiny.const.FAMILY_BK7238: [16], libretiny.const.FAMILY_BK7251: [16], } diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 40b8c8dc6c..74ac51d200 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -37,6 +37,7 @@ from .const import ( CONF_UART_PORT, FAMILIES, FAMILY_BK7231N, + FAMILY_BK7238, FAMILY_COMPONENT, FAMILY_FRIENDLY, FAMILY_RTL8710B, @@ -56,19 +57,22 @@ CODEOWNERS = ["@kuba2k2"] AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True -# BK7231N SDK options to disable unused features. +# BLE 5.x BK SDK options to disable unused features. # Disabling BLE saves ~21KB RAM and ~200KB Flash because BLE init code is # called unconditionally by the SDK. ESPHome doesn't use BLE on LibreTiny. # -# This only works on BK7231N (BLE 5.x). Other BK72XX chips using BLE 4.2 -# (BK7231T, BK7231Q, BK7251; BK7252 boards use the BK7251 family) have a bug -# where the BLE library still links and references undefined symbols when -# CFG_SUPPORT_BLE=0. +# This only works on BLE 5.x BK chips (BK7231N, BK7238). Other BK72XX chips +# using BLE 4.2 (BK7231T, BK7231Q, BK7251; BK7252 boards use the BK7251 family) +# have a bug where the BLE library still links and references undefined symbols +# when CFG_SUPPORT_BLE=0. +# +# On BK7238 the SDK also hangs at WiFi STA enable when BLE init runs, so +# disabling it is required for reliable boot, not just an optimization. # # Other options like CFG_TX_EVM_TEST, CFG_RX_SENSITIVITY_TEST, CFG_SUPPORT_BKREG, # CFG_SUPPORT_OTA_HTTP, and CFG_USE_SPI_SLAVE were evaluated but provide no # NOLINT # measurable benefit - the linker already strips unreferenced code via -gc-sections. -_BK7231N_SYS_CONFIG_OPTIONS = [ +_BLE5_BK_SYS_CONFIG_OPTIONS = [ "CFG_SUPPORT_BLE=0", ] @@ -549,9 +553,9 @@ async def component_to_code(config): cg.add_platformio_option("custom_fw_version", __version__) # Apply chip-specific SDK options to save RAM/Flash - if config[CONF_FAMILY] == FAMILY_BK7231N: + if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238): cg.add_platformio_option( - "custom_options.sys_config#h", _BK7231N_SYS_CONFIG_OPTIONS + "custom_options.sys_config#h", _BLE5_BK_SYS_CONFIG_OPTIONS ) # Tune lwIP for ESPHome's actual needs. From 3b3e003aa3312635ce22454703d386d49563fdca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:13:10 -0500 Subject: [PATCH 0963/2214] [sensor] Pack ThrottleAverageFilter have_nan_ into n_ bitfield (-4 B/instance) (#16169) --- esphome/components/sensor/__init__.py | 7 ++++++- esphome/components/sensor/filter.h | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index c18aa32f37..ed02cc2543 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -564,7 +564,12 @@ async def exponential_moving_average_filter_to_code(config, filter_id): @FILTER_REGISTRY.register( - "throttle_average", ThrottleAverageFilter, cv.positive_time_period_milliseconds + "throttle_average", + ThrottleAverageFilter, + cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(hours=24)), + ), ) async def throttle_average_filter_to_code(config, filter_id): return cg.new_Pvariable(filter_id, config) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index d61df11d9b..57a2386a7f 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -264,9 +264,12 @@ class ThrottleAverageFilter : public Filter { protected: float sum_{0.0f}; - unsigned int n_{0}; uint32_t time_period_; - bool have_nan_{false}; + // Sample count packed with NaN-seen flag in a single 32-bit word. + // n_ is bounded by YAML cap on time_period_ (24 h) × max plausible source + // rate (1 kHz) = 86.4M ≪ 2^31, so 31 bits has 25x headroom. + uint32_t n_ : 31 {0}; + uint32_t have_nan_ : 1 {0}; }; using lambda_filter_t = std::function(float)>; From 45e78e4114a9dba7628fca434163c8e4795670b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:13:54 -0500 Subject: [PATCH 0964/2214] [core] Inline loop gate expression to avoid stale local reuse (#16167) --- esphome/core/application.h | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 4a18714d0d..5baf570e62 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -637,10 +637,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // flag preserves it. wake_request_take() exchange-clears the flag; wakes // that arrive during Phase B re-set it and run Phase B again on the next // iteration. - const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency(); - const uint32_t elapsed = now - this->last_loop_; - const bool woke = esphome::wake_request_take(); - const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_); + // + // wake_request_take() must always be called first since it does an + // atomic exchange to clear the flag, and we want to run the component phase + // if either the flag was set or the scheduler requested a high-frequency loop. + const bool do_component_phase = esphome::wake_request_take() || HighFrequencyLoopRequester::is_high_frequency() || + (now - this->last_loop_ >= this->loop_interval_); if (do_component_phase) { ComponentPhaseGuard phase_guard{*this}; From 148d478decdbc07c69442162799d4a28a5e41c8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:14:20 -0500 Subject: [PATCH 0965/2214] [api] Add encode/decode benchmarks for Z-Wave, IR/RF, and serial proxy messages (#16157) --- tests/benchmarks/components/api/__init__.py | 14 +- .../components/api/bench_proto_proxy.cpp | 280 ++++++++++++++++++ .../esphome/components/infrared/infrared.h | 45 +++ .../radio_frequency/radio_frequency.h | 51 ++++ .../components/serial_proxy/serial_proxy.h | 46 +++ .../components/zwave_proxy/zwave_proxy.h | 29 ++ 6 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 tests/benchmarks/components/api/bench_proto_proxy.cpp create mode 100644 tests/benchmarks/stubs/esphome/components/infrared/infrared.h create mode 100644 tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h create mode 100644 tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h create mode 100644 tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index eb86492964..0d02e0b054 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -11,11 +11,19 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code(config): await original_to_code(config) - # Enable BLE proto message types for benchmarks. The real - # bluetooth_proxy component is ESP32-only; a lightweight stub - # header in tests/benchmarks/stubs/ satisfies the include. + # Enable proxy proto message types for benchmarks. The real + # 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("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + cg.add_define("USE_ZWAVE_PROXY") + cg.add_define("USE_INFRARED") + cg.add_define("USE_IR_RF") + cg.add_define("USE_RADIO_FREQUENCY") + cg.add_define("USE_SERIAL_PROXY") + cg.add_define("SERIAL_PROXY_COUNT", 0) + cg.add_define("ESPHOME_ENTITY_INFRARED_COUNT", 0) + cg.add_define("ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT", 0) manifest.to_code = to_code diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp new file mode 100644 index 0000000000..fa3191a969 --- /dev/null +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -0,0 +1,280 @@ +// Encode/decode microbenchmarks for proxy message families that carry +// high-volume traffic (Z-Wave, IR/RF, serial). Mirrors the existing +// BluetoothLERawAdvertisementsResponse benchmarks in bench_proto_encode.cpp. + +#include + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Encodes `src` into `out`. Caller owns `out` and must keep it alive across +// the decode loop (decoded messages may store pointers back into its bytes). +template static void encode_into(APIBuffer &out, const T &src) { + out.resize(src.calculate_size()); + ProtoWriteBuffer writer(&out, 0); + src.encode(writer); +} + +// --- ZWaveProxyFrame (Z-Wave frame, ~16 bytes payload) --- + +#ifdef USE_ZWAVE_PROXY + +static const uint8_t kZWaveFrameData[] = {0x01, 0x09, 0x00, 0x13, 0x01, 0x02, 0x00, 0x00, + 0x25, 0x00, 0x05, 0xC4, 0x00, 0x00, 0x00, 0x00}; + +static void Encode_ZWaveProxyFrame(benchmark::State &state) { + ZWaveProxyFrame msg; + msg.data = kZWaveFrameData; + msg.data_len = sizeof(kZWaveFrameData); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_ZWaveProxyFrame); + +static void Decode_ZWaveProxyFrame(benchmark::State &state) { + ZWaveProxyFrame source; + source.data = kZWaveFrameData; + source.data_len = sizeof(kZWaveFrameData); + APIBuffer encoded; + encode_into(encoded, source); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ZWaveProxyFrame msg; + msg.decode(data, size); + benchmark::DoNotOptimize(msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_ZWaveProxyFrame); + +static const uint8_t kZWaveRequestData[] = {0xDE, 0xAD, 0xBE, 0xEF}; + +static void Decode_ZWaveProxyRequest(benchmark::State &state) { + ZWaveProxyRequest source; + source.type = enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE; + source.data = kZWaveRequestData; + source.data_len = sizeof(kZWaveRequestData); + APIBuffer encoded; + encode_into(encoded, source); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ZWaveProxyRequest msg; + msg.decode(data, size); + benchmark::DoNotOptimize(msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_ZWaveProxyRequest); + +#endif // USE_ZWAVE_PROXY + +// --- SerialProxyDataReceived encode + SerialProxyWriteRequest decode --- +// +// SerialProxyWriteRequest is decode-only (SOURCE_CLIENT) but has the same +// wire layout as SerialProxyDataReceived, so we encode via the latter and +// decode as the former. + +#ifdef USE_SERIAL_PROXY + +static constexpr size_t kSerialPayloadSize = 64; +static const uint8_t kSerialPayload[kSerialPayloadSize] = { + 0x55, 0xAA, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, + 0xCD, 0xEF, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, + 0xFF, 0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0, + 0xF0, 0x0F, 0x1F, 0x2F, 0x3F, 0x4F, 0x5F, 0x6F, 0x7F, 0x8F, 0x9F, 0xAF, 0xBF, 0xCF, 0xDF, 0xEF}; + +static void Encode_SerialProxyDataReceived(benchmark::State &state) { + SerialProxyDataReceived msg; + msg.instance = 0; + msg.set_data(kSerialPayload, kSerialPayloadSize); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_SerialProxyDataReceived); + +static void Decode_SerialProxyWriteRequest(benchmark::State &state) { + SerialProxyDataReceived source; + source.instance = 0; + source.set_data(kSerialPayload, kSerialPayloadSize); + APIBuffer encoded; + encode_into(encoded, source); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + SerialProxyWriteRequest msg; + msg.decode(data, size); + benchmark::DoNotOptimize(msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_SerialProxyWriteRequest); + +#endif // USE_SERIAL_PROXY + +// --- InfraredRFReceiveEvent encode (100 sint32 timings) + +// InfraredRFTransmitRawTimingsRequest decode (hand-built wire bytes) --- + +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) + +// Mark/space pairs simulating a typical RC-5 / NEC capture (100 timings). +static std::vector make_ir_timings_100() { + std::vector v; + v.reserve(100); + for (int i = 0; i < 100; i++) { + v.push_back((i % 2 == 0) ? 560 : -560); + } + return v; +} + +static const std::vector &get_ir_timings_100() { + static const std::vector timings = make_ir_timings_100(); + return timings; +} + +static void Encode_InfraredRFReceiveEvent(benchmark::State &state) { + InfraredRFReceiveEvent msg; + msg.key = 0xDEADBEEF; + msg.timings = &get_ir_timings_100(); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_InfraredRFReceiveEvent); + +static void CalculateSize_InfraredRFReceiveEvent(benchmark::State &state) { + InfraredRFReceiveEvent msg; + msg.key = 0xDEADBEEF; + msg.timings = &get_ir_timings_100(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_InfraredRFReceiveEvent); + +// Hand-built wire bytes for InfraredRFTransmitRawTimingsRequest (decode-only, +// no sister message with identical wire layout). +// field 2 (key, fixed32): tag=0x15, 4 LE bytes +// field 3 (carrier_frequency): tag=0x18, varint +// field 4 (repeat_count): tag=0x20, varint +// field 5 (timings, packed sint32): tag=0x2A, length varint, packed payload +// field 6 (modulation): tag=0x30, varint +static APIBuffer build_infrared_rf_transmit_wire() { + uint8_t bytes[256]; + size_t len = 0; + + auto put_byte = [&](uint8_t b) { bytes[len++] = b; }; + auto put_varint = [&](uint32_t v) { + while (v >= 0x80) { + bytes[len++] = static_cast((v & 0x7F) | 0x80); + v >>= 7; + } + bytes[len++] = static_cast(v); + }; + auto encode_zigzag = [](int32_t v) -> uint32_t { + return (static_cast(v) << 1) ^ static_cast(v >> 31); + }; + + put_byte(0x15); + put_byte(0xEF); + put_byte(0xBE); + put_byte(0xAD); + put_byte(0xDE); + put_byte(0x18); + put_varint(38000); + put_byte(0x20); + put_varint(2); + + uint8_t packed[200]; + size_t packed_len = 0; + for (int i = 0; i < 100; i++) { + int32_t value = (i % 2 == 0) ? 560 : -560; + uint32_t zz = encode_zigzag(value); + while (zz >= 0x80) { + packed[packed_len++] = static_cast((zz & 0x7F) | 0x80); + zz >>= 7; + } + packed[packed_len++] = static_cast(zz); + } + put_byte(0x2A); + put_varint(static_cast(packed_len)); + std::memcpy(bytes + len, packed, packed_len); + len += packed_len; + // field 6: modulation = 1 (non-zero so it's actually emitted and exercises + // decode_varint for this field, matching the documented layout above). + put_byte(0x30); + put_varint(1); + + APIBuffer buf; + buf.resize(len); + std::memcpy(buf.data(), bytes, len); + return buf; +} + +static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state) { + auto encoded = build_infrared_rf_transmit_wire(); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + InfraredRFTransmitRawTimingsRequest msg; + msg.decode(data, size); + benchmark::DoNotOptimize(msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_InfraredRFTransmitRawTimingsRequest); + +#endif // USE_IR_RF || USE_RADIO_FREQUENCY + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/stubs/esphome/components/infrared/infrared.h b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h new file mode 100644 index 0000000000..874e7a270b --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h @@ -0,0 +1,45 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp and Application need when USE_INFRARED is defined, +// without pulling in the real remote_base/RMT dependencies. +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" + +namespace esphome::infrared { + +class Infrared; + +class InfraredCall { + public: + explicit InfraredCall(Infrared *parent) : parent_(parent) {} + InfraredCall &set_carrier_frequency(uint32_t /*frequency*/) { return *this; } + InfraredCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) { + return *this; + } + InfraredCall &set_repeat_count(uint32_t /*count*/) { return *this; } + void perform() {} + + protected: + Infrared *parent_; +}; + +class InfraredTraits { + public: + uint32_t get_receiver_frequency_hz() const { return 0; } +}; + +class Infrared : public Component, public EntityBase { + public: + Infrared() = default; + InfraredTraits &get_traits() { return this->traits_; } + const InfraredTraits &get_traits() const { return this->traits_; } + InfraredCall make_call() { return InfraredCall(this); } + uint32_t get_capability_flags() const { return 0; } + + protected: + InfraredTraits traits_; +}; + +} // namespace esphome::infrared diff --git a/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h b/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h new file mode 100644 index 0000000000..72fc08034b --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h @@ -0,0 +1,51 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp and Application need when USE_RADIO_FREQUENCY is defined. +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" + +namespace esphome::radio_frequency { + +enum RadioFrequencyModulation : uint32_t { + RADIO_FREQUENCY_MODULATION_OOK = 0, +}; + +class RadioFrequency; + +class RadioFrequencyCall { + public: + explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {} + RadioFrequencyCall &set_frequency(uint32_t /*frequency*/) { return *this; } + RadioFrequencyCall &set_modulation(RadioFrequencyModulation /*mod*/) { return *this; } + RadioFrequencyCall &set_repeat_count(uint32_t /*count*/) { return *this; } + RadioFrequencyCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) { + return *this; + } + void perform() {} + + protected: + RadioFrequency *parent_; +}; + +class RadioFrequencyTraits { + public: + uint32_t get_frequency_min_hz() const { return 0; } + uint32_t get_frequency_max_hz() const { return 0; } + uint32_t get_supported_modulations() const { return 0; } +}; + +class RadioFrequency : public Component, public EntityBase { + public: + RadioFrequency() = default; + RadioFrequencyTraits &get_traits() { return this->traits_; } + const RadioFrequencyTraits &get_traits() const { return this->traits_; } + RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } + uint32_t get_capability_flags() const { return 0; } + + protected: + RadioFrequencyTraits traits_; +}; + +} // namespace esphome::radio_frequency diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h new file mode 100644 index 0000000000..bab27549e7 --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -0,0 +1,46 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp and Application need when USE_SERIAL_PROXY is defined, +// without pulling in the real UART implementation. +#pragma once + +#include +#include +#include "esphome/components/api/api_pb2.h" + +namespace esphome { + +namespace api { +class APIConnection; +} // namespace api + +namespace uart { +enum class UARTFlushResult : uint8_t { + UART_FLUSH_RESULT_SUCCESS, + UART_FLUSH_RESULT_ASSUMED_SUCCESS, + UART_FLUSH_RESULT_TIMEOUT, + UART_FLUSH_RESULT_FAILED, +}; +} // namespace uart + +namespace serial_proxy { + +class SerialProxy { + public: + void set_instance_index(uint32_t index) { this->instance_index_ = index; } + uint32_t get_instance_index() const { return this->instance_index_; } + const char *get_name() const { return ""; } + api::enums::SerialProxyPortType get_port_type() const { return {}; } + api::APIConnection *get_api_connection() { return nullptr; } + void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {} + void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint32_t stop_bits, uint32_t data_size) {} + void write_from_client(const uint8_t *data, size_t len) {} + void set_modem_pins(uint32_t line_states) {} + uint32_t get_modem_pins() const { return 0; } + uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + + protected: + uint32_t instance_index_{0}; +}; + +} // namespace serial_proxy +} // namespace esphome diff --git a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h new file mode 100644 index 0000000000..ba97e81236 --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h @@ -0,0 +1,29 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp needs when USE_ZWAVE_PROXY is defined, +// without pulling in the real UART-based ZWaveProxy implementation. +#pragma once + +#include "esphome/components/api/api_pb2.h" + +namespace esphome { +namespace api { +class APIConnection; +} // namespace api + +namespace zwave_proxy { + +class ZWaveProxy { + public: + api::APIConnection *get_api_connection() { return nullptr; } + void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {} + void send_frame(const uint8_t *data, size_t length) {} + void api_connection_authenticated(api::APIConnection *conn) {} + uint32_t get_feature_flags() const { return 0; } + uint32_t get_home_id() { return 0; } +}; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern ZWaveProxy *global_zwave_proxy; + +} // namespace zwave_proxy +} // namespace esphome From b708d1a8260b55acd9c0c7c66af3fbadc6b4c564 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:14:34 -0500 Subject: [PATCH 0966/2214] [core] Drop unused DELAY_ACTION from InternalSchedulerID enum (#16151) --- esphome/core/component.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 185d51ab37..5baf795ca6 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -65,7 +65,6 @@ inline constexpr uint32_t SCHEDULER_DONT_RUN = 4294967295UL; /// with component-level NUMERIC_ID values, even if the uint32_t values overlap. enum class InternalSchedulerID : uint32_t { POLLING_UPDATE = 0, // PollingComponent interval - DELAY_ACTION = 1, // DelayAction timeout }; // Forward declaration From ba7c06785a03f1f7c570b572e5aa677d0ed9d073 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:14:55 -0500 Subject: [PATCH 0967/2214] [mdns] Broadcast config_hash TXT record on _esphomelib._tcp (#16145) --- esphome/components/mdns/mdns_component.cpp | 40 ++++++++++++++++++++-- esphome/components/mdns/mdns_component.h | 33 +++++------------- esphome/components/mdns/mdns_host.cpp | 7 +++- 3 files changed, 52 insertions(+), 28 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index e05373ac5d..9bf27e71e4 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -39,7 +39,39 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); -void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf) { +void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { +#ifdef USE_MDNS_STORE_SERVICES + auto &services = this->services_; +#else + StaticVector services_storage; + auto &services = services_storage; +#endif + +#ifdef USE_API +#ifdef USE_MDNS_STORE_SERVICES + get_mac_address_into_buffer(this->mac_address_); + char *mac_ptr = this->mac_address_; + format_hex_to(this->config_hash_str_, App.get_config_hash()); + char *cfg_ptr = this->config_hash_str_; +#else + char mac_address[MAC_ADDRESS_BUFFER_SIZE]; + char config_hash_str[CONFIG_HASH_STR_SIZE]; + get_mac_address_into_buffer(mac_address); + format_hex_to(config_hash_str, App.get_config_hash()); + char *mac_ptr = mac_address; + char *cfg_ptr = config_hash_str; +#endif +#else + char *mac_ptr = nullptr; + char *cfg_ptr = nullptr; +#endif + + this->compile_records_(services, mac_ptr, cfg_ptr); + platform_register(this, services); +} + +void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf, + char *config_hash_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. @@ -47,6 +79,7 @@ void MDNSComponent::compile_records_(StaticVector &); - void setup_buffers_and_register_(PlatformRegisterFn platform_register) { -#ifdef USE_MDNS_STORE_SERVICES - auto &services = this->services_; -#else - StaticVector services_storage; - auto &services = services_storage; -#endif - -#ifdef USE_API -#ifdef USE_MDNS_STORE_SERVICES - get_mac_address_into_buffer(this->mac_address_); - char *mac_ptr = this->mac_address_; -#else - char mac_address[MAC_ADDRESS_BUFFER_SIZE]; - get_mac_address_into_buffer(mac_address); - char *mac_ptr = mac_address; -#endif -#else - char *mac_ptr = nullptr; -#endif - - this->compile_records_(services, mac_ptr); - platform_register(this, services); - } + void setup_buffers_and_register_(PlatformRegisterFn platform_register); #ifdef USE_MDNS_DYNAMIC_TXT /// Storage for runtime-generated TXT values from user lambdas @@ -159,6 +139,8 @@ class MDNSComponent final : public Component #if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) /// Fixed buffer for MAC address (only needed when services are stored) char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; + /// Fixed buffer for config hash hex string (only needed when services are stored) + char config_hash_str_[CONFIG_HASH_STR_SIZE]; #endif #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; @@ -167,7 +149,8 @@ class MDNSComponent final : public Component // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif - void compile_records_(StaticVector &services, char *mac_address_buf); + void compile_records_(StaticVector &services, char *mac_address_buf, + char *config_hash_buf); }; } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 4d902319b8..1e66a10df0 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -3,6 +3,8 @@ #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "mdns_component.h" @@ -13,10 +15,13 @@ void MDNSComponent::setup() { #ifdef USE_API get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; + format_hex_to(this->config_hash_str_, App.get_config_hash()); + char *cfg_ptr = this->config_hash_str_; #else char *mac_ptr = nullptr; + char *cfg_ptr = nullptr; #endif - this->compile_records_(this->services_, mac_ptr); + this->compile_records_(this->services_, mac_ptr, cfg_ptr); #endif // Host platform doesn't have actual mDNS implementation } From 550444dc34da939cd3c1da77042d7d07c88fe766 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:15:18 -0500 Subject: [PATCH 0968/2214] [binary_sensor] Drop Component from filter classes, use self-keyed scheduler (#16131) --- esphome/components/binary_sensor/__init__.py | 15 +++------ esphome/components/binary_sensor/filter.cpp | 34 +++++++------------- esphome/components/binary_sensor/filter.h | 18 +++-------- 3 files changed, 22 insertions(+), 45 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1456e5bc66..db82290750 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -143,15 +143,15 @@ BinarySensorCondition = binary_sensor_ns.class_("BinarySensorCondition", Conditi # Filters Filter = binary_sensor_ns.class_("Filter") -TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter, cg.Component) -DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter, cg.Component) -DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter, cg.Component) -DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter, cg.Component) +TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter) +DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter) +DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter) +DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter) InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter) AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter, cg.Component) LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter) StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter) -SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter, cg.Component) +SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter) _LOGGER = getLogger(__name__) @@ -175,7 +175,6 @@ async def invert_filter_to_code(config, filter_id): ) async def timeout_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_timeout_value(template_)) return var @@ -203,7 +202,6 @@ async def timeout_filter_to_code(config, filter_id): ) async def delayed_on_off_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) if isinstance(config, dict): template_ = await cg.templatable(config[CONF_TIME_ON], [], cg.uint32) cg.add(var.set_on_delay(template_)) @@ -221,7 +219,6 @@ async def delayed_on_off_filter_to_code(config, filter_id): ) async def delayed_on_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_delay(template_)) return var @@ -234,7 +231,6 @@ async def delayed_on_filter_to_code(config, filter_id): ) async def delayed_off_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_delay(template_)) return var @@ -306,7 +302,6 @@ async def lambda_filter_to_code(config, filter_id): ) async def settle_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_delay(template_)) return var diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 914060ce13..0a463ee9a9 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -4,16 +4,14 @@ #include "filter.h" #include "binary_sensor.h" +#include "esphome/core/application.h" namespace esphome::binary_sensor { static const char *const TAG = "sensor.filter"; -// Timeout IDs for filter classes. -// Each filter is its own Component instance, so the scheduler scopes -// IDs by component pointer — no risk of collisions between instances. -constexpr uint32_t FILTER_TIMEOUT_ID = 0; -// AutorepeatFilter needs two distinct IDs (both timeouts on the same component) +// AutorepeatFilter still inherits Component (it schedules two distinct timer +// purposes), so it keeps the (Component *, id) scheduler API. constexpr uint32_t AUTOREPEAT_TIMING_ID = 0; constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1; @@ -34,46 +32,40 @@ void Filter::input(bool value) { } void TimeoutFilter::input(bool value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); }); + App.scheduler.set_timeout(this, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); }); // we do not de-dup here otherwise changes from invalid to valid state will not be output this->output(value); } optional DelayedOnOffFilter::new_value(bool value) { if (value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->on_delay_.value(), [this]() { this->output(true); }); + App.scheduler.set_timeout(this, this->on_delay_.value(), [this]() { this->output(true); }); } else { - this->set_timeout(FILTER_TIMEOUT_ID, this->off_delay_.value(), [this]() { this->output(false); }); + App.scheduler.set_timeout(this, this->off_delay_.value(), [this]() { this->output(false); }); } return {}; } -float DelayedOnOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional DelayedOnFilter::new_value(bool value) { if (value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(true); }); + App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(true); }); return {}; } else { - this->cancel_timeout(FILTER_TIMEOUT_ID); + App.scheduler.cancel_timeout(this); return false; } } -float DelayedOnFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional DelayedOffFilter::new_value(bool value) { if (!value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(false); }); + App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(false); }); return {}; } else { - this->cancel_timeout(FILTER_TIMEOUT_ID); + App.scheduler.cancel_timeout(this); return true; } } -float DelayedOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional InvertFilter::new_value(bool value) { return !value; } // AutorepeatFilterBase @@ -118,20 +110,18 @@ optional LambdaFilter::new_value(bool value) { return this->f_(value); } optional SettleFilter::new_value(bool value) { if (!this->steady_) { - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this, value]() { + App.scheduler.set_timeout(this, this->delay_.value(), [this, value]() { this->steady_ = true; this->output(value); }); return {}; } else { this->steady_ = false; - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; }); + App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->steady_ = true; }); return value; } } -float SettleFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - } // namespace esphome::binary_sensor #endif // USE_BINARY_SENSOR_FILTER diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 2e45554f81..8ff57cab0c 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -29,7 +29,7 @@ class Filter { Deduplicator dedup_; }; -class TimeoutFilter : public Filter, public Component { +class TimeoutFilter : public Filter { public: optional new_value(bool value) override { return value; } void input(bool value) override; @@ -39,12 +39,10 @@ class TimeoutFilter : public Filter, public Component { TemplatableFn timeout_delay_{}; }; -class DelayedOnOffFilter final : public Filter, public Component { +class DelayedOnOffFilter final : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_on_delay(T delay) { this->on_delay_ = delay; } template void set_off_delay(T delay) { this->off_delay_ = delay; } @@ -53,24 +51,20 @@ class DelayedOnOffFilter final : public Filter, public Component { TemplatableFn off_delay_{}; }; -class DelayedOnFilter : public Filter, public Component { +class DelayedOnFilter : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_delay(T delay) { this->delay_ = delay; } protected: TemplatableFn delay_{}; }; -class DelayedOffFilter : public Filter, public Component { +class DelayedOffFilter : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_delay(T delay) { this->delay_ = delay; } protected: @@ -146,12 +140,10 @@ class StatelessLambdaFilter : public Filter { optional (*f_)(bool); }; -class SettleFilter : public Filter, public Component { +class SettleFilter : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_delay(T delay) { this->delay_ = delay; } protected: From 24fdfcf1a18be08c720a674384cd15712168d7d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:15:41 -0500 Subject: [PATCH 0969/2214] [rp2040] Move HAL bodies into components/rp2040/hal.cpp + inline trivial dispatches (#16114) --- esphome/components/rp2040/core.cpp | 39 ++-------------------------- esphome/components/rp2040/hal.cpp | 41 ++++++++++++++++++++++++++++++ esphome/core/hal/hal_rp2040.h | 19 +++++++++++--- 3 files changed, 59 insertions(+), 40 deletions(-) create mode 100644 esphome/components/rp2040/hal.cpp diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index d3dc1cf2bb..11f23ccfef 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -1,41 +1,6 @@ #ifdef USE_RP2040 -#include "core.h" -#include "esphome/core/defines.h" -#ifdef USE_RP2040_CRASH_HANDLER -#include "crash_handler.h" -#endif -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" - -#include "hardware/timer.h" -#include "hardware/watchdog.h" - -namespace esphome { - -// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. -void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } -void arch_restart() { - watchdog_reboot(0, 0, 10); - while (1) { - continue; - } -} - -void arch_init() { -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_read_and_clear(); -#endif -#if USE_RP2040_WATCHDOG_TIMEOUT > 0 - watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); -#endif -} - -void HOT arch_feed_wdt() { watchdog_update(); } - -uint32_t HOT arch_get_cpu_cycle_count() { return ulMainGetRunTimeCounterValue(); } -uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } - -} // namespace esphome +// HAL functions live in hal.cpp. core.cpp is intentionally empty for +// rp2040 — there is no extra component bootstrap to keep here. #endif // USE_RP2040 diff --git a/esphome/components/rp2040/hal.cpp b/esphome/components/rp2040/hal.cpp new file mode 100644 index 0000000000..7475205d60 --- /dev/null +++ b/esphome/components/rp2040/hal.cpp @@ -0,0 +1,41 @@ +#ifdef USE_RP2040 + +#include "core.h" +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#ifdef USE_RP2040_CRASH_HANDLER +#include "crash_handler.h" +#endif + +#include "hardware/watchdog.h" + +// Empty rp2040 namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// rp2040 component's API. +namespace esphome::rp2040 {} // namespace esphome::rp2040 + +namespace esphome { + +// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), +// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in core/hal/hal_rp2040.h. +void arch_restart() { + watchdog_reboot(0, 0, 10); + while (1) { + continue; + } +} + +void arch_init() { +#ifdef USE_RP2040_CRASH_HANDLER + rp2040::crash_handler_read_and_clear(); +#endif +#if USE_RP2040_WATCHDOG_TIMEOUT > 0 + watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); +#endif +} + +uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/core/hal/hal_rp2040.h index 46f6e421cd..27a9b23c0b 100644 --- a/esphome/core/hal/hal_rp2040.h +++ b/esphome/core/hal/hal_rp2040.h @@ -20,8 +20,17 @@ extern "C" unsigned long millis(void); // Forward decl from . extern "C" uint64_t time_us_64(void); +// Forward decls from pico-sdk / FreeRTOS port for the inline arch_* +// wrappers below. +extern "C" void watchdog_update(void); +extern "C" unsigned long ulMainGetRunTimeCounterValue(void); + namespace esphome { +// Forward decl from helpers.h. +// NOLINTNEXTLINE(readability-redundant-declaration) +void delay_microseconds_safe(uint32_t us); + /// Returns true when executing inside an interrupt handler. __attribute__((always_inline)) inline bool in_isr_context() { uint32_t ipsr; @@ -35,9 +44,13 @@ __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::time_us_64()); } -void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) -void arch_feed_wdt(); -uint32_t arch_get_cpu_cycle_count(); +// NOLINTNEXTLINE(readability-identifier-naming) +__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } +__attribute__((always_inline)) inline void arch_feed_wdt() { watchdog_update(); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { + return static_cast(ulMainGetRunTimeCounterValue()); +} + void arch_init(); uint32_t arch_get_cpu_freq_hz(); From 3d69169141984487a28d5b1e323a3740748a1408 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:16:16 -0500 Subject: [PATCH 0970/2214] [climate] Fold ControlAction fields into a single stateless lambda (#16044) --- esphome/components/climate/__init__.py | 87 +++++++++++------- esphome/components/climate/automation.h | 35 ++----- .../fixtures/climate_control_action.yaml | 92 +++++++++++++++++++ .../test_climate_control_action.py | 84 +++++++++++++++++ 4 files changed, 238 insertions(+), 60 deletions(-) create mode 100644 tests/integration/fixtures/climate_control_action.yaml create mode 100644 tests/integration/test_climate_control_action.py diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 0fdb18a92c..7c9002d6dc 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -48,13 +48,13 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, Lambda, 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 LambdaExpression, MockObjClass IS_PLATFORM_COMPONENT = True @@ -487,38 +487,57 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( ) async def climate_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (mode := config.get(CONF_MODE)) is not None: - template_ = await cg.templatable(mode, args, ClimateMode) - cg.add(var.set_mode(template_)) - if (target_temp := config.get(CONF_TARGET_TEMPERATURE)) is not None: - template_ = await cg.templatable(target_temp, args, cg.float_) - cg.add(var.set_target_temperature(template_)) - if (target_temp_low := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None: - template_ = await cg.templatable(target_temp_low, args, cg.float_) - cg.add(var.set_target_temperature_low(template_)) - if (target_temp_high := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None: - template_ = await cg.templatable(target_temp_high, args, cg.float_) - cg.add(var.set_target_temperature_high(template_)) - if (target_humidity := config.get(CONF_TARGET_HUMIDITY)) is not None: - template_ = await cg.templatable(target_humidity, args, cg.float_) - cg.add(var.set_target_humidity(template_)) - if (fan_mode := config.get(CONF_FAN_MODE)) is not None: - template_ = await cg.templatable(fan_mode, args, ClimateFanMode) - cg.add(var.set_fan_mode(template_)) - if (custom_fan_mode := config.get(CONF_CUSTOM_FAN_MODE)) is not None: - template_ = await cg.templatable(custom_fan_mode, args, cg.std_string) - cg.add(var.set_custom_fan_mode(template_)) - if (preset := config.get(CONF_PRESET)) is not None: - template_ = await cg.templatable(preset, args, ClimatePreset) - cg.add(var.set_preset(template_)) - if (custom_preset := config.get(CONF_CUSTOM_PRESET)) is not None: - template_ = await cg.templatable(custom_preset, args, cg.std_string) - cg.add(var.set_custom_preset(template_)) - if (swing_mode := config.get(CONF_SWING_MODE)) is not None: - template_ = await cg.templatable(swing_mode, args, ClimateSwingMode) - cg.add(var.set_swing_mode(template_)) - return var + + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. + # For custom_fan_mode/custom_preset the static-string path emits the + # (const char *, size_t) overload of set_fan_mode/set_preset to avoid + # constructing a std::string and calling runtime strlen. + FIELDS = ( + (CONF_MODE, "set_mode", ClimateMode), + (CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), + (CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_), + (CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_), + (CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), + (CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), + (CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string), + (CONF_PRESET, "set_preset", ClimatePreset), + (CONF_CUSTOM_PRESET, "set_preset", cg.std_string), + (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), + ) + + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + + for conf_key, setter, type_ in FIELDS: + if (value := config.get(conf_key)) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=type_) + body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + elif type_ is cg.std_string: + # Static custom strings: emit a flash literal and pass the + # UTF-8 byte length to skip the runtime strlen inside + # set_fan_mode/set_preset. + literal = cg.safe_exp(value) + body_lines.append( + f"call.{setter}({literal}, {len(value.encode('utf-8'))});" + ) + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. + apply_args = [ + (ClimateCall.operator("ref"), "call"), + *((t.operator("const").operator("ref"), n) for t, n in args), + ] + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + apply_args, + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index fac56d9d9e..71d23fd6b6 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -5,42 +5,25 @@ namespace esphome::climate { +// All configured fields are baked into a single stateless lambda whose +// constants live in flash. The action only stores one function pointer +// plus one parent pointer, regardless of how many fields the user set. +// Trigger args are forwarded to the apply function so user lambdas +// (e.g. `target_temperature: !lambda "return x;"`) keep working. template class ControlAction : public Action { public: - explicit ControlAction(Climate *climate) : climate_(climate) {} - - TEMPLATABLE_VALUE(ClimateMode, mode) - TEMPLATABLE_VALUE(float, target_temperature) - TEMPLATABLE_VALUE(float, target_temperature_low) - TEMPLATABLE_VALUE(float, target_temperature_high) - TEMPLATABLE_VALUE(float, target_humidity) - TEMPLATABLE_VALUE(bool, away) - TEMPLATABLE_VALUE(ClimateFanMode, fan_mode) - TEMPLATABLE_VALUE(std::string, custom_fan_mode) - TEMPLATABLE_VALUE(ClimatePreset, preset) - TEMPLATABLE_VALUE(std::string, custom_preset) - TEMPLATABLE_VALUE(ClimateSwingMode, swing_mode) + using ApplyFn = void (*)(ClimateCall &, const Ts &...); + ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { auto call = this->climate_->make_call(); - call.set_mode(this->mode_.optional_value(x...)); - call.set_target_temperature(this->target_temperature_.optional_value(x...)); - call.set_target_temperature_low(this->target_temperature_low_.optional_value(x...)); - call.set_target_temperature_high(this->target_temperature_high_.optional_value(x...)); - call.set_target_humidity(this->target_humidity_.optional_value(x...)); - if (away_.has_value()) { - call.set_preset(away_.value(x...) ? CLIMATE_PRESET_AWAY : CLIMATE_PRESET_HOME); - } - call.set_fan_mode(this->fan_mode_.optional_value(x...)); - call.set_fan_mode(this->custom_fan_mode_.optional_value(x...)); - call.set_preset(this->preset_.optional_value(x...)); - call.set_preset(this->custom_preset_.optional_value(x...)); - call.set_swing_mode(this->swing_mode_.optional_value(x...)); + this->apply_(call, x...); call.perform(); } protected: Climate *climate_; + ApplyFn apply_; }; class ControlTrigger : public Trigger { diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml new file mode 100644 index 0000000000..1dd300fcc2 --- /dev/null +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -0,0 +1,92 @@ +esphome: + name: climate-control-action-test +host: +api: +logger: + level: DEBUG + +globals: + - id: test_target_temp + type: float + initial_value: "21.5" + +sensor: + - platform: template + id: temp_sensor + name: "Temp" + lambda: 'return 20.0;' + update_interval: 60s + +climate: + - platform: thermostat + id: test_climate + name: "Test Climate" + sensor: temp_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + min_cooling_run_time: 300s + heat_action: + - logger.log: heating + idle_action: + - logger.log: idle + cool_action: + - logger.log: cooling + heat_cool_mode: + - logger.log: heat_cool + preset: + - name: Default + default_target_temperature_low: 18 °C + default_target_temperature_high: 22 °C + visual: + min_temperature: 10 °C + max_temperature: 30 °C + +button: + # mode only + - platform: template + id: btn_mode + name: "Set Mode Heat" + on_press: + - climate.control: + id: test_climate + mode: HEAT + + # mode + target_temperature_low + target_temperature_high + - platform: template + id: btn_mode_temps + name: "Set Mode Temps" + on_press: + - climate.control: + id: test_climate + mode: HEAT_COOL + target_temperature_low: 19.0 °C + target_temperature_high: 23.0 °C + + # target_temperature_low only + - platform: template + id: btn_low_only + name: "Set Low Only" + on_press: + - climate.control: + id: test_climate + target_temperature_low: 17.5 °C + + # Lambda path: target_temperature_high computed at runtime + - platform: template + id: btn_lambda_high + name: "Lambda High" + on_press: + - climate.control: + id: test_climate + target_temperature_high: !lambda "return id(test_target_temp);" + + # mode only — turn off via mode + - platform: template + id: btn_off + name: "Set Off" + on_press: + - climate.control: + id: test_climate + mode: "OFF" diff --git a/tests/integration/test_climate_control_action.py b/tests/integration/test_climate_control_action.py new file mode 100644 index 0000000000..2b0293b209 --- /dev/null +++ b/tests/integration/test_climate_control_action.py @@ -0,0 +1,84 @@ +"""Integration test for climate ControlAction. + +Tests that climate.control automation actions work correctly with the +single stateless apply lambda/function pointer implementation. Exercises +multiple field combinations and the lambda path. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ( + ButtonInfo, + ClimateInfo, + ClimateMode, + ClimateState, + EntityState, +) +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_climate_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test climate ControlAction with constants and lambdas.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + climate_state_future: asyncio.Future[ClimateState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, ClimateState) + and climate_state_future is not None + and not climate_state_future.done() + ): + climate_state_future.set_result(state) + + async def wait_for_climate_state(timeout: float = 5.0) -> ClimateState: + nonlocal climate_state_future + climate_state_future = loop.create_future() + try: + return await asyncio.wait_for(climate_state_future, timeout) + finally: + climate_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_climate", ClimateInfo) + + async def press_and_wait(name: str) -> ClimateState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_climate_state() + + # mode only — set HEAT + state = await press_and_wait("Set Mode Heat") + assert state.mode == ClimateMode.HEAT + + # mode + target_temperature_low + target_temperature_high + state = await press_and_wait("Set Mode Temps") + assert state.mode == ClimateMode.HEAT_COOL + assert state.target_temperature_low == pytest.approx(19.0, abs=0.5) + assert state.target_temperature_high == pytest.approx(23.0, abs=0.5) + + # target_temperature_low only + state = await press_and_wait("Set Low Only") + assert state.target_temperature_low == pytest.approx(17.5, abs=0.5) + + # lambda path: target_temperature_high computed at runtime + state = await press_and_wait("Lambda High") + assert state.target_temperature_high == pytest.approx(21.5, abs=0.5) + + # mode only — turn off via mode + state = await press_and_wait("Set Off") + assert state.mode == ClimateMode.OFF From 92aa98f680c8ea40ff424dfd6a529fd3f6313d7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:42:38 -0500 Subject: [PATCH 0971/2214] [host] Move HAL bodies into components/host/hal.cpp + inline trivial dispatches (#16115) --- esphome/components/host/core.cpp | 60 +---------------------------- esphome/components/host/hal.cpp | 65 ++++++++++++++++++++++++++++++++ esphome/core/hal/hal_host.h | 12 +++--- 3 files changed, 73 insertions(+), 64 deletions(-) create mode 100644 esphome/components/host/hal.cpp diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index b067ebbf6e..9123975884 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -1,74 +1,16 @@ #ifdef USE_HOST #include "esphome/core/application.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #include "preferences.h" #include -#include -#include -#include namespace { volatile sig_atomic_t s_signal_received = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void signal_handler(int signal) { s_signal_received = signal; } } // namespace -namespace esphome { - -void HOT yield() { ::sched_yield(); } -uint32_t IRAM_ATTR HOT millis() { - struct timespec spec; - clock_gettime(CLOCK_MONOTONIC, &spec); - return static_cast(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000); -} -uint64_t millis_64() { - struct timespec spec; - clock_gettime(CLOCK_MONOTONIC, &spec); - return static_cast(spec.tv_sec) * 1000ULL + static_cast(spec.tv_nsec) / 1000000ULL; -} -void HOT delay(uint32_t ms) { - struct timespec ts; - ts.tv_sec = ms / 1000; - ts.tv_nsec = (ms % 1000) * 1000000; - int res; - do { - res = nanosleep(&ts, &ts); - } while (res != 0 && errno == EINTR); -} -uint32_t IRAM_ATTR HOT micros() { - struct timespec spec; - clock_gettime(CLOCK_MONOTONIC, &spec); - return static_cast(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000); -} -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { - struct timespec ts; - ts.tv_sec = us / 1000000U; - ts.tv_nsec = (us % 1000000U) * 1000U; - int res; - do { - res = nanosleep(&ts, &ts); - } while (res != 0 && errno == EINTR); -} -void arch_restart() { exit(0); } -void arch_init() { - // pass -} -void HOT arch_feed_wdt() { - // pass -} - -uint32_t arch_get_cpu_cycle_count() { - struct timespec spec; - clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t us = spec.tv_nsec; - return ((uint32_t) seconds) * 1000000000U + us; -} -uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } - -} // namespace esphome +// HAL functions live in hal.cpp. void setup(); void loop(); diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp new file mode 100644 index 0000000000..256a12ac62 --- /dev/null +++ b/esphome/components/host/hal.cpp @@ -0,0 +1,65 @@ +#ifdef USE_HOST + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +#include +#include +#include + +// Empty host namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// host component's API. +namespace esphome::host {} // namespace esphome::host + +namespace esphome { + +// yield(), arch_init(), arch_feed_wdt(), arch_get_cpu_freq_hz() inlined in +// core/hal/hal_host.h. + +uint32_t IRAM_ATTR HOT millis() { + struct timespec spec; + clock_gettime(CLOCK_MONOTONIC, &spec); + return static_cast(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000); +} +uint64_t millis_64() { + struct timespec spec; + clock_gettime(CLOCK_MONOTONIC, &spec); + return static_cast(spec.tv_sec) * 1000ULL + static_cast(spec.tv_nsec) / 1000000ULL; +} +void HOT delay(uint32_t ms) { + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (ms % 1000) * 1000000; + int res; + do { + res = nanosleep(&ts, &ts); + } while (res != 0 && errno == EINTR); +} +uint32_t IRAM_ATTR HOT micros() { + struct timespec spec; + clock_gettime(CLOCK_MONOTONIC, &spec); + return static_cast(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000); +} +void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { + struct timespec ts; + ts.tv_sec = us / 1000000U; + ts.tv_nsec = (us % 1000000U) * 1000U; + int res; + do { + res = nanosleep(&ts, &ts); + } while (res != 0 && errno == EINTR); +} +void arch_restart() { exit(0); } + +uint32_t arch_get_cpu_cycle_count() { + struct timespec spec; + clock_gettime(CLOCK_MONOTONIC, &spec); + time_t seconds = spec.tv_sec; + uint32_t ns = static_cast(spec.tv_nsec); + return static_cast(seconds) * 1000000000U + ns; +} + +} // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/hal/hal_host.h b/esphome/core/hal/hal_host.h index a8896fdf63..d7f317176e 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/core/hal/hal_host.h @@ -3,6 +3,7 @@ #ifdef USE_HOST #include +#include #define IRAM_ATTR #define PROGMEM @@ -13,17 +14,18 @@ namespace esphome { /// Host has no ISR concept. __attribute__((always_inline)) inline bool in_isr_context() { return false; } -void yield(); +__attribute__((always_inline)) inline void yield() { ::sched_yield(); } + void delay(uint32_t ms); uint32_t micros(); uint32_t millis(); uint64_t millis_64(); - void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) -void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); -void arch_init(); -uint32_t arch_get_cpu_freq_hz(); + +__attribute__((always_inline)) inline void arch_init() {} +__attribute__((always_inline)) inline void arch_feed_wdt() {} +__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } } // namespace esphome From 9999913d072b930bdf330ba421e403126d7b65a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 20:10:51 -0500 Subject: [PATCH 0972/2214] [zephyr] Move HAL bodies into components/zephyr/hal.cpp + inline trivial dispatches (#16116) --- esphome/components/zephyr/core.cpp | 52 +----------------------- esphome/components/zephyr/hal.cpp | 63 ++++++++++++++++++++++++++++++ esphome/core/hal/hal_zephyr.h | 20 ++++++---- 3 files changed, 76 insertions(+), 59 deletions(-) create mode 100644 esphome/components/zephyr/hal.cpp diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index 93a9a1ae8e..d1bdaee02d 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -1,8 +1,6 @@ #ifdef USE_ZEPHYR #include -#include -#include #include #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -10,55 +8,7 @@ namespace esphome { -#ifdef CONFIG_WATCHDOG -static int wdt_channel_id = -1; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); -#endif - -void yield() { ::k_yield(); } -uint32_t millis() { return static_cast(millis_64()); } -uint64_t millis_64() { return static_cast(k_uptime_get()); } -uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); } -void delayMicroseconds(uint32_t us) { ::k_usleep(us); } -void delay(uint32_t ms) { ::k_msleep(ms); } - -void arch_init() { -#ifdef CONFIG_WATCHDOG - if (device_is_ready(WDT)) { - static wdt_timeout_cfg wdt_config{}; - wdt_config.flags = WDT_FLAG_RESET_SOC; -#ifdef USE_ZIGBEE - // zboss thread use a lot of cpu cycles during start - wdt_config.window.max = 10000; -#else - wdt_config.window.max = 2000; -#endif - wdt_channel_id = wdt_install_timeout(WDT, &wdt_config); - if (wdt_channel_id >= 0) { - uint8_t options = 0; -#ifdef USE_DEBUG - options |= WDT_OPT_PAUSE_HALTED_BY_DBG; -#endif -#ifdef USE_DEEP_SLEEP - options |= WDT_OPT_PAUSE_IN_SLEEP; -#endif - wdt_setup(WDT, options); - } - } -#endif -} - -void arch_feed_wdt() { -#ifdef CONFIG_WATCHDOG - if (wdt_channel_id >= 0) { - wdt_feed(WDT, wdt_channel_id); - } -#endif -} - -void arch_restart() { sys_reboot(SYS_REBOOT_COLD); } -uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); } -uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); } +// HAL functions live in hal.cpp. Mutex::Mutex() { auto *mutex = new k_mutex(); diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp new file mode 100644 index 0000000000..5c08ed2519 --- /dev/null +++ b/esphome/components/zephyr/hal.cpp @@ -0,0 +1,63 @@ +#ifdef USE_ZEPHYR + +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" + +#include +#include + +// Empty zephyr namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// zephyr component's API. +namespace esphome::zephyr {} // namespace esphome::zephyr + +namespace esphome { + +#ifdef CONFIG_WATCHDOG +static int wdt_channel_id = -1; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); +#endif + +// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), +// arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() inlined in +// core/hal/hal_zephyr.h. + +void arch_init() { +#ifdef CONFIG_WATCHDOG + if (device_is_ready(WDT)) { + static wdt_timeout_cfg wdt_config{}; + wdt_config.flags = WDT_FLAG_RESET_SOC; +#ifdef USE_ZIGBEE + // zboss thread uses a lot of CPU cycles during startup + wdt_config.window.max = 10000; +#else + wdt_config.window.max = 2000; +#endif + wdt_channel_id = wdt_install_timeout(WDT, &wdt_config); + if (wdt_channel_id >= 0) { + uint8_t options = 0; +#ifdef USE_DEBUG + options |= WDT_OPT_PAUSE_HALTED_BY_DBG; +#endif +#ifdef USE_DEEP_SLEEP + options |= WDT_OPT_PAUSE_IN_SLEEP; +#endif + wdt_setup(WDT, options); + } + } +#endif +} + +void arch_feed_wdt() { +#ifdef CONFIG_WATCHDOG + if (wdt_channel_id >= 0) { + wdt_feed(WDT, wdt_channel_id); + } +#endif +} + +void arch_restart() { sys_reboot(SYS_REBOOT_COLD); } + +} // namespace esphome + +#endif // USE_ZEPHYR diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/core/hal/hal_zephyr.h index d4b37b5eb6..613b3911c1 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/core/hal/hal_zephyr.h @@ -4,6 +4,8 @@ #include +#include + #define IRAM_ATTR #define PROGMEM @@ -13,17 +15,19 @@ namespace esphome { /// Zephyr/nRF52: not currently consulted — wake path is platform-specific. __attribute__((always_inline)) inline bool in_isr_context() { return false; } -void yield(); -void delay(uint32_t ms); -uint32_t micros(); -uint32_t millis(); -uint64_t millis_64(); +__attribute__((always_inline)) inline void yield() { ::k_yield(); } +__attribute__((always_inline)) inline void delay(uint32_t ms) { ::k_msleep(ms); } +__attribute__((always_inline)) inline uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); } +__attribute__((always_inline)) inline uint64_t millis_64() { return static_cast(k_uptime_get()); } +__attribute__((always_inline)) inline uint32_t millis() { return static_cast(millis_64()); } + +// NOLINTNEXTLINE(readability-identifier-naming) +__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { ::k_usleep(us); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); } -void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); -uint32_t arch_get_cpu_cycle_count(); void arch_init(); -uint32_t arch_get_cpu_freq_hz(); } // namespace esphome From 64ab62aaf9ae2b22ea0fc2096aba9092951c75b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 20:37:40 -0500 Subject: [PATCH 0973/2214] [core] Move per-platform hal_.h into components//hal.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last step in the HAL reorg started in #15977 and continued in the per-platform follow-ups #16111 (esp32), #16112 (esp8266), #16113 (libretiny), #16114 (rp2040), #16115 (host), #16116 (zephyr) — each of which moved its platform's out-of-line HAL bodies from components//core.cpp into a new components//hal.cpp. This PR moves each platform's HAL header next to its hal.cpp inside components//, rather than under core/hal/. The dispatcher core/hal.h is updated to include from the new locations and the now-empty core/hal/ directory is removed. Unlike the wake split (#15978) where freertos-class platforms (ESP32 + LibreTiny) share a single wake_freertos.{h,cpp}, HAL primitives are genuinely per-platform — there is no shared HAL implementation across platforms — so the natural home is alongside each platform component rather than in a separate core/ subdirectory. Each .h file gains an empty 'namespace esphome:: {}' block to satisfy ci-custom's lint_namespace check. HAL functions live in 'namespace esphome' (root); they are not part of the per-component API, matching the convention used by the corresponding hal.cpp files. Comment-only updates to the components//hal.cpp files to reflect the new include path. No public API change, no behavior change. --- .../hal_esp32.h => components/esp32/hal.h} | 2 ++ esphome/components/esp8266/hal.cpp | 2 +- .../esp8266/hal.h} | 2 ++ esphome/components/host/hal.cpp | 2 +- .../hal/hal_host.h => components/host/hal.h} | 2 ++ esphome/components/libretiny/hal.cpp | 2 +- .../libretiny/hal.h} | 2 ++ esphome/components/rp2040/hal.cpp | 2 +- .../hal_rp2040.h => components/rp2040/hal.h} | 2 ++ esphome/components/zephyr/hal.cpp | 2 +- .../hal_zephyr.h => components/zephyr/hal.h} | 2 ++ esphome/core/hal.h | 24 +++++++++---------- 12 files changed, 29 insertions(+), 17 deletions(-) rename esphome/{core/hal/hal_esp32.h => components/esp32/hal.h} (98%) rename esphome/{core/hal/hal_esp8266.h => components/esp8266/hal.h} (98%) rename esphome/{core/hal/hal_host.h => components/host/hal.h} (96%) rename esphome/{core/hal/hal_libretiny.h => components/libretiny/hal.h} (99%) rename esphome/{core/hal/hal_rp2040.h => components/rp2040/hal.h} (98%) rename esphome/{core/hal/hal_zephyr.h => components/zephyr/hal.h} (97%) diff --git a/esphome/core/hal/hal_esp32.h b/esphome/components/esp32/hal.h similarity index 98% rename from esphome/core/hal/hal_esp32.h rename to esphome/components/esp32/hal.h index d5d7752bf6..2180f07f6c 100644 --- a/esphome/core/hal/hal_esp32.h +++ b/esphome/components/esp32/hal.h @@ -15,6 +15,8 @@ #define PROGMEM #endif +namespace esphome::esp32 {} + namespace esphome { // Forward decl from helpers.h (esphome/core/helpers.h) — kept here so this diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index 56910e5b39..e8f472dc8a 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -18,7 +18,7 @@ namespace esphome::esp8266 {} // namespace esphome::esp8266 namespace esphome { // yield(), micros(), millis_64(), delayMicroseconds(), arch_feed_wdt(), -// progmem_read_*() are inlined in core/hal/hal_esp8266.h. +// progmem_read_*() are inlined in components/esp8266/hal.h. // // Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit // multiplies on the LX106). Tracks a running ms counter from 32-bit diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/components/esp8266/hal.h similarity index 98% rename from esphome/core/hal/hal_esp8266.h rename to esphome/components/esp8266/hal.h index b6e3b1ee3c..effa9c9371 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/components/esp8266/hal.h @@ -25,6 +25,8 @@ extern "C" unsigned long millis(void); // NOLINTNEXTLINE(readability-redundant-declaration) extern "C" void system_soft_wdt_feed(void); +namespace esphome::esp8266 {} + namespace esphome { // Forward decl from helpers.h so this header stays cheap. diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp index 256a12ac62..c7fef8d2e8 100644 --- a/esphome/components/host/hal.cpp +++ b/esphome/components/host/hal.cpp @@ -15,7 +15,7 @@ namespace esphome::host {} // namespace esphome::host namespace esphome { // yield(), arch_init(), arch_feed_wdt(), arch_get_cpu_freq_hz() inlined in -// core/hal/hal_host.h. +// components/host/hal.h. uint32_t IRAM_ATTR HOT millis() { struct timespec spec; diff --git a/esphome/core/hal/hal_host.h b/esphome/components/host/hal.h similarity index 96% rename from esphome/core/hal/hal_host.h rename to esphome/components/host/hal.h index d7f317176e..12abf6684d 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/components/host/hal.h @@ -8,6 +8,8 @@ #define IRAM_ATTR #define PROGMEM +namespace esphome::host {} + namespace esphome { /// Returns true when executing inside an interrupt handler. diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp index e6dbb7296c..67e902024d 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -16,7 +16,7 @@ namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), // arch_feed_wdt(), arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() -// inlined in core/hal/hal_libretiny.h. +// inlined in components/libretiny/hal.h. void arch_init() { libretiny::setup_preferences(); diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/components/libretiny/hal.h similarity index 99% rename from esphome/core/hal/hal_libretiny.h rename to esphome/components/libretiny/hal.h index db0fc11bfb..9c512504b7 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/components/libretiny/hal.h @@ -61,6 +61,8 @@ extern "C" void lt_wdt_feed(void); extern "C" uint32_t lt_cpu_get_cycle_count(void); extern "C" uint32_t lt_cpu_get_freq(void); +namespace esphome::libretiny {} + namespace esphome { /// Returns true when executing inside an interrupt handler. diff --git a/esphome/components/rp2040/hal.cpp b/esphome/components/rp2040/hal.cpp index 7475205d60..e71d3fd54d 100644 --- a/esphome/components/rp2040/hal.cpp +++ b/esphome/components/rp2040/hal.cpp @@ -17,7 +17,7 @@ namespace esphome::rp2040 {} // namespace esphome::rp2040 namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), -// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in core/hal/hal_rp2040.h. +// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2040/hal.h. void arch_restart() { watchdog_reboot(0, 0, 10); while (1) { diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/components/rp2040/hal.h similarity index 98% rename from esphome/core/hal/hal_rp2040.h rename to esphome/components/rp2040/hal.h index 27a9b23c0b..c9c61c921d 100644 --- a/esphome/core/hal/hal_rp2040.h +++ b/esphome/components/rp2040/hal.h @@ -25,6 +25,8 @@ extern "C" uint64_t time_us_64(void); extern "C" void watchdog_update(void); extern "C" unsigned long ulMainGetRunTimeCounterValue(void); +namespace esphome::rp2040 {} + namespace esphome { // Forward decl from helpers.h. diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index 5c08ed2519..6c405b650e 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -20,7 +20,7 @@ static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), // arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() inlined in -// core/hal/hal_zephyr.h. +// components/zephyr/hal.h. void arch_init() { #ifdef CONFIG_WATCHDOG diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/components/zephyr/hal.h similarity index 97% rename from esphome/core/hal/hal_zephyr.h rename to esphome/components/zephyr/hal.h index 613b3911c1..11994b68b7 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/components/zephyr/hal.h @@ -9,6 +9,8 @@ #define IRAM_ATTR #define PROGMEM +namespace esphome::zephyr {} + namespace esphome { /// Returns true when executing inside an interrupt handler. diff --git a/esphome/core/hal.h b/esphome/core/hal.h index a53296979c..4babda807d 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -8,22 +8,22 @@ // Per-platform HAL bits (IRAM_ATTR / PROGMEM macros, in_isr_context(), // inline yield/delay/micros/millis/millis_64 wrappers, ESP8266 progmem -// helpers) live under esphome/core/hal/ and are dispatched here based on -// the active USE_* platform define. Each header guards its body with the -// matching #ifdef USE_ and re-enters namespace esphome {} so it -// is safe to be re-included. +// helpers) live next to each platform component as components//hal.h +// and are dispatched here based on the active USE_* platform define. Each +// header guards its body with the matching #ifdef USE_ and re-enters +// namespace esphome {} so it is safe to be re-included. #if defined(USE_ESP32) -#include "esphome/core/hal/hal_esp32.h" +#include "esphome/components/esp32/hal.h" #elif defined(USE_ESP8266) -#include "esphome/core/hal/hal_esp8266.h" +#include "esphome/components/esp8266/hal.h" #elif defined(USE_LIBRETINY) -#include "esphome/core/hal/hal_libretiny.h" +#include "esphome/components/libretiny/hal.h" #elif defined(USE_RP2040) -#include "esphome/core/hal/hal_rp2040.h" +#include "esphome/components/rp2040/hal.h" #elif defined(USE_HOST) -#include "esphome/core/hal/hal_host.h" +#include "esphome/components/host/hal.h" #elif defined(USE_ZEPHYR) -#include "esphome/core/hal/hal_zephyr.h" +#include "esphome/components/zephyr/hal.h" #else #error "hal.h: not implemented for this platform" #endif @@ -33,12 +33,12 @@ namespace esphome { // Cross-platform declarations. delayMicroseconds(), arch_feed_wdt(), // arch_get_cpu_cycle_count(), arch_init(), arch_get_cpu_freq_hz() vary // per platform (some inline, some out-of-line) so they live in -// hal/hal_.h. +// components//hal.h. void __attribute__((noreturn)) arch_restart(); #ifndef USE_ESP8266 // All non-ESP8266 platforms: PROGMEM is a no-op, so these are direct dereferences. -// ESP8266's out-of-line declarations live in hal/hal_esp8266.h. +// ESP8266's out-of-line declarations live in components/esp8266/hal.h. inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } inline const char *progmem_read_ptr(const char *const *addr) { return *addr; } inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } From faa61696e036cb26e44ef0e4a28400e0636c5ca3 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 30 Apr 2026 21:43:24 -0400 Subject: [PATCH 0974/2214] [sendspin] Use sendspin-cpp to v0.4.0 to reduce stuttering (#16178) --- esphome/components/sendspin/__init__.py | 30 ++++++++++++++++--- .../sendspin/media_source/__init__.py | 4 +++ esphome/idf_component.yml | 2 +- .../sendspin/common-media_source.yaml | 1 + 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 58687ae838..1348bf2bfc 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -24,6 +24,7 @@ CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" +CONF_DECODE_MEMORY = "decode_memory" # sendspin-cpp library lives in the global `sendspin` namespace. sendspin_library_ns = cg.global_ns.namespace("sendspin") @@ -39,6 +40,18 @@ CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject") PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig") +# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement +# preference for the player role's transfer buffers. +SendspinMemoryLocation = sendspin_library_ns.enum("MemoryLocation", is_class=True) + +MEMORY_PSRAM = "psram" +MEMORY_INTERNAL = "internal" +MEMORY_LOCATIONS = [MEMORY_PSRAM, MEMORY_INTERNAL] +MEMORY_LOCATION_ENUM = { + MEMORY_PSRAM: SendspinMemoryLocation.PREFER_EXTERNAL, + MEMORY_INTERNAL: SendspinMemoryLocation.PREFER_INTERNAL, +} + # Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace. # Analysis tools strip the trailing underscore (same pattern as `template_`). sendspin_ns = cg.esphome_ns.namespace("sendspin_") @@ -193,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.4.0") cg.add_define("USE_SENDSPIN", True) # for MDNS @@ -249,14 +262,23 @@ async def to_code(config: ConfigType) -> None: "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True ) - player_config_struct = cg.StructInitializer( - PlayerRoleConfig, + # Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not + # starved by the HTTP server during the initial encoded-audio burst at stream start), + # interpolation/decode buffer locations PREFER_EXTERNAL. + player_struct_fields = [ ("audio_formats", audio_format_structs), ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), ("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]), ("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), ("psram_stack", psram_stack), - ("priority", 2), + ] + if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None: + player_struct_fields.append( + ("decode_buffer_location", MEMORY_LOCATION_ENUM[decode_memory]) + ) + player_config_struct = cg.StructInitializer( + PlayerRoleConfig, + *player_struct_fields, ) cg.add(var.set_player_config(player_config_struct)) else: diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6d61a8a636..f689ab01cb 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -13,9 +13,11 @@ from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( + CONF_DECODE_MEMORY, CONF_FIXED_DELAY, CONF_INITIAL_STATIC_DELAY, CONF_SENDSPIN_ID, + MEMORY_LOCATIONS, SendspinHub, _validate_task_stack_in_psram, register_player_config, @@ -57,6 +59,7 @@ def _register(config: ConfigType) -> ConfigType: CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], CONF_FIXED_DELAY: config[CONF_FIXED_DELAY], CONF_TASK_STACK_IN_PSRAM: config.get(CONF_TASK_STACK_IN_PSRAM, False), + CONF_DECODE_MEMORY: config.get(CONF_DECODE_MEMORY), } ) return config @@ -82,6 +85,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.int_range( min=16000, max=96000 ), + cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True), } ), cv.only_on_esp32, diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 757fcf9dd7..d02c3adb8f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -92,6 +92,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.3.1 + version: 0.4.0 lvgl/lvgl: version: 9.5.0 diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 4a7cd79c67..5b33a54647 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -7,3 +7,4 @@ media_source: initial_static_delay: 5ms static_delay_adjustable: true fixed_delay: 480us + decode_memory: internal From 08e5cb55764033d11e4feb35c2c6a7fc3fa2da50 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:47:22 -0400 Subject: [PATCH 0975/2214] [esp32_hosted] Bump esp_hosted to 2.12.6 and esp_wifi_remote to 1.5.1 (#16176) --- esphome/components/esp32_hosted/__init__.py | 7 ++++--- esphome/idf_component.yml | 10 +++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 1619a845d8..eca7c24b10 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -246,9 +246,10 @@ async def to_code(config): 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.4.0") - esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") + esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") + esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index d02c3adb8f..f5a8dd8c60 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,15 +20,19 @@ dependencies: espressif/mdns: version: 1.11.0 espressif/esp_wifi_remote: - version: 1.4.0 + version: 1.5.1 + rules: + - if: "target in [esp32h2, esp32p4]" + espressif/wifi_remote_over_eppp: + version: 0.3.2 rules: - if: "target in [esp32h2, esp32p4]" espressif/eppp_link: - version: 1.1.4 + version: 1.1.5 rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.1 + version: 2.12.6 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From f6e39d305d5410e7524ffb4d9d7fb0b388c861ae Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 1 May 2026 04:08:55 +0200 Subject: [PATCH 0976/2214] [zigbee] Add newlib compatibility for zigbee sdk in idf 6 (#16174) --- esphome/components/zigbee/zigbee_esp32.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 1b98df6c0a..9081582c7b 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -9,6 +9,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, add_partition, + idf_version, require_vfs_select, ) import esphome.config_validation as cv @@ -186,6 +187,10 @@ async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: # The pre-built Zigbee library uses esp_log_default_level which requires # dynamic log level control to be enabled add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) + # The pre-built Zigbee library is compiled against newlib which requires newlib + # reentrancy to be enabled with picolibc compatibility. + if idf_version() >= cv.Version(6, 0, 0): + add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", True) async def attributes_to_code( From b8dfffdf062f47c1097c0a8afa28305218275915 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 21:20:07 -0500 Subject: [PATCH 0977/2214] [core] Enable ruff FLY (flynt) lint family (#16182) --- esphome/platformio_runner.py | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/platformio_runner.py b/esphome/platformio_runner.py index 599c9408a4..5b14a72557 100644 --- a/esphome/platformio_runner.py +++ b/esphome/platformio_runner.py @@ -101,7 +101,7 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init -_IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})" +_IGNORE_LIB_WARNINGS = "(?:Hash|Update)" # Regex patterns matched against each line of PlatformIO output. Lines that # match are dropped by RedirectText before they reach the parent process. # Patterns are anchored at the start of the line (RedirectText uses diff --git a/pyproject.toml b/pyproject.toml index dc6785001d..d16bf2b625 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ exclude = ['generated'] select = [ "E", # pycodestyle "F", # pyflakes/autoflake + "FLY", # flynt: convert string formatting to f-strings "FURB", # refurb "I", # isort "PERF", # performance From 0980630f6820300cfc5a1f97df3d8f333987e9ad Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 1 May 2026 12:23:14 +1000 Subject: [PATCH 0978/2214] [lvgl] Clamp values for meter line indicators (#16180) --- esphome/components/lvgl/lvgl_esphome.cpp | 6 ++++-- esphome/components/lvgl/lvgl_esphome.h | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 0308e6b783..eb85faa16c 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -454,10 +454,12 @@ void LVTouchListener::update(const touchscreen::TouchPoints_t &tpoints) { #ifdef USE_LVGL_METER -int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value) { +int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value) { auto *scale = lv_obj_get_parent(obj); auto min_value = lv_scale_get_range_min_value(scale); - return ((value - min_value) * lv_scale_get_angle_range(scale) / (lv_scale_get_range_max_value(scale) - min_value) + + auto max_value = lv_scale_get_range_max_value(scale); + value = clamp(value, min_value, max_value); + return ((value - min_value) * lv_scale_get_angle_range(scale) / (max_value - min_value) + lv_scale_get_rotation((scale))) % 360; } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 83cf9cc099..be1f150aff 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -112,7 +112,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images #endif // USE_LVGL_ANIMIMG #ifdef USE_LVGL_METER -int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value); +int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); #endif #ifdef USE_LVGL_GRADIENT From 5cc447e0da5eb24c53afe594acffcb24e8bc0bb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 21:27:31 -0500 Subject: [PATCH 0979/2214] [core] Move per-platform hal_platform.h into components/platform/hal.h (#16183) --- .../hal_esp32.h => components/esp32/hal.h} | 2 ++ esphome/components/esp8266/hal.cpp | 2 +- .../esp8266/hal.h} | 2 ++ esphome/components/host/hal.cpp | 2 +- .../hal/hal_host.h => components/host/hal.h} | 2 ++ esphome/components/libretiny/hal.cpp | 2 +- .../libretiny/hal.h} | 2 ++ esphome/components/rp2040/hal.cpp | 2 +- .../hal_rp2040.h => components/rp2040/hal.h} | 2 ++ esphome/components/zephyr/hal.cpp | 2 +- .../hal_zephyr.h => components/zephyr/hal.h} | 2 ++ esphome/core/hal.h | 24 +++++++++---------- 12 files changed, 29 insertions(+), 17 deletions(-) rename esphome/{core/hal/hal_esp32.h => components/esp32/hal.h} (98%) rename esphome/{core/hal/hal_esp8266.h => components/esp8266/hal.h} (98%) rename esphome/{core/hal/hal_host.h => components/host/hal.h} (96%) rename esphome/{core/hal/hal_libretiny.h => components/libretiny/hal.h} (99%) rename esphome/{core/hal/hal_rp2040.h => components/rp2040/hal.h} (98%) rename esphome/{core/hal/hal_zephyr.h => components/zephyr/hal.h} (97%) diff --git a/esphome/core/hal/hal_esp32.h b/esphome/components/esp32/hal.h similarity index 98% rename from esphome/core/hal/hal_esp32.h rename to esphome/components/esp32/hal.h index d5d7752bf6..2180f07f6c 100644 --- a/esphome/core/hal/hal_esp32.h +++ b/esphome/components/esp32/hal.h @@ -15,6 +15,8 @@ #define PROGMEM #endif +namespace esphome::esp32 {} + namespace esphome { // Forward decl from helpers.h (esphome/core/helpers.h) — kept here so this diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index 56910e5b39..e8f472dc8a 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -18,7 +18,7 @@ namespace esphome::esp8266 {} // namespace esphome::esp8266 namespace esphome { // yield(), micros(), millis_64(), delayMicroseconds(), arch_feed_wdt(), -// progmem_read_*() are inlined in core/hal/hal_esp8266.h. +// progmem_read_*() are inlined in components/esp8266/hal.h. // // Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit // multiplies on the LX106). Tracks a running ms counter from 32-bit diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/components/esp8266/hal.h similarity index 98% rename from esphome/core/hal/hal_esp8266.h rename to esphome/components/esp8266/hal.h index b6e3b1ee3c..effa9c9371 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/components/esp8266/hal.h @@ -25,6 +25,8 @@ extern "C" unsigned long millis(void); // NOLINTNEXTLINE(readability-redundant-declaration) extern "C" void system_soft_wdt_feed(void); +namespace esphome::esp8266 {} + namespace esphome { // Forward decl from helpers.h so this header stays cheap. diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp index 256a12ac62..c7fef8d2e8 100644 --- a/esphome/components/host/hal.cpp +++ b/esphome/components/host/hal.cpp @@ -15,7 +15,7 @@ namespace esphome::host {} // namespace esphome::host namespace esphome { // yield(), arch_init(), arch_feed_wdt(), arch_get_cpu_freq_hz() inlined in -// core/hal/hal_host.h. +// components/host/hal.h. uint32_t IRAM_ATTR HOT millis() { struct timespec spec; diff --git a/esphome/core/hal/hal_host.h b/esphome/components/host/hal.h similarity index 96% rename from esphome/core/hal/hal_host.h rename to esphome/components/host/hal.h index d7f317176e..12abf6684d 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/components/host/hal.h @@ -8,6 +8,8 @@ #define IRAM_ATTR #define PROGMEM +namespace esphome::host {} + namespace esphome { /// Returns true when executing inside an interrupt handler. diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp index e6dbb7296c..67e902024d 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -16,7 +16,7 @@ namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), // arch_feed_wdt(), arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() -// inlined in core/hal/hal_libretiny.h. +// inlined in components/libretiny/hal.h. void arch_init() { libretiny::setup_preferences(); diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/components/libretiny/hal.h similarity index 99% rename from esphome/core/hal/hal_libretiny.h rename to esphome/components/libretiny/hal.h index db0fc11bfb..9c512504b7 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/components/libretiny/hal.h @@ -61,6 +61,8 @@ extern "C" void lt_wdt_feed(void); extern "C" uint32_t lt_cpu_get_cycle_count(void); extern "C" uint32_t lt_cpu_get_freq(void); +namespace esphome::libretiny {} + namespace esphome { /// Returns true when executing inside an interrupt handler. diff --git a/esphome/components/rp2040/hal.cpp b/esphome/components/rp2040/hal.cpp index 7475205d60..e71d3fd54d 100644 --- a/esphome/components/rp2040/hal.cpp +++ b/esphome/components/rp2040/hal.cpp @@ -17,7 +17,7 @@ namespace esphome::rp2040 {} // namespace esphome::rp2040 namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), -// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in core/hal/hal_rp2040.h. +// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2040/hal.h. void arch_restart() { watchdog_reboot(0, 0, 10); while (1) { diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/components/rp2040/hal.h similarity index 98% rename from esphome/core/hal/hal_rp2040.h rename to esphome/components/rp2040/hal.h index 27a9b23c0b..c9c61c921d 100644 --- a/esphome/core/hal/hal_rp2040.h +++ b/esphome/components/rp2040/hal.h @@ -25,6 +25,8 @@ extern "C" uint64_t time_us_64(void); extern "C" void watchdog_update(void); extern "C" unsigned long ulMainGetRunTimeCounterValue(void); +namespace esphome::rp2040 {} + namespace esphome { // Forward decl from helpers.h. diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index 5c08ed2519..6c405b650e 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -20,7 +20,7 @@ static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), // arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() inlined in -// core/hal/hal_zephyr.h. +// components/zephyr/hal.h. void arch_init() { #ifdef CONFIG_WATCHDOG diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/components/zephyr/hal.h similarity index 97% rename from esphome/core/hal/hal_zephyr.h rename to esphome/components/zephyr/hal.h index 613b3911c1..11994b68b7 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/components/zephyr/hal.h @@ -9,6 +9,8 @@ #define IRAM_ATTR #define PROGMEM +namespace esphome::zephyr {} + namespace esphome { /// Returns true when executing inside an interrupt handler. diff --git a/esphome/core/hal.h b/esphome/core/hal.h index a53296979c..4babda807d 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -8,22 +8,22 @@ // Per-platform HAL bits (IRAM_ATTR / PROGMEM macros, in_isr_context(), // inline yield/delay/micros/millis/millis_64 wrappers, ESP8266 progmem -// helpers) live under esphome/core/hal/ and are dispatched here based on -// the active USE_* platform define. Each header guards its body with the -// matching #ifdef USE_ and re-enters namespace esphome {} so it -// is safe to be re-included. +// helpers) live next to each platform component as components//hal.h +// and are dispatched here based on the active USE_* platform define. Each +// header guards its body with the matching #ifdef USE_ and re-enters +// namespace esphome {} so it is safe to be re-included. #if defined(USE_ESP32) -#include "esphome/core/hal/hal_esp32.h" +#include "esphome/components/esp32/hal.h" #elif defined(USE_ESP8266) -#include "esphome/core/hal/hal_esp8266.h" +#include "esphome/components/esp8266/hal.h" #elif defined(USE_LIBRETINY) -#include "esphome/core/hal/hal_libretiny.h" +#include "esphome/components/libretiny/hal.h" #elif defined(USE_RP2040) -#include "esphome/core/hal/hal_rp2040.h" +#include "esphome/components/rp2040/hal.h" #elif defined(USE_HOST) -#include "esphome/core/hal/hal_host.h" +#include "esphome/components/host/hal.h" #elif defined(USE_ZEPHYR) -#include "esphome/core/hal/hal_zephyr.h" +#include "esphome/components/zephyr/hal.h" #else #error "hal.h: not implemented for this platform" #endif @@ -33,12 +33,12 @@ namespace esphome { // Cross-platform declarations. delayMicroseconds(), arch_feed_wdt(), // arch_get_cpu_cycle_count(), arch_init(), arch_get_cpu_freq_hz() vary // per platform (some inline, some out-of-line) so they live in -// hal/hal_.h. +// components//hal.h. void __attribute__((noreturn)) arch_restart(); #ifndef USE_ESP8266 // All non-ESP8266 platforms: PROGMEM is a no-op, so these are direct dereferences. -// ESP8266's out-of-line declarations live in hal/hal_esp8266.h. +// ESP8266's out-of-line declarations live in components/esp8266/hal.h. inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } inline const char *progmem_read_ptr(const char *const *addr) { return *addr; } inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } From 6182beb1f20966baff784ce195ad20c92fd913f6 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 1 May 2026 10:15:58 +0200 Subject: [PATCH 0980/2214] Apply suggestions --- esphome/components/esphome/ota/ota_esphome.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ae83678f3b..66ce7a9be4 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -118,7 +118,6 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; -static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. @@ -208,10 +207,9 @@ void ESPHomeOTAComponent::handle_handshake_() { const bool supports_compression = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); - // Compose the feature-ack response. When the client - // negotiates the extended protocol we emit a 2-byte response (marker + server feature flags); - // otherwise we emit the single-byte legacy response. The #ifdef wraps only the extended-proto - // branch so the legacy branch reads as unconditional code in either build configuration. + // Compose the feature-ack response. When the client negotiates the extended protocol we emit + // a 2-byte response (marker + server feature flags); otherwise we emit the single-byte + // legacy response. this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; From cb97c35d3637c83d1319360f1119ae46c2c730f3 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 1 May 2026 10:29:41 +0200 Subject: [PATCH 0981/2214] Backport changes --- .../components/esphome/ota/ota_esphome.cpp | 35 ++++++++----------- esphome/components/esphome/ota/ota_esphome.h | 2 -- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3ae7be7e8f..ca5a382875 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -237,19 +237,17 @@ void ESPHomeOTAComponent::handle_handshake_() { const bool supports_compression = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); - // Compose the feature-ack response. When USE_OTA_PARTITIONS is enabled and the client - // negotiates the extended protocol we emit a 2-byte response (marker + server feature flags); - // otherwise we emit the single-byte legacy response. The #ifdef wraps only the extended-proto - // branch so the legacy branch reads as unconditional code in either build configuration. -#ifdef USE_OTA_PARTITIONS + // Compose the feature-ack response. When the client negotiates the extended protocol we emit + // a 2-byte response (marker + server feature flags); otherwise we emit the single-byte + // legacy response. this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; - this->handshake_buf_[1] = - SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS | (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); - } else + this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); +#ifdef USE_OTA_PARTITIONS + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; #endif - { + } else { this->handshake_buf_[0] = supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; } @@ -257,15 +255,12 @@ void ESPHomeOTAComponent::handle_handshake_() { } case OTAState::FEATURE_ACK: { -#ifdef USE_OTA_PARTITIONS - const size_t ack_size = this->extended_proto_ ? 2 : 1; -#else - const size_t ack_size = 1; -#endif + static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1; + static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2; + const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE; if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } - #ifdef USE_OTA_PASSWORD // If password is set, move to auth phase if (!this->password_.empty()) { @@ -349,9 +344,7 @@ void ESPHomeOTAComponent::handle_data_() { uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; -#ifdef USE_OTA_PARTITIONS ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; -#endif #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif @@ -367,7 +360,6 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); -#ifdef USE_OTA_PARTITIONS if (this->extended_proto_) { // Read ota type, 1 byte if (!this->readall_(buf, 1)) { @@ -377,7 +369,6 @@ void ESPHomeOTAComponent::handle_data_() { ota_type = static_cast(buf[0]); } ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); -#endif // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { @@ -398,10 +389,14 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif - // This will block for a few seconds as it locks flash #ifdef USE_OTA_PARTITIONS error_code = this->backend_->begin(ota_size, ota_type); #else + if (ota_type != ota::OTA_TYPE_UPDATE_APP) { + error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + // This will block for a few seconds as it locks flash error_code = this->backend_->begin(ota_size); #endif if (error_code != ota::OTA_RESPONSE_OK) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 9bed9240aa..f612451ab0 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -91,9 +91,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD -#ifdef USE_OTA_PARTITIONS bool extended_proto_{false}; -#endif socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; From f073c1cabe180f10fc9af0b345a2b03cc4ec164e Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Fri, 1 May 2026 12:43:13 +0200 Subject: [PATCH 0982/2214] [usb_host][usb_uart] Add configurable max packet size (#14584) --- esphome/components/usb_host/__init__.py | 25 ++++++++++++++++--- esphome/components/usb_host/usb_host.h | 2 ++ .../components/usb_host/usb_host_client.cpp | 2 +- esphome/components/usb_uart/__init__.py | 12 ++++++--- esphome/components/usb_uart/usb_uart.cpp | 14 +++++------ esphome/components/usb_uart/usb_uart.h | 11 ++++---- 6 files changed, 44 insertions(+), 22 deletions(-) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 338bd8d572..8e591bd80c 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import CONF_DEVICES, CONF_ID +from esphome.core import CORE from esphome.cpp_types import Component from esphome.types import ConfigType @@ -19,14 +20,15 @@ DEPENDENCIES = ["esp32"] usb_host_ns = cg.esphome_ns.namespace("usb_host") USBHost = usb_host_ns.class_("USBHost", Component) USBClient = usb_host_ns.class_("USBClient", Component) - +DOMAIN = "usb_host" CONF_VID = "vid" CONF_PID = "pid" CONF_ENABLE_HUBS = "enable_hubs" CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests" +CONF_MAX_PACKET_SIZE = "max_packet_size" -def usb_device_schema(cls=USBClient, vid: int = None, pid: [int] = None) -> cv.Schema: +def usb_device_schema(cls=USBClient, vid: int = None, pid: int = None) -> cv.Schema: schema = cv.COMPONENT_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(cls), @@ -43,6 +45,17 @@ def usb_device_schema(cls=USBClient, vid: int = None, pid: [int] = None) -> cv.S return schema +def _set_max_packet_size(config: dict) -> dict: + CORE.data.setdefault(DOMAIN, {})[CONF_MAX_PACKET_SIZE] = config[ + CONF_MAX_PACKET_SIZE + ] + return config + + +def get_max_packet_size() -> int: + return CORE.data.get(DOMAIN, {}).get(CONF_MAX_PACKET_SIZE, 64) + + CONFIG_SCHEMA = cv.All( cv.COMPONENT_SCHEMA.extend( { @@ -51,10 +64,14 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAX_TRANSFER_REQUESTS, default=16): cv.int_range( min=1, max=32 ), + cv.Optional(CONF_MAX_PACKET_SIZE, default=64): cv.one_of( + 64, 128, 256, 512, 1024, int=True + ), cv.Optional(CONF_DEVICES): cv.ensure_list(usb_device_schema()), } ), only_on_variant(supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3]), + _set_max_packet_size, ) @@ -72,8 +89,8 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ENABLE_HUBS): add_idf_sdkconfig_option("CONFIG_USB_HOST_HUBS_SUPPORTED", True) - max_requests = config[CONF_MAX_TRANSFER_REQUESTS] - cg.add_define("USB_HOST_MAX_REQUESTS", max_requests) + cg.add_define("USB_HOST_MAX_REQUESTS", config[CONF_MAX_TRANSFER_REQUESTS]) + cg.add_define("USB_HOST_MAX_PACKET_SIZE", config[CONF_MAX_PACKET_SIZE]) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index dcb76a3a3b..480fd86750 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -66,6 +66,8 @@ static_assert(MAX_REQUESTS >= 1 && MAX_REQUESTS <= 32, "MAX_REQUESTS must be bet using trq_bitmask_t = std::conditional<(MAX_REQUESTS <= 16), uint16_t, uint32_t>::type; static constexpr trq_bitmask_t ALL_REQUESTS_IN_USE = MAX_REQUESTS == 32 ? ~0 : (1 << MAX_REQUESTS) - 1; +static constexpr size_t USB_MAX_PACKET_SIZE = + USB_HOST_MAX_PACKET_SIZE; // Max USB packet size (64 for FS, 512 for P4 HS) static constexpr size_t USB_EVENT_QUEUE_SIZE = 32; // Size of event queue between USB task and main loop static constexpr size_t USB_TASK_STACK_SIZE = 4096; // Stack size for USB task (same as ESP-IDF USB examples) static constexpr UBaseType_t USB_TASK_PRIORITY = 5; // Higher priority than main loop (tskIDLE_PRIORITY + 5) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index c34c7ef67d..4ee8e2ac5e 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -217,7 +217,7 @@ void USBClient::setup() { // Pre-allocate USB transfer buffers for all slots at startup // This avoids any dynamic allocation during runtime for (auto &request : this->requests_) { - usb_host_transfer_alloc(64, 0, &request.transfer); + usb_host_transfer_alloc(USB_MAX_PACKET_SIZE, 0, &request.transfer); request.client = this; // Set once, never changes } diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index d542788fb9..1cf78fdbd5 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,7 +1,11 @@ import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent -from esphome.components.usb_host import register_usb_client, usb_device_schema +from esphome.components.usb_host import ( + get_max_packet_size, + register_usb_client, + usb_device_schema, +) import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, @@ -118,14 +122,14 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config): # 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. Each chunk is 64 bytes (USB FS MPS); add one extra slot - # because LockFreeQueue is a ring buffer that wastes one entry. + # of every device. Add one extra slot because LockFreeQueue is a ring + # buffer that wastes one entry. max_buffer_size = max( channel[CONF_BUFFER_SIZE] for device in config for channel in device[CONF_CHANNELS] ) - output_chunk_count = max_buffer_size // 64 + 1 + output_chunk_count = max(max_buffer_size // get_max_packet_size(), 2) + 1 cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count) for device in config: diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 30ec61fdc4..e3bf5e40bc 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -157,7 +157,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { ESP_LOGE(TAG, "Output pool full - lost %zu bytes", len); break; } - size_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE); + uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE); memcpy(chunk->data, data, chunk_len); chunk->length = static_cast(chunk_len); // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if @@ -222,7 +222,7 @@ void USBUartComponent::loop() { #ifdef USE_UART_DEBUGGER if (channel->debug_) { - char buf[4 + format_hex_pretty_size(UsbDataChunk::MAX_CHUNK_SIZE)]; // "<<< " + hex + char buf[4 + format_hex_pretty_size(usb_host::USB_MAX_PACKET_SIZE)]; // "<<< " + hex memcpy(buf, "<<< ", 4); format_hex_pretty_to(buf + 4, sizeof(buf) - 4, chunk->data, chunk->length, ','); ESP_LOGD(TAG, "%s%s", channel->debug_prefix_.c_str(), buf); @@ -377,7 +377,7 @@ void USBUartComponent::start_output(USBUartChannel *channel) { this->start_output(channel); }; - const uint8_t len = chunk->length; + const auto len = chunk->length; if (!this->transfer_out(ep->bEndpointAddress, callback, chunk->data, len)) { // Transfer submission failed — return chunk and release flag so callers can retry. channel->output_pool_.release(chunk); @@ -394,10 +394,10 @@ void USBUartComponent::start_output(USBUartChannel *channel) { static void fix_mps(const usb_ep_desc_t *ep) { if (ep != nullptr) { auto *ep_mutable = const_cast(ep); - if (ep->wMaxPacketSize > 64) { - ESP_LOGW(TAG, "Corrected MPS of EP 0x%02X from %u to 64", static_cast(ep->bEndpointAddress & 0xFF), - ep->wMaxPacketSize); - ep_mutable->wMaxPacketSize = 64; + if (ep->wMaxPacketSize > usb_host::USB_MAX_PACKET_SIZE) { + ESP_LOGW(TAG, "Corrected MPS of EP 0x%02X from %u to %u", static_cast(ep->bEndpointAddress & 0xFF), + ep->wMaxPacketSize, usb_host::USB_MAX_PACKET_SIZE); + ep_mutable->wMaxPacketSize = usb_host::USB_MAX_PACKET_SIZE; } } } diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index f9648b795b..e88c41c0cb 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -106,20 +106,19 @@ class RingBuffer { // Structure for queuing received USB data chunks struct UsbDataChunk { - static constexpr size_t MAX_CHUNK_SIZE = 64; // USB packet size - uint8_t data[MAX_CHUNK_SIZE]; - uint8_t length; // Max 64 bytes, so uint8_t is sufficient + uint8_t data[usb_host::USB_MAX_PACKET_SIZE]; + uint16_t length; USBUartChannel *channel; // Required for EventPool - no cleanup needed for POD types void release() {} }; -// Structure for queuing outgoing USB data chunks (one per USB FS packet) +// Structure for queuing outgoing USB data chunks (one per USB packet) struct UsbOutputChunk { - static constexpr size_t MAX_CHUNK_SIZE = 64; // USB FS MPS + static constexpr size_t MAX_CHUNK_SIZE = usb_host::USB_MAX_PACKET_SIZE; uint8_t data[MAX_CHUNK_SIZE]; - uint8_t length; + uint16_t length; // Required for EventPool - no cleanup needed for POD types void release() {} From 3dd60c57134eea138ed0d78d84ff5d2465670e29 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 1 May 2026 08:55:08 -0400 Subject: [PATCH 0983/2214] [core] Support allocating ring buffer in internal memory (#16187) --- esphome/core/ring_buffer.cpp | 13 +++++++------ esphome/core/ring_buffer.h | 7 ++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/core/ring_buffer.cpp b/esphome/core/ring_buffer.cpp index 6a2232599f..2e0802eceb 100644 --- a/esphome/core/ring_buffer.cpp +++ b/esphome/core/ring_buffer.cpp @@ -1,11 +1,9 @@ #include "ring_buffer.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" - #ifdef USE_ESP32 -#include "helpers.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" namespace esphome { @@ -19,12 +17,15 @@ RingBuffer::~RingBuffer() { } } -std::unique_ptr RingBuffer::create(size_t len) { +std::unique_ptr RingBuffer::create(size_t len, MemoryPreference preference) { std::unique_ptr rb = make_unique(); rb->size_ = len; - RAMAllocator allocator; + const uint8_t type = (preference == MemoryPreference::INTERNAL_FIRST) ? RAMAllocator::PREFER_INTERNAL + : RAMAllocator::NONE; + + RAMAllocator allocator(type); rb->storage_ = allocator.allocate(rb->size_); if (rb->storage_ == nullptr) { return nullptr; diff --git a/esphome/core/ring_buffer.h b/esphome/core/ring_buffer.h index 98a273781f..4acd07d5b0 100644 --- a/esphome/core/ring_buffer.h +++ b/esphome/core/ring_buffer.h @@ -80,7 +80,12 @@ class RingBuffer { */ BaseType_t reset(); - static std::unique_ptr create(size_t len); + enum class MemoryPreference { + EXTERNAL_FIRST, // External RAM preferred, fall back to internal (default) + INTERNAL_FIRST, // Internal RAM preferred, fall back to external + }; + + static std::unique_ptr create(size_t len, MemoryPreference preference = MemoryPreference::EXTERNAL_FIRST); protected: /// @brief Discards data from the ring buffer. From 4c9260d96ddb1b6ca22b6bd3d0582f11279dc3f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 09:05:46 -0500 Subject: [PATCH 0984/2214] reduce memory --- esphome/components/esphome/ota/ota_esphome.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index f612451ab0..0637374ee4 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -91,7 +91,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD - bool extended_proto_{false}; socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; @@ -107,6 +106,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { uint8_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use #endif // USE_OTA_PASSWORD + bool extended_proto_{false}; }; } // namespace esphome From 45c78dd5d2b81b0b51fe40598d4048bf2d8f94e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 09:06:37 -0500 Subject: [PATCH 0985/2214] bail early so listeners do not stop bluetooth if its an error --- esphome/components/esphome/ota/ota_esphome.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 66ce7a9be4..aeaa3f9026 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -346,6 +346,11 @@ void ESPHomeOTAComponent::handle_data_() { (static_cast(buf[2]) << 8) | buf[3]; ESP_LOGV(TAG, "Size is %u bytes", ota_size); + if (ota_type != ota::OTA_TYPE_UPDATE_APP) { + error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + // Now that we've passed authentication and are actually // starting the update, set the warning status and notify // listeners. This ensures that port scanners do not @@ -356,10 +361,6 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif - if (ota_type != ota::OTA_TYPE_UPDATE_APP) { - error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } // This will block for a few seconds as it locks flash error_code = this->backend_->begin(ota_size); if (error_code != ota::OTA_RESPONSE_OK) From a6cd2a9f4d55535920ced66aade860c95eb1f75f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 09:08:07 -0500 Subject: [PATCH 0986/2214] improve error reporting in case we mess it up later --- esphome/espota2.py | 14 ++++++++++- tests/unit_tests/test_espota2.py | 40 ++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index e1cc52e7ce..ff94417f00 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -285,7 +285,19 @@ def perform_ota( features = 0 if ota_type != OTA_TYPE_UPDATE_APP: - raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") + # Any non-app OTA type requires the extended protocol and the + # partition-access server feature. Reject up front so the user gets + # a clear capability error instead of a post-auth 0x8E from the device. + if not extended_proto: + raise OTAError( + f"Device does not support extended OTA protocol; " + f"OTA type 0x{ota_type:02X} requires it" + ) + if not (features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS): + raise OTAError( + f"Device does not support partition access; " + f"OTA type 0x{ota_type:02X} cannot be used" + ) if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: upload_contents = gzip.compress(file_contents, compresslevel=9) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 1baf3f5db4..2b2a8f90f2 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -832,20 +832,50 @@ def test_perform_ota_extended_protocol_app( @pytest.mark.usefixtures("mock_time") -def test_perform_ota_extended_protocol_unsupported_type( +def test_perform_ota_non_app_type_requires_extended_protocol( mock_socket: Mock, mock_file: io.BytesIO ) -> None: - """Test OTA fails when OTA type is unsupported by the client.""" - # Setup socket responses for recv calls + """Non-app OTA type must fail when device only supports the legacy protocol.""" recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response bytes([espota2.OTA_VERSION_2_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_HEADER_OK]), # Legacy single-byte feature ack ] mock_socket.recv.side_effect = recv_responses - with pytest.raises(espota2.OTAError, match="Unsupported OTA type"): + with pytest.raises( + espota2.OTAError, match="Device does not support extended OTA protocol" + ): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + 255, + ) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_non_app_type_requires_partition_access( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Non-app OTA type must fail when device advertises extended protocol but + not the partition-access feature.""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Extended protocol marker + bytes( + [espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION] + ), # Compression only, no partition access + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises( + espota2.OTAError, match="Device does not support partition access" + ): espota2.perform_ota( mock_socket, "testpass", From 54ac510a25a8170fa3a837632d2cd7d72779dcde Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 09:08:58 -0500 Subject: [PATCH 0987/2214] cover --- tests/unit_tests/test_espota2.py | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 2b2a8f90f2..23802cff14 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -831,6 +831,47 @@ def test_perform_ota_extended_protocol_app( ) +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_device_rejects_with_unsupported_ota_type( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """End-to-end: device returns 0x8E after the size byte; perform_ota must + surface the human-readable 'unsupported OTA type' error from the lookup + table in check_error().""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Extended protocol marker + bytes( + [ + espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION + | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ] + ), # Feature flags + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE]), # Reject at size step + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises( + espota2.OTAError, + match="The requested OTA type is not supported by the device", + ): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + espota2.OTA_TYPE_UPDATE_APP, + ) + + # Verify the client did send the OTA type byte before the size step + assert mock_socket.sendall.call_args_list[2] == call( + bytes([espota2.OTA_TYPE_UPDATE_APP]) + ) + + @pytest.mark.usefixtures("mock_time") def test_perform_ota_non_app_type_requires_extended_protocol( mock_socket: Mock, mock_file: io.BytesIO From 28e1b32259b277854c89ecf27612177e9fb28c97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 09:09:23 -0500 Subject: [PATCH 0988/2214] enforce size --- esphome/components/esphome/ota/ota_esphome.cpp | 1 + esphome/components/esphome/ota/ota_esphome.h | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index aeaa3f9026..955b4dc96f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -212,6 +212,7 @@ void ESPHomeOTAComponent::handle_handshake_() { // legacy response. this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { + static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack"); this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); } else { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 0637374ee4..5043bc33ef 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -97,8 +97,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { ota::OTABackendPtr backend_; uint32_t client_connect_time_{0}; + static constexpr size_t HANDSHAKE_BUF_SIZE = 5; uint16_t port_; - uint8_t handshake_buf_[5]; + uint8_t handshake_buf_[HANDSHAKE_BUF_SIZE]; OTAState ota_state_{OTAState::IDLE}; uint8_t handshake_buf_pos_{0}; uint8_t ota_features_{0}; From 7f1cff186676c21d745e666690dd0bc99dbb29bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 09:22:37 -0500 Subject: [PATCH 0989/2214] address bot nits --- esphome/espota2.py | 26 ++++++++++- tests/unit_tests/test_espota2.py | 77 ++++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index ff94417f00..f4c0c73589 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -59,6 +59,11 @@ CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04 SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01 SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 +# OTA types this client knows how to send. Future PRs that add bootloader/partition +# updates extend this set. Anything outside the set is rejected up front so callers +# of perform_ota/run_ota get a clear error instead of a post-auth 0x8E from the device. +_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset({OTA_TYPE_UPDATE_APP}) + UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 @@ -192,8 +197,10 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None :param expect: Expected response code(s), None to skip validation. :raises OTAError: If an error code is detected or response doesn't match expected. """ - if expect is None: - return + # Detect device errors and connection-closed cases regardless of `expect`. If we + # only ran these checks when expect was set, error bytes returned during + # 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( "Error: Device closed connection without responding. " @@ -204,6 +211,8 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None error_msg = _ERROR_MESSAGES.get(dat) if error_msg is not None: raise OTAError(f"Error: {error_msg}") + if expect is None: + return if not isinstance(expect, (list, tuple)): expect = [expect] if dat not in expect: @@ -240,6 +249,19 @@ def perform_ota( filename: Path, ota_type: int = OTA_TYPE_UPDATE_APP, ) -> None: + # Validate ota_type up front. It travels as a single byte on the wire, and + # passing an out-of-range value would only surface as a ValueError from + # bytes([ota_type]) deep inside send_check, bypassing OTAError handling. + if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF: + raise OTAError( + f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255" + ) + if ota_type not in _SUPPORTED_OTA_TYPES: + supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES)) + raise OTAError( + f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}" + ) + file_contents = file_handle.read() file_size = len(file_contents) _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 23802cff14..b114f17e6c 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -873,10 +873,48 @@ def test_perform_ota_device_rejects_with_unsupported_ota_type( @pytest.mark.usefixtures("mock_time") -def test_perform_ota_non_app_type_requires_extended_protocol( +def test_perform_ota_unsupported_type_rejected_early( mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """ota_type values not in _SUPPORTED_OTA_TYPES are rejected before any I/O.""" + with pytest.raises(espota2.OTAError, match="Unsupported OTA type 0xFF"): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + 0xFF, + ) + # No bytes should have been transmitted to the device. + mock_socket.sendall.assert_not_called() + + +@pytest.mark.parametrize("bad_type", [-1, 256, 0x10000, "app", None, 1.5]) +def test_perform_ota_rejects_out_of_range_type( + mock_socket: Mock, mock_file: io.BytesIO, bad_type: object +) -> None: + """Out-of-range or non-int ota_type must raise OTAError, not ValueError.""" + with pytest.raises(espota2.OTAError, match="Invalid ota_type"): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + bad_type, # type: ignore[arg-type] + ) + mock_socket.sendall.assert_not_called() + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_non_app_type_requires_extended_protocol( + mock_socket: Mock, mock_file: io.BytesIO, monkeypatch: pytest.MonkeyPatch ) -> None: """Non-app OTA type must fail when device only supports the legacy protocol.""" + monkeypatch.setattr( + espota2, + "_SUPPORTED_OTA_TYPES", + frozenset({espota2.OTA_TYPE_UPDATE_APP, 0xFF}), + ) recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response bytes([espota2.OTA_VERSION_2_0]), # Version number @@ -893,16 +931,21 @@ def test_perform_ota_non_app_type_requires_extended_protocol( "testpass", mock_file, "test.bin", - 255, + 0xFF, ) @pytest.mark.usefixtures("mock_time") def test_perform_ota_non_app_type_requires_partition_access( - mock_socket: Mock, mock_file: io.BytesIO + mock_socket: Mock, mock_file: io.BytesIO, monkeypatch: pytest.MonkeyPatch ) -> None: """Non-app OTA type must fail when device advertises extended protocol but not the partition-access feature.""" + monkeypatch.setattr( + espota2, + "_SUPPORTED_OTA_TYPES", + frozenset({espota2.OTA_TYPE_UPDATE_APP, 0xFF}), + ) recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response bytes([espota2.OTA_VERSION_2_0]), # Version number @@ -922,5 +965,31 @@ def test_perform_ota_non_app_type_requires_partition_access( "testpass", mock_file, "test.bin", - 255, + 0xFF, ) + + +def test_check_error_detects_errors_when_expect_is_none() -> None: + """check_error must surface device error bytes even when expect is None. + + Regression test: previously, receive_exactly(..., expect=None) calls (used + during feature negotiation and nonce reads) silently passed error bytes + through, turning clean device errors into confusing later failures. + """ + with pytest.raises(espota2.OTAError, match="Error: Authentication invalid"): + espota2.check_error([espota2.RESPONSE_ERROR_AUTH_INVALID], None) + + +def test_check_error_detects_empty_when_expect_is_none() -> None: + """Empty data with expect=None must still raise (connection closed).""" + with pytest.raises( + espota2.OTAError, match="Device closed connection without responding" + ): + espota2.check_error([], None) + + +def test_check_error_passes_non_error_when_expect_is_none() -> None: + """Non-error bytes with expect=None must pass through silently.""" + espota2.check_error([espota2.RESPONSE_OK], None) + espota2.check_error([espota2.RESPONSE_HEADER_OK], None) + espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None) From 58cb7effd45323c28c679036b9574d52d0d85e95 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 1 May 2026 15:40:14 +0000 Subject: [PATCH 0990/2214] [ota] Add extended OTA protocol (#16164) Co-authored-by: J. Nick Koston --- esphome/__main__.py | 11 +- .../components/esphome/ota/ota_esphome.cpp | 50 +++- esphome/components/esphome/ota/ota_esphome.h | 4 +- esphome/components/ota/ota_backend.h | 8 + esphome/espota2.py | 220 ++++++++++------ tests/unit_tests/test_espota2.py | 244 +++++++++++++++++- tests/unit_tests/test_main.py | 23 +- 7 files changed, 456 insertions(+), 104 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 781bcd6288..e7ce36ae2d 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1125,15 +1125,16 @@ def upload_program( remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD) - if getattr(args, "file", None) is not None: - binary = Path(args.file) - else: - binary = CORE.firmware_bin # Resolve MQTT magic strings to actual IP addresses network_devices = _resolve_network_devices(devices, config, args) - return espota2.run_ota(network_devices, remote_port, password, binary) + binary = CORE.firmware_bin + ota_type = espota2.OTA_TYPE_UPDATE_APP + if getattr(args, "file", None) is not None: + binary = Path(args.file) + + return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index be771eb689..955b4dc96f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -114,8 +114,10 @@ void ESPHomeOTAComponent::loop() { this->handle_handshake_(); } -static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; -static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. @@ -201,16 +203,30 @@ void ESPHomeOTAComponent::handle_handshake_() { this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); this->transition_ota_state_(OTAState::FEATURE_ACK); - this->handshake_buf_[0] = - ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) - ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION - : ota::OTA_RESPONSE_HEADER_OK; + + const bool supports_compression = + (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); + + // Compose the feature-ack response. When the client negotiates the extended protocol we emit + // a 2-byte response (marker + server feature flags); otherwise we emit the single-byte + // legacy response. + this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; + if (this->extended_proto_) { + static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack"); + this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; + this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); + } else { + this->handshake_buf_[0] = + supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; + } [[fallthrough]]; } case OTAState::FEATURE_ACK: { - // Acknowledge header - 1 byte - if (!this->try_write_(1, LOG_STR("ack feature"))) { + static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1; + static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2; + const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE; + if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } #ifdef USE_OTA_PASSWORD @@ -296,6 +312,7 @@ void ESPHomeOTAComponent::handle_data_() { uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; + ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif @@ -311,6 +328,16 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); + if (this->extended_proto_) { + // Read ota type, 1 byte + if (!this->readall_(buf, 1)) { + this->log_read_error_(LOG_STR("OTA type")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + ota_type = static_cast(buf[0]); + } + ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); + // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { this->log_read_error_(LOG_STR("size")); @@ -320,6 +347,11 @@ void ESPHomeOTAComponent::handle_data_() { (static_cast(buf[2]) << 8) | buf[3]; ESP_LOGV(TAG, "Size is %u bytes", ota_size); + if (ota_type != ota::OTA_TYPE_UPDATE_APP) { + error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + // Now that we've passed authentication and are actually // starting the update, set the warning status and notify // listeners. This ensures that port scanners do not @@ -616,7 +648,7 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); } bool ESPHomeOTAComponent::select_auth_type_() { - bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + bool client_supports_sha256 = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_SHA256_AUTH) != 0; // Require SHA256 if (!client_supports_sha256) { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 53288fc000..5043bc33ef 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -97,8 +97,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { ota::OTABackendPtr backend_; uint32_t client_connect_time_{0}; + static constexpr size_t HANDSHAKE_BUF_SIZE = 5; uint16_t port_; - uint8_t handshake_buf_[5]; + uint8_t handshake_buf_[HANDSHAKE_BUF_SIZE]; OTAState ota_state_{OTAState::IDLE}; uint8_t handshake_buf_pos_{0}; uint8_t ota_features_{0}; @@ -106,6 +107,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { uint8_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use #endif // USE_OTA_PASSWORD + bool extended_proto_{false}; }; } // namespace esphome diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index bd9c481901..7e7b0f6523 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 + #ifdef USE_OTA_STATE_LISTENER #include #endif @@ -23,6 +25,7 @@ enum OTAResponseTypes { OTA_RESPONSE_UPDATE_END_OK = 0x45, OTA_RESPONSE_SUPPORTS_COMPRESSION = 0x46, OTA_RESPONSE_CHUNK_OK = 0x47, + OTA_RESPONSE_FEATURE_FLAGS = 0x48, OTA_RESPONSE_ERROR_MAGIC = 0x80, OTA_RESPONSE_ERROR_UPDATE_PREPARE = 0x81, @@ -38,6 +41,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_MD5_MISMATCH = 0x8B, OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C, OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D, + OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -49,6 +53,10 @@ enum OTAState { OTA_ERROR, }; +enum OTAType : uint8_t { + OTA_TYPE_UPDATE_APP = 0x00, +}; + /** Listener interface for OTA state changes. * * Components can implement this interface to receive OTA state updates diff --git a/esphome/espota2.py b/esphome/espota2.py index 39f51e02e9..f4c0c73589 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -15,6 +15,8 @@ from typing import Any from esphome.core import EsphomeError from esphome.helpers import ProgressBar, resolve_ip_address +OTA_TYPE_UPDATE_APP = 0x00 + RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 RESPONSE_REQUEST_SHA256_AUTH = 0x02 @@ -27,6 +29,7 @@ RESPONSE_RECEIVE_OK = 0x44 RESPONSE_UPDATE_END_OK = 0x45 RESPONSE_SUPPORTS_COMPRESSION = 0x46 RESPONSE_CHUNK_OK = 0x47 +RESPONSE_FEATURE_FLAGS = 0x48 RESPONSE_ERROR_MAGIC = 0x80 RESPONSE_ERROR_UPDATE_PREPARE = 0x81 @@ -42,6 +45,7 @@ RESPONSE_ERROR_NO_UPDATE_PARTITION = 0x8A RESPONSE_ERROR_MD5_MISMATCH = 0x8B RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D +RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -49,9 +53,16 @@ OTA_VERSION_2_0 = 2 MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45] -FEATURE_SUPPORTS_COMPRESSION = 0x01 -FEATURE_SUPPORTS_SHA256_AUTH = 0x02 +CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01 +CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02 +CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04 +SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01 +SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 +# OTA types this client knows how to send. Future PRs that add bootloader/partition +# updates extend this set. Anything outside the set is rejected up front so callers +# of perform_ota/run_ota get a clear error instead of a post-auth 0x8E from the device. +_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset({OTA_TYPE_UPDATE_APP}) UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 @@ -64,6 +75,62 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } +# Error response code -> human-readable message (without the "Error: " prefix; check_error() +# prepends it uniformly). Looked up by check_error() to translate a single byte from the device +# into an OTAError. Add new error codes here rather than extending the if-chain in check_error(). +_ERROR_MESSAGES: dict[int, str] = { + RESPONSE_ERROR_MAGIC: "Invalid magic byte", + RESPONSE_ERROR_UPDATE_PREPARE: ( + "Couldn't prepare flash memory for update. Is the binary too big? " + "Please try restarting the ESP." + ), + RESPONSE_ERROR_AUTH_INVALID: "Authentication invalid. Is the password correct?", + RESPONSE_ERROR_WRITING_FLASH: ( + "Writing OTA data to flash memory failed. See USB logs for more information." + ), + RESPONSE_ERROR_UPDATE_END: ( + "Finishing update failed. See the MQTT/USB logs for more information." + ), + RESPONSE_ERROR_INVALID_BOOTSTRAPPING: ( + "Please press the reset button on the ESP. A manual reset is " + "required on the first OTA-Update after flashing via USB." + ), + RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: ( + "ESP has been flashed with wrong flash size. Please choose the " + "correct 'board' option (esp01_1m always works) and then flash over USB." + ), + RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: ( + "ESP does not have the requested flash size (wrong board). Please " + "choose the correct 'board' option (esp01_1m always works) and try " + "uploading again." + ), + RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: ( + "ESP does not have enough space to store OTA file. Please try " + "flashing a minimal firmware (remove everything except ota)" + ), + RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: ( + "The OTA partition on the ESP is too small. ESPHome needs to resize " + "this partition, please flash over USB." + ), + RESPONSE_ERROR_NO_UPDATE_PARTITION: ( + "The OTA partition on the ESP couldn't be found. ESPHome needs to " + "create this partition, please flash over USB." + ), + RESPONSE_ERROR_MD5_MISMATCH: ( + "Application MD5 code mismatch. Please try again " + "or flash over USB with a good quality cable." + ), + RESPONSE_ERROR_SIGNATURE_INVALID: ( + "Firmware signature verification failed. The firmware was not signed " + "with the correct key. Ensure the signing key matches the one used to build " + "the firmware currently running on the device." + ), + RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: ( + "The requested OTA type is not supported by the device." + ), + RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", +} + class OTAError(EsphomeError): pass @@ -130,8 +197,10 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None :param expect: Expected response code(s), None to skip validation. :raises OTAError: If an error code is detected or response doesn't match expected. """ - if expect is None: - return + # Detect device errors and connection-closed cases regardless of `expect`. If we + # only ran these checks when expect was set, error bytes returned during + # 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( "Error: Device closed connection without responding. " @@ -139,69 +208,11 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "a network issue, or the connection was interrupted." ) dat = data[0] - if dat == RESPONSE_ERROR_MAGIC: - raise OTAError("Error: Invalid magic byte") - if dat == RESPONSE_ERROR_UPDATE_PREPARE: - raise OTAError( - "Error: Couldn't prepare flash memory for update. Is the binary too big? " - "Please try restarting the ESP." - ) - if dat == RESPONSE_ERROR_AUTH_INVALID: - raise OTAError("Error: Authentication invalid. Is the password correct?") - if dat == RESPONSE_ERROR_WRITING_FLASH: - raise OTAError( - "Error: Writing OTA data to flash memory failed. See USB logs for more " - "information." - ) - if dat == RESPONSE_ERROR_UPDATE_END: - raise OTAError( - "Error: Finishing update failed. See the MQTT/USB logs for more " - "information." - ) - if dat == RESPONSE_ERROR_INVALID_BOOTSTRAPPING: - raise OTAError( - "Error: Please press the reset button on the ESP. A manual reset is " - "required on the first OTA-Update after flashing via USB." - ) - if dat == RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: - raise OTAError( - "Error: ESP has been flashed with wrong flash size. Please choose the " - "correct 'board' option (esp01_1m always works) and then flash over USB." - ) - if dat == RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: - raise OTAError( - "Error: ESP does not have the requested flash size (wrong board). Please " - "choose the correct 'board' option (esp01_1m always works) and try " - "uploading again." - ) - if dat == RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: - raise OTAError( - "Error: ESP does not have enough space to store OTA file. Please try " - "flashing a minimal firmware (remove everything except ota)" - ) - if dat == RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: - raise OTAError( - "Error: The OTA partition on the ESP is too small. ESPHome needs to resize " - "this partition, please flash over USB." - ) - if dat == RESPONSE_ERROR_NO_UPDATE_PARTITION: - raise OTAError( - "Error: The OTA partition on the ESP couldn't be found. ESPHome needs to create " - "this partition, please flash over USB." - ) - if dat == RESPONSE_ERROR_MD5_MISMATCH: - raise OTAError( - "Error: Application MD5 code mismatch. Please try again " - "or flash over USB with a good quality cable." - ) - if dat == RESPONSE_ERROR_SIGNATURE_INVALID: - raise OTAError( - "Error: Firmware signature verification failed. The firmware was not signed " - "with the correct key. Ensure the signing key matches the one used to build " - "the firmware currently running on the device." - ) - if dat == RESPONSE_ERROR_UNKNOWN: - raise OTAError("Unknown error from ESP") + error_msg = _ERROR_MESSAGES.get(dat) + if error_msg is not None: + raise OTAError(f"Error: {error_msg}") + if expect is None: + return if not isinstance(expect, (list, tuple)): expect = [expect] if dat not in expect: @@ -232,8 +243,25 @@ def send_check( def perform_ota( - sock: socket.socket, password: str | None, file_handle: io.IOBase, filename: Path + sock: socket.socket, + password: str | None, + file_handle: io.IOBase, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> None: + # Validate ota_type up front. It travels as a single byte on the wire, and + # passing an out-of-range value would only surface as a ValueError from + # bytes([ota_type]) deep inside send_check, bypassing OTAError handling. + if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF: + raise OTAError( + f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255" + ) + if ota_type not in _SUPPORTED_OTA_TYPES: + supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES)) + raise OTAError( + f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}" + ) + file_contents = file_handle.read() file_size = len(file_contents) _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) @@ -251,7 +279,11 @@ def perform_ota( ) # Features - send both compression and SHA256 auth support - features_to_send = FEATURE_SUPPORTS_COMPRESSION | FEATURE_SUPPORTS_SHA256_AUTH + features_to_send = ( + CLIENT_FEATURE_SUPPORTS_COMPRESSION + | CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ) send_check(sock, features_to_send, "features") features = receive_exactly( sock, @@ -260,7 +292,36 @@ def perform_ota( None, # Accept any response )[0] - if features == RESPONSE_SUPPORTS_COMPRESSION: + extended_proto = False + if features == RESPONSE_FEATURE_FLAGS: + extended_proto = True + features = receive_exactly( + sock, + 1, + "feature flags", + None, # Accept any response + )[0] + elif features == RESPONSE_SUPPORTS_COMPRESSION: + features = SERVER_FEATURE_SUPPORTS_COMPRESSION + else: + features = 0 + + if ota_type != OTA_TYPE_UPDATE_APP: + # Any non-app OTA type requires the extended protocol and the + # partition-access server feature. Reject up front so the user gets + # a clear capability error instead of a post-auth 0x8E from the device. + if not extended_proto: + raise OTAError( + f"Device does not support extended OTA protocol; " + f"OTA type 0x{ota_type:02X} requires it" + ) + if not (features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS): + raise OTAError( + f"Device does not support partition access; " + f"OTA type 0x{ota_type:02X} cannot be used" + ) + + if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: upload_contents = gzip.compress(file_contents, compresslevel=9) _LOGGER.info("Compressed to %s bytes", len(upload_contents)) else: @@ -315,6 +376,9 @@ def perform_ota( # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) + if extended_proto: + send_check(sock, ota_type, "ota type") + upload_size = len(upload_contents) upload_size_encoded = [ (upload_size >> 24) & 0xFF, @@ -375,7 +439,11 @@ def perform_ota( def run_ota_impl_( - remote_host: str | list[str], remote_port: int, password: str | None, filename: Path + remote_host: str | list[str], + remote_port: int, + password: str | None, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -413,7 +481,7 @@ def run_ota_impl_( _LOGGER.info("Connected to %s", sa[0]) with open(filename, "rb") as file_handle: try: - perform_ota(sock, password, file_handle, filename) + perform_ota(sock, password, file_handle, filename, ota_type) except OTAError as err: _LOGGER.error(str(err)) return 1, None @@ -428,10 +496,14 @@ def run_ota_impl_( def run_ota( - remote_host: str | list[str], remote_port: int, password: str | None, filename: Path + remote_host: str | list[str], + remote_port: int, + password: str | None, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> tuple[int, str | None]: try: - return run_ota_impl_(remote_host, remote_port, password, filename) + return run_ota_impl_(remote_host, remote_port, password, filename, ota_type) except OTAError as err: _LOGGER.error(err) return 1, None diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 20ba4b1f76..b114f17e6c 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -185,6 +185,14 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: "Error: The OTA partition on the ESP couldn't be found", ), (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), + ( + espota2.RESPONSE_ERROR_SIGNATURE_INVALID, + "Error: Firmware signature verification failed", + ), + ( + espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, + "Error: The requested OTA type is not supported by the device", + ), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) @@ -270,12 +278,13 @@ def test_perform_ota_successful_md5_auth( # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent (compression + SHA256 support) + # Verify features were sent (compression + SHA256 support + extended protocol) assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -640,12 +649,13 @@ def test_perform_ota_successful_sha256_auth( # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent (compression + SHA256 support) + # Verify features were sent (compression + SHA256 support + extended protocol) assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -699,8 +709,9 @@ def test_perform_ota_sha256_fallback_to_md5( assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -765,3 +776,220 @@ def test_perform_ota_version_differences( # For v2.0, verify more recv calls due to chunk acknowledgments assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_extended_protocol_app( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test OTA extended protocol app update.""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol + bytes( + [ + espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION + | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ] + ), # Device feature flags + 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 + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + espota2.OTA_TYPE_UPDATE_APP, + ) + + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) + + # Verify features were sent (compression + SHA256 support + extended protocol) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ] + ) + ) + + # Verify ota type was sent + assert mock_socket.sendall.call_args_list[2] == call( + bytes([espota2.OTA_TYPE_UPDATE_APP]) + ) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_device_rejects_with_unsupported_ota_type( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """End-to-end: device returns 0x8E after the size byte; perform_ota must + surface the human-readable 'unsupported OTA type' error from the lookup + table in check_error().""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Extended protocol marker + bytes( + [ + espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION + | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ] + ), # Feature flags + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE]), # Reject at size step + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises( + espota2.OTAError, + match="The requested OTA type is not supported by the device", + ): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + espota2.OTA_TYPE_UPDATE_APP, + ) + + # Verify the client did send the OTA type byte before the size step + assert mock_socket.sendall.call_args_list[2] == call( + bytes([espota2.OTA_TYPE_UPDATE_APP]) + ) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_unsupported_type_rejected_early( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """ota_type values not in _SUPPORTED_OTA_TYPES are rejected before any I/O.""" + with pytest.raises(espota2.OTAError, match="Unsupported OTA type 0xFF"): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + 0xFF, + ) + # No bytes should have been transmitted to the device. + mock_socket.sendall.assert_not_called() + + +@pytest.mark.parametrize("bad_type", [-1, 256, 0x10000, "app", None, 1.5]) +def test_perform_ota_rejects_out_of_range_type( + mock_socket: Mock, mock_file: io.BytesIO, bad_type: object +) -> None: + """Out-of-range or non-int ota_type must raise OTAError, not ValueError.""" + with pytest.raises(espota2.OTAError, match="Invalid ota_type"): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + bad_type, # type: ignore[arg-type] + ) + mock_socket.sendall.assert_not_called() + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_non_app_type_requires_extended_protocol( + mock_socket: Mock, mock_file: io.BytesIO, monkeypatch: pytest.MonkeyPatch +) -> None: + """Non-app OTA type must fail when device only supports the legacy protocol.""" + monkeypatch.setattr( + espota2, + "_SUPPORTED_OTA_TYPES", + frozenset({espota2.OTA_TYPE_UPDATE_APP, 0xFF}), + ) + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Legacy single-byte feature ack + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises( + espota2.OTAError, match="Device does not support extended OTA protocol" + ): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + 0xFF, + ) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_non_app_type_requires_partition_access( + mock_socket: Mock, mock_file: io.BytesIO, monkeypatch: pytest.MonkeyPatch +) -> None: + """Non-app OTA type must fail when device advertises extended protocol but + not the partition-access feature.""" + monkeypatch.setattr( + espota2, + "_SUPPORTED_OTA_TYPES", + frozenset({espota2.OTA_TYPE_UPDATE_APP, 0xFF}), + ) + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Extended protocol marker + bytes( + [espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION] + ), # Compression only, no partition access + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises( + espota2.OTAError, match="Device does not support partition access" + ): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + 0xFF, + ) + + +def test_check_error_detects_errors_when_expect_is_none() -> None: + """check_error must surface device error bytes even when expect is None. + + Regression test: previously, receive_exactly(..., expect=None) calls (used + during feature negotiation and nonce reads) silently passed error bytes + through, turning clean device errors into confusing later failures. + """ + with pytest.raises(espota2.OTAError, match="Error: Authentication invalid"): + espota2.check_error([espota2.RESPONSE_ERROR_AUTH_INVALID], None) + + +def test_check_error_detects_empty_when_expect_is_none() -> None: + """Empty data with expect=None must still raise (connection closed).""" + with pytest.raises( + espota2.OTAError, match="Device closed connection without responding" + ): + espota2.check_error([], None) + + +def test_check_error_passes_non_error_when_expect_is_none() -> None: + """Non-error bytes with expect=None must pass through silently.""" + espota2.check_error([espota2.RESPONSE_OK], None) + espota2.check_error([espota2.RESPONSE_HEADER_OK], None) + espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fb8f206a1d..186d8a9573 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -83,6 +83,7 @@ from esphome.const import ( PLATFORM_RP2040, ) from esphome.core import CORE, EsphomeError +from esphome.espota2 import OTA_TYPE_UPDATE_APP from esphome.util import BootselResult from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1593,7 +1594,7 @@ def test_upload_program_ota_success( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, "secret", expected_firmware + ["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -1624,7 +1625,7 @@ def test_upload_program_ota_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, Path("custom.bin") + ["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP ) @@ -1682,7 +1683,7 @@ def test_upload_program_ota_with_mqtt_resolution( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware + ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -1730,7 +1731,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.50"], 3232, None, expected_firmware + ["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -3207,7 +3208,11 @@ def test_upload_program_ota_static_ip_with_mqttip( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware + ["192.168.1.100", "192.168.2.50"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, ) @@ -3250,7 +3255,11 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware + ["192.168.2.50", "192.168.2.51", "192.168.1.100"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, ) @@ -3415,7 +3424,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware + ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) From 2068d114eb0631a153e524514969150c8771b955 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 16:02:30 +0000 Subject: [PATCH 0991/2214] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_espota2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index d56f9cb6a5..04e08f9009 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -1077,4 +1077,4 @@ def test_check_error_passes_non_error_when_expect_is_none() -> None: """Non-error bytes with expect=None must pass through silently.""" espota2.check_error([espota2.RESPONSE_OK], None) espota2.check_error([espota2.RESPONSE_HEADER_OK], None) - espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None) \ No newline at end of file + espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None) From 2dd8a9913b5b639fc3e69ef2d49b909f0a2478be Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 1 May 2026 18:15:40 +0200 Subject: [PATCH 0992/2214] Fix --- esphome/components/esphome/ota/ota_esphome.h | 1 - esphome/espota2.py | 2 +- tests/unit_tests/test_espota2.py | 24 -------------------- 3 files changed, 1 insertion(+), 26 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 0431bd98e0..5043bc33ef 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -91,7 +91,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD - bool extended_proto_{false}; socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; diff --git a/esphome/espota2.py b/esphome/espota2.py index 85e24abcb7..abf56ed67a 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -65,7 +65,7 @@ SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 # OTA types this client knows how to send. Future PRs that add bootloader/partition # updates extend this set. Anything outside the set is rejected up front so callers # of perform_ota/run_ota get a clear error instead of a post-auth 0x8E from the device. -_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset({OTA_TYPE_UPDATE_APP}) +_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset({OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE}) UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 04e08f9009..28e30bbe77 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -892,30 +892,6 @@ def test_perform_ota_successful_partition_table( ) -@pytest.mark.usefixtures("mock_time") -def test_perform_ota_extended_protocol_unsupported( - mock_socket: Mock, mock_file: io.BytesIO -) -> None: - """Test OTA fails when extended protocol is required but unsupported.""" - # Setup socket responses for recv calls - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_2_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response - ] - - mock_socket.recv.side_effect = recv_responses - - with pytest.raises(espota2.OTAError, match="Device only supports app updates"): - espota2.perform_ota( - mock_socket, - "testpass", - mock_file, - "partitions.bin", - espota2.OTA_TYPE_UPDATE_PARTITION_TABLE, - ) - - @pytest.mark.usefixtures("mock_time") def test_perform_ota_device_rejects_with_unsupported_ota_type( mock_socket: Mock, mock_file: io.BytesIO From 9951a837f1ffdab8d8471f0f851267be0e7d8d20 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 16:17:31 +0000 Subject: [PATCH 0993/2214] [pre-commit.ci lite] apply automatic fixes --- esphome/espota2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index abf56ed67a..e50b748e98 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -65,7 +65,9 @@ SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 # OTA types this client knows how to send. Future PRs that add bootloader/partition # updates extend this set. Anything outside the set is rejected up front so callers # of perform_ota/run_ota get a clear error instead of a post-auth 0x8E from the device. -_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset({OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE}) +_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( + {OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE} +) UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 From fcc6f048054a7759ac2ef5c679a5b1ea963d701c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 11:56:19 -0500 Subject: [PATCH 0994/2214] [api] Don't tear down log connection on stack-trace decode failure When 'esphome logs' processes a crash backtrace from the device, process_stacktrace -> _decode_pc -> _run_idedata can raise EsphomeError if the local build dir hasn't been populated (e.g. the device was flashed from a different machine). on_log runs inside an asyncio protocol callback, so the unhandled exception triggers 'Fatal error: protocol.data_received() call failed.', the loop tears the connection down, and ReconnectLogic immediately reconnects. The device replays the same crash trace and we loop forever. Wrap the per-line decode in a helper that swallows EsphomeError so the connection stays up. Also covered with unit tests for the new helper. --- esphome/components/api/client.py | 39 ++++++--- tests/unit_tests/components/api/__init__.py | 0 .../unit_tests/components/api/test_client.py | 80 +++++++++++++++++++ 3 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 tests/unit_tests/components/api/__init__.py create mode 100644 tests/unit_tests/components/api/test_client.py diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 312d937f01..31ae8163da 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -18,7 +18,7 @@ with warnings.catch_warnings(): import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.platformio_api import process_stacktrace from . import CONF_ENCRYPTION @@ -32,6 +32,32 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +def _process_stacktrace_line( + config: dict[str, Any], + raw_line: str, + backtrace_state: bool, + platform_process_stacktrace: Any | None, +) -> bool: + """Run the stack-trace decoder for a single log line. + + on_log runs inside an asyncio protocol callback; if an exception + escapes, the loop tears the transport down with "Fatal error: + protocol.data_received() call failed." and ReconnectLogic + immediately reconnects, the device replays the same crash trace, + and we loop forever. Stack-trace decoding requires a populated + build dir for the device, which may not exist (e.g. flashed from + another machine); log and continue instead of killing the + connection. + """ + try: + if platform_process_stacktrace: + return platform_process_stacktrace(config, raw_line, backtrace_state) + return process_stacktrace(config, raw_line, backtrace_state=backtrace_state) + except EsphomeError as exc: + _LOGGER.debug("Stack-trace decoding failed: %s", exc) + return False + + async def async_run_logs( config: dict[str, Any], addresses: list[str], @@ -84,14 +110,9 @@ async def async_run_logs( for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) for raw_line in text.splitlines(): - if platform_process_stacktrace: - backtrace_state = platform_process_stacktrace( - config, raw_line, backtrace_state - ) - else: - backtrace_state = process_stacktrace( - config, raw_line, backtrace_state=backtrace_state - ) + backtrace_state = _process_stacktrace_line( + config, raw_line, backtrace_state, platform_process_stacktrace + ) # Safe to fall back to plaintext here only for this diagnostics use # case: the stream is one-way from device to client, and this code diff --git a/tests/unit_tests/components/api/__init__.py b/tests/unit_tests/components/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py new file mode 100644 index 0000000000..9f038b093d --- /dev/null +++ b/tests/unit_tests/components/api/test_client.py @@ -0,0 +1,80 @@ +"""Tests for esphome.components.api.client.""" + +from __future__ import annotations + +from unittest.mock import patch + +from esphome.components.api import client as api_client +from esphome.core import EsphomeError + + +def test_process_stacktrace_line_swallows_esphome_error() -> None: + """A failing stack-trace decode must not propagate. + + on_log runs inside an asyncio protocol callback; if EsphomeError + escapes, the loop reports "Fatal error: protocol.data_received() + call failed.", tears the connection down, and ReconnectLogic loops + forever as the device replays the same crash trace on every + reconnect. + """ + config = {"esphome": {"name": "test"}} + + with patch.object( + api_client, "process_stacktrace", side_effect=EsphomeError("no idedata") + ) as mock_process: + result = api_client._process_stacktrace_line( + config, "PC: 0x4010496e", True, None + ) + + assert mock_process.called + assert result is False + + +def test_process_stacktrace_line_swallows_platform_handler_error() -> None: + """The same protection must apply to the platform-specific handler.""" + config = {"esphome": {"name": "test"}} + + def platform_handler(_config, _line, _state): + raise EsphomeError("no idedata") + + result = api_client._process_stacktrace_line( + config, "PC: 0x4010496e", True, platform_handler + ) + + assert result is False + + +def test_process_stacktrace_line_returns_handler_result() -> None: + """When decoding succeeds, the handler's result is returned unchanged.""" + config = {"esphome": {"name": "test"}} + + with patch.object( + api_client, "process_stacktrace", return_value=True + ) as mock_process: + result = api_client._process_stacktrace_line( + config, "PC: 0x4010496e", False, None + ) + + mock_process.assert_called_once_with( + config, "PC: 0x4010496e", backtrace_state=False + ) + assert result is True + + +def test_process_stacktrace_line_uses_platform_handler_when_provided() -> None: + """The platform handler is preferred over the generic one.""" + config = {"esphome": {"name": "test"}} + calls: list[tuple[object, str, bool]] = [] + + def platform_handler(cfg, line, state): + calls.append((cfg, line, state)) + return True + + with patch.object(api_client, "process_stacktrace") as mock_generic: + result = api_client._process_stacktrace_line( + config, "BT0: 0x4010496e", False, platform_handler + ) + + assert calls == [(config, "BT0: 0x4010496e", False)] + assert mock_generic.called is False + assert result is True From f4d3fb1a1810b707558db7990fbc4d25b231690b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 12:01:19 -0500 Subject: [PATCH 0995/2214] [api] Short-circuit log-line decoder after first failure _decode_pc shells out to PlatformIO via _run_idedata; without a populated build dir for the device that subprocess fails for every PC/BT line in a crash dump. Disable decoding after the first EsphomeError (per logs session) and emit a single user-facing warning instead of retrying on every line. Also rename the helper to _LogLineProcessor since it now owns the per-session decode-enabled state, not just the decode call. --- esphome/components/api/client.py | 69 +++++++++++-------- .../unit_tests/components/api/test_client.py | 68 +++++++++++------- 2 files changed, 85 insertions(+), 52 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 31ae8163da..9b8b42ab31 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -32,30 +32,47 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -def _process_stacktrace_line( - config: dict[str, Any], - raw_line: str, - backtrace_state: bool, - platform_process_stacktrace: Any | None, -) -> bool: - """Run the stack-trace decoder for a single log line. +class _LogLineProcessor: + """Feeds incoming log lines to the stack-trace decoder. - on_log runs inside an asyncio protocol callback; if an exception - escapes, the loop tears the transport down with "Fatal error: - protocol.data_received() call failed." and ReconnectLogic - immediately reconnects, the device replays the same crash trace, - and we loop forever. Stack-trace decoding requires a populated - build dir for the device, which may not exist (e.g. flashed from - another machine); log and continue instead of killing the - connection. + Two responsibilities beyond just calling the decoder: + 1. Catch EsphomeError. on_log runs inside an asyncio protocol + callback; if an exception escapes, the loop tears the transport + down with "Fatal error: protocol.data_received() call failed." + and ReconnectLogic immediately reconnects, the device replays + the same crash trace, and we loop forever. + 2. Disable decoding after the first failure. _decode_pc shells out + to PlatformIO via _run_idedata, which is expensive; a single + crash dump can contain many PC/BT lines and we don't want to + retry the failing subprocess for each one. """ - try: - if platform_process_stacktrace: - return platform_process_stacktrace(config, raw_line, backtrace_state) - return process_stacktrace(config, raw_line, backtrace_state=backtrace_state) - except EsphomeError as exc: - _LOGGER.debug("Stack-trace decoding failed: %s", exc) - return False + + def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None: + self._config = config + self._platform_handler = platform_handler + self._decode_enabled = True + self.backtrace_state = False + + def process_line(self, raw_line: str) -> None: + if not self._decode_enabled: + return + try: + if self._platform_handler is not None: + self.backtrace_state = self._platform_handler( + self._config, raw_line, self.backtrace_state + ) + else: + self.backtrace_state = process_stacktrace( + self._config, raw_line, backtrace_state=self.backtrace_state + ) + except EsphomeError as exc: + self._decode_enabled = False + self.backtrace_state = False + _LOGGER.warning( + "Crash trace decoding unavailable (%s). Run " + "'esphome compile' for this device to enable PC decoding.", + exc, + ) async def async_run_logs( @@ -87,7 +104,6 @@ async def async_run_logs( addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard - backtrace_state = False # Try platform-specific stacktrace handler first, fall back to generic platform_process_stacktrace = None @@ -97,9 +113,10 @@ async def async_run_logs( except (AttributeError, ImportError): pass + processor = _LogLineProcessor(config, platform_process_stacktrace) + def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" - nonlocal backtrace_state time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") @@ -110,9 +127,7 @@ async def async_run_logs( for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) for raw_line in text.splitlines(): - backtrace_state = _process_stacktrace_line( - config, raw_line, backtrace_state, platform_process_stacktrace - ) + processor.process_line(raw_line) # Safe to fall back to plaintext here only for this diagnostics use # case: the stream is one-way from device to client, and this code diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py index 9f038b093d..1e16c5ebbc 100644 --- a/tests/unit_tests/components/api/test_client.py +++ b/tests/unit_tests/components/api/test_client.py @@ -8,7 +8,7 @@ from esphome.components.api import client as api_client from esphome.core import EsphomeError -def test_process_stacktrace_line_swallows_esphome_error() -> None: +def test_decoder_swallows_esphome_error() -> None: """A failing stack-trace decode must not propagate. on_log runs inside an asyncio protocol callback; if EsphomeError @@ -18,50 +18,68 @@ def test_process_stacktrace_line_swallows_esphome_error() -> None: reconnect. """ config = {"esphome": {"name": "test"}} + processor = api_client._LogLineProcessor(config, None) with patch.object( api_client, "process_stacktrace", side_effect=EsphomeError("no idedata") ) as mock_process: - result = api_client._process_stacktrace_line( - config, "PC: 0x4010496e", True, None - ) + processor.process_line("PC: 0x4010496e") assert mock_process.called - assert result is False + assert processor.backtrace_state is False -def test_process_stacktrace_line_swallows_platform_handler_error() -> None: +def test_decoder_swallows_platform_handler_error() -> None: """The same protection must apply to the platform-specific handler.""" config = {"esphome": {"name": "test"}} def platform_handler(_config, _line, _state): raise EsphomeError("no idedata") - result = api_client._process_stacktrace_line( - config, "PC: 0x4010496e", True, platform_handler - ) + processor = api_client._LogLineProcessor(config, platform_handler) + processor.process_line("PC: 0x4010496e") - assert result is False + assert processor.backtrace_state is False -def test_process_stacktrace_line_returns_handler_result() -> None: - """When decoding succeeds, the handler's result is returned unchanged.""" +def test_decoder_short_circuits_after_failure() -> None: + """After one failure, subsequent lines must not retry the decoder. + + _decode_pc shells out to PlatformIO; a crash dump can contain many + PC/BT lines and retrying the failing subprocess for each one would + stall log streaming. + """ config = {"esphome": {"name": "test"}} + processor = api_client._LogLineProcessor(config, None) with patch.object( - api_client, "process_stacktrace", return_value=True + api_client, "process_stacktrace", side_effect=EsphomeError("no idedata") ) as mock_process: - result = api_client._process_stacktrace_line( - config, "PC: 0x4010496e", False, None - ) + processor.process_line("PC: 0x4010496e") + processor.process_line("BT0: 0x4010496e") + processor.process_line("BT1: 0x401049aa") - mock_process.assert_called_once_with( - config, "PC: 0x4010496e", backtrace_state=False - ) - assert result is True + assert mock_process.call_count == 1 -def test_process_stacktrace_line_uses_platform_handler_when_provided() -> None: +def test_decoder_threads_backtrace_state() -> None: + """When decoding succeeds, backtrace_state is threaded across calls.""" + config = {"esphome": {"name": "test"}} + processor = api_client._LogLineProcessor(config, None) + + with patch.object( + api_client, "process_stacktrace", side_effect=[True, False] + ) as mock_process: + processor.process_line(">>>stack>>>") + assert processor.backtrace_state is True + processor.process_line("<< None: """The platform handler is preferred over the generic one.""" config = {"esphome": {"name": "test"}} calls: list[tuple[object, str, bool]] = [] @@ -70,11 +88,11 @@ def test_process_stacktrace_line_uses_platform_handler_when_provided() -> None: calls.append((cfg, line, state)) return True + processor = api_client._LogLineProcessor(config, platform_handler) + with patch.object(api_client, "process_stacktrace") as mock_generic: - result = api_client._process_stacktrace_line( - config, "BT0: 0x4010496e", False, platform_handler - ) + processor.process_line("BT0: 0x4010496e") assert calls == [(config, "BT0: 0x4010496e", False)] assert mock_generic.called is False - assert result is True + assert processor.backtrace_state is True From c05e66669e30eb5d5933f1bc8d9f692a1d477da9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 12:09:00 -0500 Subject: [PATCH 0996/2214] [api] Tidy decoder warning when EsphomeError has no message _run_idedata raises EsphomeError() with no message, so the warning shows 'unavailable ()' which looks like a bug. Use a generic fallback explanation when str(exc) is empty. --- esphome/components/api/client.py | 9 ++++++--- tests/unit_tests/components/api/test_client.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 9b8b42ab31..d5214ccbf6 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -68,10 +68,13 @@ class _LogLineProcessor: except EsphomeError as exc: self._decode_enabled = False self.backtrace_state = False + # _run_idedata raises EsphomeError with no message; fall back + # to a generic explanation when str(exc) is empty. + detail = str(exc) or "build artifacts not found locally" _LOGGER.warning( - "Crash trace decoding unavailable (%s). Run " - "'esphome compile' for this device to enable PC decoding.", - exc, + "Crash trace decoding unavailable: %s. " + "Run 'esphome compile' for this device to enable PC decoding.", + detail, ) diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py index 1e16c5ebbc..3970d1ce8b 100644 --- a/tests/unit_tests/components/api/test_client.py +++ b/tests/unit_tests/components/api/test_client.py @@ -42,6 +42,21 @@ def test_decoder_swallows_platform_handler_error() -> None: assert processor.backtrace_state is False +def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: + """_run_idedata raises EsphomeError with no message; the warning + must show a useful explanation rather than empty parens. + """ + config = {"esphome": {"name": "test"}} + processor = api_client._LogLineProcessor(config, None) + + with patch.object(api_client, "process_stacktrace", side_effect=EsphomeError()): + processor.process_line("PC: 0x4010496e") + + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] + assert any("build artifacts not found locally" in m for m in warnings) + assert not any("()" in m for m in warnings) + + def test_decoder_short_circuits_after_failure() -> None: """After one failure, subsequent lines must not retry the decoder. From cf01be6250aaa5b2d6bacdcbdc2c95749f865e84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 12:25:37 -0500 Subject: [PATCH 0997/2214] [esp8266] Lower WDT_FEED_INTERVAL_MS to 100 ms Restore tighter feed cadence on ESP8266 to match the smaller WDT budgets on this platform. ESP8266 soft WDT is ~1.6 s and HW WDT is ~6 s; a single long iteration (mDNS reply, wifi scan, OTA verify, lwIP TCP retransmit) can easily push the loop past a few hundred ms. The 300 ms shared default left only ~1.3 s of soft-WDT headroom; 100 ms restores ~16x margin to the soft WDT (matching pre-#15846 behavior in spirit) without per-iteration arch_feed_wdt() cost. Reports of Hardware WDT - Level1Int crashes on ESP8266 line up with this margin being too tight under the new main-loop cadence. --- esphome/core/application.h | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 5baf570e62..369c970d46 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -233,11 +233,10 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT (user-configurable): ~5x <-- auto-scaled below - /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change - /// must keep comfortable margin here - /// - ESP8266 HW WDT (~6 s): ~20x - /// - BK72xx HW WDT (10 s): ~5x <-- platform override below + /// - ESP32 task WDT (user-configurable): ~5x <-- auto-scaled below + /// - ESP8266 soft WDT (~1.6 s): ~16x <-- 100 ms feed (see USE_ESP8266 below) + /// - ESP8266 HW WDT (~6 s): ~60x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below #ifdef USE_BK72XX // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: @@ -257,6 +256,15 @@ class Application { static_assert(CONFIG_ESP_TASK_WDT_TIMEOUT_S >= 5, "CONFIG_ESP_TASK_WDT_TIMEOUT_S must be at least 5s for a safe WDT feed interval"); static constexpr uint32_t WDT_FEED_INTERVAL_MS = (CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000U) / 5U; +#elif defined(USE_ESP8266) + // ESP8266 needs a tighter feed cadence than the other targets: the soft WDT + // is ~1.6 s and the HW WDT ~6 s, but a single long iteration (mDNS reply, + // wifi scan, OTA verify, lwIP TCP retransmit storm) can push the loop past + // a few hundred ms without giving the SDK a chance to feed. 100 ms keeps a + // ~16x margin to the soft WDT and ~60x to the HW WDT while still avoiding + // the per-iteration arch_feed_wdt() cost (this is the rate limit; component + // loops and scheduler items still feed after every op). + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 100; #else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; #endif From 63c9b63fcf19b36170158f44fcb3e3e496c4d510 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 1 May 2026 19:42:39 +0200 Subject: [PATCH 0998/2214] Apply suggestions --- esphome/components/esphome/ota/ota_esphome.cpp | 6 ++---- esphome/components/ota/ota_backend_esp_idf.h | 2 -- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 4261b4c91e..84d6fa3c0e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -380,10 +380,12 @@ void ESPHomeOTAComponent::handle_data_() { (static_cast(buf[2]) << 8) | buf[3]; ESP_LOGV(TAG, "Size is %u bytes", ota_size); +#ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } +#endif // Now that we've passed authentication and are actually // starting the update, set the warning status and notify @@ -398,10 +400,6 @@ void ESPHomeOTAComponent::handle_data_() { #ifdef USE_OTA_PARTITIONS error_code = this->backend_->begin(ota_size, ota_type); #else - if (ota_type != ota::OTA_TYPE_UPDATE_APP) { - error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } // This will block for a few seconds as it locks flash error_code = this->backend_->begin(ota_size); #endif diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 08d4ac2abe..ed1f2496d1 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -14,9 +14,7 @@ namespace esphome::ota { // ESP_PARTITION_TABLE_MAX_LEN (0xC00) so the entire partition table fits before verification. // Kept separate from any OTA chunk-transfer buffer to avoid coupling unrelated sizes. static constexpr size_t PARTITION_TABLE_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 -#endif -#ifdef USE_OTA_PARTITIONS void get_running_app_position(uint32_t &offset, size_t &size); #endif From 81d147ff9e55bb164821d516bb8ffbabca7fd7a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 14:31:56 -0500 Subject: [PATCH 0999/2214] [esp32] Drop printf wrap on IDF 6.0+ (picolibc no longer needs it) (#16189) --- esphome/components/esp32/__init__.py | 12 +++- esphome/components/esp32/printf_stubs.cpp | 85 ++++++----------------- 2 files changed, 31 insertions(+), 66 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index bd299d71f0..9a9ee8fb08 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1753,7 +1753,17 @@ async def to_code(config): # Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r # (~11 KB). See printf_stubs.cpp for implementation. - if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF]: + # + # The wrap is only beneficial against newlib. Picolibc's tinystdio + # implements vsnprintf by building a string-output FILE and calling + # vfprintf, so vfprintf is unconditionally linked in by any caller + # of snprintf/vsnprintf — effectively every build — and the wrap + # saves nothing while costing ~170 B of shim. IDF 5.x defaults to + # newlib on every variant; IDF 6.0+ switches to picolibc on every + # variant. + if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF] or idf_version() >= cv.Version( + 6, 0, 0 + ): cg.add_define("USE_FULL_PRINTF") else: for symbol in ("vprintf", "printf", "fprintf", "vfprintf"): diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index 908b4023ea..489c503942 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -1,91 +1,48 @@ /* - * Linker wrap stubs for FILE*-based printf functions. + * Linker wrap stubs for FILE*-based printf functions (newlib only). * * ESP-IDF SDK components (gpio driver, ringbuf, log_write) reference - * fprintf(), printf(), vprintf(), and vfprintf() which pull in the full - * printf implementation (~11 KB on newlib's _vfprintf_r, ~2.8 KB on - * picolibc's vfprintf). This is a separate implementation from the one - * used by snprintf/vsnprintf that handles FILE* stream I/O with buffering - * and locking. + * fprintf(), printf(), vprintf(), and vfprintf(), which on newlib pull + * in _vfprintf_r (~11 KB) — a separate implementation from the one used + * by snprintf/vsnprintf that handles FILE* stream I/O with buffering. * * ESPHome replaces the ESP-IDF log handler via esp_log_set_vprintf_(), * so the SDK's vprintf() path is dead code at runtime. The fprintf() * and printf() calls in SDK components are only in debug/assert paths * (gpio_dump_io_configuration, ringbuf diagnostics) that are either * GC'd or never called. Crash backtraces and panic output are - * unaffected; they use esp_rom_printf() which is a ROM function - * and does not go through libc. + * unaffected; they use esp_rom_printf() which is a ROM function and + * does not go through libc. * - * On picolibc (default for IDF >= 5 on RISC-V, IDF >= 6 everywhere) we - * route output through a stack-allocated cookie FILE that forwards each - * byte to the real target stream via fputc(). Picolibc's tinystdio - * vfprintf walks the FILE::put callback one character at a time, so this - * costs ~32 bytes of stack for the cookie struct vs. a 512-byte format - * buffer. The buffered path overflows the loopTask stack on IDF 6. + * This wrap is newlib-only. On picolibc, vsnprintf is implemented as + * vfprintf into a string-output FILE, so vfprintf is unconditionally + * linked in by any caller of snprintf/vsnprintf and the wrap can never + * elide it — it just adds shim cost. Codegen forces USE_FULL_PRINTF + * on picolibc builds (IDF 6.0+ on all variants) so this file compiles + * to nothing there; the #error below catches a desynchronised gate. * - * On newlib (IDF <= 5 on Xtensa) we keep the original snprintf-then-fwrite - * path because that loopTask stack budget has plenty of headroom for the - * 512-byte buffer; the picolibc-only crash above does not affect it. + * Saves ~11 KB of flash on newlib. * - * Saves ~11 KB of flash on newlib, ~2.8 KB on picolibc. - * - * To disable these wraps, set enable_full_printf: true in the esp32 - * advanced config section. + * To disable this wrap on newlib, set enable_full_printf: true in the + * esp32 advanced config section. */ #if defined(USE_ESP_IDF) && !defined(USE_FULL_PRINTF) + +#ifdef __PICOLIBC__ +#error "printf wrap is net-negative on picolibc; codegen should set USE_FULL_PRINTF" +#endif + #include #include -#ifndef __PICOLIBC__ #include "esp_system.h" -#endif namespace esphome::esp32 {} // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) extern "C" { -#ifdef __PICOLIBC__ - -#include -#include - -extern int __real_vfprintf(FILE *stream, const char *fmt, va_list ap); - -namespace { - -struct CookieFile { - FILE base; - FILE *target; -}; - -// cookie_put() recovers CookieFile* from FILE* via reinterpret_cast, which is -// only well-defined when FILE is the first member at offset 0 and CookieFile -// is standard-layout. -static_assert(offsetof(CookieFile, base) == 0, "FILE must be the first member of CookieFile"); -static_assert(std::is_standard_layout::value, "CookieFile must be standard-layout"); - -int cookie_put(char c, FILE *stream) { - auto *cookie = reinterpret_cast(stream); - return fputc(static_cast(c), cookie->target); -} - -const FILE COOKIE_FILE_TEMPLATE = FDEV_SETUP_STREAM(cookie_put, nullptr, nullptr, _FDEV_SETUP_WRITE); - -} // namespace - -int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { - CookieFile cookie; - cookie.base = COOKIE_FILE_TEMPLATE; - cookie.target = stream; - return __real_vfprintf(&cookie.base, fmt, ap); -} - -int __wrap_vprintf(const char *fmt, va_list ap) { return __wrap_vfprintf(stdout, fmt, ap); } - -#else // !__PICOLIBC__ - static constexpr size_t PRINTF_BUFFER_SIZE = 512; // These stubs are essentially dead code at runtime — ESPHome replaces the @@ -117,8 +74,6 @@ int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); } -#endif // __PICOLIBC__ - int __wrap_printf(const char *fmt, ...) { va_list ap; va_start(ap, fmt); From 5e9db1c8c686d86bb6165bed56efe3f76f028740 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 21:46:29 -0500 Subject: [PATCH 1000/2214] Bump github/codeql-action from 4.35.2 to 4.35.3 (#16201) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 246a865693..5429434a7f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: category: "/language:${{matrix.language}}" From 8046ff7e1e9a35094c2cb3e6fe96c1935bebb0ea Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Sun, 3 May 2026 10:40:09 +0200 Subject: [PATCH 1001/2214] [nextion] TFT upload no longer fails when the display sends a split `0x08` ack (#16205) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../nextion/nextion_upload_arduino.cpp | 27 +++++++++++++++++++ .../nextion/nextion_upload_esp32.cpp | 27 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 399f217a19..41379c2345 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -88,6 +88,33 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { this->write_array(buffer, buffer_size); App.feed_wdt(); this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); + + // Some Nextion firmware variants (notably bootloader/recovery mode on panels + // with no installed TFT) emit the 5-byte 0x08+position fast-mode ack with a + // multi-second gap between the leading 0x08 byte and the 4 trailing position + // bytes. recv_ret_string_ returns after the first byte; manually drain the + // trailing bytes from the UART before continuing. + if (!recv_string.empty() && recv_string[0] == 0x08 && recv_string.size() < 5) { + const uint32_t deadline = millis() + NEXTION_UPLOAD_ACK_TIMEOUT_MS; + while (recv_string.size() < 5 && millis() < deadline) { + if (this->available()) { + uint8_t b = 0; + if (this->read_byte(&b)) { + recv_string.push_back(static_cast(b)); + } + } else { + delay(5); // NOLINT + App.feed_wdt(); + } + } + if (recv_string.size() < 5) { + ESP_LOGE(TAG, "Truncated 0x08 response: got %zu bytes within %" PRIu32 "ms", recv_string.size(), + NEXTION_UPLOAD_ACK_TIMEOUT_MS); + allocator.deallocate(buffer, 4096); + buffer = nullptr; + return -1; + } + } this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index db4558e2fe..cd8feab84f 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -104,6 +104,33 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r this->write_array(buffer, buffer_size); App.feed_wdt(); this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); + + // Some Nextion firmware variants (notably bootloader/recovery mode on panels + // with no installed TFT) emit the 5-byte 0x08+position fast-mode ack with a + // multi-second gap between the leading 0x08 byte and the 4 trailing position + // bytes. recv_ret_string_ returns after the first byte; manually drain the + // trailing bytes from the UART before continuing. + if (!recv_string.empty() && recv_string[0] == 0x08 && recv_string.size() < 5) { + const uint32_t deadline = millis() + NEXTION_UPLOAD_ACK_TIMEOUT_MS; + while (recv_string.size() < 5 && millis() < deadline) { + if (this->available()) { + uint8_t b = 0; + if (this->read_byte(&b)) { + recv_string.push_back(static_cast(b)); + } + } else { + vTaskDelay(pdMS_TO_TICKS(5)); // NOLINT + App.feed_wdt(); + } + } + if (recv_string.size() < 5) { + ESP_LOGE(TAG, "Truncated 0x08 response: got %zu bytes within %" PRIu32 "ms", recv_string.size(), + NEXTION_UPLOAD_ACK_TIMEOUT_MS); + allocator.deallocate(buffer, 4096); + buffer = nullptr; + return -1; + } + } this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; #ifdef USE_PSRAM From 51c5500809abbf47ac718ff1b2213f79c8d53ca7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:15:28 -0500 Subject: [PATCH 1002/2214] [ota] Validate partition-table binary host-side before OTA Read the resolved partition-table file in upload_program before opening a network connection. Reject anything that isn't 0xC00 bytes, doesn't start with ESP_PARTITION_MAGIC, or is missing the MD5 checksum entry, so mistakes (wrong file, swapped --file path) surface as a local error instead of a post-handshake OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY. Includes unit tests covering size, magic, md5-presence, missing-file, and end-to-end upload_program rejection, plus three real partition tables checked in as fixtures (ESPHome build, ESP-IDF Hello-world, esphome_dashboard prebuilt). --- esphome/__main__.py | 56 +++++++++ .../partition_tables/esp_idf_hello_world.bin | Bin 0 -> 3072 bytes .../esphome_dashboard_firmware.bin | Bin 0 -> 3072 bytes .../partition_tables/esphome_default.bin | Bin 0 -> 3072 bytes tests/unit_tests/test_main.py | 110 +++++++++++++++++- 5 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/fixtures/partition_tables/esp_idf_hello_world.bin create mode 100644 tests/unit_tests/fixtures/partition_tables/esphome_dashboard_firmware.bin create mode 100644 tests/unit_tests/fixtures/partition_tables/esphome_default.bin diff --git a/esphome/__main__.py b/esphome/__main__.py index 9292e98043..c88efd6750 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1151,9 +1151,65 @@ def upload_program( if getattr(args, "file", None) is not None: binary = Path(args.file) + if ota_type == espota2.OTA_TYPE_UPDATE_PARTITION_TABLE: + _validate_partition_table_binary(binary) + return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) +# 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 +# trailing checksum entry. Padding past the last entry is 0xFF. The full table is +# exactly ESP_PARTITION_TABLE_MAX_LEN bytes. +_PARTITION_TABLE_MAX_LEN = 0xC00 +_ESP_PARTITION_MAGIC = 0x50AA +_ESP_PARTITION_MAGIC_MD5 = 0xEBEB + + +def _validate_partition_table_binary(binary: Path) -> None: + """Validate that ``binary`` looks like an ESP32 partition table image. + + Catches common mistakes (wrong file, truncated build output, swapped --file path) + before opening a network connection so the failure mode is a clear local error + instead of a post-handshake device rejection. + """ + try: + data = binary.read_bytes() + except OSError as err: + raise EsphomeError( + f"Cannot read partition table file '{binary}': {err}" + ) from err + + if len(data) != _PARTITION_TABLE_MAX_LEN: + raise EsphomeError( + f"Partition table file '{binary}' has wrong size: expected " + f"{_PARTITION_TABLE_MAX_LEN} bytes, got {len(data)}. " + "Pass the partition table image (e.g. partitions.bin / partition-table.bin), " + "not the firmware image." + ) + + first_magic = data[0] | (data[1] << 8) + if first_magic != _ESP_PARTITION_MAGIC: + raise EsphomeError( + f"Partition table file '{binary}' does not start with the expected " + f"partition magic 0x{_ESP_PARTITION_MAGIC:04X} (got 0x{first_magic:04X}). " + "This file does not look like an ESP32 partition table." + ) + + # The MD5 checksum entry is required: without it the device-side + # esp_partition_table_verify will accept the table but the bootloader will + # refuse to boot from it. Scan the 32-byte entries for the MD5 magic. + if not any( + (data[off] | (data[off + 1] << 8)) == _ESP_PARTITION_MAGIC_MD5 + for off in range(0, _PARTITION_TABLE_MAX_LEN, 32) + ): + raise EsphomeError( + f"Partition table file '{binary}' is missing the MD5 checksum entry. " + "Regenerate the partition table with gen_esp32part.py or rebuild the project." + ) + + def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) diff --git a/tests/unit_tests/fixtures/partition_tables/esp_idf_hello_world.bin b/tests/unit_tests/fixtures/partition_tables/esp_idf_hello_world.bin new file mode 100644 index 0000000000000000000000000000000000000000..b8fa03b4b3536b1f4d0def4c1fed550e8fc2acc7 GIT binary patch literal 3072 zcmZ1#z{tcffq{V`fq@~fte62EtO{UcWca|qz#zcDP>@j>pP83gf~;m$0Eov3R*;sM zT#{c@2@-(g*RTJhfG=zPT`j`AV@pi8>6C7ps)8ap${7uT(GVC7fzc2c4S~@R7!85Z O5Eu=C(GZ|%2mk;_=9q5) literal 0 HcmV?d00001 diff --git a/tests/unit_tests/fixtures/partition_tables/esphome_dashboard_firmware.bin b/tests/unit_tests/fixtures/partition_tables/esphome_dashboard_firmware.bin new file mode 100644 index 0000000000000000000000000000000000000000..e648fa32709414410fbd9be592c84659b7ebb3ad GIT binary patch literal 3072 zcmZ1#z{tcffq{V`fPo>ete62EtO{UcV0gg5z@WgukYAFRl30?6qGVM7g8%~qBLf42 zBtv3BfdPsn0|UdV00u#@W{A8Yraa?J1_nz8kSVFD1x5L}s47+kFg7s=STZntU|=XN z$V^K^bK>jQ|51SEf~h|;7*|G0zA!t#)$$w%isUF~Gz3ONU^E0qLtr!nMnhmU1V%$( KGz3O?2mk;I)@j>pP83gf+`P^VPs%n zkYPwHC@?^l1F=^HFbFa*$in0eL1M^wRRALs1A`?40|PrlURg1+6qx<`^?wv_Dy30A wx{o#F{@PPlP82`;j3PP884ZEa5Eu=C(GVC7fzc2c4S~@R7!85Z5WpJ(0C1>a2LJ#7 literal 0 HcmV?d00001 diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 5f80a4217b..e5564b6933 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _get_configured_xtal_freq, _make_crystal_freq_callback, _resolve_network_devices, + _validate_partition_table_binary, choose_upload_log_host, command_analyze_memory, command_bundle, @@ -1630,6 +1631,21 @@ def test_upload_program_ota_with_file_arg( ) +_PARTITION_TABLE_LEN = 0xC00 + + +def _make_partition_table_bytes() -> bytes: + """Build a minimal partition table image accepted by _validate_partition_table_binary.""" + table = bytearray(b"\xff" * _PARTITION_TABLE_LEN) + # First entry: ESP_PARTITION_MAGIC (0x50AA) little-endian -> bytes 0xAA, 0x50. + table[0] = 0xAA + table[1] = 0x50 + # MD5 checksum entry at offset 32: ESP_PARTITION_MAGIC_MD5 (0xEBEB) little-endian. + table[32] = 0xEB + table[33] = 0xEB + return bytes(table) + + def test_upload_program_ota_partition_table_with_file_arg( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -1641,6 +1657,9 @@ def test_upload_program_ota_partition_table_with_file_arg( mock_get_port_type.return_value = "NETWORK" mock_run_ota.return_value = (0, "192.168.1.100") + partition_file = tmp_path / "partitions.bin" + partition_file.write_bytes(_make_partition_table_bytes()) + config = { CONF_OTA: [ { @@ -1650,7 +1669,7 @@ def test_upload_program_ota_partition_table_with_file_arg( } ] } - args = MockArgs(file="partitions.bin", partition_table=True) + args = MockArgs(file=str(partition_file), partition_table=True) devices = ["192.168.1.100"] exit_code, host = upload_program(config, args, devices) @@ -1661,7 +1680,7 @@ def test_upload_program_ota_partition_table_with_file_arg( ["192.168.1.100"], 3232, None, - Path("partitions.bin"), + partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, ) @@ -1686,6 +1705,93 @@ def test_upload_program_serial_partition_table( upload_program(config, args, devices) +def test_validate_partition_table_binary_accepts_valid(tmp_path: Path) -> None: + f = tmp_path / "partitions.bin" + f.write_bytes(_make_partition_table_bytes()) + _validate_partition_table_binary(f) + + +_PARTITION_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "partition_tables" + + +@pytest.mark.parametrize( + "fixture", + [ + # Stock ESP-IDF gen_esp32part.py output for an ESPHome build. + "esphome_default.bin", + # ESP-IDF Hello-world example partition table (vendored from espressif/esp-serial-flasher). + "esp_idf_hello_world.bin", + # Partition table shipped with esphome_dashboard's prebuilt firmware. + "esphome_dashboard_firmware.bin", + ], +) +def test_validate_partition_table_binary_accepts_real_binaries(fixture: str) -> None: + """Real-world partition-table binaries from ESP-IDF / ESPHome tooling pass validation.""" + _validate_partition_table_binary(_PARTITION_FIXTURE_DIR / fixture) + + +def test_validate_partition_table_binary_rejects_wrong_size(tmp_path: Path) -> None: + f = tmp_path / "partitions.bin" + f.write_bytes(b"\xaa\x50" + b"\xff" * 100) + with pytest.raises(EsphomeError, match="wrong size"): + _validate_partition_table_binary(f) + + +def test_validate_partition_table_binary_rejects_wrong_magic(tmp_path: Path) -> None: + data = bytearray(_make_partition_table_bytes()) + data[0] = 0x00 + data[1] = 0x00 + f = tmp_path / "partitions.bin" + f.write_bytes(bytes(data)) + with pytest.raises(EsphomeError, match="partition magic"): + _validate_partition_table_binary(f) + + +def test_validate_partition_table_binary_rejects_missing_md5(tmp_path: Path) -> None: + data = bytearray(_make_partition_table_bytes()) + data[32] = 0xFF + data[33] = 0xFF + f = tmp_path / "partitions.bin" + f.write_bytes(bytes(data)) + with pytest.raises(EsphomeError, match="missing the MD5 checksum entry"): + _validate_partition_table_binary(f) + + +def test_validate_partition_table_binary_missing_file(tmp_path: Path) -> None: + with pytest.raises(EsphomeError, match="Cannot read partition table file"): + _validate_partition_table_binary(tmp_path / "does-not-exist.bin") + + +def test_upload_program_ota_partition_table_invalid_file( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--partition-table must fail before calling run_ota when the file is not a partition table.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + + bad_file = tmp_path / "firmware.bin" + bad_file.write_bytes(b"\x00" * 4096) + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + "allow_partition_access": True, + } + ] + } + args = MockArgs(file=str(bad_file), partition_table=True) + devices = ["192.168.1.100"] + + with pytest.raises(EsphomeError, match="wrong size"): + upload_program(config, args, devices) + mock_run_ota.assert_not_called() + + def test_upload_program_ota_partition_table_without_allow_flag( mock_run_ota: Mock, mock_get_port_type: Mock, From b788529ad7e5636b5d95d74e339cd8adc2ed7d65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:17:40 -0500 Subject: [PATCH 1003/2214] [ota] Polish partition-table review feedback Split the combined "missing app, otadata, or nvs" verify failure into three separate ESP_LOGE messages so users can see which check failed, trim trailing spaces from the (err=0x%X) log strings, and document why the partition-table espota2 test mocks SERVER_FEATURE_SUPPORTS_COMPRESSION (intentional protocol-path coverage; the real IDFOTABackend never sets it). --- .../components/ota/ota_backend_esp_idf.cpp | 30 ++++++++++++------- tests/unit_tests/test_espota2.py | 11 +++++-- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 5843a010f3..053887a7db 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -208,7 +208,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -218,13 +218,13 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, reinterpret_cast(&existing_partition_table), &partition_table_map); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); esp_partition_munmap(partition_table_map); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -233,7 +233,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN bytes of data err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } // Check for missing checksum @@ -303,8 +303,16 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - if (app_partitions_found < 2 || !otadata_partition_found || !nvs_partition_found) { - ESP_LOGE(TAG, "New partition table is missing the required app, otadata or nvs partitions"); + if (app_partitions_found < 2) { + ESP_LOGE(TAG, "New partition table needs at least 2 app partitions, found %d", app_partitions_found); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (!otadata_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required otadata partition"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (!nvs_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required nvs partition"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (otadata_overlap) { @@ -337,7 +345,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { err = esp_partition_copy(app_copy_target_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } } @@ -352,7 +360,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (err != ESP_OK) { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; - ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); @@ -361,13 +369,13 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // partial-write failure path self-contained. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; - ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } // esp_partition_unload_all() invalidates every cached partition entry, including the externally @@ -388,7 +396,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } return OTA_RESPONSE_OK; diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 28e30bbe77..2cad1d2ec8 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -843,7 +843,14 @@ def test_perform_ota_extended_protocol_app( def test_perform_ota_successful_partition_table( mock_socket: Mock, mock_file: io.BytesIO ) -> None: - """Test OTA partition table update.""" + """Test OTA partition table update. + + The mocked server advertises both COMPRESSION and PARTITION_ACCESS to exercise + the full extended-protocol negotiation path. Real IDFOTABackend devices return + ``supports_compression() == false`` and never set the COMPRESSION flag for a + partition-table OTA; the flag here is intentional protocol-coverage, not a + description of on-device behaviour. + """ recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response bytes([espota2.OTA_VERSION_2_0]), # Version number @@ -853,7 +860,7 @@ def test_perform_ota_successful_partition_table( espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS ] - ), # Device feature flags + ), # Device feature flags (compression flag is unrealistic; see docstring) 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 From 4490bbf23a87139cf13c07c977583a8ec12f1450 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:23:08 -0500 Subject: [PATCH 1004/2214] [ota] Extract partition-table validation into validate_new_partition_table_ Splits the non-destructive validation phase out of update_partition_table() into a dedicated method. update_partition_table() now reads top-down as "check buffer state, find running app, validate the new table + plan the target slot, then commit", with the destructive write isolated to the final block. The chosen slot and optional copy-source partition are returned via a small PartitionTablePlan struct so the caller no longer juggles the two candidate-index variables. Refactor only; behaviour and error semantics are unchanged. --- .../components/ota/ota_backend_esp_idf.cpp | 117 +++++++++++------- esphome/components/ota/ota_backend_esp_idf.h | 15 ++- 2 files changed, 83 insertions(+), 49 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 053887a7db..75560cbe0e 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -184,26 +184,14 @@ static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min return found; } -OTAResponseTypes IDFOTABackend::update_partition_table() { - int num_partitions; - if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { - ESP_LOGE(TAG, "Not enough data received"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Get running app partition and used size. A zero size means we couldn't determine the running - // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition - // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute - // overlap or copy bounds, so fail before any flash operation. - uint32_t running_app_offset; - size_t running_app_size; - get_running_app_position(running_app_offset, running_app_size); - if (running_app_size == 0) { - ESP_LOGE(TAG, "Failed to determine running app position"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Get partition table partition +// Validate the new partition table image staged in ``buf_`` and pick the slot the running app +// will boot from after the update. Performs all non-destructive checks; the destructive write +// is in ``update_partition_table()``. Side-effect: registers the live partition-table region +// as ``partition_table_part_`` so the caller can write to it; ``abort()`` releases it on error. +OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, + PartitionTablePlan &plan) { + // Register the live primary partition table as an external partition so we can mmap it for + // verification and later issue esp_ota_begin/esp_ota_write against it. esp_err_t err = esp_partition_register_external( nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); @@ -213,6 +201,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Verify existing partition table + int num_partitions = 0; const esp_partition_info_t *existing_partition_table = nullptr; esp_partition_mmap_handle_t partition_table_map; err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, @@ -228,20 +217,22 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Verify new partition table + // Verify new partition table. esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN + // bytes; ``buf_`` is sized to that exactly. const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - // esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN bytes of data err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Check for missing checksum - // esp_partition_table_verify does not fail in this case and the ESP would not boot after the update + + // Check for missing checksum entry. esp_partition_table_verify does not fail in this case and + // the ESP would not boot after the update. bool checksum_found = false; for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { checksum_found = true; + break; } } if (!checksum_found) { @@ -249,56 +240,49 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Check if the required app and otadata partitions exist in the new partition table - // Check which app slot to boot from in the new partition table + // Walk the new table once, populating: the chosen target app slot, presence of otadata/nvs, + // and otadata-vs-running-app overlap. Selection policy when multiple app slots can host the + // running app: pick the FIRST eligible slot in table order. The no-copy path (offsets already + // match) is preferred over the copy path; within each path we lock in the first match and stop + // searching. This keeps the choice deterministic and table-ordering-stable. int app_partitions_found = 0; int new_app_part_index = -1; int new_app_part_index_with_copy = -1; - const esp_partition_t *app_copy_target_part = nullptr; + const esp_partition_t *app_copy_source_part = nullptr; bool otadata_partition_found = false; bool otadata_overlap = false; bool nvs_partition_found = false; - // Selection policy when multiple app slots in the new partition table can host the running app: - // pick the FIRST eligible slot in table order. The no-copy path (offsets already match) is - // preferred over the copy path; within each path we lock in the first match and stop searching. - // This keeps the choice deterministic and table-ordering-stable instead of "last writer wins". - for (int i = 0; i < num_partitions; i++) { // Iterate over new partition table + for (int i = 0; i < num_partitions; i++) { const esp_partition_info_t *new_part = &new_partition_table[i]; if (new_part->type == ESP_PARTITION_TYPE_APP) { - // Found an app partition in the new partition table app_partitions_found++; if (new_part->pos.size >= running_app_size) { - // Running app can fit inside this partition if (new_part->pos.offset == running_app_offset) { - // This new app partition can be used for the running app without copying because the offsets are the same. - // First match wins; once locked in, the no-copy path is preferred and won't be overwritten. + // No-copy path: same offset as running app, first match wins. if (new_app_part_index == -1) { new_app_part_index = i; } } else if (new_app_part_index_with_copy == -1 && !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { - // This new app partition can be used for the running app after copying the app into it. - // Check if there is an app partition in the old partition table at the right offset - // (esp_partition_copy needs a registered source partition; first match wins for determinism). + // Copy path: needs a registered source partition in the *current* table at the new slot's offset. const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); if (p != nullptr) { - new_app_part_index_with_copy = i; // The partition index in the new partition table - app_copy_target_part = p; // The partition in the old partition table + new_app_part_index_with_copy = i; + app_copy_source_part = p; } } } } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { - // Found the otadata partition in the new partition table otadata_partition_found = true; otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && strncmp(reinterpret_cast(new_part->label), "nvs", sizeof(new_part->label)) == 0) { - // Found the nvs partition in the new partition table nvs_partition_found = true; } } } + if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; @@ -320,6 +304,41 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } + // No-copy preferred; copy path only when no-copy slot was unavailable. + if (new_app_part_index != -1) { + plan.target_app_index = new_app_part_index; + plan.copy_source_part = nullptr; + } else { + plan.target_app_index = new_app_part_index_with_copy; + plan.copy_source_part = app_copy_source_part; + } + return OTA_RESPONSE_OK; +} + +OTAResponseTypes IDFOTABackend::update_partition_table() { + if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { + ESP_LOGE(TAG, "Not enough data received"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Get running app partition and used size. A zero size means we couldn't determine the running + // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition + // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute + // overlap or copy bounds, so fail before any flash operation. + uint32_t running_app_offset; + size_t running_app_size; + get_running_app_position(running_app_offset, running_app_size); + if (running_app_size == 0) { + ESP_LOGE(TAG, "Failed to determine running app position"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + PartitionTablePlan plan; + OTAResponseTypes validate_result = this->validate_new_partition_table_(running_app_offset, running_app_size, plan); + if (validate_result != OTA_RESPONSE_OK) { + return validate_result; + } + // Past this point any failure (power loss, watchdog reset, write error after the table has been // partially erased) can leave the device unable to boot. Logged at ERROR severity so the message // is visible in default log filters. @@ -333,16 +352,19 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // underlying ESP-IDF calls take longer than expected on a given chip variant. watchdog::WatchdogManager watchdog(15000); + esp_err_t err; + const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); + // Copy the running app partition to new position if needed. // esp_ota_get_running_partition() is still valid here (we have not yet called // esp_partition_unload_all()) and returns the same partition that find_app_partition_at would // have located, without an extra iterator walk. - if (new_app_part_index == -1) { + if (plan.copy_source_part != nullptr) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, - app_copy_target_part->address, running_app_size); + plan.copy_source_part->address, running_app_size); - err = esp_partition_copy(app_copy_target_part, 0, running_app_part, 0, running_app_size); + err = esp_partition_copy(plan.copy_source_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); @@ -386,8 +408,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { this->partition_table_part_ = nullptr; // Write otadata to set the new boot partition - const esp_partition_info_t *new_part = - &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; + const esp_partition_info_t *new_part = &new_partition_table[plan.target_app_index]; const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); if (new_boot_partition == nullptr) { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index ed1f2496d1..59712945f2 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -33,6 +33,17 @@ class IDFOTABackend final { protected: #ifdef USE_OTA_PARTITIONS + // Outcome of validating an incoming partition-table image. ``target_app_index`` is the + // entry in the new table that the running app will boot from after the update; + // ``copy_source_part`` is non-null when the running app must be copied into that slot + // first (the source is the matching slot in the *current* partition table). + struct PartitionTablePlan { + int target_app_index{-1}; + const esp_partition_t *copy_source_part{nullptr}; + }; + + OTAResponseTypes validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, + PartitionTablePlan &plan); OTAResponseTypes update_partition_table(); #endif @@ -45,7 +56,9 @@ class IDFOTABackend final { #ifdef USE_OTA_PARTITIONS // Place the byte buffer first so it sits immediately after the preceding `bool md5_set_`, // eliminating the 3-byte alignment padding that an int-sized member would otherwise force. - // Remaining members are 4-byte-aligned and pack tightly after the buffer. + // Remaining members are 4-byte-aligned and pack tightly after the buffer. The backend is + // constructed on each incoming OTA connection and destroyed on cleanup_connection_(), so this + // 3 KiB is only resident during an active OTA, not permanently. uint8_t buf_[PARTITION_TABLE_BUFFER_SIZE]; size_t buf_written_{0}; size_t image_size_{0}; From 36c120fb07923dc8d33619d384f2bc6acf3d2fb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:26:03 -0500 Subject: [PATCH 1005/2214] [ota] Hoist running-app cache to file scope The running-app position cache lived as three function-local statics inside get_running_app_position(). They cannot be IDFOTABackend members (the backend is per-connection, the cache must outlive a backend that called esp_partition_unload_all() in a prior aborted partition-table OTA), but burying them inside the function made the lifetime and shared-across-connections semantics implicit. Move them to file scope with s_running_app_ prefix so the process-scoped lifetime is visible at first read, and tighten the surrounding comments. No behaviour change. --- .../components/ota/ota_backend_esp_idf.cpp | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 75560cbe0e..a6b379a42f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -423,23 +423,22 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_OK; } -void get_running_app_position(uint32_t &offset, size_t &size) { - // Gets the start address and the used length (rounded up to flash sectors) of the running app. - // - // The result is cached because esp_ota_get_running_partition() does not return valid data after - // esp_partition_unload_all() has been called during a partition-table OTA. The running app does - // not move within a boot, so the first successful query is valid for the lifetime of the process. - // - // Caching is gated by an explicit `initialized` flag (rather than checking for size == 0) so a - // failed first call (e.g., esp_ota_get_running_partition() returning nullptr after a previously - // aborted partition-table OTA already called esp_partition_unload_all()) does not poison the - // cache; the next caller will retry. Values are written into the cache atomically only after the - // full computation succeeds. - static bool initialized = false; - static uint32_t cached_offset = 0; - static size_t cached_size = 0; +// Process-scoped cache of the running app's flash position. Cannot live on IDFOTABackend +// because the backend is created/destroyed per OTA connection, while the cached values must +// survive across connections: once a previously aborted partition-table OTA has called +// esp_partition_unload_all(), esp_ota_get_running_partition() no longer returns valid data, +// so we have to remember the answer from the first successful call. The running app does not +// move within a boot, so a single capture is valid for the process lifetime. +static bool s_running_app_initialized = false; +static uint32_t s_running_app_cached_offset = 0; +static size_t s_running_app_cached_size = 0; - if (!initialized) { +void get_running_app_position(uint32_t &offset, size_t &size) { + // Returns the start address and the used length (rounded up to flash sectors) of the running app. + // The ``s_running_app_initialized`` flag (rather than ``size == 0``) gates the cache so a failed + // first call does not poison it; the next caller retries. Values are written atomically only + // after the full computation succeeds. + if (!s_running_app_initialized) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); if (running_app_part == nullptr || running_app_part->erase_size == 0) { // Cannot determine the running app right now; surface zeros without committing to the cache @@ -466,13 +465,13 @@ void get_running_app_position(uint32_t &offset, size_t &size) { pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; - cached_offset = pending_offset; - cached_size = pending_size; - initialized = true; + s_running_app_cached_offset = pending_offset; + s_running_app_cached_size = pending_size; + s_running_app_initialized = true; } - offset = cached_offset; - size = cached_size; + offset = s_running_app_cached_offset; + size = s_running_app_cached_size; } #endif From b75f5034e5f5f0fcc0c9cb9b2039a1635b0ae7fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:29:05 -0500 Subject: [PATCH 1006/2214] [ota] Address remaining Copilot comments on partition-table OTA - upload_program: allow MQTT/MQTTIP devices for --partition-table. MQTTIP gets resolved to a real IP by _resolve_network_devices(), so rejecting any non-NETWORK port_type was incorrect; only SERIAL and BOOTSEL are non-OTA upload paths. - update_partition_table: re-initialize NVS on every failure path past nvs_flash_deinit() so components that survive a failed OTA aren't left with broken NVS handles. Success path stays as-is because the device reboots immediately afterwards. Adds an MQTTIP upload test and refreshes the gate's comment. --- esphome/__main__.py | 6 ++- .../components/ota/ota_backend_esp_idf.cpp | 8 ++++ tests/unit_tests/test_main.py | 41 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c88efd6750..9ab2dee189 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1091,7 +1091,11 @@ def upload_program( port_type = get_port_type(host) - if port_type != PortType.NETWORK and getattr(args, "partition_table", False): + # MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by + # _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths. + if port_type in (PortType.SERIAL, PortType.BOOTSEL) and getattr( + args, "partition_table", False + ): raise EsphomeError( "The option --partition-table can only be used for Over The Air updates." ) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index a6b379a42f..f018429984 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -375,6 +375,9 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Deinitialize NVS just before the first destructive write to the partition-table region. Doing // this here (instead of earlier) means that any failure path in the verify or copy phases above // returns with NVS still functional, so other components on the device aren't broken until reboot. + // Each failure path past this point calls nvs_flash_init() before returning so that, if the + // device keeps running, components that depend on NVS aren't permanently broken. The success + // path skips reinit because the device reboots immediately afterwards. nvs_flash_deinit(); // Update the partition table @@ -383,6 +386,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); + nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); @@ -392,12 +396,14 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); + nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); + nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } // esp_partition_unload_all() invalidates every cached partition entry, including the externally @@ -412,12 +418,14 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); if (new_boot_partition == nullptr) { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); + nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); + nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } return OTA_RESPONSE_OK; diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e5564b6933..798a43a4ce 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1705,6 +1705,47 @@ def test_upload_program_serial_partition_table( upload_program(config, args, devices) +def test_upload_program_ota_partition_table_mqttip( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--partition-table is allowed for MQTTIP devices; they resolve to a real IP at OTA time.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "MQTTIP" + mock_run_ota.return_value = (0, "192.168.1.100") + + partition_file = tmp_path / "partitions.bin" + partition_file.write_bytes(_make_partition_table_bytes()) + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + "allow_partition_access": True, + } + ] + } + args = MockArgs(file=str(partition_file), partition_table=True) + + with patch( + "esphome.__main__._resolve_network_devices", return_value=["192.168.1.100"] + ): + exit_code, host = upload_program(config, args, ["MQTTIP"]) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], + 3232, + None, + partition_file, + OTA_TYPE_UPDATE_PARTITION_TABLE, + ) + + def test_validate_partition_table_binary_accepts_valid(tmp_path: Path) -> None: f = tmp_path / "partitions.bin" f.write_bytes(_make_partition_table_bytes()) From 4dcccf2cdf6e86bacfd5382f8b9c87e7efb558eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:30:23 -0500 Subject: [PATCH 1007/2214] [ota] DRY NVS reinit on update_partition_table failure paths Replace the four explicit nvs_flash_init() calls with a small RAII guard (NvsReinitGuard) declared right after nvs_flash_deinit(). Each failure path now just returns; the guard reinits NVS in its destructor. The success path disarms it before the trailing return because the device reboots immediately afterwards and reinit would only churn the partition cache. No behaviour change. --- .../components/ota/ota_backend_esp_idf.cpp | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index f018429984..88cb60675d 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -184,6 +184,23 @@ static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min return found; } +// RAII helper for the destructive section of update_partition_table(). nvs_flash_deinit() is +// called immediately before the first partition-table write so that earlier failure paths leave +// NVS functional; this guard re-initializes NVS on every early return past that point so any +// component still running after a failed OTA can keep using NVS. The success path disarms the +// guard before returning because the device reboots immediately afterwards and reinit would only +// churn the partition cache. +namespace { +struct NvsReinitGuard { + bool armed{true}; + ~NvsReinitGuard() { + if (armed) { + nvs_flash_init(); + } + } +}; +} // namespace + // Validate the new partition table image staged in ``buf_`` and pick the slot the running app // will boot from after the update. Performs all non-destructive checks; the destructive write // is in ``update_partition_table()``. Side-effect: registers the live partition-table region @@ -375,10 +392,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Deinitialize NVS just before the first destructive write to the partition-table region. Doing // this here (instead of earlier) means that any failure path in the verify or copy phases above // returns with NVS still functional, so other components on the device aren't broken until reboot. - // Each failure path past this point calls nvs_flash_init() before returning so that, if the - // device keeps running, components that depend on NVS aren't permanently broken. The success - // path skips reinit because the device reboots immediately afterwards. + // The RAII guard re-initializes NVS on every early-return below; the success path disarms it + // immediately before returning, since the device reboots right after. nvs_flash_deinit(); + NvsReinitGuard nvs_guard; // Update the partition table err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); @@ -386,7 +403,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); - nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); @@ -396,14 +412,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); - nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); - nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } // esp_partition_unload_all() invalidates every cached partition entry, including the externally @@ -418,16 +432,15 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); if (new_boot_partition == nullptr) { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); - nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); - nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } + nvs_guard.armed = false; return OTA_RESPONSE_OK; } From d5cc5206dd84568034be47423caf540685991f43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:34:46 -0500 Subject: [PATCH 1008/2214] [ota] Move partition-table code into ota_partitions_esp_idf.cpp Splits the partition-table OTA implementation out of the shared ota_backend_esp_idf.cpp into a new translation unit gated by USE_OTA_PARTITIONS. Builds without allow_partition_access compile strictly less code and don't link esp_image_format / nvs_flash; the common app-OTA backend is also easier to read without ~340 lines of unrelated partition handling interleaved. What moves: validate_new_partition_table_, update_partition_table, get_running_app_position, the file-static running-app cache, the NvsReinitGuard RAII helper, and the find_app_partition_at / check_overlap helpers. What stays: begin/write/end/abort and the factory. Behaviour-preserving refactor. --- .../components/ota/ota_backend_esp_idf.cpp | 340 ----------------- .../components/ota/ota_partitions_esp_idf.cpp | 356 ++++++++++++++++++ 2 files changed, 356 insertions(+), 340 deletions(-) create mode 100644 esphome/components/ota/ota_partitions_esp_idf.cpp diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 88cb60675d..e84d8762a1 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -10,11 +10,6 @@ #include #include -#ifdef USE_OTA_PARTITIONS -#include -#include -#endif - namespace esphome::ota { static const char *const TAG = "ota.idf"; @@ -161,340 +156,5 @@ void IDFOTABackend::abort() { this->update_handle_ = 0; } -#ifdef USE_OTA_PARTITIONS -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); -} - -// Find the first registered APP partition whose address matches `address` and whose size is at least -// `min_size`. Returns nullptr when no match exists. Encapsulates the iterator + release pattern so -// callers don't have to repeat (and correctly handle) the find/get/next/release dance. -static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min_size) { - const esp_partition_t *found = nullptr; - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); - while (it != nullptr) { - const esp_partition_t *p = esp_partition_get(it); - if (p->address == address && p->size >= min_size) { - found = p; - break; - } - it = esp_partition_next(it); - } - esp_partition_iterator_release(it); - return found; -} - -// RAII helper for the destructive section of update_partition_table(). nvs_flash_deinit() is -// called immediately before the first partition-table write so that earlier failure paths leave -// NVS functional; this guard re-initializes NVS on every early return past that point so any -// component still running after a failed OTA can keep using NVS. The success path disarms the -// guard before returning because the device reboots immediately afterwards and reinit would only -// churn the partition cache. -namespace { -struct NvsReinitGuard { - bool armed{true}; - ~NvsReinitGuard() { - if (armed) { - nvs_flash_init(); - } - } -}; -} // namespace - -// Validate the new partition table image staged in ``buf_`` and pick the slot the running app -// will boot from after the update. Performs all non-destructive checks; the destructive write -// is in ``update_partition_table()``. Side-effect: registers the live partition-table region -// as ``partition_table_part_`` so the caller can write to it; ``abort()`` releases it on error. -OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, - PartitionTablePlan &plan) { - // Register the live primary partition table as an external partition so we can mmap it for - // verification and later issue esp_ota_begin/esp_ota_write against it. - esp_err_t err = esp_partition_register_external( - nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", - ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Verify existing partition table - int num_partitions = 0; - const esp_partition_info_t *existing_partition_table = nullptr; - esp_partition_mmap_handle_t partition_table_map; - err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, - reinterpret_cast(&existing_partition_table), &partition_table_map); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); - esp_partition_munmap(partition_table_map); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Verify new partition table. esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN - // bytes; ``buf_`` is sized to that exactly. - const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - err = esp_partition_table_verify(new_partition_table, true, &num_partitions); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Check for missing checksum entry. esp_partition_table_verify does not fail in this case and - // the ESP would not boot after the update. - bool checksum_found = false; - for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { - if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { - checksum_found = true; - break; - } - } - if (!checksum_found) { - ESP_LOGE(TAG, "New partition table has no checksum"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Walk the new table once, populating: the chosen target app slot, presence of otadata/nvs, - // and otadata-vs-running-app overlap. Selection policy when multiple app slots can host the - // running app: pick the FIRST eligible slot in table order. The no-copy path (offsets already - // match) is preferred over the copy path; within each path we lock in the first match and stop - // searching. This keeps the choice deterministic and table-ordering-stable. - int app_partitions_found = 0; - int new_app_part_index = -1; - int new_app_part_index_with_copy = -1; - const esp_partition_t *app_copy_source_part = nullptr; - bool otadata_partition_found = false; - bool otadata_overlap = false; - bool nvs_partition_found = false; - for (int i = 0; i < num_partitions; i++) { - const esp_partition_info_t *new_part = &new_partition_table[i]; - if (new_part->type == ESP_PARTITION_TYPE_APP) { - app_partitions_found++; - if (new_part->pos.size >= running_app_size) { - if (new_part->pos.offset == running_app_offset) { - // No-copy path: same offset as running app, first match wins. - if (new_app_part_index == -1) { - new_app_part_index = i; - } - } else if (new_app_part_index_with_copy == -1 && - !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { - // Copy path: needs a registered source partition in the *current* table at the new slot's offset. - const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); - if (p != nullptr) { - new_app_part_index_with_copy = i; - app_copy_source_part = p; - } - } - } - } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { - if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { - otadata_partition_found = true; - otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); - } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && - strncmp(reinterpret_cast(new_part->label), "nvs", sizeof(new_part->label)) == 0) { - nvs_partition_found = true; - } - } - } - - if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { - ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - if (app_partitions_found < 2) { - ESP_LOGE(TAG, "New partition table needs at least 2 app partitions, found %d", app_partitions_found); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - if (!otadata_partition_found) { - ESP_LOGE(TAG, "New partition table is missing the required otadata partition"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - if (!nvs_partition_found) { - ESP_LOGE(TAG, "New partition table is missing the required nvs partition"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - if (otadata_overlap) { - ESP_LOGE(TAG, "New otadata partition overlaps with running app"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // No-copy preferred; copy path only when no-copy slot was unavailable. - if (new_app_part_index != -1) { - plan.target_app_index = new_app_part_index; - plan.copy_source_part = nullptr; - } else { - plan.target_app_index = new_app_part_index_with_copy; - plan.copy_source_part = app_copy_source_part; - } - return OTA_RESPONSE_OK; -} - -OTAResponseTypes IDFOTABackend::update_partition_table() { - if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { - ESP_LOGE(TAG, "Not enough data received"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Get running app partition and used size. A zero size means we couldn't determine the running - // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition - // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute - // overlap or copy bounds, so fail before any flash operation. - uint32_t running_app_offset; - size_t running_app_size; - get_running_app_position(running_app_offset, running_app_size); - if (running_app_size == 0) { - ESP_LOGE(TAG, "Failed to determine running app position"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - PartitionTablePlan plan; - OTAResponseTypes validate_result = this->validate_new_partition_table_(running_app_offset, running_app_size, plan); - if (validate_result != OTA_RESPONSE_OK) { - return validate_result; - } - - // Past this point any failure (power loss, watchdog reset, write error after the table has been - // partially erased) can leave the device unable to boot. Logged at ERROR severity so the message - // is visible in default log filters. - ESP_LOGE(TAG, "Starting partition table update.\n" - " DO NOT REMOVE POWER until the device reboots successfully.\n" - " Loss of power during this operation may permanently brick the device."); - - // Hold the watchdog open for the entire critical section: optional app copy, partition-table - // erase/write, and boot partition selection. None of the steps below should yield long enough - // to require a refresh, but bundling them under a single guard avoids spurious resets if the - // underlying ESP-IDF calls take longer than expected on a given chip variant. - watchdog::WatchdogManager watchdog(15000); - - esp_err_t err; - const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - - // Copy the running app partition to new position if needed. - // esp_ota_get_running_partition() is still valid here (we have not yet called - // esp_partition_unload_all()) and returns the same partition that find_app_partition_at would - // have located, without an extra iterator walk. - if (plan.copy_source_part != nullptr) { - const esp_partition_t *running_app_part = esp_ota_get_running_partition(); - ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, - plan.copy_source_part->address, running_app_size); - - err = esp_partition_copy(plan.copy_source_part, 0, running_app_part, 0, running_app_size); - - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - } - - // Deinitialize NVS just before the first destructive write to the partition-table region. Doing - // this here (instead of earlier) means that any failure path in the verify or copy phases above - // returns with NVS still functional, so other components on the device aren't broken until reboot. - // The RAII guard re-initializes NVS on every early-return below; the success path disarms it - // immediately before returning, since the device reboots right after. - nvs_flash_deinit(); - NvsReinitGuard nvs_guard; - - // Update the partition table - err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); - if (err != ESP_OK) { - esp_ota_abort(this->update_handle_); - this->update_handle_ = 0; - ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); - if (err != ESP_OK) { - // Release the handle eagerly; abort() would also do this, but cleaning up locally keeps the - // partial-write failure path self-contained. - esp_ota_abort(this->update_handle_); - this->update_handle_ = 0; - ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - err = esp_ota_end(this->update_handle_); - this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - // esp_partition_unload_all() invalidates every cached partition entry, including the externally - // registered `partition_table_part_`, so the explicit deregister call is redundant. Do the - // unload first, then null the member pointer so it never dangles past invalidation; if abort() - // were ever to observe an in-between state, it would see a non-null but freed pointer and crash. - esp_partition_unload_all(); - this->partition_table_part_ = nullptr; - - // Write otadata to set the new boot partition - const esp_partition_info_t *new_part = &new_partition_table[plan.target_app_index]; - const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); - if (new_boot_partition == nullptr) { - ESP_LOGE(TAG, "Selected app partition not found after partition table update"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); - err = esp_ota_set_boot_partition(new_boot_partition); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - nvs_guard.armed = false; - return OTA_RESPONSE_OK; -} - -// Process-scoped cache of the running app's flash position. Cannot live on IDFOTABackend -// because the backend is created/destroyed per OTA connection, while the cached values must -// survive across connections: once a previously aborted partition-table OTA has called -// esp_partition_unload_all(), esp_ota_get_running_partition() no longer returns valid data, -// so we have to remember the answer from the first successful call. The running app does not -// move within a boot, so a single capture is valid for the process lifetime. -static bool s_running_app_initialized = false; -static uint32_t s_running_app_cached_offset = 0; -static size_t s_running_app_cached_size = 0; - -void get_running_app_position(uint32_t &offset, size_t &size) { - // Returns the start address and the used length (rounded up to flash sectors) of the running app. - // The ``s_running_app_initialized`` flag (rather than ``size == 0``) gates the cache so a failed - // first call does not poison it; the next caller retries. Values are written atomically only - // after the full computation succeeds. - if (!s_running_app_initialized) { - const esp_partition_t *running_app_part = esp_ota_get_running_partition(); - if (running_app_part == nullptr || running_app_part->erase_size == 0) { - // Cannot determine the running app right now; surface zeros without committing to the cache - // so a later call has a chance to succeed. - offset = 0; - size = 0; - return; - } - - uint32_t pending_offset = running_app_part->address; - size_t pending_size = running_app_part->size; - - const esp_partition_pos_t running_app_pos = { - .offset = running_app_part->address, - .size = running_app_part->size, - }; - esp_image_metadata_t image_metadata = {}; - image_metadata.start_addr = running_app_part->address; - if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata) == ESP_OK && - image_metadata.image_len < running_app_part->size) { - pending_size = image_metadata.image_len; - } - // Round up to flash sector size so the copy spans complete erase blocks. - pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * - running_app_part->erase_size; - - s_running_app_cached_offset = pending_offset; - s_running_app_cached_size = pending_size; - s_running_app_initialized = true; - } - - offset = s_running_app_cached_offset; - size = s_running_app_cached_size; -} -#endif - } // namespace esphome::ota #endif // USE_ESP32 diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp new file mode 100644 index 0000000000..f912916c73 --- /dev/null +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -0,0 +1,356 @@ +#ifdef USE_ESP32 +#include "ota_backend_esp_idf.h" + +#include "esphome/core/defines.h" + +#ifdef USE_OTA_PARTITIONS +#include "esphome/components/watchdog/watchdog.h" +#include "esphome/core/log.h" + +#include +#include +#include + +#include + +namespace esphome::ota { + +static const char *const TAG = "ota.idf"; + +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); +} + +// Find the first registered APP partition whose address matches `address` and whose size is at least +// `min_size`. Returns nullptr when no match exists. Encapsulates the iterator + release pattern so +// callers don't have to repeat (and correctly handle) the find/get/next/release dance. +static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min_size) { + const esp_partition_t *found = nullptr; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { + const esp_partition_t *p = esp_partition_get(it); + if (p->address == address && p->size >= min_size) { + found = p; + break; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + return found; +} + +// RAII helper for the destructive section of update_partition_table(). nvs_flash_deinit() is +// called immediately before the first partition-table write so that earlier failure paths leave +// NVS functional; this guard re-initializes NVS on every early return past that point so any +// component still running after a failed OTA can keep using NVS. The success path disarms the +// guard before returning because the device reboots immediately afterwards and reinit would only +// churn the partition cache. +namespace { +struct NvsReinitGuard { + bool armed{true}; + ~NvsReinitGuard() { + if (armed) { + nvs_flash_init(); + } + } +}; +} // namespace + +// Validate the new partition table image staged in ``buf_`` and pick the slot the running app +// will boot from after the update. Performs all non-destructive checks; the destructive write +// is in ``update_partition_table()``. Side-effect: registers the live partition-table region +// as ``partition_table_part_`` so the caller can write to it; ``abort()`` releases it on error. +OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, + PartitionTablePlan &plan) { + // Register the live primary partition table as an external partition so we can mmap it for + // verification and later issue esp_ota_begin/esp_ota_write against it. + esp_err_t err = esp_partition_register_external( + nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", + ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Verify existing partition table + int num_partitions = 0; + const esp_partition_info_t *existing_partition_table = nullptr; + esp_partition_mmap_handle_t partition_table_map; + err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, + reinterpret_cast(&existing_partition_table), &partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); + esp_partition_munmap(partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Verify new partition table. esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN + // bytes; ``buf_`` is sized to that exactly. + const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); + err = esp_partition_table_verify(new_partition_table, true, &num_partitions); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Check for missing checksum entry. esp_partition_table_verify does not fail in this case and + // the ESP would not boot after the update. + bool checksum_found = false; + for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { + if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { + checksum_found = true; + break; + } + } + if (!checksum_found) { + ESP_LOGE(TAG, "New partition table has no checksum"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Walk the new table once, populating: the chosen target app slot, presence of otadata/nvs, + // and otadata-vs-running-app overlap. Selection policy when multiple app slots can host the + // running app: pick the FIRST eligible slot in table order. The no-copy path (offsets already + // match) is preferred over the copy path; within each path we lock in the first match and stop + // searching. This keeps the choice deterministic and table-ordering-stable. + int app_partitions_found = 0; + int new_app_part_index = -1; + int new_app_part_index_with_copy = -1; + const esp_partition_t *app_copy_source_part = nullptr; + bool otadata_partition_found = false; + bool otadata_overlap = false; + bool nvs_partition_found = false; + for (int i = 0; i < num_partitions; i++) { + const esp_partition_info_t *new_part = &new_partition_table[i]; + if (new_part->type == ESP_PARTITION_TYPE_APP) { + app_partitions_found++; + if (new_part->pos.size >= running_app_size) { + if (new_part->pos.offset == running_app_offset) { + // No-copy path: same offset as running app, first match wins. + if (new_app_part_index == -1) { + new_app_part_index = i; + } + } else if (new_app_part_index_with_copy == -1 && + !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { + // Copy path: needs a registered source partition in the *current* table at the new slot's offset. + const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); + if (p != nullptr) { + new_app_part_index_with_copy = i; + app_copy_source_part = p; + } + } + } + } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { + if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { + otadata_partition_found = true; + otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); + } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && + strncmp(reinterpret_cast(new_part->label), "nvs", sizeof(new_part->label)) == 0) { + nvs_partition_found = true; + } + } + } + + if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { + ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (app_partitions_found < 2) { + ESP_LOGE(TAG, "New partition table needs at least 2 app partitions, found %d", app_partitions_found); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (!otadata_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required otadata partition"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (!nvs_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required nvs partition"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (otadata_overlap) { + ESP_LOGE(TAG, "New otadata partition overlaps with running app"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // No-copy preferred; copy path only when no-copy slot was unavailable. + if (new_app_part_index != -1) { + plan.target_app_index = new_app_part_index; + plan.copy_source_part = nullptr; + } else { + plan.target_app_index = new_app_part_index_with_copy; + plan.copy_source_part = app_copy_source_part; + } + return OTA_RESPONSE_OK; +} + +OTAResponseTypes IDFOTABackend::update_partition_table() { + if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { + ESP_LOGE(TAG, "Not enough data received"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Get running app partition and used size. A zero size means we couldn't determine the running + // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition + // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute + // overlap or copy bounds, so fail before any flash operation. + uint32_t running_app_offset; + size_t running_app_size; + get_running_app_position(running_app_offset, running_app_size); + if (running_app_size == 0) { + ESP_LOGE(TAG, "Failed to determine running app position"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + PartitionTablePlan plan; + OTAResponseTypes validate_result = this->validate_new_partition_table_(running_app_offset, running_app_size, plan); + if (validate_result != OTA_RESPONSE_OK) { + return validate_result; + } + + // Past this point any failure (power loss, watchdog reset, write error after the table has been + // partially erased) can leave the device unable to boot. Logged at ERROR severity so the message + // is visible in default log filters. + ESP_LOGE(TAG, "Starting partition table update.\n" + " DO NOT REMOVE POWER until the device reboots successfully.\n" + " Loss of power during this operation may permanently brick the device."); + + // Hold the watchdog open for the entire critical section: optional app copy, partition-table + // erase/write, and boot partition selection. None of the steps below should yield long enough + // to require a refresh, but bundling them under a single guard avoids spurious resets if the + // underlying ESP-IDF calls take longer than expected on a given chip variant. + watchdog::WatchdogManager watchdog(15000); + + esp_err_t err; + const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); + + // Copy the running app partition to new position if needed. + // esp_ota_get_running_partition() is still valid here (we have not yet called + // esp_partition_unload_all()) and returns the same partition that find_app_partition_at would + // have located, without an extra iterator walk. + if (plan.copy_source_part != nullptr) { + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, + plan.copy_source_part->address, running_app_size); + + err = esp_partition_copy(plan.copy_source_part, 0, running_app_part, 0, running_app_size); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + } + + // Deinitialize NVS just before the first destructive write to the partition-table region. Doing + // this here (instead of earlier) means that any failure path in the verify or copy phases above + // returns with NVS still functional, so other components on the device aren't broken until reboot. + // The RAII guard re-initializes NVS on every early-return below; the success path disarms it + // immediately before returning, since the device reboots right after. + nvs_flash_deinit(); + NvsReinitGuard nvs_guard; + + // Update the partition table + err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); + if (err != ESP_OK) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); + if (err != ESP_OK) { + // Release the handle eagerly; abort() would also do this, but cleaning up locally keeps the + // partial-write failure path self-contained. + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + err = esp_ota_end(this->update_handle_); + this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + // esp_partition_unload_all() invalidates every cached partition entry, including the externally + // registered `partition_table_part_`, so the explicit deregister call is redundant. Do the + // unload first, then null the member pointer so it never dangles past invalidation; if abort() + // were ever to observe an in-between state, it would see a non-null but freed pointer and crash. + esp_partition_unload_all(); + this->partition_table_part_ = nullptr; + + // Write otadata to set the new boot partition + const esp_partition_info_t *new_part = &new_partition_table[plan.target_app_index]; + const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); + if (new_boot_partition == nullptr) { + ESP_LOGE(TAG, "Selected app partition not found after partition table update"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); + err = esp_ota_set_boot_partition(new_boot_partition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + nvs_guard.armed = false; + return OTA_RESPONSE_OK; +} + +// Process-scoped cache of the running app's flash position. Cannot live on IDFOTABackend +// because the backend is created/destroyed per OTA connection, while the cached values must +// survive across connections: once a previously aborted partition-table OTA has called +// esp_partition_unload_all(), esp_ota_get_running_partition() no longer returns valid data, +// so we have to remember the answer from the first successful call. The running app does not +// move within a boot, so a single capture is valid for the process lifetime. +static bool s_running_app_initialized = false; +static uint32_t s_running_app_cached_offset = 0; +static size_t s_running_app_cached_size = 0; + +void get_running_app_position(uint32_t &offset, size_t &size) { + // Returns the start address and the used length (rounded up to flash sectors) of the running app. + // The ``s_running_app_initialized`` flag (rather than ``size == 0``) gates the cache so a failed + // first call does not poison it; the next caller retries. Values are written atomically only + // after the full computation succeeds. + if (!s_running_app_initialized) { + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + if (running_app_part == nullptr || running_app_part->erase_size == 0) { + // Cannot determine the running app right now; surface zeros without committing to the cache + // so a later call has a chance to succeed. + offset = 0; + size = 0; + return; + } + + uint32_t pending_offset = running_app_part->address; + size_t pending_size = running_app_part->size; + + const esp_partition_pos_t running_app_pos = { + .offset = running_app_part->address, + .size = running_app_part->size, + }; + esp_image_metadata_t image_metadata = {}; + image_metadata.start_addr = running_app_part->address; + if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata) == ESP_OK && + image_metadata.image_len < running_app_part->size) { + pending_size = image_metadata.image_len; + } + // Round up to flash sector size so the copy spans complete erase blocks. + pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + + s_running_app_cached_offset = pending_offset; + s_running_app_cached_size = pending_size; + s_running_app_initialized = true; + } + + offset = s_running_app_cached_offset; + size = s_running_app_cached_size; +} + +} // namespace esphome::ota + +#endif // USE_OTA_PARTITIONS +#endif // USE_ESP32 From c69b3c559099ad581dba3d8a34e57aabfbf4f486 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:43:55 -0500 Subject: [PATCH 1009/2214] [ota] Unify backend begin() signature and trim partition-table comments Every OTA backend's begin() now takes ``(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP)``. ESPHomeOTAComponent::handle_data_() calls ``backend_->begin(ota_size, ota_type)`` unconditionally; the USE_OTA_PARTITIONS ifdef around the call disappears. The four non-ESP32 backends (esp8266, rp2040, libretiny, host) accept the new parameter and reject anything other than OTA_TYPE_UPDATE_APP up front, so the client-visible behaviour is unchanged. Same commit also trims the verbose block comments in the partition- table TU and the IDF backend header, keeping the genuinely non-obvious WHYs (slot-selection policy, NvsReinitGuard semantics, unload-then-null ordering, cache-init flag, dump_config nullptr fallback) and dropping the procedural narration. No functional change. --- .../components/esphome/ota/ota_esphome.cpp | 15 +-- .../ota/ota_backend_arduino_libretiny.cpp | 5 +- .../ota/ota_backend_arduino_libretiny.h | 2 +- .../ota/ota_backend_arduino_rp2040.cpp | 5 +- .../ota/ota_backend_arduino_rp2040.h | 2 +- .../components/ota/ota_backend_esp8266.cpp | 5 +- esphome/components/ota/ota_backend_esp8266.h | 2 +- .../components/ota/ota_backend_esp_idf.cpp | 18 ++-- esphome/components/ota/ota_backend_esp_idf.h | 22 ++-- esphome/components/ota/ota_backend_host.cpp | 4 +- esphome/components/ota/ota_backend_host.h | 2 +- .../components/ota/ota_partitions_esp_idf.cpp | 102 ++++++------------ 12 files changed, 70 insertions(+), 114 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 84d6fa3c0e..00089c000d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -101,13 +101,8 @@ void ESPHomeOTAComponent::dump_config() { } #endif #ifdef USE_OTA_PARTITIONS - // Avoid running esp_image_verify here: it reads and checksums the entire app image, which is too - // expensive for a config dump. The address comes from a cached lookup; the precise used size is - // computed lazily by update_partition_table() the first time a partition-table OTA is requested. - // Guard against esp_ota_get_running_partition() returning nullptr (can happen after the partition - // cache has been unloaded) so dump_config never crashes. - // Single ESP_LOGCONFIG call so the lines stay together as one log message; on the (rare) - // nullptr path we surface zeros rather than dereferencing. + // running_app_part can be nullptr if the partition cache was unloaded by a prior aborted + // partition-table OTA; surface zeros instead of dereferencing. const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGCONFIG(TAG, " Partition access allowed\n" @@ -397,12 +392,8 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif -#ifdef USE_OTA_PARTITIONS + // begin() may block for a few seconds while it locks flash. error_code = this->backend_->begin(ota_size, ota_type); -#else - // This will block for a few seconds as it locks flash - error_code = this->backend_->begin(ota_size); -#endif if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) update_started = true; diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index dcd71e92dd..4cc99202a7 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -13,7 +13,10 @@ static const char *const TAG = "ota.arduino_libretiny"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size) { +OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ota_type) { + if (ota_type != OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } // Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA // where the exact firmware size is unknown due to multipart encoding if (image_size == 0) { diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index 3d426e6759..c2716a44d1 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -8,7 +8,7 @@ namespace esphome::ota { class ArduinoLibreTinyOTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2040.cpp index bc8ef812e6..0ca0602519 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2040.cpp @@ -15,7 +15,10 @@ static const char *const TAG = "ota.arduino_rp2040"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size) { +OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_type) { + if (ota_type != OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } // OTA size of 0 is not currently handled, but // web_server is not supported for RP2040, so this is not an issue. bool ret = Update.begin(image_size, U_FLASH); diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2040.h index 05bd2f5cc4..d04d5c1a84 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2040.h @@ -10,7 +10,7 @@ namespace esphome::ota { class ArduinoRP2040OTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 7c9d392532..6a678fb419 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -50,7 +50,10 @@ static const char *const TAG = "ota.esp8266"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) { +OTAResponseTypes ESP8266OTABackend::begin(size_t image_size, OTAType ota_type) { + if (ota_type != OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } // Handle UPDATE_SIZE_UNKNOWN (0) by calculating available space if (image_size == 0) { // Round down to sector boundary: subtract one sector, then mask to sector alignment diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index b364e216a3..21b5c12c2d 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -14,7 +14,7 @@ namespace esphome::ota { /// by not having a global Update object in .bss. class ESP8266OTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index e84d8762a1..42d106bf1f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -16,14 +16,12 @@ static const char *const TAG = "ota.idf"; std::unique_ptr make_ota_backend() { return make_unique(); } -#ifdef USE_OTA_PARTITIONS OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { +#ifdef USE_OTA_PARTITIONS this->ota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - // Partition table images produced by gen_esp32part.py are padded with 0xFF and an MD5 entry to - // exactly ESP_PARTITION_TABLE_MAX_LEN bytes. Reject anything else: an undersized image would - // leave trailing bytes from the previous table in place after the partial write, and an - // oversized image cannot fit in the reserved region. This is stricter than verify alone. + // Reject any size other than ESP_PARTITION_TABLE_MAX_LEN: under- leaves stale bytes from the + // previous table; over- can't fit the reserved region. if (image_size != ESP_PARTITION_TABLE_MAX_LEN) { ESP_LOGE(TAG, "Wrong partition table size: expected %u bytes, got %zu", ESP_PARTITION_TABLE_MAX_LEN, image_size); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; @@ -38,7 +36,9 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } #else -OTAResponseTypes IDFOTABackend::begin(size_t image_size) { + if (ota_type != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } #endif #ifdef USE_OTA_ROLLBACK // If we're starting an OTA, the current boot is good enough - mark it valid @@ -148,10 +148,8 @@ void IDFOTABackend::abort() { this->partition_table_part_ = nullptr; } #endif - // Always tear down any open OTA handle. update_partition_table() opens a handle internally to - // write the new partition table; if esp_ota_write/esp_ota_end fail mid-flight, the handle must - // be released here so it isn't leaked. esp_ota_abort with handle 0 returns ESP_ERR_INVALID_ARG - // harmlessly, so the unconditional call is safe whether or not we're mid-update. + // esp_ota_abort with handle 0 returns ESP_ERR_INVALID_ARG harmlessly, so this is safe whether + // or not an update is in flight. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; } diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 59712945f2..5e45b6d016 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -10,9 +10,7 @@ namespace esphome::ota { #ifdef USE_OTA_PARTITIONS -// Dedicated staging buffer size for the new partition table image. Must be at least -// ESP_PARTITION_TABLE_MAX_LEN (0xC00) so the entire partition table fits before verification. -// Kept separate from any OTA chunk-transfer buffer to avoid coupling unrelated sizes. +// Staging buffer holds the entire partition table for verification before any flash op. static constexpr size_t PARTITION_TABLE_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 void get_running_app_position(uint32_t &offset, size_t &size); @@ -20,11 +18,7 @@ void get_running_app_position(uint32_t &offset, size_t &size); class IDFOTABackend final { public: -#ifdef USE_OTA_PARTITIONS OTAResponseTypes begin(size_t image_size, ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP); -#else - OTAResponseTypes begin(size_t image_size); -#endif void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); @@ -33,10 +27,8 @@ class IDFOTABackend final { protected: #ifdef USE_OTA_PARTITIONS - // Outcome of validating an incoming partition-table image. ``target_app_index`` is the - // entry in the new table that the running app will boot from after the update; - // ``copy_source_part`` is non-null when the running app must be copied into that slot - // first (the source is the matching slot in the *current* partition table). + // copy_source_part non-null means the running app must be copied from this slot in the current + // table into target_app_index in the new table before the table is committed. struct PartitionTablePlan { int target_app_index{-1}; const esp_partition_t *copy_source_part{nullptr}; @@ -54,11 +46,9 @@ class IDFOTABackend final { char expected_bin_md5_[32]; bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS - // Place the byte buffer first so it sits immediately after the preceding `bool md5_set_`, - // eliminating the 3-byte alignment padding that an int-sized member would otherwise force. - // Remaining members are 4-byte-aligned and pack tightly after the buffer. The backend is - // constructed on each incoming OTA connection and destroyed on cleanup_connection_(), so this - // 3 KiB is only resident during an active OTA, not permanently. + // Buffer first so it packs tightly after the preceding `bool md5_set_` with no alignment + // padding. Only resident during an active OTA: the backend is constructed per connection and + // destroyed on cleanup_connection_(). uint8_t buf_[PARTITION_TABLE_BUFFER_SIZE]; size_t buf_written_{0}; size_t image_size_{0}; diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 2e2132418d..a2c9f2cc33 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -10,7 +10,9 @@ namespace esphome::ota { std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes HostOTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_UPDATE_PREPARE; } +OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; +} void HostOTABackend::set_update_md5(const char *expected_md5) {} diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index 300facf72f..4451fdfe18 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -9,7 +9,7 @@ namespace esphome::ota { /// OTA triggers to compile for host platform during development. class HostOTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f912916c73..910c63e8d7 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -21,9 +21,8 @@ static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_of return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); } -// Find the first registered APP partition whose address matches `address` and whose size is at least -// `min_size`. Returns nullptr when no match exists. Encapsulates the iterator + release pattern so -// callers don't have to repeat (and correctly handle) the find/get/next/release dance. +// Wraps esp_partition_find/_get/_next/_release. Returns nullptr if no APP partition at `address` +// is at least `min_size` bytes. static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min_size) { const esp_partition_t *found = nullptr; esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); @@ -39,12 +38,8 @@ static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min return found; } -// RAII helper for the destructive section of update_partition_table(). nvs_flash_deinit() is -// called immediately before the first partition-table write so that earlier failure paths leave -// NVS functional; this guard re-initializes NVS on every early return past that point so any -// component still running after a failed OTA can keep using NVS. The success path disarms the -// guard before returning because the device reboots immediately afterwards and reinit would only -// churn the partition cache. +// Re-inits NVS unless disarmed. Used so failure paths past nvs_flash_deinit() leave NVS usable +// for any component still running after a failed OTA. namespace { struct NvsReinitGuard { bool armed{true}; @@ -56,14 +51,12 @@ struct NvsReinitGuard { }; } // namespace -// Validate the new partition table image staged in ``buf_`` and pick the slot the running app -// will boot from after the update. Performs all non-destructive checks; the destructive write -// is in ``update_partition_table()``. Side-effect: registers the live partition-table region -// as ``partition_table_part_`` so the caller can write to it; ``abort()`` releases it on error. +// Validates the staged partition table and picks the post-update boot slot. All non-destructive +// checks live here; the destructive write is in update_partition_table(). +// Side effect: registers the live partition-table region as partition_table_part_ so the caller +// can write to it; abort() releases it on error. OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, PartitionTablePlan &plan) { - // Register the live primary partition table as an external partition so we can mmap it for - // verification and later issue esp_ota_begin/esp_ota_write against it. esp_err_t err = esp_partition_register_external( nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); @@ -72,7 +65,6 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Verify existing partition table int num_partitions = 0; const esp_partition_info_t *existing_partition_table = nullptr; esp_partition_mmap_handle_t partition_table_map; @@ -89,8 +81,6 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Verify new partition table. esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN - // bytes; ``buf_`` is sized to that exactly. const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { @@ -98,8 +88,8 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Check for missing checksum entry. esp_partition_table_verify does not fail in this case and - // the ESP would not boot after the update. + // esp_partition_table_verify does not catch a missing MD5 entry, but the bootloader refuses + // to boot from a table without one. bool checksum_found = false; for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { @@ -112,11 +102,9 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Walk the new table once, populating: the chosen target app slot, presence of otadata/nvs, - // and otadata-vs-running-app overlap. Selection policy when multiple app slots can host the - // running app: pick the FIRST eligible slot in table order. The no-copy path (offsets already - // match) is preferred over the copy path; within each path we lock in the first match and stop - // searching. This keeps the choice deterministic and table-ordering-stable. + // Slot-selection policy when multiple slots can host the running app: pick the FIRST eligible + // slot in table order, preferring the no-copy path (matching offset) over the copy path. + // Deterministic and table-ordering-stable. int app_partitions_found = 0; int new_app_part_index = -1; int new_app_part_index_with_copy = -1; @@ -130,13 +118,12 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a app_partitions_found++; if (new_part->pos.size >= running_app_size) { if (new_part->pos.offset == running_app_offset) { - // No-copy path: same offset as running app, first match wins. if (new_app_part_index == -1) { new_app_part_index = i; } } else if (new_app_part_index_with_copy == -1 && !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { - // Copy path: needs a registered source partition in the *current* table at the new slot's offset. + // esp_partition_copy needs a registered source partition in the current table. const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); if (p != nullptr) { new_app_part_index_with_copy = i; @@ -176,7 +163,6 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // No-copy preferred; copy path only when no-copy slot was unavailable. if (new_app_part_index != -1) { plan.target_app_index = new_app_part_index; plan.copy_source_part = nullptr; @@ -193,10 +179,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Get running app partition and used size. A zero size means we couldn't determine the running - // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition - // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute - // overlap or copy bounds, so fail before any flash operation. + // Without a valid running-app size we cannot compute overlap or copy bounds. zero indicates + // esp_ota_get_running_partition() failed (e.g. cache unloaded by a previous aborted OTA). uint32_t running_app_offset; size_t running_app_size; get_running_app_position(running_app_offset, running_app_size); @@ -211,26 +195,21 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return validate_result; } - // Past this point any failure (power loss, watchdog reset, write error after the table has been - // partially erased) can leave the device unable to boot. Logged at ERROR severity so the message - // is visible in default log filters. + // ERROR severity so the warning shows up in default log filters; any failure past this point + // can leave the device unable to boot. ESP_LOGE(TAG, "Starting partition table update.\n" " DO NOT REMOVE POWER until the device reboots successfully.\n" " Loss of power during this operation may permanently brick the device."); - // Hold the watchdog open for the entire critical section: optional app copy, partition-table - // erase/write, and boot partition selection. None of the steps below should yield long enough - // to require a refresh, but bundling them under a single guard avoids spurious resets if the - // underlying ESP-IDF calls take longer than expected on a given chip variant. + // One guard over the whole critical section in case an IDF call takes longer than expected on + // some chip variant. watchdog::WatchdogManager watchdog(15000); esp_err_t err; const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - // Copy the running app partition to new position if needed. - // esp_ota_get_running_partition() is still valid here (we have not yet called - // esp_partition_unload_all()) and returns the same partition that find_app_partition_at would - // have located, without an extra iterator walk. + // esp_ota_get_running_partition() is still valid here (esp_partition_unload_all() has not run) + // so use it directly instead of repeating the iterator walk. if (plan.copy_source_part != nullptr) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, @@ -244,11 +223,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } } - // Deinitialize NVS just before the first destructive write to the partition-table region. Doing - // this here (instead of earlier) means that any failure path in the verify or copy phases above - // returns with NVS still functional, so other components on the device aren't broken until reboot. - // The RAII guard re-initializes NVS on every early-return below; the success path disarms it - // immediately before returning, since the device reboots right after. + // Deinit NVS only just before the first destructive write so verify/copy failure paths return + // with NVS still functional. The guard re-inits on early returns; success disarms it. nvs_flash_deinit(); NvsReinitGuard nvs_guard; @@ -262,23 +238,20 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); if (err != ESP_OK) { - // Release the handle eagerly; abort() would also do this, but cleaning up locally keeps the - // partial-write failure path self-contained. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); - this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result + this->update_handle_ = 0; // esp_ota_end releases the handle internally if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - // esp_partition_unload_all() invalidates every cached partition entry, including the externally - // registered `partition_table_part_`, so the explicit deregister call is redundant. Do the - // unload first, then null the member pointer so it never dangles past invalidation; if abort() - // were ever to observe an in-between state, it would see a non-null but freed pointer and crash. + // unload first, then null the member pointer; if abort() ran between the two steps it would + // see a freed pointer. esp_partition_unload_all() invalidates partition_table_part_ too, so + // an explicit deregister would be redundant. esp_partition_unload_all(); this->partition_table_part_ = nullptr; @@ -299,26 +272,19 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_OK; } -// Process-scoped cache of the running app's flash position. Cannot live on IDFOTABackend -// because the backend is created/destroyed per OTA connection, while the cached values must -// survive across connections: once a previously aborted partition-table OTA has called -// esp_partition_unload_all(), esp_ota_get_running_partition() no longer returns valid data, -// so we have to remember the answer from the first successful call. The running app does not -// move within a boot, so a single capture is valid for the process lifetime. +// Process-scoped cache. Cannot be a backend member: backends are per-connection but the cache +// must outlive a connection that called esp_partition_unload_all(), after which +// esp_ota_get_running_partition() no longer returns valid data. static bool s_running_app_initialized = false; static uint32_t s_running_app_cached_offset = 0; static size_t s_running_app_cached_size = 0; +// Flag-gated rather than size==0 so a failed first call doesn't poison the cache. void get_running_app_position(uint32_t &offset, size_t &size) { - // Returns the start address and the used length (rounded up to flash sectors) of the running app. - // The ``s_running_app_initialized`` flag (rather than ``size == 0``) gates the cache so a failed - // first call does not poison it; the next caller retries. Values are written atomically only - // after the full computation succeeds. if (!s_running_app_initialized) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); if (running_app_part == nullptr || running_app_part->erase_size == 0) { - // Cannot determine the running app right now; surface zeros without committing to the cache - // so a later call has a chance to succeed. + // Surface zeros without committing to the cache so a later call has a chance to succeed. offset = 0; size = 0; return; @@ -337,7 +303,7 @@ void get_running_app_position(uint32_t &offset, size_t &size) { image_metadata.image_len < running_app_part->size) { pending_size = image_metadata.image_len; } - // Round up to flash sector size so the copy spans complete erase blocks. + // Round up to a full flash sector so the copy spans complete erase blocks. pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; From 87f152965dfcc2db1deb185b941b02e3c47e2a9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:48:06 -0500 Subject: [PATCH 1010/2214] [ota] Rename copy_source_part -> copy_dest_part and harden running-app lookup The PartitionTablePlan field that drives the optional app copy stores the partition that will receive the running app (i.e. the destination in the current table at the new slot's flash offset). The old name ``copy_source_part`` described the value backwards and risked future callers swapping the esp_partition_copy() arguments; renamed to ``copy_dest_part`` along with the matching local ``app_copy_dest_part`` and the comments around them. Also replace esp_ota_get_running_partition() in the copy path with find_app_partition_at(running_app_offset, running_app_size). The IDF call can return nullptr after a prior aborted partition-table OTA in the same boot called esp_partition_unload_all() (the same condition the cache in get_running_app_position() exists for); the previous code would have dereferenced nullptr on the retry that the client error message explicitly suggests. No functional change on the success path; nullptr deref on the failed- retry path is now reported as OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE. --- esphome/components/ota/ota_backend_esp_idf.h | 8 +++-- .../components/ota/ota_partitions_esp_idf.cpp | 30 +++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 5e45b6d016..54fdd24f93 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -27,11 +27,13 @@ class IDFOTABackend final { protected: #ifdef USE_OTA_PARTITIONS - // copy_source_part non-null means the running app must be copied from this slot in the current - // table into target_app_index in the new table before the table is committed. + // copy_dest_part non-null means the running app must be copied INTO this slot of the current + // table before the new partition table is committed. The destination is in the current table + // because that's where esp_partition_copy can write; once the new table replaces it, the same + // flash region becomes target_app_index in the new table. struct PartitionTablePlan { int target_app_index{-1}; - const esp_partition_t *copy_source_part{nullptr}; + const esp_partition_t *copy_dest_part{nullptr}; }; OTAResponseTypes validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index 910c63e8d7..f09a0fd145 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -108,7 +108,7 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a int app_partitions_found = 0; int new_app_part_index = -1; int new_app_part_index_with_copy = -1; - const esp_partition_t *app_copy_source_part = nullptr; + const esp_partition_t *app_copy_dest_part = nullptr; bool otadata_partition_found = false; bool otadata_overlap = false; bool nvs_partition_found = false; @@ -123,11 +123,12 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a } } else if (new_app_part_index_with_copy == -1 && !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { - // esp_partition_copy needs a registered source partition in the current table. + // esp_partition_copy writes into a registered partition; need one at this offset in the + // current table. const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); if (p != nullptr) { new_app_part_index_with_copy = i; - app_copy_source_part = p; + app_copy_dest_part = p; } } } @@ -165,10 +166,10 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a if (new_app_part_index != -1) { plan.target_app_index = new_app_part_index; - plan.copy_source_part = nullptr; + plan.copy_dest_part = nullptr; } else { plan.target_app_index = new_app_part_index_with_copy; - plan.copy_source_part = app_copy_source_part; + plan.copy_dest_part = app_copy_dest_part; } return OTA_RESPONSE_OK; } @@ -208,15 +209,18 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_err_t err; const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - // esp_ota_get_running_partition() is still valid here (esp_partition_unload_all() has not run) - // so use it directly instead of repeating the iterator walk. - if (plan.copy_source_part != nullptr) { - const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + if (plan.copy_dest_part != nullptr) { + // Resolve the source via running_app_offset rather than esp_ota_get_running_partition() in + // case a prior aborted partition-table OTA called esp_partition_unload_all() in this boot, + // which leaves esp_ota_get_running_partition() returning nullptr. + const esp_partition_t *running_app_part = find_app_partition_at(running_app_offset, running_app_size); + if (running_app_part == nullptr) { + ESP_LOGE(TAG, "Cannot resolve running app partition at offset 0x%X", running_app_offset); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, - plan.copy_source_part->address, running_app_size); - - err = esp_partition_copy(plan.copy_source_part, 0, running_app_part, 0, running_app_size); - + plan.copy_dest_part->address, running_app_size); + err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; From 1895ef1c3e667910d3eb86053ddbd7c8dcdaad98 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 3 May 2026 20:03:38 +0200 Subject: [PATCH 1011/2214] Restore 'Used size' of running app in dump_config --- esphome/components/esphome/ota/ota_esphome.cpp | 12 ++++++------ esphome/components/esphome/ota/ota_esphome.h | 4 ++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 00089c000d..369f39e22b 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -87,6 +87,10 @@ void ESPHomeOTAComponent::setup() { // no wakes fire and loop() falls back to the self-disable safety net. esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif + +#ifdef USE_OTA_PARTITIONS + ota::get_running_app_position(this->running_app_offset_, this->running_app_size_); +#endif } void ESPHomeOTAComponent::dump_config() { @@ -101,16 +105,12 @@ void ESPHomeOTAComponent::dump_config() { } #endif #ifdef USE_OTA_PARTITIONS - // running_app_part can be nullptr if the partition cache was unloaded by a prior aborted - // partition-table OTA; surface zeros instead of dereferencing. - const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" " Partition address: 0x%X\n" - " Partition size: 0x%X bytes", - running_app_part != nullptr ? running_app_part->address : 0u, - running_app_part != nullptr ? running_app_part->size : 0u); + " Used size: %zu bytes (0x%X)", + this->running_app_offset_, this->running_app_size_, this->running_app_size_); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 5043bc33ef..0053ca6969 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -98,6 +98,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { uint32_t client_connect_time_{0}; static constexpr size_t HANDSHAKE_BUF_SIZE = 5; +#ifdef USE_OTA_PARTITIONS + uint32_t running_app_offset_{0}; + size_t running_app_size_{0}; +#endif uint16_t port_; uint8_t handshake_buf_[HANDSHAKE_BUF_SIZE]; OTAState ota_state_{OTAState::IDLE}; From c19d06a07fd3e032df2b3e7af2c6778dafbc8357 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 3 May 2026 20:47:49 +0200 Subject: [PATCH 1012/2214] Format partition table similar to debug component --- esphome/components/esphome/ota/ota_esphome.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 369f39e22b..e6e75bd6e0 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -113,12 +113,15 @@ void ESPHomeOTAComponent::dump_config() { this->running_app_offset_, this->running_app_size_, this->running_app_size_); #ifdef USE_ESP32 - ESP_LOGCONFIG(TAG, " Partition table:"); - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); - while (it != nullptr) { - const esp_partition_t *p = esp_partition_get(it); - ESP_LOGCONFIG(TAG, " %s: type=0x%X, subtype=0x%X, address=0x%X, size=0x%X", p->label, p->type, p->subtype, - p->address, p->size); + ESP_LOGCONFIG(TAG, + " Partition table:\n" + " %-12s %-4s %-8s %-10s %-10s", + "Name", "Type", "Subtype", "Address", "Size"); + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *partition = esp_partition_get(it); + ESP_LOGCONFIG(TAG, " %-12s 0x%-2X 0x%-6X 0x%-8" PRIX32 " 0x%-8" PRIX32, partition->label, partition->type, + partition->subtype, partition->address, partition->size); it = esp_partition_next(it); } esp_partition_iterator_release(it); From d30f82784c72d5efb08ac82d8734c8caa1d18638 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 14:24:44 -0500 Subject: [PATCH 1013/2214] [ota] Drop ineffective NvsReinitGuard; soften brick wording Mat931 verified empirically that ``nvs_flash_init()`` after ``nvs_flash_deinit()`` does not revive NVS handles already held by other components: writes still fail with ESP_ERR_NVS_INVALID_HANDLE. The guard therefore only created the appearance of a recovery path. Remove it and document the actual contract: from nvs_flash_deinit() onward, components that hold open NVS handles will fail until the device is rebooted. The success path reboots immediately, so it is unaffected; the failure path now tells the user clearly to reboot and retry, rather than implying retry-without-reboot will work. Also soften the "permanently brick" wording in the pre-write log warning -- a bad partition-table OTA leaves a device that needs a serial flash to recover, not a permanently-dead one. --- .../components/ota/ota_partitions_esp_idf.cpp | 26 ++++++------------- esphome/espota2.py | 7 ++--- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f09a0fd145..3a79136c61 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -38,19 +38,6 @@ static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min return found; } -// Re-inits NVS unless disarmed. Used so failure paths past nvs_flash_deinit() leave NVS usable -// for any component still running after a failed OTA. -namespace { -struct NvsReinitGuard { - bool armed{true}; - ~NvsReinitGuard() { - if (armed) { - nvs_flash_init(); - } - } -}; -} // namespace - // Validates the staged partition table and picks the post-update boot slot. All non-destructive // checks live here; the destructive write is in update_partition_table(). // Side effect: registers the live partition-table region as partition_table_part_ so the caller @@ -197,10 +184,11 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // ERROR severity so the warning shows up in default log filters; any failure past this point - // can leave the device unable to boot. + // can leave the device unbootable until it is recovered with a serial flash. ESP_LOGE(TAG, "Starting partition table update.\n" " DO NOT REMOVE POWER until the device reboots successfully.\n" - " Loss of power during this operation may permanently brick the device."); + " Loss of power during this operation may render the device unable to boot until\n" + " it is recovered via a serial flash."); // One guard over the whole critical section in case an IDF call takes longer than expected on // some chip variant. @@ -228,9 +216,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Deinit NVS only just before the first destructive write so verify/copy failure paths return - // with NVS still functional. The guard re-inits on early returns; success disarms it. + // with NVS still functional. From this point on, components that hold open NVS handles + // (e.g. preferences) will fail with ESP_ERR_NVS_INVALID_HANDLE on success or failure; + // nvs_flash_init() can re-init the subsystem but cannot revive existing handles. On the + // success path the device reboots immediately afterwards so this doesn't matter; on the + // failure path the user must reboot the device before retrying. nvs_flash_deinit(); - NvsReinitGuard nvs_guard; // Update the partition table err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); @@ -272,7 +263,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - nvs_guard.armed = false; return OTA_RESPONSE_OK; } diff --git a/esphome/espota2.py b/esphome/espota2.py index e50b748e98..a45a6ef234 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -138,9 +138,10 @@ _ERROR_MESSAGES: dict[int, str] = { "made to the flash content. Check the logs for more information and retry." ), RESPONSE_ERROR_PARTITION_TABLE_UPDATE: ( - "An error occurred while updating the partition table. The device may " - "not be able to reboot to a working application. Check the logs and retry " - "the update without rebooting the device." + "An error occurred while updating the partition table. The device is now " + "in a degraded state (NVS handles are invalid; many components will fail) " + "and may not be able to boot. Check the logs, reboot the device, and " + "retry the update. If the device fails to boot, recover it via a serial flash." ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } From 7aa9f0d796397171cb130b033ea9e63b455791b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 16:06:54 -0500 Subject: [PATCH 1014/2214] [light] Fix LightControlAction trigger args with reference types `const Ts &...` is ill-formed when Ts is already a reference (e.g. a trigger that passes `std::string &`). Forward Ts by-value so the generated lambda matches ApplyFn for any valid trigger arg type. --- esphome/components/light/automation.h | 5 ++++- esphome/components/light/automation.py | 5 +++-- tests/components/light/common.yaml | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index a5c73997b0..620ea3aca9 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -36,9 +36,12 @@ template class ToggleAction : public A // plus one parent pointer, regardless of how many fields the user set. // Trigger args are forwarded to the apply function so user lambdas // (e.g. `brightness: !lambda "return x;"`) keep working. +// +// Ts... must be forwarded by-value: `const T &` is ill-formed when T is +// already a reference type (some triggers pass `std::string &`). template class LightControlAction : public Action { public: - using ApplyFn = void (*)(LightState *, LightCall &, const Ts &...); + using ApplyFn = void (*)(LightState *, LightCall &, Ts...); LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} void play(const Ts &...x) override { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index ca4018a975..370489770d 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -230,11 +230,12 @@ async def light_control_to_code(config, action_id, template_arg, args): f"call.set_effect(static_cast({_resolve_effect_index(config)}));" ) - # Match LightControlAction::ApplyFn signature: const Ts &... for trigger args. + # Match LightControlAction::ApplyFn signature: trigger args forwarded + # by-value as Ts... apply_args = [ (LightState.operator("ptr"), "parent"), (LightCall.operator("ref"), "call"), - *((t.operator("const").operator("ref"), n) for t, n in args), + *args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index e58f7baee4..044a8144fa 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -127,6 +127,21 @@ esphome: blue: 0% transition_length: 1s +# Exercise light actions inside a trigger with non-empty Ts (number on_value +# passes float). +number: + - platform: template + id: test_number_brightness + optimistic: true + min_value: 0 + max_value: 100 + step: 1 + on_value: + then: + - light.turn_on: + id: test_monochromatic_light + brightness: !lambda "return x / 100.0;" + light: - platform: binary id: test_binary_light From 31239ac9506514c735790a4aae7f28a01515ca48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 16:08:39 -0500 Subject: [PATCH 1015/2214] [climate] Fix ControlAction trigger args with reference types `const Ts &...` is ill-formed when Ts is already a reference (e.g. a trigger that passes `std::string &`). Forward Ts by-value so the generated lambda matches ApplyFn for any valid trigger arg type. --- esphome/components/climate/__init__.py | 5 +++-- esphome/components/climate/automation.h | 5 ++++- tests/components/climate/common.yaml | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 7c9002d6dc..a55c5b0f5a 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -526,10 +526,11 @@ async def climate_control_to_code(config, action_id, template_arg, args): else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. + # Match ControlAction::ApplyFn signature: trigger args forwarded + # by-value as Ts... apply_args = [ (ClimateCall.operator("ref"), "call"), - *((t.operator("const").operator("ref"), n) for t, n in args), + *args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index 71d23fd6b6..c556206926 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -10,9 +10,12 @@ namespace esphome::climate { // plus one parent pointer, regardless of how many fields the user set. // Trigger args are forwarded to the apply function so user lambdas // (e.g. `target_temperature: !lambda "return x;"`) keep working. +// +// Ts... must be forwarded by-value: `const T &` is ill-formed when T is +// already a reference type (some triggers pass `std::string &`). template class ControlAction : public Action { public: - using ApplyFn = void (*)(ClimateCall &, const Ts &...); + using ApplyFn = void (*)(ClimateCall &, Ts...); ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { diff --git a/tests/components/climate/common.yaml b/tests/components/climate/common.yaml index 2d35438afd..c28fde8eeb 100644 --- a/tests/components/climate/common.yaml +++ b/tests/components/climate/common.yaml @@ -85,3 +85,18 @@ button: - climate.control: id: climate_test_thermostat mode: "OFF" + +# Exercise climate.control inside a trigger with non-empty Ts (number on_value +# passes float). +number: + - platform: template + id: climate_target_temp_number + optimistic: true + min_value: 16 + max_value: 28 + step: 0.5 + on_value: + then: + - climate.control: + id: climate_test_thermostat + target_temperature_high: !lambda "return x;" From d481d0cb4a5dff5e0b2ad64cd98f2ce86c2852db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 16:10:03 -0500 Subject: [PATCH 1016/2214] [fan] Fix TurnOnAction trigger args with reference types `const Ts &...` is ill-formed when Ts is already a reference (e.g. a trigger that passes `std::string &`). Forward Ts by-value so the generated lambda matches ApplyFn for any valid trigger arg type. --- esphome/components/fan/__init__.py | 5 +++-- esphome/components/fan/automation.h | 5 ++++- tests/components/fan/common.yaml | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index f47fc06b3d..bc006a6602 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -369,10 +369,11 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match TurnOnAction::ApplyFn signature: const Ts &... for trigger args. + # Match TurnOnAction::ApplyFn signature: trigger args forwarded + # by-value as Ts... apply_args = [ (FanCall.operator("ref"), "call"), - *((t.operator("const").operator("ref"), n) for t, n in args), + *args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index d8eda41b27..a5ed80aec6 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -12,9 +12,12 @@ namespace fan { // plus one parent pointer, regardless of how many fields the user set. // Trigger args are forwarded to the apply function so user lambdas // (e.g. `speed: !lambda "return x;"`) keep working. +// +// Ts... must be forwarded by-value: `const T &` is ill-formed when T is +// already a reference type (some triggers pass `std::string &`). template class TurnOnAction : public Action { public: - using ApplyFn = void (*)(FanCall &, const Ts &...); + using ApplyFn = void (*)(FanCall &, Ts...); TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} void play(const Ts &...x) override { diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 6cabbd24f8..91f799432c 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -88,3 +88,18 @@ button: - fan.turn_on: id: test_fan speed: !lambda 'return 1;' + +# Exercise fan.turn_on inside a trigger with non-empty Ts (number on_value +# passes float). +number: + - platform: template + id: fan_speed_number + optimistic: true + min_value: 1 + max_value: 3 + step: 1 + on_value: + then: + - fan.turn_on: + id: test_fan + speed: !lambda "return (int) x;" From fb6920a5b1421e7aa6f18df0e6e2d52f31ed01ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 16:12:08 -0500 Subject: [PATCH 1017/2214] [valve] Fix ControlAction trigger args with reference types `const Ts &...` is ill-formed when Ts is already a reference (e.g. a trigger that passes `std::string &`). Forward Ts by-value so the generated lambda matches ApplyFn for any valid trigger arg type. --- esphome/components/valve/__init__.py | 5 +++-- esphome/components/valve/automation.h | 5 ++++- tests/components/template/common-base.yaml | 13 +++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 7377aea1ed..bc7e2ba48d 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -251,10 +251,11 @@ async def valve_control_to_code(config, action_id, template_arg, args): else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. + # Match ControlAction::ApplyFn signature: trigger args forwarded + # by-value as Ts... apply_args = [ (ValveCall.operator("ref"), "call"), - *((t.operator("const").operator("ref"), n) for t, n in args), + *args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index ae9ac0db76..81fcd12200 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -52,9 +52,12 @@ template class ToggleAction : public Action { // plus one parent pointer, regardless of how many fields the user set. // Trigger args are forwarded to the apply function so user lambdas // (e.g. `position: !lambda "return x;"`) keep working. +// +// Ts... must be forwarded by-value: `const T &` is ill-formed when T is +// already a reference type (some triggers pass `std::string &`). template class ControlAction : public Action { public: - using ApplyFn = void (*)(ValveCall &, const Ts &...); + using ApplyFn = void (*)(ValveCall &, Ts...); ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} void play(const Ts &...x) override { diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 819eaa8bbf..984ef129ad 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -356,6 +356,19 @@ number: min_value: 0 max_value: 100 step: 1 + # Exercise valve.control inside a trigger with non-empty Ts (number on_value + # passes float). + - platform: template + id: template_valve_position_number + optimistic: true + min_value: 0 + max_value: 100 + step: 1 + on_value: + then: + - valve.control: + id: template_valve + position: !lambda "return x / 100.0f;" select: - platform: template From dd0cff5f454534ba2572aea0ac481ea34aaae480 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 16:51:27 -0500 Subject: [PATCH 1018/2214] [light] Reword apply-fn comments to describe codegen failure mode --- esphome/components/light/automation.h | 7 +++++-- esphome/components/light/automation.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 620ea3aca9..8932822133 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -37,8 +37,11 @@ template class ToggleAction : public A // Trigger args are forwarded to the apply function so user lambdas // (e.g. `brightness: !lambda "return x;"`) keep working. // -// Ts... must be forwarded by-value: `const T &` is ill-formed when T is -// already a reference type (some triggers pass `std::string &`). +// Trigger args are forwarded as `Ts...`. The previous `const Ts &...` +// form caused codegen to emit `const T &` for each arg in the apply +// lambda's parameter list, which is invalid C++ source text when T is +// already a reference (e.g. `const std::string & &` for triggers that +// pass `std::string &`). template class LightControlAction : public Action { public: using ApplyFn = void (*)(LightState *, LightCall &, Ts...); diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 370489770d..fd75e976ec 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -230,8 +230,11 @@ async def light_control_to_code(config, action_id, template_arg, args): f"call.set_effect(static_cast({_resolve_effect_index(config)}));" ) - # Match LightControlAction::ApplyFn signature: trigger args forwarded - # by-value as Ts... + # Forward trigger args as Ts... so each (type, name) pair passes through + # unchanged. Wrapping in `const &` here would emit invalid C++ source + # for trigger types that already carry a reference (e.g. `std::string &`) + # and fails on plain Python types (`float`/`bool`) which have no + # `.operator()` method. apply_args = [ (LightState.operator("ptr"), "parent"), (LightCall.operator("ref"), "call"), From a73a9516c2b0d683638f368c8d60a378730d306d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 16:53:10 -0500 Subject: [PATCH 1019/2214] [climate] Use const remove_reference_t & to avoid copies of non-ref Ts --- esphome/components/climate/__init__.py | 10 +++++++--- esphome/components/climate/automation.h | 8 +++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index a55c5b0f5a..9d228e0b72 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -526,11 +526,15 @@ async def climate_control_to_code(config, action_id, template_arg, args): else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match ControlAction::ApplyFn signature: trigger args forwarded - # by-value as Ts... + # Match ControlAction::ApplyFn signature: `const std::remove_reference_t &` + # for each trigger arg so non-reference Ts stay no-copy (`const T &`) and + # reference Ts collapse correctly without producing `const T & &`. apply_args = [ (ClimateCall.operator("ref"), "call"), - *args, + *( + (cg.RawExpression(f"const std::remove_reference_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ), ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index c556206926..76784f67a0 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -11,11 +11,13 @@ namespace esphome::climate { // Trigger args are forwarded to the apply function so user lambdas // (e.g. `target_temperature: !lambda "return x;"`) keep working. // -// Ts... must be forwarded by-value: `const T &` is ill-formed when T is -// already a reference type (some triggers pass `std::string &`). +// Trigger args are forwarded as `const std::remove_reference_t &...` +// (instead of `const Ts &...`) so codegen can emit the same form in the +// apply lambda's parameter list without producing `const T & &` for +// triggers whose Ts already carries a reference (e.g. `std::string &`). template class ControlAction : public Action { public: - using ApplyFn = void (*)(ClimateCall &, Ts...); + using ApplyFn = void (*)(ClimateCall &, const std::remove_reference_t &...); ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { From 5483b27bbf76f2f5f17d92543e7abccb92a1da31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 16:54:21 -0500 Subject: [PATCH 1020/2214] [light] Use const remove_reference_t & to avoid copies of non-ref Ts --- esphome/components/light/automation.h | 11 +++++------ esphome/components/light/automation.py | 13 +++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 8932822133..c345133264 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -37,14 +37,13 @@ template class ToggleAction : public A // Trigger args are forwarded to the apply function so user lambdas // (e.g. `brightness: !lambda "return x;"`) keep working. // -// Trigger args are forwarded as `Ts...`. The previous `const Ts &...` -// form caused codegen to emit `const T &` for each arg in the apply -// lambda's parameter list, which is invalid C++ source text when T is -// already a reference (e.g. `const std::string & &` for triggers that -// pass `std::string &`). +// Trigger args are forwarded as `const std::remove_reference_t &...` +// (instead of `const Ts &...`) so codegen can emit the same form in the +// apply lambda's parameter list without producing `const T & &` for +// triggers whose Ts already carries a reference (e.g. `std::string &`). template class LightControlAction : public Action { public: - using ApplyFn = void (*)(LightState *, LightCall &, Ts...); + using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_reference_t &...); LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} void play(const Ts &...x) override { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index fd75e976ec..3a086c68c3 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -230,15 +230,16 @@ async def light_control_to_code(config, action_id, template_arg, args): f"call.set_effect(static_cast({_resolve_effect_index(config)}));" ) - # Forward trigger args as Ts... so each (type, name) pair passes through - # unchanged. Wrapping in `const &` here would emit invalid C++ source - # for trigger types that already carry a reference (e.g. `std::string &`) - # and fails on plain Python types (`float`/`bool`) which have no - # `.operator()` method. + # Match LightControlAction::ApplyFn signature: `const std::remove_reference_t &` + # for each trigger arg so non-reference Ts stay no-copy (`const T &`) and + # reference Ts collapse correctly without producing `const T & &`. apply_args = [ (LightState.operator("ptr"), "parent"), (LightCall.operator("ref"), "call"), - *args, + *( + (cg.RawExpression(f"const std::remove_reference_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ), ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], From 1509db2a2c9c9de3ea80f265dd8e49a07daf3348 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 16:55:09 -0500 Subject: [PATCH 1021/2214] [fan] Use const remove_reference_t & to avoid copies of non-ref Ts --- esphome/components/fan/__init__.py | 10 +++++++--- esphome/components/fan/automation.h | 8 +++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index bc006a6602..23483adde7 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -369,11 +369,15 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match TurnOnAction::ApplyFn signature: trigger args forwarded - # by-value as Ts... + # Match TurnOnAction::ApplyFn signature: `const std::remove_reference_t &` + # for each trigger arg so non-reference Ts stay no-copy (`const T &`) and + # reference Ts collapse correctly without producing `const T & &`. apply_args = [ (FanCall.operator("ref"), "call"), - *args, + *( + (cg.RawExpression(f"const std::remove_reference_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ), ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index a5ed80aec6..9c14eab3da 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -13,11 +13,13 @@ namespace fan { // Trigger args are forwarded to the apply function so user lambdas // (e.g. `speed: !lambda "return x;"`) keep working. // -// Ts... must be forwarded by-value: `const T &` is ill-formed when T is -// already a reference type (some triggers pass `std::string &`). +// Trigger args are forwarded as `const std::remove_reference_t &...` +// (instead of `const Ts &...`) so codegen can emit the same form in the +// apply lambda's parameter list without producing `const T & &` for +// triggers whose Ts already carries a reference (e.g. `std::string &`). template class TurnOnAction : public Action { public: - using ApplyFn = void (*)(FanCall &, Ts...); + using ApplyFn = void (*)(FanCall &, const std::remove_reference_t &...); TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} void play(const Ts &...x) override { From 431385ebc1ea01cb194e7ae4ac066212a46ac6ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 16:56:14 -0500 Subject: [PATCH 1022/2214] [valve] Use const remove_reference_t & to avoid copies of non-ref Ts --- esphome/components/valve/__init__.py | 10 +++++++--- esphome/components/valve/automation.h | 8 +++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index bc7e2ba48d..e072f70d1b 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -251,11 +251,15 @@ async def valve_control_to_code(config, action_id, template_arg, args): else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match ControlAction::ApplyFn signature: trigger args forwarded - # by-value as Ts... + # Match ControlAction::ApplyFn signature: `const std::remove_reference_t &` + # for each trigger arg so non-reference Ts stay no-copy (`const T &`) and + # reference Ts collapse correctly without producing `const T & &`. apply_args = [ (ValveCall.operator("ref"), "call"), - *args, + *( + (cg.RawExpression(f"const std::remove_reference_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ), ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 81fcd12200..cc71999c35 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -53,11 +53,13 @@ template class ToggleAction : public Action { // Trigger args are forwarded to the apply function so user lambdas // (e.g. `position: !lambda "return x;"`) keep working. // -// Ts... must be forwarded by-value: `const T &` is ill-formed when T is -// already a reference type (some triggers pass `std::string &`). +// Trigger args are forwarded as `const std::remove_reference_t &...` +// (instead of `const Ts &...`) so codegen can emit the same form in the +// apply lambda's parameter list without producing `const T & &` for +// triggers whose Ts already carries a reference (e.g. `std::string &`). template class ControlAction : public Action { public: - using ApplyFn = void (*)(ValveCall &, Ts...); + using ApplyFn = void (*)(ValveCall &, const std::remove_reference_t &...); ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} void play(const Ts &...x) override { From b62d88a5d120264e77c83ca47bc16da89272f4cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:25:30 -0500 Subject: [PATCH 1023/2214] [valve] Forward trigger args as Ts... so inner-lambda calls type-check --- esphome/components/valve/__init__.py | 11 ++++------- esphome/components/valve/automation.h | 13 ++++++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index e072f70d1b..f39eb4dc8d 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -251,15 +251,12 @@ async def valve_control_to_code(config, action_id, template_arg, args): else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match ControlAction::ApplyFn signature: `const std::remove_reference_t &` - # for each trigger arg so non-reference Ts stay no-copy (`const T &`) and - # reference Ts collapse correctly without producing `const T & &`. + # Match ControlAction::ApplyFn signature: forward trigger args as Ts... + # so the apply lambda's parameter types match both ApplyFn and the + # inner field lambdas (which are generated from `args` directly). apply_args = [ (ValveCall.operator("ref"), "call"), - *( - (cg.RawExpression(f"const std::remove_reference_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ), + *args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index cc71999c35..88c4ed760d 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -53,13 +53,16 @@ template class ToggleAction : public Action { // Trigger args are forwarded to the apply function so user lambdas // (e.g. `position: !lambda "return x;"`) keep working. // -// Trigger args are forwarded as `const std::remove_reference_t &...` -// (instead of `const Ts &...`) so codegen can emit the same form in the -// apply lambda's parameter list without producing `const T & &` for -// triggers whose Ts already carries a reference (e.g. `std::string &`). +// Trigger args are forwarded as `Ts...`. The previous `const Ts &...` +// form caused codegen to emit `const T &` for each arg in the apply +// lambda's parameter list, which is invalid C++ source text when T is +// already a reference (e.g. `const std::string & &` for triggers that +// pass `std::string &`). Forwarding `Ts...` lets the codegen reuse the +// trigger's `args` types unchanged for both the apply lambda and any +// inner field lambdas, so they always type-match. template class ControlAction : public Action { public: - using ApplyFn = void (*)(ValveCall &, const std::remove_reference_t &...); + using ApplyFn = void (*)(ValveCall &, Ts...); ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} void play(const Ts &...x) override { From 22252ef5b52166dc8faa2ce26a07d2f6ee8a3d35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:26:39 -0500 Subject: [PATCH 1024/2214] [light] Forward trigger args as Ts... so inner-lambda calls type-check --- esphome/components/light/automation.h | 13 ++++++++----- esphome/components/light/automation.py | 11 ++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index c345133264..3bd0d5afbd 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -37,13 +37,16 @@ template class ToggleAction : public A // Trigger args are forwarded to the apply function so user lambdas // (e.g. `brightness: !lambda "return x;"`) keep working. // -// Trigger args are forwarded as `const std::remove_reference_t &...` -// (instead of `const Ts &...`) so codegen can emit the same form in the -// apply lambda's parameter list without producing `const T & &` for -// triggers whose Ts already carries a reference (e.g. `std::string &`). +// Trigger args are forwarded as `Ts...`. The previous `const Ts &...` +// form caused codegen to emit `const T &` for each arg in the apply +// lambda's parameter list, which is invalid C++ source text when T is +// already a reference (e.g. `const std::string & &` for triggers that +// pass `std::string &`). Forwarding `Ts...` lets the codegen reuse the +// trigger's `args` types unchanged for both the apply lambda and any +// inner field lambdas, so they always type-match. template class LightControlAction : public Action { public: - using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_reference_t &...); + using ApplyFn = void (*)(LightState *, LightCall &, Ts...); LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} void play(const Ts &...x) override { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 3a086c68c3..c796b97db0 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -230,16 +230,13 @@ async def light_control_to_code(config, action_id, template_arg, args): f"call.set_effect(static_cast({_resolve_effect_index(config)}));" ) - # Match LightControlAction::ApplyFn signature: `const std::remove_reference_t &` - # for each trigger arg so non-reference Ts stay no-copy (`const T &`) and - # reference Ts collapse correctly without producing `const T & &`. + # Match LightControlAction::ApplyFn signature: forward trigger args as Ts... + # so the apply lambda's parameter types match both ApplyFn and the + # inner field lambdas (which are generated from `args` directly). apply_args = [ (LightState.operator("ptr"), "parent"), (LightCall.operator("ref"), "call"), - *( - (cg.RawExpression(f"const std::remove_reference_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ), + *args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], From 102618b11c7af8187357f28d082b9846ab7b61aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:28:38 -0500 Subject: [PATCH 1025/2214] [climate] Normalize trigger args to const remove_cvref_t & --- esphome/components/climate/__init__.py | 19 +++++++++++-------- esphome/components/climate/automation.h | 12 +++++++----- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 9d228e0b72..fc1b0f368e 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -506,6 +506,15 @@ async def climate_control_to_code(config, action_id, template_arg, args): (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), ) + # Normalize trigger args to `const std::remove_cvref_t &` so the + # apply lambda and any inner field lambdas (generated below via + # `process_lambda`) share one parameter spelling that's well-formed for + # any T (value, ref, or const-ref). Matches ControlAction::ApplyFn. + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ] + fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] @@ -513,7 +522,7 @@ async def climate_control_to_code(config, action_id, template_arg, args): if (value := config.get(conf_key)) is None: continue if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=type_) + inner = await cg.process_lambda(value, normalized_args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") elif type_ is cg.std_string: # Static custom strings: emit a flash literal and pass the @@ -526,15 +535,9 @@ async def climate_control_to_code(config, action_id, template_arg, args): else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match ControlAction::ApplyFn signature: `const std::remove_reference_t &` - # for each trigger arg so non-reference Ts stay no-copy (`const T &`) and - # reference Ts collapse correctly without producing `const T & &`. apply_args = [ (ClimateCall.operator("ref"), "call"), - *( - (cg.RawExpression(f"const std::remove_reference_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ), + *normalized_args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index 76784f67a0..6ac9bd8bae 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -11,13 +11,15 @@ namespace esphome::climate { // Trigger args are forwarded to the apply function so user lambdas // (e.g. `target_temperature: !lambda "return x;"`) keep working. // -// Trigger args are forwarded as `const std::remove_reference_t &...` -// (instead of `const Ts &...`) so codegen can emit the same form in the -// apply lambda's parameter list without producing `const T & &` for -// triggers whose Ts already carries a reference (e.g. `std::string &`). +// Trigger args are normalized to `const std::remove_cvref_t &...` so +// the codegen can emit a matching parameter list for both the apply lambda +// and any inner field lambdas without producing invalid C++ source text +// (e.g. `const T & &` if Ts already carries a reference, or `const const +// T &` if Ts already carries a const). This keeps trigger args no-copy +// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. template class ControlAction : public Action { public: - using ApplyFn = void (*)(ClimateCall &, const std::remove_reference_t &...); + using ApplyFn = void (*)(ClimateCall &, const std::remove_cvref_t &...); ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { From 10ea8c34921b27687da6e5f806c2f6b60ca65e17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:29:25 -0500 Subject: [PATCH 1026/2214] [light] Normalize trigger args to const remove_cvref_t & --- esphome/components/light/automation.h | 15 +++++++-------- esphome/components/light/automation.py | 18 ++++++++++++------ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 3bd0d5afbd..993d4a2ea6 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -37,16 +37,15 @@ template class ToggleAction : public A // Trigger args are forwarded to the apply function so user lambdas // (e.g. `brightness: !lambda "return x;"`) keep working. // -// Trigger args are forwarded as `Ts...`. The previous `const Ts &...` -// form caused codegen to emit `const T &` for each arg in the apply -// lambda's parameter list, which is invalid C++ source text when T is -// already a reference (e.g. `const std::string & &` for triggers that -// pass `std::string &`). Forwarding `Ts...` lets the codegen reuse the -// trigger's `args` types unchanged for both the apply lambda and any -// inner field lambdas, so they always type-match. +// Trigger args are normalized to `const std::remove_cvref_t &...` so +// the codegen can emit a matching parameter list for both the apply lambda +// and any inner field lambdas without producing invalid C++ source text +// (e.g. `const T & &` if Ts already carries a reference, or `const const +// T &` if Ts already carries a const). This keeps trigger args no-copy +// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. template class LightControlAction : public Action { public: - using ApplyFn = void (*)(LightState *, LightCall &, Ts...); + using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t &...); LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} void play(const Ts &...x) override { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index c796b97db0..cef774af38 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -200,6 +200,15 @@ async def light_control_to_code(config, action_id, template_arg, args): (CONF_WARM_WHITE, "set_warm_white", cg.float_), ) + # Normalize trigger args to `const std::remove_cvref_t &` so the + # apply lambda and any inner field lambdas (generated below via + # `process_lambda`) share one parameter spelling that's well-formed for + # any T (value, ref, or const-ref). Matches LightControlAction::ApplyFn. + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ] + fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] @@ -208,7 +217,7 @@ async def light_control_to_code(config, action_id, template_arg, args): continue value = config[conf_key] if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=type_) + inner = await cg.process_lambda(value, normalized_args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") @@ -216,7 +225,7 @@ async def light_control_to_code(config, action_id, template_arg, args): if CONF_EFFECT in config: if isinstance(config[CONF_EFFECT], Lambda): inner_lambda = await cg.process_lambda( - config[CONF_EFFECT], args, return_type=cg.std_string + config[CONF_EFFECT], normalized_args, return_type=cg.std_string ) body_lines.append( f"{{ auto __effect_s = ({inner_lambda})({fwd_args});\n" @@ -230,13 +239,10 @@ async def light_control_to_code(config, action_id, template_arg, args): f"call.set_effect(static_cast({_resolve_effect_index(config)}));" ) - # Match LightControlAction::ApplyFn signature: forward trigger args as Ts... - # so the apply lambda's parameter types match both ApplyFn and the - # inner field lambdas (which are generated from `args` directly). apply_args = [ (LightState.operator("ptr"), "parent"), (LightCall.operator("ref"), "call"), - *args, + *normalized_args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], From e4de502bd4882c50ba26ee98ac9c05357f6adade Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:30:01 -0500 Subject: [PATCH 1027/2214] [fan] Normalize trigger args to const remove_cvref_t & --- esphome/components/fan/__init__.py | 19 +++++++++++-------- esphome/components/fan/automation.h | 12 +++++++----- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 23483adde7..3949f16d2e 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -358,26 +358,29 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): (CONF_DIRECTION, "set_direction", FanDirection), ) + # Normalize trigger args to `const std::remove_cvref_t &` so the + # apply lambda and any inner field lambdas (generated below via + # `process_lambda`) share one parameter spelling that's well-formed for + # any T (value, ref, or const-ref). Matches TurnOnAction::ApplyFn. + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ] + fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] for conf_key, setter, type_ in FIELDS: if (value := config.get(conf_key)) is None: continue if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=type_) + inner = await cg.process_lambda(value, normalized_args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match TurnOnAction::ApplyFn signature: `const std::remove_reference_t &` - # for each trigger arg so non-reference Ts stay no-copy (`const T &`) and - # reference Ts collapse correctly without producing `const T & &`. apply_args = [ (FanCall.operator("ref"), "call"), - *( - (cg.RawExpression(f"const std::remove_reference_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ), + *normalized_args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 9c14eab3da..577c9ce600 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -13,13 +13,15 @@ namespace fan { // Trigger args are forwarded to the apply function so user lambdas // (e.g. `speed: !lambda "return x;"`) keep working. // -// Trigger args are forwarded as `const std::remove_reference_t &...` -// (instead of `const Ts &...`) so codegen can emit the same form in the -// apply lambda's parameter list without producing `const T & &` for -// triggers whose Ts already carries a reference (e.g. `std::string &`). +// Trigger args are normalized to `const std::remove_cvref_t &...` so +// the codegen can emit a matching parameter list for both the apply lambda +// and any inner field lambdas without producing invalid C++ source text +// (e.g. `const T & &` if Ts already carries a reference, or `const const +// T &` if Ts already carries a const). This keeps trigger args no-copy +// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. template class TurnOnAction : public Action { public: - using ApplyFn = void (*)(FanCall &, const std::remove_reference_t &...); + using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t &...); TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} void play(const Ts &...x) override { From 7c667bb9f485386a0dcbb52ada19a723266e4b60 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:31:09 -0500 Subject: [PATCH 1028/2214] [valve] Normalize trigger args to const remove_cvref_t & --- esphome/components/valve/__init__.py | 16 +++++++++++----- esphome/components/valve/automation.h | 15 +++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index f39eb4dc8d..d82a9fdec2 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -240,23 +240,29 @@ async def valve_control_to_code(config, action_id, template_arg, args): (CONF_POSITION, "set_position", cg.float_), ) + # Normalize trigger args to `const std::remove_cvref_t &` so the + # apply lambda and any inner field lambdas (generated below via + # `process_lambda`) share one parameter spelling that's well-formed for + # any T (value, ref, or const-ref). Matches ControlAction::ApplyFn. + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ] + fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] for conf_key, setter, type_ in FIELDS: if (value := config.get(conf_key)) is None: continue if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=type_) + inner = await cg.process_lambda(value, normalized_args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match ControlAction::ApplyFn signature: forward trigger args as Ts... - # so the apply lambda's parameter types match both ApplyFn and the - # inner field lambdas (which are generated from `args` directly). apply_args = [ (ValveCall.operator("ref"), "call"), - *args, + *normalized_args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 88c4ed760d..27c0e329f0 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -53,16 +53,15 @@ template class ToggleAction : public Action { // Trigger args are forwarded to the apply function so user lambdas // (e.g. `position: !lambda "return x;"`) keep working. // -// Trigger args are forwarded as `Ts...`. The previous `const Ts &...` -// form caused codegen to emit `const T &` for each arg in the apply -// lambda's parameter list, which is invalid C++ source text when T is -// already a reference (e.g. `const std::string & &` for triggers that -// pass `std::string &`). Forwarding `Ts...` lets the codegen reuse the -// trigger's `args` types unchanged for both the apply lambda and any -// inner field lambdas, so they always type-match. +// Trigger args are normalized to `const std::remove_cvref_t &...` so +// the codegen can emit a matching parameter list for both the apply lambda +// and any inner field lambdas without producing invalid C++ source text +// (e.g. `const T & &` if Ts already carries a reference, or `const const +// T &` if Ts already carries a const). This keeps trigger args no-copy +// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. template class ControlAction : public Action { public: - using ApplyFn = void (*)(ValveCall &, Ts...); + using ApplyFn = void (*)(ValveCall &, const std::remove_cvref_t &...); ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} void play(const Ts &...x) override { From 37a44a8a6f6ac20d559e040043cebe35acb9991d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:39:39 -0500 Subject: [PATCH 1029/2214] [tests] Fix flaky host_mode_climate_basic_state by ignoring prior MEMORY state The fixture shares the `host-climate-test` build dir with host_mode_climate_control.yaml. When the control test runs first and leaves the thermostat in HEAT mode, MEMORY restore on the next basic_state boot picked up HEAT and the initial-state assertion failed (`assert ClimateMode.HEAT == ClimateMode.OFF`). --- tests/integration/fixtures/host_mode_climate_basic_state.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/fixtures/host_mode_climate_basic_state.yaml b/tests/integration/fixtures/host_mode_climate_basic_state.yaml index f79d684fc6..744b418d0f 100644 --- a/tests/integration/fixtures/host_mode_climate_basic_state.yaml +++ b/tests/integration/fixtures/host_mode_climate_basic_state.yaml @@ -28,6 +28,10 @@ climate: min_temperature: 15.0 max_temperature: 32.0 temperature_step: 0.1 + # Don't restore previous state from flash — this fixture shares the + # `host-climate-test` build dir with host_mode_climate_control.yaml, so a + # prior run of that test could leave the thermostat in HEAT/COOL. + on_boot_restore_from: default_preset default_preset: home preset: - name: "away" From 1f9afebbf539b1c51ee80020882f81d8d9b3987b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:52:55 -0500 Subject: [PATCH 1030/2214] [ota] Make partition-table verify errors actionable When the running app does not fit any slot in the new partition table, the user has almost certainly picked the wrong migration .bin for their device. Replace the generic "No compatible app partition found" error with one that prints the running app's offset, used size, and the size limit a migration method must clear, plus an explicit reassurance that no flash content has been modified yet. Same treatment for the otadata-overlap case: include the running app offset/size and tell the user to pick a different migration method. Verification phase is non-destructive, so these errors are recoverable by retrying with the correct .bin -- the new wording makes that explicit so users don't lose time on a brick scare they aren't in. No code-flow change; only log message wording. --- esphome/components/ota/ota_partitions_esp_idf.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index 3a79136c61..2a2ed577f1 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -131,7 +131,14 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a } if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { - ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); + // Most likely cause: the user picked the wrong migration .bin for their running app's size. + // Rejecting here is non-destructive (no flash op has run yet); the user can safely retry with + // a different .bin. Log enough info that they can pick the right method without guessing. + ESP_LOGE(TAG, + "Running app at 0x%X (%u bytes used) does not fit any compatible slot in the new " + "partition table. Pick a migration method whose size limit is at least %u bytes and " + "retry; no flash content was modified.", + running_app_offset, running_app_size, running_app_size); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (app_partitions_found < 2) { @@ -147,7 +154,11 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (otadata_overlap) { - ESP_LOGE(TAG, "New otadata partition overlaps with running app"); + ESP_LOGE(TAG, + "New otadata partition overlaps with the running app at 0x%X (size %u). The chosen " + "partition table is not compatible with this device's current flash layout; pick a " + "different migration method.", + running_app_offset, running_app_size); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } From 59de35a7b8d46dae6bf1fff8be24844df08fefe8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:52:58 -0500 Subject: [PATCH 1031/2214] [fan] Cover fan.turn_on inside on_preset_set (StringRef Ts) in tests --- tests/components/fan/common.yaml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 91f799432c..76508f391e 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -9,6 +9,14 @@ fan: has_oscillating: true has_direction: true speed_count: 3 + # Exercise fan.turn_on inside a trigger whose Ts pack is non-empty + # (StringRef from on_preset_set) so the apply-lambda + inner-lambda + # codegen runs through the cvref-normalized path. + on_preset_set: + then: + - fan.turn_on: + id: test_fan + speed: !lambda "return x.empty() ? 1 : 3;" # Test lambdas using get_preset_mode() which returns StringRef # These examples match the migration guide in the PR description @@ -89,8 +97,11 @@ button: id: test_fan speed: !lambda 'return 1;' -# Exercise fan.turn_on inside a trigger with non-empty Ts (number on_value -# passes float). +# Exercise fan.turn_on inside triggers with non-empty Ts: +# - number.on_value: Ts = float (Python value type; previously raised +# AttributeError on .operator("const")) +# - fan.on_preset_set: Ts = StringRef (already a value-type wrapper around +# a const char * + size; tests the cvref-normalized inner-lambda path) number: - platform: template id: fan_speed_number From 0f174ee62620818c90cb73bab9e9de5f98e0f483 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:55:40 -0500 Subject: [PATCH 1032/2214] [api] Fall back to owning types for service array args used after a delay (#16140) --- esphome/components/api/__init__.py | 45 ++++++++++++++++++++------- tests/components/api/common-base.yaml | 18 +++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ad778f20ad..ca74483a2b 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -72,17 +72,35 @@ APIUnregisterServiceCallAction = api_ns.class_( UserServiceTrigger = api_ns.class_("UserServiceTrigger", automation.Trigger) ListEntitiesServicesArgument = api_ns.class_("ListEntitiesServicesArgument") -SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = { +# Owning element type for each YAML service variable type. Used to derive both +# the zero-copy native types and the owning fallback types below. +_SERVICE_ARG_SCALAR_TYPES: dict[str, MockObj] = { "bool": cg.bool_, "int": cg.int32, "float": cg.float_, + "string": cg.std_string, +} +SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = { + # Scalars are passed by value; string uses a non-owning view into rx_buf_. + **_SERVICE_ARG_SCALAR_TYPES, "string": cg.StringRef, - "bool[]": cg.FixedVector.template(cg.bool_).operator("const").operator("ref"), - "int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"), - "float[]": cg.FixedVector.template(cg.float_).operator("const").operator("ref"), - "string[]": cg.FixedVector.template(cg.std_string) - .operator("const") - .operator("ref"), + # Arrays are passed as non-owning const references into rx_buf_. + **{ + f"{name}[]": cg.FixedVector.template(t).operator("const").operator("ref") + for name, t in _SERVICE_ARG_SCALAR_TYPES.items() + }, +} +# Owning fallback types used when the action chain contains non-synchronous actions +# (delay, wait_until, script.wait, etc.). The default non-owning types reference +# storage in the receive buffer, which is reused once the synchronous portion of +# the chain returns. FixedVector is also non-copyable, so the deferred lambda +# capture in DelayAction::play_complex would fail to compile. +SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = { + "string": cg.std_string, + **{ + f"{name}[]": cg.std_vector.template(t) + for name, t in _SERVICE_ARG_SCALAR_TYPES.items() + }, } CONF_ENCRYPTION = "encryption" CONF_BATCH_DELAY = "batch_delay" @@ -381,17 +399,20 @@ async def to_code(config: ConfigType) -> None: func_args.append((cg.bool_, "return_response")) # Check if action chain has non-synchronous actions that would make - # non-owning StringRef dangle (rx_buf_ reused after delay) + # non-owning args (StringRef, const FixedVector&) dangle once the + # rx_buf_ is reused after a delay/wait_until/script.wait/etc. The + # FixedVector references would also fail to compile because they + # are non-copyable and DelayAction captures args by value. has_non_synchronous = automation.has_non_synchronous_actions( conf.get(CONF_THEN, []) ) service_arg_names: list[str] = [] for name, var_ in conf[CONF_VARIABLES].items(): - native = SERVICE_ARG_NATIVE_TYPES[var_] - # Fall back to std::string for string args if non-synchronous actions exist - if has_non_synchronous and native is cg.StringRef: - native = cg.std_string + if has_non_synchronous and var_ in SERVICE_ARG_FALLBACK_TYPES: + native = SERVICE_ARG_FALLBACK_TYPES[var_] + else: + native = SERVICE_ARG_NATIVE_TYPES[var_] service_template_args.append(native) func_args.append((native, name)) service_arg_names.append(name) diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index c766b61b13..504c52a57b 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -91,6 +91,24 @@ api: - float_arr.size() - string_arr[0].c_str() - string_arr.size() + # Test array + string args used after a non-synchronous action (delay). + # The default non-owning types (StringRef, const FixedVector&) would + # dangle once rx_buf_ is reused, and FixedVector is non-copyable so + # DelayAction's lambda capture would fail to compile. The api codegen + # must fall back to owning std::string / std::vector here. + - action: array_with_delay + variables: + name: string + int_arr: int[] + string_arr: string[] + then: + - delay: 20ms + - logger.log: + format: "Delayed: %s (%u ints, %u strings)" + args: + - name.c_str() + - int_arr.size() + - string_arr.size() # Test ContinuationAction (IfAction with then/else branches) - action: test_if_action variables: From da06bb9c7f66d81b22784b2fa2ecec4316c44b43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 18:19:24 -0500 Subject: [PATCH 1033/2214] [ota] Skip safe_shutdown after partition-table OTA For OTA_TYPE_UPDATE_PARTITION_TABLE the success path runs nvs_flash_deinit() before the final write, which leaves every preference handle held by other components invalid. App.safe_reboot() calls on_safe_shutdown() which tries to flush preferences, and each flush fails with ESP_ERR_NVS_INVALID_HANDLE -- noisy log spam during the reboot window. App.reboot() skips the safe-shutdown callbacks and goes straight to esp_restart(). For partition-table OTAs there is nothing useful to flush (NVS handles are already dead, the device is moments from a reboot anyway), so reboot directly. App-OTA path is unchanged: safe_reboot() still runs on_safe_shutdown so preferences are saved on the way out. --- esphome/components/esphome/ota/ota_esphome.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e6e75bd6e0..3ce3f2302d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -498,6 +498,13 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_COMPLETED, 100.0f, 0); #endif delay(100); // NOLINT +#ifdef USE_OTA_PARTITIONS + if (ota_type == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + // Skip on_safe_shutdown: nvs_flash_deinit() has already invalidated open NVS handles, so + // preferences flush would emit ESP_ERR_NVS_INVALID_HANDLE for every entry. Reboot directly. + App.reboot(); + } +#endif App.safe_reboot(); error: From 85e1e4b95ec6ec3d9c7ba3a5819d86d1ae1fb8a6 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 4 May 2026 02:37:32 +0200 Subject: [PATCH 1034/2214] [zephyr] feed watchdog early. Otherwise OTA may rollback. (#16218) --- esphome/components/zephyr/hal.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index 6c405b650e..ad8ed5c95c 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -46,6 +46,8 @@ void arch_init() { } } #endif + // feed watchdog early. Otherwise OTA may rollback. + arch_feed_wdt(); } void arch_feed_wdt() { From 7cfab58a0558c8070aa9475f15758c4f443a612d Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 4 May 2026 00:42:10 +0000 Subject: [PATCH 1035/2214] [ota] Add partition table update functionality to ota component (#15780) 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 --- esphome/__main__.py | 82 +++++ esphome/components/esphome/ota/__init__.py | 13 +- .../components/esphome/ota/ota_esphome.cpp | 44 ++- esphome/components/esphome/ota/ota_esphome.h | 4 + esphome/components/ota/ota_backend.h | 3 + .../ota/ota_backend_arduino_libretiny.cpp | 5 +- .../ota/ota_backend_arduino_libretiny.h | 2 +- .../ota/ota_backend_arduino_rp2040.cpp | 5 +- .../ota/ota_backend_arduino_rp2040.h | 2 +- .../components/ota/ota_backend_esp8266.cpp | 5 +- esphome/components/ota/ota_backend_esp8266.h | 2 +- .../components/ota/ota_backend_esp_idf.cpp | 56 ++- esphome/components/ota/ota_backend_esp_idf.h | 35 +- esphome/components/ota/ota_backend_host.cpp | 4 +- esphome/components/ota/ota_backend_host.h | 2 +- .../components/ota/ota_partitions_esp_idf.cpp | 327 ++++++++++++++++++ esphome/core/__init__.py | 11 + esphome/espota2.py | 17 +- .../ota/test-partition_access.esp32-idf.yaml | 5 + .../partition_tables/esp_idf_hello_world.bin | Bin 0 -> 3072 bytes .../esphome_dashboard_firmware.bin | Bin 0 -> 3072 bytes .../partition_tables/esphome_default.bin | Bin 0 -> 3072 bytes tests/unit_tests/test_espota2.py | 68 ++++ tests/unit_tests/test_main.py | 235 ++++++++++++- 24 files changed, 912 insertions(+), 15 deletions(-) create mode 100644 esphome/components/ota/ota_partitions_esp_idf.cpp create mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml create mode 100644 tests/unit_tests/fixtures/partition_tables/esp_idf_hello_world.bin create mode 100644 tests/unit_tests/fixtures/partition_tables/esphome_dashboard_firmware.bin create mode 100644 tests/unit_tests/fixtures/partition_tables/esphome_default.bin diff --git a/esphome/__main__.py b/esphome/__main__.py index e7ce36ae2d..9ab2dee189 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1091,6 +1091,15 @@ def upload_program( port_type = get_port_type(host) + # MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by + # _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths. + if port_type in (PortType.SERIAL, PortType.BOOTSEL) and getattr( + args, "partition_table", False + ): + raise EsphomeError( + "The option --partition-table can only be used for Over The Air updates." + ) + if port_type == PortType.BOOTSEL: exit_code = upload_using_picotool(config) # Return None for device - BOOTSEL can't be used for logging, @@ -1131,12 +1140,80 @@ def upload_program( binary = CORE.firmware_bin ota_type = espota2.OTA_TYPE_UPDATE_APP + if getattr(args, "partition_table", False): + # Fail fast if the resolved ESPHome OTA config does not enable allow_partition_access. + # The device-side handshake also rejects this with "Device only supports app updates", + # but checking here surfaces the misconfiguration before opening a network connection. + if not ota_conf.get("allow_partition_access"): + raise EsphomeError( + "The option --partition-table requires 'allow_partition_access: true' on the " + "esphome OTA platform in the device's YAML configuration. Add it, recompile, " + "flash a build with the option enabled, and then retry --partition-table." + ) + binary = CORE.partition_table_bin + ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE if getattr(args, "file", None) is not None: binary = Path(args.file) + if ota_type == espota2.OTA_TYPE_UPDATE_PARTITION_TABLE: + _validate_partition_table_binary(binary) + return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) +# 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 +# trailing checksum entry. Padding past the last entry is 0xFF. The full table is +# exactly ESP_PARTITION_TABLE_MAX_LEN bytes. +_PARTITION_TABLE_MAX_LEN = 0xC00 +_ESP_PARTITION_MAGIC = 0x50AA +_ESP_PARTITION_MAGIC_MD5 = 0xEBEB + + +def _validate_partition_table_binary(binary: Path) -> None: + """Validate that ``binary`` looks like an ESP32 partition table image. + + Catches common mistakes (wrong file, truncated build output, swapped --file path) + before opening a network connection so the failure mode is a clear local error + instead of a post-handshake device rejection. + """ + try: + data = binary.read_bytes() + except OSError as err: + raise EsphomeError( + f"Cannot read partition table file '{binary}': {err}" + ) from err + + if len(data) != _PARTITION_TABLE_MAX_LEN: + raise EsphomeError( + f"Partition table file '{binary}' has wrong size: expected " + f"{_PARTITION_TABLE_MAX_LEN} bytes, got {len(data)}. " + "Pass the partition table image (e.g. partitions.bin / partition-table.bin), " + "not the firmware image." + ) + + first_magic = data[0] | (data[1] << 8) + if first_magic != _ESP_PARTITION_MAGIC: + raise EsphomeError( + f"Partition table file '{binary}' does not start with the expected " + f"partition magic 0x{_ESP_PARTITION_MAGIC:04X} (got 0x{first_magic:04X}). " + "This file does not look like an ESP32 partition table." + ) + + # The MD5 checksum entry is required: without it the device-side + # esp_partition_table_verify will accept the table but the bootloader will + # refuse to boot from it. Scan the 32-byte entries for the MD5 magic. + if not any( + (data[off] | (data[off + 1] << 8)) == _ESP_PARTITION_MAGIC_MD5 + for off in range(0, _PARTITION_TABLE_MAX_LEN, 32) + ): + raise EsphomeError( + f"Partition table file '{binary}' is missing the MD5 checksum entry. " + "Regenerate the partition table with gen_esp32part.py or rebuild the project." + ) + + def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) @@ -1804,6 +1881,11 @@ def parse_args(argv): "--file", help="Manually specify the binary file to upload.", ) + parser_upload.add_argument( + "--partition-table", + help="Upload as partition table (OTA).", + action="store_true", + ) parser_logs = subparsers.add_parser( "logs", diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bfa5ffb55c..ee3b7f0c20 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,11 +16,13 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv from esphome.types import ConfigType +CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access" + _LOGGER = logging.getLogger(__name__) @@ -75,6 +77,10 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) + if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS) and not CORE.is_esp32: + raise cv.Invalid( + f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" + ) else: new_ota_conf.append(ota_conf) @@ -125,6 +131,7 @@ CONFIG_SCHEMA = cv.All( ln882x=8820, rtl87xx=8892, ): cv.port, + cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.string, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" @@ -159,6 +166,10 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PASSWORD]: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) + + if config.get(CONF_ALLOW_PARTITION_ACCESS): + cg.add_define("USE_OTA_PARTITIONS") + # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 955b4dc96f..3ce3f2302d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -87,6 +87,10 @@ void ESPHomeOTAComponent::setup() { // no wakes fire and loop() falls back to the self-disable safety net. esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif + +#ifdef USE_OTA_PARTITIONS + ota::get_running_app_position(this->running_app_offset_, this->running_app_size_); +#endif } void ESPHomeOTAComponent::dump_config() { @@ -100,6 +104,29 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Password configured"); } #endif +#ifdef USE_OTA_PARTITIONS + ESP_LOGCONFIG(TAG, + " Partition access allowed\n" + " Running app:\n" + " Partition address: 0x%X\n" + " Used size: %zu bytes (0x%X)", + this->running_app_offset_, this->running_app_size_, this->running_app_size_); + +#ifdef USE_ESP32 + ESP_LOGCONFIG(TAG, + " Partition table:\n" + " %-12s %-4s %-8s %-10s %-10s", + "Name", "Type", "Subtype", "Address", "Size"); + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *partition = esp_partition_get(it); + ESP_LOGCONFIG(TAG, " %-12s 0x%-2X 0x%-6X 0x%-8" PRIX32 " 0x%-8" PRIX32, partition->label, partition->type, + partition->subtype, partition->address, partition->size); + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); +#endif +#endif } void ESPHomeOTAComponent::loop() { @@ -118,6 +145,7 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. @@ -215,6 +243,9 @@ void ESPHomeOTAComponent::handle_handshake_() { static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack"); this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); +#ifdef USE_OTA_PARTITIONS + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; +#endif } else { this->handshake_buf_[0] = supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; @@ -347,10 +378,12 @@ void ESPHomeOTAComponent::handle_data_() { (static_cast(buf[2]) << 8) | buf[3]; ESP_LOGV(TAG, "Size is %u bytes", ota_size); +#ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } +#endif // Now that we've passed authentication and are actually // starting the update, set the warning status and notify @@ -362,8 +395,8 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif - // This will block for a few seconds as it locks flash - error_code = this->backend_->begin(ota_size); + // begin() may block for a few seconds while it locks flash. + error_code = this->backend_->begin(ota_size, ota_type); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) update_started = true; @@ -465,6 +498,13 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_COMPLETED, 100.0f, 0); #endif delay(100); // NOLINT +#ifdef USE_OTA_PARTITIONS + if (ota_type == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + // Skip on_safe_shutdown: nvs_flash_deinit() has already invalidated open NVS handles, so + // preferences flush would emit ESP_ERR_NVS_INVALID_HANDLE for every entry. Reboot directly. + App.reboot(); + } +#endif App.safe_reboot(); error: diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 5043bc33ef..0053ca6969 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -98,6 +98,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { uint32_t client_connect_time_{0}; static constexpr size_t HANDSHAKE_BUF_SIZE = 5; +#ifdef USE_OTA_PARTITIONS + uint32_t running_app_offset_{0}; + size_t running_app_size_{0}; +#endif uint16_t port_; uint8_t handshake_buf_[HANDSHAKE_BUF_SIZE]; OTAState ota_state_{OTAState::IDLE}; diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 7e7b0f6523..5888a8e12d 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -42,6 +42,8 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C, OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D, OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E, + OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F, + OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -55,6 +57,7 @@ enum OTAState { enum OTAType : uint8_t { OTA_TYPE_UPDATE_APP = 0x00, + OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01, }; /** Listener interface for OTA state changes. diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index dcd71e92dd..4cc99202a7 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -13,7 +13,10 @@ static const char *const TAG = "ota.arduino_libretiny"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size) { +OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ota_type) { + if (ota_type != OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } // Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA // where the exact firmware size is unknown due to multipart encoding if (image_size == 0) { diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index 3d426e6759..c2716a44d1 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -8,7 +8,7 @@ namespace esphome::ota { class ArduinoLibreTinyOTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2040.cpp index bc8ef812e6..0ca0602519 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2040.cpp @@ -15,7 +15,10 @@ static const char *const TAG = "ota.arduino_rp2040"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size) { +OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_type) { + if (ota_type != OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } // OTA size of 0 is not currently handled, but // web_server is not supported for RP2040, so this is not an issue. bool ret = Update.begin(image_size, U_FLASH); diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2040.h index 05bd2f5cc4..d04d5c1a84 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2040.h @@ -10,7 +10,7 @@ namespace esphome::ota { class ArduinoRP2040OTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 7c9d392532..6a678fb419 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -50,7 +50,10 @@ static const char *const TAG = "ota.esp8266"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) { +OTAResponseTypes ESP8266OTABackend::begin(size_t image_size, OTAType ota_type) { + if (ota_type != OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } // Handle UPDATE_SIZE_UNKNOWN (0) by calculating available space if (image_size == 0) { // Round down to sector boundary: subtract one sector, then mask to sector alignment diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index b364e216a3..21b5c12c2d 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -14,7 +14,7 @@ namespace esphome::ota { /// by not having a global Update object in .bss. class ESP8266OTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index b4b38a192f..42d106bf1f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -16,7 +16,30 @@ static const char *const TAG = "ota.idf"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes IDFOTABackend::begin(size_t image_size) { +OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { +#ifdef USE_OTA_PARTITIONS + this->ota_type_ = ota_type; + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + // Reject any size other than ESP_PARTITION_TABLE_MAX_LEN: under- leaves stale bytes from the + // previous table; over- can't fit the reserved region. + if (image_size != ESP_PARTITION_TABLE_MAX_LEN) { + ESP_LOGE(TAG, "Wrong partition table size: expected %u bytes, got %zu", ESP_PARTITION_TABLE_MAX_LEN, image_size); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + memset(this->buf_, 0xFF, sizeof this->buf_); + this->buf_written_ = 0; + this->image_size_ = image_size; + this->md5_.init(); + return OTA_RESPONSE_OK; + } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } +#else + if (ota_type != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } +#endif #ifdef USE_OTA_ROLLBACK // If we're starting an OTA, the current boot is good enough - mark it valid // to prevent rollback and allow the OTA to proceed even if the safe mode @@ -52,6 +75,21 @@ void IDFOTABackend::set_update_md5(const char *expected_md5) { } OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { +#ifdef USE_OTA_PARTITIONS + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + if (len > PARTITION_TABLE_BUFFER_SIZE - this->buf_written_) { + ESP_LOGE(TAG, "Wrong partition table size"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + memcpy(this->buf_ + this->buf_written_, data, len); + this->buf_written_ += len; + this->md5_.add(data, len); + return OTA_RESPONSE_OK; + } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } +#endif esp_err_t err = esp_ota_write(this->update_handle_, data, len); this->md5_.add(data, len); if (err != ESP_OK) { @@ -73,6 +111,14 @@ OTAResponseTypes IDFOTABackend::end() { return OTA_RESPONSE_ERROR_MD5_MISMATCH; } } +#ifdef USE_OTA_PARTITIONS + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + return this->update_partition_table(); + } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } +#endif esp_err_t err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; if (err == ESP_OK) { @@ -96,6 +142,14 @@ OTAResponseTypes IDFOTABackend::end() { } void IDFOTABackend::abort() { +#ifdef USE_OTA_PARTITIONS + if (this->partition_table_part_ != nullptr) { + esp_partition_deregister_external(this->partition_table_part_); + this->partition_table_part_ = nullptr; + } +#endif + // esp_ota_abort with handle 0 returns ESP_ERR_INVALID_ARG harmlessly, so this is safe whether + // or not an update is in flight. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; } diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index d007bcd128..54fdd24f93 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -9,21 +9,54 @@ namespace esphome::ota { +#ifdef USE_OTA_PARTITIONS +// Staging buffer holds the entire partition table for verification before any flash op. +static constexpr size_t PARTITION_TABLE_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 + +void get_running_app_position(uint32_t &offset, size_t &size); +#endif + class IDFOTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); bool supports_compression() { return false; } + protected: +#ifdef USE_OTA_PARTITIONS + // copy_dest_part non-null means the running app must be copied INTO this slot of the current + // table before the new partition table is committed. The destination is in the current table + // because that's where esp_partition_copy can write; once the new table replaces it, the same + // flash region becomes target_app_index in the new table. + struct PartitionTablePlan { + int target_app_index{-1}; + const esp_partition_t *copy_dest_part{nullptr}; + }; + + OTAResponseTypes validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, + PartitionTablePlan &plan); + OTAResponseTypes update_partition_table(); +#endif + private: esp_ota_handle_t update_handle_{0}; const esp_partition_t *partition_; md5::MD5Digest md5_{}; char expected_bin_md5_[32]; bool md5_set_{false}; +#ifdef USE_OTA_PARTITIONS + // Buffer first so it packs tightly after the preceding `bool md5_set_` with no alignment + // padding. Only resident during an active OTA: the backend is constructed per connection and + // destroyed on cleanup_connection_(). + uint8_t buf_[PARTITION_TABLE_BUFFER_SIZE]; + size_t buf_written_{0}; + size_t image_size_{0}; + const esp_partition_t *partition_table_part_{nullptr}; + ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; +#endif }; std::unique_ptr make_ota_backend(); diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 2e2132418d..a2c9f2cc33 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -10,7 +10,9 @@ namespace esphome::ota { std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes HostOTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_UPDATE_PREPARE; } +OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; +} void HostOTABackend::set_update_md5(const char *expected_md5) {} diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index 300facf72f..4451fdfe18 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -9,7 +9,7 @@ namespace esphome::ota { /// OTA triggers to compile for host platform during development. class HostOTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp new file mode 100644 index 0000000000..2a2ed577f1 --- /dev/null +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -0,0 +1,327 @@ +#ifdef USE_ESP32 +#include "ota_backend_esp_idf.h" + +#include "esphome/core/defines.h" + +#ifdef USE_OTA_PARTITIONS +#include "esphome/components/watchdog/watchdog.h" +#include "esphome/core/log.h" + +#include +#include +#include + +#include + +namespace esphome::ota { + +static const char *const TAG = "ota.idf"; + +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); +} + +// Wraps esp_partition_find/_get/_next/_release. Returns nullptr if no APP partition at `address` +// is at least `min_size` bytes. +static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min_size) { + const esp_partition_t *found = nullptr; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { + const esp_partition_t *p = esp_partition_get(it); + if (p->address == address && p->size >= min_size) { + found = p; + break; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + return found; +} + +// Validates the staged partition table and picks the post-update boot slot. All non-destructive +// checks live here; the destructive write is in update_partition_table(). +// Side effect: registers the live partition-table region as partition_table_part_ so the caller +// can write to it; abort() releases it on error. +OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, + PartitionTablePlan &plan) { + esp_err_t err = esp_partition_register_external( + nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", + ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + int num_partitions = 0; + const esp_partition_info_t *existing_partition_table = nullptr; + esp_partition_mmap_handle_t partition_table_map; + err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, + reinterpret_cast(&existing_partition_table), &partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); + esp_partition_munmap(partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); + err = esp_partition_table_verify(new_partition_table, true, &num_partitions); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // esp_partition_table_verify does not catch a missing MD5 entry, but the bootloader refuses + // to boot from a table without one. + bool checksum_found = false; + for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { + if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { + checksum_found = true; + break; + } + } + if (!checksum_found) { + ESP_LOGE(TAG, "New partition table has no checksum"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Slot-selection policy when multiple slots can host the running app: pick the FIRST eligible + // slot in table order, preferring the no-copy path (matching offset) over the copy path. + // Deterministic and table-ordering-stable. + int app_partitions_found = 0; + int new_app_part_index = -1; + int new_app_part_index_with_copy = -1; + const esp_partition_t *app_copy_dest_part = nullptr; + bool otadata_partition_found = false; + bool otadata_overlap = false; + bool nvs_partition_found = false; + for (int i = 0; i < num_partitions; i++) { + const esp_partition_info_t *new_part = &new_partition_table[i]; + if (new_part->type == ESP_PARTITION_TYPE_APP) { + app_partitions_found++; + if (new_part->pos.size >= running_app_size) { + if (new_part->pos.offset == running_app_offset) { + if (new_app_part_index == -1) { + new_app_part_index = i; + } + } else if (new_app_part_index_with_copy == -1 && + !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { + // esp_partition_copy writes into a registered partition; need one at this offset in the + // current table. + const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); + if (p != nullptr) { + new_app_part_index_with_copy = i; + app_copy_dest_part = p; + } + } + } + } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { + if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { + otadata_partition_found = true; + otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); + } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && + strncmp(reinterpret_cast(new_part->label), "nvs", sizeof(new_part->label)) == 0) { + nvs_partition_found = true; + } + } + } + + if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { + // Most likely cause: the user picked the wrong migration .bin for their running app's size. + // Rejecting here is non-destructive (no flash op has run yet); the user can safely retry with + // a different .bin. Log enough info that they can pick the right method without guessing. + ESP_LOGE(TAG, + "Running app at 0x%X (%u bytes used) does not fit any compatible slot in the new " + "partition table. Pick a migration method whose size limit is at least %u bytes and " + "retry; no flash content was modified.", + running_app_offset, running_app_size, running_app_size); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (app_partitions_found < 2) { + ESP_LOGE(TAG, "New partition table needs at least 2 app partitions, found %d", app_partitions_found); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (!otadata_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required otadata partition"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (!nvs_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required nvs partition"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (otadata_overlap) { + ESP_LOGE(TAG, + "New otadata partition overlaps with the running app at 0x%X (size %u). The chosen " + "partition table is not compatible with this device's current flash layout; pick a " + "different migration method.", + running_app_offset, running_app_size); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + if (new_app_part_index != -1) { + plan.target_app_index = new_app_part_index; + plan.copy_dest_part = nullptr; + } else { + plan.target_app_index = new_app_part_index_with_copy; + plan.copy_dest_part = app_copy_dest_part; + } + return OTA_RESPONSE_OK; +} + +OTAResponseTypes IDFOTABackend::update_partition_table() { + if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { + ESP_LOGE(TAG, "Not enough data received"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Without a valid running-app size we cannot compute overlap or copy bounds. zero indicates + // esp_ota_get_running_partition() failed (e.g. cache unloaded by a previous aborted OTA). + uint32_t running_app_offset; + size_t running_app_size; + get_running_app_position(running_app_offset, running_app_size); + if (running_app_size == 0) { + ESP_LOGE(TAG, "Failed to determine running app position"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + PartitionTablePlan plan; + OTAResponseTypes validate_result = this->validate_new_partition_table_(running_app_offset, running_app_size, plan); + if (validate_result != OTA_RESPONSE_OK) { + return validate_result; + } + + // ERROR severity so the warning shows up in default log filters; any failure past this point + // can leave the device unbootable until it is recovered with a serial flash. + ESP_LOGE(TAG, "Starting partition table update.\n" + " DO NOT REMOVE POWER until the device reboots successfully.\n" + " Loss of power during this operation may render the device unable to boot until\n" + " it is recovered via a serial flash."); + + // One guard over the whole critical section in case an IDF call takes longer than expected on + // some chip variant. + watchdog::WatchdogManager watchdog(15000); + + esp_err_t err; + const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); + + if (plan.copy_dest_part != nullptr) { + // Resolve the source via running_app_offset rather than esp_ota_get_running_partition() in + // case a prior aborted partition-table OTA called esp_partition_unload_all() in this boot, + // which leaves esp_ota_get_running_partition() returning nullptr. + const esp_partition_t *running_app_part = find_app_partition_at(running_app_offset, running_app_size); + if (running_app_part == nullptr) { + ESP_LOGE(TAG, "Cannot resolve running app partition at offset 0x%X", running_app_offset); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, + plan.copy_dest_part->address, running_app_size); + err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + } + + // Deinit NVS only just before the first destructive write so verify/copy failure paths return + // with NVS still functional. From this point on, components that hold open NVS handles + // (e.g. preferences) will fail with ESP_ERR_NVS_INVALID_HANDLE on success or failure; + // nvs_flash_init() can re-init the subsystem but cannot revive existing handles. On the + // success path the device reboots immediately afterwards so this doesn't matter; on the + // failure path the user must reboot the device before retrying. + nvs_flash_deinit(); + + // Update the partition table + err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); + if (err != ESP_OK) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); + if (err != ESP_OK) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + err = esp_ota_end(this->update_handle_); + this->update_handle_ = 0; // esp_ota_end releases the handle internally + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + // unload first, then null the member pointer; if abort() ran between the two steps it would + // see a freed pointer. esp_partition_unload_all() invalidates partition_table_part_ too, so + // an explicit deregister would be redundant. + esp_partition_unload_all(); + this->partition_table_part_ = nullptr; + + // Write otadata to set the new boot partition + const esp_partition_info_t *new_part = &new_partition_table[plan.target_app_index]; + const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); + if (new_boot_partition == nullptr) { + ESP_LOGE(TAG, "Selected app partition not found after partition table update"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); + err = esp_ota_set_boot_partition(new_boot_partition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + return OTA_RESPONSE_OK; +} + +// Process-scoped cache. Cannot be a backend member: backends are per-connection but the cache +// must outlive a connection that called esp_partition_unload_all(), after which +// esp_ota_get_running_partition() no longer returns valid data. +static bool s_running_app_initialized = false; +static uint32_t s_running_app_cached_offset = 0; +static size_t s_running_app_cached_size = 0; + +// Flag-gated rather than size==0 so a failed first call doesn't poison the cache. +void get_running_app_position(uint32_t &offset, size_t &size) { + if (!s_running_app_initialized) { + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + if (running_app_part == nullptr || running_app_part->erase_size == 0) { + // Surface zeros without committing to the cache so a later call has a chance to succeed. + offset = 0; + size = 0; + return; + } + + uint32_t pending_offset = running_app_part->address; + size_t pending_size = running_app_part->size; + + const esp_partition_pos_t running_app_pos = { + .offset = running_app_part->address, + .size = running_app_part->size, + }; + esp_image_metadata_t image_metadata = {}; + image_metadata.start_addr = running_app_part->address; + if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata) == ESP_OK && + image_metadata.image_len < running_app_part->size) { + pending_size = image_metadata.image_len; + } + // Round up to a full flash sector so the copy spans complete erase blocks. + pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + + s_running_app_cached_offset = pending_offset; + s_running_app_cached_size = pending_size; + s_running_app_initialized = true; + } + + offset = s_running_app_cached_offset; + size = s_running_app_cached_size; +} + +} // namespace esphome::ota + +#endif // USE_OTA_PARTITIONS +#endif // USE_ESP32 diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 4fecebcd8d..94a48dd31b 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -779,6 +779,17 @@ class EsphomeCore: return self.relative_pioenvs_path(self.name, "firmware.uf2") return self.relative_pioenvs_path(self.name, "firmware.bin") + @property + def partition_table_bin(self) -> Path: + # Native ESP-IDF (--native-idf): the partition table image is emitted under + # build/partition_table/partition-table.bin alongside firmware.bin. PlatformIO writes the + # equivalent file as partitions.bin in the env-specific .pioenvs directory. + if self.data.get(KEY_NATIVE_IDF): + return self.relative_build_path( + "build", "partition_table", "partition-table.bin" + ) + return self.relative_pioenvs_path(self.name, "partitions.bin") + @property def target_platform(self): return self.data[KEY_CORE][KEY_TARGET_PLATFORM] diff --git a/esphome/espota2.py b/esphome/espota2.py index f4c0c73589..a45a6ef234 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -16,6 +16,7 @@ from esphome.core import EsphomeError from esphome.helpers import ProgressBar, resolve_ip_address OTA_TYPE_UPDATE_APP = 0x00 +OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01 RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 @@ -46,6 +47,8 @@ RESPONSE_ERROR_MD5_MISMATCH = 0x8B RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E +RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F +RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -62,7 +65,9 @@ SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 # OTA types this client knows how to send. Future PRs that add bootloader/partition # updates extend this set. Anything outside the set is rejected up front so callers # of perform_ota/run_ota get a clear error instead of a post-auth 0x8E from the device. -_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset({OTA_TYPE_UPDATE_APP}) +_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( + {OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE} +) UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 @@ -128,6 +133,16 @@ _ERROR_MESSAGES: dict[int, str] = { RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: ( "The requested OTA type is not supported by the device." ), + RESPONSE_ERROR_PARTITION_TABLE_VERIFY: ( + "The partition table update could not be verified. No changes were " + "made to the flash content. Check the logs for more information and retry." + ), + RESPONSE_ERROR_PARTITION_TABLE_UPDATE: ( + "An error occurred while updating the partition table. The device is now " + "in a degraded state (NVS handles are invalid; many components will fail) " + "and may not be able to boot. Check the logs, reboot the device, and " + "retry the update. If the device fails to boot, recover it via a serial flash." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml new file mode 100644 index 0000000000..0cbf854952 --- /dev/null +++ b/tests/components/ota/test-partition_access.esp32-idf.yaml @@ -0,0 +1,5 @@ +ota: + - platform: esphome + allow_partition_access: true + +<<: !include common.yaml diff --git a/tests/unit_tests/fixtures/partition_tables/esp_idf_hello_world.bin b/tests/unit_tests/fixtures/partition_tables/esp_idf_hello_world.bin new file mode 100644 index 0000000000000000000000000000000000000000..b8fa03b4b3536b1f4d0def4c1fed550e8fc2acc7 GIT binary patch literal 3072 zcmZ1#z{tcffq{V`fq@~fte62EtO{UcWca|qz#zcDP>@j>pP83gf~;m$0Eov3R*;sM zT#{c@2@-(g*RTJhfG=zPT`j`AV@pi8>6C7ps)8ap${7uT(GVC7fzc2c4S~@R7!85Z O5Eu=C(GZ|%2mk;_=9q5) literal 0 HcmV?d00001 diff --git a/tests/unit_tests/fixtures/partition_tables/esphome_dashboard_firmware.bin b/tests/unit_tests/fixtures/partition_tables/esphome_dashboard_firmware.bin new file mode 100644 index 0000000000000000000000000000000000000000..e648fa32709414410fbd9be592c84659b7ebb3ad GIT binary patch literal 3072 zcmZ1#z{tcffq{V`fPo>ete62EtO{UcV0gg5z@WgukYAFRl30?6qGVM7g8%~qBLf42 zBtv3BfdPsn0|UdV00u#@W{A8Yraa?J1_nz8kSVFD1x5L}s47+kFg7s=STZntU|=XN z$V^K^bK>jQ|51SEf~h|;7*|G0zA!t#)$$w%isUF~Gz3ONU^E0qLtr!nMnhmU1V%$( KGz3O?2mk;I)@j>pP83gf+`P^VPs%n zkYPwHC@?^l1F=^HFbFa*$in0eL1M^wRRALs1A`?40|PrlURg1+6qx<`^?wv_Dy30A wx{o#F{@PPlP82`;j3PP884ZEa5Eu=C(GVC7fzc2c4S~@R7!85Z5WpJ(0C1>a2LJ#7 literal 0 HcmV?d00001 diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index b114f17e6c..2cad1d2ec8 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -193,6 +193,14 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, "Error: The requested OTA type is not supported by the device", ), + ( + espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY, + "Error: The partition table update could not be verified", + ), + ( + espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE, + "Error: An error occurred while updating the partition table", + ), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) @@ -831,6 +839,66 @@ def test_perform_ota_extended_protocol_app( ) +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_successful_partition_table( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test OTA partition table update. + + The mocked server advertises both COMPRESSION and PARTITION_ACCESS to exercise + the full extended-protocol negotiation path. Real IDFOTABackend devices return + ``supports_compression() == false`` and never set the COMPRESSION flag for a + partition-table OTA; the flag here is intentional protocol-coverage, not a + description of on-device behaviour. + """ + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol + bytes( + [ + espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION + | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ] + ), # Device feature flags (compression flag is unrealistic; see docstring) + 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 + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "partitions.bin", + espota2.OTA_TYPE_UPDATE_PARTITION_TABLE, + ) + + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) + + # Verify features were sent (compression + SHA256 support + extended protocol) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ] + ) + ) + + # Verify ota type was sent + assert mock_socket.sendall.call_args_list[2] == call( + bytes([espota2.OTA_TYPE_UPDATE_PARTITION_TABLE]) + ) + + @pytest.mark.usefixtures("mock_time") def test_perform_ota_device_rejects_with_unsupported_ota_type( mock_socket: Mock, mock_file: io.BytesIO diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 186d8a9573..798a43a4ce 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _get_configured_xtal_freq, _make_crystal_freq_callback, _resolve_network_devices, + _validate_partition_table_binary, choose_upload_log_host, command_analyze_memory, command_bundle, @@ -83,7 +84,7 @@ from esphome.const import ( PLATFORM_RP2040, ) from esphome.core import CORE, EsphomeError -from esphome.espota2 import OTA_TYPE_UPDATE_APP +from esphome.espota2 import OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE from esphome.util import BootselResult from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1112,6 +1113,7 @@ class MockArgs: reset: bool = False list_only: bool = False output: str | None = None + partition_table: bool = False def test_upload_program_serial_esp32( @@ -1629,6 +1631,237 @@ def test_upload_program_ota_with_file_arg( ) +_PARTITION_TABLE_LEN = 0xC00 + + +def _make_partition_table_bytes() -> bytes: + """Build a minimal partition table image accepted by _validate_partition_table_binary.""" + table = bytearray(b"\xff" * _PARTITION_TABLE_LEN) + # First entry: ESP_PARTITION_MAGIC (0x50AA) little-endian -> bytes 0xAA, 0x50. + table[0] = 0xAA + table[1] = 0x50 + # MD5 checksum entry at offset 32: ESP_PARTITION_MAGIC_MD5 (0xEBEB) little-endian. + table[32] = 0xEB + table[33] = 0xEB + return bytes(table) + + +def test_upload_program_ota_partition_table_with_file_arg( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA and partition table.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + partition_file = tmp_path / "partitions.bin" + partition_file.write_bytes(_make_partition_table_bytes()) + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + "allow_partition_access": True, + } + ] + } + args = MockArgs(file=str(partition_file), partition_table=True) + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], + 3232, + None, + partition_file, + OTA_TYPE_UPDATE_PARTITION_TABLE, + ) + + +def test_upload_program_serial_partition_table( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, +) -> None: + """Test serial upload with partition table option (unsupported).""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 0 + + config = {} + args = MockArgs(partition_table=True) + devices = ["/dev/ttyUSB0"] + + with pytest.raises( + EsphomeError, + match="The option --partition-table can only be used for Over The Air updates", + ): + upload_program(config, args, devices) + + +def test_upload_program_ota_partition_table_mqttip( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--partition-table is allowed for MQTTIP devices; they resolve to a real IP at OTA time.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "MQTTIP" + mock_run_ota.return_value = (0, "192.168.1.100") + + partition_file = tmp_path / "partitions.bin" + partition_file.write_bytes(_make_partition_table_bytes()) + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + "allow_partition_access": True, + } + ] + } + args = MockArgs(file=str(partition_file), partition_table=True) + + with patch( + "esphome.__main__._resolve_network_devices", return_value=["192.168.1.100"] + ): + exit_code, host = upload_program(config, args, ["MQTTIP"]) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], + 3232, + None, + partition_file, + OTA_TYPE_UPDATE_PARTITION_TABLE, + ) + + +def test_validate_partition_table_binary_accepts_valid(tmp_path: Path) -> None: + f = tmp_path / "partitions.bin" + f.write_bytes(_make_partition_table_bytes()) + _validate_partition_table_binary(f) + + +_PARTITION_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "partition_tables" + + +@pytest.mark.parametrize( + "fixture", + [ + # Stock ESP-IDF gen_esp32part.py output for an ESPHome build. + "esphome_default.bin", + # ESP-IDF Hello-world example partition table (vendored from espressif/esp-serial-flasher). + "esp_idf_hello_world.bin", + # Partition table shipped with esphome_dashboard's prebuilt firmware. + "esphome_dashboard_firmware.bin", + ], +) +def test_validate_partition_table_binary_accepts_real_binaries(fixture: str) -> None: + """Real-world partition-table binaries from ESP-IDF / ESPHome tooling pass validation.""" + _validate_partition_table_binary(_PARTITION_FIXTURE_DIR / fixture) + + +def test_validate_partition_table_binary_rejects_wrong_size(tmp_path: Path) -> None: + f = tmp_path / "partitions.bin" + f.write_bytes(b"\xaa\x50" + b"\xff" * 100) + with pytest.raises(EsphomeError, match="wrong size"): + _validate_partition_table_binary(f) + + +def test_validate_partition_table_binary_rejects_wrong_magic(tmp_path: Path) -> None: + data = bytearray(_make_partition_table_bytes()) + data[0] = 0x00 + data[1] = 0x00 + f = tmp_path / "partitions.bin" + f.write_bytes(bytes(data)) + with pytest.raises(EsphomeError, match="partition magic"): + _validate_partition_table_binary(f) + + +def test_validate_partition_table_binary_rejects_missing_md5(tmp_path: Path) -> None: + data = bytearray(_make_partition_table_bytes()) + data[32] = 0xFF + data[33] = 0xFF + f = tmp_path / "partitions.bin" + f.write_bytes(bytes(data)) + with pytest.raises(EsphomeError, match="missing the MD5 checksum entry"): + _validate_partition_table_binary(f) + + +def test_validate_partition_table_binary_missing_file(tmp_path: Path) -> None: + with pytest.raises(EsphomeError, match="Cannot read partition table file"): + _validate_partition_table_binary(tmp_path / "does-not-exist.bin") + + +def test_upload_program_ota_partition_table_invalid_file( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--partition-table must fail before calling run_ota when the file is not a partition table.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + + bad_file = tmp_path / "firmware.bin" + bad_file.write_bytes(b"\x00" * 4096) + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + "allow_partition_access": True, + } + ] + } + args = MockArgs(file=str(bad_file), partition_table=True) + devices = ["192.168.1.100"] + + with pytest.raises(EsphomeError, match="wrong size"): + upload_program(config, args, devices) + mock_run_ota.assert_not_called() + + +def test_upload_program_ota_partition_table_without_allow_flag( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--partition-table must fail fast when allow_partition_access is not enabled in YAML.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ] + } + args = MockArgs(file="partitions.bin", partition_table=True) + devices = ["192.168.1.100"] + + with pytest.raises( + EsphomeError, + match="requires 'allow_partition_access: true'", + ): + upload_program(config, args, devices) + mock_run_ota.assert_not_called() + + def test_upload_program_ota_no_config( mock_get_port_type: Mock, ) -> None: From 4108b271970d599b214765303d840cdbf392b1cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 19:43:09 -0500 Subject: [PATCH 1036/2214] [esp8266] Lower WDT_FEED_INTERVAL_MS to 100 ms (#16197) --- esphome/core/application.h | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 5baf570e62..369c970d46 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -233,11 +233,10 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT (user-configurable): ~5x <-- auto-scaled below - /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change - /// must keep comfortable margin here - /// - ESP8266 HW WDT (~6 s): ~20x - /// - BK72xx HW WDT (10 s): ~5x <-- platform override below + /// - ESP32 task WDT (user-configurable): ~5x <-- auto-scaled below + /// - ESP8266 soft WDT (~1.6 s): ~16x <-- 100 ms feed (see USE_ESP8266 below) + /// - ESP8266 HW WDT (~6 s): ~60x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below #ifdef USE_BK72XX // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: @@ -257,6 +256,15 @@ class Application { static_assert(CONFIG_ESP_TASK_WDT_TIMEOUT_S >= 5, "CONFIG_ESP_TASK_WDT_TIMEOUT_S must be at least 5s for a safe WDT feed interval"); static constexpr uint32_t WDT_FEED_INTERVAL_MS = (CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000U) / 5U; +#elif defined(USE_ESP8266) + // ESP8266 needs a tighter feed cadence than the other targets: the soft WDT + // is ~1.6 s and the HW WDT ~6 s, but a single long iteration (mDNS reply, + // wifi scan, OTA verify, lwIP TCP retransmit storm) can push the loop past + // a few hundred ms without giving the SDK a chance to feed. 100 ms keeps a + // ~16x margin to the soft WDT and ~60x to the HW WDT while still avoiding + // the per-iteration arch_feed_wdt() cost (this is the rate limit; component + // loops and scheduler items still feed after every op). + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 100; #else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; #endif From af74b639cfc210fc4b63835c8b6f9f319d355d42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 19:43:35 -0500 Subject: [PATCH 1037/2214] [fan] Fix TurnOnAction trigger args with reference types (#16222) --- esphome/components/fan/__init__.py | 14 +++++++++++--- esphome/components/fan/automation.h | 9 ++++++++- tests/components/fan/common.yaml | 26 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index f47fc06b3d..3949f16d2e 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -358,21 +358,29 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): (CONF_DIRECTION, "set_direction", FanDirection), ) + # Normalize trigger args to `const std::remove_cvref_t &` so the + # apply lambda and any inner field lambdas (generated below via + # `process_lambda`) share one parameter spelling that's well-formed for + # any T (value, ref, or const-ref). Matches TurnOnAction::ApplyFn. + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ] + fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] for conf_key, setter, type_ in FIELDS: if (value := config.get(conf_key)) is None: continue if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=type_) + inner = await cg.process_lambda(value, normalized_args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match TurnOnAction::ApplyFn signature: const Ts &... for trigger args. apply_args = [ (FanCall.operator("ref"), "call"), - *((t.operator("const").operator("ref"), n) for t, n in args), + *normalized_args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index d8eda41b27..577c9ce600 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -12,9 +12,16 @@ namespace fan { // plus one parent pointer, regardless of how many fields the user set. // Trigger args are forwarded to the apply function so user lambdas // (e.g. `speed: !lambda "return x;"`) keep working. +// +// Trigger args are normalized to `const std::remove_cvref_t &...` so +// the codegen can emit a matching parameter list for both the apply lambda +// and any inner field lambdas without producing invalid C++ source text +// (e.g. `const T & &` if Ts already carries a reference, or `const const +// T &` if Ts already carries a const). This keeps trigger args no-copy +// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. template class TurnOnAction : public Action { public: - using ApplyFn = void (*)(FanCall &, const Ts &...); + using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t &...); TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} void play(const Ts &...x) override { diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 6cabbd24f8..76508f391e 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -9,6 +9,14 @@ fan: has_oscillating: true has_direction: true speed_count: 3 + # Exercise fan.turn_on inside a trigger whose Ts pack is non-empty + # (StringRef from on_preset_set) so the apply-lambda + inner-lambda + # codegen runs through the cvref-normalized path. + on_preset_set: + then: + - fan.turn_on: + id: test_fan + speed: !lambda "return x.empty() ? 1 : 3;" # Test lambdas using get_preset_mode() which returns StringRef # These examples match the migration guide in the PR description @@ -88,3 +96,21 @@ button: - fan.turn_on: id: test_fan speed: !lambda 'return 1;' + +# Exercise fan.turn_on inside triggers with non-empty Ts: +# - number.on_value: Ts = float (Python value type; previously raised +# AttributeError on .operator("const")) +# - fan.on_preset_set: Ts = StringRef (already a value-type wrapper around +# a const char * + size; tests the cvref-normalized inner-lambda path) +number: + - platform: template + id: fan_speed_number + optimistic: true + min_value: 1 + max_value: 3 + step: 1 + on_value: + then: + - fan.turn_on: + id: test_fan + speed: !lambda "return (int) x;" From cf223674e51cff65ed9c71b46b434c9c48dd8b72 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 19:43:49 -0500 Subject: [PATCH 1038/2214] [climate] Fix ControlAction trigger args with reference types (#16221) --- esphome/components/climate/__init__.py | 14 +++++++++++--- esphome/components/climate/automation.h | 9 ++++++++- tests/components/climate/common.yaml | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 7c9002d6dc..fc1b0f368e 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -506,6 +506,15 @@ async def climate_control_to_code(config, action_id, template_arg, args): (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), ) + # Normalize trigger args to `const std::remove_cvref_t &` so the + # apply lambda and any inner field lambdas (generated below via + # `process_lambda`) share one parameter spelling that's well-formed for + # any T (value, ref, or const-ref). Matches ControlAction::ApplyFn. + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ] + fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] @@ -513,7 +522,7 @@ async def climate_control_to_code(config, action_id, template_arg, args): if (value := config.get(conf_key)) is None: continue if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=type_) + inner = await cg.process_lambda(value, normalized_args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") elif type_ is cg.std_string: # Static custom strings: emit a flash literal and pass the @@ -526,10 +535,9 @@ async def climate_control_to_code(config, action_id, template_arg, args): else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. apply_args = [ (ClimateCall.operator("ref"), "call"), - *((t.operator("const").operator("ref"), n) for t, n in args), + *normalized_args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index 71d23fd6b6..6ac9bd8bae 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -10,9 +10,16 @@ namespace esphome::climate { // plus one parent pointer, regardless of how many fields the user set. // Trigger args are forwarded to the apply function so user lambdas // (e.g. `target_temperature: !lambda "return x;"`) keep working. +// +// Trigger args are normalized to `const std::remove_cvref_t &...` so +// the codegen can emit a matching parameter list for both the apply lambda +// and any inner field lambdas without producing invalid C++ source text +// (e.g. `const T & &` if Ts already carries a reference, or `const const +// T &` if Ts already carries a const). This keeps trigger args no-copy +// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. template class ControlAction : public Action { public: - using ApplyFn = void (*)(ClimateCall &, const Ts &...); + using ApplyFn = void (*)(ClimateCall &, const std::remove_cvref_t &...); ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { diff --git a/tests/components/climate/common.yaml b/tests/components/climate/common.yaml index 2d35438afd..c28fde8eeb 100644 --- a/tests/components/climate/common.yaml +++ b/tests/components/climate/common.yaml @@ -85,3 +85,18 @@ button: - climate.control: id: climate_test_thermostat mode: "OFF" + +# Exercise climate.control inside a trigger with non-empty Ts (number on_value +# passes float). +number: + - platform: template + id: climate_target_temp_number + optimistic: true + min_value: 16 + max_value: 28 + step: 0.5 + on_value: + then: + - climate.control: + id: climate_test_thermostat + target_temperature_high: !lambda "return x;" From 41bd570d309ac9590f0f122740b134536c08ca02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 19:44:01 -0500 Subject: [PATCH 1039/2214] [light] Fix LightControlAction trigger args with reference types (#16220) --- esphome/components/light/automation.h | 9 ++++++++- esphome/components/light/automation.py | 16 ++++++++++++---- tests/components/light/common.yaml | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index a5c73997b0..993d4a2ea6 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -36,9 +36,16 @@ template class ToggleAction : public A // plus one parent pointer, regardless of how many fields the user set. // Trigger args are forwarded to the apply function so user lambdas // (e.g. `brightness: !lambda "return x;"`) keep working. +// +// Trigger args are normalized to `const std::remove_cvref_t &...` so +// the codegen can emit a matching parameter list for both the apply lambda +// and any inner field lambdas without producing invalid C++ source text +// (e.g. `const T & &` if Ts already carries a reference, or `const const +// T &` if Ts already carries a const). This keeps trigger args no-copy +// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. template class LightControlAction : public Action { public: - using ApplyFn = void (*)(LightState *, LightCall &, const Ts &...); + using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t &...); LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} void play(const Ts &...x) override { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index ca4018a975..cef774af38 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -200,6 +200,15 @@ async def light_control_to_code(config, action_id, template_arg, args): (CONF_WARM_WHITE, "set_warm_white", cg.float_), ) + # Normalize trigger args to `const std::remove_cvref_t &` so the + # apply lambda and any inner field lambdas (generated below via + # `process_lambda`) share one parameter spelling that's well-formed for + # any T (value, ref, or const-ref). Matches LightControlAction::ApplyFn. + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ] + fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] @@ -208,7 +217,7 @@ async def light_control_to_code(config, action_id, template_arg, args): continue value = config[conf_key] if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=type_) + inner = await cg.process_lambda(value, normalized_args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") @@ -216,7 +225,7 @@ async def light_control_to_code(config, action_id, template_arg, args): if CONF_EFFECT in config: if isinstance(config[CONF_EFFECT], Lambda): inner_lambda = await cg.process_lambda( - config[CONF_EFFECT], args, return_type=cg.std_string + config[CONF_EFFECT], normalized_args, return_type=cg.std_string ) body_lines.append( f"{{ auto __effect_s = ({inner_lambda})({fwd_args});\n" @@ -230,11 +239,10 @@ async def light_control_to_code(config, action_id, template_arg, args): f"call.set_effect(static_cast({_resolve_effect_index(config)}));" ) - # Match LightControlAction::ApplyFn signature: const Ts &... for trigger args. apply_args = [ (LightState.operator("ptr"), "parent"), (LightCall.operator("ref"), "call"), - *((t.operator("const").operator("ref"), n) for t, n in args), + *normalized_args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index e58f7baee4..044a8144fa 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -127,6 +127,21 @@ esphome: blue: 0% transition_length: 1s +# Exercise light actions inside a trigger with non-empty Ts (number on_value +# passes float). +number: + - platform: template + id: test_number_brightness + optimistic: true + min_value: 0 + max_value: 100 + step: 1 + on_value: + then: + - light.turn_on: + id: test_monochromatic_light + brightness: !lambda "return x / 100.0;" + light: - platform: binary id: test_binary_light From df1200629fcf59d763675c7b7c59bf26544693b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 19:44:11 -0500 Subject: [PATCH 1040/2214] [tests] Fix flaky host_mode_climate_basic_state (#16225) --- tests/integration/fixtures/host_mode_climate_basic_state.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/fixtures/host_mode_climate_basic_state.yaml b/tests/integration/fixtures/host_mode_climate_basic_state.yaml index f79d684fc6..744b418d0f 100644 --- a/tests/integration/fixtures/host_mode_climate_basic_state.yaml +++ b/tests/integration/fixtures/host_mode_climate_basic_state.yaml @@ -28,6 +28,10 @@ climate: min_temperature: 15.0 max_temperature: 32.0 temperature_step: 0.1 + # Don't restore previous state from flash — this fixture shares the + # `host-climate-test` build dir with host_mode_climate_control.yaml, so a + # prior run of that test could leave the thermostat in HEAT/COOL. + on_boot_restore_from: default_preset default_preset: home preset: - name: "away" From 33f88619daf76c64a5614e36aa54fb89cc0f734a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 19:44:21 -0500 Subject: [PATCH 1041/2214] [valve] Fix ControlAction trigger args with reference types (#16223) --- esphome/components/valve/__init__.py | 14 +++++++++++--- esphome/components/valve/automation.h | 9 ++++++++- tests/components/template/common-base.yaml | 13 +++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 7377aea1ed..d82a9fdec2 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -240,21 +240,29 @@ async def valve_control_to_code(config, action_id, template_arg, args): (CONF_POSITION, "set_position", cg.float_), ) + # Normalize trigger args to `const std::remove_cvref_t &` so the + # apply lambda and any inner field lambdas (generated below via + # `process_lambda`) share one parameter spelling that's well-formed for + # any T (value, ref, or const-ref). Matches ControlAction::ApplyFn. + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ] + fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] for conf_key, setter, type_ in FIELDS: if (value := config.get(conf_key)) is None: continue if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=type_) + inner = await cg.process_lambda(value, normalized_args, return_type=type_) body_lines.append(f"call.{setter}(({inner})({fwd_args}));") else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. apply_args = [ (ValveCall.operator("ref"), "call"), - *((t.operator("const").operator("ref"), n) for t, n in args), + *normalized_args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index ae9ac0db76..27c0e329f0 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -52,9 +52,16 @@ template class ToggleAction : public Action { // plus one parent pointer, regardless of how many fields the user set. // Trigger args are forwarded to the apply function so user lambdas // (e.g. `position: !lambda "return x;"`) keep working. +// +// Trigger args are normalized to `const std::remove_cvref_t &...` so +// the codegen can emit a matching parameter list for both the apply lambda +// and any inner field lambdas without producing invalid C++ source text +// (e.g. `const T & &` if Ts already carries a reference, or `const const +// T &` if Ts already carries a const). This keeps trigger args no-copy +// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. template class ControlAction : public Action { public: - using ApplyFn = void (*)(ValveCall &, const Ts &...); + using ApplyFn = void (*)(ValveCall &, const std::remove_cvref_t &...); ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} void play(const Ts &...x) override { diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 819eaa8bbf..984ef129ad 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -356,6 +356,19 @@ number: min_value: 0 max_value: 100 step: 1 + # Exercise valve.control inside a trigger with non-empty Ts (number on_value + # passes float). + - platform: template + id: template_valve_position_number + optimistic: true + min_value: 0 + max_value: 100 + step: 1 + on_value: + then: + - valve.control: + id: template_valve + position: !lambda "return x / 100.0f;" select: - platform: template From 15ab5422c793e94df4ea69828468c6ae7131e6a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 19:45:08 -0500 Subject: [PATCH 1042/2214] [ci] Run downstream device-builder tests against PR Python code (#16214) --- .github/workflows/ci.yml | 50 ++++++++++++++++ script/determine-jobs.py | 54 +++++++++++++++++ tests/script/test_determine_jobs.py | 93 +++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3af1709774..87058e4fa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,6 +136,53 @@ jobs: if-no-files-found: ignore retention-days: 14 + device-builder: + name: Test downstream esphome/device-builder + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.device-builder == 'true' + steps: + - name: Check out esphome (this PR) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + path: esphome + - name: Check out esphome/device-builder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: esphome/device-builder + ref: main + path: device-builder + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + - name: Set up uv + # Mirrors the install shape device-builder's own CI uses + # (esphome/device-builder#192): uv replaces pip for the + # 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + - name: Install device-builder + esphome from PR + # Install device-builder with its esphome + test extras + # first so its pinned versions of pytest/etc. land, then + # overlay the PR's esphome so the downstream tests run + # against this PR's Python code. ``--system`` installs into + # the runner's Python instead of a venv. + run: | + uv pip install --system -e './device-builder[esphome,test]' + uv pip install --system -e ./esphome + - name: Run device-builder pytest + # ``-n auto`` runs under pytest-xdist (matches device-builder's + # own CI). No ``--cov`` here -- this is purely a downstream + # smoke check against this PR's esphome code. + working-directory: device-builder + run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks + pytest: name: Run pytest strategy: @@ -204,6 +251,7 @@ jobs: clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} + device-builder: ${{ steps.determine.outputs.device-builder }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -247,6 +295,7 @@ jobs: echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT + echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -1063,6 +1112,7 @@ jobs: - clang-tidy-nosplit - clang-tidy-split - determine-jobs + - device-builder - test-build-components-split - pre-commit-ci-lite - memory-impact-target-branch diff --git a/script/determine-jobs.py b/script/determine-jobs.py index c0cf8ecbdc..b8f324784d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -10,6 +10,7 @@ what files have changed. It outputs JSON with the following structure: "clang_tidy": true/false, "clang_format": true/false, "python_linters": true/false, + "device_builder": true/false, "changed_components": ["component1", "component2", ...], "component_test_count": 5, "memory_impact": { @@ -25,6 +26,7 @@ The CI workflow uses this information to: - Skip or run clang-tidy (and whether to do a full scan) - Skip or run clang-format - Skip or run Python linters (ruff, flake8, pylint, pyupgrade) +- Skip or run downstream esphome/device-builder tests against the PR's Python code - Determine which components to test individually - Decide how to split component tests (if there are many) - Run memory impact analysis whenever there are changed components (merged config), and also for core-only changes @@ -440,6 +442,56 @@ def should_run_import_time(branch: str | None = None) -> bool: return False +# Files outside esphome/**/*.py whose changes can affect the downstream +# device-builder build. requirements.txt / pyproject.toml change the runtime +# dependency graph that device-builder picks up when it installs esphome. +DEVICE_BUILDER_TRIGGER_FILES = frozenset( + { + "requirements.txt", + "pyproject.toml", + } +) + + +def should_run_device_builder(branch: str | None = None) -> bool: + """Determine if downstream esphome/device-builder tests should run. + + device-builder imports esphome as a library, so whenever the importable + Python surface, the runtime dependencies, or any non-C++ file packaged + with esphome (pyproject.toml has ``include-package-data = true``, so + things like esphome/idf_component.yml ship and can affect installs) + changes we re-run its test suite against the PR's code to catch + breakage we'd otherwise only see after a release. + + Skipped on beta/release branches: those branches typically lag behind + device-builder@main, so a new device-builder API dependency would + falsely fail the run without reflecting any problem in the PR itself. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if the device-builder downstream tests should run, False otherwise. + """ + target_branch = get_target_branch() + if target_branch and ( + target_branch.startswith("release") or target_branch.startswith("beta") + ): + return False + + for file in changed_files(branch): + if file in DEVICE_BUILDER_TRIGGER_FILES: + return True + # Anything under esphome/ that isn't C++ source can change the + # importable / packaged surface device-builder consumes + # (Python sources, packaged YAML/JSON like idf_component.yml, + # etc.). C++ files only affect compiled firmware, not the + # Python install device-builder pulls in. + if file.startswith("esphome/") and not file.endswith(CPP_FILE_EXTENSIONS): + return True + return False + + def determine_cpp_unit_tests( branch: str | None = None, ) -> tuple[bool, list[str]]: @@ -874,6 +926,7 @@ def main() -> None: run_clang_format = should_run_clang_format(args.branch) run_python_linters = should_run_python_linters(args.branch) run_import_time = should_run_import_time(args.branch) + run_device_builder = should_run_device_builder(args.branch) changed_cpp_file_count = count_changed_cpp_files(args.branch) # Get changed components @@ -1007,6 +1060,7 @@ def main() -> None: "clang_format": run_clang_format, "python_linters": run_python_linters, "import_time": run_import_time, + "device_builder": run_device_builder, "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index e85f1757b0..cc795bc553 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -63,6 +63,13 @@ def mock_should_run_import_time() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_should_run_device_builder() -> Generator[Mock, None, None]: + """Mock should_run_device_builder from determine_jobs.""" + with patch.object(determine_jobs, "should_run_device_builder") as mock: + yield mock + + @pytest.fixture def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]: """Mock determine_cpp_unit_tests from helpers.""" @@ -99,6 +106,7 @@ def test_main_all_tests_should_run( mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -113,6 +121,7 @@ def test_main_all_tests_should_run( mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True + mock_should_run_device_builder.return_value = True mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -193,6 +202,7 @@ def test_main_all_tests_should_run( assert output["clang_format"] is True assert output["python_linters"] is True assert output["import_time"] is True + assert output["device_builder"] is True assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -225,6 +235,7 @@ def test_main_no_tests_should_run( mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -239,6 +250,7 @@ def test_main_no_tests_should_run( mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -278,6 +290,7 @@ def test_main_no_tests_should_run( assert output["clang_format"] is False assert output["python_linters"] is False assert output["import_time"] is False + assert output["device_builder"] is False assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -299,6 +312,7 @@ def test_main_with_branch_argument( mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -313,6 +327,7 @@ def test_main_with_branch_argument( mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True + mock_should_run_device_builder.return_value = True mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -350,6 +365,7 @@ def test_main_with_branch_argument( mock_should_run_clang_format.assert_called_once_with("main") mock_should_run_python_linters.assert_called_once_with("main") mock_should_run_import_time.assert_called_once_with("main") + mock_should_run_device_builder.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -362,6 +378,7 @@ def test_main_with_branch_argument( assert output["clang_format"] is False assert output["python_linters"] is True assert output["import_time"] is True + assert output["device_builder"] is True assert output["changed_components"] == ["mqtt"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -734,6 +751,82 @@ def test_should_run_import_time_with_branch() -> None: mock_changed.assert_called_once_with("release") +@pytest.mark.parametrize( + ("changed_files", "expected_result"), + [ + # esphome Python files trigger downstream device-builder tests + (["esphome/__main__.py"], True), + (["esphome/components/wifi/__init__.py"], True), + (["esphome/core/config.py"], True), + (["esphome/types.pyi"], True), + # Runtime dependency changes trigger + (["requirements.txt"], True), + (["pyproject.toml"], True), + # Non-C++ files packaged with esphome trigger -- device-builder + # picks them up because esphome's pyproject sets + # include-package-data = true. + (["esphome/idf_component.yml"], True), + (["esphome/dashboard/templates/index.html"], True), + (["esphome/components/api/api_pb2_service.json"], True), + # Mixed: any triggering file is enough + (["docs/README.md", "esphome/config.py"], True), + # Dev/test-only dependency changes don't trigger device-builder + # (they don't affect the importable surface device-builder uses) + (["requirements_dev.txt"], False), + (["requirements_test.txt"], False), + # Files outside esphome/ don't trigger + (["script/some_other_script.py"], False), + (["tests/script/test_determine_jobs.py"], False), + # C++ files under esphome/ don't trigger -- they only affect + # compiled firmware, not the Python install device-builder pulls in. + (["esphome/core/component.cpp"], False), + (["esphome/core/component.h"], False), + (["esphome/components/wifi/wifi_component.cpp"], False), + # Files outside esphome/ entirely + (["tests/components/wifi/test.esp32-idf.yaml"], False), + (["README.md"], False), + ([], False), + ], +) +def test_should_run_device_builder( + changed_files: list[str], expected_result: bool +) -> None: + """Test should_run_device_builder function (non-beta/release target).""" + with ( + patch.object(determine_jobs, "changed_files", return_value=changed_files), + # Mock target branch to "dev" so the beta/release skip is bypassed + # for these per-file behavior checks. + patch.object(determine_jobs, "get_target_branch", return_value="dev"), + ): + result = determine_jobs.should_run_device_builder() + assert result == expected_result + + +def test_should_run_device_builder_with_branch() -> None: + """Test should_run_device_builder with branch argument.""" + with ( + patch.object(determine_jobs, "changed_files") as mock_changed, + patch.object(determine_jobs, "get_target_branch", return_value="dev"), + ): + mock_changed.return_value = [] + determine_jobs.should_run_device_builder("release") + mock_changed.assert_called_once_with("release") + + +@pytest.mark.parametrize("target_branch", ["beta", "release", "release-2026.5"]) +def test_should_run_device_builder_skips_beta_release(target_branch: str) -> None: + """Beta/release target branches skip device-builder (lag behind device-builder@main).""" + with ( + patch.object(determine_jobs, "get_target_branch", return_value=target_branch), + patch.object(determine_jobs, "changed_files") as mock_changed, + ): + # Even with a triggering file present, the target-branch guard wins. + mock_changed.return_value = ["esphome/__main__.py"] + assert determine_jobs.should_run_device_builder() is False + # changed_files shouldn't even be consulted -- the guard short-circuits. + mock_changed.assert_not_called() + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ From b5eb4440155ce09e126361eb0046f200225df4d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 20:01:51 -0500 Subject: [PATCH 1043/2214] [dashboard] Stabilize device-builder dashboard backend's API surface (#16206) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/dashboard_import/__init__.py | 11 + esphome/components/esp32/__init__.py | 12 + esphome/components/esp8266/__init__.py | 12 + esphome/components/libretiny/__init__.py | 12 + esphome/components/libretiny/const.py | 8 + esphome/components/rp2040/__init__.py | 12 + esphome/dashboard/util/text.py | 18 +- esphome/helpers.py | 24 +- esphome/storage_json.py | 28 +++ esphome/zeroconf.py | 41 +++ tests/unit_tests/test_dashboard_import.py | 203 +++++++++++++++ tests/unit_tests/test_helpers.py | 45 ++++ tests/unit_tests/test_zeroconf.py | 237 ++++++++++++++++++ 13 files changed, 656 insertions(+), 7 deletions(-) create mode 100644 tests/unit_tests/test_dashboard_import.py create mode 100644 tests/unit_tests/test_zeroconf.py diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index dbe5532902..30b3394165 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -89,6 +89,17 @@ def import_config( network: str = CONF_WIFI, encryption: bool = False, ) -> None: + """Materialise a dashboard-imported device's YAML on disk. + + Used by: + - esphome.dashboard (legacy dashboard) + - device-builder (esphome/device-builder) — called from the + ``devices/import`` WS handler to seed the YAML for an adopted + factory firmware. Coordinate before changing the kwargs or the + generated YAML's top-level keys; both consumers depend on the + output shape (``esphome.name`` / ``packages:`` import url) to + route subsequent compile + flash operations. + """ p = Path(path) if p.exists(): diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9a9ee8fb08..b60dab3634 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -489,6 +489,18 @@ def get_board(core_obj=None): def get_download_types(storage_json): + """Binary-download entries for a built ESP32 firmware. + + Used by: + - esphome.dashboard (legacy "Download .bin" button) + - device-builder (esphome/device-builder) — same dispatch via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the new dashboard's download panel + doesn't have to special-case per-platform schemas. + """ return [ { "title": "Factory format (Previously Modern)", diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 34540bd48d..3c2806a307 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -94,6 +94,18 @@ def set_core_data(config): def get_download_types(storage_json): + """Binary-download entries for a built ESP8266 firmware. + + Used by: + - esphome.dashboard (legacy "Download .bin" button) + - device-builder (esphome/device-builder) — same dispatch via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the new dashboard's download panel + doesn't have to special-case per-platform schemas. + """ return [ { "title": "Standard format", diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 74ac51d200..40fb773784 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -156,6 +156,18 @@ def only_on_family(*, supported=None, unsupported=None): def get_download_types(storage_json: StorageJSON = None): + """Binary-download entries for a built LibreTiny firmware. + + Used by: + - esphome.dashboard (legacy "Download .bin" button) + - device-builder (esphome/device-builder) — same dispatch via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the new dashboard's download panel + doesn't have to special-case per-platform schemas. + """ types = [ { "title": "UF2 package (recommended)", diff --git a/esphome/components/libretiny/const.py b/esphome/components/libretiny/const.py index 5de4a164b5..0119a0db3f 100644 --- a/esphome/components/libretiny/const.py +++ b/esphome/components/libretiny/const.py @@ -54,6 +54,14 @@ COMPONENT_LN882X = "ln882x" COMPONENT_RTL87XX = "rtl87xx" # COMPONENTS - end +# Note for ``generate_components.py`` maintainers: the +# ``FAMILY_COMPONENT`` map below is also consumed externally — +# device-builder (esphome/device-builder) derives the set of +# ``target_platform`` values that should route to the ``libretiny`` +# component for the dashboard's ``get_download_types`` lookup from +# ``FAMILY_COMPONENT.values()``. New chip families added by the +# generator are picked up automatically; please don't repurpose +# the public ``FAMILY_COMPONENT`` name without coordinating. # FAMILIES - auto-generated! Do not modify this block. FAMILY_BK7231N = "BK7231N" FAMILY_BK7231Q = "BK7231Q" diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index ed246416c9..79ed00cb41 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -69,6 +69,18 @@ def set_core_data(config): def get_download_types(storage_json): + """Binary-download entries for a built RP2040 firmware. + + Used by: + - esphome.dashboard (legacy "Download .bin" button) + - device-builder (esphome/device-builder) — same dispatch via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the new dashboard's download panel + doesn't have to special-case per-platform schemas. + """ return [ { "title": "UF2 factory format", diff --git a/esphome/dashboard/util/text.py b/esphome/dashboard/util/text.py index 2a3b9042e6..bdf9abfdb9 100644 --- a/esphome/dashboard/util/text.py +++ b/esphome/dashboard/util/text.py @@ -1,9 +1,15 @@ +"""Back-compat shim for ``friendly_name_slugify``. + +The function moved to :mod:`esphome.helpers` so it survives the legacy +dashboard's eventual removal — see the +``esphome.helpers.friendly_name_slugify`` docstring. This module +re-exports the name so existing +``from esphome.dashboard.util.text import friendly_name_slugify`` +imports keep working while downstream consumers migrate. +""" + from __future__ import annotations -from esphome.helpers import slugify +from esphome.helpers import friendly_name_slugify - -def friendly_name_slugify(value: str) -> str: - """Convert a friendly name to a slug with dashes instead of underscores.""" - # First use the standard slugify, then convert underscores to dashes - return slugify(value).replace("_", "-") +__all__ = ["friendly_name_slugify"] diff --git a/esphome/helpers.py b/esphome/helpers.py index f41bec357d..bb1984e17c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -120,6 +120,24 @@ def slugify(value: str) -> str: return "".join(c for c in value if c in ALLOWED_NAME_CHARS) +def friendly_name_slugify(value: str) -> str: + """Convert a friendly name to a slug with dashes instead of underscores. + + Used by: + - esphome.dashboard.web_server (legacy dashboard) + - device-builder (esphome/device-builder) — slugifies friendly names + into the YAML filename / device name during adoption + wizard flows. + + Lives here rather than in ``esphome.dashboard.util.text`` so it + survives the legacy dashboard's eventual removal. + The dashboard module re-exports this name as a back-compat shim. + Coordinate with the device-builder team before changing the + slugification rules — the mapping must stay stable so existing + on-disk filenames keep matching across releases. + """ + return slugify(value).replace("_", "-") + + def indent_all_but_first_and_last(text, padding=" "): lines = text.splitlines(True) if len(lines) <= 2: @@ -370,7 +388,11 @@ def rmtree(path: Path | str) -> None: os.chmod(path, stat.S_IWUSR | stat.S_IRUSR) func(path) - shutil.rmtree(path, onerror=_onerror) + # ``onerror`` is deprecated in 3.12 in favour of ``onexc`` (different + # callable signature); keep the existing handler shape for now and + # silence the lint locally so this PR doesn't bundle an unrelated + # migration. + shutil.rmtree(path, onerror=_onerror) # pylint: disable=deprecated-argument def walk_files(path: Path): diff --git a/esphome/storage_json.py b/esphome/storage_json.py index d5423ab1c7..c6df16ce78 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -21,6 +21,14 @@ def storage_path() -> Path: def ext_storage_path(config_filename: str) -> Path: + """Path to the per-config StorageJSON sidecar. + + Used by: + - device-builder (esphome/device-builder) — locates the sidecar + to read board / framework / firmware-bin / loaded_integrations + info for the dashboard. Coordinate before changing the path + shape; device-builder reads the same file on disk. + """ return CORE.data_dir / "storage" / f"{config_filename}.json" @@ -29,6 +37,14 @@ def esphome_storage_path() -> Path: def ignored_devices_storage_path() -> Path: + """Path to the dashboard's ignored-devices list. + + Used by: + - device-builder (esphome/device-builder) — reads the same + ``ignored-devices.json`` so the new dashboard's "ignore" toggle + stays compatible with the legacy one. Don't change the file + shape without coordinating. + """ return CORE.data_dir / "ignored-devices.json" @@ -46,6 +62,18 @@ def _to_path_if_not_none(value: str | None) -> Path | None: class StorageJSON: + """Persisted device metadata sidecar. + + Used by: + - esphome.dashboard (legacy dashboard) + - device-builder (esphome/device-builder) — reads/writes the same + JSON file as the legacy dashboard so a single config_dir can be + shared between the two during the transition. The schema + (``storage_version``, field names, types) must stay backwards + compatible — coordinate with the device-builder team before + adding required fields or changing semantics of existing ones. + """ + def __init__( self, storage_version: int, diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index 6f5d33c808..5d922ea911 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -60,6 +60,18 @@ TXT_RECORD_VERSION = b"version" @dataclass class DiscoveredImport: + """An importable device discovered via mDNS ``_esphomelib._tcp.local.``. + + Used by: + - esphome.dashboard (legacy dashboard) + - device-builder (esphome/device-builder) — surfaces these as + "discovered devices" on the new dashboard's adoption flow. + + Fields are populated from TXT records on the broadcast service + info (see :class:`DashboardImportDiscovery`). Coordinate before + adding/removing fields — both consumers persist them. + """ + friendly_name: str | None device_name: str package_import_url: str @@ -73,6 +85,22 @@ class DashboardBrowser(AsyncServiceBrowser): class DashboardImportDiscovery: + """Track importable devices announcing on ``_esphomelib._tcp.local.``. + + Used by: + - esphome.dashboard (legacy dashboard) + - device-builder (esphome/device-builder) — wired up alongside + the dashboard's own ``ServiceBrowser`` to populate the + "Discovered devices" panel and the adoption flow. + + The class maintains ``import_state: dict[str, DiscoveredImport]`` + keyed by the mDNS service name. ``on_update`` is invoked with + ``(name, info | None)`` for additions and removals; update events + refresh ``import_state`` without firing the callback. + Coordinate before changing the callback signature or the keys + of ``import_state`` — device-builder reads both directly. + """ + def __init__( self, on_update: Callable[[str, DiscoveredImport | None], None] | None = None ) -> None: @@ -232,6 +260,19 @@ async def async_resolve_hosts( class AsyncEsphomeZeroconf(AsyncZeroconf): + """ESPHome-tuned ``AsyncZeroconf`` with a hostname-resolve helper. + + Used by: + - esphome.dashboard (legacy dashboard) + - device-builder (esphome/device-builder) — drives both the live + mDNS browser and the per-sweep ``async_resolve_host`` fallback + for non-API devices that don't broadcast esphomelib. + + Coordinate before adding required constructor args or changing + the ``async_resolve_host`` signature — device-builder calls it + on every ping cycle. + """ + async def async_resolve_host( self, host: str, timeout: float = DEFAULT_TIMEOUT ) -> list[str] | None: diff --git a/tests/unit_tests/test_dashboard_import.py b/tests/unit_tests/test_dashboard_import.py new file mode 100644 index 0000000000..427bee0f86 --- /dev/null +++ b/tests/unit_tests/test_dashboard_import.py @@ -0,0 +1,203 @@ +"""Unit tests for ``esphome.components.dashboard_import.import_config``. + +Locks the YAML shape that ``import_config`` materialises on disk for +adopted factory firmware. Both the legacy dashboard and the new +device-builder backend (esphome/device-builder) call this function +during the adoption flow and depend on the output's ``esphome.name`` +/ ``packages:`` keys to route subsequent compile + flash operations. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml as pyyaml + +from esphome.components.dashboard_import import import_config + + +def _load_plain_yaml(path: Path) -> dict: + """Load YAML without invoking ESPHome's ``CORE``-aware loader. + + ``esphome.yaml_util.load_yaml`` resolves ``!include`` / + ``!secret`` against ``CORE.config_path`` which isn't set in + these tests. We're only asserting on plain key/value structure, + so ``pyyaml.load`` with a custom loader subclassing + ``pyyaml.SafeLoader`` (and empty fallbacks for the secret/include + tags) is enough. + """ + + class _Loader(pyyaml.SafeLoader): + pass + + _Loader.add_constructor("!secret", lambda loader, node: f"!secret {node.value}") + _Loader.add_constructor("!include", lambda loader, node: f"!include {node.value}") + + return pyyaml.load(path.read_text(encoding="utf-8"), Loader=_Loader) + + +def test_basic_import_writes_expected_yaml_shape(tmp_path: Path) -> None: + """A minimal Wi-Fi import emits the substitutions / packages / esphome triad. + + These three top-level blocks are the contract: substitutions + holds the device-specific name, packages pulls in the upstream + firmware via the import URL, and esphome.name interpolates from + substitutions. Anything that depends on this output (frontend + config viewer, follow-up edits, version checks) reads those + keys directly. + """ + yaml_path = tmp_path / "kitchen.yaml" + + import_config( + path=str(yaml_path), + name="kitchen", + friendly_name="Kitchen", + project_name="acme.kitchen-light", + import_url="github://acme/firmware/kitchen.yaml@main", + ) + + assert yaml_path.exists() + config = _load_plain_yaml(yaml_path) + + assert config["substitutions"] == { + "name": "kitchen", + "friendly_name": "Kitchen", + } + assert config["packages"] == { + "acme.kitchen-light": "github://acme/firmware/kitchen.yaml@main" + } + assert config["esphome"] == { + "name": "${name}", + "name_add_mac_suffix": False, + "friendly_name": "${friendly_name}", + } + + +def test_import_appends_wifi_config_when_network_is_wifi(tmp_path: Path) -> None: + """Wi-Fi devices get a ``wifi:`` block templated with secrets references. + + Adopted Wi-Fi devices need a ``wifi:`` section so they can + actually connect on the user's LAN — the boilerplate references + ``!secret wifi_ssid`` / ``!secret wifi_password`` so the + user's existing secrets file plugs in. Devices on other + networks (Ethernet) shouldn't get the Wi-Fi block. + """ + yaml_path = tmp_path / "kitchen.yaml" + import_config( + path=str(yaml_path), + name="kitchen", + friendly_name=None, + project_name="acme.kitchen-light", + import_url="github://acme/firmware/kitchen.yaml@main", + ) + contents = yaml_path.read_text() + assert "wifi:" in contents + assert "!secret wifi_ssid" in contents + assert "!secret wifi_password" in contents + + +def test_import_omits_wifi_block_for_ethernet_network(tmp_path: Path) -> None: + """Ethernet devices get no ``wifi:`` block — caller wires Ethernet separately. + + The ``network`` parameter exists specifically so non-Wi-Fi + devices (PoE / Ethernet, etc.) skip the Wi-Fi templating — + otherwise their generated YAML would carry an unused ``wifi:`` + section the user has to clean up by hand. + """ + yaml_path = tmp_path / "olimex-poe.yaml" + import_config( + path=str(yaml_path), + name="olimex-poe", + friendly_name=None, + project_name="acme.poe-monitor", + import_url="github://acme/firmware/poe.yaml@main", + network="ethernet", + ) + contents = yaml_path.read_text() + assert "wifi:" not in contents + + +def test_import_with_encryption_writes_api_key(tmp_path: Path) -> None: + """``encryption=True`` generates a fresh Noise PSK in the api block. + + Used during the adoption flow when the device-builder UI + explicitly opts the new device into encrypted API. Each + invocation must produce a fresh 32-byte PSK base64-encoded into + the YAML; subsequent compiles and the dashboard's encryption + indicator both read it from there. + """ + yaml_path_1 = tmp_path / "a.yaml" + yaml_path_2 = tmp_path / "b.yaml" + + import_config( + path=str(yaml_path_1), + name="a", + friendly_name=None, + project_name="acme.dev", + import_url="github://acme/firmware/dev.yaml@main", + encryption=True, + ) + import_config( + path=str(yaml_path_2), + name="b", + friendly_name=None, + project_name="acme.dev", + import_url="github://acme/firmware/dev.yaml@main", + encryption=True, + ) + + config_1 = _load_plain_yaml(yaml_path_1) + config_2 = _load_plain_yaml(yaml_path_2) + assert "api" in config_1 and "encryption" in config_1["api"] + key_1 = config_1["api"]["encryption"]["key"] + key_2 = config_2["api"]["encryption"]["key"] + # Fresh per-call PSK, not a hardcoded value. + assert key_1 != key_2 + # Base64-encoded 32 bytes → length 44 with one trailing `=`. + assert len(key_1) == 44 + + +def test_import_without_friendly_name_omits_friendly_substitution( + tmp_path: Path, +) -> None: + """``friendly_name=None`` skips the friendly_name substitution. + + Some imported configs don't carry a friendly name. The output + shouldn't pretend they do — the substitutions block must omit + ``friendly_name`` so the dashboard renders blank rather than + the literal substitution token. + """ + yaml_path = tmp_path / "noname.yaml" + import_config( + path=str(yaml_path), + name="noname", + friendly_name=None, + project_name="acme.dev", + import_url="github://acme/firmware/dev.yaml@main", + ) + config = _load_plain_yaml(yaml_path) + assert config["substitutions"] == {"name": "noname"} + assert "friendly_name" not in config["esphome"] + + +def test_import_refuses_to_overwrite_existing_yaml(tmp_path: Path) -> None: + """An already-present file raises rather than clobbering the user's edits. + + Both the legacy dashboard and device-builder rely on the + ``FileExistsError`` to surface a "config already exists" message + instead of silently destroying user data. + """ + yaml_path = tmp_path / "existing.yaml" + yaml_path.write_text("# user's hand-edited config\n", encoding="utf-8") + + with pytest.raises(FileExistsError): + import_config( + path=str(yaml_path), + name="existing", + friendly_name=None, + project_name="acme.dev", + import_url="github://acme/firmware/dev.yaml@main", + ) + # Original content survives unchanged. + assert yaml_path.read_text() == "# user's hand-edited config\n" diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 159d3230ab..f2faf3ba8f 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -90,6 +90,51 @@ def test_cpp_string_escape(string, expected): assert actual == expected +@pytest.mark.parametrize( + "value, expected", + ( + # Basic underscore→dash conversion. + ("Living Room Sensor", "living-room-sensor"), + # Already-slugified input passes through with dash output. + ("kitchen_light", "kitchen-light"), + # Accents are stripped (matches the underlying ``slugify``). + ("Café Caché", "cafe-cache"), + # Mixed casing + multiple separators collapse correctly. + ("Foo Bar__Baz", "foo-bar-baz"), + # Empty input yields empty output. + ("", ""), + # Numbers survive intact. + ("Sensor 42", "sensor-42"), + ), +) +def test_friendly_name_slugify(value, expected): + """Friendly-name → URL-safe dash-slug. + + Stable mapping is part of the cross-tool contract + (legacy dashboard + device-builder both depend on it for + filename → device-name routing). Lock the cases here so a + refactor can't accidentally change a slug shape and break + on-disk filenames in already-deployed installs. + """ + assert helpers.friendly_name_slugify(value) == expected + + +def test_friendly_name_slugify_back_compat_shim(): + """``esphome.dashboard.util.text`` keeps re-exporting for back-compat. + + The function moved to ``esphome.helpers`` so the new + device-builder dashboard backend can import it without depending + on the legacy dashboard package, but downstream code that still + imports from the old path keeps working until the dashboard + module is removed. + """ + from esphome.dashboard.util.text import ( + friendly_name_slugify as legacy_friendly_name_slugify, + ) + + assert legacy_friendly_name_slugify is helpers.friendly_name_slugify + + @pytest.mark.parametrize( "host", ( diff --git a/tests/unit_tests/test_zeroconf.py b/tests/unit_tests/test_zeroconf.py new file mode 100644 index 0000000000..e325eb1e26 --- /dev/null +++ b/tests/unit_tests/test_zeroconf.py @@ -0,0 +1,237 @@ +"""Unit tests for ``esphome.zeroconf`` device-discovery primitives. + +Covers ``DashboardImportDiscovery`` (state transitions for adoption / +import flows) and ``DiscoveredImport`` (TXT-record parse shape). Both +are part of the cross-tool contract between the legacy dashboard and +the new device-builder backend (esphome/device-builder); changes to +the callback signature, the ``import_state`` dict shape, or the +``DiscoveredImport`` field set will break downstream consumers. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from zeroconf import ServiceStateChange + +from esphome.zeroconf import ( + ESPHOME_SERVICE_TYPE, + DashboardImportDiscovery, + DiscoveredImport, +) + + +def _make_service_info( + package_import_url: str = "github://esphome/example/example.yaml", + project_name: str = "esphome.example", + project_version: str = "1.0.0", + network: str | None = "wifi", + friendly_name: str | None = "Living Room", + version: str | None = "2025.1.0", +) -> MagicMock: + """Build a fake ``AsyncServiceInfo`` with the TXT records we care about. + + The real callback path resolves a service via zeroconf and then + reads ``info.properties`` (a ``dict[bytes, bytes | None]``). Mock + that shape so we can drive ``_process_service_info`` directly + without spinning up a real zeroconf instance. + """ + info = MagicMock() + properties: dict[bytes, bytes | None] = { + b"package_import_url": package_import_url.encode(), + b"project_name": project_name.encode(), + b"project_version": project_version.encode(), + } + if network is not None: + properties[b"network"] = network.encode() + if friendly_name is not None: + properties[b"friendly_name"] = friendly_name.encode() + if version is not None: + properties[b"version"] = version.encode() + info.properties = properties + info.load_from_cache.return_value = True + return info + + +def test_added_service_populates_import_state_and_fires_callback() -> None: + """An ADD with the required TXT records lands a ``DiscoveredImport`` and notifies. + + Mirrors what both the legacy dashboard and device-builder rely + on — the callback is the only signal that an importable device + has appeared on the LAN, and ``import_state`` is the snapshot + they read on demand. + """ + on_update = MagicMock() + discovery = DashboardImportDiscovery(on_update=on_update) + + info = _make_service_info() + name = f"living-room.{ESPHOME_SERVICE_TYPE}" + discovery._process_service_info(name, info) + + assert name in discovery.import_state + entry = discovery.import_state[name] + assert isinstance(entry, DiscoveredImport) + assert entry.device_name == "living-room" + assert entry.package_import_url == "github://esphome/example/example.yaml" + assert entry.project_name == "esphome.example" + assert entry.project_version == "1.0.0" + assert entry.network == "wifi" + assert entry.friendly_name == "Living Room" + on_update.assert_called_once_with(name, entry) + + +def test_added_service_without_required_txt_is_ignored() -> None: + """A device that doesn't carry ``package_import_url`` etc. isn't importable. + + The dashboard browser also fires for plain ``_esphomelib._tcp`` + services that happen to match the type but aren't dashboard + imports. Those must not land in ``import_state`` or fire the + update callback — otherwise the dashboard would surface every + API-enabled device on the LAN as "ready to adopt". + """ + on_update = MagicMock() + discovery = DashboardImportDiscovery(on_update=on_update) + + info = MagicMock() + # Empty TXT records — no import URL, no version. ``version``-only + # services hit a separate ``update_device_mdns`` path that talks + # to ``StorageJSON``; that's covered elsewhere. + info.properties = {} + info.load_from_cache.return_value = True + + discovery._process_service_info(f"plain.{ESPHOME_SERVICE_TYPE}", info) + + assert discovery.import_state == {} + on_update.assert_not_called() + + +def test_repeated_add_does_not_re_fire_callback() -> None: + """Re-resolving the same service doesn't spam the on_update callback. + + The dashboard re-resolves periodically; without the ``is_new`` + guard, every refresh would fire ``IMPORTABLE_DEVICE_ADDED`` and + the dashboard's UI would re-render endlessly. + """ + on_update = MagicMock() + discovery = DashboardImportDiscovery(on_update=on_update) + + info = _make_service_info() + name = f"living-room.{ESPHOME_SERVICE_TYPE}" + discovery._process_service_info(name, info) + discovery._process_service_info(name, info) + + on_update.assert_called_once() + + +def test_removed_service_clears_state_and_fires_none_callback() -> None: + """A ServiceStateChange.Removed pops the entry and notifies with ``None``. + + Both consumers rely on the ``(name, None)`` callback shape to + distinguish "device gone" from "device updated". Coordinate + before changing the second-arg semantics. + """ + on_update = MagicMock() + discovery = DashboardImportDiscovery(on_update=on_update) + + info = _make_service_info() + name = f"living-room.{ESPHOME_SERVICE_TYPE}" + discovery._process_service_info(name, info) + on_update.reset_mock() + + discovery.browser_callback( + zeroconf=MagicMock(), + service_type=ESPHOME_SERVICE_TYPE, + name=name, + state_change=ServiceStateChange.Removed, + ) + + assert name not in discovery.import_state + on_update.assert_called_once_with(name, None) + + +def test_remove_for_unknown_service_does_not_fire_callback() -> None: + """A spurious Removed for a service we never tracked is a silent no-op. + + The browser can fire Removed for any matching service type, + not just the importable ones we're tracking. Don't let those + confuse the callback consumer. + """ + on_update = MagicMock() + discovery = DashboardImportDiscovery(on_update=on_update) + + discovery.browser_callback( + zeroconf=MagicMock(), + service_type=ESPHOME_SERVICE_TYPE, + name=f"never-seen.{ESPHOME_SERVICE_TYPE}", + state_change=ServiceStateChange.Removed, + ) + + on_update.assert_not_called() + + +def test_updated_service_for_unknown_name_is_ignored() -> None: + """Updates without a prior Add don't seed ``import_state``. + + The dashboard counts on Add to introduce the device and Update + to refresh it. Letting Update silently introduce new state would + let an unrelated TXT change bypass the Add-time validation. + """ + on_update = MagicMock() + discovery = DashboardImportDiscovery(on_update=on_update) + + discovery.browser_callback( + zeroconf=MagicMock(), + service_type=ESPHOME_SERVICE_TYPE, + name=f"living-room.{ESPHOME_SERVICE_TYPE}", + state_change=ServiceStateChange.Updated, + ) + + assert discovery.import_state == {} + on_update.assert_not_called() + + +def test_network_defaults_to_wifi_when_txt_absent() -> None: + """Older firmware that doesn't broadcast ``network`` defaults to ``wifi``. + + The TXT record was added in a later release; pre-existing + factory firmwares advertise without it. ``DiscoveredImport`` + has to default cleanly so adoption flows can still produce a + valid YAML for those devices. + """ + discovery = DashboardImportDiscovery() + info = _make_service_info(network=None) + name = f"older.{ESPHOME_SERVICE_TYPE}" + discovery._process_service_info(name, info) + + assert discovery.import_state[name].network == "wifi" + + +def test_friendly_name_optional() -> None: + """``friendly_name`` may be ``None`` if the device doesn't broadcast it. + + Both consumers handle the ``None`` case (rendering the device + name as fallback in the UI). Locking this in keeps the + optionality explicit so a future refactor doesn't accidentally + coerce it into an empty string. + """ + discovery = DashboardImportDiscovery() + info = _make_service_info(friendly_name=None) + name = f"no-friendly.{ESPHOME_SERVICE_TYPE}" + discovery._process_service_info(name, info) + + assert discovery.import_state[name].friendly_name is None + + +def test_callback_is_optional() -> None: + """``on_update=None`` lets ``import_state`` track silently. + + Used by callers that read the dict directly rather than + subscribing to events. + """ + discovery = DashboardImportDiscovery(on_update=None) + info = _make_service_info() + name = f"silent.{ESPHOME_SERVICE_TYPE}" + discovery._process_service_info(name, info) + + # No callback to assert against; just verify state landed. + assert name in discovery.import_state From 72a75f2d3f7c2073802e55aee6519f8f6955b66e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 20:02:07 -0500 Subject: [PATCH 1044/2214] [cover] Fold ControlAction/CoverPublishAction fields into stateless lambdas (#16046) --- esphome/components/cover/__init__.py | 108 ++++++++++++++--- esphome/components/cover/automation.h | 35 +++--- esphome/components/template/cover/__init__.py | 48 +++++--- .../fixtures/cover_control_action.yaml | 111 ++++++++++++++++++ .../integration/test_cover_control_action.py | 92 +++++++++++++++ 5 files changed, 337 insertions(+), 57 deletions(-) create mode 100644 tests/integration/fixtures/cover_control_action.yaml create mode 100644 tests/integration/test_cover_control_action.py diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 41efd2ba7a..954ad7a345 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable +from dataclasses import dataclass import logging from esphome import automation @@ -36,14 +38,14 @@ from esphome.const import ( DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, ID, CoroPriority, 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_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass +from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass from esphome.types import ConfigType, TemplateArgsType IS_PLATFORM_COMPONENT = True @@ -68,6 +70,7 @@ _LOGGER = logging.getLogger(__name__) cover_ns = cg.esphome_ns.namespace("cover") Cover = cover_ns.class_("Cover", cg.EntityBase) +CoverCall = cover_ns.class_("CoverCall") COVER_OPEN = cover_ns.COVER_OPEN COVER_CLOSED = cover_ns.COVER_CLOSED @@ -294,25 +297,94 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema( ) +@dataclass(frozen=True) +class ApplyField: + """One field in a folded-lambda action. + + `conf_key` is the YAML key looked up in `config`. When present, the + helper emits `statement_fn(target, value_expr)` into the lambda body. + `target` is whatever the statement function needs to identify the + field (typically a setter name like `"set_position"` or a struct + member like `"position"`). `type_` is the C++ return type for + `cg.process_lambda` when the value is a user lambda. + """ + + conf_key: str + target: str + type_: object + + +async def build_apply_lambda_action( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + fields: tuple[ApplyField, ...], + prefix_args: list[tuple[object, str]], + statement_fn: Callable[[str, str], str], +) -> MockObj: + """Fold configured fields into a single stateless apply lambda action. + + Used by both `cover.control` and `cover.template.publish` (and shared + with the template/cover platform). Constants are emitted as flash + immediates; user lambdas are invoked inline so trigger args still flow. + The trigger arg types are wrapped as `const T &` to match the + `void (*)(..., const Ts &...)` ApplyFn signature. + """ + paren = await cg.get_variable(config[CONF_ID]) + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + for field in fields: + if (value := config.get(field.conf_key)) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=field.type_) + value_expr = f"({inner})({fwd_args})" + else: + value_expr = str(cg.safe_exp(value)) + body_lines.append(statement_fn(field.target, value_expr)) + + apply_args = [ + *prefix_args, + *((t.operator("const").operator("ref"), n) for t, n in args), + ] + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + apply_args, + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) + + +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both dispatch to set_position. +_COVER_CONTROL_FIELDS: tuple[ApplyField, ...] = ( + ApplyField(CONF_STOP, "set_stop", cg.bool_), + ApplyField(CONF_STATE, "set_position", cg.float_), + ApplyField(CONF_POSITION, "set_position", cg.float_), + ApplyField(CONF_TILT, "set_tilt", cg.float_), +) + + @automation.register_action( "cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA, synchronous=True ) -async def cover_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (stop := config.get(CONF_STOP)) is not None: - template_ = await cg.templatable(stop, args, cg.bool_) - cg.add(var.set_stop(template_)) - if (state := config.get(CONF_STATE)) is not None: - template_ = await cg.templatable(state, args, cg.float_) - cg.add(var.set_position(template_)) - if (position := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position, args, cg.float_) - cg.add(var.set_position(template_)) - if (tilt := config.get(CONF_TILT)) is not None: - template_ = await cg.templatable(tilt, args, cg.float_) - cg.add(var.set_tilt(template_)) - return var +async def cover_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await build_apply_lambda_action( + config=config, + action_id=action_id, + template_arg=template_arg, + args=args, + fields=_COVER_CONTROL_FIELDS, + prefix_args=[(CoverCall.operator("ref"), "call")], + statement_fn=lambda setter, expr: f"call.{setter}({expr});", + ) COVER_CONDITION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index f121e5c2d6..e2384c2359 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -46,48 +46,41 @@ template class ToggleAction : public Action { Cover *cover_; }; +// All configured fields are baked into a single stateless lambda whose +// constants live in flash. Each action stores only one function pointer +// plus one parent pointer, regardless of how many fields the user set. +// Trigger args are forwarded to the apply function so user lambdas +// (e.g. `position: !lambda "return x;"`) keep working. + template class ControlAction : public Action { public: - explicit ControlAction(Cover *cover) : cover_(cover) {} - - TEMPLATABLE_VALUE(bool, stop) - TEMPLATABLE_VALUE(float, position) - TEMPLATABLE_VALUE(float, tilt) + using ApplyFn = void (*)(CoverCall &, const Ts &...); + ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} void play(const Ts &...x) override { auto call = this->cover_->make_call(); - if (this->stop_.has_value()) - call.set_stop(this->stop_.value(x...)); - if (this->position_.has_value()) - call.set_position(this->position_.value(x...)); - if (this->tilt_.has_value()) - call.set_tilt(this->tilt_.value(x...)); + this->apply_(call, x...); call.perform(); } protected: Cover *cover_; + ApplyFn apply_; }; template class CoverPublishAction : public Action { public: - CoverPublishAction(Cover *cover) : cover_(cover) {} - TEMPLATABLE_VALUE(float, position) - TEMPLATABLE_VALUE(float, tilt) - TEMPLATABLE_VALUE(CoverOperation, current_operation) + using ApplyFn = void (*)(Cover *, const Ts &...); + CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} void play(const Ts &...x) override { - if (this->position_.has_value()) - this->cover_->position = this->position_.value(x...); - if (this->tilt_.has_value()) - this->cover_->tilt = this->tilt_.value(x...); - if (this->current_operation_.has_value()) - this->cover_->current_operation = this->current_operation_.value(x...); + this->apply_(this->cover_, x...); this->cover_->publish_state(); } protected: Cover *cover_; + ApplyFn apply_; }; template class CoverPositionCondition : public Condition { diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index a30c0af313..7cb50df84c 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -19,6 +19,9 @@ from esphome.const import ( CONF_TILT_ACTION, CONF_TILT_LAMBDA, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType from .. import template_ns @@ -110,6 +113,16 @@ async def to_code(config): cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both map to the position field. +_COVER_PUBLISH_FIELDS: tuple[cover.ApplyField, ...] = ( + cover.ApplyField(CONF_STATE, "position", cg.float_), + cover.ApplyField(CONF_POSITION, "position", cg.float_), + cover.ApplyField(CONF_TILT, "tilt", cg.float_), + cover.ApplyField(CONF_CURRENT_OPERATION, "current_operation", cover.CoverOperation), +) + + @automation.register_action( "cover.template.publish", cover.CoverPublishAction, @@ -126,21 +139,20 @@ async def to_code(config): ), synchronous=True, ) -async def cover_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_STATE in config: - template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) - cg.add(var.set_position(template_)) - if CONF_POSITION in config: - template_ = await cg.templatable(config[CONF_POSITION], args, cg.float_) - cg.add(var.set_position(template_)) - if CONF_TILT in config: - template_ = await cg.templatable(config[CONF_TILT], args, cg.float_) - cg.add(var.set_tilt(template_)) - if CONF_CURRENT_OPERATION in config: - template_ = await cg.templatable( - config[CONF_CURRENT_OPERATION], args, cover.CoverOperation - ) - cg.add(var.set_current_operation(template_)) - return var +async def cover_template_publish_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + # Mutates Cover fields directly (no CoverCall) since publish is a state + # push, not a control request. + return await cover.build_apply_lambda_action( + config=config, + action_id=action_id, + template_arg=template_arg, + args=args, + fields=_COVER_PUBLISH_FIELDS, + prefix_args=[(cover.Cover.operator("ptr"), "cover")], + statement_fn=lambda field, expr: f"cover->{field} = {expr};", + ) diff --git a/tests/integration/fixtures/cover_control_action.yaml b/tests/integration/fixtures/cover_control_action.yaml new file mode 100644 index 0000000000..085d632796 --- /dev/null +++ b/tests/integration/fixtures/cover_control_action.yaml @@ -0,0 +1,111 @@ +esphome: + name: cover-control-action-test +host: +api: +logger: + level: DEBUG + +globals: + - id: test_position + type: float + initial_value: "0.42" + +cover: + - platform: template + name: "Test Cover" + id: test_cover + has_position: true + optimistic: true + assumed_state: true + open_action: + - cover.template.publish: + id: test_cover + position: 1.0 + close_action: + - cover.template.publish: + id: test_cover + position: 0.0 + stop_action: + - cover.template.publish: + id: test_cover + current_operation: IDLE + tilt_action: + - lambda: |- + // Manually set tilt and publish + id(test_cover).tilt = tilt; + id(test_cover).publish_state(); + +button: + # cover.control: position only + - platform: template + id: btn_position + name: "Set Position" + on_press: + - cover.control: + id: test_cover + position: 50% + + # cover.control: tilt only + - platform: template + id: btn_tilt + name: "Set Tilt" + on_press: + - cover.control: + id: test_cover + tilt: 75% + + # cover.control: position + tilt + - platform: template + id: btn_pos_tilt + name: "Set Pos Tilt" + on_press: + - cover.control: + id: test_cover + position: 25% + tilt: 30% + + # cover.control: state alias for position + - platform: template + id: btn_open_state + name: "Open State" + on_press: + - cover.control: + id: test_cover + state: OPEN + + # cover.control: lambda position (exercises lambda path) + - platform: template + id: btn_lambda_position + name: "Lambda Position" + on_press: + - cover.control: + id: test_cover + position: !lambda "return id(test_position);" + + # cover.template.publish: position only + - platform: template + id: btn_publish_pos + name: "Publish Pos" + on_press: + - cover.template.publish: + id: test_cover + position: 0.6 + + # cover.template.publish: current_operation only + - platform: template + id: btn_publish_op + name: "Publish Op" + on_press: + - cover.template.publish: + id: test_cover + current_operation: OPENING + + # cover.control: stop only — runs after Publish Op so the test can + # verify current_operation transitions OPENING -> IDLE. + - platform: template + id: btn_stop + name: "Stop Cover" + on_press: + - cover.control: + id: test_cover + stop: true diff --git a/tests/integration/test_cover_control_action.py b/tests/integration/test_cover_control_action.py new file mode 100644 index 0000000000..9c7395371b --- /dev/null +++ b/tests/integration/test_cover_control_action.py @@ -0,0 +1,92 @@ +"""Integration test for cover ControlAction and CoverPublishAction. + +Tests that cover.control and cover.template.publish automation actions +work correctly with the single stateless apply lambda/function pointer +implementation. Exercises multiple field combinations and the lambda path. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, CoverInfo, CoverState, EntityState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_cover_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test cover ControlAction/CoverPublishAction with constants and lambdas.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + cover_state_future: asyncio.Future[CoverState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, CoverState) + and cover_state_future is not None + and not cover_state_future.done() + ): + cover_state_future.set_result(state) + + async def wait_for_cover_state(timeout: float = 5.0) -> CoverState: + nonlocal cover_state_future + cover_state_future = loop.create_future() + try: + return await asyncio.wait_for(cover_state_future, timeout) + finally: + cover_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_cover", CoverInfo) + + async def press_and_wait(name: str) -> CoverState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_cover_state() + + # cover.control: position only + state = await press_and_wait("Set Position") + assert state.position == pytest.approx(0.5, abs=0.01) + + # cover.control: tilt only + state = await press_and_wait("Set Tilt") + assert state.tilt == pytest.approx(0.75, abs=0.01) + + # cover.control: position + tilt + state = await press_and_wait("Set Pos Tilt") + assert state.position == pytest.approx(0.25, abs=0.01) + assert state.tilt == pytest.approx(0.30, abs=0.01) + + # cover.control: state alias for position 1.0 + state = await press_and_wait("Open State") + assert state.position == pytest.approx(1.0, abs=0.01) + + # cover.control: lambda position (test_position global = 0.42) + state = await press_and_wait("Lambda Position") + assert state.position == pytest.approx(0.42, abs=0.01) + + # cover.template.publish: position only + state = await press_and_wait("Publish Pos") + assert state.position == pytest.approx(0.6, abs=0.01) + + # cover.template.publish: current_operation only + state = await press_and_wait("Publish Op") + # CoverOperation.OPENING == 1 + assert state.current_operation == 1 + + # cover.control: stop only — template cover's stop_action publishes + # current_operation: IDLE. + state = await press_and_wait("Stop Cover") + # CoverOperation.IDLE == 0 + assert state.current_operation == 0 From 844a36f7a101fe5aaf1f32828392cbb89de55037 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 20:03:52 -0500 Subject: [PATCH 1045/2214] [api] Mark high-volume proxy messages as speed_optimized (Infrared/RF, Z-Wave, serial) (#16159) --- esphome/components/api/api.proto | 3 +++ esphome/components/api/api_pb2.cpp | 24 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 391efbd6eb..4d72be5407 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2511,6 +2511,7 @@ message ZWaveProxyFrame { option (source) = SOURCE_BOTH; option (ifdef) = "USE_ZWAVE_PROXY"; option (no_delay) = true; + option (speed_optimized) = true; bytes data = 1; } @@ -2571,6 +2572,7 @@ message InfraredRFReceiveEvent { option (source) = SOURCE_SERVER; option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; option (no_delay) = true; + option (speed_optimized) = true; uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance @@ -2627,6 +2629,7 @@ message SerialProxyDataReceived { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SERIAL_PROXY"; option (no_delay) = true; + option (speed_optimized) = true; uint32 instance = 1; // Instance index (0-based) bytes data = 2; // Raw data received from the serial device diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index eb25bf7461..68be7550ee 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3784,12 +3784,16 @@ bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited valu } return true; } -uint8_t *ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); return pos; } -uint32_t ZWaveProxyFrame::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +ZWaveProxyFrame::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->data_len); return size; @@ -3910,7 +3914,9 @@ bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto3 } return true; } -uint8_t *InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_DEVICES ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); @@ -3921,7 +3927,9 @@ uint8_t *InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DE } return pos; } -uint32_t InfraredRFReceiveEvent::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +InfraredRFReceiveEvent::calculate_size() const { uint32_t size = 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); @@ -4001,13 +4009,17 @@ bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_ } return true; } -uint8_t *SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +SerialProxyDataReceived::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->instance); ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); return pos; } -uint32_t SerialProxyDataReceived::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +SerialProxyDataReceived::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->instance); size += ProtoSize::calc_length(1, this->data_len_); From 120d1e51fbe320a79108e1126f206bc90eb161c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 20:04:34 -0500 Subject: [PATCH 1046/2214] [tests] Fix flaky host_mode_climate_basic_state integration test (#16192) --- .../host_mode_climate_basic_state.yaml | 3 +- tests/integration/state_utils.py | 37 ++++++++++++ .../test_host_mode_climate_basic_state.py | 60 ++++++++++--------- 3 files changed, 71 insertions(+), 29 deletions(-) diff --git a/tests/integration/fixtures/host_mode_climate_basic_state.yaml b/tests/integration/fixtures/host_mode_climate_basic_state.yaml index 744b418d0f..82bf321bbe 100644 --- a/tests/integration/fixtures/host_mode_climate_basic_state.yaml +++ b/tests/integration/fixtures/host_mode_climate_basic_state.yaml @@ -1,5 +1,5 @@ esphome: - name: host-climate-test + name: host-climate-basic-state host: api: logger: @@ -10,6 +10,7 @@ climate: name: Dual-mode Thermostat sensor: host_thermostat_temperature_sensor humidity_sensor: host_thermostat_humidity_sensor + on_boot_restore_from: default_preset humidity_hysteresis: 1.0 min_cooling_off_time: 20s min_cooling_run_time: 20s diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index d42b50ecdb..c8517aff09 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -8,6 +8,7 @@ import logging from typing import TypeVar from aioesphomeapi import ( + APIClient, BinarySensorState, ButtonInfo, EntityInfo, @@ -19,6 +20,42 @@ from aioesphomeapi import ( _LOGGER = logging.getLogger(__name__) T = TypeVar("T", bound=EntityInfo) +S = TypeVar("S", bound=EntityState) + + +async def wait_for_state( + client: APIClient, + predicate: Callable[[EntityState], bool], + timeout: float = 5.0, +) -> EntityState: + """Subscribe to states and wait for one matching ``predicate``. + + Resolves with the first :class:`EntityState` for which ``predicate`` + returns ``True``. Useful when a component publishes multiple states + during setup (e.g. before sensor readings arrive) and the test needs + to wait for the state to converge to expected values rather than + capturing whichever state happens to arrive first. + + Args: + client: Connected API client. + predicate: Callable invoked for every received state; the first + state for which it returns ``True`` is returned. + timeout: Maximum time to wait in seconds. + + Returns: + The first state matching ``predicate``. + + Raises: + asyncio.TimeoutError: If no matching state arrives within ``timeout``. + """ + future: asyncio.Future[EntityState] = asyncio.get_running_loop().create_future() + + def on_state(state: EntityState) -> None: + if not future.done() and predicate(state): + future.set_result(state) + + client.subscribe_states(on_state) + return await asyncio.wait_for(future, timeout=timeout) def find_entity( diff --git a/tests/integration/test_host_mode_climate_basic_state.py b/tests/integration/test_host_mode_climate_basic_state.py index 7d871ed5a8..0c82d28c3c 100644 --- a/tests/integration/test_host_mode_climate_basic_state.py +++ b/tests/integration/test_host_mode_climate_basic_state.py @@ -2,11 +2,17 @@ from __future__ import annotations -import aioesphomeapi -from aioesphomeapi import ClimateAction, ClimateInfo, ClimateMode, ClimatePreset +from aioesphomeapi import ( + ClimateAction, + ClimateInfo, + ClimateMode, + ClimatePreset, + ClimateState, + EntityState, +) import pytest -from .state_utils import InitialStateHelper +from .state_utils import wait_for_state from .types import APIClientConnectedFactory, RunCompiledFunction @@ -18,32 +24,30 @@ async def test_host_mode_climate_basic_state( ) -> None: """Test basic climate state reporting.""" async with run_compiled(yaml_config), api_client_connected() as client: - # Get entities and set up state synchronization - entities, services = await client.list_entities_services() - initial_state_helper = InitialStateHelper(entities) + entities, _ = await client.list_entities_services() climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] assert len(climate_infos) >= 1, "Expected at least 1 climate entity" - - # Subscribe with the wrapper (no-op callback since we just want initial states) - client.subscribe_states(initial_state_helper.on_state_wrapper(lambda _: None)) - - # Wait for all initial states to be broadcast - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") - - # Get the climate entity and its initial state test_climate = climate_infos[0] - climate_state = initial_state_helper.initial_states.get(test_climate.key) - assert climate_state is not None, "Climate initial state not found" - assert isinstance(climate_state, aioesphomeapi.ClimateState) - assert climate_state.mode == ClimateMode.OFF - assert climate_state.action == ClimateAction.OFF - assert climate_state.current_temperature == 22.0 - assert climate_state.target_temperature_low == 18.0 - assert climate_state.target_temperature_high == 24.0 - assert climate_state.preset == ClimatePreset.HOME - assert climate_state.current_humidity == 42.0 - assert climate_state.target_humidity == 20.0 + # The thermostat publishes multiple states during setup as the + # temperature/humidity sensors come online. Wait for the state to + # converge to the expected default values rather than relying on + # whichever state happens to arrive first. + def is_default_state(state: EntityState) -> bool: + return ( + isinstance(state, ClimateState) + and state.key == test_climate.key + and state.mode == ClimateMode.OFF + and state.action == ClimateAction.OFF + and state.current_temperature == 22.0 + and state.target_temperature_low == 18.0 + and state.target_temperature_high == 24.0 + and state.preset == ClimatePreset.HOME + and state.current_humidity == 42.0 + and state.target_humidity == 20.0 + ) + + try: + await wait_for_state(client, is_default_state) + except TimeoutError: + pytest.fail("Climate did not converge to expected default state") From 9ddb828da33f4f8bfe68f46b5e55b95e152b8cd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 20:05:13 -0500 Subject: [PATCH 1047/2214] [api] Don't tear down log connection on stack-trace decode failure (#16196) --- esphome/components/api/client.py | 61 ++++++++-- tests/unit_tests/components/api/__init__.py | 0 .../unit_tests/components/api/test_client.py | 113 ++++++++++++++++++ 3 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/components/api/__init__.py create mode 100644 tests/unit_tests/components/api/test_client.py diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 312d937f01..d5214ccbf6 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -18,7 +18,7 @@ with warnings.catch_warnings(): import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.platformio_api import process_stacktrace from . import CONF_ENCRYPTION @@ -32,6 +32,52 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +class _LogLineProcessor: + """Feeds incoming log lines to the stack-trace decoder. + + Two responsibilities beyond just calling the decoder: + 1. Catch EsphomeError. on_log runs inside an asyncio protocol + callback; if an exception escapes, the loop tears the transport + down with "Fatal error: protocol.data_received() call failed." + and ReconnectLogic immediately reconnects, the device replays + the same crash trace, and we loop forever. + 2. Disable decoding after the first failure. _decode_pc shells out + to PlatformIO via _run_idedata, which is expensive; a single + crash dump can contain many PC/BT lines and we don't want to + retry the failing subprocess for each one. + """ + + def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None: + self._config = config + self._platform_handler = platform_handler + self._decode_enabled = True + self.backtrace_state = False + + def process_line(self, raw_line: str) -> None: + if not self._decode_enabled: + return + try: + if self._platform_handler is not None: + self.backtrace_state = self._platform_handler( + self._config, raw_line, self.backtrace_state + ) + else: + self.backtrace_state = process_stacktrace( + self._config, raw_line, backtrace_state=self.backtrace_state + ) + except EsphomeError as exc: + self._decode_enabled = False + self.backtrace_state = False + # _run_idedata raises EsphomeError with no message; fall back + # to a generic explanation when str(exc) is empty. + detail = str(exc) or "build artifacts not found locally" + _LOGGER.warning( + "Crash trace decoding unavailable: %s. " + "Run 'esphome compile' for this device to enable PC decoding.", + detail, + ) + + async def async_run_logs( config: dict[str, Any], addresses: list[str], @@ -61,7 +107,6 @@ async def async_run_logs( addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard - backtrace_state = False # Try platform-specific stacktrace handler first, fall back to generic platform_process_stacktrace = None @@ -71,9 +116,10 @@ async def async_run_logs( except (AttributeError, ImportError): pass + processor = _LogLineProcessor(config, platform_process_stacktrace) + def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" - nonlocal backtrace_state time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") @@ -84,14 +130,7 @@ async def async_run_logs( for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) for raw_line in text.splitlines(): - if platform_process_stacktrace: - backtrace_state = platform_process_stacktrace( - config, raw_line, backtrace_state - ) - else: - backtrace_state = process_stacktrace( - config, raw_line, backtrace_state=backtrace_state - ) + processor.process_line(raw_line) # Safe to fall back to plaintext here only for this diagnostics use # case: the stream is one-way from device to client, and this code diff --git a/tests/unit_tests/components/api/__init__.py b/tests/unit_tests/components/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py new file mode 100644 index 0000000000..3970d1ce8b --- /dev/null +++ b/tests/unit_tests/components/api/test_client.py @@ -0,0 +1,113 @@ +"""Tests for esphome.components.api.client.""" + +from __future__ import annotations + +from unittest.mock import patch + +from esphome.components.api import client as api_client +from esphome.core import EsphomeError + + +def test_decoder_swallows_esphome_error() -> None: + """A failing stack-trace decode must not propagate. + + on_log runs inside an asyncio protocol callback; if EsphomeError + escapes, the loop reports "Fatal error: protocol.data_received() + call failed.", tears the connection down, and ReconnectLogic loops + forever as the device replays the same crash trace on every + reconnect. + """ + config = {"esphome": {"name": "test"}} + processor = api_client._LogLineProcessor(config, None) + + with patch.object( + api_client, "process_stacktrace", side_effect=EsphomeError("no idedata") + ) as mock_process: + processor.process_line("PC: 0x4010496e") + + assert mock_process.called + assert processor.backtrace_state is False + + +def test_decoder_swallows_platform_handler_error() -> None: + """The same protection must apply to the platform-specific handler.""" + config = {"esphome": {"name": "test"}} + + def platform_handler(_config, _line, _state): + raise EsphomeError("no idedata") + + processor = api_client._LogLineProcessor(config, platform_handler) + processor.process_line("PC: 0x4010496e") + + assert processor.backtrace_state is False + + +def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: + """_run_idedata raises EsphomeError with no message; the warning + must show a useful explanation rather than empty parens. + """ + config = {"esphome": {"name": "test"}} + processor = api_client._LogLineProcessor(config, None) + + with patch.object(api_client, "process_stacktrace", side_effect=EsphomeError()): + processor.process_line("PC: 0x4010496e") + + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] + assert any("build artifacts not found locally" in m for m in warnings) + assert not any("()" in m for m in warnings) + + +def test_decoder_short_circuits_after_failure() -> None: + """After one failure, subsequent lines must not retry the decoder. + + _decode_pc shells out to PlatformIO; a crash dump can contain many + PC/BT lines and retrying the failing subprocess for each one would + stall log streaming. + """ + config = {"esphome": {"name": "test"}} + processor = api_client._LogLineProcessor(config, None) + + with patch.object( + api_client, "process_stacktrace", side_effect=EsphomeError("no idedata") + ) as mock_process: + processor.process_line("PC: 0x4010496e") + processor.process_line("BT0: 0x4010496e") + processor.process_line("BT1: 0x401049aa") + + assert mock_process.call_count == 1 + + +def test_decoder_threads_backtrace_state() -> None: + """When decoding succeeds, backtrace_state is threaded across calls.""" + config = {"esphome": {"name": "test"}} + processor = api_client._LogLineProcessor(config, None) + + with patch.object( + api_client, "process_stacktrace", side_effect=[True, False] + ) as mock_process: + processor.process_line(">>>stack>>>") + assert processor.backtrace_state is True + processor.process_line("<< None: + """The platform handler is preferred over the generic one.""" + config = {"esphome": {"name": "test"}} + calls: list[tuple[object, str, bool]] = [] + + def platform_handler(cfg, line, state): + calls.append((cfg, line, state)) + return True + + processor = api_client._LogLineProcessor(config, platform_handler) + + with patch.object(api_client, "process_stacktrace") as mock_generic: + processor.process_line("BT0: 0x4010496e") + + assert calls == [(config, "BT0: 0x4010496e", False)] + assert mock_generic.called is False + assert processor.backtrace_state is True From 013dee44ebd36cc0fda5d4c10aa15570ce1e2a73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 20:05:27 -0500 Subject: [PATCH 1048/2214] [binary_sensor] Drop Component from AutorepeatFilter, use self-keyed scheduler (#16191) --- esphome/components/binary_sensor/__init__.py | 6 +- esphome/components/binary_sensor/filter.cpp | 22 ++-- esphome/components/binary_sensor/filter.h | 5 +- .../binary_sensor_autorepeat_filter.yaml | 40 ++++++ .../test_binary_sensor_autorepeat_filter.py | 123 ++++++++++++++++++ 5 files changed, 177 insertions(+), 19 deletions(-) create mode 100644 tests/integration/fixtures/binary_sensor_autorepeat_filter.yaml create mode 100644 tests/integration/test_binary_sensor_autorepeat_filter.py diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index db82290750..a9a09363fc 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -148,7 +148,7 @@ DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter) DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter) DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter) InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter) -AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter, cg.Component) +AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter) LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter) StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter) SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter) @@ -282,9 +282,7 @@ async def autorepeat_filter_to_code(config, filter_id): ), ) ] - var = cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings) @register_filter("lambda", LambdaFilter, cv.returning_lambda) diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 0a463ee9a9..8b882212c8 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -10,11 +10,6 @@ namespace esphome::binary_sensor { static const char *const TAG = "sensor.filter"; -// AutorepeatFilter still inherits Component (it schedules two distinct timer -// purposes), so it keeps the (Component *, id) scheduler API. -constexpr uint32_t AUTOREPEAT_TIMING_ID = 0; -constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1; - void Filter::output(bool value) { if (this->next_ == nullptr) { this->parent_->send_state_internal(value); @@ -69,6 +64,10 @@ optional DelayedOffFilter::new_value(bool value) { optional InvertFilter::new_value(bool value) { return !value; } // AutorepeatFilterBase +// Two independent timers per instance, keyed off two stable addresses inside +// the filter: `this` for the timing-step timer, `&active_timing_` for the +// on/off timer. Both are unique per instance and don't collide with anything +// else, so the self-keyed scheduler API is sufficient. optional AutorepeatFilterBase::new_value(bool value) { if (value) { if (this->active_timing_ != 0) @@ -76,8 +75,8 @@ optional AutorepeatFilterBase::new_value(bool value) { this->next_timing_(); return true; } else { - this->cancel_timeout(AUTOREPEAT_TIMING_ID); - this->cancel_timeout(AUTOREPEAT_ON_OFF_ID); + App.scheduler.cancel_timeout(this); + App.scheduler.cancel_timeout(&this->active_timing_); this->active_timing_ = 0; return false; } @@ -85,8 +84,7 @@ optional AutorepeatFilterBase::new_value(bool value) { void AutorepeatFilterBase::next_timing_() { if (this->active_timing_ < this->timings_count_) { - this->set_timeout(AUTOREPEAT_TIMING_ID, this->timings_[this->active_timing_].delay, - [this]() { this->next_timing_(); }); + App.scheduler.set_timeout(this, this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); }); } if (this->active_timing_ <= this->timings_count_) { this->active_timing_++; @@ -98,12 +96,10 @@ void AutorepeatFilterBase::next_timing_() { void AutorepeatFilterBase::next_value_(bool val) { const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2]; this->output(val); - this->set_timeout(AUTOREPEAT_ON_OFF_ID, val ? timing.time_on : timing.time_off, - [this, val]() { this->next_value_(!val); }); + App.scheduler.set_timeout(&this->active_timing_, val ? timing.time_on : timing.time_off, + [this, val]() { this->next_value_(!val); }); } -float AutorepeatFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; } - LambdaFilter::LambdaFilter(std::function(bool)> f) : f_(std::move(f)) {} optional LambdaFilter::new_value(bool value) { return this->f_(value); } diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 8ff57cab0c..6887de35e1 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -84,10 +84,11 @@ struct AutorepeatFilterTiming { /// Non-template base for AutorepeatFilter — all methods in filter.cpp. /// Lambdas capture this base pointer, so set_timeout/cancel_timeout are instantiated once. -class AutorepeatFilterBase : public Filter, public Component { +/// The two scheduled timers are keyed off `this` and `&active_timing_`; since the address +/// of `active_timing_` is taken as a scheduler key, the class must not be copied or moved. +class AutorepeatFilterBase : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; AutorepeatFilterBase(const AutorepeatFilterBase &) = delete; AutorepeatFilterBase &operator=(const AutorepeatFilterBase &) = delete; diff --git a/tests/integration/fixtures/binary_sensor_autorepeat_filter.yaml b/tests/integration/fixtures/binary_sensor_autorepeat_filter.yaml new file mode 100644 index 0000000000..5799ece00c --- /dev/null +++ b/tests/integration/fixtures/binary_sensor_autorepeat_filter.yaml @@ -0,0 +1,40 @@ +esphome: + name: test-autorepeat-filter + +host: +api: + batch_delay: 0ms # Disable batching to receive every state transition +logger: + level: DEBUG + +binary_sensor: + # The autorepeat filter is applied directly to the template sensor, so each + # write through `binary_sensor.template.publish` runs through the filter + # chain. With the source true the filter must oscillate after `delay`; once + # the source returns to false the filter must cancel both timers and emit a + # final false. + - platform: template + name: "Autorepeat Sensor" + id: autorepeat_sensor + filters: + - autorepeat: + - delay: 200ms + time_off: 100ms + time_on: 100ms + +button: + - platform: template + name: "Press" + id: press_button + on_press: + - binary_sensor.template.publish: + id: autorepeat_sensor + state: true + + - platform: template + name: "Release" + id: release_button + on_press: + - binary_sensor.template.publish: + id: autorepeat_sensor + state: false diff --git a/tests/integration/test_binary_sensor_autorepeat_filter.py b/tests/integration/test_binary_sensor_autorepeat_filter.py new file mode 100644 index 0000000000..443d5293f2 --- /dev/null +++ b/tests/integration/test_binary_sensor_autorepeat_filter.py @@ -0,0 +1,123 @@ +"""Integration test for the binary_sensor autorepeat filter. + +Verifies that the autorepeat filter: + +1. Passes the initial true through unchanged. +2. Begins oscillating after the configured ``delay`` while the source stays true. +3. Stops oscillating and emits a final false when the source goes false. + +This exercises both scheduled timers in ``AutorepeatFilter`` (the per-step +``delay`` timer keyed off the filter ``this`` pointer and the on/off toggle +timer keyed off ``&active_timing_``). +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_binary_sensor_autorepeat_filter( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Drive the source true and verify the downstream sensor oscillates.""" + collector = SensorStateCollector( + sensor_names=[], + binary_sensor_names=["autorepeat_sensor"], + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-autorepeat-filter" + + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + + press_button = require_entity(entities, "press", description="Press button") + release_button = require_entity( + entities, "release", description="Release button" + ) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(collector.on_state) + ) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + autorepeat_states = collector.binary_states["autorepeat_sensor"] + + # Press: source becomes true, autorepeat passes the initial true through + # and then oscillates after the configured delay. + # Configured timings: delay=200ms, time_on=100ms, time_off=100ms. + # Expected within ~700ms: + # true (0ms), false (200ms), true (300ms), false (400ms), + # true (500ms), false (600ms) + client.button_command(press_button.key) + + # Wait for at least 5 transitions to verify the oscillation pattern. + oscillation_seen = collector.add_waiter(lambda: len(autorepeat_states) >= 5) + try: + await asyncio.wait_for(oscillation_seen, timeout=2.0) + except TimeoutError: + pytest.fail( + f"Expected at least 5 autorepeat transitions, got {autorepeat_states}" + ) + + assert autorepeat_states[0] is True, ( + f"First transition should be the pass-through true, got {autorepeat_states}" + ) + # After the initial true and the configured delay, the filter must + # toggle false/true/false/... — verify the alternation pattern. + for index, value in enumerate(autorepeat_states): + expected = index % 2 == 0 + assert value is expected, ( + f"Expected alternating values starting with True, " + f"got {autorepeat_states} (mismatch at index {index})" + ) + + # Release: source becomes false, autorepeat must cancel both timers + # and settle on false. If the most recent oscillation was already + # false, the binary sensor will dedup and not emit a new state event; + # if it was true, exactly one final false transition arrives. Either + # way, the steady state must be false and no further toggles should + # arrive after a settle window longer than time_on + time_off. + was_true_before_release = autorepeat_states[-1] is True + before_count = len(autorepeat_states) + client.button_command(release_button.key) + + if was_true_before_release: + settle_seen = collector.add_waiter( + lambda: len(autorepeat_states) > before_count + ) + try: + await asyncio.wait_for(settle_seen, timeout=2.0) + except TimeoutError: + pytest.fail("Timeout waiting for autorepeat to settle to false") + assert autorepeat_states[-1] is False, ( + f"After release, final state should be False, got {autorepeat_states}" + ) + + steady_count = len(autorepeat_states) + await asyncio.sleep(0.5) + assert len(autorepeat_states) == steady_count, ( + f"Expected no further toggles after release, " + f"got {autorepeat_states[steady_count:]}" + ) + assert autorepeat_states[-1] is False, ( + f"Final autorepeat state should be False, got {autorepeat_states}" + ) From 1d631584801b84515f684adffcfe9e607fd0ed78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 20:05:56 -0500 Subject: [PATCH 1049/2214] [zephyr] Add nRF52 component tests so CI runs on zephyr-only changes (#16188) --- tests/components/zephyr/common.yaml | 8 ++++++++ tests/components/zephyr/test.nrf52-adafruit.yaml | 1 + 2 files changed, 9 insertions(+) create mode 100644 tests/components/zephyr/common.yaml create mode 100644 tests/components/zephyr/test.nrf52-adafruit.yaml diff --git a/tests/components/zephyr/common.yaml b/tests/components/zephyr/common.yaml new file mode 100644 index 0000000000..345042df3b --- /dev/null +++ b/tests/components/zephyr/common.yaml @@ -0,0 +1,8 @@ +esphome: + on_boot: + - lambda: |- + ESP_LOGD("test", "millis=%u micros=%u cycles=%u", + (unsigned) millis(), (unsigned) micros(), + (unsigned) arch_get_cpu_cycle_count()); + delay(1); + delayMicroseconds(1); diff --git a/tests/components/zephyr/test.nrf52-adafruit.yaml b/tests/components/zephyr/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/zephyr/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 53c4e6f386a6e25b25684707a136cffe025fc731 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 20:12:51 -0500 Subject: [PATCH 1050/2214] [tests] Drop duplicate on_boot_restore_from in host_mode_climate_basic_state (#16228) --- tests/integration/fixtures/host_mode_climate_basic_state.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/integration/fixtures/host_mode_climate_basic_state.yaml b/tests/integration/fixtures/host_mode_climate_basic_state.yaml index 82bf321bbe..2bfd63ceff 100644 --- a/tests/integration/fixtures/host_mode_climate_basic_state.yaml +++ b/tests/integration/fixtures/host_mode_climate_basic_state.yaml @@ -29,10 +29,6 @@ climate: min_temperature: 15.0 max_temperature: 32.0 temperature_step: 0.1 - # Don't restore previous state from flash — this fixture shares the - # `host-climate-test` build dir with host_mode_climate_control.yaml, so a - # prior run of that test could leave the thermostat in HEAT/COOL. - on_boot_restore_from: default_preset default_preset: home preset: - name: "away" From 24d4da1021244e33ddd385aa3194aeef8de4525b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 20:18:28 -0500 Subject: [PATCH 1051/2214] [sensor] Document why TimeoutFilterBase intentionally keeps Component (#16194) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/sensor/filter.h | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 57a2386a7f..b79bfa17d6 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -413,7 +413,31 @@ class ThrottleWithPriorityNanFilter : public Filter { uint32_t min_time_between_inputs_; }; -// Base class for timeout filters - contains common loop logic +// Base class for timeout filters - contains common loop logic. +// +// Why this intentionally inherits Component (and does NOT use the self-keyed +// `App.scheduler.set_timeout(this, ...)` pattern that the other Filter classes +// migrated to): +// +// Timeout filters re-arm on every input, so on devices with many sensors +// using timeout filters (e.g. multi-LD2450 boards) every armed filter would +// require a live SchedulerItem in RAM at the same time. A SchedulerItem is +// substantially larger than the Component bookkeeping bytes carried by this +// class, so paying the Component cost per filter (one-time, BSS) is cheaper +// than paying for a SchedulerItem per filter (live, while armed). #11922 +// is the original symptom and switchover to the loop-based design; #16173 +// attempted to migrate this onto the scheduler and was closed for exactly +// this reason — even if the scheduler pool were unbounded, RAM per armed +// filter would still be dominated by the SchedulerItem itself, not by +// anything we can shrink in the scheduler. +// +// The loop-based design has additional advantages on top of the RAM win: +// `enable_loop()` / `disable_loop()` partitions the cost away when no +// timeout is armed; while armed, work is a single timestamp compare per +// active filter, with no per-input scheduler cancel/insert path. +// +// Don't try to migrate this class onto the self-keyed scheduler. The math +// doesn't work — at scale, this design is the smaller one. class TimeoutFilterBase : public Filter, public Component { public: void loop() override; From 690a1973460f5492e67bc06ff4606afc5f778c69 Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Mon, 4 May 2026 21:07:31 +0200 Subject: [PATCH 1052/2214] [main] Move stacktrace handling out of platformio_api and FlashImage into platform components/util (#16186) --- esphome/__main__.py | 26 ++- esphome/components/api/client.py | 10 +- esphome/components/esp32/__init__.py | 76 +++++++++ esphome/components/esp8266/__init__.py | 115 ++++++++++++++ esphome/platformio_api.py | 150 +----------------- esphome/util.py | 6 + .../unit_tests/components/api/test_client.py | 23 +-- .../components/test_esp_stacktrace.py | 109 +++++++++++++ tests/unit_tests/conftest.py | 13 +- tests/unit_tests/test_main.py | 23 +-- tests/unit_tests/test_platformio_api.py | 100 +----------- 11 files changed, 359 insertions(+), 292 deletions(-) create mode 100644 tests/unit_tests/components/test_esp_stacktrace.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 9ab2dee189..222e299f6d 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -63,6 +63,7 @@ from esphome.log import AnsiFore, color, setup_log from esphome.types import ConfigType from esphome.util import ( PICOTOOL_PACKAGE, + FlashImage, detect_rp2040_bootsel, get_picotool_path, get_serial_ports, @@ -586,8 +587,6 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: from aioesphomeapi import LogParser import serial - from esphome import platformio_api - if CONF_LOGGER not in config: _LOGGER.info("Logger is not enabled. Not starting UART logs.") return 1 @@ -602,8 +601,11 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: try: module = importlib.import_module("esphome.components." + CORE.target_platform) process_stacktrace = getattr(module, "process_stacktrace") - except AttributeError: - pass + except (AttributeError, ImportError): + _LOGGER.info( + 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', + CORE.target_platform, + ) backtrace_state = False ser = serial.Serial() @@ -646,14 +648,10 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: ) safe_print(parser.parse_line(line, time_str)) - if process_stacktrace: + if process_stacktrace is not None: backtrace_state = process_stacktrace( config, line, backtrace_state ) - else: - backtrace_state = platformio_api.process_stacktrace( - config, line, backtrace_state=backtrace_state - ) except serial.SerialException: _LOGGER.error("Serial port closed!") return 0 @@ -843,22 +841,20 @@ def _make_crystal_freq_callback( def upload_using_esptool( config: ConfigType, port: str, file: str, speed: int ) -> str | int: - from esphome import platformio_api - first_baudrate = speed or config[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS].get( "upload_speed", os.getenv("ESPHOME_UPLOAD_SPEED", "460800") ) if file is not None: - flash_images = [platformio_api.FlashImage(path=file, offset="0x0")] + flash_images = [FlashImage(path=file, offset="0x0")] else: + from esphome import platformio_api + idedata = platformio_api.get_idedata(config) firmware_offset = "0x10000" if CORE.is_esp32 else "0x0" flash_images = [ - platformio_api.FlashImage( - path=idedata.firmware_bin_path, offset=firmware_offset - ), + FlashImage(path=idedata.firmware_bin_path, offset=firmware_offset), ] for image in idedata.extra_flash_images: if not image.path.is_file(): diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index d5214ccbf6..0a5370cb30 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -19,7 +19,6 @@ import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ from esphome.core import CORE, EsphomeError -from esphome.platformio_api import process_stacktrace from . import CONF_ENCRYPTION @@ -61,10 +60,6 @@ class _LogLineProcessor: self.backtrace_state = self._platform_handler( self._config, raw_line, self.backtrace_state ) - else: - self.backtrace_state = process_stacktrace( - self._config, raw_line, backtrace_state=self.backtrace_state - ) except EsphomeError as exc: self._decode_enabled = False self.backtrace_state = False @@ -114,7 +109,10 @@ async def async_run_logs( module = importlib.import_module("esphome.components." + CORE.target_platform) platform_process_stacktrace = getattr(module, "process_stacktrace") except (AttributeError, ImportError): - pass + _LOGGER.info( + 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', + CORE.target_platform, + ) processor = _LogLineProcessor(config, platform_process_stacktrace) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index b60dab3634..582721ef73 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -5,6 +5,7 @@ import logging import os from pathlib import Path import re +import subprocess from esphome import yaml_util import esphome.codegen as cg @@ -2515,3 +2516,78 @@ def copy_files(): CORE.relative_build_path(name).write_bytes(content) else: copy_file_if_changed(path, CORE.relative_build_path(name)) + + +def _decode_pc(config, addr): + from esphome import platformio_api + + idedata = platformio_api.get_idedata(config) + if not idedata.addr2line_path or not idedata.firmware_elf_path: + _LOGGER.debug("decode_pc no addr2line") + return + command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + try: + translation = subprocess.check_output(command, close_fds=False).decode().strip() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Caught exception for command %s", command, exc_info=1) + return + + if "?? ??:0" in translation: + # Nothing useful + return + translation = translation.replace(" at ??:?", "").replace(":?", "") + _LOGGER.warning("Decoded %s", translation) + + +def _parse_register(config, regex, line): + match = regex.match(line) + if match is not None: + _decode_pc(config, match.group(1)) + + +STACKTRACE_ESP32_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7}).*") +STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r"EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP32_C3_PC_RE = re.compile(r"MEPC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP32_C3_RA_RE = re.compile(r"RA\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_BAD_ALLOC_RE = re.compile( + r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" +) +STACKTRACE_ESP32_BACKTRACE_RE = re.compile( + r"Backtrace:(?:\s*0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8})+" +) +STACKTRACE_ESP32_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") +# ESP32 crash handler (stored backtrace from previous boot) +STACKTRACE_ESP32_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") + + +def process_stacktrace(config, line, backtrace_state): + line = line.strip() + + # ESP32 PC/EXCVADDR + _parse_register(config, STACKTRACE_ESP32_PC_RE, line) + _parse_register(config, STACKTRACE_ESP32_EXCVADDR_RE, line) + # ESP32-C3 PC/RA + _parse_register(config, STACKTRACE_ESP32_C3_PC_RE, line) + _parse_register(config, STACKTRACE_ESP32_C3_RA_RE, line) + + # bad alloc + match = re.match(STACKTRACE_BAD_ALLOC_RE, line) + if match is not None: + _LOGGER.warning( + "Memory allocation of %s bytes failed at %s", match.group(2), match.group(1) + ) + _decode_pc(config, match.group(1)) + + # ESP32 crash handler backtrace (from previous boot) + match = re.search(STACKTRACE_ESP32_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + + # ESP32 single-line backtrace + match = re.match(STACKTRACE_ESP32_BACKTRACE_RE, line) + if match is not None: + _LOGGER.warning("Found stack trace! Trying to decode it") + for addr in re.finditer(STACKTRACE_ESP32_BACKTRACE_PC_RE, line): + _decode_pc(config, addr.group()) + + return backtrace_state diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 3c2806a307..b6383653f4 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -1,6 +1,7 @@ import logging from pathlib import Path import re +import subprocess import esphome.codegen as cg import esphome.config_validation as cv @@ -419,3 +420,117 @@ def copy_files() -> None: remove_float_scanf_file, CORE.relative_build_path("remove_float_scanf.py"), ) + + +# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder +ESP8266_EXCEPTION_CODES = { + 0: "Illegal instruction (Is the flash damaged?)", + 1: "SYSCALL instruction", + 2: "InstructionFetchError: Processor internal physical address or data error during " + "instruction fetch", + 3: "LoadStoreError: Processor internal physical address or data error during load or store", + 4: "Level1Interrupt: Level-1 interrupt as indicated by set level-1 bits in the INTERRUPT " + "register", + 5: "Alloca: MOVSP instruction, if caller's registers are not in the register file", + 6: "Integer Divide By Zero", + 7: "reserved", + 8: "Privileged: Attempt to execute a privileged operation when CRING ? 0", + 9: "LoadStoreAlignmentCause: Load or store to an unaligned address", + 10: "reserved", + 11: "reserved", + 12: "InstrPIFDataError: PIF data error during instruction fetch", + 13: "LoadStorePIFDataError: Synchronous PIF data error during LoadStore access", + 14: "InstrPIFAddrError: PIF address error during instruction fetch", + 15: "LoadStorePIFAddrError: Synchronous PIF address error during LoadStore access", + 16: "InstTLBMiss: Error during Instruction TLB refill", + 17: "InstTLBMultiHit: Multiple instruction TLB entries matched", + 18: "InstFetchPrivilege: An instruction fetch referenced a virtual address at a ring level " + "less than CRING", + 19: "reserved", + 20: "InstFetchProhibited: An instruction fetch referenced a page mapped with an attribute " + "that does not permit instruction fetch", + 21: "reserved", + 22: "reserved", + 23: "reserved", + 24: "LoadStoreTLBMiss: Error during TLB refill for a load or store", + 25: "LoadStoreTLBMultiHit: Multiple TLB entries matched for a load or store", + 26: "LoadStorePrivilege: A load or store referenced a virtual address at a ring level less " + "than ", + 27: "reserved", + 28: "Access to invalid address: LOAD (wild pointer?)", + 29: "Access to invalid address: STORE (wild pointer?)", +} + + +def _decode_pc(config, addr): + from esphome import platformio_api + + idedata = platformio_api.get_idedata(config) + if not idedata.addr2line_path or not idedata.firmware_elf_path: + _LOGGER.debug("decode_pc no addr2line") + return + command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + try: + translation = subprocess.check_output(command, close_fds=False).decode().strip() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Caught exception for command %s", command, exc_info=1) + return + + if "?? ??:0" in translation: + # Nothing useful + return + translation = translation.replace(" at ??:?", "").replace(":?", "") + _LOGGER.warning("Decoded %s", translation) + + +def _parse_register(config, regex, line): + match = regex.match(line) + if match is not None: + _decode_pc(config, match.group(1)) + + +STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") +STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") +STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") +STACKTRACE_BAD_ALLOC_RE = re.compile( + r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" +) +STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") + + +def process_stacktrace(config, line, backtrace_state): + line = line.strip() + # ESP8266 Exception type + match = re.match(STACKTRACE_ESP8266_EXCEPTION_TYPE_RE, line) + if match is not None: + code = int(match.group(1)) + _LOGGER.warning( + "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") + ) + + # ESP8266 PC/EXCVADDR + _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) + _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) + + # bad alloc + match = re.match(STACKTRACE_BAD_ALLOC_RE, line) + if match is not None: + _LOGGER.warning( + "Memory allocation of %s bytes failed at %s", match.group(2), match.group(1) + ) + _decode_pc(config, match.group(1)) + + # ESP8266 multi-line backtrace + if ">>>stack>>>" in line: + # Start of backtrace + backtrace_state = True + _LOGGER.warning("Found stack trace! Trying to decode it") + elif "<< "IDEData": return idedata -# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder -ESP8266_EXCEPTION_CODES = { - 0: "Illegal instruction (Is the flash damaged?)", - 1: "SYSCALL instruction", - 2: "InstructionFetchError: Processor internal physical address or data error during " - "instruction fetch", - 3: "LoadStoreError: Processor internal physical address or data error during load or store", - 4: "Level1Interrupt: Level-1 interrupt as indicated by set level-1 bits in the INTERRUPT " - "register", - 5: "Alloca: MOVSP instruction, if caller's registers are not in the register file", - 6: "Integer Divide By Zero", - 7: "reserved", - 8: "Privileged: Attempt to execute a privileged operation when CRING ? 0", - 9: "LoadStoreAlignmentCause: Load or store to an unaligned address", - 10: "reserved", - 11: "reserved", - 12: "InstrPIFDataError: PIF data error during instruction fetch", - 13: "LoadStorePIFDataError: Synchronous PIF data error during LoadStore access", - 14: "InstrPIFAddrError: PIF address error during instruction fetch", - 15: "LoadStorePIFAddrError: Synchronous PIF address error during LoadStore access", - 16: "InstTLBMiss: Error during Instruction TLB refill", - 17: "InstTLBMultiHit: Multiple instruction TLB entries matched", - 18: "InstFetchPrivilege: An instruction fetch referenced a virtual address at a ring level " - "less than CRING", - 19: "reserved", - 20: "InstFetchProhibited: An instruction fetch referenced a page mapped with an attribute " - "that does not permit instruction fetch", - 21: "reserved", - 22: "reserved", - 23: "reserved", - 24: "LoadStoreTLBMiss: Error during TLB refill for a load or store", - 25: "LoadStoreTLBMultiHit: Multiple TLB entries matched for a load or store", - 26: "LoadStorePrivilege: A load or store referenced a virtual address at a ring level less " - "than ", - 27: "reserved", - 28: "Access to invalid address: LOAD (wild pointer?)", - 29: "Access to invalid address: STORE (wild pointer?)", -} - - -def _decode_pc(config, addr): - idedata = get_idedata(config) - if not idedata.addr2line_path or not idedata.firmware_elf_path: - _LOGGER.debug("decode_pc no addr2line") - return - command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] - try: - translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # pylint: disable=broad-except - _LOGGER.debug("Caught exception for command %s", command, exc_info=1) - return - - if "?? ??:0" in translation: - # Nothing useful - return - translation = translation.replace(" at ??:?", "").replace(":?", "") - _LOGGER.warning("Decoded %s", translation) - - -def _parse_register(config, regex, line): - match = regex.match(line) - if match is not None: - _decode_pc(config, match.group(1)) - - -STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") -STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") -STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") -STACKTRACE_ESP32_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7}).*") -STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r"EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") -STACKTRACE_ESP32_C3_PC_RE = re.compile(r"MEPC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") -STACKTRACE_ESP32_C3_RA_RE = re.compile(r"RA\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") -STACKTRACE_BAD_ALLOC_RE = re.compile( - r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" -) -STACKTRACE_ESP32_BACKTRACE_RE = re.compile( - r"Backtrace:(?:\s*0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8})+" -) -STACKTRACE_ESP32_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") -# ESP32 crash handler (stored backtrace from previous boot) -STACKTRACE_ESP32_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") -STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") - - -def process_stacktrace(config, line, backtrace_state): - line = line.strip() - # ESP8266 Exception type - match = re.match(STACKTRACE_ESP8266_EXCEPTION_TYPE_RE, line) - if match is not None: - code = int(match.group(1)) - _LOGGER.warning( - "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") - ) - - # ESP8266 PC/EXCVADDR - _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) - _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) - # ESP32 PC/EXCVADDR - _parse_register(config, STACKTRACE_ESP32_PC_RE, line) - _parse_register(config, STACKTRACE_ESP32_EXCVADDR_RE, line) - # ESP32-C3 PC/RA - _parse_register(config, STACKTRACE_ESP32_C3_PC_RE, line) - _parse_register(config, STACKTRACE_ESP32_C3_RA_RE, line) - - # bad alloc - match = re.match(STACKTRACE_BAD_ALLOC_RE, line) - if match is not None: - _LOGGER.warning( - "Memory allocation of %s bytes failed at %s", match.group(2), match.group(1) - ) - _decode_pc(config, match.group(1)) - - # ESP32 crash handler backtrace (from previous boot) - match = re.search(STACKTRACE_ESP32_CRASH_BT_RE, line) - if match is not None: - _decode_pc(config, match.group(1)) - - # ESP32 single-line backtrace - match = re.match(STACKTRACE_ESP32_BACKTRACE_RE, line) - if match is not None: - _LOGGER.warning("Found stack trace! Trying to decode it") - for addr in re.finditer(STACKTRACE_ESP32_BACKTRACE_PC_RE, line): - _decode_pc(config, addr.group()) - - # ESP8266 multi-line backtrace - if ">>>stack>>>" in line: - # Start of backtrace - backtrace_state = True - _LOGGER.warning("Found stack trace! Trying to decode it") - elif "<< str | None: "https://esphome.io/guides/esp32_arduino_to_idf/\n\n", ) ) + + +@dataclass +class FlashImage: + path: Path + offset: str diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py index 3970d1ce8b..333ef70b22 100644 --- a/tests/unit_tests/components/api/test_client.py +++ b/tests/unit_tests/components/api/test_client.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import patch +from esphome.components import esp32 from esphome.components.api import client as api_client from esphome.core import EsphomeError @@ -18,11 +19,11 @@ def test_decoder_swallows_esphome_error() -> None: reconnect. """ config = {"esphome": {"name": "test"}} - processor = api_client._LogLineProcessor(config, None) with patch.object( - api_client, "process_stacktrace", side_effect=EsphomeError("no idedata") + esp32, "process_stacktrace", side_effect=EsphomeError("no idedata") ) as mock_process: + processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) processor.process_line("PC: 0x4010496e") assert mock_process.called @@ -47,9 +48,9 @@ def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: must show a useful explanation rather than empty parens. """ config = {"esphome": {"name": "test"}} - processor = api_client._LogLineProcessor(config, None) - with patch.object(api_client, "process_stacktrace", side_effect=EsphomeError()): + with patch.object(esp32, "process_stacktrace", side_effect=EsphomeError()): + processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) processor.process_line("PC: 0x4010496e") warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] @@ -65,11 +66,11 @@ def test_decoder_short_circuits_after_failure() -> None: stall log streaming. """ config = {"esphome": {"name": "test"}} - processor = api_client._LogLineProcessor(config, None) with patch.object( - api_client, "process_stacktrace", side_effect=EsphomeError("no idedata") + esp32, "process_stacktrace", side_effect=EsphomeError("no idedata") ) as mock_process: + processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) processor.process_line("PC: 0x4010496e") processor.process_line("BT0: 0x4010496e") processor.process_line("BT1: 0x401049aa") @@ -80,18 +81,18 @@ def test_decoder_short_circuits_after_failure() -> None: def test_decoder_threads_backtrace_state() -> None: """When decoding succeeds, backtrace_state is threaded across calls.""" config = {"esphome": {"name": "test"}} - processor = api_client._LogLineProcessor(config, None) with patch.object( - api_client, "process_stacktrace", side_effect=[True, False] + esp32, "process_stacktrace", side_effect=[True, False] ) as mock_process: + processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) processor.process_line(">>>stack>>>") assert processor.backtrace_state is True processor.process_line("<< None: @@ -105,7 +106,7 @@ def test_decoder_uses_platform_handler_when_provided() -> None: processor = api_client._LogLineProcessor(config, platform_handler) - with patch.object(api_client, "process_stacktrace") as mock_generic: + with patch.object(esp32, "process_stacktrace") as mock_generic: processor.process_line("BT0: 0x4010496e") assert calls == [(config, "BT0: 0x4010496e", False)] diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py new file mode 100644 index 0000000000..5235f313d6 --- /dev/null +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -0,0 +1,109 @@ +"""Tests for ESP32 component.""" + +from pathlib import Path +from unittest.mock import Mock + + +def test_process_stacktrace_esp8266_exception(setup_core: Path, caplog) -> None: + """Test process_stacktrace handles ESP8266 exceptions.""" + from esphome.components.esp8266 import process_stacktrace + + config = {"name": "test"} + + # Test exception type parsing + line = "Exception (28):" + backtrace_state = False + + result = process_stacktrace(config, line, backtrace_state) + + assert "Access to invalid address: LOAD (wild pointer?)" in caplog.text + assert result is False + + +def test_process_stacktrace_esp8266_backtrace( + setup_core: Path, mock_esp8266_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 multi-line backtrace.""" + from esphome.components.esp8266 import process_stacktrace + + config = {"name": "test"} + + # Start of backtrace + line1 = ">>>stack>>>" + state = process_stacktrace(config, line1, False) + assert state is True + + # Backtrace content with addresses + line2 = "40201234 40205678" + state = process_stacktrace(config, line2, state) + assert state is True + assert mock_esp8266_decode_pc.call_count == 2 + + # End of backtrace + line3 = "<< None: + """Test process_stacktrace handles ESP32 single-line backtrace.""" + from esphome.components.esp32 import process_stacktrace + + config = {"name": "test"} + + line = "Backtrace: 0x40081234:0x3ffb1234 0x40085678:0x3ffb5678" + state = process_stacktrace(config, line, False) + + # Should decode both addresses + assert mock_esp32_decode_pc.call_count == 2 + mock_esp32_decode_pc.assert_any_call(config, "40081234") + mock_esp32_decode_pc.assert_any_call(config, "40085678") + assert state is False + + +def test_process_stacktrace_bad_alloc( + setup_core: Path, mock_esp32_decode_pc: Mock, caplog +) -> None: + """Test process_stacktrace handles bad alloc messages.""" + from esphome.components.esp32 import process_stacktrace + + config = {"name": "test"} + + line = "last failed alloc call: 40201234(512)" + state = process_stacktrace(config, line, False) + + assert "Memory allocation of 512 bytes failed at 40201234" in caplog.text + mock_esp32_decode_pc.assert_called_once_with(config, "40201234") + assert state is False + + +def test_process_stacktrace_esp32_crash_handler( + setup_core: Path, mock_esp32_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP32 crash handler backtrace lines.""" + from esphome.components.esp32 import process_stacktrace + + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp32.crash:078]: PC: 0x400D1234 (fault location)" + state = process_stacktrace(config, line_pc, False) + # PC line is matched by existing STACKTRACE_ESP32_PC_RE + mock_esp32_decode_pc.assert_called_with(config, "400D1234") + assert state is False + + mock_esp32_decode_pc.reset_mock() + + line_bt0 = "[E][esp32.crash:080]: BT0: 0x400D5678 (backtrace)" + state = process_stacktrace(config, line_bt0, False) + mock_esp32_decode_pc.assert_called_once_with(config, "400D5678") + assert state is False + + mock_esp32_decode_pc.reset_mock() + + line_bt1 = "[E][esp32.crash:080]: BT1: 0x42005ABC (backtrace)" + state = process_stacktrace(config, line_bt1, False) + mock_esp32_decode_pc.assert_called_once_with(config, "42005ABC") + assert state is False diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index dfd4305c4d..626f4168a6 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -77,9 +77,16 @@ def mock_run_platformio_cli_run() -> Generator[Mock, None, None]: @pytest.fixture -def mock_decode_pc() -> Generator[Mock, None, None]: - """Mock _decode_pc for platformio_api.""" - with patch("esphome.platformio_api._decode_pc") as mock: +def mock_esp32_decode_pc() -> Generator[Mock, None, None]: + """Mock _decode_pc for esp32.""" + with patch("esphome.components.esp32._decode_pc") as mock: + yield mock + + +@pytest.fixture +def mock_esp8266_decode_pc() -> Generator[Mock, None, None]: + """Mock _decode_pc for esp8266.""" + with patch("esphome.components.esp8266._decode_pc") as mock: yield mock diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 798a43a4ce..f8a3ea888e 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -54,6 +54,7 @@ from esphome.__main__ import ( ) from esphome.address_cache import AddressCache from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult +from esphome.components import esp32 from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_API, @@ -85,7 +86,7 @@ from esphome.const import ( ) from esphome.core import CORE, EsphomeError from esphome.espota2 import OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE -from esphome.util import BootselResult +from esphome.util import BootselResult, FlashImage from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1181,8 +1182,8 @@ def test_upload_using_esptool_path_conversion( mock_idedata = MagicMock(spec=platformio_api.IDEData) mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" mock_idedata.extra_flash_images = [ - platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), - platformio_api.FlashImage(path=tmp_path / "partitions.bin", offset="0x8000"), + FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), + FlashImage(path=tmp_path / "partitions.bin", offset="0x8000"), ] mock_get_idedata.return_value = mock_idedata @@ -1259,8 +1260,8 @@ def test_upload_using_esptool_skips_missing_extra_flash_images( mock_idedata = MagicMock(spec=platformio_api.IDEData) mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" mock_idedata.extra_flash_images = [ - platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), - platformio_api.FlashImage(path=missing_path, offset="0x2d0000"), + FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), + FlashImage(path=missing_path, offset="0x2d0000"), ] mock_get_idedata.return_value = mock_idedata @@ -4225,7 +4226,7 @@ def test_run_miniterm_batches_lines_with_same_timestamp( with ( patch("serial.Serial", return_value=mock_serial), - patch.object(platformio_api, "process_stacktrace") as mock_bt, + patch.object(esp32, "process_stacktrace") as mock_bt, ): mock_bt.return_value = False result = run_miniterm(config, "/dev/ttyUSB0", args) @@ -4264,7 +4265,7 @@ def test_run_miniterm_different_chunks_different_timestamps( with ( patch("serial.Serial", return_value=mock_serial), - patch.object(platformio_api, "process_stacktrace") as mock_bt, + patch.object(esp32, "process_stacktrace") as mock_bt, ): mock_bt.return_value = False result = run_miniterm(config, "/dev/ttyUSB0", args) @@ -4295,7 +4296,7 @@ def test_run_miniterm_handles_split_lines() -> None: with ( patch("serial.Serial", return_value=mock_serial), - patch.object(platformio_api, "process_stacktrace") as mock_bt, + patch.object(esp32, "process_stacktrace") as mock_bt, patch("esphome.__main__.safe_print") as mock_print, ): mock_bt.return_value = False @@ -4349,7 +4350,7 @@ def test_run_miniterm_backtrace_state_maintained() -> None: with ( patch("serial.Serial", return_value=mock_serial), patch.object( - platformio_api, + esp32, "process_stacktrace", side_effect=track_backtrace_state, ), @@ -4400,7 +4401,7 @@ def test_run_miniterm_handles_empty_reads( with ( patch("serial.Serial", return_value=mock_serial), - patch.object(platformio_api, "process_stacktrace") as mock_bt, + patch.object(esp32, "process_stacktrace") as mock_bt, ): mock_bt.return_value = False result = run_miniterm(config, "/dev/ttyUSB0", args) @@ -4473,7 +4474,7 @@ def test_run_miniterm_buffer_limit_prevents_unbounded_growth() -> None: with ( patch("serial.Serial", return_value=mock_serial), - patch.object(platformio_api, "process_stacktrace") as mock_bt, + patch.object(esp32, "process_stacktrace") as mock_bt, patch("esphome.__main__.safe_print") as mock_print, patch("esphome.__main__.SERIAL_BUFFER_MAX_SIZE", test_buffer_limit), ): diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index b241622f89..7a88ec4d9e 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -13,6 +13,7 @@ import pytest from esphome import platformio_api, platformio_runner from esphome.core import CORE, EsphomeError +from esphome.util import FlashImage def test_idedata_firmware_elf_path(setup_core: Path) -> None: @@ -70,7 +71,7 @@ def test_idedata_extra_flash_images(setup_core: Path) -> None: images = idedata.extra_flash_images assert len(images) == 2 - assert all(isinstance(img, platformio_api.FlashImage) for img in images) + assert all(isinstance(img, FlashImage) for img in images) assert images[0].path == Path("/path/to/bootloader.bin") assert images[0].offset == "0x1000" assert images[1].path == Path("/path/to/partition.bin") @@ -106,7 +107,7 @@ def test_idedata_cc_path(setup_core: Path) -> None: def test_flash_image_dataclass() -> None: """Test FlashImage dataclass stores path and offset correctly.""" - image = platformio_api.FlashImage(path=Path("/path/to/image.bin"), offset="0x10000") + image = FlashImage(path=Path("/path/to/image.bin"), offset="0x10000") assert image.path == Path("/path/to/image.bin") assert image.offset == "0x10000" @@ -708,101 +709,6 @@ def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None: assert build_dir.exists() -def test_process_stacktrace_esp8266_exception(setup_core: Path, caplog) -> None: - """Test process_stacktrace handles ESP8266 exceptions.""" - config = {"name": "test"} - - # Test exception type parsing - line = "Exception (28):" - backtrace_state = False - - result = platformio_api.process_stacktrace(config, line, backtrace_state) - - assert "Access to invalid address: LOAD (wild pointer?)" in caplog.text - assert result is False - - -def test_process_stacktrace_esp8266_backtrace( - setup_core: Path, mock_decode_pc: Mock -) -> None: - """Test process_stacktrace handles ESP8266 multi-line backtrace.""" - config = {"name": "test"} - - # Start of backtrace - line1 = ">>>stack>>>" - state = platformio_api.process_stacktrace(config, line1, False) - assert state is True - - # Backtrace content with addresses - line2 = "40201234 40205678" - state = platformio_api.process_stacktrace(config, line2, state) - assert state is True - assert mock_decode_pc.call_count == 2 - - # End of backtrace - line3 = "<< None: - """Test process_stacktrace handles ESP32 single-line backtrace.""" - config = {"name": "test"} - - line = "Backtrace: 0x40081234:0x3ffb1234 0x40085678:0x3ffb5678" - state = platformio_api.process_stacktrace(config, line, False) - - # Should decode both addresses - assert mock_decode_pc.call_count == 2 - mock_decode_pc.assert_any_call(config, "40081234") - mock_decode_pc.assert_any_call(config, "40085678") - assert state is False - - -def test_process_stacktrace_bad_alloc( - setup_core: Path, mock_decode_pc: Mock, caplog -) -> None: - """Test process_stacktrace handles bad alloc messages.""" - config = {"name": "test"} - - line = "last failed alloc call: 40201234(512)" - state = platformio_api.process_stacktrace(config, line, False) - - assert "Memory allocation of 512 bytes failed at 40201234" in caplog.text - mock_decode_pc.assert_called_once_with(config, "40201234") - assert state is False - - -def test_process_stacktrace_esp32_crash_handler( - setup_core: Path, mock_decode_pc: Mock -) -> None: - """Test process_stacktrace handles ESP32 crash handler backtrace lines.""" - config = {"name": "test"} - - # Simulate crash handler log lines as they appear from the API/serial - line_pc = "[E][esp32.crash:078]: PC: 0x400D1234 (fault location)" - state = platformio_api.process_stacktrace(config, line_pc, False) - # PC line is matched by existing STACKTRACE_ESP32_PC_RE - mock_decode_pc.assert_called_with(config, "400D1234") - assert state is False - - mock_decode_pc.reset_mock() - - line_bt0 = "[E][esp32.crash:080]: BT0: 0x400D5678 (backtrace)" - state = platformio_api.process_stacktrace(config, line_bt0, False) - mock_decode_pc.assert_called_once_with(config, "400D5678") - assert state is False - - mock_decode_pc.reset_mock() - - line_bt1 = "[E][esp32.crash:080]: BT1: 0x42005ABC (backtrace)" - state = platformio_api.process_stacktrace(config, line_bt1, False) - mock_decode_pc.assert_called_once_with(config, "42005ABC") - assert state is False - - def test_patch_file_downloader_succeeds_first_try() -> None: """Test patch_file_downloader succeeds on first attempt.""" mock_exception_cls = type("PackageException", (Exception,), {}) From 7c2a63bf82d7d5c34c475ba4413cc74e2876bae3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 May 2026 08:12:20 +1200 Subject: [PATCH 1053/2214] [api] Use safe_print for log output and fix safe_print bytes-repr fallback (#16160) --- esphome/components/api/client.py | 8 ++- esphome/util.py | 26 +++++-- tests/unit_tests/test_util.py | 116 +++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 0a5370cb30..d6150fbd29 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -19,6 +19,7 @@ import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ from esphome.core import CORE, EsphomeError +from esphome.util import safe_print from . import CONF_ENCRYPTION @@ -101,7 +102,6 @@ async def async_run_logs( noise_psk=noise_psk, addresses=addresses, # Pass all addresses for automatic retry ) - dashboard = CORE.dashboard # Try platform-specific stacktrace handler first, fall back to generic platform_process_stacktrace = None @@ -126,7 +126,11 @@ async def async_run_logs( f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" ) for parsed_msg in parse_log_message(text, timestamp): - print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) + # safe_print handles the dashboard \033 escaping and falls back + # to backslashreplace encoding on stdouts that can't represent + # the wifi signal-bar block characters (Windows redirected + # cp1252 pipe). + safe_print(parsed_msg) for raw_line in text.splitlines(): processor.process_line(raw_line) diff --git a/esphome/util.py b/esphome/util.py index 9d6e995f1f..39ce7c0963 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -94,13 +94,29 @@ def safe_print(message="", end="\n"): except UnicodeEncodeError: pass + # Fall back to the stream's actual encoding (e.g. cp1252 on Windows + # redirected pipes). Use "backslashreplace" so unencodable code points + # like the wifi signal-bar block characters (U+2582..U+2588) become + # readable ``\uXXXX`` escapes, and decode back to ``str`` so ``print`` + # never receives a ``bytes`` object (which would render as a ``b'...'`` + # repr). + encoding = sys.stdout.encoding or "ascii" try: - print(message.encode("utf-8", "backslashreplace"), end=end) + print( + message.encode(encoding, "backslashreplace").decode(encoding), + end=end, + ) + return except UnicodeEncodeError: - try: - print(message.encode("ascii", "backslashreplace"), end=end) - except UnicodeEncodeError: - print("Cannot print line because of invalid locale!") + pass + + try: + print( + message.encode("ascii", "backslashreplace").decode("ascii"), + end=end, + ) + except UnicodeEncodeError: + print("Cannot print line because of invalid locale!") def safe_input(prompt=""): diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index ff58fb1394..581b1aca99 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -709,3 +709,119 @@ def test_detect_rp2040_bootsel_timeout() -> None: result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 0 assert result.permission_error is False + + +class TestSafePrint: + """Tests for ``safe_print`` and its UnicodeEncodeError fallback chain.""" + + @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) + + def test_prints_plain_message(self, capsys: pytest.CaptureFixture[str]) -> None: + """ASCII-only messages take the fast path through native ``print``.""" + util.safe_print("hello world") + assert capsys.readouterr().out == "hello world\n" + + def test_prints_unicode_on_utf8_stdout( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Non-ASCII goes straight through when stdout can encode it.""" + util.safe_print("bars: \u2582\u2584\u2586\u2588") + assert capsys.readouterr().out == "bars: \u2582\u2584\u2586\u2588\n" + + def test_dashboard_escapes_esc_byte( + self, + capsys: pytest.CaptureFixture[str], + 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") + assert capsys.readouterr().out == "\\033[0;32mhi\\033[0m\n" + + def test_fallback_writes_string_not_bytes_repr( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: cp1252 fallback must produce a printable str, not ``b'...'``. + + On Windows, when stdout is a redirected pipe (e.g. the dashboard), + Python uses cp1252, which cannot encode the wifi signal-bar block + characters (U+2582..U+2588). The previous fallback path called + ``print(message.encode(...))`` with a ``bytes`` object, which + Python's ``print`` rendered as a literal ``b'...'`` repr — visible + in the user's dashboard output. The fix re-encodes through the + stream's encoding with ``backslashreplace`` and decodes back to + ``str``. + """ + buf = io.BytesIO() + cp1252_stream = io.TextIOWrapper(buf, encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", cp1252_stream) + + util.safe_print("bars: \u2582\u2584\u2586\u2588 done") + cp1252_stream.flush() + output = buf.getvalue().decode("cp1252") + + # Output is a clean line, not the bytes repr. + assert not output.startswith("b'") + assert "b'bars" not in output + # Unencodable codepoints become readable backslash escapes. + assert "\\u2582\\u2584\\u2586\\u2588" in output + # Encodable parts survive unchanged. + assert "bars: " in output + assert " done" in output + assert output.endswith("\n") + + def test_fallback_with_dashboard_escaped_message( + 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() + cp1252_stream = io.TextIOWrapper(buf, encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", cp1252_stream) + + util.safe_print("\033[0;32m\u2582\u2584\u2586\u2588\033[0m") + cp1252_stream.flush() + output = buf.getvalue().decode("cp1252") + + # Dashboard escaping turned ESC into literal "\033" (5 chars), which + # cp1252 can encode, so it survives the round-trip verbatim. + assert "\\033[0;32m" in output + assert "\\033[0m" in output + # Block characters became backslash escapes via backslashreplace. + assert "\\u2582\\u2584\\u2586\\u2588" in output + + def test_final_message_when_locale_is_invalid( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """If every encoding path fails, surface the locale-error sentinel.""" + original_print = print + call_count = 0 + + def fake_print(*args: Any, **kwargs: Any) -> None: + nonlocal call_count + call_count += 1 + # The first three calls are: native print, stream-encoding + # fallback, ASCII fallback. Make all three raise so we reach + # the final sentinel "Cannot print line..." which is expected + # to succeed (no encoding required). + if call_count <= 3: + raise UnicodeEncodeError("ascii", "x", 0, 1, "boom") + original_print(*args, **kwargs) + + monkeypatch.setattr("builtins.print", fake_print) + util.safe_print("x") + assert call_count == 4 + assert ( + capsys.readouterr().out == "Cannot print line because of invalid locale!\n" + ) From a460f5343cf8a350d34e25f159a1b54295a85a65 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 28 Apr 2026 05:27:20 -0500 Subject: [PATCH 1054/2214] [automation] Fix codegen type for component.resume update_interval (#16069) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/automation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/automation.py b/esphome/automation.py index b4dcc41995..bfbfd58d8a 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -598,7 +598,7 @@ async def component_resume_action_to_code( comp = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, comp) if CONF_UPDATE_INTERVAL in config: - template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, int) + template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, cg.uint32) cg.add(var.set_update_interval(template_)) return var From ce466c6b60fa086e323c8b7b65b2ffccb4a5070d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 18:14:07 -0500 Subject: [PATCH 1055/2214] [mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#16149) --- esphome/components/mcp23xxx_base/__init__.py | 22 +++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index cd952099c0..76a3aabe3f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -2,6 +2,7 @@ from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( + CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -30,10 +31,29 @@ MCP23XXX_INTERRUPT_MODES = { "FALLING": MCP23XXXInterruptMode.MCP23XXX_FALLING, } + +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): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) From 9371ec319a507bd0dfb185fbe65181370711e7b4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:04:52 +1200 Subject: [PATCH 1056/2214] [core] Strip \\?\ prefix from sys.executable for PlatformIO subprocess (#16158) --- esphome/platformio_api.py | 44 ++++++++++- tests/unit_tests/test_platformio_api.py | 99 +++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index fc21977fdd..544bca3b94 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -53,6 +53,37 @@ FILTER_PLATFORMIO_LINES = [ ] +def _strip_win_long_path_prefix(path: str) -> str: + r"""Strip the Windows extended-length path prefix from ``path``. + + Handles both forms documented at + https://learn.microsoft.com/windows/win32/fileio/naming-a-file: + + * ``\\?\C:\path\to\file`` -> ``C:\path\to\file`` + * ``\\?\UNC\server\share\path`` -> ``\\server\share\path`` + + The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with + ``sys.executable`` already prefixed with ``\\?\``. That prefix propagates + into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from + the environment, falling back to ``os.path.normpath(sys.executable)``) + and ends up baked into SCons-emitted command lines for build steps such + as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand + the ``\\?\`` prefix, so the build fails with + "The system cannot find the path specified." Stripping the prefix early + keeps the path shell-quotable. + + No-op on non-Windows platforms. + """ + if sys.platform != "win32": + return path + if path.startswith("\\\\?\\UNC\\"): + # \\?\UNC\server\share\... -> \\server\share\... + return "\\\\" + path[len("\\\\?\\UNC\\") :] + if path.startswith("\\\\?\\"): + return path[len("\\\\?\\") :] + return path + + def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) @@ -63,7 +94,18 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") - cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args) + # Strip the Windows extended-length path prefix from sys.executable so it + # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted + # command lines run through cmd.exe. + python_exe = _strip_win_long_path_prefix(sys.executable) + if python_exe != sys.executable: + # Only override PYTHONEXEPATH when we actually stripped a prefix. + # PlatformIO's get_pythonexe_path() reads this and falls back to + # sys.executable otherwise; setting it unconditionally would clobber + # a user-provided value (or the unmodified path on platforms that + # don't need the strip). + os.environ["PYTHONEXEPATH"] = python_exe + cmd = [python_exe, "-m", "esphome.platformio_runner"] + list(args) return run_external_process(*cmd, **kwargs) diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index ddc4e45c84..a92e00167d 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -311,6 +311,105 @@ def test_run_platformio_cli_sets_environment_variables( assert "arg" in args +@pytest.mark.parametrize( + ("platform", "input_path", "expected"), + [ + # win32: drive-letter extended-length prefix is stripped + ( + "win32", + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + ), + # win32: UNC extended-length prefix is translated to a regular UNC path + ( + "win32", + "\\\\?\\UNC\\server\\share\\python.exe", + "\\\\server\\share\\python.exe", + ), + # win32: paths without the prefix are returned unchanged + ( + "win32", + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + ), + # non-win32: prefix is left alone (no-op) + ("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"), + ("darwin", "/usr/bin/python3", "/usr/bin/python3"), + ], +) +def test_strip_win_long_path_prefix( + platform: str, input_path: str, expected: str +) -> None: + r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32.""" + with patch("esphome.platformio_api.sys.platform", platform): + assert platformio_api._strip_win_long_path_prefix(input_path) == expected + + +def test_run_platformio_cli_strips_win_long_path_prefix( + setup_core: Path, mock_run_external_process: Mock +) -> None: + r"""Windows ``\\?\`` prefix on sys.executable does not leak into the subprocess. + + The NSIS-installed esphome.exe launcher starts Python with + ``sys.executable`` already prefixed by the extended-length path marker. + That prefix would otherwise propagate into PlatformIO's ``PYTHONEXE`` and + break SCons-emitted command lines run through ``cmd.exe``. + """ + CORE.build_path = str(setup_core / "build" / "test") + prefixed_exe = ( + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe" + ) + stripped_exe = ( + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe" + ) + + with ( + patch.dict(os.environ, {}, clear=False), + patch("esphome.platformio_api.sys.platform", "win32"), + patch("esphome.platformio_api.sys.executable", prefixed_exe), + ): + # Pop any pre-existing PYTHONEXEPATH so the assertion below reflects + # what run_platformio_cli set, not whatever the test runner's + # environment happened to contain. + os.environ.pop("PYTHONEXEPATH", None) + mock_run_external_process.return_value = 0 + platformio_api.run_platformio_cli("test", "arg") + + # The subprocess is invoked with the stripped executable path. + mock_run_external_process.assert_called_once() + args = mock_run_external_process.call_args[0] + assert args[0] == stripped_exe + # PYTHONEXEPATH is exported with the stripped path so PlatformIO's + # get_pythonexe_path() picks it up in the subprocess. + assert os.environ["PYTHONEXEPATH"] == stripped_exe + + +def test_run_platformio_cli_does_not_set_pythonexepath_without_strip( + setup_core: Path, mock_run_external_process: Mock +) -> None: + r"""PYTHONEXEPATH is not touched when sys.executable has no ``\\?\`` prefix. + + Setting it unconditionally would clobber a user-provided value (or + interfere with non-Windows tooling that has no prefix to strip). + """ + CORE.build_path = str(setup_core / "build" / "test") + plain_exe = "/usr/bin/python3" + + with ( + patch.dict(os.environ, {}, clear=False), + patch("esphome.platformio_api.sys.platform", "linux"), + patch("esphome.platformio_api.sys.executable", plain_exe), + ): + os.environ.pop("PYTHONEXEPATH", None) + mock_run_external_process.return_value = 0 + platformio_api.run_platformio_cli("test", "arg") + + mock_run_external_process.assert_called_once() + args = mock_run_external_process.call_args[0] + assert args[0] == plain_exe + assert "PYTHONEXEPATH" not in os.environ + + def test_run_platformio_cli_run_builds_command( setup_core: Path, mock_run_platformio_cli: Mock ) -> None: From 60a94fd10972681f28772716103d0ca46177104d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 12:27:54 -0500 Subject: [PATCH 1057/2214] [esp32] Replace 512B stack buffer in printf wraps with picolibc cookie FILE (#16170) --- esphome/components/esp32/printf_stubs.cpp | 77 +++++++++++++++++++---- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index 386fbbd79d..908b4023ea 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -13,14 +13,21 @@ * and printf() calls in SDK components are only in debug/assert paths * (gpio_dump_io_configuration, ringbuf diagnostics) that are either * GC'd or never called. Crash backtraces and panic output are - * unaffected — they use esp_rom_printf() which is a ROM function + * unaffected; they use esp_rom_printf() which is a ROM function * and does not go through libc. * - * These stubs redirect through vsnprintf() (which uses _svfprintf_r - * already in the binary) and fwrite(), allowing the linker to - * dead-code eliminate _vfprintf_r. + * On picolibc (default for IDF >= 5 on RISC-V, IDF >= 6 everywhere) we + * route output through a stack-allocated cookie FILE that forwards each + * byte to the real target stream via fputc(). Picolibc's tinystdio + * vfprintf walks the FILE::put callback one character at a time, so this + * costs ~32 bytes of stack for the cookie struct vs. a 512-byte format + * buffer. The buffered path overflows the loopTask stack on IDF 6. * - * Saves ~11 KB of flash. + * On newlib (IDF <= 5 on Xtensa) we keep the original snprintf-then-fwrite + * path because that loopTask stack budget has plenty of headroom for the + * 512-byte buffer; the picolibc-only crash above does not affect it. + * + * Saves ~11 KB of flash on newlib, ~2.8 KB on picolibc. * * To disable these wraps, set enable_full_printf: true in the esp32 * advanced config section. @@ -30,10 +37,55 @@ #include #include +#ifndef __PICOLIBC__ #include "esp_system.h" +#endif namespace esphome::esp32 {} +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +#ifdef __PICOLIBC__ + +#include +#include + +extern int __real_vfprintf(FILE *stream, const char *fmt, va_list ap); + +namespace { + +struct CookieFile { + FILE base; + FILE *target; +}; + +// cookie_put() recovers CookieFile* from FILE* via reinterpret_cast, which is +// only well-defined when FILE is the first member at offset 0 and CookieFile +// is standard-layout. +static_assert(offsetof(CookieFile, base) == 0, "FILE must be the first member of CookieFile"); +static_assert(std::is_standard_layout::value, "CookieFile must be standard-layout"); + +int cookie_put(char c, FILE *stream) { + auto *cookie = reinterpret_cast(stream); + return fputc(static_cast(c), cookie->target); +} + +const FILE COOKIE_FILE_TEMPLATE = FDEV_SETUP_STREAM(cookie_put, nullptr, nullptr, _FDEV_SETUP_WRITE); + +} // namespace + +int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { + CookieFile cookie; + cookie.base = COOKIE_FILE_TEMPLATE; + cookie.target = stream; + return __real_vfprintf(&cookie.base, fmt, ap); +} + +int __wrap_vprintf(const char *fmt, va_list ap) { return __wrap_vfprintf(stdout, fmt, ap); } + +#else // !__PICOLIBC__ + static constexpr size_t PRINTF_BUFFER_SIZE = 512; // These stubs are essentially dead code at runtime — ESPHome replaces the @@ -55,14 +107,18 @@ static int write_printf_buffer(FILE *stream, char *buf, int len) { return len; } -// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -extern "C" { - int __wrap_vprintf(const char *fmt, va_list ap) { char buf[PRINTF_BUFFER_SIZE]; return write_printf_buffer(stdout, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); } +int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { + char buf[PRINTF_BUFFER_SIZE]; + return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); +} + +#endif // __PICOLIBC__ + int __wrap_printf(const char *fmt, ...) { va_list ap; va_start(ap, fmt); @@ -71,11 +127,6 @@ int __wrap_printf(const char *fmt, ...) { return len; } -int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { - char buf[PRINTF_BUFFER_SIZE]; - return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); -} - int __wrap_fprintf(FILE *stream, const char *fmt, ...) { va_list ap; va_start(ap, fmt); From d9c22d6b56d80adbd9cf767244dc98b0dc4af4de Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 1 May 2026 12:23:14 +1000 Subject: [PATCH 1058/2214] [lvgl] Clamp values for meter line indicators (#16180) --- esphome/components/lvgl/lvgl_esphome.cpp | 6 ++++-- esphome/components/lvgl/lvgl_esphome.h | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index d8248e4aa4..722a7a1b02 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -454,10 +454,12 @@ void LVTouchListener::update(const touchscreen::TouchPoints_t &tpoints) { #ifdef USE_LVGL_METER -int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value) { +int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value) { auto *scale = lv_obj_get_parent(obj); auto min_value = lv_scale_get_range_min_value(scale); - return ((value - min_value) * lv_scale_get_angle_range(scale) / (lv_scale_get_range_max_value(scale) - min_value) + + auto max_value = lv_scale_get_range_max_value(scale); + value = clamp(value, min_value, max_value); + return ((value - min_value) * lv_scale_get_angle_range(scale) / (max_value - min_value) + lv_scale_get_rotation((scale))) % 360; } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 146866f5bd..8c0b10e1bc 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -112,7 +112,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images #endif // USE_LVGL_ANIMIMG #ifdef USE_LVGL_METER -int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value); +int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); #endif // Parent class for things that wrap an LVGL object From 0418f2138af5ba4057708e91d06dd015a0d9d4d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 14:31:56 -0500 Subject: [PATCH 1059/2214] [esp32] Drop printf wrap on IDF 6.0+ (picolibc no longer needs it) (#16189) --- esphome/components/esp32/__init__.py | 12 +++- esphome/components/esp32/printf_stubs.cpp | 85 ++++++----------------- 2 files changed, 31 insertions(+), 66 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 77b405a449..30acbc3e41 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1740,7 +1740,17 @@ async def to_code(config): # Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r # (~11 KB). See printf_stubs.cpp for implementation. - if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF]: + # + # The wrap is only beneficial against newlib. Picolibc's tinystdio + # implements vsnprintf by building a string-output FILE and calling + # vfprintf, so vfprintf is unconditionally linked in by any caller + # of snprintf/vsnprintf — effectively every build — and the wrap + # saves nothing while costing ~170 B of shim. IDF 5.x defaults to + # newlib on every variant; IDF 6.0+ switches to picolibc on every + # variant. + if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF] or idf_version() >= cv.Version( + 6, 0, 0 + ): cg.add_define("USE_FULL_PRINTF") else: for symbol in ("vprintf", "printf", "fprintf", "vfprintf"): diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index 908b4023ea..489c503942 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -1,91 +1,48 @@ /* - * Linker wrap stubs for FILE*-based printf functions. + * Linker wrap stubs for FILE*-based printf functions (newlib only). * * ESP-IDF SDK components (gpio driver, ringbuf, log_write) reference - * fprintf(), printf(), vprintf(), and vfprintf() which pull in the full - * printf implementation (~11 KB on newlib's _vfprintf_r, ~2.8 KB on - * picolibc's vfprintf). This is a separate implementation from the one - * used by snprintf/vsnprintf that handles FILE* stream I/O with buffering - * and locking. + * fprintf(), printf(), vprintf(), and vfprintf(), which on newlib pull + * in _vfprintf_r (~11 KB) — a separate implementation from the one used + * by snprintf/vsnprintf that handles FILE* stream I/O with buffering. * * ESPHome replaces the ESP-IDF log handler via esp_log_set_vprintf_(), * so the SDK's vprintf() path is dead code at runtime. The fprintf() * and printf() calls in SDK components are only in debug/assert paths * (gpio_dump_io_configuration, ringbuf diagnostics) that are either * GC'd or never called. Crash backtraces and panic output are - * unaffected; they use esp_rom_printf() which is a ROM function - * and does not go through libc. + * unaffected; they use esp_rom_printf() which is a ROM function and + * does not go through libc. * - * On picolibc (default for IDF >= 5 on RISC-V, IDF >= 6 everywhere) we - * route output through a stack-allocated cookie FILE that forwards each - * byte to the real target stream via fputc(). Picolibc's tinystdio - * vfprintf walks the FILE::put callback one character at a time, so this - * costs ~32 bytes of stack for the cookie struct vs. a 512-byte format - * buffer. The buffered path overflows the loopTask stack on IDF 6. + * This wrap is newlib-only. On picolibc, vsnprintf is implemented as + * vfprintf into a string-output FILE, so vfprintf is unconditionally + * linked in by any caller of snprintf/vsnprintf and the wrap can never + * elide it — it just adds shim cost. Codegen forces USE_FULL_PRINTF + * on picolibc builds (IDF 6.0+ on all variants) so this file compiles + * to nothing there; the #error below catches a desynchronised gate. * - * On newlib (IDF <= 5 on Xtensa) we keep the original snprintf-then-fwrite - * path because that loopTask stack budget has plenty of headroom for the - * 512-byte buffer; the picolibc-only crash above does not affect it. + * Saves ~11 KB of flash on newlib. * - * Saves ~11 KB of flash on newlib, ~2.8 KB on picolibc. - * - * To disable these wraps, set enable_full_printf: true in the esp32 - * advanced config section. + * To disable this wrap on newlib, set enable_full_printf: true in the + * esp32 advanced config section. */ #if defined(USE_ESP_IDF) && !defined(USE_FULL_PRINTF) + +#ifdef __PICOLIBC__ +#error "printf wrap is net-negative on picolibc; codegen should set USE_FULL_PRINTF" +#endif + #include #include -#ifndef __PICOLIBC__ #include "esp_system.h" -#endif namespace esphome::esp32 {} // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) extern "C" { -#ifdef __PICOLIBC__ - -#include -#include - -extern int __real_vfprintf(FILE *stream, const char *fmt, va_list ap); - -namespace { - -struct CookieFile { - FILE base; - FILE *target; -}; - -// cookie_put() recovers CookieFile* from FILE* via reinterpret_cast, which is -// only well-defined when FILE is the first member at offset 0 and CookieFile -// is standard-layout. -static_assert(offsetof(CookieFile, base) == 0, "FILE must be the first member of CookieFile"); -static_assert(std::is_standard_layout::value, "CookieFile must be standard-layout"); - -int cookie_put(char c, FILE *stream) { - auto *cookie = reinterpret_cast(stream); - return fputc(static_cast(c), cookie->target); -} - -const FILE COOKIE_FILE_TEMPLATE = FDEV_SETUP_STREAM(cookie_put, nullptr, nullptr, _FDEV_SETUP_WRITE); - -} // namespace - -int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { - CookieFile cookie; - cookie.base = COOKIE_FILE_TEMPLATE; - cookie.target = stream; - return __real_vfprintf(&cookie.base, fmt, ap); -} - -int __wrap_vprintf(const char *fmt, va_list ap) { return __wrap_vfprintf(stdout, fmt, ap); } - -#else // !__PICOLIBC__ - static constexpr size_t PRINTF_BUFFER_SIZE = 512; // These stubs are essentially dead code at runtime — ESPHome replaces the @@ -117,8 +74,6 @@ int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); } -#endif // __PICOLIBC__ - int __wrap_printf(const char *fmt, ...) { va_list ap; va_start(ap, fmt); From be84e6c9f48294c5e231e01ea21e7fadf562c815 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:55:40 -0500 Subject: [PATCH 1060/2214] [api] Fall back to owning types for service array args used after a delay (#16140) --- esphome/components/api/__init__.py | 45 ++++++++++++++++++++------- tests/components/api/common-base.yaml | 18 +++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 84589d540d..623da2247e 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -72,17 +72,35 @@ APIUnregisterServiceCallAction = api_ns.class_( UserServiceTrigger = api_ns.class_("UserServiceTrigger", automation.Trigger) ListEntitiesServicesArgument = api_ns.class_("ListEntitiesServicesArgument") -SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = { +# Owning element type for each YAML service variable type. Used to derive both +# the zero-copy native types and the owning fallback types below. +_SERVICE_ARG_SCALAR_TYPES: dict[str, MockObj] = { "bool": cg.bool_, "int": cg.int32, "float": cg.float_, + "string": cg.std_string, +} +SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = { + # Scalars are passed by value; string uses a non-owning view into rx_buf_. + **_SERVICE_ARG_SCALAR_TYPES, "string": cg.StringRef, - "bool[]": cg.FixedVector.template(cg.bool_).operator("const").operator("ref"), - "int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"), - "float[]": cg.FixedVector.template(cg.float_).operator("const").operator("ref"), - "string[]": cg.FixedVector.template(cg.std_string) - .operator("const") - .operator("ref"), + # Arrays are passed as non-owning const references into rx_buf_. + **{ + f"{name}[]": cg.FixedVector.template(t).operator("const").operator("ref") + for name, t in _SERVICE_ARG_SCALAR_TYPES.items() + }, +} +# Owning fallback types used when the action chain contains non-synchronous actions +# (delay, wait_until, script.wait, etc.). The default non-owning types reference +# storage in the receive buffer, which is reused once the synchronous portion of +# the chain returns. FixedVector is also non-copyable, so the deferred lambda +# capture in DelayAction::play_complex would fail to compile. +SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = { + "string": cg.std_string, + **{ + f"{name}[]": cg.std_vector.template(t) + for name, t in _SERVICE_ARG_SCALAR_TYPES.items() + }, } CONF_ENCRYPTION = "encryption" CONF_BATCH_DELAY = "batch_delay" @@ -382,17 +400,20 @@ async def to_code(config: ConfigType) -> None: func_args.append((cg.bool_, "return_response")) # Check if action chain has non-synchronous actions that would make - # non-owning StringRef dangle (rx_buf_ reused after delay) + # non-owning args (StringRef, const FixedVector&) dangle once the + # rx_buf_ is reused after a delay/wait_until/script.wait/etc. The + # FixedVector references would also fail to compile because they + # are non-copyable and DelayAction captures args by value. has_non_synchronous = automation.has_non_synchronous_actions( conf.get(CONF_THEN, []) ) service_arg_names: list[str] = [] for name, var_ in conf[CONF_VARIABLES].items(): - native = SERVICE_ARG_NATIVE_TYPES[var_] - # Fall back to std::string for string args if non-synchronous actions exist - if has_non_synchronous and native is cg.StringRef: - native = cg.std_string + if has_non_synchronous and var_ in SERVICE_ARG_FALLBACK_TYPES: + native = SERVICE_ARG_FALLBACK_TYPES[var_] + else: + native = SERVICE_ARG_NATIVE_TYPES[var_] service_template_args.append(native) func_args.append((native, name)) service_arg_names.append(name) diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index c766b61b13..504c52a57b 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -91,6 +91,24 @@ api: - float_arr.size() - string_arr[0].c_str() - string_arr.size() + # Test array + string args used after a non-synchronous action (delay). + # The default non-owning types (StringRef, const FixedVector&) would + # dangle once rx_buf_ is reused, and FixedVector is non-copyable so + # DelayAction's lambda capture would fail to compile. The api codegen + # must fall back to owning std::string / std::vector here. + - action: array_with_delay + variables: + name: string + int_arr: int[] + string_arr: string[] + then: + - delay: 20ms + - logger.log: + format: "Delayed: %s (%u ints, %u strings)" + args: + - name.c_str() + - int_arr.size() + - string_arr.size() # Test ContinuationAction (IfAction with then/else branches) - action: test_if_action variables: From 2d7f9dc48d9d072d0f3c291f31fa83d9d4e6604c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 May 2026 08:12:20 +1200 Subject: [PATCH 1061/2214] [api] Use safe_print for log output and fix safe_print bytes-repr fallback (#16160) --- esphome/components/api/client.py | 8 ++- esphome/util.py | 26 +++++-- tests/unit_tests/test_util.py | 116 +++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 0c6c569c7d..0982ca905b 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -20,6 +20,7 @@ import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ from esphome.core import CORE from esphome.platformio_api import process_stacktrace +from esphome.util import safe_print from . import CONF_ENCRYPTION @@ -60,7 +61,6 @@ async def async_run_logs( noise_psk=noise_psk, addresses=addresses, # Pass all addresses for automatic retry ) - dashboard = CORE.dashboard backtrace_state = False # Try platform-specific stacktrace handler first, fall back to generic @@ -82,7 +82,11 @@ async def async_run_logs( f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" ) for parsed_msg in parse_log_message(text, timestamp): - print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) + # safe_print handles the dashboard \033 escaping and falls back + # to backslashreplace encoding on stdouts that can't represent + # the wifi signal-bar block characters (Windows redirected + # cp1252 pipe). + safe_print(parsed_msg) for raw_line in text.splitlines(): if platform_process_stacktrace: backtrace_state = platform_process_stacktrace( diff --git a/esphome/util.py b/esphome/util.py index 73cc3aa5ab..e96a52de86 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -94,13 +94,29 @@ def safe_print(message="", end="\n"): except UnicodeEncodeError: pass + # Fall back to the stream's actual encoding (e.g. cp1252 on Windows + # redirected pipes). Use "backslashreplace" so unencodable code points + # like the wifi signal-bar block characters (U+2582..U+2588) become + # readable ``\uXXXX`` escapes, and decode back to ``str`` so ``print`` + # never receives a ``bytes`` object (which would render as a ``b'...'`` + # repr). + encoding = sys.stdout.encoding or "ascii" try: - print(message.encode("utf-8", "backslashreplace"), end=end) + print( + message.encode(encoding, "backslashreplace").decode(encoding), + end=end, + ) + return except UnicodeEncodeError: - try: - print(message.encode("ascii", "backslashreplace"), end=end) - except UnicodeEncodeError: - print("Cannot print line because of invalid locale!") + pass + + try: + print( + message.encode("ascii", "backslashreplace").decode("ascii"), + end=end, + ) + except UnicodeEncodeError: + print("Cannot print line because of invalid locale!") def safe_input(prompt=""): diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index ff58fb1394..581b1aca99 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -709,3 +709,119 @@ def test_detect_rp2040_bootsel_timeout() -> None: result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 0 assert result.permission_error is False + + +class TestSafePrint: + """Tests for ``safe_print`` and its UnicodeEncodeError fallback chain.""" + + @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) + + def test_prints_plain_message(self, capsys: pytest.CaptureFixture[str]) -> None: + """ASCII-only messages take the fast path through native ``print``.""" + util.safe_print("hello world") + assert capsys.readouterr().out == "hello world\n" + + def test_prints_unicode_on_utf8_stdout( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Non-ASCII goes straight through when stdout can encode it.""" + util.safe_print("bars: \u2582\u2584\u2586\u2588") + assert capsys.readouterr().out == "bars: \u2582\u2584\u2586\u2588\n" + + def test_dashboard_escapes_esc_byte( + self, + capsys: pytest.CaptureFixture[str], + 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") + assert capsys.readouterr().out == "\\033[0;32mhi\\033[0m\n" + + def test_fallback_writes_string_not_bytes_repr( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: cp1252 fallback must produce a printable str, not ``b'...'``. + + On Windows, when stdout is a redirected pipe (e.g. the dashboard), + Python uses cp1252, which cannot encode the wifi signal-bar block + characters (U+2582..U+2588). The previous fallback path called + ``print(message.encode(...))`` with a ``bytes`` object, which + Python's ``print`` rendered as a literal ``b'...'`` repr — visible + in the user's dashboard output. The fix re-encodes through the + stream's encoding with ``backslashreplace`` and decodes back to + ``str``. + """ + buf = io.BytesIO() + cp1252_stream = io.TextIOWrapper(buf, encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", cp1252_stream) + + util.safe_print("bars: \u2582\u2584\u2586\u2588 done") + cp1252_stream.flush() + output = buf.getvalue().decode("cp1252") + + # Output is a clean line, not the bytes repr. + assert not output.startswith("b'") + assert "b'bars" not in output + # Unencodable codepoints become readable backslash escapes. + assert "\\u2582\\u2584\\u2586\\u2588" in output + # Encodable parts survive unchanged. + assert "bars: " in output + assert " done" in output + assert output.endswith("\n") + + def test_fallback_with_dashboard_escaped_message( + 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() + cp1252_stream = io.TextIOWrapper(buf, encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", cp1252_stream) + + util.safe_print("\033[0;32m\u2582\u2584\u2586\u2588\033[0m") + cp1252_stream.flush() + output = buf.getvalue().decode("cp1252") + + # Dashboard escaping turned ESC into literal "\033" (5 chars), which + # cp1252 can encode, so it survives the round-trip verbatim. + assert "\\033[0;32m" in output + assert "\\033[0m" in output + # Block characters became backslash escapes via backslashreplace. + assert "\\u2582\\u2584\\u2586\\u2588" in output + + def test_final_message_when_locale_is_invalid( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """If every encoding path fails, surface the locale-error sentinel.""" + original_print = print + call_count = 0 + + def fake_print(*args: Any, **kwargs: Any) -> None: + nonlocal call_count + call_count += 1 + # The first three calls are: native print, stream-encoding + # fallback, ASCII fallback. Make all three raise so we reach + # the final sentinel "Cannot print line..." which is expected + # to succeed (no encoding required). + if call_count <= 3: + raise UnicodeEncodeError("ascii", "x", 0, 1, "boom") + original_print(*args, **kwargs) + + monkeypatch.setattr("builtins.print", fake_print) + util.safe_print("x") + assert call_count == 4 + assert ( + capsys.readouterr().out == "Cannot print line because of invalid locale!\n" + ) From 197d4dac8e5e9da086f3596f90edbdce78886706 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 May 2026 08:27:10 +1200 Subject: [PATCH 1062/2214] Bump version to 2026.4.4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 0e6a845ed8..2e86f54e96 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.4.3 +PROJECT_NUMBER = 2026.4.4 # 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 89b6ff15ee..bc31ab36b5 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.3" +__version__ = "2026.4.4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 857e529803d10971ffd36089068b6590fac3dc6f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 4 May 2026 18:41:50 -0400 Subject: [PATCH 1063/2214] [audio] Use the microMP3 library instead of esp-audio-libs (#16236) --- esphome/components/audio/__init__.py | 1 + esphome/components/audio/audio_decoder.cpp | 108 ++++++++++----------- esphome/components/audio/audio_decoder.h | 13 +-- esphome/idf_component.yml | 2 + 4 files changed, 61 insertions(+), 63 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 8528e77ae7..60ff40ea4b 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,6 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") + add_idf_component(name="esphome/micro-mp3", ref="0.2.0") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index baa4c41c06..65a4db4e10 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -20,14 +20,6 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size) this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size); } -AudioDecoder::~AudioDecoder() { -#ifdef USE_AUDIO_MP3_SUPPORT - if (this->audio_file_type_ == AudioFileType::MP3) { - esp_audio_libs::helix_decoder::MP3FreeDecoder(this->mp3_decoder_); - } -#endif -} - esp_err_t AudioDecoder::add_source(std::weak_ptr &input_ring_buffer) { auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_); if (source == nullptr) { @@ -92,13 +84,10 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) { #endif #ifdef USE_AUDIO_MP3_SUPPORT case AudioFileType::MP3: - this->mp3_decoder_ = esp_audio_libs::helix_decoder::MP3InitDecoder(); - - // MP3 always has 1152 samples per chunk - this->free_buffer_required_ = 1152 * sizeof(int16_t) * 2; // samples * size per sample * channels - - // Always reallocate the output transfer buffer to the smallest necessary size - this->output_transfer_buffer_->reallocate(this->free_buffer_required_); + this->mp3_decoder_ = make_unique(); + this->free_buffer_required_ = + this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header + this->decoder_buffers_internally_ = true; break; #endif #ifdef USE_AUDIO_OPUS_SUPPORT @@ -312,51 +301,56 @@ FileDecoderState AudioDecoder::decode_flac_() { #ifdef USE_AUDIO_MP3_SUPPORT FileDecoderState AudioDecoder::decode_mp3_() { - // Look for the next sync word - int buffer_length = (int) this->input_buffer_->available(); - int32_t offset = esp_audio_libs::helix_decoder::MP3FindSyncWord(this->input_buffer_->data(), buffer_length); + // microMP3's samples_decoded value is samples per channel; e.g., what ESPHome typically calls an audio frame. + // microMP3 uses the term frame to refer to an MP3 frame: an encoded packet that contains multiple audio frames. + size_t bytes_consumed = 0; + size_t samples_decoded = 0; - if (offset < 0) { - // New data may have the sync word - this->input_buffer_->consume(buffer_length); + // microMP3 buffers internally: it consumes from our input buffer at its own pace, emits MP3_STREAM_INFO_READY once + // the first frame header is parsed, and only then produces PCM. It handles sync-word search and ID3v2 tag skipping. + micro_mp3::Mp3Result result = this->mp3_decoder_->decode( + this->input_buffer_->data(), this->input_buffer_->available(), this->output_transfer_buffer_->get_buffer_end(), + this->output_transfer_buffer_->free(), bytes_consumed, samples_decoded); + + this->input_buffer_->consume(bytes_consumed); + + if (result == micro_mp3::MP3_OK) { + if (samples_decoded > 0 && this->audio_stream_info_.has_value()) { + this->output_transfer_buffer_->increase_buffer_length( + this->audio_stream_info_.value().frames_to_bytes(samples_decoded)); + } + } else if (result == micro_mp3::MP3_STREAM_INFO_READY) { + // First successful header parse: capture stream info and resize the output buffer to fit one full frame. + // microMP3 always outputs 16-bit PCM. + this->audio_stream_info_ = + audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate()); + this->free_buffer_required_ = + this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t); + if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) { + return FileDecoderState::FAILED; + } + } else if (result == micro_mp3::MP3_NEED_MORE_DATA) { + return FileDecoderState::MORE_TO_PROCESS; + } else if (result == micro_mp3::MP3_OUTPUT_BUFFER_TOO_SMALL) { + // Reallocate to decode the frame on the next call + if (this->mp3_decoder_->get_channels() > 0) { + this->free_buffer_required_ = + this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t); + } else { + // Fallback to worst-case size if channel info isn't available + this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes(); + } + if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) { + return FileDecoderState::FAILED; + } + } else if (result == micro_mp3::MP3_DECODE_ERROR) { + // Corrupt frame skipped; recoverable, retry on next call + ESP_LOGW(TAG, "MP3 decoder skipped a corrupt frame"); return FileDecoderState::POTENTIALLY_FAILED; - } - - // Advance read pointer to match the offset for the syncword - this->input_buffer_->consume(offset); - const uint8_t *buffer_start = this->input_buffer_->data(); - - buffer_length = (int) this->input_buffer_->available(); - int err = esp_audio_libs::helix_decoder::MP3Decode(this->mp3_decoder_, &buffer_start, &buffer_length, - (int16_t *) this->output_transfer_buffer_->get_buffer_end(), 0); - - size_t consumed = this->input_buffer_->available() - buffer_length; - this->input_buffer_->consume(consumed); - - if (err) { - switch (err) { - case esp_audio_libs::helix_decoder::ERR_MP3_OUT_OF_MEMORY: - [[fallthrough]]; - case esp_audio_libs::helix_decoder::ERR_MP3_NULL_POINTER: - return FileDecoderState::FAILED; - break; - default: - // Most errors are recoverable by moving on to the next frame, so mark as potentailly failed - return FileDecoderState::POTENTIALLY_FAILED; - break; - } } else { - esp_audio_libs::helix_decoder::MP3FrameInfo mp3_frame_info; - esp_audio_libs::helix_decoder::MP3GetLastFrameInfo(this->mp3_decoder_, &mp3_frame_info); - if (mp3_frame_info.outputSamps > 0) { - int bytes_per_sample = (mp3_frame_info.bitsPerSample / 8); - this->output_transfer_buffer_->increase_buffer_length(mp3_frame_info.outputSamps * bytes_per_sample); - - if (!this->audio_stream_info_.has_value()) { - this->audio_stream_info_ = - audio::AudioStreamInfo(mp3_frame_info.bitsPerSample, mp3_frame_info.nChans, mp3_frame_info.samprate); - } - } + // MP3_ALLOCATION_FAILED, MP3_INPUT_INVALID, or any future error -- not recoverable + ESP_LOGE(TAG, "MP3 decoder failed: %d", static_cast(result)); + return FileDecoderState::FAILED; } return FileDecoderState::MORE_TO_PROCESS; diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h index 6e3a228a68..4cbe8b6720 100644 --- a/esphome/components/audio/audio_decoder.h +++ b/esphome/components/audio/audio_decoder.h @@ -16,9 +16,6 @@ #include "esp_err.h" // esp-audio-libs -#ifdef USE_AUDIO_MP3_SUPPORT -#include -#endif #include // micro-flac @@ -26,6 +23,11 @@ #include #endif +// micro-mp3 +#ifdef USE_AUDIO_MP3_SUPPORT +#include +#endif + // micro-opus #ifdef USE_AUDIO_OPUS_SUPPORT #include @@ -62,8 +64,7 @@ class AudioDecoder { /// @param output_buffer_size Size of the output transfer buffer in bytes. AudioDecoder(size_t input_buffer_size, size_t output_buffer_size); - /// @brief Deallocates the MP3 decoder (the flac, opus, and wav decoders are deallocated automatically) - ~AudioDecoder(); + ~AudioDecoder() = default; /// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr. /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership @@ -125,7 +126,7 @@ class AudioDecoder { #endif #ifdef USE_AUDIO_MP3_SUPPORT FileDecoderState decode_mp3_(); - esp_audio_libs::helix_decoder::HMP3Decoder mp3_decoder_; + std::unique_ptr mp3_decoder_; #endif #ifdef USE_AUDIO_OPUS_SUPPORT FileDecoderState decode_opus_(); diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f5a8dd8c60..5ad9090215 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -9,6 +9,8 @@ dependencies: version: 0.2.0 esphome/micro-flac: version: 0.1.1 + esphome/micro-mp3: + version: 0.2.0 esphome/micro-opus: version: 0.4.0 espressif/esp-dsp: From 556783b95ba30c4a40eda780f058c0fee0b18bf9 Mon Sep 17 00:00:00 2001 From: Olivier ARCHER Date: Tue, 5 May 2026 01:19:52 +0200 Subject: [PATCH 1064/2214] [http_request] remove slow http_request warning on 8266 (#16239) --- esphome/components/http_request/http_request_arduino.cpp | 6 ------ 1 file changed, 6 deletions(-) diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 217ad0064d..bb5e9427dd 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -70,12 +70,6 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur stream_ptr = std::make_unique(); #endif // USE_HTTP_REQUEST_ESP8266_HTTPS -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 1, 0) // && USE_ARDUINO_VERSION_CODE < VERSION_CODE(?, ?, ?) - if (!secure) { - ESP_LOGW(TAG, "Using HTTP on Arduino version >= 3.1 is **very** slow. Consider setting framework version to 3.0.2 " - "in your YAML, or use HTTPS"); - } -#endif // USE_ARDUINO_VERSION_CODE bool status = container->client_.begin(*stream_ptr, url.c_str()); #elif defined(USE_RP2040) From d28498ac2c6892a775988bc6712bbd18e5cf12b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 23:39:44 +0000 Subject: [PATCH 1065/2214] Bump cryptography from 47.0.0 to 48.0.0 (#16245) 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 789a3f7995..818453dc8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==47.0.0 +cryptography==48.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From f33d137669672fe909c3e4c625805f8190b4fc05 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 4 May 2026 19:45:11 -0400 Subject: [PATCH 1066/2214] [audio][media_player][speaker] WAV decoding is no longer always built (#16244) --- esphome/components/audio/__init__.py | 5 +++-- esphome/components/audio/audio.cpp | 6 ++++++ esphome/components/audio/audio.h | 2 ++ esphome/components/audio/audio_decoder.cpp | 6 ++++++ esphome/components/audio/audio_decoder.h | 18 +++++++++++++----- esphome/components/audio_file/__init__.py | 2 ++ esphome/components/media_player/__init__.py | 3 +++ .../speaker/media_player/__init__.py | 2 ++ .../media_player/speaker_media_player.cpp | 5 ++++- esphome/core/defines.h | 1 + 10 files changed, 42 insertions(+), 8 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 60ff40ea4b..7bd7ba9768 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -64,8 +64,7 @@ class AudioData: flac_support: bool = False mp3_support: bool = False opus_support: bool = False - # WAV defaults to True for backward compatibility; will become opt-in in a future release - wav_support: bool = True + wav_support: bool = False micro_decoder_support: bool = False flac: FlacOptions = field(default_factory=FlacOptions) mp3: Mp3Options = field(default_factory=Mp3Options) @@ -428,3 +427,5 @@ async def to_code(config): add_idf_sdkconfig_option( "CONFIG_OPUS_PSEUDOSTACK_SIZE", data.opus.pseudostack.size ) + if data.wav_support: + cg.add_define("USE_AUDIO_WAV_SUPPORT") diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index 3d675109e4..b977c4e918 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -55,8 +55,10 @@ const char *audio_file_type_to_string(AudioFileType file_type) { case AudioFileType::OPUS: return "OPUS"; #endif +#ifdef USE_AUDIO_WAV_SUPPORT case AudioFileType::WAV: return "WAV"; +#endif default: return "unknown"; } @@ -71,9 +73,11 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url) return AudioFileType::MP3; } #endif +#ifdef USE_AUDIO_WAV_SUPPORT if (strcasecmp(content_type, "audio/wav") == 0) { return AudioFileType::WAV; } +#endif #ifdef USE_AUDIO_FLAC_SUPPORT if (strcasecmp(content_type, "audio/flac") == 0 || strcasecmp(content_type, "audio/x-flac") == 0) { return AudioFileType::FLAC; @@ -91,9 +95,11 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url) // Fallback to URL extension if (url != nullptr && url[0] != '\0') { +#ifdef USE_AUDIO_WAV_SUPPORT if (str_endswith_ignore_case(url, ".wav")) { return AudioFileType::WAV; } +#endif #ifdef USE_AUDIO_MP3_SUPPORT if (str_endswith_ignore_case(url, ".mp3")) { return AudioFileType::MP3; diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h index d3b41a362f..9259f0a3c6 100644 --- a/esphome/components/audio/audio.h +++ b/esphome/components/audio/audio.h @@ -116,7 +116,9 @@ enum class AudioFileType : uint8_t { #ifdef USE_AUDIO_OPUS_SUPPORT OPUS, #endif +#ifdef USE_AUDIO_WAV_SUPPORT WAV, +#endif }; struct AudioFile { diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index 65a4db4e10..156704fb86 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -98,6 +98,7 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) { this->decoder_buffers_internally_ = true; break; #endif +#ifdef USE_AUDIO_WAV_SUPPORT case AudioFileType::WAV: this->wav_decoder_ = make_unique(); this->wav_decoder_->reset(); @@ -109,6 +110,7 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) { this->output_transfer_buffer_->reallocate(this->free_buffer_required_); } break; +#endif case AudioFileType::NONE: default: return ESP_ERR_NOT_SUPPORTED; @@ -226,9 +228,11 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { state = this->decode_opus_(); break; #endif +#ifdef USE_AUDIO_WAV_SUPPORT case AudioFileType::WAV: state = this->decode_wav_(); break; +#endif case AudioFileType::NONE: default: state = FileDecoderState::IDLE; @@ -395,6 +399,7 @@ FileDecoderState AudioDecoder::decode_opus_() { } #endif +#ifdef USE_AUDIO_WAV_SUPPORT FileDecoderState AudioDecoder::decode_wav_() { if (!this->audio_stream_info_.has_value()) { // Header hasn't been processed @@ -441,6 +446,7 @@ FileDecoderState AudioDecoder::decode_wav_() { return FileDecoderState::END_OF_FILE; } +#endif } // namespace audio } // namespace esphome diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h index 4cbe8b6720..8769e0b38b 100644 --- a/esphome/components/audio/audio_decoder.h +++ b/esphome/components/audio/audio_decoder.h @@ -15,9 +15,6 @@ #include "esp_err.h" -// esp-audio-libs -#include - // micro-flac #ifdef USE_AUDIO_FLAC_SUPPORT #include @@ -33,6 +30,11 @@ #include #endif +// esp-audio-libs +#ifdef USE_AUDIO_WAV_SUPPORT +#include +#endif + namespace esphome { namespace audio { @@ -56,7 +58,7 @@ class AudioDecoder { * @brief Class that facilitates decoding an audio file. * The audio file is read from a source (ring buffer or const data pointer), decoded, and sent to an audio sink * (ring buffer, speaker component, or callback). - * Supports wav, flac, mp3, and ogg opus formats. + * Supports flac, mp3, ogg opus, and wav formats (each enabled independently at compile time). */ public: /// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source() @@ -119,7 +121,6 @@ class AudioDecoder { void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; } protected: - std::unique_ptr wav_decoder_; #ifdef USE_AUDIO_FLAC_SUPPORT FileDecoderState decode_flac_(); std::unique_ptr flac_decoder_; @@ -132,7 +133,10 @@ class AudioDecoder { FileDecoderState decode_opus_(); std::unique_ptr opus_decoder_; #endif +#ifdef USE_AUDIO_WAV_SUPPORT FileDecoderState decode_wav_(); + std::unique_ptr wav_decoder_; +#endif std::unique_ptr input_buffer_; std::unique_ptr output_transfer_buffer_; @@ -142,14 +146,18 @@ class AudioDecoder { size_t input_buffer_size_{0}; size_t free_buffer_required_{0}; +#ifdef USE_AUDIO_WAV_SUPPORT size_t wav_bytes_left_{0}; +#endif uint32_t potentially_failed_count_{0}; uint32_t accumulated_frames_written_{0}; uint32_t playback_ms_{0}; bool end_of_file_{false}; +#ifdef USE_AUDIO_WAV_SUPPORT bool wav_has_known_end_{false}; +#endif bool decoder_buffers_internally_{false}; diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 88be6db168..b246633c31 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -193,6 +193,8 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType] audio.request_mp3_support() elif media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["OPUS"]): audio.request_opus_support() + elif media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["WAV"]): + audio.request_wav_support() return config diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 0024e3b965..aa1e88dca9 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -133,6 +133,7 @@ def request_codecs_for_format_configs( audio.request_flac_support() audio.request_mp3_support() audio.request_opus_support() + audio.request_wav_support() else: if "FLAC" in needed_formats: audio.request_flac_support() @@ -140,6 +141,8 @@ def request_codecs_for_format_configs( audio.request_mp3_support() if "OPUS" in needed_formats: audio.request_opus_support() + if "WAV" in needed_formats: + audio.request_wav_support() # Local config key constants diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index fbc83ef12f..90d9309f46 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -210,6 +210,8 @@ def _final_validate(config): audio.request_mp3_support() elif fmt_name == "OPUS": audio.request_opus_support() + elif fmt_name == "WAV": + audio.request_wav_support() break return config diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index ab11a89c3f..afd93b3f45 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -17,9 +17,12 @@ namespace speaker { // - Each stream has an individual speaker component for output // - Each stream is handled by an ``AudioPipeline`` object with two parts/tasks // - ``AudioReader`` handles reading from an HTTP source or from a PROGMEM flash set at compile time -// - ``AudioDecoder`` handles decoding the audio file. All formats are limited to two channels and 16 bits per sample +// - ``AudioDecoder`` handles decoding the audio file. All formats are limited to two channels and 16 bits per +// sample. +// Each format is enabled independently at compile time: // - FLAC // - MP3 (based on the libhelix decoder) +// - Ogg Opus // - WAV // - Each task runs until it is done processing the file or it receives a stop command // - Inter-task communication uses a FreeRTOS Event Group diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 93f4307e12..85454d3cc0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -180,6 +180,7 @@ #define USE_AUDIO_FLAC_SUPPORT #define USE_AUDIO_MP3_SUPPORT #define USE_AUDIO_OPUS_SUPPORT +#define USE_AUDIO_WAV_SUPPORT #define USE_API #define USE_API_CLIENT_CONNECTED_TRIGGER #define USE_API_CLIENT_DISCONNECTED_TRIGGER From ea2b2b39201a80c13d2ae1620179b2edc7bb40e9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 4 May 2026 21:12:26 -0400 Subject: [PATCH 1067/2214] [audio_file] Use microDecoder library instead of manual task management/decoding (#16237) --- .../audio_file/media_source/__init__.py | 28 +- .../media_source/audio_file_media_source.cpp | 332 +++++++----------- .../media_source/audio_file_media_source.h | 43 ++- 3 files changed, 165 insertions(+), 238 deletions(-) diff --git a/esphome/components/audio_file/media_source/__init__.py b/esphome/components/audio_file/media_source/__init__.py index e9e292a2b2..635a51b610 100644 --- a/esphome/components/audio_file/media_source/__init__.py +++ b/esphome/components/audio_file/media_source/__init__.py @@ -1,5 +1,7 @@ +from typing import Any + import esphome.codegen as cg -from esphome.components import media_source, psram +from esphome.components import audio, esp32, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM from esphome.types import ConfigType @@ -13,19 +15,30 @@ AudioFileMediaSource = audio_file_ns.class_( "AudioFileMediaSource", cg.Component, media_source.MediaSource ) + +def _request_micro_decoder(config: ConfigType) -> ConfigType: + audio.request_micro_decoder_support() + return config + + +def _validate_task_stack_in_psram(value: Any) -> bool: + if value := cv.boolean(value): + return cv.requires_component(psram.DOMAIN)(value) + return value + + CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioFileMediaSource, ) .extend( { - cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( - cv.boolean, cv.requires_component(psram.DOMAIN) - ), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, } ) .extend(cv.COMPONENT_SCHEMA), cv.only_on_esp32, + _request_micro_decoder, ) @@ -34,5 +47,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await media_source.register_media_source(var, config) - if CONF_TASK_STACK_IN_PSRAM in config: - cg.add(var.set_task_stack_in_psram(config[CONF_TASK_STACK_IN_PSRAM])) + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.cpp b/esphome/components/audio_file/media_source/audio_file_media_source.cpp index fbb5ecd88d..0cda1eca9e 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.cpp +++ b/esphome/components/audio_file/media_source/audio_file_media_source.cpp @@ -2,281 +2,185 @@ #ifdef USE_ESP32 -#include "esphome/components/audio/audio_decoder.h" +#include "esphome/core/log.h" + +#include +#include -#include #include namespace esphome::audio_file { -namespace { // anonymous namespace for internal linkage -struct AudioSinkAdapter : public audio::AudioSinkCallback { - media_source::MediaSource *source; - audio::AudioStreamInfo stream_info; - - size_t audio_sink_write(uint8_t *data, size_t length, TickType_t ticks_to_wait) override { - return this->source->write_output(data, length, pdTICKS_TO_MS(ticks_to_wait), this->stream_info); - } -}; -} // namespace - -#if defined(USE_AUDIO_OPUS_SUPPORT) -static constexpr uint32_t DECODE_TASK_STACK_SIZE = 5 * 1024; -#else -static constexpr uint32_t DECODE_TASK_STACK_SIZE = 3 * 1024; -#endif - static const char *const TAG = "audio_file_media_source"; -enum EventGroupBits : uint32_t { - // Requests to start playback (set by play_uri, handled by loop) - REQUEST_START = (1 << 0), - // Commands from main loop to decode task - COMMAND_STOP = (1 << 1), - COMMAND_PAUSE = (1 << 2), - // Decode task lifecycle signals (one-shot, cleared by loop) - TASK_STARTING = (1 << 7), - TASK_RUNNING = (1 << 8), - TASK_STOPPING = (1 << 9), - TASK_STOPPED = (1 << 10), - TASK_ERROR = (1 << 11), - // Decode task state (level-triggered, set/cleared by decode task) - TASK_PAUSED = (1 << 12), - ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits -}; +static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; +static constexpr size_t DECODER_TASK_STACK_SIZE = 5120; +static constexpr uint8_t DECODER_TASK_PRIORITY = 2; +static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20; +static constexpr char URI_PREFIX[] = "audio-file://"; + +namespace { // anonymous namespace for internal linkage + +// audio::AudioFileType and micro_decoder::AudioFileType use different numeric layouts (audio's +// values shift with USE_AUDIO_*_SUPPORT defines; micro_decoder's are fixed and guarded by +// MICRO_DECODER_CODEC_*). The codec request flow in audio/__init__.py keeps the two sets of +// guards aligned, so a switch with matching #ifdefs covers all reachable cases. +micro_decoder::AudioFileType to_micro_decoder_type(audio::AudioFileType type) { + switch (type) { +#ifdef USE_AUDIO_FLAC_SUPPORT + case audio::AudioFileType::FLAC: + return micro_decoder::AudioFileType::FLAC; +#endif +#ifdef USE_AUDIO_MP3_SUPPORT + case audio::AudioFileType::MP3: + return micro_decoder::AudioFileType::MP3; +#endif +#ifdef USE_AUDIO_OPUS_SUPPORT + case audio::AudioFileType::OPUS: + return micro_decoder::AudioFileType::OPUS; +#endif +#ifdef USE_AUDIO_WAV_SUPPORT + case audio::AudioFileType::WAV: + return micro_decoder::AudioFileType::WAV; +#endif + default: + return micro_decoder::AudioFileType::NONE; + } +} + +} // namespace void AudioFileMediaSource::dump_config() { - ESP_LOGCONFIG(TAG, "Audio File Media Source:"); - ESP_LOGCONFIG(TAG, " Task Stack in PSRAM: %s", this->task_stack_in_psram_ ? "Yes" : "No"); + ESP_LOGCONFIG(TAG, + "Audio File Media Source:\n" + " Decoder Task Stack in PSRAM: %s", + YESNO(this->decoder_task_stack_in_psram_)); } void AudioFileMediaSource::setup() { this->disable_loop(); - this->event_group_ = xEventGroupCreate(); - if (this->event_group_ == nullptr) { - ESP_LOGE(TAG, "Failed to create event group"); + micro_decoder::DecoderConfig config; + config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS; + config.decoder_priority = DECODER_TASK_PRIORITY; + config.decoder_stack_size = DECODER_TASK_STACK_SIZE; + config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_; + + this->decoder_ = std::make_unique(config); + if (this->decoder_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate decoder"); this->mark_failed(); return; } + this->decoder_->set_listener(this); } -void AudioFileMediaSource::loop() { - EventBits_t event_bits = xEventGroupGetBits(this->event_group_); +void AudioFileMediaSource::loop() { this->decoder_->loop(); } - if (event_bits & REQUEST_START) { - xEventGroupClearBits(this->event_group_, REQUEST_START); - this->decoding_state_ = AudioFileDecodingState::START_TASK; - } - - switch (this->decoding_state_) { - case AudioFileDecodingState::START_TASK: { - if (!this->decode_task_.is_created()) { - xEventGroupClearBits(this->event_group_, ALL_BITS); - if (!this->decode_task_.create(decode_task, "AudioFileDec", DECODE_TASK_STACK_SIZE, this, 1, - this->task_stack_in_psram_)) { - ESP_LOGE(TAG, "Failed to create task"); - this->status_momentary_error("task_create", 1000); - this->set_state_(media_source::MediaSourceState::ERROR); - this->decoding_state_ = AudioFileDecodingState::IDLE; - return; - } - } - this->decoding_state_ = AudioFileDecodingState::DECODING; - break; - } - case AudioFileDecodingState::DECODING: { - if (event_bits & TASK_STARTING) { - ESP_LOGD(TAG, "Starting"); - xEventGroupClearBits(this->event_group_, TASK_STARTING); - } - - if (event_bits & TASK_RUNNING) { - ESP_LOGV(TAG, "Started"); - xEventGroupClearBits(this->event_group_, TASK_RUNNING); - this->set_state_(media_source::MediaSourceState::PLAYING); - } - - if ((event_bits & TASK_PAUSED) && this->get_state() != media_source::MediaSourceState::PAUSED) { - this->set_state_(media_source::MediaSourceState::PAUSED); - } else if (!(event_bits & TASK_PAUSED) && this->get_state() == media_source::MediaSourceState::PAUSED) { - this->set_state_(media_source::MediaSourceState::PLAYING); - } - - if (event_bits & TASK_STOPPING) { - ESP_LOGV(TAG, "Stopping"); - xEventGroupClearBits(this->event_group_, TASK_STOPPING); - } - - if (event_bits & TASK_ERROR) { - // Report error so the orchestrator knows playback failed; task will have already logged the specific error - this->set_state_(media_source::MediaSourceState::ERROR); - } - - if (event_bits & TASK_STOPPED) { - ESP_LOGD(TAG, "Stopped"); - xEventGroupClearBits(this->event_group_, ALL_BITS); - - this->decode_task_.deallocate(); - this->set_state_(media_source::MediaSourceState::IDLE); - this->decoding_state_ = AudioFileDecodingState::IDLE; - } - break; - } - case AudioFileDecodingState::IDLE: { - if (this->get_state() == media_source::MediaSourceState::ERROR && !this->status_has_error()) { - this->set_state_(media_source::MediaSourceState::IDLE); - } - break; - } - } - - if ((this->decoding_state_ == AudioFileDecodingState::IDLE) && - (this->get_state() == media_source::MediaSourceState::IDLE)) { - this->disable_loop(); - } -} +bool AudioFileMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); } // Called from the orchestrator's main loop, so no synchronization needed with loop() bool AudioFileMediaSource::play_uri(const std::string &uri) { - if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener() || - xEventGroupGetBits(this->event_group_) & REQUEST_START) { + if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) { return false; } - // Check if source is already playing if (this->get_state() != media_source::MediaSourceState::IDLE) { ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str()); return false; } - // Validate URI starts with "audio-file://" - if (!uri.starts_with("audio-file://")) { + if (!uri.starts_with(URI_PREFIX)) { ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); return false; } - // Strip "audio-file://" prefix and find the file - const char *file_id = uri.c_str() + 13; // "audio-file://" is 13 characters - + const char *file_id = uri.c_str() + sizeof(URI_PREFIX) - 1; + this->current_file_ = nullptr; for (const auto &named_file : get_named_audio_files()) { if (strcmp(named_file.file_id, file_id) == 0) { this->current_file_ = named_file.file; - xEventGroupSetBits(this->event_group_, EventGroupBits::REQUEST_START); - this->enable_loop(); - return true; + break; } } - ESP_LOGE(TAG, "Unknown file: '%s'", file_id); + if (this->current_file_ == nullptr) { + ESP_LOGE(TAG, "Unknown file: '%s'", file_id); + return false; + } + + micro_decoder::AudioFileType type = to_micro_decoder_type(this->current_file_->file_type); + if (this->decoder_->play_buffer(this->current_file_->data, this->current_file_->length, type)) { + this->pause_.store(false, std::memory_order_relaxed); + this->enable_loop(); + return true; + } + + ESP_LOGE(TAG, "Failed to start playback of '%s'", file_id); return false; } // Called from the orchestrator's main loop, so no synchronization needed with loop() void AudioFileMediaSource::handle_command(media_source::MediaSourceCommand command) { - if (this->decoding_state_ != AudioFileDecodingState::DECODING) { - return; - } - switch (command) { case media_source::MediaSourceCommand::STOP: - xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_STOP); + this->decoder_->stop(); break; case media_source::MediaSourceCommand::PAUSE: - xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_PAUSE); + // Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state + // machine from getting stuck in PAUSED when no playback is active (which would block the + // next play_uri() call via its IDLE-state precondition). + if (this->get_state() != media_source::MediaSourceState::PLAYING) + break; + // PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily + // yields, which fills any internal buffering and applies back pressure that effectively + // pauses the decoder task. + this->set_state_(media_source::MediaSourceState::PAUSED); + this->pause_.store(true, std::memory_order_relaxed); break; case media_source::MediaSourceCommand::PLAY: - xEventGroupClearBits(this->event_group_, EventGroupBits::COMMAND_PAUSE); + if (this->get_state() != media_source::MediaSourceState::PAUSED) + break; + this->set_state_(media_source::MediaSourceState::PLAYING); + this->pause_.store(false, std::memory_order_relaxed); break; default: break; } } -void AudioFileMediaSource::decode_task(void *params) { - AudioFileMediaSource *this_source = static_cast(params); +// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for +// being thread-safe with respect to its own audio writer. +size_t AudioFileMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) { + if (this->pause_.load(std::memory_order_relaxed)) { + vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS)); + return 0; + } + return this->write_output(data, length, timeout_ms, this->stream_info_); +} - do { // do-while(false) ensures RAII objects are destroyed on all exit paths via break +// Called from the decoder task before the first on_audio_write(). +void AudioFileMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) { + this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate()); +} - xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STARTING); - - // 0 bytes for input transfer buffer makes it an inplace buffer - std::unique_ptr decoder = make_unique(0, 4096); - - esp_err_t err = decoder->start(this_source->current_file_->file_type); - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to start decoder: %s", esp_err_to_name(err)); - xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR | EventGroupBits::TASK_STOPPING); +// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main +// loop thread and it's safe to call set_state_() directly. +void AudioFileMediaSource::on_state_change(micro_decoder::DecoderState state) { + switch (state) { + case micro_decoder::DecoderState::IDLE: + this->set_state_(media_source::MediaSourceState::IDLE); + this->disable_loop(); break; - } - - // Add the file as a const data source - decoder->add_source(this_source->current_file_->data, this_source->current_file_->length); - - xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_RUNNING); - - AudioSinkAdapter audio_sink; - bool has_stream_info = false; - - while (true) { - EventBits_t event_bits = xEventGroupGetBits(this_source->event_group_); - - if (event_bits & EventGroupBits::COMMAND_STOP) { - break; - } - - bool paused = event_bits & EventGroupBits::COMMAND_PAUSE; - decoder->set_pause_output_state(paused); - if (paused) { - xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_PAUSED); - vTaskDelay(pdMS_TO_TICKS(20)); - } else { - xEventGroupClearBits(this_source->event_group_, EventGroupBits::TASK_PAUSED); - } - - // Will stop gracefully once finished with the current file - audio::AudioDecoderState decoder_state = decoder->decode(true); - - if (decoder_state == audio::AudioDecoderState::FINISHED) { - break; - } else if (decoder_state == audio::AudioDecoderState::FAILED) { - ESP_LOGE(TAG, "Decoder failed"); - xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR); - break; - } - - if (!has_stream_info && decoder->get_audio_stream_info().has_value()) { - has_stream_info = true; - - audio::AudioStreamInfo stream_info = decoder->get_audio_stream_info().value(); - - ESP_LOGD(TAG, "Bits per sample: %d, Channels: %d, Sample rate: %" PRIu32, stream_info.get_bits_per_sample(), - stream_info.get_channels(), stream_info.get_sample_rate()); - - if (stream_info.get_bits_per_sample() != 16 || stream_info.get_channels() > 2) { - ESP_LOGE(TAG, "Incompatible audio stream. Only 16 bits per sample and 1 or 2 channels are supported"); - xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR); - break; - } - - audio_sink.source = this_source; - audio_sink.stream_info = stream_info; - esp_err_t err = decoder->add_sink(&audio_sink); - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to add sink: %s", esp_err_to_name(err)); - xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR); - break; - } - } - } - - xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STOPPING); - } while (false); - - // All RAII objects from the do-while block (decoder, audio_sink, etc.) are now destroyed. - - xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STOPPED); - vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it + case micro_decoder::DecoderState::PLAYING: + this->set_state_(media_source::MediaSourceState::PLAYING); + break; + case micro_decoder::DecoderState::FAILED: + this->set_state_(media_source::MediaSourceState::ERROR); + break; + default: + break; + } } } // namespace esphome::audio_file diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.h b/esphome/components/audio_file/media_source/audio_file_media_source.h index 75e18c13b8..2c6189f272 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.h +++ b/esphome/components/audio_file/media_source/audio_file_media_source.h @@ -8,41 +8,48 @@ #include "esphome/components/audio_file/audio_file.h" #include "esphome/components/media_source/media_source.h" #include "esphome/core/component.h" -#include "esphome/core/static_task.h" -#include -#include +#include +#include + +#include +#include +#include namespace esphome::audio_file { -enum class AudioFileDecodingState : uint8_t { - START_TASK, - DECODING, - IDLE, -}; - -class AudioFileMediaSource : public Component, public media_source::MediaSource { +// Inherits from two unrelated listener-style interfaces: +// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator +// (the orchestrator calls set_listener() on us with a MediaSourceListener*). +// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded +// audio and state changes (we call decoder_->set_listener(this) in setup()). +class AudioFileMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { public: void setup() override; void loop() override; void dump_config() override; + void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; } + // MediaSource interface implementation bool play_uri(const std::string &uri) override; void handle_command(media_source::MediaSourceCommand command) override; - bool can_handle(const std::string &uri) const override { return uri.starts_with("audio-file://"); } + bool can_handle(const std::string &uri) const override; - void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + // DecoderListener interface implementation + size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override; + void on_stream_info(const micro_decoder::AudioStreamInfo &info) override; + void on_state_change(micro_decoder::DecoderState state) override; protected: - static void decode_task(void *params); - + std::unique_ptr decoder_; + audio::AudioStreamInfo stream_info_; audio::AudioFile *current_file_{nullptr}; - AudioFileDecodingState decoding_state_{AudioFileDecodingState::IDLE}; - EventGroupHandle_t event_group_{nullptr}; - StaticTask decode_task_; - bool task_stack_in_psram_{false}; + // Written from the main loop in handle_command(), read from the decoder task in + // on_audio_write(). Must be atomic to avoid a data race. + std::atomic pause_{false}; + bool decoder_task_stack_in_psram_{false}; }; } // namespace esphome::audio_file From efff8fe8be8a8b14076e92ecef57a78132fcba56 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 May 2026 14:29:23 +1200 Subject: [PATCH 1068/2214] [platformio_api] Remove duplicated _strip_win_long_path_prefix (#16249) --- esphome/platformio_api.py | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index fa88776acf..81ff01306a 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -12,37 +12,6 @@ from esphome.util import FlashImage, run_external_process _LOGGER = logging.getLogger(__name__) -def _strip_win_long_path_prefix(path: str) -> str: - r"""Strip the Windows extended-length path prefix from ``path``. - - Handles both forms documented at - https://learn.microsoft.com/windows/win32/fileio/naming-a-file: - - * ``\\?\C:\path\to\file`` -> ``C:\path\to\file`` - * ``\\?\UNC\server\share\path`` -> ``\\server\share\path`` - - The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with - ``sys.executable`` already prefixed with ``\\?\``. That prefix propagates - into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from - the environment, falling back to ``os.path.normpath(sys.executable)``) - and ends up baked into SCons-emitted command lines for build steps such - as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand - the ``\\?\`` prefix, so the build fails with - "The system cannot find the path specified." Stripping the prefix early - keeps the path shell-quotable. - - No-op on non-Windows platforms. - """ - if sys.platform != "win32": - return path - if path.startswith("\\\\?\\UNC\\"): - # \\?\UNC\server\share\... -> \\server\share\... - return "\\\\" + path[len("\\\\?\\UNC\\") :] - if path.startswith("\\\\?\\"): - return path[len("\\\\?\\") :] - return path - - def _strip_win_long_path_prefix(path: str) -> str: r"""Strip the Windows extended-length path prefix from ``path``. From edbb9f7b287da034eb529d325aa0f4245d2e3068 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 5 May 2026 07:15:32 -0500 Subject: [PATCH 1069/2214] [i2s_audio] Fix stereo playback when slot bit width exceeds data bit width (#16248) --- .../i2s_audio/speaker/i2s_audio_speaker_standard.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 0203464034..edb316e3a2 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -280,6 +280,9 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream } #else slot_cfg.slot_bit_width = this->slot_bit_width_; + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + slot_cfg.ws_width = static_cast(this->slot_bit_width_); + } #endif // USE_ESP32_VARIANT_ESP32 slot_cfg.slot_mask = slot_mask; From 87a705b1cc69ccfb07d2234c49356590db670621 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 5 May 2026 08:47:07 -0400 Subject: [PATCH 1070/2214] [audio] Bump microOpus to v0.4.1 (#16255) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 7bd7ba9768..7ecc45db5a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -402,7 +402,7 @@ async def to_code(config): ) if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") - add_idf_component(name="esphome/micro-opus", ref="0.4.0") + add_idf_component(name="esphome/micro-opus", ref="0.4.1") if data.opus.floating_point is not None: add_idf_sdkconfig_option( "CONFIG_OPUS_FLOATING_POINT", data.opus.floating_point diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5ad9090215..e8cad29439 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-mp3: version: 0.2.0 esphome/micro-opus: - version: 0.4.0 + version: 0.4.1 espressif/esp-dsp: version: "1.7.1" espressif/esp-tflite-micro: From 57397a318a2e08ea4c44bb7836358a7da20985f2 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 5 May 2026 12:21:02 -0400 Subject: [PATCH 1071/2214] [audio] Use the microWAV library for decoding (#16251) --- esphome/components/audio/__init__.py | 1 + esphome/components/audio/audio_decoder.cpp | 78 ++++++++-------------- esphome/components/audio/audio_decoder.h | 14 +--- esphome/idf_component.yml | 2 + 4 files changed, 35 insertions(+), 60 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 7ecc45db5a..80fd328e48 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -429,3 +429,4 @@ async def to_code(config): ) if data.wav_support: cg.add_define("USE_AUDIO_WAV_SUPPORT") + add_idf_component(name="esphome/micro-wav", ref="0.2.0") diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index 156704fb86..7abd03a36e 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -79,7 +79,6 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) { this->flac_decoder_ = make_unique(); this->free_buffer_required_ = this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header - this->decoder_buffers_internally_ = true; break; #endif #ifdef USE_AUDIO_MP3_SUPPORT @@ -87,7 +86,6 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) { this->mp3_decoder_ = make_unique(); this->free_buffer_required_ = this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header - this->decoder_buffers_internally_ = true; break; #endif #ifdef USE_AUDIO_OPUS_SUPPORT @@ -95,16 +93,12 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) { this->opus_decoder_ = make_unique(); this->free_buffer_required_ = this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header - this->decoder_buffers_internally_ = true; break; #endif #ifdef USE_AUDIO_WAV_SUPPORT case AudioFileType::WAV: - this->wav_decoder_ = make_unique(); - this->wav_decoder_->reset(); - - // Processing WAVs doesn't actually require a specific amount of buffer size, as it is already in PCM format. - // Thus, we don't reallocate to a minimum size. + this->wav_decoder_ = make_unique(); + // 1 KiB suffices to always make progress while avoiding excessive CPU spinning for decoding this->free_buffer_required_ = 1024; if (this->output_transfer_buffer_->capacity() < this->free_buffer_required_) { this->output_transfer_buffer_->reallocate(this->free_buffer_required_); @@ -181,10 +175,8 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { // Decode more audio - // Only shift data on the first loop iteration to avoid unnecessary, slow moves - // If the decoder buffers internally, then never shift - size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), - first_loop_iteration && !this->decoder_buffers_internally_); + // Never shift the input buffer; every decoder buffers internally and consumes only what it processed. + size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) { // Less data is available than what was processed in last iteration, so don't attempt to decode. @@ -401,50 +393,38 @@ FileDecoderState AudioDecoder::decode_opus_() { #ifdef USE_AUDIO_WAV_SUPPORT FileDecoderState AudioDecoder::decode_wav_() { - if (!this->audio_stream_info_.has_value()) { - // Header hasn't been processed + // microWAV's samples_decoded counts individual channel samples; e.g., for + // 16-bit stereo, 4 input bytes results in 2 samples_decoded. + size_t bytes_consumed = 0; + size_t samples_decoded = 0; - esp_audio_libs::wav_decoder::WAVDecoderResult result = - this->wav_decoder_->decode_header(this->input_buffer_->data(), this->input_buffer_->available()); + micro_wav::WAVDecoderResult result = this->wav_decoder_->decode( + this->input_buffer_->data(), this->input_buffer_->available(), this->output_transfer_buffer_->get_buffer_end(), + this->output_transfer_buffer_->free(), bytes_consumed, samples_decoded); - if (result == esp_audio_libs::wav_decoder::WAV_DECODER_SUCCESS_IN_DATA) { - this->input_buffer_->consume(this->wav_decoder_->bytes_processed()); + this->input_buffer_->consume(bytes_consumed); - this->audio_stream_info_ = audio::AudioStreamInfo( - this->wav_decoder_->bits_per_sample(), this->wav_decoder_->num_channels(), this->wav_decoder_->sample_rate()); - - this->wav_bytes_left_ = this->wav_decoder_->chunk_bytes_left(); - this->wav_has_known_end_ = (this->wav_bytes_left_ > 0); - return FileDecoderState::MORE_TO_PROCESS; - } else if (result == esp_audio_libs::wav_decoder::WAV_DECODER_WARNING_INCOMPLETE_DATA) { - // Available data didn't have the full header - return FileDecoderState::POTENTIALLY_FAILED; - } else { - return FileDecoderState::FAILED; + if (result == micro_wav::WAV_DECODER_SUCCESS) { + if (samples_decoded > 0 && this->audio_stream_info_.has_value()) { + this->output_transfer_buffer_->increase_buffer_length( + this->audio_stream_info_.value().samples_to_bytes(samples_decoded)); } + } else if (result == micro_wav::WAV_DECODER_HEADER_READY) { + // After HEADER_READY, get_bits_per_sample() returns the output bit depth + // (16 for A-law/mu-law, 32 for IEEE float, original value for PCM). + this->audio_stream_info_ = + audio::AudioStreamInfo(this->wav_decoder_->get_bits_per_sample(), this->wav_decoder_->get_channels(), + this->wav_decoder_->get_sample_rate()); + } else if (result == micro_wav::WAV_DECODER_NEED_MORE_DATA) { + return FileDecoderState::MORE_TO_PROCESS; + } else if (result == micro_wav::WAV_DECODER_END_OF_STREAM) { + return FileDecoderState::END_OF_FILE; } else { - if (!this->wav_has_known_end_ || (this->wav_bytes_left_ > 0)) { - size_t bytes_to_copy = this->input_buffer_->available(); - - if (this->wav_has_known_end_) { - bytes_to_copy = std::min(bytes_to_copy, this->wav_bytes_left_); - } - - bytes_to_copy = std::min(bytes_to_copy, this->output_transfer_buffer_->free()); - - if (bytes_to_copy > 0) { - std::memcpy(this->output_transfer_buffer_->get_buffer_end(), this->input_buffer_->data(), bytes_to_copy); - this->input_buffer_->consume(bytes_to_copy); - this->output_transfer_buffer_->increase_buffer_length(bytes_to_copy); - if (this->wav_has_known_end_) { - this->wav_bytes_left_ -= bytes_to_copy; - } - } - return FileDecoderState::IDLE; - } + ESP_LOGE(TAG, "WAV decoder failed: %d", static_cast(result)); + return FileDecoderState::FAILED; } - return FileDecoderState::END_OF_FILE; + return FileDecoderState::MORE_TO_PROCESS; } #endif diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h index 8769e0b38b..58e982317c 100644 --- a/esphome/components/audio/audio_decoder.h +++ b/esphome/components/audio/audio_decoder.h @@ -30,9 +30,9 @@ #include #endif -// esp-audio-libs +// micro-wav #ifdef USE_AUDIO_WAV_SUPPORT -#include +#include #endif namespace esphome { @@ -135,7 +135,7 @@ class AudioDecoder { #endif #ifdef USE_AUDIO_WAV_SUPPORT FileDecoderState decode_wav_(); - std::unique_ptr wav_decoder_; + std::unique_ptr wav_decoder_; #endif std::unique_ptr input_buffer_; @@ -146,20 +146,12 @@ class AudioDecoder { size_t input_buffer_size_{0}; size_t free_buffer_required_{0}; -#ifdef USE_AUDIO_WAV_SUPPORT - size_t wav_bytes_left_{0}; -#endif uint32_t potentially_failed_count_{0}; uint32_t accumulated_frames_written_{0}; uint32_t playback_ms_{0}; bool end_of_file_{false}; -#ifdef USE_AUDIO_WAV_SUPPORT - bool wav_has_known_end_{false}; -#endif - - bool decoder_buffers_internally_{false}; bool pause_output_{false}; }; diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index e8cad29439..14fb11ace5 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -13,6 +13,8 @@ dependencies: version: 0.2.0 esphome/micro-opus: version: 0.4.1 + esphome/micro-wav: + version: 0.2.0 espressif/esp-dsp: version: "1.7.1" espressif/esp-tflite-micro: From be82e8faeb35fde79b4d5e8ad51e57d8cc8de8e5 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Wed, 6 May 2026 01:02:26 +0200 Subject: [PATCH 1072/2214] [debug] Remove unused buffer in uicr lambda function (#16208) --- esphome/components/debug/debug_zephyr.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index 81c8612784..e23a0f668a 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -401,7 +401,6 @@ size_t DebugComponent::get_device_info_(std::span #endif auto uicr = [](volatile uint32_t *data, uint8_t size) { std::string res; - char buf[sizeof(uint32_t) * 2 + 1]; for (size_t i = 0; i < size; i++) { if (i > 0) { res += ' '; From f30ad588ea421db25f0784b91365bb44f5991751 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 May 2026 18:25:53 -0500 Subject: [PATCH 1073/2214] [cli] Add --ota-platform flag to pick web_server or native API OTA (#16207) --- esphome/__main__.py | 166 +++++- esphome/web_server_ota.py | 202 +++++++ tests/unit_tests/test_main.py | 283 ++++++++++ tests/unit_tests/test_web_server_ota.py | 670 ++++++++++++++++++++++++ 4 files changed, 1307 insertions(+), 14 deletions(-) create mode 100644 esphome/web_server_ota.py create mode 100644 tests/unit_tests/test_web_server_ota.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 222e299f6d..f4a276b74c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -28,6 +28,7 @@ from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, CONF_API, + CONF_AUTH, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, @@ -47,6 +48,8 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, + CONF_USERNAME, + CONF_WEB_SERVER, ENV_NOGITIGNORE, KEY_CORE, KEY_NATIVE_IDF, @@ -349,6 +352,17 @@ def choose_upload_log_host( elif bootsel.permission_error: bootsel_permission_error = True + # Annotate the OTA chooser entry only in the non-default case: when the + # config has web_server OTA but no native API OTA, the upload will fall + # through to the HTTP path and the user benefits from seeing that + # explicitly. The native-API path is the default and gets a plain label + # to avoid noise on the most common scenario. For LOGGING the OTA + # transport doesn't apply, so always leave the label plain. + if purpose == Purpose.UPLOADING and not has_native_ota() and has_web_server_ota(): + ota_suffix = " via web_server" + else: + ota_suffix = "" + def add_ota_options() -> None: """Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled.""" if (discovered := _discover_mac_suffix_devices()) is not None: @@ -356,11 +370,11 @@ def choose_upload_log_host( # intentionally skip the base-name fallback since with # name_add_mac_suffix on, the base name doesn't exist on the net. for host in discovered: - options.append((f"Over The Air ({host})", host)) + options.append((f"Over The Air{ota_suffix} ({host})", host)) elif has_resolvable_address(): - options.append((f"Over The Air ({CORE.address})", CORE.address)) + options.append((f"Over The Air{ota_suffix} ({CORE.address})", CORE.address)) if has_mqtt_ip_lookup(): - options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + options.append((f"Over The Air{ota_suffix} (MQTT IP lookup)", "MQTTIP")) if purpose == Purpose.LOGGING: if has_mqtt_logging(): @@ -429,7 +443,19 @@ def has_api() -> bool: def has_ota() -> bool: - """Check if OTA upload is available (requires platform: esphome).""" + """Check if any network OTA upload is available. + + True if the config exposes either ``platform: esphome`` (native API + OTA) or ``platform: web_server`` (HTTP OTA). Both reach the device + over the same network stack, so the OTA discovery path treats them + interchangeably; ``upload_program`` picks the actual transport based + on ``--ota-platform`` and what's configured. + """ + return has_native_ota() or has_web_server_ota() + + +def has_native_ota() -> bool: + """Check if native API OTA upload is available (``platform: esphome``).""" if CONF_OTA not in CORE.config: return False return any( @@ -438,6 +464,16 @@ def has_ota() -> bool: ) +def has_web_server_ota() -> bool: + """Check if web_server OTA upload is available (``platform: web_server``).""" + if CONF_OTA not in CORE.config: + return False + return any( + ota_item.get(CONF_PLATFORM) == CONF_WEB_SERVER + for ota_item in CORE.config[CONF_OTA] + ) + + def has_mqtt_ip_lookup() -> bool: """Check if MQTT is available and IP lookup is supported.""" from esphome.components.mqtt import CONF_DISCOVER_IP @@ -1115,25 +1151,83 @@ def upload_program( return exit_code, host if exit_code == 0 else None - ota_conf = {} + requested_platform = getattr(args, "ota_platform", None) + chosen_platform = _choose_ota_platform(config, requested_platform) + + # Resolve MQTT magic strings to actual IP addresses + network_devices = _resolve_network_devices(devices, config, args) + + if chosen_platform == CONF_WEB_SERVER: + if getattr(args, "partition_table", False): + raise EsphomeError( + "--partition-table is only supported with the esphome OTA platform; " + "the web_server OTA path can only update the firmware image." + ) + binary = CORE.firmware_bin + if getattr(args, "file", None) is not None: + binary = Path(args.file) + return _upload_via_web_server(config, network_devices, binary) + + return _upload_via_native_api(config, network_devices, args) + + +def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: + """Pick the OTA platform to use, optionally honoring ``--ota-platform``. + + Default behavior prefers ``esphome`` (native API) when it is configured. + The native API uses challenge-response auth with MD5/SHA256 hashing of a + server-issued nonce, so the password is never sent over the wire; the + ``web_server`` path uses HTTP Basic auth which transmits credentials in + cleartext over the LAN. (The native path also supports gzip compression + on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the + backend reports ``supports_compression() == false`` and the firmware is + sent uncompressed regardless of which platform is used.) Falls back to + ``web_server`` only when that is the only available platform. + """ + # Use a dict (insertion-ordered) instead of a list so error messages and + # membership checks see one entry per platform even if the user has + # multiple ``ota:`` items of the same platform; the web_server OTA + # platform's final-validate hook merges duplicates anyway. + available: dict[str, None] = {} for ota_item in config.get(CONF_OTA, []): - if ota_item[CONF_PLATFORM] == CONF_ESPHOME: + platform = ota_item.get(CONF_PLATFORM) + if platform in (CONF_ESPHOME, CONF_WEB_SERVER): + available[platform] = None + + if not available: + raise EsphomeError( + f"Cannot upload Over the Air as the {CONF_OTA} configuration is not " + f"present or does not include {CONF_PLATFORM}: {CONF_ESPHOME} or " + f"{CONF_PLATFORM}: {CONF_WEB_SERVER}" + ) + + if requested is not None: + if requested not in available: + raise EsphomeError( + f"--ota-platform {requested} was requested but the configuration " + f"only provides: {', '.join(available)}" + ) + return requested + + if CONF_ESPHOME in available: + return CONF_ESPHOME + return CONF_WEB_SERVER + + +def _upload_via_native_api( + config: ConfigType, network_devices: list[str], args: ArgsProtocol +) -> tuple[int, str | None]: + ota_conf: ConfigType = {} + for ota_item in config.get(CONF_OTA, []): + if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME: ota_conf = ota_item break - if not ota_conf: - raise EsphomeError( - f"Cannot upload Over the Air as the {CONF_OTA} configuration is not present or does not include {CONF_PLATFORM}: {CONF_ESPHOME}" - ) - from esphome import espota2 remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD) - # Resolve MQTT magic strings to actual IP addresses - network_devices = _resolve_network_devices(devices, config, args) - binary = CORE.firmware_bin ota_type = espota2.OTA_TYPE_UPDATE_APP if getattr(args, "partition_table", False): @@ -1157,6 +1251,28 @@ def upload_program( return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) +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 + + return web_server_ota.run_ota( + network_devices, remote_port, username, password, binary + ) + + # 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 @@ -1877,6 +1993,17 @@ def parse_args(argv): "--file", help="Manually specify the binary file to upload.", ) + parser_upload.add_argument( + "--ota-platform", + choices=[CONF_ESPHOME, CONF_WEB_SERVER], + help=( + "OTA platform to use for network uploads. Defaults to " + f"'{CONF_ESPHOME}' (native API) when configured because it uses " + "challenge-response auth so the password is never sent in " + f"cleartext on the wire. Falls back to '{CONF_WEB_SERVER}' " + "(HTTP Basic auth) when that is the only configured platform." + ), + ) parser_upload.add_argument( "--partition-table", help="Upload as partition table (OTA).", @@ -1951,6 +2078,17 @@ def parse_args(argv): help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).", action="store_true", ) + parser_run.add_argument( + "--ota-platform", + choices=[CONF_ESPHOME, CONF_WEB_SERVER], + help=( + "OTA platform to use for network uploads. Defaults to " + f"'{CONF_ESPHOME}' (native API) when configured because it uses " + "challenge-response auth so the password is never sent in " + f"cleartext on the wire. Falls back to '{CONF_WEB_SERVER}' " + "(HTTP Basic auth) when that is the only configured platform." + ), + ) parser_clean = subparsers.add_parser( "clean-mqtt", diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py new file mode 100644 index 0000000000..a49f46b270 --- /dev/null +++ b/esphome/web_server_ota.py @@ -0,0 +1,202 @@ +"""HTTP-based OTA upload via the ``web_server`` component's ``/update`` endpoint. + +This is the alternative to ``espota2`` (the native API OTA path). Useful when +a device only has ``platform: web_server`` configured under ``ota:``, or when +the user has lost the native OTA password but still has ``web_server`` basic +auth credentials. +""" + +from __future__ import annotations + +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 + +_LOGGER = logging.getLogger(__name__) + +OTA_PATH = "/update" +FORM_FIELD = "update" +# (connect_timeout, read_timeout). The device reboots after a successful +# upload so the read side must allow for a slow flash + response. +TIMEOUT = (20.0, 120.0) + + +class WebServerOTAError(EsphomeError): + pass + + +class _MultipartStreamer: + """Stream a single-file multipart/form-data body during transmission. + + ``requests.post(files=...)`` materializes the entire body in memory before + sending, so a progress callback wired into the file-like fires during + encoding instead of during the network send. Pass this via ``data=`` + (with ``__len__`` so urllib3 sets ``Content-Length`` instead of using + chunked transfer encoding); urllib3 then calls ``read(blocksize)`` + repeatedly during the POST and the progress bar tracks bytes leaving the + host. + """ + + def __init__(self, file: BinaryIO, file_size: int, filename: str) -> None: + self.boundary = f"esphomeOTA{secrets.token_hex(16)}" + prefix = ( + f"--{self.boundary}\r\n" + f'Content-Disposition: form-data; name="{FORM_FIELD}"; ' + f'filename="{filename}"\r\n' + f"Content-Type: application/octet-stream\r\n\r\n" + ).encode() + suffix = f"\r\n--{self.boundary}--\r\n".encode() + # Walked in order; ``read()`` advances to the next source on EOF. + self._sources: list[BinaryIO] = [io.BytesIO(prefix), file, io.BytesIO(suffix)] + self._idx = 0 + self._total = len(prefix) + file_size + len(suffix) + self._sent = 0 + self.progress = ProgressBar() + + def __len__(self) -> int: + return self._total + + @property + def content_type(self) -> str: + return f"multipart/form-data; boundary={self.boundary}" + + def read(self, size: int = -1) -> bytes: + remaining = self._total if size is None or size < 0 else size + out = bytearray() + while remaining > 0 and self._idx < len(self._sources): + chunk = self._sources[self._idx].read(remaining) + if not chunk: + self._idx += 1 + continue + out += chunk + remaining -= len(chunk) + if out: + self._sent += len(out) + self.progress.update(self._sent / self._total) + return bytes(out) + + +def _try_upload( + host: str, + port: int, + username: str | None, + password: str | None, + filename: Path, +) -> tuple[int, str | None]: + from esphome.core import CORE + + try: + addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + except EsphomeError as err: + _LOGGER.error( + "Error resolving IP address of %s. Is it connected to WiFi?", host + ) + if not CORE.dashboard: + _LOGGER.error("(If you know the IP, try --device )") + raise WebServerOTAError(err) from err + + if not addr_infos: + _LOGGER.error("Could not resolve %s", host) + return 1, None + + file_size = filename.stat().st_size + _LOGGER.info("Uploading %s (%s bytes) via web_server OTA", filename, file_size) + 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}" + _LOGGER.info("Connecting to %s port %s...", ip, port) + + try: + with open(filename, "rb") as fh: + streamer = _MultipartStreamer(fh, file_size, filename.name) + try: + response = requests.post( + url, + data=streamer, + auth=auth, + timeout=TIMEOUT, + headers={ + "Content-Type": streamer.content_type, + "Connection": "close", + }, + ) + finally: + streamer.progress.done() + except requests.RequestException as err: + _LOGGER.error("OTA upload to %s port %s failed: %s", ip, port, err) + continue + + if response.status_code == 401: + raise WebServerOTAError( + "Authentication failed (HTTP 401). Check the 'web_server' " + "'auth' username and password." + ) + if response.status_code != 200: + detail = response.text.strip() or response.reason or "no response body" + raise WebServerOTAError( + f"Unexpected HTTP {response.status_code} response from device: {detail}" + ) + + # The endpoint returns HTTP 200 for both success and failure; the + # body is what tells us which (see ota_web_server.cpp handleRequest). + body = response.text.strip() + if "Successful" in body: + _LOGGER.info("Device response: %s", body) + _LOGGER.info("OTA successful") + return 0, ip + + raise WebServerOTAError( + f"Device reported OTA failure: {body or 'no response body'}" + ) + + return 1, None + + +def run_ota( + remote_hosts: str | list[str], + remote_port: int, + username: str | None, + password: str | None, + filename: Path, +) -> tuple[int, str | None]: + """Upload ``filename`` to the first reachable host via ``web_server`` OTA. + + Mirrors :func:`esphome.espota2.run_ota` so callers can swap between the + two paths with the same return contract: ``(0, host)`` on success or + ``(1, None)`` on failure. + """ + hosts = [remote_hosts] if isinstance(remote_hosts, str) else list(remote_hosts) + for host in hosts: + try: + exit_code, used_host = _try_upload( + host, remote_port, username, password, filename + ) + except WebServerOTAError as err: + _LOGGER.error("%s", err) + continue + if exit_code == 0: + return 0, used_host + # Reached only when every attempt failed; per-attempt errors were + # already logged. This summary line gives the user an unambiguous + # "stop reading, nothing worked" marker. + _LOGGER.error("OTA upload failed.") + return 1, None diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index f8a3ea888e..0b96000a57 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -43,6 +43,7 @@ from esphome.__main__ import ( has_non_ip_address, has_ota, has_resolvable_address, + has_web_server_ota, mqtt_get_ip, run_esphome, run_miniterm, @@ -58,6 +59,7 @@ from esphome.components import esp32 from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_API, + CONF_AUTH, CONF_BAUD_RATE, CONF_BROKER, CONF_DISABLED, @@ -76,6 +78,8 @@ from esphome.const import ( CONF_SUBSTITUTIONS, CONF_TOPIC, CONF_USE_ADDRESS, + CONF_USERNAME, + CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, KEY_TARGET_PLATFORM, @@ -213,6 +217,13 @@ def mock_run_ota() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_run_web_server_ota() -> Generator[Mock]: + """Mock web_server_ota.run_ota for testing.""" + with patch("esphome.web_server_ota.run_ota") as mock: + yield mock + + @pytest.fixture def mock_is_ip_address() -> Generator[Mock]: """Mock is_ip_address for testing.""" @@ -1114,6 +1125,7 @@ class MockArgs: reset: bool = False list_only: bool = False output: str | None = None + ota_platform: str | None = None partition_table: bool = False @@ -1878,6 +1890,277 @@ def test_upload_program_ota_no_config( upload_program(config, args, devices) +def test_has_web_server_ota_detects_platform() -> None: + """has_web_server_ota returns True when web_server OTA platform is configured.""" + setup_core( + config={ + CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}], + } + ) + assert has_web_server_ota() is True + assert has_ota() is True + + +def test_has_web_server_ota_returns_false_without_config() -> None: + """has_web_server_ota returns False when only native OTA is configured.""" + setup_core( + config={ + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + } + ) + assert has_web_server_ota() is False + assert has_ota() is True + + +def test_upload_program_web_server_only_auto_dispatches( + mock_run_web_server_ota: Mock, + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """When only web_server OTA is configured, upload_program picks it automatically.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_web_server_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}], + CONF_WEB_SERVER: { + CONF_PORT: 80, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "pw"}, + }, + } + args = MockArgs() + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + expected_firmware = ( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_web_server_ota.assert_called_once_with( + ["192.168.1.100"], 80, "admin", "pw", expected_firmware + ) + mock_run_ota.assert_not_called() + + +def test_upload_program_web_server_no_auth( + mock_run_web_server_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """web_server OTA works without an auth block (passes None for credentials).""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_web_server_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}], + CONF_WEB_SERVER: {CONF_PORT: 8080}, + } + args = MockArgs() + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + expected_firmware = ( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_web_server_ota.assert_called_once_with( + ["192.168.1.100"], 8080, None, None, expected_firmware + ) + + +def test_upload_program_both_platforms_default_prefers_native( + mock_run_ota: Mock, + mock_run_web_server_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """When both OTA platforms are configured, default selection is native API.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "secret", + }, + {CONF_PLATFORM: CONF_WEB_SERVER}, + ], + CONF_WEB_SERVER: {CONF_PORT: 80}, + } + args = MockArgs() + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once() + mock_run_web_server_ota.assert_not_called() + + +def test_upload_program_ota_platform_override_to_web_server( + mock_run_ota: Mock, + mock_run_web_server_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--ota-platform web_server forces web_server OTA even when native is configured.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_web_server_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "secret", + }, + {CONF_PLATFORM: CONF_WEB_SERVER}, + ], + CONF_WEB_SERVER: {CONF_PORT: 80}, + } + args = MockArgs(ota_platform=CONF_WEB_SERVER) + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_not_called() + mock_run_web_server_ota.assert_called_once() + + +def test_upload_program_ota_platform_unavailable( + mock_get_port_type: Mock, +) -> None: + """--ota-platform must reference a platform that is actually configured.""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "NETWORK" + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "secret", + } + ], + } + args = MockArgs(ota_platform=CONF_WEB_SERVER) + devices = ["192.168.1.100"] + + with pytest.raises(EsphomeError, match="--ota-platform web_server"): + upload_program(config, args, devices) + + +def test_upload_program_web_server_missing_component( + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """web_server OTA without a web_server component fails with a clear error.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + + config = { + CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}], + # No CONF_WEB_SERVER + } + args = MockArgs() + devices = ["192.168.1.100"] + + with pytest.raises(EsphomeError, match="web_server.*not configured"): + upload_program(config, args, devices) + + +def test_upload_program_unrelated_ota_platform_ignored( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """OTA list entries that are neither esphome nor web_server are ignored. + + Covers the false branch in _choose_ota_platform's filter loop and the + no-match branch in _upload_via_native_api's lookup loop. + """ + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + {CONF_PLATFORM: "http_request"}, # unrelated platform; ignored + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "secret", + }, + ], + } + args = MockArgs() + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once() + + +def test_upload_program_duplicate_platform_dedup_in_error( + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Duplicate same-platform OTA entries don't repeat in --ota-platform errors.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + + config = { + CONF_OTA: [ + {CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}, + {CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3233}, + ], + } + args = MockArgs(ota_platform=CONF_WEB_SERVER) + devices = ["192.168.1.100"] + + with pytest.raises(EsphomeError) as excinfo: + upload_program(config, args, devices) + + # Error mentions esphome once in the platform list, not "esphome, esphome". + msg = str(excinfo.value) + assert "esphome, esphome" not in msg + assert msg.endswith(": esphome") + + +def test_upload_program_only_unrelated_ota_platforms( + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Only unrelated OTA platforms configured -> raises like missing OTA.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + + config = { + CONF_OTA: [{CONF_PLATFORM: "http_request"}], + } + args = MockArgs() + devices = ["192.168.1.100"] + + with pytest.raises(EsphomeError, match="Cannot upload Over the Air"): + upload_program(config, args, devices) + + def test_upload_program_ota_with_mqtt_resolution( mock_mqtt_get_ip: Mock, mock_is_ip_address: Mock, diff --git a/tests/unit_tests/test_web_server_ota.py b/tests/unit_tests/test_web_server_ota.py new file mode 100644 index 0000000000..606905e36e --- /dev/null +++ b/tests/unit_tests/test_web_server_ota.py @@ -0,0 +1,670 @@ +"""Unit tests for esphome.web_server_ota module.""" + +from __future__ import annotations + +import io +import logging +from pathlib import Path +import socket +from unittest.mock import MagicMock, patch + +import pytest +import requests +from requests.auth import HTTPBasicAuth + +from esphome.core import CORE, EsphomeError +from esphome.helpers import ProgressBar +from esphome.web_server_ota import ( + OTA_PATH, + WebServerOTAError, + _MultipartStreamer, + run_ota, +) + + +@pytest.fixture +def firmware(tmp_path: Path) -> Path: + binary = tmp_path / "firmware.bin" + binary.write_bytes(b"\x00\x01\x02FIRMWARE\xff" * 64) + return binary + + +def _make_response(status: int, body: str) -> MagicMock: + response = MagicMock(spec=requests.Response) + response.status_code = status + response.text = body + response.reason = "" + return response + + +def _patch_resolve( + monkeypatch: pytest.MonkeyPatch, hosts: list[tuple[str, int]] +) -> None: + """Replace resolve_ip_address so tests don't actually do DNS.""" + addr_infos = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", (host, port)) + for host, port in hosts + ] + monkeypatch.setattr( + "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + ) + + +# --------------------------------------------------------------------------- +# _MultipartStreamer +# --------------------------------------------------------------------------- + + +def test_multipart_streamer_emits_full_body() -> None: + """Streaming the whole body in one call yields prefix + file + suffix.""" + data = b"abcdef" * 100 + streamer = _MultipartStreamer(io.BytesIO(data), len(data), "fw.bin") + + body = streamer.read() + while True: + chunk = streamer.read() + if not chunk: + break + body += chunk + + assert body.startswith(f"--{streamer.boundary}\r\n".encode()) + assert b'name="update"' in body + assert b'filename="fw.bin"' in body + assert data in body + assert body.endswith(f"\r\n--{streamer.boundary}--\r\n".encode()) + + +def test_multipart_streamer_chunked_read_matches_full_read() -> None: + """Chunked reads (urllib3 calls read(8192) repeatedly) yield the same body.""" + data = b"abcdef" * 1000 # 6000 bytes + full = _MultipartStreamer(io.BytesIO(data), len(data), "fw.bin").read() + + streamed = bytearray() + s = _MultipartStreamer(io.BytesIO(data), len(data), "fw.bin") + # Same boundary lengths -> identical total length. + while True: + chunk = s.read(64) + if not chunk: + break + streamed += chunk + # Boundaries are random per instance, so compare lengths and structure. + assert len(streamed) == len(full) + assert streamed.startswith(f"--{s.boundary}\r\n".encode()) + assert streamed.endswith(f"\r\n--{s.boundary}--\r\n".encode()) + + +def test_multipart_streamer_len_matches_emitted_bytes() -> None: + """``__len__`` is what urllib3 uses to set Content-Length, so it must + equal the total bytes emitted by ``read``.""" + data = b"x" * 12345 + s = _MultipartStreamer(io.BytesIO(data), len(data), "fw.bin") + declared = len(s) + + emitted = 0 + while True: + chunk = s.read(1024) + if not chunk: + break + emitted += len(chunk) + + assert emitted == declared + + +def test_multipart_streamer_progress_ticks_during_read() -> None: + """Each read advances the progress bar (this is the whole point of + streaming via ``data=``: progress reflects bytes leaving the host).""" + data = b"x" * 1000 + s = _MultipartStreamer(io.BytesIO(data), len(data), "fw.bin") + + updates: list[float] = [] + s.progress.update = updates.append # type: ignore[method-assign] + + while True: + chunk = s.read(128) + if not chunk: + break + + assert updates, "progress.update was never called" + # Strictly non-decreasing. + assert updates == sorted(updates) + # Final update reaches (within FP) 1.0 because all bytes were read. + assert updates[-1] == pytest.approx(1.0, abs=1e-9) + + +def test_multipart_streamer_content_type_includes_boundary() -> None: + s = _MultipartStreamer(io.BytesIO(b""), 0, "fw.bin") + assert s.content_type == f"multipart/form-data; boundary={s.boundary}" + + +def test_multipart_streamer_zero_size_file() -> None: + """A zero-byte file still produces a well-formed body and progress is + skipped (avoiding a divide-by-zero on the empty file segment).""" + s = _MultipartStreamer(io.BytesIO(b""), 0, "empty.bin") + body = b"" + while True: + chunk = s.read(64) + if not chunk: + break + body += chunk + assert body.startswith(f"--{s.boundary}".encode()) + assert body.endswith(f"--{s.boundary}--\r\n".encode()) + + +def test_multipart_streamer_unique_boundary_per_instance() -> None: + a = _MultipartStreamer(io.BytesIO(b""), 0, "a") + b = _MultipartStreamer(io.BytesIO(b""), 0, "a") + assert a.boundary != b.boundary + + +def test_multipart_streamer_zero_size_read_returns_empty() -> None: + """``read(0)`` short-circuits without touching state.""" + s = _MultipartStreamer(io.BytesIO(b"x" * 10), 10, "fw.bin") + assert s.read(0) == b"" + # No bytes consumed. + assert s._sent == 0 + + +# --------------------------------------------------------------------------- +# run_ota +# --------------------------------------------------------------------------- + + +def test_run_ota_success(monkeypatch: pytest.MonkeyPatch, firmware: Path) -> None: + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ) as post: + exit_code, host = run_ota(["device.local"], 80, None, None, firmware) + + assert exit_code == 0 + assert host == "192.168.1.50" + post.assert_called_once() + args, kwargs = post.call_args + assert args == (f"http://192.168.1.50:80{OTA_PATH}",) + assert kwargs["auth"] is None + # Streaming body, not files=, so progress fires during transmission. + assert "files" not in kwargs + assert isinstance(kwargs["data"], _MultipartStreamer) + assert kwargs["headers"]["Content-Type"] == kwargs["data"].content_type + assert kwargs["headers"]["Connection"] == "close" + + +def test_run_ota_logs_device_response_body( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The device's HTTP response body is surfaced on success.""" + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + caplog.set_level(logging.INFO, logger="esphome.web_server_ota") + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ): + run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert "Device response: Update Successful!" in caplog.text + assert "OTA successful" in caplog.text + + +def test_run_ota_log_says_via_web_server( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The upload-start log line names the transport explicitly.""" + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + caplog.set_level(logging.INFO, logger="esphome.web_server_ota") + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ): + run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert "via web_server OTA" in caplog.text + + +def test_run_ota_sends_basic_auth( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ) as post: + exit_code, _ = run_ota(["192.168.1.50"], 80, "admin", "secret", firmware) + + assert exit_code == 0 + auth = post.call_args.kwargs["auth"] + assert isinstance(auth, HTTPBasicAuth) + assert auth.username == "admin" + assert auth.password == "secret" + + +def test_run_ota_skips_auth_when_no_credentials( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ) as post: + run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert post.call_args.kwargs["auth"] is None + + +def test_run_ota_skips_auth_when_only_username( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + """Both username and password are required to send Basic auth.""" + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ) as post: + run_ota(["192.168.1.50"], 80, "admin", None, firmware) + + assert post.call_args.kwargs["auth"] is None + + +def test_run_ota_uses_update_url( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + _patch_resolve(monkeypatch, [("192.168.1.50", 8080)]) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ) as post: + run_ota(["192.168.1.50"], 8080, None, None, firmware) + + url = post.call_args.args[0] + assert url == f"http://192.168.1.50:8080{OTA_PATH}" + assert OTA_PATH == "/update" + + +def test_run_ota_failure_response( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Failed!"), + ): + exit_code, host = run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert exit_code == 1 + assert host is None + assert "OTA failure" in caplog.text + + +def test_run_ota_failure_response_empty_body( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, ""), + ): + exit_code, host = run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert exit_code == 1 + assert host is None + assert "no response body" in caplog.text + + +def test_run_ota_auth_failed( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(401, "Unauthorized"), + ): + exit_code, host = run_ota(["192.168.1.50"], 80, "user", "wrong", firmware) + + assert exit_code == 1 + assert host is None + assert "Authentication failed" in caplog.text + + +def test_run_ota_unexpected_status_code( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(500, "Internal Error"), + ): + exit_code, host = run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert exit_code == 1 + assert host is None + assert "Unexpected HTTP 500" in caplog.text + + +def test_run_ota_unexpected_status_empty_body_falls_back( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Empty response body uses response.reason / a fallback in the error.""" + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + response = _make_response(503, "") + response.reason = "Service Unavailable" + + with patch( + "esphome.web_server_ota.requests.post", + return_value=response, + ): + exit_code, host = run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert exit_code == 1 + assert host is None + assert "Service Unavailable" in caplog.text + + +def test_run_ota_unexpected_status_no_body_no_reason( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Empty body and empty reason still produce a usable error message.""" + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + response = _make_response(599, "") + response.reason = "" + + with patch( + "esphome.web_server_ota.requests.post", + return_value=response, + ): + run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert "no response body" in caplog.text + + +def test_run_ota_connection_error_then_success( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + """First resolved address fails to connect, second succeeds.""" + _patch_resolve( + monkeypatch, + [("192.168.1.10", 80), ("192.168.1.50", 80)], + ) + + with patch( + "esphome.web_server_ota.requests.post", + side_effect=[ + requests.ConnectionError("refused"), + _make_response(200, "Update Successful!"), + ], + ) as post: + exit_code, host = run_ota(["device.local"], 80, None, None, firmware) + + assert exit_code == 0 + assert host == "192.168.1.50" + assert post.call_count == 2 + + +def test_run_ota_request_exception_falls_through( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + """A non-ConnectionError RequestException (e.g. timeout) falls through too.""" + _patch_resolve( + monkeypatch, + [("192.168.1.10", 80), ("192.168.1.50", 80)], + ) + + with patch( + "esphome.web_server_ota.requests.post", + side_effect=[ + requests.Timeout("read timeout"), + _make_response(200, "Update Successful!"), + ], + ): + exit_code, host = run_ota(["device.local"], 80, None, None, firmware) + + assert exit_code == 0 + assert host == "192.168.1.50" + + +def test_run_ota_all_addresses_unreachable( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + """When every resolved address fails to connect, run_ota returns failure.""" + _patch_resolve( + monkeypatch, + [("192.168.1.10", 80), ("192.168.1.20", 80)], + ) + + with patch( + "esphome.web_server_ota.requests.post", + side_effect=requests.ConnectionError("refused"), + ): + exit_code, host = run_ota(["device.local"], 80, None, None, firmware) + + assert exit_code == 1 + assert host is None + # Per-address failure is logged for each attempt; final summary follows. + assert caplog.text.count("OTA upload to ") >= 2 + assert "OTA upload failed." in caplog.text + + +def test_run_ota_no_resolved_addresses( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + """If resolve_ip_address returns no candidates, log and return failure.""" + _patch_resolve(monkeypatch, []) + + exit_code, host = run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert exit_code == 1 + assert host is None + assert "Could not resolve 192.168.1.50" in caplog.text + + +def test_run_ota_resolution_failure( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + def _raise(*_args, **_kwargs): + raise EsphomeError("dns failed") + + monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + + exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) + + assert exit_code == 1 + assert host is None + + +def test_run_ota_resolution_failure_dashboard_mode( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Dashboard mode skips the '--device ' tip on resolution failure.""" + + def _raise(*_args, **_kwargs): + raise EsphomeError("dns failed") + + monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr(CORE, "dashboard", True) + try: + exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) + finally: + monkeypatch.setattr(CORE, "dashboard", False) + + assert exit_code == 1 + assert host is None + assert "--device " not in caplog.text + + +def test_run_ota_empty_hosts(firmware: Path) -> None: + exit_code, host = run_ota([], 80, None, None, firmware) + assert exit_code == 1 + assert host is None + + +def test_run_ota_string_host_accepted( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + """A bare string is accepted in addition to a list of hosts.""" + _patch_resolve(monkeypatch, [("10.0.0.5", 80)]) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ): + exit_code, host = run_ota("10.0.0.5", 80, None, None, firmware) + + assert exit_code == 0 + assert host == "10.0.0.5" + + +def test_run_ota_multiple_hosts_first_fails( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + """Multi-host fallthrough: first host's addresses all fail, second host wins.""" + addr_lookup = { + "primary.local": [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.10", 80)), + ], + "secondary.local": [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.50", 80)), + ], + } + + def _resolve(host, port, address_cache=None): # noqa: ARG001 + return addr_lookup[host] + + monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + + with patch( + "esphome.web_server_ota.requests.post", + side_effect=[ + requests.ConnectionError("refused"), + _make_response(200, "Update Successful!"), + ], + ): + exit_code, host = run_ota( + ["primary.local", "secondary.local"], 80, None, None, firmware + ) + + assert exit_code == 0 + assert host == "192.168.1.50" + + +def test_run_ota_all_hosts_return_failure_no_exception( + monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture +) -> None: + """All hosts resolve to no addresses; run_ota cleanly returns failure.""" + addr_lookup = { + "a.local": [], + "b.local": [], + } + + def _resolve(host, port, address_cache=None): # noqa: ARG001 + return addr_lookup[host] + + monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + + exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware) + + assert exit_code == 1 + assert host is None + # Each host gets its own "Could not resolve" log line + final summary. + assert caplog.text.count("Could not resolve") == 2 + assert "OTA upload failed." in caplog.text + + +def test_web_server_ota_error_is_esphome_error() -> None: + assert issubclass(WebServerOTAError, EsphomeError) + + +def test_run_ota_finalizes_progress_bar_on_success( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + """progress.done() fires on the success path (finally block).""" + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + done_called: list[bool] = [] + + with ( + patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ), + patch.object(ProgressBar, "done", lambda self: done_called.append(True)), + ): + run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert done_called + + +def test_run_ota_finalizes_progress_bar_on_failure( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + """progress.done() fires when the request itself raises (finally block).""" + _patch_resolve(monkeypatch, [("192.168.1.50", 80)]) + + done_called: list[bool] = [] + + with ( + patch( + "esphome.web_server_ota.requests.post", + side_effect=requests.ConnectionError("boom"), + ), + patch.object(ProgressBar, "done", lambda self: done_called.append(True)), + ): + run_ota(["192.168.1.50"], 80, None, None, firmware) + + assert done_called + + +def test_run_ota_ipv6_url_brackets_host( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + """IPv6 candidates are bracketed in the URL so the port parses correctly.""" + addr_infos = [ + (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 + ) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ) as post: + exit_code, host = run_ota(["device.local"], 80, None, None, firmware) + + assert exit_code == 0 + assert host == "2001:db8::1" + url = post.call_args.args[0] + assert url == f"http://[2001:db8::1]:80{OTA_PATH}" + + +def test_run_ota_ipv6_link_local_includes_scope_id( + monkeypatch: pytest.MonkeyPatch, firmware: Path +) -> None: + """Link-local IPv6 candidates include the percent-encoded zone index.""" + addr_infos = [ + (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 + ) + + with patch( + "esphome.web_server_ota.requests.post", + return_value=_make_response(200, "Update Successful!"), + ) as post: + exit_code, _ = run_ota(["device.local"], 80, None, None, firmware) + + assert exit_code == 0 + url = post.call_args.args[0] + assert url == f"http://[fe80::1%253]:80{OTA_PATH}" From 39b2b901f7ae3cd6000a46bafaa56358a1f739bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 May 2026 18:26:19 -0500 Subject: [PATCH 1074/2214] [core] Replace scheduler pool vector with unbounded intrusive freelist (#16172) --- esphome/core/application.cpp | 7 ++ esphome/core/scheduler.cpp | 97 ++++++++++++------- esphome/core/scheduler.h | 34 ++++--- tests/benchmarks/core/bench_scheduler.cpp | 10 +- .../integration/fixtures/scheduler_pool.yaml | 10 +- tests/integration/test_scheduler_pool.py | 24 +++-- 6 files changed, 115 insertions(+), 67 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d03696fbb6..38d3503c2c 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -25,6 +25,10 @@ namespace esphome { static const char *const TAG = "app"; +// Delay after setup() finishes before trimming the scheduler freelist of its post-boot peak. +// 10 s is well past the bulk of post-setup async work (Wi-Fi/MQTT connects, first-read latency). +static constexpr uint32_t SCHEDULER_FREELIST_TRIM_DELAY_MS = 10000; + // Helper function for insertion sort of components by priority // Using insertion sort instead of std::stable_sort saves ~1.3KB of flash // by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) @@ -112,6 +116,9 @@ void Application::setup() { ESP_LOGI(TAG, "setup() finished successfully!"); + // Trim the scheduler freelist of its post-boot peak once startup churn settles. + this->scheduler.set_timeout(this, SCHEDULER_FREELIST_TRIM_DELAY_MS, [this]() { this->scheduler.trim_freelist(); }); + #ifdef USE_SETUP_PRIORITY_OVERRIDE // Clear setup priority overrides to free memory clear_setup_priority_overrides(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 57deeab0da..a7c624486d 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -14,18 +14,8 @@ namespace esphome { static const char *const TAG = "scheduler"; -// Memory pool configuration constants -// Pool size of 5 matches typical usage patterns (2-4 active timers) -// - Minimal memory overhead (~250 bytes on ESP32) -// - Sufficient for most configs with a couple sensors/components -// - Still prevents heap fragmentation and allocation stalls -// - Complex setups with many timers will just allocate beyond the pool -// See https://github.com/esphome/backlog/issues/52 -static constexpr size_t MAX_POOL_SIZE = 5; - // Maximum number of logically deleted (cancelled) items before forcing cleanup. -// Set to 5 to match the pool size - when we have as many cancelled items as our -// pool can hold, it's time to clean up and recycle them. +// Empirically chosen to balance cleanup overhead against tombstone accumulation in items_. static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5; // max delay to start an interval sequence static constexpr uint32_t MAX_INTERVAL_DELAY = 5000; @@ -165,7 +155,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type delay = 1; } - // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check + // Take lock early to protect scheduler_item_pool_head_ access and retry-cancelled check LockGuard guard{this->lock_}; // For retries, check if there's a cancelled timeout first - before allocating an item. @@ -599,7 +589,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { if (now_64 - last_print > 2000) { last_print = now_64; std::vector old_items; - ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64, this->items_.size(), this->scheduler_item_pool_.size(), + ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64, this->items_.size(), this->scheduler_item_pool_size_, now_64); // Cleanup before debug output this->cleanup_(); @@ -894,30 +884,68 @@ bool HOT Scheduler::SchedulerItem::cmp(SchedulerItem *a, SchedulerItem *b) { : (a->next_execution_high_ > b->next_execution_high_); } -// Recycle a SchedulerItem back to the pool for reuse. -// IMPORTANT: Caller must hold the scheduler lock before calling this function. -// This protects scheduler_item_pool_ from concurrent access by other threads -// that may be acquiring items from the pool in set_timer_common_(). +// Recycle a SchedulerItem back to the freelist for reuse. +// IMPORTANT: Caller must hold the scheduler lock. void Scheduler::recycle_item_main_loop_(SchedulerItem *item) { if (item == nullptr) return; - if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { - // Clear callback to release captured resources - item->callback = nullptr; - this->scheduler_item_pool_.push_back(item); + item->callback = nullptr; // release captured resources + item->next_free = this->scheduler_item_pool_head_; + this->scheduler_item_pool_head_ = item; + this->scheduler_item_pool_size_++; #ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size()); -#endif - } else { -#ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size()); + ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_size_); #endif +} + +// Shrink a SchedulerItem* vector's capacity to its current size. +// std::vector::shrink_to_fit() is non-binding and our toolchain ignores it; the classic +// swap-with-copy idiom (std::vector(other).swap(other)) instantiates the iterator-range +// constructor which pulls in std::__throw_bad_array_new_length and ~120 B of related +// stdlib RTTI/typeinfo. Build into a temp via reserve + push_back instead, then move-assign: +// reserve uses operator new (throws bad_alloc, already linked) and push_back without growth +// is the noexcept tail path. Move-assign just swaps pointers. +// Out-of-line + noinline so the callers in trim_freelist() share one body. +void __attribute__((noinline)) Scheduler::shrink_scheduler_vector_(std::vector *v) { + if (v->capacity() == v->size()) + return; // already exact, common after a quiet period + std::vector tmp; + tmp.reserve(v->size()); + for (SchedulerItem *p : *v) + tmp.push_back(p); + *v = std::move(tmp); +} + +void Scheduler::trim_freelist() { + LockGuard guard{this->lock_}; + SchedulerItem *item = this->scheduler_item_pool_head_; + size_t freed = 0; + while (item != nullptr) { + SchedulerItem *next = item->next_free; delete item; #ifdef ESPHOME_DEBUG_SCHEDULER this->debug_live_items_--; #endif + item = next; + freed++; } + this->scheduler_item_pool_head_ = nullptr; + this->scheduler_item_pool_size_ = 0; + + // items_/to_add_/defer_queue_ retain their boot-peak vector capacity (vector grows + // by doubling and otherwise keeps the peak). Reclaim that slack as well. + shrink_scheduler_vector_(&this->items_); + shrink_scheduler_vector_(&this->to_add_); +#ifndef ESPHOME_THREAD_SINGLE + shrink_scheduler_vector_(&this->defer_queue_); +#endif + +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Freelist trimmed (%zu items freed)", freed); +#else + (void) freed; +#endif } #ifdef ESPHOME_DEBUG_SCHEDULER @@ -942,14 +970,15 @@ void Scheduler::debug_log_timer_(const SchedulerItem *item, NameType name_type, } #endif /* ESPHOME_DEBUG_SCHEDULER */ -// Helper to get or create a scheduler item from the pool -// IMPORTANT: Caller must hold the scheduler lock before calling this function. +// Pop from freelist or allocate. IMPORTANT: caller must hold the lock and must overwrite +// `item->component` before releasing it -- the popped slot still holds the freelist link. Scheduler::SchedulerItem *Scheduler::get_item_from_pool_locked_() { - if (!this->scheduler_item_pool_.empty()) { - SchedulerItem *item = this->scheduler_item_pool_.back(); - this->scheduler_item_pool_.pop_back(); + if (this->scheduler_item_pool_head_ != nullptr) { + SchedulerItem *item = this->scheduler_item_pool_head_; + this->scheduler_item_pool_head_ = item->next_free; + this->scheduler_item_pool_size_--; #ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size()); + ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_size_); #endif return item; } @@ -967,7 +996,7 @@ Scheduler::SchedulerItem *Scheduler::get_item_from_pool_locked_() { bool Scheduler::debug_verify_no_leak_() const { // Invariant: every live SchedulerItem must be in exactly one container. // debug_live_items_ tracks allocations minus deletions. - size_t accounted = this->items_.size() + this->to_add_.size() + this->scheduler_item_pool_.size(); + size_t accounted = this->items_.size() + this->to_add_.size() + this->scheduler_item_pool_size_; #ifndef ESPHOME_THREAD_SINGLE accounted += this->defer_queue_.size(); #endif @@ -981,7 +1010,7 @@ bool Scheduler::debug_verify_no_leak_() const { ")", static_cast(this->debug_live_items_), static_cast(accounted), static_cast(this->items_.size()), static_cast(this->to_add_.size()), - static_cast(this->scheduler_item_pool_.size()) + static_cast(this->scheduler_item_pool_size_) #ifndef ESPHOME_THREAD_SINGLE , static_cast(this->defer_queue_.size()) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7a6be6bea9..b640aa86fe 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -132,6 +132,12 @@ class Scheduler { // @return Timestamp of the last item that ran, or `now` unchanged if none ran. uint32_t call(uint32_t now); + // Reclaim memory held by the post-boot peak. Frees every SchedulerItem in the + // recycle freelist and shrinks items_/to_add_/defer_queue_ vector capacity to + // their current sizes (std::vector grows by doubling and otherwise retains the + // peak). Live items in those vectors are preserved. + void trim_freelist(); + // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). // Inlined: the fast path (nothing to add) is just an atomic load / empty check. @@ -177,8 +183,12 @@ class Scheduler { protected: struct SchedulerItem { - // Ordered by size to minimize padding - Component *component; + // Ordered by size to minimize padding. + // `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive). + union { + Component *component; + SchedulerItem *next_free; + }; // Optimized name storage using tagged union - zero heap allocation union { const char *static_name; // For STATIC_STRING (string literals) and SELF_POINTER (caller's `this`) @@ -355,6 +365,10 @@ class Scheduler { SchedulerItem *get_item_from_pool_locked_(); private: + // Out-of-line helper that shrinks a SchedulerItem* vector's capacity to its current + // size. Centralised so trim_freelist() doesn't pay flash cost per call site. + void shrink_scheduler_vector_(std::vector *v); + // Helper to cancel matching items - must be called with lock held. // When find_first=true, stops after the first match (used by set_timer_common_ where // the cancel-before-add invariant guarantees at most one match). @@ -713,19 +727,15 @@ class Scheduler { #endif } - // Memory pool for recycling SchedulerItem objects to reduce heap churn. - // Design decisions: - // - std::vector is used instead of a fixed array because many systems only need 1-2 scheduler items - // - The vector grows dynamically up to MAX_POOL_SIZE (5) only when needed, saving memory on simple setups - // - Pool size of 5 matches typical usage (2-4 timers) while keeping memory overhead low (~250 bytes on ESP32) - // - The pool significantly reduces heap fragmentation which is critical because heap allocation/deallocation - // can stall the entire system, causing timing issues and dropped events for any components that need - // to synchronize between tasks (see https://github.com/esphome/backlog/issues/52) - std::vector scheduler_item_pool_; + // Intrusive freelist threaded through SchedulerItem::next_free. Unbounded so it quiesces at the + // app's concurrent-timer high-water mark; the previous fixed cap caused steady-state new/delete + // churn on devices with many timers (see https://github.com/esphome/backlog/issues/52). + SchedulerItem *scheduler_item_pool_head_{nullptr}; + size_t scheduler_item_pool_size_{0}; #ifdef ESPHOME_DEBUG_SCHEDULER // Leak detection: tracks total live SchedulerItem allocations. - // Invariant: debug_live_items_ == items_.size() + to_add_.size() + defer_queue_.size() + scheduler_item_pool_.size() + // Invariant: debug_live_items_ == items_.size() + to_add_.size() + defer_queue_.size() + scheduler_item_pool_size_ // Verified periodically in call() to catch leaks early. size_t debug_live_items_{0}; diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index 214fe0e4b8..32bbc2de88 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -101,8 +101,8 @@ static void Scheduler_SetTimeout(benchmark::State &state) { Component dummy_component; // Register 3 timeouts then call() — realistic worst case where multiple - // components schedule in the same loop iteration. Keeps item count within - // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. + // components schedule in the same loop iteration. warm_pool fills the + // freelist so acquire/recycle never falls back to malloc. static constexpr int kBatchSize = 3; static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); warm_pool(scheduler, &dummy_component, kBatchSize, 1000); @@ -209,9 +209,9 @@ static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) { Scheduler scheduler; Component dummy_component; - // Register 10 timeouts then call() — exceeds MAX_POOL_SIZE=5 to measure - // the performance cliff when the recycling pool is exhausted and items - // must be malloc'd/freed. + // Register 10 timeouts then call() — larger working set than the 3-item + // batches above. With the unbounded freelist, warm_pool preallocates 10 + // items so this measures steady-state, not malloc cliff. static constexpr int kBatchSize = 10; static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); warm_pool(scheduler, &dummy_component, kBatchSize, 1000); diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index 5389125188..989c1535b0 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -221,14 +221,10 @@ script: - id: test_full_pool_reuse then: - lambda: |- - ESP_LOGI("test", "Phase 6: Testing pool size limits after Phase 5 items complete"); + ESP_LOGI("test", "Phase 6: Testing pool reuse after Phase 5 items complete"); - // At this point, all Phase 5 timeouts should have completed and been recycled. - // The pool should be at its maximum size (5). - // Creating 10 new items tests that: - // - First 5 items reuse from the pool - // - Remaining 5 items allocate new (pool empty) - // - Pool doesn't grow beyond MAX_POOL_SIZE of 5 + // Phase 5 timeouts have completed and been recycled. The freelist is unbounded; + // creating 10 new items reuses from it and only allocates fresh when empty. auto *component = id(test_sensor); int full_reuse_count = 10; diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index 021917cc25..cc25190e30 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -180,16 +180,22 @@ async def test_scheduler_pool( # Verify pool behavior assert pool_recycle_count > 0, "Should have recycled items to pool" - # Check pool metrics - if pool_recycle_count > 0: - max_pool_size = 0 - for line in log_lines: - if match := recycle_pattern.search(line): - size = int(match.group(1)) - max_pool_size = max(max_pool_size, size) + # Pool is unbounded; the cap was the source of the churn it was meant to prevent. + assert pool_full_count == 0, ( + f"Pool should never report full (got {pool_full_count})" + ) - # Pool can grow up to its maximum of 5 - assert max_pool_size <= 5, f"Pool grew beyond maximum ({max_pool_size})" + # Verify the pool actually grew past the old MAX_POOL_SIZE=5 cap. + # Phase 5 + Phase 6 schedule 8 + 10 same-component timeouts respectively, so the + # observed peak should comfortably exceed 5. Without this lower-bound check, a + # silent regression that re-introduced a small cap could pass the test above. + max_pool_size = 0 + for line in log_lines: + if match := recycle_pattern.search(line): + max_pool_size = max(max_pool_size, int(match.group(1))) + assert max_pool_size > 5, ( + f"Pool should grow past the old cap of 5; observed peak {max_pool_size}" + ) # Log summary for debugging print("\nScheduler Pool Test Summary (Python Orchestrated):") From 67491c3194067a38b9463d24f7bac9cd64ea9c76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 May 2026 18:26:52 -0500 Subject: [PATCH 1075/2214] [packages] Add resolve_packages single-call seam (#16235) --- esphome/components/packages/__init__.py | 59 +++++++++ .../component_tests/packages/test_packages.py | 120 ++++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 47a1fd20a7..d63f17aa7e 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -611,3 +611,62 @@ def merge_packages(config: dict) -> dict: config = reduce(lambda new, old: merge_config(old, new), merge_list, config) del config[CONF_PACKAGES] return config + + +def resolve_packages( + config: dict[str, Any], + *, + command_line_substitutions: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Load and merge ``packages:`` in one call; return the flattened config. + + Convenience wrapper around :func:`do_packages_pass` followed by + :func:`merge_packages`. External tools that want the package- + merged dict (without going through full schema validation via + :func:`esphome.config.read_config`) get one stable seam to call + instead of having to chain the two functions and stay in sync + with the pipeline order. + + Note: the full :func:`esphome.config.validate_config` pipeline + runs two extra passes around the merge that this wrapper + deliberately skips: + + 1. :func:`esphome.components.substitutions.do_substitution_pass` + runs BETWEEN :func:`do_packages_pass` and + :func:`merge_packages`, so ``${var}`` placeholders inside + package content are NOT resolved here. Callers that need + substitution should invoke ``do_substitution_pass`` + themselves between calls, or go through the full + ``validate_config``. + 2. :func:`esphome.config.resolve_extend_remove` runs AFTER + :func:`merge_packages`, so top-level ``!remove`` / ``!extend`` + markers are NOT applied here. A package-contributed block + paired with a top-level ``key: !remove`` will still appear + in the returned dict (the marker just sits next to it). + + The wrapper exists for the "what blocks did packages + contribute?" question — metadata callers that just need to + see merged top-level keys. It is NOT a stand-in for + :func:`esphome.config.validate_config` and the two passes + above are the reasons why. + + Used by: + + - ``esphome/device-builder`` — the new WebSocket dashboard + backend reads device metadata (api / wifi / target-platform + flags) off the merged config so packages contribute the same + blocks the compiler sees, not just whatever sits at the top + of the user's YAML. See + https://github.com/esphome/device-builder/issues/288 for the + bug this fixes. + + Returns *config* unchanged when ``packages:`` isn't present, so + callers can apply this unconditionally without having to peek + at the config first. + """ + if CONF_PACKAGES not in config: + return config + config = do_packages_pass( + config, command_line_substitutions=command_line_substitutions + ) + return merge_packages(config) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 13a6da9f2c..8c809c5e91 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -14,6 +14,7 @@ from esphome.components.packages import ( do_packages_pass, is_package_definition, merge_packages, + resolve_packages, ) from esphome.components.substitutions import ContextVars, do_substitution_pass import esphome.config as config_module @@ -1621,3 +1622,122 @@ def test_remote_package_vars_resolved_against_sibling_package_substitutions( actual = packages_pass(config) assert actual[CONF_SENSOR][0]["pin"] == "GPIO5" + + +# --------------------------------------------------------------------------- +# resolve_packages — single-call wrapper around do_packages_pass + merge_packages +# --------------------------------------------------------------------------- + + +def test_resolve_packages_returns_config_unchanged_without_packages() -> None: + """No ``packages:`` key → no-op, same dict back.""" + config = {CONF_ESPHOME: {CONF_NAME: "test"}, CONF_WIFI: {CONF_SSID: "x"}} + result = resolve_packages(config) + assert result is config + assert CONF_PACKAGES not in result + + +def test_resolve_packages_loads_and_merges_in_one_call() -> None: + """End-to-end: a config with one local-dict package gets its blocks flattened.""" + config = { + CONF_ESPHOME: {CONF_NAME: "main"}, + CONF_PACKAGES: { + "shared": { + CONF_WIFI: {CONF_SSID: "from_package"}, + CONF_SENSOR: [ + {CONF_PLATFORM: "template", CONF_NAME: "from_package_sensor"}, + ], + } + }, + } + result = resolve_packages(config) + # ``packages:`` is gone — it was consumed by the merge. + assert CONF_PACKAGES not in result + # Blocks contributed by the package are now top-level. + assert result[CONF_WIFI][CONF_SSID] == "from_package" + assert result[CONF_SENSOR][0][CONF_NAME] == "from_package_sensor" + # The main config's own keys survive untouched. + assert result[CONF_ESPHOME][CONF_NAME] == "main" + + +def test_resolve_packages_preserves_main_config_overrides() -> None: + """Main-config values win over package values for the same key. + + Pinning the precedence ESPHome's compiler uses so any future + refactor of the wrapper doesn't accidentally flip the order. + """ + config = { + CONF_ESPHOME: {CONF_NAME: "main"}, + CONF_WIFI: {CONF_SSID: "main_wins"}, + CONF_PACKAGES: { + "shared": {CONF_WIFI: {CONF_SSID: "package_loses"}}, + }, + } + result = resolve_packages(config) + assert result[CONF_WIFI][CONF_SSID] == "main_wins" + + +def test_resolve_packages_forwards_command_line_substitutions() -> None: + """``command_line_substitutions`` reaches the underlying ``do_packages_pass``. + + The wrapper exists so external tools have one stable seam; if + that seam silently dropped a kwarg the underlying call accepts, + callers would see surprising behaviour. This pins the + pass-through. + """ + config = { + CONF_ESPHOME: {CONF_NAME: "main"}, + CONF_PACKAGES: {"shared": {CONF_WIFI: {CONF_SSID: "from_package"}}}, + } + with patch( + "esphome.components.packages.do_packages_pass", + wraps=do_packages_pass, + ) as spy: + resolve_packages(config, command_line_substitutions={"foo": "bar"}) + spy.assert_called_once() + _, kwargs = spy.call_args + assert kwargs.get("command_line_substitutions") == {"foo": "bar"} + + +def test_resolve_packages_does_not_run_substitutions() -> None: + """``${var}`` placeholders inside package content stay literal. + + The full ``validate_config`` pipeline runs ``do_substitution_pass`` + BETWEEN ``do_packages_pass`` and ``merge_packages``; this wrapper + skips it on purpose. Pin that contract so a future refactor can't + silently start resolving substitutions and break callers that + deliberately compose the passes themselves. + """ + config = { + CONF_ESPHOME: {CONF_NAME: "main"}, + CONF_SUBSTITUTIONS: {"ssid_value": "resolved_ssid"}, + CONF_PACKAGES: { + "shared": {CONF_WIFI: {CONF_SSID: "${ssid_value}"}}, + }, + } + result = resolve_packages(config) + # Without ``do_substitution_pass`` the placeholder is preserved. + assert result[CONF_WIFI][CONF_SSID] == "${ssid_value}" + + +def test_resolve_packages_does_not_apply_extend_remove() -> None: + """Top-level ``!remove`` / ``!extend`` markers stay in the merged dict. + + The full ``validate_config`` pipeline runs ``resolve_extend_remove`` + AFTER ``merge_packages``; this wrapper skips it on purpose. Pin + that contract: a package-contributed block paired with a top-level + ``!remove`` is left as-is for callers to handle (or for them to + call ``resolve_extend_remove`` themselves). + """ + config = { + CONF_ESPHOME: {CONF_NAME: "main"}, + CONF_WIFI: Remove(), + CONF_PACKAGES: { + "shared": {CONF_WIFI: {CONF_SSID: "from_package"}}, + }, + } + result = resolve_packages(config) + # ``merge_packages`` keeps the top-level ``!remove`` (it wins + # over the package value during merge), and the marker is not + # resolved by this wrapper. + assert isinstance(result[CONF_WIFI], Remove) From 4404dd68ba56d3c041b32cd8494bb386e3545134 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 May 2026 18:27:18 -0500 Subject: [PATCH 1076/2214] [cover] Fix ControlAction / CoverPublishAction trigger args with reference types (#16227) --- esphome/components/cover/__init__.py | 19 +++++++++++++++---- esphome/components/cover/automation.h | 11 +++++++++-- tests/components/template/common-base.yaml | 13 +++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 954ad7a345..839ca532e6 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -328,17 +328,28 @@ async def build_apply_lambda_action( Used by both `cover.control` and `cover.template.publish` (and shared with the template/cover platform). Constants are emitted as flash immediates; user lambdas are invoked inline so trigger args still flow. - The trigger arg types are wrapped as `const T &` to match the - `void (*)(..., const Ts &...)` ApplyFn signature. + Trigger arg types are normalized to `const std::remove_cvref_t &` + to match the ApplyFn signature for any T (value, ref, or const-ref). """ paren = await cg.get_variable(config[CONF_ID]) + # Normalize trigger args to `const std::remove_cvref_t &` so the + # apply lambda and any inner field lambdas (generated below via + # `process_lambda`) share one parameter spelling that's well-formed for + # any T. + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) + for t, n in args + ] + fwd_args = ", ".join(name for _, name in args) body_lines: list[str] = [] for field in fields: if (value := config.get(field.conf_key)) is None: continue if isinstance(value, Lambda): - inner = await cg.process_lambda(value, args, return_type=field.type_) + inner = await cg.process_lambda( + value, normalized_args, return_type=field.type_ + ) value_expr = f"({inner})({fwd_args})" else: value_expr = str(cg.safe_exp(value)) @@ -346,7 +357,7 @@ async def build_apply_lambda_action( apply_args = [ *prefix_args, - *((t.operator("const").operator("ref"), n) for t, n in args), + *normalized_args, ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index e2384c2359..ee7a4f5f76 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -51,10 +51,17 @@ template class ToggleAction : public Action { // plus one parent pointer, regardless of how many fields the user set. // Trigger args are forwarded to the apply function so user lambdas // (e.g. `position: !lambda "return x;"`) keep working. +// +// Trigger args are normalized to `const std::remove_cvref_t &...` so +// the codegen can emit a matching parameter list for both the apply lambda +// and any inner field lambdas without producing invalid C++ source text +// (e.g. `const T & &` if Ts already carries a reference, or `const const +// T &` if Ts already carries a const). This keeps trigger args no-copy +// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. template class ControlAction : public Action { public: - using ApplyFn = void (*)(CoverCall &, const Ts &...); + using ApplyFn = void (*)(CoverCall &, const std::remove_cvref_t &...); ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} void play(const Ts &...x) override { @@ -70,7 +77,7 @@ template class ControlAction : public Action { template class CoverPublishAction : public Action { public: - using ApplyFn = void (*)(Cover *, const Ts &...); + using ApplyFn = void (*)(Cover *, const std::remove_cvref_t &...); CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} void play(const Ts &...x) override { diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 984ef129ad..b97cafd25c 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -369,6 +369,19 @@ number: - valve.control: id: template_valve position: !lambda "return x / 100.0f;" + # Same regression test for cover.control: forces the apply-lambda + # codegen to handle a non-empty trigger Ts (float). + - platform: template + id: template_cover_position_number + optimistic: true + min_value: 0 + max_value: 100 + step: 1 + on_value: + then: + - cover.control: + id: template_cover_with_triggers + position: !lambda "return x / 100.0f;" select: - platform: template From f5c1b8839da0fdea431d11542dfd6f6bebe5332a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 May 2026 18:29:10 -0500 Subject: [PATCH 1077/2214] [web_server] Use entity_types.h X-macro for ListEntitiesIterator declarations (#16077) --- .../components/web_server/list_entities.cpp | 4 + esphome/components/web_server/list_entities.h | 83 +++---------------- 2 files changed, 15 insertions(+), 72 deletions(-) diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index c1e7599c7e..869ed3ea17 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -167,5 +167,9 @@ bool ListEntitiesIterator::on_update(update::UpdateEntity *obj) { } #endif +#ifdef USE_MEDIA_PLAYER +bool ListEntitiesIterator::on_media_player(media_player::MediaPlayer *obj) { return true; } +#endif + } // namespace esphome::web_server #endif diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index 9cfc6c7e33..3edb84f555 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -24,78 +24,17 @@ class ListEntitiesIterator final : public ComponentIterator { #elif defined(USE_ARDUINO) ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es); #endif -#ifdef USE_BINARY_SENSOR - bool on_binary_sensor(binary_sensor::BinarySensor *obj) override; -#endif -#ifdef USE_COVER - bool on_cover(cover::Cover *obj) override; -#endif -#ifdef USE_FAN - bool on_fan(fan::Fan *obj) override; -#endif -#ifdef USE_LIGHT - bool on_light(light::LightState *obj) override; -#endif -#ifdef USE_SENSOR - bool on_sensor(sensor::Sensor *obj) override; -#endif -#ifdef USE_SWITCH - bool on_switch(switch_::Switch *obj) override; -#endif -#ifdef USE_BUTTON - bool on_button(button::Button *obj) override; -#endif -#ifdef USE_TEXT_SENSOR - bool on_text_sensor(text_sensor::TextSensor *obj) override; -#endif -#ifdef USE_CLIMATE - bool on_climate(climate::Climate *obj) override; -#endif -#ifdef USE_NUMBER - bool on_number(number::Number *obj) override; -#endif -#ifdef USE_DATETIME_DATE - bool on_date(datetime::DateEntity *obj) override; -#endif -#ifdef USE_DATETIME_TIME - bool on_time(datetime::TimeEntity *obj) override; -#endif -#ifdef USE_DATETIME_DATETIME - bool on_datetime(datetime::DateTimeEntity *obj) override; -#endif -#ifdef USE_TEXT - bool on_text(text::Text *obj) override; -#endif -#ifdef USE_SELECT - bool on_select(select::Select *obj) override; -#endif -#ifdef USE_LOCK - bool on_lock(lock::Lock *obj) override; -#endif -#ifdef USE_VALVE - bool on_valve(valve::Valve *obj) override; -#endif -#ifdef USE_MEDIA_PLAYER - bool on_media_player(media_player::MediaPlayer *obj) override { return true; } -#endif -#ifdef USE_ALARM_CONTROL_PANEL - bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) override; -#endif -#ifdef USE_WATER_HEATER - bool on_water_heater(water_heater::WaterHeater *obj) override; -#endif -#ifdef USE_INFRARED - bool on_infrared(infrared::Infrared *obj) override; -#endif -#ifdef USE_RADIO_FREQUENCY - bool on_radio_frequency(radio_frequency::RadioFrequency *obj) override; -#endif -#ifdef USE_EVENT - bool on_event(event::Event *obj) override; -#endif -#ifdef USE_UPDATE - bool on_update(update::UpdateEntity *obj) override; -#endif + +// Entity overrides (generated from entity_types.h). +// Implementations live in list_entities.cpp. +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) bool on_##singular(type *obj) override; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) bool completed() { return this->state_ == IteratorState::NONE; } protected: From bf1c339dc1ff83b34ece07e45b40de3239eb149e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 May 2026 18:29:32 -0500 Subject: [PATCH 1078/2214] [api] Use entity_types.h X-macro for ListEntitiesIterator declarations (#16076) --- esphome/components/api/list_entities.h | 83 ++++---------------------- 1 file changed, 11 insertions(+), 72 deletions(-) diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 95c626feb1..88fbdb77c8 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -18,83 +18,22 @@ class APIConnection; class ListEntitiesIterator final : public ComponentIterator { public: ListEntitiesIterator(APIConnection *client); -#ifdef USE_BINARY_SENSOR - bool on_binary_sensor(binary_sensor::BinarySensor *entity) override; -#endif -#ifdef USE_COVER - bool on_cover(cover::Cover *entity) override; -#endif -#ifdef USE_FAN - bool on_fan(fan::Fan *entity) override; -#endif -#ifdef USE_LIGHT - bool on_light(light::LightState *entity) override; -#endif -#ifdef USE_SENSOR - bool on_sensor(sensor::Sensor *entity) override; -#endif -#ifdef USE_SWITCH - bool on_switch(switch_::Switch *entity) override; -#endif -#ifdef USE_BUTTON - bool on_button(button::Button *entity) override; -#endif -#ifdef USE_TEXT_SENSOR - bool on_text_sensor(text_sensor::TextSensor *entity) override; -#endif + +// Entity overrides (generated from entity_types.h). +// All implementations live in list_entities.cpp via LIST_ENTITIES_HANDLER. +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) bool on_##singular(type *entity) override; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_API_USER_DEFINED_ACTIONS bool on_service(UserServiceDescriptor *service) override; #endif #ifdef USE_CAMERA bool on_camera(camera::Camera *entity) override; -#endif -#ifdef USE_CLIMATE - bool on_climate(climate::Climate *entity) override; -#endif -#ifdef USE_NUMBER - bool on_number(number::Number *entity) override; -#endif -#ifdef USE_DATETIME_DATE - bool on_date(datetime::DateEntity *entity) override; -#endif -#ifdef USE_DATETIME_TIME - bool on_time(datetime::TimeEntity *entity) override; -#endif -#ifdef USE_DATETIME_DATETIME - bool on_datetime(datetime::DateTimeEntity *entity) override; -#endif -#ifdef USE_TEXT - bool on_text(text::Text *entity) override; -#endif -#ifdef USE_SELECT - bool on_select(select::Select *entity) override; -#endif -#ifdef USE_LOCK - bool on_lock(lock::Lock *entity) override; -#endif -#ifdef USE_VALVE - bool on_valve(valve::Valve *entity) override; -#endif -#ifdef USE_MEDIA_PLAYER - bool on_media_player(media_player::MediaPlayer *entity) override; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override; -#endif -#ifdef USE_WATER_HEATER - bool on_water_heater(water_heater::WaterHeater *entity) override; -#endif -#ifdef USE_INFRARED - bool on_infrared(infrared::Infrared *entity) override; -#endif -#ifdef USE_RADIO_FREQUENCY - bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override; -#endif -#ifdef USE_EVENT - bool on_event(event::Event *entity) override; -#endif -#ifdef USE_UPDATE - bool on_update(update::UpdateEntity *entity) override; #endif bool on_end() override; From 700676b34001d58483b7df5dbd4e339ac3d882ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 May 2026 18:29:48 -0500 Subject: [PATCH 1079/2214] [api] Use entity_types.h X-macro for InitialStateIterator declarations (#16075) --- esphome/components/api/subscribe_state.cpp | 5 +- esphome/components/api/subscribe_state.h | 86 ++++------------------ 2 files changed, 18 insertions(+), 73 deletions(-) diff --git a/esphome/components/api/subscribe_state.cpp b/esphome/components/api/subscribe_state.cpp index 4bbc17018e..09b5640d8a 100644 --- a/esphome/components/api/subscribe_state.cpp +++ b/esphome/components/api/subscribe_state.cpp @@ -67,7 +67,10 @@ INITIAL_STATE_HANDLER(water_heater, water_heater::WaterHeater) INITIAL_STATE_HANDLER(update, update::UpdateEntity) #endif -// Special cases (button and event) are already defined inline in subscribe_state.h +// event is an ENTITY_CONTROLLER_TYPE_ but has no state to send. +#ifdef USE_EVENT +bool InitialStateIterator::on_event(event::Event *entity) { return true; } +#endif InitialStateIterator::InitialStateIterator(APIConnection *client) : client_(client) {} diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index f20611e06a..6b1ae9651d 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -19,78 +19,20 @@ class APIConnection; class InitialStateIterator final : public ComponentIterator { public: InitialStateIterator(APIConnection *client); -#ifdef USE_BINARY_SENSOR - bool on_binary_sensor(binary_sensor::BinarySensor *entity) override; -#endif -#ifdef USE_COVER - bool on_cover(cover::Cover *entity) override; -#endif -#ifdef USE_FAN - bool on_fan(fan::Fan *entity) override; -#endif -#ifdef USE_LIGHT - bool on_light(light::LightState *entity) override; -#endif -#ifdef USE_SENSOR - bool on_sensor(sensor::Sensor *entity) override; -#endif -#ifdef USE_SWITCH - bool on_switch(switch_::Switch *entity) override; -#endif -#ifdef USE_BUTTON - bool on_button(button::Button *button) override { return true; }; -#endif -#ifdef USE_TEXT_SENSOR - bool on_text_sensor(text_sensor::TextSensor *entity) override; -#endif -#ifdef USE_CLIMATE - bool on_climate(climate::Climate *entity) override; -#endif -#ifdef USE_NUMBER - bool on_number(number::Number *entity) override; -#endif -#ifdef USE_DATETIME_DATE - bool on_date(datetime::DateEntity *entity) override; -#endif -#ifdef USE_DATETIME_TIME - bool on_time(datetime::TimeEntity *entity) override; -#endif -#ifdef USE_DATETIME_DATETIME - bool on_datetime(datetime::DateTimeEntity *entity) override; -#endif -#ifdef USE_TEXT - bool on_text(text::Text *entity) override; -#endif -#ifdef USE_SELECT - bool on_select(select::Select *entity) override; -#endif -#ifdef USE_LOCK - bool on_lock(lock::Lock *entity) override; -#endif -#ifdef USE_VALVE - bool on_valve(valve::Valve *entity) override; -#endif -#ifdef USE_MEDIA_PLAYER - bool on_media_player(media_player::MediaPlayer *entity) override; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override; -#endif -#ifdef USE_WATER_HEATER - bool on_water_heater(water_heater::WaterHeater *entity) override; -#endif -#ifdef USE_INFRARED - bool on_infrared(infrared::Infrared *infrared) override { return true; }; -#endif -#ifdef USE_RADIO_FREQUENCY - bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; }; -#endif -#ifdef USE_EVENT - bool on_event(event::Event *event) override { return true; }; -#endif -#ifdef USE_UPDATE - bool on_update(update::UpdateEntity *entity) override; -#endif + +// Entity overrides (generated from entity_types.h). +// ENTITY_TYPE_ entities have no state to send and default to a no-op. +// ENTITY_CONTROLLER_TYPE_ entities are implemented in subscribe_state.cpp via INITIAL_STATE_HANDLER, +// except on_event which has no state (defined out-of-line in subscribe_state.cpp). +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + bool on_##singular(type *entity) override { return true; } +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + bool on_##singular(type *entity) override; +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) protected: APIConnection *client_; From 2d6af1f7e5967e04aa7a2c5e337e77c8005a9a08 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 5 May 2026 20:22:53 -0400 Subject: [PATCH 1080/2214] [audio] Bump esp-audio-libs to v3.0.0 (#16263) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 80fd328e48..cfb2ad4e75 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -334,7 +334,7 @@ async def to_code(config): add_idf_component( name="esphome/esp-audio-libs", - ref="2.0.4", + ref="3.0.0", ) data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 14fb11ace5..37c0da11f5 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" esphome/esp-audio-libs: - version: 2.0.4 + version: 3.0.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From a99c1b3e08de399c1edc9b9364ef293fb0dc1b1a Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Wed, 6 May 2026 02:37:03 +0200 Subject: [PATCH 1081/2214] [nrf52] add reserve area for bootloader (#16204) --- esphome/components/nrf52/boards.py | 9 ++++++--- esphome/components/zigbee/__init__.py | 13 ------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/esphome/components/nrf52/boards.py b/esphome/components/nrf52/boards.py index 6064fe844a..4c33cd9939 100644 --- a/esphome/components/nrf52/boards.py +++ b/esphome/components/nrf52/boards.py @@ -31,12 +31,15 @@ BOARDS_ZEPHYR = { # https://learn.adafruit.com/introducing-the-adafruit-nrf52840-feather?view=all#hathach-memory-map BOOTLOADER_CONFIG = { BOOTLOADER_ADAFRUIT_NRF52_SD132: [ - Section("empty_app_offset", 0x0, 0x26000, "flash_primary"), + Section("SoftDevice", 0x0, 0x26000, "flash_primary"), + Section("Adafruit_nRF52_Bootloader", 0xF4000, 0xC000, "flash_primary"), ], BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: [ - Section("empty_app_offset", 0x0, 0x26000, "flash_primary"), + Section("SoftDevice", 0x0, 0x26000, "flash_primary"), + Section("Adafruit_nRF52_Bootloader", 0xF4000, 0xC000, "flash_primary"), ], BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: [ - Section("empty_app_offset", 0x0, 0x27000, "flash_primary"), + Section("SoftDevice", 0x0, 0x27000, "flash_primary"), + Section("Adafruit_nRF52_Bootloader", 0xF4000, 0xC000, "flash_primary"), ], } diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 018dab7348..8605b4fa1a 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -9,9 +9,6 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C6, VARIANT_ESP32H2, ) -from esphome.components.nrf52.boards import BOOTLOADER_CONFIG, Section -from esphome.components.zephyr import zephyr_add_pm_static, zephyr_data -from esphome.components.zephyr.const import KEY_BOOTLOADER import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -53,15 +50,6 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@luar123", "@tomaszduda23"] -def zigbee_set_core_data(config: ConfigType) -> ConfigType: - if CORE.is_nrf52 and zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: - zephyr_add_pm_static( - [Section("empty_after_zboss_offset", 0xF4000, 0xC000, "flash_primary")] - ) - - return config - - BINARY_SENSOR_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( @@ -119,7 +107,6 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), _validate_router_sleepy, zigbee_require_vfs_select, - zigbee_set_core_data, cv.Any( cv.All( cv.only_on_esp32, From e9f7579910386c07065a6184bb0245d4fd6170d9 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Wed, 6 May 2026 03:37:40 +0200 Subject: [PATCH 1082/2214] [logger] give a chance to print crash (#16203) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/logger/logger_zephyr.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 7fa9e42c6a..e9caa8d9d9 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -80,8 +80,8 @@ void Logger::pre_setup() { this->uart_dev_ = uart_dev; #if defined(USE_LOGGER_WAIT_FOR_CDC) && defined(USE_LOGGER_UART_SELECTION_USB_CDC) uint32_t dtr = 0; - uint32_t count = (10 * 100); // wait 10 sec for USB CDC to have early logs - while (dtr == 0 && count-- != 0) { + int32_t count = (10 * 100); // wait 10 sec for USB CDC to have early logs + while (dtr == 0 && count-- > 0) { uart_line_ctrl_get(this->uart_dev_, UART_LINE_CTRL_DTR, &dtr); delay(10); arch_feed_wdt(); @@ -160,6 +160,11 @@ void Logger::dump_crash_() { #if defined(CONFIG_THREAD_NAME) ESP_LOGE(TAG, "Thread: %s", crash_buf.thread); #endif + int32_t count = (2 * 100); // wait 2 sec to give a chance to print crash + while (count-- > 0) { + delay(10); + arch_feed_wdt(); + } } } From 6f6d991dd21f003bc612010ba2cd785e8a9611dc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 6 May 2026 21:42:11 +1200 Subject: [PATCH 1083/2214] [ha-addon] Add opt-in toggle for the new ESPHome Device Builder (#16247) --- .../etc/cont-init.d/40-device-builder.sh | 22 +++++++++++++++++++ .../etc/s6-overlay/s6-rc.d/esphome/run | 7 ++++++ .../etc/s6-overlay/s6-rc.d/init-nginx/run | 8 +++++++ .../etc/s6-overlay/s6-rc.d/nginx/run | 8 +++++++ 4 files changed, 45 insertions(+) create mode 100755 docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh new file mode 100755 index 0000000000..b990469762 --- /dev/null +++ b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh @@ -0,0 +1,22 @@ +#!/usr/bin/with-contenv bashio +# ============================================================================== +# Installs the latest prerelease of esphome-device-builder when the +# `use_new_device_builder` config option is enabled. +# This is a temporary install-on-boot step until esphome-device-builder +# becomes a direct dependency of esphome. +# ============================================================================== + +if ! bashio::config.true 'use_new_device_builder'; then + exit 0 +fi + +bashio::log.info "Installing latest prerelease of esphome-device-builder..." +if command -v uv > /dev/null; then + uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ + esphome-device-builder || + bashio::exit.nok "Failed installing esphome-device-builder." +else + pip install --no-cache-dir --pre --upgrade esphome-device-builder || + bashio::exit.nok "Failed installing esphome-device-builder." +fi +bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index cdbaff6c04..64ac0b18d2 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -49,5 +49,12 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then rm -rf /config/esphome/.esphome fi +if bashio::config.true 'use_new_device_builder'; then + bashio::log.info "Starting ESPHome Device Builder..." + exec esphome-device-builder /config/esphome \ + --ha-addon \ + --ingress-port "$(bashio::addon.ingress_port)" +fi + bashio::log.info "Starting ESPHome dashboard..." exec esphome dashboard /config/esphome --socket /var/run/esphome.sock --ha-addon diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run index 2725f56670..18c75898ec 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run @@ -4,6 +4,14 @@ # Community Hass.io Add-ons: ESPHome # Configures NGINX for use with ESPHome # ============================================================================== + +# When the new device builder is enabled it serves HA ingress directly, +# so nginx is not used at all -- skip configuration. +if bashio::config.true 'use_new_device_builder'; then + bashio::log.info "Skipping NGINX setup: new device builder serves ingress directly." + bashio::exit.ok +fi + mkdir -p /var/log/nginx # Generate Ingress configuration diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run index e96991cdd1..bb5f52e10c 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -5,6 +5,14 @@ # Runs the NGINX proxy # ============================================================================== +# The new device builder handles HA ingress itself, so nginx is bypassed. +# Block the longrun forever so s6 keeps the dependency satisfied and does +# not respawn it. +if bashio::config.true 'use_new_device_builder'; then + bashio::log.info "NGINX bypassed: new device builder serves ingress directly." + exec sleep infinity +fi + bashio::log.info "Waiting for ESPHome dashboard to come up..." while [[ ! -S /var/run/esphome.sock ]]; do From febf8815c733778a25c8ab358b017a579217a94d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 6 May 2026 05:59:51 -0400 Subject: [PATCH 1084/2214] [audio_file][speaker] Eliminate code duplication for files built into firmware (#16266) --- esphome/components/audio_file/__init__.py | 81 ++++---- .../speaker/media_player/__init__.py | 183 ++---------------- .../speaker/common-media_player.yaml | 13 ++ tests/components/speaker/test.wav | Bin 0 -> 46 bytes 4 files changed, 73 insertions(+), 204 deletions(-) create mode 100644 tests/components/speaker/test.wav diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index b246633c31..23c90e9b76 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -199,51 +199,60 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType] return config +def audio_files_schema() -> cv.All: + """Schema for a list of audio file entries. + + Validates each entry, downloads any web files, and detects the audio file + type while requesting codec support. Reusable by other components (e.g. + speaker media_player) that embed audio files in firmware without going + through the audio_file component's C++ registry. + """ + return cv.All( + cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), + partial(download_web_files_in_config, path_for=_compute_local_file_path), + _validate_supported_local_file, + ) + + +def generate_audio_file_code(file_config: ConfigType) -> MockObj: + """Generate the progmem data, AudioFile struct, and Pvariable for one file. + + Returns the created Pvariable. Caller is responsible for any further + registration (the audio_file component additionally registers each file in + its named C++ registry; other consumers may skip that). + """ + cache = _get_data().file_cache + file_id = str(file_config[CONF_ID]) + if file_id in cache: + data, media_file_type = cache[file_id] + else: + data, media_file_type = read_audio_file_and_type(file_config) + + rhs = [HexInt(x) for x in data] + prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs) + + media_files_struct = cg.StructInitializer( + audio.AudioFile, + ("data", prog_arr), + ("length", len(rhs)), + ("file_type", media_file_type), + ) + + return cg.new_Pvariable(file_config[CONF_ID], media_files_struct) + + CONFIG_SCHEMA = cv.All( cv.only_on_esp32, - cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), - partial(download_web_files_in_config, path_for=_compute_local_file_path), - _validate_supported_local_file, + audio_files_schema(), ) async def to_code(config: list[ConfigType]) -> None: - cache = _get_data().file_cache - for file_config in config: file_id = str(file_config[CONF_ID]) - data, media_file_type = cache[file_id] - - rhs = [HexInt(x) for x in data] - prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs) - - media_files_struct = cg.StructInitializer( - audio.AudioFile, - ( - "data", - prog_arr, - ), - ( - "length", - len(rhs), - ), - ( - "file_type", - media_file_type, - ), - ) - - cg.new_Pvariable( - file_config[CONF_ID], - media_files_struct, - ) - - # Store file ID for cross-component access + file_var = generate_audio_file_code(file_config) _get_data().file_ids[file_id] = file_config[CONF_ID] + cg.add(audio_file_ns.add_named_audio_file(file_var, file_id)) # Register all files in the shared C++ registry cg.add_define("AUDIO_FILE_MAX_FILES", len(config)) - for file_config in config: - file_id = str(file_config[CONF_ID]) - file_var = await cg.get_variable(file_config[CONF_ID]) - cg.add(audio_file_ns.add_named_audio_file(file_var, file_id)) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 90d9309f46..094043c292 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -1,13 +1,19 @@ """Speaker Media Player Setup.""" -from functools import partial -import hashlib import logging -from pathlib import Path -from esphome import automation, external_files +from esphome import automation import esphome.codegen as cg -from esphome.components import audio, esp32, media_player, network, ota, psram, speaker +from esphome.components import ( + audio, + audio_file, + esp32, + media_player, + network, + ota, + psram, + speaker, +) from esphome.components.const import ( CONF_VOLUME_INCREMENT, CONF_VOLUME_INITIAL, @@ -17,23 +23,16 @@ from esphome.components.const import ( import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, - CONF_FILE, CONF_FILES, CONF_FORMAT, CONF_ID, CONF_NUM_CHANNELS, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, - CONF_PATH, - CONF_RAW_DATA_ID, CONF_SAMPLE_RATE, CONF_SPEAKER, CONF_TASK_STACK_IN_PSRAM, - CONF_TYPE, - CONF_URL, ) -from esphome.core import CORE, HexInt -from esphome.external_files import download_web_files_in_config _LOGGER = logging.getLogger(__name__) @@ -44,9 +43,6 @@ DEPENDENCIES = ["network"] CODEOWNERS = ["@kahrendt", "@synesthesiam"] DOMAIN = "media_player" -TYPE_LOCAL = "local" -TYPE_WEB = "web" - CONF_ANNOUNCEMENT = "announcement" CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0 @@ -83,87 +79,12 @@ StopStreamAction = speaker_ns.class_( ) -def _compute_local_file_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_file_path: base_dir=%s", base_dir / key) - return base_dir / key - - _PURPOSE_MAP = { "MEDIA": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], "ANNOUNCEMENT": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], } -def _file_schema(value): - if isinstance(value, str): - return _validate_file_shorthand(value) - return TYPED_FILE_SCHEMA(value) - - -def _read_audio_file_and_type(file_config): - conf_file = file_config[CONF_FILE] - file_source = conf_file[CONF_TYPE] - if file_source == TYPE_LOCAL: - path = CORE.relative_config_path(conf_file[CONF_PATH]) - elif file_source == TYPE_WEB: - path = _compute_local_file_path(conf_file) - else: - raise cv.Invalid("Unsupported file source") - - with open(path, "rb") as f: - data = f.read() - - import puremagic - - try: - file_type: str = puremagic.from_string(data) - file_type = file_type.removeprefix(".") - except puremagic.PureError as e: - raise cv.Invalid( - f"Unable to determine audio file type of '{path}'. " - f"Try re-encoding the file into a supported format. Details: {e}" - ) from e - - media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] - if file_type in ("wav"): - media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"] - elif file_type in ("mp3", "mpeg", "mpga"): - media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"] - elif file_type in ("flac"): - media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"] - elif ( - file_type in ("ogg") - and len(data) >= 36 - and data.startswith(b"OggS") - and data[28:36] == b"OpusHead" - ): - media_file_type = audio.AUDIO_FILE_TYPE_ENUM["OPUS"] - - return data, media_file_type - - -def _validate_file_shorthand(value): - value = cv.string_strict(value) - if value.startswith("http://") or value.startswith("https://"): - return _file_schema( - { - CONF_TYPE: TYPE_WEB, - CONF_URL: value, - } - ) - return _file_schema( - { - CONF_TYPE: TYPE_LOCAL, - CONF_PATH: value, - } - ) - - _validate_pipeline = media_player.validate_preferred_format( "speaker media_player", CONF_SPEAKER ) @@ -192,60 +113,15 @@ def _final_validate(config): CONF_CODEC_SUPPORT_ENABLED, ) - # Request codecs based on pipeline formats + # Request codecs based on pipeline formats. Codecs needed by local files are + # already requested during CONFIG_SCHEMA validation (via audio_files_schema). media_player.request_codecs_for_format_configs( config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE] ) - # Validate local files and request any additional codecs they need - for file_config in config.get(CONF_FILES, []): - _, media_file_type = _read_audio_file_and_type(file_config) - if str(media_file_type) == str(audio.AUDIO_FILE_TYPE_ENUM["NONE"]): - raise cv.Invalid("Unsupported local media file") - for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items(): - if str(media_file_type) == str(fmt_enum): - if fmt_name == "FLAC": - audio.request_flac_support() - elif fmt_name == "MP3": - audio.request_mp3_support() - elif fmt_name == "OPUS": - audio.request_opus_support() - elif fmt_name == "WAV": - audio.request_wav_support() - break - return config -LOCAL_SCHEMA = cv.Schema( - { - cv.Required(CONF_PATH): cv.file_, - } -) - -WEB_SCHEMA = cv.Schema( - { - cv.Required(CONF_URL): cv.url, - } -) - - -TYPED_FILE_SCHEMA = cv.typed_schema( - { - TYPE_LOCAL: LOCAL_SCHEMA, - TYPE_WEB: WEB_SCHEMA, - }, -) - - -MEDIA_FILE_TYPE_SCHEMA = cv.Schema( - { - cv.Required(CONF_ID): cv.declare_id(audio.AudioFile), - cv.Required(CONF_FILE): _file_schema, - cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - } -) - PIPELINE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(AudioPipeline), @@ -278,12 +154,7 @@ CONFIG_SCHEMA = cv.All( ), # Remove before 2026.10.0 cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), - cv.Optional(CONF_FILES): cv.All( - cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), - partial( - download_web_files_in_config, path_for=_compute_local_file_path - ), - ), + cv.Optional(CONF_FILES): audio_file.audio_files_schema(), cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( cv.boolean, cv.requires_component(psram.DOMAIN) ), @@ -380,31 +251,7 @@ async def to_code(config): ) for file_config in config.get(CONF_FILES, []): - data, media_file_type = _read_audio_file_and_type(file_config) - - rhs = [HexInt(x) for x in data] - prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs) - - media_files_struct = cg.StructInitializer( - audio.AudioFile, - ( - "data", - prog_arr, - ), - ( - "length", - len(rhs), - ), - ( - "file_type", - media_file_type, - ), - ) - - cg.new_Pvariable( - file_config[CONF_ID], - media_files_struct, - ) + audio_file.generate_audio_file_code(file_config) @automation.register_action( diff --git a/tests/components/speaker/common-media_player.yaml b/tests/components/speaker/common-media_player.yaml index a849e04b33..3b2212a0ca 100644 --- a/tests/components/speaker/common-media_player.yaml +++ b/tests/components/speaker/common-media_player.yaml @@ -17,3 +17,16 @@ media_player: volume_max: 0.95 volume_min: 0.0 task_stack_in_psram: true + files: + - id: speaker_test_audio + file: + type: local + path: $component_dir/test.wav + +script: + - id: play_built_in_file + then: + - media_player.speaker.play_on_device_media_file: + id: speaker_media_player_id + media_file: speaker_test_audio + announcement: true diff --git a/tests/components/speaker/test.wav b/tests/components/speaker/test.wav new file mode 100644 index 0000000000000000000000000000000000000000..f9d07ef2238eb2fcb355055466d3789ee1a1fe0b GIT binary patch literal 46 ycmWIYbaPW Date: Wed, 6 May 2026 21:22:43 +1000 Subject: [PATCH 1085/2214] [lvgl] Allow line points as percentages (#16209) --- esphome/components/lvgl/lv_validation.py | 4 +- esphome/components/lvgl/schemas.py | 12 +- esphome/components/lvgl/widgets/canvas.py | 24 ++- esphome/components/lvgl/widgets/line.py | 17 +- .../lvgl/config/line_points.yaml | 84 ++++++++++ tests/component_tests/lvgl/test_line.py | 147 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 5 +- 7 files changed, 271 insertions(+), 22 deletions(-) create mode 100644 tests/component_tests/lvgl/config/line_points.yaml create mode 100644 tests/component_tests/lvgl/test_line.py diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 503730098e..974eed9e81 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -41,7 +41,7 @@ from .helpers import ( lv_fonts_used, requires_component, ) -from .types import lv_gradient_t, lv_opa_t +from .types import lv_coord_t, lv_gradient_t, lv_opa_t LV_OPA = LvConstant("LV_OPA_", "TRANSP", "COVER") @@ -277,7 +277,7 @@ def pixels_or_percent_validator(value): pixels_or_percent = LValidator( pixels_or_percent_validator, - uint32, + lv_coord_t, retmapper=lambda x: x if isinstance(x, int) else literal(f"lv_pct({int(x * 100)})"), ) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 2c57452a55..62117fbd32 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -123,8 +123,8 @@ ENCODER_SCHEMA = cv.Schema( POINT_SCHEMA = cv.Schema( { - cv.Required(CONF_X): cv.templatable(cv.int_), - cv.Required(CONF_Y): cv.templatable(cv.int_), + cv.Required(CONF_X): lvalid.pixels_or_percent, + cv.Required(CONF_Y): lvalid.pixels_or_percent, } ) @@ -137,9 +137,13 @@ def point_schema(value): """ if isinstance(value, dict): return POINT_SCHEMA(value) + if isinstance(value, list): + if len(value) != 2: + raise cv.Invalid("Invalid point format, should be , ") + return POINT_SCHEMA({CONF_X: value[0], CONF_Y: value[1]}) try: - x, y = map(int, value.split(",")) - return {CONF_X: x, CONF_Y: y} + x, y = str(value).split(",") + return POINT_SCHEMA({CONF_X: x, CONF_Y: y}) except ValueError: pass # not raising this in the catch block because pylint doesn't like it diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index f12766bae1..1308b82dcd 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -52,6 +52,7 @@ from ..lv_validation import ( lv_text, opacity, pixels, + pixels_or_percent, size, ) from ..lvcode import LocalVariable, lv, lv_assign, lv_expr @@ -59,7 +60,7 @@ from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property from ..types import LvType, ObjUpdateAction from . import Widget, WidgetType, get_widgets from .img import CONF_IMAGE -from .line import lv_point_precise_t, process_coord +from .line import lv_point_precise_t CONF_CANVAS = "canvas" CONF_BUFFER_ID = "buffer_id" @@ -434,6 +435,13 @@ LINE_PROPS = { } +def _validate_points(config): + for index, point in enumerate(config[CONF_POINTS]): + if not all(isinstance(p, int) for p in point.values()): + raise cv.Invalid("Points must be integers", path=[CONF_POINTS, index]) + return config + + @automation.register_action( "lvgl.canvas.draw_line", ObjUpdateAction, @@ -444,12 +452,15 @@ LINE_PROPS = { cv.Required(CONF_POINTS): cv.ensure_list(point_schema), **{cv.Optional(prop): validator for prop, validator in LINE_PROPS.items()}, } - ), + ).add_extra(_validate_points), synchronous=True, ) async def canvas_draw_line(config, action_id, template_arg, args): points = [ - [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + [ + await pixels.process(p[CONF_X]), + await pixels.process(p[CONF_Y]), + ] for p in config[CONF_POINTS] ] @@ -470,12 +481,15 @@ async def canvas_draw_line(config, action_id, template_arg, args): cv.Required(CONF_POINTS): cv.ensure_list(point_schema), **{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}, }, - ), + ).add_extra(_validate_points), synchronous=True, ) async def canvas_draw_polygon(config, action_id, template_arg, args): points = [ - [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + [ + await pixels_or_percent.process(p[CONF_X]), + await pixels_or_percent.process(p[CONF_Y]), + ] for p in config[CONF_POINTS] ] # Close the polygon diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index 3112cc28d0..19f421cbbd 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -1,12 +1,12 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_X, CONF_Y -from esphome.core import Lambda -from ..defines import CONF_MAIN, call_lambda +from ..defines import CONF_MAIN +from ..lv_validation import pixels_or_percent from ..lvcode import lv_add from ..schemas import point_schema -from ..types import LvCompound, LvType, lv_coord_t +from ..types import LvCompound, LvType from . import Widget, WidgetType CONF_LINE = "line" @@ -17,12 +17,6 @@ lv_point_t = cg.global_ns.struct("lv_point_t") lv_point_precise_t = cg.global_ns.struct("lv_point_precise_t") -async def process_coord(coord): - if isinstance(coord, Lambda): - return call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t)) - return cg.safe_exp(coord) - - class LineType(WidgetType): def __init__(self): super().__init__( @@ -36,7 +30,10 @@ class LineType(WidgetType): async def to_code(self, w: Widget, config): if CONF_POINTS in config: points = [ - [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + [ + await pixels_or_percent.process(p[CONF_X]), + await pixels_or_percent.process(p[CONF_Y]), + ] for p in config[CONF_POINTS] ] lv_add(w.var.set_points(points)) diff --git a/tests/component_tests/lvgl/config/line_points.yaml b/tests/component_tests/lvgl/config/line_points.yaml new file mode 100644 index 0000000000..5d7be3bc20 --- /dev/null +++ b/tests/component_tests/lvgl/config/line_points.yaml @@ -0,0 +1,84 @@ +esphome: + name: test-line + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + # Dict format + - line: + id: line_dict + points: + - x: 10 + y: 20 + - x: 100 + y: 200 + - x: 0 + y: 0 + + # List format + - line: + id: line_list + points: + - [10, 20] + - [100, 200] + - [0, 0] + + # String format + - line: + id: line_string + points: + - "10, 20" + - "100, 200" + - "0, 0" + + # Percentage - dict format + - line: + id: line_pct_dict + points: + - x: "50%" + y: "75%" + + # Percentage - list format + - line: + id: line_pct_list + points: + - ["50%", "75%"] + + # Percentage - string format + - line: + id: line_pct_string + points: + - "50%, 75%" + + # Mixed integer and percentage + - line: + id: line_mixed_dict + points: + - x: 10 + y: "50%" + - x: "25%" + y: 200 + + - line: + id: line_mixed_list + points: + - [10, "50%"] + - ["25%", 200] diff --git a/tests/component_tests/lvgl/test_line.py b/tests/component_tests/lvgl/test_line.py new file mode 100644 index 0000000000..fce0ef8fa8 --- /dev/null +++ b/tests/component_tests/lvgl/test_line.py @@ -0,0 +1,147 @@ +"""Tests for the LVGL line widget point schema and code generation.""" + +from __future__ import annotations + +import re + +import pytest + +from esphome.components.lvgl.schemas import point_schema +from esphome.config_validation import Invalid +from esphome.const import CONF_X, CONF_Y + +# --------------------------------------------------------------------------- +# Validation: point_schema normalises dict / list / string to same result +# --------------------------------------------------------------------------- + + +class TestPointSchemaValidation: + """Test that all point input formats normalise to the same dict.""" + + @pytest.mark.parametrize( + "dict_input,list_input,string_input", + [ + ({CONF_X: 10, CONF_Y: 20}, [10, 20], "10, 20"), + ({CONF_X: 0, CONF_Y: 0}, [0, 0], "0, 0"), + ({CONF_X: 100, CONF_Y: 200}, [100, 200], "100, 200"), + ({CONF_X: -5, CONF_Y: -10}, [-5, -10], "-5, -10"), + ], + ) + def test_integer_formats_produce_same_result( + self, dict_input, list_input, string_input + ): + result_dict = point_schema(dict_input) + result_list = point_schema(list_input) + result_string = point_schema(string_input) + + assert result_dict == result_list + assert result_dict == result_string + + def test_percentage_formats_produce_same_result(self): + result_dict = point_schema({CONF_X: "50%", CONF_Y: "75%"}) + result_list = point_schema(["50%", "75%"]) + result_string = point_schema("50%, 75%") + + assert result_dict == result_list + assert result_dict == result_string + + def test_pixel_suffix_matches_plain_integer(self): + result_px = point_schema({CONF_X: "10px", CONF_Y: "20px"}) + result_int = point_schema({CONF_X: 10, CONF_Y: 20}) + + assert result_px == result_int + + @pytest.mark.parametrize( + "value", + [ + {CONF_X: 50, CONF_Y: 75}, + [50, 75], + "50, 75", + ], + ) + def test_output_contains_x_and_y(self, value): + result = point_schema(value) + + assert CONF_X in result + assert CONF_Y in result + + def test_list_wrong_length_raises(self): + with pytest.raises(Invalid, match="Invalid point"): + point_schema([1]) + + with pytest.raises(Invalid, match="Invalid point"): + point_schema([1, 2, 3]) + + def test_string_without_comma_raises(self): + with pytest.raises(Invalid, match="Invalid point"): + point_schema("garbage") + + def test_string_extra_commas_raises(self): + with pytest.raises(Invalid, match="Invalid point"): + point_schema("1,2,3") + + +# --------------------------------------------------------------------------- +# Code generation: different point formats produce identical C++ output +# --------------------------------------------------------------------------- + +_SET_POINTS_RE = re.compile(r"(\w+)->set_points\((.+?)\);") + + +def _extract_set_points(main_cpp: str) -> dict[str, str]: + """Return {var_name: args_text} for every set_points() call found.""" + return {m.group(1): m.group(2) for m in _SET_POINTS_RE.finditer(main_cpp)} + + +class TestLineCodeGeneration: + """Verify that alternative point formats generate identical C++ code.""" + + @pytest.fixture() + def main_cpp(self, generate_main, component_config_path) -> str: + return generate_main(component_config_path("line_points.yaml")) + + @pytest.fixture() + def set_points_calls(self, main_cpp) -> dict[str, str]: + return _extract_set_points(main_cpp) + + def test_integer_points_all_formats_match(self, set_points_calls): + """Dict, list, and string formats with integer points produce same set_points call.""" + assert set_points_calls["line_dict"] == set_points_calls["line_list"] + assert set_points_calls["line_dict"] == set_points_calls["line_string"] + + def test_percentage_points_all_formats_match(self, set_points_calls): + """Dict, list, and string formats with percentage points produce same set_points call.""" + assert set_points_calls["line_pct_dict"] == set_points_calls["line_pct_list"] + assert set_points_calls["line_pct_dict"] == set_points_calls["line_pct_string"] + + def test_mixed_points_formats_match(self, set_points_calls): + """Dict and list formats with mixed int/percent points produce same set_points call.""" + assert ( + set_points_calls["line_mixed_dict"] == set_points_calls["line_mixed_list"] + ) + + def test_integer_points_contain_expected_values(self, set_points_calls): + """Integer points appear literally in the generated code.""" + args = set_points_calls["line_dict"] + for val in ("10", "20", "100", "200"): + assert val in args + + def test_percentage_points_use_lv_pct(self, set_points_calls): + """Percentage points are generated using the lv_pct() macro.""" + args = set_points_calls["line_pct_dict"] + assert "lv_pct(50)" in args + assert "lv_pct(75)" in args + + def test_all_lines_present(self, set_points_calls): + """All expected line IDs have a set_points call.""" + expected = { + "line_dict", + "line_list", + "line_string", + "line_pct_dict", + "line_pct_list", + "line_pct_string", + "line_mixed_dict", + "line_mixed_list", + } + assert expected.issubset(set_points_calls.keys()) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 9c4ad4bbf8..39d7472054 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1038,7 +1038,10 @@ lvgl: - 5, 5 - x: !lambda return random_uint32() % 100; y: !lambda return random_uint32() % 100; - - 70, 70 + - x: 10% + y: 50% + - 70%, 70% + - [75%, 75%] - 120, 10 - 180, 60 - 240, 10 From 85f33978e73a28df88955db52d8ccdb7557b5290 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 May 2026 06:23:35 -0500 Subject: [PATCH 1086/2214] [core] Skip external component update on `esphome clean` (#16268) --- esphome/__main__.py | 5 +++-- tests/unit_tests/test_main.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index f4a276b74c..825a502dbf 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2301,8 +2301,9 @@ def run_esphome(argv): CORE.config_path = conf_path CORE.dashboard = args.dashboard - # For logs command, skip updating external components - skip_external = args.command == "logs" + # Commands that don't need fresh external components: logs just connects + # to the device, and clean is about to delete the build directory. + skip_external = args.command in ("logs", "clean") config = read_config( dict(args.substitution) if args.substitution else {}, skip_external_update=skip_external, diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 0b96000a57..4ab7bb3344 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5038,6 +5038,32 @@ def test_run_esphome_non_bundle_skips_extraction(tmp_path: Path) -> None: assert result == 2 +@pytest.mark.parametrize( + ("command", "expected_skip"), + [ + ("logs", True), + ("clean", True), + ("compile", False), + ("config", False), + ("run", False), + ("clean-mqtt", False), + ], +) +def test_run_esphome_skip_external_update_per_command( + tmp_path: Path, command: str, expected_skip: bool +) -> None: + """read_config is invoked with skip_external_update=True only for commands + that don't need fresh external components (logs, clean).""" + yaml_file = tmp_path / "device.yaml" + yaml_file.write_text("esphome:\n name: test\n") + + with patch("esphome.__main__.read_config", return_value=None) as mock_read: + run_esphome(["esphome", command, str(yaml_file)]) + + mock_read.assert_called_once() + assert mock_read.call_args.kwargs["skip_external_update"] is expected_skip + + def test_get_configured_xtal_freq_reads_sdkconfig(tmp_path: Path) -> None: """Test reading XTAL_FREQ from sdkconfig.""" CORE.name = "test-device" From 29db5fa4bb79333310837c9287405200bdba12b9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 6 May 2026 08:11:06 -0400 Subject: [PATCH 1087/2214] [script] Make pre-commit and helpers work on Windows (#16260) Co-authored-by: Jonathan Swoboda --- .pre-commit-config.yaml | 4 ++-- script/ci-custom.py | 2 +- script/run-in-env.py | 9 ++++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ad82bd8e5d..da5fb94d5e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: hooks: - id: pylint name: pylint - entry: python3 script/run-in-env.py pylint + entry: python script/run-in-env.py pylint language: system types: [python] files: ^esphome/.+\.py$ @@ -68,5 +68,5 @@ repos: additional_dependencies: [] - id: ci-custom name: ci-custom - entry: python3 script/run-in-env.py script/ci-custom.py + entry: python script/run-in-env.py script/ci-custom.py language: system diff --git a/script/ci-custom.py b/script/ci-custom.py index b257a3818b..8cd8fd7544 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -250,7 +250,7 @@ def lint_ext_check(fname): ] ) def lint_executable_bit(fname: Path) -> str | None: - ex = EXECUTABLE_BIT[str(fname)] + ex = EXECUTABLE_BIT[fname.as_posix()] if ex != 100644: return ( f"File has invalid executable bit {ex}. If running from a windows machine please " diff --git a/script/run-in-env.py b/script/run-in-env.py index 9283ba9940..996db60554 100755 --- a/script/run-in-env.py +++ b/script/run-in-env.py @@ -44,7 +44,14 @@ def find_and_activate_virtualenv(): def run_command(): # Execute the remaining arguments in the new environment if len(sys.argv) > 1: - result = subprocess.run(sys.argv[1:], check=False, close_fds=False) + args = sys.argv[1:] + # Windows CreateProcess doesn't follow shebangs, so prepend the + # current interpreter when the entry is a .py script. Using + # sys.executable also pins the nested call to the same Python that + # ran us — no ambiguous PATH lookup for "python". + if args[0].endswith(".py"): + args = [sys.executable, *args] + result = subprocess.run(args, check=False, close_fds=False) sys.exit(result.returncode) else: print( From f06ad8c4366f4f51ae54370f9713ccf29a998573 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 May 2026 07:32:19 -0500 Subject: [PATCH 1088/2214] [http_request] Add regression test for light action inside on_response (#16270) --- .../components/http_request/http_request.yaml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index ef67671c91..46d4b88ec5 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -50,12 +50,33 @@ esphome: format: "After delay, body still: %s" args: - body.c_str() + # Regression test for esphome/esphome#16224: a LightControlAction + # nested inside on_response with capture_response: true puts + # `std::string &` into the trigger's Ts..., which exposed a codegen + # bug where the apply lambda's parameter list did not match the + # ApplyFn signature. + - light.turn_on: + id: test_regression_light + brightness: 100% + effect: "None" http_request: useragent: esphome/tagreader timeout: 10s verify_ssl: ${verify_ssl} +output: + - platform: template + id: test_regression_output + type: float + write_action: + - logger.log: "set" + +light: + - platform: monochromatic + id: test_regression_light + output: test_regression_output + script: - id: does_not_compile parameters: From ff0c5f575e84f8143fd42a40f0c8a360599aa543 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 May 2026 07:32:35 -0500 Subject: [PATCH 1089/2214] [bundle] Include secrets.yaml when `!secret` keys are quoted (#16271) --- esphome/bundle.py | 12 +++++---- tests/unit_tests/test_bundle.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index efa80acc8c..70c4fad0fd 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -98,11 +98,13 @@ _KNOWN_FILE_EXTENSIONS = frozenset( ) -# Matches !secret references in YAML text. This is intentionally a simple -# regex scan rather than a YAML parse — it may match inside comments or -# multi-line strings, which is the conservative direction (include more -# secrets rather than fewer). -_SECRET_RE = re.compile(r"!secret\s+(\S+)") +# Matches !secret references in YAML text. An optional surrounding +# quote pair around the key is allowed and ignored: YAML treats +# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is +# intentionally a simple regex scan rather than a YAML parse — it may +# match inside comments or multi-line strings, which is the conservative +# direction (include more secrets rather than fewer). +_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""") def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 89bf1a33b3..5d046252da 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -170,6 +170,23 @@ def test_find_used_secret_keys_deduplicates(tmp_path: Path) -> None: assert keys == {"key1"} +def test_find_used_secret_keys_quoted(tmp_path: Path) -> None: + """Quoted !secret keys should resolve to the same key as unquoted form. + + YAML strips surrounding quotes during parsing, so the secrets.yaml + lookup uses the unquoted key. The bundle scan must do the same. + """ + yaml1 = tmp_path / "a.yaml" + yaml1.write_text( + "single: !secret 'wifi_ssid'\n" + 'double: !secret "wifi_pw"\n' + "bare: !secret api_key\n" + ) + + keys = _find_used_secret_keys([yaml1]) + assert keys == {"wifi_ssid", "wifi_pw", "api_key"} + + # --------------------------------------------------------------------------- # _add_bytes_to_tar # --------------------------------------------------------------------------- @@ -1217,6 +1234,35 @@ def test_create_bundle_filters_secrets(tmp_path: Path) -> None: assert "should_not_appear" not in secrets_data +def test_create_bundle_filters_secrets_quoted(tmp_path: Path) -> None: + """Bundling must include secrets.yaml when !secret keys are quoted. + + Regression test for issue 16259: quoted !secret references previously + captured the quotes as part of the key, so no key matched secrets.yaml + entries and the secrets file was dropped from the bundle entirely. + """ + config_dir = _setup_config_dir(tmp_path) + + secrets = config_dir / "secrets.yaml" + secrets.write_text("ota_password: hunter2\nunused: should_not_appear\n") + + config_yaml = "ota:\n password: !secret 'ota_password'\n" + (config_dir / "test.yaml").write_text(config_yaml) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is True + + buf = io.BytesIO(result.data) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + secrets_data = tar.extractfile("secrets.yaml").read().decode() + + assert "ota_password" in secrets_data + assert "hunter2" in secrets_data + assert "unused" not in secrets_data + + def test_create_bundle_no_secrets(tmp_path: Path) -> None: _setup_config_dir(tmp_path) From caaa1aefc7d4c467fb07289bcf83f13ecb464bf6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 May 2026 08:41:17 -0500 Subject: [PATCH 1090/2214] [substitutions] Fix sibling references inside dict-valued substitutions (#16273) --- esphome/components/substitutions/__init__.py | 13 ++++++++++++- .../18-dict_self_reference.approved.yaml | 16 ++++++++++++++++ .../18-dict_self_reference.input.yaml | 18 ++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/fixtures/substitutions/18-dict_self_reference.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/18-dict_self_reference.input.yaml diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index fb7cd7c51b..ea79054c88 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -278,7 +278,18 @@ def _push_context( """Resolve a variable, recursively resolving any dependencies it references.""" value = unresolved_vars.pop(key, Missing) if value is Missing: - return Missing + # Either already resolved (in resolved_vars) or currently being + # resolved (self-reference from inside a dict-valued substitution). + # Returning what we have lets sibling references inside a dict + # value, e.g. ``${device.manufacturer}`` inside ``device.name``, + # see literal sibling values during their own resolution. + return resolved_vars.get(key, Missing) + if isinstance(value, dict): + # Dict-valued substitutions form a namespace; eagerly publish the + # original mapping so its members can reference each other while + # the dict's own substitution pass is still running. The entry is + # replaced with the fully-substituted dict once recursion returns. + resolved_vars[key] = value try: value = substitute(value, [], resolver_context, True) except UndefinedError as err: diff --git a/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.approved.yaml b/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.approved.yaml new file mode 100644 index 0000000000..e5e6d4568e --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.approved.yaml @@ -0,0 +1,16 @@ +substitutions: + device: + manufacturer: espressif + model: esp32 + mac_suffix: ffffff + name: espressif-esp32-ffffff + network: + host: example.com + port: 8080 + url: http://example.com:8080/api +esphome: + name: espressif-esp32-ffffff +test_list: + - espressif-esp32-ffffff + - http://example.com:8080/api + - espressif/esp32 diff --git a/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.input.yaml b/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.input.yaml new file mode 100644 index 0000000000..b27c4b8c29 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.input.yaml @@ -0,0 +1,18 @@ +substitutions: + device: + manufacturer: "espressif" + model: "esp32" + mac_suffix: "ffffff" + name: ${device.manufacturer}-${device.model}-${device.mac_suffix} + network: + host: "example.com" + port: 8080 + url: "http://${network.host}:${network.port}/api" + +esphome: + name: ${device.name} + +test_list: + - ${device.name} + - ${network.url} + - "${device.manufacturer}/${device.model}" From 545ee03f42e830b8ea0899d06886ab4b8f22ba85 Mon Sep 17 00:00:00 2001 From: John <34163498+CircuitSetup@users.noreply.github.com> Date: Wed, 6 May 2026 10:15:04 -0400 Subject: [PATCH 1091/2214] [atm90e32] Fix calibration instance not saving in flash properly (#14152) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/atm90e32/atm90e32.cpp | 139 ++++++++++++++++------- esphome/components/atm90e32/atm90e32.h | 3 + esphome/components/atm90e32/sensor.py | 1 + 3 files changed, 105 insertions(+), 38 deletions(-) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index db29702c54..693b1b4961 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -1,6 +1,7 @@ #include "atm90e32.h" #include #include +#include #include #include "esphome/core/log.h" @@ -8,6 +9,25 @@ namespace esphome { namespace atm90e32 { static const char *const TAG = "atm90e32"; + +static uint32_t pref_hash(const char *prefix, const char *name_space) { + auto hash = fnv1_hash(prefix); + return fnv1_hash_extend(hash, name_space); +} + +template +static int migrate_legacy_pref_if_needed(ESPPreferenceObject ¤t_pref, ESPPreferenceObject &legacy_pref, + T *scratch) { + T current{}; + if (current_pref.load(¤t)) { + return 0; + } + if (!legacy_pref.load(scratch)) { + return 0; + } + return current_pref.save(scratch) ? 1 : -1; +} + void ATM90E32Component::loop() { if (this->get_publish_interval_flag_()) { this->set_publish_interval_flag_(false); @@ -112,10 +132,14 @@ void ATM90E32Component::get_cs_summary_(std::span bu this->cs_->dump_summary(buffer.data(), buffer.size()); } +const char *ATM90E32Component::get_calibration_id_() { return this->instance_id_; } + void ATM90E32Component::setup() { this->spi_setup(); - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); + char legacy_cs[GPIO_SUMMARY_MAX_LEN]; + this->get_cs_summary_(legacy_cs); + const bool has_distinct_legacy_namespace = strcmp(cs, legacy_cs) != 0; uint16_t mmode0 = 0x87; // 3P4W 50Hz uint16_t high_thresh = 0; @@ -162,15 +186,46 @@ void ATM90E32Component::setup() { if (this->enable_offset_calibration_) { // Initialize flash storage for offset calibrations - uint32_t o_hash = fnv1_hash("_offset_calibration_"); - o_hash = fnv1_hash_extend(o_hash, cs); + uint32_t o_hash = pref_hash("_offset_calibration_", cs); this->offset_pref_ = global_preferences->make_preference(o_hash, true); - this->restore_offset_calibrations_(); + bool migrated_offset = false; + if (has_distinct_legacy_namespace) { + uint32_t legacy_o_hash = pref_hash("_offset_calibration_", legacy_cs); + auto legacy_offset_pref = global_preferences->make_preference(legacy_o_hash, true); + OffsetCalibration offset_data[3]{}; + int migration_status = migrate_legacy_pref_if_needed(this->offset_pref_, legacy_offset_pref, &offset_data); + migrated_offset = migration_status > 0; + if (migration_status > 0) { + ESP_LOGI(TAG, "[CALIBRATION][%s] Migrated offset calibrations from legacy storage.", cs); + } else if (migration_status < 0) { + ESP_LOGW(TAG, "[CALIBRATION][%s] Failed to migrate offset calibrations from legacy storage.", cs); + } + } // Initialize flash storage for power offset calibrations - uint32_t po_hash = fnv1_hash("_power_offset_calibration_"); - po_hash = fnv1_hash_extend(po_hash, cs); + uint32_t po_hash = pref_hash("_power_offset_calibration_", cs); this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); + bool migrated_power_offset = false; + if (has_distinct_legacy_namespace) { + uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs); + auto legacy_power_offset_pref = + global_preferences->make_preference(legacy_po_hash, true); + PowerOffsetCalibration power_offset_data[3]{}; + int migration_status = + migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data); + migrated_power_offset = migration_status > 0; + if (migration_status > 0) { + ESP_LOGI(TAG, "[CALIBRATION][%s] Migrated power offset calibrations from legacy storage.", cs); + } else if (migration_status < 0) { + ESP_LOGW(TAG, "[CALIBRATION][%s] Failed to migrate power offset calibrations from legacy storage.", cs); + } + } + + if (migrated_offset || migrated_power_offset) { + global_preferences->sync(); + } + + this->restore_offset_calibrations_(); this->restore_power_offset_calibrations_(); } else { ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.", @@ -189,9 +244,27 @@ void ATM90E32Component::setup() { if (this->enable_gain_calibration_) { // Initialize flash storage for gain calibration - uint32_t g_hash = fnv1_hash("_gain_calibration_"); - g_hash = fnv1_hash_extend(g_hash, cs); + uint32_t g_hash = pref_hash("_gain_calibration_", cs); this->gain_calibration_pref_ = global_preferences->make_preference(g_hash, true); + bool migrated_gain = false; + if (has_distinct_legacy_namespace) { + uint32_t legacy_g_hash = pref_hash("_gain_calibration_", legacy_cs); + auto legacy_gain_calibration_pref = global_preferences->make_preference(legacy_g_hash, true); + GainCalibration gain_data[3]{}; + int migration_status = + migrate_legacy_pref_if_needed(this->gain_calibration_pref_, legacy_gain_calibration_pref, &gain_data); + migrated_gain = migration_status > 0; + if (migration_status > 0) { + ESP_LOGI(TAG, "[CALIBRATION][%s] Migrated gain calibrations from legacy storage.", cs); + } else if (migration_status < 0) { + ESP_LOGW(TAG, "[CALIBRATION][%s] Failed to migrate gain calibrations from legacy storage.", cs); + } + } + + if (migrated_gain) { + global_preferences->sync(); + } + this->restore_gain_calibrations_(); if (!this->using_saved_calibrations_) { @@ -221,8 +294,7 @@ void ATM90E32Component::setup() { } void ATM90E32Component::log_calibration_status_() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); bool offset_mismatch = false; bool power_mismatch = false; @@ -573,8 +645,7 @@ float ATM90E32Component::get_chip_temperature_() { } void ATM90E32Component::run_gain_calibrations() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); if (!this->enable_gain_calibration_) { ESP_LOGW(TAG, "[CALIBRATION][%s] Gain calibration is disabled! Enable it first with enable_gain_calibration: true", cs); @@ -674,8 +745,7 @@ void ATM90E32Component::run_gain_calibrations() { } void ATM90E32Component::save_gain_calibration_to_memory_() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); bool success = this->gain_calibration_pref_.save(&this->gain_phase_); global_preferences->sync(); if (success) { @@ -688,8 +758,7 @@ void ATM90E32Component::save_gain_calibration_to_memory_() { } void ATM90E32Component::save_offset_calibration_to_memory_() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); bool success = this->offset_pref_.save(&this->offset_phase_); global_preferences->sync(); if (success) { @@ -705,8 +774,7 @@ void ATM90E32Component::save_offset_calibration_to_memory_() { } void ATM90E32Component::save_power_offset_calibration_to_memory_() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); bool success = this->power_offset_pref_.save(&this->power_offset_phase_); global_preferences->sync(); if (success) { @@ -722,8 +790,7 @@ void ATM90E32Component::save_power_offset_calibration_to_memory_() { } void ATM90E32Component::run_offset_calibrations() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); if (!this->enable_offset_calibration_) { ESP_LOGW(TAG, "[CALIBRATION][%s] Offset calibration is disabled! Enable it first with enable_offset_calibration: true", @@ -753,8 +820,7 @@ void ATM90E32Component::run_offset_calibrations() { } void ATM90E32Component::run_power_offset_calibrations() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); if (!this->enable_offset_calibration_) { ESP_LOGW( TAG, @@ -827,15 +893,16 @@ void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t } void ATM90E32Component::restore_gain_calibrations_() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); for (uint8_t i = 0; i < 3; ++i) { this->config_gain_phase_[i].voltage_gain = this->phase_[i].voltage_gain_; this->config_gain_phase_[i].current_gain = this->phase_[i].ct_gain_; this->gain_phase_[i] = this->config_gain_phase_[i]; } - if (this->gain_calibration_pref_.load(&this->gain_phase_)) { + bool have_data = this->gain_calibration_pref_.load(&this->gain_phase_); + + if (have_data) { bool all_zero = true; bool same_as_config = true; for (uint8_t phase = 0; phase < 3; ++phase) { @@ -882,12 +949,12 @@ void ATM90E32Component::restore_gain_calibrations_() { } void ATM90E32Component::restore_offset_calibrations_() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); for (uint8_t i = 0; i < 3; ++i) this->config_offset_phase_[i] = this->offset_phase_[i]; bool have_data = this->offset_pref_.load(&this->offset_phase_); + bool all_zero = true; if (have_data) { for (auto &phase : this->offset_phase_) { @@ -925,12 +992,12 @@ void ATM90E32Component::restore_offset_calibrations_() { } void ATM90E32Component::restore_power_offset_calibrations_() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); for (uint8_t i = 0; i < 3; ++i) this->config_power_offset_phase_[i] = this->power_offset_phase_[i]; bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_); + bool all_zero = true; if (have_data) { for (auto &phase : this->power_offset_phase_) { @@ -968,8 +1035,7 @@ void ATM90E32Component::restore_power_offset_calibrations_() { } void ATM90E32Component::clear_gain_calibrations() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); if (!this->using_saved_calibrations_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored gain calibrations to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ----------------------------------------------------------", cs); @@ -1018,8 +1084,7 @@ void ATM90E32Component::clear_gain_calibrations() { } void ATM90E32Component::clear_offset_calibrations() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); if (!this->restored_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); @@ -1061,8 +1126,7 @@ void ATM90E32Component::clear_offset_calibrations() { } void ATM90E32Component::clear_power_offset_calibrations() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); if (!this->restored_power_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); @@ -1137,8 +1201,7 @@ int16_t ATM90E32Component::calibrate_power_offset(uint8_t phase, bool reactive) } bool ATM90E32Component::verify_gain_writes_() { - char cs[GPIO_SUMMARY_MAX_LEN]; - this->get_cs_summary_(cs); + const char *cs = this->get_calibration_id_(); bool success = true; for (uint8_t phase = 0; phase < 3; phase++) { uint16_t read_voltage = this->read16_(voltage_gain_registers[phase]); diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index 95154812cb..62c7bada86 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -102,6 +102,7 @@ class ATM90E32Component : public PollingComponent, void clear_gain_calibrations(); void set_enable_offset_calibration(bool flag) { enable_offset_calibration_ = flag; } void set_enable_gain_calibration(bool flag) { enable_gain_calibration_ = flag; } + void set_instance_id(const char *id) { instance_id_ = id; } int16_t calibrate_offset(uint8_t phase, bool voltage); int16_t calibrate_power_offset(uint8_t phase, bool reactive); void run_gain_calibrations(); @@ -183,6 +184,7 @@ class ATM90E32Component : public PollingComponent, bool verify_gain_writes_(); bool validate_spi_read_(uint16_t expected, const char *context = nullptr); void log_calibration_status_(); + const char *get_calibration_id_(); void get_cs_summary_(std::span buffer); struct ATM90E32Phase { @@ -263,6 +265,7 @@ class ATM90E32Component : public PollingComponent, bool peak_current_signed_{false}; bool enable_offset_calibration_{false}; bool enable_gain_calibration_{false}; + const char *instance_id_{nullptr}; bool restored_offset_calibration_{false}; bool restored_power_offset_calibration_{false}; bool restored_gain_calibration_{false}; diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py index 7e5d85c57a..dc46138add 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -193,6 +193,7 @@ CONFIG_SCHEMA = ( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) + cg.add(var.set_instance_id(str(config[CONF_ID]))) await cg.register_component(var, config) await spi.register_spi_device(var, config) From 6e1a59da3e583c5624a18da82b62d85256e7f0f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 May 2026 09:53:14 -0500 Subject: [PATCH 1092/2214] [packages] Make package !include vars visible to its substitutions block (#16274) --- esphome/components/packages/__init__.py | 59 +++++++++++++++---- .../18-package_vars_in_subs.approved.yaml | 9 +++ .../18-package_vars_in_subs.input.yaml | 9 +++ .../18-package_vars_in_subs_inc.yaml | 8 +++ 4 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs_inc.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index d63f17aa7e..06a64208b6 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -414,25 +414,39 @@ def _substitute_package_definition( def _update_substitutions_context( parent_context: UserDict, package_substitutions: dict[str, Any], + eval_context: ContextVars | None = None, ) -> None: """Resolve and add new substitutions to the parent context. Skips keys already present (higher-priority sources win). - String values are substituted against the current context so that - cross-references between substitutions are expanded when possible. + String values are substituted against *eval_context* (or *parent_context* + if not provided) so that cross-references between substitutions are + expanded when possible. Resolved values are written into *parent_context* + and back into *package_substitutions* so that subsequent merges into the + consolidated ``substitutions:`` block carry the resolved value (the + package's ``!include vars`` are no longer in scope after this function + returns). + + *eval_context* may layer additional vars (e.g. a package's own ``!include + vars``) on top of *parent_context* so that a package's substitutions can + reference vars passed in by the parent file. """ + if eval_context is None: + eval_context = ContextVars(parent_context) for key, value in package_substitutions.items(): if key in parent_context: continue if not isinstance(value, str): parent_context[key] = value continue - parent_context[key] = substitute( + resolved = substitute( item=value, path=[CONF_SUBSTITUTIONS, key], - parent_context=ContextVars(parent_context), + parent_context=eval_context, strict_undefined=False, ) + parent_context[key] = resolved + package_substitutions[key] = resolved class _PackageProcessor: @@ -508,11 +522,36 @@ class _PackageProcessor: package_config = _process_remote_package(package_config) return package_config - def collect_substitutions(self, package_config: dict) -> None: - """Extract substitutions from a package and merge into the shared context.""" + def collect_substitutions( + self, + package_config: dict, + context_vars: ContextVars | None, + ) -> ContextVars: + """Extract substitutions from a package and merge into the shared context. + + Returns the context updated with the package's ``!include vars`` (or + an equivalent of *context_vars* if the package has none) so the caller + can reuse it when recursing into nested packages. ``None`` inputs are + normalized to an empty :class:`ContextVars`, so the result is always + non-``None``. + """ + # Push the package's own !include vars before evaluating its + # substitutions so they can reference vars passed in by the parent + # (e.g. ``vars: {my_variable: ...}`` on the include entry). + package_context = push_context( + package_config, context_vars if context_vars is not None else ContextVars() + ) if subs := package_config.pop(CONF_SUBSTITUTIONS, {}): + # Resolve before merging so that values referencing the package's + # ``!include vars`` are baked into the consolidated substitutions + # block; once we return, the package vars are no longer in scope. + # ``package_context`` is a ChainMap whose chain already terminates + # in ``self.parent_context`` (set up by ``do_packages_pass``), so + # ``parent_context`` mutations from ``_update_substitutions_context`` + # remain visible to evaluation reads. + _update_substitutions_context(self.parent_context, subs, package_context) self.substitutions.data = merge_config(subs, self.substitutions.data) - _update_substitutions_context(self.parent_context, subs) + return package_context def process_package( self, @@ -525,13 +564,13 @@ class _PackageProcessor: package_config ) package_config = self.resolve_package(package_config, context_vars, path) - self.collect_substitutions(package_config) + context_vars = self.collect_substitutions(package_config, context_vars) if CONF_PACKAGES not in package_config: return package_config - # Push context from !include vars on the package root and on the packages key - context_vars = push_context(package_config, context_vars) + # Push context from !include vars on the packages key (the package root + # was already pushed in collect_substitutions above). context_vars = push_context(package_config[CONF_PACKAGES], context_vars) # Disable the deprecated single-package fallback for remote # packages. _process_remote_package returns dicts with diff --git a/tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs.approved.yaml b/tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs.approved.yaml new file mode 100644 index 0000000000..647a33a983 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs.approved.yaml @@ -0,0 +1,9 @@ +binary_sensor: + - platform: template + id: front_door_enrolling + name: Front Door Enrolling +substitutions: + enrolling_id: front_door_enrolling + enrolling_name: Front Door Enrolling +esphome: + name: test diff --git a/tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs.input.yaml b/tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs.input.yaml new file mode 100644 index 0000000000..21a0f2d235 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs.input.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +packages: + fingerprint: !include + file: 18-package_vars_in_subs_inc.yaml + vars: + sensor_name: "Front Door" + sensor_id_prefix: "front_door" diff --git a/tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs_inc.yaml b/tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs_inc.yaml new file mode 100644 index 0000000000..8b420d73e7 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/18-package_vars_in_subs_inc.yaml @@ -0,0 +1,8 @@ +substitutions: + enrolling_id: ${sensor_id_prefix}_enrolling + enrolling_name: ${sensor_name} Enrolling + +binary_sensor: + - platform: template + id: ${enrolling_id} + name: ${enrolling_name} From 90693fb39aac35c2303e8b6165fa4638085dbd8d Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Wed, 6 May 2026 14:56:33 +0000 Subject: [PATCH 1093/2214] [core] Fix WiFi connection in safe mode (#16269) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/core/config.py | 27 ++--- tests/integration/conftest.py | 111 +++++++++--------- .../fixtures/safe_mode_loop_runs.yaml | 25 ++++ tests/integration/host_prefs.py | 39 ++++++ tests/integration/test_safe_mode_loop_runs.py | 94 +++++++++++++++ tests/integration/test_template_text_save.py | 15 +-- tests/unit_tests/core/test_config.py | 70 +++++++++++ 7 files changed, 299 insertions(+), 82 deletions(-) create mode 100644 tests/integration/fixtures/safe_mode_loop_runs.yaml create mode 100644 tests/integration/host_prefs.py create mode 100644 tests/integration/test_safe_mode_loop_runs.py diff --git a/esphome/core/config.py b/esphome/core/config.py index b4e81ce49f..fe55c0fe25 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -568,14 +568,9 @@ async def _add_controller_registry_define() -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _add_looping_components() -> None: - # Emit a constexpr that computes the looping component count at C++ compile time - # and pre-init the FixedVector with the exact capacity. Uses std::is_same_v to - # detect loop() overrides. The constexpr goes in main.cpp's global section where - # all component types are in scope. calculate_looping_components_() then skips - # the counting pass and only does the two population passes. + # Emit ESPHOME_LOOPING_COMPONENT_COUNT. Sizing of looping_components_ + # happens in core to_code() so it lands before safe_mode's early return. entries = CORE.data.get("looping_component_entries", []) - if not entries: - return # Build constexpr sum for the exact count, deduplicating by type # Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance @@ -583,7 +578,7 @@ async def _add_looping_components() -> None: terms = [ f"({count} * HasLoopOverride<{cpp_type}>::value)" for cpp_type, count in type_counts.items() - ] + ] or ["0"] constexpr_expr = " + \\\n ".join(terms) cg.add_global( cg.RawStatement( @@ -592,14 +587,6 @@ async def _add_looping_components() -> None: ) ) - # Pre-init FixedVector with exact capacity so calculate_looping_components_() - # can skip the counting pass - cg.add( - cg.RawExpression( - "App.looping_components_.init(ESPHOME_LOOPING_COMPONENT_COUNT)" - ) - ) - @coroutine_with_priority(CoroPriority.CORE) async def to_code(config: ConfigType) -> None: @@ -642,6 +629,14 @@ async def to_code(config: ConfigType) -> None: # Define component count for static allocation cg.add_define("ESPHOME_COMPONENT_COUNT", len(CORE.component_ids)) + # Pre-init FixedVector with exact capacity so calculate_looping_components_() + # can skip the counting pass + cg.add( + cg.RawExpression( + "App.looping_components_.init(ESPHOME_LOOPING_COMPONENT_COUNT)" + ) + ) + CORE.add_job(_add_platform_defines) CORE.add_job(_add_controller_registry_define) CORE.add_job(_add_looping_components) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7c85bf753c..f36543b7cd 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -501,14 +501,15 @@ async def _read_stream_lines( @asynccontextmanager -async def run_binary_and_wait_for_port( +async def run_binary( binary_path: Path, - host: str, - port: int, - timeout: float = PORT_WAIT_TIMEOUT, line_callback: Callable[[str], None] | None = None, -) -> AsyncGenerator[None]: - """Run a binary, wait for it to open a port, and clean up on exit.""" +) -> AsyncGenerator[tuple[asyncio.subprocess.Process, list[str]]]: + """Run a binary under a PTY, capture log output, and clean up on exit. + + Yields the running ``Process`` and a live list of captured log lines. + No port wait -- callers that need that should use + ``run_binary_and_wait_for_port``.""" # Create a pseudo-terminal to make the binary think it's running interactively # This is needed because the ESPHome host logger checks isatty() controller_fd, device_fd = pty.openpty() @@ -535,7 +536,6 @@ async def run_binary_and_wait_for_port( controller_transport, _ = await loop.connect_read_pipe( lambda: controller_protocol, os.fdopen(controller_fd, "rb", 0) ) - output_reader = controller_reader if process.returncode is not None: raise RuntimeError( @@ -543,27 +543,59 @@ async def run_binary_and_wait_for_port( "Ensure the binary is valid and can run successfully." ) - # Wait for the API server to start listening - loop = asyncio.get_running_loop() - start_time = loop.time() - - # Start collecting output stdout_lines: list[str] = [] - output_tasks: list[asyncio.Task] = [] + output_task = asyncio.create_task( + _read_stream_lines(controller_reader, stdout_lines, sys.stdout, line_callback) + ) try: - # Read from output stream - output_tasks = [ - asyncio.create_task( - _read_stream_lines( - output_reader, stdout_lines, sys.stdout, line_callback - ) - ) - ] - # Small yield to ensure the process has a chance to start await asyncio.sleep(0) + yield process, stdout_lines + finally: + output_task.cancel() + result = await asyncio.gather(output_task, return_exceptions=True) + if isinstance(result[0], Exception) and not isinstance( + result[0], asyncio.CancelledError + ): + print(f"Error reading from PTY: {result[0]}", file=sys.stderr) + # Close the PTY transport (Unix only) + if controller_transport is not None: + controller_transport.close() + + # Cleanup: terminate the process gracefully + if process.returncode is None: + # Send SIGINT (Ctrl+C) for graceful shutdown + process.send_signal(signal.SIGINT) + try: + await asyncio.wait_for(process.wait(), timeout=SIGINT_TIMEOUT) + except TimeoutError: + # If SIGINT didn't work, try SIGTERM + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=SIGTERM_TIMEOUT) + except TimeoutError: + # Last resort: SIGKILL + process.kill() + await process.wait() + + +@asynccontextmanager +async def run_binary_and_wait_for_port( + binary_path: Path, + host: str, + port: int, + timeout: float = PORT_WAIT_TIMEOUT, + line_callback: Callable[[str], None] | None = None, +) -> AsyncGenerator[None]: + """Run a binary, wait for it to open a port, and clean up on exit.""" + async with run_binary(binary_path, line_callback=line_callback) as ( + process, + stdout_lines, + ): + loop = asyncio.get_running_loop() + start_time = loop.time() while loop.time() - start_time < timeout: try: # Try to connect to the port @@ -593,41 +625,6 @@ async def run_binary_and_wait_for_port( raise TimeoutError(error_msg) - finally: - # Cancel output collection tasks - for task in output_tasks: - task.cancel() - # Wait for tasks to complete and check for exceptions - results = await asyncio.gather(*output_tasks, return_exceptions=True) - for i, result in enumerate(results): - if isinstance(result, Exception) and not isinstance( - result, asyncio.CancelledError - ): - print( - f"Error reading from PTY: {result}", - file=sys.stderr, - ) - - # Close the PTY transport (Unix only) - if controller_transport is not None: - controller_transport.close() - - # Cleanup: terminate the process gracefully - if process.returncode is None: - # Send SIGINT (Ctrl+C) for graceful shutdown - process.send_signal(signal.SIGINT) - try: - await asyncio.wait_for(process.wait(), timeout=SIGINT_TIMEOUT) - except TimeoutError: - # If SIGINT didn't work, try SIGTERM - process.terminate() - try: - await asyncio.wait_for(process.wait(), timeout=SIGTERM_TIMEOUT) - except TimeoutError: - # Last resort: SIGKILL - process.kill() - await process.wait() - @asynccontextmanager async def run_compiled_context( diff --git a/tests/integration/fixtures/safe_mode_loop_runs.yaml b/tests/integration/fixtures/safe_mode_loop_runs.yaml new file mode 100644 index 0000000000..342622428b --- /dev/null +++ b/tests/integration/fixtures/safe_mode_loop_runs.yaml @@ -0,0 +1,25 @@ +esphome: + name: safe-mode-loop-runs + +host: + +logger: + +safe_mode: + num_attempts: 10 + on_safe_mode: + - lambda: |- + // Spawn a detached thread that logs a unique marker. The + // non-main-thread log goes through the task log buffer, which + // is only drained by Logger::loop(). If looping components + // weren't initialized (the bug fixed in #16269), the buffer is + // never read and the marker never reaches the console. + struct MarkerThread { + static void *thread_func(void *) { + ESP_LOGI("safe_mode_test", "looping component ran in safe mode"); + return nullptr; + } + }; + pthread_t t; + pthread_create(&t, nullptr, MarkerThread::thread_func, nullptr); + pthread_detach(t); diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py new file mode 100644 index 0000000000..f835bee3bc --- /dev/null +++ b/tests/integration/host_prefs.py @@ -0,0 +1,39 @@ +"""Helpers for manipulating the host platform's preferences file. + +ESPHome's host platform stores preferences in +``~/.esphome/prefs/.prefs`` using a simple binary layout that +mirrors ``HostPreferences::sync()``: +``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry. + +Tests use these helpers to pre-populate state the binary will see at +boot (e.g. forcing safe mode) or to clear stale state between runs. +""" + +from __future__ import annotations + +from pathlib import Path +import struct + + +def host_prefs_path(device_name: str) -> Path: + """Return the on-disk prefs file path for a host-platform device.""" + return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs" + + +def clear_host_prefs(device_name: str) -> None: + """Delete the prefs file for a host-platform device, if it exists.""" + host_prefs_path(device_name).unlink(missing_ok=True) + + +def write_host_pref(device_name: str, key: int, data: bytes) -> 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(" None: + """When safe mode is active, ``App.loop()`` must still iterate looping + components -- proven here by a thread-logged marker reaching the + console (which requires ``Logger::loop()`` to run).""" + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # Compile finished successfully; pre-populate prefs so the *next* run + # enters safe mode immediately. + write_host_pref( + DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack(" None: + if not safe_mode_active.done() and safe_mode_pattern.search(line): + safe_mode_active.set_result(True) + if not thread_log_seen.done() and thread_log_pattern.search(line): + thread_log_seen.set_result(True) + + async with run_binary(binary_path, line_callback=on_log): + try: + await asyncio.wait_for(safe_mode_active, timeout=15.0) + except TimeoutError: + pytest.fail( + "Did not observe 'SAFE MODE IS ACTIVE' -- safe mode " + "didn't trigger, so this test isn't exercising the bug." + ) + try: + await asyncio.wait_for(thread_log_seen, timeout=10.0) + except TimeoutError: + pytest.fail( + f"Did not observe thread-logged marker {THREAD_LOG_MARKER!r} " + "within timeout. Logger::loop() never drained the task " + "log buffer, meaning App.looping_components_ was never " + "sized -- this is the regression #16269 fixed." + ) + finally: + clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_template_text_save.py b/tests/integration/test_template_text_save.py index 47c8e3188a..7e56209c50 100644 --- a/tests/integration/test_template_text_save.py +++ b/tests/integration/test_template_text_save.py @@ -9,7 +9,6 @@ Tests that: from __future__ import annotations import asyncio -from pathlib import Path import socket from typing import Any @@ -17,9 +16,12 @@ from aioesphomeapi import TextInfo, TextState import pytest from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs from .state_utils import InitialStateHelper, require_entity from .types import CompileFunction, ConfigWriter +DEVICE_NAME = "host-template-text-save-test" + @pytest.mark.asyncio async def test_template_text_save( @@ -32,11 +34,7 @@ async def test_template_text_save( port, port_socket = reserved_tcp_port # Clean up any stale preference file from previous runs - prefs_file = ( - Path.home() / ".esphome" / "prefs" / "host-template-text-save-test.prefs" - ) - if prefs_file.exists(): - prefs_file.unlink() + clear_host_prefs(DEVICE_NAME) # Write and compile once config_path = await write_yaml_config(yaml_config) @@ -59,7 +57,7 @@ async def test_template_text_save( wait_and_connect_api_client(port=port) as client, ): device_info = await client.device_info() - assert device_info.name == "host-template-text-save-test" + assert device_info.name == DEVICE_NAME entities, _ = await client.list_entities_services() text_entity = require_entity( @@ -127,5 +125,4 @@ async def test_template_text_save( ) # Clean up preference file - if prefs_file.exists(): - prefs_file.unlink() + clear_host_prefs(DEVICE_NAME) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 6fa8f7ed43..4ce862315d 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -10,6 +10,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config_validation as cv, core +from esphome.components.safe_mode import to_code as safe_mode_to_code from esphome.const import ( CONF_AREA, CONF_AREAS, @@ -312,6 +313,75 @@ def test_add_platform_defines_priority() -> None: ) +def test_to_code_priority_above_safe_mode() -> None: + """Test that core to_code emits the looping_components_ init before safe_mode. + + Regression test for https://github.com/esphome/esphome/issues/16262. + safe_mode emits an `if (should_enter_safe_mode(...)) return;` line in main() + at APPLICATION priority. The `App.looping_components_.init(...)` call must be + emitted at a higher priority than APPLICATION so it lands in main() before + the early return; otherwise the FixedVector is never sized when safe mode is + active and loop() never runs (Wi-Fi never connects). + """ + assert config.to_code.priority > safe_mode_to_code.priority, ( + f"core to_code priority ({config.to_code.priority}) must be greater than " + f"safe_mode to_code priority ({safe_mode_to_code.priority}) so that " + "App.looping_components_.init() is emitted before safe_mode's early return" + ) + + +@pytest.mark.asyncio +async def test_add_looping_components_handles_empty_entries() -> None: + """Test that _add_looping_components emits a valid constexpr when there are + no looping component entries. + + With zero entries the generated constexpr must still be syntactically valid + C++ (`= 0;`), not an empty expression (`= ;`). This guards the empty-list + case that would otherwise produce uncompilable main.cpp output. + """ + CORE.data["looping_component_entries"] = [] + + await config._add_looping_components() + + constexpr_lines = [ + str(s) + for s in CORE.global_statements + if "ESPHOME_LOOPING_COMPONENT_COUNT" in str(s) + ] + assert len(constexpr_lines) == 1 + text = constexpr_lines[0] + assert "static constexpr size_t ESPHOME_LOOPING_COMPONENT_COUNT" in text + # The right-hand side must contain a literal `0`, not be empty. + rhs = text.split("=", 1)[1] + assert "0" in rhs + assert rhs.strip().rstrip(";").strip(), ( + f"constexpr right-hand side must not be empty, got: {text!r}" + ) + + +@pytest.mark.asyncio +async def test_add_looping_components_with_entries() -> None: + """Test that _add_looping_components builds a HasLoopOverride sum from entries.""" + CORE.data["looping_component_entries"] = [ + "esphome::wifi::WiFiComponent", + "esphome::logger::Logger", + "esphome::wifi::WiFiComponent", + ] + + await config._add_looping_components() + + constexpr_lines = [ + str(s) + for s in CORE.global_statements + if "ESPHOME_LOOPING_COMPONENT_COUNT" in str(s) + ] + assert len(constexpr_lines) == 1 + text = constexpr_lines[0] + # Deduplicated by type, with per-type counts as multiplier. + assert "(2 * HasLoopOverride::value)" in text + assert "(1 * HasLoopOverride::value)" in text + + def test_valid_include_with_angle_brackets() -> None: """Test valid_include accepts angle bracket includes.""" assert valid_include("") == "" From 2864922ac05311159596877c51fc7464cec8fa1b Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Wed, 6 May 2026 14:59:10 +0000 Subject: [PATCH 1094/2214] [ota] Partition table update: Fix log messages (#16241) 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: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/esphome/ota/ota_esphome.cpp | 4 +-- .../components/ota/ota_backend_esp_idf.cpp | 3 +- .../components/ota/ota_partitions_esp_idf.cpp | 33 ++++++++++++------- esphome/espota2.py | 6 ++-- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3ce3f2302d..5d3deca489 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -117,8 +117,8 @@ void ESPHomeOTAComponent::dump_config() { " Partition table:\n" " %-12s %-4s %-8s %-10s %-10s", "Name", "Type", "Subtype", "Address", "Size"); - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { const esp_partition_t *partition = esp_partition_get(it); ESP_LOGCONFIG(TAG, " %-12s 0x%-2X 0x%-6X 0x%-8" PRIX32 " 0x%-8" PRIX32, partition->label, partition->type, partition->subtype, partition->address, partition->size); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 42d106bf1f..50a0988ba2 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -20,8 +20,7 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) #ifdef USE_OTA_PARTITIONS this->ota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - // Reject any size other than ESP_PARTITION_TABLE_MAX_LEN: under- leaves stale bytes from the - // previous table; over- can't fit the reserved region. + // Reject any size other than ESP_PARTITION_TABLE_MAX_LEN if (image_size != ESP_PARTITION_TABLE_MAX_LEN) { ESP_LOGE(TAG, "Wrong partition table size: expected %u bytes, got %zu", ESP_PARTITION_TABLE_MAX_LEN, image_size); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index 2a2ed577f1..f7fd529986 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -11,6 +11,7 @@ #include #include +#include #include namespace esphome::ota { @@ -135,10 +136,20 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a // Rejecting here is non-destructive (no flash op has run yet); the user can safely retry with // a different .bin. Log enough info that they can pick the right method without guessing. ESP_LOGE(TAG, - "Running app at 0x%X (%u bytes used) does not fit any compatible slot in the new " - "partition table. Pick a migration method whose size limit is at least %u bytes and " - "retry; no flash content was modified.", - running_app_offset, running_app_size, running_app_size); + "The new partition table must contain a compatible app partition with:\n" + " size: at least %" PRIu32 " bytes (0x%" PRIX32 ")\n" + " address: one of", + (uint32_t) running_app_size, (uint32_t) running_app_size); + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { + const esp_partition_t *partition = esp_partition_get(it); + if (partition->size >= running_app_size) { + ESP_LOGE(TAG, " 0x%" PRIX32, partition->address); + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + ESP_LOGE(TAG, "Upload a different partition table. No flash content was modified."); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (app_partitions_found < 2) { @@ -154,11 +165,11 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (otadata_overlap) { + // Unlikely, the otadata partition is before the start of the first app partition in most cases ESP_LOGE(TAG, - "New otadata partition overlaps with the running app at 0x%X (size %u). The chosen " - "partition table is not compatible with this device's current flash layout; pick a " - "different migration method.", - running_app_offset, running_app_size); + "New otadata partition overlaps with the running app at address: 0x%" PRIX32 ", running app size: %" PRIu32 + " bytes", + running_app_offset, (uint32_t) running_app_size); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -198,8 +209,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // can leave the device unbootable until it is recovered with a serial flash. ESP_LOGE(TAG, "Starting partition table update.\n" " DO NOT REMOVE POWER until the device reboots successfully.\n" - " Loss of power during this operation may render the device unable to boot until\n" - " it is recovered via a serial flash."); + " Loss of power during this operation may render the device\n" + " unable to boot until it is recovered via a serial flash."); // One guard over the whole critical section in case an IDF call takes longer than expected on // some chip variant. @@ -214,7 +225,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // which leaves esp_ota_get_running_partition() returning nullptr. const esp_partition_t *running_app_part = find_app_partition_at(running_app_offset, running_app_size); if (running_app_part == nullptr) { - ESP_LOGE(TAG, "Cannot resolve running app partition at offset 0x%X", running_app_offset); + ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, diff --git a/esphome/espota2.py b/esphome/espota2.py index a45a6ef234..b2a1fd2a40 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -139,9 +139,9 @@ _ERROR_MESSAGES: dict[int, str] = { ), RESPONSE_ERROR_PARTITION_TABLE_UPDATE: ( "An error occurred while updating the partition table. The device is now " - "in a degraded state (NVS handles are invalid; many components will fail) " - "and may not be able to boot. Check the logs, reboot the device, and " - "retry the update. If the device fails to boot, recover it via a serial flash." + "in a degraded state and may not be able to boot. Open the logs and retry " + "the partition table update without rebooting the device. If the device " + "fails to boot, recover it via a serial flash." ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } From cfd2c9182c9cf7355cd4c536f64bc81e7073073c Mon Sep 17 00:00:00 2001 From: Didier A Date: Wed, 6 May 2026 18:34:55 +0200 Subject: [PATCH 1095/2214] [bl0942] Remove broken 24-bit overflow tracking (#15650) Co-authored-by: DidierA <1620015+didiera@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/bl0942/bl0942.cpp | 8 ++------ esphome/components/bl0942/bl0942.h | 1 - 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 7d38597423..074aff9643 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -161,13 +161,9 @@ void BL0942::received_package_(DataPacket *data) { return; } - // cf_cnt is only 24 bits, so track overflows + // cf_cnt wraps at 24 bits; total_increasing on the energy sensor handles the + // wrap (and any spurious chip resets) downstream. uint32_t cf_cnt = (uint24_t) data->cf_cnt; - cf_cnt |= this->prev_cf_cnt_ & 0xff000000; - if (cf_cnt < this->prev_cf_cnt_) { - cf_cnt += 0x1000000; - } - this->prev_cf_cnt_ = cf_cnt; float v_rms = (uint24_t) data->v_rms / voltage_reference_; float i_rms = (uint24_t) data->i_rms / current_reference_; diff --git a/esphome/components/bl0942/bl0942.h b/esphome/components/bl0942/bl0942.h index 3c013f86e7..7604399c25 100644 --- a/esphome/components/bl0942/bl0942.h +++ b/esphome/components/bl0942/bl0942.h @@ -141,7 +141,6 @@ class BL0942 : public PollingComponent, public uart::UARTDevice { bool reset_ = false; LineFrequency line_freq_ = LINE_FREQUENCY_50HZ; optional rx_start_{}; - uint32_t prev_cf_cnt_ = 0; bool validate_checksum_(DataPacket *data); int read_reg_(uint8_t reg); From a4a57a540d38b009dff46996203ea7249fda10a8 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 6 May 2026 12:56:54 -0400 Subject: [PATCH 1096/2214] [core] Adds acquire and release methods to the ring buffer class (#16277) --- esphome/core/ring_buffer.cpp | 8 ++++++++ esphome/core/ring_buffer.h | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/esphome/core/ring_buffer.cpp b/esphome/core/ring_buffer.cpp index 2e0802eceb..486cf67f25 100644 --- a/esphome/core/ring_buffer.cpp +++ b/esphome/core/ring_buffer.cpp @@ -37,6 +37,14 @@ std::unique_ptr RingBuffer::create(size_t len, MemoryPreference pref return rb; } +void *RingBuffer::receive_acquire(size_t &length, size_t max_length, TickType_t ticks_to_wait) { + length = 0; + void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &length, ticks_to_wait, max_length); + return buffer_data; +} + +void RingBuffer::receive_release(void *item) { vRingbufferReturnItem(this->handle_, item); } + size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) { size_t bytes_read = 0; diff --git a/esphome/core/ring_buffer.h b/esphome/core/ring_buffer.h index 4acd07d5b0..8ac3ff3811 100644 --- a/esphome/core/ring_buffer.h +++ b/esphome/core/ring_buffer.h @@ -27,6 +27,28 @@ class RingBuffer { */ size_t read(void *data, size_t len, TickType_t ticks_to_wait = 0); + /** + * @brief Acquires a pointer into the ring buffer's internal storage without copying. + * + * The returned pointer is valid until receive_release() is called. Only one item + * may be checked out at a time. + * + * @param[out] length Set to the number of bytes actually acquired (may be less than max_length at wrap boundary) + * @param max_length Maximum number of bytes to acquire + * @param ticks_to_wait Maximum number of FreeRTOS ticks to wait (default: 0) + * @return Pointer into the ring buffer's internal storage, or nullptr if no data is available + */ + void *receive_acquire(size_t &length, size_t max_length, TickType_t ticks_to_wait = 0); + + /** + * @brief Releases a previously acquired ring buffer item. + * + * Must be called exactly once for each successful receive_acquire(). + * + * @param item Pointer returned by receive_acquire() + */ + void receive_release(void *item); + /** * @brief Writes to the ring buffer, overwriting oldest data if necessary. * From fc25ab0246b1575d3e1bb2ea69dbc4546a72f239 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 6 May 2026 12:57:03 -0400 Subject: [PATCH 1097/2214] [i2s_audio] Optimize software volume control (#16278) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 51 ++++++------------- .../i2s_audio/speaker/i2s_audio_speaker.h | 2 +- 2 files changed, 17 insertions(+), 36 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 836221e38a..58d17ea6c4 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -13,22 +13,16 @@ #include "esp_timer.h" +// esp-audio-libs +#include + namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker"; -// Lists the Q15 fixed point scaling factor for volume reduction. -// Has 100 values representing silence and a reduction [49, 48.5, ... 0.5, 0] dB. -// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014) -// float to Q15 fixed point formula: q15_scale_factor = floating_point_scale_factor * 2^(15) -static const std::vector Q15_VOLUME_SCALING_FACTORS = { - 0, 116, 122, 130, 137, 146, 154, 163, 173, 183, 194, 206, 218, 231, 244, - 259, 274, 291, 308, 326, 345, 366, 388, 411, 435, 461, 488, 517, 548, 580, - 615, 651, 690, 731, 774, 820, 868, 920, 974, 1032, 1094, 1158, 1227, 1300, 1377, - 1459, 1545, 1637, 1734, 1837, 1946, 2061, 2184, 2313, 2450, 2596, 2750, 2913, 3085, 3269, - 3462, 3668, 3885, 4116, 4360, 4619, 4893, 5183, 5490, 5816, 6161, 6527, 6914, 7324, 7758, - 8218, 8706, 9222, 9770, 10349, 10963, 11613, 12302, 13032, 13805, 14624, 15491, 16410, 17384, 18415, - 19508, 20665, 21891, 23189, 24565, 26022, 27566, 29201, 30933, 32767}; +// Software volume control maps the user-facing [0.0, 1.0] range to a Q31 scale factor. +// Volumes in (0.0, 1.0) map linearly to a dB reduction in [-49.0, 0.0] dB. +static constexpr float SOFTWARE_VOLUME_MIN_DB = -49.0f; void I2SAudioSpeakerBase::setup() { this->event_group_ = xEventGroupCreate(); @@ -147,14 +141,16 @@ void I2SAudioSpeakerBase::set_volume(float volume) { } else #endif // USE_AUDIO_DAC { - // Fallback to software volume control by using a Q15 fixed point scaling factor. - // At maximum volume (1.0), set to INT16_MAX to completely bypass volume processing + // Fallback to software volume control by using a Q31 fixed point scaling factor. + // At maximum volume (1.0), set to INT32_MAX to bypass volume processing entirely // and avoid any floating-point precision issues that could cause slight volume reduction. if (volume >= 1.0f) { - this->q15_volume_factor_ = INT16_MAX; + this->q31_volume_factor_ = INT32_MAX; + } else if (volume <= 0.0f) { + this->q31_volume_factor_ = 0; } else { - ssize_t decibel_index = remap(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1); - this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index]; + this->q31_volume_factor_ = + esp_audio_libs::gain::db_to_q31(remap(volume, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f)); } } } @@ -173,7 +169,7 @@ void I2SAudioSpeakerBase::set_mute_state(bool mute_state) { { if (mute_state) { // Fallback to software volume control and scale by 0 - this->q15_volume_factor_ = 0; + this->q31_volume_factor_ = 0; } else { // Revert to previous volume when unmuting this->set_volume(this->volume_); @@ -309,29 +305,14 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s } void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { - if (this->q15_volume_factor_ >= INT16_MAX) { + if (this->q31_volume_factor_ == INT32_MAX) { return; // Max volume, no processing needed } const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1); const uint32_t len = bytes_read / bytes_per_sample; - // Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31 - int32_t shift = 15; // Q31 -> Q16 - int32_t gain_factor = this->q15_volume_factor_; // Q15 - - if (bytes_per_sample >= 3) { - // Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31 - shift = 8; // Q31 -> Q23 - gain_factor >>= 7; // Q15 -> Q8 - } - - for (uint32_t i = 0; i < len; ++i) { - int32_t sample = audio::unpack_audio_sample_to_q31(&data[i * bytes_per_sample], bytes_per_sample); // Q31 - sample >>= shift; - sample *= gain_factor; // Q31 - audio::pack_q31_as_audio_sample(sample, &data[i * bytes_per_sample], bytes_per_sample); - } + esp_audio_libs::gain::apply(data, data, this->q31_volume_factor_, len, bytes_per_sample); } void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index b2644efd05..d9a228ef2c 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -151,7 +151,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool pause_state_{false}; - int16_t q15_volume_factor_{INT16_MAX}; + int32_t q31_volume_factor_{INT32_MAX}; audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info From 9f49e3f80e75d2309eaf6766222aa622a458516f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 6 May 2026 13:22:18 -0400 Subject: [PATCH 1098/2214] [audio] Bump microFLAC to v0.2.0 (#16279) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index cfb2ad4e75..67ef2e7d1a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -386,7 +386,7 @@ async def to_code(config): # Adds a define and IDF component for legacy `audio_decoder.cpp`. if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") - add_idf_component(name="esphome/micro-flac", ref="0.1.1") + add_idf_component(name="esphome/micro-flac", ref="0.2.0") _emit_memory_pair( data.flac.buffer_memory, "CONFIG_MICRO_FLAC_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 37c0da11f5..8ffcffa705 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -8,7 +8,7 @@ dependencies: esphome/micro-decoder: version: 0.2.0 esphome/micro-flac: - version: 0.1.1 + version: 0.2.0 esphome/micro-mp3: version: 0.2.0 esphome/micro-opus: From 4da62067cf5864ebcd8448c2c7ee8aed16ccdd11 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 6 May 2026 19:32:50 +0200 Subject: [PATCH 1099/2214] [nextion] Fix text sensor state not updated on string response (#16280) --- esphome/components/nextion/nextion.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index e42f7ca216..b644cad507 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -641,6 +641,7 @@ void Nextion::process_nextion_commands_() { } else { ESP_LOGN(TAG, "String resp: '%s' id: %s type: %s", to_process.c_str(), component->get_variable_name().c_str(), component->get_queue_type_string()); + component->set_state_from_string(to_process, true, false); } delete nb; // NOLINT(cppcoreguidelines-owning-memory) From 0d94ffe15dbde9cd0bb8f852b7167e14417bdbda Mon Sep 17 00:00:00 2001 From: dbl-0 Date: Wed, 6 May 2026 11:48:38 -0600 Subject: [PATCH 1100/2214] [resolver] Make RESOLVE_TIMEOUT configurable via environment variable (#15951) Co-authored-by: Daniel Lowe Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/resolver.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/esphome/resolver.py b/esphome/resolver.py index 9fb596ce7b..f80a910afe 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -2,13 +2,28 @@ from __future__ import annotations +import logging +import os + from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError import aioesphomeapi.host_resolver as hr from esphome.async_thread import AsyncThreadRunner from esphome.core import EsphomeError -RESOLVE_TIMEOUT = 10.0 # seconds +_LOGGER = logging.getLogger(__name__) + +_DEFAULT_RESOLVE_TIMEOUT = 20.0 +_env_timeout = os.environ.get("ESPHOME_RESOLVE_TIMEOUT", _DEFAULT_RESOLVE_TIMEOUT) +try: + RESOLVE_TIMEOUT = float(_env_timeout) +except ValueError: + _LOGGER.warning( + "ESPHOME_RESOLVE_TIMEOUT=%r is not a valid number; using default %.1fs", + _env_timeout, + _DEFAULT_RESOLVE_TIMEOUT, + ) + RESOLVE_TIMEOUT = _DEFAULT_RESOLVE_TIMEOUT class AsyncResolver: From 6173656bf8db35a563071cddfb3180304bf0a9fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 May 2026 12:49:00 -0500 Subject: [PATCH 1101/2214] [schema] Surface OnlyWith / OnlyWithout default + gate components in schema generator (#16276) --- script/build_language_schema.py | 35 ++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 09ff999901..05ac47bfcc 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1065,7 +1065,40 @@ def convert_keys(converted, schema, path): else: converted["key_type"] = str(k) - if hasattr(k, "default") and str(k.default) != "...": + # ``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 + # ``CORE.loaded_integrations`` is always empty, so the + # property never resolves. The unconditional default lives + # on ``_default``; expose it under a *new* per-class field + # (``default_with`` for ``OnlyWith``, ``default_without`` for + # ``OnlyWithout``) that bundles the value with the gating + # component(s). Pure addition to the bundle — old consumers + # that read only ``default`` see these fields as + # default-less (same as today, no regression where they used + # to fall back to a hard-coded UI default); new consumers + # opt-in to the gated fields and apply the default + # *conditionally* on which integrations the user has + # loaded. Without the gate info, an ethernet-only config on + # ``cv.OnlyWith(K, "wifi", default=True)`` would otherwise + # render ``True`` even though ESPHome itself wouldn't apply + # the default for that config. + if isinstance(k, (cv.OnlyWith, cv.OnlyWithout)): + default_value = k._default() + if default_value is not None: + components = ( + list(k._component) + if isinstance(k._component, list) + else [k._component] + ) + gate_field = ( + "default_with" if isinstance(k, cv.OnlyWith) else "default_without" + ) + result[gate_field] = { + "value": str(default_value), + "components": components, + } + elif hasattr(k, "default") and str(k.default) != "...": default_value = k.default() if default_value is not None: result["default"] = str(default_value) From 1e58e8729a0a73117bbfa533f0a498ffb3e228f6 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 6 May 2026 14:53:48 -0400 Subject: [PATCH 1102/2214] [uart] Use `tcdrain` for flushing instead of`tcflush` on host (#14877) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/uart/uart_component_host.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 085610a983..5bb7a49726 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -276,9 +276,12 @@ UARTFlushResult HostUartComponent::flush() { if (this->file_descriptor_ == -1) { return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } - tcflush(this->file_descriptor_, TCIOFLUSH); ESP_LOGV(TAG, " Flushing"); - return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; + if (tcdrain(this->file_descriptor_) == -1) { + this->update_error_(strerror(errno)); + return UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } + return UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } void HostUartComponent::update_error_(const std::string &error) { From d2bbaeccf33359f9b96010aae17736f5224ba659 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 6 May 2026 21:42:11 +1200 Subject: [PATCH 1103/2214] [ha-addon] Add opt-in toggle for the new ESPHome Device Builder (#16247) --- .../etc/cont-init.d/40-device-builder.sh | 22 +++++++++++++++++++ .../etc/s6-overlay/s6-rc.d/esphome/run | 7 ++++++ .../etc/s6-overlay/s6-rc.d/init-nginx/run | 8 +++++++ .../etc/s6-overlay/s6-rc.d/nginx/run | 8 +++++++ 4 files changed, 45 insertions(+) create mode 100755 docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh new file mode 100755 index 0000000000..b990469762 --- /dev/null +++ b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh @@ -0,0 +1,22 @@ +#!/usr/bin/with-contenv bashio +# ============================================================================== +# Installs the latest prerelease of esphome-device-builder when the +# `use_new_device_builder` config option is enabled. +# This is a temporary install-on-boot step until esphome-device-builder +# becomes a direct dependency of esphome. +# ============================================================================== + +if ! bashio::config.true 'use_new_device_builder'; then + exit 0 +fi + +bashio::log.info "Installing latest prerelease of esphome-device-builder..." +if command -v uv > /dev/null; then + uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ + esphome-device-builder || + bashio::exit.nok "Failed installing esphome-device-builder." +else + pip install --no-cache-dir --pre --upgrade esphome-device-builder || + bashio::exit.nok "Failed installing esphome-device-builder." +fi +bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index cdbaff6c04..64ac0b18d2 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -49,5 +49,12 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then rm -rf /config/esphome/.esphome fi +if bashio::config.true 'use_new_device_builder'; then + bashio::log.info "Starting ESPHome Device Builder..." + exec esphome-device-builder /config/esphome \ + --ha-addon \ + --ingress-port "$(bashio::addon.ingress_port)" +fi + bashio::log.info "Starting ESPHome dashboard..." exec esphome dashboard /config/esphome --socket /var/run/esphome.sock --ha-addon diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run index 2725f56670..18c75898ec 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run @@ -4,6 +4,14 @@ # Community Hass.io Add-ons: ESPHome # Configures NGINX for use with ESPHome # ============================================================================== + +# When the new device builder is enabled it serves HA ingress directly, +# so nginx is not used at all -- skip configuration. +if bashio::config.true 'use_new_device_builder'; then + bashio::log.info "Skipping NGINX setup: new device builder serves ingress directly." + bashio::exit.ok +fi + mkdir -p /var/log/nginx # Generate Ingress configuration diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run index e96991cdd1..bb5f52e10c 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -5,6 +5,14 @@ # Runs the NGINX proxy # ============================================================================== +# The new device builder handles HA ingress itself, so nginx is bypassed. +# Block the longrun forever so s6 keeps the dependency satisfied and does +# not respawn it. +if bashio::config.true 'use_new_device_builder'; then + bashio::log.info "NGINX bypassed: new device builder serves ingress directly." + exec sleep infinity +fi + bashio::log.info "Waiting for ESPHome dashboard to come up..." while [[ ! -S /var/run/esphome.sock ]]; do From 016b509b551610d8fefd3fa90f1baf1b387940c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 May 2026 07:32:35 -0500 Subject: [PATCH 1104/2214] [bundle] Include secrets.yaml when `!secret` keys are quoted (#16271) --- esphome/bundle.py | 12 +++++---- tests/unit_tests/test_bundle.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index efa80acc8c..70c4fad0fd 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -98,11 +98,13 @@ _KNOWN_FILE_EXTENSIONS = frozenset( ) -# Matches !secret references in YAML text. This is intentionally a simple -# regex scan rather than a YAML parse — it may match inside comments or -# multi-line strings, which is the conservative direction (include more -# secrets rather than fewer). -_SECRET_RE = re.compile(r"!secret\s+(\S+)") +# Matches !secret references in YAML text. An optional surrounding +# quote pair around the key is allowed and ignored: YAML treats +# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is +# intentionally a simple regex scan rather than a YAML parse — it may +# match inside comments or multi-line strings, which is the conservative +# direction (include more secrets rather than fewer). +_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""") def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 89bf1a33b3..5d046252da 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -170,6 +170,23 @@ def test_find_used_secret_keys_deduplicates(tmp_path: Path) -> None: assert keys == {"key1"} +def test_find_used_secret_keys_quoted(tmp_path: Path) -> None: + """Quoted !secret keys should resolve to the same key as unquoted form. + + YAML strips surrounding quotes during parsing, so the secrets.yaml + lookup uses the unquoted key. The bundle scan must do the same. + """ + yaml1 = tmp_path / "a.yaml" + yaml1.write_text( + "single: !secret 'wifi_ssid'\n" + 'double: !secret "wifi_pw"\n' + "bare: !secret api_key\n" + ) + + keys = _find_used_secret_keys([yaml1]) + assert keys == {"wifi_ssid", "wifi_pw", "api_key"} + + # --------------------------------------------------------------------------- # _add_bytes_to_tar # --------------------------------------------------------------------------- @@ -1217,6 +1234,35 @@ def test_create_bundle_filters_secrets(tmp_path: Path) -> None: assert "should_not_appear" not in secrets_data +def test_create_bundle_filters_secrets_quoted(tmp_path: Path) -> None: + """Bundling must include secrets.yaml when !secret keys are quoted. + + Regression test for issue 16259: quoted !secret references previously + captured the quotes as part of the key, so no key matched secrets.yaml + entries and the secrets file was dropped from the bundle entirely. + """ + config_dir = _setup_config_dir(tmp_path) + + secrets = config_dir / "secrets.yaml" + secrets.write_text("ota_password: hunter2\nunused: should_not_appear\n") + + config_yaml = "ota:\n password: !secret 'ota_password'\n" + (config_dir / "test.yaml").write_text(config_yaml) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is True + + buf = io.BytesIO(result.data) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + secrets_data = tar.extractfile("secrets.yaml").read().decode() + + assert "ota_password" in secrets_data + assert "hunter2" in secrets_data + assert "unused" not in secrets_data + + def test_create_bundle_no_secrets(tmp_path: Path) -> None: _setup_config_dir(tmp_path) From 7f6aef4f33f882936ab2e68008ed6079c7a27508 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 May 2026 08:41:17 -0500 Subject: [PATCH 1105/2214] [substitutions] Fix sibling references inside dict-valued substitutions (#16273) --- esphome/components/substitutions/__init__.py | 13 ++++++++++++- .../18-dict_self_reference.approved.yaml | 16 ++++++++++++++++ .../18-dict_self_reference.input.yaml | 18 ++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/fixtures/substitutions/18-dict_self_reference.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/18-dict_self_reference.input.yaml diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 0144c13c01..af261fe2a3 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -297,7 +297,18 @@ def _push_context( """Resolve a variable, recursively resolving any dependencies it references.""" value = unresolved_vars.pop(key, Missing) if value is Missing: - return Missing + # Either already resolved (in resolved_vars) or currently being + # resolved (self-reference from inside a dict-valued substitution). + # Returning what we have lets sibling references inside a dict + # value, e.g. ``${device.manufacturer}`` inside ``device.name``, + # see literal sibling values during their own resolution. + return resolved_vars.get(key, Missing) + if isinstance(value, dict): + # Dict-valued substitutions form a namespace; eagerly publish the + # original mapping so its members can reference each other while + # the dict's own substitution pass is still running. The entry is + # replaced with the fully-substituted dict once recursion returns. + resolved_vars[key] = value try: value = substitute(value, [], resolver_context, True) except UndefinedError as err: diff --git a/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.approved.yaml b/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.approved.yaml new file mode 100644 index 0000000000..e5e6d4568e --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.approved.yaml @@ -0,0 +1,16 @@ +substitutions: + device: + manufacturer: espressif + model: esp32 + mac_suffix: ffffff + name: espressif-esp32-ffffff + network: + host: example.com + port: 8080 + url: http://example.com:8080/api +esphome: + name: espressif-esp32-ffffff +test_list: + - espressif-esp32-ffffff + - http://example.com:8080/api + - espressif/esp32 diff --git a/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.input.yaml b/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.input.yaml new file mode 100644 index 0000000000..b27c4b8c29 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/18-dict_self_reference.input.yaml @@ -0,0 +1,18 @@ +substitutions: + device: + manufacturer: "espressif" + model: "esp32" + mac_suffix: "ffffff" + name: ${device.manufacturer}-${device.model}-${device.mac_suffix} + network: + host: "example.com" + port: 8080 + url: "http://${network.host}:${network.port}/api" + +esphome: + name: ${device.name} + +test_list: + - ${device.name} + - ${network.url} + - "${device.manufacturer}/${device.model}" From b89c71c1eac2c99412540da1a3e9c1117275c093 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Wed, 6 May 2026 14:56:33 +0000 Subject: [PATCH 1106/2214] [core] Fix WiFi connection in safe mode (#16269) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/core/config.py | 27 ++--- tests/integration/conftest.py | 111 +++++++++--------- .../fixtures/safe_mode_loop_runs.yaml | 25 ++++ tests/integration/host_prefs.py | 39 ++++++ tests/integration/test_safe_mode_loop_runs.py | 94 +++++++++++++++ tests/integration/test_template_text_save.py | 15 +-- tests/unit_tests/core/test_config.py | 70 +++++++++++ 7 files changed, 299 insertions(+), 82 deletions(-) create mode 100644 tests/integration/fixtures/safe_mode_loop_runs.yaml create mode 100644 tests/integration/host_prefs.py create mode 100644 tests/integration/test_safe_mode_loop_runs.py diff --git a/esphome/core/config.py b/esphome/core/config.py index bf210876df..70c28a0368 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -562,14 +562,9 @@ async def _add_controller_registry_define() -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _add_looping_components() -> None: - # Emit a constexpr that computes the looping component count at C++ compile time - # and pre-init the FixedVector with the exact capacity. Uses std::is_same_v to - # detect loop() overrides. The constexpr goes in main.cpp's global section where - # all component types are in scope. calculate_looping_components_() then skips - # the counting pass and only does the two population passes. + # Emit ESPHOME_LOOPING_COMPONENT_COUNT. Sizing of looping_components_ + # happens in core to_code() so it lands before safe_mode's early return. entries = CORE.data.get("looping_component_entries", []) - if not entries: - return # Build constexpr sum for the exact count, deduplicating by type # Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance @@ -577,7 +572,7 @@ async def _add_looping_components() -> None: terms = [ f"({count} * HasLoopOverride<{cpp_type}>::value)" for cpp_type, count in type_counts.items() - ] + ] or ["0"] constexpr_expr = " + \\\n ".join(terms) cg.add_global( cg.RawStatement( @@ -586,14 +581,6 @@ async def _add_looping_components() -> None: ) ) - # Pre-init FixedVector with exact capacity so calculate_looping_components_() - # can skip the counting pass - cg.add( - cg.RawExpression( - "App.looping_components_.init(ESPHOME_LOOPING_COMPONENT_COUNT)" - ) - ) - @coroutine_with_priority(CoroPriority.CORE) async def to_code(config: ConfigType) -> None: @@ -636,6 +623,14 @@ async def to_code(config: ConfigType) -> None: # Define component count for static allocation cg.add_define("ESPHOME_COMPONENT_COUNT", len(CORE.component_ids)) + # Pre-init FixedVector with exact capacity so calculate_looping_components_() + # can skip the counting pass + cg.add( + cg.RawExpression( + "App.looping_components_.init(ESPHOME_LOOPING_COMPONENT_COUNT)" + ) + ) + CORE.add_job(_add_platform_defines) CORE.add_job(_add_controller_registry_define) CORE.add_job(_add_looping_components) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7c85bf753c..f36543b7cd 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -501,14 +501,15 @@ async def _read_stream_lines( @asynccontextmanager -async def run_binary_and_wait_for_port( +async def run_binary( binary_path: Path, - host: str, - port: int, - timeout: float = PORT_WAIT_TIMEOUT, line_callback: Callable[[str], None] | None = None, -) -> AsyncGenerator[None]: - """Run a binary, wait for it to open a port, and clean up on exit.""" +) -> AsyncGenerator[tuple[asyncio.subprocess.Process, list[str]]]: + """Run a binary under a PTY, capture log output, and clean up on exit. + + Yields the running ``Process`` and a live list of captured log lines. + No port wait -- callers that need that should use + ``run_binary_and_wait_for_port``.""" # Create a pseudo-terminal to make the binary think it's running interactively # This is needed because the ESPHome host logger checks isatty() controller_fd, device_fd = pty.openpty() @@ -535,7 +536,6 @@ async def run_binary_and_wait_for_port( controller_transport, _ = await loop.connect_read_pipe( lambda: controller_protocol, os.fdopen(controller_fd, "rb", 0) ) - output_reader = controller_reader if process.returncode is not None: raise RuntimeError( @@ -543,27 +543,59 @@ async def run_binary_and_wait_for_port( "Ensure the binary is valid and can run successfully." ) - # Wait for the API server to start listening - loop = asyncio.get_running_loop() - start_time = loop.time() - - # Start collecting output stdout_lines: list[str] = [] - output_tasks: list[asyncio.Task] = [] + output_task = asyncio.create_task( + _read_stream_lines(controller_reader, stdout_lines, sys.stdout, line_callback) + ) try: - # Read from output stream - output_tasks = [ - asyncio.create_task( - _read_stream_lines( - output_reader, stdout_lines, sys.stdout, line_callback - ) - ) - ] - # Small yield to ensure the process has a chance to start await asyncio.sleep(0) + yield process, stdout_lines + finally: + output_task.cancel() + result = await asyncio.gather(output_task, return_exceptions=True) + if isinstance(result[0], Exception) and not isinstance( + result[0], asyncio.CancelledError + ): + print(f"Error reading from PTY: {result[0]}", file=sys.stderr) + # Close the PTY transport (Unix only) + if controller_transport is not None: + controller_transport.close() + + # Cleanup: terminate the process gracefully + if process.returncode is None: + # Send SIGINT (Ctrl+C) for graceful shutdown + process.send_signal(signal.SIGINT) + try: + await asyncio.wait_for(process.wait(), timeout=SIGINT_TIMEOUT) + except TimeoutError: + # If SIGINT didn't work, try SIGTERM + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=SIGTERM_TIMEOUT) + except TimeoutError: + # Last resort: SIGKILL + process.kill() + await process.wait() + + +@asynccontextmanager +async def run_binary_and_wait_for_port( + binary_path: Path, + host: str, + port: int, + timeout: float = PORT_WAIT_TIMEOUT, + line_callback: Callable[[str], None] | None = None, +) -> AsyncGenerator[None]: + """Run a binary, wait for it to open a port, and clean up on exit.""" + async with run_binary(binary_path, line_callback=line_callback) as ( + process, + stdout_lines, + ): + loop = asyncio.get_running_loop() + start_time = loop.time() while loop.time() - start_time < timeout: try: # Try to connect to the port @@ -593,41 +625,6 @@ async def run_binary_and_wait_for_port( raise TimeoutError(error_msg) - finally: - # Cancel output collection tasks - for task in output_tasks: - task.cancel() - # Wait for tasks to complete and check for exceptions - results = await asyncio.gather(*output_tasks, return_exceptions=True) - for i, result in enumerate(results): - if isinstance(result, Exception) and not isinstance( - result, asyncio.CancelledError - ): - print( - f"Error reading from PTY: {result}", - file=sys.stderr, - ) - - # Close the PTY transport (Unix only) - if controller_transport is not None: - controller_transport.close() - - # Cleanup: terminate the process gracefully - if process.returncode is None: - # Send SIGINT (Ctrl+C) for graceful shutdown - process.send_signal(signal.SIGINT) - try: - await asyncio.wait_for(process.wait(), timeout=SIGINT_TIMEOUT) - except TimeoutError: - # If SIGINT didn't work, try SIGTERM - process.terminate() - try: - await asyncio.wait_for(process.wait(), timeout=SIGTERM_TIMEOUT) - except TimeoutError: - # Last resort: SIGKILL - process.kill() - await process.wait() - @asynccontextmanager async def run_compiled_context( diff --git a/tests/integration/fixtures/safe_mode_loop_runs.yaml b/tests/integration/fixtures/safe_mode_loop_runs.yaml new file mode 100644 index 0000000000..342622428b --- /dev/null +++ b/tests/integration/fixtures/safe_mode_loop_runs.yaml @@ -0,0 +1,25 @@ +esphome: + name: safe-mode-loop-runs + +host: + +logger: + +safe_mode: + num_attempts: 10 + on_safe_mode: + - lambda: |- + // Spawn a detached thread that logs a unique marker. The + // non-main-thread log goes through the task log buffer, which + // is only drained by Logger::loop(). If looping components + // weren't initialized (the bug fixed in #16269), the buffer is + // never read and the marker never reaches the console. + struct MarkerThread { + static void *thread_func(void *) { + ESP_LOGI("safe_mode_test", "looping component ran in safe mode"); + return nullptr; + } + }; + pthread_t t; + pthread_create(&t, nullptr, MarkerThread::thread_func, nullptr); + pthread_detach(t); diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py new file mode 100644 index 0000000000..f835bee3bc --- /dev/null +++ b/tests/integration/host_prefs.py @@ -0,0 +1,39 @@ +"""Helpers for manipulating the host platform's preferences file. + +ESPHome's host platform stores preferences in +``~/.esphome/prefs/.prefs`` using a simple binary layout that +mirrors ``HostPreferences::sync()``: +``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry. + +Tests use these helpers to pre-populate state the binary will see at +boot (e.g. forcing safe mode) or to clear stale state between runs. +""" + +from __future__ import annotations + +from pathlib import Path +import struct + + +def host_prefs_path(device_name: str) -> Path: + """Return the on-disk prefs file path for a host-platform device.""" + return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs" + + +def clear_host_prefs(device_name: str) -> None: + """Delete the prefs file for a host-platform device, if it exists.""" + host_prefs_path(device_name).unlink(missing_ok=True) + + +def write_host_pref(device_name: str, key: int, data: bytes) -> 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(" None: + """When safe mode is active, ``App.loop()`` must still iterate looping + components -- proven here by a thread-logged marker reaching the + console (which requires ``Logger::loop()`` to run).""" + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # Compile finished successfully; pre-populate prefs so the *next* run + # enters safe mode immediately. + write_host_pref( + DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack(" None: + if not safe_mode_active.done() and safe_mode_pattern.search(line): + safe_mode_active.set_result(True) + if not thread_log_seen.done() and thread_log_pattern.search(line): + thread_log_seen.set_result(True) + + async with run_binary(binary_path, line_callback=on_log): + try: + await asyncio.wait_for(safe_mode_active, timeout=15.0) + except TimeoutError: + pytest.fail( + "Did not observe 'SAFE MODE IS ACTIVE' -- safe mode " + "didn't trigger, so this test isn't exercising the bug." + ) + try: + await asyncio.wait_for(thread_log_seen, timeout=10.0) + except TimeoutError: + pytest.fail( + f"Did not observe thread-logged marker {THREAD_LOG_MARKER!r} " + "within timeout. Logger::loop() never drained the task " + "log buffer, meaning App.looping_components_ was never " + "sized -- this is the regression #16269 fixed." + ) + finally: + clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_template_text_save.py b/tests/integration/test_template_text_save.py index 47c8e3188a..7e56209c50 100644 --- a/tests/integration/test_template_text_save.py +++ b/tests/integration/test_template_text_save.py @@ -9,7 +9,6 @@ Tests that: from __future__ import annotations import asyncio -from pathlib import Path import socket from typing import Any @@ -17,9 +16,12 @@ from aioesphomeapi import TextInfo, TextState import pytest from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs from .state_utils import InitialStateHelper, require_entity from .types import CompileFunction, ConfigWriter +DEVICE_NAME = "host-template-text-save-test" + @pytest.mark.asyncio async def test_template_text_save( @@ -32,11 +34,7 @@ async def test_template_text_save( port, port_socket = reserved_tcp_port # Clean up any stale preference file from previous runs - prefs_file = ( - Path.home() / ".esphome" / "prefs" / "host-template-text-save-test.prefs" - ) - if prefs_file.exists(): - prefs_file.unlink() + clear_host_prefs(DEVICE_NAME) # Write and compile once config_path = await write_yaml_config(yaml_config) @@ -59,7 +57,7 @@ async def test_template_text_save( wait_and_connect_api_client(port=port) as client, ): device_info = await client.device_info() - assert device_info.name == "host-template-text-save-test" + assert device_info.name == DEVICE_NAME entities, _ = await client.list_entities_services() text_entity = require_entity( @@ -127,5 +125,4 @@ async def test_template_text_save( ) # Clean up preference file - if prefs_file.exists(): - prefs_file.unlink() + clear_host_prefs(DEVICE_NAME) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 6fa8f7ed43..4ce862315d 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -10,6 +10,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config_validation as cv, core +from esphome.components.safe_mode import to_code as safe_mode_to_code from esphome.const import ( CONF_AREA, CONF_AREAS, @@ -312,6 +313,75 @@ def test_add_platform_defines_priority() -> None: ) +def test_to_code_priority_above_safe_mode() -> None: + """Test that core to_code emits the looping_components_ init before safe_mode. + + Regression test for https://github.com/esphome/esphome/issues/16262. + safe_mode emits an `if (should_enter_safe_mode(...)) return;` line in main() + at APPLICATION priority. The `App.looping_components_.init(...)` call must be + emitted at a higher priority than APPLICATION so it lands in main() before + the early return; otherwise the FixedVector is never sized when safe mode is + active and loop() never runs (Wi-Fi never connects). + """ + assert config.to_code.priority > safe_mode_to_code.priority, ( + f"core to_code priority ({config.to_code.priority}) must be greater than " + f"safe_mode to_code priority ({safe_mode_to_code.priority}) so that " + "App.looping_components_.init() is emitted before safe_mode's early return" + ) + + +@pytest.mark.asyncio +async def test_add_looping_components_handles_empty_entries() -> None: + """Test that _add_looping_components emits a valid constexpr when there are + no looping component entries. + + With zero entries the generated constexpr must still be syntactically valid + C++ (`= 0;`), not an empty expression (`= ;`). This guards the empty-list + case that would otherwise produce uncompilable main.cpp output. + """ + CORE.data["looping_component_entries"] = [] + + await config._add_looping_components() + + constexpr_lines = [ + str(s) + for s in CORE.global_statements + if "ESPHOME_LOOPING_COMPONENT_COUNT" in str(s) + ] + assert len(constexpr_lines) == 1 + text = constexpr_lines[0] + assert "static constexpr size_t ESPHOME_LOOPING_COMPONENT_COUNT" in text + # The right-hand side must contain a literal `0`, not be empty. + rhs = text.split("=", 1)[1] + assert "0" in rhs + assert rhs.strip().rstrip(";").strip(), ( + f"constexpr right-hand side must not be empty, got: {text!r}" + ) + + +@pytest.mark.asyncio +async def test_add_looping_components_with_entries() -> None: + """Test that _add_looping_components builds a HasLoopOverride sum from entries.""" + CORE.data["looping_component_entries"] = [ + "esphome::wifi::WiFiComponent", + "esphome::logger::Logger", + "esphome::wifi::WiFiComponent", + ] + + await config._add_looping_components() + + constexpr_lines = [ + str(s) + for s in CORE.global_statements + if "ESPHOME_LOOPING_COMPONENT_COUNT" in str(s) + ] + assert len(constexpr_lines) == 1 + text = constexpr_lines[0] + # Deduplicated by type, with per-type counts as multiplier. + assert "(2 * HasLoopOverride::value)" in text + assert "(1 * HasLoopOverride::value)" in text + + def test_valid_include_with_angle_brackets() -> None: """Test valid_include accepts angle bracket includes.""" assert valid_include("") == "" From d9835c8705492923dfaa7bbb0413066b119a101b Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 6 May 2026 19:32:50 +0200 Subject: [PATCH 1107/2214] [nextion] Fix text sensor state not updated on string response (#16280) --- esphome/components/nextion/nextion.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index e42f7ca216..b644cad507 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -641,6 +641,7 @@ void Nextion::process_nextion_commands_() { } else { ESP_LOGN(TAG, "String resp: '%s' id: %s type: %s", to_process.c_str(), component->get_variable_name().c_str(), component->get_queue_type_string()); + component->set_state_from_string(to_process, true, false); } delete nb; // NOLINT(cppcoreguidelines-owning-memory) From 5283cdec124507957ac6d096d2102e4b86bb465a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 7 May 2026 07:25:35 +1200 Subject: [PATCH 1108/2214] Bump version to 2026.4.5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 2e86f54e96..98237cc228 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.4.4 +PROJECT_NUMBER = 2026.4.5 # 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 bc31ab36b5..2513a56635 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.4" +__version__ = "2026.4.5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 3b6250bceecfa0a35388350f3483d3c2eee698d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 15:23:58 -0500 Subject: [PATCH 1109/2214] Bump CodSpeedHQ/action from 4.15.0 to 4.15.1 (#16281) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87058e4fa5..f43c46dd00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -415,7 +415,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@c381be0bfd20e844fb45594f6aa182ffcd94545c # v4.15.0 + uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1 with: run: ${{ steps.build.outputs.binary }} mode: simulation From 004aa4913189ddc9ce1211d4ca5337df22d5b1fd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 7 May 2026 06:57:53 +1000 Subject: [PATCH 1110/2214] [lvgl] Pass touch point to touch event lambdas (#16272) --- esphome/components/lvgl/defines.py | 8 ++++++++ esphome/components/lvgl/lvgl_esphome.cpp | 16 +++++++++++++++- esphome/components/lvgl/lvgl_esphome.h | 19 +++++++++++++------ esphome/components/lvgl/schemas.py | 13 +++++++++++-- esphome/components/lvgl/trigger.py | 17 +++++++++++------ esphome/components/lvgl/types.py | 2 ++ esphome/components/lvgl/widgets/__init__.py | 2 +- esphome/components/lvgl/widgets/canvas.py | 3 +-- esphome/components/lvgl/widgets/line.py | 4 ---- tests/components/lvgl/lvgl-package.yaml | 12 ++++++++++-- 10 files changed, 72 insertions(+), 24 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index ef29a99ddd..03bbaf8ddb 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -309,6 +309,14 @@ LV_EVENT_MAP = { "STYLE_CHANGE": "STYLE_CHANGED", "TRIPLE_CLICK": "TRIPLE_CLICKED", } + +LV_PRESS_EVENTS = ("PRESS", "PRESSING", "RELEASE") + + +def is_press_event(event: str) -> bool: + return event.removeprefix("on_").upper() in LV_PRESS_EVENTS + + LV_SCREEN_EVENT_MAP = { "SCREEN_LOAD": "SCREEN_LOADED", "SCREEN_LOAD_START": "SCREEN_LOAD_START", diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index eb85faa16c..3141c5f93c 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -890,7 +890,21 @@ lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos) { int32_t offset = pos - stop1->frac; return lv_color_mix(stop2->color, stop1->color, range == 0 ? 0 : (offset * 255) / range); } -#endif +#endif // USE_LVGL_GRADIENT + +lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { + auto *indev = lv_indev_get_act(); + if (indev == nullptr) { + return {INT32_MAX, INT32_MAX}; + } + lv_point_t point; + lv_indev_get_point(indev, &point); + lv_area_t coords; + lv_obj_get_coords(obj, &coords); + point.x -= coords.x1; + point.y -= coords.y1; + return point; +} static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { LV_TRACE_OBJ_CREATE("begin"); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index be1f150aff..32bf3ccac6 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -6,7 +6,7 @@ #endif // USE_BINARY_SENSOR #ifdef USE_IMAGE #include "esphome/components/image/image.h" -#endif // USE_LVGL_IMAGE +#endif // USE_IMAGE #ifdef USE_LVGL_ROTARY_ENCODER #include "esphome/components/rotary_encoder/rotary_encoder.h" #endif // USE_LVGL_ROTARY_ENCODER @@ -32,10 +32,10 @@ #ifdef USE_FONT #include "esphome/components/font/font.h" -#endif // USE_LVGL_FONT +#endif // USE_FONT #ifdef USE_TOUCHSCREEN #include "esphome/components/touchscreen/touchscreen.h" -#endif // USE_LVGL_TOUCHSCREEN +#endif // USE_TOUCHSCREEN #if defined(USE_LVGL_BUTTONMATRIX) || defined(USE_LVGL_KEYBOARD) #include "esphome/components/key_provider/key_provider.h" @@ -124,7 +124,8 @@ int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); */ lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos); -#endif +#endif // USE_LVGL_GRADIENT + // Parent class for things that wrap an LVGL object class LvCompound { public: @@ -169,9 +170,9 @@ template class ObjUpdateAction : public Action { public: explicit ObjUpdateAction(std::function &&lamb) : lamb_(std::move(lamb)) {} + protected: void play(const Ts &...x) override { this->lamb_(x...); } - protected: std::function lamb_; }; #ifdef USE_LVGL_ANIMIMG @@ -190,6 +191,12 @@ class LvglComponent : public PollingComponent { LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, int draw_rounding, bool resume_on_input, bool update_when_display_idle, RotationType rotation_type); static void static_flush_cb(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p); + /** + * + * @param obj A widget + * @return The position of the last indev point relative to the widget's origin. + */ + static lv_point_t get_touch_relative_to_obj(lv_obj_t *obj); float get_setup_priority() const override { return setup_priority::PROCESSOR; } void setup() override; @@ -311,9 +318,9 @@ class IdleTrigger : public Trigger<> { template class LvglAction : public Action, public Parented { public: explicit LvglAction(std::function &&lamb) : action_(std::move(lamb)) {} - void play(const Ts &...x) override { this->action_(this->parent_); } protected: + void play(const Ts &...x) override { this->action_(this->parent_); } std::function action_{}; }; diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 62117fbd32..a9427a9852 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -31,6 +31,7 @@ from .defines import ( CONF_TIME_FORMAT, LV_GRAD_DIR, get_remapped_uses, + is_press_event, ) from .helpers import CONF_IF_NAN, requires_component, validate_printf from .layout import ( @@ -46,6 +47,7 @@ from .types import ( LvType, lv_group_t, lv_obj_t, + lv_point_t, lv_pseudo_button_t, lv_style_t, ) @@ -370,13 +372,20 @@ def automation_schema(typ: LvType): if typ.has_on_value: events = events + (CONF_ON_VALUE,) args = typ.get_arg_type() - args.append(lv_event_t_ptr) + + def get_trigger_args(event): + result = args.copy() + if is_press_event(event): + result.append(lv_point_t) + result.append(lv_event_t_ptr) + return result + return { **{ cv.Optional(event): validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Trigger.template(*args) + Trigger.template(*get_trigger_args(event)) ), } ) diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index f825999e8a..b3d12ed183 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -24,6 +24,7 @@ from .defines import ( LV_SCREEN_EVENT_MAP, LV_SCREEN_EVENT_TRIGGERS, SWIPE_TRIGGERS, + is_press_event, literal, ) from .lvcode import ( @@ -34,11 +35,10 @@ from .lvcode import ( LvConditional, lv, lv_add, - lv_event_t_ptr, lv_expr, lvgl_static, ) -from .types import LV_EVENT +from .types import LV_EVENT, lv_point_t from .widgets import LvScrActType, get_screen_active, widget_map @@ -133,19 +133,24 @@ def _get_event_literal(trigger: str | MockObj) -> MockObj: return literal("LV_EVENT_" + TRIGGER_MAP[trigger.upper()]) -async def add_trigger(conf, w, *events, is_selected=None): +async def add_trigger(conf, w, *events: str | MockObj, is_selected=None): is_selected = is_selected or w.is_selected() tid = conf[CONF_TRIGGER_ID] trigger = cg.new_Pvariable(tid) - args = w.get_args() + [(lv_event_t_ptr, "event")] - value = w.get_values() + args = w.get_args() + value: list = w.get_values() + if len(events) == 1 and is_press_event(str(events[0])): + # Make the touch point available for selected events + args.append((lv_point_t, "point")) + value.append(lvgl_static.get_touch_relative_to_obj(w.obj)) + args.extend(EVENT_ARG) await automation.build_automation(trigger, args, conf) async with LambdaContext(EVENT_ARG, where=tid) as context: with LvConditional(is_selected): lv_add(trigger.trigger(*value, literal("event"))) callback = await context.get_lambda() event_literals = [_get_event_literal(event) for event in events] - if isinstance(events[0], str) and events[0] in DISPLAY_TRIGGERS: + if str(events[0]) in DISPLAY_TRIGGERS: assert len(events) == 1 lv.display_add_event_cb( lv_expr.obj_get_display(w.obj), callback, event_literals[0], nullptr diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 0c8ddfbfbd..1872ce2d32 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -70,6 +70,8 @@ lv_image_t = LvType("lv_image_t") lv_gradient_t = LvType("lv_grad_dsc_t") lv_event_t = LvType("lv_event_t") RotationType = lvgl_ns.enum("RotationType") +lv_point_t = cg.global_ns.struct("lv_point_t") +lv_point_precise_t = cg.global_ns.struct("lv_point_precise_t") LV_EVENT = MockObj(base="LV_EVENT_", op="") LV_STATE = MockObj(base="LV_STATE_", op="") diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 0ac4062106..d35f84c4f2 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -366,7 +366,7 @@ class Widget: def get_args(self): if isinstance(self.type.w_type, LvType): - return self.type.w_type.args + return self.type.w_type.args.copy() return [(lv_obj_t_ptr, "obj")] def get_value(self): diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 1308b82dcd..4427a3b00e 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -57,10 +57,9 @@ from ..lv_validation import ( ) from ..lvcode import LocalVariable, lv, lv_assign, lv_expr from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property -from ..types import LvType, ObjUpdateAction +from ..types import LvType, ObjUpdateAction, lv_point_precise_t from . import Widget, WidgetType, get_widgets from .img import CONF_IMAGE -from .line import lv_point_precise_t CONF_CANVAS = "canvas" CONF_BUFFER_ID = "buffer_id" diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index 19f421cbbd..9d6aa7b4ad 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -1,4 +1,3 @@ -import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_X, CONF_Y @@ -13,9 +12,6 @@ CONF_LINE = "line" CONF_POINTS = "points" CONF_POINT_LIST_ID = "point_list_id" -lv_point_t = cg.global_ns.struct("lv_point_t") -lv_point_precise_t = cg.global_ns.struct("lv_point_precise_t") - class LineType(WidgetType): def __init__(self): diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 39d7472054..4bf5b9d494 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -649,11 +649,15 @@ lvgl: on_scroll_begin: logger.log: Button clicked on_release: - logger.log: Button clicked + logger.log: + format: Button released at %d/%d + args: [point.x, point.y] on_long_press_repeat: logger.log: Button clicked on_pressing: - logger.log: Button pressing + logger.log: + format: Button pressing at %d/%d + args: [point.x, point.y] on_press_lost: logger.log: Button press lost on_single_click: @@ -925,6 +929,10 @@ lvgl: value: !lambda |- static float yyy = 83.0; return yyy + .8; + on_release: + logger.log: + format: Slider released at %d/%d with value %.0f + args: [point.x, point.y, x] - button: styles: spin_button id: spin_up From 9301f76482de58997712cefbe4391d5b9bca74ac Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 7 May 2026 06:59:22 +1000 Subject: [PATCH 1111/2214] [sensor] Add alternate calibration format for ntc (#15937) --- esphome/components/const/__init__.py | 5 ++- esphome/components/lc709203f/sensor.py | 3 +- esphome/components/ntc/sensor.py | 2 +- esphome/components/sensor/__init__.py | 49 ++++++++++++++++++---- tests/components/template/common-base.yaml | 7 +++- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 846d3fd883..6f418b48ea 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -2,11 +2,12 @@ CODEOWNERS = ["@esphome/core"] -CONF_BYTE_ORDER = "byte_order" -CONF_CLIMATE_ID = "climate_id" BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" +CONF_B_CONSTANT = "b_constant" +CONF_BYTE_ORDER = "byte_order" +CONF_CLIMATE_ID = "climate_id" CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 75ae703638..d4e6213425 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_B_CONSTANT import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -22,8 +23,6 @@ DEPENDENCIES = ["i2c"] lc709203f_ns = cg.esphome_ns.namespace("lc709203f") -CONF_B_CONSTANT = "b_constant" - LC709203FBatteryVoltage = lc709203f_ns.enum("LC709203FBatteryVoltage") BATTERY_VOLTAGE_OPTIONS = { "3.7": LC709203FBatteryVoltage.LC709203F_BATTERY_VOLTAGE_3_7, diff --git a/esphome/components/ntc/sensor.py b/esphome/components/ntc/sensor.py index d47052cac6..dd7d1bd35d 100644 --- a/esphome/components/ntc/sensor.py +++ b/esphome/components/ntc/sensor.py @@ -2,6 +2,7 @@ from math import log import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_B_CONSTANT import esphome.config_validation as cv from esphome.const import ( CONF_CALIBRATION, @@ -18,7 +19,6 @@ from esphome.const import ( ntc_ns = cg.esphome_ns.namespace("ntc") NTC = ntc_ns.class_("NTC", cg.Component, sensor.Sensor) -CONF_B_CONSTANT = "b_constant" CONF_A = "a" CONF_B = "b" CONF_C = "c" diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index ed02cc2543..f076c7f17b 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -4,6 +4,7 @@ import math 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 import esphome.config_validation as cv from esphome.const import ( CONF_ABOVE, @@ -32,6 +33,8 @@ from esphome.const import ( CONF_OPTIMISTIC, CONF_PERIOD, CONF_QUANTILE, + CONF_REFERENCE_RESISTANCE, + CONF_REFERENCE_TEMPERATURE, CONF_SEND_EVERY, CONF_SEND_FIRST_AT, CONF_STATE_CLASS, @@ -1078,16 +1081,44 @@ def ntc_get_abc(value): return a, b, c +def ntc_calc_b_constant(value): + beta = value[CONF_B_CONSTANT] + t0 = value[CONF_REFERENCE_TEMPERATURE] + ZERO_POINT + r0 = value[CONF_REFERENCE_RESISTANCE] + + a = (1 / t0) - (1 / beta) * math.log(r0) + b = 1 / beta + c = 0 + return a, b, c + + def ntc_process_calibration(value): if isinstance(value, dict): - value = cv.Schema( - { - cv.Required(CONF_A): cv.float_, - cv.Required(CONF_B): cv.float_, - cv.Required(CONF_C): cv.float_, - } - )(value) - a, b, c = ntc_get_abc(value) + if CONF_B_CONSTANT in value: + value = cv.Schema( + { + cv.Required(CONF_B_CONSTANT): cv.All( + cv.float_, cv.Range(min=0, min_included=False) + ), + cv.Required(CONF_REFERENCE_TEMPERATURE): cv.All( + cv.temperature, + cv.Range(min=-ZERO_POINT, min_included=False), + ), + cv.Required(CONF_REFERENCE_RESISTANCE): cv.All( + cv.resistance, cv.Range(min=0, min_included=False) + ), + } + )(value) + a, b, c = ntc_calc_b_constant(value) + else: + value = cv.Schema( + { + cv.Required(CONF_A): cv.float_, + cv.Required(CONF_B): cv.float_, + cv.Required(CONF_C): cv.float_, + } + )(value) + a, b, c = ntc_get_abc(value) elif isinstance(value, list): if len(value) != 3: raise cv.Invalid( @@ -1097,7 +1128,7 @@ def ntc_process_calibration(value): a, b, c = ntc_calc_steinhart_hart(value) else: raise cv.Invalid( - f"Calibration parameter accepts either a list for steinhart-hart calibration, or mapping for b-constant calibration, not {type(value)}" + f"Calibration parameter accepts either a list for steinhart-hart calibration, or mapping for b-constant or precomputed (a, b, c) calibration, not {type(value)}" ) _LOGGER.info("Coefficient: a:%s, b:%s, c:%s", a, b, c) return { diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index b97cafd25c..d3985a848b 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -202,6 +202,11 @@ sensor: value: last - timeout: timeout: 1d + - to_ntc_temperature: + calibration: + b_constant: 3950 + reference_temperature: 25.0°C + reference_resistance: 10kOhm - to_ntc_resistance: calibration: - 10.0kOhm -> 25°C @@ -270,8 +275,6 @@ cover: stop_action: - logger.log: stop_action optimistic: true - on_open: - - logger.log: "Cover on_open (deprecated)" on_opened: - logger.log: "Cover fully opened" on_closed: From 71193e2b2cbfffc46f925263eb8b80c61ef875a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 May 2026 13:21:35 -0500 Subject: [PATCH 1112/2214] [helpers] Document write_file's external consumer contract (esphome-device-builder) (#16290) --- esphome/helpers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/helpers.py b/esphome/helpers.py index bb1984e17c..9d341af146 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -465,6 +465,12 @@ def _write_file( def write_file(path: Path, text: str | bytes, private: bool = False) -> None: + """Atomically write text or bytes to path. Wraps OSError as EsphomeError. + + Used by esphome-device-builder for in-place YAML rewrites; the + atomicity (sibling tempfile + shutil.move) and EsphomeError + wrapping are part of the public contract. + """ try: _write_file(path, text, private=private) except OSError as err: From 06bd92c3880c575a366f7b8e6fb77f74fd200937 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 7 May 2026 14:21:39 -0400 Subject: [PATCH 1113/2214] [clang-tidy] Concatenate nested namespaces (1/7: components a-c) (#16294) --- esphome/components/a01nyub/a01nyub.cpp | 6 ++---- esphome/components/a01nyub/a01nyub.h | 6 ++---- esphome/components/a02yyuw/a02yyuw.cpp | 6 ++---- esphome/components/a02yyuw/a02yyuw.h | 6 ++---- esphome/components/a4988/a4988.cpp | 6 ++---- esphome/components/a4988/a4988.h | 6 ++---- esphome/components/adalight/adalight_light_effect.cpp | 6 ++---- esphome/components/adalight/adalight_light_effect.h | 6 ++---- esphome/components/adc/adc_sensor.h | 6 ++---- esphome/components/adc/adc_sensor_common.cpp | 6 ++---- esphome/components/adc/adc_sensor_esp32.cpp | 6 ++---- esphome/components/adc128s102/adc128s102.cpp | 6 ++---- esphome/components/adc128s102/adc128s102.h | 6 ++---- .../components/adc128s102/sensor/adc128s102_sensor.cpp | 6 ++---- esphome/components/adc128s102/sensor/adc128s102_sensor.h | 6 ++---- .../addressable_light/addressable_light_display.cpp | 6 ++---- .../addressable_light/addressable_light_display.h | 6 ++---- esphome/components/ade7880/ade7880.cpp | 6 ++---- esphome/components/ade7880/ade7880.h | 6 ++---- esphome/components/ade7880/ade7880_i2c.cpp | 6 ++---- esphome/components/ade7880/ade7880_registers.h | 6 ++---- esphome/components/ade7953_base/ade7953_base.cpp | 6 ++---- esphome/components/ade7953_base/ade7953_base.h | 6 ++---- esphome/components/ade7953_i2c/ade7953_i2c.cpp | 6 ++---- esphome/components/ade7953_i2c/ade7953_i2c.h | 6 ++---- esphome/components/ade7953_spi/ade7953_spi.cpp | 6 ++---- esphome/components/ade7953_spi/ade7953_spi.h | 6 ++---- esphome/components/ads1115/ads1115.cpp | 6 ++---- esphome/components/ads1115/ads1115.h | 6 ++---- esphome/components/ads1115/sensor/ads1115_sensor.cpp | 6 ++---- esphome/components/ads1115/sensor/ads1115_sensor.h | 6 ++---- esphome/components/ads1118/ads1118.cpp | 6 ++---- esphome/components/ads1118/ads1118.h | 6 ++---- esphome/components/ads1118/sensor/ads1118_sensor.cpp | 6 ++---- esphome/components/ads1118/sensor/ads1118_sensor.h | 6 ++---- esphome/components/ags10/ags10.cpp | 6 ++---- esphome/components/ags10/ags10.h | 6 ++---- esphome/components/aht10/aht10.cpp | 6 ++---- esphome/components/aht10/aht10.h | 6 ++---- esphome/components/aic3204/aic3204.cpp | 6 ++---- esphome/components/aic3204/aic3204.h | 6 ++---- esphome/components/aic3204/automation.h | 6 ++---- esphome/components/airthings_ble/airthings_listener.cpp | 6 ++---- esphome/components/airthings_ble/airthings_listener.h | 6 ++---- .../airthings_wave_base/airthings_wave_base.cpp | 6 ++---- .../components/airthings_wave_base/airthings_wave_base.h | 6 ++---- .../airthings_wave_mini/airthings_wave_mini.cpp | 6 ++---- .../components/airthings_wave_mini/airthings_wave_mini.h | 6 ++---- .../airthings_wave_plus/airthings_wave_plus.cpp | 6 ++---- .../components/airthings_wave_plus/airthings_wave_plus.h | 6 ++---- esphome/components/alpha3/alpha3.cpp | 6 ++---- esphome/components/alpha3/alpha3.h | 6 ++---- esphome/components/am2315c/am2315c.cpp | 6 ++---- esphome/components/am2315c/am2315c.h | 6 ++---- esphome/components/am2320/am2320.cpp | 6 ++---- esphome/components/am2320/am2320.h | 6 ++---- esphome/components/am43/am43_base.cpp | 6 ++---- esphome/components/am43/am43_base.h | 6 ++---- esphome/components/am43/cover/am43_cover.cpp | 6 ++---- esphome/components/am43/cover/am43_cover.h | 6 ++---- esphome/components/am43/sensor/am43_sensor.cpp | 6 ++---- esphome/components/am43/sensor/am43_sensor.h | 6 ++---- .../analog_threshold/analog_threshold_binary_sensor.cpp | 6 ++---- .../analog_threshold/analog_threshold_binary_sensor.h | 6 ++---- esphome/components/animation/animation.cpp | 6 ++---- esphome/components/animation/animation.h | 6 ++---- esphome/components/anova/anova.cpp | 6 ++---- esphome/components/anova/anova.h | 6 ++---- esphome/components/anova/anova_base.cpp | 6 ++---- esphome/components/anova/anova_base.h | 6 ++---- esphome/components/apds9306/apds9306.cpp | 6 ++---- esphome/components/apds9306/apds9306.h | 6 ++---- esphome/components/apds9960/apds9960.cpp | 6 ++---- esphome/components/apds9960/apds9960.h | 6 ++---- esphome/components/as3935/as3935.cpp | 6 ++---- esphome/components/as3935/as3935.h | 6 ++---- esphome/components/as3935_i2c/as3935_i2c.cpp | 6 ++---- esphome/components/as3935_i2c/as3935_i2c.h | 6 ++---- esphome/components/as3935_spi/as3935_spi.cpp | 6 ++---- esphome/components/as3935_spi/as3935_spi.h | 6 ++---- esphome/components/as5600/as5600.cpp | 6 ++---- esphome/components/as5600/as5600.h | 6 ++---- esphome/components/as5600/sensor/as5600_sensor.cpp | 6 ++---- esphome/components/as5600/sensor/as5600_sensor.h | 6 ++---- esphome/components/as7341/as7341.cpp | 6 ++---- esphome/components/as7341/as7341.h | 6 ++---- esphome/components/at581x/at581x.cpp | 6 ++---- esphome/components/at581x/at581x.h | 6 ++---- esphome/components/at581x/automation.h | 6 ++---- esphome/components/at581x/switch/rf_switch.cpp | 6 ++---- esphome/components/at581x/switch/rf_switch.h | 6 ++---- .../components/atc_mithermometer/atc_mithermometer.cpp | 6 ++---- esphome/components/atc_mithermometer/atc_mithermometer.h | 6 ++---- esphome/components/atm90e26/atm90e26.cpp | 6 ++---- esphome/components/atm90e26/atm90e26.h | 6 ++---- esphome/components/atm90e26/atm90e26_reg.h | 6 ++---- esphome/components/atm90e32/atm90e32.cpp | 6 ++---- esphome/components/atm90e32/atm90e32.h | 6 ++---- esphome/components/atm90e32/atm90e32_reg.h | 6 ++---- esphome/components/atm90e32/button/atm90e32_button.cpp | 6 ++---- esphome/components/atm90e32/button/atm90e32_button.h | 6 ++---- esphome/components/atm90e32/number/atm90e32_number.h | 6 ++---- esphome/components/audio/audio.cpp | 6 ++---- esphome/components/audio/audio.h | 6 ++---- esphome/components/audio/audio_decoder.cpp | 6 ++---- esphome/components/audio/audio_decoder.h | 6 ++---- esphome/components/audio/audio_reader.cpp | 6 ++---- esphome/components/audio/audio_reader.h | 6 ++---- esphome/components/audio/audio_resampler.cpp | 6 ++---- esphome/components/audio/audio_resampler.h | 6 ++---- esphome/components/audio/audio_transfer_buffer.cpp | 6 ++---- esphome/components/audio/audio_transfer_buffer.h | 6 ++---- esphome/components/audio_adc/audio_adc.h | 6 ++---- esphome/components/audio_adc/automation.h | 6 ++---- esphome/components/audio_dac/audio_dac.h | 6 ++---- esphome/components/audio_dac/automation.h | 6 ++---- .../axs15231/touchscreen/axs15231_touchscreen.cpp | 6 ++---- .../axs15231/touchscreen/axs15231_touchscreen.h | 6 ++---- esphome/components/b_parasite/b_parasite.cpp | 6 ++---- esphome/components/b_parasite/b_parasite.h | 6 ++---- esphome/components/ballu/ballu.cpp | 6 ++---- esphome/components/ballu/ballu.h | 6 ++---- esphome/components/bang_bang/bang_bang_climate.cpp | 6 ++---- esphome/components/bang_bang/bang_bang_climate.h | 6 ++---- esphome/components/bedjet/bedjet_child.h | 6 ++---- esphome/components/bedjet/bedjet_codec.cpp | 6 ++---- esphome/components/bedjet/bedjet_codec.h | 6 ++---- esphome/components/bedjet/bedjet_const.h | 6 ++---- esphome/components/bedjet/bedjet_hub.cpp | 6 ++---- esphome/components/bedjet/bedjet_hub.h | 6 ++---- esphome/components/bedjet/climate/bedjet_climate.cpp | 6 ++---- esphome/components/bedjet/climate/bedjet_climate.h | 6 ++---- esphome/components/bedjet/fan/bedjet_fan.cpp | 6 ++---- esphome/components/bedjet/fan/bedjet_fan.h | 6 ++---- esphome/components/bedjet/sensor/bedjet_sensor.cpp | 6 ++---- esphome/components/bedjet/sensor/bedjet_sensor.h | 6 ++---- esphome/components/bh1900nux/bh1900nux.cpp | 6 ++---- esphome/components/bh1900nux/bh1900nux.h | 6 ++---- esphome/components/binary/fan/binary_fan.cpp | 6 ++---- esphome/components/binary/fan/binary_fan.h | 6 ++---- esphome/components/binary/light/binary_light_output.h | 6 ++---- .../components/binary_sensor_map/binary_sensor_map.cpp | 6 ++---- esphome/components/binary_sensor_map/binary_sensor_map.h | 6 ++---- esphome/components/bl0906/bl0906.cpp | 6 ++---- esphome/components/bl0906/bl0906.h | 6 ++---- esphome/components/bl0906/constants.h | 6 ++---- esphome/components/bl0939/bl0939.cpp | 6 ++---- esphome/components/bl0939/bl0939.h | 6 ++---- esphome/components/bl0940/bl0940.cpp | 6 ++---- esphome/components/bl0940/bl0940.h | 6 ++---- .../components/bl0940/button/calibration_reset_button.cpp | 6 ++---- .../components/bl0940/button/calibration_reset_button.h | 6 ++---- esphome/components/bl0940/number/calibration_number.cpp | 6 ++---- esphome/components/bl0940/number/calibration_number.h | 6 ++---- esphome/components/bl0942/bl0942.cpp | 6 ++---- esphome/components/bl0942/bl0942.h | 6 ++---- esphome/components/ble_presence/ble_presence_device.cpp | 6 ++---- esphome/components/ble_presence/ble_presence_device.h | 6 ++---- esphome/components/ble_rssi/ble_rssi_sensor.cpp | 6 ++---- esphome/components/ble_rssi/ble_rssi_sensor.h | 6 ++---- esphome/components/ble_scanner/ble_scanner.cpp | 6 ++---- esphome/components/ble_scanner/ble_scanner.h | 6 ++---- esphome/components/bme280_base/bme280_base.cpp | 6 ++---- esphome/components/bme280_base/bme280_base.h | 6 ++---- esphome/components/bme280_i2c/bme280_i2c.cpp | 6 ++---- esphome/components/bme280_i2c/bme280_i2c.h | 6 ++---- esphome/components/bme280_spi/bme280_spi.cpp | 6 ++---- esphome/components/bme280_spi/bme280_spi.h | 6 ++---- esphome/components/bme680/bme680.cpp | 6 ++---- esphome/components/bme680/bme680.h | 6 ++---- esphome/components/bme680_bsec/bme680_bsec.cpp | 6 ++---- esphome/components/bme680_bsec/bme680_bsec.h | 6 ++---- esphome/components/bmi160/bmi160.cpp | 6 ++---- esphome/components/bmi160/bmi160.h | 6 ++---- esphome/components/bmp085/bmp085.cpp | 6 ++---- esphome/components/bmp085/bmp085.h | 6 ++---- esphome/components/bmp280_base/bmp280_base.cpp | 6 ++---- esphome/components/bmp280_base/bmp280_base.h | 6 ++---- esphome/components/bmp280_i2c/bmp280_i2c.cpp | 6 ++---- esphome/components/bmp280_i2c/bmp280_i2c.h | 6 ++---- esphome/components/bmp280_spi/bmp280_spi.cpp | 6 ++---- esphome/components/bmp280_spi/bmp280_spi.h | 6 ++---- esphome/components/bmp3xx_base/bmp3xx_base.cpp | 6 ++---- esphome/components/bmp3xx_base/bmp3xx_base.h | 6 ++---- esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp | 6 ++---- esphome/components/bmp3xx_i2c/bmp3xx_i2c.h | 6 ++---- esphome/components/bmp3xx_spi/bmp3xx_spi.cpp | 6 ++---- esphome/components/bmp3xx_spi/bmp3xx_spi.h | 6 ++---- esphome/components/bp1658cj/bp1658cj.cpp | 6 ++---- esphome/components/bp1658cj/bp1658cj.h | 6 ++---- esphome/components/bp5758d/bp5758d.cpp | 6 ++---- esphome/components/bp5758d/bp5758d.h | 6 ++---- esphome/components/bthome_mithermometer/bthome_ble.cpp | 6 ++---- esphome/components/bthome_mithermometer/bthome_ble.h | 6 ++---- esphome/components/bytebuffer/bytebuffer.h | 6 ++---- esphome/components/camera/camera.cpp | 6 ++---- esphome/components/camera/camera.h | 6 ++---- esphome/components/canbus/canbus.cpp | 6 ++---- esphome/components/canbus/canbus.h | 6 ++---- esphome/components/cap1188/cap1188.cpp | 6 ++---- esphome/components/cap1188/cap1188.h | 6 ++---- esphome/components/captive_portal/captive_portal.cpp | 7 +++---- esphome/components/captive_portal/captive_portal.h | 8 +++----- esphome/components/ccs811/ccs811.cpp | 6 ++---- esphome/components/ccs811/ccs811.h | 6 ++---- esphome/components/cd74hc4067/cd74hc4067.cpp | 6 ++---- esphome/components/cd74hc4067/cd74hc4067.h | 6 ++---- esphome/components/ch422g/ch422g.cpp | 6 ++---- esphome/components/ch422g/ch422g.h | 6 ++---- esphome/components/chsc6x/chsc6x_touchscreen.cpp | 6 ++---- esphome/components/chsc6x/chsc6x_touchscreen.h | 6 ++---- esphome/components/climate_ir/climate_ir.cpp | 6 ++---- esphome/components/climate_ir/climate_ir.h | 6 ++---- esphome/components/climate_ir_lg/climate_ir_lg.cpp | 6 ++---- esphome/components/climate_ir_lg/climate_ir_lg.h | 6 ++---- esphome/components/cm1106/cm1106.cpp | 6 ++---- esphome/components/cm1106/cm1106.h | 6 ++---- esphome/components/color_temperature/ct_light_output.h | 6 ++---- esphome/components/combination/combination.cpp | 6 ++---- esphome/components/combination/combination.h | 6 ++---- esphome/components/coolix/coolix.cpp | 6 ++---- esphome/components/coolix/coolix.h | 6 ++---- .../components/copy/binary_sensor/copy_binary_sensor.cpp | 6 ++---- .../components/copy/binary_sensor/copy_binary_sensor.h | 6 ++---- esphome/components/copy/button/copy_button.cpp | 6 ++---- esphome/components/copy/button/copy_button.h | 6 ++---- esphome/components/copy/cover/copy_cover.cpp | 6 ++---- esphome/components/copy/cover/copy_cover.h | 6 ++---- esphome/components/copy/fan/copy_fan.cpp | 6 ++---- esphome/components/copy/fan/copy_fan.h | 6 ++---- esphome/components/copy/lock/copy_lock.cpp | 6 ++---- esphome/components/copy/lock/copy_lock.h | 6 ++---- esphome/components/copy/number/copy_number.cpp | 6 ++---- esphome/components/copy/number/copy_number.h | 6 ++---- esphome/components/copy/select/copy_select.cpp | 6 ++---- esphome/components/copy/select/copy_select.h | 6 ++---- esphome/components/copy/sensor/copy_sensor.cpp | 6 ++---- esphome/components/copy/sensor/copy_sensor.h | 6 ++---- esphome/components/copy/switch/copy_switch.cpp | 6 ++---- esphome/components/copy/switch/copy_switch.h | 6 ++---- esphome/components/copy/text/copy_text.cpp | 6 ++---- esphome/components/copy/text/copy_text.h | 6 ++---- esphome/components/copy/text_sensor/copy_text_sensor.cpp | 6 ++---- esphome/components/copy/text_sensor/copy_text_sensor.h | 6 ++---- esphome/components/cs5460a/cs5460a.cpp | 6 ++---- esphome/components/cs5460a/cs5460a.h | 6 ++---- esphome/components/cse7761/cse7761.cpp | 6 ++---- esphome/components/cse7761/cse7761.h | 6 ++---- esphome/components/cst226/binary_sensor/cs226_button.h | 6 ++---- esphome/components/cst226/binary_sensor/cstt6_button.cpp | 6 ++---- .../components/cst226/touchscreen/cst226_touchscreen.cpp | 6 ++---- .../components/cst226/touchscreen/cst226_touchscreen.h | 6 ++---- .../components/cst816/touchscreen/cst816_touchscreen.cpp | 6 ++---- .../components/cst816/touchscreen/cst816_touchscreen.h | 6 ++---- esphome/components/ct_clamp/ct_clamp_sensor.cpp | 6 ++---- esphome/components/ct_clamp/ct_clamp_sensor.h | 6 ++---- esphome/components/current_based/current_based_cover.cpp | 6 ++---- esphome/components/current_based/current_based_cover.h | 6 ++---- esphome/components/cwww/cwww_light_output.h | 6 ++---- 259 files changed, 520 insertions(+), 1037 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp index 210c3557b3..344456854b 100644 --- a/esphome/components/a01nyub/a01nyub.cpp +++ b/esphome/components/a01nyub/a01nyub.cpp @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace a01nyub { +namespace esphome::a01nyub { static const char *const TAG = "a01nyub.sensor"; @@ -42,5 +41,4 @@ void A01nyubComponent::check_buffer_() { void A01nyubComponent::dump_config() { LOG_SENSOR("", "A01nyub Sensor", this); } -} // namespace a01nyub -} // namespace esphome +} // namespace esphome::a01nyub diff --git a/esphome/components/a01nyub/a01nyub.h b/esphome/components/a01nyub/a01nyub.h index 6b22e9bcad..5c0d20bd37 100644 --- a/esphome/components/a01nyub/a01nyub.h +++ b/esphome/components/a01nyub/a01nyub.h @@ -6,8 +6,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace a01nyub { +namespace esphome::a01nyub { class A01nyubComponent : public sensor::Sensor, public Component, public uart::UARTDevice { public: @@ -23,5 +22,4 @@ class A01nyubComponent : public sensor::Sensor, public Component, public uart::U std::vector buffer_; }; -} // namespace a01nyub -} // namespace esphome +} // namespace esphome::a01nyub diff --git a/esphome/components/a02yyuw/a02yyuw.cpp b/esphome/components/a02yyuw/a02yyuw.cpp index a2aad0cef1..2832334ef1 100644 --- a/esphome/components/a02yyuw/a02yyuw.cpp +++ b/esphome/components/a02yyuw/a02yyuw.cpp @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace a02yyuw { +namespace esphome::a02yyuw { static const char *const TAG = "a02yyuw.sensor"; @@ -41,5 +40,4 @@ void A02yyuwComponent::check_buffer_() { void A02yyuwComponent::dump_config() { LOG_SENSOR("", "A02yyuw Sensor", this); } -} // namespace a02yyuw -} // namespace esphome +} // namespace esphome::a02yyuw diff --git a/esphome/components/a02yyuw/a02yyuw.h b/esphome/components/a02yyuw/a02yyuw.h index 6ff370fdc3..693bcfd03c 100644 --- a/esphome/components/a02yyuw/a02yyuw.h +++ b/esphome/components/a02yyuw/a02yyuw.h @@ -6,8 +6,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace a02yyuw { +namespace esphome::a02yyuw { class A02yyuwComponent : public sensor::Sensor, public Component, public uart::UARTDevice { public: @@ -23,5 +22,4 @@ class A02yyuwComponent : public sensor::Sensor, public Component, public uart::U std::vector buffer_; }; -} // namespace a02yyuw -} // namespace esphome +} // namespace esphome::a02yyuw diff --git a/esphome/components/a4988/a4988.cpp b/esphome/components/a4988/a4988.cpp index b9efb4ea44..d8fc6752f3 100644 --- a/esphome/components/a4988/a4988.cpp +++ b/esphome/components/a4988/a4988.cpp @@ -1,8 +1,7 @@ #include "a4988.h" #include "esphome/core/log.h" -namespace esphome { -namespace a4988 { +namespace esphome::a4988 { static const char *const TAG = "a4988.stepper"; @@ -51,5 +50,4 @@ void A4988::loop() { this->step_pin_->digital_write(false); } -} // namespace a4988 -} // namespace esphome +} // namespace esphome::a4988 diff --git a/esphome/components/a4988/a4988.h b/esphome/components/a4988/a4988.h index 0fe7891110..04040241c0 100644 --- a/esphome/components/a4988/a4988.h +++ b/esphome/components/a4988/a4988.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/stepper/stepper.h" -namespace esphome { -namespace a4988 { +namespace esphome::a4988 { class A4988 : public stepper::Stepper, public Component { public: @@ -25,5 +24,4 @@ class A4988 : public stepper::Stepper, public Component { HighFrequencyLoopRequester high_freq_; }; -} // namespace a4988 -} // namespace esphome +} // namespace esphome::a4988 diff --git a/esphome/components/adalight/adalight_light_effect.cpp b/esphome/components/adalight/adalight_light_effect.cpp index 06d7e0e897..bf6849acaf 100644 --- a/esphome/components/adalight/adalight_light_effect.cpp +++ b/esphome/components/adalight/adalight_light_effect.cpp @@ -1,8 +1,7 @@ #include "adalight_light_effect.h" #include "esphome/core/log.h" -namespace esphome { -namespace adalight { +namespace esphome::adalight { static const char *const TAG = "adalight_light_effect"; @@ -138,5 +137,4 @@ AdalightLightEffect::Frame AdalightLightEffect::parse_frame_(light::AddressableL return CONSUMED; } -} // namespace adalight -} // namespace esphome +} // namespace esphome::adalight diff --git a/esphome/components/adalight/adalight_light_effect.h b/esphome/components/adalight/adalight_light_effect.h index bb7319c99c..c30e846778 100644 --- a/esphome/components/adalight/adalight_light_effect.h +++ b/esphome/components/adalight/adalight_light_effect.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace adalight { +namespace esphome::adalight { class AdalightLightEffect : public light::AddressableLightEffect, public uart::UARTDevice { public: @@ -35,5 +34,4 @@ class AdalightLightEffect : public light::AddressableLightEffect, public uart::U std::vector frame_; }; -} // namespace adalight -} // namespace esphome +} // namespace esphome::adalight diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index cf48ccd9c3..676940eca1 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -17,8 +17,7 @@ #include #endif -namespace esphome { -namespace adc { +namespace esphome::adc { #ifdef USE_ESP32 // clang-format off @@ -162,5 +161,4 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage #endif }; -} // namespace adc -} // namespace esphome +} // namespace esphome::adc diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index c779fd5893..16c86aee18 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -1,8 +1,7 @@ #include "adc_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace adc { +namespace esphome::adc { static const char *const TAG = "adc.common"; @@ -79,5 +78,4 @@ void ADCSensor::set_sample_count(uint8_t sample_count) { void ADCSensor::set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } -} // namespace adc -} // namespace esphome +} // namespace esphome::adc diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index fc707013a8..a761b37749 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace adc { +namespace esphome::adc { static const char *const TAG = "adc.esp32"; @@ -364,7 +363,6 @@ float ADCSensor::sample_autorange_() { return final_result; } -} // namespace adc -} // namespace esphome +} // namespace esphome::adc #endif // USE_ESP32 diff --git a/esphome/components/adc128s102/adc128s102.cpp b/esphome/components/adc128s102/adc128s102.cpp index 935dbde8ea..ef0db4730a 100644 --- a/esphome/components/adc128s102/adc128s102.cpp +++ b/esphome/components/adc128s102/adc128s102.cpp @@ -1,8 +1,7 @@ #include "adc128s102.h" #include "esphome/core/log.h" -namespace esphome { -namespace adc128s102 { +namespace esphome::adc128s102 { static const char *const TAG = "adc128s102"; @@ -28,5 +27,4 @@ uint16_t ADC128S102::read_data(uint8_t channel) { return digital_value; } -} // namespace adc128s102 -} // namespace esphome +} // namespace esphome::adc128s102 diff --git a/esphome/components/adc128s102/adc128s102.h b/esphome/components/adc128s102/adc128s102.h index bd6b7f7af1..f04ed87b2a 100644 --- a/esphome/components/adc128s102/adc128s102.h +++ b/esphome/components/adc128s102/adc128s102.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace adc128s102 { +namespace esphome::adc128s102 { class ADC128S102 : public Component, public spi::SPIDeviceparent_->read_data(this->channel_); } void ADC128S102Sensor::update() { this->publish_state(this->sample()); } -} // namespace adc128s102 -} // namespace esphome +} // namespace esphome::adc128s102 diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.h b/esphome/components/adc128s102/sensor/adc128s102_sensor.h index 5e6fc74e9c..c840102380 100644 --- a/esphome/components/adc128s102/sensor/adc128s102_sensor.h +++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.h @@ -7,8 +7,7 @@ #include "../adc128s102.h" -namespace esphome { -namespace adc128s102 { +namespace esphome::adc128s102 { class ADC128S102Sensor : public PollingComponent, public Parented, @@ -24,5 +23,4 @@ class ADC128S102Sensor : public PollingComponent, protected: uint8_t channel_; }; -} // namespace adc128s102 -} // namespace esphome +} // namespace esphome::adc128s102 diff --git a/esphome/components/addressable_light/addressable_light_display.cpp b/esphome/components/addressable_light/addressable_light_display.cpp index 329620bcf0..4cbcb3324b 100644 --- a/esphome/components/addressable_light/addressable_light_display.cpp +++ b/esphome/components/addressable_light/addressable_light_display.cpp @@ -1,8 +1,7 @@ #include "addressable_light_display.h" #include "esphome/core/log.h" -namespace esphome { -namespace addressable_light { +namespace esphome::addressable_light { static const char *const TAG = "addressable_light.display"; @@ -66,5 +65,4 @@ void HOT AddressableLightDisplay::draw_absolute_pixel_internal(int x, int y, Col this->addressable_light_buffer_[y * this->get_width_internal() + x] = color; } } -} // namespace addressable_light -} // namespace esphome +} // namespace esphome::addressable_light diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index d9b8680547..917d334f05 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace addressable_light { +namespace esphome::addressable_light { class AddressableLightDisplay : public display::DisplayBuffer { public: @@ -61,5 +60,4 @@ class AddressableLightDisplay : public display::DisplayBuffer { optional last_effect_index_; optional> pixel_mapper_f_; }; -} // namespace addressable_light -} // namespace esphome +} // namespace esphome::addressable_light diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp index 8fb3e55b91..9d19770c57 100644 --- a/esphome/components/ade7880/ade7880.cpp +++ b/esphome/components/ade7880/ade7880.cpp @@ -13,8 +13,7 @@ #include -namespace esphome { -namespace ade7880 { +namespace esphome::ade7880 { static const char *const TAG = "ade7880"; @@ -313,5 +312,4 @@ void ADE7880::reset_device_() { this->store_.reset_pending = true; } -} // namespace ade7880 -} // namespace esphome +} // namespace esphome::ade7880 diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h index 40bc22e54a..69c8e5abba 100644 --- a/esphome/components/ade7880/ade7880.h +++ b/esphome/components/ade7880/ade7880.h @@ -16,8 +16,7 @@ #include "ade7880_registers.h" -namespace esphome { -namespace ade7880 { +namespace esphome::ade7880 { struct NeutralChannel { void set_current(sensor::Sensor *sens) { this->current = sens; } @@ -125,5 +124,4 @@ class ADE7880 : public i2c::I2CDevice, public PollingComponent { void write_u32_register16_(uint16_t a_register, uint32_t value); }; -} // namespace ade7880 -} // namespace esphome +} // namespace esphome::ade7880 diff --git a/esphome/components/ade7880/ade7880_i2c.cpp b/esphome/components/ade7880/ade7880_i2c.cpp index fae20f175d..294fd430d3 100644 --- a/esphome/components/ade7880/ade7880_i2c.cpp +++ b/esphome/components/ade7880/ade7880_i2c.cpp @@ -9,8 +9,7 @@ #include "ade7880.h" -namespace esphome { -namespace ade7880 { +namespace esphome::ade7880 { // adapted from https://stackoverflow.com/a/55912127/1886371 template inline T sign_extend(const T &v) noexcept { @@ -97,5 +96,4 @@ void ADE7880::write_u32_register16_(uint16_t a_register, uint32_t value) { this->write_register16(a_register, reinterpret_cast(&out), sizeof(out)); } -} // namespace ade7880 -} // namespace esphome +} // namespace esphome::ade7880 diff --git a/esphome/components/ade7880/ade7880_registers.h b/esphome/components/ade7880/ade7880_registers.h index 9fd8ca3bf5..aee4e42445 100644 --- a/esphome/components/ade7880/ade7880_registers.h +++ b/esphome/components/ade7880/ade7880_registers.h @@ -4,8 +4,7 @@ // Source: https://www.analog.com/media/en/technical-documentation/application-notes/AN-1127.pdf -namespace esphome { -namespace ade7880 { +namespace esphome::ade7880 { // DSP Data Memory RAM registers constexpr uint16_t AIGAIN = 0x4380; @@ -242,5 +241,4 @@ constexpr uint8_t DSPWP_SET_RO = (1 << 7); // DSPWP_SEL Register Bits constexpr uint8_t DSPWP_SEL_SET = 0xAD; -} // namespace ade7880 -} // namespace esphome +} // namespace esphome::ade7880 diff --git a/esphome/components/ade7953_base/ade7953_base.cpp b/esphome/components/ade7953_base/ade7953_base.cpp index 2dfab8ff85..1adf44f8f7 100644 --- a/esphome/components/ade7953_base/ade7953_base.cpp +++ b/esphome/components/ade7953_base/ade7953_base.cpp @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace ade7953_base { +namespace esphome::ade7953_base { static const char *const TAG = "ade7953"; @@ -160,5 +159,4 @@ void ADE7953::update() { ADE_PUBLISH(frequency, 223750.0f, 1 + val_16); } -} // namespace ade7953_base -} // namespace esphome +} // namespace esphome::ade7953_base diff --git a/esphome/components/ade7953_base/ade7953_base.h b/esphome/components/ade7953_base/ade7953_base.h index b58f95b230..a1dfea23b0 100644 --- a/esphome/components/ade7953_base/ade7953_base.h +++ b/esphome/components/ade7953_base/ade7953_base.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace ade7953_base { +namespace esphome::ade7953_base { static constexpr uint8_t PGA_V_8 = 0x007; // PGA_V, (R/W) Default: 0x00, Unsigned, Voltage channel gain configuration (Bits[2:0]) @@ -131,5 +130,4 @@ class ADE7953 : public PollingComponent, public sensor::Sensor { virtual bool ade_read_32(uint16_t reg, uint32_t *value) = 0; }; -} // namespace ade7953_base -} // namespace esphome +} // namespace esphome::ade7953_base diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.cpp b/esphome/components/ade7953_i2c/ade7953_i2c.cpp index 59c2254d44..252e55ee5c 100644 --- a/esphome/components/ade7953_i2c/ade7953_i2c.cpp +++ b/esphome/components/ade7953_i2c/ade7953_i2c.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace ade7953_i2c { +namespace esphome::ade7953_i2c { static const char *const TAG = "ade7953"; @@ -76,5 +75,4 @@ bool AdE7953I2c::ade_read_32(uint16_t reg, uint32_t *value) { return false; } -} // namespace ade7953_i2c -} // namespace esphome +} // namespace esphome::ade7953_i2c diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.h b/esphome/components/ade7953_i2c/ade7953_i2c.h index 65dc30dddb..74d7e3e7cc 100644 --- a/esphome/components/ade7953_i2c/ade7953_i2c.h +++ b/esphome/components/ade7953_i2c/ade7953_i2c.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace ade7953_i2c { +namespace esphome::ade7953_i2c { class AdE7953I2c : public ade7953_base::ADE7953, public i2c::I2CDevice { public: @@ -24,5 +23,4 @@ class AdE7953I2c : public ade7953_base::ADE7953, public i2c::I2CDevice { bool ade_read_32(uint16_t reg, uint32_t *value) override; }; -} // namespace ade7953_i2c -} // namespace esphome +} // namespace esphome::ade7953_i2c diff --git a/esphome/components/ade7953_spi/ade7953_spi.cpp b/esphome/components/ade7953_spi/ade7953_spi.cpp index a69b7d19fb..c2d85231d6 100644 --- a/esphome/components/ade7953_spi/ade7953_spi.cpp +++ b/esphome/components/ade7953_spi/ade7953_spi.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace ade7953_spi { +namespace esphome::ade7953_spi { static const char *const TAG = "ade7953"; @@ -83,5 +82,4 @@ bool AdE7953Spi::ade_read_32(uint16_t reg, uint32_t *value) { return false; } -} // namespace ade7953_spi -} // namespace esphome +} // namespace esphome::ade7953_spi diff --git a/esphome/components/ade7953_spi/ade7953_spi.h b/esphome/components/ade7953_spi/ade7953_spi.h index 27f6025d98..657397db4e 100644 --- a/esphome/components/ade7953_spi/ade7953_spi.h +++ b/esphome/components/ade7953_spi/ade7953_spi.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace ade7953_spi { +namespace esphome::ade7953_spi { class AdE7953Spi : public ade7953_base::ADE7953, public spi::SPIDevice -namespace esphome { -namespace ads1115 { +namespace esphome::ads1115 { enum ADS1115Multiplexer { ADS1115_MULTIPLEXER_P0_N1 = 0b000, @@ -60,5 +59,4 @@ class ADS1115Component : public Component, public i2c::I2CDevice { bool continuous_mode_; }; -} // namespace ads1115 -} // namespace esphome +} // namespace esphome::ads1115 diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.cpp b/esphome/components/ads1115/sensor/ads1115_sensor.cpp index fac6b60d0a..8086d97231 100644 --- a/esphome/components/ads1115/sensor/ads1115_sensor.cpp +++ b/esphome/components/ads1115/sensor/ads1115_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace ads1115 { +namespace esphome::ads1115 { static const char *const TAG = "ads1115.sensor"; @@ -29,5 +28,4 @@ void ADS1115Sensor::dump_config() { this->multiplexer_, this->gain_, this->resolution_, this->samplerate_); } -} // namespace ads1115 -} // namespace esphome +} // namespace esphome::ads1115 diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.h b/esphome/components/ads1115/sensor/ads1115_sensor.h index 5ca25c13ad..3b82c153dd 100644 --- a/esphome/components/ads1115/sensor/ads1115_sensor.h +++ b/esphome/components/ads1115/sensor/ads1115_sensor.h @@ -8,8 +8,7 @@ #include "../ads1115.h" -namespace esphome { -namespace ads1115 { +namespace esphome::ads1115 { /// Internal holder class that is in instance of Sensor so that the hub can create individual sensors. class ADS1115Sensor : public sensor::Sensor, @@ -33,5 +32,4 @@ class ADS1115Sensor : public sensor::Sensor, ADS1115Samplerate samplerate_; }; -} // namespace ads1115 -} // namespace esphome +} // namespace esphome::ads1115 diff --git a/esphome/components/ads1118/ads1118.cpp b/esphome/components/ads1118/ads1118.cpp index f7db9f93dd..0a07193bfe 100644 --- a/esphome/components/ads1118/ads1118.cpp +++ b/esphome/components/ads1118/ads1118.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace ads1118 { +namespace esphome::ads1118 { static const char *const TAG = "ads1118"; static const uint8_t ADS1118_DATA_RATE_860_SPS = 0b111; @@ -122,5 +121,4 @@ float ADS1118::request_measurement(ADS1118Multiplexer multiplexer, ADS1118Gain g } } -} // namespace ads1118 -} // namespace esphome +} // namespace esphome::ads1118 diff --git a/esphome/components/ads1118/ads1118.h b/esphome/components/ads1118/ads1118.h index e96baab386..ef125a0b44 100644 --- a/esphome/components/ads1118/ads1118.h +++ b/esphome/components/ads1118/ads1118.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace ads1118 { +namespace esphome::ads1118 { enum ADS1118Multiplexer { ADS1118_MULTIPLEXER_P0_N1 = 0b000, @@ -41,5 +40,4 @@ class ADS1118 : public Component, uint16_t config_{0}; }; -} // namespace ads1118 -} // namespace esphome +} // namespace esphome::ads1118 diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.cpp b/esphome/components/ads1118/sensor/ads1118_sensor.cpp index 7193c3c880..383a3d25fc 100644 --- a/esphome/components/ads1118/sensor/ads1118_sensor.cpp +++ b/esphome/components/ads1118/sensor/ads1118_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace ads1118 { +namespace esphome::ads1118 { static const char *const TAG = "ads1118.sensor"; @@ -27,5 +26,4 @@ void ADS1118Sensor::update() { } } -} // namespace ads1118 -} // namespace esphome +} // namespace esphome::ads1118 diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.h b/esphome/components/ads1118/sensor/ads1118_sensor.h index d2d7a03f59..b929e75c62 100644 --- a/esphome/components/ads1118/sensor/ads1118_sensor.h +++ b/esphome/components/ads1118/sensor/ads1118_sensor.h @@ -8,8 +8,7 @@ #include "../ads1118.h" -namespace esphome { -namespace ads1118 { +namespace esphome::ads1118 { class ADS1118Sensor : public PollingComponent, public sensor::Sensor, @@ -32,5 +31,4 @@ class ADS1118Sensor : public PollingComponent, bool temperature_mode_; }; -} // namespace ads1118 -} // namespace esphome +} // namespace esphome::ads1118 diff --git a/esphome/components/ags10/ags10.cpp b/esphome/components/ags10/ags10.cpp index fa7170114c..230548ae94 100644 --- a/esphome/components/ags10/ags10.cpp +++ b/esphome/components/ags10/ags10.cpp @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace ags10 { +namespace esphome::ags10 { static const char *const TAG = "ags10"; // Data acquisition. @@ -192,5 +191,4 @@ template optional> AGS10Component::read_and_che return data; } -} // namespace ags10 -} // namespace esphome +} // namespace esphome::ags10 diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h index 9e034b20cb..703acd5228 100644 --- a/esphome/components/ags10/ags10.h +++ b/esphome/components/ags10/ags10.h @@ -5,8 +5,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace ags10 { +namespace esphome::ags10 { class AGS10Component : public PollingComponent, public i2c::I2CDevice { public: @@ -136,5 +135,4 @@ template class AGS10SetZeroPointAction : public Action, p } } }; -} // namespace ags10 -} // namespace esphome +} // namespace esphome::ags10 diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp index 1b1f8335cc..cc90abfc3a 100644 --- a/esphome/components/aht10/aht10.cpp +++ b/esphome/components/aht10/aht10.cpp @@ -17,8 +17,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace aht10 { +namespace esphome::aht10 { static const char *const TAG = "aht10"; static const uint8_t AHT10_INITIALIZE_CMD[] = {0xE1, 0x08, 0x00}; @@ -160,5 +159,4 @@ void AHT10Component::dump_config() { LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } -} // namespace aht10 -} // namespace esphome +} // namespace esphome::aht10 diff --git a/esphome/components/aht10/aht10.h b/esphome/components/aht10/aht10.h index ce9cd963ad..7b9b1761c4 100644 --- a/esphome/components/aht10/aht10.h +++ b/esphome/components/aht10/aht10.h @@ -6,8 +6,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace aht10 { +namespace esphome::aht10 { enum AHT10Variant { AHT10, AHT20 }; @@ -31,5 +30,4 @@ class AHT10Component : public PollingComponent, public i2c::I2CDevice { uint32_t start_time_{}; }; -} // namespace aht10 -} // namespace esphome +} // namespace esphome::aht10 diff --git a/esphome/components/aic3204/aic3204.cpp b/esphome/components/aic3204/aic3204.cpp index e1acf32f83..0ba960fd70 100644 --- a/esphome/components/aic3204/aic3204.cpp +++ b/esphome/components/aic3204/aic3204.cpp @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace aic3204 { +namespace esphome::aic3204 { static const char *const TAG = "aic3204"; @@ -167,5 +166,4 @@ bool AIC3204::write_volume_() { return true; } -} // namespace aic3204 -} // namespace esphome +} // namespace esphome::aic3204 diff --git a/esphome/components/aic3204/aic3204.h b/esphome/components/aic3204/aic3204.h index 28006e33fc..9b8c792824 100644 --- a/esphome/components/aic3204/aic3204.h +++ b/esphome/components/aic3204/aic3204.h @@ -6,8 +6,7 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" -namespace esphome { -namespace aic3204 { +namespace esphome::aic3204 { // TLV320AIC3204 Register Addresses // Page 0 @@ -83,5 +82,4 @@ class AIC3204 : public audio_dac::AudioDac, public Component, public i2c::I2CDev float volume_{0}; }; -} // namespace aic3204 -} // namespace esphome +} // namespace esphome::aic3204 diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h index 851ff930f8..50ae03edbd 100644 --- a/esphome/components/aic3204/automation.h +++ b/esphome/components/aic3204/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "aic3204.h" -namespace esphome { -namespace aic3204 { +namespace esphome::aic3204 { template class SetAutoMuteAction : public Action { public: @@ -19,5 +18,4 @@ template class SetAutoMuteAction : public Action { AIC3204 *aic3204_; }; -} // namespace aic3204 -} // namespace esphome +} // namespace esphome::aic3204 diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp index 58faf923f5..881b3e297b 100644 --- a/esphome/components/airthings_ble/airthings_listener.cpp +++ b/esphome/components/airthings_ble/airthings_listener.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace airthings_ble { +namespace esphome::airthings_ble { static const char *const TAG = "airthings_ble"; @@ -29,7 +28,6 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic return false; } -} // namespace airthings_ble -} // namespace esphome +} // namespace esphome::airthings_ble #endif diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h index 52f69ea970..707e9c3f21 100644 --- a/esphome/components/airthings_ble/airthings_listener.h +++ b/esphome/components/airthings_ble/airthings_listener.h @@ -5,15 +5,13 @@ #include "esphome/core/component.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" -namespace esphome { -namespace airthings_ble { +namespace esphome::airthings_ble { class AirthingsListener : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; -} // namespace airthings_ble -} // namespace esphome +} // namespace esphome::airthings_ble #endif diff --git a/esphome/components/airthings_wave_base/airthings_wave_base.cpp b/esphome/components/airthings_wave_base/airthings_wave_base.cpp index e4c7d2a81d..5fa59f22fd 100644 --- a/esphome/components/airthings_wave_base/airthings_wave_base.cpp +++ b/esphome/components/airthings_wave_base/airthings_wave_base.cpp @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace airthings_wave_base { +namespace esphome::airthings_wave_base { static const char *const TAG = "airthings_wave_base"; @@ -211,7 +210,6 @@ void AirthingsWaveBase::set_response_timeout_() { }); } -} // namespace airthings_wave_base -} // namespace esphome +} // namespace esphome::airthings_wave_base #endif // USE_ESP32 diff --git a/esphome/components/airthings_wave_base/airthings_wave_base.h b/esphome/components/airthings_wave_base/airthings_wave_base.h index 1dc2e1f71f..2f1e15491f 100644 --- a/esphome/components/airthings_wave_base/airthings_wave_base.h +++ b/esphome/components/airthings_wave_base/airthings_wave_base.h @@ -14,8 +14,7 @@ #include "esphome/core/component.h" #include "esphome/core/log.h" -namespace esphome { -namespace airthings_wave_base { +namespace esphome::airthings_wave_base { namespace espbt = esphome::esp32_ble_tracker; @@ -84,7 +83,6 @@ class AirthingsWaveBase : public PollingComponent, public ble_client::BLEClientN }; }; -} // namespace airthings_wave_base -} // namespace esphome +} // namespace esphome::airthings_wave_base #endif // USE_ESP32 diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp b/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp index 873826d06c..f487e9dbc0 100644 --- a/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp +++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp @@ -2,8 +2,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace airthings_wave_mini { +namespace esphome::airthings_wave_mini { static const char *const TAG = "airthings_wave_mini"; @@ -49,7 +48,6 @@ AirthingsWaveMini::AirthingsWaveMini() { espbt::ESPBTUUID::from_raw(ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID); } -} // namespace airthings_wave_mini -} // namespace esphome +} // namespace esphome::airthings_wave_mini #endif // USE_ESP32 diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.h b/esphome/components/airthings_wave_mini/airthings_wave_mini.h index 825ddbdc69..910ac90239 100644 --- a/esphome/components/airthings_wave_mini/airthings_wave_mini.h +++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.h @@ -4,8 +4,7 @@ #include "esphome/components/airthings_wave_base/airthings_wave_base.h" -namespace esphome { -namespace airthings_wave_mini { +namespace esphome::airthings_wave_mini { namespace espbt = esphome::esp32_ble_tracker; @@ -34,7 +33,6 @@ class AirthingsWaveMini : public airthings_wave_base::AirthingsWaveBase { }; }; -} // namespace airthings_wave_mini -} // namespace esphome +} // namespace esphome::airthings_wave_mini #endif // USE_ESP32 diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp index 5ed62fff62..80fe081b57 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp @@ -2,8 +2,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace airthings_wave_plus { +namespace esphome::airthings_wave_plus { static const char *const TAG = "airthings_wave_plus"; @@ -98,7 +97,6 @@ void AirthingsWavePlus::setup() { espbt::ESPBTUUID::from_raw(access_control_point_characteristic_uuid); } -} // namespace airthings_wave_plus -} // namespace esphome +} // namespace esphome::airthings_wave_plus #endif // USE_ESP32 diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.h b/esphome/components/airthings_wave_plus/airthings_wave_plus.h index c978a9af92..6f51f3c65a 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.h +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.h @@ -4,8 +4,7 @@ #include "esphome/components/airthings_wave_base/airthings_wave_base.h" -namespace esphome { -namespace airthings_wave_plus { +namespace esphome::airthings_wave_plus { namespace espbt = esphome::esp32_ble_tracker; @@ -58,7 +57,6 @@ class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { }; }; -} // namespace airthings_wave_plus -} // namespace esphome +} // namespace esphome::airthings_wave_plus #endif // USE_ESP32 diff --git a/esphome/components/alpha3/alpha3.cpp b/esphome/components/alpha3/alpha3.cpp index 6e82ec047d..048c365616 100644 --- a/esphome/components/alpha3/alpha3.cpp +++ b/esphome/components/alpha3/alpha3.cpp @@ -5,8 +5,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace alpha3 { +namespace esphome::alpha3 { static const char *const TAG = "alpha3"; @@ -185,7 +184,6 @@ void Alpha3::update() { delay(25); // need to wait between requests } } -} // namespace alpha3 -} // namespace esphome +} // namespace esphome::alpha3 #endif diff --git a/esphome/components/alpha3/alpha3.h b/esphome/components/alpha3/alpha3.h index 19d8e99331..c63129031a 100644 --- a/esphome/components/alpha3/alpha3.h +++ b/esphome/components/alpha3/alpha3.h @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace alpha3 { +namespace esphome::alpha3 { namespace espbt = esphome::esp32_ble_tracker; @@ -64,7 +63,6 @@ class Alpha3 : public esphome::ble_client::BLEClientNode, public PollingComponen void send_request_(uint8_t *request, size_t len); bool is_current_response_type_(const uint8_t *response_type); }; -} // namespace alpha3 -} // namespace esphome +} // namespace esphome::alpha3 #endif diff --git a/esphome/components/am2315c/am2315c.cpp b/esphome/components/am2315c/am2315c.cpp index 1390b74975..8980a8dfc3 100644 --- a/esphome/components/am2315c/am2315c.cpp +++ b/esphome/components/am2315c/am2315c.cpp @@ -24,8 +24,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace am2315c { +namespace esphome::am2315c { static const char *const TAG = "am2315c"; @@ -176,5 +175,4 @@ void AM2315C::dump_config() { LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } -} // namespace am2315c -} // namespace esphome +} // namespace esphome::am2315c diff --git a/esphome/components/am2315c/am2315c.h b/esphome/components/am2315c/am2315c.h index d7baf01cae..5a959af4c3 100644 --- a/esphome/components/am2315c/am2315c.h +++ b/esphome/components/am2315c/am2315c.h @@ -25,8 +25,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -namespace esphome { -namespace am2315c { +namespace esphome::am2315c { class AM2315C : public PollingComponent, public i2c::I2CDevice { public: @@ -45,5 +44,4 @@ class AM2315C : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *humidity_sensor_{nullptr}; }; -} // namespace am2315c -} // namespace esphome +} // namespace esphome::am2315c diff --git a/esphome/components/am2320/am2320.cpp b/esphome/components/am2320/am2320.cpp index 7fef3bb3a6..5445ab3898 100644 --- a/esphome/components/am2320/am2320.cpp +++ b/esphome/components/am2320/am2320.cpp @@ -8,8 +8,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace am2320 { +namespace esphome::am2320 { static const char *const TAG = "am2320"; @@ -86,5 +85,4 @@ bool AM2320Component::read_data_(uint8_t *data) { return true; } -} // namespace am2320 -} // namespace esphome +} // namespace esphome::am2320 diff --git a/esphome/components/am2320/am2320.h b/esphome/components/am2320/am2320.h index 708dbb632e..ddb5c6f165 100644 --- a/esphome/components/am2320/am2320.h +++ b/esphome/components/am2320/am2320.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace am2320 { +namespace esphome::am2320 { class AM2320Component : public PollingComponent, public i2c::I2CDevice { public: @@ -24,5 +23,4 @@ class AM2320Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *humidity_sensor_{nullptr}; }; -} // namespace am2320 -} // namespace esphome +} // namespace esphome::am2320 diff --git a/esphome/components/am43/am43_base.cpp b/esphome/components/am43/am43_base.cpp index d70e638382..977185e5e3 100644 --- a/esphome/components/am43/am43_base.cpp +++ b/esphome/components/am43/am43_base.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include -namespace esphome { -namespace am43 { +namespace esphome::am43 { const uint8_t START_PACKET[5] = {0x00, 0xff, 0x00, 0x00, 0x9a}; @@ -134,5 +133,4 @@ void Am43Decoder::decode(const uint8_t *data, uint16_t length) { } }; -} // namespace am43 -} // namespace esphome +} // namespace esphome::am43 diff --git a/esphome/components/am43/am43_base.h b/esphome/components/am43/am43_base.h index 35354af9ed..5df83747c0 100644 --- a/esphome/components/am43/am43_base.h +++ b/esphome/components/am43/am43_base.h @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace am43 { +namespace esphome::am43 { static const uint16_t AM43_SERVICE_UUID = 0xFE50; static const uint16_t AM43_CHARACTERISTIC_UUID = 0xFE51; @@ -74,5 +73,4 @@ class Am43Decoder { bool has_pin_response_; }; -} // namespace am43 -} // namespace esphome +} // namespace esphome::am43 diff --git a/esphome/components/am43/cover/am43_cover.cpp b/esphome/components/am43/cover/am43_cover.cpp index 2fa26d266a..35366dbaa6 100644 --- a/esphome/components/am43/cover/am43_cover.cpp +++ b/esphome/components/am43/cover/am43_cover.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace am43 { +namespace esphome::am43 { static const char *const TAG = "am43_cover"; @@ -154,7 +153,6 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } } -} // namespace am43 -} // namespace esphome +} // namespace esphome::am43 #endif diff --git a/esphome/components/am43/cover/am43_cover.h b/esphome/components/am43/cover/am43_cover.h index d6d020e98c..aa48aced15 100644 --- a/esphome/components/am43/cover/am43_cover.h +++ b/esphome/components/am43/cover/am43_cover.h @@ -10,8 +10,7 @@ #include -namespace esphome { -namespace am43 { +namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; @@ -38,7 +37,6 @@ class Am43Component : public cover::Cover, public esphome::ble_client::BLEClient float position_; }; -} // namespace am43 -} // namespace esphome +} // namespace esphome::am43 #endif diff --git a/esphome/components/am43/sensor/am43_sensor.cpp b/esphome/components/am43/sensor/am43_sensor.cpp index b2bc3254e2..ddc3eadae9 100644 --- a/esphome/components/am43/sensor/am43_sensor.cpp +++ b/esphome/components/am43/sensor/am43_sensor.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace am43 { +namespace esphome::am43 { static const char *const TAG = "am43"; @@ -111,7 +110,6 @@ void Am43::update() { } } -} // namespace am43 -} // namespace esphome +} // namespace esphome::am43 #endif diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h index 195b96a19e..9198a5cbcb 100644 --- a/esphome/components/am43/sensor/am43_sensor.h +++ b/esphome/components/am43/sensor/am43_sensor.h @@ -10,8 +10,7 @@ #include -namespace esphome { -namespace am43 { +namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; @@ -38,7 +37,6 @@ class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent uint32_t last_battery_update_; }; -} // namespace am43 -} // namespace esphome +} // namespace esphome::am43 #endif diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp index 0b3bd0e472..d25c10021c 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp @@ -1,8 +1,7 @@ #include "analog_threshold_binary_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace analog_threshold { +namespace esphome::analog_threshold { static const char *const TAG = "analog_threshold.binary_sensor"; @@ -43,5 +42,4 @@ void AnalogThresholdBinarySensor::dump_config() { this->upper_threshold_.value(), this->lower_threshold_.value()); } -} // namespace analog_threshold -} // namespace esphome +} // namespace esphome::analog_threshold diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index 55a822b9b0..c768f1f82d 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -5,8 +5,7 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace analog_threshold { +namespace esphome::analog_threshold { class AnalogThresholdBinarySensor : public Component, public binary_sensor::BinarySensor { public: @@ -24,5 +23,4 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina bool raw_state_{false}; // Pre-filter state for hysteresis logic }; -} // namespace analog_threshold -} // namespace esphome +} // namespace esphome::analog_threshold diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index 2f59a7fa5a..2a293adf1d 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" -namespace esphome { -namespace animation { +namespace esphome::animation { Animation::Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type, image::Transparency transparent) @@ -71,5 +70,4 @@ void Animation::update_data_start_() { this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_; } -} // namespace animation -} // namespace esphome +} // namespace esphome::animation diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index b33254df30..ca800ad931 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -3,8 +3,7 @@ #include "esphome/core/automation.h" -namespace esphome { -namespace animation { +namespace esphome::animation { class Animation : public image::Image { public: @@ -64,5 +63,4 @@ template class AnimationSetFrameAction : public Action { Animation *parent_; }; -} // namespace animation -} // namespace esphome +} // namespace esphome::animation diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index f21230b075..6e382872e2 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace anova { +namespace esphome::anova { static const char *const TAG = "anova"; @@ -160,7 +159,6 @@ void Anova::update() { } } -} // namespace anova -} // namespace esphome +} // namespace esphome::anova #endif diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index 2e43ebfb98..a3e175be28 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -10,8 +10,7 @@ #include -namespace esphome { -namespace anova { +namespace esphome::anova { namespace espbt = esphome::esp32_ble_tracker; @@ -45,7 +44,6 @@ class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode bool fahrenheit_; }; -} // namespace anova -} // namespace esphome +} // namespace esphome::anova #endif diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp index a14dd728a8..84dd4393eb 100644 --- a/esphome/components/anova/anova_base.cpp +++ b/esphome/components/anova/anova_base.cpp @@ -4,8 +4,7 @@ #include "esphome/core/alloc_helpers.h" -namespace esphome { -namespace anova { +namespace esphome::anova { float ftoc(float f) { return (f - 32.0) * (5.0f / 9.0f); } @@ -132,5 +131,4 @@ void AnovaCodec::decode(const uint8_t *data, uint16_t length) { } } -} // namespace anova -} // namespace esphome +} // namespace esphome::anova diff --git a/esphome/components/anova/anova_base.h b/esphome/components/anova/anova_base.h index b831157849..b3ed0f01a0 100644 --- a/esphome/components/anova/anova_base.h +++ b/esphome/components/anova/anova_base.h @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace anova { +namespace esphome::anova { enum CurrentQuery { NONE, @@ -75,5 +74,4 @@ class AnovaCodec { CurrentQuery current_query_; }; -} // namespace anova -} // namespace esphome +} // namespace esphome::anova diff --git a/esphome/components/apds9306/apds9306.cpp b/esphome/components/apds9306/apds9306.cpp index fb3adde868..57a502ca42 100644 --- a/esphome/components/apds9306/apds9306.cpp +++ b/esphome/components/apds9306/apds9306.cpp @@ -5,8 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace apds9306 { +namespace esphome::apds9306 { static const char *const TAG = "apds9306"; @@ -147,5 +146,4 @@ void APDS9306::update() { this->publish_state(lux); } -} // namespace apds9306 -} // namespace esphome +} // namespace esphome::apds9306 diff --git a/esphome/components/apds9306/apds9306.h b/esphome/components/apds9306/apds9306.h index 44362908c8..093ec55bc6 100644 --- a/esphome/components/apds9306/apds9306.h +++ b/esphome/components/apds9306/apds9306.h @@ -7,8 +7,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -namespace esphome { -namespace apds9306 { +namespace esphome::apds9306 { enum MeasurementBitWidth : uint8_t { MEASUREMENT_BIT_WIDTH_20 = 0, @@ -62,5 +61,4 @@ class APDS9306 : public sensor::Sensor, public PollingComponent, public i2c::I2C AmbientLightGain gain_; }; -} // namespace apds9306 -} // namespace esphome +} // namespace esphome::apds9306 diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index a07175f2c9..da8029b4ee 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace apds9960 { +namespace esphome::apds9960 { static const char *const TAG = "apds9960"; @@ -402,5 +401,4 @@ bool APDS9960::is_gesture_enabled_() const { #endif } -} // namespace apds9960 -} // namespace esphome +} // namespace esphome::apds9960 diff --git a/esphome/components/apds9960/apds9960.h b/esphome/components/apds9960/apds9960.h index 4574b70a42..2823294207 100644 --- a/esphome/components/apds9960/apds9960.h +++ b/esphome/components/apds9960/apds9960.h @@ -10,8 +10,7 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif -namespace esphome { -namespace apds9960 { +namespace esphome::apds9960 { class APDS9960 : public PollingComponent, public i2c::I2CDevice { #ifdef USE_SENSOR @@ -71,5 +70,4 @@ class APDS9960 : public PollingComponent, public i2c::I2CDevice { uint32_t gesture_start_{0}; }; -} // namespace apds9960 -} // namespace esphome +} // namespace esphome::apds9960 diff --git a/esphome/components/as3935/as3935.cpp b/esphome/components/as3935/as3935.cpp index c4dc0466a0..a1d74c78de 100644 --- a/esphome/components/as3935/as3935.cpp +++ b/esphome/components/as3935/as3935.cpp @@ -1,8 +1,7 @@ #include "as3935.h" #include "esphome/core/log.h" -namespace esphome { -namespace as3935 { +namespace esphome::as3935 { static const char *const TAG = "as3935"; @@ -320,5 +319,4 @@ uint8_t AS3935Component::read_register_(uint8_t reg, uint8_t mask) { return value; } -} // namespace as3935 -} // namespace esphome +} // namespace esphome::as3935 diff --git a/esphome/components/as3935/as3935.h b/esphome/components/as3935/as3935.h index 5f46dadfa8..274ce7a138 100644 --- a/esphome/components/as3935/as3935.h +++ b/esphome/components/as3935/as3935.h @@ -10,8 +10,7 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif -namespace esphome { -namespace as3935 { +namespace esphome::as3935 { static const uint8_t DIRECT_COMMAND = 0x96; static const uint8_t ANTFREQ = 3; @@ -127,5 +126,4 @@ class AS3935Component : public Component { bool calibration_; }; -} // namespace as3935 -} // namespace esphome +} // namespace esphome::as3935 diff --git a/esphome/components/as3935_i2c/as3935_i2c.cpp b/esphome/components/as3935_i2c/as3935_i2c.cpp index 3a7fa7bf84..4c1020daa7 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.cpp +++ b/esphome/components/as3935_i2c/as3935_i2c.cpp @@ -1,8 +1,7 @@ #include "as3935_i2c.h" #include "esphome/core/log.h" -namespace esphome { -namespace as3935_i2c { +namespace esphome::as3935_i2c { static const char *const TAG = "as3935_i2c"; @@ -40,5 +39,4 @@ void I2CAS3935Component::dump_config() { LOG_I2C_DEVICE(this); } -} // namespace as3935_i2c -} // namespace esphome +} // namespace esphome::as3935_i2c diff --git a/esphome/components/as3935_i2c/as3935_i2c.h b/esphome/components/as3935_i2c/as3935_i2c.h index a2a3d213ef..c43ec4afd5 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.h +++ b/esphome/components/as3935_i2c/as3935_i2c.h @@ -3,8 +3,7 @@ #include "esphome/components/as3935/as3935.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace as3935_i2c { +namespace esphome::as3935_i2c { class I2CAS3935Component : public as3935::AS3935Component, public i2c::I2CDevice { public: @@ -15,5 +14,4 @@ class I2CAS3935Component : public as3935::AS3935Component, public i2c::I2CDevice uint8_t read_register(uint8_t reg) override; }; -} // namespace as3935_i2c -} // namespace esphome +} // namespace esphome::as3935_i2c diff --git a/esphome/components/as3935_spi/as3935_spi.cpp b/esphome/components/as3935_spi/as3935_spi.cpp index 1b2e9ccd3f..026fde2f21 100644 --- a/esphome/components/as3935_spi/as3935_spi.cpp +++ b/esphome/components/as3935_spi/as3935_spi.cpp @@ -1,8 +1,7 @@ #include "as3935_spi.h" #include "esphome/core/log.h" -namespace esphome { -namespace as3935_spi { +namespace esphome::as3935_spi { static const char *const TAG = "as3935_spi"; @@ -42,5 +41,4 @@ uint8_t SPIAS3935Component::read_register(uint8_t reg) { return value; } -} // namespace as3935_spi -} // namespace esphome +} // namespace esphome::as3935_spi diff --git a/esphome/components/as3935_spi/as3935_spi.h b/esphome/components/as3935_spi/as3935_spi.h index e5422f9b37..935707a18c 100644 --- a/esphome/components/as3935_spi/as3935_spi.h +++ b/esphome/components/as3935_spi/as3935_spi.h @@ -4,8 +4,7 @@ #include "esphome/components/as3935/as3935.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace as3935_spi { +namespace esphome::as3935_spi { enum AS3935RegisterMasks { SPI_READ_M = 0x40 }; @@ -21,5 +20,4 @@ class SPIAS3935Component : public as3935::AS3935Component, uint8_t read_register(uint8_t reg) override; }; -} // namespace as3935_spi -} // namespace esphome +} // namespace esphome::as3935_spi diff --git a/esphome/components/as5600/as5600.cpp b/esphome/components/as5600/as5600.cpp index ee3083d561..da1add5458 100644 --- a/esphome/components/as5600/as5600.cpp +++ b/esphome/components/as5600/as5600.cpp @@ -1,8 +1,7 @@ #include "as5600.h" #include "esphome/core/log.h" -namespace esphome { -namespace as5600 { +namespace esphome::as5600 { static const char *const TAG = "as5600"; @@ -134,5 +133,4 @@ optional AS5600Component::read_raw_position() { return pos; } -} // namespace as5600 -} // namespace esphome +} // namespace esphome::as5600 diff --git a/esphome/components/as5600/as5600.h b/esphome/components/as5600/as5600.h index 914a4431bd..414633f978 100644 --- a/esphome/components/as5600/as5600.h +++ b/esphome/components/as5600/as5600.h @@ -6,8 +6,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace as5600 { +namespace esphome::as5600 { static const uint16_t POSITION_COUNT = 4096; static const float RAW_TO_DEGREES = 360.0 / POSITION_COUNT; @@ -100,5 +99,4 @@ class AS5600Component : public Component, public i2c::I2CDevice { float range_scale_{1.0}; }; -} // namespace as5600 -} // namespace esphome +} // namespace esphome::as5600 diff --git a/esphome/components/as5600/sensor/as5600_sensor.cpp b/esphome/components/as5600/sensor/as5600_sensor.cpp index 4e549d24d5..ba295659a1 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.cpp +++ b/esphome/components/as5600/sensor/as5600_sensor.cpp @@ -1,8 +1,7 @@ #include "as5600_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace as5600 { +namespace esphome::as5600 { static const char *const TAG = "as5600.sensor"; @@ -75,5 +74,4 @@ void AS5600Sensor::update() { this->status_clear_warning(); } -} // namespace as5600 -} // namespace esphome +} // namespace esphome::as5600 diff --git a/esphome/components/as5600/sensor/as5600_sensor.h b/esphome/components/as5600/sensor/as5600_sensor.h index 77593f4b12..0086fe54cc 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.h +++ b/esphome/components/as5600/sensor/as5600_sensor.h @@ -7,8 +7,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/as5600/as5600.h" -namespace esphome { -namespace as5600 { +namespace esphome::as5600 { class AS5600Sensor : public PollingComponent, public Parented, public sensor::Sensor { public: @@ -32,5 +31,4 @@ class AS5600Sensor : public PollingComponent, public Parented, OutRangeMode out_of_range_mode_{OUT_RANGE_MODE_MIN_MAX}; }; -} // namespace as5600 -} // namespace esphome +} // namespace esphome::as5600 diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index 1e78d814c8..32094ddf10 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace as7341 { +namespace esphome::as7341 { static const char *const TAG = "as7341"; @@ -266,5 +265,4 @@ bool AS7341Component::clear_register_bit(uint8_t address, uint8_t bit_position) uint16_t AS7341Component::swap_bytes(uint16_t data) { return (data >> 8) | (data << 8); } -} // namespace as7341 -} // namespace esphome +} // namespace esphome::as7341 diff --git a/esphome/components/as7341/as7341.h b/esphome/components/as7341/as7341.h index 3ede9d4aa4..8bc157fe79 100644 --- a/esphome/components/as7341/as7341.h +++ b/esphome/components/as7341/as7341.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace as7341 { +namespace esphome::as7341 { static const uint8_t AS7341_CHIP_ID = 0x09; @@ -139,5 +138,4 @@ class AS7341Component : public PollingComponent, public i2c::I2CDevice { uint16_t channel_readings_[12]; }; -} // namespace as7341 -} // namespace esphome +} // namespace esphome::as7341 diff --git a/esphome/components/at581x/at581x.cpp b/esphome/components/at581x/at581x.cpp index 6fc85b0790..575a9afd40 100644 --- a/esphome/components/at581x/at581x.cpp +++ b/esphome/components/at581x/at581x.cpp @@ -49,8 +49,7 @@ const uint8_t TRIGGER_KEEP_TIME_ADDR = 0x42; // 4 bytes, so up to 0x45 const uint8_t TIME41_VALUE = 1; const uint8_t SELF_CHECK_TIME_ADDR = 0x38; // 2 bytes, up to 0x39 -namespace esphome { -namespace at581x { +namespace esphome::at581x { static const char *const TAG = "at581x"; @@ -199,5 +198,4 @@ void AT581XComponent::set_rf_mode(bool enable) { } } -} // namespace at581x -} // namespace esphome +} // namespace esphome::at581x diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h index 558a5c8b19..e7f8ee3692 100644 --- a/esphome/components/at581x/at581x.h +++ b/esphome/components/at581x/at581x.h @@ -10,8 +10,7 @@ #endif #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace at581x { +namespace esphome::at581x { class AT581XComponent : public Component, public i2c::I2CDevice { public: @@ -58,5 +57,4 @@ class AT581XComponent : public Component, public i2c::I2CDevice { int power_; /*!< In µA */ }; -} // namespace at581x -} // namespace esphome +} // namespace esphome::at581x diff --git a/esphome/components/at581x/automation.h b/esphome/components/at581x/automation.h index b1611a6758..eb8b1b2562 100644 --- a/esphome/components/at581x/automation.h +++ b/esphome/components/at581x/automation.h @@ -5,8 +5,7 @@ #include "at581x.h" -namespace esphome { -namespace at581x { +namespace esphome::at581x { template class AT581XResetAction : public Action, public Parented { public: @@ -67,5 +66,4 @@ template class AT581XSettingsAction : public Action, publ } } }; -} // namespace at581x -} // namespace esphome +} // namespace esphome::at581x diff --git a/esphome/components/at581x/switch/rf_switch.cpp b/esphome/components/at581x/switch/rf_switch.cpp index f1d03dc8a5..7432233863 100644 --- a/esphome/components/at581x/switch/rf_switch.cpp +++ b/esphome/components/at581x/switch/rf_switch.cpp @@ -1,12 +1,10 @@ #include "rf_switch.h" -namespace esphome { -namespace at581x { +namespace esphome::at581x { void RFSwitch::write_state(bool state) { this->publish_state(state); this->parent_->set_rf_mode(state); } -} // namespace at581x -} // namespace esphome +} // namespace esphome::at581x diff --git a/esphome/components/at581x/switch/rf_switch.h b/esphome/components/at581x/switch/rf_switch.h index 920ddbb66a..47367fad45 100644 --- a/esphome/components/at581x/switch/rf_switch.h +++ b/esphome/components/at581x/switch/rf_switch.h @@ -3,13 +3,11 @@ #include "esphome/components/switch/switch.h" #include "../at581x.h" -namespace esphome { -namespace at581x { +namespace esphome::at581x { class RFSwitch : public switch_::Switch, public Parented { protected: void write_state(bool state) override; }; -} // namespace at581x -} // namespace esphome +} // namespace esphome::at581x diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 9afd6334f5..f8bbd9d55e 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace atc_mithermometer { +namespace esphome::atc_mithermometer { static const char *const TAG = "atc_mithermometer"; @@ -133,7 +132,6 @@ bool ATCMiThermometer::report_results_(const optional &result, cons return true; } -} // namespace atc_mithermometer -} // namespace esphome +} // namespace esphome::atc_mithermometer #endif diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index e37b5f4350..8f62f05bc1 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace atc_mithermometer { +namespace esphome::atc_mithermometer { struct ParseResult { optional temperature; @@ -44,7 +43,6 @@ class ATCMiThermometer : public Component, public esp32_ble_tracker::ESPBTDevice bool report_results_(const optional &result, const char *address); }; -} // namespace atc_mithermometer -} // namespace esphome +} // namespace esphome::atc_mithermometer #endif diff --git a/esphome/components/atm90e26/atm90e26.cpp b/esphome/components/atm90e26/atm90e26.cpp index e6602411bb..46948be47d 100644 --- a/esphome/components/atm90e26/atm90e26.cpp +++ b/esphome/components/atm90e26/atm90e26.cpp @@ -2,8 +2,7 @@ #include "atm90e26_reg.h" #include "esphome/core/log.h" -namespace esphome { -namespace atm90e26 { +namespace esphome::atm90e26 { static const char *const TAG = "atm90e26"; @@ -229,5 +228,4 @@ float ATM90E26Component::get_frequency_() { return freq / 100.0f; } -} // namespace atm90e26 -} // namespace esphome +} // namespace esphome::atm90e26 diff --git a/esphome/components/atm90e26/atm90e26.h b/esphome/components/atm90e26/atm90e26.h index d15a53ea43..657f8f3c43 100644 --- a/esphome/components/atm90e26/atm90e26.h +++ b/esphome/components/atm90e26/atm90e26.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace atm90e26 { +namespace esphome::atm90e26 { class ATM90E26Component : public PollingComponent, public spi::SPIDevice #include "esphome/core/log.h" -namespace esphome { -namespace atm90e32 { +namespace esphome::atm90e32 { static const char *const TAG = "atm90e32"; @@ -1313,5 +1312,4 @@ bool ATM90E32Component::validate_spi_read_(uint16_t expected, const char *contex return true; } -} // namespace atm90e32 -} // namespace esphome +} // namespace esphome::atm90e32 diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index 62c7bada86..5fa224b353 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -11,8 +11,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" -namespace esphome { -namespace atm90e32 { +namespace esphome::atm90e32 { class ATM90E32Component : public PollingComponent, public spi::SPIDevice -namespace esphome { -namespace atm90e32 { +namespace esphome::atm90e32 { /* STATUS REGISTERS */ static const uint16_t ATM90E32_REGISTER_METEREN = 0x00; // Metering Enable @@ -268,5 +267,4 @@ static const uint16_t ATM90E32_REGISTER_UANGLEA = 0xFD; // A Voltage Phase Angl static const uint16_t ATM90E32_REGISTER_UANGLEB = 0xFE; // B Voltage Phase Angle static const uint16_t ATM90E32_REGISTER_UANGLEC = 0xFF; // C Voltage Phase Angle -} // namespace atm90e32 -} // namespace esphome +} // namespace esphome::atm90e32 diff --git a/esphome/components/atm90e32/button/atm90e32_button.cpp b/esphome/components/atm90e32/button/atm90e32_button.cpp index a89f071997..e120e45364 100644 --- a/esphome/components/atm90e32/button/atm90e32_button.cpp +++ b/esphome/components/atm90e32/button/atm90e32_button.cpp @@ -2,8 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/log.h" -namespace esphome { -namespace atm90e32 { +namespace esphome::atm90e32 { static const char *const TAG = "atm90e32.button"; @@ -75,5 +74,4 @@ void ATM90E32ClearPowerOffsetCalibrationButton::press_action() { this->parent_->clear_power_offset_calibrations(); } -} // namespace atm90e32 -} // namespace esphome +} // namespace esphome::atm90e32 diff --git a/esphome/components/atm90e32/button/atm90e32_button.h b/esphome/components/atm90e32/button/atm90e32_button.h index 2449581531..0cfce62293 100644 --- a/esphome/components/atm90e32/button/atm90e32_button.h +++ b/esphome/components/atm90e32/button/atm90e32_button.h @@ -4,8 +4,7 @@ #include "esphome/components/atm90e32/atm90e32.h" #include "esphome/components/button/button.h" -namespace esphome { -namespace atm90e32 { +namespace esphome::atm90e32 { class ATM90E32GainCalibrationButton : public button::Button, public Parented { public: @@ -55,5 +54,4 @@ class ATM90E32ClearPowerOffsetCalibrationButton : public button::Button, public void press_action() override; }; -} // namespace atm90e32 -} // namespace esphome +} // namespace esphome::atm90e32 diff --git a/esphome/components/atm90e32/number/atm90e32_number.h b/esphome/components/atm90e32/number/atm90e32_number.h index 9b6129b26d..a575a94ea4 100644 --- a/esphome/components/atm90e32/number/atm90e32_number.h +++ b/esphome/components/atm90e32/number/atm90e32_number.h @@ -4,13 +4,11 @@ #include "esphome/components/atm90e32/atm90e32.h" #include "esphome/components/number/number.h" -namespace esphome { -namespace atm90e32 { +namespace esphome::atm90e32 { class ATM90E32Number : public number::Number, public Parented { public: void control(float value) override { this->publish_state(value); } }; -} // namespace atm90e32 -} // namespace esphome +} // namespace esphome::atm90e32 diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index b977c4e918..b0aa3c1abb 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace audio { +namespace esphome::audio { // Euclidean's algorithm for finding the greatest common divisor static uint32_t gcd(uint32_t a, uint32_t b) { @@ -129,5 +128,4 @@ void scale_audio_samples(const int16_t *audio_samples, int16_t *output_buffer, i } } -} // namespace audio -} // namespace esphome +} // namespace esphome::audio diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h index 9259f0a3c6..62c57b18cf 100644 --- a/esphome/components/audio/audio.h +++ b/esphome/components/audio/audio.h @@ -5,8 +5,7 @@ #include #include -namespace esphome { -namespace audio { +namespace esphome::audio { class AudioStreamInfo { /* Class to respresent important parameters of the audio stream that also provides helper function to convert between @@ -195,5 +194,4 @@ inline void pack_q31_as_audio_sample(int32_t sample, uint8_t *data, size_t bytes } } -} // namespace audio -} // namespace esphome +} // namespace esphome::audio diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index 7abd03a36e..3e6fad1101 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -5,8 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace audio { +namespace esphome::audio { static const char *const TAG = "audio.decoder"; @@ -428,7 +427,6 @@ FileDecoderState AudioDecoder::decode_wav_() { } #endif -} // namespace audio -} // namespace esphome +} // namespace esphome::audio #endif diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h index 58e982317c..7ea7a824f9 100644 --- a/esphome/components/audio/audio_decoder.h +++ b/esphome/components/audio/audio_decoder.h @@ -35,8 +35,7 @@ #include #endif -namespace esphome { -namespace audio { +namespace esphome::audio { enum class AudioDecoderState : uint8_t { DECODING = 0, // More data is available to decode @@ -155,7 +154,6 @@ class AudioDecoder { bool pause_output_{false}; }; -} // namespace audio -} // namespace esphome +} // namespace esphome::audio #endif diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index 79ebf58889..500f20533c 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -11,8 +11,7 @@ #include "esp_crt_bundle.h" #endif -namespace esphome { -namespace audio { +namespace esphome::audio { static const uint32_t READ_WRITE_TIMEOUT_MS = 20; @@ -289,7 +288,6 @@ void AudioReader::cleanup_connection_() { } } -} // namespace audio -} // namespace esphome +} // namespace esphome::audio #endif diff --git a/esphome/components/audio/audio_reader.h b/esphome/components/audio/audio_reader.h index 753b310213..61f187d151 100644 --- a/esphome/components/audio/audio_reader.h +++ b/esphome/components/audio/audio_reader.h @@ -11,8 +11,7 @@ #include -namespace esphome { -namespace audio { +namespace esphome::audio { enum class AudioReaderState : uint8_t { READING = 0, // More data is available to read @@ -74,7 +73,6 @@ class AudioReader { AudioFileType audio_file_type_{AudioFileType::NONE}; const uint8_t *file_current_{nullptr}; }; -} // namespace audio -} // namespace esphome +} // namespace esphome::audio #endif diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp index 20d246f1e0..ac1039971e 100644 --- a/esphome/components/audio/audio_resampler.cpp +++ b/esphome/components/audio/audio_resampler.cpp @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace audio { +namespace esphome::audio { static const uint32_t READ_WRITE_TIMEOUT_MS = 20; @@ -157,7 +156,6 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d return AudioResamplerState::RESAMPLING; } -} // namespace audio -} // namespace esphome +} // namespace esphome::audio #endif diff --git a/esphome/components/audio/audio_resampler.h b/esphome/components/audio/audio_resampler.h index 082ade3371..e7503d1de0 100644 --- a/esphome/components/audio/audio_resampler.h +++ b/esphome/components/audio/audio_resampler.h @@ -17,8 +17,7 @@ #include // esp-audio-libs -namespace esphome { -namespace audio { +namespace esphome::audio { enum class AudioResamplerState : uint8_t { RESAMPLING, // More data is available to resample @@ -96,7 +95,6 @@ class AudioResampler { std::unique_ptr resampler_; }; -} // namespace audio -} // namespace esphome +} // namespace esphome::audio #endif diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index 5cd7cf9e63..6ee9e4d28c 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -6,8 +6,7 @@ #include "esphome/core/helpers.h" -namespace esphome { -namespace audio { +namespace esphome::audio { AudioTransferBuffer::~AudioTransferBuffer() { this->deallocate_buffer_(); }; @@ -208,7 +207,6 @@ void ConstAudioSourceBuffer::consume(size_t bytes) { this->data_start_ += bytes; } -} // namespace audio -} // namespace esphome +} // namespace esphome::audio #endif diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index c32d4d0e41..7aa830fafa 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -12,8 +12,7 @@ #include -namespace esphome { -namespace audio { +namespace esphome::audio { /// @brief Abstract interface for writing decoded audio data to a sink. class AudioSinkCallback { @@ -213,7 +212,6 @@ class ConstAudioSourceBuffer : public AudioReadableBuffer { size_t length_{0}; }; -} // namespace audio -} // namespace esphome +} // namespace esphome::audio #endif diff --git a/esphome/components/audio_adc/audio_adc.h b/esphome/components/audio_adc/audio_adc.h index 94bfb57db5..a1da2360ac 100644 --- a/esphome/components/audio_adc/audio_adc.h +++ b/esphome/components/audio_adc/audio_adc.h @@ -3,8 +3,7 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" -namespace esphome { -namespace audio_adc { +namespace esphome::audio_adc { class AudioAdc { public: @@ -13,5 +12,4 @@ class AudioAdc { virtual float mic_gain() = 0; }; -} // namespace audio_adc -} // namespace esphome +} // namespace esphome::audio_adc diff --git a/esphome/components/audio_adc/automation.h b/esphome/components/audio_adc/automation.h index 0c42468479..e74e023203 100644 --- a/esphome/components/audio_adc/automation.h +++ b/esphome/components/audio_adc/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "audio_adc.h" -namespace esphome { -namespace audio_adc { +namespace esphome::audio_adc { template class SetMicGainAction : public Action { public: @@ -19,5 +18,4 @@ template class SetMicGainAction : public Action { AudioAdc *audio_adc_; }; -} // namespace audio_adc -} // namespace esphome +} // namespace esphome::audio_adc diff --git a/esphome/components/audio_dac/audio_dac.h b/esphome/components/audio_dac/audio_dac.h index a62d17b849..16a422f4ac 100644 --- a/esphome/components/audio_dac/audio_dac.h +++ b/esphome/components/audio_dac/audio_dac.h @@ -3,8 +3,7 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" -namespace esphome { -namespace audio_dac { +namespace esphome::audio_dac { class AudioDac { public: @@ -19,5 +18,4 @@ class AudioDac { bool is_muted_{false}; }; -} // namespace audio_dac -} // namespace esphome +} // namespace esphome::audio_dac diff --git a/esphome/components/audio_dac/automation.h b/esphome/components/audio_dac/automation.h index 3eb3441f3d..67bbc78ac2 100644 --- a/esphome/components/audio_dac/automation.h +++ b/esphome/components/audio_dac/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "audio_dac.h" -namespace esphome { -namespace audio_dac { +namespace esphome::audio_dac { template class MuteOffAction : public Action { public: @@ -39,5 +38,4 @@ template class SetVolumeAction : public Action { AudioDac *audio_dac_; }; -} // namespace audio_dac -} // namespace esphome +} // namespace esphome::audio_dac diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp index ab3f1dad4f..3869224b91 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace axs15231 { +namespace esphome::axs15231 { static const char *const TAG = "ax15231.touchscreen"; @@ -64,5 +63,4 @@ void AXS15231Touchscreen::dump_config() { this->x_raw_max_, this->y_raw_max_); } -} // namespace axs15231 -} // namespace esphome +} // namespace esphome::axs15231 diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h index a55c5c0d32..94d232777c 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace axs15231 { +namespace esphome::axs15231 { class AXS15231Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { public: @@ -23,5 +22,4 @@ class AXS15231Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevi GPIOPin *reset_pin_{}; }; -} // namespace axs15231 -} // namespace esphome +} // namespace esphome::axs15231 diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index 7be26efa7f..160d22a5b6 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace b_parasite { +namespace esphome::b_parasite { static const char *const TAG = "b_parasite"; @@ -113,7 +112,6 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return true; } -} // namespace b_parasite -} // namespace esphome +} // 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 7dd08968ec..c719599b99 100644 --- a/esphome/components/b_parasite/b_parasite.h +++ b/esphome/components/b_parasite/b_parasite.h @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace b_parasite { +namespace esphome::b_parasite { class BParasite : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -35,7 +34,6 @@ class BParasite : public Component, public esp32_ble_tracker::ESPBTDeviceListene sensor::Sensor *illuminance_{nullptr}; }; -} // namespace b_parasite -} // namespace esphome +} // namespace esphome::b_parasite #endif // USE_ESP32 diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp index deb742f8c6..eebc970795 100644 --- a/esphome/components/ballu/ballu.cpp +++ b/esphome/components/ballu/ballu.cpp @@ -1,8 +1,7 @@ #include "ballu.h" #include "esphome/core/log.h" -namespace esphome { -namespace ballu { +namespace esphome::ballu { static const char *const TAG = "ballu.climate"; @@ -235,5 +234,4 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) { return true; } -} // namespace ballu -} // namespace esphome +} // namespace esphome::ballu diff --git a/esphome/components/ballu/ballu.h b/esphome/components/ballu/ballu.h index 80a4699cfb..8a45d39c70 100644 --- a/esphome/components/ballu/ballu.h +++ b/esphome/components/ballu/ballu.h @@ -2,8 +2,7 @@ #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace ballu { +namespace esphome::ballu { // Support for Ballu air conditioners with YKR-K/002E remote @@ -27,5 +26,4 @@ class BalluClimate : public climate_ir::ClimateIR { bool on_receive(remote_base::RemoteReceiveData data) override; }; -} // namespace ballu -} // namespace esphome +} // namespace esphome::ballu diff --git a/esphome/components/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp index 1058bce6a4..5dfb121342 100644 --- a/esphome/components/bang_bang/bang_bang_climate.cpp +++ b/esphome/components/bang_bang/bang_bang_climate.cpp @@ -1,8 +1,7 @@ #include "bang_bang_climate.h" #include "esphome/core/log.h" -namespace esphome { -namespace bang_bang { +namespace esphome::bang_bang { static const char *const TAG = "bang_bang.climate"; @@ -231,5 +230,4 @@ BangBangClimateTargetTempConfig::BangBangClimateTargetTempConfig(float default_t float default_temperature_high) : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} -} // namespace bang_bang -} // namespace esphome +} // namespace esphome::bang_bang diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index d0ddef2848..1e5ff84883 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -5,8 +5,7 @@ #include "esphome/components/climate/climate.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace bang_bang { +namespace esphome::bang_bang { struct BangBangClimateTargetTempConfig { public: @@ -84,5 +83,4 @@ class BangBangClimate : public climate::Climate, public Component { BangBangClimateTargetTempConfig away_config_{}; }; -} // namespace bang_bang -} // namespace esphome +} // namespace esphome::bang_bang diff --git a/esphome/components/bedjet/bedjet_child.h b/esphome/components/bedjet/bedjet_child.h index 4e07745c63..5b6c5f7f25 100644 --- a/esphome/components/bedjet/bedjet_child.h +++ b/esphome/components/bedjet/bedjet_child.h @@ -3,8 +3,7 @@ #include "bedjet_codec.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { // Forward declare BedJetHub class BedJetHub; @@ -19,5 +18,4 @@ class BedJetClient : public Parented { virtual std::string describe() = 0; }; -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet diff --git a/esphome/components/bedjet/bedjet_codec.cpp b/esphome/components/bedjet/bedjet_codec.cpp index 7a959390f3..6f6242a4cd 100644 --- a/esphome/components/bedjet/bedjet_codec.cpp +++ b/esphome/components/bedjet/bedjet_codec.cpp @@ -3,8 +3,7 @@ #include #include -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { /// Converts a BedJet temp step into degrees Fahrenheit. float bedjet_temp_to_f(const uint8_t temp) { @@ -177,5 +176,4 @@ float bedjet_temp_to_c(uint8_t temp) { return temp / 2.0f; } -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet diff --git a/esphome/components/bedjet/bedjet_codec.h b/esphome/components/bedjet/bedjet_codec.h index 3936ba2315..7cf463b566 100644 --- a/esphome/components/bedjet/bedjet_codec.h +++ b/esphome/components/bedjet/bedjet_codec.h @@ -5,8 +5,7 @@ #include "bedjet_const.h" -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { struct BedjetPacket { uint8_t data_length; @@ -190,5 +189,4 @@ class BedjetCodec { /// Converts a BedJet temp step into degrees Celsius. float bedjet_temp_to_c(uint8_t temp); -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet diff --git a/esphome/components/bedjet/bedjet_const.h b/esphome/components/bedjet/bedjet_const.h index 10f403dd1a..eb777a6a83 100644 --- a/esphome/components/bedjet/bedjet_const.h +++ b/esphome/components/bedjet/bedjet_const.h @@ -2,8 +2,7 @@ #include -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { static const char *const TAG = "bedjet"; @@ -101,5 +100,4 @@ static const uint8_t BEDJET_FAN_SPEED_COUNT = 20; static constexpr const char *const BEDJET_FAN_STEP_NAMES[BEDJET_FAN_SPEED_COUNT] = BEDJET_FAN_STEP_NAMES_; -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet diff --git a/esphome/components/bedjet/bedjet_hub.cpp b/esphome/components/bedjet/bedjet_hub.cpp index fec34c5b2a..b04603b8c6 100644 --- a/esphome/components/bedjet/bedjet_hub.cpp +++ b/esphome/components/bedjet/bedjet_hub.cpp @@ -6,8 +6,7 @@ #include "esphome/core/application.h" #include -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { static const LogString *bedjet_button_to_string(BedjetButton button) { switch (button) { @@ -551,7 +550,6 @@ void BedJetHub::register_child(BedJetClient *obj) { obj->set_parent(this); } -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet #endif diff --git a/esphome/components/bedjet/bedjet_hub.h b/esphome/components/bedjet/bedjet_hub.h index 59b0af93ad..9f25f7a466 100644 --- a/esphome/components/bedjet/bedjet_hub.h +++ b/esphome/components/bedjet/bedjet_hub.h @@ -18,8 +18,7 @@ #include -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { namespace espbt = esphome::esp32_ble_tracker; @@ -172,7 +171,6 @@ class BedJetHub : public esphome::ble_client::BLEClientNode, public PollingCompo uint8_t write_notify_config_descriptor_(bool enable); }; -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet #endif diff --git a/esphome/components/bedjet/climate/bedjet_climate.cpp b/esphome/components/bedjet/climate/bedjet_climate.cpp index 88ed902a11..196d4785e9 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.cpp +++ b/esphome/components/bedjet/climate/bedjet_climate.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { using namespace esphome::climate; @@ -359,7 +358,6 @@ void BedJetClimate::update() { ESP_LOGD(TAG, "[%s] update_status result=%s", this->get_name().c_str(), result ? "true" : "false"); } -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet #endif diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index d12c2a8255..f59e67eeb7 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -10,8 +10,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { class BedJetClimate : public climate::Climate, public BedJetClient, public PollingComponent { public: @@ -72,7 +71,6 @@ class BedJetClimate : public climate::Climate, public BedJetClient, public Polli } }; -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet #endif diff --git a/esphome/components/bedjet/fan/bedjet_fan.cpp b/esphome/components/bedjet/fan/bedjet_fan.cpp index 9539e169a4..4b1bd14ae3 100644 --- a/esphome/components/bedjet/fan/bedjet_fan.cpp +++ b/esphome/components/bedjet/fan/bedjet_fan.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { using namespace esphome::fan; @@ -109,7 +108,6 @@ void BedJetFan::reset_state_() { this->state = false; this->publish_state(); } -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet #endif diff --git a/esphome/components/bedjet/fan/bedjet_fan.h b/esphome/components/bedjet/fan/bedjet_fan.h index 19db06e9d3..03f42f1438 100644 --- a/esphome/components/bedjet/fan/bedjet_fan.h +++ b/esphome/components/bedjet/fan/bedjet_fan.h @@ -10,8 +10,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { class BedJetFan : public fan::Fan, public BedJetClient, public PollingComponent { public: @@ -34,7 +33,6 @@ class BedJetFan : public fan::Fan, public BedJetClient, public PollingComponent bool update_status_(); }; -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet #endif diff --git a/esphome/components/bedjet/sensor/bedjet_sensor.cpp b/esphome/components/bedjet/sensor/bedjet_sensor.cpp index 2fda8c927f..05417bd519 100644 --- a/esphome/components/bedjet/sensor/bedjet_sensor.cpp +++ b/esphome/components/bedjet/sensor/bedjet_sensor.cpp @@ -1,8 +1,7 @@ #include "bedjet_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { std::string BedjetSensor::describe() { return "BedJet Sensor"; } @@ -30,5 +29,4 @@ void BedjetSensor::on_status(const BedjetStatusPacket *data) { } } -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet diff --git a/esphome/components/bedjet/sensor/bedjet_sensor.h b/esphome/components/bedjet/sensor/bedjet_sensor.h index 8cbaa863ee..0c3f713579 100644 --- a/esphome/components/bedjet/sensor/bedjet_sensor.h +++ b/esphome/components/bedjet/sensor/bedjet_sensor.h @@ -5,8 +5,7 @@ #include "esphome/components/bedjet/bedjet_child.h" #include "esphome/components/bedjet/bedjet_codec.h" -namespace esphome { -namespace bedjet { +namespace esphome::bedjet { class BedjetSensor : public BedJetClient, public Component { public: @@ -28,5 +27,4 @@ class BedjetSensor : public BedJetClient, public Component { sensor::Sensor *ambient_temperature_sensor_{nullptr}; }; -} // namespace bedjet -} // namespace esphome +} // namespace esphome::bedjet diff --git a/esphome/components/bh1900nux/bh1900nux.cpp b/esphome/components/bh1900nux/bh1900nux.cpp index 0e71bd6532..4f746a17d4 100644 --- a/esphome/components/bh1900nux/bh1900nux.cpp +++ b/esphome/components/bh1900nux/bh1900nux.cpp @@ -1,8 +1,7 @@ #include "esphome/core/log.h" #include "bh1900nux.h" -namespace esphome { -namespace bh1900nux { +namespace esphome::bh1900nux { static const char *const TAG = "bh1900nux.sensor"; @@ -50,5 +49,4 @@ void BH1900NUXSensor::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace bh1900nux -} // namespace esphome +} // namespace esphome::bh1900nux diff --git a/esphome/components/bh1900nux/bh1900nux.h b/esphome/components/bh1900nux/bh1900nux.h index fd7f8848d6..61d1bac268 100644 --- a/esphome/components/bh1900nux/bh1900nux.h +++ b/esphome/components/bh1900nux/bh1900nux.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace bh1900nux { +namespace esphome::bh1900nux { class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: @@ -14,5 +13,4 @@ class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i void dump_config() override; }; -} // namespace bh1900nux -} // namespace esphome +} // namespace esphome::bh1900nux diff --git a/esphome/components/binary/fan/binary_fan.cpp b/esphome/components/binary/fan/binary_fan.cpp index 17d4df095a..118d87c09d 100644 --- a/esphome/components/binary/fan/binary_fan.cpp +++ b/esphome/components/binary/fan/binary_fan.cpp @@ -1,8 +1,7 @@ #include "binary_fan.h" #include "esphome/core/log.h" -namespace esphome { -namespace binary { +namespace esphome::binary { static const char *const TAG = "binary.fan"; @@ -39,5 +38,4 @@ void BinaryFan::write_state_() { this->direction_->set_state(this->direction == fan::FanDirection::REVERSE); } -} // namespace binary -} // namespace esphome +} // namespace esphome::binary diff --git a/esphome/components/binary/fan/binary_fan.h b/esphome/components/binary/fan/binary_fan.h index 16bce2e6af..17157dd29c 100644 --- a/esphome/components/binary/fan/binary_fan.h +++ b/esphome/components/binary/fan/binary_fan.h @@ -4,8 +4,7 @@ #include "esphome/components/output/binary_output.h" #include "esphome/components/fan/fan.h" -namespace esphome { -namespace binary { +namespace esphome::binary { class BinaryFan : public Component, public fan::Fan { public: @@ -27,5 +26,4 @@ class BinaryFan : public Component, public fan::Fan { output::BinaryOutput *direction_{nullptr}; }; -} // namespace binary -} // namespace esphome +} // namespace esphome::binary diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index 8346a82cf0..f6be7e162e 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -4,8 +4,7 @@ #include "esphome/components/output/binary_output.h" #include "esphome/components/light/light_output.h" -namespace esphome { -namespace binary { +namespace esphome::binary { class BinaryLightOutput : public light::LightOutput { public: @@ -29,5 +28,4 @@ class BinaryLightOutput : public light::LightOutput { output::BinaryOutput *output_; }; -} // namespace binary -} // namespace esphome +} // namespace esphome::binary diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.cpp b/esphome/components/binary_sensor_map/binary_sensor_map.cpp index 0bf6202893..316d44ba59 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.cpp +++ b/esphome/components/binary_sensor_map/binary_sensor_map.cpp @@ -1,8 +1,7 @@ #include "binary_sensor_map.h" #include "esphome/core/log.h" -namespace esphome { -namespace binary_sensor_map { +namespace esphome::binary_sensor_map { static const char *const TAG = "binary_sensor_map"; @@ -138,5 +137,4 @@ void BinarySensorMap::add_channel(binary_sensor::BinarySensor *sensor, float pro }; this->channels_.push_back(sensor_channel); } -} // namespace binary_sensor_map -} // namespace esphome +} // namespace esphome::binary_sensor_map diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.h b/esphome/components/binary_sensor_map/binary_sensor_map.h index a07154c0e8..60224242db 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.h +++ b/esphome/components/binary_sensor_map/binary_sensor_map.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace binary_sensor_map { +namespace esphome::binary_sensor_map { enum BinarySensorMapType { BINARY_SENSOR_MAP_TYPE_GROUP, @@ -96,5 +95,4 @@ class BinarySensorMap : public sensor::Sensor, public Component { float bayesian_predicate_(bool sensor_state, float prior, float prob_given_true, float prob_given_false); }; -} // namespace binary_sensor_map -} // namespace esphome +} // namespace esphome::binary_sensor_map diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index d554057f7b..d387757051 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace bl0906 { +namespace esphome::bl0906 { static const char *const TAG = "bl0906"; @@ -262,5 +261,4 @@ void BL0906::dump_config() { LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); } -} // namespace bl0906 -} // namespace esphome +} // namespace esphome::bl0906 diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index f7ba5423d2..821aac476c 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -9,8 +9,7 @@ // https://www.belling.com.cn/media/file_object/bel_product/BL0906/datasheet/BL0906_V1.02_cn.pdf // https://www.belling.com.cn/media/file_object/bel_product/BL0906/guide/BL0906%20APP%20Note_V1.02.pdf -namespace esphome { -namespace bl0906 { +namespace esphome::bl0906 { // Stage values for the read state machine. After STAGE_CHANNEL_6 the state machine // jumps to the two sentinel stages below, then to STAGE_IDLE which marks the cycle @@ -109,5 +108,4 @@ template class ResetEnergyAction : public Action, public void play(const Ts &...x) override { this->parent_->enqueue_action_(&BL0906::reset_energy_); } }; -} // namespace bl0906 -} // namespace esphome +} // namespace esphome::bl0906 diff --git a/esphome/components/bl0906/constants.h b/esphome/components/bl0906/constants.h index a174e54bb2..423d460210 100644 --- a/esphome/components/bl0906/constants.h +++ b/esphome/components/bl0906/constants.h @@ -1,8 +1,7 @@ #pragma once #include -namespace esphome { -namespace bl0906 { +namespace esphome::bl0906 { // Total power conversion static const float BL0906_WATT = 16 * 1.097 * 1.097 * (20000 + 20000 + 20000 + 20000 + 20000) / @@ -118,5 +117,4 @@ const uint8_t BL0906_INIT[2][6] = { // Enable User Operation Write {BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}}; -} // namespace bl0906 -} // namespace esphome +} // namespace esphome::bl0906 diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index 7428e48740..9ae3360e3a 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace bl0939 { +namespace esphome::bl0939 { static const char *const TAG = "bl0939"; @@ -142,5 +141,4 @@ uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << int32_t BL0939::to_int32_t(sbe24_t input) { return input.h << 16 | input.m << 8 | input.l; } -} // namespace bl0939 -} // namespace esphome +} // namespace esphome::bl0939 diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index 673d4ff351..b4f6d42e71 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -4,8 +4,7 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace bl0939 { +namespace esphome::bl0939 { // https://datasheet.lcsc.com/lcsc/2108071830_BL-Shanghai-Belling-BL0939_C2841044.pdf // (unfortunately chinese, but the formulas can be easily understood) @@ -103,5 +102,4 @@ class BL0939 : public PollingComponent, public uart::UARTDevice { void received_package_(const DataPacket *data) const; }; -} // namespace bl0939 -} // namespace esphome +} // namespace esphome::bl0939 diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index 31625ebf6d..b7df603f2f 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace bl0940 { +namespace esphome::bl0940 { static const char *const TAG = "bl0940"; @@ -274,5 +273,4 @@ void BL0940::dump_config() { // NOLINT(readability-function-cognitive-complexit LOG_SENSOR("", "External temperature", this->external_temperature_sensor_); } -} // namespace bl0940 -} // namespace esphome +} // namespace esphome::bl0940 diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h index e0ca748a22..14cb69d0b0 100644 --- a/esphome/components/bl0940/bl0940.h +++ b/esphome/components/bl0940/bl0940.h @@ -12,8 +12,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace bl0940 { +namespace esphome::bl0940 { // Caveat: All these values are big endian (low - middle - high) struct DataPacket { @@ -148,5 +147,4 @@ class BL0940 : public PollingComponent, public uart::UARTDevice { void recalibrate_(); }; -} // namespace bl0940 -} // namespace esphome +} // namespace esphome::bl0940 diff --git a/esphome/components/bl0940/button/calibration_reset_button.cpp b/esphome/components/bl0940/button/calibration_reset_button.cpp index 79a6b872d8..a0b41b9a5b 100644 --- a/esphome/components/bl0940/button/calibration_reset_button.cpp +++ b/esphome/components/bl0940/button/calibration_reset_button.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace bl0940 { +namespace esphome::bl0940 { static const char *const TAG = "bl0940.button.calibration_reset"; @@ -16,5 +15,4 @@ void CalibrationResetButton::press_action() { this->parent_->reset_calibration(); } -} // namespace bl0940 -} // namespace esphome +} // namespace esphome::bl0940 diff --git a/esphome/components/bl0940/button/calibration_reset_button.h b/esphome/components/bl0940/button/calibration_reset_button.h index 6ea3b35cb4..d528992d58 100644 --- a/esphome/components/bl0940/button/calibration_reset_button.h +++ b/esphome/components/bl0940/button/calibration_reset_button.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/button/button.h" -namespace esphome { -namespace bl0940 { +namespace esphome::bl0940 { class BL0940; // Forward declaration of BL0940 class @@ -15,5 +14,4 @@ class CalibrationResetButton : public button::Button, public Component, public P void press_action() override; }; -} // namespace bl0940 -} // namespace esphome +} // namespace esphome::bl0940 diff --git a/esphome/components/bl0940/number/calibration_number.cpp b/esphome/components/bl0940/number/calibration_number.cpp index 5e775004bd..6f054443a2 100644 --- a/esphome/components/bl0940/number/calibration_number.cpp +++ b/esphome/components/bl0940/number/calibration_number.cpp @@ -1,8 +1,7 @@ #include "calibration_number.h" #include "esphome/core/log.h" -namespace esphome { -namespace bl0940 { +namespace esphome::bl0940 { static const char *const TAG = "bl0940.number"; @@ -25,5 +24,4 @@ void CalibrationNumber::control(float value) { void CalibrationNumber::dump_config() { LOG_NUMBER("", "Calibration Number", this); } -} // namespace bl0940 -} // namespace esphome +} // namespace esphome::bl0940 diff --git a/esphome/components/bl0940/number/calibration_number.h b/esphome/components/bl0940/number/calibration_number.h index 3a19e36dc9..062890d918 100644 --- a/esphome/components/bl0940/number/calibration_number.h +++ b/esphome/components/bl0940/number/calibration_number.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/preferences.h" -namespace esphome { -namespace bl0940 { +namespace esphome::bl0940 { class CalibrationNumber : public number::Number, public Component { public: @@ -22,5 +21,4 @@ class CalibrationNumber : public number::Number, public Component { ESPPreferenceObject pref_; }; -} // namespace bl0940 -} // namespace esphome +} // namespace esphome::bl0940 diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 074aff9643..1c57616c82 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -4,8 +4,7 @@ // Datasheet: https://www.belling.com.cn/media/file_object/bel_product/BL0942/datasheet/BL0942_V1.06_en.pdf -namespace esphome { -namespace bl0942 { +namespace esphome::bl0942 { static const char *const TAG = "bl0942"; @@ -210,5 +209,4 @@ void BL0942::dump_config() { // NOLINT(readability-function-cognitive-complexit LOG_SENSOR("", "Frequency", this->frequency_sensor_); } -} // namespace bl0942 -} // namespace esphome +} // namespace esphome::bl0942 diff --git a/esphome/components/bl0942/bl0942.h b/esphome/components/bl0942/bl0942.h index 7604399c25..c366878637 100644 --- a/esphome/components/bl0942/bl0942.h +++ b/esphome/components/bl0942/bl0942.h @@ -5,8 +5,7 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace bl0942 { +namespace esphome::bl0942 { // The BL0942 IC is "calibration-free", which means that it doesn't care // at all about calibration, and that's left to software. It measures a @@ -147,5 +146,4 @@ class BL0942 : public PollingComponent, public uart::UARTDevice { void write_reg_(uint8_t reg, uint32_t val); void received_package_(DataPacket *data); }; -} // namespace bl0942 -} // namespace esphome +} // namespace esphome::bl0942 diff --git a/esphome/components/ble_presence/ble_presence_device.cpp b/esphome/components/ble_presence/ble_presence_device.cpp index e482bb9a78..4a70648ac5 100644 --- a/esphome/components/ble_presence/ble_presence_device.cpp +++ b/esphome/components/ble_presence/ble_presence_device.cpp @@ -3,14 +3,12 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_presence { +namespace esphome::ble_presence { static const char *const TAG = "ble_presence"; void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); } -} // namespace ble_presence -} // namespace esphome +} // 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 8ae5edab3a..76e8079948 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_presence { +namespace esphome::ble_presence { class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, public esp32_ble_tracker::ESPBTDeviceListener, @@ -137,7 +136,6 @@ class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, uint32_t timeout_{}; }; -} // namespace ble_presence -} // namespace esphome +} // 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 4b37fcc6ef..f678865f47 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.cpp +++ b/esphome/components/ble_rssi/ble_rssi_sensor.cpp @@ -3,14 +3,12 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_rssi { +namespace esphome::ble_rssi { static const char *const TAG = "ble_rssi"; void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); } -} // namespace ble_rssi -} // namespace esphome +} // 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 81f21c94dd..a876fa51d2 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.h +++ b/esphome/components/ble_rssi/ble_rssi_sensor.h @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_rssi { +namespace esphome::ble_rssi { class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: @@ -120,7 +119,6 @@ class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDevi bool check_ibeacon_minor_; }; -} // namespace ble_rssi -} // namespace esphome +} // namespace esphome::ble_rssi #endif diff --git a/esphome/components/ble_scanner/ble_scanner.cpp b/esphome/components/ble_scanner/ble_scanner.cpp index f2cda227bb..d85894edc8 100644 --- a/esphome/components/ble_scanner/ble_scanner.cpp +++ b/esphome/components/ble_scanner/ble_scanner.cpp @@ -3,14 +3,12 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_scanner { +namespace esphome::ble_scanner { static const char *const TAG = "ble_scanner"; void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); } -} // namespace ble_scanner -} // namespace esphome +} // namespace esphome::ble_scanner #endif diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index c6d7f24cce..c2d48741b1 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -10,8 +10,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_scanner { +namespace esphome::ble_scanner { class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: @@ -43,7 +42,6 @@ class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESP void dump_config() override; }; -} // namespace ble_scanner -} // namespace esphome +} // namespace esphome::ble_scanner #endif diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index f31940df10..0f7e42cce3 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -9,8 +9,7 @@ #define BME280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response" -namespace esphome { -namespace bme280_base { +namespace esphome::bme280_base { static const char *const TAG = "bme280.sensor"; @@ -355,5 +354,4 @@ uint16_t BME280Component::read_u16_le_(uint8_t a_register) { } int16_t BME280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); } -} // namespace bme280_base -} // namespace esphome +} // namespace esphome::bme280_base diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index 00781d05b2..7fe5f7401d 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace bme280_base { +namespace esphome::bme280_base { /// Internal struct storing the calibration values of an BME280. struct BME280CalibrationData { @@ -109,5 +108,4 @@ class BME280Component : public PollingComponent { } error_code_{NONE}; }; -} // namespace bme280_base -} // namespace esphome +} // namespace esphome::bme280_base diff --git a/esphome/components/bme280_i2c/bme280_i2c.cpp b/esphome/components/bme280_i2c/bme280_i2c.cpp index e29675b5b7..f4380b0d34 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.cpp +++ b/esphome/components/bme280_i2c/bme280_i2c.cpp @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "../bme280_base/bme280_base.h" -namespace esphome { -namespace bme280_i2c { +namespace esphome::bme280_i2c { bool BME280I2CComponent::read_byte(uint8_t a_register, uint8_t *data) { return I2CDevice::read_byte(a_register, data); @@ -26,5 +25,4 @@ void BME280I2CComponent::dump_config() { BME280Component::dump_config(); } -} // namespace bme280_i2c -} // namespace esphome +} // namespace esphome::bme280_i2c diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index c5e2f7e342..ad4a283fc7 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -3,8 +3,7 @@ #include "esphome/components/bme280_base/bme280_base.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace bme280_i2c { +namespace esphome::bme280_i2c { static const char *const TAG = "bme280_i2c.sensor"; @@ -16,5 +15,4 @@ class BME280I2CComponent : public esphome::bme280_base::BME280Component, public void dump_config() override; }; -} // namespace bme280_i2c -} // namespace esphome +} // namespace esphome::bme280_i2c diff --git a/esphome/components/bme280_spi/bme280_spi.cpp b/esphome/components/bme280_spi/bme280_spi.cpp index c6ebfdfd0b..a4e7b7d95c 100644 --- a/esphome/components/bme280_spi/bme280_spi.cpp +++ b/esphome/components/bme280_spi/bme280_spi.cpp @@ -4,8 +4,7 @@ #include "bme280_spi.h" #include -namespace esphome { -namespace bme280_spi { +namespace esphome::bme280_spi { uint8_t set_bit(uint8_t num, int position) { int mask = 1 << position; @@ -61,5 +60,4 @@ bool BME280SPIComponent::read_byte_16(uint8_t a_register, uint16_t *data) { return true; } -} // namespace bme280_spi -} // namespace esphome +} // namespace esphome::bme280_spi diff --git a/esphome/components/bme280_spi/bme280_spi.h b/esphome/components/bme280_spi/bme280_spi.h index b6b8997fa7..4e842e9596 100644 --- a/esphome/components/bme280_spi/bme280_spi.h +++ b/esphome/components/bme280_spi/bme280_spi.h @@ -3,8 +3,7 @@ #include "esphome/components/bme280_base/bme280_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace bme280_spi { +namespace esphome::bme280_spi { class BME280SPIComponent : public esphome::bme280_base::BME280Component, public spi::SPIDeviceheater_duration_ = heater_duration; } -} // namespace bme680 -} // namespace esphome +} // namespace esphome::bme680 diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index 239823fa8c..e40daf8720 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace bme680 { +namespace esphome::bme680 { /// Enum listing all IIR Filter options for the BME680. enum BME680IIRFilter { @@ -134,5 +133,4 @@ class BME680Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *gas_resistance_sensor_{nullptr}; }; -} // namespace bme680 -} // namespace esphome +} // namespace esphome::bme680 diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index bb0417b823..b7f8c0da77 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace bme680_bsec { +namespace esphome::bme680_bsec { #ifdef USE_BSEC static const char *const TAG = "bme680_bsec.sensor"; @@ -565,5 +564,4 @@ void BME680BSECComponent::save_state_(uint8_t accuracy) { ESP_LOGI(TAG, "Saved state"); } #endif -} // namespace bme680_bsec -} // namespace esphome +} // namespace esphome::bme680_bsec diff --git a/esphome/components/bme680_bsec/bme680_bsec.h b/esphome/components/bme680_bsec/bme680_bsec.h index 22aa2789e6..742b07b59b 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.h +++ b/esphome/components/bme680_bsec/bme680_bsec.h @@ -13,8 +13,7 @@ #include #endif -namespace esphome { -namespace bme680_bsec { +namespace esphome::bme680_bsec { #ifdef USE_BSEC enum IAQMode { @@ -133,5 +132,4 @@ class BME680BSECComponent : public Component, public i2c::I2CDevice { sensor::Sensor *breath_voc_equivalent_sensor_{nullptr}; }; #endif -} // namespace bme680_bsec -} // namespace esphome +} // namespace esphome::bme680_bsec diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index ed92979d24..442ee183bf 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace bmi160 { +namespace esphome::bmi160 { static const char *const TAG = "bmi160"; static constexpr uint32_t GYRO_WAKEUP_TIMEOUT_MS = 100; @@ -265,5 +264,4 @@ void BMI160Component::update() { this->status_clear_warning(); } -} // namespace bmi160 -} // namespace esphome +} // namespace esphome::bmi160 diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index 16cab69733..e86c353eaa 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace bmi160 { +namespace esphome::bmi160 { class BMI160Component : public PollingComponent, public i2c::I2CDevice { public: @@ -38,5 +37,4 @@ class BMI160Component : public PollingComponent, public i2c::I2CDevice { i2c::ErrorCode read_le_int16_(uint8_t reg, int16_t *value, uint8_t len); }; -} // namespace bmi160 -} // namespace esphome +} // namespace esphome::bmi160 diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index 9a383b2654..f42875b208 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -1,8 +1,7 @@ #include "bmp085.h" #include "esphome/core/log.h" -namespace esphome { -namespace bmp085 { +namespace esphome::bmp085 { static const char *const TAG = "bmp085.sensor"; @@ -132,5 +131,4 @@ bool BMP085Component::set_mode_(uint8_t mode) { return this->write_byte(BMP085_REGISTER_CONTROL, mode); } -} // namespace bmp085 -} // namespace esphome +} // namespace esphome::bmp085 diff --git a/esphome/components/bmp085/bmp085.h b/esphome/components/bmp085/bmp085.h index c7315827e0..a64f3936f0 100644 --- a/esphome/components/bmp085/bmp085.h +++ b/esphome/components/bmp085/bmp085.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace bmp085 { +namespace esphome::bmp085 { class BMP085Component : public PollingComponent, public i2c::I2CDevice { public: @@ -39,5 +38,4 @@ class BMP085Component : public PollingComponent, public i2c::I2CDevice { CalibrationData calibration_; }; -} // namespace bmp085 -} // namespace esphome +} // namespace esphome::bmp085 diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 603966a2b5..1dae5a689e 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -4,8 +4,7 @@ #define BMP280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response" -namespace esphome { -namespace bmp280_base { +namespace esphome::bmp280_base { static const char *const TAG = "bmp280.sensor"; @@ -268,5 +267,4 @@ uint16_t BMP280Component::read_u16_le_(uint8_t a_register) { } int16_t BMP280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); } -} // namespace bmp280_base -} // namespace esphome +} // namespace esphome::bmp280_base diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 836eafaf8b..3bf1edab04 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace bmp280_base { +namespace esphome::bmp280_base { /// Internal struct storing the calibration values of an BMP280. struct BMP280CalibrationData { @@ -93,5 +92,4 @@ class BMP280Component : public PollingComponent { } error_code_{NONE}; }; -} // namespace bmp280_base -} // namespace esphome +} // namespace esphome::bmp280_base diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.cpp b/esphome/components/bmp280_i2c/bmp280_i2c.cpp index 75d899008d..098d1aff8b 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.cpp +++ b/esphome/components/bmp280_i2c/bmp280_i2c.cpp @@ -2,13 +2,11 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace bmp280_i2c { +namespace esphome::bmp280_i2c { void BMP280I2CComponent::dump_config() { LOG_I2C_DEVICE(this); BMP280Component::dump_config(); } -} // namespace bmp280_i2c -} // namespace esphome +} // namespace esphome::bmp280_i2c diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index 0ac956202b..bf1c2fd624 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -3,8 +3,7 @@ #include "esphome/components/bmp280_base/bmp280_base.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace bmp280_i2c { +namespace esphome::bmp280_i2c { static const char *const TAG = "bmp280_i2c.sensor"; @@ -20,5 +19,4 @@ class BMP280I2CComponent : public esphome::bmp280_base::BMP280Component, public void dump_config() override; }; -} // namespace bmp280_i2c -} // namespace esphome +} // namespace esphome::bmp280_i2c diff --git a/esphome/components/bmp280_spi/bmp280_spi.cpp b/esphome/components/bmp280_spi/bmp280_spi.cpp index 88983e77c3..04f92f9b89 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.cpp +++ b/esphome/components/bmp280_spi/bmp280_spi.cpp @@ -4,8 +4,7 @@ #include "bmp280_spi.h" #include -namespace esphome { -namespace bmp280_spi { +namespace esphome::bmp280_spi { uint8_t set_bit(uint8_t num, uint8_t position) { uint8_t mask = 1 << position; @@ -61,5 +60,4 @@ bool BMP280SPIComponent::bmp_read_byte_16(uint8_t a_register, uint16_t *data) { return true; } -} // namespace bmp280_spi -} // namespace esphome +} // namespace esphome::bmp280_spi diff --git a/esphome/components/bmp280_spi/bmp280_spi.h b/esphome/components/bmp280_spi/bmp280_spi.h index 1bb7678e55..17d3999884 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.h +++ b/esphome/components/bmp280_spi/bmp280_spi.h @@ -3,8 +3,7 @@ #include "esphome/components/bmp280_base/bmp280_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace bmp280_spi { +namespace esphome::bmp280_spi { class BMP280SPIComponent : public esphome::bmp280_base::BMP280Component, public spi::SPIDevice -namespace esphome { -namespace bmp3xx_base { +namespace esphome::bmp3xx_base { static const char *const TAG = "bmp3xx.sensor"; @@ -356,5 +355,4 @@ float BMP3XXComponent::bmp388_compensate_pressure_(float uncomp_press, float t_l return partial_out1 + partial_out2 + partial_data4; } -} // namespace bmp3xx_base -} // namespace esphome +} // namespace esphome::bmp3xx_base diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.h b/esphome/components/bmp3xx_base/bmp3xx_base.h index 8d2312231b..cd70b2fb16 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.h +++ b/esphome/components/bmp3xx_base/bmp3xx_base.h @@ -10,8 +10,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace bmp3xx_base { +namespace esphome::bmp3xx_base { static const uint8_t BMP388_ID = 0x50; // The BMP388 device ID static const uint8_t BMP390_ID = 0x60; // The BMP390 device ID @@ -237,5 +236,4 @@ class BMP3XXComponent : public PollingComponent { virtual bool write_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; }; -} // namespace bmp3xx_base -} // namespace esphome +} // namespace esphome::bmp3xx_base diff --git a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp index 7531090185..704d415993 100644 --- a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp +++ b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp @@ -2,8 +2,7 @@ #include "bmp3xx_i2c.h" #include -namespace esphome { -namespace bmp3xx_i2c { +namespace esphome::bmp3xx_i2c { static const char *const TAG = "bmp3xx_i2c.sensor"; @@ -25,5 +24,4 @@ void BMP3XXI2CComponent::dump_config() { BMP3XXComponent::dump_config(); } -} // namespace bmp3xx_i2c -} // namespace esphome +} // namespace esphome::bmp3xx_i2c diff --git a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h index d8b95cf843..bec99cf9f8 100644 --- a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h +++ b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h @@ -2,8 +2,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/bmp3xx_base/bmp3xx_base.h" -namespace esphome { -namespace bmp3xx_i2c { +namespace esphome::bmp3xx_i2c { class BMP3XXI2CComponent : public bmp3xx_base::BMP3XXComponent, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; @@ -13,5 +12,4 @@ class BMP3XXI2CComponent : public bmp3xx_base::BMP3XXComponent, public i2c::I2CD void dump_config() override; }; -} // namespace bmp3xx_i2c -} // namespace esphome +} // namespace esphome::bmp3xx_i2c diff --git a/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp b/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp index 2084530125..5657626c2d 100644 --- a/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp +++ b/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp @@ -1,8 +1,7 @@ #include "bmp3xx_spi.h" #include -namespace esphome { -namespace bmp3xx_spi { +namespace esphome::bmp3xx_spi { static const char *const TAG = "bmp3xx_spi.sensor"; @@ -53,5 +52,4 @@ bool BMP3XXSPIComponent::write_bytes(uint8_t a_register, uint8_t *data, size_t l return true; } -} // namespace bmp3xx_spi -} // namespace esphome +} // namespace esphome::bmp3xx_spi diff --git a/esphome/components/bmp3xx_spi/bmp3xx_spi.h b/esphome/components/bmp3xx_spi/bmp3xx_spi.h index 2183994abe..fa0c0e1b47 100644 --- a/esphome/components/bmp3xx_spi/bmp3xx_spi.h +++ b/esphome/components/bmp3xx_spi/bmp3xx_spi.h @@ -2,8 +2,7 @@ #include "esphome/components/bmp3xx_base/bmp3xx_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace bmp3xx_spi { +namespace esphome::bmp3xx_spi { class BMP3XXSPIComponent : public bmp3xx_base::BMP3XXComponent, public spi::SPIDevice -namespace esphome { -namespace bp1658cj { +namespace esphome::bp1658cj { class BP1658CJ : public Component { public: @@ -60,5 +59,4 @@ class BP1658CJ : public Component { bool update_{true}; }; -} // namespace bp1658cj -} // namespace esphome +} // namespace esphome::bp1658cj diff --git a/esphome/components/bp5758d/bp5758d.cpp b/esphome/components/bp5758d/bp5758d.cpp index 4f330b9c77..3cd841b2cd 100644 --- a/esphome/components/bp5758d/bp5758d.cpp +++ b/esphome/components/bp5758d/bp5758d.cpp @@ -1,8 +1,7 @@ #include "bp5758d.h" #include "esphome/core/log.h" -namespace esphome { -namespace bp5758d { +namespace esphome::bp5758d { static const char *const TAG = "bp5758d"; @@ -164,5 +163,4 @@ void BP5758D::write_buffer_(uint8_t *buffer, uint8_t size) { delayMicroseconds(BP5758D_DELAY); } -} // namespace bp5758d -} // namespace esphome +} // namespace esphome::bp5758d diff --git a/esphome/components/bp5758d/bp5758d.h b/esphome/components/bp5758d/bp5758d.h index cc7cc3d5f8..f07d51fe51 100644 --- a/esphome/components/bp5758d/bp5758d.h +++ b/esphome/components/bp5758d/bp5758d.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include -namespace esphome { -namespace bp5758d { +namespace esphome::bp5758d { class BP5758D : public Component { public: @@ -60,5 +59,4 @@ class BP5758D : public Component { bool update_{true}; }; -} // namespace bp5758d -} // namespace esphome +} // namespace esphome::bp5758d diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 32278dbfbd..ff12e6157d 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -17,8 +17,7 @@ #include "mbedtls/ccm.h" #endif -namespace esphome { -namespace bthome_mithermometer { +namespace esphome::bthome_mithermometer { static const char *const TAG = "bthome_mithermometer"; static constexpr size_t BTHOME_BINDKEY_SIZE = 16; @@ -434,7 +433,6 @@ bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceD return reported; } -} // namespace bthome_mithermometer -} // namespace esphome +} // namespace esphome::bthome_mithermometer #endif diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index ef3038ec93..9bec8ba7a1 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -10,8 +10,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace bthome_mithermometer { +namespace esphome::bthome_mithermometer { class BTHomeMiThermometer : public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: @@ -45,7 +44,6 @@ class BTHomeMiThermometer : public esp32_ble_tracker::ESPBTDeviceListener, publi sensor::Sensor *signal_strength_{nullptr}; }; -} // namespace bthome_mithermometer -} // namespace esphome +} // namespace esphome::bthome_mithermometer #endif diff --git a/esphome/components/bytebuffer/bytebuffer.h b/esphome/components/bytebuffer/bytebuffer.h index 3c68094dbc..f17b987394 100644 --- a/esphome/components/bytebuffer/bytebuffer.h +++ b/esphome/components/bytebuffer/bytebuffer.h @@ -6,8 +6,7 @@ #include #include "esphome/core/helpers.h" -namespace esphome { -namespace bytebuffer { +namespace esphome::bytebuffer { enum Endian { LITTLE, BIG }; @@ -417,5 +416,4 @@ class ByteBuffer { size_t limit_{0}; }; -} // namespace bytebuffer -} // namespace esphome +} // namespace esphome::bytebuffer diff --git a/esphome/components/camera/camera.cpp b/esphome/components/camera/camera.cpp index 66b8138f38..d35f7a4793 100644 --- a/esphome/components/camera/camera.cpp +++ b/esphome/components/camera/camera.cpp @@ -1,7 +1,6 @@ #include "camera.h" -namespace esphome { -namespace camera { +namespace esphome::camera { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) Camera *Camera::global_camera = nullptr; @@ -18,5 +17,4 @@ Camera::Camera() { Camera *Camera::instance() { return global_camera; } -} // namespace camera -} // namespace esphome +} // namespace esphome::camera diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index 6e1fc8cc06..bf80b42e54 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -5,8 +5,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace camera { +namespace esphome::camera { /** Different sources for filtering. * IDLE: Camera requests to send an image to the API. @@ -134,5 +133,4 @@ class Camera : public EntityBase, public Component { static Camera *global_camera; }; -} // namespace camera -} // namespace esphome +} // namespace esphome::camera diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index ce48bfbba5..c5ba59a645 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -2,8 +2,7 @@ #include #include "esphome/core/log.h" -namespace esphome { -namespace canbus { +namespace esphome::canbus { static const char *const TAG = "canbus"; @@ -103,5 +102,4 @@ void Canbus::loop() { } } -} // namespace canbus -} // namespace esphome +} // namespace esphome::canbus diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 420125e1d3..691d7384f1 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -7,8 +7,7 @@ #include #include -namespace esphome { -namespace canbus { +namespace esphome::canbus { enum Error : uint8_t { ERROR_OK = 0, @@ -177,5 +176,4 @@ class CanbusTrigger : public Trigger, uint32_t, bool>, publ optional remote_transmission_request_{}; }; -} // namespace canbus -} // namespace esphome +} // namespace esphome::canbus diff --git a/esphome/components/cap1188/cap1188.cpp b/esphome/components/cap1188/cap1188.cpp index 64bdc620cd..125a4aba51 100644 --- a/esphome/components/cap1188/cap1188.cpp +++ b/esphome/components/cap1188/cap1188.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace cap1188 { +namespace esphome::cap1188 { static const char *const TAG = "cap1188"; @@ -100,5 +99,4 @@ void CAP1188Component::loop() { } } -} // namespace cap1188 -} // namespace esphome +} // namespace esphome::cap1188 diff --git a/esphome/components/cap1188/cap1188.h b/esphome/components/cap1188/cap1188.h index 297c601b05..848e6fe430 100644 --- a/esphome/components/cap1188/cap1188.h +++ b/esphome/components/cap1188/cap1188.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace cap1188 { +namespace esphome::cap1188 { enum { CAP1188_I2CADDR = 0x29, @@ -67,5 +66,4 @@ class CAP1188Component : public Component, public i2c::I2CDevice { } error_code_{NONE}; }; -} // namespace cap1188 -} // namespace esphome +} // namespace esphome::cap1188 diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 183f16c5f8..365e5f64db 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -5,8 +5,7 @@ #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" -namespace esphome { -namespace captive_portal { +namespace esphome::captive_portal { static const char *const TAG = "captive_portal"; @@ -135,6 +134,6 @@ void CaptivePortal::dump_config() { ESP_LOGCONFIG(TAG, "Captive Portal:"); } CaptivePortal *global_captive_portal = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace captive_portal -} // namespace esphome +} // namespace esphome::captive_portal + #endif diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 0c63a3670a..8c8b43e608 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -12,9 +12,7 @@ #include "esphome/core/preferences.h" #include "esphome/components/web_server_base/web_server_base.h" -namespace esphome { - -namespace captive_portal { +namespace esphome::captive_portal { class CaptivePortal : public AsyncWebHandler, public Component { public: @@ -69,6 +67,6 @@ class CaptivePortal : public AsyncWebHandler, public Component { extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace captive_portal -} // namespace esphome +} // namespace esphome::captive_portal + #endif diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index 9ff01b32b2..afaeac55aa 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace ccs811 { +namespace esphome::ccs811 { static const char *const TAG = "ccs811"; @@ -186,5 +185,4 @@ void CCS811Component::dump_config() { } } -} // namespace ccs811 -} // namespace esphome +} // namespace esphome::ccs811 diff --git a/esphome/components/ccs811/ccs811.h b/esphome/components/ccs811/ccs811.h index 675ba7da97..fde2494753 100644 --- a/esphome/components/ccs811/ccs811.h +++ b/esphome/components/ccs811/ccs811.h @@ -6,8 +6,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ccs811 { +namespace esphome::ccs811 { class CCS811Component : public PollingComponent, public i2c::I2CDevice { public: @@ -51,5 +50,4 @@ class CCS811Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *temperature_{nullptr}; }; -} // namespace ccs811 -} // namespace esphome +} // namespace esphome::ccs811 diff --git a/esphome/components/cd74hc4067/cd74hc4067.cpp b/esphome/components/cd74hc4067/cd74hc4067.cpp index 302c83d7d3..c275aca75f 100644 --- a/esphome/components/cd74hc4067/cd74hc4067.cpp +++ b/esphome/components/cd74hc4067/cd74hc4067.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace cd74hc4067 { +namespace esphome::cd74hc4067 { static const char *const TAG = "cd74hc4067"; @@ -81,5 +80,4 @@ void CD74HC4067Sensor::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace cd74hc4067 -} // namespace esphome +} // namespace esphome::cd74hc4067 diff --git a/esphome/components/cd74hc4067/cd74hc4067.h b/esphome/components/cd74hc4067/cd74hc4067.h index 76ebc1ebbe..f41b5e294a 100644 --- a/esphome/components/cd74hc4067/cd74hc4067.h +++ b/esphome/components/cd74hc4067/cd74hc4067.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/voltage_sampler/voltage_sampler.h" -namespace esphome { -namespace cd74hc4067 { +namespace esphome::cd74hc4067 { class CD74HC4067Component : public Component { public: @@ -60,5 +59,4 @@ class CD74HC4067Sensor : public sensor::Sensor, public PollingComponent, public uint8_t pin_; }; -} // namespace cd74hc4067 -} // namespace esphome +} // namespace esphome::cd74hc4067 diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index fc856cd563..85603f46f4 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -1,8 +1,7 @@ #include "ch422g.h" #include "esphome/core/log.h" -namespace esphome { -namespace ch422g { +namespace esphome::ch422g { static const uint8_t CH422G_REG_MODE = 0x24; static const uint8_t CH422G_MODE_OUTPUT = 0x01; // enables output mode on 0-7 @@ -136,5 +135,4 @@ void CH422GGPIOPin::set_flags(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -} // namespace ch422g -} // namespace esphome +} // namespace esphome::ch422g diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index 6e6bdad64a..f74e0c46a4 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ch422g { +namespace esphome::ch422g { class CH422GComponent : public Component, public i2c::I2CDevice { public: @@ -65,5 +64,4 @@ class CH422GGPIOPin : public GPIOPin { gpio::Flags flags_{}; }; -} // namespace ch422g -} // namespace esphome +} // namespace esphome::ch422g diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.cpp b/esphome/components/chsc6x/chsc6x_touchscreen.cpp index 941144e451..297e7b65a2 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.cpp +++ b/esphome/components/chsc6x/chsc6x_touchscreen.cpp @@ -1,7 +1,6 @@ #include "chsc6x_touchscreen.h" -namespace esphome { -namespace chsc6x { +namespace esphome::chsc6x { void CHSC6XTouchscreen::setup() { if (this->interrupt_pin_ != nullptr) { @@ -42,5 +41,4 @@ void CHSC6XTouchscreen::dump_config() { LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); } -} // namespace chsc6x -} // namespace esphome +} // namespace esphome::chsc6x diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.h b/esphome/components/chsc6x/chsc6x_touchscreen.h index 25b79ad34a..32077b3d33 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.h +++ b/esphome/components/chsc6x/chsc6x_touchscreen.h @@ -6,8 +6,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace chsc6x { +namespace esphome::chsc6x { static const char *const TAG = "chsc6x.touchscreen"; @@ -30,5 +29,4 @@ class CHSC6XTouchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice InternalGPIOPin *interrupt_pin_{}; }; -} // namespace chsc6x -} // namespace esphome +} // namespace esphome::chsc6x diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index cc291ff17c..a8edaae6ea 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -1,8 +1,7 @@ #include "climate_ir.h" #include "esphome/core/log.h" -namespace esphome { -namespace climate_ir { +namespace esphome::climate_ir { static const char *const TAG = "climate_ir"; @@ -100,5 +99,4 @@ void ClimateIR::dump_config() { YESNO(this->supports_cool_)); } -} // namespace climate_ir -} // namespace esphome +} // namespace esphome::climate_ir diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index ac76d33853..6c49b31030 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -7,8 +7,7 @@ #include "esphome/components/remote_transmitter/remote_transmitter.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace climate_ir { +namespace esphome::climate_ir { /* A base for climate which works by sending (and receiving) IR codes @@ -71,5 +70,4 @@ class ClimateIR : public Component, sensor::Sensor *humidity_sensor_{nullptr}; }; -} // namespace climate_ir -} // namespace esphome +} // namespace esphome::climate_ir diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.cpp b/esphome/components/climate_ir_lg/climate_ir_lg.cpp index 90e3d006a8..588566dd9d 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.cpp +++ b/esphome/components/climate_ir_lg/climate_ir_lg.cpp @@ -1,8 +1,7 @@ #include "climate_ir_lg.h" #include "esphome/core/log.h" -namespace esphome { -namespace climate_ir_lg { +namespace esphome::climate_ir_lg { static const char *const TAG = "climate.climate_ir_lg"; @@ -218,5 +217,4 @@ void LgIrClimate::calc_checksum_(uint32_t &value) { value |= (sum & mask); } -} // namespace climate_ir_lg -} // namespace esphome +} // namespace esphome::climate_ir_lg diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.h b/esphome/components/climate_ir_lg/climate_ir_lg.h index 958245279f..a09da65ac6 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.h +++ b/esphome/components/climate_ir_lg/climate_ir_lg.h @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace climate_ir_lg { +namespace esphome::climate_ir_lg { // Temperature const uint8_t TEMP_MIN = 18; // Celsius @@ -54,5 +53,4 @@ class LgIrClimate : public climate_ir::ClimateIR { climate::ClimateMode mode_before_{climate::CLIMATE_MODE_OFF}; }; -} // namespace climate_ir_lg -} // namespace esphome +} // namespace esphome::climate_ir_lg diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index d88ea2e1da..7e5d25b7ae 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace cm1106 { +namespace esphome::cm1106 { static const char *const TAG = "cm1106"; static const uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; @@ -107,5 +106,4 @@ void CM1106Component::dump_config() { } } -} // namespace cm1106 -} // namespace esphome +} // namespace esphome::cm1106 diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index 8c33e56457..047e91d632 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace cm1106 { +namespace esphome::cm1106 { class CM1106Component : public PollingComponent, public uart::UARTDevice { public: @@ -34,5 +33,4 @@ template class CM1106CalibrateZeroAction : public Action CM1106Component *cm1106_; }; -} // namespace cm1106 -} // namespace esphome +} // namespace esphome::cm1106 diff --git a/esphome/components/color_temperature/ct_light_output.h b/esphome/components/color_temperature/ct_light_output.h index 4ff86c8b80..a4da1011b0 100644 --- a/esphome/components/color_temperature/ct_light_output.h +++ b/esphome/components/color_temperature/ct_light_output.h @@ -4,8 +4,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/core/component.h" -namespace esphome { -namespace color_temperature { +namespace esphome::color_temperature { class CTLightOutput : public light::LightOutput { public: @@ -34,5 +33,4 @@ class CTLightOutput : public light::LightOutput { float warm_white_temperature_; }; -} // namespace color_temperature -} // namespace esphome +} // namespace esphome::color_temperature diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index b858eee4ee..ddf1a105e0 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace combination { +namespace esphome::combination { static const char *const TAG = "combination"; @@ -267,5 +266,4 @@ void SumCombinationComponent::handle_new_value(float value) { this->publish_state(sum); } -} // namespace combination -} // namespace esphome +} // namespace esphome::combination diff --git a/esphome/components/combination/combination.h b/esphome/components/combination/combination.h index 463eedc564..34e9e4e2c6 100644 --- a/esphome/components/combination/combination.h +++ b/esphome/components/combination/combination.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace combination { +namespace esphome::combination { class CombinationComponent : public Component, public sensor::Sensor { public: @@ -143,5 +142,4 @@ class SumCombinationComponent : public CombinationNoParameterComponent { void handle_new_value(float value) override; }; -} // namespace combination -} // namespace esphome +} // namespace esphome::combination diff --git a/esphome/components/coolix/coolix.cpp b/esphome/components/coolix/coolix.cpp index d8ea676478..56eb43cd82 100644 --- a/esphome/components/coolix/coolix.cpp +++ b/esphome/components/coolix/coolix.cpp @@ -2,8 +2,7 @@ #include "esphome/components/remote_base/coolix_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace coolix { +namespace esphome::coolix { static const char *const TAG = "coolix.climate"; @@ -159,5 +158,4 @@ bool CoolixClimate::on_coolix(climate::Climate *parent, remote_base::RemoteRecei return true; } -} // namespace coolix -} // namespace esphome +} // namespace esphome::coolix diff --git a/esphome/components/coolix/coolix.h b/esphome/components/coolix/coolix.h index 51ddcdf8f2..2d8862e2b6 100644 --- a/esphome/components/coolix/coolix.h +++ b/esphome/components/coolix/coolix.h @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace coolix { +namespace esphome::coolix { // Temperature const uint8_t COOLIX_TEMP_MIN = 17; // Celsius @@ -42,5 +41,4 @@ class CoolixClimate : public climate_ir::ClimateIR { bool send_swing_cmd_{false}; }; -} // namespace coolix -} // namespace esphome +} // namespace esphome::coolix diff --git a/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp b/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp index 0d96f58750..b084b0080a 100644 --- a/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp +++ b/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp @@ -1,8 +1,7 @@ #include "copy_binary_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.binary_sensor"; @@ -14,5 +13,4 @@ void CopyBinarySensor::setup() { void CopyBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Copy Binary Sensor", this); } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/binary_sensor/copy_binary_sensor.h b/esphome/components/copy/binary_sensor/copy_binary_sensor.h index fc1e368b38..a6ce705a2a 100644 --- a/esphome/components/copy/binary_sensor/copy_binary_sensor.h +++ b/esphome/components/copy/binary_sensor/copy_binary_sensor.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopyBinarySensor : public binary_sensor::BinarySensor, public Component { public: @@ -16,5 +15,4 @@ class CopyBinarySensor : public binary_sensor::BinarySensor, public Component { binary_sensor::BinarySensor *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/button/copy_button.cpp b/esphome/components/copy/button/copy_button.cpp index 595388775c..297e1b0c94 100644 --- a/esphome/components/copy/button/copy_button.cpp +++ b/esphome/components/copy/button/copy_button.cpp @@ -1,8 +1,7 @@ #include "copy_button.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.button"; @@ -10,5 +9,4 @@ void CopyButton::dump_config() { LOG_BUTTON("", "Copy Button", this); } void CopyButton::press_action() { source_->press(); } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/button/copy_button.h b/esphome/components/copy/button/copy_button.h index 79d5dbcf04..afd783375d 100644 --- a/esphome/components/copy/button/copy_button.h +++ b/esphome/components/copy/button/copy_button.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/button/button.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopyButton : public button::Button, public Component { public: @@ -17,5 +16,4 @@ class CopyButton : public button::Button, public Component { button::Button *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/cover/copy_cover.cpp b/esphome/components/copy/cover/copy_cover.cpp index c139869d8f..3412784cc7 100644 --- a/esphome/components/copy/cover/copy_cover.cpp +++ b/esphome/components/copy/cover/copy_cover.cpp @@ -1,8 +1,7 @@ #include "copy_cover.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.cover"; @@ -50,5 +49,4 @@ void CopyCover::control(const cover::CoverCall &call) { call2.perform(); } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/cover/copy_cover.h b/esphome/components/copy/cover/copy_cover.h index ec27b6782a..0b493e4c3b 100644 --- a/esphome/components/copy/cover/copy_cover.h +++ b/esphome/components/copy/cover/copy_cover.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/cover/cover.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopyCover : public cover::Cover, public Component { public: @@ -20,5 +19,4 @@ class CopyCover : public cover::Cover, public Component { cover::Cover *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index bdaa35c467..6eed787b05 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -1,8 +1,7 @@ #include "copy_fan.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.fan"; @@ -69,5 +68,4 @@ void CopyFan::control(const fan::FanCall &call) { call2.perform(); } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/fan/copy_fan.h b/esphome/components/copy/fan/copy_fan.h index 988129f07b..9090c91095 100644 --- a/esphome/components/copy/fan/copy_fan.h +++ b/esphome/components/copy/fan/copy_fan.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/fan/fan.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopyFan : public fan::Fan, public Component { public: @@ -21,5 +20,4 @@ class CopyFan : public fan::Fan, public Component { fan::Fan *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/lock/copy_lock.cpp b/esphome/components/copy/lock/copy_lock.cpp index c846954510..5fdb9a757b 100644 --- a/esphome/components/copy/lock/copy_lock.cpp +++ b/esphome/components/copy/lock/copy_lock.cpp @@ -1,8 +1,7 @@ #include "copy_lock.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.lock"; @@ -25,5 +24,4 @@ void CopyLock::control(const lock::LockCall &call) { call2.perform(); } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/lock/copy_lock.h b/esphome/components/copy/lock/copy_lock.h index 8799eebb4a..c6c46467a9 100644 --- a/esphome/components/copy/lock/copy_lock.h +++ b/esphome/components/copy/lock/copy_lock.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/lock/lock.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopyLock : public lock::Lock, public Component { public: @@ -18,5 +17,4 @@ class CopyLock : public lock::Lock, public Component { lock::Lock *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/number/copy_number.cpp b/esphome/components/copy/number/copy_number.cpp index 46dc200b73..a10bd209f4 100644 --- a/esphome/components/copy/number/copy_number.cpp +++ b/esphome/components/copy/number/copy_number.cpp @@ -1,8 +1,7 @@ #include "copy_number.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.number"; @@ -25,5 +24,4 @@ void CopyNumber::control(float value) { call2.perform(); } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/number/copy_number.h b/esphome/components/copy/number/copy_number.h index 09b65e2cbf..b4d8bb83e6 100644 --- a/esphome/components/copy/number/copy_number.h +++ b/esphome/components/copy/number/copy_number.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/number/number.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopyNumber : public number::Number, public Component { public: @@ -18,5 +17,4 @@ class CopyNumber : public number::Number, public Component { number::Number *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/select/copy_select.cpp b/esphome/components/copy/select/copy_select.cpp index 227fe33182..e4f97f3c16 100644 --- a/esphome/components/copy/select/copy_select.cpp +++ b/esphome/components/copy/select/copy_select.cpp @@ -1,8 +1,7 @@ #include "copy_select.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.select"; @@ -24,5 +23,4 @@ void CopySelect::control(size_t index) { call.perform(); } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/select/copy_select.h b/esphome/components/copy/select/copy_select.h index bd74a93e82..1a17c7a55a 100644 --- a/esphome/components/copy/select/copy_select.h +++ b/esphome/components/copy/select/copy_select.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/select/select.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopySelect : public select::Select, public Component { public: @@ -18,5 +17,4 @@ class CopySelect : public select::Select, public Component { select::Select *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/sensor/copy_sensor.cpp b/esphome/components/copy/sensor/copy_sensor.cpp index a47a0cf22b..9df23ab92d 100644 --- a/esphome/components/copy/sensor/copy_sensor.cpp +++ b/esphome/components/copy/sensor/copy_sensor.cpp @@ -1,8 +1,7 @@ #include "copy_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.sensor"; @@ -14,5 +13,4 @@ void CopySensor::setup() { void CopySensor::dump_config() { LOG_SENSOR("", "Copy Sensor", this); } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/sensor/copy_sensor.h b/esphome/components/copy/sensor/copy_sensor.h index 500e6872fe..d6e5026ce1 100644 --- a/esphome/components/copy/sensor/copy_sensor.h +++ b/esphome/components/copy/sensor/copy_sensor.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopySensor : public sensor::Sensor, public Component { public: @@ -16,5 +15,4 @@ class CopySensor : public sensor::Sensor, public Component { sensor::Sensor *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/switch/copy_switch.cpp b/esphome/components/copy/switch/copy_switch.cpp index 8a9fbb03dd..91b76f11c0 100644 --- a/esphome/components/copy/switch/copy_switch.cpp +++ b/esphome/components/copy/switch/copy_switch.cpp @@ -1,8 +1,7 @@ #include "copy_switch.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.switch"; @@ -22,5 +21,4 @@ void CopySwitch::write_state(bool state) { } } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/switch/copy_switch.h b/esphome/components/copy/switch/copy_switch.h index 80310af03f..9ce6b48ed1 100644 --- a/esphome/components/copy/switch/copy_switch.h +++ b/esphome/components/copy/switch/copy_switch.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopySwitch : public switch_::Switch, public Component { public: @@ -18,5 +17,4 @@ class CopySwitch : public switch_::Switch, public Component { switch_::Switch *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/text/copy_text.cpp b/esphome/components/copy/text/copy_text.cpp index cb74201383..5ec3ba05b7 100644 --- a/esphome/components/copy/text/copy_text.cpp +++ b/esphome/components/copy/text/copy_text.cpp @@ -1,8 +1,7 @@ #include "copy_text.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.text"; @@ -21,5 +20,4 @@ void CopyText::control(const std::string &value) { call2.perform(); } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/text/copy_text.h b/esphome/components/copy/text/copy_text.h index 9eaebae4be..ad28936522 100644 --- a/esphome/components/copy/text/copy_text.h +++ b/esphome/components/copy/text/copy_text.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/text/text.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopyText : public text::Text, public Component { public: @@ -18,5 +17,4 @@ class CopyText : public text::Text, public Component { text::Text *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/text_sensor/copy_text_sensor.cpp b/esphome/components/copy/text_sensor/copy_text_sensor.cpp index 95fa6d7a1b..97182e8a61 100644 --- a/esphome/components/copy/text_sensor/copy_text_sensor.cpp +++ b/esphome/components/copy/text_sensor/copy_text_sensor.cpp @@ -1,8 +1,7 @@ #include "copy_text_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace copy { +namespace esphome::copy { static const char *const TAG = "copy.text_sensor"; @@ -14,5 +13,4 @@ void CopyTextSensor::setup() { void CopyTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Copy Sensor", this); } -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/copy/text_sensor/copy_text_sensor.h b/esphome/components/copy/text_sensor/copy_text_sensor.h index 489986c59d..dc4ef7a29d 100644 --- a/esphome/components/copy/text_sensor/copy_text_sensor.h +++ b/esphome/components/copy/text_sensor/copy_text_sensor.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace copy { +namespace esphome::copy { class CopyTextSensor : public text_sensor::TextSensor, public Component { public: @@ -16,5 +15,4 @@ class CopyTextSensor : public text_sensor::TextSensor, public Component { text_sensor::TextSensor *source_; }; -} // namespace copy -} // namespace esphome +} // namespace esphome::copy diff --git a/esphome/components/cs5460a/cs5460a.cpp b/esphome/components/cs5460a/cs5460a.cpp index e026eccf80..c9e8f3cf47 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -1,8 +1,7 @@ #include "cs5460a.h" #include "esphome/core/log.h" -namespace esphome { -namespace cs5460a { +namespace esphome::cs5460a { static const char *const TAG = "cs5460a"; @@ -339,5 +338,4 @@ void CS5460AComponent::dump_config() { LOG_SENSOR(" ", "Power", power_sensor_); } -} // namespace cs5460a -} // namespace esphome +} // namespace esphome::cs5460a diff --git a/esphome/components/cs5460a/cs5460a.h b/esphome/components/cs5460a/cs5460a.h index 99c3017510..c6b02f53ee 100644 --- a/esphome/components/cs5460a/cs5460a.h +++ b/esphome/components/cs5460a/cs5460a.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace cs5460a { +namespace esphome::cs5460a { enum CS5460ACommand { CMD_SYNC0 = 0xfe, @@ -119,5 +118,4 @@ template class CS5460ARestartAction : public Action { CS5460AComponent *cs5460a_; }; -} // namespace cs5460a -} // namespace esphome +} // namespace esphome::cs5460a diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 0ecaaced7f..4251751531 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace cse7761 { +namespace esphome::cse7761 { static const char *const TAG = "cse7761"; @@ -244,5 +243,4 @@ void CSE7761Component::get_data_() { } } -} // namespace cse7761 -} // namespace esphome +} // namespace esphome::cse7761 diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index 0e03171956..5f683f424b 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -4,8 +4,7 @@ #include "esphome/components/uart/uart.h" #include "esphome/core/component.h" -namespace esphome { -namespace cse7761 { +namespace esphome::cse7761 { struct CSE7761DataStruct { uint32_t frequency = 0; @@ -45,5 +44,4 @@ class CSE7761Component : public PollingComponent, public uart::UARTDevice { void get_data_(); }; -} // namespace cse7761 -} // namespace esphome +} // namespace esphome::cse7761 diff --git a/esphome/components/cst226/binary_sensor/cs226_button.h b/esphome/components/cst226/binary_sensor/cs226_button.h index 6d409df04f..e7e334b9bb 100644 --- a/esphome/components/cst226/binary_sensor/cs226_button.h +++ b/esphome/components/cst226/binary_sensor/cs226_button.h @@ -4,8 +4,7 @@ #include "../touchscreen/cst226_touchscreen.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace cst226 { +namespace esphome::cst226 { class CST226Button : public binary_sensor::BinarySensor, public Component, @@ -18,5 +17,4 @@ class CST226Button : public binary_sensor::BinarySensor, void update_button(bool state) override; }; -} // namespace cst226 -} // namespace esphome +} // namespace esphome::cst226 diff --git a/esphome/components/cst226/binary_sensor/cstt6_button.cpp b/esphome/components/cst226/binary_sensor/cstt6_button.cpp index c481ce5d57..c14da2b176 100644 --- a/esphome/components/cst226/binary_sensor/cstt6_button.cpp +++ b/esphome/components/cst226/binary_sensor/cstt6_button.cpp @@ -1,8 +1,7 @@ #include "cs226_button.h" #include "esphome/core/log.h" -namespace esphome { -namespace cst226 { +namespace esphome::cst226 { static const char *const TAG = "CST226.binary_sensor"; @@ -15,5 +14,4 @@ void CST226Button::dump_config() { LOG_BINARY_SENSOR("", "CST226 Button", this); void CST226Button::update_button(bool state) { this->publish_state(state); } -} // namespace cst226 -} // namespace esphome +} // namespace esphome::cst226 diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp index e65997b7fc..49d61cbfbc 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp @@ -1,7 +1,6 @@ #include "cst226_touchscreen.h" -namespace esphome { -namespace cst226 { +namespace esphome::cst226 { static const char *const TAG = "cst226.touchscreen"; @@ -110,5 +109,4 @@ void CST226Touchscreen::dump_config() { LOG_PIN(" Reset Pin: ", this->reset_pin_); } -} // namespace cst226 -} // namespace esphome +} // namespace esphome::cst226 diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.h b/esphome/components/cst226/touchscreen/cst226_touchscreen.h index c744e51fec..362eee5fc2 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.h +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.h @@ -6,8 +6,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace cst226 { +namespace esphome::cst226 { static const uint8_t CST226_REG_STATUS = 0x00; @@ -40,5 +39,4 @@ class CST226Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice bool button_touched_{}; }; -} // namespace cst226 -} // namespace esphome +} // namespace esphome::cst226 diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp index d18d4e7c94..50ebebbbb5 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp @@ -1,8 +1,7 @@ #include "cst816_touchscreen.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace cst816 { +namespace esphome::cst816 { void CST816Touchscreen::continue_setup_() { if (this->interrupt_pin_ != nullptr) { @@ -121,5 +120,4 @@ void CST816Touchscreen::dump_config() { ESP_LOGCONFIG(TAG, " Chip type: %s", name); } -} // namespace cst816 -} // namespace esphome +} // namespace esphome::cst816 diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 99b93d8342..19c169c3ec 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -6,8 +6,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace cst816 { +namespace esphome::cst816 { static const char *const TAG = "cst816.touchscreen"; @@ -57,5 +56,4 @@ class CST816Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice bool skip_probe_{}; // if set, do not expect to be able to probe the controller on the i2c bus. }; -} // namespace cst816 -} // namespace esphome +} // namespace esphome::cst816 diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.cpp b/esphome/components/ct_clamp/ct_clamp_sensor.cpp index 0aa0258a9b..613c3428be 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.cpp +++ b/esphome/components/ct_clamp/ct_clamp_sensor.cpp @@ -4,8 +4,7 @@ #include #include -namespace esphome { -namespace ct_clamp { +namespace esphome::ct_clamp { static const char *const TAG = "ct_clamp"; @@ -70,5 +69,4 @@ void CTClampSensor::loop() { this->sample_squared_sum_ += value * value; } -} // namespace ct_clamp -} // namespace esphome +} // namespace esphome::ct_clamp diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.h b/esphome/components/ct_clamp/ct_clamp_sensor.h index db4dc1ea57..2055edbd3e 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.h +++ b/esphome/components/ct_clamp/ct_clamp_sensor.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/voltage_sampler/voltage_sampler.h" -namespace esphome { -namespace ct_clamp { +namespace esphome::ct_clamp { class CTClampSensor : public sensor::Sensor, public PollingComponent { public: @@ -50,5 +49,4 @@ class CTClampSensor : public sensor::Sensor, public PollingComponent { bool is_sampling_ = false; }; -} // namespace ct_clamp -} // namespace esphome +} // namespace esphome::ct_clamp diff --git a/esphome/components/current_based/current_based_cover.cpp b/esphome/components/current_based/current_based_cover.cpp index 13bf11b991..5a499d54a4 100644 --- a/esphome/components/current_based/current_based_cover.cpp +++ b/esphome/components/current_based/current_based_cover.cpp @@ -4,8 +4,7 @@ #include "esphome/core/application.h" #include -namespace esphome { -namespace current_based { +namespace esphome::current_based { static const char *const TAG = "current_based.cover"; @@ -271,5 +270,4 @@ void CurrentBasedCover::recompute_position_() { this->last_recompute_time_ = now; } -} // namespace current_based -} // namespace esphome +} // namespace esphome::current_based diff --git a/esphome/components/current_based/current_based_cover.h b/esphome/components/current_based/current_based_cover.h index 40b39517e4..531f8d5a4f 100644 --- a/esphome/components/current_based/current_based_cover.h +++ b/esphome/components/current_based/current_based_cover.h @@ -6,8 +6,7 @@ #include "esphome/core/component.h" #include -namespace esphome { -namespace current_based { +namespace esphome::current_based { class CurrentBasedCover : public cover::Cover, public Component { public: @@ -92,5 +91,4 @@ class CurrentBasedCover : public cover::Cover, public Component { cover::CoverOperation last_operation_{cover::COVER_OPERATION_OPENING}; }; -} // namespace current_based -} // namespace esphome +} // namespace esphome::current_based diff --git a/esphome/components/cwww/cwww_light_output.h b/esphome/components/cwww/cwww_light_output.h index 2b7698ce5a..6eed8de7cc 100644 --- a/esphome/components/cwww/cwww_light_output.h +++ b/esphome/components/cwww/cwww_light_output.h @@ -4,8 +4,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/light/light_output.h" -namespace esphome { -namespace cwww { +namespace esphome::cwww { class CWWWLightOutput : public light::LightOutput { public: @@ -36,5 +35,4 @@ class CWWWLightOutput : public light::LightOutput { bool constant_brightness_; }; -} // namespace cwww -} // namespace esphome +} // namespace esphome::cwww From 4e4e4b4411e09a0c5cfa68bc4309067ba81987ba Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 7 May 2026 16:49:55 -0400 Subject: [PATCH 1114/2214] [clang-tidy] Concatenate nested namespaces (2/7: components d-h) (#16295) --- esphome/components/dac7678/dac7678_output.cpp | 6 ++---- esphome/components/dac7678/dac7678_output.h | 6 ++---- esphome/components/daikin/daikin.cpp | 6 ++---- esphome/components/daikin/daikin.h | 6 ++---- esphome/components/daikin_arc/daikin_arc.cpp | 6 ++---- esphome/components/daikin_arc/daikin_arc.h | 6 ++---- esphome/components/daikin_brc/daikin_brc.cpp | 6 ++---- esphome/components/daikin_brc/daikin_brc.h | 6 ++---- esphome/components/dallas_temp/dallas_temp.cpp | 6 ++---- esphome/components/dallas_temp/dallas_temp.h | 6 ++---- esphome/components/daly_bms/daly_bms.cpp | 6 ++---- esphome/components/daly_bms/daly_bms.h | 6 ++---- esphome/components/dashboard_import/dashboard_import.cpp | 6 ++---- esphome/components/dashboard_import/dashboard_import.h | 6 ++---- esphome/components/debug/debug_component.cpp | 6 ++---- esphome/components/debug/debug_component.h | 6 ++---- esphome/components/debug/debug_esp32.cpp | 7 +++---- esphome/components/deep_sleep/deep_sleep_component.cpp | 6 ++---- esphome/components/deep_sleep/deep_sleep_component.h | 6 ++---- esphome/components/deep_sleep/deep_sleep_esp32.cpp | 7 +++---- esphome/components/delonghi/delonghi.cpp | 6 ++---- esphome/components/delonghi/delonghi.h | 6 ++---- esphome/components/demo/demo_alarm_control_panel.h | 6 ++---- esphome/components/demo/demo_binary_sensor.h | 6 ++---- esphome/components/demo/demo_button.h | 6 ++---- esphome/components/demo/demo_climate.h | 6 ++---- esphome/components/demo/demo_cover.h | 6 ++---- esphome/components/demo/demo_date.h | 6 ++---- esphome/components/demo/demo_datetime.h | 6 ++---- esphome/components/demo/demo_fan.h | 6 ++---- esphome/components/demo/demo_light.h | 6 ++---- esphome/components/demo/demo_lock.h | 6 ++---- esphome/components/demo/demo_number.h | 6 ++---- esphome/components/demo/demo_select.h | 6 ++---- esphome/components/demo/demo_sensor.h | 6 ++---- esphome/components/demo/demo_switch.h | 6 ++---- esphome/components/demo/demo_text.h | 6 ++---- esphome/components/demo/demo_text_sensor.h | 6 ++---- esphome/components/demo/demo_time.h | 6 ++---- esphome/components/demo/demo_valve.h | 6 ++---- esphome/components/dfplayer/dfplayer.cpp | 6 ++---- esphome/components/dfplayer/dfplayer.h | 6 ++---- esphome/components/dfrobot_sen0395/automation.h | 6 ++---- esphome/components/dfrobot_sen0395/commands.cpp | 6 ++---- esphome/components/dfrobot_sen0395/commands.h | 6 ++---- esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp | 6 ++---- esphome/components/dfrobot_sen0395/dfrobot_sen0395.h | 6 ++---- .../dfrobot_sen0395/switch/dfrobot_sen0395_switch.cpp | 6 ++---- .../dfrobot_sen0395/switch/dfrobot_sen0395_switch.h | 6 ++---- esphome/components/dht12/dht12.cpp | 6 ++---- esphome/components/dht12/dht12.h | 6 ++---- esphome/components/display/display.cpp | 6 ++---- esphome/components/display/display.h | 6 ++---- esphome/components/display/display_buffer.cpp | 6 ++---- esphome/components/display/display_buffer.h | 6 ++---- esphome/components/display/display_color_utils.h | 6 ++---- esphome/components/display/rect.cpp | 6 ++---- esphome/components/display/rect.h | 6 ++---- esphome/components/display_menu_base/automation.h | 6 ++---- .../components/display_menu_base/display_menu_base.cpp | 6 ++---- esphome/components/display_menu_base/display_menu_base.h | 6 ++---- esphome/components/display_menu_base/menu_item.cpp | 6 ++---- esphome/components/display_menu_base/menu_item.h | 6 ++---- esphome/components/dps310/dps310.cpp | 6 ++---- esphome/components/dps310/dps310.h | 6 ++---- esphome/components/ds1307/ds1307.cpp | 6 ++---- esphome/components/ds1307/ds1307.h | 6 ++---- esphome/components/ds2484/ds2484.cpp | 6 ++---- esphome/components/ds2484/ds2484.h | 6 ++---- esphome/components/duty_cycle/duty_cycle_sensor.cpp | 6 ++---- esphome/components/duty_cycle/duty_cycle_sensor.h | 6 ++---- esphome/components/duty_time/duty_time_sensor.cpp | 6 ++---- esphome/components/duty_time/duty_time_sensor.h | 6 ++---- esphome/components/e131/e131.cpp | 7 +++---- esphome/components/e131/e131.h | 7 +++---- esphome/components/e131/e131_addressable_light_effect.cpp | 7 +++---- esphome/components/e131/e131_addressable_light_effect.h | 8 ++++---- esphome/components/e131/e131_packet.cpp | 7 +++---- esphome/components/ee895/ee895.cpp | 6 ++---- esphome/components/ee895/ee895.h | 6 ++---- esphome/components/ektf2232/touchscreen/ektf2232.cpp | 6 ++---- esphome/components/ektf2232/touchscreen/ektf2232.h | 6 ++---- esphome/components/emc2101/emc2101.cpp | 6 ++---- esphome/components/emc2101/emc2101.h | 6 ++---- esphome/components/emc2101/output/emc2101_output.cpp | 6 ++---- esphome/components/emc2101/output/emc2101_output.h | 6 ++---- esphome/components/emc2101/sensor/emc2101_sensor.cpp | 6 ++---- esphome/components/emc2101/sensor/emc2101_sensor.h | 6 ++---- esphome/components/emmeti/emmeti.cpp | 6 ++---- esphome/components/emmeti/emmeti.h | 6 ++---- esphome/components/endstop/endstop_cover.cpp | 6 ++---- esphome/components/endstop/endstop_cover.h | 6 ++---- esphome/components/ens160_base/ens160_base.cpp | 6 ++---- esphome/components/ens160_base/ens160_base.h | 6 ++---- esphome/components/ens160_i2c/ens160_i2c.cpp | 6 ++---- esphome/components/ens160_i2c/ens160_i2c.h | 6 ++---- esphome/components/ens160_spi/ens160_spi.cpp | 6 ++---- esphome/components/ens160_spi/ens160_spi.h | 6 ++---- esphome/components/ens210/ens210.cpp | 6 ++---- esphome/components/ens210/ens210.h | 6 ++---- esphome/components/es7210/es7210.cpp | 6 ++---- esphome/components/es7210/es7210.h | 6 ++---- esphome/components/es7210/es7210_const.h | 6 ++---- esphome/components/es7243e/es7243e.cpp | 6 ++---- esphome/components/es7243e/es7243e.h | 6 ++---- esphome/components/es7243e/es7243e_const.h | 6 ++---- esphome/components/es8156/es8156.cpp | 6 ++---- esphome/components/es8156/es8156.h | 6 ++---- esphome/components/es8156/es8156_const.h | 6 ++---- esphome/components/es8311/es8311.cpp | 6 ++---- esphome/components/es8311/es8311.h | 6 ++---- esphome/components/es8311/es8311_const.h | 6 ++---- esphome/components/es8388/es8388.cpp | 6 ++---- esphome/components/es8388/es8388.h | 6 ++---- esphome/components/es8388/es8388_const.h | 6 ++---- esphome/components/es8388/select/adc_input_mic_select.cpp | 6 ++---- esphome/components/es8388/select/adc_input_mic_select.h | 6 ++---- esphome/components/es8388/select/dac_output_select.cpp | 6 ++---- esphome/components/es8388/select/dac_output_select.h | 6 ++---- esphome/components/esp32/gpio.h | 6 ++---- esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp | 6 ++---- esphome/components/esp32_ble_beacon/esp32_ble_beacon.h | 6 ++---- esphome/components/esp32_ble_server/ble_2902.cpp | 6 ++---- esphome/components/esp32_ble_server/ble_2902.h | 6 ++---- .../components/esp32_ble_server/ble_characteristic.cpp | 6 ++---- esphome/components/esp32_ble_server/ble_characteristic.h | 6 ++---- esphome/components/esp32_ble_server/ble_descriptor.cpp | 6 ++---- esphome/components/esp32_ble_server/ble_descriptor.h | 6 ++---- esphome/components/esp32_ble_server/ble_server.cpp | 6 ++---- esphome/components/esp32_ble_server/ble_server.h | 6 ++---- .../esp32_ble_server/ble_server_automations.cpp | 8 ++------ .../components/esp32_ble_server/ble_server_automations.h | 8 ++------ esphome/components/esp32_ble_server/ble_service.cpp | 6 ++---- esphome/components/esp32_ble_server/ble_service.h | 6 ++---- esphome/components/esp32_camera/esp32_camera.cpp | 6 ++---- esphome/components/esp32_camera/esp32_camera.h | 6 ++---- .../esp32_camera_web_server/camera_web_server.cpp | 6 ++---- .../esp32_camera_web_server/camera_web_server.h | 6 ++---- esphome/components/esp32_can/esp32_can.cpp | 6 ++---- esphome/components/esp32_can/esp32_can.h | 6 ++---- esphome/components/esp32_dac/esp32_dac.cpp | 6 ++---- esphome/components/esp32_dac/esp32_dac.h | 6 ++---- esphome/components/esp32_improv/automation.h | 7 +++---- .../components/esp32_improv/esp32_improv_component.cpp | 6 ++---- esphome/components/esp32_improv/esp32_improv_component.h | 6 ++---- esphome/components/esp32_rmt_led_strip/led_strip.cpp | 6 ++---- esphome/components/esp32_rmt_led_strip/led_strip.h | 6 ++---- .../espnow/packet_transport/espnow_transport.cpp | 6 ++---- .../components/espnow/packet_transport/espnow_transport.h | 6 ++---- esphome/components/event/automation.h | 6 ++---- esphome/components/event/event.cpp | 6 ++---- esphome/components/event/event.h | 6 ++---- .../exposure_notifications/exposure_notifications.cpp | 6 ++---- .../exposure_notifications/exposure_notifications.h | 6 ++---- esphome/components/ezo/ezo.cpp | 6 ++---- esphome/components/ezo/ezo.h | 6 ++---- esphome/components/ezo_pmp/ezo_pmp.cpp | 6 ++---- esphome/components/ezo_pmp/ezo_pmp.h | 6 ++---- .../factory_reset/button/factory_reset_button.cpp | 6 ++---- .../factory_reset/button/factory_reset_button.h | 6 ++---- .../factory_reset/switch/factory_reset_switch.cpp | 6 ++---- .../factory_reset/switch/factory_reset_switch.h | 6 ++---- esphome/components/fan/automation.h | 6 ++---- esphome/components/fan/fan.cpp | 6 ++---- esphome/components/fan/fan.h | 6 ++---- esphome/components/fan/fan_traits.h | 7 ++----- esphome/components/fastled_base/fastled_light.cpp | 6 ++---- esphome/components/fastled_base/fastled_light.h | 6 ++---- esphome/components/fingerprint_grow/fingerprint_grow.cpp | 6 ++---- esphome/components/fingerprint_grow/fingerprint_grow.h | 6 ++---- esphome/components/font/font.cpp | 6 ++---- esphome/components/font/font.h | 6 ++---- esphome/components/fs3000/fs3000.cpp | 6 ++---- esphome/components/fs3000/fs3000.h | 6 ++---- .../components/ft5x06/touchscreen/ft5x06_touchscreen.cpp | 6 ++---- .../components/ft5x06/touchscreen/ft5x06_touchscreen.h | 6 ++---- esphome/components/ft63x6/ft63x6.cpp | 7 +++---- esphome/components/ft63x6/ft63x6.h | 6 ++---- esphome/components/fujitsu_general/fujitsu_general.cpp | 6 ++---- esphome/components/fujitsu_general/fujitsu_general.h | 6 ++---- esphome/components/gcja5/gcja5.cpp | 6 ++---- esphome/components/gcja5/gcja5.h | 6 ++---- esphome/components/gdk101/gdk101.cpp | 6 ++---- esphome/components/gdk101/gdk101.h | 6 ++---- esphome/components/gl_r01_i2c/gl_r01_i2c.cpp | 6 ++---- esphome/components/gl_r01_i2c/gl_r01_i2c.h | 6 ++---- esphome/components/gp2y1010au0f/gp2y1010au0f.cpp | 6 ++---- esphome/components/gp2y1010au0f/gp2y1010au0f.h | 6 ++---- esphome/components/gp8403/gp8403.cpp | 6 ++---- esphome/components/gp8403/gp8403.h | 6 ++---- esphome/components/gp8403/output/gp8403_output.cpp | 6 ++---- esphome/components/gp8403/output/gp8403_output.h | 6 ++---- .../components/gpio/binary_sensor/gpio_binary_sensor.cpp | 6 ++---- .../components/gpio/binary_sensor/gpio_binary_sensor.h | 6 ++---- esphome/components/gpio/one_wire/gpio_one_wire.cpp | 6 ++---- esphome/components/gpio/one_wire/gpio_one_wire.h | 6 ++---- esphome/components/gpio/output/gpio_binary_output.cpp | 6 ++---- esphome/components/gpio/output/gpio_binary_output.h | 6 ++---- esphome/components/gpio/switch/gpio_switch.cpp | 6 ++---- esphome/components/gpio/switch/gpio_switch.h | 6 ++---- esphome/components/gps/gps.cpp | 6 ++---- esphome/components/gps/gps.h | 6 ++---- esphome/components/gps/time/gps_time.cpp | 6 ++---- esphome/components/gps/time/gps_time.h | 6 ++---- esphome/components/graph/graph.cpp | 7 +++---- .../graphical_display_menu/graphical_display_menu.cpp | 6 ++---- esphome/components/gree/gree.cpp | 6 ++---- esphome/components/gree/switch/gree_switch.cpp | 6 ++---- esphome/components/gree/switch/gree_switch.h | 6 ++---- esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp | 6 ++---- esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h | 6 ++---- esphome/components/grove_tb6612fng/grove_tb6612fng.cpp | 6 ++---- esphome/components/grove_tb6612fng/grove_tb6612fng.h | 8 +++----- esphome/components/growatt_solar/growatt_solar.cpp | 6 ++---- esphome/components/growatt_solar/growatt_solar.h | 6 ++---- esphome/components/gt911/binary_sensor/gt911_button.cpp | 6 ++---- esphome/components/gt911/binary_sensor/gt911_button.h | 6 ++---- .../components/gt911/touchscreen/gt911_touchscreen.cpp | 6 ++---- esphome/components/gt911/touchscreen/gt911_touchscreen.h | 6 ++---- esphome/components/haier/automation.h | 6 ++---- esphome/components/haier/button/self_cleaning.cpp | 6 ++---- esphome/components/haier/button/self_cleaning.h | 6 ++---- esphome/components/haier/button/steri_cleaning.cpp | 6 ++---- esphome/components/haier/button/steri_cleaning.h | 6 ++---- esphome/components/haier/haier_base.cpp | 6 ++---- esphome/components/haier/haier_base.h | 6 ++---- esphome/components/haier/hon_climate.cpp | 6 ++---- esphome/components/haier/hon_climate.h | 6 ++---- esphome/components/haier/hon_packet.h | 8 ++------ esphome/components/haier/logger_handler.cpp | 6 ++---- esphome/components/haier/logger_handler.h | 6 ++---- esphome/components/haier/smartair2_climate.cpp | 6 ++---- esphome/components/haier/smartair2_climate.h | 6 ++---- esphome/components/haier/smartair2_packet.h | 8 ++------ esphome/components/haier/switch/beeper.cpp | 6 ++---- esphome/components/haier/switch/beeper.h | 6 ++---- esphome/components/haier/switch/display.cpp | 6 ++---- esphome/components/haier/switch/display.h | 6 ++---- esphome/components/haier/switch/health_mode.cpp | 6 ++---- esphome/components/haier/switch/health_mode.h | 6 ++---- esphome/components/haier/switch/quiet_mode.cpp | 6 ++---- esphome/components/haier/switch/quiet_mode.h | 6 ++---- esphome/components/havells_solar/havells_solar.cpp | 6 ++---- esphome/components/havells_solar/havells_solar.h | 6 ++---- .../components/havells_solar/havells_solar_registers.h | 7 +++---- esphome/components/hbridge/fan/hbridge_fan.cpp | 6 ++---- esphome/components/hbridge/fan/hbridge_fan.h | 6 ++---- esphome/components/hbridge/light/hbridge_light_output.h | 6 ++---- esphome/components/hbridge/switch/hbridge_switch.cpp | 6 ++---- esphome/components/hbridge/switch/hbridge_switch.h | 6 ++---- esphome/components/hdc1080/hdc1080.cpp | 6 ++---- esphome/components/hdc1080/hdc1080.h | 6 ++---- esphome/components/hdc2010/hdc2010.cpp | 7 +++---- esphome/components/hdc2010/hdc2010.h | 6 ++---- esphome/components/he60r/he60r.cpp | 6 ++---- esphome/components/he60r/he60r.h | 6 ++---- esphome/components/heatpumpir/heatpumpir.cpp | 6 ++---- esphome/components/heatpumpir/heatpumpir.h | 6 ++---- esphome/components/hitachi_ac344/hitachi_ac344.cpp | 6 ++---- esphome/components/hitachi_ac344/hitachi_ac344.h | 6 ++---- esphome/components/hitachi_ac424/hitachi_ac424.cpp | 6 ++---- esphome/components/hitachi_ac424/hitachi_ac424.h | 6 ++---- esphome/components/hlw8012/hlw8012.cpp | 6 ++---- esphome/components/hlw8012/hlw8012.h | 6 ++---- esphome/components/hm3301/hm3301.cpp | 6 ++---- esphome/components/hm3301/hm3301.h | 6 ++---- esphome/components/hmac_md5/hmac_md5.cpp | 7 +++---- esphome/components/hmac_md5/hmac_md5.h | 7 +++---- esphome/components/hmc5883l/hmc5883l.cpp | 6 ++---- esphome/components/hmc5883l/hmc5883l.h | 6 ++---- .../binary_sensor/homeassistant_binary_sensor.cpp | 6 ++---- .../binary_sensor/homeassistant_binary_sensor.h | 6 ++---- .../homeassistant/number/homeassistant_number.cpp | 6 ++---- .../homeassistant/number/homeassistant_number.h | 6 ++---- .../homeassistant/sensor/homeassistant_sensor.cpp | 6 ++---- .../homeassistant/sensor/homeassistant_sensor.h | 6 ++---- .../homeassistant/switch/homeassistant_switch.cpp | 6 ++---- .../homeassistant/switch/homeassistant_switch.h | 6 ++---- .../text_sensor/homeassistant_text_sensor.cpp | 6 ++---- .../homeassistant/text_sensor/homeassistant_text_sensor.h | 6 ++---- .../components/homeassistant/time/homeassistant_time.cpp | 6 ++---- .../components/homeassistant/time/homeassistant_time.h | 6 ++---- esphome/components/honeywell_hih_i2c/honeywell_hih.cpp | 6 ++---- esphome/components/honeywell_hih_i2c/honeywell_hih.h | 6 ++---- esphome/components/honeywellabp/honeywellabp.cpp | 6 ++---- esphome/components/honeywellabp/honeywellabp.h | 6 ++---- esphome/components/honeywellabp2_i2c/honeywellabp2.cpp | 6 ++---- esphome/components/honeywellabp2_i2c/honeywellabp2.h | 6 ++---- esphome/components/host/time/host_time.h | 6 ++---- esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp | 6 ++---- esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h | 6 ++---- esphome/components/hte501/hte501.cpp | 6 ++---- esphome/components/hte501/hte501.h | 6 ++---- esphome/components/http_request/ota/automation.h | 6 ++---- esphome/components/http_request/ota/ota_http_request.cpp | 6 ++---- esphome/components/http_request/ota/ota_http_request.h | 6 ++---- .../http_request/update/http_request_update.cpp | 6 ++---- .../components/http_request/update/http_request_update.h | 6 ++---- esphome/components/htu21d/htu21d.cpp | 6 ++---- esphome/components/htu21d/htu21d.h | 6 ++---- esphome/components/htu31d/htu31d.cpp | 6 ++---- esphome/components/htu31d/htu31d.h | 6 ++---- esphome/components/hx711/hx711.cpp | 6 ++---- esphome/components/hx711/hx711.h | 6 ++---- esphome/components/hydreon_rgxx/hydreon_rgxx.cpp | 6 ++---- esphome/components/hydreon_rgxx/hydreon_rgxx.h | 6 ++---- esphome/components/hyt271/hyt271.cpp | 6 ++---- esphome/components/hyt271/hyt271.h | 6 ++---- 308 files changed, 632 insertions(+), 1242 deletions(-) diff --git a/esphome/components/dac7678/dac7678_output.cpp b/esphome/components/dac7678/dac7678_output.cpp index 27ab54f0be..15575583d9 100644 --- a/esphome/components/dac7678/dac7678_output.cpp +++ b/esphome/components/dac7678/dac7678_output.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace dac7678 { +namespace esphome::dac7678 { static const char *const TAG = "dac7678"; @@ -82,5 +81,4 @@ void DAC7678Channel::write_state(float state) { this->parent_->set_channel_value_(this->channel_, input); } -} // namespace dac7678 -} // namespace esphome +} // namespace esphome::dac7678 diff --git a/esphome/components/dac7678/dac7678_output.h b/esphome/components/dac7678/dac7678_output.h index abd9875e4c..a017325939 100644 --- a/esphome/components/dac7678/dac7678_output.h +++ b/esphome/components/dac7678/dac7678_output.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace dac7678 { +namespace esphome::dac7678 { class DAC7678Output; @@ -51,5 +50,4 @@ class DAC7678Output : public Component, public i2c::I2CDevice { }; }; -} // namespace dac7678 -} // namespace esphome +} // namespace esphome::dac7678 diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index a285f3613d..d45586ba2d 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -1,8 +1,7 @@ #include "daikin.h" #include "esphome/components/remote_base/remote_base.h" -namespace esphome { -namespace daikin { +namespace esphome::daikin { static const char *const TAG = "daikin.climate"; @@ -251,5 +250,4 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { return this->parse_state_frame_(state_frame); } -} // namespace daikin -} // namespace esphome +} // namespace esphome::daikin diff --git a/esphome/components/daikin/daikin.h b/esphome/components/daikin/daikin.h index 159292cb55..9c05993f37 100644 --- a/esphome/components/daikin/daikin.h +++ b/esphome/components/daikin/daikin.h @@ -2,8 +2,7 @@ #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace daikin { +namespace esphome::daikin { // Values for Daikin ARC43XXX IR Controllers // Temperature @@ -60,5 +59,4 @@ class DaikinClimate : public climate_ir::ClimateIR { bool parse_state_frame_(const uint8_t frame[]); }; -} // namespace daikin -} // namespace esphome +} // namespace esphome::daikin diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index 18f12dbfc6..a455e2fd7f 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -5,8 +5,7 @@ #include "esphome/components/remote_base/remote_base.h" #include "esphome/core/log.h" -namespace esphome { -namespace daikin_arc { +namespace esphome::daikin_arc { static const char *const TAG = "daikin.climate"; @@ -492,5 +491,4 @@ void DaikinArcClimate::control(const climate::ClimateCall &call) { climate_ir::ClimateIR::control(call); } -} // namespace daikin_arc -} // namespace esphome +} // namespace esphome::daikin_arc diff --git a/esphome/components/daikin_arc/daikin_arc.h b/esphome/components/daikin_arc/daikin_arc.h index 2b4d4375aa..2337351e28 100644 --- a/esphome/components/daikin_arc/daikin_arc.h +++ b/esphome/components/daikin_arc/daikin_arc.h @@ -2,8 +2,7 @@ #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace daikin_arc { +namespace esphome::daikin_arc { // Values for Daikin ARC43XXX IR Controllers // Temperature @@ -73,5 +72,4 @@ class DaikinArcClimate : public climate_ir::ClimateIR { uint8_t last_humidity_{0x66}; }; -} // namespace daikin_arc -} // namespace esphome +} // namespace esphome::daikin_arc diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index 1179cb07d7..5fe3d30a85 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -1,8 +1,7 @@ #include "daikin_brc.h" #include "esphome/components/remote_base/remote_base.h" -namespace esphome { -namespace daikin_brc { +namespace esphome::daikin_brc { static const char *const TAG = "daikin_brc.climate"; @@ -269,5 +268,4 @@ bool DaikinBrcClimate::on_receive(remote_base::RemoteReceiveData data) { return this->parse_state_frame_(state_frame); } -} // namespace daikin_brc -} // namespace esphome +} // namespace esphome::daikin_brc diff --git a/esphome/components/daikin_brc/daikin_brc.h b/esphome/components/daikin_brc/daikin_brc.h index bdc6384809..4b5c679f7f 100644 --- a/esphome/components/daikin_brc/daikin_brc.h +++ b/esphome/components/daikin_brc/daikin_brc.h @@ -2,8 +2,7 @@ #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace daikin_brc { +namespace esphome::daikin_brc { // Values for Daikin BRC4CXXX IR Controllers // Temperature @@ -78,5 +77,4 @@ class DaikinBrcClimate : public climate_ir::ClimateIR { bool fahrenheit_{false}; }; -} // namespace daikin_brc -} // namespace esphome +} // namespace esphome::daikin_brc diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index f119e28e78..35488eab03 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -1,8 +1,7 @@ #include "dallas_temp.h" #include "esphome/core/log.h" -namespace esphome { -namespace dallas_temp { +namespace esphome::dallas_temp { static const char *const TAG = "dallas.temp.sensor"; @@ -159,5 +158,4 @@ float DallasTemperatureSensor::get_temp_c_() { return temp / 16.0f; } -} // namespace dallas_temp -} // namespace esphome +} // namespace esphome::dallas_temp diff --git a/esphome/components/dallas_temp/dallas_temp.h b/esphome/components/dallas_temp/dallas_temp.h index 1bd2865095..3f7b447fc7 100644 --- a/esphome/components/dallas_temp/dallas_temp.h +++ b/esphome/components/dallas_temp/dallas_temp.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/one_wire/one_wire.h" -namespace esphome { -namespace dallas_temp { +namespace esphome::dallas_temp { class DallasTemperatureSensor : public PollingComponent, public sensor::Sensor, public one_wire::OneWireDevice { public: @@ -27,5 +26,4 @@ class DallasTemperatureSensor : public PollingComponent, public sensor::Sensor, float get_temp_c_(); }; -} // namespace dallas_temp -} // namespace esphome +} // namespace esphome::dallas_temp diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index 90ccee78f8..530d8ad541 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace daly_bms { +namespace esphome::daly_bms { static const char *const TAG = "daly_bms"; @@ -321,5 +320,4 @@ void DalyBmsComponent::decode_data_(std::vector data) { } } -} // namespace daly_bms -} // namespace esphome +} // namespace esphome::daly_bms diff --git a/esphome/components/daly_bms/daly_bms.h b/esphome/components/daly_bms/daly_bms.h index 1983ba0ef1..6dcc66b5ee 100644 --- a/esphome/components/daly_bms/daly_bms.h +++ b/esphome/components/daly_bms/daly_bms.h @@ -15,8 +15,7 @@ #include -namespace esphome { -namespace daly_bms { +namespace esphome::daly_bms { class DalyBmsComponent : public PollingComponent, public uart::UARTDevice { public: @@ -88,5 +87,4 @@ class DalyBmsComponent : public PollingComponent, public uart::UARTDevice { uint8_t next_request_; }; -} // namespace daly_bms -} // namespace esphome +} // namespace esphome::daly_bms diff --git a/esphome/components/dashboard_import/dashboard_import.cpp b/esphome/components/dashboard_import/dashboard_import.cpp index d4a95b81f6..f553adf273 100644 --- a/esphome/components/dashboard_import/dashboard_import.cpp +++ b/esphome/components/dashboard_import/dashboard_import.cpp @@ -1,12 +1,10 @@ #include "dashboard_import.h" -namespace esphome { -namespace dashboard_import { +namespace esphome::dashboard_import { static const char *g_package_import_url = ""; // NOLINT const char *get_package_import_url() { return g_package_import_url; } void set_package_import_url(const char *url) { g_package_import_url = url; } -} // namespace dashboard_import -} // namespace esphome +} // namespace esphome::dashboard_import diff --git a/esphome/components/dashboard_import/dashboard_import.h b/esphome/components/dashboard_import/dashboard_import.h index 488bf80a2e..19f69b8546 100644 --- a/esphome/components/dashboard_import/dashboard_import.h +++ b/esphome/components/dashboard_import/dashboard_import.h @@ -1,10 +1,8 @@ #pragma once -namespace esphome { -namespace dashboard_import { +namespace esphome::dashboard_import { const char *get_package_import_url(); void set_package_import_url(const char *url); -} // namespace dashboard_import -} // namespace esphome +} // namespace esphome::dashboard_import diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index d97a4aa135..9020c261c2 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -9,8 +9,7 @@ #include #include -namespace esphome { -namespace debug { +namespace esphome::debug { static const char *const TAG = "debug"; @@ -93,5 +92,4 @@ void DebugComponent::update() { float DebugComponent::get_setup_priority() const { return setup_priority::LATE; } -} // namespace debug -} // namespace esphome +} // namespace esphome::debug diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index 3da6b800c6..871b7cfd25 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -13,8 +13,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #endif -namespace esphome { -namespace debug { +namespace esphome::debug { static constexpr size_t DEVICE_INFO_BUFFER_SIZE = 256; static constexpr size_t RESET_REASON_BUFFER_SIZE = 128; @@ -101,5 +100,4 @@ class DebugComponent : public PollingComponent { void update_platform_(); }; -} // namespace debug -} // namespace esphome +} // namespace esphome::debug diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index ea0c635207..7c01f9b54f 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -16,8 +16,7 @@ #include #endif -namespace esphome { -namespace debug { +namespace esphome::debug { static const char *const TAG = "debug"; @@ -311,6 +310,6 @@ void DebugComponent::update_platform_() { #endif } -} // namespace debug -} // namespace esphome +} // namespace esphome::debug + #endif // USE_ESP32 diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index d5e34b1f1c..e7ce70b60c 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -2,8 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" -namespace esphome { -namespace deep_sleep { +namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; // 5 seconds for deep sleep to ensure clean disconnect from Home Assistant @@ -81,5 +80,4 @@ void DeepSleepComponent::prevent_deep_sleep() { this->prevent_ = true; } void DeepSleepComponent::allow_deep_sleep() { this->prevent_ = false; } -} // namespace deep_sleep -} // namespace esphome +} // 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 59381eeabe..2df53f1540 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -15,8 +15,7 @@ #include -namespace esphome { -namespace deep_sleep { +namespace esphome::deep_sleep { #if defined(USE_ESP32) || defined(USE_BK72XX) @@ -244,5 +243,4 @@ template class AllowDeepSleepAction : public Action, publ void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } }; -} // namespace deep_sleep -} // namespace esphome +} // namespace esphome::deep_sleep diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 80a218e913..c905b8fcbc 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace deep_sleep { +namespace esphome::deep_sleep { // Deep Sleep feature support matrix for ESP32 variants: // @@ -167,6 +166,6 @@ void DeepSleepComponent::deep_sleep_() { bool DeepSleepComponent::should_teardown_() { return true; } -} // namespace deep_sleep -} // namespace esphome +} // namespace esphome::deep_sleep + #endif // USE_ESP32 diff --git a/esphome/components/delonghi/delonghi.cpp b/esphome/components/delonghi/delonghi.cpp index 19af703ab2..fab2d68347 100644 --- a/esphome/components/delonghi/delonghi.cpp +++ b/esphome/components/delonghi/delonghi.cpp @@ -1,8 +1,7 @@ #include "delonghi.h" #include "esphome/components/remote_base/remote_base.h" -namespace esphome { -namespace delonghi { +namespace esphome::delonghi { static const char *const TAG = "delonghi.climate"; @@ -182,5 +181,4 @@ bool DelonghiClimate::on_receive(remote_base::RemoteReceiveData data) { return this->parse_state_frame_(state_frame); } -} // namespace delonghi -} // namespace esphome +} // namespace esphome::delonghi diff --git a/esphome/components/delonghi/delonghi.h b/esphome/components/delonghi/delonghi.h index d310a58aee..c2fbc36b4f 100644 --- a/esphome/components/delonghi/delonghi.h +++ b/esphome/components/delonghi/delonghi.h @@ -2,8 +2,7 @@ #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace delonghi { +namespace esphome::delonghi { // Values for DELONGHI ARC43XXX IR Controllers const uint8_t DELONGHI_ADDRESS = 83; @@ -60,5 +59,4 @@ class DelonghiClimate : public climate_ir::ClimateIR { bool parse_state_frame_(const uint8_t frame[]); }; -} // namespace delonghi -} // namespace esphome +} // namespace esphome::delonghi diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index 5f0725dd4b..7aaf3219cf 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -3,8 +3,7 @@ #include "esphome/components/alarm_control_panel/alarm_control_panel.h" #include "esphome/core/component.h" -namespace esphome { -namespace demo { +namespace esphome::demo { using namespace alarm_control_panel; @@ -62,5 +61,4 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component { DemoAlarmControlPanelType type_{}; }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_binary_sensor.h b/esphome/components/demo/demo_binary_sensor.h index 4dfd038761..4bc3737d5a 100644 --- a/esphome/components/demo/demo_binary_sensor.h +++ b/esphome/components/demo/demo_binary_sensor.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoBinarySensor : public binary_sensor::BinarySensor, public PollingComponent { public: @@ -18,5 +17,4 @@ class DemoBinarySensor : public binary_sensor::BinarySensor, public PollingCompo bool last_state_ = false; }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_button.h b/esphome/components/demo/demo_button.h index be80d26a8a..a0ed92d3d8 100644 --- a/esphome/components/demo/demo_button.h +++ b/esphome/components/demo/demo_button.h @@ -3,13 +3,11 @@ #include "esphome/components/button/button.h" #include "esphome/core/log.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoButton : public button::Button { protected: void press_action() override {} }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_climate.h b/esphome/components/demo/demo_climate.h index c6d328b1bc..d0cd2d553d 100644 --- a/esphome/components/demo/demo_climate.h +++ b/esphome/components/demo/demo_climate.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/climate/climate.h" -namespace esphome { -namespace demo { +namespace esphome::demo { enum class DemoClimateType { TYPE_1, @@ -158,5 +157,4 @@ class DemoClimate : public climate::Climate, public Component { DemoClimateType type_; }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_cover.h b/esphome/components/demo/demo_cover.h index 69dd5a4d2d..c1597a7565 100644 --- a/esphome/components/demo/demo_cover.h +++ b/esphome/components/demo/demo_cover.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/cover/cover.h" -namespace esphome { -namespace demo { +namespace esphome::demo { enum class DemoCoverType { TYPE_1, @@ -85,5 +84,4 @@ class DemoCover : public cover::Cover, public Component { DemoCoverType type_; }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_date.h b/esphome/components/demo/demo_date.h index e09ab5f887..5a868342cd 100644 --- a/esphome/components/demo/demo_date.h +++ b/esphome/components/demo/demo_date.h @@ -7,8 +7,7 @@ #include "esphome/components/datetime/date_entity.h" #include "esphome/core/component.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoDate : public datetime::DateEntity, public Component { public: @@ -28,7 +27,6 @@ class DemoDate : public datetime::DateEntity, public Component { } }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo #endif diff --git a/esphome/components/demo/demo_datetime.h b/esphome/components/demo/demo_datetime.h index 5ebcc3e64e..84869d1a9f 100644 --- a/esphome/components/demo/demo_datetime.h +++ b/esphome/components/demo/demo_datetime.h @@ -7,8 +7,7 @@ #include "esphome/components/datetime/datetime_entity.h" #include "esphome/core/component.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoDateTime : public datetime::DateTimeEntity, public Component { public: @@ -34,7 +33,6 @@ class DemoDateTime : public datetime::DateTimeEntity, public Component { } }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo #endif diff --git a/esphome/components/demo/demo_fan.h b/esphome/components/demo/demo_fan.h index a8b397f19a..2e2fbce7d6 100644 --- a/esphome/components/demo/demo_fan.h +++ b/esphome/components/demo/demo_fan.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/fan/fan.h" -namespace esphome { -namespace demo { +namespace esphome::demo { enum class DemoFanType { TYPE_1, @@ -66,5 +65,4 @@ class DemoFan : public fan::Fan, public Component { DemoFanType type_; }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_light.h b/esphome/components/demo/demo_light.h index 2007e9ff50..071adb0831 100644 --- a/esphome/components/demo/demo_light.h +++ b/esphome/components/demo/demo_light.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/light/light_output.h" -namespace esphome { -namespace demo { +namespace esphome::demo { enum class DemoLightType { // binary @@ -64,5 +63,4 @@ class DemoLight : public light::LightOutput, public Component { DemoLightType type_; }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_lock.h b/esphome/components/demo/demo_lock.h index 1e3fd51db4..85c1c238ef 100644 --- a/esphome/components/demo/demo_lock.h +++ b/esphome/components/demo/demo_lock.h @@ -2,8 +2,7 @@ #include "esphome/components/lock/lock.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoLock : public lock::Lock { protected: @@ -14,5 +13,4 @@ class DemoLock : public lock::Lock { } }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_number.h b/esphome/components/demo/demo_number.h index 2ce3a269bc..0059cdc2ee 100644 --- a/esphome/components/demo/demo_number.h +++ b/esphome/components/demo/demo_number.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/number/number.h" -namespace esphome { -namespace demo { +namespace esphome::demo { enum class DemoNumberType { TYPE_1, @@ -35,5 +34,4 @@ class DemoNumber : public number::Number, public Component { DemoNumberType type_; }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_select.h b/esphome/components/demo/demo_select.h index 1a5df13eda..2ecb37db99 100644 --- a/esphome/components/demo/demo_select.h +++ b/esphome/components/demo/demo_select.h @@ -3,13 +3,11 @@ #include "esphome/components/select/select.h" #include "esphome/core/component.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoSelect : public select::Select, public Component { protected: void control(size_t index) override { this->publish_state(index); } }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_sensor.h b/esphome/components/demo/demo_sensor.h index d965d987de..867115f21b 100644 --- a/esphome/components/demo/demo_sensor.h +++ b/esphome/components/demo/demo_sensor.h @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoSensor : public sensor::Sensor, public PollingComponent { public: @@ -25,5 +24,4 @@ class DemoSensor : public sensor::Sensor, public PollingComponent { } }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_switch.h b/esphome/components/demo/demo_switch.h index 9c291318ca..b2d6e52c67 100644 --- a/esphome/components/demo/demo_switch.h +++ b/esphome/components/demo/demo_switch.h @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoSwitch : public switch_::Switch, public Component { public: @@ -18,5 +17,4 @@ class DemoSwitch : public switch_::Switch, public Component { void write_state(bool state) override { this->publish_state(state); } }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_text.h b/esphome/components/demo/demo_text.h index a753175062..56376c8c42 100644 --- a/esphome/components/demo/demo_text.h +++ b/esphome/components/demo/demo_text.h @@ -3,8 +3,7 @@ #include "esphome/components/text/text.h" #include "esphome/core/component.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoText : public text::Text, public Component { public: @@ -14,5 +13,4 @@ class DemoText : public text::Text, public Component { void control(const std::string &value) override { this->publish_state(value); } }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_text_sensor.h b/esphome/components/demo/demo_text_sensor.h index b4152fc248..03852a1e7f 100644 --- a/esphome/components/demo/demo_text_sensor.h +++ b/esphome/components/demo/demo_text_sensor.h @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoTextSensor : public text_sensor::TextSensor, public PollingComponent { public: @@ -21,5 +20,4 @@ class DemoTextSensor : public text_sensor::TextSensor, public PollingComponent { } }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/demo/demo_time.h b/esphome/components/demo/demo_time.h index 42788504bb..f94678fae4 100644 --- a/esphome/components/demo/demo_time.h +++ b/esphome/components/demo/demo_time.h @@ -7,8 +7,7 @@ #include "esphome/components/datetime/time_entity.h" #include "esphome/core/component.h" -namespace esphome { -namespace demo { +namespace esphome::demo { class DemoTime : public datetime::TimeEntity, public Component { public: @@ -28,7 +27,6 @@ class DemoTime : public datetime::TimeEntity, public Component { } }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo #endif diff --git a/esphome/components/demo/demo_valve.h b/esphome/components/demo/demo_valve.h index 9a3122aca5..3f1342959a 100644 --- a/esphome/components/demo/demo_valve.h +++ b/esphome/components/demo/demo_valve.h @@ -2,8 +2,7 @@ #include "esphome/components/valve/valve.h" -namespace esphome { -namespace demo { +namespace esphome::demo { enum class DemoValveType { TYPE_1, @@ -53,5 +52,4 @@ class DemoValve : public valve::Valve { DemoValveType type_{}; }; -} // namespace demo -} // namespace esphome +} // namespace esphome::demo diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index 1e1c33adaf..5c9d497c87 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace dfplayer { +namespace esphome::dfplayer { static const char *const TAG = "dfplayer"; @@ -283,5 +282,4 @@ void DFPlayer::dump_config() { this->check_uart_settings(9600); } -} // namespace dfplayer -} // namespace esphome +} // namespace esphome::dfplayer diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 0d240566c3..5936a06b60 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -6,8 +6,7 @@ const size_t DFPLAYER_READ_BUFFER_LENGTH = 25; // two messages + some extra -namespace esphome { -namespace dfplayer { +namespace esphome::dfplayer { enum EqPreset { NORMAL = 0, @@ -171,5 +170,4 @@ template class DFPlayerIsPlayingCondition : public Conditionparent_->is_playing(); } }; -} // namespace dfplayer -} // namespace esphome +} // namespace esphome::dfplayer diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h index 422555d6eb..bd91381d47 100644 --- a/esphome/components/dfrobot_sen0395/automation.h +++ b/esphome/components/dfrobot_sen0395/automation.h @@ -5,8 +5,7 @@ #include "dfrobot_sen0395.h" -namespace esphome { -namespace dfrobot_sen0395 { +namespace esphome::dfrobot_sen0395 { template class DfrobotSen0395ResetAction : public Action, public Parented { @@ -85,5 +84,4 @@ class DfrobotSen0395SettingsAction : public Action, public Parentedparent_->enqueue(make_unique(state)); } @@ -44,5 +43,4 @@ void Sen0395StartAfterBootSwitch::write_state(bool state) { } } -} // namespace dfrobot_sen0395 -} // namespace esphome +} // namespace esphome::dfrobot_sen0395 diff --git a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h index ab32d81dd8..d83734b034 100644 --- a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h +++ b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h @@ -5,8 +5,7 @@ #include "../dfrobot_sen0395.h" -namespace esphome { -namespace dfrobot_sen0395 { +namespace esphome::dfrobot_sen0395 { class DfrobotSen0395Switch : public switch_::Switch, public Component, public Parented {}; @@ -30,5 +29,4 @@ class Sen0395StartAfterBootSwitch : public DfrobotSen0395Switch { void write_state(bool state) override; }; -} // namespace dfrobot_sen0395 -} // namespace esphome +} // namespace esphome::dfrobot_sen0395 diff --git a/esphome/components/dht12/dht12.cpp b/esphome/components/dht12/dht12.cpp index 1d884daad6..78f9140929 100644 --- a/esphome/components/dht12/dht12.cpp +++ b/esphome/components/dht12/dht12.cpp @@ -5,8 +5,7 @@ #include "dht12.h" #include "esphome/core/log.h" -namespace esphome { -namespace dht12 { +namespace esphome::dht12 { static const char *const TAG = "dht12"; @@ -65,5 +64,4 @@ bool DHT12Component::read_data_(uint8_t *data) { return true; } -} // namespace dht12 -} // namespace esphome +} // namespace esphome::dht12 diff --git a/esphome/components/dht12/dht12.h b/esphome/components/dht12/dht12.h index ab19d7c723..5f4f822e70 100644 --- a/esphome/components/dht12/dht12.h +++ b/esphome/components/dht12/dht12.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace dht12 { +namespace esphome::dht12 { class DHT12Component : public PollingComponent, public i2c::I2CDevice { public: @@ -23,5 +22,4 @@ class DHT12Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *humidity_sensor_{nullptr}; }; -} // namespace dht12 -} // namespace esphome +} // namespace esphome::dht12 diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index f8569b6e7c..cd2d2143f5 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -5,8 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace display { +namespace esphome::display { static const char *const TAG = "display"; // COLOR_OFF and COLOR_ON are now inline constexpr in display.h @@ -927,5 +926,4 @@ const LogString *text_align_to_string(TextAlign textalign) { return LOG_STR("UNKNOWN"); } } -} // namespace display -} // namespace esphome +} // namespace esphome::display diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 6e38300d0e..6d0b7acfe8 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -23,8 +23,7 @@ #include "esphome/components/graphical_display_menu/graphical_display_menu.h" #endif -namespace esphome { -namespace display { +namespace esphome::display { /** TextAlign is used to tell the display class how to position a piece of text. By default * the coordinates you enter for the print*() functions take the upper left corner of the text @@ -871,5 +870,4 @@ class DisplayOnPageChangeTrigger : public Trigger const LogString *text_align_to_string(TextAlign textalign); -} // namespace display -} // namespace esphome +} // namespace esphome::display diff --git a/esphome/components/display/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index 0ecdccc38a..4c91914049 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -5,8 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" -namespace esphome { -namespace display { +namespace esphome::display { static const char *const TAG = "display"; @@ -68,5 +67,4 @@ void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { App.feed_wdt(); } -} // namespace display -} // namespace esphome +} // namespace esphome::display diff --git a/esphome/components/display/display_buffer.h b/esphome/components/display/display_buffer.h index b7c4db56be..d3032a33f7 100644 --- a/esphome/components/display/display_buffer.h +++ b/esphome/components/display/display_buffer.h @@ -9,8 +9,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" -namespace esphome { -namespace display { +namespace esphome::display { class DisplayBuffer : public Display { public: @@ -30,5 +29,4 @@ class DisplayBuffer : public Display { uint8_t *buffer_{nullptr}; }; -} // namespace display -} // namespace esphome +} // namespace esphome::display diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index 3114dee359..608642caa4 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -1,8 +1,7 @@ #pragma once #include "esphome/core/color.h" -namespace esphome { -namespace display { +namespace esphome::display { enum ColorOrder : uint8_t { COLOR_ORDER_RGB = 0, COLOR_ORDER_BGR = 1, COLOR_ORDER_GRB = 2 }; enum ColorBitness : uint8_t { COLOR_BITNESS_888 = 0, COLOR_BITNESS_565 = 1, COLOR_BITNESS_332 = 2 }; inline static uint8_t esp_scale(uint8_t i, uint8_t scale, uint8_t max_value = 255) { return (max_value * i / scale); } @@ -155,5 +154,4 @@ class ColorUtil { return color; } }; -} // namespace display -} // namespace esphome +} // namespace esphome::display diff --git a/esphome/components/display/rect.cpp b/esphome/components/display/rect.cpp index 2c41127860..a47f726917 100644 --- a/esphome/components/display/rect.cpp +++ b/esphome/components/display/rect.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace display { +namespace esphome::display { static const char *const TAG = "display"; @@ -90,5 +89,4 @@ void Rect::info(const std::string &prefix) { } } -} // namespace display -} // namespace esphome +} // namespace esphome::display diff --git a/esphome/components/display/rect.h b/esphome/components/display/rect.h index 5f11d94681..f4958fab88 100644 --- a/esphome/components/display/rect.h +++ b/esphome/components/display/rect.h @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" -namespace esphome { -namespace display { +namespace esphome::display { static const int16_t VALUE_NO_SET = 32766; @@ -32,5 +31,4 @@ class Rect { void info(const std::string &prefix = "rect info:"); }; -} // namespace display -} // namespace esphome +} // namespace esphome::display diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h index 50c26c344c..d4f83055d1 100644 --- a/esphome/components/display_menu_base/automation.h +++ b/esphome/components/display_menu_base/automation.h @@ -3,8 +3,7 @@ #include "esphome/core/automation.h" #include "display_menu_base.h" -namespace esphome { -namespace display_menu_base { +namespace esphome::display_menu_base { template class UpAction : public Action { public: @@ -144,5 +143,4 @@ class DisplayMenuOnPrevTrigger : public Trigger { MenuItemCustom *parent_; }; -} // namespace display_menu_base -} // namespace esphome +} // namespace esphome::display_menu_base diff --git a/esphome/components/display_menu_base/display_menu_base.cpp b/esphome/components/display_menu_base/display_menu_base.cpp index 2d8e6ae5fc..634a82a892 100644 --- a/esphome/components/display_menu_base/display_menu_base.cpp +++ b/esphome/components/display_menu_base/display_menu_base.cpp @@ -1,8 +1,7 @@ #include "display_menu_base.h" #include -namespace esphome { -namespace display_menu_base { +namespace esphome::display_menu_base { void DisplayMenuComponent::up() { if (this->check_healthy_and_active_()) { @@ -325,5 +324,4 @@ void DisplayMenuComponent::draw_menu() { } } -} // namespace display_menu_base -} // namespace esphome +} // namespace esphome::display_menu_base diff --git a/esphome/components/display_menu_base/display_menu_base.h b/esphome/components/display_menu_base/display_menu_base.h index 6208fcd3b4..07cdb7a10f 100644 --- a/esphome/components/display_menu_base/display_menu_base.h +++ b/esphome/components/display_menu_base/display_menu_base.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace display_menu_base { +namespace esphome::display_menu_base { enum MenuMode { MENU_MODE_ROTARY, @@ -78,5 +77,4 @@ class DisplayMenuComponent : public Component { bool root_on_enter_called_{false}; }; -} // namespace display_menu_base -} // namespace esphome +} // namespace esphome::display_menu_base diff --git a/esphome/components/display_menu_base/menu_item.cpp b/esphome/components/display_menu_base/menu_item.cpp index ad8b03de60..8d1d315e32 100644 --- a/esphome/components/display_menu_base/menu_item.cpp +++ b/esphome/components/display_menu_base/menu_item.cpp @@ -2,8 +2,7 @@ #include -namespace esphome { -namespace display_menu_base { +namespace esphome::display_menu_base { const LogString *menu_item_type_to_string(MenuItemType type) { switch (type) { @@ -201,5 +200,4 @@ void MenuItemCustom::on_next_() { this->on_next_callbacks_.call(); } void MenuItemCustom::on_prev_() { this->on_prev_callbacks_.call(); } -} // namespace display_menu_base -} // namespace esphome +} // namespace esphome::display_menu_base diff --git a/esphome/components/display_menu_base/menu_item.h b/esphome/components/display_menu_base/menu_item.h index 57d7350b9e..f3c41583f7 100644 --- a/esphome/components/display_menu_base/menu_item.h +++ b/esphome/components/display_menu_base/menu_item.h @@ -16,8 +16,7 @@ #include #include "esphome/core/log.h" -namespace esphome { -namespace display_menu_base { +namespace esphome::display_menu_base { enum MenuItemType { MENU_ITEM_LABEL, @@ -187,5 +186,4 @@ class MenuItemCustom : public MenuItemEditable { CallbackManager on_prev_callbacks_{}; }; -} // namespace display_menu_base -} // namespace esphome +} // namespace esphome::display_menu_base diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index b1366cd069..3b4693139d 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace dps310 { +namespace esphome::dps310 { static const char *const TAG = "dps310"; @@ -182,5 +181,4 @@ int32_t DPS310Component::twos_complement(int32_t val, uint8_t bits) { return val; } -} // namespace dps310 -} // namespace esphome +} // namespace esphome::dps310 diff --git a/esphome/components/dps310/dps310.h b/esphome/components/dps310/dps310.h index dce220d44b..09143bf6b8 100644 --- a/esphome/components/dps310/dps310.h +++ b/esphome/components/dps310/dps310.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace dps310 { +namespace esphome::dps310 { static const uint8_t DPS310_REG_PRS_B2 = 0x00; // Highest byte of pressure data static const uint8_t DPS310_REG_TMP_B2 = 0x03; // Highest byte of temperature data @@ -60,5 +59,4 @@ class DPS310Component : public PollingComponent, public i2c::I2CDevice { bool got_pres_, got_temp_, update_in_progress_; }; -} // namespace dps310 -} // namespace esphome +} // namespace esphome::dps310 diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index ba2ad6032f..1d04b52168 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -4,8 +4,7 @@ // Datasheet: // - https://datasheets.maximintegrated.com/en/ds/DS1307.pdf -namespace esphome { -namespace ds1307 { +namespace esphome::ds1307 { static const char *const TAG = "ds1307"; @@ -99,5 +98,4 @@ bool DS1307Component::write_rtc_() { ds1307_.reg.day, ONOFF(ds1307_.reg.ch), ds1307_.reg.rs, ONOFF(ds1307_.reg.sqwe), ONOFF(ds1307_.reg.out)); return true; } -} // namespace ds1307 -} // namespace esphome +} // namespace esphome::ds1307 diff --git a/esphome/components/ds1307/ds1307.h b/esphome/components/ds1307/ds1307.h index 1712056006..2004978cc6 100644 --- a/esphome/components/ds1307/ds1307.h +++ b/esphome/components/ds1307/ds1307.h @@ -4,8 +4,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/time/real_time_clock.h" -namespace esphome { -namespace ds1307 { +namespace esphome::ds1307 { class DS1307Component : public time::RealTimeClock, public i2c::I2CDevice { public: @@ -65,5 +64,4 @@ template class ReadAction : public Action, public Parente public: void play(const Ts &...x) override { this->parent_->read_time(); } }; -} // namespace ds1307 -} // namespace esphome +} // namespace esphome::ds1307 diff --git a/esphome/components/ds2484/ds2484.cpp b/esphome/components/ds2484/ds2484.cpp index 0b36f86874..69103ccbd2 100644 --- a/esphome/components/ds2484/ds2484.cpp +++ b/esphome/components/ds2484/ds2484.cpp @@ -1,7 +1,6 @@ #include "ds2484.h" -namespace esphome { -namespace ds2484 { +namespace esphome::ds2484 { static const char *const TAG = "ds2484.onewire"; void DS2484OneWireBus::setup() { @@ -204,5 +203,4 @@ uint64_t IRAM_ATTR DS2484OneWireBus::search_int() { return address; } -} // namespace ds2484 -} // namespace esphome +} // namespace esphome::ds2484 diff --git a/esphome/components/ds2484/ds2484.h b/esphome/components/ds2484/ds2484.h index 223227c0a2..9e6bb08858 100644 --- a/esphome/components/ds2484/ds2484.h +++ b/esphome/components/ds2484/ds2484.h @@ -6,8 +6,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/one_wire/one_wire.h" -namespace esphome { -namespace ds2484 { +namespace esphome::ds2484 { class DS2484OneWireBus : public one_wire::OneWireBus, public i2c::I2CDevice, public Component { public: @@ -39,5 +38,4 @@ class DS2484OneWireBus : public one_wire::OneWireBus, public i2c::I2CDevice, pub bool active_pullup_{false}; bool strong_pullup_{false}; }; -} // namespace ds2484 -} // namespace esphome +} // namespace esphome::ds2484 diff --git a/esphome/components/duty_cycle/duty_cycle_sensor.cpp b/esphome/components/duty_cycle/duty_cycle_sensor.cpp index f801769d27..fd0a48b935 100644 --- a/esphome/components/duty_cycle/duty_cycle_sensor.cpp +++ b/esphome/components/duty_cycle/duty_cycle_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace duty_cycle { +namespace esphome::duty_cycle { static const char *const TAG = "duty_cycle"; @@ -56,5 +55,4 @@ void IRAM_ATTR DutyCycleSensorStore::gpio_intr(DutyCycleSensorStore *arg) { arg->last_interrupt = now; } -} // namespace duty_cycle -} // namespace esphome +} // namespace esphome::duty_cycle diff --git a/esphome/components/duty_cycle/duty_cycle_sensor.h b/esphome/components/duty_cycle/duty_cycle_sensor.h index ffb8e3b622..58beee946a 100644 --- a/esphome/components/duty_cycle/duty_cycle_sensor.h +++ b/esphome/components/duty_cycle/duty_cycle_sensor.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace duty_cycle { +namespace esphome::duty_cycle { /// Store data in a class that doesn't use multiple-inheritance (vtables in flash) struct DutyCycleSensorStore { @@ -32,5 +31,4 @@ class DutyCycleSensor : public sensor::Sensor, public PollingComponent { uint32_t last_update_{0}; }; -} // namespace duty_cycle -} // namespace esphome +} // namespace esphome::duty_cycle diff --git a/esphome/components/duty_time/duty_time_sensor.cpp b/esphome/components/duty_time/duty_time_sensor.cpp index 561040623d..697a4e96f3 100644 --- a/esphome/components/duty_time/duty_time_sensor.cpp +++ b/esphome/components/duty_time/duty_time_sensor.cpp @@ -1,8 +1,7 @@ #include "duty_time_sensor.h" #include "esphome/core/hal.h" -namespace esphome { -namespace duty_time_sensor { +namespace esphome::duty_time_sensor { static const char *const TAG = "duty_time_sensor"; @@ -103,5 +102,4 @@ void DutyTimeSensor::dump_config() { LOG_SENSOR(" ", "Last Duty Time Sensor:", this->last_duty_time_sensor_); } -} // namespace duty_time_sensor -} // namespace esphome +} // namespace esphome::duty_time_sensor diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index d21802ebb6..9b1e10ea8c 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -10,8 +10,7 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif -namespace esphome { -namespace duty_time_sensor { +namespace esphome::duty_time_sensor { class DutyTimeSensor : public sensor::Sensor, public PollingComponent { public: @@ -71,5 +70,4 @@ template class RunningCondition : public Condition, publi bool state_; }; -} // namespace duty_time_sensor -} // namespace esphome +} // namespace esphome::duty_time_sensor diff --git a/esphome/components/e131/e131.cpp b/esphome/components/e131/e131.cpp index a7a695c167..061af4c4c0 100644 --- a/esphome/components/e131/e131.cpp +++ b/esphome/components/e131/e131.cpp @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace e131 { +namespace esphome::e131 { static const char *const TAG = "e131"; static const int PORT = 5568; @@ -134,6 +133,6 @@ bool E131Component::process_(int universe, const E131Packet &packet) { return handled; } -} // namespace e131 -} // namespace esphome +} // namespace esphome::e131 + #endif diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index 8f0b808946..bfcb0ca7f8 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -12,8 +12,7 @@ #include #include -namespace esphome { -namespace e131 { +namespace esphome::e131 { class E131AddressableLightEffect; @@ -72,6 +71,6 @@ class E131Component : public esphome::Component { std::vector universe_consumers_; }; -} // namespace e131 -} // namespace esphome +} // namespace esphome::e131 + #endif diff --git a/esphome/components/e131/e131_addressable_light_effect.cpp b/esphome/components/e131/e131_addressable_light_effect.cpp index f6300874ac..6157eba4e9 100644 --- a/esphome/components/e131/e131_addressable_light_effect.cpp +++ b/esphome/components/e131/e131_addressable_light_effect.cpp @@ -3,8 +3,7 @@ #ifdef USE_NETWORK #include "esphome/core/log.h" -namespace esphome { -namespace e131 { +namespace esphome::e131 { static const char *const TAG = "e131_addressable_light_effect"; static const int MAX_DATA_SIZE = (sizeof(E131Packet::values) - 1); @@ -91,6 +90,6 @@ bool E131AddressableLightEffect::process_(int universe, const E131Packet &packet return true; } -} // namespace e131 -} // namespace esphome +} // namespace esphome::e131 + #endif diff --git a/esphome/components/e131/e131_addressable_light_effect.h b/esphome/components/e131/e131_addressable_light_effect.h index 381e08163b..b28dc22dbe 100644 --- a/esphome/components/e131/e131_addressable_light_effect.h +++ b/esphome/components/e131/e131_addressable_light_effect.h @@ -3,8 +3,8 @@ #include "esphome/core/component.h" #include "esphome/components/light/addressable_light_effect.h" #ifdef USE_NETWORK -namespace esphome { -namespace e131 { + +namespace esphome::e131 { class E131Component; struct E131Packet; @@ -40,6 +40,6 @@ class E131AddressableLightEffect : public light::AddressableLightEffect { friend class E131Component; }; -} // namespace e131 -} // namespace esphome +} // namespace esphome::e131 + #endif diff --git a/esphome/components/e131/e131_packet.cpp b/esphome/components/e131/e131_packet.cpp index 600793f5d3..afed2abe31 100644 --- a/esphome/components/e131/e131_packet.cpp +++ b/esphome/components/e131/e131_packet.cpp @@ -12,8 +12,7 @@ #include #include -namespace esphome { -namespace e131 { +namespace esphome::e131 { static const char *const TAG = "e131"; @@ -158,6 +157,6 @@ bool E131Component::packet_(const uint8_t *data, size_t len, int &universe, E131 return true; } -} // namespace e131 -} // namespace esphome +} // namespace esphome::e131 + #endif diff --git a/esphome/components/ee895/ee895.cpp b/esphome/components/ee895/ee895.cpp index 93e5d4203b..2dd7405ff5 100644 --- a/esphome/components/ee895/ee895.cpp +++ b/esphome/components/ee895/ee895.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace ee895 { +namespace esphome::ee895 { static const char *const TAG = "ee895"; @@ -111,5 +110,4 @@ uint16_t EE895Component::calc_crc16_(const uint8_t buf[], uint8_t len) { uint16_t crc = crc16(&addr, 1); return crc16(buf, len, crc); } -} // namespace ee895 -} // namespace esphome +} // namespace esphome::ee895 diff --git a/esphome/components/ee895/ee895.h b/esphome/components/ee895/ee895.h index ff1085e05d..ba8e594fea 100644 --- a/esphome/components/ee895/ee895.h +++ b/esphome/components/ee895/ee895.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ee895 { +namespace esphome::ee895 { /// This class implements support for the ee895 of temperature i2c sensors. class EE895Component : public PollingComponent, public i2c::I2CDevice { @@ -29,5 +28,4 @@ class EE895Component : public PollingComponent, public i2c::I2CDevice { enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, CRC_CHECK_FAILED } error_code_{NONE}; }; -} // namespace ee895 -} // namespace esphome +} // namespace esphome::ee895 diff --git a/esphome/components/ektf2232/touchscreen/ektf2232.cpp b/esphome/components/ektf2232/touchscreen/ektf2232.cpp index 63ebb2166b..51532548c1 100644 --- a/esphome/components/ektf2232/touchscreen/ektf2232.cpp +++ b/esphome/components/ektf2232/touchscreen/ektf2232.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace ektf2232 { +namespace esphome::ektf2232 { static const char *const TAG = "ektf2232"; @@ -130,5 +129,4 @@ void EKTF2232Touchscreen::dump_config() { LOG_PIN(" Reset Pin: ", this->reset_pin_); } -} // namespace ektf2232 -} // namespace esphome +} // namespace esphome::ektf2232 diff --git a/esphome/components/ektf2232/touchscreen/ektf2232.h b/esphome/components/ektf2232/touchscreen/ektf2232.h index 2ddc60851f..45da74a2a5 100644 --- a/esphome/components/ektf2232/touchscreen/ektf2232.h +++ b/esphome/components/ektf2232/touchscreen/ektf2232.h @@ -6,8 +6,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace ektf2232 { +namespace esphome::ektf2232 { using namespace touchscreen; @@ -31,5 +30,4 @@ class EKTF2232Touchscreen : public Touchscreen, public i2c::I2CDevice { GPIOPin *reset_pin_; }; -} // namespace ektf2232 -} // namespace esphome +} // namespace esphome::ektf2232 diff --git a/esphome/components/emc2101/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index 068e25568f..464f49fe51 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include "emc2101.h" -namespace esphome { -namespace emc2101 { +namespace esphome::emc2101 { static const char *const TAG = "EMC2101"; @@ -165,5 +164,4 @@ float Emc2101Component::get_speed() { return tach == 0xFFFF ? 0.0f : 5400000.0f / tach; } -} // namespace emc2101 -} // namespace esphome +} // namespace esphome::emc2101 diff --git a/esphome/components/emc2101/emc2101.h b/esphome/components/emc2101/emc2101.h index 0f4bc560dd..1fe03a2630 100644 --- a/esphome/components/emc2101/emc2101.h +++ b/esphome/components/emc2101/emc2101.h @@ -3,8 +3,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -namespace esphome { -namespace emc2101 { +namespace esphome::emc2101 { /** Enum listing all DAC conversion rates for the EMC2101. * @@ -111,5 +110,4 @@ class Emc2101Component : public Component, public i2c::I2CDevice { Emc2101DACConversionRate dac_conversion_rate_; }; -} // namespace emc2101 -} // namespace esphome +} // namespace esphome::emc2101 diff --git a/esphome/components/emc2101/output/emc2101_output.cpp b/esphome/components/emc2101/output/emc2101_output.cpp index 2ed506cd99..6b046296f3 100644 --- a/esphome/components/emc2101/output/emc2101_output.cpp +++ b/esphome/components/emc2101/output/emc2101_output.cpp @@ -1,9 +1,7 @@ #include "emc2101_output.h" -namespace esphome { -namespace emc2101 { +namespace esphome::emc2101 { void EMC2101Output::write_state(float state) { this->parent_->set_duty_cycle(state); } -} // namespace emc2101 -} // namespace esphome +} // namespace esphome::emc2101 diff --git a/esphome/components/emc2101/output/emc2101_output.h b/esphome/components/emc2101/output/emc2101_output.h index 232df6ff5f..95077f5524 100644 --- a/esphome/components/emc2101/output/emc2101_output.h +++ b/esphome/components/emc2101/output/emc2101_output.h @@ -3,8 +3,7 @@ #include "../emc2101.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace emc2101 { +namespace esphome::emc2101 { /// This class allows to control the EMC2101 output. class EMC2101Output : public output::FloatOutput { @@ -18,5 +17,4 @@ class EMC2101Output : public output::FloatOutput { Emc2101Component *parent_; }; -} // namespace emc2101 -} // namespace esphome +} // namespace esphome::emc2101 diff --git a/esphome/components/emc2101/sensor/emc2101_sensor.cpp b/esphome/components/emc2101/sensor/emc2101_sensor.cpp index 3014c7da07..bb5eea21f3 100644 --- a/esphome/components/emc2101/sensor/emc2101_sensor.cpp +++ b/esphome/components/emc2101/sensor/emc2101_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace emc2101 { +namespace esphome::emc2101 { static const char *const TAG = "EMC2101.sensor"; @@ -37,5 +36,4 @@ void EMC2101Sensor::update() { } } -} // namespace emc2101 -} // namespace esphome +} // namespace esphome::emc2101 diff --git a/esphome/components/emc2101/sensor/emc2101_sensor.h b/esphome/components/emc2101/sensor/emc2101_sensor.h index 3e033f58a7..2336ac2f15 100644 --- a/esphome/components/emc2101/sensor/emc2101_sensor.h +++ b/esphome/components/emc2101/sensor/emc2101_sensor.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -namespace esphome { -namespace emc2101 { +namespace esphome::emc2101 { /// This class exposes the EMC2101 sensors. class EMC2101Sensor : public PollingComponent { @@ -33,5 +32,4 @@ class EMC2101Sensor : public PollingComponent { sensor::Sensor *duty_cycle_sensor_{nullptr}; }; -} // namespace emc2101 -} // namespace esphome +} // namespace esphome::emc2101 diff --git a/esphome/components/emmeti/emmeti.cpp b/esphome/components/emmeti/emmeti.cpp index 04976d95d7..82991aeac7 100644 --- a/esphome/components/emmeti/emmeti.cpp +++ b/esphome/components/emmeti/emmeti.cpp @@ -1,8 +1,7 @@ #include "emmeti.h" #include "esphome/components/remote_base/remote_base.h" -namespace esphome { -namespace emmeti { +namespace esphome::emmeti { static const char *const TAG = "emmeti.climate"; @@ -308,5 +307,4 @@ bool EmmetiClimate::on_receive(remote_base::RemoteReceiveData data) { return this->parse_state_frame_(curr_state); } -} // namespace emmeti -} // namespace esphome +} // namespace esphome::emmeti diff --git a/esphome/components/emmeti/emmeti.h b/esphome/components/emmeti/emmeti.h index 9bfb7a7a98..9dc78ce07c 100644 --- a/esphome/components/emmeti/emmeti.h +++ b/esphome/components/emmeti/emmeti.h @@ -2,8 +2,7 @@ #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace emmeti { +namespace esphome::emmeti { const uint8_t EMMETI_TEMP_MIN = 16; // Celsius const uint8_t EMMETI_TEMP_MAX = 30; // Celsius @@ -105,5 +104,4 @@ class EmmetiClimate : public climate_ir::ClimateIR { uint8_t blades_ = EMMETI_BLADES_STOP; }; -} // namespace emmeti -} // namespace esphome +} // namespace esphome::emmeti diff --git a/esphome/components/endstop/endstop_cover.cpp b/esphome/components/endstop/endstop_cover.cpp index 5e0b9c72d3..b3c210a8d8 100644 --- a/esphome/components/endstop/endstop_cover.cpp +++ b/esphome/components/endstop/endstop_cover.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include "esphome/core/application.h" -namespace esphome { -namespace endstop { +namespace esphome::endstop { static const char *const TAG = "endstop.cover"; @@ -192,5 +191,4 @@ void EndstopCover::recompute_position_() { this->last_recompute_time_ = now; } -} // namespace endstop -} // namespace esphome +} // namespace esphome::endstop diff --git a/esphome/components/endstop/endstop_cover.h b/esphome/components/endstop/endstop_cover.h index 32ede12335..b910139bcd 100644 --- a/esphome/components/endstop/endstop_cover.h +++ b/esphome/components/endstop/endstop_cover.h @@ -5,8 +5,7 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/cover/cover.h" -namespace esphome { -namespace endstop { +namespace esphome::endstop { class EndstopCover : public cover::Cover, public Component { public: @@ -53,5 +52,4 @@ class EndstopCover : public cover::Cover, public Component { cover::CoverOperation last_operation_{cover::COVER_OPERATION_OPENING}; }; -} // namespace endstop -} // namespace esphome +} // namespace esphome::endstop diff --git a/esphome/components/ens160_base/ens160_base.cpp b/esphome/components/ens160_base/ens160_base.cpp index 42baa68b35..20c67c3450 100644 --- a/esphome/components/ens160_base/ens160_base.cpp +++ b/esphome/components/ens160_base/ens160_base.cpp @@ -18,8 +18,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace ens160_base { +namespace esphome::ens160_base { static const char *const TAG = "ens160"; @@ -332,5 +331,4 @@ void ENS160Component::dump_config() { } } -} // namespace ens160_base -} // namespace esphome +} // namespace esphome::ens160_base diff --git a/esphome/components/ens160_base/ens160_base.h b/esphome/components/ens160_base/ens160_base.h index ae850c8180..f42272684e 100644 --- a/esphome/components/ens160_base/ens160_base.h +++ b/esphome/components/ens160_base/ens160_base.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace ens160_base { +namespace esphome::ens160_base { class ENS160Component : public PollingComponent, public sensor::Sensor { public: @@ -59,5 +58,4 @@ class ENS160Component : public PollingComponent, public sensor::Sensor { sensor::Sensor *temperature_{nullptr}; }; -} // namespace ens160_base -} // namespace esphome +} // namespace esphome::ens160_base diff --git a/esphome/components/ens160_i2c/ens160_i2c.cpp b/esphome/components/ens160_i2c/ens160_i2c.cpp index 7163a5ad6e..1f02ddb718 100644 --- a/esphome/components/ens160_i2c/ens160_i2c.cpp +++ b/esphome/components/ens160_i2c/ens160_i2c.cpp @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "../ens160_base/ens160_base.h" -namespace esphome { -namespace ens160_i2c { +namespace esphome::ens160_i2c { static const char *const TAG = "ens160_i2c.sensor"; @@ -28,5 +27,4 @@ void ENS160I2CComponent::dump_config() { LOG_I2C_DEVICE(this); } -} // namespace ens160_i2c -} // namespace esphome +} // namespace esphome::ens160_i2c diff --git a/esphome/components/ens160_i2c/ens160_i2c.h b/esphome/components/ens160_i2c/ens160_i2c.h index 2df32f27bf..98318a7eca 100644 --- a/esphome/components/ens160_i2c/ens160_i2c.h +++ b/esphome/components/ens160_i2c/ens160_i2c.h @@ -3,8 +3,7 @@ #include "esphome/components/ens160_base/ens160_base.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ens160_i2c { +namespace esphome::ens160_i2c { class ENS160I2CComponent : public esphome::ens160_base::ENS160Component, public i2c::I2CDevice { void dump_config() override; @@ -15,5 +14,4 @@ class ENS160I2CComponent : public esphome::ens160_base::ENS160Component, public bool write_bytes(uint8_t a_register, uint8_t *data, size_t len) override; }; -} // namespace ens160_i2c -} // namespace esphome +} // namespace esphome::ens160_i2c diff --git a/esphome/components/ens160_spi/ens160_spi.cpp b/esphome/components/ens160_spi/ens160_spi.cpp index fba2fdf0e4..41ab8298db 100644 --- a/esphome/components/ens160_spi/ens160_spi.cpp +++ b/esphome/components/ens160_spi/ens160_spi.cpp @@ -4,8 +4,7 @@ #include "ens160_spi.h" #include -namespace esphome { -namespace ens160_spi { +namespace esphome::ens160_spi { static const char *const TAG = "ens160_spi.sensor"; @@ -55,5 +54,4 @@ bool ENS160SPIComponent::write_bytes(uint8_t a_register, uint8_t *data, size_t l return true; } -} // namespace ens160_spi -} // namespace esphome +} // namespace esphome::ens160_spi diff --git a/esphome/components/ens160_spi/ens160_spi.h b/esphome/components/ens160_spi/ens160_spi.h index 3371f37ffd..d4d3cf3ae9 100644 --- a/esphome/components/ens160_spi/ens160_spi.h +++ b/esphome/components/ens160_spi/ens160_spi.h @@ -3,8 +3,7 @@ #include "esphome/components/ens160_base/ens160_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace ens160_spi { +namespace esphome::ens160_spi { class ENS160SPIComponent : public esphome::ens160_base::ENS160Component, public spi::SPIDevice -namespace esphome { -namespace es7210 { +namespace esphome::es7210 { static const char *const TAG = "es7210"; @@ -224,5 +223,4 @@ bool ES7210::es7210_update_reg_bit_(uint8_t reg_addr, uint8_t update_bits, uint8 return this->write_byte(reg_addr, regv); } -} // namespace es7210 -} // namespace esphome +} // namespace esphome::es7210 diff --git a/esphome/components/es7210/es7210.h b/esphome/components/es7210/es7210.h index 7071a547ec..914fbd633b 100644 --- a/esphome/components/es7210/es7210.h +++ b/esphome/components/es7210/es7210.h @@ -6,8 +6,7 @@ #include "es7210_const.h" -namespace esphome { -namespace es7210 { +namespace esphome::es7210 { enum ES7210BitsPerSample : uint8_t { ES7210_BITS_PER_SAMPLE_16 = 16, @@ -57,5 +56,4 @@ class ES7210 : public audio_adc::AudioAdc, public Component, public i2c::I2CDevi uint32_t sample_rate_{0}; }; -} // namespace es7210 -} // namespace esphome +} // namespace esphome::es7210 diff --git a/esphome/components/es7210/es7210_const.h b/esphome/components/es7210/es7210_const.h index e5ffea5743..70705b2474 100644 --- a/esphome/components/es7210/es7210_const.h +++ b/esphome/components/es7210/es7210_const.h @@ -2,8 +2,7 @@ #include -namespace esphome { -namespace es7210 { +namespace esphome::es7210 { // ES7210 register addresses static const uint8_t ES7210_RESET_REG00 = 0x00; /* Reset control */ @@ -125,5 +124,4 @@ static const ES7210Coefficient ES7210_COEFFICIENTS[] = { static const float ES7210_MIC_GAIN_MIN = 0.0; static const float ES7210_MIC_GAIN_MAX = 37.5; -} // namespace es7210 -} // namespace esphome +} // namespace esphome::es7210 diff --git a/esphome/components/es7243e/es7243e.cpp b/esphome/components/es7243e/es7243e.cpp index d45c1d5a8c..b4d9fba4c5 100644 --- a/esphome/components/es7243e/es7243e.cpp +++ b/esphome/components/es7243e/es7243e.cpp @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace es7243e { +namespace esphome::es7243e { static const char *const TAG = "es7243e"; @@ -119,5 +118,4 @@ uint8_t ES7243E::es7243e_gain_reg_value_(float mic_gain) { return 14; } -} // namespace es7243e -} // namespace esphome +} // namespace esphome::es7243e diff --git a/esphome/components/es7243e/es7243e.h b/esphome/components/es7243e/es7243e.h index f7c9d67371..6386ea529a 100644 --- a/esphome/components/es7243e/es7243e.h +++ b/esphome/components/es7243e/es7243e.h @@ -4,8 +4,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -namespace esphome { -namespace es7243e { +namespace esphome::es7243e { class ES7243E : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { /* Class for configuring an ES7243E ADC for microphone input. @@ -32,5 +31,4 @@ class ES7243E : public audio_adc::AudioAdc, public Component, public i2c::I2CDev float mic_gain_{0}; }; -} // namespace es7243e -} // namespace esphome +} // namespace esphome::es7243e diff --git a/esphome/components/es7243e/es7243e_const.h b/esphome/components/es7243e/es7243e_const.h index daae53a108..9f926db3b1 100644 --- a/esphome/components/es7243e/es7243e_const.h +++ b/esphome/components/es7243e/es7243e_const.h @@ -2,8 +2,7 @@ #include -namespace esphome { -namespace es7243e { +namespace esphome::es7243e { // ES7243E register addresses static const uint8_t ES7243E_RESET_REG00 = 0x00; // Reset control @@ -50,5 +49,4 @@ static const uint8_t ES7243E_CHIP_ID1_REGFD = 0xFD; // chip ID 1, reads 0x7 static const uint8_t ES7243E_CHIP_ID2_REGFE = 0xFE; // chip ID 2, reads 0x43 (RO) static const uint8_t ES7243E_CHIP_VERSION_REGFF = 0xFF; // chip version, reads 0x00 (RO) -} // namespace es7243e -} // namespace esphome +} // namespace esphome::es7243e diff --git a/esphome/components/es8156/es8156.cpp b/esphome/components/es8156/es8156.cpp index 961dc24b29..03d9713df9 100644 --- a/esphome/components/es8156/es8156.cpp +++ b/esphome/components/es8156/es8156.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace es8156 { +namespace esphome::es8156 { static const char *const TAG = "es8156"; @@ -118,5 +117,4 @@ bool ES8156::set_mute_state_(bool mute_state) { return this->write_byte(ES8156_REG13_DAC_MUTE, reg13); } -} // namespace es8156 -} // namespace esphome +} // namespace esphome::es8156 diff --git a/esphome/components/es8156/es8156.h b/esphome/components/es8156/es8156.h index 082514485c..c3cec3dc14 100644 --- a/esphome/components/es8156/es8156.h +++ b/esphome/components/es8156/es8156.h @@ -4,8 +4,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -namespace esphome { -namespace es8156 { +namespace esphome::es8156 { class ES8156 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: @@ -46,5 +45,4 @@ class ES8156 : public audio_dac::AudioDac, public Component, public i2c::I2CDevi bool set_mute_state_(bool mute_state); }; -} // namespace es8156 -} // namespace esphome +} // namespace esphome::es8156 diff --git a/esphome/components/es8156/es8156_const.h b/esphome/components/es8156/es8156_const.h index 0bc8f89dd4..0836e4766d 100644 --- a/esphome/components/es8156/es8156_const.h +++ b/esphome/components/es8156/es8156_const.h @@ -2,8 +2,7 @@ #include "es8156.h" -namespace esphome { -namespace es8156 { +namespace esphome::es8156 { /* ES8156 register addresses */ /* @@ -64,5 +63,4 @@ static const uint8_t ES8156_REGFD_CHIPID1 = 0xFD; static const uint8_t ES8156_REGFE_CHIPID0 = 0xFE; static const uint8_t ES8156_REGFF_CHIP_VERSION = 0xFF; -} // namespace es8156 -} // namespace esphome +} // namespace esphome::es8156 diff --git a/esphome/components/es8311/es8311.cpp b/esphome/components/es8311/es8311.cpp index cf864187f9..0386d84200 100644 --- a/esphome/components/es8311/es8311.cpp +++ b/esphome/components/es8311/es8311.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace es8311 { +namespace esphome::es8311 { static const char *const TAG = "es8311"; @@ -223,5 +222,4 @@ bool ES8311::set_mute_state_(bool mute_state) { return this->write_byte(ES8311_REG31_DAC, reg31); } -} // namespace es8311 -} // namespace esphome +} // namespace esphome::es8311 diff --git a/esphome/components/es8311/es8311.h b/esphome/components/es8311/es8311.h index 5eccc48004..1190bcb0aa 100644 --- a/esphome/components/es8311/es8311.h +++ b/esphome/components/es8311/es8311.h @@ -4,8 +4,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -namespace esphome { -namespace es8311 { +namespace esphome::es8311 { enum ES8311MicGain { ES8311_MIC_GAIN_MIN = -1, @@ -130,5 +129,4 @@ class ES8311 : public audio_dac::AudioDac, public Component, public i2c::I2CDevi ES8311Resolution resolution_out_; }; -} // namespace es8311 -} // namespace esphome +} // namespace esphome::es8311 diff --git a/esphome/components/es8311/es8311_const.h b/esphome/components/es8311/es8311_const.h index 7463a92ef1..27b9e02c13 100644 --- a/esphome/components/es8311/es8311_const.h +++ b/esphome/components/es8311/es8311_const.h @@ -2,8 +2,7 @@ #include "es8311.h" -namespace esphome { -namespace es8311 { +namespace esphome::es8311 { // ES8311 register addresses static const uint8_t ES8311_REG00_RESET = 0x00; // Reset @@ -191,5 +190,4 @@ static const ES8311Coefficient ES8311_COEFFICIENTS[] = { // clang-format on }; -} // namespace es8311 -} // namespace esphome +} // namespace esphome::es8311 diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index c252cdb707..c015393e14 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace es8388 { +namespace esphome::es8388 { static const char *const TAG = "es8388"; @@ -284,5 +283,4 @@ optional ES8388::get_mic_input() { }; } -} // namespace es8388 -} // namespace esphome +} // namespace esphome::es8388 diff --git a/esphome/components/es8388/es8388.h b/esphome/components/es8388/es8388.h index 373f71b437..1f744e25b3 100644 --- a/esphome/components/es8388/es8388.h +++ b/esphome/components/es8388/es8388.h @@ -11,8 +11,7 @@ #include "es8388_const.h" -namespace esphome { -namespace es8388 { +namespace esphome::es8388 { enum DacOutputLine : uint8_t { DAC_OUTPUT_LINE1, @@ -76,5 +75,4 @@ class ES8388 : public audio_dac::AudioDac, public Component, public i2c::I2CDevi bool set_mute_state_(bool mute_state); }; -} // namespace es8388 -} // namespace esphome +} // namespace esphome::es8388 diff --git a/esphome/components/es8388/es8388_const.h b/esphome/components/es8388/es8388_const.h index 2a51f078bc..451c9cc026 100644 --- a/esphome/components/es8388/es8388_const.h +++ b/esphome/components/es8388/es8388_const.h @@ -1,8 +1,7 @@ #pragma once #include -namespace esphome { -namespace es8388 { +namespace esphome::es8388 { /* ES8388 register */ static const uint8_t ES8388_CONTROL1 = 0x00; @@ -79,5 +78,4 @@ static const uint8_t ES8388_ADC_INPUT_MIC2 = 0x06; static const uint8_t ES8388_ADC_INPUT_LINPUT2_RINPUT2 = 0x50; static const uint8_t ES8388_ADC_INPUT_DIFFERENCE = 0xf0; -} // namespace es8388 -} // namespace esphome +} // namespace esphome::es8388 diff --git a/esphome/components/es8388/select/adc_input_mic_select.cpp b/esphome/components/es8388/select/adc_input_mic_select.cpp index 2e47534296..a91ccf4d5b 100644 --- a/esphome/components/es8388/select/adc_input_mic_select.cpp +++ b/esphome/components/es8388/select/adc_input_mic_select.cpp @@ -1,12 +1,10 @@ #include "adc_input_mic_select.h" -namespace esphome { -namespace es8388 { +namespace esphome::es8388 { void ADCInputMicSelect::control(size_t index) { this->publish_state(index); this->parent_->set_adc_input_mic(static_cast(index)); } -} // namespace es8388 -} // namespace esphome +} // namespace esphome::es8388 diff --git a/esphome/components/es8388/select/adc_input_mic_select.h b/esphome/components/es8388/select/adc_input_mic_select.h index f0fa840d00..29978f1623 100644 --- a/esphome/components/es8388/select/adc_input_mic_select.h +++ b/esphome/components/es8388/select/adc_input_mic_select.h @@ -3,13 +3,11 @@ #include "esphome/components/es8388/es8388.h" #include "esphome/components/select/select.h" -namespace esphome { -namespace es8388 { +namespace esphome::es8388 { class ADCInputMicSelect : public select::Select, public Parented { protected: void control(size_t index) override; }; -} // namespace es8388 -} // namespace esphome +} // namespace esphome::es8388 diff --git a/esphome/components/es8388/select/dac_output_select.cpp b/esphome/components/es8388/select/dac_output_select.cpp index 9af288a721..cfe0fe1472 100644 --- a/esphome/components/es8388/select/dac_output_select.cpp +++ b/esphome/components/es8388/select/dac_output_select.cpp @@ -1,12 +1,10 @@ #include "dac_output_select.h" -namespace esphome { -namespace es8388 { +namespace esphome::es8388 { void DacOutputSelect::control(size_t index) { this->publish_state(index); this->parent_->set_dac_output(static_cast(index)); } -} // namespace es8388 -} // namespace esphome +} // namespace esphome::es8388 diff --git a/esphome/components/es8388/select/dac_output_select.h b/esphome/components/es8388/select/dac_output_select.h index 40d8a66553..030f12406e 100644 --- a/esphome/components/es8388/select/dac_output_select.h +++ b/esphome/components/es8388/select/dac_output_select.h @@ -3,13 +3,11 @@ #include "esphome/components/es8388/es8388.h" #include "esphome/components/select/select.h" -namespace esphome { -namespace es8388 { +namespace esphome::es8388 { class DacOutputSelect : public select::Select, public Parented { protected: void control(size_t index) override; }; -} // namespace es8388 -} // namespace esphome +} // namespace esphome::es8388 diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index 3c13bd9b4f..a140eeef77 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace esp32 { +namespace esphome::esp32 { // Static assertions to ensure our bit-packed fields can hold the enum values static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t"); @@ -51,7 +50,6 @@ class ESP32InternalGPIOPin : public InternalGPIOPin { static bool isr_service_installed; }; -} // namespace esp32 -} // namespace esphome +} // namespace esphome::esp32 #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 093273b399..9f1723430b 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -16,8 +16,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace esp32_ble_beacon { +namespace esphome::esp32_ble_beacon { static const char *const TAG = "esp32_ble_beacon"; @@ -129,7 +128,6 @@ void ESP32BLEBeacon::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap } } -} // namespace esp32_ble_beacon -} // namespace esphome +} // namespace esphome::esp32_ble_beacon #endif diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index 44a7133454..8b3899a681 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -10,8 +10,7 @@ #endif #include -namespace esphome { -namespace esp32_ble_beacon { +namespace esphome::esp32_ble_beacon { using esp_ble_ibeacon_head_t = struct { uint8_t flags[3]; @@ -69,7 +68,6 @@ class ESP32BLEBeacon : public Component { bool advertising_{false}; }; -} // namespace esp32_ble_beacon -} // namespace esphome +} // namespace esphome::esp32_ble_beacon #endif diff --git a/esphome/components/esp32_ble_server/ble_2902.cpp b/esphome/components/esp32_ble_server/ble_2902.cpp index 2f34573c37..90d0871a96 100644 --- a/esphome/components/esp32_ble_server/ble_2902.cpp +++ b/esphome/components/esp32_ble_server/ble_2902.cpp @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace esp32_ble_server { +namespace esphome::esp32_ble_server { BLE2902::BLE2902() : BLEDescriptor(esp32_ble::ESPBTUUID::from_uint16(0x2902)) { this->value_.attr_len = 2; @@ -14,7 +13,6 @@ BLE2902::BLE2902() : BLEDescriptor(esp32_ble::ESPBTUUID::from_uint16(0x2902)) { memcpy(this->value_.attr_value, data, 2); } -} // namespace esp32_ble_server -} // namespace esphome +} // namespace esphome::esp32_ble_server #endif diff --git a/esphome/components/esp32_ble_server/ble_2902.h b/esphome/components/esp32_ble_server/ble_2902.h index 64605924ad..46a5f73e9e 100644 --- a/esphome/components/esp32_ble_server/ble_2902.h +++ b/esphome/components/esp32_ble_server/ble_2902.h @@ -4,15 +4,13 @@ #ifdef USE_ESP32 -namespace esphome { -namespace esp32_ble_server { +namespace esphome::esp32_ble_server { class BLE2902 : public BLEDescriptor { public: BLE2902(); }; -} // namespace esp32_ble_server -} // namespace esphome +} // namespace esphome::esp32_ble_server #endif diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index aa82b773ba..cc519846be 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace esp32_ble_server { +namespace esphome::esp32_ble_server { static const char *const TAG = "esp32_ble_server.characteristic"; @@ -340,7 +339,6 @@ BLECharacteristic::ClientNotificationEntry *BLECharacteristic::find_client_in_no return nullptr; } -} // namespace esp32_ble_server -} // namespace esphome +} // namespace esphome::esp32_ble_server #endif diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 062052cdf8..94c7495cbd 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -17,8 +17,7 @@ #include #include -namespace esphome { -namespace esp32_ble_server { +namespace esphome::esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; @@ -109,7 +108,6 @@ class BLECharacteristic { } state_{INIT}; }; -} // namespace esp32_ble_server -} // namespace esphome +} // namespace esphome::esp32_ble_server #endif diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 4ffca7312b..5ca80d6a7a 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace esp32_ble_server { +namespace esphome::esp32_ble_server { static const char *const TAG = "esp32_ble_server.descriptor"; @@ -91,7 +90,6 @@ void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_ } } -} // namespace esp32_ble_server -} // namespace esphome +} // namespace esphome::esp32_ble_server #endif diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 5f4f146d6f..5096d39f28 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -11,8 +11,7 @@ #include #include -namespace esphome { -namespace esp32_ble_server { +namespace esphome::esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; @@ -63,7 +62,6 @@ class BLEDescriptor { } state_{INIT}; }; -} // namespace esp32_ble_server -} // namespace esphome +} // namespace esphome::esp32_ble_server #endif diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index be0691dc06..2dea1666bb 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -16,8 +16,7 @@ #include #include -namespace esphome { -namespace esp32_ble_server { +namespace esphome::esp32_ble_server { static const char *const TAG = "esp32_ble_server"; @@ -248,7 +247,6 @@ void BLEServer::dump_config() { BLEServer *global_ble_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace esp32_ble_server -} // namespace esphome +} // namespace esphome::esp32_ble_server #endif diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 9708ed40c8..9ba108499e 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -18,8 +18,7 @@ #include -namespace esphome { -namespace esp32_ble_server { +namespace esphome::esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; @@ -113,7 +112,6 @@ class BLEServer : public Component, public Parented { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern BLEServer *global_ble_server; -} // namespace esp32_ble_server -} // namespace esphome +} // namespace esphome::esp32_ble_server #endif diff --git a/esphome/components/esp32_ble_server/ble_server_automations.cpp b/esphome/components/esp32_ble_server/ble_server_automations.cpp index 0761de994a..1b15c90fe8 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.cpp +++ b/esphome/components/esp32_ble_server/ble_server_automations.cpp @@ -2,10 +2,8 @@ #ifdef USE_ESP32 -namespace esphome { -namespace esp32_ble_server { // Interface to interact with ESPHome automations and triggers -namespace esp32_ble_server_automations { +namespace esphome::esp32_ble_server::esp32_ble_server_automations { using namespace esp32_ble; @@ -86,8 +84,6 @@ void BLECharacteristicSetValueActionManager::remove_listener_(BLECharacteristic } #endif -} // namespace esp32_ble_server_automations -} // namespace esp32_ble_server -} // namespace esphome +} // namespace esphome::esp32_ble_server::esp32_ble_server_automations #endif diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index 0bbfdffd5b..b4e9ed004e 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -11,10 +11,8 @@ #ifdef USE_ESP32 -namespace esphome { -namespace esp32_ble_server { // Interface to interact with ESPHome actions and triggers -namespace esp32_ble_server_automations { +namespace esphome::esp32_ble_server::esp32_ble_server_automations { using namespace esp32_ble; @@ -125,8 +123,6 @@ template class BLEDescriptorSetValueAction : public Action #include -namespace esphome { -namespace esp32_ble_server { +namespace esphome::esp32_ble_server { class BLEServer; @@ -80,7 +79,6 @@ class BLEService { } state_{INIT}; }; -} // namespace esp32_ble_server -} // namespace esphome +} // namespace esphome::esp32_ble_server #endif diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index a7546476d8..598fe61d46 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace esp32_camera { +namespace esphome::esp32_camera { static const char *const TAG = "esp32_camera"; static constexpr size_t FRAMEBUFFER_TASK_STACK_SIZE = 1792; @@ -556,7 +555,6 @@ bool ESP32CameraImage::was_requested_by(camera::CameraRequester requester) const return (this->requesters_ & (1 << requester)) != 0; } -} // namespace esp32_camera -} // namespace esphome +} // namespace esphome::esp32_camera #endif diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 9fbd3848f2..7d020b5caf 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -15,8 +15,7 @@ #include "esphome/components/i2c/i2c_bus.h" #endif // USE_I2C -namespace esphome { -namespace esp32_camera { +namespace esphome::esp32_camera { class ESP32Camera; @@ -259,7 +258,6 @@ class ESP32CameraStreamStopTrigger : public Trigger<>, public camera::CameraList void on_stream_stop() override { this->trigger(); } }; -} // namespace esp32_camera -} // namespace esphome +} // namespace esphome::esp32_camera #endif diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index f49578c425..7527bbf7e4 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -11,8 +11,7 @@ #include #include -namespace esphome { -namespace esp32_camera_web_server { +namespace esphome::esp32_camera_web_server { static const int IMAGE_REQUEST_TIMEOUT = 5000; static const char *const TAG = "esp32_camera_web_server"; @@ -242,7 +241,6 @@ esp_err_t CameraWebServer::snapshot_handler_(struct httpd_req *req) { return res; } -} // namespace esp32_camera_web_server -} // namespace esphome +} // namespace esphome::esp32_camera_web_server #endif // USE_ESP32 diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.h b/esphome/components/esp32_camera_web_server/camera_web_server.h index ad7b29fb11..568dc68c46 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.h +++ b/esphome/components/esp32_camera_web_server/camera_web_server.h @@ -13,8 +13,7 @@ struct httpd_req; // NOLINT(readability-identifier-naming) -namespace esphome { -namespace esp32_camera_web_server { +namespace esphome::esp32_camera_web_server { enum Mode { STREAM, SNAPSHOT }; @@ -48,7 +47,6 @@ class CameraWebServer : public Component, public camera::CameraListener { Mode mode_{STREAM}; }; -} // namespace esp32_camera_web_server -} // namespace esphome +} // namespace esphome::esp32_camera_web_server #endif // USE_ESP32 diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index f521b63430..e04405c63c 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -9,8 +9,7 @@ #undef CAN_IO_UNUSED #define CAN_IO_UNUSED ((gpio_num_t) -1) -namespace esphome { -namespace esp32_can { +namespace esphome::esp32_can { static const char *const TAG = "esp32_can"; @@ -184,7 +183,6 @@ canbus::Error ESP32Can::read_message(struct canbus::CanFrame *frame) { return canbus::ERROR_OK; } -} // namespace esp32_can -} // namespace esphome +} // namespace esphome::esp32_can #endif diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index c3f200271b..2e10d254e6 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace esp32_can { +namespace esphome::esp32_can { enum CanMode : uint8_t { CAN_MODE_NORMAL = 0, @@ -41,7 +40,6 @@ class ESP32Can : public canbus::Canbus { twai_handle_t twai_handle_{nullptr}; }; -} // namespace esp32_can -} // namespace esphome +} // namespace esphome::esp32_can #endif diff --git a/esphome/components/esp32_dac/esp32_dac.cpp b/esphome/components/esp32_dac/esp32_dac.cpp index 8f226a5cc2..54b89c46ad 100644 --- a/esphome/components/esp32_dac/esp32_dac.cpp +++ b/esphome/components/esp32_dac/esp32_dac.cpp @@ -4,8 +4,7 @@ #if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) -namespace esphome { -namespace esp32_dac { +namespace esphome::esp32_dac { #ifdef USE_ESP32_VARIANT_ESP32S2 static constexpr uint8_t DAC0_PIN = 17; @@ -41,7 +40,6 @@ void ESP32DAC::write_state(float state) { dac_oneshot_output_voltage(this->dac_handle_, state); } -} // namespace esp32_dac -} // namespace esphome +} // namespace esphome::esp32_dac #endif // USE_ESP32_VARIANT_ESP32 || USE_ESP32_VARIANT_ESP32S2 diff --git a/esphome/components/esp32_dac/esp32_dac.h b/esphome/components/esp32_dac/esp32_dac.h index 95c687d307..108b96cd39 100644 --- a/esphome/components/esp32_dac/esp32_dac.h +++ b/esphome/components/esp32_dac/esp32_dac.h @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace esp32_dac { +namespace esphome::esp32_dac { class ESP32DAC : public output::FloatOutput, public Component { public: @@ -30,7 +29,6 @@ class ESP32DAC : public output::FloatOutput, public Component { dac_oneshot_handle_t dac_handle_; }; -} // namespace esp32_dac -} // namespace esphome +} // namespace esphome::esp32_dac #endif // USE_ESP32_VARIANT_ESP32 || USE_ESP32_VARIANT_ESP32S2 diff --git a/esphome/components/esp32_improv/automation.h b/esphome/components/esp32_improv/automation.h index cd2bd84c30..19e1b6e7e3 100644 --- a/esphome/components/esp32_improv/automation.h +++ b/esphome/components/esp32_improv/automation.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace esp32_improv { +namespace esphome::esp32_improv { class ESP32ImprovProvisionedTrigger : public Trigger<> { public: @@ -81,7 +80,7 @@ class ESP32ImprovStoppedTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -} // namespace esp32_improv -} // namespace esphome +} // namespace esphome::esp32_improv + #endif #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index c24b08b06f..183820256f 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -9,8 +9,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace esp32_improv { +namespace esphome::esp32_improv { using namespace bytebuffer; @@ -490,7 +489,6 @@ improv::State ESP32ImprovComponent::get_initial_state_() const { ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace esp32_improv -} // namespace esphome +} // namespace esphome::esp32_improv #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 41799f2325..400006cfb3 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -28,8 +28,7 @@ #include -namespace esphome { -namespace esp32_improv { +namespace esphome::esp32_improv { using namespace esp32_ble_server; @@ -124,7 +123,6 @@ class ESP32ImprovComponent : public Component, public improv_base::ImprovBase { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern ESP32ImprovComponent *global_improv_component; -} // namespace esp32_improv -} // namespace esphome +} // namespace esphome::esp32_improv #endif diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index ca97a181fd..ed2a8c5a68 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -9,8 +9,7 @@ #include #include -namespace esphome { -namespace esp32_rmt_led_strip { +namespace esphome::esp32_rmt_led_strip { static const char *const TAG = "esp32_rmt_led_strip"; @@ -305,7 +304,6 @@ void ESP32RMTLEDStripLightOutput::dump_config() { float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } -} // namespace esp32_rmt_led_strip -} // namespace esphome +} // namespace esphome::esp32_rmt_led_strip #endif // USE_ESP32 diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 6f3aea9878..8fb6b63afe 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -13,8 +13,7 @@ #include #include -namespace esphome { -namespace esp32_rmt_led_strip { +namespace esphome::esp32_rmt_led_strip { enum RGBOrder : uint8_t { ORDER_RGB, @@ -102,7 +101,6 @@ class ESP32RMTLEDStripLightOutput : public light::AddressableLight { optional max_refresh_rate_{}; }; -} // namespace esp32_rmt_led_strip -} // namespace esphome +} // namespace esphome::esp32_rmt_led_strip #endif // USE_ESP32 diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index 384e3fe2a9..1e37073321 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -5,8 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" -namespace esphome { -namespace espnow { +namespace esphome::espnow { static const char *const TAG = "espnow.transport"; @@ -86,7 +85,6 @@ bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *da return false; // Allow other handlers to run } -} // namespace espnow -} // namespace esphome +} // namespace esphome::espnow #endif // USE_ESP32 diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 98c33f01fd..5916a7fa5f 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace espnow { +namespace esphome::espnow { class ESPNowTransport : public packet_transport::PacketTransport, public Parented, @@ -37,7 +36,6 @@ class ESPNowTransport : public packet_transport::PacketTransport, std::vector packet_buffer_; }; -} // namespace espnow -} // namespace esphome +} // namespace esphome::espnow #endif // USE_ESP32 diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h index 7730506c10..3444a7b1bb 100644 --- a/esphome/components/event/automation.h +++ b/esphome/components/event/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace event { +namespace esphome::event { template class TriggerEventAction : public Action, public Parented { public: @@ -21,5 +20,4 @@ class EventTrigger : public Trigger { } }; -} // namespace event -} // namespace esphome +} // namespace esphome::event diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index a5d64a2748..673ccc9802 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -3,8 +3,7 @@ #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" -namespace esphome { -namespace event { +namespace esphome::event { static const char *const TAG = "event"; @@ -45,5 +44,4 @@ void Event::set_event_types(const std::vector &event_types) { this->last_event_type_ = nullptr; // Reset when types change } -} // namespace event -} // namespace esphome +} // namespace esphome::event diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index ebbee0bfe2..e6fc7111c8 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -10,8 +10,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" -namespace esphome { -namespace event { +namespace esphome::event { #define LOG_EVENT(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -80,5 +79,4 @@ class Event : public EntityBase { const char *last_event_type_{nullptr}; }; -} // namespace event -} // namespace esphome +} // namespace esphome::event diff --git a/esphome/components/exposure_notifications/exposure_notifications.cpp b/esphome/components/exposure_notifications/exposure_notifications.cpp index 307bee26f8..e7038d2ca9 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.cpp +++ b/esphome/components/exposure_notifications/exposure_notifications.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace exposure_notifications { +namespace esphome::exposure_notifications { using namespace esp32_ble_tracker; @@ -43,7 +42,6 @@ bool ExposureNotificationTrigger::parse_device(const ESPBTDevice &device) { return true; } -} // namespace exposure_notifications -} // namespace esphome +} // namespace esphome::exposure_notifications #endif diff --git a/esphome/components/exposure_notifications/exposure_notifications.h b/esphome/components/exposure_notifications/exposure_notifications.h index f7383c28d9..80184f9cfd 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.h +++ b/esphome/components/exposure_notifications/exposure_notifications.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace exposure_notifications { +namespace esphome::exposure_notifications { struct ExposureNotification { std::array address; @@ -23,7 +22,6 @@ class ExposureNotificationTrigger : public Trigger, bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; -} // namespace exposure_notifications -} // namespace esphome +} // namespace esphome::exposure_notifications #endif diff --git a/esphome/components/ezo/ezo.cpp b/esphome/components/ezo/ezo.cpp index bb8fb92f21..d18e6e67cb 100644 --- a/esphome/components/ezo/ezo.cpp +++ b/esphome/components/ezo/ezo.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace ezo { +namespace esphome::ezo { static const char *const EZO_COMMAND_TYPE_STRINGS[] = {"EZO_READ", "EZO_LED", "EZO_DEVICE_INFORMATION", "EZO_SLOPE", "EZO_CALIBRATION", "EZO_SLEEP", @@ -238,5 +237,4 @@ void EZOSensor::send_custom(const std::string &to_send) { this->add_command_(to_send.c_str(), EzoCommandType::EZO_CUSTOM); } -} // namespace ezo -} // namespace esphome +} // namespace esphome::ezo diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index d80869fbd9..aea276e001 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include -namespace esphome { -namespace ezo { +namespace esphome::ezo { static const char *const TAG = "ezo.sensor"; @@ -102,5 +101,4 @@ class EZOSensor : public sensor::Sensor, public PollingComponent, public i2c::I2 uint32_t start_time_ = 0; }; -} // namespace ezo -} // namespace esphome +} // namespace esphome::ezo diff --git a/esphome/components/ezo_pmp/ezo_pmp.cpp b/esphome/components/ezo_pmp/ezo_pmp.cpp index 4ce4da57ff..21307fcd7a 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.cpp +++ b/esphome/components/ezo_pmp/ezo_pmp.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace ezo_pmp { +namespace esphome::ezo_pmp { static const char *const TAG = "ezo-pmp"; @@ -547,5 +546,4 @@ void EzoPMP::exec_arbitrary_command(const std::basic_string &command) { this->queue_command_(EZO_PMP_COMMAND_EXEC_ARBITRARY_COMMAND_ADDRESS, 0, 0, true); } -} // namespace ezo_pmp -} // namespace esphome +} // namespace esphome::ezo_pmp diff --git a/esphome/components/ezo_pmp/ezo_pmp.h b/esphome/components/ezo_pmp/ezo_pmp.h index bbfd899170..8a6da5fe74 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.h +++ b/esphome/components/ezo_pmp/ezo_pmp.h @@ -17,8 +17,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #endif -namespace esphome { -namespace ezo_pmp { +namespace esphome::ezo_pmp { class EzoPMP : public PollingComponent, public i2c::I2CDevice { public: @@ -247,5 +246,4 @@ template class EzoPMPArbitraryCommandAction : public Action { StringRef last_preset_mode_{}; }; -} // namespace fan -} // namespace esphome +} // namespace esphome::fan diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 9301e0cea4..853bf94ffe 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/core/progmem.h" -namespace esphome { -namespace fan { +namespace esphome::fan { static const char *const TAG = "fan"; @@ -345,5 +344,4 @@ void Fan::dump_traits_(const char *tag, const char *prefix) { } } -} // namespace fan -} // namespace esphome +} // namespace esphome::fan diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index d5763edf2f..3d731e6eb0 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -8,8 +8,7 @@ #include "esphome/core/string_ref.h" #include "fan_traits.h" -namespace esphome { -namespace fan { +namespace esphome::fan { #define LOG_FAN(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -199,5 +198,4 @@ class Fan : public EntityBase { const char *preset_mode_{nullptr}; }; -} // namespace fan -} // namespace esphome +} // namespace esphome::fan diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index a2b2633af1..1d42cce371 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -5,9 +5,7 @@ #include #include "esphome/core/helpers.h" -namespace esphome { - -namespace fan { +namespace esphome::fan { class Fan; // Forward declaration @@ -95,5 +93,4 @@ class FanTraits { std::vector compat_preset_modes_; }; -} // namespace fan -} // namespace esphome +} // namespace esphome::fan diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 504b8d473e..8d1dd49dad 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -3,8 +3,7 @@ #include "fastled_light.h" #include "esphome/core/log.h" -namespace esphome { -namespace fastled_base { +namespace esphome::fastled_base { static const char *const TAG = "fastled"; @@ -39,7 +38,6 @@ void FastLEDLightOutput::write_state(light::LightState *state) { this->controller_->showLeds(this->state_parent_->current_values.get_brightness() * 255); } -} // namespace fastled_base -} // namespace esphome +} // namespace esphome::fastled_base #endif // USE_ARDUINO diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 26f0f33d2a..8e87f67e6d 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -15,8 +15,7 @@ #include "FastLED.h" -namespace esphome { -namespace fastled_base { +namespace esphome::fastled_base { class FastLEDLightOutput : public light::AddressableLight { public: @@ -237,7 +236,6 @@ class FastLEDLightOutput : public light::AddressableLight { optional max_refresh_rate_{}; }; -} // namespace fastled_base -} // namespace esphome +} // namespace esphome::fastled_base #endif // USE_ARDUINO diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index a633fbca28..3f57789034 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace fingerprint_grow { +namespace esphome::fingerprint_grow { static const char *const TAG = "fingerprint_grow"; @@ -581,5 +580,4 @@ void FingerprintGrowComponent::dump_config() { } } -} // namespace fingerprint_grow -} // namespace esphome +} // namespace esphome::fingerprint_grow diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.h b/esphome/components/fingerprint_grow/fingerprint_grow.h index 947c701c98..7cecb7dc82 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.h +++ b/esphome/components/fingerprint_grow/fingerprint_grow.h @@ -9,8 +9,7 @@ #include #include -namespace esphome { -namespace fingerprint_grow { +namespace esphome::fingerprint_grow { static const uint16_t START_CODE = 0xEF01; @@ -274,5 +273,4 @@ template class AuraLEDControlAction : public Action, publ } }; -} // namespace fingerprint_grow -} // namespace esphome +} // namespace esphome::fingerprint_grow diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index ecf0ca6bdd..fda9c269e5 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace font { +namespace esphome::font { static const char *const TAG = "font"; #ifdef USE_LVGL_FONT @@ -359,5 +358,4 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo } } #endif -} // namespace font -} // namespace esphome +} // namespace esphome::font diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index 4a09d7314d..9c9cfa0f6d 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -10,8 +10,7 @@ #include #endif -namespace esphome { -namespace font { +namespace esphome::font { class Font; @@ -98,5 +97,4 @@ class Font #endif }; -} // namespace font -} // namespace esphome +} // namespace esphome::font diff --git a/esphome/components/fs3000/fs3000.cpp b/esphome/components/fs3000/fs3000.cpp index cea599211d..a89b166182 100644 --- a/esphome/components/fs3000/fs3000.cpp +++ b/esphome/components/fs3000/fs3000.cpp @@ -1,8 +1,7 @@ #include "fs3000.h" #include "esphome/core/log.h" -namespace esphome { -namespace fs3000 { +namespace esphome::fs3000 { static const char *const TAG = "fs3000"; @@ -101,5 +100,4 @@ float FS3000Component::fit_raw_(uint16_t raw_value) { } } -} // namespace fs3000 -} // namespace esphome +} // namespace esphome::fs3000 diff --git a/esphome/components/fs3000/fs3000.h b/esphome/components/fs3000/fs3000.h index e33c72215f..c019b1366b 100644 --- a/esphome/components/fs3000/fs3000.h +++ b/esphome/components/fs3000/fs3000.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace fs3000 { +namespace esphome::fs3000 { // FS3000 has two models, 1005 and 1015 // 1005 has a max speed detection of 7.23 m/s @@ -30,5 +29,4 @@ class FS3000Component : public PollingComponent, public i2c::I2CDevice, public s float fit_raw_(uint16_t raw_value); }; -} // namespace fs3000 -} // namespace esphome +} // namespace esphome::fs3000 diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp index 505c3cffc0..835dc4aac0 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace ft5x06 { +namespace esphome::ft5x06 { static const char *const TAG = "ft5x06.touchscreen"; @@ -99,5 +98,4 @@ bool FT5x06Touchscreen::set_mode_(FTMode mode) { return this->err_check_(this->write_register(FT5X06_MODE_REG, (uint8_t *) &mode, 1), "Set mode"); } -} // namespace ft5x06 -} // namespace esphome +} // namespace esphome::ft5x06 diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h index 23e5a0c49f..7cf8769f7a 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h @@ -6,8 +6,7 @@ #include "esphome/core/gpio.h" #include "esphome/core/hal.h" -namespace esphome { -namespace ft5x06 { +namespace esphome::ft5x06 { enum VendorId { FT5X06_ID_UNKNOWN = 0, @@ -52,5 +51,4 @@ class FT5x06Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice InternalGPIOPin *interrupt_pin_{nullptr}; }; -} // namespace ft5x06 -} // namespace esphome +} // namespace esphome::ft5x06 diff --git a/esphome/components/ft63x6/ft63x6.cpp b/esphome/components/ft63x6/ft63x6.cpp index f7c4f255a0..dd8f5ba629 100644 --- a/esphome/components/ft63x6/ft63x6.cpp +++ b/esphome/components/ft63x6/ft63x6.cpp @@ -10,8 +10,8 @@ // Registers // Reference: https://focuslcds.com/content/FT6236.pdf -namespace esphome { -namespace ft63x6 { + +namespace esphome::ft63x6 { static const uint8_t FT6X36_ADDR_DEVICE_MODE = 0x00; static const uint8_t FT63X6_ADDR_TD_STATUS = 0x02; @@ -133,5 +133,4 @@ uint8_t FT63X6Touchscreen::read_byte_(uint8_t addr) { return byte; } -} // namespace ft63x6 -} // namespace esphome +} // namespace esphome::ft63x6 diff --git a/esphome/components/ft63x6/ft63x6.h b/esphome/components/ft63x6/ft63x6.h index 8000894294..efa03168d9 100644 --- a/esphome/components/ft63x6/ft63x6.h +++ b/esphome/components/ft63x6/ft63x6.h @@ -11,8 +11,7 @@ #include "esphome/components/touchscreen/touchscreen.h" #include "esphome/core/component.h" -namespace esphome { -namespace ft63x6 { +namespace esphome::ft63x6 { using namespace touchscreen; @@ -47,5 +46,4 @@ class FT63X6Touchscreen : public Touchscreen, public i2c::I2CDevice { uint8_t read_byte_(uint8_t addr); }; -} // namespace ft63x6 -} // namespace esphome +} // namespace esphome::ft63x6 diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index 8aa0f51728..f801239153 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -1,7 +1,6 @@ #include "fujitsu_general.h" -namespace esphome { -namespace fujitsu_general { +namespace esphome::fujitsu_general { // bytes' bits are reversed for fujitsu, so nibbles are ordered 1, 0, 3, 2, 5, 4, etc... @@ -400,5 +399,4 @@ bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { return true; } -} // namespace fujitsu_general -} // namespace esphome +} // namespace esphome::fujitsu_general diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index d7d01bf6f3..ca93e4b300 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -5,8 +5,7 @@ #include "esphome/core/automation.h" #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace fujitsu_general { +namespace esphome::fujitsu_general { const uint8_t FUJITSU_GENERAL_TEMP_MIN = 16; // Celsius // TODO 16 for heating, 18 for cooling, unsupported in ESPH const uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius @@ -78,5 +77,4 @@ class FujitsuGeneralClimate : public climate_ir::ClimateIR { bool power_{false}; }; -} // namespace fujitsu_general -} // namespace esphome +} // namespace esphome::fujitsu_general diff --git a/esphome/components/gcja5/gcja5.cpp b/esphome/components/gcja5/gcja5.cpp index 43b2fa20d3..e84c51bde8 100644 --- a/esphome/components/gcja5/gcja5.cpp +++ b/esphome/components/gcja5/gcja5.cpp @@ -9,8 +9,7 @@ #include "esphome/core/application.h" #include -namespace esphome { -namespace gcja5 { +namespace esphome::gcja5 { static const char *const TAG = "gcja5"; @@ -107,5 +106,4 @@ void GCJA5Component::parse_data_() { void GCJA5Component::dump_config() { ; } -} // namespace gcja5 -} // namespace esphome +} // namespace esphome::gcja5 diff --git a/esphome/components/gcja5/gcja5.h b/esphome/components/gcja5/gcja5.h index 30bc877169..30c9464b4a 100644 --- a/esphome/components/gcja5/gcja5.h +++ b/esphome/components/gcja5/gcja5.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace gcja5 { +namespace esphome::gcja5 { class GCJA5Component : public Component, public uart::UARTDevice { public: @@ -52,5 +51,4 @@ class GCJA5Component : public Component, public uart::UARTDevice { sensor::Sensor *pmc_10_0_sensor_{nullptr}; }; -} // namespace gcja5 -} // namespace esphome +} // namespace esphome::gcja5 diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 0ee718cd20..bc0ef70578 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace gdk101 { +namespace esphome::gdk101 { static const char *const TAG = "gdk101"; static constexpr uint8_t NUMBER_OF_READ_RETRIES = 5; @@ -206,5 +205,4 @@ bool GDK101Component::read_measurement_duration_(uint8_t *data) { return true; } -} // namespace gdk101 -} // namespace esphome +} // namespace esphome::gdk101 diff --git a/esphome/components/gdk101/gdk101.h b/esphome/components/gdk101/gdk101.h index abe3fd60d8..2ef7526294 100644 --- a/esphome/components/gdk101/gdk101.h +++ b/esphome/components/gdk101/gdk101.h @@ -13,8 +13,7 @@ #endif // USE_TEXT_SENSOR #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace gdk101 { +namespace esphome::gdk101 { static const uint8_t GDK101_REG_READ_FIRMWARE = 0xB4; // Firmware version static const uint8_t GDK101_REG_RESET = 0xA0; // Reset register - reading its value triggers reset @@ -55,5 +54,4 @@ class GDK101Component : public PollingComponent, public i2c::I2CDevice { uint8_t reset_retries_remaining_{0}; }; -} // namespace gdk101 -} // namespace esphome +} // namespace esphome::gdk101 diff --git a/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp b/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp index 38328c4b03..75f0c69a7b 100644 --- a/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp +++ b/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "gl_r01_i2c.h" -namespace esphome { -namespace gl_r01_i2c { +namespace esphome::gl_r01_i2c { static const char *const TAG = "gl_r01_i2c"; @@ -65,5 +64,4 @@ void GLR01I2CComponent::read_distance_() { } } -} // namespace gl_r01_i2c -} // namespace esphome +} // namespace esphome::gl_r01_i2c diff --git a/esphome/components/gl_r01_i2c/gl_r01_i2c.h b/esphome/components/gl_r01_i2c/gl_r01_i2c.h index 9a7aa023fd..1d023c245a 100644 --- a/esphome/components/gl_r01_i2c/gl_r01_i2c.h +++ b/esphome/components/gl_r01_i2c/gl_r01_i2c.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace gl_r01_i2c { +namespace esphome::gl_r01_i2c { class GLR01I2CComponent : public sensor::Sensor, public i2c::I2CDevice, public PollingComponent { public: @@ -18,5 +17,4 @@ class GLR01I2CComponent : public sensor::Sensor, public i2c::I2CDevice, public P uint16_t version_{0}; }; -} // namespace gl_r01_i2c -} // namespace esphome +} // namespace esphome::gl_r01_i2c diff --git a/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp b/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp index c8b0f13d3a..0dd4a13a21 100644 --- a/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp +++ b/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace gp2y1010au0f { +namespace esphome::gp2y1010au0f { static const char *const TAG = "gp2y1010au0f"; static const float MIN_VOLTAGE = 0.0f; @@ -65,5 +64,4 @@ void GP2Y1010AU0FSensor::loop() { this->sample_sum_ += read_voltage; } -} // namespace gp2y1010au0f -} // namespace esphome +} // namespace esphome::gp2y1010au0f diff --git a/esphome/components/gp2y1010au0f/gp2y1010au0f.h b/esphome/components/gp2y1010au0f/gp2y1010au0f.h index 5ee58e68d2..f3398ac4a3 100644 --- a/esphome/components/gp2y1010au0f/gp2y1010au0f.h +++ b/esphome/components/gp2y1010au0f/gp2y1010au0f.h @@ -5,8 +5,7 @@ #include "esphome/components/voltage_sampler/voltage_sampler.h" #include "esphome/components/output/binary_output.h" -namespace esphome { -namespace gp2y1010au0f { +namespace esphome::gp2y1010au0f { class GP2Y1010AU0FSensor : public sensor::Sensor, public PollingComponent { public: @@ -48,5 +47,4 @@ class GP2Y1010AU0FSensor : public sensor::Sensor, public PollingComponent { bool is_sampling_ = false; }; -} // namespace gp2y1010au0f -} // namespace esphome +} // namespace esphome::gp2y1010au0f diff --git a/esphome/components/gp8403/gp8403.cpp b/esphome/components/gp8403/gp8403.cpp index 11c2f9a7c0..7e93bb6295 100644 --- a/esphome/components/gp8403/gp8403.cpp +++ b/esphome/components/gp8403/gp8403.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace gp8403 { +namespace esphome::gp8403 { static const char *const TAG = "gp8403"; @@ -51,5 +50,4 @@ void GP8403Component::write_state(float state, uint8_t channel) { } } -} // namespace gp8403 -} // namespace esphome +} // namespace esphome::gp8403 diff --git a/esphome/components/gp8403/gp8403.h b/esphome/components/gp8403/gp8403.h index a19df15515..d30d967479 100644 --- a/esphome/components/gp8403/gp8403.h +++ b/esphome/components/gp8403/gp8403.h @@ -3,8 +3,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -namespace esphome { -namespace gp8403 { +namespace esphome::gp8403 { enum GP8403Voltage : uint8_t { GP8403_VOLTAGE_5V = 0x00, @@ -30,5 +29,4 @@ class GP8403Component : public Component, public i2c::I2CDevice { GP8403Model model_{GP8403Model::GP8403}; }; -} // namespace gp8403 -} // namespace esphome +} // namespace esphome::gp8403 diff --git a/esphome/components/gp8403/output/gp8403_output.cpp b/esphome/components/gp8403/output/gp8403_output.cpp index dfdc2d6ccb..7a22a280ac 100644 --- a/esphome/components/gp8403/output/gp8403_output.cpp +++ b/esphome/components/gp8403/output/gp8403_output.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace gp8403 { +namespace esphome::gp8403 { static const char *const TAG = "gp8403.output"; @@ -16,5 +15,4 @@ void GP8403Output::dump_config() { void GP8403Output::write_state(float state) { this->parent_->write_state(state, this->channel_); } -} // namespace gp8403 -} // namespace esphome +} // namespace esphome::gp8403 diff --git a/esphome/components/gp8403/output/gp8403_output.h b/esphome/components/gp8403/output/gp8403_output.h index c0d6650500..8b1f920680 100644 --- a/esphome/components/gp8403/output/gp8403_output.h +++ b/esphome/components/gp8403/output/gp8403_output.h @@ -5,8 +5,7 @@ #include "esphome/components/gp8403/gp8403.h" -namespace esphome { -namespace gp8403 { +namespace esphome::gp8403 { class GP8403Output : public Component, public output::FloatOutput, public Parented { public: @@ -19,5 +18,4 @@ class GP8403Output : public Component, public output::FloatOutput, public Parent uint8_t channel_; }; -} // namespace gp8403 -} // namespace esphome +} // namespace esphome::gp8403 diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 1f0154c70b..ff07d76901 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/progmem.h" -namespace esphome { -namespace gpio { +namespace esphome::gpio { static const char *const TAG = "gpio.binary_sensor"; @@ -86,5 +85,4 @@ void GPIOBinarySensor::loop() { float GPIOBinarySensor::get_setup_priority() const { return setup_priority::HARDWARE; } -} // namespace gpio -} // namespace esphome +} // namespace esphome::gpio diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h index 24efc2a0e6..100edb4cca 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h @@ -5,8 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace gpio { +namespace esphome::gpio { // Store class for ISR data and configuration (no vtables, ISR-safe) class GPIOBinarySensorStore { @@ -64,5 +63,4 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon GPIOBinarySensorStore store_; }; -} // namespace gpio -} // namespace esphome +} // namespace esphome::gpio diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.cpp b/esphome/components/gpio/one_wire/gpio_one_wire.cpp index 4e2a306fc9..1fecfbf0dd 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.cpp +++ b/esphome/components/gpio/one_wire/gpio_one_wire.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace gpio { +namespace esphome::gpio { static const char *const TAG = "gpio.one_wire"; @@ -202,5 +201,4 @@ uint64_t IRAM_ATTR GPIOOneWireBus::search_int() { return address; } -} // namespace gpio -} // namespace esphome +} // namespace esphome::gpio diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.h b/esphome/components/gpio/one_wire/gpio_one_wire.h index 8874703971..02797b5737 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.h +++ b/esphome/components/gpio/one_wire/gpio_one_wire.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/one_wire/one_wire.h" -namespace esphome { -namespace gpio { +namespace esphome::gpio { class GPIOOneWireBus : public one_wire::OneWireBus, public Component { public: @@ -38,5 +37,4 @@ class GPIOOneWireBus : public one_wire::OneWireBus, public Component { bool read_bit_(uint32_t *t); }; -} // namespace gpio -} // namespace esphome +} // namespace esphome::gpio diff --git a/esphome/components/gpio/output/gpio_binary_output.cpp b/esphome/components/gpio/output/gpio_binary_output.cpp index 13538b6f2b..402ad03c49 100644 --- a/esphome/components/gpio/output/gpio_binary_output.cpp +++ b/esphome/components/gpio/output/gpio_binary_output.cpp @@ -1,8 +1,7 @@ #include "gpio_binary_output.h" #include "esphome/core/log.h" -namespace esphome { -namespace gpio { +namespace esphome::gpio { static const char *const TAG = "gpio.output"; @@ -12,5 +11,4 @@ void GPIOBinaryOutput::dump_config() { LOG_BINARY_OUTPUT(this); } -} // namespace gpio -} // namespace esphome +} // namespace esphome::gpio diff --git a/esphome/components/gpio/output/gpio_binary_output.h b/esphome/components/gpio/output/gpio_binary_output.h index 6b72c61c0f..4100cb94c2 100644 --- a/esphome/components/gpio/output/gpio_binary_output.h +++ b/esphome/components/gpio/output/gpio_binary_output.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/output/binary_output.h" -namespace esphome { -namespace gpio { +namespace esphome::gpio { class GPIOBinaryOutput : public output::BinaryOutput, public Component { public: @@ -25,5 +24,4 @@ class GPIOBinaryOutput : public output::BinaryOutput, public Component { GPIOPin *pin_; }; -} // namespace gpio -} // namespace esphome +} // namespace esphome::gpio diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index 9c6464815a..d432655a2a 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -1,8 +1,7 @@ #include "gpio_switch.h" #include "esphome/core/log.h" -namespace esphome { -namespace gpio { +namespace esphome::gpio { static const char *const TAG = "switch.gpio"; #ifdef USE_GPIO_SWITCH_INTERLOCK @@ -79,5 +78,4 @@ void GPIOSwitch::write_state(bool state) { void GPIOSwitch::set_interlock(const std::initializer_list &interlock) { this->interlock_ = interlock; } #endif -} // namespace gpio -} // namespace esphome +} // namespace esphome::gpio diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index f7415d1dba..7ed0de7c6f 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -5,8 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace gpio { +namespace esphome::gpio { class GPIOSwitch final : public switch_::Switch, public Component { public: @@ -33,5 +32,4 @@ class GPIOSwitch final : public switch_::Switch, public Component { #endif }; -} // namespace gpio -} // namespace esphome +} // namespace esphome::gpio diff --git a/esphome/components/gps/gps.cpp b/esphome/components/gps/gps.cpp index 65cddcd984..4b8abd219b 100644 --- a/esphome/components/gps/gps.cpp +++ b/esphome/components/gps/gps.cpp @@ -1,8 +1,7 @@ #include "gps.h" #include "esphome/core/log.h" -namespace esphome { -namespace gps { +namespace esphome::gps { static const char *const TAG = "gps"; @@ -91,5 +90,4 @@ void GPS::loop() { } } -} // namespace gps -} // namespace esphome +} // namespace esphome::gps diff --git a/esphome/components/gps/gps.h b/esphome/components/gps/gps.h index 36923c68be..9cd79e25b4 100644 --- a/esphome/components/gps/gps.h +++ b/esphome/components/gps/gps.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace gps { +namespace esphome::gps { class GPS; @@ -67,5 +66,4 @@ class GPS : public PollingComponent, public uart::UARTDevice { std::vector listeners_{}; }; -} // namespace gps -} // namespace esphome +} // namespace esphome::gps diff --git a/esphome/components/gps/time/gps_time.cpp b/esphome/components/gps/time/gps_time.cpp index fb662a3d60..3859983ceb 100644 --- a/esphome/components/gps/time/gps_time.cpp +++ b/esphome/components/gps/time/gps_time.cpp @@ -1,8 +1,7 @@ #include "gps_time.h" #include "esphome/core/log.h" -namespace esphome { -namespace gps { +namespace esphome::gps { static const char *const TAG = "gps.time"; @@ -24,5 +23,4 @@ void GPSTime::from_tiny_gps_(TinyGPSPlus &tiny_gps) { this->has_time_ = true; } -} // namespace gps -} // namespace esphome +} // namespace esphome::gps diff --git a/esphome/components/gps/time/gps_time.h b/esphome/components/gps/time/gps_time.h index a8414f0015..3d6d870efc 100644 --- a/esphome/components/gps/time/gps_time.h +++ b/esphome/components/gps/time/gps_time.h @@ -4,8 +4,7 @@ #include "esphome/components/time/real_time_clock.h" #include "esphome/core/component.h" -namespace esphome { -namespace gps { +namespace esphome::gps { class GPSTime : public time::RealTimeClock, public GPSListener { public: @@ -21,5 +20,4 @@ class GPSTime : public time::RealTimeClock, public GPSListener { bool has_time_{false}; }; -} // namespace gps -} // namespace esphome +} // namespace esphome::gps diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index 801c97e3f5..5d59f60509 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -4,8 +4,8 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" #include -namespace esphome { -namespace graph { + +namespace esphome::graph { using namespace display; @@ -397,5 +397,4 @@ void Graph::dump_config() { } } -} // namespace graph -} // namespace esphome +} // namespace esphome::graph diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index cf1672f217..81971e457c 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -5,8 +5,7 @@ #include #include "esphome/components/display/display.h" -namespace esphome { -namespace graphical_display_menu { +namespace esphome::graphical_display_menu { static const char *const TAG = "graphical_display_menu"; @@ -246,5 +245,4 @@ void GraphicalDisplayMenu::draw_item(const display_menu_base::MenuItem *item, co void GraphicalDisplayMenu::update() { this->on_redraw_callbacks_.call(); } -} // namespace graphical_display_menu -} // namespace esphome +} // namespace esphome::graphical_display_menu diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 732ebd9632..705c741dd0 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -1,8 +1,7 @@ #include "gree.h" #include "esphome/components/remote_base/remote_base.h" -namespace esphome { -namespace gree { +namespace esphome::gree { static const char *const TAG = "gree.climate"; @@ -241,5 +240,4 @@ uint8_t GreeClimate::preset_() { return GREE_PRESET_NONE; } -} // namespace gree -} // namespace esphome +} // namespace esphome::gree diff --git a/esphome/components/gree/switch/gree_switch.cpp b/esphome/components/gree/switch/gree_switch.cpp index 13f14e5453..2f649733af 100644 --- a/esphome/components/gree/switch/gree_switch.cpp +++ b/esphome/components/gree/switch/gree_switch.cpp @@ -1,8 +1,7 @@ #include "gree_switch.h" #include "esphome/core/log.h" -namespace esphome { -namespace gree { +namespace esphome::gree { static const char *const TAG = "gree.switch"; @@ -20,5 +19,4 @@ void GreeModeBitSwitch::write_state(bool state) { this->publish_state(state); } -} // namespace gree -} // namespace esphome +} // namespace esphome::gree diff --git a/esphome/components/gree/switch/gree_switch.h b/esphome/components/gree/switch/gree_switch.h index 239ac4bf17..9d9f187f9d 100644 --- a/esphome/components/gree/switch/gree_switch.h +++ b/esphome/components/gree/switch/gree_switch.h @@ -4,8 +4,7 @@ #include "esphome/components/switch/switch.h" #include "esphome/components/gree/gree.h" -namespace esphome { -namespace gree { +namespace esphome::gree { class GreeModeBitSwitch : public switch_::Switch, public Component, public Parented { public: @@ -20,5 +19,4 @@ class GreeModeBitSwitch : public switch_::Switch, public Component, public Paren uint8_t bit_mask_; }; -} // namespace gree -} // namespace esphome +} // namespace esphome::gree diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp index b0f3429314..0de0b02182 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace grove_gas_mc_v2 { +namespace esphome::grove_gas_mc_v2 { static const char *const TAG = "grove_gas_mc_v2"; @@ -82,5 +81,4 @@ void GroveGasMultichannelV2Component::dump_config() { } } -} // namespace grove_gas_mc_v2 -} // namespace esphome +} // namespace esphome::grove_gas_mc_v2 diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h index aab881bd05..38165ab68c 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/preferences.h" -namespace esphome { -namespace grove_gas_mc_v2 { +namespace esphome::grove_gas_mc_v2 { class GroveGasMultichannelV2Component : public PollingComponent, public i2c::I2CDevice { SUB_SENSOR(tvoc) @@ -33,5 +32,4 @@ class GroveGasMultichannelV2Component : public PollingComponent, public i2c::I2C bool read_sensor_(uint8_t address, sensor::Sensor *sensor); }; -} // namespace grove_gas_mc_v2 -} // namespace esphome +} // namespace esphome::grove_gas_mc_v2 diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index c10fa4cf25..eaa1440c4d 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace grove_tb6612fng { +namespace esphome::grove_tb6612fng { static const char *const TAG = "GroveMotorDriveTB6612FNG"; @@ -167,5 +166,4 @@ void GroveMotorDriveTB6612FNG::stepper_keep_run(StepperModeTypeT mode, uint16_t return; } } -} // namespace grove_tb6612fng -} // namespace esphome +} // namespace esphome::grove_tb6612fng diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.h b/esphome/components/grove_tb6612fng/grove_tb6612fng.h index bf47163226..c021680519 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.h +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.h @@ -4,7 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" -//#include "esphome/core/helpers.h" +// #include "esphome/core/helpers.h" /* Grove_Motor_Driver_TB6612FNG.h @@ -33,8 +33,7 @@ THE SOFTWARE. */ -namespace esphome { -namespace grove_tb6612fng { +namespace esphome::grove_tb6612fng { enum MotorChannelTypeT { MOTOR_CHA = 0, @@ -219,5 +218,4 @@ class GROVETB6612FNGMotorChangeAddressAction : public Action, public Pare void play(const Ts &...x) override { this->parent_->set_i2c_addr(this->address_.value(x...)); } }; -} // namespace grove_tb6612fng -} // namespace esphome +} // namespace esphome::grove_tb6612fng diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index 2997425872..41beb6e4e9 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace growatt_solar { +namespace esphome::growatt_solar { static const char *const TAG = "growatt_solar"; @@ -141,5 +140,4 @@ void GrowattSolar::dump_config() { this->address_); } -} // namespace growatt_solar -} // namespace esphome +} // namespace esphome::growatt_solar diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 833d6a36dd..7eba795601 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace growatt_solar { +namespace esphome::growatt_solar { static const float TWO_DEC_UNIT = 0.01; static const float ONE_DEC_UNIT = 0.1; @@ -83,5 +82,4 @@ class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { GrowattProtocolVersion protocol_version_; }; -} // namespace growatt_solar -} // namespace esphome +} // namespace esphome::growatt_solar diff --git a/esphome/components/gt911/binary_sensor/gt911_button.cpp b/esphome/components/gt911/binary_sensor/gt911_button.cpp index 35ffaecefc..7b356f3946 100644 --- a/esphome/components/gt911/binary_sensor/gt911_button.cpp +++ b/esphome/components/gt911/binary_sensor/gt911_button.cpp @@ -1,8 +1,7 @@ #include "gt911_button.h" #include "esphome/core/log.h" -namespace esphome { -namespace gt911 { +namespace esphome::gt911 { static const char *const TAG = "GT911.binary_sensor"; @@ -23,5 +22,4 @@ void GT911Button::update_button(uint8_t index, bool state) { this->publish_state(state); } -} // namespace gt911 -} // namespace esphome +} // namespace esphome::gt911 diff --git a/esphome/components/gt911/binary_sensor/gt911_button.h b/esphome/components/gt911/binary_sensor/gt911_button.h index 556ed65f91..5aab457095 100644 --- a/esphome/components/gt911/binary_sensor/gt911_button.h +++ b/esphome/components/gt911/binary_sensor/gt911_button.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace gt911 { +namespace esphome::gt911 { class GT911Button : public binary_sensor::BinarySensor, public Component, @@ -24,5 +23,4 @@ class GT911Button : public binary_sensor::BinarySensor, uint8_t index_; }; -} // namespace gt911 -} // namespace esphome +} // namespace esphome::gt911 diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 17bfa82cb4..2152ae7b84 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/core/gpio.h" -namespace esphome { -namespace gt911 { +namespace esphome::gt911 { static const char *const TAG = "gt911.touchscreen"; @@ -157,5 +156,4 @@ void GT911Touchscreen::dump_config() { LOG_PIN(" Reset Pin: ", this->reset_pin_); } -} // namespace gt911 -} // namespace esphome +} // namespace esphome::gt911 diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index a6577b5879..0f1eeae720 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace gt911 { +namespace esphome::gt911 { class GT911ButtonListener { public: @@ -57,5 +56,4 @@ class GT911Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice uint8_t button_state_{0xFF}; // last button state. Initial FF guarantees first update. }; -} // namespace gt911 -} // namespace esphome +} // namespace esphome::gt911 diff --git a/esphome/components/haier/automation.h b/esphome/components/haier/automation.h index c1ce7c01ea..e345867d6f 100644 --- a/esphome/components/haier/automation.h +++ b/esphome/components/haier/automation.h @@ -4,8 +4,7 @@ #include "haier_base.h" #include "hon_climate.h" -namespace esphome { -namespace haier { +namespace esphome::haier { template class DisplayOnAction : public Action { public: @@ -126,5 +125,4 @@ template class PowerToggleAction : public Action { HaierClimateBase *parent_; }; -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/button/self_cleaning.cpp b/esphome/components/haier/button/self_cleaning.cpp index 128726036e..bf4baa716e 100644 --- a/esphome/components/haier/button/self_cleaning.cpp +++ b/esphome/components/haier/button/self_cleaning.cpp @@ -1,9 +1,7 @@ #include "self_cleaning.h" -namespace esphome { -namespace haier { +namespace esphome::haier { void SelfCleaningButton::press_action() { this->parent_->start_self_cleaning(); } -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/button/self_cleaning.h b/esphome/components/haier/button/self_cleaning.h index 308fb70f06..9d330e4dfe 100644 --- a/esphome/components/haier/button/self_cleaning.h +++ b/esphome/components/haier/button/self_cleaning.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../hon_climate.h" -namespace esphome { -namespace haier { +namespace esphome::haier { class SelfCleaningButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class SelfCleaningButton : public button::Button, public Parented { void press_action() override; }; -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/button/steri_cleaning.cpp b/esphome/components/haier/button/steri_cleaning.cpp index 02b723f1a4..8c4f5808b2 100644 --- a/esphome/components/haier/button/steri_cleaning.cpp +++ b/esphome/components/haier/button/steri_cleaning.cpp @@ -1,9 +1,7 @@ #include "steri_cleaning.h" -namespace esphome { -namespace haier { +namespace esphome::haier { void SteriCleaningButton::press_action() { this->parent_->start_steri_cleaning(); } -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/button/steri_cleaning.h b/esphome/components/haier/button/steri_cleaning.h index 6cad313fb3..cac02dd267 100644 --- a/esphome/components/haier/button/steri_cleaning.h +++ b/esphome/components/haier/button/steri_cleaning.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../hon_climate.h" -namespace esphome { -namespace haier { +namespace esphome::haier { class SteriCleaningButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class SteriCleaningButton : public button::Button, public Parented { void press_action() override; }; -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 4a06066d3c..74a218263d 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -10,8 +10,7 @@ using namespace esphome::climate; using namespace esphome::uart; -namespace esphome { -namespace haier { +namespace esphome::haier { static const char *const TAG = "haier.climate"; constexpr size_t COMMUNICATION_TIMEOUT_MS = 60000; @@ -418,5 +417,4 @@ void HaierClimateBase::send_message_(const haier_protocol::HaierMessage &command this->last_request_timestamp_ = std::chrono::steady_clock::now(); } -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index 0c416623c0..13e8d7548d 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -11,8 +11,7 @@ #include "esphome/components/switch/switch.h" #endif -namespace esphome { -namespace haier { +namespace esphome::haier { enum class ActionRequest : uint8_t { SEND_CUSTOM_COMMAND = 0, @@ -177,5 +176,4 @@ class HaierClimateBase : public esphome::Component, ESPPreferenceObject base_rtc_; }; -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 87b8add2a3..0ad9b00ce4 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -9,8 +9,7 @@ using namespace esphome::climate; using namespace esphome::uart; -namespace esphome { -namespace haier { +namespace esphome::haier { static const char *const TAG = "haier.climate"; constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; @@ -1370,5 +1369,4 @@ bool HonClimate::should_get_big_data_() { return false; } -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index a0bcdfb548..5b477a5cea 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -18,8 +18,7 @@ #include "haier_base.h" #include "hon_packet.h" -namespace esphome { -namespace haier { +namespace esphome::haier { enum class CleaningState : uint8_t { NO_CLEANING = 0, @@ -201,5 +200,4 @@ class HonClimate : public HaierClimateBase { SwitchState quiet_mode_state_{SwitchState::OFF}; }; -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/hon_packet.h b/esphome/components/haier/hon_packet.h index 615f93528e..63799f04ba 100644 --- a/esphome/components/haier/hon_packet.h +++ b/esphome/components/haier/hon_packet.h @@ -2,9 +2,7 @@ #include -namespace esphome { -namespace haier { -namespace hon_protocol { +namespace esphome::haier::hon_protocol { enum class VerticalSwingMode : uint8_t { HEALTH_UP = 0x01, @@ -255,6 +253,4 @@ const std::string HON_ALARM_MESSAGES[] = { constexpr size_t HON_ALARM_COUNT = sizeof(HON_ALARM_MESSAGES) / sizeof(HON_ALARM_MESSAGES[0]); -} // namespace hon_protocol -} // namespace haier -} // namespace esphome +} // namespace esphome::haier::hon_protocol diff --git a/esphome/components/haier/logger_handler.cpp b/esphome/components/haier/logger_handler.cpp index f886318097..1c4004cf6c 100644 --- a/esphome/components/haier/logger_handler.cpp +++ b/esphome/components/haier/logger_handler.cpp @@ -1,8 +1,7 @@ #include "logger_handler.h" #include "esphome/core/log.h" -namespace esphome { -namespace haier { +namespace esphome::haier { void esphome_logger(haier_protocol::HaierLogLevel level, const char *tag, const char *message) { switch (level) { @@ -29,5 +28,4 @@ void esphome_logger(haier_protocol::HaierLogLevel level, const char *tag, const void init_haier_protocol_logging() { haier_protocol::set_log_handler(esphome::haier::esphome_logger); }; -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/logger_handler.h b/esphome/components/haier/logger_handler.h index 2955468f37..192259409f 100644 --- a/esphome/components/haier/logger_handler.h +++ b/esphome/components/haier/logger_handler.h @@ -3,12 +3,10 @@ // HaierProtocol #include -namespace esphome { -namespace haier { +namespace esphome::haier { // This file is called in the code generated by python script // Do not use it directly! void init_haier_protocol_logging(); -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index 752f4d4f1c..bd5678a425 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -7,8 +7,7 @@ using namespace esphome::climate; using namespace esphome::uart; -namespace esphome { -namespace haier { +namespace esphome::haier { static const char *const TAG = "haier.climate"; constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; @@ -555,5 +554,4 @@ void Smartair2Climate::set_alternative_swing_control(bool swing_control) { this->use_alternative_swing_control_ = swing_control; } -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/smartair2_climate.h b/esphome/components/haier/smartair2_climate.h index 6914d8a1fb..68b0e4a0db 100644 --- a/esphome/components/haier/smartair2_climate.h +++ b/esphome/components/haier/smartair2_climate.h @@ -3,8 +3,7 @@ #include #include "haier_base.h" -namespace esphome { -namespace haier { +namespace esphome::haier { class Smartair2Climate : public HaierClimateBase { public: @@ -36,5 +35,4 @@ class Smartair2Climate : public HaierClimateBase { bool use_alternative_swing_control_; }; -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/smartair2_packet.h b/esphome/components/haier/smartair2_packet.h index 22570ff048..144b7db879 100644 --- a/esphome/components/haier/smartair2_packet.h +++ b/esphome/components/haier/smartair2_packet.h @@ -1,8 +1,6 @@ #pragma once -namespace esphome { -namespace haier { -namespace smartair2_protocol { +namespace esphome::haier::smartair2_protocol { enum class ConditioningMode : uint8_t { AUTO = 0x00, COOL = 0x01, HEAT = 0x02, FAN = 0x03, DRY = 0x04 }; @@ -83,6 +81,4 @@ struct HaierStatus { HaierPacketControl control; }; -} // namespace smartair2_protocol -} // namespace haier -} // namespace esphome +} // namespace esphome::haier::smartair2_protocol diff --git a/esphome/components/haier/switch/beeper.cpp b/esphome/components/haier/switch/beeper.cpp index 1ce64d0848..40b048be5c 100644 --- a/esphome/components/haier/switch/beeper.cpp +++ b/esphome/components/haier/switch/beeper.cpp @@ -1,7 +1,6 @@ #include "beeper.h" -namespace esphome { -namespace haier { +namespace esphome::haier { void BeeperSwitch::write_state(bool state) { if (this->parent_->get_beeper_state() != state) { @@ -10,5 +9,4 @@ void BeeperSwitch::write_state(bool state) { this->publish_state(state); } -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/switch/beeper.h b/esphome/components/haier/switch/beeper.h index 7396a7a0dd..2d20f1cd83 100644 --- a/esphome/components/haier/switch/beeper.h +++ b/esphome/components/haier/switch/beeper.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../hon_climate.h" -namespace esphome { -namespace haier { +namespace esphome::haier { class BeeperSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class BeeperSwitch : public switch_::Switch, public Parented { void write_state(bool state) override; }; -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/switch/display.cpp b/esphome/components/haier/switch/display.cpp index 5e24843dcf..e34b45985f 100644 --- a/esphome/components/haier/switch/display.cpp +++ b/esphome/components/haier/switch/display.cpp @@ -1,7 +1,6 @@ #include "display.h" -namespace esphome { -namespace haier { +namespace esphome::haier { void DisplaySwitch::write_state(bool state) { if (this->parent_->get_display_state() != state) { @@ -10,5 +9,4 @@ void DisplaySwitch::write_state(bool state) { this->publish_state(state); } -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/switch/display.h b/esphome/components/haier/switch/display.h index f93ccfcdb7..9baf3b9fb8 100644 --- a/esphome/components/haier/switch/display.h +++ b/esphome/components/haier/switch/display.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../haier_base.h" -namespace esphome { -namespace haier { +namespace esphome::haier { class DisplaySwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class DisplaySwitch : public switch_::Switch, public Parented void write_state(bool state) override; }; -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/switch/health_mode.cpp b/esphome/components/haier/switch/health_mode.cpp index 3715759bdd..c8656fe0d4 100644 --- a/esphome/components/haier/switch/health_mode.cpp +++ b/esphome/components/haier/switch/health_mode.cpp @@ -1,7 +1,6 @@ #include "health_mode.h" -namespace esphome { -namespace haier { +namespace esphome::haier { void HealthModeSwitch::write_state(bool state) { if (this->parent_->get_health_mode() != state) { @@ -10,5 +9,4 @@ void HealthModeSwitch::write_state(bool state) { this->publish_state(state); } -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/switch/health_mode.h b/esphome/components/haier/switch/health_mode.h index cfd2aa2f22..ec77b1638a 100644 --- a/esphome/components/haier/switch/health_mode.h +++ b/esphome/components/haier/switch/health_mode.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../haier_base.h" -namespace esphome { -namespace haier { +namespace esphome::haier { class HealthModeSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class HealthModeSwitch : public switch_::Switch, public Parentedparent_->get_quiet_mode_state() != state) { @@ -10,5 +9,4 @@ void QuietModeSwitch::write_state(bool state) { this->publish_state(state); } -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/haier/switch/quiet_mode.h b/esphome/components/haier/switch/quiet_mode.h index bad5289500..8ef7b5bb89 100644 --- a/esphome/components/haier/switch/quiet_mode.h +++ b/esphome/components/haier/switch/quiet_mode.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../hon_climate.h" -namespace esphome { -namespace haier { +namespace esphome::haier { class QuietModeSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class QuietModeSwitch : public switch_::Switch, public Parented { void write_state(bool state) override; }; -} // namespace haier -} // namespace esphome +} // namespace esphome::haier diff --git a/esphome/components/havells_solar/havells_solar.cpp b/esphome/components/havells_solar/havells_solar.cpp index 20dddf39ed..9257a37fd9 100644 --- a/esphome/components/havells_solar/havells_solar.cpp +++ b/esphome/components/havells_solar/havells_solar.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace havells_solar { +namespace esphome::havells_solar { static const char *const TAG = "havells_solar"; @@ -164,5 +163,4 @@ void HavellsSolar::dump_config() { LOG_SENSOR(" ", "DCI Of T", this->dci_of_t_sensor_); } -} // namespace havells_solar -} // namespace esphome +} // namespace esphome::havells_solar diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index f3ac8fafcf..c54b0dcf14 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace havells_solar { +namespace esphome::havells_solar { class HavellsSolar : public PollingComponent, public modbus::ModbusDevice { public: @@ -113,5 +112,4 @@ class HavellsSolar : public PollingComponent, public modbus::ModbusDevice { sensor::Sensor *dci_of_t_sensor_{nullptr}; }; -} // namespace havells_solar -} // namespace esphome +} // namespace esphome::havells_solar diff --git a/esphome/components/havells_solar/havells_solar_registers.h b/esphome/components/havells_solar/havells_solar_registers.h index 8e1cb3ec7a..4ed797b3e7 100644 --- a/esphome/components/havells_solar/havells_solar_registers.h +++ b/esphome/components/havells_solar/havells_solar_registers.h @@ -1,6 +1,6 @@ #pragma once -namespace esphome { -namespace havells_solar { + +namespace esphome::havells_solar { static const float TWO_DEC_UNIT = 0.01; static const float ONE_DEC_UNIT = 0.1; @@ -45,5 +45,4 @@ static const uint16_t HAVELLS_GFCI_VALUE = 0x002A; static const uint16_t HAVELLS_DCI_OF_R = 0x002B; static const uint16_t HAVELLS_DCI_OF_S = 0x002C; static const uint16_t HAVELLS_DCI_OF_T = 0x002D; -} // namespace havells_solar -} // namespace esphome +} // namespace esphome::havells_solar diff --git a/esphome/components/hbridge/fan/hbridge_fan.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index d548128b99..fc2a738e8d 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -1,8 +1,7 @@ #include "hbridge_fan.h" #include "esphome/core/log.h" -namespace esphome { -namespace hbridge { +namespace esphome::hbridge { static const char *const TAG = "fan.hbridge"; @@ -93,5 +92,4 @@ void HBridgeFan::write_state_() { this->oscillating_->set_state(this->oscillating); } -} // namespace hbridge -} // namespace esphome +} // namespace esphome::hbridge diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index 997f66ae48..62149d99cd 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/fan/fan.h" -namespace esphome { -namespace hbridge { +namespace esphome::hbridge { enum DecayMode { DECAY_MODE_SLOW = 0, @@ -56,5 +55,4 @@ template class BrakeAction : public Action { HBridgeFan *parent_; }; -} // namespace hbridge -} // namespace esphome +} // namespace esphome::hbridge diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index 4e064d5352..16408f24f1 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace hbridge { +namespace esphome::hbridge { class HBridgeLightOutput : public Component, public light::LightOutput { public: @@ -68,5 +67,4 @@ class HBridgeLightOutput : public Component, public light::LightOutput { HighFrequencyLoopRequester high_freq_; }; -} // namespace hbridge -} // namespace esphome +} // namespace esphome::hbridge diff --git a/esphome/components/hbridge/switch/hbridge_switch.cpp b/esphome/components/hbridge/switch/hbridge_switch.cpp index 55012fed21..1012a264f2 100644 --- a/esphome/components/hbridge/switch/hbridge_switch.cpp +++ b/esphome/components/hbridge/switch/hbridge_switch.cpp @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace hbridge { +namespace esphome::hbridge { static const char *const TAG = "switch.hbridge"; @@ -89,5 +88,4 @@ void HBridgeSwitch::timer_fn_() { this->timer_running_ = false; } -} // namespace hbridge -} // namespace esphome +} // namespace esphome::hbridge diff --git a/esphome/components/hbridge/switch/hbridge_switch.h b/esphome/components/hbridge/switch/hbridge_switch.h index ce00c6baa2..de867271fe 100644 --- a/esphome/components/hbridge/switch/hbridge_switch.h +++ b/esphome/components/hbridge/switch/hbridge_switch.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace hbridge { +namespace esphome::hbridge { enum RelayState : uint8_t { RELAY_STATE_OFF = 0, @@ -46,5 +45,4 @@ class HBridgeSwitch : public switch_::Switch, public Component { bool optimistic_{false}; }; -} // namespace hbridge -} // namespace esphome +} // namespace esphome::hbridge diff --git a/esphome/components/hdc1080/hdc1080.cpp b/esphome/components/hdc1080/hdc1080.cpp index fa293f6fc5..6692345e62 100644 --- a/esphome/components/hdc1080/hdc1080.cpp +++ b/esphome/components/hdc1080/hdc1080.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace hdc1080 { +namespace esphome::hdc1080 { static const char *const TAG = "hdc1080"; @@ -78,5 +77,4 @@ void HDC1080Component::update() { }); } -} // namespace hdc1080 -} // namespace esphome +} // namespace esphome::hdc1080 diff --git a/esphome/components/hdc1080/hdc1080.h b/esphome/components/hdc1080/hdc1080.h index a5bece82c4..1e3bf77788 100644 --- a/esphome/components/hdc1080/hdc1080.h +++ b/esphome/components/hdc1080/hdc1080.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace hdc1080 { +namespace esphome::hdc1080 { class HDC1080Component : public PollingComponent, public i2c::I2CDevice { public: @@ -21,5 +20,4 @@ class HDC1080Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *humidity_{nullptr}; }; -} // namespace hdc1080 -} // namespace esphome +} // namespace esphome::hdc1080 diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp index 0334b30eec..946cf4898f 100644 --- a/esphome/components/hdc2010/hdc2010.cpp +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -2,8 +2,8 @@ #include "hdc2010.h" // https://github.com/vigsterkr/homebridge-hdc2010/blob/main/src/hdc2010.js // https://github.com/lime-labs/HDC2080-Arduino/blob/master/src/HDC2080.cpp -namespace esphome { -namespace hdc2010 { + +namespace esphome::hdc2010 { static const char *const TAG = "hdc2010"; @@ -93,5 +93,4 @@ float HDC2010Component::read_humidity() { return (float) humidity * 0.001525879f; } -} // namespace hdc2010 -} // namespace esphome +} // namespace esphome::hdc2010 diff --git a/esphome/components/hdc2010/hdc2010.h b/esphome/components/hdc2010/hdc2010.h index 52c00686e6..ad6df3ff48 100644 --- a/esphome/components/hdc2010/hdc2010.h +++ b/esphome/components/hdc2010/hdc2010.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace hdc2010 { +namespace esphome::hdc2010 { class HDC2010Component : public PollingComponent, public i2c::I2CDevice { public: @@ -28,5 +27,4 @@ class HDC2010Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *humidity_sensor_{nullptr}; }; -} // namespace hdc2010 -} // namespace esphome +} // namespace esphome::hdc2010 diff --git a/esphome/components/he60r/he60r.cpp b/esphome/components/he60r/he60r.cpp index 47440cc1f7..84edbb2866 100644 --- a/esphome/components/he60r/he60r.cpp +++ b/esphome/components/he60r/he60r.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace he60r { +namespace esphome::he60r { static const char *const TAG = "he60r.cover"; static const uint8_t QUERY_BYTE = 0x38; @@ -264,5 +263,4 @@ void HE60rCover::recompute_position_() { } } -} // namespace he60r -} // namespace esphome +} // namespace esphome::he60r diff --git a/esphome/components/he60r/he60r.h b/esphome/components/he60r/he60r.h index 02a2b44e66..e7b5c97969 100644 --- a/esphome/components/he60r/he60r.h +++ b/esphome/components/he60r/he60r.h @@ -5,8 +5,7 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/cover/cover.h" -namespace esphome { -namespace he60r { +namespace esphome::he60r { class HE60rCover : public cover::Cover, public Component, public uart::UARTDevice { public: @@ -41,5 +40,4 @@ class HE60rCover : public cover::Cover, public Component, public uart::UARTDevic uint8_t counter_{}; }; -} // namespace he60r -} // namespace esphome +} // namespace esphome::he60r diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 11e7672dc1..8e9a2c5298 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -8,8 +8,7 @@ #include "esphome/components/remote_base/remote_base.h" #include "esphome/core/log.h" -namespace esphome { -namespace heatpumpir { +namespace esphome::heatpumpir { // IRSenderESPHome - bridge between ESPHome's remote_transmitter and HeatpumpIR library // Defined here (not in a header) to isolate HeatpumpIR's headers from the rest of ESPHome, @@ -243,7 +242,6 @@ void HeatpumpIRClimate::transmit_state() { swing_h_cmd); } -} // namespace heatpumpir -} // namespace esphome +} // namespace esphome::heatpumpir #endif diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index 6270dd1e5a..a277424df6 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -8,8 +8,7 @@ // that conflict with ESPHome. class HeatpumpIR; -namespace esphome { -namespace heatpumpir { +namespace esphome::heatpumpir { // Simple enum to represent protocols. enum Protocol { @@ -126,7 +125,6 @@ class HeatpumpIRClimate : public climate_ir::ClimateIR { float min_temperature_; }; -} // namespace heatpumpir -} // namespace esphome +} // namespace esphome::heatpumpir #endif diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.cpp b/esphome/components/hitachi_ac344/hitachi_ac344.cpp index 69469cab2e..ea211a7a59 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.cpp +++ b/esphome/components/hitachi_ac344/hitachi_ac344.cpp @@ -1,7 +1,6 @@ #include "hitachi_ac344.h" -namespace esphome { -namespace hitachi_ac344 { +namespace esphome::hitachi_ac344 { static const char *const TAG = "climate.hitachi_ac344"; @@ -366,5 +365,4 @@ void HitachiClimate::dump_state_(const char action[], uint8_t state[]) { ESP_LOGV(TAG, "%s: %02X %02X %02X", action, state[40], state[41], state[42]); } -} // namespace hitachi_ac344 -} // namespace esphome +} // namespace esphome::hitachi_ac344 diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.h b/esphome/components/hitachi_ac344/hitachi_ac344.h index 0877b83261..b9d776cc59 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.h +++ b/esphome/components/hitachi_ac344/hitachi_ac344.h @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace hitachi_ac344 { +namespace esphome::hitachi_ac344 { const uint16_t HITACHI_AC344_HDR_MARK = 3300; // ac const uint16_t HITACHI_AC344_HDR_SPACE = 1700; // ac @@ -117,5 +116,4 @@ class HitachiClimate : public climate_ir::ClimateIR { void dump_state_(const char action[], uint8_t remote_state[]); }; -} // namespace hitachi_ac344 -} // namespace esphome +} // namespace esphome::hitachi_ac344 diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.cpp b/esphome/components/hitachi_ac424/hitachi_ac424.cpp index 0b3cc99a82..cdf8dda22c 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.cpp +++ b/esphome/components/hitachi_ac424/hitachi_ac424.cpp @@ -1,7 +1,6 @@ #include "hitachi_ac424.h" -namespace esphome { -namespace hitachi_ac424 { +namespace esphome::hitachi_ac424 { static const char *const TAG = "climate.hitachi_ac424"; @@ -367,5 +366,4 @@ void HitachiClimate::dump_state_(const char action[], uint8_t state[]) { ESP_LOGV(TAG, "%s: %02X %02X %02X", action, state[40], state[41], state[42]); } -} // namespace hitachi_ac424 -} // namespace esphome +} // namespace esphome::hitachi_ac424 diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.h b/esphome/components/hitachi_ac424/hitachi_ac424.h index 1005aa6df7..ef7f128a5a 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.h +++ b/esphome/components/hitachi_ac424/hitachi_ac424.h @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace hitachi_ac424 { +namespace esphome::hitachi_ac424 { const uint16_t HITACHI_AC424_HDR_MARK = 3416; // ac const uint16_t HITACHI_AC424_HDR_SPACE = 1604; // ac @@ -119,5 +118,4 @@ class HitachiClimate : public climate_ir::ClimateIR { void dump_state_(const char action[], uint8_t remote_state[]); }; -} // namespace hitachi_ac424 -} // namespace esphome +} // namespace esphome::hitachi_ac424 diff --git a/esphome/components/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index 22f292e47e..c92c76a20a 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -1,8 +1,7 @@ #include "hlw8012.h" #include "esphome/core/log.h" -namespace esphome { -namespace hlw8012 { +namespace esphome::hlw8012 { static const char *const TAG = "hlw8012"; @@ -104,5 +103,4 @@ void HLW8012Component::update() { } } -} // namespace hlw8012 -} // namespace esphome +} // namespace esphome::hlw8012 diff --git a/esphome/components/hlw8012/hlw8012.h b/esphome/components/hlw8012/hlw8012.h index 8a13ec07d8..d1d340bf45 100644 --- a/esphome/components/hlw8012/hlw8012.h +++ b/esphome/components/hlw8012/hlw8012.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace hlw8012 { +namespace esphome::hlw8012 { enum HLW8012InitialMode { HLW8012_INITIAL_MODE_CURRENT = 0, HLW8012_INITIAL_MODE_VOLTAGE }; @@ -72,5 +71,4 @@ class HLW8012Component : public PollingComponent { float power_multiplier_{0.0f}; }; -} // namespace hlw8012 -} // namespace esphome +} // namespace esphome::hlw8012 diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index 9343b47823..f46a6b8580 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -1,8 +1,7 @@ #include "esphome/core/log.h" #include "hm3301.h" -namespace esphome { -namespace hm3301 { +namespace esphome::hm3301 { static const char *const TAG = "hm3301.sensor"; @@ -94,5 +93,4 @@ uint16_t HM3301Component::get_sensor_value_(const uint8_t *data, uint8_t i) { return (uint16_t) data[i * 2] << 8 | data[i * 2 + 1]; } -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::hm3301 diff --git a/esphome/components/hm3301/hm3301.h b/esphome/components/hm3301/hm3301.h index 6b10a5e237..55e708e34a 100644 --- a/esphome/components/hm3301/hm3301.h +++ b/esphome/components/hm3301/hm3301.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/aqi/aqi_calculator_factory.h" -namespace esphome { -namespace hm3301 { +namespace esphome::hm3301 { static const uint8_t SELECT_COMM_CMD = 0x88; @@ -47,5 +46,4 @@ class HM3301Component : public PollingComponent, public i2c::I2CDevice { uint16_t get_sensor_value_(const uint8_t *data, uint8_t i); }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::hm3301 diff --git a/esphome/components/hmac_md5/hmac_md5.cpp b/esphome/components/hmac_md5/hmac_md5.cpp index d766a55fab..49a55592d3 100644 --- a/esphome/components/hmac_md5/hmac_md5.cpp +++ b/esphome/components/hmac_md5/hmac_md5.cpp @@ -4,8 +4,7 @@ #ifdef USE_MD5 #include "esphome/core/helpers.h" -namespace esphome { -namespace hmac_md5 { +namespace esphome::hmac_md5 { void HmacMD5::init(const uint8_t *key, size_t len) { uint8_t ipad[64], opad[64]; @@ -53,6 +52,6 @@ bool HmacMD5::equals_bytes(const uint8_t *expected) { return this->ohash_.equals bool HmacMD5::equals_hex(const char *expected) { return this->ohash_.equals_hex(expected); } -} // namespace hmac_md5 -} // namespace esphome +} // namespace esphome::hmac_md5 + #endif diff --git a/esphome/components/hmac_md5/hmac_md5.h b/esphome/components/hmac_md5/hmac_md5.h index fb9479e3af..c2fd7f2800 100644 --- a/esphome/components/hmac_md5/hmac_md5.h +++ b/esphome/components/hmac_md5/hmac_md5.h @@ -5,8 +5,7 @@ #include "esphome/components/md5/md5.h" #include -namespace esphome { -namespace hmac_md5 { +namespace esphome::hmac_md5 { class HmacMD5 { public: @@ -44,6 +43,6 @@ class HmacMD5 { md5::MD5Digest ohash_; }; -} // namespace hmac_md5 -} // namespace esphome +} // namespace esphome::hmac_md5 + #endif diff --git a/esphome/components/hmc5883l/hmc5883l.cpp b/esphome/components/hmc5883l/hmc5883l.cpp index bee5282125..7930df7a38 100644 --- a/esphome/components/hmc5883l/hmc5883l.cpp +++ b/esphome/components/hmc5883l/hmc5883l.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace hmc5883l { +namespace esphome::hmc5883l { static const char *const TAG = "hmc5883l"; static const uint8_t HMC5883L_ADDRESS = 0x1E; @@ -140,5 +139,4 @@ void HMC5883LComponent::update() { this->heading_sensor_->publish_state(heading); } -} // namespace hmc5883l -} // namespace esphome +} // namespace esphome::hmc5883l diff --git a/esphome/components/hmc5883l/hmc5883l.h b/esphome/components/hmc5883l/hmc5883l.h index b5cf93e62b..4f170d7401 100644 --- a/esphome/components/hmc5883l/hmc5883l.h +++ b/esphome/components/hmc5883l/hmc5883l.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace hmc5883l { +namespace esphome::hmc5883l { enum HMC5883LOversampling { HMC5883L_OVERSAMPLING_1 = 0b000, @@ -65,5 +64,4 @@ class HMC5883LComponent : public PollingComponent, public i2c::I2CDevice { HighFrequencyLoopRequester high_freq_; }; -} // namespace hmc5883l -} // namespace esphome +} // namespace esphome::hmc5883l diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp index b0d9135822..735fb9f5da 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { static const char *const TAG = "homeassistant.binary_sensor"; @@ -43,5 +42,4 @@ void HomeassistantBinarySensor::dump_config() { } float HomeassistantBinarySensor::get_setup_priority() const { return setup_priority::AFTER_WIFI; } -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h index 9aec61a370..6d95ea2c60 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { class HomeassistantBinarySensor : public binary_sensor::BinarySensor, public Component { public: @@ -20,5 +19,4 @@ class HomeassistantBinarySensor : public binary_sensor::BinarySensor, public Com bool initial_{true}; }; -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index da802b7fe9..965f91d202 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { static const char *const TAG = "homeassistant.number"; @@ -103,5 +102,4 @@ void HomeassistantNumber::control(float value) { api::global_api_server->send_homeassistant_action(resp); } -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/number/homeassistant_number.h b/esphome/components/homeassistant/number/homeassistant_number.h index 275d2d5f03..a1e351fdf4 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.h +++ b/esphome/components/homeassistant/number/homeassistant_number.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/string_ref.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { class HomeassistantNumber : public number::Number, public Component { public: @@ -25,5 +24,4 @@ class HomeassistantNumber : public number::Number, public Component { const char *entity_id_{nullptr}; }; -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp index 66300ebba5..112795a4ff 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { static const char *const TAG = "homeassistant.sensor"; @@ -34,5 +33,4 @@ void HomeassistantSensor::dump_config() { } float HomeassistantSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.h b/esphome/components/homeassistant/sensor/homeassistant_sensor.h index d89fc069ff..afc4935537 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.h +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { class HomeassistantSensor : public sensor::Sensor, public Component { public: @@ -19,5 +18,4 @@ class HomeassistantSensor : public sensor::Sensor, public Component { const char *attribute_{nullptr}; }; -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index cc3d582bf3..8a4ea19f2e 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { static const char *const TAG = "homeassistant.switch"; @@ -60,5 +59,4 @@ void HomeassistantSwitch::write_state(bool state) { api::global_api_server->send_homeassistant_action(resp); } -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.h b/esphome/components/homeassistant/switch/homeassistant_switch.h index c180b7f98a..c6c178c205 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.h +++ b/esphome/components/homeassistant/switch/homeassistant_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "esphome/core/component.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { class HomeassistantSwitch : public switch_::Switch, public Component { public: @@ -18,5 +17,4 @@ class HomeassistantSwitch : public switch_::Switch, public Component { const char *entity_id_{nullptr}; }; -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp index 109574e0c8..40d7455ece 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { static const char *const TAG = "homeassistant.text_sensor"; @@ -26,5 +25,4 @@ void HomeassistantTextSensor::dump_config() { } } float HomeassistantTextSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h index 4d66c65a17..8af81cefcb 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { class HomeassistantTextSensor : public text_sensor::TextSensor, public Component { public: @@ -19,5 +18,4 @@ class HomeassistantTextSensor : public text_sensor::TextSensor, public Component const char *attribute_{nullptr}; }; -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/time/homeassistant_time.cpp b/esphome/components/homeassistant/time/homeassistant_time.cpp index d039892073..c08bd73d62 100644 --- a/esphome/components/homeassistant/time/homeassistant_time.cpp +++ b/esphome/components/homeassistant/time/homeassistant_time.cpp @@ -1,8 +1,7 @@ #include "homeassistant_time.h" #include "esphome/core/log.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { static const char *const TAG = "homeassistant.time"; @@ -16,5 +15,4 @@ void HomeassistantTime::setup() { global_homeassistant_time = this; } void HomeassistantTime::update() { api::global_api_server->request_time(); } HomeassistantTime *global_homeassistant_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/time/homeassistant_time.h b/esphome/components/homeassistant/time/homeassistant_time.h index 455ded2022..77edb50cd0 100644 --- a/esphome/components/homeassistant/time/homeassistant_time.h +++ b/esphome/components/homeassistant/time/homeassistant_time.h @@ -4,8 +4,7 @@ #include "esphome/components/time/real_time_clock.h" #include "esphome/components/api/api_server.h" -namespace esphome { -namespace homeassistant { +namespace esphome::homeassistant { class HomeassistantTime final : public time::RealTimeClock { public: @@ -17,5 +16,4 @@ class HomeassistantTime final : public time::RealTimeClock { extern HomeassistantTime *global_homeassistant_time; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace homeassistant -} // namespace esphome +} // namespace esphome::homeassistant diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp index 904672d136..7f075847fd 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp @@ -5,8 +5,7 @@ #include "honeywell_hih.h" #include "esphome/core/log.h" -namespace esphome { -namespace honeywell_hih_i2c { +namespace esphome::honeywell_hih_i2c { static const char *const TAG = "honeywell_hih.i2c"; @@ -91,5 +90,4 @@ void HoneywellHIComponent::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace honeywell_hih_i2c -} // namespace esphome +} // namespace esphome::honeywell_hih_i2c diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.h b/esphome/components/honeywell_hih_i2c/honeywell_hih.h index 79140f7399..d9ea6401ce 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.h +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace honeywell_hih_i2c { +namespace esphome::honeywell_hih_i2c { class HoneywellHIComponent : public PollingComponent, public i2c::I2CDevice { public: @@ -29,5 +28,4 @@ class HoneywellHIComponent : public PollingComponent, public i2c::I2CDevice { void measurement_timeout_(); }; -} // namespace honeywell_hih_i2c -} // namespace esphome +} // namespace esphome::honeywell_hih_i2c diff --git a/esphome/components/honeywellabp/honeywellabp.cpp b/esphome/components/honeywellabp/honeywellabp.cpp index 58c5df230f..8bfc5e4f4f 100644 --- a/esphome/components/honeywellabp/honeywellabp.cpp +++ b/esphome/components/honeywellabp/honeywellabp.cpp @@ -1,8 +1,7 @@ #include "honeywellabp.h" #include "esphome/core/log.h" -namespace esphome { -namespace honeywellabp { +namespace esphome::honeywellabp { static const char *const TAG = "honeywellabp"; @@ -96,5 +95,4 @@ void HONEYWELLABPSensor::set_honeywellabp_max_pressure(float max_pressure) { this->honeywellabp_max_pressure_ = max_pressure; } -} // namespace honeywellabp -} // namespace esphome +} // namespace esphome::honeywellabp diff --git a/esphome/components/honeywellabp/honeywellabp.h b/esphome/components/honeywellabp/honeywellabp.h index 98f6f08c4a..3c31968c49 100644 --- a/esphome/components/honeywellabp/honeywellabp.h +++ b/esphome/components/honeywellabp/honeywellabp.h @@ -6,8 +6,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/core/component.h" -namespace esphome { -namespace honeywellabp { +namespace esphome::honeywellabp { class HONEYWELLABPSensor : public PollingComponent, public spi::SPIDevicecheck_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8); } -} // namespace hrxl_maxsonar_wr -} // namespace esphome +} // namespace esphome::hrxl_maxsonar_wr diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h index efb8bc5f4b..e98eeea723 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace hrxl_maxsonar_wr { +namespace esphome::hrxl_maxsonar_wr { class HrxlMaxsonarWrComponent : public sensor::Sensor, public Component, public uart::UARTDevice { public: @@ -21,5 +20,4 @@ class HrxlMaxsonarWrComponent : public sensor::Sensor, public Component, public std::string buffer_; }; -} // namespace hrxl_maxsonar_wr -} // namespace esphome +} // namespace esphome::hrxl_maxsonar_wr diff --git a/esphome/components/hte501/hte501.cpp b/esphome/components/hte501/hte501.cpp index ef9ef1fabf..67bfb1917e 100644 --- a/esphome/components/hte501/hte501.cpp +++ b/esphome/components/hte501/hte501.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace hte501 { +namespace esphome::hte501 { static const char *const TAG = "hte501"; @@ -71,5 +70,4 @@ void HTE501Component::update() { this->status_clear_warning(); }); } -} // namespace hte501 -} // namespace esphome +} // namespace esphome::hte501 diff --git a/esphome/components/hte501/hte501.h b/esphome/components/hte501/hte501.h index 7f29885f49..310073f88b 100644 --- a/esphome/components/hte501/hte501.h +++ b/esphome/components/hte501/hte501.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -namespace esphome { -namespace hte501 { +namespace esphome::hte501 { /// This class implements support for the hte501 of temperature i2c sensors. class HTE501Component : public PollingComponent, public i2c::I2CDevice { @@ -24,5 +23,4 @@ class HTE501Component : public PollingComponent, public i2c::I2CDevice { enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, CRC_CHECK_FAILED } error_code_{NONE}; }; -} // namespace hte501 -} // namespace esphome +} // namespace esphome::hte501 diff --git a/esphome/components/http_request/ota/automation.h b/esphome/components/http_request/ota/automation.h index 6c50bb9b0d..f6f49b14b1 100644 --- a/esphome/components/http_request/ota/automation.h +++ b/esphome/components/http_request/ota/automation.h @@ -3,8 +3,7 @@ #include "esphome/core/automation.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { template class OtaHttpRequestComponentFlashAction : public Action { public: @@ -38,5 +37,4 @@ template class OtaHttpRequestComponentFlashAction : public Actio OtaHttpRequestComponent *parent_; }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 5dd21c314c..8893b96c65 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -9,8 +9,7 @@ #include "esphome/components/md5/md5.h" #include "esphome/components/watchdog/watchdog.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { static const char *const TAG = "http_request.ota"; @@ -297,5 +296,4 @@ bool OtaHttpRequestComponent::validate_url_(const std::string &url) { return true; } -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index 70e4559fa7..a706331d9a 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -11,8 +11,7 @@ #include "../http_request.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { static const uint8_t MD5_SIZE = 32; @@ -56,5 +55,4 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 1c52a28105..57dc86d55c 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -6,8 +6,7 @@ #include "esphome/components/json/json_util.h" #include "esphome/components/network/util.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { // The update function runs in a task only on ESP32s. #ifdef USE_ESP32 @@ -258,5 +257,4 @@ void HttpRequestUpdate::perform(bool force) { this->defer([this]() { this->ota_parent_->flash(); }); } -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request diff --git a/esphome/components/http_request/update/http_request_update.h b/esphome/components/http_request/update/http_request_update.h index b8350346f9..be9fbf72bf 100644 --- a/esphome/components/http_request/update/http_request_update.h +++ b/esphome/components/http_request/update/http_request_update.h @@ -11,8 +11,7 @@ #include #endif -namespace esphome { -namespace http_request { +namespace esphome::http_request { class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener { public: @@ -43,5 +42,4 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo uint8_t initial_check_remaining_{0}; }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request diff --git a/esphome/components/htu21d/htu21d.cpp b/esphome/components/htu21d/htu21d.cpp index 58a28b213f..8a3b2cb5d5 100644 --- a/esphome/components/htu21d/htu21d.cpp +++ b/esphome/components/htu21d/htu21d.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace htu21d { +namespace esphome::htu21d { static const char *const TAG = "htu21d"; @@ -143,5 +142,4 @@ uint8_t HTU21DComponent::get_heater_level() { return raw_heater & 0xF; } -} // namespace htu21d -} // namespace esphome +} // namespace esphome::htu21d diff --git a/esphome/components/htu21d/htu21d.h b/esphome/components/htu21d/htu21d.h index 594be78326..a111722dc7 100644 --- a/esphome/components/htu21d/htu21d.h +++ b/esphome/components/htu21d/htu21d.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/automation.h" -namespace esphome { -namespace htu21d { +namespace esphome::htu21d { enum HTU21DSensorModels { HTU21D_SENSOR_MODEL_HTU21D = 0, HTU21D_SENSOR_MODEL_SI7021, HTU21D_SENSOR_MODEL_SHT21 }; @@ -57,5 +56,4 @@ template class SetHeaterAction : public Action, public Pa } }; -} // namespace htu21d -} // namespace esphome +} // namespace esphome::htu21d diff --git a/esphome/components/htu31d/htu31d.cpp b/esphome/components/htu31d/htu31d.cpp index 4bb38a11a2..0b679bf2b7 100644 --- a/esphome/components/htu31d/htu31d.cpp +++ b/esphome/components/htu31d/htu31d.cpp @@ -14,8 +14,7 @@ #include -namespace esphome { -namespace htu31d { +namespace esphome::htu31d { /** Logging prefix */ static const char *const TAG = "htu31d"; @@ -259,5 +258,4 @@ void HTU31DComponent::set_heater_state(bool desired) { } } -} // namespace htu31d -} // namespace esphome +} // namespace esphome::htu31d diff --git a/esphome/components/htu31d/htu31d.h b/esphome/components/htu31d/htu31d.h index 24d85243cc..451918cb3b 100644 --- a/esphome/components/htu31d/htu31d.h +++ b/esphome/components/htu31d/htu31d.h @@ -5,8 +5,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace htu31d { +namespace esphome::htu31d { class HTU31DComponent : public PollingComponent, public i2c::I2CDevice { public: @@ -27,5 +26,4 @@ class HTU31DComponent : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *temperature_{nullptr}; sensor::Sensor *humidity_{nullptr}; }; -} // namespace htu31d -} // namespace esphome +} // namespace esphome::htu31d diff --git a/esphome/components/hx711/hx711.cpp b/esphome/components/hx711/hx711.cpp index f2e3234127..fe2fb4c350 100644 --- a/esphome/components/hx711/hx711.cpp +++ b/esphome/components/hx711/hx711.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace hx711 { +namespace esphome::hx711 { static const char *const TAG = "hx711"; @@ -77,5 +76,4 @@ bool HX711Sensor::read_sensor_(uint32_t *result) { return true; } -} // namespace hx711 -} // namespace esphome +} // namespace esphome::hx711 diff --git a/esphome/components/hx711/hx711.h b/esphome/components/hx711/hx711.h index 37723ee81f..43ab4c0f56 100644 --- a/esphome/components/hx711/hx711.h +++ b/esphome/components/hx711/hx711.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace hx711 { +namespace esphome::hx711 { enum HX711Gain : uint8_t { HX711_GAIN_128 = 1, @@ -33,5 +32,4 @@ class HX711Sensor : public sensor::Sensor, public PollingComponent { HX711Gain gain_{HX711_GAIN_128}; }; -} // namespace hx711 -} // namespace esphome +} // namespace esphome::hx711 diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 983a0a6649..695a823cb7 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -1,8 +1,7 @@ #include "hydreon_rgxx.h" #include "esphome/core/log.h" -namespace esphome { -namespace hydreon_rgxx { +namespace esphome::hydreon_rgxx { static const char *const TAG = "hydreon_rgxx.sensor"; static const int MAX_DATA_LENGTH_BYTES = 80; @@ -284,5 +283,4 @@ void HydreonRGxxComponent::process_line_() { } } -} // namespace hydreon_rgxx -} // namespace esphome +} // namespace esphome::hydreon_rgxx diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.h b/esphome/components/hydreon_rgxx/hydreon_rgxx.h index e3f9798a93..2ae46907c1 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.h +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.h @@ -8,8 +8,7 @@ #endif #include "esphome/components/uart/uart.h" -namespace esphome { -namespace hydreon_rgxx { +namespace esphome::hydreon_rgxx { enum RGModel { RG9 = 1, @@ -92,5 +91,4 @@ class HydreonRGxxBinaryComponent : public Component { HydreonRGxxBinaryComponent(HydreonRGxxComponent *parent) {} }; -} // namespace hydreon_rgxx -} // namespace esphome +} // namespace esphome::hydreon_rgxx diff --git a/esphome/components/hyt271/hyt271.cpp b/esphome/components/hyt271/hyt271.cpp index 4c0e3cd96e..7b2c960a5f 100644 --- a/esphome/components/hyt271/hyt271.cpp +++ b/esphome/components/hyt271/hyt271.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace hyt271 { +namespace esphome::hyt271 { static const char *const TAG = "hyt271"; @@ -46,5 +45,4 @@ void HYT271Component::update() { this->status_clear_warning(); }); } -} // namespace hyt271 -} // namespace esphome +} // namespace esphome::hyt271 diff --git a/esphome/components/hyt271/hyt271.h b/esphome/components/hyt271/hyt271.h index 19409d830c..d08b3779ad 100644 --- a/esphome/components/hyt271/hyt271.h +++ b/esphome/components/hyt271/hyt271.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace hyt271 { +namespace esphome::hyt271 { class HYT271Component : public PollingComponent, public i2c::I2CDevice { public: @@ -21,5 +20,4 @@ class HYT271Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *humidity_{nullptr}; }; -} // namespace hyt271 -} // namespace esphome +} // namespace esphome::hyt271 From 56ef35716235e6e6c503e3f74a9399543ac647af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 17:51:30 -0500 Subject: [PATCH 1115/2214] Bump github/codeql-action from 4.35.3 to 4.35.4 (#16299) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5429434a7f..949e45e45c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: category: "/language:${{matrix.language}}" From cbe192df49f2a2aa3f10ae1b5e9a14cb267b662e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 7 May 2026 19:00:17 -0400 Subject: [PATCH 1116/2214] [clang-tidy] Concatenate nested namespaces (3/7: components i-m) (#16297) --- esphome/components/i2c_device/i2c_device.cpp | 6 ++---- esphome/components/i2c_device/i2c_device.h | 6 ++---- esphome/components/i2s_audio/i2s_audio.h | 6 ++---- .../i2s_audio/microphone/i2s_audio_microphone.cpp | 6 ++---- .../i2s_audio/microphone/i2s_audio_microphone.h | 6 ++---- esphome/components/iaqcore/iaqcore.cpp | 6 ++---- esphome/components/iaqcore/iaqcore.h | 6 ++---- esphome/components/ili9xxx/ili9xxx_defines.h | 6 ++---- esphome/components/ili9xxx/ili9xxx_display.cpp | 6 ++---- esphome/components/ili9xxx/ili9xxx_display.h | 6 ++---- esphome/components/ili9xxx/ili9xxx_init.h | 6 ++---- esphome/components/image/image.cpp | 6 ++---- esphome/components/image/image.h | 6 ++---- esphome/components/improv_base/improv_base.cpp | 6 ++---- esphome/components/improv_base/improv_base.h | 6 ++---- .../components/improv_serial/improv_serial_component.cpp | 7 +++---- .../components/improv_serial/improv_serial_component.h | 7 +++---- esphome/components/ina219/ina219.cpp | 6 ++---- esphome/components/ina219/ina219.h | 6 ++---- esphome/components/ina226/ina226.cpp | 6 ++---- esphome/components/ina226/ina226.h | 6 ++---- esphome/components/ina260/ina260.cpp | 6 ++---- esphome/components/ina260/ina260.h | 6 ++---- esphome/components/ina2xx_base/ina2xx_base.cpp | 6 ++---- esphome/components/ina2xx_base/ina2xx_base.h | 6 ++---- esphome/components/ina2xx_i2c/ina2xx_i2c.cpp | 6 ++---- esphome/components/ina2xx_i2c/ina2xx_i2c.h | 6 ++---- esphome/components/ina2xx_spi/ina2xx_spi.cpp | 6 ++---- esphome/components/ina2xx_spi/ina2xx_spi.h | 6 ++---- esphome/components/ina3221/ina3221.cpp | 6 ++---- esphome/components/ina3221/ina3221.h | 6 ++---- .../inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp | 6 ++---- .../components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h | 6 ++---- esphome/components/inkplate/inkplate.cpp | 6 ++---- esphome/components/inkplate/inkplate.h | 6 ++---- esphome/components/integration/integration_sensor.cpp | 6 ++---- esphome/components/integration/integration_sensor.h | 6 ++---- esphome/components/interval/interval.h | 6 ++---- esphome/components/jsn_sr04t/jsn_sr04t.cpp | 6 ++---- esphome/components/jsn_sr04t/jsn_sr04t.h | 6 ++---- esphome/components/json/json_util.cpp | 6 ++---- esphome/components/json/json_util.h | 6 ++---- esphome/components/kamstrup_kmp/kamstrup_kmp.cpp | 6 ++---- esphome/components/kamstrup_kmp/kamstrup_kmp.h | 6 ++---- esphome/components/key_collector/key_collector.cpp | 6 ++---- esphome/components/key_collector/key_collector.h | 6 ++---- esphome/components/key_provider/key_provider.cpp | 6 ++---- esphome/components/key_provider/key_provider.h | 6 ++---- esphome/components/kmeteriso/kmeteriso.cpp | 6 ++---- esphome/components/kmeteriso/kmeteriso.h | 6 ++---- esphome/components/kuntze/kuntze.cpp | 6 ++---- esphome/components/kuntze/kuntze.h | 6 ++---- esphome/components/lc709203f/lc709203f.cpp | 6 ++---- esphome/components/lc709203f/lc709203f.h | 6 ++---- esphome/components/lcd_base/lcd_display.cpp | 6 ++---- esphome/components/lcd_base/lcd_display.h | 6 ++---- esphome/components/lcd_gpio/gpio_lcd_display.cpp | 6 ++---- esphome/components/lcd_gpio/gpio_lcd_display.h | 6 ++---- esphome/components/lcd_menu/lcd_menu.cpp | 6 ++---- esphome/components/lcd_menu/lcd_menu.h | 6 ++---- esphome/components/lcd_pcf8574/pcf8574_display.cpp | 6 ++---- esphome/components/lcd_pcf8574/pcf8574_display.h | 6 ++---- esphome/components/lightwaverf/LwRx.h | 6 ++---- esphome/components/lightwaverf/LwTx.h | 6 ++---- .../lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp | 6 ++---- .../lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h | 6 ++---- esphome/components/lm75b/lm75b.cpp | 6 ++---- esphome/components/lm75b/lm75b.h | 6 ++---- esphome/components/lps22/lps22.cpp | 6 ++---- esphome/components/lps22/lps22.h | 6 ++---- esphome/components/ltr390/ltr390.cpp | 6 ++---- esphome/components/ltr390/ltr390.h | 6 ++---- esphome/components/ltr501/ltr501.cpp | 6 ++---- esphome/components/ltr501/ltr501.h | 6 ++---- esphome/components/ltr501/ltr_definitions_501.h | 6 ++---- esphome/components/ltr_als_ps/ltr_als_ps.cpp | 6 ++---- esphome/components/ltr_als_ps/ltr_als_ps.h | 6 ++---- esphome/components/ltr_als_ps/ltr_definitions.h | 6 ++---- esphome/components/lvgl/light/lvgl_light.h | 6 ++---- esphome/components/lvgl/lvgl_proxy.h | 6 ++---- esphome/components/lvgl/number/lvgl_number.h | 6 ++---- esphome/components/lvgl/select/lvgl_select.h | 6 ++---- esphome/components/lvgl/switch/lvgl_switch.h | 6 ++---- esphome/components/lvgl/text/lvgl_text.h | 6 ++---- .../binary_sensor/m5stack_8angle_binary_sensor.cpp | 6 ++---- .../binary_sensor/m5stack_8angle_binary_sensor.h | 6 ++---- .../m5stack_8angle/light/m5stack_8angle_light.cpp | 6 ++---- .../m5stack_8angle/light/m5stack_8angle_light.h | 6 ++---- esphome/components/m5stack_8angle/m5stack_8angle.cpp | 6 ++---- esphome/components/m5stack_8angle/m5stack_8angle.h | 6 ++---- .../m5stack_8angle/sensor/m5stack_8angle_sensor.cpp | 6 ++---- .../m5stack_8angle/sensor/m5stack_8angle_sensor.h | 6 ++---- .../binary_sensor/matrix_keypad_binary_sensor.h | 6 ++---- esphome/components/matrix_keypad/matrix_keypad.cpp | 6 ++---- esphome/components/matrix_keypad/matrix_keypad.h | 6 ++---- esphome/components/max17043/automation.h | 6 ++---- esphome/components/max17043/max17043.cpp | 6 ++---- esphome/components/max17043/max17043.h | 6 ++---- esphome/components/max31855/max31855.cpp | 6 ++---- esphome/components/max31855/max31855.h | 6 ++---- esphome/components/max31856/max31856.cpp | 6 ++---- esphome/components/max31856/max31856.h | 6 ++---- esphome/components/max31865/max31865.cpp | 6 ++---- esphome/components/max31865/max31865.h | 6 ++---- esphome/components/max44009/max44009.cpp | 6 ++---- esphome/components/max44009/max44009.h | 6 ++---- esphome/components/max6675/max6675.cpp | 6 ++---- esphome/components/max6675/max6675.h | 6 ++---- esphome/components/max6956/automation.h | 6 ++---- esphome/components/max6956/max6956.cpp | 6 ++---- esphome/components/max6956/max6956.h | 6 ++---- esphome/components/max6956/output/max6956_led_output.cpp | 6 ++---- esphome/components/max6956/output/max6956_led_output.h | 6 ++---- esphome/components/max7219digit/automation.h | 6 ++---- esphome/components/max7219digit/max7219digit.cpp | 6 ++---- esphome/components/max7219digit/max7219digit.h | 6 ++---- esphome/components/max7219digit/max7219font.h | 6 ++---- esphome/components/max9611/max9611.cpp | 7 +++---- esphome/components/max9611/max9611.h | 6 ++---- esphome/components/mcp23008/mcp23008.cpp | 6 ++---- esphome/components/mcp23008/mcp23008.h | 6 ++---- esphome/components/mcp23016/mcp23016.cpp | 6 ++---- esphome/components/mcp23016/mcp23016.h | 6 ++---- esphome/components/mcp23017/mcp23017.cpp | 6 ++---- esphome/components/mcp23017/mcp23017.h | 6 ++---- esphome/components/mcp23s08/mcp23s08.cpp | 6 ++---- esphome/components/mcp23s08/mcp23s08.h | 6 ++---- esphome/components/mcp23s17/mcp23s17.cpp | 6 ++---- esphome/components/mcp23s17/mcp23s17.h | 6 ++---- esphome/components/mcp23x08_base/mcp23x08_base.cpp | 6 ++---- esphome/components/mcp23x08_base/mcp23x08_base.h | 6 ++---- esphome/components/mcp23x17_base/mcp23x17_base.cpp | 6 ++---- esphome/components/mcp23x17_base/mcp23x17_base.h | 6 ++---- esphome/components/mcp23xxx_base/mcp23xxx_base.cpp | 6 ++---- esphome/components/mcp23xxx_base/mcp23xxx_base.h | 6 ++---- esphome/components/mcp2515/mcp2515.cpp | 6 ++---- esphome/components/mcp2515/mcp2515.h | 6 ++---- esphome/components/mcp2515/mcp2515_defs.h | 6 ++---- esphome/components/mcp3008/mcp3008.cpp | 6 ++---- esphome/components/mcp3008/mcp3008.h | 6 ++---- esphome/components/mcp3008/sensor/mcp3008_sensor.cpp | 6 ++---- esphome/components/mcp3008/sensor/mcp3008_sensor.h | 6 ++---- esphome/components/mcp3204/mcp3204.cpp | 6 ++---- esphome/components/mcp3204/mcp3204.h | 6 ++---- esphome/components/mcp3204/sensor/mcp3204_sensor.cpp | 6 ++---- esphome/components/mcp3204/sensor/mcp3204_sensor.h | 6 ++---- esphome/components/mcp3221/mcp3221_sensor.cpp | 6 ++---- esphome/components/mcp3221/mcp3221_sensor.h | 6 ++---- esphome/components/mcp4461/mcp4461.cpp | 6 ++---- esphome/components/mcp4461/mcp4461.h | 6 ++---- esphome/components/mcp4461/output/mcp4461_output.cpp | 6 ++---- esphome/components/mcp4461/output/mcp4461_output.h | 6 ++---- esphome/components/mcp4725/mcp4725.cpp | 6 ++---- esphome/components/mcp4725/mcp4725.h | 6 ++---- esphome/components/mcp4728/mcp4728.cpp | 6 ++---- esphome/components/mcp4728/mcp4728.h | 6 ++---- esphome/components/mcp4728/output/mcp4728_output.cpp | 6 ++---- esphome/components/mcp4728/output/mcp4728_output.h | 6 ++---- esphome/components/mcp47a1/mcp47a1.cpp | 6 ++---- esphome/components/mcp47a1/mcp47a1.h | 6 ++---- esphome/components/mcp9600/mcp9600.cpp | 6 ++---- esphome/components/mcp9600/mcp9600.h | 6 ++---- esphome/components/mcp9808/mcp9808.cpp | 6 ++---- esphome/components/mcp9808/mcp9808.h | 6 ++---- esphome/components/md5/md5.cpp | 7 +++---- esphome/components/md5/md5.h | 7 +++---- esphome/components/media_player/automation.h | 7 ++----- esphome/components/media_player/media_player.cpp | 6 ++---- esphome/components/media_player/media_player.h | 6 ++---- esphome/components/micro_wake_word/automation.h | 8 ++++---- esphome/components/micro_wake_word/micro_wake_word.cpp | 6 ++---- esphome/components/micro_wake_word/micro_wake_word.h | 6 ++---- .../components/micro_wake_word/preprocessor_settings.h | 6 ++---- esphome/components/micro_wake_word/streaming_model.cpp | 6 ++---- esphome/components/micro_wake_word/streaming_model.h | 6 ++---- esphome/components/microphone/automation.h | 6 ++---- esphome/components/microphone/microphone.h | 6 ++---- esphome/components/microphone/microphone_source.cpp | 6 ++---- esphome/components/microphone/microphone_source.h | 6 ++---- esphome/components/mics_4514/mics_4514.cpp | 6 ++---- esphome/components/mics_4514/mics_4514.h | 6 ++---- esphome/components/midea/ac_adapter.cpp | 8 ++------ esphome/components/midea/ac_adapter.h | 8 ++------ esphome/components/midea/ac_automations.h | 8 ++------ esphome/components/midea/air_conditioner.cpp | 8 ++------ esphome/components/midea/air_conditioner.h | 8 ++------ esphome/components/midea/appliance_base.h | 6 ++---- esphome/components/midea_ir/midea_data.h | 6 ++---- esphome/components/midea_ir/midea_ir.cpp | 6 ++---- esphome/components/midea_ir/midea_ir.h | 6 ++---- esphome/components/mipi_spi/mipi_spi.h | 6 ++---- esphome/components/mitsubishi/mitsubishi.cpp | 6 ++---- esphome/components/mitsubishi/mitsubishi.h | 6 ++---- esphome/components/mixer/speaker/automation.h | 6 ++---- esphome/components/mlx90393/sensor_mlx90393.cpp | 6 ++---- esphome/components/mlx90393/sensor_mlx90393.h | 6 ++---- esphome/components/mlx90614/mlx90614.cpp | 6 ++---- esphome/components/mlx90614/mlx90614.h | 6 ++---- esphome/components/mmc5603/mmc5603.cpp | 6 ++---- esphome/components/mmc5603/mmc5603.h | 6 ++---- esphome/components/mmc5983/mmc5983.cpp | 6 ++---- esphome/components/mmc5983/mmc5983.h | 6 ++---- esphome/components/modbus/modbus.cpp | 6 ++---- esphome/components/modbus/modbus.h | 6 ++---- esphome/components/modbus/modbus_definitions.h | 6 ++---- .../binary_sensor/modbus_binarysensor.cpp | 6 ++---- .../modbus_controller/binary_sensor/modbus_binarysensor.h | 6 ++---- .../components/modbus_controller/modbus_controller.cpp | 6 ++---- esphome/components/modbus_controller/modbus_controller.h | 6 ++---- .../components/modbus_controller/number/modbus_number.cpp | 6 ++---- .../components/modbus_controller/number/modbus_number.h | 6 ++---- .../components/modbus_controller/output/modbus_output.cpp | 6 ++---- .../components/modbus_controller/output/modbus_output.h | 6 ++---- .../components/modbus_controller/select/modbus_select.cpp | 6 ++---- .../components/modbus_controller/select/modbus_select.h | 6 ++---- .../components/modbus_controller/sensor/modbus_sensor.cpp | 6 ++---- .../components/modbus_controller/sensor/modbus_sensor.h | 6 ++---- .../components/modbus_controller/switch/modbus_switch.cpp | 7 +++---- .../components/modbus_controller/switch/modbus_switch.h | 6 ++---- .../modbus_controller/text_sensor/modbus_textsensor.cpp | 6 ++---- .../modbus_controller/text_sensor/modbus_textsensor.h | 6 ++---- .../components/monochromatic/monochromatic_light_output.h | 6 ++---- esphome/components/mopeka_ble/mopeka_ble.cpp | 6 ++---- esphome/components/mopeka_ble/mopeka_ble.h | 6 ++---- esphome/components/mopeka_pro_check/mopeka_pro_check.cpp | 6 ++---- esphome/components/mopeka_pro_check/mopeka_pro_check.h | 6 ++---- esphome/components/mopeka_std_check/mopeka_std_check.cpp | 6 ++---- esphome/components/mopeka_std_check/mopeka_std_check.h | 6 ++---- esphome/components/mpl3115a2/mpl3115a2.cpp | 6 ++---- esphome/components/mpl3115a2/mpl3115a2.h | 6 ++---- .../mpr121/binary_sensor/mpr121_binary_sensor.cpp | 6 ++---- .../mpr121/binary_sensor/mpr121_binary_sensor.h | 6 ++---- esphome/components/mpr121/mpr121.cpp | 6 ++---- esphome/components/mpr121/mpr121.h | 6 ++---- esphome/components/mpu6050/mpu6050.cpp | 6 ++---- esphome/components/mpu6050/mpu6050.h | 7 ++----- esphome/components/mpu6886/mpu6886.cpp | 6 ++---- esphome/components/mpu6886/mpu6886.h | 7 ++----- .../mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp | 6 ++---- .../mqtt_subscribe/sensor/mqtt_subscribe_sensor.h | 6 ++---- .../text_sensor/mqtt_subscribe_text_sensor.cpp | 6 ++---- .../text_sensor/mqtt_subscribe_text_sensor.h | 6 ++---- esphome/components/ms5611/ms5611.cpp | 6 ++---- esphome/components/ms5611/ms5611.h | 6 ++---- esphome/components/ms8607/ms8607.cpp | 6 ++---- esphome/components/ms8607/ms8607.h | 6 ++---- esphome/components/msa3xx/msa3xx.cpp | 6 ++---- esphome/components/msa3xx/msa3xx.h | 6 ++---- esphome/components/my9231/my9231.cpp | 6 ++---- esphome/components/my9231/my9231.h | 6 ++---- 250 files changed, 508 insertions(+), 1013 deletions(-) diff --git a/esphome/components/i2c_device/i2c_device.cpp b/esphome/components/i2c_device/i2c_device.cpp index 455c68fbed..2f68a9e96a 100644 --- a/esphome/components/i2c_device/i2c_device.cpp +++ b/esphome/components/i2c_device/i2c_device.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace i2c_device { +namespace esphome::i2c_device { static const char *const TAG = "i2c_device"; @@ -13,5 +12,4 @@ void I2CDeviceComponent::dump_config() { LOG_I2C_DEVICE(this); } -} // namespace i2c_device -} // namespace esphome +} // namespace esphome::i2c_device diff --git a/esphome/components/i2c_device/i2c_device.h b/esphome/components/i2c_device/i2c_device.h index 9944ca9204..aeae622c2e 100644 --- a/esphome/components/i2c_device/i2c_device.h +++ b/esphome/components/i2c_device/i2c_device.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace i2c_device { +namespace esphome::i2c_device { class I2CDeviceComponent : public Component, public i2c::I2CDevice { public: @@ -13,5 +12,4 @@ class I2CDeviceComponent : public Component, public i2c::I2CDevice { protected: }; -} // namespace i2c_device -} // namespace esphome +} // namespace esphome::i2c_device diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index 5b260fa7ed..6b32b556d9 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace i2s_audio { +namespace esphome::i2s_audio { class I2SAudioComponent; @@ -77,7 +76,6 @@ class I2SAudioComponent : public Component { int port_{}; }; -} // namespace i2s_audio -} // namespace esphome +} // namespace esphome::i2s_audio #endif // USE_ESP32 diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index d697808c99..66ca32b830 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -10,8 +10,7 @@ #include "esphome/components/audio/audio.h" -namespace esphome { -namespace i2s_audio { +namespace esphome::i2s_audio { static const UBaseType_t MAX_LISTENERS = 16; @@ -426,7 +425,6 @@ void I2SAudioMicrophone::loop() { } } -} // namespace i2s_audio -} // namespace esphome +} // namespace esphome::i2s_audio #endif // USE_ESP32 diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index e277409262..06f2de7610 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -12,8 +12,7 @@ #include #include -namespace esphome { -namespace i2s_audio { +namespace esphome::i2s_audio { class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, public Component { public: @@ -65,7 +64,6 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub int32_t dc_offset_prev_output_{0}; }; -} // namespace i2s_audio -} // namespace esphome +} // namespace esphome::i2s_audio #endif // USE_ESP32 diff --git a/esphome/components/iaqcore/iaqcore.cpp b/esphome/components/iaqcore/iaqcore.cpp index c414eb8f60..7397d0975c 100644 --- a/esphome/components/iaqcore/iaqcore.cpp +++ b/esphome/components/iaqcore/iaqcore.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace iaqcore { +namespace esphome::iaqcore { static const char *const TAG = "iaqcore"; @@ -97,5 +96,4 @@ void IAQCore::dump_config() { LOG_SENSOR(" ", "TVOC", this->tvoc_); } -} // namespace iaqcore -} // namespace esphome +} // namespace esphome::iaqcore diff --git a/esphome/components/iaqcore/iaqcore.h b/esphome/components/iaqcore/iaqcore.h index bb0bfcc754..39f290e120 100644 --- a/esphome/components/iaqcore/iaqcore.h +++ b/esphome/components/iaqcore/iaqcore.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace iaqcore { +namespace esphome::iaqcore { class IAQCore : public PollingComponent, public i2c::I2CDevice { public: @@ -23,5 +22,4 @@ class IAQCore : public PollingComponent, public i2c::I2CDevice { void publish_nans_(); }; -} // namespace iaqcore -} // namespace esphome +} // namespace esphome::iaqcore diff --git a/esphome/components/ili9xxx/ili9xxx_defines.h b/esphome/components/ili9xxx/ili9xxx_defines.h index 70e0937f79..c9b54c0f0e 100644 --- a/esphome/components/ili9xxx/ili9xxx_defines.h +++ b/esphome/components/ili9xxx/ili9xxx_defines.h @@ -2,8 +2,7 @@ #include -namespace esphome { -namespace ili9xxx { +namespace esphome::ili9xxx { // Color definitions // clang-format off @@ -98,5 +97,4 @@ static const uint8_t ILI9XXX_DELAY_FLAG = 0xFF; // special marker for delay - command byte reprents ms, length byte is an impossible value #define ILI9XXX_DELAY(ms) ((uint8_t) ((ms) | 0x80)), ILI9XXX_DELAY_FLAG -} // namespace ili9xxx -} // namespace esphome +} // namespace esphome::ili9xxx diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index 11acb8a73a..e8840c0cf1 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace ili9xxx { +namespace esphome::ili9xxx { static const uint16_t SPI_SETUP_US = 100; // estimated fixed overhead in microseconds for an SPI write static const uint16_t SPI_MAX_BLOCK_SIZE = 4092; // Max size of continuous SPI transfer @@ -470,5 +469,4 @@ void ILI9XXXDisplay::invert_colors(bool invert) { int ILI9XXXDisplay::get_width_internal() { return this->width_; } int ILI9XXXDisplay::get_height_internal() { return this->height_; } -} // namespace ili9xxx -} // namespace esphome +} // namespace esphome::ili9xxx diff --git a/esphome/components/ili9xxx/ili9xxx_display.h b/esphome/components/ili9xxx/ili9xxx_display.h index 629bbb41cb..2529e54021 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.h +++ b/esphome/components/ili9xxx/ili9xxx_display.h @@ -5,8 +5,7 @@ #include "ili9xxx_defines.h" #include "ili9xxx_init.h" -namespace esphome { -namespace ili9xxx { +namespace esphome::ili9xxx { static const char *const TAG = "ili9xxx"; const size_t ILI9XXX_TRANSFER_BUFFER_SIZE = 126; // ensure this is divisible by 6 @@ -283,5 +282,4 @@ class ILI9XXXST7735 : public ILI9XXXDisplay { ILI9XXXST7735() : ILI9XXXDisplay(INITCMD_ST7735, 128, 160) {} }; -} // namespace ili9xxx -} // namespace esphome +} // namespace esphome::ili9xxx diff --git a/esphome/components/ili9xxx/ili9xxx_init.h b/esphome/components/ili9xxx/ili9xxx_init.h index f0c6a94a65..529571022a 100644 --- a/esphome/components/ili9xxx/ili9xxx_init.h +++ b/esphome/components/ili9xxx/ili9xxx_init.h @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace ili9xxx { +namespace esphome::ili9xxx { // clang-format off static constexpr uint8_t PROGMEM INITCMD_M5STACK[] = { @@ -478,5 +477,4 @@ static constexpr uint8_t PROGMEM INITCMD_ST7735[] = { }; // clang-format on -} // namespace ili9xxx -} // namespace esphome +} // namespace esphome::ili9xxx diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 5b4ed6968c..c95b693cf0 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace image { +namespace esphome::image { void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) { int img_x0 = 0; @@ -243,5 +242,4 @@ Image::Image(const uint8_t *data_start, int width, int height, ImageType type, T } } -} // namespace image -} // namespace esphome +} // namespace esphome::image diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index d4865570e4..ccc2f23f20 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -6,8 +6,7 @@ #include "esphome/components/lvgl/lvgl_proxy.h" #endif // USE_LVGL -namespace esphome { -namespace image { +namespace esphome::image { enum ImageType { IMAGE_TYPE_BINARY = 0, @@ -61,5 +60,4 @@ class Image : public display::BaseImage { #endif }; -} // namespace image -} // namespace esphome +} // namespace esphome::image diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index d0340344a6..fa1b855d6c 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -5,8 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" -namespace esphome { -namespace improv_base { +namespace esphome::improv_base { #if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}"; @@ -65,5 +64,4 @@ size_t ImprovBase::get_formatted_next_url_(char *buffer, size_t buffer_size) { } #endif -} // namespace improv_base -} // namespace esphome +} // namespace esphome::improv_base diff --git a/esphome/components/improv_base/improv_base.h b/esphome/components/improv_base/improv_base.h index ebc8f38d60..9dded85a46 100644 --- a/esphome/components/improv_base/improv_base.h +++ b/esphome/components/improv_base/improv_base.h @@ -3,8 +3,7 @@ #include #include "esphome/core/defines.h" -namespace esphome { -namespace improv_base { +namespace esphome::improv_base { class ImprovBase { public: @@ -20,5 +19,4 @@ class ImprovBase { #endif }; -} // namespace improv_base -} // namespace esphome +} // namespace esphome::improv_base diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 003328d535..18d0b44701 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -8,8 +8,7 @@ #include "esphome/components/logger/logger.h" -namespace esphome { -namespace improv_serial { +namespace esphome::improv_serial { static const char *const TAG = "improv_serial"; @@ -329,6 +328,6 @@ void ImprovSerialComponent::on_wifi_connect_timeout_() { ImprovSerialComponent *global_improv_serial_component = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace improv_serial -} // namespace esphome +} // namespace esphome::improv_serial + #endif diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index dd8f5e4719..2f1d0136a4 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -23,8 +23,7 @@ #include #endif -namespace esphome { -namespace improv_serial { +namespace esphome::improv_serial { // TX buffer layout constants static constexpr uint8_t TX_HEADER_SIZE = 6; // Bytes 0-5 = "IMPROV" @@ -99,6 +98,6 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { extern ImprovSerialComponent *global_improv_serial_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace improv_serial -} // namespace esphome +} // namespace esphome::improv_serial + #endif diff --git a/esphome/components/ina219/ina219.cpp b/esphome/components/ina219/ina219.cpp index 278017651b..85da196584 100644 --- a/esphome/components/ina219/ina219.cpp +++ b/esphome/components/ina219/ina219.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace ina219 { +namespace esphome::ina219 { static const char *const TAG = "ina219"; @@ -196,5 +195,4 @@ void INA219Component::update() { this->status_clear_warning(); } -} // namespace ina219 -} // namespace esphome +} // namespace esphome::ina219 diff --git a/esphome/components/ina219/ina219.h b/esphome/components/ina219/ina219.h index bcadb65e36..7462c07272 100644 --- a/esphome/components/ina219/ina219.h +++ b/esphome/components/ina219/ina219.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace ina219 { +namespace esphome::ina219 { class INA219Component : public PollingComponent, public i2c::I2CDevice { public: @@ -35,5 +34,4 @@ class INA219Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *power_sensor_{nullptr}; }; -} // namespace ina219 -} // namespace esphome +} // namespace esphome::ina219 diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index cbc44c9a1a..695de57c61 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace ina226 { +namespace esphome::ina226 { static const char *const TAG = "ina226"; @@ -161,5 +160,4 @@ int32_t INA226Component::twos_complement_(int32_t val, uint8_t bits) { return val; } -} // namespace ina226 -} // namespace esphome +} // namespace esphome::ina226 diff --git a/esphome/components/ina226/ina226.h b/esphome/components/ina226/ina226.h index 0aa66ff765..7d6b526f40 100644 --- a/esphome/components/ina226/ina226.h +++ b/esphome/components/ina226/ina226.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ina226 { +namespace esphome::ina226 { enum AdcTime : uint16_t { ADC_TIME_140US = 0, @@ -73,5 +72,4 @@ class INA226Component : public PollingComponent, public i2c::I2CDevice { int32_t twos_complement_(int32_t val, uint8_t bits); }; -} // namespace ina226 -} // namespace esphome +} // namespace esphome::ina226 diff --git a/esphome/components/ina260/ina260.cpp b/esphome/components/ina260/ina260.cpp index 4d6acf400c..05039f0e33 100644 --- a/esphome/components/ina260/ina260.cpp +++ b/esphome/components/ina260/ina260.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace ina260 { +namespace esphome::ina260 { static const char *const TAG = "ina260"; @@ -122,5 +121,4 @@ void INA260Component::update() { this->status_clear_warning(); } -} // namespace ina260 -} // namespace esphome +} // namespace esphome::ina260 diff --git a/esphome/components/ina260/ina260.h b/esphome/components/ina260/ina260.h index 6cbc157cf3..856e715774 100644 --- a/esphome/components/ina260/ina260.h +++ b/esphome/components/ina260/ina260.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ina260 { +namespace esphome::ina260 { class INA260Component : public PollingComponent, public i2c::I2CDevice { public: @@ -33,5 +32,4 @@ class INA260Component : public PollingComponent, public i2c::I2CDevice { } error_code_{NONE}; }; -} // namespace ina260 -} // namespace esphome +} // namespace esphome::ina260 diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 2d08562e54..d3acf00eef 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -5,8 +5,7 @@ #include #include -namespace esphome { -namespace ina2xx_base { +namespace esphome::ina2xx_base { static const char *const TAG = "ina2xx"; @@ -600,5 +599,4 @@ bool INA2XX::read_unsigned_16_(uint8_t reg, uint16_t &out) { int64_t INA2XX::two_complement_(uint64_t value, uint8_t bits) { return (int64_t) (value << (64 - bits)) >> (64 - bits); } -} // namespace ina2xx_base -} // namespace esphome +} // namespace esphome::ina2xx_base diff --git a/esphome/components/ina2xx_base/ina2xx_base.h b/esphome/components/ina2xx_base/ina2xx_base.h index 104c384a0d..beb158944b 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.h +++ b/esphome/components/ina2xx_base/ina2xx_base.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace ina2xx_base { +namespace esphome::ina2xx_base { enum RegisterMap : uint8_t { REG_CONFIG = 0x00, @@ -250,5 +249,4 @@ class INA2XX : public PollingComponent { virtual bool read_ina_register(uint8_t a_register, uint8_t *data, size_t len) = 0; virtual bool write_ina_register(uint8_t a_register, const uint8_t *data, size_t len) = 0; }; -} // namespace ina2xx_base -} // namespace esphome +} // namespace esphome::ina2xx_base diff --git a/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp b/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp index a363a9c12f..4fc3b00a21 100644 --- a/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp +++ b/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp @@ -1,8 +1,7 @@ #include "ina2xx_i2c.h" #include "esphome/core/log.h" -namespace esphome { -namespace ina2xx_i2c { +namespace esphome::ina2xx_i2c { static const char *const TAG = "ina2xx_i2c"; @@ -35,5 +34,4 @@ bool INA2XXI2C::write_ina_register(uint8_t reg, const uint8_t *data, size_t len) } return ret == i2c::ERROR_OK; } -} // namespace ina2xx_i2c -} // namespace esphome +} // namespace esphome::ina2xx_i2c diff --git a/esphome/components/ina2xx_i2c/ina2xx_i2c.h b/esphome/components/ina2xx_i2c/ina2xx_i2c.h index c90b9bf190..783723b396 100644 --- a/esphome/components/ina2xx_i2c/ina2xx_i2c.h +++ b/esphome/components/ina2xx_i2c/ina2xx_i2c.h @@ -4,8 +4,7 @@ #include "esphome/components/ina2xx_base/ina2xx_base.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ina2xx_i2c { +namespace esphome::ina2xx_i2c { class INA2XXI2C : public ina2xx_base::INA2XX, public i2c::I2CDevice { public: @@ -17,5 +16,4 @@ class INA2XXI2C : public ina2xx_base::INA2XX, public i2c::I2CDevice { bool write_ina_register(uint8_t reg, const uint8_t *data, size_t len) override; }; -} // namespace ina2xx_i2c -} // namespace esphome +} // namespace esphome::ina2xx_i2c diff --git a/esphome/components/ina2xx_spi/ina2xx_spi.cpp b/esphome/components/ina2xx_spi/ina2xx_spi.cpp index 3e04a87665..43eb676236 100644 --- a/esphome/components/ina2xx_spi/ina2xx_spi.cpp +++ b/esphome/components/ina2xx_spi/ina2xx_spi.cpp @@ -1,8 +1,7 @@ #include "ina2xx_spi.h" #include "esphome/core/log.h" -namespace esphome { -namespace ina2xx_spi { +namespace esphome::ina2xx_spi { static const char *const TAG = "ina2xx_spi"; @@ -34,5 +33,4 @@ bool INA2XXSPI::write_ina_register(uint8_t reg, const uint8_t *data, size_t len) this->disable(); return true; } -} // namespace ina2xx_spi -} // namespace esphome +} // namespace esphome::ina2xx_spi diff --git a/esphome/components/ina2xx_spi/ina2xx_spi.h b/esphome/components/ina2xx_spi/ina2xx_spi.h index 3b21518d34..8e065de816 100644 --- a/esphome/components/ina2xx_spi/ina2xx_spi.h +++ b/esphome/components/ina2xx_spi/ina2xx_spi.h @@ -4,8 +4,7 @@ #include "esphome/components/ina2xx_base/ina2xx_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace ina2xx_spi { +namespace esphome::ina2xx_spi { class INA2XXSPI : public ina2xx_base::INA2XX, public spi::SPIDevicebus_voltage_sensor_ != nullptr || this->power_sensor_ != nullptr; } -} // namespace ina3221 -} // namespace esphome +} // namespace esphome::ina3221 diff --git a/esphome/components/ina3221/ina3221.h b/esphome/components/ina3221/ina3221.h index 3769df77aa..9d9762caf3 100644 --- a/esphome/components/ina3221/ina3221.h +++ b/esphome/components/ina3221/ina3221.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ina3221 { +namespace esphome::ina3221 { class INA3221Component : public PollingComponent, public i2c::I2CDevice { public: @@ -35,5 +34,4 @@ class INA3221Component : public PollingComponent, public i2c::I2CDevice { } channels_[3]; }; -} // namespace ina3221 -} // namespace esphome +} // namespace esphome::ina3221 diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp index c53d8e5029..4df22aa9de 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace inkbird_ibsth1_mini { +namespace esphome::inkbird_ibsth1_mini { static const char *const TAG = "inkbird_ibsth1_mini"; @@ -104,7 +103,6 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic return true; } -} // namespace inkbird_ibsth1_mini -} // namespace esphome +} // 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 cd2ea99717..37e50943f3 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace inkbird_ibsth1_mini { +namespace esphome::inkbird_ibsth1_mini { class InkbirdIbstH1Mini : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -29,7 +28,6 @@ class InkbirdIbstH1Mini : public Component, public esp32_ble_tracker::ESPBTDevic sensor::Sensor *battery_level_{nullptr}; }; -} // namespace inkbird_ibsth1_mini -} // namespace esphome +} // namespace esphome::inkbird_ibsth1_mini #endif diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 0511b451a8..39110ca83b 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace inkplate { +namespace esphome::inkplate { static const char *const TAG = "inkplate"; @@ -820,5 +819,4 @@ void Inkplate::pins_as_outputs_() { this->display_data_7_pin_->pin_mode(gpio::FLAG_OUTPUT); } -} // namespace inkplate -} // namespace esphome +} // namespace esphome::inkplate diff --git a/esphome/components/inkplate/inkplate.h b/esphome/components/inkplate/inkplate.h index bcd56b829a..40e32c4cc4 100644 --- a/esphome/components/inkplate/inkplate.h +++ b/esphome/components/inkplate/inkplate.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace inkplate { +namespace esphome::inkplate { enum InkplateModel : uint8_t { INKPLATE_6 = 0, @@ -210,5 +209,4 @@ class Inkplate : public display::DisplayBuffer, public i2c::I2CDevice { GPIOPin *wakeup_pin_; }; -} // namespace inkplate -} // namespace esphome +} // namespace esphome::inkplate diff --git a/esphome/components/integration/integration_sensor.cpp b/esphome/components/integration/integration_sensor.cpp index b084801d3b..bc26fb0f19 100644 --- a/esphome/components/integration/integration_sensor.cpp +++ b/esphome/components/integration/integration_sensor.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace integration { +namespace esphome::integration { static const char *const TAG = "integration"; @@ -47,5 +46,4 @@ void IntegrationSensor::process_sensor_value_(float value) { this->publish_and_save_(this->result_ + area); } -} // namespace integration -} // namespace esphome +} // namespace esphome::integration diff --git a/esphome/components/integration/integration_sensor.h b/esphome/components/integration/integration_sensor.h index 6c4ef7049b..1c5edfcba5 100644 --- a/esphome/components/integration/integration_sensor.h +++ b/esphome/components/integration/integration_sensor.h @@ -6,8 +6,7 @@ #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace integration { +namespace esphome::integration { enum IntegrationSensorTime { INTEGRATION_SENSOR_TIME_MILLISECOND = 0, @@ -84,5 +83,4 @@ template class SetValueAction : public Action, public Par void play(const Ts &...x) override { this->parent_->set_value(this->value_.value(x...)); } }; -} // namespace integration -} // namespace esphome +} // namespace esphome::integration diff --git a/esphome/components/interval/interval.h b/esphome/components/interval/interval.h index e419841e6c..c9d4e8ea3e 100644 --- a/esphome/components/interval/interval.h +++ b/esphome/components/interval/interval.h @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/core/automation.h" -namespace esphome { -namespace interval { +namespace esphome::interval { class IntervalTrigger : public Trigger<>, public PollingComponent { public: @@ -24,5 +23,4 @@ class IntervalTrigger : public Trigger<>, public PollingComponent { uint32_t startup_delay_{0}; }; -} // namespace interval -} // namespace esphome +} // namespace esphome::interval diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.cpp b/esphome/components/jsn_sr04t/jsn_sr04t.cpp index 6fd8b1bd65..c67771a0b6 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.cpp +++ b/esphome/components/jsn_sr04t/jsn_sr04t.cpp @@ -4,8 +4,7 @@ // Very basic support for JSN_SR04T V3.0 distance sensor in mode 2 -namespace esphome { -namespace jsn_sr04t { +namespace esphome::jsn_sr04t { static const char *const TAG = "jsn_sr04t.sensor"; @@ -62,5 +61,4 @@ void Jsnsr04tComponent::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace jsn_sr04t -} // namespace esphome +} // namespace esphome::jsn_sr04t diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.h b/esphome/components/jsn_sr04t/jsn_sr04t.h index 2a22ff92ec..f9d07ea539 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.h +++ b/esphome/components/jsn_sr04t/jsn_sr04t.h @@ -6,8 +6,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace jsn_sr04t { +namespace esphome::jsn_sr04t { enum Model { JSN_SR04T, @@ -30,5 +29,4 @@ class Jsnsr04tComponent : public sensor::Sensor, public PollingComponent, public std::vector buffer_; }; -} // namespace jsn_sr04t -} // namespace esphome +} // namespace esphome::jsn_sr04t diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index ec1490be1f..984134b95f 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -3,8 +3,7 @@ // ArduinoJson::Allocator is included via ArduinoJson.h in json_util.h -namespace esphome { -namespace json { +namespace esphome::json { static const char *const TAG = "json"; @@ -149,5 +148,4 @@ SerializationBuffer<> JsonBuilder::serialize() { return result; } -} // namespace json -} // namespace esphome +} // namespace esphome::json diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 0dc9ff883c..9f51d9927b 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -13,8 +13,7 @@ #include -namespace esphome { -namespace json { +namespace esphome::json { /// Buffer for JSON serialization that uses stack allocation for small payloads. /// Template parameter STACK_SIZE specifies the stack buffer size (default 512 bytes). @@ -192,5 +191,4 @@ class JsonBuilder { bool root_created_{false}; }; -} // namespace json -} // namespace esphome +} // namespace esphome::json diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 9bebd4cd56..ed03c4d6df 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace kamstrup_kmp { +namespace esphome::kamstrup_kmp { static const char *const TAG = "kamstrup_kmp"; @@ -303,5 +302,4 @@ uint16_t crc16_ccitt(const uint8_t *buffer, int len) { return (uint16_t) reg; } -} // namespace kamstrup_kmp -} // namespace esphome +} // namespace esphome::kamstrup_kmp diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.h b/esphome/components/kamstrup_kmp/kamstrup_kmp.h index 725cf20abf..a05a0ee17a 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.h +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.h @@ -5,8 +5,7 @@ #include "esphome/components/uart/uart.h" #include "esphome/core/component.h" -namespace esphome { -namespace kamstrup_kmp { +namespace esphome::kamstrup_kmp { /* =========================================================================== @@ -127,5 +126,4 @@ class KamstrupKMPComponent : public PollingComponent, public uart::UARTDevice { // "true" CCITT CRC-16 uint16_t crc16_ccitt(const uint8_t *buffer, int len); -} // namespace kamstrup_kmp -} // namespace esphome +} // namespace esphome::kamstrup_kmp diff --git a/esphome/components/key_collector/key_collector.cpp b/esphome/components/key_collector/key_collector.cpp index 68d1c60bf9..cb7d47b7f0 100644 --- a/esphome/components/key_collector/key_collector.cpp +++ b/esphome/components/key_collector/key_collector.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace key_collector { +namespace esphome::key_collector { static const char *const TAG = "key_collector"; @@ -102,5 +101,4 @@ void KeyCollector::send_key(uint8_t key) { this->progress_callbacks_.call(this->result_, this->start_key_); } -} // namespace key_collector -} // namespace esphome +} // namespace esphome::key_collector diff --git a/esphome/components/key_collector/key_collector.h b/esphome/components/key_collector/key_collector.h index 014e2034bd..27209c50df 100644 --- a/esphome/components/key_collector/key_collector.h +++ b/esphome/components/key_collector/key_collector.h @@ -5,8 +5,7 @@ #include "esphome/core/automation.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace key_collector { +namespace esphome::key_collector { class KeyCollector : public Component { public: @@ -63,5 +62,4 @@ template class DisableAction : public Action, public Pare void play(const Ts &...x) override { this->parent_->set_enabled(false); } }; -} // namespace key_collector -} // namespace esphome +} // namespace esphome::key_collector diff --git a/esphome/components/key_provider/key_provider.cpp b/esphome/components/key_provider/key_provider.cpp index 64b0729d4d..0efeeff006 100644 --- a/esphome/components/key_provider/key_provider.cpp +++ b/esphome/components/key_provider/key_provider.cpp @@ -1,9 +1,7 @@ #include "key_provider.h" -namespace esphome { -namespace key_provider { +namespace esphome::key_provider { void KeyProvider::send_key_(uint8_t key) { this->key_callback_.call(key); } -} // namespace key_provider -} // namespace esphome +} // namespace esphome::key_provider diff --git a/esphome/components/key_provider/key_provider.h b/esphome/components/key_provider/key_provider.h index 9740342751..85c2752384 100644 --- a/esphome/components/key_provider/key_provider.h +++ b/esphome/components/key_provider/key_provider.h @@ -3,8 +3,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace key_provider { +namespace esphome::key_provider { /// interface for components that provide keypresses class KeyProvider { @@ -17,5 +16,4 @@ class KeyProvider { CallbackManager key_callback_{}; }; -} // namespace key_provider -} // namespace esphome +} // namespace esphome::key_provider diff --git a/esphome/components/kmeteriso/kmeteriso.cpp b/esphome/components/kmeteriso/kmeteriso.cpp index 186686e472..d6934a97ac 100644 --- a/esphome/components/kmeteriso/kmeteriso.cpp +++ b/esphome/components/kmeteriso/kmeteriso.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace kmeteriso { +namespace esphome::kmeteriso { static const char *const TAG = "kmeteriso.sensor"; @@ -74,5 +73,4 @@ void KMeterISOComponent::update() { } } -} // namespace kmeteriso -} // namespace esphome +} // namespace esphome::kmeteriso diff --git a/esphome/components/kmeteriso/kmeteriso.h b/esphome/components/kmeteriso/kmeteriso.h index 6f1978105f..d5a2f9a01b 100644 --- a/esphome/components/kmeteriso/kmeteriso.h +++ b/esphome/components/kmeteriso/kmeteriso.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/i2c/i2c_bus.h" -namespace esphome { -namespace kmeteriso { +namespace esphome::kmeteriso { /// This class implements support for the KMeterISO thermocouple sensor. class KMeterISOComponent : public PollingComponent, public i2c::I2CDevice { @@ -29,5 +28,4 @@ class KMeterISOComponent : public PollingComponent, public i2c::I2CDevice { } error_code_{NONE}; }; -} // namespace kmeteriso -} // namespace esphome +} // namespace esphome::kmeteriso diff --git a/esphome/components/kuntze/kuntze.cpp b/esphome/components/kuntze/kuntze.cpp index 1b772d062c..6df114e93c 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace kuntze { +namespace esphome::kuntze { static const char *const TAG = "kuntze"; @@ -97,5 +96,4 @@ void Kuntze::dump_config() { LOG_SENSOR("", "OCI", this->oci_sensor_); } -} // namespace kuntze -} // namespace esphome +} // namespace esphome::kuntze diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index aad7c1cbbf..bbd93a22ce 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -namespace esphome { -namespace kuntze { +namespace esphome::kuntze { class Kuntze : public PollingComponent, public modbus::ModbusDevice { public: @@ -38,5 +37,4 @@ class Kuntze : public PollingComponent, public modbus::ModbusDevice { sensor::Sensor *oci_sensor_{nullptr}; }; -} // namespace kuntze -} // namespace esphome +} // namespace esphome::kuntze diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index 8c7018124a..cbd733b611 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace lc709203f { +namespace esphome::lc709203f { static const char *const TAG = "lc709203f.sensor"; @@ -279,5 +278,4 @@ void Lc709203f::set_thermistor_b_constant(uint16_t b_constant) { this->b_constan void Lc709203f::set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } -} // namespace lc709203f -} // namespace esphome +} // namespace esphome::lc709203f diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 59988a0079..42aa9a15a1 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -namespace esphome { -namespace lc709203f { +namespace esphome::lc709203f { enum LC709203FState { STATE_INIT, @@ -50,5 +49,4 @@ class Lc709203f : public sensor::Sensor, public PollingComponent, public i2c::I2 uint16_t pack_voltage_; }; -} // namespace lc709203f -} // namespace esphome +} // namespace esphome::lc709203f diff --git a/esphome/components/lcd_base/lcd_display.cpp b/esphome/components/lcd_base/lcd_display.cpp index 1f0ba482d7..0890da85c1 100644 --- a/esphome/components/lcd_base/lcd_display.cpp +++ b/esphome/components/lcd_base/lcd_display.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace lcd_base { +namespace esphome::lcd_base { static const char *const TAG = "lcd"; @@ -173,5 +172,4 @@ void LCDDisplay::loadchar(uint8_t location, uint8_t charmap[]) { } } -} // namespace lcd_base -} // namespace esphome +} // namespace esphome::lcd_base diff --git a/esphome/components/lcd_base/lcd_display.h b/esphome/components/lcd_base/lcd_display.h index 473acb0bd3..4b3413e328 100644 --- a/esphome/components/lcd_base/lcd_display.h +++ b/esphome/components/lcd_base/lcd_display.h @@ -6,8 +6,7 @@ #include #include -namespace esphome { -namespace lcd_base { +namespace esphome::lcd_base { class LCDDisplay; @@ -62,5 +61,4 @@ class LCDDisplay : public PollingComponent { std::map > user_defined_chars_; }; -} // namespace lcd_base -} // namespace esphome +} // namespace esphome::lcd_base diff --git a/esphome/components/lcd_gpio/gpio_lcd_display.cpp b/esphome/components/lcd_gpio/gpio_lcd_display.cpp index ae6e1194b8..213fc8637e 100644 --- a/esphome/components/lcd_gpio/gpio_lcd_display.cpp +++ b/esphome/components/lcd_gpio/gpio_lcd_display.cpp @@ -1,8 +1,7 @@ #include "gpio_lcd_display.h" #include "esphome/core/log.h" -namespace esphome { -namespace lcd_gpio { +namespace esphome::lcd_gpio { static const char *const TAG = "lcd_gpio"; @@ -63,5 +62,4 @@ void GPIOLCDDisplay::send(uint8_t value, bool rs) { } } -} // namespace lcd_gpio -} // namespace esphome +} // namespace esphome::lcd_gpio diff --git a/esphome/components/lcd_gpio/gpio_lcd_display.h b/esphome/components/lcd_gpio/gpio_lcd_display.h index 81e4dc51a0..dd9ea5929c 100644 --- a/esphome/components/lcd_gpio/gpio_lcd_display.h +++ b/esphome/components/lcd_gpio/gpio_lcd_display.h @@ -4,8 +4,7 @@ #include "esphome/components/lcd_base/lcd_display.h" #include "esphome/components/display/display.h" -namespace esphome { -namespace lcd_gpio { +namespace esphome::lcd_gpio { class GPIOLCDDisplay; @@ -51,5 +50,4 @@ class GPIOLCDDisplay : public lcd_base::LCDDisplay { gpio_lcd_writer_t writer_; }; -} // namespace lcd_gpio -} // namespace esphome +} // namespace esphome::lcd_gpio diff --git a/esphome/components/lcd_menu/lcd_menu.cpp b/esphome/components/lcd_menu/lcd_menu.cpp index c664b394bf..f731817bdb 100644 --- a/esphome/components/lcd_menu/lcd_menu.cpp +++ b/esphome/components/lcd_menu/lcd_menu.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace lcd_menu { +namespace esphome::lcd_menu { static const char *const TAG = "lcd_menu"; @@ -72,5 +71,4 @@ void LCDCharacterMenuComponent::draw_item(const display_menu_base::MenuItem *ite this->display_->print(0, row, data); } -} // namespace lcd_menu -} // namespace esphome +} // namespace esphome::lcd_menu diff --git a/esphome/components/lcd_menu/lcd_menu.h b/esphome/components/lcd_menu/lcd_menu.h index d0dbca7b2f..ae1c2502fe 100644 --- a/esphome/components/lcd_menu/lcd_menu.h +++ b/esphome/components/lcd_menu/lcd_menu.h @@ -6,8 +6,7 @@ #include #include -namespace esphome { -namespace lcd_menu { +namespace esphome::lcd_menu { /** Class to display a hierarchical menu. * @@ -41,5 +40,4 @@ class LCDCharacterMenuComponent : public display_menu_base::DisplayMenuComponent char mark_back_; }; -} // namespace lcd_menu -} // namespace esphome +} // namespace esphome::lcd_menu diff --git a/esphome/components/lcd_pcf8574/pcf8574_display.cpp b/esphome/components/lcd_pcf8574/pcf8574_display.cpp index d582eead91..d5fc683598 100644 --- a/esphome/components/lcd_pcf8574/pcf8574_display.cpp +++ b/esphome/components/lcd_pcf8574/pcf8574_display.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace lcd_pcf8574 { +namespace esphome::lcd_pcf8574 { static const char *const TAG = "lcd_pcf8574"; @@ -56,5 +55,4 @@ void PCF8574LCDDisplay::no_backlight() { this->write_bytes(this->backlight_value_, nullptr, 0); } -} // namespace lcd_pcf8574 -} // namespace esphome +} // namespace esphome::lcd_pcf8574 diff --git a/esphome/components/lcd_pcf8574/pcf8574_display.h b/esphome/components/lcd_pcf8574/pcf8574_display.h index 672b609036..9ec5ad71af 100644 --- a/esphome/components/lcd_pcf8574/pcf8574_display.h +++ b/esphome/components/lcd_pcf8574/pcf8574_display.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/display/display.h" -namespace esphome { -namespace lcd_pcf8574 { +namespace esphome::lcd_pcf8574 { class PCF8574LCDDisplay; @@ -32,5 +31,4 @@ class PCF8574LCDDisplay : public lcd_base::LCDDisplay, public i2c::I2CDevice { pcf8574_lcd_writer_t writer_; }; -} // namespace lcd_pcf8574 -} // namespace esphome +} // namespace esphome::lcd_pcf8574 diff --git a/esphome/components/lightwaverf/LwRx.h b/esphome/components/lightwaverf/LwRx.h index 8b34de9fbb..1e005ab44c 100644 --- a/esphome/components/lightwaverf/LwRx.h +++ b/esphome/components/lightwaverf/LwRx.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace lightwaverf { +namespace esphome::lightwaverf { // LwRx.h // @@ -138,5 +137,4 @@ class LwRx { InternalGPIOPin *rx_pin_; }; -} // namespace lightwaverf -} // namespace esphome +} // namespace esphome::lightwaverf diff --git a/esphome/components/lightwaverf/LwTx.h b/esphome/components/lightwaverf/LwTx.h index 9192426440..2d0019c095 100644 --- a/esphome/components/lightwaverf/LwTx.h +++ b/esphome/components/lightwaverf/LwTx.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace lightwaverf { +namespace esphome::lightwaverf { // LxTx.h // @@ -90,5 +89,4 @@ class LwTx { uint32_t duty_off_; }; -} // namespace lightwaverf -} // namespace esphome +} // namespace esphome::lightwaverf diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp index ee6c2ee471..87319235e9 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace lilygo_t5_47 { +namespace esphome::lilygo_t5_47 { static const char *const TAG = "lilygo_t5_47.touchscreen"; @@ -104,5 +103,4 @@ void LilygoT547Touchscreen::dump_config() { LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); } -} // namespace lilygo_t5_47 -} // namespace esphome +} // namespace esphome::lilygo_t5_47 diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h index 6767bf0a71..8b345515ab 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace lilygo_t5_47 { +namespace esphome::lilygo_t5_47 { using namespace touchscreen; @@ -27,5 +26,4 @@ class LilygoT547Touchscreen : public Touchscreen, public i2c::I2CDevice { InternalGPIOPin *interrupt_pin_; }; -} // namespace lilygo_t5_47 -} // namespace esphome +} // namespace esphome::lilygo_t5_47 diff --git a/esphome/components/lm75b/lm75b.cpp b/esphome/components/lm75b/lm75b.cpp index 19398eda85..2e8b9d3cfe 100644 --- a/esphome/components/lm75b/lm75b.cpp +++ b/esphome/components/lm75b/lm75b.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace lm75b { +namespace esphome::lm75b { static const char *const TAG = "lm75b"; @@ -35,5 +34,4 @@ void LM75BComponent::update() { } } -} // namespace lm75b -} // namespace esphome +} // namespace esphome::lm75b diff --git a/esphome/components/lm75b/lm75b.h b/esphome/components/lm75b/lm75b.h index 79d9fa3f32..eaf1b46550 100644 --- a/esphome/components/lm75b/lm75b.h +++ b/esphome/components/lm75b/lm75b.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace lm75b { +namespace esphome::lm75b { static const uint8_t LM75B_REG_TEMPERATURE = 0x00; @@ -15,5 +14,4 @@ class LM75BComponent : public PollingComponent, public i2c::I2CDevice, public se void update() override; }; -} // namespace lm75b -} // namespace esphome +} // namespace esphome::lm75b diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 592b7faaf0..020add695c 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -1,7 +1,6 @@ #include "lps22.h" -namespace esphome { -namespace lps22 { +namespace esphome::lps22 { static constexpr const char *const TAG = "lps22"; @@ -78,5 +77,4 @@ void LPS22Component::try_read_() { } } -} // namespace lps22 -} // namespace esphome +} // namespace esphome::lps22 diff --git a/esphome/components/lps22/lps22.h b/esphome/components/lps22/lps22.h index 95ee4ad442..c6746f2343 100644 --- a/esphome/components/lps22/lps22.h +++ b/esphome/components/lps22/lps22.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace lps22 { +namespace esphome::lps22 { class LPS22Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: @@ -24,5 +23,4 @@ class LPS22Component : public sensor::Sensor, public PollingComponent, public i2 uint8_t read_attempts_remaining_{0}; }; -} // namespace lps22 -} // namespace esphome +} // namespace esphome::lps22 diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index 033f31a3d1..62a0d2290a 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace ltr390 { +namespace esphome::ltr390 { static const char *const TAG = "ltr390"; @@ -203,5 +202,4 @@ void LTR390Component::update() { this->read_mode_((this->enabled_modes_ & ENABLED_MODE_ALS) ? LTR390_MODE_ALS : LTR390_MODE_UVS); } -} // namespace ltr390 -} // namespace esphome +} // namespace esphome::ltr390 diff --git a/esphome/components/ltr390/ltr390.h b/esphome/components/ltr390/ltr390.h index 47884b9166..1ead84b4a8 100644 --- a/esphome/components/ltr390/ltr390.h +++ b/esphome/components/ltr390/ltr390.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/optional.h" -namespace esphome { -namespace ltr390 { +namespace esphome::ltr390 { enum LTR390CTRL { LTR390_CTRL_EN = 1, @@ -85,5 +84,4 @@ class LTR390Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *uv_sensor_{nullptr}; }; -} // namespace ltr390 -} // namespace esphome +} // namespace esphome::ltr390 diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index 4c9006be1d..9cba06e483 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -6,8 +6,7 @@ using esphome::i2c::ErrorCode; -namespace esphome { -namespace ltr501 { +namespace esphome::ltr501 { static const char *const TAG = "ltr501"; @@ -542,5 +541,4 @@ void LTRAlsPs501Component::publish_data_part_2_(AlsReadings &data) { this->actual_integration_time_sensor_->publish_state(get_itime_ms(data.integration_time)); } } -} // namespace ltr501 -} // namespace esphome +} // namespace esphome::ltr501 diff --git a/esphome/components/ltr501/ltr501.h b/esphome/components/ltr501/ltr501.h index 2b91463108..c7eccbeea9 100644 --- a/esphome/components/ltr501/ltr501.h +++ b/esphome/components/ltr501/ltr501.h @@ -8,8 +8,7 @@ #include "ltr_definitions_501.h" -namespace esphome { -namespace ltr501 { +namespace esphome::ltr501 { enum LtrDataAvail : uint8_t { LTR_NO_DATA, LTR_BAD_DATA, LTR_DATA_OK }; @@ -162,5 +161,4 @@ class LTRAlsPs501Component : public PollingComponent, public i2c::I2CDevice { CallbackManager on_ps_high_trigger_callback_; CallbackManager on_ps_low_trigger_callback_; }; -} // namespace ltr501 -} // namespace esphome +} // namespace esphome::ltr501 diff --git a/esphome/components/ltr501/ltr_definitions_501.h b/esphome/components/ltr501/ltr_definitions_501.h index 604bd92b68..c92fad2d66 100644 --- a/esphome/components/ltr501/ltr_definitions_501.h +++ b/esphome/components/ltr501/ltr_definitions_501.h @@ -2,8 +2,7 @@ #include -namespace esphome { -namespace ltr501 { +namespace esphome::ltr501 { enum class CommandRegisters : uint8_t { ALS_CONTR = 0x80, // ALS operation mode control and SW reset @@ -256,5 +255,4 @@ union InterruptPersistRegister { } __attribute__((packed)); }; -} // namespace ltr501 -} // namespace esphome +} // namespace esphome::ltr501 diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index ff335fe34c..b7fad2e876 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -6,8 +6,7 @@ using esphome::i2c::ErrorCode; -namespace esphome { -namespace ltr_als_ps { +namespace esphome::ltr_als_ps { static const char *const TAG = "ltr_als_ps"; @@ -521,5 +520,4 @@ void LTRAlsPsComponent::publish_data_part_2_(AlsReadings &data) { this->actual_integration_time_sensor_->publish_state(get_itime_ms(data.integration_time)); } } -} // namespace ltr_als_ps -} // namespace esphome +} // namespace esphome::ltr_als_ps diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.h b/esphome/components/ltr_als_ps/ltr_als_ps.h index 8aa5c9f24b..67d8fddad2 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.h +++ b/esphome/components/ltr_als_ps/ltr_als_ps.h @@ -8,8 +8,7 @@ #include "ltr_definitions.h" -namespace esphome { -namespace ltr_als_ps { +namespace esphome::ltr_als_ps { enum LtrDataAvail : uint8_t { LTR_NO_DATA, LTR_BAD_DATA, LTR_DATA_OK }; @@ -162,5 +161,4 @@ class LTRAlsPsComponent : public PollingComponent, public i2c::I2CDevice { CallbackManager on_ps_high_trigger_callback_; CallbackManager on_ps_low_trigger_callback_; }; -} // namespace ltr_als_ps -} // namespace esphome +} // namespace esphome::ltr_als_ps diff --git a/esphome/components/ltr_als_ps/ltr_definitions.h b/esphome/components/ltr_als_ps/ltr_definitions.h index 739445e9a0..c70c2f1804 100644 --- a/esphome/components/ltr_als_ps/ltr_definitions.h +++ b/esphome/components/ltr_als_ps/ltr_definitions.h @@ -2,8 +2,7 @@ #include -namespace esphome { -namespace ltr_als_ps { +namespace esphome::ltr_als_ps { enum class CommandRegisters : uint8_t { ALS_CONTR = 0x80, // ALS operation mode control and SW reset @@ -271,5 +270,4 @@ union InterruptPersistRegister { } __attribute__((packed)); }; -} // namespace ltr_als_ps -} // namespace esphome +} // namespace esphome::ltr_als_ps diff --git a/esphome/components/lvgl/light/lvgl_light.h b/esphome/components/lvgl/light/lvgl_light.h index 7309df9763..50da7af602 100644 --- a/esphome/components/lvgl/light/lvgl_light.h +++ b/esphome/components/lvgl/light/lvgl_light.h @@ -4,8 +4,7 @@ #include "esphome/components/light/light_output.h" #include "../lvgl_esphome.h" -namespace esphome { -namespace lvgl { +namespace esphome::lvgl { class LVLight : public light::LightOutput { public: @@ -44,5 +43,4 @@ class LVLight : public light::LightOutput { optional initial_value_{}; }; -} // namespace lvgl -} // namespace esphome +} // namespace esphome::lvgl diff --git a/esphome/components/lvgl/lvgl_proxy.h b/esphome/components/lvgl/lvgl_proxy.h index 0ccd80e541..499735ad88 100644 --- a/esphome/components/lvgl/lvgl_proxy.h +++ b/esphome/components/lvgl/lvgl_proxy.h @@ -11,7 +11,5 @@ file is included in the build, LVGL is always included. #endif // LV_CONF_H #include -namespace esphome { -namespace lvgl {} // namespace lvgl -} // namespace esphome -#endif // USE_LVGL +namespace esphome::lvgl {} // namespace esphome::lvgl +#endif // USE_LVGL diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 44409a0ad5..ba16b1f0b3 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -6,8 +6,7 @@ #include "esphome/core/component.h" #include "esphome/core/preferences.h" -namespace esphome { -namespace lvgl { +namespace esphome::lvgl { class LVGLNumber : public number::Number, public Component { public: @@ -48,5 +47,4 @@ class LVGLNumber : public number::Number, public Component { ESPPreferenceObject pref_{}; }; -} // namespace lvgl -} // namespace esphome +} // namespace esphome::lvgl diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index 3b00310b67..ffbe29d701 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -8,8 +8,7 @@ #include "esphome/core/preferences.h" #include "esphome/components/lvgl/lvgl_esphome.h" -namespace esphome { -namespace lvgl { +namespace esphome::lvgl { class LVGLSelect : public select::Select, public Component { public: @@ -71,5 +70,4 @@ class LVGLSelect : public select::Select, public Component { ESPPreferenceObject pref_{}; }; -} // namespace lvgl -} // namespace esphome +} // namespace esphome::lvgl diff --git a/esphome/components/lvgl/switch/lvgl_switch.h b/esphome/components/lvgl/switch/lvgl_switch.h index 485459691c..8f5502a7d5 100644 --- a/esphome/components/lvgl/switch/lvgl_switch.h +++ b/esphome/components/lvgl/switch/lvgl_switch.h @@ -7,8 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/preferences.h" -namespace esphome { -namespace lvgl { +namespace esphome::lvgl { class LVGLSwitch : public switch_::Switch, public Component { public: @@ -21,5 +20,4 @@ class LVGLSwitch : public switch_::Switch, public Component { std::function state_lambda_{}; }; -} // namespace lvgl -} // namespace esphome +} // namespace esphome::lvgl diff --git a/esphome/components/lvgl/text/lvgl_text.h b/esphome/components/lvgl/text/lvgl_text.h index eacf69b6ec..fead48d6fe 100644 --- a/esphome/components/lvgl/text/lvgl_text.h +++ b/esphome/components/lvgl/text/lvgl_text.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace lvgl { +namespace esphome::lvgl { class LVGLText : public text::Text { public: @@ -29,5 +28,4 @@ class LVGLText : public text::Text { optional initial_state_{}; }; -} // namespace lvgl -} // namespace esphome +} // namespace esphome::lvgl diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp index 3eeba4a644..680b3bcd9e 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp @@ -1,7 +1,6 @@ #include "m5stack_8angle_binary_sensor.h" -namespace esphome { -namespace m5stack_8angle { +namespace esphome::m5stack_8angle { void M5Stack8AngleSwitchBinarySensor::update() { int8_t out = this->parent_->read_switch(); @@ -13,5 +12,4 @@ void M5Stack8AngleSwitchBinarySensor::update() { this->status_clear_warning(); } -} // namespace m5stack_8angle -} // namespace esphome +} // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h index b8bb601525..14400bcea1 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h @@ -5,8 +5,7 @@ #include "../m5stack_8angle.h" -namespace esphome { -namespace m5stack_8angle { +namespace esphome::m5stack_8angle { class M5Stack8AngleSwitchBinarySensor : public binary_sensor::BinarySensor, public PollingComponent, @@ -15,5 +14,4 @@ class M5Stack8AngleSwitchBinarySensor : public binary_sensor::BinarySensor, void update() override; }; -} // namespace m5stack_8angle -} // namespace esphome +} // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp index 0e7b902919..e132c54daa 100644 --- a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp +++ b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace m5stack_8angle { +namespace esphome::m5stack_8angle { static const char *const TAG = "m5stack_8angle.light"; @@ -41,5 +40,4 @@ light::ESPColorView M5Stack8AngleLightOutput::get_view_internal(int32_t index) c nullptr, this->effect_data_ + index, &this->correction_}; } -} // namespace m5stack_8angle -} // namespace esphome +} // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h index 204f2c04c7..0a5a50f2a8 100644 --- a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h +++ b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h @@ -5,8 +5,7 @@ #include "../m5stack_8angle.h" -namespace esphome { -namespace m5stack_8angle { +namespace esphome::m5stack_8angle { static const uint8_t M5STACK_8ANGLE_NUM_LEDS = 9; static const uint8_t M5STACK_8ANGLE_BYTES_PER_LED = 4; @@ -33,5 +32,4 @@ class M5Stack8AngleLightOutput : public light::AddressableLight, public Parented uint8_t *effect_data_{nullptr}; }; -} // namespace m5stack_8angle -} // namespace esphome +} // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/m5stack_8angle.cpp b/esphome/components/m5stack_8angle/m5stack_8angle.cpp index 2de900c21d..f466fba77e 100644 --- a/esphome/components/m5stack_8angle/m5stack_8angle.cpp +++ b/esphome/components/m5stack_8angle/m5stack_8angle.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace m5stack_8angle { +namespace esphome::m5stack_8angle { static const char *const TAG = "m5stack_8angle"; @@ -69,5 +68,4 @@ int8_t M5Stack8AngleComponent::read_switch() { } } -} // namespace m5stack_8angle -} // namespace esphome +} // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/m5stack_8angle.h b/esphome/components/m5stack_8angle/m5stack_8angle.h index 4942518054..ab2e232204 100644 --- a/esphome/components/m5stack_8angle/m5stack_8angle.h +++ b/esphome/components/m5stack_8angle/m5stack_8angle.h @@ -3,8 +3,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -namespace esphome { -namespace m5stack_8angle { +namespace esphome::m5stack_8angle { static const uint8_t M5STACK_8ANGLE_REGISTER_ANALOG_INPUT_12B = 0x00; static const uint8_t M5STACK_8ANGLE_REGISTER_ANALOG_INPUT_8B = 0x10; @@ -29,5 +28,4 @@ class M5Stack8AngleComponent : public i2c::I2CDevice, public Component { uint8_t fw_version_; }; -} // namespace m5stack_8angle -} // namespace esphome +} // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp index d22b345141..b05e1e6816 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp @@ -1,7 +1,6 @@ #include "m5stack_8angle_sensor.h" -namespace esphome { -namespace m5stack_8angle { +namespace esphome::m5stack_8angle { void M5Stack8AngleKnobSensor::update() { if (this->parent_ != nullptr) { @@ -20,5 +19,4 @@ void M5Stack8AngleKnobSensor::update() { }; } -} // namespace m5stack_8angle -} // namespace esphome +} // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h index 4848f8f80f..418503d7c8 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h @@ -5,8 +5,7 @@ #include "../m5stack_8angle.h" -namespace esphome { -namespace m5stack_8angle { +namespace esphome::m5stack_8angle { class M5Stack8AngleKnobSensor : public sensor::Sensor, public PollingComponent, @@ -23,5 +22,4 @@ class M5Stack8AngleKnobSensor : public sensor::Sensor, bool raw_; }; -} // namespace m5stack_8angle -} // namespace esphome +} // namespace esphome::m5stack_8angle diff --git a/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h b/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h index 2c1ce96f0a..53ae0b5c03 100644 --- a/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h +++ b/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h @@ -3,8 +3,7 @@ #include "esphome/components/matrix_keypad/matrix_keypad.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace matrix_keypad { +namespace esphome::matrix_keypad { class MatrixKeypadBinarySensor : public MatrixKeypadListener, public binary_sensor::BinarySensorInitiallyOff { public: @@ -47,5 +46,4 @@ class MatrixKeypadBinarySensor : public MatrixKeypadListener, public binary_sens int col_; }; -} // namespace matrix_keypad -} // namespace esphome +} // namespace esphome::matrix_keypad diff --git a/esphome/components/matrix_keypad/matrix_keypad.cpp b/esphome/components/matrix_keypad/matrix_keypad.cpp index cc46ba98d6..3b71b50fd8 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.cpp +++ b/esphome/components/matrix_keypad/matrix_keypad.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace matrix_keypad { +namespace esphome::matrix_keypad { static const char *const TAG = "matrix_keypad"; @@ -110,5 +109,4 @@ void MatrixKeypad::register_listener(MatrixKeypadListener *listener) { this->lis void MatrixKeypad::register_key_trigger(MatrixKeyTrigger *trig) { this->key_triggers_.push_back(trig); } -} // namespace matrix_keypad -} // namespace esphome +} // namespace esphome::matrix_keypad diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 8963612d0c..1e263842ea 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace matrix_keypad { +namespace esphome::matrix_keypad { class MatrixKeypadListener { public: @@ -51,5 +50,4 @@ class MatrixKeypad : public key_provider::KeyProvider, public Component { std::vector key_triggers_; }; -} // namespace matrix_keypad -} // namespace esphome +} // namespace esphome::matrix_keypad diff --git a/esphome/components/max17043/automation.h b/esphome/components/max17043/automation.h index ac201a7309..c98516d259 100644 --- a/esphome/components/max17043/automation.h +++ b/esphome/components/max17043/automation.h @@ -3,8 +3,7 @@ #include "esphome/core/automation.h" #include "max17043.h" -namespace esphome { -namespace max17043 { +namespace esphome::max17043 { template class SleepAction : public Action { public: @@ -16,5 +15,4 @@ template class SleepAction : public Action { MAX17043Component *max17043_; }; -} // namespace max17043 -} // namespace esphome +} // namespace esphome::max17043 diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index dfd59f1e7d..b59bac7ebf 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -1,8 +1,7 @@ #include "max17043.h" #include "esphome/core/log.h" -namespace esphome { -namespace max17043 { +namespace esphome::max17043 { // MAX174043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert // Consult the datasheet at https://www.analog.com/en/products/max17043.html @@ -90,5 +89,4 @@ void MAX17043Component::sleep_mode() { } } -} // namespace max17043 -} // namespace esphome +} // namespace esphome::max17043 diff --git a/esphome/components/max17043/max17043.h b/esphome/components/max17043/max17043.h index f477ce5948..dd2e35df55 100644 --- a/esphome/components/max17043/max17043.h +++ b/esphome/components/max17043/max17043.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace max17043 { +namespace esphome::max17043 { class MAX17043Component : public PollingComponent, public i2c::I2CDevice { public: @@ -24,5 +23,4 @@ class MAX17043Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *battery_remaining_sensor_{nullptr}; }; -} // namespace max17043 -} // namespace esphome +} // namespace esphome::max17043 diff --git a/esphome/components/max31855/max31855.cpp b/esphome/components/max31855/max31855.cpp index 8370977ce2..ac6b3cecb4 100644 --- a/esphome/components/max31855/max31855.cpp +++ b/esphome/components/max31855/max31855.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace max31855 { +namespace esphome::max31855 { static const char *const TAG = "max31855"; @@ -100,5 +99,4 @@ void MAX31855Sensor::read_data_() { this->status_clear_warning(); } -} // namespace max31855 -} // namespace esphome +} // namespace esphome::max31855 diff --git a/esphome/components/max31855/max31855.h b/esphome/components/max31855/max31855.h index b755d240f2..dd7a205268 100644 --- a/esphome/components/max31855/max31855.h +++ b/esphome/components/max31855/max31855.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace max31855 { +namespace esphome::max31855 { class MAX31855Sensor : public sensor::Sensor, public PollingComponent, @@ -26,5 +25,4 @@ class MAX31855Sensor : public sensor::Sensor, sensor::Sensor *temperature_reference_{nullptr}; }; -} // namespace max31855 -} // namespace esphome +} // namespace esphome::max31855 diff --git a/esphome/components/max31856/max31856.cpp b/esphome/components/max31856/max31856.cpp index 35e12309ba..4062d21bee 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace max31856 { +namespace esphome::max31856 { static const char *const TAG = "max31856"; @@ -197,5 +196,4 @@ uint32_t MAX31856Sensor::read_register24_(uint8_t reg) { return value; } -} // namespace max31856 -} // namespace esphome +} // namespace esphome::max31856 diff --git a/esphome/components/max31856/max31856.h b/esphome/components/max31856/max31856.h index a27ababa2e..0a983b72d9 100644 --- a/esphome/components/max31856/max31856.h +++ b/esphome/components/max31856/max31856.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace max31856 { +namespace esphome::max31856 { enum MAX31856RegisterMasks { SPI_WRITE_M = 0x80 }; @@ -98,5 +97,4 @@ class MAX31856Sensor : public sensor::Sensor, void set_noise_filter_(); }; -} // namespace max31856 -} // namespace esphome +} // namespace esphome::max31856 diff --git a/esphome/components/max31865/max31865.cpp b/esphome/components/max31865/max31865.cpp index 8b06a01166..220fb4e704 100644 --- a/esphome/components/max31865/max31865.cpp +++ b/esphome/components/max31865/max31865.cpp @@ -4,8 +4,7 @@ #include #include -namespace esphome { -namespace max31865 { +namespace esphome::max31865 { static const char *const TAG = "max31865"; @@ -226,5 +225,4 @@ float MAX31865Sensor::calc_temperature_(float rtd_ratio) { return neg_temp; } -} // namespace max31865 -} // namespace esphome +} // namespace esphome::max31865 diff --git a/esphome/components/max31865/max31865.h b/esphome/components/max31865/max31865.h index 440c6523a6..3362cd30de 100644 --- a/esphome/components/max31865/max31865.h +++ b/esphome/components/max31865/max31865.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace max31865 { +namespace esphome::max31865 { enum MAX31865RegisterMasks { SPI_WRITE_M = 0x80 }; enum MAX31865Registers { @@ -53,5 +52,4 @@ class MAX31865Sensor : public sensor::Sensor, float calc_temperature_(float rtd_ratio); }; -} // namespace max31865 -} // namespace esphome +} // namespace esphome::max31865 diff --git a/esphome/components/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index cbce053519..6b8bdc8de5 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace max44009 { +namespace esphome::max44009 { static const char *const TAG = "max44009.sensor"; @@ -137,5 +136,4 @@ void MAX44009Sensor::write_(uint8_t reg, uint8_t value) { void MAX44009Sensor::set_mode(MAX44009Mode mode) { this->mode_ = mode; } -} // namespace max44009 -} // namespace esphome +} // namespace esphome::max44009 diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index d0ffd7bc70..12fd0b1ce0 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -namespace esphome { -namespace max44009 { +namespace esphome::max44009 { enum MAX44009Mode { MAX44009_MODE_AUTO, MAX44009_MODE_LOW_POWER, MAX44009_MODE_CONTINUOUS }; @@ -32,5 +31,4 @@ class MAX44009Sensor : public sensor::Sensor, public PollingComponent, public i2 MAX44009Mode mode_{MAX44009_MODE_AUTO}; }; -} // namespace max44009 -} // namespace esphome +} // namespace esphome::max44009 diff --git a/esphome/components/max6675/max6675.cpp b/esphome/components/max6675/max6675.cpp index b8527c6b1d..8734405508 100644 --- a/esphome/components/max6675/max6675.cpp +++ b/esphome/components/max6675/max6675.cpp @@ -1,8 +1,7 @@ #include "max6675.h" #include "esphome/core/log.h" -namespace esphome { -namespace max6675 { +namespace esphome::max6675 { static const char *const TAG = "max6675"; @@ -43,5 +42,4 @@ void MAX6675Sensor::read_data_() { this->status_clear_warning(); } -} // namespace max6675 -} // namespace esphome +} // namespace esphome::max6675 diff --git a/esphome/components/max6675/max6675.h b/esphome/components/max6675/max6675.h index f0db4a6c26..e7b5c4dbde 100644 --- a/esphome/components/max6675/max6675.h +++ b/esphome/components/max6675/max6675.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace max6675 { +namespace esphome::max6675 { class MAX6675Sensor : public sensor::Sensor, public PollingComponent, @@ -21,5 +20,4 @@ class MAX6675Sensor : public sensor::Sensor, void read_data_(); }; -} // namespace max6675 -} // namespace esphome +} // namespace esphome::max6675 diff --git a/esphome/components/max6956/automation.h b/esphome/components/max6956/automation.h index ca2c3e3ce4..547ed5a865 100644 --- a/esphome/components/max6956/automation.h +++ b/esphome/components/max6956/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/components/max6956/max6956.h" -namespace esphome { -namespace max6956 { +namespace esphome::max6956 { template class SetCurrentGlobalAction : public Action { public: @@ -36,5 +35,4 @@ template class SetCurrentModeAction : public Action { protected: MAX6956 *max6956_; }; -} // namespace max6956 -} // namespace esphome +} // namespace esphome::max6956 diff --git a/esphome/components/max6956/max6956.cpp b/esphome/components/max6956/max6956.cpp index ce45541b63..ccb14496aa 100644 --- a/esphome/components/max6956/max6956.cpp +++ b/esphome/components/max6956/max6956.cpp @@ -1,8 +1,7 @@ #include "max6956.h" #include "esphome/core/log.h" -namespace esphome { -namespace max6956 { +namespace esphome::max6956 { static const char *const TAG = "max6956"; @@ -167,5 +166,4 @@ size_t MAX6956GPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "%u via Max6956", this->pin_); } -} // namespace max6956 -} // namespace esphome +} // namespace esphome::max6956 diff --git a/esphome/components/max6956/max6956.h b/esphome/components/max6956/max6956.h index 31f97c11f8..83ccfab559 100644 --- a/esphome/components/max6956/max6956.h +++ b/esphome/components/max6956/max6956.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace max6956 { +namespace esphome::max6956 { /// Modes for MAX6956 pins enum MAX6956GPIOMode : uint8_t { @@ -92,5 +91,4 @@ class MAX6956GPIOPin : public GPIOPin { gpio::Flags flags_; }; -} // namespace max6956 -} // namespace esphome +} // namespace esphome::max6956 diff --git a/esphome/components/max6956/output/max6956_led_output.cpp b/esphome/components/max6956/output/max6956_led_output.cpp index 5fa2dd9b34..c53a429d20 100644 --- a/esphome/components/max6956/output/max6956_led_output.cpp +++ b/esphome/components/max6956/output/max6956_led_output.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace max6956 { +namespace esphome::max6956 { static const char *const TAG = "max6956_led_channel"; @@ -22,5 +21,4 @@ void MAX6956LedChannel::dump_config() { LOG_FLOAT_OUTPUT(this); } -} // namespace max6956 -} // namespace esphome +} // namespace esphome::max6956 diff --git a/esphome/components/max6956/output/max6956_led_output.h b/esphome/components/max6956/output/max6956_led_output.h index b844a7ceee..49e5b9ef84 100644 --- a/esphome/components/max6956/output/max6956_led_output.h +++ b/esphome/components/max6956/output/max6956_led_output.h @@ -3,8 +3,7 @@ #include "esphome/components/max6956/max6956.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace max6956 { +namespace esphome::max6956 { class MAX6956; @@ -24,5 +23,4 @@ class MAX6956LedChannel : public output::FloatOutput, public Component { uint8_t pin_; }; -} // namespace max6956 -} // namespace esphome +} // namespace esphome::max6956 diff --git a/esphome/components/max7219digit/automation.h b/esphome/components/max7219digit/automation.h index be8245d14d..485a34075e 100644 --- a/esphome/components/max7219digit/automation.h +++ b/esphome/components/max7219digit/automation.h @@ -5,8 +5,7 @@ #include "max7219digit.h" -namespace esphome { -namespace max7219digit { +namespace esphome::max7219digit { template class DisplayInvertAction : public Action, public Parented { public: @@ -48,5 +47,4 @@ template class DisplayIntensityAction : public Action, pu } }; -} // namespace max7219digit -} // namespace esphome +} // namespace esphome::max7219digit diff --git a/esphome/components/max7219digit/max7219digit.cpp b/esphome/components/max7219digit/max7219digit.cpp index f9b46cf797..26c65aa5d3 100644 --- a/esphome/components/max7219digit/max7219digit.cpp +++ b/esphome/components/max7219digit/max7219digit.cpp @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace max7219digit { +namespace esphome::max7219digit { static const char *const TAG = "max7219DIGIT"; @@ -352,5 +351,4 @@ uint8_t MAX7219Component::strftimedigit(const char *format, ESPTime time) { return this->strftimedigit(0, format, time); } -} // namespace max7219digit -} // namespace esphome +} // namespace esphome::max7219digit diff --git a/esphome/components/max7219digit/max7219digit.h b/esphome/components/max7219digit/max7219digit.h index af419b9b38..bbf43059dd 100644 --- a/esphome/components/max7219digit/max7219digit.h +++ b/esphome/components/max7219digit/max7219digit.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace max7219digit { +namespace esphome::max7219digit { enum ChipLinesStyle { ZIGZAG = 0, @@ -120,5 +119,4 @@ class MAX7219Component : public display::DisplayBuffer, max7219_writer_t writer_local_{}; }; -} // namespace max7219digit -} // namespace esphome +} // namespace esphome::max7219digit diff --git a/esphome/components/max7219digit/max7219font.h b/esphome/components/max7219digit/max7219font.h index 53674dc60f..a5eea7e20f 100644 --- a/esphome/components/max7219digit/max7219font.h +++ b/esphome/components/max7219digit/max7219font.h @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" -namespace esphome { -namespace max7219digit { +namespace esphome::max7219digit { // bit patterns for the CP437 font @@ -266,5 +265,4 @@ constexpr uint8_t MAX7219_DOT_MATRIX_FONT[256][8] PROGMEM = { {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0xFF }; // end of MAX7219_Dot_Matrix_font -} // namespace max7219digit -} // namespace esphome +} // namespace esphome::max7219digit diff --git a/esphome/components/max9611/max9611.cpp b/esphome/components/max9611/max9611.cpp index f00f9d76be..68ef7c3135 100644 --- a/esphome/components/max9611/max9611.cpp +++ b/esphome/components/max9611/max9611.cpp @@ -1,8 +1,8 @@ #include "max9611.h" #include "esphome/core/log.h" #include "esphome/components/i2c/i2c_bus.h" -namespace esphome { -namespace max9611 { + +namespace esphome::max9611 { using namespace esphome::i2c; // Sign extend // http://graphics.stanford.edu/~seander/bithacks.html#FixedSignExtend @@ -91,5 +91,4 @@ void MAX9611Component::update() { ESP_LOGD(TAG, "V: %f, A: %f, W: %f, Deg C: %f", voltage, amps, watts, temp); } -} // namespace max9611 -} // namespace esphome +} // namespace esphome::max9611 diff --git a/esphome/components/max9611/max9611.h b/esphome/components/max9611/max9611.h index 1eb7542aee..b6fb5d8127 100644 --- a/esphome/components/max9611/max9611.h +++ b/esphome/components/max9611/max9611.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/hal.h" -namespace esphome { -namespace max9611 { +namespace esphome::max9611 { enum MAX9611Multiplexer { MAX9611_MULTIPLEXER_CSA_GAIN1 = 0b000, @@ -57,5 +56,4 @@ class MAX9611Component : public PollingComponent, public i2c::I2CDevice { MAX9611Multiplexer gain_; }; -} // namespace max9611 -} // namespace esphome +} // namespace esphome::max9611 diff --git a/esphome/components/mcp23008/mcp23008.cpp b/esphome/components/mcp23008/mcp23008.cpp index 5f73e03f6f..6be5f4c951 100644 --- a/esphome/components/mcp23008/mcp23008.cpp +++ b/esphome/components/mcp23008/mcp23008.cpp @@ -1,8 +1,7 @@ #include "mcp23008.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp23008 { +namespace esphome::mcp23008 { static const char *const TAG = "mcp23008"; @@ -45,5 +44,4 @@ bool MCP23008::write_reg(uint8_t reg, uint8_t value) { return this->write_byte(reg, value); } -} // namespace mcp23008 -} // namespace esphome +} // namespace esphome::mcp23008 diff --git a/esphome/components/mcp23008/mcp23008.h b/esphome/components/mcp23008/mcp23008.h index 406ce0b419..ae2f9e1f3c 100644 --- a/esphome/components/mcp23008/mcp23008.h +++ b/esphome/components/mcp23008/mcp23008.h @@ -5,8 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mcp23008 { +namespace esphome::mcp23008 { class MCP23008 : public mcp23x08_base::MCP23X08Base, public i2c::I2CDevice { public: @@ -20,5 +19,4 @@ class MCP23008 : public mcp23x08_base::MCP23X08Base, public i2c::I2CDevice { bool write_reg(uint8_t reg, uint8_t value) override; }; -} // namespace mcp23008 -} // namespace esphome +} // namespace esphome::mcp23008 diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index b7a9cfd0ce..126ece3e7b 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace mcp23016 { +namespace esphome::mcp23016 { static const char *const TAG = "mcp23016"; @@ -101,5 +100,4 @@ size_t MCP23016GPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "%u via MCP23016", this->pin_); } -} // namespace mcp23016 -} // namespace esphome +} // namespace esphome::mcp23016 diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 32149ba3e2..4a936a5b02 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" -namespace esphome { -namespace mcp23016 { +namespace esphome::mcp23016 { enum MCP23016GPIORegisters { // 0 side @@ -79,5 +78,4 @@ class MCP23016GPIOPin : public GPIOPin { gpio::Flags flags_; }; -} // namespace mcp23016 -} // namespace esphome +} // namespace esphome::mcp23016 diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index 212c15ccf2..9e3d75575a 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -1,8 +1,7 @@ #include "mcp23017.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp23017 { +namespace esphome::mcp23017 { static const char *const TAG = "mcp23017"; @@ -54,5 +53,4 @@ bool MCP23017::write_reg(uint8_t reg, uint8_t value) { return this->write_byte(reg, value); } -} // namespace mcp23017 -} // namespace esphome +} // namespace esphome::mcp23017 diff --git a/esphome/components/mcp23017/mcp23017.h b/esphome/components/mcp23017/mcp23017.h index 8959e06a41..86b84f9ad8 100644 --- a/esphome/components/mcp23017/mcp23017.h +++ b/esphome/components/mcp23017/mcp23017.h @@ -5,8 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mcp23017 { +namespace esphome::mcp23017 { class MCP23017 : public mcp23x17_base::MCP23X17Base, public i2c::I2CDevice { public: @@ -20,5 +19,4 @@ class MCP23017 : public mcp23x17_base::MCP23X17Base, public i2c::I2CDevice { bool write_reg(uint8_t reg, uint8_t value) override; }; -} // namespace mcp23017 -} // namespace esphome +} // namespace esphome::mcp23017 diff --git a/esphome/components/mcp23s08/mcp23s08.cpp b/esphome/components/mcp23s08/mcp23s08.cpp index 983c1aa600..e7f582f787 100644 --- a/esphome/components/mcp23s08/mcp23s08.cpp +++ b/esphome/components/mcp23s08/mcp23s08.cpp @@ -1,8 +1,7 @@ #include "mcp23s08.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp23s08 { +namespace esphome::mcp23s08 { static const char *const TAG = "mcp23s08"; @@ -62,5 +61,4 @@ bool MCP23S08::write_reg(uint8_t reg, uint8_t value) { return true; } -} // namespace mcp23s08 -} // namespace esphome +} // namespace esphome::mcp23s08 diff --git a/esphome/components/mcp23s08/mcp23s08.h b/esphome/components/mcp23s08/mcp23s08.h index a2a6be880a..441525469f 100644 --- a/esphome/components/mcp23s08/mcp23s08.h +++ b/esphome/components/mcp23s08/mcp23s08.h @@ -5,8 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace mcp23s08 { +namespace esphome::mcp23s08 { class MCP23S08 : public mcp23x08_base::MCP23X08Base, public spi::SPIDevice { uint8_t input_mask_{0x00}; }; -} // namespace mcp23x08_base -} // namespace esphome +} // namespace esphome::mcp23x08_base diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index efed7f5f17..870c6c165f 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp23x17_base { +namespace esphome::mcp23x17_base { static const char *const TAG = "mcp23x17_base"; @@ -108,5 +107,4 @@ void MCP23X17Base::update_reg(uint8_t pin, bool pin_value, uint8_t reg_addr) { } } -} // namespace mcp23x17_base -} // namespace esphome +} // namespace esphome::mcp23x17_base diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.h b/esphome/components/mcp23x17_base/mcp23x17_base.h index bdd66503e2..bddfff132d 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.h +++ b/esphome/components/mcp23x17_base/mcp23x17_base.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace mcp23x17_base { +namespace esphome::mcp23x17_base { enum MCP23X17GPIORegisters { // A side @@ -53,5 +52,4 @@ class MCP23X17Base : public mcp23xxx_base::MCP23XXXBase<16> { uint16_t input_mask_{0x00}; }; -} // namespace mcp23x17_base -} // namespace esphome +} // namespace esphome::mcp23x17_base diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index 4c1daac562..b8032fcec8 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp23xxx_base { +namespace esphome::mcp23xxx_base { template void MCP23XXXGPIOPin::setup() { this->pin_mode(flags_); @@ -28,5 +27,4 @@ template size_t MCP23XXXGPIOPin::dump_summary(char *buffer, size_t template class MCP23XXXGPIOPin<8>; template class MCP23XXXGPIOPin<16>; -} // namespace mcp23xxx_base -} // namespace esphome +} // namespace esphome::mcp23xxx_base diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 8a87dac143..5904a1eef6 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace mcp23xxx_base { +namespace esphome::mcp23xxx_base { enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANGE, MCP23XXX_RISING, MCP23XXX_FALLING }; @@ -81,5 +80,4 @@ template class MCP23XXXGPIOPin : public GPIOPin { MCP23XXXInterruptMode interrupt_mode_; }; -} // namespace mcp23xxx_base -} // namespace esphome +} // namespace esphome::mcp23xxx_base diff --git a/esphome/components/mcp2515/mcp2515.cpp b/esphome/components/mcp2515/mcp2515.cpp index c2db9228c8..f8c5e9f068 100644 --- a/esphome/components/mcp2515/mcp2515.cpp +++ b/esphome/components/mcp2515/mcp2515.cpp @@ -1,8 +1,7 @@ #include "mcp2515.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp2515 { +namespace esphome::mcp2515 { static const char *const TAG = "mcp2515"; @@ -707,5 +706,4 @@ canbus::Error MCP2515::set_bitrate_(canbus::CanSpeed can_speed, CanClock can_clo return canbus::ERROR_FAIL; } } -} // namespace mcp2515 -} // namespace esphome +} // namespace esphome::mcp2515 diff --git a/esphome/components/mcp2515/mcp2515.h b/esphome/components/mcp2515/mcp2515.h index c77480ce7d..b77d9a2582 100644 --- a/esphome/components/mcp2515/mcp2515.h +++ b/esphome/components/mcp2515/mcp2515.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "mcp2515_defs.h" -namespace esphome { -namespace mcp2515 { +namespace esphome::mcp2515 { static const uint32_t SPI_CLOCK = 10000000; // 10MHz static const int N_TXBUFFERS = 3; @@ -108,5 +107,4 @@ class MCP2515 : public canbus::Canbus, void clear_merr_(); void clear_errif_(); }; -} // namespace mcp2515 -} // namespace esphome +} // namespace esphome::mcp2515 diff --git a/esphome/components/mcp2515/mcp2515_defs.h b/esphome/components/mcp2515/mcp2515_defs.h index b33adcbba6..e2a7b97bd6 100644 --- a/esphome/components/mcp2515/mcp2515_defs.h +++ b/esphome/components/mcp2515/mcp2515_defs.h @@ -1,7 +1,6 @@ #pragma once -namespace esphome { -namespace mcp2515 { +namespace esphome::mcp2515 { static const uint8_t CANCTRL_REQOP = 0xE0; static const uint8_t CANCTRL_ABAT = 0x10; @@ -371,5 +370,4 @@ static const uint8_t MCP_20MHZ_33K3BPS_CFG1 = 0x0B; static const uint8_t MCP_20MHZ_33K3BPS_CFG2 = 0xFF; static const uint8_t MCP_20MHZ_33K3BPS_CFG3 = 0x87; -} // namespace mcp2515 -} // namespace esphome +} // namespace esphome::mcp2515 diff --git a/esphome/components/mcp3008/mcp3008.cpp b/esphome/components/mcp3008/mcp3008.cpp index 812a3b0c83..e65e249f52 100644 --- a/esphome/components/mcp3008/mcp3008.cpp +++ b/esphome/components/mcp3008/mcp3008.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp3008 { +namespace esphome::mcp3008 { static const char *const TAG = "mcp3008"; @@ -36,5 +35,4 @@ float MCP3008::read_data(uint8_t pin) { return data / 1023.0f; } -} // namespace mcp3008 -} // namespace esphome +} // namespace esphome::mcp3008 diff --git a/esphome/components/mcp3008/mcp3008.h b/esphome/components/mcp3008/mcp3008.h index baf8d7c152..1b1b50c793 100644 --- a/esphome/components/mcp3008/mcp3008.h +++ b/esphome/components/mcp3008/mcp3008.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace mcp3008 { +namespace esphome::mcp3008 { class MCP3008 : public Component, public spi::SPIDevicepublish_state(this->sample()); } -} // namespace mcp3008 -} // namespace esphome +} // namespace esphome::mcp3008 diff --git a/esphome/components/mcp3008/sensor/mcp3008_sensor.h b/esphome/components/mcp3008/sensor/mcp3008_sensor.h index 9478d38e74..9267f80ea8 100644 --- a/esphome/components/mcp3008/sensor/mcp3008_sensor.h +++ b/esphome/components/mcp3008/sensor/mcp3008_sensor.h @@ -6,8 +6,7 @@ #include "../mcp3008.h" -namespace esphome { -namespace mcp3008 { +namespace esphome::mcp3008 { class MCP3008Sensor : public PollingComponent, public sensor::Sensor, @@ -26,5 +25,4 @@ class MCP3008Sensor : public PollingComponent, float reference_voltage_; }; -} // namespace mcp3008 -} // namespace esphome +} // namespace esphome::mcp3008 diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index abefcad0eb..5351d6a2cb 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -1,8 +1,7 @@ #include "mcp3204.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp3204 { +namespace esphome::mcp3204 { static const char *const TAG = "mcp3204"; @@ -35,5 +34,4 @@ float MCP3204::read_data(uint8_t pin, bool differential) { return float(digital_value) / 4096.000 * this->reference_voltage_; // in V } -} // namespace mcp3204 -} // namespace esphome +} // namespace esphome::mcp3204 diff --git a/esphome/components/mcp3204/mcp3204.h b/esphome/components/mcp3204/mcp3204.h index 6287263a2a..8ce592f386 100644 --- a/esphome/components/mcp3204/mcp3204.h +++ b/esphome/components/mcp3204/mcp3204.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace mcp3204 { +namespace esphome::mcp3204 { class MCP3204 : public Component, public spi::SPIDeviceparent_->read_data(this->pin_, this->differential_mode_); } void MCP3204Sensor::update() { this->publish_state(this->sample()); } -} // namespace mcp3204 -} // namespace esphome +} // namespace esphome::mcp3204 diff --git a/esphome/components/mcp3204/sensor/mcp3204_sensor.h b/esphome/components/mcp3204/sensor/mcp3204_sensor.h index 2bf75a9c1e..5fe5f54d1b 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.h +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.h @@ -7,8 +7,7 @@ #include "../mcp3204.h" -namespace esphome { -namespace mcp3204 { +namespace esphome::mcp3204 { class MCP3204Sensor : public PollingComponent, public Parented, @@ -26,5 +25,4 @@ class MCP3204Sensor : public PollingComponent, bool differential_mode_; }; -} // namespace mcp3204 -} // namespace esphome +} // namespace esphome::mcp3204 diff --git a/esphome/components/mcp3221/mcp3221_sensor.cpp b/esphome/components/mcp3221/mcp3221_sensor.cpp index c04b1c0b93..1b794ba966 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.cpp +++ b/esphome/components/mcp3221/mcp3221_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp3221 { +namespace esphome::mcp3221 { static const char *const TAG = "mcp3221"; @@ -27,5 +26,4 @@ void MCP3221Sensor::update() { this->publish_state(v); } -} // namespace mcp3221 -} // namespace esphome +} // namespace esphome::mcp3221 diff --git a/esphome/components/mcp3221/mcp3221_sensor.h b/esphome/components/mcp3221/mcp3221_sensor.h index c83caccabf..deef14e14d 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.h +++ b/esphome/components/mcp3221/mcp3221_sensor.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace mcp3221 { +namespace esphome::mcp3221 { class MCP3221Sensor : public sensor::Sensor, public PollingComponent, @@ -24,5 +23,4 @@ class MCP3221Sensor : public sensor::Sensor, float reference_voltage_; }; -} // namespace mcp3221 -} // namespace esphome +} // namespace esphome::mcp3221 diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 48d90377df..4573553664 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace mcp4461 { +namespace esphome::mcp4461 { static const char *const TAG = "mcp4461"; constexpr uint8_t EEPROM_WRITE_TIMEOUT_MS = 10; @@ -628,5 +627,4 @@ bool Mcp4461Component::mcp4461_write_(uint8_t addr, uint16_t data, bool nonvolat } return this->write_byte(reg, value_byte); } -} // namespace mcp4461 -} // namespace esphome +} // namespace esphome::mcp4461 diff --git a/esphome/components/mcp4461/mcp4461.h b/esphome/components/mcp4461/mcp4461.h index 59f6358a56..3a76f855b8 100644 --- a/esphome/components/mcp4461/mcp4461.h +++ b/esphome/components/mcp4461/mcp4461.h @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mcp4461 { +namespace esphome::mcp4461 { struct WiperState { bool enabled = true; @@ -168,5 +167,4 @@ class Mcp4461Component : public Component, public i2c::I2CDevice { bool wiper_2_disabled_{false}; bool wiper_3_disabled_{false}; }; -} // namespace mcp4461 -} // namespace esphome +} // namespace esphome::mcp4461 diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 2d85a5df61..6912ad5f36 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp4461 { +namespace esphome::mcp4461 { static const char *const TAG = "mcp4461.output"; @@ -69,5 +68,4 @@ void Mcp4461Wiper::enable_terminal(char terminal) { this->parent_->enable_termin void Mcp4461Wiper::disable_terminal(char terminal) { this->parent_->disable_terminal_(this->wiper_, terminal); } -} // namespace mcp4461 -} // namespace esphome +} // namespace esphome::mcp4461 diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 4055cef30a..73eadceb50 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mcp4461 { +namespace esphome::mcp4461 { class Mcp4461Wiper : public output::FloatOutput, public Parented { public: @@ -45,5 +44,4 @@ class Mcp4461Wiper : public output::FloatOutput, public Parentedwrite_byte_16(64, value << 4); } -} // namespace mcp4725 -} // namespace esphome +} // namespace esphome::mcp4725 diff --git a/esphome/components/mcp4725/mcp4725.h b/esphome/components/mcp4725/mcp4725.h index d6fa52e323..1acefc3ee4 100644 --- a/esphome/components/mcp4725/mcp4725.h +++ b/esphome/components/mcp4725/mcp4725.h @@ -7,8 +7,7 @@ static const uint8_t MCP4725_ADDR = 0x60; static const uint8_t MCP4725_RES = 12; -namespace esphome { -namespace mcp4725 { +namespace esphome::mcp4725 { class MCP4725 : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void setup() override; @@ -19,5 +18,4 @@ class MCP4725 : public Component, public output::FloatOutput, public i2c::I2CDev enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; -} // namespace mcp4725 -} // namespace esphome +} // namespace esphome::mcp4725 diff --git a/esphome/components/mcp4728/mcp4728.cpp b/esphome/components/mcp4728/mcp4728.cpp index bab94cb233..1b15ca0510 100644 --- a/esphome/components/mcp4728/mcp4728.cpp +++ b/esphome/components/mcp4728/mcp4728.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp4728 { +namespace esphome::mcp4728 { static const char *const TAG = "mcp4728"; @@ -109,5 +108,4 @@ void MCP4728Component::select_gain_(MCP4728ChannelIdx channel, MCP4728Gain gain) this->update_ = true; } -} // namespace mcp4728 -} // namespace esphome +} // namespace esphome::mcp4728 diff --git a/esphome/components/mcp4728/mcp4728.h b/esphome/components/mcp4728/mcp4728.h index d657408081..13076b3c4c 100644 --- a/esphome/components/mcp4728/mcp4728.h +++ b/esphome/components/mcp4728/mcp4728.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mcp4728 { +namespace esphome::mcp4728 { enum class CMD { FAST_WRITE = 0x00, @@ -63,5 +62,4 @@ class MCP4728Component : public Component, public i2c::I2CDevice { bool update_ = false; }; -} // namespace mcp4728 -} // namespace esphome +} // namespace esphome::mcp4728 diff --git a/esphome/components/mcp4728/output/mcp4728_output.cpp b/esphome/components/mcp4728/output/mcp4728_output.cpp index b587e8801b..7cd5d9d252 100644 --- a/esphome/components/mcp4728/output/mcp4728_output.cpp +++ b/esphome/components/mcp4728/output/mcp4728_output.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp4728 { +namespace esphome::mcp4728 { void MCP4728Channel::write_state(float state) { const uint16_t max_duty = 4095; @@ -13,5 +12,4 @@ void MCP4728Channel::write_state(float state) { this->parent_->set_channel_value_(this->channel_, duty); } -} // namespace mcp4728 -} // namespace esphome +} // namespace esphome::mcp4728 diff --git a/esphome/components/mcp4728/output/mcp4728_output.h b/esphome/components/mcp4728/output/mcp4728_output.h index 453d632f4c..3ea65ecc7b 100644 --- a/esphome/components/mcp4728/output/mcp4728_output.h +++ b/esphome/components/mcp4728/output/mcp4728_output.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mcp4728 { +namespace esphome::mcp4728 { class MCP4728Channel : public output::FloatOutput { public: @@ -28,5 +27,4 @@ class MCP4728Channel : public output::FloatOutput { MCP4728ChannelIdx channel_; }; -} // namespace mcp4728 -} // namespace esphome +} // namespace esphome::mcp4728 diff --git a/esphome/components/mcp47a1/mcp47a1.cpp b/esphome/components/mcp47a1/mcp47a1.cpp index 58f3b2ac72..e2b0fa5575 100644 --- a/esphome/components/mcp47a1/mcp47a1.cpp +++ b/esphome/components/mcp47a1/mcp47a1.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp47a1 { +namespace esphome::mcp47a1 { static const char *const TAG = "mcp47a1"; @@ -17,5 +16,4 @@ void MCP47A1::write_state(float state) { this->write_byte(0, value); } -} // namespace mcp47a1 -} // namespace esphome +} // namespace esphome::mcp47a1 diff --git a/esphome/components/mcp47a1/mcp47a1.h b/esphome/components/mcp47a1/mcp47a1.h index 5c02e062ad..da9794e5aa 100644 --- a/esphome/components/mcp47a1/mcp47a1.h +++ b/esphome/components/mcp47a1/mcp47a1.h @@ -4,8 +4,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/core/component.h" -namespace esphome { -namespace mcp47a1 { +namespace esphome::mcp47a1 { class MCP47A1 : public Component, public output::FloatOutput, public i2c::I2CDevice { public: @@ -13,5 +12,4 @@ class MCP47A1 : public Component, public output::FloatOutput, public i2c::I2CDev void write_state(float state) override; }; -} // namespace mcp47a1 -} // namespace esphome +} // namespace esphome::mcp47a1 diff --git a/esphome/components/mcp9600/mcp9600.cpp b/esphome/components/mcp9600/mcp9600.cpp index 0c5362b4ba..0e9b472be2 100644 --- a/esphome/components/mcp9600/mcp9600.cpp +++ b/esphome/components/mcp9600/mcp9600.cpp @@ -1,8 +1,7 @@ #include "mcp9600.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp9600 { +namespace esphome::mcp9600 { static const char *const TAG = "mcp9600"; @@ -109,5 +108,4 @@ void MCP9600Component::update() { this->status_clear_warning(); } -} // namespace mcp9600 -} // namespace esphome +} // namespace esphome::mcp9600 diff --git a/esphome/components/mcp9600/mcp9600.h b/esphome/components/mcp9600/mcp9600.h index c414653ea6..b7c0c834ab 100644 --- a/esphome/components/mcp9600/mcp9600.h +++ b/esphome/components/mcp9600/mcp9600.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mcp9600 { +namespace esphome::mcp9600 { enum MCP9600ThermocoupleType : uint8_t { MCP9600_THERMOCOUPLE_TYPE_K = 0b000, @@ -45,5 +44,4 @@ class MCP9600Component : public PollingComponent, public i2c::I2CDevice { } error_code_{NONE}; }; -} // namespace mcp9600 -} // namespace esphome +} // namespace esphome::mcp9600 diff --git a/esphome/components/mcp9808/mcp9808.cpp b/esphome/components/mcp9808/mcp9808.cpp index ed12e52239..10e26ed709 100644 --- a/esphome/components/mcp9808/mcp9808.cpp +++ b/esphome/components/mcp9808/mcp9808.cpp @@ -1,8 +1,7 @@ #include "mcp9808.h" #include "esphome/core/log.h" -namespace esphome { -namespace mcp9808 { +namespace esphome::mcp9808 { static const uint8_t MCP9808_REG_AMBIENT_TEMP = 0x05; static const uint8_t MCP9808_REG_MANUF_ID = 0x06; @@ -74,5 +73,4 @@ void MCP9808Sensor::update() { this->status_clear_warning(); } -} // namespace mcp9808 -} // namespace esphome +} // namespace esphome::mcp9808 diff --git a/esphome/components/mcp9808/mcp9808.h b/esphome/components/mcp9808/mcp9808.h index 894e4599d0..89530d9ed0 100644 --- a/esphome/components/mcp9808/mcp9808.h +++ b/esphome/components/mcp9808/mcp9808.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mcp9808 { +namespace esphome::mcp9808 { class MCP9808Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: @@ -15,5 +14,4 @@ class MCP9808Sensor : public sensor::Sensor, public PollingComponent, public i2c void update() override; }; -} // namespace mcp9808 -} // namespace esphome +} // namespace esphome::mcp9808 diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 26554e4d3c..10c81aac39 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -3,8 +3,7 @@ #ifdef USE_MD5 #include "esphome/core/helpers.h" -namespace esphome { -namespace md5 { +namespace esphome::md5 { #if defined(USE_ARDUINO) && !defined(USE_RP2040) && !defined(USE_ESP32) void MD5Digest::init() { @@ -77,6 +76,6 @@ void MD5Digest::calculate() { MD5Digest::~MD5Digest() = default; #endif // USE_HOST -} // namespace md5 -} // namespace esphome +} // namespace esphome::md5 + #endif diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index 80e74d188e..5e841edd83 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -29,8 +29,7 @@ #define MD5_CTX_TYPE LT_MD5_CTX_T #endif -namespace esphome { -namespace md5 { +namespace esphome::md5 { class MD5Digest final : public HashBase { public: @@ -59,6 +58,6 @@ class MD5Digest final : public HashBase { #endif }; -} // namespace md5 -} // namespace esphome +} // namespace esphome::md5 + #endif diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 14ce3c6aed..9319335872 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -3,9 +3,7 @@ #include "esphome/core/automation.h" #include "media_player.h" -namespace esphome { - -namespace media_player { +namespace esphome::media_player { template class MediaPlayerCommandAction : public Action, public Parented { @@ -136,5 +134,4 @@ template class IsMutedCondition : public Condition, publi bool check(const Ts &...x) override { return this->parent_->is_muted(); } }; -} // namespace media_player -} // namespace esphome +} // namespace esphome::media_player diff --git a/esphome/components/media_player/media_player.cpp b/esphome/components/media_player/media_player.cpp index 48d23fa0b1..7dce74117a 100644 --- a/esphome/components/media_player/media_player.cpp +++ b/esphome/components/media_player/media_player.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/core/progmem.h" -namespace esphome { -namespace media_player { +namespace esphome::media_player { static const char *const TAG = "media_player"; @@ -205,5 +204,4 @@ void MediaPlayer::publish_state() { #endif } -} // namespace media_player -} // namespace esphome +} // namespace esphome::media_player diff --git a/esphome/components/media_player/media_player.h b/esphome/components/media_player/media_player.h index d5d0020797..73de603692 100644 --- a/esphome/components/media_player/media_player.h +++ b/esphome/components/media_player/media_player.h @@ -3,8 +3,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace media_player { +namespace esphome::media_player { enum MediaPlayerEntityFeature : uint32_t { PAUSE = 1 << 0, @@ -171,5 +170,4 @@ class MediaPlayer : public EntityBase { LazyCallbackManager state_callback_{}; }; -} // namespace media_player -} // namespace esphome +} // namespace esphome::media_player diff --git a/esphome/components/micro_wake_word/automation.h b/esphome/components/micro_wake_word/automation.h index 218ce9e4bc..e3b35583fb 100644 --- a/esphome/components/micro_wake_word/automation.h +++ b/esphome/components/micro_wake_word/automation.h @@ -4,8 +4,8 @@ #include "streaming_model.h" #ifdef USE_ESP32 -namespace esphome { -namespace micro_wake_word { + +namespace esphome::micro_wake_word { template class StartAction : public Action, public Parented { public: @@ -49,6 +49,6 @@ template class ModelIsEnabledCondition : public Condition WakeWordModel *wake_word_model_; }; -} // namespace micro_wake_word -} // namespace esphome +} // namespace esphome::micro_wake_word + #endif diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index f1aac875f1..1568fc6373 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -13,8 +13,7 @@ #include "esphome/components/ota/ota_backend.h" #endif -namespace esphome { -namespace micro_wake_word { +namespace esphome::micro_wake_word { static const char *const TAG = "micro_wake_word"; @@ -468,7 +467,6 @@ bool MicroWakeWord::update_model_probabilities_(const int8_t audio_features[PREP return success; } -} // namespace micro_wake_word -} // namespace esphome +} // namespace esphome::micro_wake_word #endif // USE_ESP32 diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 44d5d89372..79a1226fba 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -21,8 +21,7 @@ #include #include -namespace esphome { -namespace micro_wake_word { +namespace esphome::micro_wake_word { enum State { STARTING, @@ -137,7 +136,6 @@ class MicroWakeWord : public Component bool update_model_probabilities_(const int8_t audio_features[PREPROCESSOR_FEATURE_SIZE]); }; -} // namespace micro_wake_word -} // namespace esphome +} // namespace esphome::micro_wake_word #endif // USE_ESP32 diff --git a/esphome/components/micro_wake_word/preprocessor_settings.h b/esphome/components/micro_wake_word/preprocessor_settings.h index c9d195b49b..fa0bfffbeb 100644 --- a/esphome/components/micro_wake_word/preprocessor_settings.h +++ b/esphome/components/micro_wake_word/preprocessor_settings.h @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace micro_wake_word { +namespace esphome::micro_wake_word { // Settings for controlling the spectrogram feature generation by the preprocessor. // These must match the settings used when training a particular model. @@ -31,7 +30,6 @@ static const uint8_t PCAN_GAIN_CONTROL_GAIN_BITS = 21; static const bool LOG_SCALE_ENABLE_LOG = true; static const uint8_t LOG_SCALE_SCALE_SHIFT = 6; -} // namespace micro_wake_word -} // namespace esphome +} // namespace esphome::micro_wake_word #endif diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index e761e4866f..1cdc06b352 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -7,8 +7,7 @@ static const char *const TAG = "micro_wake_word"; -namespace esphome { -namespace micro_wake_word { +namespace esphome::micro_wake_word { void WakeWordModel::log_model_config() { ESP_LOGCONFIG(TAG, @@ -387,7 +386,6 @@ bool StreamingModel::register_streaming_ops_(tflite::MicroMutableOpResolver<20> return true; } -} // namespace micro_wake_word -} // namespace esphome +} // namespace esphome::micro_wake_word #endif diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index fc9eeb5e2d..07ba78d1f4 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -10,8 +10,7 @@ #include #include -namespace esphome { -namespace micro_wake_word { +namespace esphome::micro_wake_word { static const uint8_t MIN_SLICES_BEFORE_DETECTION = 100; static const uint32_t STREAMING_MODEL_VARIABLE_ARENA_SIZE = 1024; @@ -155,7 +154,6 @@ class VADModel final : public StreamingModel { DetectionEvent determine_detected() override; }; -} // namespace micro_wake_word -} // namespace esphome +} // namespace esphome::micro_wake_word #endif diff --git a/esphome/components/microphone/automation.h b/esphome/components/microphone/automation.h index a6c4bdae66..1dfd91f903 100644 --- a/esphome/components/microphone/automation.h +++ b/esphome/components/microphone/automation.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace microphone { +namespace esphome::microphone { template class CaptureAction : public Action, public Parented { void play(const Ts &...x) override { this->parent_->start(); } @@ -40,5 +39,4 @@ template class IsMutedCondition : public Condition, publi bool check(const Ts &...x) override { return this->parent_->get_mute_state(); } }; -} // namespace microphone -} // namespace esphome +} // namespace esphome::microphone diff --git a/esphome/components/microphone/microphone.h b/esphome/components/microphone/microphone.h index 50ce1a7281..d89b9a8362 100644 --- a/esphome/components/microphone/microphone.h +++ b/esphome/components/microphone/microphone.h @@ -7,8 +7,7 @@ #include #include "esphome/core/helpers.h" -namespace esphome { -namespace microphone { +namespace esphome::microphone { enum State : uint8_t { STATE_STOPPED = 0, @@ -48,5 +47,4 @@ class Microphone { CallbackManager &)> data_callbacks_{}; }; -} // namespace microphone -} // namespace esphome +} // namespace esphome::microphone diff --git a/esphome/components/microphone/microphone_source.cpp b/esphome/components/microphone/microphone_source.cpp index fb4ebc4a04..288e9f8c8e 100644 --- a/esphome/components/microphone/microphone_source.cpp +++ b/esphome/components/microphone/microphone_source.cpp @@ -1,7 +1,6 @@ #include "microphone_source.h" -namespace esphome { -namespace microphone { +namespace esphome::microphone { static const int32_t Q25_MAX_VALUE = (1 << 25) - 1; static const int32_t Q25_MIN_VALUE = ~Q25_MAX_VALUE; @@ -73,5 +72,4 @@ void MicrophoneSource::process_audio_(const std::vector &data, std::vec } } -} // namespace microphone -} // namespace esphome +} // namespace esphome::microphone diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index 5c8053e502..c3c675e854 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -9,8 +9,7 @@ #include #include -namespace esphome { -namespace microphone { +namespace esphome::microphone { static const int32_t MAX_GAIN_FACTOR = 64; @@ -89,5 +88,4 @@ class MicrophoneSource { bool passive_; // Only pass audio if ``mic_`` is already running }; -} // namespace microphone -} // namespace esphome +} // namespace esphome::microphone diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index ce63a7d062..d99d4fd772 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace mics_4514 { +namespace esphome::mics_4514 { static const char *const TAG = "mics_4514"; @@ -131,5 +130,4 @@ void MICS4514Component::update() { } } -} // namespace mics_4514 -} // namespace esphome +} // namespace esphome::mics_4514 diff --git a/esphome/components/mics_4514/mics_4514.h b/esphome/components/mics_4514/mics_4514.h index e7271314c8..4f8b970f06 100644 --- a/esphome/components/mics_4514/mics_4514.h +++ b/esphome/components/mics_4514/mics_4514.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace mics_4514 { +namespace esphome::mics_4514 { class MICS4514Component : public PollingComponent, public i2c::I2CDevice { SUB_SENSOR(carbon_monoxide) @@ -29,5 +28,4 @@ class MICS4514Component : public PollingComponent, public i2c::I2CDevice { float red_calibration_{0}; }; -} // namespace mics_4514 -} // namespace esphome +} // namespace esphome::mics_4514 diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 8b20a562c8..ec9dc10297 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -3,9 +3,7 @@ #include "esphome/core/log.h" #include "ac_adapter.h" -namespace esphome { -namespace midea { -namespace ac { +namespace esphome::midea::ac { const char *const Constants::TAG = "midea"; const char *const Constants::FREEZE_PROTECTION = "freeze protection"; @@ -172,8 +170,6 @@ void Converters::to_climate_traits(ClimateTraits &traits, const dudanov::midea:: // since custom presets are stored on the Climate base class } -} // namespace ac -} // namespace midea -} // namespace esphome +} // namespace esphome::midea::ac #endif // USE_ARDUINO diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index b0589a37f9..a7924ae51e 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -8,9 +8,7 @@ #include "esphome/components/climate/climate_traits.h" #include "air_conditioner.h" -namespace esphome { -namespace midea { -namespace ac { +namespace esphome::midea::ac { using MideaMode = dudanov::midea::ac::Mode; using MideaSwingMode = dudanov::midea::ac::SwingMode; @@ -44,8 +42,6 @@ class Converters { static void to_climate_traits(ClimateTraits &traits, const dudanov::midea::ac::Capabilities &capabilities); }; -} // namespace ac -} // namespace midea -} // namespace esphome +} // namespace esphome::midea::ac #endif // USE_ARDUINO diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h index 760737be87..acd9191916 100644 --- a/esphome/components/midea/ac_automations.h +++ b/esphome/components/midea/ac_automations.h @@ -5,9 +5,7 @@ #include "esphome/core/automation.h" #include "air_conditioner.h" -namespace esphome { -namespace midea { -namespace ac { +namespace esphome::midea::ac { template class MideaActionBase : public Action { public: @@ -63,8 +61,6 @@ template class PowerToggleAction : public MideaActionBase void play(const Ts &...x) override { this->parent_->do_power_toggle(); } }; -} // namespace ac -} // namespace midea -} // namespace esphome +} // namespace esphome::midea::ac #endif // USE_ARDUINO diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 69e0d46d2d..594f7fa661 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -7,9 +7,7 @@ #include #include -namespace esphome { -namespace midea { -namespace ac { +namespace esphome::midea::ac { static void set_sensor(Sensor *sensor, float value) { if (sensor != nullptr && (!sensor->has_state() || sensor->get_raw_state() != value)) @@ -197,8 +195,6 @@ void AirConditioner::do_display_toggle() { } } -} // namespace ac -} // namespace midea -} // namespace esphome +} // namespace esphome::midea::ac #endif // USE_ARDUINO diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 8dbc71b422..6ed5a82ff5 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -8,9 +8,7 @@ #include "appliance_base.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace midea { -namespace ac { +namespace esphome::midea::ac { using sensor::Sensor; using climate::ClimateCall; @@ -61,8 +59,6 @@ class AirConditioner : public ApplianceBase, Sensor *power_sensor_{nullptr}; }; -} // namespace ac -} // namespace midea -} // namespace esphome +} // namespace esphome::midea::ac #endif // USE_ARDUINO diff --git a/esphome/components/midea/appliance_base.h b/esphome/components/midea/appliance_base.h index c7737ba7d6..d36f5a322c 100644 --- a/esphome/components/midea/appliance_base.h +++ b/esphome/components/midea/appliance_base.h @@ -15,8 +15,7 @@ #include "esphome/components/climate/climate.h" #include "ir_transmitter.h" -namespace esphome { -namespace midea { +namespace esphome::midea { /* Stream from UART component */ class UARTStream : public Stream { @@ -98,7 +97,6 @@ template class ApplianceBase : public Component { #endif }; -} // namespace midea -} // namespace esphome +} // namespace esphome::midea #endif // USE_ARDUINO diff --git a/esphome/components/midea_ir/midea_data.h b/esphome/components/midea_ir/midea_data.h index 0f7e24907d..1fe65b958f 100644 --- a/esphome/components/midea_ir/midea_data.h +++ b/esphome/components/midea_ir/midea_data.h @@ -3,8 +3,7 @@ #include "esphome/components/remote_base/midea_protocol.h" #include "esphome/components/climate/climate_mode.h" -namespace esphome { -namespace midea_ir { +namespace esphome::midea_ir { using climate::ClimateMode; using climate::ClimateFanMode; @@ -88,5 +87,4 @@ class SpecialData : public MideaData { static const uint8_t TURBO_TOGGLE = 9; }; -} // namespace midea_ir -} // namespace esphome +} // namespace esphome::midea_ir diff --git a/esphome/components/midea_ir/midea_ir.cpp b/esphome/components/midea_ir/midea_ir.cpp index 220bb3f414..de25c4652c 100644 --- a/esphome/components/midea_ir/midea_ir.cpp +++ b/esphome/components/midea_ir/midea_ir.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/components/coolix/coolix.h" -namespace esphome { -namespace midea_ir { +namespace esphome::midea_ir { static const char *const TAG = "midea_ir.climate"; @@ -204,5 +203,4 @@ bool MideaIR::on_midea_(const MideaData &data) { return false; } -} // namespace midea_ir -} // namespace esphome +} // namespace esphome::midea_ir diff --git a/esphome/components/midea_ir/midea_ir.h b/esphome/components/midea_ir/midea_ir.h index b89b2a7efc..dd883172d4 100644 --- a/esphome/components/midea_ir/midea_ir.h +++ b/esphome/components/midea_ir/midea_ir.h @@ -3,8 +3,7 @@ #include "esphome/components/climate_ir/climate_ir.h" #include "midea_data.h" -namespace esphome { -namespace midea_ir { +namespace esphome::midea_ir { // Temperature const uint8_t MIDEA_TEMPC_MIN = 17; // Celsius @@ -43,5 +42,4 @@ class MideaIR : public climate_ir::ClimateIR { bool boost_{false}; }; -} // namespace midea_ir -} // namespace esphome +} // namespace esphome::midea_ir diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index f292345893..5023cf8089 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -7,8 +7,7 @@ #include "esphome/components/display/display_color_utils.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace mipi_spi { +namespace esphome::mipi_spi { constexpr static const char *const TAG = "display.mipi_spi"; @@ -672,5 +671,4 @@ class MipiSpiBuffer : public MipiSpi -namespace esphome { -namespace mitsubishi { +namespace esphome::mitsubishi { // Temperature const uint8_t MITSUBISHI_TEMP_MIN = 16; // Celsius @@ -79,5 +78,4 @@ class MitsubishiClimate : public climate_ir::ClimateIR { climate::ClimateTraits traits() override; }; -} // namespace mitsubishi -} // namespace esphome +} // namespace esphome::mitsubishi diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h index 4fa3853583..cdfda0c700 100644 --- a/esphome/components/mixer/speaker/automation.h +++ b/esphome/components/mixer/speaker/automation.h @@ -5,8 +5,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace mixer_speaker { +namespace esphome::mixer_speaker { template class DuckingApplyAction : public Action, public Parented { TEMPLATABLE_VALUE(uint8_t, decibel_reduction); TEMPLATABLE_VALUE(uint32_t, duration); @@ -14,7 +13,6 @@ template class DuckingApplyAction : public Action, public this->parent_->apply_ducking(this->decibel_reduction_.value(x...), this->duration_.value(x...)); } }; -} // namespace mixer_speaker -} // namespace esphome +} // namespace esphome::mixer_speaker #endif diff --git a/esphome/components/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index 01084e50df..7048302124 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -1,8 +1,7 @@ #include "sensor_mlx90393.h" #include "esphome/core/log.h" -namespace esphome { -namespace mlx90393 { +namespace esphome::mlx90393 { static const char *const TAG = "mlx90393"; @@ -270,5 +269,4 @@ void MLX90393Cls::verify_settings_timeout_(MLX90393Setting stage) { this->set_timeout("verify settings", 3000, [this, next_stage]() { this->verify_settings_timeout_(next_stage); }); } -} // namespace mlx90393 -} // namespace esphome +} // namespace esphome::mlx90393 diff --git a/esphome/components/mlx90393/sensor_mlx90393.h b/esphome/components/mlx90393/sensor_mlx90393.h index 845ae87e09..28053216e2 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.h +++ b/esphome/components/mlx90393/sensor_mlx90393.h @@ -7,8 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace mlx90393 { +namespace esphome::mlx90393 { enum MLX90393Setting { MLX90393_GAIN_SEL = 0, @@ -76,5 +75,4 @@ class MLX90393Cls : public PollingComponent, public i2c::I2CDevice, public MLX90 void verify_settings_timeout_(MLX90393Setting stage); }; -} // namespace mlx90393 -} // namespace esphome +} // namespace esphome::mlx90393 diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 8a514cbc26..2d3b6631bc 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace mlx90614 { +namespace esphome::mlx90614 { static const uint8_t MLX90614_RAW_IR_1 = 0x04; static const uint8_t MLX90614_RAW_IR_2 = 0x05; @@ -101,5 +100,4 @@ void MLX90614Component::update() { this->status_clear_warning(); } -} // namespace mlx90614 -} // namespace esphome +} // namespace esphome::mlx90614 diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index bf081c3e90..12081f20ac 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -namespace esphome { -namespace mlx90614 { +namespace esphome::mlx90614 { class MLX90614Component : public PollingComponent, public i2c::I2CDevice { public: @@ -28,5 +27,4 @@ class MLX90614Component : public PollingComponent, public i2c::I2CDevice { float emissivity_{NAN}; }; -} // namespace mlx90614 -} // namespace esphome +} // namespace esphome::mlx90614 diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index 51b94eb767..79c580c6b7 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -1,8 +1,7 @@ #include "mmc5603.h" #include "esphome/core/log.h" -namespace esphome { -namespace mmc5603 { +namespace esphome::mmc5603 { static const char *const TAG = "mmc5603"; static const uint8_t MMC5603_ADDRESS = 0x30; @@ -157,5 +156,4 @@ void MMC5603Component::update() { this->heading_sensor_->publish_state(heading); } -} // namespace mmc5603 -} // namespace esphome +} // namespace esphome::mmc5603 diff --git a/esphome/components/mmc5603/mmc5603.h b/esphome/components/mmc5603/mmc5603.h index 9a77b78bc1..0d8eb152a7 100644 --- a/esphome/components/mmc5603/mmc5603.h +++ b/esphome/components/mmc5603/mmc5603.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mmc5603 { +namespace esphome::mmc5603 { enum MMC5603Datarate { MMC5603_DATARATE_75_0_HZ, @@ -40,5 +39,4 @@ class MMC5603Component : public PollingComponent, public i2c::I2CDevice { } error_code_{NONE}; }; -} // namespace mmc5603 -} // namespace esphome +} // namespace esphome::mmc5603 diff --git a/esphome/components/mmc5983/mmc5983.cpp b/esphome/components/mmc5983/mmc5983.cpp index b038084a72..a99df36a70 100644 --- a/esphome/components/mmc5983/mmc5983.cpp +++ b/esphome/components/mmc5983/mmc5983.cpp @@ -4,8 +4,7 @@ #include "mmc5983.h" #include "esphome/core/log.h" -namespace esphome { -namespace mmc5983 { +namespace esphome::mmc5983 { static const char *const TAG = "mmc5983"; @@ -133,5 +132,4 @@ void MMC5983Component::dump_config() { LOG_SENSOR(" ", "Z", this->z_sensor_); } -} // namespace mmc5983 -} // namespace esphome +} // namespace esphome::mmc5983 diff --git a/esphome/components/mmc5983/mmc5983.h b/esphome/components/mmc5983/mmc5983.h index 3e87e54daa..020d3b2e4c 100644 --- a/esphome/components/mmc5983/mmc5983.h +++ b/esphome/components/mmc5983/mmc5983.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mmc5983 { +namespace esphome::mmc5983 { class MMC5983Component : public PollingComponent, public i2c::I2CDevice { public: @@ -23,5 +22,4 @@ class MMC5983Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *z_sensor_{nullptr}; }; -} // namespace mmc5983 -} // namespace esphome +} // namespace esphome::mmc5983 diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 3b1a038be3..679ec34c0f 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace modbus { +namespace esphome::modbus { static const char *const TAG = "modbus"; @@ -425,5 +424,4 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn) { } } -} // namespace modbus -} // namespace esphome +} // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index c90d4c78ae..26f64401be 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -10,8 +10,7 @@ #include #include -namespace esphome { -namespace modbus { +namespace esphome::modbus { static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; @@ -122,5 +121,4 @@ class ModbusDevice { uint8_t address_; }; -} // namespace modbus -} // namespace esphome +} // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index c86d548578..fb8c011259 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -2,8 +2,7 @@ #include "esphome/core/component.h" -namespace esphome { -namespace modbus { +namespace esphome::modbus { /// Modbus definitions from specs: /// https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf @@ -84,5 +83,4 @@ const uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D static constexpr uint16_t MAX_FRAME_SIZE = 256; /// End of Modbus definitions -} // namespace modbus -} // namespace esphome +} // namespace esphome::modbus diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index 1ea3041b4d..60c19bb66a 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -1,8 +1,7 @@ #include "modbus_binarysensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller.binary_sensor"; @@ -34,5 +33,4 @@ void ModbusBinarySensor::parse_and_publish(const std::vector &data) { this->publish_state(value); } -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 119f4fdd5a..98c6840e15 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { class ModbusBinarySensor : public Component, public binary_sensor::BinarySensor, public SensorItem { public: @@ -40,5 +39,4 @@ class ModbusBinarySensor : public Component, public binary_sensor::BinarySensor, optional transform_func_{nullopt}; }; -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index dabed7136b..6604276cc2 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -2,8 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller"; @@ -539,5 +538,4 @@ bool ModbusCommandItem::is_equal(const ModbusCommandItem &other) { other.register_type == this->register_type && other.function_code == this->function_code; } -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 40139f055b..ba86c2cd16 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -12,8 +12,7 @@ #include #include -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { class ModbusController; @@ -391,5 +390,4 @@ inline float payload_to_float(const std::vector &data, const SensorItem return float_value; } -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index ed5d91ec5b..2c81dd6830 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { static const char *const TAG = "modbus.number"; @@ -90,5 +89,4 @@ void ModbusNumber::control(float value) { } void ModbusNumber::dump_config() { LOG_NUMBER(TAG, "Modbus Number", this); } -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index 169f85ff36..dd8f418bfc 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { using value_to_data_t = std::function(float); @@ -46,5 +45,4 @@ class ModbusNumber : public number::Number, public Component, public SensorItem bool use_write_multiple_{false}; }; -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index e7f1a39716..504e09a093 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller.output"; @@ -118,5 +117,4 @@ void ModbusBinaryOutput::dump_config() { this->start_address, this->register_count, static_cast(this->sensor_value_type)); } -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index 3f3cadfe2f..c5323e3bf3 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { class ModbusFloatOutput : public output::FloatOutput, public Component, public SensorItem { public: @@ -72,5 +71,4 @@ class ModbusBinaryOutput : public output::BinaryOutput, public Component, public bool use_write_multiple_{false}; }; -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index 2cff7e89ee..859828f5f6 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -1,8 +1,7 @@ #include "modbus_select.h" #include "esphome/core/log.h" -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller.select"; @@ -86,5 +85,4 @@ void ModbusSelect::control(size_t index) { this->publish_state(index); } -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index fde441f2bc..a736abd0db 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -7,8 +7,7 @@ #include "esphome/components/select/select.h" #include "esphome/core/component.h" -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { class ModbusSelect : public Component, public select::Select, public SensorItem { public: @@ -49,5 +48,4 @@ class ModbusSelect : public Component, public select::Select, public SensorItem optional write_transform_func_{nullopt}; }; -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index a21fd91032..559724057a 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -2,8 +2,7 @@ #include "modbus_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller.sensor"; @@ -27,5 +26,4 @@ void ModbusSensor::parse_and_publish(const std::vector &data) { this->publish_state(result); } -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index ba943c873c..2e6967b07c 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { class ModbusSensor : public Component, public sensor::Sensor, public SensorItem { public: @@ -33,5 +32,4 @@ class ModbusSensor : public Component, public sensor::Sensor, public SensorItem optional transform_func_{nullopt}; }; -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index dbaff04cc6..044ca2f8cc 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -2,8 +2,8 @@ #include "modbus_switch.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace modbus_controller { + +namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller.switch"; @@ -112,5 +112,4 @@ void ModbusSwitch::write_state(bool state) { this->publish_state(state); } // ModbusSwitch end -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 301c2bf548..541a23706d 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { class ModbusSwitch : public Component, public switch_::Switch, public SensorItem { public: @@ -49,5 +48,4 @@ class ModbusSwitch : public Component, public switch_::Switch, public SensorItem bool assumed_state_{false}; }; -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp index b26411b72e..5626515638 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp @@ -2,8 +2,7 @@ #include "modbus_textsensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller.text_sensor"; @@ -56,5 +55,4 @@ void ModbusTextSensor::parse_and_publish(const std::vector &data) { this->publish_state(output_str); } -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index 6666aea976..a99fea5860 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace modbus_controller { +namespace esphome::modbus_controller { enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 }; @@ -39,5 +38,4 @@ class ModbusTextSensor : public Component, public text_sensor::TextSensor, publi RawEncoding encode_; }; -} // namespace modbus_controller -} // namespace esphome +} // namespace esphome::modbus_controller diff --git a/esphome/components/monochromatic/monochromatic_light_output.h b/esphome/components/monochromatic/monochromatic_light_output.h index f1708ae70b..458140ef09 100644 --- a/esphome/components/monochromatic/monochromatic_light_output.h +++ b/esphome/components/monochromatic/monochromatic_light_output.h @@ -4,8 +4,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/light/light_output.h" -namespace esphome { -namespace monochromatic { +namespace esphome::monochromatic { class MonochromaticLightOutput : public light::LightOutput { public: @@ -25,5 +24,4 @@ class MonochromaticLightOutput : public light::LightOutput { output::FloatOutput *output_; }; -} // namespace monochromatic -} // namespace esphome +} // namespace esphome::monochromatic diff --git a/esphome/components/mopeka_ble/mopeka_ble.cpp b/esphome/components/mopeka_ble/mopeka_ble.cpp index b926beaff2..ff5dd8d61b 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.cpp +++ b/esphome/components/mopeka_ble/mopeka_ble.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace mopeka_ble { +namespace esphome::mopeka_ble { static const char *const TAG = "mopeka_ble"; @@ -86,7 +85,6 @@ bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) return false; } -} // namespace mopeka_ble -} // namespace esphome +} // namespace esphome::mopeka_ble #endif diff --git a/esphome/components/mopeka_ble/mopeka_ble.h b/esphome/components/mopeka_ble/mopeka_ble.h index b7d0c5a9c5..cc91ef17d6 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.h +++ b/esphome/components/mopeka_ble/mopeka_ble.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace mopeka_ble { +namespace esphome::mopeka_ble { class MopekaListener : public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -21,7 +20,6 @@ class MopekaListener : public esp32_ble_tracker::ESPBTDeviceListener { bool show_sensors_without_sync_; }; -} // namespace mopeka_ble -} // namespace esphome +} // 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 9bc9900a5a..ab0ff9a113 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace mopeka_pro_check { +namespace esphome::mopeka_pro_check { static const char *const TAG = "mopeka_pro_check"; static const uint8_t MANUFACTURER_DATA_LENGTH = 10; @@ -154,7 +153,6 @@ SensorReadQuality MopekaProCheck::parse_read_quality_(const std::vector return static_cast(message[4] >> 6); } -} // namespace mopeka_pro_check -} // namespace esphome +} // 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 41fb312152..bfdfe80c48 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -9,8 +9,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace mopeka_pro_check { +namespace esphome::mopeka_pro_check { enum SensorType { STANDARD_BOTTOM_UP = 0x03, @@ -65,7 +64,6 @@ class MopekaProCheck : public Component, public esp32_ble_tracker::ESPBTDeviceLi SensorReadQuality parse_read_quality_(const std::vector &message); }; -} // namespace mopeka_pro_check -} // namespace esphome +} // namespace esphome::mopeka_pro_check #endif diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index a4a31b8260..519a45fcb5 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -5,8 +5,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace mopeka_std_check { +namespace esphome::mopeka_std_check { static const char *const TAG = "mopeka_std_check"; static const uint16_t SERVICE_UUID = 0xADA0; @@ -232,7 +231,6 @@ int8_t MopekaStdCheck::parse_temperature_(const mopeka_std_package *message) { } } -} // namespace mopeka_std_check -} // namespace esphome +} // 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 c0a02f27f2..a38abeabf0 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.h +++ b/esphome/components/mopeka_std_check/mopeka_std_check.h @@ -9,8 +9,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace mopeka_std_check { +namespace esphome::mopeka_std_check { enum SensorType { STANDARD = 0x02, @@ -74,7 +73,6 @@ class MopekaStdCheck : public Component, public esp32_ble_tracker::ESPBTDeviceLi int8_t parse_temperature_(const mopeka_std_package *message); }; -} // namespace mopeka_std_check -} // namespace esphome +} // namespace esphome::mopeka_std_check #endif diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index a689149c89..d7994327b1 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace mpl3115a2 { +namespace esphome::mpl3115a2 { static const char *const TAG = "mpl3115a2"; @@ -94,5 +93,4 @@ void MPL3115A2Component::update() { this->status_clear_warning(); } -} // namespace mpl3115a2 -} // namespace esphome +} // namespace esphome::mpl3115a2 diff --git a/esphome/components/mpl3115a2/mpl3115a2.h b/esphome/components/mpl3115a2/mpl3115a2.h index 05da71f830..d78c9d571c 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.h +++ b/esphome/components/mpl3115a2/mpl3115a2.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mpl3115a2 { +namespace esphome::mpl3115a2 { // enums from https://github.com/adafruit/Adafruit_MPL3115A2_Library/ /** MPL3115A2 registers **/ @@ -102,5 +101,4 @@ class MPL3115A2Component : public PollingComponent, public i2c::I2CDevice { } error_code_{NONE}; }; -} // namespace mpl3115a2 -} // namespace esphome +} // namespace esphome::mpl3115a2 diff --git a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.cpp b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.cpp index dce0e73b9a..4f500f4e05 100644 --- a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.cpp +++ b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.cpp @@ -1,7 +1,6 @@ #include "mpr121_binary_sensor.h" -namespace esphome { -namespace mpr121 { +namespace esphome::mpr121 { void MPR121BinarySensor::setup() { uint8_t touch_threshold = this->touch_threshold_.value_or(this->parent_->get_touch_threshold()); @@ -16,5 +15,4 @@ void MPR121BinarySensor::process(uint16_t data) { this->publish_state(new_state); } -} // namespace mpr121 -} // namespace esphome +} // namespace esphome::mpr121 diff --git a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h index 577ba82893..5fa10bf598 100644 --- a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h +++ b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h @@ -4,8 +4,7 @@ #include "../mpr121.h" -namespace esphome { -namespace mpr121 { +namespace esphome::mpr121 { class MPR121BinarySensor : public binary_sensor::BinarySensor, public MPR121Channel, public Parented { public: @@ -22,5 +21,4 @@ class MPR121BinarySensor : public binary_sensor::BinarySensor, public MPR121Chan optional release_threshold_{}; }; -} // namespace mpr121 -} // namespace esphome +} // namespace esphome::mpr121 diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index cd9c81fe03..6b183233d1 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -5,8 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace mpr121 { +namespace esphome::mpr121 { static const char *const TAG = "mpr121"; @@ -157,5 +156,4 @@ size_t MPR121GPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "ELE%u on MPR121", this->pin_); } -} // namespace mpr121 -} // namespace esphome +} // namespace esphome::mpr121 diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 085018fff0..54b5c8abf4 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace mpr121 { +namespace esphome::mpr121 { enum { MPR121_TOUCHSTATUS_L = 0x00, @@ -125,5 +124,4 @@ class MPR121GPIOPin : public GPIOPin { gpio::Flags flags_; }; -} // namespace mpr121 -} // namespace esphome +} // namespace esphome::mpr121 diff --git a/esphome/components/mpu6050/mpu6050.cpp b/esphome/components/mpu6050/mpu6050.cpp index 91a84d061a..8784e8caf8 100644 --- a/esphome/components/mpu6050/mpu6050.cpp +++ b/esphome/components/mpu6050/mpu6050.cpp @@ -1,8 +1,7 @@ #include "mpu6050.h" #include "esphome/core/log.h" -namespace esphome { -namespace mpu6050 { +namespace esphome::mpu6050 { static const char *const TAG = "mpu6050"; @@ -141,5 +140,4 @@ void MPU6050Component::update() { this->status_clear_warning(); } -} // namespace mpu6050 -} // namespace esphome +} // namespace esphome::mpu6050 diff --git a/esphome/components/mpu6050/mpu6050.h b/esphome/components/mpu6050/mpu6050.h index cc7c3620df..bac07cb4a5 100644 --- a/esphome/components/mpu6050/mpu6050.h +++ b/esphome/components/mpu6050/mpu6050.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mpu6050 { +namespace esphome::mpu6050 { class MPU6050Component : public PollingComponent, public i2c::I2CDevice { public: @@ -31,7 +30,5 @@ class MPU6050Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *gyro_y_sensor_{nullptr}; sensor::Sensor *gyro_z_sensor_{nullptr}; }; -; -} // namespace mpu6050 -} // namespace esphome +} // namespace esphome::mpu6050 diff --git a/esphome/components/mpu6886/mpu6886.cpp b/esphome/components/mpu6886/mpu6886.cpp index 02747da306..b8cbd4635a 100644 --- a/esphome/components/mpu6886/mpu6886.cpp +++ b/esphome/components/mpu6886/mpu6886.cpp @@ -1,8 +1,7 @@ #include "mpu6886.h" #include "esphome/core/log.h" -namespace esphome { -namespace mpu6886 { +namespace esphome::mpu6886 { static const char *const TAG = "mpu6886"; @@ -146,5 +145,4 @@ void MPU6886Component::update() { this->status_clear_warning(); } -} // namespace mpu6886 -} // namespace esphome +} // namespace esphome::mpu6886 diff --git a/esphome/components/mpu6886/mpu6886.h b/esphome/components/mpu6886/mpu6886.h index 96e2bf61a1..a23858a7b7 100644 --- a/esphome/components/mpu6886/mpu6886.h +++ b/esphome/components/mpu6886/mpu6886.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace mpu6886 { +namespace esphome::mpu6886 { class MPU6886Component : public PollingComponent, public i2c::I2CDevice { public: @@ -31,7 +30,5 @@ class MPU6886Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *gyro_y_sensor_{nullptr}; sensor::Sensor *gyro_z_sensor_{nullptr}; }; -; -} // namespace mpu6886 -} // namespace esphome +} // namespace esphome::mpu6886 diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp index 273de10376..40b5b46e1d 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace mqtt_subscribe { +namespace esphome::mqtt_subscribe { static const char *const TAG = "mqtt_subscribe.sensor"; @@ -32,7 +31,6 @@ void MQTTSubscribeSensor::dump_config() { ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); } -} // namespace mqtt_subscribe -} // namespace esphome +} // namespace esphome::mqtt_subscribe #endif // USE_MQTT diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h index 0619326ac9..229c0586ab 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h @@ -8,8 +8,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/mqtt/mqtt_client.h" -namespace esphome { -namespace mqtt_subscribe { +namespace esphome::mqtt_subscribe { class MQTTSubscribeSensor : public sensor::Sensor, public Component { public: @@ -27,7 +26,6 @@ class MQTTSubscribeSensor : public sensor::Sensor, public Component { uint8_t qos_{0}; }; -} // namespace mqtt_subscribe -} // namespace esphome +} // namespace esphome::mqtt_subscribe #endif // USE_MQTT diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp index 8aa094a2d4..edc197671e 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace mqtt_subscribe { +namespace esphome::mqtt_subscribe { static const char *const TAG = "mqtt_subscribe.text_sensor"; @@ -22,7 +21,6 @@ void MQTTSubscribeTextSensor::dump_config() { ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); } -} // namespace mqtt_subscribe -} // namespace esphome +} // namespace esphome::mqtt_subscribe #endif // USE_MQTT diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h index 9f8e5c63cc..f218bf2a8a 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h @@ -8,8 +8,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/mqtt/mqtt_client.h" -namespace esphome { -namespace mqtt_subscribe { +namespace esphome::mqtt_subscribe { class MQTTSubscribeTextSensor : public text_sensor::TextSensor, public Component { public: @@ -26,7 +25,6 @@ class MQTTSubscribeTextSensor : public text_sensor::TextSensor, public Component uint8_t qos_{}; }; -} // namespace mqtt_subscribe -} // namespace esphome +} // namespace esphome::mqtt_subscribe #endif // USE_MQTT diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index d47ca245b8..9b7dcbe653 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace ms5611 { +namespace esphome::ms5611 { static const char *const TAG = "ms5611"; @@ -123,5 +122,4 @@ void MS5611Component::calculate_values_(uint32_t raw_temperature, uint32_t raw_p this->status_clear_warning(); } -} // namespace ms5611 -} // namespace esphome +} // namespace esphome::ms5611 diff --git a/esphome/components/ms5611/ms5611.h b/esphome/components/ms5611/ms5611.h index 7e4806f319..c6ad5b231a 100644 --- a/esphome/components/ms5611/ms5611.h +++ b/esphome/components/ms5611/ms5611.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ms5611 { +namespace esphome::ms5611 { class MS5611Component : public PollingComponent, public i2c::I2CDevice { public: @@ -26,5 +25,4 @@ class MS5611Component : public PollingComponent, public i2c::I2CDevice { uint16_t prom_[6]; }; -} // namespace ms5611 -} // namespace esphome +} // namespace esphome::ms5611 diff --git a/esphome/components/ms8607/ms8607.cpp b/esphome/components/ms8607/ms8607.cpp index d141dcb191..f733a8349d 100644 --- a/esphome/components/ms8607/ms8607.cpp +++ b/esphome/components/ms8607/ms8607.cpp @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace ms8607 { +namespace esphome::ms8607 { /// TAG used for logging calls static const char *const TAG = "ms8607"; @@ -438,5 +437,4 @@ void MS8607Component::calculate_values_(uint32_t d2_raw_temperature, uint32_t d1 } } -} // namespace ms8607 -} // namespace esphome +} // namespace esphome::ms8607 diff --git a/esphome/components/ms8607/ms8607.h b/esphome/components/ms8607/ms8607.h index 2888b6cdd2..8f9cc9cb88 100644 --- a/esphome/components/ms8607/ms8607.h +++ b/esphome/components/ms8607/ms8607.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ms8607 { +namespace esphome::ms8607 { /** Class for I2CDevice used to communicate with the Humidity sensor @@ -108,5 +107,4 @@ class MS8607Component : public PollingComponent, public i2c::I2CDevice { uint8_t reset_attempts_remaining_{0}; }; -} // namespace ms8607 -} // namespace esphome +} // namespace esphome::ms8607 diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index 6d6b21e6af..f23fcfc8ea 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace msa3xx { +namespace esphome::msa3xx { static const char *const TAG = "msa3xx"; @@ -410,5 +409,4 @@ void MSA3xxComponent::process_motions_(RegMotionInterrupt old) { } } -} // namespace msa3xx -} // namespace esphome +} // namespace esphome::msa3xx diff --git a/esphome/components/msa3xx/msa3xx.h b/esphome/components/msa3xx/msa3xx.h index 439d3b5f4d..345afc50ab 100644 --- a/esphome/components/msa3xx/msa3xx.h +++ b/esphome/components/msa3xx/msa3xx.h @@ -14,8 +14,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #endif -namespace esphome { -namespace msa3xx { +namespace esphome::msa3xx { // Combined register map of MSA301 and MSA311 // Differences @@ -305,5 +304,4 @@ class MSA3xxComponent : public PollingComponent, public i2c::I2CDevice { void process_motions_(RegMotionInterrupt old); }; -} // namespace msa3xx -} // namespace esphome +} // namespace esphome::msa3xx diff --git a/esphome/components/my9231/my9231.cpp b/esphome/components/my9231/my9231.cpp index 25f7e6925d..0072f7196e 100644 --- a/esphome/components/my9231/my9231.cpp +++ b/esphome/components/my9231/my9231.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace my9231 { +namespace esphome::my9231 { static const char *const TAG = "my9231.output"; @@ -123,5 +122,4 @@ void MY9231OutputComponent::send_dcki_pulses_(uint8_t count) { } } -} // namespace my9231 -} // namespace esphome +} // namespace esphome::my9231 diff --git a/esphome/components/my9231/my9231.h b/esphome/components/my9231/my9231.h index dff68d247c..60b113079e 100644 --- a/esphome/components/my9231/my9231.h +++ b/esphome/components/my9231/my9231.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include -namespace esphome { -namespace my9231 { +namespace esphome::my9231 { /// MY9231 float output component. class MY9231OutputComponent : public Component { @@ -60,5 +59,4 @@ class MY9231OutputComponent : public Component { bool update_{true}; }; -} // namespace my9231 -} // namespace esphome +} // namespace esphome::my9231 From ab1d2de78e407fad54077606d48d4be86c8b1d6c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 7 May 2026 19:28:30 -0400 Subject: [PATCH 1117/2214] [clang-tidy] Concatenate nested namespaces (4/7: components n-r) (#16301) --- esphome/components/nau7802/nau7802.cpp | 6 ++---- esphome/components/nau7802/nau7802.h | 6 ++---- esphome/components/nfc/automation.cpp | 6 ++---- esphome/components/nfc/automation.h | 6 ++---- esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp | 6 ++---- esphome/components/nfc/binary_sensor/nfc_binary_sensor.h | 6 ++---- esphome/components/nfc/nci_core.h | 6 ++---- esphome/components/nfc/nci_message.cpp | 6 ++---- esphome/components/nfc/nci_message.h | 6 ++---- esphome/components/nfc/ndef_message.cpp | 6 ++---- esphome/components/nfc/ndef_message.h | 6 ++---- esphome/components/nfc/ndef_record.cpp | 6 ++---- esphome/components/nfc/ndef_record.h | 6 ++---- esphome/components/nfc/ndef_record_text.cpp | 6 ++---- esphome/components/nfc/ndef_record_text.h | 6 ++---- esphome/components/nfc/ndef_record_uri.cpp | 6 ++---- esphome/components/nfc/ndef_record_uri.h | 6 ++---- esphome/components/nfc/nfc.cpp | 6 ++---- esphome/components/nfc/nfc.h | 6 ++---- esphome/components/nfc/nfc_helpers.cpp | 6 ++---- esphome/components/nfc/nfc_helpers.h | 6 ++---- esphome/components/nfc/nfc_tag.cpp | 6 ++---- esphome/components/nfc/nfc_tag.h | 6 ++---- esphome/components/noblex/noblex.cpp | 6 ++---- esphome/components/noblex/noblex.h | 6 ++---- esphome/components/npi19/npi19.cpp | 6 ++---- esphome/components/npi19/npi19.h | 6 ++---- esphome/components/ntc/ntc.cpp | 6 ++---- esphome/components/ntc/ntc.h | 6 ++---- esphome/components/one_wire/one_wire.cpp | 6 ++---- esphome/components/one_wire/one_wire.h | 6 ++---- esphome/components/one_wire/one_wire_bus.cpp | 6 ++---- esphome/components/one_wire/one_wire_bus.h | 6 ++---- esphome/components/opentherm/automation.h | 6 ++---- esphome/components/opentherm/hub.cpp | 6 ++---- esphome/components/opentherm/hub.h | 6 ++---- esphome/components/opentherm/input.h | 6 ++---- esphome/components/opentherm/number/opentherm_number.cpp | 6 ++---- esphome/components/opentherm/number/opentherm_number.h | 6 ++---- esphome/components/opentherm/opentherm.cpp | 6 ++---- esphome/components/opentherm/opentherm.h | 6 ++---- esphome/components/opentherm/opentherm_macros.h | 7 +++---- esphome/components/opentherm/output/opentherm_output.cpp | 6 ++---- esphome/components/opentherm/output/opentherm_output.h | 6 ++---- esphome/components/opentherm/switch/opentherm_switch.cpp | 6 ++---- esphome/components/opentherm/switch/opentherm_switch.h | 6 ++---- esphome/components/opt3001/opt3001.cpp | 6 ++---- esphome/components/opt3001/opt3001.h | 6 ++---- esphome/components/output/automation.cpp | 6 ++---- esphome/components/output/automation.h | 6 ++---- esphome/components/output/binary_output.h | 6 ++---- esphome/components/output/button/output_button.cpp | 6 ++---- esphome/components/output/button/output_button.h | 6 ++---- esphome/components/output/float_output.cpp | 6 ++---- esphome/components/output/float_output.h | 6 ++---- esphome/components/output/lock/output_lock.cpp | 6 ++---- esphome/components/output/lock/output_lock.h | 6 ++---- esphome/components/output/switch/output_switch.cpp | 6 ++---- esphome/components/output/switch/output_switch.h | 6 ++---- esphome/components/packet_transport/packet_transport.cpp | 6 ++---- esphome/components/packet_transport/packet_transport.h | 6 ++---- esphome/components/partition/light_partition.cpp | 6 ++---- esphome/components/partition/light_partition.h | 6 ++---- esphome/components/pca6416a/pca6416a.cpp | 6 ++---- esphome/components/pca6416a/pca6416a.h | 6 ++---- esphome/components/pca9554/pca9554.cpp | 6 ++---- esphome/components/pca9554/pca9554.h | 6 ++---- esphome/components/pca9685/pca9685_output.cpp | 6 ++---- esphome/components/pca9685/pca9685_output.h | 6 ++---- esphome/components/pcd8544/pcd_8544.cpp | 6 ++---- esphome/components/pcd8544/pcd_8544.h | 6 ++---- esphome/components/pcf85063/pcf85063.cpp | 6 ++---- esphome/components/pcf85063/pcf85063.h | 6 ++---- esphome/components/pcf8563/pcf8563.cpp | 6 ++---- esphome/components/pcf8563/pcf8563.h | 6 ++---- esphome/components/pcf8574/pcf8574.cpp | 6 ++---- esphome/components/pcf8574/pcf8574.h | 6 ++---- esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp | 6 ++---- esphome/components/pi4ioe5v6408/pi4ioe5v6408.h | 6 ++---- esphome/components/pid/pid_autotuner.cpp | 6 ++---- esphome/components/pid/pid_autotuner.h | 6 ++---- esphome/components/pid/pid_climate.cpp | 6 ++---- esphome/components/pid/pid_climate.h | 6 ++---- esphome/components/pid/pid_controller.cpp | 6 ++---- esphome/components/pid/pid_controller.h | 6 ++---- esphome/components/pid/sensor/pid_climate_sensor.cpp | 6 ++---- esphome/components/pid/sensor/pid_climate_sensor.h | 6 ++---- esphome/components/pipsolar/output/pipsolar_output.cpp | 6 ++---- esphome/components/pipsolar/output/pipsolar_output.h | 6 ++---- esphome/components/pipsolar/pipsolar.cpp | 6 ++---- esphome/components/pipsolar/pipsolar.h | 6 ++---- esphome/components/pipsolar/switch/pipsolar_switch.cpp | 6 ++---- esphome/components/pipsolar/switch/pipsolar_switch.h | 6 ++---- esphome/components/pm1006/pm1006.cpp | 6 ++---- esphome/components/pm1006/pm1006.h | 6 ++---- esphome/components/pm2005/pm2005.cpp | 6 ++---- esphome/components/pm2005/pm2005.h | 6 ++---- esphome/components/pmsa003i/pmsa003i.cpp | 6 ++---- esphome/components/pmsa003i/pmsa003i.h | 6 ++---- esphome/components/pmwcs3/pmwcs3.cpp | 6 ++---- esphome/components/pmwcs3/pmwcs3.h | 6 ++---- esphome/components/pn532/pn532.cpp | 6 ++---- esphome/components/pn532/pn532.h | 6 ++---- esphome/components/pn532/pn532_mifare_classic.cpp | 6 ++---- esphome/components/pn532/pn532_mifare_ultralight.cpp | 6 ++---- esphome/components/pn532_i2c/pn532_i2c.cpp | 6 ++---- esphome/components/pn532_i2c/pn532_i2c.h | 6 ++---- esphome/components/pn532_spi/pn532_spi.cpp | 6 ++---- esphome/components/pn532_spi/pn532_spi.h | 6 ++---- esphome/components/pn7150/automation.h | 6 ++---- esphome/components/pn7150/pn7150.cpp | 6 ++---- esphome/components/pn7150/pn7150.h | 6 ++---- esphome/components/pn7150/pn7150_mifare_classic.cpp | 6 ++---- esphome/components/pn7150/pn7150_mifare_ultralight.cpp | 6 ++---- esphome/components/pn7150_i2c/pn7150_i2c.cpp | 6 ++---- esphome/components/pn7150_i2c/pn7150_i2c.h | 6 ++---- esphome/components/pn7160/automation.h | 6 ++---- esphome/components/pn7160/pn7160.cpp | 6 ++---- esphome/components/pn7160/pn7160.h | 6 ++---- esphome/components/pn7160/pn7160_mifare_classic.cpp | 6 ++---- esphome/components/pn7160/pn7160_mifare_ultralight.cpp | 6 ++---- esphome/components/pn7160_i2c/pn7160_i2c.cpp | 6 ++---- esphome/components/pn7160_i2c/pn7160_i2c.h | 6 ++---- esphome/components/pn7160_spi/pn7160_spi.cpp | 6 ++---- esphome/components/pn7160_spi/pn7160_spi.h | 6 ++---- esphome/components/power_supply/power_supply.cpp | 6 ++---- esphome/components/power_supply/power_supply.h | 6 ++---- esphome/components/preferences/syncer.h | 6 ++---- esphome/components/prometheus/prometheus_handler.cpp | 7 +++---- esphome/components/prometheus/prometheus_handler.h | 7 +++---- esphome/components/psram/psram.cpp | 6 ++---- esphome/components/psram/psram.h | 6 ++---- esphome/components/pulse_counter/automation.h | 7 ++----- esphome/components/pulse_counter/pulse_counter_sensor.cpp | 6 ++---- esphome/components/pulse_counter/pulse_counter_sensor.h | 6 ++---- esphome/components/pulse_meter/automation.h | 7 ++----- esphome/components/pulse_meter/pulse_meter_sensor.cpp | 6 ++---- esphome/components/pulse_meter/pulse_meter_sensor.h | 6 ++---- esphome/components/pulse_width/pulse_width.cpp | 6 ++---- esphome/components/pulse_width/pulse_width.h | 6 ++---- .../components/pvvx_mithermometer/display/pvvx_display.cpp | 7 +++---- .../components/pvvx_mithermometer/display/pvvx_display.h | 6 ++---- .../components/pvvx_mithermometer/pvvx_mithermometer.cpp | 6 ++---- esphome/components/pvvx_mithermometer/pvvx_mithermometer.h | 6 ++---- esphome/components/pylontech/pylontech.cpp | 6 ++---- esphome/components/pylontech/pylontech.h | 6 ++---- esphome/components/pylontech/sensor/pylontech_sensor.cpp | 6 ++---- esphome/components/pylontech/sensor/pylontech_sensor.h | 6 ++---- .../pylontech/text_sensor/pylontech_text_sensor.cpp | 6 ++---- .../pylontech/text_sensor/pylontech_text_sensor.h | 6 ++---- esphome/components/pzem004t/pzem004t.cpp | 6 ++---- esphome/components/pzem004t/pzem004t.h | 6 ++---- esphome/components/pzemac/pzemac.cpp | 6 ++---- esphome/components/pzemac/pzemac.h | 6 ++---- esphome/components/pzemdc/pzemdc.cpp | 6 ++---- esphome/components/pzemdc/pzemdc.h | 6 ++---- esphome/components/qmc5883l/qmc5883l.cpp | 6 ++---- esphome/components/qmc5883l/qmc5883l.h | 6 ++---- esphome/components/qmp6988/qmp6988.cpp | 6 ++---- esphome/components/qmp6988/qmp6988.h | 6 ++---- esphome/components/qr_code/qr_code.cpp | 6 ++---- esphome/components/qwiic_pir/qwiic_pir.cpp | 6 ++---- esphome/components/qwiic_pir/qwiic_pir.h | 6 ++---- esphome/components/radon_eye_ble/radon_eye_listener.cpp | 6 ++---- esphome/components/radon_eye_ble/radon_eye_listener.h | 6 ++---- esphome/components/radon_eye_rd200/radon_eye_rd200.cpp | 6 ++---- esphome/components/radon_eye_rd200/radon_eye_rd200.h | 6 ++---- esphome/components/rc522/rc522.cpp | 6 ++---- esphome/components/rc522/rc522.h | 6 ++---- esphome/components/rc522_i2c/rc522_i2c.cpp | 6 ++---- esphome/components/rc522_i2c/rc522_i2c.h | 6 ++---- esphome/components/rc522_spi/rc522_spi.cpp | 6 ++---- esphome/components/rc522_spi/rc522_spi.h | 6 ++---- esphome/components/rdm6300/rdm6300.cpp | 6 ++---- esphome/components/rdm6300/rdm6300.h | 6 ++---- esphome/components/remote_base/abbwelcome_protocol.cpp | 6 ++---- esphome/components/remote_base/abbwelcome_protocol.h | 6 ++---- esphome/components/remote_base/aeha_protocol.cpp | 6 ++---- esphome/components/remote_base/aeha_protocol.h | 6 ++---- esphome/components/remote_base/beo4_protocol.cpp | 6 ++---- esphome/components/remote_base/beo4_protocol.h | 6 ++---- esphome/components/remote_base/byronsx_protocol.cpp | 6 ++---- esphome/components/remote_base/byronsx_protocol.h | 6 ++---- esphome/components/remote_base/canalsat_protocol.cpp | 6 ++---- esphome/components/remote_base/canalsat_protocol.h | 6 ++---- esphome/components/remote_base/coolix_protocol.cpp | 6 ++---- esphome/components/remote_base/coolix_protocol.h | 6 ++---- esphome/components/remote_base/dish_protocol.cpp | 6 ++---- esphome/components/remote_base/dish_protocol.h | 6 ++---- esphome/components/remote_base/dooya_protocol.cpp | 6 ++---- esphome/components/remote_base/dooya_protocol.h | 6 ++---- esphome/components/remote_base/drayton_protocol.cpp | 6 ++---- esphome/components/remote_base/drayton_protocol.h | 6 ++---- esphome/components/remote_base/dyson_protocol.cpp | 6 ++---- esphome/components/remote_base/dyson_protocol.h | 6 ++---- esphome/components/remote_base/gobox_protocol.cpp | 6 ++---- esphome/components/remote_base/gobox_protocol.h | 6 ++---- esphome/components/remote_base/haier_protocol.cpp | 6 ++---- esphome/components/remote_base/haier_protocol.h | 6 ++---- esphome/components/remote_base/jvc_protocol.cpp | 6 ++---- esphome/components/remote_base/jvc_protocol.h | 6 ++---- esphome/components/remote_base/keeloq_protocol.cpp | 6 ++---- esphome/components/remote_base/keeloq_protocol.h | 6 ++---- esphome/components/remote_base/lg_protocol.cpp | 6 ++---- esphome/components/remote_base/lg_protocol.h | 6 ++---- esphome/components/remote_base/magiquest_protocol.cpp | 6 ++---- esphome/components/remote_base/magiquest_protocol.h | 6 ++---- esphome/components/remote_base/midea_protocol.cpp | 6 ++---- esphome/components/remote_base/midea_protocol.h | 6 ++---- esphome/components/remote_base/mirage_protocol.cpp | 6 ++---- esphome/components/remote_base/mirage_protocol.h | 6 ++---- esphome/components/remote_base/nec_protocol.cpp | 6 ++---- esphome/components/remote_base/nec_protocol.h | 6 ++---- esphome/components/remote_base/nexa_protocol.cpp | 6 ++---- esphome/components/remote_base/nexa_protocol.h | 6 ++---- esphome/components/remote_base/panasonic_protocol.cpp | 6 ++---- esphome/components/remote_base/panasonic_protocol.h | 6 ++---- esphome/components/remote_base/pioneer_protocol.cpp | 6 ++---- esphome/components/remote_base/pioneer_protocol.h | 6 ++---- esphome/components/remote_base/pronto_protocol.cpp | 6 ++---- esphome/components/remote_base/pronto_protocol.h | 6 ++---- esphome/components/remote_base/raw_protocol.cpp | 6 ++---- esphome/components/remote_base/raw_protocol.h | 6 ++---- esphome/components/remote_base/rc5_protocol.cpp | 6 ++---- esphome/components/remote_base/rc5_protocol.h | 6 ++---- esphome/components/remote_base/rc6_protocol.cpp | 6 ++---- esphome/components/remote_base/rc6_protocol.h | 6 ++---- esphome/components/remote_base/rc_switch_protocol.cpp | 6 ++---- esphome/components/remote_base/rc_switch_protocol.h | 6 ++---- esphome/components/remote_base/remote_base.cpp | 6 ++---- esphome/components/remote_base/remote_base.h | 6 ++---- esphome/components/remote_base/roomba_protocol.cpp | 6 ++---- esphome/components/remote_base/roomba_protocol.h | 6 ++---- esphome/components/remote_base/samsung36_protocol.cpp | 6 ++---- esphome/components/remote_base/samsung36_protocol.h | 6 ++---- esphome/components/remote_base/samsung_protocol.cpp | 6 ++---- esphome/components/remote_base/samsung_protocol.h | 6 ++---- esphome/components/remote_base/sony_protocol.cpp | 6 ++---- esphome/components/remote_base/sony_protocol.h | 6 ++---- esphome/components/remote_base/symphony_protocol.cpp | 6 ++---- esphome/components/remote_base/symphony_protocol.h | 6 ++---- esphome/components/remote_base/toshiba_ac_protocol.cpp | 6 ++---- esphome/components/remote_base/toshiba_ac_protocol.h | 6 ++---- esphome/components/remote_base/toto_protocol.cpp | 6 ++---- esphome/components/remote_base/toto_protocol.h | 6 ++---- esphome/components/resampler/speaker/resampler_speaker.cpp | 6 ++---- esphome/components/resampler/speaker/resampler_speaker.h | 6 ++---- esphome/components/resistance/resistance_sensor.cpp | 6 ++---- esphome/components/resistance/resistance_sensor.h | 6 ++---- esphome/components/restart/button/restart_button.cpp | 6 ++---- esphome/components/restart/button/restart_button.h | 6 ++---- esphome/components/restart/switch/restart_switch.cpp | 6 ++---- esphome/components/restart/switch/restart_switch.h | 6 ++---- esphome/components/rf_bridge/rf_bridge.cpp | 6 ++---- esphome/components/rf_bridge/rf_bridge.h | 6 ++---- esphome/components/rgb/rgb_light_output.h | 6 ++---- esphome/components/rgbct/rgbct_light_output.h | 6 ++---- esphome/components/rgbw/rgbw_light_output.h | 6 ++---- esphome/components/rgbww/rgbww_light_output.h | 6 ++---- esphome/components/rotary_encoder/rotary_encoder.cpp | 6 ++---- esphome/components/rotary_encoder/rotary_encoder.h | 6 ++---- esphome/components/ruuvi_ble/ruuvi_ble.cpp | 6 ++---- esphome/components/ruuvi_ble/ruuvi_ble.h | 6 ++---- esphome/components/ruuvitag/ruuvitag.cpp | 6 ++---- esphome/components/ruuvitag/ruuvitag.h | 6 ++---- esphome/components/rx8130/rx8130.cpp | 6 ++---- esphome/components/rx8130/rx8130.h | 6 ++---- 267 files changed, 538 insertions(+), 1070 deletions(-) diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 66d36dd741..4d73ed6dd0 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace nau7802 { +namespace esphome::nau7802 { static const char *const TAG = "nau7802"; @@ -313,5 +312,4 @@ void NAU7802Sensor::update() { bool NAU7802Sensor::is_data_ready_() { return this->reg(PU_CTRL_REG).get() & PU_CTRL_CYCLE_READY; } -} // namespace nau7802 -} // namespace esphome +} // namespace esphome::nau7802 diff --git a/esphome/components/nau7802/nau7802.h b/esphome/components/nau7802/nau7802.h index ae39e167a4..67f36ca677 100644 --- a/esphome/components/nau7802/nau7802.h +++ b/esphome/components/nau7802/nau7802.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace nau7802 { +namespace esphome::nau7802 { enum NAU7802Gain { NAU7802_GAIN_128 = 0b111, @@ -114,5 +113,4 @@ template class NAU7802CalbrateGainAction : public Action, void play(const Ts &...x) override { this->parent_->calibrate_gain(); } }; -} // namespace nau7802 -} // namespace esphome +} // namespace esphome::nau7802 diff --git a/esphome/components/nfc/automation.cpp b/esphome/components/nfc/automation.cpp index e2956e4c12..7129aaf2af 100644 --- a/esphome/components/nfc/automation.cpp +++ b/esphome/components/nfc/automation.cpp @@ -1,13 +1,11 @@ #include "automation.h" #include "nfc.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { void NfcOnTagTrigger::process(const std::unique_ptr &tag) { char uid_buf[FORMAT_UID_BUFFER_SIZE]; this->trigger(std::string(format_uid_to(uid_buf, tag->get_uid())), *tag); } -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/automation.h b/esphome/components/nfc/automation.h index 565b71bdd9..0ac3e3b8b6 100644 --- a/esphome/components/nfc/automation.h +++ b/esphome/components/nfc/automation.h @@ -5,13 +5,11 @@ #include "nfc.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { class NfcOnTagTrigger : public Trigger { public: void process(const std::unique_ptr &tag); }; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp index 524ad5a413..6e8162fc91 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp @@ -3,8 +3,7 @@ #include "../nfc_helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { static const char *const TAG = "nfc.binary_sensor"; @@ -112,5 +111,4 @@ void NfcTagBinarySensor::tag_on(NfcTag &tag) { } } -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h index 0a7ca0ca76..b3448a57cc 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h @@ -6,8 +6,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { class NfcTagBinarySensor : public binary_sensor::BinarySensor, public Component, @@ -34,5 +33,4 @@ class NfcTagBinarySensor : public binary_sensor::BinarySensor, NfcTagUid uid_; }; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/nci_core.h b/esphome/components/nfc/nci_core.h index 6b42070ed0..dd4bc547f6 100644 --- a/esphome/components/nfc/nci_core.h +++ b/esphome/components/nfc/nci_core.h @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace nfc { +namespace esphome::nfc { // Header info static constexpr uint8_t NCI_PKT_HEADER_SIZE = 3; // NCI packet (pkt) headers are always three bytes @@ -140,5 +139,4 @@ static constexpr uint8_t RF_INTF_ACTIVATED_NTF_INIT_CRED = 5 + NCI_PKT_HEADER_SI static constexpr uint8_t RF_INTF_ACTIVATED_NTF_RF_TECH_LENGTH = 6 + NCI_PKT_HEADER_SIZE; static constexpr uint8_t RF_INTF_ACTIVATED_NTF_RF_TECH_PARAMS = 7 + NCI_PKT_HEADER_SIZE; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/nci_message.cpp b/esphome/components/nfc/nci_message.cpp index c6b21f6ae0..0b60fd0ade 100644 --- a/esphome/components/nfc/nci_message.cpp +++ b/esphome/components/nfc/nci_message.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace nfc { +namespace esphome::nfc { static const char *const TAG = "NciMessage"; @@ -162,5 +161,4 @@ void NciMessage::set_payload(const std::vector &payload) { this->nci_message_ = message; } -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/nci_message.h b/esphome/components/nfc/nci_message.h index 0c5c871f74..8e8b110336 100644 --- a/esphome/components/nfc/nci_message.h +++ b/esphome/components/nfc/nci_message.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace nfc { +namespace esphome::nfc { class NciMessage { public: @@ -46,5 +45,4 @@ class NciMessage { std::vector nci_message_{0, 0, 0}; // three bytes, MT/PBF/GID, OID, payload length/size }; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/ndef_message.cpp b/esphome/components/nfc/ndef_message.cpp index 35028555c5..ba3aa77e34 100644 --- a/esphome/components/nfc/ndef_message.cpp +++ b/esphome/components/nfc/ndef_message.cpp @@ -1,8 +1,7 @@ #include "ndef_message.h" #include -namespace esphome { -namespace nfc { +namespace esphome::nfc { static const char *const TAG = "nfc.ndef_message"; @@ -120,5 +119,4 @@ std::vector NdefMessage::encode() { return data; } -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/ndef_message.h b/esphome/components/nfc/ndef_message.h index 48f79b8854..7d431b2296 100644 --- a/esphome/components/nfc/ndef_message.h +++ b/esphome/components/nfc/ndef_message.h @@ -9,8 +9,7 @@ #include "ndef_record_text.h" #include "ndef_record_uri.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { static constexpr uint8_t MAX_NDEF_RECORDS = 4; @@ -38,5 +37,4 @@ class NdefMessage { std::vector> records_; }; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/ndef_record.cpp b/esphome/components/nfc/ndef_record.cpp index 540ba62940..8b9e7023ed 100644 --- a/esphome/components/nfc/ndef_record.cpp +++ b/esphome/components/nfc/ndef_record.cpp @@ -1,7 +1,6 @@ #include "ndef_record.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { static const char *const TAG = "nfc.ndef_record"; @@ -61,5 +60,4 @@ uint8_t NdefRecord::create_flag_byte(bool first, bool last, size_t payload_size) return value; }; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/ndef_record.h b/esphome/components/nfc/ndef_record.h index 1a7c24aee9..fc9fe25402 100644 --- a/esphome/components/nfc/ndef_record.h +++ b/esphome/components/nfc/ndef_record.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace nfc { +namespace esphome::nfc { static constexpr uint8_t TNF_EMPTY = 0x00; static constexpr uint8_t TNF_WELL_KNOWN = 0x01; @@ -53,5 +52,4 @@ class NdefRecord { std::string payload_; }; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/ndef_record_text.cpp b/esphome/components/nfc/ndef_record_text.cpp index 8a9a2cb014..8ad687daf8 100644 --- a/esphome/components/nfc/ndef_record_text.cpp +++ b/esphome/components/nfc/ndef_record_text.cpp @@ -1,8 +1,7 @@ #include "ndef_record_text.h" #include "ndef_record.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { static const char *const TAG = "nfc.ndef_record_text"; @@ -41,5 +40,4 @@ std::vector NdefRecordText::get_encoded_payload() { return data; } -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/ndef_record_text.h b/esphome/components/nfc/ndef_record_text.h index e6c15704f0..ceee585e89 100644 --- a/esphome/components/nfc/ndef_record_text.h +++ b/esphome/components/nfc/ndef_record_text.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace nfc { +namespace esphome::nfc { class NdefRecordText : public NdefRecord { public: @@ -39,5 +38,4 @@ class NdefRecordText : public NdefRecord { std::string language_code_; }; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/ndef_record_uri.cpp b/esphome/components/nfc/ndef_record_uri.cpp index 9064f04f29..e2c6d21a34 100644 --- a/esphome/components/nfc/ndef_record_uri.cpp +++ b/esphome/components/nfc/ndef_record_uri.cpp @@ -1,7 +1,6 @@ #include "ndef_record_uri.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { static const char *const TAG = "nfc.ndef_record_uri"; @@ -44,5 +43,4 @@ std::vector NdefRecordUri::get_encoded_payload() { return data; } -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/ndef_record_uri.h b/esphome/components/nfc/ndef_record_uri.h index 2f7790a9a9..619cdf7cf3 100644 --- a/esphome/components/nfc/ndef_record_uri.h +++ b/esphome/components/nfc/ndef_record_uri.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace nfc { +namespace esphome::nfc { static constexpr uint8_t PAYLOAD_IDENTIFIERS_COUNT = 0x23; static const char *const PAYLOAD_IDENTIFIERS[] = {"", @@ -74,5 +73,4 @@ class NdefRecordUri : public NdefRecord { std::string uri_; }; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index 55543cd292..99e476dbdf 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { static const char *const TAG = "nfc"; @@ -108,5 +107,4 @@ bool mifare_classic_is_trailer_block(uint8_t block_num) { } } -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc.h b/esphome/components/nfc/nfc.h index 8ca5cb7ea4..42ef993913 100644 --- a/esphome/components/nfc/nfc.h +++ b/esphome/components/nfc/nfc.h @@ -9,8 +9,7 @@ #include #include -namespace esphome { -namespace nfc { +namespace esphome::nfc { static constexpr uint8_t MIFARE_CLASSIC_BLOCK_SIZE = 16; static constexpr uint8_t MIFARE_CLASSIC_LONG_TLV_SIZE = 4; @@ -95,5 +94,4 @@ class Nfcc { std::vector tag_listeners_; }; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc_helpers.cpp b/esphome/components/nfc/nfc_helpers.cpp index fb0954a833..6c8a5b626d 100644 --- a/esphome/components/nfc/nfc_helpers.cpp +++ b/esphome/components/nfc/nfc_helpers.cpp @@ -1,7 +1,6 @@ #include "nfc_helpers.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { static const char *const TAG = "nfc.helpers"; @@ -43,5 +42,4 @@ std::string get_random_ha_tag_ndef() { return uri; } -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc_helpers.h b/esphome/components/nfc/nfc_helpers.h index 74f5beba13..dedc602bf1 100644 --- a/esphome/components/nfc/nfc_helpers.h +++ b/esphome/components/nfc/nfc_helpers.h @@ -2,8 +2,7 @@ #include "nfc_tag.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { static const char HA_TAG_ID_EXT_RECORD_TYPE[] = "android.com:pkg"; static const char HA_TAG_ID_EXT_RECORD_PAYLOAD[] = "io.homeassistant.companion.android"; @@ -13,5 +12,4 @@ std::string get_ha_tag_ndef(NfcTag &tag); std::string get_random_ha_tag_ndef(); bool has_ha_tag_ndef(NfcTag &tag); -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc_tag.cpp b/esphome/components/nfc/nfc_tag.cpp index c5c15b00ec..c43210517d 100644 --- a/esphome/components/nfc/nfc_tag.cpp +++ b/esphome/components/nfc/nfc_tag.cpp @@ -1,9 +1,7 @@ #include "nfc_tag.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { static const char *const TAG = "nfc.tag"; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc_tag.h b/esphome/components/nfc/nfc_tag.h index 0ded4cd6ee..6cc1a00c62 100644 --- a/esphome/components/nfc/nfc_tag.h +++ b/esphome/components/nfc/nfc_tag.h @@ -7,8 +7,7 @@ #include "esphome/core/log.h" #include "ndef_message.h" -namespace esphome { -namespace nfc { +namespace esphome::nfc { // NFC UIDs are 4, 7, or 10 bytes depending on tag type static constexpr size_t NFC_UID_MAX_LENGTH = 10; @@ -54,5 +53,4 @@ class NfcTag { std::shared_ptr ndef_message_; }; -} // namespace nfc -} // namespace esphome +} // namespace esphome::nfc diff --git a/esphome/components/noblex/noblex.cpp b/esphome/components/noblex/noblex.cpp index e7e421d177..ac8d2641fe 100644 --- a/esphome/components/noblex/noblex.cpp +++ b/esphome/components/noblex/noblex.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace noblex { +namespace esphome::noblex { static const char *const TAG = "noblex.climate"; @@ -299,5 +298,4 @@ bool NoblexClimate::on_receive(remote_base::RemoteReceiveData data) { return true; } // end on_receive() -} // namespace noblex -} // namespace esphome +} // namespace esphome::noblex diff --git a/esphome/components/noblex/noblex.h b/esphome/components/noblex/noblex.h index 3d52a1a538..62070e5dee 100644 --- a/esphome/components/noblex/noblex.h +++ b/esphome/components/noblex/noblex.h @@ -2,8 +2,7 @@ #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace noblex { +namespace esphome::noblex { // Temperature const uint8_t NOBLEX_TEMP_MIN = 16; // Celsius @@ -45,5 +44,4 @@ class NoblexClimate : public climate_ir::ClimateIR { uint8_t remote_state_[8]{}; }; -} // namespace noblex -} // namespace esphome +} // namespace esphome::noblex diff --git a/esphome/components/npi19/npi19.cpp b/esphome/components/npi19/npi19.cpp index 995abdff37..207acc9d99 100644 --- a/esphome/components/npi19/npi19.cpp +++ b/esphome/components/npi19/npi19.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace npi19 { +namespace esphome::npi19 { static const char *const TAG = "npi19"; @@ -101,5 +100,4 @@ void NPI19Component::update() { this->status_clear_warning(); } -} // namespace npi19 -} // namespace esphome +} // namespace esphome::npi19 diff --git a/esphome/components/npi19/npi19.h b/esphome/components/npi19/npi19.h index 8e6a8e3bfa..d1f74141ac 100644 --- a/esphome/components/npi19/npi19.h +++ b/esphome/components/npi19/npi19.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace npi19 { +namespace esphome::npi19 { /// This class implements support for the npi19 pressure and temperature i2c sensors. class NPI19Component : public PollingComponent, public i2c::I2CDevice { @@ -25,5 +24,4 @@ class NPI19Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *raw_pressure_sensor_{nullptr}; }; -} // namespace npi19 -} // namespace esphome +} // namespace esphome::npi19 diff --git a/esphome/components/ntc/ntc.cpp b/esphome/components/ntc/ntc.cpp index e2097bdd77..49a42e858e 100644 --- a/esphome/components/ntc/ntc.cpp +++ b/esphome/components/ntc/ntc.cpp @@ -1,8 +1,7 @@ #include "ntc.h" #include "esphome/core/log.h" -namespace esphome { -namespace ntc { +namespace esphome::ntc { static const char *const TAG = "ntc"; @@ -26,5 +25,4 @@ void NTC::process_(float value) { this->publish_state(temp); } -} // namespace ntc -} // namespace esphome +} // namespace esphome::ntc diff --git a/esphome/components/ntc/ntc.h b/esphome/components/ntc/ntc.h index a0c72340de..466d03f789 100644 --- a/esphome/components/ntc/ntc.h +++ b/esphome/components/ntc/ntc.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace ntc { +namespace esphome::ntc { class NTC : public Component, public sensor::Sensor { public: @@ -24,5 +23,4 @@ class NTC : public Component, public sensor::Sensor { double c_; }; -} // namespace ntc -} // namespace esphome +} // namespace esphome::ntc diff --git a/esphome/components/one_wire/one_wire.cpp b/esphome/components/one_wire/one_wire.cpp index d14c1c92bd..aeab821f6a 100644 --- a/esphome/components/one_wire/one_wire.cpp +++ b/esphome/components/one_wire/one_wire.cpp @@ -1,7 +1,6 @@ #include "one_wire.h" -namespace esphome { -namespace one_wire { +namespace esphome::one_wire { static const char *const TAG = "one_wire"; @@ -51,5 +50,4 @@ bool OneWireDevice::check_address_or_index_() { return true; } -} // namespace one_wire -} // namespace esphome +} // namespace esphome::one_wire diff --git a/esphome/components/one_wire/one_wire.h b/esphome/components/one_wire/one_wire.h index 324e46cd55..4dbbe11792 100644 --- a/esphome/components/one_wire/one_wire.h +++ b/esphome/components/one_wire/one_wire.h @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace one_wire { +namespace esphome::one_wire { #define LOG_ONE_WIRE_DEVICE(this) \ ESP_LOGCONFIG(TAG, " Address: %s (%s)", this->get_address_name().c_str(), \ @@ -43,5 +42,4 @@ class OneWireDevice { bool send_command_(uint8_t cmd); }; -} // namespace one_wire -} // namespace esphome +} // namespace esphome::one_wire diff --git a/esphome/components/one_wire/one_wire_bus.cpp b/esphome/components/one_wire/one_wire_bus.cpp index 99e1f352fb..c7ea59050c 100644 --- a/esphome/components/one_wire/one_wire_bus.cpp +++ b/esphome/components/one_wire/one_wire_bus.cpp @@ -1,8 +1,7 @@ #include "one_wire_bus.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace one_wire { +namespace esphome::one_wire { static const char *const TAG = "one_wire"; @@ -93,5 +92,4 @@ void OneWireBus::dump_devices_(const char *tag) { } } -} // namespace one_wire -} // namespace esphome +} // namespace esphome::one_wire diff --git a/esphome/components/one_wire/one_wire_bus.h b/esphome/components/one_wire/one_wire_bus.h index 6302fcee7b..00f5a6462e 100644 --- a/esphome/components/one_wire/one_wire_bus.h +++ b/esphome/components/one_wire/one_wire_bus.h @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace one_wire { +namespace esphome::one_wire { class OneWireBus { public: @@ -64,5 +63,4 @@ class OneWireBus { virtual uint64_t search_int() = 0; }; -} // namespace one_wire -} // namespace esphome +} // namespace esphome::one_wire diff --git a/esphome/components/opentherm/automation.h b/esphome/components/opentherm/automation.h index acbe33ac8f..aa20a4ec5a 100644 --- a/esphome/components/opentherm/automation.h +++ b/esphome/components/opentherm/automation.h @@ -4,8 +4,7 @@ #include "hub.h" #include "opentherm.h" -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { class BeforeSendTrigger : public Trigger { public: @@ -21,5 +20,4 @@ class BeforeProcessResponseTrigger : public Trigger { } }; -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/hub.cpp b/esphome/components/opentherm/hub.cpp index 7a0cdc7f80..e2828a9e30 100644 --- a/esphome/components/opentherm/hub.cpp +++ b/esphome/components/opentherm/hub.cpp @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { static const char *const TAG = "opentherm"; namespace message_data { @@ -419,5 +418,4 @@ void OpenthermHub::dump_config() { } } -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/hub.h b/esphome/components/opentherm/hub.h index 960e23d6dd..2638137668 100644 --- a/esphome/components/opentherm/hub.h +++ b/esphome/components/opentherm/hub.h @@ -35,8 +35,7 @@ #include "opentherm_macros.h" -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { static const uint8_t REPEATING_MESSAGE_ORDER = 255; static const uint8_t INITIAL_UNORDERED_MESSAGE_ORDER = 254; @@ -175,5 +174,4 @@ class OpenthermHub : public Component { void dump_config() override; }; -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/input.h b/esphome/components/opentherm/input.h index 3567138792..f196d49037 100644 --- a/esphome/components/opentherm/input.h +++ b/esphome/components/opentherm/input.h @@ -1,7 +1,6 @@ #pragma once -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { class OpenthermInput { public: @@ -14,5 +13,4 @@ class OpenthermInput { virtual void set_auto_max_value(bool auto_max_value) { this->auto_max_value = auto_max_value; } }; -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/number/opentherm_number.cpp b/esphome/components/opentherm/number/opentherm_number.cpp index bdb02a605c..d78c6eb38a 100644 --- a/esphome/components/opentherm/number/opentherm_number.cpp +++ b/esphome/components/opentherm/number/opentherm_number.cpp @@ -1,7 +1,6 @@ #include "opentherm_number.h" -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { static const char *const TAG = "opentherm.number"; @@ -38,5 +37,4 @@ void OpenthermNumber::dump_config() { this->restore_value_, this->initial_value_, this->state); } -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/number/opentherm_number.h b/esphome/components/opentherm/number/opentherm_number.h index 6f86072754..c110bed2eb 100644 --- a/esphome/components/opentherm/number/opentherm_number.h +++ b/esphome/components/opentherm/number/opentherm_number.h @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include "esphome/components/opentherm/input.h" -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { // Just a simple number, which stores the number class OpenthermNumber : public number::Number, public Component, public OpenthermInput { @@ -27,5 +26,4 @@ class OpenthermNumber : public number::Number, public Component, public Openther void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } }; -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 97cf83a5aa..1ee4c9191b 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -16,8 +16,7 @@ #endif #include -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { using std::string; @@ -566,5 +565,4 @@ void OpenthermData::s16(int16_t value) { this->valueHB = (value >> 8) & 0xFF; } -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/opentherm.h b/esphome/components/opentherm/opentherm.h index eb8c5b3ad6..3078e92c9d 100644 --- a/esphome/components/opentherm/opentherm.h +++ b/esphome/components/opentherm/opentherm.h @@ -16,8 +16,7 @@ #include "driver/gptimer.h" #endif -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { template constexpr T read_bit(T value, uint8_t bit) { return (value >> bit) & 0x01; } @@ -403,5 +402,4 @@ class OpenTherm { #endif }; -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/opentherm_macros.h b/esphome/components/opentherm/opentherm_macros.h index 398c64aa8f..c5ed02a50b 100644 --- a/esphome/components/opentherm/opentherm_macros.h +++ b/esphome/components/opentherm/opentherm_macros.h @@ -1,6 +1,6 @@ #pragma once -namespace esphome { -namespace opentherm { + +namespace esphome::opentherm { // ===== hub.h macros ===== @@ -158,5 +158,4 @@ namespace opentherm { #define SHOW_INNER(x) #x #define SHOW(x) SHOW_INNER(x) -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index ff82ddd72c..2735c85d06 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -1,8 +1,7 @@ #include "esphome/core/helpers.h" // for clamp() and lerp() #include "opentherm_output.h" -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { static const char *const TAG = "opentherm.output"; @@ -14,5 +13,4 @@ void opentherm::OpenthermOutput::write_state(float state) { this->has_state_ = true; ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state); } -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/output/opentherm_output.h b/esphome/components/opentherm/output/opentherm_output.h index 8d6a0ee4ba..e789d72702 100644 --- a/esphome/components/opentherm/output/opentherm_output.h +++ b/esphome/components/opentherm/output/opentherm_output.h @@ -4,8 +4,7 @@ #include "esphome/components/opentherm/input.h" #include "esphome/core/log.h" -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { class OpenthermOutput : public output::FloatOutput, public Component, public OpenthermInput { protected: @@ -29,5 +28,4 @@ class OpenthermOutput : public output::FloatOutput, public Component, public Ope float get_max_value() { return this->max_value_; } }; -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/switch/opentherm_switch.cpp b/esphome/components/opentherm/switch/opentherm_switch.cpp index 5c5d62e68e..2fc99e793e 100644 --- a/esphome/components/opentherm/switch/opentherm_switch.cpp +++ b/esphome/components/opentherm/switch/opentherm_switch.cpp @@ -1,7 +1,6 @@ #include "opentherm_switch.h" -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { static const char *const TAG = "opentherm.switch"; @@ -24,5 +23,4 @@ void OpenthermSwitch::dump_config() { ESP_LOGCONFIG(TAG, " Current state: %d", this->state); } -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/switch/opentherm_switch.h b/esphome/components/opentherm/switch/opentherm_switch.h index 0c20a0d9ed..ca930d4f7c 100644 --- a/esphome/components/opentherm/switch/opentherm_switch.h +++ b/esphome/components/opentherm/switch/opentherm_switch.h @@ -4,8 +4,7 @@ #include "esphome/components/switch/switch.h" #include "esphome/core/log.h" -namespace esphome { -namespace opentherm { +namespace esphome::opentherm { class OpenthermSwitch : public switch_::Switch, public Component { protected: @@ -16,5 +15,4 @@ class OpenthermSwitch : public switch_::Switch, public Component { void dump_config() override; }; -} // namespace opentherm -} // namespace esphome +} // namespace esphome::opentherm diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index a942e45035..ce5fbdfd82 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -1,8 +1,7 @@ #include "opt3001.h" #include "esphome/core/log.h" -namespace esphome { -namespace opt3001 { +namespace esphome::opt3001 { static const char *const TAG = "opt3001.sensor"; @@ -116,5 +115,4 @@ void OPT3001Sensor::update() { }); } -} // namespace opt3001 -} // namespace esphome +} // namespace esphome::opt3001 diff --git a/esphome/components/opt3001/opt3001.h b/esphome/components/opt3001/opt3001.h index 3bce9f0aeb..e5de536353 100644 --- a/esphome/components/opt3001/opt3001.h +++ b/esphome/components/opt3001/opt3001.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace opt3001 { +namespace esphome::opt3001 { /// This class implements support for the i2c-based OPT3001 ambient light sensor. class OPT3001Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { @@ -22,5 +21,4 @@ class OPT3001Sensor : public sensor::Sensor, public PollingComponent, public i2c bool updating_{false}; }; -} // namespace opt3001 -} // namespace esphome +} // namespace esphome::opt3001 diff --git a/esphome/components/output/automation.cpp b/esphome/components/output/automation.cpp index 5533a6bee4..610da897d9 100644 --- a/esphome/components/output/automation.cpp +++ b/esphome/components/output/automation.cpp @@ -1,10 +1,8 @@ #include "automation.h" #include "esphome/core/log.h" -namespace esphome { -namespace output { +namespace esphome::output { static const char *const TAG = "output.automation"; -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h index 537226a143..301f568388 100644 --- a/esphome/components/output/automation.h +++ b/esphome/components/output/automation.h @@ -6,8 +6,7 @@ #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace output { +namespace esphome::output { template class TurnOffAction : public Action { public: @@ -67,5 +66,4 @@ template class SetMaxPowerAction : public Action { }; #endif // USE_OUTPUT_FLOAT_POWER_SCALING -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/output/binary_output.h b/esphome/components/output/binary_output.h index 7a15bc7b51..74ca3ad594 100644 --- a/esphome/components/output/binary_output.h +++ b/esphome/components/output/binary_output.h @@ -7,8 +7,7 @@ #include "esphome/components/power_supply/power_supply.h" #endif -namespace esphome { -namespace output { +namespace esphome::output { #define LOG_BINARY_OUTPUT(this) \ if (this->inverted_) { \ @@ -69,5 +68,4 @@ class BinaryOutput { #endif }; -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/output/button/output_button.cpp b/esphome/components/output/button/output_button.cpp index 4dd7ec249b..5833743586 100644 --- a/esphome/components/output/button/output_button.cpp +++ b/esphome/components/output/button/output_button.cpp @@ -1,8 +1,7 @@ #include "output_button.h" #include "esphome/core/log.h" -namespace esphome { -namespace output { +namespace esphome::output { static const char *const TAG = "output.button"; @@ -17,5 +16,4 @@ void OutputButton::press_action() { this->set_timeout("reset", this->duration_, [this]() { this->output_->turn_off(); }); } -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/output/button/output_button.h b/esphome/components/output/button/output_button.h index 8802c9754d..1a2997bdcf 100644 --- a/esphome/components/output/button/output_button.h +++ b/esphome/components/output/button/output_button.h @@ -4,8 +4,7 @@ #include "esphome/components/button/button.h" #include "esphome/components/output/binary_output.h" -namespace esphome { -namespace output { +namespace esphome::output { class OutputButton : public button::Button, public Component { public: @@ -21,5 +20,4 @@ class OutputButton : public button::Button, public Component { uint32_t duration_; }; -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/output/float_output.cpp b/esphome/components/output/float_output.cpp index 35629c828a..e2b029f368 100644 --- a/esphome/components/output/float_output.cpp +++ b/esphome/components/output/float_output.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace output { +namespace esphome::output { static const char *const TAG = "output.float"; @@ -40,5 +39,4 @@ void FloatOutput::set_level(float state) { void FloatOutput::write_state(bool state) { this->set_level(state != this->inverted_ ? 1.0f : 0.0f); } -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 3e1bd83968..673f423572 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -4,8 +4,7 @@ #include "esphome/core/defines.h" #include "binary_output.h" -namespace esphome { -namespace output { +namespace esphome::output { #ifdef USE_OUTPUT_FLOAT_POWER_SCALING #define LOG_FLOAT_OUTPUT(this) \ @@ -130,5 +129,4 @@ class FloatOutput : public BinaryOutput { #endif }; -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/output/lock/output_lock.cpp b/esphome/components/output/lock/output_lock.cpp index c373cd7b7c..ee9d918542 100644 --- a/esphome/components/output/lock/output_lock.cpp +++ b/esphome/components/output/lock/output_lock.cpp @@ -1,8 +1,7 @@ #include "output_lock.h" #include "esphome/core/log.h" -namespace esphome { -namespace output { +namespace esphome::output { static const char *const TAG = "output.lock"; @@ -21,5 +20,4 @@ void OutputLock::control(const lock::LockCall &call) { this->publish_state(state); } -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/output/lock/output_lock.h b/esphome/components/output/lock/output_lock.h index c183c3a3ea..7be96e1e82 100644 --- a/esphome/components/output/lock/output_lock.h +++ b/esphome/components/output/lock/output_lock.h @@ -4,8 +4,7 @@ #include "esphome/components/lock/lock.h" #include "esphome/components/output/binary_output.h" -namespace esphome { -namespace output { +namespace esphome::output { class OutputLock : public lock::Lock, public Component { public: @@ -20,5 +19,4 @@ class OutputLock : public lock::Lock, public Component { output::BinaryOutput *output_; }; -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/output/switch/output_switch.cpp b/esphome/components/output/switch/output_switch.cpp index 54260ba37a..7cee2a8639 100644 --- a/esphome/components/output/switch/output_switch.cpp +++ b/esphome/components/output/switch/output_switch.cpp @@ -1,8 +1,7 @@ #include "output_switch.h" #include "esphome/core/log.h" -namespace esphome { -namespace output { +namespace esphome::output { static const char *const TAG = "output.switch"; @@ -25,5 +24,4 @@ void OutputSwitch::write_state(bool state) { this->publish_state(state); } -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/output/switch/output_switch.h b/esphome/components/output/switch/output_switch.h index a184a342fe..b0d85678be 100644 --- a/esphome/components/output/switch/output_switch.h +++ b/esphome/components/output/switch/output_switch.h @@ -4,8 +4,7 @@ #include "esphome/components/switch/switch.h" #include "esphome/components/output/binary_output.h" -namespace esphome { -namespace output { +namespace esphome::output { class OutputSwitch : public switch_::Switch, public Component { public: @@ -21,5 +20,4 @@ class OutputSwitch : public switch_::Switch, public Component { output::BinaryOutput *output_; }; -} // namespace output -} // namespace esphome +} // namespace esphome::output diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index a2199977aa..a21f0e2f63 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -7,8 +7,7 @@ #include "esphome/components/xxtea/xxtea.h" -namespace esphome { -namespace packet_transport { +namespace esphome::packet_transport { // Maximum bytes to log in hex output (168 * 3 = 504, under TX buffer size of 512) static constexpr size_t PACKET_MAX_LOG_BYTES = 168; @@ -609,5 +608,4 @@ void PacketTransport::send_ping_pong_request_() { this->resend_ping_key_ = false; ESP_LOGV(TAG, "Sent new ping request %08X", (unsigned) this->ping_key_); } -} // namespace packet_transport -} // namespace esphome +} // namespace esphome::packet_transport diff --git a/esphome/components/packet_transport/packet_transport.h b/esphome/components/packet_transport/packet_transport.h index 836775bc85..3938054c15 100644 --- a/esphome/components/packet_transport/packet_transport.h +++ b/esphome/components/packet_transport/packet_transport.h @@ -22,8 +22,7 @@ * On receipt of a data packet, it should call `this->process_()` with the data. */ -namespace esphome { -namespace packet_transport { +namespace esphome::packet_transport { // std::less provides allocation-free comparison with const char * template using string_map_t = std::map>; @@ -168,5 +167,4 @@ class PacketTransport : public PollingComponent { bool is_encrypted_() const { return !this->encryption_key_.empty(); } }; -} // namespace packet_transport -} // namespace esphome +} // namespace esphome::packet_transport diff --git a/esphome/components/partition/light_partition.cpp b/esphome/components/partition/light_partition.cpp index 63c0d0186e..2755f82294 100644 --- a/esphome/components/partition/light_partition.cpp +++ b/esphome/components/partition/light_partition.cpp @@ -1,10 +1,8 @@ #include "light_partition.h" #include "esphome/core/log.h" -namespace esphome { -namespace partition { +namespace esphome::partition { static const char *const TAG = "partition.light"; -} // namespace partition -} // namespace esphome +} // namespace esphome::partition diff --git a/esphome/components/partition/light_partition.h b/esphome/components/partition/light_partition.h index bd90b4c4f1..7a2f3678c1 100644 --- a/esphome/components/partition/light_partition.h +++ b/esphome/components/partition/light_partition.h @@ -6,8 +6,7 @@ #include "esphome/core/component.h" #include "esphome/components/light/addressable_light.h" -namespace esphome { -namespace partition { +namespace esphome::partition { class AddressableSegment { public: @@ -93,5 +92,4 @@ class PartitionLightOutput : public light::AddressableLight { std::vector segments_; }; -} // namespace partition -} // namespace esphome +} // namespace esphome::partition diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index d617336e7e..4c19feab6e 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -1,8 +1,7 @@ #include "pca6416a.h" #include "esphome/core/log.h" -namespace esphome { -namespace pca6416a { +namespace esphome::pca6416a { enum PCA6416AGPIORegisters { // 0 side @@ -205,5 +204,4 @@ size_t PCA6416AGPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "%u via PCA6416A", this->pin_); } -} // namespace pca6416a -} // namespace esphome +} // namespace esphome::pca6416a diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 4d2e6b219e..3170033b28 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" -namespace esphome { -namespace pca6416a { +namespace esphome::pca6416a { class PCA6416AComponent : public Component, public i2c::I2CDevice, @@ -72,5 +71,4 @@ class PCA6416AGPIOPin : public GPIOPin { gpio::Flags flags_; }; -} // namespace pca6416a -} // namespace esphome +} // namespace esphome::pca6416a diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index 393bbfd61e..c3ea6a3c0c 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -1,8 +1,7 @@ #include "pca9554.h" #include "esphome/core/log.h" -namespace esphome { -namespace pca9554 { +namespace esphome::pca9554 { // for 16 bit expanders, these addresses will be doubled. const uint8_t INPUT_REG = 0; @@ -152,5 +151,4 @@ size_t PCA9554GPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "%u via PCA9554", this->pin_); } -} // namespace pca9554 -} // namespace esphome +} // namespace esphome::pca9554 diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index f33f9d4592..9fa398cf29 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" -namespace esphome { -namespace pca9554 { +namespace esphome::pca9554 { class PCA9554Component : public Component, public i2c::I2CDevice, @@ -76,5 +75,4 @@ class PCA9554GPIOPin : public GPIOPin { gpio::Flags flags_; }; -} // namespace pca9554 -} // namespace esphome +} // namespace esphome::pca9554 diff --git a/esphome/components/pca9685/pca9685_output.cpp b/esphome/components/pca9685/pca9685_output.cpp index 89a6bcdcc0..533b3391b1 100644 --- a/esphome/components/pca9685/pca9685_output.cpp +++ b/esphome/components/pca9685/pca9685_output.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace pca9685 { +namespace esphome::pca9685 { static const char *const TAG = "pca9685"; @@ -160,5 +159,4 @@ void PCA9685Channel::write_state(float state) { this->parent_->set_channel_value_(this->channel_, duty); } -} // namespace pca9685 -} // namespace esphome +} // namespace esphome::pca9685 diff --git a/esphome/components/pca9685/pca9685_output.h b/esphome/components/pca9685/pca9685_output.h index 785cc974da..33819f23ee 100644 --- a/esphome/components/pca9685/pca9685_output.h +++ b/esphome/components/pca9685/pca9685_output.h @@ -4,8 +4,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace pca9685 { +namespace esphome::pca9685 { enum class PhaseBalancer { NONE = 0x00, @@ -76,5 +75,4 @@ class PCA9685Output : public Component, public i2c::I2CDevice { bool update_{true}; }; -} // namespace pca9685 -} // namespace esphome +} // namespace esphome::pca9685 diff --git a/esphome/components/pcd8544/pcd_8544.cpp b/esphome/components/pcd8544/pcd_8544.cpp index 95d91ff18a..c80283ffc9 100644 --- a/esphome/components/pcd8544/pcd_8544.cpp +++ b/esphome/components/pcd8544/pcd_8544.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace pcd8544 { +namespace esphome::pcd8544 { static const char *const TAG = "pcd_8544"; @@ -128,5 +127,4 @@ void PCD8544::fill(Color color) { this->buffer_[i] = fill; } -} // namespace pcd8544 -} // namespace esphome +} // namespace esphome::pcd8544 diff --git a/esphome/components/pcd8544/pcd_8544.h b/esphome/components/pcd8544/pcd_8544.h index cfdb96de61..9e4ee93035 100644 --- a/esphome/components/pcd8544/pcd_8544.h +++ b/esphome/components/pcd8544/pcd_8544.h @@ -4,8 +4,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display_buffer.h" -namespace esphome { -namespace pcd8544 { +namespace esphome::pcd8544 { class PCD8544 : public display::DisplayBuffer, public spi::SPIDevice class ReadAction : public Action, public Parente public: void play(const Ts &...x) override { this->parent_->read_time(); } }; -} // namespace pcf85063 -} // namespace esphome +} // namespace esphome::pcf85063 diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index 50003ca378..93c0f2bdf2 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -4,8 +4,7 @@ // Datasheet: // - https://nl.mouser.com/datasheet/2/302/PCF8563-1127619.pdf -namespace esphome { -namespace pcf8563 { +namespace esphome::pcf8563 { static const char *const TAG = "PCF8563"; @@ -99,5 +98,4 @@ bool PCF8563Component::write_rtc_() { pcf8563_.reg.day, ONOFF(!pcf8563_.reg.stop), pcf8563_.reg.clkout_enabled); return true; } -} // namespace pcf8563 -} // namespace esphome +} // namespace esphome::pcf8563 diff --git a/esphome/components/pcf8563/pcf8563.h b/esphome/components/pcf8563/pcf8563.h index cd37d05816..72b600d9ba 100644 --- a/esphome/components/pcf8563/pcf8563.h +++ b/esphome/components/pcf8563/pcf8563.h @@ -4,8 +4,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/time/real_time_clock.h" -namespace esphome { -namespace pcf8563 { +namespace esphome::pcf8563 { class PCF8563Component : public time::RealTimeClock, public i2c::I2CDevice { public: @@ -119,5 +118,4 @@ template class ReadAction : public Action, public Parente public: void play(const Ts &...x) override { this->parent_->read_time(); } }; -} // namespace pcf8563 -} // namespace esphome +} // namespace esphome::pcf8563 diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 8fe8526797..2e054b0683 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -1,8 +1,7 @@ #include "pcf8574.h" #include "esphome/core/log.h" -namespace esphome { -namespace pcf8574 { +namespace esphome::pcf8574 { static const char *const TAG = "pcf8574"; @@ -131,5 +130,4 @@ size_t PCF8574GPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "%u via PCF8574", this->pin_); } -} // namespace pcf8574 -} // namespace esphome +} // namespace esphome::pcf8574 diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index cae2e930b7..ece472c4bb 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" -namespace esphome { -namespace pcf8574 { +namespace esphome::pcf8574 { // PCF8574(8 pins)/PCF8575(16 pins) always read/write all pins in a single I2C transaction // so we use uint16_t as bank type to ensure all pins are in one bank and cached together @@ -72,5 +71,4 @@ class PCF8574GPIOPin : public GPIOPin { gpio::Flags flags_; }; -} // namespace pcf8574 -} // namespace esphome +} // namespace esphome::pcf8574 diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 00f29983be..e8e9530dba 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -1,8 +1,7 @@ #include "pi4ioe5v6408.h" #include "esphome/core/log.h" -namespace esphome { -namespace pi4ioe5v6408 { +namespace esphome::pi4ioe5v6408 { static const uint8_t PI4IOE5V6408_REGISTER_DEVICE_ID = 0x01; static const uint8_t PI4IOE5V6408_REGISTER_IO_DIR = 0x03; @@ -204,5 +203,4 @@ size_t PI4IOE5V6408GPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "%u via PI4IOE5V6408", this->pin_); } -} // namespace pi4ioe5v6408 -} // namespace esphome +} // namespace esphome::pi4ioe5v6408 diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index ff2474fe99..6225956430 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace pi4ioe5v6408 { +namespace esphome::pi4ioe5v6408 { class PI4IOE5V6408Component : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { @@ -70,5 +69,4 @@ class PI4IOE5V6408GPIOPin : public GPIOPin, public Parented -namespace esphome { -namespace pid { +namespace esphome::pid { class PIDAutotuner { public: @@ -110,5 +109,4 @@ class PIDAutotuner { std::string id_; }; -} // namespace pid -} // namespace esphome +} // namespace esphome::pid diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 54b7a688b4..8c9231fda6 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -1,8 +1,7 @@ #include "pid_climate.h" #include "esphome/core/log.h" -namespace esphome { -namespace pid { +namespace esphome::pid { static const char *const TAG = "pid.climate"; @@ -186,5 +185,4 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { void PIDClimate::reset_integral_term() { this->controller_.reset_accumulated_integral(); } -} // namespace pid -} // namespace esphome +} // namespace esphome::pid diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index 479a0e48ee..9e3c89ca4d 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -9,8 +9,7 @@ #include "pid_controller.h" #include "pid_autotuner.h" -namespace esphome { -namespace pid { +namespace esphome::pid { class PIDClimate : public climate::Climate, public Component { public: @@ -164,5 +163,4 @@ template class PIDSetControlParametersAction : public Action -namespace esphome { -namespace pid { +namespace esphome::pid { struct PIDController { float update(float setpoint, float process_value); @@ -71,5 +70,4 @@ struct PIDController { FixedRingBuffer output_window_; }; // Struct PIDController -} // namespace pid -} // namespace esphome +} // namespace esphome::pid diff --git a/esphome/components/pid/sensor/pid_climate_sensor.cpp b/esphome/components/pid/sensor/pid_climate_sensor.cpp index 41ca027d8d..4e963168e6 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.cpp +++ b/esphome/components/pid/sensor/pid_climate_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace pid { +namespace esphome::pid { static const char *const TAG = "pid.sensor"; @@ -55,5 +54,4 @@ void PIDClimateSensor::update_from_parent_() { } void PIDClimateSensor::dump_config() { LOG_SENSOR("", "PID Climate Sensor", this); } -} // namespace pid -} // namespace esphome +} // namespace esphome::pid diff --git a/esphome/components/pid/sensor/pid_climate_sensor.h b/esphome/components/pid/sensor/pid_climate_sensor.h index f3774610f8..d6bdc66a46 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.h +++ b/esphome/components/pid/sensor/pid_climate_sensor.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/pid/pid_climate.h" -namespace esphome { -namespace pid { +namespace esphome::pid { enum PIDClimateSensorType { PID_SENSOR_TYPE_RESULT, @@ -33,5 +32,4 @@ class PIDClimateSensor : public sensor::Sensor, public Component { PIDClimateSensorType type_; }; -} // namespace pid -} // namespace esphome +} // namespace esphome::pid diff --git a/esphome/components/pipsolar/output/pipsolar_output.cpp b/esphome/components/pipsolar/output/pipsolar_output.cpp index 60f6342759..1af753fce3 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.cpp +++ b/esphome/components/pipsolar/output/pipsolar_output.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace pipsolar { +namespace esphome::pipsolar { static const char *const TAG = "pipsolar.output"; @@ -18,5 +17,4 @@ void PipsolarOutput::write_state(float state) { ESP_LOGD(TAG, "Will not write: %s as it is not in list of allowed values", tmp); } } -} // namespace pipsolar -} // namespace esphome +} // namespace esphome::pipsolar diff --git a/esphome/components/pipsolar/output/pipsolar_output.h b/esphome/components/pipsolar/output/pipsolar_output.h index 66eda8e391..4a6e4c29d7 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.h +++ b/esphome/components/pipsolar/output/pipsolar_output.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace pipsolar { +namespace esphome::pipsolar { class Pipsolar; @@ -40,5 +39,4 @@ template class SetOutputAction : public Action { PipsolarOutput *output_; }; -} // namespace pipsolar -} // namespace esphome +} // namespace esphome::pipsolar diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index 5123d8d9d3..bd5733fe74 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace pipsolar { +namespace esphome::pipsolar { static const char *const TAG = "pipsolar"; @@ -811,5 +810,4 @@ uint16_t Pipsolar::pipsolar_crc_(uint8_t *msg, uint8_t len) { return crc; } -} // namespace pipsolar -} // namespace esphome +} // namespace esphome::pipsolar diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index beae67a4e0..59332080cf 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -9,8 +9,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace pipsolar { +namespace esphome::pipsolar { enum ENUMPollingCommand { POLLING_QPIRI = 0, @@ -246,5 +245,4 @@ class Pipsolar : public uart::UARTDevice, public PollingComponent { PollingCommand enabled_polling_commands_[POLLING_COMMANDS_MAX]; }; -} // namespace pipsolar -} // namespace esphome +} // namespace esphome::pipsolar diff --git a/esphome/components/pipsolar/switch/pipsolar_switch.cpp b/esphome/components/pipsolar/switch/pipsolar_switch.cpp index 512587511b..1eedfed0fd 100644 --- a/esphome/components/pipsolar/switch/pipsolar_switch.cpp +++ b/esphome/components/pipsolar/switch/pipsolar_switch.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace pipsolar { +namespace esphome::pipsolar { static const char *const TAG = "pipsolar.switch"; @@ -15,5 +14,4 @@ void PipsolarSwitch::write_state(bool state) { } } -} // namespace pipsolar -} // namespace esphome +} // namespace esphome::pipsolar diff --git a/esphome/components/pipsolar/switch/pipsolar_switch.h b/esphome/components/pipsolar/switch/pipsolar_switch.h index bb62d4794a..20d2640d90 100644 --- a/esphome/components/pipsolar/switch/pipsolar_switch.h +++ b/esphome/components/pipsolar/switch/pipsolar_switch.h @@ -4,8 +4,7 @@ #include "esphome/components/switch/switch.h" #include "esphome/core/component.h" -namespace esphome { -namespace pipsolar { +namespace esphome::pipsolar { class Pipsolar; class PipsolarSwitch : public switch_::Switch, public Component { public: @@ -24,5 +23,4 @@ class PipsolarSwitch : public switch_::Switch, public Component { Pipsolar *parent_; }; -} // namespace pipsolar -} // namespace esphome +} // namespace esphome::pipsolar diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index fe8890e777..6a325c57dc 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -1,8 +1,7 @@ #include "pm1006.h" #include "esphome/core/log.h" -namespace esphome { -namespace pm1006 { +namespace esphome::pm1006 { static const char *const TAG = "pm1006"; @@ -93,5 +92,4 @@ void PM1006Component::parse_data_() { } } -} // namespace pm1006 -} // namespace esphome +} // namespace esphome::pm1006 diff --git a/esphome/components/pm1006/pm1006.h b/esphome/components/pm1006/pm1006.h index 6b6332e1e3..38ab284f47 100644 --- a/esphome/components/pm1006/pm1006.h +++ b/esphome/components/pm1006/pm1006.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace pm1006 { +namespace esphome::pm1006 { class PM1006Component : public PollingComponent, public uart::UARTDevice { public: @@ -33,5 +32,4 @@ class PM1006Component : public PollingComponent, public uart::UARTDevice { uint32_t last_transmission_{0}; }; -} // namespace pm1006 -} // namespace esphome +} // namespace esphome::pm1006 diff --git a/esphome/components/pm2005/pm2005.cpp b/esphome/components/pm2005/pm2005.cpp index d8e253a771..54a98bf3ad 100644 --- a/esphome/components/pm2005/pm2005.cpp +++ b/esphome/components/pm2005/pm2005.cpp @@ -1,8 +1,7 @@ #include "esphome/core/log.h" #include "pm2005.h" -namespace esphome { -namespace pm2005 { +namespace esphome::pm2005 { static const char *const TAG = "pm2005"; @@ -117,5 +116,4 @@ void PM2005Component::dump_config() { LOG_SENSOR(" ", "PM10 ", this->pm_10_0_sensor_); } -} // namespace pm2005 -} // namespace esphome +} // namespace esphome::pm2005 diff --git a/esphome/components/pm2005/pm2005.h b/esphome/components/pm2005/pm2005.h index e788569b7e..9661d082d1 100644 --- a/esphome/components/pm2005/pm2005.h +++ b/esphome/components/pm2005/pm2005.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace pm2005 { +namespace esphome::pm2005 { enum SensorType { PM2005, @@ -40,5 +39,4 @@ class PM2005Component : public PollingComponent, public i2c::I2CDevice { uint8_t measuring_value_index_{10}; }; -} // namespace pm2005 -} // namespace esphome +} // namespace esphome::pm2005 diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 4a618586f8..15f5d3e879 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace pmsa003i { +namespace esphome::pmsa003i { static const char *const TAG = "pmsa003i"; @@ -131,5 +130,4 @@ bool PMSA003IComponent::read_data_(PM25AQIData *data) { return true; } -} // namespace pmsa003i -} // namespace esphome +} // namespace esphome::pmsa003i diff --git a/esphome/components/pmsa003i/pmsa003i.h b/esphome/components/pmsa003i/pmsa003i.h index cd106704a6..aebe80b711 100644 --- a/esphome/components/pmsa003i/pmsa003i.h +++ b/esphome/components/pmsa003i/pmsa003i.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace pmsa003i { +namespace esphome::pmsa003i { /**! Structure holding Plantower's standard packet **/ // From https://github.com/adafruit/Adafruit_PM25AQI @@ -63,5 +62,4 @@ class PMSA003IComponent : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *pmc_10_0_sensor_{nullptr}; }; -} // namespace pmsa003i -} // namespace esphome +} // namespace esphome::pmsa003i diff --git a/esphome/components/pmwcs3/pmwcs3.cpp b/esphome/components/pmwcs3/pmwcs3.cpp index 2ed7789c53..94c0c30766 100644 --- a/esphome/components/pmwcs3/pmwcs3.cpp +++ b/esphome/components/pmwcs3/pmwcs3.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace pmwcs3 { +namespace esphome::pmwcs3 { static const uint8_t PMWCS3_I2C_ADDRESS = 0x63; static const uint8_t PMWCS3_REG_READ_START = 0x01; @@ -106,5 +105,4 @@ void PMWCS3Component::read_data_() { }); } -} // namespace pmwcs3 -} // namespace esphome +} // namespace esphome::pmwcs3 diff --git a/esphome/components/pmwcs3/pmwcs3.h b/esphome/components/pmwcs3/pmwcs3.h index b1e26eec4f..d669147819 100644 --- a/esphome/components/pmwcs3/pmwcs3.h +++ b/esphome/components/pmwcs3/pmwcs3.h @@ -7,8 +7,7 @@ // ref: // https://github.com/tinovi/i2cArduino/blob/master/i2cArduino.h -namespace esphome { -namespace pmwcs3 { +namespace esphome::pmwcs3 { class PMWCS3Component : public PollingComponent, public i2c::I2CDevice { public: @@ -64,5 +63,4 @@ template class PMWCS3NewI2cAddressAction : public Action PMWCS3Component *parent_; }; -} // namespace pmwcs3 -} // namespace esphome +} // namespace esphome::pmwcs3 diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 3017b78414..8ef7721726 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -9,8 +9,7 @@ // - https://www.nxp.com/docs/en/nxp/application-notes/AN133910.pdf // - https://www.nxp.com/docs/en/nxp/application-notes/153710.pdf -namespace esphome { -namespace pn532 { +namespace esphome::pn532 { static const char *const TAG = "pn532"; @@ -458,5 +457,4 @@ bool PN532BinarySensor::process(const nfc::NfcTagUid &data) { return true; } -} // namespace pn532 -} // namespace esphome +} // namespace esphome::pn532 diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index b76cbb1946..a26f27ed54 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -10,8 +10,7 @@ #include #include -namespace esphome { -namespace pn532 { +namespace esphome::pn532 { static const uint8_t PN532_COMMAND_VERSION_DATA = 0x02; static const uint8_t PN532_COMMAND_SAMCONFIGURATION = 0x14; @@ -138,5 +137,4 @@ template class PN532IsWritingCondition : public Condition bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; -} // namespace pn532 -} // namespace esphome +} // namespace esphome::pn532 diff --git a/esphome/components/pn532/pn532_mifare_classic.cpp b/esphome/components/pn532/pn532_mifare_classic.cpp index cca6acd96d..37674080d8 100644 --- a/esphome/components/pn532/pn532_mifare_classic.cpp +++ b/esphome/components/pn532/pn532_mifare_classic.cpp @@ -4,8 +4,7 @@ #include "pn532.h" #include "esphome/core/log.h" -namespace esphome { -namespace pn532 { +namespace esphome::pn532 { static const char *const TAG = "pn532.mifare_classic"; @@ -258,5 +257,4 @@ bool PN532::write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *mes return true; } -} // namespace pn532 -} // namespace esphome +} // namespace esphome::pn532 diff --git a/esphome/components/pn532/pn532_mifare_ultralight.cpp b/esphome/components/pn532/pn532_mifare_ultralight.cpp index 0e0dc1542f..eb3d13a7e0 100644 --- a/esphome/components/pn532/pn532_mifare_ultralight.cpp +++ b/esphome/components/pn532/pn532_mifare_ultralight.cpp @@ -4,8 +4,7 @@ #include "pn532.h" #include "esphome/core/log.h" -namespace esphome { -namespace pn532 { +namespace esphome::pn532 { static const char *const TAG = "pn532.mifare_ultralight"; @@ -189,5 +188,4 @@ bool PN532::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write return true; } -} // namespace pn532 -} // namespace esphome +} // namespace esphome::pn532 diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 41f0f079aa..7f4d78461b 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.cpp +++ b/esphome/components/pn532_i2c/pn532_i2c.cpp @@ -7,8 +7,7 @@ // - https://www.nxp.com/docs/en/nxp/application-notes/AN133910.pdf // - https://www.nxp.com/docs/en/nxp/application-notes/153710.pdf -namespace esphome { -namespace pn532_i2c { +namespace esphome::pn532_i2c { static const char *const TAG = "pn532_i2c"; @@ -125,5 +124,4 @@ void PN532I2C::dump_config() { LOG_I2C_DEVICE(this); } -} // namespace pn532_i2c -} // namespace esphome +} // namespace esphome::pn532_i2c diff --git a/esphome/components/pn532_i2c/pn532_i2c.h b/esphome/components/pn532_i2c/pn532_i2c.h index 00c0df206d..b2a2ac2e18 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.h +++ b/esphome/components/pn532_i2c/pn532_i2c.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace pn532_i2c { +namespace esphome::pn532_i2c { class PN532I2C : public pn532::PN532, public i2c::I2CDevice { public: @@ -21,5 +20,4 @@ class PN532I2C : public pn532::PN532, public i2c::I2CDevice { uint8_t read_response_length_(); }; -} // namespace pn532_i2c -} // namespace esphome +} // namespace esphome::pn532_i2c diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 553c6d26a6..13d9aebc20 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -7,8 +7,7 @@ // - https://www.nxp.com/docs/en/nxp/application-notes/AN133910.pdf // - https://www.nxp.com/docs/en/nxp/application-notes/153710.pdf -namespace esphome { -namespace pn532_spi { +namespace esphome::pn532_spi { static const char *const TAG = "pn532_spi"; @@ -151,5 +150,4 @@ void PN532Spi::dump_config() { LOG_PIN(" CS Pin: ", this->cs_); } -} // namespace pn532_spi -} // namespace esphome +} // namespace esphome::pn532_spi diff --git a/esphome/components/pn532_spi/pn532_spi.h b/esphome/components/pn532_spi/pn532_spi.h index b7adca22e9..2bfd4accf7 100644 --- a/esphome/components/pn532_spi/pn532_spi.h +++ b/esphome/components/pn532_spi/pn532_spi.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace pn532_spi { +namespace esphome::pn532_spi { class PN532Spi : public pn532::PN532, public spi::SPIDevice &data) override; }; -} // namespace pn532_spi -} // namespace esphome +} // namespace esphome::pn532_spi diff --git a/esphome/components/pn7150/automation.h b/esphome/components/pn7150/automation.h index a8c65ae633..0b2e5f5d24 100644 --- a/esphome/components/pn7150/automation.h +++ b/esphome/components/pn7150/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/components/pn7150/pn7150.h" -namespace esphome { -namespace pn7150 { +namespace esphome::pn7150 { template class PN7150IsWritingCondition : public Condition, public Parented { public: @@ -64,5 +63,4 @@ template class SetWriteModeAction : public Action, public void play(const Ts &...x) override { this->parent_->write_mode(); } }; -} // namespace pn7150 -} // namespace esphome +} // namespace esphome::pn7150 diff --git a/esphome/components/pn7150/pn7150.cpp b/esphome/components/pn7150/pn7150.cpp index d68bea41b3..2a2724f56b 100644 --- a/esphome/components/pn7150/pn7150.cpp +++ b/esphome/components/pn7150/pn7150.cpp @@ -7,8 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace pn7150 { +namespace esphome::pn7150 { static const char *const TAG = "pn7150"; @@ -1160,5 +1159,4 @@ uint8_t PN7150::wait_for_irq_(uint16_t timeout, bool pin_state) { return nfc::STATUS_FAILED; } -} // namespace pn7150 -} // namespace esphome +} // namespace esphome::pn7150 diff --git a/esphome/components/pn7150/pn7150.h b/esphome/components/pn7150/pn7150.h index a468d80943..fa38c5c313 100644 --- a/esphome/components/pn7150/pn7150.h +++ b/esphome/components/pn7150/pn7150.h @@ -11,8 +11,7 @@ #include -namespace esphome { -namespace pn7150 { +namespace esphome::pn7150 { static constexpr uint16_t NFCC_DEFAULT_TIMEOUT = 10; static constexpr uint16_t NFCC_INIT_TIMEOUT = 50; @@ -292,5 +291,4 @@ class PN7150 : public nfc::Nfcc, public Component { std::vector triggers_ontagremoved_; }; -} // namespace pn7150 -} // namespace esphome +} // namespace esphome::pn7150 diff --git a/esphome/components/pn7150/pn7150_mifare_classic.cpp b/esphome/components/pn7150/pn7150_mifare_classic.cpp index 61434cdb28..f1832d95f1 100644 --- a/esphome/components/pn7150/pn7150_mifare_classic.cpp +++ b/esphome/components/pn7150/pn7150_mifare_classic.cpp @@ -4,8 +4,7 @@ #include "pn7150.h" #include "esphome/core/log.h" -namespace esphome { -namespace pn7150 { +namespace esphome::pn7150 { static const char *const TAG = "pn7150.mifare_classic"; @@ -324,5 +323,4 @@ uint8_t PN7150::halt_mifare_classic_tag_() { return nfc::STATUS_OK; } -} // namespace pn7150 -} // namespace esphome +} // namespace esphome::pn7150 diff --git a/esphome/components/pn7150/pn7150_mifare_ultralight.cpp b/esphome/components/pn7150/pn7150_mifare_ultralight.cpp index 854ddd1be1..ef594144d9 100644 --- a/esphome/components/pn7150/pn7150_mifare_ultralight.cpp +++ b/esphome/components/pn7150/pn7150_mifare_ultralight.cpp @@ -5,8 +5,7 @@ #include "pn7150.h" #include "esphome/core/log.h" -namespace esphome { -namespace pn7150 { +namespace esphome::pn7150 { static const char *const TAG = "pn7150.mifare_ultralight"; @@ -183,5 +182,4 @@ uint8_t PN7150::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *w return nfc::STATUS_OK; } -} // namespace pn7150 -} // namespace esphome +} // namespace esphome::pn7150 diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.cpp b/esphome/components/pn7150_i2c/pn7150_i2c.cpp index 4ae884595b..a61bd27c64 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.cpp +++ b/esphome/components/pn7150_i2c/pn7150_i2c.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace pn7150_i2c { +namespace esphome::pn7150_i2c { static const char *const TAG = "pn7150_i2c"; @@ -46,5 +45,4 @@ void PN7150I2C::dump_config() { LOG_I2C_DEVICE(this); } -} // namespace pn7150_i2c -} // namespace esphome +} // namespace esphome::pn7150_i2c diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.h b/esphome/components/pn7150_i2c/pn7150_i2c.h index 9308dddd26..2ea8c8f75c 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.h +++ b/esphome/components/pn7150_i2c/pn7150_i2c.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace pn7150_i2c { +namespace esphome::pn7150_i2c { class PN7150I2C : public pn7150::PN7150, public i2c::I2CDevice { public: @@ -18,5 +17,4 @@ class PN7150I2C : public pn7150::PN7150, public i2c::I2CDevice { uint8_t write_nfcc(nfc::NciMessage &tx) override; }; -} // namespace pn7150_i2c -} // namespace esphome +} // namespace esphome::pn7150_i2c diff --git a/esphome/components/pn7160/automation.h b/esphome/components/pn7160/automation.h index 7759da8f53..7300c4a8d6 100644 --- a/esphome/components/pn7160/automation.h +++ b/esphome/components/pn7160/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/components/pn7160/pn7160.h" -namespace esphome { -namespace pn7160 { +namespace esphome::pn7160 { template class PN7160IsWritingCondition : public Condition, public Parented { public: @@ -64,5 +63,4 @@ template class SetWriteModeAction : public Action, public void play(const Ts &...x) override { this->parent_->write_mode(); } }; -} // namespace pn7160 -} // namespace esphome +} // namespace esphome::pn7160 diff --git a/esphome/components/pn7160/pn7160.cpp b/esphome/components/pn7160/pn7160.cpp index 5f0f8d0629..7abd89b371 100644 --- a/esphome/components/pn7160/pn7160.cpp +++ b/esphome/components/pn7160/pn7160.cpp @@ -7,8 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace pn7160 { +namespace esphome::pn7160 { static const char *const TAG = "pn7160"; @@ -1186,5 +1185,4 @@ uint8_t PN7160::wait_for_irq_(uint16_t timeout, bool pin_state) { return nfc::STATUS_FAILED; } -} // namespace pn7160 -} // namespace esphome +} // namespace esphome::pn7160 diff --git a/esphome/components/pn7160/pn7160.h b/esphome/components/pn7160/pn7160.h index 44f7eb0796..da4577874c 100644 --- a/esphome/components/pn7160/pn7160.h +++ b/esphome/components/pn7160/pn7160.h @@ -11,8 +11,7 @@ #include -namespace esphome { -namespace pn7160 { +namespace esphome::pn7160 { static constexpr uint16_t NFCC_DEFAULT_TIMEOUT = 10; static constexpr uint16_t NFCC_INIT_TIMEOUT = 50; @@ -311,5 +310,4 @@ class PN7160 : public nfc::Nfcc, public Component { std::vector triggers_ontagremoved_; }; -} // namespace pn7160 -} // namespace esphome +} // namespace esphome::pn7160 diff --git a/esphome/components/pn7160/pn7160_mifare_classic.cpp b/esphome/components/pn7160/pn7160_mifare_classic.cpp index 710a7198c6..0dc8bbdfe4 100644 --- a/esphome/components/pn7160/pn7160_mifare_classic.cpp +++ b/esphome/components/pn7160/pn7160_mifare_classic.cpp @@ -4,8 +4,7 @@ #include "pn7160.h" #include "esphome/core/log.h" -namespace esphome { -namespace pn7160 { +namespace esphome::pn7160 { static const char *const TAG = "pn7160.mifare_classic"; @@ -324,5 +323,4 @@ uint8_t PN7160::halt_mifare_classic_tag_() { return nfc::STATUS_OK; } -} // namespace pn7160 -} // namespace esphome +} // namespace esphome::pn7160 diff --git a/esphome/components/pn7160/pn7160_mifare_ultralight.cpp b/esphome/components/pn7160/pn7160_mifare_ultralight.cpp index 8ca0fa2c11..e319a4cb2e 100644 --- a/esphome/components/pn7160/pn7160_mifare_ultralight.cpp +++ b/esphome/components/pn7160/pn7160_mifare_ultralight.cpp @@ -5,8 +5,7 @@ #include "pn7160.h" #include "esphome/core/log.h" -namespace esphome { -namespace pn7160 { +namespace esphome::pn7160 { static const char *const TAG = "pn7160.mifare_ultralight"; @@ -183,5 +182,4 @@ uint8_t PN7160::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *w return nfc::STATUS_OK; } -} // namespace pn7160 -} // namespace esphome +} // namespace esphome::pn7160 diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.cpp b/esphome/components/pn7160_i2c/pn7160_i2c.cpp index e33c6c793d..c34cf90e68 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.cpp +++ b/esphome/components/pn7160_i2c/pn7160_i2c.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace pn7160_i2c { +namespace esphome::pn7160_i2c { static const char *const TAG = "pn7160_i2c"; @@ -46,5 +45,4 @@ void PN7160I2C::dump_config() { LOG_I2C_DEVICE(this); } -} // namespace pn7160_i2c -} // namespace esphome +} // namespace esphome::pn7160_i2c diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.h b/esphome/components/pn7160_i2c/pn7160_i2c.h index eb253085eb..d29fd04fac 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.h +++ b/esphome/components/pn7160_i2c/pn7160_i2c.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace pn7160_i2c { +namespace esphome::pn7160_i2c { class PN7160I2C : public pn7160::PN7160, public i2c::I2CDevice { public: @@ -18,5 +17,4 @@ class PN7160I2C : public pn7160::PN7160, public i2c::I2CDevice { uint8_t write_nfcc(nfc::NciMessage &tx) override; }; -} // namespace pn7160_i2c -} // namespace esphome +} // namespace esphome::pn7160_i2c diff --git a/esphome/components/pn7160_spi/pn7160_spi.cpp b/esphome/components/pn7160_spi/pn7160_spi.cpp index 09f673f700..f3c413e952 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.cpp +++ b/esphome/components/pn7160_spi/pn7160_spi.cpp @@ -1,8 +1,7 @@ #include "pn7160_spi.h" #include "esphome/core/log.h" -namespace esphome { -namespace pn7160_spi { +namespace esphome::pn7160_spi { static const char *const TAG = "pn7160_spi"; @@ -50,5 +49,4 @@ void PN7160Spi::dump_config() { LOG_PIN(" CS Pin: ", this->cs_); } -} // namespace pn7160_spi -} // namespace esphome +} // namespace esphome::pn7160_spi diff --git a/esphome/components/pn7160_spi/pn7160_spi.h b/esphome/components/pn7160_spi/pn7160_spi.h index 9b6e21fa2a..2d9c1fda11 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.h +++ b/esphome/components/pn7160_spi/pn7160_spi.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace pn7160_spi { +namespace esphome::pn7160_spi { static constexpr uint8_t TDD_SPI_READ = 0xFF; static constexpr uint8_t TDD_SPI_WRITE = 0x0A; @@ -26,5 +25,4 @@ class PN7160Spi : public pn7160::PN7160, uint8_t write_nfcc(nfc::NciMessage &tx) override; }; -} // namespace pn7160_spi -} // namespace esphome +} // namespace esphome::pn7160_spi diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index 5db2122412..4da73e76ae 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -1,8 +1,7 @@ #include "power_supply.h" #include "esphome/core/log.h" -namespace esphome { -namespace power_supply { +namespace esphome::power_supply { static const char *const TAG = "power_supply"; @@ -54,5 +53,4 @@ void PowerSupply::on_powerdown() { this->pin_->digital_write(false); } -} // namespace power_supply -} // namespace esphome +} // namespace esphome::power_supply diff --git a/esphome/components/power_supply/power_supply.h b/esphome/components/power_supply/power_supply.h index 0387074eb8..e096f69e3b 100644 --- a/esphome/components/power_supply/power_supply.h +++ b/esphome/components/power_supply/power_supply.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace power_supply { +namespace esphome::power_supply { class PowerSupply : public Component { public: @@ -63,5 +62,4 @@ class PowerSupplyRequester { bool requested_{false}; }; -} // namespace power_supply -} // namespace esphome +} // namespace esphome::power_supply diff --git a/esphome/components/preferences/syncer.h b/esphome/components/preferences/syncer.h index e28cc8c8d5..cee02394b4 100644 --- a/esphome/components/preferences/syncer.h +++ b/esphome/components/preferences/syncer.h @@ -3,8 +3,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/component.h" -namespace esphome { -namespace preferences { +namespace esphome::preferences { class IntervalSyncer final : public Component { public: @@ -25,5 +24,4 @@ class IntervalSyncer final : public Component { #endif }; -} // namespace preferences -} // namespace esphome +} // namespace esphome::preferences diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index e2639a2298..0412d8a842 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -2,8 +2,7 @@ #ifdef USE_NETWORK #include "esphome/core/application.h" -namespace esphome { -namespace prometheus { +namespace esphome::prometheus { void PrometheusHandler::handleRequest(AsyncWebServerRequest *req) { AsyncResponseStream *stream = req->beginResponseStream("text/plain; version=0.0.4; charset=utf-8"); @@ -1098,6 +1097,6 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima } #endif -} // namespace prometheus -} // namespace esphome +} // namespace esphome::prometheus + #endif diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 7aecab99d1..53326e9472 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -12,8 +12,7 @@ #include "esphome/core/log.h" #endif -namespace esphome { -namespace prometheus { +namespace esphome::prometheus { class PrometheusHandler : public AsyncWebHandler, public Component { public: @@ -218,6 +217,6 @@ class PrometheusHandler : public AsyncWebHandler, public Component { std::map relabel_map_name_; }; -} // namespace prometheus -} // namespace esphome +} // namespace esphome::prometheus + #endif diff --git a/esphome/components/psram/psram.cpp b/esphome/components/psram/psram.cpp index 6c110a577d..ab680c9695 100644 --- a/esphome/components/psram/psram.cpp +++ b/esphome/components/psram/psram.cpp @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace psram { +namespace esphome::psram { static const char *const TAG = "psram"; void PsramComponent::dump_config() { @@ -25,7 +24,6 @@ void PsramComponent::dump_config() { } } -} // namespace psram -} // namespace esphome +} // namespace esphome::psram #endif diff --git a/esphome/components/psram/psram.h b/esphome/components/psram/psram.h index 8c891feee9..22a49588b4 100644 --- a/esphome/components/psram/psram.h +++ b/esphome/components/psram/psram.h @@ -4,14 +4,12 @@ #include "esphome/core/component.h" -namespace esphome { -namespace psram { +namespace esphome::psram { class PsramComponent : public Component { void dump_config() override; }; -} // namespace psram -} // namespace esphome +} // namespace esphome::psram #endif diff --git a/esphome/components/pulse_counter/automation.h b/esphome/components/pulse_counter/automation.h index 0c0dc2552d..14264e87b3 100644 --- a/esphome/components/pulse_counter/automation.h +++ b/esphome/components/pulse_counter/automation.h @@ -4,9 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/components/pulse_counter/pulse_counter_sensor.h" -namespace esphome { - -namespace pulse_counter { +namespace esphome::pulse_counter { template class SetTotalPulsesAction : public Action { public: @@ -20,5 +18,4 @@ template class SetTotalPulsesAction : public Action { PulseCounterSensor *pulse_counter_; }; -} // namespace pulse_counter -} // namespace esphome +} // namespace esphome::pulse_counter diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.cpp b/esphome/components/pulse_counter/pulse_counter_sensor.cpp index 5d73bef7da..13bf3baf83 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.cpp +++ b/esphome/components/pulse_counter/pulse_counter_sensor.cpp @@ -7,8 +7,7 @@ #include #endif -namespace esphome { -namespace pulse_counter { +namespace esphome::pulse_counter { static const char *const TAG = "pulse_counter"; @@ -210,5 +209,4 @@ void PulseCounterSensor::update() { this->last_time_ = now; } -} // namespace pulse_counter -} // namespace esphome +} // namespace esphome::pulse_counter diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.h b/esphome/components/pulse_counter/pulse_counter_sensor.h index 7a68858099..4f23ef1548 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.h +++ b/esphome/components/pulse_counter/pulse_counter_sensor.h @@ -14,8 +14,7 @@ #endif // defined(SOC_PCNT_SUPPORTED) && __has_include() #endif // USE_ESP32 -namespace esphome { -namespace pulse_counter { +namespace esphome::pulse_counter { enum PulseCounterCountMode { PULSE_COUNTER_DISABLE = 0, @@ -85,5 +84,4 @@ class PulseCounterSensor : public sensor::Sensor, public PollingComponent { sensor::Sensor *total_sensor_{nullptr}; }; -} // namespace pulse_counter -} // namespace esphome +} // namespace esphome::pulse_counter diff --git a/esphome/components/pulse_meter/automation.h b/esphome/components/pulse_meter/automation.h index bf0768b7af..1def89c3d3 100644 --- a/esphome/components/pulse_meter/automation.h +++ b/esphome/components/pulse_meter/automation.h @@ -4,9 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/components/pulse_meter/pulse_meter_sensor.h" -namespace esphome { - -namespace pulse_meter { +namespace esphome::pulse_meter { template class SetTotalPulsesAction : public Action { public: @@ -20,5 +18,4 @@ template class SetTotalPulsesAction : public Action { PulseMeterSensor *pulse_meter_; }; -} // namespace pulse_meter -} // namespace esphome +} // namespace esphome::pulse_meter diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.cpp b/esphome/components/pulse_meter/pulse_meter_sensor.cpp index 433e1f0b7e..3fe1c722eb 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.cpp +++ b/esphome/components/pulse_meter/pulse_meter_sensor.cpp @@ -2,8 +2,7 @@ #include #include "esphome/core/log.h" -namespace esphome { -namespace pulse_meter { +namespace esphome::pulse_meter { static const char *const TAG = "pulse_meter"; @@ -186,5 +185,4 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) { sensor->last_pin_val_ = pin_val; } -} // namespace pulse_meter -} // namespace esphome +} // namespace esphome::pulse_meter diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.h b/esphome/components/pulse_meter/pulse_meter_sensor.h index e46f1e615f..243a64bf05 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.h +++ b/esphome/components/pulse_meter/pulse_meter_sensor.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace pulse_meter { +namespace esphome::pulse_meter { class PulseMeterSensor : public sensor::Sensor, public Component { public: @@ -77,5 +76,4 @@ class PulseMeterSensor : public sensor::Sensor, public Component { PulseState pulse_state_{}; }; -} // namespace pulse_meter -} // namespace esphome +} // namespace esphome::pulse_meter diff --git a/esphome/components/pulse_width/pulse_width.cpp b/esphome/components/pulse_width/pulse_width.cpp index d083d48b32..5209ed5352 100644 --- a/esphome/components/pulse_width/pulse_width.cpp +++ b/esphome/components/pulse_width/pulse_width.cpp @@ -1,8 +1,7 @@ #include "pulse_width.h" #include "esphome/core/log.h" -namespace esphome { -namespace pulse_width { +namespace esphome::pulse_width { static const char *const TAG = "pulse_width"; @@ -27,5 +26,4 @@ void PulseWidthSensor::update() { this->publish_state(width); } -} // namespace pulse_width -} // namespace esphome +} // namespace esphome::pulse_width diff --git a/esphome/components/pulse_width/pulse_width.h b/esphome/components/pulse_width/pulse_width.h index c6b896988d..f77766a961 100644 --- a/esphome/components/pulse_width/pulse_width.h +++ b/esphome/components/pulse_width/pulse_width.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace pulse_width { +namespace esphome::pulse_width { /// Store data in a class that doesn't use multiple-inheritance (vtables in flash) class PulseWidthSensorStore { @@ -39,5 +38,4 @@ class PulseWidthSensor : public sensor::Sensor, public PollingComponent { InternalGPIOPin *pin_; }; -} // namespace pulse_width -} // namespace esphome +} // namespace esphome::pulse_width diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 4d4a5466bb..7a6be40d6c 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -3,8 +3,8 @@ #include "esphome/core/log.h" #ifdef USE_ESP32 -namespace esphome { -namespace pvvx_mithermometer { + +namespace esphome::pvvx_mithermometer { static const char *const TAG = "display.pvvx_mithermometer"; @@ -186,7 +186,6 @@ void PVVXDisplay::sync_time_() { } #endif -} // namespace pvvx_mithermometer -} // namespace esphome +} // namespace esphome::pvvx_mithermometer #endif diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index 06837b94ab..e1aebae7a5 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -13,8 +13,7 @@ #include "esphome/components/time/real_time_clock.h" #endif -namespace esphome { -namespace pvvx_mithermometer { +namespace esphome::pvvx_mithermometer { class PVVXDisplay; @@ -130,7 +129,6 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { pvvx_writer_t writer_{}; }; -} // namespace pvvx_mithermometer -} // namespace esphome +} // namespace esphome::pvvx_mithermometer #endif diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp index 35badf48bb..f674fc3694 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace pvvx_mithermometer { +namespace esphome::pvvx_mithermometer { static const char *const TAG = "pvvx_mithermometer"; @@ -140,7 +139,6 @@ bool PVVXMiThermometer::report_results_(const optional &result, con return true; } -} // namespace pvvx_mithermometer -} // namespace esphome +} // namespace esphome::pvvx_mithermometer #endif diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h index 09b5e91a16..b5d6da21ef 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace pvvx_mithermometer { +namespace esphome::pvvx_mithermometer { struct ParseResult { optional temperature; @@ -46,7 +45,6 @@ class PVVXMiThermometer : public Component, public esp32_ble_tracker::ESPBTDevic bool report_results_(const optional &result, const char *address); }; -} // namespace pvvx_mithermometer -} // namespace esphome +} // namespace esphome::pvvx_mithermometer #endif diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 7eb89d5b32..0973699da8 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -24,8 +24,7 @@ } \ } -namespace esphome { -namespace pylontech { +namespace esphome::pylontech { static const char *const TAG = "pylontech"; static const int MAX_DATA_LENGTH_BYTES = 256; @@ -198,8 +197,7 @@ void PylontechComponent::process_line_(std::string &buffer) { } } -} // namespace pylontech -} // namespace esphome +} // namespace esphome::pylontech #undef PARSE_INT #undef PARSE_STR diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index 5727928a60..1d86803cc2 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -4,8 +4,7 @@ #include "esphome/core/defines.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace pylontech { +namespace esphome::pylontech { static const uint8_t NUM_BUFFERS = 20; static const uint8_t TEXT_SENSOR_MAX_LEN = 14; @@ -48,5 +47,4 @@ class PylontechComponent : public PollingComponent, public uart::UARTDevice { std::vector listeners_{}; }; -} // namespace pylontech -} // namespace esphome +} // namespace esphome::pylontech diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index 11437369ed..e2def28be5 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace pylontech { +namespace esphome::pylontech { static const char *const TAG = "pylontech.sensor"; @@ -58,5 +57,4 @@ void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { } } -} // namespace pylontech -} // namespace esphome +} // namespace esphome::pylontech diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 25e71606a4..36576e8332 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -3,8 +3,7 @@ #include "../pylontech.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace pylontech { +namespace esphome::pylontech { class PylontechSensor : public PylontechListener { public: @@ -28,5 +27,4 @@ class PylontechSensor : public PylontechListener { int8_t bat_num_; }; -} // namespace pylontech -} // namespace esphome +} // namespace esphome::pylontech diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp index 8175477cb2..a7c9db1599 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace pylontech { +namespace esphome::pylontech { static const char *const TAG = "pylontech.textsensor"; @@ -38,5 +37,4 @@ void PylontechTextSensor::on_line_read(PylontechListener::LineContents *line) { } } -} // namespace pylontech -} // namespace esphome +} // namespace esphome::pylontech diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h index 27a3993b3e..30921b13f4 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h @@ -3,8 +3,7 @@ #include "../pylontech.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace pylontech { +namespace esphome::pylontech { class PylontechTextSensor : public PylontechListener { public: @@ -22,5 +21,4 @@ class PylontechTextSensor : public PylontechListener { int8_t bat_num_; }; -} // namespace pylontech -} // namespace esphome +} // namespace esphome::pylontech diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index d0f96d6d1e..a28b448340 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -3,8 +3,7 @@ #include "esphome/core/application.h" #include -namespace esphome { -namespace pzem004t { +namespace esphome::pzem004t { static const char *const TAG = "pzem004t"; @@ -126,5 +125,4 @@ void PZEM004T::dump_config() { LOG_SENSOR("", "Power", this->power_sensor_); } -} // namespace pzem004t -} // namespace esphome +} // namespace esphome::pzem004t diff --git a/esphome/components/pzem004t/pzem004t.h b/esphome/components/pzem004t/pzem004t.h index e18413f35c..71fc1e70ad 100644 --- a/esphome/components/pzem004t/pzem004t.h +++ b/esphome/components/pzem004t/pzem004t.h @@ -4,8 +4,7 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace pzem004t { +namespace esphome::pzem004t { class PZEM004T : public PollingComponent, public uart::UARTDevice { public: @@ -42,5 +41,4 @@ class PZEM004T : public PollingComponent, public uart::UARTDevice { uint32_t last_read_{0}; }; -} // namespace pzem004t -} // namespace esphome +} // namespace esphome::pzem004t diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 0dbe0e761d..d36e5d0250 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -1,8 +1,7 @@ #include "pzemac.h" #include "esphome/core/log.h" -namespace esphome { -namespace pzemac { +namespace esphome::pzemac { static const char *const TAG = "pzemac"; @@ -83,5 +82,4 @@ void PZEMAC::reset_energy_() { this->send_raw(cmd); } -} // namespace pzemac -} // namespace esphome +} // namespace esphome::pzemac diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index e5b96115f9..264604fedc 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace pzemac { +namespace esphome::pzemac { template class ResetEnergyAction; @@ -49,5 +48,4 @@ template class ResetEnergyAction : public Action { PZEMAC *pzemac_; }; -} // namespace pzemac -} // namespace esphome +} // namespace esphome::pzemac diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index 428bcc1fcf..6ded9b3a34 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -1,8 +1,7 @@ #include "pzemdc.h" #include "esphome/core/log.h" -namespace esphome { -namespace pzemdc { +namespace esphome::pzemdc { static const char *const TAG = "pzemdc"; @@ -71,5 +70,4 @@ void PZEMDC::reset_energy() { this->send_raw(cmd); } -} // namespace pzemdc -} // namespace esphome +} // namespace esphome::pzemdc diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index 2e6c26a10c..6a7e840448 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace pzemdc { +namespace esphome::pzemdc { class PZEMDC : public PollingComponent, public modbus::ModbusDevice { public: @@ -42,5 +41,4 @@ template class ResetEnergyAction : public Action { PZEMDC *pzemdc_; }; -} // namespace pzemdc -} // namespace esphome +} // namespace esphome::pzemdc diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index 44bd006c1a..5b04a904b5 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace qmc5883l { +namespace esphome::qmc5883l { static const char *const TAG = "qmc5883l"; @@ -213,5 +212,4 @@ i2c::ErrorCode QMC5883LComponent::read_bytes_16_le_(uint8_t a_register, uint16_t return err; } -} // namespace qmc5883l -} // namespace esphome +} // namespace esphome::qmc5883l diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index 2ab6aa3e9f..6b8ffa0f40 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -5,8 +5,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/hal.h" -namespace esphome { -namespace qmc5883l { +namespace esphome::qmc5883l { enum QMC5883LDatarate { QMC5883L_DATARATE_10_HZ = 0b00, @@ -66,5 +65,4 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { HighFrequencyLoopRequester high_freq_; }; -} // namespace qmc5883l -} // namespace esphome +} // namespace esphome::qmc5883l diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 976efe7910..8c8a04c5b7 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -3,8 +3,7 @@ #include #include -namespace esphome { -namespace qmp6988 { +namespace esphome::qmp6988 { static const uint8_t QMP6988_CHIP_ID = 0x5C; @@ -351,5 +350,4 @@ void QMP6988Component::update() { this->pressure_sensor_->publish_state(pressurehectopascals); } -} // namespace qmp6988 -} // namespace esphome +} // namespace esphome::qmp6988 diff --git a/esphome/components/qmp6988/qmp6988.h b/esphome/components/qmp6988/qmp6988.h index 5b0f80c77e..26f858b5d2 100644 --- a/esphome/components/qmp6988/qmp6988.h +++ b/esphome/components/qmp6988/qmp6988.h @@ -7,8 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace qmp6988 { +namespace esphome::qmp6988 { /* oversampling */ enum QMP6988Oversampling : uint8_t { @@ -106,5 +105,4 @@ class QMP6988Component : public PollingComponent, public i2c::I2CDevice { int16_t get_compensated_temperature_(qmp6988_ik_data_t *ik, int32_t dt); }; -} // namespace qmp6988 -} // namespace esphome +} // namespace esphome::qmp6988 diff --git a/esphome/components/qr_code/qr_code.cpp b/esphome/components/qr_code/qr_code.cpp index 0322c8a141..edb78b98e1 100644 --- a/esphome/components/qr_code/qr_code.cpp +++ b/esphome/components/qr_code/qr_code.cpp @@ -3,8 +3,7 @@ #include "esphome/core/color.h" #include "esphome/core/log.h" -namespace esphome { -namespace qr_code { +namespace esphome::qr_code { static const char *const TAG = "qr_code"; @@ -74,5 +73,4 @@ uint8_t QrCode::get_size() { return size; } -} // namespace qr_code -} // namespace esphome +} // namespace esphome::qr_code diff --git a/esphome/components/qwiic_pir/qwiic_pir.cpp b/esphome/components/qwiic_pir/qwiic_pir.cpp index c04c0fcc18..baf8dc122d 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.cpp +++ b/esphome/components/qwiic_pir/qwiic_pir.cpp @@ -1,8 +1,7 @@ #include "qwiic_pir.h" #include "esphome/core/log.h" -namespace esphome { -namespace qwiic_pir { +namespace esphome::qwiic_pir { static const char *const TAG = "qwiic_pir"; @@ -129,5 +128,4 @@ void QwiicPIRComponent::clear_events_() { ESP_LOGW(TAG, "Failed to clear events"); } -} // namespace qwiic_pir -} // namespace esphome +} // namespace esphome::qwiic_pir diff --git a/esphome/components/qwiic_pir/qwiic_pir.h b/esphome/components/qwiic_pir/qwiic_pir.h index 797ded2cc6..339632a508 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.h +++ b/esphome/components/qwiic_pir/qwiic_pir.h @@ -12,8 +12,7 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace qwiic_pir { +namespace esphome::qwiic_pir { // Qwiic PIR I2C Register Addresses enum { @@ -65,5 +64,4 @@ class QwiicPIRComponent : public Component, public i2c::I2CDevice, public binary void clear_events_(); }; -} // namespace qwiic_pir -} // namespace esphome +} // namespace esphome::qwiic_pir diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.cpp b/esphome/components/radon_eye_ble/radon_eye_listener.cpp index 2c3ef77add..7e7263d73f 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.cpp +++ b/esphome/components/radon_eye_ble/radon_eye_listener.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace radon_eye_ble { +namespace esphome::radon_eye_ble { static const char *const TAG = "radon_eye_ble"; @@ -19,7 +18,6 @@ bool RadonEyeListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device return false; } -} // namespace radon_eye_ble -} // namespace esphome +} // 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 26d0233c56..ceca736e78 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.h +++ b/esphome/components/radon_eye_ble/radon_eye_listener.h @@ -5,15 +5,13 @@ #include "esphome/core/component.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" -namespace esphome { -namespace radon_eye_ble { +namespace esphome::radon_eye_ble { class RadonEyeListener : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; -} // namespace radon_eye_ble -} // namespace esphome +} // namespace esphome::radon_eye_ble #endif diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp index f2d32d51de..de5bd3d8d5 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp @@ -5,8 +5,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace radon_eye_rd200 { +namespace esphome::radon_eye_rd200 { static const char *const TAG = "radon_eye_rd200"; @@ -211,7 +210,6 @@ void RadonEyeRD200::dump_config() { RadonEyeRD200::RadonEyeRD200() : PollingComponent(10000) {} -} // namespace radon_eye_rd200 -} // namespace esphome +} // namespace esphome::radon_eye_rd200 #endif // USE_ESP32 diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h index f874c815f8..48e075c2d6 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.h +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -11,8 +11,7 @@ #include "esphome/core/component.h" #include "esphome/core/log.h" -namespace esphome { -namespace radon_eye_rd200 { +namespace esphome::radon_eye_rd200 { class RadonEyeRD200 : public PollingComponent, public ble_client::BLEClientNode { public: @@ -41,7 +40,6 @@ class RadonEyeRD200 : public PollingComponent, public ble_client::BLEClientNode esp32_ble_tracker::ESPBTUUID sensors_read_characteristic_uuid_; }; -} // namespace radon_eye_rd200 -} // namespace esphome +} // namespace esphome::radon_eye_rd200 #endif // USE_ESP32 diff --git a/esphome/components/rc522/rc522.cpp b/esphome/components/rc522/rc522.cpp index c5f7ec2cd4..7c1b6ae314 100644 --- a/esphome/components/rc522/rc522.cpp +++ b/esphome/components/rc522/rc522.cpp @@ -5,8 +5,7 @@ // Based on: // - https://github.com/miguelbalboa/rfid -namespace esphome { -namespace rc522 { +namespace esphome::rc522 { static const uint8_t WAIT_I_RQ = 0x30; // RxIRq and IdleIRq @@ -498,5 +497,4 @@ void RC522Trigger::process(std::vector &data) { this->trigger(format_hex_pretty_to(uid_buf, data.data(), data.size(), '-')); } -} // namespace rc522 -} // namespace esphome +} // namespace esphome::rc522 diff --git a/esphome/components/rc522/rc522.h b/esphome/components/rc522/rc522.h index 437cea808b..45473e04b0 100644 --- a/esphome/components/rc522/rc522.h +++ b/esphome/components/rc522/rc522.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace rc522 { +namespace esphome::rc522 { class RC522BinarySensor; class RC522Trigger; @@ -275,5 +274,4 @@ class RC522Trigger : public Trigger { void process(std::vector &data); }; -} // namespace rc522 -} // namespace esphome +} // namespace esphome::rc522 diff --git a/esphome/components/rc522_i2c/rc522_i2c.cpp b/esphome/components/rc522_i2c/rc522_i2c.cpp index 6a3d8d2486..dbc86ff8e7 100644 --- a/esphome/components/rc522_i2c/rc522_i2c.cpp +++ b/esphome/components/rc522_i2c/rc522_i2c.cpp @@ -1,8 +1,7 @@ #include "rc522_i2c.h" #include "esphome/core/log.h" -namespace esphome { -namespace rc522_i2c { +namespace esphome::rc522_i2c { static const char *const TAG = "rc522_i2c"; @@ -66,5 +65,4 @@ void RC522I2C::pcd_write_register(PcdRegister reg, ///< The register to write t write_bytes(reg >> 1, values, count); } -} // namespace rc522_i2c -} // namespace esphome +} // namespace esphome::rc522_i2c diff --git a/esphome/components/rc522_i2c/rc522_i2c.h b/esphome/components/rc522_i2c/rc522_i2c.h index 8d8b0a0716..bd6f2269d8 100644 --- a/esphome/components/rc522_i2c/rc522_i2c.h +++ b/esphome/components/rc522_i2c/rc522_i2c.h @@ -4,8 +4,7 @@ #include "esphome/components/rc522/rc522.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace rc522_i2c { +namespace esphome::rc522_i2c { class RC522I2C : public rc522::RC522, public i2c::I2CDevice { public: @@ -38,5 +37,4 @@ class RC522I2C : public rc522::RC522, public i2c::I2CDevice { ) override; }; -} // namespace rc522_i2c -} // namespace esphome +} // namespace esphome::rc522_i2c diff --git a/esphome/components/rc522_spi/rc522_spi.cpp b/esphome/components/rc522_spi/rc522_spi.cpp index 40da449814..b63ad1cfdc 100644 --- a/esphome/components/rc522_spi/rc522_spi.cpp +++ b/esphome/components/rc522_spi/rc522_spi.cpp @@ -5,8 +5,7 @@ // Based on: // - https://github.com/miguelbalboa/rfid -namespace esphome { -namespace rc522_spi { +namespace esphome::rc522_spi { static const char *const TAG = "rc522_spi"; @@ -136,5 +135,4 @@ void RC522Spi::pcd_write_register(PcdRegister reg, ///< The register to write t ESP_LOGVV(TAG, "write_register_(%d, %d) -> %s", reg, count, buf.c_str()); } -} // namespace rc522_spi -} // namespace esphome +} // namespace esphome::rc522_spi diff --git a/esphome/components/rc522_spi/rc522_spi.h b/esphome/components/rc522_spi/rc522_spi.h index 0ccbcd7588..54caf5c117 100644 --- a/esphome/components/rc522_spi/rc522_spi.h +++ b/esphome/components/rc522_spi/rc522_spi.h @@ -4,7 +4,6 @@ #include "esphome/components/rc522/rc522.h" #include "esphome/components/spi/spi.h" -namespace esphome { /** * Library based on https://github.com/miguelbalboa/rfid * and adapted to ESPHome by @glmnet @@ -13,7 +12,7 @@ namespace esphome { * * */ -namespace rc522_spi { +namespace esphome::rc522_spi { class RC522Spi : public rc522::RC522, public spi::SPIDevice #include -namespace esphome { -namespace rdm6300 { +namespace esphome::rdm6300 { class RDM6300BinarySensor; class RDM6300Trigger; @@ -52,5 +51,4 @@ class RDM6300Trigger : public Trigger { void process(uint32_t uid) { this->trigger(uid); } }; -} // namespace rdm6300 -} // namespace esphome +} // namespace esphome::rdm6300 diff --git a/esphome/components/remote_base/abbwelcome_protocol.cpp b/esphome/components/remote_base/abbwelcome_protocol.cpp index a67ca48dbe..2000148ca8 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.cpp +++ b/esphome/components/remote_base/abbwelcome_protocol.cpp @@ -1,8 +1,7 @@ #include "abbwelcome_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.abbwelcome"; @@ -123,5 +122,4 @@ void ABBWelcomeProtocol::dump(const ABBWelcomeData &data) { ESP_LOGD(TAG, "Received ABBWelcome: %s", data.format_to(buf)); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 66664a89f3..7ff32923be 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static constexpr uint8_t MAX_DATA_LENGTH = 15; static constexpr uint8_t DATA_LENGTH_MASK = 0x3f; @@ -272,5 +271,4 @@ template class ABBWelcomeAction : public RemoteTransmitterAction } data_; }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp index f40cff7623..69f91ba90e 100644 --- a/esphome/components/remote_base/aeha_protocol.cpp +++ b/esphome/components/remote_base/aeha_protocol.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.aeha"; @@ -98,5 +97,4 @@ void AEHAProtocol::dump(const AEHAData &data) { ESP_LOGI(TAG, "Received AEHA: address=0x%04X, data=[%s]", data.address, data_str.c_str()); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 51718eefcb..3f4e98bd43 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct AEHAData { uint16_t address; @@ -42,5 +41,4 @@ template class AEHAAction : public RemoteTransmitterActionBase -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.beo4"; @@ -149,5 +148,4 @@ void Beo4Protocol::dump(const Beo4Data &data) { ESP_LOGI(TAG, "Beo4: source=0x%02x command=0x%02x repeats=%d ", data.source, data.command, data.repeats); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/beo4_protocol.h b/esphome/components/remote_base/beo4_protocol.h index 445e792cbc..30b99dbeb7 100644 --- a/esphome/components/remote_base/beo4_protocol.h +++ b/esphome/components/remote_base/beo4_protocol.h @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct Beo4Data { uint8_t source; // beoSource, e.g. video, audio, light... @@ -39,5 +38,4 @@ template class Beo4Action : public RemoteTransmitterActionBase -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.byronsx"; @@ -135,5 +134,4 @@ void ByronSXProtocol::dump(const ByronSXData &data) { ESP_LOGD(TAG, "Received ByronSX: address=0x%08X, command=0x%02x", data.address, data.command); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/byronsx_protocol.h b/esphome/components/remote_base/byronsx_protocol.h index 5d23237ab1..674fa99ea1 100644 --- a/esphome/components/remote_base/byronsx_protocol.h +++ b/esphome/components/remote_base/byronsx_protocol.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct ByronSXData { uint8_t address; @@ -42,5 +41,4 @@ template class ByronSXAction : public RemoteTransmitterActionBas } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/canalsat_protocol.cpp b/esphome/components/remote_base/canalsat_protocol.cpp index 1468b66939..eafa98ebcc 100644 --- a/esphome/components/remote_base/canalsat_protocol.cpp +++ b/esphome/components/remote_base/canalsat_protocol.cpp @@ -1,8 +1,7 @@ #include "canalsat_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const CANALSAT_TAG = "remote.canalsat"; static const char *const CANALSATLD_TAG = "remote.canalsatld"; @@ -104,5 +103,4 @@ void CanalSatBaseProtocol::dump(const CanalSatData &data) { } } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/canalsat_protocol.h b/esphome/components/remote_base/canalsat_protocol.h index 180989ef99..5ba9115ea8 100644 --- a/esphome/components/remote_base/canalsat_protocol.h +++ b/esphome/components/remote_base/canalsat_protocol.h @@ -2,8 +2,7 @@ #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct CanalSatData { uint8_t device : 7; @@ -74,5 +73,4 @@ template class CanalSatLDAction : public RemoteTransmitterAction } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/coolix_protocol.cpp b/esphome/components/remote_base/coolix_protocol.cpp index 21a9f598b7..53e1d59f13 100644 --- a/esphome/components/remote_base/coolix_protocol.cpp +++ b/esphome/components/remote_base/coolix_protocol.cpp @@ -1,8 +1,7 @@ #include "coolix_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.coolix"; @@ -109,5 +108,4 @@ void CoolixProtocol::dump(const CoolixData &data) { } } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/coolix_protocol.h b/esphome/components/remote_base/coolix_protocol.h index b66415ff70..d9441e8417 100644 --- a/esphome/components/remote_base/coolix_protocol.h +++ b/esphome/components/remote_base/coolix_protocol.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct CoolixData { CoolixData() {} @@ -37,5 +36,4 @@ template class CoolixAction : public RemoteTransmitterActionBase } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dish_protocol.cpp b/esphome/components/remote_base/dish_protocol.cpp index 69226101bf..9a6420afd5 100644 --- a/esphome/components/remote_base/dish_protocol.cpp +++ b/esphome/components/remote_base/dish_protocol.cpp @@ -1,8 +1,7 @@ #include "dish_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.dish"; @@ -90,5 +89,4 @@ void DishProtocol::dump(const DishData &data) { ESP_LOGI(TAG, "Received Dish: address=0x%02X, command=0x%02X", data.address, data.command); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dish_protocol.h b/esphome/components/remote_base/dish_protocol.h index ca4d04ed34..c89f4e78e1 100644 --- a/esphome/components/remote_base/dish_protocol.h +++ b/esphome/components/remote_base/dish_protocol.h @@ -2,8 +2,7 @@ #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct DishData { uint8_t address; @@ -34,5 +33,4 @@ template class DishAction : public RemoteTransmitterActionBase -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct DooyaData { uint32_t id; @@ -45,5 +44,4 @@ template class DooyaAction : public RemoteTransmitterActionBase< } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/drayton_protocol.cpp b/esphome/components/remote_base/drayton_protocol.cpp index 946bd9cacb..2261bd04e9 100644 --- a/esphome/components/remote_base/drayton_protocol.cpp +++ b/esphome/components/remote_base/drayton_protocol.cpp @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.drayton"; @@ -236,5 +235,4 @@ void DraytonProtocol::dump(const DraytonData &data) { ((data.address << 1) & 0xffff), data.channel, data.command); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index 75213b9186..693a1bbe85 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct DraytonData { uint16_t address; @@ -42,5 +41,4 @@ template class DraytonAction : public RemoteTransmitterActionBas } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dyson_protocol.cpp b/esphome/components/remote_base/dyson_protocol.cpp index db4e1135f4..942b40d26f 100644 --- a/esphome/components/remote_base/dyson_protocol.cpp +++ b/esphome/components/remote_base/dyson_protocol.cpp @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.dyson"; @@ -67,5 +66,4 @@ void DysonProtocol::dump(const DysonData &data) { ESP_LOGI(TAG, "Dyson: code=0x%x rolling index=%d", data.code, data.index); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h index d1c08fefba..3473a489b2 100644 --- a/esphome/components/remote_base/dyson_protocol.h +++ b/esphome/components/remote_base/dyson_protocol.h @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static constexpr uint8_t IGNORE_INDEX = 0xFF; @@ -42,5 +41,4 @@ template class DysonAction : public RemoteTransmitterActionBase< } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/gobox_protocol.cpp b/esphome/components/remote_base/gobox_protocol.cpp index 0e1617659d..1d67be86b8 100644 --- a/esphome/components/remote_base/gobox_protocol.cpp +++ b/esphome/components/remote_base/gobox_protocol.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.gobox"; @@ -128,5 +127,4 @@ void GoboxProtocol::dump(const GoboxData &data) { } } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index 7e18b61458..f6b278771e 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct GoboxData { int code; @@ -50,5 +49,4 @@ template class GoboxAction : public RemoteTransmitterActionBase< } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index 734f3c7789..fa4cec773f 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.haier"; @@ -84,5 +83,4 @@ void HaierProtocol::dump(const HaierData &data) { ESP_LOGI(TAG, "Received Haier: %s", format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index 7a4ee640e8..9c45ba1a63 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -3,8 +3,7 @@ #include "remote_base.h" #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct HaierData { std::vector data; @@ -35,5 +34,4 @@ template class HaierAction : public RemoteTransmitterActionBase< } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/jvc_protocol.cpp b/esphome/components/remote_base/jvc_protocol.cpp index c33cae7a48..86a47e757d 100644 --- a/esphome/components/remote_base/jvc_protocol.cpp +++ b/esphome/components/remote_base/jvc_protocol.cpp @@ -1,8 +1,7 @@ #include "jvc_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.jvc"; @@ -48,5 +47,4 @@ optional JVCProtocol::decode(RemoteReceiveData src) { } void JVCProtocol::dump(const JVCData &data) { ESP_LOGI(TAG, "Received JVC: data=0x%04" PRIX32, data.data); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/jvc_protocol.h b/esphome/components/remote_base/jvc_protocol.h index a17e593ad2..f6e2548dea 100644 --- a/esphome/components/remote_base/jvc_protocol.h +++ b/esphome/components/remote_base/jvc_protocol.h @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct JVCData { uint32_t data; @@ -33,5 +32,4 @@ template class JVCAction : public RemoteTransmitterActionBase -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.keeloq"; @@ -190,5 +189,4 @@ void KeeloqProtocol::dump(const KeeloqData &data) { ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x", data.address, data.command); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 47125c151b..432313b87b 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct KeeloqData { uint32_t encrypted; // 32 bit encrypted field @@ -49,5 +48,4 @@ template class KeeloqAction : public RemoteTransmitterActionBase } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/lg_protocol.cpp b/esphome/components/remote_base/lg_protocol.cpp index 4c54ff00bd..e450659b42 100644 --- a/esphome/components/remote_base/lg_protocol.cpp +++ b/esphome/components/remote_base/lg_protocol.cpp @@ -1,8 +1,7 @@ #include "lg_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.lg"; @@ -54,5 +53,4 @@ void LGProtocol::dump(const LGData &data) { ESP_LOGI(TAG, "Received LG: data=0x%08" PRIX32 ", nbits=%d", data.data, data.nbits); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/lg_protocol.h b/esphome/components/remote_base/lg_protocol.h index e0039d033d..9715974995 100644 --- a/esphome/components/remote_base/lg_protocol.h +++ b/esphome/components/remote_base/lg_protocol.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct LGData { uint32_t data; @@ -37,5 +36,4 @@ template class LGAction : public RemoteTransmitterActionBase class MagiQuestAction : public RemoteTransmitterActionB } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/midea_protocol.cpp b/esphome/components/remote_base/midea_protocol.cpp index 4fa717cf08..6889c5d9b4 100644 --- a/esphome/components/remote_base/midea_protocol.cpp +++ b/esphome/components/remote_base/midea_protocol.cpp @@ -1,8 +1,7 @@ #include "midea_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.midea"; @@ -75,5 +74,4 @@ void MideaProtocol::dump(const MideaData &data) { ESP_LOGI(TAG, "Received Midea: %s", data.to_str(buf)); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 334e8a7cb3..f21dd40828 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -7,8 +7,7 @@ #include "esphome/core/helpers.h" #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { class MideaData { public: @@ -88,5 +87,4 @@ template class MideaAction : public RemoteTransmitterActionBase< } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/mirage_protocol.cpp b/esphome/components/remote_base/mirage_protocol.cpp index 2ae877f193..380cfaecb2 100644 --- a/esphome/components/remote_base/mirage_protocol.cpp +++ b/esphome/components/remote_base/mirage_protocol.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.mirage"; @@ -85,5 +84,4 @@ void MirageProtocol::dump(const MirageData &data) { ESP_LOGI(TAG, "Received Mirage: %s", format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index 4257f7fa00..c967e72f13 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct MirageData { std::vector data; @@ -35,5 +34,4 @@ template class MirageAction : public RemoteTransmitterActionBase } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nec_protocol.cpp b/esphome/components/remote_base/nec_protocol.cpp index 062f81b4d6..e639248b4e 100644 --- a/esphome/components/remote_base/nec_protocol.cpp +++ b/esphome/components/remote_base/nec_protocol.cpp @@ -1,8 +1,7 @@ #include "nec_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.nec"; @@ -98,5 +97,4 @@ void NECProtocol::dump(const NECData &data) { data.command_repeats); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nec_protocol.h b/esphome/components/remote_base/nec_protocol.h index 71e1bccba8..7b310e8ba5 100644 --- a/esphome/components/remote_base/nec_protocol.h +++ b/esphome/components/remote_base/nec_protocol.h @@ -2,8 +2,7 @@ #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct NECData { uint16_t address; @@ -37,5 +36,4 @@ template class NECAction : public RemoteTransmitterActionBase -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct NexaData { uint32_t device; @@ -50,5 +49,4 @@ template class NexaAction : public RemoteTransmitterActionBase -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct PanasonicData { uint16_t address; @@ -37,5 +36,4 @@ template class PanasonicAction : public RemoteTransmitterActionB } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_protocol.cpp b/esphome/components/remote_base/pioneer_protocol.cpp index f350ef66ae..f4d6aa4026 100644 --- a/esphome/components/remote_base/pioneer_protocol.cpp +++ b/esphome/components/remote_base/pioneer_protocol.cpp @@ -1,8 +1,7 @@ #include "pioneer_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.pioneer"; @@ -152,5 +151,4 @@ void PioneerProtocol::dump(const PioneerData &data) { } } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_protocol.h b/esphome/components/remote_base/pioneer_protocol.h index 4cac4f9f32..514ab67501 100644 --- a/esphome/components/remote_base/pioneer_protocol.h +++ b/esphome/components/remote_base/pioneer_protocol.h @@ -2,8 +2,7 @@ #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct PioneerData { uint16_t rc_code_1; @@ -34,5 +33,4 @@ template class PioneerAction : public RemoteTransmitterActionBas } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pronto_protocol.cpp b/esphome/components/remote_base/pronto_protocol.cpp index 6903cd4605..dc128d4622 100644 --- a/esphome/components/remote_base/pronto_protocol.cpp +++ b/esphome/components/remote_base/pronto_protocol.cpp @@ -35,8 +35,7 @@ #include "pronto_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.pronto"; @@ -243,5 +242,4 @@ void ProntoProtocol::dump(const ProntoData &data) { } while (remaining > 0); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index e600834d1a..f4f6b2144d 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { std::vector encode_pronto(const std::string &str); @@ -51,5 +50,4 @@ template class ProntoAction : public RemoteTransmitterActionBase } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/raw_protocol.cpp b/esphome/components/remote_base/raw_protocol.cpp index 7e6be3b77e..02c2916849 100644 --- a/esphome/components/remote_base/raw_protocol.cpp +++ b/esphome/components/remote_base/raw_protocol.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.raw"; @@ -38,5 +37,4 @@ bool RawDumper::dump(RemoteReceiveData src) { return true; } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/raw_protocol.h b/esphome/components/remote_base/raw_protocol.h index 941b6aab42..1bcf390b62 100644 --- a/esphome/components/remote_base/raw_protocol.h +++ b/esphome/components/remote_base/raw_protocol.h @@ -6,8 +6,7 @@ #include #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { class RawBinarySensor : public RemoteReceiverBinarySensorBase { public: @@ -82,5 +81,4 @@ class RawDumper : public RemoteReceiverDumperBase { bool is_secondary() override { return true; } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5_protocol.cpp b/esphome/components/remote_base/rc5_protocol.cpp index bb6d382d80..c7f79ad84a 100644 --- a/esphome/components/remote_base/rc5_protocol.cpp +++ b/esphome/components/remote_base/rc5_protocol.cpp @@ -1,8 +1,7 @@ #include "rc5_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.rc5"; @@ -86,5 +85,4 @@ void RC5Protocol::dump(const RC5Data &data) { ESP_LOGI(TAG, "Received RC5: address=0x%02X, command=0x%02X", data.address, data.command); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5_protocol.h b/esphome/components/remote_base/rc5_protocol.h index 589c8d42de..dbb89e41c6 100644 --- a/esphome/components/remote_base/rc5_protocol.h +++ b/esphome/components/remote_base/rc5_protocol.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct RC5Data { uint8_t address; @@ -35,5 +34,4 @@ template class RC5Action : public RemoteTransmitterActionBase class RC6Action : public RemoteTransmitterActionBase; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index b4a549f0be..4d9bc55f21 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote_base"; @@ -198,5 +197,4 @@ void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) { #endif this->send_internal(send_times, send_wait); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index e5e923d780..0b1109267f 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -8,8 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { enum ToleranceMode : uint8_t { TOLERANCE_MODE_PERCENTAGE = 0, @@ -317,5 +316,4 @@ template class RemoteReceiverDumper : public RemoteReceiverDumperBas using prefix##Dumper = RemoteReceiverDumper; #define DECLARE_REMOTE_PROTOCOL(prefix) DECLARE_REMOTE_PROTOCOL_(prefix) -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/roomba_protocol.cpp b/esphome/components/remote_base/roomba_protocol.cpp index 6b7d216374..8053792a60 100644 --- a/esphome/components/remote_base/roomba_protocol.cpp +++ b/esphome/components/remote_base/roomba_protocol.cpp @@ -1,8 +1,7 @@ #include "roomba_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.roomba"; @@ -52,5 +51,4 @@ optional RoombaProtocol::decode(RemoteReceiveData src) { } void RoombaProtocol::dump(const RoombaData &data) { ESP_LOGD(TAG, "Received Roomba: data=0x%02X", data.data); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/roomba_protocol.h b/esphome/components/remote_base/roomba_protocol.h index f94cb7df1b..3582dac398 100644 --- a/esphome/components/remote_base/roomba_protocol.h +++ b/esphome/components/remote_base/roomba_protocol.h @@ -2,8 +2,7 @@ #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct RoombaData { uint8_t data; @@ -31,5 +30,4 @@ template class RoombaAction : public RemoteTransmitterActionBase } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung36_protocol.cpp b/esphome/components/remote_base/samsung36_protocol.cpp index 10e8bd2d01..ded8c71aa3 100644 --- a/esphome/components/remote_base/samsung36_protocol.cpp +++ b/esphome/components/remote_base/samsung36_protocol.cpp @@ -1,8 +1,7 @@ #include "samsung36_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.samsung36"; @@ -99,5 +98,4 @@ void Samsung36Protocol::dump(const Samsung36Data &data) { ESP_LOGI(TAG, "Received Samsung36: address=0x%04X, command=0x%08" PRIX32, data.address, data.command); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung36_protocol.h b/esphome/components/remote_base/samsung36_protocol.h index aa7fd21609..4f15d906e7 100644 --- a/esphome/components/remote_base/samsung36_protocol.h +++ b/esphome/components/remote_base/samsung36_protocol.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct Samsung36Data { uint16_t address; @@ -37,5 +36,4 @@ template class Samsung36Action : public RemoteTransmitterActionB } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung_protocol.cpp b/esphome/components/remote_base/samsung_protocol.cpp index 2a48cbb918..7190e97403 100644 --- a/esphome/components/remote_base/samsung_protocol.cpp +++ b/esphome/components/remote_base/samsung_protocol.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.samsung"; @@ -61,5 +60,4 @@ void SamsungProtocol::dump(const SamsungData &data) { ESP_LOGI(TAG, "Received Samsung: data=0x%" PRIX64 ", nbits=%d", data.data, data.nbits); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung_protocol.h b/esphome/components/remote_base/samsung_protocol.h index 41434f2889..bb234d681d 100644 --- a/esphome/components/remote_base/samsung_protocol.h +++ b/esphome/components/remote_base/samsung_protocol.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct SamsungData { uint64_t data; @@ -35,5 +34,4 @@ template class SamsungAction : public RemoteTransmitterActionBas } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/sony_protocol.cpp b/esphome/components/remote_base/sony_protocol.cpp index 504b346925..0abb7fc0e0 100644 --- a/esphome/components/remote_base/sony_protocol.cpp +++ b/esphome/components/remote_base/sony_protocol.cpp @@ -1,8 +1,7 @@ #include "sony_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.sony"; @@ -65,5 +64,4 @@ void SonyProtocol::dump(const SonyData &data) { ESP_LOGI(TAG, "Received Sony: data=0x%08" PRIX32 ", nbits=%d", data.data, data.nbits); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/sony_protocol.h b/esphome/components/remote_base/sony_protocol.h index d9e4f37d53..eb873e8b7d 100644 --- a/esphome/components/remote_base/sony_protocol.h +++ b/esphome/components/remote_base/sony_protocol.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct SonyData { uint32_t data; @@ -37,5 +36,4 @@ template class SonyAction : public RemoteTransmitterActionBase -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.symphony"; @@ -118,5 +117,4 @@ void SymphonyProtocol::dump(const SymphonyData &data) { ESP_LOGI(TAG, "Received Symphony: data=0x%0*" PRIX32 ", nbits=%" PRIu8, hex_width, (uint32_t) data.data, data.nbits); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/symphony_protocol.h b/esphome/components/remote_base/symphony_protocol.h index 7e77a268ba..7caf5eab86 100644 --- a/esphome/components/remote_base/symphony_protocol.h +++ b/esphome/components/remote_base/symphony_protocol.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct SymphonyData { uint32_t data; @@ -40,5 +39,4 @@ template class SymphonyAction : public RemoteTransmitterActionBa } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toshiba_ac_protocol.cpp b/esphome/components/remote_base/toshiba_ac_protocol.cpp index a20a29b84a..077b4340fa 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.cpp +++ b/esphome/components/remote_base/toshiba_ac_protocol.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.toshibaac"; @@ -111,5 +110,4 @@ void ToshibaAcProtocol::dump(const ToshibaAcData &data) { } } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toshiba_ac_protocol.h b/esphome/components/remote_base/toshiba_ac_protocol.h index c69401c378..8a853005ac 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.h +++ b/esphome/components/remote_base/toshiba_ac_protocol.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct ToshibaAcData { uint64_t rc_code_1; @@ -35,5 +34,4 @@ template class ToshibaAcAction : public RemoteTransmitterActionB } }; -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toto_protocol.cpp b/esphome/components/remote_base/toto_protocol.cpp index f08258c4a3..042efcbc36 100644 --- a/esphome/components/remote_base/toto_protocol.cpp +++ b/esphome/components/remote_base/toto_protocol.cpp @@ -1,8 +1,7 @@ #include "toto_protocol.h" #include "esphome/core/log.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { static const char *const TAG = "remote.toto"; @@ -96,5 +95,4 @@ void TotoProtocol::dump(const TotoData &data) { data.rc_code_2, data.command); } -} // namespace remote_base -} // namespace esphome +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 53d453f7e3..285c9f2125 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -2,8 +2,7 @@ #include "remote_base.h" -namespace esphome { -namespace remote_base { +namespace esphome::remote_base { struct TotoData { uint8_t rc_code_1 : 4; @@ -39,5 +38,4 @@ template class TotoAction : public RemoteTransmitterActionBase #include -namespace esphome { -namespace resampler { +namespace esphome::resampler { static const UBaseType_t RESAMPLER_TASK_PRIORITY = 1; @@ -373,7 +372,6 @@ void ResamplerSpeaker::resample_task(void *params) { vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } -} // namespace resampler -} // namespace esphome +} // namespace esphome::resampler #endif diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index cdbc1c22db..36f39fda97 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -11,8 +11,7 @@ #include -namespace esphome { -namespace resampler { +namespace esphome::resampler { class ResamplerSpeaker : public Component, public speaker::Speaker { public: @@ -99,7 +98,6 @@ class ResamplerSpeaker : public Component, public speaker::Speaker { uint64_t callback_remainder_{0}; }; -} // namespace resampler -} // namespace esphome +} // namespace esphome::resampler #endif diff --git a/esphome/components/resistance/resistance_sensor.cpp b/esphome/components/resistance/resistance_sensor.cpp index 706a059de3..6056509093 100644 --- a/esphome/components/resistance/resistance_sensor.cpp +++ b/esphome/components/resistance/resistance_sensor.cpp @@ -1,8 +1,7 @@ #include "resistance_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace resistance { +namespace esphome::resistance { static const char *const TAG = "resistance"; @@ -43,5 +42,4 @@ void ResistanceSensor::process_(float value) { this->publish_state(res); } -} // namespace resistance -} // namespace esphome +} // namespace esphome::resistance diff --git a/esphome/components/resistance/resistance_sensor.h b/esphome/components/resistance/resistance_sensor.h index a3b6e92c59..b646fb509a 100644 --- a/esphome/components/resistance/resistance_sensor.h +++ b/esphome/components/resistance/resistance_sensor.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace resistance { +namespace esphome::resistance { enum ResistanceConfiguration { UPSTREAM, @@ -33,5 +32,4 @@ class ResistanceSensor : public Component, public sensor::Sensor { float reference_voltage_; }; -} // namespace resistance -} // namespace esphome +} // namespace esphome::resistance diff --git a/esphome/components/restart/button/restart_button.cpp b/esphome/components/restart/button/restart_button.cpp index accb1a8356..d6404315ea 100644 --- a/esphome/components/restart/button/restart_button.cpp +++ b/esphome/components/restart/button/restart_button.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace restart { +namespace esphome::restart { static const char *const TAG = "restart.button"; @@ -16,5 +15,4 @@ void RestartButton::press_action() { } void RestartButton::dump_config() { LOG_BUTTON("", "Restart Button", this); } -} // namespace restart -} // namespace esphome +} // namespace esphome::restart diff --git a/esphome/components/restart/button/restart_button.h b/esphome/components/restart/button/restart_button.h index fd51282d36..974db0cec4 100644 --- a/esphome/components/restart/button/restart_button.h +++ b/esphome/components/restart/button/restart_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "esphome/core/component.h" -namespace esphome { -namespace restart { +namespace esphome::restart { class RestartButton final : public button::Button, public Component { public: @@ -14,5 +13,4 @@ class RestartButton final : public button::Button, public Component { void press_action() override; }; -} // namespace restart -} // namespace esphome +} // namespace esphome::restart diff --git a/esphome/components/restart/switch/restart_switch.cpp b/esphome/components/restart/switch/restart_switch.cpp index 422e85f4cd..96a4fc40f5 100644 --- a/esphome/components/restart/switch/restart_switch.cpp +++ b/esphome/components/restart/switch/restart_switch.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace restart { +namespace esphome::restart { static const char *const TAG = "restart"; @@ -21,5 +20,4 @@ void RestartSwitch::write_state(bool state) { } void RestartSwitch::dump_config() { LOG_SWITCH("", "Restart Switch", this); } -} // namespace restart -} // namespace esphome +} // namespace esphome::restart diff --git a/esphome/components/restart/switch/restart_switch.h b/esphome/components/restart/switch/restart_switch.h index 7f1902ab53..67b4a2bfd1 100644 --- a/esphome/components/restart/switch/restart_switch.h +++ b/esphome/components/restart/switch/restart_switch.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace restart { +namespace esphome::restart { class RestartSwitch : public switch_::Switch, public Component { public: @@ -14,5 +13,4 @@ class RestartSwitch : public switch_::Switch, public Component { void write_state(bool state) override; }; -} // namespace restart -} // namespace esphome +} // namespace esphome::restart diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 5ca629c12b..cec32e0406 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -5,8 +5,7 @@ #include #include -namespace esphome { -namespace rf_bridge { +namespace esphome::rf_bridge { static const char *const TAG = "rf_bridge"; @@ -243,5 +242,4 @@ void RFBridgeComponent::beep(uint16_t ms) { this->flush(); } -} // namespace rf_bridge -} // namespace esphome +} // namespace esphome::rf_bridge diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index 571ac6c385..2f91459076 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -7,8 +7,7 @@ #include "esphome/components/uart/uart.h" #include "esphome/core/automation.h" -namespace esphome { -namespace rf_bridge { +namespace esphome::rf_bridge { static const uint8_t RF_MESSAGE_SIZE = 9; static const uint8_t RF_CODE_START = 0xAA; @@ -179,5 +178,4 @@ template class RFBridgeBeepAction : public Action { RFBridgeComponent *parent_; }; -} // namespace rf_bridge -} // namespace esphome +} // namespace esphome::rf_bridge diff --git a/esphome/components/rgb/rgb_light_output.h b/esphome/components/rgb/rgb_light_output.h index 783187667a..f0d599cf57 100644 --- a/esphome/components/rgb/rgb_light_output.h +++ b/esphome/components/rgb/rgb_light_output.h @@ -4,8 +4,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/light/light_output.h" -namespace esphome { -namespace rgb { +namespace esphome::rgb { class RGBLightOutput : public light::LightOutput { public: @@ -32,5 +31,4 @@ class RGBLightOutput : public light::LightOutput { output::FloatOutput *blue_; }; -} // namespace rgb -} // namespace esphome +} // namespace esphome::rgb diff --git a/esphome/components/rgbct/rgbct_light_output.h b/esphome/components/rgbct/rgbct_light_output.h index 9e23f783ae..84ecb232cc 100644 --- a/esphome/components/rgbct/rgbct_light_output.h +++ b/esphome/components/rgbct/rgbct_light_output.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/core/component.h" -namespace esphome { -namespace rgbct { +namespace esphome::rgbct { class RGBCTLightOutput : public light::LightOutput { public: @@ -55,5 +54,4 @@ class RGBCTLightOutput : public light::LightOutput { bool color_interlock_{true}; }; -} // namespace rgbct -} // namespace esphome +} // namespace esphome::rgbct diff --git a/esphome/components/rgbw/rgbw_light_output.h b/esphome/components/rgbw/rgbw_light_output.h index 140726a43c..ae96eb2024 100644 --- a/esphome/components/rgbw/rgbw_light_output.h +++ b/esphome/components/rgbw/rgbw_light_output.h @@ -4,8 +4,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/light/light_output.h" -namespace esphome { -namespace rgbw { +namespace esphome::rgbw { class RGBWLightOutput : public light::LightOutput { public: @@ -40,5 +39,4 @@ class RGBWLightOutput : public light::LightOutput { bool color_interlock_{false}; }; -} // namespace rgbw -} // namespace esphome +} // namespace esphome::rgbw diff --git a/esphome/components/rgbww/rgbww_light_output.h b/esphome/components/rgbww/rgbww_light_output.h index 9687360059..de5ee993f8 100644 --- a/esphome/components/rgbww/rgbww_light_output.h +++ b/esphome/components/rgbww/rgbww_light_output.h @@ -4,8 +4,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/light/light_output.h" -namespace esphome { -namespace rgbww { +namespace esphome::rgbww { class RGBWWLightOutput : public light::LightOutput { public: @@ -51,5 +50,4 @@ class RGBWWLightOutput : public light::LightOutput { bool color_interlock_{false}; }; -} // namespace rgbww -} // namespace esphome +} // namespace esphome::rgbww diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 38fd14375d..0831822d86 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace rotary_encoder { +namespace esphome::rotary_encoder { static const char *const TAG = "rotary_encoder"; @@ -242,5 +241,4 @@ void RotaryEncoderSensor::set_resolution(RotaryEncoderResolution mode) { this->s void RotaryEncoderSensor::set_min_value(int32_t min_value) { this->store_.min_value = min_value; } void RotaryEncoderSensor::set_max_value(int32_t max_value) { this->store_.max_value = max_value; } -} // namespace rotary_encoder -} // namespace esphome +} // namespace esphome::rotary_encoder diff --git a/esphome/components/rotary_encoder/rotary_encoder.h b/esphome/components/rotary_encoder/rotary_encoder.h index 6f4a4fd83c..8a56da4fe2 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.h +++ b/esphome/components/rotary_encoder/rotary_encoder.h @@ -7,8 +7,7 @@ #include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace rotary_encoder { +namespace esphome::rotary_encoder { /// All possible restore modes for the rotary encoder enum RotaryEncoderRestoreMode { @@ -118,5 +117,4 @@ template class RotaryEncoderSetValueAction : public Action humidity; @@ -31,7 +30,6 @@ class RuuviListener : public esp32_ble_tracker::ESPBTDeviceListener { bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; -} // namespace ruuvi_ble -} // namespace esphome +} // namespace esphome::ruuvi_ble #endif diff --git a/esphome/components/ruuvitag/ruuvitag.cpp b/esphome/components/ruuvitag/ruuvitag.cpp index 9b462b4794..99c6b8ae26 100644 --- a/esphome/components/ruuvitag/ruuvitag.cpp +++ b/esphome/components/ruuvitag/ruuvitag.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ruuvitag { +namespace esphome::ruuvitag { static const char *const TAG = "ruuvitag"; @@ -23,7 +22,6 @@ void RuuviTag::dump_config() { LOG_SENSOR(" ", "Measurement Sequence Number", this->measurement_sequence_number_); } -} // namespace ruuvitag -} // namespace esphome +} // namespace esphome::ruuvitag #endif diff --git a/esphome/components/ruuvitag/ruuvitag.h b/esphome/components/ruuvitag/ruuvitag.h index dfe393724c..259675835d 100644 --- a/esphome/components/ruuvitag/ruuvitag.h +++ b/esphome/components/ruuvitag/ruuvitag.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ruuvitag { +namespace esphome::ruuvitag { class RuuviTag : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -77,7 +76,6 @@ class RuuviTag : public Component, public esp32_ble_tracker::ESPBTDeviceListener sensor::Sensor *measurement_sequence_number_{nullptr}; }; -} // namespace ruuvitag -} // namespace esphome +} // namespace esphome::ruuvitag #endif diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 0aa6e86d31..1b5b71f0e0 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -3,8 +3,7 @@ // https://download.epsondevice.com/td/pdf/app/RX8130CE_en.pdf -namespace esphome { -namespace rx8130 { +namespace esphome::rx8130 { static const uint8_t RX8130_REG_SEC = 0x10; static const uint8_t RX8130_REG_MIN = 0x11; @@ -121,5 +120,4 @@ void RX8130Component::stop_(bool stop) { } } -} // namespace rx8130 -} // namespace esphome +} // namespace esphome::rx8130 diff --git a/esphome/components/rx8130/rx8130.h b/esphome/components/rx8130/rx8130.h index 979da3e19c..152bd10f27 100644 --- a/esphome/components/rx8130/rx8130.h +++ b/esphome/components/rx8130/rx8130.h @@ -4,8 +4,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/time/real_time_clock.h" -namespace esphome { -namespace rx8130 { +namespace esphome::rx8130 { class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { public: @@ -29,5 +28,4 @@ template class ReadAction : public Action, public Parente void play(const Ts... x) override { this->parent_->read_time(); } }; -} // namespace rx8130 -} // namespace esphome +} // namespace esphome::rx8130 From ded83812f4b88c04b8a1c6d1985b4c6d73b94fc9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 7 May 2026 20:16:51 -0400 Subject: [PATCH 1118/2214] [clang-tidy] Concatenate nested namespaces (5/7: components s) (#16302) --- esphome/components/safe_mode/button/safe_mode_button.cpp | 6 ++---- esphome/components/safe_mode/button/safe_mode_button.h | 6 ++---- esphome/components/safe_mode/switch/safe_mode_switch.cpp | 6 ++---- esphome/components/safe_mode/switch/safe_mode_switch.h | 6 ++---- esphome/components/scd30/automation.h | 6 ++---- esphome/components/scd30/scd30.cpp | 6 ++---- esphome/components/scd30/scd30.h | 6 ++---- esphome/components/scd4x/automation.h | 6 ++---- esphome/components/scd4x/scd4x.cpp | 6 ++---- esphome/components/scd4x/scd4x.h | 6 ++---- esphome/components/script/script.cpp | 6 ++---- esphome/components/script/script.h | 7 +++---- esphome/components/sdm_meter/sdm_meter.cpp | 6 ++---- esphome/components/sdm_meter/sdm_meter.h | 6 ++---- esphome/components/sdm_meter/sdm_meter_registers.h | 6 ++---- esphome/components/sdp3x/sdp3x.cpp | 6 ++---- esphome/components/sdp3x/sdp3x.h | 6 ++---- esphome/components/sds011/sds011.cpp | 6 ++---- esphome/components/sds011/sds011.h | 6 ++---- .../seeed_mr24hpc1/button/custom_mode_end_button.cpp | 6 ++---- .../seeed_mr24hpc1/button/custom_mode_end_button.h | 6 ++---- .../components/seeed_mr24hpc1/button/restart_button.cpp | 6 ++---- esphome/components/seeed_mr24hpc1/button/restart_button.h | 6 ++---- .../seeed_mr24hpc1/number/custom_mode_number.cpp | 6 ++---- .../components/seeed_mr24hpc1/number/custom_mode_number.h | 6 ++---- .../seeed_mr24hpc1/number/custom_unman_time_number.cpp | 6 ++---- .../seeed_mr24hpc1/number/custom_unman_time_number.h | 6 ++---- .../seeed_mr24hpc1/number/existence_threshold_number.cpp | 6 ++---- .../seeed_mr24hpc1/number/existence_threshold_number.h | 6 ++---- .../seeed_mr24hpc1/number/motion_threshold_number.cpp | 6 ++---- .../seeed_mr24hpc1/number/motion_threshold_number.h | 6 ++---- .../seeed_mr24hpc1/number/motion_trigger_time_number.cpp | 6 ++---- .../seeed_mr24hpc1/number/motion_trigger_time_number.h | 6 ++---- .../seeed_mr24hpc1/number/motiontorest_time_number.cpp | 6 ++---- .../seeed_mr24hpc1/number/motiontorest_time_number.h | 6 ++---- .../seeed_mr24hpc1/number/sensitivity_number.cpp | 6 ++---- .../components/seeed_mr24hpc1/number/sensitivity_number.h | 6 ++---- esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 6 ++---- esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h | 6 ++---- .../components/seeed_mr24hpc1/seeed_mr24hpc1_constants.h | 6 ++---- .../seeed_mr24hpc1/select/existence_boundary_select.cpp | 6 ++---- .../seeed_mr24hpc1/select/existence_boundary_select.h | 6 ++---- .../seeed_mr24hpc1/select/motion_boundary_select.cpp | 6 ++---- .../seeed_mr24hpc1/select/motion_boundary_select.h | 6 ++---- .../seeed_mr24hpc1/select/scene_mode_select.cpp | 6 ++---- .../components/seeed_mr24hpc1/select/scene_mode_select.h | 6 ++---- .../seeed_mr24hpc1/select/unman_time_select.cpp | 6 ++---- .../components/seeed_mr24hpc1/select/unman_time_select.h | 6 ++---- .../seeed_mr24hpc1/switch/underlyFuc_switch.cpp | 6 ++---- .../components/seeed_mr24hpc1/switch/underlyFuc_switch.h | 6 ++---- esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp | 6 ++---- esphome/components/seeed_mr60bha2/seeed_mr60bha2.h | 6 ++---- .../seeed_mr60fda2/button/get_radar_parameters_button.cpp | 6 ++---- .../seeed_mr60fda2/button/get_radar_parameters_button.h | 6 ++---- .../seeed_mr60fda2/button/reset_radar_button.cpp | 6 ++---- .../components/seeed_mr60fda2/button/reset_radar_button.h | 6 ++---- esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp | 6 ++---- esphome/components/seeed_mr60fda2/seeed_mr60fda2.h | 6 ++---- .../seeed_mr60fda2/select/height_threshold_select.cpp | 6 ++---- .../seeed_mr60fda2/select/height_threshold_select.h | 6 ++---- .../seeed_mr60fda2/select/install_height_select.cpp | 6 ++---- .../seeed_mr60fda2/select/install_height_select.h | 6 ++---- .../seeed_mr60fda2/select/sensitivity_select.cpp | 6 ++---- .../components/seeed_mr60fda2/select/sensitivity_select.h | 6 ++---- esphome/components/selec_meter/selec_meter.cpp | 6 ++---- esphome/components/selec_meter/selec_meter.h | 6 ++---- esphome/components/selec_meter/selec_meter_registers.h | 6 ++---- esphome/components/sen0321/sen0321.cpp | 6 ++---- esphome/components/sen0321/sen0321.h | 6 ++---- esphome/components/sen21231/sen21231.cpp | 6 ++---- esphome/components/sen21231/sen21231.h | 6 ++---- esphome/components/sen5x/automation.h | 6 ++---- esphome/components/sen5x/sen5x.cpp | 6 ++---- esphome/components/sen5x/sen5x.h | 6 ++---- esphome/components/senseair/senseair.cpp | 6 ++---- esphome/components/senseair/senseair.h | 6 ++---- esphome/components/sensirion_common/i2c_sensirion.cpp | 6 ++---- esphome/components/sensirion_common/i2c_sensirion.h | 6 ++---- esphome/components/servo/servo.cpp | 6 ++---- esphome/components/servo/servo.h | 6 ++---- esphome/components/sfa30/sfa30.cpp | 6 ++---- esphome/components/sfa30/sfa30.h | 6 ++---- esphome/components/sgp30/sgp30.cpp | 6 ++---- esphome/components/sgp30/sgp30.h | 6 ++---- esphome/components/sgp4x/sgp4x.cpp | 6 ++---- esphome/components/sgp4x/sgp4x.h | 6 ++---- esphome/components/sht3xd/sht3xd.cpp | 6 ++---- esphome/components/sht3xd/sht3xd.h | 6 ++---- esphome/components/sht4x/sht4x.cpp | 6 ++---- esphome/components/sht4x/sht4x.h | 6 ++---- esphome/components/shutdown/button/shutdown_button.cpp | 6 ++---- esphome/components/shutdown/button/shutdown_button.h | 6 ++---- esphome/components/shutdown/switch/shutdown_switch.cpp | 6 ++---- esphome/components/shutdown/switch/shutdown_switch.h | 6 ++---- .../components/sigma_delta_output/sigma_delta_output.cpp | 6 ++---- .../components/sigma_delta_output/sigma_delta_output.h | 6 ++---- esphome/components/sim800l/sim800l.cpp | 6 ++---- esphome/components/sim800l/sim800l.h | 6 ++---- esphome/components/slow_pwm/slow_pwm_output.cpp | 6 ++---- esphome/components/slow_pwm/slow_pwm_output.h | 6 ++---- esphome/components/sm10bit_base/sm10bit_base.cpp | 6 ++---- esphome/components/sm10bit_base/sm10bit_base.h | 6 ++---- esphome/components/sm16716/sm16716.cpp | 6 ++---- esphome/components/sm16716/sm16716.h | 6 ++---- esphome/components/sm2135/sm2135.cpp | 6 ++---- esphome/components/sm2135/sm2135.h | 6 ++---- esphome/components/sm2235/sm2235.cpp | 6 ++---- esphome/components/sm2235/sm2235.h | 6 ++---- esphome/components/sm2335/sm2335.cpp | 6 ++---- esphome/components/sm2335/sm2335.h | 6 ++---- esphome/components/sm300d2/sm300d2.cpp | 6 ++---- esphome/components/sm300d2/sm300d2.h | 6 ++---- esphome/components/sml/constants.h | 6 ++---- esphome/components/sml/sensor/sml_sensor.cpp | 6 ++---- esphome/components/sml/sensor/sml_sensor.h | 6 ++---- esphome/components/sml/sml.cpp | 6 ++---- esphome/components/sml/sml.h | 6 ++---- esphome/components/sml/sml_parser.cpp | 6 ++---- esphome/components/sml/sml_parser.h | 6 ++---- esphome/components/sml/text_sensor/sml_text_sensor.cpp | 6 ++---- esphome/components/sml/text_sensor/sml_text_sensor.h | 6 ++---- esphome/components/smt100/smt100.cpp | 6 ++---- esphome/components/smt100/smt100.h | 6 ++---- esphome/components/sn74hc165/sn74hc165.cpp | 6 ++---- esphome/components/sn74hc165/sn74hc165.h | 6 ++---- esphome/components/sn74hc595/sn74hc595.cpp | 6 ++---- esphome/components/sn74hc595/sn74hc595.h | 6 ++---- esphome/components/sntp/sntp_component.cpp | 6 ++---- esphome/components/sntp/sntp_component.h | 6 ++---- esphome/components/sonoff_d1/sonoff_d1.cpp | 6 ++---- esphome/components/sonoff_d1/sonoff_d1.h | 6 ++---- esphome/components/sound_level/sound_level.cpp | 6 ++---- esphome/components/sound_level/sound_level.h | 7 +++---- esphome/components/speaker/automation.h | 6 ++---- .../components/speaker/media_player/audio_pipeline.cpp | 6 ++---- esphome/components/speaker/media_player/audio_pipeline.h | 6 ++---- esphome/components/speaker/media_player/automation.h | 6 ++---- .../speaker/media_player/speaker_media_player.cpp | 6 ++---- .../speaker/media_player/speaker_media_player.h | 6 ++---- esphome/components/speaker/speaker.h | 6 ++---- esphome/components/speed/fan/speed_fan.cpp | 6 ++---- esphome/components/speed/fan/speed_fan.h | 6 ++---- esphome/components/spi_device/spi_device.cpp | 6 ++---- esphome/components/spi_device/spi_device.h | 6 ++---- esphome/components/spi_led_strip/spi_led_strip.cpp | 6 ++---- esphome/components/spi_led_strip/spi_led_strip.h | 6 ++---- esphome/components/sps30/automation.h | 6 ++---- esphome/components/sps30/sps30.cpp | 6 ++---- esphome/components/sps30/sps30.h | 6 ++---- esphome/components/ssd1306_base/ssd1306_base.cpp | 6 ++---- esphome/components/ssd1306_base/ssd1306_base.h | 6 ++---- esphome/components/ssd1306_i2c/ssd1306_i2c.cpp | 6 ++---- esphome/components/ssd1306_i2c/ssd1306_i2c.h | 6 ++---- esphome/components/ssd1306_spi/ssd1306_spi.cpp | 6 ++---- esphome/components/ssd1306_spi/ssd1306_spi.h | 6 ++---- esphome/components/ssd1322_base/ssd1322_base.cpp | 6 ++---- esphome/components/ssd1322_base/ssd1322_base.h | 6 ++---- esphome/components/ssd1322_spi/ssd1322_spi.cpp | 6 ++---- esphome/components/ssd1322_spi/ssd1322_spi.h | 6 ++---- esphome/components/ssd1325_base/ssd1325_base.cpp | 6 ++---- esphome/components/ssd1325_base/ssd1325_base.h | 6 ++---- esphome/components/ssd1325_spi/ssd1325_spi.cpp | 6 ++---- esphome/components/ssd1325_spi/ssd1325_spi.h | 6 ++---- esphome/components/ssd1327_base/ssd1327_base.cpp | 6 ++---- esphome/components/ssd1327_base/ssd1327_base.h | 6 ++---- esphome/components/ssd1327_i2c/ssd1327_i2c.cpp | 6 ++---- esphome/components/ssd1327_i2c/ssd1327_i2c.h | 6 ++---- esphome/components/ssd1327_spi/ssd1327_spi.cpp | 6 ++---- esphome/components/ssd1327_spi/ssd1327_spi.h | 6 ++---- esphome/components/ssd1331_base/ssd1331_base.cpp | 6 ++---- esphome/components/ssd1331_base/ssd1331_base.h | 6 ++---- esphome/components/ssd1331_spi/ssd1331_spi.cpp | 6 ++---- esphome/components/ssd1331_spi/ssd1331_spi.h | 6 ++---- esphome/components/ssd1351_base/ssd1351_base.cpp | 6 ++---- esphome/components/ssd1351_base/ssd1351_base.h | 6 ++---- esphome/components/ssd1351_spi/ssd1351_spi.cpp | 6 ++---- esphome/components/ssd1351_spi/ssd1351_spi.h | 6 ++---- esphome/components/st7567_base/st7567_base.cpp | 6 ++---- esphome/components/st7567_base/st7567_base.h | 6 ++---- esphome/components/st7567_i2c/st7567_i2c.cpp | 6 ++---- esphome/components/st7567_i2c/st7567_i2c.h | 6 ++---- esphome/components/st7567_spi/st7567_spi.cpp | 6 ++---- esphome/components/st7567_spi/st7567_spi.h | 6 ++---- esphome/components/st7735/st7735.cpp | 6 ++---- esphome/components/st7735/st7735.h | 6 ++---- esphome/components/st7789v/st7789v.cpp | 6 ++---- esphome/components/st7789v/st7789v.h | 6 ++---- esphome/components/st7920/st7920.cpp | 6 ++---- esphome/components/st7920/st7920.h | 6 ++---- esphome/components/statsd/statsd.cpp | 8 ++++---- esphome/components/statsd/statsd.h | 7 +++---- esphome/components/status_led/light/status_led_light.cpp | 6 ++---- esphome/components/status_led/light/status_led_light.h | 6 ++---- esphome/components/status_led/status_led.cpp | 6 ++---- esphome/components/status_led/status_led.h | 6 ++---- esphome/components/stepper/stepper.cpp | 6 ++---- esphome/components/stepper/stepper.h | 6 ++---- esphome/components/sts3x/sts3x.cpp | 6 ++---- esphome/components/sts3x/sts3x.h | 6 ++---- esphome/components/sun/sensor/sun_sensor.cpp | 6 ++---- esphome/components/sun/sensor/sun_sensor.h | 6 ++---- esphome/components/sun/sun.cpp | 6 ++---- esphome/components/sun/sun.h | 6 ++---- esphome/components/sun/text_sensor/sun_text_sensor.cpp | 6 ++---- esphome/components/sun/text_sensor/sun_text_sensor.h | 6 ++---- esphome/components/sun_gtil2/sun_gtil2.cpp | 6 ++---- esphome/components/sun_gtil2/sun_gtil2.h | 6 ++---- esphome/components/sx126x/automation.h | 6 ++---- .../sx126x/packet_transport/sx126x_transport.cpp | 6 ++---- .../components/sx126x/packet_transport/sx126x_transport.h | 6 ++---- esphome/components/sx126x/sx126x.cpp | 6 ++---- esphome/components/sx126x/sx126x.h | 6 ++---- esphome/components/sx126x/sx126x_reg.h | 6 ++---- esphome/components/sx127x/automation.h | 6 ++---- .../sx127x/packet_transport/sx127x_transport.cpp | 6 ++---- .../components/sx127x/packet_transport/sx127x_transport.h | 6 ++---- esphome/components/sx127x/sx127x.cpp | 6 ++---- esphome/components/sx127x/sx127x.h | 6 ++---- esphome/components/sx127x/sx127x_reg.h | 6 ++---- .../sx1509/binary_sensor/sx1509_binary_keypad_sensor.h | 6 ++---- esphome/components/sx1509/output/sx1509_float_output.cpp | 6 ++---- esphome/components/sx1509/output/sx1509_float_output.h | 6 ++---- esphome/components/sx1509/sx1509.cpp | 6 ++---- esphome/components/sx1509/sx1509.h | 6 ++---- esphome/components/sx1509/sx1509_gpio_pin.cpp | 6 ++---- esphome/components/sx1509/sx1509_gpio_pin.h | 6 ++---- esphome/components/sx1509/sx1509_registers.h | 6 ++---- 227 files changed, 459 insertions(+), 908 deletions(-) diff --git a/esphome/components/safe_mode/button/safe_mode_button.cpp b/esphome/components/safe_mode/button/safe_mode_button.cpp index bb5b64daf7..04203854fb 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.cpp +++ b/esphome/components/safe_mode/button/safe_mode_button.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace safe_mode { +namespace esphome::safe_mode { static const char *const TAG = "safe_mode.button"; @@ -23,5 +22,4 @@ void SafeModeButton::press_action() { void SafeModeButton::dump_config() { LOG_BUTTON("", "Safe Mode Button", this); } -} // namespace safe_mode -} // namespace esphome +} // namespace esphome::safe_mode diff --git a/esphome/components/safe_mode/button/safe_mode_button.h b/esphome/components/safe_mode/button/safe_mode_button.h index 0307a81feb..6012bb2aeb 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.h +++ b/esphome/components/safe_mode/button/safe_mode_button.h @@ -4,8 +4,7 @@ #include "esphome/components/safe_mode/safe_mode.h" #include "esphome/core/component.h" -namespace esphome { -namespace safe_mode { +namespace esphome::safe_mode { class SafeModeButton final : public button::Button, public Component { public: @@ -17,5 +16,4 @@ class SafeModeButton final : public button::Button, public Component { void press_action() override; }; -} // namespace safe_mode -} // namespace esphome +} // namespace esphome::safe_mode diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.cpp b/esphome/components/safe_mode/switch/safe_mode_switch.cpp index 1637da3059..f513465db0 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.cpp +++ b/esphome/components/safe_mode/switch/safe_mode_switch.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace safe_mode { +namespace esphome::safe_mode { static const char *const TAG = "safe_mode.switch"; @@ -28,5 +27,4 @@ void SafeModeSwitch::write_state(bool state) { void SafeModeSwitch::dump_config() { LOG_SWITCH("", "Safe Mode Switch", this); } -} // namespace safe_mode -} // namespace esphome +} // namespace esphome::safe_mode diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.h b/esphome/components/safe_mode/switch/safe_mode_switch.h index 24e660c803..c73a2087d7 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.h +++ b/esphome/components/safe_mode/switch/safe_mode_switch.h @@ -4,8 +4,7 @@ #include "esphome/components/switch/switch.h" #include "esphome/core/component.h" -namespace esphome { -namespace safe_mode { +namespace esphome::safe_mode { class SafeModeSwitch : public switch_::Switch, public Component { public: @@ -17,5 +16,4 @@ class SafeModeSwitch : public switch_::Switch, public Component { void write_state(bool state) override; }; -} // namespace safe_mode -} // namespace esphome +} // namespace esphome::safe_mode diff --git a/esphome/components/scd30/automation.h b/esphome/components/scd30/automation.h index 1f89e7c815..1f04739893 100644 --- a/esphome/components/scd30/automation.h +++ b/esphome/components/scd30/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "scd30.h" -namespace esphome { -namespace scd30 { +namespace esphome::scd30 { template class ForceRecalibrationWithReference : public Action, public Parented { public: @@ -19,5 +18,4 @@ template class ForceRecalibrationWithReference : public Action #endif -namespace esphome { -namespace scd30 { +namespace esphome::scd30 { static const char *const TAG = "scd30"; @@ -230,5 +229,4 @@ uint16_t SCD30Component::get_forced_calibration_reference() { return forced_calibration_reference; } -} // namespace scd30 -} // namespace esphome +} // namespace esphome::scd30 diff --git a/esphome/components/scd30/scd30.h b/esphome/components/scd30/scd30.h index ed3f5e7e9a..a5a5df1903 100644 --- a/esphome/components/scd30/scd30.h +++ b/esphome/components/scd30/scd30.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" -namespace esphome { -namespace scd30 { +namespace esphome::scd30 { /// This class implements support for the Sensirion scd30 i2c GAS (VOC and CO2eq) sensors. class SCD30Component : public Component, public sensirion_common::SensirionI2CDevice { @@ -48,5 +47,4 @@ class SCD30Component : public Component, public sensirion_common::SensirionI2CDe sensor::Sensor *temperature_sensor_{nullptr}; }; -} // namespace scd30 -} // namespace esphome +} // namespace esphome::scd30 diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h index 6ce1468577..e485289c95 100644 --- a/esphome/components/scd4x/automation.h +++ b/esphome/components/scd4x/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "scd4x.h" -namespace esphome { -namespace scd4x { +namespace esphome::scd4x { template class PerformForcedCalibrationAction : public Action, public Parented { public: @@ -24,5 +23,4 @@ template class FactoryResetAction : public Action, public void play(const Ts &...x) override { this->parent_->factory_reset(); } }; -} // namespace scd4x -} // namespace esphome +} // namespace esphome::scd4x diff --git a/esphome/components/scd4x/scd4x.cpp b/esphome/components/scd4x/scd4x.cpp index 0c108fba9d..d9a2439bb9 100644 --- a/esphome/components/scd4x/scd4x.cpp +++ b/esphome/components/scd4x/scd4x.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace scd4x { +namespace esphome::scd4x { static const char *const TAG = "scd4x"; @@ -324,5 +323,4 @@ bool SCD4XComponent::start_measurement_() { return false; } -} // namespace scd4x -} // namespace esphome +} // namespace esphome::scd4x diff --git a/esphome/components/scd4x/scd4x.h b/esphome/components/scd4x/scd4x.h index ab5d72aeec..3e4827ef14 100644 --- a/esphome/components/scd4x/scd4x.h +++ b/esphome/components/scd4x/scd4x.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" -namespace esphome { -namespace scd4x { +namespace esphome::scd4x { enum ErrorCode : uint8_t { COMMUNICATION_FAILED, @@ -59,5 +58,4 @@ class SCD4XComponent : public PollingComponent, public sensirion_common::Sensiri MeasurementMode measurement_mode_{PERIODIC}; }; -} // namespace scd4x -} // namespace esphome +} // namespace esphome::scd4x diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp index 81f652d26a..61bca5bc28 100644 --- a/esphome/components/script/script.cpp +++ b/esphome/components/script/script.cpp @@ -1,8 +1,7 @@ #include "script.h" #include "esphome/core/log.h" -namespace esphome { -namespace script { +namespace esphome::script { static const char *const TAG = "script"; @@ -16,5 +15,4 @@ void ScriptLogger::esp_log_(int level, int line, const char *format, const char } #endif -} // namespace script -} // namespace esphome +} // namespace esphome::script diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index a0dffe26bf..847fab02bd 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -7,8 +7,8 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace script { + +namespace esphome::script { class ScriptLogger { protected: @@ -338,5 +338,4 @@ template class ScriptWaitAction : public Action, std::list> param_queue_; }; -} // namespace script -} // namespace esphome +} // namespace esphome::script diff --git a/esphome/components/sdm_meter/sdm_meter.cpp b/esphome/components/sdm_meter/sdm_meter.cpp index 12a3277269..a4fe6e7d35 100644 --- a/esphome/components/sdm_meter/sdm_meter.cpp +++ b/esphome/components/sdm_meter/sdm_meter.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace sdm_meter { +namespace esphome::sdm_meter { static const char *const TAG = "sdm_meter"; @@ -110,5 +109,4 @@ void SDMMeter::dump_config() { LOG_SENSOR(" ", "Export Reactive Energy", this->export_reactive_energy_sensor_); } -} // namespace sdm_meter -} // namespace esphome +} // namespace esphome::sdm_meter diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index f8a3014a89..e729e29d6c 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace sdm_meter { +namespace esphome::sdm_meter { class SDMMeter : public PollingComponent, public modbus::ModbusDevice { public: @@ -79,5 +78,4 @@ class SDMMeter : public PollingComponent, public modbus::ModbusDevice { sensor::Sensor *export_reactive_energy_sensor_{nullptr}; }; -} // namespace sdm_meter -} // namespace esphome +} // namespace esphome::sdm_meter diff --git a/esphome/components/sdm_meter/sdm_meter_registers.h b/esphome/components/sdm_meter/sdm_meter_registers.h index dd981d6f00..b4b2855576 100644 --- a/esphome/components/sdm_meter/sdm_meter_registers.h +++ b/esphome/components/sdm_meter/sdm_meter_registers.h @@ -1,7 +1,6 @@ #pragma once -namespace esphome { -namespace sdm_meter { +namespace esphome::sdm_meter { /* PHASE STATUS REGISTERS */ static const uint16_t SDM_PHASE_1_VOLTAGE = 0x0000; @@ -110,5 +109,4 @@ static const uint16_t SDM_CURRENT_RESETTABLE_EXPORT_ENERGY = 0x0186; static const uint16_t SDM_IMPORT_POWER = 0x0500; static const uint16_t SDM_EXPORT_POWER = 0x0502; -} // namespace sdm_meter -} // namespace esphome +} // namespace esphome::sdm_meter diff --git a/esphome/components/sdp3x/sdp3x.cpp b/esphome/components/sdp3x/sdp3x.cpp index 6f6cc1ebd8..7fcd47a265 100644 --- a/esphome/components/sdp3x/sdp3x.cpp +++ b/esphome/components/sdp3x/sdp3x.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace sdp3x { +namespace esphome::sdp3x { static const char *const TAG = "sdp3x.sensor"; static const uint16_t SDP3X_SOFT_RESET = 0x0006; @@ -114,5 +113,4 @@ void SDP3XComponent::read_pressure_() { this->status_clear_warning(); } -} // namespace sdp3x -} // namespace esphome +} // namespace esphome::sdp3x diff --git a/esphome/components/sdp3x/sdp3x.h b/esphome/components/sdp3x/sdp3x.h index afb58d47c8..c4ef6a4a1e 100644 --- a/esphome/components/sdp3x/sdp3x.h +++ b/esphome/components/sdp3x/sdp3x.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" -namespace esphome { -namespace sdp3x { +namespace esphome::sdp3x { enum MeasurementMode { MASS_FLOW_AVG, DP_AVG }; @@ -25,5 +24,4 @@ class SDP3XComponent : public PollingComponent, public sensirion_common::Sensiri MeasurementMode measurement_mode_; }; -} // namespace sdp3x -} // namespace esphome +} // namespace esphome::sdp3x diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index cdfd7544ad..b1f89f18bf 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace sds011 { +namespace esphome::sds011 { static const char *const TAG = "sds011"; @@ -184,5 +183,4 @@ void SDS011Component::set_update_interval_min(uint8_t update_interval_min) { this->update_interval_min_ = update_interval_min; } -} // namespace sds011 -} // namespace esphome +} // namespace esphome::sds011 diff --git a/esphome/components/sds011/sds011.h b/esphome/components/sds011/sds011.h index d65299c635..56d46d118f 100644 --- a/esphome/components/sds011/sds011.h +++ b/esphome/components/sds011/sds011.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace sds011 { +namespace esphome::sds011 { class SDS011Component : public Component, public uart::UARTDevice { public: @@ -44,5 +43,4 @@ class SDS011Component : public Component, public uart::UARTDevice { bool rx_mode_only_; }; -} // namespace sds011 -} // namespace esphome +} // namespace esphome::sds011 diff --git a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.cpp b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.cpp index 0ae8889247..aec940dd22 100644 --- a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.cpp +++ b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.cpp @@ -1,9 +1,7 @@ #include "custom_mode_end_button.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { void CustomSetEndButton::press_action() { this->parent_->set_custom_end_mode(); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h index a1701d8581..bc98bb93b6 100644 --- a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h +++ b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class CustomSetEndButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class CustomSetEndButton : public button::Button, public Parentedparent_->set_restart(); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/button/restart_button.h b/esphome/components/seeed_mr24hpc1/button/restart_button.h index 8a2ec2087c..49a4f46138 100644 --- a/esphome/components/seeed_mr24hpc1/button/restart_button.h +++ b/esphome/components/seeed_mr24hpc1/button/restart_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class RestartButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class RestartButton : public button::Button, public Parented void press_action() override; }; -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.cpp b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.cpp index 0aebd8fb9f..08a8076e22 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.cpp +++ b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.cpp @@ -1,12 +1,10 @@ #include "custom_mode_number.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { void CustomModeNumber::control(float value) { this->publish_state(value); this->parent_->set_custom_mode(value); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h index 40ff3f201a..f51e592fc0 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h +++ b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class CustomModeNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class CustomModeNumber : public number::Number, public Parentedparent_->set_custom_unman_time(value); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h b/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h index 6b871c4c13..281e727a36 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class CustomUnmanTimeNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class CustomUnmanTimeNumber : public number::Number, public Parentedparent_->set_existence_threshold(value); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h b/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h index 656bad17de..c811b2d6b6 100644 --- a/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h +++ b/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class ExistenceThresholdNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class ExistenceThresholdNumber : public number::Number, public Parentedparent_->set_motion_threshold(value); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h b/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h index e8ae37b96f..748119f198 100644 --- a/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class MotionThresholdNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class MotionThresholdNumber : public number::Number, public Parentedparent_->set_motion_trigger_time(value); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h b/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h index 996356e237..dd7947b2a5 100644 --- a/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class MotionTriggerTimeNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class MotionTriggerTimeNumber : public number::Number, public Parentedparent_->set_motion_to_rest_time(value); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h b/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h index 559d23fdeb..47493e7954 100644 --- a/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class MotionToRestTimeNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class MotionToRestTimeNumber : public number::Number, public Parentedparent_->set_sensitivity(value); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h b/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h index fee33521d0..c1d5435151 100644 --- a/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h +++ b/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class SensitivityNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class SensitivityNumber : public number::Number, public Parented -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { static const char *const TAG = "seeed_mr24hpc1"; @@ -1002,5 +1001,4 @@ void MR24HPC1Component::set_custom_unman_time(uint16_t value) { this->get_custom_unman_time(); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index 8fc61ad37c..b62504ba0e 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -30,8 +30,7 @@ #include -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { enum FrameState { FRAME_IDLE, @@ -213,5 +212,4 @@ class MR24HPC1Component : public Component, void set_custom_unman_time(uint16_t value); }; -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1_constants.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1_constants.h index dafc6c0368..7ed7e1db94 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1_constants.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1_constants.h @@ -2,8 +2,7 @@ #include -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { static const uint8_t FRAME_BUF_MAX_SIZE = 128; static const uint8_t PRODUCT_BUF_MAX_SIZE = 32; @@ -169,5 +168,4 @@ static const uint8_t GET_KEEP_AWAY[] = { FRAME_TAIL1_VALUE, FRAME_TAIL2_VALUE, }; -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.cpp b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.cpp index 81543055a4..af01152e48 100644 --- a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.cpp +++ b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.cpp @@ -1,12 +1,10 @@ #include "existence_boundary_select.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { void ExistenceBoundarySelect::control(size_t index) { this->publish_state(index); this->parent_->set_existence_boundary(index); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h index 933279dd13..878d0525c9 100644 --- a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h +++ b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class ExistenceBoundarySelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class ExistenceBoundarySelect : public select::Select, public Parentedpublish_state(index); this->parent_->set_motion_boundary(index); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h b/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h index b0051ae6b1..eecdef2019 100644 --- a/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h +++ b/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class MotionBoundarySelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class MotionBoundarySelect : public select::Select, public Parentedpublish_state(index); this->parent_->set_scene_mode(index); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h b/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h index f478ea5b66..377c61b32f 100644 --- a/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h +++ b/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class SceneModeSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class SceneModeSelect : public select::Select, public Parentedpublish_state(index); this->parent_->set_unman_time(index); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/select/unman_time_select.h b/esphome/components/seeed_mr24hpc1/select/unman_time_select.h index a64ff4b840..e68ae5e54f 100644 --- a/esphome/components/seeed_mr24hpc1/select/unman_time_select.h +++ b/esphome/components/seeed_mr24hpc1/select/unman_time_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class UnmanTimeSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class UnmanTimeSelect : public select::Select, public Parentedpublish_state(state); this->parent_->set_underlying_open_function(state); } -} // namespace seeed_mr24hpc1 -} // namespace esphome +} // namespace esphome::seeed_mr24hpc1 diff --git a/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h b/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h index 1baabb25ce..3224640ce7 100644 --- a/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h +++ b/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../seeed_mr24hpc1.h" -namespace esphome { -namespace seeed_mr24hpc1 { +namespace esphome::seeed_mr24hpc1 { class UnderlyOpenFunctionSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class UnderlyOpenFunctionSwitch : public switch_::Switch, public Parented #include -namespace esphome { -namespace seeed_mr60bha2 { +namespace esphome::seeed_mr60bha2 { static const char *const TAG = "seeed_mr60bha2"; @@ -219,5 +218,4 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c } } -} // namespace seeed_mr60bha2 -} // namespace esphome +} // namespace esphome::seeed_mr60bha2 diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h index d20c8e50cc..008acc6a57 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h @@ -13,8 +13,7 @@ #include -namespace esphome { -namespace seeed_mr60bha2 { +namespace esphome::seeed_mr60bha2 { static const uint8_t FRAME_HEADER_BUFFER = 0x01; static const uint16_t BREATH_RATE_TYPE_BUFFER = 0x0A14; static const uint16_t PEOPLE_EXIST_TYPE_BUFFER = 0x0F09; @@ -46,5 +45,4 @@ class MR60BHA2Component : public Component, std::vector rx_message_; }; -} // namespace seeed_mr60bha2 -} // namespace esphome +} // namespace esphome::seeed_mr60bha2 diff --git a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.cpp b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.cpp index 88be6dfe7c..c377128fe6 100644 --- a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.cpp +++ b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.cpp @@ -1,9 +1,7 @@ #include "get_radar_parameters_button.h" -namespace esphome { -namespace seeed_mr60fda2 { +namespace esphome::seeed_mr60fda2 { void GetRadarParametersButton::press_action() { this->parent_->get_radar_parameters(); } -} // namespace seeed_mr60fda2 -} // namespace esphome +} // namespace esphome::seeed_mr60fda2 diff --git a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h index 9d6d507383..c1b96d5f08 100644 --- a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h +++ b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../seeed_mr60fda2.h" -namespace esphome { -namespace seeed_mr60fda2 { +namespace esphome::seeed_mr60fda2 { class GetRadarParametersButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class GetRadarParametersButton : public button::Button, public Parentedparent_->factory_reset(); } -} // namespace seeed_mr60fda2 -} // namespace esphome +} // namespace esphome::seeed_mr60fda2 diff --git a/esphome/components/seeed_mr60fda2/button/reset_radar_button.h b/esphome/components/seeed_mr60fda2/button/reset_radar_button.h index 66780fb8af..174ef5425e 100644 --- a/esphome/components/seeed_mr60fda2/button/reset_radar_button.h +++ b/esphome/components/seeed_mr60fda2/button/reset_radar_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../seeed_mr60fda2.h" -namespace esphome { -namespace seeed_mr60fda2 { +namespace esphome::seeed_mr60fda2 { class ResetRadarButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class ResetRadarButton : public button::Button, public Parented #include -namespace esphome { -namespace seeed_mr60fda2 { +namespace esphome::seeed_mr60fda2 { static const char *const TAG = "seeed_mr60fda2"; @@ -393,5 +392,4 @@ void MR60FDA2Component::factory_reset() { this->get_radar_parameters(); } -} // namespace seeed_mr60fda2 -} // namespace esphome +} // namespace esphome::seeed_mr60fda2 diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h index e1ffa4f071..0e97447074 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h @@ -19,8 +19,7 @@ #include -namespace esphome { -namespace seeed_mr60fda2 { +namespace esphome::seeed_mr60fda2 { static const uint8_t DATA_BUF_MAX_SIZE = 28; static const uint8_t FRAME_BUF_MAX_SIZE = 37; @@ -97,5 +96,4 @@ class MR60FDA2Component : public Component, void factory_reset(); }; -} // namespace seeed_mr60fda2 -} // namespace esphome +} // namespace esphome::seeed_mr60fda2 diff --git a/esphome/components/seeed_mr60fda2/select/height_threshold_select.cpp b/esphome/components/seeed_mr60fda2/select/height_threshold_select.cpp index c963ccdadd..09cb9c4c1c 100644 --- a/esphome/components/seeed_mr60fda2/select/height_threshold_select.cpp +++ b/esphome/components/seeed_mr60fda2/select/height_threshold_select.cpp @@ -1,12 +1,10 @@ #include "height_threshold_select.h" -namespace esphome { -namespace seeed_mr60fda2 { +namespace esphome::seeed_mr60fda2 { void HeightThresholdSelect::control(size_t index) { this->publish_state(index); this->parent_->set_height_threshold(index); } -} // namespace seeed_mr60fda2 -} // namespace esphome +} // namespace esphome::seeed_mr60fda2 diff --git a/esphome/components/seeed_mr60fda2/select/height_threshold_select.h b/esphome/components/seeed_mr60fda2/select/height_threshold_select.h index f5707c7a88..0e49576658 100644 --- a/esphome/components/seeed_mr60fda2/select/height_threshold_select.h +++ b/esphome/components/seeed_mr60fda2/select/height_threshold_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../seeed_mr60fda2.h" -namespace esphome { -namespace seeed_mr60fda2 { +namespace esphome::seeed_mr60fda2 { class HeightThresholdSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class HeightThresholdSelect : public select::Select, public Parentedpublish_state(index); this->parent_->set_install_height(index); } -} // namespace seeed_mr60fda2 -} // namespace esphome +} // namespace esphome::seeed_mr60fda2 diff --git a/esphome/components/seeed_mr60fda2/select/install_height_select.h b/esphome/components/seeed_mr60fda2/select/install_height_select.h index 470d96c50c..c1e2a3eeb1 100644 --- a/esphome/components/seeed_mr60fda2/select/install_height_select.h +++ b/esphome/components/seeed_mr60fda2/select/install_height_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../seeed_mr60fda2.h" -namespace esphome { -namespace seeed_mr60fda2 { +namespace esphome::seeed_mr60fda2 { class InstallHeightSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class InstallHeightSelect : public select::Select, public Parentedpublish_state(index); this->parent_->set_sensitivity(index); } -} // namespace seeed_mr60fda2 -} // namespace esphome +} // namespace esphome::seeed_mr60fda2 diff --git a/esphome/components/seeed_mr60fda2/select/sensitivity_select.h b/esphome/components/seeed_mr60fda2/select/sensitivity_select.h index 82ed4c5d79..f2e0307dc1 100644 --- a/esphome/components/seeed_mr60fda2/select/sensitivity_select.h +++ b/esphome/components/seeed_mr60fda2/select/sensitivity_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../seeed_mr60fda2.h" -namespace esphome { -namespace seeed_mr60fda2 { +namespace esphome::seeed_mr60fda2 { class SensitivitySelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class SensitivitySelect : public select::Select, public Parentedmaximum_demand_apparent_power_sensor_); } -} // namespace selec_meter -} // namespace esphome +} // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 730791c91b..159acab124 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace selec_meter { +namespace esphome::selec_meter { #define SELEC_METER_SENSOR(name) \ protected: \ @@ -43,5 +42,4 @@ class SelecMeter : public PollingComponent, public modbus::ModbusDevice { void dump_config() override; }; -} // namespace selec_meter -} // namespace esphome +} // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter_registers.h b/esphome/components/selec_meter/selec_meter_registers.h index dfaf65ff08..d299560aab 100644 --- a/esphome/components/selec_meter/selec_meter_registers.h +++ b/esphome/components/selec_meter/selec_meter_registers.h @@ -1,7 +1,6 @@ #pragma once -namespace esphome { -namespace selec_meter { +namespace esphome::selec_meter { static const float TWO_DEC_UNIT = 0.01; static const float ONE_DEC_UNIT = 0.1; @@ -28,5 +27,4 @@ static const uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; static const uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; static const uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; -} // namespace selec_meter -} // namespace esphome +} // namespace esphome::selec_meter diff --git a/esphome/components/sen0321/sen0321.cpp b/esphome/components/sen0321/sen0321.cpp index 6a5931272d..e074934d0f 100644 --- a/esphome/components/sen0321/sen0321.cpp +++ b/esphome/components/sen0321/sen0321.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace sen0321_sensor { +namespace esphome::sen0321_sensor { static const char *const TAG = "sen0321_sensor.sensor"; @@ -31,5 +30,4 @@ void Sen0321Sensor::read_data_() { this->publish_state(((uint16_t) (result[0] << 8) + result[1])); } -} // namespace sen0321_sensor -} // namespace esphome +} // namespace esphome::sen0321_sensor diff --git a/esphome/components/sen0321/sen0321.h b/esphome/components/sen0321/sen0321.h index 3bb3d5b015..6d5aa20a61 100644 --- a/esphome/components/sen0321/sen0321.h +++ b/esphome/components/sen0321/sen0321.h @@ -7,8 +7,7 @@ // ref: // https://github.com/DFRobot/DFRobot_OzoneSensor -namespace esphome { -namespace sen0321_sensor { +namespace esphome::sen0321_sensor { // Sensor Mode // While passive is supposedly supported, it does not appear to work reliably. static const uint8_t SENSOR_MODE_REGISTER = 0x03; @@ -31,5 +30,4 @@ class Sen0321Sensor : public sensor::Sensor, public PollingComponent, public i2c void read_data_(); }; -} // namespace sen0321_sensor -} // namespace esphome +} // namespace esphome::sen0321_sensor diff --git a/esphome/components/sen21231/sen21231.cpp b/esphome/components/sen21231/sen21231.cpp index 8c9f3d7134..b42ba2fa1d 100644 --- a/esphome/components/sen21231/sen21231.cpp +++ b/esphome/components/sen21231/sen21231.cpp @@ -1,8 +1,7 @@ #include "sen21231.h" #include "esphome/core/log.h" -namespace esphome { -namespace sen21231_sensor { +namespace esphome::sen21231_sensor { static const char *const TAG = "sen21231_sensor.sensor"; @@ -33,5 +32,4 @@ void Sen21231Sensor::read_data_() { } } -} // namespace sen21231_sensor -} // namespace esphome +} // namespace esphome::sen21231_sensor diff --git a/esphome/components/sen21231/sen21231.h b/esphome/components/sen21231/sen21231.h index b4d540df55..486a9473d2 100644 --- a/esphome/components/sen21231/sen21231.h +++ b/esphome/components/sen21231/sen21231.h @@ -7,8 +7,7 @@ // ref: // https://github.com/usefulsensors/person_sensor_pico_c/blob/main/person_sensor.h -namespace esphome { -namespace sen21231_sensor { +namespace esphome::sen21231_sensor { // The I2C address of the person sensor board. static const uint8_t PERSON_SENSOR_I2C_ADDRESS = 0x62; static const uint8_t PERSON_SENSOR_REG_MODE = 0x01; @@ -73,5 +72,4 @@ class Sen21231Sensor : public sensor::Sensor, public PollingComponent, public i2 void read_data_(); }; -} // namespace sen21231_sensor -} // namespace esphome +} // namespace esphome::sen21231_sensor diff --git a/esphome/components/sen5x/automation.h b/esphome/components/sen5x/automation.h index 558ea46e47..e6111f4a8f 100644 --- a/esphome/components/sen5x/automation.h +++ b/esphome/components/sen5x/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "sen5x.h" -namespace esphome { -namespace sen5x { +namespace esphome::sen5x { template class StartFanAction : public Action { public: @@ -17,5 +16,4 @@ template class StartFanAction : public Action { SEN5XComponent *sen5x_; }; -} // namespace sen5x -} // namespace esphome +} // namespace esphome::sen5x diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 09dda8bca4..588650e630 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace sen5x { +namespace esphome::sen5x { static const char *const TAG = "sen5x"; @@ -423,5 +422,4 @@ bool SEN5XComponent::start_fan_cleaning() { return true; } -} // namespace sen5x -} // namespace esphome +} // namespace esphome::sen5x diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index a9d4da86b8..ec8f9cc544 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -6,8 +6,7 @@ #include "esphome/core/application.h" #include "esphome/core/preferences.h" -namespace esphome { -namespace sen5x { +namespace esphome::sen5x { enum ERRORCODE : uint8_t { COMMUNICATION_FAILED, @@ -130,5 +129,4 @@ class SEN5XComponent : public PollingComponent, public sensirion_common::Sensiri ESPPreferenceObject pref_; }; -} // namespace sen5x -} // namespace esphome +} // namespace esphome::sen5x diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index 84520d407d..8ed9fbb53b 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace senseair { +namespace esphome::senseair { static const char *const TAG = "senseair"; static const uint8_t SENSEAIR_REQUEST_LENGTH = 8; @@ -150,5 +149,4 @@ void SenseAirComponent::dump_config() { this->check_uart_settings(9600); } -} // namespace senseair -} // namespace esphome +} // namespace esphome::senseair diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 9db849075d..333c003f48 100644 --- a/esphome/components/senseair/senseair.h +++ b/esphome/components/senseair/senseair.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace senseair { +namespace esphome::senseair { enum SenseAirStatus : uint8_t { FATAL_ERROR = 1 << 0, @@ -88,5 +87,4 @@ template class SenseAirABCGetPeriodAction : public Action SenseAirComponent *senseair_; }; -} // namespace senseair -} // namespace esphome +} // namespace esphome::senseair diff --git a/esphome/components/sensirion_common/i2c_sensirion.cpp b/esphome/components/sensirion_common/i2c_sensirion.cpp index 6e244faf59..f6ff4711d4 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.cpp +++ b/esphome/components/sensirion_common/i2c_sensirion.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace sensirion_common { +namespace esphome::sensirion_common { static const char *const TAG = "sensirion_i2c"; // To avoid memory allocations for small writes a stack buffer is used @@ -79,5 +78,4 @@ bool SensirionI2CDevice::get_register_(uint16_t reg, CommandLen command_len, uin return result; } -} // namespace sensirion_common -} // namespace esphome +} // namespace esphome::sensirion_common diff --git a/esphome/components/sensirion_common/i2c_sensirion.h b/esphome/components/sensirion_common/i2c_sensirion.h index 3c2c14ccb8..558fbdbb12 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.h +++ b/esphome/components/sensirion_common/i2c_sensirion.h @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace sensirion_common { +namespace esphome::sensirion_common { /** * Implementation of I2C functions for Sensirion sensors @@ -149,5 +148,4 @@ class SensirionI2CDevice : public i2c::I2CDevice { i2c::ErrorCode last_error_; }; -} // namespace sensirion_common -} // namespace esphome +} // namespace esphome::sensirion_common diff --git a/esphome/components/servo/servo.cpp b/esphome/components/servo/servo.cpp index b4511de2d0..d2028ce9bd 100644 --- a/esphome/components/servo/servo.cpp +++ b/esphome/components/servo/servo.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace servo { +namespace esphome::servo { static const char *const TAG = "servo"; @@ -106,5 +105,4 @@ void Servo::save_level_(float v) { this->rtc_.save(&v); } -} // namespace servo -} // namespace esphome +} // namespace esphome::servo diff --git a/esphome/components/servo/servo.h b/esphome/components/servo/servo.h index 3d15aefefe..31e9357947 100644 --- a/esphome/components/servo/servo.h +++ b/esphome/components/servo/servo.h @@ -6,8 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace servo { +namespace esphome::servo { extern uint32_t global_servo_id; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -73,5 +72,4 @@ template class ServoDetachAction : public Action { Servo *servo_; }; -} // namespace servo -} // namespace esphome +} // namespace esphome::servo diff --git a/esphome/components/sfa30/sfa30.cpp b/esphome/components/sfa30/sfa30.cpp index bbe3bcd7d2..960806e98b 100644 --- a/esphome/components/sfa30/sfa30.cpp +++ b/esphome/components/sfa30/sfa30.cpp @@ -1,8 +1,7 @@ #include "sfa30.h" #include "esphome/core/log.h" -namespace esphome { -namespace sfa30 { +namespace esphome::sfa30 { static const char *const TAG = "sfa30"; @@ -91,5 +90,4 @@ void SFA30Component::update() { }); } -} // namespace sfa30 -} // namespace esphome +} // namespace esphome::sfa30 diff --git a/esphome/components/sfa30/sfa30.h b/esphome/components/sfa30/sfa30.h index 2b744b8da4..d2f2520a57 100644 --- a/esphome/components/sfa30/sfa30.h +++ b/esphome/components/sfa30/sfa30.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" -namespace esphome { -namespace sfa30 { +namespace esphome::sfa30 { class SFA30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { enum ErrorCode { DEVICE_MARKING_READ_FAILED, MEASUREMENT_INIT_FAILED, UNKNOWN }; @@ -29,5 +28,4 @@ class SFA30Component : public PollingComponent, public sensirion_common::Sensiri sensor::Sensor *temperature_sensor_{nullptr}; }; -} // namespace sfa30 -} // namespace esphome +} // namespace esphome::sfa30 diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index 35e5b3dd42..fd007c92cc 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace sgp30 { +namespace esphome::sgp30 { static const char *const TAG = "sgp30"; @@ -302,5 +301,4 @@ void SGP30Component::update() { }); } -} // namespace sgp30 -} // namespace esphome +} // namespace esphome::sgp30 diff --git a/esphome/components/sgp30/sgp30.h b/esphome/components/sgp30/sgp30.h index 4648a33e15..cb4aa1c1bb 100644 --- a/esphome/components/sgp30/sgp30.h +++ b/esphome/components/sgp30/sgp30.h @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace sgp30 { +namespace esphome::sgp30 { struct SGP30Baselines { uint16_t eco2; @@ -67,5 +66,4 @@ class SGP30Component : public PollingComponent, public sensirion_common::Sensiri sensor::Sensor *temperature_sensor_{nullptr}; }; -} // namespace sgp30 -} // namespace esphome +} // namespace esphome::sgp30 diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index bbf1ffd4c0..94e6d69dcb 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace sgp4x { +namespace esphome::sgp4x { static const char *const TAG = "sgp4x"; @@ -290,5 +289,4 @@ void SGP4xComponent::dump_config() { LOG_SENSOR(" ", "NOx", this->nox_sensor_); } -} // namespace sgp4x -} // namespace esphome +} // namespace esphome::sgp4x diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 6b8b598aff..23bf6319a9 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -11,8 +11,7 @@ #include #include -namespace esphome { -namespace sgp4x { +namespace esphome::sgp4x { struct SGP4xBaselines { int32_t state0; @@ -134,5 +133,4 @@ class SGP4xComponent : public PollingComponent, public sensor::Sensor, public se uint32_t seconds_since_last_store_; SGP4xBaselines voc_baselines_storage_; }; -} // namespace sgp4x -} // namespace esphome +} // namespace esphome::sgp4x diff --git a/esphome/components/sht3xd/sht3xd.cpp b/esphome/components/sht3xd/sht3xd.cpp index 8050a2d5f9..eba6be65d5 100644 --- a/esphome/components/sht3xd/sht3xd.cpp +++ b/esphome/components/sht3xd/sht3xd.cpp @@ -1,8 +1,7 @@ #include "sht3xd.h" #include "esphome/core/log.h" -namespace esphome { -namespace sht3xd { +namespace esphome::sht3xd { static const char *const TAG = "sht3xd"; @@ -82,5 +81,4 @@ void SHT3XDComponent::update() { }); } -} // namespace sht3xd -} // namespace esphome +} // namespace esphome::sht3xd diff --git a/esphome/components/sht3xd/sht3xd.h b/esphome/components/sht3xd/sht3xd.h index 54514d6de7..6df5587507 100644 --- a/esphome/components/sht3xd/sht3xd.h +++ b/esphome/components/sht3xd/sht3xd.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" -namespace esphome { -namespace sht3xd { +namespace esphome::sht3xd { /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. class SHT3XDComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { @@ -25,5 +24,4 @@ class SHT3XDComponent : public PollingComponent, public sensirion_common::Sensir uint32_t serial_number_{0}; }; -} // namespace sht3xd -} // namespace esphome +} // namespace esphome::sht3xd diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index b1dbde22a4..4a3df5a91f 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace sht4x { +namespace esphome::sht4x { static const char *const TAG = "sht4x"; @@ -127,5 +126,4 @@ void SHT4XComponent::update() { }); } -} // namespace sht4x -} // namespace esphome +} // namespace esphome::sht4x diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index 51f473fe3f..d1fa9033df 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace sht4x { +namespace esphome::sht4x { enum SHT4XPRECISION { SHT4X_PRECISION_HIGH = 0, SHT4X_PRECISION_MED, SHT4X_PRECISION_LOW }; @@ -45,5 +44,4 @@ class SHT4XComponent : public PollingComponent, public sensirion_common::Sensiri sensor::Sensor *humidity_sensor_{nullptr}; }; -} // namespace sht4x -} // namespace esphome +} // namespace esphome::sht4x diff --git a/esphome/components/shutdown/button/shutdown_button.cpp b/esphome/components/shutdown/button/shutdown_button.cpp index b40af7517b..6394c6c14e 100644 --- a/esphome/components/shutdown/button/shutdown_button.cpp +++ b/esphome/components/shutdown/button/shutdown_button.cpp @@ -10,8 +10,7 @@ #include #endif -namespace esphome { -namespace shutdown { +namespace esphome::shutdown { static const char *const TAG = "shutdown.button"; @@ -29,5 +28,4 @@ void ShutdownButton::press_action() { #endif } -} // namespace shutdown -} // namespace esphome +} // namespace esphome::shutdown diff --git a/esphome/components/shutdown/button/shutdown_button.h b/esphome/components/shutdown/button/shutdown_button.h index d0094c899d..d4247ec0f9 100644 --- a/esphome/components/shutdown/button/shutdown_button.h +++ b/esphome/components/shutdown/button/shutdown_button.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/button/button.h" -namespace esphome { -namespace shutdown { +namespace esphome::shutdown { class ShutdownButton : public button::Button, public Component { public: @@ -14,5 +13,4 @@ class ShutdownButton : public button::Button, public Component { void press_action() override; }; -} // namespace shutdown -} // namespace esphome +} // namespace esphome::shutdown diff --git a/esphome/components/shutdown/switch/shutdown_switch.cpp b/esphome/components/shutdown/switch/shutdown_switch.cpp index b685ab14ab..a44a572aa1 100644 --- a/esphome/components/shutdown/switch/shutdown_switch.cpp +++ b/esphome/components/shutdown/switch/shutdown_switch.cpp @@ -10,8 +10,7 @@ #include #endif -namespace esphome { -namespace shutdown { +namespace esphome::shutdown { static const char *const TAG = "shutdown.switch"; @@ -34,5 +33,4 @@ void ShutdownSwitch::write_state(bool state) { } } -} // namespace shutdown -} // namespace esphome +} // namespace esphome::shutdown diff --git a/esphome/components/shutdown/switch/shutdown_switch.h b/esphome/components/shutdown/switch/shutdown_switch.h index 6aa64ff06b..933345915f 100644 --- a/esphome/components/shutdown/switch/shutdown_switch.h +++ b/esphome/components/shutdown/switch/shutdown_switch.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace shutdown { +namespace esphome::shutdown { class ShutdownSwitch : public switch_::Switch, public Component { public: @@ -14,5 +13,4 @@ class ShutdownSwitch : public switch_::Switch, public Component { void write_state(bool state) override; }; -} // namespace shutdown -} // namespace esphome +} // namespace esphome::shutdown diff --git a/esphome/components/sigma_delta_output/sigma_delta_output.cpp b/esphome/components/sigma_delta_output/sigma_delta_output.cpp index d386f8db1a..6cc131c68f 100644 --- a/esphome/components/sigma_delta_output/sigma_delta_output.cpp +++ b/esphome/components/sigma_delta_output/sigma_delta_output.cpp @@ -1,8 +1,7 @@ #include "sigma_delta_output.h" #include "esphome/core/log.h" -namespace esphome { -namespace sigma_delta_output { +namespace esphome::sigma_delta_output { static const char *const TAG = "output.sigma_delta"; @@ -53,5 +52,4 @@ void SigmaDeltaOutput::update() { } } -} // namespace sigma_delta_output -} // namespace esphome +} // namespace esphome::sigma_delta_output diff --git a/esphome/components/sigma_delta_output/sigma_delta_output.h b/esphome/components/sigma_delta_output/sigma_delta_output.h index 8fd1e1f761..a5df3c6c7c 100644 --- a/esphome/components/sigma_delta_output/sigma_delta_output.h +++ b/esphome/components/sigma_delta_output/sigma_delta_output.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace sigma_delta_output { +namespace esphome::sigma_delta_output { class SigmaDeltaOutput : public PollingComponent, public output::FloatOutput { public: @@ -43,5 +42,4 @@ class SigmaDeltaOutput : public PollingComponent, public output::FloatOutput { float state_{0.}; bool value_{false}; }; -} // namespace sigma_delta_output -} // namespace esphome +} // namespace esphome::sigma_delta_output diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index 001ec77454..b8e97b1121 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace sim800l { +namespace esphome::sim800l { static const char *const TAG = "sim800l"; @@ -492,5 +491,4 @@ void Sim800LComponent::set_registered_(bool registered) { #endif } -} // namespace sim800l -} // namespace esphome +} // namespace esphome::sim800l diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index d0da123039..0b3259ede0 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -13,8 +13,7 @@ #include "esphome/components/uart/uart.h" #include "esphome/core/automation.h" -namespace esphome { -namespace sim800l { +namespace esphome::sim800l { const uint16_t SIM800L_READ_BUFFER_LENGTH = 1024; @@ -184,5 +183,4 @@ template class Sim800LDisconnectAction : public Action { Sim800LComponent *parent_; }; -} // namespace sim800l -} // namespace esphome +} // namespace esphome::sim800l diff --git a/esphome/components/slow_pwm/slow_pwm_output.cpp b/esphome/components/slow_pwm/slow_pwm_output.cpp index 033729c407..e695ab9540 100644 --- a/esphome/components/slow_pwm/slow_pwm_output.cpp +++ b/esphome/components/slow_pwm/slow_pwm_output.cpp @@ -3,8 +3,7 @@ #include "esphome/core/gpio.h" #include "esphome/core/log.h" -namespace esphome { -namespace slow_pwm { +namespace esphome::slow_pwm { static const char *const TAG = "output.slow_pwm"; @@ -79,5 +78,4 @@ void SlowPWMOutput::write_state(float state) { this->restart_cycle(); } -} // namespace slow_pwm -} // namespace esphome +} // namespace esphome::slow_pwm diff --git a/esphome/components/slow_pwm/slow_pwm_output.h b/esphome/components/slow_pwm/slow_pwm_output.h index 3e5a3e2a40..d866435af1 100644 --- a/esphome/components/slow_pwm/slow_pwm_output.h +++ b/esphome/components/slow_pwm/slow_pwm_output.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace slow_pwm { +namespace esphome::slow_pwm { class SlowPWMOutput : public output::FloatOutput, public Component { public: @@ -57,5 +56,4 @@ class SlowPWMOutput : public output::FloatOutput, public Component { bool restart_cycle_on_state_change_; }; -} // namespace slow_pwm -} // namespace esphome +} // namespace esphome::slow_pwm diff --git a/esphome/components/sm10bit_base/sm10bit_base.cpp b/esphome/components/sm10bit_base/sm10bit_base.cpp index d380f31c6f..45de3c457d 100644 --- a/esphome/components/sm10bit_base/sm10bit_base.cpp +++ b/esphome/components/sm10bit_base/sm10bit_base.cpp @@ -1,8 +1,7 @@ #include "sm10bit_base.h" #include "esphome/core/log.h" -namespace esphome { -namespace sm10bit_base { +namespace esphome::sm10bit_base { static const char *const TAG = "sm10bit_base"; @@ -127,5 +126,4 @@ void Sm10BitBase::write_buffer_(uint8_t *buffer, uint8_t size) { delayMicroseconds(SM10BIT_DELAY); } -} // namespace sm10bit_base -} // namespace esphome +} // namespace esphome::sm10bit_base diff --git a/esphome/components/sm10bit_base/sm10bit_base.h b/esphome/components/sm10bit_base/sm10bit_base.h index c8e92e352f..b419b86dbf 100644 --- a/esphome/components/sm10bit_base/sm10bit_base.h +++ b/esphome/components/sm10bit_base/sm10bit_base.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include -namespace esphome { -namespace sm10bit_base { +namespace esphome::sm10bit_base { class Sm10BitBase : public Component { public: @@ -59,5 +58,4 @@ class Sm10BitBase : public Component { bool update_{true}; }; -} // namespace sm10bit_base -} // namespace esphome +} // namespace esphome::sm10bit_base diff --git a/esphome/components/sm16716/sm16716.cpp b/esphome/components/sm16716/sm16716.cpp index b8e293929b..59e9f1b712 100644 --- a/esphome/components/sm16716/sm16716.cpp +++ b/esphome/components/sm16716/sm16716.cpp @@ -1,8 +1,7 @@ #include "sm16716.h" #include "esphome/core/log.h" -namespace esphome { -namespace sm16716 { +namespace esphome::sm16716 { static const char *const TAG = "sm16716"; @@ -49,5 +48,4 @@ void SM16716::loop() { this->update_ = false; } -} // namespace sm16716 -} // namespace esphome +} // namespace esphome::sm16716 diff --git a/esphome/components/sm16716/sm16716.h b/esphome/components/sm16716/sm16716.h index 73414c0003..09deb2e8bf 100644 --- a/esphome/components/sm16716/sm16716.h +++ b/esphome/components/sm16716/sm16716.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include -namespace esphome { -namespace sm16716 { +namespace esphome::sm16716 { class SM16716 : public Component { public: @@ -68,5 +67,4 @@ class SM16716 : public Component { bool update_{true}; }; -} // namespace sm16716 -} // namespace esphome +} // namespace esphome::sm16716 diff --git a/esphome/components/sm2135/sm2135.cpp b/esphome/components/sm2135/sm2135.cpp index c3d10e70c2..0086a63878 100644 --- a/esphome/components/sm2135/sm2135.cpp +++ b/esphome/components/sm2135/sm2135.cpp @@ -3,8 +3,7 @@ // Tnx to the work of https://github.com/arendst (Tasmota) for making the initial version of the driver -namespace esphome { -namespace sm2135 { +namespace esphome::sm2135 { static const char *const TAG = "sm2135"; @@ -149,5 +148,4 @@ void SM2135::sm2135_set_high_(GPIOPin *pin) { pin->pin_mode(gpio::FLAG_PULLUP); } -} // namespace sm2135 -} // namespace esphome +} // namespace esphome::sm2135 diff --git a/esphome/components/sm2135/sm2135.h b/esphome/components/sm2135/sm2135.h index 6f207d093a..040ec14b7f 100644 --- a/esphome/components/sm2135/sm2135.h +++ b/esphome/components/sm2135/sm2135.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace sm2135 { +namespace esphome::sm2135 { enum SM2135Current : uint8_t { SM2135_CURRENT_10MA = 0x00, @@ -86,5 +85,4 @@ class SM2135 : public Component { bool update_{true}; }; -} // namespace sm2135 -} // namespace esphome +} // namespace esphome::sm2135 diff --git a/esphome/components/sm2235/sm2235.cpp b/esphome/components/sm2235/sm2235.cpp index 4476862318..e981bd0f71 100644 --- a/esphome/components/sm2235/sm2235.cpp +++ b/esphome/components/sm2235/sm2235.cpp @@ -1,8 +1,7 @@ #include "sm2235.h" #include "esphome/core/log.h" -namespace esphome { -namespace sm2235 { +namespace esphome::sm2235 { static const char *const TAG = "sm2235"; @@ -24,5 +23,4 @@ void SM2235::dump_config() { LOG_PIN(" Clock Pin: ", this->clock_pin_); } -} // namespace sm2235 -} // namespace esphome +} // namespace esphome::sm2235 diff --git a/esphome/components/sm2235/sm2235.h b/esphome/components/sm2235/sm2235.h index 56d1782055..cdb754e298 100644 --- a/esphome/components/sm2235/sm2235.h +++ b/esphome/components/sm2235/sm2235.h @@ -4,8 +4,7 @@ #include "esphome/components/sm10bit_base/sm10bit_base.h" #include "esphome/core/hal.h" -namespace esphome { -namespace sm2235 { +namespace esphome::sm2235 { class SM2235 : public sm10bit_base::Sm10BitBase { public: @@ -15,5 +14,4 @@ class SM2235 : public sm10bit_base::Sm10BitBase { void dump_config() override; }; -} // namespace sm2235 -} // namespace esphome +} // namespace esphome::sm2235 diff --git a/esphome/components/sm2335/sm2335.cpp b/esphome/components/sm2335/sm2335.cpp index f860517021..93f1096800 100644 --- a/esphome/components/sm2335/sm2335.cpp +++ b/esphome/components/sm2335/sm2335.cpp @@ -1,8 +1,7 @@ #include "sm2335.h" #include "esphome/core/log.h" -namespace esphome { -namespace sm2335 { +namespace esphome::sm2335 { static const char *const TAG = "sm2335"; @@ -24,5 +23,4 @@ void SM2335::dump_config() { LOG_PIN(" Clock Pin: ", this->clock_pin_); } -} // namespace sm2335 -} // namespace esphome +} // namespace esphome::sm2335 diff --git a/esphome/components/sm2335/sm2335.h b/esphome/components/sm2335/sm2335.h index c8cf825189..44e0e5b03f 100644 --- a/esphome/components/sm2335/sm2335.h +++ b/esphome/components/sm2335/sm2335.h @@ -4,8 +4,7 @@ #include "esphome/components/sm10bit_base/sm10bit_base.h" #include "esphome/core/hal.h" -namespace esphome { -namespace sm2335 { +namespace esphome::sm2335 { class SM2335 : public sm10bit_base::Sm10BitBase { public: @@ -15,5 +14,4 @@ class SM2335 : public sm10bit_base::Sm10BitBase { void dump_config() override; }; -} // namespace sm2335 -} // namespace esphome +} // namespace esphome::sm2335 diff --git a/esphome/components/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 365271cec9..391cc0ac11 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -1,8 +1,7 @@ #include "sm300d2.h" #include "esphome/core/log.h" -namespace esphome { -namespace sm300d2 { +namespace esphome::sm300d2 { static const char *const TAG = "sm300d2"; static const uint8_t SM300D2_RESPONSE_LENGTH = 17; @@ -104,5 +103,4 @@ void SM300D2Sensor::dump_config() { this->check_uart_settings(9600); } -} // namespace sm300d2 -} // namespace esphome +} // namespace esphome::sm300d2 diff --git a/esphome/components/sm300d2/sm300d2.h b/esphome/components/sm300d2/sm300d2.h index 4e97b54988..629e758e30 100644 --- a/esphome/components/sm300d2/sm300d2.h +++ b/esphome/components/sm300d2/sm300d2.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace sm300d2 { +namespace esphome::sm300d2 { class SM300D2Sensor : public PollingComponent, public uart::UARTDevice { public: @@ -32,5 +31,4 @@ class SM300D2Sensor : public PollingComponent, public uart::UARTDevice { sensor::Sensor *humidity_sensor_{nullptr}; }; -} // namespace sm300d2 -} // namespace esphome +} // namespace esphome::sm300d2 diff --git a/esphome/components/sml/constants.h b/esphome/components/sml/constants.h index 0142fe98f7..b44fff7bde 100644 --- a/esphome/components/sml/constants.h +++ b/esphome/components/sml/constants.h @@ -3,8 +3,7 @@ #include #include -namespace esphome { -namespace sml { +namespace esphome::sml { enum SmlType : uint8_t { SML_OCTET = 0, @@ -24,5 +23,4 @@ const uint16_t END_MASK = 0x0157; // 0x1b 1b 1b 1b 1a constexpr std::array START_SEQ = {0x1b, 0x1b, 0x1b, 0x1b, 0x01, 0x01, 0x01, 0x01}; -} // namespace sml -} // namespace esphome +} // namespace esphome::sml diff --git a/esphome/components/sml/sensor/sml_sensor.cpp b/esphome/components/sml/sensor/sml_sensor.cpp index e9a384d275..047a13e88d 100644 --- a/esphome/components/sml/sensor/sml_sensor.cpp +++ b/esphome/components/sml/sensor/sml_sensor.cpp @@ -2,8 +2,7 @@ #include "sml_sensor.h" #include "../sml_parser.h" -namespace esphome { -namespace sml { +namespace esphome::sml { static const char *const TAG = "sml_sensor"; @@ -37,5 +36,4 @@ void SmlSensor::dump_config() { ESP_LOGCONFIG(TAG, " OBIS Code: %s", this->obis_code.c_str()); } -} // namespace sml -} // namespace esphome +} // namespace esphome::sml diff --git a/esphome/components/sml/sensor/sml_sensor.h b/esphome/components/sml/sensor/sml_sensor.h index eb7b108f94..d2f8a7743f 100644 --- a/esphome/components/sml/sensor/sml_sensor.h +++ b/esphome/components/sml/sensor/sml_sensor.h @@ -2,8 +2,7 @@ #include "esphome/components/sml/sml.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace sml { +namespace esphome::sml { class SmlSensor : public SmlListener, public sensor::Sensor, public Component { public: @@ -12,5 +11,4 @@ class SmlSensor : public SmlListener, public sensor::Sensor, public Component { void dump_config() override; }; -} // namespace sml -} // namespace esphome +} // namespace esphome::sml diff --git a/esphome/components/sml/sml.cpp b/esphome/components/sml/sml.cpp index c8d5fcc269..bacfbcccef 100644 --- a/esphome/components/sml/sml.cpp +++ b/esphome/components/sml/sml.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "sml_parser.h" -namespace esphome { -namespace sml { +namespace esphome::sml { static const char *const TAG = "sml"; @@ -140,5 +139,4 @@ uint8_t get_code(uint8_t byte) { } } -} // namespace sml -} // namespace esphome +} // namespace esphome::sml diff --git a/esphome/components/sml/sml.h b/esphome/components/sml/sml.h index 29a2f48bbe..60a80e3ad8 100644 --- a/esphome/components/sml/sml.h +++ b/esphome/components/sml/sml.h @@ -7,8 +7,7 @@ #include "esphome/components/uart/uart.h" #include "sml_parser.h" -namespace esphome { -namespace sml { +namespace esphome::sml { class SmlListener { public: @@ -44,5 +43,4 @@ class Sml : public Component, public uart::UARTDevice { bool check_sml_data(const bytes &buffer); uint8_t get_code(uint8_t byte); -} // namespace sml -} // namespace esphome +} // namespace esphome::sml diff --git a/esphome/components/sml/sml_parser.cpp b/esphome/components/sml/sml_parser.cpp index ed086e385d..66e12ea64b 100644 --- a/esphome/components/sml/sml_parser.cpp +++ b/esphome/components/sml/sml_parser.cpp @@ -2,8 +2,7 @@ #include "constants.h" #include "sml_parser.h" -namespace esphome { -namespace sml { +namespace esphome::sml { SmlFile::SmlFile(const BytesView &buffer) : buffer_(buffer) { // extract messages @@ -158,5 +157,4 @@ std::string ObisInfo::code_repr() const { return buf; } -} // namespace sml -} // namespace esphome +} // namespace esphome::sml diff --git a/esphome/components/sml/sml_parser.h b/esphome/components/sml/sml_parser.h index bee0c8965b..2fe404c96a 100644 --- a/esphome/components/sml/sml_parser.h +++ b/esphome/components/sml/sml_parser.h @@ -7,8 +7,7 @@ #include #include "constants.h" -namespace esphome { -namespace sml { +namespace esphome::sml { using bytes = std::vector; @@ -80,5 +79,4 @@ uint64_t bytes_to_uint(const BytesView &buffer); int64_t bytes_to_int(const BytesView &buffer); std::string bytes_to_string(const BytesView &buffer); -} // namespace sml -} // namespace esphome +} // namespace esphome::sml diff --git a/esphome/components/sml/text_sensor/sml_text_sensor.cpp b/esphome/components/sml/text_sensor/sml_text_sensor.cpp index 17b93ecccf..d0965e3dca 100644 --- a/esphome/components/sml/text_sensor/sml_text_sensor.cpp +++ b/esphome/components/sml/text_sensor/sml_text_sensor.cpp @@ -4,8 +4,7 @@ #include "../sml_parser.h" #include -namespace esphome { -namespace sml { +namespace esphome::sml { static const char *const TAG = "sml_text_sensor"; @@ -60,5 +59,4 @@ void SmlTextSensor::dump_config() { ESP_LOGCONFIG(TAG, " OBIS Code: %s", this->obis_code.c_str()); } -} // namespace sml -} // namespace esphome +} // namespace esphome::sml diff --git a/esphome/components/sml/text_sensor/sml_text_sensor.h b/esphome/components/sml/text_sensor/sml_text_sensor.h index 20d27c9f71..6194f22349 100644 --- a/esphome/components/sml/text_sensor/sml_text_sensor.h +++ b/esphome/components/sml/text_sensor/sml_text_sensor.h @@ -4,8 +4,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #include "../constants.h" -namespace esphome { -namespace sml { +namespace esphome::sml { class SmlTextSensor : public SmlListener, public text_sensor::TextSensor, public Component { public: @@ -17,5 +16,4 @@ class SmlTextSensor : public SmlListener, public text_sensor::TextSensor, public SmlType format_; }; -} // namespace sml -} // namespace esphome +} // namespace esphome::sml diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 6eb6416447..ed33fc54c5 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -1,8 +1,7 @@ #include "smt100.h" #include "esphome/core/log.h" -namespace esphome { -namespace smt100 { +namespace esphome::smt100 { static const char *const TAG = "smt100"; @@ -91,5 +90,4 @@ int SMT100Component::readline_(int readch, char *buffer, int len) { return -1; } -} // namespace smt100 -} // namespace esphome +} // namespace esphome::smt100 diff --git a/esphome/components/smt100/smt100.h b/esphome/components/smt100/smt100.h index cb01b1ed55..b68151eeb4 100644 --- a/esphome/components/smt100/smt100.h +++ b/esphome/components/smt100/smt100.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace smt100 { +namespace esphome::smt100 { class SMT100Component : public PollingComponent, public uart::UARTDevice { static const uint16_t MAX_LINE_LENGTH = 31; @@ -40,5 +39,4 @@ class SMT100Component : public PollingComponent, public uart::UARTDevice { uint32_t last_transmission_{0}; }; -} // namespace smt100 -} // namespace esphome +} // namespace esphome::smt100 diff --git a/esphome/components/sn74hc165/sn74hc165.cpp b/esphome/components/sn74hc165/sn74hc165.cpp index 63b3f98521..fde789e90c 100644 --- a/esphome/components/sn74hc165/sn74hc165.cpp +++ b/esphome/components/sn74hc165/sn74hc165.cpp @@ -1,8 +1,7 @@ #include "sn74hc165.h" #include "esphome/core/log.h" -namespace esphome { -namespace sn74hc165 { +namespace esphome::sn74hc165 { static const char *const TAG = "sn74hc165"; @@ -68,5 +67,4 @@ size_t SN74HC165GPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "%u via SN74HC165", this->pin_); } -} // namespace sn74hc165 -} // namespace esphome +} // namespace esphome::sn74hc165 diff --git a/esphome/components/sn74hc165/sn74hc165.h b/esphome/components/sn74hc165/sn74hc165.h index 5a3f3fe8ef..596f2eb4f5 100644 --- a/esphome/components/sn74hc165/sn74hc165.h +++ b/esphome/components/sn74hc165/sn74hc165.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace sn74hc165 { +namespace esphome::sn74hc165 { class SN74HC165Component : public Component { public: @@ -60,5 +59,4 @@ class SN74HC165GPIOPin : public GPIOPin, public Parented { bool inverted_; }; -} // namespace sn74hc165 -} // namespace esphome +} // namespace esphome::sn74hc165 diff --git a/esphome/components/sn74hc595/sn74hc595.cpp b/esphome/components/sn74hc595/sn74hc595.cpp index 1bb8c7936d..710e51ad12 100644 --- a/esphome/components/sn74hc595/sn74hc595.cpp +++ b/esphome/components/sn74hc595/sn74hc595.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace sn74hc595 { +namespace esphome::sn74hc595 { static const char *const TAG = "sn74hc595"; @@ -97,5 +96,4 @@ size_t SN74HC595GPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "%u via SN74HC595", this->pin_); } -} // namespace sn74hc595 -} // namespace esphome +} // namespace esphome::sn74hc595 diff --git a/esphome/components/sn74hc595/sn74hc595.h b/esphome/components/sn74hc595/sn74hc595.h index 1cf70c86b5..23977e3d04 100644 --- a/esphome/components/sn74hc595/sn74hc595.h +++ b/esphome/components/sn74hc595/sn74hc595.h @@ -11,8 +11,7 @@ #include -namespace esphome { -namespace sn74hc595 { +namespace esphome::sn74hc595 { class SN74HC595Component : public Component { public: @@ -93,5 +92,4 @@ class SN74HC595SPIComponent : public SN74HC595Component, #endif -} // namespace sn74hc595 -} // namespace esphome +} // namespace esphome::sn74hc595 diff --git a/esphome/components/sntp/sntp_component.cpp b/esphome/components/sntp/sntp_component.cpp index c4d78b6e0b..f8d48b4098 100644 --- a/esphome/components/sntp/sntp_component.cpp +++ b/esphome/components/sntp/sntp_component.cpp @@ -9,8 +9,7 @@ #include "lwip/apps/sntp.h" #endif -namespace esphome { -namespace sntp { +namespace esphome::sntp { static const char *const TAG = "sntp"; @@ -102,5 +101,4 @@ void SNTPComponent::time_synced() { this->time_sync_callback_.call(); } -} // namespace sntp -} // namespace esphome +} // namespace esphome::sntp diff --git a/esphome/components/sntp/sntp_component.h b/esphome/components/sntp/sntp_component.h index 8f2e411c18..ef737c1978 100644 --- a/esphome/components/sntp/sntp_component.h +++ b/esphome/components/sntp/sntp_component.h @@ -4,8 +4,7 @@ #include "esphome/components/time/real_time_clock.h" #include -namespace esphome { -namespace sntp { +namespace esphome::sntp { // Server count is calculated at compile time by Python codegen // SNTP_SERVER_COUNT will always be defined @@ -42,5 +41,4 @@ class SNTPComponent : public time::RealTimeClock { #endif }; -} // namespace sntp -} // namespace esphome +} // namespace esphome::sntp diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index 03586b6398..3e5af2b51f 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -44,8 +44,7 @@ #include "sonoff_d1.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace sonoff_d1 { +namespace esphome::sonoff_d1 { static const char *const TAG = "sonoff_d1"; @@ -321,5 +320,4 @@ void SonoffD1Output::loop() { } } -} // namespace sonoff_d1 -} // namespace esphome +} // namespace esphome::sonoff_d1 diff --git a/esphome/components/sonoff_d1/sonoff_d1.h b/esphome/components/sonoff_d1/sonoff_d1.h index 20bea23287..a92877e6c8 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.h +++ b/esphome/components/sonoff_d1/sonoff_d1.h @@ -39,8 +39,7 @@ #include "esphome/components/light/light_state.h" #include "esphome/components/light/light_traits.h" -namespace esphome { -namespace sonoff_d1 { +namespace esphome::sonoff_d1 { class SonoffD1Output : public light::LightOutput, public uart::UARTDevice, public Component { public: @@ -80,5 +79,4 @@ class SonoffD1Output : public light::LightOutput, public uart::UARTDevice, publi void publish_state_(bool is_on, uint8_t brightness); }; -} // namespace sonoff_d1 -} // namespace esphome +} // namespace esphome::sonoff_d1 diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index 2719172409..99533dbdd5 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -7,8 +7,7 @@ #include #include -namespace esphome { -namespace sound_level { +namespace esphome::sound_level { static const char *const TAG = "sound_level"; @@ -190,7 +189,6 @@ bool SoundLevelComponent::start_() { void SoundLevelComponent::stop_() { this->audio_buffer_.reset(); } -} // namespace sound_level -} // namespace esphome +} // namespace esphome::sound_level #endif diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index a1021eb1e8..0e46a203e8 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -10,8 +10,7 @@ #include "esphome/core/component.h" #include "esphome/core/ring_buffer.h" -namespace esphome { -namespace sound_level { +namespace esphome::sound_level { class SoundLevelComponent : public Component { public: @@ -69,6 +68,6 @@ template class StopAction : public Action, public Parente void play(const Ts &...x) override { this->parent_->stop(); } }; -} // namespace sound_level -} // namespace esphome +} // namespace esphome::sound_level + #endif diff --git a/esphome/components/speaker/automation.h b/esphome/components/speaker/automation.h index 391c9e4c62..9997b064d5 100644 --- a/esphome/components/speaker/automation.h +++ b/esphome/components/speaker/automation.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace speaker { +namespace esphome::speaker { template class PlayAction : public Action, public Parented { public: @@ -84,5 +83,4 @@ template class IsStoppedCondition : public Condition, pub bool check(const Ts &...x) override { return this->parent_->is_stopped(); } }; -} // namespace speaker -} // namespace esphome +} // namespace esphome::speaker diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 0822d80254..892d4f4112 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -7,8 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace speaker { +namespace esphome::speaker { static const uint32_t INITIAL_BUFFER_MS = 1000; // Start playback after buffering this duration of the file @@ -513,7 +512,6 @@ void AudioPipeline::decode_task(void *params) { } } -} // namespace speaker -} // namespace esphome +} // namespace esphome::speaker #endif diff --git a/esphome/components/speaker/media_player/audio_pipeline.h b/esphome/components/speaker/media_player/audio_pipeline.h index 2c78572835..ef7f75dd38 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.h +++ b/esphome/components/speaker/media_player/audio_pipeline.h @@ -15,8 +15,7 @@ #include #include -namespace esphome { -namespace speaker { +namespace esphome::speaker { // Internal sink/source buffers for reader and decoder static const size_t DEFAULT_TRANSFER_BUFFER_SIZE = 24 * 1024; @@ -147,7 +146,6 @@ class AudioPipeline { StaticTask decode_task_; }; -} // namespace speaker -} // namespace esphome +} // namespace esphome::speaker #endif diff --git a/esphome/components/speaker/media_player/automation.h b/esphome/components/speaker/media_player/automation.h index 6270da7bd4..7843399866 100644 --- a/esphome/components/speaker/media_player/automation.h +++ b/esphome/components/speaker/media_player/automation.h @@ -7,8 +7,7 @@ #include "esphome/components/audio/audio.h" #include "esphome/core/automation.h" -namespace esphome { -namespace speaker { +namespace esphome::speaker { template class PlayOnDeviceMediaAction : public Action, public Parented { TEMPLATABLE_VALUE(audio::AudioFile *, audio_file) @@ -20,7 +19,6 @@ template class PlayOnDeviceMediaAction : public Action, p } }; -} // namespace speaker -} // namespace esphome +} // namespace esphome::speaker #endif diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index afd93b3f45..7d9cfecfdf 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -9,8 +9,7 @@ #include "esphome/components/ota/ota_backend.h" #endif -namespace esphome { -namespace speaker { +namespace esphome::speaker { // Framework: // - Media player that can handle two streams: one for media and one for announcements @@ -622,7 +621,6 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); } -} // namespace speaker -} // namespace esphome +} // namespace esphome::speaker #endif diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 3fa6f47b84..2d80377312 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -21,8 +21,7 @@ #include #include -namespace esphome { -namespace speaker { +namespace esphome::speaker { struct MediaCallCommand { optional command; @@ -167,7 +166,6 @@ class SpeakerMediaPlayer : public Component, Trigger volume_trigger_; }; -} // namespace speaker -} // namespace esphome +} // namespace esphome::speaker #endif diff --git a/esphome/components/speaker/speaker.h b/esphome/components/speaker/speaker.h index 5b89d00c69..c89b6c588c 100644 --- a/esphome/components/speaker/speaker.h +++ b/esphome/components/speaker/speaker.h @@ -16,8 +16,7 @@ #include "esphome/components/audio_dac/audio_dac.h" #endif -namespace esphome { -namespace speaker { +namespace esphome::speaker { enum State : uint8_t { STATE_STOPPED = 0, @@ -123,5 +122,4 @@ class Speaker { CallbackManager audio_output_callback_{}; }; -} // namespace speaker -} // namespace esphome +} // namespace esphome::speaker diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index eaa8a55858..af46ef52e1 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -1,8 +1,7 @@ #include "speed_fan.h" #include "esphome/core/log.h" -namespace esphome { -namespace speed { +namespace esphome::speed { static const char *const TAG = "speed.fan"; @@ -47,5 +46,4 @@ void SpeedFan::write_state_() { this->direction_->set_state(this->direction == fan::FanDirection::REVERSE); } -} // namespace speed -} // namespace esphome +} // namespace esphome::speed diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index db96039a13..c618d6bc5f 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/fan/fan.h" -namespace esphome { -namespace speed { +namespace esphome::speed { class SpeedFan : public Component, public fan::Fan { public: @@ -33,5 +32,4 @@ class SpeedFan : public Component, public fan::Fan { fan::FanTraits traits_; }; -} // namespace speed -} // namespace esphome +} // namespace esphome::speed diff --git a/esphome/components/spi_device/spi_device.cpp b/esphome/components/spi_device/spi_device.cpp index 34f83027db..bdf5978bc7 100644 --- a/esphome/components/spi_device/spi_device.cpp +++ b/esphome/components/spi_device/spi_device.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace spi_device { +namespace esphome::spi_device { static const char *const TAG = "spi_device"; @@ -23,5 +22,4 @@ void SPIDeviceComponent::dump_config() { } } -} // namespace spi_device -} // namespace esphome +} // namespace esphome::spi_device diff --git a/esphome/components/spi_device/spi_device.h b/esphome/components/spi_device/spi_device.h index e3aa74aaf0..3a2523fbab 100644 --- a/esphome/components/spi_device/spi_device.h +++ b/esphome/components/spi_device/spi_device.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace spi_device { +namespace esphome::spi_device { class SPIDeviceComponent : public Component, public spi::SPIDevicenum_leds_ = num_leds; @@ -65,5 +64,4 @@ light::ESPColorView SpiLedStrip::get_view_internal(int32_t index) const { return {this->buf_ + pos + 2, this->buf_ + pos + 1, this->buf_ + pos + 0, nullptr, this->effect_data_ + index, &this->correction_}; } -} // namespace spi_led_strip -} // namespace esphome +} // namespace esphome::spi_led_strip diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index 14c5627ac3..e2bcd5af63 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -5,8 +5,7 @@ #include "esphome/components/light/addressable_light.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace spi_led_strip { +namespace esphome::spi_led_strip { static const char *const TAG = "spi_led_strip"; class SpiLedStrip : public light::AddressableLight, @@ -36,5 +35,4 @@ class SpiLedStrip : public light::AddressableLight, uint16_t num_leds_; }; -} // namespace spi_led_strip -} // namespace esphome +} // namespace esphome::spi_led_strip diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h index 5eafc1b6c2..e58f857eb3 100644 --- a/esphome/components/sps30/automation.h +++ b/esphome/components/sps30/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "sps30.h" -namespace esphome { -namespace sps30 { +namespace esphome::sps30 { template class StartFanAction : public Action, public Parented { public: @@ -22,5 +21,4 @@ template class StopMeasurementAction : public Action, pub void play(const Ts &...x) override { this->parent_->stop_measurement(); } }; -} // namespace sps30 -} // namespace esphome +} // namespace esphome::sps30 diff --git a/esphome/components/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index e4fc4ffd31..73fa4ef463 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace sps30 { +namespace esphome::sps30 { static const char *const TAG = "sps30"; @@ -291,5 +290,4 @@ bool SPS30Component::start_fan_cleaning() { return true; } -} // namespace sps30 -} // namespace esphome +} // namespace esphome::sps30 diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index 4e9b90ba7e..ccb3e8ff41 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" -namespace esphome { -namespace sps30 { +namespace esphome::sps30 { /// This class implements support for the Sensirion SPS30 i2c/UART Particulate Matter /// PM1.0, PM2.5, PM4, PM10 Air Quality sensors. @@ -67,5 +66,4 @@ class SPS30Component : public PollingComponent, public sensirion_common::Sensiri optional idle_interval_; }; -} // namespace sps30 -} // namespace esphome +} // namespace esphome::sps30 diff --git a/esphome/components/ssd1306_base/ssd1306_base.cpp b/esphome/components/ssd1306_base/ssd1306_base.cpp index 5bd83ec8a8..5f1c6fca8f 100644 --- a/esphome/components/ssd1306_base/ssd1306_base.cpp +++ b/esphome/components/ssd1306_base/ssd1306_base.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/progmem.h" -namespace esphome { -namespace ssd1306_base { +namespace esphome::ssd1306_base { static const char *const TAG = "ssd1306"; @@ -376,5 +375,4 @@ const LogString *SSD1306::model_str_() { return ModelStrings::get_log_str(static_cast(this->model_), ModelStrings::LAST_INDEX); } -} // namespace ssd1306_base -} // namespace esphome +} // namespace esphome::ssd1306_base diff --git a/esphome/components/ssd1306_base/ssd1306_base.h b/esphome/components/ssd1306_base/ssd1306_base.h index 3cc795a323..7b4c9fe0bf 100644 --- a/esphome/components/ssd1306_base/ssd1306_base.h +++ b/esphome/components/ssd1306_base/ssd1306_base.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/display/display_buffer.h" -namespace esphome { -namespace ssd1306_base { +namespace esphome::ssd1306_base { enum SSD1306Model { SSD1306_MODEL_128_32 = 0, @@ -88,5 +87,4 @@ class SSD1306 : public display::DisplayBuffer { bool invert_{false}; }; -} // namespace ssd1306_base -} // namespace esphome +} // namespace esphome::ssd1306_base diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index e1f6e91243..8ff908fe7a 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -1,8 +1,7 @@ #include "ssd1306_i2c.h" #include "esphome/core/log.h" -namespace esphome { -namespace ssd1306_i2c { +namespace esphome::ssd1306_i2c { static const char *const TAG = "ssd1306_i2c"; @@ -76,5 +75,4 @@ void HOT I2CSSD1306::write_display_data() { } } -} // namespace ssd1306_i2c -} // namespace esphome +} // namespace esphome::ssd1306_i2c diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.h b/esphome/components/ssd1306_i2c/ssd1306_i2c.h index e3f21fe74c..0316da0e77 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.h +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.h @@ -4,8 +4,7 @@ #include "esphome/components/ssd1306_base/ssd1306_base.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ssd1306_i2c { +namespace esphome::ssd1306_i2c { class I2CSSD1306 : public ssd1306_base::SSD1306, public i2c::I2CDevice { public: @@ -19,5 +18,4 @@ class I2CSSD1306 : public ssd1306_base::SSD1306, public i2c::I2CDevice { enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; -} // namespace ssd1306_i2c -} // namespace esphome +} // namespace esphome::ssd1306_i2c diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.cpp b/esphome/components/ssd1306_spi/ssd1306_spi.cpp index af9a17c8ab..5c9369f1a2 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.cpp +++ b/esphome/components/ssd1306_spi/ssd1306_spi.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace ssd1306_spi { +namespace esphome::ssd1306_spi { static const char *const TAG = "ssd1306_spi"; @@ -63,5 +62,4 @@ void HOT SPISSD1306::write_display_data() { } } -} // namespace ssd1306_spi -} // namespace esphome +} // namespace esphome::ssd1306_spi diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.h b/esphome/components/ssd1306_spi/ssd1306_spi.h index c58ebc800a..f8346033b3 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.h +++ b/esphome/components/ssd1306_spi/ssd1306_spi.h @@ -4,8 +4,7 @@ #include "esphome/components/ssd1306_base/ssd1306_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace ssd1306_spi { +namespace esphome::ssd1306_spi { class SPISSD1306 : public ssd1306_base::SSD1306, public spi::SPIDevicedisable(); } -} // namespace ssd1322_spi -} // namespace esphome +} // namespace esphome::ssd1322_spi diff --git a/esphome/components/ssd1322_spi/ssd1322_spi.h b/esphome/components/ssd1322_spi/ssd1322_spi.h index 316742706e..31d17d0ef1 100644 --- a/esphome/components/ssd1322_spi/ssd1322_spi.h +++ b/esphome/components/ssd1322_spi/ssd1322_spi.h @@ -4,8 +4,7 @@ #include "esphome/components/ssd1322_base/ssd1322_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace ssd1322_spi { +namespace esphome::ssd1322_spi { class SPISSD1322 : public ssd1322_base::SSD1322, public spi::SPIDevicedisable(); } -} // namespace ssd1325_spi -} // namespace esphome +} // namespace esphome::ssd1325_spi diff --git a/esphome/components/ssd1325_spi/ssd1325_spi.h b/esphome/components/ssd1325_spi/ssd1325_spi.h index e4e7d55769..32cbb28fd8 100644 --- a/esphome/components/ssd1325_spi/ssd1325_spi.h +++ b/esphome/components/ssd1325_spi/ssd1325_spi.h @@ -4,8 +4,7 @@ #include "esphome/components/ssd1325_base/ssd1325_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace ssd1325_spi { +namespace esphome::ssd1325_spi { class SPISSD1325 : public ssd1325_base::SSD1325, public spi::SPIDevicedisable(); } -} // namespace ssd1327_spi -} // namespace esphome +} // namespace esphome::ssd1327_spi diff --git a/esphome/components/ssd1327_spi/ssd1327_spi.h b/esphome/components/ssd1327_spi/ssd1327_spi.h index 6f7abea96f..fd1ed0357f 100644 --- a/esphome/components/ssd1327_spi/ssd1327_spi.h +++ b/esphome/components/ssd1327_spi/ssd1327_spi.h @@ -4,8 +4,7 @@ #include "esphome/components/ssd1327_base/ssd1327_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace ssd1327_spi { +namespace esphome::ssd1327_spi { class SPISSD1327 : public ssd1327_base::SSD1327, public spi::SPIDevicedisable(); } -} // namespace ssd1331_spi -} // namespace esphome +} // namespace esphome::ssd1331_spi diff --git a/esphome/components/ssd1331_spi/ssd1331_spi.h b/esphome/components/ssd1331_spi/ssd1331_spi.h index 93b2e228b1..acdc004b26 100644 --- a/esphome/components/ssd1331_spi/ssd1331_spi.h +++ b/esphome/components/ssd1331_spi/ssd1331_spi.h @@ -4,8 +4,7 @@ #include "esphome/components/ssd1331_base/ssd1331_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace ssd1331_spi { +namespace esphome::ssd1331_spi { class SPISSD1331 : public ssd1331_base::SSD1331, public spi::SPIDevicedisable(); } -} // namespace ssd1351_spi -} // namespace esphome +} // namespace esphome::ssd1351_spi diff --git a/esphome/components/ssd1351_spi/ssd1351_spi.h b/esphome/components/ssd1351_spi/ssd1351_spi.h index b8f3310f5c..5ce41c1f9e 100644 --- a/esphome/components/ssd1351_spi/ssd1351_spi.h +++ b/esphome/components/ssd1351_spi/ssd1351_spi.h @@ -4,8 +4,7 @@ #include "esphome/components/ssd1351_base/ssd1351_base.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace ssd1351_spi { +namespace esphome::ssd1351_spi { class SPISSD1351 : public ssd1351_base::SSD1351, public spi::SPIDevicewrite_array(byte, 4); } -} // namespace st7735 -} // namespace esphome +} // namespace esphome::st7735 diff --git a/esphome/components/st7735/st7735.h b/esphome/components/st7735/st7735.h index e81be520ed..7fa0ad7335 100644 --- a/esphome/components/st7735/st7735.h +++ b/esphome/components/st7735/st7735.h @@ -4,8 +4,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display_buffer.h" -namespace esphome { -namespace st7735 { +namespace esphome::st7735 { static const uint8_t ST7735_TFTWIDTH_128 = 128; // for 1.44 and mini^M static const uint8_t ST7735_TFTWIDTH_80 = 80; // for mini^M @@ -85,5 +84,4 @@ class ST7735 : public display::DisplayBuffer, GPIOPin *dc_pin_{nullptr}; }; -} // namespace st7735 -} // namespace esphome +} // namespace esphome::st7735 diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index dc03fb04ca..b3a60af8c3 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace st7789v { +namespace esphome::st7789v { static const char *const TAG = "st7789v"; #ifdef USE_ESP32 @@ -317,5 +316,4 @@ void HOT ST7789V::draw_absolute_pixel_internal(int x, int y, Color color) { } } -} // namespace st7789v -} // namespace esphome +} // namespace esphome::st7789v diff --git a/esphome/components/st7789v/st7789v.h b/esphome/components/st7789v/st7789v.h index 29ea315979..3f9942b117 100644 --- a/esphome/components/st7789v/st7789v.h +++ b/esphome/components/st7789v/st7789v.h @@ -7,8 +7,7 @@ #include "esphome/components/power_supply/power_supply.h" #endif -namespace esphome { -namespace st7789v { +namespace esphome::st7789v { static const uint8_t ST7789_NOP = 0x00; // No Operation static const uint8_t ST7789_SWRESET = 0x01; // Software Reset @@ -168,5 +167,4 @@ class ST7789V : public display::DisplayBuffer, const char *model_str_; }; -} // namespace st7789v -} // namespace esphome +} // namespace esphome::st7789v diff --git a/esphome/components/st7920/st7920.cpp b/esphome/components/st7920/st7920.cpp index a840f98152..429ed9e57b 100644 --- a/esphome/components/st7920/st7920.cpp +++ b/esphome/components/st7920/st7920.cpp @@ -3,8 +3,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" -namespace esphome { -namespace st7920 { +namespace esphome::st7920 { static const char *const TAG = "st7920"; @@ -154,5 +153,4 @@ void ST7920::display_init_() { this->write_display_data(); } -} // namespace st7920 -} // namespace esphome +} // namespace esphome::st7920 diff --git a/esphome/components/st7920/st7920.h b/esphome/components/st7920/st7920.h index c48fe8cc1c..71fe7aa89c 100644 --- a/esphome/components/st7920/st7920.h +++ b/esphome/components/st7920/st7920.h @@ -4,8 +4,7 @@ #include "esphome/components/display/display_buffer.h" #include "esphome/components/spi/spi.h" -namespace esphome { -namespace st7920 { +namespace esphome::st7920 { class ST7920; @@ -47,5 +46,4 @@ class ST7920 : public display::DisplayBuffer, st7920_writer_t writer_local_{}; }; -} // namespace st7920 -} // namespace esphome +} // namespace esphome::st7920 diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 7d773bc56e..7086e462a7 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -3,8 +3,8 @@ #include "statsd.h" #ifdef USE_NETWORK -namespace esphome { -namespace statsd { + +namespace esphome::statsd { // send UDP packet if we reach 1Kb packed size // this is needed since statsD does not support fragmented UDP packets @@ -165,6 +165,6 @@ void StatsdComponent::send_(std::string *out) { #endif } -} // namespace statsd -} // namespace esphome +} // namespace esphome::statsd + #endif diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index eab77a7a6e..349bffe6fb 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -25,8 +25,7 @@ #include "IPAddress.h" #endif -namespace esphome { -namespace statsd { +namespace esphome::statsd { class StatsdComponent : public PollingComponent { public: @@ -82,6 +81,6 @@ class StatsdComponent : public PollingComponent { void send_(std::string *out); }; -} // namespace statsd -} // namespace esphome +} // namespace esphome::statsd + #endif diff --git a/esphome/components/status_led/light/status_led_light.cpp b/esphome/components/status_led/light/status_led_light.cpp index ec7bf2dae1..341d3bfce5 100644 --- a/esphome/components/status_led/light/status_led_light.cpp +++ b/esphome/components/status_led/light/status_led_light.cpp @@ -3,8 +3,7 @@ #include "esphome/core/application.h" #include -namespace esphome { -namespace status_led { +namespace esphome::status_led { static const char *const TAG = "status_led"; @@ -71,5 +70,4 @@ void StatusLEDLightOutput::output_state_(bool state) { this->output_->set_state(state); } -} // namespace status_led -} // namespace esphome +} // namespace esphome::status_led diff --git a/esphome/components/status_led/light/status_led_light.h b/esphome/components/status_led/light/status_led_light.h index 3a745e0017..0483669d0a 100644 --- a/esphome/components/status_led/light/status_led_light.h +++ b/esphome/components/status_led/light/status_led_light.h @@ -5,8 +5,7 @@ #include "esphome/components/light/light_output.h" #include "esphome/components/output/binary_output.h" -namespace esphome { -namespace status_led { +namespace esphome::status_led { class StatusLEDLightOutput : public light::LightOutput, public Component { public: @@ -39,5 +38,4 @@ class StatusLEDLightOutput : public light::LightOutput, public Component { void output_state_(bool state); }; -} // namespace status_led -} // namespace esphome +} // namespace esphome::status_led diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index 48762a7333..ae37b3fae7 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace status_led { +namespace esphome::status_led { static const char *const TAG = "status_led"; @@ -40,5 +39,4 @@ void StatusLED::loop() { } float StatusLED::get_setup_priority() const { return setup_priority::HARDWARE; } -} // namespace status_led -} // namespace esphome +} // namespace esphome::status_led diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index a4b5db93d7..bda144d2cd 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace status_led { +namespace esphome::status_led { class StatusLED : public Component { public: @@ -21,5 +20,4 @@ class StatusLED : public Component { extern StatusLED *global_status_led; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace status_led -} // namespace esphome +} // namespace esphome::status_led diff --git a/esphome/components/stepper/stepper.cpp b/esphome/components/stepper/stepper.cpp index 7926024204..54df83782e 100644 --- a/esphome/components/stepper/stepper.cpp +++ b/esphome/components/stepper/stepper.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace stepper { +namespace esphome::stepper { static const char *const TAG = "stepper"; @@ -47,5 +46,4 @@ int32_t Stepper::should_step_() { return 0; } -} // namespace stepper -} // namespace esphome +} // namespace esphome::stepper diff --git a/esphome/components/stepper/stepper.h b/esphome/components/stepper/stepper.h index 2bad672494..9fbd0d92e6 100644 --- a/esphome/components/stepper/stepper.h +++ b/esphome/components/stepper/stepper.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" -namespace esphome { -namespace stepper { +namespace esphome::stepper { #define LOG_STEPPER(this) \ ESP_LOGCONFIG(TAG, \ @@ -108,5 +107,4 @@ template class SetDecelerationAction : public Action { Stepper *parent_; }; -} // namespace stepper -} // namespace esphome +} // namespace esphome::stepper diff --git a/esphome/components/sts3x/sts3x.cpp b/esphome/components/sts3x/sts3x.cpp index 8713b0b6b8..ff2a7748bf 100644 --- a/esphome/components/sts3x/sts3x.cpp +++ b/esphome/components/sts3x/sts3x.cpp @@ -1,8 +1,7 @@ #include "sts3x.h" #include "esphome/core/log.h" -namespace esphome { -namespace sts3x { +namespace esphome::sts3x { static const char *const TAG = "sts3x"; @@ -66,5 +65,4 @@ void STS3XComponent::update() { }); } -} // namespace sts3x -} // namespace esphome +} // namespace esphome::sts3x diff --git a/esphome/components/sts3x/sts3x.h b/esphome/components/sts3x/sts3x.h index 6c1dd2b244..038fa0dd80 100644 --- a/esphome/components/sts3x/sts3x.h +++ b/esphome/components/sts3x/sts3x.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace sts3x { +namespace esphome::sts3x { /// This class implements support for the ST3x-DIS family of temperature i2c sensors. class STS3XComponent : public sensor::Sensor, public PollingComponent, public sensirion_common::SensirionI2CDevice { @@ -17,5 +16,4 @@ class STS3XComponent : public sensor::Sensor, public PollingComponent, public se void update() override; }; -} // namespace sts3x -} // namespace esphome +} // namespace esphome::sts3x diff --git a/esphome/components/sun/sensor/sun_sensor.cpp b/esphome/components/sun/sensor/sun_sensor.cpp index 6c90722c29..d788e19ea4 100644 --- a/esphome/components/sun/sensor/sun_sensor.cpp +++ b/esphome/components/sun/sensor/sun_sensor.cpp @@ -1,12 +1,10 @@ #include "sun_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace sun { +namespace esphome::sun { static const char *const TAG = "sun.sensor"; void SunSensor::dump_config() { LOG_SENSOR("", "Sun Sensor", this); } -} // namespace sun -} // namespace esphome +} // namespace esphome::sun diff --git a/esphome/components/sun/sensor/sun_sensor.h b/esphome/components/sun/sensor/sun_sensor.h index 2bd33375ef..148e5297d9 100644 --- a/esphome/components/sun/sensor/sun_sensor.h +++ b/esphome/components/sun/sensor/sun_sensor.h @@ -4,8 +4,7 @@ #include "esphome/components/sun/sun.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace sun { +namespace esphome::sun { enum SensorType { SUN_SENSOR_ELEVATION, @@ -37,5 +36,4 @@ class SunSensor : public sensor::Sensor, public PollingComponent { SensorType type_; }; -} // namespace sun -} // namespace esphome +} // namespace esphome::sun diff --git a/esphome/components/sun/sun.cpp b/esphome/components/sun/sun.cpp index d55a14f192..d03ff07981 100644 --- a/esphome/components/sun/sun.cpp +++ b/esphome/components/sun/sun.cpp @@ -12,8 +12,7 @@ like exact nutation are not included. But in some testing the accuracy appears t for random spots around the globe. */ -namespace esphome { -namespace sun { +namespace esphome::sun { using namespace esphome::sun::internal; @@ -322,5 +321,4 @@ optional Sun::sunset(ESPTime date, double elevation) { return this->cal double Sun::elevation() { return this->calc_coords_().elevation; } double Sun::azimuth() { return this->calc_coords_().azimuth; } -} // namespace sun -} // namespace esphome +} // namespace esphome::sun diff --git a/esphome/components/sun/sun.h b/esphome/components/sun/sun.h index 67a0306a37..2999c93c71 100644 --- a/esphome/components/sun/sun.h +++ b/esphome/components/sun/sun.h @@ -7,8 +7,7 @@ #include "esphome/components/time/real_time_clock.h" -namespace esphome { -namespace sun { +namespace esphome::sun { namespace internal { @@ -129,5 +128,4 @@ template class SunCondition : public Condition, public Pa bool above_; }; -} // namespace sun -} // namespace esphome +} // namespace esphome::sun diff --git a/esphome/components/sun/text_sensor/sun_text_sensor.cpp b/esphome/components/sun/text_sensor/sun_text_sensor.cpp index c047b87fdd..21aa4b86e0 100644 --- a/esphome/components/sun/text_sensor/sun_text_sensor.cpp +++ b/esphome/components/sun/text_sensor/sun_text_sensor.cpp @@ -1,12 +1,10 @@ #include "sun_text_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace sun { +namespace esphome::sun { static const char *const TAG = "sun.text_sensor"; void SunTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sun Text Sensor", this); } -} // namespace sun -} // namespace esphome +} // namespace esphome::sun diff --git a/esphome/components/sun/text_sensor/sun_text_sensor.h b/esphome/components/sun/text_sensor/sun_text_sensor.h index c3b60ffd65..65b0e358d0 100644 --- a/esphome/components/sun/text_sensor/sun_text_sensor.h +++ b/esphome/components/sun/text_sensor/sun_text_sensor.h @@ -6,8 +6,7 @@ #include "esphome/components/sun/sun.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace sun { +namespace esphome::sun { class SunTextSensor : public text_sensor::TextSensor, public PollingComponent { public: @@ -44,5 +43,4 @@ class SunTextSensor : public text_sensor::TextSensor, public PollingComponent { bool sunrise_; }; -} // namespace sun -} // namespace esphome +} // namespace esphome::sun diff --git a/esphome/components/sun_gtil2/sun_gtil2.cpp b/esphome/components/sun_gtil2/sun_gtil2.cpp index d416d9a636..78e398d086 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.cpp +++ b/esphome/components/sun_gtil2/sun_gtil2.cpp @@ -1,8 +1,7 @@ #include "sun_gtil2.h" #include "esphome/core/log.h" -namespace esphome { -namespace sun_gtil2 { +namespace esphome::sun_gtil2 { static const char *const TAG = "sun_gtil2"; @@ -131,5 +130,4 @@ void SunGTIL2::dump_config() { #endif } -} // namespace sun_gtil2 -} // namespace esphome +} // namespace esphome::sun_gtil2 diff --git a/esphome/components/sun_gtil2/sun_gtil2.h b/esphome/components/sun_gtil2/sun_gtil2.h index 3e28527cf7..e774fefcf8 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.h +++ b/esphome/components/sun_gtil2/sun_gtil2.h @@ -13,8 +13,7 @@ #endif #include "esphome/components/uart/uart.h" -namespace esphome { -namespace sun_gtil2 { +namespace esphome::sun_gtil2 { class SunGTIL2 : public Component, public uart::UARTDevice { public: @@ -58,5 +57,4 @@ class SunGTIL2 : public Component, public uart::UARTDevice { std::vector rx_message_; }; -} // namespace sun_gtil2 -} // namespace esphome +} // namespace esphome::sun_gtil2 diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index ed5986e097..2721cbfbbf 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/components/sx126x/sx126x.h" -namespace esphome { -namespace sx126x { +namespace esphome::sx126x { template class RunImageCalAction : public Action, public Parented { public: @@ -65,5 +64,4 @@ template class SetModeStandbyAction : public Action, publ void play(const Ts &...x) override { this->parent_->set_mode_standby(STDBY_XOSC); } }; -} // namespace sx126x -} // namespace esphome +} // namespace esphome::sx126x diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp index 59d80bd297..5e992cc731 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp @@ -2,8 +2,7 @@ #include "esphome/core/application.h" #include "sx126x_transport.h" -namespace esphome { -namespace sx126x { +namespace esphome::sx126x { static const char *const TAG = "sx126x_transport"; @@ -16,5 +15,4 @@ void SX126xTransport::send_packet(const std::vector &buf) const { this- void SX126xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } -} // namespace sx126x -} // namespace esphome +} // namespace esphome::sx126x diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.h b/esphome/components/sx126x/packet_transport/sx126x_transport.h index 640c6a76f9..7590e35c28 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.h +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.h @@ -5,8 +5,7 @@ #include "esphome/components/packet_transport/packet_transport.h" #include -namespace esphome { -namespace sx126x { +namespace esphome::sx126x { class SX126xTransport : public packet_transport::PacketTransport, public Parented, public SX126xListener { public: @@ -20,5 +19,4 @@ class SX126xTransport : public packet_transport::PacketTransport, public Parente size_t get_max_packet_size() override { return this->parent_->get_max_packet_size(); } }; -} // namespace sx126x -} // namespace esphome +} // namespace esphome::sx126x diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 02f7d972a9..6e6857fadb 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace sx126x { +namespace esphome::sx126x { static const char *const TAG = "sx126x"; static const uint16_t RAMP[8] = {10, 20, 40, 80, 200, 800, 1700, 3400}; @@ -547,5 +546,4 @@ void SX126x::dump_config() { } } -} // namespace sx126x -} // namespace esphome +} // namespace esphome::sx126x diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 87bbf18c79..8298beb36e 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace sx126x { +namespace esphome::sx126x { enum SX126xBw : uint8_t { // FSK @@ -146,5 +145,4 @@ class SX126x : public Component, bool rf_switch_{false}; }; -} // namespace sx126x -} // namespace esphome +} // namespace esphome::sx126x diff --git a/esphome/components/sx126x/sx126x_reg.h b/esphome/components/sx126x/sx126x_reg.h index 143f4a05da..c70817364f 100644 --- a/esphome/components/sx126x/sx126x_reg.h +++ b/esphome/components/sx126x/sx126x_reg.h @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" -namespace esphome { -namespace sx126x { +namespace esphome::sx126x { static const uint32_t XTAL_FREQ = 32000000; @@ -161,5 +160,4 @@ enum SX126xRampTime : uint8_t { PA_RAMP_3400 = 0x07, }; -} // namespace sx126x -} // namespace esphome +} // namespace esphome::sx126x diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h index fb0367fcca..7a2eb7ee8d 100644 --- a/esphome/components/sx127x/automation.h +++ b/esphome/components/sx127x/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/components/sx127x/sx127x.h" -namespace esphome { -namespace sx127x { +namespace esphome::sx127x { template class RunImageCalAction : public Action, public Parented { public: @@ -64,5 +63,4 @@ template class SetModeStandbyAction : public Action, publ void play(const Ts &...x) override { this->parent_->set_mode_standby(); } }; -} // namespace sx127x -} // namespace esphome +} // namespace esphome::sx127x diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp index 893726e816..52d5631791 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp @@ -2,8 +2,7 @@ #include "esphome/core/application.h" #include "sx127x_transport.h" -namespace esphome { -namespace sx127x { +namespace esphome::sx127x { static const char *const TAG = "sx127x_transport"; @@ -16,5 +15,4 @@ void SX127xTransport::send_packet(const std::vector &buf) const { this- void SX127xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } -} // namespace sx127x -} // namespace esphome +} // namespace esphome::sx127x diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.h b/esphome/components/sx127x/packet_transport/sx127x_transport.h index 6208372971..5dcfe02c33 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.h +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.h @@ -5,8 +5,7 @@ #include "esphome/components/packet_transport/packet_transport.h" #include -namespace esphome { -namespace sx127x { +namespace esphome::sx127x { class SX127xTransport : public packet_transport::PacketTransport, public Parented, public SX127xListener { public: @@ -20,5 +19,4 @@ class SX127xTransport : public packet_transport::PacketTransport, public Parente size_t get_max_packet_size() override { return this->parent_->get_max_packet_size(); } }; -} // namespace sx127x -} // namespace esphome +} // namespace esphome::sx127x diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 2b13efb38d..0596e91ccc 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace sx127x { +namespace esphome::sx127x { static const char *const TAG = "sx127x"; static const uint32_t FXOSC = 32000000u; @@ -507,5 +506,4 @@ void SX127x::dump_config() { } } -} // namespace sx127x -} // namespace esphome +} // namespace esphome::sx127x diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 76f942fdda..376c987ed1 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -7,8 +7,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace sx127x { +namespace esphome::sx127x { enum SX127xBw : uint8_t { SX127X_BW_2_6, @@ -126,5 +125,4 @@ class SX127x : public Component, bool rx_start_{false}; }; -} // namespace sx127x -} // namespace esphome +} // namespace esphome::sx127x diff --git a/esphome/components/sx127x/sx127x_reg.h b/esphome/components/sx127x/sx127x_reg.h index d5e9c50957..295af738cc 100644 --- a/esphome/components/sx127x/sx127x_reg.h +++ b/esphome/components/sx127x/sx127x_reg.h @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" -namespace esphome { -namespace sx127x { +namespace esphome::sx127x { enum SX127xReg : uint8_t { // Common registers @@ -291,5 +290,4 @@ enum SX127xModemCfg3 : uint8_t { MODEM_AGC_AUTO_ON = 0x04, }; -} // namespace sx127x -} // namespace esphome +} // namespace esphome::sx127x diff --git a/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h b/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h index 2eef19782c..bcd8901530 100644 --- a/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h +++ b/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h @@ -3,8 +3,7 @@ #include "esphome/components/sx1509/sx1509.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace sx1509 { +namespace esphome::sx1509 { class SX1509BinarySensor : public sx1509::SX1509Processor, public binary_sensor::BinarySensor { public: @@ -15,5 +14,4 @@ class SX1509BinarySensor : public sx1509::SX1509Processor, public binary_sensor: uint16_t key_{0}; }; -} // namespace sx1509 -} // namespace esphome +} // namespace esphome::sx1509 diff --git a/esphome/components/sx1509/output/sx1509_float_output.cpp b/esphome/components/sx1509/output/sx1509_float_output.cpp index 4a24d78478..528de1fde5 100644 --- a/esphome/components/sx1509/output/sx1509_float_output.cpp +++ b/esphome/components/sx1509/output/sx1509_float_output.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace sx1509 { +namespace esphome::sx1509 { static const char *const TAG = "sx1509_float_channel"; @@ -29,5 +28,4 @@ void SX1509FloatOutputChannel::dump_config() { LOG_FLOAT_OUTPUT(this); } -} // namespace sx1509 -} // namespace esphome +} // namespace esphome::sx1509 diff --git a/esphome/components/sx1509/output/sx1509_float_output.h b/esphome/components/sx1509/output/sx1509_float_output.h index 39e51839ea..ee53cef637 100644 --- a/esphome/components/sx1509/output/sx1509_float_output.h +++ b/esphome/components/sx1509/output/sx1509_float_output.h @@ -3,8 +3,7 @@ #include "esphome/components/sx1509/sx1509.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace sx1509 { +namespace esphome::sx1509 { class SX1509Component; @@ -23,5 +22,4 @@ class SX1509FloatOutputChannel : public output::FloatOutput, public Component { uint8_t pin_; }; -} // namespace sx1509 -} // namespace esphome +} // namespace esphome::sx1509 diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 1cdae76eaf..2397049000 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace sx1509 { +namespace esphome::sx1509 { static const char *const TAG = "sx1509"; @@ -313,5 +312,4 @@ void SX1509Component::set_debounce_keypad_(uint8_t time, uint8_t num_rows, uint8 set_debounce_pin_(i + 8); } -} // namespace sx1509 -} // namespace esphome +} // namespace esphome::sx1509 diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index f98fc0a44f..f645ede754 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -10,8 +10,7 @@ #include -namespace esphome { -namespace sx1509 { +namespace esphome::sx1509 { // These are used for clock config: const uint8_t INTERNAL_CLOCK_2MHZ = 2; @@ -97,5 +96,4 @@ class SX1509Component : public Component, void clock_(uint8_t osc_source = 2, uint8_t osc_pin_function = 1, uint8_t osc_freq_out = 0, uint8_t osc_divider = 0); }; -} // namespace sx1509 -} // namespace esphome +} // namespace esphome::sx1509 diff --git a/esphome/components/sx1509/sx1509_gpio_pin.cpp b/esphome/components/sx1509/sx1509_gpio_pin.cpp index a7e5d0514d..28ef1c5830 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.cpp +++ b/esphome/components/sx1509/sx1509_gpio_pin.cpp @@ -3,8 +3,7 @@ #include "sx1509.h" #include "sx1509_gpio_pin.h" -namespace esphome { -namespace sx1509 { +namespace esphome::sx1509 { static const char *const TAG = "sx1509_gpio_pin"; @@ -16,5 +15,4 @@ size_t SX1509GPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "%u via sx1509", this->pin_); } -} // namespace sx1509 -} // namespace esphome +} // namespace esphome::sx1509 diff --git a/esphome/components/sx1509/sx1509_gpio_pin.h b/esphome/components/sx1509/sx1509_gpio_pin.h index 5903af9d12..9dcad37b27 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.h +++ b/esphome/components/sx1509/sx1509_gpio_pin.h @@ -2,8 +2,7 @@ #include "esphome/core/gpio.h" -namespace esphome { -namespace sx1509 { +namespace esphome::sx1509 { class SX1509Component; @@ -29,5 +28,4 @@ class SX1509GPIOPin : public GPIOPin { gpio::Flags flags_; }; -} // namespace sx1509 -} // namespace esphome +} // namespace esphome::sx1509 diff --git a/esphome/components/sx1509/sx1509_registers.h b/esphome/components/sx1509/sx1509_registers.h index 9712cacf9b..8349a8b829 100644 --- a/esphome/components/sx1509/sx1509_registers.h +++ b/esphome/components/sx1509/sx1509_registers.h @@ -7,7 +7,6 @@ https://github.com/sparkfun/SparkFun_SX1509_Arduino_Library */ #pragma once -namespace esphome { /** Here you'll find the Arduino code used to interface with the SX1509 I2C 16 I/O expander. There are functions to take advantage of everything the @@ -25,7 +24,7 @@ local, and you've found our code helpful, please buy us a round! Distributed as-is; no warranty is given. */ -namespace sx1509 { +namespace esphome::sx1509 { const uint8_t REG_INPUT_DISABLE_B = 0x00; // RegInputDisableB Input buffer disable register _ I/O[15_8] (Bank B) 0000 0000 @@ -106,5 +105,4 @@ const uint8_t REG_RESET = 0x7D; // RegReset Software reset register 0000 00 const uint8_t REG_TEST_1 = 0x7E; // RegTest1 Test register 0000 0000 const uint8_t REG_TEST_2 = 0x7F; // RegTest2 Test register 0000 0000 -} // namespace sx1509 -} // namespace esphome +} // namespace esphome::sx1509 From 6ffcb821cae98cc4b419d6fe5efd84ad52f8eb86 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 7 May 2026 21:13:38 -0400 Subject: [PATCH 1119/2214] [clang-tidy] Concatenate nested namespaces (6/7: components t-z) (#16305) --- esphome/components/t6615/t6615.cpp | 6 ++---- esphome/components/t6615/t6615.h | 6 ++---- esphome/components/tc74/tc74.cpp | 6 ++---- esphome/components/tc74/tc74.h | 6 ++---- esphome/components/tca9548a/tca9548a.cpp | 6 ++---- esphome/components/tca9548a/tca9548a.h | 6 ++---- esphome/components/tca9555/tca9555.cpp | 6 ++---- esphome/components/tca9555/tca9555.h | 6 ++---- esphome/components/tcl112/tcl112.cpp | 6 ++---- esphome/components/tcl112/tcl112.h | 6 ++---- esphome/components/tcs34725/tcs34725.cpp | 6 ++---- esphome/components/tcs34725/tcs34725.h | 6 ++---- esphome/components/tee501/tee501.cpp | 6 ++---- esphome/components/tee501/tee501.h | 6 ++---- esphome/components/teleinfo/sensor/teleinfo_sensor.cpp | 7 +++---- esphome/components/teleinfo/sensor/teleinfo_sensor.h | 6 ++---- esphome/components/teleinfo/teleinfo.cpp | 6 ++---- esphome/components/teleinfo/teleinfo.h | 6 ++---- .../teleinfo/text_sensor/teleinfo_text_sensor.cpp | 7 +++---- .../components/teleinfo/text_sensor/teleinfo_text_sensor.h | 7 +++---- esphome/components/tem3200/tem3200.cpp | 6 ++---- esphome/components/tem3200/tem3200.h | 6 ++---- esphome/components/time_based/cover/time_based_cover.cpp | 6 ++---- esphome/components/time_based/cover/time_based_cover.h | 6 ++---- esphome/components/tlc59208f/tlc59208f_output.cpp | 6 ++---- esphome/components/tlc59208f/tlc59208f_output.h | 6 ++---- esphome/components/tlc5947/output/tlc5947_output.cpp | 6 ++---- esphome/components/tlc5947/output/tlc5947_output.h | 6 ++---- esphome/components/tlc5947/tlc5947.cpp | 6 ++---- esphome/components/tlc5947/tlc5947.h | 6 ++---- esphome/components/tlc5971/output/tlc5971_output.cpp | 6 ++---- esphome/components/tlc5971/output/tlc5971_output.h | 6 ++---- esphome/components/tlc5971/tlc5971.cpp | 6 ++---- esphome/components/tlc5971/tlc5971.h | 6 ++---- esphome/components/tm1621/tm1621.cpp | 6 ++---- esphome/components/tm1621/tm1621.h | 6 ++---- esphome/components/tm1637/tm1637.cpp | 6 ++---- esphome/components/tm1637/tm1637.h | 6 ++---- esphome/components/tm1638/binary_sensor/tm1638_key.cpp | 6 ++---- esphome/components/tm1638/binary_sensor/tm1638_key.h | 6 ++---- esphome/components/tm1638/output/tm1638_output_led.cpp | 6 ++---- esphome/components/tm1638/output/tm1638_output_led.h | 6 ++---- esphome/components/tm1638/sevenseg.h | 6 ++---- esphome/components/tm1638/switch/tm1638_switch_led.cpp | 6 ++---- esphome/components/tm1638/switch/tm1638_switch_led.h | 6 ++---- esphome/components/tm1638/tm1638.cpp | 6 ++---- esphome/components/tm1638/tm1638.h | 6 ++---- esphome/components/tm1651/tm1651.cpp | 6 ++---- esphome/components/tm1651/tm1651.h | 6 ++---- esphome/components/tmp102/tmp102.cpp | 6 ++---- esphome/components/tmp102/tmp102.h | 6 ++---- esphome/components/tmp1075/tmp1075.cpp | 6 ++---- esphome/components/tmp1075/tmp1075.h | 6 ++---- esphome/components/tof10120/tof10120_sensor.cpp | 6 ++---- esphome/components/tof10120/tof10120_sensor.h | 6 ++---- esphome/components/tormatic/tormatic_cover.cpp | 6 ++---- esphome/components/tormatic/tormatic_cover.h | 6 ++---- esphome/components/tormatic/tormatic_protocol.h | 6 ++---- esphome/components/toshiba/toshiba.cpp | 6 ++---- esphome/components/toshiba/toshiba.h | 6 ++---- .../binary_sensor/touchscreen_binary_sensor.cpp | 6 ++---- .../touchscreen/binary_sensor/touchscreen_binary_sensor.h | 6 ++---- esphome/components/touchscreen/touchscreen.cpp | 6 ++---- esphome/components/touchscreen/touchscreen.h | 6 ++---- esphome/components/tsl2561/tsl2561.cpp | 6 ++---- esphome/components/tsl2561/tsl2561.h | 6 ++---- esphome/components/tsl2591/tsl2591.cpp | 6 ++---- esphome/components/tsl2591/tsl2591.h | 6 ++---- .../components/tt21100/binary_sensor/tt21100_button.cpp | 6 ++---- esphome/components/tt21100/binary_sensor/tt21100_button.h | 6 ++---- esphome/components/tt21100/touchscreen/tt21100.cpp | 6 ++---- esphome/components/tt21100/touchscreen/tt21100.h | 6 ++---- esphome/components/ttp229_bsf/ttp229_bsf.cpp | 6 ++---- esphome/components/ttp229_bsf/ttp229_bsf.h | 6 ++---- esphome/components/ttp229_lsf/ttp229_lsf.cpp | 6 ++---- esphome/components/ttp229_lsf/ttp229_lsf.h | 6 ++---- esphome/components/tuya/automation.cpp | 6 ++---- esphome/components/tuya/automation.h | 6 ++---- .../components/tuya/binary_sensor/tuya_binary_sensor.cpp | 6 ++---- esphome/components/tuya/binary_sensor/tuya_binary_sensor.h | 6 ++---- esphome/components/tuya/climate/tuya_climate.cpp | 6 ++---- esphome/components/tuya/climate/tuya_climate.h | 6 ++---- esphome/components/tuya/cover/tuya_cover.cpp | 6 ++---- esphome/components/tuya/cover/tuya_cover.h | 6 ++---- esphome/components/tuya/fan/tuya_fan.cpp | 6 ++---- esphome/components/tuya/fan/tuya_fan.h | 6 ++---- esphome/components/tuya/light/tuya_light.cpp | 6 ++---- esphome/components/tuya/light/tuya_light.h | 6 ++---- esphome/components/tuya/number/tuya_number.cpp | 6 ++---- esphome/components/tuya/number/tuya_number.h | 6 ++---- esphome/components/tuya/select/tuya_select.cpp | 6 ++---- esphome/components/tuya/select/tuya_select.h | 6 ++---- esphome/components/tuya/sensor/tuya_sensor.cpp | 6 ++---- esphome/components/tuya/sensor/tuya_sensor.h | 6 ++---- esphome/components/tuya/switch/tuya_switch.cpp | 6 ++---- esphome/components/tuya/switch/tuya_switch.h | 6 ++---- esphome/components/tuya/text_sensor/tuya_text_sensor.cpp | 6 ++---- esphome/components/tuya/text_sensor/tuya_text_sensor.h | 6 ++---- esphome/components/tuya/tuya.cpp | 6 ++---- esphome/components/tuya/tuya.h | 6 ++---- esphome/components/tx20/tx20.cpp | 6 ++---- esphome/components/tx20/tx20.h | 6 ++---- esphome/components/udp/automation.h | 7 +++---- esphome/components/udp/packet_transport/udp_transport.cpp | 6 ++---- esphome/components/udp/packet_transport/udp_transport.h | 7 +++---- esphome/components/ufire_ec/ufire_ec.cpp | 6 ++---- esphome/components/ufire_ec/ufire_ec.h | 6 ++---- esphome/components/ufire_ise/ufire_ise.cpp | 6 ++---- esphome/components/ufire_ise/ufire_ise.h | 6 ++---- esphome/components/update/automation.h | 6 ++---- esphome/components/update/update_entity.cpp | 6 ++---- esphome/components/update/update_entity.h | 6 ++---- .../uponor_smatrix/climate/uponor_smatrix_climate.cpp | 6 ++---- .../uponor_smatrix/climate/uponor_smatrix_climate.h | 6 ++---- .../uponor_smatrix/sensor/uponor_smatrix_sensor.cpp | 6 ++---- .../uponor_smatrix/sensor/uponor_smatrix_sensor.h | 6 ++---- esphome/components/uponor_smatrix/uponor_smatrix.cpp | 6 ++---- esphome/components/uponor_smatrix/uponor_smatrix.h | 6 ++---- esphome/components/valve/automation.h | 6 ++---- esphome/components/valve/valve.cpp | 6 ++---- esphome/components/valve/valve.h | 6 ++---- esphome/components/valve/valve_traits.h | 6 ++---- .../components/vbus/binary_sensor/vbus_binary_sensor.cpp | 6 ++---- esphome/components/vbus/binary_sensor/vbus_binary_sensor.h | 6 ++---- esphome/components/vbus/sensor/vbus_sensor.cpp | 6 ++---- esphome/components/vbus/sensor/vbus_sensor.h | 6 ++---- esphome/components/vbus/vbus.cpp | 6 ++---- esphome/components/vbus/vbus.h | 6 ++---- esphome/components/veml3235/veml3235.cpp | 6 ++---- esphome/components/veml3235/veml3235.h | 6 ++---- esphome/components/veml7700/veml7700.cpp | 6 ++---- esphome/components/veml7700/veml7700.h | 6 ++---- esphome/components/vl53l0x/vl53l0x_sensor.cpp | 6 ++---- esphome/components/vl53l0x/vl53l0x_sensor.h | 6 ++---- esphome/components/voice_assistant/voice_assistant.cpp | 6 ++---- esphome/components/voice_assistant/voice_assistant.h | 6 ++---- esphome/components/voltage_sampler/voltage_sampler.h | 6 ++---- esphome/components/wake_on_lan/wake_on_lan.cpp | 7 +++---- esphome/components/wake_on_lan/wake_on_lan.h | 7 +++---- esphome/components/watchdog/watchdog.cpp | 6 ++---- esphome/components/watchdog/watchdog.h | 6 ++---- esphome/components/waveshare_epaper/waveshare_213v3.cpp | 6 ++---- esphome/components/waveshare_epaper/waveshare_epaper.cpp | 6 ++---- esphome/components/waveshare_epaper/waveshare_epaper.h | 6 ++---- esphome/components/weikai/weikai.cpp | 6 ++---- esphome/components/weikai/weikai.h | 6 ++---- esphome/components/weikai/wk_reg_def.h | 6 ++---- esphome/components/weikai_i2c/weikai_i2c.cpp | 6 ++---- esphome/components/weikai_i2c/weikai_i2c.h | 6 ++---- esphome/components/weikai_spi/weikai_spi.cpp | 6 ++---- esphome/components/weikai_spi/weikai_spi.h | 6 ++---- esphome/components/whirlpool/whirlpool.cpp | 6 ++---- esphome/components/whirlpool/whirlpool.h | 6 ++---- esphome/components/whynter/whynter.cpp | 6 ++---- esphome/components/whynter/whynter.h | 6 ++---- esphome/components/wiegand/wiegand.cpp | 6 ++---- esphome/components/wiegand/wiegand.h | 6 ++---- esphome/components/wk2132_i2c/wk2132_i2c.cpp | 4 +--- esphome/components/wl_134/wl_134.cpp | 6 ++---- esphome/components/wl_134/wl_134.h | 6 ++---- esphome/components/wled/wled_light_effect.cpp | 6 ++---- esphome/components/wled/wled_light_effect.h | 6 ++---- esphome/components/wts01/wts01.cpp | 6 ++---- esphome/components/wts01/wts01.h | 6 ++---- esphome/components/x9c/x9c.cpp | 6 ++---- esphome/components/x9c/x9c.h | 6 ++---- esphome/components/xgzp68xx/xgzp68xx.cpp | 6 ++---- esphome/components/xgzp68xx/xgzp68xx.h | 6 ++---- esphome/components/xiaomi_ble/xiaomi_ble.cpp | 6 ++---- esphome/components/xiaomi_ble/xiaomi_ble.h | 6 ++---- esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp | 6 ++---- esphome/components/xiaomi_cgd1/xiaomi_cgd1.h | 6 ++---- esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp | 6 ++---- esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h | 6 ++---- esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp | 6 ++---- esphome/components/xiaomi_cgg1/xiaomi_cgg1.h | 6 ++---- esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp | 6 ++---- esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h | 6 ++---- esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp | 6 ++---- esphome/components/xiaomi_gcls002/xiaomi_gcls002.h | 6 ++---- esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp | 6 ++---- esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h | 6 ++---- esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp | 6 ++---- esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h | 6 ++---- esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp | 6 ++---- esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h | 6 ++---- esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp | 6 ++---- esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h | 6 ++---- esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp | 6 ++---- esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h | 6 ++---- esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp | 6 ++---- esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h | 6 ++---- esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp | 6 ++---- esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h | 6 ++---- esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp | 6 ++---- esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h | 6 ++---- esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp | 6 ++---- esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h | 6 ++---- esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp | 6 ++---- esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h | 6 ++---- esphome/components/xiaomi_miscale/xiaomi_miscale.cpp | 6 ++---- esphome/components/xiaomi_miscale/xiaomi_miscale.h | 6 ++---- esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp | 6 ++---- esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h | 6 ++---- esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp | 6 ++---- esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h | 6 ++---- esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp | 6 ++---- esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h | 6 ++---- esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp | 6 ++---- esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h | 6 ++---- .../components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp | 6 ++---- esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h | 6 ++---- esphome/components/xl9535/xl9535.cpp | 6 ++---- esphome/components/xl9535/xl9535.h | 6 ++---- esphome/components/xpt2046/touchscreen/xpt2046.cpp | 6 ++---- esphome/components/xpt2046/touchscreen/xpt2046.h | 6 ++---- esphome/components/xxtea/xxtea.cpp | 6 ++---- esphome/components/xxtea/xxtea.h | 6 ++---- esphome/components/yashima/yashima.cpp | 6 ++---- esphome/components/yashima/yashima.h | 6 ++---- esphome/components/zhlt01/zhlt01.cpp | 6 ++---- esphome/components/zhlt01/zhlt01.h | 6 ++---- esphome/components/zio_ultrasonic/zio_ultrasonic.cpp | 6 ++---- esphome/components/zio_ultrasonic/zio_ultrasonic.h | 6 ++---- esphome/components/zyaura/zyaura.cpp | 6 ++---- esphome/components/zyaura/zyaura.h | 6 ++---- 226 files changed, 458 insertions(+), 903 deletions(-) diff --git a/esphome/components/t6615/t6615.cpp b/esphome/components/t6615/t6615.cpp index 75f9ed108e..1a98e48c14 100644 --- a/esphome/components/t6615/t6615.cpp +++ b/esphome/components/t6615/t6615.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace t6615 { +namespace esphome::t6615 { static const char *const TAG = "t6615"; @@ -92,5 +91,4 @@ void T6615Component::dump_config() { this->check_uart_settings(19200); } -} // namespace t6615 -} // namespace esphome +} // namespace esphome::t6615 diff --git a/esphome/components/t6615/t6615.h b/esphome/components/t6615/t6615.h index 69c406a5ba..0c2088f7b0 100644 --- a/esphome/components/t6615/t6615.h +++ b/esphome/components/t6615/t6615.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace t6615 { +namespace esphome::t6615 { enum class T6615Command : uint8_t { NONE = 0, @@ -38,5 +37,4 @@ class T6615Component : public PollingComponent, public uart::UARTDevice { sensor::Sensor *co2_sensor_{nullptr}; }; -} // namespace t6615 -} // namespace esphome +} // namespace esphome::t6615 diff --git a/esphome/components/tc74/tc74.cpp b/esphome/components/tc74/tc74.cpp index cb58e583dc..bc522d1b74 100644 --- a/esphome/components/tc74/tc74.cpp +++ b/esphome/components/tc74/tc74.cpp @@ -3,8 +3,7 @@ #include "tc74.h" #include "esphome/core/log.h" -namespace esphome { -namespace tc74 { +namespace esphome::tc74 { static const char *const TAG = "tc74"; @@ -62,5 +61,4 @@ void TC74Component::read_temperature_() { this->status_clear_warning(); } -} // namespace tc74 -} // namespace esphome +} // namespace esphome::tc74 diff --git a/esphome/components/tc74/tc74.h b/esphome/components/tc74/tc74.h index f3ce225ff4..4a53f39bc1 100644 --- a/esphome/components/tc74/tc74.h +++ b/esphome/components/tc74/tc74.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace tc74 { +namespace esphome::tc74 { class TC74Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: @@ -22,5 +21,4 @@ class TC74Component : public PollingComponent, public i2c::I2CDevice, public sen bool data_ready_ = false; }; -} // namespace tc74 -} // namespace esphome +} // namespace esphome::tc74 diff --git a/esphome/components/tca9548a/tca9548a.cpp b/esphome/components/tca9548a/tca9548a.cpp index 1de3c49108..3fc91a3bbb 100644 --- a/esphome/components/tca9548a/tca9548a.cpp +++ b/esphome/components/tca9548a/tca9548a.cpp @@ -1,8 +1,7 @@ #include "tca9548a.h" #include "esphome/core/log.h" -namespace esphome { -namespace tca9548a { +namespace esphome::tca9548a { static const char *const TAG = "tca9548a"; @@ -44,5 +43,4 @@ void TCA9548AComponent::disable_all_channels() { } } -} // namespace tca9548a -} // namespace esphome +} // namespace esphome::tca9548a diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index 0fb9ada99a..f0417ac7f7 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace tca9548a { +namespace esphome::tca9548a { static const uint8_t TCA9548A_DISABLE_CHANNELS_COMMAND = 0x00; @@ -35,5 +34,4 @@ class TCA9548AComponent : public Component, public i2c::I2CDevice { protected: friend class TCA9548AChannel; }; -} // namespace tca9548a -} // namespace esphome +} // namespace esphome::tca9548a diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 2fefe08c0d..b210c082dd 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -10,8 +10,7 @@ static const uint8_t TCA9555_POLARITY_REGISTER_1 = 0x05; static const uint8_t TCA9555_CONFIGURATION_PORT_0 = 0x06; static const uint8_t TCA9555_CONFIGURATION_PORT_1 = 0x07; -namespace esphome { -namespace tca9555 { +namespace esphome::tca9555 { static const char *const TAG = "tca9555"; @@ -162,5 +161,4 @@ size_t TCA9555GPIOPin::dump_summary(char *buffer, size_t len) const { return buf_append_printf(buffer, len, 0, "%u via TCA9555", this->pin_); } -} // namespace tca9555 -} // namespace esphome +} // namespace esphome::tca9555 diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index d4d070013c..7d37edad73 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace tca9555 { +namespace esphome::tca9555 { class TCA9555Component : public Component, public i2c::I2CDevice, @@ -67,5 +66,4 @@ class TCA9555GPIOPin : public GPIOPin, public Parented { gpio::Flags flags_; }; -} // namespace tca9555 -} // namespace esphome +} // namespace esphome::tca9555 diff --git a/esphome/components/tcl112/tcl112.cpp b/esphome/components/tcl112/tcl112.cpp index afeee3d739..cd819e5b16 100644 --- a/esphome/components/tcl112/tcl112.cpp +++ b/esphome/components/tcl112/tcl112.cpp @@ -1,8 +1,7 @@ #include "tcl112.h" #include "esphome/core/log.h" -namespace esphome { -namespace tcl112 { +namespace esphome::tcl112 { static const char *const TAG = "tcl112.climate"; @@ -240,5 +239,4 @@ bool Tcl112Climate::on_receive(remote_base::RemoteReceiveData data) { return true; } -} // namespace tcl112 -} // namespace esphome +} // namespace esphome::tcl112 diff --git a/esphome/components/tcl112/tcl112.h b/esphome/components/tcl112/tcl112.h index e982755d40..0aef2decc8 100644 --- a/esphome/components/tcl112/tcl112.h +++ b/esphome/components/tcl112/tcl112.h @@ -2,8 +2,7 @@ #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace tcl112 { +namespace esphome::tcl112 { // Temperature const float TCL112_TEMP_MAX = 31.0; @@ -24,5 +23,4 @@ class Tcl112Climate : public climate_ir::ClimateIR { bool on_receive(remote_base::RemoteReceiveData data) override; }; -} // namespace tcl112 -} // namespace esphome +} // namespace esphome::tcl112 diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 1098d8de5f..40c65e9f84 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace tcs34725 { +namespace esphome::tcs34725 { static const char *const TAG = "tcs34725"; @@ -348,5 +347,4 @@ void TCS34725Component::set_glass_attenuation_factor(float ga) { this->glass_attenuation_ = ga; } -} // namespace tcs34725 -} // namespace esphome +} // namespace esphome::tcs34725 diff --git a/esphome/components/tcs34725/tcs34725.h b/esphome/components/tcs34725/tcs34725.h index 85bb383e4b..15e4fae52f 100644 --- a/esphome/components/tcs34725/tcs34725.h +++ b/esphome/components/tcs34725/tcs34725.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace tcs34725 { +namespace esphome::tcs34725 { enum TCS34725IntegrationTime { TCS34725_INTEGRATION_TIME_2_4MS = 0xFF, @@ -85,5 +84,4 @@ class TCS34725Component : public PollingComponent, public i2c::I2CDevice { uint8_t gain_reg_{TCS34725_GAIN_1X}; }; -} // namespace tcs34725 -} // namespace esphome +} // namespace esphome::tcs34725 diff --git a/esphome/components/tee501/tee501.cpp b/esphome/components/tee501/tee501.cpp index 00a62247f9..c198ff1081 100644 --- a/esphome/components/tee501/tee501.cpp +++ b/esphome/components/tee501/tee501.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace tee501 { +namespace esphome::tee501 { static const char *const TAG = "tee501"; @@ -66,5 +65,4 @@ void TEE501Component::update() { }); } -} // namespace tee501 -} // namespace esphome +} // namespace esphome::tee501 diff --git a/esphome/components/tee501/tee501.h b/esphome/components/tee501/tee501.h index 62a6f1c944..4a08291318 100644 --- a/esphome/components/tee501/tee501.h +++ b/esphome/components/tee501/tee501.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -namespace esphome { -namespace tee501 { +namespace esphome::tee501 { /// This class implements support for the tee501 of temperature i2c sensors. class TEE501Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { @@ -18,5 +17,4 @@ class TEE501Component : public sensor::Sensor, public PollingComponent, public i enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, CRC_CHECK_FAILED } error_code_{NONE}; }; -} // namespace tee501 -} // namespace esphome +} // namespace esphome::tee501 diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp index ad9c6dae00..3878a3967b 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp @@ -1,7 +1,7 @@ #include "esphome/core/log.h" #include "teleinfo_sensor.h" -namespace esphome { -namespace teleinfo { + +namespace esphome::teleinfo { static const char *const TAG = "teleinfo_sensor"; TeleInfoSensor::TeleInfoSensor(const char *tag) { this->tag = std::string(tag); } @@ -10,5 +10,4 @@ void TeleInfoSensor::publish_val(const std::string &val) { publish_state(newval); } void TeleInfoSensor::dump_config() { LOG_SENSOR(" ", "Teleinfo Sensor", this); } -} // namespace teleinfo -} // namespace esphome +} // namespace esphome::teleinfo diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.h b/esphome/components/teleinfo/sensor/teleinfo_sensor.h index 56781166ab..37736c4e73 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.h +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.h @@ -2,8 +2,7 @@ #include "esphome/components/teleinfo/teleinfo.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace teleinfo { +namespace esphome::teleinfo { class TeleInfoSensor : public TeleInfoListener, public sensor::Sensor, public Component { public: @@ -12,5 +11,4 @@ class TeleInfoSensor : public TeleInfoListener, public sensor::Sensor, public Co void dump_config() override; }; -} // namespace teleinfo -} // namespace esphome +} // namespace esphome::teleinfo diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index 4d617ae4e6..cd2ddbbb38 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -1,8 +1,7 @@ #include "teleinfo.h" #include "esphome/core/log.h" -namespace esphome { -namespace teleinfo { +namespace esphome::teleinfo { static const char *const TAG = "teleinfo"; @@ -205,5 +204,4 @@ TeleInfo::TeleInfo(bool historical_mode) { } void TeleInfo::register_teleinfo_listener(TeleInfoListener *listener) { teleinfo_listeners_.push_back(listener); } -} // namespace teleinfo -} // namespace esphome +} // namespace esphome::teleinfo diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index 0c6217853e..eeab3b5103 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace teleinfo { +namespace esphome::teleinfo { /* * 198 bytes should be enough to contain a full session in historical mode with * three phases. But go with 1024 just to be sure. @@ -50,5 +49,4 @@ class TeleInfo : public PollingComponent, public uart::UARTDevice { bool check_crc_(const char *grp, const char *grp_end); void publish_value_(const std::string &tag, const std::string &val); }; -} // namespace teleinfo -} // namespace esphome +} // namespace esphome::teleinfo diff --git a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp index 87cf0dea17..7c638d8545 100644 --- a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp +++ b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp @@ -1,11 +1,10 @@ #include "esphome/core/log.h" #include "teleinfo_text_sensor.h" -namespace esphome { -namespace teleinfo { + +namespace esphome::teleinfo { static const char *const TAG = "teleinfo_text_sensor"; TeleInfoTextSensor::TeleInfoTextSensor(const char *tag) { this->tag = std::string(tag); } void TeleInfoTextSensor::publish_val(const std::string &val) { publish_state(val); } void TeleInfoTextSensor::dump_config() { LOG_TEXT_SENSOR(" ", "Teleinfo Text Sensor", this); } -} // namespace teleinfo -} // namespace esphome +} // namespace esphome::teleinfo diff --git a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h index 5a7dc9d1a7..f4c04a03a0 100644 --- a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h +++ b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h @@ -1,13 +1,12 @@ #pragma once #include "esphome/components/teleinfo/teleinfo.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace teleinfo { + +namespace esphome::teleinfo { class TeleInfoTextSensor : public TeleInfoListener, public text_sensor::TextSensor, public Component { public: TeleInfoTextSensor(const char *tag); void publish_val(const std::string &val) override; void dump_config() override; }; -} // namespace teleinfo -} // namespace esphome +} // namespace esphome::teleinfo diff --git a/esphome/components/tem3200/tem3200.cpp b/esphome/components/tem3200/tem3200.cpp index 9c305f8f6f..72cf31e0a6 100644 --- a/esphome/components/tem3200/tem3200.cpp +++ b/esphome/components/tem3200/tem3200.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace tem3200 { +namespace esphome::tem3200 { static const char *const TAG = "tem3200"; @@ -142,5 +141,4 @@ void TEM3200Component::update() { this->status_clear_warning(); } -} // namespace tem3200 -} // namespace esphome +} // namespace esphome::tem3200 diff --git a/esphome/components/tem3200/tem3200.h b/esphome/components/tem3200/tem3200.h index 37589b2a06..5c73a25fbb 100644 --- a/esphome/components/tem3200/tem3200.h +++ b/esphome/components/tem3200/tem3200.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace tem3200 { +namespace esphome::tem3200 { /// This class implements support for the tem3200 pressure and temperature i2c sensors. class TEM3200Component : public PollingComponent, public i2c::I2CDevice { @@ -25,5 +24,4 @@ class TEM3200Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *raw_pressure_sensor_{nullptr}; }; -} // namespace tem3200 -} // namespace esphome +} // namespace esphome::tem3200 diff --git a/esphome/components/time_based/cover/time_based_cover.cpp b/esphome/components/time_based/cover/time_based_cover.cpp index c83829ff59..613b190cf3 100644 --- a/esphome/components/time_based/cover/time_based_cover.cpp +++ b/esphome/components/time_based/cover/time_based_cover.cpp @@ -3,8 +3,7 @@ #include "esphome/core/hal.h" #include "esphome/core/application.h" -namespace esphome { -namespace time_based { +namespace esphome::time_based { static const char *const TAG = "time_based.cover"; @@ -183,5 +182,4 @@ void TimeBasedCover::recompute_position_() { this->last_recompute_time_ = now; } -} // namespace time_based -} // namespace esphome +} // namespace esphome::time_based diff --git a/esphome/components/time_based/cover/time_based_cover.h b/esphome/components/time_based/cover/time_based_cover.h index 0adc5cb370..ce0b105ceb 100644 --- a/esphome/components/time_based/cover/time_based_cover.h +++ b/esphome/components/time_based/cover/time_based_cover.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/components/cover/cover.h" -namespace esphome { -namespace time_based { +namespace esphome::time_based { class TimeBasedCover : public cover::Cover, public Component { public: @@ -50,5 +49,4 @@ class TimeBasedCover : public cover::Cover, public Component { cover::CoverOperation last_operation_{cover::COVER_OPERATION_OPENING}; }; -} // namespace time_based -} // namespace esphome +} // namespace esphome::time_based diff --git a/esphome/components/tlc59208f/tlc59208f_output.cpp b/esphome/components/tlc59208f/tlc59208f_output.cpp index d35585fe5f..def337befc 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.cpp +++ b/esphome/components/tlc59208f/tlc59208f_output.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace tlc59208f { +namespace esphome::tlc59208f { static const char *const TAG = "tlc59208f"; @@ -143,5 +142,4 @@ void TLC59208FChannel::write_state(float state) { this->parent_->set_channel_value_(this->channel_, duty); } -} // namespace tlc59208f -} // namespace esphome +} // namespace esphome::tlc59208f diff --git a/esphome/components/tlc59208f/tlc59208f_output.h b/esphome/components/tlc59208f/tlc59208f_output.h index 34663cd364..46f88de01f 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.h +++ b/esphome/components/tlc59208f/tlc59208f_output.h @@ -5,8 +5,7 @@ #include "esphome/components/output/float_output.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace tlc59208f { +namespace esphome::tlc59208f { // 0*: Group dimming, 1: Group blinking inline constexpr uint8_t TLC59208F_MODE2_DMBLNK = (1 << 5); @@ -65,5 +64,4 @@ class TLC59208FOutput : public Component, public i2c::I2CDevice { bool update_{true}; }; -} // namespace tlc59208f -} // namespace esphome +} // namespace esphome::tlc59208f diff --git a/esphome/components/tlc5947/output/tlc5947_output.cpp b/esphome/components/tlc5947/output/tlc5947_output.cpp index 9630fb8c1e..b1badbac99 100644 --- a/esphome/components/tlc5947/output/tlc5947_output.cpp +++ b/esphome/components/tlc5947/output/tlc5947_output.cpp @@ -1,12 +1,10 @@ #include "tlc5947_output.h" -namespace esphome { -namespace tlc5947 { +namespace esphome::tlc5947 { void TLC5947Channel::write_state(float state) { auto amount = static_cast(state * 0xfff); this->parent_->set_channel_value(this->channel_, amount); } -} // namespace tlc5947 -} // namespace esphome +} // namespace esphome::tlc5947 diff --git a/esphome/components/tlc5947/output/tlc5947_output.h b/esphome/components/tlc5947/output/tlc5947_output.h index 0faec96acb..16a96b5140 100644 --- a/esphome/components/tlc5947/output/tlc5947_output.h +++ b/esphome/components/tlc5947/output/tlc5947_output.h @@ -6,8 +6,7 @@ #include "../tlc5947.h" -namespace esphome { -namespace tlc5947 { +namespace esphome::tlc5947 { class TLC5947Channel : public output::FloatOutput, public Parented { public: @@ -18,5 +17,4 @@ class TLC5947Channel : public output::FloatOutput, public Parented { uint16_t channel_; }; -} // namespace tlc5947 -} // namespace esphome +} // namespace esphome::tlc5947 diff --git a/esphome/components/tlc5947/tlc5947.cpp b/esphome/components/tlc5947/tlc5947.cpp index 0a278bbaf6..f886118a08 100644 --- a/esphome/components/tlc5947/tlc5947.cpp +++ b/esphome/components/tlc5947/tlc5947.cpp @@ -1,8 +1,7 @@ #include "tlc5947.h" #include "esphome/core/log.h" -namespace esphome { -namespace tlc5947 { +namespace esphome::tlc5947 { static const char *const TAG = "tlc5947"; @@ -69,5 +68,4 @@ void TLC5947::set_channel_value(uint16_t channel, uint16_t value) { this->pwm_amounts_[channel] = value; } -} // namespace tlc5947 -} // namespace esphome +} // namespace esphome::tlc5947 diff --git a/esphome/components/tlc5947/tlc5947.h b/esphome/components/tlc5947/tlc5947.h index 95d76408c9..18acffa25f 100644 --- a/esphome/components/tlc5947/tlc5947.h +++ b/esphome/components/tlc5947/tlc5947.h @@ -7,8 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace tlc5947 { +namespace esphome::tlc5947 { class TLC5947 : public Component { public: @@ -42,5 +41,4 @@ class TLC5947 : public Component { bool update_{true}; }; -} // namespace tlc5947 -} // namespace esphome +} // namespace esphome::tlc5947 diff --git a/esphome/components/tlc5971/output/tlc5971_output.cpp b/esphome/components/tlc5971/output/tlc5971_output.cpp index b437889072..5c9183def6 100644 --- a/esphome/components/tlc5971/output/tlc5971_output.cpp +++ b/esphome/components/tlc5971/output/tlc5971_output.cpp @@ -1,12 +1,10 @@ #include "tlc5971_output.h" -namespace esphome { -namespace tlc5971 { +namespace esphome::tlc5971 { void TLC5971Channel::write_state(float state) { auto amount = static_cast(state * 0xffff); this->parent_->set_channel_value(this->channel_, amount); } -} // namespace tlc5971 -} // namespace esphome +} // namespace esphome::tlc5971 diff --git a/esphome/components/tlc5971/output/tlc5971_output.h b/esphome/components/tlc5971/output/tlc5971_output.h index ca3099e7b2..2a24a19b6c 100644 --- a/esphome/components/tlc5971/output/tlc5971_output.h +++ b/esphome/components/tlc5971/output/tlc5971_output.h @@ -6,8 +6,7 @@ #include "../tlc5971.h" -namespace esphome { -namespace tlc5971 { +namespace esphome::tlc5971 { class TLC5971Channel : public output::FloatOutput, public Parented { public: @@ -18,5 +17,4 @@ class TLC5971Channel : public output::FloatOutput, public Parented { uint16_t channel_; }; -} // namespace tlc5971 -} // namespace esphome +} // namespace esphome::tlc5971 diff --git a/esphome/components/tlc5971/tlc5971.cpp b/esphome/components/tlc5971/tlc5971.cpp index 8128dd9046..5818eace67 100644 --- a/esphome/components/tlc5971/tlc5971.cpp +++ b/esphome/components/tlc5971/tlc5971.cpp @@ -1,8 +1,7 @@ #include "tlc5971.h" #include "esphome/core/log.h" -namespace esphome { -namespace tlc5971 { +namespace esphome::tlc5971 { static const char *const TAG = "tlc5971"; @@ -90,5 +89,4 @@ void TLC5971::set_channel_value(uint16_t channel, uint16_t value) { this->pwm_amounts_[channel] = value; } -} // namespace tlc5971 -} // namespace esphome +} // namespace esphome::tlc5971 diff --git a/esphome/components/tlc5971/tlc5971.h b/esphome/components/tlc5971/tlc5971.h index 6b0daf10d1..080249c89c 100644 --- a/esphome/components/tlc5971/tlc5971.h +++ b/esphome/components/tlc5971/tlc5971.h @@ -7,8 +7,7 @@ #include "esphome/components/output/float_output.h" #include -namespace esphome { -namespace tlc5971 { +namespace esphome::tlc5971 { class TLC5971 : public Component { public: @@ -39,5 +38,4 @@ class TLC5971 : public Component { std::vector pwm_amounts_; bool update_{true}; }; -} // namespace tlc5971 -} // namespace esphome +} // namespace esphome::tlc5971 diff --git a/esphome/components/tm1621/tm1621.cpp b/esphome/components/tm1621/tm1621.cpp index c82d306460..68d16e3811 100644 --- a/esphome/components/tm1621/tm1621.cpp +++ b/esphome/components/tm1621/tm1621.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace tm1621 { +namespace esphome::tm1621 { static const char *const TAG = "tm1621"; @@ -277,5 +276,4 @@ int TM1621Display::get_command_code_(char *destination, size_t destination_size, } return result; } -} // namespace tm1621 -} // namespace esphome +} // namespace esphome::tm1621 diff --git a/esphome/components/tm1621/tm1621.h b/esphome/components/tm1621/tm1621.h index fe923417a6..7708ee6c98 100644 --- a/esphome/components/tm1621/tm1621.h +++ b/esphome/components/tm1621/tm1621.h @@ -5,8 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/components/display/display.h" -namespace esphome { -namespace tm1621 { +namespace esphome::tm1621 { class TM1621Display; @@ -71,5 +70,4 @@ class TM1621Display : public PollingComponent { bool kwh_; }; -} // namespace tm1621 -} // namespace esphome +} // namespace esphome::tm1621 diff --git a/esphome/components/tm1637/tm1637.cpp b/esphome/components/tm1637/tm1637.cpp index 4814d5b1c4..a1604fa60e 100644 --- a/esphome/components/tm1637/tm1637.cpp +++ b/esphome/components/tm1637/tm1637.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace tm1637 { +namespace esphome::tm1637 { static const char *const TAG = "display.tm1637"; const uint8_t TM1637_CMD_DATA = 0x40; //!< Display data command @@ -391,5 +390,4 @@ uint8_t TM1637Display::strftime(uint8_t pos, const char *format, ESPTime time) { } uint8_t TM1637Display::strftime(const char *format, ESPTime time) { return this->strftime(0, format, time); } -} // namespace tm1637 -} // namespace esphome +} // namespace esphome::tm1637 diff --git a/esphome/components/tm1637/tm1637.h b/esphome/components/tm1637/tm1637.h index 1738d37107..1ad56ae75a 100644 --- a/esphome/components/tm1637/tm1637.h +++ b/esphome/components/tm1637/tm1637.h @@ -12,8 +12,7 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif -namespace esphome { -namespace tm1637 { +namespace esphome::tm1637 { class TM1637Display; #ifdef USE_BINARY_SENSOR @@ -105,5 +104,4 @@ class TM1637Key : public binary_sensor::BinarySensor { }; #endif -} // namespace tm1637 -} // namespace esphome +} // namespace esphome::tm1637 diff --git a/esphome/components/tm1638/binary_sensor/tm1638_key.cpp b/esphome/components/tm1638/binary_sensor/tm1638_key.cpp index c143bafaea..9eecf97a9b 100644 --- a/esphome/components/tm1638/binary_sensor/tm1638_key.cpp +++ b/esphome/components/tm1638/binary_sensor/tm1638_key.cpp @@ -1,7 +1,6 @@ #include "tm1638_key.h" -namespace esphome { -namespace tm1638 { +namespace esphome::tm1638 { void TM1638Key::keys_update(uint8_t keys) { bool pressed = keys & (1 << key_code_); @@ -9,5 +8,4 @@ void TM1638Key::keys_update(uint8_t keys) { this->publish_state(pressed); } -} // namespace tm1638 -} // namespace esphome +} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/binary_sensor/tm1638_key.h b/esphome/components/tm1638/binary_sensor/tm1638_key.h index 0ea385f434..fba1e43bde 100644 --- a/esphome/components/tm1638/binary_sensor/tm1638_key.h +++ b/esphome/components/tm1638/binary_sensor/tm1638_key.h @@ -3,8 +3,7 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #include "../tm1638.h" -namespace esphome { -namespace tm1638 { +namespace esphome::tm1638 { class TM1638Key : public binary_sensor::BinarySensor, public KeyListener { public: @@ -15,5 +14,4 @@ class TM1638Key : public binary_sensor::BinarySensor, public KeyListener { uint8_t key_code_{0}; }; -} // namespace tm1638 -} // namespace esphome +} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/output/tm1638_output_led.cpp b/esphome/components/tm1638/output/tm1638_output_led.cpp index ea1c84e64b..e32826fa93 100644 --- a/esphome/components/tm1638/output/tm1638_output_led.cpp +++ b/esphome/components/tm1638/output/tm1638_output_led.cpp @@ -1,8 +1,7 @@ #include "tm1638_output_led.h" #include "esphome/core/log.h" -namespace esphome { -namespace tm1638 { +namespace esphome::tm1638 { static const char *const TAG = "tm1638.led"; @@ -13,5 +12,4 @@ void TM1638OutputLed::dump_config() { ESP_LOGCONFIG(TAG, " LED: %d", led_); } -} // namespace tm1638 -} // namespace esphome +} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/output/tm1638_output_led.h b/esphome/components/tm1638/output/tm1638_output_led.h index 6aa1015aae..b1c1090447 100644 --- a/esphome/components/tm1638/output/tm1638_output_led.h +++ b/esphome/components/tm1638/output/tm1638_output_led.h @@ -4,8 +4,7 @@ #include "esphome/components/output/binary_output.h" #include "../tm1638.h" -namespace esphome { -namespace tm1638 { +namespace esphome::tm1638 { class TM1638OutputLed : public output::BinaryOutput, public Component { public: @@ -21,5 +20,4 @@ class TM1638OutputLed : public output::BinaryOutput, public Component { int led_; }; -} // namespace tm1638 -} // namespace esphome +} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/sevenseg.h b/esphome/components/tm1638/sevenseg.h index a4c16c7422..61098b5a5b 100644 --- a/esphome/components/tm1638/sevenseg.h +++ b/esphome/components/tm1638/sevenseg.h @@ -1,7 +1,6 @@ #pragma once -namespace esphome { -namespace tm1638 { +namespace esphome::tm1638 { namespace TM1638Translation { constexpr unsigned char SEVEN_SEG[] PROGMEM = { @@ -103,5 +102,4 @@ constexpr unsigned char SEVEN_SEG[] PROGMEM = { }; }; // namespace TM1638Translation -} // namespace tm1638 -} // namespace esphome +} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/switch/tm1638_switch_led.cpp b/esphome/components/tm1638/switch/tm1638_switch_led.cpp index 60c9e8b4a9..743d0af507 100644 --- a/esphome/components/tm1638/switch/tm1638_switch_led.cpp +++ b/esphome/components/tm1638/switch/tm1638_switch_led.cpp @@ -1,8 +1,7 @@ #include "tm1638_switch_led.h" #include "esphome/core/log.h" -namespace esphome { -namespace tm1638 { +namespace esphome::tm1638 { static const char *const TAG = "tm1638.led"; @@ -16,5 +15,4 @@ void TM1638SwitchLed::dump_config() { ESP_LOGCONFIG(TAG, " LED: %d", led_); } -} // namespace tm1638 -} // namespace esphome +} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/switch/tm1638_switch_led.h b/esphome/components/tm1638/switch/tm1638_switch_led.h index 10516e0079..c7154eefb3 100644 --- a/esphome/components/tm1638/switch/tm1638_switch_led.h +++ b/esphome/components/tm1638/switch/tm1638_switch_led.h @@ -4,8 +4,7 @@ #include "esphome/components/switch/switch.h" #include "../tm1638.h" -namespace esphome { -namespace tm1638 { +namespace esphome::tm1638 { class TM1638SwitchLed : public switch_::Switch, public Component { public: @@ -19,5 +18,4 @@ class TM1638SwitchLed : public switch_::Switch, public Component { TM1638Component *tm1638_; int led_; }; -} // namespace tm1638 -} // namespace esphome +} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/tm1638.cpp b/esphome/components/tm1638/tm1638.cpp index c67ff1adbc..1f0692479c 100644 --- a/esphome/components/tm1638/tm1638.cpp +++ b/esphome/components/tm1638/tm1638.cpp @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace tm1638 { +namespace esphome::tm1638 { static const char *const TAG = "display.tm1638"; static const uint8_t TM1638_REGISTER_FIXEDADDRESS = 0x44; @@ -282,5 +281,4 @@ void TM1638Component::shift_out_(uint8_t val) { } } -} // namespace tm1638 -} // namespace esphome +} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/tm1638.h b/esphome/components/tm1638/tm1638.h index 27898aa3dc..24d49f4a9f 100644 --- a/esphome/components/tm1638/tm1638.h +++ b/esphome/components/tm1638/tm1638.h @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace tm1638 { +namespace esphome::tm1638 { class KeyListener { public: @@ -75,5 +74,4 @@ class TM1638Component : public PollingComponent { std::vector listeners_{}; }; -} // namespace tm1638 -} // namespace esphome +} // namespace esphome::tm1638 diff --git a/esphome/components/tm1651/tm1651.cpp b/esphome/components/tm1651/tm1651.cpp index 15ada0f8ff..282b0dcf76 100644 --- a/esphome/components/tm1651/tm1651.cpp +++ b/esphome/components/tm1651/tm1651.cpp @@ -51,8 +51,7 @@ #include "tm1651.h" #include "esphome/core/log.h" -namespace esphome { -namespace tm1651 { +namespace esphome::tm1651 { static const char *const TAG = "tm1651.display"; @@ -256,5 +255,4 @@ void TM1651Display::delineate_transmission_(bool dio_state) { delayMicroseconds(QUARTER_CLOCK_CYCLE); } -} // namespace tm1651 -} // namespace esphome +} // namespace esphome::tm1651 diff --git a/esphome/components/tm1651/tm1651.h b/esphome/components/tm1651/tm1651.h index 83e74c5f33..f1abbcc792 100644 --- a/esphome/components/tm1651/tm1651.h +++ b/esphome/components/tm1651/tm1651.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace tm1651 { +namespace esphome::tm1651 { enum TM1651Brightness : uint8_t { TM1651_DARKEST = 1, @@ -97,5 +96,4 @@ template class TurnOffAction : public Action, public Pare void play(const Ts &...x) override { this->parent_->turn_off(); } }; -} // namespace tm1651 -} // namespace esphome +} // namespace esphome::tm1651 diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 99f6753ddc..cb2462858b 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace tmp102 { +namespace esphome::tmp102 { static const char *const TAG = "tmp102"; @@ -46,5 +45,4 @@ void TMP102Component::update() { }); } -} // namespace tmp102 -} // namespace esphome +} // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index fe860a3819..aedfefd052 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace tmp102 { +namespace esphome::tmp102 { class TMP102Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: @@ -13,5 +12,4 @@ class TMP102Component : public PollingComponent, public i2c::I2CDevice, public s void update() override; }; -} // namespace tmp102 -} // namespace esphome +} // namespace esphome::tmp102 diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 3c7ed01970..681603d113 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -1,8 +1,7 @@ #include "esphome/core/log.h" #include "tmp1075.h" -namespace esphome { -namespace tmp1075 { +namespace esphome::tmp1075 { static const char *const TAG = "tmp1075"; @@ -127,5 +126,4 @@ static float regvalue2temp(const uint16_t regvalue) { return (signed_value >> 4) * 0.0625f; } -} // namespace tmp1075 -} // namespace esphome +} // namespace esphome::tmp1075 diff --git a/esphome/components/tmp1075/tmp1075.h b/esphome/components/tmp1075/tmp1075.h index b5fd60c08e..4dc9449597 100644 --- a/esphome/components/tmp1075/tmp1075.h +++ b/esphome/components/tmp1075/tmp1075.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -namespace esphome { -namespace tmp1075 { +namespace esphome::tmp1075 { struct TMP1075Config { union { @@ -85,5 +84,4 @@ class TMP1075Sensor : public PollingComponent, public sensor::Sensor, public i2c void log_config_(); }; -} // namespace tmp1075 -} // namespace esphome +} // namespace esphome::tmp1075 diff --git a/esphome/components/tof10120/tof10120_sensor.cpp b/esphome/components/tof10120/tof10120_sensor.cpp index e27c7bbd64..290bc76a52 100644 --- a/esphome/components/tof10120/tof10120_sensor.cpp +++ b/esphome/components/tof10120/tof10120_sensor.cpp @@ -5,8 +5,7 @@ // Very basic support for TOF10120 distance sensor -namespace esphome { -namespace tof10120 { +namespace esphome::tof10120 { static const char *const TAG = "tof10120"; static const uint8_t TOF10120_READ_DISTANCE_CMD[] = {0x00}; @@ -56,5 +55,4 @@ void TOF10120Sensor::update() { this->status_clear_warning(); } -} // namespace tof10120 -} // namespace esphome +} // namespace esphome::tof10120 diff --git a/esphome/components/tof10120/tof10120_sensor.h b/esphome/components/tof10120/tof10120_sensor.h index d0cca19d4c..8bf92b50a0 100644 --- a/esphome/components/tof10120/tof10120_sensor.h +++ b/esphome/components/tof10120/tof10120_sensor.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace tof10120 { +namespace esphome::tof10120 { class TOF10120Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: @@ -14,5 +13,4 @@ class TOF10120Sensor : public sensor::Sensor, public PollingComponent, public i2 void dump_config() override; void update() override; }; -} // namespace tof10120 -} // namespace esphome +} // namespace esphome::tof10120 diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index cca7b2bba0..7004c4f836 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -5,8 +5,7 @@ using namespace std; -namespace esphome { -namespace tormatic { +namespace esphome::tormatic { static const char *const TAG = "tormatic.cover"; @@ -390,5 +389,4 @@ void Tormatic::drain_rx_(uint16_t n) { } } -} // namespace tormatic -} // namespace esphome +} // namespace esphome::tormatic diff --git a/esphome/components/tormatic/tormatic_cover.h b/esphome/components/tormatic/tormatic_cover.h index 34483ed6a3..2a83213ffe 100644 --- a/esphome/components/tormatic/tormatic_cover.h +++ b/esphome/components/tormatic/tormatic_cover.h @@ -5,8 +5,7 @@ #include "tormatic_protocol.h" -namespace esphome { -namespace tormatic { +namespace esphome::tormatic { using namespace esphome::cover; @@ -56,5 +55,4 @@ class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingCom optional target_position_{}; }; -} // namespace tormatic -} // namespace esphome +} // namespace esphome::tormatic diff --git a/esphome/components/tormatic/tormatic_protocol.h b/esphome/components/tormatic/tormatic_protocol.h index 269b63ff78..476aa668d7 100644 --- a/esphome/components/tormatic/tormatic_protocol.h +++ b/esphome/components/tormatic/tormatic_protocol.h @@ -46,8 +46,7 @@ * for this purpose. */ -namespace esphome { -namespace tormatic { +namespace esphome::tormatic { using namespace esphome::cover; @@ -225,5 +224,4 @@ struct CommandRequestReply { void byteswap() { this->type = convert_big_endian(this->type); } } __attribute__((packed)); -} // namespace tormatic -} // namespace esphome +} // namespace esphome::tormatic diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index a23b4c7cc3..1b37c6897d 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace toshiba { +namespace esphome::toshiba { struct RacPt1411hwruFanSpeed { uint8_t code1; @@ -1372,5 +1371,4 @@ bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *mess return true; } -} // namespace toshiba -} // namespace esphome +} // namespace esphome::toshiba diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index ee1dec5cc9..4525d6bffe 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -3,8 +3,7 @@ #include "esphome/components/climate_ir/climate_ir.h" #include "esphome/components/remote_base/toshiba_ac_protocol.h" -namespace esphome { -namespace toshiba { +namespace esphome::toshiba { // Simple enum to represent models. enum Model { @@ -82,5 +81,4 @@ class ToshibaClimate : public climate_ir::ClimateIR { Model model_; }; -} // namespace toshiba -} // namespace esphome +} // namespace esphome::toshiba diff --git a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.cpp b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.cpp index 0662cebf87..25a7ffacf2 100644 --- a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.cpp +++ b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.cpp @@ -1,7 +1,6 @@ #include "touchscreen_binary_sensor.h" -namespace esphome { -namespace touchscreen { +namespace esphome::touchscreen { void TouchscreenBinarySensor::setup() { this->parent_->register_listener(this); @@ -30,5 +29,4 @@ void TouchscreenBinarySensor::touch(TouchPoint tp) { void TouchscreenBinarySensor::release() { this->publish_state(false); } -} // namespace touchscreen -} // namespace esphome +} // namespace esphome::touchscreen diff --git a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h index 79055e6c95..2f86bc9749 100644 --- a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h +++ b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace touchscreen { +namespace esphome::touchscreen { class TouchscreenBinarySensor : public binary_sensor::BinarySensor, public Component, @@ -44,5 +43,4 @@ class TouchscreenBinarySensor : public binary_sensor::BinarySensor, std::vector pages_{}; }; -} // namespace touchscreen -} // namespace esphome +} // namespace esphome::touchscreen diff --git a/esphome/components/touchscreen/touchscreen.cpp b/esphome/components/touchscreen/touchscreen.cpp index dcf3209752..5687213eb5 100644 --- a/esphome/components/touchscreen/touchscreen.cpp +++ b/esphome/components/touchscreen/touchscreen.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace touchscreen { +namespace esphome::touchscreen { static const char *const TAG = "touchscreen"; @@ -162,5 +161,4 @@ int16_t Touchscreen::normalize_(int16_t val, int16_t min_val, int16_t max_val, b return ret; } -} // namespace touchscreen -} // namespace esphome +} // namespace esphome::touchscreen diff --git a/esphome/components/touchscreen/touchscreen.h b/esphome/components/touchscreen/touchscreen.h index 7451c207ec..f1f5398603 100644 --- a/esphome/components/touchscreen/touchscreen.h +++ b/esphome/components/touchscreen/touchscreen.h @@ -9,8 +9,7 @@ #include #include -namespace esphome { -namespace touchscreen { +namespace esphome::touchscreen { static const uint8_t STATE_RELEASED = 0x00; static const uint8_t STATE_PRESSED = 0x01; @@ -120,5 +119,4 @@ class Touchscreen : public PollingComponent { bool skip_update_{false}; }; -} // namespace touchscreen -} // namespace esphome +} // namespace esphome::touchscreen diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index bccff1fb26..963114b230 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -1,8 +1,7 @@ #include "tsl2561.h" #include "esphome/core/log.h" -namespace esphome { -namespace tsl2561 { +namespace esphome::tsl2561 { static const char *const TAG = "tsl2561"; @@ -165,5 +164,4 @@ bool TSL2561Sensor::tsl2561_read_byte(uint8_t a_register, uint8_t *value) { return this->read_byte(a_register | TSL2561_COMMAND_BIT, value); } -} // namespace tsl2561 -} // namespace esphome +} // namespace esphome::tsl2561 diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index a8f0aef90f..0fbb59c648 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace tsl2561 { +namespace esphome::tsl2561 { /** Enum listing all conversion/integration time settings for the TSL2561 * @@ -82,5 +81,4 @@ class TSL2561Sensor : public sensor::Sensor, public PollingComponent, public i2c bool package_cs_{false}; }; -} // namespace tsl2561 -} // namespace esphome +} // namespace esphome::tsl2561 diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 4ce673a91a..fb34dd833d 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace tsl2591 { +namespace esphome::tsl2591 { static const char *const TAG = "tsl2591.sensor"; @@ -475,5 +474,4 @@ float TSL2591Component::get_actual_gain() { } } -} // namespace tsl2591 -} // namespace esphome +} // namespace esphome::tsl2591 diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 84c92b6ba9..4b63c8ec40 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace tsl2591 { +namespace esphome::tsl2591 { /** Enum listing all conversion/integration time settings for the TSL2591. * @@ -270,5 +269,4 @@ class TSL2591Component : public PollingComponent, public i2c::I2CDevice { void interval_function_for_update_(); }; -} // namespace tsl2591 -} // namespace esphome +} // namespace esphome::tsl2591 diff --git a/esphome/components/tt21100/binary_sensor/tt21100_button.cpp b/esphome/components/tt21100/binary_sensor/tt21100_button.cpp index 2d5ac22a83..ccf6e53d66 100644 --- a/esphome/components/tt21100/binary_sensor/tt21100_button.cpp +++ b/esphome/components/tt21100/binary_sensor/tt21100_button.cpp @@ -1,8 +1,7 @@ #include "tt21100_button.h" #include "esphome/core/log.h" -namespace esphome { -namespace tt21100 { +namespace esphome::tt21100 { static const char *const TAG = "tt21100.binary_sensor"; @@ -23,5 +22,4 @@ void TT21100Button::update_button(uint8_t index, uint16_t state) { this->publish_state(state > 0); } -} // namespace tt21100 -} // namespace esphome +} // namespace esphome::tt21100 diff --git a/esphome/components/tt21100/binary_sensor/tt21100_button.h b/esphome/components/tt21100/binary_sensor/tt21100_button.h index 90b55bb75a..a1f5946447 100644 --- a/esphome/components/tt21100/binary_sensor/tt21100_button.h +++ b/esphome/components/tt21100/binary_sensor/tt21100_button.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace tt21100 { +namespace esphome::tt21100 { class TT21100Button : public binary_sensor::BinarySensor, public Component, @@ -24,5 +23,4 @@ class TT21100Button : public binary_sensor::BinarySensor, uint8_t index_; }; -} // namespace tt21100 -} // namespace esphome +} // namespace esphome::tt21100 diff --git a/esphome/components/tt21100/touchscreen/tt21100.cpp b/esphome/components/tt21100/touchscreen/tt21100.cpp index b4735fe6d7..018094df73 100644 --- a/esphome/components/tt21100/touchscreen/tt21100.cpp +++ b/esphome/components/tt21100/touchscreen/tt21100.cpp @@ -1,8 +1,7 @@ #include "tt21100.h" #include "esphome/core/log.h" -namespace esphome { -namespace tt21100 { +namespace esphome::tt21100 { static const char *const TAG = "tt21100"; @@ -139,5 +138,4 @@ void TT21100Touchscreen::dump_config() { LOG_PIN(" Reset Pin: ", this->reset_pin_); } -} // namespace tt21100 -} // namespace esphome +} // namespace esphome::tt21100 diff --git a/esphome/components/tt21100/touchscreen/tt21100.h b/esphome/components/tt21100/touchscreen/tt21100.h index 5d1b2efe3c..3c6030c9c1 100644 --- a/esphome/components/tt21100/touchscreen/tt21100.h +++ b/esphome/components/tt21100/touchscreen/tt21100.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace tt21100 { +namespace esphome::tt21100 { using namespace touchscreen; @@ -39,5 +38,4 @@ class TT21100Touchscreen : public Touchscreen, public i2c::I2CDevice { std::vector button_listeners_; }; -} // namespace tt21100 -} // namespace esphome +} // namespace esphome::tt21100 diff --git a/esphome/components/ttp229_bsf/ttp229_bsf.cpp b/esphome/components/ttp229_bsf/ttp229_bsf.cpp index 8d1ed45bb0..1c7fa3531f 100644 --- a/esphome/components/ttp229_bsf/ttp229_bsf.cpp +++ b/esphome/components/ttp229_bsf/ttp229_bsf.cpp @@ -1,8 +1,7 @@ #include "ttp229_bsf.h" #include "esphome/core/log.h" -namespace esphome { -namespace ttp229_bsf { +namespace esphome::ttp229_bsf { static const char *const TAG = "ttp229_bsf"; @@ -18,5 +17,4 @@ void TTP229BSFComponent::dump_config() { LOG_PIN(" SDO pin: ", this->sdo_pin_); } -} // namespace ttp229_bsf -} // namespace esphome +} // namespace esphome::ttp229_bsf diff --git a/esphome/components/ttp229_bsf/ttp229_bsf.h b/esphome/components/ttp229_bsf/ttp229_bsf.h index fea4356b55..07f0c638c2 100644 --- a/esphome/components/ttp229_bsf/ttp229_bsf.h +++ b/esphome/components/ttp229_bsf/ttp229_bsf.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace ttp229_bsf { +namespace esphome::ttp229_bsf { class TTP229BSFChannel : public binary_sensor::BinarySensor { public: @@ -51,5 +50,4 @@ class TTP229BSFComponent : public Component { std::vector channels_{}; }; -} // namespace ttp229_bsf -} // namespace esphome +} // namespace esphome::ttp229_bsf diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.cpp b/esphome/components/ttp229_lsf/ttp229_lsf.cpp index 7bdb57ebec..eaef33d793 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.cpp +++ b/esphome/components/ttp229_lsf/ttp229_lsf.cpp @@ -1,8 +1,7 @@ #include "ttp229_lsf.h" #include "esphome/core/log.h" -namespace esphome { -namespace ttp229_lsf { +namespace esphome::ttp229_lsf { static const char *const TAG = "ttp229_lsf"; @@ -40,5 +39,4 @@ void TTP229LSFComponent::loop() { } } -} // namespace ttp229_lsf -} // namespace esphome +} // namespace esphome::ttp229_lsf diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.h b/esphome/components/ttp229_lsf/ttp229_lsf.h index 7cc4bfca89..09e7745d25 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.h +++ b/esphome/components/ttp229_lsf/ttp229_lsf.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace ttp229_lsf { +namespace esphome::ttp229_lsf { class TTP229Channel : public binary_sensor::BinarySensor { public: @@ -33,5 +32,4 @@ class TTP229LSFComponent : public Component, public i2c::I2CDevice { } error_code_{NONE}; }; -} // namespace ttp229_lsf -} // namespace esphome +} // namespace esphome::ttp229_lsf diff --git a/esphome/components/tuya/automation.cpp b/esphome/components/tuya/automation.cpp index a8cfd098f1..5c84f36b83 100644 --- a/esphome/components/tuya/automation.cpp +++ b/esphome/components/tuya/automation.cpp @@ -4,8 +4,7 @@ static const char *const TAG = "tuya.automation"; -namespace esphome { -namespace tuya { +namespace esphome::tuya { void check_expected_datapoint(const TuyaDatapoint &dp, TuyaDatapointType expected) { if (dp.type != expected) { @@ -63,5 +62,4 @@ TuyaBitmaskDatapointUpdateTrigger::TuyaBitmaskDatapointUpdateTrigger(Tuya *paren }); } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/automation.h b/esphome/components/tuya/automation.h index 8d91cfdfbf..f5c806b013 100644 --- a/esphome/components/tuya/automation.h +++ b/esphome/components/tuya/automation.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace tuya { +namespace esphome::tuya { class TuyaDatapointUpdateTrigger : public Trigger { public: @@ -51,5 +50,4 @@ class TuyaBitmaskDatapointUpdateTrigger : public Trigger { explicit TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp index a63e9c8318..f93bd31b9d 100644 --- a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp +++ b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp @@ -1,8 +1,7 @@ #include "esphome/core/log.h" #include "tuya_binary_sensor.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { static const char *const TAG = "tuya.binary_sensor"; @@ -20,5 +19,4 @@ void TuyaBinarySensor::dump_config() { this->sensor_id_); } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h index 1eeeb40477..f92652d087 100644 --- a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h +++ b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h @@ -4,8 +4,7 @@ #include "esphome/components/tuya/tuya.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { class TuyaBinarySensor : public binary_sensor::BinarySensor, public Component { public: @@ -20,5 +19,4 @@ class TuyaBinarySensor : public binary_sensor::BinarySensor, public Component { uint8_t sensor_id_{0}; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/climate/tuya_climate.cpp b/esphome/components/tuya/climate/tuya_climate.cpp index 6602ccd8c9..7dbf33878a 100644 --- a/esphome/components/tuya/climate/tuya_climate.cpp +++ b/esphome/components/tuya/climate/tuya_climate.cpp @@ -1,8 +1,7 @@ #include "tuya_climate.h" #include "esphome/core/log.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { static const char *const TAG = "tuya.climate"; @@ -533,5 +532,4 @@ void TuyaClimate::switch_to_action_(climate::ClimateAction action) { this->action = action; } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/climate/tuya_climate.h b/esphome/components/tuya/climate/tuya_climate.h index 09f3fd30c3..b9fb45257a 100644 --- a/esphome/components/tuya/climate/tuya_climate.h +++ b/esphome/components/tuya/climate/tuya_climate.h @@ -4,8 +4,7 @@ #include "esphome/components/tuya/tuya.h" #include "esphome/components/climate/climate.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { class TuyaClimate : public climate::Climate, public Component { public: @@ -125,5 +124,4 @@ class TuyaClimate : public climate::Climate, public Component { bool reports_fahrenheit_{false}; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/cover/tuya_cover.cpp b/esphome/components/tuya/cover/tuya_cover.cpp index 125afec048..dd268388d0 100644 --- a/esphome/components/tuya/cover/tuya_cover.cpp +++ b/esphome/components/tuya/cover/tuya_cover.cpp @@ -1,8 +1,7 @@ #include "esphome/core/log.h" #include "tuya_cover.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { const uint8_t COMMAND_OPEN = 0x00; const uint8_t COMMAND_CLOSE = 0x02; @@ -140,5 +139,4 @@ cover::CoverTraits TuyaCover::get_traits() { return traits; } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/cover/tuya_cover.h b/esphome/components/tuya/cover/tuya_cover.h index bb5a00bc59..ab63975683 100644 --- a/esphome/components/tuya/cover/tuya_cover.h +++ b/esphome/components/tuya/cover/tuya_cover.h @@ -4,8 +4,7 @@ #include "esphome/components/tuya/tuya.h" #include "esphome/components/cover/cover.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { enum TuyaCoverRestoreMode { COVER_NO_RESTORE, @@ -46,5 +45,4 @@ class TuyaCover : public cover::Cover, public Component { bool invert_position_report_; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/fan/tuya_fan.cpp b/esphome/components/tuya/fan/tuya_fan.cpp index a387606b77..0b5fc19038 100644 --- a/esphome/components/tuya/fan/tuya_fan.cpp +++ b/esphome/components/tuya/fan/tuya_fan.cpp @@ -1,8 +1,7 @@ #include "esphome/core/log.h" #include "tuya_fan.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { static const char *const TAG = "tuya.fan"; @@ -127,5 +126,4 @@ void TuyaFan::control(const fan::FanCall &call) { } } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/fan/tuya_fan.h b/esphome/components/tuya/fan/tuya_fan.h index 527efa8246..bfb6bdeca0 100644 --- a/esphome/components/tuya/fan/tuya_fan.h +++ b/esphome/components/tuya/fan/tuya_fan.h @@ -4,8 +4,7 @@ #include "esphome/components/tuya/tuya.h" #include "esphome/components/fan/fan.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { class TuyaFan : public Component, public fan::Fan { public: @@ -32,5 +31,4 @@ class TuyaFan : public Component, public fan::Fan { TuyaDatapointType oscillation_type_{}; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/light/tuya_light.cpp b/esphome/components/tuya/light/tuya_light.cpp index 620bb88d0b..9f3f3c13cc 100644 --- a/esphome/components/tuya/light/tuya_light.cpp +++ b/esphome/components/tuya/light/tuya_light.cpp @@ -2,8 +2,7 @@ #include "tuya_light.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { static const char *const TAG = "tuya.light"; @@ -228,5 +227,4 @@ void TuyaLight::write_state(light::LightState *state) { } } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/light/tuya_light.h b/esphome/components/tuya/light/tuya_light.h index ded94f390a..d990eea72a 100644 --- a/esphome/components/tuya/light/tuya_light.h +++ b/esphome/components/tuya/light/tuya_light.h @@ -4,8 +4,7 @@ #include "esphome/components/tuya/tuya.h" #include "esphome/components/light/light_output.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { enum TuyaColorType { RGB, HSV, RGBHSV }; @@ -65,5 +64,4 @@ class TuyaLight : public Component, public light::LightOutput { light::LightState *state_{nullptr}; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/number/tuya_number.cpp b/esphome/components/tuya/number/tuya_number.cpp index fd22e642c6..bfedbb9319 100644 --- a/esphome/components/tuya/number/tuya_number.cpp +++ b/esphome/components/tuya/number/tuya_number.cpp @@ -1,8 +1,7 @@ #include "esphome/core/log.h" #include "tuya_number.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { static const char *const TAG = "tuya.number"; @@ -91,5 +90,4 @@ void TuyaNumber::dump_config() { ESP_LOGCONFIG(TAG, " Restore Value: %s", YESNO(this->restore_value_)); } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/number/tuya_number.h b/esphome/components/tuya/number/tuya_number.h index 53137d6f66..51c53a4442 100644 --- a/esphome/components/tuya/number/tuya_number.h +++ b/esphome/components/tuya/number/tuya_number.h @@ -6,8 +6,7 @@ #include "esphome/core/optional.h" #include "esphome/core/preferences.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { class TuyaNumber : public number::Number, public Component { public: @@ -34,5 +33,4 @@ class TuyaNumber : public number::Number, public Component { ESPPreferenceObject pref_; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/select/tuya_select.cpp b/esphome/components/tuya/select/tuya_select.cpp index 9d46e4c8ca..f0fc47f504 100644 --- a/esphome/components/tuya/select/tuya_select.cpp +++ b/esphome/components/tuya/select/tuya_select.cpp @@ -1,8 +1,7 @@ #include "esphome/core/log.h" #include "tuya_select.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { static const char *const TAG = "tuya.select"; @@ -47,5 +46,4 @@ void TuyaSelect::dump_config() { } } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/select/tuya_select.h b/esphome/components/tuya/select/tuya_select.h index 24505c9910..f8d2d89ea8 100644 --- a/esphome/components/tuya/select/tuya_select.h +++ b/esphome/components/tuya/select/tuya_select.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace tuya { +namespace esphome::tuya { class TuyaSelect : public select::Select, public Component { public: @@ -32,5 +31,4 @@ class TuyaSelect : public select::Select, public Component { bool is_int_ = false; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/sensor/tuya_sensor.cpp b/esphome/components/tuya/sensor/tuya_sensor.cpp index 673471a6ce..c948984786 100644 --- a/esphome/components/tuya/sensor/tuya_sensor.cpp +++ b/esphome/components/tuya/sensor/tuya_sensor.cpp @@ -2,8 +2,7 @@ #include "tuya_sensor.h" #include -namespace esphome { -namespace tuya { +namespace esphome::tuya { static const char *const TAG = "tuya.sensor"; @@ -30,5 +29,4 @@ void TuyaSensor::dump_config() { ESP_LOGCONFIG(TAG, " Sensor has datapoint ID %u", this->sensor_id_); } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/sensor/tuya_sensor.h b/esphome/components/tuya/sensor/tuya_sensor.h index 8fd7cd1770..b700fc8bd7 100644 --- a/esphome/components/tuya/sensor/tuya_sensor.h +++ b/esphome/components/tuya/sensor/tuya_sensor.h @@ -4,8 +4,7 @@ #include "esphome/components/tuya/tuya.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { class TuyaSensor : public sensor::Sensor, public Component { public: @@ -20,5 +19,4 @@ class TuyaSensor : public sensor::Sensor, public Component { uint8_t sensor_id_{0}; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/switch/tuya_switch.cpp b/esphome/components/tuya/switch/tuya_switch.cpp index cbd794b001..8d4d183d5b 100644 --- a/esphome/components/tuya/switch/tuya_switch.cpp +++ b/esphome/components/tuya/switch/tuya_switch.cpp @@ -1,8 +1,7 @@ #include "esphome/core/log.h" #include "tuya_switch.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { static const char *const TAG = "tuya.switch"; @@ -24,5 +23,4 @@ void TuyaSwitch::dump_config() { ESP_LOGCONFIG(TAG, " Switch has datapoint ID %u", this->switch_id_); } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/switch/tuya_switch.h b/esphome/components/tuya/switch/tuya_switch.h index 89e6264e5c..7e0109c34c 100644 --- a/esphome/components/tuya/switch/tuya_switch.h +++ b/esphome/components/tuya/switch/tuya_switch.h @@ -4,8 +4,7 @@ #include "esphome/components/tuya/tuya.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { class TuyaSwitch : public switch_::Switch, public Component { public: @@ -22,5 +21,4 @@ class TuyaSwitch : public switch_::Switch, public Component { uint8_t switch_id_{0}; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp index b15fb6f85a..ebb35cead7 100644 --- a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp +++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/log.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { static const char *const TAG = "tuya.text_sensor"; @@ -43,5 +42,4 @@ void TuyaTextSensor::dump_config() { this->sensor_id_); } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.h b/esphome/components/tuya/text_sensor/tuya_text_sensor.h index 502ae5e8c7..c9ac64deb8 100644 --- a/esphome/components/tuya/text_sensor/tuya_text_sensor.h +++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.h @@ -4,8 +4,7 @@ #include "esphome/components/tuya/tuya.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace tuya { +namespace esphome::tuya { class TuyaTextSensor : public text_sensor::TextSensor, public Component { public: @@ -20,5 +19,4 @@ class TuyaTextSensor : public text_sensor::TextSensor, public Component { uint8_t sensor_id_{0}; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index a1acbf2f56..d682adffe3 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -13,8 +13,7 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -namespace esphome { -namespace tuya { +namespace esphome::tuya { static const char *const TAG = "tuya"; static const int COMMAND_DELAY = 10; @@ -760,5 +759,4 @@ void Tuya::register_listener(uint8_t datapoint_id, const std::functioninit_state_; } -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index 7e6b50f084..8ba8ac85a0 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -13,8 +13,7 @@ #include "esphome/core/time.h" #endif -namespace esphome { -namespace tuya { +namespace esphome::tuya { enum class TuyaDatapointType : uint8_t { RAW = 0x00, // variable length @@ -162,5 +161,4 @@ class Tuya : public Component, public uart::UARTDevice { CallbackManager initialized_callback_{}; }; -} // namespace tuya -} // namespace esphome +} // namespace esphome::tuya diff --git a/esphome/components/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index 3e0234fac0..353cb31513 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace tx20 { +namespace esphome::tx20 { static const char *const TAG = "tx20"; static const uint8_t MAX_BUFFER_SIZE = 41; @@ -206,5 +205,4 @@ void IRAM_ATTR Tx20ComponentStore::reset() { start_time = 0; } -} // namespace tx20 -} // namespace esphome +} // namespace esphome::tx20 diff --git a/esphome/components/tx20/tx20.h b/esphome/components/tx20/tx20.h index d1673f99f2..7ca29eaf3b 100644 --- a/esphome/components/tx20/tx20.h +++ b/esphome/components/tx20/tx20.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace tx20 { +namespace esphome::tx20 { /// Store data in a class that doesn't use multiple-inheritance (vtables in flash) struct Tx20ComponentStore { @@ -47,5 +46,4 @@ class Tx20Component : public Component { Tx20ComponentStore store_; }; -} // namespace tx20 -} // namespace esphome +} // namespace esphome::tx20 diff --git a/esphome/components/udp/automation.h b/esphome/components/udp/automation.h index b66c2a9892..c37b82921e 100644 --- a/esphome/components/udp/automation.h +++ b/esphome/components/udp/automation.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace udp { +namespace esphome::udp { template class UDPWriteAction : public Action, public Parented { public: @@ -40,6 +39,6 @@ template class UDPWriteAction : public Action, public Par } data_; }; -} // namespace udp -} // namespace esphome +} // namespace esphome::udp + #endif diff --git a/esphome/components/udp/packet_transport/udp_transport.cpp b/esphome/components/udp/packet_transport/udp_transport.cpp index b5e73af777..40ce46d74e 100644 --- a/esphome/components/udp/packet_transport/udp_transport.cpp +++ b/esphome/components/udp/packet_transport/udp_transport.cpp @@ -3,8 +3,7 @@ #include "esphome/components/network/util.h" #include "udp_transport.h" -namespace esphome { -namespace udp { +namespace esphome::udp { static const char *const TAG = "udp_transport"; @@ -17,5 +16,4 @@ void UDPTransport::setup() { } void UDPTransport::send_packet(const std::vector &buf) const { this->parent_->send_packet(buf); } -} // namespace udp -} // namespace esphome +} // namespace esphome::udp diff --git a/esphome/components/udp/packet_transport/udp_transport.h b/esphome/components/udp/packet_transport/udp_transport.h index 8d01ae0909..8621ddca48 100644 --- a/esphome/components/udp/packet_transport/udp_transport.h +++ b/esphome/components/udp/packet_transport/udp_transport.h @@ -6,8 +6,7 @@ #include "esphome/components/packet_transport/packet_transport.h" #include -namespace esphome { -namespace udp { +namespace esphome::udp { class UDPTransport : public packet_transport::PacketTransport, public Parented { public: @@ -21,6 +20,6 @@ class UDPTransport : public packet_transport::PacketTransport, public Parented -namespace esphome { -namespace ufire_ec { +namespace esphome::ufire_ec { static const char *const TAG = "ufire_ec"; @@ -122,5 +121,4 @@ void UFireECComponent::dump_config() { LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_); } -} // namespace ufire_ec -} // namespace esphome +} // namespace esphome::ufire_ec diff --git a/esphome/components/ufire_ec/ufire_ec.h b/esphome/components/ufire_ec/ufire_ec.h index 8a648b5038..fce6258632 100644 --- a/esphome/components/ufire_ec/ufire_ec.h +++ b/esphome/components/ufire_ec/ufire_ec.h @@ -6,8 +6,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ufire_ec { +namespace esphome::ufire_ec { static const uint8_t CONFIG_TEMP_COMPENSATION = 0x02; @@ -83,5 +82,4 @@ template class UFireECResetAction : public Action { UFireECComponent *parent_; }; -} // namespace ufire_ec -} // namespace esphome +} // namespace esphome::ufire_ec diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index e967fc53c3..bd2dc2836e 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace ufire_ise { +namespace esphome::ufire_ise { static const char *const TAG = "ufire_ise"; @@ -147,5 +146,4 @@ void UFireISEComponent::dump_config() { LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_); } -} // namespace ufire_ise -} // namespace esphome +} // namespace esphome::ufire_ise diff --git a/esphome/components/ufire_ise/ufire_ise.h b/esphome/components/ufire_ise/ufire_ise.h index fe9a6dfb9c..bff8eeff9d 100644 --- a/esphome/components/ufire_ise/ufire_ise.h +++ b/esphome/components/ufire_ise/ufire_ise.h @@ -6,8 +6,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace ufire_ise { +namespace esphome::ufire_ise { static const float PROBE_MV_TO_PH = 59.2; static const float PROBE_TMP_CORRECTION = 0.03; @@ -91,5 +90,4 @@ template class UFireISEResetAction : public Action { UFireISEComponent *parent_; }; -} // namespace ufire_ise -} // namespace esphome +} // namespace esphome::ufire_ise diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h index af24c838b1..821151f67c 100644 --- a/esphome/components/update/automation.h +++ b/esphome/components/update/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" -namespace esphome { -namespace update { +namespace esphome::update { template class PerformAction : public Action, public Parented { TEMPLATABLE_VALUE(bool, force) @@ -24,5 +23,4 @@ template class IsAvailableCondition : public Condition, p bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } }; -} // namespace update -} // namespace esphome +} // namespace esphome::update diff --git a/esphome/components/update/update_entity.cpp b/esphome/components/update/update_entity.cpp index 1a5a55577f..b0d4c01cc9 100644 --- a/esphome/components/update/update_entity.cpp +++ b/esphome/components/update/update_entity.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/core/progmem.h" -namespace esphome { -namespace update { +namespace esphome::update { static const char *const TAG = "update"; @@ -49,5 +48,4 @@ void UpdateEntity::publish_state() { #endif } -} // namespace update -} // namespace esphome +} // namespace esphome::update diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index f7d0032f21..f925d338ff 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/entity_base.h" -namespace esphome { -namespace update { +namespace esphome::update { struct UpdateInfo { std::string latest_version; @@ -58,5 +57,4 @@ class UpdateEntity : public EntityBase { std::unique_ptr> update_available_trigger_{nullptr}; }; -} // namespace update -} // namespace esphome +} // namespace esphome::update diff --git a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp index 512a258122..0400888511 100644 --- a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp +++ b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace uponor_smatrix { +namespace esphome::uponor_smatrix { static const char *const TAG = "uponor_smatrix.climate"; @@ -100,5 +99,4 @@ void UponorSmatrixClimate::on_device_data(const UponorSmatrixData *data, size_t this->last_data_ = millis(); } -} // namespace uponor_smatrix -} // namespace esphome +} // namespace esphome::uponor_smatrix diff --git a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h index b8458045c6..4cc5a4a3bc 100644 --- a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h +++ b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h @@ -4,8 +4,7 @@ #include "esphome/components/uponor_smatrix/uponor_smatrix.h" #include "esphome/core/component.h" -namespace esphome { -namespace uponor_smatrix { +namespace esphome::uponor_smatrix { class UponorSmatrixClimate : public climate::Climate, public Component, public UponorSmatrixDevice { public: @@ -24,5 +23,4 @@ class UponorSmatrixClimate : public climate::Climate, public Component, public U uint16_t target_temperature_raw_; }; -} // namespace uponor_smatrix -} // namespace esphome +} // namespace esphome::uponor_smatrix diff --git a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp index 5f690a6879..97e9c27570 100644 --- a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp +++ b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace uponor_smatrix { +namespace esphome::uponor_smatrix { static const char *const TAG = "uponor_smatrix.sensor"; @@ -42,5 +41,4 @@ void UponorSmatrixSensor::on_device_data(const UponorSmatrixData *data, size_t d } } -} // namespace uponor_smatrix -} // namespace esphome +} // namespace esphome::uponor_smatrix diff --git a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h index 97d0d21838..346fe1e3d6 100644 --- a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h +++ b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h @@ -4,8 +4,7 @@ #include "esphome/components/uponor_smatrix/uponor_smatrix.h" #include "esphome/core/component.h" -namespace esphome { -namespace uponor_smatrix { +namespace esphome::uponor_smatrix { class UponorSmatrixSensor : public sensor::Sensor, public Component, public UponorSmatrixDevice { SUB_SENSOR(temperature) @@ -20,5 +19,4 @@ class UponorSmatrixSensor : public sensor::Sensor, public Component, public Upon void on_device_data(const UponorSmatrixData *data, size_t data_len) override; }; -} // namespace uponor_smatrix -} // namespace esphome +} // namespace esphome::uponor_smatrix diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index 1fd53955a0..3f1feaa927 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace uponor_smatrix { +namespace esphome::uponor_smatrix { static const char *const TAG = "uponor_smatrix"; @@ -221,5 +220,4 @@ bool UponorSmatrixComponent::do_send_time_() { } #endif -} // namespace uponor_smatrix -} // namespace esphome +} // namespace esphome::uponor_smatrix diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.h b/esphome/components/uponor_smatrix/uponor_smatrix.h index bd20e9b6a0..e9e772feab 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.h +++ b/esphome/components/uponor_smatrix/uponor_smatrix.h @@ -15,8 +15,7 @@ #include #include -namespace esphome { -namespace uponor_smatrix { +namespace esphome::uponor_smatrix { /// Date/Time Part 1 (year, month, day of week) static const uint8_t UPONOR_ID_DATETIME1 = 0x08; @@ -123,5 +122,4 @@ inline uint16_t celsius_to_raw(float celsius) { : static_cast(lroundf(celsius_to_fahrenheit(celsius) * 10.0f)); } -} // namespace uponor_smatrix -} // namespace esphome +} // namespace esphome::uponor_smatrix diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 27c0e329f0..08c9f4e011 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "valve.h" -namespace esphome { -namespace valve { +namespace esphome::valve { template class OpenAction : public Action { public: @@ -121,5 +120,4 @@ class ValveClosedTrigger : public Trigger<> { Valve *valve_; }; -} // namespace valve -} // namespace esphome +} // namespace esphome::valve diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index 9e1ef9da50..8fccd1e6d6 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace valve { +namespace esphome::valve { static const char *const TAG = "valve"; @@ -176,5 +175,4 @@ void ValveRestoreState::apply(Valve *valve) { valve->publish_state(); } -} // namespace valve -} // namespace esphome +} // namespace esphome::valve diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index b4141f5ff5..c6cdf07096 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -7,8 +7,7 @@ #include "esphome/core/preferences.h" #include "valve_traits.h" -namespace esphome { -namespace valve { +namespace esphome::valve { const extern float VALVE_OPEN; const extern float VALVE_CLOSED; @@ -147,5 +146,4 @@ class Valve : public EntityBase { ESPPreferenceObject rtc_; }; -} // namespace valve -} // namespace esphome +} // namespace esphome::valve diff --git a/esphome/components/valve/valve_traits.h b/esphome/components/valve/valve_traits.h index 7e9aab2f26..81b845a5f2 100644 --- a/esphome/components/valve/valve_traits.h +++ b/esphome/components/valve/valve_traits.h @@ -1,7 +1,6 @@ #pragma once -namespace esphome { -namespace valve { +namespace esphome::valve { class ValveTraits { public: @@ -23,5 +22,4 @@ class ValveTraits { bool supports_stop_{false}; }; -} // namespace valve -} // namespace esphome +} // namespace esphome::valve diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp index e598b1de6b..ddb6b53068 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace vbus { +namespace esphome::vbus { static const char *const TAG = "vbus.binary_sensor"; @@ -199,5 +198,4 @@ void VBusCustomSubBSensor::parse_message(std::vector &message) { this->publish_state(this->message_parser_(message)); } -} // namespace vbus -} // namespace esphome +} // namespace esphome::vbus diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h index 04c9a7b826..8d372f45d6 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h @@ -3,8 +3,7 @@ #include "../vbus.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace vbus { +namespace esphome::vbus { class DeltaSolBSPlusBSensor : public VBusListener, public Component { public: @@ -166,5 +165,4 @@ class VBusCustomSubBSensor : public binary_sensor::BinarySensor, public Componen message_parser_t message_parser_; }; -} // namespace vbus -} // namespace esphome +} // namespace esphome::vbus diff --git a/esphome/components/vbus/sensor/vbus_sensor.cpp b/esphome/components/vbus/sensor/vbus_sensor.cpp index 407a81c83b..773e1435e2 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.cpp +++ b/esphome/components/vbus/sensor/vbus_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace vbus { +namespace esphome::vbus { static const char *const TAG = "vbus.sensor"; @@ -337,5 +336,4 @@ void VBusCustomSubSensor::parse_message(std::vector &message) { this->publish_state(this->message_parser_(message)); } -} // namespace vbus -} // namespace esphome +} // namespace esphome::vbus diff --git a/esphome/components/vbus/sensor/vbus_sensor.h b/esphome/components/vbus/sensor/vbus_sensor.h index ea248b1db2..34f2c44224 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.h +++ b/esphome/components/vbus/sensor/vbus_sensor.h @@ -3,8 +3,7 @@ #include "../vbus.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace vbus { +namespace esphome::vbus { class DeltaSolBSPlusSensor : public VBusListener, public Component { public: @@ -243,5 +242,4 @@ class VBusCustomSubSensor : public sensor::Sensor, public Component { message_parser_t message_parser_; }; -} // namespace vbus -} // namespace esphome +} // namespace esphome::vbus diff --git a/esphome/components/vbus/vbus.cpp b/esphome/components/vbus/vbus.cpp index 195d6ed568..81714a2049 100644 --- a/esphome/components/vbus/vbus.cpp +++ b/esphome/components/vbus/vbus.cpp @@ -4,8 +4,7 @@ #include #include -namespace esphome { -namespace vbus { +namespace esphome::vbus { static const char *const TAG = "vbus"; @@ -131,5 +130,4 @@ void VBusListener::on_message(uint16_t command, uint16_t source, uint16_t dest, this->handle_message(message); } -} // namespace vbus -} // namespace esphome +} // namespace esphome::vbus diff --git a/esphome/components/vbus/vbus.h b/esphome/components/vbus/vbus.h index 0a253f1bdb..ff523178ef 100644 --- a/esphome/components/vbus/vbus.h +++ b/esphome/components/vbus/vbus.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace vbus { +namespace esphome::vbus { using message_parser_t = std::function &)>; @@ -47,5 +46,4 @@ class VBus : public uart::UARTDevice, public Component { std::vector listeners_{}; }; -} // namespace vbus -} // namespace esphome +} // namespace esphome::vbus diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 1e02e3e802..fd6cf1e2ed 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace veml3235 { +namespace esphome::veml3235 { static const char *const TAG = "veml3235.sensor"; @@ -225,5 +224,4 @@ void VEML3235Sensor::dump_config() { digital_gain, gain, integration_time); } -} // namespace veml3235 -} // namespace esphome +} // namespace esphome::veml3235 diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index b57e1571f1..df88bc6ff5 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace veml3235 { +namespace esphome::veml3235 { // Register IDs/locations // @@ -104,5 +103,4 @@ class VEML3235Sensor : public sensor::Sensor, public PollingComponent, public i2 VEML3235ComponentIntegrationTime integration_time_{VEML3235_INTEGRATION_TIME_50MS}; }; -} // namespace veml3235 -} // namespace esphome +} // namespace esphome::veml3235 diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 1ed484119b..80e6f872ab 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace veml7700 { +namespace esphome::veml7700 { static const char *const TAG = "veml7700"; static const size_t VEML_REG_SIZE = 2; @@ -434,5 +433,4 @@ void VEML7700Component::publish_data_part_3_(Readings &data) { this->actual_integration_time_sensor_->publish_state(get_itime_ms(data.actual_time)); } } -} // namespace veml7700 -} // namespace esphome +} // namespace esphome::veml7700 diff --git a/esphome/components/veml7700/veml7700.h b/esphome/components/veml7700/veml7700.h index 4b5edf733d..a036bdf002 100644 --- a/esphome/components/veml7700/veml7700.h +++ b/esphome/components/veml7700/veml7700.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -namespace esphome { -namespace veml7700 { +namespace esphome::veml7700 { using esphome::i2c::ErrorCode; @@ -196,5 +195,4 @@ class VEML7700Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *actual_integration_time_sensor_{nullptr}; // Actual integration time for the measurement }; -} // namespace veml7700 -} // namespace esphome +} // namespace esphome::veml7700 diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.cpp b/esphome/components/vl53l0x/vl53l0x_sensor.cpp index 58b5a42675..df7929f676 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.cpp +++ b/esphome/components/vl53l0x/vl53l0x_sensor.cpp @@ -12,8 +12,7 @@ * in the vl53l0x integration directory. */ -namespace esphome { -namespace vl53l0x { +namespace esphome::vl53l0x { static const char *const TAG = "vl53l0x"; @@ -535,5 +534,4 @@ bool VL53L0XSensor::perform_single_ref_calibration_(uint8_t vhv_init_byte) { return true; } -} // namespace vl53l0x -} // namespace esphome +} // namespace esphome::vl53l0x diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.h b/esphome/components/vl53l0x/vl53l0x_sensor.h index f533005b5b..7c916f4fde 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.h +++ b/esphome/components/vl53l0x/vl53l0x_sensor.h @@ -7,8 +7,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace vl53l0x { +namespace esphome::vl53l0x { struct SequenceStepEnables { bool tcc, msrc, dss, pre_range, final_range; @@ -70,5 +69,4 @@ class VL53L0XSensor : public sensor::Sensor, public PollingComponent, public i2c static bool enable_pin_setup_complete; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) }; -} // namespace vl53l0x -} // namespace esphome +} // namespace esphome::vl53l0x diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index ddce606b2c..f2244a8ff4 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -9,8 +9,7 @@ #include #include -namespace esphome { -namespace voice_assistant { +namespace esphome::voice_assistant { static const char *const TAG = "voice_assistant"; @@ -1007,7 +1006,6 @@ const Configuration &VoiceAssistant::get_configuration() { VoiceAssistant *global_voice_assistant = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace voice_assistant -} // namespace esphome +} // namespace esphome::voice_assistant #endif // USE_VOICE_ASSISTANT diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index b1b5f20bff..eb23dcb5e0 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -26,8 +26,7 @@ #include #include -namespace esphome { -namespace voice_assistant { +namespace esphome::voice_assistant { // Version 1: Initial version // Version 2: Adds raw speaker support @@ -367,7 +366,6 @@ template class ConnectedCondition : public Condition, pub extern VoiceAssistant *global_voice_assistant; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace voice_assistant -} // namespace esphome +} // namespace esphome::voice_assistant #endif // USE_VOICE_ASSISTANT diff --git a/esphome/components/voltage_sampler/voltage_sampler.h b/esphome/components/voltage_sampler/voltage_sampler.h index d2e74d33bc..c1b6ffb11e 100644 --- a/esphome/components/voltage_sampler/voltage_sampler.h +++ b/esphome/components/voltage_sampler/voltage_sampler.h @@ -2,8 +2,7 @@ #include "esphome/core/component.h" -namespace esphome { -namespace voltage_sampler { +namespace esphome::voltage_sampler { /// Abstract interface for components to request voltage (usually ADC readings) class VoltageSampler { @@ -12,5 +11,4 @@ class VoltageSampler { virtual float sample() = 0; }; -} // namespace voltage_sampler -} // namespace esphome +} // namespace esphome::voltage_sampler diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index 8c5bdac54b..fee6377965 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -4,8 +4,7 @@ #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" -namespace esphome { -namespace wake_on_lan { +namespace esphome::wake_on_lan { static const char *const TAG = "wake_on_lan.button"; static const uint8_t PREFIX[6] = {255, 255, 255, 255, 255, 255}; @@ -84,6 +83,6 @@ void WakeOnLanButton::setup() { #endif } -} // namespace wake_on_lan -} // namespace esphome +} // namespace esphome::wake_on_lan + #endif diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index f516c4d669..48f8d00a66 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -9,8 +9,7 @@ #include "WiFiUdp.h" #endif -namespace esphome { -namespace wake_on_lan { +namespace esphome::wake_on_lan { class WakeOnLanButton : public button::Button, public Component { public: @@ -31,6 +30,6 @@ class WakeOnLanButton : public button::Button, public Component { uint8_t macaddr_[6]; }; -} // namespace wake_on_lan -} // namespace esphome +} // namespace esphome::wake_on_lan + #endif diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index edf113b0b4..b05d7d4f6d 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -14,8 +14,7 @@ #include "pico/stdlib.h" #endif -namespace esphome { -namespace watchdog { +namespace esphome::watchdog { static const char *const TAG = "http_request.watchdog"; @@ -75,5 +74,4 @@ uint32_t WatchdogManager::get_timeout_() { return timeout_ms; } -} // namespace watchdog -} // namespace esphome +} // namespace esphome::watchdog diff --git a/esphome/components/watchdog/watchdog.h b/esphome/components/watchdog/watchdog.h index 899ec3fde0..795c057672 100644 --- a/esphome/components/watchdog/watchdog.h +++ b/esphome/components/watchdog/watchdog.h @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace watchdog { +namespace esphome::watchdog { class WatchdogManager { public: @@ -20,5 +19,4 @@ class WatchdogManager { uint32_t timeout_ms_{0}; }; -} // namespace watchdog -} // namespace esphome +} // namespace esphome::watchdog diff --git a/esphome/components/waveshare_epaper/waveshare_213v3.cpp b/esphome/components/waveshare_epaper/waveshare_213v3.cpp index b55f3c8d26..cc9c34cb42 100644 --- a/esphome/components/waveshare_epaper/waveshare_213v3.cpp +++ b/esphome/components/waveshare_epaper/waveshare_213v3.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace waveshare_epaper { +namespace esphome::waveshare_epaper { static const char *const TAG = "waveshare_2.13v3"; @@ -188,5 +187,4 @@ void WaveshareEPaper2P13InV3::set_full_update_every(uint32_t full_update_every) this->full_update_every_ = full_update_every; } -} // namespace waveshare_epaper -} // namespace esphome +} // namespace esphome::waveshare_epaper diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.cpp b/esphome/components/waveshare_epaper/waveshare_epaper.cpp index 4db9438206..14ff5ed53c 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.cpp +++ b/esphome/components/waveshare_epaper/waveshare_epaper.cpp @@ -5,8 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace waveshare_epaper { +namespace esphome::waveshare_epaper { static const char *const TAG = "waveshare_epaper"; @@ -4770,5 +4769,4 @@ void WaveshareEPaper13P3InK::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace waveshare_epaper -} // namespace esphome +} // namespace esphome::waveshare_epaper diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.h b/esphome/components/waveshare_epaper/waveshare_epaper.h index 74bb153519..fa3737238e 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.h +++ b/esphome/components/waveshare_epaper/waveshare_epaper.h @@ -4,8 +4,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display_buffer.h" -namespace esphome { -namespace waveshare_epaper { +namespace esphome::waveshare_epaper { class WaveshareEPaperBase : public display::DisplayBuffer, public spi::SPIDevice -namespace esphome { -namespace whynter { +namespace esphome::whynter { // Temperature const uint8_t TEMP_MIN_C = 16; // Celsius @@ -49,5 +48,4 @@ class Whynter : public climate_ir::ClimateIR { climate::ClimateMode mode_before_{climate::CLIMATE_MODE_OFF}; }; -} // namespace whynter -} // namespace esphome +} // namespace esphome::whynter diff --git a/esphome/components/wiegand/wiegand.cpp b/esphome/components/wiegand/wiegand.cpp index f3f578794a..e5c29f8b11 100644 --- a/esphome/components/wiegand/wiegand.cpp +++ b/esphome/components/wiegand/wiegand.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace wiegand { +namespace esphome::wiegand { static const char *const TAG = "wiegand"; static const char *const KEYS = "0123456789*#"; @@ -127,5 +126,4 @@ void Wiegand::dump_config() { LOG_PIN(" D1 pin: ", this->d1_pin_); } -} // namespace wiegand -} // namespace esphome +} // namespace esphome::wiegand diff --git a/esphome/components/wiegand/wiegand.h b/esphome/components/wiegand/wiegand.h index 994631a3a3..33d81ba086 100644 --- a/esphome/components/wiegand/wiegand.h +++ b/esphome/components/wiegand/wiegand.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace wiegand { +namespace esphome::wiegand { class Wiegand; @@ -50,5 +49,4 @@ class Wiegand : public key_provider::KeyProvider, public Component { std::vector key_triggers_; }; -} // namespace wiegand -} // namespace esphome +} // namespace esphome::wiegand diff --git a/esphome/components/wk2132_i2c/wk2132_i2c.cpp b/esphome/components/wk2132_i2c/wk2132_i2c.cpp index aaefae6f97..d60e8a834f 100644 --- a/esphome/components/wk2132_i2c/wk2132_i2c.cpp +++ b/esphome/components/wk2132_i2c/wk2132_i2c.cpp @@ -1,4 +1,2 @@ /* compiling with esp-idf framework requires a .cpp file for some reason ? */ -namespace esphome { -namespace wk2132_i2c {} -} // namespace esphome +namespace esphome::wk2132_i2c {} // namespace esphome::wk2132_i2c diff --git a/esphome/components/wl_134/wl_134.cpp b/esphome/components/wl_134/wl_134.cpp index a902adfddd..f3eb17965d 100644 --- a/esphome/components/wl_134/wl_134.cpp +++ b/esphome/components/wl_134/wl_134.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace wl_134 { +namespace esphome::wl_134 { static const char *const TAG = "wl_134.sensor"; static const uint8_t ASCII_CR = 0x0D; @@ -114,5 +113,4 @@ void Wl134Component::dump_config() { // As specified in the sensor's data sheet this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8); } -} // namespace wl_134 -} // namespace esphome +} // namespace esphome::wl_134 diff --git a/esphome/components/wl_134/wl_134.h b/esphome/components/wl_134/wl_134.h index c0a90de17d..973e5a1e7c 100644 --- a/esphome/components/wl_134/wl_134.h +++ b/esphome/components/wl_134/wl_134.h @@ -6,8 +6,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace wl_134 { +namespace esphome::wl_134 { class Wl134Component : public text_sensor::TextSensor, public Component, public uart::UARTDevice { public: @@ -59,5 +58,4 @@ class Wl134Component : public text_sensor::TextSensor, public Component, public uint64_t hex_lsb_ascii_to_uint64_(const uint8_t *text, uint8_t text_size); }; -} // namespace wl_134 -} // namespace esphome +} // namespace esphome::wl_134 diff --git a/esphome/components/wled/wled_light_effect.cpp b/esphome/components/wled/wled_light_effect.cpp index db2708d6d0..e0724aa94a 100644 --- a/esphome/components/wled/wled_light_effect.cpp +++ b/esphome/components/wled/wled_light_effect.cpp @@ -17,8 +17,7 @@ #include #endif -namespace esphome { -namespace wled { +namespace esphome::wled { // Description of protocols: // https://github.com/Aircoookie/WLED/wiki/UDP-Realtime-Control @@ -284,7 +283,6 @@ bool WLEDLightEffect::parse_dnrgb_frame_(light::AddressableLight &it, const uint return true; } -} // namespace wled -} // namespace esphome +} // namespace esphome::wled #endif // USE_ARDUINO diff --git a/esphome/components/wled/wled_light_effect.h b/esphome/components/wled/wled_light_effect.h index 3f3b710611..bed897f5a6 100644 --- a/esphome/components/wled/wled_light_effect.h +++ b/esphome/components/wled/wled_light_effect.h @@ -10,8 +10,7 @@ class UDP; -namespace esphome { -namespace wled { +namespace esphome::wled { class WLEDLightEffect : public light::AddressableLightEffect { public: @@ -42,7 +41,6 @@ class WLEDLightEffect : public light::AddressableLightEffect { bool blank_on_start_{true}; }; -} // namespace wled -} // namespace esphome +} // namespace esphome::wled #endif // USE_ARDUINO diff --git a/esphome/components/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp index a7948c805a..cc7ee98079 100644 --- a/esphome/components/wts01/wts01.cpp +++ b/esphome/components/wts01/wts01.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace wts01 { +namespace esphome::wts01 { constexpr uint8_t HEADER_1 = 0x55; constexpr uint8_t HEADER_2 = 0x01; @@ -90,5 +89,4 @@ void WTS01Sensor::process_packet_() { this->publish_state(temperature); } -} // namespace wts01 -} // namespace esphome +} // namespace esphome::wts01 diff --git a/esphome/components/wts01/wts01.h b/esphome/components/wts01/wts01.h index aae90c2c77..17d4dc57a2 100644 --- a/esphome/components/wts01/wts01.h +++ b/esphome/components/wts01/wts01.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace wts01 { +namespace esphome::wts01 { constexpr uint8_t PACKET_SIZE = 9; @@ -22,5 +21,4 @@ class WTS01Sensor : public sensor::Sensor, public uart::UARTDevice, public Compo void process_packet_(); }; -} // namespace wts01 -} // namespace esphome +} // namespace esphome::wts01 diff --git a/esphome/components/x9c/x9c.cpp b/esphome/components/x9c/x9c.cpp index 773e52d6e1..52ce328b3c 100644 --- a/esphome/components/x9c/x9c.cpp +++ b/esphome/components/x9c/x9c.cpp @@ -1,8 +1,7 @@ #include "x9c.h" #include "esphome/core/log.h" -namespace esphome { -namespace x9c { +namespace esphome::x9c { static const char *const TAG = "x9c.output"; @@ -73,5 +72,4 @@ void X9cOutput::dump_config() { LOG_FLOAT_OUTPUT(this); } -} // namespace x9c -} // namespace esphome +} // namespace esphome::x9c diff --git a/esphome/components/x9c/x9c.h b/esphome/components/x9c/x9c.h index 7dcd79bb7c..112f0405d7 100644 --- a/esphome/components/x9c/x9c.h +++ b/esphome/components/x9c/x9c.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace x9c { +namespace esphome::x9c { class X9cOutput : public output::FloatOutput, public Component { public: @@ -30,5 +29,4 @@ class X9cOutput : public output::FloatOutput, public Component { int step_delay_{0}; }; -} // namespace x9c -} // namespace esphome +} // namespace esphome::x9c diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index 5e816469ac..0eefd594d0 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace xgzp68xx { +namespace esphome::xgzp68xx { static const char *const TAG = "xgzp68xx.sensor"; @@ -118,5 +117,4 @@ void XGZP68XXComponent::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace xgzp68xx -} // namespace esphome +} // namespace esphome::xgzp68xx diff --git a/esphome/components/xgzp68xx/xgzp68xx.h b/esphome/components/xgzp68xx/xgzp68xx.h index ce9cfd6b78..1bab9b091a 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.h +++ b/esphome/components/xgzp68xx/xgzp68xx.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace xgzp68xx { +namespace esphome::xgzp68xx { /// Enum listing all oversampling options for the XGZP68XX. enum XGZP68XXOversampling : uint8_t { @@ -43,5 +42,4 @@ class XGZP68XXComponent : public PollingComponent, public sensor::Sensor, public XGZP68XXOversampling last_pressure_oversampling_{XGZP68XX_OVERSAMPLING_UNKNOWN}; }; -} // namespace xgzp68xx -} // namespace esphome +} // namespace esphome::xgzp68xx diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 2c1611d0c7..0961df2bd6 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -12,8 +12,7 @@ #include "mbedtls/ccm.h" #endif -namespace esphome { -namespace xiaomi_ble { +namespace esphome::xiaomi_ble { static const char *const TAG = "xiaomi_ble"; @@ -460,7 +459,6 @@ bool XiaomiListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) return false; // with true it's not showing device scans } -} // namespace xiaomi_ble -} // namespace esphome +} // namespace esphome::xiaomi_ble #endif diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.h b/esphome/components/xiaomi_ble/xiaomi_ble.h index 42609a998b..a4ecca0c66 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.h +++ b/esphome/components/xiaomi_ble/xiaomi_ble.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_ble { +namespace esphome::xiaomi_ble { struct XiaomiParseResult { enum { @@ -78,7 +77,6 @@ class XiaomiListener : public esp32_ble_tracker::ESPBTDeviceListener { bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; -} // namespace xiaomi_ble -} // namespace esphome +} // namespace esphome::xiaomi_ble #endif diff --git a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp index 82a04f0d6e..948e02be46 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_cgd1 { +namespace esphome::xiaomi_cgd1 { static const char *const TAG = "xiaomi_cgd1"; @@ -65,7 +64,6 @@ 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 xiaomi_cgd1 -} // namespace esphome +} // namespace esphome::xiaomi_cgd1 #endif diff --git a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h index 4a34eea32a..1c510c7eb4 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_cgd1 { +namespace esphome::xiaomi_cgd1 { class XiaomiCGD1 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -29,7 +28,6 @@ class XiaomiCGD1 : public Component, public esp32_ble_tracker::ESPBTDeviceListen sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_cgd1 -} // namespace esphome +} // namespace esphome::xiaomi_cgd1 #endif diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp index 39ece3e091..ff9036db14 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_cgdk2 { +namespace esphome::xiaomi_cgdk2 { static const char *const TAG = "xiaomi_cgdk2"; @@ -65,7 +64,6 @@ 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 xiaomi_cgdk2 -} // namespace esphome +} // namespace esphome::xiaomi_cgdk2 #endif diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h index ed917e2bbd..02d098c31b 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_cgdk2 { +namespace esphome::xiaomi_cgdk2 { class XiaomiCGDK2 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -29,7 +28,6 @@ class XiaomiCGDK2 : public Component, public esp32_ble_tracker::ESPBTDeviceListe sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_cgdk2 -} // namespace esphome +} // namespace esphome::xiaomi_cgdk2 #endif diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp index 448592db16..ef4ef46424 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_cgg1 { +namespace esphome::xiaomi_cgg1 { static const char *const TAG = "xiaomi_cgg1"; @@ -65,7 +64,6 @@ 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 xiaomi_cgg1 -} // namespace esphome +} // namespace esphome::xiaomi_cgg1 #endif diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h index c560bddd69..d49e3a08d1 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_cgg1 { +namespace esphome::xiaomi_cgg1 { class XiaomiCGG1 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -30,7 +29,6 @@ class XiaomiCGG1 : public Component, public esp32_ble_tracker::ESPBTDeviceListen sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_cgg1 -} // namespace esphome +} // namespace esphome::xiaomi_cgg1 #endif diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp index 8813f6479b..3203f358b9 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_cgpr1 { +namespace esphome::xiaomi_cgpr1 { static const char *const TAG = "xiaomi_cgpr1"; @@ -62,7 +61,6 @@ 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 xiaomi_cgpr1 -} // namespace esphome +} // namespace esphome::xiaomi_cgpr1 #endif diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h index 82bbbfa58d..28a7a3ae2d 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_cgpr1 { +namespace esphome::xiaomi_cgpr1 { class XiaomiCGPR1 : public Component, public binary_sensor::BinarySensorInitiallyOff, @@ -33,7 +32,6 @@ class XiaomiCGPR1 : public Component, sensor::Sensor *illuminance_{nullptr}; }; -} // namespace xiaomi_cgpr1 -} // namespace esphome +} // namespace esphome::xiaomi_cgpr1 #endif diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp index 159b6df80b..11ea98045b 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_gcls002 { +namespace esphome::xiaomi_gcls002 { static const char *const TAG = "xiaomi_gcls002"; @@ -58,7 +57,6 @@ bool XiaomiGCLS002::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return success; } -} // namespace xiaomi_gcls002 -} // namespace esphome +} // namespace esphome::xiaomi_gcls002 #endif diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h index 83c8f15ace..e14077adb0 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_gcls002 { +namespace esphome::xiaomi_gcls002 { class XiaomiGCLS002 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -30,7 +29,6 @@ class XiaomiGCLS002 : public Component, public esp32_ble_tracker::ESPBTDeviceLis sensor::Sensor *illuminance_{nullptr}; }; -} // namespace xiaomi_gcls002 -} // namespace esphome +} // namespace esphome::xiaomi_gcls002 #endif diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp index e10754d832..1d872c68c1 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_hhccjcy01 { +namespace esphome::xiaomi_hhccjcy01 { static const char *const TAG = "xiaomi_hhccjcy01"; @@ -61,7 +60,6 @@ bool XiaomiHHCCJCY01::parse_device(const esp32_ble_tracker::ESPBTDevice &device) return success; } -} // namespace xiaomi_hhccjcy01 -} // namespace esphome +} // namespace esphome::xiaomi_hhccjcy01 #endif diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h index 96ea9217fb..8bc6399065 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_hhccjcy01 { +namespace esphome::xiaomi_hhccjcy01 { class XiaomiHHCCJCY01 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -32,7 +31,6 @@ class XiaomiHHCCJCY01 : public Component, public esp32_ble_tracker::ESPBTDeviceL sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_hhccjcy01 -} // namespace esphome +} // namespace esphome::xiaomi_hhccjcy01 #endif diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp index 028d797ac1..c6ebd5ff74 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_hhccjcy10 { +namespace esphome::xiaomi_hhccjcy10 { static const char *const TAG = "xiaomi_hhccjcy10"; @@ -63,7 +62,6 @@ bool XiaomiHHCCJCY10::parse_device(const esp32_ble_tracker::ESPBTDevice &device) return success; } -} // namespace xiaomi_hhccjcy10 -} // namespace esphome +} // namespace esphome::xiaomi_hhccjcy10 #endif diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h index bd4ad75c1d..812e3a7d8f 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_hhccjcy10 { +namespace esphome::xiaomi_hhccjcy10 { class XiaomiHHCCJCY10 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -31,7 +30,6 @@ class XiaomiHHCCJCY10 : public Component, public esp32_ble_tracker::ESPBTDeviceL sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_hhccjcy10 -} // namespace esphome +} // namespace esphome::xiaomi_hhccjcy10 #endif diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp index 2d2447db27..bbca9faaa6 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_hhccpot002 { +namespace esphome::xiaomi_hhccpot002 { static const char *const TAG = "xiaomi_hhccpot002"; @@ -52,7 +51,6 @@ bool XiaomiHHCCPOT002::parse_device(const esp32_ble_tracker::ESPBTDevice &device return success; } -} // namespace xiaomi_hhccpot002 -} // namespace esphome +} // namespace esphome::xiaomi_hhccpot002 #endif diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h index 0ec34b1871..2bdd6102be 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_hhccpot002 { +namespace esphome::xiaomi_hhccpot002 { class XiaomiHHCCPOT002 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -26,7 +25,6 @@ class XiaomiHHCCPOT002 : public Component, public esp32_ble_tracker::ESPBTDevice sensor::Sensor *conductivity_{nullptr}; }; -} // namespace xiaomi_hhccpot002 -} // namespace esphome +} // namespace esphome::xiaomi_hhccpot002 #endif diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp index 8216a92e54..c0f4de3d06 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_jqjcy01ym { +namespace esphome::xiaomi_jqjcy01ym { static const char *const TAG = "xiaomi_jqjcy01ym"; @@ -58,7 +57,6 @@ bool XiaomiJQJCY01YM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) return success; } -} // namespace xiaomi_jqjcy01ym -} // namespace esphome +} // namespace esphome::xiaomi_jqjcy01ym #endif diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h index e9c44800f2..aaf34f899f 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_jqjcy01ym { +namespace esphome::xiaomi_jqjcy01ym { class XiaomiJQJCY01YM : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -30,7 +29,6 @@ class XiaomiJQJCY01YM : public Component, public esp32_ble_tracker::ESPBTDeviceL sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_jqjcy01ym -} // namespace esphome +} // namespace esphome::xiaomi_jqjcy01ym #endif diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp index e140835d03..75909738c8 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_lywsd02 { +namespace esphome::xiaomi_lywsd02 { static const char *const TAG = "xiaomi_lywsd02"; @@ -55,7 +54,6 @@ bool XiaomiLYWSD02::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return success; } -} // namespace xiaomi_lywsd02 -} // namespace esphome +} // namespace esphome::xiaomi_lywsd02 #endif diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h index 772b389a92..e45596f966 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_lywsd02 { +namespace esphome::xiaomi_lywsd02 { class XiaomiLYWSD02 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -28,7 +27,6 @@ class XiaomiLYWSD02 : public Component, public esp32_ble_tracker::ESPBTDeviceLis sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_lywsd02 -} // namespace esphome +} // namespace esphome::xiaomi_lywsd02 #endif diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp index 2dd60d4ecb..79610ee266 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_lywsd02mmc { +namespace esphome::xiaomi_lywsd02mmc { static const char *const TAG = "xiaomi_lywsd02mmc"; @@ -65,7 +64,6 @@ 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 xiaomi_lywsd02mmc -} // namespace esphome +} // namespace esphome::xiaomi_lywsd02mmc #endif diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h index 968604fee6..23efcbf8fc 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_lywsd02mmc { +namespace esphome::xiaomi_lywsd02mmc { class XiaomiLYWSD02MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -30,7 +29,6 @@ class XiaomiLYWSD02MMC : public Component, public esp32_ble_tracker::ESPBTDevice sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_lywsd02mmc -} // namespace esphome +} // namespace esphome::xiaomi_lywsd02mmc #endif diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index b11bbdc40c..a0a9260156 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_lywsd03mmc { +namespace esphome::xiaomi_lywsd03mmc { static const char *const TAG = "xiaomi_lywsd03mmc"; @@ -69,7 +68,6 @@ 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 xiaomi_lywsd03mmc -} // namespace esphome +} // namespace esphome::xiaomi_lywsd03mmc #endif diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h index d890e5ed12..03462b850f 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_lywsd03mmc { +namespace esphome::xiaomi_lywsd03mmc { class XiaomiLYWSD03MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -29,7 +28,6 @@ class XiaomiLYWSD03MMC : public Component, public esp32_ble_tracker::ESPBTDevice sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_lywsd03mmc -} // namespace esphome +} // namespace esphome::xiaomi_lywsd03mmc #endif diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp index 65991ffa0e..56efaaef51 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_lywsdcgq { +namespace esphome::xiaomi_lywsdcgq { static const char *const TAG = "xiaomi_lywsdcgq"; @@ -55,7 +54,6 @@ bool XiaomiLYWSDCGQ::parse_device(const esp32_ble_tracker::ESPBTDevice &device) return success; } -} // namespace xiaomi_lywsdcgq -} // namespace esphome +} // namespace esphome::xiaomi_lywsdcgq #endif diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h index cf90db937f..e169afc651 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_lywsdcgq { +namespace esphome::xiaomi_lywsdcgq { class XiaomiLYWSDCGQ : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -28,7 +27,6 @@ class XiaomiLYWSDCGQ : public Component, public esp32_ble_tracker::ESPBTDeviceLi sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_lywsdcgq -} // namespace esphome +} // namespace esphome::xiaomi_lywsdcgq #endif diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp index 1097b9c1e8..74626ed0a5 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_mhoc303 { +namespace esphome::xiaomi_mhoc303 { static const char *const TAG = "xiaomi_mhoc303"; @@ -55,7 +54,6 @@ bool XiaomiMHOC303::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return success; } -} // namespace xiaomi_mhoc303 -} // namespace esphome +} // namespace esphome::xiaomi_mhoc303 #endif diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h index c3b8e7d68f..daacd6be86 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_mhoc303 { +namespace esphome::xiaomi_mhoc303 { class XiaomiMHOC303 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -28,7 +27,6 @@ class XiaomiMHOC303 : public Component, public esp32_ble_tracker::ESPBTDeviceLis sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_mhoc303 -} // namespace esphome +} // namespace esphome::xiaomi_mhoc303 #endif diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index 10cd15ddbd..1cf0de14d3 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_mhoc401 { +namespace esphome::xiaomi_mhoc401 { static const char *const TAG = "xiaomi_mhoc401"; @@ -69,7 +68,6 @@ 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 xiaomi_mhoc401 -} // namespace esphome +} // namespace esphome::xiaomi_mhoc401 #endif diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h index 13547e45d9..225c9ff189 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_mhoc401 { +namespace esphome::xiaomi_mhoc401 { class XiaomiMHOC401 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -29,7 +28,6 @@ class XiaomiMHOC401 : public Component, public esp32_ble_tracker::ESPBTDeviceLis sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_mhoc401 -} // namespace esphome +} // namespace esphome::xiaomi_mhoc401 #endif diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp index e4f77fb915..2b1492129c 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_miscale { +namespace esphome::xiaomi_miscale { static const char *const TAG = "xiaomi_miscale"; @@ -167,7 +166,6 @@ bool XiaomiMiscale::report_results_(const optional &result, const c return true; } -} // namespace xiaomi_miscale -} // namespace esphome +} // namespace esphome::xiaomi_miscale #endif diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.h b/esphome/components/xiaomi_miscale/xiaomi_miscale.h index 3d793e07ac..c75a22c9fb 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.h +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_miscale { +namespace esphome::xiaomi_miscale { struct ParseResult { int version; @@ -40,7 +39,6 @@ class XiaomiMiscale : public Component, public esp32_ble_tracker::ESPBTDeviceLis bool report_results_(const optional &result, const char *address); }; -} // namespace xiaomi_miscale -} // namespace esphome +} // namespace esphome::xiaomi_miscale #endif diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp index ec03c851cd..a7b2554aad 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_mjyd02yla { +namespace esphome::xiaomi_mjyd02yla { static const char *const TAG = "xiaomi_mjyd02yla"; @@ -65,7 +64,6 @@ 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 xiaomi_mjyd02yla -} // namespace esphome +} // namespace esphome::xiaomi_mjyd02yla #endif diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h index bf9dcaf844..ee4ed52520 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_mjyd02yla { +namespace esphome::xiaomi_mjyd02yla { class XiaomiMJYD02YLA : public Component, public binary_sensor::BinarySensorInitiallyOff, @@ -35,7 +34,6 @@ class XiaomiMJYD02YLA : public Component, binary_sensor::BinarySensor *is_light_{nullptr}; }; -} // namespace xiaomi_mjyd02yla -} // namespace esphome +} // namespace esphome::xiaomi_mjyd02yla #endif diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp index a3f9325946..259e0159c5 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_mue4094rt { +namespace esphome::xiaomi_mue4094rt { static const char *const TAG = "xiaomi_mue4094rt"; @@ -51,7 +50,6 @@ bool XiaomiMUE4094RT::parse_device(const esp32_ble_tracker::ESPBTDevice &device) return success; } -} // namespace xiaomi_mue4094rt -} // namespace esphome +} // namespace esphome::xiaomi_mue4094rt #endif diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h index f1da0705d0..a6d8abc5bf 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_mue4094rt { +namespace esphome::xiaomi_mue4094rt { class XiaomiMUE4094RT : public Component, public binary_sensor::BinarySensorInitiallyOff, @@ -26,7 +25,6 @@ class XiaomiMUE4094RT : public Component, uint16_t timeout_; }; -} // namespace xiaomi_mue4094rt -} // namespace esphome +} // namespace esphome::xiaomi_mue4094rt #endif diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp index 0f27f09c87..b42a5a3700 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_rtcgq02lm { +namespace esphome::xiaomi_rtcgq02lm { static const char *const TAG = "xiaomi_rtcgq02lm"; @@ -79,7 +78,6 @@ 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 xiaomi_rtcgq02lm -} // namespace esphome +} // namespace esphome::xiaomi_rtcgq02lm #endif diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h index 87dfc0b62b..cc6a334a20 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h @@ -13,8 +13,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_rtcgq02lm { +namespace esphome::xiaomi_rtcgq02lm { class XiaomiRTCGQ02LM : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -54,7 +53,6 @@ class XiaomiRTCGQ02LM : public Component, public esp32_ble_tracker::ESPBTDeviceL #endif }; -} // namespace xiaomi_rtcgq02lm -} // namespace esphome +} // namespace esphome::xiaomi_rtcgq02lm #endif diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp index b0e02e2372..1bf861a6af 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp @@ -3,8 +3,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_wx08zm { +namespace esphome::xiaomi_wx08zm { static const char *const TAG = "xiaomi_wx08zm"; @@ -56,7 +55,6 @@ bool XiaomiWX08ZM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return success; } -} // namespace xiaomi_wx08zm -} // namespace esphome +} // namespace esphome::xiaomi_wx08zm #endif diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h index 081705fd50..0b0cb8db0b 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_wx08zm { +namespace esphome::xiaomi_wx08zm { class XiaomiWX08ZM : public Component, public binary_sensor::BinarySensorInitiallyOff, @@ -29,7 +28,6 @@ class XiaomiWX08ZM : public Component, sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_wx08zm -} // namespace esphome +} // namespace esphome::xiaomi_wx08zm #endif diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index 50cf5f2d76..a4303b055a 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_xmwsdj04mmc { +namespace esphome::xiaomi_xmwsdj04mmc { static const char *const TAG = "xiaomi_xmwsdj04mmc"; @@ -69,7 +68,6 @@ 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 xiaomi_xmwsdj04mmc -} // namespace esphome +} // namespace esphome::xiaomi_xmwsdj04mmc #endif diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h index 22cac63059..9bab943ab9 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace xiaomi_xmwsdj04mmc { +namespace esphome::xiaomi_xmwsdj04mmc { class XiaomiXMWSDJ04MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: @@ -30,7 +29,6 @@ class XiaomiXMWSDJ04MMC : public Component, public esp32_ble_tracker::ESPBTDevic sensor::Sensor *battery_level_{nullptr}; }; -} // namespace xiaomi_xmwsdj04mmc -} // namespace esphome +} // namespace esphome::xiaomi_xmwsdj04mmc #endif diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index cfcbeeeb8d..d189d9b5c7 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -1,8 +1,7 @@ #include "xl9535.h" #include "esphome/core/log.h" -namespace esphome { -namespace xl9535 { +namespace esphome::xl9535 { static const char *const TAG = "xl9535"; @@ -118,5 +117,4 @@ void XL9535GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this-> bool XL9535GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void XL9535GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -} // namespace xl9535 -} // namespace esphome +} // namespace esphome::xl9535 diff --git a/esphome/components/xl9535/xl9535.h b/esphome/components/xl9535/xl9535.h index be0e2fbd82..253ce76273 100644 --- a/esphome/components/xl9535/xl9535.h +++ b/esphome/components/xl9535/xl9535.h @@ -4,8 +4,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/hal.h" -namespace esphome { -namespace xl9535 { +namespace esphome::xl9535 { enum { XL9535_INPUT_PORT_0_REGISTER = 0x00, @@ -52,5 +51,4 @@ class XL9535GPIOPin : public GPIOPin { gpio::Flags flags_; }; -} // namespace xl9535 -} // namespace esphome +} // namespace esphome::xl9535 diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index 84d3daf823..d08a54529d 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace xpt2046 { +namespace esphome::xpt2046 { static const char *const TAG = "xpt2046"; @@ -107,5 +106,4 @@ int16_t XPT2046Component::read_adc_(uint8_t ctrl) { // NOLINT return ((data[0] << 8) | data[1]) >> 3; } -} // namespace xpt2046 -} // namespace esphome +} // namespace esphome::xpt2046 diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.h b/esphome/components/xpt2046/touchscreen/xpt2046.h index f691ae2c7b..f619e06fb7 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.h +++ b/esphome/components/xpt2046/touchscreen/xpt2046.h @@ -7,8 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace xpt2046 { +namespace esphome::xpt2046 { using namespace touchscreen; @@ -37,5 +36,4 @@ class XPT2046Component : public Touchscreen, InternalGPIOPin *irq_pin_{nullptr}; }; -} // namespace xpt2046 -} // namespace esphome +} // namespace esphome::xpt2046 diff --git a/esphome/components/xxtea/xxtea.cpp b/esphome/components/xxtea/xxtea.cpp index ba17530b24..ded7abc221 100644 --- a/esphome/components/xxtea/xxtea.cpp +++ b/esphome/components/xxtea/xxtea.cpp @@ -1,7 +1,6 @@ #include "xxtea.h" -namespace esphome { -namespace xxtea { +namespace esphome::xxtea { static const uint32_t DELTA = 0x9e3779b9; #define MX ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((sum ^ y) + (k[(p ^ e) & 7] ^ z))) @@ -46,5 +45,4 @@ void decrypt(uint32_t *v, size_t n, const uint32_t *k) { } } -} // namespace xxtea -} // namespace esphome +} // namespace esphome::xxtea diff --git a/esphome/components/xxtea/xxtea.h b/esphome/components/xxtea/xxtea.h index 86afbd1d46..8019fb07fc 100644 --- a/esphome/components/xxtea/xxtea.h +++ b/esphome/components/xxtea/xxtea.h @@ -3,8 +3,7 @@ #include #include -namespace esphome { -namespace xxtea { +namespace esphome::xxtea { /** * Encrypt a block of data in-place using XXTEA algorithm with 256-bit key @@ -22,5 +21,4 @@ void encrypt(uint32_t *v, size_t n, const uint32_t *k); */ void decrypt(uint32_t *v, size_t n, const uint32_t *k); -} // namespace xxtea -} // namespace esphome +} // namespace esphome::xxtea diff --git a/esphome/components/yashima/yashima.cpp b/esphome/components/yashima/yashima.cpp index 4a64e6c41c..ba0a3a6404 100644 --- a/esphome/components/yashima/yashima.cpp +++ b/esphome/components/yashima/yashima.cpp @@ -1,8 +1,7 @@ #include "yashima.h" #include "esphome/core/log.h" -namespace esphome { -namespace yashima { +namespace esphome::yashima { static const char *const TAG = "yashima.climate"; @@ -197,5 +196,4 @@ void YashimaClimate::transmit_state_() { transmit.perform(); } -} // namespace yashima -} // namespace esphome +} // namespace esphome::yashima diff --git a/esphome/components/yashima/yashima.h b/esphome/components/yashima/yashima.h index 466816bd5f..336b28f5c5 100644 --- a/esphome/components/yashima/yashima.h +++ b/esphome/components/yashima/yashima.h @@ -7,8 +7,7 @@ #include "esphome/components/remote_transmitter/remote_transmitter.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace yashima { +namespace esphome::yashima { class YashimaClimate : public climate::Climate, public Component { public: @@ -36,5 +35,4 @@ class YashimaClimate : public climate::Climate, public Component { sensor::Sensor *sensor_{nullptr}; }; -} // namespace yashima -} // namespace esphome +} // namespace esphome::yashima diff --git a/esphome/components/zhlt01/zhlt01.cpp b/esphome/components/zhlt01/zhlt01.cpp index e5ab5915e4..2585ac1b57 100644 --- a/esphome/components/zhlt01/zhlt01.cpp +++ b/esphome/components/zhlt01/zhlt01.cpp @@ -1,8 +1,7 @@ #include "zhlt01.h" #include "esphome/core/log.h" -namespace esphome { -namespace zhlt01 { +namespace esphome::zhlt01 { static const char *const TAG = "zhlt01.climate"; @@ -234,5 +233,4 @@ bool ZHLT01Climate::on_receive(remote_base::RemoteReceiveData data) { return true; } -} // namespace zhlt01 -} // namespace esphome +} // namespace esphome::zhlt01 diff --git a/esphome/components/zhlt01/zhlt01.h b/esphome/components/zhlt01/zhlt01.h index 4413be2835..61fc2cc16a 100644 --- a/esphome/components/zhlt01/zhlt01.h +++ b/esphome/components/zhlt01/zhlt01.h @@ -82,8 +82,7 @@ * ***********************************************************************************/ -namespace esphome { -namespace zhlt01 { +namespace esphome::zhlt01 { /******************************************************************************** * TIMINGS @@ -163,5 +162,4 @@ class ZHLT01Climate : public climate_ir::ClimateIR { bool on_receive(remote_base::RemoteReceiveData data) override; }; -} // namespace zhlt01 -} // namespace esphome +} // namespace esphome::zhlt01 diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp b/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp index 565bbe9b4f..1fa995b3dc 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace zio_ultrasonic { +namespace esphome::zio_ultrasonic { static const char *const TAG = "zio_ultrasonic"; @@ -27,5 +26,4 @@ void ZioUltrasonicComponent::update() { } } -} // namespace zio_ultrasonic -} // namespace esphome +} // namespace esphome::zio_ultrasonic diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.h b/esphome/components/zio_ultrasonic/zio_ultrasonic.h index 23057b2ab0..d4d2ac974f 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.h +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.h @@ -6,8 +6,7 @@ static const char *const TAG = "Zio Ultrasonic"; -namespace esphome { -namespace zio_ultrasonic { +namespace esphome::zio_ultrasonic { class ZioUltrasonicComponent : public i2c::I2CDevice, public PollingComponent, public sensor::Sensor { public: @@ -16,5 +15,4 @@ class ZioUltrasonicComponent : public i2c::I2CDevice, public PollingComponent, p void update() override; }; -} // namespace zio_ultrasonic -} // namespace esphome +} // namespace esphome::zio_ultrasonic diff --git a/esphome/components/zyaura/zyaura.cpp b/esphome/components/zyaura/zyaura.cpp index 621439aa0c..0b834de90a 100644 --- a/esphome/components/zyaura/zyaura.cpp +++ b/esphome/components/zyaura/zyaura.cpp @@ -1,8 +1,7 @@ #include "zyaura.h" #include "esphome/core/log.h" -namespace esphome { -namespace zyaura { +namespace esphome::zyaura { static const char *const TAG = "zyaura"; @@ -121,5 +120,4 @@ void ZyAuraSensor::update() { } } -} // namespace zyaura -} // namespace esphome +} // namespace esphome::zyaura diff --git a/esphome/components/zyaura/zyaura.h b/esphome/components/zyaura/zyaura.h index 3070aa90c5..7c7954dec2 100644 --- a/esphome/components/zyaura/zyaura.h +++ b/esphome/components/zyaura/zyaura.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace zyaura { +namespace esphome::zyaura { static const uint8_t ZA_MAX_MS = 2; static const uint8_t ZA_MSG_LEN = 5; @@ -81,5 +80,4 @@ class ZyAuraSensor : public PollingComponent { bool publish_state_(ZaDataType data_type, sensor::Sensor *sensor, uint16_t *data_value); }; -} // namespace zyaura -} // namespace esphome +} // namespace esphome::zyaura From e152c6155b1d76fe7b5e84dd42b7f2144834c139 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 8 May 2026 14:00:50 +1200 Subject: [PATCH 1120/2214] [ci] Skip needs-docs for new components without CONFIG_SCHEMA (#16303) --- .github/scripts/auto-label-pr/detectors.js | 105 +++++++++++++++------ .github/scripts/auto-label-pr/index.js | 17 +++- 2 files changed, 86 insertions(+), 36 deletions(-) diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 25c0ba49af..410c1a53c0 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -1,4 +1,3 @@ -const fs = require('fs'); const { DOCS_PR_PATTERNS } = require('./constants'); const { COMPONENT_REGEX, @@ -9,6 +8,31 @@ const { } = require('../detect-tags'); const { loadCodeowners, getEffectiveOwners } = require('../codeowners'); +// Top-level `CONFIG_SCHEMA = ...` (assignment) or `CONFIG_SCHEMA: ConfigType = ...` (annotation). +// Ruff/Black enforce exactly one space around `=` and no space before `:`, +// so we can match strictly: `CONFIG_SCHEMA ` or `CONFIG_SCHEMA:`. +const CONFIG_SCHEMA_REGEX = /^CONFIG_SCHEMA[ :]/m; + +// Fetch a file's contents from the PR head SHA via the GitHub API. +// The auto-label workflow runs on `pull_request_target`, which checks out the +// base branch — files added by the PR don't exist in the workspace, so we have +// to fetch them from the head SHA. Returns null if the file can't be fetched. +async function fetchPrFileContent(github, context, path) { + try { + const { owner, repo } = context.repo; + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path, + ref: context.payload.pull_request.head.sha, + }); + return Buffer.from(data.content, 'base64').toString('utf8'); + } catch (error) { + console.log(`Failed to fetch ${path} from PR head:`, error.message); + return null; + } +} + // Strategy: Merge branch detection async function detectMergeBranch(context) { const labels = new Set(); @@ -45,52 +69,64 @@ async function detectComponentPlatforms(changedFiles, apiData) { } // Strategy: New component detection -async function detectNewComponents(prFiles) { +async function detectNewComponents(github, context, prFiles) { const labels = new Set(); + let hasYamlLoadable = false; const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); for (const file of addedFiles) { const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/); - if (componentMatch) { - try { - const content = fs.readFileSync(file, 'utf8'); - if (content.includes('IS_TARGET_PLATFORM = True')) { - labels.add('new-target-platform'); - } - } catch (error) { - console.log(`Failed to read content of ${file}:`, error.message); - } - labels.add('new-component'); + if (!componentMatch) continue; + + labels.add('new-component'); + const content = await fetchPrFileContent(github, context, file); + if (content === null) { + // Safe default: assume YAML-loadable so needs-docs behaviour is unchanged on fetch failure + hasYamlLoadable = true; + continue; + } + if (content.includes('IS_TARGET_PLATFORM = True')) { + labels.add('new-target-platform'); + } + if (CONFIG_SCHEMA_REGEX.test(content)) { + hasYamlLoadable = true; } } - return labels; + return { labels, hasYamlLoadable }; } // Strategy: New platform detection -async function detectNewPlatforms(prFiles, apiData) { +async function detectNewPlatforms(github, context, prFiles, apiData) { const labels = new Set(); + let hasYamlLoadable = false; const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); - for (const file of addedFiles) { - const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/); - if (platformFileMatch) { - const [, component, platform] = platformFileMatch; - if (apiData.platformComponents.includes(platform)) { - labels.add('new-platform'); - } - } + const platformPathPatterns = [ + /^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/, + /^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/, + ]; - const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/); - if (platformDirMatch) { - const [, component, platform] = platformDirMatch; - if (apiData.platformComponents.includes(platform)) { - labels.add('new-platform'); + for (const file of addedFiles) { + for (const re of platformPathPatterns) { + const match = file.match(re); + if (!match) continue; + const platform = match[2]; + if (!apiData.platformComponents.includes(platform)) break; + + labels.add('new-platform'); + const content = await fetchPrFileContent(github, context, file); + if (content === null) { + // Safe default: assume YAML-loadable so needs-docs behaviour is unchanged on fetch failure + hasYamlLoadable = true; + } else if (CONFIG_SCHEMA_REGEX.test(content)) { + hasYamlLoadable = true; } + break; } } - return labels; + return { labels, hasYamlLoadable }; } // Strategy: Core files detection @@ -300,7 +336,7 @@ function detectMaintainerAccess(context) { } // Strategy: Requirements detection -async function detectRequirements(allLabels, prFiles, context) { +async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable) { const labels = new Set(); // Check for missing tests @@ -308,8 +344,15 @@ async function detectRequirements(allLabels, prFiles, context) { labels.add('needs-tests'); } - // Check for missing docs - if (allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) { + // Check for missing docs. + // `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform` + // only count when at least one newly added file defines a top-level CONFIG_SCHEMA, + // i.e. the new component/platform is actually loadable from YAML. + const docsEligible = + allLabels.has('new-feature') || + ((allLabels.has('new-component') || allLabels.has('new-platform')) && hasYamlLoadable); + + if (docsEligible) { const prBody = context.payload.pull_request.body || ''; const hasDocsLink = DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody)); diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 021e91a9ee..9769cd8060 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -106,8 +106,8 @@ module.exports = async ({ github, context }) => { const [ branchLabels, componentLabels, - newComponentLabels, - newPlatformLabels, + newComponentResult, + newPlatformResult, coreLabels, sizeLabels, dashboardLabels, @@ -120,8 +120,8 @@ module.exports = async ({ github, context }) => { ] = await Promise.all([ detectMergeBranch(context), detectComponentPlatforms(changedFiles, apiData), - detectNewComponents(prFiles), - detectNewPlatforms(prFiles, apiData), + detectNewComponents(github, context, prFiles), + detectNewPlatforms(github, context, prFiles, apiData), detectCoreChanges(changedFiles), detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), detectDashboardChanges(changedFiles), @@ -133,6 +133,13 @@ module.exports = async ({ github, context }) => { detectMaintainerAccess(context) ]); + // Extract new-component / new-platform results + const newComponentLabels = newComponentResult.labels; + const newPlatformLabels = newPlatformResult.labels; + // Eligible for needs-docs only if any newly added component or platform file + // defines a top-level CONFIG_SCHEMA (i.e. is actually loadable from YAML). + const hasYamlLoadable = newComponentResult.hasYamlLoadable || newPlatformResult.hasYamlLoadable; + // Extract deprecated component info const deprecatedLabels = deprecatedResult.labels; const deprecatedInfo = deprecatedResult.deprecatedInfo; @@ -154,7 +161,7 @@ module.exports = async ({ github, context }) => { ]); // Detect requirements based on all other labels - const requirementLabels = await detectRequirements(allLabels, prFiles, context); + const requirementLabels = await detectRequirements(allLabels, prFiles, context, hasYamlLoadable); for (const label of requirementLabels) { allLabels.add(label); } From 08b17c9da1fcdb5d58d3133b144481969ca7bf54 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 7 May 2026 22:01:37 -0400 Subject: [PATCH 1121/2214] [core] Move core ring buffer to helper component (#16298) --- CODEOWNERS | 1 + esphome/components/audio/__init__.py | 1 + esphome/components/audio/audio_decoder.cpp | 4 +- esphome/components/audio/audio_decoder.h | 6 +- esphome/components/audio/audio_reader.cpp | 2 +- esphome/components/audio/audio_reader.h | 6 +- esphome/components/audio/audio_resampler.cpp | 4 +- esphome/components/audio/audio_resampler.h | 6 +- .../components/audio/audio_transfer_buffer.h | 10 +- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 4 +- .../i2s_audio/speaker/i2s_audio_speaker.h | 4 +- .../speaker/i2s_audio_speaker_standard.cpp | 2 +- .../components/micro_wake_word/__init__.py | 1 + .../micro_wake_word/micro_wake_word.cpp | 4 +- .../micro_wake_word/micro_wake_word.h | 4 +- .../mixer/speaker/mixer_speaker.cpp | 6 +- .../components/mixer/speaker/mixer_speaker.h | 3 +- .../resampler/speaker/resampler_speaker.cpp | 8 +- .../resampler/speaker/resampler_speaker.h | 3 +- esphome/components/ring_buffer/__init__.py | 7 + .../ring_buffer}/ring_buffer.cpp | 6 +- esphome/components/ring_buffer/ring_buffer.h | 126 ++++++++++++++++++ .../components/sound_level/sound_level.cpp | 6 +- esphome/components/sound_level/sound_level.h | 4 +- .../speaker/media_player/audio_pipeline.cpp | 6 +- .../speaker/media_player/audio_pipeline.h | 4 +- .../components/voice_assistant/__init__.py | 2 +- .../voice_assistant/voice_assistant.cpp | 4 +- .../voice_assistant/voice_assistant.h | 4 +- esphome/core/config.py | 4 - esphome/core/ring_buffer.h | 126 ++---------------- esphome/writer.py | 10 +- 32 files changed, 214 insertions(+), 174 deletions(-) create mode 100644 esphome/components/ring_buffer/__init__.py rename esphome/{core => components/ring_buffer}/ring_buffer.cpp (97%) create mode 100644 esphome/components/ring_buffer/ring_buffer.h diff --git a/CODEOWNERS b/CODEOWNERS index 471def542b..f8cdfdc6c6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -416,6 +416,7 @@ esphome/components/resampler/speaker/* @kahrendt esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz +esphome/components/ring_buffer/* @kahrendt esphome/components/rp2040/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 67ef2e7d1a..44371e87ab 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( from esphome.core import CORE import esphome.final_validate as fv +AUTO_LOAD = ["ring_buffer"] CODEOWNERS = ["@kahrendt"] DOMAIN = "audio" audio_ns = cg.esphome_ns.namespace("audio") diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index 3e6fad1101..d4ff59fc36 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -19,7 +19,7 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size) this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size); } -esp_err_t AudioDecoder::add_source(std::weak_ptr &input_ring_buffer) { +esp_err_t AudioDecoder::add_source(std::weak_ptr &input_ring_buffer) { auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_); if (source == nullptr) { return ESP_ERR_NO_MEM; @@ -36,7 +36,7 @@ esp_err_t AudioDecoder::add_source(const uint8_t *data_pointer, size_t length) { return ESP_OK; } -esp_err_t AudioDecoder::add_sink(std::weak_ptr &output_ring_buffer) { +esp_err_t AudioDecoder::add_sink(std::weak_ptr &output_ring_buffer) { if (this->output_transfer_buffer_ != nullptr) { this->output_transfer_buffer_->set_sink(output_ring_buffer); return ESP_OK; diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h index 7ea7a824f9..c34ebbc613 100644 --- a/esphome/components/audio/audio_decoder.h +++ b/esphome/components/audio/audio_decoder.h @@ -5,9 +5,9 @@ #include "audio.h" #include "audio_transfer_buffer.h" +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" -#include "esphome/core/ring_buffer.h" #ifdef USE_SPEAKER #include "esphome/components/speaker/speaker.h" @@ -70,12 +70,12 @@ class AudioDecoder { /// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr. /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated - esp_err_t add_source(std::weak_ptr &input_ring_buffer); + esp_err_t add_source(std::weak_ptr &input_ring_buffer); /// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr. /// @param output_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated - esp_err_t add_sink(std::weak_ptr &output_ring_buffer); + esp_err_t add_sink(std::weak_ptr &output_ring_buffer); #ifdef USE_SPEAKER /// @brief Adds a sink speaker for decoded audio. diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index 500f20533c..4678ed548c 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -54,7 +54,7 @@ enum HttpStatus { AudioReader::~AudioReader() { this->cleanup_connection_(); } -esp_err_t AudioReader::add_sink(const std::weak_ptr &output_ring_buffer) { +esp_err_t AudioReader::add_sink(const std::weak_ptr &output_ring_buffer) { if (current_audio_file_ != nullptr) { // A transfer buffer isn't ncessary for a local file this->file_ring_buffer_ = output_ring_buffer.lock(); diff --git a/esphome/components/audio/audio_reader.h b/esphome/components/audio/audio_reader.h index 61f187d151..b1f76172b0 100644 --- a/esphome/components/audio/audio_reader.h +++ b/esphome/components/audio/audio_reader.h @@ -5,7 +5,7 @@ #include "audio.h" #include "audio_transfer_buffer.h" -#include "esphome/core/ring_buffer.h" +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esp_err.h" @@ -35,7 +35,7 @@ class AudioReader { /// @brief Adds a sink ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr /// @param output_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership /// @return ESP_OK if successful, ESP_ERR_INVALID_STATE otherwise - esp_err_t add_sink(const std::weak_ptr &output_ring_buffer); + esp_err_t add_sink(const std::weak_ptr &output_ring_buffer); /// @brief Starts reading an audio file from an http source. The transfer buffer is allocated here. /// @param uri Web url to the http file. @@ -60,7 +60,7 @@ class AudioReader { AudioReaderState file_read_(); AudioReaderState http_read_(); - std::shared_ptr file_ring_buffer_; + std::shared_ptr file_ring_buffer_; std::unique_ptr output_transfer_buffer_; void cleanup_connection_(); diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp index ac1039971e..c04cc881f5 100644 --- a/esphome/components/audio/audio_resampler.cpp +++ b/esphome/components/audio/audio_resampler.cpp @@ -16,7 +16,7 @@ AudioResampler::AudioResampler(size_t input_buffer_size, size_t output_buffer_si this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size); } -esp_err_t AudioResampler::add_source(std::weak_ptr &input_ring_buffer) { +esp_err_t AudioResampler::add_source(std::weak_ptr &input_ring_buffer) { if (this->input_transfer_buffer_ != nullptr) { this->input_transfer_buffer_->set_source(input_ring_buffer); return ESP_OK; @@ -24,7 +24,7 @@ esp_err_t AudioResampler::add_source(std::weak_ptr &input_ring_buffe return ESP_ERR_NO_MEM; } -esp_err_t AudioResampler::add_sink(std::weak_ptr &output_ring_buffer) { +esp_err_t AudioResampler::add_sink(std::weak_ptr &output_ring_buffer) { if (this->output_transfer_buffer_ != nullptr) { this->output_transfer_buffer_->set_sink(output_ring_buffer); return ESP_OK; diff --git a/esphome/components/audio/audio_resampler.h b/esphome/components/audio/audio_resampler.h index e7503d1de0..575ad13692 100644 --- a/esphome/components/audio/audio_resampler.h +++ b/esphome/components/audio/audio_resampler.h @@ -5,9 +5,9 @@ #include "audio.h" #include "audio_transfer_buffer.h" +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" -#include "esphome/core/ring_buffer.h" #ifdef USE_SPEAKER #include "esphome/components/speaker/speaker.h" @@ -40,12 +40,12 @@ class AudioResampler { /// @brief Adds a source ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr. /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated - esp_err_t add_source(std::weak_ptr &input_ring_buffer); + esp_err_t add_source(std::weak_ptr &input_ring_buffer); /// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr. /// @param output_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated - esp_err_t add_sink(std::weak_ptr &output_ring_buffer); + esp_err_t add_sink(std::weak_ptr &output_ring_buffer); #ifdef USE_SPEAKER /// @brief Adds a sink speaker for decoded audio. diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index 7aa830fafa..68151bf4e2 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -1,8 +1,8 @@ #pragma once #ifdef USE_ESP32 +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/core/defines.h" -#include "esphome/core/ring_buffer.h" #ifdef USE_SPEAKER #include "esphome/components/speaker/speaker.h" @@ -76,7 +76,7 @@ class AudioTransferBuffer { void deallocate_buffer_(); // A possible source or sink for the transfer buffer - std::shared_ptr ring_buffer_; + std::shared_ptr ring_buffer_; uint8_t *buffer_{nullptr}; uint8_t *data_start_{nullptr}; @@ -105,7 +105,7 @@ class AudioSinkTransferBuffer : public AudioTransferBuffer { /// @brief Adds a ring buffer as the transfer buffer's sink. /// @param ring_buffer weak_ptr to the allocated ring buffer - void set_sink(const std::weak_ptr &ring_buffer) { this->ring_buffer_ = ring_buffer.lock(); } + void set_sink(const std::weak_ptr &ring_buffer) { this->ring_buffer_ = ring_buffer.lock(); } #ifdef USE_SPEAKER /// @brief Adds a speaker as the transfer buffer's sink. @@ -179,7 +179,9 @@ class AudioSourceTransferBuffer : public AudioTransferBuffer, public AudioReadab /// @brief Adds a ring buffer as the transfer buffer's source. /// @param ring_buffer weak_ptr to the allocated ring buffer - void set_source(const std::weak_ptr &ring_buffer) { this->ring_buffer_ = ring_buffer.lock(); }; + void set_source(const std::weak_ptr &ring_buffer) { + this->ring_buffer_ = ring_buffer.lock(); + }; // AudioReadableBuffer interface const uint8_t *data() const override { return this->data_start_; } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 58d17ea6c4..a71b7db3ba 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -195,7 +195,7 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t size_t bytes_written = 0; if (this->state_ == speaker::STATE_RUNNING) { - std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); if (temp_ring_buffer != nullptr) { // The weak_ptr locks successfully only while the speaker task owns the ring buffer, so it is safe to write bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, ticks_to_wait); @@ -207,7 +207,7 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t bool I2SAudioSpeakerBase::has_buffered_data() const { if (this->audio_ring_buffer_.use_count() > 0) { - std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); return temp_ring_buffer->available() > 0; } return false; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index d9a228ef2c..c598ca1bf8 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -9,12 +9,12 @@ #include #include "esphome/components/audio/audio.h" +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/speaker/speaker.h" #include "esphome/core/component.h" #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" -#include "esphome/core/ring_buffer.h" namespace esphome::i2s_audio { @@ -143,7 +143,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public QueueHandle_t i2s_event_queue_{nullptr}; - std::weak_ptr audio_ring_buffer_; + std::weak_ptr audio_ring_buffer_; uint32_t buffer_duration_ms_; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index edb316e3a2..51f2b225e2 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -54,7 +54,7 @@ void I2SAudioSpeaker::run_speaker_task() { audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer); if (transfer_buffer != nullptr) { - std::shared_ptr temp_ring_buffer = RingBuffer::create(ring_buffer_size); + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); if (temp_ring_buffer.use_count() == 1) { transfer_buffer->set_source(temp_ring_buffer); this->audio_ring_buffer_ = temp_ring_buffer; diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 22d2098de0..38926fce99 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -30,6 +30,7 @@ from esphome.core import CORE, HexInt _LOGGER = logging.getLogger(__name__) +AUTO_LOAD = ["ring_buffer"] CODEOWNERS = ["@kahrendt", "@jesserockz"] DEPENDENCIES = ["microphone"] diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 1568fc6373..c031a9f269 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -106,7 +106,7 @@ void MicroWakeWord::setup() { if (this->state_ == State::STOPPED) { return; } - std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (this->ring_buffer_.use_count() > 1) { size_t bytes_free = temp_ring_buffer->free(); @@ -156,7 +156,7 @@ void MicroWakeWord::inference_task(void *params) { if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { // Allocate ring buffer - std::shared_ptr temp_ring_buffer = RingBuffer::create( + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); if (temp_ring_buffer.use_count() == 0) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 79a1226fba..5c0c056ac0 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -6,11 +6,11 @@ #include "streaming_model.h" #include "esphome/components/microphone/microphone_source.h" +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" -#include "esphome/core/ring_buffer.h" #ifdef USE_OTA_STATE_LISTENER #include "esphome/components/ota/ota_backend.h" @@ -80,7 +80,7 @@ class MicroWakeWord : public Component Trigger wake_word_detected_trigger_; State state_{State::STOPPED}; - std::weak_ptr ring_buffer_; + std::weak_ptr ring_buffer_; std::vector wake_word_models_; #ifdef USE_MICRO_WAKE_WORD_VAD diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 0d16bce330..8dea4560c2 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -226,7 +226,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_ this->start(); } size_t bytes_written = 0; - std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (temp_ring_buffer.use_count() > 0) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); @@ -263,9 +263,9 @@ esp_err_t SourceSpeaker::start_() { return ESP_ERR_NO_MEM; } - std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (!temp_ring_buffer) { - temp_ring_buffer = RingBuffer::create(ring_buffer_size); + temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); this->ring_buffer_ = temp_ring_buffer; } diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 29876ea262..f392e83081 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -4,6 +4,7 @@ #include "esphome/components/audio/audio.h" #include "esphome/components/audio/audio_transfer_buffer.h" +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/speaker/speaker.h" #include "esphome/core/component.h" @@ -106,7 +107,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { MixerSpeaker *parent_; std::shared_ptr transfer_buffer_; - std::weak_ptr ring_buffer_; + std::weak_ptr ring_buffer_; uint32_t buffer_duration_ms_; uint32_t last_seen_data_ms_{0}; diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index 9f69a7b50b..ecbd445a80 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -226,7 +226,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic if ((this->output_speaker_->is_running()) && (!this->requires_resampling_())) { bytes_written = this->output_speaker_->play(data, length, ticks_to_wait); } else { - std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (temp_ring_buffer) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); @@ -286,7 +286,7 @@ void ResamplerSpeaker::finish() { this->send_command_(ResamplingEventGroupBits:: bool ResamplerSpeaker::has_buffered_data() const { bool has_ring_buffer_data = false; if (this->requires_resampling_()) { - std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (temp_ring_buffer) { has_ring_buffer_data = (temp_ring_buffer->available() > 0); } @@ -323,8 +323,8 @@ void ResamplerSpeaker::resample_task(void *params) { this_resampler->taps_, this_resampler->filters_); if (err == ESP_OK) { - std::shared_ptr temp_ring_buffer = - RingBuffer::create(this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( + this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); if (!temp_ring_buffer) { err = ESP_ERR_NO_MEM; diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index 36f39fda97..4a091e298a 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -4,6 +4,7 @@ #include "esphome/components/audio/audio.h" #include "esphome/components/audio/audio_transfer_buffer.h" +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/speaker/speaker.h" #include "esphome/core/component.h" @@ -75,7 +76,7 @@ class ResamplerSpeaker : public Component, public speaker::Speaker { EventGroupHandle_t event_group_{nullptr}; - std::weak_ptr ring_buffer_; + std::weak_ptr ring_buffer_; speaker::Speaker *output_speaker_{nullptr}; diff --git a/esphome/components/ring_buffer/__init__.py b/esphome/components/ring_buffer/__init__.py new file mode 100644 index 0000000000..b53476dcac --- /dev/null +++ b/esphome/components/ring_buffer/__init__.py @@ -0,0 +1,7 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["esp32"] + +ring_buffer_ns = cg.esphome_ns.namespace("ring_buffer") +RingBuffer = ring_buffer_ns.class_("RingBuffer") diff --git a/esphome/core/ring_buffer.cpp b/esphome/components/ring_buffer/ring_buffer.cpp similarity index 97% rename from esphome/core/ring_buffer.cpp rename to esphome/components/ring_buffer/ring_buffer.cpp index 486cf67f25..9604290cf0 100644 --- a/esphome/core/ring_buffer.cpp +++ b/esphome/components/ring_buffer/ring_buffer.cpp @@ -5,7 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { +namespace esphome::ring_buffer { static const char *const TAG = "ring_buffer"; @@ -135,6 +135,6 @@ bool RingBuffer::discard_bytes_(size_t discard_bytes) { return (bytes_read == discard_bytes); } -} // namespace esphome +} // namespace esphome::ring_buffer -#endif +#endif // USE_ESP32 diff --git a/esphome/components/ring_buffer/ring_buffer.h b/esphome/components/ring_buffer/ring_buffer.h new file mode 100644 index 0000000000..62094899d7 --- /dev/null +++ b/esphome/components/ring_buffer/ring_buffer.h @@ -0,0 +1,126 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include + +#include +#include + +namespace esphome::ring_buffer { + +class RingBuffer { + public: + ~RingBuffer(); + + /** + * @brief Reads from the ring buffer, waiting up to a specified number of ticks if necessary. + * + * Available bytes are read into the provided data pointer. If not enough bytes are available, + * the function will wait up to `ticks_to_wait` FreeRTOS ticks before reading what is available. + * + * @param data Pointer to copy read data into + * @param len Number of bytes to read + * @param ticks_to_wait Maximum number of FreeRTOS ticks to wait (default: 0) + * @return Number of bytes read + */ + size_t read(void *data, size_t len, TickType_t ticks_to_wait = 0); + + /** + * @brief Acquires a pointer into the ring buffer's internal storage without copying. + * + * The returned pointer is valid until receive_release() is called. Only one item + * may be checked out at a time. + * + * @param[out] length Set to the number of bytes actually acquired (may be less than max_length at wrap boundary) + * @param max_length Maximum number of bytes to acquire + * @param ticks_to_wait Maximum number of FreeRTOS ticks to wait (default: 0) + * @return Pointer into the ring buffer's internal storage, or nullptr if no data is available + */ + void *receive_acquire(size_t &length, size_t max_length, TickType_t ticks_to_wait = 0); + + /** + * @brief Releases a previously acquired ring buffer item. + * + * Must be called exactly once for each successful receive_acquire(). + * + * @param item Pointer returned by receive_acquire() + */ + void receive_release(void *item); + + /** + * @brief Writes to the ring buffer, overwriting oldest data if necessary. + * + * The provided data is written to the ring buffer. If not enough space is available, + * the function will overwrite the oldest data in the ring buffer. + * + * @param data Pointer to data for writing + * @param len Number of bytes to write + * @return Number of bytes written + */ + size_t write(const void *data, size_t len); + + /** + * @brief Writes to the ring buffer without overwriting oldest data. + * + * The provided data is written to the ring buffer. If not enough space is available, + * the function will wait up to `ticks_to_wait` FreeRTOS ticks before writing as much as possible. + * + * @param data Pointer to data for writing + * @param len Number of bytes to write + * @param ticks_to_wait Maximum number of FreeRTOS ticks to wait (default: 0) + * @return Number of bytes written + */ + size_t write_without_replacement(const void *data, size_t len, TickType_t ticks_to_wait = 0, + bool write_partial = true); + + /** + * @brief Returns the number of available bytes in the ring buffer. + * + * This function provides the number of bytes that can be read from the ring buffer + * without blocking the calling FreeRTOS task. + * + * @return Number of available bytes + */ + size_t available() const; + + /** + * @brief Returns the number of free bytes in the ring buffer. + * + * This function provides the number of bytes that can be written to the ring buffer + * without overwriting data or blocking the calling FreeRTOS task. + * + * @return Number of free bytes + */ + size_t free() const; + + /** + * @brief Resets the ring buffer, discarding all stored data. + * + * @return pdPASS if successful, pdFAIL otherwise + */ + BaseType_t reset(); + + enum class MemoryPreference { + EXTERNAL_FIRST, // External RAM preferred, fall back to internal (default) + INTERNAL_FIRST, // Internal RAM preferred, fall back to external + }; + + static std::unique_ptr create(size_t len, MemoryPreference preference = MemoryPreference::EXTERNAL_FIRST); + + protected: + /// @brief Discards data from the ring buffer. + /// @param discard_bytes amount of bytes to discard + /// @return True if all bytes were successfully discarded, false otherwise + bool discard_bytes_(size_t discard_bytes); + + RingbufHandle_t handle_{nullptr}; + StaticRingbuffer_t structure_; + uint8_t *storage_{nullptr}; + size_t size_{0}; +}; + +} // namespace esphome::ring_buffer + +#endif // USE_ESP32 diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index 99533dbdd5..fb8bfd3085 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -29,7 +29,7 @@ void SoundLevelComponent::dump_config() { void SoundLevelComponent::setup() { this->microphone_source_->add_data_callback([this](const std::vector &data) { - std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (this->ring_buffer_.use_count() == 2) { // ``audio_buffer_`` and ``temp_ring_buffer`` share ownership of a ring buffer, so its safe/useful to write temp_ring_buffer->write((void *) data.data(), data.size()); @@ -172,8 +172,8 @@ bool SoundLevelComponent::start_() { // Allocates a new ring buffer, adds it as a source for the transfer buffer, and points ring_buffer_ to it this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation - std::shared_ptr temp_ring_buffer = - RingBuffer::create(this->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( + this->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); if (temp_ring_buffer.use_count() == 0) { this->status_momentary_error("ring_buffer", 15000); this->stop_(); diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index 0e46a203e8..4f0081a510 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -4,11 +4,11 @@ #include "esphome/components/audio/audio_transfer_buffer.h" #include "esphome/components/microphone/microphone_source.h" +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/ring_buffer.h" namespace esphome::sound_level { @@ -49,7 +49,7 @@ class SoundLevelComponent : public Component { sensor::Sensor *rms_sensor_{nullptr}; std::unique_ptr audio_buffer_; - std::weak_ptr ring_buffer_; + std::weak_ptr ring_buffer_; int32_t squared_peak_{0}; uint64_t squared_samples_sum_{0}; diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 892d4f4112..010f0c50b3 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -315,10 +315,10 @@ void AudioPipeline::read_task(void *params) { if (err == ESP_OK) { size_t file_ring_buffer_size = this_pipeline->buffer_size_; - std::shared_ptr temp_ring_buffer; + std::shared_ptr temp_ring_buffer; if (!this_pipeline->raw_file_ring_buffer_.use_count()) { - temp_ring_buffer = RingBuffer::create(file_ring_buffer_size); + temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size); this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer; } @@ -502,7 +502,7 @@ void AudioPipeline::decode_task(void *params) { if (!started_playback && has_stream_info) { // Verify enough data is available before starting playback - std::shared_ptr temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock(); if (temp_ring_buffer != nullptr && temp_ring_buffer->available() >= initial_bytes_to_buffer) { started_playback = true; } diff --git a/esphome/components/speaker/media_player/audio_pipeline.h b/esphome/components/speaker/media_player/audio_pipeline.h index ef7f75dd38..89f4707ab3 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.h +++ b/esphome/components/speaker/media_player/audio_pipeline.h @@ -5,9 +5,9 @@ #include "esphome/components/audio/audio.h" #include "esphome/components/audio/audio_reader.h" #include "esphome/components/audio/audio_decoder.h" +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/speaker/speaker.h" -#include "esphome/core/ring_buffer.h" #include "esphome/core/static_task.h" #include "esp_err.h" @@ -129,7 +129,7 @@ class AudioPipeline { size_t buffer_size_; // Ring buffer between reader and decoder size_t transfer_buffer_size_; // Internal source/sink buffers for the audio reader and decoder - std::weak_ptr raw_file_ring_buffer_; + std::weak_ptr raw_file_ring_buffer_; // Handles basic control/state of the three tasks EventGroupHandle_t event_group_{nullptr}; diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d970df2a44..9387797ba2 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_SPEAKER, ) -AUTO_LOAD = ["socket"] +AUTO_LOAD = ["ring_buffer", "socket"] DEPENDENCIES = ["api", "microphone"] CODEOWNERS = ["@jesserockz", "@kahrendt"] diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index f2244a8ff4..bff0026b24 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -30,7 +30,7 @@ VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; } void VoiceAssistant::setup() { this->mic_source_->add_data_callback([this](const std::vector &data) { - std::shared_ptr temp_ring_buffer = this->ring_buffer_; + std::shared_ptr temp_ring_buffer = this->ring_buffer_; if (this->ring_buffer_.use_count() > 1) { temp_ring_buffer->write((void *) data.data(), data.size()); } @@ -116,7 +116,7 @@ bool VoiceAssistant::allocate_buffers_() { #endif if (this->ring_buffer_.use_count() == 0) { - this->ring_buffer_ = RingBuffer::create(RING_BUFFER_SIZE); + this->ring_buffer_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); if (this->ring_buffer_.use_count() == 0) { ESP_LOGE(TAG, "Could not allocate ring buffer"); return false; diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index eb23dcb5e0..faef09d8bd 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -7,9 +7,9 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" -#include "esphome/core/ring_buffer.h" #include "esphome/components/api/api_connection.h" +#include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/api/api_pb2.h" #include "esphome/components/microphone/microphone_source.h" #ifdef USE_MEDIA_PLAYER @@ -300,7 +300,7 @@ class VoiceAssistant : public Component { std::string wake_word_{""}; - std::shared_ptr ring_buffer_; + std::shared_ptr ring_buffer_; bool use_wake_word_; uint8_t noise_suppression_level_; diff --git a/esphome/core/config.py b/esphome/core/config.py index fe55c0fe25..5a98b94781 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -759,10 +759,6 @@ async def to_code(config: ConfigType) -> None: # Platform-specific source files for core FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "ring_buffer.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, "static_task.cpp": { PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, diff --git a/esphome/core/ring_buffer.h b/esphome/core/ring_buffer.h index 8ac3ff3811..fbb089e906 100644 --- a/esphome/core/ring_buffer.h +++ b/esphome/core/ring_buffer.h @@ -2,124 +2,20 @@ #ifdef USE_ESP32 -#include -#include - -#include -#include +// Deprecated: include "esphome/components/ring_buffer/ring_buffer.h" and use +// esphome::ring_buffer::RingBuffer. This shim will be removed in 2026.11.0. +#if __has_include("esphome/components/ring_buffer/ring_buffer.h") +#include "esphome/components/ring_buffer/ring_buffer.h" +#else +#error \ + "esphome/components/ring_buffer/ring_buffer.h not found. Add 'ring_buffer' to your component's AUTO_LOAD list to use esphome::ring_buffer::RingBuffer." +#endif +#include "esphome/core/helpers.h" // for ESPDEPRECATED namespace esphome { -class RingBuffer { - public: - ~RingBuffer(); - - /** - * @brief Reads from the ring buffer, waiting up to a specified number of ticks if necessary. - * - * Available bytes are read into the provided data pointer. If not enough bytes are available, - * the function will wait up to `ticks_to_wait` FreeRTOS ticks before reading what is available. - * - * @param data Pointer to copy read data into - * @param len Number of bytes to read - * @param ticks_to_wait Maximum number of FreeRTOS ticks to wait (default: 0) - * @return Number of bytes read - */ - size_t read(void *data, size_t len, TickType_t ticks_to_wait = 0); - - /** - * @brief Acquires a pointer into the ring buffer's internal storage without copying. - * - * The returned pointer is valid until receive_release() is called. Only one item - * may be checked out at a time. - * - * @param[out] length Set to the number of bytes actually acquired (may be less than max_length at wrap boundary) - * @param max_length Maximum number of bytes to acquire - * @param ticks_to_wait Maximum number of FreeRTOS ticks to wait (default: 0) - * @return Pointer into the ring buffer's internal storage, or nullptr if no data is available - */ - void *receive_acquire(size_t &length, size_t max_length, TickType_t ticks_to_wait = 0); - - /** - * @brief Releases a previously acquired ring buffer item. - * - * Must be called exactly once for each successful receive_acquire(). - * - * @param item Pointer returned by receive_acquire() - */ - void receive_release(void *item); - - /** - * @brief Writes to the ring buffer, overwriting oldest data if necessary. - * - * The provided data is written to the ring buffer. If not enough space is available, - * the function will overwrite the oldest data in the ring buffer. - * - * @param data Pointer to data for writing - * @param len Number of bytes to write - * @return Number of bytes written - */ - size_t write(const void *data, size_t len); - - /** - * @brief Writes to the ring buffer without overwriting oldest data. - * - * The provided data is written to the ring buffer. If not enough space is available, - * the function will wait up to `ticks_to_wait` FreeRTOS ticks before writing as much as possible. - * - * @param data Pointer to data for writing - * @param len Number of bytes to write - * @param ticks_to_wait Maximum number of FreeRTOS ticks to wait (default: 0) - * @return Number of bytes written - */ - size_t write_without_replacement(const void *data, size_t len, TickType_t ticks_to_wait = 0, - bool write_partial = true); - - /** - * @brief Returns the number of available bytes in the ring buffer. - * - * This function provides the number of bytes that can be read from the ring buffer - * without blocking the calling FreeRTOS task. - * - * @return Number of available bytes - */ - size_t available() const; - - /** - * @brief Returns the number of free bytes in the ring buffer. - * - * This function provides the number of bytes that can be written to the ring buffer - * without overwriting data or blocking the calling FreeRTOS task. - * - * @return Number of free bytes - */ - size_t free() const; - - /** - * @brief Resets the ring buffer, discarding all stored data. - * - * @return pdPASS if successful, pdFAIL otherwise - */ - BaseType_t reset(); - - enum class MemoryPreference { - EXTERNAL_FIRST, // External RAM preferred, fall back to internal (default) - INTERNAL_FIRST, // Internal RAM preferred, fall back to external - }; - - static std::unique_ptr create(size_t len, MemoryPreference preference = MemoryPreference::EXTERNAL_FIRST); - - protected: - /// @brief Discards data from the ring buffer. - /// @param discard_bytes amount of bytes to discard - /// @return True if all bytes were successfully discarded, false otherwise - bool discard_bytes_(size_t discard_bytes); - - RingbufHandle_t handle_{nullptr}; - StaticRingbuffer_t structure_; - uint8_t *storage_{nullptr}; - size_t size_{0}; -}; +using RingBuffer ESPDEPRECATED("Use esphome::ring_buffer::RingBuffer instead. Removed in 2026.11.0.", + "2026.5.0") = ring_buffer::RingBuffer; } // namespace esphome diff --git a/esphome/writer.py b/esphome/writer.py index 816c57a0bc..2fa43fa5eb 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -199,7 +199,15 @@ def copy_src_tree(): # Build #include list for esphome.h # X-macro files are included multiple times with different macro definitions # and must not be included bare in esphome.h - esphome_h_exclude = {Path(ENTITY_TYPES_H_TARGET)} + # Deprecated headers that re-export from a relocated component must not be + # auto-included, since their #include of the new path only resolves when the + # new component is loaded by a consumer. + esphome_h_exclude = { + Path(ENTITY_TYPES_H_TARGET), + Path( + "esphome/core/ring_buffer.h" + ), # moved to components/ring_buffer/, removed in 2026.11.0 + } include_l = [] for target, _ in source_files_l: if target.suffix in HEADER_FILE_EXTENSIONS and target not in esphome_h_exclude: From 696a65473329a0f92df5c636e6af28bd2813b2a2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 7 May 2026 23:05:17 -0400 Subject: [PATCH 1122/2214] [clang-tidy] Concatenate nested namespaces (7/7: tests, platform-gated, enable check) (#16307) --- .clang-tidy | 1 - .clang-tidy.hash | 2 +- esphome/components/adc/adc_sensor_esp8266.cpp | 6 ++---- esphome/components/adc/adc_sensor_libretiny.cpp | 6 ++---- esphome/components/adc/adc_sensor_rp2040.cpp | 6 ++---- esphome/components/adc/adc_sensor_zephyr.cpp | 6 ++---- .../components/beken_spi_led_strip/led_strip.cpp | 6 ++---- esphome/components/beken_spi_led_strip/led_strip.h | 6 ++---- .../bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp | 6 ++---- .../components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h | 6 ++---- esphome/components/debug/debug_esp8266.cpp | 6 ++---- esphome/components/debug/debug_host.cpp | 6 ++---- esphome/components/debug/debug_libretiny.cpp | 6 ++---- esphome/components/debug/debug_rp2040.cpp | 6 ++---- .../components/deep_sleep/deep_sleep_esp8266.cpp | 6 ++---- esphome/components/esp8266_pwm/esp8266_pwm.cpp | 6 ++---- esphome/components/esp8266_pwm/esp8266_pwm.h | 6 ++---- esphome/components/esp_ldo/esp_ldo.cpp | 6 ++---- esphome/components/esp_ldo/esp_ldo.h | 6 ++---- esphome/components/host/gpio.h | 6 ++---- esphome/components/libretiny_pwm/libretiny_pwm.cpp | 6 ++---- esphome/components/libretiny_pwm/libretiny_pwm.h | 6 ++---- esphome/components/light/light_traits.h | 13 ++----------- esphome/components/lightwaverf/LwRx.cpp | 6 ++---- esphome/components/lightwaverf/LwTx.cpp | 6 ++---- esphome/components/lightwaverf/lightwaverf.cpp | 6 ++---- esphome/components/lightwaverf/lightwaverf.h | 6 ++---- esphome/components/midea/ir_transmitter.h | 6 ++---- esphome/components/mipi_dsi/mipi_dsi.cpp | 6 ++---- esphome/components/mipi_dsi/mipi_dsi.h | 6 ++---- esphome/components/mipi_rgb/mipi_rgb.cpp | 6 ++---- esphome/components/mipi_rgb/mipi_rgb.h | 6 ++---- esphome/components/neopixelbus/neopixelbus_light.h | 6 ++---- esphome/components/qspi_dbi/qspi_dbi.cpp | 6 ++---- esphome/components/qspi_dbi/qspi_dbi.h | 6 ++---- esphome/components/rp2040/core.h | 4 +--- esphome/components/rp2040/gpio.h | 6 ++---- .../components/rp2040_pio_led_strip/led_strip.cpp | 6 ++---- esphome/components/rp2040_pio_led_strip/led_strip.h | 6 ++---- esphome/components/rp2040_pwm/rp2040_pwm.cpp | 6 ++---- esphome/components/rp2040_pwm/rp2040_pwm.h | 6 ++---- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 6 ++---- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 6 ++---- esphome/components/sdl/sdl_esphome.cpp | 6 ++---- esphome/components/sdl/sdl_esphome.h | 6 ++---- .../components/sdl/touchscreen/sdl_touchscreen.h | 6 ++---- esphome/components/shelly_dimmer/dev_table.h | 6 ++---- esphome/components/shelly_dimmer/shelly_dimmer.cpp | 6 ++---- esphome/components/shelly_dimmer/shelly_dimmer.h | 6 ++---- esphome/components/shelly_dimmer/stm32flash.cpp | 12 ++---------- esphome/components/shelly_dimmer/stm32flash.h | 6 ++---- esphome/components/st7701s/st7701s.cpp | 6 ++---- esphome/components/st7701s/st7701s.h | 6 ++---- .../crc8_test_component/crc8_test_component.cpp | 6 ++---- .../crc8_test_component/crc8_test_component.h | 6 ++---- .../custom_api_device_component.cpp | 6 ++---- .../custom_api_device_component.h | 6 ++---- .../defer_stress_component.cpp | 6 ++---- .../defer_stress_component/defer_stress_component.h | 6 ++---- .../loop_test_component/loop_test_component.cpp | 6 ++---- .../loop_test_component/loop_test_component.h | 6 ++---- .../loop_test_component/loop_test_isr_component.cpp | 6 ++---- .../loop_test_component/loop_test_isr_component.h | 6 ++---- .../scheduler_bulk_cleanup_component.cpp | 6 ++---- .../scheduler_bulk_cleanup_component.h | 6 ++---- .../heap_scheduler_stress_component.cpp | 6 ++---- .../heap_scheduler_stress_component.h | 6 ++---- .../rapid_cancellation_component.cpp | 6 ++---- .../rapid_cancellation_component.h | 6 ++---- .../recursive_timeout_component.cpp | 6 ++---- .../recursive_timeout_component.h | 6 ++---- .../simultaneous_callbacks_component.cpp | 6 ++---- .../simultaneous_callbacks_component.h | 6 ++---- .../string_lifetime_component.cpp | 6 ++---- .../string_lifetime_component.h | 6 ++---- .../string_name_stress_component.cpp | 6 ++---- .../string_name_stress_component.h | 6 ++---- 77 files changed, 150 insertions(+), 314 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index 4e128a1945..9aeeb1fc26 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -96,7 +96,6 @@ Checks: >- -modernize-avoid-variadic-functions, -modernize-avoid-c-arrays, -modernize-avoid-c-style-cast, - -modernize-concat-nested-namespaces, -modernize-macro-to-enum, -modernize-return-braced-init-list, -modernize-type-traits, diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 582e0c1eaa..2f973e34d9 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0c7f309d70eca8e3efd510092ddb23c530f3934c49371717efa124b788d761f8 +c1e4375738304baabf7e915e5f7ca50fb22ec9b4a2d8312827a32a659f3fa40c diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index be14b252d4..e4f2f82f08 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -11,8 +11,7 @@ ADC_MODE(ADC_VCC) #include #endif // USE_ADC_SENSOR_VCC -namespace esphome { -namespace adc { +namespace esphome::adc { static const char *const TAG = "adc.esp8266"; @@ -55,7 +54,6 @@ float ADCSensor::sample() { return aggr.aggregate() / 1024.0f; } -} // namespace adc -} // namespace esphome +} // namespace esphome::adc #endif // USE_ESP8266 diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index 0b1393c2e7..d9b9f50be1 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -3,8 +3,7 @@ #include "adc_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace adc { +namespace esphome::adc { static const char *const TAG = "adc.libretiny"; @@ -48,7 +47,6 @@ float ADCSensor::sample() { return aggr.aggregate() / 1000.0f; } -} // namespace adc -} // namespace esphome +} // namespace esphome::adc #endif // USE_LIBRETINY diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index a79707e234..8d41edb814 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -15,8 +15,7 @@ #define PICO_VSYS_PIN 29 // NOLINT(cppcoreguidelines-macro-usage) #endif -namespace esphome { -namespace adc { +namespace esphome::adc { static const char *const TAG = "adc.rp2040"; @@ -98,7 +97,6 @@ float ADCSensor::sample() { return aggr.aggregate() * 3.3f / 4096.0f * coeff; } -} // namespace adc -} // namespace esphome +} // namespace esphome::adc #endif // USE_RP2040 diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp index 2fb9d4b0e5..c3632b00e2 100644 --- a/esphome/components/adc/adc_sensor_zephyr.cpp +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -5,8 +5,7 @@ #include "hal/nrf_saadc.h" -namespace esphome { -namespace adc { +namespace esphome::adc { static const char *const TAG = "adc.zephyr"; @@ -202,6 +201,5 @@ float ADCSensor::sample() { return val_mv / 1000.0f; } -} // namespace adc -} // namespace esphome +} // namespace esphome::adc #endif diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index f425f3ca5c..4e22489844 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -33,8 +33,7 @@ static const uint32_t CTRL_NSSMD_3 = 1 << 17; static const uint32_t SPI_TX_FINISH_EN = 1 << 2; static const uint32_t SPI_RX_FINISH_EN = 1 << 3; -namespace esphome { -namespace beken_spi_led_strip { +namespace esphome::beken_spi_led_strip { static const char *const TAG = "beken_spi_led_strip"; @@ -382,7 +381,6 @@ void BekenSPILEDStripLightOutput::dump_config() { float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } -} // namespace beken_spi_led_strip -} // namespace esphome +} // namespace esphome::beken_spi_led_strip #endif // USE_BK72XX diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 705f9102a9..4ed640a3bc 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -8,8 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace beken_spi_led_strip { +namespace esphome::beken_spi_led_strip { enum RGBOrder : uint8_t { ORDER_RGB, @@ -79,7 +78,6 @@ class BekenSPILEDStripLightOutput : public light::AddressableLight { optional max_refresh_rate_{}; }; -} // namespace beken_spi_led_strip -} // namespace esphome +} // namespace esphome::beken_spi_led_strip #endif // USE_BK72XX diff --git a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp index 2d74ba6b12..2a2c5e2bcc 100644 --- a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp +++ b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace bme68x_bsec2_i2c { +namespace esphome::bme68x_bsec2_i2c { static const char *const TAG = "bme68x_bsec2_i2c.sensor"; @@ -53,6 +52,5 @@ void BME68xBSEC2I2CComponent::delay_us(uint32_t period, void *intfPtr) { delayMicroseconds(period); } -} // namespace bme68x_bsec2_i2c -} // namespace esphome +} // namespace esphome::bme68x_bsec2_i2c #endif diff --git a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h index a21a123f7b..6d20b61390 100644 --- a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h +++ b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h @@ -9,8 +9,7 @@ #include "esphome/components/bme68x_bsec2/bme68x_bsec2.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace bme68x_bsec2_i2c { +namespace esphome::bme68x_bsec2_i2c { class BME68xBSEC2I2CComponent : public bme68x_bsec2::BME68xBSEC2Component, public i2c::I2CDevice { void setup() override; @@ -23,6 +22,5 @@ class BME68xBSEC2I2CComponent : public bme68x_bsec2::BME68xBSEC2Component, publi static void delay_us(uint32_t period, void *intfPtr); }; -} // namespace bme68x_bsec2_i2c -} // namespace esphome +} // namespace esphome::bme68x_bsec2_i2c #endif diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index 0519ab72fe..272123dfc0 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -15,8 +15,7 @@ extern uint32_t core_version; extern const char *core_release; } -namespace esphome { -namespace debug { +namespace esphome::debug { static const char *const TAG = "debug"; @@ -170,6 +169,5 @@ void DebugComponent::update_platform_() { #endif } -} // namespace debug -} // namespace esphome +} // namespace esphome::debug #endif diff --git a/esphome/components/debug/debug_host.cpp b/esphome/components/debug/debug_host.cpp index 0dfab86e4c..298a7e44e7 100644 --- a/esphome/components/debug/debug_host.cpp +++ b/esphome/components/debug/debug_host.cpp @@ -2,8 +2,7 @@ #ifdef USE_HOST #include -namespace esphome { -namespace debug { +namespace esphome::debug { const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } @@ -15,6 +14,5 @@ size_t DebugComponent::get_device_info_(std::span void DebugComponent::update_platform_() {} -} // namespace debug -} // namespace esphome +} // namespace esphome::debug #endif diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 6f36debb95..1cc04dcbd8 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -2,8 +2,7 @@ #ifdef USE_LIBRETINY #include "esphome/core/log.h" -namespace esphome { -namespace debug { +namespace esphome::debug { static const char *const TAG = "debug"; @@ -62,6 +61,5 @@ void DebugComponent::update_platform_() { #endif } -} // namespace debug -} // namespace esphome +} // namespace esphome::debug #endif diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2040.cpp index 73f08492c8..adc23dbf51 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2040.cpp @@ -12,8 +12,7 @@ #ifdef USE_RP2040_CRASH_HANDLER #include "esphome/components/rp2040/crash_handler.h" #endif -namespace esphome { -namespace debug { +namespace esphome::debug { static const char *const TAG = "debug"; @@ -84,6 +83,5 @@ size_t DebugComponent::get_device_info_(std::span void DebugComponent::update_platform_() {} -} // namespace debug -} // namespace esphome +} // namespace esphome::debug #endif diff --git a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index 42c153c2f3..9239a7fb31 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -3,8 +3,7 @@ #include -namespace esphome { -namespace deep_sleep { +namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; @@ -20,6 +19,5 @@ void DeepSleepComponent::deep_sleep_() { bool DeepSleepComponent::should_teardown_() { return true; } -} // namespace deep_sleep -} // namespace esphome +} // namespace esphome::deep_sleep #endif diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.cpp b/esphome/components/esp8266_pwm/esp8266_pwm.cpp index cc6bfbc8a8..b5b3d5073a 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.cpp +++ b/esphome/components/esp8266_pwm/esp8266_pwm.cpp @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace esp8266_pwm { +namespace esphome::esp8266_pwm { static const char *const TAG = "esp8266_pwm"; @@ -53,7 +52,6 @@ void HOT ESP8266PWM::write_state(float state) { } } -} // namespace esp8266_pwm -} // namespace esphome +} // namespace esphome::esp8266_pwm #endif diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 4b021fc462..51c4ea1602 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -7,8 +7,7 @@ #include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace esp8266_pwm { +namespace esphome::esp8266_pwm { class ESP8266PWM : public output::FloatOutput, public Component { public: @@ -48,7 +47,6 @@ template class SetFrequencyAction : public Action { ESP8266PWM *parent_; }; -} // namespace esp8266_pwm -} // namespace esphome +} // namespace esphome::esp8266_pwm #endif diff --git a/esphome/components/esp_ldo/esp_ldo.cpp b/esphome/components/esp_ldo/esp_ldo.cpp index f8ebec1903..b3c9a59865 100644 --- a/esphome/components/esp_ldo/esp_ldo.cpp +++ b/esphome/components/esp_ldo/esp_ldo.cpp @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace esp_ldo { +namespace esphome::esp_ldo { static const char *const TAG = "esp_ldo"; void EspLdo::setup() { @@ -41,7 +40,6 @@ void EspLdo::adjust_voltage(float voltage) { } } -} // namespace esp_ldo -} // namespace esphome +} // namespace esphome::esp_ldo #endif // USE_ESP32_VARIANT_ESP32P4 diff --git a/esphome/components/esp_ldo/esp_ldo.h b/esphome/components/esp_ldo/esp_ldo.h index 1a20f1d08a..bb1579e83d 100644 --- a/esphome/components/esp_ldo/esp_ldo.h +++ b/esphome/components/esp_ldo/esp_ldo.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esp_ldo_regulator.h" -namespace esphome { -namespace esp_ldo { +namespace esphome::esp_ldo { class EspLdo : public Component { public: @@ -40,7 +39,6 @@ template class AdjustAction : public Action { EspLdo *ldo_; }; -} // namespace esp_ldo -} // namespace esphome +} // namespace esphome::esp_ldo #endif // USE_ESP32_VARIANT_ESP32P4 diff --git a/esphome/components/host/gpio.h b/esphome/components/host/gpio.h index ea6b13f436..6f2bccf102 100644 --- a/esphome/components/host/gpio.h +++ b/esphome/components/host/gpio.h @@ -4,8 +4,7 @@ #include "esphome/core/hal.h" -namespace esphome { -namespace host { +namespace esphome::host { class HostGPIOPin : public InternalGPIOPin { public: @@ -32,7 +31,6 @@ class HostGPIOPin : public InternalGPIOPin { gpio::Flags flags_{}; }; -} // namespace host -} // namespace esphome +} // namespace esphome::host #endif // USE_HOST diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.cpp b/esphome/components/libretiny_pwm/libretiny_pwm.cpp index 4e4a16d761..eea593a39d 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.cpp +++ b/esphome/components/libretiny_pwm/libretiny_pwm.cpp @@ -3,8 +3,7 @@ #ifdef USE_LIBRETINY -namespace esphome { -namespace libretiny_pwm { +namespace esphome::libretiny_pwm { static const char *const TAG = "libretiny.pwm"; @@ -49,7 +48,6 @@ void LibreTinyPWM::update_frequency(float frequency) { this->write_state(this->duty_); } -} // namespace libretiny_pwm -} // namespace esphome +} // namespace esphome::libretiny_pwm #endif diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.h b/esphome/components/libretiny_pwm/libretiny_pwm.h index f911709054..f7737be386 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.h +++ b/esphome/components/libretiny_pwm/libretiny_pwm.h @@ -7,8 +7,7 @@ #ifdef USE_LIBRETINY -namespace esphome { -namespace libretiny_pwm { +namespace esphome::libretiny_pwm { class LibreTinyPWM : public output::FloatOutput, public Component { public: @@ -49,7 +48,6 @@ template class SetFrequencyAction : public Action { LibreTinyPWM *parent_; }; -} // namespace libretiny_pwm -} // namespace esphome +} // namespace esphome::libretiny_pwm #endif diff --git a/esphome/components/light/light_traits.h b/esphome/components/light/light_traits.h index c3bb27a964..a2a6b0a916 100644 --- a/esphome/components/light/light_traits.h +++ b/esphome/components/light/light_traits.h @@ -3,15 +3,7 @@ #include "color_mode.h" #include "esphome/core/helpers.h" -namespace esphome { - -#ifdef USE_API -namespace api { -class APIConnection; -} // namespace api -#endif - -namespace light { +namespace esphome::light { /// This class is used to represent the capabilities of a light. class LightTraits { @@ -43,5 +35,4 @@ class LightTraits { ColorModeMask supported_color_modes_{}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/lightwaverf/LwRx.cpp b/esphome/components/lightwaverf/LwRx.cpp index 9710457850..dfb8edfaf7 100644 --- a/esphome/components/lightwaverf/LwRx.cpp +++ b/esphome/components/lightwaverf/LwRx.cpp @@ -10,8 +10,7 @@ #include #include "esphome/core/helpers.h" -namespace esphome { -namespace lightwaverf { +namespace esphome::lightwaverf { /** Pin change interrupt routine that identifies 1 and 0 LightwaveRF bits @@ -430,6 +429,5 @@ void LwRx::rx_remove_pair_(uint8_t *buf) { } } -} // namespace lightwaverf -} // namespace esphome +} // namespace esphome::lightwaverf #endif diff --git a/esphome/components/lightwaverf/LwTx.cpp b/esphome/components/lightwaverf/LwTx.cpp index 8852935bfd..339980d6ee 100644 --- a/esphome/components/lightwaverf/LwTx.cpp +++ b/esphome/components/lightwaverf/LwTx.cpp @@ -11,8 +11,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace lightwaverf { +namespace esphome::lightwaverf { static const uint8_t TX_NIBBLE[] = {0xF6, 0xEE, 0xED, 0xEB, 0xDE, 0xDD, 0xDB, 0xBE, 0xBD, 0xBB, 0xB7, 0x7E, 0x7D, 0x7B, 0x77, 0x6F}; @@ -208,6 +207,5 @@ void LwTx::lw_timer_stop() { } } -} // namespace lightwaverf -} // namespace esphome +} // namespace esphome::lightwaverf #endif diff --git a/esphome/components/lightwaverf/lightwaverf.cpp b/esphome/components/lightwaverf/lightwaverf.cpp index 2c6a1ecf5b..b8b6a9697e 100644 --- a/esphome/components/lightwaverf/lightwaverf.cpp +++ b/esphome/components/lightwaverf/lightwaverf.cpp @@ -5,8 +5,7 @@ #include "lightwaverf.h" -namespace esphome { -namespace lightwaverf { +namespace esphome::lightwaverf { static const char *const TAG = "lightwaverf.sensor"; @@ -62,7 +61,6 @@ void LightWaveRF::dump_config() { LOG_PIN(" Pin RX: ", this->pin_rx_); LOG_UPDATE_INTERVAL(this); } -} // namespace lightwaverf -} // namespace esphome +} // namespace esphome::lightwaverf #endif diff --git a/esphome/components/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index 6210e6b5d4..224da6315f 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -11,8 +11,7 @@ #include "LwRx.h" #include "LwTx.h" -namespace esphome { -namespace lightwaverf { +namespace esphome::lightwaverf { #ifdef USE_ESP8266 @@ -61,6 +60,5 @@ template class SendRawAction : public Action { }; #endif -} // namespace lightwaverf -} // namespace esphome +} // namespace esphome::lightwaverf #endif diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index a16aed2e72..f11682230d 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -4,8 +4,7 @@ #ifdef USE_REMOTE_TRANSMITTER #include "esphome/components/remote_base/midea_protocol.h" -namespace esphome { -namespace midea { +namespace esphome::midea { using remote_base::RemoteTransmitterBase; using IrData = remote_base::MideaData; @@ -84,8 +83,7 @@ class IrTransmitter { RemoteTransmitterBase *transmitter_{nullptr}; }; -} // namespace midea -} // namespace esphome +} // namespace esphome::midea #endif #endif // USE_ARDUINO diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index fc59aeffe8..9bd2dded2c 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -3,8 +3,7 @@ #include "mipi_dsi.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace mipi_dsi { +namespace esphome::mipi_dsi { // Maximum bytes to log for init commands (truncated if larger) static constexpr size_t MIPI_DSI_MAX_CMD_LOG_BYTES = 64; @@ -411,6 +410,5 @@ void MIPI_DSI::dump_config() { YESNO(this->invert_colors_), this->pclk_frequency_); LOG_PIN(" Reset Pin ", this->reset_pin_); } -} // namespace mipi_dsi -} // namespace esphome +} // namespace esphome::mipi_dsi #endif // USE_ESP32_VARIANT_ESP32P4 diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index c27c9ccc6e..82827d813e 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -16,8 +16,7 @@ #include "esp_lcd_mipi_dsi.h" -namespace esphome { -namespace mipi_dsi { +namespace esphome::mipi_dsi { constexpr static const char *const TAG = "display.mipi_dsi"; const uint8_t SW_RESET_CMD = 0x01; @@ -113,6 +112,5 @@ class MIPI_DSI : public display::Display { uint16_t y_high_{0}; }; -} // namespace mipi_dsi -} // namespace esphome +} // namespace esphome::mipi_dsi #endif diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 6f5e2f2490..b07460fdba 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace mipi_rgb { +namespace esphome::mipi_rgb { static const uint8_t DELAY_FLAG = 0xFF; @@ -400,6 +399,5 @@ void MipiRgb::dump_config() { this->dump_pins_(3, 8, "Red", 0); } -} // namespace mipi_rgb -} // namespace esphome +} // namespace esphome::mipi_rgb #endif // defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index accc251a18..4d1d836099 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -8,8 +8,7 @@ #include "esphome/components/spi/spi.h" #endif -namespace esphome { -namespace mipi_rgb { +namespace esphome::mipi_rgb { constexpr static const char *const TAG = "display.mipi_rgb"; const uint8_t SW_RESET_CMD = 0x01; @@ -120,6 +119,5 @@ class MipiRgbSpi : public MipiRgb, }; #endif -} // namespace mipi_rgb -} // namespace esphome +} // namespace esphome::mipi_rgb #endif diff --git a/esphome/components/neopixelbus/neopixelbus_light.h b/esphome/components/neopixelbus/neopixelbus_light.h index c27244b94d..1cbe889acf 100644 --- a/esphome/components/neopixelbus/neopixelbus_light.h +++ b/esphome/components/neopixelbus/neopixelbus_light.h @@ -11,8 +11,7 @@ #include "NeoPixelBus.h" -namespace esphome { -namespace neopixelbus { +namespace esphome::neopixelbus { enum class ESPNeoPixelOrder { GBWR = 0b11000110, @@ -140,7 +139,6 @@ class NeoPixelRGBWLightOutput : public NeoPixelBusLightOutputBasereset_pin_); } -} // namespace qspi_dbi -} // namespace esphome +} // namespace esphome::qspi_dbi #endif diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index 3eee9bec47..fa77cc5f76 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -11,8 +11,7 @@ #include "esp_lcd_panel_rgb.h" -namespace esphome { -namespace qspi_dbi { +namespace esphome::qspi_dbi { constexpr static const char *const TAG = "display.qspi_dbi"; static const uint8_t SW_RESET_CMD = 0x01; @@ -168,6 +167,5 @@ class QspiDbi : public display::DisplayBuffer, esp_lcd_panel_handle_t handle_{}; }; -} // namespace qspi_dbi -} // namespace esphome +} // namespace esphome::qspi_dbi #endif diff --git a/esphome/components/rp2040/core.h b/esphome/components/rp2040/core.h index 92fc4f824e..db8937a8a3 100644 --- a/esphome/components/rp2040/core.h +++ b/esphome/components/rp2040/core.h @@ -7,8 +7,6 @@ extern "C" unsigned long ulMainGetRunTimeCounterValue(); -namespace esphome { -namespace rp2040 {} // namespace rp2040 -} // namespace esphome +namespace esphome::rp2040 {} // namespace esphome::rp2040 #endif // USE_RP2040 diff --git a/esphome/components/rp2040/gpio.h b/esphome/components/rp2040/gpio.h index a98e1dab14..da97cff9b1 100644 --- a/esphome/components/rp2040/gpio.h +++ b/esphome/components/rp2040/gpio.h @@ -5,8 +5,7 @@ #include #include "esphome/core/hal.h" -namespace esphome { -namespace rp2040 { +namespace esphome::rp2040 { class RP2040GPIOPin : public InternalGPIOPin { public: @@ -33,7 +32,6 @@ class RP2040GPIOPin : public InternalGPIOPin { gpio::Flags flags_{}; }; -} // namespace rp2040 -} // namespace esphome +} // namespace esphome::rp2040 #endif // USE_RP2040 diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index fdb49fb3ef..8afba6ba1d 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -12,8 +12,7 @@ #include #include -namespace esphome { -namespace rp2040_pio_led_strip { +namespace esphome::rp2040_pio_led_strip { static const char *TAG = "rp2040_pio_led_strip"; @@ -210,7 +209,6 @@ void RP2040PIOLEDStripLightOutput::dump_config() { float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } -} // namespace rp2040_pio_led_strip -} // namespace esphome +} // namespace esphome::rp2040_pio_led_strip #endif diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index 7b62648974..ebc3bbbaa5 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -16,8 +16,7 @@ #include #include -namespace esphome { -namespace rp2040_pio_led_strip { +namespace esphome::rp2040_pio_led_strip { enum RGBOrder : uint8_t { ORDER_RGB, @@ -127,7 +126,6 @@ class RP2040PIOLEDStripLightOutput : public light::AddressableLight { inline static struct semaphore dma_write_complete_sem_[12]; }; -} // namespace rp2040_pio_led_strip -} // namespace esphome +} // namespace esphome::rp2040_pio_led_strip #endif // USE_RP2040 diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.cpp b/esphome/components/rp2040_pwm/rp2040_pwm.cpp index 90a507b14f..c9b9e6739d 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.cpp +++ b/esphome/components/rp2040_pwm/rp2040_pwm.cpp @@ -11,8 +11,7 @@ #include #include -namespace esphome { -namespace rp2040_pwm { +namespace esphome::rp2040_pwm { static const char *const TAG = "rp2040_pwm"; @@ -60,7 +59,6 @@ void HOT RP2040PWM::write_state(float state) { pwm_set_gpio_level(this->pin_->get_pin(), state * this->wrap_); } -} // namespace rp2040_pwm -} // namespace esphome +} // namespace esphome::rp2040_pwm #endif diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index b82765b1c0..58d3955a31 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -7,8 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -namespace esphome { -namespace rp2040_pwm { +namespace esphome::rp2040_pwm { class RP2040PWM : public output::FloatOutput, public Component { public: @@ -53,7 +52,6 @@ template class SetFrequencyAction : public Action { RP2040PWM *parent_; }; -} // namespace rp2040_pwm -} // namespace esphome +} // namespace esphome::rp2040_pwm #endif // USE_RP2040 diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index d29f6a0bcb..00530c3f96 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace rpi_dpi_rgb { +namespace esphome::rpi_dpi_rgb { void RpiDpiRgb::setup() { this->reset_display_(); @@ -160,7 +159,6 @@ void RpiDpiRgb::reset_display_() const { } } -} // namespace rpi_dpi_rgb -} // namespace esphome +} // namespace esphome::rpi_dpi_rgb #endif // USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index 7525040cd1..8b1457926c 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -14,8 +14,7 @@ #include "esp_lcd_panel_rgb.h" -namespace esphome { -namespace rpi_dpi_rgb { +namespace esphome::rpi_dpi_rgb { constexpr static const char *const TAG = "rpi_dpi_rgb"; @@ -92,6 +91,5 @@ class RpiDpiRgb : public display::Display { esp_lcd_panel_handle_t handle_{}; }; -} // namespace rpi_dpi_rgb -} // namespace esphome +} // namespace esphome::rpi_dpi_rgb #endif // USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index 74ca2ce39a..c99b5081b3 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -2,8 +2,7 @@ #include "sdl_esphome.h" #include "esphome/components/display/display_color_utils.h" -namespace esphome { -namespace sdl { +namespace esphome::sdl { int Sdl::get_width() { switch (this->rotation_) { @@ -162,6 +161,5 @@ void Sdl::loop() { } } -} // namespace sdl -} // namespace esphome +} // namespace esphome::sdl #endif diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index ce34cb817e..3f54b70560 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -9,8 +9,7 @@ #include "SDL.h" #include -namespace esphome { -namespace sdl { +namespace esphome::sdl { constexpr static const char *const TAG = "sdl"; @@ -66,7 +65,6 @@ class Sdl : public display::Display { uint16_t y_high_{0}; std::map> key_callbacks_{}; }; -} // namespace sdl -} // namespace esphome +} // namespace esphome::sdl #endif diff --git a/esphome/components/sdl/touchscreen/sdl_touchscreen.h b/esphome/components/sdl/touchscreen/sdl_touchscreen.h index a1f0fb15e3..cf2fd65088 100644 --- a/esphome/components/sdl/touchscreen/sdl_touchscreen.h +++ b/esphome/components/sdl/touchscreen/sdl_touchscreen.h @@ -4,8 +4,7 @@ #include "../sdl_esphome.h" #include "esphome/components/touchscreen/touchscreen.h" -namespace esphome { -namespace sdl { +namespace esphome::sdl { class SdlTouchscreen : public touchscreen::Touchscreen, public Parented { public: @@ -21,6 +20,5 @@ class SdlTouchscreen : public touchscreen::Touchscreen, public Parented { } }; -} // namespace sdl -} // namespace esphome +} // namespace esphome::sdl #endif diff --git a/esphome/components/shelly_dimmer/dev_table.h b/esphome/components/shelly_dimmer/dev_table.h index e73cd1271c..32b4810d7a 100644 --- a/esphome/components/shelly_dimmer/dev_table.h +++ b/esphome/components/shelly_dimmer/dev_table.h @@ -23,8 +23,7 @@ #ifdef USE_SHD_FIRMWARE_DATA #include "stm32flash.h" -namespace esphome { -namespace shelly_dimmer { +namespace esphome::shelly_dimmer { constexpr uint32_t SZ_128 = 0x00000080; constexpr uint32_t SZ_256 = 0x00000100; @@ -153,7 +152,6 @@ constexpr stm32_dev_t DEVICES[] = { {0x0, "", 0x0, 0x0, 0x0, 0x0, 0x0, nullptr, 0x0, 0x0, 0x0, 0x0, 0x0}, }; -} // namespace shelly_dimmer -} // namespace esphome +} // namespace esphome::shelly_dimmer #endif // USE_SHD_FIRMWARE_DATA diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index 230fb963b1..b0f43f0ffc 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -56,8 +56,7 @@ template constexpr size_t size(const T (&/*unused*/)[N]) n } // Anonymous namespace -namespace esphome { -namespace shelly_dimmer { +namespace esphome::shelly_dimmer { /// Computes a crappy checksum as defined by the Shelly Dimmer protocol. uint16_t shelly_dimmer_checksum(const uint8_t *buf, int len) { @@ -522,7 +521,6 @@ void ShellyDimmer::reset_dfu_boot_() { this->reset_(true); } -} // namespace shelly_dimmer -} // namespace esphome +} // namespace esphome::shelly_dimmer #endif // USE_ESP8266 diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.h b/esphome/components/shelly_dimmer/shelly_dimmer.h index fd75caa797..c6d0e20afe 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.h +++ b/esphome/components/shelly_dimmer/shelly_dimmer.h @@ -10,8 +10,7 @@ #include -namespace esphome { -namespace shelly_dimmer { +namespace esphome::shelly_dimmer { class ShellyDimmer : public PollingComponent, public light::LightOutput, public uart::UARTDevice { private: @@ -117,7 +116,6 @@ class ShellyDimmer : public PollingComponent, public light::LightOutput, public void reset_dfu_boot_(); }; -} // namespace shelly_dimmer -} // namespace esphome +} // namespace esphome::shelly_dimmer #endif // USE_ESP8266 diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index a1a933bcab..c758b0a312 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -112,8 +112,7 @@ constexpr char TAG[] = "stm32flash"; } // Anonymous namespace -namespace esphome { -namespace shelly_dimmer { +namespace esphome::shelly_dimmer { namespace { @@ -487,12 +486,6 @@ template stm32_unique_ptr make_stm32_with_deletor(T ptr) { } // Anonymous namespace -} // namespace shelly_dimmer -} // namespace esphome - -namespace esphome { -namespace shelly_dimmer { - /* find newer command by higher code */ #define newer(prev, a) (((prev) == STM32_CMD_ERR) ? (a) : (((prev) > (a)) ? (prev) : (a))) @@ -1059,7 +1052,6 @@ stm32_err_t stm32_crc_wrapper(const stm32_unique_ptr &stm, uint32_t address, uin return STM32_ERR_OK; } -} // namespace shelly_dimmer -} // namespace esphome +} // namespace esphome::shelly_dimmer #endif // USE_SHD_FIRMWARE_DATA diff --git a/esphome/components/shelly_dimmer/stm32flash.h b/esphome/components/shelly_dimmer/stm32flash.h index d973b35222..9a81f07373 100644 --- a/esphome/components/shelly_dimmer/stm32flash.h +++ b/esphome/components/shelly_dimmer/stm32flash.h @@ -26,8 +26,7 @@ #include #include "esphome/components/uart/uart.h" -namespace esphome { -namespace shelly_dimmer { +namespace esphome::shelly_dimmer { /* flags */ constexpr auto STREAM_OPT_BYTE = (1 << 0); /* byte (not frame) oriented */ @@ -125,7 +124,6 @@ stm32_err_t stm32_crc_memory(const stm32_unique_ptr &stm, uint32_t address, uint stm32_err_t stm32_crc_wrapper(const stm32_unique_ptr &stm, uint32_t address, uint32_t length, uint32_t *crc); uint32_t stm32_sw_crc(uint32_t crc, uint8_t *buf, unsigned int len); -} // namespace shelly_dimmer -} // namespace esphome +} // namespace esphome::shelly_dimmer #endif // USE_SHD_FIRMWARE_DATA diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 701b6dd79e..dac8ac9dbc 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace st7701s { +namespace esphome::st7701s { void ST7701S::setup() { this->spi_setup(); @@ -195,6 +194,5 @@ void ST7701S::dump_config() { ESP_LOGCONFIG(TAG, " SPI Data rate: %dMHz", (unsigned) (this->data_rate_ / 1000000)); } -} // namespace st7701s -} // namespace esphome +} // namespace esphome::st7701s #endif // USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index a1e3c2e54a..de5e4c13d4 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -12,8 +12,7 @@ #include "esp_lcd_panel_rgb.h" -namespace esphome { -namespace st7701s { +namespace esphome::st7701s { constexpr static const char *const TAG = "display.st7701s"; const uint8_t SW_RESET_CMD = 0x01; @@ -113,6 +112,5 @@ class ST7701S : public display::Display, esp_lcd_panel_handle_t handle_{}; }; -} // namespace st7701s -} // namespace esphome +} // namespace esphome::st7701s #endif diff --git a/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.cpp b/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.cpp index 6c46af19fd..0c0201508a 100644 --- a/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.cpp +++ b/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace crc8_test_component { +namespace esphome::crc8_test_component { static const char *const TAG = "crc8_test"; @@ -166,5 +165,4 @@ void CRC8TestComponent::log_test_result(const char *test_name, bool passed) { } } -} // namespace crc8_test_component -} // namespace esphome +} // namespace esphome::crc8_test_component diff --git a/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.h b/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.h index 3b8847259c..5d21ab134e 100644 --- a/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.h +++ b/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.h @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace crc8_test_component { +namespace esphome::crc8_test_component { class CRC8TestComponent : public Component { public: @@ -25,5 +24,4 @@ class CRC8TestComponent : public Component { uint8_t poly = 0x8C, bool msb_first = false); }; -} // namespace crc8_test_component -} // namespace esphome +} // namespace esphome::crc8_test_component diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp index c86ab99242..b66372d8ba 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #ifdef USE_API -namespace esphome { -namespace custom_api_device_component { +namespace esphome::custom_api_device_component { static const char *const TAG = "custom_api"; @@ -58,6 +57,5 @@ void CustomAPIDeviceComponent::on_ha_state_changed(std::string entity_id, std::s ESP_LOGI(TAG, "This subscription uses std::string API for backward compatibility"); } -} // namespace custom_api_device_component -} // namespace esphome +} // namespace esphome::custom_api_device_component #endif // USE_API diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h index 4d519d3ed1..e75b340122 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h @@ -6,8 +6,7 @@ #include "esphome/components/api/custom_api_device.h" #ifdef USE_API -namespace esphome { -namespace custom_api_device_component { +namespace esphome::custom_api_device_component { using namespace api; @@ -28,6 +27,5 @@ class CustomAPIDeviceComponent : public Component, public CustomAPIDevice { void on_ha_state_changed(std::string entity_id, std::string state); }; -} // namespace custom_api_device_component -} // namespace esphome +} // namespace esphome::custom_api_device_component #endif // USE_API diff --git a/tests/integration/fixtures/external_components/defer_stress_component/defer_stress_component.cpp b/tests/integration/fixtures/external_components/defer_stress_component/defer_stress_component.cpp index 21ca45947e..5c3bbeea84 100644 --- a/tests/integration/fixtures/external_components/defer_stress_component/defer_stress_component.cpp +++ b/tests/integration/fixtures/external_components/defer_stress_component/defer_stress_component.cpp @@ -5,8 +5,7 @@ #include #include -namespace esphome { -namespace defer_stress_component { +namespace esphome::defer_stress_component { static const char *const TAG = "defer_stress"; @@ -71,5 +70,4 @@ void DeferStressComponent::run_multi_thread_test() { ESP_LOGI(TAG, "All threads finished in %lldms. Created %d defer requests", thread_time, this->total_defers_.load()); } -} // namespace defer_stress_component -} // namespace esphome +} // namespace esphome::defer_stress_component diff --git a/tests/integration/fixtures/external_components/defer_stress_component/defer_stress_component.h b/tests/integration/fixtures/external_components/defer_stress_component/defer_stress_component.h index 59b7565726..2aac0aeddc 100644 --- a/tests/integration/fixtures/external_components/defer_stress_component/defer_stress_component.h +++ b/tests/integration/fixtures/external_components/defer_stress_component/defer_stress_component.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include -namespace esphome { -namespace defer_stress_component { +namespace esphome::defer_stress_component { class DeferStressComponent : public Component { public: @@ -16,5 +15,4 @@ class DeferStressComponent : public Component { std::atomic executed_defers_{0}; }; -} // namespace defer_stress_component -} // namespace esphome +} // namespace esphome::defer_stress_component diff --git a/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.cpp b/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.cpp index 28a05d3d45..dd54381c35 100644 --- a/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.cpp +++ b/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.cpp @@ -1,7 +1,6 @@ #include "loop_test_component.h" -namespace esphome { -namespace loop_test_component { +namespace esphome::loop_test_component { void LoopTestComponent::setup() { ESP_LOGI(TAG, "[%s] Setup called", this->name_.c_str()); } @@ -63,5 +62,4 @@ void LoopTestUpdateComponent::update() { this->update_count_, loop_disabled ? "YES" : "NO"); } -} // namespace loop_test_component -} // namespace esphome +} // namespace esphome::loop_test_component diff --git a/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h b/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h index 3dca2da2e9..1a81411861 100644 --- a/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h +++ b/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h @@ -6,8 +6,7 @@ #include "esphome/core/automation.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace loop_test_component { +namespace esphome::loop_test_component { static const char *const TAG = "loop_test_component"; @@ -79,5 +78,4 @@ class LoopTestUpdateComponent : public PollingComponent { int disable_loop_after_{0}; }; -} // namespace loop_test_component -} // namespace esphome +} // namespace esphome::loop_test_component diff --git a/tests/integration/fixtures/external_components/loop_test_component/loop_test_isr_component.cpp b/tests/integration/fixtures/external_components/loop_test_component/loop_test_isr_component.cpp index 30afec0422..b6f94aacf2 100644 --- a/tests/integration/fixtures/external_components/loop_test_component/loop_test_isr_component.cpp +++ b/tests/integration/fixtures/external_components/loop_test_component/loop_test_isr_component.cpp @@ -2,8 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/application.h" -namespace esphome { -namespace loop_test_component { +namespace esphome::loop_test_component { static const char *const ISR_TAG = "loop_test_isr_component"; @@ -76,5 +75,4 @@ void IRAM_ATTR LoopTestISRComponent::simulate_isr_enable() { // For testing, we'll track the call count and log it from the main loop } -} // namespace loop_test_component -} // namespace esphome +} // namespace esphome::loop_test_component diff --git a/tests/integration/fixtures/external_components/loop_test_component/loop_test_isr_component.h b/tests/integration/fixtures/external_components/loop_test_component/loop_test_isr_component.h index 20e11b5ecd..5537bd0233 100644 --- a/tests/integration/fixtures/external_components/loop_test_component/loop_test_isr_component.h +++ b/tests/integration/fixtures/external_components/loop_test_component/loop_test_isr_component.h @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace loop_test_component { +namespace esphome::loop_test_component { class LoopTestISRComponent : public Component { public: @@ -28,5 +27,4 @@ class LoopTestISRComponent : public Component { int isr_call_count_{0}; }; -} // namespace loop_test_component -} // namespace esphome +} // namespace esphome::loop_test_component diff --git a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp index be85228c3c..f6fd1b1de7 100644 --- a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace scheduler_bulk_cleanup_component { +namespace esphome::scheduler_bulk_cleanup_component { static const char *const TAG = "bulk_cleanup"; @@ -68,5 +67,4 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { } } -} // namespace scheduler_bulk_cleanup_component -} // namespace esphome +} // namespace esphome::scheduler_bulk_cleanup_component diff --git a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.h b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.h index f55472d426..34b4a8e0d0 100644 --- a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.h +++ b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/application.h" -namespace esphome { -namespace scheduler_bulk_cleanup_component { +namespace esphome::scheduler_bulk_cleanup_component { class SchedulerBulkCleanupComponent : public Component { public: @@ -14,5 +13,4 @@ class SchedulerBulkCleanupComponent : public Component { void trigger_bulk_cleanup(); }; -} // namespace scheduler_bulk_cleanup_component -} // namespace esphome +} // namespace esphome::scheduler_bulk_cleanup_component diff --git a/tests/integration/fixtures/external_components/scheduler_heap_stress_component/heap_scheduler_stress_component.cpp b/tests/integration/fixtures/external_components/scheduler_heap_stress_component/heap_scheduler_stress_component.cpp index 305d359591..f75a2fdd92 100644 --- a/tests/integration/fixtures/external_components/scheduler_heap_stress_component/heap_scheduler_stress_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_heap_stress_component/heap_scheduler_stress_component.cpp @@ -6,8 +6,7 @@ #include #include -namespace esphome { -namespace scheduler_heap_stress_component { +namespace esphome::scheduler_heap_stress_component { static const char *const TAG = "scheduler_heap_stress"; @@ -100,5 +99,4 @@ void SchedulerHeapStressComponent::run_multi_thread_test() { ESP_LOGI(TAG, "All threads finished in %lldms. Created %d callbacks", thread_time, this->total_callbacks_.load()); } -} // namespace scheduler_heap_stress_component -} // namespace esphome +} // namespace esphome::scheduler_heap_stress_component diff --git a/tests/integration/fixtures/external_components/scheduler_heap_stress_component/heap_scheduler_stress_component.h b/tests/integration/fixtures/external_components/scheduler_heap_stress_component/heap_scheduler_stress_component.h index 5da32ca9f8..9f7810d0ad 100644 --- a/tests/integration/fixtures/external_components/scheduler_heap_stress_component/heap_scheduler_stress_component.h +++ b/tests/integration/fixtures/external_components/scheduler_heap_stress_component/heap_scheduler_stress_component.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include -namespace esphome { -namespace scheduler_heap_stress_component { +namespace esphome::scheduler_heap_stress_component { class SchedulerHeapStressComponent : public Component { public: @@ -18,5 +17,4 @@ class SchedulerHeapStressComponent : public Component { std::atomic executed_callbacks_{0}; }; -} // namespace scheduler_heap_stress_component -} // namespace esphome +} // namespace esphome::scheduler_heap_stress_component diff --git a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp index b735c453f2..0e5525d265 100644 --- a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp @@ -6,8 +6,7 @@ #include #include -namespace esphome { -namespace scheduler_rapid_cancellation_component { +namespace esphome::scheduler_rapid_cancellation_component { static const char *const TAG = "scheduler_rapid_cancellation"; @@ -76,5 +75,4 @@ void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { }); } -} // namespace scheduler_rapid_cancellation_component -} // namespace esphome +} // namespace esphome::scheduler_rapid_cancellation_component diff --git a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.h b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.h index 0a01b2a8de..f1ef9a72b6 100644 --- a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.h +++ b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include -namespace esphome { -namespace scheduler_rapid_cancellation_component { +namespace esphome::scheduler_rapid_cancellation_component { class SchedulerRapidCancellationComponent : public Component { public: @@ -18,5 +17,4 @@ class SchedulerRapidCancellationComponent : public Component { std::atomic total_executed_{0}; }; -} // namespace scheduler_rapid_cancellation_component -} // namespace esphome +} // namespace esphome::scheduler_rapid_cancellation_component diff --git a/tests/integration/fixtures/external_components/scheduler_recursive_timeout_component/recursive_timeout_component.cpp b/tests/integration/fixtures/external_components/scheduler_recursive_timeout_component/recursive_timeout_component.cpp index 2a08bd72a9..6bc03f34c0 100644 --- a/tests/integration/fixtures/external_components/scheduler_recursive_timeout_component/recursive_timeout_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_recursive_timeout_component/recursive_timeout_component.cpp @@ -1,8 +1,7 @@ #include "recursive_timeout_component.h" #include "esphome/core/log.h" -namespace esphome { -namespace scheduler_recursive_timeout_component { +namespace esphome::scheduler_recursive_timeout_component { static const char *const TAG = "scheduler_recursive_timeout"; @@ -36,5 +35,4 @@ void SchedulerRecursiveTimeoutComponent::run_recursive_timeout_test() { }); } -} // namespace scheduler_recursive_timeout_component -} // namespace esphome +} // namespace esphome::scheduler_recursive_timeout_component diff --git a/tests/integration/fixtures/external_components/scheduler_recursive_timeout_component/recursive_timeout_component.h b/tests/integration/fixtures/external_components/scheduler_recursive_timeout_component/recursive_timeout_component.h index 8d2c085a11..237f9785b2 100644 --- a/tests/integration/fixtures/external_components/scheduler_recursive_timeout_component/recursive_timeout_component.h +++ b/tests/integration/fixtures/external_components/scheduler_recursive_timeout_component/recursive_timeout_component.h @@ -2,8 +2,7 @@ #include "esphome/core/component.h" -namespace esphome { -namespace scheduler_recursive_timeout_component { +namespace esphome::scheduler_recursive_timeout_component { class SchedulerRecursiveTimeoutComponent : public Component { public: @@ -16,5 +15,4 @@ class SchedulerRecursiveTimeoutComponent : public Component { int nested_level_{0}; }; -} // namespace scheduler_recursive_timeout_component -} // namespace esphome +} // namespace esphome::scheduler_recursive_timeout_component diff --git a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp index b4c2b8c6c2..a817b9f508 100644 --- a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp @@ -5,8 +5,7 @@ #include #include -namespace esphome { -namespace scheduler_simultaneous_callbacks_component { +namespace esphome::scheduler_simultaneous_callbacks_component { static const char *const TAG = "scheduler_simultaneous_callbacks"; @@ -105,5 +104,4 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() }); } -} // namespace scheduler_simultaneous_callbacks_component -} // namespace esphome +} // namespace esphome::scheduler_simultaneous_callbacks_component diff --git a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.h b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.h index 1a36af4b3d..9746331aec 100644 --- a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.h +++ b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include -namespace esphome { -namespace scheduler_simultaneous_callbacks_component { +namespace esphome::scheduler_simultaneous_callbacks_component { class SchedulerSimultaneousCallbacksComponent : public Component { public: @@ -20,5 +19,4 @@ class SchedulerSimultaneousCallbacksComponent : public Component { std::atomic max_concurrent_{0}; }; -} // namespace scheduler_simultaneous_callbacks_component -} // namespace esphome +} // namespace esphome::scheduler_simultaneous_callbacks_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp index 8c3f665f19..cc1b9f7814 100644 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp @@ -4,8 +4,7 @@ #include #include -namespace esphome { -namespace scheduler_string_lifetime_component { +namespace esphome::scheduler_string_lifetime_component { static const char *const TAG = "scheduler_string_lifetime"; @@ -258,5 +257,4 @@ void SchedulerStringLifetimeComponent::test_lambda_capture_lifetime() { }); } -} // namespace scheduler_string_lifetime_component -} // namespace esphome +} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h index 95532328bb..20185f128d 100644 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h +++ b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h @@ -4,8 +4,7 @@ #include #include -namespace esphome { -namespace scheduler_string_lifetime_component { +namespace esphome::scheduler_string_lifetime_component { class SchedulerStringLifetimeComponent : public Component { public: @@ -33,5 +32,4 @@ class SchedulerStringLifetimeComponent : public Component { int tests_failed_{0}; }; -} // namespace scheduler_string_lifetime_component -} // namespace esphome +} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp index 9071e573bb..677d371f25 100644 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp @@ -7,8 +7,7 @@ #include #include -namespace esphome { -namespace scheduler_string_name_stress_component { +namespace esphome::scheduler_string_name_stress_component { static const char *const TAG = "scheduler_string_name_stress"; @@ -106,5 +105,4 @@ void SchedulerStringNameStressComponent::run_string_name_stress_test() { }); } -} // namespace scheduler_string_name_stress_component -} // namespace esphome +} // namespace esphome::scheduler_string_name_stress_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h index 002a0a7b51..121bda6204 100644 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h +++ b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include -namespace esphome { -namespace scheduler_string_name_stress_component { +namespace esphome::scheduler_string_name_stress_component { class SchedulerStringNameStressComponent : public Component { public: @@ -18,5 +17,4 @@ class SchedulerStringNameStressComponent : public Component { std::atomic executed_callbacks_{0}; }; -} // namespace scheduler_string_name_stress_component -} // namespace esphome +} // namespace esphome::scheduler_string_name_stress_component From 7b6e2589f11dc8e01b346d9bd743f8d90d700928 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 8 May 2026 14:09:22 +1000 Subject: [PATCH 1123/2214] [modbus_server] Reduce log spam (#16283) --- esphome/components/modbus_server/modbus_server.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 0063da3a1d..e5ea2efa4d 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "modbus_server"; void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers) { - ESP_LOGD(TAG, + ESP_LOGV(TAG, "Received read holding/input registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " "0x%X.", this->address_, function_code, start_address, number_of_registers); @@ -30,7 +30,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star break; } int64_t value = server_register->read_lambda(); - ESP_LOGD(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", + ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", server_register->address, static_cast(server_register->value_type), server_register->register_count, server_register->format_value(value).c_str()); @@ -47,7 +47,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star if (!found) { if (this->server_courtesy_response_.enabled && (current_address <= this->server_courtesy_response_.register_last_address)) { - ESP_LOGD(TAG, + ESP_LOGV(TAG, "Could not match any register to address 0x%02X, but default allowed. " "Returning default value: %d.", current_address, this->server_courtesy_response_.register_value); From a970f05b69b5d04d198a5e1bcfa968bc4e47b982 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 8 May 2026 07:26:03 -0400 Subject: [PATCH 1124/2214] [clang-tidy] Enable readability-redundant-string-init (#16310) --- .clang-tidy | 1 - .clang-tidy.hash | 2 +- esphome/components/graph/graph.h | 2 +- esphome/components/nfc/ndef_message.cpp | 2 +- esphome/components/online_image/online_image.h | 6 +++--- esphome/components/remote_receiver/remote_receiver.h | 2 +- esphome/components/remote_transmitter/remote_transmitter.h | 2 +- esphome/components/rtttl/rtttl.h | 2 +- esphome/components/tuya/tuya.h | 2 +- esphome/components/uart/uart_component_host.h | 2 +- esphome/components/voice_assistant/voice_assistant.cpp | 6 +++--- esphome/components/voice_assistant/voice_assistant.h | 6 +++--- esphome/components/web_server_idf/web_server_idf.h | 2 +- 13 files changed, 18 insertions(+), 19 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index 9aeeb1fc26..ea7370a3b2 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -132,7 +132,6 @@ Checks: >- -readability-redundant-inline-specifier, -readability-redundant-member-init, -readability-redundant-parentheses, - -readability-redundant-string-init, -readability-redundant-typename, -readability-uppercase-literal-suffix, -readability-use-anyofallof, diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 2f973e34d9..4c4b4e5c9c 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -c1e4375738304baabf7e915e5f7ca50fb22ec9b4a2d8312827a32a659f3fa40c +edce6cd78b33b296cef3caa5869d237061345eb346e3f9cb21e3239d2711051f diff --git a/esphome/components/graph/graph.h b/esphome/components/graph/graph.h index 468583ca21..a601e9eeb1 100644 --- a/esphome/components/graph/graph.h +++ b/esphome/components/graph/graph.h @@ -123,7 +123,7 @@ class GraphTrace { protected: sensor::Sensor *sensor_{nullptr}; - std::string name_{""}; + std::string name_; uint8_t line_thickness_{3}; enum LineType line_type_ { LINE_TYPE_SOLID }; Color line_color_{COLOR_ON}; diff --git a/esphome/components/nfc/ndef_message.cpp b/esphome/components/nfc/ndef_message.cpp index ba3aa77e34..d33f3f7b5c 100644 --- a/esphome/components/nfc/ndef_message.cpp +++ b/esphome/components/nfc/ndef_message.cpp @@ -52,7 +52,7 @@ NdefMessage::NdefMessage(std::vector &data) { index += type_length; - std::string id_str = ""; + std::string id_str; if (il) { id_str = std::string(data.begin() + index, data.begin() + index + id_length); index += id_length; diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index 816d6525ea..a967bb6c0e 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -88,18 +88,18 @@ class OnlineImage : public PollingComponent, */ size_t download_buffer_initial_size_; - std::string url_{""}; + std::string url_; std::vector>> request_headers_; /** * The value of the ETag HTTP header provided in the last response. */ - std::string etag_ = ""; + std::string etag_; /** * The value of the Last-Modified HTTP header provided in the last response. */ - std::string last_modified_ = ""; + std::string last_modified_; uint32_t start_time_{0}; }; diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 5da9283a6e..cc707346eb 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -90,7 +90,7 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, uint32_t carrier_frequency_{0}; uint8_t carrier_duty_percent_{100}; esp_err_t error_code_{ESP_OK}; - std::string error_string_{""}; + std::string error_string_; #endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || defined(USE_ESP32) diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 6b4ebfe24b..d30966e3da 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -92,7 +92,7 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, rmt_channel_handle_t channel_{NULL}; rmt_encoder_handle_t encoder_{NULL}; esp_err_t error_code_{ESP_OK}; - std::string error_string_{""}; + std::string error_string_; bool inverted_{false}; bool non_blocking_{false}; #endif diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 9dac92be2a..d060b6b024 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -72,7 +72,7 @@ class Rtttl : public Component { void set_state_(State state); /// The RTTTL string to play. - std::string rtttl_{""}; + std::string rtttl_; /// The current position in the RTTTL string. size_t position_{0}; /// The default duration of a note (e.g. 4 for a quarter note). diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index 8ba8ac85a0..470b97e7e7 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -150,7 +150,7 @@ class Tuya : public Component, public uart::UARTDevice { int reset_pin_reported_ = -1; uint32_t last_command_timestamp_ = 0; uint32_t last_rx_char_timestamp_ = 0; - std::string product_ = ""; + std::string product_; std::vector listeners_; std::vector datapoints_; std::vector rx_message_; diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index 56ff525bc3..a47e5649be 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -25,7 +25,7 @@ class HostUartComponent : public UARTComponent, public Component { void update_error_(const std::string &error); void check_logger_conflict() override {} std::string port_name_; - std::string first_error_{""}; + std::string first_error_; int file_descriptor_ = -1; bool has_peek_{false}; uint8_t peek_byte_; diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index bff0026b24..50a8265297 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -676,7 +676,7 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { break; case api::enums::VOICE_ASSISTANT_INTENT_PROGRESS: { ESP_LOGD(TAG, "Intent progress"); - std::string tts_url_for_trigger = ""; + std::string tts_url_for_trigger; #ifdef USE_MEDIA_PLAYER if (this->media_player_ != nullptr) { for (const auto &arg : msg.data) { @@ -782,8 +782,8 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { break; } case api::enums::VOICE_ASSISTANT_ERROR: { - std::string code = ""; - std::string message = ""; + std::string code; + std::string message; for (const auto &arg : msg.data) { if (arg.name == "code") { code = arg.value; diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index faef09d8bd..3de4673001 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -288,7 +288,7 @@ class VoiceAssistant : public Component { #endif #ifdef USE_MEDIA_PLAYER media_player::MediaPlayer *media_player_{nullptr}; - std::string tts_response_url_{""}; + std::string tts_response_url_; bool started_streaming_tts_{false}; MediaPlayerResponseState media_player_response_state_{MediaPlayerResponseState::IDLE}; @@ -296,9 +296,9 @@ class VoiceAssistant : public Component { bool local_output_{false}; - std::string conversation_id_{""}; + std::string conversation_id_; - std::string wake_word_{""}; + std::string wake_word_; std::shared_ptr ring_buffer_; diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index cdb58c2f04..c622d53e89 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -313,7 +313,7 @@ class AsyncEventSourceResponse { std::vector deferred_queue_; esphome::web_server::WebServer *web_server_; esphome::web_server::ListEntitiesIterator entities_iterator_; - std::string event_buffer_{""}; + std::string event_buffer_; size_t event_bytes_sent_; uint16_t consecutive_send_failures_{0}; static constexpr uint16_t MAX_CONSECUTIVE_SEND_FAILURES = 2500; // ~20 seconds at 125Hz loop rate From eb52ca61fe528df25110814ae3adcd24b816edd4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 8 May 2026 07:26:14 -0400 Subject: [PATCH 1125/2214] [climate][ektf2232] Remove deprecations scheduled for 2026.5.0 (#16289) --- esphome/components/climate/climate_traits.h | 57 ------------------- .../ektf2232/touchscreen/__init__.py | 4 -- 2 files changed, 61 deletions(-) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 082b2127a9..599894c8a9 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -81,63 +81,6 @@ class ClimateTraits { bool has_feature_flags(uint32_t feature_flags) const { return this->feature_flags_ & feature_flags; } void set_feature_flags(uint32_t feature_flags) { this->feature_flags_ = feature_flags; } - ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0") - bool get_supports_current_temperature() const { - return this->has_feature_flags(CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); - } - ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0") - void set_supports_current_temperature(bool supports_current_temperature) { - if (supports_current_temperature) { - this->add_feature_flags(CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); - } else { - this->clear_feature_flags(CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); - } - } - ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0") - bool get_supports_current_humidity() const { return this->has_feature_flags(CLIMATE_SUPPORTS_CURRENT_HUMIDITY); } - ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0") - void set_supports_current_humidity(bool supports_current_humidity) { - if (supports_current_humidity) { - this->add_feature_flags(CLIMATE_SUPPORTS_CURRENT_HUMIDITY); - } else { - this->clear_feature_flags(CLIMATE_SUPPORTS_CURRENT_HUMIDITY); - } - } - ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0") - bool get_supports_two_point_target_temperature() const { - return this->has_feature_flags(CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE); - } - ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0") - void set_supports_two_point_target_temperature(bool supports_two_point_target_temperature) { - if (supports_two_point_target_temperature) - // Use CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE to mimic previous behavior - { - this->add_feature_flags(CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE); - } else { - this->clear_feature_flags(CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE); - } - } - ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0") - bool get_supports_target_humidity() const { return this->has_feature_flags(CLIMATE_SUPPORTS_TARGET_HUMIDITY); } - ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0") - void set_supports_target_humidity(bool supports_target_humidity) { - if (supports_target_humidity) { - this->add_feature_flags(CLIMATE_SUPPORTS_TARGET_HUMIDITY); - } else { - this->clear_feature_flags(CLIMATE_SUPPORTS_TARGET_HUMIDITY); - } - } - ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0") - bool get_supports_action() const { return this->has_feature_flags(CLIMATE_SUPPORTS_ACTION); } - ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0") - void set_supports_action(bool supports_action) { - if (supports_action) { - this->add_feature_flags(CLIMATE_SUPPORTS_ACTION); - } else { - this->clear_feature_flags(CLIMATE_SUPPORTS_ACTION); - } - } - void set_supported_modes(ClimateModeMask modes) { this->supported_modes_ = modes; } void add_supported_mode(ClimateMode mode) { this->supported_modes_.insert(mode); } bool supports_mode(ClimateMode mode) const { return this->supported_modes_.count(mode); } diff --git a/esphome/components/ektf2232/touchscreen/__init__.py b/esphome/components/ektf2232/touchscreen/__init__.py index 123f03ca08..64bb17a7db 100644 --- a/esphome/components/ektf2232/touchscreen/__init__.py +++ b/esphome/components/ektf2232/touchscreen/__init__.py @@ -15,7 +15,6 @@ EKTF2232Touchscreen = ektf2232_ns.class_( ) CONF_EKTF2232_ID = "ektf2232_id" -CONF_RTS_PIN = "rts_pin" # To be removed before 2026.4.0 CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( cv.Schema( @@ -25,9 +24,6 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( pins.internal_gpio_input_pin_schema ), cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_RTS_PIN): cv.invalid( - f"{CONF_RTS_PIN} has been renamed to {CONF_RESET_PIN}" - ), } ).extend(i2c.i2c_device_schema(0x15)) ) From 3d8fffbea954580a082792ae919d611e8890e5c8 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 8 May 2026 13:17:14 +0000 Subject: [PATCH 1126/2214] [htu31d][kamstrup_kmp][ms8607] Use CRC functions from helpers.h (#16313) --- esphome/components/htu31d/htu31d.cpp | 32 ++----------------- .../components/kamstrup_kmp/kamstrup_kmp.cpp | 28 ++-------------- .../components/kamstrup_kmp/kamstrup_kmp.h | 3 -- esphome/components/ms8607/ms8607.cpp | 32 +------------------ tests/components/ms8607/common.yaml | 11 +++++++ tests/components/ms8607/test.esp32-idf.yaml | 8 +++++ tests/components/ms8607/test.esp8266-ard.yaml | 8 +++++ tests/components/ms8607/test.rp2040-ard.yaml | 8 +++++ 8 files changed, 42 insertions(+), 88 deletions(-) create mode 100644 tests/components/ms8607/common.yaml create mode 100644 tests/components/ms8607/test.esp32-idf.yaml create mode 100644 tests/components/ms8607/test.esp8266-ard.yaml create mode 100644 tests/components/ms8607/test.rp2040-ard.yaml diff --git a/esphome/components/htu31d/htu31d.cpp b/esphome/components/htu31d/htu31d.cpp index 0b679bf2b7..6821b8b69e 100644 --- a/esphome/components/htu31d/htu31d.cpp +++ b/esphome/components/htu31d/htu31d.cpp @@ -43,32 +43,6 @@ static const uint8_t HTU31D_RESET = 0x1E; /** Diagnostics command. */ static const uint8_t HTU31D_DIAGNOSTICS = 0x08; -/** - * Computes a CRC result for the provided input. - * - * @returns the computed CRC result for the provided input - */ -uint8_t compute_crc(uint32_t value) { - uint32_t polynom = 0x98800000; // x^8 + x^5 + x^4 + 1 - uint32_t msb = 0x80000000; - uint32_t mask = 0xFF800000; - uint32_t threshold = 0x00000080; - uint32_t result = value; - - while (msb != threshold) { - // Check if msb of current value is 1 and apply XOR mask - if (result & msb) - result = ((result ^ polynom) & mask) | (result & ~mask); - - // Shift by one - msb >>= 1; - mask >>= 1; - polynom >>= 1; - } - - return result; -} - /** * Resets the sensor and ensures that the devices serial number can be read over * I2C. @@ -112,7 +86,7 @@ void HTU31DComponent::update() { // Calculate temperature value. uint16_t raw_temp = encode_uint16(thdata[0], thdata[1]); - uint8_t crc = compute_crc((uint32_t) raw_temp << 8); + uint8_t crc = crc8(thdata, 2, 0, 0x31, true); if (crc != thdata[2]) { this->status_set_warning(); ESP_LOGE(TAG, "Error validating temperature CRC"); @@ -131,7 +105,7 @@ void HTU31DComponent::update() { // Calculate humidty value. uint16_t raw_hum = encode_uint16(thdata[3], thdata[4]); - crc = compute_crc((uint32_t) raw_hum << 8); + crc = crc8(thdata + 3, 2, 0, 0x31, true); if (crc != thdata[5]) { this->status_set_warning(); ESP_LOGE(TAG, "Error validating humidty CRC"); @@ -197,7 +171,7 @@ uint32_t HTU31DComponent::read_serial_num_() { serial = encode_uint32(reply[0], reply[1], reply[2], padding); - uint8_t crc = compute_crc(serial); + uint8_t crc = crc8(reply, 3, 0, 0x31, true); if (crc != reply[3]) { ESP_LOGE(TAG, "Error validating serial CRC"); return 0; diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index ed03c4d6df..70f6d4eaa7 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -1,5 +1,6 @@ #include "kamstrup_kmp.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::kamstrup_kmp { @@ -95,10 +96,7 @@ void KamstrupKMPComponent::send_message_(const uint8_t *msg, int msg_len) { buffer[i] = msg[i]; } - buffer[buffer_len - 2] = 0; - buffer[buffer_len - 1] = 0; - - uint16_t crc = crc16_ccitt(buffer, buffer_len); + uint16_t crc = crc16be(buffer, buffer_len - 2); buffer[buffer_len - 2] = crc >> 8; buffer[buffer_len - 1] = crc & 0xFF; @@ -192,7 +190,7 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { } // Validate CRC - if (crc16_ccitt(msg, msg_len)) { + if (crc16be(msg, msg_len - 2) != encode_uint16(msg[msg_len - 2], msg[msg_len - 1])) { ESP_LOGE(TAG, "Received invalid message (CRC mismatch)"); return; } @@ -282,24 +280,4 @@ void KamstrupKMPComponent::set_sensor_value_(uint16_t command, float value, uint ESP_LOGD(TAG, "Received value for command 0x%04X: %.3f [%s]", command, value, unit); } -uint16_t crc16_ccitt(const uint8_t *buffer, int len) { - uint32_t poly = 0x1021; - uint32_t reg = 0x00; - for (int i = 0; i < len; i++) { - int mask = 0x80; - while (mask > 0) { - reg <<= 1; - if (buffer[i] & mask) { - reg |= 1; - } - mask >>= 1; - if (reg & 0x10000) { - reg &= 0xffff; - reg ^= poly; - } - } - } - return (uint16_t) reg; -} - } // namespace esphome::kamstrup_kmp diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.h b/esphome/components/kamstrup_kmp/kamstrup_kmp.h index a05a0ee17a..a4eacec453 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.h +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.h @@ -123,7 +123,4 @@ class KamstrupKMPComponent : public PollingComponent, public uart::UARTDevice { void set_sensor_value_(uint16_t command, float value, uint8_t unit_idx); }; -// "true" CCITT CRC-16 -uint16_t crc16_ccitt(const uint8_t *buffer, int len); - } // namespace esphome::kamstrup_kmp diff --git a/esphome/components/ms8607/ms8607.cpp b/esphome/components/ms8607/ms8607.cpp index f733a8349d..b9cbdc749b 100644 --- a/esphome/components/ms8607/ms8607.cpp +++ b/esphome/components/ms8607/ms8607.cpp @@ -63,7 +63,6 @@ enum class MS8607Component::SetupStatus { }; static uint8_t crc4(uint16_t *buffer, size_t length); -static uint8_t hsensor_crc_check(uint16_t value); void MS8607Component::setup() { this->error_code_ = ErrorCode::NONE; @@ -244,35 +243,6 @@ static uint8_t crc4(uint16_t *buffer, size_t length) { return (crc_remainder >> 12) & 0xF; // only the most significant 4 bits } -/** - * @brief Calculates CRC value for the provided humidity (+ status bits) value - * - * CRC-8 check comes from other MS8607 libraries on github. I did not find it in the datasheet, - * and it differs from the crc8 implementation that's already part of esphome. - * - * @param value two byte humidity sensor value read from i2c - * @return uint8_t computed crc value - */ -static uint8_t hsensor_crc_check(uint16_t value) { - uint32_t polynom = 0x988000; // x^8 + x^5 + x^4 + 1 - uint32_t msb = 0x800000; - uint32_t mask = 0xFF8000; - uint32_t result = (uint32_t) value << 8; // Pad with zeros as specified in spec - - while (msb != 0x80) { - // Check if msb of current value is 1 and apply XOR mask - if (result & msb) { - result = ((result ^ polynom) & mask) | (result & ~mask); - } - - // Shift by one - msb >>= 1; - mask >>= 1; - polynom >>= 1; - } - return result & 0xFF; -} - void MS8607Component::request_read_temperature_() { // Tell MS8607 to start ADC conversion of temperature sensor if (!this->write_bytes(MS8607_CMD_CONV_D2_OSR_8K, nullptr, 0)) { @@ -338,7 +308,7 @@ void MS8607Component::read_humidity_(float temperature_float) { // Bit1 of the two LSBS must be set to '1'. Bit0 is currently not assigned" uint16_t humidity = encode_uint16(bytes[0], bytes[1]); uint8_t const expected_crc = bytes[2]; - uint8_t const actual_crc = hsensor_crc_check(humidity); + uint8_t const actual_crc = crc8(bytes, 2, 0, 0x31, true); if (expected_crc != actual_crc) { ESP_LOGE(TAG, "Incorrect Humidity CRC value. Provided value 0x%01X != calculated value 0x%01X", expected_crc, actual_crc); diff --git a/tests/components/ms8607/common.yaml b/tests/components/ms8607/common.yaml new file mode 100644 index 0000000000..09a3f5a617 --- /dev/null +++ b/tests/components/ms8607/common.yaml @@ -0,0 +1,11 @@ +sensor: + - platform: ms8607 + i2c_id: i2c_bus + temperature: + name: Temperature + humidity: + name: Humidity + pressure: + name: Pressure + address: 0x76 + update_interval: 15s diff --git a/tests/components/ms8607/test.esp32-idf.yaml b/tests/components/ms8607/test.esp32-idf.yaml new file mode 100644 index 0000000000..4598505c3a --- /dev/null +++ b/tests/components/ms8607/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2c_scl: GPIO16 + i2c_sda: GPIO17 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/ms8607/test.esp8266-ard.yaml b/tests/components/ms8607/test.esp8266-ard.yaml new file mode 100644 index 0000000000..5565bb8c35 --- /dev/null +++ b/tests/components/ms8607/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + i2c_scl: GPIO5 + i2c_sda: GPIO4 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ms8607/test.rp2040-ard.yaml b/tests/components/ms8607/test.rp2040-ard.yaml new file mode 100644 index 0000000000..888762a742 --- /dev/null +++ b/tests/components/ms8607/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + i2c_scl: GPIO5 + i2c_sda: GPIO4 + +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From 88c2a1c09639a28f140cc59d89e97b5d660f1994 Mon Sep 17 00:00:00 2001 From: John Boiles Date: Fri, 8 May 2026 11:43:55 -0700 Subject: [PATCH 1127/2214] [speaker] Add SPDIF output support (#8065) Co-authored-by: Keith Burzinski Co-authored-by: Kevin Ahrendt --- esphome/components/i2s_audio/__init__.py | 5 +- .../components/i2s_audio/speaker/__init__.py | 72 +- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 629 ++++++++++++++++++ .../i2s_audio/speaker/i2s_audio_spdif.h | 34 + .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- .../i2s_audio/speaker/i2s_audio_speaker.h | 4 +- .../i2s_audio/speaker/spdif_encoder.cpp | 385 +++++++++++ .../i2s_audio/speaker/spdif_encoder.h | 146 ++++ esphome/core/defines.h | 1 + .../speaker/spdif_mode.esp32-idf.yaml | 25 + 10 files changed, 1291 insertions(+), 12 deletions(-) create mode 100644 esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp create mode 100644 esphome/components/i2s_audio/speaker/i2s_audio_spdif.h create mode 100644 esphome/components/i2s_audio/speaker/spdif_encoder.cpp create mode 100644 esphome/components/i2s_audio/speaker/spdif_encoder.h create mode 100644 tests/components/speaker/spdif_mode.esp32-idf.yaml diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index ffa63f5ee8..951b8c0498 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -201,7 +201,7 @@ async def register_i2s_audio_component(var, config): CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(I2SAudioComponent), - cv.Required(CONF_I2S_LRCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_I2S_LRCLK_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_I2S_BCLK_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_I2S_MCLK_PIN): pins.internal_gpio_output_pin_number, }, @@ -290,7 +290,8 @@ async def to_code(config): # Helps avoid callbacks being skipped due to processor load add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) - cg.add(var.set_lrclk_pin(config[CONF_I2S_LRCLK_PIN])) + if CONF_I2S_LRCLK_PIN in config: + cg.add(var.set_lrclk_pin(config[CONF_I2S_LRCLK_PIN])) if CONF_I2S_BCLK_PIN in config: cg.add(var.set_bclk_pin(config[CONF_I2S_BCLK_PIN])) if CONF_I2S_MCLK_PIN in config: diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 99aa712c68..759cc40ca9 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -18,10 +18,12 @@ from .. import ( CONF_I2S_DOUT_PIN, CONF_I2S_MODE, CONF_LEFT, + CONF_MCLK_MULTIPLE, CONF_MONO, CONF_PRIMARY, CONF_RIGHT, CONF_STEREO, + CONF_USE_APLL, I2SAudioOut, i2s_audio_component_schema, i2s_audio_ns, @@ -40,6 +42,15 @@ I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) CONF_DAC_TYPE = "dac_type" CONF_I2S_COMM_FMT = "i2s_comm_fmt" +CONF_SPDIF_MODE = "spdif_mode" + +I2SAudioSpeakerBase = i2s_audio_ns.class_( + "I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut +) +I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) +I2SAudioSpeakerSPDIF = i2s_audio_ns.class_("I2SAudioSpeakerSPDIF", I2SAudioSpeakerBase) + +I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True) I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True) @@ -77,7 +88,17 @@ def _set_num_channels_from_config(config): def _set_stream_limits(config): - if config[CONF_I2S_MODE] == CONF_PRIMARY: + if config.get(CONF_SPDIF_MODE, False): + # SPDIF mode: fixed to 16-bit stereo at configured sample rate + audio.set_stream_limits( + min_bits_per_sample=16, + max_bits_per_sample=16, + min_channels=2, + max_channels=2, + min_sample_rate=config.get(CONF_SAMPLE_RATE), + max_sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + elif config[CONF_I2S_MODE] == CONF_PRIMARY: # Primary mode has modifiable stream settings audio.set_stream_limits( min_bits_per_sample=8, @@ -101,6 +122,13 @@ def _set_stream_limits(config): return config +def _select_speaker_class(config): + """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): variant = esp32.get_esp32_variant() if config[CONF_DAC_TYPE] == "internal": @@ -155,6 +183,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_I2S_COMM_FMT, default="stand_i2s"): cv.one_of( *I2C_COMM_FMT_OPTIONS, lower=True ), + cv.Optional(CONF_SPDIF_MODE, default=False): cv.boolean, } ), }, @@ -163,6 +192,7 @@ CONFIG_SCHEMA = cv.All( _validate_esp32_variant, _set_num_channels_from_config, _set_stream_limits, + _select_speaker_class, validate_mclk_divisible_by_3, ) @@ -175,6 +205,28 @@ def _final_validate(config): if config[CONF_I2S_COMM_FMT] == "stand_max": raise cv.Invalid("I2S standard max format is no longer supported.") + if config.get(CONF_SPDIF_MODE, False): + # SPDIF mode specific validations + if config[CONF_SAMPLE_RATE] not in [44100, 48000]: + raise cv.Invalid( + "SPDIF mode only supports 44100 Hz or 48000 Hz sample rates" + ) + if config[CONF_CHANNEL] != CONF_STEREO: + raise cv.Invalid("SPDIF mode only supports stereo channel configuration") + # bits_per_sample is converted to float by the schema + if config[CONF_BITS_PER_SAMPLE] != 16: + raise cv.Invalid("SPDIF mode only supports 16 bits per sample") + if not config[CONF_USE_APLL]: + raise cv.Invalid( + "SPDIF mode requires 'use_apll: true' for accurate clock generation" + ) + if config[CONF_I2S_MODE] != CONF_PRIMARY: + raise cv.Invalid("SPDIF mode requires 'i2s_mode: primary'") + if config[CONF_I2S_COMM_FMT] != "stand_i2s": + raise cv.Invalid("SPDIF mode requires 'i2s_comm_fmt: stand_i2s'") + if config[CONF_MCLK_MULTIPLE] != 256: + raise cv.Invalid("SPDIF mode requires 'mclk_multiple: 256'") + FINAL_VALIDATE_SCHEMA = _final_validate @@ -186,12 +238,18 @@ async def to_code(config): await speaker.register_speaker(var, config) cg.add(var.set_dout_pin(config[CONF_I2S_DOUT_PIN])) - fmt = I2SCommFmt.STANDARD # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long - if config[CONF_I2S_COMM_FMT] in ["stand_msb", "i2s_lsb"]: - fmt = I2SCommFmt.MSB - elif config[CONF_I2S_COMM_FMT] in ["stand_pcm_short", "pcm_short", "pcm"]: - fmt = I2SCommFmt.PCM - cg.add(var.set_i2s_comm_fmt(fmt)) + + is_spdif = config.get(CONF_SPDIF_MODE, False) + if is_spdif: + cg.add_define("USE_I2S_AUDIO_SPDIF_MODE") + else: + fmt = I2SCommFmt.STANDARD # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long + if config[CONF_I2S_COMM_FMT] in ["stand_msb", "i2s_lsb"]: + fmt = I2SCommFmt.MSB + elif config[CONF_I2S_COMM_FMT] in ["stand_pcm_short", "pcm_short", "pcm"]: + fmt = I2SCommFmt.PCM + cg.add(var.set_i2s_comm_fmt(fmt)) + if config[CONF_TIMEOUT] != CONF_NEVER: cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp new file mode 100644 index 0000000000..e2146de63c --- /dev/null +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -0,0 +1,629 @@ +#include "i2s_audio_spdif.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/audio/audio_transfer_buffer.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esp_timer.h" + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.spdif"; + +// SPDIF mode adds overhead as each sample is encapsulated in a subframe; +// each DMA buffer can hold only 192 samples (~4ms each vs. ~15ms for standard I2S). +// To match the standard I2S buffering duration, we use more buffers to minimize +// the impact of the overhead, such as stuttering or audio/silence oscillation. +// 15 buffers x 4ms = 60ms of DMA buffering (same as 4 x 15ms for standard) +static constexpr size_t SPDIF_DMA_BUFFERS_COUNT = 15; + +// Timeout for flushing pending frames if no callback received. +static constexpr uint32_t SPDIF_FLUSH_TIMEOUT_MS = 20; + +// Number of DMA events between upstream callbacks (~16ms = 4 events x 4ms each). +// Matches non-SPDIF timing to prevent overwhelming upstream sync algorithms. +static constexpr uint32_t SPDIF_DMA_EVENTS_PER_CALLBACK = 4; + +// Consider TX stalled only if no DMA callbacks have arrived for this long. +// Zero-block non-blocking writes alone are not sufficient (they can happen when DMA is simply full). +static constexpr uint32_t SPDIF_STALL_NO_DMA_MS = 80; + +// Fallback stall detector: force recovery if silence writes make no forward progress for too long, +// even if occasional DMA callbacks are still observed. +static constexpr uint32_t SPDIF_STALL_ZERO_PROGRESS_MS = 1000; + +// Minimum spacing between re-prime attempts to avoid churn. +static constexpr uint32_t SPDIF_REPRIME_COOLDOWN_MS = 500; + +// Small waits used in SPDIF mode to keep DMA fed during rapid pipeline churn. +static constexpr uint32_t SPDIF_EMPTY_READ_DELAY_MS = 1; +static constexpr uint32_t SPDIF_SILENCE_LOOP_DELAY_MS = 1; +static constexpr uint32_t SPDIF_PLAY_RETRY_WAIT_MS = 5; + +static constexpr size_t SPDIF_I2S_EVENT_QUEUE_COUNT = SPDIF_DMA_BUFFERS_COUNT + 1; + +// Static silence buffer for SPDIF continuous mode +// 192 samples * 2 channels * 2 bytes per sample = 768 bytes +// Stored in flash (.rodata section) to avoid stack/heap usage +static const int16_t SPDIF_SILENCE_BUFFER[SPDIF_BLOCK_SAMPLES * 2] = {0}; + +// Static callback functions for SPDIF encoder (avoids std::function overhead) +static esp_err_t spdif_preload_cb(void *user_ctx, uint32_t *data, size_t size, TickType_t ticks_to_wait) { + auto *speaker = static_cast(user_ctx); + size_t bytes_written = 0; + esp_err_t err = i2s_channel_preload_data(speaker->get_tx_handle(), data, size, &bytes_written); + if (err != ESP_OK || bytes_written != size) { + ESP_LOGW(TAG, "Preload failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + return (err != ESP_OK) ? err : ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, TickType_t ticks_to_wait) { + auto *speaker = static_cast(user_ctx); + size_t bytes_written = 0; + esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait); + // ESP_ERR_TIMEOUT is expected under DMA backpressure in SPDIF mode. + if (err != ESP_OK && err != ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + } + return err; +} + +void I2SAudioSpeakerSPDIF::setup() { + I2SAudioSpeakerBase::setup(); + if (this->is_failed()) { + return; + } + + this->spdif_encoder_ = new SPDIFEncoder(); + if (!this->spdif_encoder_->setup()) { + ESP_LOGE(TAG, "Encoder setup failed"); + this->mark_failed(); + return; + } + + // Configure channel status block with the sample rate + this->spdif_encoder_->set_sample_rate(this->sample_rate_); + + // Separate callbacks for preload (during underflow recovery) and normal writes + this->spdif_encoder_->set_preload_callback(spdif_preload_cb, this); + this->spdif_encoder_->set_write_callback(spdif_write_cb, this); +} + +void I2SAudioSpeakerSPDIF::dump_config() { + I2SAudioSpeakerBase::dump_config(); + ESP_LOGCONFIG(TAG, + " SPDIF Mode: YES\n" + " Sample Rate: %" PRIu32 " Hz", + this->sample_rate_); +} + +void I2SAudioSpeakerSPDIF::on_task_stopped() { this->spdif_silence_start_ = 0; } + +size_t I2SAudioSpeakerSPDIF::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { + if (this->is_failed()) { + ESP_LOGE(TAG, "Setup failed; cannot play audio"); + return 0; + } + + // In SPDIF mode, keep accepting upstream audio while the speaker task is active. + // This avoids transient drops during stop/start transitions. + const bool task_active = (this->speaker_task_handle_ != nullptr); + + if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) { + this->start(); + } + + if (!task_active && this->state_ != speaker::STATE_RUNNING) { + // Unable to write data to a running speaker, so delay the max amount of time so it can get ready + vTaskDelay(ticks_to_wait); + ticks_to_wait = 0; + } + + size_t bytes_written = 0; + if (this->state_ == speaker::STATE_RUNNING || task_active) { + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { + // In SPDIF mode, a tiny wait helps avoid transient 0-byte writes during short backpressure windows. + TickType_t effective_ticks_to_wait = ticks_to_wait; + if (effective_ticks_to_wait == 0) { + effective_ticks_to_wait = pdMS_TO_TICKS(1); + } + bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, effective_ticks_to_wait); + if (bytes_written == 0 && length > 0) { + // Retry once to catch short free-space windows during rapid seek/track transitions. + bytes_written = + temp_ring_buffer->write_without_replacement((void *) data, length, pdMS_TO_TICKS(SPDIF_PLAY_RETRY_WAIT_MS)); + } + } + } + + return bytes_written; +} + +void I2SAudioSpeakerSPDIF::run_speaker_task() { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + + // Reset SPDIF encoder at task start to ensure clean state + // (previous task may have left stale data in encoder buffer) + if (this->spdif_encoder_ != nullptr) { + this->spdif_encoder_->reset(); + } + + const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT; + // Ensure ring buffer duration is at least the duration of all DMA buffers + const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); + + // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info + const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration); + + // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames + const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; + const size_t bytes_to_fill_single_dma_buffer = + this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + + bool successful_setup = false; + std::unique_ptr transfer_buffer = + audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer); + + if (transfer_buffer != nullptr) { + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer.use_count() == 1) { + transfer_buffer->set_source(temp_ring_buffer); + this->audio_ring_buffer_ = temp_ring_buffer; + successful_setup = true; + } + } + + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { + // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. + // This ensures the first data transmitted is valid SPDIF (not raw zeros from + // auto_clear) and prevents phantom DMA events before real audio is available. + // Track how many buffers were preloaded so the DMA event loop can skip + // frame accounting until the preloaded silence has fully drained. + uint32_t preload_buffers_remaining = 0; + this->spdif_encoder_->set_preload_mode(true); + for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { + uint32_t preload_blocks = 0; + esp_err_t preload_err = this->spdif_encoder_->write(reinterpret_cast(SPDIF_SILENCE_BUFFER), + sizeof(SPDIF_SILENCE_BUFFER), + pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS), &preload_blocks); + if (preload_err != ESP_OK || preload_blocks == 0) { + break; // DMA buffers full or error + } + preload_buffers_remaining += preload_blocks; + } + this->spdif_encoder_->set_preload_mode(false); + this->spdif_encoder_->reset(); // Clean encoder state for the main loop + + // Now register the callback and enable the channel + xQueueReset(this->i2s_event_queue_); + const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + i2s_channel_enable(this->tx_handle_); + + bool stop_gracefully = false; + bool tx_dma_underflow = true; + + uint32_t frames_written = 0; + + // SPDIF Continuous Silence Mode + Callback Decimation + // + // Key principles: + // 1. NEVER stop the I2S channel - always output a valid SPDIF stream + // 2. When no audio data, output silence-encoded SPDIF blocks (not zeros!) + // 3. Fire callbacks every 4 DMA events (~16ms), matching non-SPDIF timing + // + // This eliminates gaps that cause SPDIF receivers to re-sync, and reduces + // callback rate to prevent overwhelming upstream sync algorithms. + const uint32_t spdif_callback_threshold = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); + uint32_t spdif_pending_frames = 0; + int64_t spdif_pending_timestamp = 0; + uint32_t spdif_last_callback_time = millis(); + // Count DMA events for decimation + uint32_t spdif_dma_event_count = 0; + uint32_t spdif_last_dma_event_time = millis(); + // Detect a stalled DMA path (many silence write attempts with zero accepted blocks). + uint32_t spdif_zero_block_streak = 0; + uint32_t spdif_last_block_progress_time = millis(); + uint32_t spdif_last_reprime_time = 0; + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); + + // SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data + // to keep the receiver synced. Exits only via break (stream info change or silence timeout). + while (true) { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); + // In SPDIF continuous mode, don't tear down or expose STOPPED here. + // Keep the task alive and transition to silence output. + this->spdif_silence_start_ = millis(); + ESP_LOGV(TAG, "COMMAND_STOP received, continuing in silence mode"); + } + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); + stop_gracefully = true; + } + + if (this->audio_stream_info_ != this->current_stream_info_) { + // Audio stream info changed, stop the speaker task so it will restart with the proper settings. + ESP_LOGV(TAG, "Exiting: stream info changed"); + break; + } + + int64_t write_timestamp; + while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) { + spdif_last_dma_event_time = millis(); + + // Skip frame accounting for preloaded silence buffers still draining. + // These DMA events correspond to silence that was preloaded before the + // channel was enabled, not real audio written by the task. + if (preload_buffers_remaining > 0) { + preload_buffers_remaining--; + continue; + } + + // Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes + // on the timing info via the audio_output_callback. + uint32_t frames_sent = frames_to_fill_single_dma_buffer; + if (frames_to_fill_single_dma_buffer > frames_written) { + tx_dma_underflow = true; + frames_sent = frames_written; + const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written; + write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed); + } else { + tx_dma_underflow = false; + } + frames_written -= frames_sent; + + // SPDIF Callback Decimation: fire every 4th DMA event (~16ms) + // This matches non-SPDIF timing and prevents overwhelming upstream. + if (spdif_callback_threshold > 0) { + spdif_dma_event_count++; + + // Accumulate frames; always keep the latest timestamp so the + // callback reports when the last sample left the wire, not the first. + if (frames_sent > 0) { + spdif_pending_timestamp = write_timestamp; + spdif_pending_frames += frames_sent; + } + + // Fire callback every 4 DMA events, or on timeout if we have pending frames + bool decimation_reached = (spdif_dma_event_count >= SPDIF_DMA_EVENTS_PER_CALLBACK); + bool timeout_flush = + (spdif_pending_frames > 0) && ((millis() - spdif_last_callback_time) >= SPDIF_FLUSH_TIMEOUT_MS); + + if (decimation_reached || timeout_flush) { + if (spdif_pending_frames > 0) { + this->audio_output_callback_(spdif_pending_frames, spdif_pending_timestamp); + spdif_pending_frames = 0; + spdif_last_callback_time = millis(); + } + spdif_dma_event_count = 0; // Reset decimation counter + } + } + } + + if (this->pause_state_) { + // Pause state is accessed atomically, so thread safe + // Delay so the task yields, then skip transferring audio data + vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); + continue; + } + + // Wait half the duration of the data already written to the DMA buffers for new audio data + // The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000 + uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2; + + // In SPDIF mode, if transfer buffer is empty (we're pumping silence), use a very short timeout. + // This ensures we can pump silence fast enough to keep the DMA fed (~250 blocks/sec needed). + // Otherwise the long timeout based on frames_written causes DMA to run dry. + if (transfer_buffer->available() == 0) { + read_delay = SPDIF_EMPTY_READ_DELAY_MS; + } + + size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay)); + uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read; + + if (bytes_read > 0) { + this->apply_software_volume_(new_data, bytes_read); + this->swap_esp32_mono_samples_(new_data, bytes_read); + } + + if (transfer_buffer->available() == 0) { + // SPDIF Continuous Silence Mode: always output valid SPDIF stream + // When no audio data, write silence-encoded blocks to keep receiver happy + if (this->spdif_encoder_ != nullptr) { + // "Graceful stop" means "drain buffered audio, then stop." In SPDIF + // continuous mode we never actually stop, so once audio is drained + // (we're here), reset the flag to re-enable silence writing and stall + // recovery. Without this, stop_gracefully stays true forever and + // blocks silence output, causing DMA to degrade on auto_clear zeros. + stop_gracefully = false; + + // Track when we entered silence mode + if (this->spdif_silence_start_ == 0) { + this->spdif_silence_start_ = millis(); + } + + // If silence persists past the configured timeout, stop the task + // so components expecting timeout semantics can recover. + if (this->timeout_.has_value()) { + const uint32_t silence_duration = millis() - this->spdif_silence_start_; + if (silence_duration >= this->timeout_.value()) { + ESP_LOGV(TAG, "Silence timeout reached (%" PRIu32 "ms) - stopping speaker", silence_duration); + break; + } + } + + // First flush any partial block with silence padding (non-blocking to avoid getting stuck). + // IMPORTANT: Credit any partial block frames to frames_written so the audio_output_callback_ + // fires for them. Without this, pending_playback_frames_ in the mixer's SourceSpeaker never + // reaches 0 when a stream ends on a non-192-frame boundary, permanently blocking teardown. + if (this->spdif_encoder_->has_pending_data()) { + uint32_t partial_frames = this->spdif_encoder_->get_pending_frames(); + // Use a tiny timeout to allow DMA queue progress without stalling the task. + esp_err_t flush_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(1)); + if (flush_err == ESP_OK && partial_frames > 0) { + frames_written += partial_frames; + } + } + + // CRITICAL: In SPDIF continuous mode, ALWAYS write silence when no audio data. + // We don't check tx_dma_underflow because: + // 1. When DMA runs empty, callbacks stop, so tx_dma_underflow doesn't update + // 2. The non-blocking write handles "DMA full" gracefully (just doesn't write) + // 3. We need continuous output to prevent receiver from losing sync + if (!stop_gracefully) { + uint32_t silence_blocks = 0; + esp_err_t write_err = this->spdif_encoder_->write( + reinterpret_cast(SPDIF_SILENCE_BUFFER), sizeof(SPDIF_SILENCE_BUFFER), pdMS_TO_TICKS(1), + &silence_blocks); // Non-blocking + // Don't count silence as frames_written - it's not real audio + + // Recovery path for a stalled SPDIF TX channel: + // if silence writes repeatedly produce zero blocks AND DMA callbacks have stopped, + // re-prime DMA using preload mode. + const uint32_t ms_since_dma = millis() - spdif_last_dma_event_time; + const bool dma_events_stalled = ms_since_dma >= SPDIF_STALL_NO_DMA_MS; + if (silence_blocks > 0) { + spdif_last_block_progress_time = millis(); + } + const bool long_zero_progress = (millis() - spdif_last_block_progress_time) >= SPDIF_STALL_ZERO_PROGRESS_MS; + if (dma_events_stalled && silence_blocks == 0 && (write_err == ESP_OK || write_err == ESP_ERR_TIMEOUT)) { + spdif_zero_block_streak++; + } else { + spdif_zero_block_streak = 0; + } + + const uint32_t now_ms = millis(); + const bool reprime_cooldown_elapsed = + (spdif_last_reprime_time == 0) || ((now_ms - spdif_last_reprime_time) >= SPDIF_REPRIME_COOLDOWN_MS); + + if ((spdif_zero_block_streak >= 100 || long_zero_progress) && reprime_cooldown_elapsed) { + ESP_LOGV(TAG, "TX appears stalled, attempting DMA re-prime"); + + i2s_channel_disable(this->tx_handle_); + + const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr}; + i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this); + + this->spdif_encoder_->set_preload_mode(true); + uint32_t preload_blocks = 0; + esp_err_t preload_err = this->spdif_encoder_->write( + reinterpret_cast(SPDIF_SILENCE_BUFFER), sizeof(SPDIF_SILENCE_BUFFER), + pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS), &preload_blocks); + this->spdif_encoder_->set_preload_mode(false); + + xQueueReset(this->i2s_event_queue_); + const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + i2s_channel_enable(this->tx_handle_); + + if (preload_err == ESP_OK && preload_blocks > 0) { + tx_dma_underflow = false; + preload_buffers_remaining = preload_blocks; + frames_written = 0; // Stale after channel disable/enable cycle + ESP_LOGV(TAG, "DMA re-prime successful (%" PRIu32 " preload blocks)", preload_blocks); + spdif_last_block_progress_time = now_ms; + } else { + ESP_LOGW(TAG, "DMA re-prime failed (%s, blocks=%" PRIu32 ")", esp_err_to_name(preload_err), + preload_blocks); + } + spdif_last_reprime_time = now_ms; + spdif_zero_block_streak = 0; + } + } + } + + if (stop_gracefully && tx_dma_underflow) { + // In SPDIF continuous mode, don't break on graceful stop during silence + // Keep outputting silence until new audio arrives or explicit COMMAND_STOP + // (handled above which transitions to silence mode rather than breaking) + } + + // In SPDIF mode, use a shorter delay to pump silence faster + // We need ~250 blocks/sec to keep DMA fed, so max 4ms per iteration + vTaskDelay(pdMS_TO_TICKS(SPDIF_SILENCE_LOOP_DELAY_MS)); + } else { + // Have audio data to write + size_t bytes_written = 0; + + // Clear silence timer since we have audio data now + if (this->spdif_silence_start_ != 0) { + uint32_t silence_duration = millis() - this->spdif_silence_start_; + if (silence_duration > 100) { + ESP_LOGV(TAG, "Exiting silence mode after %" PRIu32 "ms, have audio data", silence_duration); + } + this->spdif_silence_start_ = 0; + } + + { + uint32_t blocks_sent = 0; + size_t pcm_bytes_consumed = 0; + + // Write audio data to encoder (which writes to DMA) + esp_err_t err = + this->spdif_encoder_->write(transfer_buffer->get_buffer_start(), transfer_buffer->available(), + pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS), &blocks_sent, &pcm_bytes_consumed); + if (err != ESP_OK && err != ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "Write failed: %s", esp_err_to_name(err)); + } + + // Only consume source bytes that were actually accepted by the encoder. + bytes_written = pcm_bytes_consumed; + + // Update frame accounting based on complete blocks sent (192 frames per block) + if (bytes_written > 0) { + frames_written += blocks_sent * SPDIF_BLOCK_SAMPLES; + transfer_buffer->decrease_buffer_length(bytes_written); + // Audio blocks count as DMA progress for the stall detector. + // Without this, a long uninterrupted audio stream makes the + // progress timer stale, triggering a spurious re-prime the + // instant we transition to silence. + spdif_last_block_progress_time = millis(); + } + } + } + } + // If we reach here, the while loop exited - either via break or condition became false + // In SPDIF mode, loop exit is expected when: + // 1. Timeout reached (user configured timeout) + // 2. Stream info changed + // Only warn if timeout is "never" since that should never exit + if (!this->timeout_.has_value()) { + ESP_LOGW(TAG, "Unexpected loop exit; set 'timeout: never' to prevent this"); + } + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); + + // Reset SPDIF encoder state to prevent stale state on next start + if (this->spdif_encoder_ != nullptr) { + this->spdif_encoder_->set_preload_mode(false); + this->spdif_encoder_->reset(); + } + + if (transfer_buffer != nullptr) { + transfer_buffer.reset(); + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED); + + while (true) { + // Continuously delay until the loop method deletes the task + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { + this->current_stream_info_ = audio_stream_info; + + // SPDIF mode validation + if (this->sample_rate_ != audio_stream_info.get_sample_rate()) { + ESP_LOGE(TAG, "Only supports a single sample rate (configured: %" PRIu32 " Hz, stream: %" PRIu32 " Hz)", + this->sample_rate_, audio_stream_info.get_sample_rate()); + return ESP_ERR_NOT_SUPPORTED; + } + if (audio_stream_info.get_bits_per_sample() != 16) { + ESP_LOGE(TAG, "Only supports 16 bits per sample"); + return ESP_ERR_NOT_SUPPORTED; + } + if (audio_stream_info.get_channels() != 2) { + ESP_LOGE(TAG, "Only supports stereo (2 channels)"); + return ESP_ERR_NOT_SUPPORTED; + } + + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && + (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { + ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); + return ESP_ERR_NOT_SUPPORTED; + } + + if (!this->parent_->try_lock()) { + ESP_LOGE(TAG, "Parent bus is busy"); + return ESP_ERR_INVALID_STATE; + } + + i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; + +#if SOC_CLK_APLL_SUPPORTED + if (this->use_apll_) { + clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; + } +#endif // SOC_CLK_APLL_SUPPORTED + + // SPDIF mode: fixed configuration for BMC encoding + // For new driver, dma_frame_num is in I2S frames (8 bytes each for 32-bit stereo) + uint32_t dma_buffer_length = SPDIF_BLOCK_I2S_FRAMES; // One SPDIF block = 384 I2S frames = 3072 bytes + + // Log DMA configuration for debugging + ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames = %lu bytes total", (size_t) SPDIF_DMA_BUFFERS_COUNT, + (unsigned long) dma_buffer_length, + (unsigned long) (SPDIF_DMA_BUFFERS_COUNT * dma_buffer_length * 8)); // 8 bytes per frame for 32-bit stereo + + i2s_chan_config_t chan_cfg = { + .id = this->parent_->get_port(), + .role = this->i2s_role_, + .dma_desc_num = SPDIF_DMA_BUFFERS_COUNT, + .dma_frame_num = dma_buffer_length, + .auto_clear = true, + .intr_priority = 3, + }; + + // SPDIF: double sample rate for BMC, 32-bit stereo, only data pin needed + i2s_std_clk_config_t clk_cfg = { + .sample_rate_hz = this->sample_rate_ * 2, + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + }; + + i2s_std_slot_config_t slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO); + + i2s_std_gpio_config_t gpio_cfg = { + .mclk = GPIO_NUM_NC, + .bclk = GPIO_NUM_NC, + .ws = GPIO_NUM_NC, + .dout = this->dout_pin_, + .din = GPIO_NUM_NC, + .invert_flags = + { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = slot_cfg, + .gpio_cfg = gpio_cfg, + }; + + esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, SPDIF_I2S_EVENT_QUEUE_COUNT); + if (err != ESP_OK) { + return err; + } + + // Channel is NOT enabled here. The speaker task will preload DMA buffers + // with SPDIF-encoded silence before enabling, ensuring the first data on + // the wire is valid SPDIF (not raw zeros from auto_clear) and preventing + // phantom DMA events before real audio data is available. + + return ESP_OK; +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 && USE_I2S_AUDIO_SPDIF_MODE diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.h b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.h new file mode 100644 index 0000000000..ca7774123b --- /dev/null +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.h @@ -0,0 +1,34 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include "i2s_audio_speaker.h" +#include "spdif_encoder.h" + +namespace esphome::i2s_audio { + +/// @brief SPDIF speaker implementation. +/// Encodes PCM audio into IEC 60958-3 S/PDIF bitstream using BMC encoding, +/// outputting through a single I2S data pin. Maintains continuous output +/// (silence when no audio) to keep SPDIF receivers synchronized. +class I2SAudioSpeakerSPDIF : public I2SAudioSpeakerBase { + public: + void setup() override; + void dump_config() override; + + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + + protected: + void run_speaker_task() override; + esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + void on_task_stopped() override; + + SPDIFEncoder *spdif_encoder_{nullptr}; + uint32_t spdif_silence_start_{0}; // Timestamp when silence mode started (0 = not in silence) +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 && USE_I2S_AUDIO_SPDIF_MODE diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index a71b7db3ba..f34839a314 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -257,7 +257,7 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg); if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to initialize channel"); + ESP_LOGE(TAG, "Failed to initialize I2S channel"); i2s_del_channel(this->tx_handle_); this->tx_handle_ = nullptr; this->parent_->unlock(); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index c598ca1bf8..bfde455c75 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -18,7 +18,7 @@ namespace esphome::i2s_audio { -// Shared constants for I2S audio speaker implementations +// Shared constants used by both standard and SPDIF speaker implementations static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t TASK_STACK_SIZE = 4096; static constexpr ssize_t TASK_PRIORITY = 19; @@ -42,7 +42,7 @@ enum SpeakerEventGroupBits : uint32_t { /// @brief Abstract base class for I2S audio speaker implementations. /// Provides shared infrastructure (event groups, ring buffer, volume control, task lifecycle) -/// for derived I2S speaker classes. +/// for derived standard I2S and SPDIF speaker classes. class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public Component { public: float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; } diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp new file mode 100644 index 0000000000..a853f934bb --- /dev/null +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -0,0 +1,385 @@ +#include "spdif_encoder.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include "esphome/core/log.h" + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.spdif_encoder"; + +// S/PDIF preamble patterns (8 BMC bits each) +// These are the BMC-encoded sync patterns that violate normal BMC rules for easy detection. +// All preambles end at phase HIGH (last bit = 1), enabling consistent data encoding. +// Preamble is placed at bits 24-31 of word[0] for MSB-first transmission. +static constexpr uint8_t PREAMBLE_B = 0x17; // Block start (left channel, frame 0) +static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start) +static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel + +// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33 +// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33. +static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33; + +// Constexpr BMC encoder for compile-time LUT generation. +// Encodes with start phase=true (HIGH). The complement property allows phase=false +// via XOR: bmc_encode(v, N, false) == bmc_encode(v, N, true) ^ mask +static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) { + uint16_t bmc = 0; + bool phase = true; + for (uint8_t i = 0; i < num_bits; i++) { + bool bit = (data >> i) & 1; + uint8_t bmc_pair = phase ? (bit ? 0b01 : 0b00) : (bit ? 0b10 : 0b11); + bmc |= static_cast(bmc_pair) << ((num_bits - 1 - i) * 2); + if (!bit) + phase = !phase; + } + return bmc; +} + +// 4-bit BMC lookup table: 16 entries (16 bytes in flash) +// Index: 4-bit data value (0-15), always phase=true start +static constexpr auto BMC_LUT_4 = [] { + std::array t{}; + for (uint32_t i = 0; i < 16; i++) + t[i] = static_cast(bmc_lut_encode(i, 4)); + return t; +}(); + +// 8-bit BMC lookup table: 256 entries (512 bytes in flash) +// Index: 8-bit data value (0-255), always phase=true start +static constexpr auto BMC_LUT_8 = [] { + std::array t{}; + for (uint32_t i = 0; i < 256; i++) + t[i] = bmc_lut_encode(i, 8); + return t; +}(); + +// Initialize S/PDIF buffer +bool SPDIFEncoder::setup() { + this->spdif_block_buf_ = std::make_unique(SPDIF_BLOCK_SIZE_U32); + if (!this->spdif_block_buf_) { + ESP_LOGE(TAG, "Buffer allocation failed (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); + return false; + } + ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); + + // Build initial channel status block with default sample rate + this->build_channel_status_(); + + this->reset(); + return true; +} + +void SPDIFEncoder::reset() { + this->spdif_block_ptr_ = this->spdif_block_buf_.get(); + this->frame_in_block_ = 0; + this->is_left_channel_ = true; +} + +void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { + if (this->sample_rate_ != sample_rate) { + this->sample_rate_ = sample_rate; + this->build_channel_status_(); + ESP_LOGD(TAG, "Sample rate set to %lu Hz", (unsigned long) sample_rate); + } +} + +void SPDIFEncoder::build_channel_status_() { + // IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes) + // Transmitted LSB-first within each byte, one bit per frame via C bit + // + // Byte 0: Control bits + // Bit 0: 0 = Consumer format (not professional AES3) + // Bit 1: 0 = PCM audio (not non-audio data like AC3) + // Bit 2: 0 = No copyright assertion + // Bits 3-5: 000 = No pre-emphasis + // Bits 6-7: 00 = Mode 0 (basic consumer format) + // + // Byte 1: Category code (0x00 = general, 0x01 = CD, etc.) + // + // Byte 2: Source/channel numbers + // Bits 0-3: Source number (0 = unspecified) + // Bits 4-7: Channel number (0 = unspecified) + // + // Byte 3: Sample frequency and clock accuracy + // Bits 0-3: Sample frequency code + // Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32) + // Bits 6-7: Reserved (0) + // + // Bytes 4-23: Reserved (zeros for basic compliance) + + // Clear all bytes first + this->channel_status_.fill(0); + + // Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0 + // All bits are 0, which is already set + + // Byte 1: Category code = 0x00 (general) + // Already 0 + + // Byte 2: Source/channel unspecified + // Already 0 + + // Byte 3: Sample frequency code (bits 0-3) + clock accuracy (bits 4-5) + // Clock accuracy = 00 (Level II, ±1000 ppm) - appropriate for ESP32 + uint8_t freq_code; + switch (this->sample_rate_) { + case 44100: + freq_code = 0x0; // 0000 + break; + case 48000: + freq_code = 0x2; // 0010 + break; + default: + // Other values are possible but they're not supported by ESPHome + freq_code = 0x1; // 0001 = not indicated + ESP_LOGW(TAG, "Unsupported sample rate %lu Hz, channel status will indicate 'not specified'", + (unsigned long) this->sample_rate_); + break; + } + // Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5 + this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0 + + // Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.) +} + +HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) { + // ============================================================================ + // Build raw 32-bit subframe (IEC 60958 format) + // ============================================================================ + // Bit layout: + // Bits 0-3: Preamble (handled separately, not in raw_subframe) + // Bits 4-7: Auxiliary audio data (zeros for 16-bit audio) + // Bits 8-11: Audio LSB extension (zeros for 16-bit audio) + // Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field) + // Bit 28: V (Validity) - 0 = valid audio + // Bit 29: U (User data) - 0 + // Bit 30: C (Channel status) - from channel status block + // Bit 31: P (Parity) - even parity over bits 4-31 + // ============================================================================ + + // Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB) + uint32_t raw_subframe = (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); + + // V = 0 (valid audio), U = 0 (no user data) + // C = channel status bit for current frame (same bit used for both L and R subframes) + bool c_bit = this->get_channel_status_bit_(this->frame_in_block_); + if (c_bit) { + raw_subframe |= (1U << 30); + } + + // Calculate even parity over bits 4-30 + // This ensures consistent BMC ending phase regardless of audio content + uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30) + uint32_t ones_count = __builtin_popcount(bits_4_30); + uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even + raw_subframe |= parity << 31; // Set P bit to make total even + + // ============================================================================ + // Select preamble based on position in block and channel + // ============================================================================ + // B = block start (left channel, frame 0 of 192-frame block) + // M = left channel (frames 1-191) + // W = right channel (all frames) + uint8_t preamble; + if (this->is_left_channel_) { + preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + } else { + preamble = PREAMBLE_W; + } + + // ============================================================================ + // BMC encode the data portion (bits 4-31) using lookup tables + // ============================================================================ + // The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15. + // This applies to BOTH word[0] and word[1]. + // + // word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15] + // For correct S/PDIF subframe order (preamble → aux → audio): + // - bits 16-23: preamble (8 BMC bits) + // - bits 24-31: BMC(subframe bits 4-7) - first aux nibble + // - bits 0-7: BMC(subframe bits 8-11) - second aux nibble + // - bits 8-15: BMC(subframe bits 12-15) - audio low nibble + // + // word[1] transmission order: [16-31] → [0-15] + // For correct S/PDIF subframe order: + // - bits 16-31: BMC(subframe bits 16-23) - audio mid byte + // - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP + // ============================================================================ + + // All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio; + // two zero nibbles flip phase 8 times total → back to HIGH. + // So bits 12-15 always start encoding at phase=true. + + // Bits 12-15: 4-bit LUT lookup (always phase=true start) + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t bmc_12_15 = BMC_LUT_4[nibble]; + + // Phase tracking via branchless XOR mask: + // - 0x0000 means phase=true (use LUT value directly) + // - 0xFFFF means phase=false (complement LUT value) + // End phase = start XOR (popcount & 1) since zero-bits flip phase, + // and for even bit widths: #zeros parity == popcount parity. + uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF; + + // Bits 16-23: 8-bit LUT lookup with phase correction + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask; + phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF; + + // Bits 24-31: 8-bit LUT lookup with phase correction + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; + uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask; + + // ============================================================================ + // Combine with correct positioning for I2S transmission + // ============================================================================ + // I2S with halfword swap: transmits bits 16-31, then bits 0-15. + // Within each halfword, MSB (highest bit) is transmitted first. + // + // For upper halfword (bits 16-31): bit 31 → bit 16 + // For lower halfword (bits 0-15): bit 15 → bit 0 + // + // Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15 + // + // word[0] layout for correct transmission: + // bits 24-31: preamble (transmitted 1st, as MSB of upper halfword) + // bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7) + // bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11) + // bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble) + // + // word[1] layout: + // bits 16-31: bmc_16_23 (transmitted 5th) + // bits 0-15: bmc_24_31 (transmitted 6th) + this->spdif_block_ptr_[0] = + bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); + this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16); + this->spdif_block_ptr_ += 2; + + // ============================================================================ + // Update position tracking + // ============================================================================ + if (!this->is_left_channel_) { + // Completed a stereo frame, advance frame counter + if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { + this->frame_in_block_ = 0; + } + } + this->is_left_channel_ = !this->is_left_channel_; +} + +esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { + // Use the appropriate callback and context based on preload mode + SPDIFBlockCallback callback; + void *ctx; + + if (this->preload_mode_) { + callback = this->preload_callback_; + ctx = this->preload_callback_ctx_; + } else { + callback = this->write_callback_; + ctx = this->write_callback_ctx_; + } + + if (callback == nullptr) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err = callback(ctx, this->spdif_block_buf_.get(), SPDIF_BLOCK_SIZE_BYTES, ticks_to_wait); + + if (err == ESP_OK) { + // Reset pointer for next block; position tracking continues from where it left off + this->spdif_block_ptr_ = this->spdif_block_buf_.get(); + } + + return err; +} + +size_t SPDIFEncoder::get_pending_pcm_bytes() const { + if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) { + return 0; + } + // Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer + // So pending uint32s / 2 = pending samples, and each sample is 2 bytes + size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get(); + size_t pending_samples = pending_uint32s / 2; + return pending_samples * 2; // 2 bytes per sample +} + +HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed) { + const uint8_t *pcm_data = src; + const uint8_t *pcm_end = src + size; + uint32_t block_count = 0; + + while (pcm_data < pcm_end) { + // Check if there's a pending complete block from a previous failed send + if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + if (blocks_sent != nullptr) { + *blocks_sent = block_count; + } + if (bytes_consumed != nullptr) { + *bytes_consumed = pcm_data - src; + } + return err; + } + ++block_count; + } + + // Encode one 16-bit sample + this->encode_sample_(pcm_data); + pcm_data += 2; + } + + // Send any complete block that was just finished + if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + if (blocks_sent != nullptr) { + *blocks_sent = block_count; + } + if (bytes_consumed != nullptr) { + *bytes_consumed = pcm_data - src; + } + return err; + } + ++block_count; + } + + if (blocks_sent != nullptr) { + *blocks_sent = block_count; + } + if (bytes_consumed != nullptr) { + *bytes_consumed = size; + } + return ESP_OK; +} + +esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { + // First, send any pending complete block from a previous failed send + if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + return err; + } + } + + if (!this->has_pending_data()) { + return ESP_OK; // Nothing to flush + } + + // Encode silence (zeros) until the block is complete + static const uint8_t SILENCE[2] = {0, 0}; + + while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + this->encode_sample_(SILENCE); + } + + return this->send_block_(ticks_to_wait); +} + +} // namespace esphome::i2s_audio + +#endif // USE_I2S_AUDIO_SPDIF_MODE diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.h b/esphome/components/i2s_audio/speaker/spdif_encoder.h new file mode 100644 index 0000000000..8516643432 --- /dev/null +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.h @@ -0,0 +1,146 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include +#include +#include +#include +#include "esp_err.h" +#include "esphome/core/helpers.h" + +namespace esphome::i2s_audio { + +// A SPDIF sample is 64-bits +static constexpr uint8_t SPDIF_BITS_PER_SAMPLE = 64; +// Number of samples in a SPDIF block +static constexpr uint16_t SPDIF_BLOCK_SAMPLES = 192; +// To emulate bi-phase mark code (BMC) (aka differential Manchester encoding) we send twice +// as many bits per sample so that we can generate the transitions this encoding requires. +static constexpr uint8_t EMULATED_BMC_BITS_PER_SAMPLE = SPDIF_BITS_PER_SAMPLE * 2; +static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULATED_BMC_BITS_PER_SAMPLE / 8); +static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768 +// I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo) +static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames +// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels) +static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes + +/// Callback signature for block completion (raw function pointer for minimal overhead) +/// @param user_ctx User context pointer passed during callback registration +/// @param data Pointer to SPDIF encoded block data +/// @param size Size of the block in bytes (always SPDIF_BLOCK_SIZE_BYTES) +/// @param ticks_to_wait FreeRTOS ticks to wait for write completion +/// @return ESP_OK on success, or an error code +using SPDIFBlockCallback = esp_err_t (*)(void *user_ctx, uint32_t *data, size_t size, TickType_t ticks_to_wait); + +class SPDIFEncoder { + public: + /// @brief Initialize the SPDIF working buffer + /// @return true if setup was successful, false if allocation failed + bool setup(); + + /// @brief Set callback for normal writes (used when channel is running) + /// @param callback Function pointer to call when a block is ready + /// @param user_ctx Context pointer passed to callback (typically 'this' pointer of speaker) + void set_write_callback(SPDIFBlockCallback callback, void *user_ctx) { + this->write_callback_ = callback; + this->write_callback_ctx_ = user_ctx; + } + + /// @brief Set callback for preload writes (used when preloading to DMA before enabling channel) + /// @param callback Function pointer to call when a block is ready for preload + /// @param user_ctx Context pointer passed to callback (typically 'this' pointer of speaker) + void set_preload_callback(SPDIFBlockCallback callback, void *user_ctx) { + this->preload_callback_ = callback; + this->preload_callback_ctx_ = user_ctx; + } + + /// @brief Enable or disable preload mode + /// When in preload mode, completed blocks use the preload callback instead of write callback + void set_preload_mode(bool preload) { this->preload_mode_ = preload; } + + /// @brief Check if currently in preload mode + bool is_preload_mode() const { return this->preload_mode_; } + + /// @brief Convert PCM audio data to SPDIF BMC encoded data + /// @param src Source PCM audio data (16-bit stereo) + /// @param size Size of source data in bytes + /// @param ticks_to_wait Timeout for blocking writes + /// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent + /// @param bytes_consumed Optional pointer to receive the number of PCM bytes consumed from src + /// @return esp_err_t as returned from the callback + esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr, + size_t *bytes_consumed = nullptr); + + /// @brief Get the number of PCM bytes currently pending in the partial block buffer + /// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1) + size_t get_pending_pcm_bytes() const; + + /// @brief Get the number of PCM frames currently pending in the partial block buffer + /// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1) + uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; } + + /// @brief Check if there is a partial block pending + bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); } + + /// @brief Flush any pending partial block by padding with silence and sending + /// @param ticks_to_wait Timeout for blocking writes + /// @return esp_err_t as returned from the callback, or ESP_OK if nothing to flush + esp_err_t flush_with_silence(TickType_t ticks_to_wait); + + /// @brief Reset the SPDIF block buffer and position tracking, discarding any partial block + void reset(); + + /// @brief Set the sample rate for Channel Status Block encoding + /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000) + /// Call this before writing audio data to ensure correct channel status. + void set_sample_rate(uint32_t sample_rate); + + /// @brief Get the currently configured sample rate + uint32_t get_sample_rate() const { return this->sample_rate_; } + + protected: + /// @brief Encode a single 16-bit PCM sample into the current block position + HOT void encode_sample_(const uint8_t *pcm_sample); + + /// @brief Send the completed block via the appropriate callback + esp_err_t send_block_(TickType_t ticks_to_wait); + + /// @brief Build the channel status block from current configuration + void build_channel_status_(); + + /// @brief Get the channel status bit for a specific frame + /// @param frame Frame number (0-191) + /// @return The C bit value for this frame + ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const { + // Channel status is 192 bits transmitted over 192 frames + // Bit N is transmitted in frame N, LSB-first within each byte + return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1; + } + + // Member ordering optimized to minimize padding (largest alignment first) + + // 4-byte aligned members (pointers and uint32_t) + SPDIFBlockCallback write_callback_{nullptr}; + SPDIFBlockCallback preload_callback_{nullptr}; + void *write_callback_ctx_{nullptr}; + void *preload_callback_ctx_{nullptr}; + std::unique_ptr spdif_block_buf_; // Working buffer for SPDIF block (heap allocated) + uint32_t *spdif_block_ptr_{nullptr}; // Current position in block buffer + uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding + + // 1-byte aligned members (grouped together to avoid internal padding) + uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block + bool is_left_channel_{true}; // Alternates L/R for stereo samples + bool preload_mode_{false}; // Whether to use preload callback vs write callback + + // Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames) + // Placed last since std::array has 1-byte alignment + std::array channel_status_{}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_I2S_AUDIO_SPDIF_MODE diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 85454d3cc0..162a6034b8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -72,6 +72,7 @@ #define USE_GRAPHICAL_DISPLAY_MENU #define USE_HOMEASSISTANT_TIME #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT +#define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE #define USE_IMPROV_SERIAL #define USE_IMPROV_SERIAL_NEXT_URL diff --git a/tests/components/speaker/spdif_mode.esp32-idf.yaml b/tests/components/speaker/spdif_mode.esp32-idf.yaml new file mode 100644 index 0000000000..4d6859feae --- /dev/null +++ b/tests/components/speaker/spdif_mode.esp32-idf.yaml @@ -0,0 +1,25 @@ +substitutions: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + i2s_mclk_pin: GPIO25 + i2s_dout_pin: GPIO12 + spdif_data_pin: GPIO4 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +i2s_audio: + - id: i2s_output + +speaker: + - platform: i2s_audio + id: speaker_id + dac_type: external + i2s_dout_pin: ${spdif_data_pin} + spdif_mode: true + use_apll: true + timeout: 2s + sample_rate: 48000 + bits_per_sample: 16bit + channel: stereo + i2s_mode: primary From 70b9edfabebb9a433c6730eb4e22bd26a9406e64 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 8 May 2026 17:26:09 -0400 Subject: [PATCH 1128/2214] [i2s_audio] Refactor SPDIF output, fixing synchronization problems (#16319) --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 434 ++++++------------ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 44 +- .../i2s_audio/speaker/i2s_audio_speaker.h | 8 +- .../i2s_audio/speaker/spdif_encoder.cpp | 24 +- .../i2s_audio/speaker/spdif_encoder.h | 5 +- 5 files changed, 205 insertions(+), 310 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index e2146de63c..d257dd1d8f 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -23,35 +23,14 @@ static const char *const TAG = "i2s_audio.spdif"; // 15 buffers x 4ms = 60ms of DMA buffering (same as 4 x 15ms for standard) static constexpr size_t SPDIF_DMA_BUFFERS_COUNT = 15; -// Timeout for flushing pending frames if no callback received. -static constexpr uint32_t SPDIF_FLUSH_TIMEOUT_MS = 20; - // Number of DMA events between upstream callbacks (~16ms = 4 events x 4ms each). // Matches non-SPDIF timing to prevent overwhelming upstream sync algorithms. static constexpr uint32_t SPDIF_DMA_EVENTS_PER_CALLBACK = 4; -// Consider TX stalled only if no DMA callbacks have arrived for this long. -// Zero-block non-blocking writes alone are not sufficient (they can happen when DMA is simply full). -static constexpr uint32_t SPDIF_STALL_NO_DMA_MS = 80; - -// Fallback stall detector: force recovery if silence writes make no forward progress for too long, -// even if occasional DMA callbacks are still observed. -static constexpr uint32_t SPDIF_STALL_ZERO_PROGRESS_MS = 1000; - -// Minimum spacing between re-prime attempts to avoid churn. -static constexpr uint32_t SPDIF_REPRIME_COOLDOWN_MS = 500; - -// Small waits used in SPDIF mode to keep DMA fed during rapid pipeline churn. -static constexpr uint32_t SPDIF_EMPTY_READ_DELAY_MS = 1; -static constexpr uint32_t SPDIF_SILENCE_LOOP_DELAY_MS = 1; +// Brief retry wait used by play() to catch short free-space windows during rapid track transitions. static constexpr uint32_t SPDIF_PLAY_RETRY_WAIT_MS = 5; -static constexpr size_t SPDIF_I2S_EVENT_QUEUE_COUNT = SPDIF_DMA_BUFFERS_COUNT + 1; - -// Static silence buffer for SPDIF continuous mode -// 192 samples * 2 channels * 2 bytes per sample = 768 bytes -// Stored in flash (.rodata section) to avoid stack/heap usage -static const int16_t SPDIF_SILENCE_BUFFER[SPDIF_BLOCK_SAMPLES * 2] = {0}; +static constexpr size_t SPDIF_I2S_EVENT_QUEUE_COUNT = 2 * SPDIF_DMA_BUFFERS_COUNT; // Static callback functions for SPDIF encoder (avoids std::function overhead) static esp_err_t spdif_preload_cb(void *user_ctx, uint32_t *data, size_t size, TickType_t ticks_to_wait) { @@ -59,7 +38,7 @@ static esp_err_t spdif_preload_cb(void *user_ctx, uint32_t *data, size_t size, T size_t bytes_written = 0; esp_err_t err = i2s_channel_preload_data(speaker->get_tx_handle(), data, size, &bytes_written); if (err != ESP_OK || bytes_written != size) { - ESP_LOGW(TAG, "Preload failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + ESP_LOGV(TAG, "Preload failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); return (err != ESP_OK) ? err : ESP_ERR_NO_MEM; } return ESP_OK; @@ -69,9 +48,8 @@ static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, Tic auto *speaker = static_cast(user_ctx); size_t bytes_written = 0; esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait); - // ESP_ERR_TIMEOUT is expected under DMA backpressure in SPDIF mode. - if (err != ESP_OK && err != ESP_ERR_TIMEOUT) { - ESP_LOGW(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + if (err != ESP_OK) { + ESP_LOGV(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); } return err; } @@ -157,6 +135,9 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { this->spdif_encoder_->reset(); } + // Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_. + xQueueReset(this->write_records_queue_); + const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT; // Ensure ring buffer duration is at least the duration of all DMA buffers const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); @@ -188,19 +169,16 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. // This ensures the first data transmitted is valid SPDIF (not raw zeros from // auto_clear) and prevents phantom DMA events before real audio is available. - // Track how many buffers were preloaded so the DMA event loop can skip - // frame accounting until the preloaded silence has fully drained. - uint32_t preload_buffers_remaining = 0; + // Each preloaded block pushes a 0-real-frame record so that the corresponding + // on_sent events drain in lockstep without crediting any audio frames. this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { - uint32_t preload_blocks = 0; - esp_err_t preload_err = this->spdif_encoder_->write(reinterpret_cast(SPDIF_SILENCE_BUFFER), - sizeof(SPDIF_SILENCE_BUFFER), - pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS), &preload_blocks); - if (preload_err != ESP_OK || preload_blocks == 0) { - break; // DMA buffers full or error + esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); + if (preload_err != ESP_OK) { + break; // DMA preload buffer full or error } - preload_buffers_remaining += preload_blocks; + const uint32_t silence_record = 0; + xQueueSendToBack(this->write_records_queue_, &silence_record, 0); } this->spdif_encoder_->set_preload_mode(false); this->spdif_encoder_->reset(); // Clean encoder state for the main loop @@ -211,299 +189,193 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); i2s_channel_enable(this->tx_handle_); - bool stop_gracefully = false; - bool tx_dma_underflow = true; + // Always-fill model: each iteration produces exactly one SPDIF block (= one DMA buffer). + // We drain real PCM up to one block from the ring buffer and silence-pad any remainder. + // Blocking writes pace the loop at the DMA consumption rate. This mirrors the standard + // I2S speaker pattern (PR #16317): fill what you can, then silence-pad whatever is still + // missing to complete the DMA buffer. + const uint32_t block_duration_us = this->current_stream_info_.frames_to_microseconds(SPDIF_BLOCK_SAMPLES); + // Sized to absorb the worst case where every DMA buffer is full when we issue the write. + const TickType_t write_timeout_ticks = + pdMS_TO_TICKS(((block_duration_us * (SPDIF_DMA_BUFFERS_COUNT + 1)) + 999) / 1000); + // Brief read budget when the ring buffer is empty (~half a block). + const TickType_t read_timeout_ticks = pdMS_TO_TICKS(((block_duration_us / 2) + 999) / 1000); - uint32_t frames_written = 0; - - // SPDIF Continuous Silence Mode + Callback Decimation - // - // Key principles: - // 1. NEVER stop the I2S channel - always output a valid SPDIF stream - // 2. When no audio data, output silence-encoded SPDIF blocks (not zeros!) - // 3. Fire callbacks every 4 DMA events (~16ms), matching non-SPDIF timing - // - // This eliminates gaps that cause SPDIF receivers to re-sync, and reduces - // callback rate to prevent overwhelming upstream sync algorithms. - const uint32_t spdif_callback_threshold = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); + // SPDIF Callback Decimation: fire every 4th DMA event (~16ms), matching non-SPDIF timing. uint32_t spdif_pending_frames = 0; int64_t spdif_pending_timestamp = 0; - uint32_t spdif_last_callback_time = millis(); - // Count DMA events for decimation uint32_t spdif_dma_event_count = 0; - uint32_t spdif_last_dma_event_time = millis(); - // Detect a stalled DMA path (many silence write attempts with zero accepted blocks). - uint32_t spdif_zero_block_streak = 0; - uint32_t spdif_last_block_progress_time = millis(); - uint32_t spdif_last_reprime_time = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); // SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data - // to keep the receiver synced. Exits only via break (stream info change or silence timeout). + // to keep the receiver synced. Exits only via break (stream info change, silence timeout, + // lockstep desync, dropped event, or partial-write failure). while (true) { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); - // In SPDIF continuous mode, don't tear down or expose STOPPED here. - // Keep the task alive and transition to silence output. + // The ISR pairs COMMAND_STOP with ERR_DROPPED_EVENT when it has to discard a completion + // event; that desyncs the lockstep queues permanently and the only safe recovery is a full + // task restart. + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGV(TAG, "Exiting: ISR dropped event, restarting to recover lockstep"); + break; + } + // User-initiated stop. In SPDIF continuous mode, transition to silence output rather + // than tearing the task down. this->spdif_silence_start_ = millis(); ESP_LOGV(TAG, "COMMAND_STOP received, continuing in silence mode"); } if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { + // SPDIF continuous mode never tears the channel down on graceful stop. Clear the flag and + // let the audio simply drain through the always-fill loop into the silence-timeout path. xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); - stop_gracefully = true; } if (this->audio_stream_info_ != this->current_stream_info_) { - // Audio stream info changed, stop the speaker task so it will restart with the proper settings. ESP_LOGV(TAG, "Exiting: stream info changed"); break; } + // Drain ISR completion events, popping a matching record for each. int64_t write_timestamp; + bool lockstep_broken = false; while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) { - spdif_last_dma_event_time = millis(); - - // Skip frame accounting for preloaded silence buffers still draining. - // These DMA events correspond to silence that was preloaded before the - // channel was enabled, not real audio written by the task. - if (preload_buffers_remaining > 0) { - preload_buffers_remaining--; - continue; + // Lockstep: pop the matching record (real audio frames packed into this DMA block). + // Records are pushed by the task right after each successful block commit, so the FIFO + // order matches DMA completion order. Empty records queue here means lockstep broke. + uint32_t real_frames = 0; + if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { + ESP_LOGV(TAG, "Event without matching write record"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + lockstep_broken = true; + break; } - // Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes - // on the timing info via the audio_output_callback. - uint32_t frames_sent = frames_to_fill_single_dma_buffer; - if (frames_to_fill_single_dma_buffer > frames_written) { - tx_dma_underflow = true; - frames_sent = frames_written; - const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written; + // Per-block timestamp adjustment: shift back by the silence-padding portion of the block + // so the reported timestamp reflects when the last real sample left the wire. + uint32_t frames_sent = real_frames; + if (real_frames < SPDIF_BLOCK_SAMPLES) { + const uint32_t frames_zeroed = SPDIF_BLOCK_SAMPLES - real_frames; write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed); - } else { - tx_dma_underflow = false; } - frames_written -= frames_sent; - // SPDIF Callback Decimation: fire every 4th DMA event (~16ms) - // This matches non-SPDIF timing and prevents overwhelming upstream. - if (spdif_callback_threshold > 0) { - spdif_dma_event_count++; + spdif_dma_event_count++; + // Accumulate frames; keep the latest timestamp so the callback reports when the last + // sample left the wire, not the first. + if (frames_sent > 0) { + spdif_pending_timestamp = write_timestamp; + spdif_pending_frames += frames_sent; + } - // Accumulate frames; always keep the latest timestamp so the - // callback reports when the last sample left the wire, not the first. - if (frames_sent > 0) { - spdif_pending_timestamp = write_timestamp; - spdif_pending_frames += frames_sent; - } + bool decimation_reached = (spdif_dma_event_count >= SPDIF_DMA_EVENTS_PER_CALLBACK); + // Partial blocks mark an end-of-stream boundary (silence-padded tail). Fire immediately + // so the back-shifted timestamp isn't overwritten by a later full audio block landing + // in the same decimation window. + bool partial_flush = (real_frames > 0 && real_frames < SPDIF_BLOCK_SAMPLES); - // Fire callback every 4 DMA events, or on timeout if we have pending frames - bool decimation_reached = (spdif_dma_event_count >= SPDIF_DMA_EVENTS_PER_CALLBACK); - bool timeout_flush = - (spdif_pending_frames > 0) && ((millis() - spdif_last_callback_time) >= SPDIF_FLUSH_TIMEOUT_MS); - - if (decimation_reached || timeout_flush) { - if (spdif_pending_frames > 0) { - this->audio_output_callback_(spdif_pending_frames, spdif_pending_timestamp); - spdif_pending_frames = 0; - spdif_last_callback_time = millis(); - } - spdif_dma_event_count = 0; // Reset decimation counter + if (decimation_reached || partial_flush) { + if (spdif_pending_frames > 0) { + this->audio_output_callback_(spdif_pending_frames, spdif_pending_timestamp); + spdif_pending_frames = 0; } + spdif_dma_event_count = 0; } } - - if (this->pause_state_) { - // Pause state is accessed atomically, so thread safe - // Delay so the task yields, then skip transferring audio data - vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); - continue; + if (lockstep_broken) { + ESP_LOGV(TAG, "Exiting: lockstep desync, restarting task"); + break; } - // Wait half the duration of the data already written to the DMA buffers for new audio data - // The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000 - uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2; + // Always-fill: produce exactly one SPDIF block this iteration. The blocking encoder write + // paces the task at the DMA consumption rate. + uint32_t real_frames_in_block = 0; + bool block_committed = false; + bool partial_write_failure = false; - // In SPDIF mode, if transfer buffer is empty (we're pumping silence), use a very short timeout. - // This ensures we can pump silence fast enough to keep the DMA fed (~250 blocks/sec needed). - // Otherwise the long timeout based on frames_written causes DMA to run dry. - if (transfer_buffer->available() == 0) { - read_delay = SPDIF_EMPTY_READ_DELAY_MS; - } - - size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay)); - uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read; - - if (bytes_read > 0) { - this->apply_software_volume_(new_data, bytes_read); - this->swap_esp32_mono_samples_(new_data, bytes_read); - } - - if (transfer_buffer->available() == 0) { - // SPDIF Continuous Silence Mode: always output valid SPDIF stream - // When no audio data, write silence-encoded blocks to keep receiver happy - if (this->spdif_encoder_ != nullptr) { - // "Graceful stop" means "drain buffered audio, then stop." In SPDIF - // continuous mode we never actually stop, so once audio is drained - // (we're here), reset the flag to re-enable silence writing and stall - // recovery. Without this, stop_gracefully stays true forever and - // blocks silence output, causing DMA to degrade on auto_clear zeros. - stop_gracefully = false; - - // Track when we entered silence mode - if (this->spdif_silence_start_ == 0) { - this->spdif_silence_start_ = millis(); + if (!this->pause_state_) { + while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) { + if (transfer_buffer->available() == 0) { + size_t bytes_read = transfer_buffer->transfer_data_from_source(read_timeout_ticks); + if (bytes_read == 0) { + break; // No upstream data within the read budget; silence-pad the remainder. + } + uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read; + this->apply_software_volume_(new_data, bytes_read); + this->swap_esp32_mono_samples_(new_data, bytes_read); } - // If silence persists past the configured timeout, stop the task - // so components expecting timeout semantics can recover. - if (this->timeout_.has_value()) { - const uint32_t silence_duration = millis() - this->spdif_silence_start_; - if (silence_duration >= this->timeout_.value()) { - ESP_LOGV(TAG, "Silence timeout reached (%" PRIu32 "ms) - stopping speaker", silence_duration); - break; - } - } + const uint32_t frames_still_needed = SPDIF_BLOCK_SAMPLES - real_frames_in_block; + const size_t bytes_still_needed = this->current_stream_info_.frames_to_bytes(frames_still_needed); + const size_t bytes_to_feed = std::min(transfer_buffer->available(), bytes_still_needed); - // First flush any partial block with silence padding (non-blocking to avoid getting stuck). - // IMPORTANT: Credit any partial block frames to frames_written so the audio_output_callback_ - // fires for them. Without this, pending_playback_frames_ in the mixer's SourceSpeaker never - // reaches 0 when a stream ends on a non-192-frame boundary, permanently blocking teardown. - if (this->spdif_encoder_->has_pending_data()) { - uint32_t partial_frames = this->spdif_encoder_->get_pending_frames(); - // Use a tiny timeout to allow DMA queue progress without stalling the task. - esp_err_t flush_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(1)); - if (flush_err == ESP_OK && partial_frames > 0) { - frames_written += partial_frames; - } - } - - // CRITICAL: In SPDIF continuous mode, ALWAYS write silence when no audio data. - // We don't check tx_dma_underflow because: - // 1. When DMA runs empty, callbacks stop, so tx_dma_underflow doesn't update - // 2. The non-blocking write handles "DMA full" gracefully (just doesn't write) - // 3. We need continuous output to prevent receiver from losing sync - if (!stop_gracefully) { - uint32_t silence_blocks = 0; - esp_err_t write_err = this->spdif_encoder_->write( - reinterpret_cast(SPDIF_SILENCE_BUFFER), sizeof(SPDIF_SILENCE_BUFFER), pdMS_TO_TICKS(1), - &silence_blocks); // Non-blocking - // Don't count silence as frames_written - it's not real audio - - // Recovery path for a stalled SPDIF TX channel: - // if silence writes repeatedly produce zero blocks AND DMA callbacks have stopped, - // re-prime DMA using preload mode. - const uint32_t ms_since_dma = millis() - spdif_last_dma_event_time; - const bool dma_events_stalled = ms_since_dma >= SPDIF_STALL_NO_DMA_MS; - if (silence_blocks > 0) { - spdif_last_block_progress_time = millis(); - } - const bool long_zero_progress = (millis() - spdif_last_block_progress_time) >= SPDIF_STALL_ZERO_PROGRESS_MS; - if (dma_events_stalled && silence_blocks == 0 && (write_err == ESP_OK || write_err == ESP_ERR_TIMEOUT)) { - spdif_zero_block_streak++; - } else { - spdif_zero_block_streak = 0; - } - - const uint32_t now_ms = millis(); - const bool reprime_cooldown_elapsed = - (spdif_last_reprime_time == 0) || ((now_ms - spdif_last_reprime_time) >= SPDIF_REPRIME_COOLDOWN_MS); - - if ((spdif_zero_block_streak >= 100 || long_zero_progress) && reprime_cooldown_elapsed) { - ESP_LOGV(TAG, "TX appears stalled, attempting DMA re-prime"); - - i2s_channel_disable(this->tx_handle_); - - const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr}; - i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this); - - this->spdif_encoder_->set_preload_mode(true); - uint32_t preload_blocks = 0; - esp_err_t preload_err = this->spdif_encoder_->write( - reinterpret_cast(SPDIF_SILENCE_BUFFER), sizeof(SPDIF_SILENCE_BUFFER), - pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS), &preload_blocks); - this->spdif_encoder_->set_preload_mode(false); - - xQueueReset(this->i2s_event_queue_); - const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; - i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); - i2s_channel_enable(this->tx_handle_); - - if (preload_err == ESP_OK && preload_blocks > 0) { - tx_dma_underflow = false; - preload_buffers_remaining = preload_blocks; - frames_written = 0; // Stale after channel disable/enable cycle - ESP_LOGV(TAG, "DMA re-prime successful (%" PRIu32 " preload blocks)", preload_blocks); - spdif_last_block_progress_time = now_ms; - } else { - ESP_LOGW(TAG, "DMA re-prime failed (%s, blocks=%" PRIu32 ")", esp_err_to_name(preload_err), - preload_blocks); - } - spdif_last_reprime_time = now_ms; - spdif_zero_block_streak = 0; - } - } - } - - if (stop_gracefully && tx_dma_underflow) { - // In SPDIF continuous mode, don't break on graceful stop during silence - // Keep outputting silence until new audio arrives or explicit COMMAND_STOP - // (handled above which transitions to silence mode rather than breaking) - } - - // In SPDIF mode, use a shorter delay to pump silence faster - // We need ~250 blocks/sec to keep DMA fed, so max 4ms per iteration - vTaskDelay(pdMS_TO_TICKS(SPDIF_SILENCE_LOOP_DELAY_MS)); - } else { - // Have audio data to write - size_t bytes_written = 0; - - // Clear silence timer since we have audio data now - if (this->spdif_silence_start_ != 0) { - uint32_t silence_duration = millis() - this->spdif_silence_start_; - if (silence_duration > 100) { - ESP_LOGV(TAG, "Exiting silence mode after %" PRIu32 "ms, have audio data", silence_duration); - } - this->spdif_silence_start_ = 0; - } - - { uint32_t blocks_sent = 0; - size_t pcm_bytes_consumed = 0; - - // Write audio data to encoder (which writes to DMA) - esp_err_t err = - this->spdif_encoder_->write(transfer_buffer->get_buffer_start(), transfer_buffer->available(), - pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS), &blocks_sent, &pcm_bytes_consumed); - if (err != ESP_OK && err != ESP_ERR_TIMEOUT) { - ESP_LOGW(TAG, "Write failed: %s", esp_err_to_name(err)); + size_t pcm_consumed = 0; + esp_err_t err = this->spdif_encoder_->write(transfer_buffer->get_buffer_start(), bytes_to_feed, + write_timeout_ticks, &blocks_sent, &pcm_consumed); + if (err != ESP_OK) { + // A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer; + // resuming would credit the next iteration's bytes against an old block. Bail and + // let loop() restart the task with a clean encoder. + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + partial_write_failure = true; + break; } - // Only consume source bytes that were actually accepted by the encoder. - bytes_written = pcm_bytes_consumed; - - // Update frame accounting based on complete blocks sent (192 frames per block) - if (bytes_written > 0) { - frames_written += blocks_sent * SPDIF_BLOCK_SAMPLES; - transfer_buffer->decrease_buffer_length(bytes_written); - // Audio blocks count as DMA progress for the stall detector. - // Without this, a long uninterrupted audio stream makes the - // progress timer stale, triggering a spurious re-prime the - // instant we transition to silence. - spdif_last_block_progress_time = millis(); + if (pcm_consumed > 0) { + transfer_buffer->decrease_buffer_length(pcm_consumed); + real_frames_in_block += this->current_stream_info_.bytes_to_frames(pcm_consumed); + } + if (blocks_sent > 0) { + block_committed = true; + break; } } } - } - // If we reach here, the while loop exited - either via break or condition became false - // In SPDIF mode, loop exit is expected when: - // 1. Timeout reached (user configured timeout) - // 2. Stream info changed - // Only warn if timeout is "never" since that should never exit - if (!this->timeout_.has_value()) { - ESP_LOGW(TAG, "Unexpected loop exit; set 'timeout: never' to prevent this"); + + if (partial_write_failure) { + break; + } + + if (!block_committed) { + // Pad whatever real audio we managed to feed (if any) with silence to complete one block, + // or emit a full silence block if the encoder is empty. + esp_err_t err = this->spdif_encoder_->flush_with_silence(write_timeout_ticks); + if (err != ESP_OK) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + break; + } + } + + // One block committed to DMA; push exactly one record carrying its real-audio frame count. + // Failure here means the records queue is full, which violates the lockstep invariant. + if (xQueueSendToBack(this->write_records_queue_, &real_frames_in_block, 0) != pdTRUE) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + break; + } + + // Silence-timeout tracking and graceful-stop reset. + if (real_frames_in_block == 0) { + if (this->spdif_silence_start_ == 0) { + this->spdif_silence_start_ = millis(); + } + + if (this->timeout_.has_value()) { + const uint32_t silence_duration = millis() - this->spdif_silence_start_; + if (silence_duration >= this->timeout_.value()) { + ESP_LOGV(TAG, "Silence timeout reached (%" PRIu32 "ms) - stopping speaker", silence_duration); + break; + } + } + } else if (this->spdif_silence_start_ != 0) { + uint32_t silence_duration = millis() - this->spdif_silence_start_; + if (silence_duration > 100) { + ESP_LOGV(TAG, "Exiting silence mode after %" PRIu32 "ms, have audio data", silence_duration); + } + this->spdif_silence_start_ = 0; + } } } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index f34839a314..27961050e6 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -69,6 +69,17 @@ void I2SAudioSpeakerBase::loop() { } if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { ESP_LOGV(TAG, "Stopping"); + // Lockstep-breaking error bits are latched by the task and cleared along with all other bits + // when TASK_STOPPED is processed; log them here, exactly once, as the task winds down. + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync"); + } + if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) { + ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task"); + } + if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) { + ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task"); + } xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); this->state_ = speaker::STATE_STOPPING; } @@ -87,18 +98,11 @@ void I2SAudioSpeakerBase::loop() { this->state_ = speaker::STATE_STOPPED; } - // Log any errors encountered by the task if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) { ESP_LOGE(TAG, "Not enough memory"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); } - // Warn if any playback timestamp events are dropped, which drastically reduces synced playback accuracy - if (event_group_bits & SpeakerEventGroupBits::WARN_DROPPED_EVENT) { - ESP_LOGW(TAG, "Event dropped, synchronized playback accuracy is reduced"); - xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::WARN_DROPPED_EVENT); - } - // Handle the speaker's state switch (this->state_) { case speaker::STATE_STARTING: @@ -271,6 +275,22 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c xQueueReset(this->i2s_event_queue_); } + // Lockstep records queue. One record per in-flight DMA buffer; sized to match the I2S event queue + // so a fully-saturated DMA pipeline cannot overflow either side before drain. + if (this->write_records_queue_ == nullptr) { + this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t)); + } else { + xQueueReset(this->write_records_queue_); + } + + if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)"); + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + this->parent_->unlock(); + return ESP_ERR_NO_MEM; + } + return ESP_OK; } @@ -293,10 +313,16 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { - // Queue is full, so discard the oldest event and set the warning flag to inform the user + // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` + // and any per-buffer record queue maintained by the task are permanently desynced, so the task + // must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP + // (so the task bails immediately, closing the race where loop() could clear the error bit + // before the task observes it). int64_t dummy; xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1); - xEventGroupSetBitsFromISR(this_speaker->event_group_, SpeakerEventGroupBits::WARN_DROPPED_EVENT, &need_yield2); + xEventGroupSetBitsFromISR(this_speaker->event_group_, + SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP, + &need_yield2); } xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index bfde455c75..c57af2775b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -35,7 +35,11 @@ enum SpeakerEventGroupBits : uint32_t { ERR_ESP_NO_MEM = (1 << 19), - WARN_DROPPED_EVENT = (1 << 20), + ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event + ERR_PARTIAL_WRITE = (1 << 21), // a DMA write returned fewer bytes than requested (or the encoder + // failed to commit a complete block), which breaks the lockstep + // invariant for every subsequent event + ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits }; @@ -141,7 +145,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public TaskHandle_t speaker_task_handle_{nullptr}; EventGroupHandle_t event_group_{nullptr}; + // Lockstepped DMA buffer queues: i2s_event is outgoing, write_records is incoming QueueHandle_t i2s_event_queue_{nullptr}; + QueueHandle_t write_records_queue_{nullptr}; std::weak_ptr audio_ring_buffer_; diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp index a853f934bb..42a72346cc 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -358,25 +358,15 @@ HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ti } esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { - // First, send any pending complete block from a previous failed send - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - return err; + // If a complete block is already pending (from a previous failed send), emit just that block. + // Otherwise pad the partial block with silence (or generate a full silence block if empty) + // and send. Always emits exactly one block on success. + if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + static const uint8_t SILENCE[2] = {0, 0}; + while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + this->encode_sample_(SILENCE); } } - - if (!this->has_pending_data()) { - return ESP_OK; // Nothing to flush - } - - // Encode silence (zeros) until the block is complete - static const uint8_t SILENCE[2] = {0, 0}; - - while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - this->encode_sample_(SILENCE); - } - return this->send_block_(ticks_to_wait); } diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.h b/esphome/components/i2s_audio/speaker/spdif_encoder.h index 8516643432..8c5e068841 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.h +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.h @@ -85,9 +85,10 @@ class SPDIFEncoder { /// @brief Check if there is a partial block pending bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); } - /// @brief Flush any pending partial block by padding with silence and sending + /// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send, + /// or send a full silence block if nothing is pending. Always produces exactly one block on success. /// @param ticks_to_wait Timeout for blocking writes - /// @return esp_err_t as returned from the callback, or ESP_OK if nothing to flush + /// @return esp_err_t as returned from the callback esp_err_t flush_with_silence(TickType_t ticks_to_wait); /// @brief Reset the SPDIF block buffer and position tracking, discarding any partial block From 3abf2c99a2421827980fd5454458de295efab2ae Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Fri, 8 May 2026 17:35:47 -0400 Subject: [PATCH 1129/2214] [openthread] add coroutine-with-priority COMMUNICATION (#16318) --- esphome/components/openthread/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 21373b77df..bc1e91d6da 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -21,7 +21,12 @@ from esphome.const import ( CONF_USE_ADDRESS, PLATFORM_ESP32, ) -from esphome.core import CORE, TimePeriodMilliseconds +from esphome.core import ( + CORE, + CoroPriority, + TimePeriodMilliseconds, + coroutine_with_priority, +) import esphome.final_validate as fv from esphome.types import ConfigType @@ -223,6 +228,7 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate +@coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): # Re-enable openthread IDF component (excluded by default) include_builtin_idf_component("openthread") From 136525136565860489670ff9ca6c3ccbd0bc81e7 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 8 May 2026 21:36:06 +0000 Subject: [PATCH 1130/2214] [ota] Add bootloader update functionality to ota component (#16238) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/__main__.py | 66 +++++-- .../components/esphome/ota/ota_esphome.cpp | 17 +- esphome/components/ota/ota_backend.h | 3 + .../components/ota/ota_backend_esp_idf.cpp | 34 +++- esphome/components/ota/ota_backend_esp_idf.h | 13 ++ .../components/ota/ota_bootloader_esp_idf.cpp | 133 +++++++++++++ .../components/ota/ota_partitions_esp_idf.cpp | 53 +++--- esphome/core/__init__.py | 6 + esphome/espota2.py | 32 +++- tests/unit_tests/test_core.py | 17 ++ tests/unit_tests/test_espota2.py | 68 ++++++- tests/unit_tests/test_main.py | 179 +++++++++++++++++- 12 files changed, 570 insertions(+), 51 deletions(-) create mode 100644 esphome/components/ota/ota_bootloader_esp_idf.cpp diff --git a/esphome/__main__.py b/esphome/__main__.py index 825a502dbf..c1451c5faf 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1125,11 +1125,18 @@ def upload_program( # MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by # _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths. - if port_type in (PortType.SERIAL, PortType.BOOTSEL) and getattr( - args, "partition_table", False + is_partition_table = getattr(args, "partition_table", False) + is_bootloader = getattr(args, "bootloader", False) + if is_partition_table and is_bootloader: + raise EsphomeError( + "The options --partition-table and --bootloader can't be used together." + ) + option_string = "--partition-table" if is_partition_table else "--bootloader" + if port_type in (PortType.SERIAL, PortType.BOOTSEL) and ( + is_partition_table or is_bootloader ): raise EsphomeError( - "The option --partition-table can only be used for Over The Air updates." + f"The option {option_string} can only be used for Over The Air updates." ) if port_type == PortType.BOOTSEL: @@ -1158,9 +1165,9 @@ def upload_program( network_devices = _resolve_network_devices(devices, config, args) if chosen_platform == CONF_WEB_SERVER: - if getattr(args, "partition_table", False): + if is_partition_table or is_bootloader: raise EsphomeError( - "--partition-table is only supported with the esphome OTA platform; " + f"{option_string} is only supported with the esphome OTA platform; " "the web_server OTA path can only update the firmware image." ) binary = CORE.firmware_bin @@ -1228,25 +1235,34 @@ def _upload_via_native_api( remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD) + def check_partition_access(option_string: str) -> None: + if not ota_conf.get("allow_partition_access"): + raise EsphomeError( + f"The option {option_string} requires 'allow_partition_access: true' on the " + "esphome OTA platform in the device's YAML configuration. Add it, recompile, " + f"flash a build with the option enabled, and then retry {option_string}." + ) + binary = CORE.firmware_bin ota_type = espota2.OTA_TYPE_UPDATE_APP if getattr(args, "partition_table", False): # Fail fast if the resolved ESPHome OTA config does not enable allow_partition_access. # The device-side handshake also rejects this with "Device only supports app updates", # but checking here surfaces the misconfiguration before opening a network connection. - if not ota_conf.get("allow_partition_access"): - raise EsphomeError( - "The option --partition-table requires 'allow_partition_access: true' on the " - "esphome OTA platform in the device's YAML configuration. Add it, recompile, " - "flash a build with the option enabled, and then retry --partition-table." - ) + check_partition_access("--partition-table") binary = CORE.partition_table_bin ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE + elif getattr(args, "bootloader", False): + check_partition_access("--bootloader") + binary = CORE.bootloader_bin + ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER if getattr(args, "file", None) is not None: binary = Path(args.file) if ota_type == espota2.OTA_TYPE_UPDATE_PARTITION_TABLE: _validate_partition_table_binary(binary) + if ota_type == espota2.OTA_TYPE_UPDATE_BOOTLOADER: + _validate_bootloader_binary(binary) return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) @@ -1281,6 +1297,7 @@ def _upload_via_web_server( _PARTITION_TABLE_MAX_LEN = 0xC00 _ESP_PARTITION_MAGIC = 0x50AA _ESP_PARTITION_MAGIC_MD5 = 0xEBEB +_ESP_IMAGE_HEADER_MAGIC = 0xE9 def _validate_partition_table_binary(binary: Path) -> None: @@ -1326,6 +1343,28 @@ def _validate_partition_table_binary(binary: Path) -> None: ) +def _validate_bootloader_binary(binary: Path) -> None: + """Validate that ``binary`` looks like an ESP32 bootloader image.""" + try: + data = binary.read_bytes() + except OSError as err: + raise EsphomeError(f"Cannot read bootloader file '{binary}': {err}") from err + + if not data: + raise EsphomeError( + f"Bootloader file '{binary}' is empty. " + "This file does not look like an ESP32 bootloader." + ) + + first_magic = data[0] + if first_magic != _ESP_IMAGE_HEADER_MAGIC: + raise EsphomeError( + f"Bootloader file '{binary}' does not start with the expected " + f"image header magic 0x{_ESP_IMAGE_HEADER_MAGIC:02X} (got 0x{first_magic:02X}). " + "This file does not look like an ESP32 bootloader." + ) + + def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) @@ -2009,6 +2048,11 @@ def parse_args(argv): help="Upload as partition table (OTA).", action="store_true", ) + parser_upload.add_argument( + "--bootloader", + help="Upload as bootloader (OTA).", + action="store_true", + ) parser_logs = subparsers.add_parser( "logs", diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 5d3deca489..843028fc97 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -125,8 +125,11 @@ void ESPHomeOTAComponent::dump_config() { it = esp_partition_next(it); } esp_partition_iterator_release(it); -#endif -#endif + esp_bootloader_desc_t bootloader_desc; + esp_err_t err = esp_ota_get_bootloader_description(nullptr, &bootloader_desc); + ESP_LOGCONFIG(TAG, " Bootloader: ESP-IDF %s", (err == ESP_OK) ? bootloader_desc.idf_ver : "version unknown"); +#endif // USE_ESP32 +#endif // USE_OTA_PARTITIONS } void ESPHomeOTAComponent::loop() { @@ -336,7 +339,6 @@ void ESPHomeOTAComponent::handle_data_() { /// wakeable_delay() in read(); /// write() always returns immediately ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; - bool update_started = false; size_t total = 0; uint32_t last_progress = 0; uint32_t last_data_ms = 0; @@ -399,7 +401,6 @@ void ESPHomeOTAComponent::handle_data_() { error_code = this->backend_->begin(ota_size, ota_type); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) - update_started = true; // Acknowledge prepare OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK); @@ -510,8 +511,12 @@ void ESPHomeOTAComponent::handle_data_() { error: this->write_byte_(static_cast(error_code)); - // Abort backend before cleanup - cleanup_connection_() destroys the backend - if (this->backend_ != nullptr && update_started) { + // Abort backend before cleanup - cleanup_connection_() destroys the backend. + // Always call abort() unconditionally: backends register external partitions before + // esp_ota_begin (partition table / bootloader paths), and abort() is responsible for + // releasing those even if begin() failed before an OTA handle was opened. The IDF + // backend's esp_ota_abort(0) is documented as harmless. + if (this->backend_ != nullptr) { this->backend_->abort(); } diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 5888a8e12d..de236c1951 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -44,6 +44,8 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E, OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F, OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90, + OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91, + OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -58,6 +60,7 @@ enum OTAState { enum OTAType : uint8_t { OTA_TYPE_UPDATE_APP = 0x00, OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01, + OTA_TYPE_UPDATE_BOOTLOADER = 0x02, }; /** Listener interface for OTA state changes. diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 50a0988ba2..f391c1791a 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -31,7 +31,13 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) this->md5_.init(); return OTA_RESPONSE_OK; } - if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_BOOTLOADER) { + OTAResponseTypes result = this->prepare_bootloader_update_(image_size); + if (result != OTA_RESPONSE_OK) { + return result; + } + } + if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } #else @@ -55,6 +61,7 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); esp_ota_abort(this->update_handle_); this->update_handle_ = 0; if (err == ESP_ERR_INVALID_SIZE) { @@ -64,6 +71,14 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) } return OTA_RESPONSE_ERROR_UNKNOWN; } +#ifdef USE_OTA_PARTITIONS + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_BOOTLOADER) { + OTAResponseTypes result = this->setup_bootloader_staging_(); + if (result != OTA_RESPONSE_OK) { + return result; + } + } +#endif this->md5_.init(); return OTA_RESPONSE_OK; } @@ -85,13 +100,14 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { this->md5_.add(data, len); return OTA_RESPONSE_OK; } - if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } #endif esp_err_t err = esp_ota_write(this->update_handle_, data, len); this->md5_.add(data, len); if (err != ESP_OK) { + 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_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { @@ -114,12 +130,20 @@ OTAResponseTypes IDFOTABackend::end() { if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { return this->update_partition_table(); } - if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } #endif esp_err_t err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); + } +#ifdef USE_OTA_PARTITIONS + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_BOOTLOADER) { + return this->finalize_bootloader_update_(err); + } +#endif if (err == ESP_OK) { err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { @@ -146,6 +170,10 @@ void IDFOTABackend::abort() { esp_partition_deregister_external(this->partition_table_part_); this->partition_table_part_ = nullptr; } + if (this->bootloader_part_ != nullptr) { + esp_partition_deregister_external(this->bootloader_part_); + this->bootloader_part_ = nullptr; + } #endif // esp_ota_abort with handle 0 returns ESP_ERR_INVALID_ARG harmlessly, so this is safe whether // or not an update is in flight. diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 54fdd24f93..73dd685df6 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -39,6 +39,18 @@ class IDFOTABackend final { OTAResponseTypes validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, PartitionTablePlan &plan); OTAResponseTypes update_partition_table(); + OTAResponseTypes register_and_validate_partition_table_part_(); + // Defined in ota_bootloader_esp_idf.cpp: + OTAResponseTypes register_and_validate_bootloader_part_(); + OTAResponseTypes prepare_bootloader_update_(size_t image_size); + OTAResponseTypes setup_bootloader_staging_(); + OTAResponseTypes finalize_bootloader_update_(esp_err_t ota_end_err); + + // The OTA types that flow through esp_ota_begin/write/end. Partition-table updates take a + // separate code path that buffers the table in RAM and never touches the OTA handle. + bool is_app_or_bootloader_update_() const { + return this->ota_type_ == ota::OTA_TYPE_UPDATE_APP || this->ota_type_ == ota::OTA_TYPE_UPDATE_BOOTLOADER; + } #endif private: @@ -55,6 +67,7 @@ class IDFOTABackend final { size_t buf_written_{0}; size_t image_size_{0}; const esp_partition_t *partition_table_part_{nullptr}; + const esp_partition_t *bootloader_part_{nullptr}; ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; #endif }; diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp new file mode 100644 index 0000000000..062e4d0811 --- /dev/null +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -0,0 +1,133 @@ +#ifdef USE_ESP32 +#include "ota_backend_esp_idf.h" + +#include "esphome/core/defines.h" + +#ifdef USE_OTA_PARTITIONS +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::ota { + +static const char *const TAG = "ota.idf"; + +OTAResponseTypes IDFOTABackend::register_and_validate_bootloader_part_() { + // Register the bootloader partition + esp_err_t err = esp_partition_register_external(nullptr, ESP_PRIMARY_BOOTLOADER_OFFSET, ESP_BOOTLOADER_SIZE, + "PrimaryBTLDR", ESP_PARTITION_TYPE_BOOTLOADER, + ESP_PARTITION_SUBTYPE_BOOTLOADER_PRIMARY, &this->bootloader_part_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_register_external failed (bootloader) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; + } + + // Verify existing bootloader to make sure ESP_PRIMARY_BOOTLOADER_OFFSET is correct + esp_image_metadata_t data = {}; + const esp_partition_pos_t part_pos = { + .offset = this->bootloader_part_->address, + .size = this->bootloader_part_->size, + }; + err = esp_image_verify(ESP_IMAGE_VERIFY, &part_pos, &data); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_image_verify failed (existing bootloader) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; + } + return OTA_RESPONSE_OK; +} + +// Pre-esp_ota_begin: enforce size limit, register/verify the existing bootloader, and validate the +// partition table to confirm the bootloader region is at the expected offset (and therefore the +// expected size). The partition table registration is released here; abort() cleans up the +// bootloader registration if any later step fails. +OTAResponseTypes IDFOTABackend::prepare_bootloader_update_(size_t image_size) { + if (image_size > ESP_BOOTLOADER_SIZE) { + ESP_LOGE(TAG, "Length of received data exceeds the available bootloader size: expected <=%zu bytes, got %zu", + ESP_BOOTLOADER_SIZE, image_size); + return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; + } + OTAResponseTypes result = this->register_and_validate_bootloader_part_(); + if (result != OTA_RESPONSE_OK) { + return result; + } + result = this->register_and_validate_partition_table_part_(); + if (result != OTA_RESPONSE_OK) { + return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; + } + esp_partition_deregister_external(this->partition_table_part_); + this->partition_table_part_ = nullptr; + return OTA_RESPONSE_OK; +} + +// Post-esp_ota_begin: verify the staging app partition is large enough, erase it, and redirect the +// final write target to the bootloader partition. esp_ota_set_final_partition is called with +// `restore_old_data=false` because we erased the staging region in advance. +OTAResponseTypes IDFOTABackend::setup_bootloader_staging_() { + if (this->partition_->size < this->bootloader_part_->size) { + ESP_LOGE(TAG, "Staging partition too small"); + 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 + 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 + } + err = esp_ota_set_final_partition(this->update_handle_, this->bootloader_part_, false); + if (err != ESP_OK) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + ESP_LOGE(TAG, "esp_ota_set_final_partition failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; + } + return OTA_RESPONSE_OK; +} + +// After esp_ota_end: copy the staged image into the bootloader partition. esp_partition_copy is +// the only window in which a power loss can render the device unbootable; everything before this +// point either preserves the existing bootloader or fails harmlessly. After a successful copy the +// first sector of staging is wiped so the device can't accidentally boot from it, and the +// bootloader partition is deregistered. +OTAResponseTypes IDFOTABackend::finalize_bootloader_update_(esp_err_t ota_end_err) { + if (ota_end_err != ESP_OK) { + return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; + } + esp_bootloader_desc_t bootloader_desc; + esp_err_t desc_err = esp_ota_get_bootloader_description(this->partition_, &bootloader_desc); +#ifdef USE_ESP32_SRAM1_AS_IRAM + if (desc_err != ESP_OK) { + ESP_LOGE(TAG, "New bootloader does not support SRAM1 as IRAM"); + return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; + } +#endif + ESP_LOGE(TAG, "Starting bootloader update.\n" + " DO NOT REMOVE POWER until the update completes successfully.\n" + " Loss of power during this operation may render the device\n" + " unable to boot until it is recovered via a serial flash."); + esp_err_t err = esp_partition_copy(this->bootloader_part_, 0, this->partition_, 0, this->bootloader_part_->size); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); + // Only if esp_partition_copy failed there's a chance of the device being unbootable + return OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE; + } + ESP_LOGI(TAG, + "Successfully installed the new bootloader\n" + " ESP-IDF %s", + (desc_err == ESP_OK) ? bootloader_desc.idf_ver : "version unknown"); + // Wipe first sector of staging partition to make sure the device can't boot from it + err = esp_partition_erase_range(this->partition_, 0, this->partition_->erase_size); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_partition_erase_range failed (err=0x%X)", err); + // No critical error, don't return + } + esp_partition_deregister_external(this->bootloader_part_); + this->bootloader_part_ = nullptr; + return OTA_RESPONSE_OK; +} + +} // namespace esphome::ota + +#endif // USE_OTA_PARTITIONS +#endif // USE_ESP32 diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f7fd529986..f91e88bde0 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -45,32 +45,14 @@ static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min // can write to it; abort() releases it on error. OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, PartitionTablePlan &plan) { - esp_err_t err = esp_partition_register_external( - nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", - ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + OTAResponseTypes validate_result = this->register_and_validate_partition_table_part_(); + if (validate_result != OTA_RESPONSE_OK) { + return validate_result; } int num_partitions = 0; - const esp_partition_info_t *existing_partition_table = nullptr; - esp_partition_mmap_handle_t partition_table_map; - err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, - reinterpret_cast(&existing_partition_table), &partition_table_map); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); - esp_partition_munmap(partition_table_map); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - err = esp_partition_table_verify(new_partition_table, true, &num_partitions); + esp_err_t err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; @@ -288,6 +270,33 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_OK; } +OTAResponseTypes IDFOTABackend::register_and_validate_partition_table_part_() { + esp_err_t err = esp_partition_register_external( + nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", + ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_register_external failed (partition table) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + int num_partitions = 0; + const esp_partition_info_t *existing_partition_table = nullptr; + esp_partition_mmap_handle_t partition_table_map; + err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, + reinterpret_cast(&existing_partition_table), &partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_mmap failed (partition table) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); + esp_partition_munmap(partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + return OTA_RESPONSE_OK; +} + // Process-scoped cache. Cannot be a backend member: backends are per-connection but the cache // must outlive a connection that called esp_partition_unload_all(), after which // esp_ota_get_running_partition() no longer returns valid data. diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 94a48dd31b..0cc207aa54 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -790,6 +790,12 @@ class EsphomeCore: ) return self.relative_pioenvs_path(self.name, "partitions.bin") + @property + def bootloader_bin(self) -> Path: + if self.data.get(KEY_NATIVE_IDF): + return self.relative_build_path("build", "bootloader", "bootloader.bin") + return self.relative_pioenvs_path(self.name, "bootloader.bin") + @property def target_platform(self): return self.data[KEY_CORE][KEY_TARGET_PLATFORM] diff --git a/esphome/espota2.py b/esphome/espota2.py index b2a1fd2a40..576b1c6b2d 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -17,6 +17,7 @@ from esphome.helpers import ProgressBar, resolve_ip_address OTA_TYPE_UPDATE_APP = 0x00 OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01 +OTA_TYPE_UPDATE_BOOTLOADER = 0x02 RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 @@ -49,6 +50,8 @@ RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90 +RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91 +RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -66,7 +69,7 @@ SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 # updates extend this set. Anything outside the set is rejected up front so callers # of perform_ota/run_ota get a clear error instead of a post-auth 0x8E from the device. _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( - {OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE} + {OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE, OTA_TYPE_UPDATE_BOOTLOADER} ) UPLOAD_BLOCK_SIZE = 8192 @@ -143,6 +146,16 @@ _ERROR_MESSAGES: dict[int, str] = { "the partition table update without rebooting the device. If the device " "fails to boot, recover it via a serial flash." ), + RESPONSE_ERROR_BOOTLOADER_VERIFY: ( + "The bootloader update could not be verified. No changes were " + "made to the bootloader. Check the logs for more information and retry." + ), + RESPONSE_ERROR_BOOTLOADER_UPDATE: ( + "An error occurred while updating the bootloader. The device is now " + "in a degraded state and may not be able to boot. Open the logs and retry " + "the bootloader update without rebooting the device. If the device " + "fails to boot, recover it via a serial flash." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } @@ -325,15 +338,24 @@ def perform_ota( # Any non-app OTA type requires the extended protocol and the # partition-access server feature. Reject up front so the user gets # a clear capability error instead of a post-auth 0x8E from the device. + flag_name = { + OTA_TYPE_UPDATE_PARTITION_TABLE: "--partition-table", + OTA_TYPE_UPDATE_BOOTLOADER: "--bootloader", + }.get(ota_type, f"OTA type 0x{ota_type:02X}") if not extended_proto: raise OTAError( - f"Device does not support extended OTA protocol; " - f"OTA type 0x{ota_type:02X} requires it" + f"Device does not support the extended OTA protocol that " + f"{flag_name} requires. The running firmware is too old; " + f"recompile and upload a current ESPHome firmware via a " + f"regular OTA (without {flag_name}), then retry." ) if not (features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS): raise OTAError( - f"Device does not support partition access; " - f"OTA type 0x{ota_type:02X} cannot be used" + f"The running firmware was built without " + f"'allow_partition_access: true', so {flag_name} cannot be " + f"used. Add the option to the esphome OTA platform in your " + f"YAML, recompile and upload (without {flag_name}), then " + f"retry {flag_name}." ) if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 22be59653a..9dc37918ae 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -853,6 +853,23 @@ class TestEsphomeCore: target.testing_ensure_platform_registered("sensor") assert target.platform_counts["sensor"] == 3 + def test_bootloader_bin__native_idf(self, target): + """Native ESP-IDF builds emit the bootloader under build/bootloader/bootloader.bin.""" + target.data[const.KEY_NATIVE_IDF] = True + + assert target.bootloader_bin == Path( + "foo/build/build/bootloader/bootloader.bin" + ) + + def test_bootloader_bin__platformio(self, target): + """For PlatformIO builds bootloader.bin lives in the env-specific .pioenvs directory.""" + target.name = "test-device" + target.data[const.KEY_NATIVE_IDF] = False + + assert target.bootloader_bin == Path( + "foo/build/.pioenvs/test-device/bootloader.bin" + ) + def test_add_library__extracts_short_name_from_path(self, target): """Test add_library extracts short name from library paths like owner/lib.""" target.data[const.KEY_CORE] = { diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 2cad1d2ec8..b22ad46113 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -201,6 +201,14 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE, "Error: An error occurred while updating the partition table", ), + ( + espota2.RESPONSE_ERROR_BOOTLOADER_VERIFY, + "Error: The bootloader update could not be verified", + ), + ( + espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE, + "Error: An error occurred while updating the bootloader", + ), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) @@ -992,7 +1000,8 @@ def test_perform_ota_non_app_type_requires_extended_protocol( mock_socket.recv.side_effect = recv_responses with pytest.raises( - espota2.OTAError, match="Device does not support extended OTA protocol" + espota2.OTAError, + match="Device does not support the extended OTA protocol", ): espota2.perform_ota( mock_socket, @@ -1026,7 +1035,8 @@ def test_perform_ota_non_app_type_requires_partition_access( mock_socket.recv.side_effect = recv_responses with pytest.raises( - espota2.OTAError, match="Device does not support partition access" + espota2.OTAError, + match=(r"running firmware was built without 'allow_partition_access: true'"), ): espota2.perform_ota( mock_socket, @@ -1037,6 +1047,60 @@ def test_perform_ota_non_app_type_requires_partition_access( ) +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_partition_access_error_names_bootloader_flag( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Bootloader OTA against a stale device must point at the --bootloader flag.""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), + bytes([espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_FEATURE_FLAGS]), + bytes([0]), # No partition access + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises( + espota2.OTAError, + match=r"--bootloader.*recompile and upload.*--bootloader.*retry --bootloader", + ): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + espota2.OTA_TYPE_UPDATE_BOOTLOADER, + ) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_partition_access_error_names_partition_table_flag( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Partition-table OTA against a stale device must point at the --partition-table flag.""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), + bytes([espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_FEATURE_FLAGS]), + bytes([0]), # No partition access + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises( + espota2.OTAError, + match=r"--partition-table.*retry --partition-table", + ): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "test.bin", + espota2.OTA_TYPE_UPDATE_PARTITION_TABLE, + ) + + def test_check_error_detects_errors_when_expect_is_none() -> None: """check_error must surface device error bytes even when expect is None. diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 4ab7bb3344..4b0590cf76 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _get_configured_xtal_freq, _make_crystal_freq_callback, _resolve_network_devices, + _validate_bootloader_binary, _validate_partition_table_binary, choose_upload_log_host, command_analyze_memory, @@ -89,7 +90,11 @@ from esphome.const import ( PLATFORM_RP2040, ) from esphome.core import CORE, EsphomeError -from esphome.espota2 import OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE +from esphome.espota2 import ( + OTA_TYPE_UPDATE_APP, + OTA_TYPE_UPDATE_BOOTLOADER, + OTA_TYPE_UPDATE_PARTITION_TABLE, +) from esphome.util import BootselResult, FlashImage from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1127,6 +1132,7 @@ class MockArgs: output: str | None = None ota_platform: str | None = None partition_table: bool = False + bootloader: bool = False def test_upload_program_serial_esp32( @@ -1816,6 +1822,27 @@ def test_validate_partition_table_binary_missing_file(tmp_path: Path) -> None: _validate_partition_table_binary(tmp_path / "does-not-exist.bin") +def test_validate_bootloader_binary_rejects_wrong_magic(tmp_path: Path) -> None: + data = bytearray(_make_bootloader_bytes()) + data[0] = 0x00 + f = tmp_path / "bootloader.bin" + f.write_bytes(bytes(data)) + with pytest.raises(EsphomeError, match="magic"): + _validate_bootloader_binary(f) + + +def test_validate_bootloader_binary_missing_file(tmp_path: Path) -> None: + with pytest.raises(EsphomeError, match="Cannot read bootloader file"): + _validate_bootloader_binary(tmp_path / "does-not-exist.bin") + + +def test_validate_bootloader_binary_rejects_empty_file(tmp_path: Path) -> None: + f = tmp_path / "bootloader.bin" + f.write_bytes(b"") + with pytest.raises(EsphomeError, match="is empty"): + _validate_bootloader_binary(f) + + def test_upload_program_ota_partition_table_invalid_file( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -1869,7 +1896,155 @@ def test_upload_program_ota_partition_table_without_allow_flag( with pytest.raises( EsphomeError, - match="requires 'allow_partition_access: true'", + match=( + r"The option --partition-table requires 'allow_partition_access: true'.*" + r"retry --partition-table" + ), + ): + upload_program(config, args, devices) + mock_run_ota.assert_not_called() + + +def _make_bootloader_bytes() -> bytes: + """Build a minimal bootloader image accepted by _validate_bootloader_binary.""" + table = bytearray(b"\xff") + # Starts with: ESP_IMAGE_HEADER_MAGIC (0xE9) + table[0] = 0xE9 + return bytes(table) + + +def test_upload_program_ota_bootloader_with_file_arg( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA and bootloader.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + bootloader_file = tmp_path / "bootloader.bin" + bootloader_file.write_bytes(_make_bootloader_bytes()) + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + "allow_partition_access": True, + } + ] + } + args = MockArgs(file=str(bootloader_file), bootloader=True) + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], + 3232, + None, + bootloader_file, + OTA_TYPE_UPDATE_BOOTLOADER, + ) + + +def test_upload_program_ota_partition_table_and_bootloader_options( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--partition-table and --bootloader can't be used together.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + "allow_partition_access": True, + } + ] + } + args = MockArgs(file="partitions.bin", partition_table=True, bootloader=True) + devices = ["192.168.1.100"] + + with pytest.raises( + EsphomeError, + match="--partition-table and --bootloader", + ): + upload_program(config, args, devices) + mock_run_ota.assert_not_called() + + +def test_upload_program_ota_bootloader_without_allow_flag( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--bootloader must fail fast when allow_partition_access is not enabled in YAML.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ] + } + args = MockArgs(file="bootloader.bin", bootloader=True) + devices = ["192.168.1.100"] + + with pytest.raises( + EsphomeError, + match=( + r"The option --bootloader requires 'allow_partition_access: true'.*" + r"retry --bootloader" + ), + ): + upload_program(config, args, devices) + mock_run_ota.assert_not_called() + + +def test_upload_program_ota_bootloader_platform_web_server( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test bootloader upload with web_server OTA.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + + bootloader_file = tmp_path / "bootloader.bin" + bootloader_file.write_bytes(_make_bootloader_bytes()) + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_WEB_SERVER: { + CONF_PORT: 80, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "pw"}, + }, + "allow_partition_access": True, + } + ] + } + args = MockArgs(file=str(bootloader_file), bootloader=True) + devices = ["192.168.1.100"] + + with pytest.raises( + EsphomeError, + match="the web_server OTA path can only update the firmware image", ): upload_program(config, args, devices) mock_run_ota.assert_not_called() From ed10fbea3e920ed241ff691d350f7531b015525c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 11 May 2026 07:25:49 +1200 Subject: [PATCH 1131/2214] [docker] Silence CopyIgnoredFile warning for build context root (#16311) --- .dockerignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index ccd466d8cb..d6fb5e82ae 100644 --- a/.dockerignore +++ b/.dockerignore @@ -115,4 +115,4 @@ examples/ Dockerfile .git/ tests/ -.* +.?* From 3c042e2e442330d6d2e6f2cb51c92e5064019e46 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 11 May 2026 09:58:18 +1000 Subject: [PATCH 1132/2214] [lvgl] Ensure that `on_value` events fire on checked change (#16119) --- esphome/components/lvgl/__init__.py | 38 ++-- esphome/components/lvgl/automation.py | 17 +- esphome/components/lvgl/defines.py | 130 +++++++++++--- esphome/components/lvgl/encoders.py | 7 +- esphome/components/lvgl/gradient.py | 10 +- esphome/components/lvgl/helpers.py | 25 --- esphome/components/lvgl/keypads.py | 4 +- esphome/components/lvgl/light/lvgl_light.h | 2 +- esphome/components/lvgl/lv_validation.py | 27 ++- esphome/components/lvgl/lvcode.py | 3 +- esphome/components/lvgl/lvgl_esphome.cpp | 4 +- esphome/components/lvgl/lvgl_esphome.h | 33 +++- esphome/components/lvgl/number/__init__.py | 3 +- esphome/components/lvgl/schemas.py | 8 +- esphome/components/lvgl/sensor/__init__.py | 2 - esphome/components/lvgl/styles.py | 14 +- esphome/components/lvgl/switch/__init__.py | 9 +- esphome/components/lvgl/text/__init__.py | 3 +- .../components/lvgl/text_sensor/__init__.py | 10 +- esphome/components/lvgl/touchscreens.py | 8 +- esphome/components/lvgl/trigger.py | 8 +- esphome/components/lvgl/widgets/__init__.py | 85 +++------ esphome/components/lvgl/widgets/animimg.py | 5 +- esphome/components/lvgl/widgets/button.py | 3 +- .../components/lvgl/widgets/buttonmatrix.py | 24 +-- esphome/components/lvgl/widgets/dropdown.py | 4 +- esphome/components/lvgl/widgets/keyboard.py | 14 +- esphome/components/lvgl/widgets/meter.py | 5 +- esphome/components/lvgl/widgets/msgbox.py | 2 +- esphome/components/lvgl/widgets/roller.py | 2 - .../lvgl/config/widget_state_test.yaml | 83 +++++++++ .../component_tests/lvgl/test_widget_state.py | 167 ++++++++++++++++++ 32 files changed, 532 insertions(+), 227 deletions(-) create mode 100644 tests/component_tests/lvgl/config/widget_state_test.yaml create mode 100644 tests/component_tests/lvgl/test_widget_state.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index ac0363ca69..31131d253f 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -47,9 +47,15 @@ from esphome.helpers import write_file_if_changed from esphome.writer import clean_build from esphome.yaml_util import load_yaml -from . import defines as df, helpers, lv_validation as lvalid, widgets -from .automation import focused_widgets, layers_to_code, lvgl_update, refreshed_widgets -from .defines import CONF_ALIGN_TO_LAMBDA_ID +from . import defines as df, lv_validation as lvalid, widgets +from .automation import layers_to_code, lvgl_update +from .defines import ( + CONF_ALIGN_TO_LAMBDA_ID, + get_focused_widgets, + get_lv_images_used, + get_refreshed_widgets, + set_widgets_completed, +) from .encoders import ( ENCODERS_CONFIG, encoders_to_code, @@ -58,7 +64,7 @@ from .encoders import ( ) from .gradient import GRADIENT_SCHEMA, gradients_to_code from .keypads import KEYPADS_CONFIG, keypads_to_code -from .lv_validation import lv_bool, lv_images_used +from .lv_validation import lv_bool from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( DISP_BG_SCHEMA, @@ -89,7 +95,6 @@ from .widgets import ( add_widgets, get_screen_active, set_obj_properties, - styles_used, ) # Import only what we actually use directly in this file @@ -144,7 +149,7 @@ def generate_lv_conf_h(): df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES) ) # Get the defines that are actually used based on the config - lv_defines = df.get_data(df.KEY_LV_DEFINES) + lv_defines = df.get_defines() unused_defines = all_defines - set(lv_defines) # Create the content of lv_conf.h with the used defines set to their value, and the unused defines disabled definitions = [as_macro(m, v) for m, v in lv_defines.items()] + [ @@ -211,7 +216,7 @@ def final_validation(config_list): buffer_frac = config[CONF_BUFFER_SIZE] if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config: df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM") - for w in focused_widgets: + for w in get_focused_widgets(): path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) if ( @@ -222,7 +227,7 @@ def final_validation(config_list): "A non adjustable arc may not be focused", path, ) - for w in refreshed_widgets: + for w in get_refreshed_widgets(): path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): @@ -230,7 +235,7 @@ def final_validation(config_list): f"Widget '{w}' does not have any dynamic properties to refresh", ) # Do per-widget type final validation for update actions - for widget_type, update_configs in df.get_data(df.KEY_UPDATED_WIDGETS).items(): + for widget_type, update_configs in df.get_updated_widgets().items(): for conf in update_configs: for id_conf in conf.get(CONF_ID, ()): name = id_conf[CONF_ID] @@ -279,7 +284,7 @@ async def to_code(configs): cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"), ) df.add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) - for font in helpers.lv_fonts_used: + for font in df.get_lv_fonts_used(): df.add_define(f"LV_FONT_{font.upper()}") if config_0[CONF_COLOR_DEPTH] == 16: @@ -294,7 +299,7 @@ async def to_code(configs): cg.add_build_flag("-Isrc") cg.add_global(lvgl_ns.using) - for font in helpers.esphome_fonts_used: + for font in df.get_esphome_fonts_used(): await cg.get_variable(font) default_font = config_0[df.CONF_DEFAULT_FONT] if not lvalid.is_lv_font(default_font): @@ -377,8 +382,8 @@ async def to_code(configs): await lvgl_update(lv_component, config) await msgboxes_to_code(lv_component, config) # await disp_update(lv_component.get_disp(), config) - # Set this directly since we are limited in how many methods can be added to the Widget class. - Widget.widgets_completed = True + # Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed. + set_widgets_completed(True) async with LvContext(): await generate_triggers() await generate_align_tos(configs[0]) @@ -404,9 +409,8 @@ async def to_code(configs): ) # This must be done after all widgets are created - for comp in helpers.lvgl_components_required: - cg.add_define(f"USE_LVGL_{comp.upper()}") - for use in helpers.lv_uses: + styles_used = df.get_styles_used() + for use in df.get_lv_uses(): df.add_define(f"LV_USE_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}") @@ -433,7 +437,7 @@ async def to_code(configs): } & styles_used: lv_image_formats.add("A8") - for image_id in lv_images_used: + for image_id in get_lv_images_used(): await cg.get_variable(image_id) metadata = get_image_metadata(image_id.id) image_type = IMAGE_TYPE[metadata.image_type] diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index 977f1af9b4..bf9a3d74ad 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -25,7 +25,9 @@ from .defines import ( PARTS, StaticCastExpression, add_warning, + get_focused_widgets, get_options, + get_refreshed_widgets, ) from .lv_validation import lv_bool, lv_milliseconds from .lvcode import ( @@ -70,9 +72,9 @@ from .widgets import ( wait_for_widgets, ) -# Record widgets that are used in a focused action here -focused_widgets = set() -refreshed_widgets = set() +# Widgets that are used in a focused/refreshed action are tracked in +# ``CORE.data`` (under the lvgl domain) so the state is cleared between +# successive compilations / unit tests via ``CORE.reset()``. async def layers_to_code(lv_component, config): @@ -316,7 +318,7 @@ async def resume_action_to_code(config, action_id, template_arg, args): ) async def obj_disable_to_code(config, action_id, template_arg, args): async def do_disable(widget: Widget): - widget.add_state(LV_STATE.DISABLED) + widget.set_state(LV_STATE.DISABLED, True) return await action_to_code( await get_widgets(config), do_disable, action_id, template_arg, args @@ -328,7 +330,7 @@ async def obj_disable_to_code(config, action_id, template_arg, args): ) async def obj_enable_to_code(config, action_id, template_arg, args): async def do_enable(widget: Widget): - widget.clear_state(LV_STATE.DISABLED) + widget.set_state(LV_STATE.DISABLED, False) return await action_to_code( await get_widgets(config), do_enable, action_id, template_arg, args @@ -361,7 +363,7 @@ async def obj_show_to_code(config, action_id, template_arg, args): def focused_id(value): value = cv.use_id(lv_pseudo_button_t)(value) - focused_widgets.add(value) + get_focused_widgets().add(value) return value @@ -446,8 +448,9 @@ async def obj_update_to_code(config, action_id, template_arg, args): def validate_refresh_config(config): + refreshed = get_refreshed_widgets() for w in config: - refreshed_widgets.add(w[CONF_ID]) + refreshed.add(w[CONF_ID]) return config diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 03bbaf8ddb..7bfd26bb6e 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -19,46 +19,134 @@ from esphome.cpp_generator import ( from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType -from .helpers import requires_component - LOGGER = logging.getLogger(__name__) lvgl_ns = cg.esphome_ns.namespace("lvgl") DOMAIN = "lvgl" KEY_COLOR_FORMATS = "color_formats" +KEY_ESPHOME_FONTS_USED = "esphome_fonts_used" +KEY_FOCUSED_WIDGETS = "focused_widgets" KEY_LV_DEFINES = "lv_defines" +KEY_LV_FONTS_USED = "lv_fonts_used" +KEY_LV_IMAGES_USED = "lv_images_used" +KEY_LV_USES = "lv_uses" +KEY_NAMED_STYLES = "named_styles" +KEY_REFRESHED_WIDGETS = "refreshed_widgets" KEY_REMAPPED_USES = "remapped_uses" +KEY_STYLES_USED = "styles_used" +KEY_THEME_WIDGET_MAP = "theme_widget_map" KEY_UPDATED_WIDGETS = "updated_widgets" +KEY_WIDGET_MAP = "widget_map" +KEY_WIDGETS_COMPLETED = "widgets_completed" KEY_OPTIONS = "options" KEY_WARNINGS = "warnings" +# Initial set of LVGL features that are always enabled. +_INITIAL_LV_USES = frozenset( + { + "USER_DATA", + "LOG", + "STYLE", + "FONT_PLACEHOLDER", + "THEME_DEFAULT", + } +) -def get_data(key, default=None): + +# These collections accumulate state across a single compilation run. They +# are stored under ``CORE.data`` (which ``CORE.reset()`` clears between runs) +# rather than as module-level globals, otherwise they would leak between +# successive compilations / unit tests. + + +def _get_data(key: str, default: Any) -> Any: """ Get a data structure from the global data store by key :param key: A key for the data - :param default: The default data - the default is an empty dict + :param default: The default data :return: """ - return CORE.data.setdefault(DOMAIN, {}).setdefault( - key, {} if default is None else default - ) + return CORE.data.setdefault(DOMAIN, {}).setdefault(key, default) -def get_warnings(): - return get_data(KEY_WARNINGS, set()) +def get_lv_images_used() -> set[ID]: + return _get_data(KEY_LV_IMAGES_USED, set()) -def get_remapped_uses(): - return get_data(KEY_REMAPPED_USES, set()) +def get_lv_uses() -> set[str]: + return _get_data(KEY_LV_USES, set(_INITIAL_LV_USES)) -def add_warning(msg: str): +def get_lv_fonts_used() -> set[str]: + return _get_data(KEY_LV_FONTS_USED, set()) + + +def get_esphome_fonts_used() -> set[ID]: + return _get_data(KEY_ESPHOME_FONTS_USED, set()) + + +def add_lv_use(*names: str) -> None: + uses = get_lv_uses() + for name in names: + uses.add(name) + + +def get_warnings() -> set[str]: + return _get_data(KEY_WARNINGS, set()) + + +def get_remapped_uses() -> set[str]: + return _get_data(KEY_REMAPPED_USES, set()) + + +def add_warning(msg: str) -> None: get_warnings().add(msg) -def get_options(): - return get_data(KEY_OPTIONS) +def get_options() -> dict[str, Any]: + return _get_data(KEY_OPTIONS, {}) + + +def get_defines() -> dict[str, str]: + return _get_data(KEY_LV_DEFINES, {}) + + +def get_updated_widgets() -> dict: + return _get_data(KEY_UPDATED_WIDGETS, {}) + + +def get_theme_widget_map() -> dict[str, Any]: + return _get_data(KEY_THEME_WIDGET_MAP, {}) + + +def get_styles_used() -> set[str]: + return _get_data(KEY_STYLES_USED, set()) + + +def get_widget_map() -> dict[str, Any]: + return _get_data(KEY_WIDGET_MAP, {}) + + +def get_widgets_completed() -> bool: + # ``[value]`` rather than the bare value so that we can mutate the + # entry in place; ``CORE.data`` is reset for us between runs. + return _get_data(KEY_WIDGETS_COMPLETED, [False])[0] + + +def set_widgets_completed(value: bool) -> None: + _get_data(KEY_WIDGETS_COMPLETED, [False])[0] = value + + +def is_widget_completed(name: ID) -> bool: + return name in get_widget_map() + + +def get_focused_widgets() -> set: + return _get_data(KEY_FOCUSED_WIDGETS, set()) + + +def get_refreshed_widgets() -> set: + return _get_data(KEY_REFRESHED_WIDGETS, set()) class StaticCastExpression(Expression): @@ -72,8 +160,8 @@ class StaticCastExpression(Expression): return f"static_cast<{self.type}>({self.exp})" -def add_define(macro, value="1"): - lv_defines = get_data(KEY_LV_DEFINES) +def add_define(macro: str, value="1"): + lv_defines = get_defines() value = str(value) if lv_defines.setdefault(macro, value) != value: LOGGER.error( @@ -82,8 +170,8 @@ def add_define(macro, value="1"): lv_defines[macro] = value -def is_defined(macro): - return macro in get_data(KEY_LV_DEFINES) +def is_defined(macro) -> bool: + return macro in get_defines() def literal(arg) -> MockObj: @@ -96,7 +184,7 @@ def addr(arg) -> MockObj: return MockObj(f"&{arg}") -def call_lambda(lamb: LambdaExpression): +def call_lambda(lamb: LambdaExpression) -> Expression: """ Given a lambda, either reduce to a simple expression or call it, possibly with parameters from the surrounding context @@ -135,7 +223,7 @@ class LValidator: def __call__(self, value): if self.requires: - value = requires_component(self.requires)(value) + value = cv.requires_component(self.requires)(value) if isinstance(value, cv.Lambda): return cv.returning_lambda(value) return self.validator(value) @@ -196,7 +284,7 @@ class LvConstant(LValidator): cv.ensure_list(self.one_of), cg.uint32, retmapper=self.mapper ) - def mapper(self, value): + def mapper(self, value) -> Any: if not isinstance(value, list): value = [value] value = [ diff --git a/esphome/components/lvgl/encoders.py b/esphome/components/lvgl/encoders.py index bafda8382e..e6527bbc9b 100644 --- a/esphome/components/lvgl/encoders.py +++ b/esphome/components/lvgl/encoders.py @@ -13,8 +13,8 @@ from .defines import ( CONF_LONG_PRESS_REPEAT_TIME, CONF_LONG_PRESS_TIME, CONF_RIGHT_BUTTON, + add_lv_use, ) -from .helpers import lvgl_components_required, requires_component from .lvcode import lv, lv_add, lv_assign, lv_expr, lv_Pvariable from .schemas import ENCODER_SCHEMA from .types import lv_group_t, lv_indev_type_t, lv_key_t @@ -26,7 +26,8 @@ ENCODERS_CONFIG = cv.ensure_list( cv.Required(CONF_ENTER_BUTTON): cv.use_id(BinarySensor), cv.Required(CONF_SENSOR): cv.Any( cv.All( - cv.use_id(RotaryEncoderSensor), requires_component("rotary_encoder") + cv.use_id(RotaryEncoderSensor), + cv.requires_component("rotary_encoder"), ), cv.Schema( { @@ -48,7 +49,7 @@ def get_default_group(config): async def encoders_to_code(var, config, default_group): for enc_conf in config[CONF_ENCODERS]: - lvgl_components_required.add("KEY_LISTENER") + add_lv_use("KEY_LISTENER", "ROTARY_ENCODER") lpt = enc_conf[CONF_LONG_PRESS_TIME].total_milliseconds lprt = enc_conf[CONF_LONG_PRESS_REPEAT_TIME].total_milliseconds listener = cg.new_Pvariable( diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index e075433d03..2f1be20772 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -12,8 +12,14 @@ from esphome.const import ( from esphome.core import ID from esphome.cpp_generator import MockObj -from .defines import CONF_GRADIENTS, CONF_OPA, LV_DITHER, add_define, add_warning -from .helpers import add_lv_use +from .defines import ( + CONF_GRADIENTS, + CONF_OPA, + LV_DITHER, + add_define, + add_lv_use, + add_warning, +) from .lv_validation import lv_color, lv_percentage, opacity from .lvcode import lv from .types import lv_color_t, lv_gradient_t, lv_opa_t diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index c2bd58f71c..baa618d472 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -5,23 +5,6 @@ from esphome.const import CONF_ARGS, CONF_FORMAT CONF_IF_NAN = "if_nan" -lv_uses = { - "USER_DATA", - "LOG", - "STYLE", - "FONT_PLACEHOLDER", - "THEME_DEFAULT", -} - - -def add_lv_use(*names): - for name in names: - lv_uses.add(name) - - -lv_fonts_used = set() -esphome_fonts_used = set() -lvgl_components_required = set() # noqa f_regex = re.compile( @@ -66,11 +49,3 @@ def validate_printf(value): "Use of 'if_nan' requires a single valid printf-pattern of type %f" ) return value - - -def requires_component(comp): - def validator(value): - lvgl_components_required.add(comp) - return cv.requires_component(comp)(value) - - return validator diff --git a/esphome/components/lvgl/keypads.py b/esphome/components/lvgl/keypads.py index 7d8b3dd128..6d4abbc63b 100644 --- a/esphome/components/lvgl/keypads.py +++ b/esphome/components/lvgl/keypads.py @@ -9,9 +9,9 @@ from .defines import ( CONF_KEYPADS, CONF_LONG_PRESS_REPEAT_TIME, CONF_LONG_PRESS_TIME, + add_lv_use, literal, ) -from .helpers import lvgl_components_required from .lvcode import lv, lv_assign, lv_expr, lv_Pvariable from .schemas import ENCODER_SCHEMA from .types import lv_group_t, lv_indev_type_t @@ -52,7 +52,7 @@ KEYPADS_CONFIG = cv.ensure_list( async def keypads_to_code(var, config, default_group): for enc_conf in config[CONF_KEYPADS]: - lvgl_components_required.add("KEY_LISTENER") + add_lv_use("KEY_LISTENER") lpt = enc_conf[CONF_LONG_PRESS_TIME].total_milliseconds lprt = enc_conf[CONF_LONG_PRESS_REPEAT_TIME].total_milliseconds listener = cg.new_Pvariable( diff --git a/esphome/components/lvgl/light/lvgl_light.h b/esphome/components/lvgl/light/lvgl_light.h index 50da7af602..bf019964c7 100644 --- a/esphome/components/lvgl/light/lvgl_light.h +++ b/esphome/components/lvgl/light/lvgl_light.h @@ -37,7 +37,7 @@ class LVLight : public light::LightOutput { void set_value_(lv_color_t value) { lv_led_set_color(this->obj_, value); lv_led_on(this->obj_); - lv_obj_send_event(this->obj_, lv_api_event, nullptr); + lv_obj_send_event(this->obj_, lv_update_event, nullptr); } lv_obj_t *obj_{}; optional initial_value_{}; diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 974eed9e81..a1b75182eb 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -31,16 +31,14 @@ from .defines import ( LValidator, LvConstant, StaticCastExpression, + add_lv_use, call_lambda, + get_esphome_fonts_used, + get_lv_fonts_used, + get_lv_images_used, literal, ) -from .helpers import ( - CONF_IF_NAN, - add_lv_use, - esphome_fonts_used, - lv_fonts_used, - requires_component, -) +from .helpers import CONF_IF_NAN from .types import lv_coord_t, lv_gradient_t, lv_opa_t LV_OPA = LvConstant("LV_OPA_", "TRANSP", "COVER") @@ -370,14 +368,11 @@ def stop_value(value): return cv.int_range(0, 255)(value) -lv_images_used = set() - - def image_validator(value): - value = requires_component("image")(value) + value = cv.requires_component("image")(value) value = cv.use_id(Image_)(value) - lv_images_used.add(value) - add_lv_use("img", "label") + get_lv_images_used().add(value) + add_lv_use("label") return value @@ -496,7 +491,7 @@ class LvFont(LValidator): def __init__(self): def lv_builtin_font(value): fontval = cv.one_of(*LV_FONTS, lower=True)(value) - lv_fonts_used.add(fontval) + get_lv_fonts_used().add(fontval) return fontval def validator(value): @@ -506,8 +501,8 @@ class LvFont(LValidator): return lv_builtin_font(value) add_lv_use("font") fontval = cv.use_id(Font)(value) - esphome_fonts_used.add(fontval) - return requires_component("font")(fontval) + get_esphome_fonts_used().add(fontval) + return cv.requires_component("font")(fontval) # Use font::Font* as return type for lambdas returning ESPHome fonts # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index eb8f7d4437..32fb02e3d2 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -29,10 +29,9 @@ LvglComponent = lvgl_ns.class_("LvglComponent", cg.PollingComponent) LVGL_COMP_ARG = [(LvglComponent.operator("ptr"), LVGL_COMP)] lv_event_t_ptr = cg.global_ns.namespace("lv_event_t").operator("ptr") EVENT_ARG = [(lv_event_t_ptr, "event")] -# Two custom events; API_EVENT is fired when an entity is updated remotely by an API interaction; +# One custom event; # UPDATE_EVENT is fired when an entity is programmatically updated locally. # VALUE_CHANGED is the event generated by LVGL when an entity's value changes through user interaction. -API_EVENT = literal("lvgl::lv_api_event") UPDATE_EVENT = literal("lvgl::lv_update_event") diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 3141c5f93c..678ed9dbbf 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -147,7 +147,6 @@ void LvglComponent::render_start_cb(lv_event_t *event) { comp->draw_start_(); } -lv_event_code_t lv_api_event; // NOLINT lv_event_code_t lv_update_event; // NOLINT void LvglComponent::dump_config() { ESP_LOGCONFIG(TAG, @@ -194,7 +193,6 @@ void LvglComponent::esphome_lvgl_init() { LV_GLOBAL_DEFAULT()->font_draw_buf_handlers.buf_free_cb = lv_free_core; lv_tick_set_cb([] { return millis(); }); lv_update_event = static_cast(lv_event_register_id()); - lv_api_event = static_cast(lv_event_register_id()); } void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event) { @@ -547,7 +545,7 @@ void LvSelectable::set_selected_text(const std::string &text, lv_anim_enable_t a auto index = std::find(this->options_.begin(), this->options_.end(), text); if (index != this->options_.end()) { this->set_selected_index(index - this->options_.begin(), anim); - lv_obj_send_event(this->obj, lv_api_event, nullptr); + lv_obj_send_event(this->obj, lv_update_event, nullptr); } } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 32bf3ccac6..218f9a60ab 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -50,7 +50,6 @@ using lv_color_data = uint16_t; using lv_color_data = uint32_t; #endif -extern lv_event_code_t lv_api_event; // NOLINT extern lv_event_code_t lv_update_event; // NOLINT extern std::string lv_event_code_name_for(lv_event_t *event); @@ -227,11 +226,43 @@ class LvglComponent : public PollingComponent { * Initialize the LVGL library and register custom events. */ static void esphome_lvgl_init(); + + // Convenience overloads for adding a callback for one or more events static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event); static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2); static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, lv_event_code_t event3); + // change the state of a widget and fire an event if changed (only needed for CHECKED) + + static void lv_obj_set_state_value(lv_obj_t *obj, lv_state_t state, bool value) { + if (value != lv_obj_has_state(obj, state)) { + if (value) { + lv_obj_add_state(obj, state); + } else { + lv_obj_remove_state(obj, state); + } + if (state == LV_STATE_CHECKED) + lv_obj_send_event(obj, lv_update_event, nullptr); + } + } + + // change the state of a buttonmatrix button and fire an event if changed (only needed for CHECKED) +#ifdef USE_LVGL_BUTTONMATRIX + static void lv_buttonmatrix_set_button_ctrl_value(lv_obj_t *obj, uint32_t index, lv_buttonmatrix_ctrl_t ctrl, + bool value) { + if (value != lv_buttonmatrix_has_button_ctrl(obj, index, ctrl)) { + if (value) { + lv_buttonmatrix_set_button_ctrl(obj, index, ctrl); + } else { + lv_buttonmatrix_clear_button_ctrl(obj, index, ctrl); + } + if (ctrl == LV_BUTTONMATRIX_CTRL_CHECKED) + lv_obj_send_event(obj, lv_update_event, nullptr); + } + } +#endif + void add_page(LvPageType *page); void show_page(size_t index, lv_screen_load_anim_t anim, uint32_t time); void show_next_page(lv_screen_load_anim_t anim, uint32_t time); diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index d80e93708b..0c7e4b9524 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -7,7 +7,6 @@ from esphome.cpp_generator import MockObj from ..defines import CONF_ANIMATED, CONF_UPDATE_ON_RELEASE, CONF_WIDGET from ..lv_validation import animated from ..lvcode import ( - API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, @@ -40,7 +39,7 @@ async def to_code(config): await widget.set_property( "value", MockObj("v") * MockObj(widget.get_scale()), config[CONF_ANIMATED] ) - lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) + lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr) event_code = ( LV_EVENT.VALUE_CHANGED if not config[CONF_UPDATE_ON_RELEASE] diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index a9427a9852..6b71c2875e 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -33,7 +33,7 @@ from .defines import ( get_remapped_uses, is_press_event, ) -from .helpers import CONF_IF_NAN, requires_component, validate_printf +from .helpers import CONF_IF_NAN, validate_printf from .layout import ( FLEX_OBJ_SCHEMA, GRID_CELL_SCHEMA, @@ -112,7 +112,7 @@ PRESS_TIME = cv.All( ENCODER_SCHEMA = cv.Schema( { cv.GenerateID(): cv.All( - cv.declare_id(LVEncoderListener), requires_component("binary_sensor") + cv.declare_id(LVEncoderListener), cv.requires_component("binary_sensor") ), cv.Optional(CONF_GROUP): cv.declare_id(lv_group_t), cv.Optional(df.CONF_INITIAL_FOCUS): cv.All( @@ -406,7 +406,7 @@ def _update_widget(widget_type: WidgetType) -> Callable[[dict], dict]: """ def validator(value: dict) -> dict: - df.get_data(df.KEY_UPDATED_WIDGETS).setdefault(widget_type, []).append(value) + df.get_updated_widgets().setdefault(widget_type, []).append(value) return value return validator @@ -573,7 +573,7 @@ def any_widget_schema(extras=None): container_validator = container_schema(widget_type, extras=extras) if required := widget_type.required_component: container_validator = cv.All( - container_validator, requires_component(required) + container_validator, cv.requires_component(required) ) # Apply custom validation path = [key] if is_dict else [index, key] diff --git a/esphome/components/lvgl/sensor/__init__.py b/esphome/components/lvgl/sensor/__init__.py index 167af9c6e1..682d84ede3 100644 --- a/esphome/components/lvgl/sensor/__init__.py +++ b/esphome/components/lvgl/sensor/__init__.py @@ -3,7 +3,6 @@ import esphome.config_validation as cv from ..defines import CONF_WIDGET from ..lvcode import ( - API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, @@ -35,7 +34,6 @@ async def to_code(config): widget.obj, await lamb.get_lambda(), LV_EVENT.VALUE_CHANGED, - API_EVENT, UPDATE_EVENT, ) ) diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index c17f30383b..c1441526f9 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -4,12 +4,18 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import ID -from .defines import CONF_STYLE_DEFINITIONS, CONF_THEME, LValidator, literal -from .helpers import add_lv_use +from .defines import ( + CONF_STYLE_DEFINITIONS, + CONF_THEME, + LValidator, + add_lv_use, + get_theme_widget_map, + literal, +) from .lvcode import LambdaContext, lv from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, WIDGET_TYPES, remap_property from .types import ObjUpdateAction, lv_style_t -from .widgets import collect_parts, theme_widget_map, wait_for_widgets +from .widgets import collect_parts, wait_for_widgets def has_style_props(config) -> bool: @@ -97,4 +103,4 @@ async def theme_to_code(config): ) for state, props in states.items() } - theme_widget_map[w_name] = styles + get_theme_widget_map()[w_name] = styles diff --git a/esphome/components/lvgl/switch/__init__.py b/esphome/components/lvgl/switch/__init__.py index a43851b4a3..509e4f42ad 100644 --- a/esphome/components/lvgl/switch/__init__.py +++ b/esphome/components/lvgl/switch/__init__.py @@ -7,14 +7,11 @@ from esphome.cpp_types import Component from ..defines import CONF_WIDGET, literal from ..lvcode import ( - API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, - LvConditional, LvContext, lv_add, - lv_obj, lvgl_static, ) from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t, lvgl_ns @@ -35,11 +32,7 @@ async def to_code(config): switch_id = MockObj(config[CONF_ID], "->") v = literal("v") async with LambdaContext([(cg.bool_, "v")]) as control: - with LvConditional(v) as cond: - widget.add_state(LV_STATE.CHECKED) - cond.else_() - widget.clear_state(LV_STATE.CHECKED) - lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) + widget.set_state(LV_STATE.CHECKED, literal("v")) control.add(switch_id.publish_state(v)) switch = cg.new_Pvariable(config[CONF_ID], await control.get_lambda()) await cg.register_component(switch, config) diff --git a/esphome/components/lvgl/text/__init__.py b/esphome/components/lvgl/text/__init__.py index 190ecacda5..61db5444e8 100644 --- a/esphome/components/lvgl/text/__init__.py +++ b/esphome/components/lvgl/text/__init__.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from ..defines import CONF_WIDGET from ..lvcode import ( - API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, @@ -33,7 +32,7 @@ async def to_code(config): await wait_for_widgets() async with LambdaContext([(cg.std_string, "text_value")]) as control: await widget.set_property("text", "text_value.c_str()") - lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) + lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr) control.add(textvar.publish_state(widget.get_value())) async with LambdaContext(EVENT_ARG) as lamb: lv_add(textvar.publish_state(widget.get_value())) diff --git a/esphome/components/lvgl/text_sensor/__init__.py b/esphome/components/lvgl/text_sensor/__init__.py index 4728fd137a..c3306ad57a 100644 --- a/esphome/components/lvgl/text_sensor/__init__.py +++ b/esphome/components/lvgl/text_sensor/__init__.py @@ -6,14 +6,7 @@ from esphome.components.text_sensor import ( import esphome.config_validation as cv from ..defines import CONF_WIDGET -from ..lvcode import ( - API_EVENT, - EVENT_ARG, - UPDATE_EVENT, - LambdaContext, - LvContext, - lvgl_static, -) +from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static from ..types import LV_EVENT, LvText from ..widgets import get_widgets, wait_for_widgets @@ -37,7 +30,6 @@ async def to_code(config): widget.obj, await pressed_ctx.get_lambda(), LV_EVENT.VALUE_CHANGED, - API_EVENT, UPDATE_EVENT, ) ) diff --git a/esphome/components/lvgl/touchscreens.py b/esphome/components/lvgl/touchscreens.py index 0eb9f22f12..0bb5715439 100644 --- a/esphome/components/lvgl/touchscreens.py +++ b/esphome/components/lvgl/touchscreens.py @@ -8,15 +8,17 @@ from .defines import ( CONF_LONG_PRESS_REPEAT_TIME, CONF_LONG_PRESS_TIME, CONF_TOUCHSCREENS, + add_lv_use, ) -from .helpers import lvgl_components_required from .schemas import PRESS_TIME from .types import LVTouchListener CONF_TOUCHSCREEN = "touchscreen" TOUCHSCREENS_CONFIG = cv.maybe_simple_value( { - cv.Required(CONF_TOUCHSCREEN_ID): cv.use_id(Touchscreen), + cv.Required(CONF_TOUCHSCREEN_ID): cv.All( + cv.use_id(Touchscreen), cv.requires_component(CONF_TOUCHSCREEN) + ), cv.Optional(CONF_LONG_PRESS_TIME, default="400ms"): PRESS_TIME, cv.Optional(CONF_LONG_PRESS_REPEAT_TIME, default="100ms"): PRESS_TIME, cv.GenerateID(): cv.declare_id(LVTouchListener), @@ -34,7 +36,7 @@ def touchscreen_schema(config): async def touchscreens_to_code(lv_component, config): for tconf in config[CONF_TOUCHSCREENS]: - lvgl_components_required.add(CONF_TOUCHSCREEN) + add_lv_use(CONF_TOUCHSCREEN) touchscreen = await cg.get_variable(tconf[CONF_TOUCHSCREEN_ID]) lpt = tconf[CONF_LONG_PRESS_TIME].total_milliseconds lprt = tconf[CONF_LONG_PRESS_REPEAT_TIME].total_milliseconds diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index b3d12ed183..64590d56f6 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -24,11 +24,11 @@ from .defines import ( LV_SCREEN_EVENT_MAP, LV_SCREEN_EVENT_TRIGGERS, SWIPE_TRIGGERS, + get_widget_map, is_press_event, literal, ) from .lvcode import ( - API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, @@ -39,7 +39,7 @@ from .lvcode import ( lvgl_static, ) from .types import LV_EVENT, lv_point_t -from .widgets import LvScrActType, get_screen_active, widget_map +from .widgets import LvScrActType, get_screen_active async def add_on_boot_triggers(triggers): @@ -58,7 +58,7 @@ async def generate_triggers(): all_triggers = ( LV_EVENT_TRIGGERS + LV_DISPLAY_EVENT_TRIGGERS + LV_SCREEN_EVENT_TRIGGERS ) - for w in widget_map.values(): + for w in get_widget_map().values(): config = w.config if isinstance(w.type, LvScrActType): w = get_screen_active(w.var) @@ -89,7 +89,6 @@ async def generate_triggers(): conf, w, LV_EVENT.VALUE_CHANGED, - API_EVENT, UPDATE_EVENT, ) @@ -104,6 +103,7 @@ async def generate_align_tos(config: dict): :param config: :return: """ + widget_map = get_widget_map() align_tos = tuple( w for w in widget_map.values() if w.config and CONF_ALIGN_TO in w.config ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index d35f84c4f2..534ebe0b93 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj +from esphome.types import Expression from ..defines import ( CONF_FLEX_ALIGN_CROSS, @@ -38,11 +39,15 @@ from ..defines import ( TYPE_FLEX, TYPE_GRID, LValidator, + add_lv_use, call_lambda, + get_styles_used, + get_theme_widget_map, + get_widget_map, + get_widgets_completed, join_enums, literal, ) -from ..helpers import add_lv_use from ..lvcode import ( LvConditional, add_line_marks, @@ -52,6 +57,7 @@ from ..lvcode import ( lv_expr, lv_obj, lv_Pvariable, + lvgl_static, ) from ..types import ( LV_STATE, @@ -65,9 +71,6 @@ from ..types import ( EVENT_LAMB = "event_lamb__" -theme_widget_map = {} -styles_used = set() - class WidgetType: """ @@ -157,7 +160,7 @@ class WidgetType: await self.on_create(var, config) w = Widget.create(wid, var, self, config) - if theme := theme_widget_map.get(self.name): + if theme := get_theme_widget_map().get(self.name): for part, states in theme.items(): part = "LV_PART_" + part.upper() for state, style in states.items(): @@ -240,8 +243,6 @@ class Widget: This class has a lot of methods. Adding any more runs foul of lint checks ("too many public methods"). """ - widgets_completed = False - def __init__(self, var, wtype: WidgetType, config: dict = None): self.var = var self.type = wtype @@ -262,21 +263,14 @@ class Widget: @staticmethod def create(name, var, wtype: WidgetType, config: dict = None): w = Widget(var, wtype, config) - widget_map[name] = w + get_widget_map()[name] = w return w - def add_state(self, state): - if "|" in state: - state = f"(lv_state_t)({state})" - return lv_obj.add_state(self.obj, literal(state)) + def set_state(self, state: MockObj, value: bool | Expression): + lv_add(lvgl_static.lv_obj_set_state_value(self.obj, state, value)) - def clear_state(self, state): - if "|" in state: - state = f"(lv_state_t)({state})" - return lv_obj.remove_state(self.obj, literal(state)) - - def has_state(self, state): - return (lv_expr.obj_get_state(self.obj) & literal(state)) != 0 + def has_state(self, state: MockObj): + return lv_expr.obj_has_state(self.obj, state) def is_pressed(self): return self.has_state(LV_STATE.PRESSED) @@ -346,10 +340,10 @@ class Widget: ltype = ltype or self.__type_base() return cg.RawExpression(f"lv_{ltype}_get_{prop}({self.obj})") - def set_style(self, prop, value, state=LV_STATE.DEFAULT): + def set_style(self, prop: str, value, state=LV_STATE.DEFAULT): if value is None: return - styles_used.add(prop) + get_styles_used().add(prop) if isinstance(value, str): value = literal(value) lv.call(f"obj_set_style_{prop}", self.obj, value, state) @@ -403,14 +397,6 @@ class Widget: return self.type.get_scale(self.config) -# Map of widgets to their config, used for trigger generation -widget_map: dict[ID, Widget] = {} - - -def is_widget_completed(name: ID) -> bool: - return name in widget_map - - class LvScrActType(WidgetType): """ A "widget" representing the active screen. @@ -433,10 +419,11 @@ def get_widget_generator(wid): :param wid: :return: """ + widget_map = get_widget_map() while True: if obj := widget_map.get(wid): return obj - if Widget.widgets_completed: + if get_widgets_completed(): raise Invalid( f"Widget {wid} not found, yet all widgets should be defined by now" ) @@ -444,20 +431,20 @@ def get_widget_generator(wid): async def get_widget_(wid): - if obj := widget_map.get(wid): + if obj := get_widget_map().get(wid): return obj return await FakeAwaitable(get_widget_generator(wid)) def widgets_wait_generator(): while True: - if Widget.widgets_completed: + if get_widgets_completed(): return yield async def wait_for_widgets(): - if Widget.widgets_completed: + if get_widgets_completed(): return await FakeAwaitable(widgets_wait_generator()) @@ -608,30 +595,14 @@ async def set_obj_properties(w: Widget, config): cond.else_() w.clear_flag(flag) - if states := config.get(CONF_STATE): - adds = set() - clears = set() - lambs = {} - for key, value in states.items(): - if isinstance(value, cv.Lambda): - lambs[key] = value - elif value: - adds.add(key) - else: - clears.add(key) - if adds: - adds = join_enums(adds, "LV_STATE_") - w.add_state(adds) - if clears: - clears = join_enums(clears, "LV_STATE_") - w.clear_state(clears) - for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) - state = f"LV_STATE_{key.upper()}" - with LvConditional(call_lambda(lamb)) as cond: - w.add_state(state) - cond.else_() - w.clear_state(state) + for key, value in config.get(CONF_STATE, {}).items(): + if isinstance(value, cv.Lambda): + value = call_lambda( + await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) + ) + state = getattr(LV_STATE, key.upper()) + w.set_state(state, value) + for property in OBJ_PROPERTIES: await w.set_property(property, config, lv_name="obj") diff --git a/esphome/components/lvgl/widgets/animimg.py b/esphome/components/lvgl/widgets/animimg.py index 8e2db5ff35..b6d59df7f2 100644 --- a/esphome/components/lvgl/widgets/animimg.py +++ b/esphome/components/lvgl/widgets/animimg.py @@ -4,7 +4,6 @@ from esphome.const import CONF_DURATION, CONF_ID from ..automation import action_to_code from ..defines import CONF_AUTO_START, CONF_MAIN, CONF_REPEAT_COUNT, CONF_SRC -from ..helpers import lvgl_components_required from ..lv_validation import lv_image_list, lv_milliseconds from ..lvcode import lv from ..types import LvType, ObjUpdateAction @@ -55,8 +54,6 @@ class AnimimgType(WidgetType): ) async def to_code(self, w: Widget, config): - lvgl_components_required.add(CONF_IMAGE) - lvgl_components_required.add(CONF_ANIMIMG) if srcs := config.get(CONF_SRC): srcs = await lv_image_list.process(srcs) lv.animimg_set_src(w.obj, srcs) @@ -68,7 +65,7 @@ class AnimimgType(WidgetType): lv.animimg_start(w.obj) def get_uses(self): - return "img", CONF_IMAGE, CONF_LABEL + return CONF_IMAGE, CONF_LABEL animimg_spec = AnimimgType() diff --git a/esphome/components/lvgl/widgets/button.py b/esphome/components/lvgl/widgets/button.py index b943a4d9aa..0ad512cd8b 100644 --- a/esphome/components/lvgl/widgets/button.py +++ b/esphome/components/lvgl/widgets/button.py @@ -2,8 +2,7 @@ from esphome import config_validation as cv from esphome.const import CONF_BUTTON, CONF_TEXT from esphome.cpp_generator import MockObj -from ..defines import CONF_MAIN, CONF_WIDGETS -from ..helpers import add_lv_use +from ..defines import CONF_MAIN, CONF_WIDGETS, add_lv_use from ..lv_validation import lv_text from ..lvcode import lv, lv_expr from ..schemas import TEXT_SCHEMA diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index f5ae0deba9..02dc9ed4ba 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -5,6 +5,7 @@ from esphome.components.key_provider import KeyProvider import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ITEMS, CONF_TEXT, CONF_WIDTH from esphome.cpp_generator import MockObj +from esphome.types import Expression from ..automation import action_to_code from ..defines import ( @@ -17,10 +18,10 @@ from ..defines import ( CONF_PAD_COLUMN, CONF_PAD_ROW, CONF_SELECTED, + get_widget_map, ) -from ..helpers import lvgl_components_required from ..lv_validation import key_code, lv_bool, padding -from ..lvcode import lv, lv_add, lv_expr +from ..lvcode import lv, lv_add, lv_expr, lvgl_static from ..schemas import automation_schema from ..types import ( LV_BTNMATRIX_CTRL, @@ -32,7 +33,7 @@ from ..types import ( char_ptr, lv_pseudo_button_t, ) -from . import Widget, WidgetType, get_widgets, widget_map +from . import Widget, WidgetType, get_widgets from .button import lv_button_t CONF_BUTTONMATRIX = "buttonmatrix" @@ -98,7 +99,7 @@ class MatrixButton(Widget): @staticmethod def create_button(id, parent, config: dict, index): w = MatrixButton(id, parent, config, index) - widget_map[id] = w + get_widget_map()[id] = w return w def __init__(self, id, parent: Widget, config, index): @@ -120,13 +121,13 @@ class MatrixButton(Widget): state = self.map_ctrls(state) return lv_expr.buttonmatrix_has_button_ctrl(self.obj, self.index, state) - def add_state(self, state): - state = self.map_ctrls(state) - return lv.buttonmatrix_set_button_ctrl(self.obj, self.index, state) - - def clear_state(self, state): - state = self.map_ctrls(state) - return lv.buttonmatrix_clear_button_ctrl(self.obj, self.index, state) + def set_state(self, state: MockObj, value: bool | Expression): + ctrl = self.map_ctrls(state) + lv_add( + lvgl_static.lv_buttonmatrix_set_button_ctrl_value( + self.obj, self.index, ctrl, value + ) + ) def is_pressed(self): return self.is_selected() & self.parent.has_state(LV_STATE.PRESSED) @@ -191,7 +192,6 @@ class ButtonMatrixType(WidgetType): ) async def to_code(self, w: Widget, config): - lvgl_components_required.add("BUTTONMATRIX") if CONF_ROWS not in config: return text_list, ctrl_list, width_list, key_list = await get_button_data( diff --git a/esphome/components/lvgl/widgets/dropdown.py b/esphome/components/lvgl/widgets/dropdown.py index ca89bb625b..34e6ef4e35 100644 --- a/esphome/components/lvgl/widgets/dropdown.py +++ b/esphome/components/lvgl/widgets/dropdown.py @@ -14,7 +14,6 @@ from ..defines import ( DIRECTIONS, literal, ) -from ..helpers import lvgl_components_required from ..lv_validation import lv_int, lv_text, option_string from ..lvcode import LocalVariable, lv, lv_add, lv_expr from ..schemas import part_schema @@ -95,7 +94,6 @@ class DropdownType(WidgetType): ) async def to_code(self, w: Widget, config): - lvgl_components_required.add(CONF_DROPDOWN) if options := config.get(CONF_OPTIONS): lv_add(w.var.set_options(options)) if symbol := config.get(CONF_SYMBOL): @@ -116,7 +114,7 @@ class DropdownType(WidgetType): await set_obj_properties(dwid, dlist) def get_uses(self): - return (CONF_LABEL,) + return CONF_LABEL, CONF_DROPDOWN dropdown_spec = DropdownType() diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index c5628cee3c..bcd2d2ae59 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -5,10 +5,15 @@ from esphome.core import CORE from esphome.cpp_types import std_string from .. import LvContext -from ..defines import CONF_MAIN, KEYBOARD_MODES, literal -from ..helpers import lvgl_components_required +from ..defines import ( + CONF_MAIN, + KEYBOARD_MODES, + add_lv_use, + is_widget_completed, + literal, +) from ..types import LvCompound, LvType -from . import Widget, WidgetType, get_widgets, is_widget_completed +from . import Widget, WidgetType, get_widgets from .buttonmatrix import CONF_BUTTONMATRIX from .textarea import CONF_TEXTAREA, lv_textarea_t @@ -47,8 +52,7 @@ class KeyboardType(WidgetType): return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX async def to_code(self, w: Widget, config: dict): - lvgl_components_required.add("KEY_LISTENER") - lvgl_components_required.add(CONF_KEYBOARD) + add_lv_use("KEY_LISTENER") if mode := config.get(CONF_MODE): await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode)) if textarea := config.get(CONF_TEXTAREA): diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index ab65a7c47d..62ea14bdda 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -41,10 +41,10 @@ from ..defines import ( LV_OBJ_FLAG, LV_PART, LV_SCALE_MODE, + add_lv_use, get_remapped_uses, get_warnings, ) -from ..helpers import add_lv_use from ..lv_validation import ( LV_OPA, LV_RADIUS, @@ -61,7 +61,6 @@ from ..lv_validation import ( padding, pixels, pixels_or_percent, - requires_component, size, ) from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj @@ -214,7 +213,7 @@ INDICATOR_SCHEMA = cv.Schema( cv.GenerateID(CONF_IMAGE_ID): cv.declare_id(lv_image_t), } ), - requires_component("image"), + cv.requires_component("image"), ), cv.Exclusive(CONF_ARC, CONF_INDICATORS): INDICATOR_ARC_SCHEMA.extend( { diff --git a/esphome/components/lvgl/widgets/msgbox.py b/esphome/components/lvgl/widgets/msgbox.py index d0e6bfa3a2..29087009cb 100644 --- a/esphome/components/lvgl/widgets/msgbox.py +++ b/esphome/components/lvgl/widgets/msgbox.py @@ -16,10 +16,10 @@ from ..defines import ( CONF_TITLE, LV_OBJ_FLAG, TYPE_FLEX, + add_lv_use, add_warning, literal, ) -from ..helpers import add_lv_use from ..lv_validation import lv_bool, lv_image, lv_text, pixels_or_percent from ..lvcode import EVENT_ARG, LambdaContext, LocalVariable, lv, lv_expr, lv_obj from ..schemas import ( diff --git a/esphome/components/lvgl/widgets/roller.py b/esphome/components/lvgl/widgets/roller.py index 6f9fee47d4..f3caaa4349 100644 --- a/esphome/components/lvgl/widgets/roller.py +++ b/esphome/components/lvgl/widgets/roller.py @@ -11,7 +11,6 @@ from ..defines import ( ROLLER_MODES, literal, ) -from ..helpers import lvgl_components_required from ..lv_validation import animated, lv_int, lv_text, option_string from ..lvcode import lv_add from ..types import LvSelect @@ -55,7 +54,6 @@ class RollerType(WidgetType): ) async def to_code(self, w, config): - lvgl_components_required.add(CONF_ROLLER) if mode := config.get(CONF_MODE): mode = await ROLLER_MODES.process(mode) lv_add(w.var.set_mode(mode)) diff --git a/tests/component_tests/lvgl/config/widget_state_test.yaml b/tests/component_tests/lvgl/config/widget_state_test.yaml new file mode 100644 index 0000000000..644bf7dac7 --- /dev/null +++ b/tests/component_tests/lvgl/config/widget_state_test.yaml @@ -0,0 +1,83 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + id: lvgl_id + displays: tft_display + pages: + - id: main_page + widgets: + # Widget with multiple static states; one true, one false. + - button: + id: btn_static + state: + checked: true + disabled: false + + # Widget with a templated (lambda) state. + - button: + id: btn_lambda + state: + pressed: !lambda return true; + + # Button referenced by enable/disable actions; the on_click handler + # exercises both branches of the obj_disable/obj_enable code path. + - button: + id: btn_actions + on_click: + - lvgl.widget.disable: btn_actions + - lvgl.widget.enable: btn_actions + + # Button matrix with two buttons; matrix_btn_a is targeted by + # lvgl.widget.disable/enable actions to exercise the + # MatrixButton.set_state code path. + - buttonmatrix: + id: matrix + rows: + - buttons: + - id: matrix_btn_a + text: A + control: + checkable: true + - id: matrix_btn_b + text: B + control: + checkable: true + on_click: + - lvgl.widget.disable: matrix_btn_a + - lvgl.widget.enable: matrix_btn_a + + # Switch derived from an LVGL switch widget – exercises + # set_state(LV_STATE.CHECKED, v) inside the control lambda. + - switch: + id: switch_widget + +switch: + - platform: lvgl + id: lvgl_switch + name: LVGL Switch + widget: switch_widget diff --git a/tests/component_tests/lvgl/test_widget_state.py b/tests/component_tests/lvgl/test_widget_state.py new file mode 100644 index 0000000000..2d87bb382e --- /dev/null +++ b/tests/component_tests/lvgl/test_widget_state.py @@ -0,0 +1,167 @@ +"""Tests for LVGL widget state code generation. + +These tests cover the change from the old ``add_state``/``clear_state`` helpers +on :class:`Widget` (and on :class:`MatrixButton`) to a single ``set_state`` +method that delegates to the new C++ helpers +``LvglComponent::lv_obj_set_state_value`` and +``LvglComponent::lv_buttonmatrix_set_button_ctrl_value``. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.config import read_config +from esphome.core import CORE + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared widget-state YAML config once + per module. + + Module-scoped so the (relatively expensive) codegen runs a single time; + the function-scoped fixtures from ``conftest.py`` (e.g. ``generate_main``) + can't be requested from a higher-scoped fixture, so the small amount of + setup is inlined here. The captured string is independent of + ``CORE.reset()`` calls that the per-test autouse fixtures perform after + this fixture has produced its value. + """ + config_path = Path(request.fspath).parent / "config" / "widget_state_test.yaml" + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_global_section + CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_static_state_emits_set_state_value(main_cpp: str) -> None: + """A widget with ``state: { checked: true, disabled: false }`` should + generate one ``lv_obj_set_state_value`` call per entry, with the + appropriate boolean argument. + """ + assert ( + "LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_CHECKED, true)" + in main_cpp + ) + assert ( + "LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_DISABLED, false)" + in main_cpp + ) + + +def test_lambda_state_emits_set_state_value_with_lambda(main_cpp: str) -> None: + """A widget with ``state: { pressed: !lambda return true; }`` should + generate ``lv_obj_set_state_value(..., LV_STATE_PRESSED, )`` where + ```` is the lambda's return value (cast or inlined), not a static + bool. + """ + # The set_state call is emitted for the templated state. + assert ( + "LvglComponent::lv_obj_set_state_value(btn_lambda, LV_STATE_PRESSED," + in main_cpp + ) + # And it must NOT have collapsed the lambda to a literal true/false. + assert ( + "LvglComponent::lv_obj_set_state_value(btn_lambda, LV_STATE_PRESSED, true)" + not in main_cpp + ) + # The legacy if/else over add_state/remove_state is gone. + assert "lv_obj_add_state(btn_lambda, LV_STATE_PRESSED)" not in main_cpp + assert "lv_obj_remove_state(btn_lambda, LV_STATE_PRESSED)" not in main_cpp + + +def test_widget_disable_action_uses_set_state_value(main_cpp: str) -> None: + """``lvgl.widget.disable: btn_actions`` should emit a + ``set_state_value(..., LV_STATE_DISABLED, true)`` call rather than the + legacy ``lv_obj_add_state``. + """ + assert ( + "LvglComponent::lv_obj_set_state_value(btn_actions, LV_STATE_DISABLED, true)" + in main_cpp + ) + # No leftover legacy add_state for the DISABLED state of this widget. + assert "lv_obj_add_state(btn_actions, LV_STATE_DISABLED)" not in main_cpp + + +def test_widget_enable_action_uses_set_state_value(main_cpp: str) -> None: + """``lvgl.widget.enable: btn_actions`` should emit a + ``set_state_value(..., LV_STATE_DISABLED, false)`` call rather than the + legacy ``lv_obj_remove_state``. + """ + assert ( + "LvglComponent::lv_obj_set_state_value(btn_actions, LV_STATE_DISABLED, false)" + in main_cpp + ) + assert "lv_obj_remove_state(btn_actions, LV_STATE_DISABLED)" not in main_cpp + + +def test_buttonmatrix_disable_action_uses_helper(main_cpp: str) -> None: + """``lvgl.widget.disable: matrix_btn_a`` should route through the new + ``lv_buttonmatrix_set_button_ctrl_value`` helper for button index 0 + with the ``DISABLED`` control bit set to ``true``, instead of the + legacy ``lv_buttonmatrix_set_button_ctrl``. + + The button matrix obj is the compound's ``obj`` member and the index + is the position of the button in the row layout. + """ + assert ( + "LvglComponent::lv_buttonmatrix_set_button_ctrl_value(matrix->obj, 0, " + "LV_BUTTONMATRIX_CTRL_DISABLED, true)" + ) in main_cpp + + +def test_buttonmatrix_enable_action_uses_helper(main_cpp: str) -> None: + """``lvgl.widget.enable: matrix_btn_a`` should route through the new + ``lv_buttonmatrix_set_button_ctrl_value`` helper for button index 0 + with the ``DISABLED`` control bit set to ``false``, instead of the + legacy ``lv_buttonmatrix_clear_button_ctrl``. + """ + assert ( + "LvglComponent::lv_buttonmatrix_set_button_ctrl_value(matrix->obj, 0, " + "LV_BUTTONMATRIX_CTRL_DISABLED, false)" + ) in main_cpp + # The legacy clear_button_ctrl path is gone for the matrix button enable + # action. + assert ( + "lv_buttonmatrix_clear_button_ctrl(matrix->obj, 0, LV_BUTTONMATRIX_CTRL_DISABLED)" + not in main_cpp + ) + + +def test_lvgl_switch_control_calls_set_state_value(main_cpp: str) -> None: + """The LVGL switch platform installs a control lambda that mirrors the + switch's bool value into ``LV_STATE_CHECKED`` via + ``lv_obj_set_state_value`` (replacing the previous if/else over + ``add_state``/``clear_state`` plus an explicit ``send_event`` of + ``lv_api_event``). + """ + # The control lambda calls the new helper with the bool ``v`` parameter. + assert ( + "LvglComponent::lv_obj_set_state_value(switch_widget, LV_STATE_CHECKED, v)" + in main_cpp + ) + # The deprecated lv_api_event symbol must no longer appear anywhere. + assert "lv_api_event" not in main_cpp + + +def test_default_state_does_not_emit_set_state_value(main_cpp: str) -> None: + """A widget without a ``state:`` block must not generate any + ``lv_obj_set_state_value`` calls for it. (Sanity-check that the + new code path is opt-in driven by the YAML.) + """ + assert ( + "LvglComponent::lv_obj_set_state_value(switch_widget, LV_STATE_DISABLED" + not in main_cpp + ) + assert ( + "LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_PRESSED" + not in main_cpp + ) From 930d539969db8a38970ccc53b20d98af218b6e4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 May 2026 19:07:15 -0500 Subject: [PATCH 1133/2214] [config_validation] Add a visibility UI-hint kwarg (#16267) --- esphome/components/time/__init__.py | 9 +- esphome/config_validation.py | 115 ++++++++++++- script/build_language_schema.py | 12 ++ tests/unit_tests/test_config_validation.py | 184 +++++++++++++++++++++ 4 files changed, 313 insertions(+), 7 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 3295366fea..29bb01b499 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -345,7 +345,14 @@ TIME_SCHEMA = cv.Schema( } ), } -).extend(cv.polling_component_schema("15min")) +).extend( + # ``visibility=ADVANCED`` flags the inherited ``update_interval`` + # field for visual editors — the 15min default is correct for + # essentially every user, so editors should keep it tucked under + # "advanced" so it doesn't crowd the form. Validation is + # unaffected; YAML can override as before. + cv.polling_component_schema("15min", visibility=cv.Visibility.ADVANCED) +) def _emit_dst_rule_fields(prefix, rule): diff --git a/esphome/config_validation.py b/esphome/config_validation.py index fbafc5cb07..c993c1dcc5 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -89,6 +89,7 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) +from esphome.enum import StrEnum from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG from esphome.helpers import add_class_to_obj, docs_url, list_starts_with from esphome.schema_extractors import ( @@ -281,6 +282,54 @@ RESERVED_IDS = [ ] +class Visibility(StrEnum): + """Schema-driven UI hint for visual editors. + + The values describe how a schema-aware editor (e.g. the + device-builder dashboard catalog via + ``script/build_language_schema.py``) should render the field. + They do NOT affect validation — the YAML still accepts the key + the same way. ESPHome itself ignores the value at runtime; + consumers downstream of the schema dump act on it. + + A field with no ``visibility`` set (the default) renders on the + editor's main form. The two values below are points along a + single axis of "how prominently to surface this": + + - ``ADVANCED`` — render under the editor's "advanced settings" + disclosure. Use for fields whose default is right for ~all + users (e.g. ``update_interval`` on time platforms — 15 min is + universally correct, but power users can still tune the YAML + directly). + - ``YAML_ONLY`` — never render in a visual editor. Use for + knobs that are dangerous to expose in a UI even as advanced + (``setup_priority`` is the canonical example — casual UI + tweaks can break boot). The YAML escape hatch stays + available for the rare power-user override. + + The single-axis shape encodes "yaml-only is strictly stronger + than advanced" at the type level — there's no way to ask for + both at once, and no way to set a contradictory state like + "advanced=False, yaml_only=True". + + Per-field; the dumper walks recursively into nested schemas + and emits each field's setting independently. Cascading + semantics — "a stricter parent makes its descendants at-least + as strict" — belong on the consumer side: the schema marker + is faithfully what the field author wrote, and a consumer that + cares about effective visibility walks the parent chain and + takes the strictest setting. ``YAML_ONLY`` is strictly stronger + than ``ADVANCED``, which is strictly stronger than no setting. + Inner fields can declare their own visibility; an inner + ``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``, + and the consumer's cascade keeps siblings under the parent at + ``ADVANCED`` regardless of their own (less-strict) setting. + """ + + ADVANCED = "advanced" + YAML_ONLY = "yaml_only" + + class Optional(vol.Optional): """Mark a field as optional and optionally define a default for the field. @@ -295,22 +344,45 @@ class Optional(vol.Optional): In ESPHome, all configuration defaults should be defined with the Optional class during config validation - specifically *not* in the C++ code or the code generation phase. + + See :class:`Visibility` for the ``visibility`` kwarg — a UI + hint for schema-driven editors that doesn't affect validation. """ - def __init__(self, key, default=UNDEFINED): + def __init__( + self, + key, + default=UNDEFINED, + *, + visibility: Visibility | None = None, + ): super().__init__(key, default=default) + self.visibility: Visibility | None = visibility class Required(vol.Required): """Define a field to be required to be set. The validated configuration is guaranteed to contain this key. - All required values should be acceessed with the `config[CONF_]` syntax in code + All required values should be accessed with the `config[CONF_]` syntax in code - *not* the `config.get(CONF_)` syntax. + + See :class:`Visibility` for the ``visibility`` kwarg — a UI + hint for schema-driven editors that doesn't affect validation. + Required fields rarely need it (a required field by definition + needs the user's attention) but the kwarg is exposed for + symmetry so consumers can apply uniform logic across key markers. """ - def __init__(self, key, msg=None): + def __init__( + self, + key, + msg=None, + *, + visibility: Visibility | None = None, + ): super().__init__(key, msg=msg) + self.visibility: Visibility | None = visibility class FinalExternalInvalid(Invalid): @@ -2162,16 +2234,45 @@ ENTITY_BASE_SCHEMA = Schema( ENTITY_BASE_SCHEMA.add_extra(_entity_base_validator) -COMPONENT_SCHEMA = Schema({Optional(CONF_SETUP_PRIORITY): float_}) +COMPONENT_SCHEMA = Schema( + { + # ``setup_priority`` controls the relative order in which + # components are brought up at boot. Wrong values can break + # the boot sequence in subtle ways (e.g. an i2c device set + # to higher priority than the bus). Mark it ``YAML_ONLY`` so + # visual editors never render it — the YAML escape hatch + # stays available for the rare component author who really + # needs to override the default. + Optional(CONF_SETUP_PRIORITY, visibility=Visibility.YAML_ONLY): float_, + } +) -def polling_component_schema(default_update_interval): +def polling_component_schema( + default_update_interval, *, visibility: Visibility | None = None +): """Validate that this component represents a PollingComponent with a configurable update_interval. :param default_update_interval: The default update interval to set for the integration. + :param visibility: When set, propagate to the inherited + ``update_interval`` field's :class:`Visibility` UI hint. Set + this for components whose default cadence is already correct + for ~all users (e.g. time platforms — pass + ``Visibility.ADVANCED``). + + Only honoured on the optional-default branch. When + ``default_update_interval`` is ``None`` the field becomes + ``Required`` (the component has no sensible default cadence and + needs the user to choose), and hiding a Required field behind + an advanced disclosure would be a UX hazard — collapsed-by-default + editors could let the user submit without realising the form has + an unfilled required field. The kwarg is silently ignored on that + path so callers can pass it unconditionally. """ if default_update_interval is None: + # Required → don't honour ``visibility``. + # See the docstring for the UX rationale. return COMPONENT_SCHEMA.extend( { Required(CONF_UPDATE_INTERVAL): update_interval, @@ -2181,7 +2282,9 @@ def polling_component_schema(default_update_interval): return COMPONENT_SCHEMA.extend( { Optional( - CONF_UPDATE_INTERVAL, default=default_update_interval + CONF_UPDATE_INTERVAL, + default=default_update_interval, + visibility=visibility, ): update_interval, } ) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 05ac47bfcc..a7142fa8b5 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1103,6 +1103,18 @@ def convert_keys(converted, schema, path): if default_value is not None: result["default"] = str(default_value) + # UI hint from ``cv.Optional`` / ``cv.Required`` — surfaced + # for schema consumers (visual editors) that want to render + # advanced / yaml-only fields differently. ESPHome itself + # ignores it at runtime; emitting only when set keeps the + # dump compact and backwards-compatible with markers that + # don't carry the attribute. The value is the str form of + # ``cv.Visibility`` (e.g. ``"advanced"`` / ``"yaml_only"``) + # so consumers don't need an enum import to read it. + visibility = getattr(k, "visibility", None) + if visibility is not None: + result["visibility"] = str(visibility) + # Do value convert(v, result, path + f"/{str(k)}") if "schema" not in converted: diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index f038272d8b..fd6c0e95f2 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -793,3 +793,187 @@ def test_update_interval__never_passes_through() -> None: """update_interval: never must still map to SCHEDULER_DONT_RUN.""" result = config_validation.update_interval("never") assert result.total_milliseconds == SCHEDULER_DONT_RUN + + +# --------------------------------------------------------------------------- +# Visibility UI-hint kwarg +# --------------------------------------------------------------------------- + + +def test_optional_default_visibility_is_none() -> None: + """An ``Optional`` with no ``visibility`` kwarg reports ``None``. + + Consumers can read the attribute directly with plain attribute + access; absence (``None``) means "render on the editor's main + form." + """ + o = config_validation.Optional("foo") + assert o.visibility is None + + +def test_optional_visibility_advanced() -> None: + """``visibility=Visibility.ADVANCED`` is recorded on the marker.""" + o = config_validation.Optional( + "foo", visibility=config_validation.Visibility.ADVANCED + ) + assert o.visibility is config_validation.Visibility.ADVANCED + + +def test_optional_visibility_yaml_only() -> None: + """``visibility=Visibility.YAML_ONLY`` is recorded on the marker.""" + o = config_validation.Optional( + "foo", visibility=config_validation.Visibility.YAML_ONLY + ) + assert o.visibility is config_validation.Visibility.YAML_ONLY + + +def test_visibility_str_values_match_dump_emission() -> None: + """``Visibility`` is a ``StrEnum`` whose values are the literal + strings the schema dumper emits. + + The schema bundle consumers (catalog generators, third-party + schema-aware tooling) shouldn't need an enum import to read the + field — pinning the on-the-wire spelling here keeps the dump + contract stable. + """ + assert str(config_validation.Visibility.ADVANCED) == "advanced" + assert str(config_validation.Visibility.YAML_ONLY) == "yaml_only" + + +def test_optional_visibility_does_not_affect_validation() -> None: + """The kwarg is an advisory UI hint — it must not change how the + validator behaves. A schema with ``visibility`` applied must + accept and reject the same values it would without it. + """ + plain = config_validation.Schema( + {config_validation.Optional("foo", default=42): config_validation.int_} + ) + flagged = config_validation.Schema( + { + config_validation.Optional( + "foo", + default=42, + visibility=config_validation.Visibility.YAML_ONLY, + ): config_validation.int_ + } + ) + # Same accept / default-fill behavior. + assert plain({"foo": 7}) == flagged({"foo": 7}) == {"foo": 7} + assert plain({}) == flagged({}) == {"foo": 42} + # Same rejection on bad input. + with pytest.raises(Invalid): + plain({"foo": "not-an-int"}) + with pytest.raises(Invalid): + flagged({"foo": "not-an-int"}) + + +def test_required_default_visibility_is_none() -> None: + """``Required`` mirrors ``Optional`` for the ``visibility`` kwarg.""" + r = config_validation.Required("foo") + assert r.visibility is None + + +def test_required_visibility_kwarg() -> None: + """``Required`` accepts ``visibility`` for symmetry with ``Optional``. + + Required fields rarely need the kwarg, but exposing it lets + consumers apply uniform logic across key markers. + """ + r = config_validation.Required( + "foo", visibility=config_validation.Visibility.ADVANCED + ) + assert r.visibility is config_validation.Visibility.ADVANCED + + +def test_polling_component_schema_visibility_opt_in() -> None: + """``visibility=`` propagates to the inherited ``update_interval``. + + Time platforms pass ``Visibility.ADVANCED``; sensors and other + polling components leave it ``None`` and keep the un-flagged shape. + """ + default = config_validation.polling_component_schema("15min") + advanced = config_validation.polling_component_schema( + "15min", visibility=config_validation.Visibility.ADVANCED + ) + default_keys = {str(k): k for k in default.schema} + advanced_keys = {str(k): k for k in advanced.schema} + assert default_keys["update_interval"].visibility is None + assert ( + advanced_keys["update_interval"].visibility + is config_validation.Visibility.ADVANCED + ) + # The opt-in only touches update_interval — setup_priority + # still inherits its YAML_ONLY visibility from COMPONENT_SCHEMA + # in both shapes. + assert ( + default_keys["setup_priority"].visibility + is config_validation.Visibility.YAML_ONLY + ) + assert ( + advanced_keys["setup_priority"].visibility + is config_validation.Visibility.YAML_ONLY + ) + + +def test_polling_component_schema_no_default_ignores_visibility() -> None: + """``visibility`` is silently ignored when the field is Required. + + When ``default_update_interval=None`` the field becomes + ``Required``. Hiding a Required field behind an advanced + disclosure is a UX hazard — a collapsed-by-default editor could + let the user submit without noticing the form has an unfilled + required field. The helper accepts the kwarg unconditionally + for caller ergonomics but doesn't honour it on this branch. + """ + schema = config_validation.polling_component_schema( + None, visibility=config_validation.Visibility.ADVANCED + ) + keys = {str(k): k for k in schema.schema} + assert isinstance(keys["update_interval"], config_validation.Required) + assert keys["update_interval"].visibility is None + + +def test_visibility_marker_is_per_field_no_mutation() -> None: + """Each field's ``visibility`` is recorded as the author wrote it. + + Cascading semantics — "a stricter parent forces its descendants + at-least as strict" — live on the consumer side, not in the + marker itself. The schema marker stays as-written so consumers + can walk the parent chain and compute the effective visibility + themselves; mutating the marker would lose the per-field author + intent. + + Pin both directions of the no-mutation contract: an inner + ``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY`` + on the marker (the consumer's effective-visibility cascade + would also report ``YAML_ONLY`` since it's stricter), and an + un-marked inner field stays ``None`` on the marker (the + cascade's job is to compute ``ADVANCED`` from the parent — a + detail this test deliberately doesn't pin, since it's a + consumer concern). + """ + inner_unset = config_validation.Optional("baz") + inner_yaml_only = config_validation.Optional( + "qux", visibility=config_validation.Visibility.YAML_ONLY + ) + parent = config_validation.Optional( + "foo", visibility=config_validation.Visibility.ADVANCED + ) + + # Wire them into a nested schema — none of the markers' own + # ``visibility`` should change as a result. + schema = config_validation.Schema( + { + parent: config_validation.Schema( + { + inner_unset: config_validation.int_, + inner_yaml_only: config_validation.string, + } + ) + } + ) + assert schema # touch the schema so any deferred mutation runs + + assert parent.visibility is config_validation.Visibility.ADVANCED + assert inner_unset.visibility is None + assert inner_yaml_only.visibility is config_validation.Visibility.YAML_ONLY From 17080ddce6c98a10ebdd50544fbd688c226e5c44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 May 2026 19:56:56 -0500 Subject: [PATCH 1134/2214] [wifi][rp2040] Add stable wifi-capability helpers for device-builder (#16300) --- esphome/components/rp2040/__init__.py | 16 +++- esphome/components/wifi/__init__.py | 61 +++++++++++++- tests/unit_tests/components/test_rp2040.py | 29 +++++++ tests/unit_tests/components/test_wifi.py | 96 ++++++++++++++++++++++ 4 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/components/test_rp2040.py create mode 100644 tests/unit_tests/components/test_wifi.py diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 79ed00cb41..7e450578cd 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -48,7 +48,21 @@ def board_has_wifi() -> bool: Returns True for unknown/custom boards to avoid rejecting valid configurations for boards not in the generated list. """ - board_info = boards.BOARDS.get(get_board()) + return board_id_has_wifi(get_board()) + + +def board_id_has_wifi(board_id: str) -> bool: + """Return True if *board_id* has WiFi (CYW43 wireless chip). + + Returns True for unknown/custom boards to avoid rejecting valid + configurations for boards not in the generated list. + + Used by device-builder (esphome/device-builder) — separate + explicit-arg helper so callers outside the compile pipeline + don't need ``CORE`` set up to query the board map. Please keep + the signature stable. + """ + board_info = boards.BOARDS.get(board_id) if board_info is None: return True return board_info.get("wifi", False) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 69544f3636..316d432140 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -67,9 +67,66 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["network"] -_LOGGER = logging.getLogger(__name__) - NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] + + +def variant_has_wifi(variant: str) -> bool: + """Return True if *variant* has a native WiFi PHY. + + Variants without a native PHY (ESP32-H2, ESP32-P4) need the + ``esp32_hosted`` co-processor to use ``wifi:``. + + Used by device-builder (esphome/device-builder) to decide whether + its basic-setup wizard emits a ``wifi:`` block — please keep the + signature stable. + """ + return variant not in NO_WIFI_VARIANTS + + +_WIFI_FIRST_PLATFORMS: frozenset[str] = frozenset( + { + Platform.ESP8266, + Platform.BK72XX, + Platform.RTL87XX, + Platform.LN882X, + # Legacy umbrella key for the LibreTiny families (bk72xx / + # rtl87xx / ln882x); still produced by older configs that + # haven't migrated to the per-family keys. + Platform.LIBRETINY_OLDSTYLE, + } +) + + +def has_native_wifi( + *, platform: str, board: str | None = None, variant: str | None = None +) -> bool: + """Return True when the given platform/board/variant has native WiFi. + + Single dispatch entry point for tooling that needs to decide + whether emitting a ``wifi:`` block produces a compilable + config. Caller passes whichever platform-relevant fields they + have (``variant`` for ESP32, ``board`` for RP2040), and this + function routes to the right per-platform check internally. + + Allowlist-based: unknown / Wi-Fi-less platforms (``host``, + ``nrf52``) return False so a future platform added to ESPHome + fails closed in external tooling rather than silently emitting + a ``wifi:`` block the new platform's component would reject. + + Used by device-builder (esphome/device-builder)'s basic-setup + wizard. Centralised here so callers don't have to special-case + each platform — as ESPHome adds new platforms, this dispatcher + is the one place to teach them about Wi-Fi capability. + """ + if platform == Platform.ESP32: + return variant_has_wifi(variant) if variant else True + if platform == Platform.RP2040: + from esphome.components.rp2040 import board_id_has_wifi + + return board_id_has_wifi(board) if board else True + return platform in _WIFI_FIRST_PLATFORMS + + CONF_SAVE = "save" CONF_BAND_MODE = "band_mode" CONF_MIN_AUTH_MODE = "min_auth_mode" diff --git a/tests/unit_tests/components/test_rp2040.py b/tests/unit_tests/components/test_rp2040.py new file mode 100644 index 0000000000..25a9ade567 --- /dev/null +++ b/tests/unit_tests/components/test_rp2040.py @@ -0,0 +1,29 @@ +"""Tests for RP2040 component public helpers.""" + +from esphome.components.rp2040 import board_id_has_wifi + + +def test_board_id_has_wifi_for_known_wifi_board() -> None: + """``rpipicow`` is the canonical Pico W → True.""" + assert board_id_has_wifi("rpipicow") is True + + +def test_board_id_has_wifi_for_known_non_wifi_board() -> None: + """Plain ``rpipico`` has no CYW43 → False.""" + assert board_id_has_wifi("rpipico") is False + + +def test_board_id_has_wifi_for_rp2350_w_variant() -> None: + """``rpipico2w`` is the RP2350 Pico 2 W → True.""" + assert board_id_has_wifi("rpipico2w") is True + + +def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: + """Unknown ids fail open so a custom board is not rejected. + + The validator falls back to ESPHome's compile-time check; the + helper returning True here means the wizard emits a ``wifi:`` + block and any genuinely-unsupported config trips the existing + "no CYW43" guard at compile time. + """ + assert board_id_has_wifi("not-a-real-board-id") is True diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py new file mode 100644 index 0000000000..e93ae4b503 --- /dev/null +++ b/tests/unit_tests/components/test_wifi.py @@ -0,0 +1,96 @@ +"""Tests for WiFi component public helpers.""" + +import pytest + +from esphome.components.esp32 import const +from esphome.components.wifi import has_native_wifi, variant_has_wifi +from esphome.const import Platform + + +@pytest.mark.parametrize( + "variant", + [ + const.VARIANT_ESP32, + const.VARIANT_ESP32S2, + const.VARIANT_ESP32S3, + const.VARIANT_ESP32C3, + const.VARIANT_ESP32C6, + ], +) +def test_variant_has_wifi_for_native_phy_variants(variant: str) -> None: + """Variants with a native WiFi PHY → True.""" + assert variant_has_wifi(variant) is True + + +@pytest.mark.parametrize( + "variant", + [ + const.VARIANT_ESP32H2, + const.VARIANT_ESP32P4, + ], +) +def test_variant_has_wifi_for_no_phy_variants(variant: str) -> None: + """Variants that need ``esp32_hosted`` → False.""" + assert variant_has_wifi(variant) is False + + +def test_has_native_wifi_dispatches_esp32_to_variant_check() -> None: + """ESP32 platform routes through ``variant_has_wifi``.""" + assert ( + has_native_wifi(platform=Platform.ESP32, variant=const.VARIANT_ESP32C3) is True + ) + assert ( + has_native_wifi(platform=Platform.ESP32, variant=const.VARIANT_ESP32H2) is False + ) + + +def test_has_native_wifi_dispatches_rp2040_to_board_check() -> None: + """RP2040 platform routes through ``rp2040.board_id_has_wifi``.""" + assert has_native_wifi(platform=Platform.RP2040, board="rpipicow") is True + assert has_native_wifi(platform=Platform.RP2040, board="rpipico") is False + + +def test_has_native_wifi_returns_false_for_nrf52() -> None: + """nRF52 family is BLE-only — no Wi-Fi PHY in the platform.""" + assert has_native_wifi(platform=Platform.NRF52) is False + + +def test_has_native_wifi_returns_false_for_host() -> None: + """``host`` platform compiles ESPHome to a host binary — no radio at all.""" + assert has_native_wifi(platform=Platform.HOST) is False + + +def test_has_native_wifi_returns_false_for_unknown_platform() -> None: + """Unknown platform string fails closed. + + A future platform added to ESPHome that's missed here returns + False rather than silently emitting a ``wifi:`` block external + tooling would have to compile and reject — fail-closed surfaces + the gap as an obvious "needs wifi support added" signal. + """ + assert has_native_wifi(platform="not-a-real-platform") is False + + +@pytest.mark.parametrize( + "platform", + [ + Platform.ESP8266, + Platform.BK72XX, + Platform.RTL87XX, + Platform.LN882X, + Platform.LIBRETINY_OLDSTYLE, + ], +) +def test_has_native_wifi_returns_true_for_wifi_first_platforms(platform: str) -> None: + """Catch-all Wi-Fi-first platforms → True regardless of board / variant.""" + assert has_native_wifi(platform=platform) is True + + +def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None: + """ESP32 without a variant id falls open to True (the chip family default).""" + assert has_native_wifi(platform=Platform.ESP32) is True + + +def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: + """RP2040 without a board id falls open to True (custom-board default).""" + assert has_native_wifi(platform=Platform.RP2040) is True From 66e2dcffc448ae60e8c1209bbc0aeca1b50f371d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 May 2026 20:00:09 -0500 Subject: [PATCH 1135/2214] [cli] Tighten command_rename: scoped name rewrite, target-collision check (#16296) --- esphome/__main__.py | 62 ++++- tests/unit_tests/test_main.py | 461 ++++++++++++++++++++++++++++++++++ 2 files changed, 519 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c1451c5faf..a0ee5a359f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1813,11 +1813,32 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: old_name = yaml[CONF_ESPHOME][CONF_NAME] match = re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name) if match is None: - new_raw = re.sub( - rf"name:\s+[\"']?{old_name}[\"']?", - f'name: "{new_name}"', - raw_contents, + # Only swap the ``name:`` line that sits directly under the + # top-level ``esphome:`` block. A naked ``re.sub`` would + # also clobber any other ``name:`` line whose value happens + # to match (e.g. a sensor / output / wifi entry sharing the + # device's hostname), silently rewriting unrelated user + # configuration. The pattern anchors: + # - at the start of the line so ``friendly_name:``, + # ``device_name:`` etc. don't match the trailing ``name:`` + # substring; and + # - at the end of the value (lookahead for whitespace + + # comment + EOL) so ``old_name`` doesn't match as a + # prefix of a longer value (``kitchen`` vs ``kitchen2``). + name_pattern = re.compile( + rf"^(\s*)name:\s+[\"']?{re.escape(old_name)}[\"']?(?=\s*(?:#|$))" ) + out_lines: list[str] = [] + in_esphome_block = False + for line in raw_contents.splitlines(keepends=True): + if line and not line[0].isspace() and line.strip(): + in_esphome_block = line.lstrip().startswith("esphome:") + out_lines.append(line) + continue + if in_esphome_block: + line = name_pattern.sub(rf'\1name: "{new_name}"', line, count=1) + out_lines.append(line) + new_raw = "".join(out_lines) else: old_name = yaml[CONF_SUBSTITUTIONS][match.group(1)] if ( @@ -1840,7 +1861,40 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: flags=re.MULTILINE, ) + # ``new_name == old_name`` (after substitution resolution) is + # a no-op rewrite that would still queue a pointless re-flash. + # Catch it before the path-equality check below — covers the + # case where the config filename doesn't match the device name + # (e.g. ``weird-file.yaml`` whose ``esphome.name`` is + # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` + # would otherwise just re-flash the same hostname). + if new_name == old_name: + print( + color( + AnsiFore.BOLD_RED, + f"'{new_name}' is already the device's name.", + ) + ) + return 1 + new_path: Path = CORE.config_dir / (new_name + ".yaml") + if new_path.resolve() == CORE.config_path.resolve(): + print( + color( + AnsiFore.BOLD_RED, + f"'{new_name}' is already the device's name.", + ) + ) + return 1 + if new_path.exists(): + print( + color( + AnsiFore.BOLD_RED, + f"Cannot rename: {new_path} already exists. " + "Refusing to overwrite an existing configuration.", + ) + ) + return 1 print( f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 4b0590cf76..2823310f0e 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -3595,6 +3595,467 @@ esp32: assert "Rename failed" in captured.out +def test_command_rename_install_failure_reverts( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename when the install (esphome run) step fails.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + # First call (config validation) succeeds; second (esphome run) fails. + mock_run_external_process.side_effect = [0, 1] + + result = command_rename(args, {}) + + assert result == 1 + + # New file was unlinked when install failed. + new_file = tmp_path / "newname.yaml" + assert not new_file.exists() + + # Old file is preserved so the device stays reachable under the + # original hostname. + assert config_file.exists() + + +def test_command_rename_target_exists_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when the target filename already exists. + + Without this guard, the rename would overwrite the unrelated + device's YAML and OTA-install our firmware to the wrong device. + """ + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + target_file = tmp_path / "newname.yaml" + target_file.write_text(""" +esphome: + name: someoneelse + +esp32: + board: nodemcu-32s +""") + target_original = target_file.read_text() + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + # No subprocess work happened — refusal is up-front. + mock_run_external_process.assert_not_called() + # Target file untouched: same content, still on disk. + assert target_file.exists() + assert target_file.read_text() == target_original + # Source file untouched. + assert config_file.exists() + + captured = capfd.readouterr() + assert "already exists" in captured.out + + +def test_command_rename_same_name_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when the new name matches the current name. + + A same-name rename would otherwise re-write the YAML and queue + a redundant compile + install — wasted work the user almost + certainly didn't intend. + """ + config_file = tmp_path / "samename.yaml" + config_file.write_text(""" +esphome: + name: samename + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "samename"}} + + args = MockArgs(name="samename", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + mock_run_external_process.assert_not_called() + # File preserved verbatim — no rewrite happened. + assert config_file.exists() + + captured = capfd.readouterr() + assert "already" in captured.out.lower() + + +def test_command_rename_does_not_touch_friendly_name_substring( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + r"""Test rename does not match the ``name:`` substring of ``friendly_name:``. + + Without anchoring the regex at line start, the pattern + ``\s*name:\s+`` could match the trailing ``name:`` + substring inside ``friendly_name: ``. The rewrite would + flip both lines to the new name, leaving the user with a + silently corrupted ``friendly_name``. + """ + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + friendly_name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "newname.yaml" + content = new_file.read_text() + # esphome.name swapped. + assert 'name: "newname"' in content + # friendly_name kept verbatim. + assert "friendly_name: oldname" in content + + +def test_command_rename_does_not_match_old_name_as_value_prefix( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + r"""Test rename does not match ``old_name`` as a prefix of a longer value. + + With ``old_name = kitchen`` the value ``kitchen2`` (a sensor + or wifi entry) would otherwise match the unanchored + ``["']?kitchen["']?`` pattern at the prefix and get + rewritten to the new name. The end-of-value lookahead keeps + the match restricted to whole tokens. + """ + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: kitchen + +esp32: + board: nodemcu-32s + +wifi: + ap: + ssid: kitchen2 +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "garage.yaml" + content = new_file.read_text() + assert 'name: "garage"' in content + # The wifi ssid value is unrelated and stays intact. + assert "ssid: kitchen2" in content + + +def test_command_rename_same_resolved_name_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when ``new_name`` matches the resolved device name. + + The path-equality check only catches the case where the + config filename matches the device name. For a config whose + filename and ``esphome.name`` differ (here ``weird-file.yaml`` + holds ``esphome.name: kitchen``), running + ``esphome rename weird-file.yaml kitchen`` would otherwise + fall through to the rewrite + install: the YAML's name stays + ``kitchen``, the file is renamed to ``kitchen.yaml``, and the + device gets a redundant flash. Refuse up-front so the + "already the device's name" message matches reality. + """ + config_file = tmp_path / "weird-file.yaml" + config_file.write_text(""" +esphome: + name: kitchen + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="kitchen", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + mock_run_external_process.assert_not_called() + # Source file untouched, no derived target written. + assert config_file.exists() + assert not (tmp_path / "kitchen.yaml").exists() + + captured = capfd.readouterr() + assert "already" in captured.out.lower() + + +def test_command_rename_target_path_equals_source_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when the new path resolves to the source file. + + Reachable only when the YAML's filename and ``esphome.name`` + disagree — here ``kitchen.yaml`` holds ``esphome.name: garage`` + and the user runs ``esphome rename kitchen.yaml kitchen``. The + name-equality check above passes (``garage != kitchen``), but + ``/kitchen.yaml`` resolves to the source file + itself, so the rewrite would clobber the source mid-rename. + Refuse rather than silently overwriting. + """ + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: garage + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "garage"}} + + args = MockArgs(name="kitchen", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + mock_run_external_process.assert_not_called() + # Source file still present and unmodified. + assert config_file.exists() + assert "name: garage" in config_file.read_text() + + captured = capfd.readouterr() + assert "already" in captured.out.lower() + + +def test_command_rename_does_not_touch_lookalike_name_in_other_blocks( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename only swaps the esphome.name line. + + A device whose name happens to match a sensor's / output's + ``name:`` value must not have those other names rewritten — + they're independent. Without an anchor for the esphome block + a naive regex would clobber every line whose value matches. + """ + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: kitchen + +esp32: + board: nodemcu-32s + +sensor: + - platform: template + name: kitchen + lambda: 'return 0;' +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + new_file = tmp_path / "garage.yaml" + content = new_file.read_text() + # esphome.name renamed. + assert 'name: "garage"' in content + # Sensor's name is the user's entity name — must not be touched. + assert " name: kitchen\n" in content + + +def test_command_rename_preserves_trailing_comment( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename preserves a trailing ``# comment`` on the name line.""" + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: kitchen # primary device + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + new_file = tmp_path / "garage.yaml" + content = new_file.read_text() + assert "# primary device" in content + + +def test_command_rename_handles_double_quoted_value( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename matches when the existing value is double-quoted.""" + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: "kitchen" + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "garage.yaml" + assert 'name: "garage"' in new_file.read_text() + + +def test_command_rename_handles_single_quoted_value( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename matches when the existing value is single-quoted.""" + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: 'kitchen' + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "garage.yaml" + assert 'name: "garage"' in new_file.read_text() + + +def test_command_rename_too_many_substitution_matches_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when ``${var}`` resolves to multiple matches. + + When ``esphome.name: ${device_name}`` and the substitution + definition ``device_name: foo`` appears more than once in the + YAML (e.g. inside multiple included blocks), the regex rewrite + can't tell which one to flip. Rather than silently picking one + or rewriting both, the command refuses. + """ + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +substitutions: + device_name: oldname + +esphome: + name: ${device_name} + +# A copy-pasted block that re-declares the substitution at the +# same indent level - happens when users splice in a packaged +# fragment without renaming the variable. +example: + device_name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = { + CONF_ESPHOME: {CONF_NAME: "oldname"}, + CONF_SUBSTITUTIONS: {"device_name": "oldname"}, + } + + args = MockArgs(name="newname", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + mock_run_external_process.assert_not_called() + # File untouched. + assert config_file.exists() + assert "device_name: oldname" in config_file.read_text() + + captured = capfd.readouterr() + assert "Too many matches" in captured.out + + def test_command_update_all_path_string_conversion( tmp_path: Path, mock_run_external_process: Mock, From e9cc10fedca583767a5351b520cc2c64126babd8 Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Mon, 11 May 2026 04:12:07 +0200 Subject: [PATCH 1136/2214] [core] Native idf full support (#14678) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Jonathan Swoboda Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .github/workflows/ci.yml | 86 ++ esphome/__main__.py | 124 ++- esphome/build_gen/espidf.py | 24 +- esphome/components/esp32/__init__.py | 91 +- esphome/components/ethernet/__init__.py | 3 +- esphome/components/neopixelbus/light.py | 1 + esphome/components/nrf52/ota.py | 2 +- esphome/const.py | 9 +- esphome/core/__init__.py | 25 +- esphome/espidf/__init__.py | 0 esphome/espidf/api.py | 499 ++++++++++ esphome/espidf/component.py | 937 ++++++++++++++++++ esphome/espidf/framework.py | 1098 +++++++++++++++++++++ esphome/espidf/get_idf_tool_paths.py | 51 + esphome/espidf/get_idf_version.py | 14 + esphome/espidf/runner.py | 223 +++++ esphome/espidf_api.py | 274 ----- esphome/espota2.py | 2 +- esphome/helpers.py | 13 +- esphome/web_server_ota.py | 2 +- script/ci-custom.py | 2 +- script/test_build_components.py | 51 +- tests/component_tests/esp32/test_esp32.py | 4 +- tests/unit_tests/test_core.py | 4 +- tests/unit_tests/test_espidf_component.py | 357 +++++++ tests/unit_tests/test_espota2.py | 3 +- tests/unit_tests/test_main.py | 2 + 27 files changed, 3537 insertions(+), 364 deletions(-) create mode 100644 esphome/espidf/__init__.py create mode 100644 esphome/espidf/api.py create mode 100644 esphome/espidf/component.py create mode 100644 esphome/espidf/framework.py create mode 100644 esphome/espidf/get_idf_tool_paths.py create mode 100644 esphome/espidf/get_idf_version.py create mode 100644 esphome/espidf/runner.py delete mode 100644 esphome/espidf_api.py create mode 100644 tests/unit_tests/test_espidf_component.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f43c46dd00..9909d7a5dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -783,6 +783,91 @@ jobs: # Run compilation with grouping and isolation python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv" + test-native-idf: + name: Test components with native ESP-IDF + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' + env: + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Cache ESPHome + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: ~/.esphome-idf + key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} + + - name: Run native ESP-IDF compile test + run: | + . venv/bin/activate + + # Check if /mnt has more free space than / before bind mounting + # Extract available space in KB for comparison + root_avail=$(df -k / | awk 'NR==2 {print $4}') + mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}') + + echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB" + + # Only use /mnt if it has more space than / + if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then + echo "Using /mnt for build files (more space available)" + # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there) + sudo mkdir -p /mnt/esphome-idf + sudo chown $USER:$USER /mnt/esphome-idf + mkdir -p ~/.esphome-idf + sudo mount --bind /mnt/esphome-idf ~/.esphome-idf + + # Bind mount test build directory to /mnt + sudo mkdir -p /mnt/test_build_components_build + sudo chown $USER:$USER /mnt/test_build_components_build + mkdir -p tests/test_build_components/build + sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build + else + echo "Using / for build files (more space available than /mnt or /mnt unavailable)" + fi + + echo "Testing components: $TEST_COMPONENTS" + echo "" + + # Show disk space before validation (after bind mounts setup) + echo "Disk space before config validation:" + df -h + echo "" + + # Run config validation (auto-grouped by test_build_components.py) + python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf + + echo "" + echo "Config validation passed! Starting compilation..." + echo "" + + # Show disk space before compilation + echo "Disk space before compilation:" + df -h + echo "" + + # Run compilation (auto-grouped by test_build_components.py) + python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf + + - name: Save ESPHome cache + if: github.ref == 'refs/heads/dev' + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: ~/.esphome-idf + key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} + pre-commit-ci-lite: name: pre-commit.ci lite runs-on: ubuntu-latest @@ -1114,6 +1199,7 @@ jobs: - determine-jobs - device-builder - test-build-components-split + - test-native-idf - pre-commit-ci-lite - memory-impact-target-branch - memory-impact-pr-branch diff --git a/esphome/__main__.py b/esphome/__main__.py index a0ee5a359f..01b33eb8ac 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -52,12 +52,12 @@ from esphome.const import ( CONF_WEB_SERVER, ENV_NOGITIGNORE, KEY_CORE, - KEY_NATIVE_IDF, KEY_TARGET_PLATFORM, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, SECRETS_FILES, + Toolchain, ) from esphome.core import CORE, EsphomeError, coroutine from esphome.enum import StrEnum @@ -155,7 +155,6 @@ class ArgsProtocol(Protocol): configuration: str name: str upload_speed: str | None - native_idf: bool def choose_prompt(options, purpose: str = None): @@ -720,17 +719,14 @@ def _wrap_to_code(name, comp, yaml_util): return wrapped -def write_cpp(config: ConfigType, native_idf: bool = False) -> int: +def write_cpp(config: ConfigType) -> int: from esphome import writer if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() - # Store native_idf flag so esp32 component can check it - CORE.data[KEY_NATIVE_IDF] = native_idf - generate_cpp_contents(config) - return write_cpp_file(native_idf=native_idf) + return write_cpp_file() def generate_cpp_contents(config: ConfigType) -> None: @@ -746,13 +742,13 @@ def generate_cpp_contents(config: ConfigType) -> None: CORE.flush_tasks() -def write_cpp_file(native_idf: bool = False) -> int: +def write_cpp_file() -> int: from esphome import writer code_s = indent(CORE.cpp_main_section) writer.write_cpp(code_s) - if native_idf and CORE.is_esp32 and CORE.target_framework == "esp-idf": + if CORE.using_toolchain_esp_idf: from esphome.build_gen import espidf espidf.write_project() @@ -765,22 +761,21 @@ def write_cpp_file(native_idf: bool = False) -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int: - native_idf = getattr(args, "native_idf", False) - # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) - if native_idf and CORE.is_esp32 and CORE.target_framework == "esp-idf": - from esphome import espidf_api + if CORE.using_toolchain_esp_idf: + from esphome.espidf import api - rc = espidf_api.run_compile(config, CORE.verbose) + rc = api.run_compile(config, CORE.verbose) if rc != 0: return rc - # Create factory.bin and ota.bin - espidf_api.create_factory_bin() - espidf_api.create_ota_bin() + # Create factory.bin, ota.bin, and firmware.elf copy + api.create_factory_bin() + api.create_ota_bin() + api.create_elf_copy() else: from esphome import platformio_api @@ -883,6 +878,10 @@ def upload_using_esptool( if file is not None: flash_images = [FlashImage(path=file, offset="0x0")] + elif CORE.using_toolchain_esp_idf: + from esphome.espidf import api + + flash_images = [FlashImage(path=api.get_factory_firmware_path(), offset="0x0")] else: from esphome import platformio_api @@ -1447,8 +1446,7 @@ def command_vscode(args: ArgsProtocol) -> int | None: def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: - native_idf = getattr(args, "native_idf", False) - exit_code = write_cpp(config, native_idf=native_idf) + exit_code = write_cpp(config) if exit_code != 0: return exit_code if args.only_generate: @@ -1458,9 +1456,14 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: if exit_code != 0: return exit_code if CORE.is_host: - from esphome.platformio_api import get_idedata + if CORE.using_toolchain_esp_idf: + from esphome.espidf import api - program_path = str(get_idedata(config).firmware_elf_path) + program_path = str(api.get_elf_path()) + else: + from esphome.platformio_api import get_idedata + + program_path = str(get_idedata(config).firmware_elf_path) _LOGGER.info("Successfully compiled program to path '%s'", program_path) else: _LOGGER.info("Successfully compiled program.") @@ -1503,8 +1506,7 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: - native_idf = getattr(args, "native_idf", False) - exit_code = write_cpp(config, native_idf=native_idf) + exit_code = write_cpp(config) if exit_code != 0: return exit_code exit_code = compile_program(args, config) @@ -1512,9 +1514,14 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: return exit_code _LOGGER.info("Successfully compiled program.") if CORE.is_host: - from esphome.platformio_api import get_idedata + if CORE.using_toolchain_esp_idf: + from esphome.espidf import api - program_path = str(get_idedata(config).firmware_elf_path) + program_path = str(api.get_elf_path()) + else: + from esphome.platformio_api import get_idedata + + program_path = str(get_idedata(config).firmware_elf_path) _LOGGER.info("Running program from path '%s'", program_path) return run_external_process(program_path) @@ -1705,6 +1712,13 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json + if not CORE.using_toolchain_platformio: + _LOGGER.error( + "The idedata command is not compatible with %s toolchain", + CORE.toolchain.value, + ) + return 1 + from esphome import platformio_api logging.disable(logging.INFO) @@ -1724,7 +1738,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: This command compiles the configuration and performs memory analysis. Compilation is fast if sources haven't changed (just relinking). """ - from esphome import platformio_api from esphome.analyze_memory.cli import MemoryAnalyzerCLI from esphome.analyze_memory.ram_strings import RamStringsAnalyzer @@ -1738,12 +1751,25 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: _LOGGER.info("Successfully compiled program.") # Get idedata for analysis - idedata = platformio_api.get_idedata(config) - if idedata is None: - _LOGGER.error("Failed to get IDE data for memory analysis") - return 1 + idedata = None + if CORE.using_toolchain_esp_idf: + from esphome.espidf import api - firmware_elf = Path(idedata.firmware_elf_path) + objdump_path = str(api.get_objdump_path()) + readelf_path = str(api.get_readelf_path()) + + firmware_elf = api.get_elf_path() + else: + from esphome import platformio_api + + idedata = platformio_api.get_idedata(config) + if idedata is None: + _LOGGER.error("Failed to get IDE data for memory analysis") + return 1 + objdump_path = idedata.objdump_path + readelf_path = idedata.readelf_path + + firmware_elf = Path(idedata.firmware_elf_path) # Extract external components from config external_components = detect_external_components(config) @@ -1753,8 +1779,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: _LOGGER.info("Analyzing memory usage...") analyzer = MemoryAnalyzerCLI( str(firmware_elf), - idedata.objdump_path, - idedata.readelf_path, + objdump_path, + readelf_path, external_components, idedata=idedata, ) @@ -1770,7 +1796,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: try: ram_analyzer = RamStringsAnalyzer( str(firmware_elf), - objdump_path=idedata.objdump_path, + objdump_path=objdump_path, platform=CORE.target_platform, ) ram_analyzer.analyze() @@ -2015,6 +2041,17 @@ def parse_args(argv): action="store_true", default=False, ) + options_parser.add_argument( + "--toolchain", + type=Toolchain, + default=None, + choices=list(Toolchain), + metavar="{" + ",".join(t.value for t in Toolchain) + "}", + help=( + "Select toolchain for compiling. Overrides '.toolchain' in YAML. " + f"Default: {Toolchain.PLATFORMIO.value}." + ), + ) parser = argparse.ArgumentParser( description=f"ESPHome {const.__version__}", parents=[options_parser] @@ -2059,11 +2096,6 @@ def parse_args(argv): help="Only generate source code, do not compile.", action="store_true", ) - parser_compile.add_argument( - "--native-idf", - help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).", - action="store_true", - ) parser_upload = subparsers.add_parser( "upload", @@ -2171,11 +2203,6 @@ def parse_args(argv): help="Reset the device before starting serial logs.", default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"), ) - parser_run.add_argument( - "--native-idf", - help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).", - action="store_true", - ) parser_run.add_argument( "--ota-platform", choices=[CONF_ESPHOME, CONF_WEB_SERVER], @@ -2398,6 +2425,9 @@ def run_esphome(argv): CORE.config_path = conf_path CORE.dashboard = args.dashboard + if args.toolchain is not None: + # CLI toolchain wins over esp32.toolchain in YAML. + CORE.toolchain = args.toolchain # Commands that don't need fresh external components: logs just connects # to the device, and clean is about to delete the build directory. @@ -2410,6 +2440,12 @@ def run_esphome(argv): 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. + if CORE.toolchain is None: + CORE.toolchain = Toolchain.PLATFORMIO + if args.command not in POST_CONFIG_ACTIONS: safe_print(f"Unknown command {args.command}") return 1 diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 01923baaac..b1443edac3 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,6 +6,7 @@ from pathlib import Path from esphome.components.esp32 import get_esp32_variant from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed +from esphome.writer import update_storage_json def get_available_components() -> list[str] | None: @@ -54,7 +55,7 @@ def get_project_cmakelists() -> str: idf_target = variant.lower().replace("-", "") # Extract compile definitions from build flags (-DXXX -> XXX) - compile_defs = [flag for flag in CORE.build_flags if flag.startswith("-D")] + compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")] extra_compile_options = "\n".join( f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)' for compile_def in compile_defs @@ -64,6 +65,22 @@ def get_project_cmakelists() -> str: # Auto-generated by ESPHome cmake_minimum_required(VERSION 3.16) +# On Windows, Ninja can fail with: +# "CreateProcess: The parameter is incorrect (is the command line too long?)" +# when compiler/linker command lines exceed the OS length limit. +# +# The following settings force CMake/Ninja to use *response files* (@file.rsp) +# to pass long lists of includes, objects, and other arguments indirectly, +# avoiding command-line length limits and fixing the build failure. +# +# This is especially useful for large ESP-IDF / ESPHome projects with many +# source files or include directories. +set(CMAKE_C_USE_RESPONSE_FILE_FOR_INCLUDES 1) +set(CMAKE_CXX_USE_RESPONSE_FILE_FOR_INCLUDES 1) +set(CMAKE_C_USE_RESPONSE_FILE_FOR_OBJECTS 1) +set(CMAKE_CXX_USE_RESPONSE_FILE_FOR_OBJECTS 1) +set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1) + set(IDF_TARGET {idf_target}) set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) @@ -124,6 +141,11 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC def write_project(minimal: bool = False) -> None: """Write ESP-IDF project files.""" + # Refresh /storage/.yaml.json so the dashboard's + # /info and /downloads endpoints can locate the build (they 404 + # otherwise). This mirrors the PlatformIO build-gen path's call + # in build_gen/platformio.py:write_ini(). + update_storage_json() mkdir_p(CORE.build_path) mkdir_p(CORE.relative_src_path()) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 582721ef73..ba32d13ab3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -31,6 +31,7 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_SIZE, CONF_SOURCE, + CONF_TOOLCHAIN, CONF_TYPE, CONF_VARIANT, CONF_VERSION, @@ -38,16 +39,17 @@ from esphome.const import ( KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_NAME, - KEY_NATIVE_IDF, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_ESP32, ThreadModel, + Toolchain, __version__, ) -from esphome.core import CORE, HexInt +from esphome.core import CORE, HexInt, Library from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority +from esphome.espidf.component import generate_idf_component import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed from esphome.types import ConfigType @@ -465,6 +467,9 @@ def set_core_data(config): if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: CORE.data[KEY_ESP32][KEY_IDF_VERSION] = framework_ver elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: + if CORE.using_toolchain_esp_idf: + # Official ESP-IDF frameworks don't use extra + idf_ver = cv.Version(idf_ver.major, idf_ver.minor, idf_ver.patch) CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver else: raise cv.Invalid( @@ -652,7 +657,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: return f"{ARDUINO_FRAMEWORK_PKG}@https://github.com/espressif/arduino-esp32/releases/download/{ver}/{filename}" -def _format_framework_espidf_version( +def _format_framework_pio_espidf_version( ver: cv.Version, release: str | None = None ) -> str: # format the given espidf (https://github.com/pioarduino/esp-idf/releases) version to @@ -741,6 +746,7 @@ ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { "latest": cv.Version(5, 5, 4), "dev": cv.Version(5, 5, 4), } + ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 1 @@ -774,7 +780,7 @@ PLATFORM_VERSION_LOOKUP = { } -def _check_versions(config): +def _check_pio_versions(config): config = config.copy() value = config[CONF_FRAMEWORK] @@ -785,7 +791,7 @@ def _check_versions(config): ) platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]] - value[CONF_PLATFORM_VERSION] = _parse_platform_version(str(platform_lookup)) + value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) if value[CONF_TYPE] == FRAMEWORK_ARDUINO: version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]] @@ -813,7 +819,7 @@ def _check_versions(config): platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, - _format_framework_espidf_version(version, value.get(CONF_RELEASE)), + _format_framework_pio_espidf_version(version, value.get(CONF_RELEASE)), ) if _is_framework_url(value[CONF_SOURCE]): value[CONF_SOURCE] = f"pioarduino/framework-espidf@{value[CONF_SOURCE]}" @@ -823,7 +829,7 @@ def _check_versions(config): raise cv.Invalid( "Framework version not recognized; please specify platform_version" ) - value[CONF_PLATFORM_VERSION] = _parse_platform_version(str(platform_lookup)) + value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) if version != recommended_version: _LOGGER.warning( @@ -831,7 +837,7 @@ def _check_versions(config): "If there are connectivity or build issues please remove the manual version." ) - if value[CONF_PLATFORM_VERSION] != _parse_platform_version( + if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version( str(PLATFORM_VERSION_LOOKUP["recommended"]) ): _LOGGER.warning( @@ -842,7 +848,38 @@ def _check_versions(config): return config -def _parse_platform_version(value): +def _check_esp_idf_versions(config): + config = _check_pio_versions(config) + value = config[CONF_FRAMEWORK] + + # Remove unwanted keys if present + for key in (CONF_SOURCE, CONF_PLATFORM_VERSION): + value.pop(key, None) + + # Official ESP-IDF frameworks don't use extra + version = cv.Version.parse(value[CONF_VERSION]) + version = cv.Version(version.major, version.minor, version.patch) + + value[CONF_VERSION] = str(version) + + return config + + +def _validate_toolchain(value) -> Toolchain: + return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value)) + + +def _check_versions(config): + # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. + if CORE.toolchain is None: + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + + if CORE.using_toolchain_esp_idf: + return _check_esp_idf_versions(config) + return _check_pio_versions(config) + + +def _parse_pio_platform_version(value): try: ver = cv.Version.parse(cv.version_number(value)) release = f"{ver.major}.{ver.minor:02d}.{ver.patch:02d}" @@ -1272,7 +1309,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, cv.Optional(CONF_RELEASE): cv.string_strict, cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional(CONF_PLATFORM_VERSION): _parse_pio_platform_version, cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { cv.string_strict: cv.string_strict }, @@ -1524,6 +1561,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA, + cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All( cv.positive_time_period_seconds, cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)), @@ -1672,11 +1710,11 @@ async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] conf = config[CONF_FRAMEWORK] - # Check if using native ESP-IDF build (--native-idf) - use_platformio = not CORE.data.get(KEY_NATIVE_IDF, False) + # Check if using ESP-IDF toolchain + use_platformio = not CORE.using_toolchain_esp_idf if use_platformio: # Clear IDF environment variables to avoid conflicts with PlatformIO's ESP-IDF - # but keep them when using --native-idf for native ESP-IDF builds + # but keep them when using ESP-IDF toolchain for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"): os.environ.pop(clean_var, None) @@ -1716,6 +1754,8 @@ async def to_code(config): ) else: cg.add_build_flag("-Wno-error=format") + cg.add_build_flag("-Wno-error=missing-field-initializers") + cg.add_build_flag("-Wno-error=volatile") cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") @@ -1792,7 +1832,7 @@ async def to_code(config): if (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: cg.add_platformio_option( "platform_packages", - [_format_framework_espidf_version(idf_ver)], + [_format_framework_pio_espidf_version(idf_ver)], ) # Use stub package to skip downloading precompiled libs stubs_dir = CORE.relative_build_path("arduino_libs_stub") @@ -2424,6 +2464,14 @@ def _write_sdkconfig(): clean_build(clear_pio_cache=False) +def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: + dependency: dict[str, str] = {} + name, version, path = generate_idf_component(library) + dependency["override_path"] = str(path) + dependency["version"] = version + return name, dependency + + def _write_idf_component_yml(): yml_path = CORE.relative_build_path("src/idf_component.yml") dependencies: dict[str, dict] = {} @@ -2465,6 +2513,21 @@ def _write_idf_component_yml(): if stub_path.exists(): rmtree(stub_path) + if CORE.using_toolchain_esp_idf: + add_idf_component( + name="espressif/arduino-esp32", + ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), + ) + + if CORE.using_toolchain_esp_idf: + # Try to convert PlatformIO library to ESP-IDF components + for name, library in CORE.platformio_libraries.items(): + # Don't process arduino libraries + if name in ARDUINO_DISABLED_LIBRARIES: + continue + dependency_name, dependency = _platformio_library_to_dependency(library) + dependencies[dependency_name] = dependency + if CORE.data[KEY_ESP32][KEY_COMPONENTS]: components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS] for name, component in components.items(): diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 10f9a73863..3f88f8ef9a 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -36,7 +36,6 @@ from esphome.const import ( CONF_VALUE, KEY_CORE, KEY_FRAMEWORK_VERSION, - KEY_NATIVE_IDF, Platform, PlatformFramework, ) @@ -705,7 +704,7 @@ def _filter_source_files() -> list[str]: # and pioarduino doesn't have it builtin (IDF 5.4.2 to 5.x) if eth_type != "JL1101": excluded.append("esp_eth_phy_jl1101.c") - elif CORE.is_esp32 and not CORE.data.get(KEY_NATIVE_IDF, False): + elif CORE.is_esp32 and not CORE.using_toolchain_esp_idf: from esphome.components.esp32 import idf_version # pioarduino has JL1101 builtin on IDF 5.4.2-5.x; exclude custom driver diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index 104762c69e..943fd141f6 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -244,6 +244,7 @@ async def to_code(config): # disable built in rgb support as it uses the new RMT drivers and will # conflict with NeoPixelBus which uses the legacy drivers cg.add_build_flag("-DESP32_ARDUINO_NO_RGB_BUILTIN") + cg.add_library("SPI", None) cg.add_library("makuna/NeoPixelBus", "2.8.0") else: cg.add_library("makuna/NeoPixelBus", "2.7.3") diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index e4b26b45eb..eb1caa5595 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -142,7 +142,7 @@ async def _smpmgr_upload_connected( with open(firmware, "rb") as file: image = file.read() upload_size = len(image) - progress = ProgressBar() + progress = ProgressBar("Uploading") progress.update(0) try: async for offset in smp_client.upload(image): diff --git a/esphome/const.py b/esphome/const.py index c2bf86d532..c39225fdec 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -15,6 +15,13 @@ VALID_SUBSTITUTIONS_CHARACTERS = ( ARGUMENT_HELP_DEVICE = "Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses. Use 'OTA' for resolving from MQTT, DNS or mDNS and avoiding the interactive prompt." +class Toolchain(StrEnum): + """Toolchain identifiers for ESPHome.""" + + PLATFORMIO = "platformio" + ESP_IDF = "esp-idf" + + class Platform(StrEnum): """Platform identifiers for ESPHome.""" @@ -1036,6 +1043,7 @@ CONF_TO = "to" CONF_TO_NTC_RESISTANCE = "to_ntc_resistance" CONF_TO_NTC_TEMPERATURE = "to_ntc_temperature" CONF_TOLERANCE = "tolerance" +CONF_TOOLCHAIN = "toolchain" CONF_TOPIC = "topic" CONF_TOPIC_PREFIX = "topic_prefix" CONF_TOTAL = "total" @@ -1393,7 +1401,6 @@ KEY_FRAMEWORK_VERSION = "framework_version" KEY_NAME = "name" KEY_VARIANT = "variant" KEY_PAST_SAFE_MODE = "past_safe_mode" -KEY_NATIVE_IDF = "native_idf" # Entity categories ENTITY_CATEGORY_NONE = "" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 0cc207aa54..ef0eddc603 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -17,7 +17,6 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, - KEY_NATIVE_IDF, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_BK72XX, @@ -28,6 +27,7 @@ from esphome.const import ( PLATFORM_NRF52, PLATFORM_RP2040, PLATFORM_RTL87XX, + Toolchain, ) # pylint: disable=unused-import @@ -618,6 +618,10 @@ class EsphomeCore: # When True, skip network freshness checks for cached external files # (e.g. for `esphome logs`, where remote downloads aren't needed) self.skip_external_update: bool = False + # Toolchain used for building the configuration. None until resolved + # by CLI (--toolchain) or by `esphome.toolchain:` in YAML during + # preload_core_config; defaults to PLATFORMIO if neither sets it. + self.toolchain: Toolchain | None = None def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -648,6 +652,7 @@ class EsphomeCore: self.address_cache = None self._config_hash = None self.skip_external_update = False + self.toolchain = None PIN_SCHEMA_REGISTRY.reset() @contextmanager @@ -772,8 +777,8 @@ class EsphomeCore: @property def firmware_bin(self) -> Path: - # Check if using native ESP-IDF build (--native-idf) - if self.data.get(KEY_NATIVE_IDF, False): + # Check if using ESP-IDF toolchain + if self.using_toolchain_esp_idf: return self.relative_build_path("build", f"{self.name}.bin") if self.is_libretiny: return self.relative_pioenvs_path(self.name, "firmware.uf2") @@ -781,10 +786,10 @@ class EsphomeCore: @property def partition_table_bin(self) -> Path: - # Native ESP-IDF (--native-idf): the partition table image is emitted under + # Native ESP-IDF (--toolchain esp-idf): the partition table image is emitted under # build/partition_table/partition-table.bin alongside firmware.bin. PlatformIO writes the # equivalent file as partitions.bin in the env-specific .pioenvs directory. - if self.data.get(KEY_NATIVE_IDF): + if self.using_toolchain_esp_idf: return self.relative_build_path( "build", "partition_table", "partition-table.bin" ) @@ -792,7 +797,7 @@ class EsphomeCore: @property def bootloader_bin(self) -> Path: - if self.data.get(KEY_NATIVE_IDF): + if self.using_toolchain_esp_idf: return self.relative_build_path("build", "bootloader", "bootloader.bin") return self.relative_pioenvs_path(self.name, "bootloader.bin") @@ -853,6 +858,14 @@ class EsphomeCore: ) return self.target_framework == "esp-idf" + @property + def using_toolchain_esp_idf(self): + return self.toolchain == Toolchain.ESP_IDF + + @property + def using_toolchain_platformio(self): + return self.toolchain == Toolchain.PLATFORMIO + @property def using_zephyr(self): return self.target_framework == "zephyr" diff --git a/esphome/espidf/__init__.py b/esphome/espidf/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/espidf/api.py b/esphome/espidf/api.py new file mode 100644 index 0000000000..847de249a7 --- /dev/null +++ b/esphome/espidf/api.py @@ -0,0 +1,499 @@ +"""ESP-IDF direct build API for ESPHome.""" + +from dataclasses import dataclass, field +import json +import logging +import os +from pathlib import Path +import re +import shutil +import subprocess + +from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION +from esphome.core import CORE, EsphomeError +from esphome.espidf.framework import check_esp_idf_install, get_framework_env + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "espidf_api" + + +@dataclass +class _CacheData: + paths: dict[str, tuple] = field(default_factory=dict) + env: dict[str, dict[str, str]] = field(default_factory=dict) + cmake_output: dict[Path, str] = field(default_factory=dict) + cmake_tools: dict[Path, dict[str, Path]] = field(default_factory=dict) + + +def _cache() -> _CacheData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _CacheData() + return CORE.data[DOMAIN] + + +def _get_core_framework_version(): + return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION]) + + +def _get_esphome_esp_idf_paths( + version: str | None = None, +) -> tuple[os.PathLike, os.PathLike]: + version = version or _get_core_framework_version() + paths = _cache().paths + if version not in paths: + paths[version] = check_esp_idf_install(version) + return paths[version] + + +def _get_idf_path(version: str | None = None) -> Path | None: + """Get IDF_PATH from environment or common locations.""" + # Use provided IDF framework if available + if "IDF_PATH" in os.environ: + return Path(os.environ["IDF_PATH"]) + return Path(_get_esphome_esp_idf_paths(version)[0]) + + +def _get_idf_env(version: str | None = None) -> dict[str, str]: + """Get environment variables needed for ESP-IDF build.""" + version = version or _get_core_framework_version() + env_cache = _cache().env + if version not in env_cache: + env_cache[version] = os.environ.copy() + + # Use provided IDF framework if available + if "IDF_PATH" not in os.environ: + env_cache[version] |= get_framework_env( + *_get_esphome_esp_idf_paths(version) + ) + return env_cache[version] + + +def _get_cmake_output(build_dir) -> str: + cmake_output_cache = _cache().cmake_output + if build_dir not in cmake_output_cache: + cmd = ["cmake", "-LA", "-N", "."] + + env = _get_idf_env() + result = subprocess.run( + cmd, + cwd=build_dir, + env=env, + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + raise RuntimeError(f"CMake failed: {result.stderr}") + + cmake_output_cache[build_dir] = result.stdout + return cmake_output_cache[build_dir] + + +def _get_cmake_tool_path(var_name: str) -> Path: + build_dir = CORE.relative_build_path("build") + cmake_output = _get_cmake_output(build_dir) + + cmake_tools_cache = _cache().cmake_tools + if build_dir not in cmake_tools_cache: + cmake_tools_cache[build_dir] = {} + + if var_name not in cmake_tools_cache[build_dir]: + pattern = rf"^{var_name}:FILEPATH=(.+)$" + match = re.search(pattern, cmake_output, re.MULTILINE) + + if not match: + raise RuntimeError(f"{var_name} not found in CMake output") + + path = match.group(1).strip() + cmake_tools_cache[build_dir][var_name] = Path(path) + + return cmake_tools_cache[build_dir][var_name] + + +def _get_idf_tool(name: str) -> str: + """Return the path to an executable from the ESP-IDF environment PATH or raise if not found.""" + env = _get_idf_env() + executable = shutil.which(name, path=env.get("PATH", None)) + if executable is None: + raise EsphomeError( + f"{name} executable not found in ESP-IDF environment. " + "Check that the IDF environment is correctly set up." + ) + return executable + + +def run_idf_py( + *args, cwd: Path | None = None, capture_output: bool = False +) -> int | str: + """Run idf.py with the given arguments.""" + idf_path = _get_idf_path() + if idf_path is None: + raise EsphomeError("ESP-IDF not found") + + env = _get_idf_env() + python_executable = _get_idf_tool("python") + idf_py = idf_path / "tools" / "idf.py" + # Dispatch idf.py through esphome.espidf.runner, which wraps + # sys.stdout/sys.stderr so ``isatty()`` reports True. This keeps CMake, + # Ninja, and idf.py's own progress-bar code emitting TTY-format output + # (``\r`` cursor moves, ANSI colors, fancy progress bars) even when our + # real stdout is a pipe — e.g. when esphome is running under the Home + # Assistant dashboard add-on. The runner is a plain script (not a + # ``python -m`` module) because IDF's Python venv does not have the + # esphome package installed. + runner_py = Path(__file__).parent / "runner.py" + + cmd = [python_executable, str(runner_py), str(idf_py)] + list(args) + + if cwd is None: + cwd = CORE.build_path + + _LOGGER.debug("Running: %s", " ".join(cmd)) + _LOGGER.debug(" in directory: %s", cwd) + + if capture_output: + result = subprocess.run( + cmd, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + _LOGGER.error("idf.py failed:\n%s", result.stderr) + return result.stdout + result = subprocess.run( + cmd, + cwd=cwd, + env=env, + check=False, + ) + return result.returncode + + +def _get_sdkconfig_args() -> list[str]: + """Get cmake -D flags for the sdkconfig file, if it exists.""" + sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") + if sdkconfig_path.is_file(): + return ["-D", f"SDKCONFIG={sdkconfig_path}"] + return [] + + +def run_reconfigure() -> int: + """Run cmake reconfigure only (no build).""" + return run_idf_py(*_get_sdkconfig_args(), "reconfigure") + + +def has_outdated_files(): + """Check if the build configuration is stale. + + Returns True if required build files are missing or if configuration inputs + are newer than the generated CMake/Ninja build artifacts. + """ + cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") + + cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt") + cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt") + build_config_path = CORE.relative_build_path("build/config") + sdkconfig_internal_path = CORE.relative_build_path( + f"sdkconfig.{CORE.name}.esphomeinternal" + ) + dependency_lock_path = CORE.relative_build_path("dependencies.lock") + build_ninja_path = CORE.relative_build_path("build/build.ninja") + + if not os.path.isdir(build_config_path) or not os.listdir(build_config_path): + return True + if not os.path.isfile(cmakecache_txt_path): + return True + if not os.path.isfile(build_ninja_path): + return True + if os.path.isfile(dependency_lock_path) and os.path.getmtime( + dependency_lock_path + ) > os.path.getmtime(build_ninja_path): + return True + + cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) + return any( + os.path.getmtime(f) > cmakecache_txt_mtime + for f in [ + _get_idf_path(), + cmakelists_txt_build_path, + cmakelists_txt_src_path, + sdkconfig_internal_path, + build_config_path, + ] + if f and os.path.exists(f) + ) + + +def need_reconfigure() -> bool: + from esphome.build_gen.espidf import has_discovered_components + + # We need to reconfigure either if the files are outdated or if there is no component discovered + return has_outdated_files() or not has_discovered_components() + + +def _patch_memory_segments(): + """Patch memory.ld to expand IRAM/DRAM for testing mode. + + Mirrors the PlatformIO iram_fix.py.script logic for native IDF builds. + Must be called after cmake configure (which generates memory.ld) and + before the build/link step. + """ + # Same sizes as iram_fix.py.script + testing_iram_size = 0x200000 # 2MB + testing_dram_size = 0x200000 # 2MB + + memory_ld = CORE.relative_build_path( + "build", "esp-idf", "esp_system", "ld", "memory.ld" + ) + if not memory_ld.is_file(): + _LOGGER.warning("Could not find linker script at %s", memory_ld) + return + + content = memory_ld.read_text() + patches = [] + + def _patch_segment(text, segment_name, new_size): + pattern = rf"({re.escape(segment_name)}\s*\([^)]*\)\s*:\s*org\s*=\s*.+?,\s*len\s*=\s*)(\S+[^\n]*)" + if match := re.search(pattern, text, re.DOTALL): + replacement = f"{match.group(1)}{new_size:#x}" + new_text = text[: match.start()] + replacement + text[match.end() :] + if new_text != text: + return new_text, True + return text, False + + content, patched = _patch_segment(content, "iram0_0_seg", testing_iram_size) + if patched: + patches.append(f"IRAM={testing_iram_size:#x}") + + content, patched = _patch_segment(content, "dram0_0_seg", testing_dram_size) + if patched: + patches.append(f"DRAM={testing_dram_size:#x}") + + if patches: + memory_ld.write_text(content) + _LOGGER.info("Patched %s in %s for testing mode", ", ".join(patches), memory_ld) + else: + _LOGGER.warning("Could not patch memory segments in %s", memory_ld) + + +def run_compile(config, verbose: bool) -> int: + """Compile the ESP-IDF project. + + Uses two-phase configure to auto-discover available components: + 1. If no previous build, configure with minimal REQUIRES to discover components + 2. Regenerate CMakeLists.txt with discovered components + 3. Run full build + """ + from esphome.build_gen.espidf import write_project + + # Check if we need to do discovery phase + if need_reconfigure(): + _LOGGER.info("Discovering available ESP-IDF components...") + write_project(minimal=True) + rc = run_reconfigure() + if rc != 0: + _LOGGER.error("Component discovery failed") + return rc + _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") + write_project(minimal=False) + if CORE.testing_mode: + # Reconfigure again so cmake is up to date with the full component + # list. This ensures idf.py build won't re-run cmake, which would + # regenerate memory.ld and wipe the DRAM/IRAM patches applied below. + rc = run_reconfigure() + if rc != 0: + _LOGGER.error("Reconfigure with discovered components failed") + return rc + + # In testing mode, generate the linker script first, patch DRAM/IRAM sizes, + # then build. memory.ld is regenerated by ninja during the build phase, + # so we must patch after it's generated but before linking (same timing + # as iram_fix.py.script's AddPreAction hook in the PlatformIO path). + if CORE.testing_mode: + memory_ld = CORE.relative_build_path( + "build", "esp-idf", "esp_system", "ld", "memory.ld" + ) + build_dir = CORE.relative_build_path("build") + # Build just the memory.ld target - ninja needs the path relative to build dir + memory_ld_target = os.path.relpath(str(memory_ld), str(build_dir)) + env = _get_idf_env() + ninja_executable = _get_idf_tool("ninja") + result = subprocess.run( + [ninja_executable, "-C", str(build_dir), memory_ld_target], + env=env, + check=False, + ) + if result.returncode != 0: + _LOGGER.error("Failed to generate linker script") + return result.returncode + _patch_memory_segments() + + # Build + args = [] + + if verbose: + args.append("-v") + + args.extend(_get_sdkconfig_args()) + args.append("build") + + return run_idf_py(*args) + + +def get_firmware_path() -> Path: + """Get the path to the compiled firmware binary. + + This is the file idf.py writes directly (named after the project), + not the copy used for OTA/factory downloads below. + """ + build_dir = CORE.relative_build_path("build") + return build_dir / f"{CORE.name}.bin" + + +def get_factory_firmware_path() -> Path: + """Get the path to the factory firmware (with bootloader). + + Uses the PlatformIO ``firmware.factory.bin`` naming convention so + the dashboard's download handler — which requests files by name + relative to ``firmware_bin_path.parent`` — finds it. Without this, + the native IDF path produced ``.factory.bin`` and the + dashboard returned 500 trying to locate ``firmware.factory.bin``. + """ + build_dir = CORE.relative_build_path("build") + return build_dir / "firmware.factory.bin" + + +def get_ota_firmware_path() -> Path: + """Get the path to the OTA firmware binary. + + Uses the PlatformIO ``firmware.ota.bin`` naming convention for the + same dashboard-compatibility reason as ``get_factory_firmware_path``. + """ + build_dir = CORE.relative_build_path("build") + return build_dir / "firmware.ota.bin" + + +def get_elf_path() -> Path: + """Get the path to the firmware ELF file. + + idf.py writes ``/.elf`` directly; this returns the + ``/firmware.elf`` copy created by ``create_elf_copy`` so + the dashboard's "download ELF" link can find it under the + PlatformIO-convention name. + """ + build_dir = CORE.relative_build_path("build") + return build_dir / "firmware.elf" + + +def get_objdump_path() -> Path: + return _get_cmake_tool_path("CMAKE_OBJDUMP") + + +def get_readelf_path() -> Path: + return _get_cmake_tool_path("CMAKE_READELF") + + +def get_addr2line_path() -> Path: + return _get_cmake_tool_path("CMAKE_ADDR2LINE") + + +def create_factory_bin() -> bool: + """Create factory.bin by merging bootloader, partition table, and app.""" + build_dir = CORE.relative_build_path("build") + flasher_args_path = build_dir / "flasher_args.json" + + if not flasher_args_path.is_file(): + _LOGGER.warning("flasher_args.json not found, cannot create factory.bin") + return False + + try: + with open(flasher_args_path, encoding="utf-8") as f: + flash_data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + _LOGGER.error("Failed to read flasher_args.json: %s", e) + return False + + # Get flash size from config + flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE] + + # Build esptool merge command + sections = [] + for addr, fname in sorted( + flash_data.get("flash_files", {}).items(), key=lambda kv: int(kv[0], 16) + ): + file_path = build_dir / fname + if file_path.is_file(): + sections.extend([addr, str(file_path)]) + else: + _LOGGER.warning("Flash file not found: %s", file_path) + + if not sections: + _LOGGER.warning("No flash sections found") + return False + + output_path = get_factory_firmware_path() + chip = flash_data.get("extra_esptool_args", {}).get("chip", "esp32") + + env = _get_idf_env() + python_executable = _get_idf_tool("python") + cmd = [ + python_executable, + "-m", + "esptool", + "--chip", + chip, + "merge_bin", + "--flash_size", + flash_size, + "--output", + str(output_path), + ] + sections + + _LOGGER.info("Creating factory.bin...") + result = subprocess.run(cmd, env=env, capture_output=True, text=True, check=False) + + if result.returncode != 0: + _LOGGER.error("Failed to create factory.bin: %s", result.stderr) + return False + + _LOGGER.info("Created: %s", output_path) + return True + + +def create_ota_bin() -> bool: + """Copy the firmware to firmware.ota.bin for ESPHome OTA compatibility.""" + firmware_path = get_firmware_path() + ota_path = get_ota_firmware_path() + + if not firmware_path.is_file(): + _LOGGER.warning("Firmware not found: %s", firmware_path) + return False + + shutil.copy(firmware_path, ota_path) + _LOGGER.info("Created: %s", ota_path) + return True + + +def create_elf_copy() -> bool: + """Copy the ELF binary to firmware.elf for dashboard compatibility. + + idf.py writes the ELF at ``/.elf``; the dashboard's + "download ELF" link requests the literal filename ``firmware.elf`` + (PlatformIO convention), so copy it to that name. + """ + build_dir = CORE.relative_build_path("build") + src_elf = build_dir / f"{CORE.name}.elf" + dst_elf = get_elf_path() + + if not src_elf.is_file(): + _LOGGER.warning("ELF not found: %s", src_elf) + return False + + shutil.copy(src_elf, dst_elf) + _LOGGER.info("Created: %s", dst_elf) + return True diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py new file mode 100644 index 0000000000..bb675d2c77 --- /dev/null +++ b/esphome/espidf/component.py @@ -0,0 +1,937 @@ +from collections.abc import Callable +import glob +import hashlib +import itertools +import json +import logging +import os +from pathlib import Path +import re +import tempfile +from typing import TypeVar +from urllib.parse import urlparse, urlsplit, urlunsplit + +from esphome import git, yaml_util +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, Library +from esphome.espidf.framework import archive_extract_all, download_from_mirrors, rmdir +from esphome.helpers import write_file_if_changed + +_LOGGER = logging.getLogger(__name__) + +PathType = str | os.PathLike + +# +# Constants from platformio +# + +FILTER_REGEX = re.compile(r"([+-])<([^>]+)>") +DEFAULT_BUILD_SRC_FILTER = ( + "+<*> -<.git/> -<.svn/> - - - -" +) +DEFAULT_BUILD_SRC_DIRS = "src" +DEFAULT_BUILD_INCLUDE_DIR = "include" +DEFAULT_BUILD_FLAGS = [] +SRC_FILE_EXTENSIONS = [ + ".c", + ".cpp", + ".cc", + ".cxx", + ".c++", + ".S", + ".spp", + ".SPP", + ".sx", + ".s", + ".asm", + ".ASM", +] + +ESP32_PLATFORM = "espressif32" +DOMAIN = "pio_components" + +# +# Constants for workarounds +# + +REQUIRES_DETECT_PATTERNS = { + "mbedtls": [re.compile(r'^\s*#\s*include\s*[<"]mbedtls[^">]*[">]', re.MULTILINE)], + "esp_netif": [ + re.compile(r'^\s*#\s*include\s*[<"]esp_netif[^">]*[">]', re.MULTILINE) + ], + "esp_driver_gpio": [ + re.compile(r'^\s*#\s*include\s*[<"]driver/gpio\.h[^">]*[">]', re.MULTILINE) + ], + "esp_timer": [ + re.compile(r'^\s*#\s*include\s*[<"]esp_timer\.h[^">]*[">]', re.MULTILINE) + ], + "esp_wifi": [ + re.compile( + r'^\s*#\s*include\s*[<"]WiFi\.h[^">]*[">]', re.MULTILINE + ) # Arduino WiFi + ], +} + +ESPHOME_DATA_KEY = "ESPHOME" +ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" + + +class Source: + def download(self, dir_suffix: str, force: bool = False) -> Path: + raise NotImplementedError() + + +class URLSource(Source): + def __init__(self, url: str): + self.url = url + + def download(self, dir_suffix: str, force: bool = False) -> Path: + base_dir = Path(CORE.data_dir) / DOMAIN + h = hashlib.new("sha256") + h.update(self.url.encode()) + path = base_dir / h.hexdigest()[:8] / dir_suffix + # Marker file written last to signal a complete extraction. Using a + # marker (instead of just `path.is_dir()`) means an interrupted + # extraction is correctly detected and re-run on the next invocation, + # and lets us extract directly into ``path`` — avoiding a + # post-extraction rename that races with antivirus on Windows. + extracted_marker = path / ".esphome_extracted" + if not extracted_marker.is_file() or force: + rmdir(path, msg=f"Clean up library directory {path}") + + # Download in temporary file + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading %s ...", self.url) + _LOGGER.debug("Location: %s", path) + + download_from_mirrors([self.url], {}, tmp.file) + + _LOGGER.debug("Extracting archive to %s ...", path) + archive_extract_all(tmp.file, path) + extracted_marker.touch() + return path + + def __str__(self): + return self.url + + +class GitSource(Source): + def __init__(self, url: str, ref: str): + self.url = url + self.ref = ref + + def download(self, dir_suffix: str, force: bool = False) -> Path: + path, _ = git.clone_or_update( + url=self.url, + ref=self.ref, + refresh=git.NEVER_REFRESH if not force else None, + domain=DOMAIN, + submodules=[], + subpath=Path(dir_suffix), + ) + return path + + def __str__(self): + return f"{self.url}#{self.ref}" + + +class InvalidIDFComponent(Exception): + pass + + +class IDFComponent: + def __init__(self, name: str, version: str, source: Source | None): + self.name = name + self.version = version + self.source = source + self.data = {} + self.dependencies: list[IDFComponent] = [] + self._path: Path | None = None + + def __str__(self): + return f"{self.name}@{self.version}={self.source}" + + @property + def path(self) -> Path: + if self._path is None: + raise RuntimeError(f"path not set for component {self}") + return self._path + + @path.setter + def path(self, value: Path) -> None: + self._path = value + + def get_sanitized_name(self): + return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) + + def get_require_name(self): + return self.get_sanitized_name().replace("/", "__") + + def download(self, force: bool = False): + """ + The dependency name should match the directory name at the end of the override path. + The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. + If you want to specify the full name of the component with the namespace, replace / in the component name with __. + @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html + """ + self.path = self.source.download(self.get_sanitized_name(), force=force) + + +def _sanitize_version(version: str) -> str: + """ + Sanitize a version string by removing common requirement prefixes or a leading v. + + Args: + version: Version string to clean. + + Returns: + Cleaned version string without common requirement symbols. + """ + version = version.strip() + + prefixes = ( + "^", + "~=", + "~", + ">=", + "<=", + "==", + "!=", + ">", + "<", + "=", + "v", + "V", + ) + + for p in prefixes: + if version.startswith(p): + version = version[len(p) :] + break + + return version.strip() + + +def _get_package_from_pio_registry( + username: str | None, pkgname: str, requirements: str +) -> tuple[str, str, str | None, str | None]: + """ + Fetch package information from PlatformIO registry. + + This function queries the PlatformIO registry to find a library package + that matches the given criteria and returns its metadata including version + and download URL. + + Args: + username: The owner/username of the package (can be None) + pkgname: The name of the package + requirements: Version requirements (e.g., "^1.0.0") + + Returns: + tuple[str, str, str | None, str | None]: + A tuple containing (owner, name, version, download_url) + where version and download_url can be None if not found + """ + + from platformio.package.manager._registry import PackageManagerRegistryMixin + from platformio.package.meta import PackageSpec + + # Create a minimal PackageManagerRegistry class + class PackageManagerRegistry(PackageManagerRegistryMixin): + def __init__(self): + self._registry_client = None + self.pkg_type = "library" + + @staticmethod + def is_system_compatible(value, custom_system=None): + return True + + pio_registry = PackageManagerRegistry() + + # Fetch package metadata from registry + package = pio_registry.fetch_registry_package( + PackageSpec( + owner=username, + name=pkgname, + ) + ) + owner = package["owner"]["username"] + name = package["name"] + + # Find the best matching version based on requirements + version = pio_registry.pick_best_registry_version( + package.get("versions"), + PackageSpec(owner=username, name=pkgname, requirements=requirements), + ) + + # If no version found, return with None for version and URL + if not version: + return owner, name, None, None + + # Find the compatible package file for this version + pkgfile = pio_registry.pick_compatible_pkg_file(version["files"]) + + # If no package file found, return with None for URL but valid version + if not pkgfile: + return owner, name, version["name"], None + + return owner, name, version["name"], pkgfile["download_url"] + + +def _patch_component(component: IDFComponent, first_pass: bool): + """ + Apply patches/workarounds to specific components that have known issues. + + This function modifies component data to fix compatibility issues or missing + dependencies for certain libraries. It applies different patches based on + whether it's the first or second pass of processing. + + Args: + component: The IDFComponent object to potentially patch + first_pass: Boolean indicating if this is the first pass of processing + """ + + # Patch only on the second step + if not first_pass and CORE.using_arduino: + # Add the missing dependency to Arduino framework. Source is None so + # the IDF component manager resolves it from the registry instead of + # cloning the 2 GB arduino-esp32 git history. + component.dependencies.append( + IDFComponent( + "espressif/arduino-esp32", + str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), + None, + ) + ) + + # + # fastled/FastLED + # + + # Patch only on the first step + if ( + first_pass + and component.name == _owner_pkgname_to_name("fastled", "FastLED") + and not (component.path / "idf_component.yml").is_file() + ): + # Force fake idf_component: This project already support ESP-IDF + (component.path / "idf_component.yml").write_text("") + + +T = TypeVar("T") + + +def _ensure_list(obj: T | list[T]) -> list[T]: + """ + Convert an object to a list if it isn't already a list. + + Args: + obj: Object that may or may not already be a list. + + Returns: + list[T]: The original list if ``obj`` is a list, otherwise a single-item + list containing ``obj``. + """ + return [obj] if not isinstance(obj, list) else obj + + +def _owner_pkgname_to_name(owner: str | None, pkgname: str) -> str: + """ + Convert owner and package name to a standardized component name. + + This function combines owner and package name with a forward slash when + both are provided, otherwise returns just the package name. + + Args: + owner: The owner/username of the package (can be None) + pkgname: The name of the package + + Returns: + str: The standardized component name in "owner/pkgname" format or just "pkgname" + """ + return f"{owner}/{pkgname}" if owner else pkgname + + +def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[str]: + """ + Recursively match files in a directory according to include/exclude patterns. + + This function processes a list of filter strings that indicate which files + to include or exclude. Each filter is parsed into patterns with a sign: + '+' for inclusion and '-' for exclusion. Directory patterns ending with '/' + are normalized to include all their contents recursively. + + Args: + src_dir (PathType): Root directory to search within. + src_filters (list[str]): List of filter strings, which may contain multiple + patterns. Each pattern can start with '+' or '-' to indicate inclusion + or exclusion. + + Returns: + list[str]: List of matched file paths as strings. Only files (not directories) + are returned, even if a directory matches a pattern. + """ + matches = list( + itertools.chain.from_iterable( + FILTER_REGEX.findall(src_filter) for src_filter in src_filters + ) + ) + + selected = set() + + for sign, pattern in matches: + pattern = pattern.strip() + + if pattern.endswith("/"): + pattern = pattern.rstrip("/") + "/**" + + full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) + + matched = [] + for item in glob.glob(full_pattern, recursive=True): + if not os.path.isdir(item): + matched.append(item) + else: + # PlatformIO quirk: a directory matched with "*" should include all its + # nested files and subdirectories, not just the directory itself. + for root, _, files in os.walk(item): + matched.extend([os.path.join(root, f) for f in files]) + + if sign == "+": + selected.update(matched) + elif sign == "-": + selected.difference_update(matched) + + return [r for r in selected if os.path.isfile(r)] + + +def _convert_library_to_component(library: Library) -> IDFComponent: + """ + Convert a Library object to an IDFComponent object by resolving its metadata. + + This function handles the conversion of library specifications to component + objects, resolving versions through PlatformIO registry when needed or + parsing direct repository URLs. + + Args: + library: The Library object containing name, version, and/or repository information + + Returns: + IDFComponent: The resolved component with name, version, and URL + + Raises: + ValueError: If a repository URL is missing a reference (#) + RuntimeError: If no artifact can be found for the library + """ + name = None + version = None + source = None + + # Repository is provided directly + if library.repository: + # Parse repository URL to extract name and version + split_result = urlsplit(library.repository) + if not split_result.fragment.strip(): + raise ValueError(f"Missing ref in URL {library.repository}") + + # Sanitize name + name = str(split_result.path).strip("/") + name = name.removesuffix(".git") + + # Sanitize version + version = _sanitize_version(split_result.fragment) + repository = urlunsplit(split_result._replace(fragment="")) + + source = GitSource(str(repository), split_result.fragment) + + # Version is provided - resolve using PlatformIO registry + elif library.version: + name = library.name + if "/" not in name: + owner, pkgname = None, name + else: + owner, pkgname = name.split("/", 1) + + owner, pkgname, version, url = _get_package_from_pio_registry( + owner, pkgname, library.version + ) + if url is None: + raise RuntimeError( + f"Can't find an pkg file from PlatformIO registry for library {library}" + ) + + name = _owner_pkgname_to_name(owner, pkgname) + source = URLSource(url) + + if source is None: + raise RuntimeError(f"Can't find an artifact associated to library {library}") + + assert name, "Missing library name" + assert version, "Missing library version" + + return IDFComponent(name, version, source) + + +def _detect_requires(build_src_files: list[str]) -> set[str]: + """ + Detect required components from source files. + + Args: + build_src_files: List of source file paths to analyze + + Returns: + Set of detected required components + """ + detected = set() + + # 1. Process each source file + for file in build_src_files: + path = Path(file) + + if not path.is_file(): + continue + + try: + content = path.read_text(encoding="utf-8", errors="ignore") + except Exception: # pylint: disable=broad-exception-caught + continue + + # 2. Add required component if one of these patterns matches + for require_name, patterns in REQUIRES_DETECT_PATTERNS.items(): + if require_name in detected: + continue # already found + + for pattern in patterns: + if pattern.search(content): + detected.add(require_name) + break + + return detected + + +def _split_list_by_condition( + items: list[str], match_fn: Callable[[str], str | None] +) -> tuple[list[str], list[str]]: + """ + Splits a list into two lists based on a matching function. + + Args: + items: List of items to split. + match_fn: Function that returns a value for items that should go into the "matched" list. + + Returns: + A tuple (matched, non_matched) + """ + matched = [] + non_matched = [] + for item in items: + result = match_fn(item) + if result: + matched.append(result) + else: + non_matched.append(item) + return matched, non_matched + + +def generate_cmakelists_txt(component: IDFComponent) -> str: + """ + Generate a CMakeLists.txt file for an ESP-IDF component. + + This function creates the necessary CMake configuration to build a library + with ESP-IDF, including source files, include directories, dependencies, + and build flags. + + Args: + component: The IDFComponent object containing library metadata and path + + Returns: + str: The complete CMakeLists.txt content as a string + """ + + def escape_entry(p: PathType) -> str: + # In CMakeLists.txt, backslashes need to be escaped + return f'"{str(p)}"'.replace("\\", "\\\\") + + # Extract the values + build_src_dir = component.data.get("build", {}).get("srcDir", None) + if not build_src_dir: + for d in ["src", "Src", "."]: + if (component.path / Path(d)).is_dir(): + build_src_dir = d + break + + build_include_dir = component.data.get("build", {}).get( + "includeDir", DEFAULT_BUILD_INCLUDE_DIR + ) + build_src_filter = _ensure_list( + component.data.get("build", {}).get("srcFilter", DEFAULT_BUILD_SRC_FILTER) + ) + build_flags = _ensure_list( + component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS) + ) + + # List all sources files + build_src_files = _collect_filtered_files( + component.path / Path(build_src_dir), build_src_filter + ) + + # Detect in the files which requirements to add + # By default in platformio, all the components are added: we need to detect them when using ESP-IDF + requires = _detect_requires(build_src_files) + + # Dependencies are required + for dependency in component.dependencies: + requires.add(dependency.get_require_name()) + + # Only keep sources + build_src_files = [os.path.relpath(p, component.path) for p in build_src_files] + build_src_files = [ + f for f in build_src_files if os.path.splitext(f)[1] in SRC_FILE_EXTENSIONS + ] + + # Handle build flags + include_dir_flags, build_flags = _split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None + ) + link_directories, build_flags = _split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None + ) + link_libraries, build_flags = _split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None + ) + + # Split include directories from build_flags + # Only keep an include directory if it exists + build_include_dirs = [build_include_dir, build_src_dir] + include_dir_flags + build_include_dirs = [ + d for d in build_include_dirs if (component.path / Path(d)).is_dir() + ] + + # Split build_flags list into private and public lists + private_build_flags, public_build_flags = _split_list_by_condition( + build_flags, lambda a: a if a.startswith("-W") else None + ) + + # Generate the component + content = "idf_component_register(\n" + if build_src_files: + str_srcs = " ".join([escape_entry(p) for p in sorted(build_src_files)]) + content += f" SRCS {str_srcs}\n" + if build_include_dirs: + str_include_dirs = " ".join([escape_entry(p) for p in build_include_dirs]) + content += f" INCLUDE_DIRS {str_include_dirs}\n" + if requires: + str_requires = " ".join(sorted(requires)) + content += f" REQUIRES {str_requires}\n" + content += ")\n" + + # Add public and private build flags + if public_build_flags: + content += "target_compile_options(${COMPONENT_LIB} PUBLIC\n" + for build_flag in public_build_flags: + str_build_flag = escape_entry(build_flag) + content += f" {str_build_flag}\n" + content += ")\n" + if private_build_flags: + content += "target_compile_options(${COMPONENT_LIB} PRIVATE\n" + for build_flag in private_build_flags: + str_build_flag = escape_entry(build_flag) + content += f" {str_build_flag}\n" + content += ")\n" + + # Add library paths and files + if link_directories: + content += "target_link_directories(${COMPONENT_LIB} INTERFACE\n" + for link_directory in link_directories: + str_build_flag = escape_entry(link_directory) + content += f" {str_build_flag}\n" + content += ")\n" + + if link_libraries: + content += "target_link_libraries(${COMPONENT_LIB} INTERFACE\n" + for link_library in link_libraries: + str_build_flag = escape_entry(link_library) + content += f" {str_build_flag}\n" + content += ")\n" + + # Add custom CMake scripts + content += "\n".join( + component.data.get(ESPHOME_DATA_KEY, {}).get(ESPHOME_DATA_EXTRA_CMAKE_KEY, []) + ) + + return content + + +def generate_idf_component_yml(component: IDFComponent) -> str: + """ + Generate ESP-IDF component YAML configuration for a library. + + Args: + component: IDFComponent object to generate YAML for + + Returns: + YAML string representation of ESP-IDF component configuration + """ + data = {} + + description = component.data.get("description") + if description: + data["description"] = description + + # Do not use the version from library.json/library.properties; it may be incorrect. + data["version"] = component.version + + repository = component.data.get("repository", {}).get("url", None) + if repository: + data["repository"] = repository + + for dependency in component.dependencies: + # Initialize dependencies section if needed + if "dependencies" not in data: + data["dependencies"] = {} + + # Add this dependency to dependencies + dep = {} + dep["version"] = dependency.version + + # Should use dependency.path as override path + try: + dep["override_path"] = str(dependency.path) + except RuntimeError as e: + # No local path; let the IDF component manager resolve. + # GitSource gives an explicit URL; arduino-esp32 is resolved by + # version from the registry. Anything else is a bug. + if isinstance(dependency.source, GitSource): + dep["git"] = dependency.source.url + elif dependency.name != "espressif/arduino-esp32": + raise e + + data["dependencies"][dependency.get_sanitized_name()] = dep + + return yaml_util.dump(data) + + +def _check_library_data(data: dict): + """ + Check if a library data is compatible with the ESP-IDF framework. + + Args: + component: IDFComponent object being processed + + Raises: + ValueError: If library has unsupported platforms or frameworks + """ + platforms = data.get("platforms", "*") + if isinstance(platforms, str): + platforms = [a.strip() for a in platforms.split(",")] + platforms = _ensure_list(platforms) + + # Check if library supports ESP-IDF platform + valid_platforms = "*" in platforms or ESP32_PLATFORM in platforms + + if not valid_platforms: + raise InvalidIDFComponent(f"Unsupported library platforms: {platforms}") + + frameworks = data.get("frameworks", "*") + if isinstance(frameworks, str): + frameworks = [a.strip() for a in frameworks.split(",")] + frameworks = _ensure_list(frameworks) + + # Check if library supports ESP-IDF framework + framework = "arduino" if CORE.using_arduino else "espidf" + valid_framework = "*" in frameworks or framework in frameworks + + if not valid_framework: + raise InvalidIDFComponent(f"Unsupported library frameworks: {frameworks}") + + extra_script = data.get("build", {}).get("extraScript", None) + if extra_script: + _LOGGER.warning( + 'Extra scripts are not supported. The script "%s" will not be executed.', + extra_script, + ) + + +def _process_dependencies(component: IDFComponent): + """ + Process library dependencies and generate ESP-IDF components. + + Args: + component: IDFComponent object being processed + + Returns: + None + """ + + name, version = component.name, component.version + dependencies = component.data.get("dependencies") + if not dependencies: + return + + _LOGGER.info("Processing %s@%s component dependencies...", name, version) + for dependency in dependencies: + # Validate dependency structure + if not all(k in dependency for k in ("name", "version")): + _LOGGER.debug("Ignore invalid library: %s", dependency) + continue + + try: + _check_library_data(dependency) + except InvalidIDFComponent as e: + _LOGGER.debug( + "Skip %s@%s: %s", dependency["name"], dependency["version"], str(e) + ) + continue + + # The version field may actually contain a URL + version = dependency["version"] + url = None + try: + result = urlparse(version) + if all([result.scheme, result.netloc]): + url, version = version, None + except (TypeError, ValueError): + pass + + # Generate ESP-IDF component from PlatformIO library + component.dependencies.append( + _generate_idf_component( + Library( + _owner_pkgname_to_name( + dependency.get("owner", None), dependency.get("name") + ), + version, + url, + ) + ) + ) + + +def _parse_library_json(library_json_path: PathType): + """ + Load and parse a JSON file describing a library. + + Args: + library_json_path (PathType): Path to the JSON file. + + Returns: + dict: Parsed JSON content as a Python dictionary. + """ + with open(library_json_path, encoding="utf8") as fp: + return json.load(fp) + + +def _parse_library_properties(library_properties_path: PathType): + """ + Parse a key-value platformio .properties style file into a dictionary. + + Args: + library_properties_path (PathType): Path to the properties file. + + Returns: + dict[str, str]: Mapping of parsed property keys to values. + """ + with open(library_properties_path, encoding="utf8") as fp: + data = {} + for line in fp.read().splitlines(): + line = line.strip() + if not line or "=" not in line: + continue + # skip comments + if line.startswith("#"): + continue + key, value = line.split("=", 1) + if not value.strip(): + continue + data[key.strip()] = value.strip() + return data + + +def _generate_idf_component(library: Library, force: bool = False) -> IDFComponent: + """ + Generate an ESP-IDF component from a library specification. + + This function resolves the library, downloads it, processes metadata files, + and generates necessary ESP-IDF build files (CMakeLists.txt, idf_component.yml). + + Args: + library: The library specification containing name, version, and repository URL + force: If True, forces re-download of the library even if it exists locally + + Returns: + IDFComponent: The generated component object with resolved metadata + """ + _LOGGER.info("Generate IDF component for %s library ...", library) + + # Resolve component name, version and url + component = _convert_library_to_component(library) + name, version = component.name, component.version + + # Download the library + component.download(force) + + # Paths to component metadata and build files + library_json_path = component.path / "library.json" + library_properties_path = component.path / "library.properties" + cmakelists_txt_path = component.path / "CMakeLists.txt" + idf_component_yml_path = component.path / "idf_component.yml" + + # Apply patches to the library metadata + _patch_component(component, True) + + if cmakelists_txt_path.is_file() and idf_component_yml_path.is_file(): + # Already an ESP-IDF component + return component + + if library_json_path.is_file(): + component.data = _parse_library_json(library_json_path) + elif library_properties_path.is_file(): + component.data = _parse_library_properties(library_properties_path) + else: + raise RuntimeError( + "Invalid PIO library: missing library.json and/or library.properties" + ) + + # Apply additional patches to the library metadata + _patch_component(component, False) + + # Check if the component is usable with ESP-IDF + _check_library_data(component.data) + + # Handle the dependencies (convert PlatformIO library to ESP-IDF component if needed) + _process_dependencies(component) + + # Generate files + _LOGGER.debug("Generating CMakeLists.txt for %s@%s ...", name, version) + write_file_if_changed( + cmakelists_txt_path, + generate_cmakelists_txt(component), + ) + + _LOGGER.debug("Generating idf_component.yml for %s@%s ...", name, version) + write_file_if_changed( + idf_component_yml_path, + generate_idf_component_yml(component), + ) + + return component + + +def generate_idf_component( + library: Library, force: bool = False +) -> tuple[str, str, Path]: + """ + Generate an ESP-IDF component and return its name, version, and path. + + This is a wrapper function that calls _generate_idf_component and returns + the standardized tuple format (name, version, path). + + Args: + library: The library specification containing name, version, and repository URL + force: If True, forces re-download of the library even if it exists locally + + Returns: + tuple[str, str, Path]: A tuple containing (component_name, component_version, component_path) + """ + component = _generate_idf_component(library, force) + return component.get_sanitized_name(), component.version, component.path diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py new file mode 100644 index 0000000000..7ff373aba8 --- /dev/null +++ b/esphome/espidf/framework.py @@ -0,0 +1,1098 @@ +"""ESP-IDF framework tools for ESPHome.""" + +from collections.abc import Iterable +from contextlib import ExitStack +import io +import json +import logging +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from typing import IO + +import requests + +from esphome.config_validation import Version +from esphome.core import CORE +from esphome.helpers import ProgressBar, get_str_env, rmtree + +PathType = str | os.PathLike + +_LOGGER = logging.getLogger(__name__) + +_SCRIPTS_DIR = Path(__file__).parent + + +def _str_to_lst_of_str(a: str) -> list[str]: + """ + Convert a string to a list of string + + Args: + a: A string containing semicolon-separated values + + Returns: + list of strings + """ + return list(f.strip() for f in a.split(";") if f.strip()) + + +ESPHOME_STAMP_FILE = ".esphome.stamp.json" + +# Cache-buster baked into the stamp file. Bump this whenever a change would +# make pre-existing stamped installs invalid, e.g.: +# - the inlined Python helpers (_get_idf_version, _get_idf_tool_paths) are +# rewritten in a way that's incompatible with prior installs +# - the stamp_info schema changes (keys added/renamed/removed) +# - the tool selection or env-construction logic changes meaning +# Bumping triggers a full reinstall on every user's next run. +STAMP_SCHEMA_VERSION = "0" + +ESPHOME_IDF_DEFAULT_TARGETS = _str_to_lst_of_str( + os.environ.get("ESPHOME_IDF_DEFAULT_TARGETS", "all") +) + +ESPHOME_IDF_DEFAULT_TOOLS = _str_to_lst_of_str( + os.environ.get("ESPHOME_IDF_DEFAULT_TOOLS", "cmake;ninja") +) + +ESPHOME_IDF_DEFAULT_TOOLS_FORCE = _str_to_lst_of_str( + os.environ.get("ESPHOME_IDF_DEFAULT_TOOLS_FORCE", "required") +) + +ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( + os.environ.get("ESPHOME_IDF_DEFAULT_FEATURES", "core") +) + +ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( + os.environ.get( + "ESPHOME_IDF_FRAMEWORK_MIRRORS", + "https://github.com/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip", + ) +) + +ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str( + os.environ.get( + "ESP_IDF_CONSTRAINTS_MIRRORS", + "https://dl.espressif.com/dl/esp-idf/espidf.constraints.v{VERSION}.txt", + ) +) + + +def _get_idf_tools_path() -> Path: + """ + Get the path to the ESP-IDF tools directory. + + Returns: + Path object pointing to the ESP-IDF tools directory + """ + if "ESPHOME_ESP_IDF_PREFIX" in os.environ: + return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + return CORE.data_dir / "idf" + + +def _get_framework_path(version: str) -> Path: + """ + Get the path to the ESPHome ESP-IDF framework directory for a specific version. + + Args: + version: ESP-IDF version string + + Returns: + Path object pointing to the framework directory + """ + return _get_idf_tools_path() / "frameworks" / f"{version}" + + +def _get_python_env_path(version: str) -> Path: + """ + Get the path to the ESPHome ESP-IDF Python environment directory for a specific version. + + Args: + version: ESP-IDF version string + + Returns: + Path object pointing to the Python environment directory + """ + return _get_idf_tools_path() / "penvs" / f"{version}" + + +def rmdir(directory: PathType, msg: str | None = None): + """ + Remove a directory and its contents recursively if it exists. + + Args: + directory: Path to the directory to be removed + msg: Optional debug message to log before removal or it an error occurs + + Returns: + None + + Raises: + RuntimeError: If directory removal fails + """ + if os.path.isdir(directory): + try: + if msg: + _LOGGER.debug(msg) + rmtree(directory) + except OSError as e: + raise RuntimeError( + f"Error during {msg}: can't remove `{directory}`. Please remove it manually!" + ) from e + + +def _get_pythonexe_path() -> str: + """ + Get the path to the Python executable. + + Returns: + Path to Python executable as string + """ + # Try to get PYTHONEXEPATH environment variable + # Fallback to sys.executable if not set + return os.environ.get("PYTHONEXEPATH", os.path.normpath(sys.executable)) + + +def _get_python_env_executable_path(root: PathType, binary: str) -> Path: + """ + Get the path to a Python environment executable file. + + Args: + root: Root directory of the Python environment + binary: Name of the executable binary + + Returns: + Path object pointing to the executable file + """ + if os.name == "nt": + return Path(root) / "Scripts" / f"{binary}.exe" + return Path(root) / "bin" / binary + + +def _check_stamp(file: PathType, data: dict[str, str]) -> bool: + """ + Check if a stamp file contains the expected data. + + Args: + file: Path to the stamp file + data: Dictionary containing expected data + + Returns: + True if file exists and contains expected data, False otherwise + """ + if not Path(file).is_file(): + return False + + try: + with open(file, encoding="utf-8") as f: + return json.load(f) == data + except (json.JSONDecodeError, OSError): + return False + + +def _write_stamp(file: PathType, data: dict[str, str]): + """ + Write data to a stamp file in JSON format. + + Args: + file: Path to the stamp file to write + data: Dictionary containing data to write + """ + with open(file, "w", encoding="utf8") as fp: + json.dump(data, fp) + + +def _exec( + cmd: list[str], + msg: str | None = None, + env: dict[str, str] | None = None, + stream_output: bool = False, +) -> tuple[bool, str | None, str | None]: + """ + Execute a command and return results. + + Args: + cmd: list of command arguments + msg: Optional custom message for logging + env: Optional dictionary of environment variables to set + stream_output: If True, inherit parent stdio so the subprocess prints + directly to the terminal (useful for commands that produce their + own progress output). stdout/stderr are not captured in this mode. + + Returns: + tuple of (success: bool, stdout: str or None, stderr: str or None). + When stream_output is True, stdout and stderr are always None. + """ + cmd_str = msg or " ".join(cmd) + try: + _LOGGER.debug("%s - running ...", cmd_str) + + run_env = os.environ.copy() + if env: + run_env.update(env) + + if stream_output: + result = subprocess.run(cmd, check=False, env=run_env) + stdout = stderr = None + else: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + env=run_env, + ) + stdout = result.stdout + stderr = result.stderr + + if result.returncode != 0: + if stream_output: + _LOGGER.error("%s - failed (returncode=%s)", cmd_str, result.returncode) + else: + tail = (stderr or stdout or "").strip()[-1000:] + _LOGGER.error( + "%s - failed (returncode=%s). Tail:\n%s", + cmd_str, + result.returncode, + tail, + ) + return False, stdout, stderr + + _LOGGER.debug("%s - executed successfully", cmd_str) + return True, stdout, stderr + + except (subprocess.SubprocessError, OSError) as e: + _LOGGER.error("%s - error: %s", cmd_str, str(e)) + return False, None, None + + +def _exec_ok(*args, **kwargs) -> bool: + """ + Execute a command and return only the success status. + + Args: + *args: Positional arguments to pass to _exec function + **kwargs: Keyword arguments to pass to _exec function + + Returns: + True if command executed successfully, False otherwise + """ + return _exec(*args, **kwargs)[0] + + +def _get_idf_version( + idf_framework_root: PathType, env: dict[str, str] | None = None +) -> str: + """ + Get the ESP-IDF version from the specified framework root. + + Args: + idf_framework_root: Path to the ESP-IDF framework root directory + env: Optional dictionary of environment variables to set + + Returns: + String containing ESP-IDF version + + Raises: + RuntimeError: If ESP-IDF version cannot be determined + """ + + cmd = [ + _get_pythonexe_path(), + str(_SCRIPTS_DIR / "get_idf_version.py"), + str(idf_framework_root), + ] + + success, stdout, stderr = _exec( + cmd, + msg="ESP-IDF version", + env=(env or os.environ) + | {"PYTHONPATH": str(Path(idf_framework_root) / "tools")}, + ) + if stdout: + stdout = stdout.strip() + if not success or not stdout: + detail = (stderr or "").strip() + raise RuntimeError( + f"Can't get ESP-IDF version of {idf_framework_root}" + + (f": {detail}" if detail else "") + ) + return stdout + + +def _get_idf_tool_paths( + idf_framework_root: PathType, env: dict[str, str] | None = None +) -> tuple[list[str], dict[str, str]]: + """ + Get ESP-IDF tool paths and environment variables needed for building. + + Args: + idf_framework_root: Path to the ESP-IDF framework root directory + env: Optional dictionary of environment variables to set + + Returns: + tuple containing (list of tool paths, dictionary of environment variables) + + Raises: + RuntimeError: If ESP-IDF tool paths cannot be determined + """ + + cmd = [ + _get_pythonexe_path(), + str(_SCRIPTS_DIR / "get_idf_tool_paths.py"), + str(idf_framework_root), + ] + + success, stdout, stderr = _exec( + cmd, + msg="ESP-IDF tool paths", + env=(env or os.environ) + | {"PYTHONPATH": str(Path(idf_framework_root) / "tools")}, + ) + if not success or not stdout: + detail = (stderr or "").strip() + raise RuntimeError( + f"Can't get ESP-IDF tool paths of {idf_framework_root}" + + (f": {detail}" if detail else "") + ) + + # Extract json values + try: + data = json.loads(stdout) + return data["paths_to_export"], data["export_vars"] + except Exception as e: + raise RuntimeError( + f"Can't extract ESP-IDF tool paths of {idf_framework_root}" + ) from e + + +def _get_python_version( + python_executable: PathType, + env: dict[str, str] | None = None, + throw_exception=False, +) -> str | None: + """ + Get the Python version from the specified executable. + + Args: + python_executable: Path to the Python executable to check + env: Optional dictionary of environment variables to set + throw_exception: If True, raise RuntimeError when version can't be determined + + Returns: + String containing Python version in "major.minor.patch" format, or None if failed + """ + + script = """ +import sys +print(".".join([str(x) for x in sys.version_info])) +""" + cmd = [python_executable, "-c", script] + + success, stdout, _ = _exec(cmd, msg="Python version", env=env) + + if stdout: + stdout = stdout.strip() + if throw_exception and (not success or not stdout): + raise RuntimeError(f"Can't get Python version of {python_executable}") + return stdout + + +def _create_venv(root: PathType, msg: str | None = None): + """ + Create a Python virtual environment. + + Args: + root: Path to the virtual environment directory + msg: Optional message for logging + + Returns: + None + + Raises: + Exception: If virtual environment creation fails + """ + cmd = [_get_pythonexe_path(), "-m", "venv", "--clear", root] + if not _exec_ok(cmd, msg=f"Create Python virtual environment for {msg}"): + raise RuntimeError(f"Can't create Python virtual environment for {msg}") + + +def _detect_archive_root(names: Iterable[str]) -> str | None: + """Detect a single top-level directory shared by all archive entries. + + Returns the directory name if every non-empty entry sits under the same + top-level directory, else ``None``. Extraction helpers use this to strip + the wrapper directory commonly found in source archives during extraction + rather than renaming it afterwards — post-extraction renames are + unreliable on Windows because antivirus and the search indexer briefly + hold handles on freshly written files. + """ + root: str | None = None + has_descendant = False + for raw in names: + name = raw.replace("\\", "/").strip("/") + if not name: + continue + first, sep, _ = name.partition("/") + if root is None: + root = first + elif root != first: + return None + if sep: + has_descendant = True + return root if has_descendant else None + + +def _tar_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a TAR archive to the specified directory. + + Implementation is inspired by Python 3.12's tarfile data filtering logic. + This can be replaced with the standard library implementation once + support for Python 3.11 is no longer required. + + Args: + data: File-like object containing the TAR archive + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import stat + import tarfile + + extract_dir = os.fspath(extract_dir) + abs_dest = os.path.abspath(extract_dir) + + with tarfile.open(fileobj=data, mode="r") as tar_ref: + all_members = tar_ref.getmembers() + + # Detect a single common top-level directory and strip it during + # extraction so we don't have to flatten it via a rename afterwards. + strip_root = _detect_archive_root(m.name for m in all_members) + strip_prefix = f"{strip_root}/" if strip_root is not None else None + + safe_members = [] + + for member in all_members: + name = member.name + + # 1. Strip leading slashes + name = name.lstrip("/" + os.sep) + + # 2. Reject absolute paths (incl. Windows drive) + if os.path.isabs(name) or ( + os.name == "nt" and ":" in name.split(os.sep)[0] + ): + continue + + # 3. Strip wrapper directory if one was detected + if strip_prefix is not None: + norm = name.replace("\\", "/") + if norm in (strip_root, strip_prefix): + continue + if not norm.startswith(strip_prefix): + continue + name = norm[len(strip_prefix) :] + + # 4. Compute final path + target_path = os.path.realpath(os.path.join(abs_dest, name)) + if os.path.commonpath([abs_dest, target_path]) != abs_dest: + continue + + # 5. Validate links properly + if member.issym() or member.islnk(): + linkname = member.linkname + + # Reject absolute link targets + if os.path.isabs(linkname): + continue + + # Strip leading slashes + linkname = os.path.normpath(linkname) + + if member.issym(): + link_target = os.path.join( + abs_dest, os.path.dirname(name), linkname + ) + else: + link_target = os.path.join(abs_dest, linkname) + link_target = os.path.realpath(link_target) + + if os.path.commonpath([abs_dest, link_target]) != abs_dest: + continue + + # write back normalized linkname + member.linkname = linkname + + # 6. Sanitize permissions + mode = member.mode + if mode is not None: + # Strip high bits & group/other write bits + mode &= ( + stat.S_IRWXU + | stat.S_IRGRP + | stat.S_IXGRP + | stat.S_IROTH + | stat.S_IXOTH + ) + if member.isfile() or member.islnk(): + # remove exec bits unless explicitly user-executable + if not (mode & stat.S_IXUSR): + mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + mode |= stat.S_IRUSR | stat.S_IWUSR + elif member.isdir() or member.issym(): + # Ignore mode for directories & symlinks + mode = None + else: + # Block special files + continue + + member.mode = mode + + # 7. Strip ownership + member.uid = None + member.gid = None + member.uname = None + member.gname = None + + # 8. Assign sanitized name back + member.name = name + + safe_members.append(member) + + total = len(safe_members) + progress = ( + ProgressBar(progress_header) if progress_header and total > 0 else None + ) + for i, member in enumerate(safe_members, 1): + tar_ref.extract(member, abs_dest) + if progress is not None: + progress.update(i / total) + if progress is not None: + progress.update(1) + + +def _zip_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a ZIP archive to the specified directory. + + Args: + data: File-like object containing the ZIP archive + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import zipfile + + extract_dir = os.path.abspath(extract_dir) + + with zipfile.ZipFile(data, "r") as zip_ref: + all_members = zip_ref.infolist() + + # Detect a single common top-level directory and strip it during + # extraction so we don't have to flatten it via a rename afterwards. + strip_root = _detect_archive_root(m.filename for m in all_members) + strip_prefix = f"{strip_root}/" if strip_root is not None else None + + total = len(all_members) + progress = ( + ProgressBar(progress_header) if progress_header and total > 0 else None + ) + + for i, member in enumerate(all_members, 1): + # 1. Normalize name + name = member.filename.lstrip("/\\") + + # 2. Reject absolute paths / Windows drives + if os.path.isabs(name) or ( + os.name == "nt" and ":" in name.split(os.sep)[0] + ): + continue + + # 3. Strip wrapper directory if one was detected + if strip_prefix is not None: + norm = name.replace("\\", "/") + if norm in (strip_root, strip_prefix): + continue + if not norm.startswith(strip_prefix): + continue + name = norm[len(strip_prefix) :] + + # 4. Compute safe target path + target_path = os.path.abspath(os.path.join(extract_dir, name)) + + if os.path.commonpath([extract_dir, target_path]) != extract_dir: + raise ValueError(f"Unsafe path detected: {member.filename}") + + # 5. Assign sanitized name back + member.filename = name + + # 6. Extract + zip_ref.extract(member, extract_dir) + + if progress is not None: + progress.update(i / total) + if progress is not None: + progress.update(1) + + +_ARCHIVE_MAGIC_MAP = { + b"\x1f\x8b\x08": _tar_extract_all, + b"\x42\x5a\x68": _tar_extract_all, + b"\xfd\x37\x7a\x58\x5a\x00": _tar_extract_all, + b"\x50\x4b\x03\x04": _zip_extract_all, +} + + +def archive_extract_all( + archive: PathType | io.RawIOBase | IO[bytes], + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract an archive file to the specified directory. + + Args: + archive: Path to archive file or file-like object + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + + Raises: + TypeError: If archive is not a valid type + ValueError: If archive format is unsupported + """ + + # 1. Handle different archive input types + with ExitStack() as stack: + archive_ref: io.BufferedIOBase + if isinstance(archive, (str, os.PathLike)): + archive_ref = stack.enter_context(open(archive, "rb")) + elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)): + archive_ref = archive + elif isinstance(archive, io.RawIOBase): + archive_ref = io.BufferedReader(archive) + else: + raise TypeError( + f"archive must be str, Path, or file-like object: {type(archive)}" + ) + + # 2. Detect archive format and select appropriate extraction function + matched_fct = None + magic_len = max(len(k) for k in _ARCHIVE_MAGIC_MAP) + header = archive_ref.peek(magic_len) + for magic, fct in _ARCHIVE_MAGIC_MAP.items(): + if header.startswith(magic): + matched_fct = fct + break + if matched_fct is None: + raise ValueError("Unsupported archive format") + matched_fct(archive_ref, extract_dir, progress_header=progress_header) + + +def download_from_mirrors( + mirrors: list[str], + substitutions: dict[str, str], + target: io.RawIOBase | IO[bytes] | PathType, + timeout: int = 30, +) -> str | None: + """ + 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. + + Raises: + Exception: If all download attempts fail + """ + # 1. Open target file for writing if path given + with ExitStack() as stack: + if isinstance(target, (str, os.PathLike)): + f = stack.enter_context(open(target, "wb")) + 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 + last_exception = None + + for mirror in mirrors: + # 3. Apply substitutions to URL + url = mirror.format(**substitutions) + + _LOGGER.debug("Trying downloading from %s", url) + + try: + # 4. Reset file pointer and download + f.seek(0) + f.truncate(0) + + with requests.get(url, stream=True, timeout=timeout) as r: + r.raise_for_status() + + total_size = int(r.headers.get("content-length", 0)) + downloaded = 0 + + progress = ProgressBar("Downloading") if total_size > 0 else None + + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + downloaded += len(chunk) + + if progress is not None: + progress.update(downloaded / total_size) + + if progress is not None: + progress.update(1) + + _LOGGER.debug("Downloaded successfully from: %s", url) + + # 6. Reset file pointer and return + f.seek(0) + return url + + except Exception as e: # pylint: disable=broad-exception-caught + _LOGGER.debug("Failed to download %s: %s", url, str(e)) + last_exception = e + + # 7. Raise last exception if all mirrors failed + if last_exception: + raise last_exception + return None + + +def _check_esphome_idf_framework_install( + version: str, + targets: list[str], + tools: list[str], + force: bool = False, + env: dict[str, str] | None = None, +) -> tuple[Path, bool]: + """ + Check and install ESP-IDF framework. + + Args: + version: ESP-IDF version to check/install + targets: Target platforms to install + tools: list of tools to install + force: If True, force reinstallation + env: Optional dictionary of environment variables to set + + Returns: + tuple of (framework_path, install_flag) + """ + + # Sanitize inputs + targets = sorted(set(targets)) + tools = sorted(set(tools)) + + stamp_info = {} + stamp_info["schema_version"] = STAMP_SCHEMA_VERSION + stamp_info["targets"] = targets + stamp_info["tools"] = tools + # TODO: Add stamp with this module version + + # 1. Get framework path and stamp file path + framework_path = _get_framework_path(version) + extracted_marker = framework_path / ".esphome_extracted" + env_stamp_file = framework_path / ESPHOME_STAMP_FILE + idf_tools_path = framework_path / "tools" / "idf_tools.py" + _LOGGER.info("Checking ESP-IDF %s framework ...", version) + + # 2. Download and extract the framework if not already extracted. + # The marker is written last after extraction succeeds, so its presence + # is the authoritative "extraction complete" signal — no half-extracted + # tree can pass for installed. Extracting directly into framework_path + # avoids post-extraction renames that race with antivirus on Windows. + # Tool install state is tracked separately by the stamp file in step 3, + # so we only re-extract when extraction itself is missing or incomplete. + install = force or not extracted_marker.is_file() + if install: + rmdir(framework_path, msg=f"Clean up ESP-IDF {version} framework") + + # Download in temporary file + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading ESP-IDF %s framework ...", version) + + # Create substitutions for the URLs + substitutions = {"VERSION": version} + try: + ver = Version.parse(version) + substitutions["MAJOR"] = str(ver.major) + substitutions["MINOR"] = str(ver.minor) + substitutions["PATCH"] = str(ver.patch) + substitutions["EXTRA"] = ver.extra + except ValueError: + pass + + download_from_mirrors( + ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file + ) + + _LOGGER.info("Extracting ESP-IDF %s framework ...", version) + archive_extract_all(tmp.file, framework_path, progress_header="Extracting") + extracted_marker.touch() + + # 3. Check if the framework tools are the same and correctly installed + if not install: + install = True + if _check_stamp(env_stamp_file, stamp_info): + _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) + cmd = [ + _get_pythonexe_path(), + str(idf_tools_path), + "--non-interactive", + "check", + ] + if _exec_ok(cmd, msg=f"ESP-IDF {version} check", env=env): + install = False + + # 4. Install framework tools if not installed or needs update + if install: + _LOGGER.info("Installing ESP-IDF %s framework ...", version) + targets_str = ",".join(targets) + cmd = [ + _get_pythonexe_path(), + str(idf_tools_path), + "--non-interactive", + "install", + f"--targets={targets_str}", + ] + tools + if not _exec_ok( + cmd, + msg=f"ESP-IDF {version} framework installation", + env=env, + stream_output=True, + ): + raise RuntimeError(f"ESP-IDF {version} framework installation failure") + + _write_stamp(env_stamp_file, stamp_info) + + return framework_path, install + + +def _check_esp_idf_python_env_install( + version: str, + features: list[str], + force: bool = False, + env: dict[str, str] | None = None, +) -> tuple[Path, bool]: + """ + Check and install ESP-IDF Python environment. + + Args: + version: ESP-IDF version to check/install + features: Features to install + force: If True, force reinstallation + env: Environment variables to use + + Returns: + tuple of (python_env_path, install_flag) + """ + + # Sanitize inputs + features = sorted(set(features)) + + stamp_info = {} + stamp_info["schema_version"] = STAMP_SCHEMA_VERSION + stamp_info["features"] = features + + framework_path = _get_framework_path(version) + python_env_path = _get_python_env_path(version) + env_stamp_file = python_env_path / ESPHOME_STAMP_FILE + env_python_path = _get_python_env_executable_path(python_env_path, "python") + + _LOGGER.info("Checking ESP-IDF %s Python environment ...", version) + install = force or not python_env_path.is_dir() or not env_python_path.is_file() + if not install: + # Check it against the stamp file + install = True + python_version = _get_python_version(env_python_path, env=env) + if python_version: + stamp_info["python_version"] = python_version + if _check_stamp(env_stamp_file, stamp_info): + install = False + + if install: + rmdir(python_env_path, msg=f"Clean up ESP-IDF {version} Python environment") + + _create_venv(python_env_path, msg=f"ESP-IDF {version}") + + esp_idf_version = _get_idf_version(framework_path, env=env) + constraint_file_path = ( + _get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt" + ) + _LOGGER.debug("ESP-IDF version %s", esp_idf_version) + + _LOGGER.info("Downloading constraints file for ESP-IDF %s ...", esp_idf_version) + download_from_mirrors( + ESP_IDF_CONSTRAINTS_MIRRORS, + {"VERSION": esp_idf_version}, + constraint_file_path, + ) + + cmd_pip_install = [ + str(env_python_path), + "-m", + "pip", + "install", + "--upgrade", + "--constraint", + constraint_file_path, + ] + + _LOGGER.info("Installing ESP-IDF %s Python dependencies ...", version) + cmd = cmd_pip_install + [ + "pip", + "setuptools", + ] + if not _exec_ok( + cmd, + msg=f"Upgrade ESP-IDF {version} Python environment packages", + env=env, + ): + raise RuntimeError( + f"Upgrade ESP-IDF {version} Python environment packages failure" + ) + + for feature in features: + requirements_file = ( + framework_path + / "tools" + / "requirements" + / f"requirements.{feature}.txt" + ) + cmd = cmd_pip_install + [ + "-r", + str(requirements_file), + ] + if not _exec_ok( + cmd, + msg=f"Install ESP-IDF {version} Python dependencies for {feature}", + env=env, + ): + raise RuntimeError( + f"Install ESP-IDF {version} Python dependencies for {feature} failure" + ) + + stamp_info["python_version"] = _get_python_version( + env_python_path, env=env, throw_exception=True + ) + _write_stamp(env_stamp_file, stamp_info) + + return python_env_path, install + + +def check_esp_idf_install( + version: str, + targets: list[str] | None = None, + tools: list[str] | None = None, + features: list[str] | None = None, + force: bool = False, +) -> tuple[Path, Path]: + """ + Check and install ESP-IDF framework and Python environment. + + Args: + version: ESP-IDF version to check/install + targets: Target platforms to install + tools: list of tools to install + features: Features to install + force: If True, force reinstallation + + Returns: + tuple of (framework_path, python_env_path) + """ + env = {} + env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) + env["IDF_PATH"] = "" + + targets = targets or ESPHOME_IDF_DEFAULT_TARGETS + + # Determine which tools need to be installed if not provided + if tools is None: + tools = [] + for tool in set(ESPHOME_IDF_DEFAULT_TOOLS) | set( + ESPHOME_IDF_DEFAULT_TOOLS_FORCE + ): + # Check if the tool exist + if tool in ESPHOME_IDF_DEFAULT_TOOLS_FORCE or not shutil.which(tool): + tools.append(tool) + + # 1) Framework + framework_path, installed = _check_esphome_idf_framework_install( + version, targets, tools, force=force, env=env + ) + + features = features or ESPHOME_IDF_DEFAULT_FEATURES + + # 2) Python env + python_env_path, installed = _check_esp_idf_python_env_install( + version, features, force=force or installed, env=env + ) + + return framework_path, python_env_path + + +def get_framework_env( + framework_path: PathType, + python_env_path: PathType | None = None, + env: dict[str, str] | None = None, +): + """ + Get environment variables for ESP-IDF framework. + + Args: + framework_path: Path to the ESP-IDF framework + python_env_path: Optional path to Python environment + env: Optional dictionary of environment variables to set + + Returns: + Dictionary containing updated environment variables + """ + # 1. Initialize base environment with extra ESP-IDF environment variables + env = env.copy() if env else {} + env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) + env["IDF_PATH"] = "" + + # 2. Get existing PATH from env or os.environ + if "PATH" in env: + path_list = env["PATH"].split(os.pathsep) + else: + path_list = os.environ["PATH"].split(os.pathsep) + + # 3. If Python environment path is provided, add it to PATH and set IDF_PYTHON_ENV_PATH + if python_env_path: + python_path = _get_python_env_executable_path(python_env_path, "python") + path_list.insert(0, str(python_path.parent)) + env["IDF_PYTHON_ENV_PATH"] = str(python_env_path) + + # 4. Set framework-specific environment variables + env["IDF_PATH"] = str(framework_path) + env["ESP_IDF_VERSION"] = _get_idf_version(framework_path, env) + + # 5. Get and add tool paths and environment variables + paths_to_export, export_vars = _get_idf_tool_paths(framework_path, env) + env.update(export_vars) + env["PATH"] = os.pathsep.join(paths_to_export + path_list) + + return env diff --git a/esphome/espidf/get_idf_tool_paths.py b/esphome/espidf/get_idf_tool_paths.py new file mode 100644 index 0000000000..2e8859631d --- /dev/null +++ b/esphome/espidf/get_idf_tool_paths.py @@ -0,0 +1,51 @@ +"""Print JSON ``{paths_to_export, export_vars}`` for ESP-IDF tools. + +Run via ``python ``. PYTHONPATH must include +``/tools`` so ``idf_tools`` is importable. Exits with +status 1 and prints ``Missing ESP-IDF tools: ...`` on stderr if any tool is +not installed. +""" + +# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only + +import json +import os +import sys +from types import SimpleNamespace + +from idf_tools import ( + TOOLS_FILE, + IDFEnv, + IDFTool, + filter_tools_info, + g, + load_tools_info, + process_tool, +) + +g.idf_path = sys.argv[1] +g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH") +g.tools_json = os.path.join(g.idf_path, TOOLS_FILE) + +tools_info = filter_tools_info(IDFEnv.get_idf_env(), load_tools_info()) +args = SimpleNamespace(prefer_system=False) +paths_to_export: list[str] = [] +export_vars: dict[str, str] = {} +missing_tools: list[str] = [] + +for name, tool in tools_info.items(): + if tool.get_install_type() == IDFTool.INSTALL_NEVER: + continue + tool_paths, tool_vars, found = process_tool( + tool, name, args, "install_cmd", "prefer_system_hint" + ) + if not found: + missing_tools.append(name) + paths_to_export += tool_paths + export_vars |= tool_vars + +if missing_tools: + print("Missing ESP-IDF tools: " + ", ".join(missing_tools), file=sys.stderr) + raise SystemExit(1) + +print(json.dumps({"paths_to_export": paths_to_export, "export_vars": export_vars})) diff --git a/esphome/espidf/get_idf_version.py b/esphome/espidf/get_idf_version.py new file mode 100644 index 0000000000..5be51275ec --- /dev/null +++ b/esphome/espidf/get_idf_version.py @@ -0,0 +1,14 @@ +"""Print the ESP-IDF version of a given framework root. + +Run via ``python ``. PYTHONPATH must include +``/tools`` so ``idf_tools`` is importable. +""" + +# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only + +import sys + +from idf_tools import g, get_idf_version + +g.idf_path = sys.argv[1] +print(get_idf_version()) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py new file mode 100644 index 0000000000..e740ab7285 --- /dev/null +++ b/esphome/espidf/runner.py @@ -0,0 +1,223 @@ +r"""Subprocess entry point for running ``idf.py`` with stdio wrapping. + +Invoked as ``python runner.py [script args...]``. + +Wraps ``sys.stdout`` and ``sys.stderr`` with a ``_FilteringTTYStream`` +shim so that: + +1. ``isatty()`` unconditionally returns True. CMake, Ninja, and idf.py's + own progress-bar code all check ``stream.isatty()`` to decide between + TTY-format output (``\\r`` cursor moves, ANSI colors, fancy progress + bars) and a plain fallback. With the wrapper in place they always + emit TTY format, even when our real stdout is a pipe to the parent + process (e.g. running under the Home Assistant dashboard add-on). + Downstream consumers — local terminals and the HA dashboard log + viewer — render the TTY control sequences correctly. + +2. ``FILTER_IDF_LINES`` is applied inside the shim's ``write()`` so + noisy idf.py output is dropped before it leaves this subprocess. + Filtering is skipped when ``-v`` / ``--verbose`` appears in argv so + verbose mode still shows everything. + +ESP-IDF runs under its own Python virtual environment which does not +have the ``esphome`` package installed, so the runner is intentionally +self-contained: no imports from ``esphome`` at all. The line-filtering +wrapper is inlined below rather than imported from +``esphome.util.RedirectText`` for that reason. +""" + +import sys + +# Regex patterns matched against each line of idf.py / CMake / Ninja +# output. Lines that match are dropped before reaching the parent +# process. Patterns are anchored at the start of the line (the shim +# uses ``re.match``). Disabled when the user passes ``-v`` / +# ``--verbose`` to ``esphome compile``. +FILTER_IDF_LINES: list[str] = [ + # idf.py's "how to flash" block at the end of a successful build. + # ESPHome handles flashing itself, so these instructions just clutter + # the output. + r"Project build complete\.", + r" idf\.py ", + r" python -m esptool ", + r"or$", + r"or from the ", + # CMake dumps the full list of IDF component paths on one giant line. + # It's purely informational and bloats the log. + r"-- Component paths:", + # CMake lists every linker script it adds (dozens of lines) and the + # complete flat list of IDF components on one giant line. Neither + # has diagnostic value for end users. + r"-- Adding linker script ", + r"-- Components:", + # IDF component manager notices: emitted on first build (no lock), + # once per stubbed dependency, plus the final "Processing N + # dependencies" enumeration. Patterns allow a leading run of dots + # because the component manager prints progress dots on the same + # line, so a NOTICE often arrives prefixed with ".NOTICE:" or + # "...........NOTICE:". + r"\.*NOTICE: ", +] + + +def main() -> int: + # ---- sys.path fix-up --------------------------------------------------- + # + # When Python runs this file as ``python runner.py``, it prepends the + # script's directory — ``/esphome/espidf/`` — to + # ``sys.path[0]``. That directory is part of the esphome package whose + # sibling ``types.py`` (in ``esphome/``) collides with stdlib ``types``. + # Any subsequent import that transitively touches ``types`` (``runpy``, + # ``pathlib``, ``functools``, ``typing``, ...) could resolve the wrong + # module. Drop the entry pre-emptively. ``sys`` is a built-in so + # importing it at module level earlier did not trigger the shadow. + if sys.path and sys.path[0]: + sys.path.pop(0) + # ---- end sys.path fix-up ----------------------------------------------- + + import os + import re + import runpy + + # Patch ``os.get_terminal_size`` to return a fallback size instead + # of raising ``OSError`` when the underlying fd isn't a real + # terminal. + # + # idf.py's ``fit_text_in_terminal`` (in ``idf_py_actions/tools.py``) + # unconditionally calls ``os.get_terminal_size()`` to format ninja + # progress lines. When that raises ``[Errno 25] Inappropriate + # ioctl for device`` on our pipe-backed stdout, idf.py catches the + # exception as ``EnvironmentError`` and silently exits its stdout + # reader coroutine — dropping all ninja build output from that + # point on. Returning a valid value keeps the coroutine alive so + # progress and error lines continue to flow through to the parent + # process. + # + # Honour the ``COLUMNS`` / ``LINES`` env vars if the caller set + # them explicitly. Otherwise fall back to ``(0, 0)``, which + # ``fit_text_in_terminal`` treats as "unknown width, don't + # truncate" (see the ``if not terminal_width: return out`` guard). + # Downstream log viewers (local terminals, the HA dashboard) wrap + # or scroll long lines themselves, so we'd rather emit the full + # file path than have idf.py elide its middle. + _orig_get_terminal_size = os.get_terminal_size + + def _get_terminal_size_fallback(fd: int = 1) -> os.terminal_size: + try: + return _orig_get_terminal_size(fd) + except OSError: + try: + columns = int(os.environ.get("COLUMNS", "0")) + except ValueError: + columns = 0 + try: + lines = int(os.environ.get("LINES", "0")) + except ValueError: + lines = 0 + return os.terminal_size((columns, lines)) + + os.get_terminal_size = _get_terminal_size_fallback # type: ignore[assignment] + + # Strip ANSI escape sequences before comparing a line against the filter + # patterns, so colorized lines still match plain-text patterns. + ansi_escape = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]") + + class _FilteringTTYStream: + r"""Minimal stdout/stderr wrapper. + + * ``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 + ANSI-stripped, right-stripped form matches one of + ``filter_lines`` is dropped. + * Incomplete trailing chunks are held in a buffer until a + terminator arrives. + + Mirrors the matching semantics of ``esphome.util.RedirectText`` + so filter patterns behave identically in both the PlatformIO + and IDF runner paths. + """ + + def __init__(self, stream, filter_lines: list[str] | None) -> None: + self._stream = stream + if filter_lines: + combined = r"|".join(r"(?:" + p + r")" for p in filter_lines) + self._filter_pattern: re.Pattern[str] | None = re.compile(combined) + else: + self._filter_pattern = None + self._line_buffer = "" + + def __getattr__(self, name: str): + return getattr(self._stream, name) + + def isatty(self) -> bool: + return True + + def flush(self) -> None: + self._stream.flush() + + def write(self, data) -> int: + # Text streams normally hand us ``str``; decode in case + # somebody writes bytes directly. + if not isinstance(data, str): + data = data.decode(errors="replace") + + if self._filter_pattern is None: + self._stream.write(data) + return len(data) + + 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) + return len(data) + + if len(sys.argv) < 2: + print( + "usage: runner.py [args...]", + file=sys.stderr, + ) + return 2 + + script_path = sys.argv[1] + + # Mirror the platformio_runner behaviour: verbose mode disables the + # line filter so all output reaches the user. + 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] + + # 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 + # modify sys.argv itself. + sys.argv = [script_path] + sys.argv[2:] + + # Emulate Python's default behaviour of prepending the script's + # directory to sys.path[0] when running ``python script.py``. + # runpy.run_path does not do this automatically, but idf.py relies + # on it to import its sibling modules (python_version_checker, + # idf_py_actions, ...). + script_dir = os.path.dirname(os.path.abspath(script_path)) + if script_dir not in sys.path: + sys.path.insert(0, script_dir) + + # 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__") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/esphome/espidf_api.py b/esphome/espidf_api.py deleted file mode 100644 index 9ebcc48513..0000000000 --- a/esphome/espidf_api.py +++ /dev/null @@ -1,274 +0,0 @@ -"""ESP-IDF direct build API for ESPHome.""" - -import json -import logging -import os -from pathlib import Path -import shutil -import subprocess - -from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE -from esphome.core import CORE, EsphomeError - -_LOGGER = logging.getLogger(__name__) - - -def _get_idf_path() -> Path | None: - """Get IDF_PATH from environment or common locations.""" - # Check environment variable first - if "IDF_PATH" in os.environ: - path = Path(os.environ["IDF_PATH"]) - if path.is_dir(): - return path - - # Check common installation locations - common_paths = [ - Path.home() / "esp" / "esp-idf", - Path.home() / ".espressif" / "esp-idf", - Path("/opt/esp-idf"), - ] - - for path in common_paths: - if path.is_dir() and (path / "tools" / "idf.py").is_file(): - return path - - return None - - -def _get_idf_env() -> dict[str, str]: - """Get environment variables needed for ESP-IDF build. - - Requires the user to have sourced export.sh before running esphome. - """ - env = os.environ.copy() - - idf_path = _get_idf_path() - if idf_path is None: - raise EsphomeError( - "ESP-IDF not found. Please install ESP-IDF and source export.sh:\n" - " git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git ~/esp-idf\n" - " cd ~/esp-idf && ./install.sh\n" - " source ~/esp-idf/export.sh\n" - "See: https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/" - ) - - env["IDF_PATH"] = str(idf_path) - return env - - -def run_idf_py( - *args, cwd: Path | None = None, capture_output: bool = False -) -> int | str: - """Run idf.py with the given arguments.""" - idf_path = _get_idf_path() - if idf_path is None: - raise EsphomeError("ESP-IDF not found") - - env = _get_idf_env() - idf_py = idf_path / "tools" / "idf.py" - - cmd = ["python", str(idf_py)] + list(args) - - if cwd is None: - cwd = CORE.build_path - - _LOGGER.debug("Running: %s", " ".join(cmd)) - _LOGGER.debug(" in directory: %s", cwd) - - if capture_output: - result = subprocess.run( - cmd, - cwd=cwd, - env=env, - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - _LOGGER.error("idf.py failed:\n%s", result.stderr) - return result.stdout - result = subprocess.run( - cmd, - cwd=cwd, - env=env, - check=False, - ) - return result.returncode - - -def run_reconfigure() -> int: - """Run cmake reconfigure only (no build).""" - return run_idf_py("reconfigure") - - -def has_outdated_files(): - """Check if the build configuration is stale. - - Returns True if required build files are missing or if configuration inputs - are newer than the generated CMake/Ninja build artifacts. - """ - cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") - - cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt") - cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt") - build_config_path = CORE.relative_build_path("build/config") - sdkconfig_internal_path = CORE.relative_build_path( - f"sdkconfig.{CORE.name}.esphomeinternal" - ) - dependency_lock_path = CORE.relative_build_path("dependencies.lock") - build_ninja_path = CORE.relative_build_path("build/build.ninja") - - if not os.path.isdir(build_config_path) or not os.listdir(build_config_path): - return True - if not os.path.isfile(cmakecache_txt_path): - return True - if not os.path.isfile(build_ninja_path): - return True - if os.path.isfile(dependency_lock_path) and os.path.getmtime( - dependency_lock_path - ) > os.path.getmtime(build_ninja_path): - return True - - cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) - return any( - os.path.getmtime(f) > cmakecache_txt_mtime - for f in [ - _get_idf_path(), - cmakelists_txt_build_path, - cmakelists_txt_src_path, - sdkconfig_internal_path, - build_config_path, - ] - if f and os.path.exists(f) - ) - - -def need_reconfigure() -> bool: - from esphome.build_gen.espidf import has_discovered_components - - # We need to reconfigure either if the files are outdated or if there is no component discovered - return has_outdated_files() or not has_discovered_components() - - -def run_compile(config, verbose: bool) -> int: - """Compile the ESP-IDF project. - - Uses two-phase configure to auto-discover available components: - 1. If no previous build, configure with minimal REQUIRES to discover components - 2. Regenerate CMakeLists.txt with discovered components - 3. Run full build - """ - from esphome.build_gen.espidf import write_project - - # Check if we need to do discovery phase - if need_reconfigure(): - _LOGGER.info("Discovering available ESP-IDF components...") - write_project(minimal=True) - rc = run_reconfigure() - if rc != 0: - _LOGGER.error("Component discovery failed") - return rc - _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") - write_project(minimal=False) - - # Build - args = [] - - if verbose: - args.append("-v") - - args.append("build") - - # Set the sdkconfig file - sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") - if sdkconfig_path.is_file(): - args.extend(["-D", f"SDKCONFIG={sdkconfig_path}"]) - - return run_idf_py(*args) - - -def get_firmware_path() -> Path: - """Get the path to the compiled firmware binary.""" - build_dir = CORE.relative_build_path("build") - return build_dir / f"{CORE.name}.bin" - - -def get_factory_firmware_path() -> Path: - """Get the path to the factory firmware (with bootloader).""" - build_dir = CORE.relative_build_path("build") - return build_dir / f"{CORE.name}.factory.bin" - - -def create_factory_bin() -> bool: - """Create factory.bin by merging bootloader, partition table, and app.""" - build_dir = CORE.relative_build_path("build") - flasher_args_path = build_dir / "flasher_args.json" - - if not flasher_args_path.is_file(): - _LOGGER.warning("flasher_args.json not found, cannot create factory.bin") - return False - - try: - with open(flasher_args_path, encoding="utf-8") as f: - flash_data = json.load(f) - except (json.JSONDecodeError, OSError) as e: - _LOGGER.error("Failed to read flasher_args.json: %s", e) - return False - - # Get flash size from config - flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE] - - # Build esptool merge command - sections = [] - for addr, fname in sorted( - flash_data.get("flash_files", {}).items(), key=lambda kv: int(kv[0], 16) - ): - file_path = build_dir / fname - if file_path.is_file(): - sections.extend([addr, str(file_path)]) - else: - _LOGGER.warning("Flash file not found: %s", file_path) - - if not sections: - _LOGGER.warning("No flash sections found") - return False - - output_path = get_factory_firmware_path() - chip = flash_data.get("extra_esptool_args", {}).get("chip", "esp32") - - cmd = [ - "python", - "-m", - "esptool", - "--chip", - chip, - "merge_bin", - "--flash_size", - flash_size, - "--output", - str(output_path), - ] + sections - - _LOGGER.info("Creating factory.bin...") - result = subprocess.run(cmd, capture_output=True, text=True, check=False) - - if result.returncode != 0: - _LOGGER.error("Failed to create factory.bin: %s", result.stderr) - return False - - _LOGGER.info("Created: %s", output_path) - return True - - -def create_ota_bin() -> bool: - """Copy the firmware to .ota.bin for ESPHome OTA compatibility.""" - firmware_path = get_firmware_path() - ota_path = firmware_path.with_suffix(".ota.bin") - - if not firmware_path.is_file(): - _LOGGER.warning("Firmware not found: %s", firmware_path) - return False - - shutil.copy(firmware_path, ota_path) - _LOGGER.info("Created: %s", ota_path) - return True diff --git a/esphome/espota2.py b/esphome/espota2.py index 576b1c6b2d..c13c3ea207 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -441,7 +441,7 @@ def perform_ota( start_time = time.perf_counter() offset = 0 - progress = ProgressBar() + progress = ProgressBar("Uploading") while True: chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] if not chunk: diff --git a/esphome/helpers.py b/esphome/helpers.py index 9d341af146..62ddc489ba 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -11,7 +11,7 @@ import shutil import stat import sys import tempfile -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TextIO from urllib.parse import urlparse from esphome.const import __version__ as ESPHOME_VERSION @@ -617,10 +617,15 @@ def sanitize(value): class ProgressBar: """A simple terminal progress bar for upload operations.""" - def __init__(self) -> None: + def __init__(self, header: str, stream: TextIO | None = None) -> None: + self.header = header + self.stream = stream or sys.stderr self.last_progress: int | None = None + self.enabled = hasattr(self.stream, "isatty") and self.stream.isatty() def update(self, progress: float) -> None: + if not self.enabled: + return bar_length = 60 status = "" if progress >= 1: @@ -631,11 +636,13 @@ class ProgressBar: return self.last_progress = new_progress block = int(round(bar_length * progress)) - text = f"\rUploading: [{'=' * block + ' ' * (bar_length - block)}] {new_progress}% {status}" + text = f"\r{self.header}: [{'=' * block + ' ' * (bar_length - block)}] {new_progress}% {status}" sys.stderr.write(text) sys.stderr.flush() def done(self) -> None: + if not self.enabled: + return sys.stderr.write("\n") sys.stderr.flush() diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py index a49f46b270..7c31c1b123 100644 --- a/esphome/web_server_ota.py +++ b/esphome/web_server_ota.py @@ -60,7 +60,7 @@ class _MultipartStreamer: self._idx = 0 self._total = len(prefix) + file_size + len(suffix) self._sent = 0 - self.progress = ProgressBar() + self.progress = ProgressBar("Uploading") def __len__(self) -> int: return self._total diff --git a/script/ci-custom.py b/script/ci-custom.py index 8cd8fd7544..25db32105c 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -562,7 +562,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1011 +CONST_PY_MAX_CONF = 1012 @lint_content_check(include=["esphome/const.py"]) diff --git a/script/test_build_components.py b/script/test_build_components.py index 82d05f78b2..10c5e5463f 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -175,7 +175,7 @@ def group_components_by_platform( } -def format_github_summary(test_results: list[TestResult]) -> str: +def format_github_summary(test_results: list[TestResult], toolchain=None) -> str: """Format test results as GitHub Actions job summary markdown. Args: @@ -225,11 +225,12 @@ def format_github_summary(test_results: list[TestResult]) -> str: lines.append("```bash\n") # Generate one command per platform and test type + extra_arguments = f" --toolchain {toolchain}" if toolchain else "" platform_components = group_components_by_platform(failed_results) for platform, test_type in sorted(platform_components.keys()): components_csv = ",".join(platform_components[(platform, test_type)]) lines.append( - f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}\n" + f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}{extra_arguments}\n" ) lines.append("```\n") @@ -274,13 +275,15 @@ def format_github_summary(test_results: list[TestResult]) -> str: return "".join(lines) -def write_github_summary(test_results: list[TestResult]) -> None: +def write_github_summary( + test_results: list[TestResult], toolchain: str | None = None +) -> None: """Write GitHub Actions job summary with test results and timing. Args: test_results: List of all test results """ - summary_content = format_github_summary(test_results) + summary_content = format_github_summary(test_results, toolchain) with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f: f.write(summary_content) @@ -308,6 +311,7 @@ def run_esphome_test( esphome_command: str, continue_on_fail: bool, use_testing_mode: bool = False, + toolchain: str | None = None, ) -> TestResult: """Run esphome test for a single component. @@ -367,8 +371,14 @@ def run_esphome_test( ] ) - # Add command and config file - cmd.extend([esphome_command, str(output_file)]) + if toolchain: + cmd.extend(["--toolchain", toolchain]) + + # Add command + cmd.append(esphome_command) + + # Add config file + cmd.append(str(output_file)) # Build command string for display/logging cmd_str = " ".join(cmd) @@ -432,6 +442,7 @@ def run_grouped_test( tests_dir: Path, esphome_command: str, continue_on_fail: bool, + toolchain: str | None = None, ) -> TestResult: """Run esphome test for a group of components with shared bus configs. @@ -510,10 +521,16 @@ def run_grouped_test( "-s", "target_platform", platform, - esphome_command, - str(output_file), ] + if toolchain: + cmd.extend(["--toolchain", toolchain]) + + # Add command + cmd.append(esphome_command) + + cmd.append(str(output_file)) + # Build command string for display/logging cmd_str = " ".join(cmd) @@ -576,6 +593,7 @@ def run_grouped_component_tests( esphome_command: str, continue_on_fail: bool, additional_isolated: set[str] | None = None, + toolchain: str | None = None, ) -> tuple[set[tuple[str, str]], list[TestResult]]: """Run grouped component tests. @@ -879,6 +897,7 @@ def run_grouped_component_tests( tests_dir=tests_dir, esphome_command=esphome_command, continue_on_fail=continue_on_fail, + toolchain=toolchain, ) # Mark all components as tested @@ -902,6 +921,7 @@ def run_individual_component_test( continue_on_fail: bool, tested_components: set[tuple[str, str]], test_results: list[TestResult], + toolchain: str | None = None, ) -> None: """Run an individual component test if not already tested in a group. @@ -930,6 +950,7 @@ def run_individual_component_test( build_dir=build_dir, esphome_command=esphome_command, continue_on_fail=continue_on_fail, + toolchain=toolchain, ) test_results.append(test_result) @@ -942,6 +963,7 @@ def test_components( enable_grouping: bool = True, isolated_components: set[str] | None = None, base_only: bool = False, + toolchain: str | None = None, ) -> int: """Test components with optional intelligent grouping. @@ -1018,6 +1040,7 @@ def test_components( esphome_command=esphome_command, continue_on_fail=continue_on_fail, additional_isolated=isolated_components, + toolchain=toolchain, ) test_results.extend(grouped_results) @@ -1046,6 +1069,7 @@ def test_components( continue_on_fail=continue_on_fail, tested_components=tested_components, test_results=test_results, + toolchain=toolchain, ) else: # Platform-specific test @@ -1078,6 +1102,7 @@ def test_components( continue_on_fail=continue_on_fail, tested_components=tested_components, test_results=test_results, + toolchain=toolchain, ) # Separate results into passed and failed @@ -1098,17 +1123,18 @@ def test_components( print("\n" + "=" * 80) print("Commands to reproduce failures (copy-paste to reproduce locally):") print("=" * 80) + extra_arguments = f" --toolchain {toolchain}" if toolchain else "" platform_components = group_components_by_platform(failed_results) for platform, test_type in sorted(platform_components.keys()): components_csv = ",".join(platform_components[(platform, test_type)]) print( - f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}" + f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}{extra_arguments}" ) print() # Write GitHub Actions job summary if in CI if os.environ.get("GITHUB_STEP_SUMMARY"): - write_github_summary(test_results) + write_github_summary(test_results, toolchain=toolchain) if failed_results: return 1 @@ -1161,6 +1187,10 @@ def main() -> int: action="store_true", help="Only test base test files (test.*.yaml), not variant files (test-*.yaml)", ) + parser.add_argument( + "--toolchain", + help="Select toolchain for compiling.", + ) args = parser.parse_args() @@ -1180,6 +1210,7 @@ def main() -> int: enable_grouping=not args.no_grouping, isolated_components=isolated_components, base_only=args.base_only, + toolchain=args.toolchain, ) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 203f484107..f0f96e9adc 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -16,8 +16,8 @@ from esphome.const import ( CONF_ESPHOME, CONF_IGNORE_PIN_VALIDATION_ERROR, CONF_NUMBER, - KEY_NATIVE_IDF, PlatformFramework, + Toolchain, ) from esphome.core import CORE from tests.component_tests.types import SetCoreConfigCallable @@ -266,7 +266,7 @@ def test_native_idf_enables_reproducible_build( CORE.config_path = component_config_path("reproducible_build.yaml") CORE.config = read_config({}) - CORE.data[KEY_NATIVE_IDF] = True + CORE.toolchain = Toolchain.ESP_IDF generate_cpp_contents(CORE.config) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 9dc37918ae..1a52e6b29e 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -855,7 +855,7 @@ class TestEsphomeCore: def test_bootloader_bin__native_idf(self, target): """Native ESP-IDF builds emit the bootloader under build/bootloader/bootloader.bin.""" - target.data[const.KEY_NATIVE_IDF] = True + target.toolchain = const.Toolchain.ESP_IDF assert target.bootloader_bin == Path( "foo/build/build/bootloader/bootloader.bin" @@ -864,7 +864,7 @@ class TestEsphomeCore: def test_bootloader_bin__platformio(self, target): """For PlatformIO builds bootloader.bin lives in the env-specific .pioenvs directory.""" target.name = "test-device" - target.data[const.KEY_NATIVE_IDF] = False + target.toolchain = const.Toolchain.PLATFORMIO assert target.bootloader_bin == Path( "foo/build/.pioenvs/test-device/bootloader.bin" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py new file mode 100644 index 0000000000..caef10eea3 --- /dev/null +++ b/tests/unit_tests/test_espidf_component.py @@ -0,0 +1,357 @@ +import json +import os +from unittest.mock import MagicMock + +import pytest + +from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Framework, + Platform, +) +from esphome.core import CORE, Library +import esphome.espidf.component +from esphome.espidf.component import ( + GitSource, + IDFComponent, + InvalidIDFComponent, + URLSource, + _check_library_data, + _collect_filtered_files, + _convert_library_to_component, + _detect_requires, + _parse_library_json, + _parse_library_properties, + _process_dependencies, + _split_list_by_condition, + generate_cmakelists_txt, + generate_idf_component_yml, +) + + +@pytest.fixture(name="tmp_component") +def fixture_tmp_component(tmp_path): + c = IDFComponent("owner/name", "1.0.0", source=MagicMock()) + c.path = tmp_path + return c + + +@pytest.fixture(name="esp32_idf_core") +def fixture_esp32_idf_core(): + CORE.data[KEY_CORE] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = str(Platform.ESP32) + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ESP_IDF) + + +def test_idf_component_str(): + c = IDFComponent("foo/bar", "1.0", source=URLSource("http://dummy.com")) + assert str(c) == "foo/bar@1.0=http://dummy.com" + + +def test_idf_component_sanitized_name(): + c = IDFComponent("foo/bar bar-bar", "1.0", source=URLSource("http://dummy.com")) + assert c.get_sanitized_name() == "foo/bar_bar-bar" + + +def test_idf_component_require_name(): + c = IDFComponent("foo/bar", "1.0", source=URLSource("http://dummy.com")) + assert c.get_require_name() == "foo__bar" + + +def test_collect_filtered_files_basic(tmp_path): + f1 = tmp_path / "a.c" + f2 = tmp_path / "b" / "b.cpp" + f1.write_text("int a;") + f2.parent.mkdir(parents=True) + f2.write_text("int b;") + + result = _collect_filtered_files(tmp_path, ["+<*>"]) + assert str(f1) in result + assert str(f2) in result + + +def test_collect_filtered_files_exclude(tmp_path): + f1 = tmp_path / "a.c" + f2 = tmp_path / "b.cpp" + f1.write_text("int a;") + f2.write_text("int b;") + + result = _collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"]) + assert str(f1) in result + assert str(f2) not in result + + +def test_detect_requires(tmp_path): + f = tmp_path / "main.c" + f.write_text('#include "mbedtls/foo.h"') + + result = _detect_requires([str(f)]) + assert "mbedtls" in result + + +def test_detect_requires_ignores_invalid_file(tmp_path): + result = _detect_requires([str(tmp_path / "missing.c")]) + assert result == set() + + +def test_split_list_by_condition(): + items = ["-Iinclude", "-Llib", "-Wall"] + + matched, rest = _split_list_by_condition( + items, lambda x: x[2:] if x.startswith("-I") else None + ) + + assert matched == ["include"] + assert "-Llib" in rest + assert "-Wall" in rest + + +def test_generate_cmakelists_txt_basic(tmp_component): + src_dir = tmp_component.path / "src" + src_dir.mkdir() + f = src_dir / "main.c" + f.write_text("int main() {}") + + tmp_component.data = {} + + content = generate_cmakelists_txt(tmp_component) + + assert "idf_component_register" in content + assert "main.c" in content + + +def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path): + src_dir = tmp_component.path / "src" + src_dir.mkdir() + (src_dir / "main.c").write_text("int main() {}") + + dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com")) + dep.path = tmp_path / "dep" + tmp_component.dependencies = [dep] + + tmp_component.data = { + "build": {"flags": ["-Iinclude", "-Llib", "-lmylib", "-Wall", "-DTEST"]} + } + + content = generate_cmakelists_txt(tmp_component) + sep = "\\\\" if os.name == "nt" else "/" + assert ( + content + == f"""idf_component_register( + SRCS "src{sep}main.c" + INCLUDE_DIRS "src" + REQUIRES dep +) +target_compile_options(${{COMPONENT_LIB}} PUBLIC + "-DTEST" +) +target_compile_options(${{COMPONENT_LIB}} PRIVATE + "-Wall" +) +target_link_directories(${{COMPONENT_LIB}} INTERFACE + "lib" +) +target_link_libraries(${{COMPONENT_LIB}} INTERFACE + "mylib" +) +""" + ) + + +def test_generate_idf_component_yml_basic(tmp_component): + tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} + result = generate_idf_component_yml(tmp_component) + + assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n" + + +def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): + dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com")) + dep.path = tmp_path / "dep" + + tmp_component.dependencies = [dep] + tmp_component.data = {} + + result = generate_idf_component_yml(tmp_component) + + assert ( + result + == f"""version: 1.0.0 +dependencies: + dep: + version: '1.0' + override_path: {dep.path} +""" + ) + + +def test_generate_idf_component_yml_arduino_registry_dep(tmp_component): + # Synthetic arduino-esp32 dep with no source / no path: should emit a + # version-only entry so the IDF component manager resolves it from the + # registry instead of via git. + dep = IDFComponent("espressif/arduino-esp32", "3.3.8", source=None) + + tmp_component.dependencies = [dep] + tmp_component.data = {} + + result = generate_idf_component_yml(tmp_component) + + assert ( + result + == """version: 1.0.0 +dependencies: + espressif/arduino-esp32: + version: 3.3.8 +""" + ) + + +def test_generate_idf_component_yml_missing_path_reraises(tmp_component): + # A dep without a path and without a recognised source should re-raise + # the underlying RuntimeError instead of silently producing a bad manifest. + dep = IDFComponent("foo/bar", "1.0", source=None) + + tmp_component.dependencies = [dep] + tmp_component.data = {} + + with pytest.raises(RuntimeError): + generate_idf_component_yml(tmp_component) + + +def test_check_library_data_valid(esp32_idf_core): + _check_library_data({"platforms": "*", "frameworks": "*"}) + + +def test_check_library_data_valid2(esp32_idf_core): + _check_library_data({"platforms": "*"}) + + +def test_check_library_data_valid3(esp32_idf_core): + _check_library_data({}) + + +def test_check_library_data_valid4(esp32_idf_core): + _check_library_data({"platforms": "espressif32", "frameworks": "*"}) + + +def test_check_library_data_valid5(esp32_idf_core): + _check_library_data({"platforms": "*", "frameworks": "espidf"}) + + +def test_check_library_data_invalid_platform(esp32_idf_core): + with pytest.raises(InvalidIDFComponent): + _check_library_data({"platforms": ["other"], "frameworks": "*"}) + + +def test_check_library_data_invalid_framework(esp32_idf_core): + with pytest.raises(InvalidIDFComponent): + _check_library_data({"platforms": "*", "frameworks": ["other"]}) + + +def test_extra_script_logs_warning(caplog, esp32_idf_core): + extra_script = "myscript.sh" + + with caplog.at_level("WARNING"): + _check_library_data({"build": {"extraScript": extra_script}}) + + assert "not supported" in caplog.text + assert "myscript.sh" in caplog.text + + +def test_parse_library_json(tmp_path): + f = tmp_path / "library.json" + f.write_text(json.dumps({"name": "test"})) + + result = _parse_library_json(f) + assert result["name"] == "test" + + +def test_parse_library_properties(tmp_path): + f = tmp_path / "library.properties" + f.write_text( + """ +name=Test +version=1.0 +# description=ABCD +empty= +""" + ) + + result = _parse_library_properties(f) + + assert result["name"] == "Test" + assert result["version"] == "1.0" + assert "empty" not in result + + +def test_convert_library_with_repository(): + lib = Library("name", None, "https://github.com/foo/bar.git#v1.2.3") + + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "1.2.3" + assert isinstance(result.source, GitSource) + + +def test_convert_library_missing_ref(): + lib = Library("name", None, "https://github.com/foo/bar.git") + + with pytest.raises(ValueError): + _convert_library_to_component(lib) + + +def test_convert_library_registry(monkeypatch): + lib = Library("foo/bar", "^1.0.0", None) + + monkeypatch.setattr( + esphome.espidf.component, + "_get_package_from_pio_registry", + lambda o, n, r: ("foo", "bar", "1.2.3", "http://example.com/pkg.zip"), + ) + + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "1.2.3" + assert isinstance(result.source, URLSource) + + +def test_process_dependencies_adds_valid_dependency(tmp_component, monkeypatch): + tmp_component.data = { + "dependencies": [ + { + "name": "foo", + "version": "1.0", + } + ] + } + + monkeypatch.setattr( + esphome.espidf.component, + "_generate_idf_component", + lambda lib: esphome.espidf.component.IDFComponent( + lib.name, lib.version, source=URLSource("http://dummy.com") + ), + ) + + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(tmp_component.dependencies) == 1 + + +def test_process_dependencies_skips_invalid(tmp_component): + tmp_component.data = { + "dependencies": [ + {"name": "foo", "version": "1.0", "platforms": ["arduino"]}, + {"invalid": "entry"}, + ] + } + + _process_dependencies(tmp_component) + + assert tmp_component.dependencies == [] diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index b22ad46113..d7fcedfd66 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -604,7 +604,8 @@ def test_run_ota_wrapper(mock_run_ota_impl: Mock) -> None: def test_progress_bar(capsys: CaptureFixture[str]) -> None: """Test ProgressBar functionality.""" - progress = espota2.ProgressBar() + progress = espota2.ProgressBar("Uploading") + progress.enabled = True # Fake TTY # Test initial update progress.update(0.0) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 2823310f0e..3eb50de76b 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -88,6 +88,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, + Toolchain, ) from esphome.core import CORE, EsphomeError from esphome.espota2 import ( @@ -148,6 +149,7 @@ def setup_core( config[CONF_WIFI] = {CONF_USE_ADDRESS: address} CORE.config = config + CORE.toolchain = Toolchain.PLATFORMIO if platform is not None: CORE.data[KEY_CORE] = {} From c4e85fbfc1b17a78514ba92851321fa85beb6b11 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 11 May 2026 16:57:10 +1200 Subject: [PATCH 1137/2214] [ci] sync-device-classes: mint least-privilege App token (#16350) --- .github/workflows/sync-device-classes.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index be1457387d..c6c829fbb4 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -6,12 +6,27 @@ on: schedule: - cron: "45 6 * * *" +# Repo writes (branch push, PR open) happen via the App token minted below, +# so the workflow's GITHUB_TOKEN does not need any write scopes. +permissions: + contents: read # actions/checkout for this repo and home-assistant/core + jobs: sync: name: Sync Device Classes runs-on: ubuntu-latest if: github.repository == 'esphome/esphome' steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # Scope the minted App token to the minimum needed by peter-evans/create-pull-request. + permission-contents: write # git.createCommit + refs.create/update to push the sync/device-classes branch + permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -50,4 +65,4 @@ jobs: delete-branch: true title: "Synchronise Device Classes from Home Assistant" body-path: .github/PULL_REQUEST_TEMPLATE.md - token: ${{ secrets.DEVICE_CLASS_SYNC_TOKEN }} + token: ${{ steps.generate-token.outputs.token }} From 5dadfe636771f06c4113abe13dc9f3b491231ece Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 11 May 2026 17:04:09 +1200 Subject: [PATCH 1138/2214] [ci] codeowner-review-request: mint least-privilege App token (#16351) --- .github/workflows/codeowner-review-request.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 76be6ecd7b..cd6c1d34c6 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -17,9 +17,10 @@ on: - release - beta +# PR/review writes (requestReviewers, issues.createComment) are performed with the App token minted below, +# so the workflow's GITHUB_TOKEN only needs read access for checkout. permissions: - pull-requests: write - contents: read + contents: read # actions/checkout to read CODEOWNERS and the shared codeowners.js helper jobs: request-codeowner-reviews: @@ -32,9 +33,20 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # Scope the minted App token to the minimum needed by the github-script step below. + permission-pull-requests: write # pulls.listFiles, pulls.get, pulls.listReviews, pulls.requestReviewers + permission-issues: write # issues.listComments and issues.createComment (PR comments use the issues API) + - name: Request reviews from component codeowners uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + github-token: ${{ steps.generate-token.outputs.token }} script: | const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js'); From fe66f9ba41927c05e211056f818d381c62959eda Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 11 May 2026 17:15:53 +1200 Subject: [PATCH 1139/2214] [ci] Tighten workflow permissions to least-privilege (#16349) --- .github/workflows/auto-label-pr.yml | 9 +++++++-- .github/workflows/ci-api-proto.yml | 4 ++-- .github/workflows/ci-clang-tidy-hash.yml | 4 ++-- .github/workflows/ci-docker.yml | 3 +-- .github/workflows/ci-memory-impact-comment.yml | 6 +++--- .github/workflows/ci.yml | 6 +++--- .../close-pr-from-fork-default-branch.yml | 4 ++-- .../codeowner-approved-label-update.yml | 6 +++--- .github/workflows/codeql.yml | 16 +++++++--------- .github/workflows/external-component-bot.yml | 5 ++--- .github/workflows/issue-codeowner-notify.yml | 4 ++-- .github/workflows/lock.yml | 6 ++++++ .github/workflows/pr-title-check.yml | 4 ++-- .github/workflows/release.yml | 17 ++++++++++------- .github/workflows/stale.yml | 4 ++-- .github/workflows/status-check-labels.yml | 3 +++ 16 files changed, 57 insertions(+), 44 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index ea22f75ef0..2d000658a2 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -6,9 +6,10 @@ on: pull_request_target: types: [labeled, opened, reopened, synchronize, edited] +# All PR/label/review writes are performed with the App token minted below, +# so the workflow's GITHUB_TOKEN only needs read access for checkout. permissions: - pull-requests: write - contents: read + contents: read # actions/checkout reads the workflow source env: SMALL_PR_THRESHOLD: 30 @@ -31,6 +32,10 @@ jobs: with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # Scope the minted App token to the minimum needed by auto-label-pr/*.js. + permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js + permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment + permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview - name: Auto Label PR uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index e5143911d9..2f7fd271ba 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -12,8 +12,8 @@ on: - ".github/workflows/ci-api-proto.yml" permissions: - contents: read - pull-requests: write + contents: read # actions/checkout for the PR head + pull-requests: write # pulls.createReview / listReviews / dismissReview when generated proto files are stale jobs: check: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 40cdff0cba..d9148fb06d 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -12,8 +12,8 @@ on: - ".github/workflows/ci-clang-tidy-hash.yml" permissions: - contents: read - pull-requests: write + contents: read # actions/checkout for the PR head + pull-requests: write # pulls.createReview / listReviews / dismissReview when the clang-tidy hash is out of date jobs: verify-hash: diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 4009ac1e17..3fd17888c7 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -22,8 +22,7 @@ on: - "script/platformio_install_deps.py" permissions: - contents: read - packages: read + contents: read # actions/checkout only; the build does not push images concurrency: # yamllint disable-line rule:line-length diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index fbcf5ea584..025b960985 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -7,9 +7,9 @@ on: types: [completed] permissions: - contents: read - pull-requests: write - actions: read + contents: read # actions/checkout of the base repo at the PR's target branch + pull-requests: write # gh api to look up the PR by head SHA and post/update the memory-impact comment + actions: read # gh run download for the memory-analysis artifacts produced by the CI workflow run jobs: memory-impact-comment: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9909d7a5dd..7cb8e07afa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ on: merge_group: permissions: - contents: read + contents: read # actions/checkout for all jobs; individual jobs add their own scopes when they need to write env: DEFAULT_PYTHON: "3.11" @@ -1147,8 +1147,8 @@ jobs: - memory-impact-pr-branch if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && fromJSON(needs.determine-jobs.outputs.memory_impact).should_run == 'true' && needs.memory-impact-target-branch.outputs.skip != 'true' permissions: - contents: read - pull-requests: write + contents: read # actions/checkout to load the comment-posting script + pull-requests: write # ci_memory_impact_comment.py posts/updates the memory-impact comment on the PR env: GH_TOKEN: ${{ github.token }} steps: diff --git a/.github/workflows/close-pr-from-fork-default-branch.yml b/.github/workflows/close-pr-from-fork-default-branch.yml index 1cd70f5efc..5180a07180 100644 --- a/.github/workflows/close-pr-from-fork-default-branch.yml +++ b/.github/workflows/close-pr-from-fork-default-branch.yml @@ -6,8 +6,8 @@ on: types: [opened, reopened] permissions: - pull-requests: write - issues: write + pull-requests: write # pulls.update to close the PR opened from a fork's default branch + issues: write # issues.createComment to explain to the contributor why the PR was closed jobs: close: diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 49653b6fb3..013517bde6 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -15,9 +15,9 @@ on: - beta permissions: - issues: write - pull-requests: read - contents: read + issues: write # issues.addLabels / removeLabel to manage the 'code-owner-approved' label on the PR + pull-requests: read # listReviews to determine whether a codeowner has approved + contents: read # actions/checkout to read CODEOWNERS and the shared codeowners.js helper jobs: codeowner-approved: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 949e45e45c..0a4dd9a92d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -16,6 +16,9 @@ on: schedule: - cron: "30 18 * * 4" +# Deny by default; the analyze job opts in to exactly what it needs. +permissions: {} + jobs: analyze: name: Analyze (${{ matrix.language }}) @@ -26,15 +29,10 @@ jobs: # Consider using larger runners or machines with greater resources for possible analysis time improvements. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} permissions: - # required for all workflows - security-events: write - - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read - contents: read + security-events: write # upload CodeQL SARIF results to the Code Scanning API + packages: read # fetch internal or private CodeQL query packs + actions: read # required by codeql-action when run from a private repo + contents: read # actions/checkout to scan the repository strategy: fail-fast: false diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 3165b17078..6e2bf780b8 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -5,9 +5,8 @@ on: types: [opened, synchronize] permissions: - contents: read # Needed to fetch PR details - issues: write # Needed to create and update comments (PR comments are managed via the issues REST API) - pull-requests: write # also needed? + issues: write # issues.createComment / updateComment to post the external-component usage instructions on the PR + pull-requests: read # pulls.listFiles to enumerate which components changed jobs: external-comment: diff --git a/.github/workflows/issue-codeowner-notify.yml b/.github/workflows/issue-codeowner-notify.yml index b211c13985..bc892b64e0 100644 --- a/.github/workflows/issue-codeowner-notify.yml +++ b/.github/workflows/issue-codeowner-notify.yml @@ -9,8 +9,8 @@ on: types: [labeled] permissions: - issues: write - contents: read + issues: write # issues.createComment to mention component codeowners on the newly labelled issue + contents: read # repos.getContent to fetch CODEOWNERS from the default branch jobs: notify-codeowners: diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 8d1dfe857d..5e70117652 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -6,6 +6,12 @@ on: - cron: "30 0 * * *" # Run daily at 00:30 UTC workflow_dispatch: +# Deny by default; the lock job opts in to exactly what the reusable workflow needs. +permissions: {} + jobs: lock: + permissions: + issues: write # issues.lock on closed issues + pull-requests: write # issues.lock on closed pull requests uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1 diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 8700996271..ed0bff9664 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -8,8 +8,8 @@ on: - beta permissions: - contents: read - pull-requests: read + contents: read # actions/checkout to load detect-tags.js + pull-requests: read # pulls.listFiles to map changed files to component/core/dashboard/ci tags jobs: check: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a16af92b6f..d07c8fe633 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: - cron: "0 2 * * *" permissions: - contents: read + contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write jobs: init: @@ -57,8 +57,8 @@ jobs: if: github.repository == 'esphome/esphome' && github.event_name == 'release' runs-on: ubuntu-latest permissions: - contents: read - id-token: write + contents: read # actions/checkout to build the sdist/wheel + id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python @@ -78,8 +78,8 @@ jobs: name: Build ESPHome ${{ matrix.platform.arch }} if: github.repository == 'esphome/esphome' permissions: - contents: read - packages: write + contents: read # actions/checkout to load Dockerfile and build context + packages: write # docker/login-action + build-push-action push image digests to ghcr.io runs-on: ${{ matrix.platform.os }} needs: [init] strategy: @@ -152,8 +152,8 @@ jobs: - deploy-docker if: github.repository == 'esphome/esphome' permissions: - contents: read - packages: write + contents: read # actions/checkout to load Dockerfile and build context + packages: write # docker/login-action + build-push-action push image digests to ghcr.io strategy: fail-fast: false matrix: @@ -227,6 +227,7 @@ jobs: private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: home-assistant-addon + permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - name: Trigger Workflow uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -262,6 +263,7 @@ jobs: private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: esphome-schema + permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - name: Trigger Workflow uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -293,6 +295,7 @@ jobs: private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: version-notifier + permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - name: Trigger Workflow uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ba5c32e016..2e57093bbb 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -7,8 +7,8 @@ on: workflow_dispatch: permissions: - issues: write - pull-requests: write + issues: write # actions/stale labels, comments on, and closes stale issues + pull-requests: write # actions/stale labels, comments on, and closes stale pull requests concurrency: group: lock diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 709342e5ae..d27cc0cbec 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, reopened, labeled, unlabeled, synchronize] +permissions: + pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr) + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true From c82adc3892f453b09c2a7f15fd37dcc6841f83f0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 11 May 2026 19:52:39 +1200 Subject: [PATCH 1140/2214] [ci] Fix external-component-bot 403 on PR comments (#16354) --- .github/workflows/external-component-bot.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 6e2bf780b8..2e96bec1de 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -4,19 +4,29 @@ on: pull_request_target: types: [opened, synchronize] -permissions: - issues: write # issues.createComment / updateComment to post the external-component usage instructions on the PR - pull-requests: read # pulls.listFiles to enumerate which components changed +# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with +# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes. +permissions: {} jobs: external-comment: name: External component comment runs-on: ubuntu-latest steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources + # the issues.*Comment APIs require the pull-requests scope, not issues. + permission-pull-requests: write + - name: Add external component comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | // Generate external component usage instructions function generateExternalComponentInstructions(prNumber, componentNames, owner, repo) { From 267836d09886c66797062bf45a7be6e86ea93f94 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Mon, 11 May 2026 01:27:07 -0700 Subject: [PATCH 1141/2214] [tuya] allow status pin that doesn't match the reported one (#16353) --- esphome/components/tuya/tuya.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index d682adffe3..b29905f9a0 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -209,13 +209,12 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff bool is_pin_equals = this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_; // Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send - if (is_pin_equals) { - ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_reported_); - this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); - } else { - ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. TuyaMcu will work in limited mode.", + if (!is_pin_equals) { + ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.", this->status_pin_reported_); } + ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); + this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); } else { this->init_state_ = TuyaInitState::INIT_WIFI; ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); From e4d9786f00c0c271495a0d3cf457533eaea069e9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 11 May 2026 05:34:47 -0400 Subject: [PATCH 1142/2214] [core] Move platformio files to subfolder (#16347) --- esphome/__main__.py | 68 ++++++------ esphome/analyze_memory/__init__.py | 2 +- esphome/analyze_memory/cli.py | 2 +- esphome/components/esp32/__init__.py | 4 +- esphome/components/esp8266/__init__.py | 4 +- esphome/components/nrf52/__init__.py | 4 +- esphome/components/rp2040/__init__.py | 2 +- esphome/dashboard/web_server.py | 5 +- esphome/espidf/runner.py | 2 +- esphome/espidf/{api.py => toolchain.py} | 2 +- esphome/platformio/__init__.py | 0 .../runner.py} | 2 +- .../toolchain.py} | 2 +- script/build_helpers.py | 2 +- script/ci_memory_impact_extract.py | 2 +- tests/dashboard/test_web_server.py | 4 +- tests/integration/conftest.py | 2 +- tests/unit_tests/conftest.py | 16 +-- tests/unit_tests/test_main.py | 40 +++---- ...io_api.py => test_platformio_toolchain.py} | 100 +++++++++--------- 20 files changed, 134 insertions(+), 131 deletions(-) rename esphome/espidf/{api.py => toolchain.py} (99%) create mode 100644 esphome/platformio/__init__.py rename esphome/{platformio_runner.py => platformio/runner.py} (99%) rename esphome/{platformio_api.py => platformio/toolchain.py} (99%) rename tests/unit_tests/{test_platformio_api.py => test_platformio_toolchain.py} (92%) diff --git a/esphome/__main__.py b/esphome/__main__.py index 01b33eb8ac..54d6384bfc 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -766,24 +766,24 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) if CORE.using_toolchain_esp_idf: - from esphome.espidf import api + from esphome.espidf import toolchain - rc = api.run_compile(config, CORE.verbose) + rc = toolchain.run_compile(config, CORE.verbose) if rc != 0: return rc # Create factory.bin, ota.bin, and firmware.elf copy - api.create_factory_bin() - api.create_ota_bin() - api.create_elf_copy() + toolchain.create_factory_bin() + toolchain.create_ota_bin() + toolchain.create_elf_copy() else: - from esphome import platformio_api + from esphome.platformio import toolchain - rc = platformio_api.run_compile(config, CORE.verbose) + rc = toolchain.run_compile(config, CORE.verbose) if rc != 0: return rc - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) if idedata is None: return 1 @@ -879,13 +879,15 @@ def upload_using_esptool( if file is not None: flash_images = [FlashImage(path=file, offset="0x0")] elif CORE.using_toolchain_esp_idf: - from esphome.espidf import api + from esphome.espidf import toolchain - flash_images = [FlashImage(path=api.get_factory_firmware_path(), offset="0x0")] + flash_images = [ + FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0") + ] else: - from esphome import platformio_api + from esphome.platformio import toolchain - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) firmware_offset = "0x10000" if CORE.is_esp32 else "0x0" flash_images = [ @@ -958,13 +960,13 @@ def upload_using_esptool( def upload_using_platformio(config: ConfigType, port: str) -> int: - from esphome import platformio_api + from esphome.platformio import toolchain # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for # the upload target, but 'nobuild' skips the build phase that creates it. # Create it here so the upload doesn't fail. if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040: - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) build_dir = Path(idedata.firmware_elf_path).parent firmware_bin = build_dir / "firmware.bin" signed_bin = build_dir / "firmware.bin.signed" @@ -974,15 +976,15 @@ def upload_using_platformio(config: ConfigType, port: str) -> int: upload_args = ["-t", "upload", "-t", "nobuild"] if port is not None: upload_args += ["--upload-port", port] - return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args) + return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args) def _find_picotool() -> Path | None: """Find the picotool binary from PlatformIO packages.""" - from esphome import platformio_api + from esphome.platformio import toolchain try: - idedata = platformio_api.get_idedata(CORE.config) + idedata = toolchain.get_idedata(CORE.config) except Exception: # noqa: BLE001 # pylint: disable=broad-except return None return get_picotool_path(idedata.cc_path) @@ -995,9 +997,9 @@ def upload_using_picotool(config: ConfigType) -> int: the mass storage copy approach that causes "disk not ejected properly" warnings on macOS. """ - from esphome import platformio_api + from esphome.platformio import toolchain - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) firmware_elf = Path(idedata.firmware_elf_path) if not firmware_elf.is_file(): @@ -1457,11 +1459,11 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: return exit_code if CORE.is_host: if CORE.using_toolchain_esp_idf: - from esphome.espidf import api + from esphome.espidf import toolchain - program_path = str(api.get_elf_path()) + program_path = str(toolchain.get_elf_path()) else: - from esphome.platformio_api import get_idedata + from esphome.platformio.toolchain import get_idedata program_path = str(get_idedata(config).firmware_elf_path) _LOGGER.info("Successfully compiled program to path '%s'", program_path) @@ -1515,11 +1517,11 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: _LOGGER.info("Successfully compiled program.") if CORE.is_host: if CORE.using_toolchain_esp_idf: - from esphome.espidf import api + from esphome.espidf import toolchain - program_path = str(api.get_elf_path()) + program_path = str(toolchain.get_elf_path()) else: - from esphome.platformio_api import get_idedata + from esphome.platformio.toolchain import get_idedata program_path = str(get_idedata(config).firmware_elf_path) _LOGGER.info("Running program from path '%s'", program_path) @@ -1719,12 +1721,12 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: ) return 1 - from esphome import platformio_api + from esphome.platformio import toolchain logging.disable(logging.INFO) logging.disable(logging.WARNING) - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) if idedata is None: return 1 @@ -1753,16 +1755,16 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: # Get idedata for analysis idedata = None if CORE.using_toolchain_esp_idf: - from esphome.espidf import api + from esphome.espidf import toolchain - objdump_path = str(api.get_objdump_path()) - readelf_path = str(api.get_readelf_path()) + objdump_path = str(toolchain.get_objdump_path()) + readelf_path = str(toolchain.get_readelf_path()) - firmware_elf = api.get_elf_path() + firmware_elf = toolchain.get_elf_path() else: - from esphome import platformio_api + from esphome.platformio import toolchain - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) if idedata is None: _LOGGER.error("Failed to get IDE data for memory analysis") return 1 diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index 33854ac289..1198562218 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -24,7 +24,7 @@ from .helpers import ( from .toolchain import find_tool, resolve_tool_path, run_tool if TYPE_CHECKING: - from esphome.platformio_api import IDEData + from esphome.platformio.toolchain import IDEData _LOGGER = logging.getLogger(__name__) diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index b7561e8ffc..8f1f39e1d6 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -739,7 +739,7 @@ def main(): import json from pathlib import Path - from esphome.platformio_api import IDEData + from esphome.platformio.toolchain import IDEData build_path = Path(build_dir) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ba32d13ab3..bb823937aa 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2582,9 +2582,9 @@ def copy_files(): def _decode_pc(config, addr): - from esphome import platformio_api + from esphome.platformio import toolchain - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) if not idedata.addr2line_path or not idedata.firmware_elf_path: _LOGGER.debug("decode_pc no addr2line") return diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index b6383653f4..38df282fb9 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -463,9 +463,9 @@ ESP8266_EXCEPTION_CODES = { def _decode_pc(config, addr): - from esphome import platformio_api + from esphome.platformio import toolchain - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) if not idedata.addr2line_path or not idedata.firmware_elf_path: _LOGGER.debug("decode_pc no addr2line") return diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index d2ed3b15e9..38efccab11 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -397,11 +397,11 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: def _upload_using_platformio( config: ConfigType, port: str, upload_args: list[str] ) -> int | str: - from esphome import platformio_api + from esphome.platformio import toolchain if port is not None: upload_args += ["--upload-port", port] - return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args) + return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args) def upload_program(config: ConfigType, args, host: str) -> bool: diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 7e450578cd..81809c0551 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -510,7 +510,7 @@ def process_stacktrace(config, line: str, backtrace_state: bool) -> bool: if backtrace_state: if match := _CRASH_ADDR_RE.search(line): - from esphome.platformio_api import get_idedata + from esphome.platformio.toolchain import get_idedata idedata = get_idedata(config) if idedata.addr2line_path: diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index d67245967c..916e937a53 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -40,8 +40,9 @@ import voluptuous as vol import yaml from yaml.nodes import Node -from esphome import const, platformio_api, yaml_util +from esphome import const, yaml_util from esphome.helpers import get_bool_env, mkdir_p, sort_ip_addresses +from esphome.platformio import toolchain from esphome.storage_json import ( StorageJSON, archive_storage_path, @@ -1090,7 +1091,7 @@ class DownloadBinaryRequestHandler(BaseHandler): self.send_error(404 if rc == 2 else 500) return - idedata = platformio_api.IDEData(json.loads(stdout)) + idedata = toolchain.IDEData(json.loads(stdout)) found = False for image in idedata.extra_flash_images: diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index e740ab7285..34e3e7694b 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -190,7 +190,7 @@ def main() -> int: script_path = sys.argv[1] - # Mirror the platformio_runner behaviour: verbose mode disables the + # Mirror the platformio runner behaviour: verbose mode disables the # line filter so all output reaches the user. 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 diff --git a/esphome/espidf/api.py b/esphome/espidf/toolchain.py similarity index 99% rename from esphome/espidf/api.py rename to esphome/espidf/toolchain.py index 847de249a7..da6d3a8a37 100644 --- a/esphome/espidf/api.py +++ b/esphome/espidf/toolchain.py @@ -15,7 +15,7 @@ from esphome.espidf.framework import check_esp_idf_install, get_framework_env _LOGGER = logging.getLogger(__name__) -DOMAIN = "espidf_api" +DOMAIN = "espidf_toolchain" @dataclass diff --git a/esphome/platformio/__init__.py b/esphome/platformio/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/platformio_runner.py b/esphome/platformio/runner.py similarity index 99% rename from esphome/platformio_runner.py rename to esphome/platformio/runner.py index 5b14a72557..976979dc57 100644 --- a/esphome/platformio_runner.py +++ b/esphome/platformio/runner.py @@ -1,6 +1,6 @@ """Subprocess entry point that applies ESPHome's PlatformIO patches. -Invoked via ``python -m esphome.platformio_runner`` instead of +Invoked via ``python -m esphome.platformio.runner`` instead of ``python -m platformio`` so that the patches (incremental rebuild preservation, download retries) apply inside the subprocess. Running PlatformIO in a subprocess keeps its ``sys.path`` mutations and other diff --git a/esphome/platformio_api.py b/esphome/platformio/toolchain.py similarity index 99% rename from esphome/platformio_api.py rename to esphome/platformio/toolchain.py index 81ff01306a..073e134ac4 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio/toolchain.py @@ -64,7 +64,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int: # a user-provided value (or the unmodified path on platforms that # don't need the strip). os.environ["PYTHONEXEPATH"] = python_exe - cmd = [python_exe, "-m", "esphome.platformio_runner"] + list(args) + cmd = [python_exe, "-m", "esphome.platformio.runner"] + list(args) return run_external_process(*cmd, **kwargs) diff --git a/script/build_helpers.py b/script/build_helpers.py index 0e0e8170a0..fa722aa099 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -23,7 +23,7 @@ from esphome.config import validate_config from esphome.const import CONF_PLATFORM from esphome.core import CORE from esphome.loader import get_component, get_platform -from esphome.platformio_api import get_idedata +from esphome.platformio.toolchain import get_idedata from tests.testing_helpers import ComponentManifestOverride, set_testing_manifest # This must coincide with the version in /platformio.ini diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index dd91fa861c..2aa7394b11 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -26,7 +26,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) # pylint: disable=wrong-import-position from esphome.analyze_memory import MemoryAnalyzer -from esphome.platformio_api import IDEData +from esphome.platformio.toolchain import IDEData from script.ci_helpers import write_github_output # Regex patterns for extracting memory usage from PlatformIO output diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 1a62cfda90..626aea0216 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -128,8 +128,8 @@ def mock_storage_json() -> Generator[MagicMock]: @pytest.fixture def mock_idedata() -> Generator[MagicMock]: - """Fixture to mock platformio_api.IDEData.""" - with patch("esphome.dashboard.web_server.platformio_api.IDEData") as mock: + """Fixture to mock platformio toolchain.IDEData.""" + with patch("esphome.dashboard.web_server.toolchain.IDEData") as mock: yield mock diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f36543b7cd..fb025ce427 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -23,7 +23,7 @@ import pytest_asyncio import esphome.config from esphome.core import CORE -from esphome.platformio_api import get_idedata +from esphome.platformio.toolchain import get_idedata from .const import ( API_CONNECTION_TIMEOUT, diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 626f4168a6..13450b10f0 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -64,15 +64,15 @@ def mock_copy_file_if_changed() -> Generator[Mock, None, None]: @pytest.fixture def mock_run_platformio_cli() -> Generator[Mock, None, None]: - """Mock run_platformio_cli for platformio_api.""" - with patch("esphome.platformio_api.run_platformio_cli") as mock: + """Mock run_platformio_cli for platformio toolchain.""" + with patch("esphome.platformio.toolchain.run_platformio_cli") as mock: yield mock @pytest.fixture def mock_run_platformio_cli_run() -> Generator[Mock, None, None]: - """Mock run_platformio_cli_run for platformio_api.""" - with patch("esphome.platformio_api.run_platformio_cli_run") as mock: + """Mock run_platformio_cli_run for platformio toolchain.""" + with patch("esphome.platformio.toolchain.run_platformio_cli_run") as mock: yield mock @@ -92,8 +92,8 @@ def mock_esp8266_decode_pc() -> Generator[Mock, None, None]: @pytest.fixture def mock_run_external_process() -> Generator[Mock, None, None]: - """Mock run_external_process for platformio_api.""" - with patch("esphome.platformio_api.run_external_process") as mock: + """Mock run_external_process for platformio toolchain.""" + with patch("esphome.platformio.toolchain.run_external_process") as mock: yield mock @@ -113,8 +113,8 @@ def mock_subprocess_run() -> Generator[Mock, None, None]: @pytest.fixture def mock_get_idedata() -> Generator[Mock, None, None]: - """Mock get_idedata for platformio_api.""" - with patch("esphome.platformio_api.get_idedata") as mock: + """Mock get_idedata for platformio toolchain.""" + with patch("esphome.platformio.toolchain.get_idedata") as mock: yield mock diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 3eb50de76b..3648de443d 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -18,7 +18,6 @@ import pytest from pytest import CaptureFixture from zeroconf import ServiceStateChange -from esphome import platformio_api from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -96,6 +95,7 @@ from esphome.espota2 import ( OTA_TYPE_UPDATE_BOOTLOADER, OTA_TYPE_UPDATE_PARTITION_TABLE, ) +from esphome.platformio import toolchain from esphome.util import BootselResult, FlashImage from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -287,7 +287,7 @@ def mock_run_external_process() -> Generator[Mock]: @pytest.fixture def mock_run_external_command_main() -> Generator[Mock]: - """Mock run_external_command in __main__ module (different from platformio_api).""" + """Mock run_external_command in __main__ module (different from platformio toolchain).""" with patch("esphome.__main__.run_external_command") as mock: mock.return_value = 0 # Default to success yield mock @@ -1199,7 +1199,7 @@ def test_upload_using_esptool_path_conversion( CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32} # Create mock IDEData with Path objects - mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata = MagicMock(spec=toolchain.IDEData) mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" mock_idedata.extra_flash_images = [ FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), @@ -1277,7 +1277,7 @@ def test_upload_using_esptool_skips_missing_extra_flash_images( missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin" - mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata = MagicMock(spec=toolchain.IDEData) mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" mock_idedata.extra_flash_images = [ FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), @@ -1389,8 +1389,8 @@ def test_upload_using_platformio_creates_signed_bin_for_rp2040( mock_idedata.firmware_elf_path = str(firmware_elf) with ( - patch("esphome.platformio_api.get_idedata", return_value=mock_idedata), - patch("esphome.platformio_api.run_platformio_cli_run", return_value=0), + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0), ): result = upload_using_platformio({}, "/dev/ttyACM0") @@ -1406,7 +1406,7 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040( """Test that upload_using_platformio doesn't create signed bin for non-RP2040.""" setup_core(platform=PLATFORM_ESP32) - with patch("esphome.platformio_api.run_platformio_cli_run", return_value=0): + with patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0): result = upload_using_platformio({}, "/dev/ttyUSB0") assert result == 0 @@ -1504,7 +1504,7 @@ def test_upload_using_picotool_success(tmp_path: Path) -> None: config = {} with ( - patch("esphome.platformio_api.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), patch("subprocess.run", return_value=mock_result), ): exit_code = upload_using_picotool(config) @@ -1524,7 +1524,7 @@ def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: mock_idedata.cc_path = "/fake/path/gcc" config = {} - with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata): + with patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata): exit_code = upload_using_picotool(config) assert exit_code == 1 @@ -1544,7 +1544,7 @@ def test_upload_using_picotool_not_found(tmp_path: Path) -> None: mock_idedata.cc_path = "/fake/path/gcc" config = {} - with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata): + with patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata): exit_code = upload_using_picotool(config) assert exit_code == 1 @@ -1578,7 +1578,7 @@ def test_upload_using_picotool_permission_error(tmp_path: Path) -> None: config = {} with ( - patch("esphome.platformio_api.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), patch("subprocess.run", return_value=mock_result), ): exit_code = upload_using_picotool(config) @@ -4696,7 +4696,7 @@ def test_command_analyze_memory_success( firmware_elf.write_text("mock elf file") # Mock idedata - mock_idedata_obj = MagicMock(spec=platformio_api.IDEData) + mock_idedata_obj = MagicMock(spec=toolchain.IDEData) mock_idedata_obj.firmware_elf_path = str(firmware_elf) mock_idedata_obj.objdump_path = "/path/to/objdump" mock_idedata_obj.readelf_path = "/path/to/readelf" @@ -4768,7 +4768,7 @@ def test_command_analyze_memory_with_external_components( firmware_elf.write_text("mock elf file") # Mock idedata - mock_idedata_obj = MagicMock(spec=platformio_api.IDEData) + mock_idedata_obj = MagicMock(spec=toolchain.IDEData) mock_idedata_obj.firmware_elf_path = str(firmware_elf) mock_idedata_obj.objdump_path = "/path/to/objdump" mock_idedata_obj.readelf_path = "/path/to/readelf" @@ -4859,16 +4859,18 @@ def test_command_analyze_memory_no_idedata( @pytest.fixture def mock_compile_build_info_run_compile() -> Generator[Mock]: - """Mock platformio_api.run_compile for build_info tests.""" - with patch("esphome.platformio_api.run_compile", return_value=0) as mock: + """Mock toolchain.run_compile for build_info tests.""" + with patch("esphome.platformio.toolchain.run_compile", return_value=0) as mock: yield mock @pytest.fixture def mock_compile_build_info_get_idedata() -> Generator[Mock]: - """Mock platformio_api.get_idedata for build_info tests.""" + """Mock toolchain.get_idedata for build_info tests.""" mock_idedata = MagicMock() - with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata) as mock: + with patch( + "esphome.platformio.toolchain.get_idedata", return_value=mock_idedata + ) as mock: yield mock @@ -5778,7 +5780,7 @@ def test_upload_using_esptool_passes_crystal_callback( sdkconfig = build_dir / "sdkconfig.test" sdkconfig.write_text("CONFIG_XTAL_FREQ=40\n") - mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata = MagicMock(spec=toolchain.IDEData) mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" mock_idedata.extra_flash_images = [] mock_get_idedata.return_value = mock_idedata @@ -5808,7 +5810,7 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback( sdkconfig = build_dir / "sdkconfig.test" sdkconfig.write_text("CONFIG_XTAL_FREQ=40\n") - mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata = MagicMock(spec=toolchain.IDEData) mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" mock_idedata.extra_flash_images = [] mock_get_idedata.return_value = mock_idedata diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_toolchain.py similarity index 92% rename from tests/unit_tests/test_platformio_api.py rename to tests/unit_tests/test_platformio_toolchain.py index 7a88ec4d9e..f771437dd4 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -1,4 +1,4 @@ -"""Tests for platformio_api.py path functions.""" +"""Tests for esphome.platformio.toolchain path functions.""" # pylint: disable=protected-access @@ -11,8 +11,8 @@ from unittest.mock import MagicMock, Mock, call, patch import pytest -from esphome import platformio_api, platformio_runner from esphome.core import CORE, EsphomeError +from esphome.platformio import runner, toolchain from esphome.util import FlashImage @@ -21,7 +21,7 @@ def test_idedata_firmware_elf_path(setup_core: Path) -> None: CORE.build_path = setup_core / "build" / "test" CORE.name = "test" raw_data = {"prog_path": "/path/to/firmware.elf"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) assert idedata.firmware_elf_path == Path("/path/to/firmware.elf") @@ -32,7 +32,7 @@ def test_idedata_firmware_bin_path(setup_core: Path) -> None: CORE.name = "test" prog_path = str(Path("/path/to/firmware.elf")) raw_data = {"prog_path": prog_path} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.firmware_bin_path assert isinstance(result, Path) @@ -47,7 +47,7 @@ def test_idedata_firmware_bin_path_preserves_directory(setup_core: Path) -> None CORE.name = "test" prog_path = str(Path("/complex/path/to/build/firmware.elf")) raw_data = {"prog_path": prog_path} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.firmware_bin_path expected = Path("/complex/path/to/build/firmware.bin") @@ -67,7 +67,7 @@ def test_idedata_extra_flash_images(setup_core: Path) -> None: ] }, } - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) images = idedata.extra_flash_images assert len(images) == 2 @@ -83,7 +83,7 @@ def test_idedata_extra_flash_images_empty(setup_core: Path) -> None: CORE.build_path = setup_core / "build" / "test" CORE.name = "test" raw_data = {"prog_path": "/path/to/firmware.elf", "extra": {"flash_images": []}} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) images = idedata.extra_flash_images assert images == [] @@ -97,7 +97,7 @@ def test_idedata_cc_path(setup_core: Path) -> None: "prog_path": "/path/to/firmware.elf", "cc_path": "/Users/test/.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc", } - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) assert ( idedata.cc_path @@ -132,7 +132,7 @@ def test_load_idedata_returns_dict( mock_run_platformio_cli_run.return_value = '{"prog_path": "/test/firmware.elf"}' config = {"name": "test"} - result = platformio_api._load_idedata(config) + result = toolchain._load_idedata(config) assert result is not None assert isinstance(result, dict) @@ -161,7 +161,7 @@ def test_load_idedata_uses_cache_when_valid( os.utime(idedata_path, (platformio_ini_mtime + 1, platformio_ini_mtime + 1)) config = {"name": "test"} - result = platformio_api._load_idedata(config) + result = toolchain._load_idedata(config) # Should not call _run_idedata since cache is valid mock_run_platformio_cli_run.assert_not_called() @@ -194,7 +194,7 @@ def test_load_idedata_regenerates_when_platformio_ini_newer( mock_run_platformio_cli_run.return_value = json.dumps(new_data) config = {"name": "test"} - result = platformio_api._load_idedata(config) + result = toolchain._load_idedata(config) # Should call _run_idedata since platformio.ini is newer mock_run_platformio_cli_run.assert_called_once() @@ -228,7 +228,7 @@ def test_load_idedata_regenerates_on_corrupted_cache( mock_run_platformio_cli_run.return_value = json.dumps(new_data) config = {"name": "test"} - result = platformio_api._load_idedata(config) + result = toolchain._load_idedata(config) # Should call _run_idedata since cache is corrupted mock_run_platformio_cli_run.assert_called_once() @@ -253,7 +253,7 @@ def test_run_idedata_parses_json_from_output( f"Some preamble\n{json.dumps(expected_data)}\nSome postamble" ) - result = platformio_api._run_idedata(config) + result = toolchain._run_idedata(config) assert result == expected_data @@ -267,7 +267,7 @@ def test_run_idedata_raises_on_no_json( mock_run_platformio_cli_run.return_value = "No JSON in this output" with pytest.raises(EsphomeError): - platformio_api._run_idedata(config) + toolchain._run_idedata(config) def test_run_idedata_raises_on_invalid_json( @@ -279,7 +279,7 @@ def test_run_idedata_raises_on_invalid_json( # The ValueError from json.loads is re-raised with pytest.raises(ValueError): - platformio_api._run_idedata(config) + toolchain._run_idedata(config) def test_run_platformio_cli_sets_environment_variables( @@ -290,7 +290,7 @@ def test_run_platformio_cli_sets_environment_variables( with patch.dict(os.environ, {}, clear=False): mock_run_external_process.return_value = 0 - platformio_api.run_platformio_cli("test", "arg") + toolchain.run_platformio_cli("test", "arg") # Check environment variables were set assert os.environ["PLATFORMIO_FORCE_COLOR"] == "true" @@ -303,11 +303,11 @@ def test_run_platformio_cli_sets_environment_variables( assert "PYTHONWARNINGS" in os.environ # Check command was called correctly — runs PlatformIO as a subprocess - # via the esphome.platformio_runner entry point. + # via the esphome.platformio.runner entry point. mock_run_external_process.assert_called_once() args = mock_run_external_process.call_args[0] assert "-m" in args - assert "esphome.platformio_runner" in args + assert "esphome.platformio.runner" in args assert "test" in args assert "arg" in args @@ -342,8 +342,8 @@ def test_strip_win_long_path_prefix( platform: str, input_path: str, expected: str ) -> None: r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32.""" - with patch("esphome.platformio_api.sys.platform", platform): - assert platformio_api._strip_win_long_path_prefix(input_path) == expected + with patch("esphome.platformio.toolchain.sys.platform", platform): + assert toolchain._strip_win_long_path_prefix(input_path) == expected def test_run_platformio_cli_strips_win_long_path_prefix( @@ -366,15 +366,15 @@ def test_run_platformio_cli_strips_win_long_path_prefix( with ( patch.dict(os.environ, {}, clear=False), - patch("esphome.platformio_api.sys.platform", "win32"), - patch("esphome.platformio_api.sys.executable", prefixed_exe), + patch("esphome.platformio.toolchain.sys.platform", "win32"), + patch("esphome.platformio.toolchain.sys.executable", prefixed_exe), ): # Pop any pre-existing PYTHONEXEPATH so the assertion below reflects # what run_platformio_cli set, not whatever the test runner's # environment happened to contain. os.environ.pop("PYTHONEXEPATH", None) mock_run_external_process.return_value = 0 - platformio_api.run_platformio_cli("test", "arg") + toolchain.run_platformio_cli("test", "arg") # The subprocess is invoked with the stripped executable path. mock_run_external_process.assert_called_once() @@ -398,12 +398,12 @@ def test_run_platformio_cli_does_not_set_pythonexepath_without_strip( with ( patch.dict(os.environ, {}, clear=False), - patch("esphome.platformio_api.sys.platform", "linux"), - patch("esphome.platformio_api.sys.executable", plain_exe), + patch("esphome.platformio.toolchain.sys.platform", "linux"), + patch("esphome.platformio.toolchain.sys.executable", plain_exe), ): os.environ.pop("PYTHONEXEPATH", None) mock_run_external_process.return_value = 0 - platformio_api.run_platformio_cli("test", "arg") + toolchain.run_platformio_cli("test", "arg") mock_run_external_process.assert_called_once() args = mock_run_external_process.call_args[0] @@ -419,7 +419,7 @@ def test_run_platformio_cli_run_builds_command( mock_run_platformio_cli.return_value = 0 config = {"name": "test"} - platformio_api.run_platformio_cli_run(config, True, "extra", "args") + toolchain.run_platformio_cli_run(config, True, "extra", "args") mock_run_platformio_cli.assert_called_once_with( "run", "-d", CORE.build_path, "-v", "extra", "args" @@ -434,7 +434,7 @@ def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> Non config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}} mock_run_platformio_cli_run.return_value = 0 - platformio_api.run_compile(config, verbose=True) + toolchain.run_compile(config, verbose=True) mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4") @@ -461,22 +461,22 @@ def test_get_idedata_caches_result( config = {"name": "test"} # First call should load and cache - result1 = platformio_api.get_idedata(config) + result1 = toolchain.get_idedata(config) mock_run_platformio_cli_run.assert_called_once() # Second call should use cache from CORE.data - result2 = platformio_api.get_idedata(config) + result2 = toolchain.get_idedata(config) mock_run_platformio_cli_run.assert_called_once() # Still only called once assert result1 is result2 - assert isinstance(result1, platformio_api.IDEData) + assert isinstance(result1, toolchain.IDEData) assert result1.firmware_elf_path == Path("/test/firmware.elf") def test_idedata_addr2line_path_windows(setup_core: Path) -> None: """Test IDEData.addr2line_path on Windows.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.addr2line_path assert result == "C:\\tools\\addr2line.exe" @@ -485,7 +485,7 @@ def test_idedata_addr2line_path_windows(setup_core: Path) -> None: def test_idedata_addr2line_path_unix(setup_core: Path) -> None: """Test IDEData.addr2line_path on Unix.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.addr2line_path assert result == "/usr/bin/addr2line" @@ -494,7 +494,7 @@ def test_idedata_addr2line_path_unix(setup_core: Path) -> None: def test_idedata_objdump_path_windows(setup_core: Path) -> None: """Test IDEData.objdump_path on Windows.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.objdump_path assert result == "C:\\tools\\objdump.exe" @@ -503,7 +503,7 @@ def test_idedata_objdump_path_windows(setup_core: Path) -> None: def test_idedata_objdump_path_unix(setup_core: Path) -> None: """Test IDEData.objdump_path on Unix.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.objdump_path assert result == "/usr/bin/objdump" @@ -512,7 +512,7 @@ def test_idedata_objdump_path_unix(setup_core: Path) -> None: def test_idedata_readelf_path_windows(setup_core: Path) -> None: """Test IDEData.readelf_path on Windows.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.readelf_path assert result == "C:\\tools\\readelf.exe" @@ -521,7 +521,7 @@ def test_idedata_readelf_path_windows(setup_core: Path) -> None: def test_idedata_readelf_path_unix(setup_core: Path) -> None: """Test IDEData.readelf_path on Unix.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.readelf_path assert result == "/usr/bin/readelf" @@ -547,7 +547,7 @@ def test_patch_structhash(setup_core: Path) -> None: }, ): # Call patch_structhash - platformio_runner.patch_structhash() + runner.patch_structhash() # Verify both modules had clean_build_dir patched # Check that clean_build_dir was set on both modules @@ -599,7 +599,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_runner.patch_structhash() + runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -649,7 +649,7 @@ def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_runner.patch_structhash() + runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -697,7 +697,7 @@ def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_runner.patch_structhash() + runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -727,7 +727,7 @@ def test_patch_file_downloader_succeeds_first_try() -> None: ), }, ): - platformio_runner.patch_file_downloader() + runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -766,7 +766,7 @@ def test_patch_file_downloader_retries_on_failure() -> None: ), patch("time.sleep") as mock_sleep, ): - platformio_runner.patch_file_downloader() + runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -807,7 +807,7 @@ def test_patch_file_downloader_raises_after_max_retries() -> None: ), patch("time.sleep") as mock_sleep, ): - platformio_runner.patch_file_downloader() + runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -855,7 +855,7 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() -> ), patch("time.sleep"), ): - platformio_runner.patch_file_downloader() + runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -890,9 +890,9 @@ def test_patch_file_downloader_idempotent() -> None: }, ): # Patch multiple times - platformio_runner.patch_file_downloader() - platformio_runner.patch_file_downloader() - platformio_runner.patch_file_downloader() + runner.patch_file_downloader() + runner.patch_file_downloader() + runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -910,9 +910,7 @@ def _filter_through_redirect(line: str) -> str: from esphome.util import RedirectText captured = io.StringIO() - redirect = RedirectText( - captured, filter_lines=platformio_runner.FILTER_PLATFORMIO_LINES - ) + redirect = RedirectText(captured, filter_lines=runner.FILTER_PLATFORMIO_LINES) redirect.write(line + "\n") return captured.getvalue() From b967adeb9d6b1ed30cbb296ad41145d55db0335f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 May 2026 08:11:36 -0500 Subject: [PATCH 1143/2214] [wifi] Accept lowercase variant in variant_has_wifi (#16345) --- esphome/components/wifi/__init__.py | 8 +++++- tests/unit_tests/components/test_wifi.py | 33 ++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 316d432140..bad57fc481 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -76,11 +76,17 @@ def variant_has_wifi(variant: str) -> bool: Variants without a native PHY (ESP32-H2, ESP32-P4) need the ``esp32_hosted`` co-processor to use ``wifi:``. + Case-insensitive on *variant* so external callers can pass either + the upstream uppercase form (e.g. ``"ESP32H2"`` from + ``const.VARIANT_ESP32H2``) or a lowercase form their own enum + surfaces (e.g. ``"esp32h2"`` from device-builder's + ``Esp32Variant``). Both classify identically. + Used by device-builder (esphome/device-builder) to decide whether its basic-setup wizard emits a ``wifi:`` block — please keep the signature stable. """ - return variant not in NO_WIFI_VARIANTS + return variant.upper() not in NO_WIFI_VARIANTS _WIFI_FIRST_PLATFORMS: frozenset[str] = frozenset( diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index e93ae4b503..71a14d7817 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -10,27 +10,44 @@ from esphome.const import Platform @pytest.mark.parametrize( "variant", [ + # Upstream's canonical uppercase form. const.VARIANT_ESP32, const.VARIANT_ESP32S2, const.VARIANT_ESP32S3, const.VARIANT_ESP32C3, const.VARIANT_ESP32C6, + # Lowercase form external callers (e.g. device-builder's + # ``Esp32Variant`` StrEnum) surface. + "esp32", + "esp32s3", + "esp32c3", + # Mixed-case — defence in depth against future callers that + # pull the value off some other serialisation. + "Esp32", ], ) def test_variant_has_wifi_for_native_phy_variants(variant: str) -> None: - """Variants with a native WiFi PHY → True.""" + """Variants with a native WiFi PHY → True, case-insensitive.""" assert variant_has_wifi(variant) is True @pytest.mark.parametrize( "variant", [ + # Upstream's canonical uppercase form. const.VARIANT_ESP32H2, const.VARIANT_ESP32P4, + # Lowercase form external callers (e.g. device-builder's + # ``Esp32Variant`` StrEnum) surface. + "esp32h2", + "esp32p4", + # Mixed-case — defence in depth against future callers that + # pull the value off some other serialisation. + "Esp32H2", ], ) def test_variant_has_wifi_for_no_phy_variants(variant: str) -> None: - """Variants that need ``esp32_hosted`` → False.""" + """Variants that need ``esp32_hosted`` → False, case-insensitive.""" assert variant_has_wifi(variant) is False @@ -44,6 +61,18 @@ def test_has_native_wifi_dispatches_esp32_to_variant_check() -> None: ) +def test_has_native_wifi_esp32_variant_case_insensitive() -> None: + """has_native_wifi accepts lowercase variant input. + + External callers (device-builder's wizard, etc.) may surface + variant strings from their own enums that don't match upstream's + uppercase convention. The dispatcher should classify them + identically. + """ + assert has_native_wifi(platform=Platform.ESP32, variant="esp32h2") is False + assert has_native_wifi(platform=Platform.ESP32, variant="esp32c3") is True + + def test_has_native_wifi_dispatches_rp2040_to_board_check() -> None: """RP2040 platform routes through ``rp2040.board_id_has_wifi``.""" assert has_native_wifi(platform=Platform.RP2040, board="rpipicow") is True From 4d9d6e02e5c53dcd4db3ce84be0300a8b6fb453f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 May 2026 08:13:16 -0500 Subject: [PATCH 1144/2214] [core] Add stable esphome.upload_targets module for port classification (#16346) --- esphome/__main__.py | 30 +-------- esphome/components/nrf52/__init__.py | 5 +- esphome/upload_targets.py | 66 ++++++++++++++++++++ tests/unit_tests/test_upload_targets.py | 81 +++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 esphome/upload_targets.py create mode 100644 tests/unit_tests/test_upload_targets.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 54d6384bfc..ce24f44b3a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -64,6 +64,7 @@ from esphome.enum import StrEnum from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log from esphome.types import ConfigType +from esphome.upload_targets import PortType, get_port_type from esphome.util import ( PICOTOOL_PACKAGE, FlashImage, @@ -194,14 +195,6 @@ class Purpose(StrEnum): LOGGING = "logging" -class PortType(StrEnum): - SERIAL = "SERIAL" - NETWORK = "NETWORK" - MQTT = "MQTT" - MQTTIP = "MQTTIP" - BOOTSEL = "BOOTSEL" - - # Magic MQTT port types that require special handling _MQTT_PORT_TYPES = frozenset({PortType.MQTT, PortType.MQTTIP}) @@ -597,27 +590,6 @@ def _resolve_network_devices( return network_devices -def get_port_type(port: str) -> PortType: - """Determine the type of port/device identifier. - - Returns: - PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.) - PortType.BOOTSEL for RP2040 BOOTSEL upload via picotool - PortType.MQTT for MQTT logging - PortType.MQTTIP for MQTT IP lookup - PortType.NETWORK for IP addresses, hostnames, or mDNS names - """ - if port == "BOOTSEL": - return PortType.BOOTSEL - if port.startswith("/") or port.startswith("COM"): - return PortType.SERIAL - if port == "MQTT": - return PortType.MQTT - if port == "MQTTIP": - return PortType.MQTTIP - return PortType.NETWORK - - def run_miniterm(config: ConfigType, port: str, args) -> int: from aioesphomeapi import LogParser import serial diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 38efccab11..37854d4ab7 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -405,11 +405,12 @@ def _upload_using_platformio( def upload_program(config: ConfigType, args, host: str) -> bool: - from esphome.__main__ import check_permissions, get_port_type + from esphome.__main__ import check_permissions + from esphome.upload_targets import PortType, get_port_type mcumgr_device: str | None = None - if get_port_type(host) == "SERIAL": + if get_port_type(host) == PortType.SERIAL: check_permissions(host) if zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: mcumgr_device = host diff --git a/esphome/upload_targets.py b/esphome/upload_targets.py new file mode 100644 index 0000000000..302ecf7301 --- /dev/null +++ b/esphome/upload_targets.py @@ -0,0 +1,66 @@ +"""Stable classification of ``--device`` / port strings. + +External tooling (the device-builder dashboard at +esphome/device-builder, and other consumers) needs to decide whether +a user-supplied port string names a local serial device, an OTA +network target, an MQTT magic string, or an RP2040 BOOTSEL upload. + +This module is the single stable home for that classification. The +upstream CLI (``esphome.__main__``) re-exports ``PortType`` and +``get_port_type`` from here for its own use; external callers should +import directly from ``esphome.upload_targets`` so the surface stays +stable across releases (``esphome/__main__`` is a CLI entrypoint and +not a stable import path). + +Please keep ``PortType`` member names / values and the +``get_port_type`` signature stable — see the docstrings on each for +the contract. +""" + +from __future__ import annotations + +from esphome.enum import StrEnum + + +class PortType(StrEnum): + """Port classification returned by :func:`get_port_type`. + + Used by device-builder (esphome/device-builder) and other + external tooling to route a user-supplied ``--device`` value to + the right upload / log path. Member names and string values are + part of the stable surface — adding new members is fine, but + existing names / values must not be renamed or changed. + """ + + SERIAL = "SERIAL" + NETWORK = "NETWORK" + MQTT = "MQTT" + MQTTIP = "MQTTIP" + BOOTSEL = "BOOTSEL" + + +def get_port_type(port: str) -> PortType: + """Determine the type of port/device identifier. + + Used by device-builder (esphome/device-builder)'s dashboard to + decide whether a user-supplied ``--device`` value names a local + serial port (must build / flash locally), an OTA network target + (eligible for remote builds), an MQTT magic string, or an RP2040 + BOOTSEL upload. Please keep the signature stable. + + Returns: + PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.) + PortType.BOOTSEL for RP2040 BOOTSEL upload via picotool + PortType.MQTT for MQTT logging + PortType.MQTTIP for MQTT IP lookup + PortType.NETWORK for IP addresses, hostnames, or mDNS names + """ + if port == "BOOTSEL": + return PortType.BOOTSEL + if port.startswith("/") or port.startswith("COM"): + return PortType.SERIAL + if port == "MQTT": + return PortType.MQTT + if port == "MQTTIP": + return PortType.MQTTIP + return PortType.NETWORK diff --git a/tests/unit_tests/test_upload_targets.py b/tests/unit_tests/test_upload_targets.py new file mode 100644 index 0000000000..52587ca4e6 --- /dev/null +++ b/tests/unit_tests/test_upload_targets.py @@ -0,0 +1,81 @@ +"""Tests for the stable upload-targets classification helpers.""" + +import pytest + +from esphome.upload_targets import PortType, get_port_type + + +@pytest.mark.parametrize( + "port", + [ + "/dev/ttyUSB0", + "/dev/ttyACM0", + "/dev/cu.usbserial-1410", + "/dev/tty.usbmodem1101", + "COM1", + "COM23", + ], +) +def test_get_port_type_serial(port: str) -> None: + """Local serial devices classify as SERIAL.""" + assert get_port_type(port) is PortType.SERIAL + + +def test_get_port_type_bootsel() -> None: + """``BOOTSEL`` magic string classifies as BOOTSEL.""" + assert get_port_type("BOOTSEL") is PortType.BOOTSEL + + +def test_get_port_type_mqtt() -> None: + """``MQTT`` magic string classifies as MQTT.""" + assert get_port_type("MQTT") is PortType.MQTT + + +def test_get_port_type_mqttip() -> None: + """``MQTTIP`` magic string classifies as MQTTIP.""" + assert get_port_type("MQTTIP") is PortType.MQTTIP + + +@pytest.mark.parametrize( + "port", + [ + "192.168.1.10", + "fe80::1", + "device.local", + "my-esp.example.com", + ], +) +def test_get_port_type_network(port: str) -> None: + """IP addresses, mDNS, and hostnames classify as NETWORK.""" + assert get_port_type(port) is PortType.NETWORK + + +def test_port_type_values_are_stable() -> None: + """Member values are part of the stable surface. + + External tooling (device-builder, etc.) may compare against the + string values directly. Renaming or changing these breaks + downstream consumers — guard against accidental edits. + """ + assert PortType.SERIAL.value == "SERIAL" + assert PortType.NETWORK.value == "NETWORK" + assert PortType.MQTT.value == "MQTT" + assert PortType.MQTTIP.value == "MQTTIP" + assert PortType.BOOTSEL.value == "BOOTSEL" + + +def test_main_re_exports_for_backwards_compat() -> None: + """``esphome.__main__`` re-exports the stable surface. + + The CLI entry point pre-dated the stable module and existing + internal callers (and any third-party code that snuck in via + ``__main__``) still import from there. The re-export must + resolve to the same objects. + """ + from esphome.__main__ import ( + PortType as MainPortType, + get_port_type as main_get_port_type, + ) + + assert MainPortType is PortType + assert main_get_port_type is get_port_type From 105842366ebf95b982c7de6681691d5c4c662a41 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 11 May 2026 09:57:01 -0400 Subject: [PATCH 1145/2214] [openthread] Remove-freertos-portmacro-header-include (#16338) --- esphome/components/openthread/openthread.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 21dad4f867..8557427096 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -2,8 +2,6 @@ #ifdef USE_OPENTHREAD #include "openthread.h" -#include - #include #include #include From 68534ea24d5dae6b4d82e78c5e2cfa909afb89cb Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 11 May 2026 16:04:48 +0200 Subject: [PATCH 1146/2214] [logger] fix crash on zephyr (#16330) --- esphome/components/logger/logger.cpp | 3 +++ esphome/components/logger/logger_zephyr.cpp | 4 +--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 23b69c36c6..a035525101 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -243,6 +243,9 @@ void Logger::dump_config() { #endif #ifdef USE_ZEPHYR dump_crash_(); + if (!device_is_ready(this->uart_dev_)) { + ESP_LOGE(TAG, " %s is not ready.", LOG_STR_ARG(get_uart_selection_())); + } #endif // Warn users that VERBOSE/VERY_VERBOSE logging impacts performance. // Only the compiled log level matters — all log calls up to this level diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index e9caa8d9d9..240bcc57c7 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -74,9 +74,7 @@ void Logger::pre_setup() { break; #endif } - if (!device_is_ready(uart_dev)) { - ESP_LOGE(TAG, "%s is not ready.", LOG_STR_ARG(get_uart_selection_())); - } else { + if (device_is_ready(uart_dev)) { this->uart_dev_ = uart_dev; #if defined(USE_LOGGER_WAIT_FOR_CDC) && defined(USE_LOGGER_UART_SELECTION_USB_CDC) uint32_t dtr = 0; From 2edb7ca5c224f8f67b3189f9573a6542c2d4328d Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 11 May 2026 16:05:41 +0200 Subject: [PATCH 1147/2214] [nrf52] add message that west update is running (#16321) --- .clang-tidy.hash | 2 +- esphome/components/nrf52/__init__.py | 2 +- platformio.ini | 2 +- tests/components/nrf52/test.nrf52-adafruit.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 4c4b4e5c9c..da2e863281 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -edce6cd78b33b296cef3caa5869d237061345eb346e3f9cb21e3239d2711051f +96c95feaa60831da5f43e3c6a7c7a3a237e17c5d12995a730dbc3884c8dcd11c diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 37854d4ab7..a9be7e56e7 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -182,7 +182,7 @@ CONFIG_SCHEMA = cv.All( default={}, ): cv.Schema( { - cv.Optional(CONF_VERSION, default="2.6.1-a"): cv.string_strict, + cv.Optional(CONF_VERSION, default="2.6.1-b"): cv.string_strict, cv.Optional(CONF_ADVANCED, default={}): cv.Schema( { cv.Optional( diff --git a/platformio.ini b/platformio.ini index 3023a15732..42b7400779 100644 --- a/platformio.ini +++ b/platformio.ini @@ -233,7 +233,7 @@ extends = common platform = https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-5.zip framework = zephyr platform_packages = - platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-a.zip + platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-b.zip build_flags = ${common.build_flags} -DUSE_ZEPHYR diff --git a/tests/components/nrf52/test.nrf52-adafruit.yaml b/tests/components/nrf52/test.nrf52-adafruit.yaml index 300cb7b5d7..3ae48b2a5f 100644 --- a/tests/components/nrf52/test.nrf52-adafruit.yaml +++ b/tests/components/nrf52/test.nrf52-adafruit.yaml @@ -20,4 +20,4 @@ nrf52: voltage: 2.1V uicr_erase: true framework: - version: "2.6.1-a" + version: "2.6.1-b" From a7299cb95b2f8d1b4c43492554d6f4958fd1f8f3 Mon Sep 17 00:00:00 2001 From: "@RubenKelevra" Date: Mon, 11 May 2026 16:09:15 +0200 Subject: [PATCH 1148/2214] [esp32_camera] Downgrade esp32-camera to 2.1.5 (#16293) --- esphome/components/camera_encoder/__init__.py | 2 +- esphome/components/esp32_camera/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index 3bbeae7835..a0c59a517a 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -50,7 +50,7 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - add_idf_component(name="espressif/esp32-camera", ref="2.1.6") + add_idf_component(name="espressif/esp32-camera", ref="2.1.5") cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 5165956806..9883a0a43e 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -399,7 +399,7 @@ async def to_code(config): if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG": cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION") - add_idf_component(name="espressif/esp32-camera", ref="2.1.6") + add_idf_component(name="espressif/esp32-camera", ref="2.1.5") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 8ffcffa705..45aaa827c8 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,7 +20,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.1.6 + version: 2.1.5 espressif/mdns: version: 1.11.0 espressif/esp_wifi_remote: From 30e2f7e8e9da26f68ff4fa24980344407c9ee81d Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 11 May 2026 09:13:43 -0500 Subject: [PATCH 1149/2214] [thermostat] Fix supplemental action never firing via max run time (#16308) --- .../thermostat/thermostat_climate.cpp | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d8478d2648..2390a96337 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -506,8 +506,10 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu case climate::CLIMATE_ACTION_IDLE: if (this->idle_action_ready_()) { this->start_timer_(thermostat::THERMOSTAT_TIMER_IDLE_ON); - if (this->action == climate::CLIMATE_ACTION_COOLING) + if (this->action == climate::CLIMATE_ACTION_COOLING) { this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_OFF); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); + } if (this->action == climate::CLIMATE_ACTION_FAN) { if (this->supports_fan_only_action_uses_fan_mode_timer_) { this->start_timer_(thermostat::THERMOSTAT_TIMER_FAN_MODE); @@ -515,8 +517,10 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_OFF); } } - if (this->action == climate::CLIMATE_ACTION_HEATING) + if (this->action == climate::CLIMATE_ACTION_HEATING) { this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_OFF); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); + } // trig = this->idle_action_trigger_; ESP_LOGVV(TAG, "Switching to IDLE/OFF action"); this->cooling_max_runtime_exceeded_ = false; @@ -599,16 +603,6 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu } void ThermostatClimate::switch_to_supplemental_action_(climate::ClimateAction action) { - // Always cancel max-runtime timers and clear exceeded flags when transitioning to idle/off, - // even if supplemental_action_ is already idle (early-return path). This prevents a stale - // heating_max_runtime_exceeded_ flag from triggering supplemental on the next heating cycle - // when HEATING_MAX_RUN_TIME fires while the main action is already IDLE. - if (action == climate::CLIMATE_ACTION_OFF || action == climate::CLIMATE_ACTION_IDLE) { - this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); - this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); - this->cooling_max_runtime_exceeded_ = false; - this->heating_max_runtime_exceeded_ = false; - } // setup_complete_ helps us ensure an action is called immediately after boot if ((action == this->supplemental_action_) && this->setup_complete_) { // already in target mode From 8cf0eba0437e5eb378fa902d865228fb0d8eef65 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 11 May 2026 16:47:06 +0200 Subject: [PATCH 1150/2214] [nrf52][zephyr] prepare for native builds (#16193) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 117 ++++++++++++++---- esphome/components/nrf52/boards.py | 8 ++ .../{zephyr => nrf52}/pre_build.py.script | 0 esphome/components/zephyr/__init__.py | 96 +++++--------- 4 files changed, 131 insertions(+), 90 deletions(-) rename esphome/components/{zephyr => nrf52}/pre_build.py.script (100%) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index a9be7e56e7..2aba208af7 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -9,6 +9,7 @@ import subprocess from esphome import pins import esphome.codegen as cg from esphome.components.zephyr import ( + add_extra_script, copy_files as zephyr_copy_files, zephyr_add_overlay, zephyr_add_pm_static, @@ -21,6 +22,7 @@ from esphome.components.zephyr import ( from esphome.components.zephyr.const import ( BOOTLOADER_MCUBOOT, CONF_CDC_ACM, + KEY_BOARD, KEY_BOOTLOADER, KEY_ZEPHYR, CdcAcm, @@ -36,6 +38,7 @@ from esphome.const import ( CONF_OTA, CONF_RESET_PIN, CONF_SAFE_MODE, + CONF_TOOLCHAIN, CONF_VERSION, CONF_VOLTAGE, KEY_CORE, @@ -44,10 +47,12 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PLATFORM_NRF52, ThreadModel, + Toolchain, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.core.config import BOARD_MAX_LENGTH import esphome.final_validate as fv +from esphome.helpers import write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -67,8 +72,35 @@ AUTO_LOAD = ["zephyr", "preferences"] IS_TARGET_PLATFORM = True _LOGGER = logging.getLogger(__name__) +FAKE_BOARD_MANIFEST = """ +{ + "frameworks": [ + "zephyr" + ], + "name": "esphome nrf52", + "upload": { + "maximum_ram_size": 248832, + "maximum_size": 815104, + "speed": 115200 + }, + "url": "https://esphome.io/", + "vendor": "esphome", + "build": { + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_fwid": "0x00B6" + } + } +} +""" + def set_core_data(config: ConfigType) -> ConfigType: + # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. + if CORE.toolchain is None: + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) zephyr_set_core_data(config) CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR @@ -80,10 +112,18 @@ def set_core_data(config: ConfigType) -> ConfigType: def set_framework(config: ConfigType) -> ConfigType: + if CONF_VERSION not in config[CONF_FRAMEWORK]: + default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" + config = { + **config, + CONF_FRAMEWORK: {**config[CONF_FRAMEWORK], CONF_VERSION: default_version}, + } framework_ver = cv.Version.parse( cv.version_number(config[CONF_FRAMEWORK][CONF_VERSION]) ) CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver + if not CORE.using_toolchain_platformio: + return config if framework_ver < cv.Version(2, 9, 2): return cv.require_framework_version( nrf52_zephyr=cv.Version(2, 6, 1, "a"), @@ -182,7 +222,7 @@ CONFIG_SCHEMA = cv.All( default={}, ): cv.Schema( { - cv.Optional(CONF_VERSION, default="2.6.1-b"): cv.string_strict, + cv.Optional(CONF_VERSION): cv.string_strict, cv.Optional(CONF_ADVANCED, default={}): cv.Schema( { cv.Optional( @@ -238,40 +278,51 @@ FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config: ConfigType) -> None: """Convert the configuration to code.""" - cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_build_flag("-DUSE_NRF52") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_VARIANT", "NRF52") # nRF52 processors are single-core cg.add_define(ThreadModel.SINGLE) - cg.add_platformio_option(CONF_FRAMEWORK, CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK]) - cg.add_platformio_option( - "platform", - "https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-5.zip", - ) - cg.add_platformio_option( - "platform_packages", - [ - f"platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v{CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}.zip", - ], - ) + if CORE.using_toolchain_platformio: + cg.add_platformio_option("board", config[CONF_BOARD]) + cg.add_platformio_option( + CONF_FRAMEWORK, CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] + ) + cg.add_platformio_option( + "platform", + "https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-5.zip", + ) + cg.add_platformio_option( + "platform_packages", + [ + f"platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v{CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}.zip", + ], + ) + if config[KEY_BOOTLOADER] != BOOTLOADER_MCUBOOT: + # make sure that firmware.zip is created + # for Adafruit_nRF52_Bootloader + cg.add_platformio_option("board_upload.protocol", "nrfutil") + cg.add_platformio_option("board_upload.use_1200bps_touch", "true") + cg.add_platformio_option("board_upload.require_upload_port", "true") + cg.add_platformio_option("board_upload.wait_for_upload_port", "true") + + add_extra_script( + "pre", + "pre_build.py", + Path(__file__).parent / "pre_build.py.script", + ) + # build is done by west so bypass board checking in platformio + cg.add_platformio_option("boards_dir", CORE.relative_build_path("boards")) if config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: cg.add_define("USE_BOOTLOADER_MCUBOOT") - else: - if "_sd" in config[KEY_BOOTLOADER]: - bootloader = config[KEY_BOOTLOADER].split("_") - sd_id = bootloader[2][2:] - cg.add_define("USE_SOFTDEVICE_ID", int(sd_id)) - if (len(bootloader)) > 3: - sd_version = bootloader[3][1:] - cg.add_define("USE_SOFTDEVICE_VERSION", int(sd_version)) - # make sure that firmware.zip is created - # for Adafruit_nRF52_Bootloader - cg.add_platformio_option("board_upload.protocol", "nrfutil") - cg.add_platformio_option("board_upload.use_1200bps_touch", "true") - cg.add_platformio_option("board_upload.require_upload_port", "true") - cg.add_platformio_option("board_upload.wait_for_upload_port", "true") + elif "_sd" in config[KEY_BOOTLOADER]: + bootloader = config[KEY_BOOTLOADER].split("_") + sd_id = bootloader[2][2:] + cg.add_define("USE_SOFTDEVICE_ID", int(sd_id)) + if (len(bootloader)) > 3: + sd_version = bootloader[3][1:] + cg.add_define("USE_SOFTDEVICE_VERSION", int(sd_version)) zephyr_setup_preferences() zephyr_to_code(config) @@ -341,6 +392,16 @@ async def _dfu_to_code(dfu_config): def copy_files() -> None: """Copy files to the build directory.""" + + if CORE.using_toolchain_platformio and ( + zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT + or zephyr_data()[KEY_BOARD] == "xiao_ble" + ): + write_file_if_changed( + CORE.relative_build_path(f"boards/{zephyr_data()[KEY_BOARD]}.json"), + FAKE_BOARD_MANIFEST, + ) + zephyr_copy_files() @@ -415,6 +476,8 @@ def upload_program(config: ConfigType, args, host: str) -> bool: if zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: mcumgr_device = host else: + if not CORE.using_toolchain_platformio: + raise EsphomeError("Not implemented yet") result = _upload_using_platformio(config, host, ["-t", "upload"]) if result != 0: raise EsphomeError(f"Upload failed with result: {result}") diff --git a/esphome/components/nrf52/boards.py b/esphome/components/nrf52/boards.py index 4c33cd9939..564bf560d6 100644 --- a/esphome/components/nrf52/boards.py +++ b/esphome/components/nrf52/boards.py @@ -25,6 +25,14 @@ BOARDS_ZEPHYR = { BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, ] }, + "adafruit_itsybitsy": { + KEY_BOOTLOADER: [ + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ] + }, } # https://github.com/ffenix113/zigbee_home/blob/17bb7b9e9d375e756da9e38913f53303937fb66a/types/board/known_boards.go diff --git a/esphome/components/zephyr/pre_build.py.script b/esphome/components/nrf52/pre_build.py.script similarity index 100% rename from esphome/components/zephyr/pre_build.py.script rename to esphome/components/nrf52/pre_build.py.script diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 5dccecc097..57f5778d54 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -10,9 +10,7 @@ from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.types import ConfigType from .const import ( - BOOTLOADER_MCUBOOT, CONF_CDC_ACM, - KEY_BOARD, KEY_BOOTLOADER, KEY_EXTRA_BUILD_FILES, KEY_KCONFIG, @@ -50,8 +48,8 @@ class Section: class ZephyrData(TypedDict): board: str bootloader: str - prj_conf: dict[str, tuple[PrjConfValueType, bool]] - overlay: str + prj_conf: dict[str, dict[str, tuple[PrjConfValueType, bool]]] + overlay: dict[str, str] extra_build_files: dict[str, Path] pm_static: list[Section] user: dict[str, list[str]] @@ -63,7 +61,9 @@ def zephyr_set_core_data(config: ConfigType) -> None: board=config[CONF_BOARD], bootloader=config[KEY_BOOTLOADER], prj_conf={}, - overlay="", + overlay={ + "": "", + }, # set empty to make sure that overlay is cleared after config change extra_build_files={}, pm_static=[], user={}, @@ -76,12 +76,14 @@ def zephyr_data() -> ZephyrData: def zephyr_add_prj_conf( - name: str, value: PrjConfValueType, required: bool = True + name: str, value: PrjConfValueType, required: bool = True, image: str = "" ) -> None: """Set an zephyr prj conf value.""" if not name.startswith("CONFIG_"): name = "CONFIG_" + name - prj_conf = zephyr_data()[KEY_PRJ_CONF] + if image not in zephyr_data()[KEY_PRJ_CONF]: + zephyr_data()[KEY_PRJ_CONF][image] = {} + prj_conf = zephyr_data()[KEY_PRJ_CONF][image] if name not in prj_conf: prj_conf[name] = (value, required) return @@ -94,8 +96,11 @@ def zephyr_add_prj_conf( prj_conf[name] = (value, required) -def zephyr_add_overlay(content): - zephyr_data()[KEY_OVERLAY] += textwrap.dedent(content) +def zephyr_add_overlay(content: str, image: str = "") -> None: + data = zephyr_data() + if image not in data[KEY_OVERLAY]: + data[KEY_OVERLAY][image] = "" + data[KEY_OVERLAY][image] += textwrap.dedent(content) def add_extra_build_file(filename: str, path: Path) -> bool: @@ -118,8 +123,6 @@ def zephyr_to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_ZEPHYR") cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") - # build is done by west so bypass board checking in platformio - cg.add_platformio_option("boards_dir", CORE.relative_build_path("boards")) # c++ support zephyr_add_prj_conf("NEWLIB_LIBC", True) zephyr_add_prj_conf("FPU", True) @@ -132,18 +135,12 @@ def zephyr_to_code(config: ConfigType) -> None: # os: Illegal load of EXC_RETURN into PC zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048) - add_extra_script( - "pre", - "pre_build.py", - Path(__file__).parent / "pre_build.py.script", - ) - CORE.add_job(_cdc_acm_to_code, config) @coroutine_with_priority(CoroPriority.FINAL) async def _cdc_acm_to_code(config: ConfigType) -> None: - if "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT" in zephyr_data()[KEY_PRJ_CONF]: + if "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT" in zephyr_data()[KEY_PRJ_CONF][""]: var = cg.new_Pvariable(config[CONF_CDC_ACM]) await cg.register_component(var, {}) @@ -219,55 +216,28 @@ def copy_files(): """ ) - want_opts = zephyr_data()[KEY_PRJ_CONF] - - prj_conf = ( - "\n".join( - f"{name}={_format_prj_conf_val(value[0])}" - for name, value in sorted(want_opts.items()) + for image, want_opts in zephyr_data()[KEY_PRJ_CONF].items(): + prj_conf = ( + "\n".join( + f"{name}={_format_prj_conf_val(value[0])}" + for name, value in sorted(want_opts.items()) + ) + + "\n" ) - + "\n" - ) - write_file_if_changed(CORE.relative_build_path("zephyr/prj.conf"), prj_conf) + if image: + path = CORE.relative_build_path(f"sysbuild/{image}.conf") + else: + path = CORE.relative_build_path("zephyr/prj.conf") - write_file_if_changed( - CORE.relative_build_path("zephyr/app.overlay"), - zephyr_data()[KEY_OVERLAY], - ) + write_file_if_changed(CORE.relative_build_path(path), prj_conf) - if ( - zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT - or zephyr_data()[KEY_BOARD] == "xiao_ble" - ): - fake_board_manifest = """ -{ - "frameworks": [ - "zephyr" - ], - "name": "esphome nrf52", - "upload": { - "maximum_ram_size": 248832, - "maximum_size": 815104, - "speed": 115200 - }, - "url": "https://esphome.io/", - "vendor": "esphome", - "build": { - "bsp": { - "name": "adafruit" - }, - "softdevice": { - "sd_fwid": "0x00B6" - } - } -} -""" - - write_file_if_changed( - CORE.relative_build_path(f"boards/{zephyr_data()[KEY_BOARD]}.json"), - fake_board_manifest, - ) + for image, content in zephyr_data()[KEY_OVERLAY].items(): + if image: + path = CORE.relative_build_path(f"sysbuild/{image}.overlay") + else: + path = CORE.relative_build_path("zephyr/app.overlay") + write_file_if_changed(path, content) for filename, path in zephyr_data()[KEY_EXTRA_BUILD_FILES].items(): copy_file_if_changed( From 4ac7bc46064250ca119d335d37cbc931806c931c Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Mon, 11 May 2026 16:51:47 +0200 Subject: [PATCH 1151/2214] [zigbee] Add sensor support on esp32 (#16026) --- esphome/components/zigbee/__init__.py | 11 +- esphome/components/zigbee/const.py | 113 ++++++++++++++++++ esphome/components/zigbee/const_esp32.py | 4 + .../zigbee/zigbee_attribute_esp32.h | 11 ++ esphome/components/zigbee/zigbee_ep_esp32.py | 29 +++++ esphome/components/zigbee/zigbee_esp32.py | 69 +++++++++-- .../components/zigbee/zigbee_helpers_esp32.c | 7 ++ esphome/components/zigbee/zigbee_zephyr.py | 66 +--------- esphome/const.py | 1 + tests/components/zigbee/common_esp32.yaml | 6 +- 10 files changed, 238 insertions(+), 79 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 8605b4fa1a..a7e9d1096f 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -36,6 +36,7 @@ from .const_zephyr import ( from .zigbee_esp32 import ( final_validate_esp32, validate_binary_sensor_esp32, + validate_sensor_esp32, zigbee_require_vfs_select, ) from .zigbee_zephyr import ( @@ -49,8 +50,7 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@luar123", "@tomaszduda23"] - -BINARY_SENSOR_SCHEMA = cv.Schema( +BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( cv.requires_component("zigbee"), @@ -58,8 +58,9 @@ BINARY_SENSOR_SCHEMA = cv.Schema( cv.enum(REPORT, lower=True), ) } -).extend(zephyr_binary_sensor) -SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor) +) +BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_binary_sensor) +SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_sensor) SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) @@ -227,7 +228,7 @@ def validate_sensor(config: ConfigType) -> ConfigType: if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: - return config + return validate_sensor_esp32(config) return consume_endpoint(config) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 26ae2cc0ec..7d0e14c67a 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -1,4 +1,47 @@ +from enum import IntEnum + import esphome.codegen as cg +from esphome.const import ( + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLUME_FLOW_RATE, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_CENTIMETER, + UNIT_DECIBEL, + UNIT_HECTOPASCAL, + UNIT_HERTZ, + UNIT_HOUR, + UNIT_KELVIN, + UNIT_KILOMETER, + UNIT_KILOWATT, + UNIT_KILOWATT_HOURS, + UNIT_LITRE_PER_SECOND, + UNIT_LUX, + UNIT_METER, + UNIT_MICROGRAMS_PER_CUBIC_METER, + UNIT_MILLIAMP, + UNIT_MILLIGRAMS_PER_CUBIC_METER, + UNIT_MILLIMETER, + UNIT_MILLISECOND, + UNIT_MILLIVOLT, + UNIT_MINUTE, + UNIT_OHM, + UNIT_PARTS_PER_BILLION, + UNIT_PARTS_PER_MILLION, + UNIT_PASCAL, + UNIT_PERCENT, + UNIT_SECOND, + UNIT_VOLT, + UNIT_WATT, + UNIT_WATT_HOURS, +) zigbee_ns = cg.esphome_ns.namespace("zigbee") ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component) @@ -30,3 +73,73 @@ POWER_SOURCE = { } KEY_ZIGBEE = "zigbee" + +# BACnet engineering units mapping (ZCL uses BACnet unit codes) +# See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py +BACNET_UNITS = { + UNIT_CELSIUS: 62, + UNIT_KELVIN: 63, + UNIT_VOLT: 5, + UNIT_MILLIVOLT: 124, + UNIT_AMPERE: 3, + UNIT_MILLIAMP: 2, + UNIT_OHM: 4, + UNIT_WATT: 47, + UNIT_KILOWATT: 48, + UNIT_WATT_HOURS: 18, + UNIT_KILOWATT_HOURS: 19, + UNIT_PASCAL: 53, + UNIT_HECTOPASCAL: 133, + UNIT_HERTZ: 27, + UNIT_MILLIMETER: 30, + UNIT_CENTIMETER: 118, + UNIT_METER: 31, + UNIT_KILOMETER: 193, + UNIT_MILLISECOND: 159, + UNIT_SECOND: 73, + UNIT_MINUTE: 72, + UNIT_HOUR: 71, + UNIT_PARTS_PER_MILLION: 96, + UNIT_PARTS_PER_BILLION: 97, + UNIT_MICROGRAMS_PER_CUBIC_METER: 219, + UNIT_MILLIGRAMS_PER_CUBIC_METER: 218, + UNIT_LUX: 37, + UNIT_DECIBEL: 199, + UNIT_PERCENT: 98, +} +BACNET_UNIT_NO_UNITS = 95 + + +class AnalogInputType(IntEnum): + TEMP_DEGREES_C = 0x00 + RELATIVE_HUMIDITY_PERCENT = 0x01 + PRESSURE_PASCAL = 0x02 + FLOW_LITERS_PER_SEC = 0x03 + PERCENTAGE = 0x04 + PARTS_PER_MILLION = 0x05 + ROTATIONAL_SPEED_RPM = 0x06 + CURRENT_AMPS = 0x07 + FREQUENCY_HZ = 0x08 + POWER_WATTS = 0x09 + POWER_KILO_WATTS = 0x0A + ENERGY_KILO_WATT_HOURS = 0x0B + COUNT = 0x0C + ENTHALPY_KJOULES_PER_KG = 0x0D + TIME_SECONDS = 0x0E + + +ANALOG_INPUT_APPTYPE = { + (DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS): AnalogInputType.TEMP_DEGREES_C, + (DEVICE_CLASS_HUMIDITY, UNIT_PERCENT): AnalogInputType.RELATIVE_HUMIDITY_PERCENT, + (DEVICE_CLASS_PRESSURE, UNIT_PASCAL): AnalogInputType.PRESSURE_PASCAL, + ( + DEVICE_CLASS_VOLUME_FLOW_RATE, + UNIT_LITRE_PER_SECOND, + ): AnalogInputType.FLOW_LITERS_PER_SEC, + (DEVICE_CLASS_CURRENT, UNIT_AMPERE): AnalogInputType.CURRENT_AMPS, + (DEVICE_CLASS_FREQUENCY, UNIT_HERTZ): AnalogInputType.FREQUENCY_HZ, + (DEVICE_CLASS_POWER, UNIT_WATT): AnalogInputType.POWER_WATTS, + (DEVICE_CLASS_POWER, UNIT_KILOWATT): AnalogInputType.POWER_KILO_WATTS, + (DEVICE_CLASS_ENERGY, UNIT_KILOWATT_HOURS): AnalogInputType.ENERGY_KILO_WATT_HOURS, + (DEVICE_CLASS_DURATION, UNIT_SECOND): AnalogInputType.TIME_SECONDS, +} diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index 682638439e..bb507320eb 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -11,6 +11,7 @@ CONF_CLUSTER = "cluster" SCALE = "scale" CONF_ATTRIBUTE_ID = "attribute_id" KEY_BS_EP = "binary_sensor_ep" +KEY_SENSOR_EP = "sensor_ep" ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e") DEVICE_ID = { @@ -22,6 +23,7 @@ cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t") CLUSTER_ID = { "BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC, "BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, + "ANALOG_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, } cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t") CLUSTER_ROLE = { @@ -32,4 +34,6 @@ ATTR_TYPE = { "BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL, "8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP, "CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING, + "SINGLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_SINGLE, + "DOUBLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_DOUBLE, } diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 5a0cfc4fbd..90a5cf8ff9 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -12,6 +12,9 @@ #include "esp_zigbee_core.h" #include "zigbee_esp32.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif @@ -42,6 +45,9 @@ class ZigbeeAttribute : public Component { template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(bool force); +#ifdef USE_SENSOR + template void connect(sensor::Sensor *sensor); +#endif #ifdef USE_BINARY_SENSOR template void connect(binary_sensor::BinarySensor *sensor); #endif @@ -78,6 +84,11 @@ template void ZigbeeAttribute::set_attr(const T &value) { this->enable_loop(); } +#ifdef USE_SENSOR +template void ZigbeeAttribute::connect(sensor::Sensor *sensor) { + sensor->add_on_state_callback([this](float value) { this->set_attr((T) (this->scale_ * value)); }); +} +#endif #ifdef USE_BINARY_SENSOR template void ZigbeeAttribute::connect(binary_sensor::BinarySensor *sensor) { sensor->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); }); diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 791232d463..5dd76e9903 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -46,6 +46,35 @@ ep_configs: dict[str, dict[str, Any]] = { }, ], }, + "analog_input": { + DEVICE_TYPE: "CUSTOM_ATTR", + CONF_CLUSTERS: [ + { + CONF_ID: "ANALOG_INPUT", + ROLE: CLUSTER_ROLE["SERVER"], + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "SINGLE", + CONF_REPORT: REPORT["enable"], + CONF_DEVICE: None, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "8BITMAP", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "CHAR_STRING", + }, + ], + }, + ], + }, } diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 9081582c7b..99238f758e 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -16,11 +16,13 @@ import esphome.config_validation as cv from esphome.const import ( CONF_AP, CONF_DEVICE, + CONF_DEVICE_CLASS, CONF_ID, CONF_MAX_LENGTH, CONF_MODEL, CONF_NAME, CONF_TYPE, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, CONF_WIFI, ) @@ -29,7 +31,16 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.types import ConfigType -from .const import CONF_REPORT, CONF_ROUTER, KEY_ZIGBEE, REPORT, ZigbeeAttribute +from .const import ( + ANALOG_INPUT_APPTYPE, + BACNET_UNIT_NO_UNITS, + BACNET_UNITS, + CONF_REPORT, + CONF_ROUTER, + KEY_ZIGBEE, + REPORT, + ZigbeeAttribute, +) from .const_esp32 import ( ATTR_TYPE, CLUSTER_ID, @@ -40,6 +51,7 @@ from .const_esp32 import ( DEVICE_ID, DEVICE_TYPE, KEY_BS_EP, + KEY_SENSOR_EP, ROLE, SCALE, ) @@ -55,6 +67,10 @@ def get_c_size(bits: str, options: list[int]) -> str: def get_c_type(attr_type: str) -> Any | None: if attr_type == "BOOL": return cg.bool_ + if attr_type == "SINGLE": + return cg.float_ + if attr_type == "DOUBLE": + return cg.double if "STRING" in attr_type: return cg.std_string test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) @@ -66,19 +82,23 @@ def get_c_type(attr_type: str) -> Any | None: def get_cv_by_type(attr_type: str) -> Any | None: if attr_type == "BOOL": return cv.boolean + if attr_type in ["SINGLE", "DOUBLE"]: + return cv.float_ if "STRING" in attr_type: return cv.string test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) if test and test.group(2): return cv.positive_int - return None + raise cv.Invalid(f"Zigbee: type {attr_type} not supported or implemented") -def get_default_by_type(attr_type: str) -> str | bool | int: +def get_default_by_type(attr_type: str) -> str | bool | int | float: if attr_type == "CHAR_STRING": return "" if attr_type == "BOOL": return False + if attr_type in ["SINGLE", "DOUBLE"]: + return float("nan") return 0 @@ -134,9 +154,8 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: return config -def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: - ep = copy.deepcopy(ep_configs["binary_input"]) - for cl in ep.get(CONF_CLUSTERS, []): +def setup_attributes(config: ConfigType, clusters: list[dict[str, Any]]) -> None: + for cl in clusters: for attr in cl[CONF_ATTRIBUTES]: if ( attr[CONF_ATTRIBUTE_ID] == 0x1C @@ -159,6 +178,41 @@ def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: else: attr[CONF_ID] = None validate_attributes(attr) + + +def validate_sensor_esp32(config: ConfigType) -> ConfigType: + ep = copy.deepcopy(ep_configs["analog_input"]) + # get application type from device class and meas unit + # if none get BACNET unit from meas unit + dev_class = config.get(CONF_DEVICE_CLASS) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + apptype = ANALOG_INPUT_APPTYPE.get((dev_class, unit)) + bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) + if apptype is not None: + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x100, + CONF_VALUE: (apptype << 16) | 0xFFFF, + CONF_TYPE: "U32", + }, + ) + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x75, + CONF_VALUE: bacunit, + CONF_TYPE: "16BIT_ENUM", + }, + ) + setup_attributes(config, ep[CONF_CLUSTERS]) + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + sensor_ep: list[dict] = zb_data.setdefault(KEY_SENSOR_EP, []) + sensor_ep.append(ep) + return config + + +def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: + ep = copy.deepcopy(ep_configs["binary_input"]) + setup_attributes(config, ep[CONF_CLUSTERS]) zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, []) binary_sensor_ep.append(ep) @@ -254,8 +308,9 @@ async def esp32_to_code(config: ConfigType) -> None: # create endpoints zb_data = CORE.data.get(KEY_ZIGBEE, {}) + sensor_ep: list[dict] = zb_data.get(KEY_SENSOR_EP, []) binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, []) - ep_list = create_ep(binary_sensor_ep, config.get(CONF_ROUTER)) + ep_list = create_ep(sensor_ep + binary_sensor_ep, config.get(CONF_ROUTER)) # setup zigbee components var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 4ba71ec609..5254818df4 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -33,6 +33,9 @@ esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask); break; + case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + ret = esp_zb_cluster_list_add_analog_input_cluster(cluster_list, attr_list, role_mask); + break; case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask); break; @@ -49,6 +52,8 @@ esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id return esp_zb_basic_cluster_create(NULL); case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: return esp_zb_identify_cluster_create(NULL); + case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return esp_zb_analog_input_cluster_create(NULL); case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: return esp_zb_binary_input_cluster_create(NULL); default: @@ -63,6 +68,8 @@ esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p); case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p); + case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return esp_zb_analog_input_cluster_add_attr(attr_list, attr_id, value_p); case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p); default: diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index b74074e50f..033511691c 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -10,35 +10,6 @@ from esphome.const import ( CONF_MODEL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, - UNIT_AMPERE, - UNIT_CELSIUS, - UNIT_CENTIMETER, - UNIT_DECIBEL, - UNIT_HECTOPASCAL, - UNIT_HERTZ, - UNIT_HOUR, - UNIT_KELVIN, - UNIT_KILOMETER, - UNIT_KILOWATT, - UNIT_KILOWATT_HOURS, - UNIT_LUX, - UNIT_METER, - UNIT_MICROGRAMS_PER_CUBIC_METER, - UNIT_MILLIAMP, - UNIT_MILLIGRAMS_PER_CUBIC_METER, - UNIT_MILLIMETER, - UNIT_MILLISECOND, - UNIT_MILLIVOLT, - UNIT_MINUTE, - UNIT_OHM, - UNIT_PARTS_PER_BILLION, - UNIT_PARTS_PER_MILLION, - UNIT_PASCAL, - UNIT_PERCENT, - UNIT_SECOND, - UNIT_VOLT, - UNIT_WATT, - UNIT_WATT_HOURS, __version__, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -50,6 +21,8 @@ from esphome.cpp_generator import ( from esphome.types import ConfigType from .const import ( + BACNET_UNIT_NO_UNITS, + BACNET_UNITS, CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_ROUTER, @@ -86,41 +59,6 @@ ZigbeeSensor = zigbee_ns.class_("ZigbeeSensor", cg.Component) ZigbeeSwitch = zigbee_ns.class_("ZigbeeSwitch", cg.Component) ZigbeeNumber = zigbee_ns.class_("ZigbeeNumber", cg.Component) -# BACnet engineering units mapping (ZCL uses BACnet unit codes) -# See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py -BACNET_UNITS = { - UNIT_CELSIUS: 62, - UNIT_KELVIN: 63, - UNIT_VOLT: 5, - UNIT_MILLIVOLT: 124, - UNIT_AMPERE: 3, - UNIT_MILLIAMP: 2, - UNIT_OHM: 4, - UNIT_WATT: 47, - UNIT_KILOWATT: 48, - UNIT_WATT_HOURS: 18, - UNIT_KILOWATT_HOURS: 19, - UNIT_PASCAL: 53, - UNIT_HECTOPASCAL: 133, - UNIT_HERTZ: 27, - UNIT_MILLIMETER: 30, - UNIT_CENTIMETER: 118, - UNIT_METER: 31, - UNIT_KILOMETER: 193, - UNIT_MILLISECOND: 159, - UNIT_SECOND: 73, - UNIT_MINUTE: 72, - UNIT_HOUR: 71, - UNIT_PARTS_PER_MILLION: 96, - UNIT_PARTS_PER_BILLION: 97, - UNIT_MICROGRAMS_PER_CUBIC_METER: 219, - UNIT_MILLIGRAMS_PER_CUBIC_METER: 218, - UNIT_LUX: 37, - UNIT_DECIBEL: 199, - UNIT_PERCENT: 98, -} -BACNET_UNIT_NO_UNITS = 95 - zephyr_binary_sensor = cv.Schema( { cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent), diff --git a/esphome/const.py b/esphome/const.py index c39225fdec..a256a10e62 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1240,6 +1240,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kvarh" UNIT_KILOWATT = "kW" UNIT_KILOWATT_HOURS = "kWh" UNIT_LITRE = "L" +UNIT_LITRE_PER_SECOND = "L/s" UNIT_LUX = "lx" UNIT_MEGAJOULE = "MJ" UNIT_METER = "m" diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 4494b4081d..94e3f3c8c0 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -1,10 +1,10 @@ +packages: + - !include common.yaml + binary_sensor: - platform: template name: "Garage Door Open 10" report: "enable" - - platform: template - name: "Garage Door Open 11" - report: "coordinator" - platform: template name: "Garage Door Open 12" report: "force" From a52ca4f80a29d234183e2a1a8de61bf86075d0e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 May 2026 10:51:08 -0500 Subject: [PATCH 1152/2214] [ota] Implement host platform OTA backend with re-exec for integration testing (#16304) --- esphome/components/esphome/ota/__init__.py | 1 + .../components/esphome/ota/ota_esphome.cpp | 10 +- esphome/components/host/core.cpp | 74 +++- esphome/components/host/core.h | 22 ++ esphome/components/host/hal.cpp | 18 +- esphome/components/ota/__init__.py | 9 +- esphome/components/ota/ota_backend_host.cpp | 329 +++++++++++++++++- esphome/components/ota/ota_backend_host.h | 21 +- .../components/socket/bsd_sockets_impl.cpp | 10 +- esphome/core/__init__.py | 3 + .../fixtures/host_ota_rejects_garbage.yaml | 9 + .../fixtures/host_ota_self_update.yaml | 9 + tests/integration/test_host_ota.py | 152 ++++++++ tests/unit_tests/test_core.py | 24 ++ 14 files changed, 664 insertions(+), 27 deletions(-) create mode 100644 esphome/components/host/core.h create mode 100644 tests/integration/fixtures/host_ota_rejects_garbage.yaml create mode 100644 tests/integration/fixtures/host_ota_self_update.yaml create mode 100644 tests/integration/test_host_ota.py diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index ee3b7f0c20..f7793b1493 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -130,6 +130,7 @@ CONFIG_SCHEMA = cv.All( bk72xx=8892, ln882x=8820, rtl87xx=8892, + host=8082, ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.string, diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 843028fc97..f1857ed664 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -68,7 +68,7 @@ void ESPHomeOTAComponent::setup() { return; } - err = this->server_->bind((struct sockaddr *) &server, sizeof(server)); + err = this->server_->bind((struct sockaddr *) &server, sl); if (err != 0) { this->server_failed_(LOG_STR("bind")); return; @@ -133,12 +133,12 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Self-disabling idle loop. Runs when a wake path marks us pending-enable (fast-select - // listener filter, raw-TCP accept_fn_, or host select), finds no work, and goes back - // to sleep. cleanup_connection_() deliberately leaves the loop enabled for one more - // iteration so a connection queued mid-session is still caught here. + // Self-disable idle loop where a wake path re-enables on listener readiness + // (fast-select, raw-TCP accept_fn_). Host BSD select doesn't, so stay enabled. if (this->client_ == nullptr && !this->server_->ready()) { +#ifndef USE_HOST this->disable_loop(); +#endif return; } this->handle_handshake_(); diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 9123975884..9292cd77f6 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -1,20 +1,92 @@ #ifdef USE_HOST +#include "core.h" + #include "esphome/core/application.h" #include "preferences.h" +#include #include +#include +#include + +#ifdef __APPLE__ +#include +#endif + +#ifdef __linux__ +#include +#endif namespace { volatile sig_atomic_t s_signal_received = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void signal_handler(int signal) { s_signal_received = signal; } + +char **s_argv = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +std::string *s_exe_path = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +std::string *s_reexec_path = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +std::string resolve_exe_path(const char *argv0) { +#ifdef __linux__ + char buf[PATH_MAX]; + ssize_t len = ::readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (len > 0) { + buf[len] = '\0'; + return std::string(buf); + } +#endif +#ifdef __APPLE__ + char buf[PATH_MAX]; + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) == 0) { + char real[PATH_MAX]; + if (::realpath(buf, real) != nullptr) + return std::string(real); + return std::string(buf); + } +#endif + if (argv0 == nullptr) + return {}; + char real[PATH_MAX]; + if (::realpath(argv0, real) != nullptr) + return std::string(real); + return std::string(argv0); +} } // namespace +namespace esphome::host { + +char **get_argv() { return s_argv; } + +const std::string &get_exe_path() { + static const std::string empty; + return s_exe_path != nullptr ? *s_exe_path : empty; +} + +void arm_reexec(const std::string &path) { + if (s_reexec_path != nullptr) + *s_reexec_path = path; +} + +const char *get_reexec_path() { + if (s_reexec_path == nullptr || s_reexec_path->empty()) + return nullptr; + return s_reexec_path->c_str(); +} + +} // namespace esphome::host + // HAL functions live in hal.cpp. void setup(); void loop(); -int main() { +int main(int argc, char **argv) { + s_argv = argv; + static std::string exe_path = resolve_exe_path(argc > 0 ? argv[0] : nullptr); + s_exe_path = &exe_path; + static std::string reexec_path; + s_reexec_path = &reexec_path; + // Install signal handlers for graceful shutdown (flushes preferences to disk) std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); diff --git a/esphome/components/host/core.h b/esphome/components/host/core.h new file mode 100644 index 0000000000..ab64119415 --- /dev/null +++ b/esphome/components/host/core.h @@ -0,0 +1,22 @@ +#pragma once +#ifdef USE_HOST + +#include + +namespace esphome::host { + +/// argv captured by main(); stable for process lifetime. +char **get_argv(); + +/// Absolute path to running exe (resolved at startup); empty on failure. +const std::string &get_exe_path(); + +/// Arm an execv on the next arch_restart(). Pass empty to disarm. +void arm_reexec(const std::string &path); + +/// Armed re-exec path, or nullptr. +const char *get_reexec_path(); + +} // namespace esphome::host + +#endif // USE_HOST diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp index c7fef8d2e8..9108c1ea9d 100644 --- a/esphome/components/host/hal.cpp +++ b/esphome/components/host/hal.cpp @@ -2,10 +2,14 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "core.h" #include +#include #include #include +#include // Empty host namespace block to satisfy ci-custom's lint_namespace check. // HAL functions live in namespace esphome (root) — they are not part of the @@ -50,7 +54,19 @@ void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { res = nanosleep(&ts, &ts); } while (res != 0 && errno == EINTR); } -void arch_restart() { exit(0); } +void arch_restart() { + // Host OTA: if a re-exec is armed, swap binaries instead of exiting. + if (const char *target = host::get_reexec_path()) { + char **argv = host::get_argv(); + if (argv != nullptr) { + execv(target, argv); + // execv only returns on failure. + ESP_LOGE("host", "execv('%s') failed: %s", target, std::strerror(errno)); + exit(1); + } + } + exit(0); +} uint32_t arch_get_cpu_cycle_count() { struct timespec spec; diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 579491fe1a..83d8c611d5 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -1,5 +1,3 @@ -import logging - from esphome import automation import esphome.codegen as cg from esphome.config_helpers import filter_source_files_from_platform @@ -38,8 +36,6 @@ CONF_ON_PROGRESS = "on_progress" CONF_ON_STATE_CHANGE = "on_state_change" -_LOGGER = logging.getLogger(__name__) - ota_ns = cg.esphome_ns.namespace("ota") OTAComponent = ota_ns.class_("OTAComponent", cg.Component) OTAState = ota_ns.enum("OTAState") @@ -58,10 +54,6 @@ def _ota_final_validate(config): raise cv.Invalid( f"At least one platform must be specified for '{CONF_OTA}'; add '{CONF_PLATFORM}: {CONF_ESPHOME}' for original OTA functionality" ) - if CORE.is_host: - _LOGGER.warning( - "OTA not available for platform 'host'. OTA functionality disabled." - ) FINAL_VALIDATE_SCHEMA = _ota_final_validate @@ -172,5 +164,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "ota_backend_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index a2c9f2cc33..ee503a49e1 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -1,26 +1,339 @@ #ifdef USE_HOST #include "ota_backend_host.h" -#include "esphome/core/defines.h" +#include "esphome/components/host/core.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include + +#include +#include +#include + +#ifdef __linux__ +#include +#include +#endif + +#ifdef __APPLE__ +#include +#endif namespace esphome::ota { -// Stub implementation - OTA is not supported on host platform. -// All methods return error codes to allow compilation of configs with OTA triggers. +namespace { + +const char *const TAG = "ota.host"; + +constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB +constexpr size_t HEADER_PEEK_SIZE = 64; + +ssize_t read_header_(const char *path, uint8_t *buf, size_t len) { + int fd = ::open(path, O_RDONLY); + if (fd < 0) + return -1; + ssize_t got = ::read(fd, buf, len); + ::close(fd); + return got; +} + +#ifdef __linux__ +struct ElfIdent { + bool valid; + uint8_t ei_class; + uint8_t ei_data; + uint16_t e_machine; + uint16_t e_type; +}; + +ElfIdent parse_elf_(const uint8_t *buf, size_t len) { + ElfIdent out{}; + if (len < EI_NIDENT + 4) + return out; + if (buf[EI_MAG0] != ELFMAG0 || buf[EI_MAG1] != ELFMAG1 || buf[EI_MAG2] != ELFMAG2 || buf[EI_MAG3] != ELFMAG3) + return out; + out.ei_class = buf[EI_CLASS]; + out.ei_data = buf[EI_DATA]; + // e_type @ 16, e_machine @ 18, both in EI_DATA endianness. + uint16_t e_type; + uint16_t e_machine; + std::memcpy(&e_type, buf + 16, sizeof(e_type)); + std::memcpy(&e_machine, buf + 18, sizeof(e_machine)); + if (out.ei_data == ELFDATA2LSB) { + out.e_type = le16toh(e_type); + out.e_machine = le16toh(e_machine); + } else if (out.ei_data == ELFDATA2MSB) { + out.e_type = be16toh(e_type); + out.e_machine = be16toh(e_machine); + } else { + return out; + } + out.valid = true; + return out; +} + +bool validate_elf_(const char *staging_path, const std::string &exe_path) { + uint8_t new_buf[HEADER_PEEK_SIZE]; + uint8_t cur_buf[HEADER_PEEK_SIZE]; + ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + if (new_n < static_cast(EI_NIDENT + 4) || cur_n < static_cast(EI_NIDENT + 4)) { + ESP_LOGE(TAG, "ELF header read failed"); + return false; + } + ElfIdent new_id = parse_elf_(new_buf, new_n); + ElfIdent cur_id = parse_elf_(cur_buf, cur_n); + if (!new_id.valid) { + ESP_LOGE(TAG, "Uploaded payload is not a valid ELF"); + return false; + } + if (!cur_id.valid) { + ESP_LOGE(TAG, "Could not parse running exe ELF header"); + return false; + } + if (new_id.ei_class != cur_id.ei_class) { + ESP_LOGE(TAG, "ELF class mismatch (uploaded=%u, running=%u)", new_id.ei_class, cur_id.ei_class); + return false; + } + if (new_id.ei_data != cur_id.ei_data) { + ESP_LOGE(TAG, "ELF endianness mismatch"); + return false; + } + if (new_id.e_machine != cur_id.e_machine) { + ESP_LOGE(TAG, "ELF e_machine mismatch (uploaded=0x%04x, running=0x%04x)", new_id.e_machine, cur_id.e_machine); + return false; + } + if (new_id.e_type != ET_EXEC && new_id.e_type != ET_DYN) { + ESP_LOGE(TAG, "ELF e_type=%u is not executable", new_id.e_type); + return false; + } + return true; +} +#endif // __linux__ + +#ifdef __APPLE__ +struct MachOIdent { + bool valid; + uint32_t cputype; + uint32_t cpusubtype; +}; + +MachOIdent parse_macho_(const uint8_t *buf, size_t len) { + MachOIdent out{}; + // mach_header is the common prefix of mach_header and mach_header_64; + // cputype/cpusubtype/filetype have identical offsets in both. + if (len < sizeof(struct mach_header)) + return out; + uint32_t magic; + std::memcpy(&magic, buf, sizeof(magic)); + bool swap; + if (magic == MH_MAGIC || magic == MH_MAGIC_64) { + swap = false; + } else if (magic == MH_CIGAM || magic == MH_CIGAM_64) { + swap = true; + } else { + return out; + } + struct mach_header hdr; + std::memcpy(&hdr, buf, sizeof(hdr)); + if (swap) { + hdr.cputype = OSSwapInt32(hdr.cputype); + hdr.cpusubtype = OSSwapInt32(hdr.cpusubtype); + hdr.filetype = OSSwapInt32(hdr.filetype); + } + if (hdr.filetype != MH_EXECUTE) + return out; + out.cputype = hdr.cputype; + out.cpusubtype = hdr.cpusubtype; + out.valid = true; + return out; +} + +bool validate_macho_(const char *staging_path, const std::string &exe_path) { + uint8_t new_buf[HEADER_PEEK_SIZE]; + uint8_t cur_buf[HEADER_PEEK_SIZE]; + ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + if (new_n < static_cast(sizeof(struct mach_header)) || + cur_n < static_cast(sizeof(struct mach_header))) { + ESP_LOGE(TAG, "Mach-O header read failed"); + return false; + } + MachOIdent new_id = parse_macho_(new_buf, new_n); + MachOIdent cur_id = parse_macho_(cur_buf, cur_n); + if (!new_id.valid) { + ESP_LOGE(TAG, "Uploaded payload is not a valid thin Mach-O executable"); + return false; + } + if (!cur_id.valid) { + ESP_LOGE(TAG, "Could not parse running exe Mach-O header"); + return false; + } + if (new_id.cputype != cur_id.cputype || new_id.cpusubtype != cur_id.cpusubtype) { + ESP_LOGE(TAG, "Mach-O arch mismatch (uploaded=0x%x/0x%x, running=0x%x/0x%x)", new_id.cputype, new_id.cpusubtype, + cur_id.cputype, cur_id.cpusubtype); + return false; + } + return true; +} +#endif // __APPLE__ + +bool validate_executable_(const char *staging_path, const std::string &exe_path) { +#ifdef __linux__ + return validate_elf_(staging_path, exe_path); +#elif defined(__APPLE__) + return validate_macho_(staging_path, exe_path); +#else + (void) staging_path; + (void) exe_path; + ESP_LOGE(TAG, "Host OTA validation not implemented for this OS"); + return false; +#endif +} + +} // namespace std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { - return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + if (ota_type != OTA_TYPE_UPDATE_APP) + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + // 0 = unknown size (web_server multipart); cap at MAX_OTA_SIZE. + if (image_size > MAX_OTA_SIZE) { + ESP_LOGE(TAG, "Refusing OTA of size %zu (exceeds %zu)", image_size, MAX_OTA_SIZE); + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + } + + const std::string &exe = host::get_exe_path(); + if (exe.empty()) { + ESP_LOGE(TAG, "Could not resolve running executable path; cannot stage OTA"); + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + } + this->final_path_ = exe; + this->staging_path_ = exe + ".ota.new"; + + // Clean up any leftover from a prior aborted OTA. + ::unlink(this->staging_path_.c_str()); + + this->fd_ = ::open(this->staging_path_.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0755); + if (this->fd_ < 0) { + ESP_LOGE(TAG, "Open '%s' failed: %s", this->staging_path_.c_str(), std::strerror(errno)); + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + } + + this->expected_size_ = image_size; + this->bytes_written_ = 0; + this->md5_set_ = false; + this->md5_.init(); + + ESP_LOGD(TAG, "OTA begin: staging=%s, size=%zu", this->staging_path_.c_str(), image_size); + return OTA_RESPONSE_OK; } -void HostOTABackend::set_update_md5(const char *expected_md5) {} +void HostOTABackend::set_update_md5(const char *md5) { + if (parse_hex(md5, this->expected_md5_, 16)) + this->md5_set_ = true; +} -OTAResponseTypes HostOTABackend::write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } +OTAResponseTypes HostOTABackend::write(uint8_t *data, size_t len) { + if (this->fd_ < 0) + return OTA_RESPONSE_ERROR_WRITING_FLASH; + size_t limit = this->expected_size_ != 0 ? this->expected_size_ : MAX_OTA_SIZE; + if (this->bytes_written_ + len > limit) { + ESP_LOGE(TAG, "Write past size limit (%zu)", limit); + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } -OTAResponseTypes HostOTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } + size_t remaining = len; + const uint8_t *p = data; + while (remaining > 0) { + ssize_t n = ::write(this->fd_, p, remaining); + if (n < 0) { + if (errno == EINTR) + continue; + ESP_LOGE(TAG, "Write failed: %s", std::strerror(errno)); + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + p += n; + remaining -= n; + } + this->md5_.add(data, len); + this->bytes_written_ += len; + return OTA_RESPONSE_OK; +} -void HostOTABackend::abort() {} +OTAResponseTypes HostOTABackend::end() { + if (this->fd_ < 0) + return OTA_RESPONSE_ERROR_UPDATE_END; + + if (this->bytes_written_ == 0) { + ESP_LOGE(TAG, "OTA ended with no data written"); + this->abort(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + if (this->expected_size_ != 0 && this->bytes_written_ != this->expected_size_) { + ESP_LOGE(TAG, "Size mismatch: got %zu, expected %zu", this->bytes_written_, this->expected_size_); + this->abort(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + + if (this->md5_set_) { + this->md5_.calculate(); + if (!this->md5_.equals_bytes(this->expected_md5_)) { + ESP_LOGE(TAG, "MD5 mismatch"); + this->abort(); + return OTA_RESPONSE_ERROR_MD5_MISMATCH; + } + } + + if (::fsync(this->fd_) != 0) { + ESP_LOGW(TAG, "fsync failed: %s", std::strerror(errno)); + } + ::close(this->fd_); + this->fd_ = -1; + + if (!validate_executable_(this->staging_path_.c_str(), this->final_path_)) { + ::unlink(this->staging_path_.c_str()); + this->staging_path_.clear(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + + if (::chmod(this->staging_path_.c_str(), 0755) != 0) { + ESP_LOGW(TAG, "chmod failed: %s", std::strerror(errno)); + } + + if (::rename(this->staging_path_.c_str(), this->final_path_.c_str()) != 0) { + ESP_LOGE(TAG, "rename '%s' -> '%s' failed: %s", this->staging_path_.c_str(), this->final_path_.c_str(), + std::strerror(errno)); + ::unlink(this->staging_path_.c_str()); + this->staging_path_.clear(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + + // arch_restart() (via App::safe_reboot) will execv this path with the original argv. + host::arm_reexec(this->final_path_); + this->staging_path_.clear(); + ESP_LOGI(TAG, "OTA staged at %s; will re-exec on reboot", this->final_path_.c_str()); + return OTA_RESPONSE_OK; +} + +void HostOTABackend::abort() { + if (this->fd_ >= 0) { + ::close(this->fd_); + this->fd_ = -1; + } + if (!this->staging_path_.empty()) { + ::unlink(this->staging_path_.c_str()); + this->staging_path_.clear(); + } + this->expected_size_ = 0; + this->bytes_written_ = 0; + this->md5_set_ = false; +} } // namespace esphome::ota #endif diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index 4451fdfe18..51ffdaeda3 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -2,11 +2,16 @@ #ifdef USE_HOST #include "ota_backend.h" +#include "esphome/components/md5/md5.h" + +#include +#include +#include + namespace esphome::ota { -/// Stub OTA backend for host platform - allows compilation but does not implement OTA. -/// All operations return error codes immediately. This enables configurations with -/// OTA triggers to compile for host platform during development. +/// Host OTA backend: stages new binary to `.ota.new`, validates ELF/Mach-O +/// matches the running arch, renames over ``, and arms execv via arch_restart(). class HostOTABackend final { public: OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); @@ -15,6 +20,16 @@ class HostOTABackend final { OTAResponseTypes end(); void abort(); bool supports_compression() { return false; } + + protected: + md5::MD5Digest md5_{}; + std::string staging_path_; + std::string final_path_; + size_t expected_size_{0}; + size_t bytes_written_{0}; + uint8_t expected_md5_[16]{}; + int fd_{-1}; + bool md5_set_{false}; }; std::unique_ptr make_ota_backend(); diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 8e9968e05c..ee22e4b97b 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -14,7 +14,15 @@ namespace esphome::socket { BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { this->fd_ = fd; - if (!monitor_loop || this->fd_ < 0) + if (this->fd_ < 0) + return; +#ifdef USE_HOST + // Release listening ports on OTA re-exec. + int flags = ::fcntl(this->fd_, F_GETFD, 0); + if (flags >= 0) + ::fcntl(this->fd_, F_SETFD, flags | FD_CLOEXEC); +#endif + if (!monitor_loop) return; #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index ef0eddc603..e13d5668af 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -782,6 +782,9 @@ class EsphomeCore: return self.relative_build_path("build", f"{self.name}.bin") if self.is_libretiny: return self.relative_pioenvs_path(self.name, "firmware.uf2") + if self.is_host: + # Host builds produce a native ELF/Mach-O named `program`. + return self.relative_pioenvs_path(self.name, "program") return self.relative_pioenvs_path(self.name, "firmware.bin") @property diff --git a/tests/integration/fixtures/host_ota_rejects_garbage.yaml b/tests/integration/fixtures/host_ota_rejects_garbage.yaml new file mode 100644 index 0000000000..ebf7977123 --- /dev/null +++ b/tests/integration/fixtures/host_ota_rejects_garbage.yaml @@ -0,0 +1,9 @@ +esphome: + name: host-ota-test +host: +api: +ota: + - platform: esphome + port: __OTA_PORT__ +logger: + level: DEBUG diff --git a/tests/integration/fixtures/host_ota_self_update.yaml b/tests/integration/fixtures/host_ota_self_update.yaml new file mode 100644 index 0000000000..ebf7977123 --- /dev/null +++ b/tests/integration/fixtures/host_ota_self_update.yaml @@ -0,0 +1,9 @@ +esphome: + name: host-ota-test +host: +api: +ota: + - platform: esphome + port: __OTA_PORT__ +logger: + level: DEBUG diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py new file mode 100644 index 0000000000..e1036fdf1c --- /dev/null +++ b/tests/integration/test_host_ota.py @@ -0,0 +1,152 @@ +"""End-to-end OTA tests on the host platform. + +Exercises the native OTA protocol against a real host binary, then asserts +pid is preserved across the post-OTA execv. A second OTA on the post-exec +instance covers the FD_CLOEXEC path. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +from contextlib import contextmanager +import socket + +import pytest + +from esphome import espota2 + +from .conftest import run_binary, wait_and_connect_api_client +from .const import LOCALHOST, PORT_POLL_INTERVAL, PORT_WAIT_TIMEOUT +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "host-ota-test" + + +@contextmanager +def _reserve_port() -> Generator[tuple[int, socket.socket]]: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("", 0)) + try: + yield s.getsockname()[1], s + finally: + s.close() + + +async def _wait_for_port(host: str, port: int, timeout: float) -> None: + """Poll until a TCP port accepts connections, or raise TimeoutError.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + try: + _, writer = await asyncio.open_connection(host, port) + except (ConnectionRefusedError, OSError): + await asyncio.sleep(PORT_POLL_INTERVAL) + continue + writer.close() + await writer.wait_closed() + return + raise TimeoutError(f"Port {port} on {host} did not open within {timeout}s") + + +@pytest.mark.asyncio +async def test_host_ota_self_update( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Self-OTA: upload the running binary back to itself, expect re-exec.""" + api_port, api_socket = reserved_tcp_port + with _reserve_port() as (ota_port, ota_socket): + yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port)) + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + api_socket.close() + ota_socket.close() + + loop = asyncio.get_running_loop() + ota_staged = loop.create_future() + rebooted = loop.create_future() + + def on_log(line: str) -> None: + if not ota_staged.done() and "OTA staged at" in line: + ota_staged.set_result(True) + if not rebooted.done() and "Rebooting safely" in line: + rebooted.set_result(True) + + async with run_binary(binary_path, line_callback=on_log) as (proc, _lines): + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + pid_before = proc.pid + async with wait_and_connect_api_client(port=api_port) as client: + info_before = await client.device_info() + assert info_before.name == DEVICE_NAME + + # espota2 is blocking; run in executor. + rc, _ = await loop.run_in_executor( + None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path + ) + assert rc == 0, "espota2 reported failure" + + await asyncio.wait_for(ota_staged, timeout=10.0) + await asyncio.wait_for(rebooted, timeout=10.0) + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + + # execv preserves pid; mismatch means external respawn. + assert proc.returncode is None, "process exited instead of execing" + assert proc.pid == pid_before + + async with wait_and_connect_api_client(port=api_port) as client: + info_after = await client.device_info() + assert info_after.name == DEVICE_NAME + assert info_after.name == info_before.name + + # Second OTA: catches FD_CLOEXEC regressions (EADDRINUSE on rebind). + rc, _ = await loop.run_in_executor( + None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path + ) + assert rc == 0, "second OTA failed -- listener leaked across execv" + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + assert proc.pid == pid_before + + +@pytest.mark.asyncio +async def test_host_ota_rejects_garbage( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + integration_test_dir, +) -> None: + """Bogus payload is rejected and the device keeps running.""" + api_port, api_socket = reserved_tcp_port + with _reserve_port() as (ota_port, ota_socket): + yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port)) + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # 192 bytes that are neither ELF nor Mach-O. + bogus_path = integration_test_dir / "bogus.bin" + bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8) + + api_socket.close() + ota_socket.close() + + async with run_binary(binary_path) as (proc, _lines): + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + pid_before = proc.pid + + loop = asyncio.get_running_loop() + rc, _ = await loop.run_in_executor( + None, espota2.run_ota, LOCALHOST, ota_port, None, bogus_path + ) + assert rc == 1 + + await asyncio.sleep(0.5) + assert proc.returncode is None, "process died on rejected OTA" + assert proc.pid == pid_before + + async with wait_and_connect_api_client(port=api_port) as client: + info = await client.device_info() + assert info.name == DEVICE_NAME diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 1a52e6b29e..2322fdd014 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -591,6 +591,30 @@ class TestEsphomeCore: assert target.is_esp32 is False assert target.is_esp8266 is True + def test_firmware_bin__default(self, target): + """Default platforms produce //firmware.bin.""" + target.name = "test-device" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"} + assert target.firmware_bin == Path( + "foo/build/.pioenvs/test-device/firmware.bin" + ) + + def test_firmware_bin__libretiny(self, target): + """The libretiny platform produces firmware.uf2.""" + target.name = "test-device" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "bk72xx"} + assert target.firmware_bin == Path( + "foo/build/.pioenvs/test-device/firmware.uf2" + ) + + def test_firmware_bin__host(self, target): + """Host platform produces a native ELF/Mach-O named `program`, + not firmware.bin -- needed for `esphome upload` to find the + right artifact for the host OTA backend.""" + target.name = "test-device" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "host"} + assert target.firmware_bin == Path("foo/build/.pioenvs/test-device/program") + @pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") def test_data_dir_default_unix(self, target): """Test data_dir returns .esphome in config directory by default on Unix.""" From ee8ca2a3bf77ab3ba8dd2e7e09bfc80060d60345 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Mon, 11 May 2026 17:54:35 +0200 Subject: [PATCH 1153/2214] [zigbee] add on_join trigger for esp32 (#16060) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/zigbee/__init__.py | 17 +++++++++++------ .../zigbee/time/zigbee_time_zephyr.cpp | 2 +- esphome/components/zigbee/zigbee_esp32.cpp | 16 ++++++++++++++-- esphome/components/zigbee/zigbee_esp32.h | 10 ++++++++-- esphome/components/zigbee/zigbee_esp32.py | 4 +++- esphome/components/zigbee/zigbee_zephyr.cpp | 11 +++++------ esphome/components/zigbee/zigbee_zephyr.h | 8 +++----- esphome/components/zigbee/zigbee_zephyr.py | 9 +++------ tests/components/zigbee/common_esp32.yaml | 3 +++ 9 files changed, 51 insertions(+), 29 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index a7e9d1096f..286b395e18 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -79,10 +79,7 @@ CONFIG_SCHEMA = cv.All( cv.string, cv.Length(max=31) ), cv.Optional(CONF_ROUTER, default=False): cv.boolean, - cv.Optional(CONF_ON_JOIN): cv.All( - cv.requires_component("nrf52"), - automation.validate_automation(single=True), - ), + cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), cv.OnlyWith(CONF_WIPE_ON_BOOT, "nrf52", default=False): cv.All( cv.Any( cv.boolean, @@ -146,17 +143,25 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = [ + automation.CallbackAutomation(CONF_ON_JOIN, "add_on_join_callback", [(bool, "x")]), +] + + @coroutine_with_priority(CoroPriority.CORE) async def to_code(config: ConfigType) -> None: cg.add_define("USE_ZIGBEE") + var = None if CORE.using_zephyr: from .zigbee_zephyr import zephyr_to_code - await zephyr_to_code(config) + var = await zephyr_to_code(config) if CORE.is_esp32: from .zigbee_esp32 import esp32_to_code - await esp32_to_code(config) + var = await esp32_to_code(config) + if var is not None: + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 70ceb60abe..92d238629a 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -26,7 +26,7 @@ void ZigbeeTime::setup() { global_time = this; this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); synchronize_epoch_(EPOCH_2000); - this->parent_->add_join_callback([this]() { zb_zcl_time_server_synchronize(this->endpoint_, sync_time); }); + this->parent_->add_on_join_callback([this](bool x) { zb_zcl_time_server_synchronize(this->endpoint_, sync_time); }); } void ZigbeeTime::dump_config() { diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index c16736236a..733f45cc2a 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -59,11 +59,13 @@ void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non "); global_zigbee->started = true; if (esp_zb_bdb_is_factory_new()) { + global_zigbee->factory_new = true; ESP_LOGD(TAG, "Start network steering"); esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING); } else { ESP_LOGD(TAG, "Device rebooted"); - global_zigbee->connected = true; + global_zigbee->joined = true; + global_zigbee->enable_loop_soon_any_context(); } } else { ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)", @@ -78,7 +80,8 @@ void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { steering_retry_count = 0; ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(), esp_zb_get_current_channel()); - global_zigbee->connected = true; + global_zigbee->joined = true; + global_zigbee->enable_loop_soon_any_context(); } else { ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status)); if (steering_retry_count < 10) { @@ -283,6 +286,15 @@ void ZigbeeComponent::setup() { } } xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL); + this->disable_loop(); // loop is only needed for processing events, so disable until we join a network +} + +void ZigbeeComponent::loop() { + if (this->joined.exchange(false)) { + this->connected_ = true; + this->join_cb_.call(this->factory_new); + } + this->disable_loop(); } void ZigbeeComponent::dump_config() { diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 80ecbfd639..a9072f6c8d 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -39,6 +39,7 @@ class ZigbeeAttribute; class ZigbeeComponent : public Component { public: void setup() override; + void loop() override; void dump_config() override; esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, esp_zb_cluster_list_t *esp_zb_cluster_list); @@ -59,10 +60,13 @@ class ZigbeeComponent : public Component { esp_zb_lock_release(); } + template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } + bool is_started() { return this->started; } - bool is_connected() { return this->connected; } - std::atomic connected = false; + bool is_connected() { return this->connected_; } std::atomic started = false; + std::atomic joined = false; + std::atomic factory_new = false; protected: struct { @@ -70,6 +74,7 @@ class ZigbeeComponent : public Component { uint8_t *manufacturer; uint8_t *date; } basic_cluster_data_; + bool connected_ = false; #ifdef ZB_ED_ROLE esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED; #else @@ -89,6 +94,7 @@ class ZigbeeComponent : public Component { // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create(); + CallbackManager join_cb_{}; }; extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct); diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 99238f758e..5b1808ea60 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -28,6 +28,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.coroutine import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -289,7 +290,7 @@ async def attributes_to_code( cg.add(attr_var.connect(template_arg, device)) -async def esp32_to_code(config: ConfigType) -> None: +async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zboss-lib", ref="1.6.4", @@ -332,3 +333,4 @@ async def esp32_to_code(config: ConfigType) -> None: ) ) await attributes_to_code(var, ep[CONF_NUM], cl) + return var diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 26bef8fb17..81aad7dcb1 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -40,7 +40,7 @@ void ZigbeeComponent::zboss_signal_handler_esphome(zb_bufid_t bufid) { case ZB_BDB_SIGNAL_DEVICE_REBOOT: ESP_LOGD(TAG, "ZB_BDB_SIGNAL_DEVICE_REBOOT, status: %d", status); if (status == RET_OK) { - on_join_(); + on_join_(false); } break; case ZB_BDB_SIGNAL_STEERING: @@ -88,7 +88,7 @@ void ZigbeeComponent::zboss_signal_handler_esphome(zb_bufid_t bufid) { for (int i = 0; i < addr_len; ++i) { if (ieee_addr_buf[i] != '0') { - on_join_(); + on_join_(true); break; } } @@ -130,11 +130,10 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { p_device_cb_param->status = RET_NOT_IMPLEMENTED; } -void ZigbeeComponent::on_join_() { - this->defer([this]() { +void ZigbeeComponent::on_join_(bool factory_new) { + this->defer([this, factory_new]() { ESP_LOGD(TAG, "Joined the network"); - this->join_trigger_.trigger(); - this->join_cb_.call(); + this->join_cb_.call(factory_new); }); } diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index 0a189ac1e0..d462d2a403 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -74,25 +74,23 @@ class ZigbeeComponent : public Component { // endpoints are enumerated from 1 this->callbacks_[endpoint - 1] = std::move(cb); } - template void add_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } + template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } void zboss_signal_handler_esphome(zb_bufid_t bufid); void after_reporting_info(zb_zcl_configure_reporting_req_t *config_rep_req, zb_zcl_attr_addr_info_t *attr_addr_info); void factory_reset(); - Trigger<> *get_join_trigger() { return &this->join_trigger_; }; void force_report(); void loop() override; void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } protected: static void zcl_device_cb(zb_bufid_t bufid); - void on_join_(); + void on_join_(bool factory_new); #ifdef USE_ZIGBEE_WIPE_ON_BOOT void erase_flash_(int area); #endif void dump_reporting_(); std::array, ZIGBEE_ENDPOINTS_COUNT> callbacks_{}; - CallbackManager join_cb_; - Trigger<> join_trigger_; + CallbackManager join_cb_; bool force_report_{false}; uint32_t sleep_time_{}; uint32_t sleep_remainder_{}; diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 033511691c..aa16bbef53 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -1,7 +1,6 @@ import datetime import random -from esphome import automation import esphome.codegen as cg from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv @@ -23,7 +22,6 @@ from esphome.types import ConfigType from .const import ( BACNET_UNIT_NO_UNITS, BACNET_UNITS, - CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_ROUTER, CONF_WIPE_ON_BOOT, @@ -96,7 +94,7 @@ zephyr_number = cv.Schema( ) -async def zephyr_to_code(config: ConfigType) -> None: +async def zephyr_to_code(config: ConfigType) -> "MockObj": zephyr_add_prj_conf("ZIGBEE", True) zephyr_add_prj_conf("ZIGBEE_APP_UTILS", True) if config[CONF_ROUTER]: @@ -141,15 +139,14 @@ async def zephyr_to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) - if on_join_config := config.get(CONF_ON_JOIN): - await automation.build_automation(var.get_join_trigger(), [], on_join_config) - await cg.register_component(var, config) CORE.add_job(_ctx_to_code, config) cg.add(var.set_sleepy(config[CONF_SLEEPY])) + return var + async def _attr_to_code(config: ConfigType) -> None: # Create the basic attributes structure and attribute list diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 94e3f3c8c0..77e202b523 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -12,3 +12,6 @@ binary_sensor: zigbee: model: zigbee_test router: true + on_join: + then: + - logger.log: "Joined network" From e479e8b6414464674c8dc0637e192fbd045b88e4 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Mon, 11 May 2026 20:08:52 +0200 Subject: [PATCH 1154/2214] [zigbee] Add power_source option to esp32 (#16062) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/zigbee/__init__.py | 5 ++--- esphome/components/zigbee/zigbee_esp32.cpp | 12 +++++++++--- esphome/components/zigbee/zigbee_esp32.h | 4 +++- esphome/components/zigbee/zigbee_esp32.py | 3 +++ tests/components/zigbee/common_esp32.yaml | 1 + 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 286b395e18..69e3fe9c5a 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -87,9 +87,8 @@ CONFIG_SCHEMA = cv.All( ), cv.requires_component("nrf52"), ), - cv.OnlyWith(CONF_POWER_SOURCE, "nrf52", default="DC_SOURCE"): cv.All( - cv.enum(POWER_SOURCE, upper=True), - cv.requires_component("nrf52"), + cv.Optional(CONF_POWER_SOURCE, default="DC_SOURCE"): cv.enum( + POWER_SOURCE, upper=True ), cv.Optional(CONF_IEEE802154_VENDOR_OUI): cv.All( cv.Any( diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 733f45cc2a..c534a38432 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -153,7 +153,7 @@ void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list; } -void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer) { +void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source) { char date_buf[16]; time_t time_val = App.get_build_time(); struct tm *timeinfo = localtime(&time_val); @@ -162,13 +162,14 @@ void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufactu .model = get_zcl_string(model, 31), .manufacturer = get_zcl_string(manufacturer, 31), .date = get_zcl_string(date_buf, 15), + .power_source = power_source, }; } esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() { esp_zb_basic_cluster_cfg_t basic_cluster_cfg = { .zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, - .power_source = 0, + .power_source = this->basic_cluster_data_.power_source, }; esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg); esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, @@ -192,7 +193,12 @@ static void esp_zb_task_(void *pvParameters) { ESP_LOGE(TAG, "Could not setup Zigbee"); vTaskDelete(NULL); } - esp_zb_set_node_descriptor_power_source(1); + if (global_zigbee->is_battery_powered()) { + ESP_LOGD(TAG, "Battery powered!"); + esp_zb_set_node_descriptor_power_source(0); + } else { + esp_zb_set_node_descriptor_power_source(1); + } esp_zb_stack_main_loop(); } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index a9072f6c8d..03d3286ab8 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -43,7 +43,7 @@ class ZigbeeComponent : public Component { void dump_config() override; esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, esp_zb_cluster_list_t *esp_zb_cluster_list); - void set_basic_cluster(const char *model, const char *manufacturer); + void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id); @@ -62,6 +62,7 @@ class ZigbeeComponent : public Component { template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } + bool is_battery_powered() { return this->basic_cluster_data_.power_source == ESP_ZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } bool is_started() { return this->started; } bool is_connected() { return this->connected_; } std::atomic started = false; @@ -73,6 +74,7 @@ class ZigbeeComponent : public Component { uint8_t *model; uint8_t *manufacturer; uint8_t *date; + uint8_t power_source; } basic_cluster_data_; bool connected_ = false; #ifdef ZB_ED_ROLE diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 5b1808ea60..e446377a06 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -36,9 +36,11 @@ from .const import ( ANALOG_INPUT_APPTYPE, BACNET_UNIT_NO_UNITS, BACNET_UNITS, + CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, KEY_ZIGBEE, + POWER_SOURCE, REPORT, ZigbeeAttribute, ) @@ -320,6 +322,7 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": var.set_basic_cluster( config[CONF_MODEL], "esphome", + cg.RawExpression(POWER_SOURCE[config[CONF_POWER_SOURCE]]), ) ) for ep in ep_list: diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 77e202b523..82a523fc7c 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -12,6 +12,7 @@ binary_sensor: zigbee: model: zigbee_test router: true + power_source: MAINS_SINGLE_PHASE on_join: then: - logger.log: "Joined network" From 55ef66cc265ceae81e9c872eb49b47ab1662c6d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 May 2026 13:12:55 -0500 Subject: [PATCH 1155/2214] [helpers] Re-enable ProgressBar under --dashboard mode (#16357) --- esphome/helpers.py | 11 ++++++++- tests/unit_tests/test_helpers.py | 42 ++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index 62ddc489ba..d7ddb5c416 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -618,10 +618,19 @@ class ProgressBar: """A simple terminal progress bar for upload operations.""" def __init__(self, header: str, stream: TextIO | None = None) -> None: + # Local import to avoid a top-level cycle with esphome.core. + from esphome.core import CORE + self.header = header self.stream = stream or sys.stderr self.last_progress: int | None = None - self.enabled = hasattr(self.stream, "isatty") and self.stream.isatty() + # Enable when writing to an interactive TTY *or* when running under + # ``--dashboard``. The dashboard captures our stderr via + # ``stdout=PIPE, stderr=STDOUT`` and parses the ``\rUploading: NN%`` + # frames to drive its own progress UI -- gating purely on ``isatty()`` + # silently disables every dashboard-side flash-progress indicator. + is_tty = hasattr(self.stream, "isatty") and self.stream.isatty() + self.enabled = is_tty or CORE.dashboard def update(self, progress: float) -> None: if not self.enabled: diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index f2faf3ba8f..bb00a15bee 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1,9 +1,10 @@ +import io import logging import os from pathlib import Path import socket import stat -from unittest.mock import patch +from unittest.mock import MagicMock, patch from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr from hypothesis import given @@ -12,7 +13,8 @@ import pytest from esphome import helpers from esphome.address_cache import AddressCache -from esphome.core import EsphomeError +from esphome.core import CORE, EsphomeError +from esphome.helpers import ProgressBar @pytest.mark.parametrize( @@ -1024,3 +1026,39 @@ def test_resolve_ip_address_mixed_cached_uncached() -> None: assert "192.168.1.10" in addresses # Direct IP assert "192.168.1.50" in addresses # From cache assert "192.168.1.100" in addresses # From resolver + + +def test_progressbar_enabled_on_tty(monkeypatch) -> None: + """Interactive TTY: progress writes through (pre-existing behaviour).""" + stream = MagicMock(spec=io.TextIOWrapper) + stream.isatty.return_value = True + monkeypatch.setattr(CORE, "dashboard", False) + + bar = ProgressBar("Uploading", stream=stream) + assert bar.enabled is True + + +def test_progressbar_disabled_on_pipe_without_dashboard(monkeypatch) -> None: + """Piped output without --dashboard: progress suppressed.""" + stream = MagicMock(spec=io.TextIOWrapper) + stream.isatty.return_value = False + monkeypatch.setattr(CORE, "dashboard", False) + + bar = ProgressBar("Uploading", stream=stream) + assert bar.enabled is False + + +def test_progressbar_enabled_on_pipe_with_dashboard(monkeypatch) -> None: + r"""Piped output under --dashboard: progress writes through. + + The dashboard captures stderr through a pipe (so ``isatty()`` is False) + and parses ``\rUploading: NN%`` frames to drive its progress UI. + Gating purely on ``isatty()`` silently disables every dashboard-side + flash-progress indicator. + """ + stream = MagicMock(spec=io.TextIOWrapper) + stream.isatty.return_value = False + monkeypatch.setattr(CORE, "dashboard", True) + + bar = ProgressBar("Uploading", stream=stream) + assert bar.enabled is True From 4e31b713048d0dd9385434d2d9599baa168d506d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 12 May 2026 08:52:33 +1000 Subject: [PATCH 1156/2214] [lvgl] Add new trigger `on_update` and new number option (#16312) --- esphome/components/lvgl/defines.py | 11 ++++- esphome/components/lvgl/lvcode.py | 3 +- esphome/components/lvgl/number/__init__.py | 37 ++++++++++----- esphome/components/lvgl/number/lvgl_number.h | 9 +--- esphome/components/lvgl/schemas.py | 25 +++++++++- esphome/components/lvgl/sensor/__init__.py | 18 +++---- esphome/components/lvgl/trigger.py | 8 ++++ esphome/components/lvgl/types.py | 3 +- esphome/components/lvgl/widgets/__init__.py | 13 ++--- esphome/components/lvgl/widgets/spinbox.py | 4 +- tests/components/lvgl/common.yaml | 2 +- tests/components/lvgl/lvgl-package.yaml | 50 ++++++++++++++++++++ 12 files changed, 135 insertions(+), 48 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 7bfd26bb6e..15a24f1ad2 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -20,7 +20,6 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType LOGGER = logging.getLogger(__name__) -lvgl_ns = cg.esphome_ns.namespace("lvgl") DOMAIN = "lvgl" KEY_COLOR_FORMATS = "color_formats" @@ -400,6 +399,13 @@ LV_EVENT_MAP = { LV_PRESS_EVENTS = ("PRESS", "PRESSING", "RELEASE") +VALUE_ON_CHANGE = "on_change" +VALUE_ON_UPDATE = "on_update" +VALUE_ON_VALUE = "on_value" +VALUE_ON_RELEASE = "on_release" + +LV_VALUE_EVENTS = (VALUE_ON_CHANGE, VALUE_ON_UPDATE, VALUE_ON_VALUE, VALUE_ON_RELEASE) + def is_press_event(event: str) -> bool: return event.removeprefix("on_").upper() in LV_PRESS_EVENTS @@ -788,6 +794,7 @@ CONF_SKIP = "skip" CONF_SYMBOL = "symbol" CONF_TAB_ID = "tab_id" CONF_TABS = "tabs" +CONF_THEME = "theme" CONF_TICK_STYLE = "tick_style" CONF_TIME_FORMAT = "time_format" CONF_TILE = "tile" @@ -799,7 +806,7 @@ CONF_TOUCHSCREENS = "touchscreens" CONF_TRANSFORM_ROTATION = "transform_rotation" CONF_TRANSFORM_SCALE = "transform_scale" CONF_TRANSPARENCY_KEY = "transparency_key" -CONF_THEME = "theme" +CONF_TRIGGER = "trigger" CONF_UPDATE_ON_RELEASE = "update_on_release" CONF_UPDATE_WHEN_DISPLAY_IDLE = "update_when_display_idle" CONF_VISIBLE_ROW_COUNT = "visible_row_count" diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 32fb02e3d2..de00593773 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -20,7 +20,8 @@ from esphome.cpp_generator import ( ) from esphome.yaml_util import ESPHomeDataBase -from .defines import literal, lvgl_ns +from .defines import literal +from .types import lvgl_ns LVGL_COMP = "lv_component" # used as a lambda argument in lvgl_comp() diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index 0c7e4b9524..be51963ba1 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -1,10 +1,16 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv -from esphome.const import CONF_RESTORE_VALUE +from esphome.const import CONF_ON_RELEASE, CONF_RESTORE_VALUE from esphome.cpp_generator import MockObj -from ..defines import CONF_ANIMATED, CONF_UPDATE_ON_RELEASE, CONF_WIDGET +from ..defines import ( + CONF_ANIMATED, + CONF_TRIGGER, + CONF_UPDATE_ON_RELEASE, + CONF_WIDGET, + LOGGER, +) from ..lv_validation import animated from ..lvcode import ( EVENT_ARG, @@ -14,7 +20,8 @@ from ..lvcode import ( lv_obj, lvgl_static, ) -from ..types import LV_EVENT, LvNumber, lvgl_ns +from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA +from ..types import LvNumber, lvgl_ns from ..widgets import get_widgets, wait_for_widgets LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component) @@ -22,14 +29,22 @@ LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component) CONFIG_SCHEMA = number.number_schema(LVGLNumber).extend( { cv.Required(CONF_WIDGET): cv.use_id(LvNumber), + **VALUE_TRIGGER_SCHEMA, cv.Optional(CONF_ANIMATED, default=True): animated, - cv.Optional(CONF_UPDATE_ON_RELEASE, default=False): cv.boolean, + cv.Optional(CONF_UPDATE_ON_RELEASE): cv.boolean, cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean, } ) async def to_code(config): + trigger = config[CONF_TRIGGER] + if CONF_UPDATE_ON_RELEASE in config: + LOGGER.warning( + "Option 'update_on_release' is deprecated and will be removed in 2026.11.0 - use 'trigger: on_release' instead" + ) + if config[CONF_UPDATE_ON_RELEASE]: + trigger = CONF_ON_RELEASE widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] await wait_for_widgets() @@ -40,19 +55,13 @@ async def to_code(config): "value", MockObj("v") * MockObj(widget.get_scale()), config[CONF_ANIMATED] ) lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr) - event_code = ( - LV_EVENT.VALUE_CHANGED - if not config[CONF_UPDATE_ON_RELEASE] - else LV_EVENT.RELEASED - ) var = await number.new_number( config, await control.get_lambda(), await value.get_lambda(), - event_code, config[CONF_RESTORE_VALUE], - max_value=widget.type.get_max(widget.config), - min_value=widget.type.get_min(widget.config), + max_value=await widget.type.get_max(widget.config), + min_value=await widget.type.get_min(widget.config), step=widget.type.get_step(widget.config), ) async with LambdaContext(EVENT_ARG) as event: @@ -60,6 +69,8 @@ async def to_code(config): await cg.register_component(var, config) cg.add( lvgl_static.add_event_cb( - widget.obj, await event.get_lambda(), UPDATE_EVENT, event_code + widget.obj, + await event.get_lambda(), + *TRIGGER_EVENT_MAP[trigger], ) ) diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index ba16b1f0b3..3fda9427c5 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -10,12 +10,8 @@ namespace esphome::lvgl { class LVGLNumber : public number::Number, public Component { public: - LVGLNumber(std::function control_lambda, std::function value_lambda, lv_event_code_t event, - bool restore) - : control_lambda_(std::move(control_lambda)), - value_lambda_(std::move(value_lambda)), - event_(event), - restore_(restore) {} + LVGLNumber(std::function control_lambda, std::function value_lambda, bool restore) + : control_lambda_(std::move(control_lambda)), value_lambda_(std::move(value_lambda)), restore_(restore) {} void setup() override { float value = this->value_lambda_(); @@ -42,7 +38,6 @@ class LVGLNumber : public number::Number, public Component { } std::function control_lambda_; std::function value_lambda_; - lv_event_code_t event_; bool restore_; ESPPreferenceObject pref_{}; }; diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 6b71c2875e..553e0f7398 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_GROUP, CONF_ID, CONF_ON_BOOT, + CONF_ON_UPDATE, CONF_ON_VALUE, CONF_STATE, CONF_TEXT, @@ -29,7 +30,13 @@ from .defines import ( CONF_SCROLL_SNAP_Y, CONF_SCROLLBAR_MODE, CONF_TIME_FORMAT, + CONF_TRIGGER, LV_GRAD_DIR, + LV_VALUE_EVENTS, + VALUE_ON_CHANGE, + VALUE_ON_RELEASE, + VALUE_ON_UPDATE, + VALUE_ON_VALUE, get_remapped_uses, is_press_event, ) @@ -41,8 +48,9 @@ from .layout import ( grid_alignments, ) from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity -from .lvcode import LvglComponent, lv_event_t_ptr +from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr from .types import ( + LV_EVENT, LVEncoderListener, LvType, lv_group_t, @@ -355,6 +363,19 @@ SET_STATE_SCHEMA = cv.Schema( FLAG_SCHEMA = cv.Schema({cv.Optional(flag): lvalid.lv_bool for flag in df.OBJ_FLAGS}) FLAG_LIST = cv.ensure_list(df.LV_OBJ_FLAG.one_of) +VALUE_TRIGGER_SCHEMA = { + cv.Optional(CONF_TRIGGER, default=CONF_ON_VALUE): cv.one_of( + *LV_VALUE_EVENTS, lower=True + ), +} + +TRIGGER_EVENT_MAP = { + VALUE_ON_CHANGE: (LV_EVENT.VALUE_CHANGED,), + VALUE_ON_UPDATE: (UPDATE_EVENT,), + VALUE_ON_VALUE: (LV_EVENT.VALUE_CHANGED, UPDATE_EVENT), + VALUE_ON_RELEASE: (LV_EVENT.RELEASED,), +} + def part_schema(parts): """ @@ -370,7 +391,7 @@ def part_schema(parts): def automation_schema(typ: LvType): events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS if typ.has_on_value: - events = events + (CONF_ON_VALUE,) + events = events + (CONF_ON_VALUE, CONF_ON_UPDATE) args = typ.get_arg_type() def get_trigger_args(event): diff --git a/esphome/components/lvgl/sensor/__init__.py b/esphome/components/lvgl/sensor/__init__.py index 682d84ede3..e69ea9771a 100644 --- a/esphome/components/lvgl/sensor/__init__.py +++ b/esphome/components/lvgl/sensor/__init__.py @@ -1,21 +1,16 @@ from esphome.components.sensor import Sensor, new_sensor, sensor_schema import esphome.config_validation as cv -from ..defines import CONF_WIDGET -from ..lvcode import ( - EVENT_ARG, - UPDATE_EVENT, - LambdaContext, - LvContext, - lv_add, - lvgl_static, -) -from ..types import LV_EVENT, LvNumber +from ..defines import CONF_TRIGGER, CONF_WIDGET +from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lv_add, lvgl_static +from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA +from ..types import LvNumber from ..widgets import Widget, get_widgets, wait_for_widgets CONFIG_SCHEMA = sensor_schema(Sensor).extend( { cv.Required(CONF_WIDGET): cv.use_id(LvNumber), + **VALUE_TRIGGER_SCHEMA, } ) @@ -33,7 +28,6 @@ async def to_code(config): lvgl_static.add_event_cb( widget.obj, await lamb.get_lambda(), - LV_EVENT.VALUE_CHANGED, - UPDATE_EVENT, + *TRIGGER_EVENT_MAP[config[CONF_TRIGGER]], ) ) diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 64590d56f6..5f524969e2 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.const import ( CONF_ID, CONF_ON_BOOT, + CONF_ON_UPDATE, CONF_ON_VALUE, CONF_TRIGGER_ID, CONF_X, @@ -92,6 +93,13 @@ async def generate_triggers(): UPDATE_EVENT, ) + for conf in config.get(CONF_ON_UPDATE, ()): + await add_trigger( + conf, + w, + UPDATE_EVENT, + ) + await add_on_boot_triggers(config.get(CONF_ON_BOOT, ())) diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 1872ce2d32..509d5cc782 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -3,8 +3,6 @@ from esphome.const import CONF_TEXT, CONF_VALUE from esphome.cpp_generator import MockObj from esphome.cpp_types import Component, esphome_ns -from .defines import lvgl_ns - class LvType(cg.MockObjClass): def __init__(self, *args, **kwargs): @@ -47,6 +45,7 @@ PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template()) DrawEndTrigger = esphome_ns.class_( "Trigger", automation.Trigger.template(cg.uint32, cg.uint32) ) +lvgl_ns = cg.esphome_ns.namespace("lvgl") IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template()) ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action) LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 534ebe0b93..ab1c61ff88 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -48,6 +48,7 @@ from ..defines import ( join_enums, literal, ) +from ..lv_validation import lv_int from ..lvcode import ( LvConditional, add_line_marks, @@ -207,10 +208,10 @@ class WidgetType: """ return () - def get_max(self, config: dict): + async def get_max(self, config: dict): return sys.maxsize - def get_min(self, config: dict): + async def get_min(self, config: dict): return -sys.maxsize def get_step(self, config: dict): @@ -637,8 +638,8 @@ async def widget_to_code(w_cnfig, w_type: WidgetType | str, parent) -> Widget: class NumberType(WidgetType): - def get_max(self, config: dict): - return int(config.get(CONF_MAX_VALUE, 100)) + async def get_max(self, config: dict): + return await lv_int.process(config.get(CONF_MAX_VALUE, 100)) - def get_min(self, config: dict): - return int(config.get(CONF_MIN_VALUE, 0)) + async def get_min(self, config: dict): + return await lv_int.process(config.get(CONF_MIN_VALUE, 0)) diff --git a/esphome/components/lvgl/widgets/spinbox.py b/esphome/components/lvgl/widgets/spinbox.py index 58e3435c5c..ab68a76e9c 100644 --- a/esphome/components/lvgl/widgets/spinbox.py +++ b/esphome/components/lvgl/widgets/spinbox.py @@ -125,10 +125,10 @@ class SpinboxType(WidgetType): def get_uses(self): return CONF_TEXTAREA, CONF_LABEL - def get_max(self, config: dict): + async def get_max(self, config: dict): return config[CONF_RANGE_TO] - def get_min(self, config: dict): + async def get_min(self, config: dict): return config[CONF_RANGE_FROM] def get_step(self, config: dict): diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index 652ae7e7a1..f500002f40 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -38,7 +38,7 @@ number: - platform: lvgl widget: slider_id name: LVGL Slider Number - update_on_release: true + trigger: on_release restore_value: true - platform: lvgl widget: lv_arc diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4bf5b9d494..53984bb006 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -309,6 +309,11 @@ lvgl: - logger.log: format: "Roller changed = %d: %s" args: [x, text.c_str()] + on_update: + then: + - logger.log: + format: "Roller updated = %d: %s" + args: [x, text.c_str()] - animimg: height: 60 id: anim_img @@ -630,6 +635,10 @@ lvgl: logger.log: format: "state now %d" args: [x] + on_update: + logger.log: + format: "button updated %d" + args: [x] on_short_click: lvgl.widget.hide: hello_label on_long_press: @@ -758,6 +767,9 @@ lvgl: lambda: return tile == id(tile_1); then: - logger.log: "tile 1 is now showing" + on_update: + then: + - logger.log: "tileview updated programmatically" tiles: - id: tile_1 scroll_snap_y: center @@ -983,6 +995,11 @@ lvgl: - logger.log: format: "Arc value is %f" args: [x] + on_update: + then: + - logger.log: + format: "Arc updated to %f" + args: [x] scroll_on_focus: true value: 75 min_value: 1 @@ -1085,6 +1102,11 @@ lvgl: - logger.log: format: "slider value %f" args: [x] + on_update: + then: + - logger.log: + format: "slider updated to %f" + args: [x] on_click: then: - lvgl.slider.update: @@ -1197,6 +1219,10 @@ lvgl: logger.log: format: "Dropdown changed = %d: %s" args: [x, text.c_str()] + on_update: + logger.log: + format: "Dropdown updated = %d: %s" + args: [x, text.c_str()] on_cancel: logger.log: format: "Dropdown closed = %d" @@ -1437,6 +1463,30 @@ color: blue_int: 64 white_int: 255 +sensor: + - platform: lvgl + widget: lv_arc_1 + id: lvgl_arc1_sensor_on_change + name: LVGL Arc1 Sensor on_change + trigger: on_change + - platform: lvgl + widget: bar_id + id: lvgl_bar_sensor_on_release + name: LVGL Bar Sensor on_release + trigger: on_release + +number: + - platform: lvgl + widget: lv_arc_1 + id: lvgl_arc1_number_on_update + name: LVGL Arc1 Number on_update + trigger: on_update + - platform: lvgl + widget: spinbox_id + id: lvgl_spinbox_number_on_change + name: LVGL Spinbox Number on_change + trigger: on_change + select: - platform: lvgl id: lv_roller_select From a232aedebda88a04ef7abf27e335c27d48bdf206 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 12 May 2026 12:09:42 +1000 Subject: [PATCH 1157/2214] [lvgl] Check for user defined LV_USE items (#16362) --- esphome/components/lvgl/__init__.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 31131d253f..91b101cd25 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,6 +1,7 @@ import importlib from pathlib import Path import pkgutil +import re from esphome.automation import Trigger, build_automation, validate_automation import esphome.codegen as cg @@ -30,12 +31,14 @@ import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, CONF_BUFFER_SIZE, + CONF_ESPHOME, CONF_GROUP, CONF_ID, CONF_LAMBDA, CONF_LOG_LEVEL, CONF_ON_IDLE, CONF_PAGES, + CONF_PLATFORMIO_OPTIONS, CONF_ROTATION, CONF_TIMEOUT, CONF_TRIGGER_ID, @@ -51,6 +54,7 @@ from . import defines as df, lv_validation as lvalid, widgets from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, + LOGGER, get_focused_widgets, get_lv_images_used, get_refreshed_widgets, @@ -148,9 +152,28 @@ def generate_lv_conf_h(): all_defines = set( df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES) ) - # Get the defines that are actually used based on the config + build_flags = ( + CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", []) + ) + if not isinstance(build_flags, list): + build_flags = [build_flags] + # Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART', + # or multiple defines in one string. + define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?' + defines_from_flags = { + m.group(1) for flag in build_flags for m in re.finditer(define_pattern, flag) + } + + # Get the defines that are actually used based on the config, lv_defines = df.get_defines() - unused_defines = all_defines - set(lv_defines) + clashes = defines_from_flags & lv_defines.keys() + if clashes: + LOGGER.warning( + "Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s", + sorted(list(clashes)), + ) + unused_defines = all_defines - lv_defines.keys() - defines_from_flags + # Create the content of lv_conf.h with the used defines set to their value, and the unused defines disabled definitions = [as_macro(m, v) for m, v in lv_defines.items()] + [ as_macro(m, "0") for m in unused_defines From 7dce58c58d74279ab1a81c52533d97dbde6ffa88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 02:17:42 +0000 Subject: [PATCH 1158/2214] Bump requests from 2.33.1 to 2.34.0 (#16364) 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 818453dc8f..dd539d4f86 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 -requests==2.33.1 +requests==2.34.0 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 49df1bd30e543b870c02998d6cc3eb04d0c06f85 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 02:18:06 +0000 Subject: [PATCH 1159/2214] Bump actions/cache from 5.0.3 to 5.0.5 (#16365) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cb8e07afa..bf9d474d7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -804,7 +804,7 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESPHome - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} @@ -863,7 +863,7 @@ jobs: - name: Save ESPHome cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} From b5e50144e314dfc66da12640725c0779f399679d Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Tue, 12 May 2026 02:32:58 +0000 Subject: [PATCH 1160/2214] [ota] Improve OTA error messages (#16327) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../components/ota/ota_backend_esp_idf.cpp | 3 + esphome/espota2.py | 29 +++++----- tests/unit_tests/test_espota2.py | 58 ++++++++++--------- 3 files changed, 50 insertions(+), 40 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index f391c1791a..ade726da1f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -68,6 +68,9 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) 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; + } else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) { + // This error appears with 1 factory and 1 ota partition + return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } return OTA_RESPONSE_ERROR_UNKNOWN; } diff --git a/esphome/espota2.py b/esphome/espota2.py index c13c3ea207..701a125bcd 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -118,7 +118,8 @@ _ERROR_MESSAGES: dict[int, str] = { ), RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: ( "The OTA partition on the ESP is too small. ESPHome needs to resize " - "this partition, please flash over USB." + "this partition. Please flash over USB or update the partition table " + "over the air." ), RESPONSE_ERROR_NO_UPDATE_PARTITION: ( "The OTA partition on the ESP couldn't be found. ESPHome needs to " @@ -202,19 +203,19 @@ def receive_exactly( try: data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"Error receiving acknowledge {msg}: {err}") from err + raise OTAError(f"receiving {msg} response: {err}") from err try: check_error(data, expect) except OTAError as err: sock.close() - raise OTAError(f"Error {msg}: {err}") from err + raise OTAError(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"Error receiving {msg}: {err}") from err + raise OTAError(f"receiving {msg}: {err}") from err return data @@ -231,14 +232,14 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None # silently passed through and surface later as cryptic decode/timeout failures. if not data: raise OTAError( - "Error: Device closed connection without responding. " + "Device closed connection without responding. " "This may indicate the device ran out of memory, " "a network issue, or the connection was interrupted." ) dat = data[0] error_msg = _ERROR_MESSAGES.get(dat) if error_msg is not None: - raise OTAError(f"Error: {error_msg}") + raise OTAError(error_msg) if expect is None: return if not isinstance(expect, (list, tuple)): @@ -267,7 +268,7 @@ def send_check( sock.sendall(data) except OSError as err: - raise OTAError(f"Error sending {msg}: {err}") from err + raise OTAError(f"sending {msg}: {err}") from err def perform_ota( @@ -376,7 +377,7 @@ def perform_ota( raise OTAError("ESP requests password, but no password given!") nonce_bytes = receive_exactly( - sock, nonce_size, f"{hash_name} authentication nonce", None, decode=False + sock, nonce_size, f"{hash_name} auth nonce", None, decode=False ) assert isinstance(nonce_bytes, bytes) nonce = nonce_bytes.decode() @@ -424,13 +425,13 @@ def perform_ota( (upload_size >> 0) & 0xFF, ] send_check(sock, upload_size_encoded, "binary size") - receive_exactly(sock, 1, "binary size", RESPONSE_UPDATE_PREPARE_OK) + receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK) upload_md5 = hashlib.md5(upload_contents).hexdigest() _LOGGER.debug("MD5 of upload is %s", upload_md5) send_check(sock, upload_md5, "file checksum") - receive_exactly(sock, 1, "file checksum", RESPONSE_BIN_MD5_OK) + receive_exactly(sock, 1, "file checksum result", RESPONSE_BIN_MD5_OK) # Disable nodelay for transfer sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0) @@ -451,10 +452,10 @@ def perform_ota( try: sock.sendall(chunk) if version >= OTA_VERSION_2_0: - receive_exactly(sock, 1, "chunk OK", RESPONSE_CHUNK_OK) + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) except OSError as err: sys.stderr.write("\n") - raise OTAError(f"Error sending data: {err}") from err + raise OTAError(f"sending data: {err}") from err progress.update(offset / upload_size) progress.done() @@ -465,8 +466,8 @@ def perform_ota( _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - receive_exactly(sock, 1, "receive OK", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "Update end", RESPONSE_UPDATE_END_OK) + 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") _LOGGER.info("OTA successful") diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index d7fcedfd66..9413fbcf29 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -135,7 +135,9 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: """Test receive_exactly raises OTAError on error response.""" mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]) - with pytest.raises(espota2.OTAError, match="Error auth:.*Authentication invalid"): + with pytest.raises( + espota2.OTAError, match="receiving auth:.*Authentication invalid" + ): espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) mock_socket.close.assert_called_once() @@ -145,69 +147,69 @@ 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="Error receiving acknowledge test"): + with pytest.raises(espota2.OTAError, match="receiving test response"): espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) @pytest.mark.parametrize( ("error_code", "expected_msg"), [ - (espota2.RESPONSE_ERROR_MAGIC, "Error: Invalid magic byte"), - (espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Error: Couldn't prepare flash memory"), - (espota2.RESPONSE_ERROR_AUTH_INVALID, "Error: Authentication invalid"), + (espota2.RESPONSE_ERROR_MAGIC, "Invalid magic byte"), + (espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Couldn't prepare flash memory"), + (espota2.RESPONSE_ERROR_AUTH_INVALID, "Authentication invalid"), ( espota2.RESPONSE_ERROR_WRITING_FLASH, - "Error: Writing OTA data to flash memory failed", + "Writing OTA data to flash memory failed", ), - (espota2.RESPONSE_ERROR_UPDATE_END, "Error: Finishing update failed"), + (espota2.RESPONSE_ERROR_UPDATE_END, "Finishing update failed"), ( espota2.RESPONSE_ERROR_INVALID_BOOTSTRAPPING, - "Error: Please press the reset button", + "Please press the reset button", ), ( espota2.RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG, - "Error: ESP has been flashed with wrong flash size", + "ESP has been flashed with wrong flash size", ), ( espota2.RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG, - "Error: ESP does not have the requested flash size", + "ESP does not have the requested flash size", ), ( espota2.RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE, - "Error: ESP does not have enough space", + "ESP does not have enough space", ), ( espota2.RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE, - "Error: The OTA partition on the ESP is too small", + "The OTA partition on the ESP is too small", ), ( espota2.RESPONSE_ERROR_NO_UPDATE_PARTITION, - "Error: The OTA partition on the ESP couldn't be found", + "The OTA partition on the ESP couldn't be found", ), - (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), + (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Application MD5 code mismatch"), ( espota2.RESPONSE_ERROR_SIGNATURE_INVALID, - "Error: Firmware signature verification failed", + "Firmware signature verification failed", ), ( espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, - "Error: The requested OTA type is not supported by the device", + "The requested OTA type is not supported by the device", ), ( espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY, - "Error: The partition table update could not be verified", + "The partition table update could not be verified", ), ( espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE, - "Error: An error occurred while updating the partition table", + "An error occurred while updating the partition table", ), ( espota2.RESPONSE_ERROR_BOOTLOADER_VERIFY, - "Error: The bootloader update could not be verified", + "The bootloader update could not be verified", ), ( espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE, - "Error: An error occurred while updating the bootloader", + "An error occurred while updating the bootloader", ), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], @@ -262,7 +264,7 @@ def test_send_check_socket_error(mock_socket: Mock) -> None: """Test send_check handles socket errors.""" mock_socket.sendall.side_effect = OSError("Broken pipe") - with pytest.raises(espota2.OTAError, match="Error sending test"): + with pytest.raises(espota2.OTAError, match="sending test"): espota2.send_check(mock_socket, b"data", "test") @@ -417,7 +419,9 @@ def test_perform_ota_md5_auth_wrong_password( mock_socket.recv.side_effect = recv_responses - with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"): + with pytest.raises( + espota2.OTAError, match="receiving auth.*Authentication invalid" + ): espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin") # Verify the socket was closed after auth failure @@ -441,7 +445,9 @@ def test_perform_ota_sha256_auth_wrong_password( mock_socket.recv.side_effect = recv_responses - with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"): + with pytest.raises( + espota2.OTAError, match="receiving auth.*Authentication invalid" + ): espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin") # Verify the socket was closed after auth failure @@ -484,7 +490,7 @@ def test_perform_ota_unexpected_auth_response(mock_socket: Mock) -> None: # This will actually raise "Unexpected response from ESP" from check_error with pytest.raises( - espota2.OTAError, match=r"Error auth: Unexpected response from ESP: 0x03" + espota2.OTAError, match=r"receiving auth: Unexpected response from ESP: 0x03" ): espota2.perform_ota(mock_socket, "password", mock_file, "test.bin") @@ -520,7 +526,7 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N mock_socket.recv.side_effect = recv_responses - with pytest.raises(espota2.OTAError, match="Error receiving acknowledge chunk OK"): + with pytest.raises(espota2.OTAError, match="receiving chunk result response"): espota2.perform_ota(mock_socket, None, mock_file, "test.bin") @@ -1109,7 +1115,7 @@ def test_check_error_detects_errors_when_expect_is_none() -> None: during feature negotiation and nonce reads) silently passed error bytes through, turning clean device errors into confusing later failures. """ - with pytest.raises(espota2.OTAError, match="Error: Authentication invalid"): + with pytest.raises(espota2.OTAError, match="Authentication invalid"): espota2.check_error([espota2.RESPONSE_ERROR_AUTH_INVALID], None) From 4ff946ac15e8fbe040020b487dbe1a0d9c955974 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 12 May 2026 21:52:07 +1000 Subject: [PATCH 1161/2214] [cli] Add --no-states flag to run command (#16366) --- esphome/__main__.py | 7 +++ tests/unit_tests/test_main.py | 95 +++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index ce24f44b3a..988f28a55f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2170,6 +2170,13 @@ def parse_args(argv): parser_run.add_argument( "--no-logs", help="Disable starting logs.", action="store_true" ) + + parser_run.add_argument( + "--no-states", + action="store_true", + help="Do not show entity state changes in log output.", + ) + parser_run.add_argument( "--reset", "-r", diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 3648de443d..0104854e1f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -30,6 +30,7 @@ from esphome.__main__ import ( command_bundle, command_clean_all, command_rename, + command_run, command_update_all, command_wizard, compile_program, @@ -45,6 +46,7 @@ from esphome.__main__ import ( has_resolvable_address, has_web_server_ota, mqtt_get_ip, + parse_args, run_esphome, run_miniterm, show_logs, @@ -5823,3 +5825,96 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback( call_kwargs = mock_run_external_process.call_args[1] assert "line_callbacks" in call_kwargs assert len(call_kwargs["line_callbacks"]) == 1 + + +def test_parse_args_run_no_states() -> None: + """Test that --no-states is parsed for the run command.""" + args = parse_args(["esphome", "run", "--no-states", "device.yaml"]) + assert args.no_states is True + + +def test_parse_args_run_no_states_default() -> None: + """Test that no_states defaults to False for the run command.""" + args = parse_args(["esphome", "run", "device.yaml"]) + assert args.no_states is False + + +def test_parse_args_logs_no_states() -> None: + """Test that --no-states is parsed for the logs command.""" + args = parse_args(["esphome", "logs", "--no-states", "device.yaml"]) + assert args.no_states is True + + +@patch("esphome.components.api.client.run_logs") +def test_command_run_passes_no_states_to_show_logs( + mock_run_logs: Mock, +) -> None: + """Test that command_run propagates --no-states through to run_logs.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MDNS: {CONF_DISABLED: False}, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + args = MockArgs() + args.no_states = True + args.no_logs = False + args.device = None + + with ( + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + patch( + "esphome.__main__.choose_upload_log_host", + return_value=["192.168.1.100"], + ), + patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")), + patch("esphome.__main__.get_serial_ports", return_value=[]), + ): + result = command_run(args, CORE.config) + + assert result == 0 + mock_run_logs.assert_called_once_with( + CORE.config, ["192.168.1.100"], subscribe_states=False + ) + + +@patch("esphome.components.api.client.run_logs") +def test_command_run_defaults_subscribe_states_true( + mock_run_logs: Mock, +) -> None: + """Test that command_run subscribes states by default (no --no-states).""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MDNS: {CONF_DISABLED: False}, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + args = MockArgs() + args.no_logs = False + args.device = None + + with ( + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + patch( + "esphome.__main__.choose_upload_log_host", + return_value=["192.168.1.100"], + ), + patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")), + patch("esphome.__main__.get_serial_ports", return_value=[]), + ): + result = command_run(args, CORE.config) + + assert result == 0 + mock_run_logs.assert_called_once_with( + CORE.config, ["192.168.1.100"], subscribe_states=True + ) From 365ed1931920256a6d85eb0f9105516dacb49e98 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 12 May 2026 11:19:10 -0400 Subject: [PATCH 1162/2214] [core] Fix PROGMEM_STRING_TABLE placement on ESP8266 without flash log strings (#16373) --- esphome/core/progmem.h | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 031860e3a6..d349418d02 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -25,6 +25,16 @@ #define ESPHOME_strncasecmp_P strncasecmp_P // Type for pointers to PROGMEM strings (for use with ESPHOME_F return values) using ProgmemStr = const __FlashStringHelper *; +// Storage class for PROGMEM_STRING_TABLE data. Mirrors the logger's choice of +// LOG_STR_ARG: when LOG_STR_ARG treats the LogString as PROGMEM (PGM_P), the +// table data must actually be in flash; when LOG_STR_ARG treats it as a plain +// const char* (assumes RAM), the table data must live in RAM or non-logger +// consumers (ArduinoJson, Print, MQTT publish) crash on unaligned flash reads. +#ifdef USE_STORE_LOG_STR_IN_FLASH +#define ESPHOME_PROGMEM_STRING_TABLE_STORAGE PROGMEM +#else +#define ESPHOME_PROGMEM_STRING_TABLE_STORAGE +#endif #else #define ESPHOME_F(string_literal) (string_literal) #define ESPHOME_PGM_P const char * @@ -38,6 +48,8 @@ using ProgmemStr = const __FlashStringHelper *; #define ESPHOME_strncasecmp_P strncasecmp // Type for pointers to strings (no PROGMEM on non-ESP8266 platforms) using ProgmemStr = const char *; +// No-op on non-ESP8266 platforms where PROGMEM itself is a no-op. +#define ESPHOME_PROGMEM_STRING_TABLE_STORAGE #endif namespace esphome { @@ -100,8 +112,8 @@ struct LogString; static constexpr size_t COUNT = Table::COUNT; \ static constexpr uint8_t LAST_INDEX = COUNT - 1; \ static constexpr size_t BLOB_SIZE = Table::BLOB_SIZE; \ - static constexpr auto BLOB PROGMEM = Table::make_blob(); \ - static constexpr auto OFFSETS PROGMEM = Table::make_offsets(); \ + static constexpr auto BLOB ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_blob(); \ + static constexpr auto OFFSETS ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_offsets(); \ static const char *get_(uint8_t idx, uint8_t fallback) { \ if (idx >= COUNT) \ idx = fallback; \ From 727c74da3f72e9df2a6a9756e4752c188fcc775d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 12:17:23 -0500 Subject: [PATCH 1163/2214] [script] Fix array-type parameters in script.execute (#16374) --- esphome/components/script/__init__.py | 2 + tests/components/script/common.yaml | 16 +++++ .../fixtures/script_array_params.yaml | 36 ++++++++++ tests/integration/test_script_array_params.py | 71 +++++++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 tests/integration/fixtures/script_array_params.yaml create mode 100644 tests/integration/test_script_array_params.py diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 51cae695b7..e92850fd63 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -169,6 +169,8 @@ async def script_execute_action_to_code(config, action_id, template_arg, args): return value if type == "bool": return cg.RawExpression(str(value).lower()) + if isinstance(value, (list, tuple)): + return cg.ArrayInitializer(*value) return cg.RawExpression(str(value)) return converter diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index bfd5d0e7ff..c1dc68513f 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -7,6 +7,12 @@ esphome: prefix: "Test" param2: 0 param3: true + - script.execute: + id: my_script_with_array_params + ints: [42, 100] + floats: [1.5, 2.5] + bools: [true, false] + strings: ["a", "b"] - script.wait: my_script - script.stop: my_script - if: @@ -34,6 +40,16 @@ script: mode: restart then: - lambda: 'ESP_LOGD("main", "Hello World!");' + - id: my_script_with_array_params + parameters: + ints: int[] + floats: float[] + bools: bool[] + strings: string[] + then: + - lambda: |- + ESP_LOGD("main", "ints=%d floats=%f bools=%d strings=%s", + ints[0], floats[0], bools[0], strings[0].c_str()); - id: my_script_with_params parameters: prefix: string diff --git a/tests/integration/fixtures/script_array_params.yaml b/tests/integration/fixtures/script_array_params.yaml new file mode 100644 index 0000000000..699f00a1f3 --- /dev/null +++ b/tests/integration/fixtures/script_array_params.yaml @@ -0,0 +1,36 @@ +esphome: + name: test-script-array-params + +host: + +api: + actions: + - action: run_array_script + then: + - script.execute: + id: array_script + ints: [42, 100] + floats: [1.5, 2.5] + bools: [true, false] + strings: ["hello", "world"] + +logger: + level: DEBUG + +script: + - id: array_script + parameters: + ints: int[] + floats: float[] + bools: bool[] + strings: string[] + then: + - lambda: |- + ESP_LOGI("test", "ints size=%u [0]=%d [1]=%d", + (unsigned) ints.size(), ints[0], ints[1]); + ESP_LOGI("test", "floats size=%u [0]=%.2f [1]=%.2f", + (unsigned) floats.size(), floats[0], floats[1]); + ESP_LOGI("test", "bools size=%u [0]=%d [1]=%d", + (unsigned) bools.size(), (int) bools[0], (int) bools[1]); + ESP_LOGI("test", "strings size=%u [0]=%s [1]=%s", + (unsigned) strings.size(), strings[0].c_str(), strings[1].c_str()); diff --git a/tests/integration/test_script_array_params.py b/tests/integration/test_script_array_params.py new file mode 100644 index 0000000000..2fba8a58fe --- /dev/null +++ b/tests/integration/test_script_array_params.py @@ -0,0 +1,71 @@ +"""Integration test for script array parameters (issue #16367). + +Verifies that script parameters of array types (`int[]`, `float[]`, `bool[]`, +`string[]`) compile and execute correctly. Prior to the fix in +`esphome/components/script/__init__.py`, the `script.execute` codegen emitted +the Python `repr` of the list (e.g. `return [42, 100];`) instead of a C++ +braced initializer, causing compile failures. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_array_params( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Execute a script with int[], float[], bool[], string[] parameters.""" + loop = asyncio.get_running_loop() + seen: dict[str, str] = {} + done = loop.create_future() + + patterns = { + "ints": re.compile(r"ints size=(\d+) \[0\]=(-?\d+) \[1\]=(-?\d+)"), + "floats": re.compile( + r"floats size=(\d+) \[0\]=(-?\d+\.\d+) \[1\]=(-?\d+\.\d+)" + ), + "bools": re.compile(r"bools size=(\d+) \[0\]=(\d+) \[1\]=(\d+)"), + "strings": re.compile(r"strings size=(\d+) \[0\]=(\w+) \[1\]=(\w+)"), + } + + def check_output(line: str) -> None: + for key, pat in patterns.items(): + if (m := pat.search(line)) and key not in seen: + seen[key] = m.group(0) + if len(seen) == len(patterns) and not done.done(): + done.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service = next((s for s in services if s.name == "run_array_script"), None) + assert service is not None, "run_array_script service not found" + await client.execute_service(service, {}) + + try: + await asyncio.wait_for(done, timeout=5.0) + except TimeoutError: + pytest.fail(f"Did not receive all expected log lines. Saw: {seen}") + + assert (m := patterns["ints"].search(seen["ints"])) + assert m.group(1) == "2" and m.group(2) == "42" and m.group(3) == "100" + + assert (m := patterns["floats"].search(seen["floats"])) + assert m.group(1) == "2" and m.group(2) == "1.50" and m.group(3) == "2.50" + + assert (m := patterns["bools"].search(seen["bools"])) + assert m.group(1) == "2" and m.group(2) == "1" and m.group(3) == "0" + + assert (m := patterns["strings"].search(seen["strings"])) + assert m.group(1) == "2" and m.group(2) == "hello" and m.group(3) == "world" From e5d90571142734284d5e293af4312c9baebd07a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 12:50:49 -0500 Subject: [PATCH 1164/2214] [rp2040] Bump arduino-pico framework to 5.6.0 --- .clang-tidy.hash | 2 +- esphome/components/rp2040/__init__.py | 6 +++--- platformio.ini | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index da2e863281..77b4f5323f 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -96c95feaa60831da5f43e3c6a7c7a3a237e17c5d12995a730dbc3884c8dcd11c +593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 81809c0551..862d532645 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -139,7 +139,7 @@ def _parse_platform_version(value): # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases # - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 5, 1) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 0) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags @@ -149,8 +149,8 @@ RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460" def _arduino_check_versions(value): value = value.copy() lookups = { - "dev": (cv.Version(5, 5, 1), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(5, 5, 1), None), + "dev": (cv.Version(5, 6, 0), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(5, 6, 0), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/platformio.ini b/platformio.ini index 42b7400779..8a89f96b39 100644 --- a/platformio.ini +++ b/platformio.ini @@ -193,7 +193,7 @@ board_build.filesystem_size = 0.5m platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460 platform_packages = ; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.5.1/rp2040-5.5.1.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.0/rp2040-5.6.0.zip framework = arduino lib_deps = From 76ce45c59ef453be351dca454ee4ac50b1b5783e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 13 May 2026 06:28:39 +1200 Subject: [PATCH 1165/2214] [script] Preserve source order of enum options in language schema (#16371) --- script/build_language_schema.py | 10 ++- tests/script/test_build_language_schema.py | 98 ++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/script/test_build_language_schema.py diff --git a/script/build_language_schema.py b/script/build_language_schema.py index a7142fa8b5..921ee9d3d7 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -119,7 +119,15 @@ from esphome.util import Registry # noqa: E402 def sort_obj(obj): if isinstance(obj, dict): - return {k: sort_obj(v) for k, v in sorted(obj.items(), key=lambda x: str(x[0]))} + is_enum = obj.get(S_TYPE) == "enum" + result = {} + for k, v in sorted(obj.items(), key=lambda x: str(x[0])): + if is_enum and k == "values" and isinstance(v, dict): + # Preserve source order of enum options + result[k] = {vk: sort_obj(vv) for vk, vv in v.items()} + else: + result[k] = sort_obj(v) + return result if isinstance(obj, list): return [sort_obj(item) for item in obj] return obj diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py new file mode 100644 index 0000000000..59b8c7484b --- /dev/null +++ b/tests/script/test_build_language_schema.py @@ -0,0 +1,98 @@ +"""Unit tests for script/build_language_schema.py.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +SCRIPT_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "script" + / "build_language_schema.py" +) + + +def _extract_sort_obj(): + # build_language_schema.py runs argparse, loads every component, and + # calls build_schema() at import time, so a plain import isn't viable + # in a unit test. Pull just the pure helper out via AST instead. + tree = ast.parse(SCRIPT_PATH.read_text()) + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == "sort_obj": + namespace: dict = {"S_TYPE": "type"} + module = ast.Module(body=[node], type_ignores=[]) + exec(compile(module, str(SCRIPT_PATH), "exec"), namespace) + return namespace["sort_obj"] + raise AssertionError("sort_obj not found in build_language_schema.py") + + +sort_obj = _extract_sort_obj() + + +def test_sort_obj_sorts_dict_keys() -> None: + result = sort_obj({"b": 1, "a": 2, "c": 3}) + assert list(result.keys()) == ["a", "b", "c"] + + +def test_sort_obj_sorts_nested_dicts() -> None: + result = sort_obj({"outer": {"z": 1, "a": 2}}) + assert list(result["outer"].keys()) == ["a", "z"] + + +def test_sort_obj_preserves_enum_values_order() -> None: + config = { + "type": "enum", + "values": { + "2MB": None, + "4MB": None, + "8MB": None, + "16MB": None, + "32MB": None, + }, + } + result = sort_obj(config) + assert list(result["values"].keys()) == ["2MB", "4MB", "8MB", "16MB", "32MB"] + + +def test_sort_obj_sorts_non_enum_values_key() -> None: + config = {"type": "schema", "values": {"z": 1, "a": 2}} + result = sort_obj(config) + assert list(result["values"].keys()) == ["a", "z"] + + +def test_sort_obj_sorts_other_keys_in_enum() -> None: + config = { + "type": "enum", + "default": "4MB", + "key": "Optional", + "values": {"2MB": None, "4MB": None}, + } + result = sort_obj(config) + assert list(result.keys()) == ["default", "key", "type", "values"] + assert list(result["values"].keys()) == ["2MB", "4MB"] + + +def test_sort_obj_recurses_into_enum_value_entries() -> None: + config = { + "type": "enum", + "values": { + "esp32": {"name": "ESP32", "docs": "Original"}, + "esp32-c3": {"name": "ESP32-C3", "docs": "RISC-V"}, + }, + } + result = sort_obj(config) + assert list(result["values"].keys()) == ["esp32", "esp32-c3"] + assert list(result["values"]["esp32"].keys()) == ["docs", "name"] + + +def test_sort_obj_handles_lists() -> None: + result = sort_obj([{"b": 1, "a": 2}, {"d": 3, "c": 4}]) + assert list(result[0].keys()) == ["a", "b"] + assert list(result[1].keys()) == ["c", "d"] + + +def test_sort_obj_passes_through_scalars() -> None: + assert sort_obj("hello") == "hello" + assert sort_obj(42) == 42 + assert sort_obj(None) is None + assert sort_obj(True) is True From c511dddf2a3ccaf26068fe0d9db043f2c326a225 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 12 May 2026 20:59:54 +0200 Subject: [PATCH 1166/2214] [core] allow defining run_compile in external_components (#16179) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 988f28a55f..561391708e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -737,7 +737,11 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) - if CORE.using_toolchain_esp_idf: + module = importlib.import_module("esphome.components." + CORE.target_platform) + platform_run_compile = getattr(module, "run_compile", None) + if platform_run_compile is not None and platform_run_compile(args, config): + pass + elif CORE.using_toolchain_esp_idf: from esphome.espidf import toolchain rc = toolchain.run_compile(config, CORE.verbose) From 57893a8eb1c64e0f58f54aedc3e475f1ddec8888 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 19:37:31 +0000 Subject: [PATCH 1167/2214] Bump aioesphomeapi from 44.23.0 to 44.24.2 (#16376) 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 dd539d4f86..c035051890 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==44.23.0 +aioesphomeapi==44.24.2 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 34f69e0d6ef69fe27892e1f934a2e7859fd20622 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 14:42:23 -0500 Subject: [PATCH 1168/2214] [ci] Comment on PRs that touch the legacy dashboard (#16378) --- .../dashboard-deprecation-comment.yml | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/dashboard-deprecation-comment.yml diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml new file mode 100644 index 0000000000..e15c61df5e --- /dev/null +++ b/.github/workflows/dashboard-deprecation-comment.yml @@ -0,0 +1,113 @@ +name: Add Dashboard Deprecation Comment + +on: + pull_request_target: + types: [opened, synchronize] + +# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with +# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes. +permissions: {} + +jobs: + dashboard-deprecation-comment: + name: Dashboard deprecation comment + runs-on: ubuntu-latest + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources + # the issues.*Comment APIs require the pull-requests scope, not issues. + permission-pull-requests: write + + - name: Add dashboard deprecation comment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const commentMarker = ""; + + const commentBody = `Thanks for opening this PR! + + Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository. + + What this means for your PR: + + - **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here. + - **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life. + - **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix. + + We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land. + + --- + (Added by the PR bot) + + ${commentMarker}`; + + async function getDashboardChanges(github, owner, repo, prNumber) { + const changedFiles = await github.paginate( + github.rest.pulls.listFiles, + { + owner: owner, + repo: repo, + pull_number: prNumber, + per_page: 100, + } + ); + + return changedFiles.filter(file => + file.filename.startsWith('esphome/dashboard/') || + file.filename.startsWith('tests/dashboard/') + ); + } + + async function findBotComment(github, owner, repo, prNumber) { + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: owner, + repo: repo, + issue_number: prNumber, + per_page: 100, + } + ); + + return comments.find(comment => + comment.body.includes(commentMarker) && comment.user.type === "Bot" + ); + } + + const prNumber = context.payload.pull_request.number; + const { owner, repo } = context.repo; + + const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber); + const existingComment = await findBotComment(github, owner, repo, prNumber); + + if (dashboardChanges.length === 0) { + // PR doesn't (or no longer) touches the legacy dashboard. If we previously + // commented (e.g. files were removed in a later push), leave the comment in + // place for history rather than thrash on edit/delete. + return; + } + + if (existingComment) { + if (existingComment.body === commentBody) { + return; + } + await github.rest.issues.updateComment({ + owner: owner, + repo: repo, + comment_id: existingComment.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner: owner, + repo: repo, + issue_number: prNumber, + body: commentBody, + }); + } From eeaa8dee7b5da95f54a51c92cf4b11cf1157d835 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 15:01:37 -0500 Subject: [PATCH 1169/2214] [core] Add --from-storage-json flag to upload and logs When a downstream caller (typically a dashboard) already has a known-good firmware binary on disk and only needs the CLI to ship bytes to a device or stream logs back, running the full ``read_config()`` pipeline for every subcommand is dead work. For the device-builder REMOTE install flow that runs compile, upload, and logs back-to-back this means three "Reading configuration ..." passes for one user action. Add an opt-in ``--from-storage-json`` flag to ``upload`` and ``logs`` that sources platform / build metadata from the StorageJSON sidecar produced by the last successful compile and re-parses just enough of the YAML head (substitutions + packages, no schema validation) to recover the ``api:`` / ``logger:`` / ``ota:`` / network blocks the subcommands consult. The flag is opt-in and falls back to a full ``read_config()`` pass when the sidecar is missing or older than the YAML, so a cold cache never produces a worse outcome than today. ``compile`` / ``run`` / ``clean`` / ``bundle`` continue to validate as before. --- esphome/__main__.py | 57 ++++- esphome/lite_config.py | 197 +++++++++++++++ tests/unit_tests/test_main_lite_config.py | 280 ++++++++++++++++++++++ 3 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 esphome/lite_config.py create mode 100644 tests/unit_tests/test_main_lite_config.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 561391708e..33f1727696 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2117,6 +2117,16 @@ def parse_args(argv): help="Upload as bootloader (OTA).", action="store_true", ) + parser_upload.add_argument( + "--from-storage-json", + action="store_true", + help=( + "Skip YAML schema validation by loading device metadata from " + "the .esphome/storage/.json sidecar produced by the last " + "successful compile. Falls back to a full validation pass when " + "the sidecar is missing or older than the YAML." + ), + ) parser_logs = subparsers.add_parser( "logs", @@ -2144,6 +2154,16 @@ def parse_args(argv): action="store_true", help="Do not show entity state changes in log output.", ) + parser_logs.add_argument( + "--from-storage-json", + action="store_true", + help=( + "Skip YAML schema validation by loading device metadata from " + "the .esphome/storage/.json sidecar produced by the last " + "successful compile. Falls back to a full validation pass when " + "the sidecar is missing or older than the YAML." + ), + ) parser_discover = subparsers.add_parser( "discover", @@ -2417,10 +2437,39 @@ def run_esphome(argv): # Commands that don't need fresh external components: logs just connects # to the device, and clean is about to delete the build directory. skip_external = args.command in ("logs", "clean") - config = read_config( - dict(args.substitution) if args.substitution else {}, - skip_external_update=skip_external, - ) + command_line_substitutions = dict(args.substitution) if args.substitution else {} + + # Fast path for `upload --from-storage-json` and `logs --from-storage-json`: + # the caller already has a binary on disk and only needs the CLI to ship + # bytes to a device or stream logs back. Re-running the full + # `read_config()` pipeline (parse + schema validate + final-validate + + # external component refresh) for those two subcommands is dead work and + # produces a wall of "Reading configuration ..." log lines for every + # remote install. Source the platform / build metadata from the + # StorageJSON sidecar instead; fall back to full validation if it's + # missing or stale so a cold cache never produces a worse outcome. + config = None + if getattr(args, "from_storage_json", False) and args.command in ( + "upload", + "logs", + ): + from esphome.lite_config import load_lite_config_from_storage + + config = load_lite_config_from_storage(conf_path, command_line_substitutions) + if config is None: + _LOGGER.warning( + "StorageJSON sidecar missing or stale for %s; falling back to " + "full config validation.", + conf_path, + ) + else: + _LOGGER.info("Loaded device metadata from StorageJSON sidecar.") + + if config is None: + config = read_config( + command_line_substitutions, + skip_external_update=skip_external, + ) if config is None: return 2 CORE.config = config diff --git a/esphome/lite_config.py b/esphome/lite_config.py new file mode 100644 index 0000000000..89816a00c7 --- /dev/null +++ b/esphome/lite_config.py @@ -0,0 +1,197 @@ +"""Lite-config loader for ``upload`` / ``logs`` fast paths. + +When the caller (typically a dashboard) already has a known-good +firmware binary on disk and only needs the CLI to ship bytes to a +device or stream logs back, running the full ``read_config()`` +pipeline is dead work. It re-parses every ``!include``, runs every +component's schema validator, executes all final-validate hooks, and +fetches/refreshes external components -- producing a fully validated +``Config`` object that the upload / logs subcommands only consult for a +handful of leaf keys. + +This module loads the per-config ``StorageJSON`` sidecar that was +written by the last successful ``compile`` run, populates ``CORE`` with +the platform / build metadata from it, and re-parses just enough of the +YAML head (substitutions + packages, no schema validation) to recover +``api:`` / ``logger:`` / ``ota:`` / ``mqtt:`` / network blocks for the +subcommand callers. + +If the sidecar is missing or older than the YAML, this returns ``None`` +so the dispatcher can fall back to the full ``read_config()`` path. +That makes the flag a pure optimisation: a cold cache never produces a +worse outcome than today. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from esphome.const import ( + CONF_API, + CONF_ESPHOME, + CONF_ETHERNET, + CONF_FRIENDLY_NAME, + CONF_LOGGER, + CONF_MQTT, + CONF_NAME, + CONF_OPENTHREAD, + CONF_OTA, + CONF_USE_ADDRESS, + CONF_WEB_SERVER, + CONF_WIFI, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) +from esphome.core import CORE +from esphome.storage_json import StorageJSON, ext_storage_path +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +# Top-level keys we copy from the raw YAML into the lite config dict. +# Anything outside this list is irrelevant to ``upload`` / ``logs``. +_LITE_TOP_LEVEL_KEYS: tuple[str, ...] = ( + CONF_ESPHOME, + CONF_API, + CONF_LOGGER, + CONF_OTA, + CONF_MQTT, + CONF_WIFI, + CONF_ETHERNET, + CONF_OPENTHREAD, + CONF_WEB_SERVER, +) + + +def _parse_yaml_head( + conf_path: Path, command_line_substitutions: dict[str, Any] | None +) -> dict[str, Any] | None: + """Load and lightly process the YAML, without schema validation. + + Resolves ``!include`` / ``!secret`` (via ``yaml_util``), + substitutions, and ``packages:`` -- the three passes whose output + the upload / logs subcommands need. Returns ``None`` on any error + so the caller can fall back to the full ``read_config()`` path. + """ + from esphome import yaml_util + from esphome.components.packages import resolve_packages + from esphome.components.substitutions import do_substitution_pass + + try: + config = yaml_util.load_yaml(conf_path) + except Exception: # pylint: disable=broad-except + return None + + try: + config = do_substitution_pass(config, command_line_substitutions) + except Exception: # pylint: disable=broad-except + return None + + try: + config = resolve_packages( + config, command_line_substitutions=command_line_substitutions + ) + except Exception: # pylint: disable=broad-except + return None + + return config + + +def _build_lite_config(raw_config: dict[str, Any]) -> ConfigType: + """Project the raw YAML down to the keys upload / logs read.""" + return {key: raw_config[key] for key in _LITE_TOP_LEVEL_KEYS if key in raw_config} + + +def _populate_core_from_storage(storage: StorageJSON) -> None: + """Populate ``CORE`` fields the subcommands read off the platform.""" + CORE.name = storage.name + CORE.friendly_name = storage.friendly_name + CORE.build_path = storage.build_path + CORE.loaded_integrations = set(storage.loaded_integrations) + CORE.loaded_platforms = set(storage.loaded_platforms) + + core_platform = storage.core_platform or ( + storage.target_platform.lower() if storage.target_platform else None + ) + CORE.data.setdefault(KEY_CORE, {}) + if core_platform is not None: + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = core_platform + if storage.framework is not None: + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = storage.framework + + +def _ensure_address_in_config(config: ConfigType, storage: StorageJSON) -> None: + """Make sure ``CORE.address`` resolves once the lite config is set. + + ``CORE.address`` reads ``use_address`` off the wifi / ethernet / + openthread block; if the dashboard rewrote the YAML between the + last compile and now those blocks may be missing from the lite + parse. As a backstop, lift the address from the StorageJSON when + we have one and the YAML doesn't supply one already. + """ + if storage.address is None: + return + for network_type in (CONF_WIFI, CONF_ETHERNET, CONF_OPENTHREAD): + if network_type in config and CONF_USE_ADDRESS in config[network_type]: + return + # Fall back to a synthetic wifi block so ``CORE.address`` resolves. + config.setdefault(CONF_WIFI, {})[CONF_USE_ADDRESS] = storage.address + + +def load_lite_config_from_storage( + conf_path: Path, command_line_substitutions: dict[str, Any] | None = None +) -> ConfigType | None: + """Build a minimal config + populate CORE from the StorageJSON sidecar. + + Returns the lite config dict on success, or ``None`` when the + dispatcher should fall back to ``read_config()`` (sidecar missing, + stale, unreadable, or required fields absent). + """ + storage_path = ext_storage_path(conf_path.name) + try: + yaml_stat = conf_path.stat() + except OSError: + return None + try: + storage_stat = storage_path.stat() + except OSError: + return None + + # The sidecar is written by `compile`; if the YAML has been edited + # since, the cached platform / loaded_integrations may no longer + # describe what the binary on disk was built from. Fall back to a + # full validation pass in that case. + if storage_stat.st_mtime < yaml_stat.st_mtime: + return None + + storage = StorageJSON.load(storage_path) + if storage is None: + return None + if not storage.target_platform and not storage.core_platform: + # An incomplete sidecar (e.g. from a wizard run that never + # compiled) can't drive the upload / logs subcommands. + return None + + raw_config = _parse_yaml_head(conf_path, command_line_substitutions) + if raw_config is None: + return None + if CONF_ESPHOME not in raw_config or CONF_NAME not in raw_config[CONF_ESPHOME]: + return None + + config = _build_lite_config(raw_config) + + # Backfill `esphome:` block fields from the sidecar so downstream + # callers that read `config["esphome"]["name"]` work even when the + # YAML uses substitutions that didn't survive the light parse. + esphome_block = config.setdefault(CONF_ESPHOME, {}) + esphome_block.setdefault(CONF_NAME, storage.name) + if storage.friendly_name is not None: + esphome_block.setdefault(CONF_FRIENDLY_NAME, storage.friendly_name) + + _populate_core_from_storage(storage) + _ensure_address_in_config(config, storage) + + return config diff --git a/tests/unit_tests/test_main_lite_config.py b/tests/unit_tests/test_main_lite_config.py new file mode 100644 index 0000000000..cf0fa3c086 --- /dev/null +++ b/tests/unit_tests/test_main_lite_config.py @@ -0,0 +1,280 @@ +"""Tests for the StorageJSON-backed lite config fast path.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.__main__ import run_esphome +from esphome.const import ( + CONF_API, + CONF_ESPHOME, + CONF_LOGGER, + CONF_NAME, + CONF_OTA, + CONF_USE_ADDRESS, + CONF_WIFI, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) +from esphome.core import CORE +from esphome.lite_config import load_lite_config_from_storage + +# A YAML the lite parser can load. Contains every block ``upload`` / +# ``logs`` cares about (api, logger, ota, wifi) plus a substitution so +# the tests exercise the substitution pass too. +_SAMPLE_YAML = """\ +substitutions: + device_name: lite_test + encryption_key: 6dGhpcyBpcyBhIHRlc3Q= + +esphome: + name: ${device_name} + friendly_name: Lite Test Device + +esp32: + board: nodemcu-32s + +logger: + +api: + encryption: + key: ${encryption_key} + +ota: + - platform: esphome + +wifi: + ssid: "ssid" + password: "password" + use_address: 192.168.1.42 +""" + + +def _write_storage( + storage_path: Path, + *, + name: str = "lite_test", + friendly_name: str | None = "Lite Test Device", + address: str | None = "192.168.1.42", + target_platform: str = "ESP32", + core_platform: str | None = "esp32", + framework: str | None = "arduino", + build_path: str | None = "/build/lite_test", + firmware_bin_path: str | None = "/build/lite_test/firmware.bin", + loaded_integrations: list[str] | None = None, + loaded_platforms: list[str] | None = None, +) -> None: + """Write a StorageJSON sidecar to ``storage_path`` for the tests.""" + storage_path.parent.mkdir(parents=True, exist_ok=True) + data = { + "storage_version": 1, + "name": name, + "friendly_name": friendly_name, + "comment": None, + "esphome_version": "2026.1.0", + "src_version": 1, + "address": address, + "web_port": None, + "esp_platform": target_platform, + "build_path": build_path, + "firmware_bin_path": firmware_bin_path, + "loaded_integrations": loaded_integrations or ["api", "logger", "ota", "wifi"], + "loaded_platforms": loaded_platforms or [], + "no_mdns": False, + "framework": framework, + "core_platform": core_platform, + } + storage_path.write_text(json.dumps(data)) + + +@pytest.fixture +def lite_config_files(tmp_path: Path) -> tuple[Path, Path]: + """Create a YAML + matching StorageJSON for the lite-config tests. + + The YAML lives at ``/lite_test.yaml`` so ``CORE.data_dir`` + points into ``/.esphome``. The storage sidecar is written + after the YAML and then back-dated, so callers can rely on + ``storage.mtime >= yaml.mtime`` matching the production happy + path (compile writes the sidecar last). + """ + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text(_SAMPLE_YAML) + CORE.config_path = yaml_path + + storage_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + _write_storage(storage_path) + # Storage is younger than the YAML by virtue of being created last, + # but make the relationship explicit for tests that depend on it. + yaml_stat = yaml_path.stat() + os.utime(storage_path, (yaml_stat.st_atime, yaml_stat.st_mtime + 5)) + + return yaml_path, storage_path + + +def test_lite_config_happy_path(lite_config_files: tuple[Path, Path]) -> None: + """Storage + YAML in sync: lite config returns a populated dict and CORE.""" + yaml_path, _ = lite_config_files + + config = load_lite_config_from_storage(yaml_path, {}) + + assert config is not None + assert config[CONF_ESPHOME][CONF_NAME] == "lite_test" + assert CONF_LOGGER in config + assert CONF_API in config + assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" + assert CONF_OTA in config + assert config[CONF_OTA][0]["platform"] == "esphome" + # Lite config should NOT contain bulky non-essential keys like esp32. + assert "esp32" not in config + + # CORE populated from storage. + assert CORE.name == "lite_test" + assert CORE.friendly_name == "Lite Test Device" + assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" + assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" + assert CORE.build_path == Path("/build/lite_test") + assert "api" in CORE.loaded_integrations + + +def test_lite_config_missing_storage_returns_none(tmp_path: Path) -> None: + """No StorageJSON sidecar → return None so caller falls back.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text(_SAMPLE_YAML) + CORE.config_path = yaml_path + + assert load_lite_config_from_storage(yaml_path, {}) is None + + +def test_lite_config_stale_storage_returns_none(tmp_path: Path) -> None: + """StorageJSON older than YAML → return None. + + The YAML may have grown a new ``api:`` key since the binary was + built; falling back to ``read_config()`` catches that. + """ + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text(_SAMPLE_YAML) + CORE.config_path = yaml_path + + storage_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + _write_storage(storage_path) + + yaml_stat = yaml_path.stat() + # Storage is one minute older than the YAML. + os.utime(storage_path, (yaml_stat.st_atime, yaml_stat.st_mtime - 60)) + + assert load_lite_config_from_storage(yaml_path, {}) is None + + +def test_lite_config_address_backfilled_from_storage(tmp_path: Path) -> None: + """No wifi/ethernet block in YAML → address comes from storage. + + Some users gate their network blocks behind substitutions that + don't fire in the lite parse. The loader should fall back to the + sidecar's ``address`` so ``CORE.address`` still resolves. + """ + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text( + "esphome:\n name: lite_test\nesp32:\n board: nodemcu-32s\n" + "logger:\napi:\nota:\n - platform: esphome\n" + ) + CORE.config_path = yaml_path + + storage_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + _write_storage(storage_path, address="10.0.0.5") + + yaml_stat = yaml_path.stat() + os.utime(storage_path, (yaml_stat.st_atime, yaml_stat.st_mtime + 5)) + + config = load_lite_config_from_storage(yaml_path, {}) + assert config is not None + assert config[CONF_WIFI][CONF_USE_ADDRESS] == "10.0.0.5" + + +def test_lite_config_corrupt_storage_returns_none(tmp_path: Path) -> None: + """A malformed sidecar (StorageJSON.load returns None) → fall back.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text(_SAMPLE_YAML) + CORE.config_path = yaml_path + + storage_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + storage_path.parent.mkdir(parents=True, exist_ok=True) + storage_path.write_text("not valid json{") + + yaml_stat = yaml_path.stat() + os.utime(storage_path, (yaml_stat.st_atime, yaml_stat.st_mtime + 5)) + + assert load_lite_config_from_storage(yaml_path, {}) is None + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_from_storage_json_skips_read_config( + tmp_path: Path, + command: str, + lite_config_files: tuple[Path, Path], +) -> None: + """`--from-storage-json` makes the dispatcher skip read_config().""" + yaml_path, _ = lite_config_files + + # Stub out the subcommand handler so the test doesn't try to open + # a network connection or run platform-specific upload logic. + with ( + patch("esphome.__main__.read_config") as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {command: lambda args, config: 0}, + ), + ): + result = run_esphome( + ["esphome", command, "--from-storage-json", str(yaml_path)] + ) + + mock_read.assert_not_called() + assert result == 0 + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_from_storage_json_falls_back_when_missing( + tmp_path: Path, command: str +) -> None: + """With no sidecar on disk, the dispatcher falls back to read_config().""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text(_SAMPLE_YAML) + + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {command: lambda args, config: 0}, + ), + ): + result = run_esphome( + ["esphome", command, "--from-storage-json", str(yaml_path)] + ) + + mock_read.assert_called_once() + # read_config returned None → dispatcher exits with 2. + assert result == 2 + + +def test_run_esphome_without_flag_still_calls_read_config( + tmp_path: Path, lite_config_files: tuple[Path, Path] +) -> None: + """Sanity: omitting the flag preserves the current behaviour.""" + yaml_path, _ = lite_config_files + + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "upload", str(yaml_path)]) + + mock_read.assert_called_once() From f54480ec48abaec9218caef3a6b8b8a4086f0e8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 15:20:25 -0500 Subject: [PATCH 1170/2214] [rp2040] Bump arduino-pico framework to 5.6.0 (#16375) --- .clang-tidy.hash | 2 +- esphome/components/rp2040/__init__.py | 6 +++--- esphome/components/rp2040/boards.py | 18 ++++++++++++++++++ platformio.ini | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index da2e863281..77b4f5323f 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -96c95feaa60831da5f43e3c6a7c7a3a237e17c5d12995a730dbc3884c8dcd11c +593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 81809c0551..862d532645 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -139,7 +139,7 @@ def _parse_platform_version(value): # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases # - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 5, 1) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 0) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags @@ -149,8 +149,8 @@ RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460" def _arduino_check_versions(value): value = value.copy() lookups = { - "dev": (cv.Version(5, 5, 1), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(5, 5, 1), None), + "dev": (cv.Version(5, 6, 0), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(5, 6, 0), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2040/boards.py b/esphome/components/rp2040/boards.py index aac12eae5a..1f2b3a93f4 100644 --- a/esphome/components/rp2040/boards.py +++ b/esphome/components/rp2040/boards.py @@ -457,6 +457,19 @@ RP2040_BOARD_PINS = { "SS": 17, "TX": 12, }, + "challenger_2350_nbiot": { + "LED": 15, + "MISO": 16, + "MOSI": 19, + "RX": 13, + "SCK": 18, + "SCL": 21, + "SCL1": 31, + "SDA": 20, + "SDA1": 31, + "SS": 17, + "TX": 12, + }, "challenger_2350_wifi6_ble5": { "LED": 7, "MISO": 16, @@ -1711,6 +1724,11 @@ BOARDS = { "mcu": "rp2350", "max_pin": 47, }, + "challenger_2350_nbiot": { + "name": "iLabs Challenger 2350 NB-IoT", + "mcu": "rp2350", + "max_pin": 47, + }, "challenger_2350_wifi6_ble5": { "name": "iLabs Challenger 2350 WiFi/BLE", "mcu": "rp2350", diff --git a/platformio.ini b/platformio.ini index 42b7400779..8a89f96b39 100644 --- a/platformio.ini +++ b/platformio.ini @@ -193,7 +193,7 @@ board_build.filesystem_size = 0.5m platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460 platform_packages = ; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.5.1/rp2040-5.5.1.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.0/rp2040-5.6.0.zip framework = arduino lib_deps = From ee72efa76094edfd7a1bc28c2e98aba228282437 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 May 2026 16:27:25 -0400 Subject: [PATCH 1171/2214] [sendspin] Fix client_id MAC mismatch with ethernet (#16331) --- esphome/components/sendspin/sendspin_hub.cpp | 19 +++++++++++++++++-- esphome/components/sendspin/sendspin_hub.h | 8 +++++++- .../sendspin/test-ethernet.esp32-idf.yaml | 9 +++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 tests/components/sendspin/test-ethernet.esp32-idf.yaml diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index d27c5672eb..04426b8b1d 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -3,6 +3,9 @@ #ifdef USE_ESP32 #include "esphome/components/network/util.h" +#ifdef USE_ETHERNET +#include "esphome/components/ethernet/ethernet_component.h" +#endif #ifdef USE_WIFI #include "esphome/components/wifi/wifi_component.h" #endif @@ -63,7 +66,7 @@ void SendspinHub::dump_config() { "Sendspin Hub:\n" " Client ID: %s\n" " Task stack in PSRAM: %s", - get_mac_address_pretty_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); + get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); } // --- Delegating methods --- @@ -89,11 +92,23 @@ void SendspinHub::update_state(sendspin::SendspinClientState state) { } } +const char *SendspinHub::get_client_id_into_buffer(std::span buf) { + // The server matches client_id against the L2 source MAC of the device's multicast traffic. + // ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the + // eFuse base MAC when ethernet is the active interface. +#ifdef USE_ETHERNET + if (ethernet::global_eth_component != nullptr) { + return ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf); + } +#endif + return get_mac_address_pretty_into_buffer(buf); +} + sendspin::SendspinClientConfig SendspinHub::build_client_config_() { sendspin::SendspinClientConfig config; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - config.client_id = get_mac_address_pretty_into_buffer(mac_buf); + config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf); config.name = App.get_friendly_name(); config.product_name = App.get_name(); config.manufacturer = "ESPHome"; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 12fbf156ea..c6b1ed97f7 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -35,7 +35,9 @@ namespace esphome::sendspin_ { /// without each subcomponent having to pick a priority independently. Children run /// one step later than hub so they can assume hub's setup() has already completed. namespace sendspin_priority { -inline constexpr float HUB = esphome::setup_priority::PROCESSOR; +// AFTER_WIFI so the hub runs after the wifi/ethernet drivers are up and we can read the active +// interface's MAC for client_id. +inline constexpr float HUB = esphome::setup_priority::AFTER_WIFI; inline constexpr float CHILD = HUB - 1.0f; } // namespace sendspin_priority @@ -149,6 +151,10 @@ class SendspinHub final : public Component, /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); + /// @brief Writes the active network interface's MAC into @p buf and returns its data pointer. + /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). + static const char *get_client_id_into_buffer(std::span buf); + // --- SendspinClientListener overrides --- void on_group_update(const sendspin::GroupUpdateObject &group) override; diff --git a/tests/components/sendspin/test-ethernet.esp32-idf.yaml b/tests/components/sendspin/test-ethernet.esp32-idf.yaml new file mode 100644 index 0000000000..069e397d99 --- /dev/null +++ b/tests/components/sendspin/test-ethernet.esp32-idf.yaml @@ -0,0 +1,9 @@ +ethernet: + type: OPENETH + +psram: + mode: quad + +sendspin: + id: sendspin_hub_id + task_stack_in_psram: true From 6a8a1d525671a042d52aaadc18dc2b5a3576f54e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 15:34:07 -0500 Subject: [PATCH 1172/2214] [core] Cache validated config for --from-storage-json fast path Drop the StorageJSON `compiled_config` field and the `esphome/lite_config.py` module from the first iteration. The earlier shape stored the entire validated config inside the JSON sidecar, but configs can grow past a megabyte once packages and substitutions expand, and JSON can't round-trip the YAML-specific types (lambdas, ID instances, includes) the validation pipeline produces. Instead: dump the validated config to its own YAML file alongside the sidecar (`.validated.yaml`), using `yaml_util.dump` so all esphome-specific tags survive the round trip. The fast path loads it back with `yaml_util.load_yaml(clear_secrets=False)` -- no schema validation, no final-validate, no external-component refresh. Staleness is gated by mtime: if the source YAML mtime is newer than the cache, the dispatcher falls back to a full `read_config()` with a warning. The cache is refreshed on every successful compile via `writer.update_storage_json`, so the dashboard's compile -> upload -> logs flow always sees a fresh cache. Drift surface shrinks to "what does yaml_util.dump emit" rather than "which top-level keys does upload/logs read" -- when those subcommands gain a new dependency, the cache already carries it. --- esphome/__main__.py | 31 +- esphome/lite_config.py | 197 ------------ esphome/storage_json.py | 105 ++++++- esphome/writer.py | 10 +- .../unit_tests/test_main_from_storage_json.py | 264 +++++++++++++++++ tests/unit_tests/test_main_lite_config.py | 280 ------------------ 6 files changed, 399 insertions(+), 488 deletions(-) delete mode 100644 esphome/lite_config.py create mode 100644 tests/unit_tests/test_main_from_storage_json.py delete mode 100644 tests/unit_tests/test_main_lite_config.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 33f1727696..4358cdf1a3 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2445,25 +2445,38 @@ def run_esphome(argv): # `read_config()` pipeline (parse + schema validate + final-validate + # external component refresh) for those two subcommands is dead work and # produces a wall of "Reading configuration ..." log lines for every - # remote install. Source the platform / build metadata from the - # StorageJSON sidecar instead; fall back to full validation if it's - # missing or stale so a cold cache never produces a worse outcome. + # remote install. Reload the validated config that the last compile + # cached alongside the StorageJSON sidecar; fall back to full + # validation if the cache is missing or older than the YAML so a + # cold cache never produces a worse outcome. config = None if getattr(args, "from_storage_json", False) and args.command in ( "upload", "logs", ): - from esphome.lite_config import load_lite_config_from_storage + from esphome.storage_json import ( + StorageJSON, + ext_storage_path, + load_compiled_config, + ) - config = load_lite_config_from_storage(conf_path, command_line_substitutions) + config = load_compiled_config(conf_path) + if config is not None: + storage = StorageJSON.load(ext_storage_path(conf_path.name)) + if storage is None: + config = None + else: + storage.apply_to_core() + _LOGGER.info( + "Loaded validated config cache for %s, skipping validation.", + conf_path.name, + ) if config is None: _LOGGER.warning( - "StorageJSON sidecar missing or stale for %s; falling back to " - "full config validation.", + "Validated config cache for %s is missing or older than the " + "YAML; falling back to full config validation.", conf_path, ) - else: - _LOGGER.info("Loaded device metadata from StorageJSON sidecar.") if config is None: config = read_config( diff --git a/esphome/lite_config.py b/esphome/lite_config.py deleted file mode 100644 index 89816a00c7..0000000000 --- a/esphome/lite_config.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Lite-config loader for ``upload`` / ``logs`` fast paths. - -When the caller (typically a dashboard) already has a known-good -firmware binary on disk and only needs the CLI to ship bytes to a -device or stream logs back, running the full ``read_config()`` -pipeline is dead work. It re-parses every ``!include``, runs every -component's schema validator, executes all final-validate hooks, and -fetches/refreshes external components -- producing a fully validated -``Config`` object that the upload / logs subcommands only consult for a -handful of leaf keys. - -This module loads the per-config ``StorageJSON`` sidecar that was -written by the last successful ``compile`` run, populates ``CORE`` with -the platform / build metadata from it, and re-parses just enough of the -YAML head (substitutions + packages, no schema validation) to recover -``api:`` / ``logger:`` / ``ota:`` / ``mqtt:`` / network blocks for the -subcommand callers. - -If the sidecar is missing or older than the YAML, this returns ``None`` -so the dispatcher can fall back to the full ``read_config()`` path. -That makes the flag a pure optimisation: a cold cache never produces a -worse outcome than today. -""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import Any - -from esphome.const import ( - CONF_API, - CONF_ESPHOME, - CONF_ETHERNET, - CONF_FRIENDLY_NAME, - CONF_LOGGER, - CONF_MQTT, - CONF_NAME, - CONF_OPENTHREAD, - CONF_OTA, - CONF_USE_ADDRESS, - CONF_WEB_SERVER, - CONF_WIFI, - KEY_CORE, - KEY_TARGET_FRAMEWORK, - KEY_TARGET_PLATFORM, -) -from esphome.core import CORE -from esphome.storage_json import StorageJSON, ext_storage_path -from esphome.types import ConfigType - -_LOGGER = logging.getLogger(__name__) - -# Top-level keys we copy from the raw YAML into the lite config dict. -# Anything outside this list is irrelevant to ``upload`` / ``logs``. -_LITE_TOP_LEVEL_KEYS: tuple[str, ...] = ( - CONF_ESPHOME, - CONF_API, - CONF_LOGGER, - CONF_OTA, - CONF_MQTT, - CONF_WIFI, - CONF_ETHERNET, - CONF_OPENTHREAD, - CONF_WEB_SERVER, -) - - -def _parse_yaml_head( - conf_path: Path, command_line_substitutions: dict[str, Any] | None -) -> dict[str, Any] | None: - """Load and lightly process the YAML, without schema validation. - - Resolves ``!include`` / ``!secret`` (via ``yaml_util``), - substitutions, and ``packages:`` -- the three passes whose output - the upload / logs subcommands need. Returns ``None`` on any error - so the caller can fall back to the full ``read_config()`` path. - """ - from esphome import yaml_util - from esphome.components.packages import resolve_packages - from esphome.components.substitutions import do_substitution_pass - - try: - config = yaml_util.load_yaml(conf_path) - except Exception: # pylint: disable=broad-except - return None - - try: - config = do_substitution_pass(config, command_line_substitutions) - except Exception: # pylint: disable=broad-except - return None - - try: - config = resolve_packages( - config, command_line_substitutions=command_line_substitutions - ) - except Exception: # pylint: disable=broad-except - return None - - return config - - -def _build_lite_config(raw_config: dict[str, Any]) -> ConfigType: - """Project the raw YAML down to the keys upload / logs read.""" - return {key: raw_config[key] for key in _LITE_TOP_LEVEL_KEYS if key in raw_config} - - -def _populate_core_from_storage(storage: StorageJSON) -> None: - """Populate ``CORE`` fields the subcommands read off the platform.""" - CORE.name = storage.name - CORE.friendly_name = storage.friendly_name - CORE.build_path = storage.build_path - CORE.loaded_integrations = set(storage.loaded_integrations) - CORE.loaded_platforms = set(storage.loaded_platforms) - - core_platform = storage.core_platform or ( - storage.target_platform.lower() if storage.target_platform else None - ) - CORE.data.setdefault(KEY_CORE, {}) - if core_platform is not None: - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = core_platform - if storage.framework is not None: - CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = storage.framework - - -def _ensure_address_in_config(config: ConfigType, storage: StorageJSON) -> None: - """Make sure ``CORE.address`` resolves once the lite config is set. - - ``CORE.address`` reads ``use_address`` off the wifi / ethernet / - openthread block; if the dashboard rewrote the YAML between the - last compile and now those blocks may be missing from the lite - parse. As a backstop, lift the address from the StorageJSON when - we have one and the YAML doesn't supply one already. - """ - if storage.address is None: - return - for network_type in (CONF_WIFI, CONF_ETHERNET, CONF_OPENTHREAD): - if network_type in config and CONF_USE_ADDRESS in config[network_type]: - return - # Fall back to a synthetic wifi block so ``CORE.address`` resolves. - config.setdefault(CONF_WIFI, {})[CONF_USE_ADDRESS] = storage.address - - -def load_lite_config_from_storage( - conf_path: Path, command_line_substitutions: dict[str, Any] | None = None -) -> ConfigType | None: - """Build a minimal config + populate CORE from the StorageJSON sidecar. - - Returns the lite config dict on success, or ``None`` when the - dispatcher should fall back to ``read_config()`` (sidecar missing, - stale, unreadable, or required fields absent). - """ - storage_path = ext_storage_path(conf_path.name) - try: - yaml_stat = conf_path.stat() - except OSError: - return None - try: - storage_stat = storage_path.stat() - except OSError: - return None - - # The sidecar is written by `compile`; if the YAML has been edited - # since, the cached platform / loaded_integrations may no longer - # describe what the binary on disk was built from. Fall back to a - # full validation pass in that case. - if storage_stat.st_mtime < yaml_stat.st_mtime: - return None - - storage = StorageJSON.load(storage_path) - if storage is None: - return None - if not storage.target_platform and not storage.core_platform: - # An incomplete sidecar (e.g. from a wizard run that never - # compiled) can't drive the upload / logs subcommands. - return None - - raw_config = _parse_yaml_head(conf_path, command_line_substitutions) - if raw_config is None: - return None - if CONF_ESPHOME not in raw_config or CONF_NAME not in raw_config[CONF_ESPHOME]: - return None - - config = _build_lite_config(raw_config) - - # Backfill `esphome:` block fields from the sidecar so downstream - # callers that read `config["esphome"]["name"]` work even when the - # YAML uses substitutions that didn't survive the light parse. - esphome_block = config.setdefault(CONF_ESPHOME, {}) - esphome_block.setdefault(CONF_NAME, storage.name) - if storage.friendly_name is not None: - esphome_block.setdefault(CONF_FRIENDLY_NAME, storage.friendly_name) - - _populate_core_from_storage(storage) - _ensure_address_in_config(config, storage) - - return config diff --git a/esphome/storage_json.py b/esphome/storage_json.py index c6df16ce78..d760297150 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -11,7 +11,7 @@ from esphome import const from esphome.const import CONF_DISABLED, CONF_MDNS from esphome.core import CORE from esphome.helpers import write_file_if_changed -from esphome.types import CoreType +from esphome.types import ConfigType, CoreType _LOGGER = logging.getLogger(__name__) @@ -56,6 +56,82 @@ def archive_storage_path() -> Path: return CORE.relative_config_path("archive") +def compiled_config_path(config_filename: str) -> Path: + """Path to the cached validated config alongside the storage sidecar. + + Written after every successful compile from the validated config + dict (via ``yaml_util.dump``). Powers the dispatcher's + ``--from-storage-json`` fast path in ``esphome upload`` and + ``esphome logs`` so they can skip the full ``read_config()`` + validation pipeline. + + Lives next to the existing JSON sidecar (``.json``) but + in its own file so the small metadata sidecar isn't bloated by + configs that can reach a megabyte or more once packages and + substitutions are expanded. + """ + return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" + + +def save_compiled_config(config: ConfigType) -> None: + """Dump the validated config to its sidecar YAML file. + + Called by the writer at the end of ``compile`` so the next call + to ``esphome upload --from-storage-json`` / ``esphome logs + --from-storage-json`` for this YAML can skip validation. Failures + here are non-fatal: the worst case is that the fast path falls + back to a full ``read_config`` next time. + """ + from esphome import yaml_util + + try: + # show_secrets=True so the cache is self-contained; the file + # lives next to the binary it describes, in the same trust zone + # as the rest of .esphome/storage/. + rendered = yaml_util.dump(config, show_secrets=True) + except Exception as err: # pylint: disable=broad-except + _LOGGER.debug("Skipping compiled config cache write: %s", err) + return + try: + write_file_if_changed(compiled_config_path(CORE.config_filename), rendered) + except OSError as err: + _LOGGER.debug("Skipping compiled config cache write: %s", err) + + +def load_compiled_config(config_path: Path) -> ConfigType | None: + """Load the cached validated config for ``--from-storage-json``. + + Returns ``None`` (so the caller falls back to ``read_config``) if + the cache is missing or older than the source YAML. The mtime + check catches the common "user edited the YAML and forgot to + recompile" case; deeper drift (an edited ``!include`` whose + parent YAML mtime didn't change) is the user's responsibility — + this flag is opt-in and assumes the caller knows the binary on + disk matches the cache. + """ + cache_path = compiled_config_path(config_path.name) + try: + yaml_mtime = config_path.stat().st_mtime + except OSError: + return None + try: + cache_mtime = cache_path.stat().st_mtime + except OSError: + return None + if cache_mtime < yaml_mtime: + return None + + from esphome import yaml_util + + try: + # clear_secrets=False so we don't disturb any in-flight secret + # state; the cache is self-contained and resolves no !secret + # references. + return yaml_util.load_yaml(cache_path, clear_secrets=False) + except Exception: # pylint: disable=broad-except + return None + + 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 @@ -256,6 +332,33 @@ class StorageJSON: except Exception: # pylint: disable=broad-except return None + def apply_to_core(self) -> None: + """Populate ``CORE`` from this sidecar. + + Used by the ``--from-storage-json`` fast path in + ``esphome upload`` / ``esphome logs``: those subcommands read + a handful of ``CORE`` attributes (``target_platform``, + ``build_path``, ``name``, ``loaded_integrations``) that the + normal flow populates during ``read_config``. Lifting them off + the sidecar lets us skip the validation pass entirely. + """ + from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + + CORE.name = self.name + CORE.friendly_name = self.friendly_name + CORE.build_path = self.build_path + CORE.loaded_integrations = set(self.loaded_integrations) + CORE.loaded_platforms = set(self.loaded_platforms) + + core_platform = self.core_platform or ( + self.target_platform.lower() if self.target_platform else None + ) + CORE.data.setdefault(KEY_CORE, {}) + if core_platform is not None: + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = core_platform + if self.framework is not None: + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = self.framework + def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/esphome/writer.py b/esphome/writer.py index 2fa43fa5eb..67df3d1f22 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -24,7 +24,7 @@ from esphome.helpers import ( walk_files, write_file_if_changed, ) -from esphome.storage_json import StorageJSON, storage_path +from esphome.storage_json import StorageJSON, save_compiled_config, storage_path _LOGGER = logging.getLogger(__name__) @@ -109,6 +109,14 @@ def update_storage_json() -> None: path = storage_path() old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) + + # Always refresh the validated-config cache so `esphome upload + # --from-storage-json` and `esphome logs --from-storage-json` can + # skip re-validating after this compile. Lives in its own file + # next to the sidecar; mtime gates staleness on the read side. + if CORE.config is not None: + save_compiled_config(CORE.config) + if old == new: return diff --git a/tests/unit_tests/test_main_from_storage_json.py b/tests/unit_tests/test_main_from_storage_json.py new file mode 100644 index 0000000000..2433511f7f --- /dev/null +++ b/tests/unit_tests/test_main_from_storage_json.py @@ -0,0 +1,264 @@ +"""Tests for the `--from-storage-json` validated-config cache fast path.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.__main__ import run_esphome +from esphome.const import ( + CONF_API, + CONF_ESPHOME, + CONF_NAME, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) +from esphome.core import CORE +from esphome.storage_json import StorageJSON, compiled_config_path, load_compiled_config + +_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 +""" + + +def _write_storage(storage_path: Path) -> None: + """Write a vanilla StorageJSON sidecar for the cache tests.""" + storage_path.parent.mkdir(parents=True, exist_ok=True) + data = { + "storage_version": 1, + "name": "lite_test", + "friendly_name": "Lite Test Device", + "comment": None, + "esphome_version": "2026.1.0", + "src_version": 1, + "address": "192.168.1.42", + "web_port": None, + "esp_platform": "ESP32", + "build_path": "/build/lite_test", + "firmware_bin_path": "/build/lite_test/firmware.bin", + "loaded_integrations": ["api", "logger", "ota", "wifi"], + "loaded_platforms": [], + "no_mdns": False, + "framework": "arduino", + "core_platform": "esp32", + } + storage_path.write_text(json.dumps(data)) + + +@pytest.fixture +def fresh_cache_files(tmp_path: Path) -> Path: + """Set up a YAML + StorageJSON + validated-config cache. + + Cache mtime is bumped 5s past the YAML so the staleness check + treats it as fresh. + """ + 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_path = storage_dir / "lite_test.yaml.validated.yaml" + cache_path.write_text(_VALIDATED_CONFIG_YAML) + yaml_stat = yaml_path.stat() + os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + 5)) + + return yaml_path + + +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 str(path).endswith("storage/device.yaml.validated.yaml") + + +def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: + """Fresh cache → returns the validated config dict.""" + config = load_compiled_config(fresh_cache_files) + + assert config is not None + assert config[CONF_ESPHOME][CONF_NAME] == "lite_test" + assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" + assert config["ota"][0]["password"] == "secret" + + +def test_load_compiled_config_missing_cache(tmp_path: Path) -> None: + """No cache file on disk → None so caller falls back.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + assert load_compiled_config(yaml_path) is None + + +def test_load_compiled_config_stale_cache(tmp_path: Path) -> None: + """Cache older than the YAML → None (the YAML was edited).""" + 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" + storage_dir.mkdir(parents=True, exist_ok=True) + cache_path = storage_dir / "lite_test.yaml.validated.yaml" + cache_path.write_text(_VALIDATED_CONFIG_YAML) + + yaml_stat = yaml_path.stat() + # Cache is one minute older than the YAML — the YAML's been edited + # since the last compile, so the cache no longer describes the + # binary on disk. + os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime - 60)) + + assert load_compiled_config(yaml_path) is None + + +def test_load_compiled_config_corrupt_cache(tmp_path: Path) -> None: + """Cache file is unparseable → None so caller falls back.""" + 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" + storage_dir.mkdir(parents=True, exist_ok=True) + cache_path = storage_dir / "lite_test.yaml.validated.yaml" + cache_path.write_text("not: valid: yaml: [") + + yaml_stat = yaml_path.stat() + os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + 5)) + + assert load_compiled_config(yaml_path) is None + + +def test_storage_json_apply_to_core_populates_target_platform(tmp_path: Path) -> None: + """apply_to_core sets the CORE attributes upload / logs read.""" + storage_path = tmp_path / "lite_test.yaml.json" + _write_storage(storage_path) + storage = StorageJSON.load(storage_path) + assert storage is not None + + storage.apply_to_core() + + assert CORE.name == "lite_test" + assert CORE.friendly_name == "Lite Test Device" + assert CORE.build_path == Path("/build/lite_test") + assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" + assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" + assert "api" in CORE.loaded_integrations + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_from_storage_json_skips_read_config( + command: str, fresh_cache_files: Path +) -> None: + """`--from-storage-json` makes the dispatcher skip read_config().""" + yaml_path = fresh_cache_files + + captured = {} + + def _stub(_args, config): + captured["config"] = config + return 0 + + with ( + patch("esphome.__main__.read_config") as mock_read, + patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}), + ): + result = run_esphome( + ["esphome", command, "--from-storage-json", str(yaml_path)] + ) + + mock_read.assert_not_called() + assert result == 0 + # The dispatcher hands the cached config dict through unchanged. + assert captured["config"][CONF_ESPHOME][CONF_NAME] == "lite_test" + assert captured["config"][CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_from_storage_json_falls_back_when_missing( + tmp_path: Path, command: str +) -> None: + """With no cache on disk, the dispatcher falls back to read_config().""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {command: lambda args, config: 0}, + ), + ): + result = run_esphome( + ["esphome", command, "--from-storage-json", str(yaml_path)] + ) + + mock_read.assert_called_once() + assert result == 2 + + +def test_run_esphome_from_storage_json_falls_back_when_stale( + tmp_path: Path, +) -> None: + """If YAML mtime > cache mtime, dispatcher falls back to read_config().""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json") + + cache_path = storage_dir / "lite_test.yaml.validated.yaml" + cache_path.write_text(_VALIDATED_CONFIG_YAML) + yaml_stat = yaml_path.stat() + os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime - 60)) + + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "upload", "--from-storage-json", str(yaml_path)]) + + mock_read.assert_called_once() + + +def test_run_esphome_without_flag_still_calls_read_config( + fresh_cache_files: Path, +) -> None: + """Sanity: omitting the flag preserves the current behaviour.""" + yaml_path = fresh_cache_files + + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "upload", str(yaml_path)]) + + mock_read.assert_called_once() diff --git a/tests/unit_tests/test_main_lite_config.py b/tests/unit_tests/test_main_lite_config.py deleted file mode 100644 index cf0fa3c086..0000000000 --- a/tests/unit_tests/test_main_lite_config.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Tests for the StorageJSON-backed lite config fast path.""" - -from __future__ import annotations - -import json -import os -from pathlib import Path -from unittest.mock import patch - -import pytest - -from esphome.__main__ import run_esphome -from esphome.const import ( - CONF_API, - CONF_ESPHOME, - CONF_LOGGER, - CONF_NAME, - CONF_OTA, - CONF_USE_ADDRESS, - CONF_WIFI, - KEY_CORE, - KEY_TARGET_FRAMEWORK, - KEY_TARGET_PLATFORM, -) -from esphome.core import CORE -from esphome.lite_config import load_lite_config_from_storage - -# A YAML the lite parser can load. Contains every block ``upload`` / -# ``logs`` cares about (api, logger, ota, wifi) plus a substitution so -# the tests exercise the substitution pass too. -_SAMPLE_YAML = """\ -substitutions: - device_name: lite_test - encryption_key: 6dGhpcyBpcyBhIHRlc3Q= - -esphome: - name: ${device_name} - friendly_name: Lite Test Device - -esp32: - board: nodemcu-32s - -logger: - -api: - encryption: - key: ${encryption_key} - -ota: - - platform: esphome - -wifi: - ssid: "ssid" - password: "password" - use_address: 192.168.1.42 -""" - - -def _write_storage( - storage_path: Path, - *, - name: str = "lite_test", - friendly_name: str | None = "Lite Test Device", - address: str | None = "192.168.1.42", - target_platform: str = "ESP32", - core_platform: str | None = "esp32", - framework: str | None = "arduino", - build_path: str | None = "/build/lite_test", - firmware_bin_path: str | None = "/build/lite_test/firmware.bin", - loaded_integrations: list[str] | None = None, - loaded_platforms: list[str] | None = None, -) -> None: - """Write a StorageJSON sidecar to ``storage_path`` for the tests.""" - storage_path.parent.mkdir(parents=True, exist_ok=True) - data = { - "storage_version": 1, - "name": name, - "friendly_name": friendly_name, - "comment": None, - "esphome_version": "2026.1.0", - "src_version": 1, - "address": address, - "web_port": None, - "esp_platform": target_platform, - "build_path": build_path, - "firmware_bin_path": firmware_bin_path, - "loaded_integrations": loaded_integrations or ["api", "logger", "ota", "wifi"], - "loaded_platforms": loaded_platforms or [], - "no_mdns": False, - "framework": framework, - "core_platform": core_platform, - } - storage_path.write_text(json.dumps(data)) - - -@pytest.fixture -def lite_config_files(tmp_path: Path) -> tuple[Path, Path]: - """Create a YAML + matching StorageJSON for the lite-config tests. - - The YAML lives at ``/lite_test.yaml`` so ``CORE.data_dir`` - points into ``/.esphome``. The storage sidecar is written - after the YAML and then back-dated, so callers can rely on - ``storage.mtime >= yaml.mtime`` matching the production happy - path (compile writes the sidecar last). - """ - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text(_SAMPLE_YAML) - CORE.config_path = yaml_path - - storage_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" - _write_storage(storage_path) - # Storage is younger than the YAML by virtue of being created last, - # but make the relationship explicit for tests that depend on it. - yaml_stat = yaml_path.stat() - os.utime(storage_path, (yaml_stat.st_atime, yaml_stat.st_mtime + 5)) - - return yaml_path, storage_path - - -def test_lite_config_happy_path(lite_config_files: tuple[Path, Path]) -> None: - """Storage + YAML in sync: lite config returns a populated dict and CORE.""" - yaml_path, _ = lite_config_files - - config = load_lite_config_from_storage(yaml_path, {}) - - assert config is not None - assert config[CONF_ESPHOME][CONF_NAME] == "lite_test" - assert CONF_LOGGER in config - assert CONF_API in config - assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" - assert CONF_OTA in config - assert config[CONF_OTA][0]["platform"] == "esphome" - # Lite config should NOT contain bulky non-essential keys like esp32. - assert "esp32" not in config - - # CORE populated from storage. - assert CORE.name == "lite_test" - assert CORE.friendly_name == "Lite Test Device" - assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" - assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" - assert CORE.build_path == Path("/build/lite_test") - assert "api" in CORE.loaded_integrations - - -def test_lite_config_missing_storage_returns_none(tmp_path: Path) -> None: - """No StorageJSON sidecar → return None so caller falls back.""" - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text(_SAMPLE_YAML) - CORE.config_path = yaml_path - - assert load_lite_config_from_storage(yaml_path, {}) is None - - -def test_lite_config_stale_storage_returns_none(tmp_path: Path) -> None: - """StorageJSON older than YAML → return None. - - The YAML may have grown a new ``api:`` key since the binary was - built; falling back to ``read_config()`` catches that. - """ - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text(_SAMPLE_YAML) - CORE.config_path = yaml_path - - storage_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" - _write_storage(storage_path) - - yaml_stat = yaml_path.stat() - # Storage is one minute older than the YAML. - os.utime(storage_path, (yaml_stat.st_atime, yaml_stat.st_mtime - 60)) - - assert load_lite_config_from_storage(yaml_path, {}) is None - - -def test_lite_config_address_backfilled_from_storage(tmp_path: Path) -> None: - """No wifi/ethernet block in YAML → address comes from storage. - - Some users gate their network blocks behind substitutions that - don't fire in the lite parse. The loader should fall back to the - sidecar's ``address`` so ``CORE.address`` still resolves. - """ - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text( - "esphome:\n name: lite_test\nesp32:\n board: nodemcu-32s\n" - "logger:\napi:\nota:\n - platform: esphome\n" - ) - CORE.config_path = yaml_path - - storage_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" - _write_storage(storage_path, address="10.0.0.5") - - yaml_stat = yaml_path.stat() - os.utime(storage_path, (yaml_stat.st_atime, yaml_stat.st_mtime + 5)) - - config = load_lite_config_from_storage(yaml_path, {}) - assert config is not None - assert config[CONF_WIFI][CONF_USE_ADDRESS] == "10.0.0.5" - - -def test_lite_config_corrupt_storage_returns_none(tmp_path: Path) -> None: - """A malformed sidecar (StorageJSON.load returns None) → fall back.""" - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text(_SAMPLE_YAML) - CORE.config_path = yaml_path - - storage_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" - storage_path.parent.mkdir(parents=True, exist_ok=True) - storage_path.write_text("not valid json{") - - yaml_stat = yaml_path.stat() - os.utime(storage_path, (yaml_stat.st_atime, yaml_stat.st_mtime + 5)) - - assert load_lite_config_from_storage(yaml_path, {}) is None - - -@pytest.mark.parametrize("command", ["upload", "logs"]) -def test_run_esphome_from_storage_json_skips_read_config( - tmp_path: Path, - command: str, - lite_config_files: tuple[Path, Path], -) -> None: - """`--from-storage-json` makes the dispatcher skip read_config().""" - yaml_path, _ = lite_config_files - - # Stub out the subcommand handler so the test doesn't try to open - # a network connection or run platform-specific upload logic. - with ( - patch("esphome.__main__.read_config") as mock_read, - patch.dict( - "esphome.__main__.POST_CONFIG_ACTIONS", - {command: lambda args, config: 0}, - ), - ): - result = run_esphome( - ["esphome", command, "--from-storage-json", str(yaml_path)] - ) - - mock_read.assert_not_called() - assert result == 0 - - -@pytest.mark.parametrize("command", ["upload", "logs"]) -def test_run_esphome_from_storage_json_falls_back_when_missing( - tmp_path: Path, command: str -) -> None: - """With no sidecar on disk, the dispatcher falls back to read_config().""" - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text(_SAMPLE_YAML) - - with ( - patch("esphome.__main__.read_config", return_value=None) as mock_read, - patch.dict( - "esphome.__main__.POST_CONFIG_ACTIONS", - {command: lambda args, config: 0}, - ), - ): - result = run_esphome( - ["esphome", command, "--from-storage-json", str(yaml_path)] - ) - - mock_read.assert_called_once() - # read_config returned None → dispatcher exits with 2. - assert result == 2 - - -def test_run_esphome_without_flag_still_calls_read_config( - tmp_path: Path, lite_config_files: tuple[Path, Path] -) -> None: - """Sanity: omitting the flag preserves the current behaviour.""" - yaml_path, _ = lite_config_files - - with ( - patch("esphome.__main__.read_config", return_value=None) as mock_read, - patch.dict( - "esphome.__main__.POST_CONFIG_ACTIONS", - {"upload": lambda args, config: 0}, - ), - ): - run_esphome(["esphome", "upload", str(yaml_path)]) - - mock_read.assert_called_once() From 26d2d4f65d2a8c83e5dfaac0fb58a7c9394b486d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 15:38:57 -0500 Subject: [PATCH 1173/2214] [core] Make validated-config cache automatic, no flag Drop the `--from-storage-json` CLI flag. `esphome upload` and `esphome logs` now always try the validated-config cache and transparently fall back to `read_config()` when it's missing, stale (YAML mtime > cache mtime), or corrupt. No caller change is required to benefit, and a cold cache never produces a worse outcome than today. `compile` continues to write the cache unconditionally so the next upload / logs against this YAML can skip validation; it itself always re-validates since code generation needs the fully validated config. --- esphome/__main__.py | 59 +++++-------------- esphome/writer.py | 8 +-- ...py => test_main_validated_config_cache.py} | 37 ++++++------ 3 files changed, 35 insertions(+), 69 deletions(-) rename tests/unit_tests/{test_main_from_storage_json.py => test_main_validated_config_cache.py} (89%) diff --git a/esphome/__main__.py b/esphome/__main__.py index 4358cdf1a3..435931cd28 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2117,16 +2117,6 @@ def parse_args(argv): help="Upload as bootloader (OTA).", action="store_true", ) - parser_upload.add_argument( - "--from-storage-json", - action="store_true", - help=( - "Skip YAML schema validation by loading device metadata from " - "the .esphome/storage/.json sidecar produced by the last " - "successful compile. Falls back to a full validation pass when " - "the sidecar is missing or older than the YAML." - ), - ) parser_logs = subparsers.add_parser( "logs", @@ -2154,16 +2144,6 @@ def parse_args(argv): action="store_true", help="Do not show entity state changes in log output.", ) - parser_logs.add_argument( - "--from-storage-json", - action="store_true", - help=( - "Skip YAML schema validation by loading device metadata from " - "the .esphome/storage/.json sidecar produced by the last " - "successful compile. Falls back to a full validation pass when " - "the sidecar is missing or older than the YAML." - ), - ) parser_discover = subparsers.add_parser( "discover", @@ -2439,44 +2419,33 @@ def run_esphome(argv): skip_external = args.command in ("logs", "clean") command_line_substitutions = dict(args.substitution) if args.substitution else {} - # Fast path for `upload --from-storage-json` and `logs --from-storage-json`: - # the caller already has a binary on disk and only needs the CLI to ship - # bytes to a device or stream logs back. Re-running the full - # `read_config()` pipeline (parse + schema validate + final-validate + - # external component refresh) for those two subcommands is dead work and - # produces a wall of "Reading configuration ..." log lines for every - # remote install. Reload the validated config that the last compile - # cached alongside the StorageJSON sidecar; fall back to full - # validation if the cache is missing or older than the YAML so a - # cold cache never produces a worse outcome. + # Fast path for `upload` and `logs`: the caller already has a binary + # on disk and only needs the CLI to ship bytes to a device or stream + # logs back. Re-running the full `read_config()` pipeline (parse + + # schema validate + final-validate + external component refresh) is + # dead work and produces a wall of "Reading configuration ..." log + # lines on every install. Reload the validated config the last + # compile cached alongside the StorageJSON sidecar; fall back to + # full validation if the cache is missing or older than the YAML so + # a cold cache never produces a worse outcome. config = None - if getattr(args, "from_storage_json", False) and args.command in ( - "upload", - "logs", - ): + if args.command in ("upload", "logs"): from esphome.storage_json import ( StorageJSON, ext_storage_path, load_compiled_config, ) - config = load_compiled_config(conf_path) - if config is not None: + cached = load_compiled_config(conf_path) + if cached is not None: storage = StorageJSON.load(ext_storage_path(conf_path.name)) - if storage is None: - config = None - else: + if storage is not None: storage.apply_to_core() + config = cached _LOGGER.info( "Loaded validated config cache for %s, skipping validation.", conf_path.name, ) - if config is None: - _LOGGER.warning( - "Validated config cache for %s is missing or older than the " - "YAML; falling back to full config validation.", - conf_path, - ) if config is None: config = read_config( diff --git a/esphome/writer.py b/esphome/writer.py index 67df3d1f22..8efefe68ba 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -110,10 +110,10 @@ def update_storage_json() -> None: old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) - # Always refresh the validated-config cache so `esphome upload - # --from-storage-json` and `esphome logs --from-storage-json` can - # skip re-validating after this compile. Lives in its own file - # next to the sidecar; mtime gates staleness on the read side. + # Refresh the validated-config cache. `esphome upload` and + # `esphome logs` look for it next time they run and skip the full + # validation pipeline when it's present and fresh (mtime >= YAML + # mtime). Cheap to write, big win to read. if CORE.config is not None: save_compiled_config(CORE.config) diff --git a/tests/unit_tests/test_main_from_storage_json.py b/tests/unit_tests/test_main_validated_config_cache.py similarity index 89% rename from tests/unit_tests/test_main_from_storage_json.py rename to tests/unit_tests/test_main_validated_config_cache.py index 2433511f7f..a5ecbe59c9 100644 --- a/tests/unit_tests/test_main_from_storage_json.py +++ b/tests/unit_tests/test_main_validated_config_cache.py @@ -1,4 +1,4 @@ -"""Tests for the `--from-storage-json` validated-config cache fast path.""" +"""Tests for the validated-config cache fast path used by upload/logs.""" from __future__ import annotations @@ -169,10 +169,10 @@ def test_storage_json_apply_to_core_populates_target_platform(tmp_path: Path) -> @pytest.mark.parametrize("command", ["upload", "logs"]) -def test_run_esphome_from_storage_json_skips_read_config( +def test_run_esphome_upload_and_logs_use_cache_when_fresh( command: str, fresh_cache_files: Path ) -> None: - """`--from-storage-json` makes the dispatcher skip read_config().""" + """When the cache is fresh, upload/logs skip read_config() entirely.""" yaml_path = fresh_cache_files captured = {} @@ -185,9 +185,7 @@ def test_run_esphome_from_storage_json_skips_read_config( patch("esphome.__main__.read_config") as mock_read, patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}), ): - result = run_esphome( - ["esphome", command, "--from-storage-json", str(yaml_path)] - ) + result = run_esphome(["esphome", command, str(yaml_path)]) mock_read.assert_not_called() assert result == 0 @@ -197,7 +195,7 @@ def test_run_esphome_from_storage_json_skips_read_config( @pytest.mark.parametrize("command", ["upload", "logs"]) -def test_run_esphome_from_storage_json_falls_back_when_missing( +def test_run_esphome_upload_and_logs_fall_back_when_no_cache( tmp_path: Path, command: str ) -> None: """With no cache on disk, the dispatcher falls back to read_config().""" @@ -211,17 +209,13 @@ def test_run_esphome_from_storage_json_falls_back_when_missing( {command: lambda args, config: 0}, ), ): - result = run_esphome( - ["esphome", command, "--from-storage-json", str(yaml_path)] - ) + result = run_esphome(["esphome", command, str(yaml_path)]) mock_read.assert_called_once() assert result == 2 -def test_run_esphome_from_storage_json_falls_back_when_stale( - tmp_path: Path, -) -> None: +def test_run_esphome_upload_falls_back_when_cache_stale(tmp_path: Path) -> None: """If YAML mtime > cache mtime, dispatcher falls back to read_config().""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") @@ -241,24 +235,27 @@ def test_run_esphome_from_storage_json_falls_back_when_stale( {"upload": lambda args, config: 0}, ), ): - run_esphome(["esphome", "upload", "--from-storage-json", str(yaml_path)]) + run_esphome(["esphome", "upload", str(yaml_path)]) mock_read.assert_called_once() -def test_run_esphome_without_flag_still_calls_read_config( - fresh_cache_files: Path, -) -> None: - """Sanity: omitting the flag preserves the current behaviour.""" +def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None: + """`compile` always re-validates, even with a fresh cache on disk. + + The fast path is only for upload / logs -- compile is what writes + the cache in the first place, and it needs a fully validated + config to drive code generation. + """ yaml_path = fresh_cache_files with ( patch("esphome.__main__.read_config", return_value=None) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", - {"upload": lambda args, config: 0}, + {"compile": lambda args, config: 0}, ), ): - run_esphome(["esphome", "upload", str(yaml_path)]) + run_esphome(["esphome", "compile", str(yaml_path)]) mock_read.assert_called_once() From 66e4a1dfa831ca497028f3cc2a7bddcc6865c740 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Tue, 12 May 2026 22:39:21 +0200 Subject: [PATCH 1174/2214] [mitsubishi_cn105] Add C++ API for setting/clearing remote room temperature (#15558) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../components/mitsubishi_cn105/climate.py | 71 ++++++++- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 91 ++++++++--- .../mitsubishi_cn105/mitsubishi_cn105.h | 33 ++-- .../mitsubishi_cn105_climate.h | 22 ++- .../climate/mitsubishi_cn105_tests.cpp | 147 ++++++++++++++++++ tests/components/mitsubishi_cn105/common.h | 3 + tests/components/mitsubishi_cn105/common.yaml | 10 ++ 7 files changed, 338 insertions(+), 39 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 7fa6825ea6..cc44494d89 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,8 +1,11 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import climate, uart import esphome.config_validation as cv -from esphome.const import CONF_UPDATE_INTERVAL -from esphome.types import ConfigType +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 DEPENDENCIES = ["uart"] AUTO_LOAD = ["climate"] @@ -19,6 +22,18 @@ MitsubishiCN105Climate = mitsubishi_ns.class_( uart.UARTDevice, ) +SetRemoteTemperatureAction = mitsubishi_ns.class_( + "SetRemoteTemperatureAction", + automation.Action, + cg.Parented.template(MitsubishiCN105Climate), +) + +ClearRemoteTemperatureAction = mitsubishi_ns.class_( + "ClearRemoteTemperatureAction", + automation.Action, + cg.Parented.template(MitsubishiCN105Climate), +) + CONFIG_SCHEMA = ( climate.climate_schema(MitsubishiCN105Climate) .extend(uart.UART_DEVICE_SCHEMA) @@ -53,3 +68,55 @@ async def to_code(config: ConfigType) -> None: 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), + ) + ), + } + ), + synchronous=True, +) +async def set_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( + "climate.mitsubishi_cn105.clear_remote_temperature", + ClearRemoteTemperatureAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), + } + ), + synchronous=True, +) +async def clear_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]) + return var diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index f04a5906c1..2a173997f3 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -92,6 +93,7 @@ void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); } bool MitsubishiCN105::update() { if (const auto start = this->status_update_start_ms_) { if (this->pending_updates_.any()) { + this->status_update_wait_credit_ms_ = std::min(this->update_interval_ms_, get_loop_time_ms() - *start); this->cancel_waiting_and_transition_to_(State::APPLYING_SETTINGS); return false; } @@ -105,6 +107,7 @@ bool MitsubishiCN105::update() { if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) { this->write_timeout_start_ms_.reset(); this->frame_parser_.reset(); + this->status_update_wait_credit_ms_ = 0; this->set_state_(State::READ_TIMEOUT); return false; } @@ -191,14 +194,14 @@ void MitsubishiCN105::did_transition_(State to) { } case State::SCHEDULE_NEXT_STATUS_UPDATE: - this->status_update_start_ms_ = get_loop_time_ms(); + this->status_update_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_; + this->status_update_wait_credit_ms_ = 0; this->current_status_msg_type_ = STATUS_MSG_SETTINGS; this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); break; case State::APPLYING_SETTINGS: this->apply_settings_(); - this->pending_updates_.clear(); break; case State::SETTINGS_APPLIED: @@ -309,21 +312,21 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) return false; } - if (!this->pending_updates_.has(UpdateFlag::POWER)) { + 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_.has(UpdateFlag::TEMPERATURE)) { + 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_.has(UpdateFlag::MODE)) { + if (!this->pending_updates_.contains(UpdateFlag::MODE)) { const bool i_see = payload[3] > 0x08; this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN); } - if (!this->pending_updates_.has(UpdateFlag::FAN)) { + if (!this->pending_updates_.contains(UpdateFlag::FAN)) { this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN); } @@ -342,6 +345,27 @@ bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, siz return true; } +void MitsubishiCN105::set_remote_temperature(float temperature) { + if (std::isnan(temperature)) { + ESP_LOGD(TAG, "Ignoring NaN remote temperature"); + return; + } + if (temperature < 8.0f || temperature > 39.5f) { + ESP_LOGD(TAG, "Ignoring out-of-range remote temperature: %.1f", temperature); + return; + } + this->set_remote_temperature_half_deg_(static_cast(std::round(temperature * 2.0f))); +} + +void MitsubishiCN105::clear_remote_temperature() { + this->set_remote_temperature_half_deg_(REMOTE_TEMPERATURE_DISABLED); +} + +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); +} + void MitsubishiCN105::set_power(bool power_on) { this->status_.power_on = power_on; this->pending_updates_.set(UpdateFlag::POWER); @@ -377,30 +401,47 @@ void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { } void MitsubishiCN105::apply_settings_() { - std::array payload = {0x01}; + std::array payload{}; - if (this->pending_updates_.has(UpdateFlag::POWER)) { - payload[1] |= 0x01; - payload[3] = this->status_.power_on ? 0x01 : 0x00; - } - - if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) { - payload[1] |= 0x04; - if (this->use_temperature_encoding_b_) { - payload[14] = static_cast(std::round(this->status_.target_temperature * 2.0f) + 128); + // 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[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature)); + 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); + } 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_.has(UpdateFlag::MODE) && - reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) { - payload[1] |= 0x02; - } + 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_.has(UpdateFlag::FAN) && - reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) { - payload[1] |= 0x08; + if (this->pending_updates_.contains(UpdateFlag::MODE) && + reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) { + payload[1] |= 0x02; + } + + if (this->pending_updates_.contains(UpdateFlag::FAN) && + reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) { + payload[1] |= 0x08; + } + + this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN); } 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 68d98bf6d9..52b78efccd 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -2,6 +2,7 @@ #include #include "esphome/components/uart/uart.h" +#include "esphome/core/finite_set_mask.h" namespace esphome::mitsubishi_cn105 { @@ -60,6 +61,8 @@ class MitsubishiCN105 { void set_target_temperature(float target_temperature); void set_mode(Mode mode); void set_fan_mode(FanMode fan_mode); + void set_remote_temperature(float temperature); + void clear_remote_temperature(); protected: enum class State : uint8_t { @@ -91,20 +94,25 @@ class MitsubishiCN105 { }; enum class UpdateFlag : uint8_t { - TEMPERATURE = 1 << 0, - POWER = 1 << 1, - MODE = 1 << 2, - FAN = 1 << 3, + TEMPERATURE = 0, + POWER = 1, + MODE = 2, + FAN = 3, + REMOTE_TEMPERATURE = 4, }; struct UpdateFlags { - void set(UpdateFlag f) { flags_ |= static_cast(f); } - void clear() { flags_ = 0; } - bool any() const { return flags_ != 0; } - bool has(UpdateFlag f) const { return (flags_ & static_cast(f)) != 0; } + template void set(Flags... flags) { (this->mask_.insert(flags), ...); } + template void clear(Flags... flags) { (this->mask_.erase(flags), ...); } + 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(); } protected: - uint8_t flags_{0}; + using Mask = + FiniteSetMask(UpdateFlag::REMOTE_TEMPERATURE) + 1>>; + + Mask mask_; }; void set_state_(State new_state); @@ -119,12 +127,14 @@ class MitsubishiCN105 { void cancel_waiting_and_transition_to_(State state); bool should_request_room_temperature_() const; void apply_settings_(); + 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); uart::UARTDevice &device_; uint32_t update_interval_ms_{1000}; + uint32_t status_update_wait_credit_ms_{0}; uint32_t room_temperature_min_interval_ms_{60000}; std::optional write_timeout_start_ms_; std::optional status_update_start_ms_; @@ -133,8 +143,11 @@ class MitsubishiCN105 { State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; bool use_temperature_encoding_b_{false}; - uint8_t current_status_msg_type_{0}; FrameParser frame_parser_; + uint8_t current_status_msg_type_{0}; + + static constexpr uint8_t REMOTE_TEMPERATURE_DISABLED = 0; + uint8_t remote_temperature_half_deg_{REMOTE_TEMPERATURE_DISABLED}; }; } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index eee4c20966..e09158bfcf 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/climate/climate.h" #include "esphome/components/uart/uart.h" @@ -18,8 +19,11 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public climate::ClimateTraits traits() override; void control(const climate::ClimateCall &call) override; - void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); } - void set_current_temperature_min_interval(uint32_t ms) { hp_.set_room_temperature_min_interval(ms); } + 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(); } protected: void apply_values_(); @@ -27,4 +31,18 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public MitsubishiCN105 hp_; }; +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/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 86faaeac78..7615b62d03 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -375,14 +375,22 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { auto ctx = TestContext{}; + ctx.sut.set_update_interval(2000); + ctx.sut.set_current_time(5000); + // Waiting for next scheduled status update ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{5000}); + EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Nothing to do in update (rx empty, no timeout) + ctx.sut.set_current_time(5500); ASSERT_FALSE(ctx.sut.update()); EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{5000}); + EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Write new values ctx.sut.use_temperature_encoding_b_ = true; @@ -392,11 +400,52 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO); // Waiting for next status update must be interrupted and new values send to AC + ctx.sut.set_current_time(6000); ASSERT_FALSE(ctx.sut.update()); + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); 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)); + // Write ACK response + ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); + ctx.sut.set_current_time(6500); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{6500 - 1000}); + EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); +} + +TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { + auto ctx = TestContext{}; + + // Set remote temperature + ctx.sut.set_remote_temperature(28.5f); + + ctx.sut.state_ = TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE; + ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x01, 0x29, 0xB9, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x94)); + + // Write ACK response + ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + + ctx.uart.tx.clear(); + + // Clear remote temperature + ctx.sut.clear_remote_temperature(); + + ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x00, 0x00, 0x80, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF7)); + // Write ACK response ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); @@ -404,4 +453,102 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); } +TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { + auto ctx = TestContext{}; + + // Queue normal settings plus remote temperature together. + ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.set_power(false); + ctx.sut.set_target_temperature(25.0f); + ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); + ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO); + ctx.sut.set_remote_temperature(28.5f); + + // First apply sends only the normal settings write. + ctx.sut.state_ = TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE; + ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS); + + 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)); + + // ACK the first write. Remote temperature should still be pending afterward. + ctx.uart.tx.clear(); + ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 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)); + + // The next apply sends the remote-temperature packet and clears the last pending flag. + ctx.uart.tx.clear(); + ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x01, 0x29, 0xB9, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x94)); + EXPECT_FALSE(ctx.sut.pending_updates_.any()); +} + +TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) { + auto ctx = TestContext{}; + ctx.sut.set_update_interval(2000); + ctx.sut.set_current_time(5000); + + // Start in the scheduled status update wait state. + ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; + ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); + ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + ASSERT_EQ(ctx.sut.status_update_start_ms_, std::optional{5000}); + 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.set_power(false); + ctx.sut.set_target_temperature(25.0f); + ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); + ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO); + ctx.sut.set_current_time(6000); + ASSERT_FALSE(ctx.sut.update()); + ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); + ASSERT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000); + + // Do not ACK the write. Advance time far enough to force timeout/reconnect + // handling and verify that stale wait credit is cleared during recovery. + ctx.sut.set_current_time(36000); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_NE(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); + EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); +} + +TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { + auto ctx = TestContext{}; + + ctx.sut.set_remote_temperature(7.0f); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + + ctx.sut.set_remote_temperature(40.0f); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + + ctx.sut.set_remote_temperature(NAN); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); +} + +TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) { + auto ctx = TestContext{}; + ctx.sut.set_remote_temperature(8.0f); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); +} + +TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) { + auto ctx = TestContext{}; + ctx.sut.set_remote_temperature(39.5f); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); +} + } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 0862d64fa7..d0fdca1ea5 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -42,10 +42,13 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { public: using MitsubishiCN105::MitsubishiCN105; using MitsubishiCN105::State; + using MitsubishiCN105::UpdateFlag; using MitsubishiCN105::state_; using MitsubishiCN105::write_timeout_start_ms_; using MitsubishiCN105::status_update_start_ms_; using MitsubishiCN105::use_temperature_encoding_b_; + using MitsubishiCN105::status_update_wait_credit_ms_; + using MitsubishiCN105::pending_updates_; void set_state(State s) { this->set_state_(s); } void apply_settings() { this->apply_settings_(); } diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index e885ceef81..4b64f51261 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -1,4 +1,14 @@ climate: - platform: mitsubishi_cn105 + id: ac name: "AC Test" uart_id: uart_bus + +esphome: + on_boot: + then: + - climate.mitsubishi_cn105.set_remote_temperature: + id: ac + temperature: 22.0 + - climate.mitsubishi_cn105.clear_remote_temperature: + id: ac From b512cc42a8cd04492049b87e23bfedb0062e5987 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 May 2026 16:42:44 -0400 Subject: [PATCH 1175/2214] [sendspin] Updates sendspin-cpp to v0.5.0 (#16380) --- esphome/components/sendspin/__init__.py | 7 +++++-- .../sendspin/media_source/sendspin_media_source.cpp | 8 -------- .../sendspin/media_source/sendspin_media_source.h | 3 --- esphome/idf_component.yml | 2 +- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 1348bf2bfc..35280020ba 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,12 +206,15 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.4.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0") cg.add_define("USE_SENDSPIN", True) # for MDNS data = _get_data() + # The color role is not yet wired up in ESPHome; disable it in the library for now. + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False) + # Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*) # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). if data.artwork_support: @@ -264,7 +267,7 @@ async def to_code(config: ConfigType) -> None: # Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not # starved by the HTTP server during the initial encoded-audio burst at stream start), - # interpolation/decode buffer locations PREFER_EXTERNAL. + # decode buffer location PREFER_EXTERNAL. player_struct_fields = [ ("audio_formats", audio_format_structs), ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index 0fdfb01c55..88ff234e83 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -188,14 +188,6 @@ void SendspinMediaSource::on_stream_end() { } } -// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) -void SendspinMediaSource::on_stream_clear() { - if (this->get_state() != media_source::MediaSourceState::IDLE) { - // Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes - this->set_state_(media_source::MediaSourceState::IDLE); - } -} - // THREAD CONTEXT: Main loop (PlayerRoleListener callback) void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); } diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h index 3b31716127..843578783e 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.h +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -49,9 +49,6 @@ class SendspinMediaSource : public SendspinChild, /// @brief Called when the audio stream ends (main loop thread). void on_stream_end() override; - /// @brief Called when the audio stream is cleared (main loop thread). - void on_stream_clear() override; - /// @brief Called when volume changes (main loop thread). void on_volume_changed(uint8_t volume) override; diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 45aaa827c8..814a4031c1 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.4.0 + version: 0.5.0 lvgl/lvgl: version: 9.5.0 From 76d34334250ea1539bea23d3d2ce15adb29652d3 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Tue, 12 May 2026 14:43:48 -0600 Subject: [PATCH 1176/2214] [cli] Add config-hash command (#15548) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/__main__.py | 17 +++++++++++++++++ tests/unit_tests/test_main.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index 561391708e..bca8672917 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1415,6 +1415,15 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: return 0 +def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None: + # generating code might modify config, so it must be done in order to generate + # a hash that will match what was generated when compiling and then running + # on the device + generate_cpp_contents(config) + safe_print(f"0x{CORE.config_hash:08x}") + return 0 + + def command_vscode(args: ArgsProtocol) -> int | None: from esphome import vscode @@ -1950,6 +1959,7 @@ PRE_CONFIG_ACTIONS = { POST_CONFIG_ACTIONS = { "config": command_config, + "config-hash": command_config_hash, "compile": command_compile, "upload": command_upload, "logs": command_logs, @@ -2063,6 +2073,13 @@ def parse_args(argv): "--show-secrets", help="Show secrets in output.", action="store_true" ) + parser_config_hash = subparsers.add_parser( + "config-hash", help="Calculate the hash of the configuration." + ) + parser_config_hash.add_argument( + "configuration", help="Your YAML configuration file(s).", nargs="+" + ) + parser_compile = subparsers.add_parser( "compile", help="Read the configuration and compile a program." ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 0104854e1f..6ec0069b3a 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -29,6 +29,7 @@ from esphome.__main__ import ( command_analyze_memory, command_bundle, command_clean_all, + command_config_hash, command_rename, command_run, command_update_all, @@ -3439,6 +3440,33 @@ def test_command_wizard(tmp_path: Path) -> None: mock_wizard.assert_called_once_with(config_file) +def test_command_config_hash( + tmp_path: Path, + capfd: CaptureFixture[str], +) -> None: + """command_config_hash runs codegen then prints CORE.config_hash. + + The printed format must match `0x{config_hash:08x}` used by + generate_build_info_data_cpp so the value can be compared byte-for-byte + against the ESPHOME_CONFIG_HASH embedded in firmware. + """ + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + + # generate_cpp_contents requires real components to be loaded; mock it out + # so this test isolates the command's output contract. The command must + # still call it (codegen can mutate config, which affects the hash). + with patch("esphome.__main__.generate_cpp_contents") as mock_generate: + result = command_config_hash(args, CORE.config) + + assert result == 0 + mock_generate.assert_called_once_with(CORE.config) + + output = strip_ansi_codes(capfd.readouterr().out).strip() + assert re.fullmatch(r"0x[0-9a-f]{8}", output) + assert output == f"0x{CORE.config_hash:08x}" + + def test_command_rename_invalid_characters( tmp_path: Path, capfd: CaptureFixture[str] ) -> None: From aee2fc5761b93bf0c4e3c6b72914f0c21b11b02c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 15:45:47 -0500 Subject: [PATCH 1177/2214] [core] Extract cache helpers into esphome/compiled_config.py storage_json.py is for the JSON sidecar; the validated-config cache is YAML. Move it out into its own module. Also consolidate the dispatcher's open-coded fast path (load cache + load sidecar + apply_to_core) into a single load_compiled_config() entry point, and collapse save_compiled_config()'s two try blocks into one. mtime comparison gets its own _cache_is_fresh() helper so the loader reads top to bottom. --- esphome/__main__.py | 38 ++-- esphome/compiled_config.py | 96 ++++++++++ esphome/storage_json.py | 78 +------- esphome/writer.py | 3 +- ...onfig_cache.py => test_compiled_config.py} | 179 ++++++------------ 5 files changed, 176 insertions(+), 218 deletions(-) create mode 100644 esphome/compiled_config.py rename tests/unit_tests/{test_main_validated_config_cache.py => test_compiled_config.py} (51%) diff --git a/esphome/__main__.py b/esphome/__main__.py index 435931cd28..d68dcffaf2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2419,33 +2419,23 @@ def run_esphome(argv): skip_external = args.command in ("logs", "clean") command_line_substitutions = dict(args.substitution) if args.substitution else {} - # Fast path for `upload` and `logs`: the caller already has a binary - # on disk and only needs the CLI to ship bytes to a device or stream - # logs back. Re-running the full `read_config()` pipeline (parse + - # schema validate + final-validate + external component refresh) is - # dead work and produces a wall of "Reading configuration ..." log - # lines on every install. Reload the validated config the last - # compile cached alongside the StorageJSON sidecar; fall back to - # full validation if the cache is missing or older than the YAML so - # a cold cache never produces a worse outcome. + # Fast path for `upload` and `logs`: reuse the validated config the + # last compile cached on disk. Skips parse + schema validate + + # final-validate + external-component refresh, which is dead work + # for those subcommands -- the binary on disk is already known good + # and the caller already knows the device address. Falls back to a + # full `read_config()` when the cache is missing/stale/corrupt so a + # cold cache never produces a worse outcome than today. config = None if args.command in ("upload", "logs"): - from esphome.storage_json import ( - StorageJSON, - ext_storage_path, - load_compiled_config, - ) + from esphome.compiled_config import load_compiled_config - cached = load_compiled_config(conf_path) - if cached is not None: - storage = StorageJSON.load(ext_storage_path(conf_path.name)) - if storage is not None: - storage.apply_to_core() - config = cached - _LOGGER.info( - "Loaded validated config cache for %s, skipping validation.", - conf_path.name, - ) + config = load_compiled_config(conf_path) + if config is not None: + _LOGGER.info( + "Loaded validated config cache for %s, skipping validation.", + conf_path.name, + ) if config is None: config = read_config( diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py new file mode 100644 index 0000000000..bb127a0de9 --- /dev/null +++ b/esphome/compiled_config.py @@ -0,0 +1,96 @@ +"""Validated-config cache for the ``upload`` / ``logs`` fast path. + +After every successful ``esphome compile``, the writer dumps the +validated config to ``/storage/.validated.yaml``. +The next ``esphome upload`` / ``esphome logs`` for that YAML reuses +the cache instead of re-running the full ``read_config()`` pipeline +(parse + schema validate + final-validate + external-component +refresh), which is dead work once the binary on disk is known good. + +The cache uses YAML (via ``yaml_util``) rather than JSON so all +esphome-specific tags (``!lambda``, ``!include``, ``ID`` instances, +paths) round-trip cleanly, and so the small ``StorageJSON`` sidecar +isn't bloated by configs that can grow past a megabyte. Staleness +is gated by an mtime check against the source YAML; the loader +falls back to ``None`` (and the caller to ``read_config()``) whenever +the cache is missing, stale, unparseable, or the companion +``StorageJSON`` sidecar can't be loaded. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from esphome.core import CORE +from esphome.helpers import write_file_if_changed +from esphome.storage_json import StorageJSON, ext_storage_path +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + + +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 + + +def save_compiled_config(config: ConfigType) -> None: + """Dump the validated config to its sidecar YAML file. + + Called from the writer after every successful compile so the next + ``esphome upload`` / ``esphome logs`` for this YAML can skip + validation via :func:`load_compiled_config`. Failures here are + non-fatal: the worst case is that the fast path falls back to a + full ``read_config`` next time. + """ + from esphome import yaml_util + + try: + # show_secrets=True keeps the cache self-contained; it lives + # in the same trust zone as the rest of .esphome/storage/. + rendered = yaml_util.dump(config, show_secrets=True) + write_file_if_changed(compiled_config_path(CORE.config_filename), rendered) + except Exception as err: # pylint: disable=broad-except + _LOGGER.debug("Skipping compiled config cache write: %s", err) + + +def load_compiled_config(conf_path: Path) -> ConfigType | None: + """Load the cached validated config and apply storage metadata to CORE. + + Single entry point for the ``upload`` / ``logs`` fast path: loads + the cache, applies the ``StorageJSON`` sidecar's platform / build + metadata to ``CORE``, and returns the config dict. Returns + ``None`` (so the caller falls back to ``read_config``) when the + cache is missing, older than the source YAML, unparseable, or + when the sidecar can't be loaded. The mtime check catches the + common "user edited the YAML and forgot to recompile" case; + deeper drift (an edited ``!include`` whose parent YAML mtime + didn't change) is the user's responsibility. + """ + 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: + # clear_secrets=False keeps in-flight secret state intact; the + # cache is self-contained and resolves no !secret references. + config = yaml_util.load_yaml(cache_path, clear_secrets=False) + except Exception: # pylint: disable=broad-except + return None + + storage = StorageJSON.load(ext_storage_path(conf_path.name)) + if storage is None: + return None + storage.apply_to_core() + return config diff --git a/esphome/storage_json.py b/esphome/storage_json.py index d760297150..a9a83f5896 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -11,7 +11,7 @@ from esphome import const from esphome.const import CONF_DISABLED, CONF_MDNS from esphome.core import CORE from esphome.helpers import write_file_if_changed -from esphome.types import ConfigType, CoreType +from esphome.types import CoreType _LOGGER = logging.getLogger(__name__) @@ -56,82 +56,6 @@ def archive_storage_path() -> Path: return CORE.relative_config_path("archive") -def compiled_config_path(config_filename: str) -> Path: - """Path to the cached validated config alongside the storage sidecar. - - Written after every successful compile from the validated config - dict (via ``yaml_util.dump``). Powers the dispatcher's - ``--from-storage-json`` fast path in ``esphome upload`` and - ``esphome logs`` so they can skip the full ``read_config()`` - validation pipeline. - - Lives next to the existing JSON sidecar (``.json``) but - in its own file so the small metadata sidecar isn't bloated by - configs that can reach a megabyte or more once packages and - substitutions are expanded. - """ - return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" - - -def save_compiled_config(config: ConfigType) -> None: - """Dump the validated config to its sidecar YAML file. - - Called by the writer at the end of ``compile`` so the next call - to ``esphome upload --from-storage-json`` / ``esphome logs - --from-storage-json`` for this YAML can skip validation. Failures - here are non-fatal: the worst case is that the fast path falls - back to a full ``read_config`` next time. - """ - from esphome import yaml_util - - try: - # show_secrets=True so the cache is self-contained; the file - # lives next to the binary it describes, in the same trust zone - # as the rest of .esphome/storage/. - rendered = yaml_util.dump(config, show_secrets=True) - except Exception as err: # pylint: disable=broad-except - _LOGGER.debug("Skipping compiled config cache write: %s", err) - return - try: - write_file_if_changed(compiled_config_path(CORE.config_filename), rendered) - except OSError as err: - _LOGGER.debug("Skipping compiled config cache write: %s", err) - - -def load_compiled_config(config_path: Path) -> ConfigType | None: - """Load the cached validated config for ``--from-storage-json``. - - Returns ``None`` (so the caller falls back to ``read_config``) if - the cache is missing or older than the source YAML. The mtime - check catches the common "user edited the YAML and forgot to - recompile" case; deeper drift (an edited ``!include`` whose - parent YAML mtime didn't change) is the user's responsibility — - this flag is opt-in and assumes the caller knows the binary on - disk matches the cache. - """ - cache_path = compiled_config_path(config_path.name) - try: - yaml_mtime = config_path.stat().st_mtime - except OSError: - return None - try: - cache_mtime = cache_path.stat().st_mtime - except OSError: - return None - if cache_mtime < yaml_mtime: - return None - - from esphome import yaml_util - - try: - # clear_secrets=False so we don't disturb any in-flight secret - # state; the cache is self-contained and resolves no !secret - # references. - return yaml_util.load_yaml(cache_path, clear_secrets=False) - except Exception: # pylint: disable=broad-except - return None - - 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 diff --git a/esphome/writer.py b/esphome/writer.py index 8efefe68ba..54eb254b1b 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -7,6 +7,7 @@ import re import time from esphome import loader +from esphome.compiled_config import save_compiled_config from esphome.config import iter_component_configs, iter_components from esphome.const import ( HEADER_FILE_EXTENSIONS, @@ -24,7 +25,7 @@ from esphome.helpers import ( walk_files, write_file_if_changed, ) -from esphome.storage_json import StorageJSON, save_compiled_config, storage_path +from esphome.storage_json import StorageJSON, storage_path _LOGGER = logging.getLogger(__name__) diff --git a/tests/unit_tests/test_main_validated_config_cache.py b/tests/unit_tests/test_compiled_config.py similarity index 51% rename from tests/unit_tests/test_main_validated_config_cache.py rename to tests/unit_tests/test_compiled_config.py index a5ecbe59c9..d4736f38a3 100644 --- a/tests/unit_tests/test_main_validated_config_cache.py +++ b/tests/unit_tests/test_compiled_config.py @@ -1,4 +1,4 @@ -"""Tests for the validated-config cache fast path used by upload/logs.""" +"""Tests for the validated-config cache used by upload/logs.""" from __future__ import annotations @@ -10,6 +10,7 @@ from unittest.mock import patch import pytest from esphome.__main__ import run_esphome +from esphome.compiled_config import compiled_config_path, load_compiled_config from esphome.const import ( CONF_API, CONF_ESPHOME, @@ -19,7 +20,6 @@ from esphome.const import ( KEY_TARGET_PLATFORM, ) from esphome.core import CORE -from esphome.storage_json import StorageJSON, compiled_config_path, load_compiled_config _VALIDATED_CONFIG_YAML = """\ esphome: @@ -67,36 +67,46 @@ def _write_storage(storage_path: Path) -> None: storage_path.write_text(json.dumps(data)) +def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: + """Write the cache file and return it.""" + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(body) + return cache_path + + +def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None: + """Force the cache file's mtime relative to the source YAML. + + Positive offset → cache is fresh. Negative → cache is stale. + """ + yaml_stat = yaml_path.stat() + os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + offset)) + + @pytest.fixture def fresh_cache_files(tmp_path: Path) -> Path: - """Set up a YAML + StorageJSON + validated-config cache. - - Cache mtime is bumped 5s past the YAML so the staleness check - treats it as fresh. - """ + """YAML + StorageJSON + cache, all consistent and fresh.""" 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_path = storage_dir / "lite_test.yaml.validated.yaml" - cache_path.write_text(_VALIDATED_CONFIG_YAML) - yaml_stat = yaml_path.stat() - os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + 5)) + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) return yaml_path 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 str(path).endswith("storage/device.yaml.validated.yaml") + assert str(compiled_config_path("device.yaml")).endswith( + "storage/device.yaml.validated.yaml" + ) def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: - """Fresh cache → returns the validated config dict.""" + """Fresh cache + sidecar → returns config and populates CORE.""" config = load_compiled_config(fresh_cache_files) assert config is not None @@ -104,78 +114,50 @@ 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" - -def test_load_compiled_config_missing_cache(tmp_path: Path) -> None: - """No cache file on disk → None so caller falls back.""" - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text("esphome:\n name: lite_test\n") - CORE.config_path = yaml_path - - assert load_compiled_config(yaml_path) is None - - -def test_load_compiled_config_stale_cache(tmp_path: Path) -> None: - """Cache older than the YAML → None (the YAML was edited).""" - 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" - storage_dir.mkdir(parents=True, exist_ok=True) - cache_path = storage_dir / "lite_test.yaml.validated.yaml" - cache_path.write_text(_VALIDATED_CONFIG_YAML) - - yaml_stat = yaml_path.stat() - # Cache is one minute older than the YAML — the YAML's been edited - # since the last compile, so the cache no longer describes the - # binary on disk. - os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime - 60)) - - assert load_compiled_config(yaml_path) is None - - -def test_load_compiled_config_corrupt_cache(tmp_path: Path) -> None: - """Cache file is unparseable → None so caller falls back.""" - 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" - storage_dir.mkdir(parents=True, exist_ok=True) - cache_path = storage_dir / "lite_test.yaml.validated.yaml" - cache_path.write_text("not: valid: yaml: [") - - yaml_stat = yaml_path.stat() - os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + 5)) - - assert load_compiled_config(yaml_path) is None - - -def test_storage_json_apply_to_core_populates_target_platform(tmp_path: Path) -> None: - """apply_to_core sets the CORE attributes upload / logs read.""" - storage_path = tmp_path / "lite_test.yaml.json" - _write_storage(storage_path) - storage = StorageJSON.load(storage_path) - assert storage is not None - - storage.apply_to_core() - + # apply_to_core ran as part of the orchestration. assert CORE.name == "lite_test" - assert CORE.friendly_name == "Lite Test Device" assert CORE.build_path == Path("/build/lite_test") assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" assert "api" in CORE.loaded_integrations +@pytest.mark.parametrize( + "scenario", + ["missing_cache", "stale_cache", "corrupt_cache", "missing_sidecar"], +) +def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: + """All non-happy cases return None so the caller falls back.""" + 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" + cache_path = storage_dir / "lite_test.yaml.validated.yaml" + sidecar_path = storage_dir / "lite_test.yaml.json" + + if scenario == "missing_cache": + pass # no cache, no sidecar + elif scenario == "stale_cache": + _write_storage(sidecar_path) + _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=-60) + elif scenario == "corrupt_cache": + _write_storage(sidecar_path) + _set_cache_mtime( + _write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5 + ) + elif scenario == "missing_sidecar": + # Cache fresh + parseable, but no StorageJSON → can't populate CORE. + _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is None + + @pytest.mark.parametrize("command", ["upload", "logs"]) def test_run_esphome_upload_and_logs_use_cache_when_fresh( command: str, fresh_cache_files: Path ) -> None: - """When the cache is fresh, upload/logs skip read_config() entirely.""" - yaml_path = fresh_cache_files - - captured = {} + """upload/logs skip read_config() when the cache is fresh.""" + captured: dict = {} def _stub(_args, config): captured["config"] = config @@ -185,11 +167,9 @@ def test_run_esphome_upload_and_logs_use_cache_when_fresh( patch("esphome.__main__.read_config") as mock_read, patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}), ): - result = run_esphome(["esphome", command, str(yaml_path)]) + assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0 mock_read.assert_not_called() - assert result == 0 - # The dispatcher hands the cached config dict through unchanged. assert captured["config"][CONF_ESPHOME][CONF_NAME] == "lite_test" assert captured["config"][CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" @@ -198,7 +178,7 @@ def test_run_esphome_upload_and_logs_use_cache_when_fresh( def test_run_esphome_upload_and_logs_fall_back_when_no_cache( tmp_path: Path, command: str ) -> None: - """With no cache on disk, the dispatcher falls back to read_config().""" + """Without a cache, the dispatcher falls back to read_config().""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") @@ -209,46 +189,13 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( {command: lambda args, config: 0}, ), ): - result = run_esphome(["esphome", command, str(yaml_path)]) - - mock_read.assert_called_once() - assert result == 2 - - -def test_run_esphome_upload_falls_back_when_cache_stale(tmp_path: Path) -> None: - """If YAML mtime > cache mtime, dispatcher falls back to read_config().""" - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text("esphome:\n name: lite_test\n") - - storage_dir = tmp_path / ".esphome" / "storage" - _write_storage(storage_dir / "lite_test.yaml.json") - - cache_path = storage_dir / "lite_test.yaml.validated.yaml" - cache_path.write_text(_VALIDATED_CONFIG_YAML) - yaml_stat = yaml_path.stat() - os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime - 60)) - - with ( - patch("esphome.__main__.read_config", return_value=None) as mock_read, - patch.dict( - "esphome.__main__.POST_CONFIG_ACTIONS", - {"upload": lambda args, config: 0}, - ), - ): - run_esphome(["esphome", "upload", str(yaml_path)]) + assert run_esphome(["esphome", command, str(yaml_path)]) == 2 mock_read.assert_called_once() def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None: - """`compile` always re-validates, even with a fresh cache on disk. - - The fast path is only for upload / logs -- compile is what writes - the cache in the first place, and it needs a fully validated - config to drive code generation. - """ - yaml_path = fresh_cache_files - + """compile always re-validates -- it's what writes the cache.""" with ( patch("esphome.__main__.read_config", return_value=None) as mock_read, patch.dict( @@ -256,6 +203,6 @@ def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None {"compile": lambda args, config: 0}, ), ): - run_esphome(["esphome", "compile", str(yaml_path)]) + run_esphome(["esphome", "compile", str(fresh_cache_files)]) mock_read.assert_called_once() From cc6245b87675b1156b3a3f4bbf45d0165b604edd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 15:53:46 -0500 Subject: [PATCH 1178/2214] [core] Derive CORE state from cached config dict, drop apply_to_core `StorageJSON.apply_to_core` was the inverse of `from_esphome_core`: two enumerations of the same fields that had to stay in sync. When a new CORE attribute joined the validation pipeline, both lists needed an update. Replace it with `_populate_core_from_validated_config(config)` in `compiled_config.py` that reads the cached config dict directly -- same source `read_config` writes into during validation, no separate sidecar schema. The fast path no longer touches StorageJSON at all; the sidecar stays in place for the dashboard's existing use. Drift surface shrinks to: name/friendly_name/build_path under the esphome block (config schema is stable) + target_platform discovered via `_is_target_platform` (the canonical detector) + framework via the `.framework.type` convention every platform component already follows. --- esphome/compiled_config.py | 81 ++++++++++++++++++++---- esphome/storage_json.py | 27 -------- tests/unit_tests/test_compiled_config.py | 61 ++++++------------ 3 files changed, 85 insertions(+), 84 deletions(-) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index bb127a0de9..b0c8f56319 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -13,8 +13,7 @@ paths) round-trip cleanly, and so the small ``StorageJSON`` sidecar isn't bloated by configs that can grow past a megabyte. Staleness is gated by an mtime check against the source YAML; the loader falls back to ``None`` (and the caller to ``read_config()``) whenever -the cache is missing, stale, unparseable, or the companion -``StorageJSON`` sidecar can't be loaded. +the cache is missing, stale, unparseable, or structurally incomplete. """ from __future__ import annotations @@ -22,14 +21,63 @@ from __future__ import annotations import logging from pathlib import Path +from esphome.const import ( + CONF_BUILD_PATH, + CONF_ESPHOME, + CONF_FRAMEWORK, + CONF_FRIENDLY_NAME, + CONF_NAME, + CONF_TYPE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) from esphome.core import CORE from esphome.helpers import write_file_if_changed -from esphome.storage_json import StorageJSON, ext_storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) +def _populate_core_from_validated_config(config: ConfigType) -> None: + """Set up ``CORE`` from an already-validated config dict. + + Reads from the same config keys ``preload_core_config`` and the + target-platform component validator would write into during a full + ``read_config`` -- using the cached config dict as the canonical + source instead of duplicating the field list in a separate sidecar + schema. ``CORE.address`` and ``CORE.web_port`` aren't set here: + they're already properties that derive themselves from + ``CORE.config`` on access, which the dispatcher assigns next. + """ + from esphome.core.config import _is_target_platform + + esphome_block = config[CONF_ESPHOME] + CORE.name = esphome_block[CONF_NAME] + CORE.friendly_name = esphome_block.get(CONF_FRIENDLY_NAME) + if (build_path := esphome_block.get(CONF_BUILD_PATH)) is not None: + CORE.build_path = CORE.data_dir / build_path + + CORE.data.setdefault(KEY_CORE, {}) + for domain, sub in config.items(): + if not isinstance(domain, str) or not _is_target_platform(domain): + continue + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = domain + # Every platform component follows the + # `.framework.type` convention to express which + # framework the binary was built against (esp-idf, arduino, + # zephyr). A platform without a framework block (host) just + # leaves KEY_TARGET_FRAMEWORK unset, matching what + # `read_config` would do. + if ( + isinstance(sub, dict) + and isinstance((framework := sub.get(CONF_FRAMEWORK)), dict) + and (framework_type := framework.get(CONF_TYPE)) is not None + ): + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework_type + break + + 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" @@ -64,17 +112,19 @@ def save_compiled_config(config: ConfigType) -> None: def load_compiled_config(conf_path: Path) -> ConfigType | None: - """Load the cached validated config and apply storage metadata to CORE. + """Load the cached validated config and set up ``CORE``. - Single entry point for the ``upload`` / ``logs`` fast path: loads - the cache, applies the ``StorageJSON`` sidecar's platform / build - metadata to ``CORE``, and returns the config dict. Returns + Single entry point for the ``upload`` / ``logs`` fast path. Returns ``None`` (so the caller falls back to ``read_config``) when the cache is missing, older than the source YAML, unparseable, or - when the sidecar can't be loaded. The mtime check catches the - common "user edited the YAML and forgot to recompile" case; - deeper drift (an edited ``!include`` whose parent YAML mtime - didn't change) is the user's responsibility. + structurally incomplete. The mtime check catches the common "user + edited the YAML and forgot to recompile" case; deeper drift (an + edited ``!include`` whose parent YAML mtime didn't change) is the + user's responsibility. + + CORE state is derived from the cached config dict directly -- + same source ``read_config`` uses -- so there's no separate sidecar + schema to keep in sync. """ cache_path = compiled_config_path(conf_path.name) if not _cache_is_fresh(cache_path, conf_path): @@ -89,8 +139,11 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: except Exception: # pylint: disable=broad-except return None - storage = StorageJSON.load(ext_storage_path(conf_path.name)) - if storage is None: + if not isinstance(config, dict) or CONF_ESPHOME not in config: + return None + + try: + _populate_core_from_validated_config(config) + except (KeyError, TypeError): return None - storage.apply_to_core() return config diff --git a/esphome/storage_json.py b/esphome/storage_json.py index a9a83f5896..c6df16ce78 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -256,33 +256,6 @@ class StorageJSON: except Exception: # pylint: disable=broad-except return None - def apply_to_core(self) -> None: - """Populate ``CORE`` from this sidecar. - - Used by the ``--from-storage-json`` fast path in - ``esphome upload`` / ``esphome logs``: those subcommands read - a handful of ``CORE`` attributes (``target_platform``, - ``build_path``, ``name``, ``loaded_integrations``) that the - normal flow populates during ``read_config``. Lifting them off - the sidecar lets us skip the validation pass entirely. - """ - from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM - - CORE.name = self.name - CORE.friendly_name = self.friendly_name - CORE.build_path = self.build_path - CORE.loaded_integrations = set(self.loaded_integrations) - CORE.loaded_platforms = set(self.loaded_platforms) - - core_platform = self.core_platform or ( - self.target_platform.lower() if self.target_platform else None - ) - CORE.data.setdefault(KEY_CORE, {}) - if core_platform is not None: - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = core_platform - if self.framework is not None: - CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = self.framework - def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index d4736f38a3..4efba4e0c7 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import os from pathlib import Path from unittest.mock import patch @@ -25,8 +24,11 @@ _VALIDATED_CONFIG_YAML = """\ esphome: name: lite_test friendly_name: Lite Test Device + build_path: build/lite_test esp32: board: nodemcu-32s + framework: + type: arduino logger: baud_rate: 115200 api: @@ -43,30 +45,6 @@ wifi: """ -def _write_storage(storage_path: Path) -> None: - """Write a vanilla StorageJSON sidecar for the cache tests.""" - storage_path.parent.mkdir(parents=True, exist_ok=True) - data = { - "storage_version": 1, - "name": "lite_test", - "friendly_name": "Lite Test Device", - "comment": None, - "esphome_version": "2026.1.0", - "src_version": 1, - "address": "192.168.1.42", - "web_port": None, - "esp_platform": "ESP32", - "build_path": "/build/lite_test", - "firmware_bin_path": "/build/lite_test/firmware.bin", - "loaded_integrations": ["api", "logger", "ota", "wifi"], - "loaded_platforms": [], - "no_mdns": False, - "framework": "arduino", - "core_platform": "esp32", - } - storage_path.write_text(json.dumps(data)) - - def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: """Write the cache file and return it.""" cache_path.parent.mkdir(parents=True, exist_ok=True) @@ -85,14 +63,14 @@ 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.""" + """YAML + cache, both consistent and fresh.""" 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") + cache = _write_cache( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + ) _set_cache_mtime(cache, yaml_path, offset=5) return yaml_path @@ -106,7 +84,7 @@ def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None: def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: - """Fresh cache + sidecar → returns config and populates CORE.""" + """Fresh cache → returns the config and populates CORE from it.""" config = load_compiled_config(fresh_cache_files) assert config is not None @@ -114,40 +92,37 @@ 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" - # apply_to_core ran as part of the orchestration. + # CORE state derives from the cached config dict -- same source + # `read_config` uses, no separate sidecar schema. assert CORE.name == "lite_test" - assert CORE.build_path == Path("/build/lite_test") + assert CORE.friendly_name == "Lite Test Device" + assert CORE.build_path == CORE.data_dir / "build/lite_test" assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" - assert "api" in CORE.loaded_integrations @pytest.mark.parametrize( "scenario", - ["missing_cache", "stale_cache", "corrupt_cache", "missing_sidecar"], + ["missing_cache", "stale_cache", "corrupt_cache", "missing_esphome_block"], ) def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: """All non-happy cases return None so the caller falls back.""" 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" - cache_path = storage_dir / "lite_test.yaml.validated.yaml" - sidecar_path = storage_dir / "lite_test.yaml.json" + cache_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" if scenario == "missing_cache": - pass # no cache, no sidecar + pass # no cache elif scenario == "stale_cache": - _write_storage(sidecar_path) _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=-60) elif scenario == "corrupt_cache": - _write_storage(sidecar_path) _set_cache_mtime( _write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5 ) - elif scenario == "missing_sidecar": - # Cache fresh + parseable, but no StorageJSON → can't populate CORE. - _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=5) + elif scenario == "missing_esphome_block": + # Parseable YAML but no esphome: block -- can't populate CORE. + _set_cache_mtime(_write_cache(cache_path, "logger:\n"), yaml_path, offset=5) assert load_compiled_config(yaml_path) is None From 5709ade5775459fca510892cc3751526673b92e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 15:54:23 -0500 Subject: [PATCH 1179/2214] Revert "[core] Derive CORE state from cached config dict, drop apply_to_core" This reverts commit cc6245b87675b1156b3a3f4bbf45d0165b604edd. --- esphome/compiled_config.py | 81 ++++-------------------- esphome/storage_json.py | 27 ++++++++ tests/unit_tests/test_compiled_config.py | 61 ++++++++++++------ 3 files changed, 84 insertions(+), 85 deletions(-) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index b0c8f56319..bb127a0de9 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -13,7 +13,8 @@ paths) round-trip cleanly, and so the small ``StorageJSON`` sidecar isn't bloated by configs that can grow past a megabyte. Staleness is gated by an mtime check against the source YAML; the loader falls back to ``None`` (and the caller to ``read_config()``) whenever -the cache is missing, stale, unparseable, or structurally incomplete. +the cache is missing, stale, unparseable, or the companion +``StorageJSON`` sidecar can't be loaded. """ from __future__ import annotations @@ -21,63 +22,14 @@ from __future__ import annotations import logging from pathlib import Path -from esphome.const import ( - CONF_BUILD_PATH, - CONF_ESPHOME, - CONF_FRAMEWORK, - CONF_FRIENDLY_NAME, - CONF_NAME, - CONF_TYPE, - KEY_CORE, - KEY_TARGET_FRAMEWORK, - KEY_TARGET_PLATFORM, -) from esphome.core import CORE from esphome.helpers import write_file_if_changed +from esphome.storage_json import StorageJSON, ext_storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) -def _populate_core_from_validated_config(config: ConfigType) -> None: - """Set up ``CORE`` from an already-validated config dict. - - Reads from the same config keys ``preload_core_config`` and the - target-platform component validator would write into during a full - ``read_config`` -- using the cached config dict as the canonical - source instead of duplicating the field list in a separate sidecar - schema. ``CORE.address`` and ``CORE.web_port`` aren't set here: - they're already properties that derive themselves from - ``CORE.config`` on access, which the dispatcher assigns next. - """ - from esphome.core.config import _is_target_platform - - esphome_block = config[CONF_ESPHOME] - CORE.name = esphome_block[CONF_NAME] - CORE.friendly_name = esphome_block.get(CONF_FRIENDLY_NAME) - if (build_path := esphome_block.get(CONF_BUILD_PATH)) is not None: - CORE.build_path = CORE.data_dir / build_path - - CORE.data.setdefault(KEY_CORE, {}) - for domain, sub in config.items(): - if not isinstance(domain, str) or not _is_target_platform(domain): - continue - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = domain - # Every platform component follows the - # `.framework.type` convention to express which - # framework the binary was built against (esp-idf, arduino, - # zephyr). A platform without a framework block (host) just - # leaves KEY_TARGET_FRAMEWORK unset, matching what - # `read_config` would do. - if ( - isinstance(sub, dict) - and isinstance((framework := sub.get(CONF_FRAMEWORK)), dict) - and (framework_type := framework.get(CONF_TYPE)) is not None - ): - CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework_type - break - - 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" @@ -112,19 +64,17 @@ def save_compiled_config(config: ConfigType) -> None: def load_compiled_config(conf_path: Path) -> ConfigType | None: - """Load the cached validated config and set up ``CORE``. + """Load the cached validated config and apply storage metadata to CORE. - Single entry point for the ``upload`` / ``logs`` fast path. Returns + Single entry point for the ``upload`` / ``logs`` fast path: loads + the cache, applies the ``StorageJSON`` sidecar's platform / build + metadata to ``CORE``, and returns the config dict. Returns ``None`` (so the caller falls back to ``read_config``) when the cache is missing, older than the source YAML, unparseable, or - structurally incomplete. The mtime check catches the common "user - edited the YAML and forgot to recompile" case; deeper drift (an - edited ``!include`` whose parent YAML mtime didn't change) is the - user's responsibility. - - CORE state is derived from the cached config dict directly -- - same source ``read_config`` uses -- so there's no separate sidecar - schema to keep in sync. + when the sidecar can't be loaded. The mtime check catches the + common "user edited the YAML and forgot to recompile" case; + deeper drift (an edited ``!include`` whose parent YAML mtime + didn't change) is the user's responsibility. """ cache_path = compiled_config_path(conf_path.name) if not _cache_is_fresh(cache_path, conf_path): @@ -139,11 +89,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: except Exception: # pylint: disable=broad-except return None - if not isinstance(config, dict) or CONF_ESPHOME not in config: - return None - - try: - _populate_core_from_validated_config(config) - except (KeyError, TypeError): + storage = StorageJSON.load(ext_storage_path(conf_path.name)) + if storage is None: return None + storage.apply_to_core() return config diff --git a/esphome/storage_json.py b/esphome/storage_json.py index c6df16ce78..a9a83f5896 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -256,6 +256,33 @@ class StorageJSON: except Exception: # pylint: disable=broad-except return None + def apply_to_core(self) -> None: + """Populate ``CORE`` from this sidecar. + + Used by the ``--from-storage-json`` fast path in + ``esphome upload`` / ``esphome logs``: those subcommands read + a handful of ``CORE`` attributes (``target_platform``, + ``build_path``, ``name``, ``loaded_integrations``) that the + normal flow populates during ``read_config``. Lifting them off + the sidecar lets us skip the validation pass entirely. + """ + from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + + CORE.name = self.name + CORE.friendly_name = self.friendly_name + CORE.build_path = self.build_path + CORE.loaded_integrations = set(self.loaded_integrations) + CORE.loaded_platforms = set(self.loaded_platforms) + + core_platform = self.core_platform or ( + self.target_platform.lower() if self.target_platform else None + ) + CORE.data.setdefault(KEY_CORE, {}) + if core_platform is not None: + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = core_platform + if self.framework is not None: + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = self.framework + def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 4efba4e0c7..d4736f38a3 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 +import json import os from pathlib import Path from unittest.mock import patch @@ -24,11 +25,8 @@ _VALIDATED_CONFIG_YAML = """\ esphome: name: lite_test friendly_name: Lite Test Device - build_path: build/lite_test esp32: board: nodemcu-32s - framework: - type: arduino logger: baud_rate: 115200 api: @@ -45,6 +43,30 @@ wifi: """ +def _write_storage(storage_path: Path) -> None: + """Write a vanilla StorageJSON sidecar for the cache tests.""" + storage_path.parent.mkdir(parents=True, exist_ok=True) + data = { + "storage_version": 1, + "name": "lite_test", + "friendly_name": "Lite Test Device", + "comment": None, + "esphome_version": "2026.1.0", + "src_version": 1, + "address": "192.168.1.42", + "web_port": None, + "esp_platform": "ESP32", + "build_path": "/build/lite_test", + "firmware_bin_path": "/build/lite_test/firmware.bin", + "loaded_integrations": ["api", "logger", "ota", "wifi"], + "loaded_platforms": [], + "no_mdns": False, + "framework": "arduino", + "core_platform": "esp32", + } + storage_path.write_text(json.dumps(data)) + + def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: """Write the cache file and return it.""" cache_path.parent.mkdir(parents=True, exist_ok=True) @@ -63,14 +85,14 @@ def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None: @pytest.fixture def fresh_cache_files(tmp_path: Path) -> Path: - """YAML + cache, both consistent and fresh.""" + """YAML + StorageJSON + cache, all consistent and fresh.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path - cache = _write_cache( - tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" - ) + 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) return yaml_path @@ -84,7 +106,7 @@ def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None: def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: - """Fresh cache → returns the config and populates CORE from it.""" + """Fresh cache + sidecar → returns config and populates CORE.""" config = load_compiled_config(fresh_cache_files) assert config is not None @@ -92,37 +114,40 @@ 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" - # CORE state derives from the cached config dict -- same source - # `read_config` uses, no separate sidecar schema. + # apply_to_core ran as part of the orchestration. assert CORE.name == "lite_test" - assert CORE.friendly_name == "Lite Test Device" - assert CORE.build_path == CORE.data_dir / "build/lite_test" + assert CORE.build_path == Path("/build/lite_test") assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" + assert "api" in CORE.loaded_integrations @pytest.mark.parametrize( "scenario", - ["missing_cache", "stale_cache", "corrupt_cache", "missing_esphome_block"], + ["missing_cache", "stale_cache", "corrupt_cache", "missing_sidecar"], ) def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: """All non-happy cases return None so the caller falls back.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path - cache_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + storage_dir = tmp_path / ".esphome" / "storage" + cache_path = storage_dir / "lite_test.yaml.validated.yaml" + sidecar_path = storage_dir / "lite_test.yaml.json" if scenario == "missing_cache": - pass # no cache + pass # no cache, no sidecar elif scenario == "stale_cache": + _write_storage(sidecar_path) _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=-60) elif scenario == "corrupt_cache": + _write_storage(sidecar_path) _set_cache_mtime( _write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5 ) - elif scenario == "missing_esphome_block": - # Parseable YAML but no esphome: block -- can't populate CORE. - _set_cache_mtime(_write_cache(cache_path, "logger:\n"), yaml_path, offset=5) + elif scenario == "missing_sidecar": + # Cache fresh + parseable, but no StorageJSON → can't populate CORE. + _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=5) assert load_compiled_config(yaml_path) is None From 057fc4c1a8e758622e60376f5fe5b965a2124b95 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 13 May 2026 09:19:27 +1200 Subject: [PATCH 1180/2214] Move AI instructions to AGENTS.md (#16382) --- .github/copilot-instructions.md | 2 +- .ai/instructions.md => AGENTS.md | 0 CLAUDE.md | 2 +- GEMINI.md | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename .ai/instructions.md => AGENTS.md (100%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a4b2fa310c..be77ac83a1 120000 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1 +1 @@ -../.ai/instructions.md \ No newline at end of file +../AGENTS.md \ No newline at end of file diff --git a/.ai/instructions.md b/AGENTS.md similarity index 100% rename from .ai/instructions.md rename to AGENTS.md diff --git a/CLAUDE.md b/CLAUDE.md index 49e811ff05..47dc3e3d86 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -.ai/instructions.md \ No newline at end of file +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md index 49e811ff05..47dc3e3d86 120000 --- a/GEMINI.md +++ b/GEMINI.md @@ -1 +1 @@ -.ai/instructions.md \ No newline at end of file +AGENTS.md \ No newline at end of file From a19e817d28f4873fdab42cc5505f46a64fb2fdd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 17:29:29 -0500 Subject: [PATCH 1181/2214] [core] Shrink apply_to_core to what upload/logs actually read apply_to_core was over-populating: it restored friendly_name, loaded_integrations, and loaded_platforms even though every consumer of those three lives inside a component validator (esp32_camera, esp32, deep_sleep, zigbee, lvgl, zephyr_mcumgr), and the whole point of the fast path is to skip validation. Drop them. CORE.__init__ already leaves all three at safe defaults (None / empty set) for any incidental reader. What's left is exactly what upload/logs walk: - CORE.name (api.client.run_logs, firmware_bin path, mDNS) - CORE.build_path (firmware_bin / partition_table_bin / bootloader_bin) - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] (module dispatch, .is_esp32 etc) - CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] (.is_arduino, firmware_bin branch) Method body shrinks from 9 statements to 4; setdefault + two conditional inserts collapse into one dict literal; the function-local import moves to module top. Drift surface drops from 7 paired fields to 4. The wizard-only-sidecar None case is gated once at the load_compiled_config boundary so apply_to_core no longer has to defend against it. --- esphome/compiled_config.py | 5 +++ esphome/storage_json.py | 42 +++++++++++------------- tests/unit_tests/test_compiled_config.py | 10 ++++-- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index bb127a0de9..ac817bd906 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -92,5 +92,10 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: storage = StorageJSON.load(ext_storage_path(conf_path.name)) if storage is None: return None + # `apply_to_core` assumes the sidecar was written by `from_esphome_core` + # after a real compile, which always sets at least one of these. A + # wizard-only sidecar (no compile) can't drive upload/logs. + if not storage.core_platform and not storage.target_platform: + return None storage.apply_to_core() return config diff --git a/esphome/storage_json.py b/esphome/storage_json.py index a9a83f5896..0fd4a5cc0a 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -8,7 +8,13 @@ import os from pathlib import Path from esphome import const -from esphome.const import CONF_DISABLED, CONF_MDNS +from esphome.const import ( + CONF_DISABLED, + CONF_MDNS, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) from esphome.core import CORE from esphome.helpers import write_file_if_changed from esphome.types import CoreType @@ -257,31 +263,23 @@ class StorageJSON: return None def apply_to_core(self) -> None: - """Populate ``CORE`` from this sidecar. + """Populate CORE with the metadata upload/logs read. - Used by the ``--from-storage-json`` fast path in - ``esphome upload`` / ``esphome logs``: those subcommands read - a handful of ``CORE`` attributes (``target_platform``, - ``build_path``, ``name``, ``loaded_integrations``) that the - normal flow populates during ``read_config``. Lifting them off - the sidecar lets us skip the validation pass entirely. + Inverse of :meth:`from_esphome_core`'s CORE→StorageJSON + projection. Keep paired -- a new CORE attribute the + ``upload`` / ``logs`` fast path needs has to be captured by + ``from_esphome_core`` too. Validators (``loaded_integrations``, + ``loaded_platforms``, ``friendly_name``) are deliberately not + restored: they're consumed by component validation, which the + fast path skips, and ``CORE.__init__`` already leaves them at + safe defaults. """ - from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM - CORE.name = self.name - CORE.friendly_name = self.friendly_name CORE.build_path = self.build_path - CORE.loaded_integrations = set(self.loaded_integrations) - CORE.loaded_platforms = set(self.loaded_platforms) - - core_platform = self.core_platform or ( - self.target_platform.lower() if self.target_platform else None - ) - CORE.data.setdefault(KEY_CORE, {}) - if core_platform is not None: - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = core_platform - if self.framework is not None: - CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = self.framework + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(), + KEY_TARGET_FRAMEWORK: self.framework, + } def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index d4736f38a3..5dc6df6944 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -114,12 +114,18 @@ 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" - # apply_to_core ran as part of the orchestration. + # apply_to_core populated exactly what upload/logs read off CORE. assert CORE.name == "lite_test" assert CORE.build_path == Path("/build/lite_test") assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" - assert "api" in CORE.loaded_integrations + + # The validator-only attributes are deliberately left at their + # CORE.__init__ defaults. The fast path skips validation, so + # nothing reads these. + assert CORE.loaded_integrations == set() + assert CORE.loaded_platforms == set() + assert CORE.friendly_name is None @pytest.mark.parametrize( From e7af2aaf9bb3b023e26bf368c17a210d9c734cc4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 17:39:43 -0500 Subject: [PATCH 1182/2214] [core] Address review: secrets perms, mtime freshness, substitution regression, tests - save_compiled_config: write the cache with mode 0600 (the YAML contains resolved secrets) and always rewrite so mtime advances even when content is unchanged. Previously write_file_if_changed could leave mtime pinned behind a whitespace YAML edit, locking the fast path to 'stale'. - __main__: skip the fast path when -s overrides are passed. The cache was written against the previous substitution set; reusing it would silently ignore the new values. - Add tests for save_compiled_config (write + swallow-errors), the wizard-only sidecar fallback, and the substitution-skip branch. - Trim docstrings/comments throughout. --- esphome/__main__.py | 13 ++-- esphome/compiled_config.py | 55 +++++------------ esphome/storage_json.py | 13 ++-- esphome/writer.py | 5 +- tests/unit_tests/test_compiled_config.py | 78 +++++++++++++++++++++--- 5 files changed, 96 insertions(+), 68 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d68dcffaf2..d5a9b69833 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2419,15 +2419,12 @@ def run_esphome(argv): skip_external = args.command in ("logs", "clean") command_line_substitutions = dict(args.substitution) if args.substitution else {} - # Fast path for `upload` and `logs`: reuse the validated config the - # last compile cached on disk. Skips parse + schema validate + - # final-validate + external-component refresh, which is dead work - # for those subcommands -- the binary on disk is already known good - # and the caller already knows the device address. Falls back to a - # full `read_config()` when the cache is missing/stale/corrupt so a - # cold cache never produces a worse outcome than today. + # Fast path for upload/logs: reuse the validated-config cache the + # last compile wrote. Falls back to read_config when missing/stale. + # Skipped when -s overrides are passed, since the cache was written + # against the previous substitution set. config = None - if args.command in ("upload", "logs"): + if args.command in ("upload", "logs") and not command_line_substitutions: from esphome.compiled_config import load_compiled_config config = load_compiled_config(conf_path) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index ac817bd906..92cbb7348a 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -1,20 +1,9 @@ -"""Validated-config cache for the ``upload`` / ``logs`` fast path. +"""Validated-config cache for the upload/logs fast path. -After every successful ``esphome compile``, the writer dumps the -validated config to ``/storage/.validated.yaml``. -The next ``esphome upload`` / ``esphome logs`` for that YAML reuses -the cache instead of re-running the full ``read_config()`` pipeline -(parse + schema validate + final-validate + external-component -refresh), which is dead work once the binary on disk is known good. - -The cache uses YAML (via ``yaml_util``) rather than JSON so all -esphome-specific tags (``!lambda``, ``!include``, ``ID`` instances, -paths) round-trip cleanly, and so the small ``StorageJSON`` sidecar -isn't bloated by configs that can grow past a megabyte. Staleness -is gated by an mtime check against the source YAML; the loader -falls back to ``None`` (and the caller to ``read_config()``) whenever -the cache is missing, stale, unparseable, or the companion -``StorageJSON`` sidecar can't be loaded. +compile dumps the validated config to /storage/.validated.yaml; +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. """ from __future__ import annotations @@ -23,7 +12,7 @@ import logging from pathlib import Path from esphome.core import CORE -from esphome.helpers import write_file_if_changed +from esphome.helpers import write_file from esphome.storage_json import StorageJSON, ext_storage_path from esphome.types import ConfigType @@ -44,21 +33,16 @@ def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: def save_compiled_config(config: ConfigType) -> None: - """Dump the validated config to its sidecar YAML file. + """Write the validated-config cache. Always-write so mtime stays fresh. - Called from the writer after every successful compile so the next - ``esphome upload`` / ``esphome logs`` for this YAML can skip - validation via :func:`load_compiled_config`. Failures here are - non-fatal: the worst case is that the fast path falls back to a - full ``read_config`` next time. + Mode 0600 because show_secrets=True resolves !secret inline. + Failures are non-fatal: the fast path falls back to read_config. """ from esphome import yaml_util try: - # show_secrets=True keeps the cache self-contained; it lives - # in the same trust zone as the rest of .esphome/storage/. rendered = yaml_util.dump(config, show_secrets=True) - write_file_if_changed(compiled_config_path(CORE.config_filename), rendered) + write_file(compiled_config_path(CORE.config_filename), rendered, private=True) except Exception as err: # pylint: disable=broad-except _LOGGER.debug("Skipping compiled config cache write: %s", err) @@ -66,15 +50,9 @@ def save_compiled_config(config: ConfigType) -> None: def load_compiled_config(conf_path: Path) -> ConfigType | None: """Load the cached validated config and apply storage metadata to CORE. - Single entry point for the ``upload`` / ``logs`` fast path: loads - the cache, applies the ``StorageJSON`` sidecar's platform / build - metadata to ``CORE``, and returns the config dict. Returns - ``None`` (so the caller falls back to ``read_config``) when the - cache is missing, older than the source YAML, unparseable, or - when the sidecar can't be loaded. The mtime check catches the - common "user edited the YAML and forgot to recompile" case; - deeper drift (an edited ``!include`` whose parent YAML mtime - didn't change) is the user's responsibility. + Returns None (caller falls back to read_config) when the cache is + missing, older than the source YAML, unparseable, or the sidecar + is incomplete. """ cache_path = compiled_config_path(conf_path.name) if not _cache_is_fresh(cache_path, conf_path): @@ -83,8 +61,6 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: from esphome import yaml_util try: - # clear_secrets=False keeps in-flight secret state intact; the - # cache is self-contained and resolves no !secret references. config = yaml_util.load_yaml(cache_path, clear_secrets=False) except Exception: # pylint: disable=broad-except return None @@ -92,9 +68,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: storage = StorageJSON.load(ext_storage_path(conf_path.name)) if storage is None: return None - # `apply_to_core` assumes the sidecar was written by `from_esphome_core` - # after a real compile, which always sets at least one of these. A - # wizard-only sidecar (no compile) can't drive upload/logs. + # 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: return None storage.apply_to_core() diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 0fd4a5cc0a..7d26b22f96 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -265,14 +265,11 @@ class StorageJSON: def apply_to_core(self) -> None: """Populate CORE with the metadata upload/logs read. - Inverse of :meth:`from_esphome_core`'s CORE→StorageJSON - projection. Keep paired -- a new CORE attribute the - ``upload`` / ``logs`` fast path needs has to be captured by - ``from_esphome_core`` too. Validators (``loaded_integrations``, - ``loaded_platforms``, ``friendly_name``) are deliberately not - restored: they're consumed by component validation, which the - fast path skips, and ``CORE.__init__`` already leaves them at - safe defaults. + Inverse of :meth:`from_esphome_core`. Keep paired -- a new + attribute upload/logs needs has to be captured there too. + Validator-only fields (loaded_integrations/platforms, + friendly_name) are skipped; the fast path doesn't run + validation and CORE.__init__ defaults them. """ CORE.name = self.name CORE.build_path = self.build_path diff --git a/esphome/writer.py b/esphome/writer.py index 54eb254b1b..cf04e4f8d2 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -111,10 +111,7 @@ def update_storage_json() -> None: old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) - # Refresh the validated-config cache. `esphome upload` and - # `esphome logs` look for it next time they run and skip the full - # validation pipeline when it's present and fresh (mtime >= YAML - # mtime). Cheap to write, big win to read. + # Refresh the cache upload/logs read on the next call. if CORE.config is not None: save_compiled_config(CORE.config) diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 5dc6df6944..1fda7bf5bd 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -10,7 +10,11 @@ from unittest.mock import patch import pytest from esphome.__main__ import run_esphome -from esphome.compiled_config import compiled_config_path, load_compiled_config +from esphome.compiled_config import ( + compiled_config_path, + load_compiled_config, + save_compiled_config, +) from esphome.const import ( CONF_API, CONF_ESPHOME, @@ -120,13 +124,6 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" - # The validator-only attributes are deliberately left at their - # CORE.__init__ defaults. The fast path skips validation, so - # nothing reads these. - assert CORE.loaded_integrations == set() - assert CORE.loaded_platforms == set() - assert CORE.friendly_name is None - @pytest.mark.parametrize( "scenario", @@ -200,6 +197,24 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( mock_read.assert_called_once() +def test_run_esphome_upload_with_substitution_skips_cache( + fresh_cache_files: Path, +) -> None: + """`-s key value` forces a fresh validation -- the cache was written + against the prior substitution set, so reusing it would silently + ignore the override.""" + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)]) + + mock_read.assert_called_once() + + def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None: """compile always re-validates -- it's what writes the cache.""" with ( @@ -212,3 +227,50 @@ def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None run_esphome(["esphome", "compile", str(fresh_cache_files)]) mock_read.assert_called_once() + + +def test_save_compiled_config_writes_cache(tmp_path: Path) -> None: + """`save_compiled_config` writes the dumped YAML 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 + + +def test_save_compiled_config_swallows_dump_errors( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Failures during the dump 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")): + save_compiled_config({"esphome": {"name": "lite_test"}}) + assert not compiled_config_path("lite_test.yaml").exists() + + +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 + + 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}' + ) + cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache_path, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is None From 1902519f3780e2bf61599a27ce836d967c1e3b7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 17:48:17 -0500 Subject: [PATCH 1183/2214] [core] Fix Windows path separator in cache path test --- tests/unit_tests/test_compiled_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 1fda7bf5bd..89542fad79 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -104,9 +104,9 @@ def fresh_cache_files(tmp_path: Path) -> Path: def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None: """The cache file shape is predictable from the YAML filename.""" - assert str(compiled_config_path("device.yaml")).endswith( - "storage/device.yaml.validated.yaml" - ) + path = compiled_config_path("device.yaml") + assert path.name == "device.yaml.validated.yaml" + assert path.parent.name == "storage" def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: From bcd07abb5fc957a2576deafa089cb12230f66919 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 17:48:44 -0500 Subject: [PATCH 1184/2214] [core] Fix flake8 D403 in test docstring --- tests/unit_tests/test_compiled_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 89542fad79..3db6965c1f 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -216,7 +216,7 @@ def test_run_esphome_upload_with_substitution_skips_cache( def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None: - """compile always re-validates -- it's what writes the cache.""" + """The compile subcommand always re-validates -- it's what writes the cache.""" with ( patch("esphome.__main__.read_config", return_value=None) as mock_read, patch.dict( From c056bb317767cee18b09a5bb9db4736e1775346f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 17:49:03 -0500 Subject: [PATCH 1185/2214] [core] Type-annotate config local in dispatcher --- esphome/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d5a9b69833..b492aa67c1 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2423,7 +2423,7 @@ def run_esphome(argv): # last compile wrote. Falls back to read_config when missing/stale. # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. - config = None + config: ConfigType | None = None if args.command in ("upload", "logs") and not command_line_substitutions: from esphome.compiled_config import load_compiled_config From b8c2d66c0eba0fce59eceb07a1fec15e1b20926f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 17:51:49 -0500 Subject: [PATCH 1186/2214] [core] Assert on cache-hit log line so coverage is unambiguous --- tests/unit_tests/test_compiled_config.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 3db6965c1f..34e811b97b 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -157,7 +157,9 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: @pytest.mark.parametrize("command", ["upload", "logs"]) def test_run_esphome_upload_and_logs_use_cache_when_fresh( - command: str, fresh_cache_files: Path + command: str, + fresh_cache_files: Path, + caplog: pytest.LogCaptureFixture, ) -> None: """upload/logs skip read_config() when the cache is fresh.""" captured: dict = {} @@ -167,6 +169,7 @@ def test_run_esphome_upload_and_logs_use_cache_when_fresh( return 0 with ( + caplog.at_level("INFO", logger="esphome.__main__"), patch("esphome.__main__.read_config") as mock_read, patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}), ): @@ -175,6 +178,9 @@ def test_run_esphome_upload_and_logs_use_cache_when_fresh( mock_read.assert_not_called() assert captured["config"][CONF_ESPHOME][CONF_NAME] == "lite_test" assert captured["config"][CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" + # The success-branch log line is part of the patch; assert on it so + # branch coverage stays unambiguous in CI. + assert "Loaded validated config cache" in caplog.text @pytest.mark.parametrize("command", ["upload", "logs"]) From 622c0b35a776765f1de0bdfd05095de8dedccbee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 17:58:09 -0500 Subject: [PATCH 1187/2214] [core] Document mtime check's package-edit limitation as YAGNI --- esphome/compiled_config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 92cbb7348a..9de2143094 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -53,6 +53,14 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: Returns None (caller falls back to read_config) when the cache is missing, older than the source YAML, unparseable, or the sidecar is incomplete. + + Known limitation: the mtime check only stats the top-level YAML. + A package / !include / !secret edit that changes a CLI-side key + (e.g. wifi.use_address pinned to a manual IP) without re-touching + the main YAML or recompiling would keep the stale address in the + cache. Realistic workflows recompile when anything material + changes, so this is YAGNI -- revisit if someone actually trips + over it. """ cache_path = compiled_config_path(conf_path.name) if not _cache_is_fresh(cache_path, conf_path): From 907ae46aba7f977e47a2b4f04cf898662081dc9e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 12 May 2026 19:13:04 -0400 Subject: [PATCH 1188/2214] [zigbee] Fix init-order and missing-field warnings on native ESP-IDF (#16389) --- .../zigbee/zigbee_attribute_esp32.cpp | 22 +++++++++---------- .../zigbee/zigbee_attribute_esp32.h | 4 ++-- esphome/components/zigbee/zigbee_esp32.cpp | 7 +++--- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index 4d73600171..0a06792c59 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -32,10 +32,9 @@ void ZigbeeAttribute::report_(bool has_lock) { return; } if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { - esp_zb_zcl_report_attr_cmd_t cmd = { - .address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT, - .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI, - }; + esp_zb_zcl_report_attr_cmd_t cmd = {}; + cmd.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT; + cmd.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI; cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000; cmd.zcl_basic_cmd.dst_endpoint = 1; cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_; @@ -50,14 +49,13 @@ void ZigbeeAttribute::report_(bool has_lock) { } esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() { - esp_zb_zcl_reporting_info_t reporting_info = { - .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV, - .ep = this->endpoint_id_, - .cluster_id = this->cluster_id_, - .cluster_role = this->role_, - .attr_id = this->attr_id_, - .manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC, - }; + esp_zb_zcl_reporting_info_t reporting_info = {}; + reporting_info.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV; + reporting_info.ep = this->endpoint_id_; + reporting_info.cluster_id = this->cluster_id_; + reporting_info.cluster_role = this->role_; + reporting_info.attr_id = this->attr_id_; + reporting_info.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC; reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID; reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */ reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */ diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 90a5cf8ff9..35aa60848f 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -37,8 +37,8 @@ class ZigbeeAttribute : public Component { role_(role), attr_id_(attr_id), attr_type_(attr_type), - scale_(scale), - max_size_(max_size) {} + max_size_(max_size), + scale_(scale) {} void loop() override; template void add_attr(T value); esp_zb_zcl_reporting_info_t get_reporting_info(); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index c534a38432..ade9e16572 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -204,10 +204,9 @@ static void esp_zb_task_(void *pvParameters) { void ZigbeeComponent::setup() { global_zigbee = this; - esp_zb_platform_config_t config = { - .radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(), - .host_config = ESP_ZB_DEFAULT_HOST_CONFIG(), - }; + esp_zb_platform_config_t config = {}; + config.radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(); + config.host_config = ESP_ZB_DEFAULT_HOST_CONFIG(); #ifdef USE_WIFI if (esp_coex_wifi_i154_enable() != ESP_OK) { this->mark_failed(); From aec48cf2316f3d20c601757401692c8ecf5733e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 23:19:07 +0000 Subject: [PATCH 1189/2214] Bump aioesphomeapi from 44.24.2 to 45.0.0 (#16391) 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 c035051890..6be6a95fe6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==44.24.2 +aioesphomeapi==45.0.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 9195b9898ee7b8b31e36d0a19d078d6f15b71466 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 12 May 2026 19:20:09 -0400 Subject: [PATCH 1190/2214] [ms8607] Pin humidity i2c_id in test fixture (#16386) --- tests/components/ms8607/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/ms8607/common.yaml b/tests/components/ms8607/common.yaml index 09a3f5a617..ad2a34308b 100644 --- a/tests/components/ms8607/common.yaml +++ b/tests/components/ms8607/common.yaml @@ -4,6 +4,7 @@ sensor: temperature: name: Temperature humidity: + i2c_id: i2c_bus name: Humidity pressure: name: Pressure From 45a8bd49c383bd8ebba205ec25f5eb763437e79c Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 May 2026 19:33:03 -0400 Subject: [PATCH 1191/2214] [audio] Add a RingBufferAudioSource (#16314) --- .../audio/audio_transfer_buffer.cpp | 131 ++++++++++++++++++ .../components/audio/audio_transfer_buffer.h | 74 ++++++++++ 2 files changed, 205 insertions(+) diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index 6ee9e4d28c..d9ce8060e2 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -207,6 +207,137 @@ void ConstAudioSourceBuffer::consume(size_t bytes) { this->data_start_ += bytes; } +std::unique_ptr RingBufferAudioSource::create( + std::shared_ptr ring_buffer, size_t max_fill_bytes, uint8_t alignment_bytes) { + if (ring_buffer == nullptr || max_fill_bytes == 0 || alignment_bytes == 0 || alignment_bytes > MAX_ALIGNMENT_BYTES) { + return nullptr; + } + return std::unique_ptr( + new RingBufferAudioSource(std::move(ring_buffer), max_fill_bytes, alignment_bytes)); +} + +RingBufferAudioSource::~RingBufferAudioSource() { + if (this->acquired_item_ != nullptr) { + this->ring_buffer_->receive_release(this->acquired_item_); + this->acquired_item_ = nullptr; + } +} + +void RingBufferAudioSource::release_item_() { + if (this->acquired_item_ == nullptr) { + return; + } + if (this->item_trailing_length_ > 0) { + // Copy the trailing sub-frame bytes into the splice buffer before returning the item; the next + // fill() will complete the frame from the head of the next chunk. + std::memcpy(this->splice_buffer_, this->item_trailing_ptr_, this->item_trailing_length_); + this->splice_length_ = this->item_trailing_length_; + this->item_trailing_ptr_ = nullptr; + this->item_trailing_length_ = 0; + } + this->ring_buffer_->receive_release(this->acquired_item_); + this->acquired_item_ = nullptr; +} + +void RingBufferAudioSource::consume(size_t bytes) { + bytes = std::min(bytes, this->current_available_); + this->current_data_ += bytes; + this->current_available_ -= bytes; + // Promotion of queued data is deferred to fill() so callers see new data as a fresh return value + // rather than appearing silently after consume(). When the held item has nothing left depending + // on it (no exposed bytes and no queued region), release it now so the ring buffer can be + // reclaimed by writers even if fill() is never called again. + if (this->current_available_ == 0 && this->queued_length_ == 0) { + this->release_item_(); + } +} + +bool RingBufferAudioSource::has_buffered_data() const { + // splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion + // bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports. + // Counting it separately would strand a drain loop when a stream ends mid-frame and those completion + // bytes never come. + return (this->current_available_ > 0) || (this->queued_length_ > 0) || (this->ring_buffer_->available() > 0); +} + +size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait, bool /*pre_shift*/) { + if (this->current_available_ > 0) { + // Caller has not finished consuming the current exposure + return 0; + } + + // If a queued region (the aligned remainder of the new chunk after a splice frame) is waiting, + // promote it to the exposed region and report its size as fresh data. + if (this->queued_length_ > 0) { + this->current_data_ = this->queued_data_; + this->current_available_ = this->queued_length_; + this->queued_data_ = nullptr; + this->queued_length_ = 0; + return this->current_available_; + } + + // Nothing exposed and nothing queued: release the previously held item (saving any sub-frame tail + // to splice_buffer_) and acquire a new chunk. + this->release_item_(); + + size_t chunk_length = 0; + void *item = this->ring_buffer_->receive_acquire(chunk_length, this->max_fill_bytes_, ticks_to_wait); + if (item == nullptr) { + return 0; + } + + uint8_t *chunk_data = static_cast(item); + bool exposing_splice_frame = false; + + // Complete any pending splice frame from the head of the new chunk. + if (this->splice_length_ > 0) { + const size_t needed = static_cast(this->alignment_bytes_) - this->splice_length_; + if (chunk_length < needed) { + // Not enough data to complete the spliced frame yet; absorb everything and wait for more. + std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, chunk_length); + this->splice_length_ += chunk_length; + this->ring_buffer_->receive_release(item); + return 0; + } + std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, needed); + chunk_data += needed; + chunk_length -= needed; + this->splice_length_ = 0; + exposing_splice_frame = true; + } + + this->acquired_item_ = item; + + // Split the remaining chunk into its aligned region and a (possibly zero) sub-frame trailing tail. + const size_t trailing = (this->alignment_bytes_ > 1) ? (chunk_length % this->alignment_bytes_) : 0; + const size_t aligned_bytes = chunk_length - trailing; + if (trailing > 0) { + this->item_trailing_ptr_ = chunk_data + aligned_bytes; + this->item_trailing_length_ = trailing; + } + + if (exposing_splice_frame) { + // Expose the spliced frame from splice_buffer_, queuing the chunk's aligned region for the next + // fill() call. + this->current_data_ = this->splice_buffer_; + this->current_available_ = this->alignment_bytes_; + this->queued_data_ = chunk_data; + this->queued_length_ = aligned_bytes; + return this->alignment_bytes_; + } + + if (aligned_bytes == 0) { + // The entire chunk is a sub-frame tail (only possible when alignment exceeds chunk size). Save it + // to the splice buffer and release the item so the next fill() can complete the frame. + this->release_item_(); + return 0; + } + + this->current_data_ = chunk_data; + this->current_available_ = aligned_bytes; + return aligned_bytes; +} + } // namespace esphome::audio #endif diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index 68151bf4e2..d83032582e 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -214,6 +214,80 @@ class ConstAudioSourceBuffer : public AudioReadableBuffer { size_t length_{0}; }; +/// @brief Zero-copy audio source that reads directly from a ring buffer's internal storage. +/// +/// Optionally enforces a minimum read alignment (e.g. one audio frame). When alignment_bytes > 1, the +/// source transparently stitches frames that straddle the ring buffer's wrap boundary by buffering the +/// trailing partial frame from one chunk and joining it with the head of the next chunk in a small +/// internal splice buffer, so callers always see frame-aligned data. +class RingBufferAudioSource : public AudioReadableBuffer { + public: + /// Maximum supported alignment. Sized to cover 32-bit samples across up to 2 channels (8 bytes). + static constexpr size_t MAX_ALIGNMENT_BYTES = 8; + + /// @brief Creates a new ring-buffer-backed audio source after validating its parameters. + /// @param ring_buffer The ring buffer to read from. Must be non-null. + /// @param max_fill_bytes Soft cap on bytes acquired per fill() call. Must be > 0. + /// @param alignment_bytes Minimum exposed-region alignment in bytes (defaults to 1, i.e. byte-aligned). + /// Pass bytes_per_frame to make every exposed region a whole number of frames. Must be in + /// [1, MAX_ALIGNMENT_BYTES]. + /// @return unique_ptr if parameters are valid, nullptr otherwise + static std::unique_ptr create(std::shared_ptr ring_buffer, + size_t max_fill_bytes, uint8_t alignment_bytes = 1); + + ~RingBufferAudioSource() override; + + // AudioReadableBuffer interface + const uint8_t *data() const override { return this->current_data_; } + size_t available() const override { return this->current_available_; } + void consume(size_t bytes) override; + bool has_buffered_data() const override; + size_t fill(TickType_t ticks_to_wait, bool pre_shift) override; + + /// @brief Returns a mutable pointer to the currently exposed audio data. + /// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame + /// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data + /// should be discarded after use, since the underlying storage will be reused on the next fill(). + /// Use only when the caller is the sole consumer of this source. + uint8_t *mutable_data() { return this->current_data_; } + + protected: + /// @brief Constructs a new ring-buffer-backed audio source. Use create() instead, which validates + /// arguments before construction. + explicit RingBufferAudioSource(std::shared_ptr ring_buffer, size_t max_fill_bytes, + uint8_t alignment_bytes) + : ring_buffer_(std::move(ring_buffer)), max_fill_bytes_(max_fill_bytes), alignment_bytes_(alignment_bytes) {} + + /// @brief Releases the currently held ring buffer item, first copying any trailing sub-frame bytes + /// into the splice buffer so they can be stitched with the next chunk. + void release_item_(); + + std::shared_ptr ring_buffer_; + size_t max_fill_bytes_; + + void *acquired_item_{nullptr}; + uint8_t *current_data_{nullptr}; + + // Sub-frame trailing bytes inside the held item that will be copied to splice_buffer_ on release. + uint8_t *item_trailing_ptr_{nullptr}; + + // After the currently-exposed splice frame is consumed, fill() will promote this region (the aligned + // remainder of the new chunk) to the exposed region. queued_length_ == 0 when nothing is queued. + uint8_t *queued_data_{nullptr}; + + // Splice buffer holds the start of a partial frame whose remainder lives at the head of the next + // chunk. While splice_length_ > 0, the buffer is incomplete and waiting for completion bytes. + uint8_t splice_buffer_[MAX_ALIGNMENT_BYTES]; + + size_t current_available_{0}; + size_t queued_length_{0}; + + // item_trailing_length_ and splice_length_ are bounded by MAX_ALIGNMENT_BYTES. + uint8_t alignment_bytes_; + uint8_t item_trailing_length_{0}; + uint8_t splice_length_{0}; +}; + } // namespace esphome::audio #endif From cb2dbcd70de6de08196e3e9b53a29b841c6d2444 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 13 May 2026 11:37:33 +1200 Subject: [PATCH 1192/2214] [ci] Add validate.*.yaml for config-only component tests (#16384) --- .github/workflows/ci.yml | 38 ++++- AGENTS.md | 18 ++- script/analyze_component_buses.py | 9 +- script/ci-custom.py | 7 +- script/determine-jobs.py | 46 +++++- script/helpers.py | 28 +++- script/split_components_for_ci.py | 11 +- script/test_build_components.py | 59 +++++++- tests/script/test_determine_jobs.py | 227 ++++++++++++++++++++++++++++ tests/script/test_helpers.py | 168 ++++++++++++++++++++ 10 files changed, 589 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf9d474d7a..ad39b3f346 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -261,6 +261,7 @@ jobs: cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }} cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }} component-test-batches: ${{ steps.determine.outputs.component-test-batches }} + validate-only-components: ${{ steps.determine.outputs.validate-only-components }} benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub @@ -305,6 +306,7 @@ jobs: echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT + echo "validate-only-components=$(echo "$output" | jq -c '.validate_only_components')" >> $GITHUB_OUTPUT echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' @@ -775,13 +777,45 @@ jobs: echo "Config validation passed! Starting compilation..." echo "" + # Compute the compile-stage component list. Components whose only + # changes are validate.*.yaml files are config-only -- their source + # and test fixtures didn't move, so rebuilding firmware adds no + # signal. Subtract them from this batch before invoking compile. + validate_only_json='${{ needs.determine-jobs.outputs.validate-only-components }}' + if [ -z "$validate_only_json" ]; then + validate_only_json='[]' + fi + if ! validate_only_csv=$(echo "$validate_only_json" | jq -r 'join(",")'); then + echo "::error::Failed to render validate-only-components as CSV from: $validate_only_json" + exit 1 + fi + if [ -z "$validate_only_csv" ]; then + compile_csv="$components_csv" + else + components_sorted=$(echo "$components_csv" | tr ',' '\n' | sort -u) + validate_sorted=$(echo "$validate_only_csv" | tr ',' '\n' | sort -u) + if ! diff_out=$(comm -23 <(echo "$components_sorted") <(echo "$validate_sorted")); then + echo "::error::Failed to compute compile component subset." + exit 1 + fi + compile_csv=$(echo "$diff_out" | paste -sd ',' -) + skipped=$(comm -12 <(echo "$components_sorted") <(echo "$validate_sorted") | paste -sd ',' -) + if [ -n "$skipped" ]; then + echo "Validate-only components in this batch (skipping compile): $skipped" + fi + fi + # Show disk space before compilation echo "Disk space before compilation:" df -h echo "" - # Run compilation with grouping and isolation - python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv" + if [ -n "$compile_csv" ]; then + # Run compilation with grouping and isolation + python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" + else + echo "All components in this batch are validate-only -- skipping compile stage." + fi test-native-idf: name: Test components with native ESP-IDF diff --git a/AGENTS.md b/AGENTS.md index 86f554e9ce..2139a2b796 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,13 +398,23 @@ This document provides essential context for AI models interacting with this pro │ ├── i2c/ # I2C bus │ └── spi/ # SPI bus └── components/[component]/ - ├── common.yaml # Component-only config (no bus definitions) - ├── test.esp32-idf.yaml - ├── test.esp8266-ard.yaml - └── test.rp2040-ard.yaml + ├── common.yaml # Component-only config (no bus definitions) + ├── test.esp32-idf.yaml # config + compile + ├── test.esp8266-ard.yaml # config + compile + ├── test-variant.esp32-idf.yaml # variant test, config + compile + ├── validate.esp32-idf.yaml # config-only (never compiled) + └── validate-legacy.esp32-idf.yaml # config-only variant ``` Run them using `script/test_build_components`. Use `-c ` to test specific components and `-t ` for specific platforms. + * **Config-only test files (`validate.*.yaml`):** Use this prefix when a YAML file only needs to exercise schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with `esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`: + - `validate..yaml` — base config-only test + - `validate-..yaml` — config-only variant + + Use this for things like deprecated-syntax migration tests, schema edge cases, or platform-specific validation branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and `validate.*.yaml` files. Validate files never participate in bus-grouping; each one runs as its own `esphome config` invocation. + + When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes. + * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`: ```yaml # test.esp32-idf.yaml — use packages for buses diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 17af7af577..1d86d5c71c 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -34,7 +34,7 @@ from typing import Any # Add esphome to path sys.path.insert(0, str(Path(__file__).parent.parent)) -from helpers import BASE_BUS_COMPONENTS +from helpers import BASE_BUS_COMPONENTS, is_validate_only_file from esphome import yaml_util from esphome.config_helpers import Extend, Remove @@ -283,6 +283,13 @@ def analyze_component(component_dir: Path) -> tuple[dict[str, list[str]], bool, # Analyze all YAML files in the component directory for yaml_file in component_dir.glob("*.yaml"): + # validate.*.yaml files are config-only -- they don't compile, so + # their contents must not influence compile-time grouping decisions + # (e.g. a !extend used only to exercise schema validation must not + # disqualify the whole component from being grouped). + if is_validate_only_file(yaml_file): + continue + analysis = analyze_yaml_file(yaml_file) # Track if any file uses extend/remove diff --git a/script/ci-custom.py b/script/ci-custom.py index 25db32105c..56ca0d0355 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1068,7 +1068,12 @@ PACKAGE_BUS_RE = re.compile( ) -@lint_content_check(include=["tests/components/*/test.*.yaml"]) +@lint_content_check( + include=[ + "tests/components/*/test.*.yaml", + "tests/components/*/validate.*.yaml", + ] +) def lint_test_package_key_matches_bus(fname, content): """Ensure package keys match the common bus directory name. diff --git a/script/determine-jobs.py b/script/determine-jobs.py index b8f324784d..57b3c6eb88 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -29,6 +29,8 @@ The CI workflow uses this information to: - Skip or run downstream esphome/device-builder tests against the PR's Python code - Determine which components to test individually - Decide how to split component tests (if there are many) +- Identify directly-changed components whose only edits are validate.*.yaml files, + so CI can skip the compile stage for them and run config validation only - Run memory impact analysis whenever there are changed components (merged config), and also for core-only changes Usage: @@ -68,6 +70,7 @@ from helpers import ( get_integration_test_files_for_components, get_target_branch, git_ls_files, + is_validate_only_file, parse_test_filename, root_path, ) @@ -600,14 +603,41 @@ def _component_has_tests(component: str) -> bool: """Check if a component has test files. Cached to avoid repeated filesystem operations for the same component. + Validate files (validate.*.yaml) count -- they exercise schema validation + in CI even though they are never compiled. Args: component: Component name to check Returns: - True if the component has test YAML files + True if the component has test or validate YAML files """ - return bool(get_component_test_files(component, all_variants=True)) + return bool( + get_component_test_files(component, all_variants=True, include_validate=True) + ) + + +def _component_change_is_validate_only(component: str, changed: list[str]) -> bool: + """Return True if every changed file for this component is a validate.*.yaml. + + Used to decide whether a directly-changed component can skip the compile + stage in CI. A component qualifies when: + - at least one file under ``tests/components//`` changed, AND + - no source file under ``esphome/components//`` changed, AND + - every changed test file is a ``validate.*.yaml`` or + ``validate-*.yaml`` (i.e. no regular ``test.*.yaml`` was touched). + """ + test_prefix = f"tests/components/{component}/" + src_prefix = f"esphome/components/{component}/" + test_changes: list[Path] = [] + for path in changed: + if path.startswith(src_prefix): + return False + if path.startswith(test_prefix): + test_changes.append(Path(path)) + if not test_changes: + return False + return all(is_validate_only_file(p) for p in test_changes) def _select_platform_by_preference( @@ -977,6 +1007,17 @@ def main() -> None: if component not in directly_changed_components ] + # Components whose only changes are validate.*.yaml files can skip the + # compile stage in CI -- their source and test fixtures didn't move, so + # rebuilding firmware adds no signal. Only directly-changed components + # qualify: a component pulled in transitively (because a dependency + # changed) still needs the compile to catch regressions. + validate_only_components = sorted( + component + for component in directly_changed_with_tests + if _component_change_is_validate_only(component, changed) + ) + # Detect components for memory impact analysis (merged config) memory_impact = detect_memory_impact_config(args.branch) @@ -1073,6 +1114,7 @@ def main() -> None: "cpp_unit_tests_run_all": cpp_run_all, "cpp_unit_tests_components": cpp_components, "component_test_batches": component_test_batches, + "validate_only_components": validate_only_components, "benchmarks": run_benchmarks, } diff --git a/script/helpers.py b/script/helpers.py index 7a6d7ecef6..cf82a89f93 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -117,7 +117,7 @@ def get_component_from_path(file_path: str) -> str | None: def get_component_test_files( - component: str, *, all_variants: bool = False + component: str, *, all_variants: bool = False, include_validate: bool = False ) -> list[Path]: """Get test files for a component. @@ -126,6 +126,10 @@ def get_component_test_files( all_variants: If True, returns all test files including variants (test-*.yaml). If False, returns only base test files (test.*.yaml). Default is False. + include_validate: If True, also returns config-only files (validate.*.yaml, + and validate-*.yaml when all_variants is True). These files + are validated with `esphome config` but never compiled. + Default is False. Returns: List of test file paths for the component, or empty list if none exist @@ -136,9 +140,27 @@ def get_component_test_files( if all_variants: # Match both test.*.yaml and test-*.yaml patterns - return list(tests_dir.glob("test[.-]*.yaml")) + files = list(tests_dir.glob("test[.-]*.yaml")) + if include_validate: + files.extend(tests_dir.glob("validate[.-]*.yaml")) + return files # Match only test.*.yaml (base tests) - return list(tests_dir.glob("test.*.yaml")) + files = list(tests_dir.glob("test.*.yaml")) + if include_validate: + files.extend(tests_dir.glob("validate.*.yaml")) + return files + + +def is_validate_only_file(test_file: Path) -> bool: + """Return True if the given path is a config-only validate file. + + Validate files follow the same grammar as test files but with a + ``validate`` prefix instead of ``test``: ``validate..yaml`` + or ``validate-..yaml``. They are exercised with + ``esphome config`` only and skipped during compile. + """ + name = test_file.name + return name.startswith("validate.") or name.startswith("validate-") @dataclass(frozen=True) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index d95cdcbe81..0d10246bb4 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -44,14 +44,21 @@ ALL_PLATFORMS = "all" def has_test_files(component_name: str, tests_dir: Path) -> bool: """Check if a component has test files. + Validate files (validate.*.yaml) count -- a component with only config-only + test files still needs a CI runner for schema validation. + Args: component_name: Name of the component tests_dir: Path to tests/components directory (unused, kept for compatibility) Returns: - True if the component has test.*.yaml or test-*.yaml files + True if the component has test.*.yaml, test-*.yaml, or validate.*.yaml files """ - return bool(get_component_test_files(component_name, all_variants=True)) + return bool( + get_component_test_files( + component_name, all_variants=True, include_validate=True + ) + ) def create_intelligent_batches( diff --git a/script/test_build_components.py b/script/test_build_components.py index 10c5e5463f..43b71004eb 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -39,7 +39,11 @@ from script.analyze_component_buses import ( merge_compatible_bus_groups, uses_local_file_references, ) -from script.helpers import get_component_test_files, split_conflicting_groups +from script.helpers import ( + get_component_test_files, + is_validate_only_file, + split_conflicting_groups, +) from script.merge_component_configs import merge_component_configs @@ -83,7 +87,10 @@ def show_disk_space_if_ci(esphome_command: str) -> None: def find_component_tests( - components_dir: Path, component_pattern: str = "*", base_only: bool = False + components_dir: Path, + component_pattern: str = "*", + base_only: bool = False, + include_validate: bool = False, ) -> dict[str, list[Path]]: """Find all component test files. @@ -91,6 +98,8 @@ def find_component_tests( components_dir: Path to tests/components directory component_pattern: Glob pattern for component names base_only: If True, only find base test files (test.*.yaml), not variant files (test-*.yaml) + include_validate: If True, also include config-only files (validate.*.yaml). + These are run with `esphome config` only and never compiled. Returns: Dictionary mapping component name to list of test files @@ -102,7 +111,11 @@ def find_component_tests( continue # Get test files using helper function - test_files = get_component_test_files(comp_dir.name, all_variants=not base_only) + test_files = get_component_test_files( + comp_dir.name, + all_variants=not base_only, + include_validate=include_validate, + ) if test_files: component_tests[comp_dir.name] = test_files @@ -836,12 +849,25 @@ def run_grouped_component_tests( # With grouping: # - 1 build per group (regardless of how many components) # - Individual components still need all their platform builds + # - Validate files of grouped components still run individually + # (they're config-only and bypass the grouped compile, see + # run_individual_component_test), so each adds one more invocation. individual_test_file_count = sum( len(all_tests[comp]) for comp in individual_tests if comp in all_tests ) + grouped_component_set = {c for _, _, comps in groups_to_test for c in comps} + grouped_validate_file_count = sum( + 1 + for comp in grouped_component_set + for test_file in all_tests.get(comp, []) + if is_validate_only_file(test_file) + ) + total_grouped_components = sum(len(comps) for _, _, comps in groups_to_test) - total_builds_with_grouping = len(groups_to_test) + individual_test_file_count + total_builds_with_grouping = ( + len(groups_to_test) + individual_test_file_count + grouped_validate_file_count + ) builds_saved = total_test_files - total_builds_with_grouping print(f"\n{'=' * 80}") @@ -854,6 +880,10 @@ def run_grouped_component_tests( print( f" • {individual_test_file_count} individual builds ({len(individual_tests)} components)" ) + if grouped_validate_file_count: + print( + f" • {grouped_validate_file_count} validate-only invocations for grouped components" + ) if total_test_files > 0: reduction_pct = (builds_saved / total_test_files) * 100 print(f" • Saves {builds_saved} builds ({reduction_pct:.1f}% reduction)") @@ -937,8 +967,13 @@ def run_individual_component_test( tested_components: Set of already tested components test_results: List to append test results """ - # Skip if already tested in a group - if (component, platform_with_version) in tested_components: + # Validate files (validate.*.yaml) are config-only and never participate + # in compile-time bus grouping, so always run them individually even when + # the (component, platform) pair was covered by a group test. + if ( + not is_validate_only_file(test_file) + and (component, platform_with_version) in tested_components + ): return test_result = run_esphome_test( @@ -992,13 +1027,23 @@ def test_components( # Get platform base files platform_bases = get_platform_base_files(build_components_dir) + # Validate files (validate.*.yaml) are config-only -- they exercise + # schema/validation paths but are never compiled. Include them when running + # `config` or `clean`; exclude them under `compile` so they never reach a + # toolchain build. + include_validate = esphome_command != "compile" + # Find all component tests all_tests = {} for pattern in component_patterns: # Skip empty patterns (happens when components list is empty string) if not pattern: continue - all_tests.update(find_component_tests(tests_dir, pattern, base_only)) + all_tests.update( + find_component_tests( + tests_dir, pattern, base_only, include_validate=include_validate + ) + ) # If no components found, build a reference configuration for baseline comparison # Create a synthetic "empty" component test that will build just the base config diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index cc795bc553..5e2dd670dc 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2215,3 +2215,230 @@ def test_should_run_benchmarks_with_branch() -> None: mock_changed.return_value = [] determine_jobs.should_run_benchmarks("release") mock_changed.assert_called_with("release") + + +# --------------------------------------------------------------------------- +# _component_change_is_validate_only +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("component", "changed", "expected"), + [ + # Only a base validate file changed. + ( + "foo", + ["tests/components/foo/validate.esp32-idf.yaml"], + True, + ), + # Only a validate variant changed. + ( + "foo", + ["tests/components/foo/validate-legacy.esp32-idf.yaml"], + True, + ), + # Multiple validate files (all validate). + ( + "foo", + [ + "tests/components/foo/validate.esp32-idf.yaml", + "tests/components/foo/validate-legacy.esp32-idf.yaml", + ], + True, + ), + # Mixed: validate + regular test must NOT be classified as validate-only. + ( + "foo", + [ + "tests/components/foo/validate.esp32-idf.yaml", + "tests/components/foo/test.esp32-idf.yaml", + ], + False, + ), + # Regular test only. + ( + "foo", + ["tests/components/foo/test.esp32-idf.yaml"], + False, + ), + # Source change disqualifies even if a validate file is also touched. + ( + "foo", + [ + "esphome/components/foo/foo.cpp", + "tests/components/foo/validate.esp32-idf.yaml", + ], + False, + ), + # No matching files at all. + ("foo", ["esphome/core/helpers.cpp"], False), + # Filenames merely starting with "validate" but not following the + # grammar must not match (defensive against accidental classification). + ( + "foo", + ["tests/components/foo/validatesomething.yaml"], + False, + ), + # An unrelated component's validate change doesn't affect this one. + ( + "foo", + ["tests/components/bar/validate.esp32-idf.yaml"], + False, + ), + # common.yaml change in the component dir disqualifies. + ( + "foo", + [ + "tests/components/foo/common.yaml", + "tests/components/foo/validate.esp32-idf.yaml", + ], + False, + ), + ], +) +def test_component_change_is_validate_only( + component: str, changed: list[str], expected: bool +) -> None: + """The validate-only classifier rejects anything beyond validate.* edits.""" + assert ( + determine_jobs._component_change_is_validate_only(component, changed) + is expected + ) + + +def test_main_emits_validate_only_components( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Directly-changed components whose only edits are validate.*.yaml are + listed in `validate_only_components` so CI can skip their compile stage. + """ + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_determine_cpp_unit_tests.return_value = (False, []) + + # foo: only validate file changed (qualifies) + # bar: test file changed (does not qualify) + mock_changed_files.return_value = [ + "tests/components/foo/validate.esp32-idf.yaml", + "tests/components/bar/test.esp32-idf.yaml", + ] + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), + patch.object( + determine_jobs, + "get_changed_components", + return_value=["foo", "bar"], + ), + patch.object( + determine_jobs, + "filter_component_and_test_files", + side_effect=lambda f: f.startswith("tests/components/"), + ), + patch.object( + determine_jobs, + "get_components_with_dependencies", + side_effect=lambda files, deps: ["foo", "bar"], + ), + patch.object(determine_jobs, "_component_has_tests", return_value=True), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, + "create_intelligent_batches", + return_value=([["foo", "bar"]], {}), + ), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + assert output["validate_only_components"] == ["foo"] + + +def test_main_validate_only_excludes_transitive_components( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A component pulled in only as a dependency must NOT be considered + validate-only, even if it has no source changes -- its dependency moved, + so the compile is still required. + """ + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_determine_cpp_unit_tests.return_value = (False, []) + + # Only foo's validate file changed directly. bar is a transitive dep. + mock_changed_files.return_value = [ + "tests/components/foo/validate.esp32-idf.yaml", + ] + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), + patch.object( + determine_jobs, + "get_changed_components", + return_value=["foo", "bar"], # bar pulled in via dependencies + ), + patch.object( + determine_jobs, + "filter_component_and_test_files", + side_effect=lambda f: f.startswith("tests/components/"), + ), + patch.object( + determine_jobs, + "get_components_with_dependencies", + # deps=False -> directly_changed = [foo]; deps=True -> [foo, bar] + side_effect=lambda files, deps: ["foo", "bar"] if deps else ["foo"], + ), + patch.object(determine_jobs, "_component_has_tests", return_value=True), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, + "create_intelligent_batches", + return_value=([["foo", "bar"]], {}), + ), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + # Only foo (directly changed, validate-only). bar is a transitive dep + # and still needs compile despite no source change of its own. + assert output["validate_only_components"] == ["foo"] diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index db0d2908f4..10f258aa83 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -1624,3 +1624,171 @@ def test_split_conflicting_groups_preserves_original_signature_for_first_bucket( platform, signature = next(iter(extra)) assert platform == "esp32" assert signature.startswith("i2c__conflict") + + +# --------------------------------------------------------------------------- +# get_component_test_files / is_validate_only_file +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_component_tests(tmp_path: Path) -> Path: + """Create a fake tests/components/ tree and return the repo root. + + Layout for component "demo": + test.esp32-idf.yaml + test.esp8266-ard.yaml + test-variant.esp32-idf.yaml + validate.esp32-idf.yaml + validate-legacy.esp32-idf.yaml + + Layout for component "validate_only": + validate.esp32-idf.yaml (only validate files) + + Layout for component "no_tests": + common.yaml (no test/validate files at all) + """ + tests_dir = tmp_path / "tests" / "components" + + demo = tests_dir / "demo" + demo.mkdir(parents=True) + (demo / "test.esp32-idf.yaml").write_text("") + (demo / "test.esp8266-ard.yaml").write_text("") + (demo / "test-variant.esp32-idf.yaml").write_text("") + (demo / "validate.esp32-idf.yaml").write_text("") + (demo / "validate-legacy.esp32-idf.yaml").write_text("") + + validate_only = tests_dir / "validate_only" + validate_only.mkdir(parents=True) + (validate_only / "validate.esp32-idf.yaml").write_text("") + + no_tests = tests_dir / "no_tests" + no_tests.mkdir(parents=True) + (no_tests / "common.yaml").write_text("") + + return tmp_path + + +def _names(paths: list[Path]) -> set[str]: + return {p.name for p in paths} + + +def test_get_component_test_files_default_excludes_validate( + fake_component_tests: Path, monkeypatch: MonkeyPatch +) -> None: + """Default behaviour: only base test.*.yaml; no variants, no validate.""" + monkeypatch.setattr(helpers, "root_path", str(fake_component_tests)) + + files = helpers.get_component_test_files("demo") + + assert _names(files) == {"test.esp32-idf.yaml", "test.esp8266-ard.yaml"} + + +def test_get_component_test_files_all_variants_excludes_validate( + fake_component_tests: Path, monkeypatch: MonkeyPatch +) -> None: + """all_variants=True picks up test variants but still skips validate.""" + monkeypatch.setattr(helpers, "root_path", str(fake_component_tests)) + + files = helpers.get_component_test_files("demo", all_variants=True) + + assert _names(files) == { + "test.esp32-idf.yaml", + "test.esp8266-ard.yaml", + "test-variant.esp32-idf.yaml", + } + + +def test_get_component_test_files_include_validate_base_only( + fake_component_tests: Path, monkeypatch: MonkeyPatch +) -> None: + """include_validate=True with base-only adds validate.*.yaml only.""" + monkeypatch.setattr(helpers, "root_path", str(fake_component_tests)) + + files = helpers.get_component_test_files("demo", include_validate=True) + + assert _names(files) == { + "test.esp32-idf.yaml", + "test.esp8266-ard.yaml", + "validate.esp32-idf.yaml", + } + + +def test_get_component_test_files_include_validate_all_variants( + fake_component_tests: Path, monkeypatch: MonkeyPatch +) -> None: + """include_validate=True with all_variants adds validate variants too.""" + monkeypatch.setattr(helpers, "root_path", str(fake_component_tests)) + + files = helpers.get_component_test_files( + "demo", all_variants=True, include_validate=True + ) + + assert _names(files) == { + "test.esp32-idf.yaml", + "test.esp8266-ard.yaml", + "test-variant.esp32-idf.yaml", + "validate.esp32-idf.yaml", + "validate-legacy.esp32-idf.yaml", + } + + +def test_get_component_test_files_validate_only_component( + fake_component_tests: Path, monkeypatch: MonkeyPatch +) -> None: + """A component with only validate files is invisible without the flag.""" + monkeypatch.setattr(helpers, "root_path", str(fake_component_tests)) + + assert helpers.get_component_test_files("validate_only") == [] + assert helpers.get_component_test_files("validate_only", all_variants=True) == [] + + files = helpers.get_component_test_files( + "validate_only", all_variants=True, include_validate=True + ) + assert _names(files) == {"validate.esp32-idf.yaml"} + + +def test_get_component_test_files_missing_component( + fake_component_tests: Path, monkeypatch: MonkeyPatch +) -> None: + """Unknown components return an empty list, regardless of flags.""" + monkeypatch.setattr(helpers, "root_path", str(fake_component_tests)) + + assert ( + helpers.get_component_test_files( + "does_not_exist", all_variants=True, include_validate=True + ) + == [] + ) + + +def test_get_component_test_files_component_without_tests( + fake_component_tests: Path, monkeypatch: MonkeyPatch +) -> None: + """A component with only common.yaml and no test/validate files returns [].""" + monkeypatch.setattr(helpers, "root_path", str(fake_component_tests)) + + assert ( + helpers.get_component_test_files( + "no_tests", all_variants=True, include_validate=True + ) + == [] + ) + + +@pytest.mark.parametrize( + ("filename", "expected"), + [ + ("validate.esp32-idf.yaml", True), + ("validate-legacy.esp32-idf.yaml", True), + ("validate.host.yaml", True), + ("test.esp32-idf.yaml", False), + ("test-variant.esp32-idf.yaml", False), + ("common.yaml", False), + # Defensive: a hypothetical name starting with "validate" but not + # following the grammar must not be classified as a validate file. + ("validatesomething.yaml", False), + ], +) +def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> None: + assert helpers.is_validate_only_file(tmp_path / filename) is expected From 531367d7e16b3928e4fd88c2f2a1a04748aef7ed Mon Sep 17 00:00:00 2001 From: George Galt Date: Tue, 12 May 2026 19:47:54 -0400 Subject: [PATCH 1193/2214] [micro_wake_word] Increase INFERENCE_TASK_STACK_SIZE to 8192 for P4 chip (#16390) --- esphome/components/micro_wake_word/micro_wake_word.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index c031a9f269..6877e9e5df 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -23,7 +23,13 @@ static const size_t DATA_TIMEOUT_MS = 50; static const uint32_t RING_BUFFER_DURATION_MS = 120; +#ifdef CONFIG_IDF_TARGET_ESP32P4 +// ESP32-P4 PIE-optimized esp-nn kernels (e.g. depthwise_conv_s8_ch1_pie) require +// significantly more stack than other variants, causing stack protection faults at 3072. +static const uint32_t INFERENCE_TASK_STACK_SIZE = 8192; +#else static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072; +#endif static const UBaseType_t INFERENCE_TASK_PRIORITY = 3; enum EventGroupBits : uint32_t { From 8b6cbc9f2b45cba08efef7c416d6ab0667690f40 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 May 2026 19:58:03 -0400 Subject: [PATCH 1194/2214] [i2s_audio] Speaker uses new RingBufferAudioSource (#16315) Thanks! --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 34 +++++++++--------- .../speaker/i2s_audio_speaker_standard.cpp | 35 ++++++++++--------- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index d257dd1d8f..8f67562a77 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -143,7 +143,11 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info - const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration); + const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. + const size_t ring_buffer_size = + (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; @@ -151,13 +155,13 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); bool successful_setup = false; - std::unique_ptr transfer_buffer = - audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer); + std::unique_ptr audio_source; - if (transfer_buffer != nullptr) { + { std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); - if (temp_ring_buffer.use_count() == 1) { - transfer_buffer->set_source(temp_ring_buffer); + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, bytes_to_fill_single_dma_buffer, + static_cast(bytes_per_frame)); + if (audio_source != nullptr) { this->audio_ring_buffer_ = temp_ring_buffer; successful_setup = true; } @@ -297,24 +301,24 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { if (!this->pause_state_) { while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) { - if (transfer_buffer->available() == 0) { - size_t bytes_read = transfer_buffer->transfer_data_from_source(read_timeout_ticks); + if (audio_source->available() == 0) { + size_t bytes_read = audio_source->fill(read_timeout_ticks, false); if (bytes_read == 0) { break; // No upstream data within the read budget; silence-pad the remainder. } - uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read; + uint8_t *new_data = audio_source->mutable_data(); this->apply_software_volume_(new_data, bytes_read); this->swap_esp32_mono_samples_(new_data, bytes_read); } const uint32_t frames_still_needed = SPDIF_BLOCK_SAMPLES - real_frames_in_block; const size_t bytes_still_needed = this->current_stream_info_.frames_to_bytes(frames_still_needed); - const size_t bytes_to_feed = std::min(transfer_buffer->available(), bytes_still_needed); + const size_t bytes_to_feed = std::min(audio_source->available(), bytes_still_needed); uint32_t blocks_sent = 0; size_t pcm_consumed = 0; - esp_err_t err = this->spdif_encoder_->write(transfer_buffer->get_buffer_start(), bytes_to_feed, - write_timeout_ticks, &blocks_sent, &pcm_consumed); + esp_err_t err = this->spdif_encoder_->write(audio_source->data(), bytes_to_feed, write_timeout_ticks, + &blocks_sent, &pcm_consumed); if (err != ESP_OK) { // A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer; // resuming would credit the next iteration's bytes against an old block. Bail and @@ -325,7 +329,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } if (pcm_consumed > 0) { - transfer_buffer->decrease_buffer_length(pcm_consumed); + audio_source->consume(pcm_consumed); real_frames_in_block += this->current_stream_info_.bytes_to_frames(pcm_consumed); } if (blocks_sent > 0) { @@ -387,9 +391,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { this->spdif_encoder_->reset(); } - if (transfer_buffer != nullptr) { - transfer_buffer.reset(); - } + audio_source.reset(); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 51f2b225e2..0c8b8be522 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -44,19 +44,23 @@ void I2SAudioSpeaker::run_speaker_task() { const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info - const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration); + const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. + const size_t ring_buffer_size = + (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; const uint32_t frames_to_fill_single_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); const size_t bytes_to_fill_single_dma_buffer = this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); bool successful_setup = false; - std::unique_ptr transfer_buffer = - audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer); + std::unique_ptr audio_source; - if (transfer_buffer != nullptr) { + { std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); - if (temp_ring_buffer.use_count() == 1) { - transfer_buffer->set_source(temp_ring_buffer); + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, bytes_to_fill_single_dma_buffer, + static_cast(bytes_per_frame)); + if (audio_source != nullptr) { this->audio_ring_buffer_ = temp_ring_buffer; successful_setup = true; } @@ -129,15 +133,15 @@ void I2SAudioSpeaker::run_speaker_task() { // The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000 uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2; - size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay)); - uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read; + size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(read_delay), false); + uint8_t *new_data = audio_source->mutable_data(); if (bytes_read > 0) { this->apply_software_volume_(new_data, bytes_read); this->swap_esp32_mono_samples_(new_data, bytes_read); } - if (transfer_buffer->available() == 0) { + if (audio_source->available() == 0) { if (stop_gracefully && tx_dma_underflow) { break; } @@ -150,18 +154,17 @@ void I2SAudioSpeaker::run_speaker_task() { i2s_channel_disable(this->tx_handle_); const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr}; i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this); - i2s_channel_preload_data(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(), - &bytes_written); + i2s_channel_preload_data(this->tx_handle_, audio_source->data(), audio_source->available(), &bytes_written); } else { // Audio is already playing, use regular write to add to the DMA buffers - i2s_channel_write(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(), - &bytes_written, DMA_BUFFER_DURATION_MS); + i2s_channel_write(this->tx_handle_, audio_source->data(), audio_source->available(), &bytes_written, + DMA_BUFFER_DURATION_MS); } if (bytes_written > 0) { last_data_received_time = millis(); frames_written += this->current_stream_info_.bytes_to_frames(bytes_written); - transfer_buffer->decrease_buffer_length(bytes_written); + audio_source->consume(bytes_written); if (tx_dma_underflow) { tx_dma_underflow = false; @@ -178,9 +181,7 @@ void I2SAudioSpeaker::run_speaker_task() { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); - if (transfer_buffer != nullptr) { - transfer_buffer.reset(); - } + audio_source.reset(); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED); From 7f37ee3c53a1e10a0450f4b3a3a2b44b199489f1 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 May 2026 19:58:32 -0400 Subject: [PATCH 1195/2214] [mixer] Use RingBufferAudioSource (#16316) --- .../mixer/speaker/mixer_speaker.cpp | 97 ++++++++++--------- .../components/mixer/speaker/mixer_speaker.h | 14 +-- 2 files changed, 59 insertions(+), 52 deletions(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 8dea4560c2..1a995a6edf 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -182,7 +182,7 @@ void SourceSpeaker::loop() { break; } case speaker::STATE_RUNNING: - if (!this->transfer_buffer_->has_buffered_data() && + if (!this->audio_source_->has_buffered_data() && (this->pending_playback_frames_.load(std::memory_order_acquire) == 0)) { // No audio data in buffer waiting to get mixed and no frames are pending playback if ((this->timeout_ms_.has_value() && ((millis() - this->last_seen_data_ms_) > this->timeout_ms_.value())) || @@ -254,15 +254,12 @@ void SourceSpeaker::send_command_(uint32_t command_bit, bool wake_loop) { void SourceSpeaker::start() { this->send_command_(SOURCE_SPEAKER_COMMAND_START, true); } esp_err_t SourceSpeaker::start_() { - const size_t ring_buffer_size = this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_); - if (this->transfer_buffer_.use_count() == 0) { - this->transfer_buffer_ = - audio::AudioSourceTransferBuffer::create(this->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); - - if (this->transfer_buffer_ == nullptr) { - return ESP_ERR_NO_MEM; - } - + const size_t bytes_per_frame = this->audio_stream_info_.frames_to_bytes(1); + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. + const size_t ring_buffer_size = + (this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; + if (this->audio_source_.use_count() == 0) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (!temp_ring_buffer) { temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); @@ -271,9 +268,15 @@ esp_err_t SourceSpeaker::start_() { if (!temp_ring_buffer) { return ESP_ERR_NO_MEM; - } else { - this->transfer_buffer_->set_source(temp_ring_buffer); } + + std::unique_ptr source = audio::RingBufferAudioSource::create( + temp_ring_buffer, this->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS), + static_cast(bytes_per_frame)); + if (source == nullptr) { + return ESP_ERR_NO_MEM; + } + this->audio_source_ = std::move(source); } return this->parent_->start(this->audio_stream_info_); @@ -284,7 +287,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); } void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); } bool SourceSpeaker::has_buffered_data() const { - return ((this->transfer_buffer_.use_count() > 0) && this->transfer_buffer_->has_buffered_data()); + return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data()); } void SourceSpeaker::set_mute_state(bool mute_state) { @@ -301,16 +304,18 @@ void SourceSpeaker::set_volume(float volume) { float SourceSpeaker::get_volume() { return this->parent_->get_output_speaker()->get_volume(); } -size_t SourceSpeaker::process_data_from_source(std::shared_ptr &transfer_buffer, +size_t SourceSpeaker::process_data_from_source(std::shared_ptr &audio_source, TickType_t ticks_to_wait) { - // Store current offset, as these samples are already ducked - const size_t current_length = transfer_buffer->available(); + if (audio_source->available() > 0) { + // Existing exposure was ducked when fill() promoted it; do not re-duck on partial-consume re-entry. + return 0; + } - size_t bytes_read = transfer_buffer->transfer_data_from_source(ticks_to_wait); + size_t bytes_read = audio_source->fill(ticks_to_wait, false); uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - int16_t *current_buffer = reinterpret_cast(transfer_buffer->get_buffer_start() + current_length); + int16_t *current_buffer = reinterpret_cast(audio_source->mutable_data()); duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_, &this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_, @@ -406,7 +411,7 @@ void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_t void SourceSpeaker::enter_stopping_state_() { this->state_ = speaker::STATE_STOPPING; this->stopping_start_ms_ = millis(); - this->transfer_buffer_.reset(); + this->audio_source_.reset(); } void MixerSpeaker::dump_config() { @@ -612,9 +617,9 @@ void MixerSpeaker::audio_mixer_task(void *params) { // Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema) FixedVector speakers_with_data; - FixedVector> transfer_buffers_with_data; + FixedVector> audio_sources_with_data; speakers_with_data.init(this_mixer->source_speakers_.size()); - transfer_buffers_with_data.init(this_mixer->source_speakers_.size()); + audio_sources_with_data.init(this_mixer->source_speakers_.size()); while (true) { uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_); @@ -629,27 +634,27 @@ void MixerSpeaker::audio_mixer_task(void *params) { this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free()); speakers_with_data.clear(); - transfer_buffers_with_data.clear(); + audio_sources_with_data.clear(); for (auto &speaker : this_mixer->source_speakers_) { if (speaker->is_running() && !speaker->get_pause_state()) { // Speaker is running and not paused, so it possibly can provide audio data - std::shared_ptr transfer_buffer = speaker->get_transfer_buffer().lock(); - if (transfer_buffer.use_count() == 0) { - // No transfer buffer allocated, so skip processing this speaker + std::shared_ptr audio_source = speaker->get_audio_source().lock(); + if (audio_source.use_count() == 0) { + // No audio source allocated, so skip processing this speaker continue; } - speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers + speaker->process_data_from_source(audio_source, 0); // Exposes and ducks audio from source ring buffers - if (transfer_buffer->available() > 0) { - // Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop - transfer_buffers_with_data.push_back(transfer_buffer); + if (audio_source->available() > 0) { + // Retain shared ownership across the mixing pass so the source isn't released mid-mix + audio_sources_with_data.push_back(audio_source); speakers_with_data.push_back(speaker); } } } - if (transfer_buffers_with_data.empty()) { + if (audio_sources_with_data.empty()) { // No audio available for transferring, block task temporarily delay(TASK_DELAY_MS); continue; @@ -657,7 +662,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { uint32_t frames_to_mix = output_frames_free; - if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) { + if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) { // Only one speaker has audio data, just copy samples over audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info(); @@ -667,10 +672,10 @@ void MixerSpeaker::audio_mixer_task(void *params) { // Speaker's sample rate matches the output speaker's, copy directly const uint32_t frames_available_in_buffer = - active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available()); + active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available()); frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - copy_frames(reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()), - active_stream_info, reinterpret_cast(output_transfer_buffer->get_buffer_end()), + copy_frames(reinterpret_cast(audio_sources_with_data[0]->data()), active_stream_info, + reinterpret_cast(output_transfer_buffer->get_buffer_end()), this_mixer->audio_stream_info_.value(), frames_to_mix); // Set playback delay for newly contributing source @@ -682,7 +687,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { // Update source speaker pending frames speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); - transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); + audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix)); // Update output transfer buffer length and pipeline frame count output_transfer_buffer->increase_buffer_length( @@ -709,25 +714,25 @@ void MixerSpeaker::audio_mixer_task(void *params) { } } else { // Determine how many frames to mix - for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { - const uint32_t frames_available_in_buffer = speakers_with_data[i]->get_audio_stream_info().bytes_to_frames( - transfer_buffers_with_data[i]->available()); + for (size_t i = 0; i < audio_sources_with_data.size(); ++i) { + const uint32_t frames_available_in_buffer = + speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available()); frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); } - int16_t *primary_buffer = reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()); + const int16_t *primary_buffer = reinterpret_cast(audio_sources_with_data[0]->data()); audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); // Mix two streams together - for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) { + for (size_t i = 1; i < audio_sources_with_data.size(); ++i) { mix_audio_samples(primary_buffer, primary_stream_info, - reinterpret_cast(transfer_buffers_with_data[i]->get_buffer_start()), + reinterpret_cast(audio_sources_with_data[i]->data()), speakers_with_data[i]->get_audio_stream_info(), reinterpret_cast(output_transfer_buffer->get_buffer_end()), this_mixer->audio_stream_info_.value(), frames_to_mix); - if (i != transfer_buffers_with_data.size() - 1) { + if (i != audio_sources_with_data.size() - 1) { // Need to mix more streams together, point primary buffer and stream info to the already mixed output - primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); + primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); primary_stream_info = this_mixer->audio_stream_info_.value(); } } @@ -735,8 +740,8 @@ void MixerSpeaker::audio_mixer_task(void *params) { // Get current pipeline depth for delay calculation (before incrementing) uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire); - // Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks - for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { + // Update source audio source consumption and add new audio durations to the source speaker pending playbacks + for (size_t i = 0; i < audio_sources_with_data.size(); ++i) { // Set playback delay for newly contributing sources if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) { speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release); @@ -744,7 +749,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { } speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); - transfer_buffers_with_data[i]->decrease_buffer_length( + audio_sources_with_data[i]->consume( speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix)); } diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index f392e83081..f57bead679 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -67,11 +67,13 @@ class SourceSpeaker : public speaker::Speaker, public Component { void set_pause_state(bool pause_state) override { this->pause_state_ = pause_state; } bool get_pause_state() const override { return this->pause_state_; } - /// @brief Transfers audio from the ring buffer into the transfer buffer. Ducks audio while transferring. - /// @param transfer_buffer Locked shared_ptr to the transfer buffer (must be valid, not null) + /// @brief Exposes the next ring buffer chunk (zero-copy) and ducks the freshly exposed bytes in place. + /// If the source still has bytes from a prior partial consume, this is a no-op (those bytes were already + /// ducked on the fill that exposed them). + /// @param audio_source Locked shared_ptr to the audio source (must be valid, not null) /// @param ticks_to_wait FreeRTOS ticks to wait while waiting to read from the ring buffer. - /// @return Number of bytes transferred from the ring buffer. - size_t process_data_from_source(std::shared_ptr &transfer_buffer, + /// @return Number of bytes newly exposed from the ring buffer. + size_t process_data_from_source(std::shared_ptr &audio_source, TickType_t ticks_to_wait); /// @brief Sets the ducking level for the source speaker. @@ -83,7 +85,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { void set_parent(MixerSpeaker *parent) { this->parent_ = parent; } void set_timeout(uint32_t ms) { this->timeout_ms_ = ms; } - std::weak_ptr get_transfer_buffer() { return this->transfer_buffer_; } + std::weak_ptr get_audio_source() { return this->audio_source_; } protected: friend class MixerSpeaker; @@ -106,7 +108,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { MixerSpeaker *parent_; - std::shared_ptr transfer_buffer_; + std::shared_ptr audio_source_; std::weak_ptr ring_buffer_; uint32_t buffer_duration_ms_; From 1c2043e0544bfa7d1193d3a86144574d7fee0655 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 12 May 2026 20:04:54 -0400 Subject: [PATCH 1196/2214] [esp32] Relax -Werror=reorder and -Werror=maybe-uninitialized on native ESP-IDF (#16392) --- esphome/components/esp32/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index bb823937aa..221c84c149 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1754,7 +1754,9 @@ async def to_code(config): ) else: cg.add_build_flag("-Wno-error=format") + cg.add_build_flag("-Wno-error=maybe-uninitialized") cg.add_build_flag("-Wno-error=missing-field-initializers") + cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") cg.set_cpp_standard("gnu++20") From dc95b22c763133354873a57f9c5ba1dfd391d85e Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Wed, 13 May 2026 00:07:49 +0000 Subject: [PATCH 1197/2214] [safe_mode] Allow recovering soft-bricked devices via reboot to recovery partition (#16339) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/safe_mode/safe_mode.cpp | 76 +++++++++++++++++++++- esphome/components/safe_mode/safe_mode.h | 5 +- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index bae5e42b9b..5c0047dca0 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -15,6 +15,7 @@ #elif defined(USE_ESP32) #include #include +#include #endif #endif @@ -22,6 +23,37 @@ namespace esphome::safe_mode { static const char *const TAG = "safe_mode"; +#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS) +// Find a non-running app partition. If verify is true, only returns a partition +// whose image passes verification (expensive: reads flash). Returns nullptr if none found. +static const esp_partition_t *find_alternate_app_partition(bool verify) { + const esp_partition_t *running = esp_ota_get_running_partition(); + const esp_partition_t *result = nullptr; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { + const esp_partition_t *p = esp_partition_get(it); + if (p->address != running->address) { + if (!verify) { + result = p; + break; + } + esp_image_metadata_t data = {}; + const esp_partition_pos_t part_pos = { + .offset = p->address, + .size = p->size, + }; + if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &data) == ESP_OK) { + result = p; + break; + } + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + return result; +} +#endif + void SafeModeComponent::dump_config() { ESP_LOGCONFIG(TAG, "Safe Mode:\n" @@ -34,7 +66,11 @@ void SafeModeComponent::dump_config() { #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) const char *state_str; if (this->ota_state_ == ESP_OTA_IMG_NEW) { +#ifdef USE_OTA_PARTITIONS + state_str = "support unknown"; +#else state_str = "not supported"; +#endif } else if (this->ota_state_ == ESP_OTA_IMG_PENDING_VERIFY) { state_str = "supported"; } else { @@ -64,6 +100,18 @@ void SafeModeComponent::dump_config() { " See https://esphome.io/guides/faq.html#brownout-detector-was-triggered"); } } + if (!this->app_ota_possible_) { + ESP_LOGW(TAG, "OTA updates are impossible."); +#ifdef USE_OTA_PARTITIONS + ESP_LOGW(TAG, " OTA partition table update or serial flashing is required."); +#else + if (find_alternate_app_partition(false) != nullptr) { + ESP_LOGW(TAG, " Activate safe mode to reboot to the recovery partition."); + } else { + ESP_LOGE(TAG, " No recovery partition available; serial flashing is required."); + } +#endif + } #endif } @@ -124,8 +172,10 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) // Check partition state to detect if bootloader supports rollback - const esp_partition_t *running = esp_ota_get_running_partition(); - esp_ota_get_state_partition(running, &this->ota_state_); + const esp_partition_t *running_part = esp_ota_get_running_partition(); + esp_ota_get_state_partition(running_part, &this->ota_state_); + const esp_partition_t *next_part = esp_ota_get_next_update_partition(nullptr); + this->app_ota_possible_ = (next_part != nullptr && next_part != running_part); #endif uint32_t rtc_val = this->read_rtc_(); @@ -151,6 +201,28 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en ESP_LOGE(TAG, "Boot loop detected"); } +#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS) + // Allow recovery of soft-bricked devices + // Instead of starting safe_mode, reboot to the other app partition if all conditions are met: + // - app OTA is impossible (for example because the other app partition has type 'factory') + // - the other app partition contains a valid app (for example Tasmota safeboot image or ESPHome) + // - allow_partition_access is not configured making recovery via partition table update impossible + // Image verification is deferred until here so the cost is only paid when entering safe mode, + // not on every boot. + if (!this->app_ota_possible_) { + const esp_partition_t *rollback_part = find_alternate_app_partition(true); + if (rollback_part != nullptr) { + esp_err_t err = esp_ota_set_boot_partition(rollback_part); + if (err == ESP_OK) { + ESP_LOGW(TAG, "OTA updates are impossible. Rebooting to recovery app."); + App.reboot(); + } else { + ESP_LOGE(TAG, "Failed to set recovery boot partition: %s", esp_err_to_name(err)); + } + } + } +#endif + this->status_set_error(); this->set_timeout(enable_time, []() { ESP_LOGW(TAG, "Timeout, restarting"); diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index b458a9a302..94db4357eb 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -48,11 +48,14 @@ class SafeModeComponent final : public Component { uint32_t safe_mode_enable_time_{60000}; ///< The time safe mode should remain active for uint32_t safe_mode_rtc_value_{0}; uint32_t safe_mode_start_time_{0}; ///< stores when safe mode was enabled +#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) + esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED}; // 4-byte enum +#endif // Group 1-byte members together to minimize padding bool boot_successful_{false}; ///< set to true after boot is considered successful uint8_t safe_mode_num_attempts_{0}; #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) - esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED}; + bool app_ota_possible_{true}; #endif // Larger objects at the end ESPPreferenceObject rtc_; From 3df0527c1fb8676e2eb0997b7c5bd4e8ee073836 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 May 2026 20:10:32 -0400 Subject: [PATCH 1198/2214] [audio] Document ring buffer source thread safety (#16393) --- esphome/components/audio/audio_transfer_buffer.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index d83032582e..b713326141 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -220,6 +220,10 @@ class ConstAudioSourceBuffer : public AudioReadableBuffer { /// source transparently stitches frames that straddle the ring buffer's wrap boundary by buffering the /// trailing partial frame from one chunk and joining it with the head of the next chunk in a small /// internal splice buffer, so callers always see frame-aligned data. +/// +/// Not thread-safe. The underlying ring_buffer::RingBuffer supports one producer and one consumer +/// running concurrently, but a given RingBufferAudioSource (its acquired item, splice buffer, and +/// queued region) must be used by only one thread, and that thread is the ring buffer's consumer. class RingBufferAudioSource : public AudioReadableBuffer { public: /// Maximum supported alignment. Sized to cover 32-bit samples across up to 2 channels (8 bytes). @@ -242,6 +246,8 @@ class RingBufferAudioSource : public AudioReadableBuffer { size_t available() const override { return this->current_available_; } void consume(size_t bytes) override; bool has_buffered_data() const override; + /// pre_shift is ignored: there is no intermediate transfer buffer to compact, so an unconsumed + /// exposure stays in place and fill() returns 0 until it is fully consumed. size_t fill(TickType_t ticks_to_wait, bool pre_shift) override; /// @brief Returns a mutable pointer to the currently exposed audio data. From 65b53692bdd5ba21d90cfe18d97de4f9102a2b97 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 May 2026 21:36:26 -0400 Subject: [PATCH 1199/2214] [i2s_audio] Properly track DMA input/output (#16317) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- .../i2s_audio/speaker/i2s_audio_speaker.h | 4 +- .../speaker/i2s_audio_speaker_standard.cpp | 252 ++++++++++++------ 3 files changed, 178 insertions(+), 80 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 27961050e6..680ca069c0 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -99,7 +99,7 @@ void I2SAudioSpeakerBase::loop() { } if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) { - ESP_LOGE(TAG, "Not enough memory"); + ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index c57af2775b..20bb05e322 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -36,9 +36,7 @@ enum SpeakerEventGroupBits : uint32_t { ERR_ESP_NO_MEM = (1 << 19), ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event - ERR_PARTIAL_WRITE = (1 << 21), // a DMA write returned fewer bytes than requested (or the encoder - // failed to commit a complete block), which breaks the lockstep - // invariant for every subsequent event + ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 0c8b8be522..e69601e87a 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -17,7 +17,14 @@ namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; static constexpr size_t DMA_BUFFERS_COUNT = 4; -static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1; +// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, +// doubled so that a transient backlog never overruns the queue (which would desync the lockstep +// invariant between i2s_event_queue_ and write_records_queue_). +static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2; +// Generous timeout for ``i2s_channel_write`` blocking. A buffer frees roughly every +// DMA_BUFFER_DURATION_MS, so a multiple of that gives plenty of slack against scheduling jitter +// without masking real failures. +static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1)); void I2SAudioSpeaker::dump_config() { I2SAudioSpeakerBase::dump_config(); @@ -49,30 +56,73 @@ void I2SAudioSpeaker::run_speaker_task() { // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; - const uint32_t frames_to_fill_single_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); - const size_t bytes_to_fill_single_dma_buffer = - this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); + const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); bool successful_setup = false; + std::unique_ptr audio_source; - { + // Pre-zeroed buffer used to silence-pad each DMA descriptor whenever real audio doesn't fully fill it. + RAMAllocator silence_allocator; + uint8_t *silence_buffer = silence_allocator.allocate(dma_buffer_bytes); + + if (silence_buffer != nullptr) { + memset(silence_buffer, 0, dma_buffer_bytes); + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); - audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, bytes_to_fill_single_dma_buffer, - static_cast(bytes_per_frame)); + audio_source = + audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_bytes, static_cast(bytes_per_frame)); + if (audio_source != nullptr) { + // audio_source is nullptr if the ring buffer fails to allocate this->audio_ring_buffer_ = temp_ring_buffer; successful_setup = true; } } + if (successful_setup) { + // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer. + // This guarantees that every on_sent event has a corresponding write record from the start, so + // ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime. + for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) { + size_t bytes_loaded = 0; + esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded); + if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) { + ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err, + (unsigned) bytes_loaded); + successful_setup = false; + break; + } + uint32_t zero_real_frames = 0; + if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) { + // Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries. + ESP_LOGV(TAG, "Failed to push preload write record"); + successful_setup = false; + break; + } + } + } + + if (successful_setup) { + // Register the on_sent callback BEFORE enabling the channel so the very first transmitted buffer + // generates a queued event that pairs with the first preloaded silence record. + const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + + if (i2s_channel_enable(this->tx_handle_) != ESP_OK) { + ESP_LOGV(TAG, "Failed to enable I2S channel"); + successful_setup = false; + } + } + if (!successful_setup) { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); } else { bool stop_gracefully = false; - bool tx_dma_underflow = true; - - uint32_t frames_written = 0; + // Number of records currently in ``write_records_queue_`` that carry real audio. Used by graceful + // stop to wait until every real-audio buffer has been confirmed played by an ISR event. + uint32_t pending_real_buffers = 0; uint32_t last_data_received_time = millis(); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); @@ -81,11 +131,21 @@ void I2SAudioSpeaker::run_speaker_task() { // - Paused, OR // - No timeout configured, OR // - Timeout hasn't elapsed since last data + // + // Always-fill model: every iteration writes exactly one DMA buffer's worth, mixing real audio + // and silence padding as needed. The blocking ``i2s_channel_write`` paces the loop at the DMA + // consumption rate, and every buffer write is matched 1:1 with a record on ``write_records_queue_``. + // + // While paused, the real-audio fill is skipped and the entire DMA buffer is filled with silence; + // the same blocking ``i2s_channel_write`` provides natural pacing (one buffer per ~DMA_BUFFER_DURATION_MS), + // so the lockstep invariant is preserved without burning CPU. while (this->pause_state_ || !this->timeout_.has_value() || (millis() - last_data_received_time) <= this->timeout_.value()) { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { + // COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion + // event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases). xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); break; @@ -101,80 +161,115 @@ void I2SAudioSpeaker::run_speaker_task() { break; } + // Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_ + // entry by construction (preloaded records at startup, plus exactly one record pushed per + // iteration alongside exactly one DMA-buffer-sized write). int64_t write_timestamp; + bool lockstep_broken = false; while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) { - // Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes - // on the timing info via the audio_output_callback. - uint32_t frames_sent = frames_to_fill_single_dma_buffer; - if (frames_to_fill_single_dma_buffer > frames_written) { - tx_dma_underflow = true; - frames_sent = frames_written; - const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written; - write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed); - } else { - tx_dma_underflow = false; - } - frames_written -= frames_sent; - - // Standard I2S mode: fire callback immediately for each event - if (frames_sent > 0) { - this->audio_output_callback_(frames_sent, write_timestamp); - } - } - - if (this->pause_state_) { - // Pause state is accessed atomically, so thread safe - // Delay so the task yields, then skip transferring audio data - vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); - continue; - } - - // Wait half the duration of the data already written to the DMA buffers for new audio data - // The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000 - uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2; - - size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(read_delay), false); - uint8_t *new_data = audio_source->mutable_data(); - - if (bytes_read > 0) { - this->apply_software_volume_(new_data, bytes_read); - this->swap_esp32_mono_samples_(new_data, bytes_read); - } - - if (audio_source->available() == 0) { - if (stop_gracefully && tx_dma_underflow) { + uint32_t real_frames = 0; + if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { + // Should never happen: would indicate the lockstep invariant is broken. + ESP_LOGV(TAG, "Event without matching write record"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + lockstep_broken = true; break; } - vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2)); - } else { - size_t bytes_written = 0; - - if (tx_dma_underflow) { - // Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue - i2s_channel_disable(this->tx_handle_); - const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr}; - i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this); - i2s_channel_preload_data(this->tx_handle_, audio_source->data(), audio_source->available(), &bytes_written); - } else { - // Audio is already playing, use regular write to add to the DMA buffers - i2s_channel_write(this->tx_handle_, audio_source->data(), audio_source->available(), &bytes_written, - DMA_BUFFER_DURATION_MS); + if (real_frames > 0) { + pending_real_buffers--; + // Real audio is packed at the start of each DMA buffer with any silence padding on the + // tail, so the real audio finished playing earlier than the buffer-completion timestamp + // by the duration of the trailing zeros. + const uint32_t silence_frames = frames_per_dma_buffer - real_frames; + const int64_t adjusted_ts = + write_timestamp - this->current_stream_info_.frames_to_microseconds(silence_frames); + this->audio_output_callback_(real_frames, adjusted_ts); } + } + if (lockstep_broken) { + break; + } - if (bytes_written > 0) { - last_data_received_time = millis(); - frames_written += this->current_stream_info_.bytes_to_frames(bytes_written); - audio_source->consume(bytes_written); + // Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring + // buffer has nothing left to hand over, and every real-audio buffer we submitted has been + // confirmed played. ``has_buffered_data()`` returns bytes still sitting in the ring buffer + // awaiting fill(). + if (stop_gracefully && audio_source->available() == 0 && !this->has_buffered_data() && + pending_real_buffers == 0) { + ESP_LOGV(TAG, "Exiting: graceful stop complete"); + break; + } - if (tx_dma_underflow) { - tx_dma_underflow = false; - // Enable the on_sent callback and channel after preload - xQueueReset(this->i2s_event_queue_); - const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; - i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); - i2s_channel_enable(this->tx_handle_); + // Compose exactly one DMA buffer's worth: drain as much real audio as the source currently + // exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any + // remainder with silence. All writes pack into the next free DMA descriptor in order, so the + // descriptor ends up holding [real audio][silence padding]. + size_t bytes_written_total = 0; + size_t real_bytes_total = 0; + bool partial_write_failure = false; + + if (!this->pause_state_) { + while (bytes_written_total < dma_buffer_bytes) { + size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false); + if (bytes_read > 0) { + uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read; + this->apply_software_volume_(new_data, bytes_read); + this->swap_esp32_mono_samples_(new_data, bytes_read); } + + const size_t to_write = std::min(audio_source->available(), dma_buffer_bytes - bytes_written_total); + if (to_write == 0) { + // Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer + // with silence so the lockstep invariant (one write per iteration) is preserved. + break; + } + + size_t bw = 0; + i2s_channel_write(this->tx_handle_, audio_source->data(), to_write, &bw, WRITE_TIMEOUT_TICKS); + if (bw != to_write) { + // A short real-audio write breaks DMA descriptor alignment for every subsequent event; + // the only safe recovery is to restart the task. + ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) to_write); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + partial_write_failure = true; + break; + } + audio_source->consume(bw); + bytes_written_total += bw; + real_bytes_total += bw; } + if (real_bytes_total > 0) { + last_data_received_time = millis(); + } + } + + if (partial_write_failure) { + break; + } + + const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; + if (silence_bytes > 0) { + size_t bw = 0; + i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (bw != silence_bytes) { + // Same descriptor-alignment hazard as a partial real-audio write. + ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + break; + } + } + + const uint32_t real_frames_in_buffer = this->current_stream_info_.bytes_to_frames(real_bytes_total); + // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this + // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep + // invariant is broken and every subsequent timestamp would be silently wrong, so bail. + if (xQueueSend(this->write_records_queue_, &real_frames_in_buffer, 0) != pdTRUE) { + ESP_LOGV(TAG, "Exiting: write records queue full"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + break; + } + if (real_frames_in_buffer > 0) { + pending_real_buffers++; } } } @@ -183,6 +278,11 @@ void I2SAudioSpeaker::run_speaker_task() { audio_source.reset(); + if (silence_buffer != nullptr) { + silence_allocator.deallocate(silence_buffer, dma_buffer_bytes); + silence_buffer = nullptr; + } + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED); while (true) { @@ -301,7 +401,7 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return err; } - i2s_channel_enable(this->tx_handle_); + // The speaker task will enable the channel after preloading. return ESP_OK; } From f94735dc621dc494d7383233cdd904e631fbcdad Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 12 May 2026 20:38:39 -0500 Subject: [PATCH 1200/2214] [api][voice_assistant] Add second audio channel for voice_assistant (#16265) Co-authored-by: Kevin Ahrendt Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.cpp | 7 + esphome/components/api/api_pb2.h | 4 +- esphome/components/api/api_pb2_dump.cpp | 1 + .../components/voice_assistant/__init__.py | 38 +++-- .../voice_assistant/voice_assistant.cpp | 142 +++++++++++++++--- .../voice_assistant/voice_assistant.h | 8 + .../voice_assistant/common-idf.yaml | 14 +- .../voice_assistant/test.esp32-idf.yaml | 1 + 9 files changed, 179 insertions(+), 37 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4d72be5407..f4f15c1042 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2026,6 +2026,7 @@ message VoiceAssistantAudio { bytes data = 1 [(pointer_to_buffer) = true]; bool end = 2; + bytes data2 = 3 [(pointer_to_buffer) = true]; } enum VoiceAssistantTimerEvent { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 68be7550ee..c711ef167c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2893,6 +2893,11 @@ bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited this->data_len = value.size(); break; } + case 3: { + this->data2 = value.data(); + this->data2_len = value.size(); + break; + } default: return false; } @@ -2902,12 +2907,14 @@ uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end); + ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len); return pos; } uint32_t VoiceAssistantAudio::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->data_len); size += ProtoSize::calc_bool(1, this->end); + size += ProtoSize::calc_length(1, this->data2_len); return size; } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7b82f1884d..7e926ee0d4 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -2436,13 +2436,15 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage { class VoiceAssistantAudio final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 106; - static constexpr uint8_t ESTIMATED_SIZE = 21; + static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_audio"); } #endif const uint8_t *data{nullptr}; uint16_t data_len{0}; bool end{false}; + const uint8_t *data2{nullptr}; + uint16_t data2_len{0}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 5258b355ce..850ad37bc9 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2174,6 +2174,7 @@ const char *VoiceAssistantAudio::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantAudio")); dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); dump_field(out, ESPHOME_PSTR("end"), this->end); + dump_bytes_field(out, ESPHOME_PSTR("data2"), this->data2, this->data2_len); return out.c_str(); } const char *VoiceAssistantTimerEventResponse::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 9387797ba2..958d1cbf91 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -53,6 +53,8 @@ CONF_ON_TIMER_CANCELLED = "on_timer_cancelled" CONF_ON_TIMER_FINISHED = "on_timer_finished" CONF_ON_TIMER_TICK = "on_timer_tick" +MAX_MICROPHONE_SOURCES = 2 + voice_assistant_ns = cg.esphome_ns.namespace("voice_assistant") VoiceAssistant = voice_assistant_ns.class_("VoiceAssistant", cg.Component) @@ -90,13 +92,20 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(VoiceAssistant), - cv.Optional( - CONF_MICROPHONE, default={} - ): microphone.microphone_source_schema( - min_bits_per_sample=16, - max_bits_per_sample=16, - min_channels=1, - max_channels=1, + cv.Optional(CONF_MICROPHONE, default=[{}]): cv.All( + cv.ensure_list( + microphone.microphone_source_schema( + min_bits_per_sample=16, + max_bits_per_sample=16, + min_channels=1, + max_channels=1, + ) + ), + cv.Length( + min=1, + max=MAX_MICROPHONE_SOURCES, + msg=f"Voice Assistant supports at most {MAX_MICROPHONE_SOURCES} microphone sources", + ), ), cv.Exclusive(CONF_MEDIA_PLAYER, "output"): cv.use_id( media_player.MediaPlayer @@ -179,10 +188,10 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = cv.All( cv.Schema( { - cv.Optional( - CONF_MICROPHONE - ): microphone.final_validate_microphone_source_schema( - "voice_assistant", sample_rate=16000 + cv.Optional(CONF_MICROPHONE): cv.ensure_list( + microphone.final_validate_microphone_source_schema( + "voice_assistant", sample_rate=16000 + ) ), }, extra=cv.ALLOW_EXTRA, @@ -194,9 +203,14 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - mic_source = await microphone.microphone_source_to_code(config[CONF_MICROPHONE]) + mic_sources = config[CONF_MICROPHONE] + mic_source = await microphone.microphone_source_to_code(mic_sources[0]) cg.add(var.set_microphone_source(mic_source)) + if len(mic_sources) > 1: + mic_source2 = await microphone.microphone_source_to_code(mic_sources[1]) + cg.add(var.set_microphone_source2(mic_source2)) + if CONF_MICRO_WAKE_WORD in config: mww = await cg.get_variable(config[CONF_MICRO_WAKE_WORD]) cg.add(var.set_micro_wake_word(mww)) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 50a8265297..286e6645d2 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -31,11 +31,21 @@ VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; } void VoiceAssistant::setup() { this->mic_source_->add_data_callback([this](const std::vector &data) { std::shared_ptr temp_ring_buffer = this->ring_buffer_; - if (this->ring_buffer_.use_count() > 1) { + if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } }); + // Second microphone channel + if (this->mic_source2_ != nullptr) { + this->mic_source2_->add_data_callback([this](const std::vector &data) { + std::shared_ptr temp_ring_buffer = this->ring_buffer2_; + if (temp_ring_buffer != nullptr) { + temp_ring_buffer->write((void *) data.data(), data.size()); + } + }); + } + #ifdef USE_MEDIA_PLAYER if (this->media_player_ != nullptr) { this->media_player_->add_on_state_callback([this](media_player::MediaPlayerState state) { @@ -115,9 +125,9 @@ bool VoiceAssistant::allocate_buffers_() { } #endif - if (this->ring_buffer_.use_count() == 0) { + if (this->ring_buffer_ == nullptr) { this->ring_buffer_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); - if (this->ring_buffer_.use_count() == 0) { + if (this->ring_buffer_ == nullptr) { ESP_LOGE(TAG, "Could not allocate ring buffer"); return false; } @@ -132,6 +142,26 @@ bool VoiceAssistant::allocate_buffers_() { } } + // Second microphone channel + if (this->mic_source2_ != nullptr) { + if (this->ring_buffer2_ == nullptr) { + this->ring_buffer2_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + if (this->ring_buffer2_ == nullptr) { + ESP_LOGE(TAG, "Could not allocate second ring buffer"); + return false; + } + } + + if (this->send_buffer2_ == nullptr) { + RAMAllocator send_allocator; + this->send_buffer2_ = send_allocator.allocate(SEND_BUFFER_SIZE); + if (this->send_buffer2_ == nullptr) { + ESP_LOGW(TAG, "Could not allocate second send buffer"); + return false; + } + } + } + return true; } @@ -144,6 +174,15 @@ void VoiceAssistant::clear_buffers_() { this->ring_buffer_->reset(); } + // Second microphone channel + if (this->send_buffer2_ != nullptr) { + memset(this->send_buffer2_, 0, SEND_BUFFER_SIZE); + } + + if (this->ring_buffer2_ != nullptr) { + this->ring_buffer2_->reset(); + } + #ifdef USE_SPEAKER if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) { memset(this->speaker_buffer_, 0, SPEAKER_BUFFER_SIZE); @@ -162,10 +201,17 @@ void VoiceAssistant::deallocate_buffers_() { this->send_buffer_ = nullptr; } - if (this->ring_buffer_.use_count() > 0) { - this->ring_buffer_.reset(); + this->ring_buffer_.reset(); + + // Second microphone channel + if (this->send_buffer2_ != nullptr) { + RAMAllocator send_deallocator; + send_deallocator.deallocate(this->send_buffer2_, SEND_BUFFER_SIZE); + this->send_buffer2_ = nullptr; } + this->ring_buffer2_.reset(); + #ifdef USE_SPEAKER if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) { RAMAllocator speaker_deallocator; @@ -183,7 +229,8 @@ void VoiceAssistant::reset_conversation_id() { void VoiceAssistant::loop() { if (this->api_client_ == nullptr && this->state_ != State::IDLE && this->state_ != State::STOP_MICROPHONE && this->state_ != State::STOPPING_MICROPHONE) { - if (this->mic_source_->is_running() || this->state_ == State::STARTING_MICROPHONE) { + if (this->mic_source_->is_running() || (this->mic_source2_ && this->mic_source2_->is_running()) || + this->state_ == State::STARTING_MICROPHONE) { this->set_state_(State::STOP_MICROPHONE, State::IDLE); } else { this->set_state_(State::IDLE, State::IDLE); @@ -215,11 +262,14 @@ void VoiceAssistant::loop() { this->clear_buffers_(); this->mic_source_->start(); + if (this->mic_source2_) { + this->mic_source2_->start(); + } this->set_state_(State::STARTING_MICROPHONE); break; } case State::STARTING_MICROPHONE: { - if (this->mic_source_->is_running()) { + if (this->mic_source_->is_running() && (!this->mic_source2_ || this->mic_source2_->is_running())) { this->set_state_(this->desired_state_); } break; @@ -266,15 +316,44 @@ void VoiceAssistant::loop() { break; // State changed when udp server port received } case State::STREAMING_MICROPHONE: { - size_t available = this->ring_buffer_->available(); - while (available >= SEND_BUFFER_SIZE) { - size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0); - if (this->audio_mode_ == AUDIO_MODE_API) { + if (this->audio_mode_ == AUDIO_MODE_API) { + // API audio + // Both microphone channels are sent, if configured + bool is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE; + bool is_available2 = false; + if (this->mic_source2_) { + is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE; + } + + while (is_available || is_available2) { api::VoiceAssistantAudio msg; - msg.data = this->send_buffer_; - msg.data_len = read_bytes; + + if (is_available) { + size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0); + msg.data = this->send_buffer_; + msg.data_len = read_bytes; + } + + // Second microphone channel + if (is_available2) { + size_t read_bytes = this->ring_buffer2_->read((void *) this->send_buffer2_, SEND_BUFFER_SIZE, 0); + msg.data2 = this->send_buffer2_; + msg.data2_len = read_bytes; + } + this->api_client_->send_message(msg); - } else { + is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE; + if (this->mic_source2_) { + is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE; + } else { + is_available2 = false; + } + } + } else { + // UDP (will eventually be deprecated) + // Only the primary microphone channel is used + while (this->ring_buffer_->available() >= SEND_BUFFER_SIZE) { + size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0); if (!this->udp_socket_running_) { if (!this->start_udp_socket_()) { this->set_state_(State::STOP_MICROPHONE, State::IDLE); @@ -284,14 +363,23 @@ void VoiceAssistant::loop() { this->socket_->sendto(this->send_buffer_, read_bytes, 0, (struct sockaddr *) &this->dest_addr_, sizeof(this->dest_addr_)); } - available = this->ring_buffer_->available(); - } - + } // audio mode break; } case State::STOP_MICROPHONE: { - if (this->mic_source_->is_running()) { - this->mic_source_->stop(); + // Check both microphone channels + bool is_running = this->mic_source_->is_running(); + bool is_running2 = false; + if (this->mic_source2_) { + is_running2 = this->mic_source2_->is_running(); + } + if (is_running || is_running2) { + if (is_running) { + this->mic_source_->stop(); + } + if (is_running2) { + this->mic_source2_->stop(); + } this->set_state_(State::STOPPING_MICROPHONE); } else { this->set_state_(this->desired_state_); @@ -299,7 +387,13 @@ void VoiceAssistant::loop() { break; } case State::STOPPING_MICROPHONE: { - if (this->mic_source_->is_stopped()) { + // Check both microphone channels + bool is_stopped = this->mic_source_->is_stopped(); + bool is_stopped2 = true; + if (this->mic_source2_) { + is_stopped2 = this->mic_source2_->is_stopped(); + } + if (is_stopped && is_stopped2) { this->set_state_(this->desired_state_); } break; @@ -504,7 +598,8 @@ void VoiceAssistant::start_streaming() { ESP_LOGD(TAG, "Client started, streaming microphone"); this->audio_mode_ = AUDIO_MODE_API; - if (this->mic_source_->is_running()) { + // Both microphone channels + if (this->mic_source_->is_running() && (!this->mic_source2_ || this->mic_source2_->is_running())) { this->set_state_(State::STREAMING_MICROPHONE, State::STREAMING_MICROPHONE); } else { this->set_state_(State::START_MICROPHONE, State::STREAMING_MICROPHONE); @@ -520,6 +615,10 @@ void VoiceAssistant::start_streaming(struct sockaddr_storage *addr, uint16_t por ESP_LOGD(TAG, "Client started, streaming microphone"); this->audio_mode_ = AUDIO_MODE_UDP; + if (this->mic_source2_ != nullptr) { + ESP_LOGW(TAG, "UDP audio mode does not support a second microphone channel; only the primary will be streamed"); + } + memcpy(&this->dest_addr_, addr, sizeof(this->dest_addr_)); if (this->dest_addr_.ss_family == AF_INET) { ((struct sockaddr_in *) &this->dest_addr_)->sin_port = htons(port); @@ -534,6 +633,7 @@ void VoiceAssistant::start_streaming(struct sockaddr_storage *addr, uint16_t por return; } + // Only primary microphone channel over UDP if (this->mic_source_->is_running()) { this->set_state_(State::STREAMING_MICROPHONE, State::STREAMING_MICROPHONE); } else { diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 3de4673001..c4fa7eb615 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -40,6 +40,7 @@ enum VoiceAssistantFeature : uint32_t { FEATURE_TIMERS = 1 << 3, FEATURE_ANNOUNCE = 1 << 4, FEATURE_START_CONVERSATION = 1 << 5, + FEATURE_MULTI_CHANNEL_AUDIO = 1 << 6, }; enum class State { @@ -120,6 +121,7 @@ class VoiceAssistant : public Component { void failed_to_start(); void set_microphone_source(microphone::MicrophoneSource *mic_source) { this->mic_source_ = mic_source; } + void set_microphone_source2(microphone::MicrophoneSource *mic_source2) { this->mic_source2_ = mic_source2; } #ifdef USE_MICRO_WAKE_WORD void set_micro_wake_word(micro_wake_word::MicroWakeWord *mww) { this->micro_wake_word_ = mww; } #endif @@ -149,6 +151,9 @@ class VoiceAssistant : public Component { uint32_t flags = 0; flags |= VoiceAssistantFeature::FEATURE_VOICE_ASSISTANT; flags |= VoiceAssistantFeature::FEATURE_API_AUDIO; + if (this->mic_source2_ != nullptr) { + flags |= VoiceAssistantFeature::FEATURE_MULTI_CHANNEL_AUDIO; + } #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { flags |= VoiceAssistantFeature::FEATURE_SPEAKER; @@ -276,6 +281,7 @@ class VoiceAssistant : public Component { bool timer_tick_running_{false}; microphone::MicrophoneSource *mic_source_{nullptr}; + microphone::MicrophoneSource *mic_source2_{nullptr}; #ifdef USE_SPEAKER void write_speaker_(); speaker::Speaker *speaker_{nullptr}; @@ -301,6 +307,7 @@ class VoiceAssistant : public Component { std::string wake_word_; std::shared_ptr ring_buffer_; + std::shared_ptr ring_buffer2_; bool use_wake_word_; uint8_t noise_suppression_level_; @@ -309,6 +316,7 @@ class VoiceAssistant : public Component { uint32_t conversation_timeout_; uint8_t *send_buffer_{nullptr}; + uint8_t *send_buffer2_{nullptr}; bool continuous_{false}; bool silence_detection_; diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 8565683700..0fa0903370 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -31,6 +31,11 @@ microphone: i2s_din_pin: ${i2s_din_pin} adc_type: external pdm: false + - platform: i2s_audio + id: mic_id_external2 + i2s_din_pin: ${i2s_din_pin2} + adc_type: external + pdm: false speaker: - platform: i2s_audio @@ -40,9 +45,12 @@ speaker: voice_assistant: microphone: - microphone: mic_id_external - gain_factor: 4 - channels: 0 + - microphone: mic_id_external + gain_factor: 4 + channels: 0 + - microphone: mic_id_external2 + gain_factor: 4 + channels: 0 speaker: speaker_id micro_wake_word: mww_id conversation_timeout: 60s diff --git a/tests/components/voice_assistant/test.esp32-idf.yaml b/tests/components/voice_assistant/test.esp32-idf.yaml index 1c5c9ddf99..0cc670a77e 100644 --- a/tests/components/voice_assistant/test.esp32-idf.yaml +++ b/tests/components/voice_assistant/test.esp32-idf.yaml @@ -3,6 +3,7 @@ substitutions: i2s_bclk_pin: GPIO5 i2s_mclk_pin: GPIO15 i2s_din_pin: GPIO13 + i2s_din_pin2: GPIO14 i2s_dout_pin: GPIO12 <<: !include common-idf.yaml From 1dfd3fe9c26706285cc8fca70acf6acf7591ec4a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 12 May 2026 22:52:11 -0400 Subject: [PATCH 1201/2214] [esp32] Print PlatformIO-format RAM/Flash summary after native ESP-IDF builds (#16394) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/build_gen/espidf.py | 10 +++ esphome/espidf/runner.py | 9 +++ esphome/espidf/size_summary.py | 111 +++++++++++++++++++++++++++++++++ esphome/espidf/toolchain.py | 9 ++- 4 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 esphome/espidf/size_summary.py diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index b1443edac3..dfe2d72b9d 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -89,6 +89,16 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {extra_compile_options} project({CORE.name}) + +# Emit raw JSON size data for ESPHome to read post-build. +add_custom_command( + TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD + COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw + -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json + ${{CMAKE_PROJECT_NAME}}.map + WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} + VERBATIM +) """ diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 34e3e7694b..65df37c7b2 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -57,6 +57,15 @@ FILTER_IDF_LINES: list[str] = [ # line, so a NOTICE often arrives prefixed with ".NOTICE:" or # "...........NOTICE:". r"\.*NOTICE: ", + # ``idf.py size`` prefaces its table with a centered banner; the + # per-region table below already makes the structure obvious. + r"\s*Memory Type Usage Summary", + # Prefix match for esp-idf-size's trailing "Note:" paragraph (no + # upstream flag suppresses it). + r"Note: The reported total sizes may be smaller than those in the", + # Drop the blank line rich emits after the note so the build log + # doesn't end with an orphan gap before ESPHome's own status lines. + r"\s*$", ] diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py new file mode 100644 index 0000000000..9477e664b3 --- /dev/null +++ b/esphome/espidf/size_summary.py @@ -0,0 +1,111 @@ +"""PlatformIO-format RAM/Flash one-liners after a native ESP-IDF build. + +``idf.py size`` (chained onto ``idf.py build`` in +``toolchain.run_compile``) prints the per-region table inline as part +of the build. This module adds two summary lines underneath, +byte-identical to PlatformIO's output: + + RAM: [==== ] 26.5% (used 47932 bytes from 180736 bytes) + Flash: [=== ] 48.4% (used 888511 bytes from 1835008 bytes) + +The format matches ``script/ci_memory_impact_extract.py`` so CI memory +analysis works unchanged on native ESP-IDF builds. RAM total is the +DRAM region size from the linker map; Flash total is taken from +``partitions.csv`` using PlatformIO's rule (first app partition whose +subtype is ``factory`` or ``ota_0``; see +``platform-espressif32/builder/main.py::_update_max_upload_size``). + +Structured size data is produced at link time by a CMake POST_BUILD +custom command (see ``build_gen/espidf.py``) which writes +``esp_idf_size.json`` next to the ELF. We read that file here rather +than re-running ``esp_idf_size`` from Python. +""" + +from __future__ import annotations + +import csv +import json +import logging +from pathlib import Path + +_LOGGER = logging.getLogger(__name__) +_SIZE_SUFFIXES = {"K": 1024, "M": 1024 * 1024} + + +def _parse_size(token: str) -> int: + token = token.strip() + if not token: + return 0 + if token.startswith(("0x", "0X")): + return int(token, 16) + suffix = token[-1].upper() + if suffix in _SIZE_SUFFIXES: + return int(token[:-1]) * _SIZE_SUFFIXES[suffix] + return int(token) + + +def _find_app_partition_size(partitions_csv: Path) -> int: + """Return the size of the firmware's app partition. + + Mirrors PlatformIO's ``platform-espressif32/builder/main.py:: + _update_max_upload_size``: take the first ``app``-type partition + whose subtype is ``factory`` or ``ota_0``. Order matters because + layouts like Adafruit's ``partitions-4MB-tinyuf2.csv`` repurpose + ``factory`` for a UF2 bootloader before the real OTA slot, so a + naive "prefer factory" rule would pick the wrong row. Raises + ``ValueError`` if no qualifying partition is present. + """ + if not partitions_csv.is_file(): + raise ValueError(f"partitions.csv not found at {partitions_csv}") + for row in csv.reader(partitions_csv.read_text().splitlines()): + cells = [c.strip() for c in row] + if not cells or cells[0].startswith("#") or len(cells) < 5: + continue + ptype, psubtype, psize = cells[1], cells[2], cells[4] + if ptype in ("app", "0") and psubtype in ("factory", "ota_0"): + return _parse_size(psize) + raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}") + + +def _format_bar(used: int, total: int) -> str: + """Match PlatformIO's ``_format_availale_bytes`` (pioupload.py) exactly.""" + pct_raw = used / total if total else 0 + blocks = 10 + filled = min(int(round(blocks * pct_raw)), blocks) + progress = "=" * filled + return ( + f"[{progress:<{blocks}}] {pct_raw: 6.1%} " + f"(used {used:d} bytes from {total:d} bytes)" + ) + + +def print_summary(size_json: Path, partitions_csv: Path | None) -> None: + """Print PlatformIO-shaped RAM and Flash one-liners. + + Failures are non-fatal: the build has already succeeded, we just couldn't + summarize. Logs the cause at debug level. + """ + if not size_json.is_file(): + _LOGGER.debug("Skipping size summary: %s not found", size_json) + return + try: + data = json.loads(size_json.read_text()) + except (OSError, json.JSONDecodeError) as e: + _LOGGER.debug("Skipping size summary: %s", e) + return + + dram = data.get("memory_types", {}).get("DRAM") or {} + ram_used = dram.get("used") + ram_total = dram.get("size") + if ram_total and ram_used is not None: + print(f"RAM: {_format_bar(ram_used, ram_total)}") + + image_size = data.get("image_size") + if image_size is None or partitions_csv is None: + return + try: + app_size = _find_app_partition_size(partitions_csv) + except ValueError as e: + _LOGGER.debug("Skipping Flash summary: %s", e) + return + print(f"Flash: {_format_bar(image_size, app_size)}") diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index da6d3a8a37..ecb759ed10 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -12,6 +12,7 @@ import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env +from esphome.espidf.size_summary import print_summary _LOGGER = logging.getLogger(__name__) @@ -341,8 +342,14 @@ def run_compile(config, verbose: bool) -> int: args.extend(_get_sdkconfig_args()) args.append("build") + args.append("size") - return run_idf_py(*args) + rc = run_idf_py(*args) + if rc == 0: + size_json = CORE.relative_build_path("build", "esp_idf_size.json") + partitions = CORE.relative_build_path("partitions.csv") + print_summary(size_json, partitions if partitions.is_file() else None) + return rc def get_firmware_path() -> Path: From 480c23012c6eefdc9ca647a0a03b48b936b885c3 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 12 May 2026 22:13:29 -0500 Subject: [PATCH 1202/2214] [radio_frequency] Add on_control trigger; ir_rf_proxy driver-agnostic (#16368) Co-authored-by: Claude Sonnet 4.6 --- .../components/ir_rf_proxy/ir_rf_proxy.cpp | 5 +- esphome/components/ir_rf_proxy/ir_rf_proxy.h | 5 +- .../components/ir_rf_proxy/radio_frequency.py | 12 +++-- .../components/radio_frequency/__init__.py | 9 +++- .../radio_frequency/radio_frequency.cpp | 4 ++ .../radio_frequency/radio_frequency.h | 11 ++++ .../components/ir_rf_proxy/common-cc1101.yaml | 50 +++++++++++++++++++ .../ir_rf_proxy/test-cc1101.esp32-idf.yaml | 9 ++++ .../ir_rf_proxy/test-cc1101.esp8266-ard.yaml | 9 ++++ 9 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 tests/components/ir_rf_proxy/common-cc1101.yaml create mode 100644 tests/components/ir_rf_proxy/test-cc1101.esp32-idf.yaml create mode 100644 tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index 60b0cd513b..c13c6198cb 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -106,7 +106,6 @@ void RfProxy::setup() { void RfProxy::dump_config() { ESP_LOGCONFIG(TAG, "RF Proxy '%s'\n" - " Backend: remote_transmitter/receiver\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), @@ -124,7 +123,9 @@ void RfProxy::dump_config() { } void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { - // RF: no IR carrier modulation + // RF: no IR carrier modulation. Any RF front-end coordination (state turnaround, retuning) + // happens via the radio_frequency entity's on_control trigger and remote_transmitter's + // on_transmit/on_complete triggers — wired up in user YAML. transmit_raw_timings(this->transmitter_, 0, call); } diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 973e9e2051..d0467e822d 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -43,7 +43,10 @@ class IrRfProxy : public infrared::Infrared { #endif // USE_IR_RF #ifdef USE_RADIO_FREQUENCY -/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend +/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend. +/// Driver-agnostic: integration with specific RF front-end chips (CC1101, RFM69, etc.) is done +/// in YAML by wiring their actions to `remote_transmitter`'s on_transmit/on_complete triggers and +/// to this entity's on_control trigger (see radio_frequency component docs). class RfProxy : public radio_frequency::RadioFrequency { public: RfProxy() = default; diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py index 9982f5e4d1..a243909837 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -35,17 +35,19 @@ def _final_validate(config: ConfigType) -> None: if CONF_REMOTE_TRANSMITTER_ID not in config: return - transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID] full_config = fv.full_config.get() - transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1] + transmitter_path = full_config.get_path_for_id(config[CONF_REMOTE_TRANSMITTER_ID])[ + :-1 + ] transmitter_config = full_config.get_config_for_path(transmitter_path) duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT) if duty_percent is not None and duty_percent != 100: raise cv.Invalid( - f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' " - "set to 100% for RF transmission. Dedicated RF hardware handles modulation; " - "applying a carrier duty cycle would corrupt the signal" + f"Transmitter '{config[CONF_REMOTE_TRANSMITTER_ID]}' must have " + f"'{CONF_CARRIER_DUTY_PERCENT}' set to 100% for RF transmission. " + "Dedicated RF hardware handles modulation; applying a carrier duty cycle " + "would corrupt the signal" ) diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index a54ab6e249..9fdafe428a 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -8,9 +8,10 @@ breaking changes policy. Use at your own risk. Once the API is considered stable, this warning will be removed. """ +from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_ON_CONTROL from esphome.core import CORE, coroutine_with_priority from esphome.core.entity_helpers import queue_entity_register, setup_entity from esphome.coroutine import CoroPriority @@ -42,6 +43,7 @@ def radio_frequency_schema(class_: type[cg.MockObjClass]) -> cv.Schema: return entity_schema.extend( { cv.GenerateID(): cv.declare_id(class_), + cv.Optional(CONF_ON_CONTROL): automation.validate_automation({}), } ) @@ -59,6 +61,11 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non await setup_radio_frequency_core_(var, config) CORE.register_platform_component("radio_frequency", var) + for conf in config.get(CONF_ON_CONTROL, []): + await automation.build_callback_automation( + var, "add_on_control_callback", [(RadioFrequencyCall, "x")], conf + ) + async def new_radio_frequency(config: ConfigType, *args) -> cg.Pvariable: """Create a new RadioFrequency instance. diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 3c000ae1ca..3e0a905737 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -54,6 +54,10 @@ RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) { void RadioFrequencyCall::perform() { if (this->parent_ != nullptr) { + // Fire any on_control hooks (user-wired automations) before handing off to + // the platform-specific control() — gives users a chance to react to call + // parameters (e.g. retune an external RF front-end based on call.get_frequency()). + this->parent_->control_callback_.call(*this); this->parent_->control(*this); } } diff --git a/esphome/components/radio_frequency/radio_frequency.h b/esphome/components/radio_frequency/radio_frequency.h index db73a844ed..7dfd2dd77e 100644 --- a/esphome/components/radio_frequency/radio_frequency.h +++ b/esphome/components/radio_frequency/radio_frequency.h @@ -170,6 +170,15 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: this->receive_callback_.add(std::forward(callback)); } + /// Add a callback to invoke when a transmit call is made on this entity. + /// Fires before the platform-specific control() runs, with the call object + /// (containing frequency, modulation, repeat count, etc.). Used by the + /// `on_control` YAML trigger so users can wire any RF front-end driver + /// (CC1101, RFM69, custom) to react to per-call parameters. + template void add_on_control_callback(F &&callback) { + this->control_callback_.add(std::forward(callback)); + } + protected: friend class RadioFrequencyCall; @@ -182,6 +191,8 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: // Callback manager for receive events (lazy: saves memory when no callbacks registered) LazyCallbackManager receive_callback_; + // Callback manager for on_control trigger (lazy: same memory savings) + LazyCallbackManager control_callback_; }; } // namespace esphome::radio_frequency diff --git a/tests/components/ir_rf_proxy/common-cc1101.yaml b/tests/components/ir_rf_proxy/common-cc1101.yaml new file mode 100644 index 0000000000..392e6db22e --- /dev/null +++ b/tests/components/ir_rf_proxy/common-cc1101.yaml @@ -0,0 +1,50 @@ +cc1101: + id: cc1101_radio + cs_pin: ${cs_pin} + frequency: 433.92MHz + modulation_type: ASK/OOK + output_power: 10 + +# Dual-pin wiring (recommended by the CC1101 docs): +# CC1101 GDO0 → ${gdo0_pin} (remote_transmitter) +# CC1101 GDO2 → ${gdo2_pin} (remote_receiver) +remote_transmitter: + id: rf_tx + pin: ${gdo0_pin} + carrier_duty_percent: 100% + # Switch the chip into TX state for the duration of each transmission and back to RX + # afterwards. Driver-agnostic: any RF front-end with begin_tx/begin_rx-style actions + # can be wired this way. + on_transmit: + then: + - cc1101.begin_tx: cc1101_radio + on_complete: + then: + - cc1101.begin_rx: cc1101_radio + +remote_receiver: + id: rf_rx + pin: ${gdo2_pin} + +radio_frequency: + - platform: ir_rf_proxy + id: rf_proxy_cc1101_tx + name: "CC1101 RF Transmitter" + frequency: 433.92MHz + remote_transmitter_id: rf_tx + # Optional: retune the CC1101 per-transmit when the API request specifies a + # different carrier frequency. Demonstrates the on_control trigger. + on_control: + then: + - if: + condition: + lambda: "return x.get_frequency().has_value() && *x.get_frequency() > 0;" + then: + - cc1101.set_frequency: + id: cc1101_radio + value: !lambda "return *x.get_frequency();" + - platform: ir_rf_proxy + id: rf_proxy_cc1101_rx + name: "CC1101 RF Receiver" + frequency: 433.92MHz + remote_receiver_id: rf_rx diff --git a/tests/components/ir_rf_proxy/test-cc1101.esp32-idf.yaml b/tests/components/ir_rf_proxy/test-cc1101.esp32-idf.yaml new file mode 100644 index 0000000000..bf6f3d9815 --- /dev/null +++ b/tests/components/ir_rf_proxy/test-cc1101.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + cs_pin: GPIO5 + gdo0_pin: GPIO4 + gdo2_pin: GPIO16 + +packages: + common: !include common.yaml + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + cc1101: !include common-cc1101.yaml diff --git a/tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml b/tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml new file mode 100644 index 0000000000..e25c47ab23 --- /dev/null +++ b/tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + cs_pin: GPIO5 + gdo0_pin: GPIO4 + gdo2_pin: GPIO16 + +packages: + common: !include common.yaml + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + cc1101: !include common-cc1101.yaml From 65ea29b44ac9f8e3dcb1dd64809b4a6d57dc853c Mon Sep 17 00:00:00 2001 From: Dmitrii Kuminov Date: Tue, 12 May 2026 20:41:30 -0700 Subject: [PATCH 1203/2214] [core] Fix !include vars not being substituted in !lambda values (#16320) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/yaml_util.py | 2 +- tests/unit_tests/test_substitutions.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 3cfc9c4b15..b153d160a7 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -139,7 +139,7 @@ def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: value.set_context({**value.vars, **(context_vars or {})}) return value - if context_vars and isinstance(value, (dict, list, str)): + if context_vars and isinstance(value, (dict, list, str, Lambda)): value = add_class_to_obj(value, ConfigContext) value.set_context(context_vars) return value diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index cf6d4adbf5..4783112578 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -818,3 +818,23 @@ def test_resolve_include_error_no_expanded_from_for_literal_filename( substitutions.resolve_include(include, [], substitutions.ContextVars()) assert "expanded from" not in str(exc_info.value) + + +def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None: + """!include vars: must substitute into a top-level !lambda value in the included file. + + Regression test for the case where the included file's root is a Lambda; + add_context() previously only tagged dict/list/str, so the include's vars + never reached the substitution pass for Lambda content. + """ + included = tmp_path / "lambda.yaml" + included.write_text('!lambda |-\n return "${foo}";\n') + + include = yaml_util.IncludeFile( + tmp_path / "main.yaml", "lambda.yaml", {"foo": "bar"}, yaml_util.load_yaml + ) + config = OrderedDict({"value": include.load()}) + result = substitutions.do_substitution_pass(config) + + assert isinstance(result["value"], Lambda) + assert result["value"].value == 'return "bar";' From 386e95fbef2bcb254c1adee59226202640591bf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 00:51:22 -0500 Subject: [PATCH 1204/2214] [core] Retry PlatformIO downloads on transport-layer errors --- esphome/platformio/runner.py | 12 +++-- tests/unit_tests/test_platformio_toolchain.py | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index 976979dc57..caab47dcc2 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -51,9 +51,13 @@ def patch_file_downloader() -> None: """Retry PlatformIO package downloads with exponential backoff. PlatformIO's ``FileDownloader`` uses an ``HTTPSession`` without built-in - retry for 502/503 errors. We wrap ``__init__`` to retry on - ``PackageException`` and close the session between attempts so a new - TCP connection can route to a different CDN edge node. + retry. We wrap ``__init__`` to retry on transient failures and close the + session between attempts so a new TCP connection can route to a different + CDN edge node. We catch both ``PackageException`` (raised when the server + returns a non-200 status such as 502/503) and ``OSError`` -- which covers + ``requests.exceptions.ConnectionError``, ``ReadTimeout``, and + ``ChunkedEncodingError`` (all subclasses of ``OSError``) that get raised + when the connection is aborted before a response is parsed. """ from platformio.package.download import FileDownloader from platformio.package.exception import PackageException @@ -70,7 +74,7 @@ def patch_file_downloader() -> None: try: original_init(self, *args, **kwargs) return - except PackageException as e: + except (PackageException, OSError) as e: if attempt < max_retries - 1: delay = 2 ** (attempt + 1) _LOGGER.warning( diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index f771437dd4..64f82ba74f 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -867,6 +867,56 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() -> mock_session.close.assert_called_once() +def test_patch_file_downloader_retries_on_connection_error() -> None: + """Test patch_file_downloader retries on transport-layer errors (OSError subclasses). + + ``requests.exceptions.ConnectionError`` and ``ReadTimeout`` subclass + ``OSError`` and are raised when the connection is aborted before any HTTP + response is parsed -- e.g. ``RemoteDisconnected`` mid-download. These must + retry too, not just ``PackageException``. + """ + mock_exception_cls = type("PackageException", (Exception,), {}) + call_count = 0 + + def failing_init(self, *args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ConnectionError( + f"Connection aborted attempt {call_count}: RemoteDisconnected" + ) + + with ( + patch.dict( + "sys.modules", + { + "platformio": MagicMock(), + "platformio.package": MagicMock(), + "platformio.package.download": SimpleNamespace( + FileDownloader=type( + "FileDownloader", (), {"__init__": failing_init} + ) + ), + "platformio.package.exception": SimpleNamespace( + PackageException=mock_exception_cls + ), + }, + ), + patch("time.sleep") as mock_sleep, + ): + runner.patch_file_downloader() + + from platformio.package.download import FileDownloader + + instance = object.__new__(FileDownloader) + FileDownloader.__init__(instance, "http://example.com/file.zip") + + assert call_count == 3 + assert mock_sleep.call_count == 2 + mock_sleep.assert_any_call(2) + mock_sleep.assert_any_call(4) + + def test_patch_file_downloader_idempotent() -> None: """Test patch_file_downloader does not stack wrappers when called multiple times.""" mock_exception_cls = type("PackageException", (Exception,), {}) From ac7c572303ce84b3da42586e1d3cae0326d27994 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 00:57:20 -0500 Subject: [PATCH 1205/2214] [core] Add socket-based tests for PlatformIO download retry --- tests/unit_tests/test_platformio_toolchain.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 64f82ba74f..c1d16530cb 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,10 +2,13 @@ # pylint: disable=protected-access +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path import shutil +import threading from types import SimpleNamespace from unittest.mock import MagicMock, Mock, call, patch @@ -953,6 +956,74 @@ def test_patch_file_downloader_idempotent() -> None: assert call_count == 1 +@contextmanager +def _flaky_http_server(fail_first_n: int, fail_mode: str): + """Local HTTP server that fails the first ``fail_first_n`` requests. + + ``fail_mode="drop"`` closes the TCP connection without responding, so + the client raises ``RemoteDisconnected`` -- the exact CI failure mode. + ``fail_mode="502"`` returns an HTTP 502, triggering ``PackageException``. + """ + state = {"hits": 0} + + class _Handler(BaseHTTPRequestHandler): + def handle_one_request(self) -> None: + state["hits"] += 1 + if state["hits"] <= fail_first_n and fail_mode == "drop": + return # Skip read+respond → kernel sends FIN → RemoteDisconnected + super().handle_one_request() + + def do_GET(self) -> None: # noqa: N802 + if state["hits"] <= fail_first_n and fail_mode == "502": + self.send_error(502) + return + body = b"esphome-test-payload" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + pass # silence default stderr logging + + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_address[1], state + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +@pytest.mark.parametrize("fail_mode", ["drop", "502"]) +def test_patch_file_downloader_recovers_against_real_server( + tmp_path: Path, fail_mode: str +) -> None: + """End-to-end: real PlatformIO ``FileDownloader`` against a local server + that fails twice then succeeds. Exercises the real + requests/urllib3/http.client stack for both failure modes: + + - ``drop``: TCP close mid-request → ``RemoteDisconnected`` → caught as + ``OSError`` by the retry patch (the CI failure path). + - ``502``: HTTP error response → ``PackageException`` (the original path). + """ + runner.patch_file_downloader() + from platformio.package.download import FileDownloader + + with ( + _flaky_http_server(fail_first_n=2, fail_mode=fail_mode) as (port, state), + patch("time.sleep"), + ): + fd = FileDownloader(f"http://127.0.0.1:{port}/payload.bin") + fd.set_destination(str(tmp_path / "out.bin")) + fd.start(with_progress=False, silent=True) + + assert state["hits"] == 3 # 2 failures + 1 success + assert (tmp_path / "out.bin").read_bytes() == b"esphome-test-payload" + + def _filter_through_redirect(line: str) -> str: """Write a line through RedirectText with FILTER_PLATFORMIO_LINES and return what passes.""" import io From 45a4811bb424f399515ccb08a49ef5d9f81fb634 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 13 May 2026 08:40:19 +0200 Subject: [PATCH 1206/2214] [mitsubishi_cn105] Unified timeout handling (#16385) --- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 56 +++++++++---------- .../mitsubishi_cn105/mitsubishi_cn105.h | 5 +- .../climate/mitsubishi_cn105_tests.cpp | 42 ++++++-------- tests/components/mitsubishi_cn105/common.h | 3 +- 4 files changed, 48 insertions(+), 58 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 2a173997f3..56f1ee1b3f 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -8,7 +8,7 @@ namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.driver"; -static constexpr uint32_t WRITE_TIMEOUT_MS = 2000; +static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; @@ -91,25 +91,31 @@ static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST, void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); } bool MitsubishiCN105::update() { - if (const auto start = this->status_update_start_ms_) { - if (this->pending_updates_.any()) { - this->status_update_wait_credit_ms_ = std::min(this->update_interval_ms_, get_loop_time_ms() - *start); - this->cancel_waiting_and_transition_to_(State::APPLYING_SETTINGS); - return false; - } + switch (this->state_) { + case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE: + if (this->pending_updates_.any()) { + this->status_update_wait_credit_ms_ = + std::min(this->update_interval_ms_, get_loop_time_ms() - this->operation_start_ms_); + this->set_state_(State::APPLYING_SETTINGS); + return false; + } + if (this->has_timed_out_(this->update_interval_ms_)) { + this->set_state_(State::UPDATING_STATUS); + return false; + } + break; - if ((get_loop_time_ms() - *start) >= this->update_interval_ms_) { - this->cancel_waiting_and_transition_to_(State::UPDATING_STATUS); - return false; - } - } + case State::CONNECTING: + case State::UPDATING_STATUS: + case State::APPLYING_SETTINGS: + if (this->has_timed_out_(RESPONSE_TIMEOUT_MS)) { + this->set_state_(State::READ_TIMEOUT); + return false; + } + break; - if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) { - this->write_timeout_start_ms_.reset(); - this->frame_parser_.reset(); - this->status_update_wait_credit_ms_ = 0; - this->set_state_(State::READ_TIMEOUT); - return false; + default: + break; } return this->frame_parser_.read_and_parse(this->device_, [this](uint8_t type, const uint8_t *payload, size_t len) { @@ -171,7 +177,6 @@ void MitsubishiCN105::did_transition_(State to) { break; case State::CONNECTED: - this->write_timeout_start_ms_.reset(); this->current_status_msg_type_ = STATUS_MSG_SETTINGS; this->set_state_(State::UPDATING_STATUS); break; @@ -181,7 +186,6 @@ void MitsubishiCN105::did_transition_(State to) { break; case State::STATUS_UPDATED: { - this->write_timeout_start_ms_.reset(); 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_()) { @@ -194,7 +198,7 @@ void MitsubishiCN105::did_transition_(State to) { } case State::SCHEDULE_NEXT_STATUS_UPDATE: - this->status_update_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_; + this->operation_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_; this->status_update_wait_credit_ms_ = 0; this->current_status_msg_type_ = STATUS_MSG_SETTINGS; this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); @@ -205,11 +209,12 @@ void MitsubishiCN105::did_transition_(State to) { break; case State::SETTINGS_APPLIED: - this->write_timeout_start_ms_.reset(); this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); break; case State::READ_TIMEOUT: + this->frame_parser_.reset(); + this->status_update_wait_credit_ms_ = 0; this->set_state_(State::CONNECTING); break; @@ -233,7 +238,7 @@ bool MitsubishiCN105::should_request_room_temperature_() const { void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { FrameParser::dump_buffer_vv("TX", packet, len); this->device_.write_array(packet, len); - this->write_timeout_start_ms_ = get_loop_time_ms(); + this->operation_start_ms_ = get_loop_time_ms(); } void MitsubishiCN105::update_status_() { @@ -241,11 +246,6 @@ void MitsubishiCN105::update_status_() { this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); } -void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) { - this->status_update_start_ms_.reset(); - this->set_state_(state); -} - bool MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) { switch (type) { case PACKET_TYPE_CONNECT_RESPONSE: diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 52b78efccd..60ca81cf9e 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -124,9 +124,9 @@ class MitsubishiCN105 { bool parse_status_room_temperature_(const uint8_t *payload, size_t len); void send_packet_(const uint8_t *packet, size_t len); void update_status_(); - void cancel_waiting_and_transition_to_(State state); bool should_request_room_temperature_() 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); @@ -135,9 +135,8 @@ class MitsubishiCN105 { uart::UARTDevice &device_; 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 write_timeout_start_ms_; - std::optional status_update_start_ms_; std::optional last_room_temperature_update_ms_; Status status_{}; State state_{State::NOT_CONNECTED}; diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 7615b62d03..db2fbced1c 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -16,13 +16,13 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { ctx.sut.set_current_time(123); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED); EXPECT_TRUE(ctx.uart.tx.empty()); - EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.operation_start_ms_, 0); ctx.sut.initialize(); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8)); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{123}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 123); } TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { @@ -32,8 +32,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { ctx.uart.tx.clear(); // Remove first connect packet bytes EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.operation_start_ms_, 0); // Connect response ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55}); @@ -47,8 +46,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B)); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{200}); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.operation_start_ms_, 200); // Clear TX bytes. ctx.uart.tx.clear(); @@ -77,8 +75,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { 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)); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{300}); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.operation_start_ms_, 300); // Clear TX bytes. ctx.uart.tx.clear(); @@ -101,8 +98,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_TRUE(ctx.uart.tx.empty()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); - EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value()); - EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{400}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 400); } TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { @@ -115,21 +111,21 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); EXPECT_TRUE(ctx.uart.tx.empty()); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 0); // Still no response after 1999ms, no retry yet ctx.sut.set_current_time(1999); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); EXPECT_TRUE(ctx.uart.tx.empty()); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 0); // Stop waiting after 2s and retry connect ctx.sut.set_current_time(2000); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8)); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{2000}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 2000); } TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { @@ -233,15 +229,12 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(80000); - // No scheduled status update - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); - // Status update completed, schedule next status update ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); - EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{80000}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 80000); // Wait for update_interval (ms) before doing another status update ASSERT_FALSE(ctx.sut.update()); @@ -257,7 +250,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { ASSERT_FALSE(ctx.sut.update()); EXPECT_FALSE(ctx.uart.tx.empty()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.operation_start_ms_, 82000); } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { @@ -382,14 +375,14 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); - EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{5000}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 5000); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Nothing to do in update (rx empty, no timeout) ctx.sut.set_current_time(5500); ASSERT_FALSE(ctx.sut.update()); EXPECT_TRUE(ctx.uart.tx.empty()); - EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{5000}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 5000); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Write new values @@ -402,7 +395,6 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { // Waiting for next status update must be interrupted and new values send to AC ctx.sut.set_current_time(6000); ASSERT_FALSE(ctx.sut.update()); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00, @@ -414,7 +406,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { ctx.sut.set_current_time(6500); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); - EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{6500 - 1000}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 6500 - 1000); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); } @@ -502,7 +494,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); - ASSERT_EQ(ctx.sut.status_update_start_ms_, std::optional{5000}); + ASSERT_EQ(ctx.sut.operation_start_ms_, 5000); ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Interrupt that wait with a write so credit is accumulated. @@ -514,7 +506,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ctx.sut.set_current_time(6000); ASSERT_FALSE(ctx.sut.update()); ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); - ASSERT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + ASSERT_EQ(ctx.sut.operation_start_ms_, 6000); ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000); // Do not ACK the write. Advance time far enough to force timeout/reconnect @@ -522,8 +514,8 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ctx.sut.set_current_time(36000); ASSERT_FALSE(ctx.sut.update()); EXPECT_NE(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); + ASSERT_EQ(ctx.sut.operation_start_ms_, 36000); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); } TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index d0fdca1ea5..73e09d6c84 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -44,8 +44,7 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { using MitsubishiCN105::State; using MitsubishiCN105::UpdateFlag; using MitsubishiCN105::state_; - using MitsubishiCN105::write_timeout_start_ms_; - using MitsubishiCN105::status_update_start_ms_; + using MitsubishiCN105::operation_start_ms_; using MitsubishiCN105::use_temperature_encoding_b_; using MitsubishiCN105::status_update_wait_credit_ms_; using MitsubishiCN105::pending_updates_; From 0e4922a3400d5b186214ff48ee6298be04bcba89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 05:14:19 -0500 Subject: [PATCH 1207/2214] [core] Cache validated config to skip re-validation on upload/logs (#16381) --- esphome/__main__.py | 26 ++- esphome/compiled_config.py | 76 ++++++ esphome/storage_json.py | 24 +- esphome/writer.py | 6 + tests/unit_tests/test_compiled_config.py | 282 +++++++++++++++++++++++ 5 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 esphome/compiled_config.py create mode 100644 tests/unit_tests/test_compiled_config.py diff --git a/esphome/__main__.py b/esphome/__main__.py index bca8672917..d733534a5c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2434,10 +2434,28 @@ def run_esphome(argv): # Commands that don't need fresh external components: logs just connects # to the device, and clean is about to delete the build directory. skip_external = args.command in ("logs", "clean") - config = read_config( - dict(args.substitution) if args.substitution else {}, - skip_external_update=skip_external, - ) + command_line_substitutions = dict(args.substitution) if args.substitution else {} + + # Fast path for upload/logs: reuse the validated-config cache the + # last compile wrote. Falls back to read_config when missing/stale. + # Skipped when -s overrides are passed, since the cache was written + # against the previous substitution set. + config: ConfigType | None = None + if args.command in ("upload", "logs") and not command_line_substitutions: + from esphome.compiled_config import load_compiled_config + + config = load_compiled_config(conf_path) + if config is not None: + _LOGGER.info( + "Loaded validated config cache for %s, skipping validation.", + conf_path.name, + ) + + if config is None: + config = read_config( + command_line_substitutions, + skip_external_update=skip_external, + ) if config is None: return 2 CORE.config = config diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py new file mode 100644 index 0000000000..92cbb7348a --- /dev/null +++ b/esphome/compiled_config.py @@ -0,0 +1,76 @@ +"""Validated-config cache for the upload/logs fast path. + +compile dumps the validated config to /storage/.validated.yaml; +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. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from esphome.core import CORE +from esphome.helpers import write_file +from esphome.storage_json import StorageJSON, ext_storage_path +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + + +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 + + +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. + 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) + write_file(compiled_config_path(CORE.config_filename), rendered, private=True) + except Exception as err: # pylint: disable=broad-except + _LOGGER.debug("Skipping compiled config cache write: %s", err) + + +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. + """ + 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: + config = yaml_util.load_yaml(cache_path, clear_secrets=False) + except Exception: # pylint: disable=broad-except + 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: + return None + storage.apply_to_core() + return config diff --git a/esphome/storage_json.py b/esphome/storage_json.py index c6df16ce78..7d26b22f96 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -8,7 +8,13 @@ import os from pathlib import Path from esphome import const -from esphome.const import CONF_DISABLED, CONF_MDNS +from esphome.const import ( + CONF_DISABLED, + CONF_MDNS, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) from esphome.core import CORE from esphome.helpers import write_file_if_changed from esphome.types import CoreType @@ -256,6 +262,22 @@ class StorageJSON: except Exception: # pylint: disable=broad-except return None + 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. + Validator-only fields (loaded_integrations/platforms, + friendly_name) are skipped; the fast path doesn't run + validation and CORE.__init__ defaults them. + """ + CORE.name = self.name + CORE.build_path = self.build_path + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(), + KEY_TARGET_FRAMEWORK: self.framework, + } + def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/esphome/writer.py b/esphome/writer.py index 2fa43fa5eb..cf04e4f8d2 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -7,6 +7,7 @@ import re import time from esphome import loader +from esphome.compiled_config import save_compiled_config from esphome.config import iter_component_configs, iter_components from esphome.const import ( HEADER_FILE_EXTENSIONS, @@ -109,6 +110,11 @@ def update_storage_json() -> None: path = storage_path() old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) + + # Refresh the cache upload/logs read on the next call. + if CORE.config is not None: + save_compiled_config(CORE.config) + if old == new: return diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py new file mode 100644 index 0000000000..34e811b97b --- /dev/null +++ b/tests/unit_tests/test_compiled_config.py @@ -0,0 +1,282 @@ +"""Tests for the validated-config cache used by upload/logs.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.__main__ import run_esphome +from esphome.compiled_config import ( + compiled_config_path, + load_compiled_config, + save_compiled_config, +) +from esphome.const import ( + CONF_API, + CONF_ESPHOME, + CONF_NAME, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) +from esphome.core import CORE + +_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 +""" + + +def _write_storage(storage_path: Path) -> None: + """Write a vanilla StorageJSON sidecar for the cache tests.""" + storage_path.parent.mkdir(parents=True, exist_ok=True) + data = { + "storage_version": 1, + "name": "lite_test", + "friendly_name": "Lite Test Device", + "comment": None, + "esphome_version": "2026.1.0", + "src_version": 1, + "address": "192.168.1.42", + "web_port": None, + "esp_platform": "ESP32", + "build_path": "/build/lite_test", + "firmware_bin_path": "/build/lite_test/firmware.bin", + "loaded_integrations": ["api", "logger", "ota", "wifi"], + "loaded_platforms": [], + "no_mdns": False, + "framework": "arduino", + "core_platform": "esp32", + } + storage_path.write_text(json.dumps(data)) + + +def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: + """Write the cache file and return it.""" + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(body) + return cache_path + + +def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None: + """Force the cache file's mtime relative to the source YAML. + + Positive offset → cache is fresh. Negative → cache is stale. + """ + yaml_stat = yaml_path.stat() + os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + offset)) + + +@pytest.fixture +def fresh_cache_files(tmp_path: Path) -> Path: + """YAML + StorageJSON + cache, all consistent and fresh.""" + 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) + + return yaml_path + + +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.parent.name == "storage" + + +def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: + """Fresh cache + sidecar → returns config and populates CORE.""" + config = load_compiled_config(fresh_cache_files) + + assert config is not None + assert config[CONF_ESPHOME][CONF_NAME] == "lite_test" + assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" + assert config["ota"][0]["password"] == "secret" + + # apply_to_core populated exactly what upload/logs read off CORE. + assert CORE.name == "lite_test" + assert CORE.build_path == Path("/build/lite_test") + assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" + assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" + + +@pytest.mark.parametrize( + "scenario", + ["missing_cache", "stale_cache", "corrupt_cache", "missing_sidecar"], +) +def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: + """All non-happy cases return None so the caller falls back.""" + 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" + cache_path = storage_dir / "lite_test.yaml.validated.yaml" + sidecar_path = storage_dir / "lite_test.yaml.json" + + if scenario == "missing_cache": + pass # no cache, no sidecar + elif scenario == "stale_cache": + _write_storage(sidecar_path) + _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=-60) + elif scenario == "corrupt_cache": + _write_storage(sidecar_path) + _set_cache_mtime( + _write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5 + ) + elif scenario == "missing_sidecar": + # Cache fresh + parseable, but no StorageJSON → can't populate CORE. + _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is None + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_upload_and_logs_use_cache_when_fresh( + command: str, + fresh_cache_files: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """upload/logs skip read_config() when the cache is fresh.""" + captured: dict = {} + + def _stub(_args, config): + captured["config"] = config + return 0 + + with ( + caplog.at_level("INFO", logger="esphome.__main__"), + patch("esphome.__main__.read_config") as mock_read, + patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}), + ): + assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0 + + mock_read.assert_not_called() + assert captured["config"][CONF_ESPHOME][CONF_NAME] == "lite_test" + assert captured["config"][CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" + # The success-branch log line is part of the patch; assert on it so + # branch coverage stays unambiguous in CI. + assert "Loaded validated config cache" in caplog.text + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_upload_and_logs_fall_back_when_no_cache( + tmp_path: Path, command: str +) -> None: + """Without a cache, the dispatcher falls back to read_config().""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {command: lambda args, config: 0}, + ), + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 2 + + mock_read.assert_called_once() + + +def test_run_esphome_upload_with_substitution_skips_cache( + fresh_cache_files: Path, +) -> None: + """`-s key value` forces a fresh validation -- the cache was written + against the prior substitution set, so reusing it would silently + ignore the override.""" + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)]) + + mock_read.assert_called_once() + + +def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None: + """The compile subcommand always re-validates -- it's what writes the cache.""" + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"compile": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "compile", str(fresh_cache_files)]) + + mock_read.assert_called_once() + + +def test_save_compiled_config_writes_cache(tmp_path: Path) -> None: + """`save_compiled_config` writes the dumped YAML 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 + + +def test_save_compiled_config_swallows_dump_errors( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Failures during the dump 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")): + save_compiled_config({"esphome": {"name": "lite_test"}}) + assert not compiled_config_path("lite_test.yaml").exists() + + +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 + + 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}' + ) + cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache_path, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is None From b86652543791a8c17da0c0f061606564f96dbf92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 10:01:11 -0500 Subject: [PATCH 1208/2214] [ci] Skip native ESP-IDF compile test when no relevant files changed (#16395) --- .github/workflows/ci.yml | 12 ++- script/determine-jobs.py | 123 +++++++++++++++++++++ tests/script/test_determine_jobs.py | 160 ++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad39b3f346..06c6c0fec1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -252,6 +252,8 @@ jobs: python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} device-builder: ${{ steps.determine.outputs.device-builder }} + native-idf: ${{ steps.determine.outputs.native-idf }} + native-idf-components: ${{ steps.determine.outputs.native-idf-components }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -297,6 +299,8 @@ jobs: echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT + echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT + echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -823,10 +827,14 @@ jobs: needs: - common - determine-jobs - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true' env: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf - TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner + # Comma-joined subset of the native-IDF representative component list, + # computed by script/determine-jobs.py (native_idf_components_to_test). + # Single source of truth -- the full list lives in + # script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS. + TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }} steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 57b3c6eb88..0a55b2a848 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -495,6 +495,125 @@ def should_run_device_builder(branch: str | None = None) -> bool: return False +# Components tested by the native ESP-IDF compile-test job. This is the +# single source of truth: the workflow reads the comma-joined list from the +# `native-idf-components` output of `determine-jobs` and uses it as the +# `TEST_COMPONENTS` env on the `test-native-idf` job. +NATIVE_IDF_TEST_COMPONENTS = frozenset( + { + "esp32", + "api", + "heatpumpir", + "bme280_i2c", + "bh1750", + "aht10", + "esp32_ble", + "esp32_ble_beacon", + "esp32_ble_client", + "esp32_ble_server", + "esp32_ble_tracker", + "ble_client", + "ble_presence", + "ble_rssi", + "ble_scanner", + } +) + +# Path prefixes whose changes always trigger the native ESP-IDF compile +# test: anything under esphome/espidf/ (the native IDF runner / API / +# framework / component generator). +NATIVE_IDF_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) + +# Standalone files that, when changed, also trigger the native ESP-IDF +# compile test: +# - esphome/build_gen/espidf.py -- the native IDF build generator +# (other files under build_gen/ target PlatformIO and don't affect +# the native IDF path) +# - script/test_build_components.py -- the harness the job invokes +# - .github/workflows/ci.yml -- the job's own definition +NATIVE_IDF_TRIGGER_FILES = frozenset( + { + "esphome/build_gen/espidf.py", + "script/test_build_components.py", + ".github/workflows/ci.yml", + } +) + + +def _native_idf_path_or_file_trigger(files: list[str]) -> bool: + """Whether any changed file is a native IDF infrastructure / harness trigger.""" + for file in files: + if file in NATIVE_IDF_TRIGGER_FILES: + return True + if any(file.startswith(prefix) for prefix in NATIVE_IDF_TRIGGER_PATH_PREFIXES): + return True + return False + + +def native_idf_components_to_test(branch: str | None = None) -> list[str]: + """Subset of ``NATIVE_IDF_TEST_COMPONENTS`` the job needs to compile. + + The job builds components with the native ESP-IDF toolchain (no + PlatformIO). When only a specific component (or something it depends + on) changed, there's no value in re-building every other unrelated + component in the test list -- the regular ``component-test`` matrix + already covers them via PlatformIO. So we narrow to the intersection + of ``NATIVE_IDF_TEST_COMPONENTS`` and the changed-component dependency + closure. + + Returns the full list (sorted) when we can't safely narrow: + + 1. Core C++/Python files changed (``esphome/core/*``). + 2. Native IDF infrastructure changed (``esphome/espidf/*`` or + ``esphome/build_gen/espidf.py``). + 3. The test harness or workflow itself changed + (``script/test_build_components.py``, ``.github/workflows/ci.yml``). + + Otherwise returns the intersection (sorted), which may be empty -- an + empty list signals the job should be skipped. + + The dependency closure is derived from ``files`` via + ``get_components_with_dependencies()`` (the same primitive ``main()`` + uses) so the result honors ``branch``. ``get_changed_components()`` + is deliberately not used here: it re-invokes ``changed_files()`` with + its own default branch, which would silently ignore our ``branch`` + argument. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + Sorted list of component names to compile. + """ + files = changed_files(branch) + + if core_changed(files) or _native_idf_path_or_file_trigger(files): + return sorted(NATIVE_IDF_TEST_COMPONENTS) + + component_files = [f for f in files if filter_component_and_test_files(f)] + changed = get_components_with_dependencies(component_files, True) + + return sorted(NATIVE_IDF_TEST_COMPONENTS & set(changed)) + + +def should_run_native_idf(branch: str | None = None) -> bool: + """Determine if the `test-native-idf` compile-test job should run. + + Runs whenever ``native_idf_components_to_test()`` returns a non-empty + list. Skipping the job on unrelated Python-only PRs avoids ~5 min of + CI per PR (worse on cold caches). The regular ``component-test`` + matrix still exercises the same components through PlatformIO when + those components change. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if the native ESP-IDF compile test should run, False otherwise. + """ + return bool(native_idf_components_to_test(branch)) + + def determine_cpp_unit_tests( branch: str | None = None, ) -> tuple[bool, list[str]]: @@ -957,6 +1076,8 @@ def main() -> None: run_python_linters = should_run_python_linters(args.branch) run_import_time = should_run_import_time(args.branch) run_device_builder = should_run_device_builder(args.branch) + native_idf_components = native_idf_components_to_test(args.branch) + run_native_idf = bool(native_idf_components) changed_cpp_file_count = count_changed_cpp_files(args.branch) # Get changed components @@ -1102,6 +1223,8 @@ def main() -> None: "python_linters": run_python_linters, "import_time": run_import_time, "device_builder": run_device_builder, + "native_idf": run_native_idf, + "native_idf_components": ",".join(native_idf_components), "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 5e2dd670dc..9139c6e095 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -70,6 +70,17 @@ def mock_should_run_device_builder() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_native_idf_components_to_test() -> Generator[Mock, None, None]: + """Mock native_idf_components_to_test from determine_jobs. + + main() drives both the ``native_idf`` boolean output and the + ``native_idf_components`` CSV from this one function. + """ + with patch.object(determine_jobs, "native_idf_components_to_test") as mock: + yield mock + + @pytest.fixture def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]: """Mock determine_cpp_unit_tests from helpers.""" @@ -107,6 +118,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -122,6 +134,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True + mock_native_idf_components_to_test.return_value = ["api", "esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -203,6 +216,8 @@ def test_main_all_tests_should_run( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True + assert output["native_idf"] is True + assert output["native_idf_components"] == "api,esp32" assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -236,6 +251,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -251,6 +267,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False + mock_native_idf_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -291,6 +308,8 @@ def test_main_no_tests_should_run( assert output["python_linters"] is False assert output["import_time"] is False assert output["device_builder"] is False + assert output["native_idf"] is False + assert output["native_idf_components"] == "" assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -313,6 +332,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -328,6 +348,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True + mock_native_idf_components_to_test.return_value = ["esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -366,6 +387,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.assert_called_once_with("main") mock_should_run_import_time.assert_called_once_with("main") mock_should_run_device_builder.assert_called_once_with("main") + mock_native_idf_components_to_test.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -379,6 +401,8 @@ def test_main_with_branch_argument( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True + assert output["native_idf"] is True + assert output["native_idf_components"] == "esp32" assert output["changed_components"] == ["mqtt"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -827,6 +851,142 @@ def test_should_run_device_builder_skips_beta_release(target_branch: str) -> Non mock_changed.assert_not_called() +_NATIVE_IDF_FULL_LIST_FILES = [ + # Core C++/Python changes -- caught by core_changed() + ["esphome/core/component.cpp"], + ["esphome/core/config.py"], + # Native IDF infrastructure paths + ["esphome/espidf/framework.py"], + ["esphome/espidf/component.py"], + ["esphome/espidf/api.py"], + ["esphome/build_gen/espidf.py"], + # Workflow / harness files + ["script/test_build_components.py"], + [".github/workflows/ci.yml"], +] + + +@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES) +def test_native_idf_components_to_test_returns_full_list_on_infrastructure( + changed_files: list[str], +) -> None: + """Infrastructure / core / harness changes fall back to the full component list.""" + with ( + patch.object(determine_jobs, "changed_files", return_value=changed_files), + # The dep-closure path shouldn't be consulted at all -- if it is, + # the obviously-wrong "wifi" sneaks in and the assertion catches it. + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=["wifi"] + ), + ): + result = determine_jobs.native_idf_components_to_test() + assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS) + + +@pytest.mark.parametrize( + ("changed_files", "dependency_closure", "expected"), + [ + # Single tested component changed -- narrow to just that component. + ( + ["esphome/components/esp32/__init__.py"], + ["esp32"], + ["esp32"], + ), + # Dependency closure: multiple BLE components in the changed set + # are all intersected with the test list and returned sorted. + ( + ["esphome/components/esp32_ble/ble.cpp"], + ["esp32_ble", "esp32_ble_tracker", "ble_scanner"], + ["ble_scanner", "esp32_ble", "esp32_ble_tracker"], + ), + # api in the test set -- narrow to [api] even though the closure + # has other (unrelated to native-IDF coverage) entries. + ( + ["esphome/components/api/api_connection.cpp"], + ["api", "logger"], + ["api"], + ), + # Components outside the test set return an empty list (job skipped). + ( + ["esphome/components/wifi/wifi_component.cpp"], + ["wifi", "network"], + [], + ), + # Pure Python-only change outside trigger paths -> empty. + (["esphome/yaml_util.py"], [], []), + # Non-IDF files in esphome/build_gen/ do NOT trigger the full + # list -- only esphome/build_gen/espidf.py is a trigger. + (["esphome/build_gen/platformio.py"], [], []), + # Docs / unrelated files -> empty. + (["README.md"], [], []), + ([], [], []), + ], +) +def test_native_idf_components_to_test_narrowing( + changed_files: list[str], + dependency_closure: list[str], + expected: list[str], +) -> None: + """Component changes narrow the test list to the intersection.""" + with ( + patch.object(determine_jobs, "changed_files", return_value=changed_files), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=dependency_closure, + ), + ): + result = determine_jobs.native_idf_components_to_test() + assert result == expected + + +def test_native_idf_components_to_test_with_branch() -> None: + """native_idf_components_to_test passes branch argument through. + + Regression test: an earlier version called ``get_changed_components()``, + which silently ignored the branch argument because that helper re-runs + ``changed_files()`` with its own default. The current implementation + derives the closure from ``files = changed_files(branch)`` directly, + so a branch arg has to flow through ``changed_files``. + """ + with ( + patch.object(determine_jobs, "changed_files") as mock_changed, + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + ): + mock_changed.return_value = [] + determine_jobs.native_idf_components_to_test("release") + mock_changed.assert_called_once_with("release") + + +@pytest.mark.parametrize( + ("components_to_test", "expected"), + [ + ([], False), + (["esp32"], True), + (["esp32", "api"], True), + ], +) +def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None: + """should_run_native_idf is a thin wrapper around the component list.""" + with patch.object( + determine_jobs, + "native_idf_components_to_test", + return_value=components_to_test, + ): + assert determine_jobs.should_run_native_idf() is expected + + +def test_should_run_native_idf_with_branch() -> None: + """Test should_run_native_idf passes branch argument through.""" + with patch.object( + determine_jobs, "native_idf_components_to_test", return_value=[] + ) as mock_inner: + determine_jobs.should_run_native_idf("release") + mock_inner.assert_called_once_with("release") + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ From 8bce32ec35b3506bfc5950487d7c5bf8df18e06f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 10:01:26 -0500 Subject: [PATCH 1209/2214] [tests] Cover top-level !include failure path in track_yaml_loads (#16396) --- tests/unit_tests/test_yaml_util.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 3815ac1d75..ace92fbf6f 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -390,6 +390,21 @@ def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None: assert len(yaml_util._load_listeners) == before +def test_track_yaml_loads_no_duplicate_load_on_top_level_include_failure( + tmp_path: Path, +) -> None: + """A failed top-level !include must not record any file twice in track_yaml_loads.""" + main = tmp_path / "main.yaml" + main.write_text("!include missing.yaml\n") + + with yaml_util.track_yaml_loads() as loaded, pytest.raises(EsphomeError): + yaml_util.load_yaml(main) + + assert len(loaded) == len(set(loaded)), ( + f"Files loaded more than once during a failed top-level include: {loaded}" + ) + + @pytest.mark.parametrize( "data", [ From cb520cda6bf111cb333a8b71ffb067a00df7ab7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 10:01:42 -0500 Subject: [PATCH 1210/2214] [core] Retry PlatformIO downloads on transport-layer errors (#16397) --- esphome/platformio/runner.py | 12 +- tests/unit_tests/test_platformio_toolchain.py | 121 ++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index 976979dc57..caab47dcc2 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -51,9 +51,13 @@ def patch_file_downloader() -> None: """Retry PlatformIO package downloads with exponential backoff. PlatformIO's ``FileDownloader`` uses an ``HTTPSession`` without built-in - retry for 502/503 errors. We wrap ``__init__`` to retry on - ``PackageException`` and close the session between attempts so a new - TCP connection can route to a different CDN edge node. + retry. We wrap ``__init__`` to retry on transient failures and close the + session between attempts so a new TCP connection can route to a different + CDN edge node. We catch both ``PackageException`` (raised when the server + returns a non-200 status such as 502/503) and ``OSError`` -- which covers + ``requests.exceptions.ConnectionError``, ``ReadTimeout``, and + ``ChunkedEncodingError`` (all subclasses of ``OSError``) that get raised + when the connection is aborted before a response is parsed. """ from platformio.package.download import FileDownloader from platformio.package.exception import PackageException @@ -70,7 +74,7 @@ def patch_file_downloader() -> None: try: original_init(self, *args, **kwargs) return - except PackageException as e: + except (PackageException, OSError) as e: if attempt < max_retries - 1: delay = 2 ** (attempt + 1) _LOGGER.warning( diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index f771437dd4..c1d16530cb 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,10 +2,13 @@ # pylint: disable=protected-access +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path import shutil +import threading from types import SimpleNamespace from unittest.mock import MagicMock, Mock, call, patch @@ -867,6 +870,56 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() -> mock_session.close.assert_called_once() +def test_patch_file_downloader_retries_on_connection_error() -> None: + """Test patch_file_downloader retries on transport-layer errors (OSError subclasses). + + ``requests.exceptions.ConnectionError`` and ``ReadTimeout`` subclass + ``OSError`` and are raised when the connection is aborted before any HTTP + response is parsed -- e.g. ``RemoteDisconnected`` mid-download. These must + retry too, not just ``PackageException``. + """ + mock_exception_cls = type("PackageException", (Exception,), {}) + call_count = 0 + + def failing_init(self, *args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ConnectionError( + f"Connection aborted attempt {call_count}: RemoteDisconnected" + ) + + with ( + patch.dict( + "sys.modules", + { + "platformio": MagicMock(), + "platformio.package": MagicMock(), + "platformio.package.download": SimpleNamespace( + FileDownloader=type( + "FileDownloader", (), {"__init__": failing_init} + ) + ), + "platformio.package.exception": SimpleNamespace( + PackageException=mock_exception_cls + ), + }, + ), + patch("time.sleep") as mock_sleep, + ): + runner.patch_file_downloader() + + from platformio.package.download import FileDownloader + + instance = object.__new__(FileDownloader) + FileDownloader.__init__(instance, "http://example.com/file.zip") + + assert call_count == 3 + assert mock_sleep.call_count == 2 + mock_sleep.assert_any_call(2) + mock_sleep.assert_any_call(4) + + def test_patch_file_downloader_idempotent() -> None: """Test patch_file_downloader does not stack wrappers when called multiple times.""" mock_exception_cls = type("PackageException", (Exception,), {}) @@ -903,6 +956,74 @@ def test_patch_file_downloader_idempotent() -> None: assert call_count == 1 +@contextmanager +def _flaky_http_server(fail_first_n: int, fail_mode: str): + """Local HTTP server that fails the first ``fail_first_n`` requests. + + ``fail_mode="drop"`` closes the TCP connection without responding, so + the client raises ``RemoteDisconnected`` -- the exact CI failure mode. + ``fail_mode="502"`` returns an HTTP 502, triggering ``PackageException``. + """ + state = {"hits": 0} + + class _Handler(BaseHTTPRequestHandler): + def handle_one_request(self) -> None: + state["hits"] += 1 + if state["hits"] <= fail_first_n and fail_mode == "drop": + return # Skip read+respond → kernel sends FIN → RemoteDisconnected + super().handle_one_request() + + def do_GET(self) -> None: # noqa: N802 + if state["hits"] <= fail_first_n and fail_mode == "502": + self.send_error(502) + return + body = b"esphome-test-payload" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + pass # silence default stderr logging + + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_address[1], state + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +@pytest.mark.parametrize("fail_mode", ["drop", "502"]) +def test_patch_file_downloader_recovers_against_real_server( + tmp_path: Path, fail_mode: str +) -> None: + """End-to-end: real PlatformIO ``FileDownloader`` against a local server + that fails twice then succeeds. Exercises the real + requests/urllib3/http.client stack for both failure modes: + + - ``drop``: TCP close mid-request → ``RemoteDisconnected`` → caught as + ``OSError`` by the retry patch (the CI failure path). + - ``502``: HTTP error response → ``PackageException`` (the original path). + """ + runner.patch_file_downloader() + from platformio.package.download import FileDownloader + + with ( + _flaky_http_server(fail_first_n=2, fail_mode=fail_mode) as (port, state), + patch("time.sleep"), + ): + fd = FileDownloader(f"http://127.0.0.1:{port}/payload.bin") + fd.set_destination(str(tmp_path / "out.bin")) + fd.start(with_progress=False, silent=True) + + assert state["hits"] == 3 # 2 failures + 1 success + assert (tmp_path / "out.bin").read_bytes() == b"esphome-test-payload" + + def _filter_through_redirect(line: str) -> str: """Write a line through RedirectText with FILTER_PLATFORMIO_LINES and return what passes.""" import io From 3fee97ae5a81e67f0bc0c2a2f3437949f396ad48 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 12:08:51 -0400 Subject: [PATCH 1211/2214] [espidf] Partition pio_components cache by framework (#16401) --- esphome/espidf/component.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index bb675d2c77..8cd77dc6d1 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -86,7 +86,10 @@ class URLSource(Source): self.url = url def download(self, dir_suffix: str, force: bool = False) -> Path: - base_dir = Path(CORE.data_dir) / DOMAIN + # Partition by framework: generated idf_component.yml content + # depends on CORE.using_arduino, so caches can't be shared. + framework = "arduino" if CORE.using_arduino else "idf" + base_dir = Path(CORE.data_dir) / DOMAIN / framework h = hashlib.new("sha256") h.update(self.url.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix @@ -121,11 +124,12 @@ class GitSource(Source): self.ref = ref def download(self, dir_suffix: str, force: bool = False) -> Path: + framework = "arduino" if CORE.using_arduino else "idf" path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=DOMAIN, + domain=f"{DOMAIN}/{framework}", submodules=[], subpath=Path(dir_suffix), ) From d7b00047bd2d359c4ca56d38db8c012d9965e835 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 12:27:06 -0400 Subject: [PATCH 1212/2214] [espidf] Emit -W warning flags at project scope so managed components also see them (#16403) --- esphome/build_gen/espidf.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index dfe2d72b9d..82c8537bef 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -54,11 +54,22 @@ def get_project_cmakelists() -> str: variant = get_esp32_variant() idf_target = variant.lower().replace("-", "") - # Extract compile definitions from build flags (-DXXX -> XXX) - compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")] + # Project-wide compile options: -D defines and -W warning flags (skip + # -Wl, linker flags — those go on the src component via + # target_link_options below). Emitted via idf_build_set_property so the + # flags propagate to every IDF component (including managed ones like + # esphome__micro-mp3) rather than just src/. Required so suppressions + # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in + # third-party components we don't author. + project_compile_opts = [ + flag + for flag in sorted(CORE.build_flags) + if flag.startswith("-D") + or (flag.startswith("-W") and not flag.startswith("-Wl,")) + ] extra_compile_options = "\n".join( - f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)' - for compile_def in compile_defs + f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)' + for flag in project_compile_opts ) return f"""\ @@ -107,15 +118,9 @@ def get_component_cmakelists(minimal: bool = False) -> str: idf_requires = [] if minimal else (get_available_components() or []) requires_str = " ".join(idf_requires) - # Extract compile options (-W flags, excluding linker flags) - compile_opts = [ - flag - for flag in CORE.build_flags - if flag.startswith("-W") and not flag.startswith("-Wl,") - ] - compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else "" - - # Extract linker options (-Wl, flags) + # Extract linker options (-Wl, flags). Compile flags (-D, -W) are + # emitted project-wide via idf_build_set_property in + # get_project_cmakelists so they reach every component, not just src/. link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else "" @@ -137,11 +142,6 @@ idf_component_register( # Apply C++ standard target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20) -# ESPHome compile options -target_compile_options(${{COMPONENT_LIB}} PUBLIC - {compile_opts_str} -) - # ESPHome linker options target_link_options(${{COMPONENT_LIB}} PUBLIC {link_opts_str} From 445d841229ec155fa13c6752ec54451fde553e1b Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 13 May 2026 18:49:32 +0200 Subject: [PATCH 1213/2214] [mitsubishi_cn105] Simplified protocol lookups (#16399) --- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 56f1ee1b3f..3a42616d8a 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -30,44 +30,53 @@ static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; -static constexpr std::array, 9> PROTOCOL_MODE_MAP = { - std::nullopt, // 0x00 +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; + } +}; + +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 - std::nullopt, // 0x04 - std::nullopt, // 0x05 - std::nullopt, // 0x06 + MitsubishiCN105::Mode::UNKNOWN, // 0x04 + MitsubishiCN105::Mode::UNKNOWN, // 0x05 + MitsubishiCN105::Mode::UNKNOWN, // 0x06 MitsubishiCN105::Mode::FAN_ONLY, // 0x07 MitsubishiCN105::Mode::AUTO // 0x08 -}; +}); -static constexpr std::array, 7> PROTOCOL_FAN_MODE_MAP = { +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 - std::nullopt, // 0x04 + MitsubishiCN105::FanMode::UNKNOWN, // 0x04 MitsubishiCN105::FanMode::SPEED_3, // 0x05 MitsubishiCN105::FanMode::SPEED_4 // 0x06 -}; - -template -static constexpr std::optional lookup(const std::array, N> &table, uint8_t value) { - return (value < N) ? table[value] : std::nullopt; -} - -template -static constexpr bool reverse_lookup(const std::array, N> &table, T value, uint8_t &placeholder) { - for (size_t i = 0; i < N; ++i) { - const auto &table_value = table[i]; - if (table_value.has_value() && table_value == value) { - placeholder = i; - return true; - } - } - return false; -} +}); static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); @@ -323,11 +332,11 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) if (!this->pending_updates_.contains(UpdateFlag::MODE)) { const bool i_see = payload[3] > 0x08; - this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN); + this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0)); } if (!this->pending_updates_.contains(UpdateFlag::FAN)) { - this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN); + this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]); } return true; @@ -382,7 +391,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { void MitsubishiCN105::set_mode(Mode mode) { uint8_t placeholder; - if (!reverse_lookup(PROTOCOL_MODE_MAP, mode, placeholder)) { + if (!PROTOCOL_MODE_MAP.reverse_lookup(mode, placeholder)) { ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); return; } @@ -392,7 +401,7 @@ void MitsubishiCN105::set_mode(Mode mode) { void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { uint8_t placeholder; - if (!reverse_lookup(PROTOCOL_FAN_MODE_MAP, fan_mode, placeholder)) { + if (!PROTOCOL_FAN_MODE_MAP.reverse_lookup(fan_mode, placeholder)) { ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); return; } @@ -432,12 +441,12 @@ void MitsubishiCN105::apply_settings_() { } if (this->pending_updates_.contains(UpdateFlag::MODE) && - reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) { + PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) { payload[1] |= 0x02; } if (this->pending_updates_.contains(UpdateFlag::FAN) && - reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) { + PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) { payload[1] |= 0x08; } From 03f5e4775cee9b13d8a8d09af63117f6e1687cae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 12:06:20 -0500 Subject: [PATCH 1214/2214] [tests] Add CodSpeed benchmark for compiled-config cache fast path (#16402) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 5 +- requirements_test.txt | 5 + tests/benchmarks/python/__init__.py | 0 tests/benchmarks/python/conftest.py | 22 ++++ .../fixtures/bluetooth_proxy_device.yaml | 62 ++++++++++ .../python/test_compiled_config_bench.py | 116 ++++++++++++++++++ 6 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 tests/benchmarks/python/__init__.py create mode 100644 tests/benchmarks/python/conftest.py create mode 100644 tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml create mode 100644 tests/benchmarks/python/test_compiled_config_bench.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06c6c0fec1..819dac926e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -423,7 +423,10 @@ jobs: - name: Run CodSpeed benchmarks uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1 with: - run: ${{ steps.build.outputs.binary }} + run: | + . venv/bin/activate + ${{ steps.build.outputs.binary }} + pytest tests/benchmarks/python/ --codspeed --no-cov mode: simulation clang-tidy-single: diff --git a/requirements_test.txt b/requirements_test.txt index 568d79d676..218bc0083c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,5 +13,10 @@ pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 +# CodSpeed benchmarks under tests/benchmarks/python/ +# (skipped via pytest.importorskip when missing -- only required for the +# benchmarks job in .github/workflows/ci.yml) +pytest-codspeed==5.0.1 + # Used by the import-time regression check (.github/workflows/ci.yml → import-time job) importtime-waterfall==1.0.0 diff --git a/tests/benchmarks/python/__init__.py b/tests/benchmarks/python/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/benchmarks/python/conftest.py b/tests/benchmarks/python/conftest.py new file mode 100644 index 0000000000..9b0f1a3d2b --- /dev/null +++ b/tests/benchmarks/python/conftest.py @@ -0,0 +1,22 @@ +"""Shared fixtures for the Python benchmark suite.""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest + +from esphome.core import CORE + + +@pytest.fixture(autouse=True) +def reset_core_state() -> Generator[None]: + """Reset CORE before and after every benchmark. + + Per-iteration setups inside benchmarks reset CORE for the loop body; + this fixture handles the test-level boundary so stale state from + fixture priming doesn't leak across benchmarks. + """ + CORE.reset() + yield + CORE.reset() diff --git a/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml b/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml new file mode 100644 index 0000000000..dfa5a487b8 --- /dev/null +++ b/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml @@ -0,0 +1,62 @@ +substitutions: + devicename: bluetooth_proxy_device + friendly_name: bluetooth_proxy_device + +esphome: + name: $devicename + friendly_name: $friendly_name + +esp32: + board: esp32-poe-iso + framework: + type: esp-idf + advanced: + sram1_as_iram: true + minimum_chip_revision: "3.0" + +esp32_ble_tracker: + scan_parameters: + active: false + +bluetooth_proxy: + active: true + +ethernet: + type: LAN8720 + mdc_pin: GPIO23 + mdio_pin: GPIO18 + clk_mode: GPIO17_OUT + phy_addr: 0 + power_pin: GPIO12 + +debug: +logger: +api: +ota: + platform: esphome + +button: + - platform: restart + name: Restart + +time: + - platform: homeassistant + id: homeassistant_time + - platform: sntp + id: sntp_time + +sensor: + - platform: uptime + name: Ethernet Uptime + - platform: template + name: Free Memory + lambda: return heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + unit_of_measurement: B + state_class: measurement + - platform: debug + free: + name: Heap Free + fragmentation: + name: Heap Fragmentation + min_free: + name: Heap Min Free diff --git a/tests/benchmarks/python/test_compiled_config_bench.py b/tests/benchmarks/python/test_compiled_config_bench.py new file mode 100644 index 0000000000..5c8892f8d0 --- /dev/null +++ b/tests/benchmarks/python/test_compiled_config_bench.py @@ -0,0 +1,116 @@ +"""CodSpeed benchmarks for the validated-config cache fast path. + +PR #16381 added a cache that lets ``esphome upload`` / ``esphome logs`` +skip re-running the full config-validation pipeline. These benchmarks +compare the cached path (``load_compiled_config``) against the slow +path (``read_config``) on the same input. + +The fixture YAML is a modest bluetooth-proxy device. The two paths +end up close on a config this small -- the win grows with config +complexity (external components, large package trees, deeply nested +schemas), where the slow path can be orders of magnitude slower than +the cache load. + +Skipped when ``pytest-codspeed`` isn't installed so the regular +unit-test suite keeps working unchanged. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import shutil +from typing import Any + +import pytest + +from esphome.compiled_config import compiled_config_path, load_compiled_config +from esphome.config import read_config +from esphome.core import CORE +from esphome.storage_json import ext_storage_path +from esphome.writer import update_storage_json + +pytest.importorskip("pytest_codspeed") + +HERE = Path(__file__).parent +FIXTURE_YAML = HERE / "fixtures" / "bluetooth_proxy_device.yaml" + + +def _stage_yaml(tmp_path: Path) -> Path: + """Copy fixture YAML into a fresh tmp dir. + + Each benchmark gets its own copy so the cache files (under + ``.esphome/storage/`` next to the YAML) don't bleed between cases. + """ + target = tmp_path / FIXTURE_YAML.name + shutil.copy2(FIXTURE_YAML, target) + return target + + +def _prime_cache(yaml_path: Path) -> None: + """Run full validation once and persist the cache + sidecar. + + Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``, + then ``update_storage_json`` writes both the StorageJSON sidecar and + the ``.validated.yaml`` compiled-config cache. + """ + CORE.config_path = yaml_path + config = read_config({}, skip_external_update=True) + assert config is not None, f"fixture YAML failed to validate: {yaml_path}" + CORE.config = config + update_storage_json() + + +@pytest.fixture +def staged_yaml(tmp_path: Path) -> Path: + """YAML copied into tmp_path; no cache files written yet.""" + return _stage_yaml(tmp_path) + + +@pytest.fixture +def primed_yaml(staged_yaml: Path) -> Path: + """YAML plus a fresh cache + sidecar on disk.""" + _prime_cache(staged_yaml) + assert compiled_config_path(staged_yaml.name).is_file() + assert ext_storage_path(staged_yaml.name).is_file() + return staged_yaml + + +def _resetting_setup( + yaml_path: Path, + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> Callable[[], tuple[tuple[Any, ...], dict[str, Any]]]: + """Build a per-iteration setup that resets CORE and re-pins config_path.""" + + def setup() -> tuple[tuple[Any, ...], dict[str, Any]]: + CORE.reset() + CORE.config_path = yaml_path + return args, kwargs + + return setup + + +def test_load_compiled_config_cached(primed_yaml: Path, benchmark) -> None: + """Fast path: deserialize the cached, already-validated config.""" + benchmark.pedantic( + load_compiled_config, + setup=_resetting_setup(primed_yaml, (primed_yaml,), {}), + rounds=5, + iterations=1, + ) + + +def test_read_config_uncached(primed_yaml: Path, benchmark) -> None: + """Slow path: full validation pipeline (yaml load + schema + components). + + Uses the same primed fixture as the cached path -- ``read_config`` + ignores the cache file on disk, so the two benchmarks measure the + same input from two different code paths. + """ + benchmark.pedantic( + read_config, + setup=_resetting_setup(primed_yaml, ({},), {"skip_external_update": True}), + rounds=3, + iterations=1, + ) From 1c6966b7612cc2ad2a86070b05fbce3f2234260c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 13:07:59 -0400 Subject: [PATCH 1215/2214] [espidf] Run PIO extraScript with SCons-env shim (#16404) --- esphome/espidf/component.py | 47 +++++-- esphome/espidf/extra_script.py | 161 ++++++++++++++++++++++ tests/unit_tests/test_espidf_component.py | 122 +++++++++++++++- 3 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 esphome/espidf/extra_script.py diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 8cd77dc6d1..af8640949d 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -322,6 +322,36 @@ def _patch_component(component: IDFComponent, first_pass: bool): (component.path / "idf_component.yml").write_text("") +def _apply_extra_script(component: IDFComponent) -> None: + """Run a PIO ``extraScript`` and fold its captured env vars into + ``component.data["build"]["flags"]`` so the existing -L/-l/-D + extraction in ``generate_cmakelists_txt`` picks them up.""" + extra_script = component.data.get("build", {}).get("extraScript") + if not extra_script: + return + # Resolve and confine to the component dir so a malicious library.json + # can't escape (e.g. ``"extraScript": "../../etc/passwd"``). + library_root = component.path.resolve() + script_path = (component.path / extra_script).resolve() + if not script_path.is_relative_to(library_root) or not script_path.is_file(): + return + from esphome.components.esp32 import get_esp32_variant + from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script + + idf_target = get_esp32_variant().lower().replace("-", "") + result = run_extra_script( + script_path, library_dir=component.path, idf_target=idf_target + ) + extra_flags = captured_as_build_flags(result, library_dir=component.path) + if not extra_flags: + return + flags = component.data.setdefault("build", {}).setdefault("flags", []) + if isinstance(flags, str): + flags = [flags] + flags.extend(extra_flags) + component.data["build"]["flags"] = flags + + T = TypeVar("T") @@ -748,13 +778,6 @@ def _check_library_data(data: dict): if not valid_framework: raise InvalidIDFComponent(f"Unsupported library frameworks: {frameworks}") - extra_script = data.get("build", {}).get("extraScript", None) - if extra_script: - _LOGGER.warning( - 'Extra scripts are not supported. The script "%s" will not be executed.', - extra_script, - ) - def _process_dependencies(component: IDFComponent): """ @@ -899,9 +922,17 @@ def _generate_idf_component(library: Library, force: bool = False) -> IDFCompone # Apply additional patches to the library metadata _patch_component(component, False) - # Check if the component is usable with ESP-IDF + # Check if the component is usable with ESP-IDF before executing any + # third-party Python from the library (``_apply_extra_script`` below). _check_library_data(component.data) + # If the library declares a PIO ``extraScript``, run it against a + # fake SCons env so we can fold its captured LIBPATH/LIBS/etc into + # the build-flag pipeline ``generate_cmakelists_txt`` consumes + # below. Without this, libraries that wire per-MCU archive linking + # via extraScript fail to link under native ESP-IDF. + _apply_extra_script(component) + # Handle the dependencies (convert PlatformIO library to ESP-IDF component if needed) _process_dependencies(component) diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py new file mode 100644 index 0000000000..2f22f23c10 --- /dev/null +++ b/esphome/espidf/extra_script.py @@ -0,0 +1,161 @@ +"""Run a PlatformIO ``extraScript`` against a captured SCons-env stand-in. + +PlatformIO libraries occasionally configure per-target link/build state +via a Python ``extraScript`` declared in ``library.json``'s ``build`` +section instead of static fields. The script runs under SCons during +PIO's build and mutates the active ``Environment`` (``env.Append``, +``env.Replace``, …) — chiefly to set ``LIBPATH``/``LIBS`` per chip MCU. + +ESPHome's PIO→IDF converter (``_generate_idf_component``) doesn't run +SCons, so these scripts were previously ignored and any library +relying on them failed to link under ``toolchain: esp-idf``. This +module provides a small shim that ``exec``s an extra-script with a +fake ``env`` object, captures the common ``env.Append(...)`` calls, +and returns the captured vars so the caller can fold them back into +the library's generated CMakeLists. + +Caveats +------- +* Only the ``env.Append`` API is captured. ``env.Replace``, + ``env.Prepend``, ``env.AddPreAction``, SCons file generators, and any + arbitrary I/O are silently no-ops. Scripts that depend on those will + produce incomplete output. +* Running arbitrary Python from third-party libraries is a non-trivial + trust decision. The shim does no sandboxing — anything in the + script's process can run. Use only with libraries whose source you + trust. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import logging +import os +from pathlib import Path + +_LOGGER = logging.getLogger(__name__) + +# Keys we know how to translate back into ESPHome's build-flag pipeline. +# Other env.Append kwargs are recorded but ignored downstream. +_CAPTURED_KEYS = frozenset({"LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"}) + + +@dataclass +class ExtraScriptResult: + """Build-var deltas captured from a PIO extra-script ``env.Append`` call.""" + + libpath: list[str] = field(default_factory=list) + libs: list[str] = field(default_factory=list) + cppdefines: list[str | tuple[str, str]] = field(default_factory=list) + linkflags: list[str] = field(default_factory=list) + cppflags: list[str] = field(default_factory=list) + + +class _FakeSConsEnv: + """Minimal stand-in for SCons ``Environment`` exposed to extra-scripts. + + Implements just enough surface area to let scripts query ``BOARD_MCU`` + / ``PIOENV`` and call ``env.Append(LIBPATH=…, LIBS=…, …)``. Every + other env method swallows silently so unrelated calls don't raise + ``AttributeError`` and abort the script. + """ + + def __init__(self, *, board_mcu: str, pio_env: str) -> None: + self._vars: dict[str, str] = { + "BOARD_MCU": board_mcu, + "PIOPLATFORM": "espressif32", + "PIOENV": pio_env, + } + self.result = ExtraScriptResult() + + # ----- SCons env API the common scripts use ----- + + def get(self, key: str, default: str | None = None) -> str | None: + return self._vars.get(key, default) + + def Append(self, **kwargs) -> None: # noqa: N802 (SCons API name) + for key, value in kwargs.items(): + if key not in _CAPTURED_KEYS: + continue + items = list(value) if isinstance(value, (list, tuple)) else [value] + bucket = getattr(self.result, key.lower()) + bucket.extend(items) + + # ----- Everything else is a no-op so unsupported scripts don't crash ----- + + def __getattr__(self, name: str): + def _noop(*args, **kwargs): + return None + + return _noop + + +def run_extra_script( + script_path: Path, *, library_dir: Path, idf_target: str +) -> ExtraScriptResult: + """Execute ``script_path`` with a fake SCons env and return captured vars. + + ``idf_target`` is the active ESP-IDF target name (e.g. ``esp32``, + ``esp32s3``); it's exposed to the script as PlatformIO's + ``BOARD_MCU`` so chip-conditional logic resolves the same way it + would under PIO. The script runs with ``library_dir`` as the + process CWD so relative-path lookups (``join``, ``realpath``, + ``open``) resolve against the library tree. + + On any exception inside the script we log at debug level and return + an empty result — extra-scripts are best-effort, and an unsupported + script shouldn't block the build. + """ + env = _FakeSConsEnv(board_mcu=idf_target, pio_env=f"esphome_{idf_target}") + code = compile(script_path.read_text(), str(script_path), "exec") + old_cwd = os.getcwd() + try: + os.chdir(library_dir) + exec( # noqa: S102 pylint: disable=exec-used + code, + { + "Import": lambda *_args: None, # SCons-side import; harmless here + "env": env, + "__file__": str(script_path), + "__name__": "__pio_extra_script__", + }, + ) + except Exception as e: # pylint: disable=broad-exception-caught + _LOGGER.warning("PIO extra-script %s raised %s; skipping", script_path, e) + return ExtraScriptResult() + finally: + os.chdir(old_cwd) + return env.result + + +def captured_as_build_flags( + result: ExtraScriptResult, *, library_dir: Path +) -> list[str]: + """Translate captured env vars into the ``-L`` / ``-l`` / ``-D`` / + raw-flag form ``_generate_cmakelists_txt`` already knows how to consume. + + ``LIBPATH`` entries are made relative to ``library_dir`` so the + generated CMakeLists is portable; absolute paths outside the library + tree are kept as-is (CMake handles absolute paths in + ``target_link_directories`` fine). + """ + flags: list[str] = [] + library_root = library_dir.resolve() + for path in result.libpath: + # Anchor relative paths to library_dir (not the current CWD, which + # has been restored by the time we get here). Joining an absolute + # path against library_dir returns the absolute path unchanged. + resolved = (library_dir / path).resolve() + try: + flags.append(f"-L{resolved.relative_to(library_root)}") + except ValueError: + flags.append(f"-L{resolved}") + flags.extend(f"-l{lib}" for lib in result.libs) + for define in result.cppdefines: + if isinstance(define, tuple) and len(define) == 2: + flags.append(f"-D{define[0]}={define[1]}") + else: + flags.append(f"-D{define}") + flags.extend(result.linkflags) + flags.extend(result.cppflags) + return flags diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index caef10eea3..3988c997a7 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -250,14 +250,126 @@ def test_check_library_data_invalid_framework(esp32_idf_core): _check_library_data({"platforms": "*", "frameworks": ["other"]}) -def test_extra_script_logs_warning(caplog, esp32_idf_core): - extra_script = "myscript.sh" +def test_extra_script_captures_libpath_libs_and_defines(tmp_path): + from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script + + (tmp_path / "src" / "esp32").mkdir(parents=True) + script = tmp_path / "extra_script.py" + script.write_text( + "Import('env')\n" + "mcu = env.get('BOARD_MCU')\n" + "env.Append(\n" + " LIBPATH=[join('src', mcu)],\n" + " LIBS=['algobsec'],\n" + " CPPDEFINES=['FOO', ('BAR', '1')],\n" + " LINKFLAGS=['-Wl,--gc-sections'],\n" + ")\n" + ) + # The script uses bare ``join`` (PIO's extra-scripts run inside SCons + # where this is in scope). Inject it via the script header so the + # shim's exec namespace can resolve it. + script.write_text("from os.path import join\n" + script.read_text()) + + result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32") + + assert result.libpath == [os.path.join("src", "esp32")] + assert result.libs == ["algobsec"] + assert ("BAR", "1") in result.cppdefines + assert "FOO" in result.cppdefines + assert result.linkflags == ["-Wl,--gc-sections"] + + flags = captured_as_build_flags(result, library_dir=tmp_path) + sep = os.sep + assert f"-Lsrc{sep}esp32" in flags + assert "-lalgobsec" in flags + assert "-DFOO" in flags + assert "-DBAR=1" in flags + assert "-Wl,--gc-sections" in flags + + +def test_extra_script_libpath_relative_resolves_against_library_dir( + tmp_path, monkeypatch +): + """Relative LIBPATH entries must resolve against ``library_dir``, not the + caller's CWD (the shim restores CWD before ``captured_as_build_flags`` + runs).""" + from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags + + (tmp_path / "lib" / "esp32").mkdir(parents=True) + elsewhere = tmp_path.parent / "not_the_library_dir" + elsewhere.mkdir(exist_ok=True) + monkeypatch.chdir(elsewhere) + + result = ExtraScriptResult(libpath=["lib/esp32"]) + flags = captured_as_build_flags(result, library_dir=tmp_path) + + sep = os.sep + assert flags == [f"-Llib{sep}esp32"] + + +def test_extra_script_libpath_absolute_outside_library_dir(tmp_path): + from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags + + outside = tmp_path.parent / "system_lib" + outside.mkdir(exist_ok=True) + result = ExtraScriptResult(libpath=[str(outside)]) + + flags = captured_as_build_flags(result, library_dir=tmp_path) + assert flags == [f"-L{outside.resolve()}"] + + +def test_extra_script_failure_returns_empty_result(tmp_path, caplog): + from esphome.espidf.extra_script import run_extra_script + + script = tmp_path / "broken.py" + script.write_text("raise RuntimeError('boom')\n") with caplog.at_level("WARNING"): - _check_library_data({"build": {"extraScript": extra_script}}) + result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32") - assert "not supported" in caplog.text - assert "myscript.sh" in caplog.text + assert result.libpath == [] + assert result.libs == [] + assert "broken.py" in caplog.text + + +def test_apply_extra_script_path_traversal_is_rejected(tmp_path): + from esphome.espidf.component import _apply_extra_script + + library_dir = tmp_path / "lib" + library_dir.mkdir() + outside = tmp_path / "evil.py" + outside.write_text("env.Append(LIBS=['pwned'])\n") + + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = library_dir + c.data = {"build": {"extraScript": "../evil.py"}} + + _apply_extra_script(c) + + # Nothing was folded into flags: the traversal was rejected before + # the script could run. + assert "flags" not in c.data["build"] + + +def test_apply_extra_script_merges_into_existing_flags(tmp_path, monkeypatch): + from esphome.components import esp32 as esp32_module + + monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32") + + from esphome.espidf.component import _apply_extra_script + + (tmp_path / "src").mkdir() + script = tmp_path / "extra.py" + script.write_text("env.Append(LIBS=['algobsec'])\n") + + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}} + + _apply_extra_script(c) + + assert "-DEXISTING" in c.data["build"]["flags"] + assert "-lalgobsec" in c.data["build"]["flags"] def test_parse_library_json(tmp_path): From ce8810bc42984d9b69ca795106b039f8be95eecb Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 13 May 2026 20:25:32 +0200 Subject: [PATCH 1216/2214] [mitsubishi_cn105] Add vane and wide-vane support (#16405) --- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 90 +++++++++++++++++-- .../mitsubishi_cn105/mitsubishi_cn105.h | 35 +++++++- .../mitsubishi_cn105_climate.cpp | 2 +- .../climate/mitsubishi_cn105_tests.cpp | 67 +++++++++++++- tests/components/mitsubishi_cn105/common.h | 1 + 5 files changed, 178 insertions(+), 17 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 3a42616d8a..4782a2ef93 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -50,6 +50,11 @@ template struct LookupMap { } 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]) { @@ -78,6 +83,33 @@ static constexpr auto PROTOCOL_FAN_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})); } @@ -91,7 +123,7 @@ static constexpr auto make_packet(uint8_t type, const std::arrayset_state_(State::STATUS_UPDATED); } - bool changed = previous.power_on != this->status_.power_on || previous.mode != this->status_.mode || - previous.fan_mode != this->status_.fan_mode || - previous.target_temperature != this->status_.target_temperature; + bool changed = + previous.power_on != this->status_.power_on || previous.mode != this->status_.mode || + 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()) { changed |= previous.room_temperature != this->status_.room_temperature; @@ -339,6 +372,15 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) 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; } @@ -390,8 +432,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { } void MitsubishiCN105::set_mode(Mode mode) { - uint8_t placeholder; - if (!PROTOCOL_MODE_MAP.reverse_lookup(mode, placeholder)) { + if (!PROTOCOL_MODE_MAP.is_valid(mode)) { ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); return; } @@ -400,8 +441,7 @@ void MitsubishiCN105::set_mode(Mode mode) { } void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { - uint8_t placeholder; - if (!PROTOCOL_FAN_MODE_MAP.reverse_lookup(fan_mode, placeholder)) { + if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) { ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); return; } @@ -409,6 +449,24 @@ void MitsubishiCN105::set_fan_mode(FanMode 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; + } + 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; + } + this->status_.wide_vane_mode = wide_vane_mode; + this->pending_updates_.set(UpdateFlag::WIDE_VANE); +} + void MitsubishiCN105::apply_settings_() { std::array payload{}; @@ -450,7 +508,21 @@ void MitsubishiCN105::apply_settings_() { payload[1] |= 0x08; } - this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN); + 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); } 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 60ca81cf9e..dbeb43068e 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -29,12 +29,36 @@ class MitsubishiCN105 { UNKNOWN, }; + enum class VaneMode : uint8_t { + AUTO, + POSITION_1, + POSITION_2, + POSITION_3, + POSITION_4, + POSITION_5, + SWING, + UNKNOWN, + }; + + enum class WideVaneMode : uint8_t { + FAR_LEFT, + LEFT, + CENTER, + RIGHT, + FAR_RIGHT, + LEFT_RIGHT, + SWING, + UNKNOWN, + }; + struct Status { - bool power_on{false}; float target_temperature{NAN}; + float room_temperature{NAN}; + bool power_on{false}; Mode mode{Mode::UNKNOWN}; FanMode fan_mode{FanMode::UNKNOWN}; - float room_temperature{NAN}; + VaneMode vane_mode{VaneMode::UNKNOWN}; + WideVaneMode wide_vane_mode{WideVaneMode::UNKNOWN}; }; explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {} @@ -61,6 +85,8 @@ class MitsubishiCN105 { void set_target_temperature(float target_temperature); void set_mode(Mode mode); void set_fan_mode(FanMode fan_mode); + void set_vane_mode(VaneMode vane_mode); + void set_wide_vane_mode(WideVaneMode mode); void set_remote_temperature(float temperature); void clear_remote_temperature(); @@ -98,7 +124,9 @@ class MitsubishiCN105 { POWER = 1, MODE = 2, FAN = 3, - REMOTE_TEMPERATURE = 4, + VANE = 4, + WIDE_VANE = 5, + REMOTE_TEMPERATURE = 6, }; struct UpdateFlags { @@ -142,6 +170,7 @@ class MitsubishiCN105 { State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; bool use_temperature_encoding_b_{false}; + bool set_wide_vane_high_bit_{false}; FrameParser frame_parser_; uint8_t current_status_msg_type_{0}; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 284339e57f..67a561397a 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -56,7 +56,7 @@ void MitsubishiCN105Climate::dump_config() { 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, " Current temperature: DISABLED"); } ESP_LOGCONFIG(TAG, " Update interval: %" PRIu32 " ms\n" diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index db2fbced1c..ef3cdd0fff 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -53,13 +53,15 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { // Settings response ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07, - 0x00, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x99}); + 0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C}); // Settings should still have initial values EXPECT_FALSE(ctx.sut.status().power_on); EXPECT_THAT(ctx.sut.status().target_temperature, ::testing::IsNan()); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::UNKNOWN); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::UNKNOWN); + EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN); + EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::UNKNOWN); ctx.sut.set_current_time(300); ASSERT_FALSE(ctx.sut.update()); @@ -70,6 +72,8 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_EQ(ctx.sut.status().target_temperature, 24.0f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::AUTO); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::AUTO); + 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) EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); @@ -303,6 +307,30 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { EXPECT_EQ(ctx.sut.status().room_temperature, 30.0f); } +TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { + auto ctx = TestContext{}; + + 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}); + + ctx.sut.update(); + + EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); + EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_); +} + +TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { + auto ctx = TestContext{}; + + 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}); + + ctx.sut.update(); + + EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); + EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_); +} + TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { auto ctx = TestContext{}; @@ -365,6 +393,37 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x73)); } +TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { + auto ctx = TestContext{}; + + ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x10, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x66)); +} + +TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { + auto ctx = TestContext{}; + + ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x7A)); +} + +TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { + auto ctx = TestContext{}; + + ctx.sut.set_wide_vane_high_bit_ = true; + ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x82, 0x00, 0x00, 0xFA)); +} + TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { auto ctx = TestContext{}; @@ -391,15 +450,15 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO); + ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::AUTO); // Waiting for next status update must be interrupted and new values send to AC ctx.sut.set_current_time(6000); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); - 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_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x1F, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xAB)); // Write ACK response ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 73e09d6c84..59b6203732 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -46,6 +46,7 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { using MitsubishiCN105::state_; using MitsubishiCN105::operation_start_ms_; using MitsubishiCN105::use_temperature_encoding_b_; + using MitsubishiCN105::set_wide_vane_high_bit_; using MitsubishiCN105::status_update_wait_credit_ms_; using MitsubishiCN105::pending_updates_; From c8aba6913b97dbca2e77b768b96cfde93fc89476 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 15:38:13 -0500 Subject: [PATCH 1217/2214] Bump requests from 2.34.0 to 2.34.1 (#16408) 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 6be6a95fe6..6291b5cd41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 -requests==2.34.0 +requests==2.34.1 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 910cc38dd704644e3dc2985af3feeabf566a8dc3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 19:25:35 -0400 Subject: [PATCH 1218/2214] [writer] Clean ESP-IDF build artifacts in clean_build (#16410) --- esphome/writer.py | 8 ++++++++ tests/unit_tests/test_writer.py | 28 +++++++++++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index cf04e4f8d2..72c2c355dc 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -490,6 +490,14 @@ def clean_build(clear_pio_cache: bool = True): if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) dependencies_lock.unlink() + # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir + # and the Component Manager's fetched managed components live under + # the project's build path, not under .pioenvs / .piolibdeps. + for name in ("build", "managed_components"): + idf_path = CORE.relative_build_path(name) + if idf_path.is_dir(): + _LOGGER.info("Deleting %s", idf_path) + rmtree(idf_path) if not clear_pio_cache: return diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index e76769e6a8..91b4bd8e87 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -443,6 +443,14 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # Native ESP-IDF toolchain artifacts. + idf_build_dir = tmp_path / "build" + idf_build_dir.mkdir() + (idf_build_dir / "CMakeCache.txt").write_text("cache") + managed_components_dir = tmp_path / "managed_components" + managed_components_dir.mkdir() + (managed_components_dir / "espressif__arduino-esp32").mkdir() + # Create PlatformIO cache directory platformio_cache_dir = tmp_path / ".platformio" / ".cache" platformio_cache_dir.mkdir(parents=True) @@ -454,12 +462,14 @@ def test_clean_build( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir - mock_core.relative_build_path.return_value = dependencies_lock + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert idf_build_dir.exists() + assert managed_components_dir.exists() assert platformio_cache_dir.exists() # Mock PlatformIO's ProjectConfig cache_dir @@ -482,6 +492,8 @@ def test_clean_build( assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not idf_build_dir.exists() + assert not managed_components_dir.exists() assert not platformio_cache_dir.exists() # Verify logging @@ -489,6 +501,8 @@ def test_clean_build( assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert str(idf_build_dir) in caplog.text + assert str(managed_components_dir) in caplog.text assert "PlatformIO cache" in caplog.text @@ -510,7 +524,7 @@ def test_clean_build_partial_exists( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir - mock_core.relative_build_path.return_value = dependencies_lock + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify only pioenvs exists assert pioenvs_dir.exists() @@ -547,7 +561,7 @@ def test_clean_build_nothing_exists( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir - mock_core.relative_build_path.return_value = dependencies_lock + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify nothing exists assert not pioenvs_dir.exists() @@ -583,7 +597,7 @@ def test_clean_build_platformio_not_available( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir - mock_core.relative_build_path.return_value = dependencies_lock + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify all exist before assert pioenvs_dir.exists() @@ -621,7 +635,7 @@ def test_clean_build_empty_cache_dir( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" - mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify pioenvs exists before assert pioenvs_dir.exists() @@ -1349,7 +1363,7 @@ def test_clean_build_handles_readonly_files( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" - mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify file is read-only assert not os.access(readonly_file, os.W_OK) @@ -1413,7 +1427,7 @@ def test_clean_build_reraises_for_other_errors( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" - mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name try: # Mock os.access in writer module to return True (writable) From 06786da7dda30b243f3f20d263e5d03f3538adc9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 16:28:47 -0700 Subject: [PATCH 1219/2214] Bump actions/create-github-app-token from 3.1.1 to 3.2.0 (#16409) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/codeowner-review-request.yml | 2 +- .github/workflows/dashboard-deprecation-comment.yml | 2 +- .github/workflows/external-component-bot.yml | 2 +- .github/workflows/release.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 2d000658a2..6c80d36d20 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -28,7 +28,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index cd6c1d34c6..7cdbfcf328 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -35,7 +35,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml index e15c61df5e..04a2a2151b 100644 --- a/.github/workflows/dashboard-deprecation-comment.yml +++ b/.github/workflows/dashboard-deprecation-comment.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 2e96bec1de..104988d7a5 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d07c8fe633..c1086c858c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,7 +221,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -257,7 +257,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -289,7 +289,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index c6c829fbb4..f69c7530f7 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} From a3b6f92433c1ddf27d5ec936bda5e1e1bce31b53 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 19:58:48 -0400 Subject: [PATCH 1220/2214] [espidf] Regenerate bundled CMakeLists; auto-REQUIRE via IDF build properties (#16406) --- esphome/build_gen/espidf.py | 96 ++++++++++--- esphome/components/esp32/__init__.py | 12 ++ esphome/espidf/component.py | 161 ++++------------------ esphome/espidf/toolchain.py | 17 ++- tests/unit_tests/build_gen/test_espidf.py | 159 +++++++++++++++++++++ tests/unit_tests/test_espidf_component.py | 90 +++++++----- 6 files changed, 344 insertions(+), 191 deletions(-) create mode 100644 tests/unit_tests/build_gen/test_espidf.py diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 82c8537bef..5ad2072c5b 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -10,11 +10,14 @@ from esphome.writer import update_storage_json def get_available_components() -> list[str] | None: - """Get list of available ESP-IDF components from project_description.json. + """Get list of built-in ESP-IDF components from project_description.json. - Returns only internal ESP-IDF components, excluding external/managed - components (from idf_component.yml). + Excludes ``src``, IDF-managed components (``managed_components/``), and + converted PIO libs (``pio_components/``). Returns ``None`` if the build + dir or ``project_description.json`` isn't ready yet. """ + if CORE.build_path is None: + return None project_desc = Path(CORE.build_path) / "build" / "project_description.json" if not project_desc.exists(): return None @@ -31,9 +34,9 @@ def get_available_components() -> list[str] | None: if name == "src": continue - # Exclude managed/external components + # Exclude IDF-managed and converted-PIO components (external). comp_dir = info.get("dir", "") - if "managed_components" in comp_dir: + if "managed_components" in comp_dir or "pio_components" in comp_dir: continue result.append(name) @@ -48,8 +51,12 @@ def has_discovered_components() -> bool: return get_available_components() is not None -def get_project_cmakelists() -> str: - """Generate the top-level CMakeLists.txt for ESP-IDF project.""" +def get_project_cmakelists(minimal: bool = False) -> str: + """Generate the top-level CMakeLists.txt for ESP-IDF project. + + When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS`` + since ``project_description.json`` may be stale on the first write. + """ # Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3) variant = get_esp32_variant() idf_target = variant.lower().replace("-", "") @@ -72,6 +79,37 @@ def get_project_cmakelists() -> str: for flag in project_compile_opts ) + # 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. + # + # Emit via idf_build_set_property (not plain set()) so the value is + # serialised into build_properties.temp.cmake and visible to IDF's + # early requirements-expansion pass (component_get_requirements.cmake + # 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() + ) + + # 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. + 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 []) + ) + ) + return f"""\ # Auto-generated by ESPHome cmake_minimum_required(VERSION 3.16) @@ -99,6 +137,10 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {extra_compile_options} +{managed_components_property} + +{builtin_components_property} + project({CORE.name}) # Emit raw JSON size data for ESPHome to read post-build. @@ -113,11 +155,12 @@ add_custom_command( """ -def get_component_cmakelists(minimal: bool = False) -> str: - """Generate the main component CMakeLists.txt.""" - idf_requires = [] if minimal else (get_available_components() or []) - requires_str = " ".join(idf_requires) +def get_component_cmakelists() -> str: + """Generate the main component CMakeLists.txt. + REQUIRES pulls in the discovered built-in IDF components via the + project-level variables set in the top-level CMakeLists. + """ # Extract linker options (-Wl, flags). Compile flags (-D, -W) are # emitted project-wide via idf_build_set_property in # get_project_cmakelists so they reach every component, not just src/. @@ -126,17 +169,30 @@ def get_component_cmakelists(minimal: bool = False) -> str: return f"""\ # Auto-generated by ESPHome -file(GLOB_RECURSE app_sources - "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" - "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" - "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" - "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" -) +# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test +# runs that reuse the build dir don't compile stale source paths. It's +# invalid in script mode (cmake -P), which is how IDF's +# component_get_requirements.cmake includes us, so skip it there. +if(CMAKE_SCRIPT_MODE_FILE) + file(GLOB_RECURSE app_sources + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" + ) +else() + file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" + ) +endif() idf_component_register( SRCS ${{app_sources}} INCLUDE_DIRS "." "esphome" - REQUIRES {requires_str} + REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} ) # Apply C++ standard @@ -162,11 +218,11 @@ def write_project(minimal: bool = False) -> None: # Write top-level CMakeLists.txt write_file_if_changed( CORE.relative_build_path("CMakeLists.txt"), - get_project_cmakelists(), + get_project_cmakelists(minimal=minimal), ) # Write component CMakeLists.txt in src/ write_file_if_changed( CORE.relative_src_path("CMakeLists.txt"), - get_component_cmakelists(minimal=minimal), + get_component_cmakelists(), ) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 221c84c149..1eb0bb2174 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -588,6 +588,18 @@ def add_idf_component( } +def get_managed_component_require_names() -> list[str]: + """Return sorted IDF require names for components added via + ``add_idf_component`` (``owner/name`` -> ``owner__name``). + + The build_gen layer (``build_gen.espidf.get_project_cmakelists``) + feeds this list into ``ESPHOME_PROJECT_MANAGED_COMPONENTS`` so + converted PIO libraries can REQUIRE them by name at configure time. + """ + components_registry = CORE.data.get(KEY_ESP32, {}).get(KEY_COMPONENTS, {}) + return sorted(name.replace("/", "__") for name in components_registry) + + def exclude_builtin_idf_component(name: str) -> None: """Exclude an ESP-IDF component from the build. diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index af8640949d..b9202fb6bf 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -12,7 +12,6 @@ from typing import TypeVar from urllib.parse import urlparse, urlsplit, urlunsplit from esphome import git, yaml_util -from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, Library from esphome.espidf.framework import archive_extract_all, download_from_mirrors, rmdir from esphome.helpers import write_file_if_changed @@ -50,28 +49,6 @@ SRC_FILE_EXTENSIONS = [ ESP32_PLATFORM = "espressif32" DOMAIN = "pio_components" -# -# Constants for workarounds -# - -REQUIRES_DETECT_PATTERNS = { - "mbedtls": [re.compile(r'^\s*#\s*include\s*[<"]mbedtls[^">]*[">]', re.MULTILINE)], - "esp_netif": [ - re.compile(r'^\s*#\s*include\s*[<"]esp_netif[^">]*[">]', re.MULTILINE) - ], - "esp_driver_gpio": [ - re.compile(r'^\s*#\s*include\s*[<"]driver/gpio\.h[^">]*[">]', re.MULTILINE) - ], - "esp_timer": [ - re.compile(r'^\s*#\s*include\s*[<"]esp_timer\.h[^">]*[">]', re.MULTILINE) - ], - "esp_wifi": [ - re.compile( - r'^\s*#\s*include\s*[<"]WiFi\.h[^">]*[">]', re.MULTILINE - ) # Arduino WiFi - ], -} - ESPHOME_DATA_KEY = "ESPHOME" ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" @@ -86,10 +63,7 @@ class URLSource(Source): self.url = url def download(self, dir_suffix: str, force: bool = False) -> Path: - # Partition by framework: generated idf_component.yml content - # depends on CORE.using_arduino, so caches can't be shared. - framework = "arduino" if CORE.using_arduino else "idf" - base_dir = Path(CORE.data_dir) / DOMAIN / framework + base_dir = Path(CORE.data_dir) / DOMAIN h = hashlib.new("sha256") h.update(self.url.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix @@ -124,12 +98,11 @@ class GitSource(Source): self.ref = ref def download(self, dir_suffix: str, force: bool = False) -> Path: - framework = "arduino" if CORE.using_arduino else "idf" path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=f"{DOMAIN}/{framework}", + domain=DOMAIN, submodules=[], subpath=Path(dir_suffix), ) @@ -282,46 +255,6 @@ def _get_package_from_pio_registry( return owner, name, version["name"], pkgfile["download_url"] -def _patch_component(component: IDFComponent, first_pass: bool): - """ - Apply patches/workarounds to specific components that have known issues. - - This function modifies component data to fix compatibility issues or missing - dependencies for certain libraries. It applies different patches based on - whether it's the first or second pass of processing. - - Args: - component: The IDFComponent object to potentially patch - first_pass: Boolean indicating if this is the first pass of processing - """ - - # Patch only on the second step - if not first_pass and CORE.using_arduino: - # Add the missing dependency to Arduino framework. Source is None so - # the IDF component manager resolves it from the registry instead of - # cloning the 2 GB arduino-esp32 git history. - component.dependencies.append( - IDFComponent( - "espressif/arduino-esp32", - str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), - None, - ) - ) - - # - # fastled/FastLED - # - - # Patch only on the first step - if ( - first_pass - and component.name == _owner_pkgname_to_name("fastled", "FastLED") - and not (component.path / "idf_component.yml").is_file() - ): - # Force fake idf_component: This project already support ESP-IDF - (component.path / "idf_component.yml").write_text("") - - def _apply_extra_script(component: IDFComponent) -> None: """Run a PIO ``extraScript`` and fold its captured env vars into ``component.data["build"]["flags"]`` so the existing -L/-l/-D @@ -506,43 +439,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent: return IDFComponent(name, version, source) -def _detect_requires(build_src_files: list[str]) -> set[str]: - """ - Detect required components from source files. - - Args: - build_src_files: List of source file paths to analyze - - Returns: - Set of detected required components - """ - detected = set() - - # 1. Process each source file - for file in build_src_files: - path = Path(file) - - if not path.is_file(): - continue - - try: - content = path.read_text(encoding="utf-8", errors="ignore") - except Exception: # pylint: disable=broad-exception-caught - continue - - # 2. Add required component if one of these patterns matches - for require_name, patterns in REQUIRES_DETECT_PATTERNS.items(): - if require_name in detected: - continue # already found - - for pattern in patterns: - if pattern.search(content): - detected.add(require_name) - break - - return detected - - def _split_list_by_condition( items: list[str], match_fn: Callable[[str], str | None] ) -> tuple[list[str], list[str]]: @@ -609,13 +505,14 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: component.path / Path(build_src_dir), build_src_filter ) - # Detect in the files which requirements to add - # By default in platformio, all the components are added: we need to detect them when using ESP-IDF - requires = _detect_requires(build_src_files) - - # Dependencies are required - for dependency in component.dependencies: - requires.add(dependency.get_require_name()) + # Only bake library.json-declared deps here. Project-managed and + # built-in components come in via ${ESPHOME_PROJECT_MANAGED_COMPONENTS} + # / ${ESPHOME_PROJECT_BUILTIN_COMPONENTS} set in the top-level + # CMakeLists, so this file stays project-agnostic when shared from + # the pio_components cache. + requires: set[str] = { + dependency.get_require_name() for dependency in component.dependencies + } # Only keep sources build_src_files = [os.path.relpath(p, component.path) for p in build_src_files] @@ -654,9 +551,19 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: if build_include_dirs: str_include_dirs = " ".join([escape_entry(p) for p in build_include_dirs]) content += f" INCLUDE_DIRS {str_include_dirs}\n" - if requires: - str_requires = " ".join(sorted(requires)) - content += f" REQUIRES {str_requires}\n" + # Project-managed and built-in component lists are set per-project + # via idf_build_set_property in the top-level CMakeLists; expanded + # here at configure time. Keeping them out of the per-lib REQUIRES + # means this CMakeLists is project-agnostic and reusable from the + # pio_components cache across builds. + str_requires = " ".join( + [ + *sorted(requires), + "${ESPHOME_PROJECT_MANAGED_COMPONENTS}", + "${ESPHOME_PROJECT_BUILTIN_COMPONENTS}", + ] + ) + content += f" REQUIRES {str_requires}\n" content += ")\n" # Add public and private build flags @@ -732,13 +639,10 @@ def generate_idf_component_yml(component: IDFComponent) -> str: try: dep["override_path"] = str(dependency.path) except RuntimeError as e: - # No local path; let the IDF component manager resolve. - # GitSource gives an explicit URL; arduino-esp32 is resolved by - # version from the registry. Anything else is a bug. - if isinstance(dependency.source, GitSource): - dep["git"] = dependency.source.url - elif dependency.name != "espressif/arduino-esp32": + # No local path: only a GitSource can substitute its URL. + if not isinstance(dependency.source, GitSource): raise e + dep["git"] = dependency.source.url data["dependencies"][dependency.get_sanitized_name()] = dep @@ -903,12 +807,9 @@ def _generate_idf_component(library: Library, force: bool = False) -> IDFCompone cmakelists_txt_path = component.path / "CMakeLists.txt" idf_component_yml_path = component.path / "idf_component.yml" - # Apply patches to the library metadata - _patch_component(component, True) - - if cmakelists_txt_path.is_file() and idf_component_yml_path.is_file(): - # Already an ESP-IDF component - return component + # Bundled CMakeLists.txt / idf_component.yml are ignored -- library + # authors' IDF support is frequently broken (bogus REQUIRES, hard-coded + # arduino-esp32, etc.). We always regenerate. if library_json_path.is_file(): component.data = _parse_library_json(library_json_path) @@ -919,9 +820,6 @@ def _generate_idf_component(library: Library, force: bool = False) -> IDFCompone "Invalid PIO library: missing library.json and/or library.properties" ) - # Apply additional patches to the library metadata - _patch_component(component, False) - # Check if the component is usable with ESP-IDF before executing any # third-party Python from the library (``_apply_extra_script`` below). _check_library_data(component.data) @@ -936,7 +834,6 @@ def _generate_idf_component(library: Library, force: bool = False) -> IDFCompone # Handle the dependencies (convert PlatformIO library to ESP-IDF component if needed) _process_dependencies(component) - # Generate files _LOGGER.debug("Generating CMakeLists.txt for %s@%s ...", name, version) write_file_if_changed( cmakelists_txt_path, diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index ecb759ed10..583f340996 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -302,10 +302,21 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) + # The post-discovery rewrite leaves CMakeLists newer than + # CMakeCache.txt. CMake won't re-touch CMakeCache.txt on a + # configure that only changes idf_build_set_property values + # (those aren't cache variables), so has_outdated_files() would + # return True on every subsequent build, perpetually retriggering + # the two-pass. Touch CMakeCache.txt now so its mtime stays past + # the rewritten CMakeLists. + 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. This ensures idf.py build won't re-run cmake, which would - # regenerate memory.ld and wipe the DRAM/IRAM patches applied below. + # Reconfigure again so cmake is up to date with the full + # component list before the build's idf.py invocation runs -- + # idf.py build would otherwise re-run cmake and regenerate + # memory.ld, wiping the DRAM/IRAM patches applied below. rc = run_reconfigure() if rc != 0: _LOGGER.error("Reconfigure with discovered components failed") diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py new file mode 100644 index 0000000000..36f0442355 --- /dev/null +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -0,0 +1,159 @@ +"""Tests for esphome.build_gen.espidf module.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.components.esp32 import ( + KEY_COMPONENTS, + KEY_ESP32, + KEY_PATH, + KEY_REF, + KEY_REPO, +) +from esphome.const import KEY_CORE +from esphome.core import CORE + + +@pytest.fixture(autouse=True) +def _reset_core(tmp_path: Path) -> None: + """Give each test its own CORE.build_path and a clean esp32 data slot.""" + CORE.build_path = str(tmp_path) + CORE.data.setdefault(KEY_CORE, {}) + CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}} + + +def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None: + """Stub a project_description.json with the given component_name -> dir map.""" + build_dir = tmp_path / "build" + build_dir.mkdir(exist_ok=True) + (build_dir / "project_description.json").write_text( + json.dumps( + { + "build_component_info": { + name: {"dir": dir_} for name, dir_ in components.items() + } + } + ) + ) + + +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 + from esphome.build_gen.espidf import get_available_components + + assert get_available_components() is None + + +def test_get_available_components_returns_none_without_project_description( + tmp_path: Path, +) -> None: + from esphome.build_gen.espidf import get_available_components + + assert get_available_components() is None + + +def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> None: + """Built-ins are returned; src/, managed_components/, pio_components/ skipped.""" + _write_project_description( + tmp_path, + { + "src": f"{tmp_path}/src", + "esp_lcd": "/idf/components/esp_lcd", + "espressif__arduino-esp32": f"{tmp_path}/managed_components/arduino", + "JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC", + "freertos": "/idf/components/freertos", + }, + ) + from esphome.build_gen.espidf import get_available_components + + assert sorted(get_available_components()) == ["esp_lcd", "freertos"] + + +def test_get_project_cmakelists_minimal_omits_builtin_components_property( + tmp_path: Path, +) -> None: + """Minimal write must not emit ESPHOME_PROJECT_BUILTIN_COMPONENTS even + when project_description.json exists (the data may be stale on the + 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) + + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS" not in content + + +def test_get_project_cmakelists_full_emits_builtin_components_property( + tmp_path: Path, +) -> None: + """Non-minimal write emits one idf_build_set_property line per built-in, + sorted, and excludes src/managed/pio components.""" + _write_project_description( + tmp_path, + { + "src": f"{tmp_path}/src", + "esp_lcd": "/idf/components/esp_lcd", + "freertos": "/idf/components/freertos", + "espressif__esp-dsp": f"{tmp_path}/managed_components/esp-dsp", + "JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC", + }, + ) + + 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) + + assert ( + "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd APPEND)" + in content + ) + assert ( + "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS freertos APPEND)" + in content + ) + # Excluded by get_available_components filtering. + assert "espressif__esp-dsp APPEND" not in content + assert "JPEGDEC APPEND" not in content + + +def test_get_project_cmakelists_emits_managed_components_property( + tmp_path: Path, +) -> None: + """ESPHOME_PROJECT_MANAGED_COMPONENTS is always emitted (both modes) + from the esp32 add_idf_component registry.""" + CORE.data[KEY_ESP32][KEY_COMPONENTS] = { + "espressif/esp-dsp": {KEY_REPO: None, KEY_REF: "1.7.1", KEY_PATH: None}, + "espressif/arduino-esp32": {KEY_REPO: None, KEY_REF: "3.3.8", KEY_PATH: None}, + } + + 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 + + for minimal in (True, False): + content = get_project_cmakelists(minimal=minimal) + assert ( + "idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS" + " espressif__arduino-esp32 APPEND)" + ) in content + assert ( + "idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS" + " espressif__esp-dsp APPEND)" + ) in content diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 3988c997a7..8977b05d23 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,5 +1,6 @@ import json import os +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -21,7 +22,6 @@ from esphome.espidf.component import ( _check_library_data, _collect_filtered_files, _convert_library_to_component, - _detect_requires, _parse_library_json, _parse_library_properties, _process_dependencies, @@ -83,19 +83,6 @@ def test_collect_filtered_files_exclude(tmp_path): assert str(f2) not in result -def test_detect_requires(tmp_path): - f = tmp_path / "main.c" - f.write_text('#include "mbedtls/foo.h"') - - result = _detect_requires([str(f)]) - assert "mbedtls" in result - - -def test_detect_requires_ignores_invalid_file(tmp_path): - result = _detect_requires([str(tmp_path / "missing.c")]) - assert result == set() - - def test_split_list_by_condition(): items = ["-Iinclude", "-Llib", "-Wall"] @@ -142,7 +129,7 @@ def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path): == f"""idf_component_register( SRCS "src{sep}main.c" INCLUDE_DIRS "src" - REQUIRES dep + REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} ) target_compile_options(${{COMPONENT_LIB}} PUBLIC "-DTEST" @@ -160,6 +147,58 @@ target_link_libraries(${{COMPONENT_LIB}} INTERFACE ) +def test_generate_cmakelists_txt_references_project_managed_components_variable( + tmp_component: IDFComponent, +) -> None: + # The CMakeLists is cached under pio_components// and shared + # across projects, so the project-managed REQUIRES list is exposed via + # a CMake variable expanded at configure time rather than baked here. + src_dir = tmp_component.path / "src" + src_dir.mkdir() + (src_dir / "main.c").write_text("int main() {}") + tmp_component.data = {} + + content = generate_cmakelists_txt(tmp_component) + assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content + + +def test_generate_idf_component_overwrites_bundled_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A library that ships its own CMakeLists.txt + idf_component.yml must + # have both replaced by ESPHome's generated content. Library authors' + # bundled IDF metadata is frequently broken (bogus REQUIRES, hard-coded + # frameworks), so we always regenerate from library.json. + from esphome.espidf.component import _generate_idf_component + + (tmp_path / "src").mkdir() + (tmp_path / "src" / "main.cpp").write_text("// dummy\n") + (tmp_path / "library.json").write_text(json.dumps({"name": "tripwire-lib"})) + (tmp_path / "CMakeLists.txt").write_text("# TRIPWIRE_BUNDLED_CMAKELISTS\n") + (tmp_path / "idf_component.yml").write_text("# TRIPWIRE_BUNDLED_MANIFEST\n") + + fake_component = IDFComponent( + "owner/tripwire-lib", "1.0.0", source=URLSource("http://dummy") + ) + fake_component.path = tmp_path + monkeypatch.setattr( + esphome.espidf.component, + "_convert_library_to_component", + lambda _lib: fake_component, + ) + monkeypatch.setattr(fake_component, "download", lambda force=False: None) + + _generate_idf_component(Library("owner/tripwire-lib", "1.0.0", None)) + + cml = (tmp_path / "CMakeLists.txt").read_text() + manifest = (tmp_path / "idf_component.yml").read_text() + assert "TRIPWIRE_BUNDLED_CMAKELISTS" not in cml + assert "TRIPWIRE_BUNDLED_MANIFEST" not in manifest + assert "idf_component_register" in cml + + def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) @@ -187,27 +226,6 @@ dependencies: ) -def test_generate_idf_component_yml_arduino_registry_dep(tmp_component): - # Synthetic arduino-esp32 dep with no source / no path: should emit a - # version-only entry so the IDF component manager resolves it from the - # registry instead of via git. - dep = IDFComponent("espressif/arduino-esp32", "3.3.8", source=None) - - tmp_component.dependencies = [dep] - tmp_component.data = {} - - result = generate_idf_component_yml(tmp_component) - - assert ( - result - == """version: 1.0.0 -dependencies: - espressif/arduino-esp32: - version: 3.3.8 -""" - ) - - def test_generate_idf_component_yml_missing_path_reraises(tmp_component): # A dep without a path and without a recognised source should re-raise # the underlying RuntimeError instead of silently producing a bad manifest. From 78b60ac6fa8b173163d74651a719cc1ed3d2e919 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 May 2026 12:33:43 +1200 Subject: [PATCH 1221/2214] Bump version to 2026.6.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 7fce941c9b..3537516996 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.5.0-dev +PROJECT_NUMBER = 2026.6.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 a256a10e62..fa1ea42bc6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0-dev" +__version__ = "2026.6.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 09a926fa13fa82ebe804ce8798a2a3ed4f9870cf Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 May 2026 12:33:43 +1200 Subject: [PATCH 1222/2214] Bump version to 2026.5.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 7fce941c9b..a29a78ea9c 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.5.0-dev +PROJECT_NUMBER = 2026.5.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 a256a10e62..91bc52708c 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0-dev" +__version__ = "2026.5.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From d2107e40c8c0e019de1984009ba9a4c851d88736 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 21:03:45 -0500 Subject: [PATCH 1223/2214] [ci] Prohibit curly braces in PR titles for MDX safety (#16412) --- .github/workflows/pr-title-check.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index ed0bff9664..e15d09da82 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -68,14 +68,15 @@ jobs: return; } - // Check for angle brackets not wrapped in backticks. - // Astro docs MDX treats bare < as JSX component opening tags. + // Check for MDX syntax characters not wrapped in backticks. + // Astro docs MDX treats bare `<` as JSX component opening tags and + // bare `{` as JS expressions, so both must be escaped in changelog entries. const stripped = title.replace(/`[^`]*`/g, ''); - if (/[<>]/.test(stripped)) { + if (/[<>{}]/.test(stripped)) { core.setFailed( - 'PR title contains `<` or `>` not wrapped in backticks.\n' + - 'Astro docs MDX interprets bare `<` as JSX components.\n' + - 'Please wrap angle brackets with backticks, e.g.: [component] Add `` support' + 'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' + + 'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' + + 'Please wrap these characters with backticks, e.g.: [component] Add `` support' ); return; } From e593cb6efc2f9a8243718cedbc3f048bda5356db Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 23:19:30 -0400 Subject: [PATCH 1224/2214] [espidf] Stop perpetual reconfigure loop on native ESP-IDF builds (#16415) --- esphome/components/esp32/__init__.py | 7 ++++++- esphome/espidf/toolchain.py | 30 ++++++++++++---------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1eb0bb2174..0c24dbf7b9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2509,7 +2509,12 @@ def _write_idf_component_yml(): stubs_dir = CORE.relative_build_path("component_stubs") stubs_dir.mkdir(exist_ok=True) - for component_name in components_to_stub: + # Sort so the dict insertion order (and thus the generated + # src/idf_component.yml) is deterministic across runs; otherwise + # the manifest content shuffles every build, write_file_if_changed + # always writes, and ninja keeps triggering CMake re-runs on + # otherwise-cached rebuilds. + for component_name in sorted(components_to_stub): # Create stub directory with minimal CMakeLists.txt stub_path = stubs_dir / _idf_component_stub_name(component_name) stub_path.mkdir(exist_ok=True) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 583f340996..1245c643e1 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -191,13 +191,19 @@ def run_reconfigure() -> int: def has_outdated_files(): """Check if the build configuration is stale. - Returns True if required build files are missing or if configuration inputs - are newer than the generated CMake/Ninja build artifacts. + Returns True if required build files are missing or if external + configuration inputs (IDF install, sdkconfig, CMake's own build/config + dir) are newer than CMakeCache.txt. We deliberately don't watch the + top-level/src ``CMakeLists.txt`` here -- those are written by + ``write_project`` via ``write_file_if_changed`` (so an mtime bump + means our content actually changed) and ninja already tracks them as + configure-time deps via ``build.ninja``. Including them in this check + causes a perpetual reconfigure loop: the two-pass write leaves + CMakeLists newer than CMakeCache.txt, and CMake doesn't restamp the + cache when only ``idf_build_set_property`` values change, so the + check would trip on every subsequent build. """ cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") - - cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt") - cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt") build_config_path = CORE.relative_build_path("build/config") sdkconfig_internal_path = CORE.relative_build_path( f"sdkconfig.{CORE.name}.esphomeinternal" @@ -221,8 +227,6 @@ def has_outdated_files(): os.path.getmtime(f) > cmakecache_txt_mtime for f in [ _get_idf_path(), - cmakelists_txt_build_path, - cmakelists_txt_src_path, sdkconfig_internal_path, build_config_path, ] @@ -302,21 +306,13 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) - # The post-discovery rewrite leaves CMakeLists newer than - # CMakeCache.txt. CMake won't re-touch CMakeCache.txt on a - # configure that only changes idf_build_set_property values - # (those aren't cache variables), so has_outdated_files() would - # return True on every subsequent build, perpetually retriggering - # the two-pass. Touch CMakeCache.txt now so its mtime stays past - # the rewritten CMakeLists. - 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 -- # idf.py build would otherwise re-run cmake and regenerate # memory.ld, wiping the DRAM/IRAM patches applied below. + # Outside testing mode ninja's own configure-time dep on + # CMakeLists.txt handles the re-run as part of the build step. rc = run_reconfigure() if rc != 0: _LOGGER.error("Reconfigure with discovered components failed") From f89a6f4f9c804d118efb7da701962920186dc44b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 00:02:22 -0400 Subject: [PATCH 1225/2214] [espidf] Trim has_outdated_files watch list; embed IDF version in sdkconfig (#16416) --- esphome/components/esp32/__init__.py | 8 ++++- esphome/espidf/toolchain.py | 45 +++++++++++++++++----------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0c24dbf7b9..f112549832 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2464,8 +2464,14 @@ def _write_sdkconfig(): ) want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + # Include the resolved framework version as a Kconfig comment so a + # version switch that happens to leave the option set unchanged still + # bumps this file's content -- which is what has_outdated_files() + # uses to decide whether to reconfigure. + framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] contents = ( - "\n".join( + f"# ESPHOME_IDF_VERSION={framework_version}\n" + + "\n".join( f"{name}={_format_sdkconfig_val(value)}" for name, value in sorted(want_opts.items()) ) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 1245c643e1..e0bc5bb393 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -191,23 +191,38 @@ def run_reconfigure() -> int: def has_outdated_files(): """Check if the build configuration is stale. - Returns True if required build files are missing or if external - configuration inputs (IDF install, sdkconfig, CMake's own build/config - dir) are newer than CMakeCache.txt. We deliberately don't watch the - top-level/src ``CMakeLists.txt`` here -- those are written by - ``write_project`` via ``write_file_if_changed`` (so an mtime bump - means our content actually changed) and ninja already tracks them as - configure-time deps via ``build.ninja``. Including them in this check - causes a perpetual reconfigure loop: the two-pass write leaves - CMakeLists newer than CMakeCache.txt, and CMake doesn't restamp the - cache when only ``idf_build_set_property`` values change, so the - check would trip on every subsequent build. + Returns True if required build files are missing or if ESPHome's + resolved build inputs are newer than CMakeCache.txt: + + - ``sdkconfig..esphomeinternal`` -- the canonical "what state + did ESPHome resolve the YAML to" snapshot. Any change in build + flags, enabled components, framework version, or target ends up + rewriting it (we embed a ``# ESPHOME_IDF_VERSION=`` comment line + for the version case where the option set would otherwise be + identical). + - ``src/idf_component.yml`` -- the project manifest. Managed + component additions/removals (e.g. via ``add_idf_component``) can + 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. + + We deliberately don't watch: + - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and + ninja already tracks them as configure-time deps. Including them + causes a perpetual reconfigure loop because CMake doesn't restamp + ``CMakeCache.txt`` when only ``idf_build_set_property`` values + change between configures. + - ``$IDF_PATH`` and CMake's ``build/config/`` -- both have mtime + semantics that fire after the wrong configure (or not at all in + common cases like in-place IDF version replacement). The sdkconfig + and manifest hashes subsume the meaningful signal. """ cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") build_config_path = CORE.relative_build_path("build/config") sdkconfig_internal_path = CORE.relative_build_path( f"sdkconfig.{CORE.name}.esphomeinternal" ) + idf_component_yml_path = CORE.relative_build_path("src/idf_component.yml") dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") @@ -225,12 +240,8 @@ def has_outdated_files(): cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) return any( os.path.getmtime(f) > cmakecache_txt_mtime - for f in [ - _get_idf_path(), - sdkconfig_internal_path, - build_config_path, - ] - if f and os.path.exists(f) + for f in [sdkconfig_internal_path, idf_component_yml_path] + if f.exists() ) From 348b92910ed508a641467ec246bac01ae4f731da Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 14 May 2026 15:07:38 -0500 Subject: [PATCH 1226/2214] [tinyusb] Reject `tinyusb:` configured without a USB class companion (#16413) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/tinyusb/__init__.py | 20 +++++++++++++++++++ .../components/tinyusb/tinyusb_component.cpp | 15 ++++++++++++++ tests/components/tinyusb/common.yaml | 5 +++++ 3 files changed, 40 insertions(+) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index df94ad7534..724f65721b 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,3 +1,4 @@ +from esphome import final_validate as fv import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -20,6 +21,13 @@ CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +# Components that provide a USB device class (CDC, HID, MSC, ...) on top of +# tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in +# esp_tinyusb's driver install (descriptors_set fails with no class and no +# user-provided full_speed_config), which trips the task watchdog before +# loop() ever runs. +_USB_CLASS_COMPONENTS = ("usb_cdc_acm",) + tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) @@ -41,6 +49,18 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config): + full_config = fv.full_config.get() + if not any(name in full_config for name in _USB_CLASS_COMPONENTS): + raise cv.Invalid( + "The 'tinyusb' component requires at least one USB class component" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 2ec696c3e4..3cefc0454a 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -26,6 +26,21 @@ void TinyUSB::setup() { .string_count = SIZE, }; + // Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with + // ESP_ERR_INVALID_ARG when no configuration descriptor is provided and + // no class that has a built-in default (CDC/MSC/NCM) is compiled in. In + // that case the internal task exits without notifying us, and + // tinyusb_driver_install() blocks 5s on the notify-take -- long enough + // to trip the task watchdog. Bail early so the rest of the device can + // still boot. +#if !(CFG_TUD_CDC > 0 || CFG_TUD_MSC > 0 || CFG_TUD_NCM > 0) + if (this->tusb_cfg_.descriptor.full_speed_config == nullptr) { + ESP_LOGE(TAG, "No USB class configured"); + this->mark_failed(); + return; + } +#endif + esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); diff --git a/tests/components/tinyusb/common.yaml b/tests/components/tinyusb/common.yaml index cb3f48836a..674e89dbe8 100644 --- a/tests/components/tinyusb/common.yaml +++ b/tests/components/tinyusb/common.yaml @@ -6,3 +6,8 @@ tinyusb: usb_product_str: ESPHomeTestProduct usb_serial_str: ESPHomeTestSerialNumber usb_vendor_id: 0x2345 + +# tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that. +usb_cdc_acm: + interfaces: + - id: tinyusb_test_cdc From 7436d1c1999cffa4e2121fd223a992808469dab9 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 14 May 2026 15:29:56 -0500 Subject: [PATCH 1227/2214] [tinyusb] Reject `logger.hardware_uart: USB_CDC` (#16417) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/tinyusb/__init__.py | 13 ++++++++++++- tests/components/tinyusb/test.esp32-s2-idf.yaml | 5 +++++ tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml | 5 +++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 724f65721b..0e02ff8724 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -9,7 +9,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, ) import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_HARDWARE_UART, CONF_ID CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] @@ -55,6 +55,17 @@ def _final_validate(config): raise cv.Invalid( "The 'tinyusb' component requires at least one USB class component" ) + # tinyusb owns the USB OTG peripheral. The logger's USB_CDC backend routes + # the ROM console through that same peripheral, so the two cannot coexist. + # (USB_SERIAL_JTAG is a separate peripheral and is fine alongside tinyusb.) + logger_config = full_config.get("logger") + if logger_config and logger_config.get(CONF_HARDWARE_UART) == "USB_CDC": + raise cv.Invalid( + "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " + "because both share the USB OTG peripheral. Set " + "'logger.hardware_uart' to a hardware UART (e.g. UART0), or to " + "USB_SERIAL_JTAG on variants that support it (ESP32-S3, ESP32-P4)" + ) return config diff --git a/tests/components/tinyusb/test.esp32-s2-idf.yaml b/tests/components/tinyusb/test.esp32-s2-idf.yaml index dade44d145..09b98ada40 100644 --- a/tests/components/tinyusb/test.esp32-s2-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s2-idf.yaml @@ -1 +1,6 @@ <<: !include common.yaml + +# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared +# USB OTG peripheral; route the logger to UART0 so the fixture builds. +logger: + hardware_uart: UART0 diff --git a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml index f159b38ff6..ff75731509 100644 --- a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml +++ b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml @@ -1,5 +1,10 @@ <<: !include tinyusb_common.yaml +# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared +# USB OTG peripheral; route the logger to UART0 so the fixture builds. +logger: + hardware_uart: UART0 + usb_cdc_acm: interfaces: - id: usb_cdc_acm1 From a8e69a15e40b9fe4809ee4c3555053baf23503d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:38:09 -0400 Subject: [PATCH 1228/2214] [clang-tidy] Enable readability-container-contains (#16438) --- .clang-tidy | 1 - .clang-tidy.hash | 2 +- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- esphome/components/spi/spi.cpp | 4 ++-- esphome/components/touchscreen/touchscreen.cpp | 2 +- esphome/components/uponor_smatrix/uponor_smatrix.cpp | 2 +- esphome/components/web_server/web_server.cpp | 4 ++-- 7 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index ea7370a3b2..6dab84fbd9 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -116,7 +116,6 @@ Checks: >- -portability-template-virtual-member-function, -readability-ambiguous-smartptr-reset-call, -readability-avoid-nested-conditional-operator, - -readability-container-contains, -readability-container-data-pointer, -readability-convert-member-functions-to-static, -readability-else-after-return, diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 77b4f5323f..52d75d1601 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c +27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 678ed9dbbf..12bf6d9f37 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -572,7 +572,7 @@ void LvButtonMatrixType::set_obj(lv_obj_t *lv_obj) { auto key_idx = lv_buttonmatrix_get_selected_button(self->obj); if (key_idx == LV_BUTTONMATRIX_BUTTON_NONE) return; - if (self->key_map_.count(key_idx) != 0) { + if (self->key_map_.contains(key_idx)) { self->send_key_(self->key_map_[key_idx]); return; } diff --git a/esphome/components/spi/spi.cpp b/esphome/components/spi/spi.cpp index 20359135ba..dfdc9fa624 100644 --- a/esphome/components/spi/spi.cpp +++ b/esphome/components/spi/spi.cpp @@ -16,7 +16,7 @@ GPIOPin *const NullPin::NULL_PIN = new NullPin(); // NOLINT(cppcoreguidelines-a SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIBitOrder bit_order, uint32_t data_rate, GPIOPin *cs_pin, bool release_device, bool write_only) { - if (this->devices_.count(device) != 0) { + if (this->devices_.contains(device)) { ESP_LOGE(TAG, "Device already registered"); return this->devices_[device]; } @@ -27,7 +27,7 @@ SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIB } void SPIComponent::unregister_device(SPIClient *device) { - if (this->devices_.count(device) == 0) { + if (!this->devices_.contains(device)) { esph_log_e(TAG, "Device not registered"); return; } diff --git a/esphome/components/touchscreen/touchscreen.cpp b/esphome/components/touchscreen/touchscreen.cpp index 5687213eb5..f4ef66ef3e 100644 --- a/esphome/components/touchscreen/touchscreen.cpp +++ b/esphome/components/touchscreen/touchscreen.cpp @@ -78,7 +78,7 @@ void Touchscreen::add_raw_touch_position_(uint8_t id, int16_t x_raw, int16_t y_r if (this->swap_x_y_) { std::swap(x_raw, y_raw); } - if (this->touches_.count(id) == 0) { + if (!this->touches_.contains(id)) { tp.state = STATE_PRESSED; tp.id = id; } else { diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index 3f1feaa927..0ba19f5cd7 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -154,7 +154,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) { } // Log unknown device addresses - if (!found && !this->unknown_devices_.count(device_address)) { + if (!found && !this->unknown_devices_.contains(device_address)) { ESP_LOGI(TAG, "Received packet for unknown device address 0x%08" PRIX32 " ", device_address); this->unknown_devices_.insert(device_address); } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 198267204d..150f70aa6b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2638,9 +2638,9 @@ bool WebServer::isRequestHandlerTrivial() const { return false; } void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { #ifdef USE_WEBSERVER_SORTING - if (this->sorting_entitys_.find(entity) != this->sorting_entitys_.end()) { + if (this->sorting_entitys_.contains(entity)) { root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; - if (this->sorting_groups_.find(this->sorting_entitys_[entity].group_id) != this->sorting_groups_.end()) { + if (this->sorting_groups_.contains(this->sorting_entitys_[entity].group_id)) { root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; } } From f291dc8d2feeb1a22882e3bba103793d9916e111 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:39:16 -0400 Subject: [PATCH 1229/2214] [esp32] Sweep ESP-IDF toolchain warnings + bump deprecated mark_failed (#16432) --- esphome/components/esp32/__init__.py | 4 +++- esphome/components/hdc2080/hdc2080.cpp | 2 +- esphome/components/heatpumpir/climate.py | 1 - esphome/components/pulse_meter/pulse_meter_sensor.cpp | 4 ++-- esphome/components/sim800l/sim800l.cpp | 2 +- esphome/components/tuya/tuya.cpp | 2 ++ esphome/components/tx20/tx20.cpp | 6 +++--- esphome/components/usb_uart/usb_uart.h | 2 +- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- esphome/components/wiegand/wiegand.cpp | 4 ++-- 11 files changed, 17 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f112549832..e9b0f1fd0a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1767,9 +1767,11 @@ async def to_code(config): else: cg.add_build_flag("-Wno-error=format") cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=missing-field-initializers") + cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") + # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates + cg.add_build_flag("-Wno-missing-field-initializers") cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index dcb207e099..bf3a4bc79f 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -22,7 +22,7 @@ static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only void HDC2080Component::setup() { const uint8_t data = 0x00; // automatic measurement mode disabled, heater off if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index b7e0437480..aa3a08c294 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -125,7 +125,6 @@ async def to_code(config): cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_library("tonia/HeatpumpIR", "1.0.41") if CORE.is_libretiny or CORE.is_esp32: diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.cpp b/esphome/components/pulse_meter/pulse_meter_sensor.cpp index 3fe1c722eb..d6959d1a96 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.cpp +++ b/esphome/components/pulse_meter/pulse_meter_sensor.cpp @@ -150,7 +150,7 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) { edge_state.last_sent_edge_us_ = now; state.last_detected_edge_us_ = now; state.last_rising_edge_us_ = now; - state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile) + state.count_ += 1; } // This ISR is bound to rising edges, so the pin is high @@ -173,7 +173,7 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) { } else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge pulse_state.latched_ = true; state.last_detected_edge_us_ = pulse_state.last_intr_; - state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile) + state.count_ += 1; } // Due to order of operations this includes diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index b8e97b1121..13b9888e05 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -126,7 +126,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { break; } - // Else fall thru ... + [[fallthrough]]; } case STATE_CHECK_SMS: send_cmd_("AT+CMGL=\"ALL\""); diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index b29905f9a0..fd14844908 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -684,8 +684,10 @@ void Tuya::set_numeric_datapoint_value_(uint8_t datapoint_id, TuyaDatapointType case 4: data.push_back(value >> 24); data.push_back(value >> 16); + [[fallthrough]]; case 2: data.push_back(value >> 8); + [[fallthrough]]; case 1: data.push_back(value >> 0); break; diff --git a/esphome/components/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index 353cb31513..3574bd7c2d 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -135,7 +135,7 @@ void Tx20Component::decode_and_publish_() { } if (tx20_se == tx20_sb) { tx20_wind_direction = tx20_se; - if (tx20_wind_direction >= 0 && tx20_wind_direction < 16) { + if (tx20_wind_direction < 16) { wind_cardinal_direction_ = DIRECTIONS[tx20_wind_direction]; } ESP_LOGV(TAG, "WindDirection %d", tx20_wind_direction); @@ -164,7 +164,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { } arg->buffer[arg->buffer_index] = 1; arg->start_time = now; - arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->buffer_index += 1; return; } const uint32_t delay = now - arg->start_time; @@ -195,7 +195,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { } arg->spent_time += delay; arg->start_time = now; - arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->buffer_index += 1; } void IRAM_ATTR Tx20ComponentStore::reset() { tx20_available = false; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index e88c41c0cb..fb8425f6cd 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -135,7 +135,7 @@ class USBUartChannel : public uart::UARTComponent, public Parentedarg(ESPHOME_F("detail")) == "all" ? DETAIL_ALL : DETAIL_STATE; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index e1d3e4bf34..c32acaf03e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -66,7 +66,7 @@ namespace { * - HTTPD_SOCK_ERR_TIMEOUT if the send buffer is full (EAGAIN/EWOULDBLOCK). * - HTTPD_SOCK_ERR_FAIL for other errors. */ -int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) { +[[maybe_unused]] int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) { if (buf == nullptr) { return HTTPD_SOCK_ERR_INVALID; } diff --git a/esphome/components/wiegand/wiegand.cpp b/esphome/components/wiegand/wiegand.cpp index e5c29f8b11..df64cd48aa 100644 --- a/esphome/components/wiegand/wiegand.cpp +++ b/esphome/components/wiegand/wiegand.cpp @@ -11,7 +11,7 @@ static const char *const KEYS = "0123456789*#"; void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { if (arg->d0.digital_read()) return; - arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->count += 1; arg->value <<= 1; arg->last_bit_time = millis(); arg->done = false; @@ -20,7 +20,7 @@ void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { void IRAM_ATTR HOT WiegandStore::d1_gpio_intr(WiegandStore *arg) { if (arg->d1.digital_read()) return; - arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->count += 1; arg->value = (arg->value << 1) | 1; arg->last_bit_time = millis(); arg->done = false; From f3d77434604b29122d71cad46877d9b991cf1614 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:40:40 -0400 Subject: [PATCH 1230/2214] [tests] Fix -Wformat= mismatches in test YAML lambdas/logger.log (#16435) --- esphome/components/logger/__init__.py | 14 +++++----- esphome/components/lvgl/helpers.py | 28 +++++++++---------- .../components/esp32_ble_tracker/common.yaml | 4 +-- tests/components/ld2412/common.yaml | 2 +- tests/components/lvgl/lvgl-package.yaml | 6 ++-- tests/components/modbus_server/common.yaml | 2 +- tests/components/mqtt/common.yaml | 2 +- tests/components/nextion/common.yaml | 2 +- .../remote_receiver/common-actions.yaml | 2 +- tests/components/script/common.yaml | 2 +- tests/components/udp/common.yaml | 2 +- tests/components/udp/test.host.yaml | 2 +- 12 files changed, 34 insertions(+), 34 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 9d7dc8d92c..c6c440564a 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -506,13 +506,13 @@ async def _late_logger_init(config: ConfigType) -> None: def validate_printf(value): # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python cfmt = r""" - ( # start of capture group 1 - % # literal "%" - (?:[-+0 #]{0,5}) # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + (?:[-+0 #]{0,5}) # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """ # noqa matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index baa618d472..6f70a1e3bd 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -9,13 +9,13 @@ CONF_IF_NAN = "if_nan" # noqa f_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - f # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + f # type ) """, flags=re.VERBOSE, @@ -23,13 +23,13 @@ f_regex = re.compile( # noqa c_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """, flags=re.VERBOSE, diff --git a/tests/components/esp32_ble_tracker/common.yaml b/tests/components/esp32_ble_tracker/common.yaml index 018bbb42b3..564cf1f6ea 100644 --- a/tests/components/esp32_ble_tracker/common.yaml +++ b/tests/components/esp32_ble_tracker/common.yaml @@ -29,12 +29,12 @@ esp32_ble_tracker: - service_uuid: ABCD then: - lambda: !lambda |- - ESP_LOGD("main", "Length of service data is %i", x.size()); + ESP_LOGD("main", "Length of service data is %zu", x.size()); on_ble_manufacturer_data_advertise: - manufacturer_id: ABCD then: - lambda: !lambda |- - ESP_LOGD("main", "Length of manufacturer data is %i", x.size()); + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); on_scan_end: - then: - lambda: |- diff --git a/tests/components/ld2412/common.yaml b/tests/components/ld2412/common.yaml index c5bda688dc..7a86b6fbda 100644 --- a/tests/components/ld2412/common.yaml +++ b/tests/components/ld2412/common.yaml @@ -123,7 +123,7 @@ select: - lambda: |- id(uart_bus).flush(); uint32_t new_baud_rate = stoi(x); - ESP_LOGD("change_baud_rate", "Changing baud rate from %i to %i",id(uart_bus).get_baud_rate(), new_baud_rate); + ESP_LOGD("change_baud_rate", "Changing baud rate from %" PRIu32 " to %" PRIu32, id(uart_bus).get_baud_rate(), new_baud_rate); if (id(uart_bus).get_baud_rate() != new_baud_rate) { id(uart_bus).set_baud_rate(new_baud_rate); #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 53984bb006..0f4b961297 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -660,13 +660,13 @@ lvgl: on_release: logger.log: format: Button released at %d/%d - args: [point.x, point.y] + args: ['(int) point.x', '(int) point.y'] on_long_press_repeat: logger.log: Button clicked on_pressing: logger.log: format: Button pressing at %d/%d - args: [point.x, point.y] + args: ['(int) point.x', '(int) point.y'] on_press_lost: logger.log: Button press lost on_single_click: @@ -944,7 +944,7 @@ lvgl: on_release: logger.log: format: Slider released at %d/%d with value %.0f - args: [point.x, point.y, x] + args: ['(int) point.x', '(int) point.y', x] - button: styles: spin_button id: spin_up diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 3522c9248c..2e4a81a1aa 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -21,7 +21,7 @@ modbus_server: read_lambda: |- return 31; write_lambda: |- - printf("address=%d, value=%d", x); + printf("address=%d, value=%" PRId32 "\n", (int) address, x); return true; - id: modbus_server4 modbus_id: mod_bus2 diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 8c58e9b080..6af2ce3939 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -64,7 +64,7 @@ mqtt: topic: some/topic payload: Good-bye - lambda: |- - ESP_LOGD("MQTT", "Disconnect reason %d", reason); + ESP_LOGD("MQTT", "Disconnect reason %d", (int) reason); publish_nan_as_none: false binary_sensor: diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 0616b9a41a..fba6a22b97 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -299,7 +299,7 @@ display: - lambda: |- // key: StringRef, value: int32_t if (key == "temperature_raw") { - ESP_LOGD("nextion.custom", "%s=%d", key.c_str(), value); + ESP_LOGD("nextion.custom", "%s=%" PRId32, key.c_str(), value); } on_custom_binary_sensor: then: diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 30b99eeb70..26a02d4dab 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -12,7 +12,7 @@ on_brennenstuhl: then: - logger.log: format: "on_brennenstuhl: %u" - args: ["x.code"] + args: ["(unsigned) x.code"] on_aeha: then: - logger.log: diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index c1dc68513f..f4818e2296 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -49,7 +49,7 @@ script: then: - lambda: |- ESP_LOGD("main", "ints=%d floats=%f bools=%d strings=%s", - ints[0], floats[0], bools[0], strings[0].c_str()); + ints[0], floats[0], (int) bools[0], strings[0].c_str()); - id: my_script_with_params parameters: prefix: string diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index 3466e8d2ee..a40ca455cb 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -11,7 +11,7 @@ udp: - "10.0.0.255" on_receive: - logger.log: - format: "Received %d bytes" + format: "Received %zu bytes" args: [data.size()] - udp.write: id: my_udp diff --git a/tests/components/udp/test.host.yaml b/tests/components/udp/test.host.yaml index 84e78894e5..825d86c19e 100644 --- a/tests/components/udp/test.host.yaml +++ b/tests/components/udp/test.host.yaml @@ -4,7 +4,7 @@ udp: addresses: ["239.0.60.53"] on_receive: - logger.log: - format: "Received %d bytes" + format: "Received %zu bytes" args: [data.size()] - udp.write: id: my_udp From 5d9d6e83f789b2453f748d6fa3feca206a25167f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 15:41:32 -0700 Subject: [PATCH 1231/2214] Bump ruff from 0.15.12 to 0.15.13 (#16437) 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 218bc0083c..9050132e70 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.12 # also change in .pre-commit-config.yaml when updating +ruff==0.15.13 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 1bb191aa77b181da81880a88f65643adbdd187d1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:51:36 -0400 Subject: [PATCH 1232/2214] [ci] Skip dashboard-deprecation bot on release/beta-bump PRs (#16427) --- .github/workflows/dashboard-deprecation-comment.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml index 04a2a2151b..ffd5ec7bd9 100644 --- a/.github/workflows/dashboard-deprecation-comment.yml +++ b/.github/workflows/dashboard-deprecation-comment.yml @@ -12,6 +12,12 @@ jobs: dashboard-deprecation-comment: name: Dashboard deprecation comment runs-on: ubuntu-latest + # Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably + # roll up everything merged into dev since the last cut, which can + # include dashboard changes that have already been reviewed once. + # The bot's purpose is to warn new contributors before they invest + # time -- that only applies to PRs entering dev. + if: github.event.pull_request.base.ref == 'dev' steps: - name: Generate a token id: generate-token From 1d86d856d1153fef03f5b3185ae795a943511faa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:51:59 -0400 Subject: [PATCH 1233/2214] [docker] Install libusb-1.0 so ESP-IDF tools can validate openocd (#16424) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docker/Dockerfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 540d28be7f..25de9472b6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,12 +13,16 @@ RUN git config --system --add safe.directory "*" \ && git config --system advice.detachedHead false # Install build tools for Python packages that require compilation -# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager) +# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager). +# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can +# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without +# it idf_tools.py rejects the openocd install with exit 127 and aborts +# the whole framework setup. RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base; \ + apk add --no-cache build-base libusb; \ else \ apt-get update \ - && apt-get install -y --no-install-recommends build-essential \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ && rm -rf /var/lib/apt/lists/*; \ fi From 313d97498391084a085e9f99451928ce310f5f27 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:53:42 -0400 Subject: [PATCH 1234/2214] [multiple] Fix -Wformat= mismatches in component .cpp sources (#16433) --- esphome/components/bme680_bsec/bme680_bsec.cpp | 4 ++-- .../esp32_hosted/update/esp32_hosted_update.cpp | 6 +++--- esphome/components/esphome/ota/ota_esphome.cpp | 14 +++++++------- esphome/components/fastled_base/fastled_light.cpp | 2 +- esphome/components/inkplate/inkplate.cpp | 2 +- esphome/components/midea/air_conditioner.cpp | 4 ++-- esphome/components/ota/ota_partitions_esp_idf.cpp | 4 ++-- .../components/remote_receiver/remote_receiver.cpp | 8 ++++---- esphome/components/sendspin/sendspin_hub.cpp | 4 ++-- .../total_daily_energy/total_daily_energy.cpp | 2 +- 10 files changed, 25 insertions(+), 25 deletions(-) diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index b7f8c0da77..823f32c446 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() { " IAQ Mode: %s\n" " Supply Voltage: %sV\n" " Sample Rate: %s\n" - " State Save Interval: %ims", + " State Save Interval: %" PRIu32 "ms", this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile", this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8", BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_); @@ -461,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe } void BME680BSECComponent::delay_ms(uint32_t period) { - ESP_LOGV(TAG, "Delaying for %ums", period); + ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period); delay(period); } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index af35d32888..7f3ba77895 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -92,7 +92,7 @@ void Esp32HostedUpdate::setup() { if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { // 16 bytes: "255.255.255" (11 chars) + null + safety margin char buf[16]; - snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1); + snprintf(buf, sizeof(buf), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1); this->update_info_.current_version = buf; } else { this->update_info_.current_version = "unknown"; @@ -120,8 +120,8 @@ void Esp32HostedUpdate::setup() { this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { - ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word, - ESP_APP_DESC_MAGIC_WORD); + ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", + app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f1857ed664..fb0cc2e56d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -108,8 +108,8 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" - " Partition address: 0x%X\n" - " Used size: %zu bytes (0x%X)", + " Partition address: 0x%" PRIX32 "\n" + " Used size: %zu bytes (0x%zX)", this->running_app_offset_, this->running_app_size_, this->running_app_size_); #ifdef USE_ESP32 @@ -378,7 +378,7 @@ void ESPHomeOTAComponent::handle_data_() { } ota_size = (static_cast(buf[0]) << 24) | (static_cast(buf[1]) << 16) | (static_cast(buf[2]) << 8) | buf[3]; - ESP_LOGV(TAG, "Size is %u bytes", ota_size); + ESP_LOGV(TAG, "Size is %zu bytes", ota_size); #ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { @@ -749,7 +749,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() { this->auth_buf_[0] = this->auth_type_; hasher.get_hex(buf); - ESP_LOGV(TAG, "Auth: Nonce is %.*s", hex_size, buf); + ESP_LOGV(TAG, "Auth: Nonce is %.*s", (int) hex_size, buf); } // Try to write auth_type + nonce @@ -809,13 +809,13 @@ bool ESPHomeOTAComponent::handle_auth_read_() { hasher.add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer) hasher.calculate(); - ESP_LOGV(TAG, "Auth: CNonce is %.*s", hex_size, cnonce); + ESP_LOGV(TAG, "Auth: CNonce is %.*s", (int) hex_size, cnonce); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char computed_hash[SHA256_HEX_SIZE + 1]; // Buffer for hex-encoded hash (max expected length + null terminator) hasher.get_hex(computed_hash); - ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash); + ESP_LOGV(TAG, "Auth: Result is %.*s", (int) hex_size, computed_hash); #endif - ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response); + ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response); // Compare response bool matches = hasher.equals_hex(response); diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 8d1dd49dad..0fa69a23b4 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() { ESP_LOGCONFIG(TAG, "FastLED light:\n" " Num LEDs: %u\n" - " Max refresh rate: %u", + " Max refresh rate: %" PRIu32, this->num_leds_, this->max_refresh_rate_.value_or(0)); } void FastLEDLightOutput::write_state(light::LightState *state) { diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 39110ca83b..2e837fb614 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -319,7 +319,7 @@ void Inkplate::fill(Color color) { memset(this->partial_buffer_, fill, this->get_buffer_length_()); } - ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::display() { diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 594f7fa661..7603dd5254 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -130,8 +130,8 @@ ClimateTraits AirConditioner::traits() { void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" - " [x] Period: %dms\n" - " [x] Response timeout: %dms\n" + " [x] Period: %" PRIu32 "ms\n" + " [x] Response timeout: %" PRIu32 "ms\n" " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f91e88bde0..a7fc709313 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -210,7 +210,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, + ESP_LOGD(TAG, "Copying running app from 0x%" PRIX32 " to 0x%" PRIX32 " (size: 0x%zX)", running_app_part->address, plan.copy_dest_part->address, running_app_size); err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { @@ -261,7 +261,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); + ESP_LOGD(TAG, "Setting next boot partition to 0x%" PRIX32, new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index d59ee63695..222dae8f7f 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -78,10 +78,10 @@ void RemoteReceiverComponent::setup() { void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG(TAG, "Remote Receiver:\n" - " Buffer Size: %u\n" - " Tolerance: %u%s\n" - " Filter out pulses shorter than: %u us\n" - " Signal is done after %u us of no changes", + " Buffer Size: %" PRIu32 "\n" + " Tolerance: %" PRIu32 "%s\n" + " Filter out pulses shorter than: %" PRIu32 " us\n" + " Signal is done after %" PRIu32 " us of no changes", this->buffer_size_, this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", this->filter_us_, this->idle_us_); diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 04426b8b1d..57709306cd 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -153,7 +153,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) { LastPlayedServerPref pref{.server_id_hash = hash}; bool ok = this->last_played_server_pref_.save(&pref); if (ok) { - ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash); + ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash); } else { ESP_LOGW(TAG, "Failed to persist last played server hash"); } @@ -164,7 +164,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) { std::optional SendspinHub::load_last_server_hash() { LastPlayedServerPref pref{}; if (this->last_played_server_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash); + ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash); return pref.server_id_hash; } return std::nullopt; diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index 161c712cc1..7c9dbb604f 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -72,7 +72,7 @@ void TotalDailyEnergy::schedule_midnight_reset_() { timeout_seconds = seconds_until_midnight + 1; } - ESP_LOGD(TAG, "Scheduling midnight check in %us", timeout_seconds); + ESP_LOGD(TAG, "Scheduling midnight check in %" PRIu32 "s", timeout_seconds); this->set_timeout(TIMEOUT_ID_MIDNIGHT, timeout_seconds * MILLIS_PER_SECOND, [this]() { this->schedule_midnight_reset_(); }); } From a92b607754c3babd607f3e689bb017955485072f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:54:13 -0400 Subject: [PATCH 1235/2214] [ci] Add ci-run-all label to force full CI matrix (#16421) --- .github/workflows/ci.yml | 39 ++++++-- script/determine-jobs.py | 79 ++++++++++++---- tests/script/test_determine_jobs.py | 139 ++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 819dac926e..abd2d1b3a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -249,6 +249,7 @@ jobs: integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} + clang-tidy-full-scan: ${{ steps.determine.outputs.clang-tidy-full-scan }} python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} device-builder: ${{ steps.determine.outputs.device-builder }} @@ -287,7 +288,12 @@ jobs: GH_TOKEN: ${{ github.token }} run: | . venv/bin/activate - output=$(python script/determine-jobs.py) + EXTRA_ARGS="" + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-run-all') }}" == "true" ]]; then + EXTRA_ARGS="--force-all" + echo "::notice::ci-run-all label detected -- forcing every CI job to run" + fi + output=$(python script/determine-jobs.py $EXTRA_ARGS) echo "Test determination output:" echo "$output" | jq @@ -296,6 +302,7 @@ jobs: echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT + echo "clang-tidy-full-scan=$(echo "$output" | jq -r '.clang_tidy_full_scan')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT @@ -500,7 +507,13 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=hash_changed" >> $GITHUB_OUTPUT else @@ -512,7 +525,7 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} else echo "Running clang-tidy on changed files only" @@ -572,7 +585,13 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=hash_changed" >> $GITHUB_OUTPUT else @@ -584,7 +603,7 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy else echo "Running clang-tidy on changed files only" @@ -661,7 +680,13 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=hash_changed" >> $GITHUB_OUTPUT else @@ -673,7 +698,7 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" script/clang-tidy --all-headers --fix ${{ matrix.options }} else echo "Running clang-tidy on changed files only" diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 0a55b2a848..3259fb5836 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -1062,22 +1062,42 @@ def main() -> None: parser.add_argument( "-b", "--branch", help="Branch to compare changed files against" ) + parser.add_argument( + "--force-all", + action="store_true", + help=( + "Force every job to run regardless of what changed. Used by CI " + "when the ci-run-all label is applied to a PR (escape hatch for " + "changes that need full-matrix validation but don't touch enough " + "files to trigger it organically)." + ), + ) args = parser.parse_args() # Determine what should run - integration_run_all, integration_test_files = determine_integration_tests( - args.branch - ) + if args.force_all: + integration_run_all, integration_test_files = True, [] + run_clang_tidy = True + run_clang_format = True + run_python_linters = True + run_import_time = True + run_device_builder = True + native_idf_components = sorted(NATIVE_IDF_TEST_COMPONENTS) + run_native_idf = True + else: + integration_run_all, integration_test_files = determine_integration_tests( + args.branch + ) + run_clang_tidy = should_run_clang_tidy(args.branch) + run_clang_format = should_run_clang_format(args.branch) + run_python_linters = should_run_python_linters(args.branch) + run_import_time = should_run_import_time(args.branch) + run_device_builder = should_run_device_builder(args.branch) + native_idf_components = native_idf_components_to_test(args.branch) + run_native_idf = bool(native_idf_components) run_integration, integration_test_buckets = _compute_integration_test_buckets( integration_run_all, integration_test_files ) - run_clang_tidy = should_run_clang_tidy(args.branch) - run_clang_format = should_run_clang_format(args.branch) - run_python_linters = should_run_python_linters(args.branch) - run_import_time = should_run_import_time(args.branch) - run_device_builder = should_run_device_builder(args.branch) - native_idf_components = native_idf_components_to_test(args.branch) - run_native_idf = bool(native_idf_components) changed_cpp_file_count = count_changed_cpp_files(args.branch) # Get changed components @@ -1106,11 +1126,27 @@ def main() -> None: changed_components = changed_components_result is_core_change = False - # Filter to only components that have test files - # Components without tests shouldn't generate CI test jobs - changed_components_with_tests = [ - component for component in changed_components if _component_has_tests(component) - ] + if args.force_all: + # Force every component with tests into the CI matrix. Each disk entry + # under tests/components/ is treated as a component; filtered + # below by _component_has_tests so components without YAML tests are + # still excluded. + tests_root = Path(root_path) / ESPHOME_TESTS_COMPONENTS_PATH + all_components = sorted(d.name for d in tests_root.iterdir() if d.is_dir()) + changed_components_with_tests = [ + component for component in all_components if _component_has_tests(component) + ] + # Treat as a core change so downstream logic (clang-tidy full scan, + # dep expansion) sees the same world as when esphome/core/ changes. + is_core_change = True + else: + # Filter to only components that have test files + # Components without tests shouldn't generate CI test jobs + changed_components_with_tests = [ + component + for component in changed_components + if _component_has_tests(component) + ] # Get directly changed components with tests (for isolated testing) # These will be tested WITHOUT --testing-mode in CI to enable full validation @@ -1143,8 +1179,10 @@ def main() -> None: memory_impact = detect_memory_impact_config(args.branch) # Determine clang-tidy mode based on actual files that will be checked + is_full_scan = False if run_clang_tidy: # Full scan needed if: hash changed OR core files changed + # (is_core_change is forced True under --force-all) is_full_scan = _is_clang_tidy_full_scan() or is_core_change if is_full_scan: @@ -1177,10 +1215,12 @@ def main() -> None: # Build output # Determine which C++ unit tests to run - cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch) - - # Determine if benchmarks should run - run_benchmarks = should_run_benchmarks(args.branch) + if args.force_all: + cpp_run_all, cpp_components = True, [] + run_benchmarks = True + else: + cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch) + run_benchmarks = should_run_benchmarks(args.branch) # Split components into batches for CI testing # This intelligently groups components with similar bus configurations @@ -1219,6 +1259,7 @@ def main() -> None: "integration_test_buckets": integration_test_buckets, "clang_tidy": run_clang_tidy, "clang_tidy_mode": clang_tidy_mode, + "clang_tidy_full_scan": is_full_scan, "clang_format": run_clang_format, "python_linters": run_python_linters, "import_time": run_import_time, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 9139c6e095..3fd5eada94 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2602,3 +2602,142 @@ def test_main_validate_only_excludes_transitive_components( # Only foo (directly changed, validate-only). bar is a transitive dep # and still needs compile despite no source change of its own. assert output["validate_only_components"] == ["foo"] + + +def test_main_force_all_overrides_detection( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, + mock_determine_cpp_unit_tests: Mock, + mock_changed_files: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """--force-all bypasses per-feature detection and runs every job. + + Detection mocks all return False/empty (which would normally skip + everything) -- the flag must override them. Also verifies clang-tidy + goes to ``split`` (full scan) and the component-test matrix is + populated from disk rather than from changed-files. + """ + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_native_idf_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + mock_changed_files.return_value = [] + + with ( + patch("sys.argv", ["determine-jobs.py", "--force-all"]), + patch.object(determine_jobs, "get_changed_components", return_value=[]), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object(determine_jobs, "should_run_benchmarks", return_value=False), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + + assert output["integration_tests"] is True + assert output["clang_tidy"] is True + assert output["clang_tidy_mode"] == "split" + assert output["clang_tidy_full_scan"] is True + assert output["clang_format"] is True + assert output["python_linters"] is True + assert output["import_time"] is True + assert output["device_builder"] is True + assert output["native_idf"] is True + # native_idf_components is a CSV of NATIVE_IDF_TEST_COMPONENTS + assert "esp32" in output["native_idf_components"].split(",") + assert output["cpp_unit_tests_run_all"] is True + assert output["cpp_unit_tests_components"] == [] + assert output["benchmarks"] is True + # Detection helpers must not be consulted when --force-all is set + mock_determine_integration_tests.assert_not_called() + mock_should_run_clang_tidy.assert_not_called() + mock_should_run_clang_format.assert_not_called() + mock_should_run_python_linters.assert_not_called() + mock_should_run_import_time.assert_not_called() + mock_should_run_device_builder.assert_not_called() + mock_native_idf_components_to_test.assert_not_called() + mock_determine_cpp_unit_tests.assert_not_called() + # Component matrix is populated from disk (tests/components/ in the repo) + assert output["component_test_count"] > 0 + assert len(output["component_test_batches"]) > 0 + + +def test_main_force_all_off_uses_detection( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, + mock_determine_cpp_unit_tests: Mock, + mock_changed_files: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Without --force-all, detection helpers drive the decision (regression guard).""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_native_idf_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + mock_changed_files.return_value = [] + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "get_changed_components", return_value=[]), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, "create_intelligent_batches", return_value=([], {}) + ), + patch.object(determine_jobs, "should_run_benchmarks", return_value=False), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + + assert output["integration_tests"] is False + assert output["clang_tidy"] is False + assert output["clang_format"] is False + assert output["python_linters"] is False + assert output["native_idf"] is False + assert output["component_test_count"] == 0 + mock_determine_integration_tests.assert_called_once() + mock_should_run_clang_tidy.assert_called_once() From 56983f414fb9316062a09bbd57ae1584297cc595 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 22:41:36 -0400 Subject: [PATCH 1236/2214] [espidf] Gate esp_idf_size --ng on IDF version (#16441) --- esphome/build_gen/espidf.py | 10 ++++++++-- tests/unit_tests/build_gen/test_espidf.py | 7 ++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 5ad2072c5b..96f84ebbd1 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,8 @@ import json from pathlib import Path -from esphome.components.esp32 import get_esp32_variant +from esphome.components.esp32 import get_esp32_variant, idf_version +import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed from esphome.writer import update_storage_json @@ -61,6 +62,11 @@ def get_project_cmakelists(minimal: bool = False) -> str: variant = get_esp32_variant() idf_target = variant.lower().replace("-", "") + # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and + # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get + # --format=raw because the legacy mode doesn't support it. + size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" + # Project-wide compile options: -D defines and -W warning flags (skip # -Wl, linker flags — those go on the src component via # target_link_options below). Emitted via idf_build_set_property so the @@ -146,7 +152,7 @@ project({CORE.name}) # Emit raw JSON size data for ESPHome to read post-build. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD - COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw + COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 36f0442355..540dd06731 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -11,10 +11,12 @@ import pytest from esphome.components.esp32 import ( KEY_COMPONENTS, KEY_ESP32, + KEY_IDF_VERSION, KEY_PATH, KEY_REF, KEY_REPO, ) +import esphome.config_validation as cv from esphome.const import KEY_CORE from esphome.core import CORE @@ -24,7 +26,10 @@ def _reset_core(tmp_path: Path) -> None: """Give each test its own CORE.build_path and a clean esp32 data slot.""" CORE.build_path = str(tmp_path) CORE.data.setdefault(KEY_CORE, {}) - CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}} + CORE.data[KEY_ESP32] = { + KEY_COMPONENTS: {}, + KEY_IDF_VERSION: cv.Version(5, 5, 4), + } def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None: From d046dd7276747918784bb46a6f4ccb257f5e738a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 22:51:14 -0400 Subject: [PATCH 1237/2214] [esp32_hosted] Bump esp_hosted to 2.12.7 (#16440) --- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index eca7c24b10..71d1fd3ac1 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -249,7 +249,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 814a4031c1..49c4cdbb2e 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -36,7 +36,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.6 + version: 2.12.7 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From c5c627d534df0f96043b40f1bf1267de6bc33b5a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 14 May 2026 23:31:11 -0400 Subject: [PATCH 1238/2214] [audio] Bump microMP3 to v0.2.1 (#16429) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 44371e87ab..13b379ba3a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.0") + add_idf_component(name="esphome/micro-mp3", ref="0.2.1") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 49c4cdbb2e..35c55cbb4d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.0 + version: 0.2.1 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From d663d80fdefdba965a08e8436ab00cc1e332b734 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 14 May 2026 23:33:36 -0400 Subject: [PATCH 1239/2214] [sound_level] Use RingBufferAudioSource (#16436) --- .../components/sound_level/sound_level.cpp | 58 ++++++++++--------- esphome/components/sound_level/sound_level.h | 9 +-- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index fb8bfd3085..a93e396367 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -11,7 +11,7 @@ namespace esphome::sound_level { static const char *const TAG = "sound_level"; -static const uint32_t AUDIO_BUFFER_DURATION_MS = 30; +static const uint32_t MAX_FILL_DURATION_MS = 30; static const uint32_t RING_BUFFER_DURATION_MS = 120; // Square INT16_MIN since INT16_MIN^2 > INT16_MAX^2 @@ -30,8 +30,7 @@ void SoundLevelComponent::dump_config() { void SoundLevelComponent::setup() { this->microphone_source_->add_data_callback([this](const std::vector &data) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() == 2) { - // ``audio_buffer_`` and ``temp_ring_buffer`` share ownership of a ring buffer, so its safe/useful to write + if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } }); @@ -81,10 +80,11 @@ void SoundLevelComponent::loop() { return; } - // Copy data from ring buffer into the transfer buffer - don't block to avoid slowing the main loop - this->audio_buffer_->transfer_data_from_source(0); + // Expose a chunk of the ring buffer's internal storage - don't block to avoid slowing the main loop. + // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). + this->audio_source_->fill(0, false); - if (this->audio_buffer_->available() == 0) { + if (this->audio_source_->available() == 0) { // No new audio available for processing return; } @@ -92,11 +92,11 @@ void SoundLevelComponent::loop() { const uint32_t samples_in_window = this->microphone_source_->get_audio_stream_info().ms_to_samples(this->measurement_duration_ms_); const uint32_t samples_available_to_process = - this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_buffer_->available()); + this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_source_->available()); const uint32_t samples_to_process = std::min(samples_in_window - this->sample_count_, samples_available_to_process); // MicrophoneSource always provides int16 samples due to Python codegen settings - const int16_t *audio_data = reinterpret_cast(this->audio_buffer_->get_buffer_start()); + const int16_t *audio_data = reinterpret_cast(this->audio_source_->data()); // Process all the new audio samples for (uint32_t i = 0; i < samples_to_process; ++i) { @@ -115,9 +115,8 @@ void SoundLevelComponent::loop() { ++this->sample_count_; } - // Remove the processed samples from ``audio_buffer_`` - this->audio_buffer_->decrease_buffer_length( - this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process)); + // Remove the processed samples from ``audio_source_`` + this->audio_source_->consume(this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process)); if (this->sample_count_ == samples_in_window) { // Processed enough samples for the measurement window, compute and publish the sensor values @@ -158,36 +157,39 @@ void SoundLevelComponent::stop() { } bool SoundLevelComponent::start_() { - if (this->audio_buffer_ != nullptr) { + if (this->audio_source_ != nullptr) { return true; } - // Allocate a transfer buffer - this->audio_buffer_ = audio::AudioSourceTransferBuffer::create( - this->microphone_source_->get_audio_stream_info().ms_to_bytes(AUDIO_BUFFER_DURATION_MS)); - if (this->audio_buffer_ == nullptr) { - this->status_momentary_error("transfer_buffer", 15000); + const auto &stream_info = this->microphone_source_->get_audio_stream_info(); + const size_t bytes_per_frame = stream_info.frames_to_bytes(1); + + // Allocate a ring buffer for the microphone callback to write into. Round the size down to a multiple + // of bytes_per_frame so the wrap boundary stays frame-aligned and avoids unnecessary single-frame splices. + this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation + const size_t ring_buffer_size = + (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer == nullptr) { + this->status_momentary_error("ring_buffer", 15000); return false; } - // Allocates a new ring buffer, adds it as a source for the transfer buffer, and points ring_buffer_ to it - this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( - this->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); - if (temp_ring_buffer.use_count() == 0) { - this->status_momentary_error("ring_buffer", 15000); - this->stop_(); + // Zero-copy source that reads directly from the ring buffer's internal storage. Frame-aligned reads + // ensure multi-channel frames are never split across the ring buffer's wrap boundary. + this->audio_source_ = audio::RingBufferAudioSource::create( + temp_ring_buffer, stream_info.ms_to_bytes(MAX_FILL_DURATION_MS), static_cast(bytes_per_frame)); + if (this->audio_source_ == nullptr) { + this->status_momentary_error("audio_source", 15000); return false; - } else { - this->ring_buffer_ = temp_ring_buffer; - this->audio_buffer_->set_source(temp_ring_buffer); } + this->ring_buffer_ = temp_ring_buffer; this->status_clear_error(); return true; } -void SoundLevelComponent::stop_() { this->audio_buffer_.reset(); } +void SoundLevelComponent::stop_() { this->audio_source_.reset(); } } // namespace esphome::sound_level diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index 4f0081a510..aabea62ca4 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -36,11 +36,12 @@ class SoundLevelComponent : public Component { void stop(); protected: - /// @brief Internal start command that, if necessary, allocates ``audio_buffer_`` and a ring buffer which - /// ``audio_buffer_`` owns and ``ring_buffer_`` points to. Returns true if allocations were successful. + /// @brief Internal start command that, if necessary, allocates a ring buffer and a zero-copy + /// ``RingBufferAudioSource`` that reads directly from it. ``ring_buffer_`` weakly references the + /// ring buffer owned by ``audio_source_``. Returns true if allocations were successful. bool start_(); - /// @brief Internal stop command the deallocates ``audio_buffer_`` (which automatically deallocates its ring buffer) + /// @brief Internal stop command that deallocates ``audio_source_`` (which releases its ring buffer) void stop_(); microphone::MicrophoneSource *microphone_source_{nullptr}; @@ -48,7 +49,7 @@ class SoundLevelComponent : public Component { sensor::Sensor *peak_sensor_{nullptr}; sensor::Sensor *rms_sensor_{nullptr}; - std::unique_ptr audio_buffer_; + std::unique_ptr audio_source_; std::weak_ptr ring_buffer_; int32_t squared_peak_{0}; From d832ce51cd207f3cea82b04f2a1b7d60c0b68c6e Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 15 May 2026 05:42:10 +0200 Subject: [PATCH 1240/2214] [nextion] Replace `connect_info` vector with fixed-size field parser, always log device info (#16059) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nextion/display.py | 21 ++++++-- esphome/components/nextion/nextion.cpp | 75 +++++++++++++++----------- esphome/components/nextion/nextion.h | 15 +++--- esphome/core/defines.h | 1 - tests/components/nextion/common.yaml | 1 - 5 files changed, 70 insertions(+), 43 deletions(-) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index dc3d5c6d09..89e9b93520 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import automation import esphome.codegen as cg from esphome.components import display, esp32, uart @@ -39,6 +41,8 @@ from .base_component import ( CONF_WAKE_UP_PAGE, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@senexcrenshaw", "@edwardtfn"] DEPENDENCIES = ["uart"] @@ -55,6 +59,15 @@ NextionSetBrightnessAction = nextion_ns.class_( ) +def _deprecated_dump_device_info(value): + _LOGGER.warning( + "'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. " + "Device info is now always logged at connection time. " + "Please remove this option from your configuration." + ) + return value + + def _validate_tft_upload(config): has_tft_url = CONF_TFT_URL in config for conf_key in ( @@ -81,7 +94,10 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, cv.Range(max=TimePeriod(milliseconds=255)), ), - cv.Optional(CONF_DUMP_DEVICE_INFO, default=False): cv.boolean, + # Deprecated — device info is now always logged. Remove before 2026.11.0. + cv.Optional(CONF_DUMP_DEVICE_INFO): cv.All( + cv.boolean, _deprecated_dump_device_info + ), cv.Optional(CONF_EXIT_REPARSE_ON_START, default=False): cv.boolean, cv.Optional(CONF_MAX_QUEUE_AGE, default="8000ms"): cv.All( cv.positive_time_period_milliseconds, @@ -277,9 +293,6 @@ async def to_code(config): cg.add(var.set_auto_wake_on_touch(config[CONF_AUTO_WAKE_ON_TOUCH])) - if config[CONF_DUMP_DEVICE_INFO]: - cg.add_define("USE_NEXTION_CONFIG_DUMP_DEVICE_INFO") - if config[CONF_EXIT_REPARSE_ON_START]: cg.add_define("USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START") diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index b644cad507..4ebc717552 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -117,30 +117,41 @@ bool Nextion::check_connect_() { ESP_LOGN(TAG, "connect: %s", response.c_str()); - size_t start; + // Parse comok response fields directly + // Format: comok ,,,,,, + size_t field_count = 0; + size_t start = 0; size_t end = 0; - std::vector connect_info; + auto copy_field = [&](char *dst, size_t cap) { + size_t len = (end == std::string::npos ? response.size() : end) - start; + size_t n = len < cap ? len : cap; + std::memcpy(dst, response.data() + start, n); + dst[n] = '\0'; + }; while ((start = response.find_first_not_of(',', end)) != std::string::npos) { end = response.find(',', start); - connect_info.push_back(response.substr(start, end - start)); + switch (field_count) { + case 2: + copy_field(this->device_model_, this->NEXTION_MODEL_MAX); + break; + case 3: + copy_field(this->firmware_version_, this->NEXTION_FW_MAX); + break; + case 5: + copy_field(this->serial_number_, this->NEXTION_SERIAL_MAX); + break; + case 6: + this->flash_size_ = static_cast(std::strtoul(response.data() + start, nullptr, 10)); + break; + default: + break; + } + ++field_count; } - this->is_detected_ = (connect_info.size() == 7); + this->is_detected_ = (field_count == 7); if (this->is_detected_) { - ESP_LOGN(TAG, "Connect info: %zu", connect_info.size()); -#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - this->device_model_ = connect_info[2]; - this->firmware_version_ = connect_info[3]; - this->serial_number_ = connect_info[5]; - this->flash_size_ = connect_info[6]; -#else // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - ESP_LOGI(TAG, - " Device Model: %s\n" - " FW Version: %s\n" - " Serial Number: %s\n" - " Flash Size: %s\n", - connect_info[2].c_str(), connect_info[3].c_str(), connect_info[5].c_str(), connect_info[6].c_str()); -#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO + ESP_LOGN(TAG, "Connect info: %zu fields", field_count); } else { ESP_LOGE(TAG, "Bad connect value: '%s'", response.c_str()); } @@ -178,24 +189,26 @@ void Nextion::dump_config() { #ifdef USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE ESP_LOGCONFIG(TAG, " Skip handshake: YES"); #else // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE -#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO + if (this->is_setup()) { + ESP_LOGCONFIG(TAG, + " Device Model: %s\n" + " FW Version: %s\n" + " Serial Number: %s\n" + " Flash Size: %" PRIu32 " bytes", + this->device_model_, this->firmware_version_, this->serial_number_, this->flash_size_); + } else { + ESP_LOGCONFIG(TAG, " Device info: not yet detected"); + } ESP_LOGCONFIG(TAG, - " Device Model: %s\n" - " FW Version: %s\n" - " Serial Number: %s\n" - " Flash Size: %s\n" - " Max queue age: %u ms\n" - " Startup override: %u ms\n", - this->device_model_.c_str(), this->firmware_version_.c_str(), this->serial_number_.c_str(), - this->flash_size_.c_str(), this->max_q_age_ms_, this->startup_override_ms_); -#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO #ifdef USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START - ESP_LOGCONFIG(TAG, " Exit reparse: YES\n"); + " Exit reparse: YES\n" #endif // USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START - ESP_LOGCONFIG(TAG, + " Max queue age: %u ms\n" + " Startup override: %u ms\n" " Wake On Touch: %s\n" " Touch Timeout: %" PRIu16, - YESNO(this->connection_state_.auto_wake_on_touch_), this->touch_sleep_timeout_); + this->max_q_age_ms_, this->startup_override_ms_, YESNO(this->connection_state_.auto_wake_on_touch_), + this->touch_sleep_timeout_); #endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index c62772ac75..ef030e71da 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1610,12 +1610,15 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe nextion_writer_t writer_; optional brightness_; -#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - std::string device_model_; - std::string firmware_version_; - std::string serial_number_; - std::string flash_size_; -#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO + // Device info populated from comok response (fixed-size, no heap allocation). + // Sizes derived from Nextion Upload Protocol documentation and observed hardware. + static constexpr size_t NEXTION_MODEL_MAX = 24; ///< Max observed ~18 chars from product numbering rules + static constexpr size_t NEXTION_FW_MAX = 7; ///< 'S' prefix + integer (e.g. 'S99' or `123`) + static constexpr size_t NEXTION_SERIAL_MAX = 20; ///< Consistently 16 hex chars across all documented examples + char device_model_[NEXTION_MODEL_MAX + 1]{}; + char firmware_version_[NEXTION_FW_MAX + 1]{}; + char serial_number_[NEXTION_SERIAL_MAX + 1]{}; + uint32_t flash_size_ = 0; ///< Flash size in bytes — plain integer, no string needed void remove_front_no_sensors_(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 162a6034b8..ee8e89de8b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -134,7 +134,6 @@ #define USE_MEDIA_SOURCE #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE -#define USE_NEXTION_CONFIG_DUMP_DEVICE_INFO #define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START #define USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE #define USE_NEXTION_MAX_COMMANDS_PER_LOOP diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index fba6a22b97..d79e3ee2ed 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -276,7 +276,6 @@ display: auto_wake_on_touch: true brightness: 80% command_spacing: 5ms - dump_device_info: true exit_reparse_on_start: true lambda: |- ESP_LOGD("display","Display is being tested!"); From ff968a4629915b4bf448da32773b7cc8e4204b60 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 15 May 2026 12:36:01 -0400 Subject: [PATCH 1241/2214] [ci] Fix sync-device-classes workflow (failing daily for weeks) (#16448) --- .github/workflows/sync-device-classes.yml | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index f69c7530f7..879d7d5e0c 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -30,6 +30,11 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Create sync branch + # Switch off dev before pre-commit runs so the + # no-commit-to-branch hook passes (it blocks dev/release/beta). + run: git checkout -B sync/device-classes + - name: Checkout Home Assistant uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -45,15 +50,27 @@ jobs: run: | python -m pip install --upgrade pip pip install -e lib/home-assistant - pip install -r requirements_test.txt pre-commit + # Project requirements are needed so pylint can resolve runtime + # imports (e.g. smpclient in esphome/components/nrf52/ota.py). + pip install -r requirements.txt -r requirements_test.txt pre-commit - name: Sync run: | python ./script/sync-device_class.py - - name: Run pre-commit hooks - run: | - python script/run-in-env.py pre-commit run --all-files + - name: Apply pre-commit auto-fixes + # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify + # files. pre-commit exits non-zero whenever a hook touches anything, + # which would otherwise abort the workflow before the auto-fixes + # can flow into the sync PR. + run: python script/run-in-env.py pre-commit run --all-files || true + + - name: Verify pre-commit clean + # Second pass: re-run all hooks against the now-fixed tree. + # Auto-fixers exit 0 (nothing to change); any remaining failure + # from a check-only hook (pylint / flake8 / yamllint / ci-custom) + # is a real issue and fails the workflow loudly. + run: python script/run-in-env.py pre-commit run --all-files - name: Commit changes uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 From 5b6c54c961d800bbc284729463060ec008ce849d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 09:45:11 -0700 Subject: [PATCH 1242/2214] [ci] sync-device-classes: use uv for installs and skip pylint (#16449) --- .github/workflows/sync-device-classes.yml | 31 ++++++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 879d7d5e0c..7a6f272ff2 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -46,13 +46,20 @@ jobs: with: python-version: "3.14" + - name: Set up uv + # An order of magnitude faster than pip on cold boots, with its + # own wheel cache. ``--system`` (below) installs into the + # setup-python interpreter so subsequent ``pre-commit`` / + # ``script/run-in-env.py`` steps find the deps without a + # ``uv run`` prefix. + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + - name: Install Home Assistant run: | - python -m pip install --upgrade pip - pip install -e lib/home-assistant - # Project requirements are needed so pylint can resolve runtime - # imports (e.g. smpclient in esphome/components/nrf52/ota.py). - pip install -r requirements.txt -r requirements_test.txt pre-commit + uv pip install --system -e lib/home-assistant + uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit - name: Sync run: | @@ -63,13 +70,23 @@ jobs: # files. pre-commit exits non-zero whenever a hook touches anything, # which would otherwise abort the workflow before the auto-fixes # can flow into the sync PR. + # + # pylint is skipped: this workflow only installs a subset of the + # runtime deps (HA + requirements*.txt), so pylint surfaces + # import-error / relative-beyond-top-level noise that the main CI + # already gates on real PRs. + env: + SKIP: pylint run: python script/run-in-env.py pre-commit run --all-files || true - name: Verify pre-commit clean # Second pass: re-run all hooks against the now-fixed tree. # Auto-fixers exit 0 (nothing to change); any remaining failure - # from a check-only hook (pylint / flake8 / yamllint / ci-custom) - # is a real issue and fails the workflow loudly. + # from a check-only hook (flake8 / yamllint / ci-custom) is a + # real issue and fails the workflow loudly. pylint stays skipped + # for the same reason as above. + env: + SKIP: pylint run: python script/run-in-env.py pre-commit run --all-files - name: Commit changes From 1b1e21d470589a65a63e8456a8a19de88af27d4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 09:54:01 -0700 Subject: [PATCH 1243/2214] [ci] sync-device-classes: drop branch-switch hack, skip no-commit-to-branch instead (#16450) --- .github/workflows/sync-device-classes.yml | 25 +++++++++++------------ 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 7a6f272ff2..23a63c5d8a 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -30,11 +30,6 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Create sync branch - # Switch off dev before pre-commit runs so the - # no-commit-to-branch hook passes (it blocks dev/release/beta). - run: git checkout -B sync/device-classes - - name: Checkout Home Assistant uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -71,22 +66,26 @@ jobs: # which would otherwise abort the workflow before the auto-fixes # can flow into the sync PR. # - # pylint is skipped: this workflow only installs a subset of the - # runtime deps (HA + requirements*.txt), so pylint surfaces - # import-error / relative-beyond-top-level noise that the main CI - # already gates on real PRs. + # SKIP: + # - no-commit-to-branch is a local guard against committing on + # dev/release/beta; CI runs on dev by definition, and + # peter-evans/create-pull-request creates the branch itself. + # - pylint surfaces import-error / relative-beyond-top-level + # noise here because this workflow installs only a subset of + # the runtime deps (HA + requirements*.txt); main CI already + # gates pylint on real PRs. env: - SKIP: pylint + SKIP: pylint,no-commit-to-branch run: python script/run-in-env.py pre-commit run --all-files || true - name: Verify pre-commit clean # Second pass: re-run all hooks against the now-fixed tree. # Auto-fixers exit 0 (nothing to change); any remaining failure # from a check-only hook (flake8 / yamllint / ci-custom) is a - # real issue and fails the workflow loudly. pylint stays skipped - # for the same reason as above. + # real issue and fails the workflow loudly. Same SKIP list as + # above for the same reasons. env: - SKIP: pylint + SKIP: pylint,no-commit-to-branch run: python script/run-in-env.py pre-commit run --all-files - name: Commit changes From 4189979391cb9616ca8a812da678cbb89c50e698 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 09:57:49 -0700 Subject: [PATCH 1244/2214] Synchronise Device Classes from Home Assistant (#16452) Co-authored-by: esphomebot --- esphome/components/sensor/__init__.py | 2 ++ esphome/const.py | 1 + 2 files changed, 3 insertions(+) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index f076c7f17b..6bbab76363 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -96,6 +96,7 @@ from esphome.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_UPTIME, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, DEVICE_CLASS_VOLTAGE, @@ -174,6 +175,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_UPTIME, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, DEVICE_CLASS_VOLTAGE, diff --git a/esphome/const.py b/esphome/const.py index fa1ea42bc6..4557380c73 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1367,6 +1367,7 @@ DEVICE_CLASS_TEMPERATURE = "temperature" DEVICE_CLASS_TEMPERATURE_DELTA = "temperature_delta" DEVICE_CLASS_TIMESTAMP = "timestamp" DEVICE_CLASS_UPDATE = "update" +DEVICE_CLASS_UPTIME = "uptime" DEVICE_CLASS_VIBRATION = "vibration" DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" From 4381a8baaaa369ddfdda112e90337c5f87660f3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 09:59:35 -0700 Subject: [PATCH 1245/2214] [ci] pr-title-check: skip all bot authors, not just dependabot (#16453) --- .github/workflows/pr-title-check.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index e15d09da82..e8320672d2 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -29,10 +29,11 @@ jobs: } = require('./.github/scripts/detect-tags.js'); const title = context.payload.pull_request.title; - const author = context.payload.pull_request.user.login; + const user = context.payload.pull_request.user; - // Skip bot PRs (e.g. dependabot) - they have their own title format - if (author === 'dependabot[bot]') { + // Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) - + // they have their own title formats. + if (user.type === 'Bot') { return; } From 8b3bc47547d33f96bb3b23e23e5596de27b847f6 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 15 May 2026 19:16:01 +0200 Subject: [PATCH 1246/2214] [uptime] Update device_class for Uptime sensor (#16434) Co-authored-by: J. Nick Koston --- esphome/components/uptime/sensor/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index e2a7aee1a2..6ce0795cdb 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, DEVICE_CLASS_DURATION, - DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_UPTIME, ENTITY_CATEGORY_DIAGNOSTIC, ICON_TIMER, STATE_CLASS_TOTAL_INCREASING, @@ -33,9 +33,8 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend(cv.polling_component_schema("60s")), "timestamp": sensor.sensor_schema( UptimeTimestampSensor, - icon=ICON_TIMER, accuracy_decimals=0, - device_class=DEVICE_CLASS_TIMESTAMP, + device_class=DEVICE_CLASS_UPTIME, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ) .extend( From ec1826a6ed47d9a94ad64b6751ec46a2088f7ce4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:17:50 -0700 Subject: [PATCH 1247/2214] [yaml_util] Promote include-discovery helper, share it with bundle (#16447) --- esphome/bundle.py | 97 ++----------- esphome/yaml_util.py | 125 +++++++++++++++++ tests/unit_tests/test_bundle.py | 12 +- tests/unit_tests/test_yaml_util.py | 216 +++++++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 91 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index 70c4fad0fd..4537cbce9d 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -260,42 +260,20 @@ class ConfigBundleCreator: def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. - Deliberately uses a fresh re-parse and force-loads every deferred - ``IncludeFile`` to include *all* potentially-reachable includes, - even branches not selected by the local substitutions. Bundles are - meant to be compiled on another system where command-line - substitution overrides may choose a different branch — e.g. - ``!include network/${eth_model}/config.yaml`` must ship every - candidate so the remote build can pick any one. - - Entries with unresolved substitution variables in the filename - path are skipped with a warning (they cannot be resolved without - the substitution pass). - - Secrets files are tracked separately so we can filter them to - only include the keys this config actually references. + Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a + fresh re-parse and force-loads every deferred ``IncludeFile`` so that + *all* potentially-reachable includes are captured (even branches not + selected by local substitutions). Bundles are meant to be compiled on + another system where command-line substitution overrides may choose a + different branch — e.g. ``!include network/${eth_model}/config.yaml`` + must ship every candidate so the remote build can pick any one. """ - # Must be a fresh parse: IncludeFile.load() caches its result in - # _content, and we discover files by listening for loader calls. On - # an already-parsed tree the cache is populated, .load() returns - # without calling the loader, the listener never fires, and the - # referenced files would be silently dropped from the bundle. - with yaml_util.track_yaml_loads() as loaded_files: - try: - data = yaml_util.load_yaml(self._config_path) - except EsphomeError: - _LOGGER.debug( - "Bundle: re-loading YAML for include discovery failed, " - "proceeding with partial file list" - ) - else: - _force_load_include_files(data) - - for fpath in loaded_files: - if fpath == self._config_path.resolve(): + discovered = yaml_util.discover_user_yaml_files(self._config_path) + self._secrets_paths.update(discovered.secrets) + config_resolved = self._config_path.resolve() + for fpath in discovered.files: + if fpath == config_resolved: continue # Already added as config - if fpath.name in const.SECRETS_FILES: - self._secrets_paths.add(fpath) self._add_file(fpath) def _discover_component_files(self) -> None: @@ -625,57 +603,6 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: tar.addfile(info, io.BytesIO(data)) -def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None: - """Recursively resolve any ``IncludeFile`` instances in a YAML tree. - - Nested ``!include`` returns a deferred ``IncludeFile`` that is only - resolved during the substitution pass. During bundle discovery we need - the referenced files to actually load so the ``track_yaml_loads`` - listener fires for them. - - ``IncludeFile`` instances with unresolved substitution variables in the - filename cannot be loaded — we skip and warn about those. - """ - if _seen is None: - _seen = set() - - if isinstance(obj, yaml_util.IncludeFile): - if id(obj) in _seen: - return - _seen.add(id(obj)) - if obj.has_unresolved_expressions(): - _LOGGER.warning( - "Bundle: cannot resolve !include %s (referenced from %s) " - "with substitutions in path", - obj.file, - obj.parent_file, - ) - return - try: - loaded = obj.load() - except EsphomeError as err: - _LOGGER.warning( - "Bundle: failed to load !include %s (referenced from %s): %s", - obj.file, - obj.parent_file, - err, - ) - return - _force_load_include_files(loaded, _seen) - elif isinstance(obj, dict): - if id(obj) in _seen: - return - _seen.add(id(obj)) - for value in obj.values(): - _force_load_include_files(value, _seen) - elif isinstance(obj, (list, tuple)): - if id(obj) in _seen: - return - _seen.add(id(obj)) - for item in obj: - _force_load_include_files(item, _seen) - - def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b153d160a7..b56d024418 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Generator from contextlib import contextmanager, suppress +from dataclasses import dataclass, field import functools import inspect from io import BytesIO, TextIOBase, TextIOWrapper @@ -233,6 +234,130 @@ class IncludeFile: return has_substitution_or_expression(str(self.file)) +def force_load_include_files( + obj: Any, + *, + warn_on_unresolved: bool = True, + _seen: set[int] | None = None, +) -> None: + """Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree. + + Nested ``!include`` returns a deferred ``IncludeFile`` that is only resolved + later (substitution / packages pass). Callers that need every referenced + file to actually load — bundle discovery, on-device YAML recovery — invoke + this while a :func:`track_yaml_loads` listener is active so the underlying + loader fires and records every reachable file. + + ``IncludeFile`` instances whose path contains unresolved substitution + variables cannot be loaded. By default a warning is logged for each one; + pass ``warn_on_unresolved=False`` (used by discovery paths that run on a + fresh re-parse where substitutions haven't been applied yet) to demote it + to a debug log. + """ + if _seen is None: + _seen = set() + + if isinstance(obj, IncludeFile): + if id(obj) in _seen: + return + _seen.add(id(obj)) + if obj.has_unresolved_expressions(): + log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug + log( + "Cannot resolve !include %s (referenced from %s) with substitutions in path", + obj.file, + obj.parent_file, + ) + return + try: + loaded = obj.load() + except EsphomeError as err: + _LOGGER.warning( + "Failed to load !include %s (referenced from %s): %s", + obj.file, + obj.parent_file, + err, + ) + return + force_load_include_files( + loaded, warn_on_unresolved=warn_on_unresolved, _seen=_seen + ) + elif isinstance(obj, dict): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for value in obj.values(): + force_load_include_files( + value, warn_on_unresolved=warn_on_unresolved, _seen=_seen + ) + elif isinstance(obj, (list, tuple)): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for item in obj: + force_load_include_files( + item, warn_on_unresolved=warn_on_unresolved, _seen=_seen + ) + + +@dataclass(slots=True) +class DiscoveredYamlFiles: + """Result of :func:`discover_user_yaml_files`. + + ``files`` contains every resolved path the YAML loader touched while we + were re-parsing the user's config; ``secrets`` is the subset whose + *un-resolved* filename matched :data:`esphome.const.SECRETS_FILES` (so + a ``secrets.yaml`` symlinked to a differently-named target is still + flagged as secrets). + """ + + files: list[Path] = field(default_factory=list) + secrets: set[Path] = field(default_factory=set) + + +def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: + """Fresh-re-parse ``config_path`` and report every file the YAML loader + pulled in, plus which of them came in under a secrets filename. + + Does NOT run schema validation, substitutions, or package resolution — so + component-internal YAML loaded by validators (LVGL helpers, dashboard + imports, etc.) is *not* captured. Deferred ``!include`` references whose + paths don't depend on substitutions are force-loaded here so they're + captured too. + + Must run on a fresh parse because :meth:`IncludeFile.load` caches its + result; on an already-resolved tree :meth:`load` returns without invoking + the loader and the listener would not fire for the referenced files. + """ + from esphome.const import SECRETS_FILES + + secrets: set[Path] = set() + + def _capture_secret(fname: Path) -> None: + if Path(fname).name in SECRETS_FILES: + secrets.add(Path(fname).resolve()) + + with track_yaml_loads() as loaded: + _load_listeners.append(_capture_secret) + try: + try: + data = load_yaml(config_path) + except EsphomeError: + return DiscoveredYamlFiles(list(loaded), secrets) + force_load_include_files(data, warn_on_unresolved=False) + finally: + _load_listeners.remove(_capture_secret) + + # Deduplicate while preserving first-seen order. + seen: set[Path] = set() + unique: list[Path] = [] + for path in loaded: + if path not in seen: + seen.add(path) + unique.append(path) + return DiscoveredYamlFiles(unique, secrets) + + def _add_data_ref(fn): @functools.wraps(fn) def wrapped(loader, node): diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 5d046252da..f15bbf2e29 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -22,13 +22,13 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, - _force_load_include_files, extract_bundle, is_bundle_path, prepare_bundle_for_compile, read_bundle_manifest, ) from esphome.core import CORE, EsphomeError +from esphome.yaml_util import force_load_include_files # --------------------------------------------------------------------------- # Helpers @@ -947,7 +947,7 @@ def test_discover_files_nested_include_load_failure( paths = [f.path for f in files] assert "test.yaml" in paths assert any( - "failed to load !include" in r.message and "missing.yaml" in r.message + "failed to load !include" in r.message.lower() and "missing.yaml" in r.message for r in caplog.records ) @@ -974,8 +974,8 @@ def test_force_load_skips_duplicate_include_file() -> None: # Same instance appears twice — second visit must hit the _seen guard. tree = {"a": stub, "b": [stub]} - with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude): - _force_load_include_files(tree) + with patch("esphome.yaml_util.IncludeFile", _StubInclude): + force_load_include_files(tree) assert stub.load_calls == 1 @@ -989,8 +989,8 @@ def test_force_load_handles_cyclic_containers() -> None: cyclic_list.append(cyclic_list) # Should return without recursing forever - _force_load_include_files(cyclic_dict) - _force_load_include_files(cyclic_list) + force_load_include_files(cyclic_dict) + force_load_include_files(cyclic_list) def test_discover_files_yaml_reload_failure( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index ace92fbf6f..e97a188be4 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -12,11 +12,15 @@ import esphome.config_validation as cv from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.util import OrderedDict from esphome.yaml_util import ( + DiscoveredYamlFiles, ESPHomeDataBase, ESPLiteralValue, + discover_user_yaml_files, + force_load_include_files, format_path, make_data_base, make_literal, + track_yaml_loads, ) @@ -966,3 +970,215 @@ def test_make_literal_blocks_substitution() -> None: # undefined in the context. assert result == {"pin": "${PIN}"} assert isinstance(result, ESPLiteralValue) + + +# --------------------------------------------------------------------------- +# force_load_include_files / discover_user_yaml_files +# --------------------------------------------------------------------------- + + +class _StubInclude: + """Stand-in for `IncludeFile` that records how `load()` was called. + + Patched in via `esphome.yaml_util.IncludeFile` so the recursion in + `force_load_include_files` treats instances as deferred includes without + needing an actual on-disk file. + """ + + def __init__( + self, + file: str = "stub.yaml", + parent_file: Path | None = None, + *, + unresolved: bool = False, + load_result: object = None, + raise_on_load: EsphomeError | None = None, + ) -> None: + self.file = Path(file) + self.parent_file = parent_file or Path("/tmp/parent.yaml") + self._unresolved = unresolved + self._load_result = load_result if load_result is not None else {} + self._raise = raise_on_load + self.load_calls = 0 + + def has_unresolved_expressions(self) -> bool: + return self._unresolved + + def load(self) -> object: + self.load_calls += 1 + if self._raise is not None: + raise self._raise + return self._load_result + + +@pytest.fixture +def patch_include_file(): + """Replace `IncludeFile` with `_StubInclude` so isinstance checks in + `force_load_include_files` match the stubs constructed by tests.""" + with patch("esphome.yaml_util.IncludeFile", _StubInclude): + yield + + +def test_force_load_include_files_resolves_nested_includes( + patch_include_file: None, +) -> None: + """A tree of dict/list/IncludeFile is walked and every IncludeFile is loaded.""" + inner = _StubInclude("inner.yaml") + outer = _StubInclude("outer.yaml", load_result={"nested": inner}) + force_load_include_files([{"a": outer}, "scalar"]) + assert outer.load_calls == 1 + assert inner.load_calls == 1 + + +def test_force_load_include_files_seen_guard_prevents_double_load( + patch_include_file: None, +) -> None: + """The same IncludeFile referenced from two branches loads once.""" + stub = _StubInclude("once.yaml") + force_load_include_files({"a": stub, "b": [stub]}) + assert stub.load_calls == 1 + + +def test_force_load_include_files_handles_cyclic_containers() -> None: + """Cyclic dict/list references don't trigger infinite recursion.""" + cyclic_dict: dict[str, object] = {} + cyclic_dict["self"] = cyclic_dict + cyclic_list: list[object] = [] + cyclic_list.append(cyclic_list) + # Both calls must return without recursing forever. + force_load_include_files(cyclic_dict) + force_load_include_files(cyclic_list) + + +@pytest.mark.parametrize( + ("warn_on_unresolved", "expect_level"), + [ + pytest.param(True, "WARNING", id="default-warns"), + pytest.param(False, "DEBUG", id="opt-in-demotes"), + ], +) +def test_force_load_include_files_unresolved_log_level( + patch_include_file: None, + caplog: pytest.LogCaptureFixture, + warn_on_unresolved: bool, + expect_level: str, +) -> None: + """Substitution-templated include paths skip the load and log at the + level chosen by `warn_on_unresolved`.""" + stub = _StubInclude("${var}.yaml", unresolved=True) + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + force_load_include_files({"k": stub}, warn_on_unresolved=warn_on_unresolved) + assert stub.load_calls == 0 + matching = [ + r.levelname for r in caplog.records if "Cannot resolve !include" in r.message + ] + assert matching == [expect_level] + + +def test_force_load_include_files_warns_on_load_failure( + patch_include_file: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """An `EsphomeError` raised by `load()` is caught and logged, not propagated.""" + stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) + with caplog.at_level("WARNING", logger="esphome.yaml_util"): + force_load_include_files({"k": stub}) + assert any( + "Failed to load !include" in r.message and "missing.yaml" in r.message + for r in caplog.records + ) + + +def test_discovered_yaml_files_holds_files_and_secrets() -> None: + """`DiscoveredYamlFiles` is a small data carrier; both fields are mandatory.""" + files = [Path("/tmp/a.yaml")] + secrets = {Path("/tmp/a.yaml")} + discovered = DiscoveredYamlFiles(files, secrets) + assert discovered.files is files + assert discovered.secrets is secrets + + +def _write(tmp_path: Path, name: str, content: str) -> Path: + """Write `content` to `tmp_path/name`, creating parent dirs as needed.""" + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + +def _write_entry_including(tmp_path: Path, included_name: str) -> Path: + """Write a minimal entry yaml that `!include`s `included_name`.""" + return _write( + tmp_path, + "entry.yaml", + f"esphome:\n name: test\nwifi: !include {included_name}\n", + ) + + +def test_discover_user_yaml_files_captures_includes(tmp_path: Path) -> None: + """A `!include` in the entry yaml is force-loaded so the listener fires.""" + _write(tmp_path, "wifi.yaml", "ssid: my_ssid\npassword: my_pw\n") + discovered = discover_user_yaml_files(_write_entry_including(tmp_path, "wifi.yaml")) + names = {p.name for p in discovered.files} + assert names == {"entry.yaml", "wifi.yaml"} + assert discovered.secrets == set() + + +@pytest.mark.parametrize( + "secret_name", + [ + pytest.param("secrets.yaml", id="yaml"), + pytest.param("secrets.yml", id="yml"), + ], +) +def test_discover_user_yaml_files_flags_secrets_filename( + tmp_path: Path, secret_name: str +) -> None: + """Both `secrets.yaml` and `secrets.yml` get flagged in `.secrets`.""" + _write(tmp_path, secret_name, "key: value\n") + discovered = discover_user_yaml_files(_write_entry_including(tmp_path, secret_name)) + assert (tmp_path / secret_name).resolve() in discovered.secrets + + +def test_discover_user_yaml_files_flags_secrets_symlink(tmp_path: Path) -> None: + """`secrets.yaml` symlinked to a non-secrets-named target is still flagged + because the un-resolved basename is what gets recorded.""" + target = _write(tmp_path, "real_creds.yaml", "key: value\n") + (tmp_path / "secrets.yaml").symlink_to(target) + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "secrets.yaml") + ) + # The recorded "secret path" is the resolved target — even though its + # basename is `real_creds.yaml`, it's still in `.secrets`. + assert target.resolve() in discovered.secrets + + +def test_discover_user_yaml_files_swallows_parse_errors(tmp_path: Path) -> None: + """A YAML parse failure returns whatever was tracked so far without raising.""" + entry = _write(tmp_path, "entry.yaml", "esphome: [unterminated\n") + discovered = discover_user_yaml_files(entry) + assert isinstance(discovered, DiscoveredYamlFiles) + + +def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None: + """The same file referenced twice appears once in `.files`.""" + _write(tmp_path, "wifi.yaml", "ssid: a\n") + entry = _write( + tmp_path, + "entry.yaml", + "esphome:\n name: test\nwifi: !include wifi.yaml\nfoo: !include wifi.yaml\n", + ) + discovered = discover_user_yaml_files(entry) + wifi_resolved = (tmp_path / "wifi.yaml").resolve() + assert discovered.files.count(wifi_resolved) == 1 + + +def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: + """`track_yaml_loads` is the building block — sanity-check it resolves + symlinks so callers can dedupe by identity.""" + target = _write(tmp_path, "actual.yaml", "esphome:\n name: t\n") + link = tmp_path / "alias.yaml" + link.symlink_to(target) + with track_yaml_loads() as loaded: + yaml_util.load_yaml(link) + assert target.resolve() in loaded From 46be0f4f62141a6dc5bfe7b55acb753b2b64c343 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:18:07 -0700 Subject: [PATCH 1248/2214] [ci] Log top 30 pytest durations (#16455) --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abd2d1b3a5..57c896fe19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,7 +181,7 @@ jobs: # own CI). No ``--cov`` here -- this is purely a downstream # smoke check against this PR's esphome code. working-directory: device-builder - run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks + run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks pytest: name: Run pytest @@ -222,12 +222,12 @@ jobs: if: matrix.os == 'windows-latest' run: | . ./venv/Scripts/activate.ps1 - pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Run pytest if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' run: | . venv/bin/activate - pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: @@ -370,7 +370,7 @@ jobs: . venv/bin/activate mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" - pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" + pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}" cpp-unit-tests: name: Run C++ unit tests From 1674ed9744414bc00f91a8ae5b9255dbeac2f19f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:18:27 -0700 Subject: [PATCH 1249/2214] [ci] Use uv for pip installs across CI workflows (#16451) --- .github/actions/restore-python/action.yml | 16 ++++++++++++---- .github/workflows/ci-api-proto.yml | 8 +++++++- .github/workflows/ci.yml | 22 ++++++++++++++++++---- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 21393f2aba..751f9ecf58 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -27,6 +27,14 @@ runs: path: venv # yamllint disable-line rule:line-length key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }} + - name: Set up uv + # Only needed on cache miss to populate the venv. ``uv pip install`` + # 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows' shell: bash @@ -34,8 +42,8 @@ runs: python -m venv venv source venv/bin/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.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' shell: bash @@ -43,5 +51,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -e . diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 2f7fd271ba..1dc0ccb7fe 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -26,6 +26,12 @@ jobs: uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.11" + - 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Install apt dependencies run: | @@ -34,7 +40,7 @@ jobs: sudo apt install -y protobuf-compiler protoc --version - name: Install python dependencies - run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt + run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt - name: Generate files run: script/api_protobuf/api_protobuf.py - name: Check for changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57c896fe19..de21456841 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,14 +52,22 @@ jobs: path: venv # yamllint disable-line rule:line-length key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }} + - name: Set up uv + # Only needed on cache miss to populate the venv. ``uv pip install`` + # 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | python -m venv venv . venv/bin/activate python --version - pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit - pip install -e . + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit + uv pip install -e . pylint: name: Check pylint @@ -351,14 +359,20 @@ jobs: with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} + - 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | python -m venv venv . venv/bin/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -e . - name: Register matcher run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - name: Run integration tests From 96106d25bc4555423ce7a05f2a8ee0888ed2641e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:15 -0700 Subject: [PATCH 1250/2214] [wifi] Refuse to compile when wifi_ssid is the device-builder placeholder (#16444) --- esphome/__main__.py | 8 +++ esphome/components/wifi/__init__.py | 52 +++++++++++++++- esphome/const.py | 9 +++ tests/unit_tests/components/test_wifi.py | 78 +++++++++++++++++++++++- 4 files changed, 144 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d733534a5c..16a05ad552 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -50,6 +50,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USERNAME, CONF_WEB_SERVER, + CONF_WIFI, ENV_NOGITIGNORE, KEY_CORE, KEY_TARGET_PLATFORM, @@ -733,6 +734,13 @@ def write_cpp_file() -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int: + # Keep this gate here, NOT in config validation: device-builder needs + # `esphome config` to keep succeeding with placeholders so onboarding can run. + if CONF_WIFI in config: + from esphome.components.wifi import check_placeholder_credentials + + check_placeholder_credentials(config) + # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index bad57fc481..f9cb391442 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -54,10 +54,18 @@ from esphome.const import ( CONF_TTLS_PHASE_2, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_WIFI, + PLACEHOLDER_WIFI_SSID, Platform, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeError, + HexInt, + coroutine_with_priority, +) import esphome.final_validate as fv from esphome.types import ConfigType @@ -903,3 +911,45 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO}, } ) + + +def _placeholder_wifi_credentials(config: ConfigType) -> list[str]: + """Return human-readable locations where the dashboard's placeholder wifi + values still appear. Empty list means no placeholders were found. + """ + placeholders: list[str] = [] + wifi_conf = config.get(CONF_WIFI) + if not wifi_conf: + return placeholders + + for idx, network in enumerate(wifi_conf.get(CONF_NETWORKS, [])): + ssid = network.get(CONF_SSID) + if isinstance(ssid, str) and ssid == PLACEHOLDER_WIFI_SSID: + placeholders.append(f"wifi.networks[{idx}].ssid") + + ap_conf = wifi_conf.get(CONF_AP) + if ap_conf: + ap_ssid = ap_conf.get(CONF_SSID) + if isinstance(ap_ssid, str) and ap_ssid == PLACEHOLDER_WIFI_SSID: + placeholders.append("wifi.ap.ssid") + + return placeholders + + +def check_placeholder_credentials(config: ConfigType) -> None: + """Raise EsphomeError if any wifi credential is the dashboard placeholder. + + Call only at compile time. NEVER from CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, + or any path reached by `esphome config`; device-builder relies on + validation passing with the placeholders still in place. + """ + locations = _placeholder_wifi_credentials(config) + if not locations: + return + formatted = ", ".join(locations) + raise EsphomeError( + f"wifi configuration still contains the dashboard placeholder value " + f"'{PLACEHOLDER_WIFI_SSID}' at: {formatted}. " + f"Open secrets.yaml and replace 'wifi_ssid' (and 'wifi_password') " + f"with your real wifi credentials before flashing." + ) diff --git a/esphome/const.py b/esphome/const.py index 4557380c73..9dd77a7cb8 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1416,3 +1416,12 @@ ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic" # The corresponding constant exists in c++ # when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds SCHEDULER_DONT_RUN = 4294967295 + +# Sentinel values written by the esphome-device-builder dashboard into +# secrets.yaml on first boot so that !secret wifi_ssid / !secret wifi_password +# references resolve cleanly through validation before the user has finished +# the onboarding wizard. Compilation refuses if these reach the binary so that +# a user who dismisses onboarding can't accidentally flash a device that will +# never associate with their wifi. +PLACEHOLDER_WIFI_SSID = "REPLACE_WITH_YOUR_WIFI_NETWORK" +PLACEHOLDER_WIFI_PASSWORD = "REPLACE_WITH_YOUR_WIFI_PASSWORD" # noqa: S105 diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 71a14d7817..9598c1bdd8 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -3,8 +3,20 @@ import pytest from esphome.components.esp32 import const -from esphome.components.wifi import has_native_wifi, variant_has_wifi -from esphome.const import Platform +from esphome.components.wifi import ( + check_placeholder_credentials, + has_native_wifi, + variant_has_wifi, +) +from esphome.const import ( + CONF_AP, + CONF_NETWORKS, + CONF_SSID, + CONF_WIFI, + PLACEHOLDER_WIFI_SSID, + Platform, +) +from esphome.core import EsphomeError, Lambda @pytest.mark.parametrize( @@ -123,3 +135,65 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None: def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: """RP2040 without a board id falls open to True (custom-board default).""" assert has_native_wifi(platform=Platform.RP2040) is True + + +def _wifi_config( + *, + networks: list[dict] | None = None, + ap: dict | None = None, +) -> dict: + """Build a minimal config dict matching the post-validation shape.""" + wifi: dict = {} + if networks is not None: + wifi[CONF_NETWORKS] = networks + if ap is not None: + wifi[CONF_AP] = ap + return {CONF_WIFI: wifi} + + +def test_check_placeholder_credentials_passes_with_real_ssid() -> None: + """A real SSID compiles without complaint.""" + config = _wifi_config(networks=[{CONF_SSID: "home_network"}]) + assert check_placeholder_credentials(config) is None + + +def test_check_placeholder_credentials_refuses_placeholder_ssid() -> None: + """The placeholder SSID is rejected with an actionable message.""" + config = _wifi_config(networks=[{CONF_SSID: PLACEHOLDER_WIFI_SSID}]) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + message = str(exc_info.value) + assert "wifi.networks[0].ssid" in message + assert "secrets.yaml" in message + + +def test_check_placeholder_credentials_refuses_placeholder_in_second_network() -> None: + """Index reporting picks the placeholder out of a mixed network list.""" + config = _wifi_config( + networks=[ + {CONF_SSID: "home_network"}, + {CONF_SSID: PLACEHOLDER_WIFI_SSID}, + ], + ) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + assert "wifi.networks[1].ssid" in str(exc_info.value) + + +def test_check_placeholder_credentials_refuses_placeholder_ap_ssid() -> None: + """An AP using the placeholder broadcast name is also refused.""" + config = _wifi_config(ap={CONF_SSID: PLACEHOLDER_WIFI_SSID}) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + assert "wifi.ap.ssid" in str(exc_info.value) + + +def test_check_placeholder_credentials_no_wifi_passes() -> None: + """Ethernet-only / wifi-less configs skip the check entirely.""" + assert check_placeholder_credentials({}) is None + + +def test_check_placeholder_credentials_skips_template_ssid() -> None: + """A templated (Lambda) SSID is not a string and is skipped.""" + config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}]) + assert check_placeholder_credentials(config) is None From 35631be260c0fd6fae1e4c945f16790979ba777c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:31 -0700 Subject: [PATCH 1251/2214] [writer] Mark storage_should_clean as public API for device-builder (#16443) --- esphome/writer.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/esphome/writer.py b/esphome/writer.py index 72c2c355dc..ad3877465d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -87,6 +87,21 @@ def replace_file_content(text, pattern, repl): def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: + """Return True when the build tree must be wiped before reuse. + + Predicate is True when *old* is missing (first build), + ``src_version`` differs, ``build_path`` differs, or a previously + loaded integration was removed in *new*. Adding integrations or + changing unrelated fields (friendly name, esphome version, etc.) + does not trigger a clean. + + Used by esphome-device-builder (esphome/device-builder) to gate + its remote-build artifact materialiser so a local → remote → local + cycle preserves PlatformIO's local object cache instead of wiping + it on every cycle. The signature, semantics, and ``None`` handling + for *old* are part of the public contract; keep them stable so the + offloader's wipe decision tracks core's. + """ if old is None: return True From 47eb2adbf2c789d62405d69954d468ddb51d40a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:29:15 -0700 Subject: [PATCH 1252/2214] [core] Fix KeyError: 'esp32' on upload when validated-config cache is used (#16457) --- esphome/storage_json.py | 13 +++++- tests/unit_tests/test_compiled_config.py | 56 ++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 7d26b22f96..e481827080 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -273,10 +273,21 @@ class StorageJSON: """ CORE.name = self.name CORE.build_path = self.build_path + target_platform = self.core_platform or self.target_platform.lower() CORE.data[KEY_CORE] = { - KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(), + KEY_TARGET_PLATFORM: target_platform, KEY_TARGET_FRAMEWORK: self.framework, } + # The compile pipeline populates CORE.data[KEY_ESP32] when esp32's + # validator runs; on the cache fast path that validator is skipped, + # so populate the variant upload_using_esptool reads via + # esp32.get_esp32_variant(). target_platform on disk is the variant + # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). + if target_platform == const.PLATFORM_ESP32: + from esphome.components.esp32.const import KEY_ESP32 + from esphome.const import KEY_VARIANT + + CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform} def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 34e811b97b..8c9cfa8101 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -22,6 +22,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + KEY_VARIANT, ) from esphome.core import CORE @@ -47,7 +48,12 @@ wifi: """ -def _write_storage(storage_path: Path) -> None: +def _write_storage( + storage_path: Path, + *, + esp_platform: str = "ESP32", + core_platform: str | None = "esp32", +) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) data = { @@ -59,14 +65,14 @@ def _write_storage(storage_path: Path) -> None: "src_version": 1, "address": "192.168.1.42", "web_port": None, - "esp_platform": "ESP32", + "esp_platform": esp_platform, "build_path": "/build/lite_test", "firmware_bin_path": "/build/lite_test/firmware.bin", "loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_platforms": [], "no_mdns": False, "framework": "arduino", - "core_platform": "esp32", + "core_platform": core_platform, } storage_path.write_text(json.dumps(data)) @@ -123,6 +129,50 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert CORE.build_path == Path("/build/lite_test") assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" + # upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32]. + from esphome.components.esp32.const import KEY_ESP32 + + assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32" + + +def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: + """ESP32 variants survive the cache fast path so esptool gets the right --chip.""" + from esphome.components.esp32.const import KEY_ESP32 + + 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", esp_platform="ESP32S3") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is not None + assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32S3" + + +def test_load_compiled_config_skips_esp32_block_for_other_platforms( + tmp_path: Path, +) -> None: + """Non-esp32 targets shouldn't fabricate an esp32 data block.""" + from esphome.components.esp32.const import KEY_ESP32 + + 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", + esp_platform="ESP8266", + core_platform="esp8266", + ) + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is not None + assert KEY_ESP32 not in CORE.data @pytest.mark.parametrize( From 65d6bb18ed09c4d916ed7c6f1ad98eae2284a34b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 15 May 2026 13:32:51 -0400 Subject: [PATCH 1253/2214] [esp32_hosted][fingerprint_grow] Fix two remaining ESP32 toolchain warnings (#16442) --- esphome/components/esp32_hosted/update/esp32_hosted_update.cpp | 2 +- esphome/components/fingerprint_grow/fingerprint_grow.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 7f3ba77895..70fa41b312 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -121,7 +121,7 @@ void Esp32HostedUpdate::setup() { } } else { ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", - app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); + app_desc->magic_word, static_cast(ESP_APP_DESC_MAGIC_WORD)); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 3f57789034..b38d42191b 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() { break; case ENROLL_MISMATCH: ESP_LOGE(TAG, "Scans do not match"); + [[fallthrough]]; default: return this->data_[0]; } From fb70095ba194b3d01f1a0801dd2cfdec9d7e0290 Mon Sep 17 00:00:00 2001 From: david-collett Date: Sat, 16 May 2026 03:47:26 +1000 Subject: [PATCH 1254/2214] [esp32_ble_server] Fix incorrect BLECharacteristic read truncation (#16420) (#16422) Co-authored-by: Dave Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../esp32_ble_server/ble_characteristic.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index cc519846be..842c78a8aa 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -218,13 +218,14 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt } } else { response.attr_value.offset = 0; - if (this->value_.size() + 1 > max_offset) { - response.attr_value.len = max_offset; - this->value_read_offset_ = max_offset; - } else { - response.attr_value.len = this->value_.size(); + response.attr_value.len = this->value_.size(); + if (response.attr_value.len > ESP_GATT_MAX_ATTR_LEN) { + ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", response.attr_value.len, + ESP_GATT_MAX_ATTR_LEN); + response.attr_value.len = ESP_GATT_MAX_ATTR_LEN; } memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len); + this->value_read_offset_ = 0; } response.attr_value.handle = this->handle_; From 59f8c1019f23b603344014ddba4258e95f4e30c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:37:28 -0700 Subject: [PATCH 1255/2214] Bump pytest-codspeed from 5.0.1 to 5.0.2 (#16459) 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 9050132e70..ea4941a882 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ hypothesis==6.92.1 # CodSpeed benchmarks under tests/benchmarks/python/ # (skipped via pytest.importorskip when missing -- only required for the # benchmarks job in .github/workflows/ci.yml) -pytest-codspeed==5.0.1 +pytest-codspeed==5.0.2 # Used by the import-time regression check (.github/workflows/ci.yml → import-time job) importtime-waterfall==1.0.0 From ec6669fa679ffed9d371c42ad8d3c9ee935b0bb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:37:41 -0700 Subject: [PATCH 1256/2214] Bump requests from 2.34.1 to 2.34.2 (#16460) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6291b5cd41..a0a7ea5674 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 -requests==2.34.1 +requests==2.34.2 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 4c090c6b8512adf16cf45fc0b71a031bb0b12407 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:37:56 -0700 Subject: [PATCH 1257/2214] Bump github/codeql-action from 4.35.4 to 4.35.5 (#16461) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0a4dd9a92d..fbef0f5157 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@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: category: "/language:${{matrix.language}}" From b78b78cbbbf1ee8ca65ef6ff563faadb449fe2ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:42:51 -0700 Subject: [PATCH 1258/2214] Bump aioesphomeapi from 45.0.0 to 45.0.1 (#16467) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a0a7ea5674..33a6c5b555 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.0 +aioesphomeapi==45.0.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From ff34e1061bc9ce55e39ff61f04a57a0258e94c76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:43:08 -0700 Subject: [PATCH 1259/2214] Bump resvg-py from 0.3.1 to 0.3.2 (#16466) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 33a6c5b555..1996de0928 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.2.0 -resvg-py==0.3.1 +resvg-py==0.3.2 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From 1a287bf785af01ce50b52dcecc5a938babd75a01 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 16 May 2026 08:30:04 +1000 Subject: [PATCH 1260/2214] [ft5x06] Fix setting calibration values (#16446) --- .../ft5x06/touchscreen/ft5x06_touchscreen.cpp | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp index 835dc4aac0..24d3529fb4 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp @@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() { this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); } + // reading the chip registers to get max x/y does not seem to work. + if (this->display_ != nullptr) { + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } + } + // wait 200ms after reset. this->set_timeout(200, [this] { this->continue_setup_(); }); } @@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() { this->mark_failed(); return; } - // reading the chip registers to get max x/y does not seem to work. - if (this->display_ != nullptr) { - if (this->x_raw_max_ == this->x_raw_min_) { - this->x_raw_max_ = this->display_->get_native_width(); - } - if (this->y_raw_max_ == this->y_raw_min_) { - this->y_raw_max_ = this->display_->get_native_height(); - } - } } void FT5x06Touchscreen::update_touches() { @@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() { uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]); uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]); - ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); + ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); if (status == 0 || status == 2) { this->add_raw_touch_position_(id, x, y); } From df100681e0286ad13557a81027d46f962a21f349 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 16 May 2026 08:36:53 +1000 Subject: [PATCH 1261/2214] [lvgl] Fix image define (#16468) --- esphome/components/lvgl/__init__.py | 5 +++++ esphome/components/lvgl/lvgl_esphome.h | 10 ++++++---- esphome/components/lvgl/styles.py | 2 ++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 91b101cd25..4277c14dd7 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -55,6 +55,7 @@ from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, LOGGER, + add_lv_use, get_focused_widgets, get_lv_images_used, get_refreshed_widgets, @@ -71,6 +72,7 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code from .lv_validation import lv_bool from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( + BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, STYLE_REMAP, @@ -100,6 +102,7 @@ from .widgets import ( get_screen_active, set_obj_properties, ) +from .widgets.img import CONF_IMAGE # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -433,6 +436,8 @@ async def to_code(configs): # This must be done after all widgets are created styles_used = df.get_styles_used() + if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used): + add_lv_use(CONF_IMAGE) for use in df.get_lv_uses(): df.add_define(f"LV_USE_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}") diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 218f9a60ab..3f7f1dce14 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { lv_style_set_text_font(style, font->get_lv_font()); } #endif -#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -#if LV_USE_IMAGE + +#ifdef USE_IMAGE +#ifdef USE_LVGL_IMAGE // Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } -#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); @@ -93,7 +93,8 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) { inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); } -#endif // USE_LVGL_IMAGE +#endif + #ifdef USE_LVGL_ANIMIMG inline void lv_animimg_set_src(lv_obj_t *img, std::vector images) { auto *dsc = static_cast *>(lv_obj_get_user_data(img)); @@ -109,6 +110,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size()); } #endif // USE_LVGL_ANIMIMG +#endif // USE_IMAGE #ifdef USE_LVGL_METER int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index c1441526f9..5911505555 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -9,6 +9,7 @@ from .defines import ( CONF_THEME, LValidator, add_lv_use, + get_styles_used, get_theme_widget_map, literal, ) @@ -25,6 +26,7 @@ def has_style_props(config) -> bool: async def style_set(svar, style): for prop, validator in ALL_STYLES.items(): if (value := style.get(prop)) is not None: + get_styles_used().add(prop) if isinstance(validator, LValidator): value = await validator.process(value) if isinstance(value, list): From 48d17571c82d8a048d263595865d2cf15bce2225 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 15:50:58 -0700 Subject: [PATCH 1262/2214] [tests] Mock determine_cpp_unit_tests in clang_tidy_mode tests (#16456) --- tests/script/test_determine_jobs.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 3fd5eada94..202ae9030f 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1518,6 +1518,7 @@ def test_clang_tidy_mode_full_scan( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -1529,6 +1530,9 @@ def test_clang_tidy_mode_full_scan( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False + # Without this mock, main() runs the real determine_cpp_unit_tests + # which loads the full component graph (~5s import of every component). + mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files mock_changed_files.return_value = [] @@ -1584,6 +1588,7 @@ def test_clang_tidy_mode_targeted_scan( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -1595,6 +1600,9 @@ def test_clang_tidy_mode_targeted_scan( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False + # Without this mock, main() runs the real determine_cpp_unit_tests + # which loads the full component graph (~5s import of every component). + mock_determine_cpp_unit_tests.return_value = (False, []) # Create component names components = [f"comp{i}" for i in range(component_count)] @@ -2651,6 +2659,15 @@ def test_main_force_all_overrides_detection( return_value={"should_run": "false"}, ), patch.object(determine_jobs, "should_run_benchmarks", return_value=False), + # create_intelligent_batches scans every tests/components//*.yaml + # under --force-all (~2500 YAML loads, ~10s in CI). This test only + # asserts that main() routes to it and returns non-empty -- the + # batching logic itself has its own dedicated tests. + patch.object( + determine_jobs, + "create_intelligent_batches", + return_value=([["fake_batch"]], None), + ), ): determine_jobs.main() From fb0bfea1c8955d24667da80872845598778d4865 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 22:06:33 -0700 Subject: [PATCH 1263/2214] Bump aioesphomeapi from 45.0.1 to 45.0.2 (#16469) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1996de0928..d117e47d91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.1 +aioesphomeapi==45.0.2 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 7c5d5f75dc378683b02e645f435153c3891a3226 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 22:16:52 -0700 Subject: [PATCH 1264/2214] [ci] Use larger app partition for esp32-s3-idf component test grouping (#16430) --- .../build_components_base.esp32-s3-idf.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_build_components/build_components_base.esp32-s3-idf.yaml b/tests/test_build_components/build_components_base.esp32-s3-idf.yaml index ee209000e9..f3122f977e 100644 --- a/tests/test_build_components/build_components_base.esp32-s3-idf.yaml +++ b/tests/test_build_components/build_components_base.esp32-s3-idf.yaml @@ -7,6 +7,9 @@ esp32: variant: ESP32S3 framework: type: esp-idf + # Use custom partition table with larger app partition (3MB) + # Default IDF partitions only allow 1.75MB which is too small for grouped tests + partitions: ../partitions_testing.csv logger: level: VERY_VERBOSE From 01c0d3163e4f0eedd4efc29dbf8668d9b8ff4d2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 18:41:09 -0700 Subject: [PATCH 1265/2214] Bump aioesphomeapi from 45.0.2 to 45.0.3 (#16479) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d117e47d91..eddc403820 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.2 +aioesphomeapi==45.0.3 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 155232875a671e903e62565764784fbb50bcd79e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 19:08:19 -0700 Subject: [PATCH 1266/2214] Bump zeroconf from 0.148.0 to 0.149.3 (#16480) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index eddc403820..6eef8ac643 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.148.0 +zeroconf==0.149.3 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 66907258603a2e96a71f85242bef096515b05705 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 17 May 2026 15:29:38 -0400 Subject: [PATCH 1267/2214] [espidf] Switch direct framework downloader to esphome-libs/esp-idf tarballs (#16484) --- esphome/espidf/framework.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 7ff373aba8..32bcf4fb3b 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -69,7 +69,7 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( os.environ.get( "ESPHOME_IDF_FRAMEWORK_MIRRORS", - "https://github.com/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip", + "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz;https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", ) ) From 42ad2a627281c4826f14312c18eecae0666fc066 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 18 May 2026 10:49:03 +1200 Subject: [PATCH 1268/2214] [espidf] Accept list input in _str_to_lst_of_str helper (#16485) --- esphome/espidf/framework.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 32bcf4fb3b..8996ff1e02 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -26,16 +26,18 @@ _LOGGER = logging.getLogger(__name__) _SCRIPTS_DIR = Path(__file__).parent -def _str_to_lst_of_str(a: str) -> list[str]: +def _str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string Args: - a: A string containing semicolon-separated values + a: A string containing semicolon-separated values, or an already-split list Returns: list of strings """ + if isinstance(a, list): + return a return list(f.strip() for f in a.split(";") if f.strip()) @@ -67,10 +69,11 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( ) ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( - os.environ.get( - "ESPHOME_IDF_FRAMEWORK_MIRRORS", - "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz;https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", - ) + os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") + or [ + "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", + "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", + ] ) ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str( From c863d589992079b43998471eb80ad783691fd290 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 23:19:30 -0400 Subject: [PATCH 1269/2214] [espidf] Stop perpetual reconfigure loop on native ESP-IDF builds (#16415) --- esphome/components/esp32/__init__.py | 7 ++++++- esphome/espidf/toolchain.py | 30 ++++++++++++---------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1eb0bb2174..0c24dbf7b9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2509,7 +2509,12 @@ def _write_idf_component_yml(): stubs_dir = CORE.relative_build_path("component_stubs") stubs_dir.mkdir(exist_ok=True) - for component_name in components_to_stub: + # Sort so the dict insertion order (and thus the generated + # src/idf_component.yml) is deterministic across runs; otherwise + # the manifest content shuffles every build, write_file_if_changed + # always writes, and ninja keeps triggering CMake re-runs on + # otherwise-cached rebuilds. + for component_name in sorted(components_to_stub): # Create stub directory with minimal CMakeLists.txt stub_path = stubs_dir / _idf_component_stub_name(component_name) stub_path.mkdir(exist_ok=True) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 583f340996..1245c643e1 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -191,13 +191,19 @@ def run_reconfigure() -> int: def has_outdated_files(): """Check if the build configuration is stale. - Returns True if required build files are missing or if configuration inputs - are newer than the generated CMake/Ninja build artifacts. + Returns True if required build files are missing or if external + configuration inputs (IDF install, sdkconfig, CMake's own build/config + dir) are newer than CMakeCache.txt. We deliberately don't watch the + top-level/src ``CMakeLists.txt`` here -- those are written by + ``write_project`` via ``write_file_if_changed`` (so an mtime bump + means our content actually changed) and ninja already tracks them as + configure-time deps via ``build.ninja``. Including them in this check + causes a perpetual reconfigure loop: the two-pass write leaves + CMakeLists newer than CMakeCache.txt, and CMake doesn't restamp the + cache when only ``idf_build_set_property`` values change, so the + check would trip on every subsequent build. """ cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") - - cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt") - cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt") build_config_path = CORE.relative_build_path("build/config") sdkconfig_internal_path = CORE.relative_build_path( f"sdkconfig.{CORE.name}.esphomeinternal" @@ -221,8 +227,6 @@ def has_outdated_files(): os.path.getmtime(f) > cmakecache_txt_mtime for f in [ _get_idf_path(), - cmakelists_txt_build_path, - cmakelists_txt_src_path, sdkconfig_internal_path, build_config_path, ] @@ -302,21 +306,13 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) - # The post-discovery rewrite leaves CMakeLists newer than - # CMakeCache.txt. CMake won't re-touch CMakeCache.txt on a - # configure that only changes idf_build_set_property values - # (those aren't cache variables), so has_outdated_files() would - # return True on every subsequent build, perpetually retriggering - # the two-pass. Touch CMakeCache.txt now so its mtime stays past - # the rewritten CMakeLists. - 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 -- # idf.py build would otherwise re-run cmake and regenerate # memory.ld, wiping the DRAM/IRAM patches applied below. + # Outside testing mode ninja's own configure-time dep on + # CMakeLists.txt handles the re-run as part of the build step. rc = run_reconfigure() if rc != 0: _LOGGER.error("Reconfigure with discovered components failed") From 84b5931299de172ba87dc9c5cfdf88ba7ae15773 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 00:02:22 -0400 Subject: [PATCH 1270/2214] [espidf] Trim has_outdated_files watch list; embed IDF version in sdkconfig (#16416) --- esphome/components/esp32/__init__.py | 8 ++++- esphome/espidf/toolchain.py | 45 +++++++++++++++++----------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0c24dbf7b9..f112549832 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2464,8 +2464,14 @@ def _write_sdkconfig(): ) want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + # Include the resolved framework version as a Kconfig comment so a + # version switch that happens to leave the option set unchanged still + # bumps this file's content -- which is what has_outdated_files() + # uses to decide whether to reconfigure. + framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] contents = ( - "\n".join( + f"# ESPHOME_IDF_VERSION={framework_version}\n" + + "\n".join( f"{name}={_format_sdkconfig_val(value)}" for name, value in sorted(want_opts.items()) ) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 1245c643e1..e0bc5bb393 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -191,23 +191,38 @@ def run_reconfigure() -> int: def has_outdated_files(): """Check if the build configuration is stale. - Returns True if required build files are missing or if external - configuration inputs (IDF install, sdkconfig, CMake's own build/config - dir) are newer than CMakeCache.txt. We deliberately don't watch the - top-level/src ``CMakeLists.txt`` here -- those are written by - ``write_project`` via ``write_file_if_changed`` (so an mtime bump - means our content actually changed) and ninja already tracks them as - configure-time deps via ``build.ninja``. Including them in this check - causes a perpetual reconfigure loop: the two-pass write leaves - CMakeLists newer than CMakeCache.txt, and CMake doesn't restamp the - cache when only ``idf_build_set_property`` values change, so the - check would trip on every subsequent build. + Returns True if required build files are missing or if ESPHome's + resolved build inputs are newer than CMakeCache.txt: + + - ``sdkconfig..esphomeinternal`` -- the canonical "what state + did ESPHome resolve the YAML to" snapshot. Any change in build + flags, enabled components, framework version, or target ends up + rewriting it (we embed a ``# ESPHOME_IDF_VERSION=`` comment line + for the version case where the option set would otherwise be + identical). + - ``src/idf_component.yml`` -- the project manifest. Managed + component additions/removals (e.g. via ``add_idf_component``) can + 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. + + We deliberately don't watch: + - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and + ninja already tracks them as configure-time deps. Including them + causes a perpetual reconfigure loop because CMake doesn't restamp + ``CMakeCache.txt`` when only ``idf_build_set_property`` values + change between configures. + - ``$IDF_PATH`` and CMake's ``build/config/`` -- both have mtime + semantics that fire after the wrong configure (or not at all in + common cases like in-place IDF version replacement). The sdkconfig + and manifest hashes subsume the meaningful signal. """ cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") build_config_path = CORE.relative_build_path("build/config") sdkconfig_internal_path = CORE.relative_build_path( f"sdkconfig.{CORE.name}.esphomeinternal" ) + idf_component_yml_path = CORE.relative_build_path("src/idf_component.yml") dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") @@ -225,12 +240,8 @@ def has_outdated_files(): cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) return any( os.path.getmtime(f) > cmakecache_txt_mtime - for f in [ - _get_idf_path(), - sdkconfig_internal_path, - build_config_path, - ] - if f and os.path.exists(f) + for f in [sdkconfig_internal_path, idf_component_yml_path] + if f.exists() ) From ab273a1f8fe4419bd67d9e2a2c05ed222a0beca7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 14 May 2026 15:07:38 -0500 Subject: [PATCH 1271/2214] [tinyusb] Reject `tinyusb:` configured without a USB class companion (#16413) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/tinyusb/__init__.py | 20 +++++++++++++++++++ .../components/tinyusb/tinyusb_component.cpp | 15 ++++++++++++++ tests/components/tinyusb/common.yaml | 5 +++++ 3 files changed, 40 insertions(+) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index df94ad7534..724f65721b 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,3 +1,4 @@ +from esphome import final_validate as fv import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -20,6 +21,13 @@ CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +# Components that provide a USB device class (CDC, HID, MSC, ...) on top of +# tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in +# esp_tinyusb's driver install (descriptors_set fails with no class and no +# user-provided full_speed_config), which trips the task watchdog before +# loop() ever runs. +_USB_CLASS_COMPONENTS = ("usb_cdc_acm",) + tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) @@ -41,6 +49,18 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config): + full_config = fv.full_config.get() + if not any(name in full_config for name in _USB_CLASS_COMPONENTS): + raise cv.Invalid( + "The 'tinyusb' component requires at least one USB class component" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 2ec696c3e4..3cefc0454a 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -26,6 +26,21 @@ void TinyUSB::setup() { .string_count = SIZE, }; + // Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with + // ESP_ERR_INVALID_ARG when no configuration descriptor is provided and + // no class that has a built-in default (CDC/MSC/NCM) is compiled in. In + // that case the internal task exits without notifying us, and + // tinyusb_driver_install() blocks 5s on the notify-take -- long enough + // to trip the task watchdog. Bail early so the rest of the device can + // still boot. +#if !(CFG_TUD_CDC > 0 || CFG_TUD_MSC > 0 || CFG_TUD_NCM > 0) + if (this->tusb_cfg_.descriptor.full_speed_config == nullptr) { + ESP_LOGE(TAG, "No USB class configured"); + this->mark_failed(); + return; + } +#endif + esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); diff --git a/tests/components/tinyusb/common.yaml b/tests/components/tinyusb/common.yaml index cb3f48836a..674e89dbe8 100644 --- a/tests/components/tinyusb/common.yaml +++ b/tests/components/tinyusb/common.yaml @@ -6,3 +6,8 @@ tinyusb: usb_product_str: ESPHomeTestProduct usb_serial_str: ESPHomeTestSerialNumber usb_vendor_id: 0x2345 + +# tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that. +usb_cdc_acm: + interfaces: + - id: tinyusb_test_cdc From fb659f9ac4e35022d87317a4bbfab8af9076cba0 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 14 May 2026 15:29:56 -0500 Subject: [PATCH 1272/2214] [tinyusb] Reject `logger.hardware_uart: USB_CDC` (#16417) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/tinyusb/__init__.py | 13 ++++++++++++- tests/components/tinyusb/test.esp32-s2-idf.yaml | 5 +++++ tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml | 5 +++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 724f65721b..0e02ff8724 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -9,7 +9,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, ) import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_HARDWARE_UART, CONF_ID CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] @@ -55,6 +55,17 @@ def _final_validate(config): raise cv.Invalid( "The 'tinyusb' component requires at least one USB class component" ) + # tinyusb owns the USB OTG peripheral. The logger's USB_CDC backend routes + # the ROM console through that same peripheral, so the two cannot coexist. + # (USB_SERIAL_JTAG is a separate peripheral and is fine alongside tinyusb.) + logger_config = full_config.get("logger") + if logger_config and logger_config.get(CONF_HARDWARE_UART) == "USB_CDC": + raise cv.Invalid( + "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " + "because both share the USB OTG peripheral. Set " + "'logger.hardware_uart' to a hardware UART (e.g. UART0), or to " + "USB_SERIAL_JTAG on variants that support it (ESP32-S3, ESP32-P4)" + ) return config diff --git a/tests/components/tinyusb/test.esp32-s2-idf.yaml b/tests/components/tinyusb/test.esp32-s2-idf.yaml index dade44d145..09b98ada40 100644 --- a/tests/components/tinyusb/test.esp32-s2-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s2-idf.yaml @@ -1 +1,6 @@ <<: !include common.yaml + +# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared +# USB OTG peripheral; route the logger to UART0 so the fixture builds. +logger: + hardware_uart: UART0 diff --git a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml index f159b38ff6..ff75731509 100644 --- a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml +++ b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml @@ -1,5 +1,10 @@ <<: !include tinyusb_common.yaml +# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared +# USB OTG peripheral; route the logger to UART0 so the fixture builds. +logger: + hardware_uart: UART0 + usb_cdc_acm: interfaces: - id: usb_cdc_acm1 From dd1818661c29d03f83153e95ba3bbd5dd40c4796 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:39:16 -0400 Subject: [PATCH 1273/2214] [esp32] Sweep ESP-IDF toolchain warnings + bump deprecated mark_failed (#16432) --- esphome/components/esp32/__init__.py | 4 +++- esphome/components/hdc2080/hdc2080.cpp | 2 +- esphome/components/heatpumpir/climate.py | 1 - esphome/components/pulse_meter/pulse_meter_sensor.cpp | 4 ++-- esphome/components/sim800l/sim800l.cpp | 2 +- esphome/components/tuya/tuya.cpp | 2 ++ esphome/components/tx20/tx20.cpp | 6 +++--- esphome/components/usb_uart/usb_uart.h | 2 +- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- esphome/components/wiegand/wiegand.cpp | 4 ++-- 11 files changed, 17 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f112549832..e9b0f1fd0a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1767,9 +1767,11 @@ async def to_code(config): else: cg.add_build_flag("-Wno-error=format") cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=missing-field-initializers") + cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") + # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates + cg.add_build_flag("-Wno-missing-field-initializers") cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index dcb207e099..bf3a4bc79f 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -22,7 +22,7 @@ static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only void HDC2080Component::setup() { const uint8_t data = 0x00; // automatic measurement mode disabled, heater off if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index b7e0437480..aa3a08c294 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -125,7 +125,6 @@ async def to_code(config): cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_library("tonia/HeatpumpIR", "1.0.41") if CORE.is_libretiny or CORE.is_esp32: diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.cpp b/esphome/components/pulse_meter/pulse_meter_sensor.cpp index 3fe1c722eb..d6959d1a96 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.cpp +++ b/esphome/components/pulse_meter/pulse_meter_sensor.cpp @@ -150,7 +150,7 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) { edge_state.last_sent_edge_us_ = now; state.last_detected_edge_us_ = now; state.last_rising_edge_us_ = now; - state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile) + state.count_ += 1; } // This ISR is bound to rising edges, so the pin is high @@ -173,7 +173,7 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) { } else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge pulse_state.latched_ = true; state.last_detected_edge_us_ = pulse_state.last_intr_; - state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile) + state.count_ += 1; } // Due to order of operations this includes diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index b8e97b1121..13b9888e05 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -126,7 +126,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { break; } - // Else fall thru ... + [[fallthrough]]; } case STATE_CHECK_SMS: send_cmd_("AT+CMGL=\"ALL\""); diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index b29905f9a0..fd14844908 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -684,8 +684,10 @@ void Tuya::set_numeric_datapoint_value_(uint8_t datapoint_id, TuyaDatapointType case 4: data.push_back(value >> 24); data.push_back(value >> 16); + [[fallthrough]]; case 2: data.push_back(value >> 8); + [[fallthrough]]; case 1: data.push_back(value >> 0); break; diff --git a/esphome/components/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index 353cb31513..3574bd7c2d 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -135,7 +135,7 @@ void Tx20Component::decode_and_publish_() { } if (tx20_se == tx20_sb) { tx20_wind_direction = tx20_se; - if (tx20_wind_direction >= 0 && tx20_wind_direction < 16) { + if (tx20_wind_direction < 16) { wind_cardinal_direction_ = DIRECTIONS[tx20_wind_direction]; } ESP_LOGV(TAG, "WindDirection %d", tx20_wind_direction); @@ -164,7 +164,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { } arg->buffer[arg->buffer_index] = 1; arg->start_time = now; - arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->buffer_index += 1; return; } const uint32_t delay = now - arg->start_time; @@ -195,7 +195,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { } arg->spent_time += delay; arg->start_time = now; - arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->buffer_index += 1; } void IRAM_ATTR Tx20ComponentStore::reset() { tx20_available = false; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index e88c41c0cb..fb8425f6cd 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -135,7 +135,7 @@ class USBUartChannel : public uart::UARTComponent, public Parentedarg(ESPHOME_F("detail")) == "all" ? DETAIL_ALL : DETAIL_STATE; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index e1d3e4bf34..c32acaf03e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -66,7 +66,7 @@ namespace { * - HTTPD_SOCK_ERR_TIMEOUT if the send buffer is full (EAGAIN/EWOULDBLOCK). * - HTTPD_SOCK_ERR_FAIL for other errors. */ -int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) { +[[maybe_unused]] int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) { if (buf == nullptr) { return HTTPD_SOCK_ERR_INVALID; } diff --git a/esphome/components/wiegand/wiegand.cpp b/esphome/components/wiegand/wiegand.cpp index e5c29f8b11..df64cd48aa 100644 --- a/esphome/components/wiegand/wiegand.cpp +++ b/esphome/components/wiegand/wiegand.cpp @@ -11,7 +11,7 @@ static const char *const KEYS = "0123456789*#"; void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { if (arg->d0.digital_read()) return; - arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->count += 1; arg->value <<= 1; arg->last_bit_time = millis(); arg->done = false; @@ -20,7 +20,7 @@ void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { void IRAM_ATTR HOT WiegandStore::d1_gpio_intr(WiegandStore *arg) { if (arg->d1.digital_read()) return; - arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->count += 1; arg->value = (arg->value << 1) | 1; arg->last_bit_time = millis(); arg->done = false; From d5c6efb2fe2891cde35aeae11654b776947f3d8c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:40:40 -0400 Subject: [PATCH 1274/2214] [tests] Fix -Wformat= mismatches in test YAML lambdas/logger.log (#16435) --- esphome/components/logger/__init__.py | 14 +++++----- esphome/components/lvgl/helpers.py | 28 +++++++++---------- .../components/esp32_ble_tracker/common.yaml | 4 +-- tests/components/ld2412/common.yaml | 2 +- tests/components/lvgl/lvgl-package.yaml | 6 ++-- tests/components/modbus_server/common.yaml | 2 +- tests/components/mqtt/common.yaml | 2 +- tests/components/nextion/common.yaml | 2 +- .../remote_receiver/common-actions.yaml | 2 +- tests/components/script/common.yaml | 2 +- tests/components/udp/common.yaml | 2 +- tests/components/udp/test.host.yaml | 2 +- 12 files changed, 34 insertions(+), 34 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 9d7dc8d92c..c6c440564a 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -506,13 +506,13 @@ async def _late_logger_init(config: ConfigType) -> None: def validate_printf(value): # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python cfmt = r""" - ( # start of capture group 1 - % # literal "%" - (?:[-+0 #]{0,5}) # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + (?:[-+0 #]{0,5}) # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """ # noqa matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index baa618d472..6f70a1e3bd 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -9,13 +9,13 @@ CONF_IF_NAN = "if_nan" # noqa f_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - f # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + f # type ) """, flags=re.VERBOSE, @@ -23,13 +23,13 @@ f_regex = re.compile( # noqa c_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """, flags=re.VERBOSE, diff --git a/tests/components/esp32_ble_tracker/common.yaml b/tests/components/esp32_ble_tracker/common.yaml index 018bbb42b3..564cf1f6ea 100644 --- a/tests/components/esp32_ble_tracker/common.yaml +++ b/tests/components/esp32_ble_tracker/common.yaml @@ -29,12 +29,12 @@ esp32_ble_tracker: - service_uuid: ABCD then: - lambda: !lambda |- - ESP_LOGD("main", "Length of service data is %i", x.size()); + ESP_LOGD("main", "Length of service data is %zu", x.size()); on_ble_manufacturer_data_advertise: - manufacturer_id: ABCD then: - lambda: !lambda |- - ESP_LOGD("main", "Length of manufacturer data is %i", x.size()); + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); on_scan_end: - then: - lambda: |- diff --git a/tests/components/ld2412/common.yaml b/tests/components/ld2412/common.yaml index c5bda688dc..7a86b6fbda 100644 --- a/tests/components/ld2412/common.yaml +++ b/tests/components/ld2412/common.yaml @@ -123,7 +123,7 @@ select: - lambda: |- id(uart_bus).flush(); uint32_t new_baud_rate = stoi(x); - ESP_LOGD("change_baud_rate", "Changing baud rate from %i to %i",id(uart_bus).get_baud_rate(), new_baud_rate); + ESP_LOGD("change_baud_rate", "Changing baud rate from %" PRIu32 " to %" PRIu32, id(uart_bus).get_baud_rate(), new_baud_rate); if (id(uart_bus).get_baud_rate() != new_baud_rate) { id(uart_bus).set_baud_rate(new_baud_rate); #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 53984bb006..0f4b961297 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -660,13 +660,13 @@ lvgl: on_release: logger.log: format: Button released at %d/%d - args: [point.x, point.y] + args: ['(int) point.x', '(int) point.y'] on_long_press_repeat: logger.log: Button clicked on_pressing: logger.log: format: Button pressing at %d/%d - args: [point.x, point.y] + args: ['(int) point.x', '(int) point.y'] on_press_lost: logger.log: Button press lost on_single_click: @@ -944,7 +944,7 @@ lvgl: on_release: logger.log: format: Slider released at %d/%d with value %.0f - args: [point.x, point.y, x] + args: ['(int) point.x', '(int) point.y', x] - button: styles: spin_button id: spin_up diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 3522c9248c..2e4a81a1aa 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -21,7 +21,7 @@ modbus_server: read_lambda: |- return 31; write_lambda: |- - printf("address=%d, value=%d", x); + printf("address=%d, value=%" PRId32 "\n", (int) address, x); return true; - id: modbus_server4 modbus_id: mod_bus2 diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 8c58e9b080..6af2ce3939 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -64,7 +64,7 @@ mqtt: topic: some/topic payload: Good-bye - lambda: |- - ESP_LOGD("MQTT", "Disconnect reason %d", reason); + ESP_LOGD("MQTT", "Disconnect reason %d", (int) reason); publish_nan_as_none: false binary_sensor: diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 0616b9a41a..fba6a22b97 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -299,7 +299,7 @@ display: - lambda: |- // key: StringRef, value: int32_t if (key == "temperature_raw") { - ESP_LOGD("nextion.custom", "%s=%d", key.c_str(), value); + ESP_LOGD("nextion.custom", "%s=%" PRId32, key.c_str(), value); } on_custom_binary_sensor: then: diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 30b99eeb70..26a02d4dab 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -12,7 +12,7 @@ on_brennenstuhl: then: - logger.log: format: "on_brennenstuhl: %u" - args: ["x.code"] + args: ["(unsigned) x.code"] on_aeha: then: - logger.log: diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index c1dc68513f..f4818e2296 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -49,7 +49,7 @@ script: then: - lambda: |- ESP_LOGD("main", "ints=%d floats=%f bools=%d strings=%s", - ints[0], floats[0], bools[0], strings[0].c_str()); + ints[0], floats[0], (int) bools[0], strings[0].c_str()); - id: my_script_with_params parameters: prefix: string diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index 3466e8d2ee..a40ca455cb 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -11,7 +11,7 @@ udp: - "10.0.0.255" on_receive: - logger.log: - format: "Received %d bytes" + format: "Received %zu bytes" args: [data.size()] - udp.write: id: my_udp diff --git a/tests/components/udp/test.host.yaml b/tests/components/udp/test.host.yaml index 84e78894e5..825d86c19e 100644 --- a/tests/components/udp/test.host.yaml +++ b/tests/components/udp/test.host.yaml @@ -4,7 +4,7 @@ udp: addresses: ["239.0.60.53"] on_receive: - logger.log: - format: "Received %d bytes" + format: "Received %zu bytes" args: [data.size()] - udp.write: id: my_udp From da8286f5542a9a8a80bfd6e4590707a78b30cd04 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:51:59 -0400 Subject: [PATCH 1275/2214] [docker] Install libusb-1.0 so ESP-IDF tools can validate openocd (#16424) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docker/Dockerfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 540d28be7f..25de9472b6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,12 +13,16 @@ RUN git config --system --add safe.directory "*" \ && git config --system advice.detachedHead false # Install build tools for Python packages that require compilation -# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager) +# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager). +# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can +# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without +# it idf_tools.py rejects the openocd install with exit 127 and aborts +# the whole framework setup. RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base; \ + apk add --no-cache build-base libusb; \ else \ apt-get update \ - && apt-get install -y --no-install-recommends build-essential \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ && rm -rf /var/lib/apt/lists/*; \ fi From 3831aa809f3e5fd425165274d0c2165b58546d8d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:53:42 -0400 Subject: [PATCH 1276/2214] [multiple] Fix -Wformat= mismatches in component .cpp sources (#16433) --- esphome/components/bme680_bsec/bme680_bsec.cpp | 4 ++-- .../esp32_hosted/update/esp32_hosted_update.cpp | 6 +++--- esphome/components/esphome/ota/ota_esphome.cpp | 14 +++++++------- esphome/components/fastled_base/fastled_light.cpp | 2 +- esphome/components/inkplate/inkplate.cpp | 2 +- esphome/components/midea/air_conditioner.cpp | 4 ++-- esphome/components/ota/ota_partitions_esp_idf.cpp | 4 ++-- .../components/remote_receiver/remote_receiver.cpp | 8 ++++---- esphome/components/sendspin/sendspin_hub.cpp | 4 ++-- .../total_daily_energy/total_daily_energy.cpp | 2 +- 10 files changed, 25 insertions(+), 25 deletions(-) diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index b7f8c0da77..823f32c446 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() { " IAQ Mode: %s\n" " Supply Voltage: %sV\n" " Sample Rate: %s\n" - " State Save Interval: %ims", + " State Save Interval: %" PRIu32 "ms", this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile", this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8", BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_); @@ -461,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe } void BME680BSECComponent::delay_ms(uint32_t period) { - ESP_LOGV(TAG, "Delaying for %ums", period); + ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period); delay(period); } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index af35d32888..7f3ba77895 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -92,7 +92,7 @@ void Esp32HostedUpdate::setup() { if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { // 16 bytes: "255.255.255" (11 chars) + null + safety margin char buf[16]; - snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1); + snprintf(buf, sizeof(buf), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1); this->update_info_.current_version = buf; } else { this->update_info_.current_version = "unknown"; @@ -120,8 +120,8 @@ void Esp32HostedUpdate::setup() { this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { - ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word, - ESP_APP_DESC_MAGIC_WORD); + ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", + app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f1857ed664..fb0cc2e56d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -108,8 +108,8 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" - " Partition address: 0x%X\n" - " Used size: %zu bytes (0x%X)", + " Partition address: 0x%" PRIX32 "\n" + " Used size: %zu bytes (0x%zX)", this->running_app_offset_, this->running_app_size_, this->running_app_size_); #ifdef USE_ESP32 @@ -378,7 +378,7 @@ void ESPHomeOTAComponent::handle_data_() { } ota_size = (static_cast(buf[0]) << 24) | (static_cast(buf[1]) << 16) | (static_cast(buf[2]) << 8) | buf[3]; - ESP_LOGV(TAG, "Size is %u bytes", ota_size); + ESP_LOGV(TAG, "Size is %zu bytes", ota_size); #ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { @@ -749,7 +749,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() { this->auth_buf_[0] = this->auth_type_; hasher.get_hex(buf); - ESP_LOGV(TAG, "Auth: Nonce is %.*s", hex_size, buf); + ESP_LOGV(TAG, "Auth: Nonce is %.*s", (int) hex_size, buf); } // Try to write auth_type + nonce @@ -809,13 +809,13 @@ bool ESPHomeOTAComponent::handle_auth_read_() { hasher.add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer) hasher.calculate(); - ESP_LOGV(TAG, "Auth: CNonce is %.*s", hex_size, cnonce); + ESP_LOGV(TAG, "Auth: CNonce is %.*s", (int) hex_size, cnonce); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char computed_hash[SHA256_HEX_SIZE + 1]; // Buffer for hex-encoded hash (max expected length + null terminator) hasher.get_hex(computed_hash); - ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash); + ESP_LOGV(TAG, "Auth: Result is %.*s", (int) hex_size, computed_hash); #endif - ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response); + ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response); // Compare response bool matches = hasher.equals_hex(response); diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 8d1dd49dad..0fa69a23b4 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() { ESP_LOGCONFIG(TAG, "FastLED light:\n" " Num LEDs: %u\n" - " Max refresh rate: %u", + " Max refresh rate: %" PRIu32, this->num_leds_, this->max_refresh_rate_.value_or(0)); } void FastLEDLightOutput::write_state(light::LightState *state) { diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 39110ca83b..2e837fb614 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -319,7 +319,7 @@ void Inkplate::fill(Color color) { memset(this->partial_buffer_, fill, this->get_buffer_length_()); } - ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::display() { diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 594f7fa661..7603dd5254 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -130,8 +130,8 @@ ClimateTraits AirConditioner::traits() { void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" - " [x] Period: %dms\n" - " [x] Response timeout: %dms\n" + " [x] Period: %" PRIu32 "ms\n" + " [x] Response timeout: %" PRIu32 "ms\n" " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f91e88bde0..a7fc709313 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -210,7 +210,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, + ESP_LOGD(TAG, "Copying running app from 0x%" PRIX32 " to 0x%" PRIX32 " (size: 0x%zX)", running_app_part->address, plan.copy_dest_part->address, running_app_size); err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { @@ -261,7 +261,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); + ESP_LOGD(TAG, "Setting next boot partition to 0x%" PRIX32, new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index d59ee63695..222dae8f7f 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -78,10 +78,10 @@ void RemoteReceiverComponent::setup() { void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG(TAG, "Remote Receiver:\n" - " Buffer Size: %u\n" - " Tolerance: %u%s\n" - " Filter out pulses shorter than: %u us\n" - " Signal is done after %u us of no changes", + " Buffer Size: %" PRIu32 "\n" + " Tolerance: %" PRIu32 "%s\n" + " Filter out pulses shorter than: %" PRIu32 " us\n" + " Signal is done after %" PRIu32 " us of no changes", this->buffer_size_, this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", this->filter_us_, this->idle_us_); diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 04426b8b1d..57709306cd 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -153,7 +153,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) { LastPlayedServerPref pref{.server_id_hash = hash}; bool ok = this->last_played_server_pref_.save(&pref); if (ok) { - ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash); + ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash); } else { ESP_LOGW(TAG, "Failed to persist last played server hash"); } @@ -164,7 +164,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) { std::optional SendspinHub::load_last_server_hash() { LastPlayedServerPref pref{}; if (this->last_played_server_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash); + ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash); return pref.server_id_hash; } return std::nullopt; diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index 161c712cc1..7c9dbb604f 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -72,7 +72,7 @@ void TotalDailyEnergy::schedule_midnight_reset_() { timeout_seconds = seconds_until_midnight + 1; } - ESP_LOGD(TAG, "Scheduling midnight check in %us", timeout_seconds); + ESP_LOGD(TAG, "Scheduling midnight check in %" PRIu32 "s", timeout_seconds); this->set_timeout(TIMEOUT_ID_MIDNIGHT, timeout_seconds * MILLIS_PER_SECOND, [this]() { this->schedule_midnight_reset_(); }); } From ecac6b64ec87b1e9d22a2181e724e1cb93f2b15e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 22:41:36 -0400 Subject: [PATCH 1277/2214] [espidf] Gate esp_idf_size --ng on IDF version (#16441) --- esphome/build_gen/espidf.py | 10 ++++++++-- tests/unit_tests/build_gen/test_espidf.py | 7 ++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 5ad2072c5b..96f84ebbd1 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,8 @@ import json from pathlib import Path -from esphome.components.esp32 import get_esp32_variant +from esphome.components.esp32 import get_esp32_variant, idf_version +import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed from esphome.writer import update_storage_json @@ -61,6 +62,11 @@ def get_project_cmakelists(minimal: bool = False) -> str: variant = get_esp32_variant() idf_target = variant.lower().replace("-", "") + # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and + # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get + # --format=raw because the legacy mode doesn't support it. + size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" + # Project-wide compile options: -D defines and -W warning flags (skip # -Wl, linker flags — those go on the src component via # target_link_options below). Emitted via idf_build_set_property so the @@ -146,7 +152,7 @@ project({CORE.name}) # Emit raw JSON size data for ESPHome to read post-build. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD - COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw + COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 36f0442355..540dd06731 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -11,10 +11,12 @@ import pytest from esphome.components.esp32 import ( KEY_COMPONENTS, KEY_ESP32, + KEY_IDF_VERSION, KEY_PATH, KEY_REF, KEY_REPO, ) +import esphome.config_validation as cv from esphome.const import KEY_CORE from esphome.core import CORE @@ -24,7 +26,10 @@ def _reset_core(tmp_path: Path) -> None: """Give each test its own CORE.build_path and a clean esp32 data slot.""" CORE.build_path = str(tmp_path) CORE.data.setdefault(KEY_CORE, {}) - CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}} + CORE.data[KEY_ESP32] = { + KEY_COMPONENTS: {}, + KEY_IDF_VERSION: cv.Version(5, 5, 4), + } def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None: From c037058c199ff660e828474e2b284cca787b5f9b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 22:51:14 -0400 Subject: [PATCH 1278/2214] [esp32_hosted] Bump esp_hosted to 2.12.7 (#16440) --- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index eca7c24b10..71d1fd3ac1 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -249,7 +249,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 814a4031c1..49c4cdbb2e 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -36,7 +36,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.6 + version: 2.12.7 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 4f895425cac87e79e7b0396d41994d08181fd438 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 14 May 2026 23:31:11 -0400 Subject: [PATCH 1279/2214] [audio] Bump microMP3 to v0.2.1 (#16429) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 44371e87ab..13b379ba3a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.0") + add_idf_component(name="esphome/micro-mp3", ref="0.2.1") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 49c4cdbb2e..35c55cbb4d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.0 + version: 0.2.1 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From 25dbef83de8d4168133dbba41cf4d76cc79c6f59 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 14 May 2026 23:33:36 -0400 Subject: [PATCH 1280/2214] [sound_level] Use RingBufferAudioSource (#16436) --- .../components/sound_level/sound_level.cpp | 58 ++++++++++--------- esphome/components/sound_level/sound_level.h | 9 +-- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index fb8bfd3085..a93e396367 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -11,7 +11,7 @@ namespace esphome::sound_level { static const char *const TAG = "sound_level"; -static const uint32_t AUDIO_BUFFER_DURATION_MS = 30; +static const uint32_t MAX_FILL_DURATION_MS = 30; static const uint32_t RING_BUFFER_DURATION_MS = 120; // Square INT16_MIN since INT16_MIN^2 > INT16_MAX^2 @@ -30,8 +30,7 @@ void SoundLevelComponent::dump_config() { void SoundLevelComponent::setup() { this->microphone_source_->add_data_callback([this](const std::vector &data) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() == 2) { - // ``audio_buffer_`` and ``temp_ring_buffer`` share ownership of a ring buffer, so its safe/useful to write + if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } }); @@ -81,10 +80,11 @@ void SoundLevelComponent::loop() { return; } - // Copy data from ring buffer into the transfer buffer - don't block to avoid slowing the main loop - this->audio_buffer_->transfer_data_from_source(0); + // Expose a chunk of the ring buffer's internal storage - don't block to avoid slowing the main loop. + // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). + this->audio_source_->fill(0, false); - if (this->audio_buffer_->available() == 0) { + if (this->audio_source_->available() == 0) { // No new audio available for processing return; } @@ -92,11 +92,11 @@ void SoundLevelComponent::loop() { const uint32_t samples_in_window = this->microphone_source_->get_audio_stream_info().ms_to_samples(this->measurement_duration_ms_); const uint32_t samples_available_to_process = - this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_buffer_->available()); + this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_source_->available()); const uint32_t samples_to_process = std::min(samples_in_window - this->sample_count_, samples_available_to_process); // MicrophoneSource always provides int16 samples due to Python codegen settings - const int16_t *audio_data = reinterpret_cast(this->audio_buffer_->get_buffer_start()); + const int16_t *audio_data = reinterpret_cast(this->audio_source_->data()); // Process all the new audio samples for (uint32_t i = 0; i < samples_to_process; ++i) { @@ -115,9 +115,8 @@ void SoundLevelComponent::loop() { ++this->sample_count_; } - // Remove the processed samples from ``audio_buffer_`` - this->audio_buffer_->decrease_buffer_length( - this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process)); + // Remove the processed samples from ``audio_source_`` + this->audio_source_->consume(this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process)); if (this->sample_count_ == samples_in_window) { // Processed enough samples for the measurement window, compute and publish the sensor values @@ -158,36 +157,39 @@ void SoundLevelComponent::stop() { } bool SoundLevelComponent::start_() { - if (this->audio_buffer_ != nullptr) { + if (this->audio_source_ != nullptr) { return true; } - // Allocate a transfer buffer - this->audio_buffer_ = audio::AudioSourceTransferBuffer::create( - this->microphone_source_->get_audio_stream_info().ms_to_bytes(AUDIO_BUFFER_DURATION_MS)); - if (this->audio_buffer_ == nullptr) { - this->status_momentary_error("transfer_buffer", 15000); + const auto &stream_info = this->microphone_source_->get_audio_stream_info(); + const size_t bytes_per_frame = stream_info.frames_to_bytes(1); + + // Allocate a ring buffer for the microphone callback to write into. Round the size down to a multiple + // of bytes_per_frame so the wrap boundary stays frame-aligned and avoids unnecessary single-frame splices. + this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation + const size_t ring_buffer_size = + (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer == nullptr) { + this->status_momentary_error("ring_buffer", 15000); return false; } - // Allocates a new ring buffer, adds it as a source for the transfer buffer, and points ring_buffer_ to it - this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( - this->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); - if (temp_ring_buffer.use_count() == 0) { - this->status_momentary_error("ring_buffer", 15000); - this->stop_(); + // Zero-copy source that reads directly from the ring buffer's internal storage. Frame-aligned reads + // ensure multi-channel frames are never split across the ring buffer's wrap boundary. + this->audio_source_ = audio::RingBufferAudioSource::create( + temp_ring_buffer, stream_info.ms_to_bytes(MAX_FILL_DURATION_MS), static_cast(bytes_per_frame)); + if (this->audio_source_ == nullptr) { + this->status_momentary_error("audio_source", 15000); return false; - } else { - this->ring_buffer_ = temp_ring_buffer; - this->audio_buffer_->set_source(temp_ring_buffer); } + this->ring_buffer_ = temp_ring_buffer; this->status_clear_error(); return true; } -void SoundLevelComponent::stop_() { this->audio_buffer_.reset(); } +void SoundLevelComponent::stop_() { this->audio_source_.reset(); } } // namespace esphome::sound_level diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index 4f0081a510..aabea62ca4 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -36,11 +36,12 @@ class SoundLevelComponent : public Component { void stop(); protected: - /// @brief Internal start command that, if necessary, allocates ``audio_buffer_`` and a ring buffer which - /// ``audio_buffer_`` owns and ``ring_buffer_`` points to. Returns true if allocations were successful. + /// @brief Internal start command that, if necessary, allocates a ring buffer and a zero-copy + /// ``RingBufferAudioSource`` that reads directly from it. ``ring_buffer_`` weakly references the + /// ring buffer owned by ``audio_source_``. Returns true if allocations were successful. bool start_(); - /// @brief Internal stop command the deallocates ``audio_buffer_`` (which automatically deallocates its ring buffer) + /// @brief Internal stop command that deallocates ``audio_source_`` (which releases its ring buffer) void stop_(); microphone::MicrophoneSource *microphone_source_{nullptr}; @@ -48,7 +49,7 @@ class SoundLevelComponent : public Component { sensor::Sensor *peak_sensor_{nullptr}; sensor::Sensor *rms_sensor_{nullptr}; - std::unique_ptr audio_buffer_; + std::unique_ptr audio_source_; std::weak_ptr ring_buffer_; int32_t squared_peak_{0}; From 50495c7085d618e28fa7f75531eb739673fe5d98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:15 -0700 Subject: [PATCH 1281/2214] [wifi] Refuse to compile when wifi_ssid is the device-builder placeholder (#16444) --- esphome/__main__.py | 8 +++ esphome/components/wifi/__init__.py | 52 +++++++++++++++- esphome/const.py | 9 +++ tests/unit_tests/components/test_wifi.py | 78 +++++++++++++++++++++++- 4 files changed, 144 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d733534a5c..16a05ad552 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -50,6 +50,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USERNAME, CONF_WEB_SERVER, + CONF_WIFI, ENV_NOGITIGNORE, KEY_CORE, KEY_TARGET_PLATFORM, @@ -733,6 +734,13 @@ def write_cpp_file() -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int: + # Keep this gate here, NOT in config validation: device-builder needs + # `esphome config` to keep succeeding with placeholders so onboarding can run. + if CONF_WIFI in config: + from esphome.components.wifi import check_placeholder_credentials + + check_placeholder_credentials(config) + # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index bad57fc481..f9cb391442 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -54,10 +54,18 @@ from esphome.const import ( CONF_TTLS_PHASE_2, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_WIFI, + PLACEHOLDER_WIFI_SSID, Platform, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeError, + HexInt, + coroutine_with_priority, +) import esphome.final_validate as fv from esphome.types import ConfigType @@ -903,3 +911,45 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO}, } ) + + +def _placeholder_wifi_credentials(config: ConfigType) -> list[str]: + """Return human-readable locations where the dashboard's placeholder wifi + values still appear. Empty list means no placeholders were found. + """ + placeholders: list[str] = [] + wifi_conf = config.get(CONF_WIFI) + if not wifi_conf: + return placeholders + + for idx, network in enumerate(wifi_conf.get(CONF_NETWORKS, [])): + ssid = network.get(CONF_SSID) + if isinstance(ssid, str) and ssid == PLACEHOLDER_WIFI_SSID: + placeholders.append(f"wifi.networks[{idx}].ssid") + + ap_conf = wifi_conf.get(CONF_AP) + if ap_conf: + ap_ssid = ap_conf.get(CONF_SSID) + if isinstance(ap_ssid, str) and ap_ssid == PLACEHOLDER_WIFI_SSID: + placeholders.append("wifi.ap.ssid") + + return placeholders + + +def check_placeholder_credentials(config: ConfigType) -> None: + """Raise EsphomeError if any wifi credential is the dashboard placeholder. + + Call only at compile time. NEVER from CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, + or any path reached by `esphome config`; device-builder relies on + validation passing with the placeholders still in place. + """ + locations = _placeholder_wifi_credentials(config) + if not locations: + return + formatted = ", ".join(locations) + raise EsphomeError( + f"wifi configuration still contains the dashboard placeholder value " + f"'{PLACEHOLDER_WIFI_SSID}' at: {formatted}. " + f"Open secrets.yaml and replace 'wifi_ssid' (and 'wifi_password') " + f"with your real wifi credentials before flashing." + ) diff --git a/esphome/const.py b/esphome/const.py index 91bc52708c..1819502201 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1415,3 +1415,12 @@ ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic" # The corresponding constant exists in c++ # when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds SCHEDULER_DONT_RUN = 4294967295 + +# Sentinel values written by the esphome-device-builder dashboard into +# secrets.yaml on first boot so that !secret wifi_ssid / !secret wifi_password +# references resolve cleanly through validation before the user has finished +# the onboarding wizard. Compilation refuses if these reach the binary so that +# a user who dismisses onboarding can't accidentally flash a device that will +# never associate with their wifi. +PLACEHOLDER_WIFI_SSID = "REPLACE_WITH_YOUR_WIFI_NETWORK" +PLACEHOLDER_WIFI_PASSWORD = "REPLACE_WITH_YOUR_WIFI_PASSWORD" # noqa: S105 diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 71a14d7817..9598c1bdd8 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -3,8 +3,20 @@ import pytest from esphome.components.esp32 import const -from esphome.components.wifi import has_native_wifi, variant_has_wifi -from esphome.const import Platform +from esphome.components.wifi import ( + check_placeholder_credentials, + has_native_wifi, + variant_has_wifi, +) +from esphome.const import ( + CONF_AP, + CONF_NETWORKS, + CONF_SSID, + CONF_WIFI, + PLACEHOLDER_WIFI_SSID, + Platform, +) +from esphome.core import EsphomeError, Lambda @pytest.mark.parametrize( @@ -123,3 +135,65 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None: def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: """RP2040 without a board id falls open to True (custom-board default).""" assert has_native_wifi(platform=Platform.RP2040) is True + + +def _wifi_config( + *, + networks: list[dict] | None = None, + ap: dict | None = None, +) -> dict: + """Build a minimal config dict matching the post-validation shape.""" + wifi: dict = {} + if networks is not None: + wifi[CONF_NETWORKS] = networks + if ap is not None: + wifi[CONF_AP] = ap + return {CONF_WIFI: wifi} + + +def test_check_placeholder_credentials_passes_with_real_ssid() -> None: + """A real SSID compiles without complaint.""" + config = _wifi_config(networks=[{CONF_SSID: "home_network"}]) + assert check_placeholder_credentials(config) is None + + +def test_check_placeholder_credentials_refuses_placeholder_ssid() -> None: + """The placeholder SSID is rejected with an actionable message.""" + config = _wifi_config(networks=[{CONF_SSID: PLACEHOLDER_WIFI_SSID}]) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + message = str(exc_info.value) + assert "wifi.networks[0].ssid" in message + assert "secrets.yaml" in message + + +def test_check_placeholder_credentials_refuses_placeholder_in_second_network() -> None: + """Index reporting picks the placeholder out of a mixed network list.""" + config = _wifi_config( + networks=[ + {CONF_SSID: "home_network"}, + {CONF_SSID: PLACEHOLDER_WIFI_SSID}, + ], + ) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + assert "wifi.networks[1].ssid" in str(exc_info.value) + + +def test_check_placeholder_credentials_refuses_placeholder_ap_ssid() -> None: + """An AP using the placeholder broadcast name is also refused.""" + config = _wifi_config(ap={CONF_SSID: PLACEHOLDER_WIFI_SSID}) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + assert "wifi.ap.ssid" in str(exc_info.value) + + +def test_check_placeholder_credentials_no_wifi_passes() -> None: + """Ethernet-only / wifi-less configs skip the check entirely.""" + assert check_placeholder_credentials({}) is None + + +def test_check_placeholder_credentials_skips_template_ssid() -> None: + """A templated (Lambda) SSID is not a string and is skipped.""" + config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}]) + assert check_placeholder_credentials(config) is None From 5ec0879a1049bfccc1179efc05da072327ed5de9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:29:15 -0700 Subject: [PATCH 1282/2214] [core] Fix KeyError: 'esp32' on upload when validated-config cache is used (#16457) --- esphome/storage_json.py | 13 +++++- tests/unit_tests/test_compiled_config.py | 56 ++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 7d26b22f96..e481827080 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -273,10 +273,21 @@ class StorageJSON: """ CORE.name = self.name CORE.build_path = self.build_path + target_platform = self.core_platform or self.target_platform.lower() CORE.data[KEY_CORE] = { - KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(), + KEY_TARGET_PLATFORM: target_platform, KEY_TARGET_FRAMEWORK: self.framework, } + # The compile pipeline populates CORE.data[KEY_ESP32] when esp32's + # validator runs; on the cache fast path that validator is skipped, + # so populate the variant upload_using_esptool reads via + # esp32.get_esp32_variant(). target_platform on disk is the variant + # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). + if target_platform == const.PLATFORM_ESP32: + from esphome.components.esp32.const import KEY_ESP32 + from esphome.const import KEY_VARIANT + + CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform} def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 34e811b97b..8c9cfa8101 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -22,6 +22,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + KEY_VARIANT, ) from esphome.core import CORE @@ -47,7 +48,12 @@ wifi: """ -def _write_storage(storage_path: Path) -> None: +def _write_storage( + storage_path: Path, + *, + esp_platform: str = "ESP32", + core_platform: str | None = "esp32", +) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) data = { @@ -59,14 +65,14 @@ def _write_storage(storage_path: Path) -> None: "src_version": 1, "address": "192.168.1.42", "web_port": None, - "esp_platform": "ESP32", + "esp_platform": esp_platform, "build_path": "/build/lite_test", "firmware_bin_path": "/build/lite_test/firmware.bin", "loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_platforms": [], "no_mdns": False, "framework": "arduino", - "core_platform": "esp32", + "core_platform": core_platform, } storage_path.write_text(json.dumps(data)) @@ -123,6 +129,50 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert CORE.build_path == Path("/build/lite_test") assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" + # upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32]. + from esphome.components.esp32.const import KEY_ESP32 + + assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32" + + +def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: + """ESP32 variants survive the cache fast path so esptool gets the right --chip.""" + from esphome.components.esp32.const import KEY_ESP32 + + 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", esp_platform="ESP32S3") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is not None + assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32S3" + + +def test_load_compiled_config_skips_esp32_block_for_other_platforms( + tmp_path: Path, +) -> None: + """Non-esp32 targets shouldn't fabricate an esp32 data block.""" + from esphome.components.esp32.const import KEY_ESP32 + + 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", + esp_platform="ESP8266", + core_platform="esp8266", + ) + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is not None + assert KEY_ESP32 not in CORE.data @pytest.mark.parametrize( From c6a74222f1c198cd2032fc7f6a89f6eddbf28477 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 15 May 2026 13:32:51 -0400 Subject: [PATCH 1283/2214] [esp32_hosted][fingerprint_grow] Fix two remaining ESP32 toolchain warnings (#16442) --- esphome/components/esp32_hosted/update/esp32_hosted_update.cpp | 2 +- esphome/components/fingerprint_grow/fingerprint_grow.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 7f3ba77895..70fa41b312 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -121,7 +121,7 @@ void Esp32HostedUpdate::setup() { } } else { ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", - app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); + app_desc->magic_word, static_cast(ESP_APP_DESC_MAGIC_WORD)); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 3f57789034..b38d42191b 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() { break; case ENROLL_MISMATCH: ESP_LOGE(TAG, "Scans do not match"); + [[fallthrough]]; default: return this->data_[0]; } From 26907f17f5833f08a8b9b7e3baf0f9900f22aafb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:42:51 -0700 Subject: [PATCH 1284/2214] Bump aioesphomeapi from 45.0.0 to 45.0.1 (#16467) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6291b5cd41..ae50c4046b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.0 +aioesphomeapi==45.0.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 6a8f24b951ea396e60b6ef1f2ec1acce5be2f5b6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 16 May 2026 08:30:04 +1000 Subject: [PATCH 1285/2214] [ft5x06] Fix setting calibration values (#16446) --- .../ft5x06/touchscreen/ft5x06_touchscreen.cpp | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp index 835dc4aac0..24d3529fb4 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp @@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() { this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); } + // reading the chip registers to get max x/y does not seem to work. + if (this->display_ != nullptr) { + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } + } + // wait 200ms after reset. this->set_timeout(200, [this] { this->continue_setup_(); }); } @@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() { this->mark_failed(); return; } - // reading the chip registers to get max x/y does not seem to work. - if (this->display_ != nullptr) { - if (this->x_raw_max_ == this->x_raw_min_) { - this->x_raw_max_ = this->display_->get_native_width(); - } - if (this->y_raw_max_ == this->y_raw_min_) { - this->y_raw_max_ = this->display_->get_native_height(); - } - } } void FT5x06Touchscreen::update_touches() { @@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() { uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]); uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]); - ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); + ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); if (status == 0 || status == 2) { this->add_raw_touch_position_(id, x, y); } From da237b5070cad2c0f2c430d7f54e77cf6bf8ca6b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 16 May 2026 08:36:53 +1000 Subject: [PATCH 1286/2214] [lvgl] Fix image define (#16468) --- esphome/components/lvgl/__init__.py | 5 +++++ esphome/components/lvgl/lvgl_esphome.h | 10 ++++++---- esphome/components/lvgl/styles.py | 2 ++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 91b101cd25..4277c14dd7 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -55,6 +55,7 @@ from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, LOGGER, + add_lv_use, get_focused_widgets, get_lv_images_used, get_refreshed_widgets, @@ -71,6 +72,7 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code from .lv_validation import lv_bool from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( + BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, STYLE_REMAP, @@ -100,6 +102,7 @@ from .widgets import ( get_screen_active, set_obj_properties, ) +from .widgets.img import CONF_IMAGE # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -433,6 +436,8 @@ async def to_code(configs): # This must be done after all widgets are created styles_used = df.get_styles_used() + if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used): + add_lv_use(CONF_IMAGE) for use in df.get_lv_uses(): df.add_define(f"LV_USE_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}") diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 218f9a60ab..3f7f1dce14 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { lv_style_set_text_font(style, font->get_lv_font()); } #endif -#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -#if LV_USE_IMAGE + +#ifdef USE_IMAGE +#ifdef USE_LVGL_IMAGE // Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } -#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); @@ -93,7 +93,8 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) { inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); } -#endif // USE_LVGL_IMAGE +#endif + #ifdef USE_LVGL_ANIMIMG inline void lv_animimg_set_src(lv_obj_t *img, std::vector images) { auto *dsc = static_cast *>(lv_obj_get_user_data(img)); @@ -109,6 +110,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size()); } #endif // USE_LVGL_ANIMIMG +#endif // USE_IMAGE #ifdef USE_LVGL_METER int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index c1441526f9..5911505555 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -9,6 +9,7 @@ from .defines import ( CONF_THEME, LValidator, add_lv_use, + get_styles_used, get_theme_widget_map, literal, ) @@ -25,6 +26,7 @@ def has_style_props(config) -> bool: async def style_set(svar, style): for prop, validator in ALL_STYLES.items(): if (value := style.get(prop)) is not None: + get_styles_used().add(prop) if isinstance(validator, LValidator): value = await validator.process(value) if isinstance(value, list): From 2dbaaf1efda5625b4552dbd71036bd7f2584764c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 22:06:33 -0700 Subject: [PATCH 1287/2214] Bump aioesphomeapi from 45.0.1 to 45.0.2 (#16469) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ae50c4046b..7d497e2834 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.1 +aioesphomeapi==45.0.2 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From f301e90fd9eea419dd82166f17f4ad3cd6477eb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 22:16:52 -0700 Subject: [PATCH 1288/2214] [ci] Use larger app partition for esp32-s3-idf component test grouping (#16430) --- .../build_components_base.esp32-s3-idf.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_build_components/build_components_base.esp32-s3-idf.yaml b/tests/test_build_components/build_components_base.esp32-s3-idf.yaml index ee209000e9..f3122f977e 100644 --- a/tests/test_build_components/build_components_base.esp32-s3-idf.yaml +++ b/tests/test_build_components/build_components_base.esp32-s3-idf.yaml @@ -7,6 +7,9 @@ esp32: variant: ESP32S3 framework: type: esp-idf + # Use custom partition table with larger app partition (3MB) + # Default IDF partitions only allow 1.75MB which is too small for grouped tests + partitions: ../partitions_testing.csv logger: level: VERY_VERBOSE From 20f92ad5e96cb565f898b94c79b06f2b9a699536 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 18:41:09 -0700 Subject: [PATCH 1289/2214] Bump aioesphomeapi from 45.0.2 to 45.0.3 (#16479) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7d497e2834..92e36297a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.2 +aioesphomeapi==45.0.3 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 4f188bf9bb07a98ea06cdd915e962a290362a957 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 19:08:19 -0700 Subject: [PATCH 1290/2214] Bump zeroconf from 0.148.0 to 0.149.3 (#16480) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 92e36297a6..63a25c8e36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.148.0 +zeroconf==0.149.3 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From df31c72e4e4908a040a4e87fc5e6ca315bd7c3ef Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 17 May 2026 15:29:38 -0400 Subject: [PATCH 1291/2214] [espidf] Switch direct framework downloader to esphome-libs/esp-idf tarballs (#16484) --- esphome/espidf/framework.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 7ff373aba8..32bcf4fb3b 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -69,7 +69,7 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( os.environ.get( "ESPHOME_IDF_FRAMEWORK_MIRRORS", - "https://github.com/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip", + "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz;https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", ) ) From cdf74c180e16cc9297c476953d477e2605516921 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 18 May 2026 11:11:54 +1200 Subject: [PATCH 1292/2214] Bump version to 2026.5.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a29a78ea9c..641a491828 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.5.0b1 +PROJECT_NUMBER = 2026.5.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 1819502201..d6d533a702 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0b1" +__version__ = "2026.5.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 9c696f5de1f86671a983ab322e72a5e06921c7d5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 18 May 2026 15:56:44 +1200 Subject: [PATCH 1293/2214] [ci] Move ha-addon and schema release triggers to version-notifier (#16490) --- .github/workflows/release.yml | 70 +---------------------------------- 1 file changed, 1 insertion(+), 69 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1086c858c..9799f882db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,74 +212,6 @@ jobs: docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \ $(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *) - deploy-ha-addon-repo: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: - - init - - deploy-manifest - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: home-assistant-addon - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - let description = "ESPHome"; - if (context.eventName == "release") { - description = ${{ toJSON(github.event.release.body) }}; - } - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "home-assistant-addon", - workflow_id: "bump-version.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - content: description - } - }) - - deploy-esphome-schema: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: [init] - environment: ${{ needs.init.outputs.deploy_env }} - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: esphome-schema - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "esphome-schema", - workflow_id: "generate-schemas.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - } - }) - version-notifier: if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' runs-on: ubuntu-latest @@ -302,7 +234,7 @@ jobs: with: github-token: ${{ steps.generate-token.outputs.token }} script: | - github.rest.actions.createWorkflowDispatch({ + await github.rest.actions.createWorkflowDispatch({ owner: "esphome", repo: "version-notifier", workflow_id: "notify.yml", From edb59476b1841b932fbb74754659f885dc58d31f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 23:29:03 -0700 Subject: [PATCH 1294/2214] Bump zeroconf from 0.149.3 to 0.149.7 (#16492) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6eef8ac643..e3de4a134c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.149.3 +zeroconf==0.149.7 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From b0af4a9f0dd86a7aae573d9e8cb4ea22130fb915 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:48 -0500 Subject: [PATCH 1295/2214] [sen5x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16463) --- esphome/components/sen5x/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index ce35cf5bf1..480654ee1b 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -25,7 +25,6 @@ from esphome.const import ( CONF_TEMPERATURE_COMPENSATION, CONF_TIME_CONSTANT, CONF_VOC, - DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, @@ -77,7 +76,6 @@ def _gas_sensor( return sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend( { From cb581271edd4b7a2ad41538af5792e706bd3e5c2 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:51 -0500 Subject: [PATCH 1296/2214] [sgp4x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16464) --- esphome/components/sgp4x/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 1e58a0f26a..d407f20a4e 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, CONF_VOC, - DEVICE_CLASS_AQI, ICON_RADIATOR, STATE_CLASS_MEASUREMENT, ) @@ -72,13 +71,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_VOC): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend(VOC_SENSOR), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend(NOX_SENSOR), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, From 36fc36071d44ff9f23668fa9209f207eeff615cb Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:20:08 -0500 Subject: [PATCH 1297/2214] [sen6x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16465) --- esphome/components/sen6x/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 071478e719..19c0cb500e 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, CONF_VOC, - DEVICE_CLASS_AQI, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, @@ -93,13 +92,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_VOC): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CO2): sensor.sensor_schema( From 7ecfe4b5c9326690b7d852b4ff2cc891bb80e40e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 18 May 2026 22:53:19 -0400 Subject: [PATCH 1298/2214] [i2s_audio] Compute ring buffer size with SPDIF sample count (#16400) --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 21 ++++++++++--------- .../i2s_audio/speaker/i2s_audio_speaker.h | 1 - .../speaker/i2s_audio_speaker_standard.cpp | 1 + 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 8f67562a77..877f67775b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_. xQueueReset(this->write_records_queue_); - const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT; - // Ensure ring buffer duration is at least the duration of all DMA buffers - const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); - // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); - // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and - // avoids unnecessary single-frame splices. - const size_t ring_buffer_size = - (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; - // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames + // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz), + // not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size. const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; const size_t bytes_to_fill_single_dma_buffer = this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT; + + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers. + const size_t requested_ring_buffer_bytes = + (this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; + const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes); bool successful_setup = false; std::unique_ptr audio_source; @@ -177,7 +177,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // on_sent events drain in lockstep without crediting any audio frames. this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); + // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. + esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); if (preload_err != ESP_OK) { break; // DMA preload buffer full or error } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 20bb05e322..34792bdbea 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -19,7 +19,6 @@ namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations -static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t TASK_STACK_SIZE = 4096; static constexpr ssize_t TASK_PRIORITY = 19; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index e69601e87a..ffe901504d 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -16,6 +16,7 @@ namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t DMA_BUFFERS_COUNT = 4; // Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, // doubled so that a transient backlog never overruns the queue (which would desync the lockstep From c0e71fc713702f04ff858fd381621803c19ae750 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 19 May 2026 18:53:36 +0200 Subject: [PATCH 1299/2214] [zigbee] don't allow zigbee + thread or access point (#16499) --- esphome/components/zigbee/__init__.py | 2 ++ esphome/components/zigbee/zigbee_esp32.py | 10 +++------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 69e3fe9c5a..c75b0773d2 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -50,6 +50,8 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@luar123", "@tomaszduda23"] +CONFLICTS_WITH = ["openthread"] + BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index e446377a06..89efd583ab 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -117,15 +117,11 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: if not CORE.is_esp32: return config if CONF_WIFI in fv.full_config.get(): - if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]: - raise cv.Invalid( - "Only Zigbee End Device can be used together with a Wifi Access Point." - ) if CONF_AP in fv.full_config.get()[CONF_WIFI]: - _LOGGER.warning( - "Wifi Access Point might be unstable while Zigbee is active, use only as fallback." + raise cv.Invalid( + "A Wifi Access Point can not be used together with Zigbee." ) - elif config[CONF_ROUTER]: + if config[CONF_ROUTER]: _LOGGER.warning( "The Zigbee Router might miss packets while Wifi is active and could destabilize " "your network. Use only if Wifi is off most of the time." From 1d0ddfac5d6abed8c13bc9c9c05c9a1066126155 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 19 May 2026 12:57:18 -0400 Subject: [PATCH 1300/2214] [espidf] Print RAM summary on ESP32-S3 / unified-DIRAM variants (#16494) --- esphome/espidf/size_summary.py | 7 +- tests/unit_tests/test_size_summary.py | 128 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/test_size_summary.py diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 9477e664b3..3ba0bf3b4d 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -94,9 +94,10 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: _LOGGER.debug("Skipping size summary: %s", e) return - dram = data.get("memory_types", {}).get("DRAM") or {} - ram_used = dram.get("used") - ram_total = dram.get("size") + memory_types = data.get("memory_types", {}) + ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {} + ram_used = ram_region.get("used") + ram_total = ram_region.get("size") if ram_total and ram_used is not None: print(f"RAM: {_format_bar(ram_used, ram_total)}") diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py new file mode 100644 index 0000000000..933be88476 --- /dev/null +++ b/tests/unit_tests/test_size_summary.py @@ -0,0 +1,128 @@ +"""Tests for esphome.espidf.size_summary.print_summary.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from esphome.espidf.size_summary import print_summary + + +def _write_size_json(tmp_path: Path, data: dict) -> Path: + """Drop a fake esp_idf_size.json under ``tmp_path`` and return the path.""" + out = tmp_path / "esp_idf_size.json" + out.write_text(json.dumps(data)) + return out + + +def _esp32_size_data() -> dict: + """Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM).""" + return { + "image_size": 827455, + "memory_types": { + "DRAM": { + "size": 180736, + "used": 47332, + "sections": { + ".dram0.bss": {"abbrev_name": ".bss", "size": 30616}, + ".dram0.data": {"abbrev_name": ".data", "size": 16716}, + }, + }, + "IRAM": { + "size": 131072, + "used": 80351, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 79323}, + ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + }, + }, + }, + } + + +def _s3_size_data() -> dict: + """Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM).""" + return { + "image_size": 724215, + "memory_types": { + "DIRAM": { + "size": 341760, + "used": 104999, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 58051}, + ".dram0.bss": {"abbrev_name": ".bss", "size": 27088}, + ".dram0.data": {"abbrev_name": ".data", "size": 19708}, + ".noinit": {"abbrev_name": ".noinit", "size": 152}, + }, + }, + "IRAM": { + "size": 16384, + "used": 16384, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 15356}, + ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + }, + }, + }, + } + + +def test_print_summary_esp32_uses_dram( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "RAM:" in out + assert "used 47332 bytes from 180736 bytes" in out + + +def test_print_summary_s3_falls_back_to_diram( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "used 104999 bytes from 341760 bytes" in out + + +def test_print_summary_skips_when_diram_total_collapses( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero-size region drops the RAM line rather than divide by zero.""" + size_json = _write_size_json( + tmp_path, + { + "memory_types": { + "DIRAM": { + "size": 0, + "used": 0, + "sections": {}, + }, + }, + }, + ) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "RAM:" not in out + + +def test_print_summary_handles_missing_json( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Missing size json is non-fatal and prints nothing.""" + print_summary(tmp_path / "does_not_exist.json", partitions_csv=None) + assert capsys.readouterr().out == "" + + +def test_print_summary_handles_no_memory_types( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A size json without ``memory_types`` still doesn't crash.""" + size_json = _write_size_json(tmp_path, {"image_size": 0}) + print_summary(size_json, partitions_csv=None) + assert capsys.readouterr().out == "" From 80ed54103257bab0ea1ba76caee56003ee5673e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 10:13:58 -0700 Subject: [PATCH 1301/2214] [core] Add progmem_memcpy HAL helper (#16470) --- esphome/components/esp8266/hal.h | 6 ++++++ esphome/core/hal.h | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/hal.h b/esphome/components/esp8266/hal.h index effa9c9371..f3b33da692 100644 --- a/esphome/components/esp8266/hal.h +++ b/esphome/components/esp8266/hal.h @@ -58,6 +58,12 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) { return pgm_read_word(addr); // NOLINT } +// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers +// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on +// ESP8266 is ~4x as many flash accesses as the bulk path. +__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) { + memcpy_P(dst, src, len); // NOLINT +} // NOLINTNEXTLINE(readability-identifier-naming) __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 4babda807d..b44a422836 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -1,6 +1,7 @@ #pragma once -#include #include +#include +#include #include "gpio.h" #include "esphome/core/defines.h" #include "esphome/core/time_64.h" @@ -42,6 +43,9 @@ void __attribute__((noreturn)) arch_restart(); inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } inline const char *progmem_read_ptr(const char *const *addr) { return *addr; } inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } +// Bulk copy out of PROGMEM. PROGMEM is a no-op everywhere except ESP8266, so a +// plain `std::memcpy` is correct and the fast path here. +inline void progmem_memcpy(void *dst, const void *src, size_t len) { std::memcpy(dst, src, len); } #endif } // namespace esphome From 863af482ecd4d1afbdd7c31bdcaea92f02431955 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 10:14:15 -0700 Subject: [PATCH 1302/2214] [esp32_ble_server] Honor client offset and MTU in long reads (#16458) --- .../esp32_ble_server/ble_characteristic.cpp | 48 ++++++++----------- .../esp32_ble_server/ble_characteristic.h | 1 - 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 842c78a8aa..4d364b4655 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -196,43 +196,35 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt (*this->on_read_callback_)(param->read.conn_id); } - uint16_t max_offset = 22; - + // Use the client-supplied offset for long reads; short reads always start at 0. + // The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we + // just provide as much data as we have from the requested offset and let the stack + // handle framing. The client issues subsequent blob reads with increasing offsets + // until it has received the whole value. + const uint16_t offset = param->read.is_long ? param->read.offset : 0; + esp_gatt_status_t status = ESP_GATT_OK; esp_gatt_rsp_t response; - if (param->read.is_long) { - if (this->value_read_offset_ >= this->value_.size()) { - response.attr_value.len = 0; - response.attr_value.offset = this->value_read_offset_; - this->value_read_offset_ = 0; - } else if (this->value_.size() - this->value_read_offset_ < max_offset) { - // Last message in the chain - response.attr_value.len = this->value_.size() - this->value_read_offset_; - response.attr_value.offset = this->value_read_offset_; - memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len); - this->value_read_offset_ = 0; - } else { - response.attr_value.len = max_offset; - response.attr_value.offset = this->value_read_offset_; - memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len); - this->value_read_offset_ += max_offset; - } + response.attr_value.offset = offset; + + if (offset > this->value_.size()) { + status = ESP_GATT_INVALID_OFFSET; + response.attr_value.len = 0; } else { - response.attr_value.offset = 0; - response.attr_value.len = this->value_.size(); - if (response.attr_value.len > ESP_GATT_MAX_ATTR_LEN) { - ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", response.attr_value.len, - ESP_GATT_MAX_ATTR_LEN); - response.attr_value.len = ESP_GATT_MAX_ATTR_LEN; + size_t remaining = this->value_.size() - offset; + if (remaining > ESP_GATT_MAX_ATTR_LEN) { + ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", + static_cast(remaining), ESP_GATT_MAX_ATTR_LEN); + remaining = ESP_GATT_MAX_ATTR_LEN; } - memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len); - this->value_read_offset_ = 0; + response.attr_value.len = remaining; + memcpy(response.attr_value.value, this->value_.data() + offset, remaining); } response.attr_value.handle = this->handle_; response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE; esp_err_t err = - esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response); + esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err); } diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 94c7495cbd..933177a399 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -79,7 +79,6 @@ class BLECharacteristic { esp_gatt_char_prop_t properties_; uint16_t handle_{0xFFFF}; - uint16_t value_read_offset_{0}; std::vector value_; std::vector descriptors_; From e979d461f01ca69f0715d687217609d6d4060994 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 12:15:50 -0500 Subject: [PATCH 1303/2214] Bump codecov/codecov-action from 6.0.0 to 6.0.1 (#16500) 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 de21456841..dbbb06c86c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From 9924d998f1dbc42bb868c16f794e9238d34e70c7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 19 May 2026 14:37:41 -0400 Subject: [PATCH 1304/2214] [i2s_audio] Optimize SPDIF encoder and suport higher bit depth audio (#16504) Co-authored-by: Keith Burzinski --- .../components/i2s_audio/speaker/__init__.py | 7 +- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 12 +- .../i2s_audio/speaker/spdif_encoder.cpp | 537 +++++++++++------- .../i2s_audio/speaker/spdif_encoder.h | 59 +- .../common-spdif_mode.yaml} | 11 - .../test-spdif_speaker.esp32-idf.yaml | 8 + 6 files changed, 372 insertions(+), 262 deletions(-) rename tests/components/{speaker/spdif_mode.esp32-idf.yaml => i2s_audio/common-spdif_mode.yaml} (52%) create mode 100644 tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 759cc40ca9..8215d8b518 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -89,10 +89,10 @@ def _set_num_channels_from_config(config): def _set_stream_limits(config): if config.get(CONF_SPDIF_MODE, False): - # SPDIF mode: fixed to 16-bit stereo at configured sample rate + # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( min_bits_per_sample=16, - max_bits_per_sample=16, + max_bits_per_sample=32, min_channels=2, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), @@ -213,9 +213,6 @@ def _final_validate(config): ) if config[CONF_CHANNEL] != CONF_STEREO: raise cv.Invalid("SPDIF mode only supports stereo channel configuration") - # bits_per_sample is converted to float by the schema - if config[CONF_BITS_PER_SAMPLE] != 16: - raise cv.Invalid("SPDIF mode only supports 16 bits per sample") if not config[CONF_USE_APLL]: raise cv.Invalid( "SPDIF mode requires 'use_apll: true' for accurate clock generation" diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 877f67775b..989bcf2977 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -411,8 +411,9 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s this->sample_rate_, audio_stream_info.get_sample_rate()); return ESP_ERR_NOT_SUPPORTED; } - if (audio_stream_info.get_bits_per_sample() != 16) { - ESP_LOGE(TAG, "Only supports 16 bits per sample"); + const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) { + ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample); return ESP_ERR_NOT_SUPPORTED; } if (audio_stream_info.get_channels() != 2) { @@ -420,11 +421,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s return ESP_ERR_NOT_SUPPORTED; } - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); - return ESP_ERR_NOT_SUPPORTED; - } + // Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire. + this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8); if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp index 42a72346cc..30146e0a70 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start) static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel // BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33 -// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33. +// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero. static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33; // Constexpr BMC encoder for compile-time LUT generation. @@ -36,21 +36,43 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) { return bmc; } -// 4-bit BMC lookup table: 16 entries (16 bytes in flash) -// Index: 4-bit data value (0-15), always phase=true start +// Compile-time parity helper (constexpr-friendly, runs only at LUT build time). +static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) { + uint32_t p = 0; + for (uint32_t b = 0; b < num_bits; b++) + p ^= (value >> b) & 1u; + return p; +} + +// Combined BMC + phase-delta lookup tables. +// Each entry packs the BMC pattern (lower bits, phase=high start) together with +// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0). +// XORing the delta into the running phase mask propagates parity across chunks +// without an explicit popcount. + +// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash. +// Bits 0-7 : 8-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_4 = [] { - std::array t{}; - for (uint32_t i = 0; i < 16; i++) - t[i] = static_cast(bmc_lut_encode(i, 4)); + std::array t{}; + for (uint32_t i = 0; i < 16; i++) { + uint32_t bmc = bmc_lut_encode(i, 4); + uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); -// 8-bit BMC lookup table: 256 entries (512 bytes in flash) -// Index: 8-bit data value (0-255), always phase=true start +// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash. +// Bits 0-15 : 16-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_8 = [] { - std::array t{}; - for (uint32_t i = 0; i < 256; i++) - t[i] = bmc_lut_encode(i, 8); + std::array t{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t bmc = bmc_lut_encode(i, 8); + uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); @@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() { } ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); - // Build initial channel status block with default sample rate + // Build initial channel status block with default sample rate and width this->build_channel_status_(); this->reset(); @@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() { void SPDIFEncoder::reset() { this->spdif_block_ptr_ = this->spdif_block_buf_.get(); this->frame_in_block_ = 0; - this->is_left_channel_ = true; + this->block_buf_is_silence_block_ = false; } void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { @@ -84,31 +106,27 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { } } +void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) { + if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) { + ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample); + return; + } + if (this->bytes_per_sample_ != bytes_per_sample) { + this->bytes_per_sample_ = bytes_per_sample; + this->build_channel_status_(); + // Discard any partial block built at the previous width so we never mix widths on the wire. + this->reset(); + ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8); + } +} + void SPDIFEncoder::build_channel_status_() { // IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes) - // Transmitted LSB-first within each byte, one bit per frame via C bit - // - // Byte 0: Control bits - // Bit 0: 0 = Consumer format (not professional AES3) - // Bit 1: 0 = PCM audio (not non-audio data like AC3) - // Bit 2: 0 = No copyright assertion - // Bits 3-5: 000 = No pre-emphasis - // Bits 6-7: 00 = Mode 0 (basic consumer format) - // - // Byte 1: Category code (0x00 = general, 0x01 = CD, etc.) - // - // Byte 2: Source/channel numbers - // Bits 0-3: Source number (0 = unspecified) - // Bits 4-7: Channel number (0 = unspecified) - // - // Byte 3: Sample frequency and clock accuracy - // Bits 0-3: Sample frequency code - // Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32) - // Bits 6-7: Reserved (0) - // - // Bytes 4-23: Reserved (zeros for basic compliance) + // Transmitted LSB-first within each byte, one bit per frame via C bit. + + // Any cached silence block was built for the previous channel status; it is now stale. + this->block_buf_is_silence_block_ = false; - // Clear all bytes first this->channel_status_.fill(0); // Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0 @@ -140,132 +158,148 @@ void SPDIFEncoder::build_channel_status_() { // Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5 this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0 - // Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.) + // Byte 4: Word length encoding (IEC 60958-3 consumer) + // bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits) + // bits 1-3: word length code relative to the max + // For our supported widths: + // 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20" + // 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24" + // 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code. + uint8_t word_length_code; + switch (this->bytes_per_sample_) { + case 2: + word_length_code = 0x02; + break; + case 3: // Shared case + case 4: + word_length_code = 0x0D; + break; + default: + word_length_code = 0x00; // not specified + break; + } + this->channel_status_[4] = word_length_code; } -HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) { - // ============================================================================ - // Build raw 32-bit subframe (IEC 60958 format) - // ============================================================================ - // Bit layout: - // Bits 0-3: Preamble (handled separately, not in raw_subframe) - // Bits 4-7: Auxiliary audio data (zeros for 16-bit audio) - // Bits 8-11: Audio LSB extension (zeros for 16-bit audio) - // Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field) - // Bit 28: V (Validity) - 0 = valid audio - // Bit 29: U (User data) - 0 - // Bit 30: C (Channel status) - from channel status block - // Bit 31: P (Parity) - even parity over bits 4-31 - // ============================================================================ +// Extract the C bit for the given frame from channel_status_ and shift it into bit 30 +// so it can be OR'd directly into a raw subframe. +ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array &channel_status, + uint32_t frame) { + return static_cast((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30; +} - // Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB) - uint32_t raw_subframe = (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); +// ============================================================================ +// IEC 60958 subframe bit layout +// ============================================================================ +// Bits 0-3: Preamble (handled separately, not in raw_subframe) +// Bits 4-7: Auxiliary audio data / 24-bit audio LSB +// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit) +// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode) +// Bit 28: V (Validity) - 0 = valid audio +// Bit 29: U (User data) - 0 +// Bit 30: C (Channel status) - from channel status block +// Bit 31: P (Parity) - even parity over bits 4-31 +// ============================================================================ - // V = 0 (valid audio), U = 0 (no user data) - // C = channel status bit for current frame (same bit used for both L and R subframes) - bool c_bit = this->get_channel_status_bit_(this->frame_in_block_); - if (c_bit) { - raw_subframe |= (1U << 30); +// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes. +// Caller is responsible for OR-ing in the C bit and parity. +template ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) { + static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample"); + if constexpr (Bps == 2) { + // 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27. + return (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); + } else if constexpr (Bps == 3) { + // 24-bit input: full 24-bit audio field, bits 4-27. + return (static_cast(pcm_sample[2]) << 20) | (static_cast(pcm_sample[1]) << 12) | + (static_cast(pcm_sample[0]) << 4); + } else { // Bps == 4 + // 32-bit input truncated to 24-bit: drop the lowest byte. + return (static_cast(pcm_sample[3]) << 20) | (static_cast(pcm_sample[2]) << 12) | + (static_cast(pcm_sample[1]) << 4); } +} - // Calculate even parity over bits 4-30 - // This ensures consistent BMC ending phase regardless of audio content - uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30) - uint32_t ones_count = __builtin_popcount(bits_4_30); - uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even - raw_subframe |= parity << 31; // Set P bit to make total even +// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes +// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is +// derived from the cumulative parity-mask delta of the per-byte LUT lookups. +// +// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7. +// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first. +// All preambles end at phase HIGH, so phase=true at the start of bit 4. +// +// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each +// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the +// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to +// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the +// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit +// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output +// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower +// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`. +template +ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) { + if constexpr (Bps == 2) { + // 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants. + // Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even), + // so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity. + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = lut_n & 0xFFu; + uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0 - // ============================================================================ - // Select preamble based on position in block and channel - // ============================================================================ - // B = block start (left channel, frame 0 of 192-frame block) - // M = left channel (frames 1-191) - // W = right channel (all frames) - uint8_t preamble; - if (this->is_left_channel_) { - preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + // phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31. + bmc_24_31 ^= phase_mask & 1u; + + dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } else { - preamble = PREAMBLE_W; + // 24-bit (and 32-bit truncated) path: bits 4-11 are live audio. + uint32_t byte_lo = (raw_subframe >> 4) & 0xFF; + uint32_t lut_l = BMC_LUT_8[byte_lo]; + uint32_t bmc_4_11 = lut_l & 0xFFFFu; + uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0 + + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu); + phase_mask ^= lut_n >> 16; + + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + bmc_24_31 ^= phase_mask & 1u; + + // word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15) + // word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31) + dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } +} - // ============================================================================ - // BMC encode the data portion (bits 4-31) using lookup tables - // ============================================================================ - // The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15. - // This applies to BOTH word[0] and word[1]. - // - // word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15] - // For correct S/PDIF subframe order (preamble → aux → audio): - // - bits 16-23: preamble (8 BMC bits) - // - bits 24-31: BMC(subframe bits 4-7) - first aux nibble - // - bits 0-7: BMC(subframe bits 8-11) - second aux nibble - // - bits 8-15: BMC(subframe bits 12-15) - audio low nibble - // - // word[1] transmission order: [16-31] → [0-15] - // For correct S/PDIF subframe order: - // - bits 16-31: BMC(subframe bits 16-23) - audio mid byte - // - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP - // ============================================================================ - - // All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio; - // two zero nibbles flip phase 8 times total → back to HIGH. - // So bits 12-15 always start encoding at phase=true. - - // Bits 12-15: 4-bit LUT lookup (always phase=true start) - uint32_t nibble = (raw_subframe >> 12) & 0xF; - uint32_t bmc_12_15 = BMC_LUT_4[nibble]; - - // Phase tracking via branchless XOR mask: - // - 0x0000 means phase=true (use LUT value directly) - // - 0xFFFF means phase=false (complement LUT value) - // End phase = start XOR (popcount & 1) since zero-bits flip phase, - // and for even bit widths: #zeros parity == popcount parity. - uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF; - - // Bits 16-23: 8-bit LUT lookup with phase correction - uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; - uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask; - phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF; - - // Bits 24-31: 8-bit LUT lookup with phase correction - uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; - uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask; - - // ============================================================================ - // Combine with correct positioning for I2S transmission - // ============================================================================ - // I2S with halfword swap: transmits bits 16-31, then bits 0-15. - // Within each halfword, MSB (highest bit) is transmitted first. - // - // For upper halfword (bits 16-31): bit 31 → bit 16 - // For lower halfword (bits 0-15): bit 15 → bit 0 - // - // Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15 - // - // word[0] layout for correct transmission: - // bits 24-31: preamble (transmitted 1st, as MSB of upper halfword) - // bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7) - // bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11) - // bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble) - // - // word[1] layout: - // bits 16-31: bmc_16_23 (transmitted 5th) - // bits 0-15: bmc_24_31 (transmitted 6th) - this->spdif_block_ptr_[0] = - bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); - this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16); - this->spdif_block_ptr_ += 2; - - // ============================================================================ - // Update position tracking - // ============================================================================ - if (!this->is_left_channel_) { - // Completed a stereo frame, advance frame counter - if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { - this->frame_in_block_ = 0; - } +template void SPDIFEncoder::encode_silence_frame_() { + static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0}; + uint32_t raw = build_raw_subframe(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_); + uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + bmc_encode_subframe(raw, preamble_l, this->spdif_block_ptr_); + bmc_encode_subframe(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2); + this->spdif_block_ptr_ += 4; + if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { + this->frame_in_block_ = 0; } - this->is_left_channel_ = !this->is_left_channel_; } esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { @@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { return err; } -size_t SPDIFEncoder::get_pending_pcm_bytes() const { - if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) { - return 0; +template +HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, + uint32_t *blocks_sent, size_t *bytes_consumed) { + const uint8_t *pcm_data = src; + const uint8_t *const pcm_end = src + size; + uint32_t block_count = 0; + + // Hot state lives in locals so the compiler can keep it in registers across the + // per-frame encoding work; byte writes through block_ptr may alias the member fields, + // which would block register allocation if the encoding read them directly from this->*. + uint32_t *block_ptr = this->spdif_block_ptr_; + uint32_t *const block_buf = this->spdif_block_buf_.get(); + uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32; + uint32_t frame = this->frame_in_block_; + const std::array &channel_status = this->channel_status_; + + auto save_state = [&]() { + this->spdif_block_ptr_ = block_ptr; + this->frame_in_block_ = static_cast(frame); + }; + + auto report_out_params = [&]() { + if (blocks_sent != nullptr) + *blocks_sent = block_count; + if (bytes_consumed != nullptr) + *bytes_consumed = pcm_data - src; + }; + + // Send a completed block if the buffer is full, propagating any error. + // send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it + // unchanged on error -- mirror both behaviors in our local block_ptr. + auto maybe_send = [&]() -> esp_err_t { + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + return ESP_OK; + }; + + // Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only + // buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from + // 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or + // preamble branch. The encoding body is inlined here so block_ptr lives in a register + // for the duration of the loop. + while (pcm_data + 2 * Bps <= pcm_end) { + if (frame == 0) { + esp_err_t err = maybe_send(); + if (err != ESP_OK) + return err; + + uint32_t c_bit = c_bit_for_frame(channel_status, 0); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_B, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + frame = 1; + pcm_data += 2 * Bps; + } + + // The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The + // input-size bound is folded into end_frame so a single `frame < end_frame` test + // governs termination. + uint32_t input_frames = static_cast(pcm_end - pcm_data) / (2u * Bps); + uint32_t end_frame = SPDIF_BLOCK_SAMPLES; + if (frame + input_frames < end_frame) + end_frame = frame + input_frames; + + while (frame < end_frame) { + uint32_t c_bit = c_bit_for_frame(channel_status, frame); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_M, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + ++frame; + pcm_data += 2 * Bps; + } + if (frame >= SPDIF_BLOCK_SAMPLES) + frame = 0; } - // Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer - // So pending uint32s / 2 = pending samples, and each sample is 2 bytes - size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get(); - size_t pending_samples = pending_uint32s / 2; - return pending_samples * 2; // 2 bytes per sample + + // Send any complete block that was just finished. + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + + save_state(); + report_out_params(); + return ESP_OK; } HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, size_t *bytes_consumed) { - const uint8_t *pcm_data = src; - const uint8_t *pcm_end = src + size; - uint32_t block_count = 0; + if (size > 0) { + // Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block. + this->block_buf_is_silence_block_ = false; + } + switch (this->bytes_per_sample_) { + case 2: + return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 3: + return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 4: + return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + default: + return ESP_ERR_INVALID_STATE; + } +} - while (pcm_data < pcm_end) { - // Check if there's a pending complete block from a previous failed send - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; - } - ++block_count; +template esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) { + // If a complete block is already pending (from a previous failed send), emit just that block. + // Otherwise pad the partial block with silence (or generate a full silence block if empty) and + // send. Always emits exactly one block on success. + if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get()); + // Continuous-silence idle case: a full silence block is byte-identical every time for the + // active channel status, so when the buffer already holds one, re-send it as-is. + if (was_empty && this->block_buf_is_silence_block_) { + return this->send_block_(ticks_to_wait); } - - // Encode one 16-bit sample - this->encode_sample_(pcm_data); - pcm_data += 2; - } - - // Send any complete block that was just finished - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; + // Pad with silence frames at the configured width. + while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + this->encode_silence_frame_(); } - ++block_count; + // The buffer is a reusable full-silence block only if it was built entirely from silence; a + // partial real-audio block padded out with silence is not. + this->block_buf_is_silence_block_ = was_empty; } - - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = size; - } - return ESP_OK; + return this->send_block_(ticks_to_wait); } esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { - // If a complete block is already pending (from a previous failed send), emit just that block. - // Otherwise pad the partial block with silence (or generate a full silence block if empty) - // and send. Always emits exactly one block on success. - if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - static const uint8_t SILENCE[2] = {0, 0}; - while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - this->encode_sample_(SILENCE); - } + switch (this->bytes_per_sample_) { + case 2: + return this->flush_with_silence_typed_<2>(ticks_to_wait); + case 3: + return this->flush_with_silence_typed_<3>(ticks_to_wait); + case 4: + return this->flush_with_silence_typed_<4>(ticks_to_wait); + default: + return ESP_ERR_INVALID_STATE; } - return this->send_block_(ticks_to_wait); } } // namespace esphome::i2s_audio diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.h b/esphome/components/i2s_audio/speaker/spdif_encoder.h index 8c5e068841..9e23a858f7 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.h +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.h @@ -24,8 +24,6 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768 // I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo) static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames -// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels) -static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes /// Callback signature for block completion (raw function pointer for minimal overhead) /// @param user_ctx User context pointer passed during callback registration @@ -64,8 +62,16 @@ class SPDIFEncoder { /// @brief Check if currently in preload mode bool is_preload_mode() const { return this->preload_mode_; } + /// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire). + /// Must be called before write() if input width changes from the default (16-bit). Triggers a + /// channel-status rebuild to reflect the new word length. + void set_bytes_per_sample(uint8_t bytes_per_sample); + + /// @brief Get the configured input PCM width in bytes per sample + uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; } + /// @brief Convert PCM audio data to SPDIF BMC encoded data - /// @param src Source PCM audio data (16-bit stereo) + /// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample) /// @param size Size of source data in bytes /// @param ticks_to_wait Timeout for blocking writes /// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent @@ -74,17 +80,6 @@ class SPDIFEncoder { esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr, size_t *bytes_consumed = nullptr); - /// @brief Get the number of PCM bytes currently pending in the partial block buffer - /// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1) - size_t get_pending_pcm_bytes() const; - - /// @brief Get the number of PCM frames currently pending in the partial block buffer - /// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1) - uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; } - - /// @brief Check if there is a partial block pending - bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); } - /// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send, /// or send a full silence block if nothing is pending. Always produces exactly one block on success. /// @param ticks_to_wait Timeout for blocking writes @@ -95,7 +90,7 @@ class SPDIFEncoder { void reset(); /// @brief Set the sample rate for Channel Status Block encoding - /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000) + /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000) /// Call this before writing audio data to ensure correct channel status. void set_sample_rate(uint32_t sample_rate); @@ -103,8 +98,19 @@ class SPDIFEncoder { uint32_t get_sample_rate() const { return this->sample_rate_; } protected: - /// @brief Encode a single 16-bit PCM sample into the current block position - HOT void encode_sample_(const uint8_t *pcm_sample); + /// @brief Encode a single stereo silence frame at the current block position. + /// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the + /// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers. + template void encode_silence_frame_(); + + /// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_. + template + HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed); + + /// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width + /// (or builds a full silence block when nothing is pending) and sends it. Always emits one block. + template esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait); /// @brief Send the completed block via the appropriate callback esp_err_t send_block_(TickType_t ticks_to_wait); @@ -112,15 +118,6 @@ class SPDIFEncoder { /// @brief Build the channel status block from current configuration void build_channel_status_(); - /// @brief Get the channel status bit for a specific frame - /// @param frame Frame number (0-191) - /// @return The C bit value for this frame - ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const { - // Channel status is 192 bits transmitted over 192 frames - // Bit N is transmitted in frame N, LSB-first within each byte - return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1; - } - // Member ordering optimized to minimize padding (largest alignment first) // 4-byte aligned members (pointers and uint32_t) @@ -133,9 +130,13 @@ class SPDIFEncoder { uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding // 1-byte aligned members (grouped together to avoid internal padding) - uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block - bool is_left_channel_{true}; // Alternates L/R for stereo samples - bool preload_mode_{false}; // Whether to use preload callback vs write callback + uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire. + uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block + bool preload_mode_{false}; // Whether to use preload callback vs write callback + // True when spdif_block_buf_ currently holds a complete full-silence block valid for the active + // channel status. A full silence block is deterministic for a given sample rate and word length, + // so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding. + bool block_buf_is_silence_block_{false}; // Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames) // Placed last since std::array has 1-byte alignment diff --git a/tests/components/speaker/spdif_mode.esp32-idf.yaml b/tests/components/i2s_audio/common-spdif_mode.yaml similarity index 52% rename from tests/components/speaker/spdif_mode.esp32-idf.yaml rename to tests/components/i2s_audio/common-spdif_mode.yaml index 4d6859feae..374a4bce1e 100644 --- a/tests/components/speaker/spdif_mode.esp32-idf.yaml +++ b/tests/components/i2s_audio/common-spdif_mode.yaml @@ -1,13 +1,3 @@ -substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO12 - spdif_data_pin: GPIO4 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - i2s_audio: - id: i2s_output @@ -20,6 +10,5 @@ speaker: use_apll: true timeout: 2s sample_rate: 48000 - bits_per_sample: 16bit channel: stereo i2s_mode: primary diff --git a/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml b/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml new file mode 100644 index 0000000000..a69d808d1d --- /dev/null +++ b/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + i2s_mclk_pin: GPIO25 + i2s_dout_pin: GPIO12 + spdif_data_pin: GPIO4 + +<<: !include common-spdif_mode.yaml From 09121226344c26f530dee36e78041616ac8d381f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 19 May 2026 15:16:00 -0400 Subject: [PATCH 1305/2214] [sendspin] Bump sendspin to v0.6.0 (#16496) --- 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 35280020ba..36f13f7d07 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 35c55cbb4d..42d0d5de6b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.5.0 + version: 0.6.0 lvgl/lvgl: version: 9.5.0 From 9bb70d568da7fc65059f806fc74c81c4639cc3ce Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 18 May 2026 15:56:44 +1200 Subject: [PATCH 1306/2214] [ci] Move ha-addon and schema release triggers to version-notifier (#16490) --- .github/workflows/release.yml | 70 +---------------------------------- 1 file changed, 1 insertion(+), 69 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1086c858c..9799f882db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,74 +212,6 @@ jobs: docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \ $(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *) - deploy-ha-addon-repo: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: - - init - - deploy-manifest - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: home-assistant-addon - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - let description = "ESPHome"; - if (context.eventName == "release") { - description = ${{ toJSON(github.event.release.body) }}; - } - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "home-assistant-addon", - workflow_id: "bump-version.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - content: description - } - }) - - deploy-esphome-schema: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: [init] - environment: ${{ needs.init.outputs.deploy_env }} - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: esphome-schema - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "esphome-schema", - workflow_id: "generate-schemas.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - } - }) - version-notifier: if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' runs-on: ubuntu-latest @@ -302,7 +234,7 @@ jobs: with: github-token: ${{ steps.generate-token.outputs.token }} script: | - github.rest.actions.createWorkflowDispatch({ + await github.rest.actions.createWorkflowDispatch({ owner: "esphome", repo: "version-notifier", workflow_id: "notify.yml", From e1793a1eff10f14488462e53d848c5d27b9c942f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 23:29:03 -0700 Subject: [PATCH 1307/2214] Bump zeroconf from 0.149.3 to 0.149.7 (#16492) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 63a25c8e36..3c66db489a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.149.3 +zeroconf==0.149.7 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From e9ef58d99d50f258f6c0d2a50f713af1ba198426 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:48 -0500 Subject: [PATCH 1308/2214] [sen5x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16463) --- esphome/components/sen5x/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index ce35cf5bf1..480654ee1b 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -25,7 +25,6 @@ from esphome.const import ( CONF_TEMPERATURE_COMPENSATION, CONF_TIME_CONSTANT, CONF_VOC, - DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, @@ -77,7 +76,6 @@ def _gas_sensor( return sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend( { From bbf5fe84501f6a18fbacfa8c456e9a071102c468 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:51 -0500 Subject: [PATCH 1309/2214] [sgp4x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16464) --- esphome/components/sgp4x/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 1e58a0f26a..d407f20a4e 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, CONF_VOC, - DEVICE_CLASS_AQI, ICON_RADIATOR, STATE_CLASS_MEASUREMENT, ) @@ -72,13 +71,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_VOC): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend(VOC_SENSOR), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend(NOX_SENSOR), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, From 25739091da33cfd797bc85fa440aa1550a998cc1 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:20:08 -0500 Subject: [PATCH 1310/2214] [sen6x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16465) --- esphome/components/sen6x/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 071478e719..19c0cb500e 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, CONF_VOC, - DEVICE_CLASS_AQI, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, @@ -93,13 +92,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_VOC): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CO2): sensor.sensor_schema( From 41ad2ba76380db37c72b915d47bd7029ed8cad98 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 18 May 2026 22:53:19 -0400 Subject: [PATCH 1311/2214] [i2s_audio] Compute ring buffer size with SPDIF sample count (#16400) --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 21 ++++++++++--------- .../i2s_audio/speaker/i2s_audio_speaker.h | 1 - .../speaker/i2s_audio_speaker_standard.cpp | 1 + 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 8f67562a77..877f67775b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_. xQueueReset(this->write_records_queue_); - const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT; - // Ensure ring buffer duration is at least the duration of all DMA buffers - const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); - // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); - // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and - // avoids unnecessary single-frame splices. - const size_t ring_buffer_size = - (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; - // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames + // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz), + // not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size. const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; const size_t bytes_to_fill_single_dma_buffer = this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT; + + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers. + const size_t requested_ring_buffer_bytes = + (this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; + const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes); bool successful_setup = false; std::unique_ptr audio_source; @@ -177,7 +177,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // on_sent events drain in lockstep without crediting any audio frames. this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); + // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. + esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); if (preload_err != ESP_OK) { break; // DMA preload buffer full or error } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 20bb05e322..34792bdbea 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -19,7 +19,6 @@ namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations -static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t TASK_STACK_SIZE = 4096; static constexpr ssize_t TASK_PRIORITY = 19; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index e69601e87a..ffe901504d 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -16,6 +16,7 @@ namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t DMA_BUFFERS_COUNT = 4; // Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, // doubled so that a transient backlog never overruns the queue (which would desync the lockstep From 43cc9fc879045fa20f41ad0231dd5cc1513deb3e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 19 May 2026 18:53:36 +0200 Subject: [PATCH 1312/2214] [zigbee] don't allow zigbee + thread or access point (#16499) --- esphome/components/zigbee/__init__.py | 2 ++ esphome/components/zigbee/zigbee_esp32.py | 10 +++------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 69e3fe9c5a..c75b0773d2 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -50,6 +50,8 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@luar123", "@tomaszduda23"] +CONFLICTS_WITH = ["openthread"] + BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index e446377a06..89efd583ab 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -117,15 +117,11 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: if not CORE.is_esp32: return config if CONF_WIFI in fv.full_config.get(): - if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]: - raise cv.Invalid( - "Only Zigbee End Device can be used together with a Wifi Access Point." - ) if CONF_AP in fv.full_config.get()[CONF_WIFI]: - _LOGGER.warning( - "Wifi Access Point might be unstable while Zigbee is active, use only as fallback." + raise cv.Invalid( + "A Wifi Access Point can not be used together with Zigbee." ) - elif config[CONF_ROUTER]: + if config[CONF_ROUTER]: _LOGGER.warning( "The Zigbee Router might miss packets while Wifi is active and could destabilize " "your network. Use only if Wifi is off most of the time." From 65e1e210de9150e31bfa1f30a488f68e3ff92a31 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 19 May 2026 12:57:18 -0400 Subject: [PATCH 1313/2214] [espidf] Print RAM summary on ESP32-S3 / unified-DIRAM variants (#16494) --- esphome/espidf/size_summary.py | 7 +- tests/unit_tests/test_size_summary.py | 128 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/test_size_summary.py diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 9477e664b3..3ba0bf3b4d 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -94,9 +94,10 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: _LOGGER.debug("Skipping size summary: %s", e) return - dram = data.get("memory_types", {}).get("DRAM") or {} - ram_used = dram.get("used") - ram_total = dram.get("size") + memory_types = data.get("memory_types", {}) + ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {} + ram_used = ram_region.get("used") + ram_total = ram_region.get("size") if ram_total and ram_used is not None: print(f"RAM: {_format_bar(ram_used, ram_total)}") diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py new file mode 100644 index 0000000000..933be88476 --- /dev/null +++ b/tests/unit_tests/test_size_summary.py @@ -0,0 +1,128 @@ +"""Tests for esphome.espidf.size_summary.print_summary.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from esphome.espidf.size_summary import print_summary + + +def _write_size_json(tmp_path: Path, data: dict) -> Path: + """Drop a fake esp_idf_size.json under ``tmp_path`` and return the path.""" + out = tmp_path / "esp_idf_size.json" + out.write_text(json.dumps(data)) + return out + + +def _esp32_size_data() -> dict: + """Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM).""" + return { + "image_size": 827455, + "memory_types": { + "DRAM": { + "size": 180736, + "used": 47332, + "sections": { + ".dram0.bss": {"abbrev_name": ".bss", "size": 30616}, + ".dram0.data": {"abbrev_name": ".data", "size": 16716}, + }, + }, + "IRAM": { + "size": 131072, + "used": 80351, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 79323}, + ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + }, + }, + }, + } + + +def _s3_size_data() -> dict: + """Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM).""" + return { + "image_size": 724215, + "memory_types": { + "DIRAM": { + "size": 341760, + "used": 104999, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 58051}, + ".dram0.bss": {"abbrev_name": ".bss", "size": 27088}, + ".dram0.data": {"abbrev_name": ".data", "size": 19708}, + ".noinit": {"abbrev_name": ".noinit", "size": 152}, + }, + }, + "IRAM": { + "size": 16384, + "used": 16384, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 15356}, + ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + }, + }, + }, + } + + +def test_print_summary_esp32_uses_dram( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "RAM:" in out + assert "used 47332 bytes from 180736 bytes" in out + + +def test_print_summary_s3_falls_back_to_diram( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "used 104999 bytes from 341760 bytes" in out + + +def test_print_summary_skips_when_diram_total_collapses( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero-size region drops the RAM line rather than divide by zero.""" + size_json = _write_size_json( + tmp_path, + { + "memory_types": { + "DIRAM": { + "size": 0, + "used": 0, + "sections": {}, + }, + }, + }, + ) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "RAM:" not in out + + +def test_print_summary_handles_missing_json( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Missing size json is non-fatal and prints nothing.""" + print_summary(tmp_path / "does_not_exist.json", partitions_csv=None) + assert capsys.readouterr().out == "" + + +def test_print_summary_handles_no_memory_types( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A size json without ``memory_types`` still doesn't crash.""" + size_json = _write_size_json(tmp_path, {"image_size": 0}) + print_summary(size_json, partitions_csv=None) + assert capsys.readouterr().out == "" From 302938f87507a3dc317829be93d11df353c516e2 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 19 May 2026 14:37:41 -0400 Subject: [PATCH 1314/2214] [i2s_audio] Optimize SPDIF encoder and suport higher bit depth audio (#16504) Co-authored-by: Keith Burzinski --- .../components/i2s_audio/speaker/__init__.py | 7 +- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 12 +- .../i2s_audio/speaker/spdif_encoder.cpp | 537 +++++++++++------- .../i2s_audio/speaker/spdif_encoder.h | 59 +- .../common-spdif_mode.yaml} | 11 - .../test-spdif_speaker.esp32-idf.yaml | 8 + 6 files changed, 372 insertions(+), 262 deletions(-) rename tests/components/{speaker/spdif_mode.esp32-idf.yaml => i2s_audio/common-spdif_mode.yaml} (52%) create mode 100644 tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 759cc40ca9..8215d8b518 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -89,10 +89,10 @@ def _set_num_channels_from_config(config): def _set_stream_limits(config): if config.get(CONF_SPDIF_MODE, False): - # SPDIF mode: fixed to 16-bit stereo at configured sample rate + # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( min_bits_per_sample=16, - max_bits_per_sample=16, + max_bits_per_sample=32, min_channels=2, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), @@ -213,9 +213,6 @@ def _final_validate(config): ) if config[CONF_CHANNEL] != CONF_STEREO: raise cv.Invalid("SPDIF mode only supports stereo channel configuration") - # bits_per_sample is converted to float by the schema - if config[CONF_BITS_PER_SAMPLE] != 16: - raise cv.Invalid("SPDIF mode only supports 16 bits per sample") if not config[CONF_USE_APLL]: raise cv.Invalid( "SPDIF mode requires 'use_apll: true' for accurate clock generation" diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 877f67775b..989bcf2977 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -411,8 +411,9 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s this->sample_rate_, audio_stream_info.get_sample_rate()); return ESP_ERR_NOT_SUPPORTED; } - if (audio_stream_info.get_bits_per_sample() != 16) { - ESP_LOGE(TAG, "Only supports 16 bits per sample"); + const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) { + ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample); return ESP_ERR_NOT_SUPPORTED; } if (audio_stream_info.get_channels() != 2) { @@ -420,11 +421,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s return ESP_ERR_NOT_SUPPORTED; } - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); - return ESP_ERR_NOT_SUPPORTED; - } + // Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire. + this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8); if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp index 42a72346cc..30146e0a70 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start) static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel // BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33 -// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33. +// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero. static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33; // Constexpr BMC encoder for compile-time LUT generation. @@ -36,21 +36,43 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) { return bmc; } -// 4-bit BMC lookup table: 16 entries (16 bytes in flash) -// Index: 4-bit data value (0-15), always phase=true start +// Compile-time parity helper (constexpr-friendly, runs only at LUT build time). +static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) { + uint32_t p = 0; + for (uint32_t b = 0; b < num_bits; b++) + p ^= (value >> b) & 1u; + return p; +} + +// Combined BMC + phase-delta lookup tables. +// Each entry packs the BMC pattern (lower bits, phase=high start) together with +// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0). +// XORing the delta into the running phase mask propagates parity across chunks +// without an explicit popcount. + +// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash. +// Bits 0-7 : 8-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_4 = [] { - std::array t{}; - for (uint32_t i = 0; i < 16; i++) - t[i] = static_cast(bmc_lut_encode(i, 4)); + std::array t{}; + for (uint32_t i = 0; i < 16; i++) { + uint32_t bmc = bmc_lut_encode(i, 4); + uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); -// 8-bit BMC lookup table: 256 entries (512 bytes in flash) -// Index: 8-bit data value (0-255), always phase=true start +// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash. +// Bits 0-15 : 16-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_8 = [] { - std::array t{}; - for (uint32_t i = 0; i < 256; i++) - t[i] = bmc_lut_encode(i, 8); + std::array t{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t bmc = bmc_lut_encode(i, 8); + uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); @@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() { } ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); - // Build initial channel status block with default sample rate + // Build initial channel status block with default sample rate and width this->build_channel_status_(); this->reset(); @@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() { void SPDIFEncoder::reset() { this->spdif_block_ptr_ = this->spdif_block_buf_.get(); this->frame_in_block_ = 0; - this->is_left_channel_ = true; + this->block_buf_is_silence_block_ = false; } void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { @@ -84,31 +106,27 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { } } +void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) { + if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) { + ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample); + return; + } + if (this->bytes_per_sample_ != bytes_per_sample) { + this->bytes_per_sample_ = bytes_per_sample; + this->build_channel_status_(); + // Discard any partial block built at the previous width so we never mix widths on the wire. + this->reset(); + ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8); + } +} + void SPDIFEncoder::build_channel_status_() { // IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes) - // Transmitted LSB-first within each byte, one bit per frame via C bit - // - // Byte 0: Control bits - // Bit 0: 0 = Consumer format (not professional AES3) - // Bit 1: 0 = PCM audio (not non-audio data like AC3) - // Bit 2: 0 = No copyright assertion - // Bits 3-5: 000 = No pre-emphasis - // Bits 6-7: 00 = Mode 0 (basic consumer format) - // - // Byte 1: Category code (0x00 = general, 0x01 = CD, etc.) - // - // Byte 2: Source/channel numbers - // Bits 0-3: Source number (0 = unspecified) - // Bits 4-7: Channel number (0 = unspecified) - // - // Byte 3: Sample frequency and clock accuracy - // Bits 0-3: Sample frequency code - // Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32) - // Bits 6-7: Reserved (0) - // - // Bytes 4-23: Reserved (zeros for basic compliance) + // Transmitted LSB-first within each byte, one bit per frame via C bit. + + // Any cached silence block was built for the previous channel status; it is now stale. + this->block_buf_is_silence_block_ = false; - // Clear all bytes first this->channel_status_.fill(0); // Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0 @@ -140,132 +158,148 @@ void SPDIFEncoder::build_channel_status_() { // Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5 this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0 - // Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.) + // Byte 4: Word length encoding (IEC 60958-3 consumer) + // bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits) + // bits 1-3: word length code relative to the max + // For our supported widths: + // 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20" + // 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24" + // 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code. + uint8_t word_length_code; + switch (this->bytes_per_sample_) { + case 2: + word_length_code = 0x02; + break; + case 3: // Shared case + case 4: + word_length_code = 0x0D; + break; + default: + word_length_code = 0x00; // not specified + break; + } + this->channel_status_[4] = word_length_code; } -HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) { - // ============================================================================ - // Build raw 32-bit subframe (IEC 60958 format) - // ============================================================================ - // Bit layout: - // Bits 0-3: Preamble (handled separately, not in raw_subframe) - // Bits 4-7: Auxiliary audio data (zeros for 16-bit audio) - // Bits 8-11: Audio LSB extension (zeros for 16-bit audio) - // Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field) - // Bit 28: V (Validity) - 0 = valid audio - // Bit 29: U (User data) - 0 - // Bit 30: C (Channel status) - from channel status block - // Bit 31: P (Parity) - even parity over bits 4-31 - // ============================================================================ +// Extract the C bit for the given frame from channel_status_ and shift it into bit 30 +// so it can be OR'd directly into a raw subframe. +ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array &channel_status, + uint32_t frame) { + return static_cast((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30; +} - // Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB) - uint32_t raw_subframe = (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); +// ============================================================================ +// IEC 60958 subframe bit layout +// ============================================================================ +// Bits 0-3: Preamble (handled separately, not in raw_subframe) +// Bits 4-7: Auxiliary audio data / 24-bit audio LSB +// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit) +// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode) +// Bit 28: V (Validity) - 0 = valid audio +// Bit 29: U (User data) - 0 +// Bit 30: C (Channel status) - from channel status block +// Bit 31: P (Parity) - even parity over bits 4-31 +// ============================================================================ - // V = 0 (valid audio), U = 0 (no user data) - // C = channel status bit for current frame (same bit used for both L and R subframes) - bool c_bit = this->get_channel_status_bit_(this->frame_in_block_); - if (c_bit) { - raw_subframe |= (1U << 30); +// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes. +// Caller is responsible for OR-ing in the C bit and parity. +template ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) { + static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample"); + if constexpr (Bps == 2) { + // 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27. + return (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); + } else if constexpr (Bps == 3) { + // 24-bit input: full 24-bit audio field, bits 4-27. + return (static_cast(pcm_sample[2]) << 20) | (static_cast(pcm_sample[1]) << 12) | + (static_cast(pcm_sample[0]) << 4); + } else { // Bps == 4 + // 32-bit input truncated to 24-bit: drop the lowest byte. + return (static_cast(pcm_sample[3]) << 20) | (static_cast(pcm_sample[2]) << 12) | + (static_cast(pcm_sample[1]) << 4); } +} - // Calculate even parity over bits 4-30 - // This ensures consistent BMC ending phase regardless of audio content - uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30) - uint32_t ones_count = __builtin_popcount(bits_4_30); - uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even - raw_subframe |= parity << 31; // Set P bit to make total even +// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes +// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is +// derived from the cumulative parity-mask delta of the per-byte LUT lookups. +// +// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7. +// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first. +// All preambles end at phase HIGH, so phase=true at the start of bit 4. +// +// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each +// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the +// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to +// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the +// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit +// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output +// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower +// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`. +template +ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) { + if constexpr (Bps == 2) { + // 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants. + // Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even), + // so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity. + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = lut_n & 0xFFu; + uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0 - // ============================================================================ - // Select preamble based on position in block and channel - // ============================================================================ - // B = block start (left channel, frame 0 of 192-frame block) - // M = left channel (frames 1-191) - // W = right channel (all frames) - uint8_t preamble; - if (this->is_left_channel_) { - preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + // phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31. + bmc_24_31 ^= phase_mask & 1u; + + dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } else { - preamble = PREAMBLE_W; + // 24-bit (and 32-bit truncated) path: bits 4-11 are live audio. + uint32_t byte_lo = (raw_subframe >> 4) & 0xFF; + uint32_t lut_l = BMC_LUT_8[byte_lo]; + uint32_t bmc_4_11 = lut_l & 0xFFFFu; + uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0 + + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu); + phase_mask ^= lut_n >> 16; + + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + bmc_24_31 ^= phase_mask & 1u; + + // word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15) + // word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31) + dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } +} - // ============================================================================ - // BMC encode the data portion (bits 4-31) using lookup tables - // ============================================================================ - // The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15. - // This applies to BOTH word[0] and word[1]. - // - // word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15] - // For correct S/PDIF subframe order (preamble → aux → audio): - // - bits 16-23: preamble (8 BMC bits) - // - bits 24-31: BMC(subframe bits 4-7) - first aux nibble - // - bits 0-7: BMC(subframe bits 8-11) - second aux nibble - // - bits 8-15: BMC(subframe bits 12-15) - audio low nibble - // - // word[1] transmission order: [16-31] → [0-15] - // For correct S/PDIF subframe order: - // - bits 16-31: BMC(subframe bits 16-23) - audio mid byte - // - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP - // ============================================================================ - - // All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio; - // two zero nibbles flip phase 8 times total → back to HIGH. - // So bits 12-15 always start encoding at phase=true. - - // Bits 12-15: 4-bit LUT lookup (always phase=true start) - uint32_t nibble = (raw_subframe >> 12) & 0xF; - uint32_t bmc_12_15 = BMC_LUT_4[nibble]; - - // Phase tracking via branchless XOR mask: - // - 0x0000 means phase=true (use LUT value directly) - // - 0xFFFF means phase=false (complement LUT value) - // End phase = start XOR (popcount & 1) since zero-bits flip phase, - // and for even bit widths: #zeros parity == popcount parity. - uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF; - - // Bits 16-23: 8-bit LUT lookup with phase correction - uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; - uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask; - phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF; - - // Bits 24-31: 8-bit LUT lookup with phase correction - uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; - uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask; - - // ============================================================================ - // Combine with correct positioning for I2S transmission - // ============================================================================ - // I2S with halfword swap: transmits bits 16-31, then bits 0-15. - // Within each halfword, MSB (highest bit) is transmitted first. - // - // For upper halfword (bits 16-31): bit 31 → bit 16 - // For lower halfword (bits 0-15): bit 15 → bit 0 - // - // Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15 - // - // word[0] layout for correct transmission: - // bits 24-31: preamble (transmitted 1st, as MSB of upper halfword) - // bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7) - // bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11) - // bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble) - // - // word[1] layout: - // bits 16-31: bmc_16_23 (transmitted 5th) - // bits 0-15: bmc_24_31 (transmitted 6th) - this->spdif_block_ptr_[0] = - bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); - this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16); - this->spdif_block_ptr_ += 2; - - // ============================================================================ - // Update position tracking - // ============================================================================ - if (!this->is_left_channel_) { - // Completed a stereo frame, advance frame counter - if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { - this->frame_in_block_ = 0; - } +template void SPDIFEncoder::encode_silence_frame_() { + static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0}; + uint32_t raw = build_raw_subframe(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_); + uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + bmc_encode_subframe(raw, preamble_l, this->spdif_block_ptr_); + bmc_encode_subframe(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2); + this->spdif_block_ptr_ += 4; + if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { + this->frame_in_block_ = 0; } - this->is_left_channel_ = !this->is_left_channel_; } esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { @@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { return err; } -size_t SPDIFEncoder::get_pending_pcm_bytes() const { - if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) { - return 0; +template +HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, + uint32_t *blocks_sent, size_t *bytes_consumed) { + const uint8_t *pcm_data = src; + const uint8_t *const pcm_end = src + size; + uint32_t block_count = 0; + + // Hot state lives in locals so the compiler can keep it in registers across the + // per-frame encoding work; byte writes through block_ptr may alias the member fields, + // which would block register allocation if the encoding read them directly from this->*. + uint32_t *block_ptr = this->spdif_block_ptr_; + uint32_t *const block_buf = this->spdif_block_buf_.get(); + uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32; + uint32_t frame = this->frame_in_block_; + const std::array &channel_status = this->channel_status_; + + auto save_state = [&]() { + this->spdif_block_ptr_ = block_ptr; + this->frame_in_block_ = static_cast(frame); + }; + + auto report_out_params = [&]() { + if (blocks_sent != nullptr) + *blocks_sent = block_count; + if (bytes_consumed != nullptr) + *bytes_consumed = pcm_data - src; + }; + + // Send a completed block if the buffer is full, propagating any error. + // send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it + // unchanged on error -- mirror both behaviors in our local block_ptr. + auto maybe_send = [&]() -> esp_err_t { + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + return ESP_OK; + }; + + // Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only + // buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from + // 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or + // preamble branch. The encoding body is inlined here so block_ptr lives in a register + // for the duration of the loop. + while (pcm_data + 2 * Bps <= pcm_end) { + if (frame == 0) { + esp_err_t err = maybe_send(); + if (err != ESP_OK) + return err; + + uint32_t c_bit = c_bit_for_frame(channel_status, 0); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_B, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + frame = 1; + pcm_data += 2 * Bps; + } + + // The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The + // input-size bound is folded into end_frame so a single `frame < end_frame` test + // governs termination. + uint32_t input_frames = static_cast(pcm_end - pcm_data) / (2u * Bps); + uint32_t end_frame = SPDIF_BLOCK_SAMPLES; + if (frame + input_frames < end_frame) + end_frame = frame + input_frames; + + while (frame < end_frame) { + uint32_t c_bit = c_bit_for_frame(channel_status, frame); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_M, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + ++frame; + pcm_data += 2 * Bps; + } + if (frame >= SPDIF_BLOCK_SAMPLES) + frame = 0; } - // Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer - // So pending uint32s / 2 = pending samples, and each sample is 2 bytes - size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get(); - size_t pending_samples = pending_uint32s / 2; - return pending_samples * 2; // 2 bytes per sample + + // Send any complete block that was just finished. + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + + save_state(); + report_out_params(); + return ESP_OK; } HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, size_t *bytes_consumed) { - const uint8_t *pcm_data = src; - const uint8_t *pcm_end = src + size; - uint32_t block_count = 0; + if (size > 0) { + // Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block. + this->block_buf_is_silence_block_ = false; + } + switch (this->bytes_per_sample_) { + case 2: + return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 3: + return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 4: + return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + default: + return ESP_ERR_INVALID_STATE; + } +} - while (pcm_data < pcm_end) { - // Check if there's a pending complete block from a previous failed send - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; - } - ++block_count; +template esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) { + // If a complete block is already pending (from a previous failed send), emit just that block. + // Otherwise pad the partial block with silence (or generate a full silence block if empty) and + // send. Always emits exactly one block on success. + if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get()); + // Continuous-silence idle case: a full silence block is byte-identical every time for the + // active channel status, so when the buffer already holds one, re-send it as-is. + if (was_empty && this->block_buf_is_silence_block_) { + return this->send_block_(ticks_to_wait); } - - // Encode one 16-bit sample - this->encode_sample_(pcm_data); - pcm_data += 2; - } - - // Send any complete block that was just finished - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; + // Pad with silence frames at the configured width. + while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + this->encode_silence_frame_(); } - ++block_count; + // The buffer is a reusable full-silence block only if it was built entirely from silence; a + // partial real-audio block padded out with silence is not. + this->block_buf_is_silence_block_ = was_empty; } - - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = size; - } - return ESP_OK; + return this->send_block_(ticks_to_wait); } esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { - // If a complete block is already pending (from a previous failed send), emit just that block. - // Otherwise pad the partial block with silence (or generate a full silence block if empty) - // and send. Always emits exactly one block on success. - if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - static const uint8_t SILENCE[2] = {0, 0}; - while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - this->encode_sample_(SILENCE); - } + switch (this->bytes_per_sample_) { + case 2: + return this->flush_with_silence_typed_<2>(ticks_to_wait); + case 3: + return this->flush_with_silence_typed_<3>(ticks_to_wait); + case 4: + return this->flush_with_silence_typed_<4>(ticks_to_wait); + default: + return ESP_ERR_INVALID_STATE; } - return this->send_block_(ticks_to_wait); } } // namespace esphome::i2s_audio diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.h b/esphome/components/i2s_audio/speaker/spdif_encoder.h index 8c5e068841..9e23a858f7 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.h +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.h @@ -24,8 +24,6 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768 // I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo) static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames -// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels) -static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes /// Callback signature for block completion (raw function pointer for minimal overhead) /// @param user_ctx User context pointer passed during callback registration @@ -64,8 +62,16 @@ class SPDIFEncoder { /// @brief Check if currently in preload mode bool is_preload_mode() const { return this->preload_mode_; } + /// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire). + /// Must be called before write() if input width changes from the default (16-bit). Triggers a + /// channel-status rebuild to reflect the new word length. + void set_bytes_per_sample(uint8_t bytes_per_sample); + + /// @brief Get the configured input PCM width in bytes per sample + uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; } + /// @brief Convert PCM audio data to SPDIF BMC encoded data - /// @param src Source PCM audio data (16-bit stereo) + /// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample) /// @param size Size of source data in bytes /// @param ticks_to_wait Timeout for blocking writes /// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent @@ -74,17 +80,6 @@ class SPDIFEncoder { esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr, size_t *bytes_consumed = nullptr); - /// @brief Get the number of PCM bytes currently pending in the partial block buffer - /// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1) - size_t get_pending_pcm_bytes() const; - - /// @brief Get the number of PCM frames currently pending in the partial block buffer - /// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1) - uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; } - - /// @brief Check if there is a partial block pending - bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); } - /// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send, /// or send a full silence block if nothing is pending. Always produces exactly one block on success. /// @param ticks_to_wait Timeout for blocking writes @@ -95,7 +90,7 @@ class SPDIFEncoder { void reset(); /// @brief Set the sample rate for Channel Status Block encoding - /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000) + /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000) /// Call this before writing audio data to ensure correct channel status. void set_sample_rate(uint32_t sample_rate); @@ -103,8 +98,19 @@ class SPDIFEncoder { uint32_t get_sample_rate() const { return this->sample_rate_; } protected: - /// @brief Encode a single 16-bit PCM sample into the current block position - HOT void encode_sample_(const uint8_t *pcm_sample); + /// @brief Encode a single stereo silence frame at the current block position. + /// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the + /// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers. + template void encode_silence_frame_(); + + /// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_. + template + HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed); + + /// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width + /// (or builds a full silence block when nothing is pending) and sends it. Always emits one block. + template esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait); /// @brief Send the completed block via the appropriate callback esp_err_t send_block_(TickType_t ticks_to_wait); @@ -112,15 +118,6 @@ class SPDIFEncoder { /// @brief Build the channel status block from current configuration void build_channel_status_(); - /// @brief Get the channel status bit for a specific frame - /// @param frame Frame number (0-191) - /// @return The C bit value for this frame - ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const { - // Channel status is 192 bits transmitted over 192 frames - // Bit N is transmitted in frame N, LSB-first within each byte - return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1; - } - // Member ordering optimized to minimize padding (largest alignment first) // 4-byte aligned members (pointers and uint32_t) @@ -133,9 +130,13 @@ class SPDIFEncoder { uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding // 1-byte aligned members (grouped together to avoid internal padding) - uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block - bool is_left_channel_{true}; // Alternates L/R for stereo samples - bool preload_mode_{false}; // Whether to use preload callback vs write callback + uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire. + uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block + bool preload_mode_{false}; // Whether to use preload callback vs write callback + // True when spdif_block_buf_ currently holds a complete full-silence block valid for the active + // channel status. A full silence block is deterministic for a given sample rate and word length, + // so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding. + bool block_buf_is_silence_block_{false}; // Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames) // Placed last since std::array has 1-byte alignment diff --git a/tests/components/speaker/spdif_mode.esp32-idf.yaml b/tests/components/i2s_audio/common-spdif_mode.yaml similarity index 52% rename from tests/components/speaker/spdif_mode.esp32-idf.yaml rename to tests/components/i2s_audio/common-spdif_mode.yaml index 4d6859feae..374a4bce1e 100644 --- a/tests/components/speaker/spdif_mode.esp32-idf.yaml +++ b/tests/components/i2s_audio/common-spdif_mode.yaml @@ -1,13 +1,3 @@ -substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO12 - spdif_data_pin: GPIO4 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - i2s_audio: - id: i2s_output @@ -20,6 +10,5 @@ speaker: use_apll: true timeout: 2s sample_rate: 48000 - bits_per_sample: 16bit channel: stereo i2s_mode: primary diff --git a/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml b/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml new file mode 100644 index 0000000000..a69d808d1d --- /dev/null +++ b/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + i2s_mclk_pin: GPIO25 + i2s_dout_pin: GPIO12 + spdif_data_pin: GPIO4 + +<<: !include common-spdif_mode.yaml From e4c8d1f43023cb805fce97e63afe8e35cf12157b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 19 May 2026 15:16:00 -0400 Subject: [PATCH 1315/2214] [sendspin] Bump sendspin to v0.6.0 (#16496) --- 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 35280020ba..36f13f7d07 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 35c55cbb4d..42d0d5de6b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.5.0 + version: 0.6.0 lvgl/lvgl: version: 9.5.0 From 19c4da2aa595a8cb0c25070c599d7b515813d1bd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 May 2026 12:53:26 +1200 Subject: [PATCH 1316/2214] Bump version to 2026.5.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 641a491828..433fcc6c45 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.5.0b2 +PROJECT_NUMBER = 2026.5.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 d6d533a702..3e576c8899 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0b2" +__version__ = "2026.5.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 8927ade7897904f84fad29d8cb446e7fd62fcafb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 15:40:20 +0000 Subject: [PATCH 1317/2214] Bump zeroconf from 0.149.7 to 0.149.12 (#16510) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e3de4a134c..21005fe85f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.149.7 +zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From fbe212944b01f7e768ade5edd7d259394566dde1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 10:51:53 -0500 Subject: [PATCH 1318/2214] Bump aioesphomeapi from 45.0.3 to 45.0.4 (#16513) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 21005fe85f..cfb1960ea1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.3 +aioesphomeapi==45.0.4 zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 60afad442c83309ccfd8203e61689b263023f07c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 13:36:18 -0400 Subject: [PATCH 1319/2214] [esp32] Fix sdkconfig int values silently clamped to default (#16515) --- esphome/components/esp32/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e9b0f1fd0a..fd28d80536 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2004,7 +2004,8 @@ async def to_code(config): if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: - add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) + # Kconfig range is [1,63]; 0 gets clamped to the default. + add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1) _configure_lwip_max_sockets(conf) @@ -2251,7 +2252,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) elif advanced[CONF_DISABLE_FATFS]: add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0) + # 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)) From 52c9a2d07bcde57f0ccc5fde9f9f20bc97e1c3f9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 14:31:58 -0400 Subject: [PATCH 1320/2214] [espidf] Drop version field from generated idf_component.yml (#16511) --- esphome/components/esp32/__init__.py | 3 +- esphome/espidf/component.py | 66 ++++------------------- tests/unit_tests/test_espidf_component.py | 26 ++++++--- 3 files changed, 30 insertions(+), 65 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index fd28d80536..8bc8a71c94 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2490,9 +2490,8 @@ def _write_sdkconfig(): def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: dependency: dict[str, str] = {} - name, version, path = generate_idf_component(library) + name, _version, path = generate_idf_component(library) dependency["override_path"] = str(path) - dependency["version"] = version return name, dependency diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b9202fb6bf..b1352f7791 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -154,41 +154,6 @@ class IDFComponent: self.path = self.source.download(self.get_sanitized_name(), force=force) -def _sanitize_version(version: str) -> str: - """ - Sanitize a version string by removing common requirement prefixes or a leading v. - - Args: - version: Version string to clean. - - Returns: - Cleaned version string without common requirement symbols. - """ - version = version.strip() - - prefixes = ( - "^", - "~=", - "~", - ">=", - "<=", - "==", - "!=", - ">", - "<", - "=", - "v", - "V", - ) - - for p in prefixes: - if version.startswith(p): - version = version[len(p) :] - break - - return version.strip() - - def _get_package_from_pio_registry( username: str | None, pkgname: str, requirements: str ) -> tuple[str, str, str | None, str | None]: @@ -396,7 +361,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: - # Parse repository URL to extract name and version + # Parse repository URL: path becomes the component name, fragment + # becomes the git ref stored on GitSource. split_result = urlsplit(library.repository) if not split_result.fragment.strip(): raise ValueError(f"Missing ref in URL {library.repository}") @@ -405,8 +371,10 @@ def _convert_library_to_component(library: Library) -> IDFComponent: name = str(split_result.path).strip("/") name = name.removesuffix(".git") - # Sanitize version - version = _sanitize_version(split_result.fragment) + # IDF Component Manager only accepts "*", a 40-char commit hash, or + # semver here. The actual git ref is preserved in GitSource.ref; + # override_path makes this field cosmetic at build time. + version = "*" repository = urlunsplit(split_result._replace(fragment="")) source = GitSource(str(repository), split_result.fragment) @@ -619,9 +587,6 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if description: data["description"] = description - # Do not use the version from library.json/library.properties; it may be incorrect. - data["version"] = component.version - repository = component.data.get("repository", {}).get("url", None) if repository: data["repository"] = repository @@ -631,20 +596,11 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if "dependencies" not in data: data["dependencies"] = {} - # Add this dependency to dependencies - dep = {} - dep["version"] = dependency.version - - # Should use dependency.path as override path - try: - dep["override_path"] = str(dependency.path) - except RuntimeError as e: - # No local path: only a GitSource can substitute its URL. - if not isinstance(dependency.source, GitSource): - raise e - dep["git"] = dependency.source.url - - data["dependencies"][dependency.get_sanitized_name()] = dep + # Every dependency goes through _generate_idf_component → + # component.download() before this runs, so .path is always set. + data["dependencies"][dependency.get_sanitized_name()] = { + "override_path": str(dependency.path), + } return yaml_util.dump(data) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 8977b05d23..373432f7d2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -203,7 +203,7 @@ def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) - assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n" + assert result == "description: test\nrepository: http://aaa\n" def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): @@ -217,18 +217,16 @@ def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): assert ( result - == f"""version: 1.0.0 -dependencies: + == f"""dependencies: dep: - version: '1.0' override_path: {dep.path} """ ) -def test_generate_idf_component_yml_missing_path_reraises(tmp_component): - # A dep without a path and without a recognised source should re-raise - # the underlying RuntimeError instead of silently producing a bad manifest. +def test_generate_idf_component_yml_missing_path_raises(tmp_component): + # A dep without a path is a contract violation — every dep is expected + # to have been downloaded before YAML generation. Raise loudly. dep = IDFComponent("foo/bar", "1.0", source=None) tmp_component.dependencies = [dep] @@ -422,8 +420,20 @@ def test_convert_library_with_repository(): result = _convert_library_to_component(lib) assert result.name == "foo/bar" - assert result.version == "1.2.3" + assert result.version == "*" assert isinstance(result.source, GitSource) + assert result.source.ref == "v1.2.3" + + +def test_convert_library_with_branch_ref(): + lib = Library("name", None, "https://github.com/foo/bar.git#some-branch") + + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref == "some-branch" def test_convert_library_missing_ref(): From 870f628637cbd9c2256c4c7f694ee8c575574d48 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 16:40:59 -0400 Subject: [PATCH 1321/2214] [esp32] Decouple esp-idf toolchain version check from PIO, honor framework source: override (#16516) --- esphome/components/esp32/__init__.py | 85 +++++++++++++++-------- esphome/espidf/framework.py | 22 ++++-- esphome/espidf/toolchain.py | 17 ++++- tests/unit_tests/test_espidf_toolchain.py | 58 ++++++++++++++++ 4 files changed, 147 insertions(+), 35 deletions(-) create mode 100644 tests/unit_tests/test_espidf_toolchain.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8bc8a71c94..5cf2129051 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -792,19 +792,15 @@ PLATFORM_VERSION_LOOKUP = { } -def _check_pio_versions(config): - config = config.copy() - value = config[CONF_FRAMEWORK] +def _resolve_framework_version(value: ConfigType) -> cv.Version: + """Resolve a named or raw framework version and validate the minimum. + Normalises value[CONF_VERSION] to its string form and returns the parsed + cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain- + specific concerns (source defaults, platform_version) live in the per- + toolchain functions. + """ if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: - if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value: - raise cv.Invalid( - "Version needs to be explicitly set when a custom source or platform_version is used." - ) - - platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]] - value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if value[CONF_TYPE] == FRAMEWORK_ARDUINO: version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]] else: @@ -817,7 +813,38 @@ def _check_pio_versions(config): if value[CONF_TYPE] == FRAMEWORK_ARDUINO: if version < cv.Version(3, 0, 0): raise cv.Invalid("Only Arduino 3.0+ is supported.") - recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + else: + if version < cv.Version(5, 0, 0): + raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") + recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] + + if version != recommended: + _LOGGER.warning( + "The selected framework version is not the recommended one. " + "If there are connectivity or build issues please remove the manual version." + ) + + return version + + +def _check_pio_versions(config: ConfigType) -> ConfigType: + config = config.copy() + value = config[CONF_FRAMEWORK] + + is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP + if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value): + raise cv.Invalid( + "Version needs to be explicitly set when a custom source or platform_version is used." + ) + if is_named_version: + value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version( + str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]) + ) + + version = _resolve_framework_version(value) + + if value[CONF_TYPE] == FRAMEWORK_ARDUINO: platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, _format_framework_arduino_version(version) @@ -825,9 +852,6 @@ def _check_pio_versions(config): if _is_framework_url(value[CONF_SOURCE]): value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}" else: - if version < cv.Version(5, 0, 0): - raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") - recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, @@ -843,12 +867,6 @@ def _check_pio_versions(config): ) value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if version != recommended_version: - _LOGGER.warning( - "The selected framework version is not the recommended one. " - "If there are connectivity or build issues please remove the manual version." - ) - if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version( str(PLATFORM_VERSION_LOOKUP["recommended"]) ): @@ -860,19 +878,26 @@ def _check_pio_versions(config): return config -def _check_esp_idf_versions(config): - config = _check_pio_versions(config) +def _check_esp_idf_versions(config: ConfigType) -> ConfigType: + config = config.copy() value = config[CONF_FRAMEWORK] - # Remove unwanted keys if present - for key in (CONF_SOURCE, CONF_PLATFORM_VERSION): - value.pop(key, None) + # platform_version is a PlatformIO concept; drop it if a user carried it + # over from a PIO-style config. CONF_SOURCE, on the other hand, is kept: + # it lets a user override the framework tarball URL under the esp-idf + # toolchain (the espidf framework downloader consults it). + value.pop(CONF_PLATFORM_VERSION, None) - # Official ESP-IDF frameworks don't use extra - version = cv.Version.parse(value[CONF_VERSION]) - version = cv.Version(version.major, version.minor, version.patch) + version = _resolve_framework_version(value) - value[CONF_VERSION] = str(version) + if CONF_SOURCE in value: + _LOGGER.warning( + "A custom framework source is set. " + "If there are connectivity or build issues please remove the manual source." + ) + + # Official ESP-IDF frameworks don't use the 'extra' semver component. + value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch)) return config diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 8996ff1e02..3dcb9dd242 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -789,6 +789,7 @@ def _check_esphome_idf_framework_install( tools: list[str], force: bool = False, env: dict[str, str] | None = None, + source_url: str | None = None, ) -> tuple[Path, bool]: """ Check and install ESP-IDF framework. @@ -799,6 +800,11 @@ def _check_esphome_idf_framework_install( tools: list of tools to install force: If True, force reinstallation env: Optional dictionary of environment variables to set + source_url: Optional override URL for the framework tarball. Supports + the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / + ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When + set, it replaces the default mirror list — no implicit fallback, + so a misspelled URL fails loudly. Returns: tuple of (framework_path, install_flag) @@ -820,6 +826,10 @@ def _check_esphome_idf_framework_install( env_stamp_file = framework_path / ESPHOME_STAMP_FILE idf_tools_path = framework_path / "tools" / "idf_tools.py" _LOGGER.info("Checking ESP-IDF %s framework ...", version) + # Logged every invocation (not just on install) so the user can verify the + # override. A changed URL needs ``esphome clean`` to force a re-download. + if source_url: + _LOGGER.info("Using framework source override: %s", source_url) # 2. Download and extract the framework if not already extracted. # The marker is written last after extraction succeeds, so its presence @@ -847,9 +857,8 @@ def _check_esphome_idf_framework_install( except ValueError: pass - download_from_mirrors( - ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file - ) + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + download_from_mirrors(mirrors, substitutions, tmp.file) _LOGGER.info("Extracting ESP-IDF %s framework ...", version) archive_extract_all(tmp.file, framework_path, progress_header="Extracting") @@ -1011,6 +1020,7 @@ def check_esp_idf_install( tools: list[str] | None = None, features: list[str] | None = None, force: bool = False, + source_url: str | None = None, ) -> tuple[Path, Path]: """ Check and install ESP-IDF framework and Python environment. @@ -1021,6 +1031,10 @@ def check_esp_idf_install( tools: list of tools to install features: Features to install force: If True, force reinstallation + source_url: Optional override URL for the framework tarball. When + set, it replaces the default mirror list (no fallback). Forwarded + to ``_check_esphome_idf_framework_install``; supports the same URL + substitutions. Returns: tuple of (framework_path, python_env_path) @@ -1043,7 +1057,7 @@ def check_esp_idf_install( # 1) Framework framework_path, installed = _check_esphome_idf_framework_install( - version, targets, tools, force=force, env=env + version, targets, tools, force=force, env=env, source_url=source_url ) features = features or ESPHOME_IDF_DEFAULT_FEATURES diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e0bc5bb393..ef28575caa 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -10,6 +10,7 @@ import shutil import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary @@ -37,13 +38,27 @@ def _get_core_framework_version(): return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION]) +def _get_framework_source_override() -> str | None: + """Return the user-supplied esp32.framework.source override, if any. + + The override lets a user point the IDF tarball download at a custom URL + (mirror, fork, local server). Substitutions like ``{VERSION}`` / + ``{MAJOR}`` etc. work the same as in the default mirror list. + """ + if CORE.config is None: + return None + return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE) + + def _get_esphome_esp_idf_paths( version: str | None = None, ) -> tuple[os.PathLike, os.PathLike]: version = version or _get_core_framework_version() paths = _cache().paths if version not in paths: - paths[version] = check_esp_idf_install(version) + paths[version] = check_esp_idf_install( + version, source_url=_get_framework_source_override() + ) return paths[version] diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py new file mode 100644 index 0000000000..adc8bfce63 --- /dev/null +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -0,0 +1,58 @@ +"""Tests for esphome.espidf.toolchain helpers.""" + +# pylint: disable=protected-access + +from unittest.mock import patch + +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE +from esphome.core import CORE +from esphome.espidf import toolchain + + +def test_get_framework_source_override_no_config(): + """When CORE.config hasn't been set, no override is returned.""" + CORE.config = None + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_esp32_section(): + """A config without an esp32 section yields no override.""" + CORE.config = {} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_framework_source(): + """An esp32 section without framework.source yields no override.""" + CORE.config = {"esp32": {CONF_FRAMEWORK: {}}} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_returns_value(): + """A user-supplied framework source is returned verbatim.""" + url = "https://example.com/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + assert toolchain._get_framework_source_override() == url + + +def test_get_esphome_esp_idf_paths_forwards_source_override(): + """_get_esphome_esp_idf_paths threads the override into check_esp_idf_install.""" + url = "https://my-mirror/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + # Hit a fresh cache key so check_esp_idf_install is actually called. + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=url) + + +def test_get_esphome_esp_idf_paths_no_override(): + """When no source override is configured, source_url=None is passed.""" + CORE.config = {} + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=None) From b79a306d0286ff9c675da88df5ba5bfa0d0a7e64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 15:40:20 +0000 Subject: [PATCH 1322/2214] Bump zeroconf from 0.149.7 to 0.149.12 (#16510) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3c66db489a..98106f36c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.149.7 +zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 9fdad681385f04915aca111a7c9312f9e6b1af17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 10:51:53 -0500 Subject: [PATCH 1323/2214] Bump aioesphomeapi from 45.0.3 to 45.0.4 (#16513) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 98106f36c2..4338063387 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.3 +aioesphomeapi==45.0.4 zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From ecf823b871c6a336020bb3fa9168b3fb57c42c34 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 14:31:58 -0400 Subject: [PATCH 1324/2214] [espidf] Drop version field from generated idf_component.yml (#16511) --- esphome/components/esp32/__init__.py | 3 +- esphome/espidf/component.py | 66 ++++------------------- tests/unit_tests/test_espidf_component.py | 26 ++++++--- 3 files changed, 30 insertions(+), 65 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e9b0f1fd0a..9f1af9fdf7 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2488,9 +2488,8 @@ def _write_sdkconfig(): def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: dependency: dict[str, str] = {} - name, version, path = generate_idf_component(library) + name, _version, path = generate_idf_component(library) dependency["override_path"] = str(path) - dependency["version"] = version return name, dependency diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b9202fb6bf..b1352f7791 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -154,41 +154,6 @@ class IDFComponent: self.path = self.source.download(self.get_sanitized_name(), force=force) -def _sanitize_version(version: str) -> str: - """ - Sanitize a version string by removing common requirement prefixes or a leading v. - - Args: - version: Version string to clean. - - Returns: - Cleaned version string without common requirement symbols. - """ - version = version.strip() - - prefixes = ( - "^", - "~=", - "~", - ">=", - "<=", - "==", - "!=", - ">", - "<", - "=", - "v", - "V", - ) - - for p in prefixes: - if version.startswith(p): - version = version[len(p) :] - break - - return version.strip() - - def _get_package_from_pio_registry( username: str | None, pkgname: str, requirements: str ) -> tuple[str, str, str | None, str | None]: @@ -396,7 +361,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: - # Parse repository URL to extract name and version + # Parse repository URL: path becomes the component name, fragment + # becomes the git ref stored on GitSource. split_result = urlsplit(library.repository) if not split_result.fragment.strip(): raise ValueError(f"Missing ref in URL {library.repository}") @@ -405,8 +371,10 @@ def _convert_library_to_component(library: Library) -> IDFComponent: name = str(split_result.path).strip("/") name = name.removesuffix(".git") - # Sanitize version - version = _sanitize_version(split_result.fragment) + # IDF Component Manager only accepts "*", a 40-char commit hash, or + # semver here. The actual git ref is preserved in GitSource.ref; + # override_path makes this field cosmetic at build time. + version = "*" repository = urlunsplit(split_result._replace(fragment="")) source = GitSource(str(repository), split_result.fragment) @@ -619,9 +587,6 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if description: data["description"] = description - # Do not use the version from library.json/library.properties; it may be incorrect. - data["version"] = component.version - repository = component.data.get("repository", {}).get("url", None) if repository: data["repository"] = repository @@ -631,20 +596,11 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if "dependencies" not in data: data["dependencies"] = {} - # Add this dependency to dependencies - dep = {} - dep["version"] = dependency.version - - # Should use dependency.path as override path - try: - dep["override_path"] = str(dependency.path) - except RuntimeError as e: - # No local path: only a GitSource can substitute its URL. - if not isinstance(dependency.source, GitSource): - raise e - dep["git"] = dependency.source.url - - data["dependencies"][dependency.get_sanitized_name()] = dep + # Every dependency goes through _generate_idf_component → + # component.download() before this runs, so .path is always set. + data["dependencies"][dependency.get_sanitized_name()] = { + "override_path": str(dependency.path), + } return yaml_util.dump(data) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 8977b05d23..373432f7d2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -203,7 +203,7 @@ def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) - assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n" + assert result == "description: test\nrepository: http://aaa\n" def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): @@ -217,18 +217,16 @@ def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): assert ( result - == f"""version: 1.0.0 -dependencies: + == f"""dependencies: dep: - version: '1.0' override_path: {dep.path} """ ) -def test_generate_idf_component_yml_missing_path_reraises(tmp_component): - # A dep without a path and without a recognised source should re-raise - # the underlying RuntimeError instead of silently producing a bad manifest. +def test_generate_idf_component_yml_missing_path_raises(tmp_component): + # A dep without a path is a contract violation — every dep is expected + # to have been downloaded before YAML generation. Raise loudly. dep = IDFComponent("foo/bar", "1.0", source=None) tmp_component.dependencies = [dep] @@ -422,8 +420,20 @@ def test_convert_library_with_repository(): result = _convert_library_to_component(lib) assert result.name == "foo/bar" - assert result.version == "1.2.3" + assert result.version == "*" assert isinstance(result.source, GitSource) + assert result.source.ref == "v1.2.3" + + +def test_convert_library_with_branch_ref(): + lib = Library("name", None, "https://github.com/foo/bar.git#some-branch") + + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref == "some-branch" def test_convert_library_missing_ref(): From cd7e2d79c4f9f4e454488752a798ca1b8086b077 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 16:40:59 -0400 Subject: [PATCH 1325/2214] [esp32] Decouple esp-idf toolchain version check from PIO, honor framework source: override (#16516) --- esphome/components/esp32/__init__.py | 85 +++++++++++++++-------- esphome/espidf/framework.py | 22 ++++-- esphome/espidf/toolchain.py | 17 ++++- tests/unit_tests/test_espidf_toolchain.py | 58 ++++++++++++++++ 4 files changed, 147 insertions(+), 35 deletions(-) create mode 100644 tests/unit_tests/test_espidf_toolchain.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9f1af9fdf7..1db97f95eb 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -792,19 +792,15 @@ PLATFORM_VERSION_LOOKUP = { } -def _check_pio_versions(config): - config = config.copy() - value = config[CONF_FRAMEWORK] +def _resolve_framework_version(value: ConfigType) -> cv.Version: + """Resolve a named or raw framework version and validate the minimum. + Normalises value[CONF_VERSION] to its string form and returns the parsed + cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain- + specific concerns (source defaults, platform_version) live in the per- + toolchain functions. + """ if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: - if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value: - raise cv.Invalid( - "Version needs to be explicitly set when a custom source or platform_version is used." - ) - - platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]] - value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if value[CONF_TYPE] == FRAMEWORK_ARDUINO: version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]] else: @@ -817,7 +813,38 @@ def _check_pio_versions(config): if value[CONF_TYPE] == FRAMEWORK_ARDUINO: if version < cv.Version(3, 0, 0): raise cv.Invalid("Only Arduino 3.0+ is supported.") - recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + else: + if version < cv.Version(5, 0, 0): + raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") + recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] + + if version != recommended: + _LOGGER.warning( + "The selected framework version is not the recommended one. " + "If there are connectivity or build issues please remove the manual version." + ) + + return version + + +def _check_pio_versions(config: ConfigType) -> ConfigType: + config = config.copy() + value = config[CONF_FRAMEWORK] + + is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP + if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value): + raise cv.Invalid( + "Version needs to be explicitly set when a custom source or platform_version is used." + ) + if is_named_version: + value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version( + str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]) + ) + + version = _resolve_framework_version(value) + + if value[CONF_TYPE] == FRAMEWORK_ARDUINO: platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, _format_framework_arduino_version(version) @@ -825,9 +852,6 @@ def _check_pio_versions(config): if _is_framework_url(value[CONF_SOURCE]): value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}" else: - if version < cv.Version(5, 0, 0): - raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") - recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, @@ -843,12 +867,6 @@ def _check_pio_versions(config): ) value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if version != recommended_version: - _LOGGER.warning( - "The selected framework version is not the recommended one. " - "If there are connectivity or build issues please remove the manual version." - ) - if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version( str(PLATFORM_VERSION_LOOKUP["recommended"]) ): @@ -860,19 +878,26 @@ def _check_pio_versions(config): return config -def _check_esp_idf_versions(config): - config = _check_pio_versions(config) +def _check_esp_idf_versions(config: ConfigType) -> ConfigType: + config = config.copy() value = config[CONF_FRAMEWORK] - # Remove unwanted keys if present - for key in (CONF_SOURCE, CONF_PLATFORM_VERSION): - value.pop(key, None) + # platform_version is a PlatformIO concept; drop it if a user carried it + # over from a PIO-style config. CONF_SOURCE, on the other hand, is kept: + # it lets a user override the framework tarball URL under the esp-idf + # toolchain (the espidf framework downloader consults it). + value.pop(CONF_PLATFORM_VERSION, None) - # Official ESP-IDF frameworks don't use extra - version = cv.Version.parse(value[CONF_VERSION]) - version = cv.Version(version.major, version.minor, version.patch) + version = _resolve_framework_version(value) - value[CONF_VERSION] = str(version) + if CONF_SOURCE in value: + _LOGGER.warning( + "A custom framework source is set. " + "If there are connectivity or build issues please remove the manual source." + ) + + # Official ESP-IDF frameworks don't use the 'extra' semver component. + value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch)) return config diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 32bcf4fb3b..aa97c65227 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -786,6 +786,7 @@ def _check_esphome_idf_framework_install( tools: list[str], force: bool = False, env: dict[str, str] | None = None, + source_url: str | None = None, ) -> tuple[Path, bool]: """ Check and install ESP-IDF framework. @@ -796,6 +797,11 @@ def _check_esphome_idf_framework_install( tools: list of tools to install force: If True, force reinstallation env: Optional dictionary of environment variables to set + source_url: Optional override URL for the framework tarball. Supports + the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / + ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When + set, it replaces the default mirror list — no implicit fallback, + so a misspelled URL fails loudly. Returns: tuple of (framework_path, install_flag) @@ -817,6 +823,10 @@ def _check_esphome_idf_framework_install( env_stamp_file = framework_path / ESPHOME_STAMP_FILE idf_tools_path = framework_path / "tools" / "idf_tools.py" _LOGGER.info("Checking ESP-IDF %s framework ...", version) + # Logged every invocation (not just on install) so the user can verify the + # override. A changed URL needs ``esphome clean`` to force a re-download. + if source_url: + _LOGGER.info("Using framework source override: %s", source_url) # 2. Download and extract the framework if not already extracted. # The marker is written last after extraction succeeds, so its presence @@ -844,9 +854,8 @@ def _check_esphome_idf_framework_install( except ValueError: pass - download_from_mirrors( - ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file - ) + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + download_from_mirrors(mirrors, substitutions, tmp.file) _LOGGER.info("Extracting ESP-IDF %s framework ...", version) archive_extract_all(tmp.file, framework_path, progress_header="Extracting") @@ -1008,6 +1017,7 @@ def check_esp_idf_install( tools: list[str] | None = None, features: list[str] | None = None, force: bool = False, + source_url: str | None = None, ) -> tuple[Path, Path]: """ Check and install ESP-IDF framework and Python environment. @@ -1018,6 +1028,10 @@ def check_esp_idf_install( tools: list of tools to install features: Features to install force: If True, force reinstallation + source_url: Optional override URL for the framework tarball. When + set, it replaces the default mirror list (no fallback). Forwarded + to ``_check_esphome_idf_framework_install``; supports the same URL + substitutions. Returns: tuple of (framework_path, python_env_path) @@ -1040,7 +1054,7 @@ def check_esp_idf_install( # 1) Framework framework_path, installed = _check_esphome_idf_framework_install( - version, targets, tools, force=force, env=env + version, targets, tools, force=force, env=env, source_url=source_url ) features = features or ESPHOME_IDF_DEFAULT_FEATURES diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e0bc5bb393..ef28575caa 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -10,6 +10,7 @@ import shutil import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary @@ -37,13 +38,27 @@ def _get_core_framework_version(): return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION]) +def _get_framework_source_override() -> str | None: + """Return the user-supplied esp32.framework.source override, if any. + + The override lets a user point the IDF tarball download at a custom URL + (mirror, fork, local server). Substitutions like ``{VERSION}`` / + ``{MAJOR}`` etc. work the same as in the default mirror list. + """ + if CORE.config is None: + return None + return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE) + + def _get_esphome_esp_idf_paths( version: str | None = None, ) -> tuple[os.PathLike, os.PathLike]: version = version or _get_core_framework_version() paths = _cache().paths if version not in paths: - paths[version] = check_esp_idf_install(version) + paths[version] = check_esp_idf_install( + version, source_url=_get_framework_source_override() + ) return paths[version] diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py new file mode 100644 index 0000000000..adc8bfce63 --- /dev/null +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -0,0 +1,58 @@ +"""Tests for esphome.espidf.toolchain helpers.""" + +# pylint: disable=protected-access + +from unittest.mock import patch + +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE +from esphome.core import CORE +from esphome.espidf import toolchain + + +def test_get_framework_source_override_no_config(): + """When CORE.config hasn't been set, no override is returned.""" + CORE.config = None + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_esp32_section(): + """A config without an esp32 section yields no override.""" + CORE.config = {} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_framework_source(): + """An esp32 section without framework.source yields no override.""" + CORE.config = {"esp32": {CONF_FRAMEWORK: {}}} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_returns_value(): + """A user-supplied framework source is returned verbatim.""" + url = "https://example.com/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + assert toolchain._get_framework_source_override() == url + + +def test_get_esphome_esp_idf_paths_forwards_source_override(): + """_get_esphome_esp_idf_paths threads the override into check_esp_idf_install.""" + url = "https://my-mirror/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + # Hit a fresh cache key so check_esp_idf_install is actually called. + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=url) + + +def test_get_esphome_esp_idf_paths_no_override(): + """When no source override is configured, source_url=None is passed.""" + CORE.config = {} + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=None) From de783e72d58faba684e617b2536be8b00dbdc696 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 21 May 2026 09:10:52 +1200 Subject: [PATCH 1326/2214] Bump version to 2026.5.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 433fcc6c45..f8486e9863 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.5.0b3 +PROJECT_NUMBER = 2026.5.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 3e576c8899..3c5243f304 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0b3" +__version__ = "2026.5.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 104c8bed41499cbe5235fc5762120d65961e2809 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 21 May 2026 11:16:58 +1200 Subject: [PATCH 1327/2214] Bump version to 2026.5.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index f8486e9863..206a181ffd 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.5.0b4 +PROJECT_NUMBER = 2026.5.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 3c5243f304..96554b12da 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0b4" +__version__ = "2026.5.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 43a1c2067edb9031acb1f58b37bd41f8f96ed42a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 08:28:09 -0500 Subject: [PATCH 1328/2214] Bump zeroconf from 0.149.12 to 0.149.13 (#16520) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cfb1960ea1..4daed8971e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.4 -zeroconf==0.149.12 +zeroconf==0.149.13 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From a70e358ceadd63022831744b0bbe44174c40fd65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 09:55:28 -0500 Subject: [PATCH 1329/2214] Bump zeroconf from 0.149.13 to 0.149.16 (#16533) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4daed8971e..178e05497f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.4 -zeroconf==0.149.13 +zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 52e7d3ccfb680257f94c2bd72653e4e0c74c595d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:35:25 -0400 Subject: [PATCH 1330/2214] [esp32] Use new sdkconfig key names that replaced deprecated ones (#16522) --- esphome/components/esp32/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5cf2129051..09d2e89bd3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1987,7 +1987,7 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True) # Setup watchdog - add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) + add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False) @@ -2122,7 +2122,6 @@ async def to_code(config): for key, flag in ASSERTION_LEVELS.items(): add_idf_sdkconfig_option(flag, assertion_level == key) - add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False) compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION] for key, flag in COMPILER_OPTIMIZATIONS.items(): add_idf_sdkconfig_option(flag, compiler_optimization == key) From 90715373f2b074ffd66db674b382d8c9abc41e10 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:35:51 -0400 Subject: [PATCH 1331/2214] [espidf] Filter noisy 'git rev-parse' errors when .git is stripped (#16521) --- esphome/espidf/runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 65df37c7b2..da3f77cdd3 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -66,6 +66,12 @@ FILTER_IDF_LINES: list[str] = [ # Drop the blank line rich emits after the note so the build log # doesn't end with an orphan gap before ESPHome's own status lines. r"\s*$", + # ESP-IDF shells out to ``git rev-parse`` to embed a commit hash; + # esphome-libs strips ``.git`` from the tarball so those probes fail + # noisily without affecting the build. + r"-- git rev-parse returned ", + r"fatal: not a git repository", + r"Stopping at filesystem boundary", ] From f2bfe5cd178d297ec4e1c99d61e9abf45feb5409 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:36:27 -0400 Subject: [PATCH 1332/2214] [espidf] Fix tarfile extract crashing on Python 3.11 with None mode (#16530) --- esphome/espidf/framework.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 3dcb9dd242..f20391c6a5 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -549,11 +549,11 @@ def _tar_extract_all( if not (mode & stat.S_IXUSR): mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) mode |= stat.S_IRUSR | stat.S_IWUSR - elif member.isdir() or member.issym(): - # Ignore mode for directories & symlinks - mode = None - else: - # Block special files + elif not (member.isdir() or member.issym()): + # Block special files. Directories and symlinks keep + # their masked-original mode — passing None here would + # crash tarfile.extract on Python <3.12 (its chmod + # path calls os.chmod unconditionally). continue member.mode = mode From b619e3e8c77ec710af69726013218ce3b2b1fa1d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:37:10 -0400 Subject: [PATCH 1333/2214] [espidf] Write version.txt after extract so bootloader shows the real version (#16532) --- esphome/espidf/framework.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f20391c6a5..a967f7c5af 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -783,6 +783,34 @@ def download_from_mirrors( return None +def _write_idf_version_txt(framework_path: Path, version: str) -> None: + """Write /version.txt if missing. + + IDF's build.cmake picks the version it embeds in the firmware (and + stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt`` + if present, else ``git describe`` against IDF_PATH, else the + ``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``. + On a clean esphome-libs tarball ``.git`` is fully stripped, so + git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet + wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom + framework.source pointed at a real git URL where build artifacts + mark the tree dirty) makes git_describe return ``-dirty``, + which is what then gets baked into the bootloader. Dropping + version.txt forces the right answer regardless. + """ + version_txt = framework_path / "version.txt" + if version_txt.exists(): + return + try: + version_txt.write_text(f"v{version}\n", encoding="utf-8") + except OSError as e: + _LOGGER.warning( + "Could not write %s (%s); bootloader version string may be incorrect.", + version_txt, + e, + ) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -864,6 +892,11 @@ def _check_esphome_idf_framework_install( archive_extract_all(tmp.file, framework_path, progress_header="Extracting") extracted_marker.touch() + # Idempotent post-extract patch: written every invocation so a build + # dir extracted before this fix gets the file too, without forcing a + # clean. Skips when version.txt already exists. + _write_idf_version_txt(framework_path, version) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True From e0076cb1a8b60657e24332844420ef321f25147c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:37:46 -0400 Subject: [PATCH 1334/2214] [core] Persist & restore CORE.toolchain through StorageJSON (#16531) --- esphome/storage_json.py | 20 ++++++++ tests/unit_tests/test_storage_json.py | 73 ++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index e481827080..7f8885ba5f 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -14,6 +14,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE from esphome.helpers import write_file_if_changed @@ -98,6 +99,7 @@ class StorageJSON: no_mdns: bool, framework: str | None = None, core_platform: str | None = None, + toolchain: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -134,6 +136,8 @@ class StorageJSON: self.framework = framework # The core platform of this firmware. Like "esp32", "rp2040", "host" etc. self.core_platform = core_platform + # The toolchain used for the build ("platformio" / "esp-idf") + self.toolchain = toolchain def as_dict(self): return { @@ -153,6 +157,7 @@ class StorageJSON: "no_mdns": self.no_mdns, "framework": self.framework, "core_platform": self.core_platform, + "toolchain": self.toolchain, } def to_json(self): @@ -189,6 +194,7 @@ class StorageJSON: ), framework=esph.target_framework, core_platform=esph.target_platform, + toolchain=esph.toolchain.value if esph.toolchain is not None else None, ) @staticmethod @@ -236,6 +242,7 @@ class StorageJSON: no_mdns = storage.get("no_mdns", False) framework = storage.get("framework") core_platform = storage.get("core_platform") + toolchain = storage.get("toolchain") return StorageJSON( storage_version, name, @@ -253,6 +260,7 @@ class StorageJSON: no_mdns, framework, core_platform, + toolchain, ) @staticmethod @@ -273,6 +281,18 @@ class StorageJSON: """ CORE.name = self.name CORE.build_path = self.build_path + # Restore toolchain so upload/logs picks the right firmware_bin path. + # An unknown value (corrupt sidecar, or written by a newer ESPHome) + # just leaves CORE.toolchain None — the fallback then picks PlatformIO. + if self.toolchain and CORE.toolchain is None: + try: + CORE.toolchain = Toolchain(self.toolchain) + except ValueError: + _LOGGER.debug( + "Ignoring unknown toolchain %r from %s", + self.toolchain, + storage_path(), + ) target_platform = self.core_platform or self.target_platform.lower() CORE.data[KEY_CORE] = { KEY_TARGET_PLATFORM: target_platform, diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index a3a38960e7..ea37492cf4 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -9,7 +9,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import storage_json -from esphome.const import CONF_DISABLED, CONF_MDNS +from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE @@ -308,6 +308,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.loaded_platforms = {"sensor"} mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" + mock_core.toolchain = Toolchain.ESP_IDF with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -327,6 +328,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.no_mdns is True assert result.framework == "esp-idf" assert result.core_platform == "esp32" + assert result.toolchain == "esp-idf" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -345,10 +347,12 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: mock_core.loaded_platforms = set() mock_core.config = {} # No MDNS config means enabled mock_core.target_framework = "arduino" + mock_core.toolchain = None result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) assert result.no_mdns is False + assert result.toolchain is None def test_storage_json_load_valid_file(tmp_path: Path) -> None: @@ -470,6 +474,73 @@ def test_storage_json_equality() -> None: assert storage1 != "not a storage object" +def _make_storage_with_toolchain( + toolchain: str | None, +) -> storage_json.StorageJSON: + return storage_json.StorageJSON( + storage_version=1, + name="dev", + friendly_name=None, + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="dev.local", + web_port=None, + target_platform="ESP32", + build_path=Path("/build"), + firmware_bin_path=Path("/build/firmware.bin"), + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="esp-idf", + core_platform="esp32", + toolchain=toolchain, + ) + + +def test_storage_json_toolchain_round_trip(setup_core: Path) -> None: + """Sidecar toolchain survives save -> load -> apply_to_core.""" + storage = _make_storage_with_toolchain("esp-idf") + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + # Serialization key is stable -- device-builder relies on it. + assert json.loads(path.read_text())["toolchain"] == "esp-idf" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.toolchain == "esp-idf" + + CORE.toolchain = None + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain == Toolchain.ESP_IDF + + +def test_storage_json_apply_to_core_preserves_cli_toolchain( + setup_core: Path, +) -> None: + """A CLI-set CORE.toolchain wins over the sidecar value.""" + loaded = _make_storage_with_toolchain("esp-idf") + + CORE.toolchain = Toolchain.PLATFORMIO + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain == Toolchain.PLATFORMIO + + +def test_storage_json_apply_to_core_ignores_unknown_toolchain( + setup_core: Path, +) -> None: + """Unknown enum values (corrupt sidecar / newer ESPHome) fall through to None.""" + loaded = _make_storage_with_toolchain("gcc") + + CORE.toolchain = None + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain is None + + def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( From 233a60f1062a6d344c3d19f030658a162e6940d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 10:53:34 -0500 Subject: [PATCH 1335/2214] [ci] Pin uv version in setup-uv to fix Windows manifest fetch flake (#16534) --- .github/actions/restore-python/action.yml | 4 ++++ .github/workflows/ci-api-proto.yml | 4 ++++ .github/workflows/ci.yml | 12 ++++++++++++ .github/workflows/sync-device-classes.yml | 4 ++++ 4 files changed, 24 insertions(+) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 751f9ecf58..03b4803860 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -35,6 +35,10 @@ runs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows' shell: bash diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 1dc0ccb7fe..675bbe9d2c 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -32,6 +32,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Install apt dependencies run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbbb06c86c..43b03aec85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -175,6 +179,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Install device-builder + esphome from PR # Install device-builder with its esphome + test extras # first so its pinned versions of pytest/etc. land, then @@ -365,6 +373,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 23a63c5d8a..84be3c8e22 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -50,6 +50,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Install Home Assistant run: | From 01494f7431ad2c1056d1eff0c0b2fd6cc7d50227 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 21 May 2026 11:57:32 -0400 Subject: [PATCH 1336/2214] [audio] Bump esp-audio-libs to v3.1.0 (#16519) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 13b379ba3a..c9775ab601 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -335,7 +335,7 @@ async def to_code(config): add_idf_component( name="esphome/esp-audio-libs", - ref="3.0.0", + ref="3.1.0", ) data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 42d0d5de6b..44c63e46cd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" esphome/esp-audio-libs: - version: 3.0.0 + version: 3.1.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From 750ae56778699dd727da51f5363592d543f97c1c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 12:05:27 -0400 Subject: [PATCH 1337/2214] [espidf] Backport ninja linux-arm64 entry into tools.json on aarch64 hosts (#16527) --- esphome/espidf/framework.py | 76 ++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index a967f7c5af..079c97cc98 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -7,6 +7,7 @@ import json import logging import os from pathlib import Path +import platform import shutil import subprocess import sys @@ -17,7 +18,7 @@ import requests from esphome.config_validation import Version from esphome.core import CORE -from esphome.helpers import ProgressBar, get_str_env, rmtree +from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed PathType = str | os.PathLike @@ -811,6 +812,74 @@ def _write_idf_version_txt(framework_path: Path, version: str) -> None: ) +# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release +# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no +# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a +# matching binary or grabs the x86_64 one, which can't execute on +# aarch64. cmake is already populated across the same release range; we +# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1 +# tools.json where the fix landed natively. +_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = { + "1.12.1": { + "rename_dist": "ninja-linux-arm64-v1.12.1.zip", + "sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1", + "size": 121787, + "url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip", + }, +} + + +def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: + """Inject ninja linux-arm64 entries into the framework's tools.json on aarch64. + + Idempotent: a tools.json that already has the entry, or a host that + isn't aarch64, is a no-op. Applied unconditionally on every install + check so a build dir extracted before the backport got fixed up + without forcing a clean. + """ + if platform.machine() != "aarch64": + return + + tools_json = framework_path / "tools" / "tools.json" + if not tools_json.is_file(): + return + + try: + with open(tools_json, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + _LOGGER.warning( + "Could not parse %s for linux-arm64 backport (%s); " + "skipping. A clean reinstall of the framework directory " + "may be needed.", + tools_json, + e, + ) + return + + changed = False + for tool in data.get("tools", []): + if tool.get("name") != "ninja": + continue + for ver in tool.get("versions", []): + entry = _NINJA_ARM64_BACKPORT.get(ver.get("name")) + if entry is None or ver.get("linux-arm64"): + continue + ver["linux-arm64"] = entry + changed = True + + if changed: + # write_file_if_changed stages a tempfile in the destination dir + # and atomically replaces — safe against mid-write interruption + # and concurrent invocations. + write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n") + _LOGGER.info( + "Patched %s to add ninja linux-arm64 download " + "(espressif/esp-idf#18272 backport).", + tools_json, + ) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -897,6 +966,11 @@ def _check_esphome_idf_framework_install( # clean. Skips when version.txt already exists. _write_idf_version_txt(framework_path, version) + # Apply the ninja linux-arm64 backport on every invocation, not just on + # fresh extracts — idempotent and cheap, and lets a build dir carrying + # a pre-patch tools.json get fixed up without forcing a clean. + _patch_tools_json_for_linux_arm64(framework_path) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True From 3719ea740a577659ec8e2837372e3f7cc553f1ef Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:19 -0400 Subject: [PATCH 1338/2214] [espidf] Default to remote HEAD when cg.add_library URL has no #ref (#16535) --- esphome/espidf/component.py | 15 ++++++++------- tests/unit_tests/test_espidf_component.py | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b1352f7791..7d9874ad5f 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -93,7 +93,7 @@ class URLSource(Source): class GitSource(Source): - def __init__(self, url: str, ref: str): + def __init__(self, url: str, ref: str | None): self.url = url self.ref = ref @@ -109,7 +109,7 @@ class GitSource(Source): return path def __str__(self): - return f"{self.url}#{self.ref}" + return f"{self.url}#{self.ref}" if self.ref else self.url class InvalidIDFComponent(Exception): @@ -352,7 +352,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent: IDFComponent: The resolved component with name, version, and URL Raises: - ValueError: If a repository URL is missing a reference (#) RuntimeError: If no artifact can be found for the library """ name = None @@ -362,10 +361,11 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: # Parse repository URL: path becomes the component name, fragment - # becomes the git ref stored on GitSource. + # (if any) becomes the git ref stored on GitSource. A missing + # fragment is fine -- clone_or_update leaves the depth-1 clone on + # the remote's default branch, matching PIO's lib_deps behavior + # and external_components handling. split_result = urlsplit(library.repository) - if not split_result.fragment.strip(): - raise ValueError(f"Missing ref in URL {library.repository}") # Sanitize name name = str(split_result.path).strip("/") @@ -377,7 +377,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: version = "*" repository = urlunsplit(split_result._replace(fragment="")) - source = GitSource(str(repository), split_result.fragment) + ref = split_result.fragment.strip() or None + source = GitSource(str(repository), ref) # Version is provided - resolve using PlatformIO registry elif library.version: diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 373432f7d2..c4a419d1a2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -436,11 +436,21 @@ def test_convert_library_with_branch_ref(): assert result.source.ref == "some-branch" -def test_convert_library_missing_ref(): +def test_convert_library_missing_ref_uses_default_branch(): + """A bare URL with no #ref clones the remote's default branch. + + Matches PIO's lib_deps behavior and external_components handling -- + git.clone_or_update with ref=None leaves the depth-1 clone on + whatever branch the remote HEAD points at. + """ lib = Library("name", None, "https://github.com/foo/bar.git") - with pytest.raises(ValueError): - _convert_library_to_component(lib) + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref is None def test_convert_library_registry(monkeypatch): From 56fd77e4c8480551525527de8196de17c64d05ed Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:54 -0400 Subject: [PATCH 1339/2214] [espidf] Honor the dict shorthand for library.json dependencies (#16537) --- esphome/espidf/component.py | 20 ++++ tests/unit_tests/test_espidf_component.py | 110 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7d9874ad5f..a452a3f34a 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -656,6 +656,26 @@ def _process_dependencies(component: IDFComponent): if not dependencies: return + # PIO's library.json accepts both the list-of-dicts form and the + # shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize + # the dict form so the loop below sees a uniform list. Iterating a + # dict gives string keys, which would silently fail the + # ``"name" in dependency`` substring check and skip every entry. + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + dependencies = normalized + _LOGGER.info("Processing %s@%s component dependencies...", name, version) for dependency in dependencies: # Validate dependency structure diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index c4a419d1a2..7d6c861ffd 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -505,3 +505,113 @@ def test_process_dependencies_skips_invalid(tmp_component): _process_dependencies(tmp_component) assert tmp_component.dependencies == [] + + +def test_process_dependencies_dict_form(tmp_component, monkeypatch): + """PIO library.json shorthand ``{"owner/Name": "version"}`` is honored. + + Iterating a dict gives string keys, which would silently fail the + ``"name" in dependency`` substring check. Normalize to list-of-dicts + first so the dict form (used by e.g. tesla-ble for its nanopb dep) + is treated the same as the verbose list form. + """ + captured: list[Library] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent( + library.name, library.version, source=URLSource("http://dummy.com") + ) + + tmp_component.data = { + "dependencies": { + "nanopb/Nanopb": "^0.4.91", + "BareName": "1.2.3", + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(tmp_component.dependencies) == 2 + names = sorted(lib.name for lib in captured) + versions = sorted(lib.version for lib in captured) + assert names == ["BareName", "nanopb/Nanopb"] + assert versions == ["1.2.3", "^0.4.91"] + + +def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch): + """A dict-value that's a URL gets routed to ``repository`` like the list form.""" + captured: list[Library] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent(library.name, "*", source=URLSource("http://dummy.com")) + + tmp_component.data = { + "dependencies": { + "foo/Bar": "https://github.com/foo/bar.git#main", + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(captured) == 1 + assert captured[0].name == "foo/Bar" + assert captured[0].version is None + assert captured[0].repository == "https://github.com/foo/bar.git#main" + + +def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch): + """A dict-value that's itself a dict is merged into the entry. + + PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}`` + for entries that need fields beyond just a version (platforms, + frameworks, etc.). The extra fields flow into _check_library_data + via the entry merge. + """ + captured: list[Library] = [] + checked: list[dict] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent( + library.name, library.version, source=URLSource("http://dummy.com") + ) + + tmp_component.data = { + "dependencies": { + "nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}, + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr( + esphome.espidf.component, + "_check_library_data", + checked.append, + ) + + _process_dependencies(tmp_component) + + assert len(captured) == 1 + assert captured[0].name == "nanopb/Nanopb" + assert captured[0].version == "^0.4.91" + # Extra spec fields reach _check_library_data so platform/framework + # gating still applies. + assert checked == [ + { + "name": "Nanopb", + "owner": "nanopb", + "version": "^0.4.91", + "platforms": "espidf", + } + ] From d2bda0a402f309418a0ba82e542632e12e8035d6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:03:55 -0400 Subject: [PATCH 1340/2214] [esp32] Defer esp_panic_handler wrap so arduino-esp32 IDF component skips it (#16538) --- esphome/components/esp32/__init__.py | 35 +++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 09d2e89bd3..274cc6fdb3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -113,6 +113,7 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs" ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}" +ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32" LOG_LEVELS_IDF = [ "NONE", @@ -1743,6 +1744,31 @@ async def _add_yaml_idf_components(components: list[ConfigType]): ) +@coroutine_with_priority(CoroPriority.FINAL - 1) +async def _finalize_arduino_aware_flags(): + """Build flags that depend on whether arduino-esp32 is linked in. + + Scheduler runs lower priority values later, so ``FINAL - 1`` fires + after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) -- + by then ``KEY_COMPONENTS`` is fully populated. + + - Skip our esp_panic_handler wrap when Arduino is linked; Arduino + wraps the same symbol and the linker errors on the duplicate. + - Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component + case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The + framework=arduino branch already adds it inline in to_code. + """ + arduino_linked = ( + CORE.using_arduino + or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS] + ) + if not arduino_linked: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") + elif not CORE.using_arduino: + cg.add_build_flag("-DUSE_ARDUINO") + + async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] conf = config[CONF_FRAMEWORK] @@ -1802,11 +1828,8 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") - # Arduino already wraps esp_panic_handler for its own backtrace handler, - # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. - if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - cg.add_build_flag("-Wl,--wrap=esp_panic_handler") - cg.add_define("USE_ESP32_CRASH_HANDLER") + # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. + CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") @@ -2567,7 +2590,7 @@ def _write_idf_component_yml(): if CORE.using_toolchain_esp_idf: add_idf_component( - name="espressif/arduino-esp32", + name=ARDUINO_ESP32_COMPONENT_NAME, ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), ) From 1ea95264bd91d073cfba8d0ce7bca0ec8fa96062 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:08:09 -0400 Subject: [PATCH 1341/2214] [tuya] Restore null guard on status_pin lost in #16353 (#16539) --- esphome/components/tuya/tuya.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index fd14844908..3058d82cc4 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -206,15 +206,17 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff if (this->status_pin_reported_ != -1) { this->init_state_ = TuyaInitState::INIT_DATAPOINT; this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY); - bool is_pin_equals = - this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_; - // Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send - if (!is_pin_equals) { - ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.", + if (this->status_pin_ != nullptr) { + if (this->status_pin_->get_pin() != this->status_pin_reported_) { + ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.", + this->status_pin_reported_); + } + ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); + this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); + } else { + ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.", this->status_pin_reported_); } - ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); - this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); } else { this->init_state_ = TuyaInitState::INIT_WIFI; ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); From 8dd594afa2b35c93d342ae9859ff1d8cab6237bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 13:55:17 -0500 Subject: [PATCH 1342/2214] [api] Break api_connection/api_server include cycle to drop custom unique_ptr deleter api_connection.h now forward-declares APIServer instead of including api_server.h, and api_server.h includes api_connection.h before clients_ is declared. With APIConnection complete at that point, libc++ no longer trips the incomplete-type assert in default_delete when parsing std::array, MAX_API_CONNECTIONS>, so the custom APIConnectionDeleter introduced in #16050 is no longer needed. The two APIConnection inline methods that touched APIServer (encode_to_buffer, get_batch_delay_ms_) are now defined at the bottom of api_server.h, where APIServer is complete; api_connection.cpp gains a direct include of api_server.h so those definitions are visible there. Verified locally on macOS host platform with python3.14 -m esphome compile (the path that reproduces the libc++ error). Noise and plaintext integration tests pass. --- esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_connection.h | 51 +++++-------------- esphome/components/api/api_server.cpp | 5 -- esphome/components/api/api_server.h | 62 +++++++++++++++++++---- 4 files changed, 66 insertions(+), 53 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b6f4aa2141..79123ec893 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,5 +1,6 @@ #include "api_connection.h" #ifdef USE_API +#include "api_server.h" // brings APIServer complete for encode_to_buffer/get_batch_delay_ms_ #ifdef USE_API_NOISE #include "api_frame_helper_noise.h" #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4165b7f3a2..61d099c27d 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -11,7 +11,8 @@ #endif #include "api_pb2.h" #include "api_pb2_service.h" -#include "api_server.h" +#include "list_entities.h" +#include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" #ifdef USE_ESP32_CRASH_HANDLER @@ -36,6 +37,11 @@ class ComponentIterator; namespace esphome::api { +// Forward declaration to break the api_connection.h <-> api_server.h include cycle. +// APIServer is only used through pointers/references in this header; methods that +// need the complete type are defined inline at the bottom of api_server.h. +class APIServer; + // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending @@ -413,42 +419,10 @@ class APIConnection final : public APIServerConnectionBase { // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. - static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, - const void *msg, APIConnection *conn, - uint32_t remaining_size) { -#ifdef HAS_PROTO_MESSAGE_DUMP - if (conn->flags_.log_only_mode) { - auto *proto_msg = static_cast(msg); - DumpBuffer dump_buf; - conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); - return 1; - } -#endif - const uint8_t footer_size = conn->helper_->frame_footer_size(); - - // First message uses max padding (already in buffer), subsequent use exact header size - size_t to_add; - if (conn->flags_.batch_first_message) { - conn->flags_.batch_first_message = false; - conn->batch_header_size_ = conn->helper_->frame_header_padding(); - to_add = calculated_size; - } else { - conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); - to_add = calculated_size + conn->batch_header_size_ + footer_size; - } - - // Check if it fits (using actual header size, not max padding) - uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; - if (total_calculated_size > remaining_size) - return 0; - - auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - shared_buf.resize(shared_buf.size() + to_add); - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); - - return total_calculated_size; - } + // Definition is at the bottom of api_server.h, where APIServer is a complete type + // (needed for conn->parent_->get_shared_buffer_ref()). Callers must include api_server.h. + static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, + const void *msg, APIConnection *conn, uint32_t remaining_size); // Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages). // All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion. @@ -792,7 +766,8 @@ class APIConnection final : public APIServerConnectionBase { // Read by process_batch_multi_ to pass into MessageInfo. uint8_t batch_header_size_{0}; - uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + // Definition at the bottom of api_server.h (needs APIServer complete). + uint32_t get_batch_delay_ms_() const; // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. // If its IPv6 the header is 40 bytes, and if its IPv4 diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6c26c4e187..c30bd2e612 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -30,11 +30,6 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c APIServer::APIServer() { global_api_server = this; } -// Custom deleter defined here so `delete` sees the complete APIConnection type. -// This prevents libc++ from emitting an "incomplete type" error when other -// translation units only have the forward declaration of APIConnection. -void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; } - void APIServer::socket_failed_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); this->destroy_socket_(); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 6b575e536d..d7f3e66288 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -3,6 +3,12 @@ #include "esphome/core/defines.h" #ifdef USE_API #include "api_buffer.h" +// api_connection.h must be included before clients_ is declared so APIConnection +// is a complete type when std::unique_ptr is instantiated. libc++ +// otherwise trips static_assert(sizeof(_Tp) >= 0, "cannot delete an incomplete +// type") while parsing the array member. api_connection.h transitively brings in +// list_entities.h and subscribe_state.h. +#include "api_connection.h" #include "api_noise_context.h" #include "api_pb2.h" #include "api_pb2_service.h" @@ -12,8 +18,6 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -#include "list_entities.h" -#include "subscribe_state.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -191,15 +195,9 @@ class APIServer final : public Component, bool is_connected_with_state_subscription() const; // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect - // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the + // to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the // APIConnection but cannot reset/move the slot and break the count invariant. - // Custom deleter is defined out-of-line in api_server.cpp so libc++ does not - // eagerly instantiate `delete static_cast(p)` here, where - // only the forward declaration of APIConnection is visible (incomplete type). - struct APIConnectionDeleter { - void operator()(APIConnection *p) const; - }; - using APIConnectionPtr = std::unique_ptr; + using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { const APIConnectionPtr *begin_; const APIConnectionPtr *end_; @@ -359,5 +357,49 @@ template class APIConnectedCondition : public Condition { } }; +// Inline definitions for APIConnection members declared in api_connection.h +// that need the complete APIServer type. Defined here, after APIServer is +// complete, so api_connection.h can break the include cycle by only +// forward-declaring APIServer. + +inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size, + MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size) { +#ifdef HAS_PROTO_MESSAGE_DUMP + if (conn->flags_.log_only_mode) { + auto *proto_msg = static_cast(msg); + DumpBuffer dump_buf; + conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); + return 1; + } +#endif + const uint8_t footer_size = conn->helper_->frame_footer_size(); + + // First message uses max padding (already in buffer), subsequent use exact header size + size_t to_add; + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + conn->batch_header_size_ = conn->helper_->frame_header_padding(); + to_add = calculated_size; + } else { + conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); + to_add = calculated_size + conn->batch_header_size_ + footer_size; + } + + // Check if it fits (using actual header size, not max padding) + uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; + if (total_calculated_size > remaining_size) + return 0; + + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + shared_buf.resize(shared_buf.size() + to_add); + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + + return total_calculated_size; +} + +inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + } // namespace esphome::api #endif From 419fb99fdbe32897fd2741c9df807776b4f00240 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 14:03:26 -0500 Subject: [PATCH 1343/2214] [api] Move APIConnection inlines into api_connection_buffer.h Pull encode_to_buffer and get_batch_delay_ms_ out of the tail of api_server.h into a dedicated api_connection_buffer.h so api_server.h no longer carries APIConnection method bodies. api_connection.cpp includes api_connection_buffer.h instead of api_server.h; the new header pulls api_server.h in to keep APIServer complete at the inline-definition site. No behavioral change; just file organisation on top of the previous include-cycle break. --- esphome/components/api/api_connection.cpp | 4 +- esphome/components/api/api_connection.h | 11 ++-- .../components/api/api_connection_buffer.h | 61 +++++++++++++++++++ esphome/components/api/api_server.h | 44 ------------- 4 files changed, 70 insertions(+), 50 deletions(-) create mode 100644 esphome/components/api/api_connection_buffer.h diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 79123ec893..2dcb94aac1 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,6 +1,8 @@ #include "api_connection.h" #ifdef USE_API -#include "api_server.h" // brings APIServer complete for encode_to_buffer/get_batch_delay_ms_ +// Pulls in api_server.h (APIServer complete) and the inline encode_to_buffer +// and get_batch_delay_ms_ definitions that need it. +#include "api_connection_buffer.h" #ifdef USE_API_NOISE #include "api_frame_helper_noise.h" #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 61d099c27d..7204acbce8 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -38,8 +38,8 @@ class ComponentIterator; namespace esphome::api { // Forward declaration to break the api_connection.h <-> api_server.h include cycle. -// APIServer is only used through pointers/references in this header; methods that -// need the complete type are defined inline at the bottom of api_server.h. +// APIServer is only used through pointers/references in this header; the few +// inline methods that need the complete type are defined in api_connection_buffer.h. class APIServer; // Keepalive timeout in milliseconds @@ -419,8 +419,9 @@ class APIConnection final : public APIServerConnectionBase { // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. - // Definition is at the bottom of api_server.h, where APIServer is a complete type - // (needed for conn->parent_->get_shared_buffer_ref()). Callers must include api_server.h. + // Definition lives in api_connection_buffer.h, where APIServer is a complete type + // (needed for conn->parent_->get_shared_buffer_ref()). Callers must include + // api_connection_buffer.h. static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg, APIConnection *conn, uint32_t remaining_size); @@ -766,7 +767,7 @@ class APIConnection final : public APIServerConnectionBase { // Read by process_batch_multi_ to pass into MessageInfo. uint8_t batch_header_size_{0}; - // Definition at the bottom of api_server.h (needs APIServer complete). + // Definition in api_connection_buffer.h (needs APIServer complete). uint32_t get_batch_delay_ms_() const; // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h new file mode 100644 index 0000000000..1921dbb5a5 --- /dev/null +++ b/esphome/components/api/api_connection_buffer.h @@ -0,0 +1,61 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_API + +// Inline definitions for APIConnection members that need the complete +// APIServer type (currently encode_to_buffer for the hot batch-send path and +// get_batch_delay_ms_). Pulled into a dedicated header so api_server.h does +// not have to carry APIConnection method bodies and api_connection.h can +// keep just a forward declaration of APIServer, breaking the include cycle +// that previously forced a custom std::unique_ptr deleter on libc++. +// +// Include this header from translation units that call encode_to_buffer or +// any other APIConnection inline that touches APIServer. + +#include "api_connection.h" +#include "api_server.h" + +namespace esphome::api { + +inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size, + MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size) { +#ifdef HAS_PROTO_MESSAGE_DUMP + if (conn->flags_.log_only_mode) { + auto *proto_msg = static_cast(msg); + DumpBuffer dump_buf; + conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); + return 1; + } +#endif + const uint8_t footer_size = conn->helper_->frame_footer_size(); + + // First message uses max padding (already in buffer), subsequent use exact header size + size_t to_add; + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + conn->batch_header_size_ = conn->helper_->frame_header_padding(); + to_add = calculated_size; + } else { + conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); + to_add = calculated_size + conn->batch_header_size_ + footer_size; + } + + // Check if it fits (using actual header size, not max padding) + uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; + if (total_calculated_size > remaining_size) + return 0; + + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + shared_buf.resize(shared_buf.size() + to_add); + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + + return total_calculated_size; +} + +inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + +} // namespace esphome::api +#endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index d7f3e66288..58bc2c3479 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -357,49 +357,5 @@ template class APIConnectedCondition : public Condition { } }; -// Inline definitions for APIConnection members declared in api_connection.h -// that need the complete APIServer type. Defined here, after APIServer is -// complete, so api_connection.h can break the include cycle by only -// forward-declaring APIServer. - -inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size, - MessageEncodeFn encode_fn, const void *msg, - APIConnection *conn, uint32_t remaining_size) { -#ifdef HAS_PROTO_MESSAGE_DUMP - if (conn->flags_.log_only_mode) { - auto *proto_msg = static_cast(msg); - DumpBuffer dump_buf; - conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); - return 1; - } -#endif - const uint8_t footer_size = conn->helper_->frame_footer_size(); - - // First message uses max padding (already in buffer), subsequent use exact header size - size_t to_add; - if (conn->flags_.batch_first_message) { - conn->flags_.batch_first_message = false; - conn->batch_header_size_ = conn->helper_->frame_header_padding(); - to_add = calculated_size; - } else { - conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); - to_add = calculated_size + conn->batch_header_size_ + footer_size; - } - - // Check if it fits (using actual header size, not max padding) - uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; - if (total_calculated_size > remaining_size) - return 0; - - auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - shared_buf.resize(shared_buf.size() + to_add); - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); - - return total_calculated_size; -} - -inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } - } // namespace esphome::api #endif From 7443a65541254786db86e9b4fbdcac71a751e444 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 14:06:18 -0500 Subject: [PATCH 1344/2214] [api] Trim verbose comments in include refactor --- esphome/components/api/api_connection.cpp | 4 +--- esphome/components/api/api_connection.h | 13 ++++--------- esphome/components/api/api_connection_buffer.h | 11 ++--------- esphome/components/api/api_server.h | 6 +----- 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2dcb94aac1..f2bf3752fa 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,8 +1,6 @@ #include "api_connection.h" #ifdef USE_API -// Pulls in api_server.h (APIServer complete) and the inline encode_to_buffer -// and get_batch_delay_ms_ definitions that need it. -#include "api_connection_buffer.h" +#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines #ifdef USE_API_NOISE #include "api_frame_helper_noise.h" #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7204acbce8..804cd9ddd1 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -37,9 +37,7 @@ class ComponentIterator; namespace esphome::api { -// Forward declaration to break the api_connection.h <-> api_server.h include cycle. -// APIServer is only used through pointers/references in this header; the few -// inline methods that need the complete type are defined in api_connection_buffer.h. +// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. class APIServer; // Keepalive timeout in milliseconds @@ -417,11 +415,8 @@ class APIConnection final : public APIServerConnectionBase { // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg); - // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. - // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. - // Definition lives in api_connection_buffer.h, where APIServer is a complete type - // (needed for conn->parent_->get_shared_buffer_ref()). Callers must include - // api_connection_buffer.h. + // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites. + // Defined in api_connection_buffer.h (needs APIServer complete). static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg, APIConnection *conn, uint32_t remaining_size); @@ -767,7 +762,7 @@ class APIConnection final : public APIServerConnectionBase { // Read by process_batch_multi_ to pass into MessageInfo. uint8_t batch_header_size_{0}; - // Definition in api_connection_buffer.h (needs APIServer complete). + // Defined in api_connection_buffer.h (needs APIServer complete). uint32_t get_batch_delay_ms_() const; // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h index 1921dbb5a5..1dd8a162e4 100644 --- a/esphome/components/api/api_connection_buffer.h +++ b/esphome/components/api/api_connection_buffer.h @@ -3,15 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_API -// Inline definitions for APIConnection members that need the complete -// APIServer type (currently encode_to_buffer for the hot batch-send path and -// get_batch_delay_ms_). Pulled into a dedicated header so api_server.h does -// not have to carry APIConnection method bodies and api_connection.h can -// keep just a forward declaration of APIServer, breaking the include cycle -// that previously forced a custom std::unique_ptr deleter on libc++. -// -// Include this header from translation units that call encode_to_buffer or -// any other APIConnection inline that touches APIServer. +// Inline APIConnection methods that need APIServer complete. Include this +// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_. #include "api_connection.h" #include "api_server.h" diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 58bc2c3479..fbc8115091 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -3,11 +3,7 @@ #include "esphome/core/defines.h" #ifdef USE_API #include "api_buffer.h" -// api_connection.h must be included before clients_ is declared so APIConnection -// is a complete type when std::unique_ptr is instantiated. libc++ -// otherwise trips static_assert(sizeof(_Tp) >= 0, "cannot delete an incomplete -// type") while parsing the array member. api_connection.h transitively brings in -// list_entities.h and subscribe_state.h. +// Must precede clients_ so APIConnection is complete for default_delete (libc++). #include "api_connection.h" #include "api_noise_context.h" #include "api_pb2.h" From 22ae85a2ed323e59443c0df4b25bc313dabbe6f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 14:17:14 -0500 Subject: [PATCH 1345/2214] [bluetooth_proxy] Add direct api_server.h include lost via api_connection.h refactor --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c3461f9c51..ca30aab943 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -1,5 +1,6 @@ #include "bluetooth_proxy.h" +#include "esphome/components/api/api_server.h" #include "esphome/core/log.h" #include "esphome/core/macros.h" #include "esphome/core/application.h" From 96eced0378882f9b2d2ebb3810d87cad255bf7f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 15:42:57 -0500 Subject: [PATCH 1346/2214] [api] Break api_connection/api_server include cycle to drop custom unique_ptr deleter (#16542) --- esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_connection.h | 51 ++++-------------- .../components/api/api_connection_buffer.h | 54 +++++++++++++++++++ esphome/components/api/api_server.cpp | 5 -- esphome/components/api/api_server.h | 14 ++--- .../bluetooth_proxy/bluetooth_proxy.cpp | 1 + 6 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 esphome/components/api/api_connection_buffer.h diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b6f4aa2141..f2bf3752fa 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,5 +1,6 @@ #include "api_connection.h" #ifdef USE_API +#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines #ifdef USE_API_NOISE #include "api_frame_helper_noise.h" #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4165b7f3a2..804cd9ddd1 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -11,7 +11,8 @@ #endif #include "api_pb2.h" #include "api_pb2_service.h" -#include "api_server.h" +#include "list_entities.h" +#include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" #ifdef USE_ESP32_CRASH_HANDLER @@ -36,6 +37,9 @@ class ComponentIterator; namespace esphome::api { +// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. +class APIServer; + // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending @@ -411,44 +415,10 @@ class APIConnection final : public APIServerConnectionBase { // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg); - // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. - // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. - static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, - const void *msg, APIConnection *conn, - uint32_t remaining_size) { -#ifdef HAS_PROTO_MESSAGE_DUMP - if (conn->flags_.log_only_mode) { - auto *proto_msg = static_cast(msg); - DumpBuffer dump_buf; - conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); - return 1; - } -#endif - const uint8_t footer_size = conn->helper_->frame_footer_size(); - - // First message uses max padding (already in buffer), subsequent use exact header size - size_t to_add; - if (conn->flags_.batch_first_message) { - conn->flags_.batch_first_message = false; - conn->batch_header_size_ = conn->helper_->frame_header_padding(); - to_add = calculated_size; - } else { - conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); - to_add = calculated_size + conn->batch_header_size_ + footer_size; - } - - // Check if it fits (using actual header size, not max padding) - uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; - if (total_calculated_size > remaining_size) - return 0; - - auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - shared_buf.resize(shared_buf.size() + to_add); - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); - - return total_calculated_size; - } + // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites. + // Defined in api_connection_buffer.h (needs APIServer complete). + static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, + const void *msg, APIConnection *conn, uint32_t remaining_size); // Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages). // All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion. @@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase { // Read by process_batch_multi_ to pass into MessageInfo. uint8_t batch_header_size_{0}; - uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + // Defined in api_connection_buffer.h (needs APIServer complete). + uint32_t get_batch_delay_ms_() const; // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. // If its IPv6 the header is 40 bytes, and if its IPv4 diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h new file mode 100644 index 0000000000..1dd8a162e4 --- /dev/null +++ b/esphome/components/api/api_connection_buffer.h @@ -0,0 +1,54 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_API + +// Inline APIConnection methods that need APIServer complete. Include this +// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_. + +#include "api_connection.h" +#include "api_server.h" + +namespace esphome::api { + +inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size, + MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size) { +#ifdef HAS_PROTO_MESSAGE_DUMP + if (conn->flags_.log_only_mode) { + auto *proto_msg = static_cast(msg); + DumpBuffer dump_buf; + conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); + return 1; + } +#endif + const uint8_t footer_size = conn->helper_->frame_footer_size(); + + // First message uses max padding (already in buffer), subsequent use exact header size + size_t to_add; + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + conn->batch_header_size_ = conn->helper_->frame_header_padding(); + to_add = calculated_size; + } else { + conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); + to_add = calculated_size + conn->batch_header_size_ + footer_size; + } + + // Check if it fits (using actual header size, not max padding) + uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; + if (total_calculated_size > remaining_size) + return 0; + + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + shared_buf.resize(shared_buf.size() + to_add); + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + + return total_calculated_size; +} + +inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + +} // namespace esphome::api +#endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6c26c4e187..c30bd2e612 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -30,11 +30,6 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c APIServer::APIServer() { global_api_server = this; } -// Custom deleter defined here so `delete` sees the complete APIConnection type. -// This prevents libc++ from emitting an "incomplete type" error when other -// translation units only have the forward declaration of APIConnection. -void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; } - void APIServer::socket_failed_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); this->destroy_socket_(); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 6b575e536d..fbc8115091 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_API #include "api_buffer.h" +// Must precede clients_ so APIConnection is complete for default_delete (libc++). +#include "api_connection.h" #include "api_noise_context.h" #include "api_pb2.h" #include "api_pb2_service.h" @@ -12,8 +14,6 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -#include "list_entities.h" -#include "subscribe_state.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -191,15 +191,9 @@ class APIServer final : public Component, bool is_connected_with_state_subscription() const; // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect - // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the + // to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the // APIConnection but cannot reset/move the slot and break the count invariant. - // Custom deleter is defined out-of-line in api_server.cpp so libc++ does not - // eagerly instantiate `delete static_cast(p)` here, where - // only the forward declaration of APIConnection is visible (incomplete type). - struct APIConnectionDeleter { - void operator()(APIConnection *p) const; - }; - using APIConnectionPtr = std::unique_ptr; + using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { const APIConnectionPtr *begin_; const APIConnectionPtr *end_; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c3461f9c51..ca30aab943 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -1,5 +1,6 @@ #include "bluetooth_proxy.h" +#include "esphome/components/api/api_server.h" #include "esphome/core/log.h" #include "esphome/core/macros.h" #include "esphome/core/application.h" From 38b8b41ccc9b4c4d1a963ae98b6308d9a34fe988 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 18:03:07 -0400 Subject: [PATCH 1347/2214] [sx126x] Assert NSS before wait_busy so commands wake the chip from sleep (#16546) --- esphome/components/sx126x/sx126x.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6e6857fadb..83afeac50a 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_READ_BUFFER); this->transfer_byte(offset); uint8_t status = this->transfer_byte(0x00); @@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { } void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_WRITE_BUFFER); this->transfer_byte(offset); for (const uint8_t &byte : packet) { @@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { } uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); uint8_t status = this->transfer_byte(0x00); for (int32_t i = 0; i < size; i++) { @@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); for (int32_t i = 0; i < size; i++) { this->transfer_byte(data[i]); @@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_READ_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); @@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { } void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_WRITE_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); From aea1e4d136cf2d35a3e694f9666bfab67dacd797 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 17:05:17 -0500 Subject: [PATCH 1348/2214] [core] Refresh compiled config cache after upload/logs fallback (#16548) --- esphome/__main__.py | 15 +++- tests/unit_tests/test_compiled_config.py | 100 +++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 16a05ad552..07bbd89358 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2449,7 +2449,10 @@ def run_esphome(argv): # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. config: ConfigType | None = None - if args.command in ("upload", "logs") and not command_line_substitutions: + cache_eligible = ( + args.command in ("upload", "logs") and not command_line_substitutions + ) + if cache_eligible: from esphome.compiled_config import load_compiled_config config = load_compiled_config(conf_path) @@ -2464,6 +2467,16 @@ def run_esphome(argv): command_line_substitutions, skip_external_update=skip_external, ) + # 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 CORE.config = config diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 8c9cfa8101..e12107152b 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -253,6 +253,106 @@ 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) YAML 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") + CORE.config_path = yaml_path + + with ( + patch( + "esphome.__main__.read_config", + return_value={"esphome": {"name": "lite_test"}}, + ), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "upload", str(yaml_path)]) + + mock_save.assert_not_called() + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( + tmp_path: Path, command: str +) -> None: + """A stale-cache fallback rewrites the cache so the next call hits + the fast path. Without this, every upload/logs after a YAML edit + pays for read_config() until the next compile rewrites the cache.""" + 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=-60) # stale + + fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} + + with ( + patch("esphome.__main__.read_config", return_value=fresh_config), + patch( + "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config + ) as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {command: lambda args, config: 0}, + ), + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + + mock_save.assert_called_once_with(fresh_config) + # 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 + + +def test_run_esphome_upload_with_substitution_does_not_refresh_cache( + fresh_cache_files: Path, +) -> None: + """`-s` substitutions skip the cache on both read and write -- saving + here would clobber the cache with a substitution-specific config.""" + with ( + patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)]) + + mock_save.assert_not_called() + + +def test_run_esphome_compile_does_not_refresh_cache_via_fallback( + fresh_cache_files: Path, +) -> None: + """Compile writes the cache through update_storage_json, not via the + upload/logs fallback path -- the fallback save would skip the + storage_should_clean check.""" + with ( + patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"compile": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "compile", str(fresh_cache_files)]) + + mock_save.assert_not_called() + + def test_run_esphome_upload_with_substitution_skips_cache( fresh_cache_files: Path, ) -> None: From 4ff8eb4b15c64422b79ac618524b0c8b39d04485 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 22:08:56 +0000 Subject: [PATCH 1349/2214] Bump ruff from 0.15.13 to 0.15.14 (#16543) Co-authored-by: J. Nick Koston Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da5fb94d5e..0470a948f5 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.15.12 + rev: v0.15.14 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index ea4941a882..dbdea1d935 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.13 # also change in .pre-commit-config.yaml when updating +ruff==0.15.14 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 1d3eea098e317b5c6bf9d6656a26f5b0825fa46a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 22 May 2026 13:00:22 +1200 Subject: [PATCH 1350/2214] [core] Support YAML frontmatter for arbitrary user metadata (#16552) --- esphome/core/__init__.py | 9 +- esphome/yaml_util.py | 29 +++++- tests/unit_tests/test_yaml_util.py | 158 +++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 3 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index e13d5668af..580d7f6477 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -5,7 +5,7 @@ import math import os from pathlib import Path import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from esphome.const import ( CONF_COMMENT, @@ -569,6 +569,12 @@ class EsphomeCore: self.build_path: Path | None = None # The validated configuration, this is None until the config has been validated self.config: ConfigType | None = None + # YAML frontmatter loaded from user YAML files. Frontmatter is a leading + # YAML document separated by `---` from the actual configuration. It is + # ignored by config validation and code generation, but kept here so it + # can be inspected by callers (tooling, future features). Keyed by the + # resolved Path of the source file. + self.frontmatter: dict[Path, Any] = {} # The pending tasks in the task queue (mostly for C++ generation) # This is a priority queue (with heapq) # Each item is a tuple of form: (-priority, unique number, task) @@ -634,6 +640,7 @@ class EsphomeCore: self.config_path = None self.build_path = None self.config = None + self.frontmatter = {} self.event_loop = _FakeEventLoop() self.task_counter = 0 self.variables = {} diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b56d024418..9a36ad089c 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -768,10 +768,35 @@ def _load_yaml_internal_with_type( content: TextIOWrapper, yaml_loader: Callable[[Path], dict[str, Any]], ) -> Any: - """Load a YAML file.""" + """Load a YAML file. + + Supports an optional leading YAML frontmatter document: when the file + contains two YAML documents separated by ``---``, the first document is + treated as metadata and stored in :attr:`CORE.frontmatter` keyed by the + resolved file path, while the second document is returned as the actual + configuration. Frontmatter is ignored by config validation and code + generation. + """ loader = loader_type(content, fname, yaml_loader) try: - return loader.get_single_data() or OrderedDict() + documents: list[Any] = [] + while loader.check_data(): + documents.append(loader.get_data()) + if len(documents) > 2: + raise EsphomeError( + f"YAML file '{fname}' contains {len(documents)} documents but " + f"at most two are supported (an optional frontmatter document " + f"followed by the configuration)." + ) + if len(documents) == 2: + frontmatter = documents[0] + config = documents[1] + if frontmatter is not None: + CORE.frontmatter[Path(fname).resolve()] = frontmatter + return config if config is not None else OrderedDict() + if len(documents) == 1: + return documents[0] or OrderedDict() + return OrderedDict() except yaml.YAMLError as exc: raise EsphomeError(exc) from exc finally: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e97a188be4..de70a5307d 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -34,6 +34,14 @@ def clear_secrets_cache() -> None: yaml_util._SECRET_CACHE.clear() +@pytest.fixture(autouse=True) +def clear_core_frontmatter() -> None: + """Reset CORE.frontmatter between tests.""" + core.CORE.frontmatter = {} + yield + core.CORE.frontmatter = {} + + def test_include_with_vars(fixture_path: Path) -> None: yaml_file = fixture_path / "yaml_util" / "includetest.yaml" @@ -1182,3 +1190,153 @@ def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: with track_yaml_loads() as loaded: yaml_util.load_yaml(link) assert target.resolve() in loaded + + +# --------------------------------------------------------------------------- +# YAML frontmatter +# --------------------------------------------------------------------------- + + +def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None: + """A leading `---`-separated YAML document is stored as frontmatter and + stripped from the returned config.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "author: Jesse\nlabels: [office, climate]\n---\nesphome:\n name: my_node\n" + ) + + config = yaml_util.load_yaml(yaml_file) + + # Config does not contain frontmatter keys + assert "author" not in config + assert "labels" not in config + assert config["esphome"]["name"] == "my_node" + + # Frontmatter is stored on CORE keyed by resolved path + frontmatter = core.CORE.frontmatter[yaml_file.resolve()] + assert frontmatter["author"] == "Jesse" + assert frontmatter["labels"] == ["office", "climate"] + + +def test_frontmatter_absent_when_single_document(tmp_path: Path) -> None: + """A YAML file with a single document does not populate CORE.frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("esphome:\n name: my_node\n") + + yaml_util.load_yaml(yaml_file) + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_absent_when_leading_doc_separator(tmp_path: Path) -> None: + """A leading `---` with no content above it is just a document start marker, + not frontmatter, and must not populate CORE.frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("---\nesphome:\n name: my_node\n") + + config = yaml_util.load_yaml(yaml_file) + assert config["esphome"]["name"] == "my_node" + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None: + """Frontmatter keys are not validated — any structure is accepted.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "any_key: any_value\n" + "nested:\n" + " count: 42\n" + " items:\n" + " - a\n" + " - b\n" + "---\n" + "esphome:\n" + " name: t\n" + ) + + yaml_util.load_yaml(yaml_file) + frontmatter = core.CORE.frontmatter[yaml_file.resolve()] + assert frontmatter["any_key"] == "any_value" + assert frontmatter["nested"]["count"] == 42 + assert frontmatter["nested"]["items"] == ["a", "b"] + + +def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None: + """Frontmatter preserves deeply nested dict/list structures intact.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "device:\n" + " metadata:\n" + " location:\n" + " building: HQ\n" + " floor: 3\n" + " room:\n" + " number: 302\n" + " occupants:\n" + " - name: Jesse\n" + " role:\n" + " title: maintainer\n" + " since: 2021\n" + " - name: Alice\n" + " role:\n" + " title: contributor\n" + " since: 2024\n" + "---\n" + "esphome:\n" + " name: t\n" + ) + + yaml_util.load_yaml(yaml_file) + fm = core.CORE.frontmatter[yaml_file.resolve()] + room = fm["device"]["metadata"]["location"]["room"] + assert room["number"] == 302 + assert room["occupants"][0]["name"] == "Jesse" + assert room["occupants"][0]["role"]["title"] == "maintainer" + assert room["occupants"][0]["role"]["since"] == 2021 + assert room["occupants"][1]["role"]["title"] == "contributor" + + +def test_frontmatter_more_than_two_documents_raises(tmp_path: Path) -> None: + """Three or more YAML documents is unsupported and must raise.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("a: 1\n---\nb: 2\n---\nc: 3\n") + + with pytest.raises(EsphomeError, match="at most two are supported"): + yaml_util.load_yaml(yaml_file) + + +def test_frontmatter_empty_frontmatter_doc_not_stored(tmp_path: Path) -> None: + """An empty (null) frontmatter document is treated as no frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("---\n---\nesphome:\n name: t\n") + + config = yaml_util.load_yaml(yaml_file) + assert config["esphome"]["name"] == "t" + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_empty_config_doc(tmp_path: Path) -> None: + """An empty config document after a frontmatter document yields an empty config.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("only: frontmatter\n---\n") + + config = yaml_util.load_yaml(yaml_file) + assert config == {} + assert core.CORE.frontmatter[yaml_file.resolve()]["only"] == "frontmatter" + + +def test_frontmatter_included_file_stored(tmp_path: Path) -> None: + """Frontmatter on an !include'd file is also captured on CORE, keyed by + that file's resolved path.""" + inc = tmp_path / "child.yaml" + inc.write_text("child_meta: hello\n---\nchild_key: value\n") + main = tmp_path / "main.yaml" + main.write_text("esphome:\n name: t\nchild: !include child.yaml\n") + + config = yaml_util.load_yaml(main) + # !include is deferred; force resolution so the child file actually loads + force_load_include_files(config) + assert config["child"].load()["child_key"] == "value" + # Main file has no frontmatter + assert main.resolve() not in core.CORE.frontmatter + # Included file's frontmatter is captured + assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello" From 0b2eb6481f4ab2c73b0b85430093c1f6a9a33810 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 22 May 2026 13:42:50 +1200 Subject: [PATCH 1351/2214] [light] Add light.effect.next / light.effect.previous actions (#16491) --- esphome/components/light/__init__.py | 33 ++++- esphome/components/light/automation.h | 41 ++++++ esphome/components/light/automation.py | 76 ++++++++++- esphome/components/light/types.py | 1 + .../light/test_effect_validation.py | 127 +++++++++++++++++- tests/components/light/common.yaml | 10 ++ 6 files changed, 283 insertions(+), 5 deletions(-) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 9540c64486..68d9f85af2 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -86,10 +86,22 @@ class EffectRef: component_path: list[str | int] # path_context when the action was validated +@dataclass +class EffectCycleRef: + """A pending light.effect.next/previous action to validate. + + Records that the referenced light needs at least one effect configured. + """ + + light_id: ID + component_path: list[str | int] + + @dataclass class LightData: gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr effect_refs: list[EffectRef] = field(default_factory=list) + effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list) def _get_data() -> LightData: @@ -160,13 +172,15 @@ def _final_validate(config: ConfigType) -> ConfigType: this never runs — but the ID validator will catch the missing light ID separately. """ data = _get_data() - if not data.effect_refs: + if not data.effect_refs and not data.effect_cycle_refs: return config - # Drain the list so we only validate once even though + # Drain the lists so we only validate once even though # FINAL_VALIDATE_SCHEMA runs for each light platform instance. refs = data.effect_refs data.effect_refs = [] + cycle_refs = data.effect_cycle_refs + data.effect_cycle_refs = [] fconf = fv.full_config.get() @@ -188,6 +202,21 @@ def _final_validate(config: ConfigType) -> ConfigType: path=[cv.ROOT_CONFIG_PATH] + ref.component_path, ) + for ref in cycle_refs: + try: + light_path = fconf.get_path_for_id(ref.light_id)[:-1] + light_config = fconf.get_config_for_path(light_path) + except KeyError: + continue + + if not light_config.get(CONF_EFFECTS): + raise cv.FinalExternalInvalid( + f"Light '{ref.light_id}' has no effects configured, but a " + f"'light.effect.next' or 'light.effect.previous' action " + f"references it. Add at least one effect to the light.", + path=[cv.ROOT_CONFIG_PATH] + ref.component_path, + ) + return config diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 993d4a2ea6..260414f033 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -104,6 +104,47 @@ template class DimRelativeAction : pub transition_length_{}; }; +// Cycle through the light's configured effects. `Forward` selects direction +// at compile time so the chosen branch is the only one that gets instantiated +// per action site. `include_none` is runtime so a single set of templates +// covers both the "wrap through None" and "skip None" variants. +template class LightEffectCycleAction : public Action { + public: + explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {} + + void set_include_none(bool include_none) { this->include_none_ = include_none; } + + void play(const Ts &...) override { + size_t count = this->parent_->get_effect_count(); + if (count == 0) { + return; + } + uint32_t current = this->parent_->get_current_effect_index(); + uint32_t next; + if (this->include_none_) { + uint32_t total = static_cast(count) + 1; + if constexpr (Forward) { + next = (current + 1) % total; + } else { + next = (current + total - 1) % total; + } + } else { + if constexpr (Forward) { + next = (current % static_cast(count)) + 1; + } else { + next = (current <= 1) ? static_cast(count) : current - 1; + } + } + auto call = this->parent_->turn_on(); + call.set_effect(next); + call.perform(); + } + + protected: + LightState *parent_; + bool include_none_{false}; +}; + template class LightIsOnCondition : public Condition { public: explicit LightIsOnCondition(LightState *state) : state_(state) {} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index cef774af38..7eaba9b117 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -26,8 +26,8 @@ from esphome.const import ( CONF_WARM_WHITE, CONF_WHITE, ) -from esphome.core import CORE, EsphomeError, Lambda -from esphome.cpp_generator import LambdaExpression +from esphome.core import CORE, ID, EsphomeError, Lambda +from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType from esphome.types import ConfigType from .types import ( @@ -39,12 +39,15 @@ from .types import ( DimRelativeAction, LightCall, LightControlAction, + LightEffectCycleAction, LightIsOffCondition, LightIsOnCondition, LightState, ToggleAction, ) +CONF_INCLUDE_NONE = "include_none" + @automation.register_action( "light.toggle", @@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) +def _record_effect_cycle_ref(config: ConfigType) -> ConfigType: + """Record a cycle-action reference for later validation against the target light.""" + from . import EffectCycleRef, _get_data + + _get_data().effect_cycle_refs.append( + EffectCycleRef( + light_id=config[CONF_ID], + component_path=path_context.get(), + ) + ) + return config + + +LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(LightState), + cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean, + } +) +LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref) + +LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id( + LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA +) + + +@automation.register_action( + "light.effect.next", + LightEffectCycleAction, + LIGHT_EFFECT_CYCLE_ACTION_SCHEMA, + synchronous=True, +) +async def light_effect_next_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await _light_effect_cycle_to_code(config, action_id, template_arg, True) + + +@automation.register_action( + "light.effect.previous", + LightEffectCycleAction, + LIGHT_EFFECT_CYCLE_ACTION_SCHEMA, + synchronous=True, +) +async def light_effect_previous_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await _light_effect_cycle_to_code(config, action_id, template_arg, False) + + +async def _light_effect_cycle_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + forward: bool, +) -> MockObj: + paren = await cg.get_variable(config[CONF_ID]) + cycle_template_arg = cg.TemplateArguments(forward, *template_arg) + var = cg.new_Pvariable(action_id, cycle_template_arg, paren) + cg.add(var.set_include_none(config[CONF_INCLUDE_NONE])) + return var + + CONF_RELATIVE_BRIGHTNESS = "relative_brightness" LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema( { diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 534dcd2194..c7385cbee3 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -39,6 +39,7 @@ LIMIT_MODES = { # Actions ToggleAction = light_ns.class_("ToggleAction", automation.Action) LightControlAction = light_ns.class_("LightControlAction", automation.Action) +LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition) diff --git a/tests/component_tests/light/test_effect_validation.py b/tests/component_tests/light/test_effect_validation.py index 579e92c62a..aab9072cc8 100644 --- a/tests/component_tests/light/test_effect_validation.py +++ b/tests/component_tests/light/test_effect_validation.py @@ -9,13 +9,17 @@ import pytest from esphome import config_validation as cv from esphome.components.light import ( + EffectCycleRef, EffectRef, _final_validate, _get_data, available_effects_str, find_effect_index, ) -from esphome.components.light.automation import _record_effect_ref +from esphome.components.light.automation import ( + _record_effect_cycle_ref, + _record_effect_ref, +) from esphome.config import Config, path_context from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME from esphome.core import ID, Lambda @@ -215,6 +219,111 @@ def test_final_validate_drains_refs() -> None: fv.full_config.reset(token) +# --- _final_validate: EffectCycleRef --- + + +def _setup_cycle_final_validate( + cycle_refs: list[EffectCycleRef], + light_configs: list[ConfigType], + declare_ids: list[tuple[ID, list[str | int]]], +) -> Token: + """Set up CORE.data and fv.full_config for EffectCycleRef final_validate tests.""" + data = _get_data() + data.effect_cycle_refs = cycle_refs + + full_conf = Config() + full_conf["light"] = light_configs + for id_, path in declare_ids: + full_conf.declare_ids.append((id_, path)) + + return fv.full_config.set(full_conf) + + +def test_final_validate_cycle_accepts_light_with_effects() -> None: + """Cycle ref against a light with effects should not raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_rejects_light_without_effects_key() -> None: + """Cycle ref against a light with no CONF_EFFECTS key should raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_rejects_light_with_empty_effects() -> None: + """Cycle ref against a light with empty effects list should raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: []}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_unknown_light_id_skipped() -> None: + """Cycle refs to unknown light IDs should be silently skipped.""" + data = _get_data() + data.effect_cycle_refs = [ + EffectCycleRef( + light_id=ID("nonexistent", is_declaration=True), + component_path=["esphome"], + ) + ] + + full_conf = Config() + token = fv.full_config.set(full_conf) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_drains_cycle_refs() -> None: + """Cycle refs should be drained after validation to avoid redundant runs.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + assert _get_data().effect_cycle_refs == [] + finally: + fv.full_config.reset(token) + + # --- _record_effect_ref --- @@ -278,3 +387,19 @@ def test_record_effect_ref_skips_no_effect_key() -> None: config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)} _record_effect_ref(config) assert _get_data().effect_refs == [] + + +# --- _record_effect_cycle_ref --- + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_cycle_ref() -> None: + """Cycle-action config should be recorded with light_id and path.""" + light_id = ID("led1", is_declaration=True) + config: ConfigType = {CONF_ID: light_id} + result = _record_effect_cycle_ref(config) + assert result is config + data = _get_data() + assert len(data.effect_cycle_refs) == 1 + assert data.effect_cycle_refs[0].light_id is light_id + assert data.effect_cycle_refs[0].component_path == ["esphome"] diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 044a8144fa..cd9b27768e 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -103,6 +103,16 @@ esphome: - light.turn_on: id: test_monochromatic_light effect: !lambda 'return iteration > 1 ? "Strobe" : "none";' + # Cycle through configured effects (skip "None") + - light.effect.next: test_monochromatic_light + - light.effect.previous: test_monochromatic_light + # Cycle through effects including "None" + - light.effect.next: + id: test_monochromatic_light + include_none: true + - light.effect.previous: + id: test_monochromatic_light + include_none: true - light.dim_relative: id: test_monochromatic_light relative_brightness: 5% From 0b5e7ae8fa052d6e193f730e0024bcd6a54a5fae Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 22 May 2026 06:43:08 -0400 Subject: [PATCH 1352/2214] [sendspin] Bump sendspin-cpp to v0.6.1 (#16553) --- 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 36f13f7d07..b670bd3c4d 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 44c63e46cd..6bc166ff44 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.6.0 + version: 0.6.1 lvgl/lvgl: version: 9.5.0 From 9eee8ebb642ef0c48e1af1c141509775fba07f7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 07:49:40 -0500 Subject: [PATCH 1353/2214] [uart] Wake main loop on ESP8266 software serial RX The ESP8266 software serial GPIO ISR decodes a full byte into the RX buffer, then returns. With the 2026.5.0 loop cadence change the byte may sit unread for up to one loop_interval_ tick (~16 ms), which breaks timing-sensitive components (e.g. fingerprint_grow) that poll read() in a tight setup() loop and time out before bytes appear. Call wake_loop_isrsafe() from the ISR after the byte is buffered, mirroring the ESP-IDF UART path (and the same pattern ratgdo uses via SoftwareSerial::onReceive). The wake call is a single inline flag set plus esp_schedule(); IRAM-safe and essentially free. Gated on USE_UART_WAKE_LOOP_ON_RX, now enabled by default for ESP8266 (in addition to ESP32) when networking is configured. --- esphome/components/uart/__init__.py | 9 +++++---- .../components/uart/uart_component_esp8266.cpp | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 7075228743..4ea32e26a3 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -513,10 +513,11 @@ async def uart_write_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.FINAL) async def final_step(): """Final code generation step to configure optional UART features.""" - if CORE.is_esp32 and CORE.has_networking: - # Wake-on-RX is essentially free on ESP32 (just an ISR function pointer - # registration) — enable by default to reduce RX buffer overflow risk - # by waking the main loop immediately when data arrives. + if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking: + # Wake-on-RX is essentially free (just an ISR function pointer + # registration on ESP32, an inline flag set on ESP8266 software + # serial) — enable by default to reduce RX buffer overflow risk by + # waking the main loop immediately when data arrives. cg.add_define("USE_UART_WAKE_LOOP_ON_RX") diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0ea7930760..fc1509f737 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -4,6 +4,9 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include "esphome/core/wake.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -149,7 +152,11 @@ void ESP8266UartComponent::dump_config() { if (this->hw_serial_ != nullptr) { ESP_LOGCONFIG(TAG, " Using hardware serial interface."); } else { - ESP_LOGCONFIG(TAG, " Using software serial"); + ESP_LOGCONFIG(TAG, " Using software serial" +#ifdef USE_UART_WAKE_LOOP_ON_RX + "\n Wake on data RX: ENABLED" +#endif + ); } this->check_logger_conflict(); } @@ -266,6 +273,12 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Wake the main loop so the consuming component drains the byte promptly + // instead of waiting for the next loop_interval_ tick. Important for timing + // sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow). + wake_loop_isrsafe(); +#endif } void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { From 6cb131ce54b4150efb96790d339e5cf2460f84c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 07:51:49 -0500 Subject: [PATCH 1354/2214] [esp8266] Use os_timer-based esp_delay() in delay() Replace the poll-based busy-wait introduced in #15662 with a call to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to properly suspend the cont task for the requested duration. The busy-wait only yielded via optimistic_yield(1000) once 1 ms had elapsed since the last task switch, so short waits like delay(1) often finished without ever yielding. That starved the SDK and extended interrupt latency, breaking timing sensitive interrupt-driven code such as the ESP8266 software-serial RX path used by fingerprint_grow. esp_delay()'s 1-arg form does not itself call millis(), so the slow Arduino millis() body is not pulled into IRAM by this path; the --wrap=millis goal of #15662 is preserved. Fixes #16558 --- esphome/components/esp8266/hal.cpp | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index e8f472dc8a..3501c51859 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -5,6 +5,7 @@ #include #include +#include extern "C" { #include @@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() { return result; } -// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object -// call to the original millis() that --wrap can't intercept, so calling ::delay() -// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still -// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and -// WiFi run correctly. Theoretically less power-efficient than Arduino's -// os_timer-based delay() for long waits, but nearly all ESPHome delays are short -// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is -// negligible. +// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to +// suspend the cont task for `ms` milliseconds without polling millis(). This +// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay()) +// and lets the SDK run freely while we wait, which timing-sensitive +// interrupt-driven code (e.g. ESP8266 software-serial RX in components like +// fingerprint_grow) depends on. The poll-based busy-wait that this replaced +// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and +// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does +// not call millis(), so the slow Arduino millis() body is not pulled into IRAM +// by this path (the --wrap=millis goal of #15662 is preserved). void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); return; } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } + esp_delay(ms); } void arch_restart() { From ac530c33b0c41fbbb923e0945ba6067ec30a5ca4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:08:14 -0500 Subject: [PATCH 1355/2214] Bump actions/stale from 10.2.0 to 10.3.0 (#16544) Signed-off-by: dependabot[bot] --- .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 2e57093bbb..7003f6c482 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Stale - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch remove-stale-when-updated: true From 99de741f99eff9690ba1e4e5b827c2afaf304194 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:08:29 -0500 Subject: [PATCH 1356/2214] Bump docker/build-push-action from 7.1.0 to 7.2.0 in /.github/actions/build-image (#16545) Signed-off-by: dependabot[bot] --- .github/actions/build-image/action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 52d72544d3..2081264b91 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -47,7 +47,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -73,7 +73,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false From 680c9fc9c0bc651bdf1dbd7890310e96742964e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:49:03 -0500 Subject: [PATCH 1357/2214] [dashboard] Fix flaky test_websocket_refresh_command on Windows CI (#16565) --- tests/dashboard/test_web_server.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 626aea0216..0ee841e68c 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1503,13 +1503,18 @@ async def test_websocket_refresh_command( ) -> None: """Test WebSocket refresh command triggers dashboard update.""" with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: - mock_subscriber.request_refresh = Mock() + # Signal an asyncio.Event when request_refresh is invoked so the + # test can deterministically wait for the server-side handler to run + # instead of relying on a fixed sleep (flaky on Windows CI under load). + called = asyncio.Event() + mock_subscriber.request_refresh = Mock(side_effect=called.set) # Send refresh command await websocket_client.write_message(json.dumps({"event": "refresh"})) - # Give it a moment to process - await asyncio.sleep(0.01) + # Wait for the server to process the message and invoke request_refresh + async with asyncio.timeout(5): + await called.wait() # Verify request_refresh was called mock_subscriber.request_refresh.assert_called_once() From 94b10981e1c78e1a82690edd4ac05a37fa318981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 22 May 2026 22:09:32 +0300 Subject: [PATCH 1358/2214] [libretiny] Fix LN882H IRAM_ATTR injection point in patch_linker.py (#16570) --- esphome/components/libretiny/patch_linker.py.script | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 282a31d3f2..3a8a4787ed 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -13,7 +13,9 @@ import subprocess # - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. # - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. # - LN882H: stock linker has no glob for ".sram.text", so we inject -# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) +# immediately after KEEP(*(.vectors)), so the vector table stays at +# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment. # # All families also get a post-link summary showing where IRAM_ATTR landed. @@ -27,7 +29,11 @@ _KEEP_LINE = ( "__esphome_sram_text_end = .; " + _MARKER + "\n" ) -_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") +# Inject after KEEP(*(.vectors)) so the vector table stays at +# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte- +# aligned address; injecting before the vectors would push them to an +# unaligned offset and mis-route every IRQ handler. +_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") def _detect(env): @@ -56,7 +62,7 @@ KNOWN_VARIANTS = frozenset({ def _inject_keep(host_section): - """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + """Return a patcher that injects _KEEP_LINE after `host_section` match.""" def patch(content): if _MARKER in content: return content From 64e32ebe046d8e145ddf2fc7f32f9b6d6844cc0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:30:28 -0500 Subject: [PATCH 1359/2214] [esp8266] Use os_timer-based esp_delay() in delay() (#16563) --- esphome/components/esp8266/hal.cpp | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index e8f472dc8a..3501c51859 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -5,6 +5,7 @@ #include #include +#include extern "C" { #include @@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() { return result; } -// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object -// call to the original millis() that --wrap can't intercept, so calling ::delay() -// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still -// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and -// WiFi run correctly. Theoretically less power-efficient than Arduino's -// os_timer-based delay() for long waits, but nearly all ESPHome delays are short -// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is -// negligible. +// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to +// suspend the cont task for `ms` milliseconds without polling millis(). This +// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay()) +// and lets the SDK run freely while we wait, which timing-sensitive +// interrupt-driven code (e.g. ESP8266 software-serial RX in components like +// fingerprint_grow) depends on. The poll-based busy-wait that this replaced +// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and +// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does +// not call millis(), so the slow Arduino millis() body is not pulled into IRAM +// by this path (the --wrap=millis goal of #15662 is preserved). void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); return; } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } + esp_delay(ms); } void arch_restart() { From 7182b1a8ae5a727c76f7ad5aebfb4dfa69342e4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:30:43 -0500 Subject: [PATCH 1360/2214] [uart] Wake main loop on ESP8266 software serial RX (#16562) --- esphome/components/uart/__init__.py | 9 +++++---- .../components/uart/uart_component_esp8266.cpp | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 7075228743..4ea32e26a3 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -513,10 +513,11 @@ async def uart_write_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.FINAL) async def final_step(): """Final code generation step to configure optional UART features.""" - if CORE.is_esp32 and CORE.has_networking: - # Wake-on-RX is essentially free on ESP32 (just an ISR function pointer - # registration) — enable by default to reduce RX buffer overflow risk - # by waking the main loop immediately when data arrives. + if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking: + # Wake-on-RX is essentially free (just an ISR function pointer + # registration on ESP32, an inline flag set on ESP8266 software + # serial) — enable by default to reduce RX buffer overflow risk by + # waking the main loop immediately when data arrives. cg.add_define("USE_UART_WAKE_LOOP_ON_RX") diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0ea7930760..fc1509f737 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -4,6 +4,9 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include "esphome/core/wake.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -149,7 +152,11 @@ void ESP8266UartComponent::dump_config() { if (this->hw_serial_ != nullptr) { ESP_LOGCONFIG(TAG, " Using hardware serial interface."); } else { - ESP_LOGCONFIG(TAG, " Using software serial"); + ESP_LOGCONFIG(TAG, " Using software serial" +#ifdef USE_UART_WAKE_LOOP_ON_RX + "\n Wake on data RX: ENABLED" +#endif + ); } this->check_logger_conflict(); } @@ -266,6 +273,12 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Wake the main loop so the consuming component drains the byte promptly + // instead of waiting for the next loop_interval_ tick. Important for timing + // sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow). + wake_loop_isrsafe(); +#endif } void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { From c3bef24389d9cf57598fee58efb58d550730c973 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 22 May 2026 15:43:50 -0400 Subject: [PATCH 1361/2214] [i2s_audio] Reset dout GPIO when stopping speaker driver (#16573) --- .../components/i2s_audio/speaker/i2s_audio_speaker.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 680ca069c0..691f68e912 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -2,6 +2,7 @@ #ifdef USE_ESP32 +#include #include #include "esphome/components/audio/audio.h" @@ -299,6 +300,15 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() { i2s_channel_disable(this->tx_handle_); i2s_del_channel(this->tx_handle_); this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); } this->parent_->unlock(); } From 4a78c8d45a4a4cc2152e96d994528cdbdbb0beb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 15:55:49 -0500 Subject: [PATCH 1362/2214] Bump pytest-codspeed from 5.0.2 to 5.0.3 (#16575) 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 dbdea1d935..102a9cae6e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ hypothesis==6.92.1 # CodSpeed benchmarks under tests/benchmarks/python/ # (skipped via pytest.importorskip when missing -- only required for the # benchmarks job in .github/workflows/ci.yml) -pytest-codspeed==5.0.2 +pytest-codspeed==5.0.3 # Used by the import-time regression check (.github/workflows/ci.yml → import-time job) importtime-waterfall==1.0.0 From f85fdb475ad46673d3de9ee01a8a0da3b93183ca Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 23 May 2026 07:37:51 +0930 Subject: [PATCH 1363/2214] [homeassistant] Reduce log spam for sensors (#16555) --- .../components/homeassistant/sensor/homeassistant_sensor.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp index 112795a4ff..b79a56953a 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp @@ -17,9 +17,9 @@ void HomeassistantSensor::setup() { } if (this->attribute_ != nullptr) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); + ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); } else { - ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val); + ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val); } this->publish_state(*val); }); From a58b4edb6ac12204274d021652f6bbffd82a37a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:39:06 -0500 Subject: [PATCH 1364/2214] [ci] Gate unconditional CI jobs on a single determine-jobs output instead of a path filter (#16580) --- .github/workflows/ci.yml | 17 +++--- script/determine-jobs.py | 80 ++++++++++++++++++++++++++++ tests/script/test_determine_jobs.py | 82 +++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43b03aec85..53516db913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,6 @@ on: branches: [dev, beta, release] pull_request: - paths: - - "**" - - "!.github/workflows/*.yml" - - "!.github/actions/build-image/*" - - ".github/workflows/ci.yml" - - "!.yamllint" - - "!.github/dependabot.yml" - - "!docker/**" merge_group: permissions: @@ -101,6 +93,8 @@ jobs: runs-on: ubuntu-24.04 needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -223,6 +217,8 @@ jobs: runs-on: ${{ matrix.os }} needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -261,6 +257,7 @@ jobs: needs: - common outputs: + core-ci: ${{ steps.determine.outputs.core-ci }} integration-tests: ${{ steps.determine.outputs.integration-tests }} integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }} @@ -314,6 +311,7 @@ jobs: echo "$output" | jq # Extract individual fields + echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT @@ -969,7 +967,8 @@ jobs: runs-on: ubuntu-latest needs: - common - if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') + - determine-jobs + if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 3259fb5836..ef2175eb79 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -5,6 +5,7 @@ This script is a centralized way to determine which CI jobs need to run based on what files have changed. It outputs JSON with the following structure: { + "core_ci": true/false, "integration_tests": true/false, "integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...], "clang_tidy": true/false, @@ -22,6 +23,11 @@ what files have changed. It outputs JSON with the following structure: } The CI workflow uses this information to: +- Gate the unconditional jobs (ci-custom, pytest, pre-commit-ci-lite) via core_ci; + false when a pull_request only touches CI-irrelevant meta paths (other workflow + files, .github/actions/build-image/*, .yamllint, .github/dependabot.yml, docker/**) + so workflow-only PRs satisfy the required CI Status check without running the + unconditional jobs. Always true on non-pull_request events and under --force-all. - Skip or run integration tests - Skip or run clang-tidy (and whether to do a full scan) - Skip or run clang-format @@ -712,6 +718,69 @@ def should_run_benchmarks(branch: str | None = None) -> bool: return any(get_component_from_path(f) in benchmarked_components for f in files) +# Files / path patterns whose changes alone don't warrant running the +# unconditional CI jobs (`ci-custom`, `pytest`, `pre-commit-ci-lite`). +# Single source of truth for what we treat as "CI-irrelevant" on +# pull_request events; ci.yml used to encode this in its own +# `pull_request.paths` filter, but that hid the required `CI Status` +# check on PRs that only touched these files (dependabot Action bumps, +# dependabot.yml edits, docker/ changes, etc.) and forced admin +# force-merges. +# +# ci.yml itself is deliberately *not* ignored — editing the CI workflow +# must still run CI. Workflows that have their own dedicated triggers +# (codeql.yml, ci-docker.yml, ...) are matched via the +# `.github/workflows/*.yml` prefix below and exclude ci.yml explicitly. +CI_IRRELEVANT_EXACT_FILES = frozenset( + { + ".yamllint", + ".github/dependabot.yml", + } +) + + +def _is_ci_irrelevant_path(path: str) -> bool: + """Whether a single changed path is irrelevant to the unconditional CI jobs.""" + if path in CI_IRRELEVANT_EXACT_FILES: + return True + # docker/** — all descendants + if path.startswith("docker/"): + return True + # .github/workflows/*.yml — top-level workflow files other than ci.yml + # (ci.yml itself must still trigger full CI when edited). + if path.startswith(".github/workflows/") and path.endswith(".yml"): + if path == ".github/workflows/ci.yml": + return False + if "/" not in path[len(".github/workflows/") :]: + return True + # .github/actions/build-image/* — direct children only, matches the + # single-star glob the workflow used to encode. + if path.startswith(".github/actions/build-image/"): + rest = path[len(".github/actions/build-image/") :] + if rest and "/" not in rest: + return True + return False + + +def should_run_core_ci(branch: str | None = None) -> bool: + """Determine if the unconditional CI jobs (ci-custom/pytest/pre-commit-ci-lite) should run. + + Returns False only when every changed file is in the CI-irrelevant set + above (see ``_is_ci_irrelevant_path``). Empty diffs return True so we + never accidentally skip CI when the diff probe fails. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if the unconditional CI jobs should run, False otherwise. + """ + files = changed_files(branch) + if not files: + return True + return any(not _is_ci_irrelevant_path(f) for f in files) + + def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool: """Check if a changed file ends with any of the specified extensions.""" return any(file.endswith(extensions) for file in changed_files(branch)) @@ -1075,6 +1144,16 @@ def main() -> None: args = parser.parse_args() # Determine what should run + # core_ci gates the unconditional jobs in ci.yml (ci-custom, pytest, + # pre-commit-ci-lite). Non-pull_request events (push to dev/beta/release + # and merge_group) always run them so behavior like venv-cache saves on + # push to dev is preserved. + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + run_core_ci = ( + True + if args.force_all or event_name != "pull_request" + else should_run_core_ci(args.branch) + ) if args.force_all: integration_run_all, integration_test_files = True, [] run_clang_tidy = True @@ -1255,6 +1334,7 @@ def main() -> None: component_test_batches = [] output: dict[str, Any] = { + "core_ci": run_core_ci, "integration_tests": run_integration, "integration_test_buckets": integration_test_buckets, "clang_tidy": run_clang_tidy, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 202ae9030f..7bb9fe2543 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -775,6 +775,88 @@ def test_should_run_import_time_with_branch() -> None: mock_changed.assert_called_once_with("release") +@pytest.mark.parametrize( + ("path", "expected_result"), + [ + # Exact-file matches in the CI-irrelevant set. + (".yamllint", True), + (".github/dependabot.yml", True), + # Other top-level workflow files are irrelevant; ci.yml itself is not. + (".github/workflows/codeql.yml", True), + (".github/workflows/release.yml", True), + (".github/workflows/ci.yml", False), + # Nested files under workflows/ are not matched by the single-star glob. + (".github/workflows/matchers/gcc.json", False), + # build-image action: direct children only (single-star glob). + (".github/actions/build-image/action.yml", True), + (".github/actions/build-image/nested/file.yml", False), + # Other actions are CI-relevant. + (".github/actions/restore-python/action.yml", False), + # docker/** covers everything under docker/. + ("docker/Dockerfile", True), + ("docker/scripts/run.sh", True), + # Regular source files are CI-relevant. + ("esphome/__main__.py", False), + ("esphome/components/wifi/wifi_component.cpp", False), + ("README.md", False), + ("tests/script/test_determine_jobs.py", False), + ], +) +def test_is_ci_irrelevant_path(path: str, expected_result: bool) -> None: + """Test _is_ci_irrelevant_path mirrors the historic ci.yml path filter.""" + assert determine_jobs._is_ci_irrelevant_path(path) == expected_result + + +@pytest.mark.parametrize( + ("changed_files", "expected_result"), + [ + # Empty diffs default to True — don't accidentally skip CI on a + # broken probe. + ([], True), + # Any CI-relevant file flips the result to True. + (["esphome/__main__.py"], True), + (["esphome/components/wifi/wifi_component.cpp"], True), + (["README.md"], True), + # All-irrelevant diffs return False. + ([".github/workflows/codeql.yml"], False), + ( + [".github/workflows/codeql.yml", ".github/workflows/release.yml"], + False, + ), + ([".yamllint"], False), + ([".github/dependabot.yml"], False), + (["docker/Dockerfile"], False), + ( + [ + ".github/workflows/codeql.yml", + ".github/dependabot.yml", + "docker/Dockerfile", + ], + False, + ), + # Mixed diffs always trigger CI. + ( + [".github/workflows/codeql.yml", "esphome/__main__.py"], + True, + ), + # ci.yml itself is treated as CI-relevant. + ([".github/workflows/ci.yml"], True), + ], +) +def test_should_run_core_ci(changed_files: list[str], expected_result: bool) -> None: + """Test should_run_core_ci function.""" + with patch.object(determine_jobs, "changed_files", return_value=changed_files): + assert determine_jobs.should_run_core_ci() == expected_result + + +def test_should_run_core_ci_with_branch() -> None: + """Test should_run_core_ci passes the branch through to changed_files.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + determine_jobs.should_run_core_ci("release") + mock_changed.assert_called_once_with("release") + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ From 71550bb3bee1980f7edaa71e68445aa05a007984 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:39:25 -0500 Subject: [PATCH 1365/2214] [lvgl] Memoize and lazily build container_schema (#16567) --- esphome/components/lvgl/schemas.py | 42 ++++++--- .../lvgl/test_container_schema_cache.py | 87 +++++++++++++++++++ 2 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/lvgl/test_container_schema_cache.py diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 553e0f7398..58ef88d6a8 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from typing import Any from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation @@ -534,7 +535,16 @@ def strip_defaults(schema: cv.Schema): return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()}) -def container_schema(widget_type: WidgetType, extras=None): +# Keyed by (id(widget_type), id(extras)); strong refs in the value keep both +# alive so id() can't be recycled. +_CONTAINER_SCHEMA_CACHE: dict[ + tuple[int, int], tuple[Any, Any, Callable[[Any], Any]] +] = {} + + +def container_schema( + widget_type: WidgetType, extras: Any = None +) -> Callable[[Any], Any]: """ Create a schema for a container widget of a given type. All obj properties are available, plus the extras passed in, plus any defined for the specific widget being specified. @@ -542,19 +552,31 @@ def container_schema(widget_type: WidgetType, extras=None): :param extras: Additional options to be made available, e.g. layout properties for children :return: The schema for this type of widget. """ - schema = obj_schema(widget_type).extend( - {cv.GenerateID(): cv.declare_id(widget_type.w_type)} - ) - if extras: - schema = schema.extend(extras) - # Delayed evaluation for recursion + cache_key = (id(widget_type), id(extras)) + cached = _CONTAINER_SCHEMA_CACHE.get(cache_key) + if cached is not None: + cached_widget_type, cached_extras, cached_validator = cached + if cached_widget_type is widget_type and cached_extras is extras: + return cached_validator - schema = schema.extend(widget_type.schema) + cached_schema: cv.Schema | None = None - def validator(value): + def get_schema() -> cv.Schema: + nonlocal cached_schema + if cached_schema is None: + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + cached_schema = schema.extend(widget_type.schema) + return cached_schema + + def validator(value: Any) -> Any: value = value or {} - return append_layout_schema(schema, value)(value) + return append_layout_schema(get_schema(), value)(value) + _CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator) return validator diff --git a/tests/component_tests/lvgl/test_container_schema_cache.py b/tests/component_tests/lvgl/test_container_schema_cache.py new file mode 100644 index 0000000000..39e623d720 --- /dev/null +++ b/tests/component_tests/lvgl/test_container_schema_cache.py @@ -0,0 +1,87 @@ +"""Tests for container_schema() memoization and lazy build.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from esphome import config_validation as cv +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl import schemas as lvgl_schemas +from esphome.components.lvgl.schemas import WIDGET_TYPES, container_schema + + +@pytest.fixture(autouse=True) +def _clear_container_schema_cache() -> Generator[None]: + cache = getattr(lvgl_schemas, "_CONTAINER_SCHEMA_CACHE", None) + if cache is not None: + cache.clear() + yield + if cache is not None: + cache.clear() + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_same_args_return_same_validator() -> None: + wt = _widget_type("obj") + assert container_schema(wt) is container_schema(wt) + + +def test_extras_none_vs_truthy_get_different_validators() -> None: + wt = _widget_type("obj") + no_extras = container_schema(wt) + extras = {cv.Optional("custom_extra"): cv.string} + assert no_extras is not container_schema(wt, extras) + + +def test_different_widget_types_get_different_validators() -> None: + assert container_schema(_widget_type("obj")) is not container_schema( + _widget_type("label") + ) + + +def test_schema_build_is_deferred_until_first_validation() -> None: + wt = _widget_type("obj") + with patch.object( + lvgl_schemas, "obj_schema", wraps=lvgl_schemas.obj_schema + ) as obj_schema_mock: + validator = container_schema(wt) + assert obj_schema_mock.call_count == 0 + validator({}) + assert obj_schema_mock.call_count == 1 + validator({}) + assert obj_schema_mock.call_count == 1 + + +def test_cached_validator_produces_equivalent_output() -> None: + wt = _widget_type("obj") + cached = container_schema(wt) + cached_result = cached({}) + lvgl_schemas._CONTAINER_SCHEMA_CACHE.clear() + reference = container_schema(wt) + assert cached is not reference + assert cached_result == reference({}) + + +def test_id_recycling_is_caught_by_identity_guard() -> None: + wt = _widget_type("obj") + real_extras = {cv.Optional("a"): cv.int_} + validator_a = container_schema(wt, real_extras) + + cache_key = (id(wt), id(real_extras)) + cached_entry = lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] + sentinel = {cv.Optional("a"): cv.int_} + lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] = ( + cached_entry[0], + sentinel, + cached_entry[2], + ) + + assert container_schema(wt, real_extras) is not validator_a From 55f4e5cb7553c4a77e65e0d987c2fe29817dee67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 19:18:20 -0500 Subject: [PATCH 1366/2214] Bump the docker-actions group across 1 directory with 2 updates (#16578) Signed-off-by: dependabot[bot] --- .github/workflows/ci-docker.yml | 2 +- .github/workflows/release.yml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 3fd17888c7..89fbec5420 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -48,7 +48,7 @@ jobs: with: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Set TAG run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9799f882db..344bd416c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,15 +99,15 @@ jobs: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Log in to docker hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -178,17 +178,17 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} From 9930b3c216cefdd69599a53da337d42652ffbdce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 19:18:31 -0500 Subject: [PATCH 1367/2214] Bump github/codeql-action from 4.35.5 to 4.36.0 (#16579) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fbef0f5157..dfc0e08bfa 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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: category: "/language:${{matrix.language}}" From 2b422cbd991d26125986a3f5caa40d2edda60198 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 19:20:39 -0500 Subject: [PATCH 1368/2214] [lvgl] Build widget update action schemas lazily (#16569) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/lvgl/widgets/__init__.py | 36 +++++++++++-- .../lvgl/test_update_action_lazy.py | 53 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/test_update_action_lazy.py diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index ab1c61ff88..400f7c709b 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import sys +from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action @@ -15,6 +17,7 @@ from esphome.const import ( from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj +from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression from ..defines import ( @@ -73,6 +76,34 @@ from ..types import ( EVENT_LAMB = "event_lamb__" +def _build_update_schema(widget_type: "WidgetType") -> Schema: + # Local import: ..schemas imports WidgetType from this module. + from ..schemas import base_update_schema + + return base_update_schema(widget_type, widget_type.parts).extend( + widget_type.modify_schema + ) + + +def _update_action_schema( + widget_type: "WidgetType", +) -> Schema | Callable[[Any], Any]: + # Eager when extracting so build_language_schema.py sees the mapping; + # lazy otherwise to skip ~200 ms of import-time voluptuous work. + if EnableSchemaExtraction: + return _build_update_schema(widget_type) + + cached: Schema | None = None + + def validator(value: Any) -> Any: + nonlocal cached + if cached is None: + cached = _build_update_schema(widget_type) + return cached(value) + + return validator + + class WidgetType: """ Describes a type of Widget, e.g. "bar" or "line" @@ -113,18 +144,17 @@ class WidgetType: # Local import to avoid circular import from ..automation import update_to_code - from ..schemas import WIDGET_TYPES, base_update_schema + from ..schemas import WIDGET_TYPES if not is_mock: if self.name in WIDGET_TYPES: raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") WIDGET_TYPES[self.name] = self - # Register the update action automatically, adding widget-specific properties register_action( f"lvgl.{self.name}.update", ObjUpdateAction, - base_update_schema(self, self.parts).extend(self.modify_schema), + _update_action_schema(self), synchronous=True, )(update_to_code) diff --git a/tests/component_tests/lvgl/test_update_action_lazy.py b/tests/component_tests/lvgl/test_update_action_lazy.py new file mode 100644 index 0000000000..7fcdc149cf --- /dev/null +++ b/tests/component_tests/lvgl/test_update_action_lazy.py @@ -0,0 +1,53 @@ +"""Tests for lvgl..update lazy schema build.""" + +from __future__ import annotations + +from unittest.mock import patch + +from esphome.automation import ACTION_REGISTRY +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl.schemas import WIDGET_TYPES +from esphome.components.lvgl.widgets import _update_action_schema +from esphome.config_validation import Schema + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_registry_entry_uses_lazy_validator() -> None: + entry = ACTION_REGISTRY["lvgl.label.update"] + assert callable(entry.raw_schema) + assert not isinstance(entry.raw_schema, Schema) + + +def test_lazy_validator_defers_build_until_first_call() -> None: + wt = _widget_type("label") + with patch( + "esphome.components.lvgl.widgets._build_update_schema", + wraps=lambda w: Schema({}), + ) as build_mock: + validator = _update_action_schema(wt) + assert build_mock.call_count == 0 + validator({}) + assert build_mock.call_count == 1 + validator({}) + assert build_mock.call_count == 1 + + +def test_eager_build_when_schema_extraction_enabled() -> None: + wt = _widget_type("label") + with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True): + result = _update_action_schema(wt) + assert isinstance(result, Schema) + + +def test_lazy_and_eager_produce_equivalent_validation() -> None: + wt = _widget_type("label") + with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True): + eager = _update_action_schema(wt) + lazy = _update_action_schema(wt) + sample = {"id": "label_id"} + assert lazy(sample) == eager(sample) From b0dc688c148b5bc1c5c22459c5271b60952dfd6b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 22 May 2026 20:30:25 -0400 Subject: [PATCH 1369/2214] [esp32] Demote IDF #warning deprecations from error under ESP-IDF toolchain (#16584) --- 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 274cc6fdb3..24312d64ad 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1821,6 +1821,7 @@ async def to_code(config): cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") + cg.add_build_flag("-Wno-error=cpp") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From 7d2698dd0a6ce1d8d624c2ed2c232a2f8938a932 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 22:16:08 -0500 Subject: [PATCH 1370/2214] [esp32_ble_client] Re-enable loop in set_disconnecting_ so safety timeout fires --- esphome/components/esp32_ble_client/ble_client_base.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 4e0b22cc29..8a491a898e 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -149,6 +149,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_disconnecting_() { this->disconnecting_started_ = millis(); this->set_state(espbt::ClientState::DISCONNECTING); + // BluetoothConnection::loop() disables the component loop after service discovery + // completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT + // gets lost. Re-enable the loop so the 10s safety timeout can force IDLE. + this->enable_loop(); } // Compact error logging helpers to reduce flash usage void log_error_(const char *message); From 72751210fb6a0365e3e1b7cbfdeca4fdc02a43d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 22:22:05 -0500 Subject: [PATCH 1371/2214] [bluetooth_proxy] Don't disable loop while DISCONNECTING so safety timeout can fire --- .../components/bluetooth_proxy/bluetooth_connection.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 21573f0184..d78506e067 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -135,8 +135,11 @@ void BluetoothConnection::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) - if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { + // 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(); } } From 6bb912cdd542efefc1f993035a74cf31019e1190 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 22:26:58 -0500 Subject: [PATCH 1372/2214] [bluetooth_proxy] Release proxy slot when DISCONNECTING safety timeout fires --- .../bluetooth_proxy/bluetooth_connection.cpp | 10 ++++++++++ .../components/bluetooth_proxy/bluetooth_connection.h | 2 ++ .../components/esp32_ble_client/ble_client_base.cpp | 3 +++ esphome/components/esp32_ble_client/ble_client_base.h | 4 ++++ 4 files changed, 19 insertions(+) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index d78506e067..5783382e55 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -144,6 +144,16 @@ void BluetoothConnection::loop() { } } +void BluetoothConnection::on_disconnect_timeout_() { + // The base class forced IDLE because CLOSE_EVT never arrived. Run the same slot + // cleanup the CLOSE_EVT path would have done so the proxy slot is freed, the API + // client is notified, and send_service_ is reset for the next connection. + if (this->address_ == 0) { + return; + } + this->reset_connection_(ESP_GATT_CONN_TIMEOUT); +} + void BluetoothConnection::reset_connection_(esp_err_t reason) { // Send disconnection notification this->proxy_->send_device_connection(this->address_, false, 0, reason); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index b50ea2d6a2..c841ff0a24 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { protected: friend class BluetoothProxy; + void on_disconnect_timeout_() override; + bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 7f0f2c624d..434f989be6 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -72,6 +72,9 @@ void BLEClientBase::loop() { // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. this->release_services(); this->set_idle_(); + // Notify subclasses so they can release any extra per-connection state (e.g. bluetooth_proxy + // slot accounting) that would normally be cleared by the CLOSE_EVT handler. + this->on_disconnect_timeout_(); } } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 8a491a898e..5b17ed9c6b 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -140,6 +140,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); + /// Hook called from the DISCONNECTING safety timeout after the base class forces IDLE. + /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) + /// override this to perform the same cleanup the normal CLOSE_EVT path would do. + virtual void on_disconnect_timeout_() {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { this->set_state(espbt::ClientState::IDLE); From 8c30bef34eb8b02e0beb17da02fdb7b42956b1eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 22:26:58 -0500 Subject: [PATCH 1373/2214] [bluetooth_proxy] Release proxy slot when DISCONNECTING safety timeout fires --- .../bluetooth_proxy/bluetooth_connection.cpp | 10 ++++++++++ .../components/bluetooth_proxy/bluetooth_connection.h | 2 ++ .../components/esp32_ble_client/ble_client_base.cpp | 3 +++ esphome/components/esp32_ble_client/ble_client_base.h | 4 ++++ 4 files changed, 19 insertions(+) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index d78506e067..5783382e55 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -144,6 +144,16 @@ void BluetoothConnection::loop() { } } +void BluetoothConnection::on_disconnect_timeout_() { + // The base class forced IDLE because CLOSE_EVT never arrived. Run the same slot + // cleanup the CLOSE_EVT path would have done so the proxy slot is freed, the API + // client is notified, and send_service_ is reset for the next connection. + if (this->address_ == 0) { + return; + } + this->reset_connection_(ESP_GATT_CONN_TIMEOUT); +} + void BluetoothConnection::reset_connection_(esp_err_t reason) { // Send disconnection notification this->proxy_->send_device_connection(this->address_, false, 0, reason); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index b50ea2d6a2..c841ff0a24 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { protected: friend class BluetoothProxy; + void on_disconnect_timeout_() override; + bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 7f0f2c624d..434f989be6 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -72,6 +72,9 @@ void BLEClientBase::loop() { // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. this->release_services(); this->set_idle_(); + // Notify subclasses so they can release any extra per-connection state (e.g. bluetooth_proxy + // slot accounting) that would normally be cleared by the CLOSE_EVT handler. + this->on_disconnect_timeout_(); } } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 8a491a898e..5b17ed9c6b 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -140,6 +140,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); + /// Hook called from the DISCONNECTING safety timeout after the base class forces IDLE. + /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) + /// override this to perform the same cleanup the normal CLOSE_EVT path would do. + virtual void on_disconnect_timeout_() {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { this->set_state(espbt::ClientState::IDLE); From 4badb90d003a8fc7c094e33f746e4cbdf08b4117 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 22:34:46 -0500 Subject: [PATCH 1374/2214] [esp32_ble_client] Route CLOSE_EVT and timeout cleanup through a single hook --- .../bluetooth_proxy/bluetooth_connection.cpp | 19 ++++++------------- .../bluetooth_proxy/bluetooth_connection.h | 2 +- .../esp32_ble_client/ble_client_base.cpp | 5 ++--- .../esp32_ble_client/ble_client_base.h | 8 +++++--- 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 5783382e55..a42fb3544c 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -144,14 +144,15 @@ void BluetoothConnection::loop() { } } -void BluetoothConnection::on_disconnect_timeout_() { - // The base class forced IDLE because CLOSE_EVT never arrived. Run the same slot - // cleanup the CLOSE_EVT path would have done so the proxy slot is freed, the API - // client is notified, and send_service_ is reset for the next connection. +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; } - this->reset_connection_(ESP_GATT_CONN_TIMEOUT); + 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) { @@ -385,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } - case ESP_GATTC_CLOSE_EVT: { - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, - param->close.reason); - // Now the GATT connection is fully closed and controller resources are freed - // Safe to mark the connection slot as available - this->reset_connection_(param->close.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); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index c841ff0a24..54e7c83fb3 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -33,7 +33,7 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { protected: friend class BluetoothProxy; - void on_disconnect_timeout_() override; + void on_disconnect_complete_(esp_err_t reason) override; bool supports_efficient_uuids_() const; void send_service_for_discovery_(); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 434f989be6..0c79eecd3a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -72,9 +72,7 @@ void BLEClientBase::loop() { // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. this->release_services(); this->set_idle_(); - // Notify subclasses so they can release any extra per-connection state (e.g. bluetooth_proxy - // slot accounting) that would normally be cleared by the CLOSE_EVT handler. - this->on_disconnect_timeout_(); + this->on_disconnect_complete_(ESP_GATT_CONN_TIMEOUT); } } @@ -421,6 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_lifecycle_event_("CLOSE"); this->release_services(); this->set_idle_(); + this->on_disconnect_complete_(param->close.reason); break; } case ESP_GATTC_SEARCH_RES_EVT: { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 5b17ed9c6b..f7e87e667c 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -140,10 +140,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); - /// Hook called from the DISCONNECTING safety timeout after the base class forces IDLE. + /// Hook called once a connection has been fully torn down (after release_services() and + /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) - /// override this to perform the same cleanup the normal CLOSE_EVT path would do. - virtual void on_disconnect_timeout_() {} + /// override this to release that state. `reason` is the controller reason code, or + /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + virtual void on_disconnect_complete_(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { this->set_state(espbt::ClientState::IDLE); From be99553fd4aad7afdbb5d9a49037eb30656008d0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 23 May 2026 13:56:53 +0930 Subject: [PATCH 1375/2214] [ci] Fix flash memory overflow on tests (#16587) --- tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml | 2 ++ .../build_components_base.esp32-c6-idf.yaml | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml b/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml index 6c27bd35d0..df5b0123b5 100644 --- a/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml +++ b/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml @@ -1,6 +1,8 @@ <<: !include common.yaml esp32_ble_tracker: + +esp32_ble: max_connections: 9 bluetooth_proxy: diff --git a/tests/test_build_components/build_components_base.esp32-c6-idf.yaml b/tests/test_build_components/build_components_base.esp32-c6-idf.yaml index 9dbc465ca2..4105481dc5 100644 --- a/tests/test_build_components/build_components_base.esp32-c6-idf.yaml +++ b/tests/test_build_components/build_components_base.esp32-c6-idf.yaml @@ -4,6 +4,7 @@ esphome: esp32: board: esp32-c6-devkitc-1 + flash_size: 8MB framework: type: esp-idf From 188ff7ebfd70bc1f3fb417af5dbc41a486bcf99b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 May 2026 14:30:12 -0500 Subject: [PATCH 1376/2214] [bluetooth_proxy] Recover slot stuck in DISCONNECTING when CLOSE_EVT is dropped (#16588) --- .../bluetooth_proxy/bluetooth_connection.cpp | 26 ++++++++++++------- .../bluetooth_proxy/bluetooth_connection.h | 2 ++ .../esp32_ble_client/ble_client_base.cpp | 2 ++ .../esp32_ble_client/ble_client_base.h | 10 +++++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 21573f0184..7ba9e61e19 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -135,12 +135,26 @@ void BluetoothConnection::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) - if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { + // 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) { // Send disconnection notification this->proxy_->send_device_connection(this->address_, false, 0, reason); @@ -372,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } - case ESP_GATTC_CLOSE_EVT: { - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, - param->close.reason); - // Now the GATT connection is fully closed and controller resources are freed - // Safe to mark the connection slot as available - this->reset_connection_(param->close.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); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index b50ea2d6a2..e5600f6af4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { protected: friend class BluetoothProxy; + 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); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 7f0f2c624d..3fb9632e9a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -72,6 +72,7 @@ void BLEClientBase::loop() { // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. this->release_services(); this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT); } } @@ -418,6 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_lifecycle_event_("CLOSE"); this->release_services(); this->set_idle_(); + this->on_disconnect_complete(param->close.reason); break; } case ESP_GATTC_SEARCH_RES_EVT: { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 4e0b22cc29..0291a4b993 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -140,6 +140,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); + /// Hook called once a connection has been fully torn down (after release_services() and + /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) + /// override this to release that state. `reason` is the controller reason code, or + /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { this->set_state(espbt::ClientState::IDLE); @@ -149,6 +155,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_disconnecting_() { this->disconnecting_started_ = millis(); this->set_state(espbt::ClientState::DISCONNECTING); + // BluetoothConnection::loop() disables the component loop after service discovery + // completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT + // gets lost. Re-enable the loop so the 10s safety timeout can force IDLE. + this->enable_loop(); } // Compact error logging helpers to reduce flash usage void log_error_(const char *message); From f61610362182d077af8f7e0e80ebca3fc3a77d87 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 23 May 2026 15:44:25 -0400 Subject: [PATCH 1377/2214] [esp32] Replace per-class -Wno-error=X demotes with blanket -Wno-error for ESP-IDF toolchain (#16599) --- esphome/components/esp32/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 24312d64ad..1864c3b544 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1816,12 +1816,9 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - cg.add_build_flag("-Wno-error=format") - cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=overloaded-virtual") - cg.add_build_flag("-Wno-error=reorder") - cg.add_build_flag("-Wno-error=volatile") - cg.add_build_flag("-Wno-error=cpp") + # Undo IDF's blanket -Werror so third-party libraries and user + # lambdas don't need a -Wno-error= entry per warning class. + cg.add_build_flag("-Wno-error") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From 58931f2610f65a5ca6c8c6204ed5253b18ebf378 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sat, 23 May 2026 17:37:59 -0400 Subject: [PATCH 1378/2214] [audio] Add `clear_buffered_data` method to RingBufferAudioSource (#16594) --- .../components/audio/audio_transfer_buffer.cpp | 16 ++++++++++++++++ esphome/components/audio/audio_transfer_buffer.h | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index d9ce8060e2..a611549e58 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -252,6 +252,22 @@ void RingBufferAudioSource::consume(size_t bytes) { } } +void RingBufferAudioSource::clear_buffered_data() { + // Release the held item before reset() so the source no longer references memory the reset will reclaim. + if (this->acquired_item_ != nullptr) { + this->ring_buffer_->receive_release(this->acquired_item_); + this->acquired_item_ = nullptr; + } + this->current_data_ = nullptr; + this->current_available_ = 0; + this->queued_data_ = nullptr; + this->queued_length_ = 0; + this->item_trailing_ptr_ = nullptr; + this->item_trailing_length_ = 0; + this->splice_length_ = 0; + this->ring_buffer_->reset(); +} + bool RingBufferAudioSource::has_buffered_data() const { // splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion // bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports. diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index b713326141..074684f068 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -250,6 +250,10 @@ class RingBufferAudioSource : public AudioReadableBuffer { /// exposure stays in place and fill() returns 0 until it is fully consumed. size_t fill(TickType_t ticks_to_wait, bool pre_shift) override; + /// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight + /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread. + void clear_buffered_data(); + /// @brief Returns a mutable pointer to the currently exposed audio data. /// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame /// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data From 74001ccf05646c1a1c0dfe80df0724db822613e4 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sat, 23 May 2026 17:39:20 -0400 Subject: [PATCH 1379/2214] [wifi] Wake main loop when requesting high performance mode (#16598) --- esphome/components/wifi/wifi_component.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index edfb93bba2..72832d7ac8 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2193,7 +2193,15 @@ bool WiFiComponent::request_high_performance() { } // Give the semaphore (non-blocking). This increments the count. - return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE; + bool success = xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE; + + // Wake the main loop so the switch to high-performance mode is applied on the + // next tick instead of waiting up to loop_interval. + if (success) { + App.wake_loop_threadsafe(); + } + + return success; } bool WiFiComponent::release_high_performance() { From 5cb145a8c3869e568adbb4628d99d10181e0b473 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sat, 23 May 2026 17:53:53 -0400 Subject: [PATCH 1380/2214] [ethernet] Offload W5500 bulk SPI transfers from the busy-wait path (#16596) --- .../ethernet/ethernet_component_esp32.cpp | 5 + .../components/ethernet/w5500_custom_spi.cpp | 118 ++++++++++++++++++ .../components/ethernet/w5500_custom_spi.h | 35 ++++++ 3 files changed, 158 insertions(+) create mode 100644 esphome/components/ethernet/w5500_custom_spi.cpp create mode 100644 esphome/components/ethernet/w5500_custom_spi.h diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index d4585bf100..46e2bb4ec1 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -5,6 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "w5500_custom_spi.h" #include #include @@ -207,6 +208,10 @@ void EthernetComponent::setup() { #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT w5500_config.poll_period_ms = this->polling_interval_; #endif + // Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait + // path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which + // runs the driver's init(). + install_w5500_async_spi(w5500_config); #elif defined(USE_ETHERNET_DM9051) dm9051_config.int_gpio_num = this->interrupt_pin_; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp new file mode 100644 index 0000000000..ed4f149738 --- /dev/null +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -0,0 +1,118 @@ +#include "w5500_custom_spi.h" + +#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500) + +#include +#include +#include +#include +#include + +namespace esphome::ethernet { + +namespace { + +// Per-device context returned by init() and handed back to read/write/deinit. +struct W5500CustomSpiContext { + spi_device_handle_t handle; + SemaphoreHandle_t lock; +}; + +// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger +// transfers (the frame payloads) use the blocking, DMA-backed transmit. +constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64; +constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50; + +void *w5500_custom_spi_init(const void *spi_config) { + const auto *config = static_cast(spi_config); + auto *ctx = new (std::nothrow) W5500CustomSpiContext{}; + if (ctx == nullptr) { + return nullptr; + } + // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control + // byte in the address phase; mirror what the stock driver configures. + spi_device_interface_config_t devcfg = *config->spi_devcfg; + devcfg.command_bits = 16; + devcfg.address_bits = 8; + if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { + delete ctx; + return nullptr; + } + ctx->lock = xSemaphoreCreateMutex(); + if (ctx->lock == nullptr) { + spi_bus_remove_device(ctx->handle); + delete ctx; + return nullptr; + } + return ctx; +} + +esp_err_t w5500_custom_spi_deinit(void *spi_ctx) { + auto *ctx = static_cast(spi_ctx); + spi_bus_remove_device(ctx->handle); + vSemaphoreDelete(ctx->lock); + delete ctx; + return ESP_OK; +} + +// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size. +// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register +// accesses stay on the cheaper polling path. Used by both read and write. +esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) { + if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + esp_err_t ret; + if (len > W5500_SPI_BULK_THRESHOLD) { + ret = spi_device_transmit(ctx->handle, trans); + } else { + ret = spi_device_polling_transmit(ctx->handle, trans); + } + xSemaphoreGive(ctx->lock); + return ret; +} + +esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) { + auto *ctx = static_cast(spi_ctx); + spi_transaction_t trans = {}; + trans.cmd = static_cast(cmd); + trans.addr = addr; + trans.length = 8 * len; + trans.tx_buffer = data; + return w5500_custom_spi_transfer(ctx, &trans, len); +} + +esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) { + auto *ctx = static_cast(spi_ctx); + spi_transaction_t trans = {}; + // Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary + // overwrites of adjacent registers (same guard the stock driver uses). + const bool use_rxdata = len <= 4; + trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0; + trans.cmd = static_cast(cmd); + trans.addr = addr; + trans.length = 8 * len; + trans.rx_buffer = data; + esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len); + if (use_rxdata && (ret == ESP_OK)) { + memcpy(data, trans.rx_data, len); + } + return ret; +} + +} // namespace + +void install_w5500_async_spi(eth_w5500_config_t &config) { + // Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and + // spi_devcfg back out of it. The self-reference is valid because both the config and the + // spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init(). + config.custom_spi_driver.config = &config; + config.custom_spi_driver.init = w5500_custom_spi_init; + config.custom_spi_driver.deinit = w5500_custom_spi_deinit; + config.custom_spi_driver.read = w5500_custom_spi_read; + config.custom_spi_driver.write = w5500_custom_spi_write; +} + +} // namespace esphome::ethernet + +#endif // USE_ESP32 && USE_ETHERNET_W5500 diff --git a/esphome/components/ethernet/w5500_custom_spi.h b/esphome/components/ethernet/w5500_custom_spi.h new file mode 100644 index 0000000000..8756a149af --- /dev/null +++ b/esphome/components/ethernet/w5500_custom_spi.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500) + +#include +// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t +// is no longer reachable through esp_eth.h and needs the explicit header. +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else +#include +#endif + +namespace esphome::ethernet { + +// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path. +// +// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which +// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame, +// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX +// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers +// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps +// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait +// is cheaper than an interrupt round-trip. +// +// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back +// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay +// alive until esp_eth_mac_new_w5500() returns. +void install_w5500_async_spi(eth_w5500_config_t &config); + +} // namespace esphome::ethernet + +#endif // USE_ESP32 && USE_ETHERNET_W5500 From c951881eea1a4066eef6d5ab1d872ccac0362bb4 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 23 May 2026 23:05:18 -0500 Subject: [PATCH 1381/2214] [api] Fix uint32_t/int32_t format strings for stricter GCC toolchain (#16603) --- esphome/components/api/api_server.cpp | 7 ++++--- tests/components/api/common-base.yaml | 28 +++++++++++++-------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index c30bd2e612..031fa342c1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -1,6 +1,7 @@ #include "api_server.h" #ifdef USE_API #include +#include #include "api_connection.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" @@ -677,7 +678,7 @@ uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConn // Schedule automatic cleanup after timeout (client will have given up by then) // Uses numeric ID overload to avoid heap allocation from str_sprintf this->set_timeout(action_call_id, USE_API_ACTION_CALL_TIMEOUT_MS, [this, action_call_id]() { - ESP_LOGD(TAG, "Action call %u timed out", action_call_id); + ESP_LOGD(TAG, "Action call %" PRIu32 " timed out", action_call_id); this->unregister_active_action_call(action_call_id); }); @@ -721,7 +722,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri return; } } - ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id); } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON void APIServer::send_action_response(uint32_t action_call_id, bool success, StringRef error_message, @@ -733,7 +734,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri return; } } - ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id); } #endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #endif // USE_API_USER_DEFINED_ACTION_RESPONSES diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 504c52a57b..ca86445777 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -120,12 +120,12 @@ api: lambda: 'return condition;' then: - logger.log: - format: "Condition true, value: %d" - args: ['value'] + format: "Condition true, value: %ld" + args: ['(long) value'] else: - logger.log: - format: "Condition false, value: %d" - args: ['value'] + format: "Condition false, value: %ld" + args: ['(long) value'] - logger.log: "After if/else" # Test nested IfAction (multiple ContinuationAction instances) - action: test_nested_if @@ -171,8 +171,8 @@ api: count: !lambda 'return count;' then: - logger.log: - format: "Repeat iteration: %d" - args: ['iteration'] + format: "Repeat iteration: %lu" + args: ['(unsigned long) iteration'] - logger.log: "After repeat" # Test combined continuations (if + while + repeat) - action: test_combined_continuations @@ -193,8 +193,8 @@ api: lambda: 'return id(api_continuation_test_counter) > 0;' then: - logger.log: - format: "Combined: repeat=%d, while=%d" - args: ['iteration', 'id(api_continuation_test_counter)'] + format: "Combined: repeat=%lu, while=%d" + args: ['(unsigned long) iteration', 'id(api_continuation_test_counter)'] - lambda: 'id(api_continuation_test_counter)--;' else: - logger.log: "Skipped loops" @@ -208,8 +208,8 @@ api: - api.respond: success: true - logger.log: - format: "Status response sent (call_id=%d)" - args: [call_id] + format: "Status response sent (call_id=%lu)" + args: ['(unsigned long) call_id'] - action: test_respond_status_error variables: @@ -229,8 +229,8 @@ api: value: float then: - logger.log: - format: "Optional response (call_id=%d, return_response=%d)" - args: [call_id, return_response] + format: "Optional response (call_id=%lu, return_response=%lu)" + args: ['(unsigned long) call_id', '(unsigned long) return_response'] - api.respond: data: !lambda |- root["sensor"] = sensor_name; @@ -264,8 +264,8 @@ api: input: string then: - logger.log: - format: "Only response (call_id=%d)" - args: [call_id] + format: "Only response (call_id=%lu)" + args: ['(unsigned long) call_id'] - api.respond: data: !lambda |- root["input"] = input; From 5f860ff5bdabfb574554e617ed5379dbd847fd04 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 24 May 2026 00:19:07 -0400 Subject: [PATCH 1382/2214] [esp32] Disable IDF's COMPILER_DISABLE_DEFAULT_ERRORS so -Wno-error actually undoes -Werror (#16604) --- esphome/components/esp32/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1864c3b544..a06ae89c3e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1816,8 +1816,11 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - # Undo IDF's blanket -Werror so third-party libraries and user - # lambdas don't need a -Wno-error= entry per warning class. + # Demote IDF's blanket -Werror to warnings so third-party libs + # and user lambdas don't need a -Wno-error= per warning. + # The sdkconfig knob disables IDF's rewrite to -Werror=all (which + # can't be globally undone); -Wno-error then handles the demotion. + add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False) cg.add_build_flag("-Wno-error") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From a37f27ee7f2d7ed74f666aa1b53d394b89b8d36f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Sun, 24 May 2026 07:27:31 +0200 Subject: [PATCH 1383/2214] [espnow, ethernet, network, openthread, wifi] centralize network initialization for ESP32 (#14012) Co-authored-by: kbx81 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/espnow/__init__.py | 2 +- .../components/espnow/espnow_component.cpp | 8 ----- .../ethernet/ethernet_component_esp32.cpp | 6 +--- esphome/components/network/__init__.py | 17 +++++++++- .../components/network/network_component.cpp | 33 +++++++++++++++++++ .../components/network/network_component.h | 14 ++++++++ .../components/openthread/openthread_esp.cpp | 3 +- esphome/components/wifi/wifi_component.cpp | 3 -- .../wifi/wifi_component_esp_idf.cpp | 14 ++------ 9 files changed, 69 insertions(+), 31 deletions(-) create mode 100644 esphome/components/network/network_component.cpp create mode 100644 esphome/components/network/network_component.h diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 7861c0affa..13f278d3bc 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -17,7 +17,7 @@ from esphome.core import HexInt from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] - +AUTO_LOAD = ["network"] byte_vector = cg.std_vector.template(cg.uint8) peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 91d44394e8..403e6f4944 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -149,12 +149,6 @@ bool ESPNowComponent::is_wifi_enabled() { } void ESPNowComponent::setup() { -#ifndef USE_WIFI - // Initialize LwIP stack for wake_loop_threadsafe() socket support - // When WiFi component is present, it handles esp_netif_init() - ESP_ERROR_CHECK(esp_netif_init()); -#endif - if (this->enable_on_boot_) { this->enable_(); } else { @@ -174,8 +168,6 @@ void ESPNowComponent::enable() { void ESPNowComponent::enable_() { if (!this->is_wifi_enabled()) { - esp_event_loop_create_default(); - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 46e2bb4ec1..6481c8c1f4 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -164,11 +164,7 @@ void EthernetComponent::setup() { err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); #endif - - err = esp_netif_init(); - ESPHL_ERROR_CHECK(err, "ETH netif init error"); - err = esp_event_loop_create_default(); - ESPHL_ERROR_CHECK(err, "ETH event loop error"); + // Network interface setup handled by network component esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH(); this->eth_netif_ = esp_netif_new(&cfg); diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 811e7c875a..2818b8c93e 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -5,8 +5,9 @@ import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.psram import is_guaranteed as psram_is_guaranteed import esphome.config_validation as cv -from esphome.const import CONF_ENABLE_IPV6, CONF_MIN_IPV6_ADDR_COUNT +from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["mdns"] @@ -19,6 +20,7 @@ KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" network_ns = cg.esphome_ns.namespace("network") +NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) IPAddress = network_ns.class_("IPAddress") @@ -107,6 +109,7 @@ def has_high_performance_networking() -> bool: CONFIG_SCHEMA = cv.Schema( { + cv.GenerateID(): cv.declare_id(NetworkComponent), cv.SplitDefault( CONF_ENABLE_IPV6, bk72xx=False, @@ -224,3 +227,15 @@ async def to_code(config): cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") if CORE.is_rp2040: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") + # Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it + # emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config + # (above) separate from C++ object lifecycle and allows wiring in interface + # pointers via get_variable(). + if CORE.is_esp32: + CORE.add_job(network_component_to_code, config) + + +@coroutine_with_priority(CoroPriority.NETWORK_SERVICES) +async def network_component_to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp new file mode 100644 index 0000000000..40cf64906c --- /dev/null +++ b/esphome/components/network/network_component.cpp @@ -0,0 +1,33 @@ +#include "network_component.h" + +#include "esphome/core/defines.h" +#if defined(USE_NETWORK) && defined(USE_ESP32) +#include "esphome/core/log.h" +#include "esp_err.h" +#include "esp_netif.h" +#include "esp_event.h" +namespace esphome::network { + +static const char *const TAG = "network"; + +void NetworkComponent::setup() { + // Initialize ESP-IDF network interfaces and ensure the default event loop exists + esp_err_t err; + err = esp_netif_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_netif_init failed: (%d) %s", err, esp_err_to_name(err)); + this->mark_failed(); + return; + } + err = esp_event_loop_create_default(); + // ESP_ERR_INVALID_STATE is returned if the default loop already exists, + // which is fine since we just want to make sure it exists + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_event_loop_create_default failed: (%d) %s", err, esp_err_to_name(err)); + this->mark_failed(); + return; + } +} + +} // namespace esphome::network +#endif diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h new file mode 100644 index 0000000000..dde15940e4 --- /dev/null +++ b/esphome/components/network/network_component.h @@ -0,0 +1,14 @@ +#pragma once +#include "esphome/core/defines.h" +#if defined(USE_NETWORK) && defined(USE_ESP32) +#include "esphome/core/component.h" + +namespace esphome::network { +class NetworkComponent : 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; } +}; +} // namespace esphome::network +#endif diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 27712bd86a..787f2f5de8 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -35,9 +35,8 @@ void OpenThreadComponent::setup() { esp_vfs_eventfd_config_t eventfd_config = { .max_fds = 3, }; + // Network interface setup handled by network component ESP_ERROR_CHECK(nvs_flash_init()); - ESP_ERROR_CHECK(esp_event_loop_create_default()); - ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); xTaskCreate( diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 72832d7ac8..fdbd70bc61 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -634,9 +634,6 @@ void WiFiComponent::setup() { if (this->enable_on_boot_) { this->start(); } else { -#ifdef USE_ESP32 - esp_netif_init(); -#endif this->state_ = WIFI_COMPONENT_STATE_DISABLED; } } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 4f39a3a4b1..11b39b5000 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -145,23 +145,15 @@ void WiFiComponent::wifi_pre_setup_() { get_mac_address_raw(mac); set_mac_address(mac); } - esp_err_t err = esp_netif_init(); - if (err != ERR_OK) { - ESP_LOGE(TAG, "esp_netif_init failed: %s", esp_err_to_name(err)); - return; - } + // Network interface setup handled by network component s_wifi_event_group = xEventGroupCreate(); if (s_wifi_event_group == nullptr) { ESP_LOGE(TAG, "xEventGroupCreate failed"); return; } - err = esp_event_loop_create_default(); - if (err != ERR_OK) { - ESP_LOGE(TAG, "esp_event_loop_create_default failed: %s", esp_err_to_name(err)); - return; - } esp_event_handler_instance_t instance_wifi_id, instance_ip_id; - err = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id); + esp_err_t err = + esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err)); return; From 750d52741a8c5136e855c4f1ad5992f64d973630 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 08:36:53 -0400 Subject: [PATCH 1384/2214] [voice_assistant] Use RingBufferAudioSource (#16597) --- .../components/voice_assistant/__init__.py | 2 +- .../voice_assistant/voice_assistant.cpp | 129 ++++++++---------- .../voice_assistant/voice_assistant.h | 13 +- 3 files changed, 63 insertions(+), 81 deletions(-) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 958d1cbf91..f41adfd8de 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_SPEAKER, ) -AUTO_LOAD = ["ring_buffer", "socket"] +AUTO_LOAD = ["audio", "ring_buffer", "socket"] DEPENDENCIES = ["api", "microphone"] CODEOWNERS = ["@jesserockz", "@kahrendt"] diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 286e6645d2..af1b98da02 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -30,7 +30,7 @@ VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; } void VoiceAssistant::setup() { this->mic_source_->add_data_callback([this](const std::vector &data) { - std::shared_ptr temp_ring_buffer = this->ring_buffer_; + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } @@ -39,7 +39,7 @@ void VoiceAssistant::setup() { // Second microphone channel if (this->mic_source2_ != nullptr) { this->mic_source2_->add_data_callback([this](const std::vector &data) { - std::shared_ptr temp_ring_buffer = this->ring_buffer2_; + std::shared_ptr temp_ring_buffer = this->ring_buffer2_.lock(); if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } @@ -125,62 +125,47 @@ bool VoiceAssistant::allocate_buffers_() { } #endif - if (this->ring_buffer_ == nullptr) { - this->ring_buffer_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); - if (this->ring_buffer_ == nullptr) { + if (this->audio_source_ == nullptr) { + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + if (temp_ring_buffer == nullptr) { ESP_LOGE(TAG, "Could not allocate ring buffer"); return false; } - } - - if (this->send_buffer_ == nullptr) { - RAMAllocator send_allocator; - this->send_buffer_ = send_allocator.allocate(SEND_BUFFER_SIZE); - if (send_buffer_ == nullptr) { - ESP_LOGW(TAG, "Could not allocate send buffer"); + // Zero-copy source that reads directly from the ring buffer; frame-aligned to never split an int16 sample. + this->audio_source_ = audio::RingBufferAudioSource::create(temp_ring_buffer, SEND_BUFFER_SIZE, sizeof(int16_t)); + if (this->audio_source_ == nullptr) { + ESP_LOGE(TAG, "Could not allocate audio source"); return false; } + this->ring_buffer_ = temp_ring_buffer; } // Second microphone channel - if (this->mic_source2_ != nullptr) { - if (this->ring_buffer2_ == nullptr) { - this->ring_buffer2_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); - if (this->ring_buffer2_ == nullptr) { - ESP_LOGE(TAG, "Could not allocate second ring buffer"); - return false; - } + if ((this->mic_source2_ != nullptr) && (this->audio_source2_ == nullptr)) { + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + if (temp_ring_buffer == nullptr) { + ESP_LOGE(TAG, "Could not allocate second ring buffer"); + return false; } - - if (this->send_buffer2_ == nullptr) { - RAMAllocator send_allocator; - this->send_buffer2_ = send_allocator.allocate(SEND_BUFFER_SIZE); - if (this->send_buffer2_ == nullptr) { - ESP_LOGW(TAG, "Could not allocate second send buffer"); - return false; - } + this->audio_source2_ = audio::RingBufferAudioSource::create(temp_ring_buffer, SEND_BUFFER_SIZE, sizeof(int16_t)); + if (this->audio_source2_ == nullptr) { + ESP_LOGE(TAG, "Could not allocate second audio source"); + return false; } + this->ring_buffer2_ = temp_ring_buffer; } return true; } void VoiceAssistant::clear_buffers_() { - if (this->send_buffer_ != nullptr) { - memset(this->send_buffer_, 0, SEND_BUFFER_SIZE); - } - - if (this->ring_buffer_ != nullptr) { - this->ring_buffer_->reset(); + if (this->audio_source_ != nullptr) { + this->audio_source_->clear_buffered_data(); } // Second microphone channel - if (this->send_buffer2_ != nullptr) { - memset(this->send_buffer2_, 0, SEND_BUFFER_SIZE); - } - - if (this->ring_buffer2_ != nullptr) { - this->ring_buffer2_->reset(); + if (this->audio_source2_ != nullptr) { + this->audio_source2_->clear_buffered_data(); } #ifdef USE_SPEAKER @@ -195,22 +180,11 @@ void VoiceAssistant::clear_buffers_() { } void VoiceAssistant::deallocate_buffers_() { - if (this->send_buffer_ != nullptr) { - RAMAllocator send_deallocator; - send_deallocator.deallocate(this->send_buffer_, SEND_BUFFER_SIZE); - this->send_buffer_ = nullptr; - } - - this->ring_buffer_.reset(); + // Destroying each source releases its ring buffer; the matching weak_ptr then expires automatically. + this->audio_source_.reset(); // Second microphone channel - if (this->send_buffer2_ != nullptr) { - RAMAllocator send_deallocator; - send_deallocator.deallocate(this->send_buffer2_, SEND_BUFFER_SIZE); - this->send_buffer2_ = nullptr; - } - - this->ring_buffer2_.reset(); + this->audio_source2_.reset(); #ifdef USE_SPEAKER if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) { @@ -316,52 +290,57 @@ void VoiceAssistant::loop() { break; // State changed when udp server port received } case State::STREAMING_MICROPHONE: { + // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). if (this->audio_mode_ == AUDIO_MODE_API) { // API audio // Both microphone channels are sent, if configured - bool is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE; - bool is_available2 = false; - if (this->mic_source2_) { - is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE; + size_t available = this->audio_source_->fill(0, false); + size_t available2 = 0; + if (this->audio_source2_ != nullptr) { + available2 = this->audio_source2_->fill(0, false); } - while (is_available || is_available2) { + while (available > 0 || available2 > 0) { api::VoiceAssistantAudio msg; - if (is_available) { - size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0); - msg.data = this->send_buffer_; - msg.data_len = read_bytes; + if (available > 0) { + // Zero-copy: send_message() copies the data out before we consume it + msg.data = this->audio_source_->data(); + msg.data_len = available; } // Second microphone channel - if (is_available2) { - size_t read_bytes = this->ring_buffer2_->read((void *) this->send_buffer2_, SEND_BUFFER_SIZE, 0); - msg.data2 = this->send_buffer2_; - msg.data2_len = read_bytes; + if (available2 > 0) { + msg.data2 = this->audio_source2_->data(); + msg.data2_len = available2; } this->api_client_->send_message(msg); - is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE; - if (this->mic_source2_) { - is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE; - } else { - is_available2 = false; + + if (available > 0) { + this->audio_source_->consume(available); + } + available = this->audio_source_->fill(0, false); + if (available2 > 0) { + this->audio_source2_->consume(available2); + } + if (this->audio_source2_ != nullptr) { + available2 = this->audio_source2_->fill(0, false); } } } else { // UDP (will eventually be deprecated) // Only the primary microphone channel is used - while (this->ring_buffer_->available() >= SEND_BUFFER_SIZE) { - size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0); + while (this->audio_source_->fill(0, false) > 0) { if (!this->udp_socket_running_) { if (!this->start_udp_socket_()) { this->set_state_(State::STOP_MICROPHONE, State::IDLE); break; } } - this->socket_->sendto(this->send_buffer_, read_bytes, 0, (struct sockaddr *) &this->dest_addr_, - sizeof(this->dest_addr_)); + this->socket_->sendto(this->audio_source_->data(), this->audio_source_->available(), 0, + (struct sockaddr *) &this->dest_addr_, sizeof(this->dest_addr_)); + this->audio_source_->consume(this->audio_source_->available()); } } // audio mode break; diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index c4fa7eb615..f3ea669e15 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -9,6 +9,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/api/api_connection.h" +#include "esphome/components/audio/audio_transfer_buffer.h" #include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/api/api_pb2.h" #include "esphome/components/microphone/microphone_source.h" @@ -306,8 +307,13 @@ class VoiceAssistant : public Component { std::string wake_word_; - std::shared_ptr ring_buffer_; - std::shared_ptr ring_buffer2_; + // Zero-copy sources that read directly from each microphone channel's ring buffer internal storage. + // Each source owns its ring buffer; the matching ``ring_buffer_``/``ring_buffer2_`` weak_ptr is used by + // the microphone callback (a different thread) to write into it. + std::unique_ptr audio_source_; + std::unique_ptr audio_source2_; + std::weak_ptr ring_buffer_; + std::weak_ptr ring_buffer2_; bool use_wake_word_; uint8_t noise_suppression_level_; @@ -315,9 +321,6 @@ class VoiceAssistant : public Component { float volume_multiplier_; uint32_t conversation_timeout_; - uint8_t *send_buffer_{nullptr}; - uint8_t *send_buffer2_{nullptr}; - bool continuous_{false}; bool silence_detection_; From c17c4478ac17f375c79d561701cdc92a7152ae91 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:32:43 -0400 Subject: [PATCH 1385/2214] [mixer] Support any bit depth audio (#16524) --- esphome/components/mixer/speaker/__init__.py | 29 +-- .../mixer/speaker/mixer_speaker.cpp | 210 +++--------------- .../components/mixer/speaker/mixer_speaker.h | 58 +---- tests/components/mixer/common.yaml | 4 + 4 files changed, 65 insertions(+), 236 deletions(-) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 59a80d9297..8501843d3f 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -44,20 +44,10 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( cv.positive_time_period_milliseconds, cv.one_of(CONF_NEVER, lower=True), ), - cv.Optional(CONF_BITS_PER_SAMPLE, default=16): cv.int_range(16, 16), } ) -def _set_stream_limits(config): - audio.set_stream_limits( - min_bits_per_sample=16, - max_bits_per_sample=16, - )(config) - - return config - - def _validate_source_speaker(config): fconf = fv.full_config.get() @@ -67,15 +57,25 @@ def _validate_source_speaker(config): output_speaker_id = fconf.get_config_for_path(path) config[CONF_OUTPUT_SPEAKER] = output_speaker_id + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) + audio.final_validate_audio_schema( + "mixer", + audio_device=CONF_OUTPUT_SPEAKER, + sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + +def _validate_output_speaker(config): audio.final_validate_audio_schema( "mixer", audio_device=CONF_OUTPUT_SPEAKER, bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), channels=config.get(CONF_NUM_CHANNELS), - sample_rate=config.get(CONF_SAMPLE_RATE), )(config) return config @@ -89,8 +89,8 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_SOURCE_SPEAKERS): cv.All( cv.ensure_list(SOURCE_SPEAKER_SCHEMA), cv.Length(min=2, max=8), - [_set_stream_limits], ), + cv.Optional(CONF_BITS_PER_SAMPLE): cv.one_of(8, 16, 24, 32, int=True), cv.Optional(CONF_NUM_CHANNELS): cv.int_range(min=1, max=2), cv.Optional(CONF_QUEUE_MODE, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, @@ -100,13 +100,15 @@ CONFIG_SCHEMA = cv.All( ) FINAL_VALIDATE_SCHEMA = cv.All( + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER), + inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER), cv.Schema( { cv.Optional(CONF_SOURCE_SPEAKERS): [_validate_source_speaker], }, extra=cv.ALLOW_EXTRA, ), - inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER), + _validate_output_speaker, ) @@ -116,6 +118,7 @@ async def to_code(config): spkr = await cg.get_variable(config[CONF_OUTPUT_SPEAKER]) + cg.add(var.set_output_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_output_channels(config[CONF_NUM_CHANNELS])) cg.add(var.set_output_speaker(spkr)) cg.add(var.set_queue_mode(config[CONF_QUEUE_MODE])) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 1a995a6edf..6128dc3767 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -7,8 +7,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include // esp-audio-libs +#include // esp-audio-libs + #include -#include #include namespace esphome::mixer_speaker { @@ -22,19 +24,8 @@ static const uint32_t MIXER_AUTO_STOP_DEBOUNCE_MS = 200; static const size_t TASK_STACK_SIZE = 4096; -static const int16_t MAX_AUDIO_SAMPLE_VALUE = INT16_MAX; -static const int16_t MIN_AUDIO_SAMPLE_VALUE = INT16_MIN; - static const char *const TAG = "speaker_mixer"; -// Gives the Q15 fixed point scaling factor to reduce by 0 dB, 1dB, ..., 50 dB -// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014) -// float to Q15 fixed point formula: q15_scale_factor = floating_point_scale_factor * 2^(15) -static const std::array DECIBEL_REDUCTION_TABLE = { - 32767, 29201, 26022, 23189, 20665, 18415, 16410, 14624, 13032, 11613, 10349, 9222, 8218, 7324, 6527, 5816, 5183, - 4619, 4116, 3668, 3269, 2913, 2596, 2313, 2061, 1837, 1637, 1459, 1300, 1158, 1032, 920, 820, 731, - 651, 580, 517, 461, 411, 366, 326, 291, 259, 231, 206, 183, 163, 146, 130, 116, 103}; - // Event bits for SourceSpeaker command processing enum SourceSpeakerEventBits : uint32_t { SOURCE_SPEAKER_COMMAND_START = (1 << 0), @@ -315,97 +306,17 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptraudio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - int16_t *current_buffer = reinterpret_cast(audio_source->mutable_data()); - - duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_, - &this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_, - this->db_change_per_ducking_step_); + esp_audio_libs::ducking::apply(audio_source->mutable_data(), + static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), + samples_to_duck, this->ducking_state_); } return bytes_read; } void SourceSpeaker::apply_ducking(uint8_t decibel_reduction, uint32_t duration) { - if (this->target_ducking_db_reduction_ != decibel_reduction) { - // Start transition from the previous target (which becomes the new current level) - this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_; - - this->target_ducking_db_reduction_ = decibel_reduction; - - // Calculate the number of intermediate dB steps for the transition timing. - // Subtract 1 because the first step is taken immediately after this calculation. - uint8_t total_ducking_steps = 0; - if (this->target_ducking_db_reduction_ > this->current_ducking_db_reduction_) { - // The dB reduction level is increasing (which results in quieter audio) - total_ducking_steps = this->target_ducking_db_reduction_ - this->current_ducking_db_reduction_ - 1; - this->db_change_per_ducking_step_ = 1; - } else { - // The dB reduction level is decreasing (which results in louder audio) - total_ducking_steps = this->current_ducking_db_reduction_ - this->target_ducking_db_reduction_ - 1; - this->db_change_per_ducking_step_ = -1; - } - if ((duration > 0) && (total_ducking_steps > 0)) { - this->ducking_transition_samples_remaining_ = this->audio_stream_info_.ms_to_samples(duration); - - this->samples_per_ducking_step_ = this->ducking_transition_samples_remaining_ / total_ducking_steps; - this->ducking_transition_samples_remaining_ = - this->samples_per_ducking_step_ * total_ducking_steps; // adjust for integer division rounding - - this->current_ducking_db_reduction_ += this->db_change_per_ducking_step_; - } else { - this->ducking_transition_samples_remaining_ = 0; - this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_; - } - } -} - -void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, - int8_t *current_ducking_db_reduction, uint32_t *ducking_transition_samples_remaining, - uint32_t samples_per_ducking_step, int8_t db_change_per_ducking_step) { - if (*ducking_transition_samples_remaining > 0) { - // Ducking level is still transitioning - - // Takes the ceiling of input_samples_to_duck/samples_per_ducking_step - uint32_t ducking_steps_in_batch = - input_samples_to_duck / samples_per_ducking_step + (input_samples_to_duck % samples_per_ducking_step != 0); - - for (uint32_t i = 0; i < ducking_steps_in_batch; ++i) { - uint32_t samples_left_in_step = *ducking_transition_samples_remaining % samples_per_ducking_step; - - if (samples_left_in_step == 0) { - samples_left_in_step = samples_per_ducking_step; - } - - uint32_t samples_to_duck = std::min(input_samples_to_duck, samples_left_in_step); - samples_to_duck = std::min(samples_to_duck, *ducking_transition_samples_remaining); - - // Ensure we only point to valid index in the Q15 scaling factor table - uint8_t safe_db_reduction_index = - clamp(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1); - int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index]; - - audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, samples_to_duck); - - if (samples_left_in_step - samples_to_duck == 0) { - // After scaling the current samples, we are ready to transition to the next step - *current_ducking_db_reduction += db_change_per_ducking_step; - } - - input_buffer += samples_to_duck; - *ducking_transition_samples_remaining -= samples_to_duck; - input_samples_to_duck -= samples_to_duck; - } - } - - if ((*current_ducking_db_reduction > 0) && (input_samples_to_duck > 0)) { - // Audio is ducked, but its not in the middle of a transition step - - uint8_t safe_db_reduction_index = - clamp(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1); - int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index]; - - audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, input_samples_to_duck); - } + const uint32_t transition_samples = duration > 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0; + esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples); } void SourceSpeaker::enter_stopping_state_() { @@ -417,8 +328,9 @@ void SourceSpeaker::enter_stopping_state_() { void MixerSpeaker::dump_config() { ESP_LOGCONFIG(TAG, "Speaker Mixer:\n" - " Number of output channels: %u", - this->output_channels_); + " Number of output channels: %" PRIu8 "\n" + " Output bits per sample: %" PRIu8, + this->output_channels_, this->output_bits_per_sample_); } void MixerSpeaker::setup() { @@ -512,13 +424,8 @@ void MixerSpeaker::loop() { esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { if (!this->audio_stream_info_.has_value()) { - if (stream_info.get_bits_per_sample() != 16) { - // Audio streams that don't have 16 bits per sample are not supported - return ESP_ERR_NOT_SUPPORTED; - } - - this->audio_stream_info_ = audio::AudioStreamInfo(stream_info.get_bits_per_sample(), this->output_channels_, - stream_info.get_sample_rate()); + this->audio_stream_info_ = + audio::AudioStreamInfo(this->output_bits_per_sample_, this->output_channels_, stream_info.get_sample_rate()); this->output_speaker_->set_audio_stream_info(this->audio_stream_info_.value()); } else { if (!this->queue_mode_ && (stream_info.get_sample_rate() != this->audio_stream_info_.value().get_sample_rate())) { @@ -542,57 +449,6 @@ esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { return ESP_OK; } -void MixerSpeaker::copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_transfer) { - uint8_t input_channels = input_stream_info.get_channels(); - uint8_t output_channels = output_stream_info.get_channels(); - const uint8_t max_input_channel_index = input_channels - 1; - - if (input_channels == output_channels) { - size_t bytes_to_copy = input_stream_info.frames_to_bytes(frames_to_transfer); - memcpy(output_buffer, input_buffer, bytes_to_copy); - - return; - } - - for (uint32_t frame_index = 0; frame_index < frames_to_transfer; ++frame_index) { - for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) { - uint8_t input_channel_index = std::min(output_channel_index, max_input_channel_index); - output_buffer[output_channels * frame_index + output_channel_index] = - input_buffer[input_channels * frame_index + input_channel_index]; - } - } -} - -void MixerSpeaker::mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info, - const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_mix) { - const uint8_t primary_channels = primary_stream_info.get_channels(); - const uint8_t secondary_channels = secondary_stream_info.get_channels(); - const uint8_t output_channels = output_stream_info.get_channels(); - - const uint8_t max_primary_channel_index = primary_channels - 1; - const uint8_t max_secondary_channel_index = secondary_channels - 1; - - for (uint32_t frames_index = 0; frames_index < frames_to_mix; ++frames_index) { - for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) { - const uint32_t secondary_channel_index = std::min(output_channel_index, max_secondary_channel_index); - const int32_t secondary_sample = secondary_buffer[frames_index * secondary_channels + secondary_channel_index]; - - const uint32_t primary_channel_index = std::min(output_channel_index, max_primary_channel_index); - const int32_t primary_sample = - static_cast(primary_buffer[frames_index * primary_channels + primary_channel_index]); - - const int32_t added_sample = secondary_sample + primary_sample; - - output_buffer[frames_index * output_channels + output_channel_index] = - static_cast(clamp(added_sample, MIN_AUDIO_SAMPLE_VALUE, MAX_AUDIO_SAMPLE_VALUE)); - } - } -} - // NOLINTBEGIN(bugprone-unchecked-optional-access) -- audio_stream_info_ always set before this task is created void MixerSpeaker::audio_mixer_task(void *params) { MixerSpeaker *this_mixer = static_cast(params); @@ -662,6 +518,10 @@ void MixerSpeaker::audio_mixer_task(void *params) { uint32_t frames_to_mix = output_frames_free; + const audio::AudioStreamInfo &output_info = this_mixer->audio_stream_info_.value(); + const uint8_t output_bps = output_info.get_bits_per_sample() / 8; + const uint8_t output_channels = output_info.get_channels(); + if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) { // Only one speaker has audio data, just copy samples over @@ -669,14 +529,15 @@ void MixerSpeaker::audio_mixer_task(void *params) { if (active_stream_info.get_sample_rate() == this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { - // Speaker's sample rate matches the output speaker's, copy directly + // Speaker's sample rate matches the output speaker's, convert directly into the output buffer const uint32_t frames_available_in_buffer = active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available()); frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - copy_frames(reinterpret_cast(audio_sources_with_data[0]->data()), active_stream_info, - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + esp_audio_libs::pcm_convert::copy_frames( + audio_sources_with_data[0]->data(), output_transfer_buffer->get_buffer_end(), + static_cast(active_stream_info.get_bits_per_sample() / 8), active_stream_info.get_channels(), + output_bps, output_channels, frames_to_mix); // Set playback delay for newly contributing source if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { @@ -690,8 +551,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix)); // Update output transfer buffer length and pipeline frame count - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } else { // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker @@ -703,7 +563,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { } else { // Speaker has finished writing the current audio, update the stream information and restart the speaker this_mixer->audio_stream_info_ = - audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, + audio::AudioStreamInfo(this_mixer->output_bits_per_sample_, this_mixer->output_channels_, active_stream_info.get_sample_rate()); this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); this_mixer->output_speaker_->start(); @@ -719,21 +579,22 @@ void MixerSpeaker::audio_mixer_task(void *params) { speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available()); frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); } - const int16_t *primary_buffer = reinterpret_cast(audio_sources_with_data[0]->data()); + const uint8_t *primary_buffer = audio_sources_with_data[0]->data(); audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); - // Mix two streams together + // Mix two streams together at a time, accumulating into the output buffer. for (size_t i = 1; i < audio_sources_with_data.size(); ++i) { - mix_audio_samples(primary_buffer, primary_stream_info, - reinterpret_cast(audio_sources_with_data[i]->data()), - speakers_with_data[i]->get_audio_stream_info(), - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + esp_audio_libs::mixer::mix_frames( + primary_buffer, static_cast(primary_stream_info.get_bits_per_sample() / 8), + primary_stream_info.get_channels(), audio_sources_with_data[i]->data(), + static_cast(speakers_with_data[i]->get_audio_stream_info().get_bits_per_sample() / 8), + speakers_with_data[i]->get_audio_stream_info().get_channels(), output_transfer_buffer->get_buffer_end(), + output_bps, output_channels, frames_to_mix); if (i != audio_sources_with_data.size() - 1) { // Need to mix more streams together, point primary buffer and stream info to the already mixed output - primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); - primary_stream_info = this_mixer->audio_stream_info_.value(); + primary_buffer = output_transfer_buffer->get_buffer_end(); + primary_stream_info = output_info; } } @@ -754,8 +615,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { } // Update output transfer buffer length and pipeline frame count (once, not per source) - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } } diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index f57bead679..f1ae919b50 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -11,6 +11,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" +#include // esp-audio-libs + #include #include @@ -22,7 +24,8 @@ namespace esphome::mixer_speaker { * - Source speaker commands are signaled via event group bits and processed in its loop function to ensure thread * safety * - Directly handles pausing at the SourceSpeaker level; pause state is not passed through to the output speaker. - * - Audio sent to the SourceSpeaker must have 16 bits per sample. + * - Audio sent to the SourceSpeaker can have 8, 16, 24, or 32 bits per sample. Each source is converted to the output + * speaker's bit depth as it is mixed (or copied) into the output buffer. * - Audio sent to the SourceSpeaker can have any number of channels. They are duplicated or ignored as needed to match * the number of channels required for the output speaker. * - In queue mode, the audio sent to the SourceSpeakers can have different sample rates. @@ -93,19 +96,6 @@ class SourceSpeaker : public speaker::Speaker, public Component { void enter_stopping_state_(); void send_command_(uint32_t command_bit, bool wake_loop = false); - /// @brief Ducks audio samples by a specified amount. When changing the ducking amount, it can transition gradually - /// over a specified amount of samples. - /// @param input_buffer buffer with audio samples to be ducked in place - /// @param input_samples_to_duck number of samples to process in ``input_buffer`` - /// @param current_ducking_db_reduction pointer to the current dB reduction - /// @param ducking_transition_samples_remaining pointer to the total number of samples left before the - /// transition is finished - /// @param samples_per_ducking_step total number of samples per ducking step for the transition - /// @param db_change_per_ducking_step the change in dB reduction per step - static void duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, int8_t *current_ducking_db_reduction, - uint32_t *ducking_transition_samples_remaining, uint32_t samples_per_ducking_step, - int8_t db_change_per_ducking_step); - MixerSpeaker *parent_; std::shared_ptr audio_source_; @@ -118,11 +108,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { bool pause_state_{false}; - int8_t target_ducking_db_reduction_{0}; - int8_t current_ducking_db_reduction_{0}; - int8_t db_change_per_ducking_step_{1}; - uint32_t ducking_transition_samples_remaining_{0}; - uint32_t samples_per_ducking_step_{0}; + esp_audio_libs::ducking::DuckingState ducking_state_{}; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing @@ -143,12 +129,14 @@ class MixerSpeaker : public Component { /// @brief Starts the mixer task. Called by a source speaker giving the current audio stream information /// @param stream_info The calling source speaker's audio stream information - /// @return ESP_ERR_NOT_SUPPORTED if the incoming stream is incompatible due to unsupported bits per sample - /// ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream + /// @return ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream /// ESP_OK if the incoming stream is compatible and the mixer task starts esp_err_t start(audio::AudioStreamInfo &stream_info); void set_output_channels(uint8_t output_channels) { this->output_channels_ = output_channels; } + void set_output_bits_per_sample(uint8_t output_bits_per_sample) { + this->output_bits_per_sample_ = output_bits_per_sample; + } void set_output_speaker(speaker::Speaker *speaker) { this->output_speaker_ = speaker; } void set_queue_mode(bool queue_mode) { this->queue_mode_ = queue_mode; } void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } @@ -159,33 +147,6 @@ class MixerSpeaker : public Component { uint32_t get_frames_in_pipeline() const { return this->frames_in_pipeline_.load(std::memory_order_acquire); } protected: - /// @brief Copies audio frames from the input buffer to the output buffer taking into account the number of channels - /// in each stream. If the output stream has more channels, the input samples are duplicated. If the output stream has - /// less channels, the extra channel input samples are dropped. - /// @param input_buffer - /// @param input_stream_info - /// @param output_buffer - /// @param output_stream_info - /// @param frames_to_transfer number of frames (consisting of a sample for each channel) to copy from the input buffer - static void copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, int16_t *output_buffer, - audio::AudioStreamInfo output_stream_info, uint32_t frames_to_transfer); - - /// @brief Mixes the primary and secondary streams taking into account the number of channels in each stream. Primary - /// and secondary samples are duplicated or dropped as necessary to ensure the output stream has the configured number - /// of channels. Output samples are clamped to the corresponding int16 min or max values if the mixed sample - /// overflows. - /// @param primary_buffer samples buffer for the primary stream - /// @param primary_stream_info stream info for the primary stream - /// @param secondary_buffer samples buffer for secondary stream - /// @param secondary_stream_info stream info for the secondary stream - /// @param output_buffer buffer for the mixed samples - /// @param output_stream_info stream info for the output buffer - /// @param frames_to_mix number of frames in the primary and secondary buffers to mix together - static void mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info, - const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_mix); - static void audio_mixer_task(void *params); EventGroupHandle_t event_group_{nullptr}; @@ -193,6 +154,7 @@ class MixerSpeaker : public Component { FixedVector source_speakers_; speaker::Speaker *output_speaker_{nullptr}; + uint8_t output_bits_per_sample_; uint8_t output_channels_; bool queue_mode_; bool task_stack_in_psram_{false}; diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index e171b9499c..ef613b82bc 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -16,8 +16,12 @@ speaker: id: speaker_id dac_type: external i2s_dout_pin: ${dout_pin} + bits_per_sample: 32bit + channel: stereo - platform: mixer output_speaker: speaker_id + bits_per_sample: 32 + num_channels: 2 source_speakers: - id: source_speaker_1_id - id: source_speaker_2_id From 5cb7e622415ca036f8b33a139ea5db871eabc27d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:33:32 -0400 Subject: [PATCH 1386/2214] [audio] Use RingBufferAudioSource for decoding (#16564) --- esphome/components/audio/audio_decoder.cpp | 83 +++++++++++----------- esphome/components/audio/audio_decoder.h | 9 +-- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index d4ff59fc36..f709c23fb6 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -9,9 +9,12 @@ namespace esphome::audio { static const char *const TAG = "audio.decoder"; -static const uint32_t DECODING_TIMEOUT_MS = 50; // The decode function will yield after this duration static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data +// Max consecutive decode iterations that consume input but produce no output; e.g., skipping a large metadata block, +// before yielding and returning. +static const uint8_t MAX_NO_OUTPUT_ITERATIONS = 32; + static const uint32_t MAX_POTENTIALLY_FAILED_COUNT = 10; AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size) @@ -20,11 +23,13 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size) } esp_err_t AudioDecoder::add_source(std::weak_ptr &input_ring_buffer) { - auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_); + // Zero-copy source reading directly from the ring buffer's internal storage. Raw file data is byte + // aligned, so no frame alignment is required. + auto source = RingBufferAudioSource::create(input_ring_buffer.lock(), this->input_buffer_size_); if (source == nullptr) { - return ESP_ERR_NO_MEM; + // create() only returns nullptr for invalid arguments (expired ring buffer or zero buffer size) + return ESP_ERR_INVALID_ARG; } - source->set_source(input_ring_buffer); this->input_buffer_ = std::move(source); return ESP_OK; } @@ -141,13 +146,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } FileDecoderState state = FileDecoderState::MORE_TO_PROCESS; - - uint32_t decoding_start = millis(); - - bool first_loop_iteration = true; - - size_t bytes_processed = 0; - size_t bytes_available_before_processing = 0; + uint8_t no_output_iterations = 0; while (state == FileDecoderState::MORE_TO_PROCESS) { // Transfer decoded out @@ -161,45 +160,39 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { this->playback_ms_ += this->audio_stream_info_.value().frames_to_milliseconds_with_remainder(&this->accumulated_frames_written_); } + + if ((bytes_written > 0) && (this->output_transfer_buffer_->available() == 0)) { + // All decoded audio has been flushed to the sink; return so the caller can react to stop/pause before + // decoding the next batch + return AudioDecoderState::DECODING; + } } else { // If paused, block to avoid wasting CPU resources delay(READ_WRITE_TIMEOUT_MS); } - // Verify there is enough space to store more decoded audio and that the function hasn't been running too long - if ((this->output_transfer_buffer_->free() < this->free_buffer_required_) || - (millis() - decoding_start > DECODING_TIMEOUT_MS)) { + if (this->output_transfer_buffer_->available() > 0) { + // Output transfer buffer indicates backpressure, return so caller can handle other events; + // e.g., stop/pause, before trying again return AudioDecoderState::DECODING; } - // Decode more audio - - // Never shift the input buffer; every decoder buffers internally and consumes only what it processed. - size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - - if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) { - // Less data is available than what was processed in last iteration, so don't attempt to decode. - // This attempts to avoid the decoder from consistently trying to decode an incomplete frame. The transfer buffer - // will shift the remaining data to the start and copy more from the source the next time the decode function is - // called - break; + // Reaching here means no decoded output is pending (any would have returned above). Bounds long no-output + // stretches; e.g., skipping a large metadata block, so a source that keeps the ring buffer full can't spin this + // loop without yielding and trip the watchdog. The delay yields allowing other tasks to feed the watchdog and + // the return keeps stop/pause responsive. + if (++no_output_iterations >= MAX_NO_OUTPUT_ITERATIONS) { + delay(1); + return AudioDecoderState::DECODING; } - bytes_available_before_processing = this->input_buffer_->available(); + // Expose the next chunk of file data. Every decoder buffers internally and consumes only what it + // processed, so the source does not need to accumulate or stitch chunks across fill() calls. + this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - if ((this->potentially_failed_count_ > 0) && (bytes_read == 0)) { - // Failed to decode in last attempt and there is no new data + const size_t available_before_decode = this->input_buffer_->available(); - if ((this->input_buffer_->free() == 0) && first_loop_iteration) { - // The input buffer is full (or read-only, e.g. const flash source). Since it previously failed on the exact - // same data, we can never recover. For const sources this is correct: the entire file is already available, so - // a decode failure is genuine, not a transient out-of-data condition. - state = FileDecoderState::FAILED; - } else { - // Attempt to get more data next time - state = FileDecoderState::IDLE; - } - } else if (this->input_buffer_->available() == 0) { + if (available_before_decode == 0) { // No data to decode, attempt to get more data next time state = FileDecoderState::IDLE; } else { @@ -231,9 +224,6 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } } - first_loop_iteration = false; - bytes_processed = bytes_available_before_processing - this->input_buffer_->available(); - if (state == FileDecoderState::POTENTIALLY_FAILED) { ++this->potentially_failed_count_; } else if (state == FileDecoderState::END_OF_FILE) { @@ -241,7 +231,16 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } else if (state == FileDecoderState::FAILED) { return AudioDecoderState::FAILED; } else if (state == FileDecoderState::MORE_TO_PROCESS) { - this->potentially_failed_count_ = 0; + // Reset the failsafe only when the iteration made forward progress: input was consumed or output was + // produced (output_transfer_buffer_ is drained empty above, so any available bytes are new). A + // MORE_TO_PROCESS that neither consumes input nor produces output means the decoder is stalled; count it + // toward the failsafe so a stuck stream eventually surfaces as FAILED instead of looping forever. + if ((this->input_buffer_->available() < available_before_decode) || + (this->output_transfer_buffer_->available() > 0)) { + this->potentially_failed_count_ = 0; + } else { + ++this->potentially_failed_count_; + } } } return AudioDecoderState::DECODING; diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h index c34ebbc613..e772b7eb5f 100644 --- a/esphome/components/audio/audio_decoder.h +++ b/esphome/components/audio/audio_decoder.h @@ -61,15 +61,16 @@ class AudioDecoder { */ public: /// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source() - /// @param input_buffer_size Size of the input transfer buffer in bytes. + /// @param input_buffer_size Soft cap on the bytes a ring buffer source exposes per fill, in bytes. /// @param output_buffer_size Size of the output transfer buffer in bytes. AudioDecoder(size_t input_buffer_size, size_t output_buffer_size); ~AudioDecoder() = default; - /// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr. - /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership - /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated + /// @brief Adds a source ring buffer for raw file data. Shares ownership of the ring buffer via a shared_ptr. + /// The decoder reads directly from the ring buffer's internal storage with a zero-copy RingBufferAudioSource. + /// @param input_ring_buffer weak_ptr of the source ring buffer to read from + /// @return ESP_OK if successful, ESP_ERR_INVALID_ARG if the ring buffer is expired or the buffer size is zero esp_err_t add_source(std::weak_ptr &input_ring_buffer); /// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr. From 747787ae98f3a19819aa1ecbbecafca558435267 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:34:15 -0400 Subject: [PATCH 1387/2214] [audio] Use RingBufferAudioSource for resampling (#16560) --- esphome/components/audio/audio_resampler.cpp | 61 +++++++++++++++----- esphome/components/audio/audio_resampler.h | 17 +++--- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp index c04cc881f5..bef62ce190 100644 --- a/esphome/components/audio/audio_resampler.cpp +++ b/esphome/components/audio/audio_resampler.cpp @@ -12,16 +12,17 @@ static const uint32_t READ_WRITE_TIMEOUT_MS = 20; AudioResampler::AudioResampler(size_t input_buffer_size, size_t output_buffer_size) : input_buffer_size_(input_buffer_size), output_buffer_size_(output_buffer_size) { - this->input_transfer_buffer_ = AudioSourceTransferBuffer::create(input_buffer_size); this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size); } esp_err_t AudioResampler::add_source(std::weak_ptr &input_ring_buffer) { - if (this->input_transfer_buffer_ != nullptr) { - this->input_transfer_buffer_->set_source(input_ring_buffer); - return ESP_OK; + // The zero-copy RingBufferAudioSource is created lazily on the first resample() call, once both the ring + // buffer (stored here) and the input stream info (set by start()) are available, in either order. + this->source_ring_buffer_ = input_ring_buffer.lock(); + if (this->source_ring_buffer_ == nullptr) { + return ESP_ERR_INVALID_STATE; } - return ESP_ERR_NO_MEM; + return ESP_OK; } esp_err_t AudioResampler::add_sink(std::weak_ptr &output_ring_buffer) { @@ -47,7 +48,7 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI this->input_stream_info_ = input_stream_info; this->output_stream_info_ = output_stream_info; - if ((this->input_transfer_buffer_ == nullptr) || (this->output_transfer_buffer_ == nullptr)) { + if (this->output_transfer_buffer_ == nullptr) { return ESP_ERR_NO_MEM; } @@ -56,6 +57,13 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI return ESP_ERR_NOT_SUPPORTED; } + // Reject frame sizes that can't be used as the zero-copy source's alignment up front, where the caller checks + // the return code. The lazy create() in resample() keeps its own guard since it runs before the uint8_t cast. + const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1); + if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) { + return ESP_ERR_NOT_SUPPORTED; + } + if ((input_stream_info.get_sample_rate() != output_stream_info.get_sample_rate()) || (input_stream_info.get_bits_per_sample() != output_stream_info.get_bits_per_sample())) { this->resampler_ = make_unique( @@ -87,8 +95,27 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI } AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_differential) { + if (this->audio_source_ == nullptr) { + // Lazily create the zero-copy source on first use. Frame-aligned reads ensure multi-channel frames are + // never split across the ring buffer's wrap boundary. + const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1); + if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) { + // Stream info is unset or the frame is too large to use as an alignment; the uint8_t cast below would + // truncate it and could yield a source that tears frames. + return AudioResamplerState::FAILED; + } + // Pass the shared_ptr by copy so a failed create() leaves source_ring_buffer_ intact; release our + // reference only after the source has taken ownership. + this->audio_source_ = RingBufferAudioSource::create(this->source_ring_buffer_, this->input_buffer_size_, + static_cast(bytes_per_frame)); + if (this->audio_source_ == nullptr) { + return AudioResamplerState::FAILED; + } + this->source_ring_buffer_.reset(); + } + if (stop_gracefully) { - if (!this->input_transfer_buffer_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) { + if (!this->audio_source_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) { return AudioResamplerState::FINISHED; } } @@ -102,9 +129,11 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d delay(READ_WRITE_TIMEOUT_MS); } - this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS)); + // Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource + // (there is no intermediate transfer buffer to compact). + this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - if (this->input_transfer_buffer_->available() == 0) { + if (this->audio_source_->available() == 0) { // No samples available to process return AudioResamplerState::RESAMPLING; } @@ -112,17 +141,17 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d const size_t bytes_free = this->output_transfer_buffer_->free(); const uint32_t frames_free = this->output_stream_info_.bytes_to_frames(bytes_free); - const size_t bytes_available = this->input_transfer_buffer_->available(); + const size_t bytes_available = this->audio_source_->available(); const uint32_t frames_available = this->input_stream_info_.bytes_to_frames(bytes_available); if ((this->input_stream_info_.get_sample_rate() != this->output_stream_info_.get_sample_rate()) || (this->input_stream_info_.get_bits_per_sample() != this->output_stream_info_.get_bits_per_sample())) { // Adjust gain by -3 dB to avoid clipping due to the resampling process esp_audio_libs::resampler::ResamplerResults results = - this->resampler_->resample(this->input_transfer_buffer_->get_buffer_start(), - this->output_transfer_buffer_->get_buffer_end(), frames_available, frames_free, -3); + this->resampler_->resample(this->audio_source_->data(), this->output_transfer_buffer_->get_buffer_end(), + frames_available, frames_free, -3); - this->input_transfer_buffer_->decrease_buffer_length(this->input_stream_info_.frames_to_bytes(results.frames_used)); + this->audio_source_->consume(this->input_stream_info_.frames_to_bytes(results.frames_used)); this->output_transfer_buffer_->increase_buffer_length( this->output_stream_info_.frames_to_bytes(results.frames_generated)); @@ -146,10 +175,10 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d const size_t bytes_to_transfer = std::min(this->output_stream_info_.frames_to_bytes(frames_free), this->input_stream_info_.frames_to_bytes(frames_available)); - std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), - (void *) this->input_transfer_buffer_->get_buffer_start(), bytes_to_transfer); + std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), (const void *) this->audio_source_->data(), + bytes_to_transfer); - this->input_transfer_buffer_->decrease_buffer_length(bytes_to_transfer); + this->audio_source_->consume(bytes_to_transfer); this->output_transfer_buffer_->increase_buffer_length(bytes_to_transfer); } diff --git a/esphome/components/audio/audio_resampler.h b/esphome/components/audio/audio_resampler.h index 575ad13692..c09070c0ce 100644 --- a/esphome/components/audio/audio_resampler.h +++ b/esphome/components/audio/audio_resampler.h @@ -22,7 +22,7 @@ namespace esphome::audio { enum class AudioResamplerState : uint8_t { RESAMPLING, // More data is available to resample FINISHED, // All file data has been resampled and transferred - FAILED, // Unused state included for consistency among Audio classes + FAILED, // Failed to allocate the audio source }; class AudioResampler { @@ -32,14 +32,16 @@ class AudioResampler { * component). Also supports converting bits per sample. */ public: - /// @brief Allocates the input and output transfer buffers - /// @param input_buffer_size Size of the input transfer buffer in bytes. + /// @brief Allocates the output transfer buffer. The input source is created later in resample(). + /// @param input_buffer_size Max bytes exposed per fill() call on the zero-copy input source. /// @param output_buffer_size Size of the output transfer buffer in bytes. AudioResampler(size_t input_buffer_size, size_t output_buffer_size); - /// @brief Adds a source ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr. - /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership - /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated + /// @brief Sets the ring buffer the audio is read from and takes shared ownership of it. The zero-copy + /// RingBufferAudioSource that reads directly from its internal storage is created lazily on the first + /// resample() call, so add_source() and start() may be called in any order. + /// @param input_ring_buffer weak_ptr of a shared_ptr of the source ring buffer to transfer ownership + /// @return ESP_OK if successful, ESP_ERR_INVALID_STATE if the ring buffer is no longer alive esp_err_t add_source(std::weak_ptr &input_ring_buffer); /// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr. @@ -78,7 +80,8 @@ class AudioResampler { void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; } protected: - std::unique_ptr input_transfer_buffer_; + std::shared_ptr source_ring_buffer_; + std::unique_ptr audio_source_; std::unique_ptr output_transfer_buffer_; size_t input_buffer_size_; From 9fcb638f33fcd8814cb4aa5fb2817b5480b73be8 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:34:51 -0400 Subject: [PATCH 1388/2214] [micro_wake_word] Use RingBufferAudioSource (#16595) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../micro_wake_word/micro_wake_word.cpp | 108 ++++++++++-------- .../micro_wake_word/micro_wake_word.h | 17 +-- 2 files changed, 71 insertions(+), 54 deletions(-) diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 6877e9e5df..739d64dc28 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -33,7 +33,8 @@ static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072; static const UBaseType_t INFERENCE_TASK_PRIORITY = 3; enum EventGroupBits : uint32_t { - COMMAND_STOP = (1 << 0), // Signals the inference task should stop + COMMAND_STOP = (1 << 0), // Signals the inference task should stop + COMMAND_RESET_RING_BUFFER = (1 << 1), // Signals the inference task to discard buffered audio TASK_STARTING = (1 << 3), TASK_RUNNING = (1 << 4), @@ -114,13 +115,13 @@ void MicroWakeWord::setup() { } std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (this->ring_buffer_.use_count() > 1) { - size_t bytes_free = temp_ring_buffer->free(); - - if (bytes_free < data.size()) { - xEventGroupSetBits(this->event_group_, EventGroupBits::WARNING_FULL_RING_BUFFER); - temp_ring_buffer->reset(); + // Producer-only write: never touches consumer state. If the buffer is full, ask the inference task + // to drain it - reset() is a consumer operation and must run on the inference task's thread. + // Disable partial writes so audio chunks are either fully accepted or rejected and handled below. + if (temp_ring_buffer->write_without_replacement(data.data(), data.size(), 0, false) == 0) { + xEventGroupSetBits(this->event_group_, + EventGroupBits::WARNING_FULL_RING_BUFFER | EventGroupBits::COMMAND_RESET_RING_BUFFER); } - temp_ring_buffer->write((void *) data.data(), data.size()); } }); @@ -146,56 +147,65 @@ void MicroWakeWord::inference_task(void *params) { { // Ensures any C++ objects fall out of scope to deallocate before deleting the task - const size_t new_bytes_to_process = - this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(this_mww->features_step_size_); - std::unique_ptr audio_buffer; + const auto &stream_info = this_mww->microphone_source_->get_audio_stream_info(); + const size_t bytes_per_frame = stream_info.frames_to_bytes(1); + const size_t max_fill_bytes = stream_info.ms_to_bytes(this_mww->features_step_size_); + std::unique_ptr audio_source; int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]; if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { - // Allocate audio transfer buffer - audio_buffer = audio::AudioSourceTransferBuffer::create(new_bytes_to_process); - - if (audio_buffer == nullptr) { + // Round ring buffer size down to a frame multiple so the wrap boundary never splits an int16 sample. + const size_t ring_buffer_size = + (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer == nullptr) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); + } else { + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, max_fill_bytes, + static_cast(bytes_per_frame)); + if (audio_source == nullptr) { + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); + } else { + this_mww->ring_buffer_ = temp_ring_buffer; + } } } - if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { - // Allocate ring buffer - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( - this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); - if (temp_ring_buffer.use_count() == 0) { - xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); - } - audio_buffer->set_source(temp_ring_buffer); - this_mww->ring_buffer_ = temp_ring_buffer; - } - if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { this_mww->microphone_source_->start(); xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING); - while (!(xEventGroupGetBits(this_mww->event_group_) & COMMAND_STOP)) { - audio_buffer->transfer_data_from_source(pdMS_TO_TICKS(DATA_TIMEOUT_MS)); - - if (audio_buffer->available() < new_bytes_to_process) { - // Insufficient data to generate new spectrogram features, read more next iteration - continue; + while (!(xEventGroupGetBits(this_mww->event_group_) & (COMMAND_STOP | ERROR_BITS))) { + if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_RESET_RING_BUFFER) { + // Producer asked us to drain; run the consumer-side reset from this thread. + audio_source->clear_buffered_data(); + xEventGroupClearBits(this_mww->event_group_, EventGroupBits::COMMAND_RESET_RING_BUFFER); } - // Generate new spectrogram features - uint32_t processed_samples = this_mww->generate_features_( - (int16_t *) audio_buffer->get_buffer_start(), audio_buffer->available() / sizeof(int16_t), features_buffer); - audio_buffer->decrease_buffer_length(processed_samples * sizeof(int16_t)); + audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS), false); - // Run inference using the new spectorgram features - if (!this_mww->update_model_probabilities_(features_buffer)) { - xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE); - break; + // The frontend buffers samples internally and only emits a feature once it has a full window, so we can + // hand it whatever the source exposes. The frontend consumes at least one sample per call, so available() + // strictly decreases and this loop always terminates. + while (audio_source->available() >= sizeof(int16_t)) { + const size_t samples_available = audio_source->available() / sizeof(int16_t); + const int16_t *audio_data = reinterpret_cast(audio_source->data()); + + size_t processed_samples = 0; + const bool feature_generated = + this_mww->generate_features_(audio_data, samples_available, features_buffer, &processed_samples); + audio_source->consume(processed_samples * sizeof(int16_t)); + + if (feature_generated) { + if (!this_mww->update_model_probabilities_(features_buffer)) { + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE); + break; + } + + // Process each model's probabilities and possibly send a Detection Event to the queue + this_mww->process_probabilities_(); + } } - - // Process each model's probabilities and possibly send a Detection Event to the queue - this_mww->process_probabilities_(); } } } @@ -386,11 +396,15 @@ void MicroWakeWord::set_state_(State state) { } } -size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_available, - int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]) { - size_t processed_samples = 0; +bool MicroWakeWord::generate_features_(const int16_t *audio_buffer, size_t samples_available, + int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples) { + *processed_samples = 0; struct FrontendOutput frontend_output = - FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, &processed_samples); + FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, processed_samples); + + if (frontend_output.size == 0) { + return false; + } for (size_t i = 0; i < frontend_output.size; ++i) { // These scaling values are set to match the TFLite audio frontend int8 output. @@ -415,7 +429,7 @@ size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_a features_buffer[i] = static_cast(clamp(value, INT8_MIN, INT8_MAX)); } - return processed_samples; + return true; } void MicroWakeWord::process_probabilities_() { diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 5c0c056ac0..ef440b5d37 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -115,13 +115,16 @@ class MicroWakeWord : public Component void set_state_(State state); - /// @brief Generates spectrogram features from an input buffer of audio samples - /// @param audio_buffer (int16_t *) Buffer containing input audio samples - /// @param samples_available (size_t) Number of samples avaiable in the input buffer - /// @param features_buffer (int8_t *) Buffer to store generated features - /// @return (size_t) Number of samples processed from the input buffer - size_t generate_features_(int16_t *audio_buffer, size_t samples_available, - int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]); + /// @brief Generates a spectrogram feature from an input buffer of audio samples. The frontend buffers samples + /// internally, so callers may stream arbitrary-sized chunks; a feature is only emitted once enough samples have + /// accumulated to fill a full analysis window. + /// @param audio_buffer (const int16_t *) Buffer containing input audio samples + /// @param samples_available (size_t) Number of samples available in the input buffer + /// @param features_buffer (int8_t *) Buffer to store the generated feature, valid only when the return value is true + /// @param processed_samples (size_t *) Set to the number of samples consumed from the input buffer + /// @return True if a new feature was generated; false if more samples are required + bool generate_features_(const int16_t *audio_buffer, size_t samples_available, + int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples); /// @brief Processes any new probabilities for each model. If any wake word is detected, it will send a DetectionEvent /// to the detection_queue_. From 16b6509a0348eecc22c51066049052a3c9383fc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 08:28:09 -0500 Subject: [PATCH 1389/2214] Bump zeroconf from 0.149.12 to 0.149.13 (#16520) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4338063387..bd775d16a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.4 -zeroconf==0.149.12 +zeroconf==0.149.13 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 79539cb85d937238ce977372c0953fb6bbde8ff5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 09:55:28 -0500 Subject: [PATCH 1390/2214] Bump zeroconf from 0.149.13 to 0.149.16 (#16533) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bd775d16a1..1174f957a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.4 -zeroconf==0.149.13 +zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From cc88456ce7f779c2e9b2413ae9b02bbe0bfec1d9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:35:51 -0400 Subject: [PATCH 1391/2214] [espidf] Filter noisy 'git rev-parse' errors when .git is stripped (#16521) --- esphome/espidf/runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 65df37c7b2..da3f77cdd3 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -66,6 +66,12 @@ FILTER_IDF_LINES: list[str] = [ # Drop the blank line rich emits after the note so the build log # doesn't end with an orphan gap before ESPHome's own status lines. r"\s*$", + # ESP-IDF shells out to ``git rev-parse`` to embed a commit hash; + # esphome-libs strips ``.git`` from the tarball so those probes fail + # noisily without affecting the build. + r"-- git rev-parse returned ", + r"fatal: not a git repository", + r"Stopping at filesystem boundary", ] From 32fa856bf0597f7cecfdc80b838de6add3c15730 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:36:27 -0400 Subject: [PATCH 1392/2214] [espidf] Fix tarfile extract crashing on Python 3.11 with None mode (#16530) --- esphome/espidf/framework.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index aa97c65227..0e841af7d7 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -546,11 +546,11 @@ def _tar_extract_all( if not (mode & stat.S_IXUSR): mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) mode |= stat.S_IRUSR | stat.S_IWUSR - elif member.isdir() or member.issym(): - # Ignore mode for directories & symlinks - mode = None - else: - # Block special files + elif not (member.isdir() or member.issym()): + # Block special files. Directories and symlinks keep + # their masked-original mode — passing None here would + # crash tarfile.extract on Python <3.12 (its chmod + # path calls os.chmod unconditionally). continue member.mode = mode From e92a4c9472c8bf726e51b3213ae43a7b55010090 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:37:10 -0400 Subject: [PATCH 1393/2214] [espidf] Write version.txt after extract so bootloader shows the real version (#16532) --- esphome/espidf/framework.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 0e841af7d7..6710c632c4 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -780,6 +780,34 @@ def download_from_mirrors( return None +def _write_idf_version_txt(framework_path: Path, version: str) -> None: + """Write /version.txt if missing. + + IDF's build.cmake picks the version it embeds in the firmware (and + stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt`` + if present, else ``git describe`` against IDF_PATH, else the + ``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``. + On a clean esphome-libs tarball ``.git`` is fully stripped, so + git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet + wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom + framework.source pointed at a real git URL where build artifacts + mark the tree dirty) makes git_describe return ``-dirty``, + which is what then gets baked into the bootloader. Dropping + version.txt forces the right answer regardless. + """ + version_txt = framework_path / "version.txt" + if version_txt.exists(): + return + try: + version_txt.write_text(f"v{version}\n", encoding="utf-8") + except OSError as e: + _LOGGER.warning( + "Could not write %s (%s); bootloader version string may be incorrect.", + version_txt, + e, + ) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -861,6 +889,11 @@ def _check_esphome_idf_framework_install( archive_extract_all(tmp.file, framework_path, progress_header="Extracting") extracted_marker.touch() + # Idempotent post-extract patch: written every invocation so a build + # dir extracted before this fix gets the file too, without forcing a + # clean. Skips when version.txt already exists. + _write_idf_version_txt(framework_path, version) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True From 615d5aa827560675c5531e77f4d76ec9e4d5971c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:37:46 -0400 Subject: [PATCH 1394/2214] [core] Persist & restore CORE.toolchain through StorageJSON (#16531) --- esphome/storage_json.py | 20 ++++++++ tests/unit_tests/test_storage_json.py | 73 ++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index e481827080..7f8885ba5f 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -14,6 +14,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE from esphome.helpers import write_file_if_changed @@ -98,6 +99,7 @@ class StorageJSON: no_mdns: bool, framework: str | None = None, core_platform: str | None = None, + toolchain: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -134,6 +136,8 @@ class StorageJSON: self.framework = framework # The core platform of this firmware. Like "esp32", "rp2040", "host" etc. self.core_platform = core_platform + # The toolchain used for the build ("platformio" / "esp-idf") + self.toolchain = toolchain def as_dict(self): return { @@ -153,6 +157,7 @@ class StorageJSON: "no_mdns": self.no_mdns, "framework": self.framework, "core_platform": self.core_platform, + "toolchain": self.toolchain, } def to_json(self): @@ -189,6 +194,7 @@ class StorageJSON: ), framework=esph.target_framework, core_platform=esph.target_platform, + toolchain=esph.toolchain.value if esph.toolchain is not None else None, ) @staticmethod @@ -236,6 +242,7 @@ class StorageJSON: no_mdns = storage.get("no_mdns", False) framework = storage.get("framework") core_platform = storage.get("core_platform") + toolchain = storage.get("toolchain") return StorageJSON( storage_version, name, @@ -253,6 +260,7 @@ class StorageJSON: no_mdns, framework, core_platform, + toolchain, ) @staticmethod @@ -273,6 +281,18 @@ class StorageJSON: """ CORE.name = self.name CORE.build_path = self.build_path + # Restore toolchain so upload/logs picks the right firmware_bin path. + # An unknown value (corrupt sidecar, or written by a newer ESPHome) + # just leaves CORE.toolchain None — the fallback then picks PlatformIO. + if self.toolchain and CORE.toolchain is None: + try: + CORE.toolchain = Toolchain(self.toolchain) + except ValueError: + _LOGGER.debug( + "Ignoring unknown toolchain %r from %s", + self.toolchain, + storage_path(), + ) target_platform = self.core_platform or self.target_platform.lower() CORE.data[KEY_CORE] = { KEY_TARGET_PLATFORM: target_platform, diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index a3a38960e7..ea37492cf4 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -9,7 +9,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import storage_json -from esphome.const import CONF_DISABLED, CONF_MDNS +from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE @@ -308,6 +308,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.loaded_platforms = {"sensor"} mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" + mock_core.toolchain = Toolchain.ESP_IDF with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -327,6 +328,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.no_mdns is True assert result.framework == "esp-idf" assert result.core_platform == "esp32" + assert result.toolchain == "esp-idf" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -345,10 +347,12 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: mock_core.loaded_platforms = set() mock_core.config = {} # No MDNS config means enabled mock_core.target_framework = "arduino" + mock_core.toolchain = None result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) assert result.no_mdns is False + assert result.toolchain is None def test_storage_json_load_valid_file(tmp_path: Path) -> None: @@ -470,6 +474,73 @@ def test_storage_json_equality() -> None: assert storage1 != "not a storage object" +def _make_storage_with_toolchain( + toolchain: str | None, +) -> storage_json.StorageJSON: + return storage_json.StorageJSON( + storage_version=1, + name="dev", + friendly_name=None, + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="dev.local", + web_port=None, + target_platform="ESP32", + build_path=Path("/build"), + firmware_bin_path=Path("/build/firmware.bin"), + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="esp-idf", + core_platform="esp32", + toolchain=toolchain, + ) + + +def test_storage_json_toolchain_round_trip(setup_core: Path) -> None: + """Sidecar toolchain survives save -> load -> apply_to_core.""" + storage = _make_storage_with_toolchain("esp-idf") + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + # Serialization key is stable -- device-builder relies on it. + assert json.loads(path.read_text())["toolchain"] == "esp-idf" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.toolchain == "esp-idf" + + CORE.toolchain = None + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain == Toolchain.ESP_IDF + + +def test_storage_json_apply_to_core_preserves_cli_toolchain( + setup_core: Path, +) -> None: + """A CLI-set CORE.toolchain wins over the sidecar value.""" + loaded = _make_storage_with_toolchain("esp-idf") + + CORE.toolchain = Toolchain.PLATFORMIO + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain == Toolchain.PLATFORMIO + + +def test_storage_json_apply_to_core_ignores_unknown_toolchain( + setup_core: Path, +) -> None: + """Unknown enum values (corrupt sidecar / newer ESPHome) fall through to None.""" + loaded = _make_storage_with_toolchain("gcc") + + CORE.toolchain = None + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain is None + + def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( From 522541634738b95ac91d8266be67f2dd509e97c5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 12:05:27 -0400 Subject: [PATCH 1395/2214] [espidf] Backport ninja linux-arm64 entry into tools.json on aarch64 hosts (#16527) --- esphome/espidf/framework.py | 76 ++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6710c632c4..ee612c4c7e 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -7,6 +7,7 @@ import json import logging import os from pathlib import Path +import platform import shutil import subprocess import sys @@ -17,7 +18,7 @@ import requests from esphome.config_validation import Version from esphome.core import CORE -from esphome.helpers import ProgressBar, get_str_env, rmtree +from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed PathType = str | os.PathLike @@ -808,6 +809,74 @@ def _write_idf_version_txt(framework_path: Path, version: str) -> None: ) +# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release +# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no +# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a +# matching binary or grabs the x86_64 one, which can't execute on +# aarch64. cmake is already populated across the same release range; we +# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1 +# tools.json where the fix landed natively. +_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = { + "1.12.1": { + "rename_dist": "ninja-linux-arm64-v1.12.1.zip", + "sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1", + "size": 121787, + "url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip", + }, +} + + +def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: + """Inject ninja linux-arm64 entries into the framework's tools.json on aarch64. + + Idempotent: a tools.json that already has the entry, or a host that + isn't aarch64, is a no-op. Applied unconditionally on every install + check so a build dir extracted before the backport got fixed up + without forcing a clean. + """ + if platform.machine() != "aarch64": + return + + tools_json = framework_path / "tools" / "tools.json" + if not tools_json.is_file(): + return + + try: + with open(tools_json, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + _LOGGER.warning( + "Could not parse %s for linux-arm64 backport (%s); " + "skipping. A clean reinstall of the framework directory " + "may be needed.", + tools_json, + e, + ) + return + + changed = False + for tool in data.get("tools", []): + if tool.get("name") != "ninja": + continue + for ver in tool.get("versions", []): + entry = _NINJA_ARM64_BACKPORT.get(ver.get("name")) + if entry is None or ver.get("linux-arm64"): + continue + ver["linux-arm64"] = entry + changed = True + + if changed: + # write_file_if_changed stages a tempfile in the destination dir + # and atomically replaces — safe against mid-write interruption + # and concurrent invocations. + write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n") + _LOGGER.info( + "Patched %s to add ninja linux-arm64 download " + "(espressif/esp-idf#18272 backport).", + tools_json, + ) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -894,6 +963,11 @@ def _check_esphome_idf_framework_install( # clean. Skips when version.txt already exists. _write_idf_version_txt(framework_path, version) + # Apply the ninja linux-arm64 backport on every invocation, not just on + # fresh extracts — idempotent and cheap, and lets a build dir carrying + # a pre-patch tools.json get fixed up without forcing a clean. + _patch_tools_json_for_linux_arm64(framework_path) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True From 858cfd5b94f48e8120a40c17bb31dab5c3408cdb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:19 -0400 Subject: [PATCH 1396/2214] [espidf] Default to remote HEAD when cg.add_library URL has no #ref (#16535) --- esphome/espidf/component.py | 15 ++++++++------- tests/unit_tests/test_espidf_component.py | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b1352f7791..7d9874ad5f 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -93,7 +93,7 @@ class URLSource(Source): class GitSource(Source): - def __init__(self, url: str, ref: str): + def __init__(self, url: str, ref: str | None): self.url = url self.ref = ref @@ -109,7 +109,7 @@ class GitSource(Source): return path def __str__(self): - return f"{self.url}#{self.ref}" + return f"{self.url}#{self.ref}" if self.ref else self.url class InvalidIDFComponent(Exception): @@ -352,7 +352,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent: IDFComponent: The resolved component with name, version, and URL Raises: - ValueError: If a repository URL is missing a reference (#) RuntimeError: If no artifact can be found for the library """ name = None @@ -362,10 +361,11 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: # Parse repository URL: path becomes the component name, fragment - # becomes the git ref stored on GitSource. + # (if any) becomes the git ref stored on GitSource. A missing + # fragment is fine -- clone_or_update leaves the depth-1 clone on + # the remote's default branch, matching PIO's lib_deps behavior + # and external_components handling. split_result = urlsplit(library.repository) - if not split_result.fragment.strip(): - raise ValueError(f"Missing ref in URL {library.repository}") # Sanitize name name = str(split_result.path).strip("/") @@ -377,7 +377,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: version = "*" repository = urlunsplit(split_result._replace(fragment="")) - source = GitSource(str(repository), split_result.fragment) + ref = split_result.fragment.strip() or None + source = GitSource(str(repository), ref) # Version is provided - resolve using PlatformIO registry elif library.version: diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 373432f7d2..c4a419d1a2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -436,11 +436,21 @@ def test_convert_library_with_branch_ref(): assert result.source.ref == "some-branch" -def test_convert_library_missing_ref(): +def test_convert_library_missing_ref_uses_default_branch(): + """A bare URL with no #ref clones the remote's default branch. + + Matches PIO's lib_deps behavior and external_components handling -- + git.clone_or_update with ref=None leaves the depth-1 clone on + whatever branch the remote HEAD points at. + """ lib = Library("name", None, "https://github.com/foo/bar.git") - with pytest.raises(ValueError): - _convert_library_to_component(lib) + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref is None def test_convert_library_registry(monkeypatch): From 878027ff50ea1760ff08c92189247ec28f8f5d64 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:54 -0400 Subject: [PATCH 1397/2214] [espidf] Honor the dict shorthand for library.json dependencies (#16537) --- esphome/espidf/component.py | 20 ++++ tests/unit_tests/test_espidf_component.py | 110 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7d9874ad5f..a452a3f34a 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -656,6 +656,26 @@ def _process_dependencies(component: IDFComponent): if not dependencies: return + # PIO's library.json accepts both the list-of-dicts form and the + # shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize + # the dict form so the loop below sees a uniform list. Iterating a + # dict gives string keys, which would silently fail the + # ``"name" in dependency`` substring check and skip every entry. + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + dependencies = normalized + _LOGGER.info("Processing %s@%s component dependencies...", name, version) for dependency in dependencies: # Validate dependency structure diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index c4a419d1a2..7d6c861ffd 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -505,3 +505,113 @@ def test_process_dependencies_skips_invalid(tmp_component): _process_dependencies(tmp_component) assert tmp_component.dependencies == [] + + +def test_process_dependencies_dict_form(tmp_component, monkeypatch): + """PIO library.json shorthand ``{"owner/Name": "version"}`` is honored. + + Iterating a dict gives string keys, which would silently fail the + ``"name" in dependency`` substring check. Normalize to list-of-dicts + first so the dict form (used by e.g. tesla-ble for its nanopb dep) + is treated the same as the verbose list form. + """ + captured: list[Library] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent( + library.name, library.version, source=URLSource("http://dummy.com") + ) + + tmp_component.data = { + "dependencies": { + "nanopb/Nanopb": "^0.4.91", + "BareName": "1.2.3", + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(tmp_component.dependencies) == 2 + names = sorted(lib.name for lib in captured) + versions = sorted(lib.version for lib in captured) + assert names == ["BareName", "nanopb/Nanopb"] + assert versions == ["1.2.3", "^0.4.91"] + + +def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch): + """A dict-value that's a URL gets routed to ``repository`` like the list form.""" + captured: list[Library] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent(library.name, "*", source=URLSource("http://dummy.com")) + + tmp_component.data = { + "dependencies": { + "foo/Bar": "https://github.com/foo/bar.git#main", + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(captured) == 1 + assert captured[0].name == "foo/Bar" + assert captured[0].version is None + assert captured[0].repository == "https://github.com/foo/bar.git#main" + + +def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch): + """A dict-value that's itself a dict is merged into the entry. + + PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}`` + for entries that need fields beyond just a version (platforms, + frameworks, etc.). The extra fields flow into _check_library_data + via the entry merge. + """ + captured: list[Library] = [] + checked: list[dict] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent( + library.name, library.version, source=URLSource("http://dummy.com") + ) + + tmp_component.data = { + "dependencies": { + "nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}, + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr( + esphome.espidf.component, + "_check_library_data", + checked.append, + ) + + _process_dependencies(tmp_component) + + assert len(captured) == 1 + assert captured[0].name == "nanopb/Nanopb" + assert captured[0].version == "^0.4.91" + # Extra spec fields reach _check_library_data so platform/framework + # gating still applies. + assert checked == [ + { + "name": "Nanopb", + "owner": "nanopb", + "version": "^0.4.91", + "platforms": "espidf", + } + ] From e6ed27574625d7d6706fab7243e3f6ee2b8999b6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:03:55 -0400 Subject: [PATCH 1398/2214] [esp32] Defer esp_panic_handler wrap so arduino-esp32 IDF component skips it (#16538) --- esphome/components/esp32/__init__.py | 35 +++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1db97f95eb..2fdaa991ae 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -113,6 +113,7 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs" ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}" +ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32" LOG_LEVELS_IDF = [ "NONE", @@ -1743,6 +1744,31 @@ async def _add_yaml_idf_components(components: list[ConfigType]): ) +@coroutine_with_priority(CoroPriority.FINAL - 1) +async def _finalize_arduino_aware_flags(): + """Build flags that depend on whether arduino-esp32 is linked in. + + Scheduler runs lower priority values later, so ``FINAL - 1`` fires + after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) -- + by then ``KEY_COMPONENTS`` is fully populated. + + - Skip our esp_panic_handler wrap when Arduino is linked; Arduino + wraps the same symbol and the linker errors on the duplicate. + - Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component + case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The + framework=arduino branch already adds it inline in to_code. + """ + arduino_linked = ( + CORE.using_arduino + or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS] + ) + if not arduino_linked: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") + elif not CORE.using_arduino: + cg.add_build_flag("-DUSE_ARDUINO") + + async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] conf = config[CONF_FRAMEWORK] @@ -1802,11 +1828,8 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") - # Arduino already wraps esp_panic_handler for its own backtrace handler, - # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. - if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - cg.add_build_flag("-Wl,--wrap=esp_panic_handler") - cg.add_define("USE_ESP32_CRASH_HANDLER") + # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. + CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") @@ -2566,7 +2589,7 @@ def _write_idf_component_yml(): if CORE.using_toolchain_esp_idf: add_idf_component( - name="espressif/arduino-esp32", + name=ARDUINO_ESP32_COMPONENT_NAME, ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), ) From ae2e372762235e1dbf5cfe12164367940a8fc4b4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:08:09 -0400 Subject: [PATCH 1399/2214] [tuya] Restore null guard on status_pin lost in #16353 (#16539) --- esphome/components/tuya/tuya.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index fd14844908..3058d82cc4 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -206,15 +206,17 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff if (this->status_pin_reported_ != -1) { this->init_state_ = TuyaInitState::INIT_DATAPOINT; this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY); - bool is_pin_equals = - this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_; - // Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send - if (!is_pin_equals) { - ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.", + if (this->status_pin_ != nullptr) { + if (this->status_pin_->get_pin() != this->status_pin_reported_) { + ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.", + this->status_pin_reported_); + } + ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); + this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); + } else { + ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.", this->status_pin_reported_); } - ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); - this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); } else { this->init_state_ = TuyaInitState::INIT_WIFI; ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); From 0c94a173b664ab2db05d841f622763545c41ff29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 15:42:57 -0500 Subject: [PATCH 1400/2214] [api] Break api_connection/api_server include cycle to drop custom unique_ptr deleter (#16542) --- esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_connection.h | 51 ++++-------------- .../components/api/api_connection_buffer.h | 54 +++++++++++++++++++ esphome/components/api/api_server.cpp | 5 -- esphome/components/api/api_server.h | 14 ++--- .../bluetooth_proxy/bluetooth_proxy.cpp | 1 + 6 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 esphome/components/api/api_connection_buffer.h diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b6f4aa2141..f2bf3752fa 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,5 +1,6 @@ #include "api_connection.h" #ifdef USE_API +#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines #ifdef USE_API_NOISE #include "api_frame_helper_noise.h" #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4165b7f3a2..804cd9ddd1 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -11,7 +11,8 @@ #endif #include "api_pb2.h" #include "api_pb2_service.h" -#include "api_server.h" +#include "list_entities.h" +#include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" #ifdef USE_ESP32_CRASH_HANDLER @@ -36,6 +37,9 @@ class ComponentIterator; namespace esphome::api { +// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. +class APIServer; + // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending @@ -411,44 +415,10 @@ class APIConnection final : public APIServerConnectionBase { // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg); - // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. - // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. - static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, - const void *msg, APIConnection *conn, - uint32_t remaining_size) { -#ifdef HAS_PROTO_MESSAGE_DUMP - if (conn->flags_.log_only_mode) { - auto *proto_msg = static_cast(msg); - DumpBuffer dump_buf; - conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); - return 1; - } -#endif - const uint8_t footer_size = conn->helper_->frame_footer_size(); - - // First message uses max padding (already in buffer), subsequent use exact header size - size_t to_add; - if (conn->flags_.batch_first_message) { - conn->flags_.batch_first_message = false; - conn->batch_header_size_ = conn->helper_->frame_header_padding(); - to_add = calculated_size; - } else { - conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); - to_add = calculated_size + conn->batch_header_size_ + footer_size; - } - - // Check if it fits (using actual header size, not max padding) - uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; - if (total_calculated_size > remaining_size) - return 0; - - auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - shared_buf.resize(shared_buf.size() + to_add); - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); - - return total_calculated_size; - } + // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites. + // Defined in api_connection_buffer.h (needs APIServer complete). + static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, + const void *msg, APIConnection *conn, uint32_t remaining_size); // Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages). // All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion. @@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase { // Read by process_batch_multi_ to pass into MessageInfo. uint8_t batch_header_size_{0}; - uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + // Defined in api_connection_buffer.h (needs APIServer complete). + uint32_t get_batch_delay_ms_() const; // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. // If its IPv6 the header is 40 bytes, and if its IPv4 diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h new file mode 100644 index 0000000000..1dd8a162e4 --- /dev/null +++ b/esphome/components/api/api_connection_buffer.h @@ -0,0 +1,54 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_API + +// Inline APIConnection methods that need APIServer complete. Include this +// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_. + +#include "api_connection.h" +#include "api_server.h" + +namespace esphome::api { + +inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size, + MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size) { +#ifdef HAS_PROTO_MESSAGE_DUMP + if (conn->flags_.log_only_mode) { + auto *proto_msg = static_cast(msg); + DumpBuffer dump_buf; + conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); + return 1; + } +#endif + const uint8_t footer_size = conn->helper_->frame_footer_size(); + + // First message uses max padding (already in buffer), subsequent use exact header size + size_t to_add; + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + conn->batch_header_size_ = conn->helper_->frame_header_padding(); + to_add = calculated_size; + } else { + conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); + to_add = calculated_size + conn->batch_header_size_ + footer_size; + } + + // Check if it fits (using actual header size, not max padding) + uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; + if (total_calculated_size > remaining_size) + return 0; + + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + shared_buf.resize(shared_buf.size() + to_add); + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + + return total_calculated_size; +} + +inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + +} // namespace esphome::api +#endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6c26c4e187..c30bd2e612 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -30,11 +30,6 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c APIServer::APIServer() { global_api_server = this; } -// Custom deleter defined here so `delete` sees the complete APIConnection type. -// This prevents libc++ from emitting an "incomplete type" error when other -// translation units only have the forward declaration of APIConnection. -void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; } - void APIServer::socket_failed_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); this->destroy_socket_(); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 6b575e536d..fbc8115091 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_API #include "api_buffer.h" +// Must precede clients_ so APIConnection is complete for default_delete (libc++). +#include "api_connection.h" #include "api_noise_context.h" #include "api_pb2.h" #include "api_pb2_service.h" @@ -12,8 +14,6 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -#include "list_entities.h" -#include "subscribe_state.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -191,15 +191,9 @@ class APIServer final : public Component, bool is_connected_with_state_subscription() const; // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect - // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the + // to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the // APIConnection but cannot reset/move the slot and break the count invariant. - // Custom deleter is defined out-of-line in api_server.cpp so libc++ does not - // eagerly instantiate `delete static_cast(p)` here, where - // only the forward declaration of APIConnection is visible (incomplete type). - struct APIConnectionDeleter { - void operator()(APIConnection *p) const; - }; - using APIConnectionPtr = std::unique_ptr; + using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { const APIConnectionPtr *begin_; const APIConnectionPtr *end_; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c3461f9c51..ca30aab943 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -1,5 +1,6 @@ #include "bluetooth_proxy.h" +#include "esphome/components/api/api_server.h" #include "esphome/core/log.h" #include "esphome/core/macros.h" #include "esphome/core/application.h" From 27d53ec1171fe257400a0e214420c4cd77efafbb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 18:03:07 -0400 Subject: [PATCH 1401/2214] [sx126x] Assert NSS before wait_busy so commands wake the chip from sleep (#16546) --- esphome/components/sx126x/sx126x.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6e6857fadb..83afeac50a 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_READ_BUFFER); this->transfer_byte(offset); uint8_t status = this->transfer_byte(0x00); @@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { } void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_WRITE_BUFFER); this->transfer_byte(offset); for (const uint8_t &byte : packet) { @@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { } uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); uint8_t status = this->transfer_byte(0x00); for (int32_t i = 0; i < size; i++) { @@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); for (int32_t i = 0; i < size; i++) { this->transfer_byte(data[i]); @@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_READ_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); @@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { } void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_WRITE_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); From f247def4acdeee5d707c71927cdf45c851c9452d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 17:05:17 -0500 Subject: [PATCH 1402/2214] [core] Refresh compiled config cache after upload/logs fallback (#16548) --- esphome/__main__.py | 15 +++- tests/unit_tests/test_compiled_config.py | 100 +++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 16a05ad552..07bbd89358 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2449,7 +2449,10 @@ def run_esphome(argv): # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. config: ConfigType | None = None - if args.command in ("upload", "logs") and not command_line_substitutions: + cache_eligible = ( + args.command in ("upload", "logs") and not command_line_substitutions + ) + if cache_eligible: from esphome.compiled_config import load_compiled_config config = load_compiled_config(conf_path) @@ -2464,6 +2467,16 @@ def run_esphome(argv): command_line_substitutions, skip_external_update=skip_external, ) + # 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 CORE.config = config diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 8c9cfa8101..e12107152b 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -253,6 +253,106 @@ 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) YAML 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") + CORE.config_path = yaml_path + + with ( + patch( + "esphome.__main__.read_config", + return_value={"esphome": {"name": "lite_test"}}, + ), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "upload", str(yaml_path)]) + + mock_save.assert_not_called() + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( + tmp_path: Path, command: str +) -> None: + """A stale-cache fallback rewrites the cache so the next call hits + the fast path. Without this, every upload/logs after a YAML edit + pays for read_config() until the next compile rewrites the cache.""" + 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=-60) # stale + + fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} + + with ( + patch("esphome.__main__.read_config", return_value=fresh_config), + patch( + "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config + ) as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {command: lambda args, config: 0}, + ), + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + + mock_save.assert_called_once_with(fresh_config) + # 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 + + +def test_run_esphome_upload_with_substitution_does_not_refresh_cache( + fresh_cache_files: Path, +) -> None: + """`-s` substitutions skip the cache on both read and write -- saving + here would clobber the cache with a substitution-specific config.""" + with ( + patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)]) + + mock_save.assert_not_called() + + +def test_run_esphome_compile_does_not_refresh_cache_via_fallback( + fresh_cache_files: Path, +) -> None: + """Compile writes the cache through update_storage_json, not via the + upload/logs fallback path -- the fallback save would skip the + storage_should_clean check.""" + with ( + patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"compile": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "compile", str(fresh_cache_files)]) + + mock_save.assert_not_called() + + def test_run_esphome_upload_with_substitution_skips_cache( fresh_cache_files: Path, ) -> None: From 7ae55664727da6073c7672fc0e28aba1bf67126a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 22 May 2026 06:43:08 -0400 Subject: [PATCH 1403/2214] [sendspin] Bump sendspin-cpp to v0.6.1 (#16553) --- 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 36f13f7d07..b670bd3c4d 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 42d0d5de6b..8dafde1111 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.6.0 + version: 0.6.1 lvgl/lvgl: version: 9.5.0 From 59db9a4673e1e159476a83f8d6654d669344231a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:49:03 -0500 Subject: [PATCH 1404/2214] [dashboard] Fix flaky test_websocket_refresh_command on Windows CI (#16565) --- tests/dashboard/test_web_server.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 626aea0216..0ee841e68c 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1503,13 +1503,18 @@ async def test_websocket_refresh_command( ) -> None: """Test WebSocket refresh command triggers dashboard update.""" with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: - mock_subscriber.request_refresh = Mock() + # Signal an asyncio.Event when request_refresh is invoked so the + # test can deterministically wait for the server-side handler to run + # instead of relying on a fixed sleep (flaky on Windows CI under load). + called = asyncio.Event() + mock_subscriber.request_refresh = Mock(side_effect=called.set) # Send refresh command await websocket_client.write_message(json.dumps({"event": "refresh"})) - # Give it a moment to process - await asyncio.sleep(0.01) + # Wait for the server to process the message and invoke request_refresh + async with asyncio.timeout(5): + await called.wait() # Verify request_refresh was called mock_subscriber.request_refresh.assert_called_once() From 1f4a0615721382e36c0fecfe158a8f4eac33c080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 22 May 2026 22:09:32 +0300 Subject: [PATCH 1405/2214] [libretiny] Fix LN882H IRAM_ATTR injection point in patch_linker.py (#16570) --- esphome/components/libretiny/patch_linker.py.script | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 282a31d3f2..3a8a4787ed 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -13,7 +13,9 @@ import subprocess # - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. # - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. # - LN882H: stock linker has no glob for ".sram.text", so we inject -# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) +# immediately after KEEP(*(.vectors)), so the vector table stays at +# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment. # # All families also get a post-link summary showing where IRAM_ATTR landed. @@ -27,7 +29,11 @@ _KEEP_LINE = ( "__esphome_sram_text_end = .; " + _MARKER + "\n" ) -_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") +# Inject after KEEP(*(.vectors)) so the vector table stays at +# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte- +# aligned address; injecting before the vectors would push them to an +# unaligned offset and mis-route every IRQ handler. +_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") def _detect(env): @@ -56,7 +62,7 @@ KNOWN_VARIANTS = frozenset({ def _inject_keep(host_section): - """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + """Return a patcher that injects _KEEP_LINE after `host_section` match.""" def patch(content): if _MARKER in content: return content From 4e7bc92061d5cd6a962c5512d2e2c91e6e491ac9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:30:28 -0500 Subject: [PATCH 1406/2214] [esp8266] Use os_timer-based esp_delay() in delay() (#16563) --- esphome/components/esp8266/hal.cpp | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index e8f472dc8a..3501c51859 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -5,6 +5,7 @@ #include #include +#include extern "C" { #include @@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() { return result; } -// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object -// call to the original millis() that --wrap can't intercept, so calling ::delay() -// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still -// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and -// WiFi run correctly. Theoretically less power-efficient than Arduino's -// os_timer-based delay() for long waits, but nearly all ESPHome delays are short -// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is -// negligible. +// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to +// suspend the cont task for `ms` milliseconds without polling millis(). This +// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay()) +// and lets the SDK run freely while we wait, which timing-sensitive +// interrupt-driven code (e.g. ESP8266 software-serial RX in components like +// fingerprint_grow) depends on. The poll-based busy-wait that this replaced +// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and +// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does +// not call millis(), so the slow Arduino millis() body is not pulled into IRAM +// by this path (the --wrap=millis goal of #15662 is preserved). void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); return; } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } + esp_delay(ms); } void arch_restart() { From 8f6ea62628965cdb47fa3f30d7f461be3b3c1f89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:30:43 -0500 Subject: [PATCH 1407/2214] [uart] Wake main loop on ESP8266 software serial RX (#16562) --- esphome/components/uart/__init__.py | 9 +++++---- .../components/uart/uart_component_esp8266.cpp | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 7075228743..4ea32e26a3 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -513,10 +513,11 @@ async def uart_write_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.FINAL) async def final_step(): """Final code generation step to configure optional UART features.""" - if CORE.is_esp32 and CORE.has_networking: - # Wake-on-RX is essentially free on ESP32 (just an ISR function pointer - # registration) — enable by default to reduce RX buffer overflow risk - # by waking the main loop immediately when data arrives. + if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking: + # Wake-on-RX is essentially free (just an ISR function pointer + # registration on ESP32, an inline flag set on ESP8266 software + # serial) — enable by default to reduce RX buffer overflow risk by + # waking the main loop immediately when data arrives. cg.add_define("USE_UART_WAKE_LOOP_ON_RX") diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0ea7930760..fc1509f737 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -4,6 +4,9 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include "esphome/core/wake.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -149,7 +152,11 @@ void ESP8266UartComponent::dump_config() { if (this->hw_serial_ != nullptr) { ESP_LOGCONFIG(TAG, " Using hardware serial interface."); } else { - ESP_LOGCONFIG(TAG, " Using software serial"); + ESP_LOGCONFIG(TAG, " Using software serial" +#ifdef USE_UART_WAKE_LOOP_ON_RX + "\n Wake on data RX: ENABLED" +#endif + ); } this->check_logger_conflict(); } @@ -266,6 +273,12 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Wake the main loop so the consuming component drains the byte promptly + // instead of waiting for the next loop_interval_ tick. Important for timing + // sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow). + wake_loop_isrsafe(); +#endif } void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { From adde7681e8a33884d47b11211b9a079bf12135de Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 22 May 2026 20:30:25 -0400 Subject: [PATCH 1408/2214] [esp32] Demote IDF #warning deprecations from error under ESP-IDF toolchain (#16584) --- 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 2fdaa991ae..367ec32578 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1821,6 +1821,7 @@ async def to_code(config): cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") + cg.add_build_flag("-Wno-error=cpp") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From 0babc52472c2cc617c36dd5d9e872882d5e2577e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 May 2026 14:30:12 -0500 Subject: [PATCH 1409/2214] [bluetooth_proxy] Recover slot stuck in DISCONNECTING when CLOSE_EVT is dropped (#16588) --- .../bluetooth_proxy/bluetooth_connection.cpp | 26 ++++++++++++------- .../bluetooth_proxy/bluetooth_connection.h | 2 ++ .../esp32_ble_client/ble_client_base.cpp | 2 ++ .../esp32_ble_client/ble_client_base.h | 10 +++++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 21573f0184..7ba9e61e19 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -135,12 +135,26 @@ void BluetoothConnection::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) - if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { + // 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) { // Send disconnection notification this->proxy_->send_device_connection(this->address_, false, 0, reason); @@ -372,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } - case ESP_GATTC_CLOSE_EVT: { - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, - param->close.reason); - // Now the GATT connection is fully closed and controller resources are freed - // Safe to mark the connection slot as available - this->reset_connection_(param->close.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); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index b50ea2d6a2..e5600f6af4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { protected: friend class BluetoothProxy; + 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); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 7f0f2c624d..3fb9632e9a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -72,6 +72,7 @@ void BLEClientBase::loop() { // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. this->release_services(); this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT); } } @@ -418,6 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_lifecycle_event_("CLOSE"); this->release_services(); this->set_idle_(); + this->on_disconnect_complete(param->close.reason); break; } case ESP_GATTC_SEARCH_RES_EVT: { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 4e0b22cc29..0291a4b993 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -140,6 +140,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); + /// Hook called once a connection has been fully torn down (after release_services() and + /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) + /// override this to release that state. `reason` is the controller reason code, or + /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { this->set_state(espbt::ClientState::IDLE); @@ -149,6 +155,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_disconnecting_() { this->disconnecting_started_ = millis(); this->set_state(espbt::ClientState::DISCONNECTING); + // BluetoothConnection::loop() disables the component loop after service discovery + // completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT + // gets lost. Re-enable the loop so the 10s safety timeout can force IDLE. + this->enable_loop(); } // Compact error logging helpers to reduce flash usage void log_error_(const char *message); From 9a34a6aabb8691447126e9775cb8fef90e80e932 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 23 May 2026 15:44:25 -0400 Subject: [PATCH 1410/2214] [esp32] Replace per-class -Wno-error=X demotes with blanket -Wno-error for ESP-IDF toolchain (#16599) --- esphome/components/esp32/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 367ec32578..5aeff91830 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1816,12 +1816,9 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - cg.add_build_flag("-Wno-error=format") - cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=overloaded-virtual") - cg.add_build_flag("-Wno-error=reorder") - cg.add_build_flag("-Wno-error=volatile") - cg.add_build_flag("-Wno-error=cpp") + # Undo IDF's blanket -Werror so third-party libraries and user + # lambdas don't need a -Wno-error= entry per warning class. + cg.add_build_flag("-Wno-error") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From ddd353d10560c46a97abb0b2dd7bce31b55a8bfe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 24 May 2026 00:19:07 -0400 Subject: [PATCH 1411/2214] [esp32] Disable IDF's COMPILER_DISABLE_DEFAULT_ERRORS so -Wno-error actually undoes -Werror (#16604) --- esphome/components/esp32/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5aeff91830..4f77258b2c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1816,8 +1816,11 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - # Undo IDF's blanket -Werror so third-party libraries and user - # lambdas don't need a -Wno-error= entry per warning class. + # Demote IDF's blanket -Werror to warnings so third-party libs + # and user lambdas don't need a -Wno-error= per warning. + # The sdkconfig knob disables IDF's rewrite to -Werror=all (which + # can't be globally undone); -Wno-error then handles the demotion. + add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False) cg.add_build_flag("-Wno-error") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From 03e2eb4b4a938b8600954afba8c74bdcbbbd7aee Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 25 May 2026 09:28:49 +1200 Subject: [PATCH 1412/2214] Bump version to 2026.5.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 206a181ffd..30ae42ea2c 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.5.0 +PROJECT_NUMBER = 2026.5.1 # 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 96554b12da..39c5c6b60e 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0" +__version__ = "2026.5.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 090f5a486a63418c54a0f01b0e9a78ae61ec25ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 May 2026 16:32:47 -0500 Subject: [PATCH 1413/2214] Lift dependabot pip open PR limit (#16609) --- .github/dependabot.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 528e69c478..e87939f824 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,7 @@ updates: directory: "/" schedule: interval: daily + open-pull-requests-limit: 10 ignore: # Hypotehsis is only used for testing and is updated quite often - dependency-name: hypothesis From 917ffc379795d9f42c5d92e46834811d056e3774 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 21:49:52 +0000 Subject: [PATCH 1414/2214] Bump aioesphomeapi from 45.0.4 to 45.2.2 (#16611) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 178e05497f..45401a7995 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.4 +aioesphomeapi==45.2.2 zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 62b0a93e5e032d1ad4573cb055df1e50e6a5af56 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 25 May 2026 10:43:39 +1200 Subject: [PATCH 1415/2214] [rp2040] Add variant config option for RP2040/RP2350 (#16602) --- esphome/components/rp2040/__init__.py | 109 +++++++++++++++++- esphome/components/rp2040/const.py | 26 +++++ esphome/core/defines.h | 1 + tests/components/rp2040/test.rp2040-ard.yaml | 1 + .../rp2040/test.rp2040-pico2-ard.yaml | 6 + tests/unit_tests/components/test_rp2040.py | 67 ++++++++++- 6 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 tests/components/rp2040/test.rp2040-pico2-ard.yaml diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 862d532645..830c961476 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -1,8 +1,10 @@ +from collections.abc import Callable import logging from pathlib import Path import re from string import ascii_letters, digits import subprocess +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -12,6 +14,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_PLATFORM_VERSION, CONF_SOURCE, + CONF_VARIANT, CONF_VERSION, CONF_WATCHDOG_TIMEOUT, KEY_CORE, @@ -21,12 +24,30 @@ from esphome.const import ( PLATFORM_RP2040, ThreadModel, ) -from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeCore, + EsphomeError, + coroutine_with_priority, +) from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed +from esphome.types import ConfigType from . import boards -from .const import KEY_BOARD, KEY_LWIP_OPTS, KEY_PIO_FILES, KEY_RP2040, rp2040_ns +from .const import ( + KEY_BOARD, + KEY_LWIP_OPTS, + KEY_PIO_FILES, + KEY_RP2040, + KEY_VARIANT, + MCU_TO_VARIANT, + STANDARD_BOARDS, + VARIANT_FRIENDLY, + VARIANTS, + rp2040_ns, +) # force import gpio to register pin schema from .gpio import rp2040_pin_to_code # noqa @@ -68,7 +89,7 @@ def board_id_has_wifi(board_id: str) -> bool: return board_info.get("wifi", False) -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_RP2040] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2040 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" @@ -76,12 +97,46 @@ def set_core_data(config): config[CONF_FRAMEWORK][CONF_VERSION] ) CORE.data[KEY_RP2040][KEY_BOARD] = config[CONF_BOARD] + CORE.data[KEY_RP2040][KEY_VARIANT] = config[CONF_VARIANT] CORE.data[KEY_RP2040][KEY_PIO_FILES] = {} return config +def get_rp2040_variant(core_obj: EsphomeCore | None = None) -> str: + return (core_obj or CORE).data[KEY_RP2040][KEY_VARIANT] + + +def only_on_variant( + *, + supported: str | list[str] | None = None, + unsupported: str | list[str] | None = None, + msg_prefix: str = "This feature", +) -> Callable[[Any], Any]: + """Config validator for features only available on some RP2040 variants.""" + if supported is not None and not isinstance(supported, list): + supported = [supported] + if unsupported is not None and not isinstance(unsupported, list): + unsupported = [unsupported] + + def validator_(obj: Any) -> Any: + if not CORE.is_rp2040: + raise cv.Invalid(f"{msg_prefix} is only available on RP2040") + variant = get_rp2040_variant() + if supported is not None and variant not in supported: + raise cv.Invalid( + f"{msg_prefix} is only available on {', '.join(supported)}" + ) + if unsupported is not None and variant in unsupported: + raise cv.Invalid( + f"{msg_prefix} is not available on {', '.join(unsupported)}" + ) + return obj + + return validator_ + + def get_download_types(storage_json): """Binary-download entries for a built RP2040 firmware. @@ -192,12 +247,52 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( _arduino_check_versions, ) + +def _detect_variant(value: ConfigType) -> ConfigType: + value = value.copy() + board: str | None = value.get(CONF_BOARD) + variant: str | None = value.get(CONF_VARIANT) + + if board is None: + # `cv.has_at_least_one_key` guarantees variant is set here. + board = STANDARD_BOARDS[variant] + value[CONF_BOARD] = board + + board_info = boards.BOARDS.get(board) + if board_info is None: + if variant is None: + raise cv.Invalid( + "This board is unknown; please specify the chip variant using " + f"the '{CONF_VARIANT}' option.", + path=[CONF_BOARD], + ) + _LOGGER.warning( + "This board is unknown; the specified variant '%s' will be used " + "but this may not work as expected.", + variant, + ) + else: + board_variant = MCU_TO_VARIANT[board_info["mcu"]] + if variant is None: + variant = board_variant + elif variant != board_variant: + raise cv.Invalid( + f"Option '{CONF_VARIANT}' ({variant}) does not match the " + f"selected board '{board}' ({board_variant}).", + path=[CONF_VARIANT], + ) + + value[CONF_VARIANT] = variant + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_BOARD): cv.All( + cv.Optional(CONF_BOARD): cv.All( cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) ), + cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="8388ms"): cv.All( cv.positive_time_period_milliseconds, @@ -206,6 +301,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, } ), + cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT), + _detect_variant, set_core_data, ) @@ -223,7 +320,9 @@ async def to_code(config): cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) - cg.add_define("ESPHOME_VARIANT", "RP2040") + variant = config[CONF_VARIANT] + cg.add_build_flag(f"-DUSE_RP2040_VARIANT_{variant}") + cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant]) cg.add_define(ThreadModel.SINGLE) cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2040/const.py index e381d0482d..959753d95b 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2040/const.py @@ -4,5 +4,31 @@ KEY_BOARD = "board" KEY_LWIP_OPTS = "lwip_opts" KEY_RP2040 = "rp2040" KEY_PIO_FILES = "pio_files" +KEY_VARIANT = "variant" + +VARIANT_RP2040 = "RP2040" +VARIANT_RP2350 = "RP2350" +VARIANTS = [ + VARIANT_RP2040, + VARIANT_RP2350, +] + +VARIANT_FRIENDLY = { + VARIANT_RP2040: "RP2040", + VARIANT_RP2350: "RP2350", +} + +# Map BOARDS[board]["mcu"] (lowercase) to canonical variant constant +MCU_TO_VARIANT = { + "rp2040": VARIANT_RP2040, + "rp2350": VARIANT_RP2350, +} + +# Default board chosen when only `variant` is specified — the Raspberry Pi +# Foundation reference boards (Pico W / Pico 2 W). +STANDARD_BOARDS = { + VARIANT_RP2040: "rpipicow", + VARIANT_RP2350: "rpipico2w", +} rp2040_ns = cg.esphome_ns.namespace("rp2040") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ee8e89de8b..3cb92616bb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -401,6 +401,7 @@ #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE +#define USE_RP2040_VARIANT_RP2040 #define USE_SPI #ifndef USE_ETHERNET #define USE_ETHERNET diff --git a/tests/components/rp2040/test.rp2040-ard.yaml b/tests/components/rp2040/test.rp2040-ard.yaml index 1eb315a3b4..09531f914e 100644 --- a/tests/components/rp2040/test.rp2040-ard.yaml +++ b/tests/components/rp2040/test.rp2040-ard.yaml @@ -1,4 +1,5 @@ rp2040: + variant: rp2040 enable_full_printf: false logger: diff --git a/tests/components/rp2040/test.rp2040-pico2-ard.yaml b/tests/components/rp2040/test.rp2040-pico2-ard.yaml new file mode 100644 index 0000000000..c9d795840d --- /dev/null +++ b/tests/components/rp2040/test.rp2040-pico2-ard.yaml @@ -0,0 +1,6 @@ +rp2040: + variant: rp2350 + enable_full_printf: false + +logger: + level: VERBOSE diff --git a/tests/unit_tests/components/test_rp2040.py b/tests/unit_tests/components/test_rp2040.py index 25a9ade567..8e726933ed 100644 --- a/tests/unit_tests/components/test_rp2040.py +++ b/tests/unit_tests/components/test_rp2040.py @@ -1,6 +1,11 @@ -"""Tests for RP2040 component public helpers.""" +"""Tests for RP2040 component public helpers and variant detection.""" -from esphome.components.rp2040 import board_id_has_wifi +import pytest + +from esphome.components.rp2040 import _detect_variant, board_id_has_wifi +from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350 +import esphome.config_validation as cv +from esphome.const import CONF_BOARD, CONF_VARIANT def test_board_id_has_wifi_for_known_wifi_board() -> None: @@ -27,3 +32,61 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: "no CYW43" guard at compile time. """ assert board_id_has_wifi("not-a-real-board-id") is True + + +def test_detect_variant_derives_variant_from_board() -> None: + """Board alone resolves to the matching variant.""" + result = _detect_variant({CONF_BOARD: "rpipicow"}) + assert result[CONF_BOARD] == "rpipicow" + assert result[CONF_VARIANT] == VARIANT_RP2040 + + +def test_detect_variant_derives_variant_from_rp2350_board() -> None: + """An RP2350 board resolves to ``RP2350``.""" + result = _detect_variant({CONF_BOARD: "rpipico2"}) + assert result[CONF_BOARD] == "rpipico2" + assert result[CONF_VARIANT] == VARIANT_RP2350 + + +def test_detect_variant_only_picks_default_board_rp2040() -> None: + """Variant alone picks Pico W as the canonical RP2040 board.""" + result = _detect_variant({CONF_VARIANT: VARIANT_RP2040}) + assert result[CONF_BOARD] == "rpipicow" + assert result[CONF_VARIANT] == VARIANT_RP2040 + + +def test_detect_variant_only_picks_default_board_rp2350() -> None: + """Variant alone picks Pico 2 W as the canonical RP2350 board.""" + result = _detect_variant({CONF_VARIANT: VARIANT_RP2350}) + assert result[CONF_BOARD] == "rpipico2w" + assert result[CONF_VARIANT] == VARIANT_RP2350 + + +def test_detect_variant_matching_explicit_variant_passes() -> None: + """Specifying both a board and the matching variant is allowed.""" + result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350}) + assert result[CONF_BOARD] == "rpipico2" + assert result[CONF_VARIANT] == VARIANT_RP2350 + + +def test_detect_variant_mismatched_variant_raises() -> None: + """Board/variant mismatch must be rejected and name the offending board.""" + with pytest.raises( + cv.Invalid, match=r"does not match the selected board 'rpipicow'" + ): + _detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350}) + + +def test_detect_variant_unknown_board_without_variant_raises() -> None: + """Unknown board with no variant tells the user how to recover.""" + with pytest.raises(cv.Invalid, match="please specify the chip variant"): + _detect_variant({CONF_BOARD: "not-a-real-board"}) + + +def test_detect_variant_unknown_board_with_variant_passes() -> None: + """Unknown board + explicit variant is accepted (with a warning).""" + result = _detect_variant( + {CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040} + ) + assert result[CONF_BOARD] == "not-a-real-board" + assert result[CONF_VARIANT] == VARIANT_RP2040 From e0167e9bdff94e5a0aace72fbcb010ea1d9022d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 May 2026 20:17:51 -0500 Subject: [PATCH 1416/2214] [lvgl] Memoize obj_schema by widget_type (#16615) --- esphome/components/lvgl/schemas.py | 14 +++- .../lvgl/test_obj_schema_cache.py | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/lvgl/test_obj_schema_cache.py diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 58ef88d6a8..7436581fb4 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -462,13 +462,23 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): return schema +# Widget types are module-level singletons populated at import time, so we +# can cache compiled obj_schemas by widget_type identity for the lifetime of +# the process. The strong reference in the value keeps the key (an id() +# target) from being recycled. +_OBJ_SCHEMA_CACHE: dict[int, tuple[WidgetType, cv.Schema]] = {} + + def obj_schema(widget_type: WidgetType): """ Create a schema for a widget type itself i.e. no allowance for children :param widget_type: :return: """ - return ( + cached = _OBJ_SCHEMA_CACHE.get(id(widget_type)) + if cached is not None and cached[0] is widget_type: + return cached[1] + schema = ( part_schema(widget_type.parts) .extend(ALIGN_TO_SCHEMA) .extend(automation_schema(widget_type.w_type)) @@ -479,6 +489,8 @@ def obj_schema(widget_type: WidgetType): } ) ) + _OBJ_SCHEMA_CACHE[id(widget_type)] = (widget_type, schema) + return schema ALIGN_TO_SCHEMA = { diff --git a/tests/component_tests/lvgl/test_obj_schema_cache.py b/tests/component_tests/lvgl/test_obj_schema_cache.py new file mode 100644 index 0000000000..860ee211dd --- /dev/null +++ b/tests/component_tests/lvgl/test_obj_schema_cache.py @@ -0,0 +1,67 @@ +"""Tests for obj_schema() memoization.""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest + +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl import schemas as lvgl_schemas +from esphome.components.lvgl.schemas import WIDGET_TYPES, obj_schema + + +@pytest.fixture(autouse=True) +def _clear_obj_schema_cache() -> Generator[None]: + cache = getattr(lvgl_schemas, "_OBJ_SCHEMA_CACHE", None) + if cache is not None: + cache.clear() + yield + if cache is not None: + cache.clear() + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_same_widget_type_returns_same_schema() -> None: + wt = _widget_type("obj") + assert obj_schema(wt) is obj_schema(wt) + + +def test_different_widget_types_return_different_schemas() -> None: + assert obj_schema(_widget_type("obj")) is not obj_schema(_widget_type("label")) + + +def test_cache_is_populated_after_first_call() -> None: + wt = _widget_type("obj") + assert id(wt) not in lvgl_schemas._OBJ_SCHEMA_CACHE + obj_schema(wt) + assert id(wt) in lvgl_schemas._OBJ_SCHEMA_CACHE + + +def test_cached_schema_produces_equivalent_output() -> None: + wt = _widget_type("obj") + cached_result = obj_schema(wt)({}) + lvgl_schemas._OBJ_SCHEMA_CACHE.clear() + fresh_result = obj_schema(wt)({}) + assert cached_result == fresh_result + + +def test_id_recycling_is_caught_by_identity_guard() -> None: + wt = _widget_type("obj") + real_schema = obj_schema(wt) + + cached_widget_type, _ = lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] + sentinel_schema = object() + lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] = (cached_widget_type, sentinel_schema) + assert obj_schema(wt) is sentinel_schema + + other = _widget_type("label") + lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] = (other, sentinel_schema) + rebuilt = obj_schema(wt) + assert rebuilt is not sentinel_schema + assert rebuilt is not real_schema From e7ab78366d184a912f5f76d717f6aa387b7cd21c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:03:38 -0400 Subject: [PATCH 1417/2214] [core] Add esphome.build_flags option for IDF + PlatformIO (#16629) --- esphome/const.py | 1 + esphome/core/config.py | 11 +++++++++++ script/ci-custom.py | 2 +- tests/components/esphome/common.yaml | 2 ++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/esphome/const.py b/esphome/const.py index 9dd77a7cb8..07f6bad771 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -199,6 +199,7 @@ CONF_BROKER = "broker" CONF_BSSID = "bssid" CONF_BUFFER_DURATION = "buffer_duration" CONF_BUFFER_SIZE = "buffer_size" +CONF_BUILD_FLAGS = "build_flags" CONF_BUILD_PATH = "build_path" CONF_BUS_VOLTAGE = "bus_voltage" CONF_BUSY_PIN = "busy_pin" diff --git a/esphome/core/config.py b/esphome/core/config.py index 5a98b94781..6125c4ecc9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_AREA, CONF_AREA_ID, CONF_AREAS, + CONF_BUILD_FLAGS, CONF_BUILD_PATH, CONF_COMMENT, CONF_COMPILE_PROCESS_LIMIT, @@ -288,6 +289,7 @@ CONFIG_SCHEMA = cv.All( cv.string_strict: cv.Any([cv.string], cv.string), } ), + cv.Optional(CONF_BUILD_FLAGS, default=[]): cv.ensure_list(cv.string_strict), cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema( { cv.string_strict: cv.string, @@ -510,6 +512,12 @@ async def _add_platformio_options(pio_options): cg.add_platformio_option(key, val) +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_build_flags(flags: list[str]) -> None: + for flag in flags: + cg.add_build_flag(flag) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_environment_variables(env_vars: dict[str, str]) -> None: # Set environment variables for the build process @@ -705,6 +713,9 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) + if config[CONF_BUILD_FLAGS]: + CORE.add_job(_add_build_flags, config[CONF_BUILD_FLAGS]) + if config[CONF_ENVIRONMENT_VARIABLES]: CORE.add_job(_add_environment_variables, config[CONF_ENVIRONMENT_VARIABLES]) diff --git a/script/ci-custom.py b/script/ci-custom.py index 56ca0d0355..51fea97874 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -562,7 +562,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1012 +CONST_PY_MAX_CONF = 1013 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index db75b08b38..93f82824e6 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -2,6 +2,8 @@ esphome: debug_scheduler: true platformio_options: board_build.flash_mode: dio + build_flags: + - "-DESPHOME_TEST_BUILD_FLAG" environment_variables: TEST_ENV_VAR: "test_value" BUILD_NUMBER: "12345" From 98e72133872cd7b198df16e6468dc874224f6f60 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:08:16 -0400 Subject: [PATCH 1418/2214] [espidf] Warn instead of skipping libraries with framework mismatch (#16630) --- esphome/espidf/component.py | 23 +++++++++++++++++++---- tests/unit_tests/test_espidf_component.py | 11 ++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index a452a3f34a..3534ac82f5 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -610,11 +610,17 @@ def _check_library_data(data: dict): """ Check if a library data is compatible with the ESP-IDF framework. + A platform mismatch (e.g. an AVR-only library on ESP32) raises + ``InvalidIDFComponent`` so the caller skips the library. A framework + mismatch only logs a warning — PIO manifests often understate the + frameworks they actually compile under, and IDF (unlike PIO's + ``lib_compat_mode``) has no opt-out, so we include the library anyway. + Args: - component: IDFComponent object being processed + data: PIO library manifest dict being processed. Raises: - ValueError: If library has unsupported platforms or frameworks + InvalidIDFComponent: If the library does not support the ESP32 platform. """ platforms = data.get("platforms", "*") if isinstance(platforms, str): @@ -632,12 +638,21 @@ def _check_library_data(data: dict): frameworks = [a.strip() for a in frameworks.split(",")] frameworks = _ensure_list(frameworks) - # Check if library supports ESP-IDF framework + # Check if library declares the active framework. PIO library manifests + # often list only "arduino" even when the library actually compiles fine + # under ESP-IDF, and IDF (unlike PIO with `lib_compat_mode`) has no way to + # opt out of the check. Warn instead of failing so the user isn't forced to + # fork the library to fix the manifest. framework = "arduino" if CORE.using_arduino else "espidf" valid_framework = "*" in frameworks or framework in frameworks if not valid_framework: - raise InvalidIDFComponent(f"Unsupported library frameworks: {frameworks}") + _LOGGER.warning( + "Library %s declares frameworks %s that do not include '%s'; including anyway", + data.get("name", ""), + frameworks, + framework, + ) def _process_dependencies(component: IDFComponent): diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 7d6c861ffd..f50f5317de 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -261,9 +261,14 @@ def test_check_library_data_invalid_platform(esp32_idf_core): _check_library_data({"platforms": ["other"], "frameworks": "*"}) -def test_check_library_data_invalid_framework(esp32_idf_core): - with pytest.raises(InvalidIDFComponent): - _check_library_data({"platforms": "*", "frameworks": ["other"]}) +def test_check_library_data_invalid_framework( + esp32_idf_core: None, caplog: pytest.LogCaptureFixture +) -> None: + # Framework mismatch is a warning, not a hard skip: the library is still + # included so that PIO manifests that only list "arduino" (but actually + # compile under IDF) can be used without forking them. + _check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]}) + assert "do not include 'espidf'" in caplog.text def test_extra_script_captures_libpath_libs_and_defines(tmp_path): From cde52ef75e3c9f633883719f1c15b79041ca9efa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 09:09:54 -0500 Subject: [PATCH 1419/2214] [lvgl] Merge dict-extend chains to speed up schema construction (#16614) --- esphome/components/lvgl/__init__.py | 27 +- esphome/components/lvgl/schemas.py | 77 ++++-- .../lvgl/test_schema_dict_helpers.py | 236 ++++++++++++++++++ 3 files changed, 319 insertions(+), 21 deletions(-) create mode 100644 tests/component_tests/lvgl/test_schema_dict_helpers.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 4277c14dd7..44bcda9ba9 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,3 +1,4 @@ +import functools import importlib from pathlib import Path import pkgutil @@ -79,7 +80,7 @@ from .schemas import ( WIDGET_TYPES, any_widget_schema, container_schema, - obj_schema, + obj_dict, ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code @@ -518,16 +519,32 @@ def add_hello_world(config): return config -def _theme_schema(value): +@functools.cache +def _build_theme_schema( + widget_types: tuple[tuple[str, widgets.WidgetType], ...], +) -> cv.Schema: + # The theme schema is value-independent: it depends only on the set of + # registered widget types. Key the cache on a snapshot of WIDGET_TYPES so + # that an external component registering a new widget after the first + # validation (legal per any_widget_schema's lazy-evaluation contract) + # produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache + # self-heals instead of stale-rejecting valid themes. See obj_dict() in + # schemas.py for why chained .extend() is avoided here. return cv.Schema( { cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean, **{ - cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) - for name, w in WIDGET_TYPES.items() + cv.Optional(name): cv.Schema( + {**obj_dict(w), **FULL_STYLE_SCHEMA.schema} + ) + for name, w in widget_types }, } - )(value) + ) + + +def _theme_schema(value: dict) -> dict: + return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value) FINAL_VALIDATE_SCHEMA = final_validation diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 7436581fb4..b901eb4b53 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -378,15 +378,33 @@ TRIGGER_EVENT_MAP = { } -def part_schema(parts): +def part_dict(parts: tuple[str, ...] | list[str]) -> dict[Any, Any]: + """ + Return the raw mapping used by part_schema, so callers can merge it into a + larger dict and avoid chained .extend() calls (each .extend() recompiles the + whole mapping, turning the build into O(N^2)). + + Invariant: the source schemas spread here (STATE_SCHEMA, FLAG_SCHEMA, the + nested STATE_SCHEMA values) must use the default extra=PREVENT_EXTRA and + required=False and must not register any add_extra/prepend_extra + validators. Reaching into .schema and rebuilding via cv.Schema(...) keeps + only the mapping; non-default extra/required and any _extra_schemas would + be silently dropped. + """ + return { + **STATE_SCHEMA.schema, + **FLAG_SCHEMA.schema, + **{cv.Optional(part): STATE_SCHEMA for part in parts}, + } + + +def part_schema(parts: tuple[str, ...] | list[str]) -> cv.Schema: """ Generate a schema for the various parts (e.g. main:, indicator:) of a widget type :param parts: The parts to include :return: The schema """ - return STATE_SCHEMA.extend(FLAG_SCHEMA).extend( - {cv.Optional(part): STATE_SCHEMA for part in parts} - ) + return cv.Schema(part_dict(parts)) def automation_schema(typ: LvType): @@ -462,6 +480,43 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): return schema +# Memoize obj_dict() the same way _OBJ_SCHEMA_CACHE memoizes obj_schema(). +# automation_schema(w.w_type) builds fresh Trigger.template(...) objects on +# every call, so without this cache _theme_schema pays that cost per widget +# per validation. Callers must treat the returned dict as immutable. The +# _theme_schema caller spreads it into a fresh dict, which is safe; the +# obj_schema caller passes it directly to cv.Schema(...) -- voluptuous stores +# the mapping by reference but never mutates it (.extend() copies first), so +# the alias is also safe today. Adding in-place mutation of obj_schema(w).schema +# would corrupt this cache. +_OBJ_DICT_CACHE: dict[int, tuple[WidgetType, dict[Any, Any]]] = {} + + +def obj_dict(widget_type: WidgetType) -> dict[Any, Any]: + """ + Return the raw mapping used by obj_schema, so callers can merge it into a + larger dict and avoid chained .extend() calls. + + Inherits the same source-schema invariant documented on part_dict: any + schema spread into this mapping must use the default extra=PREVENT_EXTRA + and required=False and must carry no add_extra/prepend_extra validators. + + The returned mapping is cached and must be treated as immutable by callers. + """ + cached = _OBJ_DICT_CACHE.get(id(widget_type)) + if cached is not None and cached[0] is widget_type: + return cached[1] + built = { + **part_dict(widget_type.parts), + **ALIGN_TO_SCHEMA, + **automation_schema(widget_type.w_type), + cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + cv.Optional(CONF_GROUP): cv.use_id(lv_group_t), + } + _OBJ_DICT_CACHE[id(widget_type)] = (widget_type, built) + return built + + # Widget types are module-level singletons populated at import time, so we # can cache compiled obj_schemas by widget_type identity for the lifetime of # the process. The strong reference in the value keeps the key (an id() @@ -469,7 +524,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): _OBJ_SCHEMA_CACHE: dict[int, tuple[WidgetType, cv.Schema]] = {} -def obj_schema(widget_type: WidgetType): +def obj_schema(widget_type: WidgetType) -> cv.Schema: """ Create a schema for a widget type itself i.e. no allowance for children :param widget_type: @@ -478,17 +533,7 @@ def obj_schema(widget_type: WidgetType): cached = _OBJ_SCHEMA_CACHE.get(id(widget_type)) if cached is not None and cached[0] is widget_type: return cached[1] - schema = ( - part_schema(widget_type.parts) - .extend(ALIGN_TO_SCHEMA) - .extend(automation_schema(widget_type.w_type)) - .extend( - { - cv.Optional(CONF_STATE): SET_STATE_SCHEMA, - cv.Optional(CONF_GROUP): cv.use_id(lv_group_t), - } - ) - ) + schema = cv.Schema(obj_dict(widget_type)) _OBJ_SCHEMA_CACHE[id(widget_type)] = (widget_type, schema) return schema diff --git a/tests/component_tests/lvgl/test_schema_dict_helpers.py b/tests/component_tests/lvgl/test_schema_dict_helpers.py new file mode 100644 index 0000000000..16714f54d7 --- /dev/null +++ b/tests/component_tests/lvgl/test_schema_dict_helpers.py @@ -0,0 +1,236 @@ +"""Tests for part_dict / obj_dict / part_schema / obj_schema mapping contracts. + +These guard the dict-merge refactor: the dict helpers must keep returning the +same logical mapping as the chained-extend version produced, and the +corresponding Schema(...) wrappers must accept and reject the same configs. +""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest +import voluptuous as vol + +from esphome import config_validation as cv +import esphome.components.lvgl +from esphome.components.lvgl import ( + _theme_schema, + defines as df, + schemas as lvgl_schemas, +) +from esphome.components.lvgl.schemas import ( + ALIGN_TO_SCHEMA, + FLAG_SCHEMA, + FULL_STYLE_SCHEMA, + STATE_SCHEMA, + STYLE_SCHEMA, + WIDGET_TYPES, + automation_schema, + obj_dict, + obj_schema, + part_dict, + part_schema, +) +from esphome.components.lvgl.types import LvType +from esphome.components.lvgl.widgets import WidgetType + + +@pytest.fixture(autouse=True) +def _clear_obj_dict_cache() -> Generator[None]: + cache = getattr(lvgl_schemas, "_OBJ_DICT_CACHE", None) + if cache is not None: + cache.clear() + # The lazily-built theme schema is cached on _build_theme_schema; clear it + # too so each test starts from a clean slate. + build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None) + if build_theme is not None and hasattr(build_theme, "cache_clear"): + build_theme.cache_clear() + yield + if cache is not None: + cache.clear() + if build_theme is not None and hasattr(build_theme, "cache_clear"): + build_theme.cache_clear() + + +def _marker_names(mapping) -> set[str]: + """Return the underlying string names of every voluptuous Marker key.""" + names: set[str] = set() + for key in mapping: + if isinstance(key, vol.Marker): + schema = key.schema + if isinstance(schema, str): + names.add(schema) + return names + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_part_dict_includes_state_flag_and_part_keys() -> None: + parts = ("indicator", "knob") + keys = _marker_names(part_dict(parts)) + + assert {"indicator", "knob"} <= keys + assert _marker_names(STATE_SCHEMA.schema) <= keys + assert _marker_names(FLAG_SCHEMA.schema) <= keys + + +def test_obj_dict_extends_part_dict_with_align_automation_state_group() -> None: + wt = _widget_type("obj") + part_keys = _marker_names(part_dict(wt.parts)) + obj_keys = _marker_names(obj_dict(wt)) + + assert part_keys <= obj_keys + assert _marker_names(ALIGN_TO_SCHEMA) <= obj_keys + assert _marker_names(automation_schema(wt.w_type)) <= obj_keys + assert {"state", "group"} <= obj_keys + + +def test_obj_dict_is_memoized_by_widget_type() -> None: + wt = _widget_type("obj") + first = obj_dict(wt) + second = obj_dict(wt) + assert first is second + # Different widget type, different dict. + assert obj_dict(_widget_type("label")) is not first + + +def test_part_schema_round_trips_known_state_and_part_settings() -> None: + schema = part_schema(("indicator",)) + out = schema( + { + "bg_color": 0x112233, + "checked": {"bg_color": 0x445566}, + "indicator": {"bg_color": 0x778899}, + } + ) + assert out["bg_color"] == 0x112233 + assert out["checked"]["bg_color"] == 0x445566 + assert out["indicator"]["bg_color"] == 0x778899 + + +def test_part_schema_rejects_unknown_part() -> None: + schema = part_schema(("indicator",)) + with pytest.raises(vol.Invalid): + schema({"definitely_not_a_part": {}}) + + +@pytest.mark.parametrize("name", sorted(WIDGET_TYPES)) +def test_obj_schema_accepts_empty_config_for_every_widget_type(name: str) -> None: + obj_schema(_widget_type(name))({}) + + +def test_obj_schema_accepts_align_to_and_state_group() -> None: + schema = obj_schema(_widget_type("obj")) + out = schema( + { + df.CONF_ALIGN_TO: { + "id": "some_other_widget", + df.CONF_ALIGN: "TOP_LEFT", + }, + "state": {"checked": True}, + } + ) + assert out[df.CONF_ALIGN_TO][df.CONF_ALIGN] == "LV_ALIGN_TOP_LEFT" + assert out["state"]["checked"] is True + + +def test_obj_schema_rejects_unknown_top_level_key() -> None: + with pytest.raises(vol.Invalid): + obj_schema(_widget_type("obj"))({"definitely_not_a_real_key": 1}) + + +def test_part_schema_returns_cv_schema_for_extend_callers() -> None: + schema = part_schema(("indicator",)) + extended = schema.extend({cv.Optional("extra_key"): cv.string}) + out = extended({"extra_key": "value", "bg_color": 0xAABBCC}) + assert out["extra_key"] == "value" + assert out["bg_color"] == 0xAABBCC + + +def test_obj_schema_returns_cv_schema_for_extend_callers() -> None: + schema = obj_schema(_widget_type("obj")) + extended = schema.extend({cv.Optional("extra_key"): cv.string}) + extended({"extra_key": "value"}) + + +@pytest.mark.parametrize( + "schema", + [STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA], +) +def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None: + # part_dict / obj_dict reach into .schema and rebuild via cv.Schema(...), + # which silently drops _extra_schemas and any non-default extra/required. + # Lock the invariant so a future add_extra() on these sources fails CI + # instead of quietly removing validation from part/obj/theme schemas. + assert not schema._extra_schemas + assert schema.extra is vol.PREVENT_EXTRA + assert schema.required is False + + +def test_theme_schema_merges_obj_dict_and_full_style_props() -> None: + # _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema + # share many STYLE_SCHEMA marker instances. Exercise the merged schema + # end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict + # and a FULL_STYLE-only property) to lock the behaviour against future + # regressions in either source. + out = _theme_schema( + { + df.CONF_DARK_MODE: True, + "obj": { + "bg_color": 0x112233, + "checked": {"bg_color": 0x445566}, + df.CONF_PAD_ROW: 4, + df.CONF_GRID_CELL_X_ALIGN: "CENTER", + }, + } + ) + assert out[df.CONF_DARK_MODE] is True + obj_out = out["obj"] + assert obj_out["bg_color"] == 0x112233 + assert obj_out["checked"]["bg_color"] == 0x445566 + assert obj_out[df.CONF_PAD_ROW] == 4 + assert obj_out[df.CONF_GRID_CELL_X_ALIGN] == "LV_GRID_ALIGN_CENTER" + + +def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> None: + # _build_theme_schema is functools.cached on a snapshot of WIDGET_TYPES. + # any_widget_schema explicitly supports external components registering + # widgets lazily, and the device builder revalidates in-process, so a + # widget registered after first use must invalidate the cached snapshot. + _theme_schema({df.CONF_DARK_MODE: True}) # populate the cache + + name = "test_self_heal_widget" + assert name not in WIDGET_TYPES + # is_mock=True skips registration side-effects; insert into WIDGET_TYPES + # manually so the next theme call sees the new entry. + WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True) + try: + out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}}) + assert out[name]["bg_color"] == 0x010203 + finally: + WIDGET_TYPES.pop(name, None) + + +@pytest.mark.parametrize( + "schema", + [STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA], +) +def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None: + # _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key + # collision, dict-spread keeps the first source's marker (and its default) + # but the last source's value, whereas .extend() would take both from the + # later source. The two are equivalent today because the overlapping + # markers are the same instances (both derive from STYLE_SCHEMA) and none + # carry a top-level default. Lock that so a future divergent default would + # fail CI rather than silently drift the merged validation. + offenders = [ + marker.schema + for marker in schema.schema + if isinstance(marker, vol.Optional) and marker.default is not vol.UNDEFINED + ] + assert not offenders, f"top-level Optional with default: {offenders}" From cf1fabe6d4d4e14936ce496fc4445a04ca62654f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:11:31 -0400 Subject: [PATCH 1420/2214] [esp32_hosted] Bump esp_hosted to 2.12.8 and add use_psram option (#16627) --- esphome/components/esp32_hosted/__init__.py | 10 +++++++++- esphome/idf_component.yml | 2 +- tests/components/esp32_hosted/common.yaml | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 71d1fd3ac1..94e20ea6c9 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 +from esphome.components.const import CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -39,6 +40,7 @@ BASE_SCHEMA = cv.Schema( cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True), cv.Required(CONF_ACTIVE_HIGH): cv.boolean, cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean, } ) @@ -242,6 +244,12 @@ async def to_code(config): else: _configure_spi(config) + # Place the transport mempool in PSRAM. Required on memory-tight host + # configurations (e.g. P4 with a large LVGL UI) where the internal-RAM + # mempool allocation fails at boot with `sdio_mempool_create` assert. + if config[CONF_USE_PSRAM]: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) + # Library versions idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" @@ -249,7 +257,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6bc166ff44..5af25fc351 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -36,7 +36,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.7 + version: 2.12.8 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: diff --git a/tests/components/esp32_hosted/common.yaml b/tests/components/esp32_hosted/common.yaml index ab029e5064..332fe5b070 100644 --- a/tests/components/esp32_hosted/common.yaml +++ b/tests/components/esp32_hosted/common.yaml @@ -3,6 +3,7 @@ esp32_hosted: slot: 1 active_high: true reset_pin: GPIO15 + use_psram: true cmd_pin: GPIO13 clk_pin: GPIO12 d0_pin: GPIO11 From 7c494fd3efcc9fc1c1dce0d6fc09d47a44affdfd Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 10:15:51 -0400 Subject: [PATCH 1421/2214] [psram] Consolidate task stack in PSRAM handling (#16628) --- .../audio_file/media_source/__init__.py | 16 +++----------- esphome/components/audio_http/media_source.py | 18 +++------------ esphome/components/mixer/speaker/__init__.py | 13 +++++------ esphome/components/psram/__init__.py | 22 +++++++++++++++++++ .../components/resampler/speaker/__init__.py | 8 +++---- esphome/components/sendspin/__init__.py | 17 +++----------- .../sendspin/media_source/__init__.py | 5 ++--- .../speaker/media_player/__init__.py | 9 ++------ .../audio_file/validate.esp32-idf.yaml | 11 ++++++++++ 9 files changed, 54 insertions(+), 65 deletions(-) create mode 100644 tests/components/audio_file/validate.esp32-idf.yaml diff --git a/esphome/components/audio_file/media_source/__init__.py b/esphome/components/audio_file/media_source/__init__.py index 635a51b610..0710582813 100644 --- a/esphome/components/audio_file/media_source/__init__.py +++ b/esphome/components/audio_file/media_source/__init__.py @@ -1,7 +1,5 @@ -from typing import Any - import esphome.codegen as cg -from esphome.components import audio, esp32, media_source, psram +from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM from esphome.types import ConfigType @@ -21,19 +19,13 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config -def _validate_task_stack_in_psram(value: Any) -> bool: - if value := cv.boolean(value): - return cv.requires_component(psram.DOMAIN)(value) - return value - - CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioFileMediaSource, ) .extend( { - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ) .extend(cv.COMPONENT_SCHEMA), @@ -49,6 +41,4 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py index 519d8df698..e8acbc81af 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -1,7 +1,5 @@ -from typing import Any - import esphome.codegen as cg -from esphome.components import audio, esp32, media_source, psram +from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM from esphome.types import ConfigType @@ -20,14 +18,6 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config -def _validate_task_stack_in_psram(value: Any) -> bool: - # Only require the psram component when actually enabling PSRAM stacks; validating - # the boolean first means `false` doesn't trigger the requires_component check. - if value := cv.boolean(value): - return cv.requires_component(psram.DOMAIN)(value) - return value - - CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioHTTPMediaSource, @@ -37,7 +27,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range( min=5000, max=1000000 ), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ) .extend(cv.COMPONENT_SCHEMA), @@ -53,7 +43,5 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 8501843d3f..47164a9997 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import audio, esp32, speaker +from esphome.components import audio, psram, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -93,7 +93,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BITS_PER_SAMPLE): cv.one_of(8, 16, 24, 32, int=True), cv.Optional(CONF_NUM_CHANNELS): cv.int_range(min=1, max=2), cv.Optional(CONF_QUEUE_MODE, default=False): cv.boolean, - cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ), cv.only_on([PLATFORM_ESP32]), @@ -123,12 +123,9 @@ async def to_code(config): cg.add(var.set_output_speaker(spkr)) cg.add(var.set_queue_mode(config[CONF_QUEUE_MODE])) - if task_stack_in_psram := config.get(CONF_TASK_STACK_IN_PSRAM): - cg.add(var.set_task_stack_in_psram(task_stack_in_psram)) - if task_stack_in_psram and config[CONF_TASK_STACK_IN_PSRAM]: - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + psram.request_external_task_stack() # Initialize FixedVector with exact count of source speakers cg.add(var.init_source_speakers(len(config[CONF_SOURCE_SPEAKERS]))) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 86c17ce9ca..d36d900997 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -1,5 +1,6 @@ import logging import textwrap +from typing import Any import esphome.codegen as cg from esphome.components.const import CONF_IGNORE_NOT_FOUND @@ -94,6 +95,27 @@ def is_guaranteed() -> bool: return CORE.data.get(KEY_PSRAM_GUARANTEED, False) +def request_external_task_stack() -> None: + """Allow FreeRTOS task stacks to be allocated in external RAM (PSRAM). + + Components that expose a ``task_stack_in_psram`` option should call this from their + ``to_code`` when the option is enabled. The sdkconfig option only permits external + stacks; it does not move any stack into PSRAM on its own, so it stays opt-in per task. + """ + add_idf_sdkconfig_option("CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True) + + +def validate_task_stack_in_psram(value: Any) -> bool: + """Validate a ``task_stack_in_psram`` boolean, requiring the psram component only when enabled. + + Validating the boolean first means an explicit ``false`` does not pull in the psram + requirement, so the option can still be set to false on devices without PSRAM. + """ + if value := cv.boolean(value): + return cv.requires_component(DOMAIN)(value) + return value + + def validate_psram_mode(config): esp32_config = fv.full_config.get()[PLATFORM_ESP32] if config[CONF_SPEED] == "120MHZ": diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 3134cf7646..8a13110631 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import audio, esp32, speaker +from esphome.components import audio, psram, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_BUFFER_DURATION, default="100ms" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_FILTERS, default=16): cv.int_range(min=2, max=1024), cv.Optional(CONF_TAPS, default=16): _validate_taps, } @@ -88,9 +88,7 @@ async def to_code(config): if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_target_sample_rate(config[CONF_SAMPLE_RATE])) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index b670bd3c4d..e8c643f9b9 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -121,13 +121,6 @@ def register_player_config(config: ConfigType) -> None: data.player_config = config -def _validate_task_stack_in_psram(value): - value = cv.boolean(value) - if value: - return cv.requires_component(psram.DOMAIN)(value) - return value - - def _request_high_performance_networking(config: ConfigType) -> ConfigType: """Request high performance networking for Sendspin streaming. @@ -152,7 +145,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(SendspinHub), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ), cv.only_on_esp32, @@ -201,9 +194,7 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() # sendspin-cpp library esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") @@ -261,9 +252,7 @@ async def to_code(config: ConfigType) -> None: psram_stack = player_cfg.get(CONF_TASK_STACK_IN_PSRAM, False) if psram_stack: - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() # Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not # starved by the HTTP server during the initial encoded-audio burst at stream start), diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index f689ab01cb..6af244d41f 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import media_source +from esphome.components import media_source, psram import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -19,7 +19,6 @@ from .. import ( CONF_SENDSPIN_ID, MEMORY_LOCATIONS, SendspinHub, - _validate_task_stack_in_psram, register_player_config, request_controller_support, sendspin_ns, @@ -71,7 +70,7 @@ CONFIG_SCHEMA = cv.All( ).extend( { cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(min=25000), cv.Optional(CONF_INITIAL_STATIC_DELAY, default="0ms"): cv.All( cv.positive_time_period_milliseconds, diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 094043c292..90eb19d73d 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -7,7 +7,6 @@ import esphome.codegen as cg from esphome.components import ( audio, audio_file, - esp32, media_player, network, ota, @@ -155,9 +154,7 @@ CONFIG_SCHEMA = cv.All( # Remove before 2026.10.0 cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), cv.Optional(CONF_FILES): audio_file.audio_files_schema(), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( - cv.boolean, cv.requires_component(psram.DOMAIN) - ), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage, cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage, @@ -198,9 +195,7 @@ async def to_code(config): if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT])) cg.add(var.set_volume_initial(config[CONF_VOLUME_INITIAL])) diff --git a/tests/components/audio_file/validate.esp32-idf.yaml b/tests/components/audio_file/validate.esp32-idf.yaml new file mode 100644 index 0000000000..085f853c8e --- /dev/null +++ b/tests/components/audio_file/validate.esp32-idf.yaml @@ -0,0 +1,11 @@ +audio_file: + - id: test_audio + file: + type: local + path: $component_dir/test.wav + +media_source: + - platform: audio_file + id: audio_file_source + # task_stack_in_psram: false must validate without a psram: component + task_stack_in_psram: false From 684bce8b9a588a0a939673298158b3b9380dc633 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:36:41 -0400 Subject: [PATCH 1422/2214] [esp32] Decode crash PCs via IDF toolchain on IDF builds (#16626) --- esphome/components/esp32/__init__.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a06ae89c3e..e3bff8f934 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -46,7 +46,7 @@ from esphome.const import ( Toolchain, __version__, ) -from esphome.core import CORE, HexInt, Library +from esphome.core import CORE, EsphomeError, HexInt, Library from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_component @@ -2658,13 +2658,29 @@ def copy_files(): def _decode_pc(config, addr): - from esphome.platformio import toolchain + # _decode_pc runs from the api log processor's asyncio callback, which + # only catches EsphomeError. Any other exception escaping here tears down + # the protocol and triggers an infinite reconnect/replay loop. Convert + # toolchain-resolution errors (e.g. missing build dir / cmake cache) into + # EsphomeError so the caller can disable decoding cleanly. + if CORE.using_toolchain_esp_idf: + from esphome.espidf import toolchain as idf_toolchain - idedata = toolchain.get_idedata(config) - if not idedata.addr2line_path or not idedata.firmware_elf_path: + try: + addr2line_path = idf_toolchain.get_addr2line_path() + firmware_elf_path = idf_toolchain.get_elf_path() + except RuntimeError as err: + raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err + else: + from esphome.platformio import toolchain + + idedata = toolchain.get_idedata(config) + addr2line_path = idedata.addr2line_path + firmware_elf_path = idedata.firmware_elf_path + if not addr2line_path or not firmware_elf_path: _LOGGER.debug("decode_pc no addr2line") return - command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() except Exception: # pylint: disable=broad-except From 1c7ae96e424cadc00bbc9b778fbb2327b8103020 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 11:04:26 -0400 Subject: [PATCH 1423/2214] [micro_wake_word] Allow task stack to be allocated in PSRAM (#16632) --- .../components/micro_wake_word/__init__.py | 8 ++++++- .../micro_wake_word/micro_wake_word.cpp | 24 +++++++------------ .../micro_wake_word/micro_wake_word.h | 8 ++++++- tests/components/micro_wake_word/common.yaml | 4 ++++ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 38926fce99..61d296cbba 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin from esphome import automation, external_files, git from esphome.automation import register_action, register_condition import esphome.codegen as cg -from esphome.components import esp32, microphone, ota +from esphome.components import esp32, microphone, ota, psram import esphome.config_validation as cv from esphome.const import ( CONF_FILE, @@ -20,6 +20,7 @@ from esphome.const import ( CONF_RAW_DATA_ID, CONF_REF, CONF_REFRESH, + CONF_TASK_STACK_IN_PSRAM, CONF_TYPE, CONF_URL, CONF_USERNAME, @@ -358,6 +359,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." ), @@ -451,6 +453,10 @@ async def to_code(config): cg.add_define("USE_MICRO_WAKE_WORD") ota.request_ota_state_listeners() + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + psram.request_external_task_stack() + esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1") # Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn) esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2") diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 739d64dc28..237d72229d 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -217,10 +217,7 @@ void MicroWakeWord::inference_task(void *params) { FrontendFreeStateContents(&this_mww->frontend_state_); xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_STOPPED); - while (true) { - // Continuously delay until the main loop deletes the task - delay(10); - } + vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } std::vector MicroWakeWord::get_wake_words() { @@ -243,14 +240,14 @@ void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probabilit #endif void MicroWakeWord::suspend_task_() { - if (this->inference_task_handle_ != nullptr) { - vTaskSuspend(this->inference_task_handle_); + if (this->inference_task_.is_created()) { + vTaskSuspend(this->inference_task_.get_handle()); } } void MicroWakeWord::resume_task_() { - if (this->inference_task_handle_ != nullptr) { - vTaskResume(this->inference_task_handle_); + if (this->inference_task_.is_created()) { + vTaskResume(this->inference_task_.get_handle()); } } @@ -292,8 +289,7 @@ void MicroWakeWord::loop() { if ((event_group_bits & EventGroupBits::TASK_STOPPED)) { ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); - vTaskDelete(this->inference_task_handle_); - this->inference_task_handle_ = nullptr; + this->inference_task_.deallocate(); xEventGroupClearBits(this->event_group_, ALL_BITS); xQueueReset(this->detection_queue_); this->set_state_(State::STOPPED); @@ -311,7 +307,7 @@ void MicroWakeWord::loop() { switch (this->state_) { case State::STARTING: - if ((this->inference_task_handle_ == nullptr) && !this->status_has_error()) { + if (!this->inference_task_.is_created() && !this->status_has_error()) { // Setup preprocesor feature generator. If done in the task, it would lock the task to its initial core, as it // uses floating point operations. if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_, @@ -320,10 +316,8 @@ void MicroWakeWord::loop() { return; } - xTaskCreate(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, (void *) this, - INFERENCE_TASK_PRIORITY, &this->inference_task_handle_); - - if (this->inference_task_handle_ == nullptr) { + if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, + (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state this->status_momentary_error("task_start", 1000); } diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index ef440b5d37..e4c590a423 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -11,6 +11,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/static_task.h" #ifdef USE_OTA_STATE_LISTENER #include "esphome/components/ota/ota_backend.h" @@ -59,6 +60,8 @@ class MicroWakeWord : public Component void set_stop_after_detection(bool stop_after_detection) { this->stop_after_detection_ = stop_after_detection; } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + Trigger *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; } void add_wake_word_model(WakeWordModel *model); @@ -93,6 +96,8 @@ class MicroWakeWord : public Component bool stop_after_detection_; + bool task_stack_in_psram_{false}; + uint8_t features_step_size_; // Audio frontend handles generating spectrogram features @@ -105,8 +110,9 @@ class MicroWakeWord : public Component // Used to send messages about the models' states to the main loop QueueHandle_t detection_queue_; + StaticTask inference_task_; + static void inference_task(void *params); - TaskHandle_t inference_task_handle_{nullptr}; /// @brief Suspends the inference task void suspend_task_(); diff --git a/tests/components/micro_wake_word/common.yaml b/tests/components/micro_wake_word/common.yaml index c051c8dd57..cd060c176e 100644 --- a/tests/components/micro_wake_word/common.yaml +++ b/tests/components/micro_wake_word/common.yaml @@ -1,3 +1,6 @@ +psram: + mode: quad + i2s_audio: i2s_lrclk_pin: GPIO18 i2s_bclk_pin: GPIO19 @@ -12,6 +15,7 @@ microphone: micro_wake_word: microphone: echo_microphone + task_stack_in_psram: true on_wake_word_detected: - logger.log: "Wake word detected" - micro_wake_word.stop: From 892e116680a0c8063e7d1ead63b74ed7748c89f9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 12:42:49 -0400 Subject: [PATCH 1424/2214] [router] Add a router speaker component to runtime choose output speaker (#16592) --- CODEOWNERS | 1 + esphome/components/router/__init__.py | 0 esphome/components/router/speaker/__init__.py | 123 +++++++++ .../router/speaker/router_speaker.cpp | 236 ++++++++++++++++++ .../router/speaker/router_speaker.h | 92 +++++++ tests/components/router/common.yaml | 44 ++++ tests/components/router/test.esp32-idf.yaml | 7 + 7 files changed, 503 insertions(+) create mode 100644 esphome/components/router/__init__.py create mode 100644 esphome/components/router/speaker/__init__.py create mode 100644 esphome/components/router/speaker/router_speaker.cpp create mode 100644 esphome/components/router/speaker/router_speaker.h create mode 100644 tests/components/router/common.yaml create mode 100644 tests/components/router/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f8cdfdc6c6..3c3e502058 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -417,6 +417,7 @@ esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/ring_buffer/* @kahrendt +esphome/components/router/speaker/* @kahrendt esphome/components/rp2040/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan diff --git a/esphome/components/router/__init__.py b/esphome/components/router/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py new file mode 100644 index 0000000000..2b2dc56433 --- /dev/null +++ b/esphome/components/router/speaker/__init__.py @@ -0,0 +1,123 @@ +from esphome import automation, core +import esphome.codegen as cg +from esphome.components import audio, speaker +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_ID, + CONF_NUM_CHANNELS, + CONF_OUTPUT_SPEAKER, + CONF_SAMPLE_RATE, +) +from esphome.core import ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@kahrendt"] + +CONF_OUTPUT_SPEAKERS = "output_speakers" +CONF_TARGET_SPEAKER = "target_speaker" + +router_ns = cg.esphome_ns.namespace("router") +Router = router_ns.class_("Router", cg.Component, speaker.Speaker) +SwitchOutputAction = router_ns.class_("SwitchOutputAction", automation.Action) + +SpeakerPtr = speaker.Speaker.operator("ptr") + + +def _set_stream_limits(config: ConfigType) -> ConfigType: + # Lock the router's stream limits to the user-declared format. Limits are set + # at CONFIG_SCHEMA time so they're visible to other components' FINAL_VALIDATE + # (which has no guaranteed ordering vs. ours). + audio.set_stream_limits( + min_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + min_channels=config[CONF_NUM_CHANNELS], + max_channels=config[CONF_NUM_CHANNELS], + min_sample_rate=config[CONF_SAMPLE_RATE], + max_sample_rate=config[CONF_SAMPLE_RATE], + )(config) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Router), + cv.Required(CONF_OUTPUT_SPEAKERS): cv.All( + cv.ensure_list(cv.use_id(speaker.Speaker)), + cv.Length(min=2, max=8), + ), + # All outputs must agree on a single format so the producer can keep + # streaming through a switch without reconfiguring. These are required + # rather than inherited because downstream components (e.g. mixer) + # read them from the router's declaration during FINAL_VALIDATE, + # which can't depend on our FINAL_VALIDATE running first. + cv.Required(CONF_BITS_PER_SAMPLE): cv.int_range(8, 32), + cv.Required(CONF_NUM_CHANNELS): cv.int_range(1, 2), + cv.Required(CONF_SAMPLE_RATE): cv.int_range(8000, 96000), + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, + _set_stream_limits, +) + + +def _final_validate(config: ConfigType) -> ConfigType: + # 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. + for spk_id in config[CONF_OUTPUT_SPEAKERS]: + proxy = {**config, CONF_OUTPUT_SPEAKER: spk_id} + audio.final_validate_audio_schema( + "router", + audio_device=CONF_OUTPUT_SPEAKER, + bits_per_sample=config[CONF_BITS_PER_SAMPLE], + channels=config[CONF_NUM_CHANNELS], + sample_rate=config[CONF_SAMPLE_RATE], + )(proxy) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + # The first configured output is the default active output on boot. + speakers = config[CONF_OUTPUT_SPEAKERS] + cg.add(var.set_output_count(len(speakers))) + for spk_id in speakers: + spk = await cg.get_variable(spk_id) + cg.add(var.add_output(spk)) + + +@automation.register_action( + "router.speaker.switch_output", + SwitchOutputAction, + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(Router), + cv.Required(CONF_TARGET_SPEAKER): cv.templatable( + cv.use_id(speaker.Speaker) + ), + } + ), + synchronous=True, +) +async def switch_output_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) + target = config[CONF_TARGET_SPEAKER] + if not isinstance(target, core.Lambda): + target = await cg.get_variable(target) + template_ = await cg.templatable(target, args, SpeakerPtr) + cg.add(var.set_target(template_)) + return var diff --git a/esphome/components/router/speaker/router_speaker.cpp b/esphome/components/router/speaker/router_speaker.cpp new file mode 100644 index 0000000000..f4bf7420ab --- /dev/null +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -0,0 +1,236 @@ +#include "router_speaker.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include "esp_timer.h" + +#include + +namespace esphome::router { + +static const char *const TAG = "router.speaker"; + +static inline uint32_t atomic_subtract_clamped(std::atomic &var, uint32_t amount) { + uint32_t current = var.load(std::memory_order_acquire); + uint32_t subtracted = 0; + if (current > 0) { + uint32_t new_value; + do { + subtracted = std::min(amount, current); + new_value = current - subtracted; + } while (!var.compare_exchange_weak(current, new_value, std::memory_order_release, std::memory_order_acquire)); + } + return subtracted; +} + +void Router::setup() { + // Register a callback on every configured output. Each lambda captures its own + // index and only forwards when that output is the active one. This is required + // because CallbackManager has no remove() API. + for (size_t i = 0; i < this->outputs_.size(); i++) { + this->outputs_[i]->add_audio_output_callback([this, i](uint32_t frames, int64_t timestamp_us) { + // Always suppress the draining previous output during a switch, even if it's + // also the reselected active output (switching back to the bus holder). + // loop() fires one synthetic credit for its in-flight frames instead. + if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) == static_cast(i)) { + return; + } + if (this->active_output_idx_.load(std::memory_order_relaxed) != static_cast(i)) { + return; + } + atomic_subtract_clamped(this->frames_in_pipeline_, frames); + this->audio_output_callback_.call(frames, timestamp_us); + }); + } +} + +void Router::loop() { + speaker::Speaker *active = this->get_active_output(); + + // Mid-switch: the new output's start() is deferred until the previous output + // fully releases shared hardware (e.g. a single i2s_audio bus driving two + // speakers). Starting earlier produces "Parent bus is busy" retries. The + // synthetic-credit callback is also deferred until prev is fully stopped, so + // that once its task has drained no natural callbacks can race ours. + const int8_t pending_prev_idx = this->pending_start_prev_idx_.load(std::memory_order_relaxed); + if (pending_prev_idx >= 0) { + speaker::Speaker *prev = this->outputs_[pending_prev_idx]; + if (prev->is_stopped()) { + this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed); + + // Credit any frames left in prev's ring buffer / DMA so producer frame + // accounting (SpeakerSourceMediaPlayer pending_frames, sendspin/AEC + // clocks) clears cleanly. The leftover audio is intentionally dropped and + // the producer is told it played "now", giving a clean discontinuity that + // keeps frame accounting consistent across the switch. + const uint32_t in_flight = this->frames_in_pipeline_.exchange(0, std::memory_order_acq_rel); + if (in_flight > 0) { + this->audio_output_callback_.call(in_flight, esp_timer_get_time()); + } + + this->apply_cached_state_to_active_(); + this->state_ = speaker::STATE_STARTING; + active->start(); + } + return; + } + + // Mirror the active output's running/stopped state into our own state_ so that + // is_running() / is_stopped() stay accurate from the producer's perspective. + // Also catch the active output self-stopping (e.g. i2s_audio silence timeout): + // without this, our state_ would stay RUNNING forever and the next play() would + // skip start(). The output retains its own volume/mute across a restart (and we + // forward those live regardless), but stream info arrives via the non-virtual + // set_audio_stream_info() and never reaches the output on its own; if the format + // changed while stopped, only start()'s apply_cached_state_to_active_() pushes it + // down before the output's play()-side auto-start locks in the stale format. + if (active->is_stopped()) { + this->state_ = speaker::STATE_STOPPED; + } else if (this->state_ == speaker::STATE_STARTING && active->is_running()) { + this->state_ = speaker::STATE_RUNNING; + } +} + +void Router::dump_config() { + ESP_LOGCONFIG(TAG, + "Router Speaker:\n" + " Outputs: %u", + static_cast(this->outputs_.size())); +} + +size_t Router::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { + speaker::Speaker *active = this->get_active_output(); + + // Drop frames during a mid-switch until the old output releases shared hardware; + // forwarding now would trigger the new output's play()-side auto-start while + // the bus is still busy. + if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) >= 0) { + vTaskDelay(ticks_to_wait); + return 0; + } + + // Producers (e.g. mixer) set stream info on us and then drive play() from a + // task without ever calling our start(). i2s_audio's play() auto-starts the + // underlying driver, so we must push our cached stream info to the active + // output before that auto-start, or it locks to its default (16k mono). + if (this->state_ == speaker::STATE_STOPPED) { + this->start(); + vTaskDelay(ticks_to_wait); + ticks_to_wait = 0; + } + + size_t written = active->play(data, length, ticks_to_wait); + if (written > 0) { + const uint32_t frames = this->audio_stream_info_.bytes_to_frames(written); + this->frames_in_pipeline_.fetch_add(frames, std::memory_order_release); + } + return written; +} + +void Router::start() { + this->frames_in_pipeline_.store(0, std::memory_order_release); + this->apply_cached_state_to_active_(); + this->state_ = speaker::STATE_STARTING; + this->get_active_output()->start(); +} + +void Router::stop() { + // Cancel any pending mid-switch start; the producer wants us stopped. + this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed); + this->state_ = speaker::STATE_STOPPING; + this->get_active_output()->stop(); +} + +void Router::finish() { + this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed); + this->state_ = speaker::STATE_STOPPING; + this->get_active_output()->finish(); +} + +bool Router::has_buffered_data() const { return this->get_active_output()->has_buffered_data(); } + +void Router::set_pause_state(bool pause_state) { + this->cached_pause_ = pause_state; + this->get_active_output()->set_pause_state(pause_state); +} + +void Router::set_volume(float volume) { + this->volume_ = volume; + this->get_active_output()->set_volume(volume); +} + +void Router::set_mute_state(bool mute_state) { + this->mute_state_ = mute_state; + this->get_active_output()->set_mute_state(mute_state); +} + +bool Router::switch_to_output(speaker::Speaker *target) { + if (target == nullptr) { + return false; + } + + int8_t new_idx = -1; + for (size_t i = 0; i < this->outputs_.size(); i++) { + if (this->outputs_[i] == target) { + new_idx = static_cast(i); + break; + } + } + if (new_idx < 0) { + ESP_LOGW(TAG, "Switch target is not a configured output"); + return false; + } + if (new_idx == this->active_output_idx_.load(std::memory_order_relaxed)) { + return true; + } + + // A switch is already in flight: pending_start_prev_idx_ is still releasing the + // shared bus and the current active output's start() is still deferred (it never + // started). Just redirect which output we start once the bus frees. Leave the bus + // holder (pending_start_prev_idx_), the in-flight frame counter (loop() still owes one + // synthetic credit for the bus holder's in-flight frames), and state_ alone, and + // don't stop the current active output, which never started. + if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) >= 0) { + this->active_output_idx_.store(new_idx, std::memory_order_relaxed); + return true; + } + + const bool was_active = (this->state_ == speaker::STATE_STARTING || this->state_ == speaker::STATE_RUNNING); + const int8_t old_idx = this->active_output_idx_.load(std::memory_order_relaxed); + + if (was_active) { + this->outputs_[old_idx]->stop(); + } + + this->active_output_idx_.store(new_idx, std::memory_order_relaxed); + + if (was_active) { + // Defer start and the synthetic-credit callback until the old output's + // task is fully stopped; loop() handles both. Firing the synthetic credit + // here would race the old task's still-in-flight natural callbacks, + // dispatching audio_output_callback_ concurrently from two threads, which + // some consumers (e.g. sendspin's progress sync) aren't reentrant-safe for. + // STATE_STOPPING keeps producers from observing a transient stopped state + // and lets our play() short-circuit so the new output's play() doesn't + // auto-start it while the shared bus is still being released. + this->state_ = speaker::STATE_STOPPING; + this->pending_start_prev_idx_.store(old_idx, std::memory_order_relaxed); + } else { + this->frames_in_pipeline_.store(0, std::memory_order_release); + } + return true; +} + +void Router::apply_cached_state_to_active_() { + speaker::Speaker *active = this->get_active_output(); + active->set_audio_stream_info(this->audio_stream_info_); + active->set_volume(this->volume_); + active->set_mute_state(this->mute_state_); + active->set_pause_state(this->cached_pause_); +} + +} // namespace esphome::router + +#endif // USE_ESP32 diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h new file mode 100644 index 0000000000..13b58a1c72 --- /dev/null +++ b/esphome/components/router/speaker/router_speaker.h @@ -0,0 +1,92 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/components/speaker/speaker.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +#include + +namespace esphome::router { + +class Router : public Component, public speaker::Speaker { + public: + float get_setup_priority() const override { return setup_priority::DATA; } + + void setup() override; + void loop() override; + void dump_config() override; + + size_t play(const uint8_t *data, size_t length) override { return this->play(data, length, 0); } + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + + void start() override; + void stop() override; + void finish() override; + + bool has_buffered_data() const override; + + void set_pause_state(bool pause_state) override; + bool get_pause_state() const override { return this->cached_pause_; } + + void set_volume(float volume) override; + float get_volume() override { return this->volume_; } + + void set_mute_state(bool mute_state) override; + bool get_mute_state() override { return this->mute_state_; } + + // Allocate the output list to its final size. Must be called before add_output(). + void set_output_count(size_t count) { this->outputs_.init(count); } + void add_output(speaker::Speaker *spk) { this->outputs_.push_back(spk); } + + /// Switch the active output to the given speaker. Must be one of the configured outputs. + /// Returns false if `target` is not in the output list. + bool switch_to_output(speaker::Speaker *target); + + // Always valid: active_output_idx_ stays within [0, outputs_.size()) and at least + // two outputs are required (validated in Python), so this never returns null. + speaker::Speaker *get_active_output() const { + return this->outputs_[this->active_output_idx_.load(std::memory_order_relaxed)]; + } + + protected: + // Frames written to the active output but not yet played: incremented in play() and decremented + // (clamped at zero) by the active output's audio_output_callback. Mirrors mixer_speaker's + // frames_in_pipeline_. + std::atomic frames_in_pipeline_{0}; + + bool cached_pause_{false}; + + void apply_cached_state_to_active_(); + + // Index of the previously-active output we're waiting on to fully stop before + // starting the new one. -1 means no pending start. Set by switch_to_output() + // when switching mid-playback; cleared by loop() once the old output reports + // is_stopped(). Required because shared-bus drivers (e.g. two i2s_audio + // speakers on one i2s_bus) reject start() until the previous user releases. + std::atomic pending_start_prev_idx_{-1}; + + private: + FixedVector outputs_; + // Index into outputs_, always within [0, outputs_.size()). Defaults to the first + // configured output; updated by switch_to_output(). + std::atomic active_output_idx_{0}; +}; + +template class SwitchOutputAction : public Action { + public: + explicit SwitchOutputAction(Router *parent) : parent_(parent) {} + TEMPLATABLE_VALUE(speaker::Speaker *, target) + void play(const Ts &...x) override { this->parent_->switch_to_output(this->target_.value(x...)); } + + protected: + Router *parent_; +}; + +} // namespace esphome::router + +#endif // USE_ESP32 diff --git a/tests/components/router/common.yaml b/tests/components/router/common.yaml new file mode 100644 index 0000000000..360c6daaee --- /dev/null +++ b/tests/components/router/common.yaml @@ -0,0 +1,44 @@ +esphome: + on_boot: + then: + - router.speaker.switch_output: + id: router_id + target_speaker: speaker_b_id + # id omitted: auto-resolved since there's a single router instance + - router.speaker.switch_output: + target_speaker: !lambda return id(speaker_a_id); + +i2s_audio: + - id: i2s_a + i2s_lrclk_pin: ${a_lrclk_pin} + i2s_bclk_pin: ${a_bclk_pin} + - id: i2s_b + +speaker: + - platform: i2s_audio + id: speaker_a_id + i2s_audio_id: i2s_a + dac_type: external + i2s_dout_pin: ${a_dout_pin} + sample_rate: 48000 + bits_per_sample: 16bit + channel: stereo + - platform: i2s_audio + id: speaker_b_id + i2s_audio_id: i2s_b + dac_type: external + i2s_dout_pin: ${b_dout_pin} + spdif_mode: true + use_apll: true + sample_rate: 48000 + bits_per_sample: 16bit + channel: stereo + i2s_mode: primary + - platform: router + id: router_id + output_speakers: + - speaker_a_id + - speaker_b_id + sample_rate: 48000 + bits_per_sample: 16 + num_channels: 2 diff --git a/tests/components/router/test.esp32-idf.yaml b/tests/components/router/test.esp32-idf.yaml new file mode 100644 index 0000000000..241a9a8903 --- /dev/null +++ b/tests/components/router/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + a_lrclk_pin: GPIO4 + a_bclk_pin: GPIO5 + a_dout_pin: GPIO14 + b_dout_pin: GPIO19 + +<<: !include common.yaml From dcc30f865105587c26a85fc2cf29a4212726c86e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 15:39:54 -0400 Subject: [PATCH 1425/2214] [router] Share a single I2S bus in test (#16637) --- tests/components/router/common.yaml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/components/router/common.yaml b/tests/components/router/common.yaml index 360c6daaee..f1239de3cb 100644 --- a/tests/components/router/common.yaml +++ b/tests/components/router/common.yaml @@ -9,15 +9,12 @@ esphome: target_speaker: !lambda return id(speaker_a_id); i2s_audio: - - id: i2s_a - i2s_lrclk_pin: ${a_lrclk_pin} - i2s_bclk_pin: ${a_bclk_pin} - - id: i2s_b + i2s_lrclk_pin: ${a_lrclk_pin} + i2s_bclk_pin: ${a_bclk_pin} speaker: - platform: i2s_audio id: speaker_a_id - i2s_audio_id: i2s_a dac_type: external i2s_dout_pin: ${a_dout_pin} sample_rate: 48000 @@ -25,7 +22,6 @@ speaker: channel: stereo - platform: i2s_audio id: speaker_b_id - i2s_audio_id: i2s_b dac_type: external i2s_dout_pin: ${b_dout_pin} spdif_mode: true From 0b780f1fd29a70279c41e6e376d9a19f8f6f7e58 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 26 May 2026 06:21:15 +0930 Subject: [PATCH 1426/2214] [time][homeassistant] Fix timezone handling (#16583) --- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/client.py | 1 + .../components/homeassistant/time/__init__.py | 4 +- esphome/components/time/__init__.py | 56 ++- esphome/core/defines.h | 1 + tests/component_tests/time/__init__.py | 1 + tests/component_tests/time/test_init.py | 369 ++++++++++++++++++ 7 files changed, 414 insertions(+), 20 deletions(-) create mode 100644 tests/component_tests/time/__init__.py create mode 100644 tests/component_tests/time/test_init.py diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f2bf3752fa..c880e036cb 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1169,7 +1169,7 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) { void APIConnection::on_get_time_response(const GetTimeResponse &value) { if (homeassistant::global_homeassistant_time != nullptr) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); -#ifdef USE_TIME_TIMEZONE +#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE) if (!value.timezone.empty()) { // Check if the sender provided pre-parsed timezone data. // If std_offset is non-zero or DST rules are present, the parsed data was populated. diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index d6150fbd29..7fba091730 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -101,6 +101,7 @@ async def async_run_logs( client_info=f"ESPHome Logs {__version__}", noise_psk=noise_psk, addresses=addresses, # Pass all addresses for automatic retry + provide_time=False, ) # Try platform-specific stacktrace handler first, fall back to generic diff --git a/esphome/components/homeassistant/time/__init__.py b/esphome/components/homeassistant/time/__init__.py index 62cb96a25a..05ca86a26e 100644 --- a/esphome/components/homeassistant/time/__init__.py +++ b/esphome/components/homeassistant/time/__init__.py @@ -1,7 +1,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.const import CONF_ID, CONF_TIMEZONE from .. import homeassistant_ns @@ -21,3 +21,5 @@ async def to_code(config): await time_.register_time(var, config) await cg.register_component(var, config) cg.add_define("USE_HOMEASSISTANT_TIME") + if CONF_TIMEZONE not in config: + cg.add_define("USE_HOMEASSISTANT_TIMEZONE") diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 29bb01b499..8839a988a1 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -30,13 +30,21 @@ from esphome.const import ( CONF_SECONDS, CONF_TIMEZONE, CONF_TRIGGER_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_HOST, + PLATFORM_LN882X, + PLATFORM_RP2040, + PLATFORM_RTL87XX, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True +DOMAIN = "time" time_ns = cg.esphome_ns.namespace("time") RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent) @@ -92,20 +100,34 @@ def _extract_tz_string(tzfile: bytes) -> str: raise -def detect_tz() -> str: +def detect_tz() -> str | None: + if CORE.target_platform not in { + PLATFORM_ESP8266, + PLATFORM_ESP32, + PLATFORM_RP2040, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + PLATFORM_HOST, + }: + return None + # Avoids duplicate logger messages when multiple time components are configured + if cached := CORE.data.setdefault(DOMAIN, {}).get(CONF_TIMEZONE): + return cached iana_key = tzlocal.get_localzone_name() if iana_key is None: - raise cv.Invalid( + raise EsphomeError( "Could not automatically determine timezone, please set timezone manually." ) - _LOGGER.info("Detected timezone '%s'", iana_key) tzfile = _load_tzdata(iana_key) if tzfile is None: - raise cv.Invalid( + raise EsphomeError( "Could not automatically determine timezone, please set timezone manually." ) ret = _extract_tz_string(tzfile) + _LOGGER.info("Detected timezone '%s'", iana_key) _LOGGER.debug(" -> TZ string %s", ret) + CORE.data.setdefault(DOMAIN, {})[CONF_TIMEZONE] = ret return ret @@ -312,16 +334,7 @@ def validate_tz(value: str) -> str: TIME_SCHEMA = cv.Schema( { - cv.SplitDefault( - CONF_TIMEZONE, - esp8266=detect_tz, - esp32=detect_tz, - rp2040=detect_tz, - bk72xx=detect_tz, - rtl87xx=detect_tz, - ln882x=detect_tz, - host=detect_tz, - ): cv.All( + cv.Optional(CONF_TIMEZONE): cv.All( cv.only_with_framework(["arduino", "esp-idf", "host"]), validate_tz, ), @@ -384,7 +397,11 @@ def _emit_parsed_timezone_fields(parsed): async def setup_time_core_(time_var, config): - if timezone := config.get(CONF_TIMEZONE): + timezone = config.get(CONF_TIMEZONE) + # an empty timezone is treated as disabling timezones completely as before + if timezone is None: + timezone = detect_tz() + if timezone: cg.add_define("USE_TIME_TIMEZONE") if CORE.is_host: @@ -392,8 +409,11 @@ async def setup_time_core_(time_var, config): cg.add(time_var.set_timezone(timezone)) else: # Embedded: pre-parse at codegen time, emit struct directly - parsed = parse_posix_tz_python(timezone) - _emit_parsed_timezone_fields(parsed) + try: + parsed = parse_posix_tz_python(timezone) + _emit_parsed_timezone_fields(parsed) + except ValueError as e: + raise EsphomeError(f"Invalid timezone: {timezone}") from e for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3cb92616bb..0229bc14fa 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -71,6 +71,7 @@ #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU #define USE_HOMEASSISTANT_TIME +#define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE diff --git a/tests/component_tests/time/__init__.py b/tests/component_tests/time/__init__.py new file mode 100644 index 0000000000..dc24f4e532 --- /dev/null +++ b/tests/component_tests/time/__init__.py @@ -0,0 +1 @@ +"""Tests for the time component.""" diff --git a/tests/component_tests/time/test_init.py b/tests/component_tests/time/test_init.py new file mode 100644 index 0000000000..44469cfe28 --- /dev/null +++ b/tests/component_tests/time/test_init.py @@ -0,0 +1,369 @@ +"""Tests for time component – ha-timezone branch changes. + +Covers: +- detect_tz() platform guard (returns None for unsupported platforms) +- detect_tz() result caching (avoids duplicate log messages) +- detect_tz() error paths (tzlocal None, tzdata missing) +- validate_tz() accepts/rejects POSIX timezone strings and IANA keys +- TIME_SCHEMA: timezone is now truly optional (was SplitDefault) +- homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define emitted iff + CONF_TIMEZONE is absent from the config +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +from esphome.components.time import DOMAIN, TIME_SCHEMA, detect_tz, validate_tz +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_TIMEZONE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Platform, + PlatformFramework, +) +from esphome.core import CORE, EsphomeError +from tests.component_tests.types import SetCoreConfigCallable + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# A minimal TZif v2/v3 file that encodes "EST5EDT" as the footer line. +# The binary content is not validated at this level – what matters is that +# _extract_tz_string() picks up the last-but-one newline-terminated line. +_FAKE_TZFILE = b"\x00" * 44 + b"TZif2\x00" * 1 + b"\n" + b"EST5EDT,M3.2.0,M11.1.0\n" + + +def _set_platform(platform: Platform) -> None: + """Set CORE.data so that CORE.target_platform returns *platform*.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: "arduino", + } + + +# --------------------------------------------------------------------------- +# detect_tz – platform guard +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "platform_framework", + [ + PlatformFramework.NRF52_ZEPHYR, + ], +) +def test_detect_tz_returns_none_for_unsupported_platform( + platform_framework: PlatformFramework, + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must return None for platforms that do not support TZ auto-detection.""" + set_core_config(platform_framework) + result = detect_tz() + assert result is None + + +@pytest.mark.parametrize( + "platform_framework", + [ + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP8266_ARDUINO, + PlatformFramework.RP2040_ARDUINO, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.HOST_NATIVE, + ], +) +def test_detect_tz_calls_tzlocal_for_supported_platform( + platform_framework: PlatformFramework, + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must call tzlocal for every supported platform.""" + set_core_config(platform_framework) + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="America/New_York", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ), + ): + result = detect_tz() + assert result is not None + assert isinstance(result, str) + assert len(result) > 0 + + +# --------------------------------------------------------------------------- +# detect_tz – caching +# --------------------------------------------------------------------------- + + +def test_detect_tz_caches_result( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """detect_tz() must cache the TZ string after the first call so that + subsequent invocations (e.g. when multiple time platforms are configured) + skip tzlocal and avoid duplicate INFO messages.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="America/New_York", + ) as mock_tz, + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ) as mock_load, + ): + first = detect_tz() + second = detect_tz() + + assert first == second + # tzlocal and _load_tzdata must be called exactly once despite two detect_tz() calls + mock_tz.assert_called_once() + mock_load.assert_called_once() + + +def test_detect_tz_cache_stored_in_core_data( + set_core_config: SetCoreConfigCallable, +) -> None: + """The cached TZ string should be stored under CORE.data[DOMAIN][CONF_TIMEZONE].""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="Europe/London", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ), + ): + result = detect_tz() + + assert CORE.data.get(DOMAIN, {}).get(CONF_TIMEZONE) == result + + +def test_detect_tz_returns_pre_seeded_cache( + set_core_config: SetCoreConfigCallable, +) -> None: + """If CORE.data already has a cached TZ string, detect_tz() must return it + without calling tzlocal at all.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[DOMAIN] = {CONF_TIMEZONE: "CET-1CEST,M3.5.0,M10.5.0/3"} + + with mock.patch("esphome.components.time.tzlocal.get_localzone_name") as mock_tz: + result = detect_tz() + + assert result == "CET-1CEST,M3.5.0,M10.5.0/3" + mock_tz.assert_not_called() + + +# --------------------------------------------------------------------------- +# detect_tz – error paths +# --------------------------------------------------------------------------- + + +def test_detect_tz_raises_when_tzlocal_returns_none( + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must raise EsphomeError when the local timezone cannot be determined.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value=None, + ), + pytest.raises(EsphomeError, match="Could not automatically determine timezone"), + ): + detect_tz() + + +def test_detect_tz_raises_when_tzdata_not_found( + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must raise EsphomeError when tzdata has no entry for the IANA key.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="Antarctica/Troll", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=None, + ), + pytest.raises(EsphomeError, match="Could not automatically determine timezone"), + ): + detect_tz() + + +# --------------------------------------------------------------------------- +# validate_tz +# --------------------------------------------------------------------------- + + +def test_validate_tz_accepts_valid_posix_string() -> None: + """validate_tz() must accept a syntactically valid POSIX TZ string.""" + result = validate_tz("UTC0") + assert result == "UTC0" + + +def test_validate_tz_accepts_posix_string_with_dst() -> None: + """validate_tz() must accept a full POSIX TZ string with DST rules.""" + tz = "EST5EDT,M3.2.0,M11.1.0" + result = validate_tz(tz) + assert result == tz + + +def test_validate_tz_accepts_iana_key_and_converts() -> None: + """validate_tz() must accept an IANA timezone key and return the POSIX string.""" + with mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ): + result = validate_tz("America/New_York") + + # Should have been converted from IANA to POSIX via _extract_tz_string + assert result == "EST5EDT,M3.2.0,M11.1.0" + + +def test_validate_tz_rejects_invalid_posix_string() -> None: + """validate_tz() must raise cv.Invalid for a malformed POSIX TZ string.""" + with pytest.raises(cv.Invalid, match="Invalid POSIX timezone string"): + validate_tz("NOTAVALIDTZ!!!") + + +def test_validate_tz_accepts_empty_string() -> None: + """An empty string is accepted by validate_tz() and signals 'disable timezone'.""" + result = validate_tz("") + assert result == "" + + +# --------------------------------------------------------------------------- +# TIME_SCHEMA – timezone is now cv.Optional (no SplitDefault) +# --------------------------------------------------------------------------- + + +def test_time_schema_timezone_is_optional( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must accept a config with no timezone key on a supported platform.""" + set_core_config(PlatformFramework.ESP32_IDF) + # Should not raise + config = TIME_SCHEMA({}) + assert CONF_TIMEZONE not in config + + +def test_time_schema_explicit_timezone_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must accept an explicit valid POSIX timezone on Arduino/IDF.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = TIME_SCHEMA({CONF_TIMEZONE: "UTC0"}) + assert config[CONF_TIMEZONE] == "UTC0" + + +def test_time_schema_explicit_empty_timezone_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """An empty timezone string (timezone-disable sentinel) must pass TIME_SCHEMA.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = TIME_SCHEMA({CONF_TIMEZONE: ""}) + assert config[CONF_TIMEZONE] == "" + + +def test_time_schema_timezone_rejected_on_zephyr( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must reject a timezone value on Zephyr with the framework error. + + The platform check (cv.only_with_framework) must run BEFORE validate_tz so + that users receive an actionable "unsupported framework" message rather than a + confusing TZ-parsing error. + """ + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="only available with framework"): + TIME_SCHEMA({CONF_TIMEZONE: "UTC0"}) + + +def test_time_schema_invalid_tz_on_zephyr_gives_framework_error( + set_core_config: SetCoreConfigCallable, +) -> None: + """Even a syntactically invalid TZ string must produce the framework error on Zephyr. + + This specifically tests that cv.only_with_framework is evaluated before + validate_tz: if the order were reversed, an invalid POSIX string would + generate a misleading TZ-parsing error instead. + """ + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="only available with framework"): + TIME_SCHEMA({CONF_TIMEZONE: "NOTAVALIDTZ!!!"}) + + +# --------------------------------------------------------------------------- +# homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_ha_cg(): + """Mock codegen functions used by homeassistant/time to_code.""" + with ( + mock.patch( + "esphome.components.homeassistant.time.cg.new_Pvariable", + return_value=mock.MagicMock(), + ), + mock.patch( + "esphome.components.homeassistant.time.cg.add_define", + ) as mock_add_define, + mock.patch( + "esphome.components.homeassistant.time.cg.register_component", + new_callable=mock.AsyncMock, + ), + mock.patch( + "esphome.components.homeassistant.time.time_.register_time", + new_callable=mock.AsyncMock, + ), + ): + yield mock_add_define + + +@pytest.mark.asyncio +async def test_ha_time_defines_ha_timezone_when_no_explicit_tz(mock_ha_cg) -> None: + """When CONF_TIMEZONE is absent from the config, to_code() must call + cg.add_define('USE_HOMEASSISTANT_TIMEZONE').""" + from esphome.components.homeassistant.time import to_code + + await to_code({CONF_ID: mock.MagicMock()}) + + mock_ha_cg.assert_any_call("USE_HOMEASSISTANT_TIMEZONE") + + +@pytest.mark.asyncio +async def test_ha_time_no_ha_timezone_define_when_explicit_tz(mock_ha_cg) -> None: + """When CONF_TIMEZONE is present in the config, to_code() must NOT call + cg.add_define('USE_HOMEASSISTANT_TIMEZONE').""" + from esphome.components.homeassistant.time import to_code + + await to_code({CONF_ID: mock.MagicMock(), CONF_TIMEZONE: "UTC0"}) + + define_calls = [call.args[0] for call in mock_ha_cg.call_args_list] + assert "USE_HOMEASSISTANT_TIME" in define_calls + assert "USE_HOMEASSISTANT_TIMEZONE" not in define_calls From fc0a4e22011fc4e852dc05b1433fbbc398434db7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 17:07:35 -0400 Subject: [PATCH 1427/2214] [espidf] Support github:// and https://github.com/.../.git framework sources (#16639) --- esphome/espidf/framework.py | 115 +++++++++++++--- esphome/git.py | 5 +- tests/unit_tests/test_espidf_framework.py | 156 ++++++++++++++++++++++ 3 files changed, 256 insertions(+), 20 deletions(-) create mode 100644 tests/unit_tests/test_espidf_framework.py diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 079c97cc98..fb53066edb 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -8,6 +8,7 @@ import logging import os from pathlib import Path import platform +import re import shutil import subprocess import sys @@ -784,6 +785,77 @@ def download_from_mirrors( return None +_GITHUB_SHORTHAND_RE = re.compile( + r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" +) +_GITHUB_HTTPS_RE = re.compile( + r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$" +) + + +def _parse_git_source(source_url: str) -> tuple[str, str | None] | None: + """Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or + ``https://github.com/owner/repo.git[@ref]``, else ``None``.""" + if m := _GITHUB_SHORTHAND_RE.match(source_url): + owner, repo, ref = m.group(1), m.group(2), m.group(3) + # Tolerate a trailing ".git" on the shorthand repo so the + # github://owner/repo.git form doesn't silently become repo.git.git. + repo = repo.removesuffix(".git") + return f"https://github.com/{owner}/{repo}.git", ref + if m := _GITHUB_HTTPS_RE.match(source_url): + return m.group(1), m.group(2) + return None + + +def _clone_idf_with_submodules( + framework_path: Path, git_url: str, ref: str | None +) -> None: + """Shallow-clone ESP-IDF with submodules into ``framework_path``. + + GitHub's archive zip strips submodules, so vendored components + (mbedtls, openthread, esptool, ...) come down empty and CMake fails. + + Uses clone + ``fetch FETCH_HEAD`` + ``reset --hard`` instead of + ``--branch``: ``--branch`` only accepts branch or tag names, but a + user can also point at a commit SHA. The fetch-then-reset pattern + handles branches, tags, and SHAs uniformly (mirrors the approach in + ``esphome.git.clone_or_update``). + """ + from esphome.git import run_git_command + + _LOGGER.info("Cloning ESP-IDF from %s%s", git_url, f"@{ref}" if ref else "") + run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)]) + if ref: + run_git_command( + ["git", "fetch", "--depth=1", "--", "origin", ref], + git_dir=framework_path, + ) + run_git_command( + ["git", "reset", "--hard", "FETCH_HEAD"], + git_dir=framework_path, + ) + run_git_command( + [ + "git", + "submodule", + "update", + "--init", + "--recursive", + "--depth=1", + ], + git_dir=framework_path, + ) + + # Sanity-check the resulting tree. run_git_command only raises when + # stderr is non-empty, so a clone that silently produces no working + # tree would otherwise be marked extracted and stuck until + # ``esphome clean``. + if not (framework_path / "tools" / "idf_tools.py").is_file(): + raise RuntimeError( + f"Clone of {git_url} produced no usable ESP-IDF tree at {framework_path}" + ) + + def _write_idf_version_txt(framework_path: Path, version: str) -> None: """Write /version.txt if missing. @@ -939,27 +1011,34 @@ def _check_esphome_idf_framework_install( if install: rmdir(framework_path, msg=f"Clean up ESP-IDF {version} framework") - # Download in temporary file - with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading ESP-IDF %s framework ...", version) + git_source = _parse_git_source(source_url) if source_url else None + if git_source is not None: + git_url, ref = git_source + _clone_idf_with_submodules(framework_path, git_url, ref) + else: + # Download in temporary file + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading ESP-IDF %s framework ...", version) - # Create substitutions for the URLs - substitutions = {"VERSION": version} - try: - ver = Version.parse(version) - substitutions["MAJOR"] = str(ver.major) - substitutions["MINOR"] = str(ver.minor) - substitutions["PATCH"] = str(ver.patch) - substitutions["EXTRA"] = ver.extra - except ValueError: - pass + # Create substitutions for the URLs + substitutions = {"VERSION": version} + try: + ver = Version.parse(version) + substitutions["MAJOR"] = str(ver.major) + substitutions["MINOR"] = str(ver.minor) + substitutions["PATCH"] = str(ver.patch) + substitutions["EXTRA"] = ver.extra + except ValueError: + pass - mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS - download_from_mirrors(mirrors, substitutions, tmp.file) + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + download_from_mirrors(mirrors, substitutions, tmp.file) - _LOGGER.info("Extracting ESP-IDF %s framework ...", version) - archive_extract_all(tmp.file, framework_path, progress_header="Extracting") - extracted_marker.touch() + _LOGGER.info("Extracting ESP-IDF %s framework ...", version) + archive_extract_all( + tmp.file, framework_path, progress_header="Extracting" + ) + extracted_marker.touch() # Idempotent post-extract patch: written every invocation so a build # dir extracted before this fix gets the file too, without forcing a diff --git a/esphome/git.py b/esphome/git.py index 0106f24845..f36bd559ef 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -72,8 +72,9 @@ def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str: ) except FileNotFoundError as err: raise GitNotInstalledError( - "git is not installed but required for external_components.\n" - "Please see https://git-scm.com/book/en/v2/Getting-Started-Installing-Git for installing git" + "git is not installed. See " + "https://git-scm.com/book/en/v2/Getting-Started-Installing-Git " + "for installation instructions." ) from err if ret.returncode != 0 and ret.stderr: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py new file mode 100644 index 0000000000..9f4e4fcca8 --- /dev/null +++ b/tests/unit_tests/test_espidf_framework.py @@ -0,0 +1,156 @@ +"""Tests for esphome.espidf.framework helpers.""" + +# pylint: disable=protected-access + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.espidf.framework import _clone_idf_with_submodules, _parse_git_source + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + # github:// shorthand + ( + "github://espressif/esp-idf", + ("https://github.com/espressif/esp-idf.git", None), + ), + ( + "github://espressif/esp-idf@master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), + ( + "github://espressif/esp-idf@release/v6.0", + ("https://github.com/espressif/esp-idf.git", "release/v6.0"), + ), + # explicit https://github.com/...git URL + ( + "https://github.com/espressif/esp-idf.git", + ("https://github.com/espressif/esp-idf.git", None), + ), + ( + "https://github.com/espressif/esp-idf.git@master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), + ( + "https://github.com/espressif/esp-idf.git@v6.0.1", + ("https://github.com/espressif/esp-idf.git", "v6.0.1"), + ), + # Tolerate a trailing ".git" on the shorthand so the user doesn't + # silently end up with a doubled "...esp-idf.git.git" URL. + ( + "github://espressif/esp-idf.git", + ("https://github.com/espressif/esp-idf.git", None), + ), + ( + "github://espressif/esp-idf.git@master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), + ], +) +def test_parse_git_source_recognized( + source: str, expected: tuple[str, str | None] +) -> None: + assert _parse_git_source(source) == expected + + +@pytest.mark.parametrize( + "source", + [ + # archive URLs fall through to the existing download path + "https://github.com/espressif/esp-idf/archive/refs/heads/master.zip", + "https://dl.espressif.com/dl/esp-idf/v6.0.1/esp-idf-v6.0.1.zip", + "https://github.com/esphome-libs/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz", + # SSH and other git protocols are intentionally rejected — match + # external_components, which only recognizes github:// + structured + # dicts for these. + "git@github.com:espressif/esp-idf.git", + "ssh://git@github.com/espressif/esp-idf.git", + "git://github.com/espressif/esp-idf.git", + # non-GitHub .git URLs are intentionally rejected for the same reason + "https://gitlab.com/foo/bar.git", + "https://github.example.com/foo/bar.git", + ], +) +def test_parse_git_source_rejected(source: str) -> None: + assert _parse_git_source(source) is None + + +def _make_idf_tree(framework_path: Path) -> None: + """Create the minimum tree _clone_idf_with_submodules sanity-checks for.""" + (framework_path / "tools").mkdir(parents=True) + (framework_path / "tools" / "idf_tools.py").write_text("# stub\n") + + +def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None: + framework_path = tmp_path / "idf" + framework_path.mkdir() + _make_idf_tree(framework_path) + + with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: + _clone_idf_with_submodules( + framework_path, "https://github.com/espressif/esp-idf.git", None + ) + + # No ref -> just clone + submodule update, no fetch/reset. + calls = [c.args[0] for c in run_git_command_mock.call_args_list] + assert calls[0] == [ + "git", + "clone", + "--depth=1", + "--", + "https://github.com/espressif/esp-idf.git", + str(framework_path), + ] + 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) + + +def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: + framework_path = tmp_path / "idf" + framework_path.mkdir() + _make_idf_tree(framework_path) + + with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: + _clone_idf_with_submodules( + framework_path, + "https://github.com/espressif/esp-idf.git", + "master", + ) + + calls = [c.args[0] for c in run_git_command_mock.call_args_list] + # clone, fetch ref, reset hard, submodule update + assert calls[0][:2] == ["git", "clone"] + assert calls[1] == [ + "git", + "fetch", + "--depth=1", + "--", + "origin", + "master", + ] + assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"] + assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"] + + +def test_clone_idf_with_submodules_raises_when_tree_missing( + tmp_path: Path, +) -> None: + framework_path = tmp_path / "idf" + framework_path.mkdir() + # Deliberately do NOT call _make_idf_tree — simulate a clone that + # returned 0 but produced no tools/idf_tools.py. + + with ( + patch("esphome.git.run_git_command", return_value=""), + pytest.raises(RuntimeError, match="no usable ESP-IDF tree"), + ): + _clone_idf_with_submodules( + framework_path, + "https://github.com/espressif/esp-idf.git", + None, + ) From 61e8830a3ce38a67647d2b17b863049b296e7aaf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 17:40:38 -0400 Subject: [PATCH 1428/2214] [espidf] Keep cmake output filter working when IDF writes raw bytes (#16642) --- esphome/espidf/runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index da3f77cdd3..857d16c674 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -164,6 +164,12 @@ def main() -> int: self._line_buffer = "" def __getattr__(self, name: str): + # Hide ``buffer`` so consumers that use either + # ``getattr(stream, 'buffer', None)`` or + # ``hasattr(stream, 'buffer')`` see this as a text-only stream + # and skip writing raw bytes (which would bypass the filter). + if name == "buffer": + raise AttributeError(name) return getattr(self._stream, name) def isatty(self) -> bool: From a257edba626ab1455f882f58a8a3f5787745a231 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Mon, 25 May 2026 23:46:33 +0200 Subject: [PATCH 1429/2214] [mitsubishi_cn105] Add basic swing support (#15653) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/mitsubishi_cn105/climate.py | 12 +- .../mitsubishi_cn105/mitsubishi_cn105.h | 1 + .../mitsubishi_cn105_climate.cpp | 90 ++++++++++ .../mitsubishi_cn105_climate.h | 5 + .../mitsubishi_cn105_climate_tests.cpp | 165 ++++++++++++++++++ tests/components/mitsubishi_cn105/common.h | 11 ++ tests/components/mitsubishi_cn105/common.yaml | 3 + 7 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index cc44494d89..522b9218fc 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,8 +1,14 @@ from esphome import automation import esphome.codegen as cg from esphome.components import climate, uart +from esphome.components.climate import validate_climate_swing_mode import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL +from esphome.const import ( + CONF_ID, + CONF_SUPPORTED_SWING_MODES, + CONF_TEMPERATURE, + CONF_UPDATE_INTERVAL, +) from esphome.core import ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType, TemplateArgsType @@ -43,6 +49,9 @@ CONFIG_SCHEMA = ( cv.Optional( CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s" ): cv.update_interval, + cv.Optional( + CONF_SUPPORTED_SWING_MODES, default="OFF" + ): validate_climate_swing_mode, } ) ) @@ -63,6 +72,7 @@ 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] diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index dbeb43068e..742d8e18a9 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "esphome/components/uart/uart.h" #include "esphome/core/finite_set_mask.h" diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 67a561397a..afffe7ea5e 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -84,6 +84,8 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.add_supported_fan_mode(p.second); } + traits.set_supported_swing_modes(this->supported_swing_modes_); + traits.set_visual_min_temperature(16.0f); traits.set_visual_max_temperature(31.0f); traits.set_visual_temperature_step(1.0f); @@ -114,6 +116,37 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { this->hp_.set_fan_mode(*fan_mode); } + if (const auto swing_mode = call.get_swing_mode()) { + auto vane = this->last_non_swing_vane_mode_; + auto wide = this->last_non_swing_wide_vane_mode_; + + switch (*swing_mode) { + case climate::CLIMATE_SWING_BOTH: + vane = MitsubishiCN105::VaneMode::SWING; + wide = MitsubishiCN105::WideVaneMode::SWING; + break; + + case climate::CLIMATE_SWING_VERTICAL: + vane = MitsubishiCN105::VaneMode::SWING; + break; + + case climate::CLIMATE_SWING_HORIZONTAL: + wide = MitsubishiCN105::WideVaneMode::SWING; + break; + + case climate::CLIMATE_SWING_OFF: + default: + break; + } + + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { + this->hp_.set_vane_mode(vane); + } + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { + this->hp_.set_wide_vane_mode(wide); + } + } + if (this->hp_.is_status_initialized()) { this->apply_values_(); } @@ -143,7 +176,64 @@ void MitsubishiCN105Climate::apply_values_() { ESP_LOGD(TAG, "Unable to map fan mode"); } + if (!this->supported_swing_modes_.empty()) { + bool vertical_swinging = false; + bool horizontal_swinging = false; + + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { + if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) { + vertical_swinging = true; + } else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) { + this->last_non_swing_vane_mode_ = status.vane_mode; + } + } + + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { + if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) { + horizontal_swinging = true; + } else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) { + this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode; + } + } + + if (vertical_swinging && horizontal_swinging) { + this->swing_mode = climate::CLIMATE_SWING_BOTH; + } else if (vertical_swinging) { + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else if (horizontal_swinging) { + this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + } else { + this->swing_mode = climate::CLIMATE_SWING_OFF; + } + } + this->publish_state(); } +void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) { + this->supported_swing_modes_.clear(); + switch (mode) { + case climate::CLIMATE_SWING_VERTICAL: + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL); + break; + + case climate::CLIMATE_SWING_HORIZONTAL: + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL); + break; + + case climate::CLIMATE_SWING_BOTH: + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_BOTH); + break; + + case climate::CLIMATE_SWING_OFF: + default: + break; + } +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index e09158bfcf..c83a5519c1 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -25,10 +25,15 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public 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); + 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}; }; template diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp new file mode 100644 index 0000000000..36e0fc90b4 --- /dev/null +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp @@ -0,0 +1,165 @@ +#include "../common.h" + +namespace esphome::mitsubishi_cn105::testing { + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF); + + EXPECT_FALSE(sut.traits().get_supports_swing_modes()); +} + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH)); +} + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); + + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH)); +} + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH)); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT; + + sut.apply_values_(); + + EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4); + EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4); + EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT); + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2; + sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT; + + sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN; + + sut.apply_values_(); + + EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2); + EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT); + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 59b6203732..798f7283f6 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_climate.h" namespace esphome::mitsubishi_cn105::testing { @@ -44,6 +45,7 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { using MitsubishiCN105::State; using MitsubishiCN105::UpdateFlag; using MitsubishiCN105::state_; + using MitsubishiCN105::status_; using MitsubishiCN105::operation_start_ms_; using MitsubishiCN105::use_temperature_encoding_b_; using MitsubishiCN105::set_wide_vane_high_bit_; @@ -58,4 +60,13 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { void set_current_time(uint32_t ms) { test_loop_time_ms = ms; } }; +class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { + public: + 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_; } +}; + } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 4b64f51261..5b9c3aaaf6 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -3,6 +3,9 @@ climate: id: ac name: "AC Test" uart_id: uart_bus + update_interval: 30s + current_temperature_min_interval: 120s + supported_swing_modes: BOTH esphome: on_boot: From 8645f3672d142417e5b3c0351e3946b9c2d7ebb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 18:11:40 -0500 Subject: [PATCH 1430/2214] [core] Enable additional zero-violation ruff lint families (#16645) --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d16bf2b625..94cd6d21b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,14 +113,22 @@ exclude = ['generated'] select = [ "E", # pycodestyle "F", # pyflakes/autoflake + "FA", # flake8-future-annotations "FLY", # flynt: convert string formatting to f-strings "FURB", # refurb "I", # isort + "ICN", # flake8-import-conventions + "LOG", # flake8-logging + "NPY", # numpy-specific rules "PERF", # performance "PL", # pylint + "Q", # flake8-quotes "SIM", # flake8-simplify "RET", # flake8-ret + "T10", # flake8-debugger "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 ] ignore = [ From 97267105e1917a0195d51b0267dc9b1073a47d88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:05:51 -0500 Subject: [PATCH 1431/2214] [core] Enable ruff EXE (flake8-executable) lint family (#16648) --- pyproject.toml | 1 + script/ci_helpers.py | 0 2 files changed, 1 insertion(+) mode change 100755 => 100644 script/ci_helpers.py diff --git a/pyproject.toml b/pyproject.toml index 94cd6d21b8..5de8775713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ "E", # pycodestyle + "EXE", # flake8-executable "F", # pyflakes/autoflake "FA", # flake8-future-annotations "FLY", # flynt: convert string formatting to f-strings diff --git a/script/ci_helpers.py b/script/ci_helpers.py old mode 100755 new mode 100644 From 51722279312ab8c9e0475766592a0c39a581c2a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:06:01 -0500 Subject: [PATCH 1432/2214] [core] Enable ruff SLOT (flake8-slots) lint family (#16647) --- pyproject.toml | 1 + tests/unit_tests/test_yaml_util.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5de8775713..a48bae660f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ select = [ "PL", # pylint "Q", # flake8-quotes "SIM", # flake8-simplify + "SLOT", # flake8-slots "RET", # flake8-ret "T10", # flake8-debugger "UP", # pyupgrade diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index de70a5307d..d6fb5b81f2 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -907,7 +907,7 @@ def test_format_path_current_obj_without_location_falls_back_to_key(): """An ESPHomeDataBase current_obj with no esp_range falls back to the key's location.""" class _NoRange(ESPHomeDataBase, str): - pass + __slots__ = () obj = _NoRange.__new__(_NoRange, "value") str.__init__(obj) From f1839489dd6c7ec61020fb0fa7e7e1fc5c62bda9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:06:18 -0500 Subject: [PATCH 1433/2214] [core] Enable ruff ISC (flake8-implicit-str-concat) lint family (#16646) --- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a48bae660f..8916abd6b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ select = [ "FURB", # refurb "I", # isort "ICN", # flake8-import-conventions + "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "NPY", # numpy-specific rules "PERF", # performance diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index bf672d0567..91aec91637 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1283,11 +1283,11 @@ class PackedBufferTypeInfo(TypeInfo): """Dump shows buffer info but not decoded values.""" return ( f'out.append(2, \' \').append_p(ESPHOME_PSTR("{self.name}")).append(": ");\n' - + 'out.append_p(ESPHOME_PSTR("packed buffer ["));\n' - + f"append_uint(out, this->{self.field_name}_count_);\n" - + 'out.append_p(ESPHOME_PSTR(" values, "));\n' - + f"append_uint(out, this->{self.field_name}_length_);\n" - + 'out.append_p(ESPHOME_PSTR(" bytes]\\n"));' + 'out.append_p(ESPHOME_PSTR("packed buffer ["));\n' + f"append_uint(out, this->{self.field_name}_count_);\n" + 'out.append_p(ESPHOME_PSTR(" values, "));\n' + f"append_uint(out, this->{self.field_name}_length_);\n" + 'out.append_p(ESPHOME_PSTR(" bytes]\\n"));' ) def dump(self, name: str) -> str: From bbc24ab5469ad15075ecd4ee52a60e6285bdd7c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:06:34 -0500 Subject: [PATCH 1434/2214] [core] Enable ruff RSE (flake8-raise) lint family (#16649) --- esphome/components/external_components/__init__.py | 2 +- esphome/components/waveshare_epaper/display.py | 2 +- esphome/config_validation.py | 2 +- esphome/cpp_generator.py | 2 +- esphome/espidf/component.py | 2 +- pyproject.toml | 1 + 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index 6eb577e5ad..c892ec1112 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -81,7 +81,7 @@ def _process_single_config(config: dict[str, Any]) -> None: elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) else: - raise NotImplementedError() + raise NotImplementedError if config[CONF_COMPONENTS] == "all": num_components = len(list(components_dir.glob("*/__init__.py"))) diff --git a/esphome/components/waveshare_epaper/display.py b/esphome/components/waveshare_epaper/display.py index 5db7a1fc3d..7ecc3b4a87 100644 --- a/esphome/components/waveshare_epaper/display.py +++ b/esphome/components/waveshare_epaper/display.py @@ -236,7 +236,7 @@ async def to_code(config): rhs = model.new() var = cg.Pvariable(config[CONF_ID], rhs, model) else: - raise NotImplementedError() + raise NotImplementedError await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c993c1dcc5..ca1fd8f5d4 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1862,7 +1862,7 @@ def extract_keys(schema): elif isinstance(skey, vol.Marker) and isinstance(skey.schema, str): keys.append(skey.schema) else: - raise ValueError() + raise ValueError keys.sort() return keys diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index c622207dac..c5e398b2d7 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -893,7 +893,7 @@ class MockObj(Expression): def __getattr__(self, attr: str) -> "MockObj": # prevent python dunder methods being replaced by mock objects if attr.startswith("__"): - raise AttributeError() + raise AttributeError next_op = "." if attr.startswith("P") and self.op not in ["::", ""]: attr = attr[1:] diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 3534ac82f5..81f2cd9632 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -55,7 +55,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: def download(self, dir_suffix: str, force: bool = False) -> Path: - raise NotImplementedError() + raise NotImplementedError class URLSource(Source): diff --git a/pyproject.toml b/pyproject.toml index 8916abd6b9..0e7bba82e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ select = [ "PERF", # performance "PL", # pylint "Q", # flake8-quotes + "RSE", # flake8-raise "SIM", # flake8-simplify "SLOT", # flake8-slots "RET", # flake8-ret From b39b34bfe1f866c774719638552117ba5a2d3cc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:14:26 -0500 Subject: [PATCH 1435/2214] [core] Enable ruff C4 (flake8-comprehensions) lint family (#16653) --- esphome/components/font/__init__.py | 4 ++-- esphome/components/libretiny/__init__.py | 14 +++++++------- esphome/components/lvgl/__init__.py | 2 +- esphome/components/lvgl/defines.py | 2 +- esphome/components/opentherm/generate.py | 10 +++------- esphome/components/time/__init__.py | 2 +- esphome/espidf/framework.py | 2 +- esphome/pins.py | 4 +--- pyproject.toml | 1 + script/ci-custom.py | 2 +- script/extract_automations.py | 6 +++--- 11 files changed, 22 insertions(+), 27 deletions(-) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index a10c45a9d7..cb4b1d3a60 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -563,13 +563,13 @@ async def to_code(config): point_set.update(flatten(config[CONF_GLYPHS])) # Create the codepoint to font file map base_font = FONT_CACHE[config[CONF_FILE]] - point_font_map: dict[str, Face] = {c: base_font for c in point_set} + point_font_map: dict[str, Face] = dict.fromkeys(point_set, base_font) # process extras, updating the map and extending the codepoint list for extra in config[CONF_EXTRAS]: extra_points = flatten(extra[CONF_GLYPHS]) point_set.update(extra_points) extra_font = FONT_CACHE[extra[CONF_FILE]] - point_font_map.update({c: extra_font for c in extra_points}) + point_font_map.update(dict.fromkeys(extra_points, extra_font)) codepoints = list(point_set) codepoints.sort(key=functools.cmp_to_key(glyph_comparator)) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 40fb773784..d1f1042501 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -513,13 +513,13 @@ async def component_to_code(config): # apply LibreTiny options from framework: block # setup LT logger to work nicely with ESPHome logger - lt_options = dict( - LT_LOGLEVEL="LT_LEVEL_" + framework[CONF_LOGLEVEL], - LT_LOGGER_CALLER=0, - LT_LOGGER_TASK=0, - LT_LOGGER_COLOR=1, - LT_USE_TIME=1, - ) + lt_options = { + "LT_LOGLEVEL": "LT_LEVEL_" + framework[CONF_LOGLEVEL], + "LT_LOGGER_CALLER": 0, + "LT_LOGGER_TASK": 0, + "LT_LOGGER_COLOR": 1, + "LT_USE_TIME": 1, + } # enable/disable per-module debugging for module in framework[CONF_DEBUG]: if module == "NONE": diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 44bcda9ba9..6e005f897e 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -174,7 +174,7 @@ def generate_lv_conf_h(): if clashes: LOGGER.warning( "Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s", - sorted(list(clashes)), + sorted(clashes), ) unused_defines = all_defines - lv_defines.keys() - defines_from_flags diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 15a24f1ad2..d9be881a7f 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -335,7 +335,7 @@ TYPE_NONE = "none" DIRECTIONS = LvConstant("LV_DIR_", "LEFT", "RIGHT", "BOTTOM", "TOP") -LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [ +LV_FONTS = [f"montserrat_{s}" for s in range(8, 50, 2)] + [ "dejavu_16_persian_hebrew", "simsun_16_cjk", "unscii_8", diff --git a/esphome/components/opentherm/generate.py b/esphome/components/opentherm/generate.py index 0b39895798..1c0de329e5 100644 --- a/esphome/components/opentherm/generate.py +++ b/esphome/components/opentherm/generate.py @@ -16,7 +16,7 @@ def define_has_component(component_type: str, keys: list[str]) -> None: cg.add_define( f"OPENTHERM_{component_type.upper()}_LIST(F, sep)", cg.RawExpression( - " sep ".join(map(lambda key: f"F({key}_{component_type.lower()})", keys)) + " sep ".join(f"F({key}_{component_type.lower()})" for key in keys) ), ) for key in keys: @@ -30,12 +30,8 @@ def define_has_settings(keys: list[str], schemas: dict[str, SettingSchema]) -> N "OPENTHERM_SETTING_LIST(F, sep)", cg.RawExpression( " sep ".join( - map( - lambda key: ( - f"F({schemas[key].backing_type}, {key}_setting, {schemas[key].default_value})" - ), - keys, - ) + f"F({schemas[key].backing_type}, {key}_setting, {schemas[key].default_value})" + for key in keys ) ), ) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 8839a988a1..e9f6cd77e5 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -204,7 +204,7 @@ def cron_expression_validator(name, min_value, max_value, special_mapping=None): raise cv.Invalid( f"{name} {v} is out of range (min={min_value} max={max_value})." ) - return list(sorted(value)) + return sorted(value) value = cv.string(value) values = set() for part in value.split(","): diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index fb53066edb..f393600732 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -40,7 +40,7 @@ def _str_to_lst_of_str(a: str | list[str]) -> list[str]: """ if isinstance(a, list): return a - return list(f.strip() for f in a.split(";") if f.strip()) + return [f.strip() for f in a.split(";") if f.strip()] ESPHOME_STAMP_FILE = ".esphome.stamp.json" diff --git a/esphome/pins.py b/esphome/pins.py index bdaa0e28ab..3e7848949f 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -313,9 +313,7 @@ def gpio_base_schema( :return: A schema for the pin """ mode_default = len(modes) == 1 - mode_dict = dict( - map(lambda m: (cv.Optional(m, default=mode_default), cv.boolean), modes) - ) + mode_dict = {cv.Optional(m, default=mode_default): cv.boolean for m in modes} def _number_validator(value): if isinstance(value, str) and value.upper().startswith("GPIOX"): diff --git a/pyproject.toml b/pyproject.toml index 0e7bba82e9..2b4a7272f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ + "C4", # flake8-comprehensions "E", # pycodestyle "EXE", # flake8-executable "F", # pyflakes/autoflake diff --git a/script/ci-custom.py b/script/ci-custom.py index 51fea97874..c241343a1b 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -341,7 +341,7 @@ def lint_const_ordered(fname, content): matching = [ (i + 1, line) for i, line in enumerate(lines) if line.startswith(start) ] - ordered = list(sorted(matching, key=lambda x: x[1].replace("_", " "))) + ordered = sorted(matching, key=lambda x: x[1].replace("_", " ")) ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered)] for (mi, mline), (_, ol) in zip(matching, ordered): if mline == ol: diff --git a/script/extract_automations.py b/script/extract_automations.py index 4e650ce25f..3cdfb5d32c 100755 --- a/script/extract_automations.py +++ b/script/extract_automations.py @@ -12,9 +12,9 @@ if __name__ == "__main__": components = get_components_with_dependencies(files, True) dump = { - "actions": sorted(list(ACTION_REGISTRY.keys())), - "conditions": sorted(list(CONDITION_REGISTRY.keys())), - "pin_providers": sorted(list(PIN_SCHEMA_REGISTRY.keys())), + "actions": sorted(ACTION_REGISTRY.keys()), + "conditions": sorted(CONDITION_REGISTRY.keys()), + "pin_providers": sorted(PIN_SCHEMA_REGISTRY.keys()), } print(json.dumps(dump, indent=2)) From e492f8f8b6ba03f0cbe994d666e33215385800e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:14:36 -0500 Subject: [PATCH 1436/2214] [tests] Disable hypothesis deadline on flaky IP address test (#16652) --- tests/unit_tests/test_helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index bb00a15bee..efc2d8e42a 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -7,7 +7,7 @@ import stat from unittest.mock import MagicMock, patch from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr -from hypothesis import given +from hypothesis import given, settings from hypothesis.strategies import ip_addresses import pytest @@ -151,6 +151,7 @@ def test_is_ip_address__invalid(host): assert actual is False +@settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_is_ip_address__valid(value): actual = helpers.is_ip_address(value) From dd0028c1b5a38b157143b267cd883fc0db277003 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:36:49 -0500 Subject: [PATCH 1437/2214] [core] Enable ruff G (flake8-logging-format) lint family (#16650) --- esphome/components/time/__init__.py | 2 +- esphome/loader.py | 4 ++-- esphome/pins.py | 3 ++- esphome/platformio/toolchain.py | 2 +- pyproject.toml | 1 + 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index e9f6cd77e5..f687df26c2 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -96,7 +96,7 @@ def _extract_tz_string(tzfile: bytes) -> str: return tzfile.split(b"\n")[-2].decode() except (IndexError, UnicodeDecodeError): _LOGGER.error("Could not determine TZ string. Please report this issue.") - _LOGGER.error("tzfile contents: %s", tzfile, exc_info=True) + _LOGGER.exception("tzfile contents: %s", tzfile) raise diff --git a/esphome/loader.py b/esphome/loader.py index d50554f8c9..c57c09274e 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -239,12 +239,12 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: "Unable to import component %s: %s", domain, str(e), exc_info=False ) else: - _LOGGER.error("Unable to import component %s:", domain, exc_info=True) + _LOGGER.exception("Unable to import component %s:", domain) return None except Exception: # pylint: disable=broad-except if exception: raise - _LOGGER.error("Unable to load component %s:", domain, exc_info=True) + _LOGGER.exception("Unable to load component %s:", domain) return None manif = ComponentManifest(module) diff --git a/esphome/pins.py b/esphome/pins.py index 3e7848949f..d6393508ab 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -272,9 +272,10 @@ def check_strapping_pin(conf, strapping_pin_list: set[int], logger: Logger): num = conf[CONF_NUMBER] if num in strapping_pin_list and not conf.get(CONF_IGNORE_STRAPPING_WARNING): logger.warning( - f"GPIO{num} is a strapping PIN and should only be used for I/O with care.\n" + "GPIO%s is a strapping PIN and should only be used for I/O with care.\n" "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" "See https://esphome.io/guides/faq/#why-am-i-getting-a-warning-about-strapping-pins", + num, ) # mitigate undisciplined use of strapping: if num not in strapping_pin_list and conf.get(CONF_IGNORE_STRAPPING_WARNING): diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 073e134ac4..c81420e6ca 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -96,7 +96,7 @@ def _run_idedata(config): try: return json.loads(match.group()) except ValueError: - _LOGGER.error("Could not parse idedata", exc_info=True) + _LOGGER.exception("Could not parse idedata") _LOGGER.error("Stdout: %s", stdout) raise diff --git a/pyproject.toml b/pyproject.toml index 2b4a7272f2..c8f4d88351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ select = [ "FA", # flake8-future-annotations "FLY", # flynt: convert string formatting to f-strings "FURB", # refurb + "G", # flake8-logging-format "I", # isort "ICN", # flake8-import-conventions "ISC", # flake8-implicit-str-concat From 489cf483d0f7edd2373ada70efa284dc58e182ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:55:35 -0500 Subject: [PATCH 1438/2214] [core] Enable ruff PYI (flake8-pyi) lint family (#16654) --- esphome/cpp_generator.py | 22 ++++++++++++---------- esphome/mqtt.py | 2 +- esphome/yaml_util.py | 2 +- pyproject.toml | 1 + tests/unit_tests/test_main.py | 6 +++--- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index c5e398b2d7..151018baa4 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1077,43 +1077,45 @@ class MockObj(Expression): op = BinOpExpression(other, "|", self) return MockObj(op) - def __iadd__(self, other: SafeExpType) -> "MockObj": + # MockObj operator overloads build a new C++ expression rather than mutating self, + # so the PYI034 "augmented assignment returns self" assumption does not apply. + def __iadd__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "+=", other) return MockObj(op) - def __isub__(self, other: SafeExpType) -> "MockObj": + def __isub__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "-=", other) return MockObj(op) - def __imul__(self, other: SafeExpType) -> "MockObj": + def __imul__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "*=", other) return MockObj(op) - def __itruediv__(self, other: SafeExpType) -> "MockObj": + def __itruediv__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "/=", other) return MockObj(op) - def __imod__(self, other: SafeExpType) -> "MockObj": + def __imod__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "%=", other) return MockObj(op) - def __ilshift__(self, other: SafeExpType) -> "MockObj": + def __ilshift__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "<<=", other) return MockObj(op) - def __irshift__(self, other: SafeExpType) -> "MockObj": + def __irshift__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, ">>=", other) return MockObj(op) - def __iand__(self, other: SafeExpType) -> "MockObj": + def __iand__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "&=", other) return MockObj(op) - def __ixor__(self, other: SafeExpType) -> "MockObj": + def __ixor__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "^=", other) return MockObj(op) - def __ior__(self, other: SafeExpType) -> "MockObj": + def __ior__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "|=", other) return MockObj(op) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index ccacbaea54..098292f599 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -159,7 +159,7 @@ def get_esphome_device_ip( username: str | None = None, password: str | None = None, client_id: str | None = None, - timeout: int | float = 25, + timeout: float = 25, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 9a36ad089c..28f72ab831 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -763,7 +763,7 @@ def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> def _load_yaml_internal_with_type( - loader_type: type[ESPHomeLoader] | type[ESPHomePurePythonLoader], + loader_type: type[ESPHomeLoader | ESPHomePurePythonLoader], fname: Path, content: TextIOWrapper, yaml_loader: Callable[[Path], dict[str, Any]], diff --git a/pyproject.toml b/pyproject.toml index c8f4d88351..a094b05efe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ select = [ "NPY", # numpy-specific rules "PERF", # performance "PL", # pylint + "PYI", # flake8-pyi "Q", # flake8-quotes "RSE", # flake8-raise "SIM", # flake8-simplify diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 6ec0069b3a..f6b6d0b05f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -11,7 +11,7 @@ from pathlib import Path import re import sys import time -from typing import Any +from typing import Any, Self from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -5110,11 +5110,11 @@ class MockSerial: self.timeout = 0.1 self._is_open = False - def __enter__(self) -> MockSerial: + def __enter__(self) -> Self: self._is_open = True return self - def __exit__(self, *args: Any) -> None: + def __exit__(self, *args: object) -> None: self._is_open = False @property From ae814cff5c51314f2daf43e89caeb71a832f636c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 21:28:14 -0500 Subject: [PATCH 1439/2214] [core] Enable ruff B (flake8-bugbear) lint family (#16655) --- esphome/__main__.py | 6 +++--- esphome/analyze_memory/cli.py | 6 +++--- esphome/analyze_memory/demangle.py | 2 +- esphome/components/api/client.py | 2 +- esphome/components/font/__init__.py | 4 +++- esphome/components/lvgl/lv_validation.py | 2 +- esphome/components/lvgl/widgets/tabview.py | 2 +- esphome/components/msa3xx/binary_sensor.py | 2 +- esphome/components/opentherm/output/__init__.py | 2 +- esphome/components/sensor/__init__.py | 9 ++++++--- esphome/core/__init__.py | 2 +- esphome/dashboard/status/mdns.py | 2 +- esphome/dashboard/status/ping.py | 6 +++--- esphome/dashboard/web_server.py | 9 ++++++--- esphome/writer.py | 2 +- esphome/zeroconf.py | 2 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 4 ++-- script/build_language_schema.py | 2 +- script/ci-custom.py | 4 ++-- script/determine-jobs.py | 2 +- script/split_components_for_ci.py | 2 +- script/test_build_components.py | 4 ++-- tests/component_tests/display/test_display_metadata.py | 7 +++---- tests/component_tests/packages/test_packages.py | 10 ++-------- tests/integration/conftest.py | 6 ++++-- tests/unit_tests/test_config_normalization.py | 2 +- 27 files changed, 54 insertions(+), 50 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 07bbd89358..268164acf6 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -608,7 +608,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: try: module = importlib.import_module("esphome.components." + CORE.target_platform) - process_stacktrace = getattr(module, "process_stacktrace") + process_stacktrace = module.process_stacktrace except (AttributeError, ImportError): _LOGGER.info( 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', @@ -1101,7 +1101,7 @@ def upload_program( host = devices[0] try: module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "upload_program")(config, args, host): + if module.upload_program(config, args, host): return 0, host except AttributeError: pass @@ -1353,7 +1353,7 @@ def _validate_bootloader_binary(binary: Path) -> None: def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "show_logs")(config, args, devices): + if module.show_logs(config, args, devices): return 0 except AttributeError: pass diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 8f1f39e1d6..a856e2988d 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -509,7 +509,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):" ) - for i, (symbol, demangled, size) in enumerate(large_core_symbols): + for i, (_symbol, demangled, size) in enumerate(large_core_symbols): # Core symbols only track (symbol, demangled, size) without section info, # so we don't show section labels here lines.append( @@ -601,7 +601,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):" ) - for i, (symbol, demangled, size, section) in enumerate(large_symbols): + for i, (_symbol, demangled, size, section) in enumerate(large_symbols): lines.append( f"{i + 1}. {self._format_symbol_with_section(demangled, size, section)}" ) @@ -640,7 +640,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f" Symbols > {self.RAM_SYMBOL_SIZE_THRESHOLD} B ({len(large_ram_syms)}):" ) - for symbol, demangled, size, section in large_ram_syms[:10]: + for _symbol, demangled, size, section in large_ram_syms[:10]: # Format section label consistently by stripping leading dot section_label = section.lstrip(".") if section else "" display_name = _format_pstorage_name(demangled) diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py index 8999108b51..7dbd6d4f63 100644 --- a/esphome/analyze_memory/demangle.py +++ b/esphome/analyze_memory/demangle.py @@ -154,7 +154,7 @@ def batch_demangle( failed_count = 0 for original, stripped, prefix, demangled in zip( - symbols, symbols_stripped, symbols_prefixes, demangled_lines + symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True ): # Add back any prefix that was removed demangled = _restore_symbol_prefix(prefix, stripped, demangled) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 7fba091730..327973a605 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -108,7 +108,7 @@ async def async_run_logs( platform_process_stacktrace = None try: module = importlib.import_module("esphome.components." + CORE.target_platform) - platform_process_stacktrace = getattr(module, "process_stacktrace") + platform_process_stacktrace = module.process_stacktrace except (AttributeError, ImportError): _LOGGER.info( 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index cb4b1d3a60..4ea6267275 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -594,7 +594,9 @@ async def to_code(config): x.height, ] for (x, y) in zip( - glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args])) + glyph_args, + list(accumulate([len(x.bitmap_data) for x in glyph_args])), + strict=True, ) ] diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index a1b75182eb..27cbfff694 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -239,7 +239,7 @@ def color_retmapper(value): else: r, g, b, _ = from_rgbw(cval) return literal(f"lv_color_make({r}, {g}, {b})") - assert False + raise AssertionError(f"Unhandled lv_color value: {value!r}") def option_string(value): diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 5e9e0494dd..ee252ecf0b 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -97,7 +97,7 @@ class TabviewType(WidgetType): tab_bar = Widget(bar_obj, obj_spec) await set_obj_properties(tab_bar, tab_style) if tab_items_style: - for index, tab_conf in enumerate(config[CONF_TABS]): + for index, _tab_conf in enumerate(config[CONF_TABS]): await set_obj_properties( Widget(lv_obj.get_child(bar_obj, index), button_spec), tab_items_style, diff --git a/esphome/components/msa3xx/binary_sensor.py b/esphome/components/msa3xx/binary_sensor.py index 793d5190af..732a0ed291 100644 --- a/esphome/components/msa3xx/binary_sensor.py +++ b/esphome/components/msa3xx/binary_sensor.py @@ -26,7 +26,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ), key=CONF_NAME, ) - for event, icon in zip(EVENT_SENSORS, ICONS) + for event, icon in zip(EVENT_SENSORS, ICONS, strict=True) } ) diff --git a/esphome/components/opentherm/output/__init__.py b/esphome/components/opentherm/output/__init__.py index 87307eb051..68977b9e34 100644 --- a/esphome/components/opentherm/output/__init__.py +++ b/esphome/components/opentherm/output/__init__.py @@ -21,7 +21,7 @@ async def new_openthermoutput( var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) - cg.add(getattr(var, "set_id")(cg.RawExpression(f'"{key}_{config[CONF_ID]}"'))) + cg.add(var.set_id(cg.RawExpression(f'"{key}_{config[CONF_ID]}"'))) input.generate_setters(var, config) return var diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 6bbab76363..5a2ebf03c0 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1192,7 +1192,7 @@ def _std(x): def _correlation_coeff(x, y): m_x, m_y = _mean(x), _mean(y) - s_xy = sum((x_ - m_x) * (y_ - m_y) for x_, y_ in zip(x, y)) + s_xy = sum((x_ - m_x) * (y_ - m_y) for x_, y_ in zip(x, y, strict=True)) s_sq_x = sum((x_ - m_x) ** 2 for x_ in x) s_sq_y = sum((y_ - m_y) ** 2 for y_ in y) return s_xy / math.sqrt(s_sq_x * s_sq_y) @@ -1228,7 +1228,7 @@ def _mat_copy(m): def _mat_transpose(m): - return _mat_copy(zip(*m)) + return _mat_copy(zip(*m, strict=True)) def _mat_identity(n): @@ -1237,7 +1237,10 @@ def _mat_identity(n): def _mat_dot(a, b): b_t = _mat_transpose(b) - return [[sum(x * y for x, y in zip(row_a, col_b)) for col_b in b_t] for row_a in a] + return [ + [sum(x * y for x, y in zip(row_a, col_b, strict=True)) for col_b in b_t] + for row_a in a + ] def _mat_inverse(m): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 580d7f6477..182be38b18 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1081,7 +1081,7 @@ class EnumValue: @enum_value.setter def enum_value(self, value): - setattr(self, "_enum_value", value) + self._enum_value = value CORE = EsphomeCore() diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index 881340ab24..9da9bb8f01 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -115,7 +115,7 @@ class MDNSStatus: results = await asyncio.gather( *(self.aiozc.async_resolve_host(name) for name in poll_names) ) - for name, address_list in zip(poll_names, results): + for name, address_list in zip(poll_names, results, strict=True): result = bool(address_list) host_mdns_state[name] = result for entry in poll_names[name]: diff --git a/esphome/dashboard/status/ping.py b/esphome/dashboard/status/ping.py index b4f106d21a..eb69fbb9b3 100644 --- a/esphome/dashboard/status/ping.py +++ b/esphome/dashboard/status/ping.py @@ -83,7 +83,7 @@ class PingStatus: return_exceptions=True, ) - for entry, result in zip(ping_group, dns_results): + for entry, result in zip(ping_group, dns_results, strict=True): if isinstance(result, Exception): # Only update state if its unknown or from ping # so we don't mark it as offline if we have a state @@ -106,7 +106,7 @@ class PingStatus: return_exceptions=True, ) - for entry_addresses, result in zip(entry_addresses, results): + for entry_address, result in zip(entry_addresses, results, strict=True): if isinstance(result, Exception): ping_result = False elif isinstance(result, BaseException): @@ -114,7 +114,7 @@ class PingStatus: else: host: Host = result ping_result = host.is_alive - entry: DashboardEntry = entry_addresses[0] + entry: DashboardEntry = entry_address[0] # If we can reach it via ping, we always set it # online, however if we can't reach it via ping # we only set it to offline if the state is unknown diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 916e937a53..88b454e5cf 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1030,7 +1030,7 @@ class DownloadListRequestHandler(BaseHandler): try: module = importlib.import_module(f"esphome.components.{platform}") - get_download_types = getattr(module, "get_download_types") + get_download_types = module.get_download_types except AttributeError as exc: raise ValueError(f"Unknown platform {platform}") from exc downloads = get_download_types(storage_json) @@ -1146,7 +1146,7 @@ class MainRequestHandler(BaseHandler): begin = bool(self.get_argument("begin", False)) if settings.using_password: # Simply accessing the xsrf_token sets the cookie for us - self.xsrf_token # pylint: disable=pointless-statement + self.xsrf_token # pylint: disable=pointless-statement # noqa: B018 else: self.clear_cookie("_xsrf") @@ -1519,7 +1519,10 @@ def get_static_file_url(name: str) -> str: return f"{base}?hash={hash_}" -def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application: +def make_app(debug: bool | None = None) -> tornado.web.Application: + if debug is None: + debug = get_bool_env(ENV_DEV) + def log_function(handler: tornado.web.RequestHandler) -> None: if handler.get_status() < 400: log_method = access_log.info diff --git a/esphome/writer.py b/esphome/writer.py index ad3877465d..ab014c5daa 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -358,7 +358,7 @@ def copy_src_tree(): platform = "esphome.components." + CORE.target_platform try: module = importlib.import_module(platform) - copy_files = getattr(module, "copy_files") + copy_files = module.copy_files copy_files() except AttributeError: pass diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index 5d922ea911..a4f4f46097 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -249,7 +249,7 @@ async def async_resolve_hosts( ), return_exceptions=True, ) - for host, result in zip(pending, results): + for host, result in zip(pending, results, strict=True): if isinstance(result, BaseException): _LOGGER.debug("Failed to resolve %s: %s", host, result) diff --git a/pyproject.toml b/pyproject.toml index a094b05efe..c6d96560d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ + "B", # flake8-bugbear "C4", # flake8-comprehensions "E", # pycodestyle "EXE", # flake8-executable diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 91aec91637..1cc8e1ec98 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3551,7 +3551,7 @@ static const char *const TAG = "api.service"; if id_ is not None and not mt.options.deprecated: id_to_msg_name[id_] = mt.name - for id_, (_, _, case_label) in cases: + for id_, (_, _, _case_label) in cases: msg_name = id_to_msg_name.get(id_, "") if msg_name in message_auth_map: needs_auth = message_auth_map[msg_name] @@ -3614,7 +3614,7 @@ static const char *const TAG = "api.service"; # Dispatch switch out += " switch (msg_type) {\n" - for i, (case, ifdef, case_label) in cases: + for _i, (case, ifdef, case_label) in cases: if ifdef is not None: out += _make_ifdef_line(ifdef) + "\n" diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 921ee9d3d7..9dff70af3c 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -972,7 +972,7 @@ def convert(schema, config_var, path): } elif schema_type == "use_id": if inspect.ismodule(data): - m_attr_obj = getattr(data, "CONFIG_SCHEMA") + m_attr_obj = data.CONFIG_SCHEMA use_schema = known_schemas.get(repr(m_attr_obj)) if use_schema: [output_module, output_name] = use_schema[0][1].split(".") diff --git a/script/ci-custom.py b/script/ci-custom.py index c241343a1b..f2a9681be5 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -342,8 +342,8 @@ def lint_const_ordered(fname, content): (i + 1, line) for i, line in enumerate(lines) if line.startswith(start) ] ordered = sorted(matching, key=lambda x: x[1].replace("_", " ")) - ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered)] - for (mi, mline), (_, ol) in zip(matching, ordered): + ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered, strict=True)] + for (mi, mline), (_, ol) in zip(matching, ordered, strict=True): if mline == ol: continue target = next(i for i, line in ordered if line == mline) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index ef2175eb79..01b8623813 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -1047,7 +1047,7 @@ def detect_memory_impact_config( # Find common platforms supported by ALL components # This ensures we can build all components together in a merged config common_platforms = set(MEMORY_IMPACT_PLATFORM_PREFERENCE) - for component, platforms in component_platforms_map.items(): + for platforms in component_platforms_map.values(): common_platforms &= platforms # Select the most preferred platform from the common set diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 0d10246bb4..7f06f50f48 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -295,7 +295,7 @@ def main() -> int: # Sort groups by signature for readability groupable_groups = [] isolated_groups = [] - for (platform, signature), group_comps in sorted(signature_groups.items()): + for (_platform, signature), group_comps in sorted(signature_groups.items()): if signature.startswith(ISOLATED_SIGNATURE_PREFIX): isolated_groups.append((signature, group_comps)) else: diff --git a/script/test_build_components.py b/script/test_build_components.py index 43b71004eb..51f3758291 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -890,7 +890,7 @@ def run_grouped_component_tests( print("=" * 80 + "\n") # Execute grouped tests - for (platform, signature), components in grouped_components.items(): + for (platform, _signature), components in grouped_components.items(): # Only group if we have multiple components with same signature if len(components) <= 1: continue @@ -1055,7 +1055,7 @@ def test_components( # Create empty test files for each platform (or filtered platform) reference_tests: list[Path] = [] - for platform_name, base_file in platform_bases.items(): + for platform_name in platform_bases: if platform_filter and not platform_name.startswith(platform_filter): continue # Create an empty test file named to match the platform diff --git a/tests/component_tests/display/test_display_metadata.py b/tests/component_tests/display/test_display_metadata.py index e569754494..ef3f12cb73 100644 --- a/tests/component_tests/display/test_display_metadata.py +++ b/tests/component_tests/display/test_display_metadata.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from esphome.components.display import ( DisplayMetaData, add_metadata, @@ -74,8 +76,5 @@ def test_add_metadata_overwrites_existing(): def test_metadata_is_frozen(): """Test that DisplayMetaData instances are immutable (frozen dataclass).""" meta = DisplayMetaData(320, 240, True, False) - try: + with pytest.raises(AttributeError): meta.width = 640 - assert False, "Expected FrozenInstanceError" - except AttributeError: - pass diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 8c809c5e91..66f946a5bd 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -510,15 +510,9 @@ def test_package_merge_by_missing_id() -> None: ], } - error_raised = False - try: + with pytest.raises(cv.Invalid) as exc_info: packages_pass(config) - assert False, "Expected validation error for missing ID" - except cv.Invalid as err: - error_raised = True - assert err.path == [CONF_SENSOR, 2] - - assert error_raised + assert exc_info.value.path == [CONF_SENSOR, 2] def test_package_list_remove_by_id() -> None: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index fb025ce427..e593929583 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -407,8 +407,10 @@ async def wait_and_connect_api_client( # Wait for connection with timeout try: await asyncio.wait_for(connected_future, timeout=timeout) - except TimeoutError: - raise TimeoutError(f"Failed to connect to API after {timeout} seconds") + except TimeoutError as err: + raise TimeoutError( + f"Failed to connect to API after {timeout} seconds" + ) from err if return_disconnect_event: yield client, disconnect_event diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index d70f3c24e0..4ec17b3c7c 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -67,7 +67,7 @@ def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> Non configs = list(config.iter_component_configs(test_config)) assert len(configs) == 2 - for domain, component, conf in configs: + for domain, _component, conf in configs: assert domain == "switch" assert "name" in conf From ae74920b814ad555a15002d12675d057b040e0e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 00:14:42 -0500 Subject: [PATCH 1440/2214] [core] Enable ruff PTH (flake8-use-pathlib) lint family (#16661) --- esphome/__main__.py | 4 +- esphome/analyze_memory/cli.py | 6 +-- esphome/analyze_memory/toolchain.py | 3 +- esphome/build_gen/espidf.py | 2 +- esphome/bundle.py | 2 +- esphome/components/audio_file/__init__.py | 2 +- esphome/components/bme68x_bsec2/__init__.py | 2 +- .../esp32_hosted/update/__init__.py | 4 +- esphome/components/http_request/__init__.py | 4 +- esphome/components/image/__init__.py | 2 +- .../components/micro_wake_word/__init__.py | 4 +- esphome/components/nrf52/ota.py | 2 +- esphome/components/rp2040/generate_boards.py | 4 +- esphome/components/web_server/__init__.py | 4 +- esphome/components/zigbee/zigbee_esp32.py | 5 +-- esphome/dashboard/dashboard.py | 3 +- esphome/dashboard/web_server.py | 6 +-- esphome/espidf/component.py | 18 ++++---- esphome/espidf/extra_script.py | 2 +- esphome/espidf/framework.py | 44 +++++++++++-------- esphome/espidf/get_idf_tool_paths.py | 3 +- esphome/espidf/runner.py | 3 +- esphome/espidf/toolchain.py | 19 ++++---- esphome/espota2.py | 2 +- esphome/helpers.py | 4 +- esphome/mqtt.py | 6 +-- esphome/web_server_ota.py | 2 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 12 ++--- script/build_helpers.py | 2 +- script/bump-version.py | 5 ++- script/ci-custom.py | 2 +- script/ci_add_metadata_to_json.py | 4 +- script/ci_helpers.py | 3 +- script/ci_memory_impact_comment.py | 2 +- script/ci_memory_impact_extract.py | 4 +- script/clang-format | 3 +- script/clang-tidy | 9 ++-- script/clang_tidy_hash.py | 6 +-- script/determine-jobs.py | 2 +- script/helpers.py | 19 ++++---- script/lint-python | 6 ++- script/sync-device_class.py | 5 ++- script/test_build_components.py | 2 +- tests/dashboard/test_web_server_paths.py | 10 ++--- tests/integration/conftest.py | 2 +- tests/script/test_check_import_time.py | 7 +-- tests/script/test_determine_jobs.py | 7 +-- tests/script/test_helpers.py | 4 +- tests/script/test_test_helpers.py | 5 +-- tests/unit_tests/core/test_config.py | 20 ++++----- tests/unit_tests/test_espidf_component.py | 2 +- tests/unit_tests/test_substitutions.py | 3 +- tests/unit_tests/test_writer.py | 8 ++-- 54 files changed, 162 insertions(+), 155 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 268164acf6..5f281ce832 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -794,7 +794,7 @@ def _check_and_emit_build_info() -> None: # Read build_info from JSON try: - with open(build_info_json_path, encoding="utf-8") as f: + with build_info_json_path.open(encoding="utf-8") as f: build_info = json.load(f) except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Failed to read build_info: %s", e) @@ -1056,7 +1056,7 @@ def _wait_for_serial_port( def _port_found() -> bool: if port is not None: if os.name == "posix": - return os.path.exists(port) + return Path(port).exists() return any(p.path == port for p in get_serial_ports()) ports = get_serial_ports() if known_ports is not None: diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index a856e2988d..4fbceb7e5e 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -6,6 +6,7 @@ from collections import defaultdict from collections.abc import Callable import heapq from operator import itemgetter +from pathlib import Path import sys from typing import TYPE_CHECKING @@ -699,7 +700,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): content = "\n".join(lines) if output_file: - with open(output_file, "w", encoding="utf-8") as f: + with Path(output_file).open("w", encoding="utf-8") as f: f.write(content) else: print(content) @@ -737,7 +738,6 @@ def main(): # Load build directory import json - from pathlib import Path from esphome.platformio.toolchain import IDEData @@ -785,7 +785,7 @@ def main(): if not idedata_path.exists(): continue try: - with open(idedata_path, encoding="utf-8") as f: + with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index 3a8a5f7be4..a724d52f25 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import os from pathlib import Path import subprocess from typing import TYPE_CHECKING @@ -37,7 +36,7 @@ def _find_in_platformio_packages(tool_name: str) -> str | None: Full path to the tool or None if not found """ # Get PlatformIO packages directory - platformio_home = Path(os.path.expanduser("~/.platformio/packages")) + platformio_home = Path("~/.platformio/packages").expanduser() if not platformio_home.exists(): return None diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 96f84ebbd1..0b50f72382 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -24,7 +24,7 @@ def get_available_components() -> list[str] | None: return None try: - with open(project_desc, encoding="utf-8") as f: + with project_desc.open(encoding="utf-8") as f: data = json.load(f) component_info = data.get("build_component_info", {}) diff --git a/esphome/bundle.py b/esphome/bundle.py index 4537cbce9d..d38f68ebfd 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -412,7 +412,7 @@ class ConfigBundleCreator: @staticmethod def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None: """Add a BundleFile to the tar archive with deterministic metadata.""" - with open(bf.source, "rb") as f: + with bf.source.open("rb") as f: _add_bytes_to_tar(tar, bf.path, f.read()) diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 23c90e9b76..8dc546cec1 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -98,7 +98,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: else: raise cv.Invalid("Unsupported file source") - with open(path, "rb") as f: + with path.open("rb") as f: data = f.read() try: diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 5083d283ef..62cd9e2e36 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -169,7 +169,7 @@ async def to_code_base(config): path = _compute_local_file_path(_compute_url(config)) try: - with open(path, encoding="utf-8") as f: + with path.open(encoding="utf-8") as f: bsec2_iaq_config = f.read() except Exception as e: raise core.EsphomeError( diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index b258a26b08..202df21ab5 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -75,7 +75,7 @@ def _validate_firmware(config: dict[str, Any]) -> None: return path = CORE.relative_config_path(config[CONF_PATH]) - with open(path, "rb") as f: + with path.open("rb") as f: firmware_data = f.read() calculated = hashlib.sha256(firmware_data).hexdigest() expected = config[CONF_SHA256].lower() @@ -93,7 +93,7 @@ async def to_code(config: dict[str, Any]) -> None: if config[CONF_TYPE] == TYPE_EMBEDDED: path = config[CONF_PATH] - with open(CORE.relative_config_path(path), "rb") as f: + with CORE.relative_config_path(path).open("rb") as f: firmware_data = f.read() rhs = [HexInt(x) for x in firmware_data] arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 90879c459e..2617951f0d 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -1,3 +1,5 @@ +from pathlib import Path + from esphome import automation import esphome.codegen as cg from esphome.components import esp32 @@ -174,7 +176,7 @@ async def to_code(config): if config.get(CONF_VERIFY_SSL): if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH): - with open(ca_cert_path, encoding="utf-8") as f: + with Path(ca_cert_path).open(encoding="utf-8") as f: ca_cert_content = f.read() cg.add(var.set_ca_certificate(ca_cert_content)) else: diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 365554f7d2..2fefbdcd58 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -395,7 +395,7 @@ def download_image(value): def is_svg_file(file): if not file: return False - with open(file, "rb") as f: + with Path(file).open("rb") as f: return " tuple[dict, dict]: for json_file in sorted(json_dir.glob("*.json")): board_name = json_file.stem - with open(json_file, encoding="utf-8") as f: + with json_file.open(encoding="utf-8") as f: data = json.load(f) build = data.get("build", {}) @@ -136,7 +136,7 @@ def _get_variant(json_file: Path) -> str | None: """Get variant name from a board JSON file.""" if not json_file.exists(): return None - with open(json_file, encoding="utf-8") as f: + with json_file.open(encoding="utf-8") as f: data = json.load(f) return data.get("build", {}).get("variant") diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 84910b6f90..99a9b7518c 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -326,12 +326,12 @@ async def to_code(config): if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) - with open(file=path, encoding="utf-8") as css_file: + with path.open(encoding="utf-8") as css_file: add_resource_as_progmem("CSS_INCLUDE", css_file.read()) if CONF_JS_INCLUDE in config: cg.add_define("USE_WEBSERVER_JS_INCLUDE") path = CORE.relative_config_path(config[CONF_JS_INCLUDE]) - with open(file=path, encoding="utf-8") as js_file: + with path.open(encoding="utf-8") as js_file: add_resource_as_progmem("JS_INCLUDE", js_file.read()) cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL])) if CONF_LOCAL in config and config[CONF_LOCAL]: diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 89efd583ab..a0fadbce8b 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -129,9 +129,8 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: if CONF_PARTITIONS in fv.full_config.get() and not isinstance( fv.full_config.get()[CONF_PARTITIONS], list ): - with open( - CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]), - encoding="utf8", + with CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]).open( + encoding="utf8" ) as f: partitions_tab = f.read() for partition, types in [ diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py index 81c10763e7..7fc21f8a44 100644 --- a/esphome/dashboard/dashboard.py +++ b/esphome/dashboard/dashboard.py @@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor import contextlib import logging import os +from pathlib import Path import socket import threading from time import monotonic @@ -149,4 +150,4 @@ async def async_start(args) -> None: await dashboard.async_run() finally: if sock: - os.remove(sock) + Path(sock).unlink() diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 88b454e5cf..97d6639c1f 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1040,7 +1040,7 @@ class DownloadListRequestHandler(BaseHandler): class DownloadBinaryRequestHandler(BaseHandler): def _load_file(self, path: str, compressed: bool) -> bytes: """Load a file from disk and compress it if requested.""" - with open(path, "rb") as f: + with Path(path).open("rb") as f: data = f.read() if compressed: return gzip.compress(data, 9) @@ -1292,7 +1292,7 @@ class EditRequestHandler(BaseHandler): def _read_file(self, filename: str, configuration: str) -> bytes | None: """Read a file and return the content as bytes.""" try: - with open(file=filename, encoding="utf-8") as f: + with Path(filename).open(encoding="utf-8") as f: return f.read() except FileNotFoundError: if configuration in const.SECRETS_FILES: @@ -1493,7 +1493,7 @@ def get_base_frontend_path() -> Path: static_path += "/" # This path can be relative, so resolve against the root or else templates don't work - path = Path(os.getcwd()) / static_path / "esphome_dashboard" + path = Path.cwd() / static_path / "esphome_dashboard" return path.resolve() diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 81f2cd9632..050002d9e2 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -317,24 +317,26 @@ def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[s if pattern.endswith("/"): pattern = pattern.rstrip("/") + "/**" - full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) + # glob.escape has no pathlib equivalent and the matcher works on raw + # path strings, so PTH118/PTH207 don't apply here. + full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 matched = [] - for item in glob.glob(full_pattern, recursive=True): - if not os.path.isdir(item): + for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 + if not Path(item).is_dir(): matched.append(item) else: # PlatformIO quirk: a directory matched with "*" should include all its # nested files and subdirectories, not just the directory itself. for root, _, files in os.walk(item): - matched.extend([os.path.join(root, f) for f in files]) + matched.extend([str(Path(root) / f) for f in files]) if sign == "+": selected.update(matched) elif sign == "-": selected.difference_update(matched) - return [r for r in selected if os.path.isfile(r)] + return [r for r in selected if Path(r).is_file()] def _convert_library_to_component(library: Library) -> IDFComponent: @@ -486,7 +488,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # Only keep sources build_src_files = [os.path.relpath(p, component.path) for p in build_src_files] build_src_files = [ - f for f in build_src_files if os.path.splitext(f)[1] in SRC_FILE_EXTENSIONS + f for f in build_src_files if Path(f).suffix in SRC_FILE_EXTENSIONS ] # Handle build flags @@ -740,7 +742,7 @@ def _parse_library_json(library_json_path: PathType): Returns: dict: Parsed JSON content as a Python dictionary. """ - with open(library_json_path, encoding="utf8") as fp: + with Path(library_json_path).open(encoding="utf8") as fp: return json.load(fp) @@ -754,7 +756,7 @@ def _parse_library_properties(library_properties_path: PathType): Returns: dict[str, str]: Mapping of parsed property keys to values. """ - with open(library_properties_path, encoding="utf8") as fp: + with Path(library_properties_path).open(encoding="utf8") as fp: data = {} for line in fp.read().splitlines(): line = line.strip() diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index 2f22f23c10..bead63ca21 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -108,7 +108,7 @@ def run_extra_script( """ env = _FakeSConsEnv(board_mcu=idf_target, pio_env=f"esphome_{idf_target}") code = compile(script_path.read_text(), str(script_path), "exec") - old_cwd = os.getcwd() + old_cwd = Path.cwd() try: os.chdir(library_dir) exec( # noqa: S102 pylint: disable=exec-used diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f393600732..331c2f84b0 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -138,7 +138,7 @@ def rmdir(directory: PathType, msg: str | None = None): Raises: RuntimeError: If directory removal fails """ - if os.path.isdir(directory): + if Path(directory).is_dir(): try: if msg: _LOGGER.debug(msg) @@ -192,7 +192,7 @@ def _check_stamp(file: PathType, data: dict[str, str]) -> bool: return False try: - with open(file, encoding="utf-8") as f: + with Path(file).open(encoding="utf-8") as f: return json.load(f) == data except (json.JSONDecodeError, OSError): return False @@ -206,7 +206,7 @@ def _write_stamp(file: PathType, data: dict[str, str]): file: Path to the stamp file to write data: Dictionary containing data to write """ - with open(file, "w", encoding="utf8") as fp: + with Path(file).open("w", encoding="utf8") as fp: json.dump(data, fp) @@ -471,8 +471,12 @@ def _tar_extract_all( import stat import tarfile + # Tar extraction safety: os.path.realpath / commonpath / normpath have no + # pathlib equivalents and Path.resolve() would follow symlinks unsafely. + # Use os.path for the security-sensitive parts; the simple checks move to + # Path. extract_dir = os.fspath(extract_dir) - abs_dest = os.path.abspath(extract_dir) + abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 with tarfile.open(fileobj=data, mode="r") as tar_ref: all_members = tar_ref.getmembers() @@ -491,8 +495,8 @@ def _tar_extract_all( name = name.lstrip("/" + os.sep) # 2. Reject absolute paths (incl. Windows drive) - if os.path.isabs(name) or ( - os.name == "nt" and ":" in name.split(os.sep)[0] + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 ): continue @@ -506,7 +510,7 @@ def _tar_extract_all( name = norm[len(strip_prefix) :] # 4. Compute final path - target_path = os.path.realpath(os.path.join(abs_dest, name)) + target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 if os.path.commonpath([abs_dest, target_path]) != abs_dest: continue @@ -515,18 +519,20 @@ def _tar_extract_all( linkname = member.linkname # Reject absolute link targets - if os.path.isabs(linkname): + if Path(linkname).is_absolute(): continue # Strip leading slashes linkname = os.path.normpath(linkname) if member.issym(): - link_target = os.path.join( - abs_dest, os.path.dirname(name), linkname + link_target = os.path.join( # noqa: PTH118 + abs_dest, + os.path.dirname(name), # noqa: PTH120 + linkname, ) else: - link_target = os.path.join(abs_dest, linkname) + link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 link_target = os.path.realpath(link_target) if os.path.commonpath([abs_dest, link_target]) != abs_dest: @@ -598,7 +604,9 @@ def _zip_extract_all( """ import zipfile - extract_dir = os.path.abspath(extract_dir) + # See note in archive_extract_all_tar: os.path is used intentionally for + # the security-sensitive abspath/commonpath checks below. + extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 with zipfile.ZipFile(data, "r") as zip_ref: all_members = zip_ref.infolist() @@ -618,8 +626,8 @@ def _zip_extract_all( name = member.filename.lstrip("/\\") # 2. Reject absolute paths / Windows drives - if os.path.isabs(name) or ( - os.name == "nt" and ":" in name.split(os.sep)[0] + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 ): continue @@ -633,7 +641,7 @@ def _zip_extract_all( name = norm[len(strip_prefix) :] # 4. Compute safe target path - target_path = os.path.abspath(os.path.join(extract_dir, name)) + target_path = os.path.abspath(os.path.join(extract_dir, name)) # noqa: PTH100, PTH118 if os.path.commonpath([extract_dir, target_path]) != extract_dir: raise ValueError(f"Unsafe path detected: {member.filename}") @@ -680,7 +688,7 @@ def archive_extract_all( with ExitStack() as stack: archive_ref: io.BufferedIOBase if isinstance(archive, (str, os.PathLike)): - archive_ref = stack.enter_context(open(archive, "rb")) + archive_ref = stack.enter_context(Path(archive).open("rb")) elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)): archive_ref = archive elif isinstance(archive, io.RawIOBase): @@ -727,7 +735,7 @@ def download_from_mirrors( # 1. Open target file for writing if path given with ExitStack() as stack: if isinstance(target, (str, os.PathLike)): - f = stack.enter_context(open(target, "wb")) + f = stack.enter_context(Path(target).open("wb")) elif isinstance(target, (io.RawIOBase, io.IOBase)): f = target else: @@ -917,7 +925,7 @@ def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: return try: - with open(tools_json, encoding="utf-8") as f: + with tools_json.open(encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, OSError) as e: _LOGGER.warning( diff --git a/esphome/espidf/get_idf_tool_paths.py b/esphome/espidf/get_idf_tool_paths.py index 2e8859631d..7d99e629b1 100644 --- a/esphome/espidf/get_idf_tool_paths.py +++ b/esphome/espidf/get_idf_tool_paths.py @@ -10,6 +10,7 @@ not installed. import json import os +from pathlib import Path import sys from types import SimpleNamespace @@ -25,7 +26,7 @@ from idf_tools import ( g.idf_path = sys.argv[1] g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH") -g.tools_json = os.path.join(g.idf_path, TOOLS_FILE) +g.tools_json = str(Path(g.idf_path) / TOOLS_FILE) tools_info = filter_tools_info(IDFEnv.get_idf_env(), load_tools_info()) args = SimpleNamespace(prefer_system=False) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 857d16c674..7c568db7be 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -91,6 +91,7 @@ def main() -> int: # ---- end sys.path fix-up ----------------------------------------------- import os + from pathlib import Path import re import runpy @@ -229,7 +230,7 @@ def main() -> int: # runpy.run_path does not do this automatically, but idf.py relies # on it to import its sibling modules (python_version_checker, # idf_py_actions, ...). - script_dir = os.path.dirname(os.path.abspath(script_path)) + script_dir = str(Path(script_path).resolve().parent) if script_dir not in sys.path: sys.path.insert(0, script_dir) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index ef28575caa..752f582e74 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -241,20 +241,21 @@ def has_outdated_files(): dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") - if not os.path.isdir(build_config_path) or not os.listdir(build_config_path): + if not build_config_path.is_dir() or not any(build_config_path.iterdir()): return True - if not os.path.isfile(cmakecache_txt_path): + if not cmakecache_txt_path.is_file(): return True - if not os.path.isfile(build_ninja_path): + if not build_ninja_path.is_file(): return True - if os.path.isfile(dependency_lock_path) and os.path.getmtime( - dependency_lock_path - ) > os.path.getmtime(build_ninja_path): + if ( + dependency_lock_path.is_file() + and dependency_lock_path.stat().st_mtime > build_ninja_path.stat().st_mtime + ): return True - cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) + cmakecache_txt_mtime = cmakecache_txt_path.stat().st_mtime return any( - os.path.getmtime(f) > cmakecache_txt_mtime + f.stat().st_mtime > cmakecache_txt_mtime for f in [sdkconfig_internal_path, idf_component_yml_path] if f.exists() ) @@ -452,7 +453,7 @@ def create_factory_bin() -> bool: return False try: - with open(flasher_args_path, encoding="utf-8") as f: + with flasher_args_path.open(encoding="utf-8") as f: flash_data = json.load(f) except (json.JSONDecodeError, OSError) as e: _LOGGER.error("Failed to read flasher_args.json: %s", e) diff --git a/esphome/espota2.py b/esphome/espota2.py index 701a125bcd..266702c142 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -517,7 +517,7 @@ def run_ota_impl_( continue _LOGGER.info("Connected to %s", sa[0]) - with open(filename, "rb") as file_handle: + with Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) except OTAError as err: diff --git a/esphome/helpers.py b/esphome/helpers.py index d7ddb5c416..733474c9c9 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -385,7 +385,7 @@ def rmtree(path: Path | str) -> None: def _onerror(func, path, exc_info): if os.access(path, os.W_OK): raise exc_info[1].with_traceback(exc_info[2]) - os.chmod(path, stat.S_IWUSR | stat.S_IRUSR) + Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR) func(path) # ``onerror`` is deprecated in 3.12 in favour of ``onexc`` (different @@ -512,7 +512,7 @@ def copy_file_if_changed(src: Path, dst: Path) -> bool: # -> delete file (it would be overwritten anyway), and try again # if that fails, use normal error handler with suppress(OSError): - os.unlink(dst) + Path(dst).unlink() shutil.copyfile(src, dst) return True diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 098292f599..d6bde0cbfd 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -2,7 +2,7 @@ import contextlib from datetime import datetime import json import logging -import os +from pathlib import Path import ssl import tempfile import time @@ -120,8 +120,8 @@ def prepare( key_file.close() context.load_cert_chain(cert_file.name, key_file.name) finally: - os.unlink(cert_file.name) - os.unlink(key_file.name) + Path(cert_file.name).unlink() + Path(key_file.name).unlink() client.tls_set_context(context) try: diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py index 7c31c1b123..8d0fdeecff 100644 --- a/esphome/web_server_ota.py +++ b/esphome/web_server_ota.py @@ -126,7 +126,7 @@ def _try_upload( _LOGGER.info("Connecting to %s port %s...", ip, port) try: - with open(filename, "rb") as fh: + with filename.open("rb") as fh: streamer = _MultipartStreamer(fh, file_size, filename.name) try: response = requests.post( diff --git a/pyproject.toml b/pyproject.toml index c6d96560d5..ae1bd34f60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,7 @@ select = [ "NPY", # numpy-specific rules "PERF", # performance "PL", # pylint + "PTH", # flake8-use-pathlib "PYI", # flake8-pyi "Q", # flake8-quotes "RSE", # flake8-raise diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 1cc8e1ec98..240ee7890f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3163,7 +3163,7 @@ def main() -> None: defines_content += "\n" defines_content += "\nnamespace esphome::api {} // namespace esphome::api\n" - with open(root / "api_pb2_defines.h", "w", encoding="utf-8") as f: + with (root / "api_pb2_defines.h").open("w", encoding="utf-8") as f: f.write(defines_content) content = FILE_HEADER @@ -3448,13 +3448,13 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint #endif // HAS_PROTO_MESSAGE_DUMP """ - with open(root / "api_pb2.h", "w", encoding="utf-8") as f: + with (root / "api_pb2.h").open("w", encoding="utf-8") as f: f.write(content) - with open(root / "api_pb2.cpp", "w", encoding="utf-8") as f: + with (root / "api_pb2.cpp").open("w", encoding="utf-8") as f: f.write(cpp) - with open(root / "api_pb2_dump.cpp", "w", encoding="utf-8") as f: + with (root / "api_pb2_dump.cpp").open("w", encoding="utf-8") as f: f.write(dump_cpp) hpp = FILE_HEADER @@ -3641,10 +3641,10 @@ static const char *const TAG = "api.service"; } // namespace esphome::api """ - with open(root / "api_pb2_service.h", "w", encoding="utf-8") as f: + with (root / "api_pb2_service.h").open("w", encoding="utf-8") as f: f.write(hpp) - with open(root / "api_pb2_service.cpp", "w", encoding="utf-8") as f: + with (root / "api_pb2_service.cpp").open("w", encoding="utf-8") as f: f.write(cpp) prot_file.unlink() diff --git a/script/build_helpers.py b/script/build_helpers.py index fa722aa099..52f7ee317e 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -195,7 +195,7 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict: yaml_path = tests_dir / component / BENCHMARK_YAML_FILENAME if not yaml_path.is_file(): continue - with open(yaml_path) as f: + with yaml_path.open() as f: component_config = yaml.safe_load(f) if component_config and isinstance(component_config, dict): for key, value in component_config.items(): diff --git a/script/bump-version.py b/script/bump-version.py index ed927cb991..e09fc87c60 100755 --- a/script/bump-version.py +++ b/script/bump-version.py @@ -2,6 +2,7 @@ import argparse from dataclasses import dataclass +from pathlib import Path import re import sys @@ -39,12 +40,12 @@ class Version: def sub(path, pattern, repl, expected_count=1): - with open(path, encoding="utf-8") as fh: + with Path(path).open(encoding="utf-8") as fh: content = fh.read() content, count = re.subn(pattern, repl, content, flags=re.MULTILINE) if expected_count is not None: assert count == expected_count, f"Pattern {pattern} replacement failed!" - with open(path, "w", encoding="utf-8") as fh: + with Path(path).open("w", encoding="utf-8") as fh: fh.write(content) diff --git a/script/ci-custom.py b/script/ci-custom.py index f2a9681be5..1ac13e18f7 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -14,7 +14,7 @@ import time import colorama from helpers import filter_changed, git_ls_files, print_error_for_file, styled -sys.path.append(os.path.dirname(__file__)) +sys.path.append(str(Path(__file__).parent)) def find_all(a_str, sub): diff --git a/script/ci_add_metadata_to_json.py b/script/ci_add_metadata_to_json.py index 687b5131c0..e884e9a64c 100755 --- a/script/ci_add_metadata_to_json.py +++ b/script/ci_add_metadata_to_json.py @@ -44,7 +44,7 @@ def main() -> int: return 1 try: - with open(json_path, encoding="utf-8") as f: + with Path(json_path).open(encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, OSError) as e: print(f"Error loading JSON: {e}", file=sys.stderr) @@ -74,7 +74,7 @@ def main() -> int: # Write back try: - with open(json_path, "w", encoding="utf-8") as f: + with Path(json_path).open("w", encoding="utf-8") as f: json.dump(data, f, indent=2) print(f"Added metadata to {args.json_file}", file=sys.stderr) except OSError as e: diff --git a/script/ci_helpers.py b/script/ci_helpers.py index 48b0e4bbfe..a51a857ada 100644 --- a/script/ci_helpers.py +++ b/script/ci_helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from pathlib import Path def write_github_output(outputs: dict[str, str | int]) -> None: @@ -16,7 +17,7 @@ def write_github_output(outputs: dict[str, str | int]) -> None: """ github_output = os.environ.get("GITHUB_OUTPUT") if github_output: - with open(github_output, "a", encoding="utf-8") as f: + with Path(github_output).open("a", encoding="utf-8") as f: f.writelines(f"{key}={value}\n" for key, value in outputs.items()) else: for key, value in outputs.items(): diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index 01316da27f..0908b99595 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -91,7 +91,7 @@ def load_analysis_json(json_path: str) -> dict | None: return None try: - with open(json_file, encoding="utf-8") as f: + with Path(json_file).open(encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError) as e: print(f"Failed to load analysis JSON: {e}", file=sys.stderr) diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index 2aa7394b11..feacc2b1af 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -127,7 +127,7 @@ def run_detailed_analysis(build_dir: str) -> dict | None: if not idedata_path.exists(): continue try: - with open(idedata_path, encoding="utf-8") as f: + with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) @@ -264,7 +264,7 @@ def main() -> int: output_path = Path(args.output_json) output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, "w", encoding="utf-8") as f: + with output_path.open("w", encoding="utf-8") as f: json.dump(output_data, f, indent=2) print(f"Saved analysis to {args.output_json}", file=sys.stderr) diff --git a/script/clang-format b/script/clang-format index 028d752c55..df45798a30 100755 --- a/script/clang-format +++ b/script/clang-format @@ -2,6 +2,7 @@ import argparse import os +from pathlib import Path import queue import re import subprocess @@ -70,7 +71,7 @@ def main(): ) args = parser.parse_args() - cwd = os.getcwd() + cwd = Path.cwd() files = [ os.path.relpath(path, cwd) for path in git_ls_files(["*.cpp", "*.h", "*.tcc"]) ] diff --git a/script/clang-tidy b/script/clang-tidy index 1c413ffa23..56c0a9db71 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -2,6 +2,7 @@ import argparse import os +from pathlib import Path import queue import re import shutil @@ -32,7 +33,7 @@ def clang_options(idedata): cmd = [] # extract target architecture from triplet in g++ filename - triplet = os.path.basename(idedata["cxx_path"])[:-4] + triplet = Path(idedata["cxx_path"]).name[:-4] if triplet.startswith("xtensa-"): # clang doesn't support Xtensa (yet?), so compile in 32-bit mode and pretend we're the Xtensa compiler cmd.append("-m32") @@ -153,8 +154,8 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): if sys.stdout.isatty(): invocation.append("--use-color") - invocation.append(f"--header-filter={os.path.abspath(basepath)}/.*") - invocation.append(os.path.abspath(path)) + invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") + invocation.append(str(Path(path).resolve())) invocation.append("--") invocation.extend(options) @@ -229,7 +230,7 @@ def main(): ) args = parser.parse_args() - cwd = os.getcwd() + cwd = Path.cwd() files = [os.path.relpath(path, cwd) for path in git_ls_files(["*.cpp"])] # Exclude benchmark files — they require google benchmark headers not # available in the ESP32 toolchain and use different naming conventions. diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index d0d8438437..f478535567 100755 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -16,7 +16,7 @@ sys.path.insert(0, str(script_dir)) def read_file_lines(path: Path) -> list[str]: """Read lines from a file.""" - with open(path) as f: + with path.open() as f: return f.readlines() @@ -65,7 +65,7 @@ def get_clang_tidy_version_from_requirements(repo_root: Path | None = None) -> s def read_file_bytes(path: Path) -> bytes: """Read bytes from a file.""" - with open(path, "rb") as f: + with path.open("rb") as f: return f.read() @@ -120,7 +120,7 @@ def read_stored_hash(repo_root: Path | None = None) -> str | None: def write_file_content(path: Path, content: str) -> None: """Write content to a file.""" - with open(path, "w") as f: + with path.open("w") as f: f.write(content) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 01b8623813..417716cd77 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -306,7 +306,7 @@ def _is_clang_tidy_full_scan() -> bool: """ try: result = subprocess.run( - [os.path.join(root_path, "script", "clang_tidy_hash.py"), "--check"], + [str(Path(root_path) / "script" / "clang_tidy_hash.py"), "--check"], capture_output=True, check=False, ) diff --git a/script/helpers.py b/script/helpers.py index cf82a89f93..c56a434edf 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -17,10 +17,10 @@ from typing import Any import colorama -root_path = os.path.abspath(os.path.normpath(os.path.join(__file__, "..", ".."))) -basepath = os.path.join(root_path, "esphome") -temp_folder = os.path.join(root_path, ".temp") -temp_header_file = os.path.join(temp_folder, "all-include.cpp") +root_path = str(Path(__file__).resolve().parent.parent) +basepath = str(Path(root_path) / "esphome") +temp_folder = str(Path(root_path) / ".temp") +temp_header_file = str(Path(temp_folder) / "all-include.cpp") # C++ file extensions used for clang-tidy and clang-format checks CPP_FILE_EXTENSIONS = (".cpp", ".h", ".hpp", ".cc", ".cxx", ".c", ".tcc") @@ -339,8 +339,8 @@ def _get_github_event_data() -> dict | None: Parsed event data dictionary, or None if not available """ github_event_path = os.environ.get("GITHUB_EVENT_PATH") - if github_event_path and os.path.exists(github_event_path): - with open(github_event_path) as f: + if github_event_path and Path(github_event_path).exists(): + with Path(github_event_path).open() as f: return json.load(f) return None @@ -464,7 +464,8 @@ def _get_changed_files_from_command(command: list[str]) -> list[str]: raise Exception(f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}") changed_files = splitlines_no_ends(proc.stdout) - changed_files = [os.path.relpath(f, os.getcwd()) for f in changed_files if f] + cwd = Path.cwd() + changed_files = [os.path.relpath(f, cwd) for f in changed_files if f] # noqa: PTH109 changed_files.sort() return changed_files @@ -499,7 +500,7 @@ def get_changed_components() -> list[str] | None: return None # Use list-components.py to get changed components - script_path = os.path.join(root_path, "script", "list-components.py") + script_path = str(Path(root_path) / "script" / "list-components.py") cmd = [script_path, "--changed"] try: @@ -619,7 +620,7 @@ def filter_changed(files: list[str]) -> list[str]: def filter_grep(files: list[str], value: list[str]) -> list[str]: matched = [] for file in files: - with open(file, encoding="utf-8") as handle: + with Path(file).open(encoding="utf-8") as handle: contents = handle.read() if any(v in contents for v in value): matched.append(file) diff --git a/script/lint-python b/script/lint-python index 18281c711e..e4b3314d2a 100755 --- a/script/lint-python +++ b/script/lint-python @@ -2,6 +2,7 @@ import argparse import os +from pathlib import Path import re import sys @@ -66,11 +67,12 @@ def main(): args = parser.parse_args() files = [] + cwd = Path.cwd() for path in git_ls_files(): filetypes = (".py",) - ext = os.path.splitext(path)[1] + ext = Path(path).suffix if ext in filetypes and path.startswith("esphome"): - path = os.path.relpath(path, os.getcwd()) + path = os.path.relpath(path, cwd) files.append(path) # Match against re file_name_re = re.compile("|".join(args.files)) diff --git a/script/sync-device_class.py b/script/sync-device_class.py index 121c89b8f9..660142195a 100755 --- a/script/sync-device_class.py +++ b/script/sync-device_class.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +from pathlib import Path import re # pylint: disable=import-error @@ -34,10 +35,10 @@ DOMAINS = { def sub(path, pattern, repl): - with open(path, encoding="utf-8") as handle: + with Path(path).open(encoding="utf-8") as handle: content = handle.read() content = re.sub(pattern, repl, content, flags=re.MULTILINE) - with open(path, "w", encoding="utf-8") as handle: + with Path(path).open("w", encoding="utf-8") as handle: handle.write(content) diff --git a/script/test_build_components.py b/script/test_build_components.py index 51f3758291..767b55c94b 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -297,7 +297,7 @@ def write_github_summary( test_results: List of all test results """ summary_content = format_github_summary(test_results, toolchain) - with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f: + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as f: f.write(summary_content) diff --git a/tests/dashboard/test_web_server_paths.py b/tests/dashboard/test_web_server_paths.py index b596ebb581..efeafbf3b5 100644 --- a/tests/dashboard/test_web_server_paths.py +++ b/tests/dashboard/test_web_server_paths.py @@ -34,9 +34,7 @@ def test_get_base_frontend_path_dev_mode() -> None: # The function uses Path.resolve() which resolves symlinks # The actual function adds "/" to the path, so we simulate that test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = ( - Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard" - ).resolve() + expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() assert result == expected @@ -62,9 +60,7 @@ def test_get_base_frontend_path_dev_mode_relative_path() -> None: # The function uses Path.resolve() which resolves symlinks # The actual function adds "/" to the path, so we simulate that test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = ( - Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard" - ).resolve() + expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() assert result == expected assert result.is_absolute() @@ -157,7 +153,7 @@ def test_load_file_path(tmp_path: Path) -> None: test_file = tmp_path / "test.txt" test_file.write_bytes(b"test content") - with open(test_file, "rb") as f: + with test_file.open("rb") as f: content = f.read() assert content == b"test content" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e593929583..a9c9e0686f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -79,7 +79,7 @@ def shared_platformio_cache() -> Generator[Path]: lock_file = Path.home() / ".esphome-integration-tests-init.lock" # Always acquire the lock to ensure cache is ready before proceeding - with open(lock_file, "w") as lock_fd: + with lock_file.open("w") as lock_fd: fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) # Check if the native platform is installed (the actual indicator of a populated cache) diff --git a/tests/script/test_check_import_time.py b/tests/script/test_check_import_time.py index 223c58002c..528ca0701c 100644 --- a/tests/script/test_check_import_time.py +++ b/tests/script/test_check_import_time.py @@ -4,7 +4,6 @@ from __future__ import annotations import importlib.util import json -import os from pathlib import Path import sys from unittest.mock import patch @@ -13,12 +12,10 @@ import pytest # Load the script-under-test as `check_import_time` (it's a hyphenated path # inside `script/` that mirrors the existing `determine_jobs` pattern). -script_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "script") -) +script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve()) sys.path.insert(0, script_dir) spec = importlib.util.spec_from_file_location( - "check_import_time", os.path.join(script_dir, "check_import_time.py") + "check_import_time", str(Path(script_dir) / "check_import_time.py") ) check_import_time = importlib.util.module_from_spec(spec) spec.loader.exec_module(check_import_time) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 7bb9fe2543..ac3c6424bf 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -3,7 +3,6 @@ from collections.abc import Generator import importlib.util import json -import os from pathlib import Path import sys from unittest.mock import Mock, call, patch @@ -11,9 +10,7 @@ from unittest.mock import Mock, call, patch import pytest # Add the script directory to Python path so we can import the module -script_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "script") -) +script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve()) sys.path.insert(0, script_dir) # Import helpers module for patching @@ -22,7 +19,7 @@ import helpers # noqa: E402 import script.helpers # noqa: E402 spec = importlib.util.spec_from_file_location( - "determine_jobs", os.path.join(script_dir, "determine-jobs.py") + "determine_jobs", str(Path(script_dir) / "determine-jobs.py") ) determine_jobs = importlib.util.module_from_spec(spec) spec.loader.exec_module(determine_jobs) diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 10f258aa83..82ff5e1411 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -12,9 +12,7 @@ import pytest from pytest import MonkeyPatch # Add the script directory to Python path so we can import helpers -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script")) -) +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) import helpers # noqa: E402 diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index 3149712563..a8100252da 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -1,6 +1,5 @@ """Unit tests for script/build_helpers.py manifest override and build helpers.""" -import os from pathlib import Path import sys import textwrap @@ -9,9 +8,7 @@ from unittest.mock import MagicMock, patch import pytest # Add the script directory to Python path so we can import build_helpers -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script")) -) +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) import build_helpers # noqa: E402 diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 4ce862315d..b5b35b5172 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -486,7 +486,7 @@ def test_preload_core_config_basic(setup_core: Path) -> None: assert CONF_BUILD_PATH in config[CONF_ESPHOME] # Verify default build path is "build/" build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] - assert build_path.endswith(os.path.join("build", "test_device")) + assert build_path.endswith(str(Path("build") / "test_device")) def test_preload_core_config_with_build_path(setup_core: Path) -> None: @@ -523,7 +523,7 @@ def test_preload_core_config_env_build_path(setup_core: Path) -> None: assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH] # Verify it uses the env var path with device name appended build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] - expected_path = os.path.join("/env/build", "test_device") + expected_path = str(Path("/env/build") / "test_device") assert build_path == expected_path or build_path == expected_path.replace( "/", os.sep ) @@ -739,7 +739,7 @@ async def test_add_includes_with_single_file( """Test add_includes copies a single header file to build directory.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include file include_file = tmp_path / "my_header.h" @@ -769,7 +769,7 @@ async def test_add_includes_with_directory_unix( """Test add_includes copies all files from a directory on Unix.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include directory with files include_dir = tmp_path / "includes" @@ -814,7 +814,7 @@ async def test_add_includes_with_directory_windows( """Test add_includes copies all files from a directory on Windows.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include directory with files include_dir = tmp_path / "includes" @@ -856,7 +856,7 @@ async def test_add_includes_with_multiple_sources( """Test add_includes with multiple files and directories.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create various include sources single_file = tmp_path / "single.h" @@ -884,7 +884,7 @@ async def test_add_includes_empty_directory( """Test add_includes with an empty directory doesn't fail.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create empty directory empty_dir = tmp_path / "empty" @@ -906,7 +906,7 @@ async def test_add_includes_preserves_directory_structure_unix( """Test that add_includes preserves relative directory structure on Unix.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create nested directory structure lib_dir = tmp_path / "lib" @@ -940,7 +940,7 @@ async def test_add_includes_preserves_directory_structure_windows( """Test that add_includes preserves relative directory structure on Windows.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create nested directory structure lib_dir = tmp_path / "lib" @@ -973,7 +973,7 @@ async def test_add_includes_overwrites_existing_files( """Test that add_includes overwrites existing files in build directory.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include file include_file = tmp_path / "header.h" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index f50f5317de..4f0a71053d 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -293,7 +293,7 @@ def test_extra_script_captures_libpath_libs_and_defines(tmp_path): result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32") - assert result.libpath == [os.path.join("src", "esp32")] + assert result.libpath == [str(Path("src") / "esp32")] assert result.libs == ["algobsec"] assert ("BAR", "1") in result.cppdefines assert "FOO" in result.cppdefines diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 4783112578..c71be2fbab 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -1,4 +1,3 @@ -import glob import logging from pathlib import Path from typing import Any @@ -106,7 +105,7 @@ REMOTES = { # Collect all input YAML files for test_substitutions_fixtures parametrized tests: HERE = Path(__file__).parent BASE_DIR = HERE / "fixtures" / "substitutions" -SOURCES = sorted(glob.glob(str(BASE_DIR / "*.input.yaml"))) +SOURCES = sorted(str(p) for p in BASE_DIR.glob("*.input.yaml")) assert SOURCES, f"test_substitutions_fixtures: No input YAML files found in {BASE_DIR}" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 91b4bd8e87..fc49f03067 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1358,7 +1358,7 @@ def test_clean_build_handles_readonly_files( # Create a read-only file (simulating git pack files on Windows) readonly_file = git_dir / "pack-abc123.pack" readonly_file.write_text("pack data") - os.chmod(readonly_file, stat.S_IRUSR) # Read-only + readonly_file.chmod(stat.S_IRUSR) # Read-only # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir @@ -1393,7 +1393,7 @@ def test_clean_all_handles_readonly_files( subdir.mkdir() readonly_file = subdir / "readonly.txt" readonly_file.write_text("content") - os.chmod(readonly_file, stat.S_IRUSR) # Read-only + readonly_file.chmod(stat.S_IRUSR) # Read-only # Verify file is read-only assert not os.access(readonly_file, os.W_OK) @@ -1422,7 +1422,7 @@ def test_clean_build_reraises_for_other_errors( test_file.write_text("content") # Make subdir read-only so files inside can't be deleted - os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR) + subdir.chmod(stat.S_IRUSR | stat.S_IXUSR) # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir @@ -1440,7 +1440,7 @@ def test_clean_build_reraises_for_other_errors( clean_build() finally: # Cleanup - restore write permission so tmp_path cleanup works - os.chmod(subdir, stat.S_IRWXU) + subdir.chmod(stat.S_IRWXU) # Tests for get_build_info() From 423b60c90ce030f02f0bfa560464d4016301da59 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 26 May 2026 19:56:44 +1200 Subject: [PATCH 1441/2214] [packages] Resolve git symlinks on Windows when materialized as text (#16657) --- esphome/components/packages/__init__.py | 42 +++- esphome/git.py | 87 +++++++ tests/unit_tests/test_git.py | 303 +++++++++++++++++++++++- tests/unit_tests/test_substitutions.py | 83 +++++++ 4 files changed, 507 insertions(+), 8 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 06a64208b6..f3e0e0db8f 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: If loading fails after cloning, attempts a revert and retry in case a prior cached checkout is stale. """ - repo_dir, revert = git.clone_or_update( + repo_root, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), refresh=config[CONF_REFRESH], @@ -225,6 +225,10 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: ) files: list[dict[str, Any]] = [] + # ``repo_root`` is the directory containing ``.git`` and must be passed + # to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a + # subdirectory via the user's CONF_PATH and is used for file lookups. + repo_dir = repo_root if base_path := config.get(CONF_PATH): repo_dir = repo_dir / base_path @@ -236,13 +240,37 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: def _load_package_yaml(yaml_file: Path, filename: str) -> dict: """Load a YAML file from a remote package, validating min_version.""" - try: - new_yaml = yaml_util.load_yaml(yaml_file) - except EsphomeError as e: + + def _load(path: Path) -> dict | str | None: + try: + return yaml_util.load_yaml(path) + except EsphomeError as e: + raise cv.Invalid( + f"{filename} is not a valid YAML file." + f" Please check the file contents.\n{e}" + ) from e + + new_yaml = _load(yaml_file) + if not isinstance(new_yaml, dict): + # On Windows, git defaults to core.symlinks=false unless the user + # has Developer Mode enabled or is running elevated. Files stored + # in the repo as symlinks (tree mode 120000) are then checked out + # as plain text files containing the symlink target path, so + # parsing them as YAML yields a bare scalar instead of a mapping. + # Best-effort: follow the symlink target ourselves and re-load. + target = git.resolve_symlink_stub(repo_root, yaml_file) + if target is not None: + new_yaml = _load(target) + if not isinstance(new_yaml, dict): raise cv.Invalid( - f"{filename} is not a valid YAML file." - f" Please check the file contents.\n{e}" - ) from e + f"{filename} does not contain a YAML mapping at the top level " + f"(got {type(new_yaml).__name__}). " + f"If this file is a git symlink in the source repository, it " + f"may not have been materialized correctly on your platform " + f"(this is a known issue with git on Windows without Developer " + f"Mode enabled). Try pointing your package at the real file " + f"path instead." + ) esphome_config = new_yaml.get(CONF_ESPHOME) or {} min_version = esphome_config.get(CONF_MIN_VERSION) if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse( diff --git a/esphome/git.py b/esphome/git.py index f36bd559ef..094a6dae19 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -6,6 +6,7 @@ import logging from pathlib import Path import re import subprocess +import sys import urllib.parse import esphome.config_validation as cv @@ -94,6 +95,92 @@ def _compute_destination_path(key: str, domain: str) -> Path: return base_dir / h.hexdigest()[:8] +def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: + """Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub. + + On Windows, when ``core.symlinks=false`` (the default unless the user has + SeCreateSymbolicLinkPrivilege — i.e. Developer Mode or running elevated), + git materializes files with tree mode ``120000`` as plain text files + whose content is the literal symlink target path. Opening such a file + yields the target path string instead of the target's content. + + If ``file_path`` is one of those stubs, return the resolved target Path + inside ``repo_dir``. Otherwise return ``None`` and the caller should use + ``file_path`` as-is. + + Designed to be called *only* when normal access has already produced an + unexpected result (e.g. YAML parsed as a top-level scalar), so the + per-file ``git ls-files`` subprocess cost is paid only on the failure + path. Returns ``None`` on any error or check failure — it's purely a + best-effort recovery, never raises. + """ + # On non-Windows, git creates real symlinks; ordinary file access already + # transparently follows them. + if sys.platform != "win32": + return None + if file_path.is_symlink(): + return None + if not file_path.is_file(): + return None + + try: + rel = file_path.relative_to(repo_dir) + except ValueError: + return None + + try: + # ``git ls-files -s `` prints " \t" + # for that single entry, or empty if untracked. + out = run_git_command( + ["git", "ls-files", "-s", "--", rel.as_posix()], + git_dir=repo_dir, + ) + except GitException: + return None + + parts = out.split() + if not parts or parts[0] != "120000": + return None + + # Stubs are short ASCII relative paths. Decode defensively, and only + # strip the trailing newline git's checkout may append — preserving any + # whitespace that could be part of a valid target name. + try: + raw = file_path.read_bytes() + except OSError: + return None + try: + target_str = raw.decode("utf-8").rstrip("\r\n") + except UnicodeDecodeError: + return None + + # ``Path()`` and ``Path.resolve()`` can raise on malformed inputs (e.g. + # embedded NUL bytes from a hostile symlink blob, paths too long for the + # OS, or temporary I/O errors). Catch broadly — this helper is purely a + # best-effort recovery and must never raise. + try: + target_path = (file_path.parent / target_str).resolve() + repo_root_resolved = repo_dir.resolve() + except (OSError, ValueError, RuntimeError): + return None + + # ``Path.resolve()`` follows ``..``; re-verify containment afterwards. + try: + target_path.relative_to(repo_root_resolved) + except ValueError: + _LOGGER.warning( + "Refusing to follow symlink %s -> %s (escapes repository)", + file_path, + target_str, + ) + return None + + if not target_path.is_file(): + return None + + return target_path + + def clone_or_update( *, url: str, diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index eab6bfc2cb..690c47c183 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta import os from pathlib import Path from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -1001,3 +1001,304 @@ def test_refresh_picks_up_new_remote_commits( "--hard", "old_sha", ] + + +def test_resolve_symlink_stub_returns_none_on_non_windows( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """On non-Windows, resolve_symlink_stub returns None without calling git.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + stub = repo_dir / "file.yaml" + stub.write_text("static/file.yaml") + + with patch("esphome.git.sys.platform", "linux"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_target_for_mode_120000( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A mode-120000 file is recognised as a stub; its target Path is returned.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "static").mkdir() + + target = repo_dir / "static" / "real.yaml" + target.write_text("esphome:\n name: real\n") + + stub = repo_dir / "real.yaml" + stub.write_text("static/real.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\treal.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + # Stub file itself was not modified — only inspected. + assert stub.read_text() == "static/real.yaml" + + +def test_resolve_symlink_stub_resolves_relative_parent_paths( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Symlink targets with ``..`` segments resolve correctly within the repo.""" + repo_dir = tmp_path / "repo" + (repo_dir / "subdir").mkdir(parents=True) + (repo_dir / "static").mkdir() + + target = repo_dir / "static" / "shared.yaml" + target.write_text("shared content") + + stub = repo_dir / "subdir" / "shared.yaml" + stub.write_text("../static/shared.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tsubdir/shared.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + + +def test_resolve_symlink_stub_refuses_escape_outside_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A symlink pointing outside the repository is not followed.""" + outside = tmp_path / "outside.yaml" + outside.write_text("sensitive") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "escape.yaml" + stub.write_text("../outside.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tescape.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_for_real_symlink( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A real symlink already opens transparently, so the helper short-circuits. + + Skipped on Windows where symlink creation requires + SeCreateSymbolicLinkPrivilege. + """ + if os.name == "nt": + pytest.skip("Requires symlink-creation privilege on Windows") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + target = repo_dir / "real.yaml" + target.write_text("real content") + + real_link = repo_dir / "link.yaml" + real_link.symlink_to("real.yaml") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, real_link) + + assert result is None + # No git call needed for real symlinks. + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_for_regular_file( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A regular file (mode 100644) whose content looks path-shaped is not + followed.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + regular = repo_dir / "looks_like_path.txt" + regular.write_text("static/something.yaml") + + mock_run_git_command.return_value = "100644 abc123 0\tlooks_like_path.txt" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, regular) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_git_fails( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """If ``git ls-files`` fails (e.g. not a repo), the helper returns None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "real.yaml" + stub.write_text("static/real.yaml") + + mock_run_git_command.side_effect = GitCommandError("ls-files exploded") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_for_non_utf8_content( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file whose bytes are not valid UTF-8 must not raise — return None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "binary.bin" + stub.write_bytes(b"\xff\xfe\x00\xff") + + mock_run_git_command.return_value = "120000 abc123 0\tbinary.bin" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_preserves_whitespace_in_target( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Only trailing CR/LF is stripped — internal whitespace is preserved.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + target_dir = repo_dir / "dir with spaces" + target_dir.mkdir() + target = target_dir / "real.yaml" + target.write_text("hello") + + stub = repo_dir / "link.yaml" + # Trailing newline (as git's checkout may append) is stripped, but + # whitespace inside the target path itself must survive. + stub.write_bytes(b"dir with spaces/real.yaml\n") + + mock_run_git_command.return_value = "120000 abc123 0\tlink.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + + +def test_resolve_symlink_stub_returns_none_for_directory_target( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A symlink pointing at a directory has no file content to load.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "dir_target").mkdir() + + stub = repo_dir / "link_to_dir" + stub.write_text("dir_target") + + mock_run_git_command.return_value = "120000 abc123 0\tlink_to_dir" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_resolve_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Path.resolve() raising (e.g. on a malformed target) must not propagate.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "broken.yaml" + stub.write_text("ignored") + + mock_run_git_command.return_value = "120000 abc123 0\tbroken.yaml" + + with ( + patch("esphome.git.sys.platform", "win32"), + patch.object(Path, "resolve", side_effect=OSError("bad path")), + ): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_file_missing( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file path that doesn't exist is rejected before git is consulted.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + missing = repo_dir / "ghost.yaml" # not created + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, missing) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_when_path_outside_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file path that isn't under repo_dir is rejected (ValueError from relative_to).""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + outside = tmp_path / "stray.yaml" + outside.write_text("something") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, outside) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_when_untracked( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Empty `git ls-files` output (untracked file) makes the helper return None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "untracked.yaml" + stub.write_text("static/foo.yaml") + + mock_run_git_command.return_value = "" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_read_bytes_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """An OSError from read_bytes() (e.g. file vanished mid-call) must not propagate.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "racy.yaml" + stub.write_text("static/racy.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tracy.yaml" + + with ( + patch("esphome.git.sys.platform", "win32"), + patch.object(Path, "read_bytes", side_effect=OSError("vanished")), + ): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index c71be2fbab..b5816f742e 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -837,3 +837,86 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None: assert isinstance(result["value"], Lambda) assert result["value"].value == 'return "bar";' + + +@patch("esphome.git.resolve_symlink_stub") +@patch("esphome.git.clone_or_update") +def test_remote_package_symlink_stub_is_followed( + mock_clone_or_update: MagicMock, + mock_resolve_symlink_stub: MagicMock, + tmp_path: Path, +) -> None: + """When a package YAML is a scalar (symlink stub) and resolve_symlink_stub + returns a target, the loader follows the target and uses its content.""" + CORE.config_path = tmp_path / "test.yaml" + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "static").mkdir() + + # Stub file: content is the target path string (simulating Windows behavior). + stub = repo_dir / "file1.yaml" + stub.write_text("static/file1.yaml") + + # Real target with valid YAML mapping. + target = repo_dir / "static" / "file1.yaml" + target.write_text("substitutions:\n hello: world\n") + + mock_clone_or_update.return_value = (repo_dir, None) + mock_resolve_symlink_stub.return_value = target + + config: dict[str, Any] = { + "packages": { + "test_package": { + "url": "https://github.com/esphome/repo1", + "ref": "main", + "files": ["file1.yaml"], + } + } + } + + # Must succeed (does not raise the helpful cv.Invalid) because the stub + # was followed and a valid mapping was loaded from the target. + do_packages_pass(config) + assert mock_resolve_symlink_stub.called + + +@patch("esphome.git.clone_or_update") +def test_remote_package_scalar_yaml_raises_helpful_error( + mock_clone_or_update: MagicMock, tmp_path: Path +) -> None: + """A remote package YAML that is a top-level scalar (e.g. an unmaterialized + git symlink on Windows) raises a clear cv.Invalid, not AttributeError. + + Regression test for the case where a repo containing a YAML symlink, + checked out on Windows without symlink privilege, lands as a short text + file containing the symlink target path. PyYAML parses that as a bare + string scalar; the package loader must reject it with a human-readable + error instead of dying inside ``.get()``. + """ + CORE.config_path = tmp_path / "test.yaml" + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + # Simulate the broken-symlink state: a YAML file whose entire content is + # the symlink target string. PyYAML parses this as a top-level scalar. + (repo_dir / "file1.yaml").write_text("static/file1.yaml") + + mock_clone_or_update.return_value = (repo_dir, None) + + config: dict[str, Any] = { + "packages": { + "test_package": { + "url": "https://github.com/esphome/repo1", + "ref": "main", + "files": ["file1.yaml"], + } + } + } + + with pytest.raises(cv.Invalid) as exc_info: + do_packages_pass(config) + + msg = str(exc_info.value) + assert "mapping at the top level" in msg + assert "file1.yaml" in msg From 8b62cfded7aec52d5d74b7f12751502b9ad2a059 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 02:57:44 -0500 Subject: [PATCH 1442/2214] [libretiny] Fix RTL8710B IRAM_ATTR section being dropped from flashed image (#16616) --- esphome/components/libretiny/hal.h | 24 +++++---- .../libretiny/patch_linker.py.script | 54 +++++++++++++++---- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 9c512504b7..01a7b5450b 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -11,11 +11,19 @@ #include "esphome/core/time_64.h" // IRAM_ATTR places a function in executable RAM so it is callable from an -// ISR even while flash is busy (XIP stall, OTA, logger flash write). -// Each family uses a section its stock linker already routes to RAM: -// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the -// exception: its stock linker has no matching glob, so patch_linker.py -// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// ISR even while flash is busy (XIP stall, OTA, logger flash write). All +// LibreTiny families that need it share the same .sram.text input section +// name; how that section is routed into RAM differs per family: +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the +// top of .ram_image2.data (which IS in ltchiptool's +// sections_ram). The stock linker has KEEP(*(.image2.ram.text*)) +// in .ram_image2.text but that output section is NOT in +// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed +// there is dropped from the flashed binary. +// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into +// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors)) +// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR. // // BK72xx (all variants) are left as a no-op: their SDK wraps flash // operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for @@ -26,13 +34,7 @@ // layer. #if defined(USE_BK72XX) #define IRAM_ATTR -#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) -// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). -#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) #else -// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. -// LN882H: patch_linker.py.script injects *(.sram.text*) into -// .flash_copysection (> RAM0 AT> FLASH). #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #endif #define PROGMEM diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 3a8a4787ed..dfeaaa57d1 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -6,12 +6,18 @@ import re import subprocess # ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family -# section routed into RAM-executable memory (see esphome/core/hal.h). +# section routed into RAM-executable memory (see esphome/core/hal.h). The +# input section name is always .sram.text; only the output section it lands +# in differs per family. # # This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK # masks FIQ+IRQ around flash writes). On the remaining families: -# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. -# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text, +# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list +# .ram_image2.text in sections_ram, so code there is silently dropped from +# the flashed image. Inject KEEP(*(.sram.text*)) at the top of +# .ram_image2.data (which IS extracted) instead. # - LN882H: stock linker has no glob for ".sram.text", so we inject # KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) # immediately after KEEP(*(.vectors)), so the vector table stays at @@ -34,6 +40,20 @@ _KEEP_LINE = ( # aligned address; injecting before the vectors would push them to an # unaligned offset and mis-route every IRQ handler. _LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") +# Inject at the top of .ram_image2.data, before __data_start__ so our code +# does not fall inside the data range markers. .ram_image2.data is one of the +# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is +# executable. AmbZ has no C runtime .data copy loop (the bootloader loads +# image2 into BD_RAM whole) so the inline code is not clobbered after boot. +# +# The regex is intentionally strict (no attribute / ALIGN between the section +# name and the opening brace, brace on its own line). If a future AmbZ SDK +# linker template changes this format, _pre_link raises RuntimeError on the +# unpatched .ld file(s), and the RTL8710B CI compile job in +# tests/test_build_components fails on the PR, surfacing the mismatch loudly +# rather than silently shipping a binary with IRAM_ATTR code dropped from +# one or both OTA slots. +_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)") def _detect(env): @@ -71,12 +91,11 @@ def _inject_keep(host_section): # Variants not listed here intentionally have no .ld patcher: -# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker -# already routes into .ram_image2.text (> BD_RAM). -# - RTL8720C: stock linker already consumes *(.sram.text*). +# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text. # - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. _PATCHERS_BY_VARIANT = { "LN882H": (_inject_keep(_LN_COPY),), + "RTL8710B": (_inject_keep(_AMBZ_DATA),), } @@ -87,13 +106,14 @@ def _patchers_for(variant): def _pre_link(target, source, env): build_dir = env.subst("$BUILD_DIR") ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] - patched = 0 + patched = [] + unpatched = [] for name in ld_files: path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: original = fh.read() if _MARKER in original: - patched += 1 + patched.append(name) continue content = original for fn in _patchers: @@ -102,7 +122,9 @@ def _pre_link(target, source, env): with open(path, "w", encoding="utf-8") as fh: fh.write(content) print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) - patched += 1 + patched.append(name) + else: + unpatched.append(name) if not patched: raise RuntimeError( "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " @@ -110,6 +132,20 @@ def _pre_link(target, source, env): build_dir ) ) + # Every .ld in the build must be patched. RTL8710B generates one .ld per + # OTA slot (xip1, xip2); if only one matches, the unpatched slot would + # ship with IRAM_ATTR code dropped to zeros and brick the device on the + # boot after an OTA into that slot. + if unpatched: + raise RuntimeError( + "ESPHome: {} of {} .ld file(s) in {} were not patched for " + "IRAM_ATTR: {}. The regex in patch_linker.py.script " + "(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not " + "these. Update the regex to cover all linker scripts.".format( + len(unpatched), len(ld_files), build_dir, + ", ".join(unpatched), _variant, + ) + ) # Substrings matched against demangled names as a fallback on RTL8720C, From ceb9d406e172919020c174e7931d69916a0ceeea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 06:46:44 -0500 Subject: [PATCH 1443/2214] [core] Enable ruff PIE (flake8-pie) lint family (#16658) --- esphome/components/as5600/__init__.py | 2 +- esphome/components/audio_file/__init__.py | 2 +- esphome/components/font/__init__.py | 2 +- esphome/components/http_request/__init__.py | 2 +- esphome/components/image/__init__.py | 2 +- esphome/components/packages/__init__.py | 2 +- esphome/components/time/__init__.py | 6 +++--- esphome/log.py | 6 ++++-- esphome/upload_targets.py | 2 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 7 +------ script/determine-jobs.py | 10 +++------- script/helpers.py | 6 ++---- tests/integration/test_gpio_expander_cache.py | 4 ++-- tests/unit_tests/components/test_time.py | 4 ++-- 15 files changed, 25 insertions(+), 33 deletions(-) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index 444306cec3..c05e556376 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -100,7 +100,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION): if isinstance(value, str) and value.endswith("%"): value = percent_to_position(value) - if isinstance(value, str) and (value.endswith("°") or value.endswith("deg")): + if isinstance(value, str) and value.endswith(("°", "deg")): return angle_to_position( value, min=round(min * POSITION_TO_ANGLE), diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 8dc546cec1..53193c8008 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -72,7 +72,7 @@ def _file_schema(value: ConfigType | str) -> ConfigType: def _validate_file_shorthand(value: str) -> ConfigType: value = cv.string_strict(value) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return _file_schema( { CONF_TYPE: TYPE_WEB, diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 4ea6267275..7510f2f8b6 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -401,7 +401,7 @@ def validate_file_shorthand(value): data[CONF_WEIGHT] = weight[1:] return font_file_schema(data) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return font_file_schema( { CONF_TYPE: TYPE_WEB, diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 2617951f0d..fd033dac7f 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -65,7 +65,7 @@ CONF_JSON = "json" def validate_url(value): value = cv.url(value) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return value raise cv.Invalid("URL must start with 'http://' or 'https://'") diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 2fefbdcd58..5f8e5ca132 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -408,7 +408,7 @@ def validate_file_shorthand(value): raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") return download_gh_svg(parts[1], parts[0]) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return download_image(value) value = cv.file_(value) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index f3e0e0db8f..c1c5bd2ae3 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -112,7 +112,7 @@ def expand_file_to_files(config: dict): def validate_yaml_filename(value): value = cv.string(value) - if not (value.endswith(".yaml") or value.endswith(".yml")): + if not value.endswith((".yaml", ".yml")): raise cv.Invalid("Only YAML (.yaml / .yml) files are supported.") return value diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index f687df26c2..b3bf2d44d7 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -418,11 +418,11 @@ async def setup_time_core_(time_var, config): for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) - seconds = conf.get(CONF_SECONDS, list(range(0, 61))) + seconds = conf.get(CONF_SECONDS, list(range(61))) cg.add(trigger.add_seconds(seconds)) - minutes = conf.get(CONF_MINUTES, list(range(0, 60))) + minutes = conf.get(CONF_MINUTES, list(range(60))) cg.add(trigger.add_minutes(minutes)) - hours = conf.get(CONF_HOURS, list(range(0, 24))) + hours = conf.get(CONF_HOURS, list(range(24))) cg.add(trigger.add_hours(hours)) days_of_month = conf.get(CONF_DAYS_OF_MONTH, list(range(1, 32))) cg.add(trigger.add_days_of_month(days_of_month)) diff --git a/esphome/log.py b/esphome/log.py index bfd1875b55..b120c930d0 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -28,10 +28,12 @@ class AnsiFore(Enum): class AnsiStyle(Enum): + # BOLD/BRIGHT and THIN/DIM are intentional ANSI synonyms; Enum treats the + # second name in each pair as an alias of the first. BRIGHT = "\033[1m" - BOLD = "\033[1m" + BOLD = "\033[1m" # noqa: PIE796 DIM = "\033[2m" - THIN = "\033[2m" + THIN = "\033[2m" # noqa: PIE796 NORMAL = "\033[22m" RESET_ALL = "\033[0m" diff --git a/esphome/upload_targets.py b/esphome/upload_targets.py index 302ecf7301..d9d9713fc1 100644 --- a/esphome/upload_targets.py +++ b/esphome/upload_targets.py @@ -57,7 +57,7 @@ def get_port_type(port: str) -> PortType: """ if port == "BOOTSEL": return PortType.BOOTSEL - if port.startswith("/") or port.startswith("COM"): + if port.startswith(("/", "COM")): return PortType.SERIAL if port == "MQTT": return PortType.MQTT diff --git a/pyproject.toml b/pyproject.toml index ae1bd34f60..6572078746 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ select = [ "LOG", # flake8-logging "NPY", # numpy-specific rules "PERF", # performance + "PIE", # flake8-pie "PL", # pylint "PTH", # flake8-use-pathlib "PYI", # flake8-pyi diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 240ee7890f..451cd9ac1f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -84,12 +84,7 @@ def indent_list(text: str, padding: str = " ") -> list[str]: """Indent each line of the given text with the specified padding.""" lines = [] for line in text.splitlines(): - if ( - line == "" - or line.startswith("#ifdef") - or line.startswith("#if ") - or line.startswith("#endif") - ): + if line == "" or line.startswith(("#ifdef", "#if ", "#endif")): p = "" else: p = padding diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 417716cd77..d91936952e 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -483,9 +483,7 @@ def should_run_device_builder(branch: str | None = None) -> bool: True if the device-builder downstream tests should run, False otherwise. """ target_branch = get_target_branch() - if target_branch and ( - target_branch.startswith("release") or target_branch.startswith("beta") - ): + if target_branch and (target_branch.startswith(("release", "beta"))): return False for file in changed_files(branch): @@ -955,9 +953,7 @@ def detect_memory_impact_config( # all components at once would produce nonsensical memory impact results. # Memory impact analysis is most useful for focused PRs targeting dev. target_branch = get_target_branch() - if target_branch and ( - target_branch.startswith("release") or target_branch.startswith("beta") - ): + if target_branch and (target_branch.startswith(("release", "beta"))): print( f"Memory impact: Skipping analysis for target branch {target_branch} " f"(would try to build all components at once, giving nonsensical results)", @@ -1311,7 +1307,7 @@ def main() -> None: # (no isolation, all components are groupable) target_branch = get_target_branch() is_release_branch = target_branch and ( - target_branch.startswith("release") or target_branch.startswith("beta") + target_branch.startswith(("release", "beta")) ) if is_release_branch: diff --git a/script/helpers.py b/script/helpers.py index c56a434edf..9839e766e2 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -103,9 +103,7 @@ def get_component_from_path(file_path: str) -> str | None: Returns: Component name if path is in components or tests directory, None otherwise """ - if file_path.startswith(ESPHOME_COMPONENTS_PATH) or file_path.startswith( - ESPHOME_TESTS_COMPONENTS_PATH - ): + if file_path.startswith((ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH)): parts = file_path.split("/") if len(parts) >= 3 and parts[2]: # Verify that parts[2] is actually a component directory, not a file @@ -160,7 +158,7 @@ def is_validate_only_file(test_file: Path) -> bool: ``esphome config`` only and skipped during compile. """ name = test_file.name - return name.startswith("validate.") or name.startswith("validate-") + return name.startswith(("validate.", "validate-")) @dataclass(frozen=True) diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py index e5f0f2818f..1d36ca3446 100644 --- a/tests/integration/test_gpio_expander_cache.py +++ b/tests/integration/test_gpio_expander_cache.py @@ -43,7 +43,7 @@ async def test_gpio_expander_cache( # ensure logs are in the expected order log_order = [ (digital_read_hw_pattern, 0), - [(digital_read_cache_pattern, i) for i in range(0, 8)], + [(digital_read_cache_pattern, i) for i in range(8)], (digital_read_hw_pattern, 8), [(digital_read_cache_pattern, i) for i in range(8, 16)], (digital_read_hw_pattern, 16), @@ -68,7 +68,7 @@ async def test_gpio_expander_cache( # uint16_t component tests (single bank of 16 pins) (uint16_read_hw_pattern, 0), # First pin triggers hw read [ - (uint16_read_cache_pattern, i) for i in range(0, 16) + (uint16_read_cache_pattern, i) for i in range(16) ], # All 16 pins return via cache # After cache reset (uint16_read_hw_pattern, 5), # First read after reset triggers hw diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 6325bfbe75..5ae9d787d6 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -70,11 +70,11 @@ def test_numeric_offset_slash() -> None: def test_star() -> None: - assert _parse_cron_part("*", 0, 59, {}) == set(range(0, 60)) + assert _parse_cron_part("*", 0, 59, {}) == set(range(60)) def test_question() -> None: - assert _parse_cron_part("?", 0, 59, {}) == set(range(0, 60)) + assert _parse_cron_part("?", 0, 59, {}) == set(range(60)) def test_range() -> None: From 88b12a1c457f171ef877b033dcf8f4231e74bf13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 08:41:54 -0500 Subject: [PATCH 1444/2214] [lvgl] Build automation_schema event validators lazily (#16633) --- esphome/components/lvgl/schemas.py | 34 ++++++++- .../lvgl/test_automation_schema_lazy.py | 71 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/test_automation_schema_lazy.py diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index b901eb4b53..bdaa91f15c 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,6 +22,7 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger +from esphome.schema_extractors import EnableSchemaExtraction from . import defines as df, lv_validation as lvalid from .defines import ( @@ -407,7 +408,34 @@ def part_schema(parts: tuple[str, ...] | list[str]) -> cv.Schema: return cv.Schema(part_dict(parts)) -def automation_schema(typ: LvType): +def _lazy_validate_automation(extra_schema: dict) -> Callable[[Any], Any]: + """Return a validator that defers building the validate_automation schema. + + validate_automation() runs AUTOMATION_SCHEMA.extend(extra_schema), which + voluptuous compiles eagerly. automation_schema() builds ~60 of these per + widget type, and the vast majority of slots are never invoked by a given + user config. Deferring the build to first use removes that work from + schema-construction time. + + When EnableSchemaExtraction is set (build_language_schema.py), fall back + to eager construction so the @schema_extractor("automation") decoration + inside validate_automation is registered. + """ + if EnableSchemaExtraction: + return validate_automation(extra_schema) + + cached: Callable[[Any], Any] | None = None + + def validator(value: Any) -> Any: + nonlocal cached + if cached is None: + cached = validate_automation(extra_schema) + return cached(value) + + return validator + + +def automation_schema(typ: LvType) -> dict[Any, Any]: events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS if typ.has_on_value: events = events + (CONF_ON_VALUE, CONF_ON_UPDATE) @@ -422,7 +450,7 @@ def automation_schema(typ: LvType): return { **{ - cv.Optional(event): validate_automation( + cv.Optional(event): _lazy_validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( Trigger.template(*get_trigger_args(event)) @@ -431,7 +459,7 @@ def automation_schema(typ: LvType): ) for event in events }, - cv.Optional(CONF_ON_BOOT): validate_automation( + cv.Optional(CONF_ON_BOOT): _lazy_validate_automation( {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)} ), } diff --git a/tests/component_tests/lvgl/test_automation_schema_lazy.py b/tests/component_tests/lvgl/test_automation_schema_lazy.py new file mode 100644 index 0000000000..46430824f6 --- /dev/null +++ b/tests/component_tests/lvgl/test_automation_schema_lazy.py @@ -0,0 +1,71 @@ +"""Tests for lvgl automation_schema lazy validate_automation build.""" + +from __future__ import annotations + +from unittest.mock import patch + +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl import schemas as lvgl_schemas +from esphome.components.lvgl.schemas import ( + WIDGET_TYPES, + _lazy_validate_automation, + automation_schema, +) +from esphome.components.lvgl.widgets import WidgetType +from esphome.config_validation import GenerateID, declare_id +from esphome.const import CONF_TRIGGER_ID +from esphome.core.config import StartupTrigger + + +def _widget_type(name: str = "obj") -> WidgetType: + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def _trigger_extra_schema() -> dict: + return {GenerateID(CONF_TRIGGER_ID): declare_id(StartupTrigger)} + + +def test_lazy_validator_defers_build_until_first_call() -> None: + with patch( + "esphome.components.lvgl.schemas.validate_automation", + wraps=lvgl_schemas.validate_automation, + ) as va_mock: + validator = _lazy_validate_automation(_trigger_extra_schema()) + assert va_mock.call_count == 0 + validator({"then": []}) + assert va_mock.call_count == 1 + validator({"then": []}) + assert va_mock.call_count == 1 + + +def test_eager_build_when_schema_extraction_enabled() -> None: + with ( + patch("esphome.components.lvgl.schemas.EnableSchemaExtraction", True), + patch( + "esphome.components.lvgl.schemas.validate_automation", + wraps=lvgl_schemas.validate_automation, + ) as va_mock, + ): + _lazy_validate_automation(_trigger_extra_schema()) + assert va_mock.call_count == 1 + + +def test_lazy_and_eager_produce_equivalent_validation() -> None: + extra = _trigger_extra_schema() + with patch("esphome.components.lvgl.schemas.EnableSchemaExtraction", True): + eager = _lazy_validate_automation(extra) + lazy = _lazy_validate_automation(_trigger_extra_schema()) + sample = {"then": []} + assert lazy(sample) == eager(sample) + + +def test_automation_schema_uses_lazy_validators() -> None: + wt = _widget_type("obj") + with patch( + "esphome.components.lvgl.schemas.validate_automation", + wraps=lvgl_schemas.validate_automation, + ) as va_mock: + automation_schema(wt.w_type) + assert va_mock.call_count == 0 From 722cbfe843cad4aa729ef848016261fdb4bb884d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 26 May 2026 14:05:57 -0400 Subject: [PATCH 1445/2214] [voice_assistant] Never send zero-length audio to Home Assistant (#16634) --- .../voice_assistant/voice_assistant.cpp | 136 ++++++++++++------ .../voice_assistant/voice_assistant.h | 13 ++ 2 files changed, 107 insertions(+), 42 deletions(-) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index af1b98da02..f13ea39fa2 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -4,6 +4,7 @@ #ifdef USE_VOICE_ASSISTANT #include "esphome/components/socket/socket.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" #include @@ -26,6 +27,11 @@ static const size_t SEND_BUFFER_SIZE = SEND_BUFFER_SAMPLES * sizeof(int16_t); static const size_t RECEIVE_SIZE = 1024; static const size_t SPEAKER_BUFFER_SIZE = 16 * RECEIVE_SIZE; +// If one microphone channel keeps producing audio while another configured channel produces none for this +// long, treat the silent channel as failed and stop the stream. A working microphone exposes a chunk every +// SEND_BUFFER_SAMPLES (32 ms), so this is far longer than any legitimate gap between chunks. +static const uint32_t AUDIO_CHANNEL_STALL_TIMEOUT_MS = 2000; + VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; } void VoiceAssistant::setup() { @@ -168,6 +174,9 @@ void VoiceAssistant::clear_buffers_() { this->audio_source2_->clear_buffered_data(); } + // Reset the multi-channel stall watchdog (see audio_channel_stall_start_). + this->audio_channel_stall_start_ = 0; + #ifdef USE_SPEAKER if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) { memset(this->speaker_buffer_, 0, SPEAKER_BUFFER_SIZE); @@ -200,6 +209,79 @@ void VoiceAssistant::reset_conversation_id() { ESP_LOGD(TAG, "reset conversation ID"); } +void VoiceAssistant::stream_api_audio_() { + // Both microphone channels are sent together, if configured. Home Assistant feeds one of the + // channels to its speech-to-text stream and treats an empty payload on that channel as + // end-of-stream, and the device cannot know which channel it picked, so only send once every + // configured channel has audio exposed, and always send them together. We don't target any + // particular message size: Home Assistant re-chunks the audio, and each fill() exposes at most + // SEND_BUFFER_SIZE bytes. + while (true) { + // fill() exposes a new chunk, or returns 0 if a previous chunk is still exposed; available() + // reports the currently exposed bytes either way. + this->audio_source_->fill(0, false); + size_t available = this->audio_source_->available(); + size_t available2 = 0; + if (this->audio_source2_ != nullptr) { + this->audio_source2_->fill(0, false); + available2 = this->audio_source2_->available(); + } + + const bool channel_empty = (available == 0); + const bool channel2_empty = (this->audio_source2_ != nullptr) && (available2 == 0); + if (channel_empty || channel2_empty) { + // A configured channel has no audio yet, so keep any chunk exposed on the other channel for the + // next pass rather than sending an empty payload. + this->handle_channel_stall_(available, available2); + break; + } + + // Both channels have audio exposed; clear any in-progress stall timer. + this->audio_channel_stall_start_ = 0; + + api::VoiceAssistantAudio msg; + // Zero-copy: send_message() copies the data out before we consume it. + msg.data = this->audio_source_->data(); + msg.data_len = available; + if (this->audio_source2_ != nullptr) { + msg.data2 = this->audio_source2_->data(); + msg.data2_len = available2; + } + + this->api_client_->send_message(msg); + + this->audio_source_->consume(available); + if (this->audio_source2_ != nullptr) { + this->audio_source2_->consume(available2); + } + } +} + +void VoiceAssistant::handle_channel_stall_(size_t available, size_t available2) { + // Called when at least one configured channel has no audio exposed. When one channel has data and the + // other does not, watch how long the empty channel stays starved: Home Assistant has no stream timeout + // and would never tell us to stop, so a channel that fails outright would otherwise hang streaming + // forever with the live channel's chunk held. Stop the stream with an error after a prolonged imbalance. + if ((available == 0) && (available2 == 0)) { + // Both channels are idle (no audio buffered yet); normal, not a stalled channel. + this->audio_channel_stall_start_ = 0; + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + if (this->audio_channel_stall_start_ == 0) { + this->audio_channel_stall_start_ = now; + } else if ((now - this->audio_channel_stall_start_) >= AUDIO_CHANNEL_STALL_TIMEOUT_MS) { + ESP_LOGW(TAG, "Mic channel %d stalled, stopping stream", (available == 0) ? 0 : 1); + this->audio_channel_stall_start_ = 0; + this->signal_stop_(); + this->set_state_(State::STOP_MICROPHONE, State::IDLE); + this->defer([this]() { + this->error_trigger_.trigger("mic-channel-stalled", "A microphone channel stopped producing audio"); + }); + } +} + void VoiceAssistant::loop() { if (this->api_client_ == nullptr && this->state_ != State::IDLE && this->state_ != State::STOP_MICROPHONE && this->state_ != State::STOPPING_MICROPHONE) { @@ -292,55 +374,25 @@ void VoiceAssistant::loop() { case State::STREAMING_MICROPHONE: { // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). if (this->audio_mode_ == AUDIO_MODE_API) { - // API audio - // Both microphone channels are sent, if configured - size_t available = this->audio_source_->fill(0, false); - size_t available2 = 0; - if (this->audio_source2_ != nullptr) { - available2 = this->audio_source2_->fill(0, false); - } - - while (available > 0 || available2 > 0) { - api::VoiceAssistantAudio msg; - - if (available > 0) { - // Zero-copy: send_message() copies the data out before we consume it - msg.data = this->audio_source_->data(); - msg.data_len = available; - } - - // Second microphone channel - if (available2 > 0) { - msg.data2 = this->audio_source2_->data(); - msg.data2_len = available2; - } - - this->api_client_->send_message(msg); - - if (available > 0) { - this->audio_source_->consume(available); - } - available = this->audio_source_->fill(0, false); - if (available2 > 0) { - this->audio_source2_->consume(available2); - } - if (this->audio_source2_ != nullptr) { - available2 = this->audio_source2_->fill(0, false); - } - } + this->stream_api_audio_(); } else { // UDP (will eventually be deprecated) // Only the primary microphone channel is used - while (this->audio_source_->fill(0, false) > 0) { + while (true) { + this->audio_source_->fill(0, false); + size_t available = this->audio_source_->available(); + if (available == 0) { + break; + } if (!this->udp_socket_running_) { if (!this->start_udp_socket_()) { this->set_state_(State::STOP_MICROPHONE, State::IDLE); break; } } - this->socket_->sendto(this->audio_source_->data(), this->audio_source_->available(), 0, - (struct sockaddr *) &this->dest_addr_, sizeof(this->dest_addr_)); - this->audio_source_->consume(this->audio_source_->available()); + this->socket_->sendto(this->audio_source_->data(), available, 0, (struct sockaddr *) &this->dest_addr_, + sizeof(this->dest_addr_)); + this->audio_source_->consume(available); } } // audio mode break; @@ -841,8 +893,8 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { }); State new_state = this->local_output_ ? State::STREAMING_RESPONSE : State::IDLE; if (new_state != this->state_) { - // Don't needlessly change the state. The intent progress stage may have already changed the state to streaming - // response. + // Don't needlessly change the state. The intent progress stage may have already changed the state to + // streaming response. this->set_state_(new_state, new_state); } break; diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index f3ea669e15..76b076a366 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -244,6 +244,12 @@ class VoiceAssistant : public Component { void signal_stop_(); void start_playback_timeout_(); + // Drains the exposed microphone audio and sends it to Home Assistant over the API in one loop() pass. + void stream_api_audio_(); + // Handles a pass where at least one configured channel has no audio exposed, timing out a channel that + // stalls. See audio_channel_stall_start_. + void handle_channel_stall_(size_t available, size_t available2); + std::unique_ptr socket_ = nullptr; struct sockaddr_storage dest_addr_; @@ -315,6 +321,13 @@ class VoiceAssistant : public Component { std::weak_ptr ring_buffer_; std::weak_ptr ring_buffer2_; + // When streaming multiple channels, the send loop holds an exposed chunk on one channel until the other + // channel also has audio so the channels are always sent together (an empty payload looks like + // end-of-stream to Home Assistant). Home Assistant has no stream timeout, so a channel that stops + // producing entirely would hang streaming forever. This records when such an imbalance began so a + // prolonged one can be detected and stopped; 0 means no imbalance is currently being timed. + uint32_t audio_channel_stall_start_{0}; + bool use_wake_word_; uint8_t noise_suppression_level_; uint8_t auto_gain_; From bac62cb7dec73d5e3311d16d607fb1485e4e9584 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 15:29:06 -0500 Subject: [PATCH 1446/2214] [core] Add cv.sensitive marker for schema-level sensitive fields (#16673) --- esphome/config_validation.py | 47 +++++++++++++++ script/build_language_schema.py | 39 +++++++++++- tests/script/test_build_language_schema.py | 69 +++++++++++++++++++++- tests/unit_tests/test_config_validation.py | 43 ++++++++++++++ 4 files changed, 193 insertions(+), 5 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ca1fd8f5d4..1d5e27c9ae 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -487,6 +487,53 @@ def string_strict(value): ) +# Substring fallbacks for fields whose validator isn't explicitly wrapped in +# ``cv.sensitive``. Frontends and dump tooling should prefer the explicit +# marker; this list exists so we still mask obvious leaks in unmigrated or +# third-party schemas. Kept here as the single source of truth. +SENSITIVE_KEY_FRAGMENTS: frozenset[str] = frozenset( + { + "password", + "passcode", + "secret", + "token", + "api_key", + "apikey", + "psk", + } +) + + +class SensitiveValidator: + """Marker wrapper that flags a field as containing sensitive data (passwords, + encryption keys, PSKs, tokens). Frontends and dump tooling detect this marker + to mask the value; validation behavior is delegated to the inner validator. + """ + + def __init__(self, inner: Callable[[typing.Any], typing.Any]) -> None: + self.inner = inner + + def __call__(self, value: typing.Any) -> typing.Any: + return self.inner(value) + + def __repr__(self) -> str: + # Mirror the inner validator's repr so ``build_language_schema``'s + # ``known_schemas``/``extended_schemas`` dedup (keyed on ``repr(schema)``) + # treats two wrappers around the same inner as identical, and so + # voluptuous error messages stay readable. + return repr(self.inner) + + +def sensitive( + inner: Callable[[typing.Any], typing.Any] = string, +) -> SensitiveValidator: + """Mark a field as sensitive so that frontends mask it and dump tooling redacts it. + + Validation behavior is identical to ``inner`` (defaults to ``cv.string``). + """ + return SensitiveValidator(inner) + + def icon(value): """Validate that a given config value is a valid icon.""" from esphome.core.config import ICON_MAX_LENGTH diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 9dff70af3c..6e4000e06e 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -39,7 +39,11 @@ parser.add_argument( ) parser.add_argument("--check", action="store_true", help="Check only for CI") -args = parser.parse_args() +# Module-level ``Namespace`` so helper functions can reference ``args`` +# without threading it through every call. ``main()`` fills it via +# ``parser.parse_args(namespace=args)``; tests import this module without +# invoking ``main()`` and rely on the defaults below. +args = argparse.Namespace(output_path=".", check=False) DUMP_RAW = False DUMP_UNKNOWN = False @@ -850,6 +854,12 @@ def convert(schema, config_var, path): convert(ext, config_var, f"{path}/ext{idx}") return + if isinstance(schema, cv.SensitiveValidator): + config_var["sensitive"] = True + config_var["sensitive_source"] = "explicit" + convert(schema.inner, config_var, f"{path}/sensitive") + return + if isinstance(schema, cv.All): i = 0 for inner in schema.validators: @@ -1125,6 +1135,25 @@ def convert_keys(converted, schema, path): # Do value convert(v, result, path + f"/{str(k)}") + + # Heuristic fallback when the field's validator wasn't explicitly + # wrapped in ``cv.sensitive``. Only applies to string-typed leaves so + # we don't mark unrelated nested schemas. ``sensitive_source`` lets + # consumers distinguish explicit markers from heuristic matches. Pull + # the field name from ``k.schema`` (voluptuous's stored key) rather + # than ``str(k)`` so we don't depend on the marker's ``__str__`` + # representation. + if ( + "sensitive" not in result + and result.get(S_TYPE) == "string" + and isinstance(k, (cv.Required, cv.Optional, cv.Inclusive, cv.Exclusive)) + and isinstance(k.schema, str) + ): + key_lower = k.schema.lower() + if any(frag in key_lower for frag in cv.SENSITIVE_KEY_FRAGMENTS): + result["sensitive"] = True + result["sensitive_source"] = "heuristic" + if "schema" not in converted: converted[S_TYPE] = "schema" converted["schema"] = {S_CONFIG_VARS: {}} @@ -1142,4 +1171,10 @@ def convert_keys(converted, schema, path): config_vars["string"] = config_vars.pop(key) -build_schema() +def main() -> None: + parser.parse_args(namespace=args) + build_schema() + + +if __name__ == "__main__": + main() diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 59b8c7484b..dd1d88e74c 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -3,8 +3,11 @@ from __future__ import annotations import ast +import importlib.util from pathlib import Path +from esphome import config_validation as cv + SCRIPT_PATH = ( Path(__file__).resolve().parent.parent.parent / "script" @@ -12,10 +15,16 @@ SCRIPT_PATH = ( ) +def _load_script_module(): + spec = importlib.util.spec_from_file_location("build_language_schema", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def _extract_sort_obj(): - # build_language_schema.py runs argparse, loads every component, and - # calls build_schema() at import time, so a plain import isn't viable - # in a unit test. Pull just the pure helper out via AST instead. + # ``sort_obj`` is pure and self-contained; pulling it via AST avoids + # exercising the module-level component-loading state for these tests. tree = ast.parse(SCRIPT_PATH.read_text()) for node in tree.body: if isinstance(node, ast.FunctionDef) and node.name == "sort_obj": @@ -27,6 +36,7 @@ def _extract_sort_obj(): sort_obj = _extract_sort_obj() +_bls = _load_script_module() def test_sort_obj_sorts_dict_keys() -> None: @@ -96,3 +106,56 @@ def test_sort_obj_passes_through_scalars() -> None: assert sort_obj(42) == 42 assert sort_obj(None) is None assert sort_obj(True) is True + + +def test_convert_emits_explicit_sensitive_marker() -> None: + config_var: dict = {} + _bls.convert(cv.sensitive(cv.string), config_var, "/test") + + assert config_var["sensitive"] is True + assert config_var["sensitive_source"] == "explicit" + assert config_var["type"] == "string" + + +def test_convert_keys_emits_heuristic_sensitive_marker() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") + + entry = converted["schema"]["config_vars"]["password"] + assert entry["sensitive"] is True + assert entry["sensitive_source"] == "heuristic" + assert entry["type"] == "string" + + +def test_convert_keys_explicit_beats_heuristic() -> None: + # Key name matches a fragment but the validator is explicitly wrapped; + # the explicit branch should win and emit ``sensitive_source: explicit``. + converted: dict = {} + _bls.convert_keys( + converted, {cv.Optional("password"): cv.sensitive(cv.string)}, "/root" + ) + + entry = converted["schema"]["config_vars"]["password"] + assert entry["sensitive"] is True + assert entry["sensitive_source"] == "explicit" + + +def test_convert_keys_no_heuristic_for_non_string_leaves() -> None: + # Even though the key contains a fragment, a non-string leaf must not + # be flagged. Prevents false positives on unrelated fields whose name + # happens to embed a substring like "token". + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional("password"): cv.boolean}, "/root") + + entry = converted["schema"]["config_vars"]["password"] + assert "sensitive" not in entry + assert "sensitive_source" not in entry + + +def test_convert_keys_no_marker_for_non_sensitive_field() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional("hostname"): cv.string}, "/root") + + entry = converted["schema"]["config_vars"]["hostname"] + assert "sensitive" not in entry + assert "sensitive_source" not in entry diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index fd6c0e95f2..2c34cbfb07 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -127,6 +127,49 @@ def test_string_string__invalid(value): config_validation.string_strict(value) +def test_sensitive__default_delegates_to_string() -> None: + validator = config_validation.sensitive() + + assert isinstance(validator, config_validation.SensitiveValidator) + assert validator.inner is config_validation.string + assert validator("hunter2") == "hunter2" + assert validator(42) == "42" + + +def test_sensitive__custom_inner_delegates_validation() -> None: + validator = config_validation.sensitive(config_validation.string_strict) + + assert validator.inner is config_validation.string_strict + assert validator("abc") == "abc" + with pytest.raises(Invalid, match="Must be string, got"): + validator(123) + + +def test_sensitive__is_detectable_via_isinstance() -> None: + validator = config_validation.sensitive() + + assert isinstance(validator, config_validation.SensitiveValidator) + + +def test_sensitive__repr_mirrors_inner() -> None: + # The schema dump dedups on ``repr(schema)``; mirroring the inner + # validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers + # interchangeable for that purpose and avoids leaking the wrapper as + # noise in voluptuous error messages. + assert repr(config_validation.sensitive(config_validation.string)) == repr( + config_validation.string + ) + assert repr(config_validation.sensitive(config_validation.string)) == repr( + config_validation.sensitive(config_validation.string) + ) + + +def test_sensitive_key_fragments__covers_common_terms() -> None: + assert isinstance(config_validation.SENSITIVE_KEY_FRAGMENTS, frozenset) + for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"): + assert term in config_validation.SENSITIVE_KEY_FRAGMENTS + + @given( builds( lambda v: "mdi:" + v, From 96816e24916192a36daf7119b8fca81836067476 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 15:29:38 -0500 Subject: [PATCH 1447/2214] [core] Enable ruff DTZ (flake8-datetimez) lint family (#16660) --- esphome/__main__.py | 2 +- esphome/components/api/client.py | 2 +- esphome/components/zigbee/zigbee_zephyr.py | 5 ++++- esphome/config_validation.py | 4 +++- esphome/external_files.py | 9 ++++++--- esphome/git.py | 8 ++++---- esphome/mqtt.py | 6 +++--- esphome/storage_json.py | 5 ++++- pyproject.toml | 1 + tests/unit_tests/test_external_files.py | 4 ++-- tests/unit_tests/test_git.py | 18 +++++++++--------- tests/unit_tests/test_storage_json.py | 4 ++-- 12 files changed, 40 insertions(+), 28 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 5f281ce832..dd97c6eee9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -639,7 +639,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: chunk = ser.read(ser.in_waiting or 1) if not chunk: continue - time_ = datetime.now() + time_ = datetime.now().astimezone() milliseconds = time_.microsecond // 1000 time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]" diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 327973a605..44edc035f9 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -119,7 +119,7 @@ async def async_run_logs( def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" - time_ = datetime.now() + time_ = datetime.now().astimezone() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") nanoseconds = time_.microsecond // 1000 diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index aa16bbef53..39ecadfddf 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -161,7 +161,10 @@ async def _attr_to_code(config: ConfigType) -> None: zigbee_set_string(basic_attrs.mf_name, "esphome"), zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( - basic_attrs.date_code, datetime.datetime.now().strftime("%Y%m%d %H%M%S") + basic_attrs.date_code, + # Local build time, matching the esp32 implementation + # (App.get_build_time() in C++). + datetime.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), ), zigbee_assign( basic_attrs.power_source, diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1d5e27c9ae..f826b254ac 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1183,7 +1183,9 @@ def date_time(date: bool, time: bool): format += "%p" try: - date_obj = datetime.strptime(value, format) + # The generated format never includes %z/%Z, so this parses a + # naive wall-clock date/time by design. + date_obj = datetime.strptime(value, format) # noqa: DTZ007 except ValueError as err: raise Invalid(f"Invalid {exc_message}: {err}") from err diff --git a/esphome/external_files.py b/esphome/external_files.py index dfabc54f47..4e73c8dc21 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -7,6 +7,7 @@ from datetime import UTC, datetime import logging import os from pathlib import Path +import time import requests @@ -141,9 +142,11 @@ def has_remote_file_changed( def is_file_recent(file_path: Path, refresh: TimePeriodSeconds) -> bool: if file_path.exists(): - creation_time = file_path.stat().st_ctime - current_time = datetime.now().timestamp() - return current_time - creation_time <= refresh.total_seconds + # st_mtime, not st_ctime: ctime is inode-change time on POSIX + # (bumped by chmod/chown/rename) so a metadata touch would make + # the file look fresh. + modification_time = file_path.stat().st_mtime + return time.time() - modification_time <= refresh.total_seconds return False diff --git a/esphome/git.py b/esphome/git.py index 094a6dae19..744ce35ef6 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -1,12 +1,12 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime import hashlib import logging from pathlib import Path import re import subprocess import sys +import time import urllib.parse import esphome.config_validation as cv @@ -247,11 +247,11 @@ def clone_or_update( return repo_dir, None file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD") - # On first clone, FETCH_HEAD does not exists + # On first clone, FETCH_HEAD does not exist if not file_timestamp.exists(): file_timestamp = Path(repo_dir / ".git" / "HEAD") - age = datetime.now() - datetime.fromtimestamp(file_timestamp.stat().st_mtime) - if refresh is None or age.total_seconds() > refresh.total_seconds: + age_seconds = time.time() - file_timestamp.stat().st_mtime + if refresh is None or age_seconds > refresh.total_seconds: # Try to update the repository, recovering from broken state if needed old_sha: str | None = None try: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index d6bde0cbfd..c6a7a7558b 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -139,7 +139,7 @@ def show_discover(config, username=None, password=None, client_id=None): _LOGGER.info("Starting log output from %s", topic) def on_message(client, userdata, msg): - time_ = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload @@ -184,7 +184,7 @@ def get_esphome_device_ip( def on_message(client, userdata, msg): nonlocal dev_ip - time_ = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload @@ -253,7 +253,7 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): _LOGGER.info("Starting log output from %s", topic) def on_message(client, userdata, msg): - time_ = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") message = time_ + payload safe_print(message) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 7f8885ba5f..04f5881465 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -338,7 +338,10 @@ class EsphomeStorageJSON: @property def last_update_check(self) -> datetime | None: try: - return datetime.strptime(self.last_update_check_str, "%Y-%m-%dT%H:%M:%S") + # Stored format is naive ISO without %z; preserved for backward compat. + return datetime.strptime( # noqa: DTZ007 + self.last_update_check_str, "%Y-%m-%dT%H:%M:%S" + ) except Exception: # pylint: disable=broad-except return None diff --git a/pyproject.toml b/pyproject.toml index 6572078746..d92c7ba894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ exclude = ['generated'] select = [ "B", # flake8-bugbear "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez "E", # pycodestyle "EXE", # flake8-executable "F", # pyflakes/autoflake diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 64ef149581..16cee9564f 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -120,7 +120,7 @@ def test_is_file_recent_with_old_file(setup_core: Path) -> None: old_time = time.time() - 7200 mock_stat = MagicMock() - mock_stat.st_ctime = old_time + mock_stat.st_mtime = old_time with patch.object(Path, "stat", return_value=mock_stat): refresh = TimePeriod(seconds=3600) @@ -147,7 +147,7 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None: # Mock stat to return a time 10 seconds ago mock_stat = MagicMock() - mock_stat.st_ctime = time.time() - 10 + mock_stat.st_mtime = time.time() - 10 with patch.object(Path, "stat", return_value=mock_stat): refresh = TimePeriod(seconds=0) result = external_files.is_file_recent(test_file, refresh) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 690c47c183..62d2344069 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,8 +1,8 @@ """Tests for git.py module.""" -from datetime import datetime, timedelta import os from pathlib import Path +import time from typing import Any from unittest.mock import Mock, patch @@ -34,9 +34,9 @@ def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None: # Create FETCH_HEAD file with old timestamp fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - old_time = datetime.now() - timedelta(days=days_old) + old_time = time.time() - days_old * 86400 fetch_head.touch() - os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) + os.utime(fetch_head, (old_time, old_time)) def _get_git_command_type(cmd: list[str]) -> str | None: @@ -285,10 +285,10 @@ def test_clone_or_update_with_refresh_updates_old_repo( # Create FETCH_HEAD file with old timestamp (2 days ago) fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - old_time = datetime.now() - timedelta(days=2) + old_time = time.time() - 2 * 86400 fetch_head.touch() # Create the file # Set modification time to 2 days ago - os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) + os.utime(fetch_head, (old_time, old_time)) # Mock git command responses mock_run_git_command.return_value = "abc123" # SHA for rev-parse @@ -333,10 +333,10 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( # Create FETCH_HEAD file with recent timestamp (1 hour ago) fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - recent_time = datetime.now() - timedelta(hours=1) + recent_time = time.time() - 3600 fetch_head.touch() # Create the file # Set modification time to 1 hour ago - os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) + os.utime(fetch_head, (recent_time, recent_time)) # Call with refresh=1d (1 day) refresh = TimePeriodSeconds(days=1) @@ -409,10 +409,10 @@ def test_clone_or_update_with_none_refresh_always_updates( # Create FETCH_HEAD file with very recent timestamp (1 second ago) fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - recent_time = datetime.now() - timedelta(seconds=1) + recent_time = time.time() - 1 fetch_head.touch() # Create the file # Set modification time to 1 second ago - os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) + os.utime(fetch_head, (recent_time, recent_time)) # Mock git command responses mock_run_git_command.return_value = "abc123" # SHA for rev-parse diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index ea37492cf4..b3f8a05605 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -576,8 +576,8 @@ def test_esphome_storage_json_last_update_check_property() -> None: assert result.hour == 10 assert result.minute == 30 - # Test setter - new_date = datetime(2024, 2, 20, 15, 45, 30) + # Test setter — naive datetime matches the storage round-trip format. + new_date = datetime(2024, 2, 20, 15, 45, 30) # noqa: DTZ001 storage.last_update_check = new_date assert storage.last_update_check_str == "2024-02-20T15:45:30" From 52ead52ef29fd01feabf6c6826b10b5f4fda200c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 15:29:54 -0500 Subject: [PATCH 1448/2214] [core] Enable ruff PGH (pygrep-hooks) lint family (#16651) --- esphome/components/esp32/__init__.py | 4 ++-- esphome/components/host/__init__.py | 2 +- esphome/components/libretiny/__init__.py | 2 +- .../libretiny/generate_components.py | 18 +++++++++++++----- esphome/components/light/__init__.py | 2 +- esphome/components/logger/__init__.py | 2 +- esphome/components/lvgl/helpers.py | 2 -- esphome/components/nrf52/__init__.py | 2 +- esphome/components/rp2040/__init__.py | 2 +- pyproject.toml | 1 + 10 files changed, 22 insertions(+), 15 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e3bff8f934..7b94a26f54 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -56,7 +56,7 @@ from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache from .boards import BOARDS, STANDARD_BOARDS -from .const import ( # noqa +from .const import ( KEY_ARDUINO_LIBRARIES, KEY_BOARD, KEY_COMPONENTS, @@ -86,7 +86,7 @@ from .const import ( # noqa ) # force import gpio to register pin schema -from .gpio import esp32_pin_to_code # noqa +from .gpio import esp32_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 8adbfb02ec..50deb1acf6 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -14,7 +14,7 @@ from esphome.core import CORE from .const import KEY_HOST # force import gpio to register pin schema -from .gpio import host_pin_to_code # noqa +from .gpio import host_pin_to_code # noqa: F401 CODEOWNERS = ["@esphome/core", "@clydebarrow"] AUTO_LOAD = ["network", "preferences"] diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index d1f1042501..afe0360c22 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -28,7 +28,7 @@ from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed from esphome.storage_json import StorageJSON -from . import gpio # noqa +from . import gpio # noqa: F401 from .const import ( COMPONENT_BK72XX, CONF_GPIO_RECOVER, diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index d5437895a6..6ca16f277f 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -1,7 +1,7 @@ # Copyright (c) Kuba Szczodrzyński 2023-06-01. # pylint: skip-file -# flake8: noqa +# ruff: noqa: C408, I001 import json import re @@ -313,8 +313,12 @@ def write_const( # build component constants comp_str = "\n".join(f'COMPONENT_{f} = "{f.lower()}"' for f in components) # replace the 2nd regex group only - repl = lambda m: m.group(1) + comp_str + m.group(3) - code = re.sub(comp_regex, repl, code, flags=re.DOTALL | re.MULTILINE) + code = re.sub( + comp_regex, + lambda m: m.group(1) + comp_str + m.group(3), + code, + flags=re.DOTALL | re.MULTILINE, + ) # regex for finding the family list block fam_regex = r"(# FAMILIES.+?\n)(.*?)(\n# FAMILIES)" @@ -337,8 +341,12 @@ def write_const( ] var_str = "\n".join(fam_lines) # replace the 2nd regex group only - repl = lambda m: m.group(1) + var_str + m.group(3) - code = re.sub(fam_regex, repl, code, flags=re.DOTALL | re.MULTILINE) + code = re.sub( + fam_regex, + lambda m: m.group(1) + var_str + m.group(3), + code, + flags=re.DOTALL | re.MULTILINE, + ) # format with black code = format_str(code, mode=FileMode()) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 68d9f85af2..7c4d7ed431 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -58,7 +58,7 @@ from .effects import ( RGB_EFFECTS, validate_effects, ) -from .types import ( # noqa +from .types import ( # noqa: F401 AddressableLight, AddressableLightState, ColorMode, diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index c6c440564a..5f160352cc 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -514,7 +514,7 @@ def validate_printf(value): (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size [cCdiouxXeEfgGaAnpsSZ] # type ) - """ # noqa + """ matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) if len(matches) != len(value[CONF_ARGS]): raise cv.Invalid( diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index 6f70a1e3bd..3da8643308 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -6,7 +6,6 @@ from esphome.const import CONF_ARGS, CONF_FORMAT CONF_IF_NAN = "if_nan" -# noqa f_regex = re.compile( r""" ( # start of capture group 1 @@ -20,7 +19,6 @@ f_regex = re.compile( """, flags=re.VERBOSE, ) -# noqa c_regex = re.compile( r""" ( # start of capture group 1 diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 2aba208af7..4ba1ab5d4d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -65,7 +65,7 @@ from .const import ( ) # force import gpio to register pin schema -from .gpio import nrf52_pin_to_code # noqa +from .gpio import nrf52_pin_to_code # noqa: F401 CODEOWNERS = ["@tomaszduda23"] AUTO_LOAD = ["zephyr", "preferences"] diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 830c961476..6ec0ee08b8 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -50,7 +50,7 @@ from .const import ( ) # force import gpio to register pin schema -from .gpio import rp2040_pin_to_code # noqa +from .gpio import rp2040_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@jesserockz"] diff --git a/pyproject.toml b/pyproject.toml index d92c7ba894..d2f30ea3d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,7 @@ select = [ "LOG", # flake8-logging "NPY", # numpy-specific rules "PERF", # performance + "PGH", # pygrep-hooks "PIE", # flake8-pie "PL", # pylint "PTH", # flake8-use-pathlib From 62b3b1cc7509e2b7a1ce5f26ebd08abe4fc8b495 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 27 May 2026 06:00:08 +0930 Subject: [PATCH 1449/2214] [lvgl] Support `rounded` property for meter arcs (#16669) --- esphome/components/lvgl/widgets/meter.py | 3 ++- tests/components/lvgl/lvgl-package.yaml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 62ea14bdda..e2407fad5a 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -184,6 +184,7 @@ INDICATOR_ARC_SCHEMA = cv.Schema( cv.Optional(CONF_START_VALUE): lv_float, cv.Optional(CONF_END_VALUE): lv_float, cv.Optional(CONF_OPA, default=1.0): opacity, + cv.Optional(CONF_ROUNDED, default=False): cv.boolean, } ).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE)) @@ -417,7 +418,7 @@ class MeterType(WidgetType): "arc_width": v[CONF_WIDTH], "arc_color": v[CONF_COLOR], "arc_opa": v[CONF_OPA], - "arc_rounded": v.get("arc_rounded", False), + "arc_rounded": v[CONF_ROUNDED], } if CONF_R_MOD in v: get_warnings().add( diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 0f4b961297..7af058e6b8 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1313,6 +1313,7 @@ lvgl: width: 6 start_value: 0 end_value: 360 + rounded: true - id: page3 layout: Horizontal pad_all: 6px From 4d908798bcf41b8cbe151a2d5c1aa753d7aaaa40 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:45:50 -0400 Subject: [PATCH 1450/2214] [core] Remove deprecated custom_components folder loading (#16679) --- esphome/config.py | 1 - esphome/loader.py | 13 ------------- esphome/writer.py | 4 ++-- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 79d0d2b02b..9da39a387b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1005,7 +1005,6 @@ def validate_config( CORE.skip_external_update = skip_external_update loader.clear_component_meta_finders() - loader.install_custom_components_meta_finder() # 0. Load packages if CONF_PACKAGES in config: diff --git a/esphome/loader.py b/esphome/loader.py index c57c09274e..8823d82fc1 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -12,7 +12,6 @@ from types import ModuleType from typing import TYPE_CHECKING, Any from esphome.const import SOURCE_FILE_EXTENSIONS -from esphome.core import CORE from esphome.types import ConfigType if TYPE_CHECKING: @@ -206,18 +205,6 @@ def install_meta_finder( sys.meta_path.insert(0, ComponentMetaFinder(components_path, allowed_components)) -def install_custom_components_meta_finder(): - # Remove before 2026.6.0 - custom_components_dir = (Path(CORE.config_dir) / "custom_components").resolve() - if custom_components_dir.is_dir() and any(custom_components_dir.iterdir()): - _LOGGER.warning( - "The 'custom_components' folder is deprecated and will be removed in 2026.6.0. " - "Please use 'external_components' instead. " - "See https://esphome.io/components/external_components.html for more information." - ) - install_meta_finder(custom_components_dir) - - def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: if domain in _COMPONENT_CACHE: return _COMPONENT_CACHE[domain] diff --git a/esphome/writer.py b/esphome/writer.py index ab014c5daa..ef7cbf5ac4 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -200,8 +200,8 @@ ESPHome automatically populates the build directory, and any changes to this directory will be removed the next time esphome is run. -For modifying esphome's core files, please use a development esphome install, -the custom_components folder or the external_components feature. +For modifying esphome's core files, please use a development esphome install +or the external_components feature. """ From b71d445e7963303f1ab76b6dd9ccdebf609115b1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:46:45 -0400 Subject: [PATCH 1451/2214] [core] Remove deprecated const char* mark_failed/status_set_error (#16680) --- esphome/core/component.cpp | 33 +++++++-------------------------- esphome/core/component.h | 17 ----------------- 2 files changed, 7 insertions(+), 43 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index e33652482e..2d80301897 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -32,10 +32,7 @@ static const char *const TAG = "component"; namespace { struct ComponentErrorMessage { const Component *component; - const char *message; - // Track if message is flash pointer (needs LOG_STR_ARG) or RAM pointer - // Remove before 2026.6.0 when deprecated const char* API is removed - bool is_flash_ptr; + const LogString *message; }; #ifdef USE_SETUP_PRIORITY_OVERRIDE @@ -56,9 +53,8 @@ std::vector *setup_priority_overrides = nullptr; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::vector *component_error_messages = nullptr; -// Helper to store error messages - reduces duplication between deprecated and new API -// Remove before 2026.6.0 when deprecated const char* API is removed -void store_component_error_message(const Component *component, const char *message, bool is_flash_ptr) { +// Helper to store error messages +void store_component_error_message(const Component *component, const LogString *message) { // Lazy allocate the error messages vector if needed if (!component_error_messages) { component_error_messages = new std::vector(); @@ -67,12 +63,11 @@ void store_component_error_message(const Component *component, const char *messa for (auto &entry : *component_error_messages) { if (entry.component == component) { entry.message = message; - entry.is_flash_ptr = is_flash_ptr; return; } } // Add new error message - component_error_messages->emplace_back(ComponentErrorMessage{component, message, is_flash_ptr}); + component_error_messages->emplace_back(ComponentErrorMessage{component, message}); } } // namespace @@ -209,21 +204,17 @@ void Component::call_dump_config_() { this->dump_config(); if (this->is_failed()) { // Look up error message from global vector - const char *error_msg = nullptr; - bool is_flash_ptr = false; + const LogString *error_msg = nullptr; if (component_error_messages) { for (const auto &entry : *component_error_messages) { if (entry.component == this) { error_msg = entry.message; - is_flash_ptr = entry.is_flash_ptr; break; } } } - // Log with appropriate format based on pointer type ESP_LOGE(TAG, " %s is marked FAILED: %s", LOG_STR_ARG(this->get_component_log_str()), - error_msg ? (is_flash_ptr ? LOG_STR_ARG((const LogString *) error_msg) : error_msg) - : LOG_STR_LITERAL("unspecified")); + error_msg ? LOG_STR_ARG(error_msg) : LOG_STR_LITERAL("unspecified")); } } @@ -390,23 +381,13 @@ void Component::status_set_warning(const LogString *message) { message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } -void Component::status_set_error(const char *message) { - if (!this->set_status_flag_(STATUS_LED_ERROR)) - return; - ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), - message ? message : LOG_STR_LITERAL("unspecified")); - if (message != nullptr) { - store_component_error_message(this, message, false); - } -} void Component::status_set_error(const LogString *message) { if (!this->set_status_flag_(STATUS_LED_ERROR)) return; ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); if (message != nullptr) { - // Store the LogString pointer directly (safe because LogString is always in flash/static memory) - store_component_error_message(this, LOG_STR_ARG(message), true); + store_component_error_message(this, message); } } void Component::status_clear_warning_slow_path_() { diff --git a/esphome/core/component.h b/esphome/core/component.h index 5baf795ca6..ff10f1a8f1 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -220,18 +220,6 @@ class Component { */ void mark_failed(); - // Remove before 2026.6.0 - ESPDEPRECATED("Use mark_failed(LOG_STR(\"static string literal\")) instead. Do NOT use .c_str() from temporary " - "strings. Will stop working in 2026.6.0", - "2025.12.0") - void mark_failed(const char *message) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->status_set_error(message); -#pragma GCC diagnostic pop - this->mark_failed(); - } - void mark_failed(const LogString *message) { this->status_set_error(message); this->mark_failed(); @@ -296,11 +284,6 @@ class Component { void status_set_warning(const LogString *message); void status_set_error(); // Set error flag without message - // Remove before 2026.6.0 - ESPDEPRECATED("Use status_set_error(LOG_STR(\"static string literal\")) instead. Do NOT use .c_str() from temporary " - "strings. Will stop working in 2026.6.0", - "2025.12.0") - void status_set_error(const char *message); void status_set_error(const LogString *message); void status_clear_warning() { From 171ded35a58728ef48cb65198bcd457ea92f1fa9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:47:16 -0400 Subject: [PATCH 1452/2214] [core] Remove cv.only_with_esp_idf and CORE.using_esp_idf (#16681) --- esphome/config_validation.py | 10 ---------- esphome/core/__init__.py | 9 --------- script/ci-custom.py | 13 ------------- 3 files changed, 32 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index f826b254ac..2f09fdc105 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -857,16 +857,6 @@ only_on_rp2040 = only_on(PLATFORM_RP2040) only_with_arduino = only_with_framework(Framework.ARDUINO) -def only_with_esp_idf(obj): - """Deprecated: use only_on_esp32 instead.""" - _LOGGER.warning( - "cv.only_with_esp_idf was deprecated in 2026.1, will change behavior in 2026.6. " - "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. " - "Use cv.only_on_esp32 and/or cv.only_with_arduino instead." - ) - return only_with_framework(Framework.ESP_IDF)(obj) - - # Adapted from: # https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666 def has_at_least_one_key(*keys): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 182be38b18..df8fd0a756 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -859,15 +859,6 @@ class EsphomeCore: def using_arduino(self): return self.target_framework == "arduino" - @property - def using_esp_idf(self): - _LOGGER.warning( - "CORE.using_esp_idf was deprecated in 2026.1, will change behavior in 2026.6. " - "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. " - "Use CORE.is_esp32 and/or CORE.using_arduino instead." - ) - return self.target_framework == "esp-idf" - @property def using_toolchain_esp_idf(self): return self.toolchain == Toolchain.ESP_IDF diff --git a/script/ci-custom.py b/script/ci-custom.py index 1ac13e18f7..78ff6cf781 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -693,19 +693,6 @@ def lint_esphome_h(fname, line, col, content): ) -@lint_content_find_check( - "CORE.using_esp_idf", - include=py_include, - exclude=["esphome/core/__init__.py", "script/ci-custom.py"], -) -def lint_using_esp_idf_deprecated(fname, line, col, content): - return ( - f"{highlight('CORE.using_esp_idf')} is deprecated and will change behavior in 2026.6. " - "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. " - f"Please use {highlight('CORE.is_esp32')} and/or {highlight('CORE.using_arduino')} instead." - ) - - @lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"]) def lint_pragma_once(fname, content): if "#pragma once" not in content: From fb0b73980bf188d5530e62c02dda451560164c2b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:47:40 -0400 Subject: [PATCH 1453/2214] [wifi] Default ESP8266 min_auth_mode to WPA2 (#16682) --- esphome/components/wifi/__init__.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index f9cb391442..e5e57cc97d 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -326,23 +326,9 @@ def validate_variant(_): def _apply_min_auth_mode_default(config): - """Apply platform-specific default for min_auth_mode and warn ESP8266 users.""" - # Only apply defaults for platforms that support min_auth_mode + """Apply platform-specific default for min_auth_mode.""" if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): - if CORE.is_esp8266: - _LOGGER.warning( - "The minimum WiFi authentication mode (wifi -> min_auth_mode) is not set. " - "This controls the weakest encryption your device will accept when connecting to WiFi. " - "Currently defaults to WPA (less secure), but will change to WPA2 (more secure) in 2026.6.0. " - "WPA uses TKIP encryption which has known security vulnerabilities and should be avoided. " - "WPA2 uses AES encryption which is significantly more secure. " - "To silence this warning, explicitly set min_auth_mode under 'wifi:'. " - "If your router supports WPA2 or WPA3, set 'min_auth_mode: WPA2'. " - "If your router only supports WPA, set 'min_auth_mode: WPA'." - ) - config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA") - elif CORE.is_esp32: - config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA2") + config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA2") return config From eb1196c6b22ffe8da8c273d52bba546489ff0d04 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:48:17 -0400 Subject: [PATCH 1454/2214] [nfc] Remove deprecated heap-allocating format helpers (#16684) --- esphome/components/nfc/nfc.cpp | 11 ----------- esphome/components/nfc/nfc.h | 7 ------- 2 files changed, 18 deletions(-) diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index 99e476dbdf..76a391f1de 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -15,17 +15,6 @@ char *format_bytes_to(char *buffer, std::span bytes) { return format_hex_pretty_to(buffer, FORMAT_BYTES_BUFFER_SIZE, bytes.data(), bytes.size(), ' '); } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -// Deprecated wrappers intentionally use heap-allocating version for backward compatibility -std::string format_uid(std::span uid) { - return format_hex_pretty(uid.data(), uid.size(), '-', false); // NOLINT -} -std::string format_bytes(std::span bytes) { - return format_hex_pretty(bytes.data(), bytes.size(), ' ', false); // NOLINT -} -#pragma GCC diagnostic pop - uint8_t guess_tag_type(uint8_t uid_length) { if (uid_length == 4) { return TAG_TYPE_MIFARE_CLASSIC; diff --git a/esphome/components/nfc/nfc.h b/esphome/components/nfc/nfc.h index 42ef993913..36b27ce5f6 100644 --- a/esphome/components/nfc/nfc.h +++ b/esphome/components/nfc/nfc.h @@ -63,13 +63,6 @@ static constexpr size_t FORMAT_BYTES_BUFFER_SIZE = 192; /// Format bytes to buffer with ' ' separator (e.g., "04 11 22 33"). Returns buffer for inline use. char *format_bytes_to(char *buffer, std::span bytes); -// Remove before 2026.6.0 -ESPDEPRECATED("Use format_uid_to() with stack buffer instead. Removed in 2026.6.0", "2025.12.0") -std::string format_uid(std::span uid); -// Remove before 2026.6.0 -ESPDEPRECATED("Use format_bytes_to() with stack buffer instead. Removed in 2026.6.0", "2025.12.0") -std::string format_bytes(std::span bytes); - uint8_t guess_tag_type(uint8_t uid_length); int8_t get_mifare_classic_ndef_start_index(std::vector &data); bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_length, uint8_t &message_start_index); From 6c4a8a324515f9d6dc3280ed4e82993022c5138b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:49:44 -0400 Subject: [PATCH 1455/2214] [dsmr] Force BearSSL on ESP8266 to avoid mbedtls link failure (#16686) --- esphome/components/dsmr/dsmr.h | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index 626a389c1f..e55db9f976 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -16,9 +16,14 @@ #include #include +// On ESP8266 Arduino, BearSSL is the native crypto. The mbedtls headers can +// still be in scope when a sibling component (e.g. wireguard) pulls in +// esp_mbedtls_esp8266, but that build leaves MBEDTLS_GCM_C disabled so the +// gcm.h symbols are unresolved at link time. Force BearSSL on ESP8266 to +// avoid that linker error. #if __has_include() #include -#elif __has_include() +#elif !defined(USE_ESP8266) && __has_include() #if __has_include() #include #endif @@ -33,7 +38,7 @@ namespace esphome::dsmr { #if __has_include() using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa; -#elif __has_include() +#elif !defined(USE_ESP8266) && __has_include() using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls; #else using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl; From f728cb437377ae32c9c2b0b99ca9c55d6c8d9925 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:50:20 -0400 Subject: [PATCH 1456/2214] [core] Remove deprecated seq/gens templates (#16685) --- esphome/core/automation.h | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 468ea3b382..ea522a4d2d 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -13,27 +13,6 @@ namespace esphome { -// C++20 std::index_sequence is now used for tuple unpacking -// Legacy seq<>/gens<> pattern deprecated but kept for backwards compatibility -// https://stackoverflow.com/questions/7858817/unpacking-a-tuple-to-call-a-matching-function-pointer/7858971#7858971 -// Remove before 2026.6.0 -// NOLINTBEGIN(readability-identifier-naming) -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -#endif - -template struct ESPDEPRECATED("Use std::index_sequence instead. Removed in 2026.6.0", "2025.12.0") seq {}; -template -struct ESPDEPRECATED("Use std::make_index_sequence instead. Removed in 2026.6.0", "2025.12.0") gens - : gens {}; -template struct gens<0, S...> { using type = seq; }; - -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic pop -#endif -// NOLINTEND(readability-identifier-naming) - /// Function-pointer-only templatable storage (4 bytes on 32-bit). /// Used by the TEMPLATABLE_VALUE macro for codegen-managed fields. /// Codegen wraps constants in stateless lambdas so only a function pointer is needed. From e174c44b283f0ed6f3748f0c796b9ee62922bdb4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 19:15:25 -0400 Subject: [PATCH 1457/2214] [neopixelbus] Deprecate on ESP32 (#16676) --- esphome/components/neopixelbus/light.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index 943fd141f6..2e18688af0 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import light @@ -22,6 +24,7 @@ from esphome.const import ( Framework, ) from esphome.core import CORE +from esphome.types import ConfigType from ._methods import ( METHOD_BIT_BANG, @@ -34,6 +37,8 @@ from ._methods import ( ) from .const import CHIP_TYPES, CONF_ASYNC, CONF_BUS, ONE_WIRE_CHIPS +_LOGGER = logging.getLogger(__name__) + neopixelbus_ns = cg.esphome_ns.namespace("neopixelbus") NeoPixelBusLightOutputBase = neopixelbus_ns.class_( "NeoPixelBusLightOutputBase", light.AddressableLight @@ -134,6 +139,17 @@ def _validate(config): return config +def _warn_esp32_deprecated(config: ConfigType) -> ConfigType: + if CORE.is_esp32: + _LOGGER.warning( + "'neopixelbus' on ESP32 is deprecated. The upstream library " + "(makuna/NeoPixelBus) is no longer actively maintained. Migrate " + "to 'esp32_rmt_led_strip'. Removal is targeted for 2027.1 but " + "may happen sooner once ESPHome moves to ESP-IDF 6." + ) + return config + + def _validate_method(value): if value is None: # default method is determined afterwards because it depends on the chip type chosen @@ -195,6 +211,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), _choose_default_method, _validate, + _warn_esp32_deprecated, ) From a6ef67aa65892d7312ba7191bbe41742b2d0c80a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 19:34:52 -0400 Subject: [PATCH 1458/2214] [text_sensor] Remove deprecated public raw_state member (#16683) --- .../components/text_sensor/text_sensor.cpp | 19 ++++++------------- esphome/components/text_sensor/text_sensor.h | 10 ++-------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 31543117b8..d2483619a6 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -39,16 +39,13 @@ void TextSensor::publish_state(const char *state, size_t len) { #ifdef USE_TEXT_SENSOR_FILTER } else { // Has filters: need separate raw storage -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" // Only assign if changed to avoid heap allocation - if (len != this->raw_state.size() || memcmp(state, this->raw_state.data(), len) != 0) { - this->raw_state.assign(state, len); + if (len != this->raw_state_.size() || memcmp(state, this->raw_state_.data(), len) != 0) { + this->raw_state_.assign(state, len); } - this->raw_callback_.call(this->raw_state); - ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), this->raw_state.c_str()); - this->filter_list_->input(this->raw_state); -#pragma GCC diagnostic pop + this->raw_callback_.call(this->raw_state_); + ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), this->raw_state_.c_str()); + this->filter_list_->input(this->raw_state_); } #endif } @@ -89,11 +86,7 @@ const std::string &TextSensor::get_state() const { return this->state; } const std::string &TextSensor::get_raw_state() const { #ifdef USE_TEXT_SENSOR_FILTER if (this->filter_list_ != nullptr) { - // Suppress deprecation warning - get_raw_state() is the replacement API -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return this->raw_state; -#pragma GCC diagnostic pop + return this->raw_state_; } #endif return this->state; // No filters, raw == filtered diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 3f69e91c8d..aa48781f41 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -29,19 +29,12 @@ class TextSensor : public EntityBase { public: std::string state; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.6.0. - ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.6.0", "2025.12.0") - std::string raw_state; - TextSensor() = default; ~TextSensor() = default; -#pragma GCC diagnostic pop /// Getter-syntax for .state. const std::string &get_state() const; - /// Getter-syntax for .raw_state + /// Returns the raw (pre-filter) state. const std::string &get_raw_state() const; void publish_state(const std::string &state); @@ -84,6 +77,7 @@ class TextSensor : public EntityBase { /// Notify frontend that state has changed (assumes this->state is already set) void notify_frontend_(); #ifdef USE_TEXT_SENSOR_FILTER + std::string raw_state_; ///< Backing storage for the raw (pre-filter) value. Only used when a filter is attached. LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. #endif LazyCallbackManager callback_; ///< Storage for filtered state callbacks. From 91ead4ff543e6d46f5e6aa987e7f4206e6de868a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 19:16:47 -0500 Subject: [PATCH 1459/2214] [core] Mark canonical sensitive fields with cv.sensitive (#16677) --- esphome/components/api/__init__.py | 2 +- esphome/components/esphome/ota/__init__.py | 2 +- esphome/components/http_request/ota/__init__.py | 2 +- esphome/components/mqtt/__init__.py | 2 +- esphome/components/web_server/__init__.py | 4 ++-- esphome/components/wifi/__init__.py | 8 ++++---- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ca74483a2b..932702d47a 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -234,7 +234,7 @@ ACTIONS_SCHEMA = automation.validate_automation( ENCRYPTION_SCHEMA = cv.Schema( { - cv.Optional(CONF_KEY): validate_encryption_key, + cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key), } ) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f7793b1493..66a33e1935 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -133,7 +133,7 @@ CONFIG_SCHEMA = cv.All( host=8082, ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, - cv.Optional(CONF_PASSWORD): cv.string, + cv.Optional(CONF_PASSWORD): cv.sensitive(), cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" ), diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index fb59e51943..1bb54599dc 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -57,7 +57,7 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( cv.Optional(CONF_MD5): cv.templatable( cv.All(cv.string, cv.Length(min=32, max=32)) ), - cv.Optional(CONF_PASSWORD): cv.templatable(cv.string), + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.templatable(cv.string)), cv.Optional(CONF_USERNAME): cv.templatable(cv.string), cv.Required(CONF_URL): cv.templatable(cv.url), } diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index cb6b9d144f..86bba11a60 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -232,7 +232,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_PORT, default=1883): cv.port, cv.Optional(CONF_USERNAME, default=""): cv.string, - cv.Optional(CONF_PASSWORD, default=""): cv.string, + cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(), cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean, cv.Optional(CONF_CLIENT_ID): cv.string, cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All( diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 99a9b7518c..fd380a38dd 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -193,8 +193,8 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_USERNAME): cv.All( cv.string_strict, cv.Length(min=1) ), - cv.Required(CONF_PASSWORD): cv.All( - cv.string_strict, cv.Length(min=1) + cv.Required(CONF_PASSWORD): cv.sensitive( + cv.All(cv.string_strict, cv.Length(min=1)) ), } ), diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index e5e57cc97d..4e7dcc82e5 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -251,7 +251,7 @@ EAP_AUTH_SCHEMA = cv.All( { cv.Optional(CONF_IDENTITY): cv.string_strict, cv.Optional(CONF_USERNAME): cv.string_strict, - cv.Optional(CONF_PASSWORD): cv.string_strict, + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.string_strict), cv.Optional(CONF_CERTIFICATE_AUTHORITY): wpa2_eap.validate_certificate, cv.SplitDefault(CONF_TTLS_PHASE_2, esp32="mschapv2"): cv.All( cv.enum(TTLS_PHASE_2), cv.only_on_esp32 @@ -272,7 +272,7 @@ WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), cv.Optional(CONF_SSID): cv.ssid, - cv.Optional(CONF_PASSWORD): validate_password, + cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, } @@ -435,7 +435,7 @@ CONFIG_SCHEMA = cv.All( cv.ensure_list(WIFI_NETWORK_STA), cv.Length(max=MAX_WIFI_NETWORKS) ), cv.Optional(CONF_SSID): cv.ssid, - cv.Optional(CONF_PASSWORD): validate_password, + cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, @@ -851,7 +851,7 @@ async def final_step(): cv.Schema( { cv.Required(CONF_SSID): cv.templatable(cv.ssid), - cv.Required(CONF_PASSWORD): cv.templatable(validate_password), + cv.Required(CONF_PASSWORD): cv.sensitive(cv.templatable(validate_password)), cv.Optional(CONF_SAVE, default=True): cv.templatable(cv.boolean), cv.Optional(CONF_TIMEOUT, default="30000ms"): cv.templatable( cv.positive_time_period_milliseconds From 87d0e24d194164e1988ba742cd75b32176cf6cf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 19:57:29 -0500 Subject: [PATCH 1460/2214] Bump aioesphomeapi from 45.2.2 to 45.3.1 (#16688) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 45401a7995..14dddbb1aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.2.2 +aioesphomeapi==45.3.1 zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 8d19c55be2325d0a1c2dd06898ad4ec19c7c6602 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 19:58:13 -0500 Subject: [PATCH 1461/2214] Bump pytest-asyncio from 1.3.0 to 1.4.0 (#16687) 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 102a9cae6e..aad1da0807 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ pre-commit pytest==9.0.3 pytest-cov==7.1.0 pytest-mock==3.15.1 -pytest-asyncio==1.3.0 +pytest-asyncio==1.4.0 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 From 49b9d9313edbf5ca45c5b2803bf42a64cf18f1a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 21:49:31 -0500 Subject: [PATCH 1462/2214] [core] Sensitive redaction via yaml_util representer cv.sensitive(...) now returns a SensitiveStr (thin str subclass) so the tag travels with the validated value. yaml_util.dump constructs a per-call ESPHomeDumper subclass with a class-attribute redaction flag; the PyYAML representer for SensitiveStr renders values wrapped in literal \\033[8m...\\033[28m text when show_secrets is False and raw when True. The post-dump regex in command_config is deleted. Also tags wifi.ssid sites with cv.sensitive so SSID coverage isn't lost when the regex (which matched 'ssid:' via substring) goes away. No module-level mutable state; the per-call subclass keeps each dump invocation self-contained and thread-safe by construction. --- esphome/__main__.py | 5 -- esphome/components/wifi/__init__.py | 6 +-- esphome/config_validation.py | 10 +++- esphome/yaml_util.py | 43 ++++++++++++++++- tests/unit_tests/test_config_validation.py | 40 ++++++++++++++++ tests/unit_tests/test_yaml_util.py | 55 ++++++++++++++++++++++ 6 files changed, 148 insertions(+), 11 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index dd97c6eee9..7eef646ebb 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1412,11 +1412,6 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) - # add the console decoration so the front-end can hide the secrets - if not args.show_secrets: - output = re.sub( - r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output - ) if not CORE.quiet: safe_print(output) _LOGGER.info("Configuration is valid!") diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4e7dcc82e5..b7719c80d1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -271,7 +271,7 @@ EAP_AUTH_SCHEMA = cv.All( WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), - cv.Optional(CONF_SSID): cv.ssid, + cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, @@ -434,7 +434,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_NETWORKS): cv.All( cv.ensure_list(WIFI_NETWORK_STA), cv.Length(max=MAX_WIFI_NETWORKS) ), - cv.Optional(CONF_SSID): cv.ssid, + cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, @@ -850,7 +850,7 @@ async def final_step(): WiFiConfigureAction, cv.Schema( { - cv.Required(CONF_SSID): cv.templatable(cv.ssid), + cv.Required(CONF_SSID): cv.sensitive(cv.templatable(cv.ssid)), cv.Required(CONF_PASSWORD): cv.sensitive(cv.templatable(validate_password)), cv.Optional(CONF_SAVE, default=True): cv.templatable(cv.boolean), cv.Optional(CONF_TIMEOUT, default="30000ms"): cv.templatable( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 2f09fdc105..0ef6d212fe 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -101,7 +101,7 @@ from esphome.schema_extractors import ( ) from esphome.util import parse_esphome_version from esphome.voluptuous_schema import _Schema -from esphome.yaml_util import make_data_base +from esphome.yaml_util import SensitiveStr, make_data_base _LOGGER = logging.getLogger(__name__) @@ -514,7 +514,13 @@ class SensitiveValidator: self.inner = inner def __call__(self, value: typing.Any) -> typing.Any: - return self.inner(value) + validated = self.inner(value) + # Tag string results so yaml_util.dump can mask them. Non-string + # results pass through unchanged; already-tagged values are not + # re-wrapped to keep nested cv.sensitive applications idempotent. + if isinstance(validated, str) and not isinstance(validated, SensitiveStr): + return SensitiveStr(validated) + return validated def __repr__(self) -> str: # Mirror the inner validator's repr so ``build_language_schema``'s diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 28f72ab831..19cb775a50 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -52,6 +52,16 @@ _load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] +class SensitiveStr(str): + """Marker subclass for validated strings that should be masked in + user-visible YAML output. ``cv.sensitive`` wraps validated values in this + type so ``dump()`` can render them with ANSI conceal codes without + needing a post-process regex. + """ + + __slots__ = () + + @contextmanager def track_yaml_loads() -> Generator[list[Path]]: """Context manager that records every file loaded by the YAML loader. @@ -808,11 +818,18 @@ def dump(dict_, show_secrets=False, sort_keys=False): if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() + + # Per-call subclass so the redaction flag lives on the dumper class itself, + # not in module-level mutable state. Cheap (one type object per call), + # encapsulated, and thread-safe by construction. + class _Dumper(ESPHomeDumper): + _redact_sensitive = not show_secrets + return yaml.dump( dict_, default_flow_style=False, allow_unicode=True, - Dumper=ESPHomeDumper, + Dumper=_Dumper, sort_keys=sort_keys, ) @@ -958,6 +975,10 @@ def format_path(path: DocumentPath, current_obj: Any) -> str: class ESPHomeDumper(yaml.SafeDumper): + # Default for the base class; per-call subclass in ``dump()`` overrides. + # When True, ``represent_sensitive`` wraps values in ANSI conceal codes. + _redact_sensitive: bool = False + def represent_mapping(self, tag, mapping, flow_style=None): value = [] node = yaml.MappingNode(tag, value, flow_style=flow_style) @@ -992,6 +1013,23 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + def represent_sensitive(self, value): + # ``!secret`` references win: keep the original representation so the + # dumped YAML round-trips back to ``!secret name`` instead of leaking + # the resolved value. + if is_secret(value): + return self.represent_secret(value) + if self._redact_sensitive: + # Emit the conceal sequence as literal ``\033`` text (not actual + # ESC bytes) so the dump matches the previous regex-based output + # and downstream consumers like device-builder, which match + # ``\033[8m...\033[28m`` against the rendered text, keep working. + return self.represent_scalar( + tag="tag:yaml.org,2002:str", + value=f"\\033[8m{value}\\033[28m", + ) + return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + # pylint: disable=arguments-renamed def represent_bool(self, value): return self.represent_scalar( @@ -1063,6 +1101,9 @@ ESPHomeDumper.add_multi_representer( ) ESPHomeDumper.add_multi_representer(bool, ESPHomeDumper.represent_bool) ESPHomeDumper.add_multi_representer(str, ESPHomeDumper.represent_stringify) +# Registered after ``str`` so ``add_multi_representer``'s MRO lookup prefers +# the more specific ``SensitiveStr`` representer over the bare-``str`` one. +ESPHomeDumper.add_multi_representer(SensitiveStr, ESPHomeDumper.represent_sensitive) ESPHomeDumper.add_multi_representer(int, ESPHomeDumper.represent_int) ESPHomeDumper.add_multi_representer(float, ESPHomeDumper.represent_float) ESPHomeDumper.add_multi_representer(_BaseAddress, ESPHomeDumper.represent_stringify) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2c34cbfb07..f45efeb20a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -145,6 +145,46 @@ def test_sensitive__custom_inner_delegates_validation() -> None: validator(123) +def test_sensitive__wraps_string_result_in_sensitive_str() -> None: + from esphome.yaml_util import SensitiveStr + + validator = config_validation.sensitive() + result = validator("hunter2") + + assert isinstance(result, SensitiveStr) + assert isinstance(result, str) + assert result == "hunter2" + + +def test_sensitive__does_not_double_tag_already_sensitive() -> None: + from esphome.yaml_util import SensitiveStr + + # If the inner validator already returns a SensitiveStr (e.g., nested + # cv.sensitive wrappers), re-tagging is a no-op rather than a new + # SensitiveStr around the same value. + pre_tagged = SensitiveStr("hunter2") + + def inner(_value): + return pre_tagged + + validator = config_validation.sensitive(inner) + result = validator("anything") + + assert result is pre_tagged + + +def test_sensitive__non_string_result_passes_through() -> None: + # If an inner validator returns something other than a string (e.g., a + # Lambda template), the sensitive wrapper must not coerce it. + sentinel = object() + + def inner(_value): + return sentinel + + validator = config_validation.sensitive(inner) + assert validator("anything") is sentinel + + def test_sensitive__is_detectable_via_isinstance() -> None: validator = config_validation.sensitive() diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index d6fb5b81f2..6be090b869 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -15,6 +15,7 @@ from esphome.yaml_util import ( DiscoveredYamlFiles, ESPHomeDataBase, ESPLiteralValue, + SensitiveStr, discover_user_yaml_files, force_load_include_files, format_path, @@ -1340,3 +1341,57 @@ def test_frontmatter_included_file_stored(tmp_path: Path) -> None: assert main.resolve() not in core.CORE.frontmatter # Included file's frontmatter is captured assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello" + + +def test_sensitive_str__is_a_str_subclass() -> None: + value = SensitiveStr("hunter2") + assert isinstance(value, str) + assert value == "hunter2" + + +def test_dump__redacts_sensitive_str_by_default() -> None: + out = yaml_util.dump({"password": SensitiveStr("hunter2")}) + assert "\\033[8mhunter2\\033[28m" in out + assert "hunter2" not in out.replace( + "\\033[8mhunter2\\033[28m", "" + ) # the raw value is only present inside the wrap + + +def test_dump__show_secrets_emits_sensitive_str_raw() -> None: + out = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) + assert "hunter2" in out + assert "\\033[8m" not in out + assert "\\033[28m" not in out + + +def test_dump__plain_str_is_not_redacted() -> None: + out = yaml_util.dump({"hostname": "myserver"}) + assert "myserver" in out + assert "\\033[8m" not in out + + +def test_dump__secret_reference_wins_over_redaction() -> None: + # If the value also has an entry in _SECRET_VALUES (i.e., it was loaded + # via !secret), the dump should render it as !secret , not as a + # redacted scalar. SensitiveStr layered on top must not change that. + value = SensitiveStr("hunter2") + yaml_util._SECRET_VALUES[str(value)] = "my_secret_name" + try: + out = yaml_util.dump({"password": value}) + assert "!secret" in out + assert "my_secret_name" in out + assert "\\033[8m" not in out + finally: + yaml_util._SECRET_VALUES.clear() + + +def test_dump__redaction_flag_does_not_leak_between_calls() -> None: + # Per-call _Dumper subclass means show_secrets in one call doesn't + # affect another. Run them in both orders to catch any leakage. + redacted = yaml_util.dump({"password": SensitiveStr("hunter2")}) + raw = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) + redacted_again = yaml_util.dump({"password": SensitiveStr("hunter2")}) + + assert "\\033[8m" in redacted + assert "\\033[8m" not in raw + assert "\\033[8m" in redacted_again From 23df0229a27848796d93401319e9d8c7f8cd7ca0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 21:52:22 -0500 Subject: [PATCH 1463/2214] [core] Type represent_sensitive and hoist SensitiveStr import in test --- esphome/yaml_util.py | 2 +- tests/unit_tests/test_config_validation.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 19cb775a50..5355d03e9c 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1013,7 +1013,7 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) - def represent_sensitive(self, value): + def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: # ``!secret`` references win: keep the original representation so the # dumped YAML round-trips back to ``!secret name`` instead of leaking # the resolved value. diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index f45efeb20a..74d9a5047a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -27,6 +27,7 @@ from esphome.const import ( SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda +from esphome.yaml_util import SensitiveStr def test_check_not_templatable__invalid(): @@ -146,8 +147,6 @@ def test_sensitive__custom_inner_delegates_validation() -> None: def test_sensitive__wraps_string_result_in_sensitive_str() -> None: - from esphome.yaml_util import SensitiveStr - validator = config_validation.sensitive() result = validator("hunter2") @@ -157,8 +156,6 @@ def test_sensitive__wraps_string_result_in_sensitive_str() -> None: def test_sensitive__does_not_double_tag_already_sensitive() -> None: - from esphome.yaml_util import SensitiveStr - # If the inner validator already returns a SensitiveStr (e.g., nested # cv.sensitive wrappers), re-tagging is a no-op rather than a new # SensitiveStr around the same value. From 2e53a226a3dbd0aeb44baf8b2431aecdb8593dac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 21:55:35 -0500 Subject: [PATCH 1464/2214] [core] Keep legacy redaction regex as deprecation fallback Restore the substring regex as a second pass in command_config so sensitive-shaped fields that haven't been tagged with cv.sensitive(...) yet are still redacted. Each unique unmarked field name caught by the heuristic emits a one-time deprecation warning naming the field and the fix; the fallback itself is slated for removal in 2026.12.0. --- esphome/__main__.py | 35 ++++++++++++++++++++++++++ tests/unit_tests/test_main.py | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7eef646ebb..d62fa1bafd 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1412,12 +1412,47 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) + if not args.show_secrets: + output = _redact_with_legacy_fallback(output) if not CORE.quiet: safe_print(output) _LOGGER.info("Configuration is valid!") return 0 +# Legacy substring redaction fallback. Catches sensitive-looking fields that +# aren't yet tagged with ``cv.sensitive(...)`` so config dumps don't regress +# for unmigrated/third-party schemas. Each match also logs a one-time +# deprecation warning naming the field; this fallback is slated for removal +# in 2026.12.0 once canonical sensitive fields are migrated. +# +# The negative lookahead ``(?!\\033\[8m)`` skips values already wrapped by the +# SensitiveStr representer, so explicit tagging silences the warning. +_LEGACY_REDACTION_RE = re.compile( + r"(?P\w*(?:password|key|psk|ssid)\w*)\: (?!\\033\[8m)(?P.+)" +) +_LEGACY_REDACTION_REMOVAL = "2026.12.0" + + +def _redact_with_legacy_fallback(output: str) -> str: + unmarked: set[str] = set() + + def _replace(m: re.Match[str]) -> str: + unmarked.add(m.group("key")) + return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" + + output = _LEGACY_REDACTION_RE.sub(_replace, output) + for key in sorted(unmarked): + _LOGGER.warning( + "Field '%s' is being redacted by a legacy substring heuristic. " + "Mark this field's schema validator with cv.sensitive(...) for " + "deterministic redaction; the heuristic will be removed in %s.", + key, + _LEGACY_REDACTION_REMOVAL, + ) + return output + + def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None: # generating code might modify config, so it must be done in order to generate # a hash that will match what was generated when compiling and then running diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index f6b6d0b05f..a103f14da5 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -22,6 +22,7 @@ from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, + _redact_with_legacy_fallback, _resolve_network_devices, _validate_bootloader_binary, _validate_partition_table_binary, @@ -340,6 +341,52 @@ def mock_ram_strings_analyzer() -> Generator[Mock]: yield mock_class +def test_redact_with_legacy_fallback__wraps_unmarked_field( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unmarked sensitive-shaped fields are redacted; a deprecation warning + is emitted naming the field.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("password: hunter2\n") + assert "password: \\033[8mhunter2\\033[28m" in out + assert any( + "password" in rec.message and "cv.sensitive" in rec.message + for rec in caplog.records + ) + + +def test_redact_with_legacy_fallback__skips_already_wrapped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Values already wrapped by the SensitiveStr representer don't trigger + the heuristic or the warning.""" + wrapped = "password: \\033[8mhunter2\\033[28m\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(wrapped) + assert out == wrapped + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__captures_full_field_name( + caplog: pytest.LogCaptureFixture, +) -> None: + """The warning names the actual field, not just the matched fragment.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + _redact_with_legacy_fallback("encryption_key: abc\n") + assert any("encryption_key" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__deduplicates_warnings( + caplog: pytest.LogCaptureFixture, +) -> None: + """One warning per unique field name even if it appears many times.""" + text = "password: a\npassword: b\npassword: c\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + _redact_with_legacy_fallback(text) + password_warnings = [rec for rec in caplog.records if "'password'" in rec.message] + assert len(password_warnings) == 1 + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() From 27385cee325a9e758c9b8eed22ab3cf70a8b4d64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 22:06:25 -0500 Subject: [PATCH 1465/2214] [core] Tighten legacy redaction regex to avoid mid-word matches The trailing \w* after the fragment over-matched fields like key_value_pair: which the prior regex did not catch. Drop it so the fragment must end the captured field name, preserving the previous matching scope while still capturing the full field name (via the leading \w*) for the warning message. --- esphome/__main__.py | 7 +++++-- tests/unit_tests/test_main.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d62fa1bafd..7049f8025e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1427,9 +1427,12 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: # in 2026.12.0 once canonical sensitive fields are migrated. # # The negative lookahead ``(?!\\033\[8m)`` skips values already wrapped by the -# SensitiveStr representer, so explicit tagging silences the warning. +# SensitiveStr representer, so explicit tagging silences the warning. No +# trailing ``\w*`` after the fragment so the fragment must end the field +# name (preserves the prior regex's matching scope while letting the +# leading ``\w*`` capture the full field name for the warning). _LEGACY_REDACTION_RE = re.compile( - r"(?P\w*(?:password|key|psk|ssid)\w*)\: (?!\\033\[8m)(?P.+)" + r"(?P\w*(?:password|key|psk|ssid))\: (?!\\033\[8m)(?P.+)" ) _LEGACY_REDACTION_REMOVAL = "2026.12.0" diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a103f14da5..ef8d922a8e 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -387,6 +387,18 @@ def test_redact_with_legacy_fallback__deduplicates_warnings( assert len(password_warnings) == 1 +def test_redact_with_legacy_fallback__does_not_match_fragment_in_middle( + caplog: pytest.LogCaptureFixture, +) -> None: + """Fragment must end the field name; embedded matches like + ``key_value_pair`` are unrelated to a sensitive key and must not be + redacted (matching the prior regex's scope).""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("key_value_pair: abc\n") + assert "\\033[8m" not in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() From e358b8328fc7237e26903ca12897f719ff9854f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 22:13:49 -0500 Subject: [PATCH 1466/2214] [core] Cover command_config redaction wiring Add two tests that exercise command_config end-to-end: one confirms the legacy fallback wraps an unmarked sensitive field, the other confirms --show-secrets bypasses redaction. Closes the patch-coverage gap on the 'output = _redact_with_legacy_fallback(output)' line. --- tests/unit_tests/test_main.py | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index ef8d922a8e..1873988f83 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -30,6 +30,7 @@ from esphome.__main__ import ( command_analyze_memory, command_bundle, command_clean_all, + command_config, command_config_hash, command_rename, command_run, @@ -399,6 +400,41 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_in_middle( assert not any("legacy substring" in rec.message for rec in caplog.records) +def test_command_config__invokes_legacy_fallback_when_redacting( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """``command_config`` runs the legacy fallback on the dumped output when + ``--show-secrets`` is off. Cover the wiring (not just the helper). + """ + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = False + + result = command_config(args, {"wifi": {"password": "hunter2"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "\\033[8mhunter2\\033[28m" in output + + +def test_command_config__show_secrets_skips_redaction( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """With ``--show-secrets`` the helper isn't invoked and the value + renders raw. + """ + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + + result = command_config(args, {"wifi": {"password": "hunter2"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "hunter2" in output + assert "\\033[8m" not in output + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() From 6d689e8297c3528acbca1f6908776ec601c84675 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 22:18:09 -0500 Subject: [PATCH 1467/2214] [core] Suppress deprecation warning for lambda values in tagged fields Lambda values in cv.sensitive(cv.templatable(...)) fields still hit the legacy regex because Lambda isn't a str and therefore doesn't carry the SensitiveStr tag. The field IS tagged correctly; warning the author to add cv.sensitive would be misleading. Still wrap the first line so the user-visible output matches the prior regex. --- esphome/__main__.py | 11 +++++++++-- tests/unit_tests/test_main.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7049f8025e..f4e9e89e57 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1441,8 +1441,15 @@ def _redact_with_legacy_fallback(output: str) -> str: unmarked: set[str] = set() def _replace(m: re.Match[str]) -> str: - unmarked.add(m.group("key")) - return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" + val = m.group("val") + # Lambda values in a ``cv.sensitive(cv.templatable(...))`` field still + # hit this branch because ``Lambda`` isn't a ``str`` and so doesn't + # carry the ``SensitiveStr`` tag. The field itself IS tagged; warning + # the author to add ``cv.sensitive`` would be misleading. Still wrap + # the first line so the user-visible output matches the prior regex. + if not val.startswith("!lambda"): + unmarked.add(m.group("key")) + return f"{m.group('key')}: \\033[8m{val}\\033[28m" output = _LEGACY_REDACTION_RE.sub(_replace, output) for key in sorted(unmarked): diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 1873988f83..77f852688d 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -388,6 +388,20 @@ def test_redact_with_legacy_fallback__deduplicates_warnings( assert len(password_warnings) == 1 +def test_redact_with_legacy_fallback__suppresses_warning_for_lambda( + caplog: pytest.LogCaptureFixture, +) -> None: + """Lambda values in cv.sensitive(cv.templatable(...)) fields hit the + legacy regex (Lambda isn't str so SensitiveStr tagging doesn't apply), + but the field IS tagged. The warning would mislead the author, so it's + suppressed; the first line still gets wrapped for visual parity.""" + text = ' ssid: !lambda |-\n return "x";\n' + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ssid: \\033[8m!lambda |-\\033[28m" in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + def test_redact_with_legacy_fallback__does_not_match_fragment_in_middle( caplog: pytest.LogCaptureFixture, ) -> None: From bc2a81156886d35b7afbc00b0b2c1252cc0beb76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 22:23:40 -0500 Subject: [PATCH 1468/2214] [core] Skip !secret and !lambda in legacy fallback regex Address reviewer feedback: - legacy regex was wrapping password: !secret name and clobbering the dumper's user-friendly !secret round-trip; extend the negative lookahead to skip !secret (and !lambda) values entirely - drop the in-replacement !lambda check now that the lookahead handles it - reword the thread-safety claim in dump() since _SECRET_VALUES / _SECRET_CACHE remain module globals - reword the SensitiveStr representer registration comment to reflect PyYAML's MRO-walked dispatch rather than registration order - tighten the legacy regex comment Adds tests for the !secret and !lambda skip paths. --- esphome/__main__.py | 31 ++++++++++--------------------- esphome/yaml_util.py | 9 ++++----- tests/unit_tests/test_main.py | 22 ++++++++++++++++------ 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index f4e9e89e57..99370064ef 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1420,19 +1420,15 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: return 0 -# Legacy substring redaction fallback. Catches sensitive-looking fields that -# aren't yet tagged with ``cv.sensitive(...)`` so config dumps don't regress -# for unmigrated/third-party schemas. Each match also logs a one-time -# deprecation warning naming the field; this fallback is slated for removal -# in 2026.12.0 once canonical sensitive fields are migrated. -# -# The negative lookahead ``(?!\\033\[8m)`` skips values already wrapped by the -# SensitiveStr representer, so explicit tagging silences the warning. No -# trailing ``\w*`` after the fragment so the fragment must end the field -# name (preserves the prior regex's matching scope while letting the -# leading ``\w*`` capture the full field name for the warning). +# Legacy substring redaction fallback for unmigrated schemas; removed in +# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips +# values that already render themselves: ``\033[8m`` (SensitiveStr wrap), +# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line +# block; first line is structural). Un-anchored on purpose to match the +# prior regex; leading ``\w*`` captures the full field name for warnings. _LEGACY_REDACTION_RE = re.compile( - r"(?P\w*(?:password|key|psk|ssid))\: (?!\\033\[8m)(?P.+)" + r"(?P\w*(?:password|key|psk|ssid))\: " + r"(?!\\033\[8m|!secret\b|!lambda\b)(?P.+)" ) _LEGACY_REDACTION_REMOVAL = "2026.12.0" @@ -1441,15 +1437,8 @@ def _redact_with_legacy_fallback(output: str) -> str: unmarked: set[str] = set() def _replace(m: re.Match[str]) -> str: - val = m.group("val") - # Lambda values in a ``cv.sensitive(cv.templatable(...))`` field still - # hit this branch because ``Lambda`` isn't a ``str`` and so doesn't - # carry the ``SensitiveStr`` tag. The field itself IS tagged; warning - # the author to add ``cv.sensitive`` would be misleading. Still wrap - # the first line so the user-visible output matches the prior regex. - if not val.startswith("!lambda"): - unmarked.add(m.group("key")) - return f"{m.group('key')}: \\033[8m{val}\\033[28m" + unmarked.add(m.group("key")) + return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" output = _LEGACY_REDACTION_RE.sub(_replace, output) for key in sorted(unmarked): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 5355d03e9c..2f3d25b4fc 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -819,9 +819,9 @@ def dump(dict_, show_secrets=False, sort_keys=False): _SECRET_VALUES.clear() _SECRET_CACHE.clear() - # Per-call subclass so the redaction flag lives on the dumper class itself, - # not in module-level mutable state. Cheap (one type object per call), - # encapsulated, and thread-safe by construction. + # Per-call subclass so the redaction flag doesn't leak across calls. + # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML + # processing is single-threaded today, so this isolates only the flag.) class _Dumper(ESPHomeDumper): _redact_sensitive = not show_secrets @@ -1101,8 +1101,7 @@ ESPHomeDumper.add_multi_representer( ) ESPHomeDumper.add_multi_representer(bool, ESPHomeDumper.represent_bool) ESPHomeDumper.add_multi_representer(str, ESPHomeDumper.represent_stringify) -# Registered after ``str`` so ``add_multi_representer``'s MRO lookup prefers -# the more specific ``SensitiveStr`` representer over the bare-``str`` one. +# MRO-walked dispatch; SensitiveStr's own entry wins over the str one. ESPHomeDumper.add_multi_representer(SensitiveStr, ESPHomeDumper.represent_sensitive) ESPHomeDumper.add_multi_representer(int, ESPHomeDumper.represent_int) ESPHomeDumper.add_multi_representer(float, ESPHomeDumper.represent_float) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 77f852688d..1c58e9b294 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -388,17 +388,27 @@ def test_redact_with_legacy_fallback__deduplicates_warnings( assert len(password_warnings) == 1 -def test_redact_with_legacy_fallback__suppresses_warning_for_lambda( +def test_redact_with_legacy_fallback__skips_lambda_values( caplog: pytest.LogCaptureFixture, ) -> None: - """Lambda values in cv.sensitive(cv.templatable(...)) fields hit the - legacy regex (Lambda isn't str so SensitiveStr tagging doesn't apply), - but the field IS tagged. The warning would mislead the author, so it's - suppressed; the first line still gets wrapped for visual parity.""" + """``!lambda`` first line is structural, body is unreachable by a + single-line regex anyway, and tagged fields shouldn't trigger a warning.""" text = ' ssid: !lambda |-\n return "x";\n' with caplog.at_level(logging.WARNING, logger="esphome.__main__"): out = _redact_with_legacy_fallback(text) - assert "ssid: \\033[8m!lambda |-\\033[28m" in out + assert out == text + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__skips_secret_references( + caplog: pytest.LogCaptureFixture, +) -> None: + """``!secret name`` is the dumper's user-friendly representation; the + name isn't the secret, so wrapping it would clobber the round-trip.""" + text = " password: !secret wifi_password\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert out == text assert not any("legacy substring" in rec.message for rec in caplog.records) From 14a5ee87fe53e8b9aeaa2589d7e566d9043f36b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 22:34:59 -0500 Subject: [PATCH 1469/2214] [core] Delegate non-redact path in represent_sensitive Hand off to represent_stringify for the !secret + plain-str branches instead of duplicating the is_secret check and scalar emission. --- esphome/yaml_util.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 2f3d25b4fc..bfe1fb0136 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1014,21 +1014,18 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: - # ``!secret`` references win: keep the original representation so the - # dumped YAML round-trips back to ``!secret name`` instead of leaking - # the resolved value. - if is_secret(value): - return self.represent_secret(value) - if self._redact_sensitive: - # Emit the conceal sequence as literal ``\033`` text (not actual - # ESC bytes) so the dump matches the previous regex-based output - # and downstream consumers like device-builder, which match - # ``\033[8m...\033[28m`` against the rendered text, keep working. + # Only the redact-and-not-a-secret branch is unique to sensitive + # values; otherwise let ``represent_stringify`` handle ``!secret`` + # precedence and the plain-str fallthrough. Conceal sequence is + # emitted as literal ``\033`` text (not actual ESC bytes) so the + # output matches the prior regex format and device-builder's + # ``\033[8m...\033[28m`` parser keeps working. + if self._redact_sensitive and not is_secret(value): return self.represent_scalar( tag="tag:yaml.org,2002:str", value=f"\\033[8m{value}\\033[28m", ) - return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + return self.represent_stringify(value) # pylint: disable=arguments-renamed def represent_bool(self, value): From 4c6437b825508d8ffb8040717d87da550d2aa246 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 22:46:41 -0500 Subject: [PATCH 1470/2214] [core] Anchor legacy redaction regex to avoid false-positive warnings The unanchored leading \w* could match fields like 'monkey:' (via the 'key' fragment), naming a non-sensitive field in the deprecation warning. Restrict the fragment to either start the name or follow '_', so warnings only fire when there's an actual sensitive-shaped field the author can migrate. --- esphome/__main__.py | 7 ++++--- tests/unit_tests/test_main.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 99370064ef..9bd19e517a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1424,10 +1424,11 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: # 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips # values that already render themselves: ``\033[8m`` (SensitiveStr wrap), # ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line -# block; first line is structural). Un-anchored on purpose to match the -# prior regex; leading ``\w*`` captures the full field name for warnings. +# block; first line is structural). The fragment must either start the +# field name or follow ``_`` so the warning names a real field; this avoids +# false positives like ``monkey:`` matching the ``key`` fragment. _LEGACY_REDACTION_RE = re.compile( - r"(?P\w*(?:password|key|psk|ssid))\: " + r"(?P\b(?:\w+_)?(?:password|key|psk|ssid))\: " r"(?!\\033\[8m|!secret\b|!lambda\b)(?P.+)" ) _LEGACY_REDACTION_REMOVAL = "2026.12.0" diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 1c58e9b294..26b550669f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -424,6 +424,18 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_in_middle( assert not any("legacy substring" in rec.message for rec in caplog.records) +def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( + caplog: pytest.LogCaptureFixture, +) -> None: + """Fragment must start the name or follow ``_``; ``monkey:`` shouldn't + fire a 'legacy heuristic' warning because there's no sensitive field + here — the user has nothing to migrate.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("monkey: 1234\n") + assert "\\033[8m" not in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + def test_command_config__invokes_legacy_fallback_when_redacting( tmp_path: Path, capfd: CaptureFixture[str] ) -> None: From 28f30c990e4b7e15a423aaf6bc88708a6a787ab0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 23:56:41 -0500 Subject: [PATCH 1471/2214] revert --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 2 ++ esphome/components/api/api_pb2.cpp | 4 ++++ esphome/components/api/api_pb2.h | 3 ++- esphome/components/api/api_pb2_dump.cpp | 1 + 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 35dcd9dbe7..f4f15c1042 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -911,7 +911,7 @@ message GetTimeResponse { option (no_delay) = true; fixed32 epoch_seconds = 1; - string timezone = 2 [deprecated = true]; // Use parsed_timezone instead. Remove before 2026.9.0. + string timezone = 2; ParsedTimezone parsed_timezone = 3; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 798deb5197..c880e036cb 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1192,6 +1192,8 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { tz.dst_end.week = static_cast(pt.dst_end.week); tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week); time::set_global_tz(tz); + } else { + homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size()); } } #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index e6c4bdba00..c711ef167c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1147,6 +1147,10 @@ bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value } bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { + case 2: { + this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); + break; + } case 3: value.decode_to_message(this->parsed_timezone); break; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 8d002b6dd9..7e926ee0d4 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1202,11 +1202,12 @@ class ParsedTimezone final : public ProtoDecodableMessage { class GetTimeResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 37; - static constexpr uint8_t ESTIMATED_SIZE = 22; + static constexpr uint8_t ESTIMATED_SIZE = 31; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("get_time_response"); } #endif uint32_t epoch_seconds{0}; + StringRef timezone{}; ParsedTimezone parsed_timezone{}; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 2bd47a5ed5..850ad37bc9 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1403,6 +1403,7 @@ const char *ParsedTimezone::dump_to(DumpBuffer &out) const { const char *GetTimeResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("GetTimeResponse")); dump_field(out, ESPHOME_PSTR("epoch_seconds"), this->epoch_seconds); + dump_field(out, ESPHOME_PSTR("timezone"), this->timezone); out.append(2, ' ').append_p(ESPHOME_PSTR("parsed_timezone")).append(": "); this->parsed_timezone.dump_to(out); out.append("\n"); From 7463a15c7e4b5c897a8e210fdd43891e5465c8cf Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Wed, 27 May 2026 08:43:38 +0100 Subject: [PATCH 1472/2214] [network] Add Zephyr IPv6 networking support for nRF52 (#16336) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: tomaszduda23 --- esphome/components/e131/e131.h | 2 + esphome/components/mdns/__init__.py | 1 + esphome/components/mdns/mdns_zephyr.cpp | 17 ++++++++ esphome/components/network/__init__.py | 16 +++++++ esphome/components/network/ip_address.h | 42 +++++++++++++++++-- .../prometheus/prometheus_handler.h | 4 +- esphome/components/statsd/statsd.cpp | 2 +- esphome/components/statsd/statsd.h | 4 +- esphome/components/sx1509/sx1509.h | 10 ++--- esphome/components/tca9555/tca9555.h | 7 ++-- .../components/wake_on_lan/wake_on_lan.cpp | 2 +- esphome/components/wake_on_lan/wake_on_lan.h | 4 +- .../web_server_base/web_server_base.cpp | 2 +- .../web_server_base/web_server_base.h | 4 +- esphome/core/defines.h | 2 +- .../network/test.nrf52-adafruit.yaml | 1 + .../components/network/test.nrf52-mcumgr.yaml | 1 + .../network/test.nrf52-xiao-ble.yaml | 1 + 18 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 esphome/components/mdns/mdns_zephyr.cpp create mode 100644 tests/components/network/test.nrf52-adafruit.yaml create mode 100644 tests/components/network/test.nrf52-mcumgr.yaml create mode 100644 tests/components/network/test.nrf52-xiao-ble.yaml diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index bfcb0ca7f8..6574037efb 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -52,6 +52,8 @@ class E131Component : public esphome::Component { if (!this->udp_.parsePacket()) return -1; return this->udp_.read(buf, len); +#else + return -1; #endif } bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet); diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 2b25cf243d..2de67542b2 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -280,5 +280,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "mdns_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/mdns/mdns_zephyr.cpp b/esphome/components/mdns/mdns_zephyr.cpp new file mode 100644 index 0000000000..0b2fd9e62b --- /dev/null +++ b/esphome/components/mdns/mdns_zephyr.cpp @@ -0,0 +1,17 @@ +#include "esphome/core/defines.h" +#if defined(USE_ZEPHYR) && defined(USE_MDNS) + +#include "mdns_component.h" +#include "esphome/core/log.h" + +namespace esphome::mdns { + +static const char *const TAG = "mdns.zephyr"; + +void MDNSComponent::setup() { ESP_LOGW(TAG, "mDNS is not implemented for Zephyr"); } + +void MDNSComponent::on_shutdown() {} + +} // namespace esphome::mdns + +#endif // USE_ZEPHYR && USE_MDNS diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 2818b8c93e..3bb14a05a7 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -4,6 +4,7 @@ import logging import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.psram import is_guaranteed as psram_is_guaranteed +from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -117,6 +118,7 @@ CONFIG_SCHEMA = cv.Schema( esp8266=False, host=False, rp2040=False, + nrf52=True, ): cv.All( cv.boolean, cv.Any( @@ -127,6 +129,7 @@ CONFIG_SCHEMA = cv.Schema( esp8266_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), rp2040_arduino=cv.Version(0, 0, 0), + nrf52_zephyr=cv.Version(0, 0, 0), ), cv.boolean_false, ), @@ -205,6 +208,19 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) + if CORE.is_nrf52: + enable_ipv6 = config.get(CONF_ENABLE_IPV6, True) + if not enable_ipv6: + _LOGGER.warning( + "IPv6 cannot be disabled on nRF52 because the Zephyr IPAddress implementation is IPv6-only. " + "Forcing CONFIG_NET_IPV6=y." + ) + config[CONF_ENABLE_IPV6] = True + zephyr_add_prj_conf("NETWORKING", True) + zephyr_add_prj_conf("NET_IPV6", True) + zephyr_add_prj_conf("NET_TCP", True) + zephyr_add_prj_conf("NET_UDP", True) + if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) if enable_ipv6: diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index c0e7b2886c..55bb2a1c89 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -5,13 +5,13 @@ #include #include #include +#include #include "esphome/core/helpers.h" #include "esphome/core/macros.h" #if defined(USE_ESP32) || defined(USE_LIBRETINY) || USE_ARDUINO_VERSION_CODE > VERSION_CODE(3, 0, 0) #include #endif - #if USE_ARDUINO #include #include @@ -24,6 +24,14 @@ using ip4_addr_t = in_addr; #define ipaddr_aton(x, y) inet_aton((x), (y)) #endif +#ifdef USE_ZEPHYR +#include +#include +#include +using ip_addr_t = struct in6_addr; +static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) { return inet_pton(AF_INET6, cp, addr) == 1 ? 1 : 0; } +#endif + #if USE_ESP32_FRAMEWORK_ARDUINO #define arduino_ns Arduino_h #elif USE_LIBRETINY @@ -33,7 +41,6 @@ using ip4_addr_t = in_addr; #endif #ifdef USE_ESP32 -#include #include #endif @@ -52,7 +59,36 @@ inline void lowercase_ip_str(char *buf) { struct IPAddress { public: -#ifdef USE_HOST +#ifdef USE_ZEPHYR + IPAddress() { memset(&ip_addr_, 0, sizeof(ip_addr_)); } + IPAddress(const std::string &in_address) : ip_addr_{} { ipaddr_aton(in_address.c_str(), &ip_addr_); } + IPAddress(const struct in6_addr *other_ip) { ip_addr_ = *other_ip; } + IPAddress(const struct sockaddr_in6 *addr) { ip_addr_ = addr->sin6_addr; } + + operator struct in6_addr() const { return ip_addr_; } + + bool is_set() const { return !net_ipv6_is_addr_unspecified(&ip_addr_); } + bool is_ip4() const { return false; } + bool is_ip6() const { return this->is_set(); } + bool is_multicast() const { return net_ipv6_is_addr_mcast(&ip_addr_); } + // Remove before 2026.8.0 + ESPDEPRECATED( + "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", + "2026.2.0") + std::string str() const { + char buf[IP_ADDRESS_BUFFER_SIZE]; + this->str_to(buf); + return buf; + } + char *str_to(char *buf) const { + if (inet_ntop(AF_INET6, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr) + buf[0] = '\0'; + return buf; + } + bool operator==(const IPAddress &other) const { return net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); } + bool operator!=(const IPAddress &other) const { return !net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); } + +#elif defined(USE_HOST) IPAddress() { ip_addr_.s_addr = 0; } IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) { this->ip_addr_.s_addr = htonl((first << 24) | (second << 16) | (third << 8) | fourth); diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 53326e9472..008081f586 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include #include @@ -219,4 +219,4 @@ class PrometheusHandler : public AsyncWebHandler, public Component { } // namespace esphome::prometheus -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 7086e462a7..2a56551255 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -2,7 +2,7 @@ #include "statsd.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) namespace esphome::statsd { diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 349bffe6fb..77f3d797c5 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -3,7 +3,7 @@ #include #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/core/component.h" #include "esphome/components/socket/socket.h" #include "esphome/components/network/ip_address.h" @@ -83,4 +83,4 @@ class StatsdComponent : public PollingComponent { } // namespace esphome::statsd -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index f645ede754..35883eed5b 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -51,7 +51,7 @@ class SX1509Component : public Component, this->cols_ = cols; this->has_keypad_ = true; }; - void set_keys(std::string keys) { this->keys_ = std::move(keys); }; + void set_keys(std::string keys) { this->keys_ = std::move(keys); }; // NOLINT(performance-unnecessary-value-param) void set_sleep_time(uint16_t sleep_time) { this->sleep_time_ = sleep_time; }; void set_scan_time(uint8_t scan_time) { this->scan_time_ = scan_time; }; void set_debounce_time(uint8_t debounce_time = 1) { this->debounce_time_ = debounce_time; }; @@ -62,10 +62,10 @@ class SX1509Component : public Component, void setup_led_driver(uint8_t pin); protected: - // Virtual methods from CachedGpioExpander - bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; - void digital_write_hw(uint8_t pin, bool value) override; + // Virtual methods from CachedGpioExpander — names come from base class + bool digital_read_hw(uint8_t pin) override; // NOLINT(readability-identifier-naming) + bool digital_read_cache(uint8_t pin) override; // NOLINT(readability-identifier-naming) + void digital_write_hw(uint8_t pin, bool value) override; // NOLINT(readability-identifier-naming) uint32_t clk_x_ = 2000000; uint8_t frequency_ = 0; diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 7d37edad73..19773a0e93 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -27,9 +27,10 @@ class TCA9555Component : public Component, protected: static void IRAM_ATTR gpio_intr(TCA9555Component *arg); - bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; - void digital_write_hw(uint8_t pin, bool value) override; + // Virtual methods from GpioExpander base class — names come from base + bool digital_read_hw(uint8_t pin) override; // NOLINT(readability-identifier-naming) + bool digital_read_cache(uint8_t pin) override; // NOLINT(readability-identifier-naming) + void digital_write_hw(uint8_t pin, bool value) override; // NOLINT(readability-identifier-naming) /// Mask for the pin mode - 1 means output, 0 means input uint16_t mode_mask_{0x00}; diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index fee6377965..a514a55d80 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -1,5 +1,5 @@ #include "wake_on_lan.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/core/log.h" #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index 48f8d00a66..84bc26e064 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" #include "esphome/core/component.h" #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) @@ -32,4 +32,4 @@ class WakeOnLanButton : public button::Button, public Component { } // namespace esphome::wake_on_lan -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 3e1baf34ba..ccfc04f674 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -1,5 +1,5 @@ #include "web_server_base.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) namespace esphome::web_server_base { diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 2aa3ae215c..c7162c139a 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include #include @@ -145,4 +145,4 @@ class WebServerBase { }; } // namespace esphome::web_server_base -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0229bc14fa..f536467e2f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -133,6 +133,7 @@ #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE +#define USE_NETWORK #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE #define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START @@ -202,7 +203,6 @@ #define USE_SHA256 #define USE_MQTT #define USE_MQTT_COVER_JSON -#define USE_NETWORK #define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG diff --git a/tests/components/network/test.nrf52-adafruit.yaml b/tests/components/network/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..61889b0361 --- /dev/null +++ b/tests/components/network/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +network: diff --git a/tests/components/network/test.nrf52-mcumgr.yaml b/tests/components/network/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..61889b0361 --- /dev/null +++ b/tests/components/network/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +network: diff --git a/tests/components/network/test.nrf52-xiao-ble.yaml b/tests/components/network/test.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..61889b0361 --- /dev/null +++ b/tests/components/network/test.nrf52-xiao-ble.yaml @@ -0,0 +1 @@ +network: From 3cc875c40b0b0a0f4ccd1b6110f4d4bd16a02288 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2026 03:09:57 -0500 Subject: [PATCH 1473/2214] [core] Enable ruff BLE (flake8-blind-except) lint family (#16659) --- esphome/__main__.py | 2 +- esphome/async_thread.py | 2 +- esphome/compiled_config.py | 4 ++-- esphome/components/esp32/__init__.py | 2 +- esphome/components/esp8266/__init__.py | 2 +- esphome/components/nrf52/__init__.py | 2 +- esphome/dashboard/web_server.py | 2 +- esphome/espidf/extra_script.py | 2 +- esphome/espidf/framework.py | 2 +- esphome/platformio/runner.py | 2 +- esphome/storage_json.py | 6 +++--- esphome/util.py | 4 ++-- esphome/vscode.py | 4 ++-- esphome/zeroconf.py | 2 +- pyproject.toml | 1 + script/analyze_component_buses.py | 6 +++--- script/build_helpers.py | 2 +- script/determine-jobs.py | 2 +- script/merge_component_configs.py | 2 +- script/stress_test_connect.py | 2 +- script/test_component_grouping.py | 2 +- tests/integration/test_syslog.py | 2 +- tests/integration/test_udp.py | 2 +- 23 files changed, 30 insertions(+), 29 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index dd97c6eee9..03f12c75d7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1800,7 +1800,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: ram_report = ram_analyzer.generate_report() print() print(ram_report) - except Exception as e: # pylint: disable=broad-except + except Exception as e: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.warning("RAM strings analysis failed: %s", e) return 0 diff --git a/esphome/async_thread.py b/esphome/async_thread.py index 7be3c83a9a..c5225a7a14 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -45,7 +45,7 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]): async def _runner(self) -> None: try: self.result = await self._coro_factory() - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except # Capture all exceptions so ``event`` is always set — otherwise a # crash would hang the waiter forever. self.exception = exc diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 92cbb7348a..f4fd205285 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -43,7 +43,7 @@ def save_compiled_config(config: ConfigType) -> None: try: rendered = yaml_util.dump(config, show_secrets=True) write_file(compiled_config_path(CORE.config_filename), rendered, private=True) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Skipping compiled config cache write: %s", err) @@ -62,7 +62,7 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: try: config = yaml_util.load_yaml(cache_path, clear_secrets=False) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7b94a26f54..703463bee9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2683,7 +2683,7 @@ def _decode_pc(config, addr): command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 38df282fb9..dd10a32fd6 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -472,7 +472,7 @@ def _decode_pc(config, addr): command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 4ba1ab5d4d..48b67e1ef9 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -535,7 +535,7 @@ def _addr2line(addr2line: str, elf: Path, addr: str) -> str: check=True, ) return result.stdout.strip().splitlines()[0] - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.error("Running command failed: %s", err) return "" diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 97d6639c1f..f5203efe9c 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1379,7 +1379,7 @@ class LoginHandler(BaseHandler): loop = asyncio.get_running_loop() try: req = await loop.run_in_executor(None, self._make_supervisor_auth_request) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.warning("Error during Hass.io auth request: %s", err) self.set_status(500) self.render_login_page(error="Internal server error") diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index bead63ca21..5f59254aee 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -120,7 +120,7 @@ def run_extra_script( "__name__": "__pio_extra_script__", }, ) - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught _LOGGER.warning("PIO extra-script %s raised %s; skipping", script_path, e) return ExtraScriptResult() finally: diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 331c2f84b0..b2251d00d8 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -783,7 +783,7 @@ def download_from_mirrors( f.seek(0) return url - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught _LOGGER.debug("Failed to download %s: %s", url, str(e)) last_exception = e diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index caab47dcc2..c49220a044 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -94,7 +94,7 @@ def patch_file_downloader() -> None: self._http_response.close() if hasattr(self, "_http_session"): self._http_session.close() - except Exception: + except Exception: # noqa: BLE001 pass # pylint: enable=protected-access,broad-except time.sleep(delay) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 04f5881465..3df12f3985 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -267,7 +267,7 @@ class StorageJSON: def load(path: Path) -> StorageJSON | None: try: return StorageJSON._load_impl(path) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None def apply_to_core(self) -> None: @@ -342,7 +342,7 @@ class EsphomeStorageJSON: return datetime.strptime( # noqa: DTZ007 self.last_update_check_str, "%Y-%m-%dT%H:%M:%S" ) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None @last_update_check.setter @@ -371,7 +371,7 @@ class EsphomeStorageJSON: def load(path: str) -> EsphomeStorageJSON | None: try: return EsphomeStorageJSON._load_impl(path) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None @staticmethod diff --git a/esphome/util.py b/esphome/util.py index 39ce7c0963..b597b4b42e 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -271,7 +271,7 @@ def run_external_command( raise except SystemExit as err: return err.args[0] - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.error("Running command failed: %s", err) _LOGGER.error("Please try running %s locally.", full_cmd) return 1 @@ -318,7 +318,7 @@ def run_external_process(*cmd: str, **kwargs: Any) -> int | str: return proc.stdout if capture_stdout else proc.returncode except KeyboardInterrupt: # pylint: disable=try-except-raise raise - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.error("Running command failed: %s", err) _LOGGER.error("Please try running %s locally.", full_cmd) return 1 diff --git a/esphome/vscode.py b/esphome/vscode.py index 53bb339a8e..f404f02f00 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -134,13 +134,13 @@ def read_config(args): try: config = loader(file_name) res = validate_config(config, command_line_substitutions) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except vs.add_yaml_error(str(err)) else: for err in res.errors: try: range_ = _get_invalid_range(res, err) vs.add_validation_error(range_, _format_vol_invalid(err, res)) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except continue print(vs.dump()) diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index a4f4f46097..e4b9abb976 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -342,7 +342,7 @@ async def async_discover_mdns_devices( ) try: aiozc = AsyncEsphomeZeroconf() - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except # Zeroconf init can raise OSError, NonUniqueNameException, etc. # Any failure here just means we can't discover — log and move on. _LOGGER.warning("mDNS discovery failed to initialize: %s", err) diff --git a/pyproject.toml b/pyproject.toml index d2f30ea3d7..a292377835 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ "B", # flake8-bugbear + "BLE", # flake8-blind-except "C4", # flake8-comprehensions "DTZ", # flake8-datetimez "E", # pycodestyle diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 1d86d5c71c..fc66605694 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -128,7 +128,7 @@ def uses_local_file_references(component_dir: Path) -> bool: try: content = common_yaml.read_text() - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught return False # Pattern to match $component_dir or ${component_dir} references @@ -164,7 +164,7 @@ def is_platform_component(component_dir: Path) -> bool: try: content = comp_init.read_text() return "IS_PLATFORM_COMPONENT = True" in content - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught return False @@ -222,7 +222,7 @@ def analyze_yaml_file(yaml_file: Path) -> dict[str, Any]: try: data = yaml_util.load_yaml(yaml_file) result["loaded"] = True - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught return result # Check for Extend/Remove objects diff --git a/script/build_helpers.py b/script/build_helpers.py index 52f7ee317e..eaf3a1f1a7 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -392,7 +392,7 @@ def compile_and_get_binary( if exit_code != 0: print(f"Error compiling {label} for {', '.join(components)}") return exit_code, None - except Exception as e: + except Exception as e: # noqa: BLE001 print(f"Error compiling {label} for {', '.join(components)}: {e}") return EXIT_COMPILE_ERROR, None diff --git a/script/determine-jobs.py b/script/determine-jobs.py index d91936952e..cf098f92c9 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -312,7 +312,7 @@ def _is_clang_tidy_full_scan() -> bool: ) # Exit 0 means hash changed (full scan needed) return result.returncode == 0 - except Exception: + except Exception: # noqa: BLE001 # If hash check fails, run full scan to be safe return True diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index df7ad4a28c..a952ecff16 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -437,7 +437,7 @@ def main() -> None: tests_dir=args.tests_dir, output_file=args.output, ) - except Exception as e: + except Exception as e: # noqa: BLE001 print(f"Error merging configs: {e}", file=sys.stderr) import traceback diff --git a/script/stress_test_connect.py b/script/stress_test_connect.py index f91a7e8f99..e34cffb8e2 100644 --- a/script/stress_test_connect.py +++ b/script/stress_test_connect.py @@ -21,7 +21,7 @@ async def connect_disconnect(client_id: int, iteration: int) -> tuple[int, bool, await asyncio.wait_for(cli.connect(login=True), timeout=10) await cli.disconnect() return iteration, True, "" - except Exception as e: + except Exception as e: # noqa: BLE001 return ( iteration, False, diff --git a/script/test_component_grouping.py b/script/test_component_grouping.py index a2cee6e888..1e7dfc1792 100755 --- a/script/test_component_grouping.py +++ b/script/test_component_grouping.py @@ -63,7 +63,7 @@ def test_component_group( try: result = subprocess.run(cmd, check=False) return result.returncode == 0 - except Exception as e: + except Exception as e: # noqa: BLE001 print(f"Error running test: {e}") return False diff --git a/tests/integration/test_syslog.py b/tests/integration/test_syslog.py index b31a19392c..0567164805 100644 --- a/tests/integration/test_syslog.py +++ b/tests/integration/test_syslog.py @@ -110,7 +110,7 @@ async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]: receiver.on_message(msg) except BlockingIOError: await asyncio.sleep(0.01) - except Exception: + except Exception: # noqa: BLE001 break task = asyncio.create_task(receive_messages()) diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py index 2187d13814..4ee3bba444 100644 --- a/tests/integration/test_udp.py +++ b/tests/integration/test_udp.py @@ -80,7 +80,7 @@ async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]] receiver.on_message(data) except BlockingIOError: await asyncio.sleep(0.01) - except Exception: + except Exception: # noqa: BLE001 break task = asyncio.create_task(receive_messages()) From 21e548f1d78a3ed225694bb9ef3d8df7feab71cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2026 09:20:50 -0500 Subject: [PATCH 1474/2214] [core] Sensitive redaction via yaml_util representer (#16690) --- esphome/__main__.py | 38 +++++- esphome/components/wifi/__init__.py | 6 +- esphome/config_validation.py | 10 +- esphome/yaml_util.py | 39 +++++- tests/unit_tests/test_config_validation.py | 37 ++++++ tests/unit_tests/test_main.py | 131 +++++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 55 +++++++++ 7 files changed, 306 insertions(+), 10 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 03f12c75d7..000087063f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1412,17 +1412,47 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) - # add the console decoration so the front-end can hide the secrets if not args.show_secrets: - output = re.sub( - r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output - ) + output = _redact_with_legacy_fallback(output) if not CORE.quiet: safe_print(output) _LOGGER.info("Configuration is valid!") return 0 +# Legacy substring redaction fallback for unmigrated schemas; removed in +# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips +# values that already render themselves: ``\033[8m`` (SensitiveStr wrap), +# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line +# block; first line is structural). The fragment must either start the +# field name or follow ``_`` so the warning names a real field; this avoids +# false positives like ``monkey:`` matching the ``key`` fragment. +_LEGACY_REDACTION_RE = re.compile( + r"(?P\b(?:\w+_)?(?:password|key|psk|ssid))\: " + r"(?!\\033\[8m|!secret\b|!lambda\b)(?P.+)" +) +_LEGACY_REDACTION_REMOVAL = "2026.12.0" + + +def _redact_with_legacy_fallback(output: str) -> str: + unmarked: set[str] = set() + + def _replace(m: re.Match[str]) -> str: + unmarked.add(m.group("key")) + return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" + + output = _LEGACY_REDACTION_RE.sub(_replace, output) + for key in sorted(unmarked): + _LOGGER.warning( + "Field '%s' is being redacted by a legacy substring heuristic. " + "Mark this field's schema validator with cv.sensitive(...) for " + "deterministic redaction; the heuristic will be removed in %s.", + key, + _LEGACY_REDACTION_REMOVAL, + ) + return output + + def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None: # generating code might modify config, so it must be done in order to generate # a hash that will match what was generated when compiling and then running diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4e7dcc82e5..b7719c80d1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -271,7 +271,7 @@ EAP_AUTH_SCHEMA = cv.All( WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), - cv.Optional(CONF_SSID): cv.ssid, + cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, @@ -434,7 +434,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_NETWORKS): cv.All( cv.ensure_list(WIFI_NETWORK_STA), cv.Length(max=MAX_WIFI_NETWORKS) ), - cv.Optional(CONF_SSID): cv.ssid, + cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, @@ -850,7 +850,7 @@ async def final_step(): WiFiConfigureAction, cv.Schema( { - cv.Required(CONF_SSID): cv.templatable(cv.ssid), + cv.Required(CONF_SSID): cv.sensitive(cv.templatable(cv.ssid)), cv.Required(CONF_PASSWORD): cv.sensitive(cv.templatable(validate_password)), cv.Optional(CONF_SAVE, default=True): cv.templatable(cv.boolean), cv.Optional(CONF_TIMEOUT, default="30000ms"): cv.templatable( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 2f09fdc105..0ef6d212fe 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -101,7 +101,7 @@ from esphome.schema_extractors import ( ) from esphome.util import parse_esphome_version from esphome.voluptuous_schema import _Schema -from esphome.yaml_util import make_data_base +from esphome.yaml_util import SensitiveStr, make_data_base _LOGGER = logging.getLogger(__name__) @@ -514,7 +514,13 @@ class SensitiveValidator: self.inner = inner def __call__(self, value: typing.Any) -> typing.Any: - return self.inner(value) + validated = self.inner(value) + # Tag string results so yaml_util.dump can mask them. Non-string + # results pass through unchanged; already-tagged values are not + # re-wrapped to keep nested cv.sensitive applications idempotent. + if isinstance(validated, str) and not isinstance(validated, SensitiveStr): + return SensitiveStr(validated) + return validated def __repr__(self) -> str: # Mirror the inner validator's repr so ``build_language_schema``'s diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 28f72ab831..bfe1fb0136 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -52,6 +52,16 @@ _load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] +class SensitiveStr(str): + """Marker subclass for validated strings that should be masked in + user-visible YAML output. ``cv.sensitive`` wraps validated values in this + type so ``dump()`` can render them with ANSI conceal codes without + needing a post-process regex. + """ + + __slots__ = () + + @contextmanager def track_yaml_loads() -> Generator[list[Path]]: """Context manager that records every file loaded by the YAML loader. @@ -808,11 +818,18 @@ def dump(dict_, show_secrets=False, sort_keys=False): if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() + + # Per-call subclass so the redaction flag doesn't leak across calls. + # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML + # processing is single-threaded today, so this isolates only the flag.) + class _Dumper(ESPHomeDumper): + _redact_sensitive = not show_secrets + return yaml.dump( dict_, default_flow_style=False, allow_unicode=True, - Dumper=ESPHomeDumper, + Dumper=_Dumper, sort_keys=sort_keys, ) @@ -958,6 +975,10 @@ def format_path(path: DocumentPath, current_obj: Any) -> str: class ESPHomeDumper(yaml.SafeDumper): + # Default for the base class; per-call subclass in ``dump()`` overrides. + # When True, ``represent_sensitive`` wraps values in ANSI conceal codes. + _redact_sensitive: bool = False + def represent_mapping(self, tag, mapping, flow_style=None): value = [] node = yaml.MappingNode(tag, value, flow_style=flow_style) @@ -992,6 +1013,20 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: + # Only the redact-and-not-a-secret branch is unique to sensitive + # values; otherwise let ``represent_stringify`` handle ``!secret`` + # precedence and the plain-str fallthrough. Conceal sequence is + # emitted as literal ``\033`` text (not actual ESC bytes) so the + # output matches the prior regex format and device-builder's + # ``\033[8m...\033[28m`` parser keeps working. + if self._redact_sensitive and not is_secret(value): + return self.represent_scalar( + tag="tag:yaml.org,2002:str", + value=f"\\033[8m{value}\\033[28m", + ) + return self.represent_stringify(value) + # pylint: disable=arguments-renamed def represent_bool(self, value): return self.represent_scalar( @@ -1063,6 +1098,8 @@ ESPHomeDumper.add_multi_representer( ) ESPHomeDumper.add_multi_representer(bool, ESPHomeDumper.represent_bool) ESPHomeDumper.add_multi_representer(str, ESPHomeDumper.represent_stringify) +# MRO-walked dispatch; SensitiveStr's own entry wins over the str one. +ESPHomeDumper.add_multi_representer(SensitiveStr, ESPHomeDumper.represent_sensitive) ESPHomeDumper.add_multi_representer(int, ESPHomeDumper.represent_int) ESPHomeDumper.add_multi_representer(float, ESPHomeDumper.represent_float) ESPHomeDumper.add_multi_representer(_BaseAddress, ESPHomeDumper.represent_stringify) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2c34cbfb07..74d9a5047a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -27,6 +27,7 @@ from esphome.const import ( SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda +from esphome.yaml_util import SensitiveStr def test_check_not_templatable__invalid(): @@ -145,6 +146,42 @@ def test_sensitive__custom_inner_delegates_validation() -> None: validator(123) +def test_sensitive__wraps_string_result_in_sensitive_str() -> None: + validator = config_validation.sensitive() + result = validator("hunter2") + + assert isinstance(result, SensitiveStr) + assert isinstance(result, str) + assert result == "hunter2" + + +def test_sensitive__does_not_double_tag_already_sensitive() -> None: + # If the inner validator already returns a SensitiveStr (e.g., nested + # cv.sensitive wrappers), re-tagging is a no-op rather than a new + # SensitiveStr around the same value. + pre_tagged = SensitiveStr("hunter2") + + def inner(_value): + return pre_tagged + + validator = config_validation.sensitive(inner) + result = validator("anything") + + assert result is pre_tagged + + +def test_sensitive__non_string_result_passes_through() -> None: + # If an inner validator returns something other than a string (e.g., a + # Lambda template), the sensitive wrapper must not coerce it. + sentinel = object() + + def inner(_value): + return sentinel + + validator = config_validation.sensitive(inner) + assert validator("anything") is sentinel + + def test_sensitive__is_detectable_via_isinstance() -> None: validator = config_validation.sensitive() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index f6b6d0b05f..26b550669f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -22,6 +22,7 @@ from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, + _redact_with_legacy_fallback, _resolve_network_devices, _validate_bootloader_binary, _validate_partition_table_binary, @@ -29,6 +30,7 @@ from esphome.__main__ import ( command_analyze_memory, command_bundle, command_clean_all, + command_config, command_config_hash, command_rename, command_run, @@ -340,6 +342,135 @@ def mock_ram_strings_analyzer() -> Generator[Mock]: yield mock_class +def test_redact_with_legacy_fallback__wraps_unmarked_field( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unmarked sensitive-shaped fields are redacted; a deprecation warning + is emitted naming the field.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("password: hunter2\n") + assert "password: \\033[8mhunter2\\033[28m" in out + assert any( + "password" in rec.message and "cv.sensitive" in rec.message + for rec in caplog.records + ) + + +def test_redact_with_legacy_fallback__skips_already_wrapped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Values already wrapped by the SensitiveStr representer don't trigger + the heuristic or the warning.""" + wrapped = "password: \\033[8mhunter2\\033[28m\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(wrapped) + assert out == wrapped + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__captures_full_field_name( + caplog: pytest.LogCaptureFixture, +) -> None: + """The warning names the actual field, not just the matched fragment.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + _redact_with_legacy_fallback("encryption_key: abc\n") + assert any("encryption_key" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__deduplicates_warnings( + caplog: pytest.LogCaptureFixture, +) -> None: + """One warning per unique field name even if it appears many times.""" + text = "password: a\npassword: b\npassword: c\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + _redact_with_legacy_fallback(text) + password_warnings = [rec for rec in caplog.records if "'password'" in rec.message] + assert len(password_warnings) == 1 + + +def test_redact_with_legacy_fallback__skips_lambda_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """``!lambda`` first line is structural, body is unreachable by a + single-line regex anyway, and tagged fields shouldn't trigger a warning.""" + text = ' ssid: !lambda |-\n return "x";\n' + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert out == text + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__skips_secret_references( + caplog: pytest.LogCaptureFixture, +) -> None: + """``!secret name`` is the dumper's user-friendly representation; the + name isn't the secret, so wrapping it would clobber the round-trip.""" + text = " password: !secret wifi_password\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert out == text + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__does_not_match_fragment_in_middle( + caplog: pytest.LogCaptureFixture, +) -> None: + """Fragment must end the field name; embedded matches like + ``key_value_pair`` are unrelated to a sensitive key and must not be + redacted (matching the prior regex's scope).""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("key_value_pair: abc\n") + assert "\\033[8m" not in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( + caplog: pytest.LogCaptureFixture, +) -> None: + """Fragment must start the name or follow ``_``; ``monkey:`` shouldn't + fire a 'legacy heuristic' warning because there's no sensitive field + here — the user has nothing to migrate.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("monkey: 1234\n") + assert "\\033[8m" not in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_command_config__invokes_legacy_fallback_when_redacting( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """``command_config`` runs the legacy fallback on the dumped output when + ``--show-secrets`` is off. Cover the wiring (not just the helper). + """ + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = False + + result = command_config(args, {"wifi": {"password": "hunter2"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "\\033[8mhunter2\\033[28m" in output + + +def test_command_config__show_secrets_skips_redaction( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """With ``--show-secrets`` the helper isn't invoked and the value + renders raw. + """ + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + + result = command_config(args, {"wifi": {"password": "hunter2"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "hunter2" in output + assert "\\033[8m" not in output + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index d6fb5b81f2..6be090b869 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -15,6 +15,7 @@ from esphome.yaml_util import ( DiscoveredYamlFiles, ESPHomeDataBase, ESPLiteralValue, + SensitiveStr, discover_user_yaml_files, force_load_include_files, format_path, @@ -1340,3 +1341,57 @@ def test_frontmatter_included_file_stored(tmp_path: Path) -> None: assert main.resolve() not in core.CORE.frontmatter # Included file's frontmatter is captured assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello" + + +def test_sensitive_str__is_a_str_subclass() -> None: + value = SensitiveStr("hunter2") + assert isinstance(value, str) + assert value == "hunter2" + + +def test_dump__redacts_sensitive_str_by_default() -> None: + out = yaml_util.dump({"password": SensitiveStr("hunter2")}) + assert "\\033[8mhunter2\\033[28m" in out + assert "hunter2" not in out.replace( + "\\033[8mhunter2\\033[28m", "" + ) # the raw value is only present inside the wrap + + +def test_dump__show_secrets_emits_sensitive_str_raw() -> None: + out = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) + assert "hunter2" in out + assert "\\033[8m" not in out + assert "\\033[28m" not in out + + +def test_dump__plain_str_is_not_redacted() -> None: + out = yaml_util.dump({"hostname": "myserver"}) + assert "myserver" in out + assert "\\033[8m" not in out + + +def test_dump__secret_reference_wins_over_redaction() -> None: + # If the value also has an entry in _SECRET_VALUES (i.e., it was loaded + # via !secret), the dump should render it as !secret , not as a + # redacted scalar. SensitiveStr layered on top must not change that. + value = SensitiveStr("hunter2") + yaml_util._SECRET_VALUES[str(value)] = "my_secret_name" + try: + out = yaml_util.dump({"password": value}) + assert "!secret" in out + assert "my_secret_name" in out + assert "\\033[8m" not in out + finally: + yaml_util._SECRET_VALUES.clear() + + +def test_dump__redaction_flag_does_not_leak_between_calls() -> None: + # Per-call _Dumper subclass means show_secrets in one call doesn't + # affect another. Run them in both orders to catch any leakage. + redacted = yaml_util.dump({"password": SensitiveStr("hunter2")}) + raw = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) + redacted_again = yaml_util.dump({"password": SensitiveStr("hunter2")}) + + assert "\\033[8m" in redacted + assert "\\033[8m" not in raw + assert "\\033[8m" in redacted_again From e64b6bc3982936b0cdedce5f9ea5052cde7467ad Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 27 May 2026 11:00:51 -0400 Subject: [PATCH 1475/2214] [esp32] Stub arduino-esp32 with INTERFACE re-export to framework (#16695) --- esphome/components/esp32/__init__.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 703463bee9..ac0d2eaba2 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2583,6 +2583,26 @@ def _write_idf_component_yml(): "override_path": str(stub_path), } + # On the PlatformIO toolchain, framework-arduinoespressif32 already + # ships arduino-esp32. Stub the managed component so anything that + # `REQUIRES arduino-esp32` (e.g. third-party FastLED) resolves to a + # CMake target that re-exports the framework's INTERFACE properties + # (INCLUDE_DIRS, public compile options like -DESP32, transitive + # REQUIRES) instead of triggering a duplicate download/rebuild. + if CORE.using_toolchain_platformio: + arduino_stub = stubs_dir / "arduino-esp32" + arduino_stub.mkdir(exist_ok=True) + write_file_if_changed( + arduino_stub / "CMakeLists.txt", + "idf_component_register()\n" + "target_link_libraries(${COMPONENT_LIB} " + f"INTERFACE idf::{ARDUINO_FRAMEWORK_NAME})\n", + ) + dependencies[ARDUINO_ESP32_COMPONENT_NAME] = { + "version": "*", + "override_path": str(arduino_stub), + } + # Remove stubs for components that are now required by enabled libraries for component_name in required_idf_components: stub_path = stubs_dir / _idf_component_stub_name(component_name) From 911e330c0948231b8141474827f1ce22aaba1345 Mon Sep 17 00:00:00 2001 From: Elvin Luff Date: Wed, 27 May 2026 20:13:03 +0200 Subject: [PATCH 1476/2214] [core] Add Codeberg as a supported git url (#16501) --- esphome/git.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/git.py b/esphome/git.py index 744ce35ef6..c4a612753b 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -341,6 +341,7 @@ def clone_or_update( GIT_DOMAINS = { + "codeberg": "codeberg.org", "github": "github.com", "gitlab": "gitlab.com", } @@ -363,6 +364,8 @@ class GitFile: def raw_url(self) -> str: if self.ref is None: raise ValueError("URL has no ref") + if self.domain == "codeberg.org": + return f"https://codeberg.org/{self.owner}/{self.repo}/raw/commit/{self.ref}/{self.filename}" if self.domain == "github.com": return f"https://raw.githubusercontent.com/{self.owner}/{self.repo}/{self.ref}/{self.filename}" if self.domain == "gitlab.com": From e87190edb49356f9cad167c9a788853f89e2f11f Mon Sep 17 00:00:00 2001 From: SoCuul <63339559+SoCuul@users.noreply.github.com> Date: Wed, 27 May 2026 11:20:00 -0700 Subject: [PATCH 1477/2214] [midea] fix casing of custom fan modes (#16419) --- esphome/components/midea/ac_adapter.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index ec9dc10297..2f4ef5c948 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -6,9 +6,9 @@ namespace esphome::midea::ac { const char *const Constants::TAG = "midea"; -const char *const Constants::FREEZE_PROTECTION = "freeze protection"; -const char *const Constants::SILENT = "silent"; -const char *const Constants::TURBO = "turbo"; +const char *const Constants::FREEZE_PROTECTION = "Freeze Protection"; +const char *const Constants::SILENT = "Silent"; +const char *const Constants::TURBO = "Turbo"; ClimateMode Converters::to_climate_mode(MideaMode mode) { switch (mode) { From ac29fad120115230cb52298cc3f43d9a44bb2978 Mon Sep 17 00:00:00 2001 From: GuzTech Date: Wed, 27 May 2026 20:21:50 +0200 Subject: [PATCH 1478/2214] [growatt_solar] Replace hard coded register addresses with constexpr (#16581) --- .../growatt_solar/growatt_solar.cpp | 105 ++++++++++-------- .../components/growatt_solar/growatt_solar.h | 49 ++++++++ 2 files changed, 110 insertions(+), 44 deletions(-) diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index 41beb6e4e9..fc35271017 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -63,71 +63,88 @@ void GrowattSolar::on_modbus_data(const std::vector &data) { switch (this->protocol_version_) { case RTU: { - publish_1_reg_sensor_state(this->inverter_status_, 0, 1); + publish_1_reg_sensor_state(this->inverter_status_, RTU_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, RTU_PV_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU_PV1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU_PV1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, RTU_PV1_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU_PV2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU_PV2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, RTU_PV2_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->grid_active_power_sensor_, 11, 12, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->grid_frequency_sensor_, 13, TWO_DEC_UNIT); + publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, RTU_GRID_ACTIVE_POWER + 1, + ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU_GRID_FREQUENCY, TWO_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 14, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 15, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 16, 17, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU_PHASE1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU_PHASE1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER, + RTU_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 18, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 19, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 20, 21, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU_PHASE2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU_PHASE2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER, + RTU_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 22, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 23, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 24, 25, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU_PHASE3_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU_PHASE3_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER, + RTU_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->today_production_, 26, 27, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->total_energy_production_, 28, 29, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, RTU_TODAY_PRODUCTION + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION, + RTU_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->inverter_module_temp_, 32, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->inverter_module_temp_, RTU_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; } case RTU2: { - publish_1_reg_sensor_state(this->inverter_status_, 0, 1); + publish_1_reg_sensor_state(this->inverter_status_, RTU2_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, RTU2_PV_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU2_PV1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU2_PV1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, RTU2_PV1_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU2_PV2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU2_PV2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, RTU2_PV2_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->grid_active_power_sensor_, 35, 36, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->grid_frequency_sensor_, 37, TWO_DEC_UNIT); + publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, RTU2_GRID_ACTIVE_POWER + 1, + ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU2_GRID_FREQUENCY, TWO_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 38, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 39, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 40, 41, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU2_PHASE1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU2_PHASE1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER, + RTU2_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 42, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 43, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 44, 45, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU2_PHASE2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU2_PHASE2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER, + RTU2_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 46, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 47, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 48, 49, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU2_PHASE3_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU2_PHASE3_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER, + RTU2_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->today_production_, 53, 54, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->total_energy_production_, 55, 56, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, RTU2_TODAY_PRODUCTION + 1, + ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION, + RTU2_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->inverter_module_temp_, 93, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->inverter_module_temp_, RTU2_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; } } diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 7eba795601..27ae32cc46 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -16,6 +16,55 @@ enum GrowattProtocolVersion { RTU2, }; +// Register addresses for the RTU protocol. +constexpr size_t RTU_INVERTER_STATUS = 0; // length = 1 +constexpr size_t RTU_PV_ACTIVE_POWER = 1; // length = 2 +constexpr size_t RTU_PV1_VOLTAGE = 3; // length = 1 +constexpr size_t RTU_PV1_CURRENT = 4; // length = 1 +constexpr size_t RTU_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr size_t RTU_PV2_VOLTAGE = 7; // length = 1 +constexpr size_t RTU_PV2_CURRENT = 8; // length = 1 +constexpr size_t RTU_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr size_t RTU_GRID_ACTIVE_POWER = 11; // length = 2 +constexpr size_t RTU_GRID_FREQUENCY = 13; // length = 1 +constexpr size_t RTU_PHASE1_VOLTAGE = 14; // length = 1 +constexpr size_t RTU_PHASE1_CURRENT = 15; // length = 1 +constexpr size_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2 +constexpr size_t RTU_PHASE2_VOLTAGE = 18; // length = 1 +constexpr size_t RTU_PHASE2_CURRENT = 19; // length = 1 +constexpr size_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2 +constexpr size_t RTU_PHASE3_VOLTAGE = 22; // length = 1 +constexpr size_t RTU_PHASE3_CURRENT = 23; // length = 1 +constexpr size_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2 +constexpr size_t RTU_TODAY_PRODUCTION = 26; // length = 2 +constexpr size_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2 +constexpr size_t RTU_INVERTER_MODULE_TEMP = 32; // length = 1 + +// Input register addresses for the RTU2 protocol as described +// in the "GROWATT INVERTER MODBUS PROTOCOL_II V1.39" document. +constexpr size_t RTU2_INVERTER_STATUS = 0; // length = 1 +constexpr size_t RTU2_PV_ACTIVE_POWER = 1; // length = 2 +constexpr size_t RTU2_PV1_VOLTAGE = 3; // length = 1 +constexpr size_t RTU2_PV1_CURRENT = 4; // length = 1 +constexpr size_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr size_t RTU2_PV2_VOLTAGE = 7; // length = 1 +constexpr size_t RTU2_PV2_CURRENT = 8; // length = 1 +constexpr size_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr size_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2 +constexpr size_t RTU2_GRID_FREQUENCY = 37; // length = 1 +constexpr size_t RTU2_PHASE1_VOLTAGE = 38; // length = 1 +constexpr size_t RTU2_PHASE1_CURRENT = 39; // length = 1 +constexpr size_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2 +constexpr size_t RTU2_PHASE2_VOLTAGE = 42; // length = 1 +constexpr size_t RTU2_PHASE2_CURRENT = 43; // length = 1 +constexpr size_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2 +constexpr size_t RTU2_PHASE3_VOLTAGE = 46; // length = 1 +constexpr size_t RTU2_PHASE3_CURRENT = 47; // length = 1 +constexpr size_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2 +constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 +constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 +constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 + class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { public: void loop() override; From 9a6157b469225923d68baa501af4e29fe32df41d Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 27 May 2026 15:50:43 -0400 Subject: [PATCH 1479/2214] [tests] Sandbox PlatformIO paths in test_writer to fix xdist race (#16619) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- tests/unit_tests/test_writer.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index fc49f03067..d6df559571 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -44,6 +44,42 @@ from esphome.writer import ( ) +@pytest.fixture(autouse=True) +def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: + """Sandbox PlatformIO path lookups so tests never touch ~/.platformio. + + `clean_all` and `clean_build` both query ProjectConfig for paths like + `cache_dir` and `core_dir` and `rmtree` anything that exists. By + default `core_dir` resolves to ~/.platformio, which is global state + shared across pytest-xdist workers — multiple workers can each pass + `is_dir()` and then race inside `shutil.rmtree`, producing + FileNotFoundError flakes (and trashing developers' local PIO state + when the suite is run outside CI). + + 14 of the 18 `clean_*` tests in this file invoke `clean_all` / + `clean_build` without installing their own ProjectConfig mock, so + making the fixture autouse is simpler than tagging each test + individually. + + Patch ProjectConfig.get_instance to point every PIO dir at a unique + tmp directory that doesn't actually exist on disk — `is_dir()` + returns False, so the rmtree loop is skipped entirely. Tests that + want to verify the PIO-cleanup branch (e.g. test_clean_all, + test_clean_all_partial_exists) install their own inner patch which + stacks on top of this one and wins for the duration of their block. + """ + pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" + mock_cfg = MagicMock() + mock_cfg.get.side_effect = lambda section, option: ( + str(pio_root / option) if section == "platformio" else "" + ) + with patch( + "platformio.project.config.ProjectConfig.get_instance", + return_value=mock_cfg, + ): + yield + + @pytest.fixture def mock_copy_src_tree(): """Mock copy_src_tree to avoid side effects during tests.""" From ec597bfc0349b48dd03d110659b9181e7617df59 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 28 May 2026 14:54:42 +1200 Subject: [PATCH 1480/2214] [docs] Update esphome-docs references to esphome.io after repo rename (#16705) --- .claude/skills/pr-workflow/SKILL.md | 8 ++++---- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 6 +++--- .github/scripts/auto-label-pr/constants.js | 3 +++ AGENTS.md | 4 ++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md index 4ec2551804..2c529dcd0f 100644 --- a/.claude/skills/pr-workflow/SKILL.md +++ b/.claude/skills/pr-workflow/SKILL.md @@ -29,7 +29,7 @@ Required fields: - **What does this implement/fix?**: Brief description of changes - **Types of changes**: Check ONE appropriate box (Bugfix, New feature, Breaking change, etc.) - **Related issue**: Use `fixes ` syntax if applicable -- **Pull request in esphome-docs**: Link if docs are needed +- **Pull request in esphome.io**: Link if docs are needed - **Test Environment**: Check platforms you tested on - **Example config.yaml**: Include working example YAML - **Checklist**: Verify code is tested and tests added @@ -54,9 +54,9 @@ Required fields: - fixes https://github.com/esphome/esphome/issues/XXX -**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):** +**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):** -- esphome/esphome-docs#XXX +- esphome/esphome.io#XXX ## Test Environment @@ -83,7 +83,7 @@ component_name: - [x] Tests have been added to verify that the new code works (under `tests/` folder). If user exposed functionality or configuration variables are added/changed: - - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs). + - [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io). ``` ## 5. Push and Create PR diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 19f52349a6..3b39d519c4 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,7 +2,7 @@ blank_issues_enabled: false contact_links: - name: Report an issue with the ESPHome documentation - url: https://github.com/esphome/esphome-docs/issues/new/choose + url: https://github.com/esphome/esphome.io/issues/new/choose about: Report an issue with the ESPHome documentation. - name: Report an issue with the ESPHome web server url: https://github.com/esphome/esphome-webserver/issues/new/choose diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 72013e411e..08def88577 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,9 +16,9 @@ - fixes -**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):** +**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):** -- esphome/esphome-docs# +- esphome/esphome.io# ## Test Environment @@ -43,4 +43,4 @@ - [ ] Tests have been added to verify that the new code works (under `tests/` folder). If user exposed functionality or configuration variables are added/changed: - - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs). + - [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io). diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index e02b450bf0..2938fd923c 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -35,6 +35,9 @@ module.exports = { ], DOCS_PR_PATTERNS: [ + /https:\/\/github\.com\/esphome\/esphome\.io\/pull\/\d+/, + /esphome\/esphome\.io#\d+/, + // Keep matching the old esphome-docs name during the transition period /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, /esphome\/esphome-docs#\d+/ ] diff --git a/AGENTS.md b/AGENTS.md index 2139a2b796..4adc53cae9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -462,7 +462,7 @@ This document provides essential context for AI models interacting with this pro 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. * **Documentation Contributions:** - * Documentation is hosted in the separate `esphome/esphome-docs` repository. + * Documentation is hosted in the separate `esphome/esphome.io` repository. * The contribution workflow is the same as for the codebase. * When editing a component's documentation page, also update the corresponding component index page to ensure both pages remain in sync. @@ -681,7 +681,7 @@ This document provides essential context for AI models interacting with this pro - [ ] Explored non-breaking alternatives - [ ] Added deprecation warnings if possible (use `ESPDEPRECATED` macro for C++) - [ ] Documented migration path in PR description with before/after examples - - [ ] Updated all internal usage and esphome-docs + - [ ] Updated all internal usage and esphome.io - [ ] Tested backward compatibility during deprecation period * **Deprecation Pattern (C++):** From 5732d7135f395b7a3b5de5cec4c8ffd2b48b08bf Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 28 May 2026 14:39:11 +0200 Subject: [PATCH 1481/2214] [network] move ipv6 enforcement to validation step (#16701) --- esphome/components/network/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 3bb14a05a7..b662293ab5 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -108,6 +108,13 @@ def has_high_performance_networking() -> bool: return CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False) +def validate_ipv6(value: bool) -> bool: + if CORE.is_nrf52 and not value: + raise cv.Invalid("On nRF52, enable_ipv6 must be true") + + return value + + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(NetworkComponent), @@ -133,6 +140,7 @@ CONFIG_SCHEMA = cv.Schema( ), cv.boolean_false, ), + validate_ipv6, ), cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32), @@ -209,13 +217,6 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) if CORE.is_nrf52: - enable_ipv6 = config.get(CONF_ENABLE_IPV6, True) - if not enable_ipv6: - _LOGGER.warning( - "IPv6 cannot be disabled on nRF52 because the Zephyr IPAddress implementation is IPv6-only. " - "Forcing CONFIG_NET_IPV6=y." - ) - config[CONF_ENABLE_IPV6] = True zephyr_add_prj_conf("NETWORKING", True) zephyr_add_prj_conf("NET_IPV6", True) zephyr_add_prj_conf("NET_TCP", True) From f41866a9b8ba9b8711e325f367733354bf2b5d4b Mon Sep 17 00:00:00 2001 From: Mischa Siekmann <45062894+gnumpi@users.noreply.github.com> Date: Thu, 28 May 2026 15:11:48 +0200 Subject: [PATCH 1482/2214] [gpio][binary_sensor] Fix pin validation for external GPIO pins (#16528) --- esphome/components/gpio/binary_sensor/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 390b26ba1d..f14a920c24 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -74,8 +74,6 @@ def _final_validate(config): if not use_interrupt: return config - pin_num = config[CONF_PIN][CONF_NUMBER] - # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt # attachment — only internal/native GPIO pins do. if pins.PIN_SCHEMA_REGISTRY.get_key(config[CONF_PIN]) != CORE.target_platform: @@ -87,6 +85,8 @@ def _final_validate(config): config[CONF_USE_INTERRUPT] = False return config + pin_num = config[CONF_PIN][CONF_NUMBER] + # GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt(). if CORE.is_esp8266 and pin_num == 16: _LOGGER.warning( From 4b8e06b5bc454b0de2af07522a0c3a4e71625c49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 09:12:35 -0400 Subject: [PATCH 1483/2214] Bump tornado from 6.5.5 to 6.5.6 (#16704) 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 14dddbb1aa..17b618dde7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.5 +tornado==6.5.6 tzlocal==5.3.1 # from time tzdata>=2026.2 # from time pyserial==3.5 From 8945550c6c375d4097d4b0ae620ce2da30f6162c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 03:35:37 +0000 Subject: [PATCH 1484/2214] Bump ruff from 0.15.14 to 0.15.15 (#16712) Co-authored-by: J. Nick Koston Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0470a948f5..a076128975 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.15.14 + rev: v0.15.15 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index aad1da0807..203cd2bbea 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.14 # also change in .pre-commit-config.yaml when updating +ruff==0.15.15 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From a85f8ad9359c041eedbb64ccd6ea3c1ca2f1e6df Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 00:28:08 -0400 Subject: [PATCH 1485/2214] [core] Use esp_rom_crc.h public API instead of legacy rom/crc.h (#16698) --- esphome/core/helpers.cpp | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1eb3345491..112dde7c45 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -15,7 +15,7 @@ #include #ifdef USE_ESP32 -#include "rom/crc.h" +#include "esp_rom_crc.h" #endif namespace esphome { @@ -47,7 +47,7 @@ static const uint16_t CRC16_8408_LE_LUT_H[] = {0x0000, 0x1081, 0x2102, 0x3183, 0 0x8408, 0x9489, 0xa50a, 0xb58b, 0xc60c, 0xd68d, 0xe70e, 0xf78f}; #endif -#if !defined(USE_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) +#ifndef USE_ESP32 static const uint16_t CRC16_1021_BE_LUT_L[] = {0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef}; static const uint16_t CRC16_1021_BE_LUT_H[] = {0x0000, 0x1231, 0x2462, 0x3653, 0x48c4, 0x5af5, 0x6ca6, 0x7e97, @@ -86,7 +86,7 @@ uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc, uint8_t poly, bool m uint16_t crc16(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t reverse_poly, bool refin, bool refout) { #ifdef USE_ESP32 if (reverse_poly == 0x8408) { - crc = crc16_le(refin ? crc : (crc ^ 0xffff), data, len); + crc = esp_rom_crc16_le(refin ? crc : (crc ^ 0xffff), data, len); return refout ? crc : (crc ^ 0xffff); } #endif @@ -124,23 +124,24 @@ uint16_t crc16(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t reverse } uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t poly, bool refin, bool refout) { -#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32S2) +#ifdef USE_ESP32 if (poly == 0x1021) { - crc = crc16_be(refin ? crc : (crc ^ 0xffff), data, len); + crc = esp_rom_crc16_be(refin ? crc : (crc ^ 0xffff), data, len); return refout ? crc : (crc ^ 0xffff); } #endif if (refin) { crc ^= 0xffff; } -#if !defined(USE_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) +#ifndef USE_ESP32 if (poly == 0x1021) { while (len--) { uint8_t combo = (crc >> 8) ^ *data++; crc = (crc << 8) ^ CRC16_1021_BE_LUT_L[combo & 0x0F] ^ CRC16_1021_BE_LUT_H[combo >> 4]; } - } else { + } else #endif + { while (len--) { crc ^= (((uint16_t) *data++) << 8); for (uint8_t i = 0; i < 8; i++) { @@ -151,9 +152,7 @@ uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t poly, } } } -#if !defined(USE_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) } -#endif return refout ? (crc ^ 0xffff) : crc; } From 10abb0647c5c4204a1d4c3270b8f78284b50dbb5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 00:30:52 -0400 Subject: [PATCH 1486/2214] [esp32] Add ESP32-S31, ESP32-H4 and ESP32-H21 variant scaffolding (#16700) --- esphome/components/esp32/__init__.py | 32 ++++++++++++++++-- esphome/components/esp32/boards.py | 4 --- esphome/components/esp32/const.py | 9 +++++ esphome/components/esp32/gpio.py | 18 ++++++++++ esphome/components/esp32/gpio_esp32_h21.py | 34 +++++++++++++++++++ esphome/components/esp32/gpio_esp32_h4.py | 34 +++++++++++++++++++ esphome/components/esp32/gpio_esp32_s31.py | 38 ++++++++++++++++++++++ esphome/components/logger/__init__.py | 9 +++++ esphome/core/defines.h | 5 ++- tests/component_tests/esp32/test_esp32.py | 16 ++++++++- 10 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 esphome/components/esp32/gpio_esp32_h21.py create mode 100644 esphome/components/esp32/gpio_esp32_h4.py create mode 100644 esphome/components/esp32/gpio_esp32_s31.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ac0d2eaba2..4e3ffdc1e4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -78,9 +78,12 @@ from .const import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, VARIANT_FRIENDLY, VARIANTS, ) @@ -403,9 +406,12 @@ CPU_FREQUENCIES = { VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32C61: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96), + VARIANT_ESP32H4: get_cpu_frequencies(48, 64, 96), + VARIANT_ESP32H21: get_cpu_frequencies(48, 64, 96), VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400), VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240), + VARIANT_ESP32S31: get_cpu_frequencies(240, 320), } # Make sure not missed here if a new variant added. @@ -907,11 +913,16 @@ def _validate_toolchain(value) -> Toolchain: return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value)) -def _check_versions(config): +def _resolve_toolchain(value: ConfigType) -> ConfigType: # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. + # Runs before _detect_variant so downstream validators can rely on + # CORE.toolchain instead of re-resolving it from the config dict. if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + return value + +def _check_versions(config: ConfigType) -> ConfigType: if CORE.using_toolchain_esp_idf: return _check_esp_idf_versions(config) return _check_pio_versions(config) @@ -933,7 +944,21 @@ def _detect_variant(value): variant = value.get(CONF_VARIANT) if variant and board is None: # If variant is set, we can derive the board from it - # variant has already been validated against the known set + # variant has already been validated against the known set. + # PlatformIO needs a real board name to find its board file; the + # ESP-IDF toolchain only uses CONF_BOARD as the informational + # ESPHOME_BOARD string, so synthesize one from the friendly variant + # name rather than carrying a PIO board name through the IDF build. + if CORE.using_toolchain_esp_idf: + value = value.copy() + value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower() + return value + if variant not in STANDARD_BOARDS: + raise cv.Invalid( + f"No default board is known for {variant}. " + f"Please specify the `board:` option explicitly.", + path=[CONF_VARIANT], + ) value = value.copy() value[CONF_BOARD] = STANDARD_BOARDS[variant] if variant == VARIANT_ESP32P4: @@ -1606,6 +1631,7 @@ CONFIG_SCHEMA = cv.All( ), } ), + _resolve_toolchain, _detect_variant, _set_default_framework, _check_versions, diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 2c73fe7d08..6062631d98 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -9,7 +9,6 @@ from .const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, - VARIANTS, ) STANDARD_BOARDS = { @@ -25,9 +24,6 @@ STANDARD_BOARDS = { VARIANT_ESP32S3: "esp32-s3-devkitc-1", } -# Make sure not missed here if a new variant added. -assert all(v in STANDARD_BOARDS for v in VARIANTS) - ESP32_BASE_PINS = { "TX": 1, "RX": 3, diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index d0d00723fc..322054ea91 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -24,9 +24,12 @@ VARIANT_ESP32C5 = "ESP32C5" VARIANT_ESP32C6 = "ESP32C6" VARIANT_ESP32C61 = "ESP32C61" VARIANT_ESP32H2 = "ESP32H2" +VARIANT_ESP32H4 = "ESP32H4" +VARIANT_ESP32H21 = "ESP32H21" VARIANT_ESP32P4 = "ESP32P4" VARIANT_ESP32S2 = "ESP32S2" VARIANT_ESP32S3 = "ESP32S3" +VARIANT_ESP32S31 = "ESP32S31" VARIANTS = [ VARIANT_ESP32, VARIANT_ESP32C2, @@ -35,9 +38,12 @@ VARIANTS = [ VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ] VARIANT_FRIENDLY = { @@ -48,9 +54,12 @@ VARIANT_FRIENDLY = { VARIANT_ESP32C6: "ESP32-C6", VARIANT_ESP32C61: "ESP32-C61", VARIANT_ESP32H2: "ESP32-H2", + VARIANT_ESP32H4: "ESP32-H4", + VARIANT_ESP32H21: "ESP32-H21", VARIANT_ESP32P4: "ESP32-P4", VARIANT_ESP32S2: "ESP32-S2", VARIANT_ESP32S3: "ESP32-S3", + VARIANT_ESP32S31: "ESP32-S31", } esp32_ns = cg.esphome_ns.namespace("esp32") diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 36dd44155a..2ff39cab69 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -31,9 +31,12 @@ from .const import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, esp32_ns, ) from .gpio_esp32 import esp32_validate_gpio_pin, esp32_validate_supports @@ -43,9 +46,12 @@ from .gpio_esp32_c5 import esp32_c5_validate_gpio_pin, esp32_c5_validate_support from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports from .gpio_esp32_c61 import esp32_c61_validate_gpio_pin, esp32_c61_validate_supports from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports +from .gpio_esp32_h4 import esp32_h4_validate_gpio_pin, esp32_h4_validate_supports +from .gpio_esp32_h21 import esp32_h21_validate_gpio_pin, esp32_h21_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports from .gpio_esp32_s3 import esp32_s3_validate_gpio_pin, esp32_s3_validate_supports +from .gpio_esp32_s31 import esp32_s31_validate_gpio_pin, esp32_s31_validate_supports ESP32InternalGPIOPin = esp32_ns.class_("ESP32InternalGPIOPin", cg.InternalGPIOPin) @@ -120,6 +126,14 @@ _esp32_validations = { pin_validation=esp32_h2_validate_gpio_pin, usage_validation=esp32_h2_validate_supports, ), + VARIANT_ESP32H4: ESP32ValidationFunctions( + pin_validation=esp32_h4_validate_gpio_pin, + usage_validation=esp32_h4_validate_supports, + ), + VARIANT_ESP32H21: ESP32ValidationFunctions( + pin_validation=esp32_h21_validate_gpio_pin, + usage_validation=esp32_h21_validate_supports, + ), VARIANT_ESP32P4: ESP32ValidationFunctions( pin_validation=esp32_p4_validate_gpio_pin, usage_validation=esp32_p4_validate_supports, @@ -132,6 +146,10 @@ _esp32_validations = { pin_validation=esp32_s3_validate_gpio_pin, usage_validation=esp32_s3_validate_supports, ), + VARIANT_ESP32S31: ESP32ValidationFunctions( + pin_validation=esp32_s31_validate_gpio_pin, + usage_validation=esp32_s31_validate_supports, + ), } diff --git a/esphome/components/esp32/gpio_esp32_h21.py b/esphome/components/esp32/gpio_esp32_h21.py new file mode 100644 index 0000000000..5ab1b7c074 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_h21.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# Partial set from the ESP-IDF / esptool boot-mode docs: +# https://docs.espressif.com/projects/esptool/en/latest/esp32h21/advanced-topics/boot-mode-selection.html +# The full list awaits the ESP32-H21 datasheet's "Strapping Pins" section. +_ESP32H21_STRAPPING_PINS: set[int] = {13, 14} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_h21_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 25: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-25)") + return value + + +def esp32_h21_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 25: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-25)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32H21_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/esp32/gpio_esp32_h4.py b/esphome/components/esp32/gpio_esp32_h4.py new file mode 100644 index 0000000000..86a4d55858 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_h4.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# Partial set from the ESP-IDF / esptool boot-mode docs: +# https://docs.espressif.com/projects/esptool/en/latest/esp32h4/advanced-topics/boot-mode-selection.html +# The full list awaits the ESP32-H4 datasheet's "Strapping Pins" section. +_ESP32H4_STRAPPING_PINS: set[int] = {13, 14} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_h4_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 39: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)") + return value + + +def esp32_h4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 39: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-39)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32H4_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py new file mode 100644 index 0000000000..6a19e3fee4 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -0,0 +1,38 @@ +import logging +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# Per the ESP32-S31 datasheet (page 96): +# https://documentation.espressif.com/esp32-s31_datasheet_en.pdf +_ESP32S31_SPI_FLASH_PINS: set[int] = {27, 28, 29, 31, 32, 33} +_ESP32S31_STRAPPING_PINS: set[int] = {60, 61} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_s31_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 61: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-61)") + if value in _ESP32S31_SPI_FLASH_PINS: + raise cv.Invalid( + f"GPIO{value} is reserved for the SPI flash interface on ESP32-S31 and cannot be used." + ) + return value + + +def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 61: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-61)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 5f160352cc..e4921ae196 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -11,9 +11,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, require_usb_serial_jtag_secondary, @@ -113,9 +116,12 @@ UART_SELECTION_ESP32 = { VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C61: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32H4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32H21: [UART0, UART1, USB_SERIAL_JTAG], VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32S2: [UART0, UART1, USB_CDC], VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32S31: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], } UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1] @@ -270,9 +276,12 @@ CONFIG_SCHEMA = cv.All( esp32_c6=USB_SERIAL_JTAG, esp32_c61=USB_SERIAL_JTAG, esp32_h2=USB_SERIAL_JTAG, + esp32_h4=USB_SERIAL_JTAG, + esp32_h21=USB_SERIAL_JTAG, esp32_p4=USB_SERIAL_JTAG, esp32_s2=USB_CDC, esp32_s3=USB_SERIAL_JTAG, + esp32_s31=USB_SERIAL_JTAG, rp2040=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f536467e2f..765c1aa3b2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -354,9 +354,12 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) #define USE_LOGGER_USB_CDC #define USE_LOGGER_UART_SELECTION_USB_CDC +#elif defined(USE_ESP32_VARIANT_ESP32H21) +#define USE_LOGGER_USB_SERIAL_JTAG #elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) + defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) #define USE_LOGGER_USB_CDC #define USE_LOGGER_UART_SELECTION_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index f0f96e9adc..e0fcbab0ee 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -45,11 +45,20 @@ def test_esp32_config( config = CONFIG_SCHEMA(config) assert config["variant"] == VARIANT_ESP32 - # Check that defining a variant sets the board name correctly + # Check that defining a variant sets the board name correctly. + # Run under the ESP-IDF toolchain so variants without an entry in + # STANDARD_BOARDS (S31, H4, H21) still derive a board name from + # VARIANT_FRIENDLY rather than failing with cv.Invalid. CORE.toolchain + # gets pinned by the first CONFIG_SCHEMA() call above (via + # _resolve_toolchain) and that pinned value wins over the dict's + # CONF_TOOLCHAIN, so clear it between iterations to mirror a fresh + # config run. for variant in VARIANTS: + CORE.toolchain = None config = CONFIG_SCHEMA( { "variant": variant, + "toolchain": Toolchain.ESP_IDF.value, } ) assert VARIANT_FRIENDLY[variant].lower() in config["board"] @@ -73,6 +82,11 @@ def test_esp32_config( r"Option 'variant' does not match selected board. @ data\['variant'\]", id="mismatched_board_variant_config", ), + pytest.param( + {"variant": "esp32s31"}, + r"No default board is known for ESP32S31\. Please specify the `board:` option explicitly\. @ data\['variant'\]", + id="variant_without_default_board_requires_explicit_board_under_platformio", + ), pytest.param( { "variant": "esp32s2", From dd961156d098ec3656a67021bd68e5c0a77633e6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 00:54:14 -0400 Subject: [PATCH 1487/2214] [ledc] Adapt to LEDC LL API changes in ESP-IDF 6.1 (#16697) --- esphome/components/ledc/ledc_output.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 5b7b6c7ee6..bfb629143d 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -53,7 +53,11 @@ static_assert( "re-evaluate for this target"); static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) { +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) + auto *hw = LEDC_LL_GET_HW(0); +#else auto *hw = LEDC_LL_GET_HW(); +#endif return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0; } #endif @@ -161,7 +165,9 @@ void LEDCOutput::write_state(float state) { void LEDCOutput::setup() { if (!ledc_peripheral_reset_done) { ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) + PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); } +#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) PERIPH_RCC_ATOMIC() { ledc_ll_enable_reset_reg(true); ledc_ll_enable_reset_reg(false); From 091a05ccde035ed9812aaedabf8b171f9d6aacb7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 01:16:55 -0400 Subject: [PATCH 1488/2214] [esp32_camera] Enable PicolibC Newlib compatibility on IDF 6.0+ (#16703) --- esphome/components/camera_encoder/__init__.py | 7 ++- esphome/components/esp32/__init__.py | 43 ++++++++++++++----- esphome/components/esp32_camera/__init__.py | 8 +++- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index a0c59a517a..7d4cdc881e 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -1,5 +1,8 @@ import esphome.codegen as cg -from esphome.components.esp32 import add_idf_component +from esphome.components.esp32 import ( + add_idf_component, + require_libc_picolibc_newlib_compat, +) import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE from esphome.types import ConfigType @@ -51,6 +54,8 @@ async def to_code(config: ConfigType) -> None: cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream + require_libc_picolibc_newlib_compat() cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4e3ffdc1e4..beb41b30f4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1245,6 +1245,7 @@ KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" +KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required" def require_vfs_select() -> None: @@ -1353,6 +1354,15 @@ def require_adc_oneshot_iram() -> None: CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True +def require_libc_picolibc_newlib_compat() -> None: + """Keep CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY enabled on IDF 6.0+. + + Call this from components that link against precompiled Newlib binaries + referencing types/symbols the shim provides (e.g. esp32-camera). + """ + CORE.data[KEY_ESP32][KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -1758,6 +1768,26 @@ async def _write_arduino_libraries_sdkconfig() -> None: add_idf_sdkconfig_option(f"CONFIG_ARDUINO_SELECTIVE_{lib}", lib in enabled_libs) +@coroutine_with_priority(CoroPriority.FINAL) +async def _set_libc_picolibc_newlib_compat() -> None: + """Apply the PicolibC Newlib compatibility shim option on IDF 6.0+. + + IDF 6.0 switched from Newlib to PicolibC; the shim is disabled by default. + Runs at FINAL priority so every require_libc_picolibc_newlib_compat() call + (default priority) is seen before the option is written. A user-supplied + sdkconfig_options value takes precedence. + """ + if idf_version() < cv.Version(6, 0, 0): + return + option = "CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY" + if option in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]: + return + add_idf_sdkconfig_option( + option, + CORE.data[KEY_ESP32].get(KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED, False), + ) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -2291,17 +2321,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False) add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False) - # Disable PicolibC Newlib compatibility shim on IDF 6.0+ - # IDF 6.0 switched from Newlib to PicolibC. The shim provides thread-local - # stdin/stdout/stderr and getreent() for code compiled against Newlib. - # ESPHome doesn't link against Newlib-built libraries that use stdio. - # If a component needs it (e.g. precompiled Newlib binaries), re-enable via: - # esp32: - # framework: - # sdkconfig_options: - # CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY: "y" - if idf_version() >= cv.Version(6, 0, 0): - add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", False) + # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call + CORE.add_job(_set_libc_picolibc_newlib_compat) # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 9883a0a43e..763a1f3405 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -3,7 +3,11 @@ import logging from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c -from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_component, + add_idf_sdkconfig_option, + require_libc_picolibc_newlib_compat, +) from esphome.components.psram import DOMAIN as psram_domain import esphome.config_validation as cv from esphome.const import ( @@ -402,6 +406,8 @@ async def to_code(config): add_idf_component(name="espressif/esp32-camera", ref="2.1.5") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) + # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream + require_libc_picolibc_newlib_compat() for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) From 07a57d7557bc451f4e66dc0f81818bd98112b35d Mon Sep 17 00:00:00 2001 From: Fyleo Date: Sat, 30 May 2026 05:03:42 +0200 Subject: [PATCH 1489/2214] [sx126x] fix a typo in image calibration on 863 - 870 Mhz frequency (#16731) --- esphome/components/sx126x/sx126x.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 83afeac50a..aed0105e1f 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -394,7 +394,7 @@ void SX126x::run_image_cal() { buf[1] = 0xE9; } else if (this->frequency_ > 850000000) { buf[0] = 0xD7; - buf[1] = 0xD8; + buf[1] = 0xDB; } else if (this->frequency_ > 770000000) { buf[0] = 0xC1; buf[1] = 0xC5; From f0202155b318f42b8166d5d2046d0ea036a14616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 May 2026 00:09:07 -0500 Subject: [PATCH 1490/2214] [core] Persist esphome.area in StorageJSON (#16710) --- esphome/core/config.py | 1 + esphome/storage_json.py | 7 +++++ tests/unit_tests/core/test_config.py | 27 +++++++++++++++++++ tests/unit_tests/test_storage_json.py | 38 +++++++++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index 6125c4ecc9..8214fcf80c 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -722,6 +722,7 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: + CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 3df12f3985..ba576fcfd7 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -100,6 +100,7 @@ class StorageJSON: framework: str | None = None, core_platform: str | None = None, toolchain: str | None = None, + area: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -138,6 +139,8 @@ class StorageJSON: self.core_platform = core_platform # The toolchain used for the build ("platformio" / "esp-idf") self.toolchain = toolchain + # The area of the node + self.area = area def as_dict(self): return { @@ -158,6 +161,7 @@ class StorageJSON: "framework": self.framework, "core_platform": self.core_platform, "toolchain": self.toolchain, + "area": self.area, } def to_json(self): @@ -195,6 +199,7 @@ class StorageJSON: framework=esph.target_framework, core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, + area=esph.area, ) @staticmethod @@ -243,6 +248,7 @@ class StorageJSON: framework = storage.get("framework") core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") + area = storage.get("area") return StorageJSON( storage_version, name, @@ -261,6 +267,7 @@ class StorageJSON: framework, core_platform, toolchain, + area, ) @staticmethod diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index b5b35b5172..ff150f2540 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -140,6 +140,33 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: } +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +@pytest.mark.parametrize( + ("fixture", "expected_area"), + [ + ("legacy_string_area.yaml", "Living Room"), + ("multiple_areas_devices.yaml", "Main Area"), + ], +) +async def test_to_code_records_core_area( + yaml_file: Callable[[str], Path], + fixture: str, + expected_area: str, +) -> None: + """``to_code`` records the node's area name on CORE for StorageJSON.""" + result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) + assert result is not None + assert CORE.area is None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + assert CORE.area == expected_area + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index b3f8a05605..105d78505f 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -205,6 +205,7 @@ def test_storage_json_as_dict() -> None: no_mdns=True, framework="arduino", core_platform="esp32", + area="Living Room", ) result = storage.as_dict() @@ -233,6 +234,7 @@ def test_storage_json_as_dict() -> None: assert result["no_mdns"] is True assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" + assert result["area"] == "Living Room" def test_storage_json_to_json() -> None: @@ -309,6 +311,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" mock_core.toolchain = Toolchain.ESP_IDF + mock_core.area = "Living Room" with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -329,6 +332,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.framework == "esp-idf" assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" + assert result.area == "Living Room" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -729,3 +733,37 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: assert result is not None assert result.esphome_version == "1.14.0" # Should map to esphome_version + + +def test_storage_json_load_area(tmp_path: Path) -> None: + """``area`` round-trips through load; absence loads as None.""" + file_path = tmp_path / "with_area.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + "area": "Living Room", + } + ) + ) + result = storage_json.StorageJSON.load(file_path) + assert result is not None + assert result.area == "Living Room" + + legacy_path = tmp_path / "no_area.json" + legacy_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + } + ) + ) + legacy = storage_json.StorageJSON.load(legacy_path) + assert legacy is not None + assert legacy.area is None From 95397948b9a2a55514c4de84c131f082cb825213 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 00:09:27 -0500 Subject: [PATCH 1491/2214] Bump CodSpeedHQ/action from 4.15.1 to 4.17.0 (#16730) 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 53516db913..63efff1b3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -452,7 +452,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1 + uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 with: run: | . venv/bin/activate From bf621240324db870dbce2909acdfafb0cffa949a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 30 May 2026 07:43:21 -0400 Subject: [PATCH 1492/2214] [esp32] Refine ESP-IDF framework version suffix handling (#16726) --- esphome/components/esp32/__init__.py | 35 ++++++++++++++++++++-------- esphome/espidf/framework.py | 9 +++---- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index beb41b30f4..d2dc979966 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -470,21 +470,20 @@ def set_core_data(config): framework_ver = cv.Version.parse(config[CONF_FRAMEWORK][CONF_VERSION]) CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver - # Store the underlying IDF version for framework-agnostic checks + # Store the underlying IDF version for framework-agnostic checks. if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - CORE.data[KEY_ESP32][KEY_IDF_VERSION] = framework_ver - elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: - if CORE.using_toolchain_esp_idf: - # Official ESP-IDF frameworks don't use extra - idf_ver = cv.Version(idf_ver.major, idf_ver.minor, idf_ver.patch) - CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver - else: + idf_ver = framework_ver + elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is None: raise cv.Invalid( f"Arduino version {framework_ver} has no known ESP-IDF version mapping. " "Please update ARDUINO_IDF_VERSION_LOOKUP.", path=[CONF_FRAMEWORK, CONF_VERSION], ) + # The esp-idf toolchain doesn't use pioarduino's packaging revision; PIO does. + if CORE.using_toolchain_esp_idf: + idf_ver = _strip_pioarduino_revision(idf_ver) + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD] CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = config[CONF_FLASH_SIZE] CORE.data[KEY_ESP32][KEY_VARIANT] = variant @@ -721,6 +720,9 @@ ARDUINO_FRAMEWORK_VERSION_LOOKUP = { "dev": cv.Version(3, 3, 8), } ARDUINO_PLATFORM_VERSION_LOOKUP = { + cv.Version( + 4, 0, 0, "alpha1" + ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), @@ -741,6 +743,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # These versions correspond to pioarduino/esp-idf releases # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { + cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), cv.Version(3, 3, 6): cv.Version(5, 5, 2), @@ -835,6 +838,16 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version: return version +def _strip_pioarduino_revision(ver: cv.Version) -> cv.Version: + """Drop a numeric 'extra' (pioarduino packaging revision, e.g. "5.5.3-1"). + + Alphanumeric prerelease extras (e.g. "6.0.0-rc1") are kept. + """ + if ver.extra.isdigit(): + return cv.Version(ver.major, ver.minor, ver.patch) + return ver + + def _check_pio_versions(config: ConfigType) -> ConfigType: config = config.copy() value = config[CONF_FRAMEWORK] @@ -903,8 +916,10 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType: "If there are connectivity or build issues please remove the manual source." ) - # Official ESP-IDF frameworks don't use the 'extra' semver component. - value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch)) + # esp-idf framework only: drop pioarduino's packaging revision (config + download). + # Arduino keeps its extra (it's the arduino-esp32 release tag / lookup key). + if value[CONF_TYPE] == FRAMEWORK_ESP_IDF: + value[CONF_VERSION] = str(_strip_pioarduino_revision(version)) return config diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index b2251d00d8..6ef73a2199 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -74,7 +74,7 @@ ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") or [ "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", - "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", + "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}{EXTRA}/esp-idf-v{MAJOR}.{MINOR}{EXTRA}.tar.xz", ] ) @@ -979,8 +979,9 @@ def _check_esphome_idf_framework_install( env: Optional dictionary of environment variables to set source_url: Optional override URL for the framework tarball. Supports the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / - ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When - set, it replaces the default mirror list — no implicit fallback, + ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS + (``{EXTRA}`` includes its leading ``-``, e.g. ``-rc1``, or is empty). + When set, it replaces the default mirror list — no implicit fallback, so a misspelled URL fails loudly. Returns: @@ -1035,7 +1036,7 @@ def _check_esphome_idf_framework_install( substitutions["MAJOR"] = str(ver.major) substitutions["MINOR"] = str(ver.minor) substitutions["PATCH"] = str(ver.patch) - substitutions["EXTRA"] = ver.extra + substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else "" except ValueError: pass From 7865dc33bc8bb3420dab2d2115f6cd20ac5fe70d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 10:50:17 -0400 Subject: [PATCH 1493/2214] [ethernet] Bump espressif/dm9051 to 1.1.0 (#16735) --- esphome/components/ethernet/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 3f88f8ef9a..22f5eb33e1 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -163,7 +163,7 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "KSZ8081": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), "KSZ8081RNA": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), "W5500": IDFRegistryComponent("espressif/w5500", "1.0.1"), - "DM9051": IDFRegistryComponent("espressif/dm9051", "1.0.0"), + "DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"), "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), } diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5af25fc351..9476b38b72 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -78,7 +78,7 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/dm9051: - version: "1.0.0" + version: "1.1.0" rules: - if: "idf_version >=6.0.0" espressif/esp_tinyusb: From 48844a68badaed2568f4f0581de0d559331d16a4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 16:29:16 -0400 Subject: [PATCH 1494/2214] [core] Clean build when the toolchain changes (#16744) --- esphome/writer.py | 16 ++++++++++++---- tests/unit_tests/test_writer.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index ef7cbf5ac4..84f2f8101a 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -90,10 +90,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: """Return True when the build tree must be wiped before reuse. Predicate is True when *old* is missing (first build), - ``src_version`` differs, ``build_path`` differs, or a previously - loaded integration was removed in *new*. Adding integrations or - changing unrelated fields (friendly name, esphome version, etc.) - does not trigger a clean. + ``src_version`` differs, ``build_path`` differs, the build + ``toolchain`` differs (e.g. switching between the PlatformIO and + native ESP-IDF toolchains, which produce incompatible build trees), + or a previously loaded integration was removed in *new*. Adding + integrations or changing unrelated fields (friendly name, esphome + version, etc.) does not trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -109,6 +111,8 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.build_path != new.build_path: return True + if old.toolchain != new.toolchain: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) @@ -505,6 +509,10 @@ def clean_build(clear_pio_cache: bool = True): if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) dependencies_lock.unlink() + idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") + if idedata_cache.is_file(): + _LOGGER.info("Deleting %s", idedata_cache) + idedata_cache.unlink() # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir # and the Component Manager's fetched managed components live under # the project's build path, not under .pioenvs / .piolibdeps. diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index d6df559571..6f137fb351 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -111,6 +111,7 @@ def create_storage() -> Callable[..., StorageJSON]: no_mdns=kwargs.get("no_mdns", False), framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), + toolchain=kwargs.get("toolchain", "platformio"), ) return _create @@ -142,6 +143,20 @@ def test_storage_should_clean_when_build_path_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_toolchain_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the build toolchain changes. + + Switching between the PlatformIO and native ESP-IDF toolchains produces + incompatible build trees (and toolchain-specific idedata), so the build + must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], toolchain="platformio") + new = create_storage(loaded_integrations=["api", "wifi"], toolchain="esp-idf") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: @@ -479,6 +494,11 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # idedata cache lives under the data dir, not the build path. + idedata_cache = tmp_path / "idedata" / "test.json" + idedata_cache.parent.mkdir() + idedata_cache.write_text("{}") + # Native ESP-IDF toolchain artifacts. idf_build_dir = tmp_path / "build" idf_build_dir.mkdir() @@ -499,11 +519,14 @@ def test_clean_build( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.name = "test" + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert idedata_cache.exists() assert idf_build_dir.exists() assert managed_components_dir.exists() assert platformio_cache_dir.exists() @@ -528,6 +551,7 @@ def test_clean_build( assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() assert not platformio_cache_dir.exists() @@ -537,6 +561,7 @@ def test_clean_build( assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert str(idedata_cache) in caplog.text assert str(idf_build_dir) in caplog.text assert str(managed_components_dir) in caplog.text assert "PlatformIO cache" in caplog.text From 6116d10ab1f6f5168692d3fa6fc1de3152bfcb45 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 17:44:12 -0400 Subject: [PATCH 1495/2214] [espidf] Derive idedata from the native ESP-IDF compile_commands.json (#16742) --- esphome/__main__.py | 1 + esphome/espidf/idedata.py | 178 ++++++++++++++++++++ esphome/espidf/toolchain.py | 28 ++++ tests/unit_tests/test_espidf_idedata.py | 196 ++++++++++++++++++++++ tests/unit_tests/test_espidf_toolchain.py | 92 ++++++++++ 5 files changed, 495 insertions(+) create mode 100644 esphome/espidf/idedata.py create mode 100644 tests/unit_tests/test_espidf_idedata.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 000087063f..cc179ebf98 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -760,6 +760,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: toolchain.create_factory_bin() toolchain.create_ota_bin() toolchain.create_elf_copy() + toolchain.get_idedata() else: from esphome.platformio import toolchain diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py new file mode 100644 index 0000000000..6fce8a55d9 --- /dev/null +++ b/esphome/espidf/idedata.py @@ -0,0 +1,178 @@ +"""Derive idedata from an ESP-IDF native-toolchain ``compile_commands.json``. + +PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native ESP-IDF +toolchain has no such command, but its CMake build emits +``build/compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS). This module +turns that file into the same fields consumers (IDE integration, clang-tidy) +expect: + + {cxx_path, cxx_flags, defines, includes: {build, toolchain}} +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +import shlex +import subprocess + +_LOGGER = logging.getLogger(__name__) + +# C++ translation-unit suffixes used to identify ESPHome source files. +_CXX_SUFFIXES = (".cpp", ".cc") +# Suffixes of input/output files that appear bare on the command line (and so +# must not be mistaken for compiler flags). +_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s") +# Path marker identifying an ESPHome source translation unit. +_ESPHOME_SRC_MARKER = "/src/esphome/" + + +def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: + """Inline any ``@response-file`` arguments (paths relative to ``directory``). + + GCC response files embed flags that must be expanded so GCC-only flags + inside them (e.g. ``-mlongcalls``) can be filtered downstream; left as + ``@file`` clang would read them and choke. + """ + out: list[str] = [] + for tok in tokens: + if tok.startswith("@"): + rf = Path(tok[1:]) + if not rf.is_absolute(): + rf = directory / rf + try: + out.extend( + _expand_response_files( + shlex.split(rf.read_text(encoding="utf-8")), directory + ) + ) + continue + except OSError as err: + # Keep the literal token if the file can't be read, but log it + # so the (otherwise opaque) downstream clang failure is traceable. + _LOGGER.warning("Could not read response file %s: %s", rf, err) + out.append(tok) + return out + + +def _pick_entry(entries: list[dict]) -> dict: + """Pick a representative ESPHome C++ translation unit. + + All ESPHome sources share the same component flags/defines, so any one of + them yields the cxx_path / cxx_flags / defines we need. + """ + for entry in entries: + f = entry["file"] + if _ESPHOME_SRC_MARKER in f and f.endswith(_CXX_SUFFIXES): + return entry + for entry in entries: + if entry["file"].endswith(_CXX_SUFFIXES): + return entry + raise ValueError("no C++ translation unit found in compile_commands.json") + + +def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: + """Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags).""" + directory = Path(entry["directory"]) + tokens = _expand_response_files(shlex.split(entry["command"]), directory) + + def _include(raw: str) -> str: + # Include paths in compile_commands are interpreted relative to the + # entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them + # so the cached idedata is usable regardless of the consumer's cwd. + raw = raw.strip() + if raw and not Path(raw).is_absolute(): + raw = os.path.normpath(directory / raw) + return raw + + cxx_path = tokens[0] + defines: list[str] = [] + includes: list[str] = [] + cxx_flags: list[str] = [] + + it = iter(tokens[1:]) + for tok in it: + if tok in ("-c", "-o"): + next(it, None) # drop the flag and its argument (input/output) + elif tok.startswith("-D"): + # ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single + # quoted arg with a space after -D) that some flags arrive as. + defines.append(tok[2:].strip() if len(tok) > 2 else next(it, "").strip()) + elif tok.startswith("-I"): + includes.append(_include(tok[2:] if len(tok) > 2 else next(it, ""))) + elif tok == "-isystem": + includes.append(_include(next(it, ""))) + elif tok.startswith("-isystem"): + includes.append(_include(tok[len("-isystem") :])) + elif tok in ("-MT", "-MF", "-MQ"): + next(it, None) # dependency-file flag + its argument + elif tok.startswith(("-MD", "-MMD", "-MP", "-MM")): + pass # dependency-generation flags, no argument + elif tok.endswith(_INPUT_FILE_SUFFIXES): + pass # input/output files + else: + cxx_flags.append(tok) + return cxx_path, defines, includes, cxx_flags + + +def _get_toolchain_includes(cxx_path: str) -> list[str]: + """Query the compiler for its builtin ``#include <...>`` search dirs.""" + result = subprocess.run( + [cxx_path, "-E", "-x", "c++", "-", "-v"], + input="", + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + check=False, + close_fds=False, + ) + includes: list[str] = [] + capture = False + for line in result.stderr.splitlines(): + if "#include <...> search starts here:" in line: + capture = True + continue + if "End of search list." in line: + break + if capture: + includes.append(line.strip()) + if result.returncode != 0 or not includes: + raise RuntimeError( + f"Could not query builtin include dirs from {cxx_path} " + f"(return code {result.returncode}); stderr:\n{result.stderr.strip()}" + ) + return includes + + +def idedata_from_build(compile_commands: Path) -> dict: + """Parse compile_commands.json into the idedata fields consumers expect. + + A single ESP-IDF compile entry only carries its own component's REQUIRES + include set, but consumers (clang-tidy) analyze ESPHome headers that + transitively pull in other components. So take cxx_path / cxx_flags / + defines from a representative ESPHome TU, but union the include dirs across + all ESPHome TUs to get a project-wide superset (as PlatformIO's idedata + provides). + """ + entries = json.loads(Path(compile_commands).read_text(encoding="utf-8")) + cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries)) + + build_includes: dict[str, None] = {} + for entry in entries: + f = entry["file"] + if _ESPHOME_SRC_MARKER not in f or not f.endswith(_CXX_SUFFIXES): + continue + for inc in _parse_entry(entry)[2]: + build_includes.setdefault(inc, None) + + return { + "cxx_path": cxx_path, + "cxx_flags": cxx_flags, + "defines": defines, + "includes": { + "build": list(build_includes), + "toolchain": _get_toolchain_includes(cxx_path), + }, + } diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 752f582e74..2fef3faf8d 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -443,6 +443,34 @@ def get_addr2line_path() -> Path: return _get_cmake_tool_path("CMAKE_ADDR2LINE") +def get_idedata() -> dict | None: + """Derive idedata from the build's compile_commands.json. + + The native ESP-IDF toolchain has no ``pio run -t idedata`` equivalent, but + its CMake build emits ``build/compile_commands.json``. Parse that into the + idedata fields IDE integrations and clang-tidy expect, cached alongside the + PlatformIO idedata path. Returns None if the compile DB doesn't exist yet. + """ + from esphome.espidf.idedata import idedata_from_build + + compile_commands = CORE.relative_build_path("build", "compile_commands.json") + if not compile_commands.is_file(): + _LOGGER.debug("No %s yet; skipping idedata generation", compile_commands) + return None + + cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") + if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime: + try: + return json.loads(cache.read_text(encoding="utf-8")) + except ValueError: + pass + + data = idedata_from_build(compile_commands) + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return data + + def create_factory_bin() -> bool: """Create factory.bin by merging bootloader, partition table, and app.""" build_dir = CORE.relative_build_path("build") diff --git a/tests/unit_tests/test_espidf_idedata.py b/tests/unit_tests/test_espidf_idedata.py new file mode 100644 index 0000000000..849ef274ed --- /dev/null +++ b/tests/unit_tests/test_espidf_idedata.py @@ -0,0 +1,196 @@ +"""Tests for esphome.espidf.idedata (compile_commands.json -> idedata).""" + +# pylint: disable=protected-access + +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.espidf import idedata + +# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so +# tests exercise the same is-absolute / normalize behavior as a real compile DB +# (a drive-qualified path on Windows, a leading slash elsewhere). +ABS = "C:/" if os.name == "nt" else "/" + + +def _entry(directory: str, file: str, command: str) -> dict: + return {"directory": directory, "file": file, "command": command} + + +def test_parse_entry_extracts_fields() -> None: + """cxx_path, defines, includes and remaining flags are split apart.""" + entry = _entry( + f"{ABS}build", + f"{ABS}build/src/esphome/core/application.cpp", + f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 " + f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o", + ) + + cxx_path, defines, includes, cxx_flags = idedata._parse_entry(entry) + + assert cxx_path == "/tools/xtensa-esp32-elf-g++" + assert "USE_ESP32" in defines + assert "ESPHOME_LOG_LEVEL=5" in defines + assert f"{ABS}inc/a" in includes + assert f"{ABS}sys/b" in includes + assert "-std=gnu++20" in cxx_flags + # input/output files and their flags are not treated as flags + assert "-c" not in cxx_flags + assert "-o" not in cxx_flags + assert "app.cpp" not in cxx_flags + assert "app.cpp.o" not in cxx_flags + + +def test_parse_entry_space_separated_args() -> None: + """``-D X`` / ``-I path`` (separate arg) and ``-isystem`` (joined).""" + entry = _entry( + f"{ABS}build", + f"{ABS}build/src/esphome/x.cpp", + f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp", + ) + + _, defines, includes, _ = idedata._parse_entry(entry) + + assert "FOO=1" in defines + assert f"{ABS}inc/sep" in includes + assert f"{ABS}sys/joined" in includes + + +def test_parse_entry_resolves_relative_includes() -> None: + """Relative includes are resolved against the entry's ``directory``.""" + directory = f"{ABS}build/proj" + entry = _entry( + directory, + f"{directory}/src/esphome/x.cpp", + "g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp", + ) + + _, _, includes, _ = idedata._parse_entry(entry) + + def resolved(rel: str) -> str: + return os.path.normpath(Path(directory) / rel) + + assert resolved("config") in includes + assert resolved("../shared") in includes # ../ normalized away + assert resolved("rel/sys") in includes + # nothing is left relative + assert all(Path(inc).is_absolute() for inc in includes) + + +def test_parse_entry_skips_dependency_flags() -> None: + """Dependency-generation flags (and their args) are dropped.""" + entry = _entry( + "/build", + "/build/src/esphome/x.cpp", + "g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o", + ) + + _, _, _, cxx_flags = idedata._parse_entry(entry) + + for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"): + assert tok not in cxx_flags + + +def test_expand_response_files(tmp_path: Path) -> None: + """``@file`` arguments are inlined relative to the directory.""" + rsp = tmp_path / "flags.rsp" + rsp.write_text("-DFROM_RSP -I/rsp/inc") + + tokens = idedata._expand_response_files( + ["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path + ) + + assert "-DFROM_RSP" in tokens + assert "-I/rsp/inc" in tokens + assert not any(t.startswith("@") for t in tokens) + + +def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None: + """An unreadable ``@file`` token is kept verbatim rather than dropped.""" + tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path) + assert "@nope.rsp" in tokens + + +def test_pick_entry_prefers_esphome_tu() -> None: + """A ``/src/esphome/`` C++ TU is picked over other compile entries.""" + entries = [ + _entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"), + _entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"), + ] + assert idedata._pick_entry(entries)["file"].endswith("app.cpp") + + +def test_idedata_from_build(tmp_path: Path) -> None: + """Full transform: representative entry + include union + toolchain dirs.""" + compile_commands = tmp_path / "compile_commands.json" + entries = [ + _entry( + f"{ABS}b", + f"{ABS}b/src/esphome/core/app.cpp", + f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o", + ), + _entry( + f"{ABS}b", + f"{ABS}b/src/esphome/sensor/s.cpp", + f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o", + ), + # non-esphome TU: its includes must not leak into the union + _entry( + f"{ABS}b", + f"{ABS}b/managed_components/x/x.c", + f"gcc -I{ABS}inc/managed -c x.c", + ), + ] + compile_commands.write_text(json.dumps(entries)) + + fake_proc = MagicMock( + returncode=0, + stderr=( + "ignored\n" + "#include <...> search starts here:\n" + " /tc/inc/c++\n" + " /tc/inc\n" + "End of search list.\n" + "more ignored\n" + ), + ) + with patch.object(idedata.subprocess, "run", return_value=fake_proc): + data = idedata.idedata_from_build(compile_commands) + + assert data["cxx_path"] == "g++" + assert "USE_ESP32" in data["defines"] + assert "-std=gnu++20" in data["cxx_flags"] + # include dirs unioned across all esphome TUs + assert f"{ABS}inc/core" in data["includes"]["build"] + assert f"{ABS}inc/sensor" in data["includes"]["build"] + # the non-esphome TU is excluded from the union + assert f"{ABS}inc/managed" not in data["includes"]["build"] + # toolchain search dirs parsed from the compiler's -v output + assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"] + + +def test_get_toolchain_includes_raises_on_probe_failure() -> None: + """A failed compiler probe is a hard error, not a silent empty list.""" + fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found") + with ( + patch.object(idedata.subprocess, "run", return_value=fake_proc), + pytest.raises(RuntimeError, match="builtin include dirs"), + ): + idedata._get_toolchain_includes("/bad/compiler") + + +def test_get_toolchain_includes_raises_when_no_dirs_found() -> None: + """Markers present but no dirs (anomalous output) also raises.""" + fake_proc = MagicMock( + returncode=0, + stderr="#include <...> search starts here:\nEnd of search list.\n", + ) + with ( + patch.object(idedata.subprocess, "run", return_value=fake_proc), + pytest.raises(RuntimeError, match="builtin include dirs"), + ): + idedata._get_toolchain_includes("/some/compiler") diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index adc8bfce63..d00d8662f5 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -2,6 +2,9 @@ # pylint: disable=protected-access +import json +import os +from pathlib import Path from unittest.mock import patch from esphome.const import CONF_FRAMEWORK, CONF_SOURCE @@ -56,3 +59,92 @@ def test_get_esphome_esp_idf_paths_no_override(): ) as mock_install: toolchain._get_esphome_esp_idf_paths("5.5.4") mock_install.assert_called_once_with("5.5.4", source_url=None) + + +def _setup_build(setup_core: Path) -> tuple[Path, Path]: + """Point CORE at a build dir; return (compile_commands, idedata cache) paths.""" + CORE.name = "test" + CORE.build_path = setup_core / "build" / "test" + compile_commands = CORE.relative_build_path("build", "compile_commands.json") + cache = CORE.relative_internal_path("idedata", "test.json") + return compile_commands, cache + + +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) + assert toolchain.get_idedata() is None + + +def test_get_idedata_generates_and_caches(setup_core: Path) -> None: + """Generates from the compile DB and writes the cache.""" + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "g++"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result == {"cxx_path": "g++"} + assert json.loads(cache.read_text()) == {"cxx_path": "g++"} + + +def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: + """A cache at least as new as the compile DB is reused without regenerating.""" + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text('{"cxx_path": "cached"}') + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch("esphome.espidf.idedata.idedata_from_build") as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_not_called() + assert result == {"cxx_path": "cached"} + + +def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None: + """A compile DB newer than the cache forces regeneration.""" + compile_commands, cache = _setup_build(setup_core) + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text('{"cxx_path": "stale"}') + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache_mtime = cache.stat().st_mtime + os.utime(compile_commands, (cache_mtime + 1, cache_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "fresh"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result == {"cxx_path": "fresh"} + + +def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: + """An unparseable (but newer) cache falls back to regeneration.""" + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text("{not json") + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "regen"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result == {"cxx_path": "regen"} From 805aa252d537490b4f73121acbd11646b4cfc11f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:30:05 +1000 Subject: [PATCH 1496/2214] [const] Move CONF_SHA256 to common code (#16751) --- esphome/components/const/__init__.py | 1 + .../esp32_hosted/update/__init__.py | 2 +- esphome/components/shelly_dimmer/light.py | 2 +- tests/components/const/common.yaml | 37 ------------------- tests/components/const/test.esp32-s3-idf.yaml | 4 -- 5 files changed, 3 insertions(+), 43 deletions(-) delete mode 100644 tests/components/const/common.yaml delete mode 100644 tests/components/const/test.esp32-s3-idf.yaml diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6f418b48ea..3f7777883e 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -22,6 +22,7 @@ CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" +CONF_SHA256 = "sha256" CONF_STOP_BITS = "stop_bits" CONF_USE_PSRAM = "use_psram" CONF_VOLUME_INCREMENT = "volume_increment" diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index 202df21ab5..8e85cce75a 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -3,6 +3,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, update +from esphome.components.const import CONF_SHA256 import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE from esphome.core import CORE, ID, HexInt @@ -11,7 +12,6 @@ CODEOWNERS = ["@swoboda1337"] AUTO_LOAD = ["sha256", "watchdog", "json"] DEPENDENCIES = ["esp32_hosted"] -CONF_SHA256 = "sha256" CONF_HTTP_REQUEST_ID = "http_request_id" TYPE_EMBEDDED = "embedded" diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index 97538e13c9..ddf7fa161b 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -7,6 +7,7 @@ import requests from esphome import pins import esphome.codegen as cg from esphome.components import light, sensor, uart +from esphome.components.const import CONF_SHA256 import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, @@ -39,7 +40,6 @@ ShellyDimmer = shelly_dimmer_ns.class_( ) CONF_FIRMWARE = "firmware" -CONF_SHA256 = "sha256" CONF_UPDATE = "update" CONF_LEADING_EDGE = "leading_edge" diff --git a/tests/components/const/common.yaml b/tests/components/const/common.yaml deleted file mode 100644 index 109db65b63..0000000000 --- a/tests/components/const/common.yaml +++ /dev/null @@ -1,37 +0,0 @@ -display: - - platform: qspi_dbi - model: RM690B0 - data_rate: 80MHz - spi_mode: mode0 - dimensions: - width: 450 - height: 600 - offset_width: 16 - color_order: rgb - invert_colors: false - brightness: 255 - cs_pin: 11 - reset_pin: 13 - enable_pin: 9 - - - platform: qspi_dbi - model: CUSTOM - id: main_lcd - draw_from_origin: true - dimensions: - height: 240 - width: 536 - transform: - mirror_x: true - swap_xy: true - color_order: rgb - brightness: 255 - cs_pin: 6 - reset_pin: 17 - enable_pin: 38 - init_sequence: - - [0x3A, 0x66] - - [0x11] - - delay 120ms - - [0x29] - - delay 20ms diff --git a/tests/components/const/test.esp32-s3-idf.yaml b/tests/components/const/test.esp32-s3-idf.yaml deleted file mode 100644 index c335dee1f3..0000000000 --- a/tests/components/const/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - qspi: !include ../../test_build_components/common/qspi/esp32-s3-idf.yaml - -<<: !include common.yaml From 4e4868246818a1e2bbe13c11ff82dd64f07ad747 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Jun 2026 14:18:29 -0500 Subject: [PATCH 1497/2214] [wifi] Defer esp_wifi_init() to lazy-init so enable_on_boot: false actually saves RAM (#16606) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/wifi/wifi_component.cpp | 8 +++++++ esphome/components/wifi/wifi_component.h | 12 ++++++++++ .../wifi/wifi_component_esp_idf.cpp | 22 ++++++++++++++++--- .../wifi/test-lifecycle.esp32-idf.yaml | 15 +++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 tests/components/wifi/test-lifecycle.esp32-idf.yaml diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index fdbd70bc61..07cb2ac243 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -632,6 +632,9 @@ void WiFiComponent::setup() { #endif if (this->enable_on_boot_) { +#ifdef USE_ESP32 + this->wifi_lazy_init_(); +#endif this->start(); } else { this->state_ = WIFI_COMPONENT_STATE_DISABLED; @@ -1275,6 +1278,11 @@ void WiFiComponent::enable() { ESP_LOGD(TAG, "Enabling"); this->state_ = WIFI_COMPONENT_STATE_OFF; +#ifdef USE_ESP32 + // Idempotent — only allocates DMA buffers + netifs on the first call. After this, + // start() can safely run. + this->wifi_lazy_init_(); +#endif this->start(); } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 0437267a1f..d0521e548a 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -694,6 +694,12 @@ class WiFiComponent final : public Component { bool wifi_apply_hostname_(); bool wifi_sta_connect_(const WiFiAP &ap); void wifi_pre_setup_(); +#ifdef USE_ESP32 + // ESP-IDF only: defers esp_wifi_init() + netif creation (which allocate ~15-30KB of + // DMA-capable internal SRAM) until wifi actually needs to come up. Idempotent. + // Called from setup() only when enable_on_boot_=true, and from enable() on first use. + void wifi_lazy_init_(); +#endif WiFiSTAConnectStatus wifi_sta_connect_status_() const; bool is_connected_() const { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && @@ -889,6 +895,12 @@ class WiFiComponent final : public Component { bool rrm_{false}; #endif bool enable_on_boot_{true}; +#ifdef USE_ESP32 + // Tracks whether esp_wifi_init() + netif creation has happened. Allows enable() + // to be called at runtime without re-allocating, and ensures the heavy init is + // skipped entirely when enable_on_boot_ is false until first enable(). + bool wifi_initialized_{false}; +#endif bool got_ipv4_address_{false}; bool keep_scan_results_{false}; bool has_completed_scan_after_captive_portal_start_{ diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 11b39b5000..b395c77141 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -163,11 +163,26 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err)); return; } + // NOTE: netif creation + esp_wifi_init() used to live here. They allocate ~15-30KB of + // DMA-capable internal SRAM, which competes with W5500 SPI DMA and I2S DMA on + // memory-tight devices. They are now deferred to wifi_lazy_init_(), called from + // setup() when enable_on_boot_ is true, or from enable() on first runtime enable. + // This makes enable_on_boot:false genuinely skip the wifi DMA allocation. +} - s_sta_netif = esp_netif_create_default_wifi_sta(); +void WiFiComponent::wifi_lazy_init_() { + if (this->wifi_initialized_) + return; + + // Guard each creation so partial init (e.g. a failed esp_wifi_init() below) + // followed by a retry via enable() does not leak the existing netif handle + // nor re-register the default WiFi handlers. + if (s_sta_netif == nullptr) + s_sta_netif = esp_netif_create_default_wifi_sta(); #ifdef USE_WIFI_AP - s_ap_netif = esp_netif_create_default_wifi_ap(); + if (s_ap_netif == nullptr) + s_ap_netif = esp_netif_create_default_wifi_ap(); #endif // USE_WIFI_AP wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); @@ -175,7 +190,7 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGW(TAG, "starting wifi without nvs"); cfg.nvs_enable = false; } - err = esp_wifi_init(&cfg); + esp_err_t err = esp_wifi_init(&cfg); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); return; @@ -185,6 +200,7 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); return; } + this->wifi_initialized_ = true; } bool WiFiComponent::wifi_mode_(optional sta, optional ap) { diff --git a/tests/components/wifi/test-lifecycle.esp32-idf.yaml b/tests/components/wifi/test-lifecycle.esp32-idf.yaml new file mode 100644 index 0000000000..229a24b2d1 --- /dev/null +++ b/tests/components/wifi/test-lifecycle.esp32-idf.yaml @@ -0,0 +1,15 @@ +wifi: + ssid: MySSID + password: password1 + enable_on_boot: false + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + wifi.enabled: + then: + - wifi.enable: From 2454ad1645321a8cde03f5a0e167c6f2b0ed5b2c Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Jun 2026 15:30:07 -0500 Subject: [PATCH 1498/2214] [ethernet] Add enable_on_boot lifecycle + lazy-init to reclaim DMA-capable SRAM (#16607) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ethernet/__init__.py | 28 +++++++ esphome/components/ethernet/automation.h | 30 ++++++++ .../components/ethernet/ethernet_component.h | 32 ++++++++ .../ethernet/ethernet_component_esp32.cpp | 76 ++++++++++++++++++- .../ethernet/ethernet_component_rp2040.cpp | 17 +++++ .../ethernet/test-lifecycle.esp32-idf.yaml | 39 ++++++++++ 6 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 esphome/components/ethernet/automation.h create mode 100644 tests/components/ethernet/test-lifecycle.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 22f5eb33e1..784f5dee8c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import logging from esphome import automation, pins +from esphome.automation import Condition import esphome.codegen as cg from esphome.components.network import ip_address_literal from esphome.config_helpers import filter_source_files_from_platform @@ -13,6 +14,7 @@ from esphome.const import ( CONF_DNS1, CONF_DNS2, CONF_DOMAIN, + CONF_ENABLE_ON_BOOT, CONF_GATEWAY, CONF_ID, CONF_INTERRUPT_PIN, @@ -217,6 +219,10 @@ MANUAL_IP_SCHEMA = cv.Schema( EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") +EthernetConnectedCondition = ethernet_ns.class_("EthernetConnectedCondition", Condition) +EthernetEnabledCondition = ethernet_ns.class_("EthernetEnabledCondition", Condition) +EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action) +EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action) def _is_framework_spi_polling_mode_supported() -> bool: @@ -348,6 +354,7 @@ BASE_SCHEMA = cv.Schema( cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True), cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True), } @@ -494,6 +501,9 @@ async def to_code(config): cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + # enable_on_boot defaults to true in C++ - only set if false + if not config[CONF_ENABLE_ON_BOOT]: + cg.add(var.set_enable_on_boot(False)) CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE] if CONF_MANUAL_IP in config: @@ -715,3 +725,21 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files + + +async def _new_pvariable_to_code(config, id_, template_arg, args): + return cg.new_Pvariable(id_, template_arg) + + +for _name, _cls in ( + ("ethernet.connected", EthernetConnectedCondition), + ("ethernet.enabled", EthernetEnabledCondition), +): + automation.register_condition(_name, _cls, cv.Schema({}))(_new_pvariable_to_code) +for _name, _cls in ( + ("ethernet.enable", EthernetEnableAction), + ("ethernet.disable", EthernetDisableAction), +): + automation.register_action(_name, _cls, cv.Schema({}), synchronous=True)( + _new_pvariable_to_code + ) diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h new file mode 100644 index 0000000000..c16abc5bda --- /dev/null +++ b/esphome/components/ethernet/automation.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ETHERNET +#include "ethernet_component.h" + +namespace esphome::ethernet { + +template class EthernetConnectedCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->is_connected(); } +}; + +template class EthernetEnabledCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } +}; + +template class EthernetEnableAction : public Action { + public: + void play(const Ts &...x) override { global_eth_component->enable(); } +}; + +template class EthernetDisableAction : public Action { + public: + void play(const Ts &...x) override { global_eth_component->disable(); } +}; + +} // namespace esphome::ethernet +#endif diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 17c84ee954..7d06377f90 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -124,6 +124,17 @@ class EthernetComponent final : public Component { void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } + // Per-interface lifecycle (parallels WiFiComponent::enable/disable/is_disabled). + // enable_on_boot defaults to true; when false, setup() runs all the driver/netif + // installation but skips esp_eth_start(), keeping the link cold until enable() is + // called. This is the primary lever for memory reclamation in multi-interface + // configurations where only one interface should carry traffic at a time. + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + void enable(); + void disable(); + bool is_disabled() { return this->disabled_; } + bool is_enabled() { return !this->disabled_; } + void set_type(EthernetType type); #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); @@ -194,6 +205,16 @@ class EthernetComponent final : public Component { void finish_connect_(); void dump_connect_params_(); +#ifdef USE_ESP32 + // ESP-IDF only: defers the SPI bus init, netif creation, MAC/PHY install, driver + // install, netif attach, and event handler registration (which together allocate + // ~3-8KB of DMA-capable internal SRAM via SPI driver state + eth driver RX queue) + // until ethernet actually needs to come up. Idempotent — guarded by the + // ethernet_initialized_ flag. Called from setup() when enable_on_boot_=true, or + // from enable() on first runtime enable. Mirrors wifi_lazy_init_() in WiFi. + void ethernet_lazy_init_(); +#endif + #ifdef USE_ETHERNET_IP_STATE_LISTENERS void notify_ip_state_listeners_(); #endif @@ -287,6 +308,17 @@ class EthernetComponent final : public Component { bool started_{false}; bool connected_{false}; bool got_ipv4_address_{false}; + // Codegen-time YAML option. When false, setup() defers esp_eth_start(). + bool enable_on_boot_{true}; + // Mirror of "is the link intentionally stopped" — set when setup() honors + // enable_on_boot=false, cleared by enable(), set again by disable(). + bool disabled_{false}; +#ifdef USE_ESP32 + // Tracks whether ethernet_lazy_init_() has completed successfully. Allows enable() + // to be called at runtime after enable_on_boot:false without re-allocating, and + // ensures setup() skips the heavy init when enable_on_boot_ is false. + bool ethernet_initialized_{false}; +#endif #if LWIP_IPV6 uint8_t ipv6_count_{0}; bool ipv6_setup_done_{false}; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 6481c8c1f4..544ec79c32 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -138,6 +138,24 @@ void EthernetComponent::setup() { delay(300); // NOLINT } + if (this->enable_on_boot_) { + this->ethernet_lazy_init_(); + if (!this->ethernet_initialized_) { + // lazy_init bailed early via ESPHL_ERROR_CHECK or mark_failed; nothing more to do. + return; + } + esp_err_t err = esp_eth_start(this->eth_handle_); + ESPHL_ERROR_CHECK(err, "ETH start error"); + } else { + ESP_LOGCONFIG(TAG, "Skipping init (enable_on_boot: false)"); + this->disabled_ = true; + } +} + +void EthernetComponent::ethernet_lazy_init_() { + if (this->ethernet_initialized_) + return; + esp_err_t err; #ifdef USE_ETHERNET_SPI @@ -371,9 +389,41 @@ void EthernetComponent::setup() { ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error"); #endif /* USE_NETWORK_IPV6 */ - /* start Ethernet driver state machine */ - err = esp_eth_start(this->eth_handle_); - ESPHL_ERROR_CHECK(err, "ETH start error"); + this->ethernet_initialized_ = true; +} + +void EthernetComponent::enable() { + if (!this->disabled_) + return; + + ESP_LOGD(TAG, "Enabling"); + this->ethernet_lazy_init_(); + if (!this->ethernet_initialized_) { + ESP_LOGE(TAG, "Cannot enable - init failed"); + return; + } + esp_err_t err = esp_eth_start(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_eth_start failed: %s", esp_err_to_name(err)); + return; + } + this->disabled_ = false; + // The ETH_EVENT_START handler will set started_=true; the loop state machine + // will then drive the STOPPED -> CONNECTING -> CONNECTED transitions. + this->enable_loop(); +} + +void EthernetComponent::disable() { + if (this->disabled_) + return; + + ESP_LOGD(TAG, "Disabling"); + esp_err_t err = esp_eth_stop(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_eth_stop failed: %s — disabling anyway", esp_err_to_name(err)); + } + this->disabled_ = true; + // ETH_EVENT_STOP will clear started_; loop() will transition to STOPPED. } void EthernetComponent::dump_config() { @@ -487,6 +537,8 @@ void EthernetComponent::dump_config() { network::IPAddresses EthernetComponent::get_ip_addresses() { network::IPAddresses addresses; + if (!this->ethernet_initialized_) + return addresses; // all-zero IPs esp_netif_ip_info_t ip; esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip); if (err != ESP_OK) { @@ -709,6 +761,10 @@ void EthernetComponent::start_connect_() { } void EthernetComponent::dump_connect_params_() { + if (!this->ethernet_initialized_) { + ESP_LOGCONFIG(TAG, " uninitialized/disabled"); + return; + } esp_netif_ip_info_t ip; esp_netif_get_ip_info(this->eth_netif_, &ip); const ip_addr_t *dns_ip1; @@ -776,6 +832,16 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy #endif void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { + if (!this->ethernet_initialized_) { + // 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); + } else { + esp_read_mac(mac, ESP_MAC_ETH); + } + return; + } esp_err_t err; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac); ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); @@ -795,6 +861,8 @@ const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( } eth_duplex_t EthernetComponent::get_duplex_mode() { + if (!this->ethernet_initialized_) + return ETH_DUPLEX_HALF; esp_err_t err; eth_duplex_t duplex_mode; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode); @@ -803,6 +871,8 @@ eth_duplex_t EthernetComponent::get_duplex_mode() { } eth_speed_t EthernetComponent::get_link_speed() { + if (!this->ethernet_initialized_) + return ETH_SPEED_10M; esp_err_t err; eth_speed_t speed; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed); diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2040.cpp index ef7bd46332..250297ddb5 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2040.cpp @@ -361,6 +361,23 @@ 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 + // RP2040 for schema parity but has no effect. + if (!this->disabled_) + return; + ESP_LOGW(TAG, "enable_on_boot/disable not supported"); + this->disabled_ = false; +} + +void EthernetComponent::disable() { + if (this->disabled_) + return; + ESP_LOGW(TAG, "enable_on_boot/disable not supported"); + this->disabled_ = true; +} + } // namespace esphome::ethernet #endif // USE_ETHERNET && USE_RP2040 diff --git a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml new file mode 100644 index 0000000000..904a916789 --- /dev/null +++ b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml @@ -0,0 +1,39 @@ +ethernet: + id: eth + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + enable_on_boot: false + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + mac_address: "02:AA:BB:CC:DD:01" + interface: spi2 + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + ethernet.enabled: + then: + - ethernet.enable: + +button: + - platform: template + name: "Disable Ethernet" + on_press: + - ethernet.disable: + +binary_sensor: + - platform: template + name: "Ethernet Connected" + lambda: |- + return id(eth).is_connected(); From ab46f8bd7451d8f058bfd317096cbcd6bb74fbf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 15:32:23 -0500 Subject: [PATCH 1499/2214] [api] Fix crash loop on VoiceAssistantConfigurationRequest (#16757) --- esphome/components/api/api_connection.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c880e036cb..2b1458e2ae 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1306,6 +1306,9 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) { VoiceAssistantConfigurationResponse resp; if (!this->check_voice_assistant_api_connection_()) { + // send_message encodes synchronously, so this stack local outlives the encode + const std::vector empty_wake_words; + resp.active_wake_words = &empty_wake_words; return this->send_message(resp); } From d7d20f4f6bd76a8c7a2575da0c3d99772d41dbd5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:04:35 +1000 Subject: [PATCH 1500/2214] [cli] Allow state reporting control via env (#16746) --- esphome/__main__.py | 50 +++++++++++++++++++++------ tests/unit_tests/test_main.py | 64 +++++++++++++++++++++++++++++++---- 2 files changed, 96 insertions(+), 18 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index cc179ebf98..47dd8d273c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1351,6 +1351,19 @@ def _validate_bootloader_binary(binary: Path) -> None: ) +def _should_subscribe_states(args: ArgsProtocol) -> bool: + """Determine whether entity state changes should be shown in log output. + + The ``--states``/``--no-states`` command line flags take precedence. When + neither is given, the ``ESPHOME_LOG_STATES`` environment variable controls + the behavior, defaulting to showing states. + """ + states = getattr(args, "states", None) + if states is not None: + return states + return get_bool_env("ESPHOME_LOG_STATES", True) + + def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) @@ -1380,7 +1393,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_logs( config, network_devices, - subscribe_states=not getattr(args, "no_states", False), + subscribe_states=_should_subscribe_states(args), ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): @@ -2019,6 +2032,29 @@ SIMPLE_CONFIG_ACTIONS = [ ] +def _add_states_args(parser: argparse.ArgumentParser) -> None: + """Add mutually exclusive ``--states``/``--no-states`` flags to a parser. + + When neither flag is given, the ``ESPHOME_LOG_STATES`` environment variable + controls whether entity state changes are shown (defaulting to showing them). + """ + states_group = parser.add_mutually_exclusive_group() + states_group.add_argument( + "--states", + dest="states", + action="store_true", + default=None, + help="Show entity state changes in log output (overrides ESPHOME_LOG_STATES).", + ) + states_group.add_argument( + "--no-states", + dest="states", + action="store_false", + default=None, + help="Do not show entity state changes in log output.", + ) + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2195,11 +2231,7 @@ def parse_args(argv): help="Reset the device before starting serial logs.", default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"), ) - parser_logs.add_argument( - "--no-states", - action="store_true", - help="Do not show entity state changes in log output.", - ) + _add_states_args(parser_logs) parser_discover = subparsers.add_parser( "discover", @@ -2231,11 +2263,7 @@ def parse_args(argv): "--no-logs", help="Disable starting logs.", action="store_true" ) - parser_run.add_argument( - "--no-states", - action="store_true", - help="Do not show entity state changes in log output.", - ) + _add_states_args(parser_run) parser_run.add_argument( "--reset", diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 26b550669f..8cce60d351 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1269,6 +1269,7 @@ class MockArgs: ota_platform: str | None = None partition_table: bool = False bootloader: bool = False + states: bool | None = None def test_upload_program_serial_esp32( @@ -2663,7 +2664,7 @@ def test_show_logs_api_no_states( mock_run_logs.return_value = 0 args = MockArgs() - args.no_states = True + args.states = False devices = ["192.168.1.100"] result = show_logs(CORE.config, args, devices) @@ -5989,19 +5990,68 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback( def test_parse_args_run_no_states() -> None: """Test that --no-states is parsed for the run command.""" args = parse_args(["esphome", "run", "--no-states", "device.yaml"]) - assert args.no_states is True + assert args.states is False -def test_parse_args_run_no_states_default() -> None: - """Test that no_states defaults to False for the run command.""" +def test_parse_args_run_states() -> None: + """Test that --states is parsed for the run command.""" + args = parse_args(["esphome", "run", "--states", "device.yaml"]) + assert args.states is True + + +def test_parse_args_run_states_default() -> None: + """Test that states defaults to None (unset) for the run command.""" args = parse_args(["esphome", "run", "device.yaml"]) - assert args.no_states is False + assert args.states is None def test_parse_args_logs_no_states() -> None: """Test that --no-states is parsed for the logs command.""" args = parse_args(["esphome", "logs", "--no-states", "device.yaml"]) - assert args.no_states is True + assert args.states is False + + +def test_parse_args_logs_states() -> None: + """Test that --states is parsed for the logs command.""" + args = parse_args(["esphome", "logs", "--states", "device.yaml"]) + assert args.states is True + + +def test_should_subscribe_states_default() -> None: + """Test that states are shown by default when nothing is set.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("ESPHOME_LOG_STATES", None) + assert _should_subscribe_states(args) is True + + +def test_should_subscribe_states_env_suppresses() -> None: + """Test that ESPHOME_LOG_STATES=false suppresses states by default.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}): + assert _should_subscribe_states(args) is False + + +def test_should_subscribe_states_flag_overrides_env() -> None: + """Test that --states overrides ESPHOME_LOG_STATES=false.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "--states", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}): + assert _should_subscribe_states(args) is True + + +def test_should_subscribe_states_no_flag_overrides_env() -> None: + """Test that --no-states overrides ESPHOME_LOG_STATES=true.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "--no-states", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}): + assert _should_subscribe_states(args) is False @patch("esphome.components.api.client.run_logs") @@ -6020,7 +6070,7 @@ def test_command_run_passes_no_states_to_show_logs( mock_run_logs.return_value = 0 args = MockArgs() - args.no_states = True + args.states = False args.no_logs = False args.device = None From d7f809181a43878b0e8e100e0edb5610d9535906 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:31 -0700 Subject: [PATCH 1501/2214] [writer] Mark storage_should_clean as public API for device-builder (#16443) --- esphome/writer.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/esphome/writer.py b/esphome/writer.py index 72c2c355dc..ad3877465d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -87,6 +87,21 @@ def replace_file_content(text, pattern, repl): def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: + """Return True when the build tree must be wiped before reuse. + + Predicate is True when *old* is missing (first build), + ``src_version`` differs, ``build_path`` differs, or a previously + loaded integration was removed in *new*. Adding integrations or + changing unrelated fields (friendly name, esphome version, etc.) + does not trigger a clean. + + Used by esphome-device-builder (esphome/device-builder) to gate + its remote-build artifact materialiser so a local → remote → local + cycle preserves PlatformIO's local object cache instead of wiping + it on every cycle. The signature, semantics, and ``None`` handling + for *old* are part of the public contract; keep them stable so the + offloader's wipe decision tracks core's. + """ if old is None: return True From 3f57117efddb699089f64a762c98b103b9baf89c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:36:41 -0400 Subject: [PATCH 1502/2214] [esp32] Decode crash PCs via IDF toolchain on IDF builds (#16626) --- esphome/components/esp32/__init__.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4f77258b2c..1a95f77437 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -46,7 +46,7 @@ from esphome.const import ( Toolchain, __version__, ) -from esphome.core import CORE, HexInt, Library +from esphome.core import CORE, EsphomeError, HexInt, Library from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_component @@ -2657,13 +2657,29 @@ def copy_files(): def _decode_pc(config, addr): - from esphome.platformio import toolchain + # _decode_pc runs from the api log processor's asyncio callback, which + # only catches EsphomeError. Any other exception escaping here tears down + # the protocol and triggers an infinite reconnect/replay loop. Convert + # toolchain-resolution errors (e.g. missing build dir / cmake cache) into + # EsphomeError so the caller can disable decoding cleanly. + if CORE.using_toolchain_esp_idf: + from esphome.espidf import toolchain as idf_toolchain - idedata = toolchain.get_idedata(config) - if not idedata.addr2line_path or not idedata.firmware_elf_path: + try: + addr2line_path = idf_toolchain.get_addr2line_path() + firmware_elf_path = idf_toolchain.get_elf_path() + except RuntimeError as err: + raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err + else: + from esphome.platformio import toolchain + + idedata = toolchain.get_idedata(config) + addr2line_path = idedata.addr2line_path + firmware_elf_path = idedata.firmware_elf_path + if not addr2line_path or not firmware_elf_path: _LOGGER.debug("decode_pc no addr2line") return - command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() except Exception: # pylint: disable=broad-except From a04f6da814e318e9fecf766232edb4fc426619cb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 26 May 2026 19:56:44 +1200 Subject: [PATCH 1503/2214] [packages] Resolve git symlinks on Windows when materialized as text (#16657) --- esphome/components/packages/__init__.py | 42 +++- esphome/git.py | 87 +++++++ tests/unit_tests/test_git.py | 303 +++++++++++++++++++++++- tests/unit_tests/test_substitutions.py | 83 +++++++ 4 files changed, 507 insertions(+), 8 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 06a64208b6..f3e0e0db8f 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: If loading fails after cloning, attempts a revert and retry in case a prior cached checkout is stale. """ - repo_dir, revert = git.clone_or_update( + repo_root, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), refresh=config[CONF_REFRESH], @@ -225,6 +225,10 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: ) files: list[dict[str, Any]] = [] + # ``repo_root`` is the directory containing ``.git`` and must be passed + # to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a + # subdirectory via the user's CONF_PATH and is used for file lookups. + repo_dir = repo_root if base_path := config.get(CONF_PATH): repo_dir = repo_dir / base_path @@ -236,13 +240,37 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: def _load_package_yaml(yaml_file: Path, filename: str) -> dict: """Load a YAML file from a remote package, validating min_version.""" - try: - new_yaml = yaml_util.load_yaml(yaml_file) - except EsphomeError as e: + + def _load(path: Path) -> dict | str | None: + try: + return yaml_util.load_yaml(path) + except EsphomeError as e: + raise cv.Invalid( + f"{filename} is not a valid YAML file." + f" Please check the file contents.\n{e}" + ) from e + + new_yaml = _load(yaml_file) + if not isinstance(new_yaml, dict): + # On Windows, git defaults to core.symlinks=false unless the user + # has Developer Mode enabled or is running elevated. Files stored + # in the repo as symlinks (tree mode 120000) are then checked out + # as plain text files containing the symlink target path, so + # parsing them as YAML yields a bare scalar instead of a mapping. + # Best-effort: follow the symlink target ourselves and re-load. + target = git.resolve_symlink_stub(repo_root, yaml_file) + if target is not None: + new_yaml = _load(target) + if not isinstance(new_yaml, dict): raise cv.Invalid( - f"{filename} is not a valid YAML file." - f" Please check the file contents.\n{e}" - ) from e + f"{filename} does not contain a YAML mapping at the top level " + f"(got {type(new_yaml).__name__}). " + f"If this file is a git symlink in the source repository, it " + f"may not have been materialized correctly on your platform " + f"(this is a known issue with git on Windows without Developer " + f"Mode enabled). Try pointing your package at the real file " + f"path instead." + ) esphome_config = new_yaml.get(CONF_ESPHOME) or {} min_version = esphome_config.get(CONF_MIN_VERSION) if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse( diff --git a/esphome/git.py b/esphome/git.py index 0106f24845..c724ea2875 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -6,6 +6,7 @@ import logging from pathlib import Path import re import subprocess +import sys import urllib.parse import esphome.config_validation as cv @@ -93,6 +94,92 @@ def _compute_destination_path(key: str, domain: str) -> Path: return base_dir / h.hexdigest()[:8] +def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: + """Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub. + + On Windows, when ``core.symlinks=false`` (the default unless the user has + SeCreateSymbolicLinkPrivilege — i.e. Developer Mode or running elevated), + git materializes files with tree mode ``120000`` as plain text files + whose content is the literal symlink target path. Opening such a file + yields the target path string instead of the target's content. + + If ``file_path`` is one of those stubs, return the resolved target Path + inside ``repo_dir``. Otherwise return ``None`` and the caller should use + ``file_path`` as-is. + + Designed to be called *only* when normal access has already produced an + unexpected result (e.g. YAML parsed as a top-level scalar), so the + per-file ``git ls-files`` subprocess cost is paid only on the failure + path. Returns ``None`` on any error or check failure — it's purely a + best-effort recovery, never raises. + """ + # On non-Windows, git creates real symlinks; ordinary file access already + # transparently follows them. + if sys.platform != "win32": + return None + if file_path.is_symlink(): + return None + if not file_path.is_file(): + return None + + try: + rel = file_path.relative_to(repo_dir) + except ValueError: + return None + + try: + # ``git ls-files -s `` prints " \t" + # for that single entry, or empty if untracked. + out = run_git_command( + ["git", "ls-files", "-s", "--", rel.as_posix()], + git_dir=repo_dir, + ) + except GitException: + return None + + parts = out.split() + if not parts or parts[0] != "120000": + return None + + # Stubs are short ASCII relative paths. Decode defensively, and only + # strip the trailing newline git's checkout may append — preserving any + # whitespace that could be part of a valid target name. + try: + raw = file_path.read_bytes() + except OSError: + return None + try: + target_str = raw.decode("utf-8").rstrip("\r\n") + except UnicodeDecodeError: + return None + + # ``Path()`` and ``Path.resolve()`` can raise on malformed inputs (e.g. + # embedded NUL bytes from a hostile symlink blob, paths too long for the + # OS, or temporary I/O errors). Catch broadly — this helper is purely a + # best-effort recovery and must never raise. + try: + target_path = (file_path.parent / target_str).resolve() + repo_root_resolved = repo_dir.resolve() + except (OSError, ValueError, RuntimeError): + return None + + # ``Path.resolve()`` follows ``..``; re-verify containment afterwards. + try: + target_path.relative_to(repo_root_resolved) + except ValueError: + _LOGGER.warning( + "Refusing to follow symlink %s -> %s (escapes repository)", + file_path, + target_str, + ) + return None + + if not target_path.is_file(): + return None + + return target_path + + def clone_or_update( *, url: str, diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index eab6bfc2cb..690c47c183 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta import os from pathlib import Path from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -1001,3 +1001,304 @@ def test_refresh_picks_up_new_remote_commits( "--hard", "old_sha", ] + + +def test_resolve_symlink_stub_returns_none_on_non_windows( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """On non-Windows, resolve_symlink_stub returns None without calling git.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + stub = repo_dir / "file.yaml" + stub.write_text("static/file.yaml") + + with patch("esphome.git.sys.platform", "linux"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_target_for_mode_120000( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A mode-120000 file is recognised as a stub; its target Path is returned.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "static").mkdir() + + target = repo_dir / "static" / "real.yaml" + target.write_text("esphome:\n name: real\n") + + stub = repo_dir / "real.yaml" + stub.write_text("static/real.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\treal.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + # Stub file itself was not modified — only inspected. + assert stub.read_text() == "static/real.yaml" + + +def test_resolve_symlink_stub_resolves_relative_parent_paths( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Symlink targets with ``..`` segments resolve correctly within the repo.""" + repo_dir = tmp_path / "repo" + (repo_dir / "subdir").mkdir(parents=True) + (repo_dir / "static").mkdir() + + target = repo_dir / "static" / "shared.yaml" + target.write_text("shared content") + + stub = repo_dir / "subdir" / "shared.yaml" + stub.write_text("../static/shared.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tsubdir/shared.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + + +def test_resolve_symlink_stub_refuses_escape_outside_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A symlink pointing outside the repository is not followed.""" + outside = tmp_path / "outside.yaml" + outside.write_text("sensitive") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "escape.yaml" + stub.write_text("../outside.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tescape.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_for_real_symlink( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A real symlink already opens transparently, so the helper short-circuits. + + Skipped on Windows where symlink creation requires + SeCreateSymbolicLinkPrivilege. + """ + if os.name == "nt": + pytest.skip("Requires symlink-creation privilege on Windows") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + target = repo_dir / "real.yaml" + target.write_text("real content") + + real_link = repo_dir / "link.yaml" + real_link.symlink_to("real.yaml") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, real_link) + + assert result is None + # No git call needed for real symlinks. + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_for_regular_file( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A regular file (mode 100644) whose content looks path-shaped is not + followed.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + regular = repo_dir / "looks_like_path.txt" + regular.write_text("static/something.yaml") + + mock_run_git_command.return_value = "100644 abc123 0\tlooks_like_path.txt" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, regular) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_git_fails( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """If ``git ls-files`` fails (e.g. not a repo), the helper returns None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "real.yaml" + stub.write_text("static/real.yaml") + + mock_run_git_command.side_effect = GitCommandError("ls-files exploded") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_for_non_utf8_content( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file whose bytes are not valid UTF-8 must not raise — return None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "binary.bin" + stub.write_bytes(b"\xff\xfe\x00\xff") + + mock_run_git_command.return_value = "120000 abc123 0\tbinary.bin" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_preserves_whitespace_in_target( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Only trailing CR/LF is stripped — internal whitespace is preserved.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + target_dir = repo_dir / "dir with spaces" + target_dir.mkdir() + target = target_dir / "real.yaml" + target.write_text("hello") + + stub = repo_dir / "link.yaml" + # Trailing newline (as git's checkout may append) is stripped, but + # whitespace inside the target path itself must survive. + stub.write_bytes(b"dir with spaces/real.yaml\n") + + mock_run_git_command.return_value = "120000 abc123 0\tlink.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + + +def test_resolve_symlink_stub_returns_none_for_directory_target( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A symlink pointing at a directory has no file content to load.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "dir_target").mkdir() + + stub = repo_dir / "link_to_dir" + stub.write_text("dir_target") + + mock_run_git_command.return_value = "120000 abc123 0\tlink_to_dir" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_resolve_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Path.resolve() raising (e.g. on a malformed target) must not propagate.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "broken.yaml" + stub.write_text("ignored") + + mock_run_git_command.return_value = "120000 abc123 0\tbroken.yaml" + + with ( + patch("esphome.git.sys.platform", "win32"), + patch.object(Path, "resolve", side_effect=OSError("bad path")), + ): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_file_missing( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file path that doesn't exist is rejected before git is consulted.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + missing = repo_dir / "ghost.yaml" # not created + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, missing) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_when_path_outside_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file path that isn't under repo_dir is rejected (ValueError from relative_to).""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + outside = tmp_path / "stray.yaml" + outside.write_text("something") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, outside) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_when_untracked( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Empty `git ls-files` output (untracked file) makes the helper return None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "untracked.yaml" + stub.write_text("static/foo.yaml") + + mock_run_git_command.return_value = "" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_read_bytes_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """An OSError from read_bytes() (e.g. file vanished mid-call) must not propagate.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "racy.yaml" + stub.write_text("static/racy.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tracy.yaml" + + with ( + patch("esphome.git.sys.platform", "win32"), + patch.object(Path, "read_bytes", side_effect=OSError("vanished")), + ): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 4783112578..4ff857951f 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -838,3 +838,86 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None: assert isinstance(result["value"], Lambda) assert result["value"].value == 'return "bar";' + + +@patch("esphome.git.resolve_symlink_stub") +@patch("esphome.git.clone_or_update") +def test_remote_package_symlink_stub_is_followed( + mock_clone_or_update: MagicMock, + mock_resolve_symlink_stub: MagicMock, + tmp_path: Path, +) -> None: + """When a package YAML is a scalar (symlink stub) and resolve_symlink_stub + returns a target, the loader follows the target and uses its content.""" + CORE.config_path = tmp_path / "test.yaml" + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "static").mkdir() + + # Stub file: content is the target path string (simulating Windows behavior). + stub = repo_dir / "file1.yaml" + stub.write_text("static/file1.yaml") + + # Real target with valid YAML mapping. + target = repo_dir / "static" / "file1.yaml" + target.write_text("substitutions:\n hello: world\n") + + mock_clone_or_update.return_value = (repo_dir, None) + mock_resolve_symlink_stub.return_value = target + + config: dict[str, Any] = { + "packages": { + "test_package": { + "url": "https://github.com/esphome/repo1", + "ref": "main", + "files": ["file1.yaml"], + } + } + } + + # Must succeed (does not raise the helpful cv.Invalid) because the stub + # was followed and a valid mapping was loaded from the target. + do_packages_pass(config) + assert mock_resolve_symlink_stub.called + + +@patch("esphome.git.clone_or_update") +def test_remote_package_scalar_yaml_raises_helpful_error( + mock_clone_or_update: MagicMock, tmp_path: Path +) -> None: + """A remote package YAML that is a top-level scalar (e.g. an unmaterialized + git symlink on Windows) raises a clear cv.Invalid, not AttributeError. + + Regression test for the case where a repo containing a YAML symlink, + checked out on Windows without symlink privilege, lands as a short text + file containing the symlink target path. PyYAML parses that as a bare + string scalar; the package loader must reject it with a human-readable + error instead of dying inside ``.get()``. + """ + CORE.config_path = tmp_path / "test.yaml" + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + # Simulate the broken-symlink state: a YAML file whose entire content is + # the symlink target string. PyYAML parses this as a top-level scalar. + (repo_dir / "file1.yaml").write_text("static/file1.yaml") + + mock_clone_or_update.return_value = (repo_dir, None) + + config: dict[str, Any] = { + "packages": { + "test_package": { + "url": "https://github.com/esphome/repo1", + "ref": "main", + "files": ["file1.yaml"], + } + } + } + + with pytest.raises(cv.Invalid) as exc_info: + do_packages_pass(config) + + msg = str(exc_info.value) + assert "mapping at the top level" in msg + assert "file1.yaml" in msg From f9aba18f8e992581bf9c70a9f24a3c9d81c56110 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 02:57:44 -0500 Subject: [PATCH 1504/2214] [libretiny] Fix RTL8710B IRAM_ATTR section being dropped from flashed image (#16616) --- esphome/components/libretiny/hal.h | 24 +++++---- .../libretiny/patch_linker.py.script | 54 +++++++++++++++---- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 9c512504b7..01a7b5450b 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -11,11 +11,19 @@ #include "esphome/core/time_64.h" // IRAM_ATTR places a function in executable RAM so it is callable from an -// ISR even while flash is busy (XIP stall, OTA, logger flash write). -// Each family uses a section its stock linker already routes to RAM: -// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the -// exception: its stock linker has no matching glob, so patch_linker.py -// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// ISR even while flash is busy (XIP stall, OTA, logger flash write). All +// LibreTiny families that need it share the same .sram.text input section +// name; how that section is routed into RAM differs per family: +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the +// top of .ram_image2.data (which IS in ltchiptool's +// sections_ram). The stock linker has KEEP(*(.image2.ram.text*)) +// in .ram_image2.text but that output section is NOT in +// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed +// there is dropped from the flashed binary. +// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into +// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors)) +// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR. // // BK72xx (all variants) are left as a no-op: their SDK wraps flash // operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for @@ -26,13 +34,7 @@ // layer. #if defined(USE_BK72XX) #define IRAM_ATTR -#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) -// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). -#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) #else -// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. -// LN882H: patch_linker.py.script injects *(.sram.text*) into -// .flash_copysection (> RAM0 AT> FLASH). #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #endif #define PROGMEM diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 3a8a4787ed..dfeaaa57d1 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -6,12 +6,18 @@ import re import subprocess # ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family -# section routed into RAM-executable memory (see esphome/core/hal.h). +# section routed into RAM-executable memory (see esphome/core/hal.h). The +# input section name is always .sram.text; only the output section it lands +# in differs per family. # # This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK # masks FIQ+IRQ around flash writes). On the remaining families: -# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. -# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text, +# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list +# .ram_image2.text in sections_ram, so code there is silently dropped from +# the flashed image. Inject KEEP(*(.sram.text*)) at the top of +# .ram_image2.data (which IS extracted) instead. # - LN882H: stock linker has no glob for ".sram.text", so we inject # KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) # immediately after KEEP(*(.vectors)), so the vector table stays at @@ -34,6 +40,20 @@ _KEEP_LINE = ( # aligned address; injecting before the vectors would push them to an # unaligned offset and mis-route every IRQ handler. _LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") +# Inject at the top of .ram_image2.data, before __data_start__ so our code +# does not fall inside the data range markers. .ram_image2.data is one of the +# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is +# executable. AmbZ has no C runtime .data copy loop (the bootloader loads +# image2 into BD_RAM whole) so the inline code is not clobbered after boot. +# +# The regex is intentionally strict (no attribute / ALIGN between the section +# name and the opening brace, brace on its own line). If a future AmbZ SDK +# linker template changes this format, _pre_link raises RuntimeError on the +# unpatched .ld file(s), and the RTL8710B CI compile job in +# tests/test_build_components fails on the PR, surfacing the mismatch loudly +# rather than silently shipping a binary with IRAM_ATTR code dropped from +# one or both OTA slots. +_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)") def _detect(env): @@ -71,12 +91,11 @@ def _inject_keep(host_section): # Variants not listed here intentionally have no .ld patcher: -# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker -# already routes into .ram_image2.text (> BD_RAM). -# - RTL8720C: stock linker already consumes *(.sram.text*). +# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text. # - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. _PATCHERS_BY_VARIANT = { "LN882H": (_inject_keep(_LN_COPY),), + "RTL8710B": (_inject_keep(_AMBZ_DATA),), } @@ -87,13 +106,14 @@ def _patchers_for(variant): def _pre_link(target, source, env): build_dir = env.subst("$BUILD_DIR") ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] - patched = 0 + patched = [] + unpatched = [] for name in ld_files: path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: original = fh.read() if _MARKER in original: - patched += 1 + patched.append(name) continue content = original for fn in _patchers: @@ -102,7 +122,9 @@ def _pre_link(target, source, env): with open(path, "w", encoding="utf-8") as fh: fh.write(content) print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) - patched += 1 + patched.append(name) + else: + unpatched.append(name) if not patched: raise RuntimeError( "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " @@ -110,6 +132,20 @@ def _pre_link(target, source, env): build_dir ) ) + # Every .ld in the build must be patched. RTL8710B generates one .ld per + # OTA slot (xip1, xip2); if only one matches, the unpatched slot would + # ship with IRAM_ATTR code dropped to zeros and brick the device on the + # boot after an OTA into that slot. + if unpatched: + raise RuntimeError( + "ESPHome: {} of {} .ld file(s) in {} were not patched for " + "IRAM_ATTR: {}. The regex in patch_linker.py.script " + "(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not " + "these. Update the regex to cover all linker scripts.".format( + len(unpatched), len(ld_files), build_dir, + ", ".join(unpatched), _variant, + ) + ) # Substrings matched against demangled names as a fallback on RTL8720C, From 8e57894af709aa174971ac05d2d5f598e00db94a Mon Sep 17 00:00:00 2001 From: Fyleo Date: Sat, 30 May 2026 05:03:42 +0200 Subject: [PATCH 1505/2214] [sx126x] fix a typo in image calibration on 863 - 870 Mhz frequency (#16731) --- esphome/components/sx126x/sx126x.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 83afeac50a..aed0105e1f 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -394,7 +394,7 @@ void SX126x::run_image_cal() { buf[1] = 0xE9; } else if (this->frequency_ > 850000000) { buf[0] = 0xD7; - buf[1] = 0xD8; + buf[1] = 0xDB; } else if (this->frequency_ > 770000000) { buf[0] = 0xC1; buf[1] = 0xC5; From a4d247fa0a47d935cc006b0748ebe7acc170c4eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 May 2026 00:09:07 -0500 Subject: [PATCH 1506/2214] [core] Persist esphome.area in StorageJSON (#16710) --- esphome/core/config.py | 1 + esphome/storage_json.py | 7 +++++ tests/unit_tests/core/test_config.py | 27 +++++++++++++++++++ tests/unit_tests/test_storage_json.py | 38 +++++++++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index 5a98b94781..e4298b0865 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -711,6 +711,7 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: + CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 7f8885ba5f..dc1576ab18 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -100,6 +100,7 @@ class StorageJSON: framework: str | None = None, core_platform: str | None = None, toolchain: str | None = None, + area: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -138,6 +139,8 @@ class StorageJSON: self.core_platform = core_platform # The toolchain used for the build ("platformio" / "esp-idf") self.toolchain = toolchain + # The area of the node + self.area = area def as_dict(self): return { @@ -158,6 +161,7 @@ class StorageJSON: "framework": self.framework, "core_platform": self.core_platform, "toolchain": self.toolchain, + "area": self.area, } def to_json(self): @@ -195,6 +199,7 @@ class StorageJSON: framework=esph.target_framework, core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, + area=esph.area, ) @staticmethod @@ -243,6 +248,7 @@ class StorageJSON: framework = storage.get("framework") core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") + area = storage.get("area") return StorageJSON( storage_version, name, @@ -261,6 +267,7 @@ class StorageJSON: framework, core_platform, toolchain, + area, ) @staticmethod diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 4ce862315d..39cd042a96 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -140,6 +140,33 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: } +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +@pytest.mark.parametrize( + ("fixture", "expected_area"), + [ + ("legacy_string_area.yaml", "Living Room"), + ("multiple_areas_devices.yaml", "Main Area"), + ], +) +async def test_to_code_records_core_area( + yaml_file: Callable[[str], Path], + fixture: str, + expected_area: str, +) -> None: + """``to_code`` records the node's area name on CORE for StorageJSON.""" + result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) + assert result is not None + assert CORE.area is None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + assert CORE.area == expected_area + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index ea37492cf4..2a6f22abb1 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -205,6 +205,7 @@ def test_storage_json_as_dict() -> None: no_mdns=True, framework="arduino", core_platform="esp32", + area="Living Room", ) result = storage.as_dict() @@ -233,6 +234,7 @@ def test_storage_json_as_dict() -> None: assert result["no_mdns"] is True assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" + assert result["area"] == "Living Room" def test_storage_json_to_json() -> None: @@ -309,6 +311,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" mock_core.toolchain = Toolchain.ESP_IDF + mock_core.area = "Living Room" with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -329,6 +332,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.framework == "esp-idf" assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" + assert result.area == "Living Room" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -729,3 +733,37 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: assert result is not None assert result.esphome_version == "1.14.0" # Should map to esphome_version + + +def test_storage_json_load_area(tmp_path: Path) -> None: + """``area`` round-trips through load; absence loads as None.""" + file_path = tmp_path / "with_area.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + "area": "Living Room", + } + ) + ) + result = storage_json.StorageJSON.load(file_path) + assert result is not None + assert result.area == "Living Room" + + legacy_path = tmp_path / "no_area.json" + legacy_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + } + ) + ) + legacy = storage_json.StorageJSON.load(legacy_path) + assert legacy is not None + assert legacy.area is None From 571a12ffe5dbfd8967802b9352bf3cc3b02e07fa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 16:29:16 -0400 Subject: [PATCH 1507/2214] [core] Clean build when the toolchain changes (#16744) --- esphome/writer.py | 16 ++++++++++++---- tests/unit_tests/test_writer.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index ad3877465d..192c9d68e8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -90,10 +90,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: """Return True when the build tree must be wiped before reuse. Predicate is True when *old* is missing (first build), - ``src_version`` differs, ``build_path`` differs, or a previously - loaded integration was removed in *new*. Adding integrations or - changing unrelated fields (friendly name, esphome version, etc.) - does not trigger a clean. + ``src_version`` differs, ``build_path`` differs, the build + ``toolchain`` differs (e.g. switching between the PlatformIO and + native ESP-IDF toolchains, which produce incompatible build trees), + or a previously loaded integration was removed in *new*. Adding + integrations or changing unrelated fields (friendly name, esphome + version, etc.) does not trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -109,6 +111,8 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.build_path != new.build_path: return True + if old.toolchain != new.toolchain: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) @@ -505,6 +509,10 @@ def clean_build(clear_pio_cache: bool = True): if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) dependencies_lock.unlink() + idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") + if idedata_cache.is_file(): + _LOGGER.info("Deleting %s", idedata_cache) + idedata_cache.unlink() # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir # and the Component Manager's fetched managed components live under # the project's build path, not under .pioenvs / .piolibdeps. diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 91b4bd8e87..be37dd5d58 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -75,6 +75,7 @@ def create_storage() -> Callable[..., StorageJSON]: no_mdns=kwargs.get("no_mdns", False), framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), + toolchain=kwargs.get("toolchain", "platformio"), ) return _create @@ -106,6 +107,20 @@ def test_storage_should_clean_when_build_path_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_toolchain_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the build toolchain changes. + + Switching between the PlatformIO and native ESP-IDF toolchains produces + incompatible build trees (and toolchain-specific idedata), so the build + must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], toolchain="platformio") + new = create_storage(loaded_integrations=["api", "wifi"], toolchain="esp-idf") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: @@ -443,6 +458,11 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # idedata cache lives under the data dir, not the build path. + idedata_cache = tmp_path / "idedata" / "test.json" + idedata_cache.parent.mkdir() + idedata_cache.write_text("{}") + # Native ESP-IDF toolchain artifacts. idf_build_dir = tmp_path / "build" idf_build_dir.mkdir() @@ -463,11 +483,14 @@ def test_clean_build( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.name = "test" + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert idedata_cache.exists() assert idf_build_dir.exists() assert managed_components_dir.exists() assert platformio_cache_dir.exists() @@ -492,6 +515,7 @@ def test_clean_build( assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() assert not platformio_cache_dir.exists() @@ -501,6 +525,7 @@ def test_clean_build( assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert str(idedata_cache) in caplog.text assert str(idf_build_dir) in caplog.text assert str(managed_components_dir) in caplog.text assert "PlatformIO cache" in caplog.text From 559cfd1555f4af48687b3898b2fc281bd0b33942 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 15:32:23 -0500 Subject: [PATCH 1508/2214] [api] Fix crash loop on VoiceAssistantConfigurationRequest (#16757) --- esphome/components/api/api_connection.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f2bf3752fa..cd5b3fd694 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1306,6 +1306,9 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) { VoiceAssistantConfigurationResponse resp; if (!this->check_voice_assistant_api_connection_()) { + // send_message encodes synchronously, so this stack local outlives the encode + const std::vector empty_wake_words; + resp.active_wake_words = &empty_wake_words; return this->send_message(resp); } From 070c14b04a10d986ee4aab16403c4a08be7b9ac9 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:33:41 +1200 Subject: [PATCH 1509/2214] Bump version to 2026.5.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 30ae42ea2c..3dfe6c5ed4 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.5.1 +PROJECT_NUMBER = 2026.5.2 # 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 39c5c6b60e..fdbbbe5eab 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.1" +__version__ = "2026.5.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 1740e541053b007fd47d1504a42dfe1443ad055a Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 1 Jun 2026 20:20:18 -0700 Subject: [PATCH 1510/2214] [ci] Fix auto label platform restructure false positive (#16734) Co-authored-by: Claude --- .github/scripts/auto-label-pr/detectors.js | 8 + .github/scripts/auto-label-pr/package.json | 7 + .../auto-label-pr/tests/detectors.test.js | 147 ++++++++++++++++++ .github/workflows/ci-github-scripts.yml | 27 ++++ 4 files changed, 189 insertions(+) create mode 100644 .github/scripts/auto-label-pr/package.json create mode 100644 .github/scripts/auto-label-pr/tests/detectors.test.js create mode 100644 .github/workflows/ci-github-scripts.yml diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 410c1a53c0..81bb77843d 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -107,6 +107,8 @@ async function detectNewPlatforms(github, context, prFiles, apiData) { /^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/, ]; + const removedFiles = new Set(prFiles.filter(file => file.status === 'removed').map(file => file.filename)); + for (const file of addedFiles) { for (const re of platformPathPatterns) { const match = file.match(re); @@ -114,6 +116,12 @@ async function detectNewPlatforms(github, context, prFiles, apiData) { const platform = match[2]; if (!apiData.platformComponents.includes(platform)) break; + // Skip if this is a restructure between flat and subdirectory forms (either direction): + // /.py <-> //__init__.py + const flatEquivalent = `esphome/components/${match[1]}/${platform}.py`; + const subdirEquivalent = `esphome/components/${match[1]}/${platform}/__init__.py`; + if (removedFiles.has(flatEquivalent) || removedFiles.has(subdirEquivalent)) break; + labels.add('new-platform'); const content = await fetchPrFileContent(github, context, file); if (content === null) { diff --git a/.github/scripts/auto-label-pr/package.json b/.github/scripts/auto-label-pr/package.json new file mode 100644 index 0000000000..401b376db6 --- /dev/null +++ b/.github/scripts/auto-label-pr/package.json @@ -0,0 +1,7 @@ +{ + "name": "auto-label-pr", + "private": true, + "scripts": { + "test": "node --test tests/*.test.js" + } +} diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js new file mode 100644 index 0000000000..02d69ca95e --- /dev/null +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -0,0 +1,147 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { detectNewPlatforms, detectNewComponents } = require('../detectors'); + +// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents +// to check for CONFIG_SCHEMA in newly added files. +function makeGithub(content = '') { + return { + rest: { + repos: { + getContent: async () => ({ + data: { content: Buffer.from(content).toString('base64') } + }) + } + } + }; +} + +const CONTEXT = { + repo: { owner: 'esphome', repo: 'esphome' }, + payload: { pull_request: { head: { sha: 'abc123' }, base: { ref: 'dev' } } } +}; + +const API_DATA = { + targetPlatforms: ['esp32', 'esp8266', 'rp2040'], + platformComponents: ['cover', 'sensor', 'binary_sensor', 'switch', 'light', 'fan', 'climate', 'valve'] +}; + +const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})'; +const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; + +// --------------------------------------------------------------------------- +// detectNewPlatforms +// --------------------------------------------------------------------------- + +describe('detectNewPlatforms', () => { + describe('restructure detection (no false positives)', () => { + it('flat .py -> subdir __init__.py is not a new platform', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover.py', status: 'removed' }, + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + + it('subdir __init__.py -> flat .py is not a new platform', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'removed' }, + { filename: 'esphome/components/endstop/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + }); + + describe('genuine new platforms', () => { + it('new subdir platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new flat platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new platform without CONFIG_SCHEMA sets new-platform but not hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, false); + }); + + it('non-platform file addition produces no labels', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/sensor.py', status: 'added' }, + ]; + // Override platformComponents so 'sensor' is not a recognized platform -> no label expected. + const nonPlatformApiData = { ...API_DATA, platformComponents: ['cover'] }; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, nonPlatformApiData); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// detectNewComponents +// --------------------------------------------------------------------------- + +describe('detectNewComponents', () => { + it('new top-level __init__.py sets new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/actuator/__init__.py', status: 'added', }, + ]; + const result = await detectNewComponents(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.equal(result.hasYamlLoadable, false); + }); + + it('new top-level __init__.py with CONFIG_SCHEMA sets hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_component/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new top-level __init__.py with IS_TARGET_PLATFORM sets new-target-platform', async () => { + const prFiles = [ + { filename: 'esphome/components/my_platform/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub('IS_TARGET_PLATFORM = True'), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.ok(result.labels.has('new-target-platform')); + }); + + it('modified __init__.py does not set new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/existing/__init__.py', status: 'modified' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.equal(result.labels.size, 0); + }); + + it('nested __init__.py does not set new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.equal(result.labels.size, 0); + }); +}); diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml new file mode 100644 index 0000000000..6713fcc454 --- /dev/null +++ b/.github/workflows/ci-github-scripts.yml @@ -0,0 +1,27 @@ +name: CI - GitHub Scripts + +on: + push: + branches: [dev, beta, release] + paths: + - ".github/scripts/**" + - ".github/workflows/ci-github-scripts.yml" + pull_request: + paths: + - ".github/scripts/**" + - ".github/workflows/ci-github-scripts.yml" + +permissions: + contents: read + +jobs: + test-auto-label-pr: + name: Test auto-label-pr scripts + runs-on: ubuntu-latest + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run tests + working-directory: .github/scripts/auto-label-pr + run: npm test From 063770bcf403f6a8f91b59ac85112405f36ec385 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 2 Jun 2026 09:32:27 -0400 Subject: [PATCH 1511/2214] [i2s_audio] Fix speaker DMA buffer sizing and validate bit depth at compile time (#16672) --- esphome/components/i2s_audio/__init__.py | 2 +- .../components/i2s_audio/speaker/__init__.py | 33 ++++++----- .../speaker/i2s_audio_speaker_standard.cpp | 55 +++++++++++++++++-- 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 951b8c0498..8e432695a1 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -170,7 +170,7 @@ def i2s_audio_component_schema( min=1 ), cv.Optional(CONF_BITS_PER_SAMPLE, default=default_bits_per_sample): cv.All( - _validate_bits, cv.one_of(*I2S_BITS_PER_SAMPLE) + _validate_bits, cv.int_, cv.one_of(*I2S_BITS_PER_SAMPLE) ), cv.Optional(CONF_I2S_MODE, default=CONF_PRIMARY): cv.one_of( *I2S_MODE_OPTIONS, lower=True diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 8215d8b518..5ba2f4b1a5 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -98,11 +98,19 @@ def _set_stream_limits(config): min_sample_rate=config.get(CONF_SAMPLE_RATE), max_sample_rate=config.get(CONF_SAMPLE_RATE), )(config) - elif config[CONF_I2S_MODE] == CONF_PRIMARY: - # Primary mode has modifiable stream settings + return config + + # The original ESP32 cannot lay out sub-16-bit slots that match ESPHome's packed audio, so the smallest + # stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit. + min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8 + + if config[CONF_I2S_MODE] == CONF_PRIMARY: + # Primary mode can reconfigure the bus to the incoming sample rate and channel count, but the + # configured bits per sample is a hard ceiling: the speaker rejects any stream that exceeds the + # slot bit width it was set up with (see start_i2s_driver), so advertise that as the maximum. audio.set_stream_limits( - min_bits_per_sample=8, - max_bits_per_sample=32, + min_bits_per_sample=min_bits_per_sample, + max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], min_channels=1, max_channels=2, min_sample_rate=16000, @@ -111,13 +119,13 @@ def _set_stream_limits(config): else: # Secondary mode has unmodifiable max bits per sample and min/max sample rates audio.set_stream_limits( - min_bits_per_sample=8, - max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + min_bits_per_sample=min_bits_per_sample, + max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], min_channels=1, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), max_sample_rate=config.get(CONF_SAMPLE_RATE), - ) + )(config) return config @@ -134,12 +142,11 @@ def _validate_esp32_variant(config): if config[CONF_DAC_TYPE] == "internal": if variant not in INTERNAL_DAC_VARIANTS: raise cv.Invalid(f"{variant} does not have an internal DAC") - elif ( - variant == esp32.VARIANT_ESP32 - and config.get(CONF_BITS_PER_SAMPLE) == 8 - and config.get(CONF_CHANNEL) in (CONF_MONO, CONF_LEFT, CONF_RIGHT) - ): - raise cv.Invalid("8-bit mono mode is not supported on ESP32") + elif variant == esp32.VARIANT_ESP32 and config[CONF_BITS_PER_SAMPLE] == 8: + # The original ESP32 I2S peripheral packs each sample into a whole number of 16-bit words, so an + # 8-bit slot does not line up with ESPHome's tightly packed audio (see start_i2s_driver). Reject it + # at config time rather than emitting corrupted output at runtime. + raise cv.Invalid("8-bit audio is not supported on the original ESP32") return config diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index ffe901504d..0afb67fb36 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include +#include #include "esphome/components/audio/audio.h" #include "esphome/components/audio/audio_transfer_buffer.h" @@ -16,8 +17,16 @@ namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; -static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; -static constexpr size_t DMA_BUFFERS_COUNT = 4; +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 10; +static constexpr size_t DMA_BUFFERS_COUNT = 5; +// ESP-IDF clamps each DMA descriptor to this many bytes when allocating the channel (see i2s_get_buf_size in +// the I2S driver). Mirror its target-dependent selection so the requested dma_frame_num stays in range; the +// speaker task reads the size actually allocated back from the driver rather than relying on this value. +#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_64B_ALIGNED; +#else +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED; +#endif // Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, // doubled so that a transient backlog never overruns the queue (which would desync the lockstep // invariant between i2s_event_queue_ and write_records_queue_). @@ -27,6 +36,17 @@ static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2; // without masking real failures. static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1)); +// Requested frames per DMA buffer for the given stream, clamped so the byte size stays within the ESP-IDF +// maximum DMA descriptor size. This is only the value handed to the channel config: ESP-IDF may still adjust +// it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back +// from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a +// noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths. +static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) { + const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1); + return std::min(frames_from_duration, max_frames); +} + void I2SAudioSpeaker::dump_config() { I2SAudioSpeakerBase::dump_config(); const char *fmt_str; @@ -57,8 +77,21 @@ void I2SAudioSpeaker::run_speaker_task() { // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; - const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); - const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); + // ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it + // clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through + // the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually + // allocated so preload, silence padding, and the write/event lockstep all match it exactly. The channel is + // in the READY state here because start_i2s_driver() initialized it before this task was created. + size_t dma_buffer_bytes; + i2s_chan_info_t chan_info; + if (i2s_channel_get_info(this->tx_handle_, &chan_info) == ESP_OK && chan_info.total_dma_buf_size > 0) { + // total_dma_buf_size spans all DMA_BUFFERS_COUNT descriptors and is an exact multiple of the count. + dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; + } else { + // Should not happen for a READY channel; fall back to the requested size. + dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(dma_buffer_frames(this->current_stream_info_)); + } + const uint32_t frames_per_dma_buffer = this->current_stream_info_.bytes_to_frames(dma_buffer_bytes); bool successful_setup = false; @@ -308,12 +341,24 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_NOT_SUPPORTED; } +#ifdef USE_ESP32_VARIANT_ESP32 + // The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample + // occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples + // tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple + // of 16, so reject anything else rather than emit corrupted audio. + if (audio_stream_info.get_bits_per_sample() % 16 != 0) { + ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit audio, got %u-bit", + (unsigned) audio_stream_info.get_bits_per_sample()); + return ESP_ERR_NOT_SUPPORTED; + } +#endif // USE_ESP32_VARIANT_ESP32 + if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); return ESP_ERR_INVALID_STATE; } - uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + uint32_t dma_buffer_length = dma_buffer_frames(audio_stream_info); i2s_role_t i2s_role = this->i2s_role_; i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; From 792e1ff30466d798805a75ea0224847da98cab9b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:12:50 +1200 Subject: [PATCH 1512/2214] [i2c] Add basic host platform support (#14489) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/i2c/__init__.py | 119 +++++-- esphome/components/i2c/i2c_bus_host.cpp | 297 ++++++++++++++++++ esphome/components/i2c/i2c_bus_host.h | 41 +++ tests/components/i2c/test.host.yaml | 4 + .../common/i2c/host.yaml | 7 + 5 files changed, 449 insertions(+), 19 deletions(-) create mode 100644 esphome/components/i2c/i2c_bus_host.cpp create mode 100644 esphome/components/i2c/i2c_bus_host.h create mode 100644 tests/components/i2c/test.host.yaml create mode 100644 tests/test_build_components/common/i2c/host.yaml diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 1684f479ba..d9dd6d5ee2 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,4 +1,6 @@ import logging +import re +import sys from esphome import pins import esphome.codegen as cg @@ -29,6 +31,7 @@ from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, + CONF_DEVICE, CONF_FREQUENCY, CONF_I2C, CONF_I2C_ID, @@ -40,6 +43,7 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_HOST, PLATFORM_NRF52, PLATFORM_RP2040, PlatformFramework, @@ -56,6 +60,7 @@ InternalI2CBus = i2c_ns.class_("InternalI2CBus", I2CBus) ArduinoI2CBus = i2c_ns.class_("ArduinoI2CBus", InternalI2CBus, cg.Component) IDFI2CBus = i2c_ns.class_("IDFI2CBus", InternalI2CBus, cg.Component) ZephyrI2CBus = i2c_ns.class_("ZephyrI2CBus", I2CBus, cg.Component) +HostI2CBus = i2c_ns.class_("HostI2CBus", I2CBus, cg.Component) I2CDevice = i2c_ns.class_("I2CDevice") ESP32_I2C_CAPABILITIES = { @@ -83,6 +88,12 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" MULTI_CONF = True +def validate_device(value): + 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): if CORE.is_esp32: return cv.declare_id(IDFI2CBus)(value) @@ -90,6 +101,8 @@ def _bus_declare_type(value): return cv.declare_id(ArduinoI2CBus)(value) if CORE.using_zephyr: return cv.declare_id(ZephyrI2CBus)(value) + if CORE.is_host: + return cv.declare_id(HostI2CBus)(value) raise NotImplementedError @@ -121,15 +134,48 @@ def validate_config(config): return config +def validate_host_config(config): + if CORE.is_host: + # Host I2C is currently only supported on Linux + if not sys.platform.lower().startswith("linux"): + raise cv.Invalid( + "I2C is only supported on Linux for the host platform. " + f"Current platform: {sys.platform}" + ) + if CONF_SDA in config or CONF_SCL in config: + raise cv.Invalid( + "'sda' and 'scl' are not supported on host platform; use 'device' instead." + ) + if CONF_SDA_PULLUP_ENABLED in config or CONF_SCL_PULLUP_ENABLED in config: + raise cv.Invalid("Pull-up configuration is not supported on host platform.") + if CONF_DEVICE not in config: + raise cv.Invalid( + "'device' is required for host platform (e.g., /dev/i2c-0)." + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): _bus_declare_type, - cv.Optional(CONF_SDA, default="SDA"): pins.internal_gpio_pin_number, + cv.SplitDefault( + CONF_SDA, + esp32="SDA", + esp8266="SDA", + rp2040="SDA", + nrf52="SDA", + ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All( cv.only_on_esp32, cv.boolean ), - cv.Optional(CONF_SCL, default="SCL"): pins.internal_gpio_pin_number, + cv.SplitDefault( + CONF_SCL, + esp32="SCL", + esp8266="SCL", + rp2040="SCL", + nrf52="SCL", + ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All( cv.only_on_esp32, cv.boolean ), @@ -139,6 +185,7 @@ CONFIG_SCHEMA = cv.All( esp8266="50kHz", rp2040="50kHz", nrf52="100kHz", + host="50kHz", ): cv.All( cv.frequency, cv.float_range(min=0, min_included=False), @@ -155,10 +202,22 @@ CONFIG_SCHEMA = cv.All( ), cv.boolean, ), + cv.Optional(CONF_DEVICE): cv.All( + cv.only_on(PLATFORM_HOST), validate_device + ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_NRF52]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2040, + PLATFORM_NRF52, + PLATFORM_HOST, + ] + ), validate_config, + validate_host_config, ) @@ -217,7 +276,13 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") - if CORE.using_zephyr: + if CORE.is_host: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add(var.set_device(config[CONF_DEVICE])) + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + elif CORE.using_zephyr: zephyr_add_prj_conf("I2C", True) i2c = "i2c0" if zephyr_data()[KEY_BOARD] == "xiao_ble": @@ -244,25 +309,40 @@ async def to_code(config): var = cg.new_Pvariable( config[CONF_ID], MockObj(f"DEVICE_DT_GET(DT_NODELABEL({i2c}))") ) + await cg.register_component(var, config) + + cg.add(var.set_sda_pin(config[CONF_SDA])) + if CONF_SDA_PULLUP_ENABLED in config: + cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) + cg.add(var.set_scl_pin(config[CONF_SCL])) + if CONF_SCL_PULLUP_ENABLED in config: + cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) + + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + if CONF_TIMEOUT in config: + cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) else: var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) + await cg.register_component(var, config) - cg.add(var.set_sda_pin(config[CONF_SDA])) - if CONF_SDA_PULLUP_ENABLED in config: - cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) - cg.add(var.set_scl_pin(config[CONF_SCL])) - if CONF_SCL_PULLUP_ENABLED in config: - cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) + cg.add(var.set_sda_pin(config[CONF_SDA])) + if CONF_SDA_PULLUP_ENABLED in config: + cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) + cg.add(var.set_scl_pin(config[CONF_SCL])) + if CONF_SCL_PULLUP_ENABLED in config: + cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) - cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) - cg.add(var.set_scan(config[CONF_SCAN])) - if CONF_TIMEOUT in config: - cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) - if CORE.using_arduino and not CORE.is_esp32: - cg.add_library("Wire", None) - if CONF_LOW_POWER_MODE in config: - cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + if CONF_TIMEOUT in config: + cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) + if CORE.using_arduino and not CORE.is_esp32: + cg.add_library("Wire", None) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) def i2c_device_schema(default_address): @@ -365,5 +445,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "i2c_bus_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + "i2c_bus_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) diff --git a/esphome/components/i2c/i2c_bus_host.cpp b/esphome/components/i2c/i2c_bus_host.cpp new file mode 100644 index 0000000000..17279fda50 --- /dev/null +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -0,0 +1,297 @@ +#ifdef USE_HOST +#if defined(__linux__) + +#include "i2c_bus_host.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace esphome::i2c { + +static const char *const TAG = "i2c.host"; + +HostI2CBus::~HostI2CBus() { + if (this->file_descriptor_ != -1) { + close(this->file_descriptor_); + this->file_descriptor_ = -1; + } +} + +void HostI2CBus::setup() { + ESP_LOGCONFIG(TAG, "Setting up I2C bus..."); + + // Open I2C device file + this->file_descriptor_ = open(this->device_.c_str(), O_RDWR); + if (this->file_descriptor_ == -1) { + int err = errno; + if (err == ENOENT) { + this->update_error_("not found"); + } else if (err == EACCES) { + this->update_error_("permission denied"); + } else { + this->update_error_(std::string("failed to open: ") + strerror(err)); + } + this->mark_failed(); + return; + } + + this->initialized_ = true; + ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str()); + + // Run bus scan if enabled + if (this->scan_) { + this->i2c_scan_(); + } +} + +void HostI2CBus::dump_config() { + ESP_LOGCONFIG(TAG, "I2C Bus:"); + ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str()); + // Bus frequency cannot be set from userspace via i2c-dev; report it as informational only + ESP_LOGCONFIG(TAG, " Frequency: %u Hz (informational; not applied on host)", this->frequency_); + + if (!this->first_error_.empty()) { + ESP_LOGE(TAG, " Setup Error: %s", this->first_error_.c_str()); + } + + if (this->scan_) { + ESP_LOGI(TAG, " Scan Results:"); + for (const auto &s : this->scan_results_) { + if (s.second) { + ESP_LOGI(TAG, " 0x%02X: Found", s.first); + } + } + } +} + +ErrorCode HostI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { + if (!this->initialized_) { + ESP_LOGE(TAG, "I2C bus not initialized"); + return ERROR_NOT_INITIALIZED; + } + + ESP_LOGVV(TAG, "I2C write_readv addr=0x%02X write=%zu read=%zu", address, write_count, read_count); + + // Handle special case: probe (no write data, no read data) + // This is used for device detection during bus scanning + if (write_count == 0 && read_count == 0) { + struct i2c_msg msg; + msg.addr = address; + msg.flags = 0; + msg.len = 0; + msg.buf = nullptr; + + struct i2c_rdwr_ioctl_data rdwr_data; + rdwr_data.msgs = &msg; + rdwr_data.nmsgs = 1; + + int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data); + if (ret < 0) { + int err = errno; + // If I2C_RDWR not supported, try SMBus Quick command (what i2cdetect uses) + if (err == EOPNOTSUPP || err == ENOSYS) { + ESP_LOGVV(TAG, "I2C_RDWR probe failed, trying SMBus Quick for addr=0x%02X", address); + if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT + return this->map_errno_to_error_code_(errno); + } + // Use I2C_SMBUS ioctl with Quick command + union i2c_smbus_data data; + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_WRITE; + args.command = 0; + args.size = I2C_SMBUS_QUICK; + args.data = &data; + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + return this->map_errno_to_error_code_(errno); + } + return ERROR_OK; + } + return this->map_errno_to_error_code_(err); + } + return ERROR_OK; + } + + // i2c_msg.len is a 16-bit field; reject transfers that would silently truncate + if (write_count > UINT16_MAX || read_count > UINT16_MAX) { + ESP_LOGE(TAG, "I2C transfer too large: write=%zu read=%zu (max %u)", write_count, read_count, + (unsigned) UINT16_MAX); + return ERROR_TOO_LARGE; + } + + // Prepare messages for combined write-read transaction + struct i2c_msg msgs[2]; + int num_msgs = 0; + + // Add write message if write data present + if (write_count > 0) { + msgs[num_msgs].addr = address; + msgs[num_msgs].flags = 0; // Write + msgs[num_msgs].len = write_count; + msgs[num_msgs].buf = const_cast(write_buffer); + num_msgs++; + } + + // Add read message if read data requested + if (read_count > 0) { + msgs[num_msgs].addr = address; + msgs[num_msgs].flags = I2C_M_RD; // Read + msgs[num_msgs].len = read_count; + msgs[num_msgs].buf = read_buffer; + num_msgs++; + } + + // Execute I2C transaction + struct i2c_rdwr_ioctl_data rdwr_data; + rdwr_data.msgs = msgs; + rdwr_data.nmsgs = num_msgs; + + int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data); + if (ret < 0) { + int err = errno; + if (err == EOPNOTSUPP || err == ENOSYS) { + ESP_LOGV(TAG, "I2C_RDWR not supported, using I2C_SLAVE fallback for addr=0x%02X", address); // NOLINT + if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT + ESP_LOGV(TAG, "I2C_SLAVE ioctl failed: %s", strerror(errno)); // NOLINT + return this->map_errno_to_error_code_(errno); + } + // Perform write if needed + if (write_count > 0) { + ssize_t written = ::write(this->file_descriptor_, write_buffer, write_count); + if (written != (ssize_t) write_count) { + int write_err = errno; + // If write() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort + if (write_err == EOPNOTSUPP || write_err == ENOSYS) { + ESP_LOGV(TAG, "I2C_SLAVE write not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT + // Use I2C_SMBUS_I2C_BLOCK_DATA for writes up to 32 bytes + // Standard SMBus mapping: first byte is command, remaining bytes are data + if (write_count < 1) { + ESP_LOGE(TAG, "Write size too small for I2C_SMBUS"); + return ERROR_INVALID_ARGUMENT; + } + if (write_count > I2C_SMBUS_BLOCK_MAX + 1) { + ESP_LOGE(TAG, "Write size %zu exceeds I2C_SMBUS_BLOCK_MAX+1 (%d)", write_count, I2C_SMBUS_BLOCK_MAX + 1); + return ERROR_INVALID_ARGUMENT; + } + union i2c_smbus_data data; + // Standard SMBus: first byte = command, rest = data + uint8_t command = write_buffer[0]; + size_t data_len = write_count - 1; + data.block[0] = data_len; + if (data_len > 0) { + memcpy(&data.block[1], write_buffer + 1, data_len); + } + + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_WRITE; + args.command = command; + args.size = I2C_SMBUS_I2C_BLOCK_DATA; + args.data = &data; + + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + ESP_LOGV(TAG, "I2C_SMBUS write failed: %s", strerror(errno)); + return this->map_errno_to_error_code_(errno); + } + } else { + ESP_LOGV(TAG, "I2C write failed: %s", strerror(write_err)); + return this->map_errno_to_error_code_(write_err); + } + } + } + // Perform read if needed + if (read_count > 0) { + ssize_t bytes_read = ::read(this->file_descriptor_, read_buffer, read_count); + if (bytes_read != (ssize_t) read_count) { + int read_err = errno; + // If read() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort + if (read_err == EOPNOTSUPP || read_err == ENOSYS) { + ESP_LOGV(TAG, "I2C_SLAVE read not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT + // Use I2C_SMBUS_I2C_BLOCK_DATA for reads up to 32 bytes + if (read_count > I2C_SMBUS_BLOCK_MAX) { + ESP_LOGE(TAG, "Read size %zu exceeds I2C_SMBUS_BLOCK_MAX (%d)", read_count, I2C_SMBUS_BLOCK_MAX); + return ERROR_INVALID_ARGUMENT; + } + union i2c_smbus_data data; + data.block[0] = read_count; + + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_READ; + args.command = 0; // Start register/command + args.size = I2C_SMBUS_I2C_BLOCK_DATA; + args.data = &data; + + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + ESP_LOGV(TAG, "I2C_SMBUS read failed: %s", strerror(errno)); + return this->map_errno_to_error_code_(errno); + } + // I2C_SMBUS_I2C_BLOCK_DATA returns the actual byte count in block[0]; + // a short read means we did not receive all requested bytes + if (data.block[0] < read_count) { + ESP_LOGV(TAG, "I2C_SMBUS short read: got %u, expected %zu", data.block[0], read_count); + return ERROR_NOT_ACKNOWLEDGED; + } + // Copy data from SMBus buffer to output buffer + memcpy(read_buffer, &data.block[1], read_count); + } else { + ESP_LOGV(TAG, "I2C read failed: %s", strerror(read_err)); + return this->map_errno_to_error_code_(read_err); + } + } + } + ESP_LOGVV(TAG, "I2C transaction successful (I2C_SLAVE method)"); // NOLINT + return ERROR_OK; + } + ESP_LOGV(TAG, "I2C transaction failed: %s", strerror(err)); + return this->map_errno_to_error_code_(err); + } + + ESP_LOGVV(TAG, "I2C transaction successful"); + return ERROR_OK; +} + +ErrorCode HostI2CBus::map_errno_to_error_code_(int err) { + switch (err) { + case ENXIO: + return ERROR_NOT_ACKNOWLEDGED; + case ETIMEDOUT: + return ERROR_TIMEOUT; + case EINVAL: + return ERROR_INVALID_ARGUMENT; + case ENODEV: + case ENOTTY: + return ERROR_NOT_INITIALIZED; + case EOPNOTSUPP: + case ENOSYS: + // Operation not supported - some I2C adapters don't support zero-length transactions + ESP_LOGVV(TAG, "I2C adapter does not support this operation (likely zero-length probe)"); + return ERROR_NOT_ACKNOWLEDGED; + default: + ESP_LOGV(TAG, "Unmapped error code: %d (%s)", err, strerror(err)); + return ERROR_UNKNOWN; + } +} + +void HostI2CBus::update_error_(const std::string &error) { + if (this->first_error_.empty()) { + this->first_error_ = error; + } + ESP_LOGE(TAG, "[%s] %s", this->device_.c_str(), error.c_str()); +} + +} // namespace esphome::i2c + +#else +#error "HostI2CBus is only supported on Linux" +#endif // defined(__linux__) +#endif // USE_HOST diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h new file mode 100644 index 0000000000..8e3aff7977 --- /dev/null +++ b/esphome/components/i2c/i2c_bus_host.h @@ -0,0 +1,41 @@ +#pragma once + +#ifdef USE_HOST + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "i2c_bus.h" + +namespace esphome::i2c { + +class HostI2CBus : public I2CBus, public Component { + public: + ~HostI2CBus() override; + + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override; + + void set_device(const std::string &device) { this->device_ = device; } + void set_scan(bool scan) { this->scan_ = scan; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } + + const std::string &get_device() const { return this->device_; } + + protected: + void update_error_(const std::string &error); + ErrorCode map_errno_to_error_code_(int err); + + std::string device_; + uint32_t frequency_{50000}; + int file_descriptor_{-1}; + bool initialized_{false}; + std::string first_error_; +}; + +} // namespace esphome::i2c + +#endif // USE_HOST diff --git a/tests/components/i2c/test.host.yaml b/tests/components/i2c/test.host.yaml new file mode 100644 index 0000000000..6ae617e230 --- /dev/null +++ b/tests/components/i2c/test.host.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/host.yaml + +<<: !include common.yaml diff --git a/tests/test_build_components/common/i2c/host.yaml b/tests/test_build_components/common/i2c/host.yaml new file mode 100644 index 0000000000..00bad206d8 --- /dev/null +++ b/tests/test_build_components/common/i2c/host.yaml @@ -0,0 +1,7 @@ +# Common I2C configuration for host platform tests + +i2c: + - id: i2c_bus + device: /dev/i2c-0 + frequency: 100kHz + scan: true From 997ab116876c73ccc14c61f5e0735d6050f7671a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:21:33 +1000 Subject: [PATCH 1513/2214] [lvgl][mipi_spi][mipi_rgb][mipi_dsi][display] Metadata (#16702) --- esphome/components/display/__init__.py | 102 ++++++++-- esphome/components/lvgl/__init__.py | 116 ++++++------ esphome/components/mipi_dsi/display.py | 17 +- esphome/components/mipi_rgb/display.py | 17 +- esphome/components/mipi_spi/display.py | 28 ++- .../display/test_display_metadata.py | 130 ++++++++++--- tests/component_tests/lvgl/test_validation.py | 177 ++++++++++++++++++ .../mipi_spi/test_display_metadata.py | 106 ++++------- 8 files changed, 520 insertions(+), 173 deletions(-) create mode 100644 tests/component_tests/lvgl/test_validation.py diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 744b5d16c4..7a66da11f2 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -3,11 +3,18 @@ from dataclasses import dataclass from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg -from esphome.components.const import KEY_METADATA +from esphome.components.const import ( + BYTE_ORDER_BIG, + CONF_BYTE_ORDER, + CONF_DRAW_ROUNDING, + KEY_METADATA, +) import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, + CONF_DIMENSIONS, CONF_FROM, + CONF_HEIGHT, CONF_ID, CONF_LAMBDA, CONF_PAGE_ID, @@ -16,10 +23,11 @@ from esphome.const import ( CONF_TO, CONF_TRIGGER_ID, CONF_UPDATE_INTERVAL, + CONF_WIDTH, SCHEDULER_DONT_RUN, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.final_validate import full_config DOMAIN = "display" IS_PLATFORM_COMPONENT = True @@ -159,29 +167,97 @@ async def setup_display_core_(var, config): class DisplayMetaData: width: int = 0 height: int = 0 - has_writer: bool = False has_hardware_rotation: bool = False + byte_order: str = BYTE_ORDER_BIG + has_writer: bool = False + rotation: int = 0 + draw_rounding: int = 0 + + +def _get_metadata_list() -> list[tuple]: + """Get the raw metadata list. Each entry is (id, DisplayMetaData).""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, []) def get_all_display_metadata() -> dict[str, DisplayMetaData]: - """Get all display metadata.""" - return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) + """Get all display metadata as a dict keyed by resolved ID strings. + + Must not be called before IDs have been finalised. + """ + entries = _get_metadata_list() + assert all(id_.id is not None for id_, _ in entries), ( + "get_all_display_metadata called before display IDs have been resolved" + ) + return {id_.id: meta for id_, meta in entries} -def get_display_metadata(display_id: str) -> DisplayMetaData | None: - """Get display metadata by ID for use by other components.""" - return get_all_display_metadata().get(display_id, DisplayMetaData()) +def get_display_metadata(display_id: ID) -> DisplayMetaData: + """Get display metadata by ID object + + Must not be called before IDs have been finalised. + """ + for id_, meta in _get_metadata_list(): + if id_ is display_id: + return meta + assert id_.id is not None, ( + "get_display_metadata called before display IDs have been resolved" + ) + if id_.id == display_id.id: + return meta + # No metadata found, display driver may not yet support it. + # Read the raw config to populate the returned data + global_config = full_config.get() + path = global_config.get_path_for_id(display_id)[:-1] + disp_config = global_config.get_config_for_path(path) + dimensions = disp_config.get(CONF_DIMENSIONS, (0, 0)) + if isinstance(dimensions, dict): + dimensions = (dimensions.get(CONF_WIDTH, 0), dimensions.get(CONF_HEIGHT, 0)) + elif not isinstance(dimensions, tuple) or len(dimensions) != 2: + dimensions = (0, 0) + + meta = DisplayMetaData( + width=dimensions[0], + height=dimensions[1], + has_hardware_rotation=False, + byte_order=disp_config.get(CONF_BYTE_ORDER, cv.UNDEFINED), + has_writer=disp_config.get(CONF_AUTO_CLEAR_ENABLED) is True + or disp_config.get(CONF_PAGES) is not None + or disp_config.get(CONF_LAMBDA) is not None + or disp_config.get(CONF_SHOW_TEST_CARD) is True, + rotation=disp_config.get(CONF_ROTATION, 0), + draw_rounding=disp_config.get(CONF_DRAW_ROUNDING, 0), + ) + _get_metadata_list().append((display_id, meta)) + return meta def add_metadata( - id: str | MockObj, + id: ID, width: int, height: int, - has_writer: bool, has_hardware_rotation: bool = False, + byte_order: str = BYTE_ORDER_BIG, + has_writer: bool = False, + rotation: int = 0, + draw_rounding: int = 0, ): - get_all_display_metadata()[str(id)] = DisplayMetaData( - width, height, has_writer, has_hardware_rotation + entries = _get_metadata_list() + assert not any(existing_id is id for existing_id, _ in entries), ( + f"Duplicate display metadata for ID {id}" + ) + entries.append( + ( + id, + DisplayMetaData( + width=width, + height=height, + has_hardware_rotation=has_hardware_rotation, + byte_order=byte_order, + has_writer=has_writer, + rotation=rotation, + draw_rounding=draw_rounding, + ), + ) ) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6e005f897e..022d629960 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -7,6 +7,7 @@ import re from esphome.automation import Trigger, build_automation, validate_automation import esphome.codegen as cg from esphome.components.const import ( + BYTE_ORDER_BIG, CONF_BYTE_ORDER, CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING, @@ -30,12 +31,10 @@ from esphome.components.image import ( from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( - CONF_AUTO_CLEAR_ENABLED, CONF_BUFFER_SIZE, CONF_ESPHOME, CONF_GROUP, CONF_ID, - CONF_LAMBDA, CONF_LOG_LEVEL, CONF_ON_IDLE, CONF_PAGES, @@ -214,61 +213,73 @@ def multi_conf_validate(configs: list[dict]): def final_validation(config_list): - if len(config_list) != 1: - multi_conf_validate(config_list) global_config = full_config.get() + # Resolve byte_order from display metadata before multi-config validation for config in config_list: + metas = [get_display_metadata(disp) for disp in config[df.CONF_DISPLAYS]] + if any(m.has_writer for m in metas): + raise cv.Invalid( + "Using lambda:, pages:, auto_clear_enabled: true, or show_test_card: true in display config is not compatible with LVGL" + ) + if any(m.rotation != 0 for m in metas): + raise cv.Invalid( + "use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead" + ) + config[CONF_DRAW_ROUNDING] = max( + [m.draw_rounding for m in metas] + [config[CONF_DRAW_ROUNDING]] + ) + display_byte_orders = { + m.byte_order for m in metas if m.byte_order is not cv.UNDEFINED + } + if len(display_byte_orders) > 1: + raise cv.Invalid( + "All displays configured for an LVGL instance must use the same byte_order" + ) + if display_byte_orders: + display_order = next(iter(display_byte_orders)) + if CONF_BYTE_ORDER in config: + if config[CONF_BYTE_ORDER] != display_order: + raise cv.Invalid( + "LVGL byte order must match the display byte order", + [CONF_BYTE_ORDER], + ) + else: + config[CONF_BYTE_ORDER] = display_order + if CONF_BYTE_ORDER not in config: + config[CONF_BYTE_ORDER] = BYTE_ORDER_BIG + if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages): raise cv.Invalid("At least one page must not be skipped") - for display_id in config[df.CONF_DISPLAYS]: - path = global_config.get_path_for_id(display_id)[:-1] - display = global_config.get_config_for_path(path) - if CONF_LAMBDA in display or CONF_PAGES in display: - raise cv.Invalid( - "Using lambda: or pages: in display config is not compatible with LVGL" - ) - # treating 0 as false is intended here. - if display.get(CONF_ROTATION): - raise cv.Invalid( - "use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead" - ) - if display.get(CONF_AUTO_CLEAR_ENABLED) is True: - raise cv.Invalid( - "Using auto_clear_enabled: true in display config not compatible with LVGL" - ) - if draw_rounding := display.get(CONF_DRAW_ROUNDING): - config[CONF_DRAW_ROUNDING] = max( - draw_rounding, config[CONF_DRAW_ROUNDING] - ) buffer_frac = config[CONF_BUFFER_SIZE] if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config: df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM") - for w in get_focused_widgets(): - path = global_config.get_path_for_id(w) - widget_conf = global_config.get_config_for_path(path[:-1]) - if ( - df.CONF_ADJUSTABLE in widget_conf - and not widget_conf[df.CONF_ADJUSTABLE] - ): - raise cv.Invalid( - "A non adjustable arc may not be focused", - path, - ) - for w in get_refreshed_widgets(): - path = global_config.get_path_for_id(w) - widget_conf = global_config.get_config_for_path(path[:-1]) - if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): - raise cv.Invalid( - f"Widget '{w}' does not have any dynamic properties to refresh", - ) - # Do per-widget type final validation for update actions - for widget_type, update_configs in df.get_updated_widgets().items(): - for conf in update_configs: - for id_conf in conf.get(CONF_ID, ()): - name = id_conf[CONF_ID] - path = global_config.get_path_for_id(name) - widget_conf = global_config.get_config_for_path(path[:-1]) - widget_type.final_validate(name, conf, widget_conf, path[1:]) + + if len(config_list) != 1: + multi_conf_validate(config_list) + + for w in get_focused_widgets(): + path = global_config.get_path_for_id(w) + widget_conf = global_config.get_config_for_path(path[:-1]) + if df.CONF_ADJUSTABLE in widget_conf and not widget_conf[df.CONF_ADJUSTABLE]: + raise cv.Invalid( + "A non adjustable arc may not be focused", + path, + ) + for w in get_refreshed_widgets(): + path = global_config.get_path_for_id(w) + widget_conf = global_config.get_config_for_path(path[:-1]) + if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): + raise cv.Invalid( + f"Widget '{w}' does not have any dynamic properties to refresh", + ) + # Do per-widget type final validation for update actions + for widget_type, update_configs in df.get_updated_widgets().items(): + for conf in update_configs: + for id_conf in conf.get(CONF_ID, ()): + name = id_conf[CONF_ID] + path = global_config.get_path_for_id(name) + widget_conf = global_config.get_config_for_path(path[:-1]) + widget_type.final_validate(name, conf, widget_conf, path[1:]) async def to_code(configs): @@ -367,8 +378,7 @@ async def to_code(configs): # options will have CONF_ROTATION true if rotation is changed in an automation. if CONF_ROTATION in config or df.get_options().get(CONF_ROTATION) is True: if all( - get_display_metadata(str(disp)).has_hardware_rotation - for disp in displays + get_display_metadata(disp).has_hardware_rotation for disp in displays ): rotation_type = RotationType.ROTATION_HARDWARE df.LOGGER.info("LVGL will use hardware rotation via display driver") @@ -583,7 +593,7 @@ LVGL_SCHEMA = cv.All( cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), - cv.Optional(CONF_BYTE_ORDER, default="big_endian"): cv.one_of( + cv.Optional(CONF_BYTE_ORDER): cv.one_of( "big_endian", "little_endian", lower=True ), cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 026c214569..3554e32299 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -37,6 +37,7 @@ from esphome.components.mipi import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_COLOR_ORDER, CONF_DIMENSIONS, CONF_DISABLED, @@ -167,7 +168,21 @@ def _config_schema(config): }, extra=cv.ALLOW_EXTRA, )(config) - return model_schema(config)(config) + config = model_schema(config)(config) + model = MODELS[config[CONF_MODEL].upper()] + width, height, _offset_width, _offset_height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_rotation=False, + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) + return config def _final_validate(config): diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 4952bda95f..b38ddad491 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -39,6 +39,7 @@ from esphome.components.rpi_dpi_rgb.display import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BLUE, CONF_COLOR_ORDER, CONF_CS_PIN, @@ -226,11 +227,25 @@ def _config_schema(config): extra=cv.ALLOW_EXTRA, )(config) schema = model_schema(config) - return cv.All( + config = cv.All( schema, cv.only_on_esp32, only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) + model = MODELS[config[CONF_MODEL].upper()] + width, height, _offset_width, _offset_height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + model.rotation_as_transform(config), + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) + return config CONFIG_SCHEMA = _config_schema diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 364ada9046..3c5a84594e 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -30,6 +30,7 @@ from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BRIGHTNESS, CONF_BUFFER_SIZE, CONF_COLOR_ORDER, @@ -47,6 +48,7 @@ from esphome.const import ( CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, + CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, @@ -267,6 +269,28 @@ def customise_schema(config): if bus_mode != TYPE_QUAD and CONF_DC_PIN not in config: raise cv.Invalid(f"DC pin is required in {bus_mode} mode") denominator(config) + model = MODELS[config[CONF_MODEL]] + has_hardware_transform = config.get( + CONF_TRANSFORM + ) != CONF_DISABLED and model.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + width, height, _offset_width, _offset_height = model.get_dimensions( + config, not has_hardware_transform + ) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_transform, + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) return config @@ -338,7 +362,6 @@ def get_instance(config): buffer_type = cg.uint8 if color_depth == 8 else cg.uint16 frac = denominator(config) madctl = model.get_madctl(model.get_base_transform(config), config) - has_writer = requires_buffer(config) templateargs = [ buffer_type, bufferpixels, @@ -352,9 +375,6 @@ def get_instance(config): madctl, has_hardware_transform, ] - display.add_metadata( - config[CONF_ID], width, height, has_writer, has_hardware_transform - ) # If a buffer is required, use MipiSpiBuffer, otherwise use MipiSpi if requires_buffer(config): templateargs.extend( diff --git a/tests/component_tests/display/test_display_metadata.py b/tests/component_tests/display/test_display_metadata.py index ef3f12cb73..befb019612 100644 --- a/tests/component_tests/display/test_display_metadata.py +++ b/tests/component_tests/display/test_display_metadata.py @@ -4,77 +4,145 @@ from unittest.mock import patch import pytest +from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE from esphome.components.display import ( DisplayMetaData, add_metadata, get_all_display_metadata, get_display_metadata, ) -from esphome.cpp_generator import MockObj +from esphome.config import Config +from esphome.core import ID +from esphome.final_validate import full_config -def test_add_metadata_with_string_id(): - """Test adding metadata with a plain string ID.""" +def test_add_metadata_basic(): + """Test adding metadata with an ID object.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("my_display", 320, 240, True) - meta = get_display_metadata("my_display") + add_metadata(ID("my_display"), 320, 240) + meta = get_display_metadata(ID("my_display")) assert meta == DisplayMetaData( - width=320, height=240, has_writer=True, has_hardware_rotation=False + width=320, + height=240, + has_hardware_rotation=False, + byte_order=BYTE_ORDER_BIG, ) -def test_add_metadata_with_mockobj_id(): - """Test adding metadata with a MockObj ID (converted via str()).""" +def test_add_metadata_with_all_fields(): + """Test adding metadata with all fields set.""" with patch("esphome.components.display.CORE.data", {}): - mock_id = MockObj("my_display_obj") - add_metadata(mock_id, 480, 320, False, has_hardware_rotation=True) - meta = get_display_metadata("my_display_obj") + add_metadata( + ID("my_display"), + 480, + 320, + has_hardware_rotation=True, + byte_order=BYTE_ORDER_LITTLE, + ) + meta = get_display_metadata(ID("my_display")) assert meta == DisplayMetaData( - width=480, height=320, has_writer=False, has_hardware_rotation=True + width=480, + height=320, + has_hardware_rotation=True, + byte_order=BYTE_ORDER_LITTLE, ) def test_add_metadata_hardware_rotation_default(): """Test that has_hardware_rotation defaults to False.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("disp", 128, 64, False) - meta = get_display_metadata("disp") + add_metadata(ID("disp"), 128, 64) + meta = get_display_metadata(ID("disp")) assert meta.has_hardware_rotation is False + assert meta.byte_order == BYTE_ORDER_BIG -def test_get_display_metadata_missing_returns_none(): - """Test that querying a non-existent ID returns None.""" +def test_add_metadata_with_byte_order(): + """Test adding metadata with explicit byte_order.""" with patch("esphome.components.display.CORE.data", {}): - data = get_display_metadata("no_such_display") - assert data.width == 0 - assert data.height == 0 - assert data.has_writer is False + add_metadata(ID("disp"), 240, 320, byte_order=BYTE_ORDER_LITTLE) + meta = get_display_metadata(ID("disp")) + assert meta.byte_order == BYTE_ORDER_LITTLE + + +def test_get_display_metadata_missing_reads_raw_config(): + """Querying a non-existent ID falls back to raw config lookup.""" + with patch("esphome.components.display.CORE.data", {}): + # Set up a minimal full_config with a display entry so the fallback + # path in get_display_metadata can find the display config. + fc = Config() + fc["display"] = [ + { + "id": ID("no_such_display", True), + "auto_clear_enabled": True, + "dimensions": {"width": 320, "height": 240}, + "byte_order": BYTE_ORDER_LITTLE, + "rotation": 90, + }, + { + "id": ID("other_display", True), + "auto_clear_enabled": "undefined", + "dimensions": (1024, 600), + }, + ] + fc.declare_ids.append((ID("no_such_display", True), ["display", 0, "id"])) + fc.declare_ids.append((ID("other_display", True), ["display", 1, "id"])) + full_config.set(fc) + data = get_display_metadata(ID("no_such_display")) + assert data.width == 320 + assert data.height == 240 assert data.has_hardware_rotation is False + assert data.has_writer is True + assert data.byte_order == BYTE_ORDER_LITTLE + assert data.rotation == 90 + + data = get_display_metadata(ID("other_display")) + assert data.width == 1024 + assert data.height == 600 + assert data.has_writer is False def test_add_multiple_displays(): """Test adding metadata for multiple displays.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("disp_a", 320, 240, True) - add_metadata("disp_b", 128, 64, False, has_hardware_rotation=True) + add_metadata(ID("disp_a"), 320, 240) + add_metadata(ID("disp_b"), 128, 64, has_hardware_rotation=True) all_meta = get_all_display_metadata() assert len(all_meta) == 2 - assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, False) - assert all_meta["disp_b"] == DisplayMetaData(128, 64, False, True) + assert all_meta["disp_a"] == DisplayMetaData(320, 240, False) + assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, BYTE_ORDER_BIG) -def test_add_metadata_overwrites_existing(): - """Test that adding metadata for the same ID overwrites the previous entry.""" +def test_add_duplicate_id_asserts(): + """Adding metadata for the same ID object twice should assert.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("disp", 320, 240, True) - add_metadata("disp", 640, 480, False, has_hardware_rotation=True) - meta = get_display_metadata("disp") - assert meta == DisplayMetaData(640, 480, False, True) + id_obj = ID("disp") + add_metadata(id_obj, 320, 240) + with pytest.raises(AssertionError, match="Duplicate"): + add_metadata(id_obj, 640, 480) def test_metadata_is_frozen(): """Test that DisplayMetaData instances are immutable (frozen dataclass).""" - meta = DisplayMetaData(320, 240, True, False) + meta = DisplayMetaData(320, 240, False, BYTE_ORDER_BIG) with pytest.raises(AttributeError): meta.width = 640 + with pytest.raises(AttributeError): + meta.byte_order = BYTE_ORDER_LITTLE + + +def test_get_all_metadata_asserts_on_unresolved_id(): + """get_all_display_metadata should assert if any ID has id=None.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata(ID(None), 320, 240) + with pytest.raises(AssertionError, match="resolved"): + get_all_display_metadata() + + +def test_get_metadata_asserts_on_unresolved_id(): + """get_display_metadata should assert if any ID has id=None.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata(ID(None), 320, 240) + with pytest.raises(AssertionError, match="resolved"): + get_display_metadata(ID("anything")) diff --git a/tests/component_tests/lvgl/test_validation.py b/tests/component_tests/lvgl/test_validation.py new file mode 100644 index 0000000000..9a767c0dae --- /dev/null +++ b/tests/component_tests/lvgl/test_validation.py @@ -0,0 +1,177 @@ +"""Tests for LVGL final_validation display metadata checks.""" + +from __future__ import annotations + +import pytest + +from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER +from esphome.components.display import add_metadata +from esphome.components.lvgl import final_validation +from esphome.config import Config +from esphome.config_validation import Invalid +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.core import CORE, ID +from esphome.final_validate import full_config + + +@pytest.fixture(autouse=True) +def _setup_core(): + """Ensure CORE.data has enough context for final_validation.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "host", + KEY_TARGET_FRAMEWORK: "", + } + full_config.set(Config()) + yield + CORE.reset() + + +def _register_displays(*display_ids: str) -> None: + """Register display IDs in full_config so get_path_for_id works.""" + fc = full_config.get() + display_list = [{"id": ID(d, True)} for d in display_ids] + fc["display"] = display_list + for i, disp_id in enumerate(display_ids): + fc.declare_ids.append((ID(disp_id, True), ["display", i, "id"])) + + +def _make_lvgl_config( + display_ids: list[str], + byte_order: str | None = None, +) -> dict: + """Build a minimal LVGL config dict for final_validation.""" + _register_displays(*display_ids) + config = { + "displays": [ID(d, True) for d in display_ids], + "log_level": "WARN", + "color_depth": 16, + "transparency_key": 0x000400, + "draw_rounding": 2, + "buffer_size": 0, + } + if byte_order is not None: + config[CONF_BYTE_ORDER] = byte_order + return config + + +class TestByteOrderAutoConfig: + """Test that LVGL auto-configures byte_order from display metadata.""" + + def test_inherits_big_endian_from_display(self) -> None: + """LVGL should inherit big_endian from display metadata.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG + + def test_inherits_little_endian_from_display(self) -> None: + """LVGL should inherit little_endian from display metadata.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE + + def test_defaults_to_big_endian_when_no_metadata(self) -> None: + """LVGL should default to big_endian when display has no metadata.""" + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG + + +class TestByteOrderExplicitMismatchError: + """Test that LVGL rejects explicit byte_order mismatch with display.""" + + def test_raises_on_mismatch(self) -> None: + """Explicit LVGL byte_order different from display should raise.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)] + with pytest.raises( + Invalid, match="LVGL byte order must match the display byte order" + ): + final_validation(configs) + + def test_no_error_when_matching(self) -> None: + """Explicit LVGL byte_order matching display should pass.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG) + configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)] + final_validation(configs) + + +class TestByteOrderMultipleDisplays: + """Test byte_order validation with multiple displays.""" + + def test_consistent_displays_inherit(self) -> None: + """All displays with same byte_order should set LVGL byte_order.""" + add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_LITTLE) + add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["disp_a", "disp_b"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE + + def test_inconsistent_displays_raises(self) -> None: + """Displays with different byte_order should raise an error.""" + add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_BIG) + add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["disp_a", "disp_b"])] + with pytest.raises(Invalid, match="same byte_order"): + final_validation(configs) + + +class TestHasWriterCheck: + """Test that LVGL rejects displays with has_writer set.""" + + def test_display_with_writer_raises(self) -> None: + """Display with lambda/pages/auto_clear should be rejected.""" + add_metadata(ID("my_disp"), 320, 240, has_writer=True) + configs = [_make_lvgl_config(["my_disp"])] + with pytest.raises(Invalid, match="not compatible with LVGL"): + final_validation(configs) + + def test_display_without_writer_passes(self) -> None: + """Display without writer should pass.""" + add_metadata(ID("my_disp"), 320, 240, has_writer=False) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + + +class TestRotationCheck: + """Test that LVGL rejects displays with non-zero rotation.""" + + def test_display_with_rotation_raises(self) -> None: + """Display with rotation should be rejected.""" + add_metadata(ID("my_disp"), 320, 240, rotation=90) + configs = [_make_lvgl_config(["my_disp"])] + with pytest.raises(Invalid, match="rotation.*not compatible with LVGL"): + final_validation(configs) + + def test_display_without_rotation_passes(self) -> None: + """Display with rotation=0 should pass.""" + add_metadata(ID("my_disp"), 320, 240, rotation=0) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + + +class TestDrawRoundingMerge: + """Test that display draw_rounding is merged into LVGL config.""" + + def test_display_draw_rounding_overrides_lower(self) -> None: + """Display draw_rounding higher than LVGL default should win.""" + add_metadata(ID("my_disp"), 320, 240, draw_rounding=8) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0]["draw_rounding"] == 8 + + def test_display_draw_rounding_does_not_lower(self) -> None: + """Display draw_rounding lower than LVGL config should not reduce it.""" + add_metadata(ID("my_disp"), 320, 240, draw_rounding=1) + configs = [_make_lvgl_config(["my_disp"])] + configs[0]["draw_rounding"] = 4 + final_validation(configs) + assert configs[0]["draw_rounding"] == 4 + + def test_zero_draw_rounding_no_change(self) -> None: + """Display with draw_rounding=0 should not affect LVGL config.""" + add_metadata(ID("my_disp"), 320, 240, draw_rounding=0) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0]["draw_rounding"] == 2 diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py index c11c7816e4..e7f5143d91 100644 --- a/tests/component_tests/mipi_spi/test_display_metadata.py +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -3,22 +3,15 @@ from collections.abc import Callable from pathlib import Path -from esphome.components.display import ( - DisplayMetaData, - get_all_display_metadata, - get_display_metadata, -) +from esphome.components.const import BYTE_ORDER_BIG +from esphome.components.display import get_all_display_metadata, get_display_metadata from esphome.components.esp32 import ( KEY_BOARD, KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32S3, ) -from esphome.components.mipi_spi.display import ( - CONFIG_SCHEMA, - FINAL_VALIDATE_SCHEMA, - get_instance, -) +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import PlatformFramework from tests.component_tests.types import SetCoreConfigCallable @@ -38,38 +31,32 @@ def test_metadata_native_quad_default_test_card( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) - config = validated_config({"model": "JC3636W518"}) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + config = CONFIG_SCHEMA({"model": "JC3636W518", "id": "jc3232w518"}) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.width == 360 assert meta.height == 360 - # final validation auto-enables show_test_card when no drawing methods are configured - assert meta.has_writer is True assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG def test_metadata_single_mode_with_dc_pin( set_core_config: SetCoreConfigCallable, ) -> None: - """A single-mode display with no explicit drawing gets a test card from final validation.""" + """A single-mode display with no explicit drawing gets metadata from schema validation.""" set_core_config( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, ) - config = validated_config( - { - "model": "ST7735", - "dc_pin": 18, - } + config = CONFIG_SCHEMA( + {"model": "ST7735", "dc_pin": 18, "id": "single_mode_with_dc_pin"} ) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.width == 128 assert meta.height == 160 - assert meta.has_writer is True assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG def test_metadata_custom_dimensions( @@ -80,47 +67,22 @@ def test_metadata_custom_dimensions( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, ) - config = validated_config( + config = CONFIG_SCHEMA( { "model": "custom", "dc_pin": 18, "dimensions": {"width": 480, "height": 320}, "init_sequence": [[0xA0, 0x01]], + "id": "custom_dimensions", } ) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.width == 480 assert meta.height == 320 - # final validation auto-enables show_test_card - assert meta.has_writer is True assert meta.has_hardware_rotation is True -def test_metadata_with_test_card_has_writer( - set_core_config: SetCoreConfigCallable, -) -> None: - """When show_test_card is enabled, has_writer should be True.""" - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) - config = validated_config( - { - "model": "custom", - "dc_pin": 18, - "dimensions": {"width": 240, "height": 240}, - "init_sequence": [[0xA0, 0x01]], - "show_test_card": True, - } - ) - get_instance(config) - meta = get_display_metadata(str(config["id"])) - assert meta is not None - assert meta.has_writer is True - - def test_metadata_no_swap_xy_not_full_hardware_rotation( set_core_config: SetCoreConfigCallable, ) -> None: @@ -130,9 +92,8 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation( platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) # JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only - config = validated_config({"model": "JC3248W535"}) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"}) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.has_hardware_rotation is False @@ -145,7 +106,7 @@ def test_metadata_multiple_displays_independent( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, ) - config_a = validated_config( + CONFIG_SCHEMA( { "id": "disp_a", "model": "custom", @@ -154,7 +115,7 @@ def test_metadata_multiple_displays_independent( "init_sequence": [[0xA0, 0x01]], } ) - config_b = validated_config( + CONFIG_SCHEMA( { "id": "disp_b", "model": "custom", @@ -163,13 +124,16 @@ def test_metadata_multiple_displays_independent( "init_sequence": [[0xA0, 0x01]], } ) - get_instance(config_a) - get_instance(config_b) all_meta = get_all_display_metadata() - # final validation auto-enables show_test_card for both - assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, True) - assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, True) + assert all_meta["disp_a"].width == 320 + assert all_meta["disp_a"].height == 240 + assert all_meta["disp_a"].has_hardware_rotation is True + assert all_meta["disp_a"].byte_order == BYTE_ORDER_BIG + assert all_meta["disp_b"].width == 128 + assert all_meta["disp_b"].height == 64 + assert all_meta["disp_b"].has_hardware_rotation is True + assert all_meta["disp_b"].byte_order == BYTE_ORDER_BIG def test_metadata_via_code_generation_native( @@ -179,12 +143,13 @@ def test_metadata_via_code_generation_native( """Full code generation for native.yaml should produce correct metadata.""" generate_main(component_fixture_path("native.yaml")) all_meta = get_all_display_metadata() - # native.yaml: model JC3636W518 -> 360x360, no writer, full hardware rotation + # native.yaml: model JC3636W518 -> 360x360, full hardware rotation assert len(all_meta) == 1 meta = next(iter(all_meta.values())) - assert meta == DisplayMetaData( - width=360, height=360, has_writer=True, has_hardware_rotation=True - ) + assert meta.width == 360 + assert meta.height == 360 + assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG def test_metadata_via_code_generation_lvgl( @@ -194,9 +159,10 @@ def test_metadata_via_code_generation_lvgl( """Full code generation for lvgl.yaml should produce correct metadata.""" generate_main(component_fixture_path("lvgl.yaml")) all_meta = get_all_display_metadata() - # lvgl.yaml: model ST7735 -> 128x160, no writer (lvgl draws directly), full hw rotation + # lvgl.yaml: model ST7735 -> 128x160, full hw rotation assert len(all_meta) == 1 meta = next(iter(all_meta.values())) - assert meta == DisplayMetaData( - width=128, height=160, has_writer=False, has_hardware_rotation=True - ) + assert meta.width == 128 + assert meta.height == 160 + assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG From e4980713d1a265613f3006b0ad29439f7a468cc2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:35:23 -0400 Subject: [PATCH 1514/2214] [core] esphome clean wipes the whole build directory (#16772) --- esphome/__main__.py | 7 +- esphome/build_gen/espidf.py | 6 - esphome/build_gen/platformio.py | 3 +- esphome/espidf/framework.py | 4 +- esphome/writer.py | 104 ++++++++++---- tests/unit_tests/build_gen/test_platformio.py | 32 +---- tests/unit_tests/test_writer.py | 128 ++++++++++-------- 7 files changed, 164 insertions(+), 120 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 47dd8d273c..7c4028da44 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -695,6 +695,11 @@ def _wrap_to_code(name, comp, yaml_util): def write_cpp(config: ConfigType) -> int: from esphome import writer + # Refresh the storage sidecar and clean an incompatible previous build + # before regenerating any sources. This may full-wipe the build dir, so it + # has to run before write_cpp_file writes src/. + writer.update_storage_json() + if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() @@ -1631,7 +1636,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import writer try: - writer.clean_build() + writer.clean_build(full=True) except OSError as err: _LOGGER.error("Error deleting build files: %s", err) return 1 diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 0b50f72382..9cc7a7ff12 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -7,7 +7,6 @@ from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed -from esphome.writer import update_storage_json def get_available_components() -> list[str] | None: @@ -213,11 +212,6 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC def write_project(minimal: bool = False) -> None: """Write ESP-IDF project files.""" - # Refresh /storage/.yaml.json so the dashboard's - # /info and /downloads endpoints can locate the build (they 404 - # otherwise). This mirrors the PlatformIO build-gen path's call - # in build_gen/platformio.py:write_ini(). - update_storage_json() mkdir_p(CORE.build_path) mkdir_p(CORE.relative_src_path()) diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 30dbb69d86..16c1597ccd 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -1,7 +1,7 @@ from esphome.const import __version__ from esphome.core import CORE from esphome.helpers import mkdir_p, read_file, write_file_if_changed -from esphome.writer import find_begin_end, update_storage_json +from esphome.writer import find_begin_end INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ===========" INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============" @@ -58,7 +58,6 @@ def get_ini_content(): def write_ini(content): - update_storage_json() path = CORE.relative_build_path("platformio.ini") if path.is_file(): diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6ef73a2199..2c520d0d2c 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1005,7 +1005,9 @@ def _check_esphome_idf_framework_install( idf_tools_path = framework_path / "tools" / "idf_tools.py" _LOGGER.info("Checking ESP-IDF %s framework ...", version) # Logged every invocation (not just on install) so the user can verify the - # override. A changed URL needs ``esphome clean`` to force a re-download. + # override. A changed URL needs ``esphome clean-all`` to force a re-download + # (``esphome clean`` only wipes the build dir, not the extracted framework + # under /idf/frameworks/). if source_url: _LOGGER.info("Using framework source override: %s", source_url) diff --git a/esphome/writer.py b/esphome/writer.py index 84f2f8101a..b29b3c4b79 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -126,6 +126,13 @@ def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> boo def update_storage_json() -> None: + """Refresh the storage sidecar and clean an incompatible build. + + Runs at the start of ``write_cpp`` -- BEFORE any source/project files are + regenerated -- so the clean below can safely ``full``-wipe the whole build + directory (a switch of toolchain/framework/version also drops the stale + project scaffolding, not just the compiled objects). + """ path = storage_path() old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) @@ -146,7 +153,7 @@ def update_storage_json() -> None: ) else: _LOGGER.info("Core config or version changed, cleaning build files...") - clean_build(clear_pio_cache=False) + clean_build(clear_pio_cache=False, full=True) elif storage_should_update_cmake_cache(old, new): _LOGGER.info("Integrations changed, cleaning cmake cache...") clean_cmake_cache() @@ -483,48 +490,89 @@ def write_cpp(code_s): def clean_cmake_cache(): - pioenvs = CORE.relative_pioenvs_path() - if pioenvs.is_dir(): - pioenvs_cmake_path = pioenvs / CORE.name / "CMakeCache.txt" - if pioenvs_cmake_path.is_file(): - _LOGGER.info("Deleting %s", pioenvs_cmake_path) - pioenvs_cmake_path.unlink() + # Drop the CMake cache so a component-set change forces a reconfigure. + # PlatformIO keeps it under .pioenvs//; the native ESP-IDF toolchain + # keeps it under build/ (where espidf's has_outdated_files() treats a + # missing CMakeCache.txt as stale). Only one exists for a given build. + cmake_cache_paths = ( + CORE.relative_pioenvs_path(CORE.name, "CMakeCache.txt"), + CORE.relative_build_path("build", "CMakeCache.txt"), + ) + for cmake_cache_path in cmake_cache_paths: + if cmake_cache_path.is_file(): + _LOGGER.info("Deleting %s", cmake_cache_path) + cmake_cache_path.unlink() -def clean_build(clear_pio_cache: bool = True): +def clean_build(clear_pio_cache: bool = True, *, full: bool = False): + """Remove build artifacts. + + By default only the compiled outputs are removed (``.pioenvs`` / + ``.piolibdeps`` / the native ESP-IDF ``build`` and ``managed_components`` + dirs) while the generated ``src/`` and project files are kept. This is what + in-build callers need: they regenerate a source/sdkconfig and then force a + rebuild without discarding the sources they just wrote. + + ``full=True`` wipes the entire build directory instead. Used by the + ``esphome clean`` command and by the pre-build clean in + ``update_storage_json`` (which runs before sources are regenerated) -- in + both cases nothing is mid-regeneration, so the next compile rebuilds from + scratch. It also drops stale project scaffolding the allow-list keeps (e.g. a + leftover platformio.ini / CMakeLists.txt from the other toolchain), making a + toolchain switch reliable. + """ # Allow skipping cache cleaning for integration tests if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): _LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)") return - pioenvs = CORE.relative_pioenvs_path() - if pioenvs.is_dir(): - _LOGGER.info("Deleting %s", pioenvs) - rmtree(pioenvs) - piolibdeps = CORE.relative_piolibdeps_path() - if piolibdeps.is_dir(): - _LOGGER.info("Deleting %s", piolibdeps) - rmtree(piolibdeps) - dependencies_lock = CORE.relative_build_path("dependencies.lock") - if dependencies_lock.is_file(): - _LOGGER.info("Deleting %s", dependencies_lock) - dependencies_lock.unlink() + if full: + if CORE.build_path is not None: + build_path = Path(CORE.build_path) + if build_path.is_dir(): + _LOGGER.info("Deleting %s", build_path) + rmtree(build_path) + else: + pioenvs = CORE.relative_pioenvs_path() + if pioenvs.is_dir(): + _LOGGER.info("Deleting %s", pioenvs) + rmtree(pioenvs) + piolibdeps = CORE.relative_piolibdeps_path() + if piolibdeps.is_dir(): + _LOGGER.info("Deleting %s", piolibdeps) + rmtree(piolibdeps) + dependencies_lock = CORE.relative_build_path("dependencies.lock") + if dependencies_lock.is_file(): + _LOGGER.info("Deleting %s", dependencies_lock) + dependencies_lock.unlink() + # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir + # and the Component Manager's fetched managed components live under + # the project's build path, not under .pioenvs / .piolibdeps. + for name in ("build", "managed_components"): + idf_path = CORE.relative_build_path(name) + if idf_path.is_dir(): + _LOGGER.info("Deleting %s", idf_path) + rmtree(idf_path) + + # The idedata cache is derived from the build but lives under the data dir, + # not the build path, so it must be removed separately in both modes. idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") if idedata_cache.is_file(): _LOGGER.info("Deleting %s", idedata_cache) idedata_cache.unlink() - # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir - # and the Component Manager's fetched managed components live under - # the project's build path, not under .pioenvs / .piolibdeps. - for name in ("build", "managed_components"): - idf_path = CORE.relative_build_path(name) - if idf_path.is_dir(): - _LOGGER.info("Deleting %s", idf_path) - rmtree(idf_path) if not clear_pio_cache: return + # The native ESP-IDF toolchain caches PlatformIO libraries converted to IDF + # components under /pio_components, shared across builds and keyed + # by source hash (the analog of PlatformIO's global package cache). Drop it + # on an explicit clean so a corrupt/stale converted lib is re-fetched. + pio_components = CORE.relative_internal_path("pio_components") + if pio_components.is_dir(): + _LOGGER.info("Deleting %s", pio_components) + rmtree(pio_components) + # Clean PlatformIO cache to resolve CMake compiler detection issues # This helps when toolchain paths change or get corrupted try: diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index a124dbc128..da0010afa3 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -12,13 +12,6 @@ from esphome.build_gen import platformio from esphome.core import CORE -@pytest.fixture -def mock_update_storage_json() -> Generator[MagicMock]: - """Mock update_storage_json for all tests.""" - with patch("esphome.build_gen.platformio.update_storage_json") as mock: - yield mock - - @pytest.fixture def mock_write_file_if_changed() -> Generator[MagicMock]: """Mock write_file_if_changed for tests.""" @@ -26,9 +19,7 @@ def mock_write_file_if_changed() -> Generator[MagicMock]: yield mock -def test_write_ini_creates_new_file( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: +def test_write_ini_creates_new_file(tmp_path: Path) -> None: """Test write_ini creates a new platformio.ini file.""" CORE.build_path = str(tmp_path) @@ -50,9 +41,7 @@ framework = arduino assert platformio.INI_AUTO_GENERATE_END in file_content -def test_write_ini_updates_existing_file( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: +def test_write_ini_updates_existing_file(tmp_path: Path) -> None: """Test write_ini updates existing platformio.ini file.""" CORE.build_path = str(tmp_path) @@ -97,9 +86,7 @@ framework = arduino assert "platform = old" not in file_content -def test_write_ini_preserves_custom_sections( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: +def test_write_ini_preserves_custom_sections(tmp_path: Path) -> None: """Test write_ini preserves custom sections outside auto-generate markers.""" CORE.build_path = str(tmp_path) @@ -148,7 +135,6 @@ monitor_speed = 115200 def test_write_ini_no_change_when_content_same( tmp_path: Path, - mock_update_storage_json: MagicMock, mock_write_file_if_changed: MagicMock, ) -> None: """Test write_ini doesn't rewrite file when content is unchanged.""" @@ -174,15 +160,3 @@ def test_write_ini_no_change_when_content_same( call_args = mock_write_file_if_changed.call_args[0] assert call_args[0] == ini_file assert content in call_args[1] - - -def test_write_ini_calls_update_storage_json( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: - """Test write_ini calls update_storage_json.""" - CORE.build_path = str(tmp_path) - - content = "[env:test]\nplatform = esp32" - - platformio.write_ini(content) - mock_update_storage_json.assert_called_once() diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 6f137fb351..1487517ca2 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -341,8 +341,8 @@ def test_update_storage_json_logging_when_old_is_none( with caplog.at_level("INFO"): update_storage_json() - # Verify clean_build was called - mock_clean_build.assert_called_once() + # Verify clean_build was called with a full wipe (runs before src is written) + mock_clean_build.assert_called_once_with(clear_pio_cache=False, full=True) # Verify the correct log message was used (not the component removal message) assert "Core config or version changed, cleaning build files..." in caplog.text @@ -392,60 +392,50 @@ def test_update_storage_json_logging_components_removed( new_storage.save.assert_called_once_with("/test/path") +def _mock_cmake_cache_paths(mock_core: MagicMock, tmp_path: Path) -> None: + """Wire relative_pioenvs_path/relative_build_path to tmp_path subtrees.""" + mock_core.name = "test_device" + mock_core.relative_pioenvs_path.side_effect = (tmp_path / ".pioenvs").joinpath + mock_core.relative_build_path.side_effect = tmp_path.joinpath + + @patch("esphome.writer.CORE") -def test_clean_cmake_cache( +def test_clean_cmake_cache_platformio( mock_core: MagicMock, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Test clean_cmake_cache removes CMakeCache.txt file.""" - # Create directory structure - pioenvs_dir = tmp_path / ".pioenvs" - pioenvs_dir.mkdir() - device_dir = pioenvs_dir / "test_device" - device_dir.mkdir() - cmake_cache_file = device_dir / "CMakeCache.txt" + """Test clean_cmake_cache removes the PlatformIO CMakeCache.txt.""" + _mock_cmake_cache_paths(mock_core, tmp_path) + cmake_cache_file = tmp_path / ".pioenvs" / "test_device" / "CMakeCache.txt" + cmake_cache_file.parent.mkdir(parents=True) cmake_cache_file.write_text("# CMake cache file") - # Setup mocks - mock_core.relative_pioenvs_path.return_value = pioenvs_dir - mock_core.name = "test_device" - - # Verify file exists before - assert cmake_cache_file.exists() - - # Call the function with caplog.at_level("INFO"): clean_cmake_cache() - # Verify file was removed assert not cmake_cache_file.exists() - - # Verify logging assert "Deleting" in caplog.text assert "CMakeCache.txt" in caplog.text @patch("esphome.writer.CORE") -def test_clean_cmake_cache_no_pioenvs_dir( +def test_clean_cmake_cache_esp_idf( mock_core: MagicMock, tmp_path: Path, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test clean_cmake_cache when pioenvs directory doesn't exist.""" - # Setup non-existent directory path - pioenvs_dir = tmp_path / ".pioenvs" + """Test clean_cmake_cache removes the native ESP-IDF build/CMakeCache.txt.""" + _mock_cmake_cache_paths(mock_core, tmp_path) + cmake_cache_file = tmp_path / "build" / "CMakeCache.txt" + cmake_cache_file.parent.mkdir(parents=True) + cmake_cache_file.write_text("# CMake cache file") - # Setup mocks - mock_core.relative_pioenvs_path.return_value = pioenvs_dir + with caplog.at_level("INFO"): + clean_cmake_cache() - # Verify directory doesn't exist - assert not pioenvs_dir.exists() - - # Call the function - should not crash - clean_cmake_cache() - - # Verify directory still doesn't exist - assert not pioenvs_dir.exists() + assert not cmake_cache_file.exists() + assert str(cmake_cache_file) in caplog.text @patch("esphome.writer.CORE") @@ -453,27 +443,11 @@ def test_clean_cmake_cache_no_cmake_file( mock_core: MagicMock, tmp_path: Path, ) -> None: - """Test clean_cmake_cache when CMakeCache.txt doesn't exist.""" - # Create directory structure without CMakeCache.txt - pioenvs_dir = tmp_path / ".pioenvs" - pioenvs_dir.mkdir() - device_dir = pioenvs_dir / "test_device" - device_dir.mkdir() - cmake_cache_file = device_dir / "CMakeCache.txt" + """Test clean_cmake_cache when no CMakeCache.txt exists -- should not crash.""" + _mock_cmake_cache_paths(mock_core, tmp_path) - # Setup mocks - mock_core.relative_pioenvs_path.return_value = pioenvs_dir - mock_core.name = "test_device" - - # Verify file doesn't exist - assert not cmake_cache_file.exists() - - # Call the function - should not crash clean_cmake_cache() - # Verify file still doesn't exist - assert not cmake_cache_file.exists() - @patch("esphome.writer.CORE") def test_clean_build( @@ -507,6 +481,11 @@ def test_clean_build( managed_components_dir.mkdir() (managed_components_dir / "espressif__arduino-esp32").mkdir() + # Converted-PIO-library cache (native ESP-IDF), under the data dir. + pio_components_dir = tmp_path / "pio_components" + pio_components_dir.mkdir() + (pio_components_dir / "abc12345").mkdir() + # Create PlatformIO cache directory platformio_cache_dir = tmp_path / ".platformio" / ".cache" platformio_cache_dir.mkdir(parents=True) @@ -529,6 +508,7 @@ def test_clean_build( assert idedata_cache.exists() assert idf_build_dir.exists() assert managed_components_dir.exists() + assert pio_components_dir.exists() assert platformio_cache_dir.exists() # Mock PlatformIO's ProjectConfig cache_dir @@ -554,6 +534,7 @@ def test_clean_build( assert not idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() + assert not pio_components_dir.exists() assert not platformio_cache_dir.exists() # Verify logging @@ -567,6 +548,41 @@ def test_clean_build( assert "PlatformIO cache" in caplog.text +@patch("esphome.writer.CORE") +def test_clean_build_full_wipes_build_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """full=True wipes the whole build dir (incl. src/) but keeps siblings.""" + build_dir = tmp_path / "build" / "test" + (build_dir / "src").mkdir(parents=True) + (build_dir / "src" / "main.cpp").write_text("// generated") + (build_dir / "platformio.ini").write_text("[platformio]") + (build_dir / ".pioenvs").mkdir() + + idedata_cache = tmp_path / "idedata" / "test.json" + idedata_cache.parent.mkdir() + idedata_cache.write_text("{}") + + # A sibling of the build dir (under the data dir) must survive. + survivor = tmp_path / "keep_me.txt" + survivor.write_text("keep") + + # build_path may be a str (e.g. set from config); clean_build must coerce. + mock_core.build_path = str(build_dir) + mock_core.name = "test" + mock_core.relative_internal_path.side_effect = tmp_path.joinpath + + with caplog.at_level("INFO"): + clean_build(clear_pio_cache=False, full=True) + + assert not build_dir.exists() + assert not idedata_cache.exists() + assert survivor.exists() + assert str(build_dir) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_build_partial_exists( mock_core: MagicMock, @@ -586,6 +602,7 @@ def test_clean_build_partial_exists( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify only pioenvs exists assert pioenvs_dir.exists() @@ -623,6 +640,7 @@ def test_clean_build_nothing_exists( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify nothing exists assert not pioenvs_dir.exists() @@ -659,6 +677,7 @@ def test_clean_build_platformio_not_available( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify all exist before assert pioenvs_dir.exists() @@ -697,6 +716,7 @@ def test_clean_build_empty_cache_dir( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify pioenvs exists before assert pioenvs_dir.exists() @@ -1425,6 +1445,7 @@ def test_clean_build_handles_readonly_files( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify file is read-only assert not os.access(readonly_file, os.W_OK) @@ -1489,6 +1510,7 @@ def test_clean_build_reraises_for_other_errors( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath try: # Mock os.access in writer module to return True (writable) From 89ddd34cb9cd7e81e38d9dc2b305e6e5bc887b0d Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:55:51 +0200 Subject: [PATCH 1515/2214] [dmsr] [breaking] Fix decryption that uses custom auth key. Add CRC to telegram sensor. Automatic hex string detection in equipment_id fields. Support EON Hungary smart meters (#16561) --- .clang-tidy.hash | 2 +- esphome/components/dsmr/__init__.py | 2 +- esphome/components/dsmr/dsmr.cpp | 7 ++++--- esphome/components/dsmr/dsmr.h | 11 ++++++++--- esphome/components/dsmr/sensor.py | 8 ++++---- esphome/components/dsmr/text_sensor.py | 1 + platformio.ini | 2 +- 7 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 52d75d1601..29c8b414f6 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f +a30d2e50f2cac76e9c504eb7e5b250070dc92df23469c44a7eb8e52e26fd375d diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 31ec1ce5b5..05f9a78156 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -87,7 +87,7 @@ async def to_code(config): cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - cg.add_library("esphome/dsmr_parser", "1.4.0") + cg.add_library("esphome/dsmr_parser", "1.8.0") def final_validate(config: ConfigType) -> ConfigType: diff --git a/esphome/components/dsmr/dsmr.cpp b/esphome/components/dsmr/dsmr.cpp index 2fa51f73af..9580464a2e 100644 --- a/esphome/components/dsmr/dsmr.cpp +++ b/esphome/components/dsmr/dsmr.cpp @@ -153,8 +153,9 @@ void Dsmr::receive_encrypted_telegram_() { bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) { this->stop_requesting_data_(); - ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size()); - ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.content().size()), telegram.content().data()); + ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.full_content().size()); + ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.full_content().size()), + telegram.full_content().data()); MyData data; if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) { @@ -167,7 +168,7 @@ bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) // Publish the telegram, after publishing the sensors so it can also trigger action based on latest values if (this->s_telegram_ != nullptr) { - this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size()); + this->s_telegram_->publish_state(telegram.full_content().data(), telegram.full_content().size()); } return true; } diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index e55db9f976..3642309c26 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -74,7 +74,8 @@ class Dsmr : public Component, public uart::UARTDevice { receive_timeout_(receive_timeout), request_pin_(request_pin), buffer_(max_telegram_length), - packet_accumulator_(buffer_, crc_check) { + packet_accumulator_(buffer_, crc_check), + dlms_decryptor_(gcm_decryptor_, crc_check) { this->set_decryption_key_(decryption_key); } @@ -97,7 +98,11 @@ class Dsmr : public Component, public uart::UARTDevice { // Remove before 2026.8.0 ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0") - void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); } + void set_decryption_key(const std::string &decryption_key) { + // Some YAML configs pass a string longer than 32 symbols. We only need the first 32 symbols, + // otherwise `Aes128GcmDecryptionKey::from_hex` will fail. + this->set_decryption_key_(std::string(decryption_key, 0, 32).c_str()); + } // Sensor setters #define DSMR_SET_SENSOR(s) \ @@ -143,7 +148,7 @@ class Dsmr : public Component, public uart::UARTDevice { std::vector buffer_; dsmr_parser::PacketAccumulator packet_accumulator_; Aes128GcmDecryptorImpl gcm_decryptor_; - dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_}; + dsmr_parser::DlmsPacketDecryptor dlms_decryptor_; std::array uart_chunk_reading_buf_; }; } // namespace esphome::dsmr diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index 292e5a1156..7d93ee62e1 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -248,10 +248,6 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional("electricity_switch_position"): sensor.sensor_schema( - accuracy_decimals=3, - state_class=STATE_CLASS_MEASUREMENT, - ), cv.Optional("electricity_failures"): sensor.sensor_schema( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, @@ -808,6 +804,10 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_DURATION, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional("electricity_switch_position"): cv.invalid( + "'electricity_switch_position' has moved to the 'text_sensor' platform." + "Move it under 'text_sensor' to fix." + ), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/dsmr/text_sensor.py b/esphome/components/dsmr/text_sensor.py index a8f29c7ca8..54b5711923 100644 --- a/esphome/components/dsmr/text_sensor.py +++ b/esphome/components/dsmr/text_sensor.py @@ -14,6 +14,7 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional("p1_version"): text_sensor.text_sensor_schema(), cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(), cv.Optional("timestamp"): text_sensor.text_sensor_schema(), + cv.Optional("electricity_switch_position"): text_sensor.text_sensor_schema(), cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(), cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(), cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(), diff --git a/platformio.ini b/platformio.ini index 8a89f96b39..4ac60d8099 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,7 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.4.0 ; dsmr + esphome/dsmr_parser@1.8.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library From 2009f6cc5f80bef33913383b8786cdbc133c58c1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:43:48 +1000 Subject: [PATCH 1516/2214] [lvgl] Fix indicator updates (#16780) --- esphome/components/lvgl/widgets/__init__.py | 1 + esphome/components/lvgl/widgets/meter.py | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 400f7c709b..4d62c3de05 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -290,6 +290,7 @@ class Widget: # Properties for linear equations self.slope = None self.y_int = None + self.parent = None @staticmethod def create(name, var, wtype: WidgetType, config: dict = None): diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index e2407fad5a..166e88f382 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -430,7 +430,8 @@ class MeterType(WidgetType): tvar, LV_PART.MAIN, await arc_style.get_var() ) lw = Widget.create(iid, tvar, arc_indicator_type) - await set_indicator_values(lw, v) + lw.parent = scale_var + await set_indicator_values(scale_var, lw, v) if t == CONF_TICK_STYLE: # No object created for this @@ -482,7 +483,8 @@ class MeterType(WidgetType): if option in v: props["line_" + option] = v[option] lw = await widget_to_code(props, line_indicator_type, scale_var) - await set_indicator_values(lw, v) + lw.parent = scale_var + await set_indicator_values(scale_var, lw, v) if t == CONF_IMAGE: add_lv_use(CONF_IMAGE) @@ -501,7 +503,8 @@ class MeterType(WidgetType): } iw = await widget_to_code(props, image_indicator_type, scale_var) await iw.set_property(CONF_SRC, await lv_image.process(src)) - await set_indicator_values(iw, v) + iw.parent = scale_var + await set_indicator_values(scale_var, iw, v) # Hide the scale line lv.obj_set_style_arc_opa(scale_var, LV_OPA.TRANSP, LV_PART.MAIN) @@ -607,27 +610,27 @@ async def indicator_update_to_code(config, action_id, template_arg, args): widget = await get_widgets(config) async def set_value(w: Widget): - await set_indicator_values(w, config) + await set_indicator_values(w.parent, w, config) return await action_to_code( widget, set_value, action_id, template_arg, args, config ) -async def set_indicator_values(indicator: Widget, config): +async def set_indicator_values(scale: MockObj, indicator: Widget, config): """Update scale section values (replaces meter indicator values)""" start_value = await get_start_value(config) end_value = await get_end_value(config) if indicator.type is arc_indicator_type: # For scale sections, we update the range if start_value is not None and end_value is not None: - lv.scale_section_set_range(indicator.obj, start_value, end_value) + lv.scale_set_section_range(scale, indicator.obj, start_value, end_value) elif start_value is not None: # If only start value, use it as both start and end (single point) - lv.scale_section_set_range(indicator.obj, start_value, start_value) + lv.scale_set_section_range(scale, indicator.obj, start_value, start_value) elif end_value is not None: # If only end value, assume range from 0 to end_value - lv.scale_section_set_range(indicator.obj, 0, end_value) + lv.scale_set_section_range(scale, indicator.obj, 0, end_value) return if start_value is None: From 712ef2ec0eba901ecf2fecec48df36f27f3796b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:45:52 -0400 Subject: [PATCH 1517/2214] Bump esptool from 5.2.0 to 5.3.0 (#16774) 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 17b618dde7..85d9857e7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ tzlocal==5.3.1 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 -esptool==5.2.0 +esptool==5.3.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.3.1 From eba70dc193d61982a94920b80de64f0d76f5d777 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:46:07 -0400 Subject: [PATCH 1518/2214] Bump github/codeql-action from 4.36.0 to 4.36.1 (#16775) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dfc0e08bfa..122bb30b5e 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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: category: "/language:${{matrix.language}}" From 87735d71a043293e3cdd09224d6bcbfa7e2ee3c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:46:22 -0400 Subject: [PATCH 1519/2214] Bump actions/checkout from 6.0.2 to 6.0.3 (#16776) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci-github-scripts.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 40 +++++++++---------- .../codeowner-approved-label-update.yml | 2 +- .../workflows/codeowner-review-request.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/pr-title-check.yml | 2 +- .github/workflows/release.yml | 8 ++-- .github/workflows/sync-device-classes.yml | 4 +- 13 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 6c80d36d20..e48d6f69bd 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,7 +24,7 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 675bbe9d2c..2a5b701248 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index d9148fb06d..73c437467b 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 89fbec5420..2a40675f3b 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -42,7 +42,7 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml index 6713fcc454..43d530128c 100644 --- a/.github/workflows/ci-github-scripts.yml +++ b/.github/workflows/ci-github-scripts.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Run tests working-directory: .github/scripts/auto-label-pr diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 025b960985..35cfce65f8 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63efff1b3a..d3fc19ca41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -74,7 +74,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -97,7 +97,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -123,7 +123,7 @@ jobs: if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -151,11 +151,11 @@ jobs: if: needs.determine-jobs.outputs.device-builder == 'true' steps: - name: Check out esphome (this PR) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: path: esphome - name: Check out esphome/device-builder - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: esphome/device-builder ref: main @@ -221,7 +221,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -281,7 +281,7 @@ jobs: benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -353,7 +353,7 @@ jobs: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python 3.13 id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -405,7 +405,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python @@ -434,7 +434,7 @@ jobs: (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python @@ -490,7 +490,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -575,7 +575,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -670,7 +670,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -764,7 +764,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -889,7 +889,7 @@ jobs: TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python @@ -971,7 +971,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -997,7 +997,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.base_ref }} @@ -1179,7 +1179,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1248,7 +1248,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 013517bde6..1bd60fd11d 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 7cdbfcf328..5ad0b02de1 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 122bb30b5e..c71d7204de 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index e8320672d2..0e2efb1bcf 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 344bd416c6..8efc395951 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read # actions/checkout to build the sdist/wheel id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 84be3c8e22..8796ddf7f0 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -28,10 +28,10 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Checkout Home Assistant - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: home-assistant/core path: lib/home-assistant From 3b0f669f4782117661a9603137615f1fae99d128 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 3 Jun 2026 14:38:00 -0300 Subject: [PATCH 1520/2214] [gree] Fix HEAT_COOL advertised when supports_heat is false; restrict YAN swing to vertical (#16199) --- esphome/components/gree/gree.cpp | 16 ++++++++++++++++ esphome/components/gree/gree.h | 1 + 2 files changed, 17 insertions(+) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 705c741dd0..a794e7721f 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -5,7 +5,23 @@ namespace esphome::gree { static const char *const TAG = "gree.climate"; +climate::ClimateTraits GreeClimate::traits() { + auto t = climate_ir::ClimateIR::traits(); + // ClimateIR unconditionally includes HEAT_COOL in the base mode set; remove it when heat is not supported. + if (!this->supports_heat_) { + auto modes = t.get_supported_modes(); + modes.erase(climate::CLIMATE_MODE_HEAT_COOL); + t.set_supported_modes(modes); + } + return t; +} + void GreeClimate::set_model(Model model) { + if (model == GREE_YAN) { + // YAN only has a vertical vane; the horizontal swing IR bytes are not defined for this model. + this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL); + this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH); + } if (model == GREE_YX1FF) { this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); // YX1FF 4 speed this->presets_.insert(climate::CLIMATE_PRESET_NONE); // YX1FF sleep mode diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 24453750ae..1eb812ae46 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -94,6 +94,7 @@ class GreeClimate : public climate_ir::ClimateIR { protected: // Transmit via IR the state of this climate controller. void transmit_state() override; + climate::ClimateTraits traits() override; uint8_t operation_mode_(); uint8_t fan_speed_(); From 7b8cbe2de19d4d530af6103fda140bbb4575142f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Jun 2026 04:18:16 +1000 Subject: [PATCH 1521/2214] [sdl] Add option to choose display screen (#16363) --- esphome/components/sdl/display.py | 36 ++++++++++++++++++++++++---- esphome/components/sdl/sdl_esphome.h | 6 ++--- tests/components/sdl/common.yaml | 22 +++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 78c180aa65..57266f33e2 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -20,6 +20,7 @@ Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component) sdl_window_flags = cg.global_ns.enum("SDL_WindowFlags") +CONF_CENTERED_ON_DISPLAY = "centered_on_display" CONF_SDL_OPTIONS = "sdl_options" CONF_SDL_ID = "sdl_id" CONF_WINDOW_OPTIONS = "window_options" @@ -31,6 +32,8 @@ WINDOW_OPTIONS = ( "resizable", ) +SDL_WINDOWPOS_CENTERED_MASK = 0x2FFF0000 + def get_sdl_options(value): if value != "": @@ -47,6 +50,20 @@ def get_window_options(): return {cv.Optional(option, default=False): cv.boolean for option in WINDOW_OPTIONS} +def _validate_position(config: dict) -> dict: + if CONF_CENTERED_ON_DISPLAY in config: + if CONF_X in config or CONF_Y in config: + raise cv.Invalid( + f"Cannot specify '{CONF_CENTERED_ON_DISPLAY}' with '{CONF_X}' and '{CONF_Y}' options" + ) + return config + if CONF_X in config and CONF_Y in config: + return config + if CONF_X in config or CONF_Y in config: + raise cv.Invalid(f"Must specify both '{CONF_X}' and '{CONF_Y}' options") + raise cv.Invalid("Must specify either 'x' and 'y' or 'centered_on_display'") + + CONFIG_SCHEMA = cv.All( display.FULL_DISPLAY_SCHEMA.extend( cv.Schema( @@ -66,10 +83,13 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_POSITION): cv.Schema( { - cv.Required(CONF_X): cv.int_, - cv.Required(CONF_Y): cv.int_, + cv.Optional(CONF_X): cv.int_, + cv.Optional(CONF_Y): cv.int_, + cv.Optional(CONF_CENTERED_ON_DISPLAY): cv.int_range( + 0, 128 + ), } - ), + ).add_extra(_validate_position), **get_window_options(), } ), @@ -105,7 +125,15 @@ async def to_code(config): cg.add(var.set_window_options(create_flags)) if position := window_options.get(CONF_POSITION): - cg.add(var.set_position(position[CONF_X], position[CONF_Y])) + if (centered := position.get(CONF_CENTERED_ON_DISPLAY)) is not None: + cg.add( + var.set_position( + SDL_WINDOWPOS_CENTERED_MASK | centered, + SDL_WINDOWPOS_CENTERED_MASK | centered, + ) + ) + else: + cg.add(var.set_position(position[CONF_X], position[CONF_Y])) if lamb := config.get(CONF_LAMBDA): lambda_ = await cg.process_lambda( diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index 3f54b70560..a5ebf44c38 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -28,7 +28,7 @@ class Sdl : public display::Display { this->height_ = height; } void set_window_options(uint32_t window_options) { this->window_options_ = window_options; } - void set_position(uint16_t pos_x, uint16_t pos_y) { + void set_position(int32_t pos_x, int32_t pos_y) { this->pos_x_ = pos_x; this->pos_y_ = pos_y; } @@ -54,8 +54,8 @@ class Sdl : public display::Display { int width_{}; int height_{}; uint32_t window_options_{0}; - int pos_x_{SDL_WINDOWPOS_UNDEFINED}; - int pos_y_{SDL_WINDOWPOS_UNDEFINED}; + int32_t pos_x_{SDL_WINDOWPOS_UNDEFINED}; + int32_t pos_y_{SDL_WINDOWPOS_UNDEFINED}; SDL_Renderer *renderer_{}; SDL_Window *window_{}; SDL_Texture *texture_{}; diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index 66f93915b6..d3d3c9ee5e 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -10,6 +10,28 @@ display: dimensions: width: 450 height: 600 + window_options: + position: + x: 100 + y: 100 + + - platform: sdl + id: second_display + dimensions: + width: 450 + height: 600 + window_options: + position: + centered_on_display: 1 + + - platform: sdl + id: third_display + dimensions: + width: 450 + height: 600 + window_options: + position: + centered_on_display: 0 binary_sensor: - platform: sdl From 92819d86586037b369fadf67ff1ce2ddcd14d723 Mon Sep 17 00:00:00 2001 From: Jon Little Date: Wed, 3 Jun 2026 15:54:34 -0500 Subject: [PATCH 1522/2214] [logger] Fix USB JTAG VFS symbols linked when logging is disabled (#15721) Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/logger/__init__.py | 6 +++++- esphome/components/logger/logger_esp32.cpp | 6 ++++-- esphome/core/defines.h | 5 +++-- .../logger/common-uart0_no_logging.yaml | 3 +++ .../test-uart0_no_logging.esp32-h2-idf.yaml | 1 + .../build_components_base.esp32-h2-idf.yaml | 20 +++++++++++++++++++ 6 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 tests/components/logger/common-uart0_no_logging.yaml create mode 100644 tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml create mode 100644 tests/test_build_components/build_components_base.esp32-h2-idf.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index e4921ae196..9629dce0bf 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -461,7 +461,11 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_define("USE_LOGGER_USB_SERIAL_JTAG") # USB Serial JTAG code is compiled when platform supports it. # Enable secondary USB serial JTAG console so the VFS functions are available. - if CORE.is_esp32 and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG: + if ( + CORE.is_esp32 + and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG + and has_serial_logging + ): require_usb_serial_jtag_secondary() require_vfs_termios() except cv.Invalid: diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 8e0c00267a..b216a5427d 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -6,7 +6,7 @@ #include -#ifdef USE_LOGGER_USB_SERIAL_JTAG +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #include #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0) #include @@ -29,7 +29,7 @@ namespace esphome::logger { static const char *const TAG = "logger"; -#ifdef USE_LOGGER_USB_SERIAL_JTAG +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG static void init_usb_serial_jtag_() { setvbuf(stdin, NULL, _IONBF, 0); // Disable buffering on stdin @@ -108,7 +108,9 @@ void Logger::pre_setup() { #endif #ifdef USE_LOGGER_USB_SERIAL_JTAG case UART_SELECTION_USB_SERIAL_JTAG: +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG init_usb_serial_jtag_(); +#endif break; #endif } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 765c1aa3b2..6c840f56ee 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -358,11 +358,12 @@ #define USE_LOGGER_USB_SERIAL_JTAG #elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32S31) + defined(USE_ESP32_VARIANT_ESP32H21) || defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32S31) #define USE_LOGGER_USB_CDC #define USE_LOGGER_UART_SELECTION_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG +#define USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #endif #endif diff --git a/tests/components/logger/common-uart0_no_logging.yaml b/tests/components/logger/common-uart0_no_logging.yaml new file mode 100644 index 0000000000..3bb1691767 --- /dev/null +++ b/tests/components/logger/common-uart0_no_logging.yaml @@ -0,0 +1,3 @@ +logger: + hardware_uart: UART0 + baud_rate: 0 diff --git a/tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml b/tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml new file mode 100644 index 0000000000..76444a2e89 --- /dev/null +++ b/tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml @@ -0,0 +1 @@ +<<: !include common-uart0_no_logging.yaml diff --git a/tests/test_build_components/build_components_base.esp32-h2-idf.yaml b/tests/test_build_components/build_components_base.esp32-h2-idf.yaml new file mode 100644 index 0000000000..a60c1fddd9 --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-h2-idf.yaml @@ -0,0 +1,20 @@ +esphome: + name: componenttestesp32h2idf + friendly_name: $component_name + +esp32: + board: esp32-h2-devkitm-1 + framework: + type: esp-idf + # Use custom partition table with larger app partition (3MB) + # Default IDF partitions only allow 1.75MB which is too small for grouped tests + partitions: ../partitions_testing.csv + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From 78d8a93fff36c749a9786811e01c382e79d5971f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:45:56 -0400 Subject: [PATCH 1523/2214] [remote_base] Fix RC5 decoding at either receive polarity (#16767) --- .../components/remote_base/rc5_protocol.cpp | 90 +++++++++++-------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/esphome/components/remote_base/rc5_protocol.cpp b/esphome/components/remote_base/rc5_protocol.cpp index c7f79ad84a..fd136a4e6d 100644 --- a/esphome/components/remote_base/rc5_protocol.cpp +++ b/esphome/components/remote_base/rc5_protocol.cpp @@ -7,6 +7,7 @@ static const char *const TAG = "remote.rc5"; static constexpr uint32_t BIT_TIME_US = 889; static constexpr uint8_t NBITS = 14; +static constexpr uint8_t NHALFBITS = NBITS * 2; void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) { static bool toggle = false; @@ -35,52 +36,63 @@ void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) { } toggle = !toggle; } + optional RC5Protocol::decode(RemoteReceiveData src) { - RC5Data out{ - .address = 0, - .command = 0, - }; - uint8_t field_bit; - - if (src.expect_space(BIT_TIME_US) && src.expect_mark(BIT_TIME_US)) { - field_bit = 1; - } else if (src.expect_space(2 * BIT_TIME_US)) { - field_bit = 0; - } else { - return {}; - } - - if (!(((src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US)) || - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) && - (((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) && - (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) || - ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US)))))) { - return {}; - } - - uint32_t out_data = 0; - for (int bit = NBITS - 4; bit >= 1; bit--) { - if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { - out_data |= 0 << bit; - } else if ((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) && - (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) { - out_data |= 1 << bit; + // Expand the runs into half-bit levels (true = mark). Each run is exactly one + // half-bit (BIT_TIME_US) or two (2 * BIT_TIME_US); stop at anything else. + // + // halfbits[0] is reserved for the leading half-bit, which is always dropped -- + // S1 is 1, so its first half sits at the idle level (at either polarity) and + // merges into the pre-frame idle. Captured half-bits start at index 1. + bool halfbits[NHALFBITS + 2]; + uint8_t n = 1; + for (uint32_t i = 0; n <= NHALFBITS && src.is_valid(i); i++) { + if (src.peek_mark(BIT_TIME_US, i)) { + halfbits[n++] = true; + } else if (src.peek_space(BIT_TIME_US, i)) { + halfbits[n++] = false; + } else if (src.peek_mark(2 * BIT_TIME_US, i)) { + halfbits[n++] = true; + halfbits[n++] = true; + } else if (src.peek_space(2 * BIT_TIME_US, i)) { + halfbits[n++] = false; + halfbits[n++] = false; } else { - return {}; + break; } } - if (src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) { - out_data |= 0; - } else if (src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) { - out_data |= 1; + + // Expect a full frame once the leading half is restored: 27 captured halves + // (n == 28) or 26 when the final bit also ends on idle and its trailing half + // is dropped too (n == 27). A dropped edge half is the inverse of its partner + // (a Manchester bit always transitions mid-bit), so reconstruct the leading + // half (always) and the trailing half (only when it was dropped). + if (n != NHALFBITS && n != NHALFBITS - 1) { + return {}; + } + halfbits[0] = !halfbits[1]; + if (n == NHALFBITS - 1) { + halfbits[n] = !halfbits[n - 1]; } - out.command = (uint8_t) (out_data & 0x3F) + (1 - field_bit) * 64u; - out.address = (out_data >> 6) & 0x1F; - return out; + const bool carrier = halfbits[1]; + uint16_t bits = 0; + for (uint8_t i = 0; i < NBITS; i++) { + const bool first = halfbits[2 * i]; + const bool second = halfbits[2 * i + 1]; + if (first == second) { + return {}; // no midpoint transition -> not a valid Manchester bit + } + bits = (bits << 1) | (second == carrier ? 1 : 0); + } + + const bool field_bit = bits & (1 << 12); // S2: the inverted 7th command bit + return RC5Data{ + .address = static_cast((bits >> 6) & 0x1F), + .command = static_cast((bits & 0x3F) | (field_bit ? 0 : 0x40)), + }; } + void RC5Protocol::dump(const RC5Data &data) { ESP_LOGI(TAG, "Received RC5: address=0x%02X, command=0x%02X", data.address, data.command); } From 74a1ff9fc76b4ee5129e47c7ac4a007fd7471987 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:01 -0400 Subject: [PATCH 1524/2214] [esp32][core] Restore ESP-IDF version on logs/upload fast path and clean build on framework change (#16770) --- esphome/storage_json.py | 29 ++++++++++-- esphome/writer.py | 13 ++++-- tests/unit_tests/test_espidf_toolchain.py | 9 ++++ tests/unit_tests/test_storage_json.py | 56 ++++++++++++++++++++++- tests/unit_tests/test_writer.py | 27 +++++++++++ 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index ba576fcfd7..3bdda1a9a1 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -16,7 +16,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, Toolchain, ) -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.helpers import write_file_if_changed from esphome.types import CoreType @@ -101,6 +101,7 @@ class StorageJSON: core_platform: str | None = None, toolchain: str | None = None, area: str | None = None, + framework_version: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -141,6 +142,8 @@ class StorageJSON: self.toolchain = toolchain # The area of the node self.area = area + # The framework version the build used (for esp32, the resolved ESP-IDF version) + self.framework_version = framework_version def as_dict(self): return { @@ -162,6 +165,7 @@ class StorageJSON: "core_platform": self.core_platform, "toolchain": self.toolchain, "area": self.area, + "framework_version": self.framework_version, } def to_json(self): @@ -173,10 +177,12 @@ class StorageJSON: @staticmethod def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: hardware = esph.target_platform.upper() + framework_version: str | None = None if esph.is_esp32: from esphome.components import esp32 hardware = esp32.get_esp32_variant(esph) + framework_version = str(esp32.idf_version()) return StorageJSON( storage_version=1, name=esph.name, @@ -200,6 +206,7 @@ class StorageJSON: core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, area=esph.area, + framework_version=framework_version, ) @staticmethod @@ -249,6 +256,7 @@ class StorageJSON: core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") area = storage.get("area") + framework_version = storage.get("framework_version") return StorageJSON( storage_version, name, @@ -268,6 +276,7 @@ class StorageJSON: core_platform, toolchain, area, + framework_version, ) @staticmethod @@ -311,10 +320,24 @@ class StorageJSON: # esp32.get_esp32_variant(). target_platform on disk is the variant # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). if target_platform == const.PLATFORM_ESP32: - from esphome.components.esp32.const import KEY_ESP32 + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION from esphome.const import KEY_VARIANT - CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform} + esp32_data = {KEY_VARIANT: self.target_platform} + if self.framework_version: + import esphome.config_validation as cv + + try: + esp32_data[KEY_IDF_VERSION] = cv.Version.parse( + self.framework_version + ) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{self.framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err + CORE.data[KEY_ESP32] = esp32_data def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/esphome/writer.py b/esphome/writer.py index b29b3c4b79..a9c072f156 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -93,9 +93,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: ``src_version`` differs, ``build_path`` differs, the build ``toolchain`` differs (e.g. switching between the PlatformIO and native ESP-IDF toolchains, which produce incompatible build trees), - or a previously loaded integration was removed in *new*. Adding - integrations or changing unrelated fields (friendly name, esphome - version, etc.) does not trigger a clean. + the ``framework`` or ``framework_version`` differs (e.g. switching + arduino <-> esp-idf, or bumping the ESP-IDF version, which also + produce incompatible build trees), or a previously loaded + integration was removed in *new*. Adding integrations or changing + unrelated fields (friendly name, esphome version, etc.) does not + trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -113,6 +116,10 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.toolchain != new.toolchain: return True + if old.framework != new.framework: + return True + if old.framework_version != new.framework_version: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index d00d8662f5..8849ea8bc8 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -148,3 +148,12 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: mock_transform.assert_called_once() assert result == {"cxx_path": "regen"} + + +def test_get_core_framework_version_from_core_data(): + """The version is read from CORE.data when validation populated it.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + import esphome.config_validation as cv + + CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}} + assert toolchain._get_core_framework_version() == "5.5.4" diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 105d78505f..7ba56b05f4 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import storage_json +from esphome import config_validation as cv, storage_json from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE @@ -206,6 +206,7 @@ def test_storage_json_as_dict() -> None: framework="arduino", core_platform="esp32", area="Living Room", + framework_version="5.3.1", ) result = storage.as_dict() @@ -235,6 +236,7 @@ def test_storage_json_as_dict() -> None: assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" assert result["area"] == "Living Room" + assert result["framework_version"] == "5.3.1" def test_storage_json_to_json() -> None: @@ -313,8 +315,12 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.toolchain = Toolchain.ESP_IDF mock_core.area = "Living Room" - with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: + with ( + patch("esphome.components.esp32.get_esp32_variant") as mock_variant, + patch("esphome.components.esp32.idf_version") as mock_idf_version, + ): mock_variant.return_value = "ESP32-C3" + mock_idf_version.return_value = cv.Version(5, 3, 1) result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) @@ -333,6 +339,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" assert result.area == "Living Room" + assert result.framework_version == "5.3.1" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -545,6 +552,51 @@ def test_storage_json_apply_to_core_ignores_unknown_toolchain( assert CORE.toolchain is None +def test_storage_json_framework_version_round_trip(setup_core: Path) -> None: + """Sidecar framework_version restores CORE.data[esp32][idf_version].""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + + storage = _make_storage_with_toolchain("esp-idf") + storage.framework_version = "5.3.1" + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + assert json.loads(path.read_text())["framework_version"] == "5.3.1" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.framework_version == "5.3.1" + + loaded.apply_to_core() + assert CORE.data[KEY_ESP32][KEY_IDF_VERSION] == cv.Version(5, 3, 1) + + +def test_storage_json_apply_to_core_without_framework_version( + setup_core: Path, +) -> None: + """Older sidecars lacking framework_version don't populate idf_version.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + + loaded = _make_storage_with_toolchain("esp-idf") + assert loaded.framework_version is None + + loaded.apply_to_core() + assert KEY_IDF_VERSION not in CORE.data[KEY_ESP32] + + +def test_storage_json_apply_to_core_raises_on_invalid_framework_version( + setup_core: Path, +) -> None: + """A malformed version string fails with an actionable error at parse time.""" + from esphome.core import EsphomeError + + loaded = _make_storage_with_toolchain("esp-idf") + loaded.framework_version = "not-a-version" + + with pytest.raises(EsphomeError, match="clean the build"): + loaded.apply_to_core() + + def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 1487517ca2..c8cf68ff3e 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -112,6 +112,7 @@ def create_storage() -> Callable[..., StorageJSON]: framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), toolchain=kwargs.get("toolchain", "platformio"), + framework_version=kwargs.get("framework_version"), ) return _create @@ -157,6 +158,32 @@ def test_storage_should_clean_when_toolchain_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_framework_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the framework changes. + + Switching between arduino and esp-idf produces incompatible build trees + even on the same toolchain, so the build must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], framework="arduino") + new = create_storage(loaded_integrations=["api", "wifi"], framework="esp-idf") + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_framework_version_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the framework version changes. + + A different framework/ESP-IDF version compiles against a different SDK, so + the stale build tree must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.3.1") + new = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.4.0") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: From 0fcfd1e3d636e9a1810f715832494c09ac82fa94 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:08 -0400 Subject: [PATCH 1525/2214] [rp2040] Fix lwipopts template load on Windows extended-length paths (#16783) --- esphome/components/rp2040/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 6ec0ee08b8..f98cde7968 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -501,18 +501,21 @@ 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, FileSystemLoader + from jinja2 import Environment lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) if not lwip_defines: return - template_dir = Path(__file__).parent - jinja_env = Environment( - loader=FileSystemLoader(str(template_dir)), - keep_trailing_newline=True, + # Read the template via pathlib and render from a string rather than using + # FileSystemLoader. jinja2's loader joins the search path with posixpath, which + # breaks on Windows extended-length paths (\\?\C:\...) where forward slashes are + # not accepted, causing a spurious TemplateNotFound (see issue #16732). + template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text( + encoding="utf-8" ) - template = jinja_env.get_template("lwipopts.h.jinja") + jinja_env = Environment(keep_trailing_newline=True) + template = jinja_env.from_string(template_text) content = template.render(**lwip_defines) lwip_dir = CORE.relative_build_path("lwip_override") From 0d7d091e7127b42edd7542c3e5c9dc894ed67bbc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:17 -0400 Subject: [PATCH 1526/2214] [esp32_ble_server] Fix duplicate Device Information Service with string UUIDs (#16784) --- .../components/esp32_ble_server/__init__.py | 28 +++++++++-- .../esp32_ble_server/__init__.py | 0 .../esp32_ble_server/test_esp32_ble_server.py | 47 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/esp32_ble_server/__init__.py create mode 100644 tests/component_tests/esp32_ble_server/test_esp32_ble_server.py diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 7bf3092a4e..d45f2d9df2 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -62,6 +62,26 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29 MODEL_CHARACTERISTIC_UUID = 0x2A24 FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26 +# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit. +_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB" + + +def uuid_is(uuid: int | str, uuid16: int) -> bool: + """Return True if a validated UUID refers to the given 16-bit short UUID. + + A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an + uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every + representation of the same UUID must be considered equivalent. + """ + if isinstance(uuid, int): + return uuid == uuid16 + return uuid.upper() in ( + f"{uuid16:04X}", + f"{uuid16:08X}", + f"{uuid16:08X}{_BASE_UUID_SUFFIX}", + ) + + # Core key to store the global configuration KEY_NOTIFY_REQUIRED = "notify_required" KEY_SET_VALUE = "set_value" @@ -195,7 +215,7 @@ def create_description_cud(char_config): return char_config # If the config displays a description, there cannot be a descriptor with the CUD UUID for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present" ) @@ -218,7 +238,7 @@ def create_notify_cccd(char_config): return char_config # If the CCCD descriptor is already present, return the config for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID): # Check if the WRITE property is set if not desc[CONF_WRITE]: raise cv.Invalid( @@ -244,7 +264,7 @@ def create_device_information_service(config): # If there is already a device information service, # there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties for service in config[CONF_SERVICES]: - if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): if ( CONF_MODEL in config or CONF_MANUFACTURER in config @@ -592,7 +612,7 @@ async def to_code(config): ) for char_conf in service_config[CONF_CHARACTERISTICS]: await to_code_characteristic(service_var, char_conf) - if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): cg.add(var.set_device_information_service(service_var)) else: cg.add(var.enqueue_start_service(service_var)) diff --git a/tests/component_tests/esp32_ble_server/__init__.py b/tests/component_tests/esp32_ble_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py new file mode 100644 index 0000000000..88307d0dcf --- /dev/null +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -0,0 +1,47 @@ +"""Tests for esp32_ble_server configuration helpers.""" + +import pytest + +from esphome.components.esp32_ble_server import ( + CCCD_DESCRIPTOR_UUID, + CUD_DESCRIPTOR_UUID, + DEVICE_INFORMATION_SERVICE_UUID, + uuid_is, +) + + +@pytest.mark.parametrize( + "uuid", + [ + DEVICE_INFORMATION_SERVICE_UUID, # int form (cv.hex_uint32_t) + "180A", # 16 bit short form (bt_uuid) + "180a", # lowercase is normalized by bt_uuid but guard anyway + "0000180A", # 32 bit form + "0000180A-0000-1000-8000-00805F9B34FB", # full 128 bit form + ], +) +def test_uuid_is_matches_all_representations(uuid) -> None: + """All representations of the same 16 bit UUID must compare equal.""" + assert uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize( + "uuid", + [ + 0x1818, # Cycling Power Service (different int) + "1818", # different 16 bit short form + "0000180B", # adjacent UUID + "0000180A-0000-1000-8000-00805F9B34FC", # wrong base UUID suffix + ], +) +def test_uuid_is_rejects_other_uuids(uuid) -> None: + """A different UUID must not be mistaken for the device information service.""" + assert not uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize("uuid16", [CUD_DESCRIPTOR_UUID, CCCD_DESCRIPTOR_UUID]) +def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: + """Reserved descriptor UUIDs match whether given as int or short string.""" + assert uuid_is(uuid16, uuid16) + assert uuid_is(f"{uuid16:04X}", uuid16) + assert uuid_is(f"{uuid16:08X}", uuid16) From 93f25258ee65f741fa4654047232ba38db1b5041 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:08:36 +1200 Subject: [PATCH 1527/2214] [config] Add --no-defaults flag to config command (#16718) --- esphome/__main__.py | 17 +++++- esphome/config.py | 15 +++++ tests/unit_tests/test_main.py | 82 ++++++++++++++++++++++++++ tests/unit_tests/test_substitutions.py | 41 +++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7c4028da44..f7d3f8e834 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1428,7 +1428,16 @@ def command_wizard(args: ArgsProtocol) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import yaml_util - if not CORE.verbose: + if getattr(args, "no_defaults", False): + user_config = getattr(config, "user_config", None) + if user_config is None: + _LOGGER.warning( + "--no-defaults requested but the user-only config snapshot is " + "unavailable; falling back to the validated configuration." + ) + else: + config = user_config + elif not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) if not args.show_secrets: @@ -2152,6 +2161,12 @@ def parse_args(argv): parser_config.add_argument( "--show-secrets", help="Show secrets in output.", action="store_true" ) + parser_config.add_argument( + "--no-defaults", + help="Only output the user-supplied configuration without " + "schema defaults applied.", + action="store_true", + ) parser_config_hash = subparsers.add_parser( "config-hash", help="Calculate the hash of the configuration." diff --git a/esphome/config.py b/esphome/config.py index 9da39a387b..91e6df8bad 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import abc from contextlib import contextmanager import contextvars +import copy import functools import heapq import logging @@ -168,6 +169,11 @@ class Config(OrderedDict, fv.FinalValidateConfig): self.output_paths: list[tuple[ConfigPath, str]] = [] # A list of components ids with the config path self.declare_ids: list[tuple[core.ID, ConfigPath]] = [] + # Snapshot of the user's configuration after substitutions/packages/ + # extend-remove resolution but before any schema validation defaults + # are applied. Populated by validate_config; used by `esphome config + # --no-defaults` to emit only the user-supplied keys. + self.user_config: ConfigType | None = None self._data = {} # Store pending validation tasks (in heap order) self._validation_tasks: list[_ValidationStepTask] = [] @@ -1076,6 +1082,15 @@ def validate_config( ) return result + # Snapshot the user's config before any schema validation defaults are + # applied. preload_core_config and later validation steps rewrite entries + # in-place with defaulted values; deep-copying here preserves the + # user-supplied keys for `esphome config --no-defaults`. + result.user_config = copy.deepcopy(config) + if substitutions is not None: + result.user_config[CONF_SUBSTITUTIONS] = copy.deepcopy(substitutions) + result.user_config.move_to_end(CONF_SUBSTITUTIONS, last=False) + # 2. Load partial core config import esphome.core.config as core_config diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8cce60d351..e99a630e83 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -471,6 +471,88 @@ def test_command_config__show_secrets_skips_redaction( assert "\\033[8m" not in output +def test_command_config__no_defaults_dumps_user_snapshot( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """``--no-defaults`` dumps ``config.user_config`` instead of the + validated config, so schema defaults don't leak into the output.""" + from esphome.config import Config + + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + validated = Config() + validated["esphome"] = {"name": "test", "build_path": "build/test"} + validated["wifi"] = {"ssid": "MyNet", "reboot_timeout": "15min"} + validated.user_config = { + "esphome": {"name": "test"}, + "wifi": {"ssid": "MyNet"}, + } + + result = command_config(args, validated) + + assert result == 0 + output = capfd.readouterr().out + assert "ssid: MyNet" in output + # Defaults present on the validated config must not appear. + assert "reboot_timeout" not in output + assert "build_path" not in output + + +def test_command_config__no_defaults_warns_when_snapshot_missing( + tmp_path: Path, + capfd: CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """If the snapshot is unavailable (e.g. a plain dict was passed in), + ``--no-defaults`` logs a warning and falls back to the input config.""" + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + result = command_config(args, {"wifi": {"ssid": "MyNet"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "ssid: MyNet" in output + assert any( + "user-only config snapshot is unavailable" in rec.message + for rec in caplog.records + ) + + +def test_command_config__no_defaults_skips_strip_default_ids( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """When ``--no-defaults`` is set, ``strip_default_ids`` isn't run -- + the user snapshot is already free of schema-injected IDs.""" + from esphome.config import Config + + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + validated = Config() + validated["sensor"] = [{"name": "x", "id": "auto_generated"}] + validated.user_config = {"sensor": [{"name": "x"}]} + + with patch( + "esphome.__main__.strip_default_ids", side_effect=AssertionError + ) as mock_strip: + result = command_config(args, validated) + + assert result == 0 + mock_strip.assert_not_called() + output = capfd.readouterr().out + assert "name: x" in output + assert "auto_generated" not in output + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index b5816f742e..baaa99f2a7 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -361,6 +361,47 @@ def test_validate_config_without_command_line_substitutions_maintains_ordered_di assert result[CONF_SUBSTITUTIONS]["var2"] == "value2" +def test_validate_config_captures_user_config_snapshot(tmp_path: Path) -> None: + """validate_config stores a deep copy of the user's config -- with + substitutions re-added and no schema defaults applied -- on + ``result.user_config`` for ``esphome config --no-defaults``. + """ + test_config = _get_test_minimal_valid_config(tmp_path) + + result = config_module.validate_config(test_config, None) + + # Snapshot is populated. + assert result.user_config is not None + # Substitutions are re-added and appear first. + assert list(result.user_config.keys())[0] == CONF_SUBSTITUTIONS + assert result.user_config[CONF_SUBSTITUTIONS]["var1"] == "value1" + # User-supplied keys are present without schema-default fields like + # ``build_path`` (which preload_core_config injects on the validated + # result's esphome section). + assert result.user_config["esphome"] == {"name": "test_device"} + assert "build_path" not in result.user_config["esphome"] + assert "min_version" not in result.user_config["esphome"] + assert result.user_config["esp32"] == {"board": "esp32dev"} + + +def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> None: + """The snapshot is independent of subsequent mutations to the result + config -- preload_core_config rewrites ``esphome:`` in place, but the + snapshot keeps the user's literal block. + """ + test_config = _get_test_minimal_valid_config(tmp_path) + + result = config_module.validate_config(test_config, None) + + assert result.user_config is not None + # preload_core_config injected build_path onto the validated config. + assert "build_path" in result["esphome"] + # The snapshot was taken before that and is unaffected. + assert "build_path" not in result.user_config["esphome"] + # And the two are not aliased. + assert result["esphome"] is not result.user_config["esphome"] + + def test_merge_config_preserves_ordered_dict() -> None: """Test that merge_config preserves OrderedDict type. From a02b9c379641a3c093df709b0fbae0b6dc02d0fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:06:45 -0500 Subject: [PATCH 1528/2214] Bump astral-sh/setup-uv from 8.1.0 to 8.2.0 (#16791) 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 2a5b701248..c6e9a358ab 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3fc19ca41..ca1fb07fda 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -170,7 +170,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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -368,7 +368,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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 8796ddf7f0..ab1ce2b587 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From d47f6b896e9edbdbcd46f70ff6016daffcdfbc09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:06:59 -0500 Subject: [PATCH 1529/2214] Bump astral-sh/setup-uv from 8.1.0 to 8.2.0 in /.github/actions/restore-python (#16790) 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 03b4803860..66d016b42d 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 3e562b9267b142c1f01502397234b4e9fb1ac23e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jun 2026 22:30:36 -0500 Subject: [PATCH 1530/2214] [ci] Fix memory impact build selecting unbuildable platform (#16788) --- script/determine-jobs.py | 104 +++++++++++++++++----------- script/helpers.py | 25 +++++++ script/test_build_components.py | 16 +---- tests/script/test_determine_jobs.py | 100 +++++++++++++++++++++----- 4 files changed, 171 insertions(+), 74 deletions(-) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index cf098f92c9..94a78e8423 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -70,6 +70,7 @@ from helpers import ( get_changed_components, get_component_from_path, get_component_test_files, + get_component_test_platforms, get_components_with_dependencies, get_cpp_changed_components, get_fixture_to_test_files, @@ -77,7 +78,6 @@ from helpers import ( get_target_branch, git_ls_files, is_validate_only_file, - parse_test_filename, root_path, ) from split_components_for_ci import create_intelligent_batches @@ -169,24 +169,6 @@ MEMORY_IMPACT_FALLBACK_COMPONENT = "api" # Representative component for core ch MEMORY_IMPACT_FALLBACK_PLATFORM = Platform.ESP32_IDF # Most representative platform MEMORY_IMPACT_MAX_COMPONENTS = 40 # Max components before results become nonsensical -# Platform-specific components that can only be built on their respective platforms -# These components contain platform-specific code and cannot be cross-compiled -# Regular components (wifi, logger, api, etc.) are cross-platform and not listed here -PLATFORM_SPECIFIC_COMPONENTS = frozenset( - { - "esp32", # ESP32 platform implementation - "esp8266", # ESP8266 platform implementation - "rp2040", # Raspberry Pi Pico / RP2040 platform implementation - "libretiny", # LibreTiny base platform implementation - "bk72xx", # Beken BK72xx platform implementation (uses LibreTiny) - "rtl87xx", # Realtek RTL87xx platform implementation (uses LibreTiny) - "ln882x", # Winner Micro LN882x platform implementation (uses LibreTiny) - "host", # Host platform (for testing on development machine) - "nrf52", # Nordic nRF52 platform implementation (uses Zephyr) - "zephyr", # Zephyr RTOS platform implementation - } -) - # Platform preference order for memory impact analysis # This order is used when no platform-specific hints are detected from filenames # Priority rationale: @@ -1006,23 +988,24 @@ def detect_memory_impact_config( ] = {} # Track which platforms each component supports for component in sorted(changed_component_set): - # Look for test files on preferred platforms - test_files = get_component_test_files(component, all_variants=True) - if not test_files: - continue - - # Check if component has tests for any preferred platform - available_platforms = [ - platform - for test_file in test_files - if (platform := parse_test_filename(test_file)[1]) != "all" - and platform in MEMORY_IMPACT_PLATFORM_PREFERENCE - ] + # Discover the platforms this component has BASE tests for, using the + # same logic as the build runner (get_component_test_platforms wraps the + # shared get_component_test_files + parse_test_filename helpers). Base + # tests only: the memory impact CI build runs test_build_components.py + # with --base-only, which compiles base test..yaml files but + # never variant test-..yaml files. Counting + # variant-only platforms here would let us select a platform the build + # then has nothing to compile for, producing no memory output. + available_platforms = { + Platform(platform) + for platform in get_component_test_platforms(component) + if platform in MEMORY_IMPACT_PLATFORM_PREFERENCE + } if not available_platforms: continue - component_platforms_map[component] = set(available_platforms) + component_platforms_map[component] = available_platforms components_with_tests.append(component) # If no components have tests, don't run memory impact @@ -1084,20 +1067,57 @@ def detect_memory_impact_config( ) platform = _select_platform_by_count(platform_counts) - # Filter out platform-specific components that are incompatible with selected platform - # Platform components (esp32, esp8266, rp2040, etc.) can only build on their own platform - # Other components (wifi, logger, etc.) are cross-platform and can build anywhere - compatible_components = [ - component - for component in components_with_tests - if component not in PLATFORM_SPECIFIC_COMPONENTS - or platform in component_platforms_map.get(component, set()) - ] + # Keep only components that have a base test on the selected platform. + # The merged build runs test_build_components.py -t --base-only, + # so a component without a base test..yaml compiles nothing and + # contributes no memory output. This also covers platform-specific + # components (esp32, esp8266, etc.), which only have tests on their own + # platform. When components don't share a common platform we build the + # largest subset that does, dropping the rest. + def components_supporting(target: Platform) -> list[str]: + return [ + component + for component in components_with_tests + if target in component_platforms_map.get(component, set()) + ] - # If no components are compatible with the selected platform, don't run + compatible_components = components_supporting(platform) + + # A platform hint (or no-common-platform fallback) can pick a platform that + # no changed component actually has a base test for, leaving nothing to + # build. In that case fall back to the platform supported by the most + # components. component_platforms_map is non-empty (guarded above) and every + # value is a non-empty platform set (components with no supported platform + # are skipped at discovery), so this always yields a buildable platform with + # at least one compatible component. + if not compatible_components: + platform = _select_platform_by_count( + Counter( + p for platforms in component_platforms_map.values() for p in platforms + ) + ) + compatible_components = components_supporting(platform) + + # Defensive backstop: unreachable given the invariant above, but guards + # against a future regression in platform selection silently passing an + # empty component list to the build. if not compatible_components: return {"should_run": "false"} + # Log components dropped because they lack a base test on the selected + # platform so partial-subset builds are visible in CI logs. + dropped_components = [ + component + for component in components_with_tests + if component not in compatible_components + ] + if dropped_components: + print( + f"Memory impact: Dropping components without a base test on " + f"{platform}: {dropped_components}", + file=sys.stderr, + ) + # Debug output print("Memory impact analysis:", file=sys.stderr) print(f" Changed components: {sorted(changed_component_set)}", file=sys.stderr) diff --git a/script/helpers.py b/script/helpers.py index 9839e766e2..1ebfe405a7 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -149,6 +149,31 @@ def get_component_test_files( return files +def get_component_test_platforms(component: str, *, base_only: bool = True) -> set[str]: + """Return the set of platforms a component has compilable test files for. + + Uses the same discovery as ``test_build_components.py`` (``get_component_test_files`` + + ``parse_test_filename``) so callers agree with what the build runner would + actually compile. With ``base_only=True`` (the default, matching the + memory-impact build's ``--base-only``), only base ``test..yaml`` + files are considered; variant ``test-..yaml`` files are + excluded. The ``"all"`` platform sentinel is excluded. + + Args: + component: Component name (e.g. "wifi") + base_only: If True, only consider base test files (default). + + Returns: + Set of platform identifiers (e.g. {"esp32-idf", "esp8266-ard"}). + """ + platforms: set[str] = set() + for test_file in get_component_test_files(component, all_variants=not base_only): + platform = parse_test_filename(test_file)[1] + if platform != "all": + platforms.add(platform) + return platforms + + def is_validate_only_file(test_file: Path) -> bool: """Return True if the given path is a config-only validate file. diff --git a/script/test_build_components.py b/script/test_build_components.py index 767b55c94b..651268609e 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -42,6 +42,7 @@ from script.analyze_component_buses import ( from script.helpers import ( get_component_test_files, is_validate_only_file, + parse_test_filename, split_conflicting_groups, ) from script.merge_component_configs import merge_component_configs @@ -122,21 +123,6 @@ def find_component_tests( return dict(component_tests) -def parse_test_filename(test_file: Path) -> tuple[str, str]: - """Parse test filename to extract test name and platform. - - Args: - test_file: Path to test file - - Returns: - Tuple of (test_name, platform) - """ - parts = test_file.stem.split(".") - if len(parts) == 2: - return parts[0], parts[1] # test, platform - return parts[0], "all" - - def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]: """Get all platform base files. diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index ac3c6424bf..acc268fa68 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1426,7 +1426,15 @@ def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) -> @pytest.mark.usefixtures("mock_target_branch_dev") def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: - """Test memory impact detection when components have no common platform.""" + """Test memory impact detection when components have no common platform. + + The merged build runs with --base-only on a single platform, so components + without a base test on the selected platform cannot be built and must be + dropped. We build the largest subset that shares the selected platform + rather than handing the runner components it has nothing to compile for + (which previously produced "0 passed, 0 failed" and a failed memory + extraction). + """ # Create test directory structure tests_dir = tmp_path / "tests" / "components" @@ -1453,12 +1461,70 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: result = determine_jobs.detect_memory_impact_config() - # Should pick the most frequently supported platform + # No common platform: pick the most preferred platform among those supported + # (esp8266-ard outranks esp32-idf in the preference list) and build only the + # components that have a base test on it. wifi (esp32-idf only) is dropped. assert result["should_run"] == "true" - assert set(result["components"]) == {"wifi", "logger"} - # When no common platform, picks most commonly supported - # esp8266-ard is preferred over esp32-idf in the preference list - assert result["platform"] in ["esp32-idf", "esp8266-ard"] + assert result["platform"] == "esp8266-ard" + assert result["components"] == ["logger"] + assert result["use_merged_config"] == "true" + + +def test_detect_memory_impact_config_variant_only_platform_excluded( + tmp_path: Path, +) -> None: + """Regression test for the const + shelly_dimmer memory-impact failure. + + Reproduces https://github.com/esphome/esphome/actions/runs/26746938473 + where a platform hint selected esp32-idf even though neither changed + component had a base test.esp32-idf.yaml. The merged --base-only build then + found nothing to compile ("0 passed, 0 failed") and memory extraction + failed. Also covers a component whose only esp32-idf test is a *variant* + (test-*.esp32-idf.yaml): --base-only never compiles variants, so it must + not count toward platform availability. + """ + tests_dir = tmp_path / "tests" / "components" + + # const: base test only on esp32-s3-idf + const_dir = tests_dir / "const" + const_dir.mkdir(parents=True) + (const_dir / "test.esp32-s3-idf.yaml").write_text("test: const") + + # shelly_dimmer: base test only on esp8266-ard + shelly_dir = tests_dir / "shelly_dimmer" + shelly_dir.mkdir(parents=True) + (shelly_dir / "test.esp8266-ard.yaml").write_text("test: shelly_dimmer") + + # mdns: only a VARIANT test on esp32-idf (no base test.esp32-idf.yaml). + # --base-only would never build it, so it must be excluded entirely. + mdns_dir = tests_dir / "mdns" + mdns_dir.mkdir(parents=True) + (mdns_dir / "test-min.esp32-idf.yaml").write_text("test: mdns") + + with ( + patch.object(determine_jobs, "root_path", str(tmp_path)), + patch.object(helpers, "root_path", str(tmp_path)), + patch.object(determine_jobs, "changed_files") as mock_changed_files, + ): + # The "_esp32" filename yields an esp32-idf platform hint, reproducing + # the original bug where the hint picked a platform no component could + # build as a base test. + mock_changed_files.return_value = [ + "esphome/components/const/const.cpp", + "esphome/components/shelly_dimmer/shelly_dimmer_esp32.cpp", + "esphome/components/mdns/mdns.cpp", + ] + + result = determine_jobs.detect_memory_impact_config() + + # The esp32-idf hint is unbuildable (no base test), so we fall back to the + # platform supported by the most components, broken by preference order: + # esp8266-ard (shelly_dimmer) outranks esp32-s3-idf (const). Only the + # component with a base test on the selected platform is returned; the + # variant-only mdns is excluded entirely. + assert result["should_run"] == "true" + assert result["platform"] == "esp8266-ard" + assert result["components"] == ["shelly_dimmer"] assert result["use_merged_config"] == "true" @@ -1545,12 +1611,16 @@ def test_detect_memory_impact_config_includes_base_bus_components( @pytest.mark.usefixtures("mock_target_branch_dev") -def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: - """Test memory impact detection for components with only variant test files. +def test_detect_memory_impact_config_variant_only_components_skipped( + tmp_path: Path, +) -> None: + """Components with only variant tests are skipped for memory impact. - This verifies that memory impact analysis works correctly for components like - improv_serial, ethernet, mdns, etc. which only have variant test files - (test-*.yaml) instead of base test files (test.*.yaml). + Components like improv_serial and ethernet only have variant test files + (test-*.yaml), no base test..yaml. The memory-impact build runs + test_build_components.py with --base-only, which never compiles variants, so + these components have nothing buildable and must not be selected. Selecting + them previously produced "0 passed, 0 failed" and a failed memory extraction. """ # Create test directory structure tests_dir = tmp_path / "tests" / "components" @@ -1581,12 +1651,8 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: result = determine_jobs.detect_memory_impact_config() - # Should detect both components even though they only have variant tests - assert result["should_run"] == "true" - assert set(result["components"]) == {"improv_serial", "ethernet"} - # Both components support esp32-idf - assert result["platform"] == "esp32-idf" - assert result["use_merged_config"] == "true" + # Neither component has a base test, so nothing is buildable under --base-only + assert result["should_run"] == "false" # Tests for clang-tidy split mode logic From 53d685f2423284ad3f5d5c219de72e8d2ff385b8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:37:29 -0400 Subject: [PATCH 1531/2214] [mixer] Give mixer test its own speaker id to avoid CI grouping collision (#16792) --- tests/components/mixer/common.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index ef613b82bc..dee42ed280 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -13,13 +13,13 @@ i2s_audio: speaker: - platform: i2s_audio - id: speaker_id + id: mixer_output_speaker_id dac_type: external i2s_dout_pin: ${dout_pin} bits_per_sample: 32bit channel: stereo - platform: mixer - output_speaker: speaker_id + output_speaker: mixer_output_speaker_id bits_per_sample: 32 num_channels: 2 source_speakers: From ffaa31febc7f7bb5b02f2ed7df4cdfbca2e00e65 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:48:34 -0400 Subject: [PATCH 1532/2214] [clang-tidy] Hash idf_component.yml and trigger hash hook on more inputs (#16753) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .clang-tidy.hash | 2 +- .pre-commit-config.yaml | 2 +- script/clang_tidy_hash.py | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 29c8b414f6..648b31f8f0 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a30d2e50f2cac76e9c504eb7e5b250070dc92df23469c44a7eb8e52e26fd375d +44db8a62d94c8fba83b95b73938db4377ebacc0adb504881387389f1cd8f2f3a diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a076128975..3b6278e6b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,7 +63,7 @@ repos: name: Update clang-tidy hash entry: python script/clang_tidy_hash.py --update-if-changed language: python - files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$ + files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt|sdkconfig\.defaults|esphome/idf_component\.yml)$ pass_filenames: false additional_dependencies: [] - id: ci-custom diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index f478535567..1a6e4eb7be 100755 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -105,6 +105,12 @@ def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: sdkconfig_content = read_file_bytes(sdkconfig_path) hasher.update(sdkconfig_content) + # Hash esphome/idf_component.yml: its managed deps drive the ESP-IDF + # build's include set, which clang-tidy analyzes. + idf_component_path = repo_root / "esphome" / "idf_component.yml" + if idf_component_path.exists(): + hasher.update(read_file_bytes(idf_component_path)) + return hasher.hexdigest() From 891ec33c94ef89d48b2dedea25f8dd6cf3d4d660 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:39:17 -0400 Subject: [PATCH 1533/2214] [esp32] Deduplicate PlatformIO library conversion by resolving the batch together (#16756) --- esphome/components/esp32/__init__.py | 31 +- esphome/espidf/component.py | 566 ++++++++++--------- esphome/espidf/extra_script.py | 4 +- tests/unit_tests/test_espidf_component.py | 626 ++++++++++++++-------- 4 files changed, 702 insertions(+), 525 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d2dc979966..160c06534e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -46,10 +46,10 @@ from esphome.const import ( Toolchain, __version__, ) -from esphome.core import CORE, EsphomeError, HexInt, Library +from esphome.core import CORE, EsphomeError, HexInt from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.espidf.component import generate_idf_component +from esphome.espidf.component import generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed from esphome.types import ConfigType @@ -2598,13 +2598,6 @@ def _write_sdkconfig(): clean_build(clear_pio_cache=False) -def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: - dependency: dict[str, str] = {} - name, _version, path = generate_idf_component(library) - dependency["override_path"] = str(path) - return name, dependency - - def _write_idf_component_yml(): yml_path = CORE.relative_build_path("src/idf_component.yml") dependencies: dict[str, dict] = {} @@ -2678,13 +2671,21 @@ def _write_idf_component_yml(): ) if CORE.using_toolchain_esp_idf: - # Try to convert PlatformIO library to ESP-IDF components - for name, library in CORE.platformio_libraries.items(): + # Convert the PlatformIO libraries to ESP-IDF components as a batch so + # PlatformIO resolves the whole dependency tree at once -- deduplicating + # shared transitive deps (e.g. esphome/libsodium pulled by both noise-c + # and esp_wireguard) to a single version instead of clashing + # override_path entries. + libraries = [ + library + for name, library in CORE.platformio_libraries.items() # Don't process arduino libraries - if name in ARDUINO_DISABLED_LIBRARIES: - continue - dependency_name, dependency = _platformio_library_to_dependency(library) - dependencies[dependency_name] = dependency + if name not in ARDUINO_DISABLED_LIBRARIES + ] + for component in generate_idf_components(libraries): + dependencies[component.get_sanitized_name()] = { + "override_path": str(component.path) + } if CORE.data[KEY_ESP32][KEY_COMPONENTS]: components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS] diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 050002d9e2..7398a91c36 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -1,4 +1,6 @@ +from collections import deque from collections.abc import Callable +from dataclasses import dataclass, field import glob import hashlib import itertools @@ -8,7 +10,7 @@ import os from pathlib import Path import re import tempfile -from typing import TypeVar +from typing import Any, TypeVar from urllib.parse import urlparse, urlsplit, urlunsplit from esphome import git, yaml_util @@ -154,72 +156,6 @@ class IDFComponent: self.path = self.source.download(self.get_sanitized_name(), force=force) -def _get_package_from_pio_registry( - username: str | None, pkgname: str, requirements: str -) -> tuple[str, str, str | None, str | None]: - """ - Fetch package information from PlatformIO registry. - - This function queries the PlatformIO registry to find a library package - that matches the given criteria and returns its metadata including version - and download URL. - - Args: - username: The owner/username of the package (can be None) - pkgname: The name of the package - requirements: Version requirements (e.g., "^1.0.0") - - Returns: - tuple[str, str, str | None, str | None]: - A tuple containing (owner, name, version, download_url) - where version and download_url can be None if not found - """ - - from platformio.package.manager._registry import PackageManagerRegistryMixin - from platformio.package.meta import PackageSpec - - # Create a minimal PackageManagerRegistry class - class PackageManagerRegistry(PackageManagerRegistryMixin): - def __init__(self): - self._registry_client = None - self.pkg_type = "library" - - @staticmethod - def is_system_compatible(value, custom_system=None): - return True - - pio_registry = PackageManagerRegistry() - - # Fetch package metadata from registry - package = pio_registry.fetch_registry_package( - PackageSpec( - owner=username, - name=pkgname, - ) - ) - owner = package["owner"]["username"] - name = package["name"] - - # Find the best matching version based on requirements - version = pio_registry.pick_best_registry_version( - package.get("versions"), - PackageSpec(owner=username, name=pkgname, requirements=requirements), - ) - - # If no version found, return with None for version and URL - if not version: - return owner, name, None, None - - # Find the compatible package file for this version - pkgfile = pio_registry.pick_compatible_pkg_file(version["files"]) - - # If no package file found, return with None for URL but valid version - if not pkgfile: - return owner, name, version["name"], None - - return owner, name, version["name"], pkgfile["download_url"] - - def _apply_extra_script(component: IDFComponent) -> None: """Run a PIO ``extraScript`` and fold its captured env vars into ``component.data["build"]["flags"]`` so the existing -L/-l/-D @@ -339,77 +275,6 @@ def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[s return [r for r in selected if Path(r).is_file()] -def _convert_library_to_component(library: Library) -> IDFComponent: - """ - Convert a Library object to an IDFComponent object by resolving its metadata. - - This function handles the conversion of library specifications to component - objects, resolving versions through PlatformIO registry when needed or - parsing direct repository URLs. - - Args: - library: The Library object containing name, version, and/or repository information - - Returns: - IDFComponent: The resolved component with name, version, and URL - - Raises: - RuntimeError: If no artifact can be found for the library - """ - name = None - version = None - source = None - - # Repository is provided directly - if library.repository: - # Parse repository URL: path becomes the component name, fragment - # (if any) becomes the git ref stored on GitSource. A missing - # fragment is fine -- clone_or_update leaves the depth-1 clone on - # the remote's default branch, matching PIO's lib_deps behavior - # and external_components handling. - split_result = urlsplit(library.repository) - - # Sanitize name - name = str(split_result.path).strip("/") - name = name.removesuffix(".git") - - # IDF Component Manager only accepts "*", a 40-char commit hash, or - # semver here. The actual git ref is preserved in GitSource.ref; - # override_path makes this field cosmetic at build time. - version = "*" - repository = urlunsplit(split_result._replace(fragment="")) - - ref = split_result.fragment.strip() or None - source = GitSource(str(repository), ref) - - # Version is provided - resolve using PlatformIO registry - elif library.version: - name = library.name - if "/" not in name: - owner, pkgname = None, name - else: - owner, pkgname = name.split("/", 1) - - owner, pkgname, version, url = _get_package_from_pio_registry( - owner, pkgname, library.version - ) - if url is None: - raise RuntimeError( - f"Can't find an pkg file from PlatformIO registry for library {library}" - ) - - name = _owner_pkgname_to_name(owner, pkgname) - source = URLSource(url) - - if source is None: - raise RuntimeError(f"Can't find an artifact associated to library {library}") - - assert name, "Missing library name" - assert version, "Missing library version" - - return IDFComponent(name, version, source) - - def _split_list_by_condition( items: list[str], match_fn: Callable[[str], str | None] ) -> tuple[list[str], list[str]]: @@ -599,8 +464,8 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if "dependencies" not in data: data["dependencies"] = {} - # Every dependency goes through _generate_idf_component → - # component.download() before this runs, so .path is always set. + # Every dependency has been resolved and downloaded before this runs, + # so .path is always set. data["dependencies"][dependency.get_sanitized_name()] = { "override_path": str(dependency.path), } @@ -657,81 +522,6 @@ def _check_library_data(data: dict): ) -def _process_dependencies(component: IDFComponent): - """ - Process library dependencies and generate ESP-IDF components. - - Args: - component: IDFComponent object being processed - - Returns: - None - """ - - name, version = component.name, component.version - dependencies = component.data.get("dependencies") - if not dependencies: - return - - # PIO's library.json accepts both the list-of-dicts form and the - # shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize - # the dict form so the loop below sees a uniform list. Iterating a - # dict gives string keys, which would silently fail the - # ``"name" in dependency`` substring check and skip every entry. - if isinstance(dependencies, dict): - normalized = [] - for raw_name, spec in dependencies.items(): - if "/" in raw_name: - owner, pkgname = raw_name.split("/", 1) - else: - owner, pkgname = None, raw_name - entry = {"name": pkgname, "owner": owner} - if isinstance(spec, dict): - entry.update(spec) - else: - entry["version"] = spec - normalized.append(entry) - dependencies = normalized - - _LOGGER.info("Processing %s@%s component dependencies...", name, version) - for dependency in dependencies: - # Validate dependency structure - if not all(k in dependency for k in ("name", "version")): - _LOGGER.debug("Ignore invalid library: %s", dependency) - continue - - try: - _check_library_data(dependency) - except InvalidIDFComponent as e: - _LOGGER.debug( - "Skip %s@%s: %s", dependency["name"], dependency["version"], str(e) - ) - continue - - # The version field may actually contain a URL - version = dependency["version"] - url = None - try: - result = urlparse(version) - if all([result.scheme, result.netloc]): - url, version = version, None - except (TypeError, ValueError): - pass - - # Generate ESP-IDF component from PlatformIO library - component.dependencies.append( - _generate_idf_component( - Library( - _owner_pkgname_to_name( - dependency.get("owner", None), dependency.get("name") - ), - version, - url, - ) - ) - ) - - def _parse_library_json(library_json_path: PathType): """ Load and parse a JSON file describing a library. @@ -772,92 +562,294 @@ def _parse_library_properties(library_properties_path: PathType): return data -def _generate_idf_component(library: Library, force: bool = False) -> IDFComponent: +def _make_registry_client() -> Any: + """Create a minimal PlatformIO registry client with no system filtering. + + ``is_system_compatible`` is forced True so version selection is driven purely + by the requested version requirements -- ESP-IDF/target compatibility is + handled elsewhere, not by the PlatformIO registry. """ - Generate an ESP-IDF component from a library specification. + from platformio.package.manager._registry import PackageManagerRegistryMixin - This function resolves the library, downloads it, processes metadata files, - and generates necessary ESP-IDF build files (CMakeLists.txt, idf_component.yml). + class _Registry(PackageManagerRegistryMixin): + def __init__(self) -> None: + self._registry_client = None + self.pkg_type = "library" - Args: - library: The library specification containing name, version, and repository URL - force: If True, forces re-download of the library even if it exists locally + @staticmethod + def is_system_compatible(value: Any, custom_system: Any = None) -> bool: + return True - Returns: - IDFComponent: The generated component object with resolved metadata + return _Registry() + + +def _resolve_registry_version( + owner: str | None, pkgname: str, requirements: set[str] +) -> tuple[str, str, str, str]: + """Resolve a registry package to the single highest version satisfying ALL + the given requirements; return ``(owner, name, version, download_url)``. + + Intersecting every requirement (rather than resolving each consumer in + isolation) makes the result independent of processing order and guarantees + no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as + both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. """ - _LOGGER.info("Generate IDF component for %s library ...", library) + from platformio.package.meta import PackageSpec - # Resolve component name, version and url - component = _convert_library_to_component(library) - name, version = component.name, component.version + registry = _make_registry_client() + package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) + owner = package["owner"]["username"] + name = package["name"] - # Download the library - component.download(force) - - # Paths to component metadata and build files - library_json_path = component.path / "library.json" - library_properties_path = component.path / "library.properties" - cmakelists_txt_path = component.path / "CMakeLists.txt" - idf_component_yml_path = component.path / "idf_component.yml" - - # Bundled CMakeLists.txt / idf_component.yml are ignored -- library - # authors' IDF support is frequently broken (bogus REQUIRES, hard-coded - # arduino-esp32, etc.). We always regenerate. - - if library_json_path.is_file(): - component.data = _parse_library_json(library_json_path) - elif library_properties_path.is_file(): - component.data = _parse_library_properties(library_properties_path) - else: + # Chaining the per-requirement filter intersects all constraints. + versions = package.get("versions") or [] + for requirement in sorted(requirements): + versions = registry.get_compatible_registry_versions( + versions, PackageSpec(owner=owner, name=name, requirements=requirement) + ) + if not versions: raise RuntimeError( - "Invalid PIO library: missing library.json and/or library.properties" + f"No version of {owner}/{name} satisfies all requirements " + f"{sorted(requirements)} requested across the library tree" ) - # Check if the component is usable with ESP-IDF before executing any - # third-party Python from the library (``_apply_extra_script`` below). - _check_library_data(component.data) - - # If the library declares a PIO ``extraScript``, run it against a - # fake SCons env so we can fold its captured LIBPATH/LIBS/etc into - # the build-flag pipeline ``generate_cmakelists_txt`` consumes - # below. Without this, libraries that wire per-MCU archive linking - # via extraScript fail to link under native ESP-IDF. - _apply_extra_script(component) - - # Handle the dependencies (convert PlatformIO library to ESP-IDF component if needed) - _process_dependencies(component) - - _LOGGER.debug("Generating CMakeLists.txt for %s@%s ...", name, version) - write_file_if_changed( - cmakelists_txt_path, - generate_cmakelists_txt(component), - ) - - _LOGGER.debug("Generating idf_component.yml for %s@%s ...", name, version) - write_file_if_changed( - idf_component_yml_path, - generate_idf_component_yml(component), - ) - - return component + best = registry.pick_best_registry_version(versions) + pkgfile = registry.pick_compatible_pkg_file(best["files"]) + if not pkgfile: + raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") + return owner, name, best["name"], pkgfile["download_url"] -def generate_idf_component( - library: Library, force: bool = False -) -> tuple[str, str, Path]: +def _normalize_dependencies(dependencies: Any) -> list[dict]: + """Normalize a library manifest's ``dependencies`` to a list of dicts. + + PIO's library.json accepts both the list-of-dicts form and the shorthand + dict form (``{"owner/Name": "version_spec"}``); normalize the latter so + callers see a uniform list. """ - Generate an ESP-IDF component and return its name, version, and path. + if not dependencies: + return [] + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + return normalized + return [d for d in dependencies if isinstance(d, dict)] - This is a wrapper function that calls _generate_idf_component and returns - the standardized tuple format (name, version, path). - Args: - library: The library specification containing name, version, and repository URL - force: If True, forces re-download of the library even if it exists locally +@dataclass +class _LibNode: + """A node in the library dependency graph being resolved as a batch.""" - Returns: - tuple[str, str, Path]: A tuple containing (component_name, component_version, component_path) + key: str + is_git: bool + owner: str | None = None + pkgname: str | None = None + requirements: set[str] = field(default_factory=set) + url: str | None = None + ref: str | None = None + edges: set[str] = field(default_factory=set) + + +def _node_key( + name: str | None, version: str | None, repository: str | None +) -> tuple[str, bool, tuple[str | None, str | None]]: + """Return ``(key, is_git, locator)`` for a library or dependency spec. + + The key is derived from the *input* spec (the registry name as written, or + the git URL path), not the resolved canonical name. So a package referenced + inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps + to distinct keys and isn't deduplicated; ``generate_idf_components`` warns + about that after resolution rather than merging the nodes. """ - component = _generate_idf_component(library, force) - return component.get_sanitized_name(), component.version, component.path + if repository: + split_result = urlsplit(repository) + key = str(split_result.path).strip("/").removesuffix(".git") + ref = split_result.fragment.strip() or None + url = urlunsplit(split_result._replace(fragment="")) + return key, True, (url, ref) + if name and "/" in name: + owner, pkgname = name.split("/", 1) + else: + owner, pkgname = None, name + return name, False, (owner, pkgname) + + +def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: + """Resolve and convert a batch of PlatformIO libraries to IDF components. + + Resolves the whole set together rather than each library independently: it + walks the dependency graph collecting every version *requirement* per + component name, then resolves each name once to a single version satisfying + all of them. So a transitive dependency shared under + different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and + ``esp_wireguard``) becomes one component instead of two clashing + ``override_path`` entries -- order-independently, and without ever violating + a stated constraint. + + The returned list holds the top-level components (those directly requested); + transitive dependencies are converted too and wired into each component's + generated manifest. + """ + nodes: dict[str, _LibNode] = {} + + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: + key, is_git, locator = _node_key(name, version, repository) + node = nodes.get(key) or _LibNode(key=key, is_git=is_git) + nodes[key] = node + if is_git: + node.is_git = True + node.url, node.ref = locator + else: + node.owner, node.pkgname = locator + if version: + node.requirements.add(version) + return key + + top_level = [ + add_spec(library.name, library.version, library.repository) + for library in libraries + ] + + # Collect + resolve to a fixpoint: a node is (re)resolved whenever its + # requirement set has grown since the last time, so every requirement in the + # graph is accounted for before conversion. + components: dict[str, IDFComponent] = {} + resolved_requirements: dict[str, frozenset[str]] = {} + top_level_keys = set(top_level) + worklist = deque(dict.fromkeys(top_level)) + while worklist: + key = worklist.popleft() + node = nodes[key] + + # A node is queued once per referring edge; skip the (uncached) registry + # lookup + download + dependency walk unless its requirement set grew + # since the last resolve. Requirements only ever grow, so this still + # converges the fixpoint and terminates dependency cycles. + requirements = frozenset(node.requirements) + if resolved_requirements.get(key) == requirements: + continue + resolved_requirements[key] = requirements + + if node.is_git: + component = IDFComponent(key, "*", GitSource(node.url, node.ref)) + else: + owner, name, version, url = _resolve_registry_version( + node.owner, node.pkgname, node.requirements + ) + component = IDFComponent( + _owner_pkgname_to_name(owner, name), version, URLSource(url) + ) + component.download() + + library_json_path = component.path / "library.json" + library_properties_path = component.path / "library.properties" + if library_json_path.is_file(): + component.data = _parse_library_json(library_json_path) + elif library_properties_path.is_file(): + component.data = _parse_library_properties(library_properties_path) + else: + raise RuntimeError( + f"Invalid PIO library {key}: missing library.json and " + "library.properties" + ) + + try: + _check_library_data(component.data) + except InvalidIDFComponent as e: + # Skip an incompatible transitive dependency, but fail fast if a + # top-level library the build explicitly requested is incompatible. + if key in top_level_keys: + raise RuntimeError( + f"Requested library {key} is not compatible with ESP-IDF: {e}" + ) from e + _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) + continue + components[key] = component + + # Requirements changed (we got past the short-circuit above), so + # (re)walk this component's dependencies. + node.edges = set() + for dependency in _normalize_dependencies(component.data.get("dependencies")): + if "name" not in dependency or "version" not in dependency: + continue + try: + _check_library_data(dependency) + except InvalidIDFComponent as e: + _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) + continue + # The version field may actually be a URL (git/archive dependency). + dep_version = dependency["version"] + dep_url = None + try: + parsed = urlparse(dep_version) + if all([parsed.scheme, parsed.netloc]): + dep_url, dep_version = dep_version, None + except (TypeError, ValueError): + pass + dep_key = add_spec( + _owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")), + dep_version, + dep_url, + ) + node.edges.add(dep_key) + worklist.append(dep_key) + + # A git source wins over any registry version requested for the same + # component. That's intentional, but warn so a dropped registry pin isn't a + # silent surprise. + for node in nodes.values(): + if node.is_git and node.requirements: + _LOGGER.warning( + "Library %s is requested both from a git source (%s) and as " + "registry version(s) %s; using the git source.", + node.key, + node.url, + sorted(node.requirements), + ) + + # Two graph nodes that resolve to the same component name (e.g. a package + # referenced both bare and as ``owner/name``) are not deduplicated and can + # produce conflicting component definitions. Warn so it's not silent. + canonical_keys: dict[str, str] = {} + for node_key, component in components.items(): + canonical = component.get_sanitized_name() + if canonical_keys.setdefault(canonical, node_key) != node_key: + _LOGGER.warning( + "Library %s is referenced under multiple names (%s and %s); these " + "are not deduplicated. Reference it consistently as %s.", + canonical, + canonical_keys[canonical], + node_key, + canonical, + ) + + # Wire each component's dependencies to the single resolved instances, then + # regenerate build files. + for key, component in components.items(): + component.dependencies = [ + components[dep_key] + for dep_key in sorted(nodes[key].edges) + if dep_key in components + ] + for component in components.values(): + _apply_extra_script(component) + write_file_if_changed( + component.path / "CMakeLists.txt", + generate_cmakelists_txt(component), + ) + write_file_if_changed( + component.path / "idf_component.yml", + generate_idf_component_yml(component), + ) + + return [components[key] for key in top_level if key in components] diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index 5f59254aee..4d06fb842a 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -6,8 +6,8 @@ section instead of static fields. The script runs under SCons during PIO's build and mutates the active ``Environment`` (``env.Append``, ``env.Replace``, …) — chiefly to set ``LIBPATH``/``LIBS`` per chip MCU. -ESPHome's PIO→IDF converter (``_generate_idf_component``) doesn't run -SCons, so these scripts were previously ignored and any library +ESPHome's PIO→IDF converter doesn't run SCons, so these scripts were +previously ignored and any library relying on them failed to link under ``toolchain: esp-idf``. This module provides a small shim that ``exec``s an extra-script with a fake ``env`` object, captures the common ``env.Append(...)`` calls, diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 4f0a71053d..602ff03942 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -21,13 +21,15 @@ from esphome.espidf.component import ( URLSource, _check_library_data, _collect_filtered_files, - _convert_library_to_component, + _node_key, + _normalize_dependencies, _parse_library_json, _parse_library_properties, - _process_dependencies, + _resolve_registry_version, _split_list_by_condition, generate_cmakelists_txt, generate_idf_component_yml, + generate_idf_components, ) @@ -162,43 +164,6 @@ def test_generate_cmakelists_txt_references_project_managed_components_variable( assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content -def test_generate_idf_component_overwrites_bundled_files( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - esp32_idf_core: None, -) -> None: - # A library that ships its own CMakeLists.txt + idf_component.yml must - # have both replaced by ESPHome's generated content. Library authors' - # bundled IDF metadata is frequently broken (bogus REQUIRES, hard-coded - # frameworks), so we always regenerate from library.json. - from esphome.espidf.component import _generate_idf_component - - (tmp_path / "src").mkdir() - (tmp_path / "src" / "main.cpp").write_text("// dummy\n") - (tmp_path / "library.json").write_text(json.dumps({"name": "tripwire-lib"})) - (tmp_path / "CMakeLists.txt").write_text("# TRIPWIRE_BUNDLED_CMAKELISTS\n") - (tmp_path / "idf_component.yml").write_text("# TRIPWIRE_BUNDLED_MANIFEST\n") - - fake_component = IDFComponent( - "owner/tripwire-lib", "1.0.0", source=URLSource("http://dummy") - ) - fake_component.path = tmp_path - monkeypatch.setattr( - esphome.espidf.component, - "_convert_library_to_component", - lambda _lib: fake_component, - ) - monkeypatch.setattr(fake_component, "download", lambda force=False: None) - - _generate_idf_component(Library("owner/tripwire-lib", "1.0.0", None)) - - cml = (tmp_path / "CMakeLists.txt").read_text() - manifest = (tmp_path / "idf_component.yml").read_text() - assert "TRIPWIRE_BUNDLED_CMAKELISTS" not in cml - assert "TRIPWIRE_BUNDLED_MANIFEST" not in manifest - assert "idf_component_register" in cml - - def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) @@ -419,200 +384,58 @@ empty= assert "empty" not in result -def test_convert_library_with_repository(): - lib = Library("name", None, "https://github.com/foo/bar.git#v1.2.3") - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "*" - assert isinstance(result.source, GitSource) - assert result.source.ref == "v1.2.3" - - -def test_convert_library_with_branch_ref(): - lib = Library("name", None, "https://github.com/foo/bar.git#some-branch") - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "*" - assert isinstance(result.source, GitSource) - assert result.source.ref == "some-branch" - - -def test_convert_library_missing_ref_uses_default_branch(): - """A bare URL with no #ref clones the remote's default branch. - - Matches PIO's lib_deps behavior and external_components handling -- - git.clone_or_update with ref=None leaves the depth-1 clone on - whatever branch the remote HEAD points at. - """ - lib = Library("name", None, "https://github.com/foo/bar.git") - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "*" - assert isinstance(result.source, GitSource) - assert result.source.ref is None - - -def test_convert_library_registry(monkeypatch): - lib = Library("foo/bar", "^1.0.0", None) - - monkeypatch.setattr( - esphome.espidf.component, - "_get_package_from_pio_registry", - lambda o, n, r: ("foo", "bar", "1.2.3", "http://example.com/pkg.zip"), +def test_node_key_git_with_ref(): + key, is_git, locator = _node_key( + "name", None, "https://github.com/foo/bar.git#v1.2.3" ) - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "1.2.3" - assert isinstance(result.source, URLSource) + assert key == "foo/bar" + assert is_git is True + assert locator == ("https://github.com/foo/bar.git", "v1.2.3") -def test_process_dependencies_adds_valid_dependency(tmp_component, monkeypatch): - tmp_component.data = { - "dependencies": [ - { - "name": "foo", - "version": "1.0", - } - ] - } - - monkeypatch.setattr( - esphome.espidf.component, - "_generate_idf_component", - lambda lib: esphome.espidf.component.IDFComponent( - lib.name, lib.version, source=URLSource("http://dummy.com") - ), +def test_node_key_git_branch_ref(): + key, is_git, locator = _node_key( + "name", None, "https://github.com/foo/bar.git#some-branch" ) - - monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) - - _process_dependencies(tmp_component) - - assert len(tmp_component.dependencies) == 1 + assert (key, is_git, locator[1]) == ("foo/bar", True, "some-branch") -def test_process_dependencies_skips_invalid(tmp_component): - tmp_component.data = { - "dependencies": [ - {"name": "foo", "version": "1.0", "platforms": ["arduino"]}, - {"invalid": "entry"}, - ] - } - - _process_dependencies(tmp_component) - - assert tmp_component.dependencies == [] +def test_node_key_git_no_ref(): + _key, is_git, locator = _node_key("name", None, "https://github.com/foo/bar.git") + assert is_git is True + assert locator == ("https://github.com/foo/bar.git", None) -def test_process_dependencies_dict_form(tmp_component, monkeypatch): - """PIO library.json shorthand ``{"owner/Name": "version"}`` is honored. +def test_node_key_registry_owner_name(): + key, is_git, locator = _node_key("foo/bar", "^1.0.0", None) + assert (key, is_git, locator) == ("foo/bar", False, ("foo", "bar")) - Iterating a dict gives string keys, which would silently fail the - ``"name" in dependency`` substring check. Normalize to list-of-dicts - first so the dict form (used by e.g. tesla-ble for its nanopb dep) - is treated the same as the verbose list form. - """ - captured: list[Library] = [] - def fake_generate(library): - captured.append(library) - return IDFComponent( - library.name, library.version, source=URLSource("http://dummy.com") - ) +def test_node_key_registry_bare_name(): + key, is_git, locator = _node_key("bar", "1.0", None) + assert (key, is_git, locator) == ("bar", False, (None, "bar")) - tmp_component.data = { - "dependencies": { - "nanopb/Nanopb": "^0.4.91", - "BareName": "1.2.3", - } - } - monkeypatch.setattr( - esphome.espidf.component, "_generate_idf_component", fake_generate + +def test_normalize_dependencies_none(): + assert _normalize_dependencies(None) == [] + + +def test_normalize_dependencies_list_form(): + deps = [{"name": "foo", "version": "1.0"}] + assert _normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}] + + +def test_normalize_dependencies_dict_form(): + out = _normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"}) + assert {"name": "Nanopb", "owner": "nanopb", "version": "^0.4.91"} in out + assert {"name": "BareName", "owner": None, "version": "1.2.3"} in out + + +def test_normalize_dependencies_dict_form_nested_spec(): + out = _normalize_dependencies( + {"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}} ) - monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) - - _process_dependencies(tmp_component) - - assert len(tmp_component.dependencies) == 2 - names = sorted(lib.name for lib in captured) - versions = sorted(lib.version for lib in captured) - assert names == ["BareName", "nanopb/Nanopb"] - assert versions == ["1.2.3", "^0.4.91"] - - -def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch): - """A dict-value that's a URL gets routed to ``repository`` like the list form.""" - captured: list[Library] = [] - - def fake_generate(library): - captured.append(library) - return IDFComponent(library.name, "*", source=URLSource("http://dummy.com")) - - tmp_component.data = { - "dependencies": { - "foo/Bar": "https://github.com/foo/bar.git#main", - } - } - monkeypatch.setattr( - esphome.espidf.component, "_generate_idf_component", fake_generate - ) - monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) - - _process_dependencies(tmp_component) - - assert len(captured) == 1 - assert captured[0].name == "foo/Bar" - assert captured[0].version is None - assert captured[0].repository == "https://github.com/foo/bar.git#main" - - -def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch): - """A dict-value that's itself a dict is merged into the entry. - - PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}`` - for entries that need fields beyond just a version (platforms, - frameworks, etc.). The extra fields flow into _check_library_data - via the entry merge. - """ - captured: list[Library] = [] - checked: list[dict] = [] - - def fake_generate(library): - captured.append(library) - return IDFComponent( - library.name, library.version, source=URLSource("http://dummy.com") - ) - - tmp_component.data = { - "dependencies": { - "nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}, - } - } - monkeypatch.setattr( - esphome.espidf.component, "_generate_idf_component", fake_generate - ) - monkeypatch.setattr( - esphome.espidf.component, - "_check_library_data", - checked.append, - ) - - _process_dependencies(tmp_component) - - assert len(captured) == 1 - assert captured[0].name == "nanopb/Nanopb" - assert captured[0].version == "^0.4.91" - # Extra spec fields reach _check_library_data so platform/framework - # gating still applies. - assert checked == [ + assert out == [ { "name": "Nanopb", "owner": "nanopb", @@ -620,3 +443,364 @@ def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypa "platforms": "espidf", } ] + + +def _patch_registry(monkeypatch, versions): + """Patch the registry client to serve a canned version list (no network). + + Only ``fetch_registry_package`` is faked; the real + ``get_compatible_registry_versions`` / ``pick_best_registry_version`` run on + the canned data so the intersection logic is exercised for real. + """ + registry = esphome.espidf.component._make_registry_client() + monkeypatch.setattr( + registry, + "fetch_registry_package", + lambda spec: { + "owner": {"username": spec.owner or "owner"}, + "name": spec.name, + "versions": [ + {"name": v, "files": [{"download_url": f"http://x/{v}.tar.gz"}]} + for v in versions + ], + }, + ) + monkeypatch.setattr( + esphome.espidf.component, "_make_registry_client", lambda: registry + ) + + +def test_resolve_registry_version_intersects_constraints(monkeypatch): + _patch_registry(monkeypatch, ["1.10018.1", "1.10021.0", "1.10021.1"]) + owner, name, version, url = _resolve_registry_version( + "esphome", "libsodium", {"==1.10021.0", "^1.10018.1"} + ) + assert (owner, name, version) == ("esphome", "libsodium", "1.10021.0") + assert url == "http://x/1.10021.0.tar.gz" + + +def test_resolve_registry_version_picks_highest_satisfying(monkeypatch): + _patch_registry(monkeypatch, ["1.0.0", "1.5.0", "2.0.0"]) + _owner, _name, version, _url = _resolve_registry_version("o", "p", {"^1.0.0"}) + assert version == "1.5.0" + + +def test_resolve_registry_version_conflict_raises(monkeypatch): + _patch_registry(monkeypatch, ["1.0.0", "2.0.0"]) + with pytest.raises(RuntimeError, match="satisfies all requirements"): + _resolve_registry_version("o", "p", {"==1.0.0", "==2.0.0"}) + + +def test_generate_idf_components_dedupes_shared_dependency( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A and B both depend on shared C under different version specs. The batch + # must resolve C once with BOTH requirements collected, wire a single C + # instance into both, and regenerate (overwrite) each library's build files. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": { + "name": "B", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "^1.10018.1"} + ], + }, + "esphome/C": {"name": "C"}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + (self.path / "CMakeLists.txt").write_text("# TRIPWIRE\n") + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + captured: dict[str, set[str]] = {} + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + captured[f"{owner}/{pkgname}"] = set(requirements) + version = "1.10021.0" if pkgname == "C" else "1.0.0" + return owner, pkgname, version, f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + # C resolved once (not once per consumer) with BOTH requirements gathered. + assert captured["esphome/C"] == {"==1.10021.0", "^1.10018.1"} + assert resolve_calls.count("C") == 1 + # Top-level components returned in request order. + assert [c.name for c in top] == ["esphome/A", "esphome/B"] + # A and B reference the SAME single C instance (deduped). + a_dep = top[0].dependencies[0] + b_dep = top[1].dependencies[0] + assert a_dep.name == "esphome/C" + assert a_dep is b_dep + # The bundled CMakeLists was overwritten with generated content. + generated = (a_dep.path / "CMakeLists.txt").read_text() + assert "TRIPWIRE" not in generated + assert "idf_component_register" in generated + + +def test_generate_idf_components_handles_dependency_cycle( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A -> B -> A. Must terminate (not recurse forever) and wire the cycle with + # a single instance per component. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [{"owner": "esphome", "name": "B", "version": "1.0.0"}], + }, + "esphome/B": { + "name": "B", + "dependencies": [{"owner": "esphome", "name": "A", "version": "1.0.0"}], + }, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + top = generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + assert [c.name for c in top] == ["esphome/A"] + component_a = top[0] + component_b = component_a.dependencies[0] + assert component_b.name == "esphome/B" + # The cycle is wired back to the same A instance, not a duplicate. + assert component_b.dependencies[0] is component_a + + +def test_generate_idf_components_git_overrides_registry_warns( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, + caplog: pytest.LogCaptureFixture, +) -> None: + # A pulls shared as a registry pin; B pulls the same component from a git + # source. The git source wins, but the dropped registry pin must be warned + # about (not silently discarded). + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "shared", "version": "==1.0.0"} + ], + }, + "esphome/B": { + "name": "B", + "dependencies": [ + { + "owner": "esphome", + "name": "shared", + "version": "https://github.com/esphome/shared.git#main", + } + ], + }, + "esphome/shared": {"name": "shared"}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + # shared resolved from the git source (version "*"), not the registry pin. + shared = top[0].dependencies[0] + assert shared.name == "esphome/shared" + assert isinstance(shared.source, GitSource) + assert "using the git source" in caplog.text + assert "==1.0.0" in caplog.text + + +def test_generate_idf_components_missing_manifest_raises( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A library with neither library.json nor library.properties is invalid; + # fail loudly rather than silently generating build files for it. + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + # no library.json / library.properties written + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + with pytest.raises(RuntimeError, match="missing library.json"): + generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + +def test_generate_idf_components_warns_on_noncanonical_duplicate( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, + caplog: pytest.LogCaptureFixture, +) -> None: + # A references "shared" (bare) and B references "owner/shared"; both resolve + # to the same canonical name but as distinct graph nodes, so they aren't + # deduplicated -- warn about it. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [{"name": "shared", "version": "1.0.0"}], + }, + "esphome/B": { + "name": "B", + "dependencies": [{"owner": "owner", "name": "shared", "version": "1.0.0"}], + }, + "owner/shared": {"name": "shared"}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + # Bare "shared" and "owner/shared" both resolve to canonical owner/shared. + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner or "owner", + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert "referenced under multiple names" in caplog.text + + +def test_generate_idf_components_incompatible_top_level_raises( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, + # not be silently dropped. + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "library.json").write_text( + json.dumps({"name": "A", "platforms": ["espressif8266"]}) + ) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + with pytest.raises(RuntimeError, match="not compatible with ESP-IDF"): + generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + +def test_generate_idf_components_incompatible_dependency_skipped( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # An incompatible *transitive* dependency is skipped (not fatal): A is fine, + # its esp8266-only dep B is dropped and not wired. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [{"owner": "esphome", "name": "B", "version": "1.0.0"}], + }, + "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + top = generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + assert [c.name for c in top] == ["esphome/A"] + # The incompatible dependency was dropped, not wired in. + assert top[0].dependencies == [] From 1734dc85d21ab4691290cb5fa3fce13ceef8d4b1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:42:01 +1000 Subject: [PATCH 1534/2214] [const][animation][dfplayer] Extract CONF_LOOP to const (#16797) --- esphome/components/animation/__init__.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/dfplayer/__init__.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index e9630f5266..9c9c7e3871 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -2,6 +2,7 @@ import logging from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_LOOP import esphome.components.image as espImage import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_REPEAT @@ -14,7 +15,6 @@ DEPENDENCIES = ["display"] MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True -CONF_LOOP = "loop" CONF_START_FRAME = "start_frame" CONF_END_FRAME = "end_frame" CONF_FRAME = "frame" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 3f7777883e..ebb4186a2b 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -15,6 +15,7 @@ CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" +CONF_LOOP = "loop" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 7796f5d891..d589381461 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_LOOP import esphome.config_validation as cv from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME @@ -15,7 +16,6 @@ DFPlayerIsPlayingCondition = dfplayer_ns.class_( MULTI_CONF = True CONF_FOLDER = "folder" -CONF_LOOP = "loop" CONF_EQ_PRESET = "eq_preset" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" From c765e22622856c1150b5884d1bb477fee3f919ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jun 2026 10:39:54 -0500 Subject: [PATCH 1535/2214] [ci] Exclude device-builder slow e2e tests from downstream CI (#16801) --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca1fb07fda..9f227b37a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,9 +189,12 @@ jobs: - name: Run device-builder pytest # ``-n auto`` runs under pytest-xdist (matches device-builder's # own CI). No ``--cov`` here -- this is purely a downstream - # smoke check against this PR's esphome code. + # smoke check against this PR's esphome code. ``tests/e2e/slow`` + # is excluded: those are real multi-minute toolchain compiles + # (LibreTiny SDK clone, native ESP-IDF install) that device-builder + # runs in its own dedicated jobs, not this smoke check. working-directory: device-builder - run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks + run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow pytest: name: Run pytest From 148a5ba68ea468eeb1d6a201d204314ef8da011b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:47:42 -0400 Subject: [PATCH 1536/2214] [esp32] Run clang-tidy via the native ESP-IDF toolchain (#16748) --- .clang-tidy.hash | 2 +- esphome/espidf/clang_tidy.py | 440 +++++++++++++++++++++++++++++++++++ script/clang-tidy | 26 ++- script/helpers.py | 40 ++-- sdkconfig.defaults | 15 +- 5 files changed, 491 insertions(+), 32 deletions(-) create mode 100644 esphome/espidf/clang_tidy.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 648b31f8f0..c007df6b9d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -44db8a62d94c8fba83b95b73938db4377ebacc0adb504881387389f1cd8f2f3a +0550a8ea4182dbc007660de060dd023ce22c865c8e95040a36f3d07a5b354fc6 diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py new file mode 100644 index 0000000000..2cfbe67a70 --- /dev/null +++ b/esphome/espidf/clang_tidy.py @@ -0,0 +1,440 @@ +"""Generate clang-tidy idedata via the native ESP-IDF toolchain. + +Produces idedata for clang-tidy **without an ESPHome YAML config**. Instead of +running codegen on a config, it generates a minimal ESP-IDF CMake project: + +* the managed-component dependencies come from ESPHome's own + ``idf_component.yml`` (arduinojson, lvgl, mdns, ...); +* the PlatformIO ``lib_deps`` (qr-code, mlx90393, ...) are converted to local + IDF components via the ESPHome PlatformIO->IDF converter; +* the ``main`` component ``REQUIRES`` every target-available builtin IDF + component, so their public include dirs land on the translation unit; +* the repo ``sdkconfig.defaults`` enables sdkconfig-gated components (bt, ...). + +then runs ``idf.py reconfigure`` (configure only, no compile) and reads the +resulting ``build/compile_commands.json``. The IDF version is the esp32 +component's recommended version. + +``ESPHOME_IDF_COMPILE_COMMANDS`` may point at an existing build's +``compile_commands.json`` to skip generation (fast iteration). +""" + +from dataclasses import dataclass +import os +from pathlib import Path + +TIDY_PROJECT_NAME = "esphome_tidy" + +# A do-nothing C++ app: just enough for IDF to configure a valid project. It's +# C++ (not C) so the compile command uses the C++ compiler and flags, matching +# how clang-tidy analyzes ESPHome's C++ sources. +_TIDY_MAIN_CPP = 'extern "C" void app_main() {}\n' + + +@dataclass(frozen=True) +class _Settings: + """Per-environment build settings derived from the tidy env name. + + The platform defines below are what a real ESPHome build adds via + cg.add_define; defines.h only *consumes* them, so without them + esphome/core/hal.h errors with "not implemented for this platform". + """ + + idf_target: str # esp32, esp32s3, ... + variant: str # ESP32, ESP32S3, ... + idf_version: str # ESP-IDF version to build with + target_framework: str # "espidf" or "arduino" + platform_defines: tuple[str, ...] + # Extra idf_component.yml deps the framework needs (e.g. arduino-esp32). + framework_deps: dict[str, dict] + + +def _settings_for(environment: str) -> _Settings: + """Derive build settings from a ``--tidy`` env name. + + Arduino on esp32 is itself a native ESP-IDF build with the + ``espressif/arduino-esp32`` component added, so both frameworks use this + path -- only the defines, IDF version, and that one component differ. + """ + from esphome.components.esp32 import ( + ARDUINO_FRAMEWORK_VERSION_LOOKUP, + ARDUINO_IDF_VERSION_LOOKUP, + ESP_IDF_FRAMEWORK_VERSION_LOOKUP, + ) + + parts = environment.split("-") + if len(parts) != 3 or parts[2] != "tidy" or parts[1] not in ("idf", "arduino"): + raise ValueError( + f"Unsupported clang-tidy environment {environment!r}: expected " + "--tidy with framework 'idf' or 'arduino' " + "(e.g. esp32-idf-tidy, esp32s3-arduino-tidy)" + ) + idf_target, framework, _ = parts + variant = idf_target.upper() + # Defines shared by both frameworks. ESPHOME_LOG_LEVEL must be set up front + # (as the PlatformIO tidy build_flags do) -- otherwise log.h's ``#ifndef`` + # sets it to NONE before defines.h redefines it, a macro-redefined warning + # across nearly every source. + common_defines = ( + "USE_ESP32", + f"USE_ESP32_VARIANT_{variant}", + "ESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE", + ) + + if framework == "arduino": + fw_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + return _Settings( + idf_target=idf_target, + variant=variant, + idf_version=str(ARDUINO_IDF_VERSION_LOOKUP[fw_version]), + target_framework="arduino", + platform_defines=( + *common_defines, + "USE_ARDUINO", + "USE_ESP32_FRAMEWORK_ARDUINO", + ), + framework_deps=_arduino_framework_deps(str(fw_version)), + ) + return _Settings( + idf_target=idf_target, + variant=variant, + idf_version=str(ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]), + target_framework="espidf", + platform_defines=( + *common_defines, + "USE_ESP_IDF", + "USE_ESP32_FRAMEWORK_ESP_IDF", + ), + framework_deps={}, + ) + + +def _arduino_framework_deps(version: str) -> dict[str, dict]: + """Arduino-only managed deps merged on top of esphome/idf_component.yml. + + arduino-esp32 provides Arduino.h and the arduino libraries; its version is + the recommended arduino framework version so the tidy build matches what + ESPHome ships. + """ + from esphome.components.esp32 import ARDUINO_ESP32_COMPONENT_NAME + + return {ARDUINO_ESP32_COMPONENT_NAME: {"version": version}} + + +_TOP_CMAKELISTS = """\ +# Auto-generated by ESPHome (clang-tidy idedata project) +cmake_minimum_required(VERSION 3.16) +set(IDF_TARGET {idf_target}) +include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{compile_options} +project({name}) +""" + +_MAIN_CMAKELISTS = """\ +# Auto-generated by ESPHome (clang-tidy idedata project) +idf_component_register( + SRCS "tidy.cpp" + REQUIRES {requires} +) +""" + + +def _setup_core(work_dir: Path, settings: _Settings) -> None: + """Point CORE at the tidy project + IDF version, without any YAML config.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT + import esphome.config_validation as cv + from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + from esphome.core import CORE + + CORE.name = TIDY_PROJECT_NAME + # config_path's parent is the data dir root: the IDF install lives at + # ``/.esphome/idf`` -- keep it beside (not inside) the per-run + # project dir so clearing the project doesn't force an IDF re-download. + CORE.config_path = work_dir.parent / "tidy.yaml" + CORE.build_path = work_dir + esp32 = CORE.data.setdefault(KEY_ESP32, {}) + esp32[KEY_IDF_VERSION] = cv.Version.parse(settings.idf_version) + esp32[KEY_VARIANT] = settings.variant + # The target framework drives the PlatformIO-library -> IDF-component + # converter and ESPHome's CORE.using_arduino / using_esp_idf helpers. + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = "esp32" + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = settings.target_framework + + +# Special IDF "components" that are tools/subprojects, not requirable by an app +# (they provide no public includes and break requirement resolution), plus our +# own ``main``. +_NON_REQUIRABLE_COMPONENTS = frozenset( + {"bootloader", "esptool_py", "partition_table", "main"} +) + + +def _parse_lib_deps(platformio_ini: Path, framework: str): + """Parse the framework's ``lib_deps`` from platformio.ini into Library specs. + + These are the PlatformIO libraries ESPHome components pull in via + ``cg.add_library``. The set is framework-specific: the arduino envs add + libs (FastLED, NeoPixelBus, MideaUART, ...) the idf envs don't. We read the + relevant ``[common*]`` sections directly (resolving the env's ``extends`` + chain) and skip the ``${...}`` cross-references and non-library entries. + """ + import configparser + + from esphome.core import Library + + parser = configparser.ConfigParser(interpolation=None, strict=False) + parser.read(platformio_ini) + + sections = [("common", "lib_deps_base"), ("common", "lib_deps")] + if framework == "arduino": + sections += [ + ("common:arduino", "lib_deps"), + ("common:esp32-arduino", "lib_deps"), + ] + else: + sections += [ + ("common:idf", "lib_deps"), + ("common:esp32-idf", "lib_deps"), + ] + + tokens: list[str] = [] + for section, key in sections: + if parser.has_option(section, key): + tokens += parser.get(section, key).splitlines() + + libs: list[Library] = [] + seen: set[str] = set() + for token in tokens: + token = token.split(";", 1)[0].strip() # drop trailing ; comment + # Skip blanks, ${...} cross-refs, and +<...> source filters. + if not token or token.startswith(("${", "+<")) or token in seen: + continue + seen.add(token) + if "://" in token or ".git" in token: + libs.append(Library(token, None, token)) # git repository (with #ref) + elif "@" in token: + name, _, version = token.partition("@") + libs.append(Library(name, version)) + # A bare name (SPI, Wire, WiFi, Networking, "ESP32 Async UDP", ...) is an + # Arduino framework built-in provided by arduino-esp32, not a convertible + # registry library (no owner/version), so skip it. + return libs + + +def _convert_pio_libs( + platformio_ini: Path, framework: str +) -> dict[str, dict[str, str]]: + """Convert the PlatformIO libs to IDF components; return manifest deps. + + Returns a mapping suitable for an ``idf_component.yml`` ``dependencies`` + block (``{name: {"override_path": }}``), reusing + ESPHome's own PlatformIO->IDF converter (registry/git resolution, no pio). + + The whole library set is resolved as a single batch so a shared transitive + dependency (e.g. esphome/libsodium pulled by both noise-c and esp_wireguard) + is deduplicated to one component instead of clashing override_path entries. + """ + from esphome.espidf.component import generate_idf_components + + libraries = _parse_lib_deps(platformio_ini, framework) + deps: dict[str, dict[str, str]] = {} + for component in generate_idf_components(libraries): + deps[component.get_sanitized_name()] = {"override_path": str(component.path)} + return deps + + +def _arduino_excluded_stubs(work_dir: Path) -> dict[str, dict]: + """Stub the arduino-bundled IDF components ESPHome doesn't use. + + arduino-esp32 declares deps (libsodium, RainMaker, modbus, ...) that ESPHome + replaces with its own library (noise-c) or doesn't use; point each at an + empty override_path component so the IDF manager doesn't resolve/download + them -- notably so ``espressif/libsodium`` doesn't clash with the converted + noise-c's ``libsodium``. Mirrors esp32's ``_write_idf_component_yml``. + + Components ESPHome's own idf_component.yml provides (e.g. lan867x for + ethernet) are NOT stubbed -- those are real deps we need, and arduino-esp32 + resolves to the same component rather than conflicting. + """ + import yaml + + from esphome.components.esp32 import ( + ARDUINO_EXCLUDED_IDF_COMPONENTS, + _idf_component_dep_name, + _idf_component_stub_name, + ) + + esphome_dir = Path(__file__).resolve().parent.parent + base_manifest = yaml.safe_load( + (esphome_dir / "idf_component.yml").read_text(encoding="utf-8") + ) + esphome_deps = set(base_manifest.get("dependencies") or {}) + + stubs_dir = work_dir / "component_stubs" + stubs_dir.mkdir(parents=True, exist_ok=True) + deps: dict[str, dict] = {} + for component in sorted(ARDUINO_EXCLUDED_IDF_COMPONENTS): + if _idf_component_dep_name(component) in esphome_deps: + continue # ESPHome needs this one for real (don't stub it away) + stub_path = stubs_dir / _idf_component_stub_name(component) + stub_path.mkdir(exist_ok=True) + (stub_path / "CMakeLists.txt").write_text( + "idf_component_register()\n", encoding="utf-8" + ) + deps[_idf_component_dep_name(component)] = { + "version": "*", + "override_path": str(stub_path), + } + return deps + + +def _write_tidy_project( + work_dir: Path, + requires: list[str], + extra_deps: dict[str, dict[str, str]], + settings: _Settings, +) -> None: + """Generate the minimal IDF CMake project (top + main + idf_component.yml).""" + main_dir = work_dir / "main" + main_dir.mkdir(parents=True, exist_ok=True) + + compile_options = "\n".join( + f'idf_build_set_property(COMPILE_OPTIONS "-D{define}" APPEND)' + for define in settings.platform_defines + ) + (work_dir / "CMakeLists.txt").write_text( + _TOP_CMAKELISTS.format( + name=TIDY_PROJECT_NAME, + compile_options=compile_options, + idf_target=settings.idf_target, + ), + encoding="utf-8", + ) + (main_dir / "CMakeLists.txt").write_text( + _MAIN_CMAKELISTS.format(requires=" ".join(requires)), encoding="utf-8" + ) + (main_dir / "tidy.cpp").write_text(_TIDY_MAIN_CPP, encoding="utf-8") + + # Managed components: ESPHome's own manifest (arduinojson, lvgl, mdns, ...), + # plus the converted PlatformIO libs as local (override_path) deps. Placing + # it in main/ makes every dep a requirement of the main component, so their + # public includes land on the tidy translation unit. + import yaml + + esphome_dir = Path(__file__).resolve().parent.parent # esphome/espidf -> esphome + manifest = yaml.safe_load( + (esphome_dir / "idf_component.yml").read_text(encoding="utf-8") + ) + manifest.setdefault("dependencies", {}).update(extra_deps) + (main_dir / "idf_component.yml").write_text( + yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8" + ) + + # ESPHome's static-analysis sdkconfig (repo root): enables the flags any + # component sets (e.g. CONFIG_BT_ENABLED) so sdkconfig-gated IDF components + # register and expose their includes. IDF reads ``sdkconfig.defaults`` from + # the project root. + (work_dir / "sdkconfig.defaults").write_text( + (esphome_dir.parent / "sdkconfig.defaults").read_text(encoding="utf-8"), + encoding="utf-8", + ) + + +def _generate_compile_commands( + work_dir: Path, settings: _Settings, platformio_ini: Path +) -> Path: + """Generate the tidy project and run ``idf.py reconfigure`` (no build). + + Two-phase, like a real ESPHome build: a first configure with no builtin + requires discovers which components actually register for the target (e.g. + ``esp_tee`` only registers on c5/c6/h2), then a second configure requires + that discovered set so their public includes reach the tidy TU. + """ + import logging + + from esphome.build_gen.espidf import get_available_components + from esphome.espidf import toolchain + + # Surface ESPHome's INFO logs (ESP-IDF framework download/extract/install, + # git-library clones) -- they go through logging, which the clang-tidy + # script otherwise leaves at WARNING so the first-run downloads look silent. + logging.basicConfig(level=logging.INFO, format="%(message)s") + + _setup_core(work_dir, settings) + + # Framework deps (e.g. arduino-esp32) + PlatformIO libs converted to local + # IDF components, all added to the manifest as deps. + extra_deps = dict(settings.framework_deps) + extra_deps.update(_convert_pio_libs(platformio_ini, settings.target_framework)) + if settings.target_framework == "arduino": + # Stub the arduino-bundled components ESPHome doesn't use (avoids the + # libsodium clash with noise-c and ~26 unused heavy downloads). + extra_deps.update(_arduino_excluded_stubs(work_dir)) + + # Phase 1: discover the components available for this target. + _write_tidy_project(work_dir, [], extra_deps, settings) + if toolchain.run_reconfigure() != 0: + raise RuntimeError("idf.py reconfigure (discovery) failed") + + requires = sorted( + set(get_available_components() or []) - _NON_REQUIRABLE_COMPONENTS + ) + + # Phase 2: require every available builtin component. + _write_tidy_project(work_dir, requires, extra_deps, settings) + if toolchain.run_reconfigure() != 0: + raise RuntimeError("idf.py reconfigure failed") + + return work_dir / "build" / "compile_commands.json" + + +def _idedata_from_tidy_project(compile_commands: Path) -> dict: + """Assemble idedata from the single tidy translation unit. + + Unlike a real ESPHome build (many ``/src/esphome/`` TUs unioned), the tidy + project has one TU (``main/tidy.cpp``) that -- by requiring every component -- + already carries the full include set, so we parse it directly. + """ + import json + + from esphome.espidf.idedata import _get_toolchain_includes, _parse_entry + + entries = json.loads(Path(compile_commands).read_text(encoding="utf-8")) + entry = next((e for e in entries if e["file"].endswith("tidy.cpp")), None) + if entry is None: + raise RuntimeError(f"tidy.cpp not found in {compile_commands}") + cxx_path, defines, includes, cxx_flags = _parse_entry(entry) + + return { + "cxx_path": cxx_path, + "cxx_flags": cxx_flags, + "defines": defines, + "includes": { + "build": includes, + "toolchain": _get_toolchain_includes(cxx_path), + }, + } + + +def load_idedata(environment: str, temp_folder: str, platformio_ini: Path) -> dict: + if explicit := os.environ.get("ESPHOME_IDF_COMPILE_COMMANDS"): + compile_commands = Path(explicit) + else: + # The tidy env is ``--tidy`` (e.g. esp32-idf-tidy, + # esp32s3-arduino-tidy); derive the target, variant and framework. + settings = _settings_for(environment) + # Resolve to an absolute path: ``override_path`` entries in the generated + # component manifests are interpreted by the IDF component manager relative + # to the manifest's own directory, so a relative work dir would be + # mis-resolved (doubled under ``main/``). + work_dir = ( + Path(temp_folder) + / f"idf-tidy-{settings.idf_target}-{settings.target_framework}" + ).resolve() + compile_commands = _generate_compile_commands( + work_dir, settings, platformio_ini + ) + + if not compile_commands.is_file(): + raise RuntimeError(f"compile_commands.json not found: {compile_commands}") + return _idedata_from_tidy_project(compile_commands) diff --git a/script/clang-tidy b/script/clang-tidy index 56c0a9db71..ce266e2382 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -28,6 +28,12 @@ from helpers import ( temp_header_file, ) +# Limit the ESP-IDF tool install to esp32 for clang-tidy: the one xtensa-esp-elf +# toolchain bundles the s2/s3 compilers too, so all xtensa tidy envs still +# reconfigure while the large riscv32-esp-elf toolchain is skipped. Must be set +# before esphome.espidf.framework is imported (lazily, via load_idedata). +os.environ.setdefault("ESPHOME_IDF_DEFAULT_TARGETS", "esp32") + def clang_options(idedata): cmd = [] @@ -52,6 +58,10 @@ def clang_options(idedata): "-mfix-esp32-psram-cache-issue", "-mfix-esp32-psram-cache-strategy=memw", "-fno-tree-switch-conversion", + # GCC-only flags emitted by the native ESP-IDF toolchain build + "-freorder-blocks", + "-fno-jump-tables", + "-fno-shrink-wrap", ) if "zephyr" in triplet: @@ -97,8 +107,20 @@ def clang_options(idedata): ] ) - # copy compiler flags, except those clang doesn't understand. - cmd.extend(flag for flag in idedata["cxx_flags"] if flag not in omit_flags) + # Copy compiler flags, dropping: ones clang doesn't understand; -Werror* + # (clang-tidy enforces .clang-tidy's WarningsAsErrors, and a build -Werror + # would bypass the -clang-diagnostic-* suppressions); and -std= (the native + # ESP-IDF build defaults to gnu++2b, but ESPHome compiles with gnu++20 per + # platformio.ini -- analyzing as C++23 flags code that doesn't build under + # gnu++20). Force gnu++20 to match the real build. + cmd.extend( + flag + for flag in idedata["cxx_flags"] + if flag not in omit_flags + and not flag.startswith("-Werror") + and not flag.startswith("-std=") + ) + cmd.append("-std=gnu++20") # defines cmd.extend(f"-D{define}" for define in idedata["defines"]) diff --git a/script/helpers.py b/script/helpers.py index 1ebfe405a7..8b6751c1d3 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -664,26 +664,22 @@ def load_idedata(environment: str) -> dict[str, Any]: start_time = time.time() print(f"Loading IDE data for environment '{environment}'...") - platformio_ini = Path(root_path) / "platformio.ini" + # Reuse the clang-tidy input hash as the cache key: it already covers every + # file baked into the generated idedata (platformio.ini, sdkconfig.defaults, + # esphome/idf_component.yml), so this can't drift from that file list. A + # content hash -- unlike an mtime comparison -- stays correct across git + # checkouts, which don't preserve mtimes. + from clang_tidy_hash import calculate_clang_tidy_hash + temp_idedata = Path(temp_folder) / f"idedata-{environment}.json" - changed = False - if ( - not platformio_ini.is_file() - or not temp_idedata.is_file() - or platformio_ini.stat().st_mtime >= temp_idedata.stat().st_mtime - ): - changed = True + temp_hash = Path(temp_folder) / f"idedata-{environment}.hash" - if "idf" in environment: - # remove full sdkconfig when the defaults have changed so that it is regenerated - default_sdkconfig = Path(root_path) / "sdkconfig.defaults" - temp_sdkconfig = Path(temp_folder) / f"sdkconfig-{environment}" - - if not temp_sdkconfig.is_file(): - changed = True - elif default_sdkconfig.stat().st_mtime >= temp_sdkconfig.stat().st_mtime: - temp_sdkconfig.unlink() - changed = True + cache_key = calculate_clang_tidy_hash() + changed = ( + not temp_idedata.is_file() + or not temp_hash.is_file() + or temp_hash.read_text().strip() != cache_key + ) if not changed: data = json.loads(temp_idedata.read_text()) @@ -694,7 +690,12 @@ def load_idedata(environment: str) -> dict[str, Any]: # ensure temp directory exists before running pio, as it writes sdkconfig to it Path(temp_folder).mkdir(exist_ok=True) - if "nrf" in environment: + platformio_ini = Path(root_path) / "platformio.ini" + if "esp32" in environment: + from esphome.espidf.clang_tidy import load_idedata as idf_load_idedata + + data = idf_load_idedata(environment, temp_folder, platformio_ini) + elif "nrf" in environment: from helpers_zephyr import load_idedata as zephyr_load_idedata data = zephyr_load_idedata(environment, temp_folder, platformio_ini) @@ -705,6 +706,7 @@ def load_idedata(environment: str) -> dict[str, Any]: match = re.search(r'{\s*".*}', stdout.decode("utf-8")) data = json.loads(match.group()) temp_idedata.write_text(json.dumps(data, indent=2) + "\n") + temp_hash.write_text(cache_key + "\n") elapsed = time.time() - start_time print(f"IDE data generated and cached in {elapsed:.2f} seconds") diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 2996490295..b277ed18d0 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -1,15 +1,11 @@ -# ESP-IDF sdkconfig defaults used for development purposes only, not used during runtime. Used when PlatformIO is ran -# directly from the source directory, e.g. by IDEs or for static analysis (clang-tidy). This should enable all flags -# that are set by any component. +# ESP-IDF sdkconfig defaults used for development purposes only, not used during runtime. Used for static analysis +# (clang-tidy) -- by both the PlatformIO and the native ESP-IDF toolchain paths -- and when PlatformIO is run directly +# from the source directory (e.g. by IDEs). This should enable all flags that are set by any component. # esp32 -CONFIG_COMPILER_OPTIMIZATION_DEFAULT=n CONFIG_COMPILER_OPTIMIZATION_SIZE=y -CONFIG_PARTITION_TABLE_CUSTOM=y -#CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" -CONFIG_PARTITION_TABLE_SINGLE_APP=n CONFIG_FREERTOS_HZ=1000 -CONFIG_ESP_TASK_WDT=y +CONFIG_ESP_TASK_WDT_INIT=y CONFIG_ESP_TASK_WDT_PANIC=y CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n @@ -18,8 +14,7 @@ CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n CONFIG_BT_ENABLED=y # esp32_camera -CONFIG_RTCIO_SUPPORT_RTC_GPIO_DESC=y -CONFIG_ESP32_SPIRAM_SUPPORT=y +CONFIG_SPIRAM=y # zigbee CONFIG_ZB_ENABLED=y From 419bde18b05fb0e162159f2ee171fbf26d9e6047 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 4 Jun 2026 16:24:47 -0400 Subject: [PATCH 1537/2214] [audio] Bump esp-audio-libs to v3.2.0 (#16806) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index c007df6b9d..c3604e7ef2 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0550a8ea4182dbc007660de060dd023ce22c865c8e95040a36f3d07a5b354fc6 +adf1b0ed175c64877f959b14ff1ff8d3ba0d15bafcd86fab85a66f1d5ce953e8 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index c9775ab601..c051d70f3d 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -335,7 +335,7 @@ async def to_code(config): add_idf_component( name="esphome/esp-audio-libs", - ref="3.1.0", + ref="3.2.0", ) data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 9476b38b72..4190c80027 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" esphome/esp-audio-libs: - version: 3.1.0 + version: 3.2.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From e2459a39235a18b0f75ce6dfd3d6d5e9792ae6d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:57:56 -0400 Subject: [PATCH 1538/2214] [clang-tidy] Support RISC-V targets natively (#16809) --- script/clang-tidy | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/script/clang-tidy b/script/clang-tidy index ce266e2382..47f59e62a4 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -28,12 +28,6 @@ from helpers import ( temp_header_file, ) -# Limit the ESP-IDF tool install to esp32 for clang-tidy: the one xtensa-esp-elf -# toolchain bundles the s2/s3 compilers too, so all xtensa tidy envs still -# reconfigure while the large riscv32-esp-elf toolchain is skipped. Must be set -# before esphome.espidf.framework is imported (lazily, via load_idedata). -os.environ.setdefault("ESPHOME_IDF_DEFAULT_TARGETS", "esp32") - def clang_options(idedata): cmd = [] @@ -46,7 +40,14 @@ def clang_options(idedata): cmd.append("-D__XTENSA__") cmd.append("-D_LIBC") else: + # RISC-V (and other non-Xtensa targets) have a real clang backend, so + # compile for the actual triplet. Espressif's RISC-V GCC -march adds + # vendor extensions (xesploop, xespv) upstream clang doesn't know; those + # are stripped from the copied cxx_flags below. cmd.append(f"--target={triplet}") + # The GCC build passes flags (e.g. -fno-plt) that clang accepts for some + # targets but not others; don't error on the ones unused for this target. + cmd.append("-Qunused-arguments") omit_flags = ( "-free", @@ -113,8 +114,15 @@ def clang_options(idedata): # ESP-IDF build defaults to gnu++2b, but ESPHome compiles with gnu++20 per # platformio.ini -- analyzing as C++23 flags code that doesn't build under # gnu++20). Force gnu++20 to match the real build. + # Strip Espressif's non-standard RISC-V -march extensions (e.g. xesploop, + # xespv); clang rejects the whole arch string otherwise. + def strip_esp_march(flag): + if flag.startswith("-march=") and triplet.startswith("riscv"): + return re.sub(r"_xesp\w+", "", flag) + return flag + cmd.extend( - flag + strip_esp_march(flag) for flag in idedata["cxx_flags"] if flag not in omit_flags and not flag.startswith("-Werror") From 5288767abf18d9109b0e821f7e70a9f9c234d67c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:58:22 -0400 Subject: [PATCH 1539/2214] [clang-tidy] Add --exclude-grep to skip files by content (#16813) --- script/clang-tidy | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/script/clang-tidy b/script/clang-tidy index 47f59e62a4..633b8d4b7d 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -247,6 +247,12 @@ def main(): action="append", help="only run on files containing value", ) + parser.add_argument( + "-x", + "--exclude-grep", + action="append", + help="skip files containing value", + ) parser.add_argument( "--split-num", type=int, help="split the files into X jobs.", default=None ) @@ -281,6 +287,10 @@ def main(): if args.grep: files = filter_grep(files, args.grep) + if args.exclude_grep: + excluded = set(filter_grep(files, args.exclude_grep)) + files = [f for f in files if f not in excluded] + files.sort() if args.split_num: From d2c388f8934f4508756b08684648bb2f374a716d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:01:00 -0400 Subject: [PATCH 1540/2214] [ota][logger][esp32][internal_temperature] Fix clang-tidy findings surfaced by RISC-V analysis (#16811) --- esphome/components/esp32/crash_handler.cpp | 5 +++++ .../internal_temperature/internal_temperature.h | 11 +++++++++++ .../internal_temperature_esp32.cpp | 12 +++--------- esphome/components/logger/logger_esp32.cpp | 4 ++-- esphome/components/ota/ota_backend_esp_idf.h | 3 ++- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index ed61b61936..a7de48a6ee 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -41,6 +41,7 @@ static inline bool is_return_addr(uint32_t addr) { // Use memcpy for alignment safety — RISC-V C extension means code addresses // are only 2-byte aligned, so addr-4 may not be 4-byte aligned. uint32_t inst; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - reading code memory at a raw address is the point memcpy(&inst, (const void *) (addr - 4), sizeof(inst)); // RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode @@ -51,6 +52,7 @@ static inline bool is_return_addr(uint32_t addr) { // Check for 2-byte compressed c.jalr before this address (C extension). // c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10 if (addr >= 2) { + // NOLINTNEXTLINE(performance-no-int-to-ptr) - reading code memory at a raw address is the point uint16_t c_inst = *(uint16_t *) (addr - 2); if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0) return true; @@ -101,6 +103,7 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou out[count++] = frame->ra; } *reg_count = count; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - walking the raw stack by address is the point auto *scan_start = (uint32_t *) frame->sp; for (uint32_t i = 0; i < 64 && count < max; i++) { uint32_t val = scan_start[i]; @@ -354,6 +357,8 @@ void crash_handler_log() { #if SOC_CPU_CORES_NUM > 1 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); +#else + (void) pos; // There is no second-core append on single-core targets, so pos would otherwise be unread. #endif ESP_LOGE(TAG, "%s", hint); } diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 4810e8478d..41fea5a255 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -3,6 +3,12 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" +// Every ESP32 variant except the original one exposes the on-chip sensor through +// the IDF temperature_sensor driver (the original uses the legacy temprature_sens_read). +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32) +#include "driver/temperature_sensor.h" +#endif + namespace esphome::internal_temperature { class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent { @@ -13,6 +19,11 @@ class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent void dump_config() override; void update() override; + +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32) + protected: + temperature_sensor_handle_t tsens_{nullptr}; +#endif }; } // namespace esphome::internal_temperature diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 09121fa9c9..1c44a9a238 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -19,12 +19,6 @@ namespace esphome::internal_temperature { static const char *const TAG = "internal_temperature.esp32"; -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) -static temperature_sensor_handle_t tsensNew = NULL; -#endif // USE_ESP32_VARIANT - void InternalTemperatureSensor::update() { float temperature = NAN; bool success = false; @@ -37,7 +31,7 @@ void InternalTemperatureSensor::update() { defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ defined(USE_ESP32_VARIANT_ESP32S3) - esp_err_t result = temperature_sensor_get_celsius(tsensNew, &temperature); + esp_err_t result = temperature_sensor_get_celsius(this->tsens_, &temperature); success = (result == ESP_OK); if (!success) { ESP_LOGE(TAG, "Reading failed (%d)", result); @@ -60,14 +54,14 @@ void InternalTemperatureSensor::setup() { defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); - esp_err_t result = temperature_sensor_install(&tsens_config, &tsensNew); + esp_err_t result = temperature_sensor_install(&tsens_config, &this->tsens_); if (result != ESP_OK) { ESP_LOGE(TAG, "Install failed (%d)", result); this->mark_failed(); return; } - result = temperature_sensor_enable(tsensNew); + result = temperature_sensor_enable(this->tsens_); if (result != ESP_OK) { ESP_LOGE(TAG, "Enabling failed (%d)", result); this->mark_failed(); diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index b216a5427d..05fc959ceb 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -30,7 +30,7 @@ namespace esphome::logger { static const char *const TAG = "logger"; #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG -static void init_usb_serial_jtag_() { +static void init_usb_serial_jtag() { setvbuf(stdin, NULL, _IONBF, 0); // Disable buffering on stdin #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0) @@ -109,7 +109,7 @@ void Logger::pre_setup() { #ifdef USE_LOGGER_USB_SERIAL_JTAG case UART_SELECTION_USB_SERIAL_JTAG: #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG - init_usb_serial_jtag_(); + init_usb_serial_jtag(); #endif break; #endif diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 73dd685df6..a49a5e34b3 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -54,9 +54,10 @@ class IDFOTABackend final { #endif private: + // 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_; - md5::MD5Digest md5_{}; char expected_bin_md5_[32]; bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS From 82efa451871f89ee1693b39cfbb30aa9224c00cd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:16:18 -0400 Subject: [PATCH 1541/2214] [multiple] Avoid float-to-double promotion in math calls (#16812) --- esphome/components/daikin_arc/daikin_arc.cpp | 2 +- esphome/components/display/display.cpp | 13 +++++++------ esphome/components/esp32/core.cpp | 4 +++- esphome/components/nau7802/nau7802.cpp | 3 ++- esphome/components/sgp4x/sgp4x.cpp | 3 ++- esphome/components/thermopro_ble/thermopro_ble.cpp | 3 ++- esphome/components/tuya/number/tuya_number.cpp | 4 +++- 7 files changed, 20 insertions(+), 12 deletions(-) diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index a455e2fd7f..e31f72dfb9 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -216,7 +216,7 @@ uint8_t DaikinArcClimate::temperature_() { return 0xc0; default: float new_temp = clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX); - uint8_t temperature = (uint8_t) floor(new_temp); + uint8_t temperature = (uint8_t) std::floor(new_temp); return temperature << 1 | (new_temp - temperature > 0 ? 0x01 : 0); } } diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index cd2d2143f5..b24c099bce 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -1,4 +1,5 @@ #include "display.h" +#include #include #include #include "display_color_utils.h" @@ -238,7 +239,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int lhline_width = -(dxmax - dxmin) + 1; if (progress >= 50) { if (float(dymax) < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); } else { upd_dxmax = -dxmax; } @@ -253,7 +254,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, } } else { if (float(dymin) > float(-dxmin) * tan_a) { - upd_dxmin = ceil(float(dymin) / tan_a); + upd_dxmin = std::ceil(float(dymin) / tan_a); } else { upd_dxmin = -dxmin; } @@ -268,12 +269,12 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int hline_width = 2 * (-dxmax) + 1; if (progress >= 50) { if (dymax < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); hline_width = -dxmax + upd_dxmax + 1; } } else { if (dymax < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); hline_width = -dxmax - upd_dxmax + 1; } else { hline_width = 0; @@ -452,8 +453,8 @@ void HOT Display::get_regular_polygon_vertex(int vertex_id, int *vertex_x, int * rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi / edges : 0.0; float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi + rotation_radians; - *vertex_x = (int) round(cos(vertex_angle) * radius) + center_x; - *vertex_y = (int) round(sin(vertex_angle) * radius) + center_y; + *vertex_x = (int) std::round(std::cos(vertex_angle) * radius) + center_x; + *vertex_y = (int) std::round(std::sin(vertex_angle) * radius) + center_y; } } diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 5249f4a59e..098a59937a 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -8,7 +8,9 @@ void setup(); // NOLINT(readability-redundant-declaration) -// Weak stub for initArduino - overridden when the Arduino component is present +// Weak stub for initArduino - overridden when the Arduino component is present. +// Name must match the Arduino framework's entry point, so the naming check is suppressed. +// NOLINTNEXTLINE(readability-identifier-naming) extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 4d73ed6dd0..2092452087 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -1,4 +1,5 @@ #include "nau7802.h" +#include #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -76,7 +77,7 @@ void NAU7802Sensor::setup() { return; } - uint32_t gcal = (uint32_t) (round(this->gain_calibration_ * (1 << GCAL1_FRACTIONAL))); + uint32_t gcal = (uint32_t) (std::round(this->gain_calibration_ * (1 << GCAL1_FRACTIONAL))); this->write_value_(OCAL1_B2_REG, 3, this->offset_calibration_); this->write_value_(GCAL1_B3_REG, 4, gcal); diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 94e6d69dcb..db56bd13f0 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" #include +#include namespace esphome::sgp4x { @@ -199,7 +200,7 @@ void SGP4xComponent::measure_raw_() { response_words = 2; } } - uint16_t rhticks = (uint16_t) llround((humidity * 65535) / 100); + uint16_t rhticks = (uint16_t) std::llround((humidity * 65535) / 100); uint16_t tempticks = (uint16_t) (((temperature + 45) * 65535) / 175); // first parameter are the relative humidity ticks data[0] = rhticks; diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 2c90ee23f8..1ccf59a2f6 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -1,4 +1,5 @@ #include "thermopro_ble.h" +#include #include "esphome/core/log.h" #ifdef USE_ESP32 @@ -136,7 +137,7 @@ static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { // A*tanh(B*x+C)+D // Where A,B,C,D are the variables to optimize for. This yielded the below function static float tp96_battery(uint16_t voltage) { - float level = 52.317286f * tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; + float level = 52.317286f * std::tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; return std::max(0.0f, std::min(level, 100.0f)); } diff --git a/esphome/components/tuya/number/tuya_number.cpp b/esphome/components/tuya/number/tuya_number.cpp index bfedbb9319..b0bbfce649 100644 --- a/esphome/components/tuya/number/tuya_number.cpp +++ b/esphome/components/tuya/number/tuya_number.cpp @@ -1,3 +1,5 @@ +#include + #include "esphome/core/log.h" #include "tuya_number.h" @@ -63,7 +65,7 @@ void TuyaNumber::setup() { void TuyaNumber::control(float value) { ESP_LOGV(TAG, "Setting number %u: %f", this->number_id_, value); if (this->type_ == TuyaDatapointType::INTEGER) { - int integer_value = lround(value * multiply_by_); + int integer_value = std::lround(value * multiply_by_); this->parent_->set_integer_datapoint_value(this->number_id_, integer_value); } else if (this->type_ == TuyaDatapointType::ENUM) { this->parent_->set_enum_datapoint_value(this->number_id_, value); From 9fbd4c38aeee6f998b2cf980dc111d9dd35529cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jun 2026 17:28:32 -0500 Subject: [PATCH 1542/2214] [i2s_audio] Move test bus into a shared package and give fixtures unique ids (#16793) --- script/analyze_component_buses.py | 1 + script/helpers.py | 1 + .../i2s_audio/common-spdif_mode.yaml | 2 +- tests/components/micro_wake_word/common.yaml | 7 ++---- .../micro_wake_word/test.esp32-idf.yaml | 6 +++++ .../micro_wake_word/test.esp32-s3-idf.yaml | 6 +++++ tests/components/mixer/common.yaml | 6 +---- tests/components/mixer/test.esp32-idf.yaml | 4 +--- tests/components/mixer/test.esp32-s3-idf.yaml | 6 ++--- tests/components/resampler/common.yaml | 10 +++------ .../components/resampler/test.esp32-idf.yaml | 6 ++--- .../resampler/test.esp32-s3-idf.yaml | 8 +++---- tests/components/router/common.yaml | 6 ++--- tests/components/router/test.esp32-idf.yaml | 9 ++++---- tests/components/sound_level/common.yaml | 7 ++---- .../sound_level/test.esp32-idf.yaml | 5 ++--- .../sound_level/test.esp32-s3-idf.yaml | 7 +++--- .../components/speaker/common-audio_dac.yaml | 6 +---- tests/components/speaker/common.yaml | 6 +---- .../speaker/test-audio_dac.esp32-idf.yaml | 6 ++--- .../speaker/test-media_player.esp32-idf.yaml | 10 ++++----- tests/components/speaker/test.esp32-idf.yaml | 6 ++--- tests/components/speaker_source/common.yaml | 10 +++------ .../speaker_source/test.esp32-idf.yaml | 10 ++++----- .../voice_assistant/common-idf.yaml | 22 +++++++++---------- tests/components/voice_assistant/common.yaml | 15 +++++-------- .../voice_assistant/test.esp32-idf.yaml | 12 +++++----- tests/test_build_components/common/README.md | 9 ++++++++ .../common/i2s_audio/esp32-idf.yaml | 14 ++++++++++++ .../common/i2s_audio/esp32-s3-idf.yaml | 14 ++++++++++++ 30 files changed, 122 insertions(+), 115 deletions(-) create mode 100644 tests/test_build_components/common/i2s_audio/esp32-idf.yaml create mode 100644 tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index fc66605694..a343e34328 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -59,6 +59,7 @@ DIRECT_BUS_TYPES = ( "modbus", "remote_transmitter", "remote_receiver", + "i2s_audio", ) # Signature for components with no bus requirements diff --git a/script/helpers.py b/script/helpers.py index 8b6751c1d3..fc2a3607fb 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -53,6 +53,7 @@ BASE_BUS_COMPONENTS = { "canbus", "remote_transmitter", "remote_receiver", + "i2s_audio", } # Cache version for components graph diff --git a/tests/components/i2s_audio/common-spdif_mode.yaml b/tests/components/i2s_audio/common-spdif_mode.yaml index 374a4bce1e..681ec2aa53 100644 --- a/tests/components/i2s_audio/common-spdif_mode.yaml +++ b/tests/components/i2s_audio/common-spdif_mode.yaml @@ -3,7 +3,7 @@ i2s_audio: speaker: - platform: i2s_audio - id: speaker_id + id: spdif_speaker_id dac_type: external i2s_dout_pin: ${spdif_data_pin} spdif_mode: true diff --git a/tests/components/micro_wake_word/common.yaml b/tests/components/micro_wake_word/common.yaml index cd060c176e..9ac1056ba4 100644 --- a/tests/components/micro_wake_word/common.yaml +++ b/tests/components/micro_wake_word/common.yaml @@ -1,14 +1,11 @@ psram: mode: quad -i2s_audio: - i2s_lrclk_pin: GPIO18 - i2s_bclk_pin: GPIO19 - microphone: - platform: i2s_audio id: echo_microphone - i2s_din_pin: GPIO17 + i2s_audio_id: i2s_audio_bus + i2s_din_pin: ${mic_din_pin} adc_type: external pdm: true bits_per_sample: 16bit diff --git a/tests/components/micro_wake_word/test.esp32-idf.yaml b/tests/components/micro_wake_word/test.esp32-idf.yaml index dade44d145..fa3984d57e 100644 --- a/tests/components/micro_wake_word/test.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-idf.yaml @@ -1 +1,7 @@ +substitutions: + mic_din_pin: GPIO36 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + <<: !include common.yaml diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index dade44d145..a1b7b75423 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -1 +1,7 @@ +substitutions: + mic_din_pin: GPIO18 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml + <<: !include common.yaml diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index dee42ed280..55e96df4c2 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -6,14 +6,10 @@ esphome: decibel_reduction: 10 duration: 1s -i2s_audio: - i2s_lrclk_pin: ${lrclk_pin} - i2s_bclk_pin: ${bclk_pin} - i2s_mclk_pin: ${mclk_pin} - speaker: - platform: i2s_audio id: mixer_output_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${dout_pin} bits_per_sample: 32bit diff --git a/tests/components/mixer/test.esp32-idf.yaml b/tests/components/mixer/test.esp32-idf.yaml index 6712f1e468..ba42761635 100644 --- a/tests/components/mixer/test.esp32-idf.yaml +++ b/tests/components/mixer/test.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO15 dout_pin: GPIO14 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/mixer/test.esp32-s3-idf.yaml b/tests/components/mixer/test.esp32-s3-idf.yaml index f1721f0862..f12a9615af 100644 --- a/tests/components/mixer/test.esp32-s3-idf.yaml +++ b/tests/components/mixer/test.esp32-s3-idf.yaml @@ -1,7 +1,7 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO6 dout_pin: GPIO7 +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml + <<: !include common.yaml diff --git a/tests/components/resampler/common.yaml b/tests/components/resampler/common.yaml index 8ff09ed256..782dc831c4 100644 --- a/tests/components/resampler/common.yaml +++ b/tests/components/resampler/common.yaml @@ -1,13 +1,9 @@ -i2s_audio: - i2s_lrclk_pin: ${lrclk_pin} - i2s_bclk_pin: ${bclk_pin} - i2s_mclk_pin: ${mclk_pin} - speaker: - platform: i2s_audio - id: speaker_id + id: resampler_i2s_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${dout_pin} - platform: resampler id: resampler_speaker_id - output_speaker: speaker_id + output_speaker: resampler_i2s_speaker_id diff --git a/tests/components/resampler/test.esp32-idf.yaml b/tests/components/resampler/test.esp32-idf.yaml index 6712f1e468..c6bc03e661 100644 --- a/tests/components/resampler/test.esp32-idf.yaml +++ b/tests/components/resampler/test.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO15 - dout_pin: GPIO14 + dout_pin: GPIO21 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/resampler/test.esp32-s3-idf.yaml b/tests/components/resampler/test.esp32-s3-idf.yaml index f1721f0862..1d80d24cdf 100644 --- a/tests/components/resampler/test.esp32-s3-idf.yaml +++ b/tests/components/resampler/test.esp32-s3-idf.yaml @@ -1,7 +1,7 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO6 - dout_pin: GPIO7 + dout_pin: GPIO16 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/router/common.yaml b/tests/components/router/common.yaml index f1239de3cb..5914e4ca45 100644 --- a/tests/components/router/common.yaml +++ b/tests/components/router/common.yaml @@ -8,13 +8,10 @@ esphome: - router.speaker.switch_output: target_speaker: !lambda return id(speaker_a_id); -i2s_audio: - i2s_lrclk_pin: ${a_lrclk_pin} - i2s_bclk_pin: ${a_bclk_pin} - speaker: - platform: i2s_audio id: speaker_a_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${a_dout_pin} sample_rate: 48000 @@ -22,6 +19,7 @@ speaker: channel: stereo - platform: i2s_audio id: speaker_b_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${b_dout_pin} spdif_mode: true diff --git a/tests/components/router/test.esp32-idf.yaml b/tests/components/router/test.esp32-idf.yaml index 241a9a8903..8288774941 100644 --- a/tests/components/router/test.esp32-idf.yaml +++ b/tests/components/router/test.esp32-idf.yaml @@ -1,7 +1,8 @@ substitutions: - a_lrclk_pin: GPIO4 - a_bclk_pin: GPIO5 - a_dout_pin: GPIO14 - b_dout_pin: GPIO19 + a_dout_pin: GPIO26 + b_dout_pin: GPIO27 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/sound_level/common.yaml b/tests/components/sound_level/common.yaml index cc04f5bf79..eceef3a9b5 100644 --- a/tests/components/sound_level/common.yaml +++ b/tests/components/sound_level/common.yaml @@ -1,11 +1,8 @@ -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - microphone: - platform: i2s_audio id: i2s_microphone - i2s_din_pin: ${i2s_dout_pin} + i2s_audio_id: i2s_audio_bus + i2s_din_pin: ${i2s_din_pin} adc_type: external bits_per_sample: 16bit diff --git a/tests/components/sound_level/test.esp32-idf.yaml b/tests/components/sound_level/test.esp32-idf.yaml index 20e38e8df8..4d89f4cd2e 100644 --- a/tests/components/sound_level/test.esp32-idf.yaml +++ b/tests/components/sound_level/test.esp32-idf.yaml @@ -1,9 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO25 - i2s_lrclk_pin: GPIO26 - i2s_dout_pin: GPIO27 + i2s_din_pin: GPIO39 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/sound_level/test.esp32-s3-idf.yaml b/tests/components/sound_level/test.esp32-s3-idf.yaml index 9c1f32d5bd..9dfe3b4877 100644 --- a/tests/components/sound_level/test.esp32-s3-idf.yaml +++ b/tests/components/sound_level/test.esp32-s3-idf.yaml @@ -1,6 +1,7 @@ substitutions: - i2s_bclk_pin: GPIO4 - i2s_lrclk_pin: GPIO5 - i2s_dout_pin: GPIO6 + i2s_din_pin: GPIO17 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/speaker/common-audio_dac.yaml b/tests/components/speaker/common-audio_dac.yaml index 67bd6c28ef..e3972b4da9 100644 --- a/tests/components/speaker/common-audio_dac.yaml +++ b/tests/components/speaker/common-audio_dac.yaml @@ -14,11 +14,6 @@ esphome: - speaker.finish: - speaker.stop: -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - audio_dac: - platform: aic3204 i2c_id: i2c_bus @@ -27,6 +22,7 @@ audio_dac: speaker: - platform: i2s_audio id: speaker_with_audio_dac_id + i2s_audio_id: i2s_audio_bus audio_dac: internal_dac dac_type: external i2s_dout_pin: ${i2s_dout_pin} diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 9aaf639162..895f4b4b8f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -48,13 +48,9 @@ button: data: !lambda |- return {0x01, 0x02, (uint8_t)id(my_number).state}; -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - speaker: - platform: i2s_audio id: speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} diff --git a/tests/components/speaker/test-audio_dac.esp32-idf.yaml b/tests/components/speaker/test-audio_dac.esp32-idf.yaml index 71c8b06e24..48c55769da 100644 --- a/tests/components/speaker/test-audio_dac.esp32-idf.yaml +++ b/tests/components/speaker/test-audio_dac.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO23 + i2s_dout_pin: GPIO33 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common-audio_dac.yaml diff --git a/tests/components/speaker/test-media_player.esp32-idf.yaml b/tests/components/speaker/test-media_player.esp32-idf.yaml index 4712e4bae8..9ef164bb03 100644 --- a/tests/components/speaker/test-media_player.esp32-idf.yaml +++ b/tests/components/speaker/test-media_player.esp32-idf.yaml @@ -1,9 +1,7 @@ substitutions: - scl_pin: GPIO16 - sda_pin: GPIO17 - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO23 + i2s_dout_pin: GPIO13 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common-media_player.yaml diff --git a/tests/components/speaker/test.esp32-idf.yaml b/tests/components/speaker/test.esp32-idf.yaml index 27b8604656..b6aeca4faa 100644 --- a/tests/components/speaker/test.esp32-idf.yaml +++ b/tests/components/speaker/test.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO12 + i2s_dout_pin: GPIO13 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index 7d663b802c..d31b97553e 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -1,17 +1,13 @@ -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - speaker: - platform: i2s_audio - id: speaker_id + id: speaker_source_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} sample_rate: 48000 num_channels: 2 - platform: mixer - output_speaker: speaker_id + output_speaker: speaker_source_speaker_id source_speakers: - id: announcement_mixer_speaker_id - id: media_mixer_speaker_id diff --git a/tests/components/speaker_source/test.esp32-idf.yaml b/tests/components/speaker_source/test.esp32-idf.yaml index e2439ebdf2..5a2fd16938 100644 --- a/tests/components/speaker_source/test.esp32-idf.yaml +++ b/tests/components/speaker_source/test.esp32-idf.yaml @@ -1,9 +1,7 @@ substitutions: - scl_pin: GPIO16 - sda_pin: GPIO17 - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO23 + i2s_dout_pin: GPIO22 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 0fa0903370..812e7a2314 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -11,14 +11,9 @@ wifi: api: -i2s_audio: - i2s_lrclk_pin: ${i2s_lrclk_pin} - i2s_bclk_pin: ${i2s_bclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - micro_wake_word: id: mww_id - microphone: mic_id_external + microphone: va_mic_id_external on_wake_word_detected: - voice_assistant.start: wake_word: !lambda return wake_word; @@ -27,31 +22,34 @@ micro_wake_word: microphone: - platform: i2s_audio - id: mic_id_external + id: va_mic_id_external + i2s_audio_id: i2s_audio_bus i2s_din_pin: ${i2s_din_pin} adc_type: external pdm: false - platform: i2s_audio - id: mic_id_external2 + id: va_mic_id_external2 + i2s_audio_id: i2s_audio_bus i2s_din_pin: ${i2s_din_pin2} adc_type: external pdm: false speaker: - platform: i2s_audio - id: speaker_id + id: va_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} voice_assistant: microphone: - - microphone: mic_id_external + - microphone: va_mic_id_external gain_factor: 4 channels: 0 - - microphone: mic_id_external2 + - microphone: va_mic_id_external2 gain_factor: 4 channels: 0 - speaker: speaker_id + speaker: va_speaker_id micro_wake_word: mww_id conversation_timeout: 60s on_listening: diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index d09de74396..8604bea795 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -11,30 +11,27 @@ wifi: api: -i2s_audio: - i2s_lrclk_pin: ${i2s_lrclk_pin} - i2s_bclk_pin: ${i2s_bclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - microphone: - platform: i2s_audio - id: mic_id_external + id: va_mic_id_external + i2s_audio_id: i2s_audio_bus i2s_din_pin: ${i2s_din_pin} adc_type: external pdm: false speaker: - platform: i2s_audio - id: speaker_id + id: va_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} voice_assistant: microphone: - microphone: mic_id_external + microphone: va_mic_id_external gain_factor: 4 channels: 0 - speaker: speaker_id + speaker: va_speaker_id conversation_timeout: 60s on_listening: - logger.log: "Voice assistant microphone listening" diff --git a/tests/components/voice_assistant/test.esp32-idf.yaml b/tests/components/voice_assistant/test.esp32-idf.yaml index 0cc670a77e..de2b221da7 100644 --- a/tests/components/voice_assistant/test.esp32-idf.yaml +++ b/tests/components/voice_assistant/test.esp32-idf.yaml @@ -1,9 +1,9 @@ substitutions: - i2s_lrclk_pin: GPIO4 - i2s_bclk_pin: GPIO5 - i2s_mclk_pin: GPIO15 - i2s_din_pin: GPIO13 - i2s_din_pin2: GPIO14 - i2s_dout_pin: GPIO12 + i2s_din_pin: GPIO34 + i2s_din_pin2: GPIO35 + i2s_dout_pin: GPIO32 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common-idf.yaml diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 76f14b8664..5e925d0067 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -145,6 +145,15 @@ Same pin allocations as standard I2C, but with 10kHz frequency for components re Same UART pins as above, plus: - **flow_control_pin**: GPIO4 (all platforms) +### I2S Audio +Provides a shared `i2s_audio_bus` (clock pins only); ESP32 family only: +- **ESP32 IDF / ESP32-S3 IDF**: BCLK=GPIO5, LRCLK=GPIO4, MCLK=GPIO15 + +Each consumer keeps its own `i2s_dout_pin`/`i2s_din_pin` substitution and must use a +unique data pin, since several speakers/microphones can share one bus when grouped. +The `i2s_audio` component itself (and the isolated PDM `microphone`) keep defining the +bus inline and are not grouped. + ### BLE - **ESP32**: Shared `esp32_ble_tracker` infrastructure - Each component defines unique `ble_client` with different MAC addresses diff --git a/tests/test_build_components/common/i2s_audio/esp32-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-idf.yaml new file mode 100644 index 0000000000..b540ca9af0 --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} diff --git a/tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml new file mode 100644 index 0000000000..d6632cc264 --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32-S3 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} From a8032054ea38ef30409bf54177865a872e64d869 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:28:50 +1200 Subject: [PATCH 1543/2214] [light] Pass light reference into lambda light effect (#16815) --- esphome/components/light/base_light_effects.h | 6 +++--- esphome/components/light/effects.py | 5 ++++- esphome/components/light/types.py | 1 + tests/components/light/common.yaml | 3 +++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index cdb9f1f666..ba3fba6c12 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -111,7 +111,7 @@ class RandomLightEffect : public LightEffect { class LambdaLightEffect : public LightEffect { public: - LambdaLightEffect(const char *name, void (*f)(bool initial_run), uint32_t update_interval) + LambdaLightEffect(const char *name, void (*f)(LightState &, bool initial_run), uint32_t update_interval) : LightEffect(name), f_(f), update_interval_(update_interval) {} void start() override { this->initial_run_ = true; } @@ -119,7 +119,7 @@ class LambdaLightEffect : public LightEffect { const uint32_t now = millis(); if (now - this->last_run_ >= this->update_interval_ || this->initial_run_) { this->last_run_ = now; - this->f_(this->initial_run_); + this->f_(*this->state_, this->initial_run_); this->initial_run_ = false; } } @@ -129,7 +129,7 @@ class LambdaLightEffect : public LightEffect { uint32_t get_current_index() const { return this->get_index(); } protected: - void (*f_)(bool initial_run); + void (*f_)(LightState &, bool initial_run); uint32_t update_interval_; uint32_t last_run_{0}; bool initial_run_; diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 4088a78e0d..3ae15f9ee5 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -51,6 +51,7 @@ from .types import ( FlickerLightEffect, LambdaLightEffect, LightColorValues, + LightStateRef, PulseLightEffect, RandomLightEffect, StrobeLightEffect, @@ -175,7 +176,9 @@ def register_addressable_effect( ) async def lambda_effect_to_code(config, effect_id): lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(bool, "initial_run")], return_type=cg.void + config[CONF_LAMBDA], + [(LightStateRef, "it"), (bool, "initial_run")], + return_type=cg.void, ) return cg.new_Pvariable( effect_id, config[CONF_NAME], lambda_, config[CONF_UPDATE_INTERVAL] diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index c7385cbee3..9c1c7331d1 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -4,6 +4,7 @@ import esphome.codegen as cg # Base light_ns = cg.esphome_ns.namespace("light") LightState = light_ns.class_("LightState", cg.EntityBase, cg.Component) +LightStateRef = LightState.operator("ref") AddressableLightState = light_ns.class_("AddressableLightState", LightState) LightOutput = light_ns.class_("LightOutput") AddressableLight = light_ns.class_("AddressableLight", LightOutput, cg.Component) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index cd9b27768e..2acc080c6d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -182,6 +182,9 @@ light: state += 1; if (state == 4) state = 0; + if (initial_run) { + ESP_LOGD("custom_effect", "Effect %s started", it.get_name().c_str()); + } - pulse: transition_length: 10s update_interval: 20s From ef64d27ed46f74437e318b88bd2817c00b493ed1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:30:18 -0500 Subject: [PATCH 1544/2214] Bump ruff from 0.15.15 to 0.15.16 (#16807) 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 203cd2bbea..9da27acc19 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.15 # also change in .pre-commit-config.yaml when updating +ruff==0.15.16 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 772cae445fdaae90f114e2bf30053339e3eb9e8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:30:29 -0500 Subject: [PATCH 1545/2214] Bump github/codeql-action from 4.36.1 to 4.36.2 (#16808) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c71d7204de..e559472b60 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: category: "/language:${{matrix.language}}" From a5b4a7cd514d7dac5bf317c6a9e558e246828bbf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:45:56 -0400 Subject: [PATCH 1546/2214] [remote_base] Fix RC5 decoding at either receive polarity (#16767) --- .../components/remote_base/rc5_protocol.cpp | 90 +++++++++++-------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/esphome/components/remote_base/rc5_protocol.cpp b/esphome/components/remote_base/rc5_protocol.cpp index c7f79ad84a..fd136a4e6d 100644 --- a/esphome/components/remote_base/rc5_protocol.cpp +++ b/esphome/components/remote_base/rc5_protocol.cpp @@ -7,6 +7,7 @@ static const char *const TAG = "remote.rc5"; static constexpr uint32_t BIT_TIME_US = 889; static constexpr uint8_t NBITS = 14; +static constexpr uint8_t NHALFBITS = NBITS * 2; void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) { static bool toggle = false; @@ -35,52 +36,63 @@ void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) { } toggle = !toggle; } + optional RC5Protocol::decode(RemoteReceiveData src) { - RC5Data out{ - .address = 0, - .command = 0, - }; - uint8_t field_bit; - - if (src.expect_space(BIT_TIME_US) && src.expect_mark(BIT_TIME_US)) { - field_bit = 1; - } else if (src.expect_space(2 * BIT_TIME_US)) { - field_bit = 0; - } else { - return {}; - } - - if (!(((src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US)) || - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) && - (((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) && - (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) || - ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US)))))) { - return {}; - } - - uint32_t out_data = 0; - for (int bit = NBITS - 4; bit >= 1; bit--) { - if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { - out_data |= 0 << bit; - } else if ((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) && - (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) { - out_data |= 1 << bit; + // Expand the runs into half-bit levels (true = mark). Each run is exactly one + // half-bit (BIT_TIME_US) or two (2 * BIT_TIME_US); stop at anything else. + // + // halfbits[0] is reserved for the leading half-bit, which is always dropped -- + // S1 is 1, so its first half sits at the idle level (at either polarity) and + // merges into the pre-frame idle. Captured half-bits start at index 1. + bool halfbits[NHALFBITS + 2]; + uint8_t n = 1; + for (uint32_t i = 0; n <= NHALFBITS && src.is_valid(i); i++) { + if (src.peek_mark(BIT_TIME_US, i)) { + halfbits[n++] = true; + } else if (src.peek_space(BIT_TIME_US, i)) { + halfbits[n++] = false; + } else if (src.peek_mark(2 * BIT_TIME_US, i)) { + halfbits[n++] = true; + halfbits[n++] = true; + } else if (src.peek_space(2 * BIT_TIME_US, i)) { + halfbits[n++] = false; + halfbits[n++] = false; } else { - return {}; + break; } } - if (src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) { - out_data |= 0; - } else if (src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) { - out_data |= 1; + + // Expect a full frame once the leading half is restored: 27 captured halves + // (n == 28) or 26 when the final bit also ends on idle and its trailing half + // is dropped too (n == 27). A dropped edge half is the inverse of its partner + // (a Manchester bit always transitions mid-bit), so reconstruct the leading + // half (always) and the trailing half (only when it was dropped). + if (n != NHALFBITS && n != NHALFBITS - 1) { + return {}; + } + halfbits[0] = !halfbits[1]; + if (n == NHALFBITS - 1) { + halfbits[n] = !halfbits[n - 1]; } - out.command = (uint8_t) (out_data & 0x3F) + (1 - field_bit) * 64u; - out.address = (out_data >> 6) & 0x1F; - return out; + const bool carrier = halfbits[1]; + uint16_t bits = 0; + for (uint8_t i = 0; i < NBITS; i++) { + const bool first = halfbits[2 * i]; + const bool second = halfbits[2 * i + 1]; + if (first == second) { + return {}; // no midpoint transition -> not a valid Manchester bit + } + bits = (bits << 1) | (second == carrier ? 1 : 0); + } + + const bool field_bit = bits & (1 << 12); // S2: the inverted 7th command bit + return RC5Data{ + .address = static_cast((bits >> 6) & 0x1F), + .command = static_cast((bits & 0x3F) | (field_bit ? 0 : 0x40)), + }; } + void RC5Protocol::dump(const RC5Data &data) { ESP_LOGI(TAG, "Received RC5: address=0x%02X, command=0x%02X", data.address, data.command); } From 375ecdfb2c4489300ce281942ee16fad3a0f7bd4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:01 -0400 Subject: [PATCH 1547/2214] [esp32][core] Restore ESP-IDF version on logs/upload fast path and clean build on framework change (#16770) --- esphome/storage_json.py | 29 ++++++++++-- esphome/writer.py | 13 ++++-- tests/unit_tests/test_espidf_toolchain.py | 9 ++++ tests/unit_tests/test_storage_json.py | 56 ++++++++++++++++++++++- tests/unit_tests/test_writer.py | 27 +++++++++++ 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index dc1576ab18..65444a2ed8 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -16,7 +16,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, Toolchain, ) -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.helpers import write_file_if_changed from esphome.types import CoreType @@ -101,6 +101,7 @@ class StorageJSON: core_platform: str | None = None, toolchain: str | None = None, area: str | None = None, + framework_version: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -141,6 +142,8 @@ class StorageJSON: self.toolchain = toolchain # The area of the node self.area = area + # The framework version the build used (for esp32, the resolved ESP-IDF version) + self.framework_version = framework_version def as_dict(self): return { @@ -162,6 +165,7 @@ class StorageJSON: "core_platform": self.core_platform, "toolchain": self.toolchain, "area": self.area, + "framework_version": self.framework_version, } def to_json(self): @@ -173,10 +177,12 @@ class StorageJSON: @staticmethod def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: hardware = esph.target_platform.upper() + framework_version: str | None = None if esph.is_esp32: from esphome.components import esp32 hardware = esp32.get_esp32_variant(esph) + framework_version = str(esp32.idf_version()) return StorageJSON( storage_version=1, name=esph.name, @@ -200,6 +206,7 @@ class StorageJSON: core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, area=esph.area, + framework_version=framework_version, ) @staticmethod @@ -249,6 +256,7 @@ class StorageJSON: core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") area = storage.get("area") + framework_version = storage.get("framework_version") return StorageJSON( storage_version, name, @@ -268,6 +276,7 @@ class StorageJSON: core_platform, toolchain, area, + framework_version, ) @staticmethod @@ -311,10 +320,24 @@ class StorageJSON: # esp32.get_esp32_variant(). target_platform on disk is the variant # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). if target_platform == const.PLATFORM_ESP32: - from esphome.components.esp32.const import KEY_ESP32 + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION from esphome.const import KEY_VARIANT - CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform} + esp32_data = {KEY_VARIANT: self.target_platform} + if self.framework_version: + import esphome.config_validation as cv + + try: + esp32_data[KEY_IDF_VERSION] = cv.Version.parse( + self.framework_version + ) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{self.framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err + CORE.data[KEY_ESP32] = esp32_data def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/esphome/writer.py b/esphome/writer.py index 192c9d68e8..67202ff925 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -93,9 +93,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: ``src_version`` differs, ``build_path`` differs, the build ``toolchain`` differs (e.g. switching between the PlatformIO and native ESP-IDF toolchains, which produce incompatible build trees), - or a previously loaded integration was removed in *new*. Adding - integrations or changing unrelated fields (friendly name, esphome - version, etc.) does not trigger a clean. + the ``framework`` or ``framework_version`` differs (e.g. switching + arduino <-> esp-idf, or bumping the ESP-IDF version, which also + produce incompatible build trees), or a previously loaded + integration was removed in *new*. Adding integrations or changing + unrelated fields (friendly name, esphome version, etc.) does not + trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -113,6 +116,10 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.toolchain != new.toolchain: return True + if old.framework != new.framework: + return True + if old.framework_version != new.framework_version: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index adc8bfce63..15e2213816 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -56,3 +56,12 @@ def test_get_esphome_esp_idf_paths_no_override(): ) as mock_install: toolchain._get_esphome_esp_idf_paths("5.5.4") mock_install.assert_called_once_with("5.5.4", source_url=None) + + +def test_get_core_framework_version_from_core_data(): + """The version is read from CORE.data when validation populated it.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + import esphome.config_validation as cv + + CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}} + assert toolchain._get_core_framework_version() == "5.5.4" diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 2a6f22abb1..5b318008e1 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import storage_json +from esphome import config_validation as cv, storage_json from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE @@ -206,6 +206,7 @@ def test_storage_json_as_dict() -> None: framework="arduino", core_platform="esp32", area="Living Room", + framework_version="5.3.1", ) result = storage.as_dict() @@ -235,6 +236,7 @@ def test_storage_json_as_dict() -> None: assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" assert result["area"] == "Living Room" + assert result["framework_version"] == "5.3.1" def test_storage_json_to_json() -> None: @@ -313,8 +315,12 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.toolchain = Toolchain.ESP_IDF mock_core.area = "Living Room" - with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: + with ( + patch("esphome.components.esp32.get_esp32_variant") as mock_variant, + patch("esphome.components.esp32.idf_version") as mock_idf_version, + ): mock_variant.return_value = "ESP32-C3" + mock_idf_version.return_value = cv.Version(5, 3, 1) result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) @@ -333,6 +339,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" assert result.area == "Living Room" + assert result.framework_version == "5.3.1" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -545,6 +552,51 @@ def test_storage_json_apply_to_core_ignores_unknown_toolchain( assert CORE.toolchain is None +def test_storage_json_framework_version_round_trip(setup_core: Path) -> None: + """Sidecar framework_version restores CORE.data[esp32][idf_version].""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + + storage = _make_storage_with_toolchain("esp-idf") + storage.framework_version = "5.3.1" + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + assert json.loads(path.read_text())["framework_version"] == "5.3.1" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.framework_version == "5.3.1" + + loaded.apply_to_core() + assert CORE.data[KEY_ESP32][KEY_IDF_VERSION] == cv.Version(5, 3, 1) + + +def test_storage_json_apply_to_core_without_framework_version( + setup_core: Path, +) -> None: + """Older sidecars lacking framework_version don't populate idf_version.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + + loaded = _make_storage_with_toolchain("esp-idf") + assert loaded.framework_version is None + + loaded.apply_to_core() + assert KEY_IDF_VERSION not in CORE.data[KEY_ESP32] + + +def test_storage_json_apply_to_core_raises_on_invalid_framework_version( + setup_core: Path, +) -> None: + """A malformed version string fails with an actionable error at parse time.""" + from esphome.core import EsphomeError + + loaded = _make_storage_with_toolchain("esp-idf") + loaded.framework_version = "not-a-version" + + with pytest.raises(EsphomeError, match="clean the build"): + loaded.apply_to_core() + + def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index be37dd5d58..2e3499e8e3 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -76,6 +76,7 @@ def create_storage() -> Callable[..., StorageJSON]: framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), toolchain=kwargs.get("toolchain", "platformio"), + framework_version=kwargs.get("framework_version"), ) return _create @@ -121,6 +122,32 @@ def test_storage_should_clean_when_toolchain_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_framework_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the framework changes. + + Switching between arduino and esp-idf produces incompatible build trees + even on the same toolchain, so the build must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], framework="arduino") + new = create_storage(loaded_integrations=["api", "wifi"], framework="esp-idf") + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_framework_version_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the framework version changes. + + A different framework/ESP-IDF version compiles against a different SDK, so + the stale build tree must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.3.1") + new = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.4.0") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: From 5662e1b7cddd5ece07dfe8c60b47f2a48555ad30 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:08 -0400 Subject: [PATCH 1548/2214] [rp2040] Fix lwipopts template load on Windows extended-length paths (#16783) --- esphome/components/rp2040/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 862d532645..2ac3c4698b 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -402,18 +402,21 @@ 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, FileSystemLoader + from jinja2 import Environment lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) if not lwip_defines: return - template_dir = Path(__file__).parent - jinja_env = Environment( - loader=FileSystemLoader(str(template_dir)), - keep_trailing_newline=True, + # Read the template via pathlib and render from a string rather than using + # FileSystemLoader. jinja2's loader joins the search path with posixpath, which + # breaks on Windows extended-length paths (\\?\C:\...) where forward slashes are + # not accepted, causing a spurious TemplateNotFound (see issue #16732). + template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text( + encoding="utf-8" ) - template = jinja_env.get_template("lwipopts.h.jinja") + jinja_env = Environment(keep_trailing_newline=True) + template = jinja_env.from_string(template_text) content = template.render(**lwip_defines) lwip_dir = CORE.relative_build_path("lwip_override") From bcf5606b31630a91bfde8a5608d524fa260113e6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:17 -0400 Subject: [PATCH 1549/2214] [esp32_ble_server] Fix duplicate Device Information Service with string UUIDs (#16784) --- .../components/esp32_ble_server/__init__.py | 28 +++++++++-- .../esp32_ble_server/__init__.py | 0 .../esp32_ble_server/test_esp32_ble_server.py | 47 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/esp32_ble_server/__init__.py create mode 100644 tests/component_tests/esp32_ble_server/test_esp32_ble_server.py diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 7bf3092a4e..d45f2d9df2 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -62,6 +62,26 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29 MODEL_CHARACTERISTIC_UUID = 0x2A24 FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26 +# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit. +_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB" + + +def uuid_is(uuid: int | str, uuid16: int) -> bool: + """Return True if a validated UUID refers to the given 16-bit short UUID. + + A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an + uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every + representation of the same UUID must be considered equivalent. + """ + if isinstance(uuid, int): + return uuid == uuid16 + return uuid.upper() in ( + f"{uuid16:04X}", + f"{uuid16:08X}", + f"{uuid16:08X}{_BASE_UUID_SUFFIX}", + ) + + # Core key to store the global configuration KEY_NOTIFY_REQUIRED = "notify_required" KEY_SET_VALUE = "set_value" @@ -195,7 +215,7 @@ def create_description_cud(char_config): return char_config # If the config displays a description, there cannot be a descriptor with the CUD UUID for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present" ) @@ -218,7 +238,7 @@ def create_notify_cccd(char_config): return char_config # If the CCCD descriptor is already present, return the config for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID): # Check if the WRITE property is set if not desc[CONF_WRITE]: raise cv.Invalid( @@ -244,7 +264,7 @@ def create_device_information_service(config): # If there is already a device information service, # there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties for service in config[CONF_SERVICES]: - if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): if ( CONF_MODEL in config or CONF_MANUFACTURER in config @@ -592,7 +612,7 @@ async def to_code(config): ) for char_conf in service_config[CONF_CHARACTERISTICS]: await to_code_characteristic(service_var, char_conf) - if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): cg.add(var.set_device_information_service(service_var)) else: cg.add(var.enqueue_start_service(service_var)) diff --git a/tests/component_tests/esp32_ble_server/__init__.py b/tests/component_tests/esp32_ble_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py new file mode 100644 index 0000000000..88307d0dcf --- /dev/null +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -0,0 +1,47 @@ +"""Tests for esp32_ble_server configuration helpers.""" + +import pytest + +from esphome.components.esp32_ble_server import ( + CCCD_DESCRIPTOR_UUID, + CUD_DESCRIPTOR_UUID, + DEVICE_INFORMATION_SERVICE_UUID, + uuid_is, +) + + +@pytest.mark.parametrize( + "uuid", + [ + DEVICE_INFORMATION_SERVICE_UUID, # int form (cv.hex_uint32_t) + "180A", # 16 bit short form (bt_uuid) + "180a", # lowercase is normalized by bt_uuid but guard anyway + "0000180A", # 32 bit form + "0000180A-0000-1000-8000-00805F9B34FB", # full 128 bit form + ], +) +def test_uuid_is_matches_all_representations(uuid) -> None: + """All representations of the same 16 bit UUID must compare equal.""" + assert uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize( + "uuid", + [ + 0x1818, # Cycling Power Service (different int) + "1818", # different 16 bit short form + "0000180B", # adjacent UUID + "0000180A-0000-1000-8000-00805F9B34FC", # wrong base UUID suffix + ], +) +def test_uuid_is_rejects_other_uuids(uuid) -> None: + """A different UUID must not be mistaken for the device information service.""" + assert not uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize("uuid16", [CUD_DESCRIPTOR_UUID, CCCD_DESCRIPTOR_UUID]) +def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: + """Reserved descriptor UUIDs match whether given as int or short string.""" + assert uuid_is(uuid16, uuid16) + assert uuid_is(f"{uuid16:04X}", uuid16) + assert uuid_is(f"{uuid16:08X}", uuid16) From 7f3feec3a38e8df647f15af10e1732c2a1015aba Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:11:36 +1200 Subject: [PATCH 1550/2214] Bump version to 2026.5.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3dfe6c5ed4..3d74858d3d 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.5.2 +PROJECT_NUMBER = 2026.5.3 # 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 fdbbbe5eab..8bc7907cd0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.2" +__version__ = "2026.5.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ea3ac1ee96acc4214b2e65a32df77f0eb259c4ce Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:02:22 -0400 Subject: [PATCH 1551/2214] [audio] Bump esp-audio-libs to v3.2.1 (#16818) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index c3604e7ef2..1dc63cc7bb 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -adf1b0ed175c64877f959b14ff1ff8d3ba0d15bafcd86fab85a66f1d5ce953e8 +0119a5940f061725291b5dfbafbd0ef843dbe2b40489f38d1d456ae81ee3dbe7 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index c051d70f3d..2ddce577ef 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -335,7 +335,7 @@ async def to_code(config): add_idf_component( name="esphome/esp-audio-libs", - ref="3.2.0", + ref="3.2.1", ) data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4190c80027..9c87a7e5cf 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" esphome/esp-audio-libs: - version: 3.2.0 + version: 3.2.1 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From e209a3fa91318283672ae0bc8584eeab7d71237e Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Fri, 5 Jun 2026 05:57:05 +0200 Subject: [PATCH 1552/2214] [usb_uart] Add FTDI FT23XX USB UART driver (#14587) Co-authored-by: Oliver Kleinecke Co-authored-by: Claude Sonnet 4.6 Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/usb_uart/__init__.py | 18 +- esphome/components/usb_uart/ft23xx.cpp | 454 ++++++++++++++++++++++++ esphome/components/usb_uart/usb_uart.h | 19 + tests/components/usb_uart/common.yaml | 10 + 4 files changed, 497 insertions(+), 4 deletions(-) create mode 100644 esphome/components/usb_uart/ft23xx.cpp diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 1cf78fdbd5..7b9c320879 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS +from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent from esphome.components.usb_host import ( get_max_packet_size, @@ -15,6 +16,7 @@ from esphome.const import ( CONF_DUMMY_RECEIVER, CONF_ID, ) +from esphome.core import CORE from esphome.cpp_types import Component AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] @@ -55,16 +57,24 @@ class Type: uart_types = ( - Type("CH34X", 0x1A86, 0x55D5, "CH34X", 3), - Type("CH340", 0x1A86, 0x7523, "CH34X", 1), - Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), - Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), + Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4), + Type("CH340", 0x1A86, 0x7523, "CH34X", 1), + Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), + Type("FT232", 0x0403, 0x6001, "FT23XX", 1), + Type("FT2232", 0x0403, 0x6010, "FT23XX", 2), + Type("FT4232", 0x0403, 0x6011, "FT23XX", 4), + Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), ) def channel_schema(channels, baud_rate_required): + # For now S3 is restricted to 3 channels since each needs 2 endpoints, plus the control endpoint, and + # there are only a total of 8 endpoints available. + # This will need updating when the 8 channel devices that multiplex over an endpoint are added. + if CORE.is_esp32 and get_esp32_variant() != VARIANT_ESP32P4 and channels > 3: + channels = 3 return cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp new file mode 100644 index 0000000000..d57d7fe3bd --- /dev/null +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -0,0 +1,454 @@ +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#include "usb_uart.h" +#include "usb/usb_host.h" +#include "esphome/core/log.h" +#include "esphome/components/uart/uart_debugger.h" + +#include "esphome/components/bytebuffer/bytebuffer.h" + +namespace esphome::usb_uart { + +using namespace bytebuffer; + +// FTDI chip family identifiers. These map to USB device bcdDevice values +// and determine how baudrate divisors and clock sources are calculated. +enum ftdi_chip_type { + TYPE_AM = 0, + TYPE_BM = 1, + TYPE_2232C = 2, + TYPE_R = 3, + TYPE_2232H = 4, + TYPE_4232H = 5, + TYPE_232H = 6, + TYPE_230X = 7, +}; + +static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { + static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; + static const char am_adjust_up[8] = {0, 0, 0, 1, 0, 3, 2, 1}; + static const char am_adjust_dn[8] = {0, 0, 0, 1, 0, 1, 2, 3}; + int divisor, best_divisor, best_baud, best_baud_diff; + int i; + divisor = 24000000 / baudrate; + + divisor -= am_adjust_dn[divisor & 7]; + + best_divisor = 0; + best_baud = 0; + best_baud_diff = 0; + for (i = 0; i < 2; i++) { + int try_divisor = divisor + i; + int baud_estimate; + int baud_diff; + + if (try_divisor <= 8) { + try_divisor = 8; + } else if (divisor < 16) { + try_divisor = 16; + } else { + try_divisor += am_adjust_up[try_divisor & 7]; + if (try_divisor > 0x1FFF8) { + // Round down to maximum supported divisor value (for AM) + try_divisor = 0x1FFF8; + } + } + baud_estimate = (24000000 + (try_divisor / 2)) / try_divisor; + if (baud_estimate < baudrate) { + baud_diff = baudrate - baud_estimate; + } else { + baud_diff = baud_estimate - baudrate; + } + if (i == 0 || baud_diff < best_baud_diff) { + best_divisor = try_divisor; + best_baud = baud_estimate; + best_baud_diff = baud_diff; + if (baud_diff == 0) { + break; + } + } + } + *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 7] << 14); + if (*encoded_divisor == 1) { + *encoded_divisor = 0; // 3000000 baud + } else if (*encoded_divisor == 0x4001) { + *encoded_divisor = 1; // 2000000 baud (BM only) + } + return best_baud; +} + +static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, unsigned long *encoded_divisor) { + static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; + int best_baud = 0; + int divisor, best_divisor; + if (baudrate >= clk / clk_div) { + *encoded_divisor = 0; + best_baud = clk / clk_div; + } else if (baudrate >= clk / (clk_div + clk_div / 2)) { + *encoded_divisor = 1; + best_baud = clk / (clk_div + clk_div / 2); + } else if (baudrate >= clk / (2 * clk_div)) { + *encoded_divisor = 2; + best_baud = clk / (2 * clk_div); + } else { + divisor = clk * 16 / clk_div / baudrate; + if (divisor & 1) + best_divisor = divisor / 2 + 1; + else + best_divisor = divisor / 2; + if (best_divisor > 0x20000) + best_divisor = 0x1ffff; + best_baud = clk * 16 / clk_div / best_divisor; + if (best_baud & 1) + best_baud = best_baud / 2 + 1; + else + best_baud = best_baud / 2; + *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 0x7] << 14); + } + return best_baud; +} + +static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, unsigned short *value, + unsigned short *index) { + int best_baud; + unsigned long encoded_divisor; + + if (baudrate <= 0) { + return -1; + } + + static constexpr uint32_t H_CLK = 120000000; + static constexpr uint32_t C_CLK = 48000000; + if ((chip_type == TYPE_2232H) || (chip_type == TYPE_4232H) || (chip_type == TYPE_232H)) { + if (baudrate * 10 > H_CLK / 0x3fff) { + best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); + encoded_divisor |= 0x20000; /* switch on CLK/10*/ + } else + best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + } else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) { + best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + } else { + best_baud = ftdi_to_clkbits_AM(baudrate, &encoded_divisor); + } + + *value = (unsigned short) (encoded_divisor & 0xFFFF); + if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) { + *index = (unsigned short) (encoded_divisor >> 8); + *index &= 0xFF00; + *index |= (channel_index + 1); + } else + *index = (unsigned short) (encoded_divisor >> 16); + + return best_baud; +} + +static optional get_uart(const usb_config_desc_t *config_desc, uint8_t intf_idx) { + int conf_offset, ep_offset; + CdcEps eps{}; + + const auto *intf_desc = usb_parse_interface_descriptor(config_desc, intf_idx, 0, &conf_offset); + if (!intf_desc) { + ESP_LOGD(TAG, "usb_parse_interface_descriptor failed for intf_idx=%d (end of interfaces)", intf_idx); + return nullopt; + } + ESP_LOGD(TAG, + "intf_desc [idx=%d]: bInterfaceClass=%02X, bInterfaceSubClass=%02X, bInterfaceProtocol=%02X, " + "bNumEndpoints=%d, bInterfaceNumber=%d", + intf_idx, intf_desc->bInterfaceClass, intf_desc->bInterfaceSubClass, intf_desc->bInterfaceProtocol, + intf_desc->bNumEndpoints, intf_desc->bInterfaceNumber); + + std::vector endpoints; + for (uint8_t i = 0; i != intf_desc->bNumEndpoints; i++) { + ep_offset = conf_offset; + const auto *ep = usb_parse_endpoint_descriptor_by_index(intf_desc, i, config_desc->wTotalLength, &ep_offset); + if (!ep) { + ESP_LOGE(TAG, "Ran out of endpoints at %d before finding all %d endpoints", i, intf_desc->bNumEndpoints); + return nullopt; + } + ESP_LOGD(TAG, "ep: bEndpointAddress=%02X, bmAttributes=%02X", ep->bEndpointAddress, ep->bmAttributes); + + if (ep->bmAttributes != 0x2) { + ESP_LOGD(TAG, "Skipping non-bulk endpoint: %02X", ep->bEndpointAddress); + continue; + } + endpoints.push_back(ep); + } + + const usb_ep_desc_t *ep1 = nullptr; + const usb_ep_desc_t *ep2 = nullptr; + for (const auto *ep : endpoints) { + if (ep1 == nullptr) { + ep1 = ep; + } else if (ep2 == nullptr) { + ep2 = ep; + break; + } + } + + if (ep1 == nullptr || ep2 == nullptr) { + ESP_LOGD(TAG, "Interface %d has %zu endpoints (need 2 bulk endpoints)", intf_idx, endpoints.size()); + return nullopt; + } + + ESP_LOGD(TAG, "Interface %d: ep1=0x%02X, ep2=0x%02X", intf_idx, ep1->bEndpointAddress, ep2->bEndpointAddress); + + if (ep1->bEndpointAddress & usb_host::USB_DIR_IN) { + eps.in_ep = ep1; + eps.out_ep = ep2; + ESP_LOGD(TAG, "ep1 is IN (RX): ep1=0x%02X (in_ep), ep2=0x%02X (out_ep)", ep1->bEndpointAddress, + ep2->bEndpointAddress); + } else { + eps.out_ep = ep1; + eps.in_ep = ep2; + ESP_LOGD(TAG, "ep1 is OUT (TX): ep1=0x%02X (out_ep), ep2=0x%02X (in_ep)", ep1->bEndpointAddress, + ep2->bEndpointAddress); + } + + eps.bulk_interface_number = intf_desc->bInterfaceNumber; + return eps; +} + +std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev_hdl) { + const usb_config_desc_t *config_desc; + const usb_device_desc_t *device_desc; + std::vector cdc_devs{}; + std::string type_string; + + if (usb_host_get_device_descriptor(dev_hdl, &device_desc) != ESP_OK) { + ESP_LOGE(TAG, "get_device_descriptor failed"); + return {}; + } + if (usb_host_get_active_config_descriptor(dev_hdl, &config_desc) != ESP_OK) { + ESP_LOGE(TAG, "get_active_config_descriptor failed"); + return {}; + } + if (device_desc->bcdDevice == 0x400 || (device_desc->bcdDevice == 0x200 && device_desc->iSerialNumber == 0)) { + this->chip_type_ = TYPE_BM; + type_string = "BM type chip"; + } else if (device_desc->bcdDevice == 0x200) { + this->chip_type_ = TYPE_AM; + type_string = "AM type chip"; + } else if (device_desc->bcdDevice == 0x500) { + this->chip_type_ = TYPE_2232C; + type_string = "2232C chip"; + } else if (device_desc->bcdDevice == 0x600) { + this->chip_type_ = TYPE_R; + type_string = "type R chip"; + } else if (device_desc->bcdDevice == 0x700) { + this->chip_type_ = TYPE_2232H; + type_string = "2232H chip"; + } else if (device_desc->bcdDevice == 0x800) { + this->chip_type_ = TYPE_4232H; + type_string = "4232H chip"; + } else if (device_desc->bcdDevice == 0x900) { + this->chip_type_ = TYPE_232H; + type_string = "232H type chip"; + } else if (device_desc->bcdDevice == 0x1000) { + this->chip_type_ = TYPE_230X; + type_string = "230x chip"; + } + + ESP_LOGD(TAG, "Found FTDI %s based device", type_string.c_str()); + for (uint8_t intf_idx = 0; intf_idx < this->channels_.size(); intf_idx++) { + if (auto eps = get_uart(config_desc, intf_idx)) { + cdc_devs.push_back(*eps); + ESP_LOGD(TAG, "Found CDC interface at USB interface index %d", intf_idx); + } + } + return cdc_devs; +} + +int USBUartTypeFT23XX::reset(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + } else { + ESP_LOGD(TAG, "Reset successful, setting baudrate..."); + this->set_baudrate(channel); + } + }; + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, + channel->cdc_dev_.bulk_interface_number + 1, callback); + if (!ok) { + ESP_LOGE(TAG, "Reset control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +int USBUartTypeFT23XX::set_baudrate(USBUartChannel *channel, uint32_t baudrate) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + } else { + ESP_LOGD(TAG, "Baudrate %d set, setting line properties...", channel->baud_rate_); + this->set_line_properties(channel); + } + }; + if (baudrate == 0) { + baudrate = channel->baud_rate_; + } + unsigned short value, ftdi_index; + ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); + ESP_LOGD(TAG, "Baudrate: %d, value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); + uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback); + if (!ok) { + ESP_LOGE(TAG, "Set baudrate control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +int USBUartTypeFT23XX::set_line_properties(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + return; + } + ESP_LOGD(TAG, "Line properties set, setting modem control..."); + this->set_dtr_rts(channel); + }; + + unsigned short value = channel->data_bits_; + + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + value |= (0x00 << 8); + break; + case UART_CONFIG_PARITY_ODD: + value |= (0x01 << 8); + break; + case UART_CONFIG_PARITY_EVEN: + value |= (0x02 << 8); + break; + case UART_CONFIG_PARITY_MARK: + value |= (0x03 << 8); + break; + case UART_CONFIG_PARITY_SPACE: + value |= (0x04 << 8); + break; + } + + switch (channel->stop_bits_) { + case UART_CONFIG_STOP_BITS_1: + value |= (0x00 << 11); + break; + case UART_CONFIG_STOP_BITS_1_5: + value |= (0x01 << 11); + break; + case UART_CONFIG_STOP_BITS_2: + value |= (0x02 << 11); + break; + } + + value |= (0x00 << 14); + + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, + channel->cdc_dev_.bulk_interface_number + 1, callback); + if (!ok) { + ESP_LOGE(TAG, "Set line properties control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +int USBUartTypeFT23XX::set_dtr_rts(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + return; + } + ESP_LOGD(TAG, "Modem control set for channel %d, starting input...", channel->index_); + channel->initialised_.store(true); + this->start_input(channel); + uint8_t next_index = channel->index_ + 1; + if (next_index < this->channels_.size()) { + USBUartChannel *next_channel = this->channels_[next_index]; + ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_); + this->reset(next_channel); + return; + } else { + ESP_LOGI(TAG, "All channels configured"); + } + }; + + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, + channel->cdc_dev_.bulk_interface_number + 1, callback); + if (!ok) { + ESP_LOGE(TAG, "Set modem control control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { + if (!channel->initialised_.load() || channel->input_started_.load()) + return; + + const auto *ep = channel->cdc_dev_.in_ep; + + auto callback = [this, channel](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "RX Transfer failed, status=%s", esp_err_to_name(status.error_code)); + channel->input_started_.store(false); + return; + } + + size_t uart_data_len = (status.data_len > 2) ? (status.data_len - 2) : 0; + + if (uart_data_len > 0) { + ESP_LOGV(TAG, "RX callback: Received %zu bytes, channel=%d", uart_data_len, channel->index_); + if (!channel->dummy_receiver_) { + // Copy the entire received UART payload into the ring buffer in one + // operation to avoid per-byte overhead and reduce the chance of + // heap activity in hot paths. + channel->input_buffer_.push(status.data + 2, uart_data_len); +#ifdef USE_UART_DEBUGGER + if (channel->debug_) { + // Debug path creates a temporary vector for logging only; this is + // acceptable because debug mode is opt-in and not used in release. + uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, + std::vector(status.data + 2, status.data + 2 + uart_data_len), ',', + channel->debug_prefix_); + } +#endif + } + } else { + ESP_LOGVV(TAG, "RX: Status packet, modem=0x%02X line=0x%02X, ch=%d", status.data[0], status.data[1], + channel->index_); + } + + channel->input_started_.store(false); + if (channel->dummy_receiver_ || + channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) { + this->start_input(channel); + } + }; + + channel->input_started_.store(true); + this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); +} + +void USBUartTypeFT23XX::enable_channels() { + if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) { + this->reset(this->channels_[0]); + } + + for (auto *channel : this->channels_) { + if (!channel->initialised_.load()) + continue; + channel->input_started_.store(false); + channel->output_started_.store(false); + } +} + +} // namespace esphome::usb_uart +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index fb8425f6cd..7a19aa8e4b 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -129,6 +129,7 @@ class USBUartChannel : public uart::UARTComponent, public Parented parse_descriptors(usb_device_handle_t dev_hdl) override; + void enable_channels() override; + + int reset(USBUartChannel *channel); + int set_baudrate(USBUartChannel *channel, uint32_t baudrate = 0); + int set_line_properties(USBUartChannel *channel); + int set_dtr_rts(USBUartChannel *channel); + + uint8_t chip_type_{255}; +}; + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 5869b9468b..c8c1ee7df2 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -42,3 +42,13 @@ usb_uart: baud_rate: 9600 debug: true debug_prefix: "[CP210X] " + - id: uart_6 + type: ft2232 + channels: + - id: channel_6_1 + baud_rate: 115200 + - id: channel_6_2 + baud_rate: 9600 + stop_bits: 2 + data_bits: 7 + parity: odd From cbd3aaa1e001e889eac042d22558406ef4c09bb0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:40:18 +1200 Subject: [PATCH 1553/2214] [ci] Add codecov.yml to enforce 100% patch coverage on PRs (#16827) --- codecov.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..f8afbbde04 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,18 @@ +coverage: + status: + patch: + default: + target: 100% + threshold: 0% + project: + default: + informational: true + +ignore: + - "esphome/components/**/*" + - "esphome/analyze_memory/**/*" + - "tests/integration/**/*" + +comment: + layout: "reach, diff, flags, files" + require_changes: true From 61bb1805b166f4a3afc19a652b490c51b6afdc14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 10:47:32 -0500 Subject: [PATCH 1554/2214] [api] Fix nullptr deref when client teardown reenters state dispatch (#16834) --- esphome/components/api/api_server.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 031fa342c1..ddd03ace4a 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -186,8 +186,12 @@ void APIServer::remove_client_(uint8_t client_index) { if (client_index < last_index) { std::swap(this->clients_[client_index], this->clients_[last_index]); } - this->clients_[last_index].reset(); + // Drop the count before resetting the slot. reset() runs ~APIConnection(), which can reenter the + // server (e.g. voice_assistant unsubscribes in its disconnect trigger, publishing entity state -> + // on_*_update iterating active_clients()). Excluding the dying slot from the active range first + // keeps that reentrant iteration from dereferencing the now-null slot. this->api_connection_count_--; + this->clients_[last_index].reset(); // Last client disconnected - set warning and start tracking for reboot timeout if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { From 80c84d6665a47513c406b5181b7b76304b10307c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:55:39 -0400 Subject: [PATCH 1555/2214] [usb_host][usb_cdc_acm][tinyusb] Fix clang-tidy findings (#16836) --- esphome/components/tinyusb/tinyusb_component.cpp | 2 +- esphome/components/tinyusb/tinyusb_component.h | 2 +- esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp | 6 +++--- esphome/components/usb_host/usb_host.h | 2 +- esphome/components/usb_host/usb_host_client.cpp | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 3cefc0454a..567a84f8c3 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -6,7 +6,7 @@ namespace esphome::tinyusb { -static const char *TAG = "tinyusb"; +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 diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 7d8caade74..56c33a708f 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -17,7 +17,7 @@ enum USBDStringDescriptor : uint8_t { SIZE = 6, }; -static const char *DEFAULT_USB_STR = "ESPHome"; +static const char *const DEFAULT_USB_STR = "ESPHome"; class TinyUSB : public Component { public: 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 5498c38515..592207efa8 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -96,9 +96,9 @@ static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *ev } static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, - TickType_t xTicksToWait) { + TickType_t x_ticks_to_wait) { size_t read_sz; - uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, xTicksToWait, out_buf_sz)); + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); if (buf == nullptr) { return ESP_FAIL; @@ -186,7 +186,7 @@ void USBCDCACMInstance::usb_tx_task() { uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; size_t tx_data_size = 0; - while (1) { + while (true) { // Wait for a notification from the bridge component ulTaskNotifyTake(pdTRUE, portMAX_DELAY); diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 480fd86750..a9f07a5422 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -167,7 +167,7 @@ class USBClient : public Component { // USB task management static void usb_task_fn(void *arg); - [[noreturn]] void usb_task_loop() const; + [[noreturn]] void usb_task_loop_() const; // Members ordered to minimize struct padding on 32-bit platforms TransferRequest requests_[MAX_REQUESTS]{}; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 4ee8e2ac5e..45e2be17c7 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -236,9 +236,9 @@ void USBClient::setup() { void USBClient::usb_task_fn(void *arg) { auto *client = static_cast(arg); - client->usb_task_loop(); + client->usb_task_loop_(); } -void USBClient::usb_task_loop() const { +void USBClient::usb_task_loop_() const { while (true) { usb_host_client_handle_events(this->handle_, portMAX_DELAY); } From b0e1b94c450c4ba80ff13c04413f82884d8cdfe2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:56:48 -0400 Subject: [PATCH 1556/2214] [mipi_dsi][mipi_rgb][st7701s][rpi_dpi_rgb] Fix clang-tidy findings (#16837) --- esphome/components/mipi_dsi/display.py | 4 +-- esphome/components/mipi_dsi/mipi_dsi.cpp | 29 +++++++++---------- esphome/components/mipi_dsi/mipi_dsi.h | 4 +-- esphome/components/mipi_rgb/mipi_rgb.h | 8 ++--- .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 5 ++-- esphome/components/st7701s/st7701s.cpp | 5 ++-- esphome/components/st7701s/st7701s.h | 1 - .../mipi_dsi/test_mipi_dsi_config.py | 6 ++-- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 3554e32299..0939d84aa5 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -65,7 +65,7 @@ DOMAIN = "mipi_dsi" LOGGER = logging.getLogger(DOMAIN) -MIPI_DSI = mipi_dsi_ns.class_("MIPI_DSI", display.Display, cg.Component) +MipiDsi = mipi_dsi_ns.class_("MipiDsi", display.Display, cg.Component) ColorOrder = display.display_ns.enum("ColorMode") ColorBitness = display.display_ns.enum("ColorBitness") @@ -114,7 +114,7 @@ def model_schema(config): schema = display.FULL_DISPLAY_SCHEMA.extend( { model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, - cv.GenerateID(): cv.declare_id(MIPI_DSI), + cv.GenerateID(): cv.declare_id(MipiDsi), cv_dimensions(CONF_DIMENSIONS): dimension_schema( model.get_default(CONF_DRAW_ROUNDING, 1) ), diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 9bd2dded2c..0ff934ae94 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -9,18 +9,18 @@ namespace esphome::mipi_dsi { static constexpr size_t MIPI_DSI_MAX_CMD_LOG_BYTES = 64; static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel_event_data_t *edata, void *user_ctx) { - auto sem = static_cast(user_ctx); + SemaphoreHandle_t sem = static_cast(user_ctx); BaseType_t need_yield = pdFALSE; xSemaphoreGiveFromISR(sem, &need_yield); return (need_yield == pdTRUE); } -void MIPI_DSI::smark_failed(const LogString *message, esp_err_t err) { +void MipiDsi::smark_failed(const LogString *message, esp_err_t err) { ESP_LOGE(TAG, "%s: %s", LOG_STR_ARG(message), esp_err_to_name(err)); this->mark_failed(message); } -void MIPI_DSI::setup() { +void MipiDsi::setup() { ESP_LOGCONFIG(TAG, "Running Setup"); if (!this->enable_pins_.empty()) { @@ -175,7 +175,7 @@ void MIPI_DSI::setup() { ESP_LOGCONFIG(TAG, "MIPI DSI setup complete"); } -void MIPI_DSI::update() { +void MipiDsi::update() { if (this->auto_clear_enabled_) { this->clear(); } @@ -202,8 +202,8 @@ void MIPI_DSI::update() { this->y_high_ = 0; } -void MIPI_DSI::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void MipiDsi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0) return; // if color mapping is required, pass the buck. @@ -216,8 +216,8 @@ void MIPI_DSI::draw_pixels_at(int x_start, int y_start, int w, int h, const uint this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } -void MIPI_DSI::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, - int x_pad) { +void MipiDsi::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad) { esp_err_t err = ESP_OK; auto bytes_per_pixel = 3 - this->color_depth_; auto stride = (x_offset + w + x_pad) * bytes_per_pixel; @@ -241,7 +241,7 @@ void MIPI_DSI::write_to_display_(int x_start, int y_start, int w, int h, const u ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err)); } -bool MIPI_DSI::check_buffer_() { +bool MipiDsi::check_buffer_() { if (this->is_failed()) return false; if (this->buffer_ != nullptr) @@ -257,7 +257,7 @@ bool MIPI_DSI::check_buffer_() { return true; } -void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { +void MipiDsi::draw_pixel_at(int x, int y, Color color) { if (!this->get_clipping().inside(x, y)) return; @@ -280,7 +280,6 @@ void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) { return; } - auto pixel = convert_big_endian(display::ColorUtil::color_to_565(color)); if (!this->check_buffer_()) return; size_t pos = (y * this->width_) + x; @@ -319,7 +318,7 @@ void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { if (y > this->y_high_) this->y_high_ = y; } -void MIPI_DSI::fill(Color color) { +void MipiDsi::fill(Color color) { if (!this->check_buffer_()) return; @@ -359,7 +358,7 @@ void MIPI_DSI::fill(Color color) { } } -int MIPI_DSI::get_width() { +int MipiDsi::get_width() { switch (this->rotation_) { case display::DISPLAY_ROTATION_90_DEGREES: case display::DISPLAY_ROTATION_270_DEGREES: @@ -371,7 +370,7 @@ int MIPI_DSI::get_width() { } } -int MIPI_DSI::get_height() { +int MipiDsi::get_height() { switch (this->rotation_) { case display::DISPLAY_ROTATION_0_DEGREES: case display::DISPLAY_ROTATION_180_DEGREES: @@ -385,7 +384,7 @@ int MIPI_DSI::get_height() { static const uint8_t PIXEL_MODES[] = {0, 16, 18, 24}; -void MIPI_DSI::dump_config() { +void MipiDsi::dump_config() { ESP_LOGCONFIG(TAG, "MIPI_DSI RGB LCD" "\n Model: %s" diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 82827d813e..c99f69989a 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -35,9 +35,9 @@ const uint8_t MADCTL_MV = 0x20; // row/column swap const uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally const uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically -class MIPI_DSI : public display::Display { +class MipiDsi : public display::Display { public: - MIPI_DSI(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) + MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 4d1d836099..dfa8a36e1a 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -30,9 +30,6 @@ class MipiRgb : public display::Display { void fill(Color color) override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; - void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, - int x_pad); - bool check_buffer_(); display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } @@ -60,12 +57,15 @@ class MipiRgb : public display::Display { display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } - void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void dump_config() override; void draw_pixel_at(int x, int y, Color color) override; // this will be horribly slow. protected: + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad); + bool check_buffer_(); + void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void setup_enables_(); void common_setup_(); InternalGPIOPin *de_pin_{nullptr}; diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index 00530c3f96..aacb217965 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -54,8 +54,9 @@ void RpiDpiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uin // if color mapping is required, pass the buck. // note that endianness is not considered here - it is assumed to match! if (bitness != display::COLOR_BITNESS_565) { - return display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; } x_start += this->offset_x_; y_start += this->offset_y_; diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index dac8ac9dbc..3ffef86f3e 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -57,8 +57,9 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 // if color mapping is required, pass the buck. // note that endianness is not considered here - it is assumed to match! if (bitness != display::COLOR_BITNESS_565) { - return display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; } x_start += this->offset_x_; y_start += this->offset_y_; diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index de5e4c13d4..c65a213929 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -32,7 +32,6 @@ class ST7701S : public display::Display, public: void update() override { this->do_update_(); } void setup() override; - void complete_setup_(); void loop() override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 955e945526..c14abdb4fd 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -124,15 +124,15 @@ def test_code_generation( main_cpp = generate_main(component_fixture_path("mipi_dsi.yaml")) assert ( - "alignas(mipi_dsi::MIPI_DSI) static unsigned char mipi_dsi__p4_nano__pstorage[sizeof(mipi_dsi::MIPI_DSI)];" + "alignas(mipi_dsi::MipiDsi) static unsigned char mipi_dsi__p4_nano__pstorage[sizeof(mipi_dsi::MipiDsi)];" in main_cpp ) assert ( - "static mipi_dsi::MIPI_DSI *const p4_nano = reinterpret_cast(mipi_dsi__p4_nano__pstorage);" + "static mipi_dsi::MipiDsi *const p4_nano = reinterpret_cast(mipi_dsi__p4_nano__pstorage);" in main_cpp ) assert ( - "new(p4_nano) mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);" + "new(p4_nano) mipi_dsi::MipiDsi(800, 1280, display::COLOR_BITNESS_565, 16);" in main_cpp ) assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp From 351b98689686c94793c0dfd1f07fc610e249c64a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:01:53 -0400 Subject: [PATCH 1557/2214] [ci] Make ESP32 IDF the comprehensive clang-tidy pass (#16823) Co-authored-by: J. Nick Koston --- .github/workflows/ci.yml | 67 ++++---------------- esphome/components/heatpumpir/heatpumpir.cpp | 3 +- 2 files changed, 16 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f227b37a8..ae3f4e2b98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -482,9 +482,8 @@ jobs: options: --environment esp8266-arduino-tidy --grep USE_ESP8266 pio_cache_key: tidyesp8266 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF - options: --environment esp32-idf-tidy --grep USE_ESP_IDF - pio_cache_key: tidyesp32-idf + name: Run script/clang-tidy for ESP32 Arduino + options: --environment esp32-arduino-tidy --grep USE_ARDUINO - id: clang-tidy name: Run script/clang-tidy for ZEPHYR options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 @@ -505,14 +504,14 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio - if: github.ref != 'refs/heads/dev' + if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio @@ -523,13 +522,6 @@ jobs: echo "::add-matcher::.github/workflows/matchers/gcc.json" echo "::add-matcher::.github/workflows/matchers/clang-tidy.json" - - name: Run 'pio run --list-targets -e esp32-idf-tidy' - if: matrix.name == 'Run script/clang-tidy for ESP32 IDF' - run: | - . venv/bin/activate - mkdir -p .temp - pio run --list-targets -e esp32-idf-tidy - - name: Check if full clang-tidy scan needed id: check_full_scan run: | @@ -568,7 +560,7 @@ jobs: if: always() clang-tidy-nosplit: - name: Run script/clang-tidy for ESP32 Arduino + name: Run script/clang-tidy for ESP32 IDF runs-on: ubuntu-24.04 needs: - common @@ -589,20 +581,6 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: github.ref == 'refs/heads/dev' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - - name: Cache platformio - if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -631,10 +609,10 @@ jobs: . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" - script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy + script/clang-tidy --all-headers --fix --environment esp32-idf-tidy else echo "Running clang-tidy on changed files only" - script/clang-tidy --all-headers --fix --changed --environment esp32-arduino-tidy + script/clang-tidy --all-headers --fix --changed --environment esp32-idf-tidy fi env: # Also cache libdeps, store them in a ~/.platformio subfolder @@ -655,21 +633,18 @@ jobs: GH_TOKEN: ${{ github.token }} strategy: fail-fast: false - max-parallel: 2 + max-parallel: 3 matrix: include: - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 1/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 1 + name: Run script/clang-tidy for ESP32 IDF 1/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 1 - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 2/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 2 + name: Run script/clang-tidy for ESP32 IDF 2/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 2 - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 3/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 3 - - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 4/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 4 + name: Run script/clang-tidy for ESP32 IDF 3/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 3 steps: - name: Check out code from GitHub @@ -684,20 +659,6 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: github.ref == 'refs/heads/dev' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - - name: Cache platformio - if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 8e9a2c5298..502f83cd5d 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -2,6 +2,7 @@ #if defined(USE_ARDUINO) || defined(USE_ESP32) +#include #include #include #include @@ -113,7 +114,7 @@ void HeatpumpIRClimate::setup() { this->current_temperature = state; IRSenderESPHome esp_sender(this->transmitter_); - this->heatpump_ir_->send(esp_sender, uint8_t(lround(this->current_temperature))); + this->heatpump_ir_->send(esp_sender, uint8_t(std::lround(this->current_temperature))); // current temperature changed, publish state this->publish_state(); From 42cf421f5c41c7cb71f47ef8043de0ecbde831c1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:03:22 -0400 Subject: [PATCH 1558/2214] [usb_uart] Fix clang-tidy findings (#16835) --- esphome/components/usb_uart/ch34x.cpp | 58 ++++++++-------- esphome/components/usb_uart/cp210x.cpp | 10 +-- esphome/components/usb_uart/ft23xx.cpp | 88 +++++++++++++----------- esphome/components/usb_uart/usb_uart.cpp | 24 +++---- esphome/components/usb_uart/usb_uart.h | 10 +-- 5 files changed, 96 insertions(+), 94 deletions(-) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index d5428cc8d7..c5f904ead1 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -10,43 +10,43 @@ namespace esphome::usb_uart { using namespace bytebuffer; struct CH34xEntry { + const char *name; uint16_t pid; uint8_t byte_idx; // which status.data[] byte to inspect uint8_t mask; // bitmask applied before comparison uint8_t match; // 0xFF = wildcard (default/fallthrough for this PID) CH34xChipType chiptype; - const char *name; uint8_t num_ports; }; static const CH34xEntry CH34X_TABLE[] = { - {0x55D2, 1, 0xFF, 0x41, CHIP_CH342K, "CH342K", 2}, - {0x55D2, 1, 0xFF, 0xFF, CHIP_CH342F, "CH342F", 2}, - {0x55D3, 1, 0xFF, 0x02, CHIP_CH343J, "CH343J", 1}, - {0x55D3, 1, 0xFF, 0x01, CHIP_CH343K, "CH343K", 1}, - {0x55D3, 1, 0xFF, 0x18, CHIP_CH343G_AUTOBAUD, "CH343G_AUTOBAUD", 1}, - {0x55D3, 1, 0xFF, 0xFF, CHIP_CH343GP, "CH343GP", 1}, - {0x55D4, 1, 0xFF, 0x09, CHIP_CH9102X, "CH9102X", 1}, - {0x55D4, 1, 0xFF, 0xFF, CHIP_CH9102F, "CH9102F", 1}, - {0x55D5, 1, 0xFF, 0xC0, CHIP_CH344L, "CH344L", 4}, // CH344L vs CH344L_V2 resolved below - {0x55D5, 1, 0xFF, 0xFF, CHIP_CH344Q, "CH344Q", 4}, - {0x55D7, 1, 0xFF, 0xFF, CHIP_CH9103M, "CH9103M", 2}, - {0x55D8, 1, 0xFF, 0x0A, CHIP_CH9101RY, "CH9101RY", 1}, - {0x55D8, 1, 0xFF, 0xFF, CHIP_CH9101UH, "CH9101UH", 1}, - {0x55DB, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 1}, - {0x55DD, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 1}, - {0x55DA, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 2}, - {0x55DE, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 2}, - {0x55E7, 1, 0xFF, 0xFF, CHIP_CH339W, "CH339W", 1}, - {0x55DF, 1, 0xFF, 0xFF, CHIP_CH9104L, "CH9104L", 4}, - {0x55E9, 1, 0xFF, 0xFF, CHIP_CH9111L_M0, "CH9111L_M0", 1}, - {0x55EA, 1, 0xFF, 0xFF, CHIP_CH9111L_M1, "CH9111L_M1", 1}, - {0x55E8, 2, 0xFF, 0x48, CHIP_CH9114L, "CH9114L", 4}, - {0x55E8, 2, 0xFF, 0x49, CHIP_CH9114W, "CH9114W", 4}, - {0x55E8, 2, 0xFF, 0x4A, CHIP_CH9114F, "CH9114F", 4}, - {0x55EB, 4, 0x01, 0x01, CHIP_CH346C_M1, "CH346C_M1", 1}, - {0x55EB, 4, 0x01, 0xFF, CHIP_CH346C_M0, "CH346C_M0", 1}, - {0x55EC, 1, 0xFF, 0xFF, CHIP_CH346C_M2, "CH346C_M2", 2}, + {"CH342K", 0x55D2, 1, 0xFF, 0x41, CHIP_CH342K, 2}, + {"CH342F", 0x55D2, 1, 0xFF, 0xFF, CHIP_CH342F, 2}, + {"CH343J", 0x55D3, 1, 0xFF, 0x02, CHIP_CH343J, 1}, + {"CH343K", 0x55D3, 1, 0xFF, 0x01, CHIP_CH343K, 1}, + {"CH343G_AUTOBAUD", 0x55D3, 1, 0xFF, 0x18, CHIP_CH343G_AUTOBAUD, 1}, + {"CH343GP", 0x55D3, 1, 0xFF, 0xFF, CHIP_CH343GP, 1}, + {"CH9102X", 0x55D4, 1, 0xFF, 0x09, CHIP_CH9102X, 1}, + {"CH9102F", 0x55D4, 1, 0xFF, 0xFF, CHIP_CH9102F, 1}, + {"CH344L", 0x55D5, 1, 0xFF, 0xC0, CHIP_CH344L, 4}, // CH344L vs CH344L_V2 resolved below + {"CH344Q", 0x55D5, 1, 0xFF, 0xFF, CHIP_CH344Q, 4}, + {"CH9103M", 0x55D7, 1, 0xFF, 0xFF, CHIP_CH9103M, 2}, + {"CH9101RY", 0x55D8, 1, 0xFF, 0x0A, CHIP_CH9101RY, 1}, + {"CH9101UH", 0x55D8, 1, 0xFF, 0xFF, CHIP_CH9101UH, 1}, + {"CH347TF", 0x55DB, 1, 0xFF, 0xFF, CHIP_CH347TF, 1}, + {"CH347TF", 0x55DD, 1, 0xFF, 0xFF, CHIP_CH347TF, 1}, + {"CH347TF", 0x55DA, 1, 0xFF, 0xFF, CHIP_CH347TF, 2}, + {"CH347TF", 0x55DE, 1, 0xFF, 0xFF, CHIP_CH347TF, 2}, + {"CH339W", 0x55E7, 1, 0xFF, 0xFF, CHIP_CH339W, 1}, + {"CH9104L", 0x55DF, 1, 0xFF, 0xFF, CHIP_CH9104L, 4}, + {"CH9111L_M0", 0x55E9, 1, 0xFF, 0xFF, CHIP_CH9111L_M0, 1}, + {"CH9111L_M1", 0x55EA, 1, 0xFF, 0xFF, CHIP_CH9111L_M1, 1}, + {"CH9114L", 0x55E8, 2, 0xFF, 0x48, CHIP_CH9114L, 4}, + {"CH9114W", 0x55E8, 2, 0xFF, 0x49, CHIP_CH9114W, 4}, + {"CH9114F", 0x55E8, 2, 0xFF, 0x4A, CHIP_CH9114F, 4}, + {"CH346C_M1", 0x55EB, 4, 0x01, 0x01, CHIP_CH346C_M1, 1}, + {"CH346C_M0", 0x55EB, 4, 0x01, 0xFF, CHIP_CH346C_M0, 1}, + {"CH346C_M2", 0x55EC, 1, 0xFF, 0xFF, CHIP_CH346C_M2, 2}, }; void USBUartTypeCH34X::enable_channels() { @@ -157,7 +157,7 @@ void USBUartTypeCH34X::apply_line_settings_() { this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); } - this->start_channels(); + this->start_channels_(); } std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 261f40c0db..67fd03a813 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -65,7 +65,7 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev } for (uint8_t i = 0; i != config_desc->bNumInterfaces; i++) { - auto data_desc = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset); + const auto *data_desc = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset); if (!data_desc) { ESP_LOGE(TAG, "data_desc: usb_parse_interface_descriptor failed"); break; @@ -76,13 +76,13 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev continue; } ep_offset = conf_offset; - auto out_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 0, config_desc->wTotalLength, &ep_offset); + const auto *out_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 0, config_desc->wTotalLength, &ep_offset); if (!out_ep) { ESP_LOGE(TAG, "out_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; } ep_offset = conf_offset; - auto in_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 1, config_desc->wTotalLength, &ep_offset); + const auto *in_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 1, config_desc->wTotalLength, &ep_offset); if (!in_ep) { ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; @@ -98,7 +98,7 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev void USBUartTypeCP210X::enable_channels() { // enable the channels - for (auto channel : this->channels_) { + for (auto *channel : this->channels_) { if (!channel->initialised_.load()) continue; usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { @@ -118,7 +118,7 @@ void USBUartTypeCP210X::enable_channels() { this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, callback, baud.get_data()); } - this->start_channels(); + this->start_channels_(); } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index d57d7fe3bd..c2c8993805 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -12,7 +12,7 @@ using namespace bytebuffer; // FTDI chip family identifiers. These map to USB device bcdDevice values // and determine how baudrate divisors and clock sources are calculated. -enum ftdi_chip_type { +enum FtdiChipType { TYPE_AM = 0, TYPE_BM = 1, TYPE_2232C = 2, @@ -23,15 +23,15 @@ enum ftdi_chip_type { TYPE_230X = 7, }; -static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { - static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; - static const char am_adjust_up[8] = {0, 0, 0, 1, 0, 3, 2, 1}; - static const char am_adjust_dn[8] = {0, 0, 0, 1, 0, 1, 2, 3}; +static int ftdi_to_clkbits_am(int baudrate, uint32_t *encoded_divisor) { + static const char FRAC_CODE[8] = {0, 3, 2, 4, 1, 5, 6, 7}; + static const char AM_ADJUST_UP[8] = {0, 0, 0, 1, 0, 3, 2, 1}; + static const char AM_ADJUST_DN[8] = {0, 0, 0, 1, 0, 1, 2, 3}; int divisor, best_divisor, best_baud, best_baud_diff; int i; divisor = 24000000 / baudrate; - divisor -= am_adjust_dn[divisor & 7]; + divisor -= AM_ADJUST_DN[divisor & 7]; best_divisor = 0; best_baud = 0; @@ -46,7 +46,7 @@ static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { } else if (divisor < 16) { try_divisor = 16; } else { - try_divisor += am_adjust_up[try_divisor & 7]; + try_divisor += AM_ADJUST_UP[try_divisor & 7]; if (try_divisor > 0x1FFF8) { // Round down to maximum supported divisor value (for AM) try_divisor = 0x1FFF8; @@ -67,7 +67,7 @@ static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { } } } - *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 7] << 14); + *encoded_divisor = (best_divisor >> 3) | (FRAC_CODE[best_divisor & 7] << 14); if (*encoded_divisor == 1) { *encoded_divisor = 0; // 3000000 baud } else if (*encoded_divisor == 0x4001) { @@ -76,8 +76,8 @@ static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { return best_baud; } -static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, unsigned long *encoded_divisor) { - static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; +static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, uint32_t *encoded_divisor) { + static const char FRAC_CODE[8] = {0, 3, 2, 4, 1, 5, 6, 7}; int best_baud = 0; int divisor, best_divisor; if (baudrate >= clk / clk_div) { @@ -91,26 +91,28 @@ static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, unsigned best_baud = clk / (2 * clk_div); } else { divisor = clk * 16 / clk_div / baudrate; - if (divisor & 1) + if (divisor & 1) { best_divisor = divisor / 2 + 1; - else + } else { best_divisor = divisor / 2; + } if (best_divisor > 0x20000) best_divisor = 0x1ffff; best_baud = clk * 16 / clk_div / best_divisor; - if (best_baud & 1) + if (best_baud & 1) { best_baud = best_baud / 2 + 1; - else + } else { best_baud = best_baud / 2; - *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 0x7] << 14); + } + *encoded_divisor = (best_divisor >> 3) | (FRAC_CODE[best_divisor & 0x7] << 14); } return best_baud; } -static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, unsigned short *value, - unsigned short *index) { +static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, uint16_t *value, + uint16_t *index) { int best_baud; - unsigned long encoded_divisor; + uint32_t encoded_divisor; if (baudrate <= 0) { return -1; @@ -122,21 +124,23 @@ static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channe if (baudrate * 10 > H_CLK / 0x3fff) { best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); encoded_divisor |= 0x20000; /* switch on CLK/10*/ - } else + } else { best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + } } else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) { best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } else { - best_baud = ftdi_to_clkbits_AM(baudrate, &encoded_divisor); + best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); } - *value = (unsigned short) (encoded_divisor & 0xFFFF); + *value = (uint16_t) (encoded_divisor & 0xFFFF); if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) { - *index = (unsigned short) (encoded_divisor >> 8); + *index = (uint16_t) (encoded_divisor >> 8); *index &= 0xFF00; *index |= (channel_index + 1); - } else - *index = (unsigned short) (encoded_divisor >> 16); + } else { + *index = (uint16_t) (encoded_divisor >> 16); + } return best_baud; } @@ -248,23 +252,23 @@ std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev } ESP_LOGD(TAG, "Found FTDI %s based device", type_string.c_str()); - for (uint8_t intf_idx = 0; intf_idx < this->channels_.size(); intf_idx++) { - if (auto eps = get_uart(config_desc, intf_idx)) { + for (size_t intf_idx = 0; intf_idx < this->channels_.size(); intf_idx++) { + if (auto eps = get_uart(config_desc, static_cast(intf_idx))) { cdc_devs.push_back(*eps); - ESP_LOGD(TAG, "Found CDC interface at USB interface index %d", intf_idx); + ESP_LOGD(TAG, "Found CDC interface at USB interface index %zu", intf_idx); } } return cdc_devs; } -int USBUartTypeFT23XX::reset(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::reset_(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); } else { ESP_LOGD(TAG, "Reset successful, setting baudrate..."); - this->set_baudrate(channel); + this->set_baudrate_(channel); } }; bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, @@ -277,20 +281,20 @@ int USBUartTypeFT23XX::reset(USBUartChannel *channel) { return 0; } -int USBUartTypeFT23XX::set_baudrate(USBUartChannel *channel, uint32_t baudrate) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); } else { ESP_LOGD(TAG, "Baudrate %d set, setting line properties...", channel->baud_rate_); - this->set_line_properties(channel); + this->set_line_properties_(channel); } }; if (baudrate == 0) { baudrate = channel->baud_rate_; } - unsigned short value, ftdi_index; + uint16_t value, ftdi_index; ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); ESP_LOGD(TAG, "Baudrate: %d, value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); @@ -303,18 +307,18 @@ int USBUartTypeFT23XX::set_baudrate(USBUartChannel *channel, uint32_t baudrate) return 0; } -int USBUartTypeFT23XX::set_line_properties(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::set_line_properties_(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); return; } ESP_LOGD(TAG, "Line properties set, setting modem control..."); - this->set_dtr_rts(channel); + this->set_dtr_rts_(channel); }; - unsigned short value = channel->data_bits_; + uint16_t value = channel->data_bits_; switch (channel->parity_) { case UART_CONFIG_PARITY_NONE: @@ -358,8 +362,8 @@ int USBUartTypeFT23XX::set_line_properties(USBUartChannel *channel) { return 0; } -int USBUartTypeFT23XX::set_dtr_rts(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); @@ -372,7 +376,7 @@ int USBUartTypeFT23XX::set_dtr_rts(USBUartChannel *channel) { if (next_index < this->channels_.size()) { USBUartChannel *next_channel = this->channels_[next_index]; ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_); - this->reset(next_channel); + this->reset_(next_channel); return; } else { ESP_LOGI(TAG, "All channels configured"); @@ -439,7 +443,7 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { void USBUartTypeFT23XX::enable_channels() { if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) { - this->reset(this->channels_[0]); + this->reset_(this->channels_[0]); } for (auto *channel : this->channels_) { diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index e3bf5e40bc..3fdf35a472 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -141,13 +141,12 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { } #ifdef USE_UART_DEBUGGER if (this->debug_) { - constexpr size_t BATCH = 16; - char buf[4 + format_hex_pretty_size(BATCH)]; // ">>> " + "XX,XX,...,XX\0" - for (size_t off = 0; off < len; off += BATCH) { - size_t n = std::min(len - off, BATCH); - memcpy(buf, ">>> ", 4); - format_hex_pretty_to(buf + 4, sizeof(buf) - 4, data + off, n, ','); - ESP_LOGD(TAG, "%s%s", this->debug_prefix_.c_str(), buf); + constexpr size_t batch = 16; + char buf[format_hex_pretty_size(batch)]; // "XX,XX,...,XX\0" + for (size_t off = 0; off < len; off += batch) { + size_t n = std::min(len - off, batch); + format_hex_pretty_to(buf, data + off, n, ','); + ESP_LOGD(TAG, "%s>>> %s", this->debug_prefix_.c_str(), buf); } } #endif @@ -222,10 +221,9 @@ void USBUartComponent::loop() { #ifdef USE_UART_DEBUGGER if (channel->debug_) { - char buf[4 + format_hex_pretty_size(usb_host::USB_MAX_PACKET_SIZE)]; // "<<< " + hex - memcpy(buf, "<<< ", 4); - format_hex_pretty_to(buf + 4, sizeof(buf) - 4, chunk->data, chunk->length, ','); - ESP_LOGD(TAG, "%s%s", channel->debug_prefix_.c_str(), buf); + char buf[format_hex_pretty_size(usb_host::USB_MAX_PACKET_SIZE)]; // "XX,XX,...,XX\0" + format_hex_pretty_to(buf, chunk->data, chunk->length, ','); + ESP_LOGD(TAG, "%s<<< %s", channel->debug_prefix_.c_str(), buf); } #endif @@ -528,10 +526,10 @@ void USBUartTypeCdcAcm::enable_channels() { } }); } - this->start_channels(); + this->start_channels_(); } -void USBUartTypeCdcAcm::start_channels() { +void USBUartTypeCdcAcm::start_channels_() { for (auto *channel : this->channels_) { if (!channel->initialised_.load()) continue; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 7a19aa8e4b..41dc2c546d 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -214,7 +214,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { /// Resets per-channel transfer flags and posts the first bulk IN transfer. /// Called by enable_channels() and by vendor-specific subclass overrides that /// handle their own line-coding setup before starting data flow. - void start_channels(); + void start_channels_(); }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { @@ -251,10 +251,10 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; void enable_channels() override; - int reset(USBUartChannel *channel); - int set_baudrate(USBUartChannel *channel, uint32_t baudrate = 0); - int set_line_properties(USBUartChannel *channel); - int set_dtr_rts(USBUartChannel *channel); + int reset_(USBUartChannel *channel); + int set_baudrate_(USBUartChannel *channel, uint32_t baudrate = 0); + int set_line_properties_(USBUartChannel *channel); + int set_dtr_rts_(USBUartChannel *channel); uint8_t chip_type_{255}; }; From 2b581ecd3c992cc35e8e932e6ef3e16d190394c3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:22:20 -0400 Subject: [PATCH 1559/2214] [esp32] Bump platform to 55.03.39, Arduino to 3.3.9 (#16803) --- .clang-tidy.hash | 2 +- esphome/components/esp32/__init__.py | 14 ++++++++------ platformio.ini | 6 +++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 1dc63cc7bb..cab077385d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0119a5940f061725291b5dfbafbd0ef843dbe2b40489f38d1d456ae81ee3dbe7 +6f2f1745246a413712801462c8a02b92aae003d75b6cf45ca1a3cb2996b41f57 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 160c06534e..6ecb41bff8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -715,14 +715,15 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 8), - "latest": cv.Version(3, 3, 8), - "dev": cv.Version(3, 3, 8), + "recommended": cv.Version(3, 3, 9), + "latest": cv.Version(3, 3, 9), + "dev": cv.Version(3, 3, 9), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 9): cv.Version(55, 3, 39), cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), @@ -744,6 +745,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 9): cv.Version(5, 5, 4), cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), cv.Version(3, 3, 6): cv.Version(5, 5, 2), @@ -776,7 +778,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"), + cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), cv.Version(5, 5, 2): cv.Version(55, 3, 37), @@ -796,8 +798,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 38, "1"), - "latest": cv.Version(55, 3, 38, "1"), + "recommended": cv.Version(55, 3, 39), + "latest": cv.Version(55, 3, 39), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } diff --git a/platformio.ini b/platformio.ini index 4ac60d8099..07e9b8aad3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -132,9 +132,9 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.8/esp32-core-3.3.8.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component @@ -167,7 +167,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz From aa11ddb333184fb450853985df377fe405a5d72d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:22:55 -0400 Subject: [PATCH 1560/2214] [zigbee][openthread][esp32_hosted] Fix clang-tidy findings (#16838) --- .../update/esp32_hosted_update.cpp | 5 +++- esphome/components/openthread/openthread.cpp | 5 ++-- esphome/components/openthread/openthread.h | 2 +- .../components/openthread/openthread_esp.cpp | 5 +++- esphome/components/zigbee/zigbee_esp32.cpp | 26 +++++++++---------- esphome/components/zigbee/zigbee_esp32.h | 5 +--- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 70fa41b312..351b0869b0 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -56,7 +56,10 @@ static bool parse_version(const std::string &version_str, int &major, int &minor major = minor = patch = 0; const char *ptr = version_str.c_str(); - if (!parse_int(ptr, major) || *ptr++ != '.' || !parse_int(ptr, minor)) + if (!parse_int(ptr, major) || *ptr != '.') + return false; + ++ptr; + if (!parse_int(ptr, minor)) return false; if (*ptr == '.') parse_int(++ptr, patch); diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 8557427096..bf14514636 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -9,6 +9,7 @@ #include #include +#include #include "esphome/core/application.h" #include "esphome/core/helpers.h" @@ -43,7 +44,7 @@ void OpenThreadComponent::dump_config() { } } -void OpenThreadComponent::on_state_changed_(otChangedFlags flags, void *context) { +void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) { if (flags & OT_CHANGED_THREAD_ROLE) { auto *self = static_cast(context); // This runs on the OpenThread task thread with the OT lock held, @@ -241,7 +242,7 @@ bool OpenThreadComponent::teardown() { } void OpenThreadComponent::on_factory_reset(std::function callback) { - factory_reset_external_callback_ = callback; + this->factory_reset_external_callback_ = std::move(callback); ESP_LOGD(TAG, "Start Removal SRP Host and Services"); otError error; InstanceLock lock = InstanceLock::acquire(); diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index b42fdd2d30..5898492a50 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -46,7 +46,7 @@ class OpenThreadComponent : public Component { protected: std::optional get_omr_address_(InstanceLock &lock); - static void on_state_changed_(otChangedFlags flags, void *context); + static void on_state_changed(otChangedFlags flags, void *context); otInstance *get_openthread_instance_(); int openthread_stop_(); std::function factory_reset_external_callback_; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 787f2f5de8..cf1288d90c 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -179,7 +179,10 @@ void OpenThreadComponent::ot_main() { ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); // Register state change callback to update connected_ reactively instead of polling - otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed_, this); + otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); + if (ot_err != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to register state change callback: %d", ot_err); + } esp_openthread_launch_mainloop(); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index ade9e16572..1809f181be 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -42,7 +42,7 @@ static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) { } } -void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { +extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { static uint8_t steering_retry_count = 0; uint32_t *p_sg_p = signal_struct->p_app_signal; esp_err_t err_status = signal_struct->esp_err_status; @@ -183,21 +183,21 @@ esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_d esp_zb_cluster_list_t *esp_zb_cluster_list) { esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id, .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, - .app_device_id = device_id, + .app_device_id = static_cast(device_id), .app_device_version = 0}; return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config); } -static void esp_zb_task_(void *pvParameters) { +static void esp_zb_task(void *pv_parameters) { if (esp_zb_start(false) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); vTaskDelete(NULL); } if (global_zigbee->is_battery_powered()) { ESP_LOGD(TAG, "Battery powered!"); - esp_zb_set_node_descriptor_power_source(0); + esp_zb_set_node_descriptor_power_source(false); } else { - esp_zb_set_node_descriptor_power_source(1); + esp_zb_set_node_descriptor_power_source(true); } esp_zb_stack_main_loop(); } @@ -218,20 +218,20 @@ void ZigbeeComponent::setup() { return; } - esp_zb_zed_cfg_t zb_zed_cfg = { - .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, - }; - esp_zb_zczr_cfg_t zb_zczr_cfg = { - .max_children = MAX_CHILDREN, - }; esp_zb_cfg_t zb_nwk_cfg = { .esp_zb_role = this->device_role_, .install_code_policy = false, }; #ifdef ZB_ROUTER_ROLE + esp_zb_zczr_cfg_t zb_zczr_cfg = { + .max_children = MAX_CHILDREN, + }; zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg; #else + esp_zb_zed_cfg_t zb_zed_cfg = { + .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, + .keep_alive = ED_KEEP_ALIVE, + }; zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg; #endif esp_zb_init(&zb_nwk_cfg); @@ -290,7 +290,7 @@ void ZigbeeComponent::setup() { } } } - xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL); + xTaskCreate(esp_zb_task, "Zigbee_main", 4096, NULL, 24, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 03d3286ab8..34b2b827b6 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -13,7 +13,6 @@ #include "ha/esp_zigbee_ha_standard.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/defines.h" #include "zigbee_helpers_esp32.h" #ifdef USE_BINARY_SENSOR @@ -99,8 +98,6 @@ class ZigbeeComponent : public Component { CallbackManager join_cb_{}; }; -extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct); - template void ZigbeeComponent::add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value) { @@ -129,7 +126,7 @@ template void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p) { esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}]; - esp_err_t ret = esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); + esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; From 2ab4399ae51a02be3b0931f9cb4208593ad9931c Mon Sep 17 00:00:00 2001 From: Ross Tyler Date: Fri, 5 Jun 2026 11:31:17 -0700 Subject: [PATCH 1561/2214] [qmp6988] fix false report of software reset error (#16843) --- esphome/components/qmp6988/qmp6988.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 8c8a04c5b7..293d8aa648 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -216,10 +216,7 @@ int32_t QMP6988Component::get_compensated_pressure_(qmp6988_ik_data_t *ik, int32 } void QMP6988Component::software_reset_() { - uint8_t ret = 0; - - ret = this->write_byte(QMP6988_RESET_REG, 0xe6); - if (ret != i2c::ERROR_OK) { + if (!this->write_byte(QMP6988_RESET_REG, 0xe6)) { ESP_LOGE(TAG, "Software Reset (0xe6) failed"); } delay(10); From 4cb6f2c04609752711bc72d006146f43ace6ca2d Mon Sep 17 00:00:00 2001 From: Ross Tyler Date: Fri, 5 Jun 2026 11:35:33 -0700 Subject: [PATCH 1562/2214] [qmp6988] fix publishing bogus zero values on i2c error (#16840) --- esphome/components/qmp6988/qmp6988.cpp | 15 ++++++++++----- esphome/components/qmp6988/qmp6988.h | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 293d8aa648..bb47e7b0f5 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -280,20 +280,21 @@ void QMP6988Component::calculate_altitude_(float pressure, float temp) { this->qmp6988_data_.altitude = altitude; } -void QMP6988Component::calculate_pressure_() { +bool QMP6988Component::calculate_pressure_() { uint8_t err = 0; uint32_t p_read, t_read; int32_t p_raw, t_raw; uint8_t a_data_uint8_tr[6] = {0}; int32_t t_int, p_int; - this->qmp6988_data_.temperature = 0; - this->qmp6988_data_.pressure = 0; err = this->read_register(QMP6988_PRESSURE_MSB_REG, a_data_uint8_tr, 6); if (err != i2c::ERROR_OK) { ESP_LOGE(TAG, "Error reading raw pressure/temp values"); - return; + this->status_set_warning(); + return false; } + this->status_clear_warning(); + p_read = encode_uint24(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[2]); p_raw = (int32_t) (p_read - SUBTRACTOR); @@ -305,6 +306,7 @@ void QMP6988Component::calculate_pressure_() { this->qmp6988_data_.temperature = (float) t_int / 256.0f; this->qmp6988_data_.pressure = (float) p_int / 16.0f; + return true; } void QMP6988Component::setup() { @@ -336,7 +338,10 @@ void QMP6988Component::dump_config() { } void QMP6988Component::update() { - this->calculate_pressure_(); + if (!this->calculate_pressure_()) { + return; + } + float pressurehectopascals = this->qmp6988_data_.pressure / 100; float temperature = this->qmp6988_data_.temperature; diff --git a/esphome/components/qmp6988/qmp6988.h b/esphome/components/qmp6988/qmp6988.h index 26f858b5d2..41759478b8 100644 --- a/esphome/components/qmp6988/qmp6988.h +++ b/esphome/components/qmp6988/qmp6988.h @@ -98,7 +98,7 @@ class QMP6988Component : public PollingComponent, public i2c::I2CDevice { void write_oversampling_temperature_(QMP6988Oversampling oversampling_t); void write_oversampling_pressure_(QMP6988Oversampling oversampling_p); void write_filter_(QMP6988IIRFilter filter); - void calculate_pressure_(); + bool calculate_pressure_(); void calculate_altitude_(float pressure, float temp); int32_t get_compensated_pressure_(qmp6988_ik_data_t *ik, int32_t dp, int16_t tx); From 77f644f57649bf2bc3bf564fe7aff3defb67b874 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:42:51 -0400 Subject: [PATCH 1563/2214] [ci] Share a cached native ESP-IDF install across clang-tidy and build jobs (#16841) --- .github/actions/cache-esp-idf/action.yml | 46 +++++++++++++++++++ .github/workflows/ci.yml | 56 ++++++++++++++++-------- 2 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 .github/actions/cache-esp-idf/action.yml diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml new file mode 100644 index 0000000000..7a17c222a3 --- /dev/null +++ b/.github/actions/cache-esp-idf/action.yml @@ -0,0 +1,46 @@ +name: Cache ESP-IDF +description: > + Resolve the pinned ESP-IDF version and cache the native ESP-IDF install + (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF + natively (clang-tidy for IDF/Arduino and the native-IDF component build) + shares one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS + defaults to "all", so all toolchains are present regardless of the chip). + Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the + Python venv already restored. +inputs: + framework: + description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".' + default: espidf +runs: + using: composite + steps: + - name: Resolve ESP-IDF version for cache key + # The native-IDF version is pinned in code, not in any file that feeds the + # other cache keys, so resolve it explicitly. Keying on it means the cache + # invalidates on a version bump (actions/cache never overwrites a key). + id: version + shell: bash + run: | + . venv/bin/activate + if [ "${{ inputs.framework }}" = "arduino" ]; then + version=$(python -c 'from esphome.components.esp32 import ARDUINO_FRAMEWORK_VERSION_LOOKUP as A, ARDUINO_IDF_VERSION_LOOKUP as L; print(L[A["recommended"]])') + else + version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])') + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + # Mirror the adjacent PlatformIO cache: only dev-branch runs write the + # shared cache (so it lives in the default-branch scope readable by all + # PRs), and PRs are restore-only -- they never push multi-GB artifacts into + # their own scope / the repo quota (e.g. on a version-bump PR). + - name: Cache ESP-IDF install (write on dev) + if: github.ref == 'refs/heads/dev' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.esphome-idf + key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} + - name: Cache ESP-IDF install (restore-only off dev) + if: github.ref != 'refs/heads/dev' + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.esphome-idf + key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae3f4e2b98..40267240d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -472,6 +472,8 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy == 'true' env: GH_TOKEN: ${{ github.token }} + # esp32-arduino-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: 2 @@ -484,6 +486,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 Arduino options: --environment esp32-arduino-tidy --grep USE_ARDUINO + cache_idf: true - id: clang-tidy name: Run script/clang-tidy for ZEPHYR options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 @@ -517,6 +520,13 @@ jobs: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + - name: Cache ESP-IDF install + # Shared with the IDF tidy + native-IDF build jobs (same install). + if: matrix.cache_idf + uses: ./.github/actions/cache-esp-idf + with: + framework: arduino + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -568,6 +578,8 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy-mode == 'nosplit' env: GH_TOKEN: ${{ github.token }} + # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -581,6 +593,10 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install + # Shared with the Arduino tidy + native-IDF build jobs (same install). + uses: ./.github/actions/cache-esp-idf + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -631,6 +647,8 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy-mode == 'split' env: GH_TOKEN: ${{ github.token }} + # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: 3 @@ -659,6 +677,10 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install + # Shared with the Arduino tidy + native-IDF build jobs (same install). + uses: ./.github/actions/cache-esp-idf + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -785,7 +807,7 @@ jobs: fi echo "" - # Show disk space before validation (after bind mounts setup) + # Show disk space before validation echo "Disk space before config validation:" df -h echo "" @@ -861,33 +883,20 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache ESPHome - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} - - - name: Run native ESP-IDF compile test + - name: Prepare build storage on /mnt + # Bind-mount the larger /mnt disk over the IDF install + build dirs BEFORE + # restoring the cache, so the ~4.5GB restore lands on the roomier volume + # instead of being shadowed by a mount set up later in the run step. run: | - . venv/bin/activate - - # Check if /mnt has more free space than / before bind mounting - # Extract available space in KB for comparison root_avail=$(df -k / | awk 'NR==2 {print $4}') mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}') - echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB" - - # Only use /mnt if it has more space than / if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then echo "Using /mnt for build files (more space available)" - # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there) sudo mkdir -p /mnt/esphome-idf sudo chown $USER:$USER /mnt/esphome-idf mkdir -p ~/.esphome-idf sudo mount --bind /mnt/esphome-idf ~/.esphome-idf - - # Bind mount test build directory to /mnt sudo mkdir -p /mnt/test_build_components_build sudo chown $USER:$USER /mnt/test_build_components_build mkdir -p tests/test_build_components/build @@ -896,10 +905,19 @@ jobs: echo "Using / for build files (more space available than /mnt or /mnt unavailable)" fi + - name: Cache ESP-IDF install + # Shared with the IDF/Arduino clang-tidy jobs (same install); restores + # into the /mnt bind-mount prepared above when present. + uses: ./.github/actions/cache-esp-idf + + - name: Run native ESP-IDF compile test + run: | + . venv/bin/activate + echo "Testing components: $TEST_COMPONENTS" echo "" - # Show disk space before validation (after bind mounts setup) + # Show disk space before validation echo "Disk space before config validation:" df -h echo "" From b63e327ae35186c35771d572b3d95bf6f2e19b98 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 5 Jun 2026 17:22:03 -0400 Subject: [PATCH 1564/2214] [audio] Deprecate unused scale_audio_samples helper (#16831) --- esphome/components/audio/audio.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h index 62c57b18cf..36780a3055 100644 --- a/esphome/components/audio/audio.h +++ b/esphome/components/audio/audio.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" // for ESPDEPRECATED #include #include @@ -143,6 +144,8 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url); /// @param output_buffer Buffer to store the scaled samples /// @param scale_factor Q15 fixed point scaling factor /// @param samples_to_scale Number of samples to scale +// Remove before 2026.12.0 +ESPDEPRECATED("Use esp_audio_libs::gain::apply() (from ) instead. Removed in 2026.12.0.", "2026.6.0") void scale_audio_samples(const int16_t *audio_samples, int16_t *output_buffer, int16_t scale_factor, size_t samples_to_scale); From 913b9f5ca442c44e9c7589883a715120dc5d70be Mon Sep 17 00:00:00 2001 From: i-am-no-magic Date: Fri, 5 Jun 2026 23:37:40 +0200 Subject: [PATCH 1565/2214] [tuya] Fixed hysteresis bug for Tuya climate (#16832) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/tuya/climate/tuya_climate.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/tuya/climate/tuya_climate.cpp b/esphome/components/tuya/climate/tuya_climate.cpp index 7dbf33878a..111d090c3e 100644 --- a/esphome/components/tuya/climate/tuya_climate.cpp +++ b/esphome/components/tuya/climate/tuya_climate.cpp @@ -513,14 +513,14 @@ void TuyaClimate::compute_state_() { } else { // Fallback to active state calc based on temp and hysteresis const float temp_diff = this->target_temperature - this->current_temperature; - if (std::abs(temp_diff) > this->hysteresis_) { - if (this->supports_heat_ && temp_diff > 0) { - target_action = climate::CLIMATE_ACTION_HEATING; - this->mode = climate::CLIMATE_MODE_HEAT; - } else if (this->supports_cool_ && temp_diff < 0) { - target_action = climate::CLIMATE_ACTION_COOLING; - this->mode = climate::CLIMATE_MODE_COOL; - } + if ((this->supports_heat_ && temp_diff >= this->hysteresis_) || + (this->action == climate::CLIMATE_ACTION_HEATING && temp_diff > 0)) { + target_action = climate::CLIMATE_ACTION_HEATING; + this->mode = climate::CLIMATE_MODE_HEAT; + } else if ((this->supports_cool_ && temp_diff <= -this->hysteresis_) || + (this->action == climate::CLIMATE_ACTION_COOLING && temp_diff < 0)) { + target_action = climate::CLIMATE_ACTION_COOLING; + this->mode = climate::CLIMATE_MODE_COOL; } } From 93334d4e606dd4c710c9ccca411c966825baa879 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:44:31 +1000 Subject: [PATCH 1566/2214] [scripts] Fix build_language_schema (#16816) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- script/build_language_schema.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 6e4000e06e..025186299d 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -924,9 +924,14 @@ def convert(schema, config_var, path): config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) elif schema_type == "maybe": - config_var[S_TYPE] = S_SCHEMA + # maybe_simple_value: either a scalar shorthand (mapped to the key in + # data[1]) or the full wrapped schema. The wrapped schema is usually a + # plain Schema (converts to a "schema" config var), but may be something + # else, e.g. a typed_schema (converts to a "typed" config var with + # "types" and no top-level "schema" key). Merge whatever it produced + # rather than assuming a "schema" key is present. config_var["maybe"] = data[1] - config_var["schema"] = convert_config(data[0], path + "/maybe")["schema"] + config_var.update(convert_config(data[0], path + "/maybe")) # esphome/on_boot elif schema_type == "automation": extra_schema = None From 85fd83288da8ce5985f6b960c93d97682f8b7c91 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:29:33 -0400 Subject: [PATCH 1567/2214] [esp32_camera] Bump esp32-camera to 2.1.7 (#16846) --- .clang-tidy.hash | 2 +- esphome/components/camera_encoder/__init__.py | 9 ++------- esphome/components/esp32/__init__.py | 5 ++++- esphome/components/esp32_camera/__init__.py | 10 ++-------- esphome/components/zigbee/zigbee_esp32.py | 7 +++---- esphome/idf_component.yml | 2 +- 6 files changed, 13 insertions(+), 22 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index cab077385d..0782b065f3 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -6f2f1745246a413712801462c8a02b92aae003d75b6cf45ca1a3cb2996b41f57 +0b8325f52fca9224efb80dacca51ccbc8b3499bde7bb4aaa6f28a848c2e0a6a8 diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index 7d4cdc881e..344248fcf3 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -1,8 +1,5 @@ import esphome.codegen as cg -from esphome.components.esp32 import ( - add_idf_component, - require_libc_picolibc_newlib_compat, -) +from esphome.components.esp32 import add_idf_component import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE from esphome.types import ConfigType @@ -53,9 +50,7 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") - # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream - require_libc_picolibc_newlib_compat() + add_idf_component(name="espressif/esp32-camera", ref="2.1.7") cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 6ecb41bff8..7e7b127814 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1375,8 +1375,11 @@ def require_libc_picolibc_newlib_compat() -> None: """Keep CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY enabled on IDF 6.0+. Call this from components that link against precompiled Newlib binaries - referencing types/symbols the shim provides (e.g. esp32-camera). + referencing types/symbols the shim provides (e.g. zigbee). No-op on + IDF < 6.0.0. """ + if idf_version() < cv.Version(6, 0, 0): + return CORE.data[KEY_ESP32][KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED] = True diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 763a1f3405..c3b35a8279 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -3,11 +3,7 @@ import logging from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c -from esphome.components.esp32 import ( - add_idf_component, - add_idf_sdkconfig_option, - require_libc_picolibc_newlib_compat, -) +from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.components.psram import DOMAIN as psram_domain import esphome.config_validation as cv from esphome.const import ( @@ -403,11 +399,9 @@ async def to_code(config): if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG": cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION") - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + add_idf_component(name="espressif/esp32-camera", ref="2.1.7") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) - # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream - require_libc_picolibc_newlib_compat() for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index a0fadbce8b..086cdcc267 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -9,7 +9,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, add_partition, - idf_version, + require_libc_picolibc_newlib_compat, require_vfs_select, ) import esphome.config_validation as cv @@ -240,9 +240,8 @@ async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: # dynamic log level control to be enabled add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) # The pre-built Zigbee library is compiled against newlib which requires newlib - # reentrancy to be enabled with picolibc compatibility. - if idf_version() >= cv.Version(6, 0, 0): - add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", True) + # reentrancy to be enabled with picolibc compatibility (IDF 6.0+ only). + require_libc_picolibc_newlib_compat() async def attributes_to_code( diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 9c87a7e5cf..3a5b050072 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,7 +20,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.1.5 + version: 2.1.7 espressif/mdns: version: 1.11.0 espressif/esp_wifi_remote: From f18cf954bae11aa10f1ef574ab4ec5a3313a0c87 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:30:26 -0400 Subject: [PATCH 1568/2214] [improv_serial] Fix build on ESP32-C5/P4 and simplify variant guards (#16833) --- .../components/improv_serial/improv_serial_component.cpp | 9 +++------ .../components/improv_serial/improv_serial_component.h | 3 +-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 18d0b44701..206df2c844 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -68,11 +68,9 @@ optional ImprovSerialComponent::read_byte_() { switch (logger::global_logger->get_uart()) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32) case logger::UART_SELECTION_UART2: -#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32C6 && !USE_ESP32_VARIANT_ESP32C61 && - // !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 +#endif if (this->uart_num_ >= 0) { size_t available; uart_get_buffered_data_len(this->uart_num_, &available); @@ -136,8 +134,7 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) switch (logger::global_logger->get_uart()) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32) case logger::UART_SELECTION_UART2: #endif uart_write_bytes(this->uart_num_, this->tx_header_, header_tx_len); diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 2f1d0136a4..c58c42f0d8 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -11,8 +11,7 @@ #ifdef USE_ESP32 #include -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32S3) +#ifdef USE_LOGGER_USB_SERIAL_JTAG #include #include #endif From 70d9ab25f3e20f8301ab353ce45dd543e61087db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 17:57:42 -0500 Subject: [PATCH 1569/2214] [tests] Fail component test merge on conflicting duplicate IDs (#16795) --- .github/workflows/ci.yml | 1 + script/ci_check_duplicate_test_ids.py | 122 ++++++++++++++++++ script/merge_component_configs.py | 48 ++++++- tests/components/adc/test.bk72xx-ard.yaml | 2 +- tests/components/adc/test.esp32-c2-idf.yaml | 2 +- tests/components/adc/test.esp32-c3-idf.yaml | 2 +- tests/components/adc/test.esp32-idf.yaml | 2 +- tests/components/adc/test.esp32-p4-idf.yaml | 2 +- tests/components/adc/test.esp32-s2-idf.yaml | 2 +- tests/components/adc/test.esp32-s3-idf.yaml | 2 +- tests/components/adc/test.esp8266-ard.yaml | 2 +- tests/components/adc/test.ln882x-ard.yaml | 2 +- tests/components/adc/test.rp2040-ard.yaml | 2 +- .../components/adc/test.rp2040-pico2-ard.yaml | 2 +- .../alarm_control_panel/common.yaml | 6 +- .../components/animation/test.esp32-idf.yaml | 2 +- .../animation/test.esp8266-ard.yaml | 2 +- .../components/animation/test.rp2040-ard.yaml | 2 +- tests/components/axs15231/common.yaml | 4 +- .../components/axs15231/test.esp8266-ard.yaml | 4 +- tests/components/bang_bang/common.yaml | 12 +- tests/components/binary_sensor/common.yaml | 6 +- .../components/binary_sensor_map/common.yaml | 24 ++-- tests/components/ble_client/common.yaml | 4 +- tests/components/canbus/common.yaml | 6 +- tests/components/climate_ir_lg/common.yaml | 4 +- .../components/color_temperature/common.yaml | 8 +- tests/components/copy/common.yaml | 8 +- tests/components/current_based/common.yaml | 6 +- tests/components/cwww/common.yaml | 4 +- tests/components/cwww/test.esp32-idf.yaml | 4 +- tests/components/cwww/test.esp8266-ard.yaml | 4 +- tests/components/cwww/test.rp2040-ard.yaml | 4 +- tests/components/display/common.yaml | 2 +- tests/components/duty_time/common.yaml | 4 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- tests/components/ektf2232/common.yaml | 4 +- tests/components/endstop/common.yaml | 12 +- tests/components/esp32_can/common.yaml | 2 +- .../esp32_can/test.esp32-c6-idf.yaml | 6 +- tests/components/espnow/common.yaml | 6 +- .../components/fastled_clockless/common.yaml | 6 +- tests/components/fastled_spi/common.yaml | 6 +- tests/components/font/common.yaml | 6 +- tests/components/font/test.host.yaml | 6 +- tests/components/graph/common.yaml | 2 +- .../graphical_display_menu/common.yaml | 17 +-- tests/components/gt911/common.yaml | 4 +- tests/components/image/test.esp32-idf.yaml | 2 +- tests/components/image/test.esp8266-ard.yaml | 2 +- tests/components/image/test.rp2040-ard.yaml | 2 +- .../components/integration/common-esp32.yaml | 4 +- .../integration/test.esp8266-ard.yaml | 4 +- .../integration/test.rp2040-ard.yaml | 4 +- tests/components/lcd_menu/common.yaml | 4 +- tests/components/light/common.yaml | 2 +- tests/components/light/test.esp32-idf.yaml | 2 +- tests/components/light/test.esp8266-ard.yaml | 2 +- .../components/light/test.nrf52-adafruit.yaml | 4 +- tests/components/light/test.nrf52-mcumgr.yaml | 4 +- tests/components/light/test.rp2040-ard.yaml | 2 +- tests/components/lilygo_t5_47/common.yaml | 4 +- tests/components/lock/common.yaml | 4 +- tests/components/mapping/test.esp32-idf.yaml | 2 +- .../components/mapping/test.esp8266-ard.yaml | 2 +- tests/components/mapping/test.rp2040-ard.yaml | 2 +- tests/components/monochromatic/common.yaml | 4 +- tests/components/mpr121/common.yaml | 6 +- tests/components/nextion/common.yaml | 4 +- .../components/nextion/common_tft_upload.yaml | 2 +- .../nextion/common_tft_upload_watchdog.yaml | 2 +- tests/components/ntc/common.yaml | 10 +- tests/components/number/common.yaml | 4 +- .../components/online_image/common-esp32.yaml | 2 +- .../online_image/common-esp8266.yaml | 2 +- .../online_image/common-rp2040.yaml | 2 +- .../online_image/test.esp32-s3-ard.yaml | 2 +- .../online_image/test.esp32-s3-idf.yaml | 2 +- tests/components/output/common.yaml | 12 +- tests/components/pi4ioe5v6408/common.yaml | 2 +- tests/components/pid/common.yaml | 6 +- tests/components/prometheus/common.yaml | 6 +- tests/components/qspi_dbi/common.yaml | 2 +- .../remote_transmitter/common-buttons.yaml | 6 +- tests/components/resistance/common.yaml | 6 +- tests/components/rgb/common.yaml | 8 +- tests/components/rgbct/common.yaml | 8 +- tests/components/rgbw/common.yaml | 8 +- tests/components/rgbww/common.yaml | 8 +- .../rp2040_pio_led_strip/common.yaml | 2 +- tests/components/rp2040_pwm/common.yaml | 4 +- tests/components/sdl/common.yaml | 8 +- tests/components/speaker/common.yaml | 4 +- tests/components/speed/common.yaml | 6 +- tests/components/sprinkler/common.yaml | 12 +- tests/components/ssd1306_i2c/common.yaml | 2 +- tests/components/switch/common.yaml | 2 +- tests/components/sx126x/common.yaml | 4 +- tests/components/sx127x/common.yaml | 4 +- tests/components/template/common-base.yaml | 14 +- tests/components/tt21100/common.yaml | 4 +- tests/components/uart/test.esp32-idf.yaml | 4 +- tests/components/udp/common.yaml | 4 +- tests/components/ufire_ec/common.yaml | 6 +- tests/components/ufire_ise/common.yaml | 4 +- tests/components/web_server_idf/common.yaml | 4 +- tests/components/wk2132_i2c/common.yaml | 2 +- tests/components/wk2132_spi/common.yaml | 2 +- tests/components/wk2168_i2c/common.yaml | 2 +- tests/components/wk2168_spi/common.yaml | 2 +- tests/components/wk2204_i2c/common.yaml | 2 +- tests/components/wk2204_spi/common.yaml | 2 +- tests/components/wk2212_i2c/common.yaml | 2 +- tests/components/wk2212_spi/common.yaml | 2 +- tests/script/test_merge_component_configs.py | 72 +++++++++++ 115 files changed, 482 insertions(+), 252 deletions(-) create mode 100755 script/ci_check_duplicate_test_ids.py create mode 100644 tests/script/test_merge_component_configs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40267240d8..96c205fb70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,7 @@ jobs: script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check + script/ci_check_duplicate_test_ids.py import-time: name: Check import esphome.__main__ time diff --git a/script/ci_check_duplicate_test_ids.py b/script/ci_check_duplicate_test_ids.py new file mode 100755 index 0000000000..9d498dd64f --- /dev/null +++ b/script/ci_check_duplicate_test_ids.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Fail when two component test fixtures define the same id with different content. + +Component tests are merged and built in groups in CI (see +``script/merge_component_configs.py``). When two components declare the same id +under the same section but with different content, the merge silently keeps the +first and drops the rest, which can make a cross-reference resolve to an +incompatible entity (this is what broke the i2s_audio speaker tests). The merge +now raises on such a collision, but only when the two components land in the same +group. This script is the complete, batch-independent guard: it scans every +component's ``test..yaml`` per platform and reports any id that is +defined by more than one component with differing content. + +Ids that are intentionally shared across components (e.g. a singleton +``sntp_time`` clock) are listed in ``INTENTIONALLY_SHARED_IDS`` and skipped. +""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from script.merge_component_configs import ( # noqa: E402 + INTENTIONALLY_SHARED_IDS, + load_yaml_file, +) + +TESTS_DIR = Path("tests/components") + + +def _normalize(value: object) -> object: + """Return a hashable, order-independent representation for comparison.""" + if isinstance(value, dict): + return tuple(sorted((str(k), _normalize(v)) for k, v in value.items())) + if isinstance(value, (list, tuple)): + return tuple(_normalize(v) for v in value) + # Scalars (and ESPHome tag objects like !lambda) compare by their text form + return str(value) + + +def _collect_ids( + data: object, section: str, out: dict[tuple[str, str], object] +) -> None: + """Walk a parsed config and record (section, id) -> normalized content.""" + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and "id" in item: + out[(key, str(item["id"]))] = _normalize(item) + _collect_ids(item, key, out) + else: + _collect_ids(value, key, out) + elif isinstance(data, list): + for item in data: + _collect_ids(item, section, out) + + +def _discover_platforms() -> set[str]: + platforms: set[str] = set() + for test_file in TESTS_DIR.glob("*/test.*.yaml"): + # test..yaml -> platform is the middle dotted part + parts = test_file.name.split(".") + if len(parts) == 3: + platforms.add(parts[1]) + return platforms + + +def main() -> int: + conflicts: list[str] = [] + for platform in sorted(_discover_platforms()): + # (section, id) -> {normalized_content: [components]} + by_id: dict[tuple[str, str], dict[object, list[str]]] = defaultdict( + lambda: defaultdict(list) + ) + for comp_dir in sorted(TESTS_DIR.iterdir()): + if not comp_dir.is_dir(): + continue + test_file = comp_dir / f"test.{platform}.yaml" + if not test_file.exists(): + continue + try: + data = load_yaml_file(test_file) + except Exception as err: # noqa: BLE001 + print(f"WARNING: could not parse {test_file}: {err}", file=sys.stderr) + continue + ids: dict[tuple[str, str], object] = {} + _collect_ids(data, "", ids) + for (section, id_), content in ids.items(): + if id_ in INTENTIONALLY_SHARED_IDS: + continue + by_id[(section, id_)][content].append(comp_dir.name) + + for (section, id_), variants in sorted(by_id.items()): + if len(variants) < 2: + continue + components = sorted({c for comps in variants.values() for c in comps}) + conflicts.append( + f"[{platform}] id '{id_}' under '{section}' is defined " + f"differently by: {', '.join(components)}" + ) + + if conflicts: + print("Conflicting test component ids found:\n") + for line in conflicts: + print(f" - {line}") + print( + "\nGive each component a unique id (e.g. '_'), or add the " + "id to INTENTIONALLY_SHARED_IDS in script/merge_component_configs.py if " + "it is a deliberately shared singleton." + ) + return 1 + + print("No conflicting test component ids found.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index a952ecff16..20457e906a 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -161,18 +161,42 @@ def prefix_substitutions_in_dict( return data +# Ids that several components intentionally share. ESPHome treats these as a +# single instance when merged (e.g. multiple components each declaring the same +# `sntp_time` clock collapse into one), so duplicates with differing content are +# expected and must not be flagged as accidental collisions. +INTENTIONALLY_SHARED_IDS = frozenset( + { + # Several components each declare an `sntp_time` clock; ESPHome merges + # them into one time source. + "sntp_time", + # esp_ldo and mipi_dsi both configure the channel-3 internal LDO on the + # ESP32-P4; only one LDO per channel may exist, so the shared id lets the + # merge collapse them into a single LDO. + "ldo_id", + } +) + + def deduplicate_by_id(data: dict) -> dict: """Deduplicate list items with the same ID. - Keeps only the first occurrence of each ID. If items with the same ID - are identical, this silently deduplicates. If they differ, the first - one is kept (ESPHome's validation will catch if this causes issues). + Identical items sharing an ID (e.g. a shared bus from a common package pulled + in by several components) are collapsed to the first occurrence. Two items that + share an ID but differ in content are a real conflict: when merged, the first + one silently wins and the others are dropped, which can make cross-references + resolve to an incompatible entity. Rather than defer that to downstream + validation (where it surfaces as a confusing, order-dependent failure), raise + immediately so the offending ID is named. Args: data: Parsed config dictionary Returns: Config with deduplicated lists + + Raises: + ValueError: If two items share an ID but have different content. """ if not isinstance(data, dict): return data @@ -181,16 +205,26 @@ def deduplicate_by_id(data: dict) -> dict: for key, value in data.items(): if isinstance(value, list): # Check for items with 'id' field - seen_ids = set() + seen_items = {} deduped_list = [] for item in value: if isinstance(item, dict) and "id" in item: item_id = item["id"] - if item_id not in seen_ids: - seen_ids.add(item_id) + if item_id not in seen_items: + seen_items[item_id] = item deduped_list.append(item) - # else: skip duplicate ID (keep first occurrence) + elif item_id in INTENTIONALLY_SHARED_IDS: + # Designed singleton shared by several components (e.g. an + # `sntp_time` clock); ESPHome collapses these, so keep first. + pass + elif item != seen_items[item_id]: + raise ValueError( + f"Conflicting definitions for id '{item_id}' under " + f"'{key}' when merging test configs; give each " + f"component a unique id" + ) + # else: identical duplicate (e.g. shared bus package) -> skip else: # No ID, just add it deduped_list.append(item) diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/test.bk72xx-ard.yaml index 0645333a81..09ef0e1fad 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/test.bk72xx-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: P23 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c2-idf.yaml b/tests/components/adc/test.esp32-c2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c2-idf.yaml +++ b/tests/components/adc/test.esp32-c2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c3-idf.yaml b/tests/components/adc/test.esp32-c3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c3-idf.yaml +++ b/tests/components/adc/test.esp32-c3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-idf.yaml b/tests/components/adc/test.esp32-idf.yaml index ff1e3bb919..f31e0e087d 100644 --- a/tests/components/adc/test.esp32-idf.yaml +++ b/tests/components/adc/test.esp32-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A0 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/test.esp32-p4-idf.yaml index b77dc299c2..77cf50d17c 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/test.esp32-p4-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO16 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/test.esp32-s2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/test.esp32-s2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s3-idf.yaml b/tests/components/adc/test.esp32-s3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s3-idf.yaml +++ b/tests/components/adc/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp8266-ard.yaml b/tests/components/adc/test.esp8266-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.esp8266-ard.yaml +++ b/tests/components/adc/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml index face38b647..d899259773 100644 --- a/tests/components/adc/test.ln882x-ard.yaml +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A5 name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-ard.yaml b/tests/components/adc/test.rp2040-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-ard.yaml +++ b/tests/components/adc/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-pico2-ard.yaml +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 39d5739255..327234d6ca 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: gpio - id: bin1 + id: alarm_control_panel_bin1 pin: 1 alarm_control_panel: @@ -18,7 +18,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true @@ -39,7 +39,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true diff --git a/tests/components/animation/test.esp32-idf.yaml b/tests/components/animation/test.esp32-idf.yaml index c28e9584dd..b844f5ae92 100644 --- a/tests/components/animation/test.esp32-idf.yaml +++ b/tests/components/animation/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 12 diff --git a/tests/components/animation/test.esp8266-ard.yaml b/tests/components/animation/test.esp8266-ard.yaml index 11a7117d91..a7937ffca2 100644 --- a/tests/components/animation/test.esp8266-ard.yaml +++ b/tests/components/animation/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/animation/test.rp2040-ard.yaml b/tests/components/animation/test.rp2040-ard.yaml index 2c99e937f3..2cbb254adf 100644 --- a/tests/components/animation/test.rp2040-ard.yaml +++ b/tests/components/animation/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/axs15231/common.yaml b/tests/components/axs15231/common.yaml index d4fd3becbb..03e82ab26e 100644 --- a/tests/components/axs15231/common.yaml +++ b/tests/components/axs15231/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: axs15231_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: 19 pages: @@ -13,6 +13,6 @@ touchscreen: - platform: axs15231 i2c_id: i2c_bus id: axs15231_touchscreen - display: ssd1306_i2c_display + display: axs15231_ssd1306_i2c_display interrupt_pin: 20 reset_pin: 18 diff --git a/tests/components/axs15231/test.esp8266-ard.yaml b/tests/components/axs15231/test.esp8266-ard.yaml index eb599da773..245b87bec9 100644 --- a/tests/components/axs15231/test.esp8266-ard.yaml +++ b/tests/components/axs15231/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: axs15231_ssd1306_display model: SSD1306_128X64 reset_pin: 13 pages: @@ -15,5 +15,5 @@ display: touchscreen: - platform: axs15231 i2c_id: i2c_bus - display: ssd1306_display + display: axs15231_ssd1306_display interrupt_pin: 12 diff --git a/tests/components/bang_bang/common.yaml b/tests/components/bang_bang/common.yaml index 5882025191..28798f8173 100644 --- a/tests/components/bang_bang/common.yaml +++ b/tests/components/bang_bang/common.yaml @@ -1,6 +1,6 @@ switch: - platform: template - id: template_switch1 + id: bang_bang_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -8,7 +8,7 @@ switch: sensor: - platform: template - id: template_sensor1 + id: bang_bang_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -20,16 +20,16 @@ sensor: climate: - platform: bang_bang name: Bang Bang Climate - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: bang_bang_template_sensor1 + humidity_sensor: bang_bang_template_sensor1 default_target_temperature_low: 18°C default_target_temperature_high: 24°C idle_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 cool_action: - switch.turn_on: template_switch2 heat_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 away_config: default_target_temperature_low: 16°C default_target_temperature_high: 20°C diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index e3fd159b08..4f4cf6ea59 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -1,7 +1,7 @@ binary_sensor: - platform: template trigger_on_initial_state: true - id: some_binary_sensor + id: binary_sensor_some_binary_sensor name: "Random binary" lambda: return (random_uint32() & 1) == 0; filters: @@ -21,7 +21,7 @@ binary_sensor: time_off: 100ms time_on: 400ms - lambda: |- - if (id(some_binary_sensor).state) { + if (id(binary_sensor_some_binary_sensor).state) { return x; } return {}; @@ -36,7 +36,7 @@ binary_sensor: - logger.log: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - - binary_sensor.invalidate_state: some_binary_sensor + - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor # Test autorepeat with default configuration (no timings) - platform: template diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index c054022583..667d0be9e7 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -1,20 +1,20 @@ binary_sensor: - platform: template - id: bin1 + id: binary_sensor_map_bin1 lambda: |- if (millis() > 10000) { return true; } return false; - platform: template - id: bin2 + id: binary_sensor_map_bin2 lambda: |- if (millis() > 20000) { return true; } return false; - platform: template - id: bin3 + id: binary_sensor_map_bin3 lambda: |- if (millis() > 30000) { return true; @@ -26,33 +26,33 @@ sensor: name: Binary Sensor Map Group type: group channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Sum type: sum channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Bayesian type: bayesian prior: 0.4 observations: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 prob_given_true: 0.9 prob_given_false: 0.4 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 prob_given_true: 0.7 prob_given_false: 0.05 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 prob_given_true: 0.8 prob_given_false: 0.2 diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index 4ea1dd60f3..4ed6ad7fc9 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -56,7 +56,7 @@ sensor: number: - platform: template name: "Test Number" - id: test_number + id: ble_client_test_number optimistic: true min_value: 0 max_value: 255 @@ -72,5 +72,5 @@ button: service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" value: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(ble_client_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index e779f7f078..3ba3564608 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -1,6 +1,6 @@ canbus: - platform: esp32_can - id: esp32_internal_can + id: canbus_esp32_internal_can rx_pin: 4 tx_pin: 5 can_id: 4 @@ -40,7 +40,7 @@ canbus: number: - platform: template name: "Test Number" - id: test_number + id: canbus_test_number optimistic: true min_value: 0 max_value: 255 @@ -62,5 +62,5 @@ button: - canbus.send: !lambda return {0, 1, 2}; # Test canbus.send with lambda that references a component (function pointer) - canbus.send: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(canbus_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index 37011b16ee..e0bc185d2c 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,6 +1,6 @@ sensor: - platform: template - id: temp_sensor + id: climate_ir_lg_temp_sensor lambda: return 22.0; update_interval: 60s - platform: template @@ -12,5 +12,5 @@ climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr - sensor: temp_sensor + sensor: climate_ir_lg_temp_sensor humidity_sensor: humidity_sensor diff --git a/tests/components/color_temperature/common.yaml b/tests/components/color_temperature/common.yaml index fe0c5bf917..0db54d10d0 100644 --- a/tests/components/color_temperature/common.yaml +++ b/tests/components/color_temperature/common.yaml @@ -1,15 +1,15 @@ output: - platform: ${light_platform} - id: light_output_1 + id: color_temperature_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: color_temperature_light_output_2 pin: ${pin_o2} light: - platform: color_temperature name: Lights - color_temperature: light_output_1 - brightness: light_output_2 + color_temperature: color_temperature_light_output_1 + brightness: color_temperature_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index a376004b2f..cbd056f070 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -1,17 +1,17 @@ output: - platform: ${pwm_platform} - id: fan_output_1 + id: copy_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: copy_fan_speed + output: copy_fan_output_1 preset_modes: - Eco - Turbo - platform: copy - source_id: fan_speed + source_id: copy_fan_speed name: Fan Speed Copy select: diff --git a/tests/components/current_based/common.yaml b/tests/components/current_based/common.yaml index 503c4596e9..139571ccec 100644 --- a/tests/components/current_based/common.yaml +++ b/tests/components/current_based/common.yaml @@ -31,7 +31,7 @@ sensor: switch: - platform: template - id: template_switch1 + id: current_based_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -46,7 +46,7 @@ cover: open_obstacle_current_threshold: 0.8 open_duration: 12s open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: current_based_template_switch1 close_sensor: ade7953_current_b close_moving_current_threshold: 0.5 close_obstacle_current_threshold: 0.8 @@ -54,7 +54,7 @@ cover: close_action: - switch.turn_on: template_switch2 stop_action: - - switch.turn_off: template_switch1 + - switch.turn_off: current_based_template_switch1 - switch.turn_off: template_switch2 obstacle_rollback: 30% start_sensing_delay: 0.8s diff --git a/tests/components/cwww/common.yaml b/tests/components/cwww/common.yaml index 7fa5ab668c..bbb6c9182b 100644 --- a/tests/components/cwww/common.yaml +++ b/tests/components/cwww/common.yaml @@ -1,8 +1,8 @@ light: - platform: cwww name: CWWW Light - cold_white: light_output_1 - warm_white: light_output_2 + cold_white: cwww_light_output_1 + warm_white: cwww_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds constant_brightness: true diff --git a/tests/components/cwww/test.esp32-idf.yaml b/tests/components/cwww/test.esp32-idf.yaml index 01edf0b0b5..0665879b08 100644 --- a/tests/components/cwww/test.esp32-idf.yaml +++ b/tests/components/cwww/test.esp32-idf.yaml @@ -5,11 +5,11 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} channel: 0 - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} channel: 1 phase_angle: 180° diff --git a/tests/components/cwww/test.esp8266-ard.yaml b/tests/components/cwww/test.esp8266-ard.yaml index 49d73b7d3d..bb1868fdef 100644 --- a/tests/components/cwww/test.esp8266-ard.yaml +++ b/tests/components/cwww/test.esp8266-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/cwww/test.rp2040-ard.yaml b/tests/components/cwww/test.rp2040-ard.yaml index ba8e0ad071..27fc930b68 100644 --- a/tests/components/cwww/test.rp2040-ard.yaml +++ b/tests/components/cwww/test.rp2040-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/display/common.yaml b/tests/components/display/common.yaml index a722a5f7c2..6617671972 100644 --- a/tests/components/display/common.yaml +++ b/tests/components/display/common.yaml @@ -1,6 +1,6 @@ display: - platform: ili9xxx - id: main_lcd + id: display_main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 761d10f16a..12e4397c49 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: duty_time_bin1 lambda: |- if (millis() > 10000) { return true; @@ -10,4 +10,4 @@ binary_sensor: sensor: - platform: duty_time name: Duty Time - sensor: bin1 + sensor: duty_time_bin1 diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 25fe3b6796..4593784ef9 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -2,7 +2,7 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: e131_led_strip pin: 2 pio: 0 num_leds: 256 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 1c4d768b08..070b03eeb9 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: ektf2232_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -15,7 +15,7 @@ touchscreen: id: ektf2232_touchscreen interrupt_pin: ${interrupt_pin} reset_pin: ${touch_reset_pin} - display: ssd1306_i2c_display + display: ektf2232_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/endstop/common.yaml b/tests/components/endstop/common.yaml index b92b1e13b9..6f5cf61268 100644 --- a/tests/components/endstop/common.yaml +++ b/tests/components/endstop/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: endstop_bin1 lambda: |- if (millis() > 10000) { return true; @@ -9,7 +9,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: endstop_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -20,12 +20,12 @@ cover: id: endstop_cover name: Endstop Cover stop_action: - - switch.turn_on: template_switch1 - open_endstop: bin1 + - switch.turn_on: endstop_template_switch1 + open_endstop: endstop_bin1 open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: endstop_template_switch1 open_duration: 5min - close_endstop: bin1 + close_endstop: endstop_bin1 close_action: - switch.turn_on: template_switch2 close_duration: 4.5min diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index 3b9b33c048..f15b609d84 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -13,7 +13,7 @@ esphome: canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: ${rx_pin} tx_pin: ${tx_pin} can_id: 4 diff --git a/tests/components/esp32_can/test.esp32-c6-idf.yaml b/tests/components/esp32_can/test.esp32-c6-idf.yaml index ac978482fc..c548b4f0f4 100644 --- a/tests/components/esp32_can/test.esp32-c6-idf.yaml +++ b/tests/components/esp32_can/test.esp32-c6-idf.yaml @@ -3,20 +3,20 @@ esphome: then: - canbus.send: # Extended ID explicit - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can use_extended_id: true can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - canbus.send: # Standard ID by default - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] # Note: esp32_internal_can_2 uses LISTENONLY mode, so no send actions canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: GPIO8 tx_pin: GPIO7 can_id: 4 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index bdc478ea03..f05735e8f4 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: encryption: key: "0123456789abcdef0123456789abcdef" sensors: - - temp_sensor + - espnow_temp_sensor providers: - name: test-provider encryption: @@ -70,9 +70,9 @@ packet_transport: sensor: - platform: internal_temperature - id: temp_sensor + id: espnow_temp_sensor - platform: packet_transport provider: test-provider - remote_id: temp_sensor + remote_id: espnow_temp_sensor id: remote_temp diff --git a/tests/components/fastled_clockless/common.yaml b/tests/components/fastled_clockless/common.yaml index 8b1447a17a..a7ce7ed280 100644 --- a/tests/components/fastled_clockless/common.yaml +++ b/tests/components/fastled_clockless/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_clockless - id: addr1 + id: fastled_clockless_addr1 chipset: WS2811 pin: 13 num_leds: 100 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/fastled_spi/common.yaml b/tests/components/fastled_spi/common.yaml index f6f7c5553b..19d00627f8 100644 --- a/tests/components/fastled_spi/common.yaml +++ b/tests/components/fastled_spi/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_spi - id: addr1 + id: fastled_spi_addr1 chipset: WS2801 clock_pin: 22 data_pin: 23 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/font/common.yaml b/tests/components/font/common.yaml index c156b4aea1..59063291e7 100644 --- a/tests/components/font/common.yaml +++ b/tests/components/font/common.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -50,11 +50,11 @@ font: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: font_ssd1306_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(monocraft), "Hello, World!"); it.print(0, 60, id(monocraft2), "Hello, World!"); diff --git a/tests/components/font/test.host.yaml b/tests/components/font/test.host.yaml index 387ea47335..8ada8b7a4e 100644 --- a/tests/components/font/test.host.yaml +++ b/tests/components/font/test.host.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -44,12 +44,12 @@ font: display: - platform: sdl - id: sdl_display + id: font_sdl_display dimensions: width: 800 height: 600 lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(roboto_greek), "Hello κόσμε!"); it.print(0, 60, id(monocraft), "Hello, World!"); diff --git a/tests/components/graph/common.yaml b/tests/components/graph/common.yaml index 11e2a16ca1..edf4493aa6 100644 --- a/tests/components/graph/common.yaml +++ b/tests/components/graph/common.yaml @@ -12,7 +12,7 @@ graph: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: graph_ssd1306_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: diff --git a/tests/components/graphical_display_menu/common.yaml b/tests/components/graphical_display_menu/common.yaml index 6cee2af232..50f8a5bc85 100644 --- a/tests/components/graphical_display_menu/common.yaml +++ b/tests/components/graphical_display_menu/common.yaml @@ -1,6 +1,7 @@ display: - platform: ssd1306_i2c - id: ssd1306_i2c_display + i2c_id: i2c_bus + id: graphical_display_menu_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -10,12 +11,12 @@ display: font: - file: "gfonts://Roboto" - id: roboto + id: graphical_display_menu_roboto size: 20 number: - platform: template - id: test_number + id: graphical_display_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -31,13 +32,13 @@ select: switch: - platform: template - id: test_switch + id: graphical_display_menu_test_switch optimistic: true graphical_display_menu: id: test_graphical_display_menu - display: ssd1306_i2c_display - font: roboto + display: graphical_display_menu_ssd1306_i2c_display + font: graphical_display_menu_roboto active: false mode: rotary on_enter: @@ -80,7 +81,7 @@ graphical_display_menu: lambda: 'ESP_LOGI("graphical_display_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: "Number" - number: test_number + number: graphical_display_menu_test_number on_enter: then: lambda: 'ESP_LOGI("graphical_display_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' @@ -97,7 +98,7 @@ graphical_display_menu: - display_menu.hide: test_graphical_display_menu - type: switch text: "Switch" - switch: test_switch + switch: graphical_display_menu_test_switch on_text: "Bright" off_text: "Dark" immediate_edit: false diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index ff464cda24..0fc40737f0 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: gt911_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: ssd1306_i2c_display + display: gt911_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/image/test.esp32-idf.yaml b/tests/components/image/test.esp32-idf.yaml index aea2b4bbb0..9e93c4c289 100644 --- a/tests/components/image/test.esp32-idf.yaml +++ b/tests/components/image/test.esp32-idf.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 15 diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 2e7bfc5ae5..492b57c449 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/image/test.rp2040-ard.yaml b/tests/components/image/test.rp2040-ard.yaml index 03a9c42a38..ce2a13fca7 100644 --- a/tests/components/image/test.rp2040-ard.yaml +++ b/tests/components/image/test.rp2040-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/integration/common-esp32.yaml b/tests/components/integration/common-esp32.yaml index 26550d3c5c..c912fb9b84 100644 --- a/tests/components/integration/common-esp32.yaml +++ b/tests/components/integration/common-esp32.yaml @@ -9,11 +9,11 @@ esphome: sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: ${pin} attenuation: 12db - platform: integration id: integration_sensor - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.esp8266-ard.yaml b/tests/components/integration/test.esp8266-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.esp8266-ard.yaml +++ b/tests/components/integration/test.esp8266-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.rp2040-ard.yaml b/tests/components/integration/test.rp2040-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.rp2040-ard.yaml +++ b/tests/components/integration/test.rp2040-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/lcd_menu/common.yaml b/tests/components/lcd_menu/common.yaml index 970c18e0d2..a7740e771d 100644 --- a/tests/components/lcd_menu/common.yaml +++ b/tests/components/lcd_menu/common.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: lcd_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -83,7 +83,7 @@ lcd_menu: lambda: 'ESP_LOGI("lcd_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: Number - number: test_number + number: lcd_menu_test_number on_enter: then: lambda: 'ESP_LOGI("lcd_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 2acc080c6d..71c00e5f10 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -156,7 +156,7 @@ light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 925197182c..49e49b4318 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 12 - platform: ledc id: test_ledc_1 diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 518011e925..1eb58eabc4 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 4 - platform: esp8266_pwm id: test_ledc_1 diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index a5a37fd559..21d5cad774 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 - platform: rp2040_pwm id: test_ledc_1 diff --git a/tests/components/lilygo_t5_47/common.yaml b/tests/components/lilygo_t5_47/common.yaml index 18f1ba10ae..5e71736eb0 100644 --- a/tests/components/lilygo_t5_47/common.yaml +++ b/tests/components/lilygo_t5_47/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: lilygo_t5_47_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -14,7 +14,7 @@ touchscreen: i2c_id: i2c_bus id: lilygo_touchscreen interrupt_pin: ${interrupt_pin} - display: ssd1306_i2c_display + display: lilygo_t5_47_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 9ba7f34857..08001855cb 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -7,7 +7,7 @@ esphome: output: - platform: gpio - id: test_binary + id: lock_test_binary pin: 4 lock: @@ -32,4 +32,4 @@ lock: - platform: output name: Generic Output Lock id: test_lock2 - output: test_binary + output: lock_test_binary diff --git a/tests/components/mapping/test.esp32-idf.yaml b/tests/components/mapping/test.esp32-idf.yaml index 93adcf9988..d99f8ddc4e 100644 --- a/tests/components/mapping/test.esp32-idf.yaml +++ b/tests/components/mapping/test.esp32-idf.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: main_lcd + id: mapping_main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/mapping/test.esp8266-ard.yaml b/tests/components/mapping/test.esp8266-ard.yaml index 6a308b67dd..e51240f20d 100644 --- a/tests/components/mapping/test.esp8266-ard.yaml +++ b/tests/components/mapping/test.esp8266-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: main_lcd + id: mapping_main_lcd model: ili9342 cs_pin: 5 dc_pin: 15 diff --git a/tests/components/mapping/test.rp2040-ard.yaml b/tests/components/mapping/test.rp2040-ard.yaml index 01b83c4ab8..0562a4ba51 100644 --- a/tests/components/mapping/test.rp2040-ard.yaml +++ b/tests/components/mapping/test.rp2040-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: main_lcd + id: mapping_main_lcd model: ili9342 data_rate: 31.25MHz cs_pin: 20 diff --git a/tests/components/monochromatic/common.yaml b/tests/components/monochromatic/common.yaml index 9915e086eb..e57c7bec29 100644 --- a/tests/components/monochromatic/common.yaml +++ b/tests/components/monochromatic/common.yaml @@ -1,13 +1,13 @@ output: - platform: ${light_platform} - id: light_output_1 + id: monochromatic_light_output_1 pin: ${pin} light: - platform: monochromatic name: Monochromatic Light id: monochromatic_light - output: light_output_1 + output: monochromatic_light_output_1 gamma_correct: 2.8 default_transition_length: 2s effects: diff --git a/tests/components/mpr121/common.yaml b/tests/components/mpr121/common.yaml index 67a06cf9c1..f96651e9bf 100644 --- a/tests/components/mpr121/common.yaml +++ b/tests/components/mpr121/common.yaml @@ -9,15 +9,15 @@ binary_sensor: name: touchkey0 channel: 0 - platform: mpr121 - id: bin1 + id: mpr121_bin1 name: touchkey1 channel: 1 - platform: mpr121 - id: bin2 + id: mpr121_bin2 name: touchkey2 channel: 2 - platform: mpr121 - id: bin3 + id: mpr121_bin3 name: touchkey3 channel: 6 diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index d79e3ee2ed..9eadd97a6d 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -1,6 +1,6 @@ esphome: on_boot: - - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(main_lcd).is_connected()));' + - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(nextion_main_lcd).is_connected()));' - display.nextion.set_brightness: 80% @@ -272,7 +272,7 @@ text_sensor: display: - platform: nextion - id: main_lcd + id: nextion_main_lcd auto_wake_on_touch: true brightness: 80% command_spacing: 5ms diff --git a/tests/components/nextion/common_tft_upload.yaml b/tests/components/nextion/common_tft_upload.yaml index 190abbc7b1..70a0809883 100644 --- a/tests/components/nextion/common_tft_upload.yaml +++ b/tests/components/nextion/common_tft_upload.yaml @@ -1,5 +1,5 @@ display: - - id: !extend main_lcd + - id: !extend nextion_main_lcd tft_url: http://esphome.io/default35.tft tft_upload_http_timeout: 20s tft_upload_http_retries: 10 diff --git a/tests/components/nextion/common_tft_upload_watchdog.yaml b/tests/components/nextion/common_tft_upload_watchdog.yaml index 385fee359e..f0b44ce8c3 100644 --- a/tests/components/nextion/common_tft_upload_watchdog.yaml +++ b/tests/components/nextion/common_tft_upload_watchdog.yaml @@ -1,3 +1,3 @@ display: - - id: !extend main_lcd + - id: !extend nextion_main_lcd tft_upload_watchdog_timeout: 30s diff --git a/tests/components/ntc/common.yaml b/tests/components/ntc/common.yaml index 79ae7f601d..1be2c335bc 100644 --- a/tests/components/ntc/common.yaml +++ b/tests/components/ntc/common.yaml @@ -1,23 +1,23 @@ sensor: - platform: adc - id: my_sensor + id: ntc_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: ntc_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: ntc_resist - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor calibration: b_constant: 3950 reference_resistance: 10k reference_temperature: 25°C - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor2 calibration: - 10.0kOhm -> 25°C diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index c17c2dd5f8..b1a16ebfed 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Test Number" - id: test_number + id: number_test_number optimistic: true min_value: 0 max_value: 100 @@ -10,4 +10,4 @@ number: sensor: - platform: number name: "Test Number Value" - source_id: test_number + source_id: number_test_number diff --git a/tests/components/online_image/common-esp32.yaml b/tests/components/online_image/common-esp32.yaml index 32c909d351..ee4c1ed0b8 100644 --- a/tests/components/online_image/common-esp32.yaml +++ b/tests/components/online_image/common-esp32.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/common-esp8266.yaml b/tests/components/online_image/common-esp8266.yaml index d7722d171a..fc61aad92e 100644 --- a/tests/components/online_image/common-esp8266.yaml +++ b/tests/components/online_image/common-esp8266.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 15 dc_pin: 3 diff --git a/tests/components/online_image/common-rp2040.yaml b/tests/components/online_image/common-rp2040.yaml index bbb514bded..4d2785f3e8 100644 --- a/tests/components/online_image/common-rp2040.yaml +++ b/tests/components/online_image/common-rp2040.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 data_rate: 20MHz cs_pin: 20 diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml index 9116fd86e0..9972a673c0 100644 --- a/tests/components/online_image/test.esp32-s3-ard.yaml +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml index f219f71ee2..1f1485fd6c 100644 --- a/tests/components/online_image/test.esp32-s3-idf.yaml +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index 81d802e9bf..df20dcde2b 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -1,19 +1,19 @@ esphome: on_boot: then: - - output.turn_off: light_output_1 - - output.turn_on: light_output_1 + - output.turn_off: output_light_output_1 + - output.turn_on: output_light_output_1 - output.set_level: - id: light_output_1 + id: output_light_output_1 level: 50% - output.set_min_power: - id: light_output_1 + id: output_light_output_1 min_power: 20% - output.set_max_power: - id: light_output_1 + id: output_light_output_1 max_power: 80% output: - platform: ${output_platform} - id: light_output_1 + id: output_light_output_1 pin: ${pin} diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index 77a77fa3e4..aeda76d35c 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -9,7 +9,7 @@ pi4ioe5v6408: switch: - platform: gpio - id: switch1 + id: pi4ioe5v6408_switch1 pin: pi4ioe5v6408: pi4ioe1 number: 0 diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 262e75591e..320e5f775f 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -23,7 +23,7 @@ output: sensor: - platform: template - id: template_sensor1 + id: pid_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -35,8 +35,8 @@ climate: - platform: pid id: pid_climate name: PID Climate Controller - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: pid_template_sensor1 + humidity_sensor: pid_template_sensor1 default_target_temperature: 21°C heat_output: pid_slow_pwm control_parameters: diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index 7ff416dccb..951d8f7fc5 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -31,7 +31,7 @@ update: sensor: - platform: template - id: template_sensor1 + id: prometheus_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -91,7 +91,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: prometheus_template_switch1 lambda: |- if (millis() > 10000) { return true; @@ -185,7 +185,7 @@ climate: prometheus: include_internal: true relabel: - template_sensor1: + prometheus_template_sensor1: id: hellow_world name: Hello World template_text_sensor1: diff --git a/tests/components/qspi_dbi/common.yaml b/tests/components/qspi_dbi/common.yaml index 109db65b63..0eadfa7392 100644 --- a/tests/components/qspi_dbi/common.yaml +++ b/tests/components/qspi_dbi/common.yaml @@ -16,7 +16,7 @@ display: - platform: qspi_dbi model: CUSTOM - id: main_lcd + id: qspi_dbi_main_lcd draw_from_origin: true dimensions: height: 240 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index c6c7049605..5631c48f95 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: remote_transmitter_test_number optimistic: true min_value: 0 max_value: 255 @@ -151,7 +151,7 @@ button: on_press: remote_transmitter.transmit_raw: code: !lambda |- - return {(int32_t)id(test_number).state * 100, -1000}; + return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; - platform: template name: AEHA id: eaha_hitachi_climate_power_on @@ -253,7 +253,7 @@ button: destination_address: 0x5678 message_type: 0x01 data: !lambda |- - return {(uint8_t)id(test_number).state, 0x20, 0x30}; + return {(uint8_t)id(remote_transmitter_test_number).state, 0x20, 0x30}; - platform: template name: Digital Write on_press: diff --git a/tests/components/resistance/common.yaml b/tests/components/resistance/common.yaml index b3eec49548..8966b574df 100644 --- a/tests/components/resistance/common.yaml +++ b/tests/components/resistance/common.yaml @@ -1,11 +1,11 @@ sensor: - platform: adc - id: my_sensor + id: resistance_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: resistance_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: resistance_resist diff --git a/tests/components/rgb/common.yaml b/tests/components/rgb/common.yaml index 9f25efa431..bd72abbd17 100644 --- a/tests/components/rgb/common.yaml +++ b/tests/components/rgb/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgb_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgb_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -13,6 +13,6 @@ light: - platform: rgb name: RGB Light id: rgb_light - red: light_output_1 - green: light_output_2 + red: rgb_light_output_1 + green: rgb_light_output_2 blue: light_output_3 diff --git a/tests/components/rgbct/common.yaml b/tests/components/rgbct/common.yaml index 65bb248e95..46d8082706 100644 --- a/tests/components/rgbct/common.yaml +++ b/tests/components/rgbct/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbct_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbct_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbct name: RGBCT Light - red: light_output_1 - green: light_output_2 + red: rgbct_light_output_1 + green: rgbct_light_output_2 blue: light_output_3 color_temperature: light_output_4 white_brightness: light_output_5 diff --git a/tests/components/rgbw/common.yaml b/tests/components/rgbw/common.yaml index b0f44869d3..4a8e56a255 100644 --- a/tests/components/rgbw/common.yaml +++ b/tests/components/rgbw/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbw_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbw_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -15,8 +15,8 @@ output: light: - platform: rgbw name: RGBW Light - red: light_output_1 - green: light_output_2 + red: rgbw_light_output_1 + green: rgbw_light_output_2 blue: light_output_3 white: light_output_4 color_interlock: true diff --git a/tests/components/rgbww/common.yaml b/tests/components/rgbww/common.yaml index 0013960c10..bb1d73b3bc 100644 --- a/tests/components/rgbww/common.yaml +++ b/tests/components/rgbww/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbww_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbww_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbww name: RGBWW Light - red: light_output_1 - green: light_output_2 + red: rgbww_light_output_1 + green: rgbww_light_output_2 blue: light_output_3 cold_white: light_output_4 warm_white: light_output_5 diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index b9b1436cdb..254ac0e13d 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -1,6 +1,6 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: rp2040_pio_led_strip_led_strip pin: 4 num_leds: 60 pio: 0 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 45c039106f..2970a48afb 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,7 +1,7 @@ output: - platform: rp2040_pwm - id: light_output_1 + id: rp2040_pwm_light_output_1 pin: 2 - platform: rp2040_pwm - id: light_output_2 + id: rp2040_pwm_light_output_2 pin: 3 diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index d3d3c9ee5e..3be86cf8be 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -3,7 +3,7 @@ host: display: - platform: sdl - id: sdl_display + id: sdl_sdl_display update_interval: 1s auto_clear_enabled: false show_test_card: true @@ -35,14 +35,14 @@ display: binary_sensor: - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_up key: SDLK_UP - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_down key: SDLK_DOWN - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_enter key: SDLK_RETURN diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 895f4b4b8f..96f459c53f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Speaker Number" - id: my_number + id: speaker_my_number optimistic: true min_value: 0 max_value: 100 @@ -46,7 +46,7 @@ button: - speaker.play: id: speaker_id data: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(speaker_my_number).state}; speaker: - platform: i2s_audio diff --git a/tests/components/speed/common.yaml b/tests/components/speed/common.yaml index be8172af7e..70c91259ba 100644 --- a/tests/components/speed/common.yaml +++ b/tests/components/speed/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${output_platform} - id: fan_output_1 + id: speed_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: speed_fan_speed + output: speed_fan_output_1 diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index f099f77729..dbe109f524 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -34,7 +34,7 @@ esphome: switch: - platform: template - id: switch1 + id: sprinkler_switch1 optimistic: true - platform: template id: switch2 @@ -52,17 +52,17 @@ sprinkler: valves: - valve_switch: Yard Valve 0 enable_switch: Enable Yard Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 1 enable_switch: Enable Yard Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 2 enable_switch: Enable Yard Valve 2 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - id: garden_sprinkler_ctrlr @@ -73,11 +73,11 @@ sprinkler: valves: - valve_switch: Garden Valve 0 enable_switch: Enable Garden Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Garden Valve 1 enable_switch: Enable Garden Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 diff --git a/tests/components/ssd1306_i2c/common.yaml b/tests/components/ssd1306_i2c/common.yaml index 09eb569a8e..b3b8ad85dc 100644 --- a/tests/components/ssd1306_i2c/common.yaml +++ b/tests/components/ssd1306_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1306_128X64 reset_pin: ${reset_pin} address: 0x3C - id: ssd1306_i2c_display + id: ssd1306_i2c_ssd1306_i2c_display contrast: 60% pages: - id: ssd1306_i2c_page1 diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index afdf26c150..3ea235cfb9 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: switch - id: some_binary_sensor + id: switch_some_binary_sensor name: "Template Switch State" source_id: the_switch diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index 659550cc01..a4a24d8da7 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -29,7 +29,7 @@ sx126x: number: - platform: template name: "SX126x Number" - id: my_number + id: sx126x_my_number optimistic: true min_value: 0 max_value: 100 @@ -47,4 +47,4 @@ button: - sx126x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx126x_my_number).state}; diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index 6e48952fcc..b7eadc084f 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -29,7 +29,7 @@ sx127x: number: - platform: template name: "SX127x Number" - id: my_number + id: sx127x_my_number optimistic: true min_value: 0 max_value: 100 @@ -48,4 +48,4 @@ button: - sx127x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index d3985a848b..f1387a7afe 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -52,7 +52,7 @@ esphome: binary_sensor: - platform: template - id: some_binary_sensor + id: template_some_binary_sensor name: "Garage Door Open" lambda: |- if (id(template_sens).state > 30) { @@ -108,7 +108,7 @@ sensor: name: "Template Sensor" id: template_sens lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return 42.0; } return 0.0; @@ -230,7 +230,7 @@ switch: id: test_switch name: "Template Switch" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return true; } return false; @@ -249,7 +249,7 @@ cover: - platform: template name: "Template Cover" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -264,7 +264,7 @@ cover: name: "Template Cover with Triggers" id: template_cover_with_triggers lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -442,7 +442,7 @@ lock: - platform: template name: "Template Lock" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return LOCK_STATE_LOCKED; } return LOCK_STATE_UNLOCKED; @@ -458,7 +458,7 @@ valve: id: template_valve name: "Template Valve" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return VALVE_OPEN; } return VALVE_CLOSED; diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 56089aed1e..1f9249f1ba 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: tt21100_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${disp_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: ssd1306_i2c_display + display: tt21100_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index fa76316b9c..c805188005 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -79,7 +79,7 @@ switch: number: - platform: template name: "Test Number" - id: test_number + id: uart_test_number optimistic: true min_value: 0 max_value: 100 @@ -103,7 +103,7 @@ button: - uart.write: id: uart_id data: !lambda |- - std::string cmd = "VALUE=" + str_sprintf("%.0f", id(test_number).state) + "\r\n"; + std::string cmd = "VALUE=" + str_sprintf("%.0f", id(uart_test_number).state) + "\r\n"; return std::vector(cmd.begin(), cmd.end()); event: diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index a40ca455cb..6824c5cca8 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -24,7 +24,7 @@ udp: number: - platform: template name: "UDP Number" - id: my_number + id: udp_my_number optimistic: true min_value: 0 max_value: 100 @@ -38,4 +38,4 @@ button: - udp.write: data: [0x01, 0x02, 0x03] - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(my_number).state}; + return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; diff --git a/tests/components/ufire_ec/common.yaml b/tests/components/ufire_ec/common.yaml index 4260f0ab4c..2365b7a368 100644 --- a/tests/components/ufire_ec/common.yaml +++ b/tests/components/ufire_ec/common.yaml @@ -4,18 +4,18 @@ esphome: - ufire_ec.calibrate_probe: id: ufire_ec_board solution: 0.146 - temperature: !lambda "return id(test_sensor).state;" + temperature: !lambda "return id(ufire_ec_test_sensor).state;" - ufire_ec.reset: sensor: - platform: template - id: test_sensor + id: ufire_ec_test_sensor lambda: "return 21;" - platform: ufire_ec i2c_id: i2c_bus id: ufire_ec_board ec: name: Ufire EC - temperature_sensor: test_sensor + temperature_sensor: ufire_ec_test_sensor temperature_compensation: 20.0 temperature_coefficient: 0.019 diff --git a/tests/components/ufire_ise/common.yaml b/tests/components/ufire_ise/common.yaml index f7865ea87b..478c75ad37 100644 --- a/tests/components/ufire_ise/common.yaml +++ b/tests/components/ufire_ise/common.yaml @@ -11,11 +11,11 @@ esphome: sensor: - platform: template - id: test_sensor + id: ufire_ise_test_sensor lambda: "return 21;" - platform: ufire_ise i2c_id: i2c_bus id: ufire_ise_sensor - temperature_sensor: test_sensor + temperature_sensor: ufire_ise_test_sensor ph: name: Ufire pH diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml index b1885af266..cfba0060d9 100644 --- a/tests/components/web_server_idf/common.yaml +++ b/tests/components/web_server_idf/common.yaml @@ -12,7 +12,7 @@ network: sensor: - platform: template name: "Test Sensor" - id: test_sensor + id: web_server_idf_test_sensor update_interval: 60s lambda: "return 42.5;" @@ -25,5 +25,5 @@ binary_sensor: switch: - platform: template name: "Test Switch" - id: test_switch + id: web_server_idf_test_switch optimistic: true diff --git a/tests/components/wk2132_i2c/common.yaml b/tests/components/wk2132_i2c/common.yaml index 39013baeb2..93bb17b38f 100644 --- a/tests/components/wk2132_i2c/common.yaml +++ b/tests/components/wk2132_i2c/common.yaml @@ -16,4 +16,4 @@ wk2132_i2c: sensor: - platform: a02yyuw uart_id: wk2132_id_1 - id: distance_sensor + id: wk2132_i2c_distance_sensor diff --git a/tests/components/wk2132_spi/common.yaml b/tests/components/wk2132_spi/common.yaml index 18294974b9..5ff48bc64c 100644 --- a/tests/components/wk2132_spi/common.yaml +++ b/tests/components/wk2132_spi/common.yaml @@ -17,4 +17,4 @@ wk2132_spi: sensor: - platform: a02yyuw uart_id: wk2132_spi_uart1 - id: distance_sensor + id: wk2132_spi_distance_sensor diff --git a/tests/components/wk2168_i2c/common.yaml b/tests/components/wk2168_i2c/common.yaml index 49f0d1ec6b..1b2de74c02 100644 --- a/tests/components/wk2168_i2c/common.yaml +++ b/tests/components/wk2168_i2c/common.yaml @@ -23,7 +23,7 @@ wk2168_i2c: sensor: - platform: a02yyuw uart_id: wk2168_i2c_uart3 - id: distance_sensor + id: wk2168_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2168_spi/common.yaml b/tests/components/wk2168_spi/common.yaml index b402077aa3..a21a4a34d0 100644 --- a/tests/components/wk2168_spi/common.yaml +++ b/tests/components/wk2168_spi/common.yaml @@ -23,7 +23,7 @@ wk2168_spi: sensor: - platform: a02yyuw uart_id: wk2168_spi_uart3 - id: distance_sensor + id: wk2168_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2204_i2c/common.yaml b/tests/components/wk2204_i2c/common.yaml index 863633937b..55c67efd88 100644 --- a/tests/components/wk2204_i2c/common.yaml +++ b/tests/components/wk2204_i2c/common.yaml @@ -24,4 +24,4 @@ wk2204_i2c: sensor: - platform: a02yyuw uart_id: wk2204_id_3 - id: distance_sensor + id: wk2204_i2c_distance_sensor diff --git a/tests/components/wk2204_spi/common.yaml b/tests/components/wk2204_spi/common.yaml index 0b62a7a009..ee00da22bb 100644 --- a/tests/components/wk2204_spi/common.yaml +++ b/tests/components/wk2204_spi/common.yaml @@ -25,4 +25,4 @@ wk2204_spi: sensor: - platform: a02yyuw uart_id: wk2204_spi_uart3 - id: distance_sensor + id: wk2204_spi_distance_sensor diff --git a/tests/components/wk2212_i2c/common.yaml b/tests/components/wk2212_i2c/common.yaml index a754bec5c7..d48063bb4d 100644 --- a/tests/components/wk2212_i2c/common.yaml +++ b/tests/components/wk2212_i2c/common.yaml @@ -19,7 +19,7 @@ wk2212_i2c: sensor: - platform: a02yyuw uart_id: uart_i2c_id1 - id: distance_sensor + id: wk2212_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2212_spi/common.yaml b/tests/components/wk2212_spi/common.yaml index 969f16bb12..d17db2f676 100644 --- a/tests/components/wk2212_spi/common.yaml +++ b/tests/components/wk2212_spi/common.yaml @@ -17,7 +17,7 @@ wk2212_spi: sensor: - platform: a02yyuw uart_id: wk2212_spi_uart1 - id: distance_sensor + id: wk2212_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py new file mode 100644 index 0000000000..9286380de1 --- /dev/null +++ b/tests/script/test_merge_component_configs.py @@ -0,0 +1,72 @@ +"""Unit tests for script/merge_component_configs.py deduplication.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to Python path so we can import the module +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import merge_component_configs # noqa: E402 + +deduplicate_by_id = merge_component_configs.deduplicate_by_id + + +def test_identical_duplicate_ids_collapse() -> None: + """Two identical items sharing an id collapse to one without error.""" + data = { + "sensor": [ + {"id": "shared", "platform": "template", "name": "A"}, + {"id": "shared", "platform": "template", "name": "A"}, + ] + } + result = deduplicate_by_id(data) + assert result["sensor"] == [{"id": "shared", "platform": "template", "name": "A"}] + + +def test_conflicting_duplicate_ids_raise() -> None: + """Two different items sharing an id is a hard error naming the id.""" + data = { + "sensor": [ + {"id": "dup", "platform": "template", "name": "A"}, + {"id": "dup", "platform": "template", "name": "B"}, + ] + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) + + +def test_intentionally_shared_id_does_not_raise() -> None: + """Allowlisted singleton ids may differ across components and collapse.""" + shared = next(iter(merge_component_configs.INTENTIONALLY_SHARED_IDS)) + data = { + "time": [ + {"id": shared, "platform": "sntp"}, + {"id": shared, "platform": "sntp", "servers": ["a"]}, + ] + } + result = deduplicate_by_id(data) + # First occurrence wins, no error raised + assert result["time"] == [{"id": shared, "platform": "sntp"}] + + +def test_items_without_id_are_preserved() -> None: + """Items lacking an id are passed through untouched.""" + data = {"binary_sensor": [{"platform": "gpio"}, {"platform": "gpio"}]} + result = deduplicate_by_id(data) + assert result["binary_sensor"] == [{"platform": "gpio"}, {"platform": "gpio"}] + + +def test_nested_lists_are_checked() -> None: + """Conflicts nested inside dict values are also detected.""" + data = { + "wrapper": { + "sensor": [ + {"id": "dup", "value": 1}, + {"id": "dup", "value": 2}, + ] + } + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) From 8f8a70b2be9e10cd81053420c4bc7a04e3d92b4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 17:58:50 -0500 Subject: [PATCH 1570/2214] Exit nginx bypass placeholder cleanly on SIGTERM (#16845) --- .../ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run index bb5f52e10c..b8251e8e01 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -6,11 +6,15 @@ # ============================================================================== # The new device builder handles HA ingress itself, so nginx is bypassed. -# Block the longrun forever so s6 keeps the dependency satisfied and does -# not respawn it. +# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on +# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish +# stamp the container exit 143, which trips the Supervisor's SIGTERM check. if bashio::config.true 'use_new_device_builder'; then bashio::log.info "NGINX bypassed: new device builder serves ingress directly." - exec sleep infinity + trap 'exit 0' TERM + sleep infinity & + wait + exit 0 fi bashio::log.info "Waiting for ESPHome dashboard to come up..." From 2a4913713a9ccb4799ec5ee8c7800a52c2070b10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 19:37:19 -0500 Subject: [PATCH 1571/2214] Revert "[tests] Fail component test merge on conflicting duplicate IDs" (#16848) --- .github/workflows/ci.yml | 1 - script/ci_check_duplicate_test_ids.py | 122 ------------------ script/merge_component_configs.py | 48 +------ tests/components/adc/test.bk72xx-ard.yaml | 2 +- tests/components/adc/test.esp32-c2-idf.yaml | 2 +- tests/components/adc/test.esp32-c3-idf.yaml | 2 +- tests/components/adc/test.esp32-idf.yaml | 2 +- tests/components/adc/test.esp32-p4-idf.yaml | 2 +- tests/components/adc/test.esp32-s2-idf.yaml | 2 +- tests/components/adc/test.esp32-s3-idf.yaml | 2 +- tests/components/adc/test.esp8266-ard.yaml | 2 +- tests/components/adc/test.ln882x-ard.yaml | 2 +- tests/components/adc/test.rp2040-ard.yaml | 2 +- .../components/adc/test.rp2040-pico2-ard.yaml | 2 +- .../alarm_control_panel/common.yaml | 6 +- .../components/animation/test.esp32-idf.yaml | 2 +- .../animation/test.esp8266-ard.yaml | 2 +- .../components/animation/test.rp2040-ard.yaml | 2 +- tests/components/axs15231/common.yaml | 4 +- .../components/axs15231/test.esp8266-ard.yaml | 4 +- tests/components/bang_bang/common.yaml | 12 +- tests/components/binary_sensor/common.yaml | 6 +- .../components/binary_sensor_map/common.yaml | 24 ++-- tests/components/ble_client/common.yaml | 4 +- tests/components/canbus/common.yaml | 6 +- tests/components/climate_ir_lg/common.yaml | 4 +- .../components/color_temperature/common.yaml | 8 +- tests/components/copy/common.yaml | 8 +- tests/components/current_based/common.yaml | 6 +- tests/components/cwww/common.yaml | 4 +- tests/components/cwww/test.esp32-idf.yaml | 4 +- tests/components/cwww/test.esp8266-ard.yaml | 4 +- tests/components/cwww/test.rp2040-ard.yaml | 4 +- tests/components/display/common.yaml | 2 +- tests/components/duty_time/common.yaml | 4 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- tests/components/ektf2232/common.yaml | 4 +- tests/components/endstop/common.yaml | 12 +- tests/components/esp32_can/common.yaml | 2 +- .../esp32_can/test.esp32-c6-idf.yaml | 6 +- tests/components/espnow/common.yaml | 6 +- .../components/fastled_clockless/common.yaml | 6 +- tests/components/fastled_spi/common.yaml | 6 +- tests/components/font/common.yaml | 6 +- tests/components/font/test.host.yaml | 6 +- tests/components/graph/common.yaml | 2 +- .../graphical_display_menu/common.yaml | 17 ++- tests/components/gt911/common.yaml | 4 +- tests/components/image/test.esp32-idf.yaml | 2 +- tests/components/image/test.esp8266-ard.yaml | 2 +- tests/components/image/test.rp2040-ard.yaml | 2 +- .../components/integration/common-esp32.yaml | 4 +- .../integration/test.esp8266-ard.yaml | 4 +- .../integration/test.rp2040-ard.yaml | 4 +- tests/components/lcd_menu/common.yaml | 4 +- tests/components/light/common.yaml | 2 +- tests/components/light/test.esp32-idf.yaml | 2 +- tests/components/light/test.esp8266-ard.yaml | 2 +- .../components/light/test.nrf52-adafruit.yaml | 4 +- tests/components/light/test.nrf52-mcumgr.yaml | 4 +- tests/components/light/test.rp2040-ard.yaml | 2 +- tests/components/lilygo_t5_47/common.yaml | 4 +- tests/components/lock/common.yaml | 4 +- tests/components/mapping/test.esp32-idf.yaml | 2 +- .../components/mapping/test.esp8266-ard.yaml | 2 +- tests/components/mapping/test.rp2040-ard.yaml | 2 +- tests/components/monochromatic/common.yaml | 4 +- tests/components/mpr121/common.yaml | 6 +- tests/components/nextion/common.yaml | 4 +- .../components/nextion/common_tft_upload.yaml | 2 +- .../nextion/common_tft_upload_watchdog.yaml | 2 +- tests/components/ntc/common.yaml | 10 +- tests/components/number/common.yaml | 4 +- .../components/online_image/common-esp32.yaml | 2 +- .../online_image/common-esp8266.yaml | 2 +- .../online_image/common-rp2040.yaml | 2 +- .../online_image/test.esp32-s3-ard.yaml | 2 +- .../online_image/test.esp32-s3-idf.yaml | 2 +- tests/components/output/common.yaml | 12 +- tests/components/pi4ioe5v6408/common.yaml | 2 +- tests/components/pid/common.yaml | 6 +- tests/components/prometheus/common.yaml | 6 +- tests/components/qspi_dbi/common.yaml | 2 +- .../remote_transmitter/common-buttons.yaml | 6 +- tests/components/resistance/common.yaml | 6 +- tests/components/rgb/common.yaml | 8 +- tests/components/rgbct/common.yaml | 8 +- tests/components/rgbw/common.yaml | 8 +- tests/components/rgbww/common.yaml | 8 +- .../rp2040_pio_led_strip/common.yaml | 2 +- tests/components/rp2040_pwm/common.yaml | 4 +- tests/components/sdl/common.yaml | 8 +- tests/components/speaker/common.yaml | 4 +- tests/components/speed/common.yaml | 6 +- tests/components/sprinkler/common.yaml | 12 +- tests/components/ssd1306_i2c/common.yaml | 2 +- tests/components/switch/common.yaml | 2 +- tests/components/sx126x/common.yaml | 4 +- tests/components/sx127x/common.yaml | 4 +- tests/components/template/common-base.yaml | 14 +- tests/components/tt21100/common.yaml | 4 +- tests/components/uart/test.esp32-idf.yaml | 4 +- tests/components/udp/common.yaml | 4 +- tests/components/ufire_ec/common.yaml | 6 +- tests/components/ufire_ise/common.yaml | 4 +- tests/components/web_server_idf/common.yaml | 4 +- tests/components/wk2132_i2c/common.yaml | 2 +- tests/components/wk2132_spi/common.yaml | 2 +- tests/components/wk2168_i2c/common.yaml | 2 +- tests/components/wk2168_spi/common.yaml | 2 +- tests/components/wk2204_i2c/common.yaml | 2 +- tests/components/wk2204_spi/common.yaml | 2 +- tests/components/wk2212_i2c/common.yaml | 2 +- tests/components/wk2212_spi/common.yaml | 2 +- tests/script/test_merge_component_configs.py | 72 ----------- 115 files changed, 252 insertions(+), 482 deletions(-) delete mode 100755 script/ci_check_duplicate_test_ids.py delete mode 100644 tests/script/test_merge_component_configs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96c205fb70..40267240d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,6 @@ jobs: script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check - script/ci_check_duplicate_test_ids.py import-time: name: Check import esphome.__main__ time diff --git a/script/ci_check_duplicate_test_ids.py b/script/ci_check_duplicate_test_ids.py deleted file mode 100755 index 9d498dd64f..0000000000 --- a/script/ci_check_duplicate_test_ids.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -"""Fail when two component test fixtures define the same id with different content. - -Component tests are merged and built in groups in CI (see -``script/merge_component_configs.py``). When two components declare the same id -under the same section but with different content, the merge silently keeps the -first and drops the rest, which can make a cross-reference resolve to an -incompatible entity (this is what broke the i2s_audio speaker tests). The merge -now raises on such a collision, but only when the two components land in the same -group. This script is the complete, batch-independent guard: it scans every -component's ``test..yaml`` per platform and reports any id that is -defined by more than one component with differing content. - -Ids that are intentionally shared across components (e.g. a singleton -``sntp_time`` clock) are listed in ``INTENTIONALLY_SHARED_IDS`` and skipped. -""" - -from __future__ import annotations - -from collections import defaultdict -from pathlib import Path -import sys - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from script.merge_component_configs import ( # noqa: E402 - INTENTIONALLY_SHARED_IDS, - load_yaml_file, -) - -TESTS_DIR = Path("tests/components") - - -def _normalize(value: object) -> object: - """Return a hashable, order-independent representation for comparison.""" - if isinstance(value, dict): - return tuple(sorted((str(k), _normalize(v)) for k, v in value.items())) - if isinstance(value, (list, tuple)): - return tuple(_normalize(v) for v in value) - # Scalars (and ESPHome tag objects like !lambda) compare by their text form - return str(value) - - -def _collect_ids( - data: object, section: str, out: dict[tuple[str, str], object] -) -> None: - """Walk a parsed config and record (section, id) -> normalized content.""" - if isinstance(data, dict): - for key, value in data.items(): - if isinstance(value, list): - for item in value: - if isinstance(item, dict) and "id" in item: - out[(key, str(item["id"]))] = _normalize(item) - _collect_ids(item, key, out) - else: - _collect_ids(value, key, out) - elif isinstance(data, list): - for item in data: - _collect_ids(item, section, out) - - -def _discover_platforms() -> set[str]: - platforms: set[str] = set() - for test_file in TESTS_DIR.glob("*/test.*.yaml"): - # test..yaml -> platform is the middle dotted part - parts = test_file.name.split(".") - if len(parts) == 3: - platforms.add(parts[1]) - return platforms - - -def main() -> int: - conflicts: list[str] = [] - for platform in sorted(_discover_platforms()): - # (section, id) -> {normalized_content: [components]} - by_id: dict[tuple[str, str], dict[object, list[str]]] = defaultdict( - lambda: defaultdict(list) - ) - for comp_dir in sorted(TESTS_DIR.iterdir()): - if not comp_dir.is_dir(): - continue - test_file = comp_dir / f"test.{platform}.yaml" - if not test_file.exists(): - continue - try: - data = load_yaml_file(test_file) - except Exception as err: # noqa: BLE001 - print(f"WARNING: could not parse {test_file}: {err}", file=sys.stderr) - continue - ids: dict[tuple[str, str], object] = {} - _collect_ids(data, "", ids) - for (section, id_), content in ids.items(): - if id_ in INTENTIONALLY_SHARED_IDS: - continue - by_id[(section, id_)][content].append(comp_dir.name) - - for (section, id_), variants in sorted(by_id.items()): - if len(variants) < 2: - continue - components = sorted({c for comps in variants.values() for c in comps}) - conflicts.append( - f"[{platform}] id '{id_}' under '{section}' is defined " - f"differently by: {', '.join(components)}" - ) - - if conflicts: - print("Conflicting test component ids found:\n") - for line in conflicts: - print(f" - {line}") - print( - "\nGive each component a unique id (e.g. '_'), or add the " - "id to INTENTIONALLY_SHARED_IDS in script/merge_component_configs.py if " - "it is a deliberately shared singleton." - ) - return 1 - - print("No conflicting test component ids found.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 20457e906a..a952ecff16 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -161,42 +161,18 @@ def prefix_substitutions_in_dict( return data -# Ids that several components intentionally share. ESPHome treats these as a -# single instance when merged (e.g. multiple components each declaring the same -# `sntp_time` clock collapse into one), so duplicates with differing content are -# expected and must not be flagged as accidental collisions. -INTENTIONALLY_SHARED_IDS = frozenset( - { - # Several components each declare an `sntp_time` clock; ESPHome merges - # them into one time source. - "sntp_time", - # esp_ldo and mipi_dsi both configure the channel-3 internal LDO on the - # ESP32-P4; only one LDO per channel may exist, so the shared id lets the - # merge collapse them into a single LDO. - "ldo_id", - } -) - - def deduplicate_by_id(data: dict) -> dict: """Deduplicate list items with the same ID. - Identical items sharing an ID (e.g. a shared bus from a common package pulled - in by several components) are collapsed to the first occurrence. Two items that - share an ID but differ in content are a real conflict: when merged, the first - one silently wins and the others are dropped, which can make cross-references - resolve to an incompatible entity. Rather than defer that to downstream - validation (where it surfaces as a confusing, order-dependent failure), raise - immediately so the offending ID is named. + Keeps only the first occurrence of each ID. If items with the same ID + are identical, this silently deduplicates. If they differ, the first + one is kept (ESPHome's validation will catch if this causes issues). Args: data: Parsed config dictionary Returns: Config with deduplicated lists - - Raises: - ValueError: If two items share an ID but have different content. """ if not isinstance(data, dict): return data @@ -205,26 +181,16 @@ def deduplicate_by_id(data: dict) -> dict: for key, value in data.items(): if isinstance(value, list): # Check for items with 'id' field - seen_items = {} + seen_ids = set() deduped_list = [] for item in value: if isinstance(item, dict) and "id" in item: item_id = item["id"] - if item_id not in seen_items: - seen_items[item_id] = item + if item_id not in seen_ids: + seen_ids.add(item_id) deduped_list.append(item) - elif item_id in INTENTIONALLY_SHARED_IDS: - # Designed singleton shared by several components (e.g. an - # `sntp_time` clock); ESPHome collapses these, so keep first. - pass - elif item != seen_items[item_id]: - raise ValueError( - f"Conflicting definitions for id '{item_id}' under " - f"'{key}' when merging test configs; give each " - f"component a unique id" - ) - # else: identical duplicate (e.g. shared bus package) -> skip + # else: skip duplicate ID (keep first occurrence) else: # No ID, just add it deduped_list.append(item) diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/test.bk72xx-ard.yaml index 09ef0e1fad..0645333a81 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/test.bk72xx-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: P23 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c2-idf.yaml b/tests/components/adc/test.esp32-c2-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-c2-idf.yaml +++ b/tests/components/adc/test.esp32-c2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c3-idf.yaml b/tests/components/adc/test.esp32-c3-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-c3-idf.yaml +++ b/tests/components/adc/test.esp32-c3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-idf.yaml b/tests/components/adc/test.esp32-idf.yaml index f31e0e087d..ff1e3bb919 100644 --- a/tests/components/adc/test.esp32-idf.yaml +++ b/tests/components/adc/test.esp32-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: A0 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/test.esp32-p4-idf.yaml index 77cf50d17c..b77dc299c2 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/test.esp32-p4-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO16 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/test.esp32-s2-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/test.esp32-s2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s3-idf.yaml b/tests/components/adc/test.esp32-s3-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-s3-idf.yaml +++ b/tests/components/adc/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp8266-ard.yaml b/tests/components/adc/test.esp8266-ard.yaml index 617464818b..4cc865bb5d 100644 --- a/tests/components/adc/test.esp8266-ard.yaml +++ b/tests/components/adc/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml index d899259773..face38b647 100644 --- a/tests/components/adc/test.ln882x-ard.yaml +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: A5 name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-ard.yaml b/tests/components/adc/test.rp2040-ard.yaml index 617464818b..4cc865bb5d 100644 --- a/tests/components/adc/test.rp2040-ard.yaml +++ b/tests/components/adc/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml index 617464818b..4cc865bb5d 100644 --- a/tests/components/adc/test.rp2040-pico2-ard.yaml +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 327234d6ca..39d5739255 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: gpio - id: alarm_control_panel_bin1 + id: bin1 pin: 1 alarm_control_panel: @@ -18,7 +18,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: alarm_control_panel_bin1 + - input: bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true @@ -39,7 +39,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: alarm_control_panel_bin1 + - input: bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true diff --git a/tests/components/animation/test.esp32-idf.yaml b/tests/components/animation/test.esp32-idf.yaml index b844f5ae92..c28e9584dd 100644 --- a/tests/components/animation/test.esp32-idf.yaml +++ b/tests/components/animation/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: animation_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 12 diff --git a/tests/components/animation/test.esp8266-ard.yaml b/tests/components/animation/test.esp8266-ard.yaml index a7937ffca2..11a7117d91 100644 --- a/tests/components/animation/test.esp8266-ard.yaml +++ b/tests/components/animation/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: animation_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/animation/test.rp2040-ard.yaml b/tests/components/animation/test.rp2040-ard.yaml index 2cbb254adf..2c99e937f3 100644 --- a/tests/components/animation/test.rp2040-ard.yaml +++ b/tests/components/animation/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: animation_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/axs15231/common.yaml b/tests/components/axs15231/common.yaml index 03e82ab26e..d4fd3becbb 100644 --- a/tests/components/axs15231/common.yaml +++ b/tests/components/axs15231/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: axs15231_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: 19 pages: @@ -13,6 +13,6 @@ touchscreen: - platform: axs15231 i2c_id: i2c_bus id: axs15231_touchscreen - display: axs15231_ssd1306_i2c_display + display: ssd1306_i2c_display interrupt_pin: 20 reset_pin: 18 diff --git a/tests/components/axs15231/test.esp8266-ard.yaml b/tests/components/axs15231/test.esp8266-ard.yaml index 245b87bec9..eb599da773 100644 --- a/tests/components/axs15231/test.esp8266-ard.yaml +++ b/tests/components/axs15231/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: axs15231_ssd1306_display + id: ssd1306_display model: SSD1306_128X64 reset_pin: 13 pages: @@ -15,5 +15,5 @@ display: touchscreen: - platform: axs15231 i2c_id: i2c_bus - display: axs15231_ssd1306_display + display: ssd1306_display interrupt_pin: 12 diff --git a/tests/components/bang_bang/common.yaml b/tests/components/bang_bang/common.yaml index 28798f8173..5882025191 100644 --- a/tests/components/bang_bang/common.yaml +++ b/tests/components/bang_bang/common.yaml @@ -1,6 +1,6 @@ switch: - platform: template - id: bang_bang_template_switch1 + id: template_switch1 optimistic: true - platform: template id: template_switch2 @@ -8,7 +8,7 @@ switch: sensor: - platform: template - id: bang_bang_template_sensor1 + id: template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -20,16 +20,16 @@ sensor: climate: - platform: bang_bang name: Bang Bang Climate - sensor: bang_bang_template_sensor1 - humidity_sensor: bang_bang_template_sensor1 + sensor: template_sensor1 + humidity_sensor: template_sensor1 default_target_temperature_low: 18°C default_target_temperature_high: 24°C idle_action: - - switch.turn_on: bang_bang_template_switch1 + - switch.turn_on: template_switch1 cool_action: - switch.turn_on: template_switch2 heat_action: - - switch.turn_on: bang_bang_template_switch1 + - switch.turn_on: template_switch1 away_config: default_target_temperature_low: 16°C default_target_temperature_high: 20°C diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 4f4cf6ea59..e3fd159b08 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -1,7 +1,7 @@ binary_sensor: - platform: template trigger_on_initial_state: true - id: binary_sensor_some_binary_sensor + id: some_binary_sensor name: "Random binary" lambda: return (random_uint32() & 1) == 0; filters: @@ -21,7 +21,7 @@ binary_sensor: time_off: 100ms time_on: 400ms - lambda: |- - if (id(binary_sensor_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return x; } return {}; @@ -36,7 +36,7 @@ binary_sensor: - logger.log: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor + - binary_sensor.invalidate_state: some_binary_sensor # Test autorepeat with default configuration (no timings) - platform: template diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index 667d0be9e7..c054022583 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -1,20 +1,20 @@ binary_sensor: - platform: template - id: binary_sensor_map_bin1 + id: bin1 lambda: |- if (millis() > 10000) { return true; } return false; - platform: template - id: binary_sensor_map_bin2 + id: bin2 lambda: |- if (millis() > 20000) { return true; } return false; - platform: template - id: binary_sensor_map_bin3 + id: bin3 lambda: |- if (millis() > 30000) { return true; @@ -26,33 +26,33 @@ sensor: name: Binary Sensor Map Group type: group channels: - - binary_sensor: binary_sensor_map_bin1 + - binary_sensor: bin1 value: 10.0 - - binary_sensor: binary_sensor_map_bin2 + - binary_sensor: bin2 value: 15.0 - - binary_sensor: binary_sensor_map_bin3 + - binary_sensor: bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Sum type: sum channels: - - binary_sensor: binary_sensor_map_bin1 + - binary_sensor: bin1 value: 10.0 - - binary_sensor: binary_sensor_map_bin2 + - binary_sensor: bin2 value: 15.0 - - binary_sensor: binary_sensor_map_bin3 + - binary_sensor: bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Bayesian type: bayesian prior: 0.4 observations: - - binary_sensor: binary_sensor_map_bin1 + - binary_sensor: bin1 prob_given_true: 0.9 prob_given_false: 0.4 - - binary_sensor: binary_sensor_map_bin2 + - binary_sensor: bin2 prob_given_true: 0.7 prob_given_false: 0.05 - - binary_sensor: binary_sensor_map_bin3 + - binary_sensor: bin3 prob_given_true: 0.8 prob_given_false: 0.2 diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index 4ed6ad7fc9..4ea1dd60f3 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -56,7 +56,7 @@ sensor: number: - platform: template name: "Test Number" - id: ble_client_test_number + id: test_number optimistic: true min_value: 0 max_value: 255 @@ -72,5 +72,5 @@ button: service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" value: !lambda |- - uint8_t val = (uint8_t)id(ble_client_test_number).state; + uint8_t val = (uint8_t)id(test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 3ba3564608..e779f7f078 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -1,6 +1,6 @@ canbus: - platform: esp32_can - id: canbus_esp32_internal_can + id: esp32_internal_can rx_pin: 4 tx_pin: 5 can_id: 4 @@ -40,7 +40,7 @@ canbus: number: - platform: template name: "Test Number" - id: canbus_test_number + id: test_number optimistic: true min_value: 0 max_value: 255 @@ -62,5 +62,5 @@ button: - canbus.send: !lambda return {0, 1, 2}; # Test canbus.send with lambda that references a component (function pointer) - canbus.send: !lambda |- - uint8_t val = (uint8_t)id(canbus_test_number).state; + uint8_t val = (uint8_t)id(test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index e0bc185d2c..37011b16ee 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,6 +1,6 @@ sensor: - platform: template - id: climate_ir_lg_temp_sensor + id: temp_sensor lambda: return 22.0; update_interval: 60s - platform: template @@ -12,5 +12,5 @@ climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr - sensor: climate_ir_lg_temp_sensor + sensor: temp_sensor humidity_sensor: humidity_sensor diff --git a/tests/components/color_temperature/common.yaml b/tests/components/color_temperature/common.yaml index 0db54d10d0..fe0c5bf917 100644 --- a/tests/components/color_temperature/common.yaml +++ b/tests/components/color_temperature/common.yaml @@ -1,15 +1,15 @@ output: - platform: ${light_platform} - id: color_temperature_light_output_1 + id: light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: color_temperature_light_output_2 + id: light_output_2 pin: ${pin_o2} light: - platform: color_temperature name: Lights - color_temperature: color_temperature_light_output_1 - brightness: color_temperature_light_output_2 + color_temperature: light_output_1 + brightness: light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index cbd056f070..a376004b2f 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -1,17 +1,17 @@ output: - platform: ${pwm_platform} - id: copy_fan_output_1 + id: fan_output_1 pin: ${pin} fan: - platform: speed - id: copy_fan_speed - output: copy_fan_output_1 + id: fan_speed + output: fan_output_1 preset_modes: - Eco - Turbo - platform: copy - source_id: copy_fan_speed + source_id: fan_speed name: Fan Speed Copy select: diff --git a/tests/components/current_based/common.yaml b/tests/components/current_based/common.yaml index 139571ccec..503c4596e9 100644 --- a/tests/components/current_based/common.yaml +++ b/tests/components/current_based/common.yaml @@ -31,7 +31,7 @@ sensor: switch: - platform: template - id: current_based_template_switch1 + id: template_switch1 optimistic: true - platform: template id: template_switch2 @@ -46,7 +46,7 @@ cover: open_obstacle_current_threshold: 0.8 open_duration: 12s open_action: - - switch.turn_on: current_based_template_switch1 + - switch.turn_on: template_switch1 close_sensor: ade7953_current_b close_moving_current_threshold: 0.5 close_obstacle_current_threshold: 0.8 @@ -54,7 +54,7 @@ cover: close_action: - switch.turn_on: template_switch2 stop_action: - - switch.turn_off: current_based_template_switch1 + - switch.turn_off: template_switch1 - switch.turn_off: template_switch2 obstacle_rollback: 30% start_sensing_delay: 0.8s diff --git a/tests/components/cwww/common.yaml b/tests/components/cwww/common.yaml index bbb6c9182b..7fa5ab668c 100644 --- a/tests/components/cwww/common.yaml +++ b/tests/components/cwww/common.yaml @@ -1,8 +1,8 @@ light: - platform: cwww name: CWWW Light - cold_white: cwww_light_output_1 - warm_white: cwww_light_output_2 + cold_white: light_output_1 + warm_white: light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds constant_brightness: true diff --git a/tests/components/cwww/test.esp32-idf.yaml b/tests/components/cwww/test.esp32-idf.yaml index 0665879b08..01edf0b0b5 100644 --- a/tests/components/cwww/test.esp32-idf.yaml +++ b/tests/components/cwww/test.esp32-idf.yaml @@ -5,11 +5,11 @@ substitutions: output: - platform: ${light_platform} - id: cwww_light_output_1 + id: light_output_1 pin: ${pin_o1} channel: 0 - platform: ${light_platform} - id: cwww_light_output_2 + id: light_output_2 pin: ${pin_o2} channel: 1 phase_angle: 180° diff --git a/tests/components/cwww/test.esp8266-ard.yaml b/tests/components/cwww/test.esp8266-ard.yaml index bb1868fdef..49d73b7d3d 100644 --- a/tests/components/cwww/test.esp8266-ard.yaml +++ b/tests/components/cwww/test.esp8266-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: cwww_light_output_1 + id: light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: cwww_light_output_2 + id: light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/cwww/test.rp2040-ard.yaml b/tests/components/cwww/test.rp2040-ard.yaml index 27fc930b68..ba8e0ad071 100644 --- a/tests/components/cwww/test.rp2040-ard.yaml +++ b/tests/components/cwww/test.rp2040-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: cwww_light_output_1 + id: light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: cwww_light_output_2 + id: light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/display/common.yaml b/tests/components/display/common.yaml index 6617671972..a722a5f7c2 100644 --- a/tests/components/display/common.yaml +++ b/tests/components/display/common.yaml @@ -1,6 +1,6 @@ display: - platform: ili9xxx - id: display_main_lcd + id: main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 12e4397c49..761d10f16a 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: duty_time_bin1 + id: bin1 lambda: |- if (millis() > 10000) { return true; @@ -10,4 +10,4 @@ binary_sensor: sensor: - platform: duty_time name: Duty Time - sensor: duty_time_bin1 + sensor: bin1 diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 4593784ef9..25fe3b6796 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -2,7 +2,7 @@ light: - platform: rp2040_pio_led_strip - id: e131_led_strip + id: led_strip pin: 2 pio: 0 num_leds: 256 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 070b03eeb9..1c4d768b08 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ektf2232_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -15,7 +15,7 @@ touchscreen: id: ektf2232_touchscreen interrupt_pin: ${interrupt_pin} reset_pin: ${touch_reset_pin} - display: ektf2232_ssd1306_i2c_display + display: ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/endstop/common.yaml b/tests/components/endstop/common.yaml index 6f5cf61268..b92b1e13b9 100644 --- a/tests/components/endstop/common.yaml +++ b/tests/components/endstop/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: endstop_bin1 + id: bin1 lambda: |- if (millis() > 10000) { return true; @@ -9,7 +9,7 @@ binary_sensor: switch: - platform: template - id: endstop_template_switch1 + id: template_switch1 optimistic: true - platform: template id: template_switch2 @@ -20,12 +20,12 @@ cover: id: endstop_cover name: Endstop Cover stop_action: - - switch.turn_on: endstop_template_switch1 - open_endstop: endstop_bin1 + - switch.turn_on: template_switch1 + open_endstop: bin1 open_action: - - switch.turn_on: endstop_template_switch1 + - switch.turn_on: template_switch1 open_duration: 5min - close_endstop: endstop_bin1 + close_endstop: bin1 close_action: - switch.turn_on: template_switch2 close_duration: 4.5min diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index f15b609d84..3b9b33c048 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -13,7 +13,7 @@ esphome: canbus: - platform: esp32_can - id: esp32_can_esp32_internal_can + id: esp32_internal_can rx_pin: ${rx_pin} tx_pin: ${tx_pin} can_id: 4 diff --git a/tests/components/esp32_can/test.esp32-c6-idf.yaml b/tests/components/esp32_can/test.esp32-c6-idf.yaml index c548b4f0f4..ac978482fc 100644 --- a/tests/components/esp32_can/test.esp32-c6-idf.yaml +++ b/tests/components/esp32_can/test.esp32-c6-idf.yaml @@ -3,20 +3,20 @@ esphome: then: - canbus.send: # Extended ID explicit - canbus_id: esp32_can_esp32_internal_can + canbus_id: esp32_internal_can use_extended_id: true can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - canbus.send: # Standard ID by default - canbus_id: esp32_can_esp32_internal_can + canbus_id: esp32_internal_can can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] # Note: esp32_internal_can_2 uses LISTENONLY mode, so no send actions canbus: - platform: esp32_can - id: esp32_can_esp32_internal_can + id: esp32_internal_can rx_pin: GPIO8 tx_pin: GPIO7 can_id: 4 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index f05735e8f4..bdc478ea03 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: encryption: key: "0123456789abcdef0123456789abcdef" sensors: - - espnow_temp_sensor + - temp_sensor providers: - name: test-provider encryption: @@ -70,9 +70,9 @@ packet_transport: sensor: - platform: internal_temperature - id: espnow_temp_sensor + id: temp_sensor - platform: packet_transport provider: test-provider - remote_id: espnow_temp_sensor + remote_id: temp_sensor id: remote_temp diff --git a/tests/components/fastled_clockless/common.yaml b/tests/components/fastled_clockless/common.yaml index a7ce7ed280..8b1447a17a 100644 --- a/tests/components/fastled_clockless/common.yaml +++ b/tests/components/fastled_clockless/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_clockless - id: fastled_clockless_addr1 + id: addr1 chipset: WS2811 pin: 13 num_leds: 100 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: fastled_clockless_addr1 + id: addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: fastled_clockless_addr1 + id: addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/fastled_spi/common.yaml b/tests/components/fastled_spi/common.yaml index 19d00627f8..f6f7c5553b 100644 --- a/tests/components/fastled_spi/common.yaml +++ b/tests/components/fastled_spi/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_spi - id: fastled_spi_addr1 + id: addr1 chipset: WS2801 clock_pin: 22 data_pin: 23 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: fastled_spi_addr1 + id: addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: fastled_spi_addr1 + id: addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/font/common.yaml b/tests/components/font/common.yaml index 59063291e7..c156b4aea1 100644 --- a/tests/components/font/common.yaml +++ b/tests/components/font/common.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: font_roboto + id: roboto size: 20 glyphs: "0123456789." extras: @@ -50,11 +50,11 @@ font: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: font_ssd1306_display + id: ssd1306_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} lambda: |- - it.print(0, 0, id(font_roboto), "Hello, World!"); + it.print(0, 0, id(roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(monocraft), "Hello, World!"); it.print(0, 60, id(monocraft2), "Hello, World!"); diff --git a/tests/components/font/test.host.yaml b/tests/components/font/test.host.yaml index 8ada8b7a4e..387ea47335 100644 --- a/tests/components/font/test.host.yaml +++ b/tests/components/font/test.host.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: font_roboto + id: roboto size: 20 glyphs: "0123456789." extras: @@ -44,12 +44,12 @@ font: display: - platform: sdl - id: font_sdl_display + id: sdl_display dimensions: width: 800 height: 600 lambda: |- - it.print(0, 0, id(font_roboto), "Hello, World!"); + it.print(0, 0, id(roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(roboto_greek), "Hello κόσμε!"); it.print(0, 60, id(monocraft), "Hello, World!"); diff --git a/tests/components/graph/common.yaml b/tests/components/graph/common.yaml index edf4493aa6..11e2a16ca1 100644 --- a/tests/components/graph/common.yaml +++ b/tests/components/graph/common.yaml @@ -12,7 +12,7 @@ graph: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: graph_ssd1306_display + id: ssd1306_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: diff --git a/tests/components/graphical_display_menu/common.yaml b/tests/components/graphical_display_menu/common.yaml index 50f8a5bc85..6cee2af232 100644 --- a/tests/components/graphical_display_menu/common.yaml +++ b/tests/components/graphical_display_menu/common.yaml @@ -1,7 +1,6 @@ display: - platform: ssd1306_i2c - i2c_id: i2c_bus - id: graphical_display_menu_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -11,12 +10,12 @@ display: font: - file: "gfonts://Roboto" - id: graphical_display_menu_roboto + id: roboto size: 20 number: - platform: template - id: graphical_display_menu_test_number + id: test_number min_value: 0 step: 1 max_value: 10 @@ -32,13 +31,13 @@ select: switch: - platform: template - id: graphical_display_menu_test_switch + id: test_switch optimistic: true graphical_display_menu: id: test_graphical_display_menu - display: graphical_display_menu_ssd1306_i2c_display - font: graphical_display_menu_roboto + display: ssd1306_i2c_display + font: roboto active: false mode: rotary on_enter: @@ -81,7 +80,7 @@ graphical_display_menu: lambda: 'ESP_LOGI("graphical_display_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: "Number" - number: graphical_display_menu_test_number + number: test_number on_enter: then: lambda: 'ESP_LOGI("graphical_display_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' @@ -98,7 +97,7 @@ graphical_display_menu: - display_menu.hide: test_graphical_display_menu - type: switch text: "Switch" - switch: graphical_display_menu_test_switch + switch: test_switch on_text: "Bright" off_text: "Dark" immediate_edit: false diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index 0fc40737f0..ff464cda24 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: gt911_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: gt911_ssd1306_i2c_display + display: ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/image/test.esp32-idf.yaml b/tests/components/image/test.esp32-idf.yaml index 9e93c4c289..aea2b4bbb0 100644 --- a/tests/components/image/test.esp32-idf.yaml +++ b/tests/components/image/test.esp32-idf.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: image_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 15 diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 492b57c449..2e7bfc5ae5 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: image_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/image/test.rp2040-ard.yaml b/tests/components/image/test.rp2040-ard.yaml index ce2a13fca7..03a9c42a38 100644 --- a/tests/components/image/test.rp2040-ard.yaml +++ b/tests/components/image/test.rp2040-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: image_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/integration/common-esp32.yaml b/tests/components/integration/common-esp32.yaml index c912fb9b84..26550d3c5c 100644 --- a/tests/components/integration/common-esp32.yaml +++ b/tests/components/integration/common-esp32.yaml @@ -9,11 +9,11 @@ esphome: sensor: - platform: adc - id: integration_my_sensor + id: my_sensor pin: ${pin} attenuation: 12db - platform: integration id: integration_sensor - sensor: integration_my_sensor + sensor: my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.esp8266-ard.yaml b/tests/components/integration/test.esp8266-ard.yaml index 377bad5578..51d3e19077 100644 --- a/tests/components/integration/test.esp8266-ard.yaml +++ b/tests/components/integration/test.esp8266-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: integration_my_sensor + id: my_sensor pin: VCC - platform: integration - sensor: integration_my_sensor + sensor: my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.rp2040-ard.yaml b/tests/components/integration/test.rp2040-ard.yaml index 377bad5578..51d3e19077 100644 --- a/tests/components/integration/test.rp2040-ard.yaml +++ b/tests/components/integration/test.rp2040-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: integration_my_sensor + id: my_sensor pin: VCC - platform: integration - sensor: integration_my_sensor + sensor: my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/lcd_menu/common.yaml b/tests/components/lcd_menu/common.yaml index a7740e771d..970c18e0d2 100644 --- a/tests/components/lcd_menu/common.yaml +++ b/tests/components/lcd_menu/common.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: lcd_menu_test_number + id: test_number min_value: 0 step: 1 max_value: 10 @@ -83,7 +83,7 @@ lcd_menu: lambda: 'ESP_LOGI("lcd_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: Number - number: lcd_menu_test_number + number: test_number on_enter: then: lambda: 'ESP_LOGI("lcd_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 71c00e5f10..2acc080c6d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -156,7 +156,7 @@ light: - platform: binary id: test_binary_light name: Binary Light - output: light_test_binary + output: test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 49e49b4318..925197182c 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: light_test_binary + id: test_binary pin: 12 - platform: ledc id: test_ledc_1 diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 1eb58eabc4..518011e925 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: light_test_binary + id: test_binary pin: 4 - platform: esp8266_pwm id: test_ledc_1 diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index 60521b8088..cb421ed4bb 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: light_test_binary + id: test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: light_test_binary + output: test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index 60521b8088..cb421ed4bb 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: light_test_binary + id: test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: light_test_binary + output: test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index 21d5cad774..a5a37fd559 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: light_test_binary + id: test_binary pin: 0 - platform: rp2040_pwm id: test_ledc_1 diff --git a/tests/components/lilygo_t5_47/common.yaml b/tests/components/lilygo_t5_47/common.yaml index 5e71736eb0..18f1ba10ae 100644 --- a/tests/components/lilygo_t5_47/common.yaml +++ b/tests/components/lilygo_t5_47/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: lilygo_t5_47_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -14,7 +14,7 @@ touchscreen: i2c_id: i2c_bus id: lilygo_touchscreen interrupt_pin: ${interrupt_pin} - display: lilygo_t5_47_ssd1306_i2c_display + display: ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 08001855cb..9ba7f34857 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -7,7 +7,7 @@ esphome: output: - platform: gpio - id: lock_test_binary + id: test_binary pin: 4 lock: @@ -32,4 +32,4 @@ lock: - platform: output name: Generic Output Lock id: test_lock2 - output: lock_test_binary + output: test_binary diff --git a/tests/components/mapping/test.esp32-idf.yaml b/tests/components/mapping/test.esp32-idf.yaml index d99f8ddc4e..93adcf9988 100644 --- a/tests/components/mapping/test.esp32-idf.yaml +++ b/tests/components/mapping/test.esp32-idf.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: mapping_main_lcd + id: main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/mapping/test.esp8266-ard.yaml b/tests/components/mapping/test.esp8266-ard.yaml index e51240f20d..6a308b67dd 100644 --- a/tests/components/mapping/test.esp8266-ard.yaml +++ b/tests/components/mapping/test.esp8266-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: mapping_main_lcd + id: main_lcd model: ili9342 cs_pin: 5 dc_pin: 15 diff --git a/tests/components/mapping/test.rp2040-ard.yaml b/tests/components/mapping/test.rp2040-ard.yaml index 0562a4ba51..01b83c4ab8 100644 --- a/tests/components/mapping/test.rp2040-ard.yaml +++ b/tests/components/mapping/test.rp2040-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: mapping_main_lcd + id: main_lcd model: ili9342 data_rate: 31.25MHz cs_pin: 20 diff --git a/tests/components/monochromatic/common.yaml b/tests/components/monochromatic/common.yaml index e57c7bec29..9915e086eb 100644 --- a/tests/components/monochromatic/common.yaml +++ b/tests/components/monochromatic/common.yaml @@ -1,13 +1,13 @@ output: - platform: ${light_platform} - id: monochromatic_light_output_1 + id: light_output_1 pin: ${pin} light: - platform: monochromatic name: Monochromatic Light id: monochromatic_light - output: monochromatic_light_output_1 + output: light_output_1 gamma_correct: 2.8 default_transition_length: 2s effects: diff --git a/tests/components/mpr121/common.yaml b/tests/components/mpr121/common.yaml index f96651e9bf..67a06cf9c1 100644 --- a/tests/components/mpr121/common.yaml +++ b/tests/components/mpr121/common.yaml @@ -9,15 +9,15 @@ binary_sensor: name: touchkey0 channel: 0 - platform: mpr121 - id: mpr121_bin1 + id: bin1 name: touchkey1 channel: 1 - platform: mpr121 - id: mpr121_bin2 + id: bin2 name: touchkey2 channel: 2 - platform: mpr121 - id: mpr121_bin3 + id: bin3 name: touchkey3 channel: 6 diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 9eadd97a6d..d79e3ee2ed 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -1,6 +1,6 @@ esphome: on_boot: - - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(nextion_main_lcd).is_connected()));' + - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(main_lcd).is_connected()));' - display.nextion.set_brightness: 80% @@ -272,7 +272,7 @@ text_sensor: display: - platform: nextion - id: nextion_main_lcd + id: main_lcd auto_wake_on_touch: true brightness: 80% command_spacing: 5ms diff --git a/tests/components/nextion/common_tft_upload.yaml b/tests/components/nextion/common_tft_upload.yaml index 70a0809883..190abbc7b1 100644 --- a/tests/components/nextion/common_tft_upload.yaml +++ b/tests/components/nextion/common_tft_upload.yaml @@ -1,5 +1,5 @@ display: - - id: !extend nextion_main_lcd + - id: !extend main_lcd tft_url: http://esphome.io/default35.tft tft_upload_http_timeout: 20s tft_upload_http_retries: 10 diff --git a/tests/components/nextion/common_tft_upload_watchdog.yaml b/tests/components/nextion/common_tft_upload_watchdog.yaml index f0b44ce8c3..385fee359e 100644 --- a/tests/components/nextion/common_tft_upload_watchdog.yaml +++ b/tests/components/nextion/common_tft_upload_watchdog.yaml @@ -1,3 +1,3 @@ display: - - id: !extend nextion_main_lcd + - id: !extend main_lcd tft_upload_watchdog_timeout: 30s diff --git a/tests/components/ntc/common.yaml b/tests/components/ntc/common.yaml index 1be2c335bc..79ae7f601d 100644 --- a/tests/components/ntc/common.yaml +++ b/tests/components/ntc/common.yaml @@ -1,23 +1,23 @@ sensor: - platform: adc - id: ntc_my_sensor + id: my_sensor pin: ${pin} - platform: resistance - sensor: ntc_my_sensor + sensor: my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: ntc_resist + id: resist - platform: ntc - sensor: ntc_resist + sensor: resist name: NTC Sensor calibration: b_constant: 3950 reference_resistance: 10k reference_temperature: 25°C - platform: ntc - sensor: ntc_resist + sensor: resist name: NTC Sensor2 calibration: - 10.0kOhm -> 25°C diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index b1a16ebfed..c17c2dd5f8 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Test Number" - id: number_test_number + id: test_number optimistic: true min_value: 0 max_value: 100 @@ -10,4 +10,4 @@ number: sensor: - platform: number name: "Test Number Value" - source_id: number_test_number + source_id: test_number diff --git a/tests/components/online_image/common-esp32.yaml b/tests/components/online_image/common-esp32.yaml index ee4c1ed0b8..32c909d351 100644 --- a/tests/components/online_image/common-esp32.yaml +++ b/tests/components/online_image/common-esp32.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/common-esp8266.yaml b/tests/components/online_image/common-esp8266.yaml index fc61aad92e..d7722d171a 100644 --- a/tests/components/online_image/common-esp8266.yaml +++ b/tests/components/online_image/common-esp8266.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 15 dc_pin: 3 diff --git a/tests/components/online_image/common-rp2040.yaml b/tests/components/online_image/common-rp2040.yaml index 4d2785f3e8..bbb514bded 100644 --- a/tests/components/online_image/common-rp2040.yaml +++ b/tests/components/online_image/common-rp2040.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 data_rate: 20MHz cs_pin: 20 diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml index 9972a673c0..9116fd86e0 100644 --- a/tests/components/online_image/test.esp32-s3-ard.yaml +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml index 1f1485fd6c..f219f71ee2 100644 --- a/tests/components/online_image/test.esp32-s3-idf.yaml +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index df20dcde2b..81d802e9bf 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -1,19 +1,19 @@ esphome: on_boot: then: - - output.turn_off: output_light_output_1 - - output.turn_on: output_light_output_1 + - output.turn_off: light_output_1 + - output.turn_on: light_output_1 - output.set_level: - id: output_light_output_1 + id: light_output_1 level: 50% - output.set_min_power: - id: output_light_output_1 + id: light_output_1 min_power: 20% - output.set_max_power: - id: output_light_output_1 + id: light_output_1 max_power: 80% output: - platform: ${output_platform} - id: output_light_output_1 + id: light_output_1 pin: ${pin} diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index aeda76d35c..77a77fa3e4 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -9,7 +9,7 @@ pi4ioe5v6408: switch: - platform: gpio - id: pi4ioe5v6408_switch1 + id: switch1 pin: pi4ioe5v6408: pi4ioe1 number: 0 diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 320e5f775f..262e75591e 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -23,7 +23,7 @@ output: sensor: - platform: template - id: pid_template_sensor1 + id: template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -35,8 +35,8 @@ climate: - platform: pid id: pid_climate name: PID Climate Controller - sensor: pid_template_sensor1 - humidity_sensor: pid_template_sensor1 + sensor: template_sensor1 + humidity_sensor: template_sensor1 default_target_temperature: 21°C heat_output: pid_slow_pwm control_parameters: diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index 951d8f7fc5..7ff416dccb 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -31,7 +31,7 @@ update: sensor: - platform: template - id: prometheus_template_sensor1 + id: template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -91,7 +91,7 @@ binary_sensor: switch: - platform: template - id: prometheus_template_switch1 + id: template_switch1 lambda: |- if (millis() > 10000) { return true; @@ -185,7 +185,7 @@ climate: prometheus: include_internal: true relabel: - prometheus_template_sensor1: + template_sensor1: id: hellow_world name: Hello World template_text_sensor1: diff --git a/tests/components/qspi_dbi/common.yaml b/tests/components/qspi_dbi/common.yaml index 0eadfa7392..109db65b63 100644 --- a/tests/components/qspi_dbi/common.yaml +++ b/tests/components/qspi_dbi/common.yaml @@ -16,7 +16,7 @@ display: - platform: qspi_dbi model: CUSTOM - id: qspi_dbi_main_lcd + id: main_lcd draw_from_origin: true dimensions: height: 240 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 5631c48f95..c6c7049605 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: remote_transmitter_test_number + id: test_number optimistic: true min_value: 0 max_value: 255 @@ -151,7 +151,7 @@ button: on_press: remote_transmitter.transmit_raw: code: !lambda |- - return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; + return {(int32_t)id(test_number).state * 100, -1000}; - platform: template name: AEHA id: eaha_hitachi_climate_power_on @@ -253,7 +253,7 @@ button: destination_address: 0x5678 message_type: 0x01 data: !lambda |- - return {(uint8_t)id(remote_transmitter_test_number).state, 0x20, 0x30}; + return {(uint8_t)id(test_number).state, 0x20, 0x30}; - platform: template name: Digital Write on_press: diff --git a/tests/components/resistance/common.yaml b/tests/components/resistance/common.yaml index 8966b574df..b3eec49548 100644 --- a/tests/components/resistance/common.yaml +++ b/tests/components/resistance/common.yaml @@ -1,11 +1,11 @@ sensor: - platform: adc - id: resistance_my_sensor + id: my_sensor pin: ${pin} - platform: resistance - sensor: resistance_my_sensor + sensor: my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resistance_resist + id: resist diff --git a/tests/components/rgb/common.yaml b/tests/components/rgb/common.yaml index bd72abbd17..9f25efa431 100644 --- a/tests/components/rgb/common.yaml +++ b/tests/components/rgb/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgb_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgb_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -13,6 +13,6 @@ light: - platform: rgb name: RGB Light id: rgb_light - red: rgb_light_output_1 - green: rgb_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 diff --git a/tests/components/rgbct/common.yaml b/tests/components/rgbct/common.yaml index 46d8082706..65bb248e95 100644 --- a/tests/components/rgbct/common.yaml +++ b/tests/components/rgbct/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgbct_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgbct_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbct name: RGBCT Light - red: rgbct_light_output_1 - green: rgbct_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 color_temperature: light_output_4 white_brightness: light_output_5 diff --git a/tests/components/rgbw/common.yaml b/tests/components/rgbw/common.yaml index 4a8e56a255..b0f44869d3 100644 --- a/tests/components/rgbw/common.yaml +++ b/tests/components/rgbw/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgbw_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgbw_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -15,8 +15,8 @@ output: light: - platform: rgbw name: RGBW Light - red: rgbw_light_output_1 - green: rgbw_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 white: light_output_4 color_interlock: true diff --git a/tests/components/rgbww/common.yaml b/tests/components/rgbww/common.yaml index bb1d73b3bc..0013960c10 100644 --- a/tests/components/rgbww/common.yaml +++ b/tests/components/rgbww/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgbww_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgbww_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbww name: RGBWW Light - red: rgbww_light_output_1 - green: rgbww_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 cold_white: light_output_4 warm_white: light_output_5 diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 254ac0e13d..b9b1436cdb 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -1,6 +1,6 @@ light: - platform: rp2040_pio_led_strip - id: rp2040_pio_led_strip_led_strip + id: led_strip pin: 4 num_leds: 60 pio: 0 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 2970a48afb..45c039106f 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,7 +1,7 @@ output: - platform: rp2040_pwm - id: rp2040_pwm_light_output_1 + id: light_output_1 pin: 2 - platform: rp2040_pwm - id: rp2040_pwm_light_output_2 + id: light_output_2 pin: 3 diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index 3be86cf8be..d3d3c9ee5e 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -3,7 +3,7 @@ host: display: - platform: sdl - id: sdl_sdl_display + id: sdl_display update_interval: 1s auto_clear_enabled: false show_test_card: true @@ -35,14 +35,14 @@ display: binary_sensor: - platform: sdl - sdl_id: sdl_sdl_display + sdl_id: sdl_display id: key_up key: SDLK_UP - platform: sdl - sdl_id: sdl_sdl_display + sdl_id: sdl_display id: key_down key: SDLK_DOWN - platform: sdl - sdl_id: sdl_sdl_display + sdl_id: sdl_display id: key_enter key: SDLK_RETURN diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 96f459c53f..895f4b4b8f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Speaker Number" - id: speaker_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -46,7 +46,7 @@ button: - speaker.play: id: speaker_id data: !lambda |- - return {0x01, 0x02, (uint8_t)id(speaker_my_number).state}; + return {0x01, 0x02, (uint8_t)id(my_number).state}; speaker: - platform: i2s_audio diff --git a/tests/components/speed/common.yaml b/tests/components/speed/common.yaml index 70c91259ba..be8172af7e 100644 --- a/tests/components/speed/common.yaml +++ b/tests/components/speed/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${output_platform} - id: speed_fan_output_1 + id: fan_output_1 pin: ${pin} fan: - platform: speed - id: speed_fan_speed - output: speed_fan_output_1 + id: fan_speed + output: fan_output_1 diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index dbe109f524..f099f77729 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -34,7 +34,7 @@ esphome: switch: - platform: template - id: sprinkler_switch1 + id: switch1 optimistic: true - platform: template id: switch2 @@ -52,17 +52,17 @@ sprinkler: valves: - valve_switch: Yard Valve 0 enable_switch: Enable Yard Valve 0 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 1 enable_switch: Enable Yard Valve 1 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 2 enable_switch: Enable Yard Valve 2 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - id: garden_sprinkler_ctrlr @@ -73,11 +73,11 @@ sprinkler: valves: - valve_switch: Garden Valve 0 enable_switch: Enable Garden Valve 0 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Garden Valve 1 enable_switch: Enable Garden Valve 1 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 diff --git a/tests/components/ssd1306_i2c/common.yaml b/tests/components/ssd1306_i2c/common.yaml index b3b8ad85dc..09eb569a8e 100644 --- a/tests/components/ssd1306_i2c/common.yaml +++ b/tests/components/ssd1306_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1306_128X64 reset_pin: ${reset_pin} address: 0x3C - id: ssd1306_i2c_ssd1306_i2c_display + id: ssd1306_i2c_display contrast: 60% pages: - id: ssd1306_i2c_page1 diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index 3ea235cfb9..afdf26c150 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: switch - id: switch_some_binary_sensor + id: some_binary_sensor name: "Template Switch State" source_id: the_switch diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index a4a24d8da7..659550cc01 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -29,7 +29,7 @@ sx126x: number: - platform: template name: "SX126x Number" - id: sx126x_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -47,4 +47,4 @@ button: - sx126x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(sx126x_my_number).state}; + return {0x01, 0x02, (uint8_t)id(my_number).state}; diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index b7eadc084f..6e48952fcc 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -29,7 +29,7 @@ sx127x: number: - platform: template name: "SX127x Number" - id: sx127x_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -48,4 +48,4 @@ button: - sx127x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; + return {0x01, 0x02, (uint8_t)id(my_number).state}; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index f1387a7afe..d3985a848b 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -52,7 +52,7 @@ esphome: binary_sensor: - platform: template - id: template_some_binary_sensor + id: some_binary_sensor name: "Garage Door Open" lambda: |- if (id(template_sens).state > 30) { @@ -108,7 +108,7 @@ sensor: name: "Template Sensor" id: template_sens lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return 42.0; } return 0.0; @@ -230,7 +230,7 @@ switch: id: test_switch name: "Template Switch" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return true; } return false; @@ -249,7 +249,7 @@ cover: - platform: template name: "Template Cover" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -264,7 +264,7 @@ cover: name: "Template Cover with Triggers" id: template_cover_with_triggers lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -442,7 +442,7 @@ lock: - platform: template name: "Template Lock" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return LOCK_STATE_LOCKED; } return LOCK_STATE_UNLOCKED; @@ -458,7 +458,7 @@ valve: id: template_valve name: "Template Valve" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return VALVE_OPEN; } return VALVE_CLOSED; diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 1f9249f1ba..56089aed1e 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: tt21100_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${disp_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: tt21100_ssd1306_i2c_display + display: ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index c805188005..fa76316b9c 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -79,7 +79,7 @@ switch: number: - platform: template name: "Test Number" - id: uart_test_number + id: test_number optimistic: true min_value: 0 max_value: 100 @@ -103,7 +103,7 @@ button: - uart.write: id: uart_id data: !lambda |- - std::string cmd = "VALUE=" + str_sprintf("%.0f", id(uart_test_number).state) + "\r\n"; + std::string cmd = "VALUE=" + str_sprintf("%.0f", id(test_number).state) + "\r\n"; return std::vector(cmd.begin(), cmd.end()); event: diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index 6824c5cca8..a40ca455cb 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -24,7 +24,7 @@ udp: number: - platform: template name: "UDP Number" - id: udp_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -38,4 +38,4 @@ button: - udp.write: data: [0x01, 0x02, 0x03] - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; + return {0x10, 0x20, (uint8_t)id(my_number).state}; diff --git a/tests/components/ufire_ec/common.yaml b/tests/components/ufire_ec/common.yaml index 2365b7a368..4260f0ab4c 100644 --- a/tests/components/ufire_ec/common.yaml +++ b/tests/components/ufire_ec/common.yaml @@ -4,18 +4,18 @@ esphome: - ufire_ec.calibrate_probe: id: ufire_ec_board solution: 0.146 - temperature: !lambda "return id(ufire_ec_test_sensor).state;" + temperature: !lambda "return id(test_sensor).state;" - ufire_ec.reset: sensor: - platform: template - id: ufire_ec_test_sensor + id: test_sensor lambda: "return 21;" - platform: ufire_ec i2c_id: i2c_bus id: ufire_ec_board ec: name: Ufire EC - temperature_sensor: ufire_ec_test_sensor + temperature_sensor: test_sensor temperature_compensation: 20.0 temperature_coefficient: 0.019 diff --git a/tests/components/ufire_ise/common.yaml b/tests/components/ufire_ise/common.yaml index 478c75ad37..f7865ea87b 100644 --- a/tests/components/ufire_ise/common.yaml +++ b/tests/components/ufire_ise/common.yaml @@ -11,11 +11,11 @@ esphome: sensor: - platform: template - id: ufire_ise_test_sensor + id: test_sensor lambda: "return 21;" - platform: ufire_ise i2c_id: i2c_bus id: ufire_ise_sensor - temperature_sensor: ufire_ise_test_sensor + temperature_sensor: test_sensor ph: name: Ufire pH diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml index cfba0060d9..b1885af266 100644 --- a/tests/components/web_server_idf/common.yaml +++ b/tests/components/web_server_idf/common.yaml @@ -12,7 +12,7 @@ network: sensor: - platform: template name: "Test Sensor" - id: web_server_idf_test_sensor + id: test_sensor update_interval: 60s lambda: "return 42.5;" @@ -25,5 +25,5 @@ binary_sensor: switch: - platform: template name: "Test Switch" - id: web_server_idf_test_switch + id: test_switch optimistic: true diff --git a/tests/components/wk2132_i2c/common.yaml b/tests/components/wk2132_i2c/common.yaml index 93bb17b38f..39013baeb2 100644 --- a/tests/components/wk2132_i2c/common.yaml +++ b/tests/components/wk2132_i2c/common.yaml @@ -16,4 +16,4 @@ wk2132_i2c: sensor: - platform: a02yyuw uart_id: wk2132_id_1 - id: wk2132_i2c_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2132_spi/common.yaml b/tests/components/wk2132_spi/common.yaml index 5ff48bc64c..18294974b9 100644 --- a/tests/components/wk2132_spi/common.yaml +++ b/tests/components/wk2132_spi/common.yaml @@ -17,4 +17,4 @@ wk2132_spi: sensor: - platform: a02yyuw uart_id: wk2132_spi_uart1 - id: wk2132_spi_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2168_i2c/common.yaml b/tests/components/wk2168_i2c/common.yaml index 1b2de74c02..49f0d1ec6b 100644 --- a/tests/components/wk2168_i2c/common.yaml +++ b/tests/components/wk2168_i2c/common.yaml @@ -23,7 +23,7 @@ wk2168_i2c: sensor: - platform: a02yyuw uart_id: wk2168_i2c_uart3 - id: wk2168_i2c_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2168_spi/common.yaml b/tests/components/wk2168_spi/common.yaml index a21a4a34d0..b402077aa3 100644 --- a/tests/components/wk2168_spi/common.yaml +++ b/tests/components/wk2168_spi/common.yaml @@ -23,7 +23,7 @@ wk2168_spi: sensor: - platform: a02yyuw uart_id: wk2168_spi_uart3 - id: wk2168_spi_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2204_i2c/common.yaml b/tests/components/wk2204_i2c/common.yaml index 55c67efd88..863633937b 100644 --- a/tests/components/wk2204_i2c/common.yaml +++ b/tests/components/wk2204_i2c/common.yaml @@ -24,4 +24,4 @@ wk2204_i2c: sensor: - platform: a02yyuw uart_id: wk2204_id_3 - id: wk2204_i2c_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2204_spi/common.yaml b/tests/components/wk2204_spi/common.yaml index ee00da22bb..0b62a7a009 100644 --- a/tests/components/wk2204_spi/common.yaml +++ b/tests/components/wk2204_spi/common.yaml @@ -25,4 +25,4 @@ wk2204_spi: sensor: - platform: a02yyuw uart_id: wk2204_spi_uart3 - id: wk2204_spi_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2212_i2c/common.yaml b/tests/components/wk2212_i2c/common.yaml index d48063bb4d..a754bec5c7 100644 --- a/tests/components/wk2212_i2c/common.yaml +++ b/tests/components/wk2212_i2c/common.yaml @@ -19,7 +19,7 @@ wk2212_i2c: sensor: - platform: a02yyuw uart_id: uart_i2c_id1 - id: wk2212_i2c_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2212_spi/common.yaml b/tests/components/wk2212_spi/common.yaml index d17db2f676..969f16bb12 100644 --- a/tests/components/wk2212_spi/common.yaml +++ b/tests/components/wk2212_spi/common.yaml @@ -17,7 +17,7 @@ wk2212_spi: sensor: - platform: a02yyuw uart_id: wk2212_spi_uart1 - id: wk2212_spi_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py deleted file mode 100644 index 9286380de1..0000000000 --- a/tests/script/test_merge_component_configs.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Unit tests for script/merge_component_configs.py deduplication.""" - -from pathlib import Path -import sys - -import pytest - -# Add the script directory to Python path so we can import the module -sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) - -import merge_component_configs # noqa: E402 - -deduplicate_by_id = merge_component_configs.deduplicate_by_id - - -def test_identical_duplicate_ids_collapse() -> None: - """Two identical items sharing an id collapse to one without error.""" - data = { - "sensor": [ - {"id": "shared", "platform": "template", "name": "A"}, - {"id": "shared", "platform": "template", "name": "A"}, - ] - } - result = deduplicate_by_id(data) - assert result["sensor"] == [{"id": "shared", "platform": "template", "name": "A"}] - - -def test_conflicting_duplicate_ids_raise() -> None: - """Two different items sharing an id is a hard error naming the id.""" - data = { - "sensor": [ - {"id": "dup", "platform": "template", "name": "A"}, - {"id": "dup", "platform": "template", "name": "B"}, - ] - } - with pytest.raises(ValueError, match="dup"): - deduplicate_by_id(data) - - -def test_intentionally_shared_id_does_not_raise() -> None: - """Allowlisted singleton ids may differ across components and collapse.""" - shared = next(iter(merge_component_configs.INTENTIONALLY_SHARED_IDS)) - data = { - "time": [ - {"id": shared, "platform": "sntp"}, - {"id": shared, "platform": "sntp", "servers": ["a"]}, - ] - } - result = deduplicate_by_id(data) - # First occurrence wins, no error raised - assert result["time"] == [{"id": shared, "platform": "sntp"}] - - -def test_items_without_id_are_preserved() -> None: - """Items lacking an id are passed through untouched.""" - data = {"binary_sensor": [{"platform": "gpio"}, {"platform": "gpio"}]} - result = deduplicate_by_id(data) - assert result["binary_sensor"] == [{"platform": "gpio"}, {"platform": "gpio"}] - - -def test_nested_lists_are_checked() -> None: - """Conflicts nested inside dict values are also detected.""" - data = { - "wrapper": { - "sensor": [ - {"id": "dup", "value": 1}, - {"id": "dup", "value": 2}, - ] - } - } - with pytest.raises(ValueError, match="dup"): - deduplicate_by_id(data) From 8aa4157574e6c7dcbbe797e1f51e3f29018f9ed0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:01:29 -0400 Subject: [PATCH 1572/2214] [fastled_base] Use FastLED IDF component on ESP32 (#16804) --- .clang-tidy.hash | 2 +- esphome/components/fastled_base/__init__.py | 12 ++++-- .../components/fastled_base/fastled_light.h | 2 - esphome/espidf/clang_tidy.py | 6 +++ esphome/idf_component.yml | 5 +++ platformio.ini | 3 +- script/clang-tidy | 12 +++++- tests/unit_tests/test_espidf_clang_tidy.py | 39 +++++++++++++++++++ 8 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 tests/unit_tests/test_espidf_clang_tidy.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 0782b065f3..3bcf356f86 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0b8325f52fca9224efb80dacca51ccbc8b3499bde7bb4aaa6f28a848c2e0a6a8 +d583091c0f465aed86a825138e309af6d9db6834106ab424f36712424a6c2223 diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index c944e8a930..d99dffdc08 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -41,10 +41,16 @@ async def new_fastled_light(config): if CONF_MAX_REFRESH_RATE in config: cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE])) - cg.add_library("fastled/FastLED", "3.9.16") if CORE.is_esp32: - from esphome.components.esp32 import include_builtin_idf_component + from esphome.components.esp32 import add_idf_component - include_builtin_idf_component("esp_lcd") + add_idf_component( + name="fastled/FastLED", + repo="https://github.com/FastLED/FastLED.git", + ref="d44c800a9e876a8394caefc2ce4915dd96dac77b", + ) + cg.add_library("SPI", None) + else: + cg.add_library("fastled/FastLED", "3.9.16") await light.register_light(var, config) return var diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 8e87f67e6d..f8535eb628 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -143,7 +143,6 @@ class FastLEDLightOutput : public light::AddressableLight { } } -#ifdef FASTLED_HAS_CLOCKLESS template class CHIPSET, uint8_t DATA_PIN, EOrder RGB_ORDER> CLEDController &add_leds(int num_leds) { static CHIPSET controller; @@ -160,7 +159,6 @@ class FastLEDLightOutput : public light::AddressableLight { static CHIPSET controller; return add_leds(&controller, num_leds); } -#endif template class CHIPSET, EOrder RGB_ORDER> CLEDController &add_leds(int num_leds) { static CHIPSET controller; diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 2cfbe67a70..7647db63f5 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -160,6 +160,12 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = "esp32" CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = settings.target_framework + # Gates arduino-only components in esphome/idf_component.yml (IDF reads it at + # reconfigure time). Set here -- before the manifest is written/reconfigured. + os.environ["ESPHOME_ARDUINO"] = ( + "1" if settings.target_framework == "arduino" else "0" + ) + # Special IDF "components" that are tools/subprojects, not requirable by an app # (they provide no public includes and break requirement resolution), plus our diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3a5b050072..4a4bc18579 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -103,3 +103,8 @@ dependencies: version: 0.6.1 lvgl/lvgl: version: 9.5.0 + fastled/FastLED: + git: https://github.com/FastLED/FastLED.git + version: d44c800a9e876a8394caefc2ce4915dd96dac77b + rules: + - if: "$ESPHOME_ARDUINO == 1" diff --git a/platformio.ini b/platformio.ini index 07e9b8aad3..d7bcc49758 100644 --- a/platformio.ini +++ b/platformio.ini @@ -79,7 +79,6 @@ lib_deps = SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt - fastled/FastLED@3.9.16 ; fastled_base freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea tonia/HeatpumpIR@1.0.41 ; heatpumpir @@ -108,6 +107,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + fastled/FastLED@3.9.16 ; fastled_base bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) Update ; ota (Arduino built-in) @@ -198,6 +198,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + fastled/FastLED@3.9.16 ; fastled_base ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base diff --git a/script/clang-tidy b/script/clang-tidy index 633b8d4b7d..f19bdb9b56 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -35,9 +35,19 @@ def clang_options(idedata): # extract target architecture from triplet in g++ filename triplet = Path(idedata["cxx_path"]).name[:-4] if triplet.startswith("xtensa-"): - # clang doesn't support Xtensa (yet?), so compile in 32-bit mode and pretend we're the Xtensa compiler + # clang has an Xtensa frontend, but only a generic core -- the esp32 IDF + # toolchain headers (xtruntime, xtensa/config) need the GCC core config + # (XCHAL_*) it doesn't ship, so we still compile in 32-bit x86 mode and + # just pretend to be Xtensa. Undefine the host x86 arch macros -m32 sets, + # so libraries with x86 SIMD paths (FastLED's fl/math/simd, simd_x86.hpp) + # fall back to their scalar implementation instead of an incomplete + # host-x86 one, and define the xtensa endianness macro newlib's + # machine/ieeefp.h then needs in their place. cmd.append("-m32") + cmd.append("-U__i386__") + cmd.append("-U__x86_64__") cmd.append("-D__XTENSA__") + cmd.append("-D__XTENSA_EL__") cmd.append("-D_LIBC") else: # RISC-V (and other non-Xtensa targets) have a real clang backend, so diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py new file mode 100644 index 0000000000..7a71dc26f4 --- /dev/null +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -0,0 +1,39 @@ +"""Tests for esphome.espidf.clang_tidy tidy-project setup.""" + +import os +from pathlib import Path + +import pytest + +from esphome.espidf.clang_tidy import _Settings, _setup_core + + +def _settings(target_framework: str) -> _Settings: + return _Settings( + idf_target="esp32", + variant="ESP32", + idf_version="5.5.4", + target_framework=target_framework, + platform_defines=("USE_ESP32",), + framework_deps={}, + ) + + +@pytest.mark.parametrize( + ("target_framework", "expected"), + [("arduino", "1"), ("espidf", "0")], +) +def test_setup_core_sets_arduino_env( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + target_framework: str, + expected: str, +) -> None: + """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + # monkeypatch snapshots os.environ, so the env var _setup_core writes is + # restored after the test instead of leaking into later tests. + monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) + + _setup_core(tmp_path / "proj", _settings(target_framework)) + + assert os.environ["ESPHOME_ARDUINO"] == expected From 6996b7ed1c2d6aa6abf639b705af0af928a9361c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:03:08 -0400 Subject: [PATCH 1573/2214] [ci] Add ESP32 Variants clang-tidy run (S3/P4/C6) (#16825) --- .clang-tidy.hash | 2 +- .github/workflows/ci.yml | 88 ++++++++++++++++++++++ .gitignore | 1 + esphome/core/defines.h | 2 + esphome/espidf/clang_tidy.py | 11 ++- platformio.ini | 11 +++ script/clang_tidy_hash.py | 11 +-- sdkconfig.defaults | 5 -- sdkconfig.defaults.esp32c6 | 14 ++++ sdkconfig.defaults.esp32p4 | 31 ++++++++ sdkconfig.defaults.esp32s3 | 12 +++ tests/script/test_clang_tidy_hash.py | 24 ++++++ tests/unit_tests/test_espidf_clang_tidy.py | 41 ++++++++-- 13 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 sdkconfig.defaults.esp32c6 create mode 100644 sdkconfig.defaults.esp32p4 create mode 100644 sdkconfig.defaults.esp32s3 diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 3bcf356f86..e89b4230ad 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d583091c0f465aed86a825138e309af6d9db6834106ab424f36712424a6c2223 +d9c755e5f019b2ecb324834717bc1fb8563e622f5751794cb7156d324884481e diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40267240d8..a0d604f248 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -722,6 +722,93 @@ jobs: run: script/ci-suggest-changes if: always() + clang-tidy-esp32-variants: + name: ${{ matrix.name }} + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.clang-tidy == 'true' + env: + GH_TOKEN: ${{ github.token }} + # The variant tidy envs install ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + strategy: + fail-fast: false + max-parallel: 3 + matrix: + include: + - id: clang-tidy + name: Run script/clang-tidy for ESP32 S3 + options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 P4 + # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, + # so their code paths differ -- lint them under the P4 build too. + # yamllint disable-line rule:line-length + options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE + - id: clang-tidy + name: Run script/clang-tidy for ESP32 C6 + # yamllint disable-line rule:line-length + options: --environment esp32c6-idf-tidy --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE + + steps: + - name: Check out code from GitHub + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # Need history for HEAD~1 to work for checking changed files + fetch-depth: 2 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Cache ESP-IDF install + # Shared with the IDF/Arduino clang-tidy jobs + native-IDF build (same install). + uses: ./.github/actions/cache-esp-idf + + - name: Register problem matchers + run: | + echo "::add-matcher::.github/workflows/matchers/gcc.json" + echo "::add-matcher::.github/workflows/matchers/clang-tidy.json" + + - name: Check if full clang-tidy scan needed + id: check_full_scan + run: | + . venv/bin/activate + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=hash_changed" >> $GITHUB_OUTPUT + else + echo "full_scan=false" >> $GITHUB_OUTPUT + echo "reason=normal" >> $GITHUB_OUTPUT + fi + + - name: Run clang-tidy + # Limited variant scan: only the files carrying that variant's code paths + # (no --all-headers; the comprehensive esp32-idf pass covers the shared tree). + run: | + . venv/bin/activate + if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" + script/clang-tidy --fix ${{ matrix.options }} + else + echo "Running clang-tidy on changed files only" + script/clang-tidy --fix --changed ${{ matrix.options }} + fi + + - name: Suggested changes + run: script/ci-suggest-changes + if: always() + test-build-components-split: name: Test components batch (${{ matrix.components }}) runs-on: ubuntu-24.04 @@ -1273,6 +1360,7 @@ jobs: - clang-tidy-single - clang-tidy-nosplit - clang-tidy-split + - clang-tidy-esp32-variants - determine-jobs - device-builder - test-build-components-split diff --git a/.gitignore b/.gitignore index 4a4a88fd48..de3e4fa68e 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,7 @@ tests/.esphome/ sdkconfig.* !sdkconfig.defaults +!sdkconfig.defaults.* .tests/ diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6c840f56ee..410858f904 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -64,6 +64,7 @@ #define USE_ESP32_BLE_PSRAM #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED +#define USE_ESP32_HOSTED_HTTP_UPDATE #define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_EVENT #define USE_FAN @@ -312,6 +313,7 @@ #define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2 #define USE_WIFI_RUNTIME_POWER_SAVE #define USB_HOST_MAX_REQUESTS 16 +#define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 #ifdef USE_ARDUINO diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 7647db63f5..62d6f0d00d 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -339,11 +339,18 @@ def _write_tidy_project( # ESPHome's static-analysis sdkconfig (repo root): enables the flags any # component sets (e.g. CONFIG_BT_ENABLED) so sdkconfig-gated IDF components # register and expose their includes. IDF reads ``sdkconfig.defaults`` from - # the project root. + # the project root, plus a per-target ``sdkconfig.defaults.`` + # for variant-only components (e.g. openthread on c6/h2). + repo_root = esphome_dir.parent (work_dir / "sdkconfig.defaults").write_text( - (esphome_dir.parent / "sdkconfig.defaults").read_text(encoding="utf-8"), + (repo_root / "sdkconfig.defaults").read_text(encoding="utf-8"), encoding="utf-8", ) + target_defaults = repo_root / f"sdkconfig.defaults.{settings.idf_target}" + if target_defaults.is_file(): + (work_dir / target_defaults.name).write_text( + target_defaults.read_text(encoding="utf-8"), encoding="utf-8" + ) def _generate_compile_commands( diff --git a/platformio.ini b/platformio.ini index d7bcc49758..d3fde193b4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -392,6 +392,17 @@ build_flags = ${flags:runtime.build_flags} -DUSE_ESP32_VARIANT_ESP32P4 +[env:esp32p4-idf-tidy] +extends = common:esp32-idf +board = esp32-p4-evboard +board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32p4-idf-tidy +build_flags = + ${common:esp32-idf.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_ESP32_VARIANT_ESP32P4 +build_unflags = + ${common.build_unflags} + ;;;;;;;; ESP32-S2 ;;;;;;;; [env:esp32s2-arduino] diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index 1a6e4eb7be..62f76246b4 100755 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -99,11 +99,12 @@ def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: platformio_content = read_file_bytes(platformio_path) hasher.update(platformio_content) - # Hash sdkconfig.defaults file - sdkconfig_path = repo_root / "sdkconfig.defaults" - if sdkconfig_path.exists(): - sdkconfig_content = read_file_bytes(sdkconfig_path) - hasher.update(sdkconfig_content) + # Hash sdkconfig.defaults and any per-target sdkconfig.defaults.: + # the per-target files flip CONFIG flags that change which variant code + # paths clang-tidy sees. Include the filename so a rename is detected. + for sdkconfig_path in sorted(repo_root.glob("sdkconfig.defaults*")): + hasher.update(sdkconfig_path.name.encode()) + hasher.update(read_file_bytes(sdkconfig_path)) # Hash esphome/idf_component.yml: its managed deps drive the ESP-IDF # build's include set, which clang-tidy analyzes. diff --git a/sdkconfig.defaults b/sdkconfig.defaults index b277ed18d0..8d177a7e26 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -15,8 +15,3 @@ CONFIG_BT_ENABLED=y # esp32_camera CONFIG_SPIRAM=y - -# zigbee -CONFIG_ZB_ENABLED=y -CONFIG_ZB_ZED=y -CONFIG_ZB_RADIO_NATIVE=y diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 new file mode 100644 index 0000000000..6dd5f4f329 --- /dev/null +++ b/sdkconfig.defaults.esp32c6 @@ -0,0 +1,14 @@ +# Per-target ESP-IDF sdkconfig defaults for esp32c6 static analysis (clang-tidy) only. +# Read by IDF in addition to sdkconfig.defaults. Enables variant-only components so +# their headers register for the tidy translation unit (these are normally set at +# codegen via add_idf_sdkconfig_option, which the stub tidy build skips). + +# openthread (only the C6/H2 variants have the 802.15.4 radio) +CONFIG_IEEE802154_ENABLED=y +CONFIG_OPENTHREAD_ENABLED=y +CONFIG_OPENTHREAD_RADIO_NATIVE=y + +# zigbee +CONFIG_ZB_ENABLED=y +CONFIG_ZB_ZED=y +CONFIG_ZB_RADIO_NATIVE=y diff --git a/sdkconfig.defaults.esp32p4 b/sdkconfig.defaults.esp32p4 new file mode 100644 index 0000000000..b49dcf0ef2 --- /dev/null +++ b/sdkconfig.defaults.esp32p4 @@ -0,0 +1,31 @@ +# Per-target ESP-IDF sdkconfig defaults for esp32p4 static analysis (clang-tidy) only. +# Read by IDF in addition to sdkconfig.defaults. Enables variant-only components so +# their headers register for the tidy translation unit (these are normally set at +# codegen via add_idf_sdkconfig_option, which the stub tidy build skips). + +# esp32_hosted (P4 has no native Wi-Fi; it drives a co-processor over SDIO/SPI). +# Mirrors a default SDIO 4-bit setup (slot 1, ESP32-C6 slave) so the esp_hosted +# code paths compile under static analysis. +CONFIG_SLAVE_IDF_TARGET_ESP32C6=y +CONFIG_ESP_HOSTED_SDIO_SLOT_1=y +CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS=y +CONFIG_ESP_HOSTED_CUSTOM_SDIO_PINS=y +CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ=40000 +CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y +CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE=54 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_1=18 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_1=19 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_1=14 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_1=15 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_1=16 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_1=17 + +# BLE runs over the hosted co-processor on P4 (no native BT controller), so +# esp32_ble_tracker must take the hosted bluedroid path instead of . +CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID=y + +# tinyusb CDC (usb_cdc_acm), same as esp32s3 +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_CDC_RX_BUFSIZE=256 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=256 diff --git a/sdkconfig.defaults.esp32s3 b/sdkconfig.defaults.esp32s3 new file mode 100644 index 0000000000..15b97eb1b4 --- /dev/null +++ b/sdkconfig.defaults.esp32s3 @@ -0,0 +1,12 @@ +# Per-target ESP-IDF sdkconfig defaults for esp32s3 static analysis (clang-tidy) only. +# Read by IDF in addition to sdkconfig.defaults. Enables variant-only components so +# their headers register for the tidy translation unit (these are normally set at +# codegen via add_idf_sdkconfig_option, which the stub tidy build skips). + +# tinyusb CDC (usb_cdc_acm) -- the esp_tinyusb managed component is already in +# esphome/idf_component.yml; these enable its CDC class so tud_cdc_* and the +# CONFIG_TINYUSB_CDC_* macros are declared. +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_CDC_RX_BUFSIZE=256 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=256 diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index e19e7886a2..194926a5df 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -63,6 +63,7 @@ def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: expected_hasher.update(clang_tidy_content) expected_hasher.update(requirements_version.encode()) expected_hasher.update(platformio_content) + expected_hasher.update(b"sdkconfig.defaults") expected_hasher.update(sdkconfig_content) expected_hash = expected_hasher.hexdigest() @@ -71,6 +72,29 @@ def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: assert result == expected_hash +def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( + tmp_path: Path, +) -> None: + """Per-target sdkconfig.defaults. files must be part of the hash.""" + (tmp_path / ".clang-tidy").write_bytes(b"Checks: '-*'\n") + (tmp_path / "platformio.ini").write_bytes(b"[env:esp32]\n") + (tmp_path / "requirements_dev.txt").write_text("clang-tidy==18.1.5\n") + (tmp_path / "sdkconfig.defaults").write_bytes(b"CONFIG_BASE=y\n") + + before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + + # Adding a per-target file must change the hash. + per_target = tmp_path / "sdkconfig.defaults.esp32c6" + per_target.write_bytes(b"CONFIG_OPENTHREAD_ENABLED=y\n") + after_add = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + assert after_add != before + + # Editing the per-target file must change the hash again. + per_target.write_bytes(b"CONFIG_OPENTHREAD_ENABLED=n\n") + after_edit = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + assert after_edit != after_add + + def test_calculate_clang_tidy_hash_without_sdkconfig(tmp_path: Path) -> None: """Test calculating hash without sdkconfig.defaults file.""" clang_tidy_content = b"Checks: '-*,readability-*'\n" diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 7a71dc26f4..9791dfc543 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -1,24 +1,51 @@ -"""Tests for esphome.espidf.clang_tidy tidy-project setup.""" +"""Tests for esphome.espidf.clang_tidy tidy-project generation.""" import os from pathlib import Path import pytest -from esphome.espidf.clang_tidy import _Settings, _setup_core +from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project + +REPO_ROOT = Path(__file__).resolve().parents[2] -def _settings(target_framework: str) -> _Settings: +def _settings(idf_target: str = "esp32", target_framework: str = "espidf") -> _Settings: return _Settings( - idf_target="esp32", - variant="ESP32", + idf_target=idf_target, + variant=idf_target.upper(), idf_version="5.5.4", target_framework=target_framework, - platform_defines=("USE_ESP32",), + platform_defines=( + "USE_ESP32", + f"USE_ESP32_VARIANT_{idf_target.upper()}", + "USE_ESP_IDF", + ), framework_deps={}, ) +def test_write_tidy_project_copies_base_sdkconfig(tmp_path: Path) -> None: + """The shared sdkconfig.defaults is always copied; no per-target file for esp32.""" + _write_tidy_project(tmp_path, [], {}, _settings("esp32")) + + assert (tmp_path / "sdkconfig.defaults").is_file() + # esp32 has no sdkconfig.defaults.esp32, so nothing extra is copied. + assert not (tmp_path / "sdkconfig.defaults.esp32").exists() + + +def test_write_tidy_project_copies_per_target_sdkconfig(tmp_path: Path) -> None: + """A repo-root sdkconfig.defaults. is also copied into the build dir.""" + _write_tidy_project(tmp_path, [], {}, _settings("esp32c6")) + + target = tmp_path / "sdkconfig.defaults.esp32c6" + assert (tmp_path / "sdkconfig.defaults").is_file() + assert target.is_file() + assert target.read_text(encoding="utf-8") == ( + REPO_ROOT / "sdkconfig.defaults.esp32c6" + ).read_text(encoding="utf-8") + + @pytest.mark.parametrize( ("target_framework", "expected"), [("arduino", "1"), ("espidf", "0")], @@ -34,6 +61,6 @@ def test_setup_core_sets_arduino_env( # restored after the test instead of leaking into later tests. monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) - _setup_core(tmp_path / "proj", _settings(target_framework)) + _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) assert os.environ["ESPHOME_ARDUINO"] == expected From 745db9f705818a5a1df2893176e98409254f4ee9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:10:08 +1000 Subject: [PATCH 1574/2214] [motion] Implement hub component for IMUs (#16226) Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/motion/__init__.py | 221 +++++ .../components/motion/motion_component.cpp | 194 ++++ esphome/components/motion/motion_component.h | 154 +++ esphome/components/motion/sensor.py | 128 +++ tests/component_tests/motion/__init__.py | 0 tests/component_tests/motion/test_motion.py | 899 ++++++++++++++++++ 7 files changed, 1597 insertions(+) create mode 100644 esphome/components/motion/__init__.py create mode 100644 esphome/components/motion/motion_component.cpp create mode 100644 esphome/components/motion/motion_component.h create mode 100644 esphome/components/motion/sensor.py create mode 100644 tests/component_tests/motion/__init__.py create mode 100644 tests/component_tests/motion/test_motion.py diff --git a/CODEOWNERS b/CODEOWNERS index 3c3e502058..abe33f9467 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -351,6 +351,7 @@ esphome/components/modbus_server/* @exciton esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan esphome/components/mopeka_pro_check/* @spbrogan esphome/components/mopeka_std_check/* @Fabian-Schmidt +esphome/components/motion/* @esphome/core esphome/components/mpl3115a2/* @kbickar esphome/components/mpu6886/* @fabaff esphome/components/ms8607/* @e28eta diff --git a/esphome/components/motion/__init__.py b/esphome/components/motion/__init__.py new file mode 100644 index 0000000000..aea052fa2f --- /dev/null +++ b/esphome/components/motion/__init__.py @@ -0,0 +1,221 @@ +from collections.abc import Callable +import re + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.helpers import fnv1_hash_object_id + +CODEOWNERS = ["@esphome/core"] + +DOMAIN = "motion" +IS_PLATFORM_COMPONENT = True + +# C++ namespace / class +motion_ns = cg.esphome_ns.namespace("motion") +MotionComponent = motion_ns.class_("MotionComponent", cg.PollingComponent) + +AXES = ["x", "y", "z"] + +CONF_AXIS_MAP = "axis_map" +CONF_MOTION_ID = "motion_id" +CONF_TRANSFORM_MATRIX = "transform_matrix" + +CalibrateLevelAction = motion_ns.class_("CalibrateLevelAction", automation.Action) +CalibrateHeadingAction = motion_ns.class_("CalibrateHeadingAction", automation.Action) +ClearCalibrationAction = motion_ns.class_("ClearCalibrationAction", automation.Action) + +KEY_ACCELEROMETER = "accelerometer" +KEY_GYROSCOPE = "gyroscope" + +SENSOR_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MOTION_ID): cv.use_id(MotionComponent), + } +) + +_AXIS_REGEX = re.compile(r"^[+-]?[xyz]$", re.IGNORECASE) + + +def _axis_map(config: dict) -> dict: + errors = [] + for key, axis in config.items(): + if _AXIS_REGEX.fullmatch(axis) is None: + errors.append( + cv.Invalid( + "Each 'axis_map' config value must be one of 'x', 'y' or 'z' (optionally preceded by '+' or '-').", + path=[key], + ) + ) + values = {x.lower().removeprefix("-").removeprefix("+") for x in config.values()} + if values != set(AXES): + errors.append(cv.Invalid("Each axis may be mapped only once")) + if errors: + raise cv.MultipleInvalid(errors) + return config + + +def _axis_map_to_matrix(config: dict[str, str]) -> list[float]: + matrix = [] + for target_axis in AXES: + source_axis = config[target_axis].lower() + sign = -1.0 if source_axis.startswith("-") else 1.0 + source_axis = source_axis.removeprefix("+").removeprefix("-") + + row = [0.0, 0.0, 0.0] + row[AXES.index(source_axis)] = sign + matrix.extend(row) + + return matrix + + +def _transform_matrix(value): + """Accept a flat list of 9 floats or a 3x3 nested list.""" + if not isinstance(value, list) or len(value) == 0: + raise cv.Invalid("Expected a list of 9 numbers or a 3x3 nested list") + # Nested 3x3 + if isinstance(value[0], list): + if len(value) != 3: + raise cv.Invalid(f"3x3 matrix must have 3 rows, got {len(value)}") + flat = [] + for i, row in enumerate(value): + if not isinstance(row, list) or len(row) != 3: + raise cv.Invalid("Each row must be a list of 3 numbers", path=[i]) + flat.extend(cv.float_(v) for v in row) + return flat + # Flat list + if len(value) != 9: + raise cv.Invalid(f"Flat matrix must have exactly 9 values, got {len(value)}") + return [cv.float_(v) for v in value] + + +def _validate_matrix_options(config): + if CONF_AXIS_MAP in config and CONF_TRANSFORM_MATRIX in config: + raise cv.Invalid( + f"'{CONF_AXIS_MAP}' and '{CONF_TRANSFORM_MATRIX}' are mutually exclusive" + ) + return config + + +# Top-level CONFIG_SCHEMA +_CONFIG_SCHEMA = ( + cv.Schema( + { + cv.Optional(CONF_AXIS_MAP): cv.All( + {cv.Required(k): cv.string_strict for k in AXES}, + _axis_map, + ), + cv.Optional(CONF_TRANSFORM_MATRIX): _transform_matrix, + } + ) + .extend(cv.polling_component_schema("250ms")) + .add_extra(_validate_matrix_options) +) + + +def _add_data(has_accel: bool, has_gyro: bool) -> Callable[[dict], dict]: + + def validator(config): + config = config.copy() + config[KEY_ACCELEROMETER] = has_accel + config[KEY_GYROSCOPE] = has_gyro + return config + + return validator + + +def motion_schema(class_: MockObjClass, has_accel: bool, has_gyro: bool) -> cv.Schema: + return _CONFIG_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(class_), + } + ).add_extra(_add_data(has_accel, has_gyro)) + + +# Code generation +async def register_motion_component(var: MockObj, config) -> None: + await cg.register_component(var, config) + # Set preference key for NVS save/restore (based on component ID) + obj_id = config[CONF_ID].id + pref_hash = fnv1_hash_object_id(obj_id) + cg.add(var.set_calibration_key(pref_hash)) + if axis_map := config.get(CONF_AXIS_MAP): + cg.add(var.set_matrix(_axis_map_to_matrix(axis_map))) + elif transform_matrix := config.get(CONF_TRANSFORM_MATRIX): + cg.add(var.set_matrix(transform_matrix)) + + +async def new_motion_component(config: dict) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await register_motion_component(var, config) + return var + + +# --- Actions --- + +CONF_SAVE = "save" + +CALIBRATE_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(MotionComponent), + cv.Optional(CONF_SAVE, default=False): cv.boolean, + cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True), + cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True), + } +) + + +async def _build_calibrate_action(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + if config.get(CONF_SAVE): + cg.add(var.set_save(True)) + if on_success := config.get(CONF_ON_SUCCESS): + await automation.build_automation(var.get_success_trigger(), [], on_success) + if on_error := config.get(CONF_ON_ERROR): + await automation.build_automation(var.get_error_trigger(), [], on_error) + return var + + +@automation.register_action( + "motion.calibrate_level", + CalibrateLevelAction, + CALIBRATE_ACTION_SCHEMA, + synchronous=True, +) +async def calibrate_level_to_code(config, action_id, template_arg, args): + return await _build_calibrate_action(config, action_id, template_arg, args) + + +@automation.register_action( + "motion.calibrate_heading", + CalibrateHeadingAction, + CALIBRATE_ACTION_SCHEMA, + synchronous=True, +) +async def calibrate_heading_to_code(config, action_id, template_arg, args): + return await _build_calibrate_action(config, action_id, template_arg, args) + + +CLEAR_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(MotionComponent), + cv.Optional(CONF_SAVE, default=False): cv.boolean, + } +) + + +@automation.register_action( + "motion.clear_calibration", + ClearCalibrationAction, + CLEAR_ACTION_SCHEMA, + synchronous=True, +) +async def clear_calibration_to_code(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + if config.get(CONF_SAVE): + cg.add(var.set_save(True)) + return var diff --git a/esphome/components/motion/motion_component.cpp b/esphome/components/motion/motion_component.cpp new file mode 100644 index 0000000000..8715c8385c --- /dev/null +++ b/esphome/components/motion/motion_component.cpp @@ -0,0 +1,194 @@ +#include "motion_component.h" +#include "esphome/core/log.h" + +namespace esphome::motion { + +static const char *const TAG = "motion"; + +static void log_matrix(const float m[9]) { + ESP_LOGCONFIG(TAG, " Calibration matrix:"); + ESP_LOGCONFIG(TAG, " - [%9.6f, %9.6f, %9.6f]", m[0], m[1], m[2]); + ESP_LOGCONFIG(TAG, " - [%9.6f, %9.6f, %9.6f]", m[3], m[4], m[5]); + ESP_LOGCONFIG(TAG, " - [%9.6f, %9.6f, %9.6f]", m[6], m[7], m[8]); +} + +// FNV-1a over the raw bytes of the matrix. Identical axis maps always yield +// bit-identical matrices, so this is a stable fingerprint of the build-time base. +static uint32_t hash_matrix(const float m[9]) { + const uint8_t *bytes = reinterpret_cast(m); + uint32_t hash = 2166136261UL; + for (size_t i = 0; i < sizeof(float) * 9; i++) { + hash ^= bytes[i]; + hash *= 16777619UL; + } + return hash; +} + +void MotionComponent::setup() { + // matrix_ currently holds the build-time base (set_matrix ran during codegen). + this->base_hash_ = hash_matrix(this->base_matrix_); + this->pref_ = global_preferences->make_preference(this->pref_key_); + CalibrationPref saved; + if (this->pref_.load(&saved) && saved.base_hash == this->base_hash_) { + memcpy(this->matrix_, saved.matrix, sizeof(this->matrix_)); + ESP_LOGI(TAG, "Restored calibration from NVS"); + } else { + ESP_LOGD(TAG, "No matching saved calibration; using build-time matrix"); + } + log_matrix(this->matrix_); +} +void MotionComponent::dump_config() { + LOG_UPDATE_INTERVAL(this); + log_matrix(this->matrix_); +} +bool MotionComponent::save_calibration() { + if (this->pref_key_ == 0) { + ESP_LOGW(TAG, "Cannot save calibration: no preference key set"); + return false; + } + CalibrationPref pref{this->base_hash_, {}}; + memcpy(pref.matrix, this->matrix_, sizeof(pref.matrix)); + if (this->pref_.save(&pref)) { + global_preferences->sync(); + ESP_LOGI(TAG, "Saved calibration to NVS"); + return true; + } + ESP_LOGW(TAG, "Calibration save failed"); + return false; +} +void MotionComponent::clear_calibration() { + memcpy(this->matrix_, this->base_matrix_, sizeof(this->matrix_)); + ESP_LOGI(TAG, "Calibration reset to build-time matrix"); + log_matrix(this->matrix_); +} +void MotionComponent::update() { + if (this->is_failed()) + return; + MotionData motion_data{}; + MotionData raw_data{}; + if (!this->update_data(raw_data)) + return; + this->map_axes_(motion_data.acceleration, raw_data.acceleration); + this->map_axes_(motion_data.angular_rate, raw_data.angular_rate); + this->motion_data_callback_.call(motion_data); + + ESP_LOGV(TAG, "Accel: [%.3f, %.3f, %.3f] g; Gyro: [%.3f, %.3f, %.3f] °/s", motion_data.acceleration[X_AXIS], + motion_data.acceleration[Y_AXIS], motion_data.acceleration[Z_AXIS], motion_data.angular_rate[X_AXIS], + motion_data.angular_rate[Y_AXIS], motion_data.angular_rate[Z_AXIS]); +} + +bool MotionComponent::calibrate_level() { + MotionData raw{}; + if (!this->update_data(raw)) { + ESP_LOGW(TAG, "calibrate_level: failed to read sensor data"); + return false; + } + + // Apply the current matrix first so any existing axis mapping is preserved. + float mapped[3]; + this->map_axes_(mapped, raw.acceleration); + + float nx = mapped[X_AXIS]; + float ny = mapped[Y_AXIS]; + float nz = mapped[Z_AXIS]; + float mag = std::sqrt(nx * nx + ny * ny + nz * nz); + if (mag < 0.1f) { + ESP_LOGW(TAG, "calibrate_level: acceleration magnitude too small (%.3f)", mag); + return false; + } + + // Normalize + nx /= mag; + ny /= mag; + nz /= mag; + + // Compute rotation matrix R such that R * [nx, ny, nz] = [0, 0, 1] + // using Rodrigues' rotation formula, then compose with the existing matrix. + if (nz > 0.99999f) { + // Already aligned with +Z — nothing to compose + ESP_LOGI(TAG, "Level calibration: already aligned"); + log_matrix(this->matrix_); + // returning true here will trigger on_success and a save to NVS, but the save will ultimately be a no-op + // since the backend sync will not write unchanged values. + return true; + } + + float r[9]; + if (nz < -0.9999f) { + // Aligned with -Z — 180° rotation about X + float m[9] = {1, 0, 0, 0, -1, 0, 0, 0, -1}; + memcpy(r, m, sizeof(r)); + } else { + float f = 1.0f / (1.0f + nz); + r[0] = 1.0f - nx * nx * f; + r[1] = -nx * ny * f; + r[2] = -nx; + r[3] = -nx * ny * f; + r[4] = 1.0f - ny * ny * f; + r[5] = -ny; + r[6] = nx; + r[7] = ny; + r[8] = nz; + } + + // Compose: new_matrix = R * old_matrix + float old[9]; + memcpy(old, this->matrix_, sizeof(old)); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + this->matrix_[i * 3 + j] = r[i * 3 + 0] * old[j] + r[i * 3 + 1] * old[3 + j] + r[i * 3 + 2] * old[6 + j]; + } + } + + ESP_LOGI(TAG, "Level calibration applied (mapped accel: [%.3f, %.3f, %.3f])", mapped[X_AXIS], mapped[Y_AXIS], + mapped[Z_AXIS]); + log_matrix(this->matrix_); + return true; +} + +bool MotionComponent::calibrate_heading() { + MotionData raw{}; + if (!this->update_data(raw)) { + ESP_LOGW(TAG, "calibrate_heading: failed to read sensor data"); + return false; + } + + // Apply current matrix to get the mapped acceleration + float mapped[3]; + this->map_axes_(mapped, raw.acceleration); + + float mx = mapped[X_AXIS]; + float my = mapped[Y_AXIS]; + float h = std::sqrt(mx * mx + my * my); + if (h < 0.05f) { + ESP_LOGW(TAG, "calibrate_heading: device must be tilted (XY magnitude %.3f too small)", h); + return false; + } + + // Rotation angle in the XY plane: eliminate Y component while preserving X sign. + // Without the sign correction, atan2(my,mx) would rotate everything to +X, + // flipping the sign when the tilt projects onto -X. + float sign_mx = mx >= 0 ? 1.0f : -1.0f; + float cos_phi = sign_mx * mx / h; // = |mx| / h + float sin_phi = sign_mx * my / h; + + // Compose Rz(-phi) with the current matrix + // Rz(-phi) = [[cos_phi, sin_phi, 0], [-sin_phi, cos_phi, 0], [0, 0, 1]] + float old[9]; + memcpy(old, this->matrix_, sizeof(old)); + + this->matrix_[0] = cos_phi * old[0] + sin_phi * old[3]; + this->matrix_[1] = cos_phi * old[1] + sin_phi * old[4]; + this->matrix_[2] = cos_phi * old[2] + sin_phi * old[5]; + this->matrix_[3] = -sin_phi * old[0] + cos_phi * old[3]; + this->matrix_[4] = -sin_phi * old[1] + cos_phi * old[4]; + this->matrix_[5] = -sin_phi * old[2] + cos_phi * old[5]; + // Row 2 unchanged + + ESP_LOGI(TAG, "Heading calibration applied (mapped accel: [%.3f, %.3f, %.3f])", mapped[X_AXIS], mapped[Y_AXIS], + mapped[Z_AXIS]); + log_matrix(this->matrix_); + return true; +} + +} // namespace esphome::motion diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h new file mode 100644 index 0000000000..00310c16fe --- /dev/null +++ b/esphome/components/motion/motion_component.h @@ -0,0 +1,154 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" +#include +#include +#include // required for generated lambda code + +namespace esphome::motion { + +// ---Data class + +struct MotionData { + float acceleration[3]{NAN, NAN, NAN}; + float angular_rate[3]{NAN, NAN, NAN}; + // TODO - compass +}; + +// indices into data arrays +static constexpr uint8_t X_AXIS = 0; +static constexpr uint8_t Y_AXIS = 1; +static constexpr uint8_t Z_AXIS = 2; + +// Persisted calibration. `base_hash` ties the stored matrix to the build-time +// (axis_map / transform_matrix) base; if the base changes the saved calibration +// is ignored. Stored under a stable, ID-derived key so it overwrites in place. +struct CalibrationPref { + uint32_t base_hash; + float matrix[9]; +} PACKED; + +// Main component class +class MotionComponent : public PollingComponent { + public: + // Lifecycle + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + void set_matrix(const std::array &m) { + memcpy(this->base_matrix_, m.data(), sizeof(this->base_matrix_)); + memcpy(this->matrix_, m.data(), sizeof(this->matrix_)); + } + void set_calibration_key(uint32_t key) { this->pref_key_ = key; } + + /// Calibrate the matrix so the current reading maps to [0, 0, 1] (device flat). + bool calibrate_level(); + /// Assuming Y-axis rotation only, correct the heading so X/Y align correctly. + bool calibrate_heading(); + /// Save the current matrix to NVS. + bool save_calibration(); + /// Restore the build-time (axis_map / transform_matrix) base, discarding calibration. + void clear_calibration(); + + template void add_listener(F &&cb) { this->motion_data_callback_.add(std::forward(cb)); } + + protected: + // platforms must implement this method to update raw data. + virtual bool update_data(MotionData &data) = 0; + + // for mapping axes + float matrix_[9]{ + 1, 0, 0, 0, 1, 0, 0, 0, 1, + }; + // build-time base (axis_map / transform_matrix); used to detect config changes + // and to restore on clear_calibration(). + float base_matrix_[9]{ + 1, 0, 0, 0, 1, 0, 0, 0, 1, + }; + + void map_axes_(float output[3], const float input[3]) const { + output[0] = input[X_AXIS] * this->matrix_[0] + input[Y_AXIS] * this->matrix_[1] + input[Z_AXIS] * this->matrix_[2]; + output[1] = input[X_AXIS] * this->matrix_[3] + input[Y_AXIS] * this->matrix_[4] + input[Z_AXIS] * this->matrix_[5]; + output[2] = input[X_AXIS] * this->matrix_[6] + input[Y_AXIS] * this->matrix_[7] + input[Z_AXIS] * this->matrix_[8]; + } + + LazyCallbackManager motion_data_callback_{}; + uint32_t pref_key_{0}; + uint32_t base_hash_{0}; // hash of base_matrix_, captured in setup() + ESPPreferenceObject pref_{}; +}; + +// --- Actions --- + +template class CalibrateLevelAction : public Action { + public: + explicit CalibrateLevelAction(MotionComponent *parent) : parent_(parent) {} + void set_save(bool save) { this->save_ = save; } + Trigger<> *get_success_trigger() { return &this->success_trigger_; } + Trigger<> *get_error_trigger() { return &this->error_trigger_; } + + protected: + void play(const Ts &...) override { + if (this->parent_->calibrate_level()) { + // if not saving, calibration success is enough. If save required only report success after that succeeds too. + if (!this->save_ || this->parent_->save_calibration()) { + this->success_trigger_.trigger(); + return; + } + } + this->error_trigger_.trigger(); + } + + MotionComponent *parent_; + Trigger<> success_trigger_; + Trigger<> error_trigger_; + bool save_{false}; +}; + +template class CalibrateHeadingAction : public Action { + public: + explicit CalibrateHeadingAction(MotionComponent *parent) : parent_(parent) {} + void set_save(bool save) { this->save_ = save; } + Trigger<> *get_success_trigger() { return &this->success_trigger_; } + Trigger<> *get_error_trigger() { return &this->error_trigger_; } + + protected: + void play(const Ts &...) override { + if (this->parent_->calibrate_heading()) { + // if not saving, calibration success is enough. If save required only report success after that succeeds too. + if (!this->save_ || this->parent_->save_calibration()) { + this->success_trigger_.trigger(); + return; + } + } + this->error_trigger_.trigger(); + } + + MotionComponent *parent_; + Trigger<> success_trigger_; + Trigger<> error_trigger_; + bool save_{false}; +}; + +template class ClearCalibrationAction : public Action { + public: + explicit ClearCalibrationAction(MotionComponent *parent) : parent_(parent) {} + void set_save(bool save) { this->save_ = save; } + + protected: + void play(const Ts &...) override { + this->parent_->clear_calibration(); + if (this->save_) + this->parent_->save_calibration(); + } + + MotionComponent *parent_; + bool save_{false}; +}; + +} // namespace esphome::motion diff --git a/esphome/components/motion/sensor.py b/esphome/components/motion/sensor.py new file mode 100644 index 0000000000..ad3163a01a --- /dev/null +++ b/esphome/components/motion/sensor.py @@ -0,0 +1,128 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TYPE, + ICON_ACCELERATION, + ICON_ROTATE_RIGHT, + STATE_CLASS_MEASUREMENT, + UNIT_DEGREE_PER_SECOND, + UNIT_DEGREES, + UNIT_G, +) +from esphome.cpp_generator import MockObj +from esphome.cpp_types import std_ns +import esphome.final_validate as fv + +from . import ( + AXES, + CONF_MOTION_ID, + KEY_ACCELEROMETER, + KEY_GYROSCOPE, + SENSOR_SCHEMA, + motion_ns, +) + +MotionData = motion_ns.class_("MotionData") + +CONF_PITCH = "pitch" +CONF_ROLL = "roll" +ICON_SEESAW = "mdi:seesaw" + + +def _accel_sensor_schema(): + return sensor.sensor_schema( + unit_of_measurement=UNIT_G, + icon=ICON_ACCELERATION, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(SENSOR_SCHEMA) + + +def _gyro_sensor_schema(): + return sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREE_PER_SECOND, + icon=ICON_ROTATE_RIGHT, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(SENSOR_SCHEMA) + + +def _level_sensor_schema(): + return sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREES, + icon=ICON_SEESAW, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(SENSOR_SCHEMA) + + +_ACCELERATIONS = ["acceleration_" + a for a in AXES] +_GYROSCOPES = ["gyroscope_" + g for g in AXES] +_ANGULAR_RATES = ["angular_rate_" + r for r in AXES] + +CONFIG_SCHEMA = cv.typed_schema( + { + **{x: _accel_sensor_schema() for x in _ACCELERATIONS}, + **{x: _gyro_sensor_schema() for x in _GYROSCOPES}, + **{x: _gyro_sensor_schema() for x in _ANGULAR_RATES}, + **{x: _level_sensor_schema() for x in (CONF_PITCH, CONF_ROLL)}, + } +) + + +def _final_validate(config: dict) -> None: + full_config = fv.full_config.get() + motion_path = full_config.get_path_for_id(config[CONF_MOTION_ID])[:-1] + motion_config = full_config.get_config_for_path(motion_path) + has_accel = motion_config.get(KEY_ACCELEROMETER, False) + has_gyro = motion_config.get(KEY_GYROSCOPE, False) + + sensor_type = config[CONF_TYPE] + if ( + sensor_type in _ACCELERATIONS or sensor_type in (CONF_ROLL, CONF_PITCH) + ) and not has_accel: + raise cv.Invalid( + "The motion device does not measure acceleration", path=[CONF_TYPE] + ) + if (sensor_type in _GYROSCOPES or sensor_type in _ANGULAR_RATES) and not has_gyro: + raise cv.Invalid( + "The motion device does not measure angular rate", path=[CONF_TYPE] + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def build_sensor_expr(sensor_type: str, data: MockObj) -> MockObj: + """Build the C++ expression for a motion sensor type.""" + + # Note that is included via this component's header file. + pif = std_ns.namespace("numbers").pi_v.template(cg.float_) + if sensor_type == CONF_ROLL: + ay = data.acceleration[1] + az = data.acceleration[2] + return std_ns.atan2(ay, az) * (180.0 / pif) + if sensor_type == CONF_PITCH: + ax = data.acceleration[0] + ay = data.acceleration[1] + az = data.acceleration[2] + return std_ns.atan2(-ax, std_ns.sqrt(ay * ay + az * az)) * (180.0 / pif) + sensor_offset = AXES.index(sensor_type[-1:]) + if sensor_type in _GYROSCOPES: + sensor_type = _ANGULAR_RATES[sensor_offset] + return getattr(data, str(sensor_type[:-2]))[sensor_offset] + + +async def to_code(config): + sensor_type = config[CONF_TYPE] + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_MOTION_ID]) + data = MockObj("data") + expr = build_sensor_expr(sensor_type, data) + value_lambda = await cg.process_lambda( + var.publish_state(expr), + [(MotionData.operator("ref"), str(data))], + ) + cg.add(parent.add_listener(value_lambda)) diff --git a/tests/component_tests/motion/__init__.py b/tests/component_tests/motion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/motion/test_motion.py b/tests/component_tests/motion/test_motion.py new file mode 100644 index 0000000000..f2c0f26344 --- /dev/null +++ b/tests/component_tests/motion/test_motion.py @@ -0,0 +1,899 @@ +"""Tests for the motion component.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.motion import ( + CALIBRATE_ACTION_SCHEMA, + CLEAR_ACTION_SCHEMA, + CONF_AXIS_MAP, + CONF_SAVE, + CONF_TRANSFORM_MATRIX, + _axis_map, + _axis_map_to_matrix, + _build_calibrate_action, + _transform_matrix, + _validate_matrix_options, + clear_calibration_to_code, +) +from esphome.components.motion.sensor import ( + _ACCELERATIONS, + _ANGULAR_RATES, + _GYROSCOPES, + CONF_PITCH, + CONF_ROLL, + CONFIG_SCHEMA, + build_sensor_expr, +) +from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS +from esphome.cpp_generator import MockObj + +# --- Axis map validation --- + + +class TestAxisMapValidation: + """Tests for the _axis_map validator.""" + + def test_identity_map(self): + result = _axis_map({"x": "x", "y": "y", "z": "z"}) + assert result == {"x": "x", "y": "y", "z": "z"} + + def test_axis_swap(self): + result = _axis_map({"x": "y", "y": "z", "z": "x"}) + assert result == {"x": "y", "y": "z", "z": "x"} + + def test_negation(self): + result = _axis_map({"x": "-y", "y": "z", "z": "x"}) + assert result == {"x": "-y", "y": "z", "z": "x"} + + def test_plus_prefix(self): + result = _axis_map({"x": "+y", "y": "z", "z": "x"}) + assert result == {"x": "+y", "y": "z", "z": "x"} + + def test_case_insensitive(self): + result = _axis_map({"x": "X", "y": "Y", "z": "Z"}) + assert result == {"x": "X", "y": "Y", "z": "Z"} + + def test_invalid_axis_value(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "a", "y": "y", "z": "z"}) + + def test_duplicate_mapping(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "x", "y": "x", "z": "z"}) + + def test_all_same_axis(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "x", "y": "x", "z": "x"}) + + def test_empty_value(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "", "y": "y", "z": "z"}) + + def test_invalid_and_duplicate(self): + """Both invalid value and duplicate should produce multiple errors.""" + with pytest.raises(MultipleInvalid) as exc_info: + _axis_map({"x": "a", "y": "x", "z": "z"}) + # Should have at least the invalid regex error and the duplicate error + assert len(exc_info.value.errors) >= 2 + + +# --- Transform matrix validation --- + + +class TestTransformMatrix: + """Tests for the _transform_matrix validator.""" + + def test_flat_identity(self): + result = _transform_matrix([1, 0, 0, 0, 1, 0, 0, 0, 1]) + assert result == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] + + def test_flat_values_converted_to_float(self): + result = _transform_matrix([1, 2, 3, 4, 5, 6, 7, 8, 9]) + assert all(isinstance(v, float) for v in result) + + def test_nested_3x3(self): + result = _transform_matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + assert result == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] + + def test_nested_3x3_values(self): + result = _transform_matrix( + [[0.5, 0.1, -0.2], [-0.1, 0.9, 0.3], [0.2, -0.3, 0.8]] + ) + assert len(result) == 9 + assert result[0] == pytest.approx(0.5) + assert result[3] == pytest.approx(-0.1) + assert result[8] == pytest.approx(0.8) + + def test_flat_wrong_length_short(self): + with pytest.raises(Invalid, match="exactly 9"): + _transform_matrix([1, 0, 0]) + + def test_flat_wrong_length_long(self): + with pytest.raises(Invalid, match="exactly 9"): + _transform_matrix([1] * 12) + + def test_nested_wrong_row_count(self): + with pytest.raises(Invalid, match="3 rows"): + _transform_matrix([[1, 0, 0], [0, 1, 0]]) + + def test_nested_wrong_column_count(self): + with pytest.raises(Invalid, match="3 numbers"): + _transform_matrix([[1, 0], [0, 1, 0], [0, 0, 1]]) + + def test_empty_list(self): + with pytest.raises(Invalid): + _transform_matrix([]) + + def test_not_a_list(self): + with pytest.raises(Invalid): + _transform_matrix("identity") + + +class TestValidateMatrixOptions: + """Tests for mutual exclusivity of axis_map and transform_matrix.""" + + def test_neither_passes(self): + config = {"some_key": "value"} + assert _validate_matrix_options(config) is config + + def test_axis_map_only_passes(self): + config = {CONF_AXIS_MAP: {"x": "x", "y": "y", "z": "z"}} + assert _validate_matrix_options(config) is config + + def test_transform_matrix_only_passes(self): + config = {CONF_TRANSFORM_MATRIX: [1, 0, 0, 0, 1, 0, 0, 0, 1]} + assert _validate_matrix_options(config) is config + + def test_both_raises(self): + config = { + CONF_AXIS_MAP: {"x": "x", "y": "y", "z": "z"}, + CONF_TRANSFORM_MATRIX: [1, 0, 0, 0, 1, 0, 0, 0, 1], + } + with pytest.raises(Invalid, match="mutually exclusive"): + _validate_matrix_options(config) + + +# --- Axis map to matrix --- + + +class TestAxisMapToMatrix: + """Tests for _axis_map_to_matrix conversion.""" + + def test_identity(self): + assert _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) == [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + ] + + def test_swap_xy(self): + # x←y, y←x, z←z + assert _axis_map_to_matrix({"x": "y", "y": "x", "z": "z"}) == [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + ] + + def test_rotate_xyz(self): + # x←y, y←z, z←x + assert _axis_map_to_matrix({"x": "y", "y": "z", "z": "x"}) == [ + 0, + 1, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + ] + + def test_negate_x(self): + assert _axis_map_to_matrix({"x": "-x", "y": "y", "z": "z"}) == [ + -1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + ] + + def test_negate_z(self): + assert _axis_map_to_matrix({"x": "x", "y": "y", "z": "-z"}) == [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + -1, + ] + + def test_swap_and_negate(self): + # x←-y, y←z, z←x + assert _axis_map_to_matrix({"x": "-y", "y": "z", "z": "x"}) == [ + 0, + -1, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + ] + + def test_plus_prefix_ignored(self): + assert _axis_map_to_matrix({"x": "+y", "y": "z", "z": "x"}) == [ + 0, + 1, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + ] + + +# --- Sensor expression generation --- + + +def _expr_str(sensor_type: str) -> str: + """Build a sensor expression via the production function and return its string form.""" + return str(build_sensor_expr(sensor_type, MockObj("data"))) + + +class TestSensorExpressions: + """Tests that sensor code generation produces correct C++ expressions.""" + + @pytest.mark.parametrize( + "sensor_type,expected_index", + [ + ("acceleration_x", 0), + ("acceleration_y", 1), + ("acceleration_z", 2), + ], + ) + def test_acceleration_sensors(self, sensor_type, expected_index): + assert _expr_str(sensor_type) == f"data.acceleration[{expected_index}]" + + @pytest.mark.parametrize( + "sensor_type,expected_index", + [ + ("angular_rate_x", 0), + ("angular_rate_y", 1), + ("angular_rate_z", 2), + ], + ) + def test_angular_rate_sensors(self, sensor_type, expected_index): + assert _expr_str(sensor_type) == f"data.angular_rate[{expected_index}]" + + @pytest.mark.parametrize( + "sensor_type,expected_index", + [ + ("gyroscope_x", 0), + ("gyroscope_y", 1), + ("gyroscope_z", 2), + ], + ) + def test_gyroscope_maps_to_angular_rate(self, sensor_type, expected_index): + """Gyroscope sensor types should be remapped to angular_rate in the expression.""" + assert _expr_str(sensor_type) == f"data.angular_rate[{expected_index}]" + + def test_roll_expression(self): + expr = _expr_str("roll") + assert "std::atan2" in expr + assert "data.acceleration[1]" in expr + assert "data.acceleration[2]" in expr + assert "180.0f" in expr + assert "std::numbers::pi_v" in expr + # Roll should NOT reference acceleration[0] + assert "data.acceleration[0]" not in expr + + def test_pitch_expression(self): + expr = _expr_str("pitch") + assert "std::atan2" in expr + assert "std::sqrt" in expr + # All three axes used + assert "data.acceleration[0]" in expr + assert "data.acceleration[1]" in expr + assert "data.acceleration[2]" in expr + assert "180.0f" in expr + assert "std::numbers::pi_v" in expr + # Pitch negates the x component + assert "(-data.acceleration[0])" in expr + + +# --- Calibration math --- +# +# Pure-Python reimplementation of the C++ calibration algorithms so we can +# verify the mathematical properties without needing to compile C++. + + +def _mat_vec(m: list[float], v: list[float]) -> list[float]: + """Multiply a row-major 3x3 matrix by a 3-vector.""" + return [ + m[0] * v[0] + m[1] * v[1] + m[2] * v[2], + m[3] * v[0] + m[4] * v[1] + m[5] * v[2], + m[6] * v[0] + m[7] * v[1] + m[8] * v[2], + ] + + +def _mat_mul(a: list[float], b: list[float]) -> list[float]: + """Multiply two row-major 3x3 matrices.""" + r = [0.0] * 9 + for i in range(3): + for j in range(3): + r[i * 3 + j] = sum(a[i * 3 + k] * b[k * 3 + j] for k in range(3)) + return r + + +def _transpose(m: list[float]) -> list[float]: + """Transpose a row-major 3x3 matrix.""" + return [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]] + + +def _det(m: list[float]) -> float: + """Determinant of a 3x3 matrix.""" + return ( + m[0] * (m[4] * m[8] - m[5] * m[7]) + - m[1] * (m[3] * m[8] - m[5] * m[6]) + + m[2] * (m[3] * m[7] - m[4] * m[6]) + ) + + +def _calibrate_level( + raw: list[float], matrix: list[float] | None = None +) -> list[float]: + """Python port of MotionComponent::calibrate_level. + + Composes the correction with *matrix* (defaults to identity). + """ + import math + + if matrix is None: + matrix = list(IDENTITY) + + # Apply current matrix first + mapped = _mat_vec(matrix, raw) + + nx, ny, nz = mapped + mag = math.sqrt(nx * nx + ny * ny + nz * nz) + nx /= mag + ny /= mag + nz /= mag + + if nz > 0.9999: + return matrix[:] # already aligned, preserve existing matrix + + if nz < -0.9999: + r = [1, 0, 0, 0, -1, 0, 0, 0, -1] + else: + f = 1.0 / (1.0 + nz) + r = [ + 1.0 - nx * nx * f, + -nx * ny * f, + -nx, + -nx * ny * f, + 1.0 - ny * ny * f, + -ny, + nx, + ny, + nz, + ] + + return _mat_mul(r, matrix) + + +def _calibrate_heading(matrix: list[float], raw: list[float]) -> list[float]: + """Python port of MotionComponent::calibrate_heading.""" + import math + + mapped = _mat_vec(matrix, raw) + mx, my = mapped[0], mapped[1] + h = math.sqrt(mx * mx + my * my) + sign_mx = 1.0 if mx >= 0 else -1.0 + cos_phi = sign_mx * mx / h # = |mx| / h + sin_phi = sign_mx * my / h + + old = matrix[:] + new = old[:] + new[0] = cos_phi * old[0] + sin_phi * old[3] + new[1] = cos_phi * old[1] + sin_phi * old[4] + new[2] = cos_phi * old[2] + sin_phi * old[5] + new[3] = -sin_phi * old[0] + cos_phi * old[3] + new[4] = -sin_phi * old[1] + cos_phi * old[4] + new[5] = -sin_phi * old[2] + cos_phi * old[5] + return new + + +IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1] + + +class TestCalibrateLevel: + """Verify the Rodrigues-based level calibration matrix.""" + + def _assert_maps_to_z(self, raw: list[float]) -> list[float]: + """Assert that the calibration matrix maps raw to [0, 0, 1].""" + import math + + m = _calibrate_level(raw) + mag = math.sqrt(sum(v * v for v in raw)) + norm = [v / mag for v in raw] + result = _mat_vec(m, norm) + assert result[0] == pytest.approx(0, abs=1e-6) + assert result[1] == pytest.approx(0, abs=1e-6) + assert result[2] == pytest.approx(1, abs=1e-6) + return m + + def test_already_flat(self): + m = _calibrate_level([0, 0, 1.0]) + assert m == IDENTITY + + def test_preserves_existing_matrix_when_flat(self): + """If already flat after axis mapping, level cal should not change the matrix.""" + swap = [0, 1, 0, 1, 0, 0, 0, 0, 1] # swap X↔Y + m = _calibrate_level([0, 0, 1.0], swap) + assert m == swap + + def test_composes_with_existing_matrix(self): + """Level calibration should correct tilt while preserving an existing axis swap.""" + import math + + swap = [0, 1, 0, 1, 0, 0, 0, 0, 1] # swap X↔Y + # Tilted raw: gravity has X component in raw frame + raw = [0.3, 0.0, 0.954] + m = _calibrate_level(raw, swap) + # After calibration, current raw should map to [0, 0, ~1] + mag = math.sqrt(sum(v * v for v in raw)) + norm = [v / mag for v in raw] + result = _mat_vec(m, norm) + assert result[0] == pytest.approx(0, abs=1e-5) + assert result[1] == pytest.approx(0, abs=1e-5) + assert result[2] == pytest.approx(1, abs=1e-5) + # Result should differ from calibrating without the swap + m_no_swap = _calibrate_level(raw) + assert m != m_no_swap + + def test_upside_down(self): + m = _calibrate_level([0, 0, -1.0]) + # 180° about X + assert m == [1, 0, 0, 0, -1, 0, 0, 0, -1] + result = _mat_vec(m, [0, 0, -1]) + assert result[2] == pytest.approx(1, abs=1e-6) + + def test_gravity_along_x(self): + self._assert_maps_to_z([1.0, 0, 0]) + + def test_gravity_along_neg_x(self): + self._assert_maps_to_z([-1.0, 0, 0]) + + def test_gravity_along_y(self): + self._assert_maps_to_z([0, 1.0, 0]) + + def test_tilted_45_degrees(self): + import math + + self._assert_maps_to_z( + [math.sin(math.radians(45)), 0, math.cos(math.radians(45))] + ) + + def test_arbitrary_vector(self): + self._assert_maps_to_z([0.3, -0.5, 0.81]) + + def test_unnormalized_input(self): + """Input does not need to be unit length.""" + self._assert_maps_to_z([0.6, -1.0, 1.62]) + + @pytest.mark.parametrize( + "raw", + [ + [1.0, 0, 0], + [0, 1.0, 0], + [0.3, -0.5, 0.81], + [-0.7, 0.4, 0.59], + ], + ) + def test_result_is_proper_rotation(self, raw): + """The resulting matrix should be orthogonal with determinant +1.""" + m = _calibrate_level(raw) + # R^T * R ≈ I + product = _mat_mul(_transpose(m), m) + for i in range(9): + expected = 1.0 if i % 4 == 0 else 0.0 + assert product[i] == pytest.approx(expected, abs=1e-6) + # det ≈ 1 + assert _det(m) == pytest.approx(1.0, abs=1e-6) + + +class TestCalibrateHeading: + """Verify the Z-rotation heading correction.""" + + def test_y_axis_tilt_no_heading_error(self): + """Device tilted purely around Y — heading should already be correct.""" + import math + + flat_raw = [0, 0, 1.0] + level_m = _calibrate_level(flat_raw) + # Tilt 30° around Y: gravity = [-sin30, 0, cos30] + tilted_raw = [-math.sin(math.radians(30)), 0, math.cos(math.radians(30))] + heading_m = _calibrate_heading(level_m, tilted_raw) + # Matrix should barely change since there's no Y component + for i in range(9): + assert heading_m[i] == pytest.approx(level_m[i], abs=1e-6) + + def test_corrects_heading_rotation(self): + """After level+heading calibration, mapped Y should be ~0 when tilted.""" + import math + + # Simulate a sensor whose chip is rotated 30° around Z relative to enclosure + angle = math.radians(30) + # When the enclosure is flat, the raw reading is [0, 0, 1] regardless of Z rotation + level_m = _calibrate_level([0, 0, 1.0]) + + # When tilted around the enclosure's Y axis, the raw reading in the + # chip frame has both X and Y components due to the Z-rotation offset + tilt = math.radians(20) + # In enclosure frame: [-sin(tilt), 0, cos(tilt)] + # Rotated by Z-angle into chip frame: + ex = -math.sin(tilt) * math.cos(angle) + ey = -math.sin(tilt) * math.sin(angle) + ez = math.cos(tilt) + tilted_raw = [ex, ey, ez] + + heading_m = _calibrate_heading(level_m, tilted_raw) + # After correction, mapped Y should be 0 + result = _mat_vec(heading_m, tilted_raw) + assert result[1] == pytest.approx(0, abs=1e-6) + # Z should still be correct + assert result[2] == pytest.approx(math.cos(tilt), abs=1e-6) + + def test_full_calibration_sequence(self): + """End-to-end: level then heading produces correct frame alignment.""" + import math + + # Chip is mounted tilted 15° around Y and 25° around Z + # Build the chip-to-enclosure rotation: Rz(25°) * Ry(15°) + yz = math.radians(25) + yy = math.radians(15) + # Ry(yy) + ry = [ + math.cos(yy), + 0, + math.sin(yy), + 0, + 1, + 0, + -math.sin(yy), + 0, + math.cos(yy), + ] + # Rz(yz) + rz = [ + math.cos(yz), + -math.sin(yz), + 0, + math.sin(yz), + math.cos(yz), + 0, + 0, + 0, + 1, + ] + chip_rot = _mat_mul(rz, ry) # chip orientation in enclosure frame + # Inverse (transpose) maps enclosure vectors to chip readings + chip_rot_inv = _transpose(chip_rot) + + # Step 1: Device flat — gravity in enclosure frame is [0, 0, 1] + flat_raw = _mat_vec(chip_rot_inv, [0, 0, 1]) + level_m = _calibrate_level(flat_raw) + + # After level calibration, flat reading should map to [0, 0, 1] + check_flat = _mat_vec(level_m, flat_raw) + assert check_flat[0] == pytest.approx(0, abs=1e-5) + assert check_flat[1] == pytest.approx(0, abs=1e-5) + assert check_flat[2] == pytest.approx(1, abs=1e-5) + + # Step 2: Tilt enclosure around Y by 20° + tilt = math.radians(20) + tilted_enclosure = [-math.sin(tilt), 0, math.cos(tilt)] + tilted_raw = _mat_vec(chip_rot_inv, tilted_enclosure) + heading_m = _calibrate_heading(level_m, tilted_raw) + + # After heading calibration, the mapped reading should be + # [-sin(tilt), 0, cos(tilt)] — all horizontal component in X + result = _mat_vec(heading_m, tilted_raw) + assert result[0] == pytest.approx(-math.sin(tilt), abs=1e-5) + assert result[1] == pytest.approx(0, abs=1e-5) + assert result[2] == pytest.approx(math.cos(tilt), abs=1e-5) + + @pytest.mark.parametrize( + "raw", + [ + [0.3, -0.5, 0.81], + [-0.7, 0.4, 0.59], + ], + ) + def test_heading_preserves_orthogonality(self, raw): + """Heading correction composed with level should remain a proper rotation.""" + + level_m = _calibrate_level(raw) + # Create a tilted reading for heading calibration + tilt_raw = [v + 0.3 for v in raw] # perturb to get XY component + heading_m = _calibrate_heading(level_m, tilt_raw) + product = _mat_mul(_transpose(heading_m), heading_m) + for i in range(9): + expected = 1.0 if i % 4 == 0 else 0.0 + assert product[i] == pytest.approx(expected, abs=1e-5) + assert _det(heading_m) == pytest.approx(1.0, abs=1e-5) + + +# --- Calibration action schema & codegen --- + + +class TestCalibrateActionSchema: + """Tests for the CALIBRATE_ACTION_SCHEMA used by both calibration actions.""" + + def test_schema_accepts_on_success_key(self): + """on_success must be a recognised optional key.""" + schema_keys = {str(k) for k in CALIBRATE_ACTION_SCHEMA.schema} + assert CONF_ON_SUCCESS in schema_keys + + def test_schema_accepts_on_error_key(self): + """on_error must be a recognised optional key.""" + schema_keys = {str(k) for k in CALIBRATE_ACTION_SCHEMA.schema} + assert CONF_ON_ERROR in schema_keys + + +@pytest.fixture +def mock_codegen(): + """Mock cg and automation functions used by _build_calibrate_action.""" + mock_var = MagicMock() + mock_parent = MagicMock() + + with ( + patch( + "esphome.components.motion.cg.get_variable", + new_callable=AsyncMock, + return_value=mock_parent, + ) as mock_get_var, + patch( + "esphome.components.motion.cg.new_Pvariable", + return_value=mock_var, + ) as mock_new_pvar, + patch( + "esphome.components.motion.automation.build_automation", + new_callable=AsyncMock, + ) as mock_build_auto, + ): + yield { + "get_variable": mock_get_var, + "new_Pvariable": mock_new_pvar, + "build_automation": mock_build_auto, + "var": mock_var, + "parent": mock_parent, + } + + +@pytest.mark.asyncio +async def test_build_calibrate_action_no_triggers(mock_codegen): + """Without on_success/on_error, build_automation should not be called.""" + config = {CONF_ID: MagicMock()} + action_id = MagicMock() + template_arg = MagicMock() + + result = await _build_calibrate_action(config, action_id, template_arg, []) + + assert result is mock_codegen["var"] + mock_codegen["new_Pvariable"].assert_called_once_with( + action_id, template_arg, mock_codegen["parent"] + ) + mock_codegen["build_automation"].assert_not_called() + + +@pytest.mark.asyncio +async def test_build_calibrate_action_with_on_success(mock_codegen): + """on_success should wire build_automation to get_success_trigger().""" + on_success_config = MagicMock() + config = {CONF_ID: MagicMock(), CONF_ON_SUCCESS: on_success_config} + + await _build_calibrate_action(config, MagicMock(), MagicMock(), []) + + mock_codegen["build_automation"].assert_called_once_with( + mock_codegen["var"].get_success_trigger(), [], on_success_config + ) + + +@pytest.mark.asyncio +async def test_build_calibrate_action_with_on_error(mock_codegen): + """on_error should wire build_automation to get_error_trigger().""" + on_error_config = MagicMock() + config = {CONF_ID: MagicMock(), CONF_ON_ERROR: on_error_config} + + await _build_calibrate_action(config, MagicMock(), MagicMock(), []) + + mock_codegen["build_automation"].assert_called_once_with( + mock_codegen["var"].get_error_trigger(), [], on_error_config + ) + + +@pytest.mark.asyncio +async def test_build_calibrate_action_with_both_triggers(mock_codegen): + """Both on_success and on_error should each produce a build_automation call.""" + on_success_config = MagicMock() + on_error_config = MagicMock() + config = { + CONF_ID: MagicMock(), + CONF_ON_SUCCESS: on_success_config, + CONF_ON_ERROR: on_error_config, + } + + await _build_calibrate_action(config, MagicMock(), MagicMock(), []) + + assert mock_codegen["build_automation"].call_count == 2 + calls = mock_codegen["build_automation"].call_args_list + # First call: on_success + assert calls[0].args == ( + mock_codegen["var"].get_success_trigger(), + [], + on_success_config, + ) + # Second call: on_error + assert calls[1].args == ( + mock_codegen["var"].get_error_trigger(), + [], + on_error_config, + ) + + +# --- Clear calibration action --- + + +class TestClearActionSchema: + """Tests for CLEAR_ACTION_SCHEMA.""" + + def test_schema_has_save_key(self): + schema_keys = {str(k) for k in CLEAR_ACTION_SCHEMA.schema} + assert CONF_SAVE in schema_keys + + def test_save_defaults_to_false(self): + result = CLEAR_ACTION_SCHEMA({CONF_ID: "x"}) + assert result[CONF_SAVE] is False + + +@pytest.fixture +def mock_clear_codegen(): + """Mock cg functions used by clear_calibration_to_code.""" + mock_var = MagicMock() + mock_parent = MagicMock() + with ( + patch( + "esphome.components.motion.cg.get_variable", + new_callable=AsyncMock, + return_value=mock_parent, + ), + patch( + "esphome.components.motion.cg.new_Pvariable", + return_value=mock_var, + ) as mock_new_pvar, + patch("esphome.components.motion.cg.add") as mock_add, + ): + yield {"new_Pvariable": mock_new_pvar, "add": mock_add, "var": mock_var} + + +@pytest.mark.asyncio +async def test_clear_action_without_save(mock_clear_codegen): + """With save=False, set_save should not be emitted.""" + config = {CONF_ID: MagicMock(), CONF_SAVE: False} + result = await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) + assert result is mock_clear_codegen["var"] + mock_clear_codegen["add"].assert_not_called() + + +@pytest.mark.asyncio +async def test_clear_action_with_save(mock_clear_codegen): + """With save=True, set_save(True) should be emitted exactly once.""" + config = {CONF_ID: MagicMock(), CONF_SAVE: True} + await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) + mock_clear_codegen["var"].set_save.assert_called_once_with(True) + mock_clear_codegen["add"].assert_called_once() + + +# --- Calibration persistence invalidation --- +# +# The C++ side stores a hash of the build-time base matrix alongside the saved +# calibration so a changed axis_map invalidates stale NVS data without orphaning +# storage (the pref key stays ID-stable). These tests pin the design properties +# of that base-matrix fingerprint: deterministic for identical maps, distinct +# for different ones. + + +def _hash_matrix(matrix: list[float]) -> int: + """Python port of the C++ hash_matrix() (FNV-1a over the float bytes).""" + import struct + + data = struct.pack("<9f", *matrix) + h = 2166136261 + for b in data: + h ^= b + h = (h * 16777619) & 0xFFFFFFFF + return h + + +class TestBaseMatrixHash: + """Properties of the base-matrix fingerprint used for NVS invalidation.""" + + def test_identical_axis_maps_hash_equal(self): + a = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + b = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + assert _hash_matrix([float(v) for v in a]) == _hash_matrix( + [float(v) for v in b] + ) + + def test_different_axis_maps_hash_differ(self): + identity = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + swapped = _axis_map_to_matrix({"x": "y", "y": "x", "z": "z"}) + assert _hash_matrix([float(v) for v in identity]) != _hash_matrix( + [float(v) for v in swapped] + ) + + def test_sign_change_hashes_differ(self): + pos = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + neg = _axis_map_to_matrix({"x": "-x", "y": "y", "z": "z"}) + assert _hash_matrix([float(v) for v in pos]) != _hash_matrix( + [float(v) for v in neg] + ) + + +# --- Sensor config schema type validation --- + + +class TestSensorConfigSchema: + """Tests for sensor CONFIG_SCHEMA type key validation.""" + + def test_invalid_type_rejected(self): + with pytest.raises((Invalid, MultipleInvalid), match="Unknown value"): + CONFIG_SCHEMA({"type": "invalid_type"}) + + def test_missing_type_rejected(self): + with pytest.raises((Invalid, MultipleInvalid)): + CONFIG_SCHEMA({}) + + @pytest.mark.parametrize( + "sensor_type", + _ACCELERATIONS + _GYROSCOPES + _ANGULAR_RATES + [CONF_PITCH, CONF_ROLL], + ) + def test_valid_types_accepted(self, sensor_type): + """Valid sensor types should pass type validation (errors from missing + required fields like motion_id are expected and acceptable).""" + try: + CONFIG_SCHEMA({"type": sensor_type}) + except (Invalid, MultipleInvalid) as e: + # Should NOT be a type validation error + assert "Unknown value" not in str(e), ( + f"Type '{sensor_type}' was rejected as unknown" + ) From 8400bab9265b0d12252514421294c07a4095d253 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:58:25 -0400 Subject: [PATCH 1575/2214] [esp32] Make no-default-board variant test explicit about platformio toolchain (#16847) --- tests/component_tests/esp32/test_esp32.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e0fcbab0ee..e9fa9446d4 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -83,7 +83,7 @@ def test_esp32_config( id="mismatched_board_variant_config", ), pytest.param( - {"variant": "esp32s31"}, + {"variant": "esp32s31", "toolchain": Toolchain.PLATFORMIO.value}, r"No default board is known for ESP32S31\. Please specify the `board:` option explicitly\. @ data\['variant'\]", id="variant_without_default_board_requires_explicit_board_under_platformio", ), From 64fc09646cdaecab29c55a002435f147a78a2dc4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:00:42 -0400 Subject: [PATCH 1576/2214] [esp32] Fix clang-tidy on ESP-IDF 6 (#16850) --- .clang-tidy.hash | 2 +- esphome/components/bthome_mithermometer/bthome_ble.cpp | 1 + esphome/components/ledc/ledc_output.cpp | 3 +++ platformio.ini | 9 ++------- script/clang-tidy | 2 ++ sdkconfig.defaults | 8 ++++++++ 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index e89b4230ad..25ae506732 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d9c755e5f019b2ecb324834717bc1fb8563e622f5751794cb7156d324884481e +58a760f5fd174bd438bcc3a7018292c158530c1a1d15181941c832d4c032511c diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index ff12e6157d..ff38ab1740 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -222,6 +222,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da } size_t plaintext_length; + // NOLINTNEXTLINE(readability-suspicious-call-argument) - similarly named size args are not swapped psa_status_t status = psa_aead_decrypt(key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE), nonce.data(), nonce.size(), nullptr, 0, ct_with_tag, ct_with_tag_size, payload.data(), ciphertext_size, &plaintext_length); diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index bfb629143d..62833a7649 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -165,6 +165,8 @@ void LEDCOutput::write_state(float state) { void LEDCOutput::setup() { if (!ledc_peripheral_reset_done) { ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); + // Skip under clang-tidy: the inlined HAL MMIO writes trip clang-analyzer-core.FixedAddressDereference +#if !defined(CLANG_TIDY) #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); } #elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) @@ -174,6 +176,7 @@ void LEDCOutput::setup() { } #else periph_module_reset(PERIPH_LEDC_MODULE); +#endif #endif ledc_peripheral_reset_done = true; } diff --git a/platformio.ini b/platformio.ini index d3fde193b4..182f426a31 100644 --- a/platformio.ini +++ b/platformio.ini @@ -132,10 +132,7 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip -platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz +platform = https://github.com/pioarduino/platform-espressif32.git framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -167,9 +164,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip -platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz +platform = https://github.com/pioarduino/platform-espressif32.git framework = espidf lib_deps = diff --git a/script/clang-tidy b/script/clang-tidy index f19bdb9b56..1416b9b332 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -73,6 +73,7 @@ def clang_options(idedata): "-freorder-blocks", "-fno-jump-tables", "-fno-shrink-wrap", + "-mno-target-align", ) if "zephyr" in triplet: @@ -137,6 +138,7 @@ def clang_options(idedata): if flag not in omit_flags and not flag.startswith("-Werror") and not flag.startswith("-std=") + and not flag.startswith("-mtune=esp") ) cmd.append("-std=gnu++20") diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 8d177a7e26..2bd702f48e 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -2,6 +2,14 @@ # (clang-tidy) -- by both the PlatformIO and the native ESP-IDF toolchain paths -- and when PlatformIO is run directly # from the source directory (e.g. by IDEs). This should enable all flags that are set by any component. +# clang-tidy analyzes with clang, and IDF 6 only offers newlib under clang +# (picolibc's Kconfig depends on !IDF_TOOLCHAIN_CLANG). The idedata is generated +# with GCC, whose default is picolibc -- but clang-tidy uses the toolchain's +# newlib headers, so a picolibc build config mismatches them (IDF's hal/assert.h +# redeclares abort()/__assert_func() with [[noreturn]] after newlib's stdlib.h, +# tripping clang-diagnostic-error). Pin newlib to match the analyzed headers. +CONFIG_LIBC_NEWLIB=y + # esp32 CONFIG_COMPILER_OPTIMIZATION_SIZE=y CONFIG_FREERTOS_HZ=1000 From cbc3770b11709bcdd7b5725bb42bfc8493f30b53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jun 2026 17:30:43 -0500 Subject: [PATCH 1577/2214] Include model-driven display schemas in the language schema dump (#16872) --- esphome/components/epaper_spi/display.py | 7 ++- esphome/components/mipi/__init__.py | 54 ++++++++++++++++++++++ esphome/components/mipi_dsi/display.py | 2 + esphome/components/mipi_rgb/display.py | 2 + esphome/components/mipi_spi/display.py | 2 + script/build_language_schema.py | 4 ++ tests/script/test_build_language_schema.py | 17 +++++++ 7 files changed, 87 insertions(+), 1 deletion(-) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 658f9e2c4a..b7c56a283a 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -5,7 +5,11 @@ from esphome import core, pins import esphome.codegen as cg from esphome.components import display, spi from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation -from esphome.components.mipi import flatten_sequence, map_sequence +from esphome.components.mipi import ( + flatten_sequence, + map_sequence, + model_schema_extractor, +) import esphome.config_validation as cv from esphome.config_validation import update_interval from esphome.const import ( @@ -111,6 +115,7 @@ def model_schema(config): ) +@model_schema_extractor(MODELS, model_schema) def customise_schema(config): """ Create a customised config schema for a specific model and validate the configuration. diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index ccd43c72cf..c3b744c919 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -2,8 +2,12 @@ # Various configuration constants for MIPI displays # Various utility functions for MIPI DBI configuration +from collections.abc import Callable +import functools from typing import Any, Self +import voluptuous as vol + from esphome.components.const import CONF_COLOR_DEPTH from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns import esphome.config_validation as cv @@ -18,6 +22,7 @@ from esphome.const import ( CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, + CONF_MODEL, CONF_OFFSET_HEIGHT, CONF_OFFSET_WIDTH, CONF_PAGES, @@ -27,6 +32,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import TimePeriod +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor LOGGER = cv.logging.getLogger(__name__) @@ -239,6 +245,54 @@ def delay(ms): return DELAY_FLAG, ms +# Generic placeholder model present in every DriverChip registry; skipped when +# choosing a representative model for schema extraction. +_CUSTOM_MODEL = "CUSTOM" + + +def model_schema_extractor( + models: dict[str, Any], + model_schema: Callable[[dict[str, Any]], Any], + extra: dict[str, Any] | None = None, +) -> Callable[[Callable[[Any], Any]], Callable[[Any], Any]]: + """ + Decorate a model-driven display CONFIG_SCHEMA so the language-schema dumper + can extract it. + + The schema is generated per ``model`` at validation time, so the static + dumper has nothing to walk. When the dumper passes SCHEMA_EXTRACT, resolve a + representative schema for a real model (the generic "CUSTOM" placeholder + over-constrains fields like init_sequence) plus any *extra* keys the model + needs, e.g. a bus mode, and hand that back; runtime validation is untouched. + """ + + def decorate(config_schema: Callable[[Any], Any]) -> Callable[[Any], Any]: + @schema_extractor("schema") + @functools.wraps(config_schema) + def wrapper(config: Any) -> Any: + if config is not SCHEMA_EXTRACT: + return config_schema(config) + names = sorted(models) + representative = next((n for n in names if n != _CUSTOM_MODEL), names[0]) + schema = model_schema({CONF_MODEL: representative, **(extra or {})}) + if isinstance(schema, vol.All): + schema = next( + (v for v in schema.validators if isinstance(v, vol.Schema)), + schema, + ) + if isinstance(schema, vol.Schema): + # The resolved schema pins ``model`` to the representative; expose + # the full model list so the dumped enum offers every model. + schema = schema.extend( + {cv.Required(CONF_MODEL): cv.one_of(*names, upper=True)} + ) + return schema + + return wrapper + + return decorate + + class DriverChip: """ A class representing a MIPI DBI driver chip model. diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 0939d84aa5..46e7a7d5a7 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -32,6 +32,7 @@ from esphome.components.mipi import ( dimension_schema, get_color_depth, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) @@ -161,6 +162,7 @@ def model_schema(config): ) +@model_schema_extractor(MODELS, model_schema) def _config_schema(config): config = cv.Schema( { diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index b38ddad491..3c33c26726 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -30,6 +30,7 @@ from esphome.components.mipi import ( DriverChip, dimension_schema, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) @@ -219,6 +220,7 @@ def model_schema(config): return schema +@model_schema_extractor(MODELS, model_schema) def _config_schema(config): config = cv.Schema( { diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 3c5a84594e..8c6ffff500 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -22,6 +22,7 @@ from esphome.components.mipi import ( dimension_schema, get_color_depth, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) @@ -227,6 +228,7 @@ def model_schema(config): return schema +@model_schema_extractor(MODELS, model_schema, extra={CONF_BUS_MODE: TYPE_SINGLE}) def customise_schema(config): """ Create a customised config schema for a specific model and validate the configuration. diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 025186299d..4b0b0ee548 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1002,6 +1002,10 @@ def convert(schema, config_var, path): else: config_var["use_id_type"] = str(data.base) config_var[S_TYPE] = "use_id" + elif schema_type == "schema": + # A callable CONFIG_SCHEMA that returned a representative schema + # for extraction (model-driven components); walk it as usual. + convert(data, config_var, path) else: raise TypeError("Unknown extracted schema type") elif config_var.get("key") == "GeneratedID": diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index dd1d88e74c..8b81a57fef 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -117,6 +117,23 @@ def test_convert_emits_explicit_sensitive_marker() -> None: assert config_var["type"] == "string" +def test_convert_walks_callable_schema_extractor() -> None: + """A callable schema tagged for "schema" extraction is resolved and walked.""" + from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor + + @schema_extractor("schema") + def dynamic_schema(value): + if value is SCHEMA_EXTRACT: + return cv.Schema({cv.Required("foo"): cv.string}) + return value + + config_var: dict = {} + _bls.convert(dynamic_schema, config_var, "/test") + + assert config_var["type"] == "schema" + assert "foo" in config_var["schema"]["config_vars"] + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") From 54c73bf1bcb863f2afa53f9c2bbe3c8de885d2bb Mon Sep 17 00:00:00 2001 From: "Kevin P. Fleming" Date: Mon, 8 Jun 2026 09:04:09 -0400 Subject: [PATCH 1578/2214] [ade7880][airthings_wave_base] Remove kpfleming from CODEOWNERS (#16858) --- CODEOWNERS | 3 +-- esphome/components/ade7880/__init__.py | 1 - esphome/components/airthings_wave_base/__init__.py | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index abe33f9467..6a81cc1d40 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -19,7 +19,6 @@ esphome/components/ac_dimmer/* @glmnet esphome/components/adc/* @esphome/core esphome/components/adc128s102/* @DeerMaximum esphome/components/addressable_light/* @justfalter -esphome/components/ade7880/* @kpfleming esphome/components/ade7953/* @angelnu esphome/components/ade7953_base/* @angelnu esphome/components/ade7953_i2c/* @angelnu @@ -28,7 +27,7 @@ esphome/components/ads1118/* @solomondg1 esphome/components/ags10/* @mak-42 esphome/components/aic3204/* @kbx81 esphome/components/airthings_ble/* @jeromelaban -esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau +esphome/components/airthings_wave_base/* @jeromelaban @ncareau esphome/components/airthings_wave_mini/* @ncareau esphome/components/airthings_wave_plus/* @jeromelaban @precurse esphome/components/alarm_control_panel/* @grahambrown11 @hwstar diff --git a/esphome/components/ade7880/__init__.py b/esphome/components/ade7880/__init__.py index aed63c7dfa..e69de29bb2 100644 --- a/esphome/components/ade7880/__init__.py +++ b/esphome/components/ade7880/__init__.py @@ -1 +0,0 @@ -CODEOWNERS = ["@kpfleming"] diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py index c3f3b8f199..dee26b524a 100644 --- a/esphome/components/airthings_wave_base/__init__.py +++ b/esphome/components/airthings_wave_base/__init__.py @@ -21,7 +21,7 @@ from esphome.const import ( UNIT_VOLT, ) -CODEOWNERS = ["@ncareau", "@jeromelaban", "@kpfleming"] +CODEOWNERS = ["@ncareau", "@jeromelaban"] DEPENDENCIES = ["ble_client"] From 36e043debb277f3830ca9ac566b08d143001b054 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jun 2026 12:49:25 -0500 Subject: [PATCH 1579/2214] [tests] Fail component test merge on conflicting duplicate IDs (#16849) --- .github/workflows/ci.yml | 1 + script/analyze_component_buses.py | 9 +- script/ci_check_duplicate_test_ids.py | 214 ++++++++++++++++++ script/merge_component_configs.py | 164 ++++++++------ tests/components/adc/test.bk72xx-ard.yaml | 2 +- tests/components/adc/test.esp32-c2-idf.yaml | 2 +- tests/components/adc/test.esp32-c3-idf.yaml | 2 +- tests/components/adc/test.esp32-idf.yaml | 2 +- tests/components/adc/test.esp32-p4-idf.yaml | 2 +- tests/components/adc/test.esp32-s2-idf.yaml | 2 +- tests/components/adc/test.esp32-s3-idf.yaml | 2 +- tests/components/adc/test.esp8266-ard.yaml | 2 +- tests/components/adc/test.ln882x-ard.yaml | 2 +- tests/components/adc/test.rp2040-ard.yaml | 2 +- .../components/adc/test.rp2040-pico2-ard.yaml | 2 +- .../alarm_control_panel/common.yaml | 6 +- .../components/animation/test.esp32-idf.yaml | 2 +- .../animation/test.esp8266-ard.yaml | 2 +- .../components/animation/test.rp2040-ard.yaml | 2 +- tests/components/api/common-base.yaml | 2 +- tests/components/audio_file/common.yaml | 2 +- .../audio_file/validate.esp32-idf.yaml | 2 +- tests/components/axs15231/common.yaml | 4 +- .../components/axs15231/test.esp8266-ard.yaml | 4 +- tests/components/bang_bang/common.yaml | 12 +- tests/components/binary_sensor/common.yaml | 6 +- .../components/binary_sensor_map/common.yaml | 24 +- tests/components/ble_client/common.yaml | 4 +- tests/components/canbus/common.yaml | 6 +- tests/components/cd74hc4067/common.yaml | 6 +- tests/components/climate_ir_lg/common.yaml | 4 +- .../components/color_temperature/common.yaml | 8 +- tests/components/copy/common.yaml | 8 +- tests/components/ct_clamp/common.yaml | 4 +- tests/components/current_based/common.yaml | 6 +- tests/components/cwww/common.yaml | 4 +- tests/components/cwww/test.esp32-idf.yaml | 4 +- tests/components/cwww/test.esp8266-ard.yaml | 4 +- tests/components/cwww/test.rp2040-ard.yaml | 4 +- tests/components/duty_time/common.yaml | 4 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- tests/components/ektf2232/common.yaml | 4 +- tests/components/endstop/common.yaml | 12 +- tests/components/esp32_can/common.yaml | 2 +- .../esp32_can/test.esp32-c6-idf.yaml | 6 +- tests/components/espnow/common.yaml | 6 +- .../components/fastled_clockless/common.yaml | 6 +- tests/components/fastled_spi/common.yaml | 6 +- tests/components/font/common.yaml | 6 +- tests/components/font/test.host.yaml | 6 +- tests/components/graph/common.yaml | 2 +- .../graphical_display_menu/common.yaml | 17 +- tests/components/gt911/common.yaml | 4 +- tests/components/homeassistant/common.yaml | 4 +- tests/components/image/test.esp32-idf.yaml | 2 +- tests/components/image/test.esp8266-ard.yaml | 2 +- tests/components/image/test.rp2040-ard.yaml | 2 +- tests/components/infrared/common.yaml | 2 +- .../components/integration/common-esp32.yaml | 4 +- .../integration/test.esp8266-ard.yaml | 4 +- .../integration/test.rp2040-ard.yaml | 4 +- tests/components/ir_rf_proxy/common-rx.yaml | 2 +- tests/components/lcd_gpio/common.yaml | 2 +- tests/components/lcd_menu/common.yaml | 8 +- tests/components/light/common.yaml | 2 +- tests/components/light/test.esp32-idf.yaml | 2 +- tests/components/light/test.esp8266-ard.yaml | 2 +- .../components/light/test.nrf52-adafruit.yaml | 4 +- tests/components/light/test.nrf52-mcumgr.yaml | 4 +- tests/components/light/test.rp2040-ard.yaml | 2 +- tests/components/lilygo_t5_47/common.yaml | 4 +- tests/components/lock/common.yaml | 4 +- tests/components/monochromatic/common.yaml | 4 +- tests/components/mpr121/common.yaml | 6 +- tests/components/mqtt/common.yaml | 20 +- .../components/mqtt_subscribe/common-ard.yaml | 4 +- .../components/mqtt_subscribe/common-idf.yaml | 4 +- tests/components/ntc/common.yaml | 10 +- tests/components/number/common.yaml | 4 +- .../components/online_image/common-esp32.yaml | 2 +- .../online_image/common-esp8266.yaml | 2 +- .../online_image/common-rp2040.yaml | 2 +- .../online_image/test.esp32-s3-ard.yaml | 2 +- .../online_image/test.esp32-s3-idf.yaml | 2 +- tests/components/output/common.yaml | 12 +- tests/components/pi4ioe5v6408/common.yaml | 2 +- tests/components/pid/common.yaml | 6 +- tests/components/prometheus/common.yaml | 6 +- tests/components/qspi_dbi/common.yaml | 2 +- .../remote_transmitter/common-buttons.yaml | 6 +- tests/components/resistance/common.yaml | 6 +- tests/components/rgb/common.yaml | 12 +- tests/components/rgbct/common.yaml | 20 +- tests/components/rgbw/common.yaml | 16 +- tests/components/rgbww/common.yaml | 20 +- .../rp2040_pio_led_strip/common.yaml | 2 +- tests/components/rp2040_pwm/common.yaml | 4 +- tests/components/sdl/common.yaml | 8 +- tests/components/speaker/common.yaml | 4 +- tests/components/speaker_source/common.yaml | 2 +- tests/components/speed/common.yaml | 6 +- tests/components/sprinkler/common.yaml | 12 +- tests/components/ssd1306_i2c/common.yaml | 2 +- tests/components/switch/common.yaml | 2 +- tests/components/sx126x/common.yaml | 4 +- tests/components/sx127x/common.yaml | 4 +- tests/components/template/common-base.yaml | 40 ++-- tests/components/tlc5947/common.yaml | 4 +- tests/components/tlc5971/common.yaml | 4 +- tests/components/tt21100/common.yaml | 4 +- tests/components/uart/test.esp32-idf.yaml | 4 +- tests/components/udp/common.yaml | 4 +- tests/components/ufire_ec/common.yaml | 6 +- tests/components/ufire_ise/common.yaml | 4 +- tests/components/web_server_idf/common.yaml | 4 +- tests/components/wk2132_i2c/common.yaml | 2 +- tests/components/wk2132_spi/common.yaml | 2 +- tests/components/wk2168_i2c/common.yaml | 2 +- tests/components/wk2168_spi/common.yaml | 2 +- tests/components/wk2204_i2c/common.yaml | 2 +- tests/components/wk2204_spi/common.yaml | 2 +- tests/components/wk2212_i2c/common.yaml | 2 +- tests/components/wk2212_spi/common.yaml | 2 +- .../test_ci_check_duplicate_test_ids.py | 114 ++++++++++ tests/script/test_merge_component_configs.py | 101 +++++++++ 125 files changed, 836 insertions(+), 372 deletions(-) create mode 100755 script/ci_check_duplicate_test_ids.py create mode 100644 tests/script/test_ci_check_duplicate_test_ids.py create mode 100644 tests/script/test_merge_component_configs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0d604f248..3115b5b473 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,7 @@ jobs: script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check + script/ci_check_duplicate_test_ids.py import-time: name: Check import esphome.__main__ time diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index a343e34328..8eb80d9943 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -39,8 +39,13 @@ from helpers import BASE_BUS_COMPONENTS, is_validate_only_file from esphome import yaml_util from esphome.config_helpers import Extend, Remove -# Path to common bus configs -COMMON_BUS_PATH = Path("tests/test_build_components/common") +# Path to common bus configs (resolved relative to this file, not the CWD) +COMMON_BUS_PATH = ( + Path(__file__).resolve().parent.parent + / "tests" + / "test_build_components" + / "common" +) # Package dependencies - maps packages to the packages they include # When a component uses a package on the left, it automatically gets diff --git a/script/ci_check_duplicate_test_ids.py b/script/ci_check_duplicate_test_ids.py new file mode 100755 index 0000000000..13da66c9b1 --- /dev/null +++ b/script/ci_check_duplicate_test_ids.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Fail when two component test fixtures define the same id with different content. + +Component tests are merged and built in groups in CI (see +``script/merge_component_configs.py``). When two components declare the same id +under the same section but with different content, the merge keeps the first and +drops the rest, which can make a cross-reference resolve to an incompatible +entity (this is what broke the i2s_audio speaker tests). That only surfaces when +the two components happen to land in the same group, often in an unrelated PR +long after the duplicate was written. + +This script is the complete, batch-independent guard: it scans every component's +``test..yaml`` per platform and reports any id that is defined by more +than one component with differing content, so a collision fails the PR that +introduces it and names the exact id and components. + +To stay byte-for-byte consistent with what the merge actually does (so the guard +never disagrees with the build), it reuses the merge's own helpers: + +* ``prefix_substitutions_in_dict`` -- the merge prefixes every component's + substitution references with the component name before deduplicating, so e.g. + ``pin: ${pin}`` in two components becomes ``${a_pin}`` and ``${b_pin}`` and + conflicts. We apply the same prefixing; otherwise a shared id whose only + difference is a substitution looks identical here but conflicts at merge time. +* ``deduplicate_by_id`` -- the actual merge comparison (including the + ``INTENTIONALLY_SHARED_IDS`` allowlist for deliberately shared singletons such + as ``sntp_time``). We feed each shared id's prefixed items straight through it + and treat a raised ``ValueError`` as a conflict, so this check and the merge + can never diverge. + +``packages:`` are left as opaque ``!include`` objects by the loader -- exactly as +the merge sees them at dedup time -- so package-provided bus ids (``i2c_bus`` ...) +are not compared here, matching the merge, which re-adds those packages once. +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from esphome.core import EsphomeError # noqa: E402 +from script.merge_component_configs import ( # noqa: E402 + deduplicate_by_id, + load_yaml_file, + prepare_component_body, +) + +# Resolved relative to this file (not the CWD) so the scan cannot silently cover +# nothing when run from a different directory. +TESTS_DIR = Path(__file__).resolve().parent.parent / "tests" / "components" + + +def _collect_ids( + data: object, + path: tuple[str, ...], + out: dict[tuple[tuple[str, ...], object], object], +) -> None: + """Record (dict_path, id) -> item for id-bearing items in dict-reachable lists. + + Keyed by the full dict path (not just the immediate key) so items under + different paths that happen to share a list key name are never compared. Only + lists reached purely through dict keys are recorded: once the merge + concatenates a list, items from different components live in separate elements, + so anything deeper is never compared across components (matching how + ``merge_config`` combines bodies). Ids keep their original type so ``5`` and + ``"5"`` stay distinct, exactly as ``deduplicate_by_id`` treats them; an + unhashable id (rare) falls back to its ``repr`` so it can still be grouped. + """ + if not isinstance(data, dict): + return + for key, value in data.items(): + new_path = path + (key,) + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and "id" in item: + item_id = item["id"] + try: + hash(item_id) + except TypeError: + item_id = repr(item_id) + out[(new_path, item_id)] = item + elif isinstance(value, dict): + _collect_ids(value, new_path, out) + + +def _discover_platforms() -> set[str]: + platforms: set[str] = set() + for test_file in TESTS_DIR.glob("*/test.*.yaml"): + # test..yaml -> platform is the middle dotted part + parts = test_file.name.split(".") + if len(parts) == 3: + platforms.add(parts[1]) + return platforms + + +def _load_components( + platform: str, parse_errors: list[str] +) -> Iterator[tuple[str, object]]: + """Yield (component, prefixed config) for each component testing this platform. + + Each body is prepared with ``prepare_component_body`` (the same helper the + merge uses: it expands component-specific package includes and prefixes + substitutions), so the comparison sees what the build merges. Fixtures that + fail to parse are recorded in ``parse_errors`` so the run can fail rather than + silently skip them. + """ + for comp_dir in sorted(TESTS_DIR.iterdir()): + test_file = comp_dir / f"test.{platform}.yaml" + if not comp_dir.is_dir() or not test_file.exists(): + continue + try: + data = load_yaml_file(test_file) + except EsphomeError as err: + parse_errors.append(str(test_file)) + print(f"ERROR: could not parse {test_file}: {err}", file=sys.stderr) + continue + yield comp_dir.name, prepare_component_body(data, comp_dir.name, comp_dir) + + +@dataclass +class ScanResult: + """Outcome of a scan. A caller cannot observe a clean result while files were + skipped or nothing was scanned -- all three fields are reported together.""" + + conflicts: list[str] = field(default_factory=list) + parse_errors: list[str] = field(default_factory=list) + components_scanned: int = 0 + + +def scan() -> ScanResult: + """Scan every component's base test fixture and report cross-component id conflicts. + + Only base ``test..yaml`` fixtures are scanned because only those are + combined by ``merge_component_configs`` in grouped CI builds; variant + (``test-*.yaml``) fixtures are built individually and never cross-merged. + """ + result = ScanResult() + for platform in sorted(_discover_platforms()): + # (dict_path, id) -> {component: prefixed_item} + groups: dict[tuple[tuple[str, ...], object], dict[str, object]] = defaultdict( + dict + ) + for component, data in _load_components(platform, result.parse_errors): + result.components_scanned += 1 + collected: dict[tuple[tuple[str, ...], object], object] = {} + _collect_ids(data, (), collected) + for key, item in collected.items(): + groups[key][component] = item + + for (path, id_), by_component in sorted( + groups.items(), key=lambda kv: (kv[0][0], str(kv[0][1])) + ): + if len(by_component) < 2: + continue + # Delegate the decision to the merge's own deduplication so this guard + # can never disagree with what the build does. + try: + deduplicate_by_id({path[-1]: list(by_component.values())}) + except ValueError: + result.conflicts.append( + f"[{platform}] id '{id_}' under '{'.'.join(path)}' is defined " + f"differently by: {', '.join(sorted(by_component))}" + ) + return result + + +def main() -> int: + result = scan() + if result.conflicts: + print("Conflicting test component ids found:\n") + for line in result.conflicts: + print(f" - {line}") + print( + "\nGive each component a unique id (e.g. '_'), or add the " + "id to INTENTIONALLY_SHARED_IDS in script/merge_component_configs.py if " + "it is a deliberately shared singleton." + ) + + if result.parse_errors: + # A fixture we could not parse was never scanned, so the run is not a + # clean pass even if no conflicts were found among the rest. + print( + f"\n{len(result.parse_errors)} test fixture(s) could not be parsed and " + "were not checked:" + ) + for path in result.parse_errors: + print(f" - {path}") + + if result.components_scanned == 0: + # A scan that covered nothing is a false green -- the whole point of the + # guard is defeated. Fail loudly (wrong working directory or layout change). + print( + f"\nERROR: scanned 0 component test fixtures under {TESTS_DIR}; " + "the guard covered nothing.", + file=sys.stderr, + ) + + if result.conflicts or result.parse_errors or result.components_scanned == 0: + return 1 + + print( + f"No conflicting test component ids found " + f"({result.components_scanned} fixtures scanned)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index a952ecff16..5eeeafac2a 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -161,18 +161,46 @@ def prefix_substitutions_in_dict( return data +# (section, id) pairs that several components intentionally share. ESPHome +# treats these as a single instance when merged, so duplicates with differing +# content are expected and must not be flagged as accidental collisions. Keyed on +# the section as well as the id so a generic name (e.g. `ldo_id`) is only exempt +# in its intended section -- an accidental collision on the same name elsewhere +# is still caught. +INTENTIONALLY_SHARED_IDS = frozenset( + { + # Several components each declare an `sntp_time` clock; ESPHome merges + # them into one time source. + ("time", "sntp_time"), + # esp_ldo and mipi_dsi both configure the channel-3 internal LDO on the + # ESP32-P4; only one LDO per channel may exist, so the shared id lets the + # merge collapse them into a single LDO. + ("esp_ldo", "ldo_id"), + } +) + + def deduplicate_by_id(data: dict) -> dict: """Deduplicate list items with the same ID. - Keeps only the first occurrence of each ID. If items with the same ID - are identical, this silently deduplicates. If they differ, the first - one is kept (ESPHome's validation will catch if this causes issues). + Identical items sharing an ID (e.g. a shared bus from a common package pulled + in by several components) are collapsed to the first occurrence. Two items + that share an ID but differ in content are a real conflict: when merged, the + first silently wins and the others are dropped, which can make a + cross-reference resolve to an incompatible entity. Rather than defer that to + downstream validation (where it surfaces as a confusing, order-dependent + failure in an unrelated build), raise immediately so the offending ID is + named. Ids in ``INTENTIONALLY_SHARED_IDS`` are deliberately shared singletons + and keep their collapse behaviour. Args: data: Parsed config dictionary Returns: Config with deduplicated lists + + Raises: + ValueError: If two items share an ID but have different content. """ if not isinstance(data, dict): return data @@ -181,16 +209,25 @@ def deduplicate_by_id(data: dict) -> dict: for key, value in data.items(): if isinstance(value, list): # Check for items with 'id' field - seen_ids = set() + seen_items: dict[str, Any] = {} deduped_list = [] for item in value: if isinstance(item, dict) and "id" in item: item_id = item["id"] - if item_id not in seen_ids: - seen_ids.add(item_id) + if item_id not in seen_items: + seen_items[item_id] = item deduped_list.append(item) - # else: skip duplicate ID (keep first occurrence) + elif (key, item_id) in INTENTIONALLY_SHARED_IDS: + # Deliberately shared singleton -> keep first occurrence. + pass + elif item != seen_items[item_id]: + raise ValueError( + f"Conflicting definitions for id '{item_id}' under " + f"'{key}' when merging test configs; give each " + f"component a unique id" + ) + # else: identical duplicate (e.g. shared bus package) -> skip else: # No ID, just add it deduped_list.append(item) @@ -205,6 +242,55 @@ def deduplicate_by_id(data: dict) -> dict: return result +def prepare_component_body(comp_data: dict, comp_name: str, comp_dir: Path) -> dict: + """Return a component's test body as it enters the merge. + + Expands component-specific package includes inline (common bus packages are + left for the merge to re-add once), applies ESPHome's top-level-substitutions + -override-package-substitutions rule, then prefixes every substitution + reference with the component name. Shared by ``merge_component_configs`` and + the duplicate-id guard (``script/ci_check_duplicate_test_ids.py``) so the + guard compares exactly what the build merges. + """ + # $component_dir resolves to the component's absolute path. + comp_abs_dir = str(comp_dir.absolute()) + + # Top-level substitutions override package substitutions, so capture them + # before expanding packages can introduce their own. + top_level_subs = ( + comp_data["substitutions"].copy() + if isinstance(comp_data.get("substitutions"), dict) + else {} + ) + + packages_value = comp_data.get("packages") + if isinstance(packages_value, dict): + common_bus_packages = get_common_bus_packages() + for pkg_name, pkg_value in list(packages_value.items()): + if pkg_name in common_bus_packages: + continue + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + elif isinstance(packages_value, list): + for pkg_value in packages_value: + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + # Common bus packages are re-added once by the caller; drop them here. + comp_data.pop("packages", None) + + subs = comp_data.get("substitutions") or {} + subs.update(top_level_subs) + prefixed_subs = {f"{comp_name}_{name}": value for name, value in subs.items()} + prefixed_subs[f"{comp_name}_component_dir"] = comp_abs_dir + comp_data["substitutions"] = prefixed_subs + + return prefix_substitutions_in_dict(comp_data, comp_name) + + def merge_component_configs( component_names: list[str], platform: str, @@ -266,67 +352,9 @@ def merge_component_configs( # New package type - add it all_packages[pkg_name] = pkg_config - # Handle $component_dir by replacing with absolute path - # This allows components that use local file references to be grouped - comp_abs_dir = str(comp_dir.absolute()) - - # Save top-level substitutions BEFORE expanding packages - # In ESPHome, top-level substitutions override package substitutions - top_level_subs = ( - comp_data["substitutions"].copy() - if "substitutions" in comp_data and comp_data["substitutions"] is not None - else {} - ) - - # Expand packages - but we'll restore substitution priority after - if "packages" in comp_data: - packages_value = comp_data["packages"] - - if isinstance(packages_value, dict): - # Dict format - check each package - common_bus_packages = get_common_bus_packages() - for pkg_name, pkg_value in list(packages_value.items()): - if pkg_name in common_bus_packages: - continue - # Resolve deferred !include files before checking type - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if not isinstance(pkg_value, dict): - continue - # Component-specific package - expand its content into top level - comp_data = merge_config(comp_data, pkg_value) - elif isinstance(packages_value, list): - # List format - expand all package includes - for pkg_value in packages_value: - # Resolve deferred !include files before checking type - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if not isinstance(pkg_value, dict): - continue - comp_data = merge_config(comp_data, pkg_value) - - # Remove all packages (common will be re-added at the end) - del comp_data["packages"] - - # Restore top-level substitution priority - # Top-level substitutions override any from packages - if "substitutions" not in comp_data or comp_data["substitutions"] is None: - comp_data["substitutions"] = {} - - # Merge: package subs as base, top-level subs override - comp_data["substitutions"].update(top_level_subs) - - # Now prefix the final merged substitutions - comp_data["substitutions"] = { - f"{comp_name}_{sub_name}": sub_value - for sub_name, sub_value in comp_data["substitutions"].items() - } - - # Add component_dir substitution with absolute path for this component - comp_data["substitutions"][f"{comp_name}_component_dir"] = comp_abs_dir - - # Prefix substitution references throughout the config - comp_data = prefix_substitutions_in_dict(comp_data, comp_name) + # Expand component-specific packages and prefix substitutions, exactly as + # the duplicate-id guard does, so both see the same body. + comp_data = prepare_component_body(comp_data, comp_name, comp_dir) # Use ESPHome's merge_config to merge this component into the result # merge_config handles list merging with ID-based deduplication automatically diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/test.bk72xx-ard.yaml index 0645333a81..09ef0e1fad 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/test.bk72xx-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: P23 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c2-idf.yaml b/tests/components/adc/test.esp32-c2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c2-idf.yaml +++ b/tests/components/adc/test.esp32-c2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c3-idf.yaml b/tests/components/adc/test.esp32-c3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c3-idf.yaml +++ b/tests/components/adc/test.esp32-c3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-idf.yaml b/tests/components/adc/test.esp32-idf.yaml index ff1e3bb919..f31e0e087d 100644 --- a/tests/components/adc/test.esp32-idf.yaml +++ b/tests/components/adc/test.esp32-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A0 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/test.esp32-p4-idf.yaml index b77dc299c2..77cf50d17c 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/test.esp32-p4-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO16 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/test.esp32-s2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/test.esp32-s2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s3-idf.yaml b/tests/components/adc/test.esp32-s3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s3-idf.yaml +++ b/tests/components/adc/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp8266-ard.yaml b/tests/components/adc/test.esp8266-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.esp8266-ard.yaml +++ b/tests/components/adc/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml index face38b647..d899259773 100644 --- a/tests/components/adc/test.ln882x-ard.yaml +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A5 name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-ard.yaml b/tests/components/adc/test.rp2040-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-ard.yaml +++ b/tests/components/adc/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-pico2-ard.yaml +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 39d5739255..327234d6ca 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: gpio - id: bin1 + id: alarm_control_panel_bin1 pin: 1 alarm_control_panel: @@ -18,7 +18,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true @@ -39,7 +39,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true diff --git a/tests/components/animation/test.esp32-idf.yaml b/tests/components/animation/test.esp32-idf.yaml index c28e9584dd..b844f5ae92 100644 --- a/tests/components/animation/test.esp32-idf.yaml +++ b/tests/components/animation/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 12 diff --git a/tests/components/animation/test.esp8266-ard.yaml b/tests/components/animation/test.esp8266-ard.yaml index 11a7117d91..a7937ffca2 100644 --- a/tests/components/animation/test.esp8266-ard.yaml +++ b/tests/components/animation/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/animation/test.rp2040-ard.yaml b/tests/components/animation/test.rp2040-ard.yaml index 2c99e937f3..2cbb254adf 100644 --- a/tests/components/animation/test.rp2040-ard.yaml +++ b/tests/components/animation/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index ca86445777..060254990d 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -296,7 +296,7 @@ api: event: - platform: template name: Test Event - id: test_event + id: api_test_event event_types: - single_click - double_click diff --git a/tests/components/audio_file/common.yaml b/tests/components/audio_file/common.yaml index e7f55b4806..02cb3814f2 100644 --- a/tests/components/audio_file/common.yaml +++ b/tests/components/audio_file/common.yaml @@ -1,5 +1,5 @@ audio_file: - - id: test_audio + - id: audio_file_test_audio file: type: local path: $component_dir/test.wav diff --git a/tests/components/audio_file/validate.esp32-idf.yaml b/tests/components/audio_file/validate.esp32-idf.yaml index 085f853c8e..1d8d4646fa 100644 --- a/tests/components/audio_file/validate.esp32-idf.yaml +++ b/tests/components/audio_file/validate.esp32-idf.yaml @@ -1,5 +1,5 @@ audio_file: - - id: test_audio + - id: audio_file_test_audio file: type: local path: $component_dir/test.wav diff --git a/tests/components/axs15231/common.yaml b/tests/components/axs15231/common.yaml index d4fd3becbb..03e82ab26e 100644 --- a/tests/components/axs15231/common.yaml +++ b/tests/components/axs15231/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: axs15231_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: 19 pages: @@ -13,6 +13,6 @@ touchscreen: - platform: axs15231 i2c_id: i2c_bus id: axs15231_touchscreen - display: ssd1306_i2c_display + display: axs15231_ssd1306_i2c_display interrupt_pin: 20 reset_pin: 18 diff --git a/tests/components/axs15231/test.esp8266-ard.yaml b/tests/components/axs15231/test.esp8266-ard.yaml index eb599da773..245b87bec9 100644 --- a/tests/components/axs15231/test.esp8266-ard.yaml +++ b/tests/components/axs15231/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: axs15231_ssd1306_display model: SSD1306_128X64 reset_pin: 13 pages: @@ -15,5 +15,5 @@ display: touchscreen: - platform: axs15231 i2c_id: i2c_bus - display: ssd1306_display + display: axs15231_ssd1306_display interrupt_pin: 12 diff --git a/tests/components/bang_bang/common.yaml b/tests/components/bang_bang/common.yaml index 5882025191..28798f8173 100644 --- a/tests/components/bang_bang/common.yaml +++ b/tests/components/bang_bang/common.yaml @@ -1,6 +1,6 @@ switch: - platform: template - id: template_switch1 + id: bang_bang_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -8,7 +8,7 @@ switch: sensor: - platform: template - id: template_sensor1 + id: bang_bang_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -20,16 +20,16 @@ sensor: climate: - platform: bang_bang name: Bang Bang Climate - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: bang_bang_template_sensor1 + humidity_sensor: bang_bang_template_sensor1 default_target_temperature_low: 18°C default_target_temperature_high: 24°C idle_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 cool_action: - switch.turn_on: template_switch2 heat_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 away_config: default_target_temperature_low: 16°C default_target_temperature_high: 20°C diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index e3fd159b08..4f4cf6ea59 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -1,7 +1,7 @@ binary_sensor: - platform: template trigger_on_initial_state: true - id: some_binary_sensor + id: binary_sensor_some_binary_sensor name: "Random binary" lambda: return (random_uint32() & 1) == 0; filters: @@ -21,7 +21,7 @@ binary_sensor: time_off: 100ms time_on: 400ms - lambda: |- - if (id(some_binary_sensor).state) { + if (id(binary_sensor_some_binary_sensor).state) { return x; } return {}; @@ -36,7 +36,7 @@ binary_sensor: - logger.log: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - - binary_sensor.invalidate_state: some_binary_sensor + - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor # Test autorepeat with default configuration (no timings) - platform: template diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index c054022583..667d0be9e7 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -1,20 +1,20 @@ binary_sensor: - platform: template - id: bin1 + id: binary_sensor_map_bin1 lambda: |- if (millis() > 10000) { return true; } return false; - platform: template - id: bin2 + id: binary_sensor_map_bin2 lambda: |- if (millis() > 20000) { return true; } return false; - platform: template - id: bin3 + id: binary_sensor_map_bin3 lambda: |- if (millis() > 30000) { return true; @@ -26,33 +26,33 @@ sensor: name: Binary Sensor Map Group type: group channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Sum type: sum channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Bayesian type: bayesian prior: 0.4 observations: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 prob_given_true: 0.9 prob_given_false: 0.4 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 prob_given_true: 0.7 prob_given_false: 0.05 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 prob_given_true: 0.8 prob_given_false: 0.2 diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index 4ea1dd60f3..4ed6ad7fc9 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -56,7 +56,7 @@ sensor: number: - platform: template name: "Test Number" - id: test_number + id: ble_client_test_number optimistic: true min_value: 0 max_value: 255 @@ -72,5 +72,5 @@ button: service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" value: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(ble_client_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index e779f7f078..3ba3564608 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -1,6 +1,6 @@ canbus: - platform: esp32_can - id: esp32_internal_can + id: canbus_esp32_internal_can rx_pin: 4 tx_pin: 5 can_id: 4 @@ -40,7 +40,7 @@ canbus: number: - platform: template name: "Test Number" - id: test_number + id: canbus_test_number optimistic: true min_value: 0 max_value: 255 @@ -62,5 +62,5 @@ button: - canbus.send: !lambda return {0, 1, 2}; # Test canbus.send with lambda that references a component (function pointer) - canbus.send: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(canbus_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/cd74hc4067/common.yaml b/tests/components/cd74hc4067/common.yaml index 9afb39cd31..c217ce9d39 100644 --- a/tests/components/cd74hc4067/common.yaml +++ b/tests/components/cd74hc4067/common.yaml @@ -6,13 +6,13 @@ cd74hc4067: sensor: - platform: adc - id: esp_adc_sensor + id: cd74hc4067_esp_adc_sensor pin: ${pin} - platform: cd74hc4067 id: cd74hc4067_adc_0 number: 0 - sensor: esp_adc_sensor + sensor: cd74hc4067_esp_adc_sensor - platform: cd74hc4067 id: cd74hc4067_adc_1 number: 1 - sensor: esp_adc_sensor + sensor: cd74hc4067_esp_adc_sensor diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index 37011b16ee..e0bc185d2c 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,6 +1,6 @@ sensor: - platform: template - id: temp_sensor + id: climate_ir_lg_temp_sensor lambda: return 22.0; update_interval: 60s - platform: template @@ -12,5 +12,5 @@ climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr - sensor: temp_sensor + sensor: climate_ir_lg_temp_sensor humidity_sensor: humidity_sensor diff --git a/tests/components/color_temperature/common.yaml b/tests/components/color_temperature/common.yaml index fe0c5bf917..0db54d10d0 100644 --- a/tests/components/color_temperature/common.yaml +++ b/tests/components/color_temperature/common.yaml @@ -1,15 +1,15 @@ output: - platform: ${light_platform} - id: light_output_1 + id: color_temperature_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: color_temperature_light_output_2 pin: ${pin_o2} light: - platform: color_temperature name: Lights - color_temperature: light_output_1 - brightness: light_output_2 + color_temperature: color_temperature_light_output_1 + brightness: color_temperature_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index a376004b2f..cbd056f070 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -1,17 +1,17 @@ output: - platform: ${pwm_platform} - id: fan_output_1 + id: copy_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: copy_fan_speed + output: copy_fan_output_1 preset_modes: - Eco - Turbo - platform: copy - source_id: fan_speed + source_id: copy_fan_speed name: Fan Speed Copy select: diff --git a/tests/components/ct_clamp/common.yaml b/tests/components/ct_clamp/common.yaml index 3ed9678447..656b1971a5 100644 --- a/tests/components/ct_clamp/common.yaml +++ b/tests/components/ct_clamp/common.yaml @@ -1,9 +1,9 @@ sensor: - platform: adc - id: esp_adc_sensor + id: ct_clamp_esp_adc_sensor pin: ${pin} - platform: ct_clamp - sensor: esp_adc_sensor + sensor: ct_clamp_esp_adc_sensor name: CT Clamp sample_duration: 500ms update_interval: 5s diff --git a/tests/components/current_based/common.yaml b/tests/components/current_based/common.yaml index 503c4596e9..139571ccec 100644 --- a/tests/components/current_based/common.yaml +++ b/tests/components/current_based/common.yaml @@ -31,7 +31,7 @@ sensor: switch: - platform: template - id: template_switch1 + id: current_based_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -46,7 +46,7 @@ cover: open_obstacle_current_threshold: 0.8 open_duration: 12s open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: current_based_template_switch1 close_sensor: ade7953_current_b close_moving_current_threshold: 0.5 close_obstacle_current_threshold: 0.8 @@ -54,7 +54,7 @@ cover: close_action: - switch.turn_on: template_switch2 stop_action: - - switch.turn_off: template_switch1 + - switch.turn_off: current_based_template_switch1 - switch.turn_off: template_switch2 obstacle_rollback: 30% start_sensing_delay: 0.8s diff --git a/tests/components/cwww/common.yaml b/tests/components/cwww/common.yaml index 7fa5ab668c..bbb6c9182b 100644 --- a/tests/components/cwww/common.yaml +++ b/tests/components/cwww/common.yaml @@ -1,8 +1,8 @@ light: - platform: cwww name: CWWW Light - cold_white: light_output_1 - warm_white: light_output_2 + cold_white: cwww_light_output_1 + warm_white: cwww_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds constant_brightness: true diff --git a/tests/components/cwww/test.esp32-idf.yaml b/tests/components/cwww/test.esp32-idf.yaml index 01edf0b0b5..0665879b08 100644 --- a/tests/components/cwww/test.esp32-idf.yaml +++ b/tests/components/cwww/test.esp32-idf.yaml @@ -5,11 +5,11 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} channel: 0 - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} channel: 1 phase_angle: 180° diff --git a/tests/components/cwww/test.esp8266-ard.yaml b/tests/components/cwww/test.esp8266-ard.yaml index 49d73b7d3d..bb1868fdef 100644 --- a/tests/components/cwww/test.esp8266-ard.yaml +++ b/tests/components/cwww/test.esp8266-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/cwww/test.rp2040-ard.yaml b/tests/components/cwww/test.rp2040-ard.yaml index ba8e0ad071..27fc930b68 100644 --- a/tests/components/cwww/test.rp2040-ard.yaml +++ b/tests/components/cwww/test.rp2040-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 761d10f16a..12e4397c49 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: duty_time_bin1 lambda: |- if (millis() > 10000) { return true; @@ -10,4 +10,4 @@ binary_sensor: sensor: - platform: duty_time name: Duty Time - sensor: bin1 + sensor: duty_time_bin1 diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 25fe3b6796..4593784ef9 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -2,7 +2,7 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: e131_led_strip pin: 2 pio: 0 num_leds: 256 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 1c4d768b08..070b03eeb9 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: ektf2232_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -15,7 +15,7 @@ touchscreen: id: ektf2232_touchscreen interrupt_pin: ${interrupt_pin} reset_pin: ${touch_reset_pin} - display: ssd1306_i2c_display + display: ektf2232_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/endstop/common.yaml b/tests/components/endstop/common.yaml index b92b1e13b9..6f5cf61268 100644 --- a/tests/components/endstop/common.yaml +++ b/tests/components/endstop/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: endstop_bin1 lambda: |- if (millis() > 10000) { return true; @@ -9,7 +9,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: endstop_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -20,12 +20,12 @@ cover: id: endstop_cover name: Endstop Cover stop_action: - - switch.turn_on: template_switch1 - open_endstop: bin1 + - switch.turn_on: endstop_template_switch1 + open_endstop: endstop_bin1 open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: endstop_template_switch1 open_duration: 5min - close_endstop: bin1 + close_endstop: endstop_bin1 close_action: - switch.turn_on: template_switch2 close_duration: 4.5min diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index 3b9b33c048..f15b609d84 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -13,7 +13,7 @@ esphome: canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: ${rx_pin} tx_pin: ${tx_pin} can_id: 4 diff --git a/tests/components/esp32_can/test.esp32-c6-idf.yaml b/tests/components/esp32_can/test.esp32-c6-idf.yaml index ac978482fc..c548b4f0f4 100644 --- a/tests/components/esp32_can/test.esp32-c6-idf.yaml +++ b/tests/components/esp32_can/test.esp32-c6-idf.yaml @@ -3,20 +3,20 @@ esphome: then: - canbus.send: # Extended ID explicit - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can use_extended_id: true can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - canbus.send: # Standard ID by default - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] # Note: esp32_internal_can_2 uses LISTENONLY mode, so no send actions canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: GPIO8 tx_pin: GPIO7 can_id: 4 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index bdc478ea03..f05735e8f4 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: encryption: key: "0123456789abcdef0123456789abcdef" sensors: - - temp_sensor + - espnow_temp_sensor providers: - name: test-provider encryption: @@ -70,9 +70,9 @@ packet_transport: sensor: - platform: internal_temperature - id: temp_sensor + id: espnow_temp_sensor - platform: packet_transport provider: test-provider - remote_id: temp_sensor + remote_id: espnow_temp_sensor id: remote_temp diff --git a/tests/components/fastled_clockless/common.yaml b/tests/components/fastled_clockless/common.yaml index 8b1447a17a..a7ce7ed280 100644 --- a/tests/components/fastled_clockless/common.yaml +++ b/tests/components/fastled_clockless/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_clockless - id: addr1 + id: fastled_clockless_addr1 chipset: WS2811 pin: 13 num_leds: 100 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/fastled_spi/common.yaml b/tests/components/fastled_spi/common.yaml index f6f7c5553b..19d00627f8 100644 --- a/tests/components/fastled_spi/common.yaml +++ b/tests/components/fastled_spi/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_spi - id: addr1 + id: fastled_spi_addr1 chipset: WS2801 clock_pin: 22 data_pin: 23 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/font/common.yaml b/tests/components/font/common.yaml index c156b4aea1..59063291e7 100644 --- a/tests/components/font/common.yaml +++ b/tests/components/font/common.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -50,11 +50,11 @@ font: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: font_ssd1306_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(monocraft), "Hello, World!"); it.print(0, 60, id(monocraft2), "Hello, World!"); diff --git a/tests/components/font/test.host.yaml b/tests/components/font/test.host.yaml index 387ea47335..8ada8b7a4e 100644 --- a/tests/components/font/test.host.yaml +++ b/tests/components/font/test.host.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -44,12 +44,12 @@ font: display: - platform: sdl - id: sdl_display + id: font_sdl_display dimensions: width: 800 height: 600 lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(roboto_greek), "Hello κόσμε!"); it.print(0, 60, id(monocraft), "Hello, World!"); diff --git a/tests/components/graph/common.yaml b/tests/components/graph/common.yaml index 11e2a16ca1..edf4493aa6 100644 --- a/tests/components/graph/common.yaml +++ b/tests/components/graph/common.yaml @@ -12,7 +12,7 @@ graph: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: graph_ssd1306_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: diff --git a/tests/components/graphical_display_menu/common.yaml b/tests/components/graphical_display_menu/common.yaml index 6cee2af232..50f8a5bc85 100644 --- a/tests/components/graphical_display_menu/common.yaml +++ b/tests/components/graphical_display_menu/common.yaml @@ -1,6 +1,7 @@ display: - platform: ssd1306_i2c - id: ssd1306_i2c_display + i2c_id: i2c_bus + id: graphical_display_menu_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -10,12 +11,12 @@ display: font: - file: "gfonts://Roboto" - id: roboto + id: graphical_display_menu_roboto size: 20 number: - platform: template - id: test_number + id: graphical_display_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -31,13 +32,13 @@ select: switch: - platform: template - id: test_switch + id: graphical_display_menu_test_switch optimistic: true graphical_display_menu: id: test_graphical_display_menu - display: ssd1306_i2c_display - font: roboto + display: graphical_display_menu_ssd1306_i2c_display + font: graphical_display_menu_roboto active: false mode: rotary on_enter: @@ -80,7 +81,7 @@ graphical_display_menu: lambda: 'ESP_LOGI("graphical_display_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: "Number" - number: test_number + number: graphical_display_menu_test_number on_enter: then: lambda: 'ESP_LOGI("graphical_display_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' @@ -97,7 +98,7 @@ graphical_display_menu: - display_menu.hide: test_graphical_display_menu - type: switch text: "Switch" - switch: test_switch + switch: graphical_display_menu_test_switch on_text: "Bright" off_text: "Dark" immediate_edit: false diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index ff464cda24..0fc40737f0 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: gt911_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: ssd1306_i2c_display + display: gt911_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 60e3defd49..71a7ac65c2 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -93,12 +93,12 @@ text_sensor: event: - platform: template name: Test Event - id: test_event + id: homeassistant_test_event event_types: - test_event_type on_event: - homeassistant.event: - event: esphome.test_event + event: esphome.homeassistant_test_event data: event_name: !lambda |- return event_type; diff --git a/tests/components/image/test.esp32-idf.yaml b/tests/components/image/test.esp32-idf.yaml index aea2b4bbb0..9e93c4c289 100644 --- a/tests/components/image/test.esp32-idf.yaml +++ b/tests/components/image/test.esp32-idf.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 15 diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 2e7bfc5ae5..492b57c449 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/image/test.rp2040-ard.yaml b/tests/components/image/test.rp2040-ard.yaml index 03a9c42a38..ce2a13fca7 100644 --- a/tests/components/image/test.rp2040-ard.yaml +++ b/tests/components/image/test.rp2040-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/infrared/common.yaml b/tests/components/infrared/common.yaml index cd2b10d31b..d9a4a43a26 100644 --- a/tests/components/infrared/common.yaml +++ b/tests/components/infrared/common.yaml @@ -23,7 +23,7 @@ infrared: # Infrared receiver - platform: ir_rf_proxy - id: ir_rx + id: infrared_ir_rx name: "IR Receiver" remote_receiver_id: ir_receiver diff --git a/tests/components/integration/common-esp32.yaml b/tests/components/integration/common-esp32.yaml index 26550d3c5c..c912fb9b84 100644 --- a/tests/components/integration/common-esp32.yaml +++ b/tests/components/integration/common-esp32.yaml @@ -9,11 +9,11 @@ esphome: sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: ${pin} attenuation: 12db - platform: integration id: integration_sensor - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.esp8266-ard.yaml b/tests/components/integration/test.esp8266-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.esp8266-ard.yaml +++ b/tests/components/integration/test.esp8266-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.rp2040-ard.yaml b/tests/components/integration/test.rp2040-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.rp2040-ard.yaml +++ b/tests/components/integration/test.rp2040-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/ir_rf_proxy/common-rx.yaml b/tests/components/ir_rf_proxy/common-rx.yaml index 37033a128e..7ced9a29b3 100644 --- a/tests/components/ir_rf_proxy/common-rx.yaml +++ b/tests/components/ir_rf_proxy/common-rx.yaml @@ -6,7 +6,7 @@ remote_receiver: infrared: # Infrared receiver - platform: ir_rf_proxy - id: ir_rx + id: ir_rf_proxy_ir_rx name: "IR Receiver" receiver_frequency: 38kHz remote_receiver_id: ir_receiver diff --git a/tests/components/lcd_gpio/common.yaml b/tests/components/lcd_gpio/common.yaml index bd842454a1..cebadcbf2c 100644 --- a/tests/components/lcd_gpio/common.yaml +++ b/tests/components/lcd_gpio/common.yaml @@ -1,6 +1,6 @@ display: - platform: lcd_gpio - id: my_lcd_gpio + id: lcd_gpio_my_lcd_gpio dimensions: 18x4 data_pins: - number: ${d0_pin} diff --git a/tests/components/lcd_menu/common.yaml b/tests/components/lcd_menu/common.yaml index 970c18e0d2..2287e812bd 100644 --- a/tests/components/lcd_menu/common.yaml +++ b/tests/components/lcd_menu/common.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: lcd_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -22,7 +22,7 @@ switch: display: - platform: lcd_gpio - id: my_lcd_gpio + id: lcd_menu_my_lcd_gpio dimensions: 18x4 data_pins: - number: ${d0_pin} @@ -36,7 +36,7 @@ display: lcd_menu: id: test_lcd_menu - display_id: my_lcd_gpio + display_id: lcd_menu_my_lcd_gpio mark_back: 0x5e mark_selected: 0x3e mark_editing: 0x2a @@ -83,7 +83,7 @@ lcd_menu: lambda: 'ESP_LOGI("lcd_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: Number - number: test_number + number: lcd_menu_test_number on_enter: then: lambda: 'ESP_LOGI("lcd_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 2acc080c6d..71c00e5f10 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -156,7 +156,7 @@ light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 925197182c..49e49b4318 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 12 - platform: ledc id: test_ledc_1 diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 518011e925..1eb58eabc4 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 4 - platform: esp8266_pwm id: test_ledc_1 diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index a5a37fd559..21d5cad774 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 - platform: rp2040_pwm id: test_ledc_1 diff --git a/tests/components/lilygo_t5_47/common.yaml b/tests/components/lilygo_t5_47/common.yaml index 18f1ba10ae..5e71736eb0 100644 --- a/tests/components/lilygo_t5_47/common.yaml +++ b/tests/components/lilygo_t5_47/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: lilygo_t5_47_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -14,7 +14,7 @@ touchscreen: i2c_id: i2c_bus id: lilygo_touchscreen interrupt_pin: ${interrupt_pin} - display: ssd1306_i2c_display + display: lilygo_t5_47_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 9ba7f34857..08001855cb 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -7,7 +7,7 @@ esphome: output: - platform: gpio - id: test_binary + id: lock_test_binary pin: 4 lock: @@ -32,4 +32,4 @@ lock: - platform: output name: Generic Output Lock id: test_lock2 - output: test_binary + output: lock_test_binary diff --git a/tests/components/monochromatic/common.yaml b/tests/components/monochromatic/common.yaml index 9915e086eb..e57c7bec29 100644 --- a/tests/components/monochromatic/common.yaml +++ b/tests/components/monochromatic/common.yaml @@ -1,13 +1,13 @@ output: - platform: ${light_platform} - id: light_output_1 + id: monochromatic_light_output_1 pin: ${pin} light: - platform: monochromatic name: Monochromatic Light id: monochromatic_light - output: light_output_1 + output: monochromatic_light_output_1 gamma_correct: 2.8 default_transition_length: 2s effects: diff --git a/tests/components/mpr121/common.yaml b/tests/components/mpr121/common.yaml index 67a06cf9c1..f96651e9bf 100644 --- a/tests/components/mpr121/common.yaml +++ b/tests/components/mpr121/common.yaml @@ -9,15 +9,15 @@ binary_sensor: name: touchkey0 channel: 0 - platform: mpr121 - id: bin1 + id: mpr121_bin1 name: touchkey1 channel: 1 - platform: mpr121 - id: bin2 + id: mpr121_bin2 name: touchkey2 channel: 2 - platform: mpr121 - id: bin3 + id: mpr121_bin3 name: touchkey3 channel: 6 diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 6af2ce3939..a1d27cdbd5 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -74,7 +74,7 @@ binary_sensor: state_topic: some/topic/binary_sensor qos: 2 lambda: |- - if (id(template_sens).state > 30) { + if (id(mqtt_template_sens).state > 30) { // Garage Door is open. return true; } @@ -105,8 +105,8 @@ button: climate: - platform: thermostat name: Test Thermostat - sensor: template_sens - humidity_sensor: template_sens + sensor: mqtt_template_sens + humidity_sensor: mqtt_template_sens action_state_topic: some/topicaction_state current_temperature_state_topic: some/topiccurrent_temperature_state current_humidity_state_topic: some/topiccurrent_humidity_state @@ -283,10 +283,10 @@ cover: datetime: - platform: template name: Date - id: test_date + id: mqtt_test_date type: date state_topic: some/topic/date - command_topic: test_date/custom_command_topic + command_topic: mqtt_test_date/custom_command_topic qos: 2 subscribe_qos: 2 set_action: @@ -300,7 +300,7 @@ datetime: - x.day_of_month - platform: template name: Time - id: test_time + id: mqtt_test_time type: time state_topic: some/topic/time qos: 2 @@ -315,7 +315,7 @@ datetime: - x.second - platform: template name: DateTime - id: test_datetime + id: mqtt_test_datetime type: datetime state_topic: some/topic/datetime qos: 2 @@ -407,7 +407,7 @@ select: sensor: - platform: template name: Template Sensor - id: template_sens + id: mqtt_template_sens lambda: |- if (id(some_binary_sensor).state) { return 42.0; @@ -423,13 +423,13 @@ sensor: - platform: mqtt_subscribe name: MQTT Subscribe Sensor topic: mqtt/topic - id: the_sensor + id: mqtt_the_sensor qos: 2 on_value: - mqtt.publish_json: topic: the/topic payload: |- - root["key"] = id(template_sens).state; + root["key"] = id(mqtt_template_sens).state; root["greeting"] = "Hello World"; switch: diff --git a/tests/components/mqtt_subscribe/common-ard.yaml b/tests/components/mqtt_subscribe/common-ard.yaml index 13ed311b17..6b0b16e500 100644 --- a/tests/components/mqtt_subscribe/common-ard.yaml +++ b/tests/components/mqtt_subscribe/common-ard.yaml @@ -18,13 +18,13 @@ sensor: - platform: mqtt_subscribe name: MQTT Subscribe Sensor topic: mqtt/topic - id: the_sensor + id: mqtt_subscribe_the_sensor qos: 2 on_value: - mqtt.publish_json: topic: the/topic payload: |- - root["key"] = id(the_sensor).state; + root["key"] = id(mqtt_subscribe_the_sensor).state; root["greeting"] = "Hello World"; text_sensor: diff --git a/tests/components/mqtt_subscribe/common-idf.yaml b/tests/components/mqtt_subscribe/common-idf.yaml index 070672f15c..0f5293ac61 100644 --- a/tests/components/mqtt_subscribe/common-idf.yaml +++ b/tests/components/mqtt_subscribe/common-idf.yaml @@ -19,13 +19,13 @@ sensor: - platform: mqtt_subscribe name: MQTT Subscribe Sensor topic: mqtt/topic - id: the_sensor + id: mqtt_subscribe_the_sensor qos: 2 on_value: - mqtt.publish_json: topic: the/topic payload: |- - root["key"] = id(the_sensor).state; + root["key"] = id(mqtt_subscribe_the_sensor).state; root["greeting"] = "Hello World"; text_sensor: diff --git a/tests/components/ntc/common.yaml b/tests/components/ntc/common.yaml index 79ae7f601d..1be2c335bc 100644 --- a/tests/components/ntc/common.yaml +++ b/tests/components/ntc/common.yaml @@ -1,23 +1,23 @@ sensor: - platform: adc - id: my_sensor + id: ntc_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: ntc_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: ntc_resist - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor calibration: b_constant: 3950 reference_resistance: 10k reference_temperature: 25°C - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor2 calibration: - 10.0kOhm -> 25°C diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index c17c2dd5f8..b1a16ebfed 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Test Number" - id: test_number + id: number_test_number optimistic: true min_value: 0 max_value: 100 @@ -10,4 +10,4 @@ number: sensor: - platform: number name: "Test Number Value" - source_id: test_number + source_id: number_test_number diff --git a/tests/components/online_image/common-esp32.yaml b/tests/components/online_image/common-esp32.yaml index 32c909d351..ee4c1ed0b8 100644 --- a/tests/components/online_image/common-esp32.yaml +++ b/tests/components/online_image/common-esp32.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/common-esp8266.yaml b/tests/components/online_image/common-esp8266.yaml index d7722d171a..fc61aad92e 100644 --- a/tests/components/online_image/common-esp8266.yaml +++ b/tests/components/online_image/common-esp8266.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 15 dc_pin: 3 diff --git a/tests/components/online_image/common-rp2040.yaml b/tests/components/online_image/common-rp2040.yaml index bbb514bded..4d2785f3e8 100644 --- a/tests/components/online_image/common-rp2040.yaml +++ b/tests/components/online_image/common-rp2040.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 data_rate: 20MHz cs_pin: 20 diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml index 9116fd86e0..9972a673c0 100644 --- a/tests/components/online_image/test.esp32-s3-ard.yaml +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml index f219f71ee2..1f1485fd6c 100644 --- a/tests/components/online_image/test.esp32-s3-idf.yaml +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index 81d802e9bf..df20dcde2b 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -1,19 +1,19 @@ esphome: on_boot: then: - - output.turn_off: light_output_1 - - output.turn_on: light_output_1 + - output.turn_off: output_light_output_1 + - output.turn_on: output_light_output_1 - output.set_level: - id: light_output_1 + id: output_light_output_1 level: 50% - output.set_min_power: - id: light_output_1 + id: output_light_output_1 min_power: 20% - output.set_max_power: - id: light_output_1 + id: output_light_output_1 max_power: 80% output: - platform: ${output_platform} - id: light_output_1 + id: output_light_output_1 pin: ${pin} diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index 77a77fa3e4..aeda76d35c 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -9,7 +9,7 @@ pi4ioe5v6408: switch: - platform: gpio - id: switch1 + id: pi4ioe5v6408_switch1 pin: pi4ioe5v6408: pi4ioe1 number: 0 diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 262e75591e..320e5f775f 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -23,7 +23,7 @@ output: sensor: - platform: template - id: template_sensor1 + id: pid_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -35,8 +35,8 @@ climate: - platform: pid id: pid_climate name: PID Climate Controller - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: pid_template_sensor1 + humidity_sensor: pid_template_sensor1 default_target_temperature: 21°C heat_output: pid_slow_pwm control_parameters: diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index 7ff416dccb..951d8f7fc5 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -31,7 +31,7 @@ update: sensor: - platform: template - id: template_sensor1 + id: prometheus_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -91,7 +91,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: prometheus_template_switch1 lambda: |- if (millis() > 10000) { return true; @@ -185,7 +185,7 @@ climate: prometheus: include_internal: true relabel: - template_sensor1: + prometheus_template_sensor1: id: hellow_world name: Hello World template_text_sensor1: diff --git a/tests/components/qspi_dbi/common.yaml b/tests/components/qspi_dbi/common.yaml index 109db65b63..0eadfa7392 100644 --- a/tests/components/qspi_dbi/common.yaml +++ b/tests/components/qspi_dbi/common.yaml @@ -16,7 +16,7 @@ display: - platform: qspi_dbi model: CUSTOM - id: main_lcd + id: qspi_dbi_main_lcd draw_from_origin: true dimensions: height: 240 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index c6c7049605..5631c48f95 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: remote_transmitter_test_number optimistic: true min_value: 0 max_value: 255 @@ -151,7 +151,7 @@ button: on_press: remote_transmitter.transmit_raw: code: !lambda |- - return {(int32_t)id(test_number).state * 100, -1000}; + return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; - platform: template name: AEHA id: eaha_hitachi_climate_power_on @@ -253,7 +253,7 @@ button: destination_address: 0x5678 message_type: 0x01 data: !lambda |- - return {(uint8_t)id(test_number).state, 0x20, 0x30}; + return {(uint8_t)id(remote_transmitter_test_number).state, 0x20, 0x30}; - platform: template name: Digital Write on_press: diff --git a/tests/components/resistance/common.yaml b/tests/components/resistance/common.yaml index b3eec49548..8966b574df 100644 --- a/tests/components/resistance/common.yaml +++ b/tests/components/resistance/common.yaml @@ -1,11 +1,11 @@ sensor: - platform: adc - id: my_sensor + id: resistance_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: resistance_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: resistance_resist diff --git a/tests/components/rgb/common.yaml b/tests/components/rgb/common.yaml index 9f25efa431..fb7b08eeb4 100644 --- a/tests/components/rgb/common.yaml +++ b/tests/components/rgb/common.yaml @@ -1,18 +1,18 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgb_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgb_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgb_light_output_3 pin: ${pin3} light: - platform: rgb name: RGB Light id: rgb_light - red: light_output_1 - green: light_output_2 - blue: light_output_3 + red: rgb_light_output_1 + green: rgb_light_output_2 + blue: rgb_light_output_3 diff --git a/tests/components/rgbct/common.yaml b/tests/components/rgbct/common.yaml index 65bb248e95..670f3ef9a4 100644 --- a/tests/components/rgbct/common.yaml +++ b/tests/components/rgbct/common.yaml @@ -1,28 +1,28 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbct_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbct_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgbct_light_output_3 pin: ${pin3} - platform: ${light_platform} - id: light_output_4 + id: rgbct_light_output_4 pin: ${pin4} - platform: ${light_platform} - id: light_output_5 + id: rgbct_light_output_5 pin: ${pin5} light: - platform: rgbct name: RGBCT Light - red: light_output_1 - green: light_output_2 - blue: light_output_3 - color_temperature: light_output_4 - white_brightness: light_output_5 + red: rgbct_light_output_1 + green: rgbct_light_output_2 + blue: rgbct_light_output_3 + color_temperature: rgbct_light_output_4 + white_brightness: rgbct_light_output_5 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds color_interlock: true diff --git a/tests/components/rgbw/common.yaml b/tests/components/rgbw/common.yaml index b0f44869d3..2b1ccae5a7 100644 --- a/tests/components/rgbw/common.yaml +++ b/tests/components/rgbw/common.yaml @@ -1,22 +1,22 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbw_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbw_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgbw_light_output_3 pin: ${pin3} - platform: ${light_platform} - id: light_output_4 + id: rgbw_light_output_4 pin: ${pin4} light: - platform: rgbw name: RGBW Light - red: light_output_1 - green: light_output_2 - blue: light_output_3 - white: light_output_4 + red: rgbw_light_output_1 + green: rgbw_light_output_2 + blue: rgbw_light_output_3 + white: rgbw_light_output_4 color_interlock: true diff --git a/tests/components/rgbww/common.yaml b/tests/components/rgbww/common.yaml index 0013960c10..5baecaebb8 100644 --- a/tests/components/rgbww/common.yaml +++ b/tests/components/rgbww/common.yaml @@ -1,28 +1,28 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbww_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbww_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgbww_light_output_3 pin: ${pin3} - platform: ${light_platform} - id: light_output_4 + id: rgbww_light_output_4 pin: ${pin4} - platform: ${light_platform} - id: light_output_5 + id: rgbww_light_output_5 pin: ${pin5} light: - platform: rgbww name: RGBWW Light - red: light_output_1 - green: light_output_2 - blue: light_output_3 - cold_white: light_output_4 - warm_white: light_output_5 + red: rgbww_light_output_1 + green: rgbww_light_output_2 + blue: rgbww_light_output_3 + cold_white: rgbww_light_output_4 + warm_white: rgbww_light_output_5 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds color_interlock: true diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index b9b1436cdb..254ac0e13d 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -1,6 +1,6 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: rp2040_pio_led_strip_led_strip pin: 4 num_leds: 60 pio: 0 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 45c039106f..2970a48afb 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,7 +1,7 @@ output: - platform: rp2040_pwm - id: light_output_1 + id: rp2040_pwm_light_output_1 pin: 2 - platform: rp2040_pwm - id: light_output_2 + id: rp2040_pwm_light_output_2 pin: 3 diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index d3d3c9ee5e..3be86cf8be 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -3,7 +3,7 @@ host: display: - platform: sdl - id: sdl_display + id: sdl_sdl_display update_interval: 1s auto_clear_enabled: false show_test_card: true @@ -35,14 +35,14 @@ display: binary_sensor: - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_up key: SDLK_UP - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_down key: SDLK_DOWN - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_enter key: SDLK_RETURN diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 895f4b4b8f..96f459c53f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Speaker Number" - id: my_number + id: speaker_my_number optimistic: true min_value: 0 max_value: 100 @@ -46,7 +46,7 @@ button: - speaker.play: id: speaker_id data: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(speaker_my_number).state}; speaker: - platform: i2s_audio diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index d31b97553e..655e4241f1 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -13,7 +13,7 @@ speaker: - id: media_mixer_speaker_id audio_file: - - id: test_audio + - id: speaker_source_test_audio file: type: local path: $component_dir/test.wav diff --git a/tests/components/speed/common.yaml b/tests/components/speed/common.yaml index be8172af7e..70c91259ba 100644 --- a/tests/components/speed/common.yaml +++ b/tests/components/speed/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${output_platform} - id: fan_output_1 + id: speed_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: speed_fan_speed + output: speed_fan_output_1 diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index f099f77729..dbe109f524 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -34,7 +34,7 @@ esphome: switch: - platform: template - id: switch1 + id: sprinkler_switch1 optimistic: true - platform: template id: switch2 @@ -52,17 +52,17 @@ sprinkler: valves: - valve_switch: Yard Valve 0 enable_switch: Enable Yard Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 1 enable_switch: Enable Yard Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 2 enable_switch: Enable Yard Valve 2 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - id: garden_sprinkler_ctrlr @@ -73,11 +73,11 @@ sprinkler: valves: - valve_switch: Garden Valve 0 enable_switch: Enable Garden Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Garden Valve 1 enable_switch: Enable Garden Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 diff --git a/tests/components/ssd1306_i2c/common.yaml b/tests/components/ssd1306_i2c/common.yaml index 09eb569a8e..b3b8ad85dc 100644 --- a/tests/components/ssd1306_i2c/common.yaml +++ b/tests/components/ssd1306_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1306_128X64 reset_pin: ${reset_pin} address: 0x3C - id: ssd1306_i2c_display + id: ssd1306_i2c_ssd1306_i2c_display contrast: 60% pages: - id: ssd1306_i2c_page1 diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index afdf26c150..3ea235cfb9 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: switch - id: some_binary_sensor + id: switch_some_binary_sensor name: "Template Switch State" source_id: the_switch diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index 659550cc01..a4a24d8da7 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -29,7 +29,7 @@ sx126x: number: - platform: template name: "SX126x Number" - id: my_number + id: sx126x_my_number optimistic: true min_value: 0 max_value: 100 @@ -47,4 +47,4 @@ button: - sx126x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx126x_my_number).state}; diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index 6e48952fcc..b7eadc084f 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -29,7 +29,7 @@ sx127x: number: - platform: template name: "SX127x Number" - id: my_number + id: sx127x_my_number optimistic: true min_value: 0 max_value: 100 @@ -48,4 +48,4 @@ button: - sx127x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index d3985a848b..92a1fc8eda 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -1,12 +1,12 @@ esphome: on_boot: - sensor.template.publish: - id: template_sens + id: template_template_sens state: 42.0 # Templated - sensor.template.publish: - id: template_sens + id: template_template_sens state: !lambda "return 42.0;" - water_heater.template.publish: @@ -28,34 +28,34 @@ esphome: # Test C++ API: set_template() with stateless lambda (no captures) # NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break. - lambda: |- - id(template_sens).set_template([]() -> std::optional { + id(template_template_sens).set_template([]() -> std::optional { return 123.0f; }); # Test that esphome::optional alias still works for backward compatibility - lambda: |- - id(template_sens).set_template([]() -> esphome::optional { + id(template_template_sens).set_template([]() -> esphome::optional { return 42.0f; }); - datetime.date.set: - id: test_date + id: template_test_date date: year: 2021 month: 1 day: 1 - datetime.date.set: - id: test_date + id: template_test_date date: !lambda "return {.day_of_month = 1, .month = 1, .year = 2021};" - datetime.date.set: - id: test_date + id: template_test_date date: "2021-01-01" binary_sensor: - platform: template - id: some_binary_sensor + id: template_some_binary_sensor name: "Garage Door Open" lambda: |- - if (id(template_sens).state > 30) { + if (id(template_template_sens).state > 30) { // Garage Door is open. return true; } else { @@ -78,7 +78,7 @@ binary_sensor: name: "Garage Door Closed" condition: sensor.in_range: - id: template_sens + id: template_template_sens below: 30.0 filters: - invert: @@ -106,9 +106,9 @@ binary_sensor: sensor: - platform: template name: "Template Sensor" - id: template_sens + id: template_template_sens lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return 42.0; } return 0.0; @@ -230,7 +230,7 @@ switch: id: test_switch name: "Template Switch" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return true; } return false; @@ -249,7 +249,7 @@ cover: - platform: template name: "Template Cover" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -264,7 +264,7 @@ cover: name: "Template Cover with Triggers" id: template_cover_with_triggers lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -442,7 +442,7 @@ lock: - platform: template name: "Template Lock" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return LOCK_STATE_LOCKED; } return LOCK_STATE_UNLOCKED; @@ -458,7 +458,7 @@ valve: id: template_valve name: "Template Valve" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return VALVE_OPEN; } return VALVE_CLOSED; @@ -537,7 +537,7 @@ water_heater: datetime: - platform: template name: Date - id: test_date + id: template_test_date type: date initial_value: "2000-1-2" set_action: @@ -551,7 +551,7 @@ datetime: - x.day_of_month - platform: template name: Time - id: test_time + id: template_test_time type: time initial_value: "12:34:56am" set_action: @@ -565,7 +565,7 @@ datetime: - x.second - platform: template name: DateTime - id: test_datetime + id: template_test_datetime type: datetime initial_value: "2000-1-2 12:34:56" set_action: diff --git a/tests/components/tlc5947/common.yaml b/tests/components/tlc5947/common.yaml index 89588f3c76..f16f07503e 100644 --- a/tests/components/tlc5947/common.yaml +++ b/tests/components/tlc5947/common.yaml @@ -5,9 +5,9 @@ tlc5947: output: - platform: tlc5947 - id: output_1 + id: tlc5947_output_1 channel: 0 max_power: 0.8 - platform: tlc5947 - id: output_2 + id: tlc5947_output_2 channel: 1 diff --git a/tests/components/tlc5971/common.yaml b/tests/components/tlc5971/common.yaml index fe7fe25f0e..e372582ac1 100644 --- a/tests/components/tlc5971/common.yaml +++ b/tests/components/tlc5971/common.yaml @@ -4,9 +4,9 @@ tlc5971: output: - platform: tlc5971 - id: output_1 + id: tlc5971_output_1 channel: 0 max_power: 0.8 - platform: tlc5971 - id: output_2 + id: tlc5971_output_2 channel: 1 diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 56089aed1e..1f9249f1ba 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: tt21100_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${disp_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: ssd1306_i2c_display + display: tt21100_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index fa76316b9c..c805188005 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -79,7 +79,7 @@ switch: number: - platform: template name: "Test Number" - id: test_number + id: uart_test_number optimistic: true min_value: 0 max_value: 100 @@ -103,7 +103,7 @@ button: - uart.write: id: uart_id data: !lambda |- - std::string cmd = "VALUE=" + str_sprintf("%.0f", id(test_number).state) + "\r\n"; + std::string cmd = "VALUE=" + str_sprintf("%.0f", id(uart_test_number).state) + "\r\n"; return std::vector(cmd.begin(), cmd.end()); event: diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index a40ca455cb..6824c5cca8 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -24,7 +24,7 @@ udp: number: - platform: template name: "UDP Number" - id: my_number + id: udp_my_number optimistic: true min_value: 0 max_value: 100 @@ -38,4 +38,4 @@ button: - udp.write: data: [0x01, 0x02, 0x03] - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(my_number).state}; + return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; diff --git a/tests/components/ufire_ec/common.yaml b/tests/components/ufire_ec/common.yaml index 4260f0ab4c..2365b7a368 100644 --- a/tests/components/ufire_ec/common.yaml +++ b/tests/components/ufire_ec/common.yaml @@ -4,18 +4,18 @@ esphome: - ufire_ec.calibrate_probe: id: ufire_ec_board solution: 0.146 - temperature: !lambda "return id(test_sensor).state;" + temperature: !lambda "return id(ufire_ec_test_sensor).state;" - ufire_ec.reset: sensor: - platform: template - id: test_sensor + id: ufire_ec_test_sensor lambda: "return 21;" - platform: ufire_ec i2c_id: i2c_bus id: ufire_ec_board ec: name: Ufire EC - temperature_sensor: test_sensor + temperature_sensor: ufire_ec_test_sensor temperature_compensation: 20.0 temperature_coefficient: 0.019 diff --git a/tests/components/ufire_ise/common.yaml b/tests/components/ufire_ise/common.yaml index f7865ea87b..478c75ad37 100644 --- a/tests/components/ufire_ise/common.yaml +++ b/tests/components/ufire_ise/common.yaml @@ -11,11 +11,11 @@ esphome: sensor: - platform: template - id: test_sensor + id: ufire_ise_test_sensor lambda: "return 21;" - platform: ufire_ise i2c_id: i2c_bus id: ufire_ise_sensor - temperature_sensor: test_sensor + temperature_sensor: ufire_ise_test_sensor ph: name: Ufire pH diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml index b1885af266..cfba0060d9 100644 --- a/tests/components/web_server_idf/common.yaml +++ b/tests/components/web_server_idf/common.yaml @@ -12,7 +12,7 @@ network: sensor: - platform: template name: "Test Sensor" - id: test_sensor + id: web_server_idf_test_sensor update_interval: 60s lambda: "return 42.5;" @@ -25,5 +25,5 @@ binary_sensor: switch: - platform: template name: "Test Switch" - id: test_switch + id: web_server_idf_test_switch optimistic: true diff --git a/tests/components/wk2132_i2c/common.yaml b/tests/components/wk2132_i2c/common.yaml index 39013baeb2..93bb17b38f 100644 --- a/tests/components/wk2132_i2c/common.yaml +++ b/tests/components/wk2132_i2c/common.yaml @@ -16,4 +16,4 @@ wk2132_i2c: sensor: - platform: a02yyuw uart_id: wk2132_id_1 - id: distance_sensor + id: wk2132_i2c_distance_sensor diff --git a/tests/components/wk2132_spi/common.yaml b/tests/components/wk2132_spi/common.yaml index 18294974b9..5ff48bc64c 100644 --- a/tests/components/wk2132_spi/common.yaml +++ b/tests/components/wk2132_spi/common.yaml @@ -17,4 +17,4 @@ wk2132_spi: sensor: - platform: a02yyuw uart_id: wk2132_spi_uart1 - id: distance_sensor + id: wk2132_spi_distance_sensor diff --git a/tests/components/wk2168_i2c/common.yaml b/tests/components/wk2168_i2c/common.yaml index 49f0d1ec6b..1b2de74c02 100644 --- a/tests/components/wk2168_i2c/common.yaml +++ b/tests/components/wk2168_i2c/common.yaml @@ -23,7 +23,7 @@ wk2168_i2c: sensor: - platform: a02yyuw uart_id: wk2168_i2c_uart3 - id: distance_sensor + id: wk2168_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2168_spi/common.yaml b/tests/components/wk2168_spi/common.yaml index b402077aa3..a21a4a34d0 100644 --- a/tests/components/wk2168_spi/common.yaml +++ b/tests/components/wk2168_spi/common.yaml @@ -23,7 +23,7 @@ wk2168_spi: sensor: - platform: a02yyuw uart_id: wk2168_spi_uart3 - id: distance_sensor + id: wk2168_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2204_i2c/common.yaml b/tests/components/wk2204_i2c/common.yaml index 863633937b..55c67efd88 100644 --- a/tests/components/wk2204_i2c/common.yaml +++ b/tests/components/wk2204_i2c/common.yaml @@ -24,4 +24,4 @@ wk2204_i2c: sensor: - platform: a02yyuw uart_id: wk2204_id_3 - id: distance_sensor + id: wk2204_i2c_distance_sensor diff --git a/tests/components/wk2204_spi/common.yaml b/tests/components/wk2204_spi/common.yaml index 0b62a7a009..ee00da22bb 100644 --- a/tests/components/wk2204_spi/common.yaml +++ b/tests/components/wk2204_spi/common.yaml @@ -25,4 +25,4 @@ wk2204_spi: sensor: - platform: a02yyuw uart_id: wk2204_spi_uart3 - id: distance_sensor + id: wk2204_spi_distance_sensor diff --git a/tests/components/wk2212_i2c/common.yaml b/tests/components/wk2212_i2c/common.yaml index a754bec5c7..d48063bb4d 100644 --- a/tests/components/wk2212_i2c/common.yaml +++ b/tests/components/wk2212_i2c/common.yaml @@ -19,7 +19,7 @@ wk2212_i2c: sensor: - platform: a02yyuw uart_id: uart_i2c_id1 - id: distance_sensor + id: wk2212_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2212_spi/common.yaml b/tests/components/wk2212_spi/common.yaml index 969f16bb12..d17db2f676 100644 --- a/tests/components/wk2212_spi/common.yaml +++ b/tests/components/wk2212_spi/common.yaml @@ -17,7 +17,7 @@ wk2212_spi: sensor: - platform: a02yyuw uart_id: wk2212_spi_uart1 - id: distance_sensor + id: wk2212_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/script/test_ci_check_duplicate_test_ids.py b/tests/script/test_ci_check_duplicate_test_ids.py new file mode 100644 index 0000000000..1ac8edeca0 --- /dev/null +++ b/tests/script/test_ci_check_duplicate_test_ids.py @@ -0,0 +1,114 @@ +"""Unit tests for script/ci_check_duplicate_test_ids.py. + +These lock in that the guard stays consistent with the actual config merge: it +prefixes substitutions the same way and delegates the conflict decision to +``merge_component_configs.deduplicate_by_id``. +""" + +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import ci_check_duplicate_test_ids as checker # noqa: E402 + + +def _write_component(tests_dir: Path, name: str, body: str) -> None: + comp = tests_dir / name + comp.mkdir(parents=True) + (comp / "test.esp32-idf.yaml").write_text(body) + + +@pytest.fixture +def tests_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(checker, "TESTS_DIR", tmp_path) + return tmp_path + + +def test_substitution_only_difference_is_a_conflict(tests_dir: Path) -> None: + """Raw-identical items that differ only by a substitution still conflict. + + This is the class that the first version missed (and broke CI): the merge + prefixes ``${pin}`` per component, so the two become ``${a_pin}`` and + ``${b_pin}`` and collide. + """ + shared = "sensor:\n - platform: adc\n id: shared\n pin: ${pin}\n" + _write_component(tests_dir, "comp_a", shared) + _write_component(tests_dir, "comp_b", shared) + result = checker.scan() + assert any("shared" in line for line in result.conflicts), result.conflicts + + +def test_identical_substitution_free_items_do_not_conflict(tests_dir: Path) -> None: + same = "sensor:\n - platform: template\n id: shared\n name: Fixed\n" + _write_component(tests_dir, "comp_a", same) + _write_component(tests_dir, "comp_b", same) + assert checker.scan().conflicts == [] + + +def test_unique_ids_do_not_conflict(tests_dir: Path) -> None: + _write_component( + tests_dir, + "comp_a", + "sensor:\n - platform: adc\n id: comp_a_sensor\n pin: ${pin}\n", + ) + _write_component( + tests_dir, + "comp_b", + "sensor:\n - platform: adc\n id: comp_b_sensor\n pin: ${pin}\n", + ) + assert checker.scan().conflicts == [] + + +def test_same_list_key_under_different_paths_is_not_compared(tests_dir: Path) -> None: + """Ids sharing a list key name but under different parent paths don't conflict. + + The merge only concatenates lists at the same path, so ``foo.shared`` and + ``bar.shared`` are never compared against each other. + """ + _write_component( + tests_dir, "comp_a", "foo:\n shared:\n - id: dup\n v: 1\n" + ) + _write_component( + tests_dir, "comp_b", "bar:\n shared:\n - id: dup\n v: 2\n" + ) + assert checker.scan().conflicts == [] + + +def test_int_and_string_ids_are_distinct(tests_dir: Path) -> None: + """``5`` and ``"5"`` are different ids, exactly as deduplicate_by_id treats them.""" + _write_component(tests_dir, "comp_a", "sensor:\n - platform: t\n id: 5\n") + _write_component(tests_dir, "comp_b", 'sensor:\n - platform: t\n id: "5"\n') + assert checker.scan().conflicts == [] + + +def test_unparseable_fixture_is_reported_and_fails(tests_dir: Path) -> None: + """A fixture that cannot be parsed is surfaced and fails the run, not skipped.""" + _write_component(tests_dir, "broken", "foo: [unbalanced\n") + result = checker.scan() + assert result.conflicts == [] + assert any("broken" in path for path in result.parse_errors) + # The run as a whole must not pass when a covered fixture was not scanned. + assert checker.main() == 1 + + +def test_allowlisted_singleton_is_not_a_conflict(tests_dir: Path) -> None: + """Ids in INTENTIONALLY_SHARED_IDS may differ across components.""" + _write_component( + tests_dir, "comp_a", "time:\n - platform: sntp\n id: sntp_time\n" + ) + _write_component( + tests_dir, + "comp_b", + "time:\n - platform: sntp\n id: sntp_time\n servers: [a.example]\n", + ) + assert checker.scan().conflicts == [] + + +def test_empty_scan_fails(tests_dir: Path) -> None: + """A scan that covers zero fixtures is a false green and must fail.""" + result = checker.scan() + assert result.components_scanned == 0 + assert checker.main() == 1 diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py new file mode 100644 index 0000000000..6ed1bd2c1e --- /dev/null +++ b/tests/script/test_merge_component_configs.py @@ -0,0 +1,101 @@ +"""Unit tests for script/merge_component_configs.py deduplication.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to Python path so we can import the module +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import merge_component_configs # noqa: E402 + +deduplicate_by_id = merge_component_configs.deduplicate_by_id + + +def test_identical_duplicate_ids_collapse() -> None: + """Two identical items sharing an id collapse to one without error.""" + data = { + "sensor": [ + {"id": "shared", "platform": "template", "name": "A"}, + {"id": "shared", "platform": "template", "name": "A"}, + ] + } + result = deduplicate_by_id(data) + assert result["sensor"] == [{"id": "shared", "platform": "template", "name": "A"}] + + +def test_conflicting_duplicate_ids_raise() -> None: + """Two different items sharing an id is a hard error naming the id.""" + data = { + "sensor": [ + {"id": "dup", "platform": "template", "name": "A"}, + {"id": "dup", "platform": "template", "name": "B"}, + ] + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) + + +def test_intentionally_shared_id_does_not_raise() -> None: + """An allowlisted (section, id) may differ across components and collapse.""" + section, id_ = "time", "sntp_time" + assert (section, id_) in merge_component_configs.INTENTIONALLY_SHARED_IDS + data = { + section: [ + {"id": id_, "platform": "sntp"}, + {"id": id_, "platform": "sntp", "servers": ["a"]}, + ] + } + result = deduplicate_by_id(data) + # First occurrence wins, no error raised + assert result[section] == [{"id": id_, "platform": "sntp"}] + + +def test_allowlisted_id_in_other_section_still_raises() -> None: + """The allowlist is keyed on (section, id): the same id elsewhere conflicts.""" + data = { + "sensor": [ + {"id": "sntp_time", "platform": "a"}, + {"id": "sntp_time", "platform": "b"}, + ] + } + with pytest.raises(ValueError, match="sntp_time"): + deduplicate_by_id(data) + + +def test_items_without_id_are_preserved() -> None: + """Items lacking an id are passed through untouched.""" + data = {"binary_sensor": [{"platform": "gpio"}, {"platform": "gpio"}]} + result = deduplicate_by_id(data) + assert result["binary_sensor"] == [{"platform": "gpio"}, {"platform": "gpio"}] + + +def test_comparison_is_type_sensitive() -> None: + """Comparison matches the merge exactly: 5 and "5" are a conflict. + + The duplicate-id CI guard reuses this function, so a looser (e.g. string + normalized) comparison would let the guard disagree with the build. + """ + data = { + "sensor": [ + {"id": "dup", "platform": "adc", "pin": 5}, + {"id": "dup", "platform": "adc", "pin": "5"}, + ] + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) + + +def test_nested_lists_are_checked() -> None: + """Conflicts nested inside dict values are also detected.""" + data = { + "wrapper": { + "sensor": [ + {"id": "dup", "value": 1}, + {"id": "dup", "value": 2}, + ] + } + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) From b21a69f07a341a7b1498e38afefe40510fc76c64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:08:39 +1200 Subject: [PATCH 1580/2214] Bump codecov/codecov-action from 6.0.1 to 7.0.0 (#16884) 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 3115b5b473..a57be34e9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,7 +245,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From e0072ef4c546106295235b7377eeaed2f440ee03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:22:48 -0500 Subject: [PATCH 1581/2214] Bump tornado from 6.5.6 to 6.5.7 (#16883) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85d9857e7d..8202a2bb44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.6 +tornado==6.5.7 tzlocal==5.3.1 # from time tzdata>=2026.2 # from time pyserial==3.5 From 6e01f3fccd5e6f2b54d4038392b1e0eb54a13fac Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:29:35 -0400 Subject: [PATCH 1582/2214] [heatpumpir] Bump tonia/HeatpumpIR to 1.0.42 (#16880) --- .clang-tidy.hash | 2 +- esphome/components/heatpumpir/climate.py | 2 +- platformio.ini | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 25ae506732..591ca3eb4d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -58a760f5fd174bd438bcc3a7018292c158530c1a1d15181941c832d4c032511c +a1aa12cb72cb0cc57c25649aafed8412434b013885cfda107f8aac5c083b4577 diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index aa3a08c294..cd1b7d2bb0 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -126,6 +126,6 @@ async def to_code(config): cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_library("tonia/HeatpumpIR", "1.0.41") + cg.add_library("tonia/HeatpumpIR", "1.0.42") if CORE.is_libretiny or CORE.is_esp32: CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/platformio.ini b/platformio.ini index 182f426a31..251339fb5f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -81,7 +81,7 @@ lib_deps = heman/AsyncMqttClient-esphome@1.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea - tonia/HeatpumpIR@1.0.41 ; heatpumpir + tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = ${common.build_flags} -DUSE_ARDUINO @@ -170,7 +170,7 @@ framework = espidf lib_deps = ${common:idf.lib_deps} droscy/esp_wireguard@0.4.5 ; wireguard - tonia/HeatpumpIR@1.0.41 ; heatpumpir + tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = ${common:idf.build_flags} -Wno-nonnull-compare From a32817207c76616f3dc01f004fe737676d154e48 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:30:03 -0400 Subject: [PATCH 1583/2214] [ade7880] Fix reverse active energy reading from reserved register (#16822) --- esphome/components/ade7880/ade7880.cpp | 41 ++++++++----------- esphome/components/ade7880/ade7880.h | 3 +- .../components/ade7880/ade7880_registers.h | 4 +- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp index 9d19770c57..0f4189ad90 100644 --- a/esphome/components/ade7880/ade7880.cpp +++ b/esphome/components/ade7880/ade7880.cpp @@ -87,14 +87,24 @@ void ADE7880::update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_ sensor->publish_state(f(val)); } -template -void ADE7880::update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f) { - if (sensor == nullptr) { +void ADE7880::update_active_energy_(PowerChannel *channel, uint16_t a_register) { + if (channel->forward_active_energy == nullptr && channel->reverse_active_energy == nullptr) { return; } - float val = this->read_s32_register16_(a_register); - sensor->publish_state(f(val)); + // The ADE7880 has no separate forward/reverse active energy accumulators. The xWATTHR registers + // accumulate signed energy since the last read (positive = imported/forward, negative = exported/ + // reverse), so split the value by sign into the forward and reverse running totals. + float val = this->read_s32_register16_(a_register) / 14400.0f; + if (val >= 0.0f) { + if (channel->forward_active_energy != nullptr) { + channel->forward_active_energy->publish_state(channel->forward_active_energy_total += val); + } + } else { + if (channel->reverse_active_energy != nullptr) { + channel->reverse_active_energy->publish_state(channel->reverse_active_energy_total -= val); + } + } } void ADE7880::update() { @@ -117,12 +127,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, AVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, APF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, AFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, ARWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, AWATTHR); } if (this->channel_b_ != nullptr) { @@ -133,12 +138,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, BVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, BPF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, BFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, BRWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, BWATTHR); } if (this->channel_c_ != nullptr) { @@ -149,12 +149,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, CVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, CPF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, CFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, CRWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, CWATTHR); } ESP_LOGD(TAG, "update took %" PRIu32 " ms", millis() - start); diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h index 69c8e5abba..53f501dee2 100644 --- a/esphome/components/ade7880/ade7880.h +++ b/esphome/components/ade7880/ade7880.h @@ -105,7 +105,8 @@ class ADE7880 : public i2c::I2CDevice, public PollingComponent { // the callable will be passed a 'float' value and is expected to return a 'float' template void update_sensor_from_s24zp_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); template void update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); - template void update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); + + void update_active_energy_(PowerChannel *channel, uint16_t a_register); void reset_device_(); diff --git a/esphome/components/ade7880/ade7880_registers.h b/esphome/components/ade7880/ade7880_registers.h index aee4e42445..8b0b86fe7a 100644 --- a/esphome/components/ade7880/ade7880_registers.h +++ b/esphome/components/ade7880/ade7880_registers.h @@ -84,9 +84,7 @@ constexpr uint16_t CWATTHR = 0xE402; constexpr uint16_t AFWATTHR = 0xE403; constexpr uint16_t BFWATTHR = 0xE404; constexpr uint16_t CFWATTHR = 0xE405; -constexpr uint16_t ARWATTHR = 0xE406; -constexpr uint16_t BRWATTHR = 0xE407; -constexpr uint16_t CRWATTHR = 0xE408; +// 0xE406-0xE408 are reserved on the ADE7880 (it does not implement total reactive energy accumulation) constexpr uint16_t AFVARHR = 0xE409; constexpr uint16_t BFVARHR = 0xE40A; constexpr uint16_t CFVARHR = 0xE40B; From ddd21ba442f9d066834440fc92b92978db7c7330 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:06:13 +1000 Subject: [PATCH 1584/2214] [mipi_spi] add WAVESHARE-ESP32-S3-TOUCH-AMOLED-2.16 (#16887) --- esphome/components/mipi_spi/models/waveshare.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee8bd06700..ee46f931de 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -177,6 +177,20 @@ CO5300.extend( reset_pin=39, ) +# Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller) +# Pin assignments are the same as the 1.75" devkit: CS=12, RESET=39. Width/height set to 480x480. +CO5300.extend( + "WAVESHARE-ESP32-S3-TOUCH-AMOLED-2.16", + width=480, + height=480, + pixel_mode="16bit", + offset_height=0, + offset_width=0, + cs_pin=12, + reset_pin=39, + data_rate="40MHz", +) + AXS15231.extend( "WAVESHARE-ESP32-S3-TOUCH-LCD-3.49", width=172, From cdc63f0fed7d91a1a94504c480b538d671c6e32d Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 9 Jun 2026 08:33:15 +0200 Subject: [PATCH 1585/2214] [pcm5122] Add PCM5122 audio DAC component (#15709) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: kbx81 --- CODEOWNERS | 1 + esphome/components/pcm5122/__init__.py | 1 + esphome/components/pcm5122/audio_dac.py | 98 ++++++++++++ esphome/components/pcm5122/pcm5122.cpp | 140 ++++++++++++++++++ esphome/components/pcm5122/pcm5122.h | 72 +++++++++ esphome/components/pcm5122/pcm5122_gpio.cpp | 69 +++++++++ esphome/components/pcm5122/pcm5122_gpio.h | 29 ++++ tests/components/pcm5122/common.yaml | 24 +++ tests/components/pcm5122/test.esp32-ard.yaml | 4 + tests/components/pcm5122/test.esp32-idf.yaml | 4 + .../components/pcm5122/test.esp8266-ard.yaml | 4 + tests/components/pcm5122/test.rp2040-ard.yaml | 4 + 12 files changed, 450 insertions(+) create mode 100644 esphome/components/pcm5122/__init__.py create mode 100644 esphome/components/pcm5122/audio_dac.py create mode 100644 esphome/components/pcm5122/pcm5122.cpp create mode 100644 esphome/components/pcm5122/pcm5122.h create mode 100644 esphome/components/pcm5122/pcm5122_gpio.cpp create mode 100644 esphome/components/pcm5122/pcm5122_gpio.h create mode 100644 tests/components/pcm5122/common.yaml create mode 100644 tests/components/pcm5122/test.esp32-ard.yaml create mode 100644 tests/components/pcm5122/test.esp32-idf.yaml create mode 100644 tests/components/pcm5122/test.esp8266-ard.yaml create mode 100644 tests/components/pcm5122/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 6a81cc1d40..c5beba8c0b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -379,6 +379,7 @@ esphome/components/pca6416a/* @Mat931 esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman +esphome/components/pcm5122/* @remcom esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 diff --git a/esphome/components/pcm5122/__init__.py b/esphome/components/pcm5122/__init__.py new file mode 100644 index 0000000000..81e00ca74b --- /dev/null +++ b/esphome/components/pcm5122/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@remcom"] diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py new file mode 100644 index 0000000000..0017a1ef5a --- /dev/null +++ b/esphome/components/pcm5122/audio_dac.py @@ -0,0 +1,98 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.audio_dac import AudioDac +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_ID, + CONF_INPUT, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) + +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] + +pcm5122_ns = cg.esphome_ns.namespace("pcm5122") +PCM5122 = pcm5122_ns.class_("PCM5122", AudioDac, cg.Component, i2c.I2CDevice) +CONF_PCM5122 = "pcm5122" + +pcm5122_bits_per_sample = pcm5122_ns.enum("PCM5122BitsPerSample") +PCM5122_BITS_PER_SAMPLE_ENUM = { + 16: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_16, + 24: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_24, + 32: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_32, +} + +_validate_bits = cv.float_with_unit("bits", "bit") + + +PCM5122GPIOPin = pcm5122_ns.class_( + "PCM5122GPIOPin", + cg.GPIOPin, + cg.Parented.template(PCM5122), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PCM5122), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + _validate_bits, cv.enum(PCM5122_BITS_PER_SAMPLE_ENUM) + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x4D)) +) + + +def _validate_pin_mode(value): + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output, not both") + return value + + +def _validate_pin(value): + 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 + + +PIN_SCHEMA = cv.All( + pins.gpio_base_schema( + PCM5122GPIOPin, + cv.int_range(min=3, max=6), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=_validate_pin_mode, + ).extend( + { + cv.Required(CONF_PCM5122): cv.use_id(PCM5122), + } + ), + _validate_pin, +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCM5122, PIN_SCHEMA) +async def pcm5122_pin_to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_PCM5122]) + + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) diff --git a/esphome/components/pcm5122/pcm5122.cpp b/esphome/components/pcm5122/pcm5122.cpp new file mode 100644 index 0000000000..68bbd50e4f --- /dev/null +++ b/esphome/components/pcm5122/pcm5122.cpp @@ -0,0 +1,140 @@ +#include "pcm5122.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::pcm5122 { + +static const char *const TAG = "pcm5122"; + +void PCM5122::setup() { + // Select page 0 and verify chip presence via I2C ACK + if (!this->select_page_(0)) { + ESP_LOGE(TAG, "Write failed"); + this->status_set_error(LOG_STR("Write failed")); + this->mark_failed(); + return; + } + + // Reset audio modules + this->reg(PCM5122_REG_RESET) = PCM5122_RESET_MODULES; + delay(20); + this->reg(PCM5122_REG_RESET) = 0x00; + + // Ignore clock halt detection; enable clock divider autoset + optional err_detect = this->read_byte(PCM5122_REG_ERROR_DETECT); + if (!err_detect.has_value()) { + ESP_LOGE(TAG, "Failed to read ERROR_DETECT"); + this->mark_failed(); + return; + } + uint8_t err_detect_val = err_detect.value(); + err_detect_val |= PCM5122_ERROR_DETECT_IGNORE_CLKHALT; + err_detect_val &= ~PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET; + this->reg(PCM5122_REG_ERROR_DETECT) = err_detect_val; + + // I2S format with the configured word length + uint8_t alen; + switch (this->bits_per_sample_) { + case PCM5122_BITS_PER_SAMPLE_16: + alen = PCM5122_AUDIO_FORMAT_ALEN_16BIT; + break; + case PCM5122_BITS_PER_SAMPLE_24: + alen = PCM5122_AUDIO_FORMAT_ALEN_24BIT; + break; + case PCM5122_BITS_PER_SAMPLE_32: + default: + alen = PCM5122_AUDIO_FORMAT_ALEN_32BIT; + break; + } + this->reg(PCM5122_REG_AUDIO_FORMAT) = PCM5122_AUDIO_FORMAT_I2S | alen; + + // PLL reference clock: BCK + optional pll_ref = this->read_byte(PCM5122_REG_PLL_REF); + if (!pll_ref.has_value()) { + ESP_LOGE(TAG, "Failed to read PLL_REF"); + this->mark_failed(); + return; + } + uint8_t pll_ref_val = pll_ref.value(); + pll_ref_val &= ~PCM5122_PLL_REF_MASK; + pll_ref_val |= PCM5122_PLL_REF_SOURCE_BCK; + this->reg(PCM5122_REG_PLL_REF) = pll_ref_val; + + if (!this->set_mute_on() || !this->set_volume(this->volume_)) { + this->mark_failed(); + return; + } +} + +void PCM5122::dump_config() { + ESP_LOGCONFIG(TAG, "Audio DAC:"); + LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, + " Bits per sample: %u\n" + " Muted: %s", + this->bits_per_sample_, YESNO(this->is_muted_)); +} + +bool PCM5122::set_mute_off() { + this->is_muted_ = false; + return this->write_mute_(); +} + +bool PCM5122::set_mute_on() { + this->is_muted_ = true; + return this->write_mute_(); +} + +bool PCM5122::set_volume(float volume) { + this->volume_ = clamp(volume, 0.0f, 1.0f); + return this->write_volume_(); +} + +bool PCM5122::is_muted() { return this->is_muted_; } + +float PCM5122::volume() { return this->volume_; } + +bool PCM5122::select_page_(uint8_t page) { + if (this->current_page_ == page) + return true; + if (!this->write_byte(PCM5122_REG_PAGE_SELECT, page)) { + this->current_page_ = -1; + return false; + } + this->current_page_ = page; + return true; +} + +bool PCM5122::write_mute_() { + uint8_t mute_byte = this->is_muted() ? 0x11 : 0x00; + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_MUTE, mute_byte)) { + ESP_LOGE(TAG, "Writing mute failed"); + return false; + } + return true; +} + +bool PCM5122::write_volume_() { + // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFF = mute (-0.5 dB/step). + // Note: volume=0.0 maps to -52.5 dB (still audible), not true silence. + // Use set_mute_on() for silence. + const uint8_t dvol_max_volume = 0x30; // 0 dB at full scale + const uint8_t dvol_min_volume = 0x99; // -52.5 dB at minimum + + const uint8_t volume_byte = + dvol_max_volume + static_cast(lroundf((1.0f - this->volume_) * (dvol_min_volume - dvol_max_volume))); + + ESP_LOGV(TAG, "Setting volume to 0x%.2x", volume_byte); + + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_DVOL_LEFT, volume_byte) || + !this->write_byte(PCM5122_REG_DVOL_RIGHT, volume_byte)) { + ESP_LOGE(TAG, "Writing volume failed"); + return false; + } + return true; +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h new file mode 100644 index 0000000000..f86b096c82 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::pcm5122 { + +// Page 0 register addresses +static const uint8_t PCM5122_REG_PAGE_SELECT = 0x00; +static const uint8_t PCM5122_REG_RESET = 0x01; +static const uint8_t PCM5122_REG_MUTE = 0x03; +static const uint8_t PCM5122_REG_GPIO_ENABLE = 0x08; +static const uint8_t PCM5122_REG_PLL_REF = 0x0D; +static const uint8_t PCM5122_REG_ERROR_DETECT = 0x25; +static const uint8_t PCM5122_REG_AUDIO_FORMAT = 0x28; +static const uint8_t PCM5122_REG_DVOL_LEFT = 0x3D; +static const uint8_t PCM5122_REG_DVOL_RIGHT = 0x3E; +static const uint8_t PCM5122_REG_GPIO_OUTPUT_SELECT = 0x50; // Base address; GPIO n uses offset n-1 +static const uint8_t PCM5122_GPIO_OUTPUT_SELECT_REGISTER = 0x02; // GPIO driven by GPIO_OUTPUT register (reg 0x56) +static const uint8_t PCM5122_REG_GPIO_OUTPUT = 0x56; +static const uint8_t PCM5122_REG_GPIO_INVERT = 0x57; +static const uint8_t PCM5122_REG_GPIO_INPUT = 0x77; + +// Register values for init sequence +static const uint8_t PCM5122_RESET_MODULES = 0x10; // RSTM: reset audio modules +static const uint8_t PCM5122_AUDIO_FORMAT_I2S = 0x00; // AFMT = I2S (bits [5:4] = 00) +// ALEN (word length) occupies bits [1:0] of the audio format register +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_16BIT = 0x00; +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_24BIT = 0x02; +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_32BIT = 0x03; +static const uint8_t PCM5122_ERROR_DETECT_IGNORE_CLKHALT = (1 << 3); +static const uint8_t PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET = (1 << 1); +static const uint8_t PCM5122_PLL_REF_MASK = (7 << 4); // SREF bits [6:4] +static const uint8_t PCM5122_PLL_REF_SOURCE_BCK = (1 << 4); // SREF = 001 (BCK) + +enum PCM5122BitsPerSample : uint8_t { + PCM5122_BITS_PER_SAMPLE_16 = 16, + PCM5122_BITS_PER_SAMPLE_24 = 24, + PCM5122_BITS_PER_SAMPLE_32 = 32, +}; + +class PCM5122 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + + void set_bits_per_sample(PCM5122BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + + bool set_mute_off() override; + bool set_mute_on() override; + bool set_volume(float volume) override; + + bool is_muted() override; + float volume() override; + + friend class PCM5122GPIOPin; + + protected: + bool select_page_(uint8_t page); + bool write_mute_(); + bool write_volume_(); + + float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) + int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes + bool is_muted_{false}; + PCM5122BitsPerSample bits_per_sample_{PCM5122_BITS_PER_SAMPLE_16}; +}; + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122_gpio.cpp b/esphome/components/pcm5122/pcm5122_gpio.cpp new file mode 100644 index 0000000000..1aef130457 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122_gpio.cpp @@ -0,0 +1,69 @@ +#include "pcm5122_gpio.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::pcm5122 { + +static const char *const TAG = "pcm5122.gpio"; + +void PCM5122GPIOPin::setup() { this->pin_mode(this->flags_); } + +void PCM5122GPIOPin::pin_mode(gpio::Flags flags) { + this->flags_ = flags; + if (!this->parent_->select_page_(0)) { + ESP_LOGE(TAG, "Failed to select page 0"); + return; + } + optional curr = this->parent_->read_byte(PCM5122_REG_GPIO_ENABLE); + if (!curr.has_value()) { + ESP_LOGE(TAG, "Failed to read GPIO_ENABLE"); + return; + } + if (flags & gpio::FLAG_INPUT) { + this->parent_->reg(PCM5122_REG_GPIO_ENABLE) = curr.value() & ~(1 << (this->pin_ - 1)); + } else if (flags & gpio::FLAG_OUTPUT) { + this->parent_->reg(PCM5122_REG_GPIO_ENABLE) = curr.value() | (1 << (this->pin_ - 1)); + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT_SELECT + (this->pin_ - 1)) = PCM5122_GPIO_OUTPUT_SELECT_REGISTER; + optional invert = this->parent_->read_byte(PCM5122_REG_GPIO_INVERT); + if (!invert.has_value()) { + ESP_LOGE(TAG, "Failed to read GPIO_INVERT"); + return; + } + if (this->inverted_) { + this->parent_->reg(PCM5122_REG_GPIO_INVERT) = invert.value() | (1 << (this->pin_ - 1)); + } else { + this->parent_->reg(PCM5122_REG_GPIO_INVERT) = invert.value() & ~(1 << (this->pin_ - 1)); + } + } +} + +void PCM5122GPIOPin::digital_write(bool value) { + if (!this->parent_->select_page_(0)) + return; + optional curr = this->parent_->read_byte(PCM5122_REG_GPIO_OUTPUT); + if (!curr.has_value()) + return; + if (value) { + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT) = curr.value() | (1 << (this->pin_ - 1)); + } else { + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT) = curr.value() & ~(1 << (this->pin_ - 1)); + } +} + +bool PCM5122GPIOPin::digital_read() { + if (!this->parent_->select_page_(0)) + return this->value_; + optional read = this->parent_->read_byte(PCM5122_REG_GPIO_INPUT); + if (read.has_value()) { + // GPIO input register has RSV at bit 0; GPIN_N is at bit N (unlike other GPIO registers) + this->value_ = !!(read.value() & (1 << this->pin_)) != this->inverted_; + } + return this->value_; +} + +size_t PCM5122GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "PCM5122 GPIO%u", this->pin_); +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122_gpio.h b/esphome/components/pcm5122/pcm5122_gpio.h new file mode 100644 index 0000000000..8edaa6d3e8 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122_gpio.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/gpio.h" + +#include "pcm5122.h" + +namespace esphome::pcm5122 { + +class PCM5122GPIOPin : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{gpio::FLAG_NONE}; + bool value_{false}; +}; + +} // namespace esphome::pcm5122 diff --git a/tests/components/pcm5122/common.yaml b/tests/components/pcm5122/common.yaml new file mode 100644 index 0000000000..cf96f57464 --- /dev/null +++ b/tests/components/pcm5122/common.yaml @@ -0,0 +1,24 @@ +audio_dac: + - platform: pcm5122 + id: pcm5122_dac + i2c_id: i2c_bus + address: 0x4D + bits_per_sample: 32bit + +output: + - platform: gpio + id: pcm5122_amp_enable + pin: + pcm5122: pcm5122_dac + number: 3 + mode: + output: true + +binary_sensor: + - platform: gpio + id: pcm5122_gpio_input + pin: + pcm5122: pcm5122_dac + number: 4 + mode: + input: true diff --git a/tests/components/pcm5122/test.esp32-ard.yaml b/tests/components/pcm5122/test.esp32-ard.yaml new file mode 100644 index 0000000000..7c503b0ccb --- /dev/null +++ b/tests/components/pcm5122/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/pcm5122/test.esp32-idf.yaml b/tests/components/pcm5122/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/pcm5122/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/pcm5122/test.esp8266-ard.yaml b/tests/components/pcm5122/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/pcm5122/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/pcm5122/test.rp2040-ard.yaml b/tests/components/pcm5122/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/pcm5122/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From 25d656d468ae8e51921d34f5a372951d93c66da1 Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:04:10 +0200 Subject: [PATCH 1586/2214] [dsmr] Update dsmr_parser library to 1.9.0 (#16881) --- .clang-tidy.hash | 2 +- esphome/components/dsmr/__init__.py | 2 +- platformio.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 591ca3eb4d..566cac066e 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a1aa12cb72cb0cc57c25649aafed8412434b013885cfda107f8aac5c083b4577 +def25306bb0f5e09b94fe7b74ffa6995a56bb951e7a27d9ad0a21103532a74a9 diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 05f9a78156..1dc3664602 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -87,7 +87,7 @@ async def to_code(config): cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - cg.add_library("esphome/dsmr_parser", "1.8.0") + cg.add_library("esphome/dsmr_parser", "1.9.0") def final_validate(config: ConfigType) -> ConfigType: diff --git a/platformio.ini b/platformio.ini index 251339fb5f..b41e850bcd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,7 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.8.0 ; dsmr + esphome/dsmr_parser@1.9.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library From 5faed9d5f5284b9182d5e12af578ba91c38a395a Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 9 Jun 2026 13:04:51 +0200 Subject: [PATCH 1587/2214] [nrf52] native build - download toolchain and sdk in venv (#16388) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Jonathan Swoboda --- esphome/components/nrf52/__init__.py | 13 + esphome/components/nrf52/framework.py | 171 ++++ esphome/const.py | 1 + esphome/core/__init__.py | 4 + esphome/espidf/framework.py | 597 +---------- esphome/framework_helpers.py | 677 +++++++++++++ requirements.txt | 1 + tests/unit_tests/test_core.py | 7 + tests/unit_tests/test_espidf_framework.py | 530 +++++++++- tests/unit_tests/test_framework_helpers.py | 954 ++++++++++++++++++ tests/unit_tests/test_nrf52_framework.py | 219 ++++ tests/unit_tests/test_platformio_toolchain.py | 15 + 12 files changed, 2624 insertions(+), 565 deletions(-) create mode 100644 esphome/components/nrf52/framework.py create mode 100644 esphome/framework_helpers.py create mode 100644 tests/unit_tests/test_framework_helpers.py create mode 100644 tests/unit_tests/test_nrf52_framework.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 48b67e1ef9..56367d0b26 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -63,6 +63,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) +from .framework import check_and_install # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -562,3 +563,15 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> _LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr)) return False + + +def run_compile(args, config: ConfigType) -> bool: + if CORE.using_toolchain_platformio: + return False + if not CORE.using_toolchain_sdk_nrf: + raise EsphomeError( + "Unsupported toolchain for nRF52. " + "Supported toolchains are 'platformio' and 'sdk-nrf'." + ) + check_and_install() + raise EsphomeError("Native build for nRF52 is not implemented yet") diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py new file mode 100644 index 0000000000..607ad0c7ed --- /dev/null +++ b/esphome/components/nrf52/framework.py @@ -0,0 +1,171 @@ +import logging +import os +from pathlib import Path +import platform +import tempfile + +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import ( + archive_extract_all, + create_venv, + download_from_mirrors, + get_python_env_executable_path, + rmdir, + run_command_ok, + str_to_lst_of_str, +) + +_LOGGER = logging.getLogger(__name__) + +_WEST_VERSION = "1.5.0" +_TOOLCHAIN_VERSION = "0.17.4" + +SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( + os.environ.get( + "ESPHOME_SDK_NG_TOOLCHAIN_MIRRORS", + "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/toolchain_{sysname}-{machine}_arm-zephyr-eabi.{extension}", + ) +) + + +def _get_tools_path() -> Path: + return CORE.data_dir / "sdk-nrf" + + +def _get_python_env_path(version: str) -> Path: + return _get_tools_path() / "penvs" / version + + +def _get_framework_path(version: str) -> Path: + return _get_tools_path() / "frameworks" / f"{version}" + + +def _get_toolchain_path(version: str) -> Path: + return _get_tools_path() / "toolchains" / f"{version}" + + +# onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. +_SITECUSTOMIZE = """\ +import os, stat, shutil, sys +_orig = shutil.rmtree +def _handler(func, path, exc): + os.chmod(path, stat.S_IWRITE); func(path) +if sys.version_info >= (3, 12): + def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): + if onerror is None and onexc is None: + onexc = _handler + return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) +else: + def _rmtree(path, ignore_errors=False, onerror=None): + if onerror is None: + onerror = _handler + return _orig(path, ignore_errors=ignore_errors, onerror=onerror) +shutil.rmtree = _rmtree +""" + + +def _install_sitecustomize(python_env_path: Path) -> None: + """Patch shutil.rmtree inside the penv to handle read-only files. + + west init's shutil.move falls back to copytree+rmtree on Windows, and + rmtree dies on the read-only .idx/.pack files git just wrote into + manifest-tmp. Dropping a sitecustomize.py into the venv applies the + same fix esphome.helpers.rmtree uses, but inside the subprocess. + """ + if os.name != "nt": + return + site_packages = python_env_path / "Lib" / "site-packages" + site_packages.mkdir(parents=True, exist_ok=True) + (site_packages / "sitecustomize.py").write_text(_SITECUSTOMIZE, encoding="utf-8") + + +def _get_toolchain_platform_info() -> tuple[str, str, str]: + """Return (sysname, machine, extension) for the current host.""" + extension = "tar.xz" + sysname = platform.system().lower() + machine = platform.machine() + if machine == "arm64": + machine = "aarch64" + if sysname == "darwin": + sysname = "macos" + elif sysname == "windows": + machine = "x86_64" + extension = "7z" + return sysname, machine, extension + + +def check_and_install() -> None: + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + version = f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + python_env_path = _get_python_env_path(version) + env_python_path = get_python_env_executable_path(python_env_path, "python") + sentinel = python_env_path / ".ready" + install_venv = not sentinel.exists() + if install_venv: + rmdir(python_env_path, msg=f"Clean up {version} Python environment") + + create_venv(python_env_path, msg=f"{version}") + + _install_sitecustomize(python_env_path) + + _LOGGER.info("Installing west %s ...", _WEST_VERSION) + cmd = [str(env_python_path), "-m", "pip", "install", f"west=={_WEST_VERSION}"] + if not run_command_ok(cmd): + raise EsphomeError(f"Install west for {version} Python environment failure") + sentinel.touch() + + framework_path = _get_framework_path(version) + sentinel = framework_path / ".ready" + if install_venv or not sentinel.exists(): + rmdir(framework_path, msg=f"Clean up {version} framework environment") + _LOGGER.info("Initializing nRF Connect SDK %s ...", version) + cmd = [ + str(env_python_path), + "-m", + "west", + "init", + "-m", + "https://github.com/nrfconnect/sdk-nrf", + "--mr", + f"{version}", + str(framework_path), + ] + if not run_command_ok(cmd): + raise EsphomeError(f"Can't initialize nRF Connect SDK {version}") + _LOGGER.info("Updating nRF Connect SDK %s (this may take a while) ...", version) + cmd = [ + str(env_python_path), + "-m", + "west", + "update", + "--narrow", + "--fetch-opt=--depth=1", + ] + if not run_command_ok(cmd, cwd=framework_path): + raise EsphomeError(f"Can't update nRF Connect SDK {version}") + sentinel.touch() + + toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) + sentinel = toolchains_dir / ".ready" + if not sentinel.exists(): + rmdir( + toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" + ) + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) + + sysname, machine, extension = _get_toolchain_platform_info() + + download_from_mirrors( + SDK_NG_TOOLCHAIN_MIRRORS, + { + "VERSION": _TOOLCHAIN_VERSION, + "sysname": sysname, + "machine": machine, + "extension": extension, + }, + tmp.file, + ) + archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") + sentinel.touch() diff --git a/esphome/const.py b/esphome/const.py index 07f6bad771..22351244bd 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -20,6 +20,7 @@ class Toolchain(StrEnum): PLATFORMIO = "platformio" ESP_IDF = "esp-idf" + SDK_NRF = "sdk-nrf" class Platform(StrEnum): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index df8fd0a756..90c162fedd 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -867,6 +867,10 @@ class EsphomeCore: def using_toolchain_platformio(self): return self.toolchain == Toolchain.PLATFORMIO + @property + def using_toolchain_sdk_nrf(self): + return self.toolchain == Toolchain.SDK_NRF + @property def using_zephyr(self): return self.target_framework == "zephyr" diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 2c520d0d2c..1bc79cc412 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1,8 +1,5 @@ """ESP-IDF framework tools for ESPHome.""" -from collections.abc import Iterable -from contextlib import ExitStack -import io import json import logging import os @@ -10,39 +7,29 @@ from pathlib import Path import platform import re import shutil -import subprocess -import sys import tempfile -from typing import IO - -import requests from esphome.config_validation import Version from esphome.core import CORE -from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed - -PathType = str | os.PathLike +from esphome.framework_helpers import ( + PathType, + archive_extract_all, + create_venv, + download_from_mirrors, + get_python_env_executable_path, + get_system_python_path, + rmdir, + run_command, + run_command_ok, + str_to_lst_of_str, +) +from esphome.helpers import get_str_env, write_file_if_changed _LOGGER = logging.getLogger(__name__) _SCRIPTS_DIR = Path(__file__).parent -def _str_to_lst_of_str(a: str | list[str]) -> list[str]: - """ - Convert a string to a list of string - - Args: - a: A string containing semicolon-separated values, or an already-split list - - Returns: - list of strings - """ - if isinstance(a, list): - return a - return [f.strip() for f in a.split(";") if f.strip()] - - ESPHOME_STAMP_FILE = ".esphome.stamp.json" # Cache-buster baked into the stamp file. Bump this whenever a change would @@ -54,23 +41,23 @@ ESPHOME_STAMP_FILE = ".esphome.stamp.json" # Bumping triggers a full reinstall on every user's next run. STAMP_SCHEMA_VERSION = "0" -ESPHOME_IDF_DEFAULT_TARGETS = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_TARGETS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TARGETS", "all") ) -ESPHOME_IDF_DEFAULT_TOOLS = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_TOOLS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TOOLS", "cmake;ninja") ) -ESPHOME_IDF_DEFAULT_TOOLS_FORCE = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_TOOLS_FORCE = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TOOLS_FORCE", "required") ) -ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_FEATURES = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_FEATURES", "core") ) -ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( +ESPHOME_IDF_FRAMEWORK_MIRRORS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") or [ "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", @@ -78,7 +65,7 @@ ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( ] ) -ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str( +ESP_IDF_CONSTRAINTS_MIRRORS = str_to_lst_of_str( os.environ.get( "ESP_IDF_CONSTRAINTS_MIRRORS", "https://dl.espressif.com/dl/esp-idf/espidf.constraints.v{VERSION}.txt", @@ -124,59 +111,6 @@ def _get_python_env_path(version: str) -> Path: return _get_idf_tools_path() / "penvs" / f"{version}" -def rmdir(directory: PathType, msg: str | None = None): - """ - Remove a directory and its contents recursively if it exists. - - Args: - directory: Path to the directory to be removed - msg: Optional debug message to log before removal or it an error occurs - - Returns: - None - - Raises: - RuntimeError: If directory removal fails - """ - if Path(directory).is_dir(): - try: - if msg: - _LOGGER.debug(msg) - rmtree(directory) - except OSError as e: - raise RuntimeError( - f"Error during {msg}: can't remove `{directory}`. Please remove it manually!" - ) from e - - -def _get_pythonexe_path() -> str: - """ - Get the path to the Python executable. - - Returns: - Path to Python executable as string - """ - # Try to get PYTHONEXEPATH environment variable - # Fallback to sys.executable if not set - return os.environ.get("PYTHONEXEPATH", os.path.normpath(sys.executable)) - - -def _get_python_env_executable_path(root: PathType, binary: str) -> Path: - """ - Get the path to a Python environment executable file. - - Args: - root: Root directory of the Python environment - binary: Name of the executable binary - - Returns: - Path object pointing to the executable file - """ - if os.name == "nt": - return Path(root) / "Scripts" / f"{binary}.exe" - return Path(root) / "bin" / binary - - def _check_stamp(file: PathType, data: dict[str, str]) -> bool: """ Check if a stamp file contains the expected data. @@ -210,84 +144,6 @@ def _write_stamp(file: PathType, data: dict[str, str]): json.dump(data, fp) -def _exec( - cmd: list[str], - msg: str | None = None, - env: dict[str, str] | None = None, - stream_output: bool = False, -) -> tuple[bool, str | None, str | None]: - """ - Execute a command and return results. - - Args: - cmd: list of command arguments - msg: Optional custom message for logging - env: Optional dictionary of environment variables to set - stream_output: If True, inherit parent stdio so the subprocess prints - directly to the terminal (useful for commands that produce their - own progress output). stdout/stderr are not captured in this mode. - - Returns: - tuple of (success: bool, stdout: str or None, stderr: str or None). - When stream_output is True, stdout and stderr are always None. - """ - cmd_str = msg or " ".join(cmd) - try: - _LOGGER.debug("%s - running ...", cmd_str) - - run_env = os.environ.copy() - if env: - run_env.update(env) - - if stream_output: - result = subprocess.run(cmd, check=False, env=run_env) - stdout = stderr = None - else: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=False, - env=run_env, - ) - stdout = result.stdout - stderr = result.stderr - - if result.returncode != 0: - if stream_output: - _LOGGER.error("%s - failed (returncode=%s)", cmd_str, result.returncode) - else: - tail = (stderr or stdout or "").strip()[-1000:] - _LOGGER.error( - "%s - failed (returncode=%s). Tail:\n%s", - cmd_str, - result.returncode, - tail, - ) - return False, stdout, stderr - - _LOGGER.debug("%s - executed successfully", cmd_str) - return True, stdout, stderr - - except (subprocess.SubprocessError, OSError) as e: - _LOGGER.error("%s - error: %s", cmd_str, str(e)) - return False, None, None - - -def _exec_ok(*args, **kwargs) -> bool: - """ - Execute a command and return only the success status. - - Args: - *args: Positional arguments to pass to _exec function - **kwargs: Keyword arguments to pass to _exec function - - Returns: - True if command executed successfully, False otherwise - """ - return _exec(*args, **kwargs)[0] - - def _get_idf_version( idf_framework_root: PathType, env: dict[str, str] | None = None ) -> str: @@ -306,12 +162,12 @@ def _get_idf_version( """ cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(_SCRIPTS_DIR / "get_idf_version.py"), str(idf_framework_root), ] - success, stdout, stderr = _exec( + success, stdout, stderr = run_command( cmd, msg="ESP-IDF version", env=(env or os.environ) @@ -346,12 +202,12 @@ def _get_idf_tool_paths( """ cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(_SCRIPTS_DIR / "get_idf_tool_paths.py"), str(idf_framework_root), ] - success, stdout, stderr = _exec( + success, stdout, stderr = run_command( cmd, msg="ESP-IDF tool paths", env=(env or os.environ) @@ -397,7 +253,7 @@ print(".".join([str(x) for x in sys.version_info])) """ cmd = [python_executable, "-c", script] - success, stdout, _ = _exec(cmd, msg="Python version", env=env) + success, stdout, _ = run_command(cmd, msg="Python version", env=env) if stdout: stdout = stdout.strip() @@ -406,393 +262,6 @@ print(".".join([str(x) for x in sys.version_info])) return stdout -def _create_venv(root: PathType, msg: str | None = None): - """ - Create a Python virtual environment. - - Args: - root: Path to the virtual environment directory - msg: Optional message for logging - - Returns: - None - - Raises: - Exception: If virtual environment creation fails - """ - cmd = [_get_pythonexe_path(), "-m", "venv", "--clear", root] - if not _exec_ok(cmd, msg=f"Create Python virtual environment for {msg}"): - raise RuntimeError(f"Can't create Python virtual environment for {msg}") - - -def _detect_archive_root(names: Iterable[str]) -> str | None: - """Detect a single top-level directory shared by all archive entries. - - Returns the directory name if every non-empty entry sits under the same - top-level directory, else ``None``. Extraction helpers use this to strip - the wrapper directory commonly found in source archives during extraction - rather than renaming it afterwards — post-extraction renames are - unreliable on Windows because antivirus and the search indexer briefly - hold handles on freshly written files. - """ - root: str | None = None - has_descendant = False - for raw in names: - name = raw.replace("\\", "/").strip("/") - if not name: - continue - first, sep, _ = name.partition("/") - if root is None: - root = first - elif root != first: - return None - if sep: - has_descendant = True - return root if has_descendant else None - - -def _tar_extract_all( - data: io.BufferedIOBase, - extract_dir: PathType = ".", - progress_header: str | None = None, -): - """ - Extract a TAR archive to the specified directory. - - Implementation is inspired by Python 3.12's tarfile data filtering logic. - This can be replaced with the standard library implementation once - support for Python 3.11 is no longer required. - - Args: - data: File-like object containing the TAR archive - extract_dir: Directory to extract contents to - progress_header: If set, show a progress bar with this header - """ - import stat - import tarfile - - # Tar extraction safety: os.path.realpath / commonpath / normpath have no - # pathlib equivalents and Path.resolve() would follow symlinks unsafely. - # Use os.path for the security-sensitive parts; the simple checks move to - # Path. - extract_dir = os.fspath(extract_dir) - abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 - - with tarfile.open(fileobj=data, mode="r") as tar_ref: - all_members = tar_ref.getmembers() - - # Detect a single common top-level directory and strip it during - # extraction so we don't have to flatten it via a rename afterwards. - strip_root = _detect_archive_root(m.name for m in all_members) - strip_prefix = f"{strip_root}/" if strip_root is not None else None - - safe_members = [] - - for member in all_members: - name = member.name - - # 1. Strip leading slashes - name = name.lstrip("/" + os.sep) - - # 2. Reject absolute paths (incl. Windows drive) - if Path(name).is_absolute() or ( - os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 - ): - continue - - # 3. Strip wrapper directory if one was detected - if strip_prefix is not None: - norm = name.replace("\\", "/") - if norm in (strip_root, strip_prefix): - continue - if not norm.startswith(strip_prefix): - continue - name = norm[len(strip_prefix) :] - - # 4. Compute final path - target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 - if os.path.commonpath([abs_dest, target_path]) != abs_dest: - continue - - # 5. Validate links properly - if member.issym() or member.islnk(): - linkname = member.linkname - - # Reject absolute link targets - if Path(linkname).is_absolute(): - continue - - # Strip leading slashes - linkname = os.path.normpath(linkname) - - if member.issym(): - link_target = os.path.join( # noqa: PTH118 - abs_dest, - os.path.dirname(name), # noqa: PTH120 - linkname, - ) - else: - link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 - link_target = os.path.realpath(link_target) - - if os.path.commonpath([abs_dest, link_target]) != abs_dest: - continue - - # write back normalized linkname - member.linkname = linkname - - # 6. Sanitize permissions - mode = member.mode - if mode is not None: - # Strip high bits & group/other write bits - mode &= ( - stat.S_IRWXU - | stat.S_IRGRP - | stat.S_IXGRP - | stat.S_IROTH - | stat.S_IXOTH - ) - if member.isfile() or member.islnk(): - # remove exec bits unless explicitly user-executable - if not (mode & stat.S_IXUSR): - mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - mode |= stat.S_IRUSR | stat.S_IWUSR - elif not (member.isdir() or member.issym()): - # Block special files. Directories and symlinks keep - # their masked-original mode — passing None here would - # crash tarfile.extract on Python <3.12 (its chmod - # path calls os.chmod unconditionally). - continue - - member.mode = mode - - # 7. Strip ownership - member.uid = None - member.gid = None - member.uname = None - member.gname = None - - # 8. Assign sanitized name back - member.name = name - - safe_members.append(member) - - total = len(safe_members) - progress = ( - ProgressBar(progress_header) if progress_header and total > 0 else None - ) - for i, member in enumerate(safe_members, 1): - tar_ref.extract(member, abs_dest) - if progress is not None: - progress.update(i / total) - if progress is not None: - progress.update(1) - - -def _zip_extract_all( - data: io.BufferedIOBase, - extract_dir: PathType = ".", - progress_header: str | None = None, -): - """ - Extract a ZIP archive to the specified directory. - - Args: - data: File-like object containing the ZIP archive - extract_dir: Directory to extract contents to - progress_header: If set, show a progress bar with this header - """ - import zipfile - - # See note in archive_extract_all_tar: os.path is used intentionally for - # the security-sensitive abspath/commonpath checks below. - extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 - - with zipfile.ZipFile(data, "r") as zip_ref: - all_members = zip_ref.infolist() - - # Detect a single common top-level directory and strip it during - # extraction so we don't have to flatten it via a rename afterwards. - strip_root = _detect_archive_root(m.filename for m in all_members) - strip_prefix = f"{strip_root}/" if strip_root is not None else None - - total = len(all_members) - progress = ( - ProgressBar(progress_header) if progress_header and total > 0 else None - ) - - for i, member in enumerate(all_members, 1): - # 1. Normalize name - name = member.filename.lstrip("/\\") - - # 2. Reject absolute paths / Windows drives - if Path(name).is_absolute() or ( - os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 - ): - continue - - # 3. Strip wrapper directory if one was detected - if strip_prefix is not None: - norm = name.replace("\\", "/") - if norm in (strip_root, strip_prefix): - continue - if not norm.startswith(strip_prefix): - continue - name = norm[len(strip_prefix) :] - - # 4. Compute safe target path - target_path = os.path.abspath(os.path.join(extract_dir, name)) # noqa: PTH100, PTH118 - - if os.path.commonpath([extract_dir, target_path]) != extract_dir: - raise ValueError(f"Unsafe path detected: {member.filename}") - - # 5. Assign sanitized name back - member.filename = name - - # 6. Extract - zip_ref.extract(member, extract_dir) - - if progress is not None: - progress.update(i / total) - if progress is not None: - progress.update(1) - - -_ARCHIVE_MAGIC_MAP = { - b"\x1f\x8b\x08": _tar_extract_all, - b"\x42\x5a\x68": _tar_extract_all, - b"\xfd\x37\x7a\x58\x5a\x00": _tar_extract_all, - b"\x50\x4b\x03\x04": _zip_extract_all, -} - - -def archive_extract_all( - archive: PathType | io.RawIOBase | IO[bytes], - extract_dir: PathType = ".", - progress_header: str | None = None, -): - """ - Extract an archive file to the specified directory. - - Args: - archive: Path to archive file or file-like object - extract_dir: Directory to extract contents to - progress_header: If set, show a progress bar with this header - - Raises: - TypeError: If archive is not a valid type - ValueError: If archive format is unsupported - """ - - # 1. Handle different archive input types - with ExitStack() as stack: - archive_ref: io.BufferedIOBase - if isinstance(archive, (str, os.PathLike)): - archive_ref = stack.enter_context(Path(archive).open("rb")) - elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)): - archive_ref = archive - elif isinstance(archive, io.RawIOBase): - archive_ref = io.BufferedReader(archive) - else: - raise TypeError( - f"archive must be str, Path, or file-like object: {type(archive)}" - ) - - # 2. Detect archive format and select appropriate extraction function - matched_fct = None - magic_len = max(len(k) for k in _ARCHIVE_MAGIC_MAP) - header = archive_ref.peek(magic_len) - for magic, fct in _ARCHIVE_MAGIC_MAP.items(): - if header.startswith(magic): - matched_fct = fct - break - if matched_fct is None: - raise ValueError("Unsupported archive format") - matched_fct(archive_ref, extract_dir, progress_header=progress_header) - - -def download_from_mirrors( - mirrors: list[str], - substitutions: dict[str, str], - target: io.RawIOBase | IO[bytes] | PathType, - timeout: int = 30, -) -> str | None: - """ - 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. - - Raises: - Exception: If all download attempts fail - """ - # 1. Open target file for writing if path given - with ExitStack() as stack: - if isinstance(target, (str, os.PathLike)): - f = stack.enter_context(Path(target).open("wb")) - 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 - last_exception = None - - for mirror in mirrors: - # 3. Apply substitutions to URL - url = mirror.format(**substitutions) - - _LOGGER.debug("Trying downloading from %s", url) - - try: - # 4. Reset file pointer and download - f.seek(0) - f.truncate(0) - - with requests.get(url, stream=True, timeout=timeout) as r: - r.raise_for_status() - - total_size = int(r.headers.get("content-length", 0)) - downloaded = 0 - - progress = ProgressBar("Downloading") if total_size > 0 else None - - for chunk in r.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - - downloaded += len(chunk) - - if progress is not None: - progress.update(downloaded / total_size) - - if progress is not None: - progress.update(1) - - _LOGGER.debug("Downloaded successfully from: %s", url) - - # 6. Reset file pointer and return - f.seek(0) - return url - - except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught - _LOGGER.debug("Failed to download %s: %s", url, str(e)) - last_exception = e - - # 7. Raise last exception if all mirrors failed - if last_exception: - raise last_exception - return None - - _GITHUB_SHORTHAND_RE = re.compile( r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" ) @@ -1067,12 +536,12 @@ def _check_esphome_idf_framework_install( if _check_stamp(env_stamp_file, stamp_info): _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(idf_tools_path), "--non-interactive", "check", ] - if _exec_ok(cmd, msg=f"ESP-IDF {version} check", env=env): + if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env): install = False # 4. Install framework tools if not installed or needs update @@ -1080,13 +549,13 @@ def _check_esphome_idf_framework_install( _LOGGER.info("Installing ESP-IDF %s framework ...", version) targets_str = ",".join(targets) cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(idf_tools_path), "--non-interactive", "install", f"--targets={targets_str}", ] + tools - if not _exec_ok( + if not run_command_ok( cmd, msg=f"ESP-IDF {version} framework installation", env=env, @@ -1128,7 +597,7 @@ def _check_esp_idf_python_env_install( framework_path = _get_framework_path(version) python_env_path = _get_python_env_path(version) env_stamp_file = python_env_path / ESPHOME_STAMP_FILE - env_python_path = _get_python_env_executable_path(python_env_path, "python") + env_python_path = get_python_env_executable_path(python_env_path, "python") _LOGGER.info("Checking ESP-IDF %s Python environment ...", version) install = force or not python_env_path.is_dir() or not env_python_path.is_file() @@ -1144,7 +613,7 @@ def _check_esp_idf_python_env_install( if install: rmdir(python_env_path, msg=f"Clean up ESP-IDF {version} Python environment") - _create_venv(python_env_path, msg=f"ESP-IDF {version}") + create_venv(python_env_path, msg=f"ESP-IDF {version}") esp_idf_version = _get_idf_version(framework_path, env=env) constraint_file_path = ( @@ -1174,7 +643,7 @@ def _check_esp_idf_python_env_install( "pip", "setuptools", ] - if not _exec_ok( + if not run_command_ok( cmd, msg=f"Upgrade ESP-IDF {version} Python environment packages", env=env, @@ -1194,7 +663,7 @@ def _check_esp_idf_python_env_install( "-r", str(requirements_file), ] - if not _exec_ok( + if not run_command_ok( cmd, msg=f"Install ESP-IDF {version} Python dependencies for {feature}", env=env, @@ -1296,7 +765,7 @@ def get_framework_env( # 3. If Python environment path is provided, add it to PATH and set IDF_PYTHON_ENV_PATH if python_env_path: - python_path = _get_python_env_executable_path(python_env_path, "python") + python_path = get_python_env_executable_path(python_env_path, "python") path_list.insert(0, str(python_path.parent)) env["IDF_PYTHON_ENV_PATH"] = str(python_env_path) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py new file mode 100644 index 0000000000..276dfbbf1c --- /dev/null +++ b/esphome/framework_helpers.py @@ -0,0 +1,677 @@ +"""Generic toolchain installation helpers shared across framework implementations.""" + +from collections.abc import Iterable +from contextlib import ExitStack +import io +import logging +import os +from pathlib import Path +import subprocess +import sys +import time +from typing import IO + +import requests + +from esphome.helpers import ProgressBar, rmtree + +PathType = str | os.PathLike + +_LOGGER = logging.getLogger(__name__) + + +def str_to_lst_of_str(a: str | list[str]) -> list[str]: + """ + Convert a string to a list of string + + Args: + a: A string containing semicolon-separated values, or an already-split list + + Returns: + list of strings + """ + if isinstance(a, list): + return a + return [f.strip() for f in a.split(";") if f.strip()] + + +def rmdir(directory: PathType, msg: str | None = None): + """ + Remove a directory and its contents recursively if it exists. + + Args: + directory: Path to the directory to be removed + msg: Optional debug message to log before removal or it an error occurs + + Returns: + None + + Raises: + RuntimeError: If directory removal fails + """ + if Path(directory).is_dir(): + try: + if msg: + _LOGGER.debug(msg) + rmtree(directory) + except OSError as e: + raise RuntimeError( + f"Error during {msg}: can't remove `{directory}`. Please remove it manually!" + ) from e + + +def get_system_python_path() -> str: + """ + Get the path to the Python executable. + + Returns: + Path to Python executable as string + """ + # Try to get PYTHONEXEPATH environment variable + # Fallback to sys.executable if not set + return os.environ.get("PYTHONEXEPATH", os.path.normpath(sys.executable)) + + +def get_python_env_executable_path(root: PathType, binary: str) -> Path: + """ + Get the path to a Python environment executable file. + + Args: + root: Root directory of the Python environment + binary: Name of the executable binary + + Returns: + Path object pointing to the executable file + """ + if os.name == "nt": + return Path(root) / "Scripts" / f"{binary}.exe" + return Path(root) / "bin" / binary + + +def run_command( + cmd: list[str], + msg: str | None = None, + env: dict[str, str] | None = None, + stream_output: bool = False, + cwd: PathType | None = None, +) -> tuple[bool, str | None, str | None]: + """ + Execute a command and return results. + + Args: + cmd: list of command arguments + msg: Optional custom message for logging + env: Optional dictionary of environment variables to set + stream_output: If True, inherit parent stdio so the subprocess prints + directly to the terminal (useful for commands that produce their + own progress output). stdout/stderr are not captured in this mode. + cwd: Optional working directory for the subprocess. + + Returns: + tuple of (success: bool, stdout: str or None, stderr: str or None). + When stream_output is True, stdout and stderr are always None. + """ + cmd_str = msg or " ".join(cmd) + try: + _LOGGER.debug("%s - running ...", cmd_str) + + run_env = os.environ.copy() + if env: + run_env.update(env) + + if stream_output: + result = subprocess.run(cmd, check=False, env=run_env, cwd=cwd) + stdout = stderr = None + else: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + env=run_env, + cwd=cwd, + ) + stdout = result.stdout + stderr = result.stderr + + if result.returncode != 0: + if stream_output: + _LOGGER.error("%s - failed (returncode=%s)", cmd_str, result.returncode) + else: + tail = (stderr or stdout or "").strip()[-1000:] + _LOGGER.error( + "%s - failed (returncode=%s). Tail:\n%s", + cmd_str, + result.returncode, + tail, + ) + return False, stdout, stderr + + _LOGGER.debug("%s - executed successfully", cmd_str) + return True, stdout, stderr + + except (subprocess.SubprocessError, OSError) as e: + _LOGGER.error("%s - error: %s", cmd_str, str(e)) + return False, None, None + + +def run_command_ok(*args, **kwargs) -> bool: + """ + Execute a command and return only the success status. + + Args: + *args: Positional arguments to pass to run_command + **kwargs: Keyword arguments to pass to run_command + + Returns: + True if command executed successfully, False otherwise + """ + return run_command(*args, **kwargs)[0] + + +def create_venv(root: PathType, msg: str | None = None): + """ + Create a Python virtual environment. + + Args: + root: Path to the virtual environment directory + msg: Optional message for logging + + Returns: + None + + Raises: + RuntimeError: If virtual environment creation fails + """ + cmd = [get_system_python_path(), "-m", "venv", "--clear", root] + if not run_command_ok(cmd, msg=f"Create Python virtual environment for {msg}"): + raise RuntimeError(f"Can't create Python virtual environment for {msg}") + + +def _detect_archive_root(names: Iterable[str]) -> str | None: + """Detect a single top-level directory shared by all archive entries. + + Returns the directory name if every non-empty entry sits under the same + top-level directory, else ``None``. Extraction helpers use this to strip + the wrapper directory commonly found in source archives during extraction + rather than renaming it afterwards — post-extraction renames are + unreliable on Windows because antivirus and the search indexer briefly + hold handles on freshly written files. + """ + root: str | None = None + has_descendant = False + for raw in names: + name = raw.replace("\\", "/").strip("/") + if not name: + continue + first, sep, _ = name.partition("/") + if root is None: + root = first + elif root != first: + return None + if sep: + has_descendant = True + return root if has_descendant else None + + +def _tar_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a TAR archive to the specified directory. + + Implementation is inspired by Python 3.12's tarfile data filtering logic. + This can be replaced with the standard library implementation once + support for Python 3.11 is no longer required. + + Args: + data: File-like object containing the TAR archive + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import stat + import tarfile + + # Tar extraction safety: os.path.realpath / commonpath / normpath have no + # pathlib equivalents and Path.resolve() would follow symlinks unsafely. + # Use os.path for the security-sensitive parts; the simple checks move to + # Path. + extract_dir = os.fspath(extract_dir) + abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 + + with tarfile.open(fileobj=data, mode="r") as tar_ref: + all_members = tar_ref.getmembers() + + # Detect a single common top-level directory and strip it during + # extraction so we don't have to flatten it via a rename afterwards. + strip_root = _detect_archive_root(m.name for m in all_members) + strip_prefix = f"{strip_root}/" if strip_root is not None else None + + safe_members = [] + + for member in all_members: + name = member.name + + # 1. Strip leading slashes + name = name.lstrip("/" + os.sep) + + # 2. Reject absolute paths (incl. Windows drive) + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 + ): + continue + + # 3. Strip wrapper directory if one was detected + if strip_prefix is not None: + norm = name.replace("\\", "/") + if norm in (strip_root, strip_prefix): + continue + if not norm.startswith(strip_prefix): + continue + name = norm[len(strip_prefix) :] + + # 4. Compute final path + target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 + if os.path.commonpath([abs_dest, target_path]) != abs_dest: + continue + + # 5. Validate links properly + if member.issym() or member.islnk(): + linkname = member.linkname + + # Reject absolute link targets + if Path(linkname).is_absolute(): + continue + + if member.islnk() and strip_prefix is not None: + # Hard-link linknames reference another archive member + # by its archive name. We've stripped the wrapper prefix + # from member.name above (step 3); strip it here too so + # tarfile._find_link_target can resolve the target during + # extraction. Symlink linknames are filesystem-relative + # paths, not archive-member references, so they don't + # need this treatment. + norm_link = linkname.replace("\\", "/") + if norm_link in (strip_root, strip_prefix): + continue + if not norm_link.startswith(strip_prefix): + continue + linkname = norm_link[len(strip_prefix) :] + + # Strip leading slashes + linkname = os.path.normpath(linkname) + + if member.issym(): + link_target = os.path.join( # noqa: PTH118 + abs_dest, + os.path.dirname(name), # noqa: PTH120 + linkname, + ) + else: + link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 + link_target = os.path.realpath(link_target) + + if os.path.commonpath([abs_dest, link_target]) != abs_dest: + continue + + # write back normalized linkname + member.linkname = linkname + + # 6. Sanitize permissions + mode = member.mode + if mode is not None: + # Strip high bits & group/other write bits + mode &= ( + stat.S_IRWXU + | stat.S_IRGRP + | stat.S_IXGRP + | stat.S_IROTH + | stat.S_IXOTH + ) + if member.isfile() or member.islnk(): + # remove exec bits unless explicitly user-executable + if not (mode & stat.S_IXUSR): + mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + mode |= stat.S_IRUSR | stat.S_IWUSR + elif not (member.isdir() or member.issym()): + # Block special files. Directories and symlinks keep + # their masked-original mode — passing None here would + # crash tarfile.extract on Python <3.12 (its chmod + # path calls os.chmod unconditionally). + continue + + member.mode = mode + + # 7. Strip ownership + member.uid = None + member.gid = None + member.uname = None + member.gname = None + + # 8. Assign sanitized name back + member.name = name + + safe_members.append(member) + + total = len(safe_members) + progress = ( + ProgressBar(progress_header) if progress_header and total > 0 else None + ) + for i, member in enumerate(safe_members, 1): + tar_ref.extract(member, abs_dest) + if progress is not None: + progress.update(i / total) + if progress is not None: + progress.update(1) + + +def _zip_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a ZIP archive to the specified directory. + + Args: + data: File-like object containing the ZIP archive + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import zipfile + + # See note in _tar_extract_all: os.path is used intentionally for + # the security-sensitive abspath/commonpath checks below. + extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 + + with zipfile.ZipFile(data, "r") as zip_ref: + all_members = zip_ref.infolist() + + # Detect a single common top-level directory and strip it during + # extraction so we don't have to flatten it via a rename afterwards. + strip_root = _detect_archive_root(m.filename for m in all_members) + strip_prefix = f"{strip_root}/" if strip_root is not None else None + + total = len(all_members) + progress = ( + ProgressBar(progress_header) if progress_header and total > 0 else None + ) + + for i, member in enumerate(all_members, 1): + # 1. Normalize name + name = member.filename.lstrip("/\\") + + # 2. Reject absolute paths / Windows drives + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 + ): + continue + + # 3. Strip wrapper directory if one was detected + if strip_prefix is not None: + norm = name.replace("\\", "/") + if norm in (strip_root, strip_prefix): + continue + if not norm.startswith(strip_prefix): + continue + name = norm[len(strip_prefix) :] + + # 4. Compute safe target path + target_path = os.path.abspath(os.path.join(extract_dir, name)) # noqa: PTH100, PTH118 + + if os.path.commonpath([extract_dir, target_path]) != extract_dir: + raise ValueError(f"Unsafe path detected: {member.filename}") + + # 5. Assign sanitized name back + member.filename = name + + # 6. Extract + zip_ref.extract(member, extract_dir) + + if progress is not None: + progress.update(i / total) + if progress is not None: + progress.update(1) + + +def _rename_with_retry(src: Path, dst: Path, attempts: int = 5) -> None: + """Rename ``src`` to ``dst`` with backoff retries on Windows sharing violations. + + Antivirus/indexer handles on freshly-written files can briefly block + ``os.rename`` with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED. The + handle is released within tens of ms in practice, so exponential backoff + works. + """ + for i in range(attempts): + try: + src.rename(dst) + return + except PermissionError: + if i == attempts - 1: + raise + time.sleep(0.1 * (2**i)) + + +def _7z_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a 7z archive to the specified directory. + + py7zr only supports bulk extraction (no per-member rename hook like + tarfile/zipfile), so we extract into a unique staging subdir of + ``extract_dir`` and then move children up. This keeps everything on + the same volume and sidesteps wrapper-vs-child name collisions + (e.g. ``arm-zephyr-eabi/`` containing another ``arm-zephyr-eabi/``). + + Args: + data: File-like object containing the 7z archive (must be seekable) + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import py7zr + + extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 + Path(extract_dir).mkdir(parents=True, exist_ok=True) + + suffix = 0 + while True: + staging = Path(extract_dir) / f".extract_tmp_{suffix}" + if not staging.exists(): + break + suffix += 1 + staging.mkdir() + + try: + with py7zr.SevenZipFile(data, "r") as z: + all_names = z.getnames() + + # Detect a single common top-level directory to flatten. + strip_root = _detect_archive_root(all_names) + + # Validate names: reject absolute paths, Windows drives, and + # path traversal. Filter via targets= since py7zr can't rename + # per-member. + safe_targets: list[str] = [] + for raw in all_names: + name = raw.lstrip("/\\") + if not name: + continue + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 + ): + continue + target_path = os.path.abspath(os.path.join(staging, name)) # noqa: PTH100, PTH118 + if os.path.commonpath([str(staging), target_path]) != str(staging): + continue + safe_targets.append(raw) + + progress = ( + ProgressBar(progress_header) + if progress_header and safe_targets + else None + ) + + if len(safe_targets) == len(all_names): + z.extractall(path=staging) + else: + z.extract(path=staging, targets=safe_targets) + + if progress is not None: + progress.update(1) + + src_root = staging / strip_root if strip_root else staging + for item in src_root.iterdir(): + dest = Path(extract_dir) / item.name + if dest.exists(): + if dest.is_dir(): + rmtree(dest) + else: + dest.unlink() + _rename_with_retry(item, dest) + finally: + # staging is created before the try, so it always exists here; the + # guard is defensive cleanup and its False branch is unreachable. + if staging.exists(): # pragma: no cover + rmtree(staging) + + +_ARCHIVE_MAGIC_MAP = { + b"\x1f\x8b\x08": _tar_extract_all, + b"\x42\x5a\x68": _tar_extract_all, + b"\xfd\x37\x7a\x58\x5a\x00": _tar_extract_all, + b"\x50\x4b\x03\x04": _zip_extract_all, + b"\x37\x7a\xbc\xaf\x27\x1c": _7z_extract_all, +} + + +def archive_extract_all( + archive: PathType | io.RawIOBase | IO[bytes], + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract an archive file to the specified directory. + + Args: + archive: Path to archive file or file-like object + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + + Raises: + TypeError: If archive is not a valid type + ValueError: If archive format is unsupported + """ + + # 1. Handle different archive input types + with ExitStack() as stack: + archive_ref: io.BufferedIOBase + if isinstance(archive, (str, os.PathLike)): + archive_ref = stack.enter_context(Path(archive).open("rb")) + elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)): + archive_ref = archive + elif isinstance(archive, io.RawIOBase): + archive_ref = io.BufferedReader(archive) + else: + raise TypeError( + f"archive must be str, Path, or file-like object: {type(archive)}" + ) + + # 2. Detect archive format and select appropriate extraction function + matched_fct = None + magic_len = max(len(k) for k in _ARCHIVE_MAGIC_MAP) + header = archive_ref.peek(magic_len) + for magic, fct in _ARCHIVE_MAGIC_MAP.items(): + if header.startswith(magic): + matched_fct = fct + break + if matched_fct is None: + raise ValueError("Unsupported archive format") + matched_fct(archive_ref, extract_dir, progress_header=progress_header) + + +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. + + Raises: + ValueError: If mirrors list is empty. + Exception: If all download attempts fail. + """ + # 1. Open target file for writing if path given + with ExitStack() as stack: + if isinstance(target, (str, os.PathLike)): + f = stack.enter_context(Path(target).open("wb")) + 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 + last_exception = None + + for mirror in mirrors: + # 3. Apply substitutions to URL + url = mirror.format(**substitutions) + + _LOGGER.debug("Trying downloading from %s", url) + + try: + # 4. Reset file pointer and download + f.seek(0) + f.truncate(0) + + with requests.get(url, stream=True, timeout=timeout) as r: + r.raise_for_status() + + total_size = int(r.headers.get("content-length", 0)) + downloaded = 0 + + progress = ProgressBar("Downloading") if total_size > 0 else None + + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + downloaded += len(chunk) + + if progress is not None: + progress.update(downloaded / total_size) + + if progress is not None: + progress.update(1) + + _LOGGER.debug("Downloaded successfully from: %s", url) + + # 6. Reset file pointer and return + f.seek(0) + return url + + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + _LOGGER.debug("Failed to download %s: %s", url, str(e)) + last_exception = e + + # 7. Raise last exception if all mirrors failed + if last_exception: + raise last_exception + raise ValueError("download_from_mirrors called with an empty mirrors list") diff --git a/requirements.txt b/requirements.txt index 8202a2bb44..ed7f2c2941 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 +py7zr==0.22.0 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 2322fdd014..cc371ee1f9 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -894,6 +894,13 @@ class TestEsphomeCore: "foo/build/.pioenvs/test-device/bootloader.bin" ) + def test_using_toolchain_sdk_nrf(self, target): + """using_toolchain_sdk_nrf is True only for the SDK_NRF toolchain.""" + target.toolchain = const.Toolchain.SDK_NRF + assert target.using_toolchain_sdk_nrf is True + target.toolchain = const.Toolchain.ESP_IDF + assert target.using_toolchain_sdk_nrf is False + def test_add_library__extracts_short_name_from_path(self, target): """Test add_library extracts short name from library paths like owner/lib.""" target.data[const.KEY_CORE] = { diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 9f4e4fcca8..036c7c0454 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -2,12 +2,32 @@ # pylint: disable=protected-access +import io +import json from pathlib import Path +import tarfile +from types import SimpleNamespace from unittest.mock import patch import pytest -from esphome.espidf.framework import _clone_idf_with_submodules, _parse_git_source +from esphome.espidf.framework import ( + _check_stamp, + _clone_idf_with_submodules, + _get_framework_path, + _get_idf_tool_paths, + _get_idf_tools_path, + _get_idf_version, + _get_python_env_path, + _get_python_version, + _parse_git_source, + _patch_tools_json_for_linux_arm64, + _write_idf_version_txt, + _write_stamp, + check_esp_idf_install, + get_framework_env, +) +from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path @pytest.mark.parametrize( @@ -154,3 +174,511 @@ def test_clone_idf_with_submodules_raises_when_tree_missing( "https://github.com/espressif/esp-idf.git", None, ) + + +# --------------------------------------------------------------------------- +# Helpers for _tar_extract_all hard-link prefix-stripping tests +# --------------------------------------------------------------------------- + + +def _make_tar( + members: list[tarfile.TarInfo], file_contents: dict[str, bytes] +) -> io.BytesIO: + """Build an in-memory tar archive from a list of TarInfo objects.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + for info in members: + if info.isreg() and info.name in file_contents: + data = file_contents[info.name] + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + else: + tf.addfile(info) + buf.seek(0) + return buf + + +def _regular(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.REGTYPE + info.size = 0 + info.mode = 0o644 + return info + + +def _hardlink(name: str, linkname: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.LNKTYPE + info.linkname = linkname + info.size = 0 + info.mode = 0o644 + return info + + +class TestTarExtractHardLinkPrefixStripping: + """ + Covers the hard-link prefix-stripping block in _tar_extract_all (L528-541). + + Archive layout used by every test: + + wrapper/ ← single top-level wrapper dir (stripped) + wrapper/target.txt ← regular file; becomes target.txt in dest + wrapper/link_good ← hard link to wrapper/target.txt (kept, linkname stripped) + wrapper/link_exact_root ← hard link to "wrapper" (skipped – equals strip_root) + wrapper/link_exact_prefix ← hard link to "wrapper/" (skipped – equals strip_prefix) + wrapper/link_outside ← hard link to "other/target.txt" (skipped – not under prefix) + """ + + WRAPPER = "wrapper" + + def _build_archive(self) -> io.BytesIO: + members = [ + _regular(f"{self.WRAPPER}/"), + _regular(f"{self.WRAPPER}/target.txt"), + _hardlink(f"{self.WRAPPER}/link_good", f"{self.WRAPPER}/target.txt"), + _hardlink(f"{self.WRAPPER}/link_exact_root", self.WRAPPER), + _hardlink(f"{self.WRAPPER}/link_exact_prefix", f"{self.WRAPPER}/"), + _hardlink(f"{self.WRAPPER}/link_outside", "other/target.txt"), + ] + return _make_tar(members, {f"{self.WRAPPER}/target.txt": b"hello"}) + + def test_good_hardlink_is_extracted_with_stripped_linkname( + self, tmp_path: Path + ) -> None: + """Hard link whose linkname starts with wrapper/ is extracted and its + linkname has the prefix removed so tarfile can resolve the target.""" + _tar_extract_all(self._build_archive(), tmp_path) + link = tmp_path / "link_good" + assert link.exists(), "link_good should have been extracted" + assert link.read_bytes() == b"hello" + + def test_hardlink_equal_to_strip_root_is_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname equals strip_root exactly must be dropped.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert not (tmp_path / "link_exact_root").exists() + + def test_hardlink_equal_to_strip_prefix_is_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname equals strip_prefix (strip_root + '/') must be dropped.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert not (tmp_path / "link_exact_prefix").exists() + + def test_hardlink_outside_prefix_is_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname does not start with wrapper/ must be dropped.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert not (tmp_path / "link_outside").exists() + + def test_regular_file_and_no_spurious_files(self, tmp_path: Path) -> None: + """Sanity check: target.txt is extracted and no unexpected files appear.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert (tmp_path / "target.txt").read_bytes() == b"hello" + extracted = {p.name for p in tmp_path.iterdir()} + assert extracted == {"target.txt", "link_good"} + + +_IDF_VERSION = "5.1.2" + + +@pytest.fixture +def espidf_mocks(setup_core: Path): + """Patch the heavy I/O of check_esp_idf_install and pre-create the framework dir.""" + # archive_extract_all is mocked, so pre-create the framework dir that the + # extracted-marker touch writes into. + _get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True) + with ( + patch("esphome.espidf.framework.rmdir"), + patch( + "esphome.espidf.framework.download_from_mirrors", + return_value="https://example.com/idf.tar.xz", + ) as download, + patch("esphome.espidf.framework.archive_extract_all") as extract, + patch("esphome.espidf.framework.create_venv") as venv, + patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, + patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, + patch("esphome.espidf.framework._write_idf_version_txt"), + patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), + patch("esphome.espidf.framework._write_stamp"), + patch("esphome.espidf.framework._check_stamp", return_value=True), + patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION), + patch("esphome.espidf.framework._get_python_version", return_value="3.11.0"), + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + yield SimpleNamespace( + download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone + ) + + +def test_check_esp_idf_install_fresh(espidf_mocks: SimpleNamespace) -> None: + """A forced install drives download/extract, venv creation, and pip installs.""" + framework_path, python_env_path = check_esp_idf_install(_IDF_VERSION, force=True) + + assert framework_path == _get_framework_path(_IDF_VERSION) + assert python_env_path == _get_python_env_path(_IDF_VERSION) + # framework tarball + python-env constraints file are both downloaded + assert espidf_mocks.download.call_count == 2 + espidf_mocks.extract.assert_called_once() + espidf_mocks.venv.assert_called_once() + espidf_mocks.clone.assert_not_called() + + +def test_check_esp_idf_install_git_source(espidf_mocks: SimpleNamespace) -> None: + """A git source_url clones instead of downloading; explicit tools skip discovery.""" + check_esp_idf_install( + _IDF_VERSION, + force=True, + source_url="https://github.com/espressif/esp-idf.git", + tools=["xtensa-esp-elf"], + ) + + espidf_mocks.clone.assert_called_once() + # framework is cloned, so only the python-env constraints file is downloaded + assert espidf_mocks.download.call_count == 1 + + +def test_check_esp_idf_install_already_installed(espidf_mocks: SimpleNamespace) -> None: + """Marker + matching stamps + existing python env → nothing is re-installed.""" + framework_path = _get_framework_path(_IDF_VERSION) + (framework_path / ".esphome_extracted").touch() + python_env_path = _get_python_env_path(_IDF_VERSION) + env_python = get_python_env_executable_path(python_env_path, "python") + env_python.parent.mkdir(parents=True, exist_ok=True) + env_python.touch() + + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() + espidf_mocks.venv.assert_not_called() + + +def test_check_esp_idf_install_framework_failure(espidf_mocks: SimpleNamespace) -> None: + """A failing idf_tools install raises.""" + espidf_mocks.run_ok.side_effect = [False] + with pytest.raises(RuntimeError, match="framework installation failure"): + check_esp_idf_install(_IDF_VERSION, force=True) + + +def test_check_esp_idf_install_pip_upgrade_failure( + espidf_mocks: SimpleNamespace, +) -> None: + """A failing pip upgrade in the python env raises (framework install ok).""" + espidf_mocks.run_ok.side_effect = [True, False] + with pytest.raises(RuntimeError, match="Python environment packages failure"): + check_esp_idf_install(_IDF_VERSION, force=True) + + +def test_check_esp_idf_install_feature_failure(espidf_mocks: SimpleNamespace) -> None: + """A failing feature requirements install raises.""" + espidf_mocks.run_ok.side_effect = [True, True, False] + with pytest.raises(RuntimeError, match="Python dependencies for"): + check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"]) + + +def _mark_installed() -> None: + """Create the extracted marker and python-env interpreter so the install + check takes the already-installed path rather than force-installing.""" + (_get_framework_path(_IDF_VERSION) / ".esphome_extracted").touch() + env_python = get_python_env_executable_path( + _get_python_env_path(_IDF_VERSION), "python" + ) + env_python.parent.mkdir(parents=True, exist_ok=True) + env_python.touch() + + +def test_check_esp_idf_install_stamp_mismatch_reinstalls( + espidf_mocks: SimpleNamespace, +) -> None: + """A stamp mismatch reinstalls tools (marker present, so no re-extract).""" + _mark_installed() + with patch("esphome.espidf.framework._check_stamp", return_value=False): + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() # marker present -> no re-extract + espidf_mocks.venv.assert_called_once() # tools reinstall -> venv rebuilt + + +def test_check_esp_idf_install_check_command_failure_reinstalls( + espidf_mocks: SimpleNamespace, +) -> None: + """A failing idf_tools check reinstalls tools (marker present, no re-extract).""" + _mark_installed() + # idf_tools check fails -> install stays True; the later installs succeed. + espidf_mocks.run_ok.side_effect = [False, True, True, True] + check_esp_idf_install(_IDF_VERSION, features=["fb"]) + + espidf_mocks.extract.assert_not_called() + espidf_mocks.venv.assert_called_once() + + +def test_check_esp_idf_install_unknown_python_version_reinstalls( + espidf_mocks: SimpleNamespace, +) -> None: + """An undeterminable python version rebuilds the venv (framework stamp still ok).""" + _mark_installed() + with patch("esphome.espidf.framework._get_python_version", return_value=None): + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() # framework stamp matched + espidf_mocks.venv.assert_called_once() # python env rebuilt + + +def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( + espidf_mocks: SimpleNamespace, +) -> None: + """Framework stamp matches but the python-env stamp does not -> venv rebuilt.""" + + # _check_stamp passes for the framework (no python_version key) and fails + # for the python env (carries python_version), so only the venv rebuilds. + def stamp_ok(_stamp_file, info: dict) -> bool: + return "python_version" not in info + + _mark_installed() + with patch("esphome.espidf.framework._check_stamp", side_effect=stamp_ok): + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() + espidf_mocks.venv.assert_called_once() + + +def test_check_esp_idf_install_unparseable_version( + espidf_mocks: SimpleNamespace, +) -> None: + """A non-semver version skips the MAJOR/MINOR substitutions without erroring.""" + bad_version = "main" + _get_framework_path(bad_version).mkdir(parents=True, exist_ok=True) + check_esp_idf_install(bad_version, force=True) + + espidf_mocks.extract.assert_called_once() + + +# --------------------------------------------------------------------------- +# _patch_tools_json_for_linux_arm64 (arm64-only ninja backport) +# --------------------------------------------------------------------------- + + +def _write_tools_json(framework_path: Path, data: dict) -> Path: + tools_dir = framework_path / "tools" + tools_dir.mkdir(parents=True, exist_ok=True) + tools_json = tools_dir / "tools.json" + tools_json.write_text(json.dumps(data), encoding="utf-8") + return tools_json + + +def test_patch_tools_json_non_aarch64_is_noop(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, {"tools": [{"name": "ninja", "versions": [{"name": "1.12.1"}]}]} + ) + before = tools_json.read_text(encoding="utf-8") + with patch("esphome.espidf.framework.platform.machine", return_value="x86_64"): + _patch_tools_json_for_linux_arm64(tmp_path) + assert tools_json.read_text(encoding="utf-8") == before + + +def test_patch_tools_json_missing_file_is_noop(tmp_path: Path) -> None: + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) # no tools/tools.json present + + +def test_patch_tools_json_corrupt_file_warns_and_skips(tmp_path: Path) -> None: + (tmp_path / "tools").mkdir() + (tmp_path / "tools" / "tools.json").write_text("{ not json", encoding="utf-8") + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) # JSONDecodeError -> skip + + +def test_patch_tools_json_injects_ninja_arm64(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + {"name": "ninja", "versions": [{"name": "1.12.1"}]}, + {"name": "cmake", "versions": [{"name": "3.24.0"}]}, + ] + }, + ) + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) + + data = json.loads(tools_json.read_text(encoding="utf-8")) + ninja = next(t for t in data["tools"] if t["name"] == "ninja") + assert "linux-arm64" in ninja["versions"][0] + assert ninja["versions"][0]["linux-arm64"]["size"] == 121787 + + +def test_patch_tools_json_already_patched_is_noop(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + { + "name": "ninja", + "versions": [{"name": "1.12.1", "linux-arm64": {"url": "x"}}], + } + ] + }, + ) + before = tools_json.read_text(encoding="utf-8") + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) + assert tools_json.read_text(encoding="utf-8") == before + + +# --------------------------------------------------------------------------- +# Subprocess-backed helpers (_exec -> run_command rename) and get_framework_env +# --------------------------------------------------------------------------- + + +def test_get_idf_version_parses_stdout(tmp_path: Path) -> None: + with patch( + "esphome.espidf.framework.run_command", return_value=(True, "5.1.2\n", "") + ): + assert _get_idf_version(tmp_path) == "5.1.2" + + +def test_get_idf_version_raises_on_failure(tmp_path: Path) -> None: + with ( + patch("esphome.espidf.framework.run_command", return_value=(False, "", "boom")), + pytest.raises(RuntimeError, match="Can't get ESP-IDF version"), + ): + _get_idf_version(tmp_path) + + +def test_get_idf_tool_paths_parses_json(tmp_path: Path) -> None: + payload = json.dumps({"paths_to_export": ["/a", "/b"], "export_vars": {"X": "1"}}) + with patch( + "esphome.espidf.framework.run_command", return_value=(True, payload, "") + ): + paths, export_vars = _get_idf_tool_paths(tmp_path) + assert paths == ["/a", "/b"] + assert export_vars == {"X": "1"} + + +def test_get_idf_tool_paths_raises_on_bad_json(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework.run_command", return_value=(True, "not json", "") + ), + pytest.raises(RuntimeError, match="Can't extract ESP-IDF tool paths"), + ): + _get_idf_tool_paths(tmp_path) + + +def test_get_idf_tool_paths_raises_on_failure(tmp_path: Path) -> None: + with ( + patch("esphome.espidf.framework.run_command", return_value=(False, "", "err")), + pytest.raises(RuntimeError, match="Can't get ESP-IDF tool paths"), + ): + _get_idf_tool_paths(tmp_path) + + +def test_get_python_version_parses_stdout(tmp_path: Path) -> None: + with patch( + "esphome.espidf.framework.run_command", return_value=(True, "3.11.0\n", "") + ): + assert _get_python_version(tmp_path / "python") == "3.11.0" + + +def test_get_python_version_returns_falsy_on_failure(tmp_path: Path) -> None: + with patch("esphome.espidf.framework.run_command", return_value=(False, "", "")): + # non-throwing failure returns the (empty) stdout as-is + assert not _get_python_version(tmp_path / "python") + + +def test_get_python_version_raises_when_requested(tmp_path: Path) -> None: + with ( + patch("esphome.espidf.framework.run_command", return_value=(False, "", "")), + pytest.raises(RuntimeError, match="Can't get Python version"), + ): + _get_python_version(tmp_path / "python", throw_exception=True) + + +def test_write_stamp_writes_json(tmp_path: Path) -> None: + stamp = tmp_path / "stamp.json" + _write_stamp(stamp, {"a": "1", "b": "2"}) + assert json.loads(stamp.read_text(encoding="utf-8")) == {"a": "1", "b": "2"} + + +def test_get_framework_env_with_python_env(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=tmp_path / "tools", + ), + patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), + patch( + "esphome.espidf.framework._get_idf_tool_paths", + return_value=(["/tool/bin"], {"IDF_X": "1"}), + ), + ): + env = get_framework_env( + tmp_path / "fw", tmp_path / "penv", {"PATH": "/usr/bin"} + ) + + assert env["IDF_PATH"] == str(tmp_path / "fw") + assert env["ESP_IDF_VERSION"] == "5.1.2" + assert env["IDF_X"] == "1" + assert env["IDF_PYTHON_ENV_PATH"] == str(tmp_path / "penv") + assert "/tool/bin" in env["PATH"] + + +def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=tmp_path / "tools", + ), + patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), + patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), + ): + env = get_framework_env(tmp_path / "fw") + + assert "IDF_PYTHON_ENV_PATH" not in env + assert env["PATH"] # taken from os.environ + + +# --------------------------------------------------------------------------- +# _check_stamp / _write_idf_version_txt / _get_idf_tools_path +# --------------------------------------------------------------------------- + + +def test_check_stamp_matches(tmp_path: Path) -> None: + f = tmp_path / "s.json" + f.write_text(json.dumps({"a": "1"}), encoding="utf-8") + assert _check_stamp(f, {"a": "1"}) is True + + +def test_check_stamp_mismatch(tmp_path: Path) -> None: + f = tmp_path / "s.json" + f.write_text(json.dumps({"a": "1"}), encoding="utf-8") + assert _check_stamp(f, {"a": "2"}) is False + + +def test_check_stamp_missing_file(tmp_path: Path) -> None: + assert _check_stamp(tmp_path / "nope.json", {"a": "1"}) is False + + +def test_check_stamp_corrupt_file(tmp_path: Path) -> None: + f = tmp_path / "s.json" + f.write_text("{ not json", encoding="utf-8") + assert _check_stamp(f, {"a": "1"}) is False + + +def test_write_idf_version_txt_writes_when_missing(tmp_path: Path) -> None: + _write_idf_version_txt(tmp_path, "5.1.2") + assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "v5.1.2\n" + + +def test_write_idf_version_txt_skips_when_present(tmp_path: Path) -> None: + (tmp_path / "version.txt").write_text("existing\n", encoding="utf-8") + _write_idf_version_txt(tmp_path, "5.1.2") + assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "existing\n" + + +def test_get_idf_tools_path_env_override(tmp_path: Path) -> None: + override = str(tmp_path / "custom-idf") + with patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": override}): + assert _get_idf_tools_path() == Path(override) + + +def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: + with patch("pathlib.Path.write_text", side_effect=OSError("denied")): + # write failure is caught and warned, not raised + _write_idf_version_txt(tmp_path, "5.1.2") diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py new file mode 100644 index 0000000000..a8533608c0 --- /dev/null +++ b/tests/unit_tests/test_framework_helpers.py @@ -0,0 +1,954 @@ +"""Tests for esphome.framework_helpers.""" + +# pylint: disable=protected-access + +import importlib.util +import io +import logging +import os +from pathlib import Path +import subprocess +import sys +import tarfile +from unittest.mock import MagicMock, Mock, patch +import zipfile + +import pytest +import requests as req + +from esphome.framework_helpers import ( + _7z_extract_all, + _detect_archive_root, + _rename_with_retry, + _tar_extract_all, + _zip_extract_all, + archive_extract_all, + create_venv, + download_from_mirrors, + get_python_env_executable_path, + get_system_python_path, + rmdir, + run_command, + run_command_ok, + str_to_lst_of_str, +) + +_HAS_PY7ZR = importlib.util.find_spec("py7zr") is not None + +# --------------------------------------------------------------------------- +# str_to_lst_of_str +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("a;b;c", ["a", "b", "c"]), + (" a ; b ", ["a", "b"]), + (";; a ;;", ["a"]), + ("single", ["single"]), + ("", []), + (["already", "a", "list"], ["already", "a", "list"]), + ], +) +def test_str_to_lst_of_str(value: str | list, expected: list) -> None: + assert str_to_lst_of_str(value) == expected + + +# --------------------------------------------------------------------------- +# rmdir +# --------------------------------------------------------------------------- + + +def test_rmdir_nonexistent_is_noop(tmp_path: Path) -> None: + rmdir(tmp_path / "missing") + + +def test_rmdir_removes_existing_directory(tmp_path: Path) -> None: + d = tmp_path / "to_remove" + d.mkdir() + (d / "file.txt").write_text("x") + rmdir(d) + assert not d.exists() + + +def test_rmdir_logs_debug_with_msg( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + d = tmp_path / "logged" + d.mkdir() + with caplog.at_level(logging.DEBUG, logger="esphome.framework_helpers"): + rmdir(d, msg="cleanup message") + assert "cleanup message" in caplog.text + + +def test_rmdir_raises_runtime_error_on_os_error(tmp_path: Path) -> None: + d = tmp_path / "stubborn" + d.mkdir() + with ( + patch("esphome.framework_helpers.rmtree", side_effect=OSError("perm denied")), + pytest.raises(RuntimeError, match="can't remove"), + ): + rmdir(d, msg="cleanup step") + + +# --------------------------------------------------------------------------- +# get_system_python_path +# --------------------------------------------------------------------------- + + +def test_get_system_python_path_returns_env_var() -> None: + with patch.dict(os.environ, {"PYTHONEXEPATH": "/custom/python"}): + assert get_system_python_path() == "/custom/python" + + +def test_get_system_python_path_falls_back_to_sys_executable() -> None: + env = {k: v for k, v in os.environ.items() if k != "PYTHONEXEPATH"} + with patch.dict(os.environ, env, clear=True): + assert get_system_python_path() == os.path.normpath(sys.executable) + + +# --------------------------------------------------------------------------- +# get_python_env_executable_path +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(os.name != "posix", reason="PosixPath construction requires POSIX") +def test_get_python_env_executable_path_posix() -> None: + assert get_python_env_executable_path("/env", "python") == Path("/env/bin/python") + + +@pytest.mark.skipif(os.name != "nt", reason="WindowsPath construction requires Windows") +def test_get_python_env_executable_path_windows() -> None: + assert get_python_env_executable_path("/env", "python") == Path( + "/env/Scripts/python.exe" + ) + + +# --------------------------------------------------------------------------- +# run_command +# --------------------------------------------------------------------------- + + +def test_run_command_success_returns_stdout(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="out\n", stderr="") + ok, stdout, _stderr = run_command(["echo", "hello"]) + assert ok is True + assert stdout == "out\n" + + +def test_run_command_failure_returns_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=1, stdout="", stderr="boom") + ok, _stdout, stderr = run_command(["bad"]) + assert ok is False + assert stderr == "boom" + + +def test_run_command_stream_output_success(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0) + ok, stdout, stderr = run_command(["cmd"], stream_output=True) + assert ok is True + assert stdout is None + assert stderr is None + + +def test_run_command_stream_output_failure(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=2) + ok, stdout, _stderr = run_command(["cmd"], stream_output=True) + assert ok is False + assert stdout is None + + +def test_run_command_subprocess_error_returns_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.side_effect = subprocess.SubprocessError("exploded") + ok, stdout, stderr = run_command(["cmd"]) + assert ok is False + assert stdout is None + assert stderr is None + + +def test_run_command_os_error_returns_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.side_effect = OSError("not found") + ok, _stdout, _stderr = run_command(["cmd"]) + assert ok is False + + +def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + run_command(["cmd"], env={"MY_VAR": "42"}) + assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" + + +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)) + assert mock_subprocess_run.call_args[1]["cwd"] == str(tmp_path) + + +# --------------------------------------------------------------------------- +# run_command_ok +# --------------------------------------------------------------------------- + + +def test_run_command_ok_true(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + assert run_command_ok(["cmd"]) is True + + +def test_run_command_ok_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=1, stdout="", stderr="") + assert run_command_ok(["cmd"]) is False + + +# --------------------------------------------------------------------------- +# create_venv +# --------------------------------------------------------------------------- + + +def test_create_venv_calls_run_command_ok(tmp_path: Path) -> None: + with patch( + "esphome.framework_helpers.run_command_ok", return_value=True + ) as mock_cmd: + create_venv(tmp_path / "env", msg="test") + mock_cmd.assert_called_once() + + +def test_create_venv_raises_on_failure(tmp_path: Path) -> None: + with ( + patch("esphome.framework_helpers.run_command_ok", return_value=False), + pytest.raises(RuntimeError, match="Can't create Python virtual environment"), + ): + create_venv(tmp_path / "env", msg="test") + + +# --------------------------------------------------------------------------- +# _detect_archive_root +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("names", "expected"), + [ + (["wrapper/", "wrapper/a.txt", "wrapper/sub/b.txt"], "wrapper"), + (["root1/a.txt", "root2/b.txt"], None), + (["wrapper"], None), # no descendant → None + (["", "wrapper/file.txt"], "wrapper"), # empty names skipped + (["wrapper\\file.txt"], "wrapper"), # backslash normalised + (["w/a", "w/b", "w/c"], "w"), + ], +) +def test_detect_archive_root(names: list[str], expected: str | None) -> None: + assert _detect_archive_root(names) == expected + + +# --------------------------------------------------------------------------- +# Tar archive helpers +# --------------------------------------------------------------------------- + + +def _make_tar( + members: list[tarfile.TarInfo], + file_contents: dict[str, bytes] | None = None, +) -> io.BytesIO: + buf = io.BytesIO() + contents = file_contents or {} + with tarfile.open(fileobj=buf, mode="w") as tf: + for info in members: + if info.isreg() and info.name in contents: + data = contents[info.name] + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + else: + tf.addfile(info) + buf.seek(0) + return buf + + +def _reg(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.REGTYPE + info.size = 0 + info.mode = 0o644 + return info + + +def _dir(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.DIRTYPE + info.mode = 0o755 + return info + + +def _sym(name: str, target: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.SYMTYPE + info.linkname = target + info.mode = 0o777 + return info + + +def _special(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.CHRTYPE + info.mode = 0o600 + return info + + +def _hlnk(name: str, target: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.LNKTYPE + info.linkname = target + info.mode = 0o644 + return info + + +# --------------------------------------------------------------------------- +# _tar_extract_all — branches not covered by the hard-link prefix-strip tests +# --------------------------------------------------------------------------- + + +class TestTarExtractAllSecurity: + def test_flat_archive_no_wrapper(self, tmp_path: Path) -> None: + """Without a single common root files land directly in extract_dir.""" + buf = _make_tar( + [_reg("a.txt"), _reg("b.txt")], + {"a.txt": b"aaa", "b.txt": b"bbb"}, + ) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "a.txt").read_bytes() == b"aaa" + assert (tmp_path / "b.txt").read_bytes() == b"bbb" + + def test_directory_member_extracted(self, tmp_path: Path) -> None: + buf = _make_tar([_dir("subdir/")]) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "subdir").is_dir() + + def test_symlink_within_dest_extracted(self, tmp_path: Path) -> None: + buf = _make_tar( + [_reg("target.txt"), _sym("link.txt", "target.txt")], + {"target.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "link.txt").exists() + + def test_path_traversal_skipped(self, tmp_path: Path) -> None: + """Member resolving outside extract_dir via .. is silently skipped.""" + info = tarfile.TarInfo(name="sub/../../escape.txt") + info.type = tarfile.REGTYPE + info.size = 5 + info.mode = 0o644 + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + tf.addfile(info, io.BytesIO(b"OOPS!")) + buf.seek(0) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path.parent / "escape.txt").exists() + assert not list(tmp_path.rglob("escape.txt")) + + def test_absolute_symlink_target_skipped(self, tmp_path: Path) -> None: + """Symlink pointing to an absolute path is silently skipped.""" + buf = _make_tar( + [_reg("real.txt"), _sym("danger.lnk", "/etc/passwd")], + {"real.txt": b"ok"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "danger.lnk").exists() + + def test_symlink_escaping_dest_skipped(self, tmp_path: Path) -> None: + """Symlink whose resolved path exits extract_dir is silently skipped.""" + buf = _make_tar([_sym("up.lnk", "../outside.txt")]) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "up.lnk").exists() + + def test_special_file_skipped(self, tmp_path: Path) -> None: + """Character-device and other special-file members are silently skipped.""" + buf = _make_tar([_special("chardev")]) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "chardev").exists() + + @pytest.mark.skipif( + os.name == "nt", reason="Windows has no POSIX executable permission bit" + ) + def test_executable_bit_preserved(self, tmp_path: Path) -> None: + """User-executable bit is kept for explicitly executable files.""" + info = _reg("script.sh") + info.mode = 0o755 + buf = _make_tar([info], {"script.sh": b"#!/bin/sh"}) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "script.sh").stat().st_mode & 0o100 # S_IXUSR + + def test_non_executable_exec_bits_stripped(self, tmp_path: Path) -> None: + """Exec bits are removed when S_IXUSR is not set.""" + info = _reg("data.bin") + info.mode = 0o654 # group/other exec present, user exec absent + buf = _make_tar([info], {"data.bin": b"\x00"}) + _tar_extract_all(buf, tmp_path) + mode = (tmp_path / "data.bin").stat().st_mode + assert not (mode & 0o111) # all exec bits cleared + + +# --------------------------------------------------------------------------- +# ZIP archive helper +# --------------------------------------------------------------------------- + + +def _make_zip(entries: list[tuple[str, str | bytes]]) -> io.BytesIO: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, content in entries: + zf.writestr(name, content) + buf.seek(0) + return buf + + +# --------------------------------------------------------------------------- +# _zip_extract_all +# --------------------------------------------------------------------------- + + +class TestZipExtractAll: + def test_basic_extraction_strips_wrapper(self, tmp_path: Path) -> None: + buf = _make_zip([("wrapper/file.txt", "hello")]) + _zip_extract_all(buf, tmp_path) + assert (tmp_path / "file.txt").read_text() == "hello" + + def test_flat_archive_no_wrapper(self, tmp_path: Path) -> None: + buf = _make_zip([("a.txt", "aaa"), ("b.txt", "bbb")]) + _zip_extract_all(buf, tmp_path) + assert (tmp_path / "a.txt").read_text() == "aaa" + assert (tmp_path / "b.txt").read_text() == "bbb" + + def test_wrapper_root_entry_skipped(self, tmp_path: Path) -> None: + """The wrapper directory entry itself (step 3a) does not appear in dest.""" + buf = _make_zip([("wrapper/", ""), ("wrapper/file.txt", "content")]) + _zip_extract_all(buf, tmp_path) + assert (tmp_path / "file.txt").read_text() == "content" + assert not (tmp_path / "wrapper").exists() + + def test_path_traversal_raises(self, tmp_path: Path) -> None: + # Two members with different roots so _detect_archive_root returns None + # and strip_prefix is not applied, leaving "../escape.txt" to hit the + # commonpath safety check directly. + buf = _make_zip([("safe.txt", "ok"), ("../escape.txt", "bad")]) + with pytest.raises(ValueError, match="Unsafe path"): + _zip_extract_all(buf, tmp_path) + + def test_multiple_files_extracted(self, tmp_path: Path) -> None: + entries = [(f"root/{c}.txt", c * 3) for c in "abc"] + buf = _make_zip(entries) + _zip_extract_all(buf, tmp_path) + for c in "abc": + assert (tmp_path / f"{c}.txt").read_text() == c * 3 + + +# --------------------------------------------------------------------------- +# archive_extract_all dispatch +# --------------------------------------------------------------------------- + + +def _gzip_tar_bytes(entries: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + for name, content in entries.items(): + info = tarfile.TarInfo(name=name) + info.size = len(content) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(content)) + return buf.getvalue() + + +class TestArchiveExtractAll: + def test_path_input_gzip_tar(self, tmp_path: Path) -> None: + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"hello"})) + dest = tmp_path / "out" + dest.mkdir() + archive_extract_all(archive, dest) + assert (dest / "file.txt").read_bytes() == b"hello" + + def test_buffered_reader_input(self, tmp_path: Path) -> None: + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"data"})) + dest = tmp_path / "out" + dest.mkdir() + with archive.open("rb") as f: # io.BufferedReader + archive_extract_all(f, dest) + assert (dest / "file.txt").read_bytes() == b"data" + + def test_rawio_input(self, tmp_path: Path) -> None: + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"raw"})) + dest = tmp_path / "out" + dest.mkdir() + archive_extract_all(io.FileIO(archive), dest) + assert (dest / "file.txt").read_bytes() == b"raw" + + def test_zip_dispatched(self, tmp_path: Path) -> None: + archive = tmp_path / "test.zip" + archive.write_bytes(_make_zip([("file.txt", "hi")]).getvalue()) + dest = tmp_path / "out" + dest.mkdir() + archive_extract_all(archive, dest) + assert (dest / "file.txt").read_text() == "hi" + + def test_invalid_type_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="archive must be"): + archive_extract_all(42, ".") # type: ignore[arg-type] + + def test_unsupported_format_raises_value_error(self, tmp_path: Path) -> None: + bad = tmp_path / "bad.bin" + bad.write_bytes(b"\x00\x01\x02\x03\x04\x05\x06") + with pytest.raises(ValueError, match="Unsupported archive format"): + archive_extract_all(bad, tmp_path) + + +# --------------------------------------------------------------------------- +# download_from_mirrors +# --------------------------------------------------------------------------- + + +def _mock_response(content: bytes, ok: bool = True) -> MagicMock: + r = MagicMock() + r.__enter__.return_value = r + r.__exit__.return_value = False + if ok: + r.raise_for_status.return_value = None + else: + r.raise_for_status.side_effect = req.HTTPError("503") + r.headers = {"content-length": "0"} # suppress ProgressBar + r.iter_content.return_value = [content] if content else [] + return r + + +class TestDownloadFromMirrors: + def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None: + target = tmp_path / "out.bin" + with patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"filedata"), + ): + url = download_from_mirrors(["https://example.com/f"], {}, target) + assert url == "https://example.com/f" + assert target.read_bytes() == b"filedata" + + def test_substitutions_applied_to_url(self, tmp_path: Path) -> None: + with patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"x"), + ) as mock_get: + download_from_mirrors( + ["https://example.com/{VERSION}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert mock_get.call_args[0][0] == "https://example.com/1.2.3.bin" + + def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: + with patch( + "esphome.framework_helpers.requests.get", + side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")], + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], + {}, + tmp_path / "out.bin", + ) + assert url == "https://mirror2.com/f" + assert (tmp_path / "out.bin").read_bytes() == b"second" + + def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None: + with ( + patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"", ok=False), + ), + pytest.raises(req.HTTPError), + ): + download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") + + def test_empty_mirrors_raises_value_error(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="empty mirrors list"): + download_from_mirrors([], {}, tmp_path / "out.bin") + + def test_invalid_target_type_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="target must be"): + download_from_mirrors(["https://example.com/f"], {}, 42) # type: ignore[arg-type] + + def test_file_like_target_written(self) -> None: + buf = io.BytesIO() + with patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"bytes"), + ): + download_from_mirrors(["https://example.com/f"], {}, buf) + buf.seek(0) + assert buf.read() == b"bytes" + + def test_progress_bar_shown_when_content_length_known(self, tmp_path: Path) -> None: + r = _mock_response(b"1234567890") + r.headers = {"content-length": "10"} + with ( + patch("esphome.framework_helpers.requests.get", return_value=r), + patch("esphome.framework_helpers.ProgressBar") as mock_pb, + ): + download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") + mock_pb.assert_called_once_with("Downloading") + mock_pb.return_value.update.assert_called() + + def test_empty_chunk_not_written(self, tmp_path: Path) -> None: + """Empty chunks yielded by iter_content are skipped without writing.""" + r = MagicMock() + r.__enter__.return_value = r + r.__exit__.return_value = False + r.raise_for_status.return_value = None + r.headers = {"content-length": "0"} + r.iter_content.return_value = [b""] # one empty chunk + target = tmp_path / "out.bin" + with patch("esphome.framework_helpers.requests.get", return_value=r): + download_from_mirrors(["https://example.com/f"], {}, target) + assert target.exists() + assert target.read_bytes() == b"" + + +# --------------------------------------------------------------------------- +# get_python_env_executable_path — Windows branch +# --------------------------------------------------------------------------- + + +def test_get_python_env_executable_path_nt() -> None: + """Windows path uses Scripts/ and .exe suffix.""" + from pathlib import PurePosixPath + + with ( + patch.object(os, "name", "nt"), + patch("esphome.framework_helpers.Path", PurePosixPath), + ): + result = get_python_env_executable_path("/env", "python") + assert str(result) == "/env/Scripts/python.exe" + + +# --------------------------------------------------------------------------- +# _tar_extract_all — additional branch coverage +# --------------------------------------------------------------------------- + + +class TestTarExtractAllBranches: + @pytest.mark.skipif( + sys.version_info < (3, 12), + reason="patching os.name makes pathlib build a WindowsPath, which only " + "instantiates on POSIX in 3.12+", + ) + def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: + """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" + info = tarfile.TarInfo(name="C:/secret.txt") + info.type = tarfile.REGTYPE + info.size = 0 + info.mode = 0o644 + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + tf.addfile(info) + buf.seek(0) + with patch.object(os, "name", "nt"): + _tar_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_strip_root_exact_match_skipped(self, tmp_path: Path) -> None: + """Member whose name equals strip_root exactly (no trailing slash) is skipped.""" + # "wrapper" (file entry) + "wrapper/file.txt" causes _detect_archive_root + # to return "wrapper"; the bare "wrapper" entry matches strip_root exactly. + buf = _make_tar( + [_reg("wrapper"), _reg("wrapper/file.txt")], + {"wrapper/file.txt": b"content"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "wrapper").exists() + assert (tmp_path / "file.txt").read_bytes() == b"content" + + def test_member_not_under_strip_prefix_skipped(self, tmp_path: Path) -> None: + """Member whose name doesn't start with strip_prefix is silently skipped.""" + buf = _make_tar([_reg("other/file.txt")], {"other/file.txt": b"data"}) + with patch("esphome.framework_helpers._detect_archive_root", return_value="w"): + _tar_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_hardlink_prefix_stripped(self, tmp_path: Path) -> None: + """Hard-link linkname has wrapper prefix stripped along with its entry name.""" + buf = _make_tar( + [_reg("wrapper/file.txt"), _hlnk("wrapper/link.txt", "wrapper/file.txt")], + {"wrapper/file.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "file.txt").read_bytes() == b"data" + assert (tmp_path / "link.txt").exists() + + def test_hardlink_linkname_equals_strip_root_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname equals strip_root is silently skipped.""" + buf = _make_tar( + [_reg("wrapper/file.txt"), _hlnk("wrapper/link.txt", "wrapper")], + {"wrapper/file.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "link.txt").exists() + + def test_hardlink_linkname_outside_prefix_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname doesn't start with strip_prefix is skipped.""" + buf = _make_tar( + [_reg("wrapper/file.txt"), _hlnk("wrapper/link.txt", "other/file.txt")], + {"wrapper/file.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "link.txt").exists() + + def test_member_mode_none_skips_sanitization(self, tmp_path: Path) -> None: + """Member with mode=None bypasses the sanitization block without error.""" + info = _reg("file.txt") + buf = _make_tar([info], {"file.txt": b"data"}) + buf.seek(0) + with tarfile.open(fileobj=buf) as tf: + members = tf.getmembers() + for m in members: + m.mode = None + buf.seek(0) + with ( + patch("tarfile.TarFile.getmembers", return_value=members), + patch("tarfile.TarFile.extract"), + ): + _tar_extract_all(buf, tmp_path) + + def test_progress_bar_shown(self, tmp_path: Path) -> None: + """A non-empty progress_header causes ProgressBar to be created and updated.""" + buf = _make_tar([_reg("file.txt")], {"file.txt": b"x"}) + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _tar_extract_all(buf, tmp_path, progress_header="Extracting") + mock_pb.assert_called_once_with("Extracting") + mock_pb.return_value.update.assert_called() + + +# --------------------------------------------------------------------------- +# _zip_extract_all — additional branch coverage +# --------------------------------------------------------------------------- + + +class TestZipExtractAllBranches: + @pytest.mark.skipif( + sys.version_info < (3, 12), + reason="patching os.name makes pathlib build a WindowsPath, which only " + "instantiates on POSIX in 3.12+", + ) + def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: + """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" + buf = _make_zip([("C:/secret.txt", "bad")]) + with patch.object(os, "name", "nt"): + _zip_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_member_not_under_strip_prefix_skipped(self, tmp_path: Path) -> None: + """Member whose name doesn't start with strip_prefix is silently skipped.""" + buf = _make_zip([("other/file.txt", "data")]) + with patch("esphome.framework_helpers._detect_archive_root", return_value="w"): + _zip_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_progress_bar_shown(self, tmp_path: Path) -> None: + """A non-empty progress_header causes ProgressBar to be created and updated.""" + buf = _make_zip([("file.txt", "hello")]) + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _zip_extract_all(buf, tmp_path, progress_header="Unzipping") + mock_pb.assert_called_once_with("Unzipping") + mock_pb.return_value.update.assert_called() + + +# --------------------------------------------------------------------------- +# _rename_with_retry +# --------------------------------------------------------------------------- + + +class TestRenameWithRetry: + def test_success_on_first_attempt(self, tmp_path: Path) -> None: + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + _rename_with_retry(src, dst) + assert dst.read_text() == "data" + assert not src.exists() + + def test_retries_on_permission_error_then_succeeds(self, tmp_path: Path) -> None: + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + call_count = 0 + original_rename = Path.rename + + def flaky_rename(self, target): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise PermissionError("locked") + return original_rename(self, target) + + with ( + patch.object(Path, "rename", flaky_rename), + patch("esphome.framework_helpers.time.sleep"), + ): + _rename_with_retry(src, dst, attempts=3) + assert dst.read_text() == "data" + + def test_raises_after_all_attempts_fail(self, tmp_path: Path) -> None: + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + with ( + patch.object(Path, "rename", side_effect=PermissionError("locked")), + patch("esphome.framework_helpers.time.sleep"), + pytest.raises(PermissionError), + ): + _rename_with_retry(src, dst, attempts=3) + + def test_attempts_zero_is_noop(self, tmp_path: Path) -> None: + """Zero attempts means the for-loop body never runs; src is untouched.""" + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + _rename_with_retry(src, dst, attempts=0) + assert src.exists() + assert not dst.exists() + + +# --------------------------------------------------------------------------- +# _7z_extract_all +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _HAS_PY7ZR, reason="py7zr not installed") +class TestSevenZipExtractAll: + @staticmethod + def _make_7z(entries: dict[str, bytes]) -> io.BytesIO: + import py7zr + + buf = io.BytesIO() + with py7zr.SevenZipFile(buf, "w") as sz: + for name, content in entries.items(): + sz.writef(io.BytesIO(content), name) + buf.seek(0) + return buf + + def test_basic_extraction_no_wrapper(self, tmp_path: Path) -> None: + buf = self._make_7z({"a.txt": b"aaa", "b.txt": b"bbb"}) + out = tmp_path / "out" + out.mkdir() + _7z_extract_all(buf, out) + assert (out / "a.txt").exists() + assert (out / "b.txt").exists() + + def test_strips_wrapper_directory(self, tmp_path: Path) -> None: + buf = self._make_7z({"wrapper/file.txt": b"data"}) + out = tmp_path / "out" + out.mkdir() + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + assert not (out / "wrapper").exists() + + def test_staging_suffix_collision(self, tmp_path: Path) -> None: + """When .extract_tmp_0 already exists, suffix is incremented to find a free slot.""" + out = tmp_path / "out" + out.mkdir() + (out / ".extract_tmp_0").mkdir() + buf = self._make_7z({"file.txt": b"hi"}) + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + # .extract_tmp_1 should be cleaned up after extraction + assert not (out / ".extract_tmp_1").exists() + + def test_overwrites_existing_directory(self, tmp_path: Path) -> None: + """Pre-existing destination directory is replaced.""" + out = tmp_path / "out" + out.mkdir() + existing_dir = out / "file.txt" + existing_dir.mkdir() + buf = self._make_7z({"file.txt": b"new"}) + _7z_extract_all(buf, out) + assert (out / "file.txt").is_file() + + def test_overwrites_existing_file(self, tmp_path: Path) -> None: + """Pre-existing destination file is replaced.""" + out = tmp_path / "out" + out.mkdir() + (out / "file.txt").write_bytes(b"old") + buf = self._make_7z({"file.txt": b"new"}) + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + + def test_empty_name_skipped(self, tmp_path: Path) -> None: + """Archive entries with empty names are silently skipped.""" + import py7zr + + buf = self._make_7z({"file.txt": b"data"}) + out = tmp_path / "out" + out.mkdir() + with patch.object( + py7zr.SevenZipFile, "getnames", return_value=["", "file.txt"] + ): + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + + def test_path_traversal_skipped(self, tmp_path: Path) -> None: + """Entries whose resolved path exits extract_dir are skipped.""" + import py7zr + + buf = self._make_7z({"file.txt": b"safe"}) + out = tmp_path / "out" + out.mkdir() + with patch.object( + py7zr.SevenZipFile, "getnames", return_value=["../escape.txt", "file.txt"] + ): + _7z_extract_all(buf, out) + assert not (tmp_path / "escape.txt").exists() + assert (out / "file.txt").exists() + + def test_progress_bar_shown(self, tmp_path: Path) -> None: + buf = self._make_7z({"file.txt": b"x"}) + out = tmp_path / "out" + out.mkdir() + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _7z_extract_all(buf, out, progress_header="Unpacking 7z") + mock_pb.assert_called_once_with("Unpacking 7z") + mock_pb.return_value.update.assert_called() + + def test_absolute_path_in_names_skipped(self, tmp_path: Path) -> None: + """Names that resolve as absolute are silently skipped.""" + import py7zr + + buf = self._make_7z({"file.txt": b"safe"}) + out = tmp_path / "out" + out.mkdir() + + original_is_absolute = Path.is_absolute + + def patched_is_absolute(self: Path) -> bool: + if str(self).startswith("C:"): + return True + return original_is_absolute(self) + + with ( + patch.object( + py7zr.SevenZipFile, "getnames", return_value=["C:/evil.txt", "file.txt"] + ), + patch.object(Path, "is_absolute", patched_is_absolute), + ): + _7z_extract_all(buf, out) + # Avoid `out / "C:"` here: pathlib treats "C:" as a drive (always + # "exists" on Windows). Assert on the actual extracted files instead. + extracted = sorted(p.name for p in out.rglob("*") if p.is_file()) + assert extracted == ["file.txt"] + + def test_dispatched_via_archive_extract_all(self, tmp_path: Path) -> None: + """archive_extract_all dispatches 7z archives to _7z_extract_all.""" + buf = self._make_7z({"hello.txt": b"world"}) + data = buf.read() + assert data[:6] == b"\x37\x7a\xbc\xaf\x27\x1c" + archive = tmp_path / "test.7z" + archive.write_bytes(data) + out = tmp_path / "out" + out.mkdir() + archive_extract_all(archive, out) + assert (out / "hello.txt").exists() diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py new file mode 100644 index 0000000000..9652ad08eb --- /dev/null +++ b/tests/unit_tests/test_nrf52_framework.py @@ -0,0 +1,219 @@ +"""Tests for esphome.components.nrf52.framework helpers.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.components.nrf52.framework import ( + _TOOLCHAIN_VERSION, + _get_toolchain_platform_info, + check_and_install, +) +from esphome.config_validation import Version +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError + + +@pytest.mark.parametrize( + ("system", "machine", "expected"), + [ + # default — no branch hit + ("Linux", "x86_64", ("linux", "x86_64", "tar.xz")), + # arm64 → aarch64 rename + ("Linux", "arm64", ("linux", "aarch64", "tar.xz")), + # darwin → macos rename only + ("Darwin", "x86_64", ("macos", "x86_64", "tar.xz")), + # both renames apply + ("Darwin", "arm64", ("macos", "aarch64", "tar.xz")), + # windows forces x86_64 + 7z; arm64 rename is overwritten + ("Windows", "arm64", ("windows", "x86_64", "7z")), + ], +) +def test_get_toolchain_platform_info( + system: str, machine: str, expected: tuple[str, str, str] +) -> None: + with ( + patch("platform.system", return_value=system), + patch("platform.machine", return_value=machine), + ): + assert _get_toolchain_platform_info() == expected + + +# --------------------------------------------------------------------------- +# Helpers and fixtures for check_and_install tests +# --------------------------------------------------------------------------- + +_TEST_SDK_VERSION = "2.9.0" + + +@pytest.fixture +def nrf52_dirs(setup_core: Path) -> SimpleNamespace: + """Populate CORE and pre-create SDK directories so sentinel.touch() succeeds.""" + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(_TEST_SDK_VERSION)} + tools = CORE.data_dir / "sdk-nrf" + python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}" + framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}" + toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION + for d in (python_env, framework, toolchain_dir): + d.mkdir(parents=True, exist_ok=True) + return SimpleNamespace( + python_env=python_env, + framework=framework, + toolchain=toolchain_dir, + ) + + +@pytest.fixture +def mock_nrf52_ops(): + """Patch all heavy I/O operations used by check_and_install.""" + with ( + patch("esphome.components.nrf52.framework.rmdir") as mock_rmdir, + patch("esphome.components.nrf52.framework.create_venv") as mock_create_venv, + patch( + "esphome.components.nrf52.framework.run_command_ok", return_value=True + ) as mock_run_cmd, + patch( + "esphome.components.nrf52.framework.download_from_mirrors", + return_value="https://example.com/tc.tar.xz", + ) as mock_download, + patch("esphome.components.nrf52.framework.archive_extract_all") as mock_extract, + ): + yield SimpleNamespace( + rmdir=mock_rmdir, + create_venv=mock_create_venv, + run_command_ok=mock_run_cmd, + download_from_mirrors=mock_download, + archive_extract_all=mock_extract, + ) + + +# --------------------------------------------------------------------------- +# check_and_install tests +# --------------------------------------------------------------------------- + + +class TestCheckAndInstall: + def test_all_installed_skips_all_steps( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """All three sentinels present → nothing downloaded or compiled.""" + (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.framework / ".ready").touch() + (nrf52_dirs.toolchain / ".ready").touch() + + check_and_install() + + mock_nrf52_ops.create_venv.assert_not_called() + mock_nrf52_ops.run_command_ok.assert_not_called() + mock_nrf52_ops.download_from_mirrors.assert_not_called() + mock_nrf52_ops.archive_extract_all.assert_not_called() + + def test_fresh_install_runs_all_steps( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """No sentinels → venv created, west installed, SDK init+update, toolchain downloaded.""" + check_and_install() + + mock_nrf52_ops.create_venv.assert_called_once() + # pip install west, west init, west update + assert mock_nrf52_ops.run_command_ok.call_count == 3 + mock_nrf52_ops.download_from_mirrors.assert_called_once() + mock_nrf52_ops.archive_extract_all.assert_called_once() + assert (nrf52_dirs.python_env / ".ready").exists() + assert (nrf52_dirs.framework / ".ready").exists() + assert (nrf52_dirs.toolchain / ".ready").exists() + + def test_venv_exists_installs_framework_and_toolchain( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Venv ready but framework missing → skip venv creation, run SDK init+update.""" + (nrf52_dirs.python_env / ".ready").touch() + + check_and_install() + + mock_nrf52_ops.create_venv.assert_not_called() + # west init + west update only (no pip install) + assert mock_nrf52_ops.run_command_ok.call_count == 2 + mock_nrf52_ops.download_from_mirrors.assert_called_once() + + def test_toolchain_only_missing( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Venv and framework ready → only toolchain downloaded and extracted.""" + (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.framework / ".ready").touch() + + check_and_install() + + mock_nrf52_ops.create_venv.assert_not_called() + mock_nrf52_ops.run_command_ok.assert_not_called() + mock_nrf52_ops.download_from_mirrors.assert_called_once() + mock_nrf52_ops.archive_extract_all.assert_called_once() + + def test_west_install_failure_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing pip install west raises EsphomeError.""" + mock_nrf52_ops.run_command_ok.return_value = False + + with pytest.raises(EsphomeError, match="Install west"): + check_and_install() + + def test_framework_init_failure_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing west init raises EsphomeError.""" + (nrf52_dirs.python_env / ".ready").touch() + mock_nrf52_ops.run_command_ok.return_value = False + + with pytest.raises(EsphomeError, match="Can't initialize"): + check_and_install() + + def test_framework_update_failure_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing west update raises EsphomeError.""" + (nrf52_dirs.python_env / ".ready").touch() + # init succeeds, update fails + mock_nrf52_ops.run_command_ok.side_effect = [True, False] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + def test_toolchain_download_passes_platform_substitutions( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """download_from_mirrors receives VERSION + platform triple from _get_toolchain_platform_info.""" + (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.framework / ".ready").touch() + + with patch( + "esphome.components.nrf52.framework._get_toolchain_platform_info", + return_value=("linux", "x86_64", "tar.xz"), + ): + check_and_install() + + args, _ = mock_nrf52_ops.download_from_mirrors.call_args + substitutions = args[1] + assert substitutions["VERSION"] == _TOOLCHAIN_VERSION + assert substitutions["sysname"] == "linux" + assert substitutions["machine"] == "x86_64" + assert substitutions["extension"] == "tar.xz" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index c1d16530cb..a37b19f584 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -442,6 +442,21 @@ def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> Non mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4") +def test_run_compile_without_process_limit( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """When no compile_process_limit is set, run_compile passes no -j flag.""" + from esphome.const import CONF_ESPHOME + + CORE.build_path = str(setup_core / "build" / "test") + config = {CONF_ESPHOME: {}} + mock_run_platformio_cli_run.return_value = 0 + + toolchain.run_compile(config, verbose=False) + + mock_run_platformio_cli_run.assert_called_once_with(config, False) + + def test_get_idedata_caches_result( setup_core: Path, mock_run_platformio_cli_run: Mock ) -> None: From 8206df6e4e21ffb855be60865a832c142f473f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Lohynsk=C3=BD?= <85194189+Tomer27cz@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:57:13 +0200 Subject: [PATCH 1588/2214] [dlms_meter] dlms_parser library (#15458) Co-authored-by: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- CODEOWNERS | 2 +- esphome/components/dlms_meter/__init__.py | 253 ++++++- .../dlms_meter/binary_sensor/__init__.py | 20 + esphome/components/dlms_meter/dlms.h | 71 -- esphome/components/dlms_meter/dlms_meter.cpp | 674 +++++------------- esphome/components/dlms_meter/dlms_meter.h | 181 +++-- esphome/components/dlms_meter/mbus.h | 69 -- esphome/components/dlms_meter/obis.h | 94 --- .../components/dlms_meter/sensor/__init__.py | 228 +++--- .../dlms_meter/text_sensor/__init__.py | 68 +- esphome/idf_component.yml | 2 + platformio.ini | 4 + .../components/dlms_meter/common-generic.yaml | 11 - .../components/dlms_meter/common-netznoe.yaml | 17 - tests/components/dlms_meter/common.yaml | 40 ++ .../components/dlms_meter/test.esp32-ard.yaml | 4 +- .../components/dlms_meter/test.esp32-idf.yaml | 4 +- .../dlms_meter/test.esp8266-ard.yaml | 4 +- .../dlms_meter/test.rp2040-ard.yaml | 4 + 20 files changed, 796 insertions(+), 956 deletions(-) create mode 100644 esphome/components/dlms_meter/binary_sensor/__init__.py delete mode 100644 esphome/components/dlms_meter/dlms.h delete mode 100644 esphome/components/dlms_meter/mbus.h delete mode 100644 esphome/components/dlms_meter/obis.h delete mode 100644 tests/components/dlms_meter/common-generic.yaml delete mode 100644 tests/components/dlms_meter/common-netznoe.yaml create mode 100644 tests/components/dlms_meter/test.rp2040-ard.yaml diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 566cac066e..3c1c2be289 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -def25306bb0f5e09b94fe7b74ffa6995a56bb951e7a27d9ad0a21103532a74a9 +fe0fe4fde52c61eb40b1214675af8db44d2678c6b7bc2674d51ed4836ecf94da diff --git a/CODEOWNERS b/CODEOWNERS index c5beba8c0b..c69f8bccd4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -138,7 +138,7 @@ esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 -esphome/components/dlms_meter/* @SimonFischer04 +esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index c22ab7b552..7094699b0b 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,57 +1,258 @@ -import esphome.codegen as cg -from esphome.components import uart -import esphome.config_validation as cv -from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +import logging +import re -CODEOWNERS = ["@SimonFischer04"] +import esphome.codegen as cg +from esphome.components import esp32, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_NAME, + CONF_PATTERN, + CONF_PRIORITY, + CONF_RECEIVE_TIMEOUT, +) +from esphome.core import CORE + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] DEPENDENCIES = ["uart"] CONF_DLMS_METER_ID = "dlms_meter_id" CONF_DECRYPTION_KEY = "decryption_key" +CONF_AUTH_KEY = "auth_key" +CONF_OBIS_CODE = "obis_code" +CONF_CUSTOM_PATTERNS = "custom_patterns" +CONF_SKIP_CRC = "skip_crc" +CONF_DEFAULT_OBIS = "default_obis" CONF_PROVIDER = "provider" -PROVIDERS = {"generic": 0, "netznoe": 1} - dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter") DlmsMeterComponent = dlms_meter_component_ns.class_( "DlmsMeterComponent", cg.Component, uart.UARTDevice ) -def validate_key(value): - value = cv.string_strict(value) - if len(value) != 32: - raise cv.Invalid("Decryption key must be 32 hex characters (16 bytes)") - try: - return [int(value[i : i + 2], 16) for i in range(0, 32, 2)] - except ValueError as exc: - raise cv.Invalid("Decryption key must be hex values from 00 to FF") from exc +def obis_code(value): + # 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): + value = cv.string(value) + normalized = re.sub(r"[\-\:\*]", ".", value) + parts = normalized.split(".") + if len(parts) < 5 or len(parts) > 6: + raise cv.Invalid("OBIS code must have 5 or 6 parts") + try: + bytes_list = [int(p) for p in parts] + except ValueError as exc: + raise cv.Invalid("OBIS code parts must be integers") from exc + for b in bytes_list: + if b < 0 or b > 255: + raise cv.Invalid("OBIS code parts must be between 0 and 255") + if len(bytes_list) == 5: + bytes_list.append(255) + return bytes_list + + +def custom_pattern_dict(value): + if isinstance(value, str): + return {CONF_PATTERN: value} + return value + + +def validate_custom_pattern(value): + 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): + if CONF_PROVIDER in config: + provider = str(config[CONF_PROVIDER]).lower() + if provider == "netznoe": + _LOGGER.warning( + "The 'provider: netznoe' option is deprecated and will be removed in 2026.11.0. " + "The required custom patterns have been added automatically for this release, but you must update your configuration.\n" + "Please remove the 'provider' key and explicitly replace it with the following:\n\n" + "custom_patterns:\n" + ' - pattern: "L, TSTR"\n' + ' name: "MeterID"\n' + ' default_obis: "0.0.96.1.0.255"\n' + ' - pattern: "F, TDTM"\n' + ' name: "DateTime"\n' + ' default_obis: "0.0.1.0.0.255"\n' + ) + patterns = config.get(CONF_CUSTOM_PATTERNS, []) + + # Ensure "L, TSTR" for MeterID is present + if not any(p.get(CONF_PATTERN) == "L, TSTR" for p in patterns): + patterns.append( + { + CONF_PATTERN: "L, TSTR", + CONF_NAME: "MeterID", + CONF_DEFAULT_OBIS: [0, 0, 96, 1, 0, 255], + CONF_PRIORITY: 0, + } + ) + + # Ensure "F, TDTM" for DateTime is present + if not any(p.get(CONF_PATTERN) == "F, TDTM" for p in patterns): + patterns.append( + { + CONF_PATTERN: "F, TDTM", + CONF_NAME: "DateTime", + CONF_DEFAULT_OBIS: [0, 0, 1, 0, 0, 255], + CONF_PRIORITY: 0, + } + ) + + config[CONF_CUSTOM_PATTERNS] = patterns + else: + _LOGGER.warning( + "The 'provider' option is deprecated and will be removed in 2026.11.0. " + "The dlms_parser library now handles quirks dynamically. " + "Please remove this option from your configuration." + ) + return config + + +CUSTOM_PATTERN_SCHEMA = cv.All( + custom_pattern_dict, + cv.Schema( + { + cv.Required(CONF_PATTERN): cv.string, + cv.Optional(CONF_NAME): cv.string, + cv.Optional(CONF_PRIORITY, default=0): cv.int_, + cv.Optional(CONF_DEFAULT_OBIS): parse_obis_code_bytes, + } + ), + validate_custom_pattern, +) + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), - cv.Required(CONF_DECRYPTION_KEY): validate_key, - cv.Optional(CONF_PROVIDER, default="generic"): cv.enum( - PROVIDERS, lower=True + cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( + value, name="Decryption key" ), + cv.Optional(CONF_AUTH_KEY): lambda value: cv.bind_key( + value, name="Authentication key" + ), + cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), + cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, + cv.Optional(CONF_PROVIDER): cv.string, + cv.Optional( + CONF_RECEIVE_TIMEOUT, default="1000ms" + ): cv.positive_time_period_milliseconds, } ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP8266, PLATFORM_ESP32]), + validate_provider_deprecation, ) -FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "dlms_meter", baud_rate=2400, require_rx=True -) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + 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)] + dec_key_expr = cg.RawExpression( + f"std::array{{{', '.join(key_bytes)}}}" + ) + + auth_key_expr = cg.RawExpression("std::nullopt") + if auth_key := config.get(CONF_AUTH_KEY): + key_bytes = [str(int(auth_key[i : i + 2], 16)) for i in range(0, 32, 2)] + auth_key_expr = cg.RawExpression( + f"std::array{{{', '.join(key_bytes)}}}" + ) + + patterns = [] + if custom_patterns := config.get(CONF_CUSTOM_PATTERNS): + for p in custom_patterns: + name_expr = cg.RawExpression("std::nullopt") + if name_val := p.get(CONF_NAME): + name_expr = name_val + + if obis_vals := p.get(CONF_DEFAULT_OBIS): + obis_expr = cg.RawExpression( + f"std::array{{{obis_vals[0]}, {obis_vals[1]}, {obis_vals[2]}, {obis_vals[3]}, {obis_vals[4]}, {obis_vals[5]}}}" + ) + else: + obis_expr = cg.RawExpression("std::nullopt") + + patterns.append( + cg.ArrayInitializer( + p[CONF_PATTERN], + name_expr, + p.get(CONF_PRIORITY, 0), + obis_expr, + ) + ) + + patterns_expr = ( + cg.ArrayInitializer(*patterns) if patterns else cg.RawExpression("{}") + ) + + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_RECEIVE_TIMEOUT], + config[CONF_SKIP_CRC], + dec_key_expr, + auth_key_expr, + patterns_expr, + ) + + hub_id = config[CONF_ID].id + + sensor_count = 0 + for sens_conf in CORE.config.get("sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + if CONF_OBIS_CODE in sens_conf: + sensor_count += 1 + else: + from .sensor import NUMERIC_KEYS + + sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf) + + text_sensor_count = 0 + for sens_conf in CORE.config.get("text_sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + if CONF_OBIS_CODE in sens_conf: + text_sensor_count += 1 + else: + from .text_sensor import TEXT_KEYS + + text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf) + + binary_sensor_count = 0 + for sens_conf in CORE.config.get("binary_sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + binary_sensor_count += 1 + + cg.add_define("DLMS_MAX_SENSORS", sensor_count) + cg.add_define("DLMS_MAX_TEXT_SENSORS", text_sensor_count) + cg.add_define("DLMS_MAX_BINARY_SENSORS", binary_sensor_count) + await cg.register_component(var, config) await uart.register_uart_device(var, config) - key = ", ".join(str(b) for b in config[CONF_DECRYPTION_KEY]) - cg.add(var.set_decryption_key(cg.RawExpression(f"{{{key}}}"))) - cg.add(var.set_provider(PROVIDERS[config[CONF_PROVIDER]])) + + if CORE.is_esp32: + esp32.add_idf_component(name="esphome/dlms_parser", ref="1.1.0") + else: + cg.add_library("esphome/dlms_parser", "1.1.0") diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py new file mode 100644 index 0000000000..f9bc1d9df7 --- /dev/null +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -0,0 +1,20 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv + +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code + +DEPENDENCIES = ["dlms_meter"] + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Required(CONF_OBIS_CODE): obis_code, + } +) + + +async def to_code(config): + 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/dlms.h b/esphome/components/dlms_meter/dlms.h deleted file mode 100644 index a3d8f62ce6..0000000000 --- a/esphome/components/dlms_meter/dlms.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -/* -+-------------------------------+ -| Ciphering Service | -+-------------------------------+ -| System Title Length | -+-------------------------------+ -| | -| | -| | -| System | -| Title | -| | -| | -| | -+-------------------------------+ -| Length | (1 or 3 Bytes) -+-------------------------------+ -| Security Control Byte | -+-------------------------------+ -| | -| Frame | -| Counter | -| | -+-------------------------------+ -| | -~ ~ - Encrypted Payload -~ ~ -| | -+-------------------------------+ - -Ciphering Service: 0xDB (General-Glo-Ciphering) -System Title Length: 0x08 -System Title: Unique ID of meter -Length: 1 Byte=Length <= 127, 3 Bytes=Length > 127 (0x82 & 2 Bytes length) -Security Control Byte: -- Bit 3…0: Security_Suite_Id -- Bit 4: "A" subfield: indicates that authentication is applied -- Bit 5: "E" subfield: indicates that encryption is applied -- Bit 6: Key_Set subfield: 0 = Unicast, 1 = Broadcast -- Bit 7: Indicates the use of compression. - */ - -static constexpr uint8_t DLMS_HEADER_LENGTH = 16; -static constexpr uint8_t DLMS_HEADER_EXT_OFFSET = 2; // Extra offset for extended length header -static constexpr uint8_t DLMS_CIPHER_OFFSET = 0; -static constexpr uint8_t DLMS_SYST_OFFSET = 1; -static constexpr uint8_t DLMS_LENGTH_OFFSET = 10; -static constexpr uint8_t TWO_BYTE_LENGTH = 0x82; -static constexpr uint8_t DLMS_LENGTH_CORRECTION = 5; // Header bytes included in length field -static constexpr uint8_t DLMS_SECBYTE_OFFSET = 11; -static constexpr uint8_t DLMS_FRAMECOUNTER_OFFSET = 12; -static constexpr uint8_t DLMS_FRAMECOUNTER_LENGTH = 4; -static constexpr uint8_t DLMS_PAYLOAD_OFFSET = 16; -static constexpr uint8_t GLO_CIPHERING = 0xDB; -static constexpr uint8_t DATA_NOTIFICATION = 0x0F; -static constexpr uint8_t TIMESTAMP_DATETIME = 0x0C; -static constexpr uint16_t MAX_MESSAGE_LENGTH = 512; // Maximum size of message (when having 2 bytes length in header). - -// Provider specific quirks -static constexpr uint8_t NETZ_NOE_MAGIC_BYTE = 0x81; // Magic length byte used by Netz NOE -static constexpr uint8_t NETZ_NOE_EXPECTED_MESSAGE_LENGTH = 0xF8; -static constexpr uint8_t NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE = 0x20; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index b732e71d24..bdbf798df5 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,516 +1,236 @@ #include "dlms_meter.h" +#include "esphome/core/log.h" -#include - -#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) -#include -#elif defined(USE_ESP32) -#include -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) -#include -#else -#include "mbedtls/esp_config.h" -#include "mbedtls/gcm.h" -#endif -#endif +#include namespace esphome::dlms_meter { -static constexpr const char *TAG = "dlms_meter"; +static const char *const TAG = "dlms_meter"; +static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list args) { + std::array buf; + vsnprintf(buf.data(), buf.size(), fmt, args); + switch (level) { + case dlms_parser::LogLevel::ERROR: + ESP_LOGE(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::WARNING: + ESP_LOGW(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::INFO: + ESP_LOGI(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::VERBOSE: + ESP_LOGV(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::VERY_VERBOSE: + ESP_LOGVV(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::DEBUG: + ESP_LOGD(TAG, "%s", buf.data()); + break; + } +} + +DlmsMeterComponent::DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, + std::optional> decryption_key, + std::optional> authentication_key, + std::vector custom_patterns) + : receive_timeout_ms_(receive_timeout_ms), + skip_crc_check_(skip_crc_check), + custom_patterns_(std::move(custom_patterns)), + parser_(&decryptor_) { + dlms_parser::Logger::set_log_function(log_callback); + + if (decryption_key.has_value()) { +#ifdef DLMS_METER_NO_CRYPTO + ESP_LOGE(TAG, "Decryption is not supported on this platform (no compatible crypto library found)"); +#else + auto opt_key = dlms_parser::Aes128GcmDecryptionKey::from_bytes(decryption_key.value()); + if (opt_key) { + this->parser_.set_decryption_key(*opt_key); + } else { + ESP_LOGE(TAG, "Failed to set decryption key: invalid key format"); + } +#endif + } + + if (authentication_key.has_value()) { +#ifdef DLMS_METER_NO_CRYPTO + ESP_LOGE(TAG, "Authentication is not supported on this platform (no compatible crypto library found)"); +#else + auto opt_key = dlms_parser::Aes128GcmAuthenticationKey::from_bytes(authentication_key.value()); + if (opt_key) { + this->parser_.set_authentication_key(*opt_key); + } else { + ESP_LOGE(TAG, "Failed to set authentication key: invalid key format"); + } +#endif + } + + this->parser_.set_skip_crc_check(this->skip_crc_check_); + + this->parser_.load_default_patterns(); + for (const auto &pattern : this->custom_patterns_) { + if (pattern.default_obis.has_value() && pattern.name.has_value()) { + this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority, + pattern.default_obis.value()); + } else if (pattern.name.has_value()) { + this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority); + } else { + this->parser_.register_pattern(pattern.pattern.c_str()); + } + } +} + +void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); } void DlmsMeterComponent::dump_config() { - const char *provider_name = this->provider_ == PROVIDER_NETZNOE ? "Netz NOE" : "Generic"; - ESP_LOGCONFIG(TAG, - "DLMS Meter:\n" - " Provider: %s\n" - " Read Timeout: %" PRIu32 " ms", - provider_name, this->read_timeout_); -#define DLMS_METER_LOG_SENSOR(s) LOG_SENSOR(" ", #s, this->s##_sensor_); - DLMS_METER_SENSOR_LIST(DLMS_METER_LOG_SENSOR, ) -#define DLMS_METER_LOG_TEXT_SENSOR(s) LOG_TEXT_SENSOR(" ", #s, this->s##_text_sensor_); - DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_LOG_TEXT_SENSOR, ) + ESP_LOGCONFIG(TAG, "DLMS Meter:"); + ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_); + ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_)); + + for (const auto &pattern : this->custom_patterns_) { + if (pattern.default_obis.has_value() && pattern.name.has_value()) { + const auto &obis = pattern.default_obis.value(); + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %d.%d.%d.%d.%d.%d)", + pattern.pattern.c_str(), pattern.name->c_str(), pattern.priority, obis[0], obis[1], obis[2], + obis[3], obis[4], obis[5]); + } else if (pattern.name.has_value()) { + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d)", pattern.pattern.c_str(), + pattern.name->c_str(), pattern.priority); + } else { + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s'", pattern.pattern.c_str()); + } + } + +#ifdef USE_SENSOR + for (const auto &entry : this->sensors_) { + LOG_SENSOR(" ", "Numeric Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif +#ifdef USE_TEXT_SENSOR + for (const auto &entry : this->text_sensors_) { + LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif +#ifdef USE_BINARY_SENSOR + for (const auto &entry : this->binary_sensors_) { + LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif } void DlmsMeterComponent::loop() { - // Read while data is available, netznoe uses two frames so allow 2x max frame length - size_t avail = this->available(); - if (avail > 0) { - size_t remaining = MBUS_MAX_FRAME_LENGTH * 2 - this->receive_buffer_.size(); - if (remaining == 0) { - ESP_LOGW(TAG, "Receive buffer full, dropping remaining bytes"); - } else { - // Read all available bytes in batches to reduce UART call overhead. - // Cap reads to remaining buffer capacity. - if (avail > remaining) { - avail = remaining; - } - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; - } - avail -= to_read; - this->receive_buffer_.insert(this->receive_buffer_.end(), buf, buf + to_read); - this->last_read_ = millis(); - } - } - } - - if (!this->receive_buffer_.empty() && millis() - this->last_read_ > this->read_timeout_) { - this->mbus_payload_.clear(); - if (!this->parse_mbus_(this->mbus_payload_)) - return; - - uint16_t message_length; - uint8_t systitle_length; - uint16_t header_offset; - if (!this->parse_dlms_(this->mbus_payload_, message_length, systitle_length, header_offset)) - return; - - if (message_length < DECODER_START_OFFSET || message_length > MAX_MESSAGE_LENGTH) { - ESP_LOGE(TAG, "DLMS: Message length invalid: %u", message_length); - this->receive_buffer_.clear(); - return; - } - - // Decrypt in place and then decode the OBIS codes - if (!this->decrypt_(this->mbus_payload_, message_length, systitle_length, header_offset)) - return; - this->decode_obis_(&this->mbus_payload_[header_offset + DLMS_PAYLOAD_OFFSET], message_length); + this->read_rx_buffer_(); + if (this->bytes_accumulated_ > 0 && + App.get_loop_component_start_time() - this->last_rx_char_time_ > this->receive_timeout_ms_) { + this->process_frame_(); } } -bool DlmsMeterComponent::parse_mbus_(std::vector &mbus_payload) { - ESP_LOGV(TAG, "Parsing M-Bus frames"); - uint16_t frame_offset = 0; // Offset is used if the M-Bus message is split into multiple frames - - while (frame_offset < this->receive_buffer_.size()) { - // Ensure enough bytes remain for the minimal intro header before accessing indices - if (this->receive_buffer_.size() - frame_offset < MBUS_HEADER_INTRO_LENGTH) { - ESP_LOGE(TAG, "MBUS: Not enough data for frame header (need %d, have %d)", MBUS_HEADER_INTRO_LENGTH, - (this->receive_buffer_.size() - frame_offset)); - this->receive_buffer_.clear(); - return false; - } - - // Check start bytes - if (this->receive_buffer_[frame_offset + MBUS_START1_OFFSET] != START_BYTE_LONG_FRAME || - this->receive_buffer_[frame_offset + MBUS_START2_OFFSET] != START_BYTE_LONG_FRAME) { - ESP_LOGE(TAG, "MBUS: Start bytes do not match"); - this->receive_buffer_.clear(); - return false; - } - - // Both length bytes must be identical - if (this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET] != - this->receive_buffer_[frame_offset + MBUS_LENGTH2_OFFSET]) { - ESP_LOGE(TAG, "MBUS: Length bytes do not match"); - this->receive_buffer_.clear(); - return false; - } - - uint8_t frame_length = this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET]; // Get length of this frame - - // Check if received data is enough for the given frame length - if (this->receive_buffer_.size() - frame_offset < - frame_length + 3) { // length field inside packet does not account for second start- + checksum- + stop- byte - ESP_LOGE(TAG, "MBUS: Frame too big for received data"); - this->receive_buffer_.clear(); - return false; - } - - // Ensure we have full frame (header + payload + checksum + stop byte) before accessing stop byte - size_t required_total = - frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH; // payload + header + 2 footer bytes - if (this->receive_buffer_.size() - frame_offset < required_total) { - ESP_LOGE(TAG, "MBUS: Incomplete frame (need %d, have %d)", (unsigned int) required_total, - this->receive_buffer_.size() - frame_offset); - this->receive_buffer_.clear(); - return false; - } - - if (this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH - 1] != - STOP_BYTE) { - ESP_LOGE(TAG, "MBUS: Invalid stop byte"); - this->receive_buffer_.clear(); - return false; - } - - // Verify checksum: sum of all bytes starting at MBUS_HEADER_INTRO_LENGTH, take last byte - uint8_t checksum = 0; // use uint8_t so only the 8 least significant bits are stored - for (uint16_t i = 0; i < frame_length; i++) { - checksum += this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + i]; - } - if (checksum != this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]) { - ESP_LOGE(TAG, "MBUS: Invalid checksum: %x != %x", checksum, - this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]); - this->receive_buffer_.clear(); - return false; - } - - mbus_payload.insert(mbus_payload.end(), &this->receive_buffer_[frame_offset + MBUS_FULL_HEADER_LENGTH], - &this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + frame_length]); - - frame_offset += MBUS_HEADER_INTRO_LENGTH + frame_length + MBUS_FOOTER_LENGTH; +void DlmsMeterComponent::flush_rx_buffer_() { + while (this->available()) { + this->read(); } - return true; } -bool DlmsMeterComponent::parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, - uint8_t &systitle_length, uint16_t &header_offset) { - ESP_LOGV(TAG, "Parsing DLMS header"); - if (mbus_payload.size() < DLMS_HEADER_LENGTH + DLMS_HEADER_EXT_OFFSET) { - ESP_LOGE(TAG, "DLMS: Payload too short"); - this->receive_buffer_.clear(); - return false; +void DlmsMeterComponent::read_rx_buffer_() { + int available = this->available(); + if (available == 0) + return; + + if (this->bytes_accumulated_ + available > this->rx_buffer_.size()) { + ESP_LOGW(TAG, "RX Buffer overflow. Frame too large! Dropping frame."); + this->bytes_accumulated_ = 0; + + this->flush_rx_buffer_(); + return; } - if (mbus_payload[DLMS_CIPHER_OFFSET] != GLO_CIPHERING) { // Only general-glo-ciphering is supported (0xDB) - ESP_LOGE(TAG, "DLMS: Unsupported cipher"); - this->receive_buffer_.clear(); - return false; + bool success = this->read_array(this->rx_buffer_.data() + this->bytes_accumulated_, available); + if (!success) { + ESP_LOGW(TAG, "UART read failed. Dropping frame."); + this->bytes_accumulated_ = 0; + this->flush_rx_buffer_(); + return; } - systitle_length = mbus_payload[DLMS_SYST_OFFSET]; + this->bytes_accumulated_ += available; - if (systitle_length != 0x08) { // Only system titles with length of 8 are supported - ESP_LOGE(TAG, "DLMS: Unsupported system title length"); - this->receive_buffer_.clear(); - return false; - } - - message_length = mbus_payload[DLMS_LENGTH_OFFSET]; - header_offset = 0; - - if (this->provider_ == PROVIDER_NETZNOE) { - // for some reason EVN seems to set the standard "length" field to 0x81 and then the actual length is in the next - // byte. Check some bytes to see if received data still matches expectation - if (message_length == NETZ_NOE_MAGIC_BYTE && - mbus_payload[DLMS_LENGTH_OFFSET + 1] == NETZ_NOE_EXPECTED_MESSAGE_LENGTH && - mbus_payload[DLMS_LENGTH_OFFSET + 2] == NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE) { - message_length = mbus_payload[DLMS_LENGTH_OFFSET + 1]; - header_offset = 1; - } else { - ESP_LOGE(TAG, "Wrong Length - Security Control Byte sequence detected for provider EVN"); - } - } else { - if (message_length == TWO_BYTE_LENGTH) { - message_length = encode_uint16(mbus_payload[DLMS_LENGTH_OFFSET + 1], mbus_payload[DLMS_LENGTH_OFFSET + 2]); - header_offset = DLMS_HEADER_EXT_OFFSET; - } - } - if (message_length < DLMS_LENGTH_CORRECTION) { - ESP_LOGE(TAG, "DLMS: Message length too short: %u", message_length); - this->receive_buffer_.clear(); - return false; - } - message_length -= DLMS_LENGTH_CORRECTION; // Correct message length due to part of header being included in length - - if (mbus_payload.size() - DLMS_HEADER_LENGTH - header_offset != message_length) { - ESP_LOGV(TAG, "DLMS: Length mismatch - payload=%d, header=%d, offset=%d, message=%d", mbus_payload.size(), - DLMS_HEADER_LENGTH, header_offset, message_length); - ESP_LOGE(TAG, "DLMS: Message has invalid length"); - this->receive_buffer_.clear(); - return false; - } - - if (mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != 0x21 && - mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != - 0x20) { // Only certain security suite is supported (0x21 || 0x20) - ESP_LOGE(TAG, "DLMS: Unsupported security control byte"); - this->receive_buffer_.clear(); - return false; - } - - return true; + this->last_rx_char_time_ = App.get_loop_component_start_time(); } -bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, - uint16_t header_offset) { - ESP_LOGV(TAG, "Decrypting payload"); - uint8_t iv[12]; // Reserve space for the IV, always 12 bytes - // Copy system title to IV (System title is before length; no header offset needed!) - // Add 1 to the offset in order to skip the system title length byte - memcpy(&iv[0], &mbus_payload[DLMS_SYST_OFFSET + 1], systitle_length); - memcpy(&iv[8], &mbus_payload[header_offset + DLMS_FRAMECOUNTER_OFFSET], - DLMS_FRAMECOUNTER_LENGTH); // Copy frame counter to IV +void DlmsMeterComponent::process_frame_() { + ESP_LOGV(TAG, "Processing frame of size: %zu bytes", this->bytes_accumulated_); - uint8_t *payload_ptr = &mbus_payload[header_offset + DLMS_PAYLOAD_OFFSET]; + auto callback = [this](const char *obis_code, float float_val, const char *str_val, bool is_numeric) { + this->on_data_(obis_code, float_val, str_val, is_numeric); + }; -#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) - br_gcm_context gcm_ctx; - br_aes_ct_ctr_keys bc; - br_aes_ct_ctr_init(&bc, this->decryption_key_.data(), this->decryption_key_.size()); - br_gcm_init(&gcm_ctx, &bc.vtable, br_ghash_ctmul32); - br_gcm_reset(&gcm_ctx, iv, sizeof(iv)); - br_gcm_flip(&gcm_ctx); - br_gcm_run(&gcm_ctx, 0, payload_ptr, message_length); -#elif defined(USE_ESP32) -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) - // PSA Crypto multipart AEAD (no tag verification, matching legacy behavior) - psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; - psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); - psa_set_key_bits(&attributes, this->decryption_key_.size() * 8); - psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); - psa_set_key_algorithm(&attributes, PSA_ALG_GCM); + this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}, callback); - mbedtls_svc_key_id_t key_id; - bool decrypt_failed = true; - if (psa_import_key(&attributes, this->decryption_key_.data(), this->decryption_key_.size(), &key_id) == PSA_SUCCESS) { - psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT; - if (psa_aead_decrypt_setup(&op, key_id, PSA_ALG_GCM) == PSA_SUCCESS && - psa_aead_set_nonce(&op, iv, sizeof(iv)) == PSA_SUCCESS) { - size_t outlen = 0; - if (psa_aead_update(&op, payload_ptr, message_length, payload_ptr, message_length, &outlen) == PSA_SUCCESS && - outlen == message_length) { - decrypt_failed = false; + this->bytes_accumulated_ = 0; +} + +void DlmsMeterComponent::on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric) { + int updated_count = 0; + +#ifdef USE_SENSOR + if (is_numeric) { + for (auto &item : this->sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(float_val); + updated_count++; } } - psa_aead_abort(&op); - psa_destroy_key(key_id); - } - if (decrypt_failed) { - ESP_LOGE(TAG, "Decryption failed"); - this->receive_buffer_.clear(); - return false; - } -#else - size_t outlen = 0; - mbedtls_gcm_context gcm_ctx; - mbedtls_gcm_init(&gcm_ctx); - mbedtls_gcm_setkey(&gcm_ctx, MBEDTLS_CIPHER_ID_AES, this->decryption_key_.data(), this->decryption_key_.size() * 8); - mbedtls_gcm_starts(&gcm_ctx, MBEDTLS_GCM_DECRYPT, iv, sizeof(iv)); - auto ret = mbedtls_gcm_update(&gcm_ctx, payload_ptr, message_length, payload_ptr, message_length, &outlen); - mbedtls_gcm_free(&gcm_ctx); - if (ret != 0) { - ESP_LOGE(TAG, "Decryption failed with error: %d", ret); - this->receive_buffer_.clear(); - return false; } #endif -#else -#error "Invalid Platform" + +#ifdef USE_TEXT_SENSOR + if (!is_numeric && str_val != nullptr) { + for (auto &item : this->text_sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(str_val); + updated_count++; + } + } + } #endif - if (payload_ptr[0] != DATA_NOTIFICATION || payload_ptr[5] != TIMESTAMP_DATETIME) { - ESP_LOGE(TAG, "OBIS: Packet was decrypted but data is invalid"); - this->receive_buffer_.clear(); - return false; - } - ESP_LOGV(TAG, "Decrypted payload: %d bytes", message_length); - return true; -} - -void DlmsMeterComponent::decode_obis_(uint8_t *plaintext, uint16_t message_length) { - ESP_LOGV(TAG, "Decoding payload"); - MeterData data{}; - uint16_t current_position = DECODER_START_OFFSET; - bool power_factor_found = false; - - while (current_position + OBIS_CODE_OFFSET <= message_length) { - if (plaintext[current_position + OBIS_TYPE_OFFSET] != DataType::OCTET_STRING) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS header type: %x", plaintext[current_position + OBIS_TYPE_OFFSET]); - this->receive_buffer_.clear(); - return; - } - - uint8_t obis_code_length = plaintext[current_position + OBIS_LENGTH_OFFSET]; - if (obis_code_length != OBIS_CODE_LENGTH_STANDARD && obis_code_length != OBIS_CODE_LENGTH_EXTENDED) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS header length: %x", obis_code_length); - this->receive_buffer_.clear(); - return; - } - if (current_position + OBIS_CODE_OFFSET + obis_code_length > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for OBIS code"); - this->receive_buffer_.clear(); - return; - } - - uint8_t *obis_code = &plaintext[current_position + OBIS_CODE_OFFSET]; - uint8_t obis_medium = obis_code[OBIS_A]; - uint16_t obis_cd = encode_uint16(obis_code[OBIS_C], obis_code[OBIS_D]); - - bool timestamp_found = false; - bool meter_number_found = false; - if (this->provider_ == PROVIDER_NETZNOE) { - // Do not advance Position when reading the Timestamp at DECODER_START_OFFSET - if ((obis_code_length == OBIS_CODE_LENGTH_EXTENDED) && (current_position == DECODER_START_OFFSET)) { - timestamp_found = true; - } else if (power_factor_found) { - meter_number_found = true; - power_factor_found = false; - } else { - current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code and position - } - } else { - current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code, position and type - } - if (!timestamp_found && !meter_number_found && obis_medium != Medium::ELECTRICITY && - obis_medium != Medium::ABSTRACT) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS medium: %x", obis_medium); - this->receive_buffer_.clear(); - return; - } - - if (current_position >= message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for data type"); - this->receive_buffer_.clear(); - return; - } - - float value = 0.0f; - uint8_t value_size = 0; - uint8_t data_type = plaintext[current_position]; - current_position++; - - switch (data_type) { - case DataType::DOUBLE_LONG_UNSIGNED: { - value_size = 4; - if (current_position + value_size > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for DOUBLE_LONG_UNSIGNED"); - this->receive_buffer_.clear(); - return; - } - value = encode_uint32(plaintext[current_position + 0], plaintext[current_position + 1], - plaintext[current_position + 2], plaintext[current_position + 3]); - current_position += value_size; - break; - } - case DataType::LONG_UNSIGNED: { - value_size = 2; - if (current_position + value_size > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for LONG_UNSIGNED"); - this->receive_buffer_.clear(); - return; - } - value = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); - current_position += value_size; - break; - } - case DataType::OCTET_STRING: { - uint8_t data_length = plaintext[current_position]; - current_position++; // Advance past string length - if (current_position + data_length > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for OCTET_STRING"); - this->receive_buffer_.clear(); - return; - } - // Handle timestamp (normal OBIS code or NETZNOE special case) - if (obis_cd == OBIS_TIMESTAMP || timestamp_found) { - if (data_length < 8) { - ESP_LOGE(TAG, "OBIS: Timestamp data too short: %u", data_length); - this->receive_buffer_.clear(); - return; - } - uint16_t year = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); - uint8_t month = plaintext[current_position + 2]; - uint8_t day = plaintext[current_position + 3]; - uint8_t hour = plaintext[current_position + 5]; - uint8_t minute = plaintext[current_position + 6]; - uint8_t second = plaintext[current_position + 7]; - if (year > 9999 || month > 12 || day > 31 || hour > 23 || minute > 59 || second > 59) { - ESP_LOGE(TAG, "Invalid timestamp values: %04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, minute, - second); - this->receive_buffer_.clear(); - return; - } - snprintf(data.timestamp, sizeof(data.timestamp), "%04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, - minute, second); - } else if (meter_number_found) { - snprintf(data.meternumber, sizeof(data.meternumber), "%.*s", data_length, &plaintext[current_position]); - } - current_position += data_length; - break; - } - default: - ESP_LOGE(TAG, "OBIS: Unsupported OBIS data type: %x", data_type); - this->receive_buffer_.clear(); - return; - } - - // Skip break after data - if (this->provider_ == PROVIDER_NETZNOE) { - // Don't skip the break on the first timestamp, as there's none - if (!timestamp_found) { - current_position += 2; - } - } else { - current_position += 2; - } - - // Check for additional data (scaler-unit structure) - if (current_position < message_length && plaintext[current_position] == DataType::INTEGER) { - // Apply scaler: real_value = raw_value × 10^scaler - if (current_position + 1 < message_length) { - int8_t scaler = static_cast(plaintext[current_position + 1]); - if (scaler != 0) { - value *= pow10_int(scaler); - } - } - - // on EVN Meters there is no additional break - if (this->provider_ == PROVIDER_NETZNOE) { - current_position += 4; - } else { - current_position += 6; - } - } - - // Handle numeric values (LONG_UNSIGNED and DOUBLE_LONG_UNSIGNED) - if (value_size > 0) { - switch (obis_cd) { - case OBIS_VOLTAGE_L1: - data.voltage_l1 = value; - break; - case OBIS_VOLTAGE_L2: - data.voltage_l2 = value; - break; - case OBIS_VOLTAGE_L3: - data.voltage_l3 = value; - break; - case OBIS_CURRENT_L1: - data.current_l1 = value; - break; - case OBIS_CURRENT_L2: - data.current_l2 = value; - break; - case OBIS_CURRENT_L3: - data.current_l3 = value; - break; - case OBIS_ACTIVE_POWER_PLUS: - data.active_power_plus = value; - break; - case OBIS_ACTIVE_POWER_MINUS: - data.active_power_minus = value; - break; - case OBIS_ACTIVE_ENERGY_PLUS: - data.active_energy_plus = value; - break; - case OBIS_ACTIVE_ENERGY_MINUS: - data.active_energy_minus = value; - break; - case OBIS_REACTIVE_ENERGY_PLUS: - data.reactive_energy_plus = value; - break; - case OBIS_REACTIVE_ENERGY_MINUS: - data.reactive_energy_minus = value; - break; - case OBIS_POWER_FACTOR: - data.power_factor = value; - power_factor_found = true; - break; - default: - ESP_LOGW(TAG, "Unsupported OBIS code 0x%04X", obis_cd); +#ifdef USE_BINARY_SENSOR + if (is_numeric) { + bool state = float_val != 0.0f; + for (auto &item : this->binary_sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(state); + updated_count++; } } } +#endif - this->receive_buffer_.clear(); - - ESP_LOGI(TAG, "Received valid data"); - this->publish_sensors(data); - this->status_clear_warning(); + if (updated_count == 0) { + ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code); + } } +#ifdef USE_SENSOR +void DlmsMeterComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) { + this->sensors_.push_back({obis_code, sensor}); +} +#endif +#ifdef USE_TEXT_SENSOR +void DlmsMeterComponent::register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor) { + this->text_sensors_.push_back({obis_code, sensor}); +} +#endif +#ifdef USE_BINARY_SENSOR +void DlmsMeterComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) { + this->binary_sensors_.push_back({obis_code, sensor}); +} +#endif + } // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index c50e6f6b4d..cdc53d5685 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -2,95 +2,150 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "esphome/components/uart/uart.h" + #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif #ifdef USE_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif -#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif -#include "mbus.h" -#include "dlms.h" -#include "obis.h" +#include -#include #include +#include +#include +#include +#include + +#if __has_include() +#include +#elif !defined(USE_ESP8266) && __has_include() +#if __has_include() +#include +#endif +#include +#elif __has_include() +#include +#else +#define DLMS_METER_NO_CRYPTO +#endif + +#ifndef DLMS_MAX_SENSORS +static constexpr uint8_t DLMS_MAX_SENSORS = 0; +#endif +#ifndef DLMS_MAX_TEXT_SENSORS +static constexpr uint8_t DLMS_MAX_TEXT_SENSORS = 0; +#endif +#ifndef DLMS_MAX_BINARY_SENSORS +static constexpr uint8_t DLMS_MAX_BINARY_SENSORS = 0; +#endif namespace esphome::dlms_meter { -#ifndef DLMS_METER_SENSOR_LIST -#define DLMS_METER_SENSOR_LIST(F, SEP) -#endif - -#ifndef DLMS_METER_TEXT_SENSOR_LIST -#define DLMS_METER_TEXT_SENSOR_LIST(F, SEP) -#endif - -struct MeterData { - float voltage_l1 = 0.0f; // Voltage L1 - float voltage_l2 = 0.0f; // Voltage L2 - float voltage_l3 = 0.0f; // Voltage L3 - float current_l1 = 0.0f; // Current L1 - float current_l2 = 0.0f; // Current L2 - float current_l3 = 0.0f; // Current L3 - float active_power_plus = 0.0f; // Active power taken from grid - float active_power_minus = 0.0f; // Active power put into grid - float active_energy_plus = 0.0f; // Active energy taken from grid - float active_energy_minus = 0.0f; // Active energy put into grid - float reactive_energy_plus = 0.0f; // Reactive energy taken from grid - float reactive_energy_minus = 0.0f; // Reactive energy put into grid - char timestamp[27]{}; // Text sensor for the timestamp value - - // Netz NOE - float power_factor = 0.0f; // Power Factor - char meternumber[13]{}; // Text sensor for the meterNumber value +#ifdef DLMS_METER_NO_CRYPTO +// Fallback dummy decryptor for platforms without supported crypto (e.g., Zephyr during clang-tidy) +class Aes128GcmDecryptorDummy : public dlms_parser::Aes128GcmDecryptor { + public: + void set_decryption_key(const dlms_parser::Aes128GcmDecryptionKey &key) override {} + bool decrypt_in_place(std::span iv, std::span ciphertext_and_plaintext, + std::span aad, std::span tag) override { + return false; + } }; +#endif -// Provider constants -enum Providers : uint32_t { PROVIDER_GENERIC = 0x00, PROVIDER_NETZNOE = 0x01 }; +#if __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; +#elif !defined(USE_ESP8266) && __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; +#elif __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; +#else +using Aes128GcmDecryptorImpl = Aes128GcmDecryptorDummy; +#endif + +#ifdef USE_SENSOR +struct SensorItem { + std::string obis_code; + sensor::Sensor *sensor; +}; +#endif +#ifdef USE_TEXT_SENSOR +struct TextSensorItem { + std::string obis_code; + text_sensor::TextSensor *sensor; +}; +#endif +#ifdef USE_BINARY_SENSOR +struct BinarySensorItem { + std::string obis_code; + binary_sensor::BinarySensor *sensor; +}; +#endif + +struct CustomPattern { + std::string pattern; + std::optional name; + int priority{0}; + std::optional> default_obis; +}; class DlmsMeterComponent : public Component, public uart::UARTDevice { public: - DlmsMeterComponent() = default; + DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, + std::optional> decryption_key, + std::optional> authentication_key, + std::vector custom_patterns); + void setup() override; void dump_config() override; void loop() override; - void set_decryption_key(const std::array &key) { this->decryption_key_ = key; } - void set_provider(uint32_t provider) { this->provider_ = provider; } - - void publish_sensors(MeterData &data) { -#define DLMS_METER_PUBLISH_SENSOR(s) \ - if (this->s##_sensor_ != nullptr) \ - s##_sensor_->publish_state(data.s); - DLMS_METER_SENSOR_LIST(DLMS_METER_PUBLISH_SENSOR, ) - -#define DLMS_METER_PUBLISH_TEXT_SENSOR(s) \ - if (this->s##_text_sensor_ != nullptr) \ - s##_text_sensor_->publish_state(data.s); - DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_PUBLISH_TEXT_SENSOR, ) - } - - DLMS_METER_SENSOR_LIST(SUB_SENSOR, ) - DLMS_METER_TEXT_SENSOR_LIST(SUB_TEXT_SENSOR, ) +#ifdef USE_SENSOR + void register_sensor(const std::string &obis_code, sensor::Sensor *sensor); +#endif +#ifdef USE_TEXT_SENSOR + void register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor); +#endif +#ifdef USE_BINARY_SENSOR + void register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor); +#endif protected: - bool parse_mbus_(std::vector &mbus_payload); - bool parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, uint8_t &systitle_length, - uint16_t &header_offset); - bool decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, - uint16_t header_offset); - void decode_obis_(uint8_t *plaintext, uint16_t message_length); + void read_rx_buffer_(); + void flush_rx_buffer_(); + void process_frame_(); + void on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric); - std::vector receive_buffer_; // Stores the packet currently being received - std::vector mbus_payload_; // Parsed M-Bus payload, reused to avoid heap churn - uint32_t last_read_ = 0; // Timestamp when data was last read - uint32_t read_timeout_ = 1000; // Time to wait after last byte before considering data complete + std::array rx_buffer_; + size_t bytes_accumulated_{0}; + uint32_t last_rx_char_time_{0}; - uint32_t provider_ = PROVIDER_GENERIC; // Provider of the meter / your grid operator - std::array decryption_key_; + uint32_t receive_timeout_ms_{1000}; + bool skip_crc_check_{false}; + + std::vector custom_patterns_; + + Aes128GcmDecryptorImpl decryptor_; + dlms_parser::DlmsParser parser_; + +#ifdef USE_SENSOR + StaticVector sensors_; +#endif +#ifdef USE_TEXT_SENSOR + StaticVector text_sensors_; +#endif +#ifdef USE_BINARY_SENSOR + StaticVector binary_sensors_; +#endif }; } // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/mbus.h b/esphome/components/dlms_meter/mbus.h deleted file mode 100644 index 293d43a55b..0000000000 --- a/esphome/components/dlms_meter/mbus.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -/* -+----------------------------------------------------+ - -| Start Character [0x68] | \ -+----------------------------------------------------+ | -| Data Length (L) | | -+----------------------------------------------------+ | -| Data Length Repeat (L) | | -+----------------------------------------------------+ > M-Bus Data link layer -| Start Character Repeat [0x68] | | -+----------------------------------------------------+ | -| Control/Function Field (C) | | -+----------------------------------------------------+ | -| Address Field (A) | / -+----------------------------------------------------+ - -| Control Information Field (CI) | \ -+----------------------------------------------------+ | -| Source Transport Service Access Point (STSAP) | > DLMS/COSEM M-Bus transport layer -+----------------------------------------------------+ | -| Destination Transport Service Access Point (DTSAP) | / -+----------------------------------------------------+ - -| | \ -~ ~ | - Data > DLMS/COSEM Application Layer -~ ~ | -| | / -+----------------------------------------------------+ - -| Checksum | \ -+----------------------------------------------------+ > M-Bus Data link layer -| Stop Character [0x16] | / -+----------------------------------------------------+ - - -Data_Length = L - C - A - CI -Each line (except Data) is one Byte - -Possible Values found in publicly available docs: -- C: 0x53/0x73 (SND_UD) -- A: FF (Broadcast) -- CI: 0x00-0x1F/0x60/0x61/0x7C/0x7D -- STSAP: 0x01 (Management Logical Device ID 1 of the meter) -- DTSAP: 0x67 (Consumer Information Push Client ID 103) - */ - -// MBUS start bytes for different telegram formats: -// - Single Character: 0xE5 (length=1) -// - Short Frame: 0x10 (length=5) -// - Control Frame: 0x68 (length=9) -// - Long Frame: 0x68 (length=9+data_length) -// This component currently only uses Long Frame. -static constexpr uint8_t START_BYTE_SINGLE_CHARACTER = 0xE5; -static constexpr uint8_t START_BYTE_SHORT_FRAME = 0x10; -static constexpr uint8_t START_BYTE_CONTROL_FRAME = 0x68; -static constexpr uint8_t START_BYTE_LONG_FRAME = 0x68; -static constexpr uint8_t MBUS_HEADER_INTRO_LENGTH = 4; // Header length for the intro (0x68, length, length, 0x68) -static constexpr uint8_t MBUS_FULL_HEADER_LENGTH = 9; // Total header length -static constexpr uint8_t MBUS_FOOTER_LENGTH = 2; // Footer after frame -static constexpr uint8_t MBUS_MAX_FRAME_LENGTH = 250; // Maximum size of frame -static constexpr uint8_t MBUS_START1_OFFSET = 0; // Offset of first start byte -static constexpr uint8_t MBUS_LENGTH1_OFFSET = 1; // Offset of first length byte -static constexpr uint8_t MBUS_LENGTH2_OFFSET = 2; // Offset of (duplicated) second length byte -static constexpr uint8_t MBUS_START2_OFFSET = 3; // Offset of (duplicated) second start byte -static constexpr uint8_t STOP_BYTE = 0x16; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/obis.h b/esphome/components/dlms_meter/obis.h deleted file mode 100644 index 1bb960e61e..0000000000 --- a/esphome/components/dlms_meter/obis.h +++ /dev/null @@ -1,94 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -// Data types as per specification -enum DataType { - NULL_DATA = 0x00, - BOOLEAN = 0x03, - BIT_STRING = 0x04, - DOUBLE_LONG = 0x05, - DOUBLE_LONG_UNSIGNED = 0x06, - OCTET_STRING = 0x09, - VISIBLE_STRING = 0x0A, - UTF8_STRING = 0x0C, - BINARY_CODED_DECIMAL = 0x0D, - INTEGER = 0x0F, - LONG = 0x10, - UNSIGNED = 0x11, - LONG_UNSIGNED = 0x12, - LONG64 = 0x14, - LONG64_UNSIGNED = 0x15, - ENUM = 0x16, - FLOAT32 = 0x17, - FLOAT64 = 0x18, - DATE_TIME = 0x19, - DATE = 0x1A, - TIME = 0x1B, - - ARRAY = 0x01, - STRUCTURE = 0x02, - COMPACT_ARRAY = 0x13 -}; - -enum Medium { - ABSTRACT = 0x00, - ELECTRICITY = 0x01, - HEAT_COST_ALLOCATOR = 0x04, - COOLING = 0x05, - HEAT = 0x06, - GAS = 0x07, - COLD_WATER = 0x08, - HOT_WATER = 0x09, - OIL = 0x10, - COMPRESSED_AIR = 0x11, - NITROGEN = 0x12 -}; - -// Data structure -static constexpr uint8_t DECODER_START_OFFSET = 20; // Skip header, timestamp and break block -static constexpr uint8_t OBIS_TYPE_OFFSET = 0; -static constexpr uint8_t OBIS_LENGTH_OFFSET = 1; -static constexpr uint8_t OBIS_CODE_OFFSET = 2; -static constexpr uint8_t OBIS_CODE_LENGTH_STANDARD = 0x06; // 6-byte OBIS code (A.B.C.D.E.F) -static constexpr uint8_t OBIS_CODE_LENGTH_EXTENDED = 0x0C; // 12-byte extended OBIS code -static constexpr uint8_t OBIS_A = 0; -static constexpr uint8_t OBIS_B = 1; -static constexpr uint8_t OBIS_C = 2; -static constexpr uint8_t OBIS_D = 3; -static constexpr uint8_t OBIS_E = 4; -static constexpr uint8_t OBIS_F = 5; - -// Metadata -static constexpr uint16_t OBIS_TIMESTAMP = 0x0100; -static constexpr uint16_t OBIS_SERIAL_NUMBER = 0x6001; -static constexpr uint16_t OBIS_DEVICE_NAME = 0x2A00; - -// Voltage -static constexpr uint16_t OBIS_VOLTAGE_L1 = 0x2007; -static constexpr uint16_t OBIS_VOLTAGE_L2 = 0x3407; -static constexpr uint16_t OBIS_VOLTAGE_L3 = 0x4807; - -// Current -static constexpr uint16_t OBIS_CURRENT_L1 = 0x1F07; -static constexpr uint16_t OBIS_CURRENT_L2 = 0x3307; -static constexpr uint16_t OBIS_CURRENT_L3 = 0x4707; - -// Power -static constexpr uint16_t OBIS_ACTIVE_POWER_PLUS = 0x0107; -static constexpr uint16_t OBIS_ACTIVE_POWER_MINUS = 0x0207; - -// Active energy -static constexpr uint16_t OBIS_ACTIVE_ENERGY_PLUS = 0x0108; -static constexpr uint16_t OBIS_ACTIVE_ENERGY_MINUS = 0x0208; - -// Reactive energy -static constexpr uint16_t OBIS_REACTIVE_ENERGY_PLUS = 0x0308; -static constexpr uint16_t OBIS_REACTIVE_ENERGY_MINUS = 0x0408; - -// Netz NOE specific -static constexpr uint16_t OBIS_POWER_FACTOR = 0x0D07; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index 27fd44f008..ec4639351d 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -1,8 +1,9 @@ +import logging + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -16,109 +17,142 @@ from esphome.const import ( UNIT_WATT_HOURS, ) -from .. import CONF_DLMS_METER_ID, DlmsMeterComponent +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -AUTO_LOAD = ["dlms_meter"] +_LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.Schema( +DEPENDENCIES = ["dlms_meter"] + +NUMERIC_KEYS = { + "voltage_l1": "1.0.32.7.0.255", + "voltage_l2": "1.0.52.7.0.255", + "voltage_l3": "1.0.72.7.0.255", + "current_l1": "1.0.31.7.0.255", + "current_l2": "1.0.51.7.0.255", + "current_l3": "1.0.71.7.0.255", + "active_power_plus": "1.0.1.7.0.255", + "active_power_minus": "1.0.2.7.0.255", + "active_energy_plus": "1.0.1.8.0.255", + "active_energy_minus": "1.0.2.8.0.255", + "reactive_energy_plus": "1.0.3.8.0.255", + "reactive_energy_minus": "1.0.4.8.0.255", + "power_factor": "1.0.13.7.0.255", +} + +DYNAMIC_SCHEMA = sensor.sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("voltage_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("active_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - # Netz NOE - cv.Optional("power_factor"): sensor.sensor_schema( - accuracy_decimals=3, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, - ), + cv.Required(CONF_OBIS_CODE): obis_code, } -).extend(cv.COMPONENT_SCHEMA) +) + + +def deprecation_warning(config): + _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'." + ) + return config + + +OLD_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("voltage_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("active_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("power_factor"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ).extend(cv.COMPONENT_SCHEMA), + deprecation_warning, +) + + +CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) async def to_code(config): hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - sensors = [] - for key, conf in config.items(): - if not isinstance(conf, dict): - continue - id = conf[CONF_ID] - if id and id.type == sensor.Sensor: - sens = await sensor.new_sensor(conf) - cg.add(getattr(hub, f"set_{key}_sensor")(sens)) - sensors.append(f"F({key})") - - if sensors: - cg.add_define( - "DLMS_METER_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors)) - ) + if obis := config.get(CONF_OBIS_CODE): + var = await sensor.new_sensor(config) + cg.add(hub.register_sensor(obis, var)) + else: + for key, obis_val in NUMERIC_KEYS.items(): + if sensor_config := config.get(key): + sens = await sensor.new_sensor(sensor_config) + cg.add(hub.register_sensor(obis_val, sens)) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 4d2373f4f9..0bfb43a285 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -1,37 +1,59 @@ +import logging + import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv -from esphome.const import CONF_ID -from .. import CONF_DLMS_METER_ID, DlmsMeterComponent +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -AUTO_LOAD = ["dlms_meter"] +_LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.Schema( +DEPENDENCIES = ["dlms_meter"] + +TEXT_KEYS = { + "timestamp": "0.0.1.0.0.255", + "meternumber": "0.0.96.1.0.255", +} + +DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("timestamp"): text_sensor.text_sensor_schema(), - # Netz NOE - cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + cv.Required(CONF_OBIS_CODE): obis_code, } -).extend(cv.COMPONENT_SCHEMA) +) + + +def deprecation_warning(config): + _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'." + ) + return config + + +OLD_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("timestamp"): text_sensor.text_sensor_schema(), + cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + } + ).extend(cv.COMPONENT_SCHEMA), + deprecation_warning, +) + + +CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) async def to_code(config): hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - text_sensors = [] - for key, conf in config.items(): - if not isinstance(conf, dict): - continue - id = conf[CONF_ID] - if id and id.type == text_sensor.TextSensor: - sens = await text_sensor.new_text_sensor(conf) - cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) - text_sensors.append(f"F({key})") - - if text_sensors: - cg.add_define( - "DLMS_METER_TEXT_SENSOR_LIST(F, sep)", - cg.RawExpression(" sep ".join(text_sensors)), - ) + if obis := config.get(CONF_OBIS_CODE): + var = await text_sensor.new_text_sensor(config) + cg.add(hub.register_text_sensor(obis, var)) + else: + for key, obis_val in TEXT_KEYS.items(): + if text_sensor_config := config.get(key): + sens = await text_sensor.new_text_sensor(text_sensor_config) + cg.add(hub.register_text_sensor(obis_val, sens)) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4a4bc18579..7cbc2ac4ae 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -1,6 +1,8 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" + esphome/dlms_parser: + version: 1.1.0 esphome/esp-audio-libs: version: 3.2.1 esphome/esp-micro-speech-features: diff --git a/platformio.ini b/platformio.ini index b41e850bcd..d60a4fd68d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -107,6 +107,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + esphome/dlms_parser@1.1.0 ; dlms_meter fastled/FastLED@3.9.16 ; fastled_base bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) @@ -193,6 +194,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + esphome/dlms_parser@1.1.0 ; dlms_meter fastled/FastLED@3.9.16 ; fastled_base ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp bblanchon/ArduinoJson@7.4.2 ; json @@ -212,6 +214,7 @@ platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 framework = arduino lib_compat_mode = soft lib_deps = + esphome/dlms_parser@1.1.0 ; dlms_meter bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard @@ -236,6 +239,7 @@ build_flags = -DUSE_NRF52 lib_deps = ${common.lib_deps_base} + esphome/dlms_parser@1.1.0 ; dlms_meter bblanchon/ArduinoJson@7.4.2 ; json lvgl/lvgl@9.5.0 ; lvgl diff --git a/tests/components/dlms_meter/common-generic.yaml b/tests/components/dlms_meter/common-generic.yaml deleted file mode 100644 index edb1c66f0f..0000000000 --- a/tests/components/dlms_meter/common-generic.yaml +++ /dev/null @@ -1,11 +0,0 @@ -dlms_meter: - decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key! - -sensor: - - platform: dlms_meter - reactive_energy_plus: - name: "Reactive energy taken from grid" - reactive_energy_minus: - name: "Reactive energy put into grid" - -<<: !include common.yaml diff --git a/tests/components/dlms_meter/common-netznoe.yaml b/tests/components/dlms_meter/common-netznoe.yaml deleted file mode 100644 index db064b64f9..0000000000 --- a/tests/components/dlms_meter/common-netznoe.yaml +++ /dev/null @@ -1,17 +0,0 @@ -dlms_meter: - decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key! - provider: netznoe # (optional) key - only set if using evn - -sensor: - - platform: dlms_meter - # EVN - power_factor: - name: "Power Factor" - -text_sensor: - - platform: dlms_meter - # EVN - meternumber: - name: "meterNumber" - -<<: !include common.yaml diff --git a/tests/components/dlms_meter/common.yaml b/tests/components/dlms_meter/common.yaml index 6aa4e1b0ff..59d854a3ae 100644 --- a/tests/components/dlms_meter/common.yaml +++ b/tests/components/dlms_meter/common.yaml @@ -1,4 +1,16 @@ +dlms_meter: + id: dlms_meter_hub + receive_timeout: 50ms + decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" + auth_key: "11223344556677889900AABBCCDDEEFF" + skip_crc: true + provider: "netznoe" + custom_patterns: + - "custom_pattern_1" + - "custom_pattern_2" + sensor: + # Old Schema tests - platform: dlms_meter voltage_l1: name: "Voltage L1" @@ -20,8 +32,36 @@ sensor: name: "Active energy taken from grid" active_energy_minus: name: "Active energy put into grid" + reactive_energy_plus: + name: "Reactive energy taken from grid" + reactive_energy_minus: + name: "Reactive energy put into grid" + power_factor: + name: "Power factor" + + # Dynamic Schema tests + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "1-0:99.99.9" + name: "Custom Dynamic Sensor" text_sensor: + # Old Schema tests - platform: dlms_meter timestamp: name: "timestamp" + meternumber: + name: "Meter Number" + + # Dynamic Schema tests + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "0-0:99.99.9" + name: "Custom Dynamic Text Sensor" + +binary_sensor: + # Dynamic Schema tests (Binary sensors only use the dynamic schema) + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "0-1:2.3.4" + name: "Custom Binary Sensor" diff --git a/tests/components/dlms_meter/test.esp32-ard.yaml b/tests/components/dlms_meter/test.esp32-ard.yaml index c9910aa600..bd11a44373 100644 --- a/tests/components/dlms_meter/test.esp32-ard.yaml +++ b/tests/components/dlms_meter/test.esp32-ard.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp32-ard.yaml + uart: !include ../../test_build_components/common/uart/esp32-ard.yaml -<<: !include common-generic.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.esp32-idf.yaml b/tests/components/dlms_meter/test.esp32-idf.yaml index 1547532f1e..2d29656c94 100644 --- a/tests/components/dlms_meter/test.esp32-idf.yaml +++ b/tests/components/dlms_meter/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp32-idf.yaml + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml -<<: !include common-netznoe.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.esp8266-ard.yaml b/tests/components/dlms_meter/test.esp8266-ard.yaml index 119a1978de..5a05efa259 100644 --- a/tests/components/dlms_meter/test.esp8266-ard.yaml +++ b/tests/components/dlms_meter/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp8266-ard.yaml + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml -<<: !include common-generic.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.rp2040-ard.yaml b/tests/components/dlms_meter/test.rp2040-ard.yaml new file mode 100644 index 0000000000..f1df2daf83 --- /dev/null +++ b/tests/components/dlms_meter/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + +<<: !include common.yaml From 2310b9e3fe83c9765cc4a7f5eb9d8039b9b4da33 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Tue, 9 Jun 2026 20:27:37 +0200 Subject: [PATCH 1589/2214] [usb_uart] Add Prolific PL2303 USB-serial driver (#16885) --- esphome/components/usb_uart/__init__.py | 9 +- esphome/components/usb_uart/pl2303.cpp | 298 ++++++++++++++++++++++++ esphome/components/usb_uart/usb_uart.h | 25 ++ tests/components/usb_uart/common.yaml | 13 ++ 4 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 esphome/components/usb_uart/pl2303.cpp diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 7b9c320879..e42a2c092b 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -58,13 +58,20 @@ class Type: uart_types = ( Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), - Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4), Type("CH340", 0x1A86, 0x7523, "CH34X", 1), + Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), Type("FT232", 0x0403, 0x6001, "FT23XX", 1), Type("FT2232", 0x0403, 0x6010, "FT23XX", 2), Type("FT4232", 0x0403, 0x6011, "FT23XX", 4), + Type("PL2303", 0x067B, 0x2303, "PL2303", 1), + Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1), + Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1), + Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1), + Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1), + Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1), + Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1), Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), ) diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp new file mode 100644 index 0000000000..a50f1cf2d4 --- /dev/null +++ b/esphome/components/usb_uart/pl2303.cpp @@ -0,0 +1,298 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_uart.h" +#include "usb/usb_host.h" +#include "esphome/core/log.h" + +namespace esphome::usb_uart { + +// Control request types +static constexpr uint8_t SET_LINE_REQUEST_TYPE = 0x21; +static constexpr uint8_t SET_LINE_REQUEST = 0x20; + +static constexpr uint8_t SET_CONTROL_REQUEST_TYPE = 0x21; +static constexpr uint8_t SET_CONTROL_REQUEST = 0x22; +static constexpr uint8_t CONTROL_DTR = 0x01; +static constexpr uint8_t CONTROL_RTS = 0x02; + +static constexpr uint8_t VENDOR_WRITE_REQUEST_TYPE = 0x40; +static constexpr uint8_t VENDOR_WRITE_REQUEST = 0x01; + +static constexpr uint8_t VENDOR_READ_REQUEST_TYPE = 0xc0; +static constexpr uint8_t VENDOR_READ_REQUEST = 0x01; + +// Supported standard baud rates for direct encoding (TYPE_H, TYPE_HX, TYPE_HXD, TYPE_HXN) +static const uint32_t SUPPORTED_BAUD_RATES[] = { + 75, 150, 300, 600, 1200, 1800, 2400, 3600, 4800, 7200, 9600, 14400, 19200, + 28800, 38400, 57600, 115200, 230400, 460800, 614400, 921600, 1228800, 2457600, 3000000, 6000000, +}; + +static const char *pl2303_type_name(Pl2303ChipType type) { + switch (type) { + case PL2303_TYPE_H: + return "H (legacy)"; + case PL2303_TYPE_HX: + return "HX"; + case PL2303_TYPE_TA: + return "TA"; + case PL2303_TYPE_TB: + return "TB"; + case PL2303_TYPE_HXD: + return "HXD"; + case PL2303_TYPE_HXN: + return "G/HXN (newer)"; + default: + return "unknown"; + } +} + +// Find nearest supported baud rate for direct encoding +static uint32_t nearest_supported_baud(uint32_t baud) { + size_t n = sizeof(SUPPORTED_BAUD_RATES) / sizeof(SUPPORTED_BAUD_RATES[0]); + for (size_t i = 0; i < n; i++) { + if (SUPPORTED_BAUD_RATES[i] > baud) { + if (i == 0) + return SUPPORTED_BAUD_RATES[0]; + uint32_t lower = SUPPORTED_BAUD_RATES[i - 1]; + uint32_t upper = SUPPORTED_BAUD_RATES[i]; + return (upper - baud) > (baud - lower) ? lower : upper; + } + } + return SUPPORTED_BAUD_RATES[n - 1]; +} + +// Direct encoding: little-endian 32-bit baud rate value +static void encode_baud_direct(uint8_t buf[4], uint32_t baud) { + buf[0] = baud & 0xFF; + buf[1] = (baud >> 8) & 0xFF; + buf[2] = (baud >> 16) & 0xFF; + buf[3] = (baud >> 24) & 0xFF; +} + +// Divisor encoding for TYPE_HX, TYPE_HXD: baudrate = 12M*32 / (mantissa * 4^exponent) +static void encode_baud_divisor(uint8_t buf[4], uint32_t baud) { + static constexpr uint32_t BASELINE = 12000000 * 32; + uint32_t mantissa = BASELINE / baud; + if (mantissa == 0) + mantissa = 1; + uint8_t exponent = 0; + while (mantissa >= 512) { + if (exponent < 7) { + mantissa >>= 2; + exponent++; + } else { + mantissa = 511; + break; + } + } + buf[3] = 0x80; + buf[2] = 0; + buf[1] = (exponent << 1) | (mantissa >> 8); + buf[0] = mantissa & 0xFF; +} + +// Alt divisor encoding for TYPE_TA, TYPE_TB: baudrate = 12M*32 / (mantissa * 2^exponent) +static void encode_baud_divisor_alt(uint8_t buf[4], uint32_t baud) { + static constexpr uint32_t BASELINE = 12000000 * 32; + uint32_t mantissa = BASELINE / baud; + if (mantissa == 0) + mantissa = 1; + uint8_t exponent = 0; + while (mantissa >= 2048) { + if (exponent < 15) { + mantissa >>= 1; + exponent++; + } else { + mantissa = 2047; + break; + } + } + buf[3] = 0x80; + buf[2] = exponent & 0x01; + buf[1] = ((exponent & ~0x01) << 4) | (mantissa >> 8); + buf[0] = mantissa & 0xFF; +} + +std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev_hdl) { + const usb_config_desc_t *config_desc; + const usb_device_desc_t *device_desc; + std::vector cdc_devs{}; + + if (usb_host_get_device_descriptor(dev_hdl, &device_desc) != ESP_OK) { + ESP_LOGE(TAG, "PL2303: get_device_descriptor failed"); + return {}; + } + if (usb_host_get_active_config_descriptor(dev_hdl, &config_desc) != ESP_OK) { + ESP_LOGE(TAG, "PL2303: get_active_config_descriptor failed"); + return {}; + } + + // Detect chip type from USB descriptor fields (mirrors pl2303_detect_type in Linux driver) + uint16_t bcd_device = device_desc->bcdDevice; + uint16_t bcd_usb = device_desc->bcdUSB; + uint8_t bmax_packet = device_desc->bMaxPacketSize0; + uint8_t bdev_class = device_desc->bDeviceClass; + + if (bdev_class == 0x02 || bmax_packet != 0x40) { + this->chip_type_ = PL2303_TYPE_H; + } else { + switch (bcd_usb) { + case 0x0101: + case 0x0110: + this->chip_type_ = (bcd_device == 0x0400) ? PL2303_TYPE_HXD : PL2303_TYPE_HX; + break; + default: + // TA and TB are distinguishable by bcdDevice without any USB probe. + if (bcd_device == 0x0300) { + this->chip_type_ = PL2303_TYPE_TA; + } else if (bcd_device == 0x0500) { + this->chip_type_ = PL2303_TYPE_TB; + } else { + this->chip_type_ = PL2303_TYPE_HXN; + } + break; + } + } + + ESP_LOGI(TAG, "PL2303 chip type: %s (bcdUSB=0x%04X bcdDevice=0x%04X bMaxPkt=%u)", pl2303_type_name(this->chip_type_), + bcd_usb, bcd_device, bmax_packet); + + // PL2303 is single-port: find first interface with 2 bulk endpoints + int conf_offset = 0; + for (uint8_t i = 0; i < config_desc->bNumInterfaces; i++) { + int ep_offset = conf_offset; + const auto *intf = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset); + if (!intf) + break; + if (intf->bNumEndpoints < 2) + continue; + + const usb_ep_desc_t *in_ep = nullptr; + const usb_ep_desc_t *out_ep = nullptr; + const usb_ep_desc_t *notify_ep = nullptr; + + for (uint8_t e = 0; e < intf->bNumEndpoints; e++) { + ep_offset = conf_offset; + const auto *ep = usb_parse_endpoint_descriptor_by_index(intf, e, config_desc->wTotalLength, &ep_offset); + if (!ep) + break; + if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK) { + if (ep->bEndpointAddress & usb_host::USB_DIR_IN) { + in_ep = ep; + } else { + out_ep = ep; + } + } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { + notify_ep = ep; + } + } + + if (in_ep && out_ep) { + cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); + break; // PL2303 is single-port + } + } + + if (cdc_devs.empty()) + ESP_LOGE(TAG, "PL2303: failed to find bulk IN+OUT endpoints"); + + return cdc_devs; +} + +void USBUartTypePL2303::enable_channels() { + if (this->channels_.empty()) + return; + + auto *channel = this->channels_[0]; + bool is_legacy = (this->chip_type_ == PL2303_TYPE_H); + bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN); + + usb_host::transfer_cb_t nop_cb = [](const usb_host::TransferStatus &status) { + if (!status.success) + ESP_LOGW(TAG, "PL2303: vendor init transfer failed"); + }; + + // Init sequence for non-HXN chips (mirrors pl2303_startup in Linux driver): + // Read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, + // write 0x0404=1, read 0x8484, read 0x8383, + // write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+) + if (!is_hxn) { + uint8_t req = VENDOR_READ_REQUEST; + uint8_t wreq = VENDOR_WRITE_REQUEST; + + // Fire-and-forget vendor reads: result discarded, chip requires this sequence. + // Pass a 1-byte buffer to set wLength=1 so the IN data stage is performed. + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 0, nop_cb); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 1, nop_cb); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0, 1, nop_cb); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 1, 0, nop_cb); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 2, is_legacy ? 0x24 : 0x44, nop_cb); + } + + // Build 7-byte line coding structure: + // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits + uint8_t line_coding[7] = {}; + uint32_t baud = channel->get_baud_rate(); + + // Choose baud encoding based on chip type + uint32_t nearest = nearest_supported_baud(baud); + if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { + encode_baud_direct(line_coding, baud); + } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { + encode_baud_divisor_alt(line_coding, baud); + } else { + encode_baud_divisor(line_coding, baud); + } + + // Stop bits: 0=1, 1=1.5, 2=2 + switch (channel->get_stop_bits()) { + case 2: + line_coding[4] = 2; + break; + default: + line_coding[4] = 0; + break; + } + + // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space + switch (channel->parity_) { + case UART_CONFIG_PARITY_ODD: + line_coding[5] = 1; + break; + case UART_CONFIG_PARITY_EVEN: + line_coding[5] = 2; + break; + case UART_CONFIG_PARITY_MARK: + line_coding[5] = 3; + break; + case UART_CONFIG_PARITY_SPACE: + line_coding[5] = 4; + break; + default: + line_coding[5] = 0; + break; + } + + // Data bits + line_coding[6] = channel->get_data_bits(); + + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], + line_coding[6]); + + std::vector lc_vec(line_coding, line_coding + 7); + uint16_t iface = channel->cdc_dev_.bulk_interface_number; + this->control_transfer(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, nop_cb, lc_vec); + + // Assert DTR + RTS + this->control_transfer(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface, nop_cb); + + this->start_channels_(); +} + +} // namespace esphome::usb_uart +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 41dc2c546d..d0dccf42b9 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -16,6 +16,7 @@ namespace esphome::usb_uart { class USBUartTypeCdcAcm; class USBUartComponent; class USBUartChannel; +class USBUartTypePL2303; static const char *const TAG = "usb_uart"; @@ -130,6 +131,7 @@ class USBUartChannel : public uart::UARTComponent, public Parented parse_descriptors(usb_device_handle_t dev_hdl) override; + void enable_channels() override; + + Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; +}; + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index c8c1ee7df2..5b23f9d685 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -52,3 +52,16 @@ usb_uart: stop_bits: 2 data_bits: 7 parity: odd + - id: uart_7 + type: pl2303 + channels: + - id: channel_7_1 + baud_rate: 115200 + - id: uart_8 + type: pl2303gc + channels: + - id: channel_8_1 + baud_rate: 9600 + stop_bits: 2 + data_bits: 7 + parity: even From 7533835e044a2148a68e60fd7fe85776037a7acf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:03:31 -0400 Subject: [PATCH 1590/2214] Bump py7zr from 0.22.0 to 1.1.0 (#16901) 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 ed7f2c2941..62ed506e36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 -py7zr==0.22.0 +py7zr==1.1.0 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From eb6d6eac7dd1eaf274d733d236bb5cfafea5bdc6 Mon Sep 17 00:00:00 2001 From: Ricky Tsai <49546657+RT530@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:58:02 +1200 Subject: [PATCH 1591/2214] [xdb401] XDB401 Pressure Sensor (#15108) Co-authored-by: Ricky Tsai Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/xdb401/__init__.py | 1 + esphome/components/xdb401/sensor.py | 64 +++++++ esphome/components/xdb401/xdb401.cpp | 177 ++++++++++++++++++ esphome/components/xdb401/xdb401.h | 37 ++++ tests/components/xdb401/common.yaml | 10 + tests/components/xdb401/test.esp32-idf.yaml | 4 + tests/components/xdb401/test.esp8266-ard.yaml | 4 + 8 files changed, 298 insertions(+) create mode 100644 esphome/components/xdb401/__init__.py create mode 100644 esphome/components/xdb401/sensor.py create mode 100644 esphome/components/xdb401/xdb401.cpp create mode 100644 esphome/components/xdb401/xdb401.h create mode 100644 tests/components/xdb401/common.yaml create mode 100644 tests/components/xdb401/test.esp32-idf.yaml create mode 100644 tests/components/xdb401/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c69f8bccd4..a64d6f3daf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -596,6 +596,7 @@ esphome/components/wk2212_spi/* @DrCoolZic esphome/components/wl_134/* @hobbypunk90 esphome/components/wts01/* @alepee esphome/components/x9c/* @EtienneMD +esphome/components/xdb401/* @RT530 esphome/components/xgzp68xx/* @gcormier esphome/components/xiaomi_hhccjcy10/* @fariouche esphome/components/xiaomi_lywsd02mmc/* @juanluss31 diff --git a/esphome/components/xdb401/__init__.py b/esphome/components/xdb401/__init__.py new file mode 100644 index 0000000000..943139e19a --- /dev/null +++ b/esphome/components/xdb401/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@RT530"] diff --git a/esphome/components/xdb401/sensor.py b/esphome/components/xdb401/sensor.py new file mode 100644 index 0000000000..7545343f02 --- /dev/null +++ b/esphome/components/xdb401/sensor.py @@ -0,0 +1,64 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_PRESSURE, + CONF_TEMPERATURE, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PASCAL, +) + +DEPENDENCIES = ["i2c"] + +CONF_PRESSURE_RANGE_BAR = "pressure_range_bar" + +xdb401_ns = cg.esphome_ns.namespace("xdb401") + +XDB401Component = xdb401_ns.class_( + "XDB401Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XDB401Component), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE): sensor.sensor_schema( + unit_of_measurement=UNIT_PASCAL, + accuracy_decimals=0, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE_RANGE_BAR, default=10): cv.one_of( + 1, 2, 5, 10, 20, 50, 100, int=True + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x7F)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_pressure_range_bar(config[CONF_PRESSURE_RANGE_BAR])) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature_sensor(sens)) + + if pressure_config := config.get(CONF_PRESSURE): + sens = await sensor.new_sensor(pressure_config) + cg.add(var.set_pressure_sensor(sens)) diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp new file mode 100644 index 0000000000..3a24d63760 --- /dev/null +++ b/esphome/components/xdb401/xdb401.cpp @@ -0,0 +1,177 @@ +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "xdb401.h" + +namespace esphome::xdb401 { + +static const char *const TAG = "xdb401"; + +static const uint8_t REG_PRESSURE = 0x06; +static const uint8_t REG_TEMPERATURE = 0x09; +static const uint8_t REG_MAKE_MEASURE = 0x30; +static const uint8_t CMD_MAKE_MEASURE = 0x0A; +static const uint8_t MASK_MEASURE_READY = 0x08; +static const float CONVERT_PRESSURE = 8388608.0f; // 0x800000 + +static const uint32_t CHECK_DELAY = 5; +static const uint8_t CHECK_ATTEMPTS = 6; +static const uint8_t MARK_FAIL_AFTER = 5; + +void XDB401Component::setup() { + ESP_LOGCONFIG(TAG, "Running setup"); + + uint8_t meas_resp[1] = {}; + i2c::ErrorCode err_code = this->read_register(REG_MAKE_MEASURE, meas_resp, sizeof(meas_resp)); + if (err_code != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("I2C communication failed")); + return; + } + + this->comm_err_counter_ = 0; +} + +void XDB401Component::dump_config() { + ESP_LOGCONFIG(TAG, "XDB401:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Pressure Range: %u bar", this->pressure_range_bar_); + LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +} + +void XDB401Component::handle_comm_failure_(const char *message) { + this->status_set_warning(message); + + if (this->comm_err_counter_ >= MARK_FAIL_AFTER) { + this->mark_failed(LOG_STR("Too many consecutive I2C communication errors")); + } else { + this->comm_err_counter_++; + } + + this->measurement_in_progress_ = false; +} + +i2c::ErrorCode XDB401Component::start_measurement_() { + i2c::ErrorCode err_code = this->write_register(REG_MAKE_MEASURE, &CMD_MAKE_MEASURE, sizeof(CMD_MAKE_MEASURE)); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error starting measurement, code: %u", err_code); + return err_code; + } + + return i2c::ERROR_OK; +} + +void XDB401Component::check_measurement_ready_(uint8_t attempt) { + uint8_t meas_resp[1] = {}; + i2c::ErrorCode err_code = this->read_register(REG_MAKE_MEASURE, meas_resp, sizeof(meas_resp)); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error reading measurement status, code: %u", err_code); + this->handle_comm_failure_("I2C communication failed"); + return; + } + + ESP_LOGV(TAG, "Config response %02X", meas_resp[0]); + + // Bit 3 shall be 0 when measurement is ready + if ((meas_resp[0] & MASK_MEASURE_READY) == 0) { + ESP_LOGV(TAG, "Meas mode entered after %u ms", attempt * CHECK_DELAY); + this->read_measurement_(); + return; + } + + if (attempt >= CHECK_ATTEMPTS) { + ESP_LOGE(TAG, "Device not in measurement mode after timeout of %u ms", CHECK_DELAY * CHECK_ATTEMPTS); + this->handle_comm_failure_("Measurement timeout"); + return; + } + + this->set_timeout(CHECK_DELAY, [this, attempt]() { this->check_measurement_ready_(attempt + 1); }); +} + +void XDB401Component::read_measurement_() { + float temperature{}; + float pressure{}; + + i2c::ErrorCode err_code = this->read_pressure_(pressure); + if (err_code != i2c::ERROR_OK) { + this->handle_comm_failure_("Could not read pressure data"); + return; + } + + err_code = this->read_temperature_(temperature); + if (err_code != i2c::ERROR_OK) { + this->handle_comm_failure_("Could not read temperature data"); + return; + } + + ESP_LOGD(TAG, "Got pressure=%.1f Pa, temperature=%.2f°C", pressure, temperature); + + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + if (this->pressure_sensor_ != nullptr) + this->pressure_sensor_->publish_state(pressure); + + this->comm_err_counter_ = 0; + this->status_clear_warning(); + this->measurement_in_progress_ = false; +} + +i2c::ErrorCode XDB401Component::read_pressure_(float &pressure) { + uint8_t p_data[3]{}; + i2c::ErrorCode err_code = this->read_register(REG_PRESSURE, p_data, 3); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error reading pressure register"); + return err_code; + } + char pressure_buf[format_hex_pretty_size(3)]; + format_hex_pretty_to(pressure_buf, sizeof(pressure_buf), p_data, 3); + ESP_LOGV(TAG, "Got pressure data: %s", pressure_buf); + + // Sign-extend 24-bit big-endian pressure value to int32_t. + int32_t raw_pressure = static_cast(encode_uint24(p_data[0], p_data[1], p_data[2]) << 8) >> 8; + ESP_LOGD(TAG, "Pressure data raw %i", raw_pressure); + + pressure = (static_cast(raw_pressure) / CONVERT_PRESSURE) * + XDB401Component::full_scale_pressure_pa(this->pressure_range_bar_); + + return err_code; +} + +i2c::ErrorCode XDB401Component::read_temperature_(float &temperature) { + uint8_t t_data[2]{}; + i2c::ErrorCode err_code = this->read_register(REG_TEMPERATURE, t_data, 2); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error reading temperature register"); + return err_code; + } + + char temperature_buf[format_hex_pretty_size(2)]; + format_hex_pretty_to(temperature_buf, sizeof(temperature_buf), t_data, 2); + ESP_LOGV(TAG, "Got temperature data: %s", temperature_buf); + + // Temperature is a signed 16-bit big-endian value in 1/256 °C (Q8.8 fixed point). + int16_t raw_temperature = static_cast(encode_uint16(t_data[0], t_data[1])); + ESP_LOGD(TAG, "Temperature data raw %i", raw_temperature); + + temperature = static_cast(raw_temperature) / 256.0f; + + return err_code; +} + +void XDB401Component::update() { + if (this->measurement_in_progress_) { + ESP_LOGV(TAG, "Skipping update, measurement already in progress"); + return; + } + + i2c::ErrorCode err_code = this->start_measurement_(); + if (err_code != i2c::ERROR_OK) { + this->handle_comm_failure_("I2C communication failed"); + return; + } + + this->measurement_in_progress_ = true; + this->set_timeout(CHECK_DELAY, [this]() { this->check_measurement_ready_(1); }); +} + +} // namespace esphome::xdb401 diff --git a/esphome/components/xdb401/xdb401.h b/esphome/components/xdb401/xdb401.h new file mode 100644 index 0000000000..674d26fe8e --- /dev/null +++ b/esphome/components/xdb401/xdb401.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::xdb401 { + +class XDB401Component : public PollingComponent, public i2c::I2CDevice { + public: + void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } + void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } + void set_pressure_range_bar(uint8_t pressure_range_bar) { this->pressure_range_bar_ = pressure_range_bar; } + + void setup() override; + void dump_config() override; + void update() override; + + protected: + void handle_comm_failure_(const char *message); + i2c::ErrorCode start_measurement_(); + void check_measurement_ready_(uint8_t attempt); + void read_measurement_(); + i2c::ErrorCode read_pressure_(float &pressure); + i2c::ErrorCode read_temperature_(float &temperature); + + static constexpr float full_scale_pressure_pa(uint8_t pressure_range_bar) { return pressure_range_bar * 100000.0f; } + + uint8_t comm_err_counter_{0}; + bool measurement_in_progress_{false}; + uint8_t pressure_range_bar_{10}; + + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *pressure_sensor_{nullptr}; +}; + +} // namespace esphome::xdb401 diff --git a/tests/components/xdb401/common.yaml b/tests/components/xdb401/common.yaml new file mode 100644 index 0000000000..feaa46010e --- /dev/null +++ b/tests/components/xdb401/common.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xdb401 + update_interval: 1s + i2c_id: i2c_bus + + temperature: + name: water temperature + + pressure: + name: water pressure diff --git a/tests/components/xdb401/test.esp32-idf.yaml b/tests/components/xdb401/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/xdb401/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/xdb401/test.esp8266-ard.yaml b/tests/components/xdb401/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/xdb401/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml From 4f62bb7171fb30762c3c941ea66e89024ca4a714 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:04:28 +1000 Subject: [PATCH 1592/2214] [bmi270] Support Bosch BMI270 IMU (#16202) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bmi270/__init__.py | 10 + esphome/components/bmi270/bmi270.cpp | 209 +++++++++ esphome/components/bmi270/bmi270.h | 108 +++++ esphome/components/bmi270/bmi270_config.h | 483 ++++++++++++++++++++ esphome/components/bmi270/motion.py | 91 ++++ esphome/components/bmi270/sensor.py | 41 ++ esphome/components/const/__init__.py | 4 + tests/components/bmi270/common.yaml | 68 +++ tests/components/bmi270/test.esp32-idf.yaml | 4 + 10 files changed, 1019 insertions(+) create mode 100644 esphome/components/bmi270/__init__.py create mode 100644 esphome/components/bmi270/bmi270.cpp create mode 100644 esphome/components/bmi270/bmi270.h create mode 100644 esphome/components/bmi270/bmi270_config.h create mode 100644 esphome/components/bmi270/motion.py create mode 100644 esphome/components/bmi270/sensor.py create mode 100644 tests/components/bmi270/common.yaml create mode 100644 tests/components/bmi270/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index a64d6f3daf..300ae13cf4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -83,6 +83,7 @@ esphome/components/bme680_bsec/* @trvrnrth esphome/components/bme68x_bsec2/* @kbx81 @neffs esphome/components/bme68x_bsec2_i2c/* @kbx81 @neffs esphome/components/bmi160/* @flaviut +esphome/components/bmi270/* @clydebarrow esphome/components/bmp280_base/* @ademuri esphome/components/bmp280_i2c/* @ademuri esphome/components/bmp280_spi/* @ademuri diff --git a/esphome/components/bmi270/__init__.py b/esphome/components/bmi270/__init__.py new file mode 100644 index 0000000000..0e67e41a0e --- /dev/null +++ b/esphome/components/bmi270/__init__.py @@ -0,0 +1,10 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] + +CONF_BMI270_ID = "bmi270_id" +# C++ namespace / class +bmi270_ns = cg.esphome_ns.namespace("bmi270") +BMI270Component = bmi270_ns.class_("BMI270Component", MotionComponent, i2c.I2CDevice) diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp new file mode 100644 index 0000000000..acb93158d4 --- /dev/null +++ b/esphome/components/bmi270/bmi270.cpp @@ -0,0 +1,209 @@ +#include "bmi270.h" +#include "bmi270_config.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::bmi270 { + +static const char *const TAG = "bmi270"; + +#if defined(USE_ARDUINO) && !defined(USE_ESP32) +static const size_t MAX_I2C_BUFFER_SIZE = 32; +#else +static const size_t MAX_I2C_BUFFER_SIZE = 256; +#endif + +// Configuration blob upload +// The BMI270 requires a firmware config blob to be written to its internal +// memory after every power-on before sensors can be used. + +bool BMI270Component::load_config_file_() { + // 1. Disable advanced power-save so the config port is accessible + if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00)) + return false; + delay(1); + + // 2. Prepare config load: write 0x00 to INIT_CTRL to start + if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00)) + return false; + + // 3. Burst-write the config in pages + const uint8_t *cfg = BMI270_CONFIG_FILE; + constexpr size_t cfg_len = sizeof(BMI270_CONFIG_FILE); + size_t index = 0; + + while (index != cfg_len) { + // Set the page address in INIT_ADDR registers + uint8_t addr_lsb = (uint8_t) ((index / 2) & 0x0F); + uint8_t addr_msb = (uint8_t) ((index / 2) >> 4); + if (!this->write_byte(BMI270_REG_INIT_ADDR_0, addr_lsb)) + return false; + if (!this->write_byte(BMI270_REG_INIT_ADDR_0 + 1, addr_msb)) + return false; + + // Write a burst of up to the maximum allowed size + size_t burst = clamp_at_most(cfg_len - index, MAX_I2C_BUFFER_SIZE); + if (this->write_register(BMI270_REG_INIT_DATA, cfg + index, burst) != i2c::ERROR_OK) + return false; + + index += burst; + } + + // 4. Signal end of config load + if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01)) + return false; + delay(20); // spec: wait ≥20 ms for init to complete + + // 5. Check INTERNAL_STATUS: bit[0:3] should be 0x01 ("initialisation OK") + uint8_t status = 0; + if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status)) + return false; + if ((status & 0x0F) != 0x01) { + ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status); + return false; + } + return true; +} + +// setup() ─ + +void BMI270Component::setup() { + MotionComponent::setup(); + // 1. Verify chip ID + uint8_t chip_id = 0; + if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) { + ESP_LOGE(TAG, "Failed to read chip ID – check wiring / address"); + this->mark_failed(); + return; + } + if (chip_id != BMI270_CHIP_ID_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", chip_id, BMI270_CHIP_ID_VALUE); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); + + // 2. Soft-reset via CMD register (0x7E = 0xB6) + if (!this->write_byte(0x7E, 0xB6)) { + this->mark_failed(); + return; + } + delay(20); + + // 4. Upload the configuration blob + if (!load_config_file_()) { + ESP_LOGE(TAG, "Config file upload failed"); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Config blob uploaded ✓"); + + // 5. Configure accelerometer + // ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1) + uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7); + if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) { + this->mark_failed(); + return; + } + if (!this->write_byte(BMI270_REG_ACC_RANGE, (uint8_t) accel_range_)) { + this->mark_failed(); + return; + } + + // 6. Configure gyroscope + // GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1) + uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7); + if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) { + this->mark_failed(); + return; + } + if (!this->write_byte(BMI270_REG_GYR_RANGE, (uint8_t) gyro_range_)) { + this->mark_failed(); + return; + } + + // 7. Enable accelerometer, gyroscope, and temperature sensor + // PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1] + if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) { + this->mark_failed(); + return; + } + delay(5); + + // 8. Re-enable advanced power save (optional; keeps current low between reads) + // Disabled here for simplicity – leave in performance mode + if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup + this->mark_failed(); + return; + } + + ESP_LOGCONFIG(TAG, "BMI270 initialised successfully"); +} + +void BMI270Component::dump_config() { + ESP_LOGCONFIG(TAG, "BMI270 IMU:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, " Communication failed!"); + return; + } + + static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; + static constexpr const char *const GYRO_RANGE_STRS[] = {"±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"}; + + ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); + ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); + MotionComponent::dump_config(); +} + +// update() ─ +// Reads all 6 axes + temperature in one block + +bool BMI270Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) + uint8_t raw_data[REG_READ_LEN]; + if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) { + ESP_LOGW(TAG, "Failed to read IMU data"); + return false; + } + // Scale factor: LSB/g depends on range + // raw is a signed 16-bit value; full-scale = range_g * 2^15 lsb + static constexpr float ACCEL_SCALE[] = { + 2.0f / 32768.0f, + 4.0f / 32768.0f, + 8.0f / 32768.0f, + 16.0f / 32768.0f, + }; + float scale = ACCEL_SCALE[this->accel_range_]; + + data.acceleration[motion::X_AXIS] = (int16_t) ((raw_data[1] << 8) | raw_data[0]) * scale; + data.acceleration[motion::Y_AXIS] = (int16_t) ((raw_data[3] << 8) | raw_data[2]) * scale; + data.acceleration[motion::Z_AXIS] = (int16_t) ((raw_data[5] << 8) | raw_data[4]) * scale; + + // Gyroscope: registers 0x12–0x17 (6 bytes) + // Scale: full-scale range / 2^15 + static constexpr float GYRO_SCALE[] = { + 2000.0f / 32768.0f, 1000.0f / 32768.0f, 500.0f / 32768.0f, 250.0f / 32768.0f, 125.0f / 32768.0f, + }; + static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8; + scale = GYRO_SCALE[this->gyro_range_]; + + data.angular_rate[motion::X_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 1] << 8) | raw_data[GYR_OFFS + 0]) * scale; + data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale; + data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale; + + if (this->temperature_callback_.empty()) + return true; + // Temperature: registers 0x22–0x23 + // Formula from datasheet: T[°C] = raw / 512 + 23 + static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + float temperature = (raw_t / 512.0f) + 23.0f; + this->temperature_callback_.call(temperature); + return true; +} + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h new file mode 100644 index 0000000000..7c5a2db015 --- /dev/null +++ b/esphome/components/bmi270/bmi270.h @@ -0,0 +1,108 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" +#include + +namespace esphome::bmi270 { + +// Register map +static const uint8_t BMI270_REG_CHIP_ID = 0x00; +static const uint8_t BMI270_REG_ERR_REG = 0x02; +static const uint8_t BMI270_REG_STATUS = 0x03; +static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB +static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB +static const uint8_t BMI270_REG_TEMP_0 = 0x22; +static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) + +static constexpr uint8_t REG_READ_LEN = + BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 + + 1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4) + +static const uint8_t BMI270_REG_PWR_CONF = 0x7C; +static const uint8_t BMI270_REG_PWR_CTRL = 0x7D; +static const uint8_t BMI270_REG_INIT_CTRL = 0x59; +static const uint8_t BMI270_REG_INIT_DATA = 0x5E; +static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; +static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; +static const uint8_t BMI270_REG_ACC_CONF = 0x40; +static const uint8_t BMI270_REG_ACC_RANGE = 0x41; +static const uint8_t BMI270_REG_GYR_CONF = 0x42; +static const uint8_t BMI270_REG_GYR_RANGE = 0x43; + +static const uint8_t BMI270_CHIP_ID_VALUE = 0x24; + +// Accelerometer range options +enum BMI270AccelRange : uint8_t { + BMI270_ACCEL_RANGE_2G = 0x00, + BMI270_ACCEL_RANGE_4G = 0x01, + BMI270_ACCEL_RANGE_8G = 0x02, + BMI270_ACCEL_RANGE_16G = 0x03, +}; + +// Accelerometer ODR options +enum BMI270AccelODR : uint8_t { + BMI270_ACCEL_ODR_12_5 = 0x05, + BMI270_ACCEL_ODR_25 = 0x06, + BMI270_ACCEL_ODR_50 = 0x07, + BMI270_ACCEL_ODR_100 = 0x08, + BMI270_ACCEL_ODR_200 = 0x09, + BMI270_ACCEL_ODR_400 = 0x0A, + BMI270_ACCEL_ODR_800 = 0x0B, + BMI270_ACCEL_ODR_1600 = 0x0C, +}; + +// Gyroscope range options +enum BMI270GyroRange : uint8_t { + BMI270_GYRO_RANGE_2000 = 0x00, + BMI270_GYRO_RANGE_1000 = 0x01, + BMI270_GYRO_RANGE_500 = 0x02, + BMI270_GYRO_RANGE_250 = 0x03, + BMI270_GYRO_RANGE_125 = 0x04, +}; + +// Gyroscope ODR options +enum BMI270GyroODR : uint8_t { + BMI270_GYRO_ODR_25 = 0x06, + BMI270_GYRO_ODR_50 = 0x07, + BMI270_GYRO_ODR_100 = 0x08, + BMI270_GYRO_ODR_200 = 0x09, + BMI270_GYRO_ODR_400 = 0x0A, + BMI270_GYRO_ODR_800 = 0x0B, + BMI270_GYRO_ODR_1600 = 0x0C, + BMI270_GYRO_ODR_3200 = 0x0D, +}; + +// ---Data class + +// Main component class +class BMI270Component : public motion::MotionComponent, public i2c::I2CDevice { + public: + // Lifecycle + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters + void set_accel_range(BMI270AccelRange r) { this->accel_range_ = r; } + void set_accel_odr(BMI270AccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(BMI270GyroODR o) { this->gyro_odr_ = o; } + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + bool load_config_file_(); + + // Config + BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G}; + BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100}; + BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000}; + BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmi270_config.h b/esphome/components/bmi270/bmi270_config.h new file mode 100644 index 0000000000..4243f4e157 --- /dev/null +++ b/esphome/components/bmi270/bmi270_config.h @@ -0,0 +1,483 @@ +#pragma once +#include + +namespace esphome::bmi270 { + +/** + BMI270 configuration file (chip ID 0x24, firmware v2.86.1) + Source: Bosch Sensortec BMI270_SensorAPI (BSD-3-Clause) + https://github.com/boschsensortec/BMI270_SensorAPI + +Copyright (c) 2023 Bosch Sensortec GmbH. All rights reserved. + +BSD-3-Clause + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + + This blob MUST be written to the chip's internal INIT_DATA register + after every power cycle, before any sensor data can be read. + --------------------------------------------------------------------------- */ + +static constexpr uint8_t BMI270_CONFIG_FILE[] = { + 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x3d, 0xb1, 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x91, 0x03, 0x80, 0x2e, 0xbc, + 0xb0, 0x80, 0x2e, 0xa3, 0x03, 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x00, 0xb0, 0x50, 0x30, 0x21, 0x2e, 0x59, 0xf5, + 0x10, 0x30, 0x21, 0x2e, 0x6a, 0xf5, 0x80, 0x2e, 0x3b, 0x03, 0x00, 0x00, 0x00, 0x00, 0x08, 0x19, 0x01, 0x00, 0x22, + 0x00, 0x75, 0x00, 0x00, 0x10, 0x00, 0x10, 0xd1, 0x00, 0xb3, 0x43, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0xe0, 0x5f, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x92, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x19, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, + 0xe0, 0xaa, 0x38, 0x05, 0xe0, 0x90, 0x30, 0xfa, 0x00, 0x96, 0x00, 0x4b, 0x09, 0x11, 0x00, 0x11, 0x00, 0x02, 0x00, + 0x2d, 0x01, 0xd4, 0x7b, 0x3b, 0x01, 0xdb, 0x7a, 0x04, 0x00, 0x3f, 0x7b, 0xcd, 0x6c, 0xc3, 0x04, 0x85, 0x09, 0xc3, + 0x04, 0xec, 0xe6, 0x0c, 0x46, 0x01, 0x00, 0x27, 0x00, 0x19, 0x00, 0x96, 0x00, 0xa0, 0x00, 0x01, 0x00, 0x0c, 0x00, + 0xf0, 0x3c, 0x00, 0x01, 0x01, 0x00, 0x03, 0x00, 0x01, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x32, 0x00, 0x05, 0x00, 0xee, + 0x06, 0x04, 0x00, 0xc8, 0x00, 0x00, 0x00, 0x04, 0x00, 0xa8, 0x05, 0xee, 0x06, 0x00, 0x04, 0xbc, 0x02, 0xb3, 0x00, + 0x85, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xb4, 0x00, 0x01, 0x00, 0xb9, 0x00, 0x01, 0x00, 0x98, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x80, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x2e, 0x00, 0xc1, 0xfd, 0x2d, 0xde, + 0x00, 0xeb, 0x00, 0xda, 0x00, 0x00, 0x0c, 0xff, 0x0f, 0x00, 0x04, 0xc0, 0x00, 0x5b, 0xf5, 0xc9, 0x01, 0x1e, 0xf2, + 0x80, 0x00, 0x3f, 0xff, 0x19, 0xf4, 0x58, 0xf5, 0x66, 0xf5, 0x64, 0xf5, 0xc0, 0xf1, 0xf0, 0x00, 0xe0, 0x00, 0xcd, + 0x01, 0xd3, 0x01, 0xdb, 0x01, 0xff, 0x7f, 0xff, 0x01, 0xe4, 0x00, 0x74, 0xf7, 0xf3, 0x00, 0xfa, 0x00, 0xff, 0x3f, + 0xca, 0x03, 0x6c, 0x38, 0x56, 0xfe, 0x44, 0xfd, 0xbc, 0x02, 0xf9, 0x06, 0x00, 0xfc, 0x12, 0x02, 0xae, 0x01, 0x58, + 0xfa, 0x9a, 0xfd, 0x77, 0x05, 0xbb, 0x02, 0x96, 0x01, 0x95, 0x01, 0x7f, 0x01, 0x82, 0x01, 0x89, 0x01, 0x87, 0x01, + 0x88, 0x01, 0x8a, 0x01, 0x8c, 0x01, 0x8f, 0x01, 0x8d, 0x01, 0x92, 0x01, 0x91, 0x01, 0xdd, 0x00, 0x9f, 0x01, 0x7e, + 0x01, 0xdb, 0x00, 0xb6, 0x01, 0x70, 0x69, 0x26, 0xd3, 0x9c, 0x07, 0x1f, 0x05, 0x9d, 0x00, 0x00, 0x08, 0xbc, 0x05, + 0x37, 0xfa, 0xa2, 0x01, 0xaa, 0x01, 0xa1, 0x01, 0xa8, 0x01, 0xa0, 0x01, 0xa8, 0x05, 0xb4, 0x01, 0xb4, 0x01, 0xce, + 0x00, 0xd0, 0x00, 0xfc, 0x00, 0xc5, 0x01, 0xff, 0xfb, 0xb1, 0x00, 0x00, 0x38, 0x00, 0x30, 0xfd, 0xf5, 0xfc, 0xf5, + 0xcd, 0x01, 0xa0, 0x00, 0x5f, 0xff, 0x00, 0x40, 0xff, 0x00, 0x00, 0x80, 0x6d, 0x0f, 0xeb, 0x00, 0x7f, 0xff, 0xc2, + 0xf5, 0x68, 0xf7, 0xb3, 0xf1, 0x67, 0x0f, 0x5b, 0x0f, 0x61, 0x0f, 0x80, 0x0f, 0x58, 0xf7, 0x5b, 0xf7, 0x83, 0x0f, + 0x86, 0x00, 0x72, 0x0f, 0x85, 0x0f, 0xc6, 0xf1, 0x7f, 0x0f, 0x6c, 0xf7, 0x00, 0xe0, 0x00, 0xff, 0xd1, 0xf5, 0x87, + 0x0f, 0x8a, 0x0f, 0xff, 0x03, 0xf0, 0x3f, 0x8b, 0x00, 0x8e, 0x00, 0x90, 0x00, 0xb9, 0x00, 0x2d, 0xf5, 0xca, 0xf5, + 0xcb, 0x01, 0x20, 0xf2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x50, 0x98, 0x2e, + 0xd7, 0x0e, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x00, 0x30, 0xf0, 0x7f, 0x00, 0x2e, 0x00, 0x2e, 0xd0, 0x2e, 0x00, + 0x2e, 0x01, 0x80, 0x08, 0xa2, 0xfb, 0x2f, 0x98, 0x2e, 0xba, 0x03, 0x21, 0x2e, 0x19, 0x00, 0x01, 0x2e, 0xee, 0x00, + 0x00, 0xb2, 0x07, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x03, 0x2f, 0x01, 0x50, 0x03, 0x52, 0x98, 0x2e, 0x07, + 0xcc, 0x01, 0x2e, 0xdd, 0x00, 0x00, 0xb2, 0x27, 0x2f, 0x05, 0x2e, 0x8a, 0x00, 0x05, 0x52, 0x98, 0x2e, 0xc7, 0xc1, + 0x03, 0x2e, 0xe9, 0x00, 0x40, 0xb2, 0xf0, 0x7f, 0x08, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x04, 0x2f, 0x00, + 0x30, 0x21, 0x2e, 0xe9, 0x00, 0x98, 0x2e, 0xb4, 0xb1, 0x01, 0x2e, 0x18, 0x00, 0x00, 0xb2, 0x10, 0x2f, 0x05, 0x50, + 0x98, 0x2e, 0x4d, 0xc3, 0x05, 0x50, 0x98, 0x2e, 0x5a, 0xc7, 0x98, 0x2e, 0xf9, 0xb4, 0x98, 0x2e, 0x54, 0xb2, 0x98, + 0x2e, 0x67, 0xb6, 0x98, 0x2e, 0x17, 0xb2, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x01, 0x2e, 0xef, 0x00, 0x00, 0xb2, + 0x04, 0x2f, 0x98, 0x2e, 0x7a, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0xef, 0x00, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0xae, 0x0b, + 0x2f, 0x01, 0x2e, 0xdd, 0x00, 0x00, 0xb2, 0x07, 0x2f, 0x05, 0x52, 0x98, 0x2e, 0x8e, 0x0e, 0x00, 0xb2, 0x02, 0x2f, + 0x10, 0x30, 0x21, 0x2e, 0x7d, 0x00, 0x01, 0x2e, 0x7d, 0x00, 0x00, 0x90, 0x90, 0x2e, 0xf1, 0x02, 0x01, 0x2e, 0xd7, + 0x00, 0x00, 0xb2, 0x04, 0x2f, 0x98, 0x2e, 0x2f, 0x0e, 0x00, 0x30, 0x21, 0x2e, 0x7b, 0x00, 0x01, 0x2e, 0x7b, 0x00, + 0x00, 0xb2, 0x12, 0x2f, 0x01, 0x2e, 0xd4, 0x00, 0x00, 0x90, 0x02, 0x2f, 0x98, 0x2e, 0x1f, 0x0e, 0x09, 0x2d, 0x98, + 0x2e, 0x81, 0x0d, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0x90, 0x02, 0x2f, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x00, 0x30, + 0x21, 0x2e, 0x7b, 0x00, 0x01, 0x2e, 0x7c, 0x00, 0x00, 0xb2, 0x90, 0x2e, 0x09, 0x03, 0x01, 0x2e, 0x7c, 0x00, 0x01, + 0x31, 0x01, 0x08, 0x00, 0xb2, 0x04, 0x2f, 0x98, 0x2e, 0x47, 0xcb, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x81, 0x30, + 0x01, 0x2e, 0x7c, 0x00, 0x01, 0x08, 0x00, 0xb2, 0x61, 0x2f, 0x03, 0x2e, 0x89, 0x00, 0x01, 0x2e, 0xd4, 0x00, 0x98, + 0xbc, 0x98, 0xb8, 0x05, 0xb2, 0x0f, 0x58, 0x23, 0x2f, 0x07, 0x90, 0x09, 0x54, 0x00, 0x30, 0x37, 0x2f, 0x15, 0x41, + 0x04, 0x41, 0xdc, 0xbe, 0x44, 0xbe, 0xdc, 0xba, 0x2c, 0x01, 0x61, 0x00, 0x0f, 0x56, 0x4a, 0x0f, 0x0c, 0x2f, 0xd1, + 0x42, 0x94, 0xb8, 0xc1, 0x42, 0x11, 0x30, 0x05, 0x2e, 0x6a, 0xf7, 0x2c, 0xbd, 0x2f, 0xb9, 0x80, 0xb2, 0x08, 0x22, + 0x98, 0x2e, 0xc3, 0xb7, 0x21, 0x2d, 0x61, 0x30, 0x23, 0x2e, 0xd4, 0x00, 0x98, 0x2e, 0xc3, 0xb7, 0x00, 0x30, 0x21, + 0x2e, 0x5a, 0xf5, 0x18, 0x2d, 0xe1, 0x7f, 0x50, 0x30, 0x98, 0x2e, 0xfa, 0x03, 0x0f, 0x52, 0x07, 0x50, 0x50, 0x42, + 0x70, 0x30, 0x0d, 0x54, 0x42, 0x42, 0x7e, 0x82, 0xe2, 0x6f, 0x80, 0xb2, 0x42, 0x42, 0x05, 0x2f, 0x21, 0x2e, 0xd4, + 0x00, 0x10, 0x30, 0x98, 0x2e, 0xc3, 0xb7, 0x03, 0x2d, 0x60, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x01, 0x2e, 0xd4, 0x00, + 0x06, 0x90, 0x18, 0x2f, 0x01, 0x2e, 0x76, 0x00, 0x0b, 0x54, 0x07, 0x52, 0xe0, 0x7f, 0x98, 0x2e, 0x7a, 0xc1, 0xe1, + 0x6f, 0x08, 0x1a, 0x40, 0x30, 0x08, 0x2f, 0x21, 0x2e, 0xd4, 0x00, 0x20, 0x30, 0x98, 0x2e, 0xaf, 0xb7, 0x50, 0x32, + 0x98, 0x2e, 0xfa, 0x03, 0x05, 0x2d, 0x98, 0x2e, 0x38, 0x0e, 0x00, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x00, 0x30, 0x21, + 0x2e, 0x7c, 0x00, 0x18, 0x2d, 0x01, 0x2e, 0xd4, 0x00, 0x03, 0xaa, 0x01, 0x2f, 0x98, 0x2e, 0x45, 0x0e, 0x01, 0x2e, + 0xd4, 0x00, 0x3f, 0x80, 0x03, 0xa2, 0x01, 0x2f, 0x00, 0x2e, 0x02, 0x2d, 0x98, 0x2e, 0x5b, 0x0e, 0x30, 0x30, 0x98, + 0x2e, 0xce, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0x7d, 0x00, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x01, 0x2e, 0x77, 0x00, + 0x00, 0xb2, 0x24, 0x2f, 0x98, 0x2e, 0xf5, 0xcb, 0x03, 0x2e, 0xd5, 0x00, 0x11, 0x54, 0x01, 0x0a, 0xbc, 0x84, 0x83, + 0x86, 0x21, 0x2e, 0xc9, 0x01, 0xe0, 0x40, 0x13, 0x52, 0xc4, 0x40, 0x82, 0x40, 0xa8, 0xb9, 0x52, 0x42, 0x43, 0xbe, + 0x53, 0x42, 0x04, 0x0a, 0x50, 0x42, 0xe1, 0x7f, 0xf0, 0x31, 0x41, 0x40, 0xf2, 0x6f, 0x25, 0xbd, 0x08, 0x08, 0x02, + 0x0a, 0xd0, 0x7f, 0x98, 0x2e, 0xa8, 0xcf, 0x06, 0xbc, 0xd1, 0x6f, 0xe2, 0x6f, 0x08, 0x0a, 0x80, 0x42, 0x98, 0x2e, + 0x58, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0xee, 0x00, 0x21, 0x2e, 0x77, 0x00, 0x21, 0x2e, 0xdd, 0x00, 0x80, 0x2e, 0xf4, + 0x01, 0x1a, 0x24, 0x22, 0x00, 0x80, 0x2e, 0xec, 0x01, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0xf3, 0x03, 0x57, 0x50, + 0xfb, 0x6f, 0x01, 0x30, 0x71, 0x54, 0x11, 0x42, 0x42, 0x0e, 0xfc, 0x2f, 0xc0, 0x2e, 0x01, 0x42, 0xf0, 0x5f, 0x80, + 0x2e, 0x00, 0xc1, 0xfd, 0x2d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9a, 0x01, + 0x34, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x20, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x06, 0x32, 0x0f, 0x2e, 0x61, 0xf5, 0xfe, 0x09, 0xc0, 0xb3, 0x04, + 0x2f, 0x17, 0x30, 0x2f, 0x2e, 0xef, 0x00, 0x2d, 0x2e, 0x61, 0xf5, 0xf6, 0x6f, 0xe7, 0x6f, 0xe0, 0x5f, 0xc8, 0x2e, + 0x20, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x46, 0x30, 0x0f, 0x2e, 0xa4, 0xf1, 0xbe, 0x09, 0x80, 0xb3, 0x06, 0x2f, 0x0d, + 0x2e, 0xd4, 0x00, 0x84, 0xaf, 0x02, 0x2f, 0x16, 0x30, 0x2d, 0x2e, 0x7b, 0x00, 0x86, 0x30, 0x2d, 0x2e, 0x60, 0xf5, + 0xf6, 0x6f, 0xe7, 0x6f, 0xe0, 0x5f, 0xc8, 0x2e, 0x01, 0x2e, 0x77, 0xf7, 0x09, 0xbc, 0x0f, 0xb8, 0x00, 0xb2, 0x10, + 0x50, 0xfb, 0x7f, 0x10, 0x30, 0x0b, 0x2f, 0x03, 0x2e, 0x8a, 0x00, 0x96, 0xbc, 0x9f, 0xb8, 0x40, 0xb2, 0x05, 0x2f, + 0x03, 0x2e, 0x68, 0xf7, 0x9e, 0xbc, 0x9f, 0xb8, 0x40, 0xb2, 0x07, 0x2f, 0x03, 0x2e, 0x7e, 0x00, 0x41, 0x90, 0x01, + 0x2f, 0x98, 0x2e, 0xdc, 0x03, 0x03, 0x2c, 0x00, 0x30, 0x21, 0x2e, 0x7e, 0x00, 0xfb, 0x6f, 0xf0, 0x5f, 0xb8, 0x2e, + 0x20, 0x50, 0xe0, 0x7f, 0xfb, 0x7f, 0x00, 0x2e, 0x27, 0x50, 0x98, 0x2e, 0x3b, 0xc8, 0x29, 0x50, 0x98, 0x2e, 0xa7, + 0xc8, 0x01, 0x50, 0x98, 0x2e, 0x55, 0xcc, 0xe1, 0x6f, 0x2b, 0x50, 0x98, 0x2e, 0xe0, 0xc9, 0xfb, 0x6f, 0x00, 0x30, + 0xe0, 0x5f, 0x21, 0x2e, 0x7e, 0x00, 0xb8, 0x2e, 0x73, 0x50, 0x01, 0x30, 0x57, 0x54, 0x11, 0x42, 0x42, 0x0e, 0xfc, + 0x2f, 0xb8, 0x2e, 0x21, 0x2e, 0x59, 0xf5, 0x10, 0x30, 0xc0, 0x2e, 0x21, 0x2e, 0x4a, 0xf1, 0x90, 0x50, 0xf7, 0x7f, + 0xe6, 0x7f, 0xd5, 0x7f, 0xc4, 0x7f, 0xb3, 0x7f, 0xa1, 0x7f, 0x90, 0x7f, 0x82, 0x7f, 0x7b, 0x7f, 0x98, 0x2e, 0x35, + 0xb7, 0x00, 0xb2, 0x90, 0x2e, 0x97, 0xb0, 0x03, 0x2e, 0x8f, 0x00, 0x07, 0x2e, 0x91, 0x00, 0x05, 0x2e, 0xb1, 0x00, + 0x3f, 0xba, 0x9f, 0xb8, 0x01, 0x2e, 0xb1, 0x00, 0xa3, 0xbd, 0x4c, 0x0a, 0x05, 0x2e, 0xb1, 0x00, 0x04, 0xbe, 0xbf, + 0xb9, 0xcb, 0x0a, 0x4f, 0xba, 0x22, 0xbd, 0x01, 0x2e, 0xb3, 0x00, 0xdc, 0x0a, 0x2f, 0xb9, 0x03, 0x2e, 0xb8, 0x00, + 0x0a, 0xbe, 0x9a, 0x0a, 0xcf, 0xb9, 0x9b, 0xbc, 0x01, 0x2e, 0x97, 0x00, 0x9f, 0xb8, 0x93, 0x0a, 0x0f, 0xbc, 0x91, + 0x0a, 0x0f, 0xb8, 0x90, 0x0a, 0x25, 0x2e, 0x18, 0x00, 0x05, 0x2e, 0xc1, 0xf5, 0x2e, 0xbd, 0x2e, 0xb9, 0x01, 0x2e, + 0x19, 0x00, 0x31, 0x30, 0x8a, 0x04, 0x00, 0x90, 0x07, 0x2f, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0xa2, 0x03, 0x2f, 0x01, + 0x2e, 0x18, 0x00, 0x00, 0xb2, 0x0c, 0x2f, 0x19, 0x50, 0x05, 0x52, 0x98, 0x2e, 0x4d, 0xb7, 0x05, 0x2e, 0x78, 0x00, + 0x80, 0x90, 0x10, 0x30, 0x01, 0x2f, 0x21, 0x2e, 0x78, 0x00, 0x25, 0x2e, 0xdd, 0x00, 0x98, 0x2e, 0x3e, 0xb7, 0x00, + 0xb2, 0x02, 0x30, 0x01, 0x30, 0x04, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x00, 0x2f, 0x21, 0x30, 0x01, 0x2e, + 0xea, 0x00, 0x08, 0x1a, 0x0e, 0x2f, 0x23, 0x2e, 0xea, 0x00, 0x33, 0x30, 0x1b, 0x50, 0x0b, 0x09, 0x01, 0x40, 0x17, + 0x56, 0x46, 0xbe, 0x4b, 0x08, 0x4c, 0x0a, 0x01, 0x42, 0x0a, 0x80, 0x15, 0x52, 0x01, 0x42, 0x00, 0x2e, 0x01, 0x2e, + 0x18, 0x00, 0x00, 0xb2, 0x1f, 0x2f, 0x03, 0x2e, 0xc0, 0xf5, 0xf0, 0x30, 0x48, 0x08, 0x47, 0xaa, 0x74, 0x30, 0x07, + 0x2e, 0x7a, 0x00, 0x61, 0x22, 0x4b, 0x1a, 0x05, 0x2f, 0x07, 0x2e, 0x66, 0xf5, 0xbf, 0xbd, 0xbf, 0xb9, 0xc0, 0x90, + 0x0b, 0x2f, 0x1d, 0x56, 0x2b, 0x30, 0xd2, 0x42, 0xdb, 0x42, 0x01, 0x04, 0xc2, 0x42, 0x04, 0xbd, 0xfe, 0x80, 0x81, + 0x84, 0x23, 0x2e, 0x7a, 0x00, 0x02, 0x42, 0x02, 0x32, 0x25, 0x2e, 0x62, 0xf5, 0x05, 0x2e, 0xd6, 0x00, 0x81, 0x84, + 0x25, 0x2e, 0xd6, 0x00, 0x02, 0x31, 0x25, 0x2e, 0x60, 0xf5, 0x05, 0x2e, 0x8a, 0x00, 0x0b, 0x50, 0x90, 0x08, 0x80, + 0xb2, 0x0b, 0x2f, 0x05, 0x2e, 0xca, 0xf5, 0xf0, 0x3e, 0x90, 0x08, 0x25, 0x2e, 0xca, 0xf5, 0x05, 0x2e, 0x59, 0xf5, + 0xe0, 0x3f, 0x90, 0x08, 0x25, 0x2e, 0x59, 0xf5, 0x90, 0x6f, 0xa1, 0x6f, 0xb3, 0x6f, 0xc4, 0x6f, 0xd5, 0x6f, 0xe6, + 0x6f, 0xf7, 0x6f, 0x7b, 0x6f, 0x82, 0x6f, 0x70, 0x5f, 0xc8, 0x2e, 0xc0, 0x50, 0x90, 0x7f, 0xe5, 0x7f, 0xd4, 0x7f, + 0xc3, 0x7f, 0xb1, 0x7f, 0xa2, 0x7f, 0x87, 0x7f, 0xf6, 0x7f, 0x7b, 0x7f, 0x00, 0x2e, 0x01, 0x2e, 0x60, 0xf5, 0x60, + 0x7f, 0x98, 0x2e, 0x35, 0xb7, 0x02, 0x30, 0x63, 0x6f, 0x15, 0x52, 0x50, 0x7f, 0x62, 0x7f, 0x5a, 0x2c, 0x02, 0x32, + 0x1a, 0x09, 0x00, 0xb3, 0x14, 0x2f, 0x00, 0xb2, 0x03, 0x2f, 0x09, 0x2e, 0x18, 0x00, 0x00, 0x91, 0x0c, 0x2f, 0x43, + 0x7f, 0x98, 0x2e, 0x97, 0xb7, 0x1f, 0x50, 0x02, 0x8a, 0x02, 0x32, 0x04, 0x30, 0x25, 0x2e, 0x64, 0xf5, 0x15, 0x52, + 0x50, 0x6f, 0x43, 0x6f, 0x44, 0x43, 0x25, 0x2e, 0x60, 0xf5, 0xd9, 0x08, 0xc0, 0xb2, 0x36, 0x2f, 0x98, 0x2e, 0x3e, + 0xb7, 0x00, 0xb2, 0x06, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x02, 0x2f, 0x50, 0x6f, 0x00, 0x90, 0x0a, 0x2f, + 0x01, 0x2e, 0x79, 0x00, 0x00, 0x90, 0x19, 0x2f, 0x10, 0x30, 0x21, 0x2e, 0x79, 0x00, 0x00, 0x30, 0x98, 0x2e, 0xdc, + 0x03, 0x13, 0x2d, 0x01, 0x2e, 0xc3, 0xf5, 0x0c, 0xbc, 0x0f, 0xb8, 0x12, 0x30, 0x10, 0x04, 0x03, 0xb0, 0x26, 0x25, + 0x21, 0x50, 0x03, 0x52, 0x98, 0x2e, 0x4d, 0xb7, 0x10, 0x30, 0x21, 0x2e, 0xee, 0x00, 0x02, 0x30, 0x60, 0x7f, 0x25, + 0x2e, 0x79, 0x00, 0x60, 0x6f, 0x00, 0x90, 0x05, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0xea, 0x00, 0x15, 0x50, 0x21, 0x2e, + 0x64, 0xf5, 0x15, 0x52, 0x23, 0x2e, 0x60, 0xf5, 0x02, 0x32, 0x50, 0x6f, 0x00, 0x90, 0x02, 0x2f, 0x03, 0x30, 0x27, + 0x2e, 0x78, 0x00, 0x07, 0x2e, 0x60, 0xf5, 0x1a, 0x09, 0x00, 0x91, 0xa3, 0x2f, 0x19, 0x09, 0x00, 0x91, 0xa0, 0x2f, + 0x90, 0x6f, 0xa2, 0x6f, 0xb1, 0x6f, 0xc3, 0x6f, 0xd4, 0x6f, 0xe5, 0x6f, 0x7b, 0x6f, 0xf6, 0x6f, 0x87, 0x6f, 0x40, + 0x5f, 0xc8, 0x2e, 0xc0, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x26, 0x30, 0x0f, 0x2e, 0x61, 0xf5, 0x2f, 0x2e, 0x7c, 0x00, + 0x0f, 0x2e, 0x7c, 0x00, 0xbe, 0x09, 0xa2, 0x7f, 0x80, 0x7f, 0x80, 0xb3, 0xd5, 0x7f, 0xc4, 0x7f, 0xb3, 0x7f, 0x91, + 0x7f, 0x7b, 0x7f, 0x0b, 0x2f, 0x23, 0x50, 0x1a, 0x25, 0x12, 0x40, 0x42, 0x7f, 0x74, 0x82, 0x12, 0x40, 0x52, 0x7f, + 0x00, 0x2e, 0x00, 0x40, 0x60, 0x7f, 0x98, 0x2e, 0x6a, 0xd6, 0x81, 0x30, 0x01, 0x2e, 0x7c, 0x00, 0x01, 0x08, 0x00, + 0xb2, 0x42, 0x2f, 0x03, 0x2e, 0x89, 0x00, 0x01, 0x2e, 0x89, 0x00, 0x97, 0xbc, 0x06, 0xbc, 0x9f, 0xb8, 0x0f, 0xb8, + 0x00, 0x90, 0x23, 0x2e, 0xd8, 0x00, 0x10, 0x30, 0x01, 0x30, 0x2a, 0x2f, 0x03, 0x2e, 0xd4, 0x00, 0x44, 0xb2, 0x05, + 0x2f, 0x47, 0xb2, 0x00, 0x30, 0x2d, 0x2f, 0x21, 0x2e, 0x7c, 0x00, 0x2b, 0x2d, 0x03, 0x2e, 0xfd, 0xf5, 0x9e, 0xbc, + 0x9f, 0xb8, 0x40, 0x90, 0x14, 0x2f, 0x03, 0x2e, 0xfc, 0xf5, 0x99, 0xbc, 0x9f, 0xb8, 0x40, 0x90, 0x0e, 0x2f, 0x03, + 0x2e, 0x49, 0xf1, 0x25, 0x54, 0x4a, 0x08, 0x40, 0x90, 0x08, 0x2f, 0x98, 0x2e, 0x35, 0xb7, 0x00, 0xb2, 0x10, 0x30, + 0x03, 0x2f, 0x50, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x10, 0x2d, 0x98, 0x2e, 0xaf, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0x7c, + 0x00, 0x0a, 0x2d, 0x05, 0x2e, 0x69, 0xf7, 0x2d, 0xbd, 0x2f, 0xb9, 0x80, 0xb2, 0x01, 0x2f, 0x21, 0x2e, 0x7d, 0x00, + 0x23, 0x2e, 0x7c, 0x00, 0xe0, 0x31, 0x21, 0x2e, 0x61, 0xf5, 0xf6, 0x6f, 0xe7, 0x6f, 0x80, 0x6f, 0xa2, 0x6f, 0xb3, + 0x6f, 0xc4, 0x6f, 0xd5, 0x6f, 0x7b, 0x6f, 0x91, 0x6f, 0x40, 0x5f, 0xc8, 0x2e, 0x60, 0x51, 0x0a, 0x25, 0x36, 0x88, + 0xf4, 0x7f, 0xeb, 0x7f, 0x00, 0x32, 0x31, 0x52, 0x32, 0x30, 0x13, 0x30, 0x98, 0x2e, 0x15, 0xcb, 0x0a, 0x25, 0x33, + 0x84, 0xd2, 0x7f, 0x43, 0x30, 0x05, 0x50, 0x2d, 0x52, 0x98, 0x2e, 0x95, 0xc1, 0xd2, 0x6f, 0x27, 0x52, 0x98, 0x2e, + 0xd7, 0xc7, 0x2a, 0x25, 0xb0, 0x86, 0xc0, 0x7f, 0xd3, 0x7f, 0xaf, 0x84, 0x29, 0x50, 0xf1, 0x6f, 0x98, 0x2e, 0x4d, + 0xc8, 0x2a, 0x25, 0xae, 0x8a, 0xaa, 0x88, 0xf2, 0x6e, 0x2b, 0x50, 0xc1, 0x6f, 0xd3, 0x6f, 0xf4, 0x7f, 0x98, 0x2e, + 0xb6, 0xc8, 0xe0, 0x6e, 0x00, 0xb2, 0x32, 0x2f, 0x33, 0x54, 0x83, 0x86, 0xf1, 0x6f, 0xc3, 0x7f, 0x04, 0x30, 0x30, + 0x30, 0xf4, 0x7f, 0xd0, 0x7f, 0xb2, 0x7f, 0xe3, 0x30, 0xc5, 0x6f, 0x56, 0x40, 0x45, 0x41, 0x28, 0x08, 0x03, 0x14, + 0x0e, 0xb4, 0x08, 0xbc, 0x82, 0x40, 0x10, 0x0a, 0x2f, 0x54, 0x26, 0x05, 0x91, 0x7f, 0x44, 0x28, 0xa3, 0x7f, 0x98, + 0x2e, 0xd9, 0xc0, 0x08, 0xb9, 0x33, 0x30, 0x53, 0x09, 0xc1, 0x6f, 0xd3, 0x6f, 0xf4, 0x6f, 0x83, 0x17, 0x47, 0x40, + 0x6c, 0x15, 0xb2, 0x6f, 0xbe, 0x09, 0x75, 0x0b, 0x90, 0x42, 0x45, 0x42, 0x51, 0x0e, 0x32, 0xbc, 0x02, 0x89, 0xa1, + 0x6f, 0x7e, 0x86, 0xf4, 0x7f, 0xd0, 0x7f, 0xb2, 0x7f, 0x04, 0x30, 0x91, 0x6f, 0xd6, 0x2f, 0xeb, 0x6f, 0xa0, 0x5e, + 0xb8, 0x2e, 0x03, 0x2e, 0x97, 0x00, 0x1b, 0xbc, 0x60, 0x50, 0x9f, 0xbc, 0x0c, 0xb8, 0xf0, 0x7f, 0x40, 0xb2, 0xeb, + 0x7f, 0x2b, 0x2f, 0x03, 0x2e, 0x7f, 0x00, 0x41, 0x40, 0x01, 0x2e, 0xc8, 0x00, 0x01, 0x1a, 0x11, 0x2f, 0x37, 0x58, + 0x23, 0x2e, 0xc8, 0x00, 0x10, 0x41, 0xa0, 0x7f, 0x38, 0x81, 0x01, 0x41, 0xd0, 0x7f, 0xb1, 0x7f, 0x98, 0x2e, 0x64, + 0xcf, 0xd0, 0x6f, 0x07, 0x80, 0xa1, 0x6f, 0x11, 0x42, 0x00, 0x2e, 0xb1, 0x6f, 0x01, 0x42, 0x11, 0x30, 0x01, 0x2e, + 0xfc, 0x00, 0x00, 0xa8, 0x03, 0x30, 0xcb, 0x22, 0x4a, 0x25, 0x01, 0x2e, 0x7f, 0x00, 0x3c, 0x89, 0x35, 0x52, 0x05, + 0x54, 0x98, 0x2e, 0xc4, 0xce, 0xc1, 0x6f, 0xf0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0x04, 0x2d, 0x01, 0x30, 0xf0, 0x6f, + 0x98, 0x2e, 0x95, 0xcf, 0xeb, 0x6f, 0xa0, 0x5f, 0xb8, 0x2e, 0x03, 0x2e, 0xb3, 0x00, 0x02, 0x32, 0xf0, 0x30, 0x03, + 0x31, 0x30, 0x50, 0x8a, 0x08, 0x08, 0x08, 0xcb, 0x08, 0xe0, 0x7f, 0x80, 0xb2, 0xf3, 0x7f, 0xdb, 0x7f, 0x25, 0x2f, + 0x03, 0x2e, 0xca, 0x00, 0x41, 0x90, 0x04, 0x2f, 0x01, 0x30, 0x23, 0x2e, 0xca, 0x00, 0x98, 0x2e, 0x3f, 0x03, 0xc0, + 0xb2, 0x05, 0x2f, 0x03, 0x2e, 0xda, 0x00, 0x00, 0x30, 0x41, 0x04, 0x23, 0x2e, 0xda, 0x00, 0x98, 0x2e, 0x92, 0xb2, + 0x10, 0x25, 0xf0, 0x6f, 0x00, 0xb2, 0x05, 0x2f, 0x01, 0x2e, 0xda, 0x00, 0x02, 0x30, 0x10, 0x04, 0x21, 0x2e, 0xda, + 0x00, 0x40, 0xb2, 0x01, 0x2f, 0x23, 0x2e, 0xc8, 0x01, 0xdb, 0x6f, 0xe0, 0x6f, 0xd0, 0x5f, 0x80, 0x2e, 0x95, 0xcf, + 0x01, 0x30, 0xe0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0x11, 0x30, 0x23, 0x2e, 0xca, 0x00, 0xdb, 0x6f, 0xd0, 0x5f, 0xb8, + 0x2e, 0xd0, 0x50, 0x0a, 0x25, 0x33, 0x84, 0x55, 0x50, 0xd2, 0x7f, 0xe2, 0x7f, 0x03, 0x8c, 0xc0, 0x7f, 0xbb, 0x7f, + 0x00, 0x30, 0x05, 0x5a, 0x39, 0x54, 0x51, 0x41, 0xa5, 0x7f, 0x96, 0x7f, 0x80, 0x7f, 0x98, 0x2e, 0xd9, 0xc0, 0x05, + 0x30, 0xf5, 0x7f, 0x20, 0x25, 0x91, 0x6f, 0x3b, 0x58, 0x3d, 0x5c, 0x3b, 0x56, 0x98, 0x2e, 0x67, 0xcc, 0xc1, 0x6f, + 0xd5, 0x6f, 0x52, 0x40, 0x50, 0x43, 0xc1, 0x7f, 0xd5, 0x7f, 0x10, 0x25, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, + 0x2e, 0x74, 0xc0, 0x86, 0x6f, 0x30, 0x28, 0x92, 0x6f, 0x82, 0x8c, 0xa5, 0x6f, 0x6f, 0x52, 0x69, 0x0e, 0x39, 0x54, + 0xdb, 0x2f, 0x19, 0xa0, 0x15, 0x30, 0x03, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0x81, 0x01, 0x0a, 0x2d, 0x01, 0x2e, 0x81, + 0x01, 0x05, 0x28, 0x42, 0x36, 0x21, 0x2e, 0x81, 0x01, 0x02, 0x0e, 0x01, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0x57, 0x50, + 0x12, 0x30, 0x01, 0x40, 0x98, 0x2e, 0xfe, 0xc9, 0x51, 0x6f, 0x0b, 0x5c, 0x8e, 0x0e, 0x3b, 0x6f, 0x57, 0x58, 0x02, + 0x30, 0x21, 0x2e, 0x95, 0x01, 0x45, 0x6f, 0x2a, 0x8d, 0xd2, 0x7f, 0xcb, 0x7f, 0x13, 0x2f, 0x02, 0x30, 0x3f, 0x50, + 0xd2, 0x7f, 0xa8, 0x0e, 0x0e, 0x2f, 0xc0, 0x6f, 0x53, 0x54, 0x02, 0x00, 0x51, 0x54, 0x42, 0x0e, 0x10, 0x30, 0x59, + 0x52, 0x02, 0x30, 0x01, 0x2f, 0x00, 0x2e, 0x03, 0x2d, 0x50, 0x42, 0x42, 0x42, 0x12, 0x30, 0xd2, 0x7f, 0x80, 0xb2, + 0x03, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0x80, 0x01, 0x12, 0x2d, 0x01, 0x2e, 0xc9, 0x00, 0x02, 0x80, 0x05, 0x2e, 0x80, + 0x01, 0x11, 0x30, 0x91, 0x28, 0x00, 0x40, 0x25, 0x2e, 0x80, 0x01, 0x10, 0x0e, 0x05, 0x2f, 0x01, 0x2e, 0x7f, 0x01, + 0x01, 0x90, 0x01, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0x00, 0x2e, 0xa0, 0x41, 0x01, 0x90, 0xa6, 0x7f, 0x90, 0x2e, 0xe3, + 0xb4, 0x01, 0x2e, 0x95, 0x01, 0x00, 0xa8, 0x90, 0x2e, 0xe3, 0xb4, 0x5b, 0x54, 0x95, 0x80, 0x82, 0x40, 0x80, 0xb2, + 0x02, 0x40, 0x2d, 0x8c, 0x3f, 0x52, 0x96, 0x7f, 0x90, 0x2e, 0xc2, 0xb3, 0x29, 0x0e, 0x76, 0x2f, 0x01, 0x2e, 0xc9, + 0x00, 0x00, 0x40, 0x81, 0x28, 0x45, 0x52, 0xb3, 0x30, 0x98, 0x2e, 0x0f, 0xca, 0x5d, 0x54, 0x80, 0x7f, 0x00, 0x2e, + 0xa1, 0x40, 0x72, 0x7f, 0x82, 0x80, 0x82, 0x40, 0x60, 0x7f, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, 0x74, + 0xc0, 0x62, 0x6f, 0x05, 0x30, 0x87, 0x40, 0xc0, 0x91, 0x04, 0x30, 0x05, 0x2f, 0x05, 0x2e, 0x83, 0x01, 0x80, 0xb2, + 0x14, 0x30, 0x00, 0x2f, 0x04, 0x30, 0x05, 0x2e, 0xc9, 0x00, 0x73, 0x6f, 0x81, 0x40, 0xe2, 0x40, 0x69, 0x04, 0x11, + 0x0f, 0xe1, 0x40, 0x16, 0x30, 0xfe, 0x29, 0xcb, 0x40, 0x02, 0x2f, 0x83, 0x6f, 0x83, 0x0f, 0x22, 0x2f, 0x47, 0x56, + 0x13, 0x0f, 0x12, 0x30, 0x77, 0x2f, 0x49, 0x54, 0x42, 0x0e, 0x12, 0x30, 0x73, 0x2f, 0x00, 0x91, 0x0a, 0x2f, 0x01, + 0x2e, 0x8b, 0x01, 0x19, 0xa8, 0x02, 0x30, 0x6c, 0x2f, 0x63, 0x50, 0x00, 0x2e, 0x17, 0x42, 0x05, 0x42, 0x68, 0x2c, + 0x12, 0x30, 0x0b, 0x25, 0x08, 0x0f, 0x50, 0x30, 0x02, 0x2f, 0x21, 0x2e, 0x83, 0x01, 0x03, 0x2d, 0x40, 0x30, 0x21, + 0x2e, 0x83, 0x01, 0x2b, 0x2e, 0x85, 0x01, 0x5a, 0x2c, 0x12, 0x30, 0x00, 0x91, 0x2b, 0x25, 0x04, 0x2f, 0x63, 0x50, + 0x02, 0x30, 0x17, 0x42, 0x17, 0x2c, 0x02, 0x42, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, 0x74, 0xc0, 0x05, + 0x2e, 0xc9, 0x00, 0x81, 0x84, 0x5b, 0x30, 0x82, 0x40, 0x37, 0x2e, 0x83, 0x01, 0x02, 0x0e, 0x07, 0x2f, 0x5f, 0x52, + 0x40, 0x30, 0x62, 0x40, 0x41, 0x40, 0x91, 0x0e, 0x01, 0x2f, 0x21, 0x2e, 0x83, 0x01, 0x05, 0x30, 0x2b, 0x2e, 0x85, + 0x01, 0x12, 0x30, 0x36, 0x2c, 0x16, 0x30, 0x15, 0x25, 0x81, 0x7f, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, + 0x74, 0xc0, 0x19, 0xa2, 0x16, 0x30, 0x15, 0x2f, 0x05, 0x2e, 0x97, 0x01, 0x80, 0x6f, 0x82, 0x0e, 0x05, 0x2f, 0x01, + 0x2e, 0x86, 0x01, 0x06, 0x28, 0x21, 0x2e, 0x86, 0x01, 0x0b, 0x2d, 0x03, 0x2e, 0x87, 0x01, 0x5f, 0x54, 0x4e, 0x28, + 0x91, 0x42, 0x00, 0x2e, 0x82, 0x40, 0x90, 0x0e, 0x01, 0x2f, 0x21, 0x2e, 0x88, 0x01, 0x02, 0x30, 0x13, 0x2c, 0x05, + 0x30, 0xc0, 0x6f, 0x08, 0x1c, 0xa8, 0x0f, 0x16, 0x30, 0x05, 0x30, 0x5b, 0x50, 0x09, 0x2f, 0x02, 0x80, 0x2d, 0x2e, + 0x82, 0x01, 0x05, 0x42, 0x05, 0x80, 0x00, 0x2e, 0x02, 0x42, 0x3e, 0x80, 0x00, 0x2e, 0x06, 0x42, 0x02, 0x30, 0x90, + 0x6f, 0x3e, 0x88, 0x01, 0x40, 0x04, 0x41, 0x4c, 0x28, 0x01, 0x42, 0x07, 0x80, 0x10, 0x25, 0x24, 0x40, 0x00, 0x40, + 0x00, 0xa8, 0xf5, 0x22, 0x23, 0x29, 0x44, 0x42, 0x7a, 0x82, 0x7e, 0x88, 0x43, 0x40, 0x04, 0x41, 0x00, 0xab, 0xf5, + 0x23, 0xdf, 0x28, 0x43, 0x42, 0xd9, 0xa0, 0x14, 0x2f, 0x00, 0x90, 0x02, 0x2f, 0xd2, 0x6f, 0x81, 0xb2, 0x05, 0x2f, + 0x63, 0x54, 0x06, 0x28, 0x90, 0x42, 0x85, 0x42, 0x09, 0x2c, 0x02, 0x30, 0x5b, 0x50, 0x03, 0x80, 0x29, 0x2e, 0x7e, + 0x01, 0x2b, 0x2e, 0x82, 0x01, 0x05, 0x42, 0x12, 0x30, 0x2b, 0x2e, 0x83, 0x01, 0x45, 0x82, 0x00, 0x2e, 0x40, 0x40, + 0x7a, 0x82, 0x02, 0xa0, 0x08, 0x2f, 0x63, 0x50, 0x3b, 0x30, 0x15, 0x42, 0x05, 0x42, 0x37, 0x80, 0x37, 0x2e, 0x7e, + 0x01, 0x05, 0x42, 0x12, 0x30, 0x01, 0x2e, 0xc9, 0x00, 0x02, 0x8c, 0x40, 0x40, 0x84, 0x41, 0x7a, 0x8c, 0x04, 0x0f, + 0x03, 0x2f, 0x01, 0x2e, 0x8b, 0x01, 0x19, 0xa4, 0x04, 0x2f, 0x2b, 0x2e, 0x82, 0x01, 0x98, 0x2e, 0xf3, 0x03, 0x12, + 0x30, 0x81, 0x90, 0x61, 0x52, 0x08, 0x2f, 0x65, 0x42, 0x65, 0x42, 0x43, 0x80, 0x39, 0x84, 0x82, 0x88, 0x05, 0x42, + 0x45, 0x42, 0x85, 0x42, 0x05, 0x43, 0x00, 0x2e, 0x80, 0x41, 0x00, 0x90, 0x90, 0x2e, 0xe1, 0xb4, 0x65, 0x54, 0xc1, + 0x6f, 0x80, 0x40, 0x00, 0xb2, 0x43, 0x58, 0x69, 0x50, 0x44, 0x2f, 0x55, 0x5c, 0xb7, 0x87, 0x8c, 0x0f, 0x0d, 0x2e, + 0x96, 0x01, 0xc4, 0x40, 0x36, 0x2f, 0x41, 0x56, 0x8b, 0x0e, 0x2a, 0x2f, 0x0b, 0x52, 0xa1, 0x0e, 0x0a, 0x2f, 0x05, + 0x2e, 0x8f, 0x01, 0x14, 0x25, 0x98, 0x2e, 0xfe, 0xc9, 0x4b, 0x54, 0x02, 0x0f, 0x69, 0x50, 0x05, 0x30, 0x65, 0x54, + 0x15, 0x2f, 0x03, 0x2e, 0x8e, 0x01, 0x4d, 0x5c, 0x8e, 0x0f, 0x3a, 0x2f, 0x05, 0x2e, 0x8f, 0x01, 0x98, 0x2e, 0xfe, + 0xc9, 0x4f, 0x54, 0x82, 0x0f, 0x05, 0x30, 0x69, 0x50, 0x65, 0x54, 0x30, 0x2f, 0x6d, 0x52, 0x15, 0x30, 0x42, 0x8c, + 0x45, 0x42, 0x04, 0x30, 0x2b, 0x2c, 0x84, 0x43, 0x6b, 0x52, 0x42, 0x8c, 0x00, 0x2e, 0x85, 0x43, 0x15, 0x30, 0x24, + 0x2c, 0x45, 0x42, 0x8e, 0x0f, 0x20, 0x2f, 0x0d, 0x2e, 0x8e, 0x01, 0xb1, 0x0e, 0x1c, 0x2f, 0x23, 0x2e, 0x8e, 0x01, + 0x1a, 0x2d, 0x0e, 0x0e, 0x17, 0x2f, 0xa1, 0x0f, 0x15, 0x2f, 0x23, 0x2e, 0x8d, 0x01, 0x13, 0x2d, 0x98, 0x2e, 0x74, + 0xc0, 0x43, 0x54, 0xc2, 0x0e, 0x0a, 0x2f, 0x65, 0x50, 0x04, 0x80, 0x0b, 0x30, 0x06, 0x82, 0x0b, 0x42, 0x79, 0x80, + 0x41, 0x40, 0x12, 0x30, 0x25, 0x2e, 0x8c, 0x01, 0x01, 0x42, 0x05, 0x30, 0x69, 0x50, 0x65, 0x54, 0x84, 0x82, 0x43, + 0x84, 0xbe, 0x8c, 0x84, 0x40, 0x86, 0x41, 0x26, 0x29, 0x94, 0x42, 0xbe, 0x8e, 0xd5, 0x7f, 0x19, 0xa1, 0x43, 0x40, + 0x0b, 0x2e, 0x8c, 0x01, 0x84, 0x40, 0xc7, 0x41, 0x5d, 0x29, 0x27, 0x29, 0x45, 0x42, 0x84, 0x42, 0xc2, 0x7f, 0x01, + 0x2f, 0xc0, 0xb3, 0x1d, 0x2f, 0x05, 0x2e, 0x94, 0x01, 0x99, 0xa0, 0x01, 0x2f, 0x80, 0xb3, 0x13, 0x2f, 0x80, 0xb3, + 0x18, 0x2f, 0xc0, 0xb3, 0x16, 0x2f, 0x12, 0x40, 0x01, 0x40, 0x92, 0x7f, 0x98, 0x2e, 0x74, 0xc0, 0x92, 0x6f, 0x10, + 0x0f, 0x20, 0x30, 0x03, 0x2f, 0x10, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0x0a, 0x2d, 0x21, 0x2e, 0x7e, 0x01, 0x07, 0x2d, + 0x20, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0x03, 0x2d, 0x10, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0xc2, 0x6f, 0x01, 0x2e, 0xc9, + 0x00, 0xbc, 0x84, 0x02, 0x80, 0x82, 0x40, 0x00, 0x40, 0x90, 0x0e, 0xd5, 0x6f, 0x02, 0x2f, 0x15, 0x30, 0x98, 0x2e, + 0xf3, 0x03, 0x41, 0x91, 0x05, 0x30, 0x07, 0x2f, 0x67, 0x50, 0x3d, 0x80, 0x2b, 0x2e, 0x8f, 0x01, 0x05, 0x42, 0x04, + 0x80, 0x00, 0x2e, 0x05, 0x42, 0x02, 0x2c, 0x00, 0x30, 0x00, 0x30, 0xa2, 0x6f, 0x98, 0x8a, 0x86, 0x40, 0x80, 0xa7, + 0x05, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0xc0, 0x30, 0x21, 0x2e, 0x95, 0x01, 0x06, 0x25, 0x1a, 0x25, 0xe2, 0x6f, 0x76, + 0x82, 0x96, 0x40, 0x56, 0x43, 0x51, 0x0e, 0xfb, 0x2f, 0xbb, 0x6f, 0x30, 0x5f, 0xb8, 0x2e, 0x01, 0x2e, 0xb8, 0x00, + 0x01, 0x31, 0x41, 0x08, 0x40, 0xb2, 0x20, 0x50, 0xf2, 0x30, 0x02, 0x08, 0xfb, 0x7f, 0x01, 0x30, 0x10, 0x2f, 0x05, + 0x2e, 0xcc, 0x00, 0x81, 0x90, 0xe0, 0x7f, 0x03, 0x2f, 0x23, 0x2e, 0xcc, 0x00, 0x98, 0x2e, 0x55, 0xb6, 0x98, 0x2e, + 0x1d, 0xb5, 0x10, 0x25, 0xfb, 0x6f, 0xe0, 0x6f, 0xe0, 0x5f, 0x80, 0x2e, 0x95, 0xcf, 0x98, 0x2e, 0x95, 0xcf, 0x10, + 0x30, 0x21, 0x2e, 0xcc, 0x00, 0xfb, 0x6f, 0xe0, 0x5f, 0xb8, 0x2e, 0x00, 0x51, 0x05, 0x58, 0xeb, 0x7f, 0x2a, 0x25, + 0x89, 0x52, 0x6f, 0x5a, 0x89, 0x50, 0x13, 0x41, 0x06, 0x40, 0xb3, 0x01, 0x16, 0x42, 0xcb, 0x16, 0x06, 0x40, 0xf3, + 0x02, 0x13, 0x42, 0x65, 0x0e, 0xf5, 0x2f, 0x05, 0x40, 0x14, 0x30, 0x2c, 0x29, 0x04, 0x42, 0x08, 0xa1, 0x00, 0x30, + 0x90, 0x2e, 0x52, 0xb6, 0xb3, 0x88, 0xb0, 0x8a, 0xb6, 0x84, 0xa4, 0x7f, 0xc4, 0x7f, 0xb5, 0x7f, 0xd5, 0x7f, 0x92, + 0x7f, 0x73, 0x30, 0x04, 0x30, 0x55, 0x40, 0x42, 0x40, 0x8a, 0x17, 0xf3, 0x08, 0x6b, 0x01, 0x90, 0x02, 0x53, 0xb8, + 0x4b, 0x82, 0xad, 0xbe, 0x71, 0x7f, 0x45, 0x0a, 0x09, 0x54, 0x84, 0x7f, 0x98, 0x2e, 0xd9, 0xc0, 0xa3, 0x6f, 0x7b, + 0x54, 0xd0, 0x42, 0xa3, 0x7f, 0xf2, 0x7f, 0x60, 0x7f, 0x20, 0x25, 0x71, 0x6f, 0x75, 0x5a, 0x77, 0x58, 0x79, 0x5c, + 0x75, 0x56, 0x98, 0x2e, 0x67, 0xcc, 0xb1, 0x6f, 0x62, 0x6f, 0x50, 0x42, 0xb1, 0x7f, 0xb3, 0x30, 0x10, 0x25, 0x98, + 0x2e, 0x0f, 0xca, 0x84, 0x6f, 0x20, 0x29, 0x71, 0x6f, 0x92, 0x6f, 0xa5, 0x6f, 0x76, 0x82, 0x6a, 0x0e, 0x73, 0x30, + 0x00, 0x30, 0xd0, 0x2f, 0xd2, 0x6f, 0xd1, 0x7f, 0xb4, 0x7f, 0x98, 0x2e, 0x2b, 0xb7, 0x15, 0xbd, 0x0b, 0xb8, 0x02, + 0x0a, 0xc2, 0x6f, 0xc0, 0x7f, 0x98, 0x2e, 0x2b, 0xb7, 0x15, 0xbd, 0x0b, 0xb8, 0x42, 0x0a, 0xc0, 0x6f, 0x08, 0x17, + 0x41, 0x18, 0x89, 0x16, 0xe1, 0x18, 0xd0, 0x18, 0xa1, 0x7f, 0x27, 0x25, 0x16, 0x25, 0x98, 0x2e, 0x79, 0xc0, 0x8b, + 0x54, 0x90, 0x7f, 0xb3, 0x30, 0x82, 0x40, 0x80, 0x90, 0x0d, 0x2f, 0x7d, 0x52, 0x92, 0x6f, 0x98, 0x2e, 0x0f, 0xca, + 0xb2, 0x6f, 0x90, 0x0e, 0x06, 0x2f, 0x8b, 0x50, 0x14, 0x30, 0x42, 0x6f, 0x51, 0x6f, 0x14, 0x42, 0x12, 0x42, 0x01, + 0x42, 0x00, 0x2e, 0x31, 0x6f, 0x98, 0x2e, 0x74, 0xc0, 0x41, 0x6f, 0x80, 0x7f, 0x98, 0x2e, 0x74, 0xc0, 0x82, 0x6f, + 0x10, 0x04, 0x43, 0x52, 0x01, 0x0f, 0x05, 0x2e, 0xcb, 0x00, 0x00, 0x30, 0x04, 0x30, 0x21, 0x2f, 0x51, 0x6f, 0x43, + 0x58, 0x8c, 0x0e, 0x04, 0x30, 0x1c, 0x2f, 0x85, 0x88, 0x41, 0x6f, 0x04, 0x41, 0x8c, 0x0f, 0x04, 0x30, 0x16, 0x2f, + 0x84, 0x88, 0x00, 0x2e, 0x04, 0x41, 0x04, 0x05, 0x8c, 0x0e, 0x04, 0x30, 0x0f, 0x2f, 0x82, 0x88, 0x31, 0x6f, 0x04, + 0x41, 0x04, 0x05, 0x8c, 0x0e, 0x04, 0x30, 0x08, 0x2f, 0x83, 0x88, 0x00, 0x2e, 0x04, 0x41, 0x8c, 0x0f, 0x04, 0x30, + 0x02, 0x2f, 0x21, 0x2e, 0xad, 0x01, 0x14, 0x30, 0x00, 0x91, 0x14, 0x2f, 0x03, 0x2e, 0xa1, 0x01, 0x41, 0x90, 0x0e, + 0x2f, 0x03, 0x2e, 0xad, 0x01, 0x14, 0x30, 0x4c, 0x28, 0x23, 0x2e, 0xad, 0x01, 0x46, 0xa0, 0x06, 0x2f, 0x81, 0x84, + 0x8d, 0x52, 0x48, 0x82, 0x82, 0x40, 0x21, 0x2e, 0xa1, 0x01, 0x42, 0x42, 0x5c, 0x2c, 0x02, 0x30, 0x05, 0x2e, 0xaa, + 0x01, 0x80, 0xb2, 0x02, 0x30, 0x55, 0x2f, 0x03, 0x2e, 0xa9, 0x01, 0x92, 0x6f, 0xb3, 0x30, 0x98, 0x2e, 0x0f, 0xca, + 0xb2, 0x6f, 0x90, 0x0f, 0x00, 0x30, 0x02, 0x30, 0x4a, 0x2f, 0xa2, 0x6f, 0x87, 0x52, 0x91, 0x00, 0x85, 0x52, 0x51, + 0x0e, 0x02, 0x2f, 0x00, 0x2e, 0x43, 0x2c, 0x02, 0x30, 0xc2, 0x6f, 0x7f, 0x52, 0x91, 0x0e, 0x02, 0x30, 0x3c, 0x2f, + 0x51, 0x6f, 0x81, 0x54, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0xb3, 0x30, 0x21, 0x25, 0x98, 0x2e, 0x0f, 0xca, 0x32, + 0x6f, 0xc0, 0x7f, 0xb3, 0x30, 0x12, 0x25, 0x98, 0x2e, 0x0f, 0xca, 0x42, 0x6f, 0xb0, 0x7f, 0xb3, 0x30, 0x12, 0x25, + 0x98, 0x2e, 0x0f, 0xca, 0xb2, 0x6f, 0x90, 0x28, 0x83, 0x52, 0x98, 0x2e, 0xfe, 0xc9, 0xc2, 0x6f, 0x90, 0x0f, 0x00, + 0x30, 0x02, 0x30, 0x1d, 0x2f, 0x05, 0x2e, 0xa1, 0x01, 0x80, 0xb2, 0x12, 0x30, 0x0f, 0x2f, 0x42, 0x6f, 0x03, 0x2e, + 0xab, 0x01, 0x91, 0x0e, 0x02, 0x30, 0x12, 0x2f, 0x52, 0x6f, 0x03, 0x2e, 0xac, 0x01, 0x91, 0x0f, 0x02, 0x30, 0x0c, + 0x2f, 0x21, 0x2e, 0xaa, 0x01, 0x0a, 0x2c, 0x12, 0x30, 0x03, 0x2e, 0xcb, 0x00, 0x8d, 0x58, 0x08, 0x89, 0x41, 0x40, + 0x11, 0x43, 0x00, 0x43, 0x25, 0x2e, 0xa1, 0x01, 0xd4, 0x6f, 0x8f, 0x52, 0x00, 0x43, 0x3a, 0x89, 0x00, 0x2e, 0x10, + 0x43, 0x10, 0x43, 0x61, 0x0e, 0xfb, 0x2f, 0x03, 0x2e, 0xa0, 0x01, 0x11, 0x1a, 0x02, 0x2f, 0x02, 0x25, 0x21, 0x2e, + 0xa0, 0x01, 0xeb, 0x6f, 0x00, 0x5f, 0xb8, 0x2e, 0x91, 0x52, 0x10, 0x30, 0x02, 0x30, 0x95, 0x56, 0x52, 0x42, 0x4b, + 0x0e, 0xfc, 0x2f, 0x8d, 0x54, 0x88, 0x82, 0x93, 0x56, 0x80, 0x42, 0x53, 0x42, 0x40, 0x42, 0x42, 0x86, 0x83, 0x54, + 0xc0, 0x2e, 0xc2, 0x42, 0x00, 0x2e, 0xa3, 0x52, 0x00, 0x51, 0x52, 0x40, 0x47, 0x40, 0x1a, 0x25, 0x01, 0x2e, 0x97, + 0x00, 0x8f, 0xbe, 0x72, 0x86, 0xfb, 0x7f, 0x0b, 0x30, 0x7c, 0xbf, 0xa5, 0x50, 0x10, 0x08, 0xdf, 0xba, 0x70, 0x88, + 0xf8, 0xbf, 0xcb, 0x42, 0xd3, 0x7f, 0x6c, 0xbb, 0xfc, 0xbb, 0xc5, 0x0a, 0x90, 0x7f, 0x1b, 0x7f, 0x0b, 0x43, 0xc0, + 0xb2, 0xe5, 0x7f, 0xb7, 0x7f, 0xa6, 0x7f, 0xc4, 0x7f, 0x90, 0x2e, 0x1c, 0xb7, 0x07, 0x2e, 0xd2, 0x00, 0xc0, 0xb2, + 0x0b, 0x2f, 0x97, 0x52, 0x01, 0x2e, 0xcd, 0x00, 0x82, 0x7f, 0x98, 0x2e, 0xbb, 0xcc, 0x0b, 0x30, 0x37, 0x2e, 0xd2, + 0x00, 0x82, 0x6f, 0x90, 0x6f, 0x1a, 0x25, 0x00, 0xb2, 0x8b, 0x7f, 0x14, 0x2f, 0xa6, 0xbd, 0x25, 0xbd, 0xb6, 0xb9, + 0x2f, 0xb9, 0x80, 0xb2, 0xd4, 0xb0, 0x0c, 0x2f, 0x99, 0x54, 0x9b, 0x56, 0x0b, 0x30, 0x0b, 0x2e, 0xb1, 0x00, 0xa1, + 0x58, 0x9b, 0x42, 0xdb, 0x42, 0x6c, 0x09, 0x2b, 0x2e, 0xb1, 0x00, 0x8b, 0x42, 0xcb, 0x42, 0x86, 0x7f, 0x73, 0x84, + 0xa7, 0x56, 0xc3, 0x08, 0x39, 0x52, 0x05, 0x50, 0x72, 0x7f, 0x63, 0x7f, 0x98, 0x2e, 0xc2, 0xc0, 0xe1, 0x6f, 0x62, + 0x6f, 0xd1, 0x0a, 0x01, 0x2e, 0xcd, 0x00, 0xd5, 0x6f, 0xc4, 0x6f, 0x72, 0x6f, 0x97, 0x52, 0x9d, 0x5c, 0x98, 0x2e, + 0x06, 0xcd, 0x23, 0x6f, 0x90, 0x6f, 0x99, 0x52, 0xc0, 0xb2, 0x04, 0xbd, 0x54, 0x40, 0xaf, 0xb9, 0x45, 0x40, 0xe1, + 0x7f, 0x02, 0x30, 0x06, 0x2f, 0xc0, 0xb2, 0x02, 0x30, 0x03, 0x2f, 0x9b, 0x5c, 0x12, 0x30, 0x94, 0x43, 0x85, 0x43, + 0x03, 0xbf, 0x6f, 0xbb, 0x80, 0xb3, 0x20, 0x2f, 0x06, 0x6f, 0x26, 0x01, 0x16, 0x6f, 0x6e, 0x03, 0x45, 0x42, 0xc0, + 0x90, 0x29, 0x2e, 0xce, 0x00, 0x9b, 0x52, 0x14, 0x2f, 0x9b, 0x5c, 0x00, 0x2e, 0x93, 0x41, 0x86, 0x41, 0xe3, 0x04, + 0xae, 0x07, 0x80, 0xab, 0x04, 0x2f, 0x80, 0x91, 0x0a, 0x2f, 0x86, 0x6f, 0x73, 0x0f, 0x07, 0x2f, 0x83, 0x6f, 0xc0, + 0xb2, 0x04, 0x2f, 0x54, 0x42, 0x45, 0x42, 0x12, 0x30, 0x04, 0x2c, 0x11, 0x30, 0x02, 0x2c, 0x11, 0x30, 0x11, 0x30, + 0x02, 0xbc, 0x0f, 0xb8, 0xd2, 0x7f, 0x00, 0xb2, 0x0a, 0x2f, 0x01, 0x2e, 0xfc, 0x00, 0x05, 0x2e, 0xc7, 0x01, 0x10, + 0x1a, 0x02, 0x2f, 0x21, 0x2e, 0xc7, 0x01, 0x03, 0x2d, 0x02, 0x2c, 0x01, 0x30, 0x01, 0x30, 0xb0, 0x6f, 0x98, 0x2e, + 0x95, 0xcf, 0xd1, 0x6f, 0xa0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0xe2, 0x6f, 0x9f, 0x52, 0x01, 0x2e, 0xce, 0x00, 0x82, + 0x40, 0x50, 0x42, 0x0c, 0x2c, 0x42, 0x42, 0x11, 0x30, 0x23, 0x2e, 0xd2, 0x00, 0x01, 0x30, 0xb0, 0x6f, 0x98, 0x2e, + 0x95, 0xcf, 0xa0, 0x6f, 0x01, 0x30, 0x98, 0x2e, 0x95, 0xcf, 0x00, 0x2e, 0xfb, 0x6f, 0x00, 0x5f, 0xb8, 0x2e, 0x83, + 0x86, 0x01, 0x30, 0x00, 0x30, 0x94, 0x40, 0x24, 0x18, 0x06, 0x00, 0x53, 0x0e, 0x4f, 0x02, 0xf9, 0x2f, 0xb8, 0x2e, + 0xa9, 0x52, 0x00, 0x2e, 0x60, 0x40, 0x41, 0x40, 0x0d, 0xbc, 0x98, 0xbc, 0xc0, 0x2e, 0x01, 0x0a, 0x0f, 0xb8, 0xab, + 0x52, 0x53, 0x3c, 0x52, 0x40, 0x40, 0x40, 0x4b, 0x00, 0x82, 0x16, 0x26, 0xb9, 0x01, 0xb8, 0x41, 0x40, 0x10, 0x08, + 0x97, 0xb8, 0x01, 0x08, 0xc0, 0x2e, 0x11, 0x30, 0x01, 0x08, 0x43, 0x86, 0x25, 0x40, 0x04, 0x40, 0xd8, 0xbe, 0x2c, + 0x0b, 0x22, 0x11, 0x54, 0x42, 0x03, 0x80, 0x4b, 0x0e, 0xf6, 0x2f, 0xb8, 0x2e, 0x9f, 0x50, 0x10, 0x50, 0xad, 0x52, + 0x05, 0x2e, 0xd3, 0x00, 0xfb, 0x7f, 0x00, 0x2e, 0x13, 0x40, 0x93, 0x42, 0x41, 0x0e, 0xfb, 0x2f, 0x98, 0x2e, 0xa5, + 0xb7, 0x98, 0x2e, 0x87, 0xcf, 0x01, 0x2e, 0xd9, 0x00, 0x00, 0xb2, 0xfb, 0x6f, 0x0b, 0x2f, 0x01, 0x2e, 0x69, 0xf7, + 0xb1, 0x3f, 0x01, 0x08, 0x01, 0x30, 0xf0, 0x5f, 0x23, 0x2e, 0xd9, 0x00, 0x21, 0x2e, 0x69, 0xf7, 0x80, 0x2e, 0x7a, + 0xb7, 0xf0, 0x5f, 0xb8, 0x2e, 0x01, 0x2e, 0xc0, 0xf8, 0x03, 0x2e, 0xfc, 0xf5, 0x15, 0x54, 0xaf, 0x56, 0x82, 0x08, + 0x0b, 0x2e, 0x69, 0xf7, 0xcb, 0x0a, 0xb1, 0x58, 0x80, 0x90, 0xdd, 0xbe, 0x4c, 0x08, 0x5f, 0xb9, 0x59, 0x22, 0x80, + 0x90, 0x07, 0x2f, 0x03, 0x34, 0xc3, 0x08, 0xf2, 0x3a, 0x0a, 0x08, 0x02, 0x35, 0xc0, 0x90, 0x4a, 0x0a, 0x48, 0x22, + 0xc0, 0x2e, 0x23, 0x2e, 0xfc, 0xf5, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0x56, 0xc7, 0x98, 0x2e, 0x49, 0xc3, 0x10, + 0x30, 0xfb, 0x6f, 0xf0, 0x5f, 0x21, 0x2e, 0xcc, 0x00, 0x21, 0x2e, 0xca, 0x00, 0xb8, 0x2e, 0x03, 0x2e, 0xd3, 0x00, + 0x16, 0xb8, 0x02, 0x34, 0x4a, 0x0c, 0x21, 0x2e, 0x2d, 0xf5, 0xc0, 0x2e, 0x23, 0x2e, 0xd3, 0x00, 0x03, 0xbc, 0x21, + 0x2e, 0xd5, 0x00, 0x03, 0x2e, 0xd5, 0x00, 0x40, 0xb2, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x01, 0x30, 0x05, 0x2f, + 0x05, 0x2e, 0xd8, 0x00, 0x80, 0x90, 0x01, 0x2f, 0x23, 0x2e, 0x6f, 0xf5, 0xc0, 0x2e, 0x21, 0x2e, 0xd9, 0x00, 0x11, + 0x30, 0x81, 0x08, 0x01, 0x2e, 0x6a, 0xf7, 0x71, 0x3f, 0x23, 0xbd, 0x01, 0x08, 0x02, 0x0a, 0xc0, 0x2e, 0x21, 0x2e, + 0x6a, 0xf7, 0x30, 0x25, 0x00, 0x30, 0x21, 0x2e, 0x5a, 0xf5, 0x10, 0x50, 0x21, 0x2e, 0x7b, 0x00, 0x21, 0x2e, 0x7c, + 0x00, 0xfb, 0x7f, 0x98, 0x2e, 0xc3, 0xb7, 0x40, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0xfb, 0x6f, 0xf0, 0x5f, 0x03, 0x25, + 0x80, 0x2e, 0xaf, 0xb7, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x01, 0x2e, 0x5d, 0xf7, 0x08, 0xbc, 0x80, 0xac, 0x0e, 0xbb, 0x02, 0x2f, + 0x00, 0x30, 0x41, 0x04, 0x82, 0x06, 0xc0, 0xa4, 0x00, 0x30, 0x11, 0x2f, 0x40, 0xa9, 0x03, 0x2f, 0x40, 0x91, 0x0d, + 0x2f, 0x00, 0xa7, 0x0b, 0x2f, 0x80, 0xb3, 0xb3, 0x58, 0x02, 0x2f, 0x90, 0xa1, 0x26, 0x13, 0x20, 0x23, 0x80, 0x90, + 0x10, 0x30, 0x01, 0x2f, 0xcc, 0x0e, 0x00, 0x2f, 0x00, 0x30, 0xb8, 0x2e, 0xb5, 0x50, 0x18, 0x08, 0x08, 0xbc, 0x88, + 0xb6, 0x0d, 0x17, 0xc6, 0xbd, 0x56, 0xbc, 0xb7, 0x58, 0xda, 0xba, 0x04, 0x01, 0x1d, 0x0a, 0x10, 0x50, 0x05, 0x30, + 0x32, 0x25, 0x45, 0x03, 0xfb, 0x7f, 0xf6, 0x30, 0x21, 0x25, 0x98, 0x2e, 0x37, 0xca, 0x16, 0xb5, 0x9a, 0xbc, 0x06, + 0xb8, 0x80, 0xa8, 0x41, 0x0a, 0x0e, 0x2f, 0x80, 0x90, 0x02, 0x2f, 0x2d, 0x50, 0x48, 0x0f, 0x09, 0x2f, 0xbf, 0xa0, + 0x04, 0x2f, 0xbf, 0x90, 0x06, 0x2f, 0xb7, 0x54, 0xca, 0x0f, 0x03, 0x2f, 0x00, 0x2e, 0x02, 0x2c, 0xb7, 0x52, 0x2d, + 0x52, 0xf2, 0x33, 0x98, 0x2e, 0xd9, 0xc0, 0xfb, 0x6f, 0xf1, 0x37, 0xc0, 0x2e, 0x01, 0x08, 0xf0, 0x5f, 0xbf, 0x56, + 0xb9, 0x54, 0xd0, 0x40, 0xc4, 0x40, 0x0b, 0x2e, 0xfd, 0xf3, 0xbf, 0x52, 0x90, 0x42, 0x94, 0x42, 0x95, 0x42, 0x05, + 0x30, 0xc1, 0x50, 0x0f, 0x88, 0x06, 0x40, 0x04, 0x41, 0x96, 0x42, 0xc5, 0x42, 0x48, 0xbe, 0x73, 0x30, 0x0d, 0x2e, + 0xd8, 0x00, 0x4f, 0xba, 0x84, 0x42, 0x03, 0x42, 0x81, 0xb3, 0x02, 0x2f, 0x2b, 0x2e, 0x6f, 0xf5, 0x06, 0x2d, 0x05, + 0x2e, 0x77, 0xf7, 0xbd, 0x56, 0x93, 0x08, 0x25, 0x2e, 0x77, 0xf7, 0xbb, 0x54, 0x25, 0x2e, 0xc2, 0xf5, 0x07, 0x2e, + 0xfd, 0xf3, 0x42, 0x30, 0xb4, 0x33, 0xda, 0x0a, 0x4c, 0x00, 0x27, 0x2e, 0xfd, 0xf3, 0x43, 0x40, 0xd4, 0x3f, 0xdc, + 0x08, 0x43, 0x42, 0x00, 0x2e, 0x00, 0x2e, 0x43, 0x40, 0x24, 0x30, 0xdc, 0x0a, 0x43, 0x42, 0x04, 0x80, 0x03, 0x2e, + 0xfd, 0xf3, 0x4a, 0x0a, 0x23, 0x2e, 0xfd, 0xf3, 0x61, 0x34, 0xc0, 0x2e, 0x01, 0x42, 0x00, 0x2e, 0x60, 0x50, 0x1a, + 0x25, 0x7a, 0x86, 0xe0, 0x7f, 0xf3, 0x7f, 0x03, 0x25, 0xc3, 0x52, 0x41, 0x84, 0xdb, 0x7f, 0x33, 0x30, 0x98, 0x2e, + 0x16, 0xc2, 0x1a, 0x25, 0x7d, 0x82, 0xf0, 0x6f, 0xe2, 0x6f, 0x32, 0x25, 0x16, 0x40, 0x94, 0x40, 0x26, 0x01, 0x85, + 0x40, 0x8e, 0x17, 0xc4, 0x42, 0x6e, 0x03, 0x95, 0x42, 0x41, 0x0e, 0xf4, 0x2f, 0xdb, 0x6f, 0xa0, 0x5f, 0xb8, 0x2e, + 0xb0, 0x51, 0xfb, 0x7f, 0x98, 0x2e, 0xe8, 0x0d, 0x5a, 0x25, 0x98, 0x2e, 0x0f, 0x0e, 0xcb, 0x58, 0x32, 0x87, 0xc4, + 0x7f, 0x65, 0x89, 0x6b, 0x8d, 0xc5, 0x5a, 0x65, 0x7f, 0xe1, 0x7f, 0x83, 0x7f, 0xa6, 0x7f, 0x74, 0x7f, 0xd0, 0x7f, + 0xb6, 0x7f, 0x94, 0x7f, 0x17, 0x30, 0xc7, 0x52, 0xc9, 0x54, 0x51, 0x7f, 0x00, 0x2e, 0x85, 0x6f, 0x42, 0x7f, 0x00, + 0x2e, 0x51, 0x41, 0x45, 0x81, 0x42, 0x41, 0x13, 0x40, 0x3b, 0x8a, 0x00, 0x40, 0x4b, 0x04, 0xd0, 0x06, 0xc0, 0xac, + 0x85, 0x7f, 0x02, 0x2f, 0x02, 0x30, 0x51, 0x04, 0xd3, 0x06, 0x41, 0x84, 0x05, 0x30, 0x5d, 0x02, 0xc9, 0x16, 0xdf, + 0x08, 0xd3, 0x00, 0x8d, 0x02, 0xaf, 0xbc, 0xb1, 0xb9, 0x59, 0x0a, 0x65, 0x6f, 0x11, 0x43, 0xa1, 0xb4, 0x52, 0x41, + 0x53, 0x41, 0x01, 0x43, 0x34, 0x7f, 0x65, 0x7f, 0x26, 0x31, 0xe5, 0x6f, 0xd4, 0x6f, 0x98, 0x2e, 0x37, 0xca, 0x32, + 0x6f, 0x75, 0x6f, 0x83, 0x40, 0x42, 0x41, 0x23, 0x7f, 0x12, 0x7f, 0xf6, 0x30, 0x40, 0x25, 0x51, 0x25, 0x98, 0x2e, + 0x37, 0xca, 0x14, 0x6f, 0x20, 0x05, 0x70, 0x6f, 0x25, 0x6f, 0x69, 0x07, 0xa2, 0x6f, 0x31, 0x6f, 0x0b, 0x30, 0x04, + 0x42, 0x9b, 0x42, 0x8b, 0x42, 0x55, 0x42, 0x32, 0x7f, 0x40, 0xa9, 0xc3, 0x6f, 0x71, 0x7f, 0x02, 0x30, 0xd0, 0x40, + 0xc3, 0x7f, 0x03, 0x2f, 0x40, 0x91, 0x15, 0x2f, 0x00, 0xa7, 0x13, 0x2f, 0x00, 0xa4, 0x11, 0x2f, 0x84, 0xbd, 0x98, + 0x2e, 0x79, 0xca, 0x55, 0x6f, 0xb7, 0x54, 0x54, 0x41, 0x82, 0x00, 0xf3, 0x3f, 0x45, 0x41, 0xcb, 0x02, 0xf6, 0x30, + 0x98, 0x2e, 0x37, 0xca, 0x35, 0x6f, 0xa4, 0x6f, 0x41, 0x43, 0x03, 0x2c, 0x00, 0x43, 0xa4, 0x6f, 0x35, 0x6f, 0x17, + 0x30, 0x42, 0x6f, 0x51, 0x6f, 0x93, 0x40, 0x42, 0x82, 0x00, 0x41, 0xc3, 0x00, 0x03, 0x43, 0x51, 0x7f, 0x00, 0x2e, + 0x94, 0x40, 0x41, 0x41, 0x4c, 0x02, 0xc4, 0x6f, 0xd1, 0x56, 0x63, 0x0e, 0x74, 0x6f, 0x51, 0x43, 0xa5, 0x7f, 0x8a, + 0x2f, 0x09, 0x2e, 0xd8, 0x00, 0x01, 0xb3, 0x21, 0x2f, 0xcb, 0x58, 0x90, 0x6f, 0x13, 0x41, 0xb6, 0x6f, 0xe4, 0x7f, + 0x00, 0x2e, 0x91, 0x41, 0x14, 0x40, 0x92, 0x41, 0x15, 0x40, 0x17, 0x2e, 0x6f, 0xf5, 0xb6, 0x7f, 0xd0, 0x7f, 0xcb, + 0x7f, 0x98, 0x2e, 0x00, 0x0c, 0x07, 0x15, 0xc2, 0x6f, 0x14, 0x0b, 0x29, 0x2e, 0x6f, 0xf5, 0xc3, 0xa3, 0xc1, 0x8f, + 0xe4, 0x6f, 0xd0, 0x6f, 0xe6, 0x2f, 0x14, 0x30, 0x05, 0x2e, 0x6f, 0xf5, 0x14, 0x0b, 0x29, 0x2e, 0x6f, 0xf5, 0x18, + 0x2d, 0xcd, 0x56, 0x04, 0x32, 0xb5, 0x6f, 0x1c, 0x01, 0x51, 0x41, 0x52, 0x41, 0xc3, 0x40, 0xb5, 0x7f, 0xe4, 0x7f, + 0x98, 0x2e, 0x1f, 0x0c, 0xe4, 0x6f, 0x21, 0x87, 0x00, 0x43, 0x04, 0x32, 0xcf, 0x54, 0x5a, 0x0e, 0xef, 0x2f, 0x15, + 0x54, 0x09, 0x2e, 0x77, 0xf7, 0x22, 0x0b, 0x29, 0x2e, 0x77, 0xf7, 0xfb, 0x6f, 0x50, 0x5e, 0xb8, 0x2e, 0x10, 0x50, + 0x01, 0x2e, 0xd4, 0x00, 0x00, 0xb2, 0xfb, 0x7f, 0x51, 0x2f, 0x01, 0xb2, 0x48, 0x2f, 0x02, 0xb2, 0x42, 0x2f, 0x03, + 0x90, 0x56, 0x2f, 0xd7, 0x52, 0x79, 0x80, 0x42, 0x40, 0x81, 0x84, 0x00, 0x40, 0x42, 0x42, 0x98, 0x2e, 0x93, 0x0c, + 0xd9, 0x54, 0xd7, 0x50, 0xa1, 0x40, 0x98, 0xbd, 0x82, 0x40, 0x3e, 0x82, 0xda, 0x0a, 0x44, 0x40, 0x8b, 0x16, 0xe3, + 0x00, 0x53, 0x42, 0x00, 0x2e, 0x43, 0x40, 0x9a, 0x02, 0x52, 0x42, 0x00, 0x2e, 0x41, 0x40, 0x15, 0x54, 0x4a, 0x0e, + 0x3a, 0x2f, 0x3a, 0x82, 0x00, 0x30, 0x41, 0x40, 0x21, 0x2e, 0x85, 0x0f, 0x40, 0xb2, 0x0a, 0x2f, 0x98, 0x2e, 0xb1, + 0x0c, 0x98, 0x2e, 0x45, 0x0e, 0x98, 0x2e, 0x5b, 0x0e, 0xfb, 0x6f, 0xf0, 0x5f, 0x00, 0x30, 0x80, 0x2e, 0xce, 0xb7, + 0xdd, 0x52, 0xd3, 0x54, 0x42, 0x42, 0x4f, 0x84, 0x73, 0x30, 0xdb, 0x52, 0x83, 0x42, 0x1b, 0x30, 0x6b, 0x42, 0x23, + 0x30, 0x27, 0x2e, 0xd7, 0x00, 0x37, 0x2e, 0xd4, 0x00, 0x21, 0x2e, 0xd6, 0x00, 0x7a, 0x84, 0x17, 0x2c, 0x42, 0x42, + 0x30, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x12, 0x2d, 0x21, 0x30, 0x00, 0x30, 0x23, 0x2e, 0xd4, 0x00, 0x21, 0x2e, 0x7b, + 0xf7, 0x0b, 0x2d, 0x17, 0x30, 0x98, 0x2e, 0x51, 0x0c, 0xd5, 0x50, 0x0c, 0x82, 0x72, 0x30, 0x2f, 0x2e, 0xd4, 0x00, + 0x25, 0x2e, 0x7b, 0xf7, 0x40, 0x42, 0x00, 0x2e, 0xfb, 0x6f, 0xf0, 0x5f, 0xb8, 0x2e, 0x70, 0x50, 0x0a, 0x25, 0x39, + 0x86, 0xfb, 0x7f, 0xe1, 0x32, 0x62, 0x30, 0x98, 0x2e, 0xc2, 0xc4, 0xb5, 0x56, 0xa5, 0x6f, 0xab, 0x08, 0x91, 0x6f, + 0x4b, 0x08, 0xdf, 0x56, 0xc4, 0x6f, 0x23, 0x09, 0x4d, 0xba, 0x93, 0xbc, 0x8c, 0x0b, 0xd1, 0x6f, 0x0b, 0x09, 0xcb, + 0x52, 0xe1, 0x5e, 0x56, 0x42, 0xaf, 0x09, 0x4d, 0xba, 0x23, 0xbd, 0x94, 0x0a, 0xe5, 0x6f, 0x68, 0xbb, 0xeb, 0x08, + 0xbd, 0xb9, 0x63, 0xbe, 0xfb, 0x6f, 0x52, 0x42, 0xe3, 0x0a, 0xc0, 0x2e, 0x43, 0x42, 0x90, 0x5f, 0xd1, 0x50, 0x03, + 0x2e, 0x25, 0xf3, 0x13, 0x40, 0x00, 0x40, 0x9b, 0xbc, 0x9b, 0xb4, 0x08, 0xbd, 0xb8, 0xb9, 0x98, 0xbc, 0xda, 0x0a, + 0x08, 0xb6, 0x89, 0x16, 0xc0, 0x2e, 0x19, 0x00, 0x62, 0x02, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0x81, 0x0d, 0x01, + 0x2e, 0xd4, 0x00, 0x31, 0x30, 0x08, 0x04, 0xfb, 0x6f, 0x01, 0x30, 0xf0, 0x5f, 0x23, 0x2e, 0xd6, 0x00, 0x21, 0x2e, + 0xd7, 0x00, 0xb8, 0x2e, 0x01, 0x2e, 0xd7, 0x00, 0x03, 0x2e, 0xd6, 0x00, 0x48, 0x0e, 0x01, 0x2f, 0x80, 0x2e, 0x1f, + 0x0e, 0xb8, 0x2e, 0xe3, 0x50, 0x21, 0x34, 0x01, 0x42, 0x82, 0x30, 0xc1, 0x32, 0x25, 0x2e, 0x62, 0xf5, 0x01, 0x00, + 0x22, 0x30, 0x01, 0x40, 0x4a, 0x0a, 0x01, 0x42, 0xb8, 0x2e, 0xe3, 0x54, 0xf0, 0x3b, 0x83, 0x40, 0xd8, 0x08, 0xe5, + 0x52, 0x83, 0x42, 0x00, 0x30, 0x83, 0x30, 0x50, 0x42, 0xc4, 0x32, 0x27, 0x2e, 0x64, 0xf5, 0x94, 0x00, 0x50, 0x42, + 0x40, 0x42, 0xd3, 0x3f, 0x84, 0x40, 0x7d, 0x82, 0xe3, 0x08, 0x40, 0x42, 0x83, 0x42, 0xb8, 0x2e, 0xdd, 0x52, 0x00, + 0x30, 0x40, 0x42, 0x7c, 0x86, 0xb9, 0x52, 0x09, 0x2e, 0x70, 0x0f, 0xbf, 0x54, 0xc4, 0x42, 0xd3, 0x86, 0x54, 0x40, + 0x55, 0x40, 0x94, 0x42, 0x85, 0x42, 0x21, 0x2e, 0xd7, 0x00, 0x42, 0x40, 0x25, 0x2e, 0xfd, 0xf3, 0xc0, 0x42, 0x7e, + 0x82, 0x05, 0x2e, 0x7d, 0x00, 0x80, 0xb2, 0x14, 0x2f, 0x05, 0x2e, 0x89, 0x00, 0x27, 0xbd, 0x2f, 0xb9, 0x80, 0x90, + 0x02, 0x2f, 0x21, 0x2e, 0x6f, 0xf5, 0x0c, 0x2d, 0x07, 0x2e, 0x71, 0x0f, 0x14, 0x30, 0x1c, 0x09, 0x05, 0x2e, 0x77, + 0xf7, 0xbd, 0x56, 0x47, 0xbe, 0x93, 0x08, 0x94, 0x0a, 0x25, 0x2e, 0x77, 0xf7, 0xe7, 0x54, 0x50, 0x42, 0x4a, 0x0e, + 0xfc, 0x2f, 0xb8, 0x2e, 0x50, 0x50, 0x02, 0x30, 0x43, 0x86, 0xe5, 0x50, 0xfb, 0x7f, 0xe3, 0x7f, 0xd2, 0x7f, 0xc0, + 0x7f, 0xb1, 0x7f, 0x00, 0x2e, 0x41, 0x40, 0x00, 0x40, 0x48, 0x04, 0x98, 0x2e, 0x74, 0xc0, 0x1e, 0xaa, 0xd3, 0x6f, + 0x14, 0x30, 0xb1, 0x6f, 0xe3, 0x22, 0xc0, 0x6f, 0x52, 0x40, 0xe4, 0x6f, 0x4c, 0x0e, 0x12, 0x42, 0xd3, 0x7f, 0xeb, + 0x2f, 0x03, 0x2e, 0x86, 0x0f, 0x40, 0x90, 0x11, 0x30, 0x03, 0x2f, 0x23, 0x2e, 0x86, 0x0f, 0x02, 0x2c, 0x00, 0x30, + 0xd0, 0x6f, 0xfb, 0x6f, 0xb0, 0x5f, 0xb8, 0x2e, 0x40, 0x50, 0xf1, 0x7f, 0x0a, 0x25, 0x3c, 0x86, 0xeb, 0x7f, 0x41, + 0x33, 0x22, 0x30, 0x98, 0x2e, 0xc2, 0xc4, 0xd3, 0x6f, 0xf4, 0x30, 0xdc, 0x09, 0x47, 0x58, 0xc2, 0x6f, 0x94, 0x09, + 0xeb, 0x58, 0x6a, 0xbb, 0xdc, 0x08, 0xb4, 0xb9, 0xb1, 0xbd, 0xe9, 0x5a, 0x95, 0x08, 0x21, 0xbd, 0xf6, 0xbf, 0x77, + 0x0b, 0x51, 0xbe, 0xf1, 0x6f, 0xeb, 0x6f, 0x52, 0x42, 0x54, 0x42, 0xc0, 0x2e, 0x43, 0x42, 0xc0, 0x5f, 0x50, 0x50, + 0xf5, 0x50, 0x31, 0x30, 0x11, 0x42, 0xfb, 0x7f, 0x7b, 0x30, 0x0b, 0x42, 0x11, 0x30, 0x02, 0x80, 0x23, 0x33, 0x01, + 0x42, 0x03, 0x00, 0x07, 0x2e, 0x80, 0x03, 0x05, 0x2e, 0xd3, 0x00, 0x23, 0x52, 0xe2, 0x7f, 0xd3, 0x7f, 0xc0, 0x7f, + 0x98, 0x2e, 0xb6, 0x0e, 0xd1, 0x6f, 0x08, 0x0a, 0x1a, 0x25, 0x7b, 0x86, 0xd0, 0x7f, 0x01, 0x33, 0x12, 0x30, 0x98, + 0x2e, 0xc2, 0xc4, 0xd1, 0x6f, 0x08, 0x0a, 0x00, 0xb2, 0x0d, 0x2f, 0xe3, 0x6f, 0x01, 0x2e, 0x80, 0x03, 0x51, 0x30, + 0xc7, 0x86, 0x23, 0x2e, 0x21, 0xf2, 0x08, 0xbc, 0xc0, 0x42, 0x98, 0x2e, 0xa5, 0xb7, 0x00, 0x2e, 0x00, 0x2e, 0xd0, + 0x2e, 0xb0, 0x6f, 0x0b, 0xb8, 0x03, 0x2e, 0x1b, 0x00, 0x08, 0x1a, 0xb0, 0x7f, 0x70, 0x30, 0x04, 0x2f, 0x21, 0x2e, + 0x21, 0xf2, 0x00, 0x2e, 0x00, 0x2e, 0xd0, 0x2e, 0x98, 0x2e, 0x6d, 0xc0, 0x98, 0x2e, 0x5d, 0xc0, 0xed, 0x50, 0x98, + 0x2e, 0x44, 0xcb, 0xef, 0x50, 0x98, 0x2e, 0x46, 0xc3, 0xf1, 0x50, 0x98, 0x2e, 0x53, 0xc7, 0x35, 0x50, 0x98, 0x2e, + 0x64, 0xcf, 0x10, 0x30, 0x98, 0x2e, 0xdc, 0x03, 0x20, 0x26, 0xc0, 0x6f, 0x02, 0x31, 0x12, 0x42, 0xab, 0x33, 0x0b, + 0x42, 0x37, 0x80, 0x01, 0x30, 0x01, 0x42, 0xf3, 0x37, 0xf7, 0x52, 0xfb, 0x50, 0x44, 0x40, 0xa2, 0x0a, 0x42, 0x42, + 0x8b, 0x31, 0x09, 0x2e, 0x5e, 0xf7, 0xf9, 0x54, 0xe3, 0x08, 0x83, 0x42, 0x1b, 0x42, 0x23, 0x33, 0x4b, 0x00, 0xbc, + 0x84, 0x0b, 0x40, 0x33, 0x30, 0x83, 0x42, 0x0b, 0x42, 0xe0, 0x7f, 0xd1, 0x7f, 0x98, 0x2e, 0x58, 0xb7, 0xd1, 0x6f, + 0x80, 0x30, 0x40, 0x42, 0x03, 0x30, 0xe0, 0x6f, 0xf3, 0x54, 0x04, 0x30, 0x00, 0x2e, 0x00, 0x2e, 0x01, 0x89, 0x62, + 0x0e, 0xfa, 0x2f, 0x43, 0x42, 0x11, 0x30, 0xfb, 0x6f, 0xc0, 0x2e, 0x01, 0x42, 0xb0, 0x5f, 0xc1, 0x4a, 0x00, 0x00, + 0x6d, 0x57, 0x00, 0x00, 0x77, 0x8e, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, 0xd3, 0xff, 0xff, 0xff, 0xe5, 0xff, 0xff, + 0xff, 0xee, 0xe1, 0xff, 0xff, 0x7c, 0x13, 0x00, 0x00, 0x46, 0xe6, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1 + +}; + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py new file mode 100644 index 0000000000..c1616665f9 --- /dev/null +++ b/esphome/components/bmi270/motion.py @@ -0,0 +1,91 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_GYROSCOPE_ODR, + CONF_GYROSCOPE_RANGE, +) +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv + +from . import BMI270Component, bmi270_ns + +DEPENDENCIES = ["i2c"] + +# Enum proxies (must match the C++ enum values exactly) +BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G, + "4G": BMI270AccelRange.BMI270_ACCEL_RANGE_4G, + "8G": BMI270AccelRange.BMI270_ACCEL_RANGE_8G, + "16G": BMI270AccelRange.BMI270_ACCEL_RANGE_16G, +} + +BMI270GyroRange = bmi270_ns.enum("BMI270GyroRange") +GYRO_RANGE_OPTIONS = { + "2000DPS": BMI270GyroRange.BMI270_GYRO_RANGE_2000, + "1000DPS": BMI270GyroRange.BMI270_GYRO_RANGE_1000, + "500DPS": BMI270GyroRange.BMI270_GYRO_RANGE_500, + "250DPS": BMI270GyroRange.BMI270_GYRO_RANGE_250, + "125DPS": BMI270GyroRange.BMI270_GYRO_RANGE_125, +} + +BMI270AccelODR = bmi270_ns.enum("BMI270AccelODR") +ACCEL_ODR_OPTIONS = { + "12_5HZ": BMI270AccelODR.BMI270_ACCEL_ODR_12_5, + "25HZ": BMI270AccelODR.BMI270_ACCEL_ODR_25, + "50HZ": BMI270AccelODR.BMI270_ACCEL_ODR_50, + "100HZ": BMI270AccelODR.BMI270_ACCEL_ODR_100, + "200HZ": BMI270AccelODR.BMI270_ACCEL_ODR_200, + "400HZ": BMI270AccelODR.BMI270_ACCEL_ODR_400, + "800HZ": BMI270AccelODR.BMI270_ACCEL_ODR_800, + "1600HZ": BMI270AccelODR.BMI270_ACCEL_ODR_1600, +} + +BMI270GyroODR = bmi270_ns.enum("BMI270GyroODR") +GYRO_ODR_OPTIONS = { + "25HZ": BMI270GyroODR.BMI270_GYRO_ODR_25, + "50HZ": BMI270GyroODR.BMI270_GYRO_ODR_50, + "100HZ": BMI270GyroODR.BMI270_GYRO_ODR_100, + "200HZ": BMI270GyroODR.BMI270_GYRO_ODR_200, + "400HZ": BMI270GyroODR.BMI270_GYRO_ODR_400, + "800HZ": BMI270GyroODR.BMI270_GYRO_ODR_800, + "1600HZ": BMI270GyroODR.BMI270_GYRO_ODR_1600, + "3200HZ": BMI270GyroODR.BMI270_GYRO_ODR_3200, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(BMI270Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="100HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2000DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x68)) +) + + +# Code generation +async def to_code(config): + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + # Accelerometer sensors + # Hardware configuration + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) + cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) diff --git a/esphome/components/bmi270/sensor.py b/esphome/components/bmi270/sensor.py new file mode 100644 index 0000000000..69235ed8dc --- /dev/null +++ b/esphome/components/bmi270/sensor.py @@ -0,0 +1,41 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj + +from . import CONF_BMI270_ID, BMI270Component + +AUTO_LOAD = ["bmi270"] + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component), + } +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_BMI270_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index ebb4186a2b..9951243f0d 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -5,6 +5,8 @@ CODEOWNERS = ["@esphome/core"] BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" +CONF_ACCELEROMETER_ODR = "accelerometer_odr" +CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" CONF_BYTE_ORDER = "byte_order" CONF_CLIMATE_ID = "climate_id" @@ -13,6 +15,8 @@ CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" +CONF_GYROSCOPE_ODR = "gyroscope_odr" +CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml new file mode 100644 index 0000000000..0ffb1c6281 --- /dev/null +++ b/tests/components/bmi270/common.yaml @@ -0,0 +1,68 @@ +sensor: + - platform: bmi270 + name: "BMI270 Temperature" + + - platform: motion + type: acceleration_x + name: "Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + type: acceleration_y + name: "Accel Y" + accuracy_decimals: 4 + - platform: motion + type: acceleration_z + name: "Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + type: gyroscope_x + name: "Gyro X" + - platform: motion + type: gyroscope_y + name: "Gyro Y" + - platform: motion + type: gyroscope_z + name: "Gyro Z" + + - platform: motion + type: angular_rate_x + name: "Angular Rate X" + - platform: motion + type: angular_rate_y + name: "Angular Rate Y" + - platform: motion + type: angular_rate_z + name: "Angular Rate Z" + + - platform: motion + type: pitch + name: "Pitch" + - platform: motion + type: roll + name: "Roll" + +motion: + - platform: bmi270 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + + # Accelerometer output data rate: 12_5HZ | 25HZ | 50HZ | 100HZ | + # 200HZ | 400HZ | 800HZ | 1600HZ + accelerometer_odr: 100HZ + + # Gyroscope full-scale range: 125DPS | 250DPS | 500DPS | 1000DPS | 2000DPS + gyroscope_range: 2000DPS + + # Gyroscope output data rate: 25HZ | 50HZ | 100HZ | 200HZ | + # 400HZ | 800HZ | 1600HZ | 3200HZ + gyroscope_odr: 200HZ + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/bmi270/test.esp32-idf.yaml b/tests/components/bmi270/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/bmi270/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From 4963ddcb95d777b0753bf7ecaca8b43b280fe45e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:09:50 -0400 Subject: [PATCH 1593/2214] [espidf] Fix idedata generation on Windows (#16894) --- esphome/espidf/idedata.py | 66 ++++++++++++++++++++--- tests/unit_tests/test_espidf_idedata.py | 70 ++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 9 deletions(-) diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py index 6fce8a55d9..0ed357a759 100644 --- a/esphome/espidf/idedata.py +++ b/esphome/espidf/idedata.py @@ -29,6 +29,54 @@ _INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s") _ESPHOME_SRC_MARKER = "/src/esphome/" +def _is_esphome_src(file: str) -> bool: + """Whether ``file`` is an ESPHome C++ translation unit. + + ``compile_commands.json`` ``file`` paths use the OS-native separator, so on + Windows they contain backslashes; normalize to ``/`` before testing the + marker, otherwise no source matches and the build-include union is empty. + """ + return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith( + _CXX_SUFFIXES + ) + + +def _split_command(command: str) -> list[str]: + r"""Tokenize a compile_commands.json / response-file command string. + + On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``. + ESP-IDF's compile_commands.json there mixes two backslash conventions in one + string: literal path separators in the compiler path (``C:\Users\...g++.exe``, + no quote follows) and shell quote-escaping in -D defines (``-DVER=\"1.2.3\"``). + Only the real Windows parser — where a backslash escapes solely a following + quote — handles both, and it is the exact tokenizer the compiler is launched + with. ``shlex`` cannot: POSIX mode eats the path separators, and disabling + its escape mangles the defines. + """ + if os.name != "nt": + return shlex.split(command) + + import ctypes + from ctypes import wintypes + + # CommandLineToArgvW("") returns the current process name, not []; guard it + # so an empty response file tokenizes the same as it would via shlex. + if not command.strip(): + return [] + + CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW + CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] + CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) + argc = ctypes.c_int() + argv = CommandLineToArgvW(command, ctypes.byref(argc)) + if not argv: # pragma: no cover + raise ctypes.WinError() + try: + return [argv[i] for i in range(argc.value)] + finally: + ctypes.windll.kernel32.LocalFree(argv) + + def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: """Inline any ``@response-file`` arguments (paths relative to ``directory``). @@ -45,7 +93,7 @@ def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: try: out.extend( _expand_response_files( - shlex.split(rf.read_text(encoding="utf-8")), directory + _split_command(rf.read_text(encoding="utf-8")), directory ) ) continue @@ -64,8 +112,7 @@ def _pick_entry(entries: list[dict]) -> dict: them yields the cxx_path / cxx_flags / defines we need. """ for entry in entries: - f = entry["file"] - if _ESPHOME_SRC_MARKER in f and f.endswith(_CXX_SUFFIXES): + if _is_esphome_src(entry["file"]): return entry for entry in entries: if entry["file"].endswith(_CXX_SUFFIXES): @@ -76,18 +123,22 @@ def _pick_entry(entries: list[dict]) -> dict: def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: """Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags).""" directory = Path(entry["directory"]) - tokens = _expand_response_files(shlex.split(entry["command"]), directory) + tokens = _expand_response_files(_split_command(entry["command"]), directory) def _include(raw: str) -> str: # Include paths in compile_commands are interpreted relative to the # entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them # so the cached idedata is usable regardless of the consumer's cwd. + # Emit forward slashes (``normpath`` yields ``\`` on Windows) so the + # paths match the absolute, already-forward-slash entries in the JSON. raw = raw.strip() if raw and not Path(raw).is_absolute(): raw = os.path.normpath(directory / raw) - return raw + return raw.replace("\\", "/") - cxx_path = tokens[0] + # token0 is the compiler path; the rest of the command already uses forward + # slashes on Windows, so normalize it too for a consistent idedata file. + cxx_path = tokens[0].replace("\\", "/") defines: list[str] = [] includes: list[str] = [] cxx_flags: list[str] = [] @@ -161,8 +212,7 @@ def idedata_from_build(compile_commands: Path) -> dict: build_includes: dict[str, None] = {} for entry in entries: - f = entry["file"] - if _ESPHOME_SRC_MARKER not in f or not f.endswith(_CXX_SUFFIXES): + if not _is_esphome_src(entry["file"]): continue for inc in _parse_entry(entry)[2]: build_includes.setdefault(inc, None) diff --git a/tests/unit_tests/test_espidf_idedata.py b/tests/unit_tests/test_espidf_idedata.py index 849ef274ed..1088517ed1 100644 --- a/tests/unit_tests/test_espidf_idedata.py +++ b/tests/unit_tests/test_espidf_idedata.py @@ -72,7 +72,9 @@ def test_parse_entry_resolves_relative_includes() -> None: _, _, includes, _ = idedata._parse_entry(entry) def resolved(rel: str) -> str: - return os.path.normpath(Path(directory) / rel) + # _parse_entry emits forward slashes for consistency (normpath would + # yield backslashes on Windows). + return os.path.normpath(Path(directory) / rel).replace("\\", "/") assert resolved("config") in includes assert resolved("../shared") in includes # ../ normalized away @@ -124,6 +126,29 @@ def test_pick_entry_prefers_esphome_tu() -> None: assert idedata._pick_entry(entries)["file"].endswith("app.cpp") +def test_pick_entry_falls_back_to_any_cxx_tu() -> None: + """With no ``/src/esphome/`` TU present, the first C++ entry is the fallback.""" + entries = [ + _entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"), + _entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"), + ] + assert idedata._pick_entry(entries)["file"].endswith("x.cpp") + + +def test_is_esphome_src_handles_backslash_paths() -> None: + r"""The src marker must match Windows ``\src\esphome\`` paths too. + + compile_commands ``file`` entries use the OS-native separator; if the + marker only matched forward slashes no source would match on Windows and + the build-include union would be silently empty. + """ + assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp") + assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp") + # non-esphome and non-C++ still rejected regardless of separator + assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp") + assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h") + + def test_idedata_from_build(tmp_path: Path) -> None: """Full transform: representative entry + include union + toolchain dirs.""" compile_commands = tmp_path / "compile_commands.json" @@ -194,3 +219,46 @@ def test_get_toolchain_includes_raises_when_no_dirs_found() -> None: pytest.raises(RuntimeError, match="builtin include dirs"), ): idedata._get_toolchain_includes("/some/compiler") + + +# ESP-IDF's compile_commands.json on Windows mixes literal backslash path +# separators in the compiler path with shell ``\"`` quote-escaping in defines, +# which only the real Windows argv parser handles. These exercise that path. +@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization") +def test_split_command_preserves_paths_and_unescapes_quotes() -> None: + r"""Backslash paths survive while ``\"`` define-quoting is unescaped.""" + command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp" + + tokens = idedata._split_command(command) + + assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe" + assert '-DVER="1.2.3"' in tokens + assert "-IC:/inc/a" in tokens + + +@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization") +def test_split_command_empty_returns_empty() -> None: + """An empty or blank command tokenizes to ``[]`` (e.g. an empty response file). + + Guards against ``CommandLineToArgvW("")`` returning the current process name + instead of an empty list. + """ + assert idedata._split_command("") == [] + assert idedata._split_command(" ") == [] + + +@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization") +def test_parse_entry_normalizes_windows_cxx_path() -> None: + """A backslash compiler path is emitted forward-slashed; define unescaped.""" + entry = _entry( + r"C:\b", + r"C:\b\src\esphome\x.cpp", + r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp", + ) + + cxx_path, defines, includes, _ = idedata._parse_entry(entry) + + assert cxx_path == "C:/esp/bin/g++.exe" + assert "\\" not in cxx_path + assert 'VER="1.2.3"' in defines + assert "C:/inc/a" in includes From e16a877745bcee26ce040109b35bc0872ef27b62 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:11:11 -0400 Subject: [PATCH 1594/2214] [platformio] De-duplicate non-ESP32 lib_deps into common:idf-component-libs (#16893) --- .clang-tidy.hash | 2 +- platformio.ini | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 3c1c2be289..6f6339ff84 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -fe0fe4fde52c61eb40b1214675af8db44d2678c6b7bc2674d51ed4836ecf94da +442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 diff --git a/platformio.ini b/platformio.ini index d60a4fd68d..718dfb672f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -97,6 +97,16 @@ build_flags = build_unflags = ${common.build_unflags} +; Libraries shared by the non-ESP32 embedded environments (esp8266, rp2040, +; libretiny, nrf52). On ESP32 these are provided as ESP-IDF managed components +; via the esphome/idf_component.yml manifest, so they must not be listed in the +; esp32 envs (which would double-include them). +[common:idf-component-libs] +lib_deps = + esphome/dlms_parser@1.1.0 ; dlms_meter + bblanchon/ArduinoJson@7.4.2 ; json + lvgl/lvgl@9.5.0 ; lvgl + ; This are common settings for the ESP8266 using Arduino. [common:esp8266-arduino] extends = common:arduino @@ -107,9 +117,8 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} - esphome/dlms_parser@1.1.0 ; dlms_meter + ${common:idf-component-libs.lib_deps} fastled/FastLED@3.9.16 ; fastled_base - bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) Update ; ota (Arduino built-in) ESP32Async/ESPAsyncTCP@2.0.0 ; async_tcp @@ -119,7 +128,6 @@ lib_deps = ESP8266mDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) droscy/esp_wireguard@0.4.5 ; wireguard - lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} @@ -194,12 +202,9 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} - esphome/dlms_parser@1.1.0 ; dlms_meter - fastled/FastLED@3.9.16 ; fastled_base + ${common:idf-component-libs.lib_deps} ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp - bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base - lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} -DUSE_RP2040 @@ -214,11 +219,9 @@ platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 framework = arduino lib_compat_mode = soft lib_deps = - esphome/dlms_parser@1.1.0 ; dlms_meter - bblanchon/ArduinoJson@7.4.2 ; json + ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY @@ -239,9 +242,7 @@ build_flags = -DUSE_NRF52 lib_deps = ${common.lib_deps_base} - esphome/dlms_parser@1.1.0 ; dlms_meter - bblanchon/ArduinoJson@7.4.2 ; json - lvgl/lvgl@9.5.0 ; lvgl + ${common:idf-component-libs.lib_deps} ; All the actual environments are defined below. From 6809af3de0a7369203287157a3388bd4d059b6e7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:12:28 -0400 Subject: [PATCH 1595/2214] [espidf] Warn when the install path is too long for Windows MAX_PATH (#16896) --- esphome/espidf/framework.py | 71 +++++++++++++ tests/unit_tests/test_espidf_framework.py | 118 ++++++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 1bc79cc412..c0e9a0051f 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -85,6 +85,75 @@ def _get_idf_tools_path() -> Path: return CORE.data_dir / "idf" +# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply +# below the IDF tools directory: the longest file on disk (picolibc C++ +# headers) sits ~209 characters down, but the operative number is worse -- gcc +# probes its multilib include dirs via un-normalized self-relative paths +# ("bin/../lib/gcc///../../../..//include/..."), and +# Windows checks the path string as given, before collapsing "..". Measured +# worst case (riscv32, esp-15.2.0, longest multilib + no-rtti, probing +# bits/c++config.h): ~243 characters below the tools directory. Exceeding the +# limit surfaces as cryptic build failures -- missing headers ("fatal error: +# bits/c++config.h: No such file or directory") or partial extraction +# ("cannot execute 'as'"). Warn up front so the user can shorten the path or +# enable long path support. +_WINDOWS_MAX_PATH = 260 +# Measured 243 plus a small safety margin for future toolchain growth. +_TOOLCHAIN_NESTED_PATH_LEN = 245 + + +def _windows_long_paths_enabled() -> bool: + """Return True if Windows long path support is enabled in the registry.""" + try: + import winreg # pylint: disable=import-error # Windows-only module + + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\FileSystem", + ) as key: + value, _ = winreg.QueryValueEx(key, "LongPathsEnabled") + return value == 1 + except OSError: + return False + + +def _check_windows_path_length() -> None: + """Warn when the install path is too long for Windows' MAX_PATH limit. + + No-op off Windows or when long path support is enabled. Otherwise warns if + the deepest toolchain file would exceed the 260-character limit, which makes + ESP-IDF toolchains extract incompletely and fail to build. + """ + if platform.system() != "Windows" or _windows_long_paths_enabled(): + return + tools_path = str(_get_idf_tools_path()) + projected = len(tools_path) + _TOOLCHAIN_NESTED_PATH_LEN + if projected <= _WINDOWS_MAX_PATH: + return + _LOGGER.warning( + "ESP-IDF tools path is too long for the default Windows path limit:\n" + " %s (%d characters)\n" + "ESP-IDF toolchain paths reach up to ~%d characters deeper (including the\n" + "compiler's internal 'bin/../lib/...' relative paths), projecting to ~%d\n" + "characters -- over the %d-character limit. This causes cryptic build\n" + "failures such as:\n" + " fatal error: bits/c++config.h: No such file or directory\n" + " cannot execute 'as': CreateProcess: No such file or directory\n" + "To fix, either:\n" + " - Enable Windows long path support: set\n" + " HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled\n" + " to 1 and reboot, or\n" + " - Move your ESPHome project to a shorter path\n" + "Then delete the ESP-IDF tools directory above so the toolchain " + "reinstalls cleanly.", + tools_path, + len(tools_path), + _TOOLCHAIN_NESTED_PATH_LEN, + projected, + _WINDOWS_MAX_PATH, + ) + + def _get_framework_path(version: str) -> Path: """ Get the path to the ESPHome ESP-IDF framework directory for a specific version. @@ -705,6 +774,8 @@ def check_esp_idf_install( Returns: tuple of (framework_path, python_env_path) """ + _check_windows_path_length() + env = {} env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) env["IDF_PATH"] = "" diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 036c7c0454..d89b93f478 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -2,9 +2,12 @@ # pylint: disable=protected-access +from contextlib import contextmanager import io import json +import logging from pathlib import Path +import sys import tarfile from types import SimpleNamespace from unittest.mock import patch @@ -13,6 +16,7 @@ import pytest from esphome.espidf.framework import ( _check_stamp, + _check_windows_path_length, _clone_idf_with_submodules, _get_framework_path, _get_idf_tool_paths, @@ -22,6 +26,7 @@ from esphome.espidf.framework import ( _get_python_version, _parse_git_source, _patch_tools_json_for_linux_arm64, + _windows_long_paths_enabled, _write_idf_version_txt, _write_stamp, check_esp_idf_install, @@ -682,3 +687,116 @@ def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: with patch("pathlib.Path.write_text", side_effect=OSError("denied")): # write failure is caught and warned, not raised _write_idf_version_txt(tmp_path, "5.1.2") + + +def _fake_winreg( + query_result: int | None = None, query_error: OSError | None = None +) -> SimpleNamespace: + """Build a minimal winreg stand-in (the real module is Windows-only).""" + + @contextmanager + def open_key(root, path): + yield "hkey" + + def query_value_ex(key, name): + if query_error is not None: + raise query_error + return query_result, 4 # (value, REG_DWORD) + + return SimpleNamespace( + HKEY_LOCAL_MACHINE=object(), + OpenKey=open_key, + QueryValueEx=query_value_ex, + ) + + +@pytest.mark.parametrize(("reg_value", "expected"), [(1, True), (0, False)]) +def test_windows_long_paths_enabled_reads_registry( + reg_value: int, expected: bool +) -> None: + with patch.dict(sys.modules, {"winreg": _fake_winreg(query_result=reg_value)}): + assert _windows_long_paths_enabled() is expected + + +def test_windows_long_paths_enabled_missing_value() -> None: + """A missing registry value (FileNotFoundError is an OSError) reads as disabled.""" + fake = _fake_winreg(query_error=FileNotFoundError("no such value")) + with patch.dict(sys.modules, {"winreg": fake}): + assert _windows_long_paths_enabled() is False + + +# 8 chars -> projected well under the 260 limit even with the ~245-char reserve +_SHORT_IDF_PATH = "C:\\e\\idf" +# 25 chars -> projected over the limit +_LONG_IDF_PATH = "C:\\Users\\bob\\.esphome\\idf" + + +def test_check_windows_path_length_noop_off_windows( + caplog: pytest.LogCaptureFixture, +) -> None: + """Off Windows the check returns before touching the registry or the path.""" + with ( + patch("esphome.espidf.framework.platform.system", return_value="Linux"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled" + ) as long_paths_mock, + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + long_paths_mock.assert_not_called() + assert not caplog.records + + +def test_check_windows_path_length_noop_when_long_paths_enabled( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=True + ), + patch("esphome.espidf.framework._get_idf_tools_path") as get_path_mock, + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + get_path_mock.assert_not_called() + assert not caplog.records + + +def test_check_windows_path_length_short_path_silent( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=False + ), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=_SHORT_IDF_PATH, + ), + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + assert not caplog.records + + +def test_check_windows_path_length_long_path_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=False + ), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=_LONG_IDF_PATH, + ), + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + assert len(caplog.records) == 1 + message = caplog.records[0].getMessage() + assert _LONG_IDF_PATH in message + assert "long path support" in message From a25ac28ae5224e53fdac8df0a0b5a8c603222da6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:22:44 +1000 Subject: [PATCH 1596/2214] [lsm6ds] Add motion platform for STMicro LSM6DS IMU (#16232) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/lsm6ds/__init__.py | 15 ++ esphome/components/lsm6ds/lsm6ds.cpp | 203 ++++++++++++++++++++ esphome/components/lsm6ds/lsm6ds.h | 111 +++++++++++ esphome/components/lsm6ds/motion.py | 106 ++++++++++ esphome/components/lsm6ds/sensor.py | 39 ++++ tests/components/lsm6ds/common.yaml | 63 ++++++ tests/components/lsm6ds/test.esp32-idf.yaml | 4 + 8 files changed, 542 insertions(+) create mode 100644 esphome/components/lsm6ds/__init__.py create mode 100644 esphome/components/lsm6ds/lsm6ds.cpp create mode 100644 esphome/components/lsm6ds/lsm6ds.h create mode 100644 esphome/components/lsm6ds/motion.py create mode 100644 esphome/components/lsm6ds/sensor.py create mode 100644 tests/components/lsm6ds/common.yaml create mode 100644 tests/components/lsm6ds/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 300ae13cf4..10128c64e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -291,6 +291,7 @@ esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow esphome/components/lps22/* @nagisa +esphome/components/lsm6ds/* @clydebarrow esphome/components/ltr390/* @latonita @sjtrny esphome/components/ltr501/* @latonita esphome/components/ltr_als_ps/* @latonita diff --git a/esphome/components/lsm6ds/__init__.py b/esphome/components/lsm6ds/__init__.py new file mode 100644 index 0000000000..b1044276a1 --- /dev/null +++ b/esphome/components/lsm6ds/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] + +CONF_LSM6DS_ID = "lsm6ds_id" +# C++ namespace / class + +lsm6ds_ns = cg.esphome_ns.namespace("lsm6ds") +LSM6DSComponent = lsm6ds_ns.class_( + "LSM6DSComponent", + MotionComponent, + i2c.I2CDevice, +) diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp new file mode 100644 index 0000000000..efdd241578 --- /dev/null +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -0,0 +1,203 @@ +#include "lsm6ds.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::lsm6ds { + +static const char *const TAG = "lsm6ds"; + +static const struct { + uint8_t who_am_i; + const char *const name; +} CHIP_IDS[] = {{0x69, "LSMDSO"}, {0x6A, "LSM6DS3"}}; + +void LSM6DSComponent::setup() { + MotionComponent::setup(); + uint8_t who_am_i = 0; + if (this->read_register(LSM6DS_REG_WHO_AM_I, &who_am_i, 1) != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Failed to read WHO_AM_I — check wiring and I2C address"); + this->mark_failed(); + return; + } + const char *chip_name = nullptr; + for (const auto &chip : CHIP_IDS) { + if (chip.who_am_i == who_am_i) { + chip_name = chip.name; + break; + } + } + if (chip_name == nullptr) { + ESP_LOGE(TAG, "Unknown WHO_AM_I: 0x%02X", who_am_i); + this->mark_failed(LOG_STR("Unknown WHO_AM_I value")); + return; + } + ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", chip_name, who_am_i); + this->chip_name_ = chip_name; + + // 2. Software reset — clears all registers to defaults + if (this->write_register(LSM6DS_REG_CTRL3_C, &CTRL3_C_SW_RESET, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Software reset failed")); + return; + } + // Datasheet: reset bit self-clears after boot (typ. 50 µs); + delay(2); + + // 3. Enable auto-increment and block data update (BDU). + // BDU prevents reading a high-byte from one sample and a low-byte from the next. + // IF_INC is set by default after reset but we set it explicitly for clarity. + uint8_t ctrl3 = CTRL3_C_IF_INC | CTRL3_C_BDU; + if (this->write_register(LSM6DS_REG_CTRL3_C, &ctrl3, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Config failed")); + return; + } + + // 4. Configure accelerometer: ODR in bits[7:4], FS in bits[3:2] + // Anti-aliasing filter bandwidth is left at power-on default (bits[1:0] = 00 = ODR/2). + uint8_t ctrl1_xl = (uint8_t) (this->accel_odr_ << 4) | (uint8_t) (this->accel_range_ << 2); + if (this->write_register(LSM6DS_REG_CTRL1_XL, &ctrl1_xl, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to configure accelerometer")); + return; + } + + // 5. Configure gyroscope: ODR in bits[7:4], FS_G + FS_125 in bits[3:0] + // For ±125 dps: FS_G[2:1]=00 and FS_125(bit1)=1, so gyro_range_ encodes the full nibble. + uint8_t ctrl2_g = (uint8_t) (this->gyro_odr_ << 4) | (uint8_t) (this->gyro_range_); + if (this->write_register(LSM6DS_REG_CTRL2_G, &ctrl2_g, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to configure gyroscope")); + return; + } + + // 6. Ensure accelerometer is in high-performance mode (CTRL6_C bit 4 = XL_HM_MODE = 0) + // and gyroscope is in high-performance mode (CTRL7_G bit 7 = G_HM_MODE = 0). + // Both default to 0 (high-performance) after reset, but write explicitly. + uint8_t zero = 0x00; + if (this->write_register(LSM6DS_REG_CTRL6_C, &zero, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + if (this->write_register(LSM6DS_REG_CTRL7_G, &zero, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } +} + +void LSM6DSComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "LSM6DS IMU:\n" + " Chip type: %s\n", + this->chip_name_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + + // Accel range — index into the sensitivity table (datasheet Table 3) + static const char *const ACCEL_RANGE_STR[] = {"±2g", "±16g", "±4g", "±8g"}; + + const char *gyro_str; + switch (this->gyro_range_) { + case LSM6DS_GYRO_RANGE_125: + gyro_str = "±125dps"; + break; + case LSM6DS_GYRO_RANGE_250: + gyro_str = "±250dps"; + break; + case LSM6DS_GYRO_RANGE_500: + gyro_str = "±500dps"; + break; + case LSM6DS_GYRO_RANGE_1000: + gyro_str = "±1000dps"; + break; + case LSM6DS_GYRO_RANGE_2000: + gyro_str = "±2000dps"; + break; + default: + gyro_str = "unknown"; + break; + } + auto accel_odr = this->accel_odr_ == 0 ? 0 : 13 * (1 << (this->accel_odr_ - 1)); + auto gyro_odr = this->gyro_odr_ == 0 ? 0 : 13 * (1 << (this->gyro_odr_ - 1)); + ESP_LOGCONFIG(TAG, + " Accel range : %s\n" + " Accel data rate : %dHz\n" + " Gyro range : %s\n" + " Gyro data rate : %dHz", + ACCEL_RANGE_STR[this->accel_range_], accel_odr, gyro_str, gyro_odr); +} + +// update_data() +// Called by MotionComponent::update() on each polling interval. +// Reads gyro XYZ and accel XYZ in a single 12-byte burst (registers 0x22–0x2D). +// Values are in g (accel) and °/s (gyro) — MotionComponent handles axis mapping +// and sensor publishing. + +bool LSM6DSComponent::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Single burst: gyro X/Y/Z (0x22–0x27) then accel X/Y/Z (0x28–0x2D) + uint8_t raw[LSM6DS_BURST_LEN]; + if (!this->read_bytes(LSM6DS_REG_OUTX_L_G, raw, LSM6DS_BURST_LEN)) { + this->status_set_error(LOG_STR("Failed to read IMU data")); + return false; + } + this->status_clear_error(); + + // Gyroscope + // Sensitivity (mdps/LSB) from datasheet Table 3. + // Multiply by 1e-3 to convert mdps → dps (°/s). + static constexpr float GYRO_SCALE[] = { + 8.75e-3f, // 0x00 — ±250 dps + 8.75e-3f, // 0x01 — unused (maps to 250 as fallback) + 4.375e-3f, // 0x02 — ±125 dps (FS_125 bit set) + 8.75e-3f, // 0x03 — unused + 17.50e-3f, // 0x04 — ±500 dps + 17.50e-3f, // 0x05 — unused + 8.75e-3f, // 0x06 — unused + 8.75e-3f, // 0x07 — unused + 35.0e-3f, // 0x08 — ±1000 dps + 35.0e-3f, // 0x09 — unused + 17.50e-3f, // 0x0A — unused + 17.50e-3f, // 0x0B — unused + 70.0e-3f, // 0x0C — ±2000 dps + }; + float gyro_scale = GYRO_SCALE[this->gyro_range_]; + + data.angular_rate[motion::X_AXIS] = (int16_t) ((raw[1] << 8) | raw[0]) * gyro_scale; + data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw[3] << 8) | raw[2]) * gyro_scale; + data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw[5] << 8) | raw[4]) * gyro_scale; + + // Accelerometer + // Sensitivity (mg/LSB) from datasheet Table 3. + // Multiply by 1e-3 to convert mg → g. + // Note: FS_XL register values are non-monotonic (0=2g, 1=16g, 2=4g, 3=8g). + static constexpr float ACCEL_SCALE[] = { + 0.061e-3f, // 0x00 — ±2g + 0.488e-3f, // 0x01 — ±16g + 0.122e-3f, // 0x02 — ±4g + 0.244e-3f, // 0x03 — ±8g + }; + float accel_scale = ACCEL_SCALE[this->accel_range_]; + + data.acceleration[motion::X_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 1] << 8) | raw[LSM6DS_ACCEL_OFFSET + 0]) * accel_scale; + data.acceleration[motion::Y_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 3] << 8) | raw[LSM6DS_ACCEL_OFFSET + 2]) * accel_scale; + data.acceleration[motion::Z_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 5] << 8) | raw[LSM6DS_ACCEL_OFFSET + 4]) * accel_scale; + + // Temperature (lazy — only read if a listener is registered) + // Kept as a separate 2-byte read to avoid extending the burst to 14 bytes when + // temperature is not needed. + // Formula: T(°C) = (raw / 256.0) + 25.0 (datasheet Table 90, OUT_TEMP register) + if (!this->temperature_callback_.empty()) { + uint8_t raw_t[2]; + if (this->read_bytes(LSM6DS_REG_OUT_TEMP_L, raw_t, 2)) { + int16_t temp_raw = (int16_t) ((raw_t[1] << 8) | raw_t[0]); + float temperature = (temp_raw / 256.0f) + 25.0f; + this->temperature_callback_.call(temperature); + } + } + + return true; +} + +} // namespace esphome::lsm6ds diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h new file mode 100644 index 0000000000..75462ff1fb --- /dev/null +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -0,0 +1,111 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/motion/motion_component.h" + +namespace esphome::lsm6ds { + +// ── Register map (datasheet DocID030071 Rev 3, Table 19) ──────────────────── +static const uint8_t LSM6DS_REG_WHO_AM_I = 0x0F; +static const uint8_t LSM6DS_REG_CTRL1_XL = 0x10; // Accel ODR + FS +static const uint8_t LSM6DS_REG_CTRL2_G = 0x11; // Gyro ODR + FS +static const uint8_t LSM6DS_REG_CTRL3_C = 0x12; // SW_RESET, BDU, IF_INC +static const uint8_t LSM6DS_REG_CTRL6_C = 0x15; // Accel HP disable, Gyro LPF1 +static const uint8_t LSM6DS_REG_CTRL7_G = 0x16; // Gyro HP disable +static const uint8_t LSM6DS_REG_STATUS = 0x1E; // XLDA, GDA, TDA +static const uint8_t LSM6DS_REG_OUT_TEMP_L = 0x20; // Temperature LSB +static const uint8_t LSM6DS_REG_OUTX_L_G = 0x22; // Gyro X LSB (burst start) +static const uint8_t LSM6DS_REG_OUTX_L_XL = 0x28; // Accel X LSB + +// Burst read from 0x22 to 0x2D inclusive: gyro XYZ (6 bytes) + accel XYZ (6 bytes) +static const uint8_t LSM6DS_BURST_LEN = 12; +static const uint8_t LSM6DS_ACCEL_OFFSET = 6; // 0x28 - 0x22 + +// ── CTRL3_C bit fields ─────────────────────────────────────────────────────── +static const uint8_t CTRL3_C_SW_RESET = (1 << 0); +static const uint8_t CTRL3_C_IF_INC = (1 << 2); // auto-increment address on burst (default 1) +static const uint8_t CTRL3_C_BDU = (1 << 6); // block data update + +// ── Accelerometer full-scale range ────────────────────────────────────────── +// CTRL1_XL bits [3:2] — FS_XL[1:0] +// Note: 0x01 = ±16g is intentional per Table 52 — the mapping is non-monotonic +enum LSM6DSAccelRange : uint8_t { + LSM6DS_ACCEL_RANGE_2G = 0x00, // ±2 g, 0.061 mg/LSB + LSM6DS_ACCEL_RANGE_16G = 0x01, // ±16 g, 0.488 mg/LSB + LSM6DS_ACCEL_RANGE_4G = 0x02, // ±4 g, 0.122 mg/LSB + LSM6DS_ACCEL_RANGE_8G = 0x03, // ±8 g, 0.244 mg/LSB +}; + +// ── Accelerometer output data rate ────────────────────────────────────────── +// CTRL1_XL bits [7:4] — ODR_XL[3:0] +enum LSM6DSAccelODR : uint8_t { + LSM6DS_ACCEL_ODR_OFF = 0x00, + LSM6DS_ACCEL_ODR_12_5 = 0x01, // 12.5 Hz + LSM6DS_ACCEL_ODR_26 = 0x02, // 26 Hz + LSM6DS_ACCEL_ODR_52 = 0x03, // 52 Hz + LSM6DS_ACCEL_ODR_104 = 0x04, // 104 Hz + LSM6DS_ACCEL_ODR_208 = 0x05, // 208 Hz + LSM6DS_ACCEL_ODR_416 = 0x06, // 416 Hz + LSM6DS_ACCEL_ODR_833 = 0x07, // 833 Hz + LSM6DS_ACCEL_ODR_1666 = 0x08, // 1666 Hz + LSM6DS_ACCEL_ODR_3332 = 0x09, // 3332 Hz + LSM6DS_ACCEL_ODR_6664 = 0x0A, // 6664 Hz +}; + +// ── Gyroscope full-scale range ─────────────────────────────────────────────── +// CTRL2_G bits [3:0] — FS_G[2:1] and FS_125 (bit 1) +// The FS_125 bit (bit 1) enables the ±125 dps range independently of FS_G. +// For all other ranges, bits [3:2] select the range and bit 1 = 0. +enum LSM6DSGyroRange : uint8_t { + LSM6DS_GYRO_RANGE_125 = 0x02, // ±125 dps, 4.375 mdps/LSB (FS_125=1) + LSM6DS_GYRO_RANGE_250 = 0x00, // ±250 dps, 8.75 mdps/LSB + LSM6DS_GYRO_RANGE_500 = 0x04, // ±500 dps, 17.50 mdps/LSB + LSM6DS_GYRO_RANGE_1000 = 0x08, // ±1000 dps, 35 mdps/LSB + LSM6DS_GYRO_RANGE_2000 = 0x0C, // ±2000 dps, 70 mdps/LSB +}; + +// ── Gyroscope output data rate ─────────────────────────────────────────────── +// CTRL2_G bits [7:4] — ODR_G[3:0] +enum LSM6DSGyroODR : uint8_t { + LSM6DS_GYRO_ODR_OFF = 0x00, + LSM6DS_GYRO_ODR_12_5 = 0x01, // 12.5 Hz + LSM6DS_GYRO_ODR_26 = 0x02, // 26 Hz + LSM6DS_GYRO_ODR_52 = 0x03, // 52 Hz + LSM6DS_GYRO_ODR_104 = 0x04, // 104 Hz + LSM6DS_GYRO_ODR_208 = 0x05, // 208 Hz + LSM6DS_GYRO_ODR_416 = 0x06, // 416 Hz + LSM6DS_GYRO_ODR_833 = 0x07, // 833 Hz + LSM6DS_GYRO_ODR_1666 = 0x08, // 1666 Hz + LSM6DS_GYRO_ODR_3332 = 0x09, // 3332 Hz + LSM6DS_GYRO_ODR_6664 = 0x0A, // 6664 Hz +}; + +// ── Main component class ───────────────────────────────────────────────────── +class LSM6DSComponent : public motion::MotionComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters (called from Python codegen) + void set_accel_range(LSM6DSAccelRange r) { this->accel_range_ = r; } + void set_accel_odr(LSM6DSAccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(LSM6DSGyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(LSM6DSGyroODR o) { this->gyro_odr_ = o; } + + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + const char *chip_name_{"Unknown"}; + bool update_data(motion::MotionData &data) override; + + LSM6DSAccelRange accel_range_{LSM6DS_ACCEL_RANGE_4G}; + LSM6DSAccelODR accel_odr_{LSM6DS_ACCEL_ODR_104}; + LSM6DSGyroRange gyro_range_{LSM6DS_GYRO_RANGE_2000}; + LSM6DSGyroODR gyro_odr_{LSM6DS_GYRO_ODR_208}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::lsm6ds diff --git a/esphome/components/lsm6ds/motion.py b/esphome/components/lsm6ds/motion.py new file mode 100644 index 0000000000..8c2c5198ea --- /dev/null +++ b/esphome/components/lsm6ds/motion.py @@ -0,0 +1,106 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_GYROSCOPE_ODR, + CONF_GYROSCOPE_RANGE, +) +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv + +from . import LSM6DSComponent, lsm6ds_ns + +# ── Dependency declarations ────────────────────────────────────────────────── +DEPENDENCIES = ["i2c"] +DOMAIN = "lsm6ds" + +# ── C++ namespace / class ──────────────────────────────────────────────────── +# ── Enum proxies ───────────────────────────────────────────────────────────── +LSM6DSAccelRange = lsm6ds_ns.enum("LSM6DSAccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_2G, + "4G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_4G, + "8G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_8G, + "16G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_16G, +} + +LSM6DSAccelODR = lsm6ds_ns.enum("LSM6DSAccelODR") +ACCEL_ODR_OPTIONS = { + "OFF": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_OFF, + "12_5HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_12_5, + "26HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_26, + "52HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_52, + "104HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_104, + "208HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_208, + "416HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_416, + "833HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_833, + "1666HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_1666, + "3332HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_3332, + "6664HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_6664, +} + +LSM6DSGyroRange = lsm6ds_ns.enum("LSM6DSGyroRange") +GYRO_RANGE_OPTIONS = { + "125DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_125, + "250DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_250, + "500DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_500, + "1000DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_1000, + "2000DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_2000, +} + +LSM6DSGyroODR = lsm6ds_ns.enum("LSM6DSGyroODR") +GYRO_ODR_OPTIONS = { + "OFF": LSM6DSGyroODR.LSM6DS_GYRO_ODR_OFF, + "12_5HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_12_5, + "26HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_26, + "52HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_52, + "104HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_104, + "208HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_208, + "416HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_416, + "833HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_833, + "1666HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_1666, + "3332HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_3332, + "6664HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_6664, +} + +# ── CONFIG_SCHEMA ───────────────────────────────────────────────────────────── +# Extend the motion platform schema which provides: +# - accel_x/y/z sensor schemas +# - gyro_x/y/z sensor schemas +# - axis_mapping schema + validation +# - update_interval / polling +CONFIG_SCHEMA = ( + motion_schema(LSM6DSComponent, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="104HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2000DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="208HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x6A)) +) + + +# ── Code generation ────────────────────────────────────────────────────────── +async def to_code(config): + var = await new_motion_component(config) + + # Let the motion platform handle sensor wiring, axis mapping, and polling + await i2c.register_i2c_device(var, config) + + # Chip-specific hardware configuration + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) + cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) diff --git a/esphome/components/lsm6ds/sensor.py b/esphome/components/lsm6ds/sensor.py new file mode 100644 index 0000000000..980e84a2e9 --- /dev/null +++ b/esphome/components/lsm6ds/sensor.py @@ -0,0 +1,39 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj + +from . import CONF_LSM6DS_ID, LSM6DSComponent + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): CONF_TEMPERATURE, + cv.GenerateID(CONF_LSM6DS_ID): cv.use_id(LSM6DSComponent), + } +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_LSM6DS_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/components/lsm6ds/common.yaml b/tests/components/lsm6ds/common.yaml new file mode 100644 index 0000000000..832254781f --- /dev/null +++ b/tests/components/lsm6ds/common.yaml @@ -0,0 +1,63 @@ +sensor: + - platform: lsm6ds + name: "lsm6ds Temperature" + + - platform: motion + type: acceleration_x + name: "Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + type: acceleration_y + name: "Accel Y" + accuracy_decimals: 4 + - platform: motion + type: acceleration_z + name: "Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + type: gyroscope_x + name: "Gyro X" + - platform: motion + type: gyroscope_y + name: "Gyro Y" + - platform: motion + type: gyroscope_z + name: "Gyro Z" + + - platform: motion + type: angular_rate_x + name: "Angular Rate X" + - platform: motion + type: angular_rate_y + name: "Angular Rate Y" + - platform: motion + type: angular_rate_z + name: "Angular Rate Z" + + - platform: motion + type: pitch + name: "Pitch" + - platform: motion + type: roll + name: "Roll" + +motion: + - platform: lsm6ds + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + + accelerometer_odr: 104HZ + + gyroscope_range: 2000DPS + + gyroscope_odr: 208HZ + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/lsm6ds/test.esp32-idf.yaml b/tests/components/lsm6ds/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/lsm6ds/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From dafc3560ddb897798050cabf5d7baf9e2be3fa71 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:44:58 +1000 Subject: [PATCH 1597/2214] [tests] Isolate ESPHOME_LOG_STATES in main logs-states tests (#16905) Co-authored-by: Claude Opus 4.8 --- tests/unit_tests/test_main.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e99a630e83..03c005dc27 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -6118,6 +6118,15 @@ def test_should_subscribe_states_env_suppresses() -> None: assert _should_subscribe_states(args) is False +def test_should_subscribe_states_env_enables() -> None: + """Test that ESPHOME_LOG_STATES=true enables states by default.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}): + assert _should_subscribe_states(args) is True + + def test_should_subscribe_states_flag_overrides_env() -> None: """Test that --states overrides ESPHOME_LOG_STATES=false.""" from esphome.__main__ import _should_subscribe_states @@ -6202,7 +6211,11 @@ def test_command_run_defaults_subscribe_states_true( ), patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")), patch("esphome.__main__.get_serial_ports", return_value=[]), + patch.dict(os.environ, {}, clear=False), ): + # Ensure the default behavior is not affected by an ambient + # ESPHOME_LOG_STATES set in the test runner's environment. + os.environ.pop("ESPHOME_LOG_STATES", None) result = command_run(args, CORE.config) assert result == 0 From 29a79b1373be6a0969efb5da0eb330e8407f178b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:25:03 -0400 Subject: [PATCH 1598/2214] [core] Make set_cpp_standard work on the native IDF toolchain (#16907) --- esphome/build_gen/espidf.py | 22 ++++++-- esphome/build_gen/platformio.py | 15 ++++++ esphome/core/__init__.py | 3 ++ esphome/cpp_generator.py | 11 +--- tests/unit_tests/build_gen/test_espidf.py | 50 +++++++++++++++++++ tests/unit_tests/build_gen/test_platformio.py | 40 +++++++++++++++ 6 files changed, 129 insertions(+), 12 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 9cc7a7ff12..9e11d785c0 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -8,6 +8,17 @@ import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed +# Replaces the IDF default C++ standard (-std=gnu++2b appended to +# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via +# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(), +# i.e. after IDF appends its default and before the options are consumed, and +# applies project-wide like PlatformIO build_unflags. +CPP_STANDARD_TEMPLATE = """\ +idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS) +list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=") +list(APPEND esphome_cxx_compile_options "-std={standard}") +idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")""" + def get_available_components() -> list[str] | None: """Get list of built-in ESP-IDF components from project_description.json. @@ -84,6 +95,12 @@ def get_project_cmakelists(minimal: bool = False) -> str: for flag in project_compile_opts ) + cpp_standard_options = ( + CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard) + if CORE.cpp_standard + else "" + ) + # 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. @@ -140,6 +157,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{cpp_standard_options} + {extra_compile_options} {managed_components_property} @@ -200,9 +219,6 @@ idf_component_register( REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} ) -# Apply C++ standard -target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20) - # ESPHome linker options target_link_options(${{COMPONENT_LIB}} PUBLIC {link_opts_str} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 16c1597ccd..a583279ea7 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -33,12 +33,27 @@ def format_ini(data: dict[str, str | list[str]]) -> str: return content +# All -std= variants a platform/framework may set by default, in both the GNU +# and strict dialects; unflagged so the cg.set_cpp_standard() value is the +# only standard left in the build. +CPP_STD_VARIANTS = [ + f"{prefix}{year}" + for year in ("11", "14", "17", "20", "23", "26", "2a", "2b", "2c") + for prefix in ("gnu++", "c++") +] + + def get_ini_content(): CORE.add_platformio_option( "lib_deps", [x.as_lib_dep for x in CORE.platformio_libraries.values()] + ["${common.lib_deps}"], ) + if CORE.cpp_standard: + for variant in CPP_STD_VARIANTS: + if variant != CORE.cpp_standard: + CORE.add_build_unflag(f"-std={variant}") + CORE.add_build_flag(f"-std={CORE.cpp_standard}") # Sort to avoid changing build flags order CORE.add_platformio_option("build_flags", sorted(CORE.build_flags)) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 90c162fedd..4289cdf3e5 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -593,6 +593,8 @@ class EsphomeCore: self.build_flags: set[str] = set() # A set of build unflags to set in the platformio project self.build_unflags: set[str] = set() + # The C++ language standard for the build (e.g. "gnu++20"), set via cg.set_cpp_standard() + self.cpp_standard: str | None = None # A set of defines to set for the compile process in esphome/core/defines.h self.defines: set[Define] = set() # A map of all platformio options to apply @@ -649,6 +651,7 @@ class EsphomeCore: self.platformio_libraries = {} self.build_flags = set() self.build_unflags = set() + self.cpp_standard = None self.defines = set() self.platformio_options = {} self.loaded_integrations = set() diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 151018baa4..582b8fc74d 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -705,15 +705,8 @@ def add_build_unflag(build_unflag: str) -> None: def set_cpp_standard(standard: str) -> None: - """Set C++ standard with compiler flag `-std={standard}`.""" - CORE.add_build_unflag("-std=gnu++11") - CORE.add_build_unflag("-std=gnu++14") - CORE.add_build_unflag("-std=gnu++17") - CORE.add_build_unflag("-std=gnu++23") - CORE.add_build_unflag("-std=gnu++2a") - CORE.add_build_unflag("-std=gnu++2b") - CORE.add_build_unflag("-std=gnu++2c") - CORE.add_build_flag(f"-std={standard}") + """Set the C++ language standard for the build (e.g. ``gnu++20``).""" + CORE.cpp_standard = standard def add_define(name: str, value: SafeExpType = None): diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 540dd06731..a5c2719f42 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -162,3 +162,53 @@ def test_get_project_cmakelists_emits_managed_components_property( "idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS" " espressif__esp-dsp APPEND)" ) in content + + +def test_get_project_cmakelists_replaces_cpp_standard(tmp_path: Path) -> None: + """cg.set_cpp_standard() replaces the IDF default -std in + CXX_COMPILE_OPTIONS between include(project.cmake) and project().""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", "gnu++20"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert ( + "idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS)" + in content + ) + assert 'list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")' in content + assert 'list(APPEND esphome_cxx_compile_options "-std=gnu++20")' in content + # The replacement must come after project.cmake (which appends the IDF + # default) and before project() (which consumes the options). + include_pos = content.index("tools/cmake/project.cmake") + replace_pos = content.index("CXX_COMPILE_OPTIONS") + project_pos = content.index("project(test)") + assert include_pos < replace_pos < project_pos + + +def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", None), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert "CXX_COMPILE_OPTIONS" not in content + + +def test_get_component_cmakelists_no_compile_features() -> None: + """The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the + top-level CMakeLists; the src component must not set its own.""" + with patch.object(CORE, "build_flags", set()): + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + + assert "target_compile_features" not in content diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index da0010afa3..2ae3836a25 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -160,3 +160,43 @@ def test_write_ini_no_change_when_content_same( call_args = mock_write_file_if_changed.call_args[0] assert call_args[0] == ini_file assert content in call_args[1] + + +@pytest.fixture +def clean_core(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(CORE, "name", "test") + monkeypatch.setattr(CORE, "platformio_options", {}) + monkeypatch.setattr(CORE, "platformio_libraries", {}) + monkeypatch.setattr(CORE, "build_flags", set()) + monkeypatch.setattr(CORE, "build_unflags", set()) + + +def test_get_ini_content_pins_cpp_standard( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """cg.set_cpp_standard() pins -std via build_flags and unflags every other + known standard so the platform/framework default is stripped.""" + monkeypatch.setattr(CORE, "cpp_standard", "gnu++20") + + content = platformio.get_ini_content() + + flags_section = content.split("build_flags =")[1].split("build_unflags =")[0] + unflags_section = content.split("build_unflags =")[1].split("extra_scripts")[0] + assert "-std=gnu++20\n" in flags_section + # Both the GNU and strict dialects of every other standard are stripped. + for year in ("11", "14", "17", "23", "26", "2a", "2b", "2c"): + assert f"-std=gnu++{year}\n" in unflags_section + assert f"-std=c++{year}\n" in unflags_section + assert "-std=c++20\n" in unflags_section + # The selected standard must not unflag itself. + assert "-std=gnu++20\n" not in unflags_section + + +def test_get_ini_content_no_cpp_standard( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(CORE, "cpp_standard", None) + + content = platformio.get_ini_content() + + assert "-std=" not in content From cd7e54dbf23b91dfec42bbbb1c6b3207d8c22770 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:32:17 -0400 Subject: [PATCH 1599/2214] Bump cryptography from 48.0.0 to 48.0.1 (#16909) 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 62ed506e36..a825cd9bff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==48.0.0 +cryptography==48.0.1 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From 77009cfafe06b4db8b017116b75a9a9dec118611 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 10 Jun 2026 18:21:04 -0400 Subject: [PATCH 1600/2214] [resampler] Allow resampler to passthrough bits per sample instead of converting (#16892) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/resampler/speaker/__init__.py | 21 ++++++++++-- .../resampler/speaker/resampler_speaker.cpp | 32 ++++++++++++++----- .../resampler/speaker/resampler_speaker.h | 25 +++++++++------ tests/components/resampler/common.yaml | 5 +++ 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 8a13110631..ea080adc6b 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -24,6 +24,8 @@ ResamplerSpeaker = resampler_ns.class_( CONF_TAPS = "taps" +PASSTHROUGH = "passthrough" + def _set_stream_limits(config): audio.set_stream_limits( @@ -35,14 +37,21 @@ def _set_stream_limits(config): def _validate_audio_compatibility(config): - inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) + # In passthrough mode the output bits per sample is determined at runtime from the input stream, so there is + # nothing to inherit or validate against the output speaker. + passthrough = config.get(CONF_BITS_PER_SAMPLE) == PASSTHROUGH + if not passthrough: + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) + audio.final_validate_audio_schema( "source_speaker", audio_device=CONF_OUTPUT_SPEAKER, - bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + bits_per_sample=cv.UNDEFINED + if passthrough + else config.get(CONF_BITS_PER_SAMPLE), channels=config.get(CONF_NUM_CHANNELS), sample_rate=config.get(CONF_SAMPLE_RATE), )(config) @@ -60,6 +69,9 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(ResamplerSpeaker), cv.Required(CONF_OUTPUT_SPEAKER): cv.use_id(speaker.Speaker), + cv.Optional(CONF_BITS_PER_SAMPLE, default=PASSTHROUGH): cv.Any( + cv.one_of(PASSTHROUGH, lower=True), cv.int_range(8, 32) + ), cv.Optional( CONF_BUFFER_DURATION, default="100ms" ): cv.positive_time_period_milliseconds, @@ -90,7 +102,10 @@ async def to_code(config): cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() - cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) + if config[CONF_BITS_PER_SAMPLE] == PASSTHROUGH: + cg.add(var.set_passthrough_bits_per_sample(True)) + else: + cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_target_sample_rate(config[CONF_SAMPLE_RATE])) cg.add(var.set_filters(config[CONF_FILTERS])) diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index ecbd445a80..f1ebd180cc 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -40,11 +40,19 @@ enum ResamplingEventGroupBits : uint32_t { }; void ResamplerSpeaker::dump_config() { - ESP_LOGCONFIG(TAG, - "Resampler Speaker:\n" - " Target Bits Per Sample: %u\n" - " Target Sample Rate: %" PRIu32 " Hz", - this->target_bits_per_sample_, this->target_sample_rate_); + if (this->passthrough_bits_per_sample_) { + ESP_LOGCONFIG(TAG, + "Resampler Speaker:\n" + " Target Bits Per Sample: passthrough\n" + " Target Sample Rate: %" PRIu32 " Hz", + this->target_sample_rate_); + } else { + ESP_LOGCONFIG(TAG, + "Resampler Speaker:\n" + " Target Bits Per Sample: %" PRIu8 "\n" + " Target Sample Rate: %" PRIu32 " Hz", + this->target_bits_per_sample_, this->target_sample_rate_); + } } void ResamplerSpeaker::setup() { @@ -253,8 +261,12 @@ void ResamplerSpeaker::send_command_(uint32_t command_bit, bool wake_loop) { void ResamplerSpeaker::start() { this->send_command_(ResamplingEventGroupBits::COMMAND_START, true); } esp_err_t ResamplerSpeaker::start_() { - this->target_stream_info_ = audio::AudioStreamInfo( - this->target_bits_per_sample_, this->audio_stream_info_.get_channels(), this->target_sample_rate_); + // In passthrough mode, the output keeps the input's bits per sample so only the sample rate is resampled. + const uint8_t target_bits_per_sample = this->passthrough_bits_per_sample_ + ? this->audio_stream_info_.get_bits_per_sample() + : this->target_bits_per_sample_; + this->target_stream_info_ = audio::AudioStreamInfo(target_bits_per_sample, this->audio_stream_info_.get_channels(), + this->target_sample_rate_); this->output_speaker_->set_audio_stream_info(this->target_stream_info_); this->output_speaker_->start(); @@ -305,7 +317,11 @@ void ResamplerSpeaker::set_volume(float volume) { } bool ResamplerSpeaker::requires_resampling_() const { - return (this->audio_stream_info_.get_sample_rate() != this->target_sample_rate_) || + if (this->audio_stream_info_.get_sample_rate() != this->target_sample_rate_) { + return true; + } + // In passthrough mode the bits per sample always matches the input, so it never forces resampling. + return !this->passthrough_bits_per_sample_ && (this->audio_stream_info_.get_bits_per_sample() != this->target_bits_per_sample_); } diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index 4a091e298a..f482ce4b88 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -49,6 +49,12 @@ class ResamplerSpeaker : public Component, public speaker::Speaker { } void set_target_sample_rate(uint32_t target_sample_rate) { this->target_sample_rate_ = target_sample_rate; } + /// @brief When enabled, the input bits per sample are passed through to the output speaker unchanged instead of being + /// converted to a fixed target. Only the sample rate is resampled if it differs from the target. + void set_passthrough_bits_per_sample(bool passthrough_bits_per_sample) { + this->passthrough_bits_per_sample_ = passthrough_bits_per_sample; + } + void set_filters(uint16_t filters) { this->filters_ = filters; } void set_taps(uint16_t taps) { this->taps_ = taps; } @@ -80,23 +86,24 @@ class ResamplerSpeaker : public Component, public speaker::Speaker { speaker::Speaker *output_speaker_{nullptr}; - bool task_stack_in_psram_{false}; - bool waiting_for_output_{false}; - StaticTask task_; audio::AudioStreamInfo target_stream_info_; - uint16_t taps_; - uint16_t filters_; - - uint8_t target_bits_per_sample_; - uint32_t target_sample_rate_; + uint64_t callback_remainder_{0}; uint32_t buffer_duration_ms_; uint32_t state_start_ms_{0}; + uint32_t target_sample_rate_; - uint64_t callback_remainder_{0}; + uint16_t taps_; + uint16_t filters_; + + uint8_t target_bits_per_sample_{0}; + + bool passthrough_bits_per_sample_{false}; + bool task_stack_in_psram_{false}; + bool waiting_for_output_{false}; }; } // namespace esphome::resampler diff --git a/tests/components/resampler/common.yaml b/tests/components/resampler/common.yaml index 782dc831c4..65dd5590ee 100644 --- a/tests/components/resampler/common.yaml +++ b/tests/components/resampler/common.yaml @@ -7,3 +7,8 @@ speaker: - platform: resampler id: resampler_speaker_id output_speaker: resampler_i2s_speaker_id + bits_per_sample: 16 + - platform: resampler + id: resampler_speaker_2_id + output_speaker: resampler_speaker_id + bits_per_sample: passthrough From 92c82f3d25596a9bfb51cd91a53ccf3d1d1820c7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 10 Jun 2026 17:26:50 -0500 Subject: [PATCH 1601/2214] [improv_serial] Report stopped state when Wi-Fi is disabled (#16904) Co-authored-by: Claude Opus 4.8 (1M context) --- .../esp32_improv/esp32_improv_component.cpp | 8 +++++++ .../improv_serial/improv_serial_component.cpp | 23 ++++++++++++++++++- .../improv_serial/improv_serial_component.h | 1 + 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 183820256f..e6fcc018d9 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -338,6 +338,14 @@ void ESP32ImprovComponent::process_incoming_data_() { this->incoming_data_.clear(); return; } + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled, so we can't provision. Respond immediately + // instead of letting the client wait out its provisioning timeout. + ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); + this->incoming_data_.clear(); + return; + } wifi::WiFiAP sta{}; sta.set_ssid(command.ssid.c_str()); sta.set_password(command.password.c_str()); diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 206df2c844..4ee703f363 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -22,7 +22,9 @@ void ImprovSerialComponent::setup() { if (wifi::global_wifi_component->has_sta()) { this->state_ = improv::STATE_PROVISIONED; - } else { + } else if (!wifi::global_wifi_component->is_disabled()) { + // Respect Wi-Fi's disabled state; forcing a scan while disabled throws + // the wifi component into an invalid state from which it cannot recover. wifi::global_wifi_component->start_scanning(); } } @@ -230,6 +232,13 @@ bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) { bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command) { switch (command.command) { case improv::WIFI_SETTINGS: { + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled, so we can't provision. Respond immediately + // instead of letting the client wait out its provisioning timeout. + ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); + return true; + } wifi::WiFiAP sta{}; sta.set_ssid(command.ssid.c_str()); sta.set_password(command.password.c_str()); @@ -245,6 +254,14 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command return true; } case improv::GET_CURRENT_STATE: + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled; report the Improv "stopped" state so a client can tell + // the user that provisioning is unavailable. Reported transiently without + // disturbing our internal provisioning state machine, so a later `wifi.enable` + // still reports the correct state. + this->send_current_state_(improv::STATE_STOPPED); + return true; + } this->set_state_(this->state_); if (this->state_ == improv::STATE_PROVISIONED) { std::vector url = this->build_rpc_settings_response_(improv::GET_CURRENT_STATE); @@ -299,6 +316,10 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command void ImprovSerialComponent::set_state_(improv::State state) { this->state_ = state; + this->send_current_state_(state); +} + +void ImprovSerialComponent::send_current_state_(improv::State state) { this->tx_header_[TX_TYPE_IDX] = TYPE_CURRENT_STATE; this->tx_header_[TX_DATA_IDX] = state; this->write_data_(); diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index c58c42f0d8..70f9214e2d 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -57,6 +57,7 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { bool parse_improv_payload_(improv::ImprovCommand &command); void set_state_(improv::State state); + void send_current_state_(improv::State state); void set_error_(improv::Error error); void send_response_(std::vector &response); void on_wifi_connect_timeout_(); From e0b0c1e8d3a4763e255a45a7fa9eb0ebe1392110 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:41:19 +1200 Subject: [PATCH 1602/2214] Bump version to 2026.7.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3537516996..9f4e20b977 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0-dev +PROJECT_NUMBER = 2026.7.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 22351244bd..3ca7b2e618 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0-dev" +__version__ = "2026.7.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 4dbc5ce920e50b37ac1e301e338c15ed8cb90f12 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:41:19 +1200 Subject: [PATCH 1603/2214] Bump version to 2026.6.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3537516996..647d25559a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0-dev +PROJECT_NUMBER = 2026.6.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 22351244bd..9a951c1527 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0-dev" +__version__ = "2026.6.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6a527c7efc24a3a14b5d29db862810ee830bc7c5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:04:22 +1200 Subject: [PATCH 1604/2214] [tests] Mock target branch in memory-impact exclusion test (#16913) --- tests/script/test_determine_jobs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index acc268fa68..a9defcacac 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1470,6 +1470,7 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: assert result["use_merged_config"] == "true" +@pytest.mark.usefixtures("mock_target_branch_dev") def test_detect_memory_impact_config_variant_only_platform_excluded( tmp_path: Path, ) -> None: From abf6212a5a3b28c57a9a8f933247fa86b268a1b8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:04:22 +1200 Subject: [PATCH 1605/2214] [tests] Mock target branch in memory-impact exclusion test (#16913) --- tests/script/test_determine_jobs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index acc268fa68..a9defcacac 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1470,6 +1470,7 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: assert result["use_merged_config"] == "true" +@pytest.mark.usefixtures("mock_target_branch_dev") def test_detect_memory_impact_config_variant_only_platform_excluded( tmp_path: Path, ) -> None: From 750cf1995b894a80fcae6c875a0a60d3c56beee6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jun 2026 08:47:50 -0500 Subject: [PATCH 1606/2214] [esp8266] Decode crash handler PC and backtrace in logs (#16911) --- esphome/components/esp8266/__init__.py | 18 ++++++++++- .../components/test_esp_stacktrace.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index dd10a32fd6..db94f0ec6d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -492,6 +492,15 @@ def _parse_register(config, regex, line): STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") +# Structured crash handler output (crash_handler.cpp) from a previous boot: +# PC: 0x40220060 +# EXCVADDR: 0x0000008A +# BT0: 0x40212345 +STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile( + r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})" +) +STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state): "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") ) - # ESP8266 PC/EXCVADDR + # ESP8266 PC/EXCVADDR (legacy Arduino postmortem) _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) + # ESP8266 structured crash handler (crash_handler.cpp) from previous boot + _parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line) + _parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line) + match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) if match is not None: diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index 5235f313d6..f231ac5fb7 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -45,6 +45,36 @@ def test_process_stacktrace_esp8266_backtrace( assert state is False +def test_process_stacktrace_esp8266_crash_handler( + setup_core: Path, mock_esp8266_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 crash handler backtrace lines.""" + from esphome.components.esp8266 import process_stacktrace + + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp8266:191]: PC: 0x40220060" + state = process_stacktrace(config, line_pc, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40220060") + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + # Near-null data address (wild pointer) is not a code address, must be ignored + line_excvaddr = "[E][esp8266:193]: EXCVADDR: 0x0000008A" + state = process_stacktrace(config, line_excvaddr, False) + mock_esp8266_decode_pc.assert_not_called() + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + line_bt0 = "[E][esp8266:196]: BT0: 0x40212345" + state = process_stacktrace(config, line_bt0, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40212345") + assert state is False + + def test_process_stacktrace_esp32_backtrace( setup_core: Path, mock_esp32_decode_pc: Mock ) -> None: From 28dd935359ea59270c18b463f858041eed35ef25 Mon Sep 17 00:00:00 2001 From: Dan Drown Date: Thu, 11 Jun 2026 11:35:44 -0500 Subject: [PATCH 1607/2214] [xpt2046] touchscreen driver enhancement (#16414) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../xpt2046/touchscreen/xpt2046.cpp | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index d08a54529d..83a7332005 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -6,6 +6,13 @@ namespace esphome::xpt2046 { +static constexpr uint8_t XPT_READ_Z1 = 0xB0; +static constexpr uint8_t XPT_READ_Z2 = 0xC0; +static constexpr uint8_t XPT_READ_X = 0xD0; +static constexpr uint8_t XPT_READ_Y = 0x90; +static constexpr uint8_t XPT_ADC_ON = 0x01; +static constexpr uint8_t XPT_VREF_ON = 0x02; + static const char *const TAG = "xpt2046"; void XPT2046Component::setup() { @@ -20,7 +27,7 @@ void XPT2046Component::setup() { this->attach_interrupt_(this->irq_pin_, gpio::INTERRUPT_FALLING_EDGE); } this->spi_setup(); - this->read_adc_(0xD0); // ADC powerdown, enable PENIRQ pin + this->read_adc_(XPT_READ_X); // ADC powerdown, enable PENIRQ pin } void XPT2046Component::update_touches() { @@ -29,21 +36,22 @@ void XPT2046Component::update_touches() { enable(); - int16_t touch_pressure_1 = this->read_adc_(0xB1 /* touch_pressure_1 */); - int16_t touch_pressure_2 = this->read_adc_(0xC1 /* touch_pressure_2 */); + int16_t touch_pressure_1 = this->read_adc_(XPT_READ_Z1 | XPT_ADC_ON); + int16_t touch_pressure_2 = this->read_adc_(XPT_READ_Z2 | XPT_ADC_ON); z_raw = touch_pressure_1 + 0xfff - touch_pressure_2; ESP_LOGVV(TAG, "Touchscreen Update z = %d", z_raw); touch = (z_raw >= this->threshold_); if (touch) { - read_adc_(0xD1 /* X */); // dummy Y measure, 1st is always noisy - data[0] = this->read_adc_(0x91 /* Y */); - data[1] = this->read_adc_(0xD1 /* X */); // make 3 x-y measurements - data[2] = this->read_adc_(0x91 /* Y */); - data[3] = this->read_adc_(0xD1 /* X */); - data[4] = this->read_adc_(0x91 /* Y */); + read_adc_(XPT_READ_X | XPT_ADC_ON); // dummy X measure, 1st is always noisy + // make 3 x-y measurements + data[0] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); + data[1] = this->read_adc_(XPT_READ_X | XPT_ADC_ON); + data[2] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); + data[3] = this->read_adc_(XPT_READ_X | XPT_ADC_ON); + data[4] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); } - data[5] = this->read_adc_(0xD0 /* X */); // Last X touch power down + data[5] = this->read_adc_(XPT_READ_X); // Last X touch power down disable(); @@ -95,15 +103,16 @@ int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t v return reta; } -int16_t XPT2046Component::read_adc_(uint8_t ctrl) { // NOLINT - uint8_t data[2]; +int16_t XPT2046Component::read_adc_(uint8_t ctrl) { + uint8_t data[3]; - this->write_byte(ctrl); - delay(1); - data[0] = this->read_byte(); - data[1] = this->read_byte(); + data[0] = ctrl; + data[1] = 0; + data[2] = 0; - return ((data[0] << 8) | data[1]) >> 3; + this->transfer_array(data, sizeof(data)); + + return ((data[1] << 8) | data[2]) >> 3; } } // namespace esphome::xpt2046 From 6ef35b6d3d163d0d027866b10805c617a841bfdc Mon Sep 17 00:00:00 2001 From: Tobiasz Jakubowski <12734857+tjakubo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:50:51 +0200 Subject: [PATCH 1608/2214] [spi] Skip logging on begin_transaction() of an auto-releasing write-only SPI device (#16921) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/spi/spi_esp_idf.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 107b6a3f1a..0731078eec 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate { write_only_(write_only) { if (!this->release_device_) add_device_(); + + if (this->write_only_) { + ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", + Utility::get_pin_no(this->cs_pin_)); + } } bool is_ready() override { return this->handle_ != nullptr; } @@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate { config.post_cb = nullptr; if (this->bit_order_ == BIT_ORDER_LSB_FIRST) config.flags |= SPI_DEVICE_BIT_LSBFIRST; - if (this->write_only_) { + if (this->write_only_) config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY; - ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", - Utility::get_pin_no(this->cs_pin_)); - } esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Add device failed - err %X", err); From 88084f2ec712ef015c51feb57f1d0bbaf7955737 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:32:51 -0400 Subject: [PATCH 1609/2214] Bump ruff from 0.15.16 to 0.15.17 (#16918) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9da27acc19..5ba806a2f5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.16 # also change in .pre-commit-config.yaml when updating +ruff==0.15.17 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From bf6c8568d364b8c2d76c29aba756c9ebd4651ab5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:33:28 -0400 Subject: [PATCH 1610/2214] Bump CodSpeedHQ/action from 4.17.0 to 4.17.5 (#16919) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a57be34e9b..deeec72095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 + uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5 with: run: | . venv/bin/activate From 10ce6024bf2339b888a5182aea1230634d789d69 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:21:38 +1000 Subject: [PATCH 1611/2214] [lvgl] Fix schema extraction (#16895) Co-authored-by: Claude Opus 4.8 --- esphome/components/lvgl/__init__.py | 175 ++++++++++++--------- esphome/components/lvgl/schemas.py | 48 +++++- script/build_language_schema.py | 28 ++++ tests/script/test_build_language_schema.py | 107 +++++++++++++ 4 files changed, 276 insertions(+), 82 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 022d629960..9137412abe 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -75,10 +76,14 @@ from .schemas import ( BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, + SET_STATE_SCHEMA, + STATE_SCHEMA, STYLE_REMAP, + STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, container_schema, + container_schema_value, obj_dict, ) from .styles import styles_to_code, theme_to_code @@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA page_spec, ) +# These style schemas live in .schemas but are imported here so they land in +# this module's namespace, where script/build_language_schema.py registers them +# as *named* schemas and emits `extends` references — instead of inlining the +# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the +# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in +# this file; this tuple keeps the imports live (and self-documents why). +_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA) + # Widget registration happens via WidgetType.__init__ in individual widget files # The imports below trigger creation of the widget types # Action registration (lvgl.{widget}.update) happens automatically @@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict: FINAL_VALIDATE_SCHEMA = final_validation -LVGL_SCHEMA = cv.All( - container_schema( - obj_spec, - cv.polling_component_schema("1s") - .extend( - { - **{ - cv.Optional(event): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) - ), - } - ) - for event in df.LV_SCREEN_EVENT_TRIGGERS - + df.LV_DISPLAY_EVENT_TRIGGERS - }, - cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), - cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), - cv.GenerateID(df.CONF_DISPLAYS): display_schema, - cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), - cv.Optional( - df.CONF_DEFAULT_FONT, default="montserrat_14" - ): lvalid.lv_font, - cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, - cv.Optional( - df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False - ): cv.boolean, - cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, - cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(CONF_ROTATION): validate_rotation, - cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( - *df.LV_LOG_LEVELS, upper=True - ), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "big_endian", "little_endian", lower=True - ), - cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( - FULL_STYLE_SCHEMA - ) - ), - cv.Optional(CONF_ON_IDLE): validate_automation( +# The options accepted at the top level of an `lvgl:` block, on top of the base +# object schema that `container_schema(obj_spec, ...)` supplies. Held in a +# module-level name (rather than inline) so the schema-extractor wrapper on +# CONFIG_SCHEMA below can hand the language-schema dumper the same composed +# schema the runtime validates against. +LVGL_TOP_LEVEL_SCHEMA = ( + cv.polling_component_schema("1s") + .extend( + { + **{ + cv.Optional(event): validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), - cv.Required(CONF_TIMEOUT): cv.templatable( - cv.positive_time_period_milliseconds + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) ), } - ), - cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), - **{ - cv.Optional(x): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), - }, - single=True, - ) - for x in SIMPLE_TRIGGERS - }, - cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), - cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, - cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), - cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), - cv.Optional( - df.CONF_TRANSPARENCY_KEY, default=0x000400 - ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, - cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, - cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, - cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, - cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, - cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), - cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, - } - ) - .extend(DISP_BG_SCHEMA), - ), + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS + }, + cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), + cv.GenerateID(df.CONF_DISPLAYS): display_schema, + cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, + cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, + cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, + cv.Optional(CONF_ROTATION): validate_rotation, + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( + *df.LV_LOG_LEVELS, upper=True + ), + cv.Optional(CONF_BYTE_ORDER): cv.one_of( + "big_endian", "little_endian", lower=True + ), + cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( + cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( + FULL_STYLE_SCHEMA + ) + ), + cv.Optional(CONF_ON_IDLE): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), + cv.Required(CONF_TIMEOUT): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), + cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, + cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, + cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + cv.Optional(df.CONF_THEME): _theme_schema, + cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, + cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, + cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, + cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, + cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), + cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + } + ) + .extend(DISP_BG_SCHEMA) +) + + +LVGL_SCHEMA = cv.All( + container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, ) +@schema_extractor("schema") def lvgl_config_schema(config): """ Can't use cv.ensure_list here because it converts an empty config to an empty list, rather than a default config. """ + if config is SCHEMA_EXTRACT: + # CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema + # closure, so the language-schema dumper can't see the top-level `lvgl:` + # fields (it would emit an empty schema). Hand it the same composed + # obj + top-level schema the runtime validates against, plus the + # `widgets:` key (added per-value by append_layout_schema at runtime, so + # otherwise invisible to the dumper). Validation of real configs (the + # branches below) is unchanged. + return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend( + {cv.Optional(df.CONF_WIDGETS): any_widget_schema()} + ) if not config or isinstance(config, dict): return [LVGL_SCHEMA(config)] return cv.Schema([LVGL_SCHEMA])(config) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bdaa91f15c..d7df628907 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,7 +22,11 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger -from esphome.schema_extractors import EnableSchemaExtraction +from esphome.schema_extractors import ( + SCHEMA_EXTRACT, + EnableSchemaExtraction, + schema_extractor, +) from . import defines as df, lv_validation as lvalid from .defines import ( @@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[ ] = {} +def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema: + """ + Build the static schema that :func:`container_schema` validates against, i.e. + everything except the value-dependent ``append_layout_schema`` applied at + validation time. + + Factored out and exposed so the language-schema dumper can extract a + representative schema for a widget — and for the top-level ``lvgl:`` block, + whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the + :func:`container_schema` validator closure. + """ + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + return schema.extend(widget_type.schema) + + def container_schema( widget_type: WidgetType, extras: Any = None ) -> Callable[[Any], Any]: @@ -649,12 +672,7 @@ def container_schema( def get_schema() -> cv.Schema: nonlocal cached_schema if cached_schema is None: - schema = obj_schema(widget_type).extend( - {cv.GenerateID(): cv.declare_id(widget_type.w_type)} - ) - if extras: - schema = schema.extend(extras) - cached_schema = schema.extend(widget_type.schema) + cached_schema = container_schema_value(widget_type, extras) return cached_schema def validator(value: Any) -> Any: @@ -678,7 +696,23 @@ def any_widget_schema(extras=None): :return: A validator for the Widgets key """ + @schema_extractor("schema") def validator(value): + if value is SCHEMA_EXTRACT: + # The widgets: list is built per-value at validation time, so the + # language-schema dumper sees nothing. Enumerate every registered + # widget type as an optional key (a widget item is really a + # single-key mapping; over-listing them lets editors complete any + # widget — `esphome config` enforces exactly one). extras carries the + # layout child options where applicable. + return cv.ensure_list( + cv.Schema( + { + cv.Optional(name): container_schema_value(widget_type, extras) + for name, widget_type in WIDGET_TYPES.items() + } + ) + ) if isinstance(value, dict): # Convert to list is_dict = True diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 4b0b0ee548..61845c4b25 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -428,6 +428,33 @@ def fix_menu(): menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") +def fix_lvgl_widgets(): + # lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The + # dumper has no cycle detection, so — like fix_menu — hoist the inlined + # widget-type enumeration into a named schema and reference it for both the + # top-level list and each widget's own children, instead of expanding it. + if "lvgl" not in output: + return + schemas = output["lvgl"][S_SCHEMAS] + config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS] + widgets = config_vars.get("widgets") + if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]: + return + # 1. Hoist the (one-level) widget enumeration into a named schema. + schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]} + # 2. Reference it from the top-level widgets: list instead of inlining. + widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]} + # 3. Let every widget contain child widgets, via the same named ref. + for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values(): + if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget: + widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = { + S_TYPE: S_SCHEMA, + "is_list": True, + "key": "Optional", + S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}, + } + + def get_logger_tags(): pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE) # tags not in components dir @@ -740,6 +767,7 @@ def build_schema(): add_logger_tags() shrink() fix_menu() + fix_lvgl_widgets() # aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc. data = {} diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8b81a57fef..badd4686f6 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -4,7 +4,12 @@ from __future__ import annotations import ast import importlib.util +import json from pathlib import Path +import subprocess +import sys + +import pytest from esphome import config_validation as cv @@ -176,3 +181,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: entry = converted["schema"]["config_vars"]["hostname"] assert "sensitive" not in entry assert "sensitive_source" not in entry + + +# --------------------------------------------------------------------------- +# Regression tests for the lvgl schema dump. +# +# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are +# built lazily at validation time, so the static dumper used to emit an empty +# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA +# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise +# the full `build_schema()` and assert the generated lvgl.json carries the data +# the schema_extractor hooks added. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: + """Run the full language-schema build once and return parsed lvgl.json. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps this test isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return json.loads((out_dir / "lvgl.json").read_text()) + + +def _lvgl_config_vars(lvgl_schema: dict) -> dict: + config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"] + # Previously empty (`{}`); the schema_extractor on lvgl_config_schema now + # hands the dumper the composed top-level schema. + assert config_schema["type"] == "schema" + return config_schema["schema"]["config_vars"] + + +def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed. + assert len(config_vars) > 100 + # A representative spread of top-level options the runtime validates. + for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"): + assert key in config_vars, f"missing top-level lvgl option: {key}" + + +def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # The widgets: list is assembled per-value at runtime; the extractor + # enumerates every registered widget type into a named WIDGET_TYPES schema + # which the widgets: list references (recursive, so widgets can nest). + assert "widgets" in config_vars + widgets = config_vars["widgets"] + assert widgets["is_list"] is True + assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][ + "config_vars" + ] + # Every registered widget type should appear as an optional key. + for name in ("obj", "label", "button", "slider", "switch", "arc"): + assert name in widget_types, f"widget type not enumerated: {name}" + # Each enumerated widget carries its own property schema, not an empty stub. + assert widget_types["label"]["type"] == "schema" + assert len(widget_types["label"]["schema"]["config_vars"]) > 0 + # Each widget can contain child widgets, via the same named ref — so the + # tree is recursive and the dump stays finite. + nested = widget_types["obj"]["schema"]["config_vars"]["widgets"] + assert nested["is_list"] is True + assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + +def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None: + schemas = lvgl_schema["lvgl"]["schemas"] + # Importing these into the lvgl __init__ namespace lets the dumper register + # them as named schemas and emit `extends` refs instead of inlining them. + for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"): + assert name in schemas, f"style schema not registered as named: {name}" + + # STYLE_SCHEMA must be referenced via `extends`, not inlined at every use + # site. Count the references to prove the dedup actually happened. + refs = 0 + + def _count(node: object) -> None: + nonlocal refs + if isinstance(node, dict): + extends = node.get("extends") + if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends: + refs += 1 + for value in node.values(): + _count(value) + elif isinstance(node, list): + for value in node: + _count(value) + + _count(lvgl_schema) + assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}" From 35e5c7c7c353ab8182d3c74a65b434e1738ec2e3 Mon Sep 17 00:00:00 2001 From: guillempages Date: Sat, 13 Jun 2026 23:40:49 +0200 Subject: [PATCH 1612/2214] [runtime_image] Improve error logging (#16943) --- esphome/components/online_image/online_image.cpp | 3 ++- esphome/components/runtime_image/image_decoder.h | 16 ++++++++++++++++ .../components/runtime_image/jpeg_decoder.cpp | 16 ++++++++++++++-- esphome/components/runtime_image/png_decoder.cpp | 1 + 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index a5a3ea5104..22bce4cc41 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -1,4 +1,5 @@ #include "online_image.h" +#include "esphome/components/runtime_image/image_decoder.h" #include "esphome/core/log.h" #include @@ -181,7 +182,7 @@ void OnlineImage::loop() { auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread()); if (consumed < 0) { - ESP_LOGE(TAG, "Error decoding image: %d", consumed); + ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed)); this->end_connection_(); this->download_error_callback_.call(); return; diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 926108a8a0..c68ea5720b 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -7,8 +7,24 @@ enum DecodeError : int { DECODE_ERROR_INVALID_TYPE = -1, DECODE_ERROR_UNSUPPORTED_FORMAT = -2, DECODE_ERROR_OUT_OF_MEMORY = -3, + DECODE_ERROR_INTERNAL_DECODER_ERROR = -4, }; +constexpr const char *decode_error_to_string(int error) { + switch (error) { + case DECODE_ERROR_INVALID_TYPE: + return "Invalid type"; + case DECODE_ERROR_UNSUPPORTED_FORMAT: + return "Unsupported format"; + case DECODE_ERROR_OUT_OF_MEMORY: + return "Out of memory"; + case DECODE_ERROR_INTERNAL_DECODER_ERROR: + return "Internal decoder error"; + default: + return "Unknown error"; + } +} + class RuntimeImage; /** diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index dcaa07cd58..c46e86fd0d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -89,9 +89,21 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { return DECODE_ERROR_OUT_OF_MEMORY; } if (!this->jpeg_.decode(0, 0, 0)) { - ESP_LOGE(TAG, "Error while decoding."); + auto error = this->jpeg_.getLastError(); + ESP_LOGE(TAG, "Error while decoding: %d", error); this->jpeg_.close(); - return DECODE_ERROR_UNSUPPORTED_FORMAT; + switch (error) { + case JPEG_ERROR_MEMORY: + return DECODE_ERROR_OUT_OF_MEMORY; + case JPEG_UNSUPPORTED_FEATURE: + return DECODE_ERROR_UNSUPPORTED_FORMAT; + case JPEG_INVALID_FILE: + case JPEG_INVALID_PARAMETER: + return DECODE_ERROR_INVALID_TYPE; + case JPEG_DECODE_ERROR: + default: + return DECODE_ERROR_INTERNAL_DECODER_ERROR; + } } this->decoded_bytes_ = size; this->jpeg_.close(); diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 591504328d..12bce0d284 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -95,6 +95,7 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) { auto fed = pngle_feed(this->pngle_, buffer, size); if (fed < 0) { ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_)); + return DECODE_ERROR_INTERNAL_DECODER_ERROR; } else { this->decoded_bytes_ += fed; } From 5b7f8cf90d0d78a0563cd342b718fa6fd75992e5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:36:38 +1000 Subject: [PATCH 1613/2214] [mipi_spi] Implement automatic mapping of offsets (#16722) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 131 ++++-- esphome/components/mipi_dsi/display.py | 8 +- esphome/components/mipi_rgb/display.py | 8 +- esphome/components/mipi_spi/display.py | 36 +- esphome/components/mipi_spi/mipi_spi.h | 87 ++-- esphome/components/mipi_spi/models/ili.py | 28 ++ .../components/mipi_spi/models/waveshare.py | 13 + tests/component_tests/mipi_spi/test_init.py | 4 +- .../mipi_spi/test_padding_and_offsets.py | 434 ++++++++++++++++++ 9 files changed, 662 insertions(+), 87 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_padding_and_offsets.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index c3b744c919..129befe600 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay +CONF_PAD_HEIGHT = "pad_height" +CONF_PAD_WIDTH = "pad_width" CONF_PIXEL_MODE = "pixel_mode" CONF_USE_AXIS_FLIPS = "use_axis_flips" @@ -202,6 +204,8 @@ def dimension_schema(rounding): rounding ), cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), + cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding), + cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding), } ), ) @@ -311,6 +315,36 @@ class DriverChip: name = name.upper() self.name = name self.initsequence = initsequence + if CONF_NATIVE_WIDTH in defaults: + if CONF_WIDTH not in defaults: + defaults[CONF_WIDTH] = ( + defaults[CONF_NATIVE_WIDTH] + - defaults.get(CONF_OFFSET_WIDTH, 0) + - defaults.get(CONF_PAD_WIDTH, 0) + ) + else: + native_width = ( + defaults.get(CONF_WIDTH, 0) + + defaults.get(CONF_OFFSET_WIDTH, 0) + + defaults.get(CONF_PAD_WIDTH, 0) + ) + if native_width != 0: + defaults[CONF_NATIVE_WIDTH] = native_width + if CONF_NATIVE_HEIGHT in defaults: + if CONF_HEIGHT not in defaults: + defaults[CONF_HEIGHT] = ( + defaults[CONF_NATIVE_HEIGHT] + - defaults.get(CONF_OFFSET_HEIGHT, 0) + - defaults.get(CONF_PAD_HEIGHT, 0) + ) + else: + native_height = ( + defaults.get(CONF_HEIGHT, 0) + + defaults.get(CONF_OFFSET_HEIGHT, 0) + + defaults.get(CONF_PAD_HEIGHT, 0) + ) + if native_height != 0: + defaults[CONF_NATIVE_HEIGHT] = native_height self.defaults = defaults DriverChip.models[name] = self @@ -336,18 +370,6 @@ class DriverChip: initsequence = list(kwargs.pop("initsequence", self.initsequence)) initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() - if ( - CONF_WIDTH in defaults - and CONF_OFFSET_WIDTH in kwargs - and CONF_NATIVE_WIDTH not in defaults - ): - defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] - if ( - CONF_HEIGHT in defaults - and CONF_OFFSET_HEIGHT in kwargs - and CONF_NATIVE_HEIGHT not in defaults - ): - defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) @@ -385,13 +407,16 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + def get_dimensions( + self, config, swap: bool = True + ) -> tuple[int, int, int, int, int, int]: """ Return the dimensions of the current model. :param config: The current configuration :param swap: If width/height should be swapped when axes are swapped. - :return: + :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] @@ -400,33 +425,71 @@ class DriverChip: height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] offset_height = dimensions[CONF_OFFSET_HEIGHT] - return width, height, offset_width, offset_height - (width, height) = dimensions - return width, height, 0, 0 + if CONF_PAD_WIDTH in dimensions: + pad_width = dimensions[CONF_PAD_WIDTH] + native_width = width + offset_width + pad_width + else: + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + if native_width == 0: + pad_width = 0 + native_width = width + offset_width + else: + pad_width = native_width - width - offset_width + if CONF_PAD_HEIGHT in dimensions: + pad_height = dimensions[CONF_PAD_HEIGHT] + native_height = height + offset_height + pad_height + else: + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if native_height == 0: + pad_height = 0 + native_height = height + offset_height + else: + pad_height = native_height - height - offset_height + if ( + pad_width + offset_width >= native_width + or pad_height + offset_height >= native_height + ): + raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS]) + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS]) + + return width, height, offset_width, offset_height, pad_width, pad_height + + # Must be a tuple + width, height = dimensions + return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) offset_width = self.get_default(CONF_OFFSET_WIDTH, 0) offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0) + pad_width = self.get_default( + CONF_PAD_WIDTH, native_width - width - offset_width + ) + pad_height = self.get_default( + CONF_PAD_HEIGHT, native_height - height - offset_height + ) + + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS]) # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where # the offset is asymmetric if transform.get(CONF_MIRROR_X): - native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) - offset_width = native_width - width - offset_width + offset_width, pad_width = pad_width, offset_width if transform.get(CONF_MIRROR_Y): - native_height = self.get_default( - CONF_NATIVE_HEIGHT, height + offset_height * 2 - ) - offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + offset_height, pad_height = pad_height, offset_height + # Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height - return width, height, offset_width, offset_height + pad_width, pad_height = pad_height, pad_width + return width, height, offset_width, offset_height, pad_width, pad_height def get_base_transform(self, config): transform = config.get( @@ -450,20 +513,8 @@ class DriverChip: def get_transform(self, config) -> dict[str, bool]: transform = self.get_base_transform(config) - can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True + transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform def swap_xy_schema(self): @@ -498,8 +549,8 @@ class DriverChip: return madctl def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - # This takes into account rotation if it can be implemented in the transform + # Add the MADCTL command to the sequence based on the base configuration. + # Rotation is not applied here, it will be done at runtime. transform = self.get_transform(config) madctl = self.get_madctl(transform, config) sequence.append((MADCTL, madctl & 0xFF)) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 46e7a7d5a7..896140b4b1 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -172,7 +172,9 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -206,7 +208,9 @@ async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) sequence = model.get_sequence(config) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 3c33c26726..1eacc31fc5 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -235,7 +235,9 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height) cg.add(var.set_model(model.name)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 8c6ffff500..abb7eaa458 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -27,7 +27,7 @@ from esphome.components.mipi import ( requires_buffer, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN -from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( @@ -121,7 +121,9 @@ def denominator(config): """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - _width, height, _offset_width, _offset_height = model.get_dimensions(config) + _width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) if frac is None or frac > 0.75 or height < 32: return 1 try: @@ -169,11 +171,22 @@ def model_schema(config): ] if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) + # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. + spi_mode = model.get_default(CONF_SPI_MODE) + if not spi_mode: + if bus_mode == TYPE_OCTAL or ( + bus_mode == TYPE_SINGLE + and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + ): + spi_mode = "MODE3" + else: + spi_mode = "MODE0" + schema = ( display.FULL_DISPLAY_SCHEMA.extend( spi.spi_device_schema( cs_pin_required=False, - default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0", + default_mode=spi_mode, default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), mode=bus_mode, ) @@ -279,8 +292,8 @@ def customise_schema(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, _offset_width, _offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) display.add_metadata( config[CONF_ID], @@ -313,14 +326,17 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + # Always call this to check dimensions during validation + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) - width, height, _offset_width, _offset_height = model.get_dimensions(config) - buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here fraction = min(20000.0, buffer_size // 4) / buffer_size @@ -347,8 +363,8 @@ def get_instance(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, offset_width, offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, offset_width, offset_height, pad_width, pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) @@ -374,6 +390,8 @@ def get_instance(config): height, offset_width, offset_height, + pad_width, + pad_height, madctl, has_hardware_transform, ] diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 5023cf8089..a594e48209 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -126,17 +131,6 @@ class MipiSpi : public display::Display, return HEIGHT; } - // If hardware rotation is in use, the actual display width/height changes with rotation - int get_width_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_width(); - return WIDTH; - } - int get_height_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_height(); - return HEIGHT; - } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -233,14 +227,25 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, - (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, - this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, - HAS_HARDWARE_ROTATION); + internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), + this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, + IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, + this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } protected: /* METHODS */ + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } // convenience functions to write commands with or without data void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); } void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); } @@ -330,20 +335,34 @@ class MipiSpi : public display::Display, this->write_command_(MADCTL_CMD, madctl); } - uint16_t get_offset_width_() { + uint16_t get_offset_width_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_HEIGHT; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return OFFSET_HEIGHT; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_270_DEGREES: + return PAD_HEIGHT; + default: + break; + } } return OFFSET_WIDTH; } - uint16_t get_offset_height_() { + uint16_t get_offset_height_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_WIDTH; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_HEIGHT; + case display::DISPLAY_ROTATION_270_DEGREES: + return OFFSET_WIDTH; + default: + break; + } } return OFFSET_HEIGHT; } @@ -396,7 +415,7 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8); } } else { - for (size_t y = 0; y != static_cast(h); y++) { + for (size_t y = 0; y != h; y++) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -492,19 +511,23 @@ class MipiSpi : public display::Display, * @tparam BUFFERPIXEL Color depth of the buffer * @tparam DISPLAYPIXEL Color depth of the display * @tparam BUS_TYPE The type of the interface bus (single, quad, octal) - * @tparam ROTATION The rotation of the display * @tparam WIDTH Width of the display in pixels * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer). * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template -class MipiSpiBuffer : public MipiSpi { + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, + uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> +class MipiSpiBuffer + : public MipiSpi { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and @@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi::dump_config(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi::setup(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup(); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index ae6accb907..5df7a275df 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -179,6 +179,9 @@ ILI9342 = DriverChip( # M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation ILI9341.extend( "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, width=320, height=240, mirror_x=False, @@ -786,3 +789,28 @@ ST7796.extend( dc_pin=0, invert_colors=True, ) + +ST7789V.extend( + "GEEKMAGIC-SMALLTV", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=2, + dc_pin=0, +) +ST7789V.extend( + "GEEKMAGIC-SMALLTV-PRO", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=4, + dc_pin=2, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee46f931de..3c719b0f5e 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -269,3 +269,16 @@ ST7789V.extend( cs_pin=14, dc_pin={"number": 15, "ignore_strapping_warning": True}, ) + +ST7789V.extend( + "WAVESHARE-ESP32-S3-GEEK", + cs_pin=10, + dc_pin=8, + reset_pin=9, + width=135, + height=240, + offset_width=52, + offset_height=40, + invert_colors=True, + data_rate="40MHz", +) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 4873892a8d..d681908027 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -314,7 +314,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp @@ -330,7 +330,7 @@ def test_lvgl_generation( main_cpp = generate_main(component_fixture_path("lvgl.yaml")) assert ( - "mipi_spi::MipiSpi();" + "mipi_spi::MipiSpi();" in main_cpp ) assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py new file mode 100644 index 0000000000..82adf88b7e --- /dev/null +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -0,0 +1,434 @@ +"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.components.mipi_spi.display import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + MODELS, + get_instance, +) +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: ConfigType) -> ConfigType: + """Run schema + final validation and return the validated config.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +class TestSPIModeCalculation: + """Test default SPI mode calculation logic.""" + + @pytest.mark.parametrize( + ("bus_mode", "cs_pin", "expected_mode"), + [ + pytest.param( + TYPE_OCTAL, + None, + "MODE3", + id="octal_bus_no_cs", + ), + pytest.param( + TYPE_OCTAL, + 14, + "MODE3", + id="octal_bus_with_cs", + ), + pytest.param( + TYPE_SINGLE, + None, + "MODE3", + id="single_bus_no_cs", + ), + pytest.param( + TYPE_SINGLE, + 14, + "MODE0", + id="single_bus_with_cs", + ), + pytest.param( + TYPE_QUAD, + None, + "MODE0", + id="quad_bus_no_cs", + ), + pytest.param( + TYPE_QUAD, + 14, + "MODE0", + id="quad_bus_with_cs", + ), + ], + ) + def test_default_spi_mode_calculation( + self, + bus_mode: str, + cs_pin: int | None, + expected_mode: str, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that SPI mode is correctly calculated based on bus mode and CS pin.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config: ConfigType = { + "model": "custom", + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": bus_mode, + } + + # Add dc_pin for modes that require it (single and octal) + # quad mode does not allow dc_pin + if bus_mode != TYPE_QUAD: + config[CONF_DC_PIN] = 11 + + # Add CS pin if specified + if cs_pin is not None: + config[CONF_CS_PIN] = cs_pin + + validated = validated_config(config) + # The validated config should have the correct SPI mode set by model_schema + assert validated.get(CONF_SPI_MODE) == expected_mode + + def test_explicit_spi_mode_overrides_default( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that an explicitly configured SPI mode is not overridden.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # For octal bus, default is MODE3, but we specify MODE0 + config = validated_config( + { + "model": "custom", + "dc_pin": 11, # Required for octal mode + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": TYPE_OCTAL, + "spi_mode": "MODE0", # Explicitly set + } + ) + + assert config[CONF_SPI_MODE] == "MODE0" + + +class TestModelWithPaddingDimensions: + """Test that padding dimensions are correctly returned by models.""" + + def test_model_get_dimensions_returns_six_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that get_dimensions() returns 6 values including padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # Test with a real model + model = MODELS["ST7735"] + config = {"model": "ST7735", "dc_pin": 18} + + # Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height) + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6 + assert all(isinstance(v, int) for v in dimensions) + + def test_custom_model_padding_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding values for a custom model with explicit offset.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 20, + "offset_height": 10, + }, + "init_sequence": [[0xA0, 0x01]], + } + ) + + # For custom models, the model is created dynamically from the config + # We can verify the config has the right dimensions + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 320 + assert config["dimensions"]["offset_width"] == 20 + assert config["dimensions"]["offset_height"] == 10 + # Padding is not stored in config for custom models (defaults to 0) + assert config["dimensions"].get("offset_width_pad", 0) == 0 + assert config["dimensions"].get("offset_height_pad", 0) == 0 + + +class TestNewModelVariants: + """Test new model variants added in this change.""" + + def test_m5core2_with_native_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test M5CORE2 variant with reset native_width and native_height.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # M5CORE2 should validate successfully + config = validated_config({"model": "M5CORE2"}) + assert config is not None + + # Verify the model has correct dimensions + model = MODELS["M5CORE2"] + dimensions = model.get_dimensions(config) + width, height, _, _, _, _ = dimensions + assert width == 320 + assert height == 240 + + def test_geekmagic_smalltv_variant( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test GEEKMAGIC-SMALLTV variant of ST7789V.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # GEEKMAGIC-SMALLTV should validate successfully + config = validated_config({"model": "GEEKMAGIC-SMALLTV"}) + assert config is not None + + # Verify it's a variant of ST7789V with expected dimensions + model = MODELS["GEEKMAGIC-SMALLTV"] + dimensions = model.get_dimensions(config) + width, height, offset_x, offset_y, _, _ = dimensions + assert width == 240 + assert height == 240 + assert offset_x == 0 + assert offset_y == 0 + + def test_all_predefined_models_with_new_get_dimensions_signature( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Verify all predefined models work with new 6-value get_dimensions().""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + for name, model in MODELS.items(): + # Skip custom model + if name == "custom": + continue + + config = {"model": name} + + # Try to get dimensions - should return 6 values for all models + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6, ( + f"Model {name} should return 6 dimensions, got {len(dimensions)}" + ) + + +class TestTemplateParameterPassing: + """Test that padding parameters are correctly passed to C++ templates.""" + + def test_instance_creation_with_padding( + self, + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], + ) -> None: + """Test that get_instance() correctly passes padding parameters to template.""" + main_cpp = generate_main(component_fixture_path("native.yaml")) + + # native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer + # (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION, + # FRACTION, ROUNDING) + # The instantiation should include padding values (0, 0 for default) + assert ( + "mipi_spi::MipiSpiBuffer()" + in main_cpp + ), ( + "Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation" + ) + + def test_single_mode_with_offset_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that single-mode display with custom offset works with padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Should not raise any errors + instance = get_instance(config) + assert instance is not None + + +class TestUserConfiguredPadding: + """Test that pad_width and pad_height can be configured in user dimensions.""" + + def test_explicit_pad_width_and_height_in_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that pad_width and pad_height can be explicitly set in dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + "pad_width": 80, + "pad_height": 40, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Config should validate successfully with padding dimensions + assert config is not None + assert config["dimensions"]["pad_width"] == 80 + assert config["dimensions"]["pad_height"] == 40 + + def test_padding_for_native_dimension_calculation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that explicit padding allows native dimensions to be calculated.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A controller that has 320x320 total pixels with: + # - 240x320 active display area + # - offset_width=40, offset_height=20 + # - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, # Active display width + "height": 320, # Active display height + "offset_width": 40, + "offset_height": 0, + "pad_width": 40, # Pixels after width+offset + "pad_height": 0, # Pixels after height+offset + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Get instance should work and correctly calculate native dimensions + instance = get_instance(config) + assert instance is not None + + def test_padding_without_offset( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding can be used without offset for controllers with top-left-aligned displays.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A display with no offset but padding on right and bottom + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 240, + "offset_width": 0, + "offset_height": 0, + "pad_width": 0, + "pad_height": 16, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + assert config is not None + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 240 + assert config["dimensions"]["pad_height"] == 16 From 1e5771a3fa446c0de961a9a667efc19c8002ec5c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:48:43 -0400 Subject: [PATCH 1614/2214] [esp32] Fix idedata generation failing on unset ESPHOME_ARDUINO (#16925) --- .clang-tidy.hash | 2 +- esphome/components/esp32/pre_build.py.script | 7 +++++++ esphome/espidf/clang_tidy.py | 2 +- esphome/idf_component.yml | 2 +- platformio.ini | 18 +++++++++++++----- tests/unit_tests/test_espidf_clang_tidy.py | 6 +++--- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 6f6339ff84..7497cc3679 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 +a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script index af12275a0b..8728e02a34 100644 --- a/esphome/components/esp32/pre_build.py.script +++ b/esphome/components/esp32/pre_build.py.script @@ -1,3 +1,5 @@ +import os + Import("env") # noqa: F821 # Remove custom_sdkconfig from the board config as it causes @@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board: del board._manifest["espidf"]["custom_sdkconfig"] if not board._manifest["espidf"]: del board._manifest["espidf"] + +# Referenced by rules in esphome/idf_component.yml; an unset env var is a +# fatal error there. Always 0: in PlatformIO builds arduino is not a managed +# IDF component. +os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0") diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 62d6f0d00d..d3f4d151c2 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: # Gates arduino-only components in esphome/idf_component.yml (IDF reads it at # reconfigure time). Set here -- before the manifest is written/reconfigured. - os.environ["ESPHOME_ARDUINO"] = ( + os.environ["ESPHOME_ARDUINO_COMPONENT"] = ( "1" if settings.target_framework == "arduino" else "0" ) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7cbc2ac4ae..c97e8906a8 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -109,4 +109,4 @@ dependencies: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b rules: - - if: "$ESPHOME_ARDUINO == 1" + - if: "$ESPHOME_ARDUINO_COMPONENT == 1" diff --git a/platformio.ini b/platformio.ini index 718dfb672f..862b7a7dbe 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,7 +141,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -168,12 +171,16 @@ build_flags = -DAUDIO_NO_SD_FS ; i2s_audio build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = espidf lib_deps = @@ -187,7 +194,9 @@ build_flags = -DUSE_ESP32_FRAMEWORK_ESP_IDF build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; These are common settings for the RP2040 using Arduino. [common:rp2040-arduino] @@ -271,7 +280,6 @@ build_unflags = [env:esp32-arduino] extends = common:esp32-arduino board = esp32dev -board_build.partitions = huge_app.csv build_flags = ${common:esp32-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 9791dfc543..cb25535d8d 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -56,11 +56,11 @@ def test_setup_core_sets_arduino_env( target_framework: str, expected: str, ) -> None: - """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + """_setup_core sets ESPHOME_ARDUINO_COMPONENT, which gates arduino-only manifest deps.""" # monkeypatch snapshots os.environ, so the env var _setup_core writes is # restored after the test instead of leaking into later tests. - monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) + monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False) _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) - assert os.environ["ESPHOME_ARDUINO"] == expected + assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected From e191fc5d47284c2e0609c4fe368847d1fb33e79f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:03 -0400 Subject: [PATCH 1615/2214] [core] Support platformio_options on the native ESP-IDF toolchain (#16917) --- esphome/core/__init__.py | 7 + esphome/core/config.py | 66 ++++++++-- esphome/espidf/component.py | 55 ++++++-- tests/unit_tests/core/test_config.py | 123 ++++++++++++++++++ .../fixtures/core/config/libraries.yaml | 8 ++ tests/unit_tests/test_core.py | 18 +++ tests/unit_tests/test_espidf_component.py | 122 ++++++++++++++++- 7 files changed, 366 insertions(+), 33 deletions(-) create mode 100644 tests/unit_tests/fixtures/core/config/libraries.yaml diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 4289cdf3e5..21ff7ef07c 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -958,6 +958,13 @@ class EsphomeCore: return build_flag def add_build_unflag(self, build_unflag: str) -> None: + if self.using_toolchain_esp_idf: + # The native ESP-IDF build generator does not consume build_unflags + _LOGGER.warning( + "Build unflag %s is ignored when building with the native " + "ESP-IDF toolchain", + build_unflag, + ) self.build_unflags.add(build_unflag) _LOGGER.debug("Adding build unflag: %s", build_unflag) diff --git a/esphome/core/config.py b/esphome/core/config.py index 8214fcf80c..b925f0b7d9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None: include_file(path, basename, is_c_header) +def _add_library_str(lib: str) -> None: + if "@" in lib: + name, vers = lib.split("@", 1) + cg.add_library(name, vers) + elif "://" in lib: + # Repository... + if "=" in lib: + name, repo = lib.split("=", 1) + cg.add_library(name, None, repo) + else: + cg.add_library(None, None, lib) + else: + cg.add_library(lib, None) + + @coroutine_with_priority(CoroPriority.FINAL) -async def _add_platformio_options(pio_options): +async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: + if CORE.using_toolchain_esp_idf: + # The native ESP-IDF build doesn't read platformio.ini; honor the + # options with a native equivalent and warn about the rest, which + # would otherwise be silently ignored. + for key, val in pio_options.items(): + vals = [val] if isinstance(val, str) else val + if key == CONF_BUILD_FLAGS: + # Deprecated: esphome->build_flags is the native equivalent. + # Remove before 2026.12.0 + _LOGGER.warning( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead. Support for it will be removed " + "in 2026.12.0." + ) + for flag in vals: + cg.add_build_flag(flag) + elif key == "lib_deps": + # Routed through the regular library mechanism so the libraries + # are converted to IDF components like any other PIO library + for lib in vals: + _add_library_str(lib) + elif key == "lib_ignore": + # Read by the PIO-library-to-IDF-component conversion + # (generate_idf_components); filters both top-level libraries + # and dependencies discovered during conversion + cg.add_platformio_option(key, vals) + elif key != "upload_speed": + # upload_speed needs no handling: it is read from the raw + # config at upload time (upload_using_esptool) + _LOGGER.warning( + "esphome->platformio_options->%s is ignored when building with " + "the native ESP-IDF toolchain", + key, + ) + return # Add includes at the very end, so that they override everything for key, val in pio_options.items(): if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): @@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None: # Libraries for lib in config[CONF_LIBRARIES]: - if "@" in lib: - name, vers = lib.split("@", 1) - cg.add_library(name, vers) - elif "://" in lib: - # Repository... - if "=" in lib: - name, repo = lib.split("=", 1) - cg.add_library(name, None, repo) - else: - cg.add_library(None, None, lib) - - else: - cg.add_library(lib, None) + _add_library_str(lib) cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7398a91c36..cfd42916b2 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: raise NotImplementedError @@ -64,10 +64,12 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: base_dir = Path(CORE.data_dir) / DOMAIN h = hashlib.new("sha256") h.update(self.url.encode()) + if salt: + h.update(salt.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix # Marker file written last to signal a complete extraction. Using a # marker (instead of just `path.is_dir()`) means an interrupted @@ -99,12 +101,12 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=DOMAIN, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, submodules=[], subpath=Path(dir_suffix), ) @@ -146,14 +148,16 @@ class IDFComponent: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False): + def download(self, force: bool = False, salt: str = ""): """ The dependency name should match the directory name at the end of the override path. The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. If you want to specify the full name of the component with the namespace, replace / in the component name with __. @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html """ - self.path = self.source.download(self.get_sanitized_name(), force=force) + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) def _apply_extra_script(component: IDFComponent) -> None: @@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: The returned list holds the top-level components (those directly requested); transitive dependencies are converted too and wired into each component's generated manifest. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. """ nodes: dict[str, _LibNode] = {} + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated CMakeLists.txt/idf_component.yml inside the shared cache + # bake in the dependency wiring, which lib_ignore changes; salt the cache + # path so configs with different lib_ignore values don't fight over (and + # constantly rewrite) the same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: key, is_git, locator = _node_key(name, version, repository) node = nodes.get(key) or _LibNode(key=key, is_git=is_git) @@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: top_level = [ add_spec(library.name, library.version, library.repository) for library in libraries + if not is_ignored(library.name) ] # Collect + resolve to a fixpoint: a node is (re)resolved whenever its @@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: component = IDFComponent( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download() + component.download(salt=salt) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" @@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: except InvalidIDFComponent as e: _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] dep_url = None @@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: dep_url, dep_version = dep_version, None except (TypeError, ValueError): pass - dep_key = add_spec( - _owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")), - dep_version, - dep_url, - ) + dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) worklist.append(dep_key) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ff150f2540..e2b34d92d8 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -20,6 +20,9 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE, config from esphome.core.config import ( @@ -1161,3 +1164,123 @@ def test_make_app_name_cpp_special_chars_escaped() -> None: cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False) # cpp_string_escape uses octal escapes for quotes assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes + + +@pytest.mark.parametrize( + ("lib", "name", "version", "repository"), + [ + ("ArduinoJson", "ArduinoJson", None, None), + ("bblanchon/ArduinoJson@7.4.2", "bblanchon/ArduinoJson", "7.4.2", None), + ( + "noise-c=https://github.com/esphome/noise-c.git", + "noise-c", + None, + "https://github.com/esphome/noise-c.git", + ), + ], +) +def test_add_library_str( + lib: str, name: str, version: str | None, repository: str | None +) -> None: + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + config._add_library_str(lib) + + libraries = list(CORE.platformio_libraries.values()) + assert len(libraries) == 1 + assert libraries[0].name == name + assert libraries[0].version == version + assert libraries[0].repository == repository + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_idf( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the native IDF toolchain, build_flags/lib_deps/lib_ignore are + honored, upload_speed is silent and everything else warns.""" + CORE.toolchain = Toolchain.ESP_IDF + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", # string and list forms both valid + "lib_deps": ["bblanchon/ArduinoJson@7.4.2"], + "lib_ignore": "libsodium", + "upload_speed": "115200", + "board_build.f_flash": "80000000L", + } + ) + + assert "-DSINGLE_FLAG" in CORE.build_flags + assert "ArduinoJson" in CORE.platformio_libraries + # lib_ignore is stored (listified) for generate_idf_components to read; + # nothing else lands in platformio_options on the native toolchain. + assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} + assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text + assert "upload_speed" not in caplog.text + # build_flags has a first-class esphome equivalent, so it is deprecated. + # lib_deps/lib_ignore are kept as valid platformio_options (no warning). + assert ( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead" in caplog.text + ) + assert "lib_deps is deprecated" not in caplog.text + assert "lib_ignore is deprecated" not in caplog.text + + +@pytest.mark.asyncio +async def test_add_platformio_options_platformio( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the PlatformIO toolchain all options pass through to the ini, + with build_flags/lib_ignore listified.""" + CORE.toolchain = Toolchain.PLATFORMIO + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", + "lib_ignore": "libsodium", + "upload_speed": "115200", + } + ) + + assert CORE.platformio_options == { + "build_flags": ["-DSINGLE_FLAG"], + "lib_ignore": ["libsodium"], + "upload_speed": "115200", + } + # platformio_options is the correct mechanism on the PlatformIO toolchain, + # so the native-equivalent deprecation must not fire here. + assert "deprecated" not in caplog.text + + +def test_add_library_str_bare_url_requires_name() -> None: + """A bare repository URL has no library name; CORE.add_library rejects it.""" + with pytest.raises(ValueError, match="must have a name"): + config._add_library_str("https://github.com/esphome/noise-c.git") + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: + """esphome->libraries entries are parsed and registered via cg.add_library.""" + result = load_config_from_fixture(yaml_file, "libraries.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + mock_cg.add_library.assert_any_call("SomeLib", None) + mock_cg.add_library.assert_any_call("bblanchon/ArduinoJson", "7.4.2") + mock_cg.add_library.assert_any_call( + "noise-c", None, "https://github.com/esphome/noise-c.git" + ) diff --git a/tests/unit_tests/fixtures/core/config/libraries.yaml b/tests/unit_tests/fixtures/core/config/libraries.yaml new file mode 100644 index 0000000000..c93e828f31 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/libraries.yaml @@ -0,0 +1,8 @@ +esphome: + name: test-libraries + libraries: + - SomeLib + - bblanchon/ArduinoJson@7.4.2 + - noise-c=https://github.com/esphome/noise-c.git + +host: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index cc371ee1f9..a61b6ae7ae 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -915,3 +915,21 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + def test_add_build_unflag__warns_on_native_idf_toolchain( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Build unflags are not consumed by the native IDF build generator, + so adding one on that toolchain warns; PlatformIO stays silent.""" + target.toolchain = const.Toolchain.PLATFORMIO + target.add_build_unflag("-fno-rtti") + assert "ignored" not in caplog.text + + target.toolchain = const.Toolchain.ESP_IDF + target.add_build_unflag("-fno-exceptions") + assert ( + "Build unflag -fno-exceptions is ignored when building with the " + "native ESP-IDF toolchain" in caplog.text + ) + # The unflag is still recorded either way. + assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 602ff03942..87e168dc94 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import hashlib import json import os from pathlib import Path @@ -515,7 +516,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -557,6 +558,62 @@ def test_generate_idf_components_dedupes_shared_dependency( assert "idf_component_register" in generated +def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # lib_ignore must drop B at the top level and C when it is discovered as a + # dependency of A during the graph walk -- neither may be resolved, + # downloaded, or wired into a manifest. Matching is by lowercase short name. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": {"name": "B"}, + } + + download_salts: list[str] = [] + + def fake_download(self, force=False, salt=""): + download_salts.append(salt) + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + # lib_ignore is read from CORE.platformio_options (stored there by + # _add_platformio_options); matched by lowercase short name. + monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]}) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert [c.name for c in top] == ["esphome/A"] + # Ignored libraries were never resolved (and therefore never downloaded). + assert resolve_calls == ["A"] + # The ignored dependency is not wired into A's manifest. + assert top[0].dependencies == [] + # lib_ignore changes the generated wiring, so the cache path is salted to + # keep this conversion separate from ones with a different lib_ignore. + assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]] + + def test_generate_idf_components_handles_dependency_cycle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -575,7 +632,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -632,7 +689,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -669,7 +726,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> None: # A library with neither library.json nor library.properties is invalid; # fail loudly rather than silently generating build files for it. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -711,7 +768,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -744,7 +801,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> None: # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, # not be silently dropped. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -782,7 +839,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text(json.dumps(manifests[self.name])) @@ -804,3 +861,54 @@ def test_generate_idf_components_incompatible_dependency_skipped( assert [c.name for c in top] == ["esphome/A"] # The incompatible dependency was dropped, not wired in. assert top[0].dependencies == [] + + +def test_url_source_salt_changes_cache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The salt is mixed into the URL hash so salted conversions get their own + cache tree. Pre-created extraction markers keep this network-free.""" + monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml") + url = "http://example.com/lib.tar.gz" + base = tmp_path / ".esphome" / "pio_components" + expected = {} + for salt in ("", "abcd1234"): + digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8] + expected[salt] = base / digest / "lib" + expected[salt].mkdir(parents=True) + (expected[salt] / ".esphome_extracted").touch() + + source = URLSource(url) + assert source.download("lib") == expected[""] + assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + + +def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: + """The salt becomes a subdirectory of the git clone domain.""" + domains: list[str] = [] + + def fake_clone_or_update(**kwargs): + domains.append(kwargs["domain"]) + return Path("/cloned"), None + + monkeypatch.setattr( + esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + ) + + source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") + source.download("noise-c") + source.download("noise-c", salt="abcd1234") + assert domains == ["pio_components", "pio_components/abcd1234"] + + +def test_idf_component_download_passes_salt() -> None: + """IDFComponent.download forwards the sanitized name and salt to the + source and records the returned path.""" + source = MagicMock() + source.download.return_value = Path("/converted/owner/name") + + c = IDFComponent("owner/name", "1.0", source=source) + c.download(force=True, salt="abcd1234") + + source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + assert c.path == Path("/converted/owner/name") From efebea32969ba72ce34524798f057064ffb7f766 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:18 -0400 Subject: [PATCH 1616/2214] [esp32] Add flash_mode and flash_frequency config options (#16920) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 24 +++++++++++++++++ .../esp32/config/flash_mode_default.yaml | 7 +++++ .../esp32/config/flash_mode_idf.yaml | 9 +++++++ tests/component_tests/esp32/test_esp32.py | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 tests/component_tests/esp32/config/flash_mode_default.yaml create mode 100644 tests/component_tests/esp32/config/flash_mode_idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7e7b127814..d703e22e46 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1615,8 +1615,14 @@ FLASH_SIZES = [ ] CONF_FLASH_SIZE = "flash_size" +CONF_FLASH_MODE = "flash_mode" +CONF_FLASH_FREQUENCY = "flash_frequency" CONF_CPU_FREQUENCY = "cpu_frequency" CONF_PARTITIONS = "partitions" +FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"] +FLASH_FREQUENCIES = [ + f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16) +] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -1630,6 +1636,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of( *FLASH_SIZES, upper=True ), + cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True), + cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( + *FLASH_FREQUENCIES, upper=True + ), cv.Optional(CONF_PARTITIONS): cv.Any( cv.file_, cv.ensure_list( @@ -1866,6 +1876,12 @@ async def to_code(config): "board_upload.maximum_size", int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, ) + if flash_mode := config.get(CONF_FLASH_MODE): + cg.add_platformio_option("board_build.flash_mode", flash_mode) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + cg.add_platformio_option( + "board_build.f_flash", f"{flash_frequency[:-3]}000000L" + ) if CONF_SOURCE in conf: cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) @@ -2016,6 +2032,14 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) + if flash_mode := config.get(CONF_FLASH_MODE): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True + ) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True + ) # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 # from y to n. PlatformIO uses sections.ld.in (for rev <3) or diff --git a/tests/component_tests/esp32/config/flash_mode_default.yaml b/tests/component_tests/esp32/config/flash_mode_default.yaml new file mode 100644 index 0000000000..0d05142099 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_default.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml new file mode 100644 index 0000000000..7c7f50a439 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + flash_mode: qio + flash_frequency: 80MHz + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e9fa9446d4..a8b5720a80 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -285,3 +285,29 @@ def test_native_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_flash_mode_sets_sdkconfig_and_pio_option( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode/flash_frequency select the esptool flash parameters on both backends.""" + generate_main(component_config_path("flash_mode_idf.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert CORE.platformio_options.get("board_build.flash_mode") == "qio" + assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" + + +def test_flash_mode_unset_leaves_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_mode the board/sdkconfig defaults stay untouched.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig) + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) + assert "board_build.flash_mode" not in CORE.platformio_options + assert "board_build.f_flash" not in CORE.platformio_options From 83504d2de2567619cdee1770a9bfbce36ff8da11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jun 2026 08:47:50 -0500 Subject: [PATCH 1617/2214] [esp8266] Decode crash handler PC and backtrace in logs (#16911) --- esphome/components/esp8266/__init__.py | 18 ++++++++++- .../components/test_esp_stacktrace.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index dd10a32fd6..db94f0ec6d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -492,6 +492,15 @@ def _parse_register(config, regex, line): STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") +# Structured crash handler output (crash_handler.cpp) from a previous boot: +# PC: 0x40220060 +# EXCVADDR: 0x0000008A +# BT0: 0x40212345 +STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile( + r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})" +) +STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state): "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") ) - # ESP8266 PC/EXCVADDR + # ESP8266 PC/EXCVADDR (legacy Arduino postmortem) _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) + # ESP8266 structured crash handler (crash_handler.cpp) from previous boot + _parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line) + _parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line) + match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) if match is not None: diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index 5235f313d6..f231ac5fb7 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -45,6 +45,36 @@ def test_process_stacktrace_esp8266_backtrace( assert state is False +def test_process_stacktrace_esp8266_crash_handler( + setup_core: Path, mock_esp8266_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 crash handler backtrace lines.""" + from esphome.components.esp8266 import process_stacktrace + + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp8266:191]: PC: 0x40220060" + state = process_stacktrace(config, line_pc, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40220060") + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + # Near-null data address (wild pointer) is not a code address, must be ignored + line_excvaddr = "[E][esp8266:193]: EXCVADDR: 0x0000008A" + state = process_stacktrace(config, line_excvaddr, False) + mock_esp8266_decode_pc.assert_not_called() + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + line_bt0 = "[E][esp8266:196]: BT0: 0x40212345" + state = process_stacktrace(config, line_bt0, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40212345") + assert state is False + + def test_process_stacktrace_esp32_backtrace( setup_core: Path, mock_esp32_decode_pc: Mock ) -> None: From 20925b32207ebd70060bc0c21799de862a447fd4 Mon Sep 17 00:00:00 2001 From: Tobiasz Jakubowski <12734857+tjakubo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:50:51 +0200 Subject: [PATCH 1618/2214] [spi] Skip logging on begin_transaction() of an auto-releasing write-only SPI device (#16921) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/spi/spi_esp_idf.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 107b6a3f1a..0731078eec 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate { write_only_(write_only) { if (!this->release_device_) add_device_(); + + if (this->write_only_) { + ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", + Utility::get_pin_no(this->cs_pin_)); + } } bool is_ready() override { return this->handle_ != nullptr; } @@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate { config.post_cb = nullptr; if (this->bit_order_ == BIT_ORDER_LSB_FIRST) config.flags |= SPI_DEVICE_BIT_LSBFIRST; - if (this->write_only_) { + if (this->write_only_) config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY; - ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", - Utility::get_pin_no(this->cs_pin_)); - } esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Add device failed - err %X", err); From 26ccaf70dbb3e4e6d422c1cd2584973edbc06647 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:21:38 +1000 Subject: [PATCH 1619/2214] [lvgl] Fix schema extraction (#16895) Co-authored-by: Claude Opus 4.8 --- esphome/components/lvgl/__init__.py | 175 ++++++++++++--------- esphome/components/lvgl/schemas.py | 48 +++++- script/build_language_schema.py | 28 ++++ tests/script/test_build_language_schema.py | 107 +++++++++++++ 4 files changed, 276 insertions(+), 82 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 022d629960..9137412abe 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -75,10 +76,14 @@ from .schemas import ( BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, + SET_STATE_SCHEMA, + STATE_SCHEMA, STYLE_REMAP, + STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, container_schema, + container_schema_value, obj_dict, ) from .styles import styles_to_code, theme_to_code @@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA page_spec, ) +# These style schemas live in .schemas but are imported here so they land in +# this module's namespace, where script/build_language_schema.py registers them +# as *named* schemas and emits `extends` references — instead of inlining the +# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the +# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in +# this file; this tuple keeps the imports live (and self-documents why). +_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA) + # Widget registration happens via WidgetType.__init__ in individual widget files # The imports below trigger creation of the widget types # Action registration (lvgl.{widget}.update) happens automatically @@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict: FINAL_VALIDATE_SCHEMA = final_validation -LVGL_SCHEMA = cv.All( - container_schema( - obj_spec, - cv.polling_component_schema("1s") - .extend( - { - **{ - cv.Optional(event): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) - ), - } - ) - for event in df.LV_SCREEN_EVENT_TRIGGERS - + df.LV_DISPLAY_EVENT_TRIGGERS - }, - cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), - cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), - cv.GenerateID(df.CONF_DISPLAYS): display_schema, - cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), - cv.Optional( - df.CONF_DEFAULT_FONT, default="montserrat_14" - ): lvalid.lv_font, - cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, - cv.Optional( - df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False - ): cv.boolean, - cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, - cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(CONF_ROTATION): validate_rotation, - cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( - *df.LV_LOG_LEVELS, upper=True - ), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "big_endian", "little_endian", lower=True - ), - cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( - FULL_STYLE_SCHEMA - ) - ), - cv.Optional(CONF_ON_IDLE): validate_automation( +# The options accepted at the top level of an `lvgl:` block, on top of the base +# object schema that `container_schema(obj_spec, ...)` supplies. Held in a +# module-level name (rather than inline) so the schema-extractor wrapper on +# CONFIG_SCHEMA below can hand the language-schema dumper the same composed +# schema the runtime validates against. +LVGL_TOP_LEVEL_SCHEMA = ( + cv.polling_component_schema("1s") + .extend( + { + **{ + cv.Optional(event): validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), - cv.Required(CONF_TIMEOUT): cv.templatable( - cv.positive_time_period_milliseconds + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) ), } - ), - cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), - **{ - cv.Optional(x): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), - }, - single=True, - ) - for x in SIMPLE_TRIGGERS - }, - cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), - cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, - cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), - cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), - cv.Optional( - df.CONF_TRANSPARENCY_KEY, default=0x000400 - ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, - cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, - cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, - cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, - cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, - cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), - cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, - } - ) - .extend(DISP_BG_SCHEMA), - ), + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS + }, + cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), + cv.GenerateID(df.CONF_DISPLAYS): display_schema, + cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, + cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, + cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, + cv.Optional(CONF_ROTATION): validate_rotation, + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( + *df.LV_LOG_LEVELS, upper=True + ), + cv.Optional(CONF_BYTE_ORDER): cv.one_of( + "big_endian", "little_endian", lower=True + ), + cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( + cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( + FULL_STYLE_SCHEMA + ) + ), + cv.Optional(CONF_ON_IDLE): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), + cv.Required(CONF_TIMEOUT): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), + cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, + cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, + cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + cv.Optional(df.CONF_THEME): _theme_schema, + cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, + cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, + cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, + cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, + cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), + cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + } + ) + .extend(DISP_BG_SCHEMA) +) + + +LVGL_SCHEMA = cv.All( + container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, ) +@schema_extractor("schema") def lvgl_config_schema(config): """ Can't use cv.ensure_list here because it converts an empty config to an empty list, rather than a default config. """ + if config is SCHEMA_EXTRACT: + # CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema + # closure, so the language-schema dumper can't see the top-level `lvgl:` + # fields (it would emit an empty schema). Hand it the same composed + # obj + top-level schema the runtime validates against, plus the + # `widgets:` key (added per-value by append_layout_schema at runtime, so + # otherwise invisible to the dumper). Validation of real configs (the + # branches below) is unchanged. + return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend( + {cv.Optional(df.CONF_WIDGETS): any_widget_schema()} + ) if not config or isinstance(config, dict): return [LVGL_SCHEMA(config)] return cv.Schema([LVGL_SCHEMA])(config) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bdaa91f15c..d7df628907 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,7 +22,11 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger -from esphome.schema_extractors import EnableSchemaExtraction +from esphome.schema_extractors import ( + SCHEMA_EXTRACT, + EnableSchemaExtraction, + schema_extractor, +) from . import defines as df, lv_validation as lvalid from .defines import ( @@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[ ] = {} +def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema: + """ + Build the static schema that :func:`container_schema` validates against, i.e. + everything except the value-dependent ``append_layout_schema`` applied at + validation time. + + Factored out and exposed so the language-schema dumper can extract a + representative schema for a widget — and for the top-level ``lvgl:`` block, + whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the + :func:`container_schema` validator closure. + """ + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + return schema.extend(widget_type.schema) + + def container_schema( widget_type: WidgetType, extras: Any = None ) -> Callable[[Any], Any]: @@ -649,12 +672,7 @@ def container_schema( def get_schema() -> cv.Schema: nonlocal cached_schema if cached_schema is None: - schema = obj_schema(widget_type).extend( - {cv.GenerateID(): cv.declare_id(widget_type.w_type)} - ) - if extras: - schema = schema.extend(extras) - cached_schema = schema.extend(widget_type.schema) + cached_schema = container_schema_value(widget_type, extras) return cached_schema def validator(value: Any) -> Any: @@ -678,7 +696,23 @@ def any_widget_schema(extras=None): :return: A validator for the Widgets key """ + @schema_extractor("schema") def validator(value): + if value is SCHEMA_EXTRACT: + # The widgets: list is built per-value at validation time, so the + # language-schema dumper sees nothing. Enumerate every registered + # widget type as an optional key (a widget item is really a + # single-key mapping; over-listing them lets editors complete any + # widget — `esphome config` enforces exactly one). extras carries the + # layout child options where applicable. + return cv.ensure_list( + cv.Schema( + { + cv.Optional(name): container_schema_value(widget_type, extras) + for name, widget_type in WIDGET_TYPES.items() + } + ) + ) if isinstance(value, dict): # Convert to list is_dict = True diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 4b0b0ee548..61845c4b25 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -428,6 +428,33 @@ def fix_menu(): menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") +def fix_lvgl_widgets(): + # lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The + # dumper has no cycle detection, so — like fix_menu — hoist the inlined + # widget-type enumeration into a named schema and reference it for both the + # top-level list and each widget's own children, instead of expanding it. + if "lvgl" not in output: + return + schemas = output["lvgl"][S_SCHEMAS] + config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS] + widgets = config_vars.get("widgets") + if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]: + return + # 1. Hoist the (one-level) widget enumeration into a named schema. + schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]} + # 2. Reference it from the top-level widgets: list instead of inlining. + widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]} + # 3. Let every widget contain child widgets, via the same named ref. + for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values(): + if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget: + widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = { + S_TYPE: S_SCHEMA, + "is_list": True, + "key": "Optional", + S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}, + } + + def get_logger_tags(): pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE) # tags not in components dir @@ -740,6 +767,7 @@ def build_schema(): add_logger_tags() shrink() fix_menu() + fix_lvgl_widgets() # aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc. data = {} diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8b81a57fef..badd4686f6 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -4,7 +4,12 @@ from __future__ import annotations import ast import importlib.util +import json from pathlib import Path +import subprocess +import sys + +import pytest from esphome import config_validation as cv @@ -176,3 +181,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: entry = converted["schema"]["config_vars"]["hostname"] assert "sensitive" not in entry assert "sensitive_source" not in entry + + +# --------------------------------------------------------------------------- +# Regression tests for the lvgl schema dump. +# +# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are +# built lazily at validation time, so the static dumper used to emit an empty +# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA +# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise +# the full `build_schema()` and assert the generated lvgl.json carries the data +# the schema_extractor hooks added. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: + """Run the full language-schema build once and return parsed lvgl.json. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps this test isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return json.loads((out_dir / "lvgl.json").read_text()) + + +def _lvgl_config_vars(lvgl_schema: dict) -> dict: + config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"] + # Previously empty (`{}`); the schema_extractor on lvgl_config_schema now + # hands the dumper the composed top-level schema. + assert config_schema["type"] == "schema" + return config_schema["schema"]["config_vars"] + + +def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed. + assert len(config_vars) > 100 + # A representative spread of top-level options the runtime validates. + for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"): + assert key in config_vars, f"missing top-level lvgl option: {key}" + + +def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # The widgets: list is assembled per-value at runtime; the extractor + # enumerates every registered widget type into a named WIDGET_TYPES schema + # which the widgets: list references (recursive, so widgets can nest). + assert "widgets" in config_vars + widgets = config_vars["widgets"] + assert widgets["is_list"] is True + assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][ + "config_vars" + ] + # Every registered widget type should appear as an optional key. + for name in ("obj", "label", "button", "slider", "switch", "arc"): + assert name in widget_types, f"widget type not enumerated: {name}" + # Each enumerated widget carries its own property schema, not an empty stub. + assert widget_types["label"]["type"] == "schema" + assert len(widget_types["label"]["schema"]["config_vars"]) > 0 + # Each widget can contain child widgets, via the same named ref — so the + # tree is recursive and the dump stays finite. + nested = widget_types["obj"]["schema"]["config_vars"]["widgets"] + assert nested["is_list"] is True + assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + +def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None: + schemas = lvgl_schema["lvgl"]["schemas"] + # Importing these into the lvgl __init__ namespace lets the dumper register + # them as named schemas and emit `extends` refs instead of inlining them. + for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"): + assert name in schemas, f"style schema not registered as named: {name}" + + # STYLE_SCHEMA must be referenced via `extends`, not inlined at every use + # site. Count the references to prove the dedup actually happened. + refs = 0 + + def _count(node: object) -> None: + nonlocal refs + if isinstance(node, dict): + extends = node.get("extends") + if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends: + refs += 1 + for value in node.values(): + _count(value) + elif isinstance(node, list): + for value in node: + _count(value) + + _count(lvgl_schema) + assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}" From 9ffd350095e5836de12a3110fef73a95e77bdb53 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:36:38 +1000 Subject: [PATCH 1620/2214] [mipi_spi] Implement automatic mapping of offsets (#16722) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 131 ++++-- esphome/components/mipi_dsi/display.py | 8 +- esphome/components/mipi_rgb/display.py | 8 +- esphome/components/mipi_spi/display.py | 36 +- esphome/components/mipi_spi/mipi_spi.h | 87 ++-- esphome/components/mipi_spi/models/ili.py | 28 ++ .../components/mipi_spi/models/waveshare.py | 13 + tests/component_tests/mipi_spi/test_init.py | 4 +- .../mipi_spi/test_padding_and_offsets.py | 434 ++++++++++++++++++ 9 files changed, 662 insertions(+), 87 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_padding_and_offsets.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index c3b744c919..129befe600 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay +CONF_PAD_HEIGHT = "pad_height" +CONF_PAD_WIDTH = "pad_width" CONF_PIXEL_MODE = "pixel_mode" CONF_USE_AXIS_FLIPS = "use_axis_flips" @@ -202,6 +204,8 @@ def dimension_schema(rounding): rounding ), cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), + cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding), + cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding), } ), ) @@ -311,6 +315,36 @@ class DriverChip: name = name.upper() self.name = name self.initsequence = initsequence + if CONF_NATIVE_WIDTH in defaults: + if CONF_WIDTH not in defaults: + defaults[CONF_WIDTH] = ( + defaults[CONF_NATIVE_WIDTH] + - defaults.get(CONF_OFFSET_WIDTH, 0) + - defaults.get(CONF_PAD_WIDTH, 0) + ) + else: + native_width = ( + defaults.get(CONF_WIDTH, 0) + + defaults.get(CONF_OFFSET_WIDTH, 0) + + defaults.get(CONF_PAD_WIDTH, 0) + ) + if native_width != 0: + defaults[CONF_NATIVE_WIDTH] = native_width + if CONF_NATIVE_HEIGHT in defaults: + if CONF_HEIGHT not in defaults: + defaults[CONF_HEIGHT] = ( + defaults[CONF_NATIVE_HEIGHT] + - defaults.get(CONF_OFFSET_HEIGHT, 0) + - defaults.get(CONF_PAD_HEIGHT, 0) + ) + else: + native_height = ( + defaults.get(CONF_HEIGHT, 0) + + defaults.get(CONF_OFFSET_HEIGHT, 0) + + defaults.get(CONF_PAD_HEIGHT, 0) + ) + if native_height != 0: + defaults[CONF_NATIVE_HEIGHT] = native_height self.defaults = defaults DriverChip.models[name] = self @@ -336,18 +370,6 @@ class DriverChip: initsequence = list(kwargs.pop("initsequence", self.initsequence)) initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() - if ( - CONF_WIDTH in defaults - and CONF_OFFSET_WIDTH in kwargs - and CONF_NATIVE_WIDTH not in defaults - ): - defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] - if ( - CONF_HEIGHT in defaults - and CONF_OFFSET_HEIGHT in kwargs - and CONF_NATIVE_HEIGHT not in defaults - ): - defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) @@ -385,13 +407,16 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + def get_dimensions( + self, config, swap: bool = True + ) -> tuple[int, int, int, int, int, int]: """ Return the dimensions of the current model. :param config: The current configuration :param swap: If width/height should be swapped when axes are swapped. - :return: + :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] @@ -400,33 +425,71 @@ class DriverChip: height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] offset_height = dimensions[CONF_OFFSET_HEIGHT] - return width, height, offset_width, offset_height - (width, height) = dimensions - return width, height, 0, 0 + if CONF_PAD_WIDTH in dimensions: + pad_width = dimensions[CONF_PAD_WIDTH] + native_width = width + offset_width + pad_width + else: + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + if native_width == 0: + pad_width = 0 + native_width = width + offset_width + else: + pad_width = native_width - width - offset_width + if CONF_PAD_HEIGHT in dimensions: + pad_height = dimensions[CONF_PAD_HEIGHT] + native_height = height + offset_height + pad_height + else: + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if native_height == 0: + pad_height = 0 + native_height = height + offset_height + else: + pad_height = native_height - height - offset_height + if ( + pad_width + offset_width >= native_width + or pad_height + offset_height >= native_height + ): + raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS]) + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS]) + + return width, height, offset_width, offset_height, pad_width, pad_height + + # Must be a tuple + width, height = dimensions + return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) offset_width = self.get_default(CONF_OFFSET_WIDTH, 0) offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0) + pad_width = self.get_default( + CONF_PAD_WIDTH, native_width - width - offset_width + ) + pad_height = self.get_default( + CONF_PAD_HEIGHT, native_height - height - offset_height + ) + + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS]) # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where # the offset is asymmetric if transform.get(CONF_MIRROR_X): - native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) - offset_width = native_width - width - offset_width + offset_width, pad_width = pad_width, offset_width if transform.get(CONF_MIRROR_Y): - native_height = self.get_default( - CONF_NATIVE_HEIGHT, height + offset_height * 2 - ) - offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + offset_height, pad_height = pad_height, offset_height + # Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height - return width, height, offset_width, offset_height + pad_width, pad_height = pad_height, pad_width + return width, height, offset_width, offset_height, pad_width, pad_height def get_base_transform(self, config): transform = config.get( @@ -450,20 +513,8 @@ class DriverChip: def get_transform(self, config) -> dict[str, bool]: transform = self.get_base_transform(config) - can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True + transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform def swap_xy_schema(self): @@ -498,8 +549,8 @@ class DriverChip: return madctl def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - # This takes into account rotation if it can be implemented in the transform + # Add the MADCTL command to the sequence based on the base configuration. + # Rotation is not applied here, it will be done at runtime. transform = self.get_transform(config) madctl = self.get_madctl(transform, config) sequence.append((MADCTL, madctl & 0xFF)) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 46e7a7d5a7..896140b4b1 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -172,7 +172,9 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -206,7 +208,9 @@ async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) sequence = model.get_sequence(config) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 3c33c26726..1eacc31fc5 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -235,7 +235,9 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height) cg.add(var.set_model(model.name)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 8c6ffff500..abb7eaa458 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -27,7 +27,7 @@ from esphome.components.mipi import ( requires_buffer, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN -from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( @@ -121,7 +121,9 @@ def denominator(config): """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - _width, height, _offset_width, _offset_height = model.get_dimensions(config) + _width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) if frac is None or frac > 0.75 or height < 32: return 1 try: @@ -169,11 +171,22 @@ def model_schema(config): ] if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) + # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. + spi_mode = model.get_default(CONF_SPI_MODE) + if not spi_mode: + if bus_mode == TYPE_OCTAL or ( + bus_mode == TYPE_SINGLE + and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + ): + spi_mode = "MODE3" + else: + spi_mode = "MODE0" + schema = ( display.FULL_DISPLAY_SCHEMA.extend( spi.spi_device_schema( cs_pin_required=False, - default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0", + default_mode=spi_mode, default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), mode=bus_mode, ) @@ -279,8 +292,8 @@ def customise_schema(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, _offset_width, _offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) display.add_metadata( config[CONF_ID], @@ -313,14 +326,17 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + # Always call this to check dimensions during validation + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) - width, height, _offset_width, _offset_height = model.get_dimensions(config) - buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here fraction = min(20000.0, buffer_size // 4) / buffer_size @@ -347,8 +363,8 @@ def get_instance(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, offset_width, offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, offset_width, offset_height, pad_width, pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) @@ -374,6 +390,8 @@ def get_instance(config): height, offset_width, offset_height, + pad_width, + pad_height, madctl, has_hardware_transform, ] diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 5023cf8089..a594e48209 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -126,17 +131,6 @@ class MipiSpi : public display::Display, return HEIGHT; } - // If hardware rotation is in use, the actual display width/height changes with rotation - int get_width_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_width(); - return WIDTH; - } - int get_height_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_height(); - return HEIGHT; - } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -233,14 +227,25 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, - (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, - this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, - HAS_HARDWARE_ROTATION); + internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), + this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, + IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, + this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } protected: /* METHODS */ + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } // convenience functions to write commands with or without data void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); } void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); } @@ -330,20 +335,34 @@ class MipiSpi : public display::Display, this->write_command_(MADCTL_CMD, madctl); } - uint16_t get_offset_width_() { + uint16_t get_offset_width_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_HEIGHT; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return OFFSET_HEIGHT; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_270_DEGREES: + return PAD_HEIGHT; + default: + break; + } } return OFFSET_WIDTH; } - uint16_t get_offset_height_() { + uint16_t get_offset_height_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_WIDTH; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_HEIGHT; + case display::DISPLAY_ROTATION_270_DEGREES: + return OFFSET_WIDTH; + default: + break; + } } return OFFSET_HEIGHT; } @@ -396,7 +415,7 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8); } } else { - for (size_t y = 0; y != static_cast(h); y++) { + for (size_t y = 0; y != h; y++) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -492,19 +511,23 @@ class MipiSpi : public display::Display, * @tparam BUFFERPIXEL Color depth of the buffer * @tparam DISPLAYPIXEL Color depth of the display * @tparam BUS_TYPE The type of the interface bus (single, quad, octal) - * @tparam ROTATION The rotation of the display * @tparam WIDTH Width of the display in pixels * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer). * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template -class MipiSpiBuffer : public MipiSpi { + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, + uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> +class MipiSpiBuffer + : public MipiSpi { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and @@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi::dump_config(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi::setup(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup(); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index ae6accb907..5df7a275df 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -179,6 +179,9 @@ ILI9342 = DriverChip( # M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation ILI9341.extend( "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, width=320, height=240, mirror_x=False, @@ -786,3 +789,28 @@ ST7796.extend( dc_pin=0, invert_colors=True, ) + +ST7789V.extend( + "GEEKMAGIC-SMALLTV", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=2, + dc_pin=0, +) +ST7789V.extend( + "GEEKMAGIC-SMALLTV-PRO", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=4, + dc_pin=2, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee46f931de..3c719b0f5e 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -269,3 +269,16 @@ ST7789V.extend( cs_pin=14, dc_pin={"number": 15, "ignore_strapping_warning": True}, ) + +ST7789V.extend( + "WAVESHARE-ESP32-S3-GEEK", + cs_pin=10, + dc_pin=8, + reset_pin=9, + width=135, + height=240, + offset_width=52, + offset_height=40, + invert_colors=True, + data_rate="40MHz", +) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 4873892a8d..d681908027 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -314,7 +314,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp @@ -330,7 +330,7 @@ def test_lvgl_generation( main_cpp = generate_main(component_fixture_path("lvgl.yaml")) assert ( - "mipi_spi::MipiSpi();" + "mipi_spi::MipiSpi();" in main_cpp ) assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py new file mode 100644 index 0000000000..82adf88b7e --- /dev/null +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -0,0 +1,434 @@ +"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.components.mipi_spi.display import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + MODELS, + get_instance, +) +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: ConfigType) -> ConfigType: + """Run schema + final validation and return the validated config.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +class TestSPIModeCalculation: + """Test default SPI mode calculation logic.""" + + @pytest.mark.parametrize( + ("bus_mode", "cs_pin", "expected_mode"), + [ + pytest.param( + TYPE_OCTAL, + None, + "MODE3", + id="octal_bus_no_cs", + ), + pytest.param( + TYPE_OCTAL, + 14, + "MODE3", + id="octal_bus_with_cs", + ), + pytest.param( + TYPE_SINGLE, + None, + "MODE3", + id="single_bus_no_cs", + ), + pytest.param( + TYPE_SINGLE, + 14, + "MODE0", + id="single_bus_with_cs", + ), + pytest.param( + TYPE_QUAD, + None, + "MODE0", + id="quad_bus_no_cs", + ), + pytest.param( + TYPE_QUAD, + 14, + "MODE0", + id="quad_bus_with_cs", + ), + ], + ) + def test_default_spi_mode_calculation( + self, + bus_mode: str, + cs_pin: int | None, + expected_mode: str, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that SPI mode is correctly calculated based on bus mode and CS pin.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config: ConfigType = { + "model": "custom", + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": bus_mode, + } + + # Add dc_pin for modes that require it (single and octal) + # quad mode does not allow dc_pin + if bus_mode != TYPE_QUAD: + config[CONF_DC_PIN] = 11 + + # Add CS pin if specified + if cs_pin is not None: + config[CONF_CS_PIN] = cs_pin + + validated = validated_config(config) + # The validated config should have the correct SPI mode set by model_schema + assert validated.get(CONF_SPI_MODE) == expected_mode + + def test_explicit_spi_mode_overrides_default( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that an explicitly configured SPI mode is not overridden.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # For octal bus, default is MODE3, but we specify MODE0 + config = validated_config( + { + "model": "custom", + "dc_pin": 11, # Required for octal mode + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": TYPE_OCTAL, + "spi_mode": "MODE0", # Explicitly set + } + ) + + assert config[CONF_SPI_MODE] == "MODE0" + + +class TestModelWithPaddingDimensions: + """Test that padding dimensions are correctly returned by models.""" + + def test_model_get_dimensions_returns_six_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that get_dimensions() returns 6 values including padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # Test with a real model + model = MODELS["ST7735"] + config = {"model": "ST7735", "dc_pin": 18} + + # Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height) + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6 + assert all(isinstance(v, int) for v in dimensions) + + def test_custom_model_padding_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding values for a custom model with explicit offset.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 20, + "offset_height": 10, + }, + "init_sequence": [[0xA0, 0x01]], + } + ) + + # For custom models, the model is created dynamically from the config + # We can verify the config has the right dimensions + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 320 + assert config["dimensions"]["offset_width"] == 20 + assert config["dimensions"]["offset_height"] == 10 + # Padding is not stored in config for custom models (defaults to 0) + assert config["dimensions"].get("offset_width_pad", 0) == 0 + assert config["dimensions"].get("offset_height_pad", 0) == 0 + + +class TestNewModelVariants: + """Test new model variants added in this change.""" + + def test_m5core2_with_native_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test M5CORE2 variant with reset native_width and native_height.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # M5CORE2 should validate successfully + config = validated_config({"model": "M5CORE2"}) + assert config is not None + + # Verify the model has correct dimensions + model = MODELS["M5CORE2"] + dimensions = model.get_dimensions(config) + width, height, _, _, _, _ = dimensions + assert width == 320 + assert height == 240 + + def test_geekmagic_smalltv_variant( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test GEEKMAGIC-SMALLTV variant of ST7789V.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # GEEKMAGIC-SMALLTV should validate successfully + config = validated_config({"model": "GEEKMAGIC-SMALLTV"}) + assert config is not None + + # Verify it's a variant of ST7789V with expected dimensions + model = MODELS["GEEKMAGIC-SMALLTV"] + dimensions = model.get_dimensions(config) + width, height, offset_x, offset_y, _, _ = dimensions + assert width == 240 + assert height == 240 + assert offset_x == 0 + assert offset_y == 0 + + def test_all_predefined_models_with_new_get_dimensions_signature( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Verify all predefined models work with new 6-value get_dimensions().""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + for name, model in MODELS.items(): + # Skip custom model + if name == "custom": + continue + + config = {"model": name} + + # Try to get dimensions - should return 6 values for all models + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6, ( + f"Model {name} should return 6 dimensions, got {len(dimensions)}" + ) + + +class TestTemplateParameterPassing: + """Test that padding parameters are correctly passed to C++ templates.""" + + def test_instance_creation_with_padding( + self, + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], + ) -> None: + """Test that get_instance() correctly passes padding parameters to template.""" + main_cpp = generate_main(component_fixture_path("native.yaml")) + + # native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer + # (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION, + # FRACTION, ROUNDING) + # The instantiation should include padding values (0, 0 for default) + assert ( + "mipi_spi::MipiSpiBuffer()" + in main_cpp + ), ( + "Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation" + ) + + def test_single_mode_with_offset_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that single-mode display with custom offset works with padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Should not raise any errors + instance = get_instance(config) + assert instance is not None + + +class TestUserConfiguredPadding: + """Test that pad_width and pad_height can be configured in user dimensions.""" + + def test_explicit_pad_width_and_height_in_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that pad_width and pad_height can be explicitly set in dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + "pad_width": 80, + "pad_height": 40, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Config should validate successfully with padding dimensions + assert config is not None + assert config["dimensions"]["pad_width"] == 80 + assert config["dimensions"]["pad_height"] == 40 + + def test_padding_for_native_dimension_calculation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that explicit padding allows native dimensions to be calculated.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A controller that has 320x320 total pixels with: + # - 240x320 active display area + # - offset_width=40, offset_height=20 + # - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, # Active display width + "height": 320, # Active display height + "offset_width": 40, + "offset_height": 0, + "pad_width": 40, # Pixels after width+offset + "pad_height": 0, # Pixels after height+offset + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Get instance should work and correctly calculate native dimensions + instance = get_instance(config) + assert instance is not None + + def test_padding_without_offset( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding can be used without offset for controllers with top-left-aligned displays.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A display with no offset but padding on right and bottom + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 240, + "offset_width": 0, + "offset_height": 0, + "pad_width": 0, + "pad_height": 16, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + assert config is not None + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 240 + assert config["dimensions"]["pad_height"] == 16 From c768e2eabc1cc88a6b78437c42d23ed04b171199 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:48:43 -0400 Subject: [PATCH 1621/2214] [esp32] Fix idedata generation failing on unset ESPHOME_ARDUINO (#16925) --- .clang-tidy.hash | 2 +- esphome/components/esp32/pre_build.py.script | 7 +++++++ esphome/espidf/clang_tidy.py | 2 +- esphome/idf_component.yml | 2 +- platformio.ini | 18 +++++++++++++----- tests/unit_tests/test_espidf_clang_tidy.py | 6 +++--- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 6f6339ff84..7497cc3679 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 +a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script index af12275a0b..8728e02a34 100644 --- a/esphome/components/esp32/pre_build.py.script +++ b/esphome/components/esp32/pre_build.py.script @@ -1,3 +1,5 @@ +import os + Import("env") # noqa: F821 # Remove custom_sdkconfig from the board config as it causes @@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board: del board._manifest["espidf"]["custom_sdkconfig"] if not board._manifest["espidf"]: del board._manifest["espidf"] + +# Referenced by rules in esphome/idf_component.yml; an unset env var is a +# fatal error there. Always 0: in PlatformIO builds arduino is not a managed +# IDF component. +os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0") diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 62d6f0d00d..d3f4d151c2 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: # Gates arduino-only components in esphome/idf_component.yml (IDF reads it at # reconfigure time). Set here -- before the manifest is written/reconfigured. - os.environ["ESPHOME_ARDUINO"] = ( + os.environ["ESPHOME_ARDUINO_COMPONENT"] = ( "1" if settings.target_framework == "arduino" else "0" ) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7cbc2ac4ae..c97e8906a8 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -109,4 +109,4 @@ dependencies: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b rules: - - if: "$ESPHOME_ARDUINO == 1" + - if: "$ESPHOME_ARDUINO_COMPONENT == 1" diff --git a/platformio.ini b/platformio.ini index 718dfb672f..862b7a7dbe 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,7 +141,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -168,12 +171,16 @@ build_flags = -DAUDIO_NO_SD_FS ; i2s_audio build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = espidf lib_deps = @@ -187,7 +194,9 @@ build_flags = -DUSE_ESP32_FRAMEWORK_ESP_IDF build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; These are common settings for the RP2040 using Arduino. [common:rp2040-arduino] @@ -271,7 +280,6 @@ build_unflags = [env:esp32-arduino] extends = common:esp32-arduino board = esp32dev -board_build.partitions = huge_app.csv build_flags = ${common:esp32-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 9791dfc543..cb25535d8d 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -56,11 +56,11 @@ def test_setup_core_sets_arduino_env( target_framework: str, expected: str, ) -> None: - """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + """_setup_core sets ESPHOME_ARDUINO_COMPONENT, which gates arduino-only manifest deps.""" # monkeypatch snapshots os.environ, so the env var _setup_core writes is # restored after the test instead of leaking into later tests. - monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) + monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False) _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) - assert os.environ["ESPHOME_ARDUINO"] == expected + assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected From f83e3ad6a6c4d7fdb2dfd20be815313f2afe6e81 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:03 -0400 Subject: [PATCH 1622/2214] [core] Support platformio_options on the native ESP-IDF toolchain (#16917) --- esphome/core/__init__.py | 7 + esphome/core/config.py | 66 ++++++++-- esphome/espidf/component.py | 55 ++++++-- tests/unit_tests/core/test_config.py | 123 ++++++++++++++++++ .../fixtures/core/config/libraries.yaml | 8 ++ tests/unit_tests/test_core.py | 18 +++ tests/unit_tests/test_espidf_component.py | 122 ++++++++++++++++- 7 files changed, 366 insertions(+), 33 deletions(-) create mode 100644 tests/unit_tests/fixtures/core/config/libraries.yaml diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 4289cdf3e5..21ff7ef07c 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -958,6 +958,13 @@ class EsphomeCore: return build_flag def add_build_unflag(self, build_unflag: str) -> None: + if self.using_toolchain_esp_idf: + # The native ESP-IDF build generator does not consume build_unflags + _LOGGER.warning( + "Build unflag %s is ignored when building with the native " + "ESP-IDF toolchain", + build_unflag, + ) self.build_unflags.add(build_unflag) _LOGGER.debug("Adding build unflag: %s", build_unflag) diff --git a/esphome/core/config.py b/esphome/core/config.py index 8214fcf80c..b925f0b7d9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None: include_file(path, basename, is_c_header) +def _add_library_str(lib: str) -> None: + if "@" in lib: + name, vers = lib.split("@", 1) + cg.add_library(name, vers) + elif "://" in lib: + # Repository... + if "=" in lib: + name, repo = lib.split("=", 1) + cg.add_library(name, None, repo) + else: + cg.add_library(None, None, lib) + else: + cg.add_library(lib, None) + + @coroutine_with_priority(CoroPriority.FINAL) -async def _add_platformio_options(pio_options): +async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: + if CORE.using_toolchain_esp_idf: + # The native ESP-IDF build doesn't read platformio.ini; honor the + # options with a native equivalent and warn about the rest, which + # would otherwise be silently ignored. + for key, val in pio_options.items(): + vals = [val] if isinstance(val, str) else val + if key == CONF_BUILD_FLAGS: + # Deprecated: esphome->build_flags is the native equivalent. + # Remove before 2026.12.0 + _LOGGER.warning( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead. Support for it will be removed " + "in 2026.12.0." + ) + for flag in vals: + cg.add_build_flag(flag) + elif key == "lib_deps": + # Routed through the regular library mechanism so the libraries + # are converted to IDF components like any other PIO library + for lib in vals: + _add_library_str(lib) + elif key == "lib_ignore": + # Read by the PIO-library-to-IDF-component conversion + # (generate_idf_components); filters both top-level libraries + # and dependencies discovered during conversion + cg.add_platformio_option(key, vals) + elif key != "upload_speed": + # upload_speed needs no handling: it is read from the raw + # config at upload time (upload_using_esptool) + _LOGGER.warning( + "esphome->platformio_options->%s is ignored when building with " + "the native ESP-IDF toolchain", + key, + ) + return # Add includes at the very end, so that they override everything for key, val in pio_options.items(): if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): @@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None: # Libraries for lib in config[CONF_LIBRARIES]: - if "@" in lib: - name, vers = lib.split("@", 1) - cg.add_library(name, vers) - elif "://" in lib: - # Repository... - if "=" in lib: - name, repo = lib.split("=", 1) - cg.add_library(name, None, repo) - else: - cg.add_library(None, None, lib) - - else: - cg.add_library(lib, None) + _add_library_str(lib) cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7398a91c36..cfd42916b2 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: raise NotImplementedError @@ -64,10 +64,12 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: base_dir = Path(CORE.data_dir) / DOMAIN h = hashlib.new("sha256") h.update(self.url.encode()) + if salt: + h.update(salt.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix # Marker file written last to signal a complete extraction. Using a # marker (instead of just `path.is_dir()`) means an interrupted @@ -99,12 +101,12 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=DOMAIN, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, submodules=[], subpath=Path(dir_suffix), ) @@ -146,14 +148,16 @@ class IDFComponent: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False): + def download(self, force: bool = False, salt: str = ""): """ The dependency name should match the directory name at the end of the override path. The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. If you want to specify the full name of the component with the namespace, replace / in the component name with __. @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html """ - self.path = self.source.download(self.get_sanitized_name(), force=force) + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) def _apply_extra_script(component: IDFComponent) -> None: @@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: The returned list holds the top-level components (those directly requested); transitive dependencies are converted too and wired into each component's generated manifest. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. """ nodes: dict[str, _LibNode] = {} + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated CMakeLists.txt/idf_component.yml inside the shared cache + # bake in the dependency wiring, which lib_ignore changes; salt the cache + # path so configs with different lib_ignore values don't fight over (and + # constantly rewrite) the same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: key, is_git, locator = _node_key(name, version, repository) node = nodes.get(key) or _LibNode(key=key, is_git=is_git) @@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: top_level = [ add_spec(library.name, library.version, library.repository) for library in libraries + if not is_ignored(library.name) ] # Collect + resolve to a fixpoint: a node is (re)resolved whenever its @@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: component = IDFComponent( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download() + component.download(salt=salt) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" @@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: except InvalidIDFComponent as e: _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] dep_url = None @@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: dep_url, dep_version = dep_version, None except (TypeError, ValueError): pass - dep_key = add_spec( - _owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")), - dep_version, - dep_url, - ) + dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) worklist.append(dep_key) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ff150f2540..e2b34d92d8 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -20,6 +20,9 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE, config from esphome.core.config import ( @@ -1161,3 +1164,123 @@ def test_make_app_name_cpp_special_chars_escaped() -> None: cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False) # cpp_string_escape uses octal escapes for quotes assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes + + +@pytest.mark.parametrize( + ("lib", "name", "version", "repository"), + [ + ("ArduinoJson", "ArduinoJson", None, None), + ("bblanchon/ArduinoJson@7.4.2", "bblanchon/ArduinoJson", "7.4.2", None), + ( + "noise-c=https://github.com/esphome/noise-c.git", + "noise-c", + None, + "https://github.com/esphome/noise-c.git", + ), + ], +) +def test_add_library_str( + lib: str, name: str, version: str | None, repository: str | None +) -> None: + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + config._add_library_str(lib) + + libraries = list(CORE.platformio_libraries.values()) + assert len(libraries) == 1 + assert libraries[0].name == name + assert libraries[0].version == version + assert libraries[0].repository == repository + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_idf( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the native IDF toolchain, build_flags/lib_deps/lib_ignore are + honored, upload_speed is silent and everything else warns.""" + CORE.toolchain = Toolchain.ESP_IDF + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", # string and list forms both valid + "lib_deps": ["bblanchon/ArduinoJson@7.4.2"], + "lib_ignore": "libsodium", + "upload_speed": "115200", + "board_build.f_flash": "80000000L", + } + ) + + assert "-DSINGLE_FLAG" in CORE.build_flags + assert "ArduinoJson" in CORE.platformio_libraries + # lib_ignore is stored (listified) for generate_idf_components to read; + # nothing else lands in platformio_options on the native toolchain. + assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} + assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text + assert "upload_speed" not in caplog.text + # build_flags has a first-class esphome equivalent, so it is deprecated. + # lib_deps/lib_ignore are kept as valid platformio_options (no warning). + assert ( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead" in caplog.text + ) + assert "lib_deps is deprecated" not in caplog.text + assert "lib_ignore is deprecated" not in caplog.text + + +@pytest.mark.asyncio +async def test_add_platformio_options_platformio( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the PlatformIO toolchain all options pass through to the ini, + with build_flags/lib_ignore listified.""" + CORE.toolchain = Toolchain.PLATFORMIO + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", + "lib_ignore": "libsodium", + "upload_speed": "115200", + } + ) + + assert CORE.platformio_options == { + "build_flags": ["-DSINGLE_FLAG"], + "lib_ignore": ["libsodium"], + "upload_speed": "115200", + } + # platformio_options is the correct mechanism on the PlatformIO toolchain, + # so the native-equivalent deprecation must not fire here. + assert "deprecated" not in caplog.text + + +def test_add_library_str_bare_url_requires_name() -> None: + """A bare repository URL has no library name; CORE.add_library rejects it.""" + with pytest.raises(ValueError, match="must have a name"): + config._add_library_str("https://github.com/esphome/noise-c.git") + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: + """esphome->libraries entries are parsed and registered via cg.add_library.""" + result = load_config_from_fixture(yaml_file, "libraries.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + mock_cg.add_library.assert_any_call("SomeLib", None) + mock_cg.add_library.assert_any_call("bblanchon/ArduinoJson", "7.4.2") + mock_cg.add_library.assert_any_call( + "noise-c", None, "https://github.com/esphome/noise-c.git" + ) diff --git a/tests/unit_tests/fixtures/core/config/libraries.yaml b/tests/unit_tests/fixtures/core/config/libraries.yaml new file mode 100644 index 0000000000..c93e828f31 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/libraries.yaml @@ -0,0 +1,8 @@ +esphome: + name: test-libraries + libraries: + - SomeLib + - bblanchon/ArduinoJson@7.4.2 + - noise-c=https://github.com/esphome/noise-c.git + +host: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index cc371ee1f9..a61b6ae7ae 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -915,3 +915,21 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + def test_add_build_unflag__warns_on_native_idf_toolchain( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Build unflags are not consumed by the native IDF build generator, + so adding one on that toolchain warns; PlatformIO stays silent.""" + target.toolchain = const.Toolchain.PLATFORMIO + target.add_build_unflag("-fno-rtti") + assert "ignored" not in caplog.text + + target.toolchain = const.Toolchain.ESP_IDF + target.add_build_unflag("-fno-exceptions") + assert ( + "Build unflag -fno-exceptions is ignored when building with the " + "native ESP-IDF toolchain" in caplog.text + ) + # The unflag is still recorded either way. + assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 602ff03942..87e168dc94 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import hashlib import json import os from pathlib import Path @@ -515,7 +516,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -557,6 +558,62 @@ def test_generate_idf_components_dedupes_shared_dependency( assert "idf_component_register" in generated +def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # lib_ignore must drop B at the top level and C when it is discovered as a + # dependency of A during the graph walk -- neither may be resolved, + # downloaded, or wired into a manifest. Matching is by lowercase short name. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": {"name": "B"}, + } + + download_salts: list[str] = [] + + def fake_download(self, force=False, salt=""): + download_salts.append(salt) + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + # lib_ignore is read from CORE.platformio_options (stored there by + # _add_platformio_options); matched by lowercase short name. + monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]}) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert [c.name for c in top] == ["esphome/A"] + # Ignored libraries were never resolved (and therefore never downloaded). + assert resolve_calls == ["A"] + # The ignored dependency is not wired into A's manifest. + assert top[0].dependencies == [] + # lib_ignore changes the generated wiring, so the cache path is salted to + # keep this conversion separate from ones with a different lib_ignore. + assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]] + + def test_generate_idf_components_handles_dependency_cycle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -575,7 +632,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -632,7 +689,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -669,7 +726,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> None: # A library with neither library.json nor library.properties is invalid; # fail loudly rather than silently generating build files for it. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -711,7 +768,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -744,7 +801,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> None: # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, # not be silently dropped. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -782,7 +839,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text(json.dumps(manifests[self.name])) @@ -804,3 +861,54 @@ def test_generate_idf_components_incompatible_dependency_skipped( assert [c.name for c in top] == ["esphome/A"] # The incompatible dependency was dropped, not wired in. assert top[0].dependencies == [] + + +def test_url_source_salt_changes_cache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The salt is mixed into the URL hash so salted conversions get their own + cache tree. Pre-created extraction markers keep this network-free.""" + monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml") + url = "http://example.com/lib.tar.gz" + base = tmp_path / ".esphome" / "pio_components" + expected = {} + for salt in ("", "abcd1234"): + digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8] + expected[salt] = base / digest / "lib" + expected[salt].mkdir(parents=True) + (expected[salt] / ".esphome_extracted").touch() + + source = URLSource(url) + assert source.download("lib") == expected[""] + assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + + +def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: + """The salt becomes a subdirectory of the git clone domain.""" + domains: list[str] = [] + + def fake_clone_or_update(**kwargs): + domains.append(kwargs["domain"]) + return Path("/cloned"), None + + monkeypatch.setattr( + esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + ) + + source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") + source.download("noise-c") + source.download("noise-c", salt="abcd1234") + assert domains == ["pio_components", "pio_components/abcd1234"] + + +def test_idf_component_download_passes_salt() -> None: + """IDFComponent.download forwards the sanitized name and salt to the + source and records the returned path.""" + source = MagicMock() + source.download.return_value = Path("/converted/owner/name") + + c = IDFComponent("owner/name", "1.0", source=source) + c.download(force=True, salt="abcd1234") + + source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + assert c.path == Path("/converted/owner/name") From 99425e3a976ac8c5c9602ffffa35a3b987f9d779 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:18 -0400 Subject: [PATCH 1623/2214] [esp32] Add flash_mode and flash_frequency config options (#16920) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 24 +++++++++++++++++ .../esp32/config/flash_mode_default.yaml | 7 +++++ .../esp32/config/flash_mode_idf.yaml | 9 +++++++ tests/component_tests/esp32/test_esp32.py | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 tests/component_tests/esp32/config/flash_mode_default.yaml create mode 100644 tests/component_tests/esp32/config/flash_mode_idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7e7b127814..d703e22e46 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1615,8 +1615,14 @@ FLASH_SIZES = [ ] CONF_FLASH_SIZE = "flash_size" +CONF_FLASH_MODE = "flash_mode" +CONF_FLASH_FREQUENCY = "flash_frequency" CONF_CPU_FREQUENCY = "cpu_frequency" CONF_PARTITIONS = "partitions" +FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"] +FLASH_FREQUENCIES = [ + f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16) +] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -1630,6 +1636,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of( *FLASH_SIZES, upper=True ), + cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True), + cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( + *FLASH_FREQUENCIES, upper=True + ), cv.Optional(CONF_PARTITIONS): cv.Any( cv.file_, cv.ensure_list( @@ -1866,6 +1876,12 @@ async def to_code(config): "board_upload.maximum_size", int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, ) + if flash_mode := config.get(CONF_FLASH_MODE): + cg.add_platformio_option("board_build.flash_mode", flash_mode) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + cg.add_platformio_option( + "board_build.f_flash", f"{flash_frequency[:-3]}000000L" + ) if CONF_SOURCE in conf: cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) @@ -2016,6 +2032,14 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) + if flash_mode := config.get(CONF_FLASH_MODE): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True + ) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True + ) # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 # from y to n. PlatformIO uses sections.ld.in (for rev <3) or diff --git a/tests/component_tests/esp32/config/flash_mode_default.yaml b/tests/component_tests/esp32/config/flash_mode_default.yaml new file mode 100644 index 0000000000..0d05142099 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_default.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml new file mode 100644 index 0000000000..7c7f50a439 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + flash_mode: qio + flash_frequency: 80MHz + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e9fa9446d4..a8b5720a80 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -285,3 +285,29 @@ def test_native_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_flash_mode_sets_sdkconfig_and_pio_option( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode/flash_frequency select the esptool flash parameters on both backends.""" + generate_main(component_config_path("flash_mode_idf.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert CORE.platformio_options.get("board_build.flash_mode") == "qio" + assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" + + +def test_flash_mode_unset_leaves_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_mode the board/sdkconfig defaults stay untouched.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig) + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) + assert "board_build.flash_mode" not in CORE.platformio_options + assert "board_build.f_flash" not in CORE.platformio_options From a46aa594b33b11c252b7eb38002e92568e1f3aa4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:04:46 +1200 Subject: [PATCH 1624/2214] Bump version to 2026.6.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 647d25559a..809f934797 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0b1 +PROJECT_NUMBER = 2026.6.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 9a951c1527..27abfa2dd2 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0b1" +__version__ = "2026.6.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From f1fd5f2f4957849602f7903c7d70dc57e119671e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:10:58 +1000 Subject: [PATCH 1625/2214] [epaper_spi] Metadata, bug fixes, new model (#16950) --- esphome/components/epaper_spi/display.py | 22 +- esphome/components/epaper_spi/epaper_spi.cpp | 4 + esphome/components/epaper_spi/epaper_spi.h | 2 + .../components/epaper_spi/models/ssd1677.py | 17 +- tests/component_tests/conftest.py | 38 ++++ .../epaper_spi/config/enable_pin_test.yaml | 24 +++ .../epaper_spi/test_display_metadata.py | 156 ++++++++++++++ tests/component_tests/epaper_spi/test_init.py | 190 ++++++++++++++---- tests/component_tests/mipi_spi/conftest.py | 39 +--- 9 files changed, 412 insertions(+), 80 deletions(-) create mode 100644 tests/component_tests/epaper_spi/config/enable_pin_test.yaml create mode 100644 tests/component_tests/epaper_spi/test_display_metadata.py diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index b7c56a283a..ce28fb0d67 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -13,6 +13,7 @@ from esphome.components.mipi import ( import esphome.config_validation as cv from esphome.config_validation import update_interval from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BUSY_PIN, CONF_CS_PIN, CONF_DATA_RATE, @@ -129,7 +130,23 @@ def customise_schema(config): }, extra=cv.ALLOW_EXTRA, )(config) - return model_schema(config)(config) + model = MODELS[config[CONF_MODEL]] + config = model_schema(config)(config) + width, height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_rotation=True, + byte_order=cv.UNDEFINED, + has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True + or config.get(CONF_PAGES) is not None + or config.get(CONF_LAMBDA) is not None + or config.get(CONF_SHOW_TEST_CARD) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=0, + ) + return config CONFIG_SCHEMA = customise_schema @@ -197,6 +214,9 @@ async def to_code(config): if busy_pin := config.get(CONF_BUSY_PIN): busy = await cg.gpio_pin_expression(busy_pin) cg.add(var.set_busy_pin(busy)) + if enable_pin := config.get(CONF_ENABLE_PIN): + enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] + cg.add(var.set_enable_pins(enable)) cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) if CONF_RESET_DURATION in config: cg.add(var.set_reset_duration(config[CONF_RESET_DURATION])) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index a2ca311b30..3214f932bf 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -38,6 +38,10 @@ bool EPaperBase::init_buffer_(size_t buffer_length) { } void EPaperBase::setup_pins_() const { + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } this->dc_pin_->setup(); // OUTPUT this->dc_pin_->digital_write(false); diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 2992ca5afd..8e2fd78e62 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -50,6 +50,7 @@ class EPaperBase : public Display, float get_setup_priority() const override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; } + void set_enable_pins(std::vector enable_pins) { this->enable_pins_ = std::move(enable_pins); } void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; } void set_transform(uint8_t transform) { this->transform_ = transform; @@ -177,6 +178,7 @@ class EPaperBase : public Display, GPIOPin *dc_pin_{}; GPIOPin *busy_pin_{}; GPIOPin *reset_pin_{}; + std::vector enable_pins_{}; bool waiting_for_idle_{}; uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index bad33a6a02..13f1035045 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -10,11 +10,11 @@ class SSD1677(EpaperModel): # fmt: off def get_init_sequence(self, config: dict): - width, _height = self.get_dimensions(config) + _width, height = self.get_dimensions(config) return ( (0x18, 0x80), # Select internal Temp sensor (0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2 - (0x01, (width - 1) % 256, (width - 1) // 256, 0x02), # Set column gate limit + (0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1) (0x3C, 0x01), # Set border waveform (0x11, 3), # Set transform ) @@ -51,3 +51,16 @@ ssd1677.extend( height=480, mirror_x=True, ) + +ssd1677.extend( + "seeed-reterminal-sticky", + width=800, + height=480, + mirror_x=True, + enable_pin=47, + cs_pin=15, + dc_pin=16, + reset_pin=17, + busy_pin=18, + data_rate="10MHz", +) diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 763628f57c..3730978ec3 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -104,6 +104,44 @@ def set_component_config() -> Callable[[str, Any], None]: return setter +@pytest.fixture +def choose_variant_with_pins() -> Generator[Callable[[list], None]]: + """Set the ESP32 variant to the first one on which all the given pins are valid. + + For ESP32 only, since the other platforms do not have variants. The core + configuration must already have been set up for an ESP32 target. + Using local imports to avoid importing when ESP32 is not the target. + """ + from esphome import config_validation as cv + from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS + from esphome.components.esp32.gpio import validate_gpio_pin + from esphome.const import CONF_INPUT, CONF_OUTPUT + from esphome.pins import gpio_pin_schema + + def chooser(pins: list) -> None: + for variant in VARIANTS: + try: + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + for pin in pins: + if pin is not None: + pin = gpio_pin_schema( + { + CONF_INPUT: True, + CONF_OUTPUT: True, + }, + internal=True, + )(pin) + validate_gpio_pin(pin) + return + except cv.Invalid: + continue + raise cv.Invalid( + f"No compatible variant found for pins: {', '.join(map(str, pins))}" + ) + + yield chooser + + @pytest.fixture def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]: """Return a function to get absolute paths relative to the component's fixtures directory.""" diff --git a/tests/component_tests/epaper_spi/config/enable_pin_test.yaml b/tests/component_tests/epaper_spi/config/enable_pin_test.yaml new file mode 100644 index 0000000000..d238cd1d9e --- /dev/null +++ b/tests/component_tests/epaper_spi/config/enable_pin_test.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + enable_pin: + - GPIO25 + - GPIO26 + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_display_metadata.py b/tests/component_tests/epaper_spi/test_display_metadata.py new file mode 100644 index 0000000000..95afefcf35 --- /dev/null +++ b/tests/component_tests/epaper_spi/test_display_metadata.py @@ -0,0 +1,156 @@ +"""Tests for display metadata created by the epaper_spi component.""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from esphome import config_validation as cv +from esphome.components.display import get_all_display_metadata, get_display_metadata +from esphome.components.epaper_spi.display import CONFIG_SCHEMA +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _base_config(**overrides: Any) -> ConfigType: + """Build a minimal valid ssd1677 config, allowing field overrides.""" + config: ConfigType = { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": {"width": 200, "height": 300}, + } + config.update(overrides) + return config + + +def test_metadata_dimensions_and_defaults( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Metadata picks up explicit dimensions and epaper_spi defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config()) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.width == 200 + assert meta.height == 300 + # epaper_spi always reports full hardware rotation + assert meta.has_hardware_rotation is True + # epaper_spi does not declare a byte order + assert meta.byte_order is cv.UNDEFINED + assert meta.draw_rounding == 0 + # no drawing methods configured -> no writer + assert meta.has_writer is False + + +def test_metadata_default_dimensions_from_model( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A model with built-in dimensions reports those without explicit dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + # waveshare-4.26in is an ssd1677 derivative with default 800x480 dimensions + config = CONFIG_SCHEMA( + { + "id": "wave_display", + "model": "waveshare-4.26in", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + } + ) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.width == 800 + assert meta.height == 480 + + +def test_metadata_has_writer_with_auto_clear( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A display with auto_clear_enabled reports has_writer=True.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config(auto_clear_enabled=True)) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.has_writer is True + + +def test_metadata_rotation_propagated( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The configured rotation is stored in the metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config(rotation=90)) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.rotation == 90 + + +def test_metadata_multiple_displays_independent( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Each display gets its own independent metadata entry.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + CONFIG_SCHEMA(_base_config(id="disp_a", dimensions={"width": 200, "height": 300})) + CONFIG_SCHEMA(_base_config(id="disp_b", dimensions={"width": 400, "height": 480})) + + all_meta = get_all_display_metadata() + assert all_meta["disp_a"].width == 200 + assert all_meta["disp_a"].height == 300 + assert all_meta["disp_b"].width == 400 + assert all_meta["disp_b"].height == 480 + + +def test_metadata_via_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Full code generation registers metadata for the configured display.""" + generate_main(component_config_path("enable_pin_test.yaml")) + + all_meta = get_all_display_metadata() + assert len(all_meta) == 1 + meta = next(iter(all_meta.values())) + # enable_pin_test.yaml: ssd1677 at 200x200 + assert meta.width == 200 + assert meta.height == 200 + assert meta.has_hardware_rotation is True diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index a9f5735fca..c7f34d7dd2 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -1,6 +1,8 @@ """Tests for epaper_spi configuration validation.""" from collections.abc import Callable +from pathlib import Path +import re from typing import Any import pytest @@ -11,17 +13,13 @@ from esphome.components.epaper_spi.display import ( FINAL_VALIDATE_SCHEMA, MODELS, ) -from esphome.components.esp32 import ( - KEY_BOARD, - KEY_VARIANT, - VARIANT_ESP32, - VARIANT_ESP32S3, -) +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, CONF_DC_PIN, CONF_DIMENSIONS, + CONF_ENABLE_PIN, CONF_HEIGHT, CONF_INIT_SEQUENCE, CONF_RESET_PIN, @@ -31,6 +29,30 @@ from esphome.const import ( from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable +# Pin options whose values must be valid on the chosen ESP32 variant. +_PIN_CONF_KEYS = ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_RESET_PIN, + CONF_BUSY_PIN, + CONF_ENABLE_PIN, +) + + +def _pins_for(model: Any, config: ConfigType) -> list: + """Collect every GPIO the config will actually use (model defaults or injected).""" + pins: list = [] + for key in _PIN_CONF_KEYS: + # An injected value in the config takes precedence over the model default. + value = config[key] if key in config else model.get_default(key) + if not value: # get_default returns False for pins the model omits + continue + if isinstance(value, list): + pins.extend(value) + else: + pins.append(value) + return pins + def run_schema_validation( config: ConfigType, with_final_validate: bool = False @@ -90,29 +112,20 @@ def test_basic_configuration_errors( def test_all_predefined_models( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test all predefined epaper models validate successfully with appropriate defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + # Test all models, providing default values where necessary for name, model in MODELS.items(): - # SEEED models are designed for ESP32-S3 hardware - if name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={ - KEY_BOARD: "esp32-s3-devkitc-1", - KEY_VARIANT: VARIANT_ESP32S3, - }, - ) - else: - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) - - # Configure SPI component which is required by epaper_spi - set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) - config = {"model": name} # Add ID field @@ -141,6 +154,10 @@ def test_all_predefined_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Select an ESP32 variant on which all of this model's pins are valid + # (some models default to high-numbered pins only present on the S3). + choose_variant_with_pins(_pins_for(model, config)) + run_schema_validation(config) @@ -152,27 +169,19 @@ def test_individual_models( model_name: str, set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test each epaper model individually to ensure it validates correctly.""" - # SEEED models are designed for ESP32-S3 hardware - if model_name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={ - KEY_BOARD: "esp32-s3-devkitc-1", - KEY_VARIANT: VARIANT_ESP32S3, - }, - ) - else: - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) + model = MODELS[model_name] + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) # Configure SPI component which is required by epaper_spi set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) - model = MODELS[model_name] config: dict[str, Any] = {"model": model_name, "id": "test_display"} # Add required fields based on model defaults @@ -195,6 +204,10 @@ def test_individual_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Select an ESP32 variant on which all of this model's pins are valid + # (some models default to high-numbered pins only present on the S3). + choose_variant_with_pins(_pins_for(model, config)) + # This should not raise any exceptions run_schema_validation(config) @@ -342,3 +355,102 @@ def test_busy_pin_input_mode_ssd1677( reset_pin_config = result[CONF_RESET_PIN] assert "mode" in reset_pin_config assert reset_pin_config["mode"]["output"] is True + + +def test_enable_pin_single( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test that a single enable_pin is accepted and normalised to a list of output pins.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + result = run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "enable_pin": 25, + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + # A single pin is normalised to a list by cv.ensure_list + assert CONF_ENABLE_PIN in result + enable_pins = result[CONF_ENABLE_PIN] + assert isinstance(enable_pins, list) + assert len(enable_pins) == 1 + # enable pins are configured as outputs + assert enable_pins[0]["mode"]["output"] is True + + +def test_enable_pin_multiple( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test that a list of enable_pins is accepted.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + result = run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "enable_pin": [25, 26], + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + assert CONF_ENABLE_PIN in result + enable_pins = result[CONF_ENABLE_PIN] + assert isinstance(enable_pins, list) + assert len(enable_pins) == 2 + assert all(pin["mode"]["output"] is True for pin in enable_pins) + + +def test_enable_pin_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that enable_pins are wired up in the generated C++ code.""" + main_cpp = generate_main(component_config_path("enable_pin_test.yaml")) + + # Derive the auto-generated pin variable names from the set_pin() lines + # rather than hard-coding them, so the test does not break when unrelated + # codegen details shift the generated IDs. + def pin_var_for(gpio_num: int) -> str: + match = re.search(rf"(\w+)->set_pin\(::GPIO_NUM_{gpio_num}\);", main_cpp) + assert match is not None, ( + f"GPIO_NUM_{gpio_num} pin not set up in generated code" + ) + return match.group(1) + + pin_25 = pin_var_for(25) + pin_26 = pin_var_for(26) + + # Both pin objects must be passed to the display via set_enable_pins() as a + # std::vector initializer list, in the configured order. + assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp diff --git a/tests/component_tests/mipi_spi/conftest.py b/tests/component_tests/mipi_spi/conftest.py index 082a9e55f2..ed48056f63 100644 --- a/tests/component_tests/mipi_spi/conftest.py +++ b/tests/component_tests/mipi_spi/conftest.py @@ -1,16 +1,10 @@ """Tests for mpip_spi configuration validation.""" -from collections.abc import Callable, Generator from unittest import mock import pytest -from esphome import config_validation as cv -from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS -from esphome.components.esp32.gpio import validate_gpio_pin -from esphome.const import CONF_INPUT, CONF_OUTPUT -from esphome.core import CORE -from esphome.pins import gpio_pin_schema +# choose_variant_with_pins is provided by the shared parent conftest. @pytest.fixture(autouse=True) @@ -21,34 +15,3 @@ def mock_spi_final_validate(): return_value=lambda config: None, ): yield - - -@pytest.fixture -def choose_variant_with_pins() -> Generator[Callable[[list], None]]: - """ - Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms - do not have variants. - """ - - def chooser(pins: list) -> None: - for variant in VARIANTS: - try: - CORE.data[KEY_ESP32][KEY_VARIANT] = variant - for pin in pins: - if pin is not None: - pin = gpio_pin_schema( - { - CONF_INPUT: True, - CONF_OUTPUT: True, - }, - internal=True, - )(pin) - validate_gpio_pin(pin) - return - except cv.Invalid: - continue - raise cv.Invalid( - f"No compatible variant found for pins: {', '.join(map(str, pins))}" - ) - - yield chooser From c1a7a8ff55e2384e89a9958c0bec3e6b69ba31c3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:01:44 +1200 Subject: [PATCH 1626/2214] Add PEP 572 walrus operator preference to coding conventions (#16951) --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4adc53cae9..4346ffbdae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,19 @@ This document provides essential context for AI models interacting with this pro - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations +* **Python Idioms:** + * **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead: + ```python + # Bad - looks up CONF_BLAH twice + if CONF_BLAH in config: + cg.add(var.set_blah(config[CONF_BLAH])) + + # Good - single lookup, value bound inline + if (blah := config.get(CONF_BLAH)) is not None: + cg.add(var.set_blah(blah)) + ``` + The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line. + * **C++ Field Visibility:** * **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`. * **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants: From 1ee49720c7fe4a112b8b7604a13cdf2044fae965 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:55:21 +1200 Subject: [PATCH 1627/2214] [psram] Make schema extractable with per-variant options (#16949) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 29 +++++++++++ esphome/components/psram/__init__.py | 52 +++++++++++++------ script/build_language_schema.py | 9 ++++ tests/component_tests/psram/test_psram.py | 48 +++++++++++++++++ .../psram/validate-quad.esp32-s3-idf.yaml | 5 ++ .../components/psram/validate.esp32-idf.yaml | 4 ++ .../psram/validate.esp32-p4-idf.yaml | 4 ++ tests/script/test_build_language_schema.py | 22 ++++++++ 8 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 tests/components/psram/validate-quad.esp32-s3-idf.yaml create mode 100644 tests/components/psram/validate.esp32-idf.yaml create mode 100644 tests/components/psram/validate.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d703e22e46..5d4b3b8b47 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable, Iterable import contextlib from dataclasses import dataclass import itertools @@ -6,6 +7,7 @@ import os from pathlib import Path import re import subprocess +from typing import Any from esphome import yaml_util import esphome.codegen as cg @@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache @@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT] +def variant_filtered_enum( + by_variant: dict[str, Iterable[Any]], **kwargs: Any +) -> Callable[[Any], Any]: + """Build a ``one_of`` validator whose valid set depends on the active variant. + + ``by_variant`` maps each ESP32 variant constant to the iterable of values that + are valid on that variant. At validation time the value is checked against the + set allowed for the current target variant. For schema extraction the inverted + ``{value: [variants, ...]}`` map is returned instead, so the language-schema + dump can tag every option with the variants that accept it and frontends can + filter to the user's selected variant. + """ + by_value: dict[str, list[str]] = {} + for variant, values in by_variant.items(): + for value in values: + by_value.setdefault(str(value), []).append(variant) + + @schema_extractor("variant_enum") + def validator(value: Any) -> Any: + if value is SCHEMA_EXTRACT: + return by_value + return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value) + + return validator + + def get_board(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD] diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index d36d900997..296ea6c08c 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, get_esp32_variant, idf_version, + variant_filtered_enum, ) import esphome.config_validation as cv from esphome.const import ( @@ -29,6 +30,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "psram" @@ -70,6 +72,11 @@ SPIRAM_SPEEDS = { VARIANT_ESP32P4: (20, 100, 200), } +SPIRAM_SPEEDS_MHZ = { + variant: tuple(f"{speed}MHZ" for speed in speeds) + for variant, speeds in SPIRAM_SPEEDS.items() +} + def supported() -> bool: if not CORE.is_esp32: @@ -145,15 +152,23 @@ def validate_psram_mode(config): return config -def get_config_schema(config): +def _set_variant_defaults(config: ConfigType) -> ConfigType: + """Resolve variant-dependent defaults before the static schema validates. + + The set of valid ``mode``/``speed`` values is variant-specific (enforced by + ``variant_filtered_enum`` in the schema below); this only supplies the default + when the user omits the option. ``mode`` has no single default on chips that + support more than one mode, so selection is required there. + """ variant = get_esp32_variant() - speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] - if not speeds: + modes = SPIRAM_MODES.get(variant) + speeds = SPIRAM_SPEEDS.get(variant) + if not modes or not speeds: raise cv.Invalid("PSRAM is not supported on this chip") - modes = SPIRAM_MODES[variant] - if CONF_MODE not in config and len(modes) != 1: - raise ( - cv.Invalid( + config = config.copy() + if CONF_MODE not in config: + if len(modes) != 1: + raise cv.Invalid( textwrap.dedent( f""" {variant} requires PSRAM mode selection; one of {", ".join(modes)} @@ -161,20 +176,27 @@ def get_config_schema(config): """ ) ) - ) - return cv.Schema( + config[CONF_MODE] = modes[0] + if CONF_SPEED not in config: + config[CONF_SPEED] = f"{speeds[0]}MHZ" + return config + + +CONFIG_SCHEMA = cv.All( + _set_variant_defaults, + cv.Schema( { cv.GenerateID(): cv.declare_id(PsramComponent), - cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True), + cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True), cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean, - cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True), + cv.Optional(CONF_SPEED): variant_filtered_enum( + SPIRAM_SPEEDS_MHZ, upper=True + ), cv.Optional(CONF_DISABLED, default=False): cv.boolean, cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean, } - )(config) - - -CONFIG_SCHEMA = get_config_schema + ), +) def _store_psram_guaranteed(config): diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 61845c4b25..974957245a 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -951,6 +951,15 @@ def convert(schema, config_var, path): elif schema_type == "enum": config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) + elif schema_type == "variant_enum": + # Per-variant enum (e.g. psram mode/speed): each value carries the + # list of variants that accept it so clients can filter to the + # user's selected variant. Additive to the plain enum format — + # consumers that ignore the metadata still see every option. + config_var[S_TYPE] = "enum" + config_var["values"] = { + value: {"variants": variants} for value, variants in data.items() + } elif schema_type == "maybe": # maybe_simple_value: either a scalar shorthand (mapped to the key in # data[1]) or the full wrapped schema. The wrapped schema is usually a diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 0924e66adc..ea4adc69a9 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants( FINAL_VALIDATE_SCHEMA(config) +def test_psram_applies_single_mode_default( + set_core_config: SetCoreConfigCallable, +) -> None: + """On a single-mode variant the omitted mode/speed fall back to defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + config = CONFIG_SCHEMA({}) + assert config["mode"] == "quad" + assert config["speed"] == "40MHZ" + assert config["disabled"] is False + assert config["ignore_not_found"] is True + + +def test_psram_requires_mode_on_multi_mode_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A variant with multiple modes requires an explicit mode selection.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"): + CONFIG_SCHEMA({}) + + +def test_psram_rejects_mode_invalid_for_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A mode not supported by the active variant is rejected by the schema.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"): + CONFIG_SCHEMA({"mode": "octal"}) + + def _setup_psram_final_validation_test( esp32_config: dict, set_core_config: SetCoreConfigCallable, diff --git a/tests/components/psram/validate-quad.esp32-s3-idf.yaml b/tests/components/psram/validate-quad.esp32-s3-idf.yaml new file mode 100644 index 0000000000..3fa6360d14 --- /dev/null +++ b/tests/components/psram/validate-quad.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses +# octal; this exercises the other branch of the per-variant mode enum (quad) and +# lets speed fall back to its 40MHz default. +psram: + mode: quad diff --git a/tests/components/psram/validate.esp32-idf.yaml b/tests/components/psram/validate.esp32-idf.yaml new file mode 100644 index 0000000000..9c04284163 --- /dev/null +++ b/tests/components/psram/validate.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: with no options the single-mode ESP32 resolves mode -> quad and +# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here, +# so this only runs through `esphome config`. +psram: diff --git a/tests/components/psram/validate.esp32-p4-idf.yaml b/tests/components/psram/validate.esp32-p4-idf.yaml new file mode 100644 index 0000000000..3e5899061f --- /dev/null +++ b/tests/components/psram/validate.esp32-p4-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz). +# With no options it resolves mode -> hex and speed -> 20MHz, exercising the +# P4-specific default branch of the per-variant enums. +psram: diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index badd4686f6..8bbaa2773a 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -139,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None: assert "foo" in config_var["schema"]["config_vars"] +def test_convert_emits_variant_enum() -> None: + """A per-variant enum is dumped with each value tagged by its variants.""" + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32S3, + variant_filtered_enum, + ) + + validator = variant_filtered_enum( + {VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")}, + lower=True, + ) + config_var: dict = {} + _bls.convert(validator, config_var, "/test") + + assert config_var["type"] == "enum" + assert config_var["values"] == { + "quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]}, + "octal": {"variants": [VARIANT_ESP32S3]}, + } + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") From 963465a0a6977db8693057b7609de64ffde613a6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:25:54 +1000 Subject: [PATCH 1628/2214] [mipi_dsi] Add SWRESET command to M5Stack Tab5-V2 init sequence (#16975) --- esphome/components/mipi_dsi/models/m5stack.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 2298f76cd4..53fac9b534 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -71,6 +71,7 @@ DriverChip( swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ + (0x01,), (0x60, 0x71, 0x23, 0xa2), (0x60, 0x71, 0x23, 0xa3), (0x60, 0x71, 0x23, 0xa4), From 3420cff31647983904e4bd6289aed972fcd8142f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Jun 2026 15:46:33 -0500 Subject: [PATCH 1629/2214] [core] Attribute "took a long time" blocking warning to the owning script (#16768) --- .../components/runtime_stats/runtime_stats.h | 2 +- esphome/components/script/script.h | 19 ++- esphome/core/application.h | 95 +++++++++++++- esphome/core/base_automation.h | 9 +- esphome/core/component.cpp | 30 +++-- esphome/core/component.h | 60 +-------- esphome/core/millis_internal.h | 4 +- esphome/core/scheduler.cpp | 33 +++-- esphome/core/scheduler.h | 39 ++++-- .../fixtures/scheduler_blocking_warning.yaml | 22 ++++ ...duler_blocking_warning_generic_source.yaml | 30 +++++ ...eduler_delay_runs_on_failed_component.yaml | 29 +++++ .../test_scheduler_blocking_warning.py | 120 ++++++++++++++++++ 13 files changed, 389 insertions(+), 103 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_blocking_warning.yaml create mode 100644 tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml create mode 100644 tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml create mode 100644 tests/integration/test_scheduler_blocking_warning.py diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 888d48e672..1e4910453a 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -47,7 +47,7 @@ class RuntimeStatsCollector { // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, - // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // LoopBlockingGuard construction/destruction, feed_wdt_with_time calls, // the for-loop itself). void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { this->period_active_count_++; diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 847fab02bd..6cd33e566c 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -3,6 +3,7 @@ #include #include #include +#include "esphome/core/application.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -57,6 +58,14 @@ template class Script : public ScriptLogger, public Triggerexecute(std::get(tuple)...); } + // Run the action chain with this script's name published as the current source (RAII save/restore, + // so nesting composes), so deferred work inside the script is attributed to it in blocking + // warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame). + inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE { + ScopedSourceGuard source_guard{this->name_}; + this->trigger(x...); + } + const LogString *name_{nullptr}; }; @@ -74,7 +83,7 @@ template class SingleScript : public Script { return; } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -91,7 +100,7 @@ template class RestartScript : public Script { this->stop_action(); } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -136,7 +145,7 @@ template class QueueingScript : public Script, public Com return; } - this->trigger(x...); + this->run_actions_(x...); // Check if the trigger was immediate and we can continue right away. this->loop(); } @@ -175,7 +184,7 @@ template class QueueingScript : public Script, public Com } template void trigger_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->trigger(std::get(tuple)...); + this->run_actions_(std::get(tuple)...); } int num_queued_ = 0; // Number of queued instances (not including currently running) @@ -197,7 +206,7 @@ template class ParallelScript : public Script { LOG_STR_ARG(this->name_)); return; } - this->trigger(x...); + this->run_actions_(x...); } void set_max_runs(int max_runs) { max_runs_ = max_runs; } diff --git a/esphome/core/application.h b/esphome/core/application.h index 369c970d46..7c12a66b2c 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -104,9 +104,13 @@ class Application { void register_area(Area *area) { this->areas_.push_back(area); } #endif - void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } + // Owning script of the action chain currently executing (nullptr when none); used to attribute + // blocking warnings for deferred work to the script that scheduled it. + void set_current_source(const LogString *source) { this->current_source_ = source; } + const LogString *get_current_source() { return this->current_source_; } + // Entity register methods (generated from entity_types.h). // Each entity type gets two overloads: // - register_(obj) — bare push_back @@ -393,6 +397,7 @@ class Application { protected: friend Component; friend class Scheduler; + friend class LoopBlockingGuard; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -402,6 +407,14 @@ class Application { /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + // Publish the running unit's identity (component + source) and dispatch time together, so a + // dispatch site can't set one without the others. Friend-only (Scheduler). + void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) { + this->current_component_ = component; + this->current_source_ = source; + this->set_loop_component_start_time_(now); + } + /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// (which is a friend) to decide whether to clear the corresponding bit on @@ -482,6 +495,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; + const LogString *current_source_{nullptr}; // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components @@ -554,6 +568,76 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the +/// previous value on exit, attributing deferred work scheduled inside to that source. +class ScopedSourceGuard { + public: + explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) { + App.set_current_source(source); + } + ~ScopedSourceGuard() { App.set_current_source(this->prev_); } + ScopedSourceGuard(const ScopedSourceGuard &) = delete; + ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete; + + private: + const LogString *prev_; +}; + +// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the +// main loop too long. The constructor publishes the unit's identity + dispatch time to App; +// finish()/the cold warning path read them back, so the guard stores no copy. +// +// Guards must not nest: the constructor publishes to App but never restores on destruction, so a +// nested guard would clobber the outer's context. Safe because the two dispatch sites (component +// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback. +class LoopBlockingGuard { + public: + // Publish the unit's identity + dispatch time, then start timing. The millis start lives in App, + // so only the runtime-stats micros stamp is kept here. + LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) { + App.set_current_execution_context_(component, source, now); +#ifdef USE_RUNTIME_STATS + this->started_us_ = micros(); +#endif + } + + // Finish the timing operation and return the current time (millis) + // Inlined: the fast path is just millis() + subtract + compare + inline uint32_t HOT finish() { +#ifdef USE_RUNTIME_STATS + uint32_t elapsed_us = micros() - this->started_us_; + // Delays have no component; accumulate into the global counter so loop() can subtract them. + Component *component = App.get_current_component(); + if (component != nullptr) { + component->runtime_stats_.record_time(elapsed_us); + } else { + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } +#endif + uint32_t curr_time = MillisInternal::get(); +#ifndef USE_BENCHMARK + // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) + static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; + uint32_t blocking_time = curr_time - App.get_loop_component_start_time(); + if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { + warn_blocking(blocking_time); + } +#endif + return curr_time; + } + + ~LoopBlockingGuard() = default; + +#ifdef USE_RUNTIME_STATS + protected: + uint32_t started_us_; +#endif + + private: + // Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit. + static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // before/tail splits recorded below. uint32_t loop_active_start_us = micros(); // Snapshot the cumulative component-recorded time so we can subtract the - // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // slice that the scheduler spends inside its own LoopBlockingGuard // (scheduler.cpp) — that time is already counted in per-component stats, // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; @@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->current_loop_index_++) { Component *component = this->looping_components_[this->current_loop_index_]; - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + // Guard publishes this component (no script source) + dispatch time, then times loop(). + LoopBlockingGuard guard{component, nullptr, last_op_end_time}; component->loop(); // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index dcad7c9d2e..cf8b05a300 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,10 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // Record the owning script (if any) so the blocking warning can name it; propagates across + // chained delays via the scheduler. + /* source= */ App.get_current_source()); } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay @@ -212,7 +215,9 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // See the no-argument branch above: record the owning script for log attribution. + /* source= */ App.get_current_source()); } } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2d80301897..7ef5ff50a5 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -258,9 +258,11 @@ void Component::call() { break; } } -bool Component::should_warn_of_blocking(uint32_t blocking_time) { +bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) { // Convert centisecond threshold to milliseconds for comparison uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; + // Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate. + threshold_ms_out = threshold_ms; if (blocking_time > threshold_ms) { // Set new threshold: blocking_time + increment, converted back to centiseconds uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; @@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif -void __attribute__((noinline, cold)) -WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { - bool should_warn; +void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) { + // Identity is published on App by the caller before the guard is built; read it back here. + Component *component = App.get_current_component(); + // Component-less path always warns (the caller already checked the constant threshold). + uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS; + if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) { + return; // Component's (possibly ratcheted) threshold not exceeded yet + } + // Component name if any, else the published source (owning script), else a generic label. + const LogString *name; if (component != nullptr) { - should_warn = component->should_warn_of_blocking(blocking_time); + name = component->get_component_log_str(); } else { - should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller - } - if (should_warn) { - ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms", - component == nullptr ? LOG_STR_LITERAL("") : LOG_STR_ARG(component->get_component_log_str()), - blocking_time); + name = App.get_current_source(); + if (name == nullptr) + name = LOG_STR("a scheduled task"); } + ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name), + blocking_time, threshold_ms); } #ifdef USE_SETUP_PRIORITY_OVERRIDE diff --git a/esphome/core/component.h b/esphome/core/component.h index ff10f1a8f1..299a5f72ea 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -118,7 +118,7 @@ struct ComponentRuntimeStats { // Cumulative sum of every record_time() duration since boot, across all // components. Used by Application::loop() to snapshot time spent inside - // WarnIfComponentBlockingGuard (including guards constructed by the + // LoopBlockingGuard (including guards constructed by the // scheduler at scheduler.cpp) so main-loop overhead accounting can // subtract scheduled-callback time from the before_loop_tasks_ wall time. static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -326,7 +326,7 @@ class Component { return component_source_lookup(this->component_source_index_); } - bool should_warn_of_blocking(uint32_t blocking_time); + bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out); protected: friend class Application; @@ -571,7 +571,7 @@ class Component { volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; ComponentRuntimeStats runtime_stats_; #endif }; @@ -619,59 +619,7 @@ class PollingComponent : public Component { uint32_t update_interval_; }; -// millis() and micros() are available via hal.h - -class WarnIfComponentBlockingGuard { - public: - WarnIfComponentBlockingGuard(Component *component, uint32_t start_time) - : started_(start_time), - component_(component) -#ifdef USE_RUNTIME_STATS - , - started_us_(micros()) -#endif - { - } - - // Finish the timing operation and return the current time (millis) - // Inlined: the fast path is just millis() + subtract + compare - inline uint32_t HOT finish() { -#ifdef USE_RUNTIME_STATS - uint32_t elapsed_us = micros() - this->started_us_; - // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) - if (this->component_ != nullptr) { - this->component_->runtime_stats_.record_time(elapsed_us); - } else { - // Still accumulate into the global counter so Application::loop() can subtract - // this time from before_loop_tasks_ wall time. - ComponentRuntimeStats::global_recorded_us += elapsed_us; - } -#endif - uint32_t curr_time = MillisInternal::get(); -#ifndef USE_BENCHMARK - // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) - static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; - uint32_t blocking_time = curr_time - this->started_; - if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { - warn_blocking(this->component_, blocking_time); - } -#endif - return curr_time; - } - - ~WarnIfComponentBlockingGuard() = default; - - protected: - uint32_t started_; - Component *component_; -#ifdef USE_RUNTIME_STATS - uint32_t started_us_; -#endif - - private: - // Cold path for blocking warning - defined in component.cpp - static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time); -}; +// LoopBlockingGuard lives in application.h because it reads its state from App. // Function to clear setup priority overrides after all components are set up // Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index bc1d55a1c4..7297d22357 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -16,7 +16,7 @@ namespace esphome { // Friend-gated accessor for a fast millis() variant intended only for // known task-context callers on the main loop hot path (Application::loop() -// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// and LoopBlockingGuard::finish()). It skips the ISR-context // dispatch that the public esphome::millis() pays on ESP32 and libretiny. // // MUST NOT be called from ISR context: on ESP32 and libretiny it calls the @@ -50,7 +50,7 @@ class MillisInternal { #endif } friend class Application; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; }; } // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a7c624486d..15bb9ea239 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel) { + std::function &&func, bool is_retry, bool skip_cancel, + const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { @@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); - item->component = component; + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } item->set_name(name_type, static_name, hash_or_id); item->type = type; // Use destroy + placement-new instead of move-assignment. @@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Not reached timeout yet, done for this call break; } - // Don't run on failed components - if (item->component != nullptr && item->component->is_failed()) { + // Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays). + if (this->is_item_failed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; @@ -790,10 +796,21 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { - App.set_current_component(item->component); - // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. - App.set_loop_component_start_time_(now); - WarnIfComponentBlockingGuard guard{item->component, now}; + // Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared + // union slot with a single name-type check. Self-keyed items have no owning component; their slot + // holds the source name (e.g. the owning script), published so deferred work chained inside the + // callback re-captures it and the blocking warning can name the script instead of "". + Component *component; + const LogString *source; + if (item->get_name_type() == NameType::SELF_POINTER) { + component = nullptr; + source = item->source_name; + } else { + component = item->component; + source = nullptr; + } + // Guard publishes the item's identity + dispatch time, then times the callback. + LoopBlockingGuard guard{component, source, now}; item->callback(); uint32_t end = guard.finish(); // Feed the watchdog after each scheduled item (both main heap and defer diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b640aa86fe..378c0fb94b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -183,11 +183,12 @@ class Scheduler { protected: struct SchedulerItem { - // Ordered by size to minimize padding. - // `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive). + // Ordered by size to minimize padding. Mutually exclusive by state; read the component via + // get_component() so SELF_POINTER items read as component-less. union { - Component *component; - SchedulerItem *next_free; + Component *component; // live, non-SELF_POINTER: owning component + const LogString *source_name; // live SELF_POINTER: owning script name (log attribution) + SchedulerItem *next_free; // while pooled }; // Optimized name storage using tagged union - zero heap allocation union { @@ -302,14 +303,23 @@ class Scheduler { next_execution_high_ = static_cast(value >> 32); } constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } - const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); } + // The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead). + // All component access goes through this so SELF_POINTER items read as component-less. + Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; } + const LogString *get_source() const { + // Same no-source label as warn_blocking, for consistent log vocabulary. + if (name_type_ == NameType::SELF_POINTER) + return source_name != nullptr ? source_name : LOG_STR("a scheduled task"); + return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown"); + } }; // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id + // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false); + bool skip_cancel = false, const LogString *source = nullptr); // Common implementation for retry - Remove before 2026.8.0 // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id @@ -402,8 +412,10 @@ class Scheduler { // Fixes: https://github.com/esphome/esphome/issues/11940 if (item == nullptr) return false; - if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) || - (match_retry && !item->is_retry)) { + // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they + // match by the `this` key alone. + if (item->get_component() != component || item->type != type || + (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -423,11 +435,16 @@ class Scheduler { // Helper to execute a scheduler item uint32_t execute_item_(SchedulerItem *item, uint32_t now); - // Helper to check if item should be skipped - bool should_skip_item_(SchedulerItem *item) const { - return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); + // True if the item's component is failed (so it must not run). SELF_POINTER delays have no + // component (get_component() == nullptr) and always fire. + bool is_item_failed_(SchedulerItem *item) const { + Component *component = item->get_component(); + return component != nullptr && component->is_failed(); } + // Helper to check if item should be skipped + bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); } + // Helper to recycle a SchedulerItem back to the pool. // Takes a raw pointer — caller transfers ownership. The item is either added to the // pool or deleted if the pool is full. diff --git a/tests/integration/fixtures/scheduler_blocking_warning.yaml b/tests/integration/fixtures/scheduler_blocking_warning.yaml new file mode 100644 index 0000000000..594ec46afb --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning.yaml @@ -0,0 +1,22 @@ +esphome: + name: scheduler-blocking-warning + on_boot: + then: + - script.execute: blocking_script + +host: +api: +logger: + level: DEBUG + +# The busy-block runs in the second delay's continuation; the warning must name the script. Two +# delays verify the source survives chained delays (the scheduler republishes it each continuation). +script: + - id: blocking_script + then: + - delay: 10ms + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml new file mode 100644 index 0000000000..2d8a62f25b --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml @@ -0,0 +1,30 @@ +esphome: + name: scheduler-blocking-generic + +host: +api: +logger: + level: DEBUG + +globals: + - id: done + type: bool + restore_value: false + initial_value: "false" + +# A delay in a plain (non-script) automation has no owning script, so the block must log the +# generic "a scheduled task" label, not a script name. +interval: + - interval: 100ms + id: gen_interval + then: + - if: + condition: + lambda: "return !id(done);" + then: + - lambda: "id(done) = true;" + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml new file mode 100644 index 0000000000..860fa00c37 --- /dev/null +++ b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml @@ -0,0 +1,29 @@ +esphome: + name: scheduler-delay-failed + +host: +api: +logger: + level: DEBUG + +globals: + - id: started + type: bool + restore_value: false + initial_value: "false" + +# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed +# component must not drop it, since the SELF_POINTER scheduler item has no owning component. +interval: + - interval: 100ms + id: host_interval + then: + - if: + condition: + lambda: "return !id(started);" + then: + - lambda: |- + id(started) = true; + id(host_interval)->mark_failed(); + - delay: 200ms + - logger.log: "DELAY_FIRED_AFTER_FAIL" diff --git a/tests/integration/test_scheduler_blocking_warning.py b/tests/integration/test_scheduler_blocking_warning.py new file mode 100644 index 0000000000..699a5bc746 --- /dev/null +++ b/tests/integration/test_scheduler_blocking_warning.py @@ -0,0 +1,120 @@ +"""Integration tests for blocking-warning source attribution. + +A blocking operation that runs inside a deferred scheduler continuation (e.g. after a ``delay`` +in a script) used to be reported as `` took a long time for an operation (NN ms), +max is 30 ms`` because the continuation carries no component. The warning should instead name +the owning script and report the real threshold (50 ms). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Matches: " took a long time for an operation (NN ms), max is NN ms" +WARN_PATTERN = re.compile( + r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" +) + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Deferred blocking work inside a script is attributed to the script, not "".""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + + # on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the + # continuation, tripping the blocking warning. + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + # Must name the owning script, not "" and not the generic fallback. + assert "" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + assert "a scheduled task" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + match = WARN_PATTERN.search(warning_line) + assert match is not None + assert match.group(1) == "blocking_script", ( + f"Warning should name 'blocking_script', got: {warning_line}" + ) + # The reported threshold must be the real default (50 ms), not the stale "30 ms". + assert match.group(3) == "50", f"Expected 'max is 50 ms', got: {warning_line}" + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning_generic_source( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay in a plain (non-script) automation logs the generic label, not a script name.""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + assert "a scheduled task took a long time" in warning_line, ( + f"Non-script deferred work should log the generic label, got: {warning_line}" + ) + assert "" not in warning_line + match = WARN_PATTERN.search(warning_line) + assert match is not None and match.group(3) == "50", ( + f"Expected 'max is 50 ms', got: {warning_line}" + ) + + +@pytest.mark.asyncio +async def test_scheduler_delay_runs_on_failed_component( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay must still fire even when its context component is marked failed. + + Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's + failed-component skip must not drop them. + """ + loop = asyncio.get_running_loop() + fired: asyncio.Future[bool] = loop.create_future() + + def check_output(line: str) -> None: + if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done(): + fired.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + # If the failed host component wrongly dropped the delay, this times out. + await asyncio.wait_for(fired, timeout=10.0) From 7a2657cea19b5ce831b52a2682f6bae5fb62bfd7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 15 Jun 2026 16:48:07 -0400 Subject: [PATCH 1630/2214] [audio] Bump microMP3 to v0.2.3 (#16977) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7497cc3679..7a3cfc7a03 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c +34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2ddce577ef..2aceff0c97 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.1") + add_idf_component(name="esphome/micro-mp3", ref="0.2.3") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c97e8906a8..04220488cc 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.1 + version: 0.2.3 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From a7a407c22c255f0cb4e3bb5014415e4268a60327 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:05:50 -0400 Subject: [PATCH 1631/2214] [openthread] Fix InstanceLock releasing the lock twice on try_acquire (#16980) --- esphome/components/openthread/openthread.cpp | 2 +- esphome/components/openthread/openthread.h | 23 +++++++++++++++---- .../components/openthread/openthread_esp.cpp | 17 +++++++------- .../openthread_info_text_sensor.h | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index bf14514636..c8ffc02131 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,7 +227,7 @@ bool OpenThreadComponent::teardown() { ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory"); return true; } - otInstance *instance = lock->get_instance(); + otInstance *instance = lock.get_instance(); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); global_openthread_component = nullptr; diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 5898492a50..96f1abdb92 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -86,19 +86,32 @@ class OpenThreadSrpComponent : public Component { void *pool_alloc_(size_t size); }; +// RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the +// guard may or may not own the lock (try_acquire can fail), so check it with +// operator bool before use. Non-copyable and non-movable: the factories return +// by value via guaranteed copy elision, so a guard is never duplicated and the +// lock is released exactly once, when the owning guard goes out of scope. class InstanceLock { public: - static std::optional try_acquire(int delay); + // May fail to acquire within delay ms; check the returned guard with operator bool. + static InstanceLock try_acquire(int delay); + // Blocks until the lock is held. static InstanceLock acquire(); + InstanceLock(const InstanceLock &) = delete; + InstanceLock(InstanceLock &&) = delete; + InstanceLock &operator=(const InstanceLock &) = delete; + InstanceLock &operator=(InstanceLock &&) = delete; ~InstanceLock(); - // Returns the global openthread instance guarded by this lock + explicit operator bool() const { return this->owns_; } + + // Returns the global openthread instance. Only valid on an owning guard + // (operator bool is true); the instance must not be used without the lock held. otInstance *get_instance(); private: - // Use a private constructor in order to force the handling - // of acquisition failure - InstanceLock() {} + explicit InstanceLock(bool owns) : owns_(owns) {} + bool owns_; }; } // namespace esphome::openthread diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index cf1288d90c..4d88cbd226 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -216,14 +216,11 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } -std::optional InstanceLock::try_acquire(int delay) { +InstanceLock InstanceLock::try_acquire(int delay) { if (!global_openthread_component->is_lock_initialized()) { - return {}; + return InstanceLock(false); } - if (esp_openthread_lock_acquire(delay)) { - return InstanceLock(); - } - return {}; + return InstanceLock(esp_openthread_lock_acquire(delay)); } InstanceLock InstanceLock::acquire() { @@ -242,12 +239,16 @@ InstanceLock InstanceLock::acquire() { while (!esp_openthread_lock_acquire(100)) { esp_task_wdt_reset(); } - return InstanceLock(); + return InstanceLock(true); } otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); } -InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } +InstanceLock::~InstanceLock() { + if (this->owns_) { + esp_openthread_lock_release(); + } +} } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index 10e83281f0..ef7c5cc8e9 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -17,7 +17,7 @@ class OpenThreadInstancePollingComponent : public PollingComponent { return; } - this->update_instance(lock->get_instance()); + this->update_instance(lock.get_instance()); } float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } From 73f839437ea4450b786ca6d767def371f7bdc015 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:12:53 +1200 Subject: [PATCH 1632/2214] [docker] Remove alpine base, build only on debian (#16991) --- .github/actions/build-image/action.yaml | 7 ------- docker/Dockerfile | 15 +++++---------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 2081264b91..494c0cebe8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -15,11 +15,6 @@ inputs: description: "Version to build" required: true example: "2023.12.0" - base_os: - description: "Base OS to use" - required: false - default: "debian" - example: "debian" runs: using: "composite" steps: @@ -60,7 +55,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true @@ -86,7 +80,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true diff --git a/docker/Dockerfile b/docker/Dockerfile index 25de9472b6..c360ae1a4a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,9 @@ ARG BUILD_VERSION=dev -ARG BUILD_OS=alpine ARG BUILD_BASE_VERSION=2025.04.0 ARG BUILD_TYPE=docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon +FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker +FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon ARG BUILD_TYPE FROM base-source-${BUILD_TYPE} AS base @@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \ # validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without # it idf_tools.py rejects the openocd install with exit 127 and aborts # the whole framework setup. -RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base libusb; \ - else \ - apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ - && rm -rf /var/lib/apt/lists/*; \ - fi +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 From bb6cd97948206d38469eba878e53a806292afaa0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:15:12 -0400 Subject: [PATCH 1633/2214] Bump clang-tidy from 22.1.0.1 to 22.1.7 (#16984) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- requirements_dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7a3cfc7a03..1f709bb90d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 +007cddcd7aa933f0ff9b3fd65f0b7571579ac223d11c6117af2b291bd2f9fe74 diff --git a/requirements_dev.txt b/requirements_dev.txt index 31463e07c3..7e66c7244d 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating -clang-tidy==22.1.0.1 +clang-tidy==22.1.7 yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating From b09a5f9e43efd49abed4d7a2845758d2f37fd257 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:37:31 +1200 Subject: [PATCH 1634/2214] [ci] Push branch-tagged docker images to ghcr.io for local testing (#16992) --- .github/workflows/ci-docker.yml | 84 ++++++++++++++- docker/build.py | 55 +++++++--- tests/script/test_docker_build.py | 169 ++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 tests/script/test_docker_build.py diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 2a40675f3b..7d4b850356 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -22,7 +22,7 @@ on: - "script/platformio_install_deps.py" permissions: - contents: read # actions/checkout only; the build does not push images + contents: read # actions/checkout only concurrency: # yamllint disable-line rule:line-length @@ -33,6 +33,9 @@ jobs: check-docker: name: Build docker containers runs-on: ${{ matrix.os }} + permissions: + contents: read # actions/checkout to load Dockerfile and build context + packages: write # push branch-tagged images to ghcr.io for local testing strategy: fail-fast: false matrix: @@ -41,6 +44,9 @@ jobs: - "ha-addon" - "docker" # - "lint" + outputs: + tag: ${{ steps.tag.outputs.tag }} + push: ${{ steps.tag.outputs.push }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python @@ -50,14 +56,82 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - name: Set TAG + - name: Determine tag and whether to push + id: tag run: | - echo "TAG=check" >> $GITHUB_ENV + # Sanitize the branch name into a valid docker tag: replace invalid + # characters, ensure the first character is valid (tags must start + # with [A-Za-z0-9_]), and cap the length at 128 characters. + branch="${{ github.head_ref || github.ref_name }}" + tag="${branch//[^a-zA-Z0-9_.-]/-}" + case "$tag" in + [a-zA-Z0-9_]*) ;; + *) tag="pr-${tag}" ;; + esac + tag="${tag:0:128}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + # Only push branch images for same-repo pull requests. Push events + # only fire for dev/beta/release, whose images are owned by the + # release pipeline -- never overwrite those from here. + if [ "${{ github.event_name }}" = "pull_request" ] \ + && [ "${{ github.repository }}" = "esphome/esphome" ] \ + && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then + echo "push=true" >> "$GITHUB_OUTPUT" + else + echo "push=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the GitHub container registry + if: steps.tag.outputs.push == 'true' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Run build run: | docker/build.py \ - --tag "${TAG}" \ + --tag "${{ steps.tag.outputs.tag }}" \ --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ - build + --registry ghcr \ + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + + manifest: + name: Push ${{ matrix.build_type }} manifest to ghcr.io + needs: [check-docker] + if: needs.check-docker.outputs.push == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to run docker/build.py + packages: write # buildx imagetools writes the multi-arch tag to ghcr.io + strategy: + fail-fast: false + matrix: + build_type: + - "ha-addon" + - "docker" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to the GitHub container registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + docker/build.py \ + --tag "${{ needs.check-docker.outputs.tag }}" \ + --build-type "${{ matrix.build_type }}" \ + --registry ghcr \ + manifest diff --git a/docker/build.py b/docker/build.py index 4d093cf88d..475986e905 100755 --- a/docker/build.py +++ b/docker/build.py @@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon" TYPE_LINT = "lint" TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] +REGISTRY_GHCR = "ghcr" +REGISTRY_DOCKERHUB = "dockerhub" +REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB] + parser = argparse.ArgumentParser() parser.add_argument( @@ -34,6 +38,12 @@ parser.add_argument( parser.add_argument( "--build-type", choices=TYPES, required=True, help="The type of build to run" ) +parser.add_argument( + "--registry", + choices=REGISTRIES, + action="append", + help="Restrict to specific registries (default: all). May be passed multiple times.", +) parser.add_argument( "--dry-run", action="store_true", help="Don't run any commands, just print them" ) @@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t build_parser.add_argument( "--load", help="Load the docker image locally", action="store_true" ) +build_parser.add_argument( + "--no-cache-to", + help="Don't write the build cache (avoids polluting the shared cache)", + action="store_true", +) manifest_parser = subparsers.add_parser( "manifest", help="Create a manifest from already pushed images" ) @@ -95,11 +110,14 @@ def main(): print("Command failed") sys.exit(1) + registries = args.registry or REGISTRIES + # detect channel from tag match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) major_minor_version = None if match is None: - channel = CHANNEL_DEV + # Custom tag (e.g. a branch name) -- push only the tag itself + channel = None elif match.group(2) is None: major_minor_version = match.group(1) channel = CHANNEL_RELEASE @@ -128,11 +146,18 @@ def main(): CHANNEL_DEV: "cache-dev", CHANNEL_BETA: "cache-beta", CHANNEL_RELEASE: "cache-latest", - }[channel] - cache_img = f"ghcr.io/{params.build_to}:{cache_tag}" + }.get(channel, "cache-dev") + # Cache images live alongside the pushed images; prefer GHCR when it is + # one of the selected registries, otherwise fall back to Docker Hub so a + # registry-restricted build doesn't need GHCR auth. + cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else "" + cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}" - imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push] - imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] + imgs = [] + if REGISTRY_DOCKERHUB in registries: + imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] # 3. build cmd = [ @@ -155,7 +180,9 @@ def main(): for img in imgs: cmd += ["--tag", img] if args.push: - cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"] + cmd += ["--push"] + if not args.no_cache_to: + cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"] if args.load: cmd += ["--load"] @@ -163,20 +190,22 @@ def main(): elif args.command == "manifest": manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to - targets = [f"{manifest}:{tag}" for tag in tags_to_push] - targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] - # 1. Create manifests + targets = [] + if REGISTRY_DOCKERHUB in registries: + targets += [f"{manifest}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] + # Use buildx imagetools (not `docker manifest`) so the per-arch sources, + # which buildx pushes as single-platform manifest lists, are combined + # and pushed correctly in one step. for target in targets: - cmd = ["docker", "manifest", "create", target] + cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] for arch in ARCHS: src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" if target.startswith("ghcr.io"): src = f"ghcr.io/{src}" cmd.append(src) run_command(*cmd) - # 2. Push manifests - for target in targets: - run_command("docker", "manifest", "push", target) if __name__ == "__main__": diff --git a/tests/script/test_docker_build.py b/tests/script/test_docker_build.py new file mode 100644 index 0000000000..34bcc4e714 --- /dev/null +++ b/tests/script/test_docker_build.py @@ -0,0 +1,169 @@ +"""Unit tests for docker/build.py command generation.""" + +import importlib.util +from pathlib import Path +import sys + +import pytest + +_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py" +_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY) +docker_build = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(docker_build) + + +def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]: + """Run build.py main() in dry-run mode and return the emitted commands.""" + full_argv = ["build.py", "--dry-run", *argv] + with pytest.MonkeyPatch.context() as mp: + mp.setattr(sys, "argv", full_argv) + docker_build.main() + out = capsys.readouterr().out + return [line[2:] for line in out.splitlines() if line.startswith("$ ")] + + +def test_branch_build_pushes_single_ghcr_tag_without_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + "--push", + "--no-cache-to", + ) + + assert len(commands) == 1 + cmd = commands[0] + # Custom tag -> only the tag itself, no companion "dev"/"latest" tags + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert ":dev" not in cmd + # ghcr only -> no Docker Hub image name + assert "--tag esphome/esphome-amd64:my-branch" not in cmd + # custom tag falls back to the dev cache for reads + assert ( + "--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd + ) + assert "--push" in cmd + # --no-cache-to must suppress the cache write + assert "--cache-to" not in cmd + + +def test_branch_manifest_targets_ghcr_only( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "ha-addon", + "--registry", + "ghcr", + "manifest", + ) + + assert commands == [ + "docker buildx imagetools create " + "--tag ghcr.io/esphome/esphome-hassio:my-branch " + "ghcr.io/esphome/esphome-hassio-amd64:my-branch " + "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ] + + +def test_release_build_keeps_both_registries_and_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "2025.6.0", + "--arch", + "amd64", + "--build-type", + "docker", + "build", + "--push", + ) + + cmd = commands[0] + # Default (no --registry) keeps both Docker Hub and ghcr image names + assert "--tag esphome/esphome-amd64:2025.6.0" in cmd + assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd + # Release channel still gets its companion tags + assert "--tag esphome/esphome-amd64:latest" in cmd + # Without --no-cache-to the cache write is preserved + assert ( + "--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max" + in cmd + ) + + +def test_build_no_push_omits_push_and_cache( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + ) + + cmd = commands[0] + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert "--push" not in cmd + assert "--cache-to" not in cmd + + +def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "dockerhub", + "build", + "--push", + ) + + cmd = commands[0] + assert "--tag esphome/esphome-amd64:my-branch" in cmd + assert "ghcr.io" not in cmd + # Cache reference falls back to Docker Hub when GHCR isn't selected + assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd + + +def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "docker", + "--registry", + "dockerhub", + "manifest", + ) + + create = commands[0] + assert create.startswith( + "docker buildx imagetools create --tag esphome/esphome:my-branch " + ) + assert "ghcr.io" not in create From d8fa0e414093cc8625ec6f4ce538bd4352b8d56f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:24:26 +1200 Subject: [PATCH 1635/2214] [core] Stop parent git repos from breaking ESP-IDF/PlatformIO builds (#16994) --- esphome/espidf/toolchain.py | 6 +++++ esphome/helpers.py | 21 +++++++++++++++ esphome/platformio/toolchain.py | 5 ++++ tests/unit_tests/test_espidf_toolchain.py | 14 ++++++++++ tests/unit_tests/test_helpers.py | 27 +++++++++++++++++++ tests/unit_tests/test_platformio_toolchain.py | 5 ++++ 6 files changed, 78 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 2fef3faf8d..c622a2dd36 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -14,6 +14,7 @@ from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary +from esphome.helpers import add_git_ceiling_directory _LOGGER = logging.getLogger(__name__) @@ -82,6 +83,11 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] |= get_framework_env( *_get_esphome_esp_idf_paths(version) ) + + # Cap git's repo search at the config directory so ESP-IDF's + # `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(env_cache[version], CORE.config_dir) return env_cache[version] diff --git a/esphome/helpers.py b/esphome/helpers.py index 733474c9c9..ef7e2d0b93 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import MutableMapping from contextlib import suppress import ipaddress import logging @@ -374,6 +375,26 @@ def is_ha_addon(): return get_bool_env("ESPHOME_IS_HA_ADDON") +def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) -> None: + """Add ``directory`` to ``env``'s ``GIT_CEILING_DIRECTORIES`` list. + + Git stops walking up the directory tree to find a repository once it reaches + a ceiling directory, so this caps the search at ``directory`` (the ESPHome + project root). Without it, an uninitialized or corrupt git repo in a parent + directory makes the ``git describe`` that build toolchains run for the app + version error out and fail the whole build. + + ``GIT_CEILING_DIRECTORIES`` is an ``os.pathsep``-joined list of absolute + paths; any existing entries are preserved and duplicates are skipped. + """ + ceiling = str(directory) + existing = env.get("GIT_CEILING_DIRECTORIES", "") + parts = existing.split(os.pathsep) if existing else [] + if ceiling not in parts: + parts.append(ceiling) + env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts) + + def rmtree(path: Path | str) -> None: """Remove a directory tree, handling read-only files on Windows. diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index c81420e6ca..c97df812e3 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -7,6 +7,7 @@ import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError +from esphome.helpers import add_git_ceiling_directory from esphome.util import FlashImage, run_external_process _LOGGER = logging.getLogger(__name__) @@ -53,6 +54,10 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") + # Cap git's repo search at the config directory so the framework's build + # scripts running `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(os.environ, CORE.config_dir) # Strip the Windows extended-length path prefix from sys.executable so it # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted # command lines run through cmd.exe. diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 8849ea8bc8..b2309439f9 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -150,6 +150,20 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: assert result == {"cxx_path": "regen"} +def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: + """The IDF env caps git's upward search at the config directory. + + This stops ESP-IDF's `git describe` from walking into an uninitialized or + corrupt git repo in a parent directory and failing the build. + """ + toolchain._cache().env.clear() + # Set IDF_PATH so the framework-install branch is skipped. + with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}): + env = toolchain._get_idf_env(version="5.5.4") + assert CORE.config_dir == setup_core + assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) + + def test_get_core_framework_version_from_core_data(): """The version is read from CORE.data when validation populated it.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index efc2d8e42a..70c4b90082 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -196,6 +196,33 @@ def test_is_ha_addon(monkeypatch, value, expected): assert actual == expected +def test_add_git_ceiling_directory_sets_when_unset(): + """An empty env gets GIT_CEILING_DIRECTORIES set to the directory.""" + env: dict[str, str] = {} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + +def test_add_git_ceiling_directory_appends_to_existing(): + """An existing value is preserved and the new directory is appended.""" + env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [ + str(Path("/some/ceiling")), + str(directory), + ] + + +def test_add_git_ceiling_directory_skips_duplicate(): + """A directory already in the list is not appended again.""" + directory = Path("/home/user/config") + env = {"GIT_CEILING_DIRECTORIES": str(directory)} + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + def test_walk_files(fixture_path): path = fixture_path / "helpers" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index a37b19f584..568b43a259 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -304,6 +304,11 @@ def test_run_platformio_cli_sets_environment_variables( ) assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ + # Caps git's upward search at the config dir so an uninitialized or + # corrupt parent git repo can't break the framework's `git describe`. + assert str(CORE.config_dir) in os.environ["GIT_CEILING_DIRECTORIES"].split( + os.pathsep + ) # Check command was called correctly — runs PlatformIO as a subprocess # via the esphome.platformio.runner entry point. From 930cf2b5b94dcf8143aa4a5afd236b72ee1cc668 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:47:14 +1200 Subject: [PATCH 1636/2214] [docker] Bundle device-builder 1.0.1, make HA add-on builder-only (#16989) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 5 +- docker/docker_entrypoint.sh | 8 ++ .../etc/cont-init.d/40-device-builder.sh | 22 ----- .../etc/nginx/includes/mime.types | 96 ------------------- .../etc/nginx/includes/proxy_params.conf | 16 ---- .../etc/nginx/includes/server_params.conf | 8 -- .../etc/nginx/includes/ssl_params.conf | 8 -- .../etc/nginx/includes/upstream.conf | 3 - docker/ha-addon-rootfs/etc/nginx/nginx.conf | 30 ------ .../etc/nginx/servers/.gitkeep | 1 - .../etc/nginx/templates/direct.gtpl | 28 ------ .../etc/nginx/templates/ingress.gtpl | 18 ---- .../s6-rc.d/discovery/dependencies.d/nginx | 0 .../etc/s6-overlay/s6-rc.d/discovery/run | 2 +- .../etc/s6-overlay/s6-rc.d/esphome/finish | 4 +- .../etc/s6-overlay/s6-rc.d/esphome/run | 15 +-- .../s6-rc.d/init-nginx/dependencies.d/base | 0 .../etc/s6-overlay/s6-rc.d/init-nginx/run | 35 ------- .../etc/s6-overlay/s6-rc.d/init-nginx/type | 1 - .../etc/s6-overlay/s6-rc.d/init-nginx/up | 1 - .../s6-rc.d/nginx/dependencies.d/esphome | 0 .../s6-rc.d/nginx/dependencies.d/init-nginx | 0 .../etc/s6-overlay/s6-rc.d/nginx/finish | 25 ----- .../etc/s6-overlay/s6-rc.d/nginx/run | 27 ------ .../etc/s6-overlay/s6-rc.d/nginx/type | 1 - .../s6-rc.d/user/contents.d/init-nginx | 0 .../s6-overlay/s6-rc.d/user/contents.d/nginx | 0 27 files changed, 20 insertions(+), 334 deletions(-) delete mode 100755 docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/mime.types delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/nginx.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/dependencies.d/base delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/up delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/esphome delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-nginx delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/finish delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx diff --git a/docker/Dockerfile b/docker/Dockerfile index c360ae1a4a..c7634cf1c8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.0 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -31,6 +31,9 @@ RUN \ uv pip install --no-cache-dir \ -r /requirements.txt +# Install the ESPHome Device Builder dashboard. +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 + RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 1b9224244c..18baf40c29 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -27,4 +27,12 @@ if [[ -d /build ]]; then export ESPHOME_BUILD_PATH=/build fi +# The default CMD is "dashboard /config". Route the dashboard to the new +# Device Builder, but pass every other subcommand (compile, run, config, +# logs, ...) straight through to the esphome CLI so direct CLI use keeps working. +if [[ "$1" == "dashboard" ]]; then + shift + exec esphome-device-builder "$@" +fi + exec esphome "$@" diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh deleted file mode 100755 index b990469762..0000000000 --- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/with-contenv bashio -# ============================================================================== -# Installs the latest prerelease of esphome-device-builder when the -# `use_new_device_builder` config option is enabled. -# This is a temporary install-on-boot step until esphome-device-builder -# becomes a direct dependency of esphome. -# ============================================================================== - -if ! bashio::config.true 'use_new_device_builder'; then - exit 0 -fi - -bashio::log.info "Installing latest prerelease of esphome-device-builder..." -if command -v uv > /dev/null; then - uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ - esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -else - pip install --no-cache-dir --pre --upgrade esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -fi -bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types deleted file mode 100644 index 7c7cdef2d1..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types +++ /dev/null @@ -1,96 +0,0 @@ -types { - text/html html htm shtml; - text/css css; - text/xml xml; - image/gif gif; - image/jpeg jpeg jpg; - application/javascript js; - application/atom+xml atom; - application/rss+xml rss; - - text/mathml mml; - text/plain txt; - text/vnd.sun.j2me.app-descriptor jad; - text/vnd.wap.wml wml; - text/x-component htc; - - image/png png; - image/svg+xml svg svgz; - image/tiff tif tiff; - image/vnd.wap.wbmp wbmp; - image/webp webp; - image/x-icon ico; - image/x-jng jng; - image/x-ms-bmp bmp; - - font/woff woff; - font/woff2 woff2; - - application/java-archive jar war ear; - application/json json; - application/mac-binhex40 hqx; - application/msword doc; - application/pdf pdf; - application/postscript ps eps ai; - application/rtf rtf; - application/vnd.apple.mpegurl m3u8; - application/vnd.google-earth.kml+xml kml; - application/vnd.google-earth.kmz kmz; - application/vnd.ms-excel xls; - application/vnd.ms-fontobject eot; - application/vnd.ms-powerpoint ppt; - application/vnd.oasis.opendocument.graphics odg; - application/vnd.oasis.opendocument.presentation odp; - application/vnd.oasis.opendocument.spreadsheet ods; - application/vnd.oasis.opendocument.text odt; - application/vnd.openxmlformats-officedocument.presentationml.presentation - pptx; - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - xlsx; - application/vnd.openxmlformats-officedocument.wordprocessingml.document - docx; - application/vnd.wap.wmlc wmlc; - application/x-7z-compressed 7z; - application/x-cocoa cco; - application/x-java-archive-diff jardiff; - application/x-java-jnlp-file jnlp; - application/x-makeself run; - application/x-perl pl pm; - application/x-pilot prc pdb; - application/x-rar-compressed rar; - application/x-redhat-package-manager rpm; - application/x-sea sea; - application/x-shockwave-flash swf; - application/x-stuffit sit; - application/x-tcl tcl tk; - application/x-x509-ca-cert der pem crt; - application/x-xpinstall xpi; - application/xhtml+xml xhtml; - application/xspf+xml xspf; - application/zip zip; - - application/octet-stream bin exe dll; - application/octet-stream deb; - application/octet-stream dmg; - application/octet-stream iso img; - application/octet-stream msi msp msm; - - audio/midi mid midi kar; - audio/mpeg mp3; - audio/ogg ogg; - audio/x-m4a m4a; - audio/x-realaudio ra; - - video/3gpp 3gpp 3gp; - video/mp2t ts; - video/mp4 mp4; - video/mpeg mpeg mpg; - video/quicktime mov; - video/webm webm; - video/x-flv flv; - video/x-m4v m4v; - video/x-mng mng; - video/x-ms-asf asx asf; - video/x-ms-wmv wmv; - video/x-msvideo avi; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf deleted file mode 100644 index a1ebb5079a..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf +++ /dev/null @@ -1,16 +0,0 @@ -proxy_http_version 1.1; -proxy_ignore_client_abort off; -proxy_read_timeout 86400s; -proxy_redirect off; -proxy_send_timeout 86400s; -proxy_max_temp_file_size 0; - -proxy_set_header Accept-Encoding ""; -proxy_set_header Connection $connection_upgrade; -proxy_set_header Host $http_host; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-NginX-Proxy true; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header Authorization ""; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf deleted file mode 100644 index debdf83a8c..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -root /dev/null; -server_name $hostname; - -client_max_body_size 512m; - -add_header X-Content-Type-Options nosniff; -add_header X-XSS-Protection "1; mode=block"; -add_header X-Robots-Tag none; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf deleted file mode 100644 index e6789cbb9b..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -ssl_protocols TLSv1.2 TLSv1.3; -ssl_prefer_server_ciphers off; -ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; -ssl_session_timeout 10m; -ssl_session_cache shared:SSL:10m; -ssl_session_tickets off; -ssl_stapling on; -ssl_stapling_verify on; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf deleted file mode 100644 index 8e782bdc88..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf +++ /dev/null @@ -1,3 +0,0 @@ -upstream esphome { - server unix:/var/run/esphome.sock; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf deleted file mode 100644 index 497427596d..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -daemon off; -user root; -pid /var/run/nginx.pid; -worker_processes 1; -error_log /proc/1/fd/1 error; -events { - worker_connections 1024; -} - -http { - include /etc/nginx/includes/mime.types; - - access_log off; - default_type application/octet-stream; - gzip on; - keepalive_timeout 65; - sendfile on; - server_tokens off; - - tcp_nodelay on; - tcp_nopush on; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - include /etc/nginx/includes/upstream.conf; - include /etc/nginx/servers/*.conf; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep deleted file mode 100644 index 85ad51be5f..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley) diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl deleted file mode 100644 index 4fb0ca3f90..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl +++ /dev/null @@ -1,28 +0,0 @@ -server { - {{ if not .ssl }} - listen 6052 default_server; - {{ else }} - listen 6052 default_server ssl http2; - {{ end }} - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - {{ if .ssl }} - include /etc/nginx/includes/ssl_params.conf; - - ssl_certificate /ssl/{{ .certfile }}; - ssl_certificate_key /ssl/{{ .keyfile }}; - - # Redirect http requests to https on the same port. - # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/ - error_page 497 https://$http_host$request_uri; - {{ end }} - - # Clear Home Assistant Ingress header - proxy_set_header X-HA-Ingress ""; - - location / { - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl deleted file mode 100644 index 105ddde710..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 127.0.0.1:{{ .port }} default_server; - listen {{ .interface }}:{{ .port }} default_server; - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - # Set Home Assistant Ingress header - proxy_set_header X-HA-Ingress "YES"; - - location / { - allow 172.30.32.2; - allow 127.0.0.1; - deny all; - - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index 111157d301..bb36cfcdb4 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -16,7 +16,7 @@ fi port=$(bashio::addon.ingress_port) -# Wait for NGINX to become available +# Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 config=$(\ diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish index 6e0f8fe23a..da450c25f9 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish @@ -2,7 +2,7 @@ # shellcheck shell=bash # ============================================================================== # Home Assistant Community Add-on: ESPHome -# Take down the S6 supervision tree when ESPHome dashboard fails +# Take down the S6 supervision tree when ESPHome Device Builder fails # ============================================================================== declare exit_code readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode - fi - [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt -elif [[ "${exit_code_service}" -ne 0 ]]; then - if [[ "${exit_code_container}" -eq 0 ]]; then - echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode - fi - exec /run/s6/basedir/bin/halt -fi diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run deleted file mode 100755 index b8251e8e01..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ /dev/null @@ -1,27 +0,0 @@ -#!/command/with-contenv bashio -# shellcheck shell=bash -# ============================================================================== -# Community Hass.io Add-ons: ESPHome -# Runs the NGINX proxy -# ============================================================================== - -# The new device builder handles HA ingress itself, so nginx is bypassed. -# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on -# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish -# stamp the container exit 143, which trips the Supervisor's SIGTERM check. -if bashio::config.true 'use_new_device_builder'; then - bashio::log.info "NGINX bypassed: new device builder serves ingress directly." - trap 'exit 0' TERM - sleep infinity & - wait - exit 0 -fi - -bashio::log.info "Waiting for ESPHome dashboard to come up..." - -while [[ ! -S /var/run/esphome.sock ]]; do - sleep 0.5 -done - -bashio::log.info "Starting NGINX..." -exec nginx diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type deleted file mode 100644 index 5883cff0cd..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 From 32ab3abd7c0cb4cbca7bb43cb8e20cf637395f02 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:55:21 +1200 Subject: [PATCH 1637/2214] [psram] Make schema extractable with per-variant options (#16949) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 29 +++++++++++ esphome/components/psram/__init__.py | 52 +++++++++++++------ script/build_language_schema.py | 9 ++++ tests/component_tests/psram/test_psram.py | 48 +++++++++++++++++ .../psram/validate-quad.esp32-s3-idf.yaml | 5 ++ .../components/psram/validate.esp32-idf.yaml | 4 ++ .../psram/validate.esp32-p4-idf.yaml | 4 ++ tests/script/test_build_language_schema.py | 22 ++++++++ 8 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 tests/components/psram/validate-quad.esp32-s3-idf.yaml create mode 100644 tests/components/psram/validate.esp32-idf.yaml create mode 100644 tests/components/psram/validate.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d703e22e46..5d4b3b8b47 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable, Iterable import contextlib from dataclasses import dataclass import itertools @@ -6,6 +7,7 @@ import os from pathlib import Path import re import subprocess +from typing import Any from esphome import yaml_util import esphome.codegen as cg @@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache @@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT] +def variant_filtered_enum( + by_variant: dict[str, Iterable[Any]], **kwargs: Any +) -> Callable[[Any], Any]: + """Build a ``one_of`` validator whose valid set depends on the active variant. + + ``by_variant`` maps each ESP32 variant constant to the iterable of values that + are valid on that variant. At validation time the value is checked against the + set allowed for the current target variant. For schema extraction the inverted + ``{value: [variants, ...]}`` map is returned instead, so the language-schema + dump can tag every option with the variants that accept it and frontends can + filter to the user's selected variant. + """ + by_value: dict[str, list[str]] = {} + for variant, values in by_variant.items(): + for value in values: + by_value.setdefault(str(value), []).append(variant) + + @schema_extractor("variant_enum") + def validator(value: Any) -> Any: + if value is SCHEMA_EXTRACT: + return by_value + return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value) + + return validator + + def get_board(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD] diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index d36d900997..296ea6c08c 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, get_esp32_variant, idf_version, + variant_filtered_enum, ) import esphome.config_validation as cv from esphome.const import ( @@ -29,6 +30,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "psram" @@ -70,6 +72,11 @@ SPIRAM_SPEEDS = { VARIANT_ESP32P4: (20, 100, 200), } +SPIRAM_SPEEDS_MHZ = { + variant: tuple(f"{speed}MHZ" for speed in speeds) + for variant, speeds in SPIRAM_SPEEDS.items() +} + def supported() -> bool: if not CORE.is_esp32: @@ -145,15 +152,23 @@ def validate_psram_mode(config): return config -def get_config_schema(config): +def _set_variant_defaults(config: ConfigType) -> ConfigType: + """Resolve variant-dependent defaults before the static schema validates. + + The set of valid ``mode``/``speed`` values is variant-specific (enforced by + ``variant_filtered_enum`` in the schema below); this only supplies the default + when the user omits the option. ``mode`` has no single default on chips that + support more than one mode, so selection is required there. + """ variant = get_esp32_variant() - speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] - if not speeds: + modes = SPIRAM_MODES.get(variant) + speeds = SPIRAM_SPEEDS.get(variant) + if not modes or not speeds: raise cv.Invalid("PSRAM is not supported on this chip") - modes = SPIRAM_MODES[variant] - if CONF_MODE not in config and len(modes) != 1: - raise ( - cv.Invalid( + config = config.copy() + if CONF_MODE not in config: + if len(modes) != 1: + raise cv.Invalid( textwrap.dedent( f""" {variant} requires PSRAM mode selection; one of {", ".join(modes)} @@ -161,20 +176,27 @@ def get_config_schema(config): """ ) ) - ) - return cv.Schema( + config[CONF_MODE] = modes[0] + if CONF_SPEED not in config: + config[CONF_SPEED] = f"{speeds[0]}MHZ" + return config + + +CONFIG_SCHEMA = cv.All( + _set_variant_defaults, + cv.Schema( { cv.GenerateID(): cv.declare_id(PsramComponent), - cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True), + cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True), cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean, - cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True), + cv.Optional(CONF_SPEED): variant_filtered_enum( + SPIRAM_SPEEDS_MHZ, upper=True + ), cv.Optional(CONF_DISABLED, default=False): cv.boolean, cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean, } - )(config) - - -CONFIG_SCHEMA = get_config_schema + ), +) def _store_psram_guaranteed(config): diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 61845c4b25..974957245a 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -951,6 +951,15 @@ def convert(schema, config_var, path): elif schema_type == "enum": config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) + elif schema_type == "variant_enum": + # Per-variant enum (e.g. psram mode/speed): each value carries the + # list of variants that accept it so clients can filter to the + # user's selected variant. Additive to the plain enum format — + # consumers that ignore the metadata still see every option. + config_var[S_TYPE] = "enum" + config_var["values"] = { + value: {"variants": variants} for value, variants in data.items() + } elif schema_type == "maybe": # maybe_simple_value: either a scalar shorthand (mapped to the key in # data[1]) or the full wrapped schema. The wrapped schema is usually a diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 0924e66adc..ea4adc69a9 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants( FINAL_VALIDATE_SCHEMA(config) +def test_psram_applies_single_mode_default( + set_core_config: SetCoreConfigCallable, +) -> None: + """On a single-mode variant the omitted mode/speed fall back to defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + config = CONFIG_SCHEMA({}) + assert config["mode"] == "quad" + assert config["speed"] == "40MHZ" + assert config["disabled"] is False + assert config["ignore_not_found"] is True + + +def test_psram_requires_mode_on_multi_mode_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A variant with multiple modes requires an explicit mode selection.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"): + CONFIG_SCHEMA({}) + + +def test_psram_rejects_mode_invalid_for_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A mode not supported by the active variant is rejected by the schema.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"): + CONFIG_SCHEMA({"mode": "octal"}) + + def _setup_psram_final_validation_test( esp32_config: dict, set_core_config: SetCoreConfigCallable, diff --git a/tests/components/psram/validate-quad.esp32-s3-idf.yaml b/tests/components/psram/validate-quad.esp32-s3-idf.yaml new file mode 100644 index 0000000000..3fa6360d14 --- /dev/null +++ b/tests/components/psram/validate-quad.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses +# octal; this exercises the other branch of the per-variant mode enum (quad) and +# lets speed fall back to its 40MHz default. +psram: + mode: quad diff --git a/tests/components/psram/validate.esp32-idf.yaml b/tests/components/psram/validate.esp32-idf.yaml new file mode 100644 index 0000000000..9c04284163 --- /dev/null +++ b/tests/components/psram/validate.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: with no options the single-mode ESP32 resolves mode -> quad and +# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here, +# so this only runs through `esphome config`. +psram: diff --git a/tests/components/psram/validate.esp32-p4-idf.yaml b/tests/components/psram/validate.esp32-p4-idf.yaml new file mode 100644 index 0000000000..3e5899061f --- /dev/null +++ b/tests/components/psram/validate.esp32-p4-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz). +# With no options it resolves mode -> hex and speed -> 20MHz, exercising the +# P4-specific default branch of the per-variant enums. +psram: diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index badd4686f6..8bbaa2773a 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -139,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None: assert "foo" in config_var["schema"]["config_vars"] +def test_convert_emits_variant_enum() -> None: + """A per-variant enum is dumped with each value tagged by its variants.""" + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32S3, + variant_filtered_enum, + ) + + validator = variant_filtered_enum( + {VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")}, + lower=True, + ) + config_var: dict = {} + _bls.convert(validator, config_var, "/test") + + assert config_var["type"] == "enum" + assert config_var["values"] == { + "quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]}, + "octal": {"variants": [VARIANT_ESP32S3]}, + } + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") From 33ace9d698a8ff8e6bed06a71b741b38bed2ecc7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:25:54 +1000 Subject: [PATCH 1638/2214] [mipi_dsi] Add SWRESET command to M5Stack Tab5-V2 init sequence (#16975) --- esphome/components/mipi_dsi/models/m5stack.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 2298f76cd4..53fac9b534 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -71,6 +71,7 @@ DriverChip( swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ + (0x01,), (0x60, 0x71, 0x23, 0xa2), (0x60, 0x71, 0x23, 0xa3), (0x60, 0x71, 0x23, 0xa4), From 9bf35ab8fbc69847a4f7b292784946cbc8e2e37b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Jun 2026 15:46:33 -0500 Subject: [PATCH 1639/2214] [core] Attribute "took a long time" blocking warning to the owning script (#16768) --- .../components/runtime_stats/runtime_stats.h | 2 +- esphome/components/script/script.h | 19 ++- esphome/core/application.h | 95 +++++++++++++- esphome/core/base_automation.h | 9 +- esphome/core/component.cpp | 30 +++-- esphome/core/component.h | 60 +-------- esphome/core/millis_internal.h | 4 +- esphome/core/scheduler.cpp | 33 +++-- esphome/core/scheduler.h | 39 ++++-- .../fixtures/scheduler_blocking_warning.yaml | 22 ++++ ...duler_blocking_warning_generic_source.yaml | 30 +++++ ...eduler_delay_runs_on_failed_component.yaml | 29 +++++ .../test_scheduler_blocking_warning.py | 120 ++++++++++++++++++ 13 files changed, 389 insertions(+), 103 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_blocking_warning.yaml create mode 100644 tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml create mode 100644 tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml create mode 100644 tests/integration/test_scheduler_blocking_warning.py diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 888d48e672..1e4910453a 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -47,7 +47,7 @@ class RuntimeStatsCollector { // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, - // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // LoopBlockingGuard construction/destruction, feed_wdt_with_time calls, // the for-loop itself). void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { this->period_active_count_++; diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 847fab02bd..6cd33e566c 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -3,6 +3,7 @@ #include #include #include +#include "esphome/core/application.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -57,6 +58,14 @@ template class Script : public ScriptLogger, public Triggerexecute(std::get(tuple)...); } + // Run the action chain with this script's name published as the current source (RAII save/restore, + // so nesting composes), so deferred work inside the script is attributed to it in blocking + // warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame). + inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE { + ScopedSourceGuard source_guard{this->name_}; + this->trigger(x...); + } + const LogString *name_{nullptr}; }; @@ -74,7 +83,7 @@ template class SingleScript : public Script { return; } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -91,7 +100,7 @@ template class RestartScript : public Script { this->stop_action(); } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -136,7 +145,7 @@ template class QueueingScript : public Script, public Com return; } - this->trigger(x...); + this->run_actions_(x...); // Check if the trigger was immediate and we can continue right away. this->loop(); } @@ -175,7 +184,7 @@ template class QueueingScript : public Script, public Com } template void trigger_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->trigger(std::get(tuple)...); + this->run_actions_(std::get(tuple)...); } int num_queued_ = 0; // Number of queued instances (not including currently running) @@ -197,7 +206,7 @@ template class ParallelScript : public Script { LOG_STR_ARG(this->name_)); return; } - this->trigger(x...); + this->run_actions_(x...); } void set_max_runs(int max_runs) { max_runs_ = max_runs; } diff --git a/esphome/core/application.h b/esphome/core/application.h index 369c970d46..7c12a66b2c 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -104,9 +104,13 @@ class Application { void register_area(Area *area) { this->areas_.push_back(area); } #endif - void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } + // Owning script of the action chain currently executing (nullptr when none); used to attribute + // blocking warnings for deferred work to the script that scheduled it. + void set_current_source(const LogString *source) { this->current_source_ = source; } + const LogString *get_current_source() { return this->current_source_; } + // Entity register methods (generated from entity_types.h). // Each entity type gets two overloads: // - register_(obj) — bare push_back @@ -393,6 +397,7 @@ class Application { protected: friend Component; friend class Scheduler; + friend class LoopBlockingGuard; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -402,6 +407,14 @@ class Application { /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + // Publish the running unit's identity (component + source) and dispatch time together, so a + // dispatch site can't set one without the others. Friend-only (Scheduler). + void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) { + this->current_component_ = component; + this->current_source_ = source; + this->set_loop_component_start_time_(now); + } + /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// (which is a friend) to decide whether to clear the corresponding bit on @@ -482,6 +495,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; + const LogString *current_source_{nullptr}; // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components @@ -554,6 +568,76 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the +/// previous value on exit, attributing deferred work scheduled inside to that source. +class ScopedSourceGuard { + public: + explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) { + App.set_current_source(source); + } + ~ScopedSourceGuard() { App.set_current_source(this->prev_); } + ScopedSourceGuard(const ScopedSourceGuard &) = delete; + ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete; + + private: + const LogString *prev_; +}; + +// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the +// main loop too long. The constructor publishes the unit's identity + dispatch time to App; +// finish()/the cold warning path read them back, so the guard stores no copy. +// +// Guards must not nest: the constructor publishes to App but never restores on destruction, so a +// nested guard would clobber the outer's context. Safe because the two dispatch sites (component +// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback. +class LoopBlockingGuard { + public: + // Publish the unit's identity + dispatch time, then start timing. The millis start lives in App, + // so only the runtime-stats micros stamp is kept here. + LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) { + App.set_current_execution_context_(component, source, now); +#ifdef USE_RUNTIME_STATS + this->started_us_ = micros(); +#endif + } + + // Finish the timing operation and return the current time (millis) + // Inlined: the fast path is just millis() + subtract + compare + inline uint32_t HOT finish() { +#ifdef USE_RUNTIME_STATS + uint32_t elapsed_us = micros() - this->started_us_; + // Delays have no component; accumulate into the global counter so loop() can subtract them. + Component *component = App.get_current_component(); + if (component != nullptr) { + component->runtime_stats_.record_time(elapsed_us); + } else { + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } +#endif + uint32_t curr_time = MillisInternal::get(); +#ifndef USE_BENCHMARK + // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) + static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; + uint32_t blocking_time = curr_time - App.get_loop_component_start_time(); + if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { + warn_blocking(blocking_time); + } +#endif + return curr_time; + } + + ~LoopBlockingGuard() = default; + +#ifdef USE_RUNTIME_STATS + protected: + uint32_t started_us_; +#endif + + private: + // Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit. + static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // before/tail splits recorded below. uint32_t loop_active_start_us = micros(); // Snapshot the cumulative component-recorded time so we can subtract the - // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // slice that the scheduler spends inside its own LoopBlockingGuard // (scheduler.cpp) — that time is already counted in per-component stats, // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; @@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->current_loop_index_++) { Component *component = this->looping_components_[this->current_loop_index_]; - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + // Guard publishes this component (no script source) + dispatch time, then times loop(). + LoopBlockingGuard guard{component, nullptr, last_op_end_time}; component->loop(); // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index dcad7c9d2e..cf8b05a300 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,10 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // Record the owning script (if any) so the blocking warning can name it; propagates across + // chained delays via the scheduler. + /* source= */ App.get_current_source()); } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay @@ -212,7 +215,9 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // See the no-argument branch above: record the owning script for log attribution. + /* source= */ App.get_current_source()); } } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2d80301897..7ef5ff50a5 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -258,9 +258,11 @@ void Component::call() { break; } } -bool Component::should_warn_of_blocking(uint32_t blocking_time) { +bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) { // Convert centisecond threshold to milliseconds for comparison uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; + // Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate. + threshold_ms_out = threshold_ms; if (blocking_time > threshold_ms) { // Set new threshold: blocking_time + increment, converted back to centiseconds uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; @@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif -void __attribute__((noinline, cold)) -WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { - bool should_warn; +void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) { + // Identity is published on App by the caller before the guard is built; read it back here. + Component *component = App.get_current_component(); + // Component-less path always warns (the caller already checked the constant threshold). + uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS; + if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) { + return; // Component's (possibly ratcheted) threshold not exceeded yet + } + // Component name if any, else the published source (owning script), else a generic label. + const LogString *name; if (component != nullptr) { - should_warn = component->should_warn_of_blocking(blocking_time); + name = component->get_component_log_str(); } else { - should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller - } - if (should_warn) { - ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms", - component == nullptr ? LOG_STR_LITERAL("") : LOG_STR_ARG(component->get_component_log_str()), - blocking_time); + name = App.get_current_source(); + if (name == nullptr) + name = LOG_STR("a scheduled task"); } + ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name), + blocking_time, threshold_ms); } #ifdef USE_SETUP_PRIORITY_OVERRIDE diff --git a/esphome/core/component.h b/esphome/core/component.h index ff10f1a8f1..299a5f72ea 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -118,7 +118,7 @@ struct ComponentRuntimeStats { // Cumulative sum of every record_time() duration since boot, across all // components. Used by Application::loop() to snapshot time spent inside - // WarnIfComponentBlockingGuard (including guards constructed by the + // LoopBlockingGuard (including guards constructed by the // scheduler at scheduler.cpp) so main-loop overhead accounting can // subtract scheduled-callback time from the before_loop_tasks_ wall time. static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -326,7 +326,7 @@ class Component { return component_source_lookup(this->component_source_index_); } - bool should_warn_of_blocking(uint32_t blocking_time); + bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out); protected: friend class Application; @@ -571,7 +571,7 @@ class Component { volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; ComponentRuntimeStats runtime_stats_; #endif }; @@ -619,59 +619,7 @@ class PollingComponent : public Component { uint32_t update_interval_; }; -// millis() and micros() are available via hal.h - -class WarnIfComponentBlockingGuard { - public: - WarnIfComponentBlockingGuard(Component *component, uint32_t start_time) - : started_(start_time), - component_(component) -#ifdef USE_RUNTIME_STATS - , - started_us_(micros()) -#endif - { - } - - // Finish the timing operation and return the current time (millis) - // Inlined: the fast path is just millis() + subtract + compare - inline uint32_t HOT finish() { -#ifdef USE_RUNTIME_STATS - uint32_t elapsed_us = micros() - this->started_us_; - // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) - if (this->component_ != nullptr) { - this->component_->runtime_stats_.record_time(elapsed_us); - } else { - // Still accumulate into the global counter so Application::loop() can subtract - // this time from before_loop_tasks_ wall time. - ComponentRuntimeStats::global_recorded_us += elapsed_us; - } -#endif - uint32_t curr_time = MillisInternal::get(); -#ifndef USE_BENCHMARK - // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) - static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; - uint32_t blocking_time = curr_time - this->started_; - if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { - warn_blocking(this->component_, blocking_time); - } -#endif - return curr_time; - } - - ~WarnIfComponentBlockingGuard() = default; - - protected: - uint32_t started_; - Component *component_; -#ifdef USE_RUNTIME_STATS - uint32_t started_us_; -#endif - - private: - // Cold path for blocking warning - defined in component.cpp - static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time); -}; +// LoopBlockingGuard lives in application.h because it reads its state from App. // Function to clear setup priority overrides after all components are set up // Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index bc1d55a1c4..7297d22357 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -16,7 +16,7 @@ namespace esphome { // Friend-gated accessor for a fast millis() variant intended only for // known task-context callers on the main loop hot path (Application::loop() -// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// and LoopBlockingGuard::finish()). It skips the ISR-context // dispatch that the public esphome::millis() pays on ESP32 and libretiny. // // MUST NOT be called from ISR context: on ESP32 and libretiny it calls the @@ -50,7 +50,7 @@ class MillisInternal { #endif } friend class Application; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; }; } // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a7c624486d..15bb9ea239 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel) { + std::function &&func, bool is_retry, bool skip_cancel, + const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { @@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); - item->component = component; + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } item->set_name(name_type, static_name, hash_or_id); item->type = type; // Use destroy + placement-new instead of move-assignment. @@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Not reached timeout yet, done for this call break; } - // Don't run on failed components - if (item->component != nullptr && item->component->is_failed()) { + // Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays). + if (this->is_item_failed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; @@ -790,10 +796,21 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { - App.set_current_component(item->component); - // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. - App.set_loop_component_start_time_(now); - WarnIfComponentBlockingGuard guard{item->component, now}; + // Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared + // union slot with a single name-type check. Self-keyed items have no owning component; their slot + // holds the source name (e.g. the owning script), published so deferred work chained inside the + // callback re-captures it and the blocking warning can name the script instead of "". + Component *component; + const LogString *source; + if (item->get_name_type() == NameType::SELF_POINTER) { + component = nullptr; + source = item->source_name; + } else { + component = item->component; + source = nullptr; + } + // Guard publishes the item's identity + dispatch time, then times the callback. + LoopBlockingGuard guard{component, source, now}; item->callback(); uint32_t end = guard.finish(); // Feed the watchdog after each scheduled item (both main heap and defer diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b640aa86fe..378c0fb94b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -183,11 +183,12 @@ class Scheduler { protected: struct SchedulerItem { - // Ordered by size to minimize padding. - // `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive). + // Ordered by size to minimize padding. Mutually exclusive by state; read the component via + // get_component() so SELF_POINTER items read as component-less. union { - Component *component; - SchedulerItem *next_free; + Component *component; // live, non-SELF_POINTER: owning component + const LogString *source_name; // live SELF_POINTER: owning script name (log attribution) + SchedulerItem *next_free; // while pooled }; // Optimized name storage using tagged union - zero heap allocation union { @@ -302,14 +303,23 @@ class Scheduler { next_execution_high_ = static_cast(value >> 32); } constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } - const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); } + // The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead). + // All component access goes through this so SELF_POINTER items read as component-less. + Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; } + const LogString *get_source() const { + // Same no-source label as warn_blocking, for consistent log vocabulary. + if (name_type_ == NameType::SELF_POINTER) + return source_name != nullptr ? source_name : LOG_STR("a scheduled task"); + return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown"); + } }; // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id + // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false); + bool skip_cancel = false, const LogString *source = nullptr); // Common implementation for retry - Remove before 2026.8.0 // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id @@ -402,8 +412,10 @@ class Scheduler { // Fixes: https://github.com/esphome/esphome/issues/11940 if (item == nullptr) return false; - if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) || - (match_retry && !item->is_retry)) { + // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they + // match by the `this` key alone. + if (item->get_component() != component || item->type != type || + (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -423,11 +435,16 @@ class Scheduler { // Helper to execute a scheduler item uint32_t execute_item_(SchedulerItem *item, uint32_t now); - // Helper to check if item should be skipped - bool should_skip_item_(SchedulerItem *item) const { - return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); + // True if the item's component is failed (so it must not run). SELF_POINTER delays have no + // component (get_component() == nullptr) and always fire. + bool is_item_failed_(SchedulerItem *item) const { + Component *component = item->get_component(); + return component != nullptr && component->is_failed(); } + // Helper to check if item should be skipped + bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); } + // Helper to recycle a SchedulerItem back to the pool. // Takes a raw pointer — caller transfers ownership. The item is either added to the // pool or deleted if the pool is full. diff --git a/tests/integration/fixtures/scheduler_blocking_warning.yaml b/tests/integration/fixtures/scheduler_blocking_warning.yaml new file mode 100644 index 0000000000..594ec46afb --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning.yaml @@ -0,0 +1,22 @@ +esphome: + name: scheduler-blocking-warning + on_boot: + then: + - script.execute: blocking_script + +host: +api: +logger: + level: DEBUG + +# The busy-block runs in the second delay's continuation; the warning must name the script. Two +# delays verify the source survives chained delays (the scheduler republishes it each continuation). +script: + - id: blocking_script + then: + - delay: 10ms + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml new file mode 100644 index 0000000000..2d8a62f25b --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml @@ -0,0 +1,30 @@ +esphome: + name: scheduler-blocking-generic + +host: +api: +logger: + level: DEBUG + +globals: + - id: done + type: bool + restore_value: false + initial_value: "false" + +# A delay in a plain (non-script) automation has no owning script, so the block must log the +# generic "a scheduled task" label, not a script name. +interval: + - interval: 100ms + id: gen_interval + then: + - if: + condition: + lambda: "return !id(done);" + then: + - lambda: "id(done) = true;" + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml new file mode 100644 index 0000000000..860fa00c37 --- /dev/null +++ b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml @@ -0,0 +1,29 @@ +esphome: + name: scheduler-delay-failed + +host: +api: +logger: + level: DEBUG + +globals: + - id: started + type: bool + restore_value: false + initial_value: "false" + +# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed +# component must not drop it, since the SELF_POINTER scheduler item has no owning component. +interval: + - interval: 100ms + id: host_interval + then: + - if: + condition: + lambda: "return !id(started);" + then: + - lambda: |- + id(started) = true; + id(host_interval)->mark_failed(); + - delay: 200ms + - logger.log: "DELAY_FIRED_AFTER_FAIL" diff --git a/tests/integration/test_scheduler_blocking_warning.py b/tests/integration/test_scheduler_blocking_warning.py new file mode 100644 index 0000000000..699a5bc746 --- /dev/null +++ b/tests/integration/test_scheduler_blocking_warning.py @@ -0,0 +1,120 @@ +"""Integration tests for blocking-warning source attribution. + +A blocking operation that runs inside a deferred scheduler continuation (e.g. after a ``delay`` +in a script) used to be reported as `` took a long time for an operation (NN ms), +max is 30 ms`` because the continuation carries no component. The warning should instead name +the owning script and report the real threshold (50 ms). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Matches: " took a long time for an operation (NN ms), max is NN ms" +WARN_PATTERN = re.compile( + r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" +) + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Deferred blocking work inside a script is attributed to the script, not "".""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + + # on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the + # continuation, tripping the blocking warning. + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + # Must name the owning script, not "" and not the generic fallback. + assert "" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + assert "a scheduled task" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + match = WARN_PATTERN.search(warning_line) + assert match is not None + assert match.group(1) == "blocking_script", ( + f"Warning should name 'blocking_script', got: {warning_line}" + ) + # The reported threshold must be the real default (50 ms), not the stale "30 ms". + assert match.group(3) == "50", f"Expected 'max is 50 ms', got: {warning_line}" + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning_generic_source( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay in a plain (non-script) automation logs the generic label, not a script name.""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + assert "a scheduled task took a long time" in warning_line, ( + f"Non-script deferred work should log the generic label, got: {warning_line}" + ) + assert "" not in warning_line + match = WARN_PATTERN.search(warning_line) + assert match is not None and match.group(3) == "50", ( + f"Expected 'max is 50 ms', got: {warning_line}" + ) + + +@pytest.mark.asyncio +async def test_scheduler_delay_runs_on_failed_component( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay must still fire even when its context component is marked failed. + + Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's + failed-component skip must not drop them. + """ + loop = asyncio.get_running_loop() + fired: asyncio.Future[bool] = loop.create_future() + + def check_output(line: str) -> None: + if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done(): + fired.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + # If the failed host component wrongly dropped the delay, this times out. + await asyncio.wait_for(fired, timeout=10.0) From aef9b5b72f731ff6d8d307e71cfbdcf37dcb52c5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 15 Jun 2026 16:48:07 -0400 Subject: [PATCH 1640/2214] [audio] Bump microMP3 to v0.2.3 (#16977) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7497cc3679..7a3cfc7a03 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c +34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2ddce577ef..2aceff0c97 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.1") + add_idf_component(name="esphome/micro-mp3", ref="0.2.3") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c97e8906a8..04220488cc 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.1 + version: 0.2.3 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From 1d38498ca7c27609dd166610397909cdcad8d174 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:05:50 -0400 Subject: [PATCH 1641/2214] [openthread] Fix InstanceLock releasing the lock twice on try_acquire (#16980) --- esphome/components/openthread/openthread.cpp | 2 +- esphome/components/openthread/openthread.h | 23 +++++++++++++++---- .../components/openthread/openthread_esp.cpp | 17 +++++++------- .../openthread_info_text_sensor.h | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index bf14514636..c8ffc02131 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,7 +227,7 @@ bool OpenThreadComponent::teardown() { ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory"); return true; } - otInstance *instance = lock->get_instance(); + otInstance *instance = lock.get_instance(); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); global_openthread_component = nullptr; diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 5898492a50..96f1abdb92 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -86,19 +86,32 @@ class OpenThreadSrpComponent : public Component { void *pool_alloc_(size_t size); }; +// RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the +// guard may or may not own the lock (try_acquire can fail), so check it with +// operator bool before use. Non-copyable and non-movable: the factories return +// by value via guaranteed copy elision, so a guard is never duplicated and the +// lock is released exactly once, when the owning guard goes out of scope. class InstanceLock { public: - static std::optional try_acquire(int delay); + // May fail to acquire within delay ms; check the returned guard with operator bool. + static InstanceLock try_acquire(int delay); + // Blocks until the lock is held. static InstanceLock acquire(); + InstanceLock(const InstanceLock &) = delete; + InstanceLock(InstanceLock &&) = delete; + InstanceLock &operator=(const InstanceLock &) = delete; + InstanceLock &operator=(InstanceLock &&) = delete; ~InstanceLock(); - // Returns the global openthread instance guarded by this lock + explicit operator bool() const { return this->owns_; } + + // Returns the global openthread instance. Only valid on an owning guard + // (operator bool is true); the instance must not be used without the lock held. otInstance *get_instance(); private: - // Use a private constructor in order to force the handling - // of acquisition failure - InstanceLock() {} + explicit InstanceLock(bool owns) : owns_(owns) {} + bool owns_; }; } // namespace esphome::openthread diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index cf1288d90c..4d88cbd226 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -216,14 +216,11 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } -std::optional InstanceLock::try_acquire(int delay) { +InstanceLock InstanceLock::try_acquire(int delay) { if (!global_openthread_component->is_lock_initialized()) { - return {}; + return InstanceLock(false); } - if (esp_openthread_lock_acquire(delay)) { - return InstanceLock(); - } - return {}; + return InstanceLock(esp_openthread_lock_acquire(delay)); } InstanceLock InstanceLock::acquire() { @@ -242,12 +239,16 @@ InstanceLock InstanceLock::acquire() { while (!esp_openthread_lock_acquire(100)) { esp_task_wdt_reset(); } - return InstanceLock(); + return InstanceLock(true); } otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); } -InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } +InstanceLock::~InstanceLock() { + if (this->owns_) { + esp_openthread_lock_release(); + } +} } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index 10e83281f0..ef7c5cc8e9 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -17,7 +17,7 @@ class OpenThreadInstancePollingComponent : public PollingComponent { return; } - this->update_instance(lock->get_instance()); + this->update_instance(lock.get_instance()); } float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } From 66be793cd8a57af57c032e5ab207a34b5f83caa7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:12:53 +1200 Subject: [PATCH 1642/2214] [docker] Remove alpine base, build only on debian (#16991) --- .github/actions/build-image/action.yaml | 7 ------- docker/Dockerfile | 15 +++++---------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 2081264b91..494c0cebe8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -15,11 +15,6 @@ inputs: description: "Version to build" required: true example: "2023.12.0" - base_os: - description: "Base OS to use" - required: false - default: "debian" - example: "debian" runs: using: "composite" steps: @@ -60,7 +55,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true @@ -86,7 +80,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true diff --git a/docker/Dockerfile b/docker/Dockerfile index 25de9472b6..c360ae1a4a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,9 @@ ARG BUILD_VERSION=dev -ARG BUILD_OS=alpine ARG BUILD_BASE_VERSION=2025.04.0 ARG BUILD_TYPE=docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon +FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker +FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon ARG BUILD_TYPE FROM base-source-${BUILD_TYPE} AS base @@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \ # validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without # it idf_tools.py rejects the openocd install with exit 127 and aborts # the whole framework setup. -RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base libusb; \ - else \ - apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ - && rm -rf /var/lib/apt/lists/*; \ - fi +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 From 0ce89c17ab7233216c78a7e66875477f08d0acf3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:37:31 +1200 Subject: [PATCH 1643/2214] [ci] Push branch-tagged docker images to ghcr.io for local testing (#16992) --- .github/workflows/ci-docker.yml | 84 ++++++++++++++- docker/build.py | 55 +++++++--- tests/script/test_docker_build.py | 169 ++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 tests/script/test_docker_build.py diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 2a40675f3b..7d4b850356 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -22,7 +22,7 @@ on: - "script/platformio_install_deps.py" permissions: - contents: read # actions/checkout only; the build does not push images + contents: read # actions/checkout only concurrency: # yamllint disable-line rule:line-length @@ -33,6 +33,9 @@ jobs: check-docker: name: Build docker containers runs-on: ${{ matrix.os }} + permissions: + contents: read # actions/checkout to load Dockerfile and build context + packages: write # push branch-tagged images to ghcr.io for local testing strategy: fail-fast: false matrix: @@ -41,6 +44,9 @@ jobs: - "ha-addon" - "docker" # - "lint" + outputs: + tag: ${{ steps.tag.outputs.tag }} + push: ${{ steps.tag.outputs.push }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python @@ -50,14 +56,82 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - name: Set TAG + - name: Determine tag and whether to push + id: tag run: | - echo "TAG=check" >> $GITHUB_ENV + # Sanitize the branch name into a valid docker tag: replace invalid + # characters, ensure the first character is valid (tags must start + # with [A-Za-z0-9_]), and cap the length at 128 characters. + branch="${{ github.head_ref || github.ref_name }}" + tag="${branch//[^a-zA-Z0-9_.-]/-}" + case "$tag" in + [a-zA-Z0-9_]*) ;; + *) tag="pr-${tag}" ;; + esac + tag="${tag:0:128}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + # Only push branch images for same-repo pull requests. Push events + # only fire for dev/beta/release, whose images are owned by the + # release pipeline -- never overwrite those from here. + if [ "${{ github.event_name }}" = "pull_request" ] \ + && [ "${{ github.repository }}" = "esphome/esphome" ] \ + && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then + echo "push=true" >> "$GITHUB_OUTPUT" + else + echo "push=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the GitHub container registry + if: steps.tag.outputs.push == 'true' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Run build run: | docker/build.py \ - --tag "${TAG}" \ + --tag "${{ steps.tag.outputs.tag }}" \ --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ - build + --registry ghcr \ + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + + manifest: + name: Push ${{ matrix.build_type }} manifest to ghcr.io + needs: [check-docker] + if: needs.check-docker.outputs.push == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to run docker/build.py + packages: write # buildx imagetools writes the multi-arch tag to ghcr.io + strategy: + fail-fast: false + matrix: + build_type: + - "ha-addon" + - "docker" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to the GitHub container registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + docker/build.py \ + --tag "${{ needs.check-docker.outputs.tag }}" \ + --build-type "${{ matrix.build_type }}" \ + --registry ghcr \ + manifest diff --git a/docker/build.py b/docker/build.py index 4d093cf88d..475986e905 100755 --- a/docker/build.py +++ b/docker/build.py @@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon" TYPE_LINT = "lint" TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] +REGISTRY_GHCR = "ghcr" +REGISTRY_DOCKERHUB = "dockerhub" +REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB] + parser = argparse.ArgumentParser() parser.add_argument( @@ -34,6 +38,12 @@ parser.add_argument( parser.add_argument( "--build-type", choices=TYPES, required=True, help="The type of build to run" ) +parser.add_argument( + "--registry", + choices=REGISTRIES, + action="append", + help="Restrict to specific registries (default: all). May be passed multiple times.", +) parser.add_argument( "--dry-run", action="store_true", help="Don't run any commands, just print them" ) @@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t build_parser.add_argument( "--load", help="Load the docker image locally", action="store_true" ) +build_parser.add_argument( + "--no-cache-to", + help="Don't write the build cache (avoids polluting the shared cache)", + action="store_true", +) manifest_parser = subparsers.add_parser( "manifest", help="Create a manifest from already pushed images" ) @@ -95,11 +110,14 @@ def main(): print("Command failed") sys.exit(1) + registries = args.registry or REGISTRIES + # detect channel from tag match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) major_minor_version = None if match is None: - channel = CHANNEL_DEV + # Custom tag (e.g. a branch name) -- push only the tag itself + channel = None elif match.group(2) is None: major_minor_version = match.group(1) channel = CHANNEL_RELEASE @@ -128,11 +146,18 @@ def main(): CHANNEL_DEV: "cache-dev", CHANNEL_BETA: "cache-beta", CHANNEL_RELEASE: "cache-latest", - }[channel] - cache_img = f"ghcr.io/{params.build_to}:{cache_tag}" + }.get(channel, "cache-dev") + # Cache images live alongside the pushed images; prefer GHCR when it is + # one of the selected registries, otherwise fall back to Docker Hub so a + # registry-restricted build doesn't need GHCR auth. + cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else "" + cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}" - imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push] - imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] + imgs = [] + if REGISTRY_DOCKERHUB in registries: + imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] # 3. build cmd = [ @@ -155,7 +180,9 @@ def main(): for img in imgs: cmd += ["--tag", img] if args.push: - cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"] + cmd += ["--push"] + if not args.no_cache_to: + cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"] if args.load: cmd += ["--load"] @@ -163,20 +190,22 @@ def main(): elif args.command == "manifest": manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to - targets = [f"{manifest}:{tag}" for tag in tags_to_push] - targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] - # 1. Create manifests + targets = [] + if REGISTRY_DOCKERHUB in registries: + targets += [f"{manifest}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] + # Use buildx imagetools (not `docker manifest`) so the per-arch sources, + # which buildx pushes as single-platform manifest lists, are combined + # and pushed correctly in one step. for target in targets: - cmd = ["docker", "manifest", "create", target] + cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] for arch in ARCHS: src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" if target.startswith("ghcr.io"): src = f"ghcr.io/{src}" cmd.append(src) run_command(*cmd) - # 2. Push manifests - for target in targets: - run_command("docker", "manifest", "push", target) if __name__ == "__main__": diff --git a/tests/script/test_docker_build.py b/tests/script/test_docker_build.py new file mode 100644 index 0000000000..34bcc4e714 --- /dev/null +++ b/tests/script/test_docker_build.py @@ -0,0 +1,169 @@ +"""Unit tests for docker/build.py command generation.""" + +import importlib.util +from pathlib import Path +import sys + +import pytest + +_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py" +_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY) +docker_build = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(docker_build) + + +def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]: + """Run build.py main() in dry-run mode and return the emitted commands.""" + full_argv = ["build.py", "--dry-run", *argv] + with pytest.MonkeyPatch.context() as mp: + mp.setattr(sys, "argv", full_argv) + docker_build.main() + out = capsys.readouterr().out + return [line[2:] for line in out.splitlines() if line.startswith("$ ")] + + +def test_branch_build_pushes_single_ghcr_tag_without_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + "--push", + "--no-cache-to", + ) + + assert len(commands) == 1 + cmd = commands[0] + # Custom tag -> only the tag itself, no companion "dev"/"latest" tags + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert ":dev" not in cmd + # ghcr only -> no Docker Hub image name + assert "--tag esphome/esphome-amd64:my-branch" not in cmd + # custom tag falls back to the dev cache for reads + assert ( + "--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd + ) + assert "--push" in cmd + # --no-cache-to must suppress the cache write + assert "--cache-to" not in cmd + + +def test_branch_manifest_targets_ghcr_only( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "ha-addon", + "--registry", + "ghcr", + "manifest", + ) + + assert commands == [ + "docker buildx imagetools create " + "--tag ghcr.io/esphome/esphome-hassio:my-branch " + "ghcr.io/esphome/esphome-hassio-amd64:my-branch " + "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ] + + +def test_release_build_keeps_both_registries_and_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "2025.6.0", + "--arch", + "amd64", + "--build-type", + "docker", + "build", + "--push", + ) + + cmd = commands[0] + # Default (no --registry) keeps both Docker Hub and ghcr image names + assert "--tag esphome/esphome-amd64:2025.6.0" in cmd + assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd + # Release channel still gets its companion tags + assert "--tag esphome/esphome-amd64:latest" in cmd + # Without --no-cache-to the cache write is preserved + assert ( + "--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max" + in cmd + ) + + +def test_build_no_push_omits_push_and_cache( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + ) + + cmd = commands[0] + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert "--push" not in cmd + assert "--cache-to" not in cmd + + +def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "dockerhub", + "build", + "--push", + ) + + cmd = commands[0] + assert "--tag esphome/esphome-amd64:my-branch" in cmd + assert "ghcr.io" not in cmd + # Cache reference falls back to Docker Hub when GHCR isn't selected + assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd + + +def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "docker", + "--registry", + "dockerhub", + "manifest", + ) + + create = commands[0] + assert create.startswith( + "docker buildx imagetools create --tag esphome/esphome:my-branch " + ) + assert "ghcr.io" not in create From 0422b581cb1537b6ceebb1b12546911a51efc43b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:24:26 +1200 Subject: [PATCH 1644/2214] [core] Stop parent git repos from breaking ESP-IDF/PlatformIO builds (#16994) --- esphome/espidf/toolchain.py | 6 +++++ esphome/helpers.py | 21 +++++++++++++++ esphome/platformio/toolchain.py | 5 ++++ tests/unit_tests/test_espidf_toolchain.py | 14 ++++++++++ tests/unit_tests/test_helpers.py | 27 +++++++++++++++++++ tests/unit_tests/test_platformio_toolchain.py | 5 ++++ 6 files changed, 78 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 2fef3faf8d..c622a2dd36 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -14,6 +14,7 @@ from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary +from esphome.helpers import add_git_ceiling_directory _LOGGER = logging.getLogger(__name__) @@ -82,6 +83,11 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] |= get_framework_env( *_get_esphome_esp_idf_paths(version) ) + + # Cap git's repo search at the config directory so ESP-IDF's + # `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(env_cache[version], CORE.config_dir) return env_cache[version] diff --git a/esphome/helpers.py b/esphome/helpers.py index 733474c9c9..ef7e2d0b93 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import MutableMapping from contextlib import suppress import ipaddress import logging @@ -374,6 +375,26 @@ def is_ha_addon(): return get_bool_env("ESPHOME_IS_HA_ADDON") +def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) -> None: + """Add ``directory`` to ``env``'s ``GIT_CEILING_DIRECTORIES`` list. + + Git stops walking up the directory tree to find a repository once it reaches + a ceiling directory, so this caps the search at ``directory`` (the ESPHome + project root). Without it, an uninitialized or corrupt git repo in a parent + directory makes the ``git describe`` that build toolchains run for the app + version error out and fail the whole build. + + ``GIT_CEILING_DIRECTORIES`` is an ``os.pathsep``-joined list of absolute + paths; any existing entries are preserved and duplicates are skipped. + """ + ceiling = str(directory) + existing = env.get("GIT_CEILING_DIRECTORIES", "") + parts = existing.split(os.pathsep) if existing else [] + if ceiling not in parts: + parts.append(ceiling) + env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts) + + def rmtree(path: Path | str) -> None: """Remove a directory tree, handling read-only files on Windows. diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index c81420e6ca..c97df812e3 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -7,6 +7,7 @@ import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError +from esphome.helpers import add_git_ceiling_directory from esphome.util import FlashImage, run_external_process _LOGGER = logging.getLogger(__name__) @@ -53,6 +54,10 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") + # Cap git's repo search at the config directory so the framework's build + # scripts running `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(os.environ, CORE.config_dir) # Strip the Windows extended-length path prefix from sys.executable so it # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted # command lines run through cmd.exe. diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 8849ea8bc8..b2309439f9 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -150,6 +150,20 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: assert result == {"cxx_path": "regen"} +def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: + """The IDF env caps git's upward search at the config directory. + + This stops ESP-IDF's `git describe` from walking into an uninitialized or + corrupt git repo in a parent directory and failing the build. + """ + toolchain._cache().env.clear() + # Set IDF_PATH so the framework-install branch is skipped. + with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}): + env = toolchain._get_idf_env(version="5.5.4") + assert CORE.config_dir == setup_core + assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) + + def test_get_core_framework_version_from_core_data(): """The version is read from CORE.data when validation populated it.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index efc2d8e42a..70c4b90082 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -196,6 +196,33 @@ def test_is_ha_addon(monkeypatch, value, expected): assert actual == expected +def test_add_git_ceiling_directory_sets_when_unset(): + """An empty env gets GIT_CEILING_DIRECTORIES set to the directory.""" + env: dict[str, str] = {} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + +def test_add_git_ceiling_directory_appends_to_existing(): + """An existing value is preserved and the new directory is appended.""" + env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [ + str(Path("/some/ceiling")), + str(directory), + ] + + +def test_add_git_ceiling_directory_skips_duplicate(): + """A directory already in the list is not appended again.""" + directory = Path("/home/user/config") + env = {"GIT_CEILING_DIRECTORIES": str(directory)} + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + def test_walk_files(fixture_path): path = fixture_path / "helpers" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index a37b19f584..568b43a259 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -304,6 +304,11 @@ def test_run_platformio_cli_sets_environment_variables( ) assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ + # Caps git's upward search at the config dir so an uninitialized or + # corrupt parent git repo can't break the framework's `git describe`. + assert str(CORE.config_dir) in os.environ["GIT_CEILING_DIRECTORIES"].split( + os.pathsep + ) # Check command was called correctly — runs PlatformIO as a subprocess # via the esphome.platformio.runner entry point. From 310baab5248a4fd28cc4c7941f2b716be63e3482 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:47:14 +1200 Subject: [PATCH 1645/2214] [docker] Bundle device-builder 1.0.1, make HA add-on builder-only (#16989) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 5 +- docker/docker_entrypoint.sh | 8 ++ .../etc/cont-init.d/40-device-builder.sh | 22 ----- .../etc/nginx/includes/mime.types | 96 ------------------- .../etc/nginx/includes/proxy_params.conf | 16 ---- .../etc/nginx/includes/server_params.conf | 8 -- .../etc/nginx/includes/ssl_params.conf | 8 -- .../etc/nginx/includes/upstream.conf | 3 - docker/ha-addon-rootfs/etc/nginx/nginx.conf | 30 ------ .../etc/nginx/servers/.gitkeep | 1 - .../etc/nginx/templates/direct.gtpl | 28 ------ .../etc/nginx/templates/ingress.gtpl | 18 ---- .../s6-rc.d/discovery/dependencies.d/nginx | 0 .../etc/s6-overlay/s6-rc.d/discovery/run | 2 +- .../etc/s6-overlay/s6-rc.d/esphome/finish | 4 +- .../etc/s6-overlay/s6-rc.d/esphome/run | 15 +-- .../s6-rc.d/init-nginx/dependencies.d/base | 0 .../etc/s6-overlay/s6-rc.d/init-nginx/run | 35 ------- .../etc/s6-overlay/s6-rc.d/init-nginx/type | 1 - .../etc/s6-overlay/s6-rc.d/init-nginx/up | 1 - .../s6-rc.d/nginx/dependencies.d/esphome | 0 .../s6-rc.d/nginx/dependencies.d/init-nginx | 0 .../etc/s6-overlay/s6-rc.d/nginx/finish | 25 ----- .../etc/s6-overlay/s6-rc.d/nginx/run | 27 ------ .../etc/s6-overlay/s6-rc.d/nginx/type | 1 - .../s6-rc.d/user/contents.d/init-nginx | 0 .../s6-overlay/s6-rc.d/user/contents.d/nginx | 0 27 files changed, 20 insertions(+), 334 deletions(-) delete mode 100755 docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/mime.types delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/nginx.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/dependencies.d/base delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/up delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/esphome delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-nginx delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/finish delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx diff --git a/docker/Dockerfile b/docker/Dockerfile index c360ae1a4a..c7634cf1c8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.0 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -31,6 +31,9 @@ RUN \ uv pip install --no-cache-dir \ -r /requirements.txt +# Install the ESPHome Device Builder dashboard. +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 + RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 1b9224244c..18baf40c29 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -27,4 +27,12 @@ if [[ -d /build ]]; then export ESPHOME_BUILD_PATH=/build fi +# The default CMD is "dashboard /config". Route the dashboard to the new +# Device Builder, but pass every other subcommand (compile, run, config, +# logs, ...) straight through to the esphome CLI so direct CLI use keeps working. +if [[ "$1" == "dashboard" ]]; then + shift + exec esphome-device-builder "$@" +fi + exec esphome "$@" diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh deleted file mode 100755 index b990469762..0000000000 --- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/with-contenv bashio -# ============================================================================== -# Installs the latest prerelease of esphome-device-builder when the -# `use_new_device_builder` config option is enabled. -# This is a temporary install-on-boot step until esphome-device-builder -# becomes a direct dependency of esphome. -# ============================================================================== - -if ! bashio::config.true 'use_new_device_builder'; then - exit 0 -fi - -bashio::log.info "Installing latest prerelease of esphome-device-builder..." -if command -v uv > /dev/null; then - uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ - esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -else - pip install --no-cache-dir --pre --upgrade esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -fi -bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types deleted file mode 100644 index 7c7cdef2d1..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types +++ /dev/null @@ -1,96 +0,0 @@ -types { - text/html html htm shtml; - text/css css; - text/xml xml; - image/gif gif; - image/jpeg jpeg jpg; - application/javascript js; - application/atom+xml atom; - application/rss+xml rss; - - text/mathml mml; - text/plain txt; - text/vnd.sun.j2me.app-descriptor jad; - text/vnd.wap.wml wml; - text/x-component htc; - - image/png png; - image/svg+xml svg svgz; - image/tiff tif tiff; - image/vnd.wap.wbmp wbmp; - image/webp webp; - image/x-icon ico; - image/x-jng jng; - image/x-ms-bmp bmp; - - font/woff woff; - font/woff2 woff2; - - application/java-archive jar war ear; - application/json json; - application/mac-binhex40 hqx; - application/msword doc; - application/pdf pdf; - application/postscript ps eps ai; - application/rtf rtf; - application/vnd.apple.mpegurl m3u8; - application/vnd.google-earth.kml+xml kml; - application/vnd.google-earth.kmz kmz; - application/vnd.ms-excel xls; - application/vnd.ms-fontobject eot; - application/vnd.ms-powerpoint ppt; - application/vnd.oasis.opendocument.graphics odg; - application/vnd.oasis.opendocument.presentation odp; - application/vnd.oasis.opendocument.spreadsheet ods; - application/vnd.oasis.opendocument.text odt; - application/vnd.openxmlformats-officedocument.presentationml.presentation - pptx; - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - xlsx; - application/vnd.openxmlformats-officedocument.wordprocessingml.document - docx; - application/vnd.wap.wmlc wmlc; - application/x-7z-compressed 7z; - application/x-cocoa cco; - application/x-java-archive-diff jardiff; - application/x-java-jnlp-file jnlp; - application/x-makeself run; - application/x-perl pl pm; - application/x-pilot prc pdb; - application/x-rar-compressed rar; - application/x-redhat-package-manager rpm; - application/x-sea sea; - application/x-shockwave-flash swf; - application/x-stuffit sit; - application/x-tcl tcl tk; - application/x-x509-ca-cert der pem crt; - application/x-xpinstall xpi; - application/xhtml+xml xhtml; - application/xspf+xml xspf; - application/zip zip; - - application/octet-stream bin exe dll; - application/octet-stream deb; - application/octet-stream dmg; - application/octet-stream iso img; - application/octet-stream msi msp msm; - - audio/midi mid midi kar; - audio/mpeg mp3; - audio/ogg ogg; - audio/x-m4a m4a; - audio/x-realaudio ra; - - video/3gpp 3gpp 3gp; - video/mp2t ts; - video/mp4 mp4; - video/mpeg mpeg mpg; - video/quicktime mov; - video/webm webm; - video/x-flv flv; - video/x-m4v m4v; - video/x-mng mng; - video/x-ms-asf asx asf; - video/x-ms-wmv wmv; - video/x-msvideo avi; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf deleted file mode 100644 index a1ebb5079a..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf +++ /dev/null @@ -1,16 +0,0 @@ -proxy_http_version 1.1; -proxy_ignore_client_abort off; -proxy_read_timeout 86400s; -proxy_redirect off; -proxy_send_timeout 86400s; -proxy_max_temp_file_size 0; - -proxy_set_header Accept-Encoding ""; -proxy_set_header Connection $connection_upgrade; -proxy_set_header Host $http_host; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-NginX-Proxy true; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header Authorization ""; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf deleted file mode 100644 index debdf83a8c..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -root /dev/null; -server_name $hostname; - -client_max_body_size 512m; - -add_header X-Content-Type-Options nosniff; -add_header X-XSS-Protection "1; mode=block"; -add_header X-Robots-Tag none; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf deleted file mode 100644 index e6789cbb9b..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -ssl_protocols TLSv1.2 TLSv1.3; -ssl_prefer_server_ciphers off; -ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; -ssl_session_timeout 10m; -ssl_session_cache shared:SSL:10m; -ssl_session_tickets off; -ssl_stapling on; -ssl_stapling_verify on; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf deleted file mode 100644 index 8e782bdc88..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf +++ /dev/null @@ -1,3 +0,0 @@ -upstream esphome { - server unix:/var/run/esphome.sock; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf deleted file mode 100644 index 497427596d..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -daemon off; -user root; -pid /var/run/nginx.pid; -worker_processes 1; -error_log /proc/1/fd/1 error; -events { - worker_connections 1024; -} - -http { - include /etc/nginx/includes/mime.types; - - access_log off; - default_type application/octet-stream; - gzip on; - keepalive_timeout 65; - sendfile on; - server_tokens off; - - tcp_nodelay on; - tcp_nopush on; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - include /etc/nginx/includes/upstream.conf; - include /etc/nginx/servers/*.conf; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep deleted file mode 100644 index 85ad51be5f..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley) diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl deleted file mode 100644 index 4fb0ca3f90..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl +++ /dev/null @@ -1,28 +0,0 @@ -server { - {{ if not .ssl }} - listen 6052 default_server; - {{ else }} - listen 6052 default_server ssl http2; - {{ end }} - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - {{ if .ssl }} - include /etc/nginx/includes/ssl_params.conf; - - ssl_certificate /ssl/{{ .certfile }}; - ssl_certificate_key /ssl/{{ .keyfile }}; - - # Redirect http requests to https on the same port. - # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/ - error_page 497 https://$http_host$request_uri; - {{ end }} - - # Clear Home Assistant Ingress header - proxy_set_header X-HA-Ingress ""; - - location / { - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl deleted file mode 100644 index 105ddde710..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 127.0.0.1:{{ .port }} default_server; - listen {{ .interface }}:{{ .port }} default_server; - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - # Set Home Assistant Ingress header - proxy_set_header X-HA-Ingress "YES"; - - location / { - allow 172.30.32.2; - allow 127.0.0.1; - deny all; - - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index 111157d301..bb36cfcdb4 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -16,7 +16,7 @@ fi port=$(bashio::addon.ingress_port) -# Wait for NGINX to become available +# Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 config=$(\ diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish index 6e0f8fe23a..da450c25f9 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish @@ -2,7 +2,7 @@ # shellcheck shell=bash # ============================================================================== # Home Assistant Community Add-on: ESPHome -# Take down the S6 supervision tree when ESPHome dashboard fails +# Take down the S6 supervision tree when ESPHome Device Builder fails # ============================================================================== declare exit_code readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode - fi - [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt -elif [[ "${exit_code_service}" -ne 0 ]]; then - if [[ "${exit_code_container}" -eq 0 ]]; then - echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode - fi - exec /run/s6/basedir/bin/halt -fi diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run deleted file mode 100755 index b8251e8e01..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ /dev/null @@ -1,27 +0,0 @@ -#!/command/with-contenv bashio -# shellcheck shell=bash -# ============================================================================== -# Community Hass.io Add-ons: ESPHome -# Runs the NGINX proxy -# ============================================================================== - -# The new device builder handles HA ingress itself, so nginx is bypassed. -# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on -# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish -# stamp the container exit 143, which trips the Supervisor's SIGTERM check. -if bashio::config.true 'use_new_device_builder'; then - bashio::log.info "NGINX bypassed: new device builder serves ingress directly." - trap 'exit 0' TERM - sleep infinity & - wait - exit 0 -fi - -bashio::log.info "Waiting for ESPHome dashboard to come up..." - -while [[ ! -S /var/run/esphome.sock ]]; do - sleep 0.5 -done - -bashio::log.info "Starting NGINX..." -exec nginx diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type deleted file mode 100644 index 5883cff0cd..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 From 53fd99578ae69088a4a93bc7fc8ad9b3f4932969 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:02:55 +1200 Subject: [PATCH 1646/2214] Bump version to 2026.6.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 809f934797..c94ea34387 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0b2 +PROJECT_NUMBER = 2026.6.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 27abfa2dd2..bf770ae5b5 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0b2" +__version__ = "2026.6.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ce11d38c9bac04b1dfe5570cb289399baff6e6f0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:53:11 -0400 Subject: [PATCH 1647/2214] [esp32_hosted] Bump esp_hosted to 2.12.9 (#16999) --- .clang-tidy.hash | 2 +- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 1f709bb90d..591ce70a62 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -007cddcd7aa933f0ff9b3fd65f0b7571579ac223d11c6117af2b291bd2f9fe74 +6765760d573967b853b1f790f0f5478135d12f2b15ffa8bee9b0314090b582ee diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 94e20ea6c9..7f420f27d8 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -257,7 +257,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 04220488cc..5f3000e52d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.8 + version: 2.12.9 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 29e8949e3e66c1cea04b5a16ab32b87eb539bd6f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:46 -0400 Subject: [PATCH 1648/2214] [ota] Scale ESP-IDF OTA erase watchdog to image size (#16998) --- esphome/components/ota/ota_backend_esp_idf.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ade726da1f..ac765d8018 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - watchdog::WatchdogManager watchdog(15000); + // esp_ota_begin() erases the destination region, which blocks loopTask and + // scales with the erase size -- a fixed watchdog overruns on large OTA slots. + // An unknown size (0, e.g. web_server uploads) erases the whole partition, so + // budget against the bytes actually erased. ~10ms/KiB (conservative + // ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still + // resets rather than hanging forever. + size_t erase_size = image_size; + if (erase_size == 0 || erase_size > this->partition_->size) { + erase_size = this->partition_->size; + } + const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10; + watchdog::WatchdogManager watchdog(erase_budget_ms); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); if (err != ESP_OK) { From e80461eba972acaad9e0592a912948c9855e7f83 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:28:27 -0500 Subject: [PATCH 1649/2214] Bump bundled esphome-device-builder to 1.0.3 (#17005) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c7634cf1c8..8e7580490f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 RUN \ platformio settings set enable_telemetry No \ From 009c6dd9957df088017cae2e34617728f794d1d6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:38:23 -0500 Subject: [PATCH 1650/2214] Bump bundled esphome-device-builder to 1.0.4 (#17013) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e7580490f..185a0740ed 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 RUN \ platformio settings set enable_telemetry No \ From 40d0cbee3fea57b43eb3dfd7d8181588b1efef56 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:07:36 -0500 Subject: [PATCH 1651/2214] Bump bundled esphome-device-builder to 1.0.5 (#17014) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 185a0740ed..980791013f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.5 RUN \ platformio settings set enable_telemetry No \ From 900e0b8566a535b58a4ce14a9b07c720bcedf71f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:50:56 -0500 Subject: [PATCH 1652/2214] Bump bundled esphome-device-builder to 1.0.6 (#17016) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 980791013f..706dd93e67 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 RUN \ platformio settings set enable_telemetry No \ From 0f5defa67eebccbbca0b997d8e4fd3ec0192b8a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:51:08 -0500 Subject: [PATCH 1653/2214] Bump tzlocal from 5.3.1 to 5.4.3 (#17015) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a825cd9bff..4ef3df60ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 tornado==6.5.7 -tzlocal==5.3.1 # from time +tzlocal==5.4.3 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 From 24e276c3f96a8a75eb1ca795e56eca8d90332625 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:53:11 -0400 Subject: [PATCH 1654/2214] [esp32_hosted] Bump esp_hosted to 2.12.9 (#16999) --- .clang-tidy.hash | 2 +- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7a3cfc7a03..84daffc69f 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 +72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6 diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 94e20ea6c9..7f420f27d8 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -257,7 +257,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 04220488cc..5f3000e52d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.8 + version: 2.12.9 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 045de436ba8e5c002babc54fc8f42a2ee26cd0aa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:46 -0400 Subject: [PATCH 1655/2214] [ota] Scale ESP-IDF OTA erase watchdog to image size (#16998) --- esphome/components/ota/ota_backend_esp_idf.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ade726da1f..ac765d8018 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - watchdog::WatchdogManager watchdog(15000); + // esp_ota_begin() erases the destination region, which blocks loopTask and + // scales with the erase size -- a fixed watchdog overruns on large OTA slots. + // An unknown size (0, e.g. web_server uploads) erases the whole partition, so + // budget against the bytes actually erased. ~10ms/KiB (conservative + // ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still + // resets rather than hanging forever. + size_t erase_size = image_size; + if (erase_size == 0 || erase_size > this->partition_->size) { + erase_size = this->partition_->size; + } + const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10; + watchdog::WatchdogManager watchdog(erase_budget_ms); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); if (err != ESP_OK) { From 41f7f8cccb143b8c46ef1cd8d90c60184150f910 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:28:27 -0500 Subject: [PATCH 1656/2214] Bump bundled esphome-device-builder to 1.0.3 (#17005) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c7634cf1c8..8e7580490f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 RUN \ platformio settings set enable_telemetry No \ From cdd2bfbc609ec3d1cafb2a87a6340458f3a06b6b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:38:23 -0500 Subject: [PATCH 1657/2214] Bump bundled esphome-device-builder to 1.0.4 (#17013) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e7580490f..185a0740ed 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 RUN \ platformio settings set enable_telemetry No \ From 7ab95ddcb1668db200ab76d5cfff39271330a0ad Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:50:56 -0500 Subject: [PATCH 1658/2214] Bump bundled esphome-device-builder to 1.0.6 (#17016) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 185a0740ed..706dd93e67 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 RUN \ platformio settings set enable_telemetry No \ From db6b9166f457ecb9041a85212cfbe425ec423272 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:20:15 +1200 Subject: [PATCH 1659/2214] Bump version to 2026.6.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index c94ea34387..aab6c1000d 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0b3 +PROJECT_NUMBER = 2026.6.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 bf770ae5b5..1386565d78 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0b3" +__version__ = "2026.6.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6d9490b5a361459c1f5b1009e372a46a310fed9b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:53:10 -0500 Subject: [PATCH 1660/2214] Bump bundled esphome-device-builder to 1.0.7 (#17018) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 706dd93e67..b48ba64aa8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 RUN \ platformio settings set enable_telemetry No \ From 9e7b3e033084fe5cdd29d42c7439347394053678 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:18:37 +1200 Subject: [PATCH 1661/2214] Bump version to 2026.6.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index aab6c1000d..56879237d4 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0b4 +PROJECT_NUMBER = 2026.6.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 1386565d78..c045e452f7 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0b4" +__version__ = "2026.6.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ae7c800de826aee6a0a8aa548c7c93c96dd484a8 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:32:37 -0500 Subject: [PATCH 1662/2214] Bump bundled esphome-device-builder to 1.0.8 (#17020) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b48ba64aa8..c199f2edbd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 RUN \ platformio settings set enable_telemetry No \ From 77a99bceb2739a3cd8e857e705fc9d22299c8ed9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:38:39 -0500 Subject: [PATCH 1663/2214] Bump bundled esphome-device-builder to 1.0.9 (#17021) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c199f2edbd..18a9903735 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 RUN \ platformio settings set enable_telemetry No \ From 9ac22f924405223df483927c5d9ab4b021e99db0 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:53:10 -0500 Subject: [PATCH 1664/2214] Bump bundled esphome-device-builder to 1.0.7 (#17018) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 706dd93e67..b48ba64aa8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 RUN \ platformio settings set enable_telemetry No \ From c4076ec8a99c5781065477be90e7153fbd74f3dc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:32:37 -0500 Subject: [PATCH 1665/2214] Bump bundled esphome-device-builder to 1.0.8 (#17020) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b48ba64aa8..c199f2edbd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 RUN \ platformio settings set enable_telemetry No \ From d934fb3910fc4d96806f4f29b3aff533b498a472 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:38:39 -0500 Subject: [PATCH 1666/2214] Bump bundled esphome-device-builder to 1.0.9 (#17021) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c199f2edbd..18a9903735 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 RUN \ platformio settings set enable_telemetry No \ From 7cb6cf2f2a46436117c370ce303dda2055fc6e38 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:12:39 -0400 Subject: [PATCH 1667/2214] [ci] Replace clang-tidy hash with direct config-file diff check (#17019) --- .clang-tidy.hash | 1 - .github/workflows/ci-clang-tidy-hash.yml | 76 ----- .github/workflows/ci.yml | 38 +-- .pre-commit-config.yaml | 9 +- script/ci-custom.py | 2 +- script/clang_tidy_hash.py | 208 +++----------- script/determine-jobs.py | 65 ++--- tests/script/test_clang_tidy_hash.py | 351 +++-------------------- tests/script/test_determine_jobs.py | 48 ++-- 9 files changed, 124 insertions(+), 674 deletions(-) delete mode 100644 .clang-tidy.hash delete mode 100644 .github/workflows/ci-clang-tidy-hash.yml mode change 100755 => 100644 script/clang_tidy_hash.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash deleted file mode 100644 index 591ce70a62..0000000000 --- a/.clang-tidy.hash +++ /dev/null @@ -1 +0,0 @@ -6765760d573967b853b1f790f0f5478135d12f2b15ffa8bee9b0314090b582ee diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml deleted file mode 100644 index 73c437467b..0000000000 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Clang-tidy Hash CI - -on: - pull_request: - paths: - - ".clang-tidy" - - "platformio.ini" - - "requirements_dev.txt" - - "sdkconfig.defaults" - - ".clang-tidy.hash" - - "script/clang_tidy_hash.py" - - ".github/workflows/ci-clang-tidy-hash.yml" - -permissions: - contents: read # actions/checkout for the PR head - pull-requests: write # pulls.createReview / listReviews / dismissReview when the clang-tidy hash is out of date - -jobs: - verify-hash: - name: Verify clang-tidy hash - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.11" - - - name: Verify hash - run: | - python script/clang_tidy_hash.py --verify - - - if: failure() - name: Show hash details - run: | - python script/clang_tidy_hash.py - echo "## Job Failed" | tee -a $GITHUB_STEP_SUMMARY - echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY - echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY - - - if: failure() && github.event.pull_request.head.repo.full_name == github.repository - name: Request changes - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - await github.rest.pulls.createReview({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - event: 'REQUEST_CHANGES', - body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.' - }) - - - if: success() && github.event.pull_request.head.repo.full_name == github.repository - name: Dismiss review - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - let reviews = await github.rest.pulls.listReviews({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo - }); - for (let review of reviews.data) { - if (review.user.login === 'github-actions[bot]' && review.state === 'CHANGES_REQUESTED') { - await github.rest.pulls.dismissReview({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - review_id: review.id, - message: 'Clang-tidy hash now matches configuration.' - }); - } - } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deeec72095..1b1032bcde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -537,15 +537,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -607,15 +604,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -691,15 +685,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -779,15 +770,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -1049,7 +1037,7 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache env: - SKIP: pylint,clang-tidy-hash,ci-custom + SKIP: pylint,ci-custom - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b6278e6b5..ba74aff07c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ ci: autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit # Skip hooks that have issues in pre-commit CI environment - skip: [pylint, clang-tidy-hash] + skip: [pylint] repos: - repo: https://github.com/astral-sh/ruff-pre-commit @@ -59,13 +59,6 @@ repos: language: system types: [python] files: ^esphome/.+\.py$ - - id: clang-tidy-hash - name: Update clang-tidy hash - entry: python script/clang_tidy_hash.py --update-if-changed - language: python - files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt|sdkconfig\.defaults|esphome/idf_component\.yml)$ - pass_filenames: false - additional_dependencies: [] - id: ci-custom name: ci-custom entry: python script/run-in-env.py script/ci-custom.py diff --git a/script/ci-custom.py b/script/ci-custom.py index 78ff6cf781..cbc54ce55d 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -276,7 +276,7 @@ def lint_newline(fname, line, col, content): return "File contains Windows newline. Please set your editor to Unix newline mode." -@lint_content_check(exclude=["*.svg", ".clang-tidy.hash"]) +@lint_content_check(exclude=["*.svg"]) def lint_end_newline(fname, content): if content and not content.endswith("\n"): return "File does not end with a newline, please add an empty line at the end of the file." diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py old mode 100755 new mode 100644 index 62f76246b4..00bcaf45b0 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -1,66 +1,32 @@ -#!/usr/bin/env python3 -"""Calculate and manage hash for clang-tidy configuration.""" +"""Files that affect clang-tidy results, and a content hash over them. + +``CLANG_TIDY_GLOBAL_FILES`` (plus ``SDKCONFIG_DEFAULTS_PREFIX``) is the single +source of truth for which files influence clang-tidy output. A change to any of +them can surface warnings in source files a PR didn't touch, so: + +* ``script/determine-jobs.py`` runs a full clang-tidy scan when one changes, and +* ``calculate_clang_tidy_hash()`` folds them into the idedata cache key used by + ``script/helpers.py`` (a content hash, unlike an mtime check, stays correct + across git checkouts). +""" from __future__ import annotations -import argparse import hashlib from pathlib import Path -import re -import sys -# Add the script directory to path to import helpers -script_dir = Path(__file__).parent -sys.path.insert(0, str(script_dir)) +# Root-relative paths whose contents affect clang-tidy results. +CLANG_TIDY_GLOBAL_FILES = ( + ".clang-tidy", + "platformio.ini", + "requirements_dev.txt", + "esphome/idf_component.yml", +) - -def read_file_lines(path: Path) -> list[str]: - """Read lines from a file.""" - with path.open() as f: - return f.readlines() - - -def parse_requirement_line(line: str) -> tuple[str, str] | None: - """Parse a requirement line and return (package, original_line) or None. - - Handles formats like: - - package==1.2.3 - - package==1.2.3 # comment - - package>=1.2.3,<2.0.0 - """ - original_line = line.strip() - - # Extract the part before any comment for parsing - parse_line = line - if "#" in parse_line: - parse_line = parse_line[: parse_line.index("#")] - - parse_line = parse_line.strip() - if not parse_line: - return None - - # Use regex to extract package name - # This matches package names followed by version operators - match = re.match(r"^([a-zA-Z0-9_-]+)(==|>=|<=|>|<|!=|~=)(.+)$", parse_line) - if match: - return (match.group(1), original_line) # Return package name and original line - - return None - - -def get_clang_tidy_version_from_requirements(repo_root: Path | None = None) -> str: - """Get clang-tidy version from requirements_dev.txt""" - repo_root = _ensure_repo_root(repo_root) - requirements_path = repo_root / "requirements_dev.txt" - lines = read_file_lines(requirements_path) - - for line in lines: - parsed = parse_requirement_line(line) - if parsed and parsed[0] == "clang-tidy": - # Return the original line (preserves comments) - return parsed[1] - - return "clang-tidy version not found" +# sdkconfig.defaults and per-target sdkconfig.defaults. files flip the +# CONFIG flags that decide which variant code paths clang-tidy sees. Matched by +# this prefix at the repo root. +SDKCONFIG_DEFAULTS_PREFIX = "sdkconfig.defaults" def read_file_bytes(path: Path) -> bytes: @@ -80,130 +46,20 @@ def _ensure_repo_root(repo_root: Path | None) -> Path: def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: - """Calculate hash of clang-tidy configuration and version""" + """Calculate a hash of the files that affect clang-tidy results.""" repo_root = _ensure_repo_root(repo_root) hasher = hashlib.sha256() - # Hash .clang-tidy file - clang_tidy_path = repo_root / ".clang-tidy" - content = read_file_bytes(clang_tidy_path) - hasher.update(content) + for name in CLANG_TIDY_GLOBAL_FILES: + path = repo_root / name + if path.exists(): + hasher.update(read_file_bytes(path)) - # Hash clang-tidy version from requirements_dev.txt - version = get_clang_tidy_version_from_requirements(repo_root) - hasher.update(version.encode()) - - # Hash the entire platformio.ini file - platformio_path = repo_root / "platformio.ini" - platformio_content = read_file_bytes(platformio_path) - hasher.update(platformio_content) - - # Hash sdkconfig.defaults and any per-target sdkconfig.defaults.: - # the per-target files flip CONFIG flags that change which variant code - # paths clang-tidy sees. Include the filename so a rename is detected. - for sdkconfig_path in sorted(repo_root.glob("sdkconfig.defaults*")): - hasher.update(sdkconfig_path.name.encode()) - hasher.update(read_file_bytes(sdkconfig_path)) - - # Hash esphome/idf_component.yml: its managed deps drive the ESP-IDF - # build's include set, which clang-tidy analyzes. - idf_component_path = repo_root / "esphome" / "idf_component.yml" - if idf_component_path.exists(): - hasher.update(read_file_bytes(idf_component_path)) + # Hash each sdkconfig.defaults* file. Include the filename so adding or + # renaming a per-target variant is detected, not just content edits. + for path in sorted(repo_root.glob(f"{SDKCONFIG_DEFAULTS_PREFIX}*")): + hasher.update(path.name.encode()) + hasher.update(read_file_bytes(path)) return hasher.hexdigest() - - -def read_stored_hash(repo_root: Path | None = None) -> str | None: - """Read the stored hash from file""" - repo_root = _ensure_repo_root(repo_root) - hash_file = repo_root / ".clang-tidy.hash" - if hash_file.exists(): - lines = read_file_lines(hash_file) - return lines[0].strip() if lines else None - return None - - -def write_file_content(path: Path, content: str) -> None: - """Write content to a file.""" - with path.open("w") as f: - f.write(content) - - -def write_hash(hash_value: str, repo_root: Path | None = None) -> None: - """Write hash to file""" - repo_root = _ensure_repo_root(repo_root) - hash_file = repo_root / ".clang-tidy.hash" - # Strip any trailing newlines to ensure consistent formatting - write_file_content(hash_file, hash_value.strip() + "\n") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage clang-tidy configuration hash") - parser.add_argument( - "--check", - action="store_true", - help="Check if full scan needed (exit 0 if needed)", - ) - parser.add_argument("--update", action="store_true", help="Update the hash file") - parser.add_argument( - "--update-if-changed", - action="store_true", - help="Update hash only if configuration changed (for pre-commit)", - ) - parser.add_argument( - "--verify", action="store_true", help="Verify hash matches (for CI)" - ) - - args = parser.parse_args() - - current_hash = calculate_clang_tidy_hash() - stored_hash = read_stored_hash() - - if args.check: - # Check if hash changed OR if .clang-tidy.hash was updated in this PR - # This is used in CI to determine if a full clang-tidy scan is needed - hash_changed = current_hash != stored_hash - - # Lazy import to avoid requiring dependencies that aren't needed for other modes - from helpers import changed_files # noqa: E402 - - hash_file_updated = ".clang-tidy.hash" in changed_files() - - # Exit 0 if full scan needed - sys.exit(0 if (hash_changed or hash_file_updated) else 1) - - elif args.verify: - # Verify that hash file is up to date with current configuration - # This is used in pre-commit and CI checks to ensure hash was updated - if current_hash != stored_hash: - print("ERROR: Clang-tidy configuration has changed but hash not updated!") - print(f"Expected: {current_hash}") - print(f"Found: {stored_hash}") - print("\nPlease run: script/clang_tidy_hash.py --update") - sys.exit(1) - print("Hash verification passed") - - elif args.update: - write_hash(current_hash) - print(f"Hash updated: {current_hash}") - - elif args.update_if_changed: - if current_hash != stored_hash: - write_hash(current_hash) - print(f"Clang-tidy hash updated: {current_hash}") - # Exit 0 so pre-commit can stage the file - sys.exit(0) - else: - print("Clang-tidy hash unchanged") - sys.exit(0) - - else: - print(f"Current hash: {current_hash}") - print(f"Stored hash: {stored_hash}") - print(f"Match: {current_hash == stored_hash}") - - -if __name__ == "__main__": - main() diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 94a78e8423..4904883ca9 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -55,10 +55,10 @@ from functools import cache import json import os from pathlib import Path -import subprocess import sys from typing import Any +from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES, SDKCONFIG_DEFAULTS_PREFIX from helpers import ( CPP_FILE_EXTENSIONS, ESPHOME_TESTS_COMPONENTS_PATH, @@ -280,23 +280,22 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s @cache -def _is_clang_tidy_full_scan() -> bool: - """Check if clang-tidy configuration changed (requires full scan). +def _is_clang_tidy_full_scan(branch: str | None = None) -> bool: + """Check if a clang-tidy-relevant config file changed (requires full scan). + + A change to a file that affects clang-tidy globally can surface warnings in + source files the PR didn't touch, so the entire codebase must be re-scanned. Returns: - True if full scan is needed (hash changed), False otherwise. + True if full scan is needed, False otherwise. """ - try: - result = subprocess.run( - [str(Path(root_path) / "script" / "clang_tidy_hash.py"), "--check"], - capture_output=True, - check=False, - ) - # Exit 0 means hash changed (full scan needed) - return result.returncode == 0 - except Exception: # noqa: BLE001 - # If hash check fails, run full scan to be safe - return True + for file in changed_files(branch): + if file in CLANG_TIDY_GLOBAL_FILES: + return True + # Root-level sdkconfig.defaults and per-target sdkconfig.defaults. + if "/" not in file and file.startswith(SDKCONFIG_DEFAULTS_PREFIX): + return True + return False def should_run_clang_tidy(branch: str | None = None) -> bool: @@ -307,13 +306,12 @@ def should_run_clang_tidy(branch: str | None = None) -> bool: Clang-tidy will run when ANY of the following conditions are met: - 1. Clang-tidy configuration changed - - The hash of .clang-tidy configuration file has changed - - The hash includes the .clang-tidy file, clang-tidy version from requirements_dev.txt, - and relevant platformio.ini sections - - When configuration changes, a full scan is needed to ensure all code complies - with the new rules - - Detected by script/clang_tidy_hash.py --check returning exit code 0 + 1. A clang-tidy-relevant config file changed (full scan needed) + - Any file in CLANG_TIDY_GLOBAL_FILES (.clang-tidy, platformio.ini, + requirements_dev.txt, esphome/idf_component.yml) or a root-level + sdkconfig.defaults* file + - These affect clang-tidy results globally, so all code must be re-checked + to ensure it still complies 2. Any C++ source files changed - Any file with C++ extensions: .cpp, .h, .hpp, .cc, .cxx, .c, .tcc @@ -321,27 +319,14 @@ def should_run_clang_tidy(branch: str | None = None) -> bool: - This ensures all C++ code is checked, including tests, examples, etc. - Examples: esphome/core/component.cpp, tests/custom/my_component.h - 3. The .clang-tidy.hash file itself changed - - This indicates the configuration has been updated and clang-tidy should run - - Ensures that PRs updating the clang-tidy configuration are properly validated - - If the hash check fails for any reason, clang-tidy runs as a safety measure to ensure - code quality is maintained. - Args: branch: Branch to compare against. If None, uses default. Returns: True if clang-tidy should run, False otherwise. """ - # First check if clang-tidy configuration changed (full scan needed) - if _is_clang_tidy_full_scan(): - return True - - # Check if .clang-tidy.hash file itself was changed - # This handles the case where the hash was properly updated in the PR - files = changed_files(branch) - if ".clang-tidy.hash" in files: + # First check if a clang-tidy-relevant config file changed (full scan needed) + if _is_clang_tidy_full_scan(branch): return True return _any_changed_file_endswith(branch, CPP_FILE_EXTENSIONS) @@ -1276,9 +1261,9 @@ def main() -> None: # Determine clang-tidy mode based on actual files that will be checked is_full_scan = False if run_clang_tidy: - # Full scan needed if: hash changed OR core files changed - # (is_core_change is forced True under --force-all) - is_full_scan = _is_clang_tidy_full_scan() or is_core_change + # Full scan needed if: a clang-tidy-relevant config file changed OR + # core files changed (is_core_change is forced True under --force-all) + is_full_scan = _is_clang_tidy_full_scan(args.branch) or is_core_change if is_full_scan: # Full scan checks all files - always use split mode for efficiency diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index 194926a5df..b5a9d8ebe9 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -1,9 +1,7 @@ """Unit tests for script/clang_tidy_hash.py module.""" -import hashlib from pathlib import Path import sys -from unittest.mock import Mock, patch import pytest @@ -11,76 +9,45 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) import clang_tidy_hash # noqa: E402 +from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES # noqa: E402 -@pytest.mark.parametrize( - ("file_content", "expected"), - [ - ( - "clang-tidy==18.1.5 # via -r requirements_dev.in\n", - "clang-tidy==18.1.5 # via -r requirements_dev.in", - ), - ( - "other-package==1.0\nclang-tidy==17.0.0\nmore-packages==2.0\n", - "clang-tidy==17.0.0", - ), - ( - "# comment\nclang-tidy==16.0.0 # some comment\n", - "clang-tidy==16.0.0 # some comment", - ), - ("no-clang-tidy-here==1.0\n", "clang-tidy version not found"), - ], -) -def test_get_clang_tidy_version_from_requirements( - file_content: str, expected: str +def _populate(repo_root: Path) -> None: + """Create every clang-tidy global file plus a base sdkconfig.defaults.""" + for name in CLANG_TIDY_GLOBAL_FILES: + path = repo_root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"contents of {name}\n") + (repo_root / "sdkconfig.defaults").write_text("CONFIG_BASE=y\n") + + +def test_calculate_clang_tidy_hash_is_deterministic(tmp_path: Path) -> None: + """Same inputs must produce the same hash.""" + _populate(tmp_path) + assert clang_tidy_hash.calculate_clang_tidy_hash( + repo_root=tmp_path + ) == clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + + +@pytest.mark.parametrize("filename", CLANG_TIDY_GLOBAL_FILES) +def test_calculate_clang_tidy_hash_changes_with_each_global_file( + tmp_path: Path, filename: str ) -> None: - """Test extracting clang-tidy version from various file formats.""" - # Mock read_file_lines to return our test content - with patch("clang_tidy_hash.read_file_lines") as mock_read: - mock_read.return_value = file_content.splitlines(keepends=True) + """Editing any global file must change the hash.""" + _populate(tmp_path) + before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - result = clang_tidy_hash.get_clang_tidy_version_from_requirements() + (tmp_path / filename).write_text("changed\n") + after = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - assert result == expected - - -def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: - """Test calculating hash from all configuration sources including sdkconfig.defaults.""" - clang_tidy_content = b"Checks: '-*,readability-*'\n" - requirements_version = "clang-tidy==18.1.5" - platformio_content = b"[env:esp32]\nplatform = espressif32\n" - sdkconfig_content = b"" - requirements_content = "clang-tidy==18.1.5\n" - - # Create temporary files - (tmp_path / ".clang-tidy").write_bytes(clang_tidy_content) - (tmp_path / "platformio.ini").write_bytes(platformio_content) - (tmp_path / "sdkconfig.defaults").write_bytes(sdkconfig_content) - (tmp_path / "requirements_dev.txt").write_text(requirements_content) - - # Expected hash calculation - expected_hasher = hashlib.sha256() - expected_hasher.update(clang_tidy_content) - expected_hasher.update(requirements_version.encode()) - expected_hasher.update(platformio_content) - expected_hasher.update(b"sdkconfig.defaults") - expected_hasher.update(sdkconfig_content) - expected_hash = expected_hasher.hexdigest() - - result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - - assert result == expected_hash + assert after != before def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( tmp_path: Path, ) -> None: """Per-target sdkconfig.defaults. files must be part of the hash.""" - (tmp_path / ".clang-tidy").write_bytes(b"Checks: '-*'\n") - (tmp_path / "platformio.ini").write_bytes(b"[env:esp32]\n") - (tmp_path / "requirements_dev.txt").write_text("clang-tidy==18.1.5\n") - (tmp_path / "sdkconfig.defaults").write_bytes(b"CONFIG_BASE=y\n") - + _populate(tmp_path) before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) # Adding a per-target file must change the hash. @@ -95,230 +62,14 @@ def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( assert after_edit != after_add -def test_calculate_clang_tidy_hash_without_sdkconfig(tmp_path: Path) -> None: - """Test calculating hash without sdkconfig.defaults file.""" - clang_tidy_content = b"Checks: '-*,readability-*'\n" - requirements_version = "clang-tidy==18.1.5" - platformio_content = b"[env:esp32]\nplatform = espressif32\n" - requirements_content = "clang-tidy==18.1.5\n" - - # Create temporary files (without sdkconfig.defaults) - (tmp_path / ".clang-tidy").write_bytes(clang_tidy_content) - (tmp_path / "platformio.ini").write_bytes(platformio_content) - (tmp_path / "requirements_dev.txt").write_text(requirements_content) - - # Expected hash calculation (no sdkconfig) - expected_hasher = hashlib.sha256() - expected_hasher.update(clang_tidy_content) - expected_hasher.update(requirements_version.encode()) - expected_hasher.update(platformio_content) - expected_hash = expected_hasher.hexdigest() - +def test_calculate_clang_tidy_hash_handles_missing_optional_files( + tmp_path: Path, +) -> None: + """Hash calculation must not fail when files are absent.""" + # Only .clang-tidy present; everything else missing. + (tmp_path / ".clang-tidy").write_text("Checks: '-*'\n") result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - - assert result == expected_hash - - -def test_read_stored_hash_exists(tmp_path: Path) -> None: - """Test reading hash when file exists.""" - stored_hash = "abc123def456" - hash_file = tmp_path / ".clang-tidy.hash" - hash_file.write_text(f"{stored_hash}\n") - - result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path) - - assert result == stored_hash - - -def test_read_stored_hash_not_exists(tmp_path: Path) -> None: - """Test reading hash when file doesn't exist.""" - result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path) - - assert result is None - - -def test_write_hash(tmp_path: Path) -> None: - """Test writing hash to file.""" - hash_value = "abc123def456" - hash_file = tmp_path / ".clang-tidy.hash" - - clang_tidy_hash.write_hash(hash_value, repo_root=tmp_path) - - assert hash_file.exists() - assert hash_file.read_text() == hash_value.strip() + "\n" - - -@pytest.mark.parametrize( - ("args", "current_hash", "stored_hash", "hash_file_in_changed", "expected_exit"), - [ - (["--check"], "abc123", "abc123", False, 1), # Hashes match, no scan needed - (["--check"], "abc123", "def456", False, 0), # Hashes differ, scan needed - (["--check"], "abc123", None, False, 0), # No stored hash, scan needed - ( - ["--check"], - "abc123", - "abc123", - True, - 0, - ), # Hash file updated in PR, scan needed - ], -) -def test_main_check_mode( - args: list[str], - current_hash: str, - stored_hash: str | None, - hash_file_in_changed: bool, - expected_exit: int, -) -> None: - """Test main function in check mode.""" - changed = [".clang-tidy.hash"] if hash_file_in_changed else [] - - # Create a mock module that can be imported - mock_helpers = Mock() - mock_helpers.changed_files = Mock(return_value=changed) - - with ( - patch("sys.argv", ["clang_tidy_hash.py"] + args), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch.dict("sys.modules", {"helpers": mock_helpers}), - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == expected_exit - - -def test_main_update_mode(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in update mode.""" - current_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - ): - clang_tidy_hash.main() - - mock_write.assert_called_once_with(current_hash) - captured = capsys.readouterr() - assert f"Hash updated: {current_hash}" in captured.out - - -@pytest.mark.parametrize( - ("current_hash", "stored_hash"), - [ - ("abc123", "def456"), # Hash changed, should update - ("abc123", None), # No stored hash, should update - ], -) -def test_main_update_if_changed_mode_update( - current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - """Test main function in update-if-changed mode when update is needed.""" - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 0 - mock_write.assert_called_once_with(current_hash) - captured = capsys.readouterr() - assert "Clang-tidy hash updated" in captured.out - - -def test_main_update_if_changed_mode_no_update( - capsys: pytest.CaptureFixture[str], -) -> None: - """Test main function in update-if-changed mode when no update is needed.""" - current_hash = "abc123" - stored_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 0 - mock_write.assert_not_called() - captured = capsys.readouterr() - assert "Clang-tidy hash unchanged" in captured.out - - -def test_main_verify_mode_success(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in verify mode when verification passes.""" - current_hash = "abc123" - stored_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - ): - clang_tidy_hash.main() - captured = capsys.readouterr() - assert "Hash verification passed" in captured.out - - -@pytest.mark.parametrize( - ("current_hash", "stored_hash"), - [ - ("abc123", "def456"), # Hashes differ, verification fails - ("abc123", None), # No stored hash, verification fails - ], -) -def test_main_verify_mode_failure( - current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - """Test main function in verify mode when verification fails.""" - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "ERROR: Clang-tidy configuration has changed" in captured.out - - -def test_main_default_mode(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in default mode (no arguments).""" - current_hash = "abc123" - stored_hash = "def456" - - with ( - patch("sys.argv", ["clang_tidy_hash.py"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - ): - clang_tidy_hash.main() - - captured = capsys.readouterr() - assert f"Current hash: {current_hash}" in captured.out - assert f"Stored hash: {stored_hash}" in captured.out - assert "Match: False" in captured.out - - -def test_read_file_lines(tmp_path: Path) -> None: - """Test read_file_lines helper function.""" - test_file = tmp_path / "test.txt" - test_content = "line1\nline2\nline3\n" - test_file.write_text(test_content) - - result = clang_tidy_hash.read_file_lines(test_file) - - assert result == ["line1\n", "line2\n", "line3\n"] + assert len(result) == 64 # sha256 hexdigest length def test_read_file_bytes(tmp_path: Path) -> None: @@ -330,35 +81,3 @@ def test_read_file_bytes(tmp_path: Path) -> None: result = clang_tidy_hash.read_file_bytes(test_file) assert result == test_content - - -def test_write_file_content(tmp_path: Path) -> None: - """Test write_file_content helper function.""" - test_file = tmp_path / "test.txt" - test_content = "test content" - - clang_tidy_hash.write_file_content(test_file, test_content) - - assert test_file.read_text() == test_content - - -@pytest.mark.parametrize( - ("line", "expected"), - [ - ("clang-tidy==18.1.5", ("clang-tidy", "clang-tidy==18.1.5")), - ( - "clang-tidy==18.1.5 # comment", - ("clang-tidy", "clang-tidy==18.1.5 # comment"), - ), - ("some-package>=1.0,<2.0", ("some-package", "some-package>=1.0,<2.0")), - ("pkg_with-dashes==1.0", ("pkg_with-dashes", "pkg_with-dashes==1.0")), - ("# just a comment", None), - ("", None), - (" ", None), - ("invalid line without version", None), - ], -) -def test_parse_requirement_line(line: str, expected: tuple[str, str] | None) -> None: - """Test parsing individual requirement lines.""" - result = clang_tidy_hash.parse_requirement_line(line) - assert result == expected diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index a9defcacac..f8f359ee22 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -5,7 +5,7 @@ import importlib.util import json from pathlib import Path import sys -from unittest.mock import Mock, call, patch +from unittest.mock import Mock, patch import pytest @@ -653,52 +653,38 @@ def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None: @pytest.mark.parametrize( - ("check_returncode", "changed_files", "expected_result"), + ("changed_files", "expected_result"), [ - (0, [], True), # Hash changed - need full scan - (1, ["esphome/core.cpp"], True), # C++ file changed - (1, ["README.md"], False), # No C++ files changed - (1, [".clang-tidy.hash"], True), # Hash file itself changed - (1, ["platformio.ini", ".clang-tidy.hash"], True), # Config + hash changed + ([], False), # Nothing changed + (["esphome/core.cpp"], True), # C++ file changed + (["README.md"], False), # No C++ files changed + ([".clang-tidy"], True), # clang-tidy config changed - full scan + (["platformio.ini"], True), # build config changed - full scan + (["requirements_dev.txt"], True), # clang-tidy version source changed + (["sdkconfig.defaults"], True), # sdkconfig changed - full scan + (["sdkconfig.defaults.esp32c6"], True), # per-target sdkconfig changed + (["esphome/idf_component.yml"], True), # idf managed deps changed + (["platformio.ini", "README.md"], True), # config + non-C++ ], ) def test_should_run_clang_tidy( - check_returncode: int, changed_files: list[str], expected_result: bool, ) -> None: """Test should_run_clang_tidy function.""" - with ( - patch.object(determine_jobs, "changed_files", return_value=changed_files), - patch("subprocess.run") as mock_run, - ): - # Test with hash check returning specific code - mock_run.return_value = Mock(returncode=check_returncode) + with patch.object(determine_jobs, "changed_files", return_value=changed_files): result = determine_jobs.should_run_clang_tidy() assert result == expected_result -def test_should_run_clang_tidy_hash_check_exception() -> None: - """Test should_run_clang_tidy when hash check fails with exception.""" - # When hash check fails, clang-tidy should run as a safety measure - with ( - patch.object(determine_jobs, "changed_files", return_value=["README.md"]), - patch("subprocess.run", side_effect=Exception("Hash check failed")), - ): - result = determine_jobs.should_run_clang_tidy() - assert result is True # Fail safe - run clang-tidy - - def test_should_run_clang_tidy_with_branch() -> None: """Test should_run_clang_tidy with branch argument.""" with patch.object(determine_jobs, "changed_files") as mock_changed: mock_changed.return_value = [] - with patch("subprocess.run") as mock_run: - mock_run.return_value = Mock(returncode=1) # Hash unchanged - determine_jobs.should_run_clang_tidy("release") - # Changed files is called twice now - once for hash check, once for .clang-tidy.hash check - assert mock_changed.call_count == 2 - mock_changed.assert_has_calls([call("release"), call("release")]) + determine_jobs.should_run_clang_tidy("release") + # changed_files is queried against the given branch by both the + # config-file full-scan check and the C++ extension check. + mock_changed.assert_called_with("release") @pytest.mark.parametrize( From c9095841ae74cc59093100907b19f29aa3bfab5b Mon Sep 17 00:00:00 2001 From: Petter Ljungqvist Date: Thu, 18 Jun 2026 04:16:28 +0300 Subject: [PATCH 1668/2214] [ufm01] Add UFM-01 ultrasonic flow meter component (#16582) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/ufm01/__init__.py | 40 +++ esphome/components/ufm01/binary_sensor.py | 52 ++++ esphome/components/ufm01/sensor.py | 63 +++++ esphome/components/ufm01/ufm01.cpp | 234 ++++++++++++++++++ esphome/components/ufm01/ufm01.h | 57 +++++ tests/components/ufm01/common.yaml | 30 +++ tests/components/ufm01/test.esp32-idf.yaml | 4 + tests/components/ufm01/test.esp8266-ard.yaml | 4 + tests/components/ufm01/test.rp2040-ard.yaml | 4 + .../common/uart_2400_even/esp32-idf.yaml | 12 + .../common/uart_2400_even/esp8266-ard.yaml | 12 + .../common/uart_2400_even/rp2040-ard.yaml | 12 + 13 files changed, 525 insertions(+) create mode 100644 esphome/components/ufm01/__init__.py create mode 100644 esphome/components/ufm01/binary_sensor.py create mode 100644 esphome/components/ufm01/sensor.py create mode 100644 esphome/components/ufm01/ufm01.cpp create mode 100644 esphome/components/ufm01/ufm01.h create mode 100644 tests/components/ufm01/common.yaml create mode 100644 tests/components/ufm01/test.esp32-idf.yaml create mode 100644 tests/components/ufm01/test.esp8266-ard.yaml create mode 100644 tests/components/ufm01/test.rp2040-ard.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/esp32-idf.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 10128c64e5..3265627c03 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -561,6 +561,7 @@ esphome/components/uart/packet_transport/* @clydebarrow esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli +esphome/components/ufm01/* @ljungqvist esphome/components/ultrasonic/* @ssieb @swoboda1337 esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py new file mode 100644 index 0000000000..51cf3cfd91 --- /dev/null +++ b/esphome/components/ufm01/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@ljungqvist"] + +MULTI_CONF = True + +DEPENDENCIES = ["uart"] + +ufm01_ns = cg.esphome_ns.namespace("ufm01") +UFM01Component = ufm01_ns.class_("UFM01Component", uart.UARTDevice, cg.Component) + +CONF_UFM01_ID = "ufm01_id" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(UFM01Component), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "ufm01", + require_tx=True, + require_rx=True, + baud_rate=2400, + parity="EVEN", + stop_bits=1, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/ufm01/binary_sensor.py b/esphome/components/ufm01/binary_sensor.py new file mode 100644 index 0000000000..92ae585d96 --- /dev/null +++ b/esphome/components/ufm01/binary_sensor.py @@ -0,0 +1,52 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_UFC_CHIP_ERROR = "ufc_chip_error" +CONF_FLOW_DIRECTION_WRONG = "flow_direction_wrong" +CONF_EMPTY_TUBE = "empty_tube" +CONF_FLOW_RATE_OUT_OF_RANGE = "flow_rate_out_of_range" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_UFC_CHIP_ERROR): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, device_class=DEVICE_CLASS_PROBLEM + ), + cv.Optional(CONF_FLOW_DIRECTION_WRONG): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), + cv.Optional(CONF_EMPTY_TUBE): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), + cv.Optional(CONF_FLOW_RATE_OUT_OF_RANGE): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), +} + + +async def to_code(config): + ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + + if ufc_chip_error_config := config.get(CONF_UFC_CHIP_ERROR): + sens = await binary_sensor.new_binary_sensor(ufc_chip_error_config) + cg.add(ufm01_component.set_ufc_chip_error_binary_sensor(sens)) + + if flow_direction_wrong_config := config.get(CONF_FLOW_DIRECTION_WRONG): + sens = await binary_sensor.new_binary_sensor(flow_direction_wrong_config) + cg.add(ufm01_component.set_flow_direction_wrong_binary_sensor(sens)) + + if empty_tube_config := config.get(CONF_EMPTY_TUBE): + sens = await binary_sensor.new_binary_sensor(empty_tube_config) + cg.add(ufm01_component.set_empty_tube_binary_sensor(sens)) + + if flow_rate_out_of_range_config := config.get(CONF_FLOW_RATE_OUT_OF_RANGE): + sens = await binary_sensor.new_binary_sensor(flow_rate_out_of_range_config) + cg.add(ufm01_component.set_flow_rate_out_of_range_binary_sensor(sens)) diff --git a/esphome/components/ufm01/sensor.py b/esphome/components/ufm01/sensor.py new file mode 100644 index 0000000000..4dcd7ceebe --- /dev/null +++ b/esphome/components/ufm01/sensor.py @@ -0,0 +1,63 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_FLOW, + CONF_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLUME_FLOW_RATE, + DEVICE_CLASS_WATER, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_CUBIC_METER_PER_HOUR, + UNIT_LITRE, +) + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_ACCUMULATED_FLOW = "accumulated_flow" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_ACCUMULATED_FLOW): sensor.sensor_schema( + unit_of_measurement=UNIT_LITRE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_WATER, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_FLOW): sensor.sensor_schema( + unit_of_measurement=UNIT_CUBIC_METER_PER_HOUR, + accuracy_decimals=5, + device_class=DEVICE_CLASS_VOLUME_FLOW_RATE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:waves-arrow-right", + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:thermometer-water", + ), + } +) + + +async def to_code(config): + ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + + if CONF_ACCUMULATED_FLOW in config: + sens = await sensor.new_sensor(config[CONF_ACCUMULATED_FLOW]) + cg.add(ufm01_component.set_accumulated_flow_sensor(sens)) + + if CONF_FLOW in config: + sens = await sensor.new_sensor(config[CONF_FLOW]) + cg.add(ufm01_component.set_flow_sensor(sens)) + + if CONF_TEMPERATURE in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) + cg.add(ufm01_component.set_temperature_sensor(sens)) diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp new file mode 100644 index 0000000000..1380c34284 --- /dev/null +++ b/esphome/components/ufm01/ufm01.cpp @@ -0,0 +1,234 @@ +#include "ufm01.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ufm01 { + +static const char *const TAG = "ufm01"; + +static constexpr uint8_t COMMAND_ACK = 0xE5; +static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 200; + +static constexpr float L_PER_M3 = 1000.0f; +static constexpr float M3_PER_L = 1.0f / L_PER_M3; + +static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; +static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; + +// Active-mode frame layout (datasheet Table 7) +static constexpr size_t FRAME_CHECKSUM_INDEX = 30; +static constexpr size_t FRAME_STOP_INDEX = 31; +static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; +static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; +static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; +static constexpr uint8_t FRAME_INDEX_TEMP_FLAG = 24; +static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; +static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; +static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; + +// Measurement decoding +static constexpr uint8_t FRAME_ACC_FLOW_FLAG_INDEX = 8; +static constexpr uint8_t ACC_FLOW_M3_FLAG = 0x1A; +static constexpr uint8_t FRAME_FLOW_SIGN_INDEX = 20; +static constexpr uint8_t FLOW_NEGATIVE_SIGN = 0x80; + +// Status bytes (datasheet ST1 / ST2) +static constexpr uint8_t FRAME_ST1_INDEX = 28; +static constexpr uint8_t FRAME_ST2_INDEX = 29; +static constexpr uint8_t ST1_EMPTY_TUBE_MASK = 0x20; +static constexpr uint8_t ST2_UFC_ERROR_MASK = 0x20; +static constexpr uint8_t ST2_FLOW_DIRECTION_WRONG_MASK = 0x08; +static constexpr uint8_t ST2_FLOW_RATE_OUT_OF_RANGE_MASK = 0x04; + +static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } + +static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { + if (data[index] == expected) + return true; + ESP_LOGW(TAG, "%s (byte %zu) - expected 0x%02X, but was 0x%02X", name, index, expected, data[index]); + return false; +} + +static bool validate_data(uint8_t data[FRAME_SIZE]) { + uint8_t sum = 0; + for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) + sum += data[i]; + return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") && + check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") && + check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") && + check_byte(data, FRAME_INDEX_RESERVED_SECTION, FRAME_FLAG_RESERVED_SECTION, "reserved section flag") && + check_byte(data, FRAME_INDEX_TEMP_FLAG, FRAME_FLAG_TEMP, "temperature flag") && + check_byte(data, FRAME_CHECKSUM_INDEX, sum, "checksum") && + check_byte(data, FRAME_STOP_INDEX, FRAME_STOP_BYTE, "stop byte"); +} + +static float read_accumulated_flow(uint8_t data[FRAME_SIZE]) { + return (data[FRAME_ACC_FLOW_FLAG_INDEX] == ACC_FLOW_M3_FLAG ? L_PER_M3 : 1.0f) * + (to_float(data[14]) * 10000000.0f + to_float(data[13]) * 100000.0f + to_float(data[12]) * 1000.0f + + to_float(data[11]) * 10.0f + to_float(data[10]) * 0.1f + to_float(data[9]) * 0.001f); +} + +static float read_flow(uint8_t data[FRAME_SIZE]) { + return (data[FRAME_FLOW_SIGN_INDEX] == FLOW_NEGATIVE_SIGN ? -1.0f : 1.0f) * + (to_float(data[19]) * 10000.0f + to_float(data[18]) * 100.0f + to_float(data[17]) + + to_float(data[16]) * 0.01f) * + M3_PER_L; +} + +static void log_hex(const uint8_t *data, size_t len) { + char hex_buf[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); +} + +static float read_temperature(uint8_t data[FRAME_SIZE]) { + // happens sometimes before getting a real reading + if (data[27] == 0x00 && (data[26] == 0x00 || data[26] == 0x70) && data[25] == 0x00) { + return NAN; + } + return to_float(data[27]) * 100.0f + to_float(data[26]) + to_float(data[25]) * 0.01f; +} + +static bool read_ufc_chip_error(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST2_INDEX] & ST2_UFC_ERROR_MASK; } + +static bool read_flow_direction_wrong(const uint8_t data[FRAME_SIZE]) { + return data[FRAME_ST2_INDEX] & ST2_FLOW_DIRECTION_WRONG_MASK; +} + +static bool read_empty_tube(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST1_INDEX] & ST1_EMPTY_TUBE_MASK; } + +static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { + return data[FRAME_ST2_INDEX] & ST2_FLOW_RATE_OUT_OF_RANGE_MASK; +} + +bool UFM01Component::send_command_(const std::array &command) { + this->write_array(command); + this->flush(); + const uint32_t start = millis(); + while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { + if (this->available()) { + uint8_t byte; + if (this->read_byte(&byte)) { + if (byte == COMMAND_ACK) + return true; + ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); + } + } + delay(1); + } + return false; +} + +bool UFM01Component::reset_device_() { return this->send_command_(RESET_DEVICE); } + +bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEAR_ACCUMULATED_FLOW); } + +bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } + +float UFM01Component::get_setup_priority() const { return setup_priority::IO; } + +void UFM01Component::setup() { + ESP_LOGI(TAG, "Setting up UFM-01..."); + if (!this->set_active_mode_()) { + ESP_LOGW(TAG, "Failed to set active mode (no ACK from device)"); + this->mark_failed(); + } +} + +void UFM01Component::dump_config() { + ESP_LOGCONFIG(TAG, "UFM-01:"); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Accumulated Flow", this->accumulated_flow_sensor_); + LOG_SENSOR(" ", "Flow", this->flow_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "UFC Chip Error", this->ufc_chip_error_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Flow Direction Wrong", this->flow_direction_wrong_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); +#endif + this->check_uart_settings(2400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); + if (this->is_failed()) { + ESP_LOGW(TAG, "Setup failed: active mode not acknowledged by device"); + } +} + +void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { + bool empty_tube = read_empty_tube(data); +#ifdef USE_BINARY_SENSOR + if (this->ufc_chip_error_binary_sensor_ != nullptr) + this->ufc_chip_error_binary_sensor_->publish_state(read_ufc_chip_error(data)); + if (this->flow_direction_wrong_binary_sensor_ != nullptr) + this->flow_direction_wrong_binary_sensor_->publish_state(read_flow_direction_wrong(data)); + if (this->empty_tube_binary_sensor_ != nullptr) + this->empty_tube_binary_sensor_->publish_state(empty_tube); + if (this->flow_rate_out_of_range_binary_sensor_ != nullptr) + this->flow_rate_out_of_range_binary_sensor_->publish_state(read_flow_rate_out_of_range(data)); +#endif + +#ifdef USE_SENSOR + // Total volume remains valid when the tube is dry; flow and temperature are not. + if (this->accumulated_flow_sensor_ != nullptr) + this->accumulated_flow_sensor_->publish_state(read_accumulated_flow(data)); + + if (empty_tube) { + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); + } else { + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(read_flow(data)); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(read_temperature(data)); + } +#endif +} + +void UFM01Component::loop() { + // Drain the UART buffer each loop, reading one byte at a time into the frame + while (this->available()) { + if (!this->read_byte(&this->data_[this->read_index_])) { + ESP_LOGW(TAG, "unable to read byte"); + this->read_index_ = 0; + continue; + } + if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) || + (this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) { + ESP_LOGW(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); + this->read_index_ = 0; + continue; + } + if (++this->read_index_ < static_cast(FRAME_SIZE)) + continue; + + // Full frame received + if (validate_data(this->data_)) { + this->on_data_(this->data_); + this->read_index_ = 0; + continue; + } + + // Invalid frame: try to resync on the next start marker within the buffer + log_hex(this->data_, sizeof(this->data_)); + ESP_LOGE(TAG, "unable to read data"); + for (int32_t i = 2; + i < static_cast(FRAME_STOP_INDEX) && this->read_index_ == static_cast(FRAME_SIZE); ++i) { + if ((this->data_[i] == FRAME_START_BYTE_1) && (this->data_[i + 1] == FRAME_START_BYTE_2)) { + for (int32_t j = i; j < static_cast(FRAME_SIZE); ++j) + this->data_[j - i] = this->data_[j]; + this->read_index_ = static_cast(FRAME_SIZE) - i; + } + } + if (this->read_index_ == static_cast(FRAME_SIZE)) + this->read_index_ = 0; + } +} + +} // namespace esphome::ufm01 diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h new file mode 100644 index 0000000000..e759de9169 --- /dev/null +++ b/esphome/components/ufm01/ufm01.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#include "esphome/components/uart/uart.h" + +#include + +// component API definition at https://www.sciosense.com/wp-content/uploads/2025/06/UFM-01-Datasheet-1.pdf + +namespace esphome::ufm01 { + +static constexpr size_t FRAME_SIZE = 32; + +class UFM01Component : public uart::UARTDevice, public Component { +#ifdef USE_SENSOR + SUB_SENSOR(accumulated_flow) + SUB_SENSOR(flow) + SUB_SENSOR(temperature) +#endif + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(ufc_chip_error) + SUB_BINARY_SENSOR(flow_direction_wrong) + SUB_BINARY_SENSOR(empty_tube) + SUB_BINARY_SENSOR(flow_rate_out_of_range) +#endif + + public: + void setup() override; + + void dump_config() override; + + void loop() override; + + float get_setup_priority() const override; + + protected: + bool clear_accumulated_flow_(); + bool set_active_mode_(); + bool reset_device_(); + + private: + bool send_command_(const std::array &command); + + int32_t read_index_ = 0; + uint8_t data_[FRAME_SIZE]; + void on_data_(uint8_t data[FRAME_SIZE]); +}; + +} // namespace esphome::ufm01 diff --git a/tests/components/ufm01/common.yaml b/tests/components/ufm01/common.yaml new file mode 100644 index 0000000000..c818dc2965 --- /dev/null +++ b/tests/components/ufm01/common.yaml @@ -0,0 +1,30 @@ +ufm01: + id: ufm01_component + uart_id: uart_bus + +sensor: + - platform: ufm01 + accumulated_flow: + id: accumulated_flow + name: "Accumulated flow" + flow: + id: flow + name: "Flow" + temperature: + id: temperature + name: "Temperature" + +binary_sensor: + - platform: ufm01 + ufc_chip_error: + id: ufc_chip_error + name: "UFC chip error" + flow_direction_wrong: + id: flow_direction_wrong + name: "Flow direction wrong" + empty_tube: + id: empty_tube + name: "Empty tube" + flow_rate_out_of_range: + id: flow_rate_out_of_range + name: "Flow rate out of range" diff --git a/tests/components/ufm01/test.esp32-idf.yaml b/tests/components/ufm01/test.esp32-idf.yaml new file mode 100644 index 0000000000..34041cc223 --- /dev/null +++ b/tests/components/ufm01/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/ufm01/test.esp8266-ard.yaml b/tests/components/ufm01/test.esp8266-ard.yaml new file mode 100644 index 0000000000..195f4b41b5 --- /dev/null +++ b/tests/components/ufm01/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ufm01/test.rp2040-ard.yaml b/tests/components/ufm01/test.rp2040-ard.yaml new file mode 100644 index 0000000000..13b3284fe3 --- /dev/null +++ b/tests/components/ufm01/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml b/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml new file mode 100644 index 0000000000..92a65c463e --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP32 IDF tests - 2400 baud, EVEN parity + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml b/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml new file mode 100644 index 0000000000..00333867db --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP8266 Arduino tests - 2400 baud even parity + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml b/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml new file mode 100644 index 0000000000..c915e7846d --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for RP2040 Arduino tests - 2400 baud even parity + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN From e3f164fff20edb2e3aa7e4b9a3a4330d4c41fbec Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 18 Jun 2026 03:17:07 +0200 Subject: [PATCH 1669/2214] [nrf52] add support for native builds (#16898) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/build_gen/espidf.py | 12 +-- esphome/components/nrf52/__init__.py | 98 +++++++++++++++++- esphome/components/nrf52/framework.py | 114 ++++++++++++++++++--- esphome/components/nrf52/requirements.txt | 3 + esphome/framework_helpers.py | 19 ++++ tests/unit_tests/build_gen/test_espidf.py | 48 +++++++++ tests/unit_tests/test_framework_helpers.py | 83 +++++++++++++++ tests/unit_tests/test_nrf52_framework.py | 33 +++--- 8 files changed, 369 insertions(+), 41 deletions(-) create mode 100644 esphome/components/nrf52/requirements.txt diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 9e11d785c0..dec6ea04de 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,6 +6,7 @@ from pathlib import Path from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE +from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags from esphome.helpers import mkdir_p, write_file_if_changed # Replaces the IDF default C++ standard (-std=gnu++2b appended to @@ -84,12 +85,7 @@ def get_project_cmakelists(minimal: bool = False) -> str: # esphome__micro-mp3) rather than just src/. Required so suppressions # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in # third-party components we don't author. - project_compile_opts = [ - flag - for flag in sorted(CORE.build_flags) - if flag.startswith("-D") - or (flag.startswith("-W") and not flag.startswith("-Wl,")) - ] + project_compile_opts = get_project_compile_flags() extra_compile_options = "\n".join( f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)' for flag in project_compile_opts @@ -188,8 +184,8 @@ def get_component_cmakelists() -> str: # Extract linker options (-Wl, flags). Compile flags (-D, -W) are # emitted project-wide via idf_build_set_property in # get_project_cmakelists so they reach every component, not just src/. - link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] - link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else "" + link_opts = get_project_link_flags() + link_opts_str = "\n ".join(link_opts) if link_opts else "" return f"""\ # Auto-generated by ESPHome diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 56367d0b26..d87318b03d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -52,6 +52,11 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.core.config import BOARD_MAX_LENGTH import esphome.final_validate as fv +from esphome.framework_helpers import ( + get_project_compile_flags, + get_project_link_flags, + run_command_ok, +) from esphome.helpers import write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -63,7 +68,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) -from .framework import check_and_install +from .framework import check_and_install, get_build_env, get_build_paths # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -99,9 +104,6 @@ FAKE_BOARD_MANIFEST = """ def set_core_data(config: ConfigType) -> ConfigType: - # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. - if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) zephyr_set_core_data(config) CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR @@ -112,6 +114,12 @@ def set_core_data(config: ConfigType) -> ConfigType: return config +def _resolve_toolchain(config: ConfigType) -> ConfigType: + if CORE.toolchain is None: + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + return config + + def set_framework(config: ConfigType) -> ConfigType: if CONF_VERSION not in config[CONF_FRAMEWORK]: default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" @@ -147,6 +155,12 @@ BOOTLOADERS = [ ] +def _validate_toolchain(value) -> Toolchain: + return Toolchain( + cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value) + ) + + def _detect_bootloader(config: ConfigType) -> ConfigType: """Detect the bootloader for the given board.""" config = config.copy() @@ -233,9 +247,11 @@ CONFIG_SCHEMA = cv.All( ), } ), + cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm), } ), + _resolve_toolchain, set_framework, ) @@ -565,6 +581,47 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False +def _generate_cmake_lists() -> None: + compile_flags = get_project_compile_flags() + link_flags = get_project_link_flags() + + lines = [ + "cmake_minimum_required(VERSION 3.20.0)", + "", + 'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")', + "", + "find_package(Zephyr REQUIRED)", + "", + f"project({CORE.name})", + "", + 'file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/../src/*.cpp" "${CMAKE_CURRENT_LIST_DIR}/../src/*.c")', + "", + "target_sources(app PRIVATE ${APP_SOURCES})", + 'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")', + ] + + if compile_flags: + lines += [ + "", + "target_compile_options(app PRIVATE", + *[f' "{flag}"' for flag in compile_flags], + ")", + ] + + if link_flags: + lines += [ + "", + "zephyr_ld_options(", + *[f' "{flag}"' for flag in link_flags], + ")", + ] + + write_file_if_changed( + CORE.relative_build_path("zephyr", "CMakeLists.txt"), + "\n".join(lines) + "\n", + ) + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: return False @@ -574,4 +631,35 @@ def run_compile(args, config: ConfigType) -> bool: "Supported toolchains are 'platformio' and 'sdk-nrf'." ) check_and_install() - raise EsphomeError("Native build for nRF52 is not implemented yet") + + paths = get_build_paths() + env = get_build_env() + + _generate_cmake_lists() + + board = zephyr_data()[KEY_BOARD] + build_dir = CORE.relative_pioenvs_path(CORE.name) + source_dir = CORE.relative_build_path("zephyr") + + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "build", + "--pristine=auto", + "-b", + board, + "-d", + str(build_dir), + str(source_dir), + ] + + if not run_command_ok( + west_cmd, + env=env, + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 native build failed") + + return True diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 607ad0c7ed..a35ba3ef85 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -18,7 +18,7 @@ from esphome.framework_helpers import ( _LOGGER = logging.getLogger(__name__) -_WEST_VERSION = "1.5.0" +_REQUIREMENTS = Path(__file__).parent / "requirements.txt" _TOOLCHAIN_VERSION = "0.17.4" SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( @@ -28,6 +28,15 @@ SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( ) ) +# Minimal SDK provides cmake discovery files (Zephyr-sdkConfig.cmake) and +# host tools (dtc etc.) required by the Zephyr cmake build system. +SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str( + os.environ.get( + "ESPHOME_SDK_NG_MINIMAL_MIRRORS", + "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/zephyr-sdk-{VERSION}_{sysname}-{machine}_minimal.{extension}", + ) +) + def _get_tools_path() -> Path: return CORE.data_dir / "sdk-nrf" @@ -38,11 +47,11 @@ def _get_python_env_path(version: str) -> Path: def _get_framework_path(version: str) -> Path: - return _get_tools_path() / "frameworks" / f"{version}" + return _get_tools_path() / "frameworks" / version def _get_toolchain_path(version: str) -> Path: - return _get_tools_path() / "toolchains" / f"{version}" + return _get_tools_path() / "toolchains" / version # onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. @@ -95,29 +104,68 @@ def _get_toolchain_platform_info() -> tuple[str, str, str]: return sysname, machine, extension -def check_and_install() -> None: +def _get_version_str() -> str: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - version = f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + return f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + + +def get_build_paths() -> dict: + version = _get_version_str() + return { + "python_executable": get_python_env_executable_path( + _get_python_env_path(version), "python" + ), + "framework_path": _get_framework_path(version), + } + + +def get_build_env() -> dict: + version = _get_version_str() + venv_bin_dir = get_python_env_executable_path( + _get_python_env_path(version), "python" + ).parent + env = os.environ.copy() + env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") + env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") + env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(_TOOLCHAIN_VERSION) / "cmake") + return env + + +def check_and_install() -> None: + version = _get_version_str() python_env_path = _get_python_env_path(version) env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" - install_venv = not sentinel.exists() + install_venv = ( + not sentinel.exists() + or _REQUIREMENTS.stat().st_mtime > sentinel.stat().st_mtime + ) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") - create_venv(python_env_path, msg=f"{version}") + create_venv(python_env_path, msg=version) _install_sitecustomize(python_env_path) - _LOGGER.info("Installing west %s ...", _WEST_VERSION) - cmd = [str(env_python_path), "-m", "pip", "install", f"west=={_WEST_VERSION}"] + _LOGGER.info("Installing requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(_REQUIREMENTS), + ] if not run_command_ok(cmd): - raise EsphomeError(f"Install west for {version} Python environment failure") + raise EsphomeError( + f"Install requirements for {version} Python environment failure" + ) sentinel.touch() framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" - if install_venv or not sentinel.exists(): + zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" + if not sentinel.exists() or not zephyr_reqs.exists(): rmdir(framework_path, msg=f"Clean up {version} framework environment") _LOGGER.info("Initializing nRF Connect SDK %s ...", version) cmd = [ @@ -128,7 +176,7 @@ def check_and_install() -> None: "-m", "https://github.com/nrfconnect/sdk-nrf", "--mr", - f"{version}", + version, str(framework_path), ] if not run_command_ok(cmd): @@ -146,17 +194,47 @@ def check_and_install() -> None: raise EsphomeError(f"Can't update nRF Connect SDK {version}") sentinel.touch() + zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" + if ( + install_venv + or not zephyr_sentinel.exists() + or zephyr_reqs.stat().st_mtime > zephyr_sentinel.stat().st_mtime + ): + _LOGGER.info("Installing Zephyr requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(zephyr_reqs), + ] + if not run_command_ok(cmd): + raise EsphomeError(f"Install Zephyr requirements for {version} failure") + zephyr_sentinel.touch() + toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): rmdir( toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" ) + sysname, machine, extension = _get_toolchain_platform_info() + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading Zephyr SDK %s minimal ...", _TOOLCHAIN_VERSION) + download_from_mirrors( + SDK_NG_MINIMAL_MIRRORS, + { + "VERSION": _TOOLCHAIN_VERSION, + "sysname": sysname, + "machine": machine, + "extension": extension, + }, + tmp.file, + ) + archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") with tempfile.NamedTemporaryFile() as tmp: _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) - - sysname, machine, extension = _get_toolchain_platform_info() - download_from_mirrors( SDK_NG_TOOLCHAIN_MIRRORS, { @@ -167,5 +245,9 @@ def check_and_install() -> None: }, tmp.file, ) - archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") + archive_extract_all( + tmp.file, + toolchains_dir / "arm-zephyr-eabi", + progress_header="Extracting", + ) sentinel.touch() diff --git a/esphome/components/nrf52/requirements.txt b/esphome/components/nrf52/requirements.txt new file mode 100644 index 0000000000..250d3a29cf --- /dev/null +++ b/esphome/components/nrf52/requirements.txt @@ -0,0 +1,3 @@ +west==1.5.0 +ninja==1.13.0 +cmake==4.3.2 diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 276dfbbf1c..6bf389240b 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -20,6 +20,25 @@ PathType = str | os.PathLike _LOGGER = logging.getLogger(__name__) +def get_project_link_flags() -> list[str]: + """Return the sorted -Wl, linker flags from the current build.""" + from esphome.core import CORE # local import to avoid circular dependency + + return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,")) + + +def get_project_compile_flags() -> list[str]: + """Return the sorted -D and -W (non-linker) flags from the current build.""" + from esphome.core import CORE # local import to avoid circular dependency + + return [ + flag + for flag in sorted(CORE.build_flags) + if flag.startswith("-D") + or (flag.startswith("-W") and not flag.startswith("-Wl,")) + ] + + def str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index a5c2719f42..0f4444f719 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -136,6 +136,54 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_component_cmakelists_no_link_flags() -> None: + """With no -Wl, flags the target_link_options block is emitted with an empty body.""" + CORE.build_flags = set() + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert "target_link_options(${COMPONENT_LIB} PUBLIC\n \n)" in content + + +def test_get_component_cmakelists_single_link_flag() -> None: + """A single -Wl, flag appears indented inside target_link_options.""" + CORE.build_flags = {"-Wl,--gc-sections"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert ( + "target_link_options(${COMPONENT_LIB} PUBLIC\n -Wl,--gc-sections\n)" + in content + ) + + +def test_get_component_cmakelists_multiple_link_flags_sorted() -> None: + """Multiple -Wl, flags are sorted and joined with the four-space indent.""" + CORE.build_flags = {"-Wl,-z,noexecstack", "-Wl,--gc-sections", "-Wl,-Map=out.map"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + expected = ( + "target_link_options(${COMPONENT_LIB} PUBLIC\n" + " -Wl,--gc-sections\n" + " -Wl,-Map=out.map\n" + " -Wl,-z,noexecstack\n" + ")" + ) + assert expected in content + + +def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> None: + """-D and -W (non-linker) flags must not appear in target_link_options.""" + CORE.build_flags = {"-DFOO", "-Wall", "-Wl,--gc-sections"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert "-DFOO" not in content.split("target_link_options")[1] + assert "-Wall" not in content.split("target_link_options")[1] + assert "-Wl,--gc-sections" in content + + def test_get_project_cmakelists_emits_managed_components_property( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index a8533608c0..f6e783b5e8 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -25,6 +25,8 @@ from esphome.framework_helpers import ( archive_extract_all, create_venv, download_from_mirrors, + get_project_compile_flags, + get_project_link_flags, get_python_env_executable_path, get_system_python_path, rmdir, @@ -952,3 +954,84 @@ class TestSevenZipExtractAll: out.mkdir() archive_extract_all(archive, out) assert (out / "hello.txt").exists() + + +# --------------------------------------------------------------------------- +# get_project_compile_flags / get_project_link_flags +# --------------------------------------------------------------------------- + + +def _make_core(flags: set[str]): + core = MagicMock() + core.build_flags = flags + return core + + +class TestGetProjectCompileFlags: + def test_returns_define_flags(self) -> None: + with patch("esphome.core.CORE", _make_core({"-DFOO", "-DBAR=1"})): + assert get_project_compile_flags() == ["-DBAR=1", "-DFOO"] + + def test_returns_warning_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wno-error", "-Wall"}), + ): + assert get_project_compile_flags() == ["-Wall", "-Wno-error"] + + def test_excludes_linker_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DFOO", "-Wl,--gc-sections", "-Wl,-Map=output.map"}), + ): + assert get_project_compile_flags() == ["-DFOO"] + + def test_excludes_other_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-O2", "-std=gnu++20", "-DFOO"}), + ): + assert get_project_compile_flags() == ["-DFOO"] + + def test_empty_build_flags(self) -> None: + with patch("esphome.core.CORE", _make_core(set())): + assert get_project_compile_flags() == [] + + def test_result_is_sorted(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DZFLAG", "-DAFLAG", "-Wno-unused"}), + ): + result = get_project_compile_flags() + assert result == sorted(result) + + +class TestGetProjectLinkFlags: + def test_returns_linker_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wl,--gc-sections", "-Wl,-Map=output.map"}), + ): + assert get_project_link_flags() == [ + "-Wl,--gc-sections", + "-Wl,-Map=output.map", + ] + + def test_excludes_compile_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DFOO", "-Wall", "-Wl,--gc-sections"}), + ): + assert get_project_link_flags() == ["-Wl,--gc-sections"] + + def test_empty_build_flags(self) -> None: + with patch("esphome.core.CORE", _make_core(set())): + assert get_project_link_flags() == [] + + def test_result_is_sorted(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wl,-z", "-Wl,-a", "-Wl,-m"}), + ): + result = get_project_link_flags() + assert result == sorted(result) diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 9652ad08eb..04c712f0b7 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -58,6 +58,9 @@ def nrf52_dirs(setup_core: Path) -> SimpleNamespace: toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION for d in (python_env, framework, toolchain_dir): d.mkdir(parents=True, exist_ok=True) + zephyr_scripts = framework / "zephyr" / "scripts" + zephyr_scripts.mkdir(parents=True, exist_ok=True) + (zephyr_scripts / "requirements.txt").touch() return SimpleNamespace( python_env=python_env, framework=framework, @@ -102,6 +105,7 @@ class TestCheckAndInstall: ) -> None: """All three sentinels present → nothing downloaded or compiled.""" (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.toolchain / ".ready").touch() @@ -121,11 +125,13 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_called_once() - # pip install west, west init, west update - assert mock_nrf52_ops.run_command_ok.call_count == 3 - mock_nrf52_ops.download_from_mirrors.assert_called_once() - mock_nrf52_ops.archive_extract_all.assert_called_once() + # pip install requirements, west init, west update, pip install zephyr reqs + assert mock_nrf52_ops.run_command_ok.call_count == 4 + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 + assert mock_nrf52_ops.archive_extract_all.call_count == 2 assert (nrf52_dirs.python_env / ".ready").exists() + assert (nrf52_dirs.python_env / ".zephyr_reqs_ready").exists() assert (nrf52_dirs.framework / ".ready").exists() assert (nrf52_dirs.toolchain / ".ready").exists() @@ -140,9 +146,10 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_not_called() - # west init + west update only (no pip install) - assert mock_nrf52_ops.run_command_ok.call_count == 2 - mock_nrf52_ops.download_from_mirrors.assert_called_once() + # west init, west update, pip install zephyr reqs + assert mock_nrf52_ops.run_command_ok.call_count == 3 + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 def test_toolchain_only_missing( self, @@ -151,24 +158,26 @@ class TestCheckAndInstall: ) -> None: """Venv and framework ready → only toolchain downloaded and extracted.""" (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() check_and_install() mock_nrf52_ops.create_venv.assert_not_called() mock_nrf52_ops.run_command_ok.assert_not_called() - mock_nrf52_ops.download_from_mirrors.assert_called_once() - mock_nrf52_ops.archive_extract_all.assert_called_once() + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 + assert mock_nrf52_ops.archive_extract_all.call_count == 2 - def test_west_install_failure_raises( + def test_requirements_install_failure_raises( self, nrf52_dirs: SimpleNamespace, mock_nrf52_ops: SimpleNamespace, ) -> None: - """Failing pip install west raises EsphomeError.""" + """Failing pip install -r requirements.txt raises EsphomeError.""" mock_nrf52_ops.run_command_ok.return_value = False - with pytest.raises(EsphomeError, match="Install west"): + with pytest.raises(EsphomeError, match="Install requirements"): check_and_install() def test_framework_init_failure_raises( From c214a8ce799cfa483eaecbf8cad4dc3d3ceaaf44 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:21:00 +1200 Subject: [PATCH 1670/2214] [core] Add generic component alias infrastructure (#16826) --- esphome/config.py | 102 +++++ esphome/loader.py | 305 +++++++++++++++ tests/unit_tests/test_loader.py | 663 +++++++++++++++++++++++++++++++- 3 files changed, 1068 insertions(+), 2 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 91e6df8bad..33e687137f 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -137,6 +137,96 @@ def _path_begins_with(path: ConfigPath, other: ConfigPath) -> bool: return path[: len(other)] == other +# CORE.data key for the per-alias "already warned this run" dedupe set. +# Cleared between runs because CORE.data is reset; one warning per alias +# per `esphome config|compile|run` invocation is the desired UX. +_ALIAS_WARNED_KEY = "_component_aliases_warned" + + +def _resolve_component_aliases(config: dict[str, Any]) -> None: + """Rewrite legacy top-level keys to their canonical names, in place. + + Looks up each top-level key against the component-alias map built by + :mod:`esphome.loader` (see ``ComponentManifest.aliases``); when a + matching alias is found, the key is moved to its canonical name and a + one-shot deprecation warning is logged (per alias, per run — deduped + via ``CORE.data``). + + Ambiguous configurations raise ``cv.Invalid`` rather than silently + keeping one entry — that would hide a real misconfiguration. Two cases + are rejected: the canonical key together with one of its deprecated + aliases, and two or more different aliases of the same canonical + component. + + The rest of the validator chain (dependency resolution, schema + validation, codegen) sees only canonical names, so component + `DEPENDENCIES = [""]` works regardless of which spelling + the user typed. + """ + alias_meta_map = loader.get_alias_metadata() + if not alias_meta_map: + return + + # Group every legacy alias key present in the config by the canonical + # component it resolves to, preserving config order within each group. + legacy_by_canonical: dict[str, list[str]] = {} + for key in config: + meta = alias_meta_map.get(key) + if meta is not None: + legacy_by_canonical.setdefault(meta.canonical, []).append(key) + + if not legacy_by_canonical: + return + + # Reject ambiguous configurations up front — checking before rewriting + # means a conflict is caught regardless of key order. + for canonical, legacies in legacy_by_canonical.items(): + if canonical in config: + # The canonical key and (at least) one deprecated alias are both + # present. + raise vol.Invalid( + f"Both '{legacies[0]}:' (deprecated alias of '{canonical}:') " + f"and '{canonical}:' are present in the configuration. Remove " + f"the deprecated '{legacies[0]}:' key.", + path=[legacies[0]], + ) + if len(legacies) > 1: + # Several different deprecated aliases of the same component. + listed = ", ".join(f"'{alias}:'" for alias in legacies) + raise vol.Invalid( + f"Multiple deprecated aliases of '{canonical}:' are present " + f"({listed}). Use only '{canonical}:'.", + path=[legacies[0]], + ) + + warned: set[str] = CORE.data.setdefault(_ALIAS_WARNED_KEY, set()) + + # Rebuild in place so each canonical key keeps the legacy key's original + # position — top-level key order matters for some downstream passes + # (e.g. auto-load ordering). A plain `config[canonical] = config.pop(...)` + # would instead move the renamed key to the end. + rewritten: dict[str, Any] = {} + for key, value in config.items(): + meta = alias_meta_map.get(key) + if meta is None: + rewritten[key] = value + continue + rewritten[meta.canonical] = value + if key not in warned: + warned.add(key) + removal = ( + f" Removed in {meta.removal_version}." if meta.removal_version else "" + ) + _LOGGER.warning( + "The '%s:' top-level key is deprecated; rename it to '%s:'.%s", + key, + meta.canonical, + removal, + ) + config.clear() + config.update(rewritten) + + @functools.total_ordering class _ValidationStepTask: def __init__(self, priority: float, id_number: int, step: ConfigValidationStep): @@ -1048,6 +1138,18 @@ def validate_config( substitutions = config.pop(CONF_SUBSTITUTIONS, None) CORE.raw_config = config + # 1.15. Resolve component aliases so legacy top-level keys + # (`rp2040:`, …) route to their canonical component before any + # downstream pass touches the config. Logs a deprecation warning + # per alias; mutates `config` in place. Errors here surface as + # plain config errors and abort further validation. + try: + _resolve_component_aliases(config) + except vol.Invalid as err: + result.update(config) + result.add_error(err) + return result + # 1.2. Resolve !extend and !remove and check for REPLACEME # After this step, there will not be any Extend or Remove values in the config anymore try: diff --git a/esphome/loader.py b/esphome/loader.py index 8823d82fc1..a9287abf86 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -101,6 +101,27 @@ class ComponentManifest: def codeowners(self) -> list[str]: return getattr(self.module, "CODEOWNERS", []) + @property + def aliases(self) -> list[str]: + """Legacy names that should transparently route to this component. + + See the :func:`_build_alias_map` documentation for how aliases are + discovered (AST scan, no execution) and registered both for the YAML + loader (top-level key rename in :mod:`esphome.config`) and for + Python imports (``sys.meta_path`` finder, below). + """ + return getattr(self.module, "ALIASES", []) + + @property + def alias_removal_version(self) -> str | None: + """Optional ESPHome version when the alias warning becomes a hard error. + + Surfaced in the deprecation warning emitted by the YAML pre-pass so + users know how long they have to migrate. ``None`` means the warning + does not mention a specific version. + """ + return getattr(self.module, "ALIAS_REMOVAL_VERSION", None) + @property def instance_type(self) -> "MockObjClass | None": return getattr(self.module, "INSTANCE_TYPE", None) @@ -216,6 +237,17 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: _COMPONENT_CACHE[domain] = manif return manif + # If `domain` is the legacy name of a renamed component, redirect to the + # canonical module so the rest of the loader (and every caller of + # `get_component(legacy)`) transparently sees the new component. + alias_map = _get_alias_map() + if domain in alias_map: + canonical = alias_map[domain] + manif = _lookup_module(canonical, exception) + if manif is not None: + _COMPONENT_CACHE[domain] = manif + return manif + try: module = importlib.import_module(f"esphome.components.{domain}") except ImportError as e: @@ -261,3 +293,276 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non code should never call this. """ _COMPONENT_CACHE[domain] = manifest + + +# --------------------------------------------------------------------------- +# Component aliases (renamed-platform back-compat) +# --------------------------------------------------------------------------- +# +# A component can declare ``ALIASES = ["legacy_name"]`` (and optionally +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two +# integrations are then wired up automatically: +# +# 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) +# intercepts ``esphome.components.``/``....`` +# imports and resolves them against the canonical component so external +# custom components that still import from the old path keep working. +# +# 2. **YAML loader** — ``_lookup_module`` consults the alias map so +# ``get_component("legacy")`` returns the canonical manifest. The +# ``esphome.config`` pre-pass uses the same map to rewrite legacy +# top-level keys in the user's config (with a deprecation warning) so +# dependency checks, schema validation and codegen all see only the +# canonical name. +# +# Both lookups are populated by ``_build_alias_map``, which **AST-parses** +# every component's ``__init__.py`` rather than importing it. That keeps the +# cost low: scanning ~400 components on disk takes ~5 ms instead of the +# multi-second cost of executing every component's import side-effects. + + +_ALIAS_MAP_CACHE: dict[str, str] | None = None +_ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None + + +@dataclass(frozen=True) +class AliasMeta: + """Metadata for a single deprecated alias entry. + + Used by the YAML pre-pass in :mod:`esphome.config` to produce a + deprecation warning citing the canonical name and (optionally) the + removal version declared by the canonical component. + """ + + canonical: str + removal_version: str | None + + +def _ensure_alias_caches() -> None: + """Populate both alias caches from a single directory scan. + + ``_build_alias_map`` returns both maps together, so building them in one + shot avoids scanning every component's ``__init__.py`` twice when a run + needs both the canonical map (loader) and the metadata map (config + pre-pass). + """ + global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE + if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: + _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() + + +def _get_alias_map() -> dict[str, str]: + """Return the legacy-name → canonical-name map, building it lazily.""" + _ensure_alias_caches() + return _ALIAS_MAP_CACHE + + +def get_alias_metadata() -> dict[str, AliasMeta]: + """Return the legacy-name → :class:`AliasMeta` map (cached). + + Used by the YAML pre-pass to format a per-alias deprecation warning. + """ + _ensure_alias_caches() + return _ALIAS_META_CACHE + + +def _build_alias_map() -> tuple[dict[str, str], dict[str, AliasMeta]]: + """Scan every core component dir for ``ALIASES`` declarations. + + Uses :mod:`ast` to read each component's ``__init__.py`` without + executing it — component import side-effects (logger setup, + namespace registration, etc.) shouldn't run just because we're + enumerating aliases. + + Raises if the same alias is claimed by two canonical components, since + silently picking one would cause non-deterministic routing depending on + directory-iteration order. Also raises if an alias shadows an existing + component package: that would hijack a live component domain and, in the + self-alias case (alias == canonical), send ``_lookup_module`` into + infinite recursion redirecting a domain to itself. + """ + import ast + + alias_to_canonical: dict[str, str] = {} + alias_to_meta: dict[str, AliasMeta] = {} + + if not CORE_COMPONENTS_PATH.is_dir(): + return alias_to_canonical, alias_to_meta + + for child in sorted(CORE_COMPONENTS_PATH.iterdir()): + if not child.is_dir(): + continue + init = child / "__init__.py" + if not init.is_file(): + continue + aliases, removal_version = _read_aliases(init, ast) + if not aliases: + continue + canonical = child.name + for alias in aliases: + if (CORE_COMPONENTS_PATH / alias / "__init__.py").is_file(): + from esphome.core import EsphomeError + + raise EsphomeError( + f"Component alias '{alias}' (declared by '{canonical}') " + "shadows an existing component package of the same name. " + "An alias may only name a component that no longer exists." + ) + if alias in alias_to_canonical: + from esphome.core import EsphomeError + + raise EsphomeError( + f"Component alias '{alias}' is declared by both " + f"'{alias_to_canonical[alias]}' and '{canonical}'. " + "Each alias must map to exactly one canonical component." + ) + alias_to_canonical[alias] = canonical + alias_to_meta[alias] = AliasMeta( + canonical=canonical, removal_version=removal_version + ) + return alias_to_canonical, alias_to_meta + + +def _read_aliases( + init_path: Path, ast_module: ModuleType +) -> tuple[list[str], str | None]: + """Extract ``ALIASES`` and ``ALIAS_REMOVAL_VERSION`` from a component + ``__init__.py`` via AST parsing. + + Only handles the simple ``NAME = [str_literal, ...]`` / ``NAME = "..."`` + forms — anything more dynamic (function call, conditional, etc.) is + silently ignored. Components should keep their alias declarations + static so this scanner can see them. + """ + try: + source = init_path.read_text(encoding="utf-8") + except OSError as err: + _LOGGER.warning( + "Could not read %s while scanning for component aliases: %s", + init_path, + err, + ) + return [], None + + # Cheap substring pre-filter: almost no component declares ALIASES, and + # parsing every component __init__.py with ast is comparatively expensive. + # Skip the parse entirely unless the token appears in the file at all. + if "ALIASES" not in source: + return [], None + + try: + tree = ast_module.parse(source) + except SyntaxError as err: + _LOGGER.warning( + "Could not parse %s while scanning for component aliases: %s", + init_path, + err, + ) + return [], None + + aliases: list[str] = [] + removal_version: str | None = None + + for node in tree.body: + if not isinstance(node, ast_module.Assign): + continue + for target in node.targets: + if not isinstance(target, ast_module.Name): + continue + if target.id == "ALIASES" and isinstance(node.value, ast_module.List): + aliases.extend( + elt.value + for elt in node.value.elts + if isinstance(elt, ast_module.Constant) + and isinstance(elt.value, str) + ) + elif ( + target.id == "ALIAS_REMOVAL_VERSION" + and isinstance(node.value, ast_module.Constant) + and isinstance(node.value.value, str) + ): + removal_version = node.value.value + return aliases, removal_version + + +class _AliasFinder(importlib.abc.MetaPathFinder): + """``sys.meta_path`` finder that resolves legacy-component imports. + + Routes ``esphome.components.[.]`` to the canonical + component's module/submodule of the same name, so external code that + still imports ``from esphome.components.rp2040 import boards`` keeps + working without the canonical component having to maintain a shim + package on disk. + + The finder caches the resolved module in ``sys.modules`` under the + legacy name on first lookup, so subsequent imports hit the cache and + skip this finder entirely. + """ + + _PREFIX = "esphome.components." + + def find_spec(self, fullname, path, target=None): # noqa: ARG002 + if not fullname.startswith(self._PREFIX): + return None + # Anything matching the ``esphome.components.`` prefix splits into at + # least three parts, so ``parts[2]`` (the domain) always exists. + parts = fullname.split(".") + domain = parts[2] + alias_map = _get_alias_map() + if domain not in alias_map: + return None + + parts[2] = alias_map[domain] + canonical_fullname = ".".join(parts) + try: + canonical_module = importlib.import_module(canonical_fullname) + except ModuleNotFoundError as err: + # Only treat a missing *canonical target* as "no alias to + # resolve" (let the normal import machinery report it). If some + # other module is missing, the canonical exists but failed to + # import one of its own dependencies — surface that real error + # rather than masking it as an unresolved alias. + if err.name == canonical_fullname: + return None + raise + # Do NOT pre-populate ``sys.modules[fullname]`` here. Python's + # ``_find_spec`` (in importlib._bootstrap) has an optimization that + # detects ``name in sys.modules`` after a finder returns and prefers + # ``sys.modules[name].__spec__`` over the finder's spec — for an + # alias, that's the canonical module's own SourceFileLoader spec, + # which Python then *re-loads*, defeating the aliasing. Letting + # ``_load_unlocked`` populate sys.modules itself (via our + # ``_AliasLoader.create_module``) sidesteps that branch. + return importlib.util.spec_from_loader(fullname, _AliasLoader(canonical_module)) + + +class _AliasLoader(importlib.abc.Loader): + """No-op loader that returns the already-resolved canonical module. + + :class:`_AliasFinder` populates ``sys.modules`` itself; this loader + just satisfies the :mod:`importlib` protocol so Python doesn't try to + re-execute the module. + """ + + def __init__(self, module: ModuleType) -> None: + self._module = module + + def create_module(self, spec): # noqa: ARG002 + return self._module + + def exec_module(self, module): # noqa: ARG002 + # Nothing to execute — the canonical module is already initialized. + return None + + +# Register once at module load. Idempotent: re-installing the finder on +# repeated imports (e.g. by tests that reload `esphome.loader`) is a no-op +# because we check for an existing instance first. +def _install_alias_finder() -> None: + for entry in sys.meta_path: + if isinstance(entry, _AliasFinder): + return + sys.meta_path.append(_AliasFinder()) + + +_install_alias_finder() diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 3fb0eca4a0..42e5203a73 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -1,8 +1,28 @@ """Unit tests for esphome.loader module.""" -from unittest.mock import MagicMock, patch +import ast +import logging +from pathlib import Path +import sys +import textwrap +from types import ModuleType +from unittest.mock import MagicMock, Mock, patch -from esphome.loader import ComponentManifest, _replace_component_manifest, get_component +import pytest +import voluptuous as vol + +from esphome import config as esphome_config, config_validation as cv +from esphome.core import CORE +import esphome.loader as loader_mod +from esphome.loader import ( + AliasMeta, + ComponentManifest, + _AliasFinder, + _build_alias_map, + _read_aliases, + _replace_component_manifest, + get_component, +) from tests.testing_helpers import ComponentManifestOverride # --------------------------------------------------------------------------- @@ -322,3 +342,642 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub names = [r.resource for r in manifest.resources] assert names == ["wake/wake_freertos.cpp"] + + +# --------------------------------------------------------------------------- +# Component aliases (renamed-platform back-compat) +# --------------------------------------------------------------------------- +# +# These tests pin down the substrate behind `ALIASES = [...]` on component +# `__init__.py` files: the AST scanner, the resulting global alias map, the +# Python-import `sys.meta_path` finder, the `get_component` integration, and +# the YAML pre-pass that rewrites legacy top-level keys. +# +# The framework is component-agnostic, so the integration tests inject a +# synthetic alias map (pointing a fake legacy name at the real `esp32` +# component) rather than depending on any specific renamed component. + +# A legacy name that is NOT a real component, used as a synthetic alias. +_FAKE_ALIAS = "esp32_legacy_alias" + + +def _write_component(root: Path, name: str, body: str) -> None: + """Write a fake component package at ``root//__init__.py``.""" + pkg = root / name + pkg.mkdir() + (pkg / "__init__.py").write_text(body) + + +def test_read_aliases_extracts_list_literal(tmp_path: Path) -> None: + """AST scan should pick up ``ALIASES = ["legacy"]`` without executing.""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['legacy_name']\n") + aliases, removal = _read_aliases(init, ast) + assert aliases == ["legacy_name"] + assert removal is None + + +def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None: + """``ALIAS_REMOVAL_VERSION`` should be paired with the alias list.""" + init = tmp_path / "__init__.py" + init.write_text( + textwrap.dedent("""\ + ALIASES = ['old'] + ALIAS_REMOVAL_VERSION = "2027.6.0" + """) + ) + aliases, removal = _read_aliases(init, ast) + assert aliases == ["old"] + assert removal == "2027.6.0" + + +def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None: + """A call-expression / non-literal ALIASES shouldn't surface — the + scanner deliberately ignores anything non-static to keep behavior + predictable (and avoid executing component code).""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = list_helper()\nALIASES = ['caught'] if False else []\n") + aliases, _ = _read_aliases(init, ast) + assert aliases == [] + + +def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> None: + init = tmp_path / "__init__.py" + init.write_text("CODEOWNERS = ['@me']\n") + aliases, removal = _read_aliases(init, ast) + assert aliases == [] + assert removal is None + + +def test_read_aliases_handles_syntax_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A broken __init__.py shouldn't crash the alias scanner — it'll + surface as an ImportError elsewhere, but the scanner logs a warning and + yields nothing so other components keep working. The substring pre-filter + only skips files with no ``ALIASES`` token, so this file (which has one) + still reaches the parse.""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['x']\ndef broken( :\n") + assert _read_aliases(init, ast) == ([], None) + assert "Could not parse" in caplog.text + + +def test_read_aliases_handles_read_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unreadable __init__.py logs a warning and yields nothing rather + than aborting the whole component scan.""" + missing = tmp_path / "nope" / "__init__.py" + assert _read_aliases(missing, ast) == ([], None) + assert "Could not read" in caplog.text + + +def test_build_alias_map_aggregates_components(tmp_path: Path) -> None: + """End-to-end map build over a fake components dir.""" + _write_component(tmp_path, "newcomp", "ALIASES = ['oldcomp']\n") + _write_component(tmp_path, "other", "") + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, meta_map = _build_alias_map() + + assert alias_map == {"oldcomp": "newcomp"} + assert meta_map == {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)} + + +def test_build_alias_map_carries_removal_version(tmp_path: Path) -> None: + _write_component( + tmp_path, + "newcomp", + "ALIASES = ['oldcomp']\nALIAS_REMOVAL_VERSION = '2028.1.0'\n", + ) + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + _, meta_map = _build_alias_map() + + assert meta_map["oldcomp"].removal_version == "2028.1.0" + + +def test_build_alias_map_rejects_duplicate_alias(tmp_path: Path) -> None: + """If two canonical components both claim the same legacy alias, + routing becomes ambiguous — the build must refuse to start so the + conflict surfaces immediately at import time, not later as a + 'mysterious wrong component' bug.""" + _write_component(tmp_path, "comp_a", "ALIASES = ['shared']\n") + _write_component(tmp_path, "comp_b", "ALIASES = ['shared']\n") + + from esphome.core import EsphomeError + + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shared"), + ): + _build_alias_map() + + +def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None: + """If the components directory doesn't exist (unlikely in production, + but possible in some test contexts), we want an empty map rather than + a crash — the rest of the loader can still function.""" + fake = tmp_path / "does-not-exist" + with patch("esphome.loader.CORE_COMPONENTS_PATH", fake): + alias_map, meta_map = _build_alias_map() + assert alias_map == {} + assert meta_map == {} + + +def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None: + """An alias that names an existing component package is refused: it would + hijack a live domain, and a self-alias (alias == canonical) would send + ``_lookup_module`` into infinite recursion.""" + # `newcomp` declares itself as an alias — its own package already exists. + _write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n") + + from esphome.core import EsphomeError + + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shadows an existing component"), + ): + _build_alias_map() + + +# ---- Integration against a synthetic alias map (fake legacy -> esp32) ---- + + +def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None: + """Force the loader's alias map (used by the finder and get_component). + + Patches the lazily-built caches so both ``_get_alias_map`` and the + installed meta-path finder resolve against ``mapping`` regardless of + what the real on-disk scan would produce. + """ + monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping) + + +def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None: + """``get_component()`` should return the canonical manifest — every + caller of the loader (dep checker, schema validator, codegen) hits + the canonical component without knowing about the alias.""" + import esphome.loader as loader_mod + + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None) + + canonical = get_component("esp32") + aliased = get_component(_FAKE_ALIAS) + assert canonical is not None + assert aliased is canonical + + +def test_alias_finder_resolves_top_level_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``import esphome.components.`` resolves to the canonical + module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) + + finder = _AliasFinder() + spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None) + assert spec is not None + + import esphome.components.esp32 + import esphome.components.esp32_legacy_alias + + assert esphome.components.esp32_legacy_alias is esphome.components.esp32 + + +def test_alias_finder_resolves_submodule_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``from esphome.components. import boards`` routes through to + ``esphome.components.esp32.boards`` — same submodule object on both paths. + + The canonical submodule is imported first so its parent module carries + the ``boards`` attribute; ``from import boards`` then resolves + the aliased parent (via the finder) and reads that same attribute, + rather than triggering a fresh file load under the alias name. + ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) + + finder = _AliasFinder() + spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None) + assert spec is not None + + from esphome.components.esp32 import boards as canonical_boards + from esphome.components.esp32_legacy_alias import boards as aliased_boards + + assert aliased_boards is canonical_boards + + +def test_alias_finder_ignores_non_components_path() -> None: + """The finder must scope itself to ``esphome.components.`` — + everything else (other esphome submodules, third-party packages) is + left for the normal import machinery.""" + finder = _AliasFinder() + assert finder.find_spec("esphome.core", None) is None + assert finder.find_spec("os.path", None) is None + # `esphome.components` itself (no domain segment) is not a candidate. + assert finder.find_spec("esphome.components", None) is None + # A real, non-aliased component domain defers to normal import machinery + # (no component declares an alias in this repo, so the live map is empty). + assert finder.find_spec("esphome.components.logger", None) is None + + +# --------------------------------------------------------------------------- +# YAML pre-pass: top-level key rename + centralized deprecation warning +# --------------------------------------------------------------------------- +# +# The companion to the loader-side alias map: ``esphome.config`` runs a +# pre-pass over the user's parsed YAML that rewrites legacy top-level keys +# to their canonical names, surfacing a one-shot deprecation warning. These +# tests inject a synthetic alias-metadata map so the rewrite behavior, the +# warning text, and the both-keys-present conflict can be tested in isolation. + + +def _patch_alias_metadata( + monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta] +) -> None: + monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping) + + +def test_resolve_component_aliases_renames_legacy_key( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A legacy alias key should be renamed to the canonical key and a + deprecation warning citing the removal version logged.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires + config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}} + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert "oldcomp" not in config + assert config["newcomp"] == {"board": "x"} + assert any( + "'oldcomp:' top-level key is deprecated" in record.message + and "rename it to 'newcomp:'" in record.message + and "2027.6.0" in record.message + for record in caplog.records + ) + + +def test_resolve_component_aliases_dedupes_warning_within_a_run( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Schema validators can run twice (auto-load discovery + final pass) + so the rename pass must emit the warning only once per alias per run. + Deduped via ``CORE.data``; cleared between runs.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases({"oldcomp": {"board": "a"}}) + _resolve_component_aliases({"oldcomp": {"board": "b"}}) + + matches = [ + r + for r in caplog.records + if "'oldcomp:' top-level key is deprecated" in r.message + ] + assert len(matches) == 1 + + +def test_resolve_component_aliases_rejects_both_keys_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the user has BOTH legacy and canonical keys, silently dropping + one would hide a real misconfiguration. Raise instead.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}} + with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_rejects_canonical_key_after_legacy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The both-keys conflict must be detected even when the canonical key + appears *after* the legacy key in the config (the up-front conflict + scan, not a position-dependent check).""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}} + with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two different deprecated aliases of the same canonical component is + ambiguous — silently keeping one would hide a misconfiguration.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + { + "oldcomp": AliasMeta(canonical="newcomp", removal_version=None), + "legacycomp": AliasMeta(canonical="newcomp", removal_version=None), + }, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}} + with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_preserves_key_position( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The renamed canonical key keeps the legacy key's original position + rather than being moved to the end of the config.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}} + + _resolve_component_aliases(config) + + assert list(config) == ["esphome", "newcomp", "logger"] + + +def test_resolve_component_aliases_no_op_when_no_legacy_keys( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The pre-pass must be a no-op (no warning, no mutation) for configs + that already use canonical keys.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}} + original = dict(config) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert config == original + assert not any("deprecated" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# ComponentManifest alias properties +# --------------------------------------------------------------------------- + + +def test_component_manifest_alias_properties_default_empty() -> None: + """``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None`` + when the component module declares neither. + + Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the + ``getattr(..., default)`` fallback is actually exercised — a bare mock + auto-creates any attribute on access and would never hit the default.""" + mod = ModuleType("fake_component") + manifest = ComponentManifest(mod) + assert manifest.aliases == [] + assert manifest.alias_removal_version is None + + +def test_component_manifest_alias_properties_read_module_values() -> None: + """The properties surface the module's declared values verbatim.""" + mod = MagicMock() + mod.ALIASES = ["legacy"] + mod.ALIAS_REMOVAL_VERSION = "2027.6.0" + manifest = ComponentManifest(mod) + assert manifest.aliases == ["legacy"] + assert manifest.alias_removal_version == "2027.6.0" + + +# --------------------------------------------------------------------------- +# Real (unpatched) lazy build + cache and remaining scanner branches +# --------------------------------------------------------------------------- + + +def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None: + """Exercise the real lazy build over the actual components dir (no patch): + the first call scans and caches, the second returns the cached object.""" + monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None) + first = loader_mod._get_alias_map() + second = loader_mod._get_alias_map() + assert isinstance(first, dict) + assert first is second # cached, not rebuilt on the second call + + +def test_get_alias_metadata_real_build_and_caches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None) + first = loader_mod.get_alias_metadata() + second = loader_mod.get_alias_metadata() + assert isinstance(first, dict) + assert first is second + + +def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None: + """Loose files and directories without an ``__init__.py`` are ignored; + only real component packages contribute to the map.""" + (tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n") + (tmp_path / "initless").mkdir() # a dir, but no __init__.py + _write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n") + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, _ = _build_alias_map() + + assert alias_map == {"legacy": "realcomp"} + + +def test_read_aliases_ignores_non_assignment_and_complex_targets( + tmp_path: Path, +) -> None: + """Non-assignment statements and assignments to non-Name targets are + skipped; only simple ``NAME = ...`` assignments are read.""" + init = tmp_path / "__init__.py" + init.write_text( + "import os\n" # non-Assign (Import) node -> skipped + "obj.attr = 'v'\n" # Assign with an Attribute target -> skipped + "ALIASES = ['legacy']\n" + ) + aliases, _ = _read_aliases(init, ast) + assert aliases == ["legacy"] + + +# --------------------------------------------------------------------------- +# Finder / loader edge branches +# --------------------------------------------------------------------------- + + +def test_alias_finder_returns_none_when_canonical_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an alias points at a canonical *target* that doesn't exist, the + finder declines (returns None) and lets normal import machinery report + the missing module.""" + _patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"}) + finder = _AliasFinder() + assert finder.find_spec("esphome.components.broken_alias", None) is None + + +def test_alias_finder_reraises_when_canonical_dependency_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the canonical module exists but fails to import one of its own + dependencies, the finder surfaces that real error instead of masking it + as an unresolved alias (which would silently fall through to a confusing + 'no module named ').""" + _patch_alias_map(monkeypatch, {"some_alias": "real_canonical"}) + + def boom(name: str) -> None: + raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep") + + monkeypatch.setattr("esphome.loader.importlib.import_module", boom) + finder = _AliasFinder() + with pytest.raises(ModuleNotFoundError, match="missing_dep"): + finder.find_spec("esphome.components.some_alias", None) + + +def test_install_alias_finder_is_idempotent() -> None: + """The finder is installed once at import; calling the installer again is + a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``).""" + before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] + assert len(before) == 1 # installed at module import time + loader_mod._install_alias_finder() + after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] + assert len(after) == 1 + + +def test_get_component_alias_to_missing_canonical_returns_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an alias resolves to a canonical component that can't be loaded, + ``get_component`` returns None and caches no bogus manifest.""" + _patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"}) + loader_mod._COMPONENT_CACHE.pop("ghost_alias", None) + + assert get_component("ghost_alias") is None + assert "ghost_alias" not in loader_mod._COMPONENT_CACHE + + +# --------------------------------------------------------------------------- +# YAML pre-pass: empty-map fast path + validate_config integration +# --------------------------------------------------------------------------- + + +def test_resolve_component_aliases_noop_when_no_aliases_declared( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When no component declares an alias, the pre-pass returns immediately + without inspecting or mutating the config.""" + from esphome.config import _resolve_component_aliases + + monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map + config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}} + original = dict(config) + _resolve_component_aliases(config) + assert config == original + + +def _default_component_mock() -> Mock: + """A permissive component mock that validates any config (ALLOW_EXTRA).""" + return Mock( + auto_load=[], + is_platform_component=False, + is_platform=False, + multi_conf=False, + multi_conf_no_default=False, + dependencies=[], + conflicts_with=[], + config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA), + ) + + +@pytest.mark.usefixtures("setup_core") +def test_validate_config_renames_alias_key( + mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """End-to-end: a legacy top-level key is renamed to its canonical name + before the rest of ``validate_config`` runs, and validation succeeds. + + A real ``esp32`` target platform is included so ``preload_core_config`` + is satisfied and validation runs to completion (the renamed canonical + key is loaded via the mocked, permissive component).""" + mock_get_component.side_effect = lambda name: _default_component_mock() + monkeypatch.setattr( + "esphome.loader.get_alias_metadata", + lambda: { + "legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0") + }, + ) + CORE.data.pop("_component_aliases_warned", None) + + raw_config = { + "esphome": {"name": "test"}, + "esp32": {"board": "esp32dev"}, + "legacyfoo": {"opt": 1}, + } + result = esphome_config.validate_config(raw_config, {}) + + assert not result.errors, f"unexpected errors: {result.errors}" + assert "newcomp" in result + assert "legacyfoo" not in result + + +@pytest.mark.usefixtures("setup_core") +def test_validate_config_reports_alias_conflict_as_error( + mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """If both the legacy and canonical keys are present, ``validate_config`` + surfaces the conflict as a config error (the ``vol.Invalid`` path).""" + mock_get_component.return_value = _default_component_mock() + monkeypatch.setattr( + "esphome.loader.get_alias_metadata", + lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop("_component_aliases_warned", None) + + raw_config = { + "esphome": {"name": "test"}, + "newcomp": {"opt": 1}, + "legacyfoo": {"opt": 2}, + } + result = esphome_config.validate_config(raw_config, {}) + + assert result.errors + assert "Both 'legacyfoo:'" in str(result.errors) From ac6a0f34ecbaec6217e5701bcfa8b825ed0aa6f8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:45:30 -0400 Subject: [PATCH 1671/2214] [esp32] Make ESP-IDF the default toolchain (#16910) --- esphome/components/esp32/__init__.py | 2 +- .../esp32/config/flash_mode_idf.yaml | 1 + tests/component_tests/esp32/test_esp32.py | 32 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5d4b3b8b47..3ffec6b826 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -964,7 +964,7 @@ def _resolve_toolchain(value: ConfigType) -> ConfigType: # Runs before _detect_variant so downstream validators can rely on # CORE.toolchain instead of re-resolving it from the config dict. if CORE.toolchain is None: - CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF) return value diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml index 7c7f50a439..d12d4a734b 100644 --- a/tests/component_tests/esp32/config/flash_mode_idf.yaml +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -5,5 +5,6 @@ esp32: board: esp32dev flash_mode: qio flash_frequency: 80MHz + toolchain: platformio framework: type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index a8b5720a80..e3311f6860 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -64,6 +64,38 @@ def test_esp32_config( assert VARIANT_FRIENDLY[variant].lower() in config["board"] +@pytest.mark.parametrize( + ("config_toolchain", "expected"), + [ + # No `toolchain:` set -> the new default for esp32. + (None, Toolchain.ESP_IDF), + # An explicit `toolchain:` still wins over the default. + (Toolchain.PLATFORMIO.value, Toolchain.PLATFORMIO), + (Toolchain.ESP_IDF.value, Toolchain.ESP_IDF), + ], +) +def test_esp32_default_toolchain_is_esp_idf( + set_core_config: SetCoreConfigCallable, + config_toolchain: str | None, + expected: Toolchain, +) -> None: + """With no `toolchain:` set (and nothing pinned via the CLI), esp32 resolves + to the ESP-IDF toolchain; an explicit `toolchain:` still wins.""" + set_core_config(PlatformFramework.ESP32_IDF) + + from esphome.components.esp32 import CONFIG_SCHEMA + + # Fresh run: no --toolchain CLI and no prior config pinned CORE.toolchain. + CORE.toolchain = None + config: dict[str, Any] = {"variant": VARIANT_ESP32} + if config_toolchain is not None: + config["toolchain"] = config_toolchain + + CONFIG_SCHEMA(config) + + assert CORE.toolchain == expected + + @pytest.mark.parametrize( ("config", "error_match"), [ From 4b8568e94824341dd49aa8ee05d5f5927f65af9c Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 17 Jun 2026 21:54:29 -0400 Subject: [PATCH 1672/2214] [socket] bugfix Set wake-request gate flag on LwIP socket receive event (#17010) Co-authored-by: Claude Sonnet 4.6 --- esphome/core/lwip_fast_select.c | 7 +- esphome/core/wake/wake_freertos.cpp | 5 ++ esphome/core/wake/wake_host.cpp | 8 ++ .../fixtures/socket_wake_gate_tcp.yaml | 27 +++++++ .../integration/test_socket_wake_gate_tcp.py | 75 +++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 tests/integration/fixtures/socket_wake_gate_tcp.yaml create mode 100644 tests/integration/test_socket_wake_gate_tcp.py diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 36000d4e77..2042c43804 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,8 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +extern void esphome_wake_loop_threadsafe(void); + #ifdef USE_OTA_PLATFORM_ESPHOME static struct netconn *s_ota_listener_conn = NULL; extern void esphome_wake_ota_component_any_context(void); @@ -189,10 +191,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt esphome_wake_ota_component_any_context(); } #endif - TaskHandle_t task = esphome_main_task_handle; - if (task != NULL) { - xTaskNotifyGive(task); - } + esphome_wake_loop_threadsafe(); } } diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp index 0bf700daa8..458ef51f89 100644 --- a/esphome/core/wake/wake_freertos.cpp +++ b/esphome/core/wake/wake_freertos.cpp @@ -30,4 +30,9 @@ void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } } // namespace esphome +extern "C" void esphome_wake_loop_threadsafe() { + esphome::wake_request_set(); + esphome_main_task_notify(); +} + #endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 9d2a650ca2..8cb382a77e 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -123,6 +123,14 @@ void wakeable_delay(uint32_t ms) { if (ms == 0) [[unlikely]] { yield(); } + // A socket woke select() early — open the component-phase gate so the + // owning component's loop() drains the data on this tick rather than + // waiting up to loop_interval_ ms. Idempotent if wake_loop_threadsafe() + // already set the flag (wake socket fired); required when an application + // socket fired and nothing else set the flag. + if (ret > 0) { + wake_request_set(); + } return; } // ret < 0: error (EINTR is normal, anything else is unexpected). diff --git a/tests/integration/fixtures/socket_wake_gate_tcp.yaml b/tests/integration/fixtures/socket_wake_gate_tcp.yaml new file mode 100644 index 0000000000..4dbf89cbf0 --- /dev/null +++ b/tests/integration/fixtures/socket_wake_gate_tcp.yaml @@ -0,0 +1,27 @@ +esphome: + name: socket-wake-gate-tcp + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 2000ms. Without wake_request_set() being + // called when select() returns due to socket data, the component + // phase would be gated for up to 2000ms after a TCP request arrives. + App.set_loop_interval(2000); + # Let boot transients and API handshake settle. + - delay: 500ms + - lambda: |- + ESP_LOGI("test", "BOOT_DONE"); + +host: + +api: + actions: + - action: ping + then: + - logger.log: + format: "PONG" + level: INFO + +logger: + level: INFO diff --git a/tests/integration/test_socket_wake_gate_tcp.py b/tests/integration/test_socket_wake_gate_tcp.py new file mode 100644 index 0000000000..2955d2803a --- /dev/null +++ b/tests/integration/test_socket_wake_gate_tcp.py @@ -0,0 +1,75 @@ +"""Test that a TCP socket receive opens the component-phase gate immediately. + +Regression test for the wake-request flag not being set when select() returns +due to socket data on the host platform (wake_host.cpp wakeable_delay fix). + +The API server's accepted connection sockets use accept_loop_monitored(), so +they are registered with the host select() loop. A service call from the Python +client arrives on that socket. Without the fix, select() returning early did not +set g_wake_requested, so Application::loop()'s Phase B gate stayed closed until +loop_interval_ expired. With the fix, the gate opens immediately. +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_socket_wake_gate_tcp( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """TCP socket receive must open the component-phase gate immediately, + even with loop_interval_ raised to 2000ms.""" + loop = asyncio.get_running_loop() + boot_done: asyncio.Future[None] = loop.create_future() + pong: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "BOOT_DONE" in line and not boot_done.done(): + boot_done.set_result(None) + if "PONG" in line and not pong.done(): + pong.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "socket-wake-gate-tcp" + + try: + await asyncio.wait_for(boot_done, timeout=15.0) + except TimeoutError: + pytest.fail("BOOT_DONE never appeared — device did not complete boot") + + _, services = await client.list_entities_services() + ping_service = next((s for s in services if s.name == "ping"), None) + assert ping_service is not None, "ping service not found" + + # Execute the service and time how long until PONG appears in logs. + # The request bytes arrive on an accept_loop_monitored() TCP socket, + # which is registered with the host select() loop. + t_send = time.monotonic() + await client.execute_service(ping_service, {}) + + try: + await asyncio.wait_for(pong, timeout=5.0) + except TimeoutError: + pytest.fail("PONG never appeared — service did not execute") + + elapsed_ms = (time.monotonic() - t_send) * 1000 + # Without the fix the gate stays closed for up to loop_interval_=2000ms. + # With the fix the gate opens on the next tick; 500ms gives ample CI headroom. + assert elapsed_ms < 500, ( + f"Service response took {elapsed_ms:.0f}ms with loop_interval_=2000ms — " + f"expected < 500ms; without the wake-request fix this would take up to 2000ms" + ) From f76dfd579cbe64e619440e04d70f39cf858edc09 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:58:51 +0100 Subject: [PATCH 1673/2214] [openthread] Add basic Openthread support to Zephyr/nRF52 platform (#16854) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: tomaszduda23 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/openthread/__init__.py | 72 +++++++-- esphome/components/openthread/openthread.cpp | 22 ++- esphome/components/openthread/openthread.h | 3 +- .../components/openthread/openthread_esp.cpp | 2 +- .../openthread/openthread_zephyr.cpp | 141 ++++++++++++++++++ esphome/components/zephyr/__init__.py | 7 +- .../openthread/test.nrf52-adafruit.yaml | 5 + 7 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 esphome/components/openthread/openthread_zephyr.cpp create mode 100644 tests/components/openthread/test.nrf52-adafruit.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index bc1e91d6da..215f921229 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -10,6 +10,8 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage +from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -20,6 +22,7 @@ from esphome.const import ( CONF_OUTPUT_POWER, CONF_USE_ADDRESS, PLATFORM_ESP32, + PlatformFramework, ) from esphome.core import ( CORE, @@ -52,7 +55,6 @@ AUTO_LOAD = ["network"] # Wi-fi / Bluetooth / Thread coexistence isn't implemented at this time # TODO: Doesn't conflict with wifi if you're using another ESP as an RCP (radio coprocessor), but this isn't implemented yet CONFLICTS_WITH = ["wifi"] -DEPENDENCIES = ["esp32"] IDF_TO_OT_LOG_LEVEL = { "NONE": "NONE", @@ -98,9 +100,7 @@ def set_sdkconfig_options(config): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - if tlv := config.get(CONF_TLV): - cg.add_define("USE_OPENTHREAD_TLVS", tlv) - else: + if not config.get(CONF_TLV): if pan_id := config.get(CONF_PAN_ID): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) @@ -128,9 +128,6 @@ def set_sdkconfig_options(config): "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() ) - if config.get(CONF_FORCE_DATASET): - cg.add_define("USE_OPENTHREAD_FORCE_DATASET") - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) @@ -159,6 +156,11 @@ _CONNECTION_SCHEMA = cv.Schema( def _validate(config: ConfigType) -> ConfigType: if CONF_USE_ADDRESS not in config: config[CONF_USE_ADDRESS] = f"{CORE.name}.local" + if CORE.using_zephyr and CONF_TLV not in config: + raise cv.Invalid( + "On nRF52, OpenThread credentials must be provided via 'tlv'. " + "Individual parameters (network_key, pan_id, channel, etc.) are not yet supported on this platform." + ) device_type = config.get(CONF_DEVICE_TYPE) poll_period = config.get(CONF_POLL_PERIOD) if ( @@ -175,11 +177,33 @@ def _validate(config: ConfigType) -> ConfigType: def _require_vfs_select(config): """Register VFS select requirement during config validation.""" - # OpenThread uses esp_vfs_eventfd which requires VFS select support - require_vfs_select() + # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) + if CORE.is_esp32: + require_vfs_select() return config +def _validate_platform(config): + if CORE.using_zephyr: + return config + return only_on_variant( + supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2] + )(config) + + +def _validate_tlv_hex(value): + s = cv.string_strict(value) + if len(s) % 2 != 0: + raise cv.Invalid("TLV must have an even number of hex characters") + try: + raw = bytes.fromhex(s) + except ValueError as e: + raise cv.Invalid(f"TLV must be valid hex: {e}") from e + if len(raw) > 254: # sizeof(otOperationalDatasetTlvs::mTlvs) + raise cv.Invalid(f"TLV too long ({len(raw)} bytes, max 254)") + return s + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -190,7 +214,7 @@ CONFIG_SCHEMA = cv.All( *CONF_DEVICE_TYPES, upper=True ), cv.Optional(CONF_FORCE_DATASET): cv.boolean, - cv.Optional(CONF_TLV): cv.string_strict, + cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, cv.Optional(CONF_OUTPUT_POWER): cv.All( @@ -200,7 +224,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), - only_on_variant(supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2]), + _validate_platform, _validate, _require_vfs_select, ) @@ -227,13 +251,27 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "openthread_esp.cpp": { + PlatformFramework.ESP32_IDF, + }, + "openthread_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + } +) + @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): # Re-enable openthread IDF component (excluded by default) - include_builtin_idf_component("openthread") + if CORE.is_esp32: + include_builtin_idf_component("openthread") cg.add_define("USE_OPENTHREAD") + if config.get(CONF_FORCE_DATASET): + cg.add_define("USE_OPENTHREAD_FORCE_DATASET") + if tlv := config.get(CONF_TLV): + cg.add_define("USE_OPENTHREAD_TLVS", tlv) # OpenThread SRP needs access to mDNS services after setup enable_mdns_storage() @@ -252,4 +290,12 @@ async def to_code(config): if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: cg.add(ot.set_output_power(output_power)) - set_sdkconfig_options(config) + if CORE.is_esp32: + set_sdkconfig_options(config) + elif CORE.using_zephyr: + zephyr_add_prj_conf("NET_L2_OPENTHREAD", True) + zephyr_add_prj_conf( + f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True + ) + zephyr_add_prj_conf(f"OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) + zephyr_add_prj_conf("MAIN_STACK_SIZE", 4096) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index c8ffc02131..102424c62e 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -132,7 +132,7 @@ void OpenThreadSrpComponent::setup() { char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size); const auto &host_name = App.get_name(); uint16_t host_name_len = host_name.size(); - if (host_name_len > size) { + if (host_name_len >= size) { ESP_LOGW(TAG, "Hostname is too long, choose a shorter project name"); return; } @@ -151,7 +151,7 @@ void OpenThreadSrpComponent::setup() { return; } - // Get mdns services and copy their data (strings are copied with strdup below) + // Get mdns services and copy their data (strdup on ESP32, pool_alloc_ on Zephyr) const auto &mdns_services = this->mdns_->get_services(); ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_services.size()); for (const auto &service : mdns_services) { @@ -164,7 +164,7 @@ void OpenThreadSrpComponent::setup() { // Set service name char *string = otSrpClientBuffersGetServiceEntryServiceNameString(entry, &size); std::string full_service = std::string(MDNS_STR_ARG(service.service_type)) + "." + MDNS_STR_ARG(service.proto); - if (full_service.size() > size) { + if (full_service.size() >= size) { ESP_LOGW(TAG, "Service name too long: %s", full_service.c_str()); continue; } @@ -172,7 +172,7 @@ void OpenThreadSrpComponent::setup() { // Set instance name (using host_name) string = otSrpClientBuffersGetServiceEntryInstanceNameString(entry, &size); - if (host_name_len > size) { + if (host_name_len >= size) { ESP_LOGW(TAG, "Instance name too long: %s", host_name.c_str()); continue; } @@ -189,11 +189,21 @@ void OpenThreadSrpComponent::setup() { for (size_t i = 0; i < service.txt_records.size(); i++) { const auto &txt = service.txt_records[i]; // Value is either a compile-time string literal in flash or a pointer to dynamic_txt_values_ - // OpenThread SRP client expects the data to persist, so we strdup it + // OpenThread SRP client expects the data to persist, so we copy it const char *value_str = MDNS_STR_ARG(txt.value); txt_entries[i].mKey = MDNS_STR_ARG(txt.key); +#ifndef USE_ZEPHYR txt_entries[i].mValue = reinterpret_cast(strdup(value_str)); txt_entries[i].mValueLength = strlen(value_str); +#else + // strdup is not available on zephyr + // https:// github.com/zephyrproject-rtos/zephyr/issues/22464 + size_t value_len = strlen(value_str); + char *value_copy = reinterpret_cast(this->pool_alloc_(value_len + 1)); + memcpy(value_copy, value_str, value_len + 1); + txt_entries[i].mValue = reinterpret_cast(value_copy); + txt_entries[i].mValueLength = value_len; +#endif } entry->mService.mTxtEntries = txt_entries; entry->mService.mNumTxtEntries = service.txt_records.size(); @@ -233,7 +243,7 @@ bool OpenThreadComponent::teardown() { global_openthread_component = nullptr; ESP_LOGD(TAG, "Exit main loop "); int error = this->openthread_stop_(); - if (error != ESP_OK) { + if (error != 0) { ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error); this->teardown_complete_ = true; } diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 96f1abdb92..f1c79fb9cb 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -43,10 +43,11 @@ class OpenThreadComponent : public Component { void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } #endif void set_output_power(int8_t output_power) { this->output_power_ = output_power; } + void set_connected(bool connected) { this->connected_ = connected; } + static void on_state_changed(otChangedFlags flags, void *context); protected: std::optional get_omr_address_(InstanceLock &lock); - static void on_state_changed(otChangedFlags flags, void *context); otInstance *get_openthread_instance_(); int openthread_stop_(); std::function factory_reset_external_callback_; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 4d88cbd226..6edaa98524 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -217,7 +217,7 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } InstanceLock InstanceLock::try_acquire(int delay) { - if (!global_openthread_component->is_lock_initialized()) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { return InstanceLock(false); } return InstanceLock(esp_openthread_lock_acquire(delay)); diff --git a/esphome/components/openthread/openthread_zephyr.cpp b/esphome/components/openthread/openthread_zephyr.cpp new file mode 100644 index 0000000000..7b9f14ab8c --- /dev/null +++ b/esphome/components/openthread/openthread_zephyr.cpp @@ -0,0 +1,141 @@ +#include "esphome/core/defines.h" +#if defined(USE_OPENTHREAD) && defined(USE_NRF52) +#include +#include +#include +#include "openthread.h" +#include "esphome/core/helpers.h" +#include + +static const char *const TAG = "openthread"; + +namespace esphome::openthread { + +static void on_thread_state_changed(otChangedFlags flags, struct openthread_context *ot_context, void *user_data) { + // Delegate connection status tracking to common callback + if (global_openthread_component != nullptr) { + OpenThreadComponent::on_state_changed(flags, global_openthread_component); + } + if (flags & OT_CHANGED_THREAD_ROLE) { + otDeviceRole role = otThreadGetDeviceRole(ot_context->instance); + ESP_LOGI(TAG, "Thread role changed to %s", otThreadDeviceRoleToString(role)); + } + if (flags & OT_CHANGED_THREAD_NETDATA) { + ESP_LOGI(TAG, "Thread network data updated"); + } + if (flags & (OT_CHANGED_THREAD_ROLE | OT_CHANGED_THREAD_NETDATA)) { + char buf[NET_IPV6_ADDR_LEN]; + for (const otNetifAddress *addr = otIp6GetUnicastAddresses(ot_context->instance); addr != nullptr; + addr = addr->mNext) { + ESP_LOGI(TAG, " Address: %s", net_addr_ntop(AF_INET6, &addr->mAddress, buf, sizeof(buf))); + } + } +} + +static struct openthread_state_changed_cb ot_state_changed_cb = {.state_changed_cb = on_thread_state_changed}; + +void OpenThreadComponent::setup() { + struct openthread_context *context = openthread_get_default_context(); + this->lock_initialized_ = true; + otOperationalDatasetTlvs dataset = {}; + +#ifndef USE_OPENTHREAD_FORCE_DATASET + otError error = otDatasetGetActiveTlvs(context->instance, &dataset); + if (error != OT_ERROR_NONE) { + dataset.mLength = 0; + } else { + ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); + } +#endif + +#ifdef USE_OPENTHREAD_TLVS + if (dataset.mLength == 0) { + const size_t tlv_chars = sizeof(USE_OPENTHREAD_TLVS) - 1; + if ((tlv_chars % 2) != 0) { + ESP_LOGE(TAG, "Invalid OpenThread TLV hex string length (must be even, got %zu)", tlv_chars); + this->mark_failed(); + return; + } + + size_t len = tlv_chars / 2; + if (len > sizeof(dataset.mTlvs)) { + ESP_LOGE(TAG, "OpenThread TLV too long (max %zu bytes, got %zu bytes)", sizeof(dataset.mTlvs), len); + this->mark_failed(); + return; + } + + size_t parsed = parse_hex(USE_OPENTHREAD_TLVS, tlv_chars, dataset.mTlvs, len); + if (parsed != tlv_chars) { + ESP_LOGE(TAG, "Invalid OpenThread TLV hex string (expected %zu hex chars, got %zu)", tlv_chars, parsed); + this->mark_failed(); + return; + } + dataset.mLength = len; + } +#endif + if (dataset.mLength > 0) { + otError error = otDatasetSetActiveTlvs(context->instance, &dataset); + if (error != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set active dataset: %s", otThreadErrorToString(error)); + this->mark_failed(); + return; + } + } + openthread_state_changed_cb_register(context, &ot_state_changed_cb); + openthread_start(context); +} + +void OpenThreadComponent::ot_main() {} + +otInstance *OpenThreadComponent::get_openthread_instance_() { return openthread_get_default_instance(); } + +int OpenThreadComponent::openthread_stop_() { + // OT stack is intentionally left running — no Zephyr stop API. The state callback stays + // registered but is safe (null-checks global_openthread_component). nRF52840 never + // re-enters setup() after teardown so this is functionally correct. + this->teardown_complete_ = true; + return 0; +} + +network::IPAddresses OpenThreadComponent::get_ip_addresses() { + network::IPAddresses addresses; + auto lock = InstanceLock::acquire(); + size_t addr_count = 0; + for (const otNetifAddress *addr = otIp6GetUnicastAddresses(openthread_get_default_instance()); + addr != nullptr && addr_count + 1 < addresses.size(); addr = addr->mNext) { + struct in6_addr ip6; + memcpy(&ip6, addr->mAddress.mFields.m8, sizeof(ip6)); + addresses[addr_count + 1] = network::IPAddress(&ip6); + addr_count++; + } + return addresses; +} + +InstanceLock InstanceLock::try_acquire(int delay) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { + return InstanceLock(false); + } + struct openthread_context *ot_context = openthread_get_default_context(); + if (k_mutex_lock(&ot_context->api_lock, K_MSEC(delay)) == 0) { + return InstanceLock(true); + } + return InstanceLock(false); +} + +InstanceLock InstanceLock::acquire() { + struct openthread_context *ot_context = openthread_get_default_context(); + k_mutex_lock(&ot_context->api_lock, K_FOREVER); + return InstanceLock(true); +} + +otInstance *InstanceLock::get_instance() { return openthread_get_default_instance(); } + +InstanceLock::~InstanceLock() { + if (this->owns_) { + struct openthread_context *ot_context = openthread_get_default_context(); + k_mutex_unlock(&ot_context->api_lock); + } +} + +} // namespace esphome::openthread +#endif diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 57f5778d54..bd5f01aa3a 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -76,7 +76,10 @@ def zephyr_data() -> ZephyrData: def zephyr_add_prj_conf( - name: str, value: PrjConfValueType, required: bool = True, image: str = "" + name: str, + value: PrjConfValueType, + required: bool = True, + image: str = "", ) -> None: """Set an zephyr prj conf value.""" if not name.startswith("CONFIG_"): @@ -133,7 +136,7 @@ def zephyr_to_code(config: ConfigType) -> None: # os: ***** USAGE FAULT ***** # os: Illegal load of EXC_RETURN into PC - zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048) + zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048, required=False) CORE.add_job(_cdc_acm_to_code, config) diff --git a/tests/components/openthread/test.nrf52-adafruit.yaml b/tests/components/openthread/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..ac2fe63739 --- /dev/null +++ b/tests/components/openthread/test.nrf52-adafruit.yaml @@ -0,0 +1,5 @@ +network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 From b6763cfaed5dfd1a2d40b7e0d3f8866ac184a1bd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:02:07 +1200 Subject: [PATCH 1674/2214] [ci] Smoke-test docker image by compiling each target toolchain (#16995) Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci-docker.yml | 102 ++++++++++++++++-- docker/test_configs/bk72xx-arduino.yaml | 7 ++ .../test_configs/esp32-arduino-esp-idf.yaml | 10 ++ .../esp32-arduino-platformio.yaml | 10 ++ docker/test_configs/esp32-idf-esp-idf.yaml | 10 ++ docker/test_configs/esp32-idf-platformio.yaml | 10 ++ docker/test_configs/esp8266-arduino.yaml | 7 ++ docker/test_configs/host.yaml | 6 ++ docker/test_configs/ln882x-arduino.yaml | 7 ++ docker/test_configs/nrf52.yaml | 8 ++ docker/test_configs/rp2040-arduino.yaml | 7 ++ docker/test_configs/rtl87xx-arduino.yaml | 7 ++ 12 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 docker/test_configs/bk72xx-arduino.yaml create mode 100644 docker/test_configs/esp32-arduino-esp-idf.yaml create mode 100644 docker/test_configs/esp32-arduino-platformio.yaml create mode 100644 docker/test_configs/esp32-idf-esp-idf.yaml create mode 100644 docker/test_configs/esp32-idf-platformio.yaml create mode 100644 docker/test_configs/esp8266-arduino.yaml create mode 100644 docker/test_configs/host.yaml create mode 100644 docker/test_configs/ln882x-arduino.yaml create mode 100644 docker/test_configs/nrf52.yaml create mode 100644 docker/test_configs/rp2040-arduino.yaml create mode 100644 docker/test_configs/rtl87xx-arduino.yaml diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 7d4b850356..373cd905b1 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -1,25 +1,38 @@ --- name: CI for docker images -# Only run when docker paths change +# Only run on PRs that touch the docker image, its build inputs, or any code +# whose toolchain the compile smoke test exercises (core + target platforms). on: - push: - branches: [dev, beta, release] - paths: - - "docker/**" - - ".github/workflows/ci-docker.yml" - - "requirements*.txt" - - "platformio.ini" - - "script/platformio_install_deps.py" - pull_request: paths: + # Docker image and its build inputs. - "docker/**" - ".github/workflows/ci-docker.yml" - "requirements*.txt" + - "pyproject.toml" - "platformio.ini" + - "esphome/idf_component.yml" - "script/platformio_install_deps.py" + # Core, build pipeline, toolchain, and target-platform changes can change + # how a toolchain is set up or built, so re-run the per-toolchain compile + # smoke test when they change. + - "esphome/core/**" + - "esphome/writer.py" + - "esphome/build_gen/**" + - "esphome/espidf/**" + - "esphome/platformio/**" + - "esphome/components/bk72xx/**" + - "esphome/components/esp32/**" + - "esphome/components/esp8266/**" + - "esphome/components/host/**" + - "esphome/components/libretiny/**" + - "esphome/components/ln882x/**" + - "esphome/components/nrf52/**" + - "esphome/components/rp2040/**" + - "esphome/components/rtl87xx/**" + - "esphome/components/zephyr/**" permissions: contents: read # actions/checkout only @@ -96,7 +109,26 @@ jobs: --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ --registry ghcr \ - build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} ${{ (matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker') && '--load' || '' }} + + # The amd64 "docker" image is also loaded locally (above) and handed to + # compile-test as an artifact, so the smoke test reuses this build instead + # of building the image a second time. Using an artifact (rather than the + # pushed image) keeps it working for fork PRs, which never push to ghcr.io. + - name: Export image for compile-test + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz + + - name: Upload compile-test image artifact + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # The tar is already gzipped, so upload it as-is. archive: false skips + # the redundant zip and makes the file name the artifact name (the + # `name` input is ignored in that mode). + path: compile-test-image.tar.gz + retention-days: 1 + archive: false manifest: name: Push ${{ matrix.build_type }} manifest to ghcr.io @@ -135,3 +167,51 @@ jobs: --build-type "${{ matrix.build_type }}" \ --registry ghcr \ manifest + + # Smoke-test the built image by compiling one minimal config per target + # platform / toolchain. This catches missing system dependencies in the image + # that only surface when a given toolchain is downloaded and run. The image is + # the amd64 "docker" build produced by check-docker (shared as an artifact). + compile-test: + name: Compile ${{ matrix.id }} + needs: check-docker + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to load the test configs + strategy: + fail-fast: false + # Cap concurrency so this smoke test doesn't hog all the shared runners. + max-parallel: 2 + matrix: + # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) + # share a toolchain bundle, so esp32 is exercised on the base variant + # across the full framework x toolchain cross-product (arduino/esp-idf + # framework, each built with the platformio and native esp-idf + # toolchains) so both toolchains stay covered regardless of which one is + # the default. + id: + - esp8266-arduino + - esp32-arduino-platformio + - esp32-arduino-esp-idf + - esp32-idf-platformio + - esp32-idf-esp-idf + - rp2040-arduino + - bk72xx-arduino + - rtl87xx-arduino + - ln882x-arduino + - nrf52 + - host + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Download image artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: compile-test-image.tar.gz + - name: Load image + run: docker load --input compile-test-image.tar.gz + - name: Compile ${{ matrix.id }} + run: | + docker run --rm \ + -v "${{ github.workspace }}/docker/test_configs:/config" \ + "ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \ + compile "${{ matrix.id }}.yaml" diff --git a/docker/test_configs/bk72xx-arduino.yaml b/docker/test_configs/bk72xx-arduino.yaml new file mode 100644 index 0000000000..138aa9e282 --- /dev/null +++ b/docker/test_configs/bk72xx-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-bk72xx-arduino + +bk72xx: + board: generic-bk7231n-qfn32-tuya + +logger: diff --git a/docker/test_configs/esp32-arduino-esp-idf.yaml b/docker/test_configs/esp32-arduino-esp-idf.yaml new file mode 100644 index 0000000000..fbc68aff0c --- /dev/null +++ b/docker/test_configs/esp32-arduino-esp-idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-ard-idf + +esp32: + variant: esp32 + framework: + type: arduino + toolchain: esp-idf + +logger: diff --git a/docker/test_configs/esp32-arduino-platformio.yaml b/docker/test_configs/esp32-arduino-platformio.yaml new file mode 100644 index 0000000000..e216c02059 --- /dev/null +++ b/docker/test_configs/esp32-arduino-platformio.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-ard-pio + +esp32: + variant: esp32 + framework: + type: arduino + toolchain: platformio + +logger: diff --git a/docker/test_configs/esp32-idf-esp-idf.yaml b/docker/test_configs/esp32-idf-esp-idf.yaml new file mode 100644 index 0000000000..b180aa9c0a --- /dev/null +++ b/docker/test_configs/esp32-idf-esp-idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-idf-idf + +esp32: + variant: esp32 + framework: + type: esp-idf + toolchain: esp-idf + +logger: diff --git a/docker/test_configs/esp32-idf-platformio.yaml b/docker/test_configs/esp32-idf-platformio.yaml new file mode 100644 index 0000000000..5aec23e40d --- /dev/null +++ b/docker/test_configs/esp32-idf-platformio.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-idf-pio + +esp32: + variant: esp32 + framework: + type: esp-idf + toolchain: platformio + +logger: diff --git a/docker/test_configs/esp8266-arduino.yaml b/docker/test_configs/esp8266-arduino.yaml new file mode 100644 index 0000000000..80b52260e4 --- /dev/null +++ b/docker/test_configs/esp8266-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-esp8266-arduino + +esp8266: + board: d1_mini + +logger: diff --git a/docker/test_configs/host.yaml b/docker/test_configs/host.yaml new file mode 100644 index 0000000000..9f99069304 --- /dev/null +++ b/docker/test_configs/host.yaml @@ -0,0 +1,6 @@ +esphome: + name: docker-test-host + +host: + +logger: diff --git a/docker/test_configs/ln882x-arduino.yaml b/docker/test_configs/ln882x-arduino.yaml new file mode 100644 index 0000000000..4cff3a4883 --- /dev/null +++ b/docker/test_configs/ln882x-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-ln882x-arduino + +ln882x: + board: generic-ln882hki + +logger: diff --git a/docker/test_configs/nrf52.yaml b/docker/test_configs/nrf52.yaml new file mode 100644 index 0000000000..d6337149cc --- /dev/null +++ b/docker/test_configs/nrf52.yaml @@ -0,0 +1,8 @@ +esphome: + name: docker-test-nrf52 + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: diff --git a/docker/test_configs/rp2040-arduino.yaml b/docker/test_configs/rp2040-arduino.yaml new file mode 100644 index 0000000000..4b5df11d87 --- /dev/null +++ b/docker/test_configs/rp2040-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-rp2040-arduino + +rp2040: + variant: rp2040 + +logger: diff --git a/docker/test_configs/rtl87xx-arduino.yaml b/docker/test_configs/rtl87xx-arduino.yaml new file mode 100644 index 0000000000..e8d9cf7503 --- /dev/null +++ b/docker/test_configs/rtl87xx-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-rtl87xx-arduino + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: From 3a1a8a89559477cbab10c5b7bd73dacdd8edefef Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:02:22 +1200 Subject: [PATCH 1675/2214] [ci] Fail CI Status job when workflow is cancelled (#17024) Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b1032bcde..aca6d9007a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1369,4 +1369,7 @@ jobs: # 1. The target branch has a build issue independent of this PR # 2. This PR fixes a build issue on the target branch # In either case, we only care that the PR branch builds successfully. - echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result != "failure")' + # Every other job must have succeeded or been skipped; a "cancelled" or + # "failure" result fails this check so CI is not reported green when the + # workflow was cancelled. + echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result == "success" or .result == "skipped")' From c2784c9fd8a388a4edbc2ec208101fc72be9686a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 17 Jun 2026 22:09:39 -0500 Subject: [PATCH 1676/2214] [esp32] Consolidate network/coexistence sdkconfig into a single reconciler (#17008) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/esp32/__init__.py | 126 ++++++++++- esphome/components/esp32/const.py | 1 + esphome/components/esp32_ble/__init__.py | 10 +- .../components/esp32_ble_beacon/__init__.py | 5 +- .../components/esp32_ble_server/__init__.py | 4 +- .../components/esp32_ble_tracker/__init__.py | 10 +- esphome/components/ethernet/__init__.py | 8 +- esphome/components/wifi/__init__.py | 9 +- .../esp32/config/network_ethernet_only.yaml | 17 ++ .../config/network_wifi_ble_coexistence.yaml | 14 ++ .../esp32/config/network_wifi_only.yaml | 11 + tests/component_tests/esp32/test_esp32.py | 195 +++++++++++++++++- 12 files changed, 382 insertions(+), 28 deletions(-) create mode 100644 tests/component_tests/esp32/config/network_ethernet_only.yaml create mode 100644 tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml create mode 100644 tests/component_tests/esp32/config/network_wifi_only.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3ffec6b826..aee86a0554 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -69,6 +69,7 @@ from .const import ( KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, KEY_REPO, @@ -597,6 +598,59 @@ def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType): CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS][name] = value +@dataclass +class NetworkSdkconfigData: + """Inputs for the network-related esp32 sdkconfig flags, reconciled at FINAL. + + Components call the request_*() helpers below (and esp32's own to_code fills + in enable_lwip_dhcp_server) instead of setting the WiFi/Ethernet/Bluetooth + sdkconfig flags directly; the single _reconcile_network_sdkconfig() coroutine + then decides the final values so they no longer depend on call order. + """ + + wifi: bool = False # WiFi component active (STA and/or AP) + wifi_ap: bool = False # WiFi AP mode configured + ethernet: bool = False # Ethernet component active + bluetooth: bool = False # any BLE component active + ble_42: bool = False # BLE 4.2 features needed + software_coexistence: bool = False # WiFi/BT software coexistence requested + # esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset) + enable_lwip_dhcp_server: bool | None = None + + +def _network_sdkconfig() -> NetworkSdkconfigData: + data = CORE.data[KEY_ESP32] + if KEY_NETWORK_SDKCONFIG not in data: + data[KEY_NETWORK_SDKCONFIG] = NetworkSdkconfigData() + return data[KEY_NETWORK_SDKCONFIG] + + +def request_wifi(ap: bool = False) -> None: + """Request the WiFi stack. Pass ap=True when AP mode is configured.""" + net = _network_sdkconfig() + net.wifi = True + if ap: + net.wifi_ap = True + + +def request_ethernet() -> None: + """Request the Ethernet stack.""" + _network_sdkconfig().ethernet = True + + +def request_bluetooth(ble_42: bool = False) -> None: + """Request the Bluetooth controller. Pass ble_42=True for 4.2 features.""" + net = _network_sdkconfig() + net.bluetooth = True + if ble_42: + net.ble_42 = True + + +def request_software_coexistence() -> None: + """Request WiFi/BT software coexistence (only valid alongside WiFi).""" + _network_sdkconfig().software_coexistence = True + + def add_idf_component( *, name: str, @@ -1847,6 +1901,61 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_network_sdkconfig() -> None: + """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. + + Single decision point for flags that multiple components used to set + directly (and sometimes with conflicting values). Runs at FINAL priority so + every request_*() call (made from the various components' to_code at their + own priorities) is seen first. A user-supplied sdkconfig_options value + always takes precedence. + """ + net = CORE.data[KEY_ESP32].get(KEY_NETWORK_SDKCONFIG, NetworkSdkconfigData()) + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + is_arduino = CORE.using_arduino + + def set_opt(name: str, value: SdkconfigValueType) -> None: + # User sdkconfig_options (applied during to_code) win. + if name not in opts: + add_idf_sdkconfig_option(name, value) + + # Bluetooth: only ever enable when requested. The IDF default is off and + # nothing sets these False today, so never write False here. + if net.bluetooth: + set_opt("CONFIG_BT_ENABLED", True) + if net.ble_42: + set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + + # WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi + # relies on the IDF default (enabled), so it is never written True here. + wifi_disabled = net.ethernet and not net.wifi + if wifi_disabled: + set_opt("CONFIG_ESP_WIFI_ENABLED", False) + + # Software coexistence: enable when requested (the schema only allows it + # alongside WiFi). Disable only in the Ethernet-without-WiFi case. + if net.software_coexistence: + set_opt("CONFIG_SW_COEXIST_ENABLE", True) + elif wifi_disabled: + set_opt("CONFIG_SW_COEXIST_ENABLE", False) + + # SoftAP support: drop it when WiFi is used without AP mode (IDF only). + if not is_arduino and net.wifi and not net.wifi_ap: + set_opt("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) + + # LWIP DHCP server: a WiFi-AP-mode / enable_lwip_dhcp_server concern (not + # coexistence). Disable when WiFi has no AP (IDF) or the enable_lwip_dhcp_server + # option is set to false, unless Arduino+Ethernet needs the symbols to compile. + wifi_wants_dhcps_off = not is_arduino and net.wifi and not net.wifi_ap + dhcp_server_disabled_by_option = net.enable_lwip_dhcp_server is False + arduino_eth_exclusion = is_arduino and net.ethernet + if ( + wifi_wants_dhcps_off or dhcp_server_disabled_by_option + ) and not arduino_eth_exclusion: + set_opt("CONFIG_LWIP_DHCPS", False) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -2171,14 +2280,12 @@ async def to_code(config): for component_name in advanced.get(CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, []): include_builtin_idf_component(component_name) - # DHCP server: only disable if explicitly set to false - # WiFi component handles its own optimization when AP mode is not used - # When using Arduino with Ethernet, DHCP server functions must be available - # for the Network library to compile, even if not actively used - if advanced.get(CONF_ENABLE_LWIP_DHCP_SERVER) is False and not ( - conf[CONF_TYPE] == FRAMEWORK_ARDUINO and "ethernet" in CORE.loaded_integrations - ): - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + # DHCP server (CONFIG_LWIP_DHCPS) is reconciled in _reconcile_network_sdkconfig + # together with the WiFi component's own AP-mode optimization; record the user's + # advanced tristate (True/False/None) for it to consume at FINAL priority. + _network_sdkconfig().enable_lwip_dhcp_server = advanced.get( + CONF_ENABLE_LWIP_DHCP_SERVER + ) if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: @@ -2397,6 +2504,9 @@ async def to_code(config): # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call CORE.add_job(_set_libc_picolibc_newlib_compat) + # FINAL priority: runs after every network/coexistence request_*() call + CORE.add_job(_reconcile_network_sdkconfig) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 322054ea91..83fcfd233e 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -16,6 +16,7 @@ KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" KEY_IDF_VERSION = "idf_version" +KEY_NETWORK_SDKCONFIG = "network_sdkconfig" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c7b6b40394..c9fb42fde4 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -8,7 +8,12 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_USE_PSRAM -from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + const, + get_esp32_variant, + request_bluetooth, +) from esphome.components.esp32.const import VARIANT_ESP32C2 import esphome.config_validation as cv from esphome.const import ( @@ -599,8 +604,7 @@ async def to_code(config): max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + request_bluetooth(ble_42=True) # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for # heap allocations and use dynamic (heap-based) environment memory tables diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 8052c13596..7a59cce19b 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -1,6 +1,6 @@ import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import CONF_BLE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TX_POWER, CONF_TYPE, CONF_UUID @@ -86,5 +86,4 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + request_bluetooth(ble_42=True) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index d45f2d9df2..ea2a9667d7 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,7 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv from esphome.config_validation import UNDEFINED @@ -632,7 +632,7 @@ async def to_code(config): ) cg.add_define("USE_ESP32_BLE_SERVER") cg.add_define("USE_ESP32_BLE_ADVERTISING") - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + request_bluetooth() @automation.register_action( diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index d758b400c4..e4139bed65 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -6,7 +6,11 @@ import logging from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble, ota -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + request_bluetooth, + request_software_coexistence, +) from esphome.components.esp32_ble import ( IDF_MAX_CONNECTIONS, BTLoggers, @@ -315,9 +319,9 @@ async def to_code(config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + request_bluetooth() if config.get(CONF_SOFTWARE_COEXISTENCE): - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", True) + request_software_coexistence() # https://github.com/espressif/esp-idf/issues/4101 # https://github.com/espressif/esp-idf/issues/2503 # Match arduino CONFIG_BTU_TASK_STACK_SIZE diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 784f5dee8c..f6afc30ff2 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -540,6 +540,7 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: add_idf_sdkconfig_option, idf_version, include_builtin_idf_component, + request_ethernet, ) if config[CONF_TYPE] in SPI_ETHERNET_TYPES: @@ -586,10 +587,9 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: ) cg.add(var.add_phy_register(reg)) - # Disable WiFi when using Ethernet to save memory - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) - # Also disable WiFi/BT coexistence since WiFi is disabled - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + # Register Ethernet with the esp32 sdkconfig reconciler, which disables the + # WiFi stack and WiFi/BT coexistence when Ethernet is used without WiFi. + request_ethernet() # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) include_builtin_idf_component("esp_eth") diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b7719c80d1..080a7bb97b 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( const, get_esp32_variant, only_on_variant, + request_wifi, ) from esphome.components.network import ( has_high_performance_networking, @@ -594,9 +595,11 @@ async def to_code(config): ) cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) cg.add_define("USE_WIFI_AP") - elif CORE.is_esp32 and not CORE.using_arduino: - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + + # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which + # drops SoftAP support / the LWIP DHCP server when AP mode is unused. + if CORE.is_esp32: + request_wifi(ap=CONF_AP in config) # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: diff --git a/tests/component_tests/esp32/config/network_ethernet_only.yaml b/tests/component_tests/esp32/config/network_ethernet_only.yaml new file mode 100644 index 0000000000..73d11e0a13 --- /dev/null +++ b/tests/component_tests/esp32/config/network_ethernet_only.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz diff --git a/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml b/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml new file mode 100644 index 0000000000..9aff46b7c4 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +esp32_ble_tracker: + software_coexistence: true diff --git a/tests/component_tests/esp32/config/network_wifi_only.yaml b/tests/component_tests/esp32/config/network_wifi_only.yaml new file mode 100644 index 0000000000..61dfde3e03 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e3311f6860..bdba981c44 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2,14 +2,25 @@ Test ESP32 configuration """ +import asyncio from collections.abc import Callable from pathlib import Path from typing import Any import pytest -from esphome.components.esp32 import VARIANT_ESP32, VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANTS, + NetworkSdkconfigData, + _reconcile_network_sdkconfig, +) +from esphome.components.esp32.const import ( + KEY_ESP32, + KEY_NETWORK_SDKCONFIG, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, +) from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv from esphome.const import ( @@ -343,3 +354,183 @@ def test_flash_mode_unset_leaves_defaults( assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) assert "board_build.flash_mode" not in CORE.platformio_options assert "board_build.f_flash" not in CORE.platformio_options + + +@pytest.mark.parametrize( + ("framework", "net", "preset", "expected"), + [ + # --- IDF: single-interface cases (must match pre-refactor behavior) --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True), + {}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_no_ap", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True, wifi_ap=True), + {}, + {}, + id="idf_wifi_ap_leaves_softap_dhcps", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="idf_ethernet_only", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData( + wifi=True, bluetooth=True, ble_42=True, software_coexistence=True + ), + {}, + { + "CONFIG_BT_ENABLED": True, + "CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True, + "CONFIG_SW_COEXIST_ENABLE": True, + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_ble_tracker_coexistence", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(bluetooth=True), + {}, + {"CONFIG_BT_ENABLED": True}, + id="idf_ble_server_only_no_ble42", + ), + # --- IDF: user sdkconfig_options always win --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True), + {"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_user_override_wins", + ), + # --- IDF: user advanced enable_lwip_dhcp_server: false, even with AP --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData( + wifi=True, wifi_ap=True, enable_lwip_dhcp_server=False + ), + {}, + {"CONFIG_LWIP_DHCPS": False}, + id="idf_user_disables_dhcps_with_ap", + ), + # --- IDF: WiFi + Ethernet coexist (the multi-interface unlock) --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True, ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_and_ethernet_keeps_wifi_enabled", + ), + # --- Arduino: SoftAP/DHCPS disable is IDF-only --- + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(wifi=True), + {}, + {}, + id="arduino_wifi_no_ap_untouched", + ), + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="arduino_ethernet_only_disables_wifi", + ), + # --- Arduino + Ethernet: DHCPS stays available even if user disabled it --- + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(ethernet=True, enable_lwip_dhcp_server=False), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="arduino_ethernet_dhcps_exclusion", + ), + ], +) +def test_reconcile_network_sdkconfig( + set_core_config: SetCoreConfigCallable, + framework: PlatformFramework, + net: NetworkSdkconfigData, + preset: dict[str, Any], + expected: dict[str, Any], +) -> None: + """The FINAL-priority reconciler resolves WiFi/Ethernet/Bluetooth/coexistence + sdkconfig flags from the requests recorded in NetworkSdkconfigData.""" + set_core_config(framework) + CORE.data[KEY_ESP32] = { + KEY_SDKCONFIG_OPTIONS: dict(preset), + KEY_NETWORK_SDKCONFIG: net, + } + + asyncio.run(_reconcile_network_sdkconfig()) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + +def test_network_wifi_only_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: codegen for an ESP-IDF WiFi (no AP) config runs the reconciler + after wifi's request_wifi(), disabling SoftAP support and the DHCP server.""" + generate_main(component_config_path("network_wifi_only.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + # WiFi stack stays enabled (no ethernet) and no Bluetooth requested. + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig + assert "CONFIG_BT_ENABLED" not in sdkconfig + + +def test_network_ethernet_only_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: ethernet's request_ethernet() makes the reconciler disable the + WiFi stack and coexistence when WiFi is absent.""" + generate_main(component_config_path("network_ethernet_only.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False + + +def test_network_wifi_ble_coexistence_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: WiFi + esp32_ble_tracker software_coexistence resolves to + BT enabled and coexistence on, with SoftAP/DHCP server dropped (no AP).""" + generate_main(component_config_path("network_wifi_ble_coexistence.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_BT_ENABLED") is True + assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + # WiFi present alongside BT -> WiFi stack must stay enabled. + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig From bd9375117a91d86854a81f0ba7090b2678309a92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jun 2026 22:11:24 -0500 Subject: [PATCH 1677/2214] [core] Honor transferred address cache in has_resolvable_address (#17025) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 6 ++++++ tests/unit_tests/test_main.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index f7d3f8e834..27dd878495 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -504,6 +504,12 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True + # The dashboard pre-resolves the device and passes the IPs via + # --mdns-address-cache/--dns-address-cache; honor a cached address even when the + # device has mDNS disabled (e.g. a .local host found via ping). + if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address): + return True + if has_mdns(): return True diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 03c005dc27..e44f746a75 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -689,6 +689,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: assert result == ["192.168.1.100"] +def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None: + """A .local device with mDNS disabled resolves via the dashboard-supplied cache.""" + setup_core( + config={ + CONF_API: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + CONF_MDNS: {CONF_DISABLED: True}, + }, + address="esp32-a1s.local", + ) + CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]}) + + for purpose in (Purpose.LOGGING, Purpose.UPLOADING): + result = choose_upload_log_host( + default="OTA", check_default=None, purpose=purpose + ) + assert result == ["192.168.1.50"] + + def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") @@ -3135,6 +3154,22 @@ def test_has_resolvable_address() -> None: setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None) assert has_resolvable_address() is False + # mDNS disabled + .local, but the dashboard cached the address -> resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache( + mdns_cache={"esphome-device.local": ["192.168.1.100"]} + ) + assert has_resolvable_address() is True + + # mDNS disabled + .local, cache present but missing this host -> not resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]}) + assert has_resolvable_address() is False + def test_has_name_add_mac_suffix() -> None: """Test has_name_add_mac_suffix function.""" From d4b642608793a06249656ea16f26d5d97bcb58e6 Mon Sep 17 00:00:00 2001 From: "Thomas A." Date: Thu, 18 Jun 2026 05:12:22 +0200 Subject: [PATCH 1678/2214] [esp32] Pin Names for Seeed XIAO C3 / C6 / S3 (#17002) Co-authored-by: Thomas A <1294885+zeroflow@users.noreply.github.com> Co-authored-by: Claude --- esphome/components/esp32/boards.py | 90 +++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 6062631d98..729b0c89ab 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1240,6 +1240,43 @@ ESP32_BOARD_PINS = { "LED_BUILTINB": 4, }, "sensesiot_weizen": {}, + # Source: https://wiki.seeedstudio.com/XIAO_ESP32C3_Getting_Started/ + # The XIAO ESP32-C3 has no user-controllable LED (only a hardwired charge + # LED), so LED/LED_BUILTIN are intentionally omitted. The Ax keys override + # the incorrect ESP32_BASE_PINS A* fallback (which otherwise makes pin: A0 + # resolve to phantom GPIO36 and pin: A1/A2 raise cv.Invalid). + "seeed_xiao_esp32c3": { + "D0": 2, + "D1": 3, + "D2": 4, + "D3": 5, + "D4": 6, + "D5": 7, + "D6": 21, + "D7": 20, + "D8": 8, + "D9": 9, + "D10": 10, + "MTDO": 7, + "MTCK": 6, + "MTDI": 5, + "MTMS": 4, + "BOOT": 9, + "TX": 21, + "RX": 20, + "SDA": 6, + "SCL": 7, + "SCK": 8, + "MISO": 9, + "MOSI": 10, + "A0": 2, + "A1": 3, + "A2": 4, + "A3": 5, + }, + # Source: https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/ + # The Ax keys override the incorrect ESP32_BASE_PINS A* fallback (which + # otherwise makes pin: A0 resolve to phantom GPIO36). "seeed_xiao_esp32c6": { "D0": 0, "D1": 1, @@ -1257,10 +1294,59 @@ ESP32_BOARD_PINS = { "MTDI": 5, "MTMS": 4, "BOOT": 9, - "LED": 8, - "LED_BUILTIN": 8, + "LED": 15, # Bugfix: was GPIO8; the yellow user LED is GPIO15 + "LED_BUILTIN": 15, # Bugfix: was GPIO8; the yellow user LED is GPIO15 "RF_SWITCH_EN": 3, "RF_ANT_SELECT": 14, + "TX": 16, + "RX": 17, + "SDA": 22, + "SCL": 23, + "SCK": 19, + "MISO": 20, + "MOSI": 18, + "A0": 0, + "A1": 1, + "A2": 2, + }, + # Source: https://wiki.seeedstudio.com/xiao_esp32s3_getting_started/ + # LED (GPIO21) is active-LOW; BOOT=GPIO0 is the standard ESP32-S3 strapping + # pin. The Ax keys override the incorrect ESP32_BASE_PINS A* fallback for the + # published silkscreen set. A6/A7 are intentionally absent (D6/D7 = GPIO43/44 + # have no ADC); because ESP32_BASE_PINS already defines A6=34/A7=35, pin: A6/A7 + # still resolve to those classic-ESP32 phantom values via the base-pins + # fallback (a disclosed residual, not fixable without editing ESP32_BASE_PINS). + "seeed_xiao_esp32s3": { + "D0": 1, + "D1": 2, + "D2": 3, + "D3": 4, + "D4": 5, + "D5": 6, + "D6": 43, + "D7": 44, + "D8": 7, + "D9": 8, + "D10": 9, + "BOOT": 0, + "LED": 21, + "LED_BUILTIN": 21, + "TX": 43, + "RX": 44, + "SDA": 5, + "SCL": 6, + "SCK": 7, + "MISO": 8, + "MOSI": 9, + "A0": 1, + "A1": 2, + "A2": 3, + "A3": 4, + "A4": 5, + "A5": 6, + "A8": 7, + "A9": 8, + "A10": 9, }, "sg-o_airMon": {}, "sparkfun_lora_gateway_1-channel": {"MISO": 12, "MOSI": 13, "SCK": 14, "SS": 16}, From 9ace0ffb262a3cbbc24822a3ff036d1116fd39ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:15 -0400 Subject: [PATCH 1679/2214] Bump pylint from 4.0.5 to 4.0.6 (#16983) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5ba806a2f5..438d6cd005 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.5 +pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.15.17 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating From 26c42af35478ff74d7a02ef7ae9645508b0e71d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:46 -0400 Subject: [PATCH 1680/2214] Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.1 (#16986) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aca6d9007a..6ff846e4b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@681749ae568c81c2037cb9185e38b709b261bd2f # v1.5.3 with: packages: libsdl2-dev version: 1.0 From 3b2564bbf3b7a31fae5794185fb740aca6b5cd3a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:56 -0400 Subject: [PATCH 1681/2214] Bump cryptography from 48.0.1 to 49.0.0 (#16985) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4ef3df60ff..efb5ec8723 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==48.0.1 +cryptography==49.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From c63bed8c217ebb127c7e9ffdf776c08207db7d26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:16:04 -0400 Subject: [PATCH 1682/2214] Bump pytest from 9.0.3 to 9.1.0 (#16981) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 438d6cd005..fc9681921a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.0.3 +pytest==9.1.0 pytest-cov==7.1.0 pytest-mock==3.15.1 pytest-asyncio==1.4.0 From 2b38e4b7e2f0cfbd49a782855ff94373100916f5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 17 Jun 2026 23:23:18 -0400 Subject: [PATCH 1683/2214] [audio] Bump microMP3 to v0.3.0 (#17009) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/audio/__init__.py | 6 +++--- esphome/idf_component.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2aceff0c97..091f496e33 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,11 +395,11 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.3") + add_idf_component(name="esphome/micro-mp3", ref="0.3.0") _emit_memory_pair( data.mp3.buffer_memory, - "CONFIG_MP3_DECODER_PREFER_PSRAM", - "CONFIG_MP3_DECODER_PREFER_INTERNAL", + "CONFIG_MICRO_MP3_PREFER_PSRAM", + "CONFIG_MICRO_MP3_PREFER_INTERNAL", ) if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5f3000e52d..b3b670d77b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.3 + version: 0.3.0 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From 11deff2bed04b9c887c311889cd35abb60efec3d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:35:03 +1200 Subject: [PATCH 1684/2214] Mark configurable classes as final (1/21: a01nyub-aqi) (#16952) --- esphome/components/a01nyub/a01nyub.h | 2 +- esphome/components/a02yyuw/a02yyuw.h | 2 +- esphome/components/a4988/a4988.h | 2 +- .../absolute_humidity/absolute_humidity.h | 2 +- esphome/components/ac_dimmer/ac_dimmer.h | 2 +- esphome/components/adc/adc_sensor.h | 2 +- esphome/components/adc128s102/adc128s102.h | 6 +++--- .../adc128s102/sensor/adc128s102_sensor.h | 8 ++++---- .../addressable_light_display.h | 2 +- esphome/components/ade7880/ade7880.h | 2 +- esphome/components/ade7953_i2c/ade7953_i2c.h | 2 +- esphome/components/ads1115/ads1115.h | 2 +- .../ads1115/sensor/ads1115_sensor.h | 8 ++++---- esphome/components/ads1118/ads1118.h | 6 +++--- .../ads1118/sensor/ads1118_sensor.h | 8 ++++---- esphome/components/ags10/ags10.h | 6 +++--- esphome/components/aht10/aht10.h | 2 +- esphome/components/aic3204/aic3204.h | 2 +- esphome/components/aic3204/automation.h | 2 +- .../airthings_ble/airthings_listener.h | 2 +- .../airthings_wave_mini/airthings_wave_mini.h | 2 +- .../airthings_wave_plus/airthings_wave_plus.h | 2 +- .../alarm_control_panel/automation.h | 14 +++++++------- esphome/components/alpha3/alpha3.h | 2 +- esphome/components/am2315c/am2315c.h | 2 +- esphome/components/am2320/am2320.h | 2 +- esphome/components/am43/cover/am43_cover.h | 2 +- esphome/components/am43/sensor/am43_sensor.h | 2 +- .../analog_threshold_binary_sensor.h | 2 +- esphome/components/animation/animation.h | 8 ++++---- esphome/components/anova/anova.h | 2 +- esphome/components/apds9306/apds9306.h | 2 +- esphome/components/apds9960/apds9960.h | 2 +- esphome/components/api/api_server.h | 2 +- .../components/api/homeassistant_service.h | 2 +- esphome/components/api/user_services.h | 19 ++++++++++--------- esphome/components/aqi/aqi_sensor.h | 2 +- 37 files changed, 70 insertions(+), 69 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.h b/esphome/components/a01nyub/a01nyub.h index 5c0d20bd37..69636eb8e4 100644 --- a/esphome/components/a01nyub/a01nyub.h +++ b/esphome/components/a01nyub/a01nyub.h @@ -8,7 +8,7 @@ namespace esphome::a01nyub { -class A01nyubComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class A01nyubComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/a02yyuw/a02yyuw.h b/esphome/components/a02yyuw/a02yyuw.h index 693bcfd03c..2e71651301 100644 --- a/esphome/components/a02yyuw/a02yyuw.h +++ b/esphome/components/a02yyuw/a02yyuw.h @@ -8,7 +8,7 @@ namespace esphome::a02yyuw { -class A02yyuwComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class A02yyuwComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/a4988/a4988.h b/esphome/components/a4988/a4988.h index 04040241c0..f50b5926c1 100644 --- a/esphome/components/a4988/a4988.h +++ b/esphome/components/a4988/a4988.h @@ -6,7 +6,7 @@ namespace esphome::a4988 { -class A4988 : public stepper::Stepper, public Component { +class A4988 final : public stepper::Stepper, public Component { public: void set_step_pin(GPIOPin *step_pin) { step_pin_ = step_pin; } void set_dir_pin(GPIOPin *dir_pin) { dir_pin_ = dir_pin; } diff --git a/esphome/components/absolute_humidity/absolute_humidity.h b/esphome/components/absolute_humidity/absolute_humidity.h index be28d3dc50..9989bb17fc 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.h +++ b/esphome/components/absolute_humidity/absolute_humidity.h @@ -13,7 +13,7 @@ enum SaturationVaporPressureEquation { }; /// This class implements calculation of absolute humidity from temperature and relative humidity. -class AbsoluteHumidityComponent : public sensor::Sensor, public Component { +class AbsoluteHumidityComponent final : public sensor::Sensor, public Component { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h index 6bfcf0bdb5..783a9d7e24 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.h +++ b/esphome/components/ac_dimmer/ac_dimmer.h @@ -41,7 +41,7 @@ struct AcDimmerDataStore { #endif }; -class AcDimmer : public output::FloatOutput, public Component { +class AcDimmer final : public output::FloatOutput, public Component { public: void setup() override; diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 676940eca1..03de6f8b4b 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -54,7 +54,7 @@ template class Aggregator { SamplingMode mode_{SamplingMode::AVG}; }; -class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { +class ADCSensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { public: /// Update the sensor's state by reading the current ADC value. /// This method is called periodically based on the update interval. diff --git a/esphome/components/adc128s102/adc128s102.h b/esphome/components/adc128s102/adc128s102.h index f04ed87b2a..7d6355815e 100644 --- a/esphome/components/adc128s102/adc128s102.h +++ b/esphome/components/adc128s102/adc128s102.h @@ -6,9 +6,9 @@ namespace esphome::adc128s102 { -class ADC128S102 : public Component, - public spi::SPIDevice { +class ADC128S102 final : public Component, + public spi::SPIDevice { public: ADC128S102() = default; diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.h b/esphome/components/adc128s102/sensor/adc128s102_sensor.h index c840102380..3c42e709f2 100644 --- a/esphome/components/adc128s102/sensor/adc128s102_sensor.h +++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.h @@ -9,10 +9,10 @@ namespace esphome::adc128s102 { -class ADC128S102Sensor : public PollingComponent, - public Parented, - public sensor::Sensor, - public voltage_sampler::VoltageSampler { +class ADC128S102Sensor final : public PollingComponent, + public Parented, + public sensor::Sensor, + public voltage_sampler::VoltageSampler { public: ADC128S102Sensor(uint8_t channel); diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index 917d334f05..39d62b8733 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -9,7 +9,7 @@ namespace esphome::addressable_light { -class AddressableLightDisplay : public display::DisplayBuffer { +class AddressableLightDisplay final : public display::DisplayBuffer { public: light::AddressableLight *get_light() const { return this->light_; } diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h index 53f501dee2..12be0849ff 100644 --- a/esphome/components/ade7880/ade7880.h +++ b/esphome/components/ade7880/ade7880.h @@ -65,7 +65,7 @@ struct ADE7880Store { static void gpio_intr(ADE7880Store *arg); }; -class ADE7880 : public i2c::I2CDevice, public PollingComponent { +class ADE7880 final : public i2c::I2CDevice, public PollingComponent { public: void set_irq0_pin(InternalGPIOPin *pin) { this->irq0_pin_ = pin; } void set_irq1_pin(InternalGPIOPin *pin) { this->irq1_pin_ = pin; } diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.h b/esphome/components/ade7953_i2c/ade7953_i2c.h index 74d7e3e7cc..0b368a73ee 100644 --- a/esphome/components/ade7953_i2c/ade7953_i2c.h +++ b/esphome/components/ade7953_i2c/ade7953_i2c.h @@ -10,7 +10,7 @@ namespace esphome::ade7953_i2c { -class AdE7953I2c : public ade7953_base::ADE7953, public i2c::I2CDevice { +class AdE7953I2c final : public ade7953_base::ADE7953, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/ads1115/ads1115.h b/esphome/components/ads1115/ads1115.h index b1eed68aff..0b7f7ae700 100644 --- a/esphome/components/ads1115/ads1115.h +++ b/esphome/components/ads1115/ads1115.h @@ -43,7 +43,7 @@ enum ADS1115Samplerate { ADS1115_860SPS = 0b111 }; -class ADS1115Component : public Component, public i2c::I2CDevice { +class ADS1115Component final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.h b/esphome/components/ads1115/sensor/ads1115_sensor.h index 3b82c153dd..ecc8fb7af8 100644 --- a/esphome/components/ads1115/sensor/ads1115_sensor.h +++ b/esphome/components/ads1115/sensor/ads1115_sensor.h @@ -11,10 +11,10 @@ namespace esphome::ads1115 { /// Internal holder class that is in instance of Sensor so that the hub can create individual sensors. -class ADS1115Sensor : public sensor::Sensor, - public PollingComponent, - public voltage_sampler::VoltageSampler, - public Parented { +class ADS1115Sensor final : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public Parented { public: void update() override; void set_multiplexer(ADS1115Multiplexer multiplexer) { this->multiplexer_ = multiplexer; } diff --git a/esphome/components/ads1118/ads1118.h b/esphome/components/ads1118/ads1118.h index ef125a0b44..275933c70d 100644 --- a/esphome/components/ads1118/ads1118.h +++ b/esphome/components/ads1118/ads1118.h @@ -26,9 +26,9 @@ enum ADS1118Gain { ADS1118_GAIN_0P256 = 0b101, }; -class ADS1118 : public Component, - public spi::SPIDevice { +class ADS1118 final : public Component, + public spi::SPIDevice { public: ADS1118() = default; void setup() override; diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.h b/esphome/components/ads1118/sensor/ads1118_sensor.h index b929e75c62..8987dba073 100644 --- a/esphome/components/ads1118/sensor/ads1118_sensor.h +++ b/esphome/components/ads1118/sensor/ads1118_sensor.h @@ -10,10 +10,10 @@ namespace esphome::ads1118 { -class ADS1118Sensor : public PollingComponent, - public sensor::Sensor, - public voltage_sampler::VoltageSampler, - public Parented { +class ADS1118Sensor final : public PollingComponent, + public sensor::Sensor, + public voltage_sampler::VoltageSampler, + public Parented { public: void update() override; diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h index 703acd5228..8ebc8da544 100644 --- a/esphome/components/ags10/ags10.h +++ b/esphome/components/ags10/ags10.h @@ -7,7 +7,7 @@ namespace esphome::ags10 { -class AGS10Component : public PollingComponent, public i2c::I2CDevice { +class AGS10Component final : public PollingComponent, public i2c::I2CDevice { public: /** * Sets TVOC sensor. @@ -100,7 +100,7 @@ class AGS10Component : public PollingComponent, public i2c::I2CDevice { template optional> read_and_check_(uint8_t a_register); }; -template class AGS10NewI2cAddressAction : public Action, public Parented { +template class AGS10NewI2cAddressAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, new_address) @@ -116,7 +116,7 @@ enum AGS10SetZeroPointActionMode { CUSTOM_VALUE, }; -template class AGS10SetZeroPointAction : public Action, public Parented { +template class AGS10SetZeroPointAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, value) TEMPLATABLE_VALUE(AGS10SetZeroPointActionMode, mode) diff --git a/esphome/components/aht10/aht10.h b/esphome/components/aht10/aht10.h index 7b9b1761c4..e99ba6fb98 100644 --- a/esphome/components/aht10/aht10.h +++ b/esphome/components/aht10/aht10.h @@ -10,7 +10,7 @@ namespace esphome::aht10 { enum AHT10Variant { AHT10, AHT20 }; -class AHT10Component : public PollingComponent, public i2c::I2CDevice { +class AHT10Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/aic3204/aic3204.h b/esphome/components/aic3204/aic3204.h index 9b8c792824..ae99a8f4d6 100644 --- a/esphome/components/aic3204/aic3204.h +++ b/esphome/components/aic3204/aic3204.h @@ -61,7 +61,7 @@ static const uint8_t AIC3204_ADC_PTM = 0x3D; // Register 61 - ADC Power Tu static const uint8_t AIC3204_AN_IN_CHRG = 0x47; // Register 71 - Analog Input Quick Charging Config static const uint8_t AIC3204_REF_STARTUP = 0x7B; // Register 123 - Reference Power Up Config -class AIC3204 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class AIC3204 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h index 50ae03edbd..f0f8856614 100644 --- a/esphome/components/aic3204/automation.h +++ b/esphome/components/aic3204/automation.h @@ -6,7 +6,7 @@ namespace esphome::aic3204 { -template class SetAutoMuteAction : public Action { +template class SetAutoMuteAction final : public Action { public: explicit SetAutoMuteAction(AIC3204 *aic3204) : aic3204_(aic3204) {} diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h index 707e9c3f21..8105ac32eb 100644 --- a/esphome/components/airthings_ble/airthings_listener.h +++ b/esphome/components/airthings_ble/airthings_listener.h @@ -7,7 +7,7 @@ namespace esphome::airthings_ble { -class AirthingsListener : public esp32_ble_tracker::ESPBTDeviceListener { +class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.h b/esphome/components/airthings_wave_mini/airthings_wave_mini.h index 910ac90239..c41dde15c9 100644 --- a/esphome/components/airthings_wave_mini/airthings_wave_mini.h +++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.h @@ -12,7 +12,7 @@ static const char *const SERVICE_UUID = "b42e3882-ade7-11e4-89d3-123b93f75cba"; static const char *const CHARACTERISTIC_UUID = "b42e3b98-ade7-11e4-89d3-123b93f75cba"; static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID = "b42e3ef4-ade7-11e4-89d3-123b93f75cba"; -class AirthingsWaveMini : public airthings_wave_base::AirthingsWaveBase { +class AirthingsWaveMini final : public airthings_wave_base::AirthingsWaveBase { public: AirthingsWaveMini(); diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.h b/esphome/components/airthings_wave_plus/airthings_wave_plus.h index 6f51f3c65a..af355e45d6 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.h +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.h @@ -19,7 +19,7 @@ static const char *const CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e4dcc-ade7-11 static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e50d8-ade7-11e4-89d3-123b93f75cba"; -class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { +class AirthingsWavePlus final : public airthings_wave_base::AirthingsWaveBase { public: void setup() override; diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index 022d2650d2..dcb5121c60 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,7 +27,7 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class ArmAwayAction : public Action { +template class ArmAwayAction final : public Action { public: explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -39,7 +39,7 @@ template class ArmAwayAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class ArmHomeAction : public Action { +template class ArmHomeAction final : public Action { public: explicit ArmHomeAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -51,7 +51,7 @@ template class ArmHomeAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class ArmNightAction : public Action { +template class ArmNightAction final : public Action { public: explicit ArmNightAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -63,7 +63,7 @@ template class ArmNightAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class DisarmAction : public Action { +template class DisarmAction final : public Action { public: explicit DisarmAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -75,7 +75,7 @@ template class DisarmAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class PendingAction : public Action { +template class PendingAction final : public Action { public: explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -85,7 +85,7 @@ template class PendingAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class TriggeredAction : public Action { +template class TriggeredAction final : public Action { public: explicit TriggeredAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -95,7 +95,7 @@ template class TriggeredAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class AlarmControlPanelCondition : public Condition { +template class AlarmControlPanelCondition final : public Condition { public: AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {} bool check(const Ts &...x) override { diff --git a/esphome/components/alpha3/alpha3.h b/esphome/components/alpha3/alpha3.h index c63129031a..5a5b01ac0b 100644 --- a/esphome/components/alpha3/alpha3.h +++ b/esphome/components/alpha3/alpha3.h @@ -31,7 +31,7 @@ static const int16_t GENI_RESPONSE_POWER_OFFSET = 12; static const int16_t GENI_RESPONSE_MOTOR_POWER_OFFSET = 16; // not sure static const int16_t GENI_RESPONSE_MOTOR_SPEED_OFFSET = 20; -class Alpha3 : public esphome::ble_client::BLEClientNode, public PollingComponent { +class Alpha3 final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void update() override; diff --git a/esphome/components/am2315c/am2315c.h b/esphome/components/am2315c/am2315c.h index 5a959af4c3..73dc0d8758 100644 --- a/esphome/components/am2315c/am2315c.h +++ b/esphome/components/am2315c/am2315c.h @@ -27,7 +27,7 @@ namespace esphome::am2315c { -class AM2315C : public PollingComponent, public i2c::I2CDevice { +class AM2315C final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void update() override; diff --git a/esphome/components/am2320/am2320.h b/esphome/components/am2320/am2320.h index ddb5c6f165..f92156b154 100644 --- a/esphome/components/am2320/am2320.h +++ b/esphome/components/am2320/am2320.h @@ -6,7 +6,7 @@ namespace esphome::am2320 { -class AM2320Component : public PollingComponent, public i2c::I2CDevice { +class AM2320Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/am43/cover/am43_cover.h b/esphome/components/am43/cover/am43_cover.h index aa48aced15..be7af59ade 100644 --- a/esphome/components/am43/cover/am43_cover.h +++ b/esphome/components/am43/cover/am43_cover.h @@ -14,7 +14,7 @@ namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; -class Am43Component : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component { +class Am43Component final : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h index 9198a5cbcb..944681bb60 100644 --- a/esphome/components/am43/sensor/am43_sensor.h +++ b/esphome/components/am43/sensor/am43_sensor.h @@ -14,7 +14,7 @@ namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; -class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent { +class Am43 final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void update() override; diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index c768f1f82d..a4df00ff05 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -7,7 +7,7 @@ namespace esphome::analog_threshold { -class AnalogThresholdBinarySensor : public Component, public binary_sensor::BinarySensor { +class AnalogThresholdBinarySensor final : public Component, public binary_sensor::BinarySensor { public: void dump_config() override; void setup() override; diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index ca800ad931..64cddbf09c 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -5,7 +5,7 @@ namespace esphome::animation { -class Animation : public image::Image { +class Animation final : public image::Image { public: Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type, image::Transparency transparent); @@ -35,7 +35,7 @@ class Animation : public image::Image { int loop_current_iteration_; }; -template class AnimationNextFrameAction : public Action { +template class AnimationNextFrameAction final : public Action { public: AnimationNextFrameAction(Animation *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->next_frame(); } @@ -44,7 +44,7 @@ template class AnimationNextFrameAction : public Action { Animation *parent_; }; -template class AnimationPrevFrameAction : public Action { +template class AnimationPrevFrameAction final : public Action { public: AnimationPrevFrameAction(Animation *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->prev_frame(); } @@ -53,7 +53,7 @@ template class AnimationPrevFrameAction : public Action { Animation *parent_; }; -template class AnimationSetFrameAction : public Action { +template class AnimationSetFrameAction final : public Action { public: AnimationSetFrameAction(Animation *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, frame) diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index a3e175be28..49b1100c37 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; static const uint16_t ANOVA_SERVICE_UUID = 0xFFE0; static const uint16_t ANOVA_CHARACTERISTIC_UUID = 0xFFE1; -class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { +class Anova final : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/apds9306/apds9306.h b/esphome/components/apds9306/apds9306.h index 093ec55bc6..f971290cdd 100644 --- a/esphome/components/apds9306/apds9306.h +++ b/esphome/components/apds9306/apds9306.h @@ -39,7 +39,7 @@ enum AmbientLightGain : uint8_t { }; static const uint8_t AMBIENT_LIGHT_GAIN_VALUES[] = {1, 3, 6, 9, 18}; -class APDS9306 : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class APDS9306 final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; float get_setup_priority() const override { return setup_priority::BUS; } diff --git a/esphome/components/apds9960/apds9960.h b/esphome/components/apds9960/apds9960.h index 2823294207..bfa64bcc74 100644 --- a/esphome/components/apds9960/apds9960.h +++ b/esphome/components/apds9960/apds9960.h @@ -12,7 +12,7 @@ namespace esphome::apds9960 { -class APDS9960 : public PollingComponent, public i2c::I2CDevice { +class APDS9960 final : public PollingComponent, public i2c::I2CDevice { #ifdef USE_SENSOR SUB_SENSOR(red) SUB_SENSOR(green) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index fbc8115091..16b5762f68 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -342,7 +342,7 @@ class APIServer final : public Component, extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class APIConnectedCondition : public Condition { +template class APIConnectedCondition final : public Condition { TEMPLATABLE_VALUE(bool, state_subscription_only) public: bool check(const Ts &...x) override { diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index aef046fbb0..9e0faf9881 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -104,7 +104,7 @@ class ActionResponse { template using ActionResponseCallback = std::function; #endif -template class HomeAssistantServiceCallAction : public Action { +template class HomeAssistantServiceCallAction final : public Action { public: explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) { this->flags_.is_event = is_event; diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 29eadda927..ea57d0944b 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -164,7 +164,8 @@ template class UserServiceTrig // Specialization for NONE - no extra trigger arguments template -class UserServiceTrigger : public UserServiceBase, public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {} @@ -175,8 +176,8 @@ class UserServiceTrigger : public UserServ // Specialization for OPTIONAL - call_id and return_response trigger arguments template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {} @@ -189,8 +190,8 @@ class UserServiceTrigger : public User // Specialization for ONLY - just call_id trigger argument template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_ONLY) {} @@ -201,8 +202,8 @@ class UserServiceTrigger : public UserServ // Specialization for STATUS - just call_id trigger argument (reports success/error without data) template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_STATUS) {} @@ -221,7 +222,7 @@ class UserServiceTrigger : public UserSe namespace esphome::api { -template class APIRespondAction : public Action { +template class APIRespondAction final : public Action { public: explicit APIRespondAction(APIServer *parent) : parent_(parent) {} @@ -286,7 +287,7 @@ template class APIRespondAction : public Action { // Action to unregister a service call after execution completes // Automatically appended to the end of action lists for non-none response modes -template class APIUnregisterServiceCallAction : public Action { +template class APIUnregisterServiceCallAction final : public Action { public: explicit APIUnregisterServiceCallAction(APIServer *parent) : parent_(parent) {} diff --git a/esphome/components/aqi/aqi_sensor.h b/esphome/components/aqi/aqi_sensor.h index 2e526ca825..aa64fa5a4d 100644 --- a/esphome/components/aqi/aqi_sensor.h +++ b/esphome/components/aqi/aqi_sensor.h @@ -6,7 +6,7 @@ namespace esphome::aqi { -class AQISensor : public sensor::Sensor, public Component { +class AQISensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; From 92028e53b5d55ee55608e361d6fa019ab5b8fe30 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:35:12 +1200 Subject: [PATCH 1685/2214] Mark configurable classes as final (2/21: as3935_i2c-ble_rssi) (#16953) --- esphome/components/as3935_i2c/as3935_i2c.h | 2 +- esphome/components/as3935_spi/as3935_spi.h | 6 ++--- esphome/components/as5600/as5600.h | 2 +- .../components/as5600/sensor/as5600_sensor.h | 2 +- esphome/components/as7341/as7341.h | 2 +- esphome/components/at581x/at581x.h | 2 +- esphome/components/at581x/automation.h | 4 ++-- esphome/components/at581x/switch/rf_switch.h | 2 +- .../atc_mithermometer/atc_mithermometer.h | 2 +- esphome/components/atm90e26/atm90e26.h | 6 ++--- esphome/components/atm90e32/atm90e32.h | 6 ++--- .../atm90e32/button/atm90e32_button.h | 12 +++++----- esphome/components/audio_adc/automation.h | 2 +- esphome/components/audio_dac/automation.h | 6 ++--- .../media_source/audio_file_media_source.h | 4 +++- .../audio_http/audio_http_media_source.h | 4 +++- .../touchscreen/axs15231_touchscreen.h | 2 +- esphome/components/ballu/ballu.h | 2 +- .../components/bang_bang/bang_bang_climate.h | 2 +- esphome/components/bedjet/bedjet_hub.h | 2 +- .../bedjet/climate/bedjet_climate.h | 2 +- esphome/components/bedjet/fan/bedjet_fan.h | 2 +- .../components/bedjet/sensor/bedjet_sensor.h | 2 +- .../beken_spi_led_strip/led_strip.h | 2 +- esphome/components/bh1750/bh1750.h | 2 +- esphome/components/bh1900nux/bh1900nux.h | 2 +- esphome/components/binary/fan/binary_fan.h | 2 +- .../binary/light/binary_light_output.h | 2 +- esphome/components/binary_sensor/automation.h | 20 ++++++++--------- .../binary_sensor_map/binary_sensor_map.h | 2 +- esphome/components/bl0906/bl0906.h | 4 ++-- esphome/components/bl0939/bl0939.h | 2 +- esphome/components/bl0940/bl0940.h | 2 +- .../bl0940/button/calibration_reset_button.h | 2 +- .../bl0940/number/calibration_number.h | 2 +- esphome/components/bl0942/bl0942.h | 2 +- esphome/components/ble_client/automation.h | 22 +++++++++---------- esphome/components/ble_client/ble_client.h | 2 +- .../ble_client/output/ble_binary_output.h | 2 +- .../components/ble_client/sensor/automation.h | 2 +- .../ble_client/sensor/ble_rssi_sensor.h | 2 +- .../components/ble_client/switch/ble_switch.h | 2 +- .../ble_client/text_sensor/automation.h | 2 +- esphome/components/ble_nus/ble_nus.h | 2 +- .../ble_presence/ble_presence_device.h | 6 ++--- esphome/components/ble_rssi/ble_rssi_sensor.h | 2 +- 46 files changed, 86 insertions(+), 82 deletions(-) diff --git a/esphome/components/as3935_i2c/as3935_i2c.h b/esphome/components/as3935_i2c/as3935_i2c.h index c43ec4afd5..c15f2d6e3e 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.h +++ b/esphome/components/as3935_i2c/as3935_i2c.h @@ -5,7 +5,7 @@ namespace esphome::as3935_i2c { -class I2CAS3935Component : public as3935::AS3935Component, public i2c::I2CDevice { +class I2CAS3935Component final : public as3935::AS3935Component, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/as3935_spi/as3935_spi.h b/esphome/components/as3935_spi/as3935_spi.h index 935707a18c..053e34b3d0 100644 --- a/esphome/components/as3935_spi/as3935_spi.h +++ b/esphome/components/as3935_spi/as3935_spi.h @@ -8,9 +8,9 @@ namespace esphome::as3935_spi { enum AS3935RegisterMasks { SPI_READ_M = 0x40 }; -class SPIAS3935Component : public as3935::AS3935Component, - public spi::SPIDevice { +class SPIAS3935Component final : public as3935::AS3935Component, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/as5600/as5600.h b/esphome/components/as5600/as5600.h index 414633f978..a385322b70 100644 --- a/esphome/components/as5600/as5600.h +++ b/esphome/components/as5600/as5600.h @@ -43,7 +43,7 @@ enum AS5600MagnetStatus : uint8_t { MAGNET_WEAK = 6, // 0b110 / magnet too weak }; -class AS5600Component : public Component, public i2c::I2CDevice { +class AS5600Component final : public Component, public i2c::I2CDevice { public: /// Set up the internal sensor array. void setup() override; diff --git a/esphome/components/as5600/sensor/as5600_sensor.h b/esphome/components/as5600/sensor/as5600_sensor.h index 0086fe54cc..170ff6d86b 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.h +++ b/esphome/components/as5600/sensor/as5600_sensor.h @@ -9,7 +9,7 @@ namespace esphome::as5600 { -class AS5600Sensor : public PollingComponent, public Parented, public sensor::Sensor { +class AS5600Sensor final : public PollingComponent, public Parented, public sensor::Sensor { public: void update() override; void dump_config() override; diff --git a/esphome/components/as7341/as7341.h b/esphome/components/as7341/as7341.h index 8bc157fe79..2d72987f1c 100644 --- a/esphome/components/as7341/as7341.h +++ b/esphome/components/as7341/as7341.h @@ -73,7 +73,7 @@ enum AS7341Gain { AS7341_GAIN_512X, }; -class AS7341Component : public PollingComponent, public i2c::I2CDevice { +class AS7341Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h index e7f8ee3692..594395e96d 100644 --- a/esphome/components/at581x/at581x.h +++ b/esphome/components/at581x/at581x.h @@ -12,7 +12,7 @@ namespace esphome::at581x { -class AT581XComponent : public Component, public i2c::I2CDevice { +class AT581XComponent final : public Component, public i2c::I2CDevice { public: #ifdef USE_SWITCH void set_rf_power_switch(switch_::Switch *s) { diff --git a/esphome/components/at581x/automation.h b/esphome/components/at581x/automation.h index eb8b1b2562..a732d2bcc7 100644 --- a/esphome/components/at581x/automation.h +++ b/esphome/components/at581x/automation.h @@ -7,12 +7,12 @@ namespace esphome::at581x { -template class AT581XResetAction : public Action, public Parented { +template class AT581XResetAction final : public Action, public Parented { public: void play(const Ts &...x) { this->parent_->reset_hardware_frontend(); } }; -template class AT581XSettingsAction : public Action, public Parented { +template class AT581XSettingsAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int8_t, hw_frontend_reset) TEMPLATABLE_VALUE(int, frequency) diff --git a/esphome/components/at581x/switch/rf_switch.h b/esphome/components/at581x/switch/rf_switch.h index 47367fad45..0e251b8baa 100644 --- a/esphome/components/at581x/switch/rf_switch.h +++ b/esphome/components/at581x/switch/rf_switch.h @@ -5,7 +5,7 @@ namespace esphome::at581x { -class RFSwitch : public switch_::Switch, public Parented { +class RFSwitch final : public switch_::Switch, public Parented { protected: void write_state(bool state) override; }; diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 8f62f05bc1..3dde5f1868 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -18,7 +18,7 @@ struct ParseResult { int raw_offset; }; -class ATCMiThermometer : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/atm90e26/atm90e26.h b/esphome/components/atm90e26/atm90e26.h index 657f8f3c43..0381d8e5c1 100644 --- a/esphome/components/atm90e26/atm90e26.h +++ b/esphome/components/atm90e26/atm90e26.h @@ -6,9 +6,9 @@ namespace esphome::atm90e26 { -class ATM90E26Component : public PollingComponent, - public spi::SPIDevice { +class ATM90E26Component final : public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index 5fa224b353..c636e5065a 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -13,9 +13,9 @@ namespace esphome::atm90e32 { -class ATM90E32Component : public PollingComponent, - public spi::SPIDevice { +class ATM90E32Component final : public PollingComponent, + public spi::SPIDevice { public: static const uint8_t PHASEA = 0; static const uint8_t PHASEB = 1; diff --git a/esphome/components/atm90e32/button/atm90e32_button.h b/esphome/components/atm90e32/button/atm90e32_button.h index 0cfce62293..988c6d5c16 100644 --- a/esphome/components/atm90e32/button/atm90e32_button.h +++ b/esphome/components/atm90e32/button/atm90e32_button.h @@ -6,7 +6,7 @@ namespace esphome::atm90e32 { -class ATM90E32GainCalibrationButton : public button::Button, public Parented { +class ATM90E32GainCalibrationButton final : public button::Button, public Parented { public: ATM90E32GainCalibrationButton() = default; @@ -14,7 +14,7 @@ class ATM90E32GainCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearGainCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearGainCalibrationButton() = default; @@ -22,7 +22,7 @@ class ATM90E32ClearGainCalibrationButton : public button::Button, public Parente void press_action() override; }; -class ATM90E32OffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32OffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32OffsetCalibrationButton() = default; @@ -30,7 +30,7 @@ class ATM90E32OffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearOffsetCalibrationButton() = default; @@ -38,7 +38,7 @@ class ATM90E32ClearOffsetCalibrationButton : public button::Button, public Paren void press_action() override; }; -class ATM90E32PowerOffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32PowerOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32PowerOffsetCalibrationButton() = default; @@ -46,7 +46,7 @@ class ATM90E32PowerOffsetCalibrationButton : public button::Button, public Paren void press_action() override; }; -class ATM90E32ClearPowerOffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearPowerOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearPowerOffsetCalibrationButton() = default; diff --git a/esphome/components/audio_adc/automation.h b/esphome/components/audio_adc/automation.h index e74e023203..fc7af25622 100644 --- a/esphome/components/audio_adc/automation.h +++ b/esphome/components/audio_adc/automation.h @@ -6,7 +6,7 @@ namespace esphome::audio_adc { -template class SetMicGainAction : public Action { +template class SetMicGainAction final : public Action { public: explicit SetMicGainAction(AudioAdc *audio_adc) : audio_adc_(audio_adc) {} diff --git a/esphome/components/audio_dac/automation.h b/esphome/components/audio_dac/automation.h index 67bbc78ac2..9c5348271c 100644 --- a/esphome/components/audio_dac/automation.h +++ b/esphome/components/audio_dac/automation.h @@ -6,7 +6,7 @@ namespace esphome::audio_dac { -template class MuteOffAction : public Action { +template class MuteOffAction final : public Action { public: explicit MuteOffAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} @@ -16,7 +16,7 @@ template class MuteOffAction : public Action { AudioDac *audio_dac_; }; -template class MuteOnAction : public Action { +template class MuteOnAction final : public Action { public: explicit MuteOnAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} @@ -26,7 +26,7 @@ template class MuteOnAction : public Action { AudioDac *audio_dac_; }; -template class SetVolumeAction : public Action { +template class SetVolumeAction final : public Action { public: explicit SetVolumeAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.h b/esphome/components/audio_file/media_source/audio_file_media_source.h index 2c6189f272..d269f77c35 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.h +++ b/esphome/components/audio_file/media_source/audio_file_media_source.h @@ -23,7 +23,9 @@ namespace esphome::audio_file { // (the orchestrator calls set_listener() on us with a MediaSourceListener*). // - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded // audio and state changes (we call decoder_->set_listener(this) in setup()). -class AudioFileMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { +class AudioFileMediaSource final : public Component, + public media_source::MediaSource, + public micro_decoder::DecoderListener { public: void setup() override; void loop() override; diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h index e4bd69e9e6..f794aa1f02 100644 --- a/esphome/components/audio_http/audio_http_media_source.h +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -23,7 +23,9 @@ namespace esphome::audio_http { // - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded // audio and state changes (we call decoder_->set_listener(this) in setup()). // The two set_listener() methods live on different base classes and serve opposite directions. -class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { +class AudioHTTPMediaSource final : public Component, + public media_source::MediaSource, + public micro_decoder::DecoderListener { public: void setup() override; void loop() override; diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h index 94d232777c..43bd379925 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h @@ -7,7 +7,7 @@ namespace esphome::axs15231 { -class AXS15231Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class AXS15231Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ballu/ballu.h b/esphome/components/ballu/ballu.h index 8a45d39c70..cb40f415ad 100644 --- a/esphome/components/ballu/ballu.h +++ b/esphome/components/ballu/ballu.h @@ -10,7 +10,7 @@ namespace esphome::ballu { const float YKR_K_002E_TEMP_MIN = 16.0; const float YKR_K_002E_TEMP_MAX = 32.0; -class BalluClimate : public climate_ir::ClimateIR { +class BalluClimate final : public climate_ir::ClimateIR { public: BalluClimate() : climate_ir::ClimateIR(YKR_K_002E_TEMP_MIN, YKR_K_002E_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index 1e5ff84883..d83257f9f3 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -16,7 +16,7 @@ struct BangBangClimateTargetTempConfig { float default_temperature_high{NAN}; }; -class BangBangClimate : public climate::Climate, public Component { +class BangBangClimate final : public climate::Climate, public Component { public: BangBangClimate(); void setup() override; diff --git a/esphome/components/bedjet/bedjet_hub.h b/esphome/components/bedjet/bedjet_hub.h index 9f25f7a466..32ddd94cff 100644 --- a/esphome/components/bedjet/bedjet_hub.h +++ b/esphome/components/bedjet/bedjet_hub.h @@ -33,7 +33,7 @@ static const espbt::ESPBTUUID BEDJET_NAME_UUID = espbt::ESPBTUUID::from_raw("000 /** * Hub component connecting to the BedJet device over Bluetooth. */ -class BedJetHub : public esphome::ble_client::BLEClientNode, public PollingComponent { +class BedJetHub final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: /* BedJet functionality exposed to `BedJetClient` children and/or accessible from action lambdas. */ diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index f59e67eeb7..6f81b87289 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -12,7 +12,7 @@ namespace esphome::bedjet { -class BedJetClimate : public climate::Climate, public BedJetClient, public PollingComponent { +class BedJetClimate final : public climate::Climate, public BedJetClient, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/bedjet/fan/bedjet_fan.h b/esphome/components/bedjet/fan/bedjet_fan.h index 03f42f1438..814a87d8b9 100644 --- a/esphome/components/bedjet/fan/bedjet_fan.h +++ b/esphome/components/bedjet/fan/bedjet_fan.h @@ -12,7 +12,7 @@ namespace esphome::bedjet { -class BedJetFan : public fan::Fan, public BedJetClient, public PollingComponent { +class BedJetFan final : public fan::Fan, public BedJetClient, public PollingComponent { public: void update() override; void dump_config() override; diff --git a/esphome/components/bedjet/sensor/bedjet_sensor.h b/esphome/components/bedjet/sensor/bedjet_sensor.h index 0c3f713579..c387e9d5fd 100644 --- a/esphome/components/bedjet/sensor/bedjet_sensor.h +++ b/esphome/components/bedjet/sensor/bedjet_sensor.h @@ -7,7 +7,7 @@ namespace esphome::bedjet { -class BedjetSensor : public BedJetClient, public Component { +class BedjetSensor final : public BedJetClient, public Component { public: void dump_config() override; diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 4ed640a3bc..909634e266 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -19,7 +19,7 @@ enum RGBOrder : uint8_t { ORDER_BRG, }; -class BekenSPILEDStripLightOutput : public light::AddressableLight { +class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/bh1750/bh1750.h b/esphome/components/bh1750/bh1750.h index 39dbd1d6a9..092a21359b 100644 --- a/esphome/components/bh1750/bh1750.h +++ b/esphome/components/bh1750/bh1750.h @@ -13,7 +13,7 @@ enum BH1750Mode : uint8_t { }; /// This class implements support for the i2c-based BH1750 ambient light sensor. -class BH1750Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class BH1750Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/bh1900nux/bh1900nux.h b/esphome/components/bh1900nux/bh1900nux.h index 61d1bac268..f1d62d1647 100644 --- a/esphome/components/bh1900nux/bh1900nux.h +++ b/esphome/components/bh1900nux/bh1900nux.h @@ -6,7 +6,7 @@ namespace esphome::bh1900nux { -class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class BH1900NUXSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/binary/fan/binary_fan.h b/esphome/components/binary/fan/binary_fan.h index 17157dd29c..601f4cb641 100644 --- a/esphome/components/binary/fan/binary_fan.h +++ b/esphome/components/binary/fan/binary_fan.h @@ -6,7 +6,7 @@ namespace esphome::binary { -class BinaryFan : public Component, public fan::Fan { +class BinaryFan final : public Component, public fan::Fan { public: void setup() override; void dump_config() override; diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index f6be7e162e..32707e8b0c 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -6,7 +6,7 @@ namespace esphome::binary { -class BinaryLightOutput : public light::LightOutput { +class BinaryLightOutput final : public light::LightOutput { public: void set_output(output::BinaryOutput *output) { output_ = output; } light::LightTraits get_traits() override { diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 1875910aff..d5a85ca9c4 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -18,7 +18,7 @@ struct MultiClickTriggerEvent { uint32_t max_length; }; -class PressTrigger : public Trigger<> { +class PressTrigger final : public Trigger<> { public: explicit PressTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { @@ -28,7 +28,7 @@ class PressTrigger : public Trigger<> { } }; -class ReleaseTrigger : public Trigger<> { +class ReleaseTrigger final : public Trigger<> { public: explicit ReleaseTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { @@ -40,7 +40,7 @@ class ReleaseTrigger : public Trigger<> { bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); -class ClickTrigger : public Trigger<> { +class ClickTrigger final : public Trigger<> { public: explicit ClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length) : min_length_(min_length), max_length_(max_length) { @@ -61,7 +61,7 @@ class ClickTrigger : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -class DoubleClickTrigger : public Trigger<> { +class DoubleClickTrigger final : public Trigger<> { public: explicit DoubleClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length) : min_length_(min_length), max_length_(max_length) { @@ -127,7 +127,7 @@ class MultiClickTriggerBase : public Trigger<>, public Component { /// Template wrapper that provides inline std::array storage for timing events. /// N is set by code generation to match the exact number of timing events configured in YAML. -template class MultiClickTrigger : public MultiClickTriggerBase { +template class MultiClickTrigger final : public MultiClickTriggerBase { public: MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) : MultiClickTriggerBase(parent) { @@ -140,14 +140,14 @@ template class MultiClickTrigger : public MultiClickTriggerBase { std::array timing_storage_{}; }; -class StateTrigger : public Trigger { +class StateTrigger final : public Trigger { public: explicit StateTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { this->trigger(state); }); } }; -class StateChangeTrigger : public Trigger, optional > { +class StateChangeTrigger final : public Trigger, optional > { public: explicit StateChangeTrigger(BinarySensor *parent) { parent->add_full_state_callback( @@ -155,7 +155,7 @@ class StateChangeTrigger : public Trigger, optional > { } }; -template class BinarySensorCondition : public Condition { +template class BinarySensorCondition final : public Condition { public: BinarySensorCondition(BinarySensor *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { return this->parent_->state == this->state_; } @@ -165,7 +165,7 @@ template class BinarySensorCondition : public Condition { bool state_; }; -template class BinarySensorPublishAction : public Action { +template class BinarySensorPublishAction final : public Action { public: explicit BinarySensorPublishAction(BinarySensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(bool, state) @@ -179,7 +179,7 @@ template class BinarySensorPublishAction : public Action BinarySensor *sensor_; }; -template class BinarySensorInvalidateAction : public Action { +template class BinarySensorInvalidateAction final : public Action { public: explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.h b/esphome/components/binary_sensor_map/binary_sensor_map.h index 60224242db..bb2c273957 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.h +++ b/esphome/components/binary_sensor_map/binary_sensor_map.h @@ -29,7 +29,7 @@ struct BinarySensorMapChannel { * * Each binary sensor has configured parameters that each mapping type uses to compute the single numerical result */ -class BinarySensorMap : public sensor::Sensor, public Component { +class BinarySensorMap final : public sensor::Sensor, public Component { public: void dump_config() override; diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index 821aac476c..54de9f9b0c 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -53,7 +53,7 @@ class BL0906; using ActionCallbackFuncPtr = void (BL0906::*)(); -class BL0906 : public PollingComponent, public uart::UARTDevice { +class BL0906 final : public PollingComponent, public uart::UARTDevice { SUB_SENSOR(voltage) SUB_SENSOR(current_1) SUB_SENSOR(current_2) @@ -103,7 +103,7 @@ class BL0906 : public PollingComponent, public uart::UARTDevice { std::vector action_queue_{}; }; -template class ResetEnergyAction : public Action, public Parented { +template class ResetEnergyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->enqueue_action_(&BL0906::reset_energy_); } }; diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index b4f6d42e71..333bca3715 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -56,7 +56,7 @@ union DataPacket { // NOLINT(altera-struct-pack-align) }; } __attribute__((packed)); -class BL0939 : public PollingComponent, public uart::UARTDevice { +class BL0939 final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor_1(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h index 14cb69d0b0..007fa990d5 100644 --- a/esphome/components/bl0940/bl0940.h +++ b/esphome/components/bl0940/bl0940.h @@ -33,7 +33,7 @@ struct DataPacket { uint8_t checksum; // Packet checksum } __attribute__((packed)); -class BL0940 : public PollingComponent, public uart::UARTDevice { +class BL0940 final : public PollingComponent, public uart::UARTDevice { public: // Sensor setters void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } diff --git a/esphome/components/bl0940/button/calibration_reset_button.h b/esphome/components/bl0940/button/calibration_reset_button.h index d528992d58..f5a4f50886 100644 --- a/esphome/components/bl0940/button/calibration_reset_button.h +++ b/esphome/components/bl0940/button/calibration_reset_button.h @@ -7,7 +7,7 @@ namespace esphome::bl0940 { class BL0940; // Forward declaration of BL0940 class -class CalibrationResetButton : public button::Button, public Component, public Parented { +class CalibrationResetButton final : public button::Button, public Component, public Parented { public: void dump_config() override; diff --git a/esphome/components/bl0940/number/calibration_number.h b/esphome/components/bl0940/number/calibration_number.h index 062890d918..186a34c583 100644 --- a/esphome/components/bl0940/number/calibration_number.h +++ b/esphome/components/bl0940/number/calibration_number.h @@ -6,7 +6,7 @@ namespace esphome::bl0940 { -class CalibrationNumber : public number::Number, public Component { +class CalibrationNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/bl0942/bl0942.h b/esphome/components/bl0942/bl0942.h index c366878637..f926dd022d 100644 --- a/esphome/components/bl0942/bl0942.h +++ b/esphome/components/bl0942/bl0942.h @@ -83,7 +83,7 @@ enum LineFrequency : uint8_t { LINE_FREQUENCY_60HZ = 60, }; -class BL0942 : public PollingComponent, public uart::UARTDevice { +class BL0942 final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { this->current_sensor_ = current_sensor; } diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 01590d1d53..94eeb83b3e 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -23,7 +23,7 @@ class Automation { }; // implement on_connect automation. -class BLEClientConnectTrigger : public Trigger<>, public BLEClientNode { +class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientConnectTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -37,7 +37,7 @@ class BLEClientConnectTrigger : public Trigger<>, public BLEClientNode { }; // on_disconnect automation -class BLEClientDisconnectTrigger : public Trigger<>, public BLEClientNode { +class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientDisconnectTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -61,7 +61,7 @@ class BLEClientDisconnectTrigger : public Trigger<>, public BLEClientNode { } }; -class BLEClientPasskeyRequestTrigger : public Trigger<>, public BLEClientNode { +class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -71,7 +71,7 @@ class BLEClientPasskeyRequestTrigger : public Trigger<>, public BLEClientNode { } }; -class BLEClientPasskeyNotificationTrigger : public Trigger, public BLEClientNode { +class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientNode { public: explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -82,7 +82,7 @@ class BLEClientPasskeyNotificationTrigger : public Trigger, public BLE } }; -class BLEClientNumericComparisonRequestTrigger : public Trigger, public BLEClientNode { +class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientNode { public: explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -94,7 +94,7 @@ class BLEClientNumericComparisonRequestTrigger : public Trigger, publi }; // implement the ble_client.ble_write action. -template class BLEClientWriteAction : public Action, public BLEClientNode { +template class BLEClientWriteAction final : public Action, public BLEClientNode { public: BLEClientWriteAction(BLEClient *ble_client) { ble_client->register_ble_node(this); @@ -231,7 +231,7 @@ template class BLEClientWriteAction : public Action, publ esp_gatt_write_type_t write_type_{}; }; -template class BLEClientPasskeyReplyAction : public Action { +template class BLEClientPasskeyReplyAction final : public Action { public: BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -268,7 +268,7 @@ template class BLEClientPasskeyReplyAction : public Action class BLEClientNumericComparisonReplyAction : public Action { +template class BLEClientNumericComparisonReplyAction final : public Action { public: BLEClientNumericComparisonReplyAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -301,7 +301,7 @@ template class BLEClientNumericComparisonReplyAction : public Ac } value_{.simple = false}; }; -template class BLEClientRemoveBondAction : public Action { +template class BLEClientRemoveBondAction final : public Action { public: BLEClientRemoveBondAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -315,7 +315,7 @@ template class BLEClientRemoveBondAction : public Action BLEClient *parent_{nullptr}; }; -template class BLEClientConnectAction : public Action, public BLEClientNode { +template class BLEClientConnectAction final : public Action, public BLEClientNode { public: BLEClientConnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); @@ -364,7 +364,7 @@ template class BLEClientConnectAction : public Action, pu std::tuple var_{}; }; -template class BLEClientDisconnectAction : public Action, public BLEClientNode { +template class BLEClientDisconnectAction final : public Action, public BLEClientNode { public: BLEClientDisconnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index ca523251ef..f27bef332b 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -44,7 +44,7 @@ class BLEClientNode { uint64_t address_; }; -class BLEClient : public BLEClientBase { +class BLEClient final : public BLEClientBase { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ble_client/output/ble_binary_output.h b/esphome/components/ble_client/output/ble_binary_output.h index 299de9b860..8ea700529b 100644 --- a/esphome/components/ble_client/output/ble_binary_output.h +++ b/esphome/components/ble_client/output/ble_binary_output.h @@ -11,7 +11,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEBinaryOutput : public output::BinaryOutput, public BLEClientNode, public Component { +class BLEBinaryOutput final : public output::BinaryOutput, public BLEClientNode, public Component { public: void dump_config() override; void loop() override {} diff --git a/esphome/components/ble_client/sensor/automation.h b/esphome/components/ble_client/sensor/automation.h index 84430cb7d9..e805ebdb59 100644 --- a/esphome/components/ble_client/sensor/automation.h +++ b/esphome/components/ble_client/sensor/automation.h @@ -7,7 +7,7 @@ namespace esphome::ble_client { -class BLESensorNotifyTrigger : public Trigger, public BLESensor { +class BLESensorNotifyTrigger final : public Trigger, public BLESensor { public: explicit BLESensorNotifyTrigger(BLESensor *sensor) { sensor_ = sensor; } void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.h b/esphome/components/ble_client/sensor/ble_rssi_sensor.h index 570a5b423c..e1590dbdeb 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.h +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.h @@ -12,7 +12,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEClientRSSISensor : public sensor::Sensor, public PollingComponent, public BLEClientNode { +class BLEClientRSSISensor final : public sensor::Sensor, public PollingComponent, public BLEClientNode { public: void loop() override; void update() override; diff --git a/esphome/components/ble_client/switch/ble_switch.h b/esphome/components/ble_client/switch/ble_switch.h index 9be6d06b1c..42b450243a 100644 --- a/esphome/components/ble_client/switch/ble_switch.h +++ b/esphome/components/ble_client/switch/ble_switch.h @@ -12,7 +12,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEClientSwitch : public switch_::Switch, public Component, public BLEClientNode { +class BLEClientSwitch final : public switch_::Switch, public Component, public BLEClientNode { public: void dump_config() override; void loop() override {} diff --git a/esphome/components/ble_client/text_sensor/automation.h b/esphome/components/ble_client/text_sensor/automation.h index d4114cd1ba..8a81610668 100644 --- a/esphome/components/ble_client/text_sensor/automation.h +++ b/esphome/components/ble_client/text_sensor/automation.h @@ -7,7 +7,7 @@ namespace esphome::ble_client { -class BLETextSensorNotifyTrigger : public Trigger, public BLETextSensor { +class BLETextSensorNotifyTrigger final : public Trigger, public BLETextSensor { public: explicit BLETextSensorNotifyTrigger(BLETextSensor *sensor) { sensor_ = sensor; } void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/ble_nus/ble_nus.h b/esphome/components/ble_nus/ble_nus.h index f1afd54af9..82e2db6900 100644 --- a/esphome/components/ble_nus/ble_nus.h +++ b/esphome/components/ble_nus/ble_nus.h @@ -11,7 +11,7 @@ namespace esphome::ble_nus { -class BLENUS : public uart::UARTComponent, public Component { +class BLENUS final : public uart::UARTComponent, public Component { enum TxStatus { TX_DISABLED, TX_ENABLED, diff --git a/esphome/components/ble_presence/ble_presence_device.h b/esphome/components/ble_presence/ble_presence_device.h index 76e8079948..e17e26ff1c 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -8,9 +8,9 @@ namespace esphome::ble_presence { -class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener, - public Component { +class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener, + public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.h b/esphome/components/ble_rssi/ble_rssi_sensor.h index a876fa51d2..8e804ab8e7 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.h +++ b/esphome/components/ble_rssi/ble_rssi_sensor.h @@ -8,7 +8,7 @@ namespace esphome::ble_rssi { -class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; From 69f905f15448270b842803aaeba562c9e359e79a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 04:18:12 -0500 Subject: [PATCH 1686/2214] [ci] Revert "Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.1" (#17028) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ff846e4b2..aca6d9007a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@681749ae568c81c2037cb9185e38b709b261bd2f # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 with: packages: libsdl2-dev version: 1.0 From 1753ccd81198b8a1cdf40374a12c478265c9e5c0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:57:59 -0400 Subject: [PATCH 1687/2214] [ci] Update component-test CI for ESP-IDF default toolchain (#16383) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .github/actions/cache-esp-idf/action.yml | 14 +- .github/workflows/ci.yml | 89 +++-------- script/determine-jobs.py | 124 +++++++++------ tests/script/test_determine_jobs.py | 187 ++++++++++++++++------- 4 files changed, 247 insertions(+), 167 deletions(-) diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml index 7a17c222a3..f566ba4c43 100644 --- a/.github/actions/cache-esp-idf/action.yml +++ b/.github/actions/cache-esp-idf/action.yml @@ -2,8 +2,8 @@ name: Cache ESP-IDF description: > Resolve the pinned ESP-IDF version and cache the native ESP-IDF install (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF - natively (clang-tidy for IDF/Arduino and the native-IDF component build) - shares one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS + natively (clang-tidy for IDF/Arduino and the component test batches) shares + one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS defaults to "all", so all toolchains are present regardless of the chip). Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the Python venv already restored. @@ -11,6 +11,12 @@ inputs: framework: description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".' default: espidf + restore-only: + description: > + When "true", only restore -- never save the cache, even on dev. Use from + jobs that may not produce an ESP-IDF install (e.g. a component batch with + no esp32 target), so a partial/empty install is never written to the key. + default: "false" runs: using: composite steps: @@ -33,13 +39,13 @@ runs: # PRs), and PRs are restore-only -- they never push multi-GB artifacts into # their own scope / the repo quota (e.g. on a version-bump PR). - name: Cache ESP-IDF install (write on dev) - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} - name: Cache ESP-IDF install (restore-only off dev) - if: github.ref != 'refs/heads/dev' + if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aca6d9007a..29d42330cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,8 +270,8 @@ jobs: python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} device-builder: ${{ steps.determine.outputs.device-builder }} - native-idf: ${{ steps.determine.outputs.native-idf }} - native-idf-components: ${{ steps.determine.outputs.native-idf-components }} + esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} + esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -324,8 +324,8 @@ jobs: echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT - echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT - echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT + echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT + echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -522,7 +522,6 @@ jobs: key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install - # Shared with the IDF tidy + native-IDF build jobs (same install). if: matrix.cache_idf uses: ./.github/actions/cache-esp-idf with: @@ -592,7 +591,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the Arduino tidy + native-IDF build jobs (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -673,7 +671,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the Arduino tidy + native-IDF build jobs (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -758,7 +755,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the IDF/Arduino clang-tidy jobs + native-IDF build (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -805,6 +801,10 @@ jobs: - common - determine-jobs if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 + env: + # esp32 component builds use the native ESP-IDF toolchain (default), so + # share the tidy jobs' install location -- the restore below lands here. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} @@ -832,6 +832,12 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install (restore-only) + # A batch may contain no esp32 build, so never save -- just reuse the + # shared install the dev tidy jobs already cached when present. + uses: ./.github/actions/cache-esp-idf + with: + restore-only: true - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -935,20 +941,19 @@ jobs: echo "All components in this batch are validate-only -- skipping compile stage." fi - test-native-idf: - name: Test components with native ESP-IDF + test-esp32-platformio: + name: Test esp32 components with PlatformIO runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true' + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp32-platformio == 'true' env: - ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf - # Comma-joined subset of the native-IDF representative component list, - # computed by script/determine-jobs.py (native_idf_components_to_test). + # Comma-joined subset of the esp32 PlatformIO representative component list, + # computed by script/determine-jobs.py (esp32_platformio_components_to_test). # Single source of truth -- the full list lives in - # script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS. - TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }} + # script/determine-jobs.py::ESP32_PLATFORMIO_TEST_COMPONENTS. + TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -959,66 +964,22 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Prepare build storage on /mnt - # Bind-mount the larger /mnt disk over the IDF install + build dirs BEFORE - # restoring the cache, so the ~4.5GB restore lands on the roomier volume - # instead of being shadowed by a mount set up later in the run step. - run: | - root_avail=$(df -k / | awk 'NR==2 {print $4}') - mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}') - echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB" - if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then - echo "Using /mnt for build files (more space available)" - sudo mkdir -p /mnt/esphome-idf - sudo chown $USER:$USER /mnt/esphome-idf - mkdir -p ~/.esphome-idf - sudo mount --bind /mnt/esphome-idf ~/.esphome-idf - sudo mkdir -p /mnt/test_build_components_build - sudo chown $USER:$USER /mnt/test_build_components_build - mkdir -p tests/test_build_components/build - sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build - else - echo "Using / for build files (more space available than /mnt or /mnt unavailable)" - fi - - - name: Cache ESP-IDF install - # Shared with the IDF/Arduino clang-tidy jobs (same install); restores - # into the /mnt bind-mount prepared above when present. - uses: ./.github/actions/cache-esp-idf - - - name: Run native ESP-IDF compile test + - name: Run PlatformIO compile test run: | . venv/bin/activate echo "Testing components: $TEST_COMPONENTS" echo "" - # Show disk space before validation - echo "Disk space before config validation:" - df -h - echo "" - # Run config validation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf + python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio echo "" echo "Config validation passed! Starting compilation..." echo "" - # Show disk space before compilation - echo "Disk space before compilation:" - df -h - echo "" - # Run compilation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf - - - name: Save ESPHome cache - if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} + python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio pre-commit-ci-lite: name: pre-commit.ci lite @@ -1353,7 +1314,7 @@ jobs: - determine-jobs - device-builder - test-build-components-split - - test-native-idf + - test-esp32-platformio - pre-commit-ci-lite - memory-impact-target-branch - memory-impact-pr-branch diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 4904883ca9..af3e83f96b 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -466,11 +466,11 @@ def should_run_device_builder(branch: str | None = None) -> bool: return False -# Components tested by the native ESP-IDF compile-test job. This is the +# Components tested by the PlatformIO compile-test job. This is the # single source of truth: the workflow reads the comma-joined list from the -# `native-idf-components` output of `determine-jobs` and uses it as the -# `TEST_COMPONENTS` env on the `test-native-idf` job. -NATIVE_IDF_TEST_COMPONENTS = frozenset( +# `esp32-platformio-components` output of `determine-jobs` and uses it as the +# `TEST_COMPONENTS` env on the `test-esp32-platformio` job. +ESP32_PLATFORMIO_TEST_COMPONENTS = frozenset( { "esp32", "api", @@ -490,53 +490,75 @@ NATIVE_IDF_TEST_COMPONENTS = frozenset( } ) -# Path prefixes whose changes always trigger the native ESP-IDF compile -# test: anything under esphome/espidf/ (the native IDF runner / API / -# framework / component generator). -NATIVE_IDF_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) +# Path prefixes whose changes always trigger the PlatformIO compile test: +# anything under esphome/platformio/ (the PlatformIO runner / toolchain that +# drives every PlatformIO build). The esp32 platform component is already in +# ESP32_PLATFORMIO_TEST_COMPONENTS, so its changes are covered by the normal +# component-narrowing path. +ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",) -# Standalone files that, when changed, also trigger the native ESP-IDF -# compile test: -# - esphome/build_gen/espidf.py -- the native IDF build generator -# (other files under build_gen/ target PlatformIO and don't affect -# the native IDF path) +# Standalone files that, when changed, trigger the PlatformIO compile test: +# - esphome/build_gen/platformio.py -- the PlatformIO build generator # - script/test_build_components.py -- the harness the job invokes # - .github/workflows/ci.yml -- the job's own definition -NATIVE_IDF_TRIGGER_FILES = frozenset( +ESP32_PLATFORMIO_TRIGGER_FILES = frozenset( { - "esphome/build_gen/espidf.py", + "esphome/build_gen/platformio.py", "script/test_build_components.py", ".github/workflows/ci.yml", } ) -def _native_idf_path_or_file_trigger(files: list[str]) -> bool: - """Whether any changed file is a native IDF infrastructure / harness trigger.""" +def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool: + """Whether any changed file is a PlatformIO infrastructure / harness trigger.""" for file in files: - if file in NATIVE_IDF_TRIGGER_FILES: + if file in ESP32_PLATFORMIO_TRIGGER_FILES: return True - if any(file.startswith(prefix) for prefix in NATIVE_IDF_TRIGGER_PATH_PREFIXES): + if any( + file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES + ): return True return False -def native_idf_components_to_test(branch: str | None = None) -> list[str]: - """Subset of ``NATIVE_IDF_TEST_COMPONENTS`` the job needs to compile. +# ESP-IDF infra: changes under esphome/espidf/ or to the IDF build generator +# affect every esp32 IDF build (now the default toolchain) but aren't +# components, so the component matrix wouldn't otherwise force any esp32 +# compile. When they change we fold the `esp32` component into the matrix so +# the default native-IDF build path is still compiled on an infra-only PR. +ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) +ESP_IDF_INFRA_TRIGGER_FILES = frozenset({"esphome/build_gen/espidf.py"}) - The job builds components with the native ESP-IDF toolchain (no - PlatformIO). When only a specific component (or something it depends - on) changed, there's no value in re-building every other unrelated - component in the test list -- the regular ``component-test`` matrix - already covers them via PlatformIO. So we narrow to the intersection - of ``NATIVE_IDF_TEST_COMPONENTS`` and the changed-component dependency + +def _esp_idf_infra_changed(files: list[str]) -> bool: + """Whether any changed file is ESP-IDF build/runner infrastructure.""" + for file in files: + if file in ESP_IDF_INFRA_TRIGGER_FILES: + return True + if any( + file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES + ): + return True + return False + + +def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: + """Subset of ``ESP32_PLATFORMIO_TEST_COMPONENTS`` the job needs to compile. + + The job builds components with the PlatformIO toolchain. When only a + specific component (or something it depends on) changed, there's no + value in re-building every other unrelated component in the test list -- + the regular ``component-test`` matrix already covers them via the + default toolchain. So we narrow to the intersection of + ``ESP32_PLATFORMIO_TEST_COMPONENTS`` and the changed-component dependency closure. Returns the full list (sorted) when we can't safely narrow: 1. Core C++/Python files changed (``esphome/core/*``). - 2. Native IDF infrastructure changed (``esphome/espidf/*`` or - ``esphome/build_gen/espidf.py``). + 2. PlatformIO infrastructure changed (``esphome/platformio/*`` or + ``esphome/build_gen/platformio.py``). 3. The test harness or workflow itself changed (``script/test_build_components.py``, ``.github/workflows/ci.yml``). @@ -558,31 +580,31 @@ def native_idf_components_to_test(branch: str | None = None) -> list[str]: """ files = changed_files(branch) - if core_changed(files) or _native_idf_path_or_file_trigger(files): - return sorted(NATIVE_IDF_TEST_COMPONENTS) + if core_changed(files) or _esp32_platformio_path_or_file_trigger(files): + return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) component_files = [f for f in files if filter_component_and_test_files(f)] changed = get_components_with_dependencies(component_files, True) - return sorted(NATIVE_IDF_TEST_COMPONENTS & set(changed)) + return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed)) -def should_run_native_idf(branch: str | None = None) -> bool: - """Determine if the `test-native-idf` compile-test job should run. +def should_run_esp32_platformio(branch: str | None = None) -> bool: + """Determine if the `test-esp32-platformio` compile-test job should run. - Runs whenever ``native_idf_components_to_test()`` returns a non-empty + Runs whenever ``esp32_platformio_components_to_test()`` returns a non-empty list. Skipping the job on unrelated Python-only PRs avoids ~5 min of CI per PR (worse on cold caches). The regular ``component-test`` - matrix still exercises the same components through PlatformIO when - those components change. + matrix still exercises the same components through the default + toolchain when those components change. Args: branch: Branch to compare against. If None, uses default. Returns: - True if the native ESP-IDF compile test should run, False otherwise. + True if the PlatformIO compile test should run, False otherwise. """ - return bool(native_idf_components_to_test(branch)) + return bool(esp32_platformio_components_to_test(branch)) def determine_cpp_unit_tests( @@ -1162,8 +1184,8 @@ def main() -> None: run_python_linters = True run_import_time = True run_device_builder = True - native_idf_components = sorted(NATIVE_IDF_TEST_COMPONENTS) - run_native_idf = True + esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) + run_esp32_platformio = True else: integration_run_all, integration_test_files = determine_integration_tests( args.branch @@ -1173,8 +1195,8 @@ def main() -> None: run_python_linters = should_run_python_linters(args.branch) run_import_time = should_run_import_time(args.branch) run_device_builder = should_run_device_builder(args.branch) - native_idf_components = native_idf_components_to_test(args.branch) - run_native_idf = bool(native_idf_components) + esp32_platformio_components = esp32_platformio_components_to_test(args.branch) + run_esp32_platformio = bool(esp32_platformio_components) run_integration, integration_test_buckets = _compute_integration_test_buckets( integration_run_all, integration_test_files ) @@ -1228,6 +1250,18 @@ def main() -> None: if _component_has_tests(component) ] + # ESP-IDF build-gen/runner changed but no component pulled esp32 in: fold the + # `esp32` component into the matrix so the default native-IDF build path is + # still compiled on an infra-only PR. force_all/core already test everything, + # so skip there. Runs grouped (not added to directly-changed). + if ( + not is_core_change + and _esp_idf_infra_changed(changed) + and "esp32" not in changed_components_with_tests + and _component_has_tests("esp32") + ): + changed_components_with_tests.append("esp32") + # Get directly changed components with tests (for isolated testing) # These will be tested WITHOUT --testing-mode in CI to enable full validation # (pin conflicts, etc.) since they contain the actual changes being reviewed @@ -1345,8 +1379,8 @@ def main() -> None: "python_linters": run_python_linters, "import_time": run_import_time, "device_builder": run_device_builder, - "native_idf": run_native_idf, - "native_idf_components": ",".join(native_idf_components), + "esp32_platformio": run_esp32_platformio, + "esp32_platformio_components": ",".join(esp32_platformio_components), "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index f8f359ee22..a9876632bd 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -68,13 +68,13 @@ def mock_should_run_device_builder() -> Generator[Mock, None, None]: @pytest.fixture -def mock_native_idf_components_to_test() -> Generator[Mock, None, None]: - """Mock native_idf_components_to_test from determine_jobs. +def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]: + """Mock esp32_platformio_components_to_test from determine_jobs. - main() drives both the ``native_idf`` boolean output and the - ``native_idf_components`` CSV from this one function. + main() drives both the ``esp32_platformio`` boolean output and the + ``esp32_platformio_components`` CSV from this one function. """ - with patch.object(determine_jobs, "native_idf_components_to_test") as mock: + with patch.object(determine_jobs, "esp32_platformio_components_to_test") as mock: yield mock @@ -115,7 +115,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -131,7 +131,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True - mock_native_idf_components_to_test.return_value = ["api", "esp32"] + mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -213,8 +213,8 @@ def test_main_all_tests_should_run( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - assert output["native_idf_components"] == "api,esp32" + assert output["esp32_platformio"] is True + assert output["esp32_platformio_components"] == "api,esp32" assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -248,7 +248,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -264,7 +264,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -305,8 +305,8 @@ def test_main_no_tests_should_run( assert output["python_linters"] is False assert output["import_time"] is False assert output["device_builder"] is False - assert output["native_idf"] is False - assert output["native_idf_components"] == "" + assert output["esp32_platformio"] is False + assert output["esp32_platformio_components"] == "" assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -322,6 +322,65 @@ def test_main_no_tests_should_run( assert output["component_test_batches"] == [] +def test_main_esp_idf_infra_change_folds_esp32( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_esp32_platformio_components_to_test: Mock, + mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An ESP-IDF infra-only change folds the `esp32` component into the matrix, + so the default native-IDF build path is still compiled.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_esp32_platformio_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + + # IDF build generator changed; no component changed. + mock_changed_files.return_value = ["esphome/build_gen/espidf.py"] + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "get_changed_components", return_value=[]), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + # esp32 has tests on disk, but pin it so the fold-in isn't coupled to layout. + patch.object(determine_jobs, "_component_has_tests", return_value=True), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, "create_intelligent_batches", return_value=([], {}) + ), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + # Only `esp32` is folded in (not the whole representative set), and it's + # grouped, not isolated (infra changed, not the component). + assert output["changed_components_with_tests"] == ["esp32"] + assert output["directly_changed_components_with_tests"] == [] + assert output["component_test_count"] == 1 + + def test_main_with_branch_argument( mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, @@ -329,7 +388,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -345,7 +404,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True - mock_native_idf_components_to_test.return_value = ["esp32"] + mock_esp32_platformio_components_to_test.return_value = ["esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -384,7 +443,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.assert_called_once_with("main") mock_should_run_import_time.assert_called_once_with("main") mock_should_run_device_builder.assert_called_once_with("main") - mock_native_idf_components_to_test.assert_called_once_with("main") + mock_esp32_platformio_components_to_test.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -398,8 +457,8 @@ def test_main_with_branch_argument( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - assert output["native_idf_components"] == "esp32" + assert output["esp32_platformio"] is True + assert output["esp32_platformio_components"] == "esp32" assert output["changed_components"] == ["mqtt"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -916,23 +975,22 @@ def test_should_run_device_builder_skips_beta_release(target_branch: str) -> Non mock_changed.assert_not_called() -_NATIVE_IDF_FULL_LIST_FILES = [ +_ESP32_PLATFORMIO_FULL_LIST_FILES = [ # Core C++/Python changes -- caught by core_changed() ["esphome/core/component.cpp"], ["esphome/core/config.py"], - # Native IDF infrastructure paths - ["esphome/espidf/framework.py"], - ["esphome/espidf/component.py"], - ["esphome/espidf/api.py"], - ["esphome/build_gen/espidf.py"], + # PlatformIO subsystem (path-prefix trigger) + build generator + ["esphome/platformio/runner.py"], + ["esphome/platformio/toolchain.py"], + ["esphome/build_gen/platformio.py"], # Workflow / harness files ["script/test_build_components.py"], [".github/workflows/ci.yml"], ] -@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES) -def test_native_idf_components_to_test_returns_full_list_on_infrastructure( +@pytest.mark.parametrize("changed_files", _ESP32_PLATFORMIO_FULL_LIST_FILES) +def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure( changed_files: list[str], ) -> None: """Infrastructure / core / harness changes fall back to the full component list.""" @@ -944,8 +1002,8 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( determine_jobs, "get_components_with_dependencies", return_value=["wifi"] ), ): - result = determine_jobs.native_idf_components_to_test() - assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS) + result = determine_jobs.esp32_platformio_components_to_test() + assert result == sorted(determine_jobs.ESP32_PLATFORMIO_TEST_COMPONENTS) @pytest.mark.parametrize( @@ -965,7 +1023,7 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( ["ble_scanner", "esp32_ble", "esp32_ble_tracker"], ), # api in the test set -- narrow to [api] even though the closure - # has other (unrelated to native-IDF coverage) entries. + # has other (unrelated to PlatformIO coverage) entries. ( ["esphome/components/api/api_connection.cpp"], ["api", "logger"], @@ -979,15 +1037,15 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( ), # Pure Python-only change outside trigger paths -> empty. (["esphome/yaml_util.py"], [], []), - # Non-IDF files in esphome/build_gen/ do NOT trigger the full - # list -- only esphome/build_gen/espidf.py is a trigger. - (["esphome/build_gen/platformio.py"], [], []), + # Non-PlatformIO files in esphome/build_gen/ do NOT trigger the + # full list -- only esphome/build_gen/platformio.py is a trigger. + (["esphome/build_gen/espidf.py"], [], []), # Docs / unrelated files -> empty. (["README.md"], [], []), ([], [], []), ], ) -def test_native_idf_components_to_test_narrowing( +def test_esp32_platformio_components_to_test_narrowing( changed_files: list[str], dependency_closure: list[str], expected: list[str], @@ -1001,12 +1059,12 @@ def test_native_idf_components_to_test_narrowing( return_value=dependency_closure, ), ): - result = determine_jobs.native_idf_components_to_test() + result = determine_jobs.esp32_platformio_components_to_test() assert result == expected -def test_native_idf_components_to_test_with_branch() -> None: - """native_idf_components_to_test passes branch argument through. +def test_esp32_platformio_components_to_test_with_branch() -> None: + """esp32_platformio_components_to_test passes branch argument through. Regression test: an earlier version called ``get_changed_components()``, which silently ignored the branch argument because that helper re-runs @@ -1021,7 +1079,7 @@ def test_native_idf_components_to_test_with_branch() -> None: ), ): mock_changed.return_value = [] - determine_jobs.native_idf_components_to_test("release") + determine_jobs.esp32_platformio_components_to_test("release") mock_changed.assert_called_once_with("release") @@ -1033,25 +1091,46 @@ def test_native_idf_components_to_test_with_branch() -> None: (["esp32", "api"], True), ], ) -def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None: - """should_run_native_idf is a thin wrapper around the component list.""" +def test_should_run_esp32_platformio( + components_to_test: list[str], expected: bool +) -> None: + """should_run_esp32_platformio is a thin wrapper around the component list.""" with patch.object( determine_jobs, - "native_idf_components_to_test", + "esp32_platformio_components_to_test", return_value=components_to_test, ): - assert determine_jobs.should_run_native_idf() is expected + assert determine_jobs.should_run_esp32_platformio() is expected -def test_should_run_native_idf_with_branch() -> None: - """Test should_run_native_idf passes branch argument through.""" +def test_should_run_esp32_platformio_with_branch() -> None: + """Test should_run_esp32_platformio passes branch argument through.""" with patch.object( - determine_jobs, "native_idf_components_to_test", return_value=[] + determine_jobs, "esp32_platformio_components_to_test", return_value=[] ) as mock_inner: - determine_jobs.should_run_native_idf("release") + determine_jobs.should_run_esp32_platformio("release") mock_inner.assert_called_once_with("release") +@pytest.mark.parametrize( + ("changed_files", "expected"), + [ + # ESP-IDF runner / framework / build generator -> trigger + (["esphome/espidf/runner.py"], True), + (["esphome/espidf/framework.py"], True), + (["esphome/build_gen/espidf.py"], True), + # PlatformIO build gen and esp32 component are NOT IDF-infra triggers + (["esphome/build_gen/platformio.py"], False), + (["esphome/components/esp32/__init__.py"], False), + (["README.md"], False), + ([], False), + ], +) +def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None: + """ESP-IDF build/runner infra paths are detected; other paths are not.""" + assert determine_jobs._esp_idf_infra_changed(changed_files) is expected + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ @@ -2751,7 +2830,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], @@ -2772,7 +2851,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) mock_changed_files.return_value = [] @@ -2813,9 +2892,9 @@ def test_main_force_all_overrides_detection( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - # native_idf_components is a CSV of NATIVE_IDF_TEST_COMPONENTS - assert "esp32" in output["native_idf_components"].split(",") + assert output["esp32_platformio"] is True + # esp32_platformio_components is a CSV of ESP32_PLATFORMIO_TEST_COMPONENTS + assert "esp32" in output["esp32_platformio_components"].split(",") assert output["cpp_unit_tests_run_all"] is True assert output["cpp_unit_tests_components"] == [] assert output["benchmarks"] is True @@ -2826,7 +2905,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters.assert_not_called() mock_should_run_import_time.assert_not_called() mock_should_run_device_builder.assert_not_called() - mock_native_idf_components_to_test.assert_not_called() + mock_esp32_platformio_components_to_test.assert_not_called() mock_determine_cpp_unit_tests.assert_not_called() # Component matrix is populated from disk (tests/components/ in the repo) assert output["component_test_count"] > 0 @@ -2840,7 +2919,7 @@ def test_main_force_all_off_uses_detection( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], @@ -2855,7 +2934,7 @@ def test_main_force_all_off_uses_detection( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) mock_changed_files.return_value = [] @@ -2886,7 +2965,7 @@ def test_main_force_all_off_uses_detection( assert output["clang_tidy"] is False assert output["clang_format"] is False assert output["python_linters"] is False - assert output["native_idf"] is False + assert output["esp32_platformio"] is False assert output["component_test_count"] == 0 mock_determine_integration_tests.assert_called_once() mock_should_run_clang_tidy.assert_called_once() From bf12af46458c023a372d76f07800426550b702c4 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 18 Jun 2026 09:31:49 -0400 Subject: [PATCH 1688/2214] [wifi] Add runtime suppression of post-connect roaming scans (#17012) Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 16 ++++++ esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 61 ++++++++++++++++++++++ esphome/core/defines.h | 1 + tests/components/wifi/test.esp32-idf.yaml | 6 ++- 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 080a7bb97b..1cfd2b9821 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -764,6 +764,7 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" SCAN_RESULTS_LISTENERS_KEY = "wifi_scan_results_listeners" @@ -794,6 +795,19 @@ def enable_runtime_power_save_control(): CORE.data[RUNTIME_POWER_SAVE_KEY] = True +def enable_runtime_roaming_suppression() -> None: + """Enable runtime suppression of post-connect roaming scans. + + Components that are disrupted by the radio briefly going off-channel during a + roaming scan (e.g., audio playback) should call this function during their code + generation. This enables the request_roaming_suppression() and + release_roaming_suppression() APIs, which pause periodic roaming scans while active. + + Only supported on ESP32. + """ + CORE.data[RUNTIME_ROAMING_SUPPRESSION_KEY] = True + + def request_wifi_ip_state_listener() -> None: """Request an IP state listener slot.""" CORE.data[IP_STATE_LISTENERS_KEY] = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) + 1 @@ -827,6 +841,8 @@ async def final_step(): ) if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False): cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") + if CORE.data.get(RUNTIME_ROAMING_SUPPRESSION_KEY, False): + cg.add_define("USE_WIFI_RUNTIME_ROAMING_SUPPRESSION") # Generate listener defines - each listener type has its own #ifdef ip_state_count = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 07cb2ac243..ffc6ea8e14 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -822,7 +822,7 @@ void WiFiComponent::loop() { } // else: scan in progress, wait } else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS && - now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL) { + now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) { this->check_roaming_(now); } } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index d0521e548a..c774e3a68e 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -16,6 +16,8 @@ #endif #include "esphome/core/string_ref.h" +#include +#include #include #include #include @@ -604,6 +606,49 @@ class WiFiComponent final : public Component { bool release_high_performance(); #endif // USE_WIFI_RUNTIME_POWER_SAVE +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + /** Request that post-connect roaming scans be suppressed. + * + * Components that are disrupted by the radio briefly going off-channel during a + * scan (e.g., audio playback) can call this to pause periodic roaming scans while + * active. Multiple components can request suppression simultaneously; roaming + * resumes once every requester has called release_roaming_suppression(). + * + * A roaming scan already in progress is allowed to finish; this only prevents new + * roaming scans from starting. The roaming interval timer is not reset, so roaming + * resumes on the next loop once suppression is released (and the interval elapsed). + * + * Note: Only supported on ESP32. + * + * Thread-safe: may be called from any task. + */ + void request_roaming_suppression() { + uint8_t current = this->roaming_suppression_count_.load(std::memory_order_relaxed); + // CAS loop: saturate at max instead of wrapping, so an excess of requests can't roll the + // counter back to zero and unintentionally re-enable roaming. + while (current < std::numeric_limits::max() && + !this->roaming_suppression_count_.compare_exchange_weak(current, current + 1, std::memory_order_relaxed)) { + } + } + + /** Release a roaming suppression request. + * + * Must be paired with a prior request_roaming_suppression() call. When all requests + * are released (count reaches zero), post-connect roaming resumes. A release with no + * outstanding request is ignored rather than underflowing the counter. + * + * Thread-safe: may be called from any task. + */ + void release_roaming_suppression() { + uint8_t current = this->roaming_suppression_count_.load(std::memory_order_relaxed); + // CAS loop: decrement only if non-zero, so an unmatched release can't wrap the counter + // and permanently suppress roaming. + while (current > 0 && + !this->roaming_suppression_count_.compare_exchange_weak(current, current - 1, std::memory_order_relaxed)) { + } + } +#endif // USE_ESP32 && USE_WIFI_RUNTIME_ROAMING_SUPPRESSION + protected: #ifdef USE_WIFI_AP void setup_ap_config_(); @@ -732,6 +777,15 @@ class WiFiComponent final : public Component { void process_roaming_scan_(); void clear_roaming_state_(); + /// Returns true if a component has requested that roaming scans be suppressed (e.g. during audio playback). + bool roaming_suppressed_() const { +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + return this->roaming_suppression_count_.load(std::memory_order_relaxed) != 0; +#else + return false; +#endif + } + /// Free scan results memory unless a component needs them void release_scan_results_(); @@ -845,6 +899,13 @@ class WiFiComponent final : public Component { // int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS) int8_t selected_sta_index_{-1}; uint8_t roaming_attempts_{0}; +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + // Count of active roaming-suppression requests. Incremented/decremented from any task + // (e.g. audio playback), read in loop(). Roaming scans are paused while non-zero. + // Relaxed ordering is sufficient: the count value is the only data shared across threads, + // so no happens-before relationship with other memory needs to be established. + std::atomic roaming_suppression_count_{0}; +#endif #if USE_NETWORK_IPV6 uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 410858f904..17b5e64862 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -312,6 +312,7 @@ #define ESPHOME_WIFI_CONNECT_STATE_LISTENERS 2 #define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2 #define USE_WIFI_RUNTIME_POWER_SAVE +#define USE_WIFI_RUNTIME_ROAMING_SUPPRESSION #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index b2b2233ef3..d000c61170 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -1,15 +1,19 @@ psram: -# Tests the high performance request and release; requires the USE_WIFI_RUNTIME_POWER_SAVE define +# Tests the high performance and roaming suppression request/release APIs; +# requires the USE_WIFI_RUNTIME_POWER_SAVE and USE_WIFI_RUNTIME_ROAMING_SUPPRESSION defines esphome: platformio_options: build_flags: - "-DUSE_WIFI_RUNTIME_POWER_SAVE" + - "-DUSE_WIFI_RUNTIME_ROAMING_SUPPRESSION" on_boot: - then: - lambda: |- esphome::wifi::global_wifi_component->request_high_performance(); esphome::wifi::global_wifi_component->release_high_performance(); + esphome::wifi::global_wifi_component->request_roaming_suppression(); + esphome::wifi::global_wifi_component->release_roaming_suppression(); wifi: use_psram: true From c694e96f24a5e7b047b08191ae1729dba29ab8ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 14:07:03 -0500 Subject: [PATCH 1689/2214] [logger] Hold recursion guard while draining the task log buffer The synchronous logging path holds the main-task recursion guard around notify_listeners_, but the buffered drain in Logger::process_messages_ did not. When a message logged from a non-main thread was drained on the main loop and a listener (the API log forwarder, or a logger.on_message automation) logged again on the main task, that re-entrant log reused the shared tx_buffer_ and the API shared_write_buffer_ while they were still in use, corrupting the in-flight message; on ESP32 this surfaced as a StoreProhibited panic in the API send path. Hold a RecursionGuard on main_task_recursion_guard_ across the drain so re-entrant main-task logs are dropped there too, mirroring the synchronous path. Adds an integration test that drives the buffered drain with a re-entrant on_message log and verifies the buffered messages are delivered uncorrupted. --- esphome/components/logger/logger.cpp | 4 + .../logger_buffered_recursion_guard.yaml | 61 +++++++++ .../test_logger_buffered_recursion_guard.py | 119 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/integration/fixtures/logger_buffered_recursion_guard.yaml create mode 100644 tests/integration/test_logger_buffered_recursion_guard.py diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index a035525101..684da0202e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -175,6 +175,10 @@ void Logger::process_messages_() { #ifdef USE_ESPHOME_TASK_LOG_BUFFER // Process any buffered messages when available if (this->log_buffer_.has_messages()) { + // Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering + // and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here. + // Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path. + RecursionGuard guard(this->main_task_recursion_guard_); logger::TaskLogBuffer::LogMessage *message; uint16_t text_length; while (this->log_buffer_.borrow_message_main_loop(message, text_length)) { diff --git a/tests/integration/fixtures/logger_buffered_recursion_guard.yaml b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml new file mode 100644 index 0000000000..058adbff99 --- /dev/null +++ b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml @@ -0,0 +1,61 @@ +esphome: + name: logger-recursion-test +host: +api: +logger: + level: DEBUG + on_message: + # Fires on the main loop for every message delivered to listeners, including + # messages drained from the task log buffer (i.e. logged from a non-main thread). + # The lambda logs again on the main task. Without a recursion guard on the buffered + # drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the + # buffered message that is still being delivered, corrupting its console output. + - level: VERY_VERBOSE + then: + - lambda: |- + ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER"); + +button: + - platform: template + name: "Start Race Test" + id: start_test_button + on_press: + - lambda: |- + // Keep the count well under the host task-log-buffer slot count so every + // message goes through the ring buffer (buffered drain path) instead of the + // emergency console fallback. The main loop is blocked in pthread_join while + // the thread logs, so all messages are drained together once it returns. + static const int NUM_MESSAGES = 30; + + struct ThreadTest { + static void *thread_func(void *arg) { + char thread_name[16]; + snprintf(thread_name, sizeof(thread_name), "LogThread"); + #ifdef __APPLE__ + pthread_setname_np(thread_name); + #else + pthread_setname_np(pthread_self(), thread_name); + #endif + + for (int i = 0; i < NUM_MESSAGES; i++) { + // Verifiable payload: data is a deterministic function of the message + // index, so a clobbered buffer shows up as a missing or mismatched line. + ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345); + } + return nullptr; + } + }; + + // RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the + // synchronous path, which already holds the recursion guard) so the test can + // always detect completion even when the buffered path is corrupted. + ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES); + + pthread_t thread; + if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) { + ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread"); + return; + } + pthread_join(thread, nullptr); + + ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES); diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py new file mode 100644 index 0000000000..152e204214 --- /dev/null +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -0,0 +1,119 @@ +"""Integration test for the recursion guard on the buffered logger drain path. + +Regression test for a crash where a log message drained from the task log buffer +(i.e. logged from a non-main thread) re-entered the logger on the main task while it +was still being delivered to listeners. The buffered drain in +``Logger::process_messages_`` did not hold the main-task recursion guard that the +synchronous logging path holds, so a listener callback that logged again on the main +task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused +the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed +up as a ``StoreProhibited`` panic inside the API send path. + +The fixture logs a small batch of verifiable messages from a non-main thread (kept +under the host task-log-buffer slot count so they all take the buffered drain path +rather than the emergency console fallback) while an ``on_message`` automation re-logs +``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message. + +Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while +the buffered thread message is still being delivered, so the message the API receives is +contaminated (it contains the marker and an embedded newline glued onto the thread +payload). With the guard the re-entrant log is dropped during the drain, the marker +never appears, and every thread message is delivered clean. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import LogLevel +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index +THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})") + +NUM_MESSAGES = 30 + + +@pytest.mark.asyncio +async def test_logger_buffered_recursion_guard( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Buffered (non-main-thread) log messages survive a re-entrant main-task log.""" + console_lines: list[str] = [] + api_messages: list[str] = [] + test_complete_event = asyncio.Event() + + def line_callback(line: str) -> None: + console_lines.append(line) + if "RACE_TEST_COMPLETE" in line: + test_complete_event.set() + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "logger-recursion-test" + + # Subscribe over the API: this is the exact path that crashed in the field + # (the API log callback runs during the buffered drain). The API message field + # preserves embedded newlines, so it reliably exposes a clobbered buffer. + def on_log(msg) -> None: + api_messages.append(msg.message.decode("utf-8", errors="replace")) + + client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE) + + entities, _ = await client.list_entities_services() + buttons = [e for e in entities if e.name == "Start Race Test"] + assert buttons, "Could not find Start Race Test button" + client.button_command(buttons[0].key) + + # RACE_TEST_COMPLETE is logged from the main task, so it arrives even if the + # buffered path is corrupted. The buffered messages are drained afterwards. + try: + await asyncio.wait_for(test_complete_event.wait(), timeout=30.0) + except TimeoutError: + pytest.fail( + "Test did not complete within timeout; device likely crashed or hung. " + f"Collected {len(console_lines)} console lines." + ) + + # Allow the buffered messages to drain after the thread joined. + await asyncio.sleep(0.5) + + intact: set[int] = set() + contaminated: list[str] = [] + for raw in api_messages: + text = _ANSI.sub("", raw) + if "THREADMSG" not in text: + continue + # A clean thread message is a single line carrying only its own payload. A + # clobbered buffer glues the re-entrant marker (and an embedded newline) onto it. + if "REENTRANT" in text or "\n" in text: + contaminated.append(repr(raw)) + continue + match = THREAD_MSG_PATTERN.search(text) + assert match, f"Unexpected thread message format: {raw!r}" + msg_num = int(match.group(1)) + expected = f"{msg_num * 12345:08X}" + if match.group(2) != expected: + contaminated.append(repr(raw)) + continue + intact.add(msg_num) + + assert not contaminated, ( + "Buffered thread messages were clobbered by a re-entrant main-task log " + "(missing recursion guard on the buffered drain path):\n" + + "\n".join(contaminated[:10]) + ) + assert len(intact) == NUM_MESSAGES, ( + f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got " + f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}" + ) From 14e89f3dae752e968d979b40f437ed2814ee3615 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:31:17 +0000 Subject: [PATCH 1690/2214] Bump actions/checkout from 6.0.3 to 7.0.0 (#17049) Signed-off-by: dependabot[bot] --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 6 +-- .github/workflows/ci-github-scripts.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 42 +++++++++---------- .../codeowner-approved-label-update.yml | 2 +- .../workflows/codeowner-review-request.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/pr-title-check.yml | 2 +- .github/workflows/release.yml | 8 ++-- .github/workflows/sync-device-classes.yml | 4 +- 12 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index e48d6f69bd..d034227ef6 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,7 +24,7 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index c6e9a358ab..2155b67b25 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 373cd905b1..8301f8e9e3 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -61,7 +61,7 @@ jobs: tag: ${{ steps.tag.outputs.tag }} push: ${{ steps.tag.outputs.push }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -145,7 +145,7 @@ jobs: - "ha-addon" - "docker" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -202,7 +202,7 @@ jobs: - nrf52 - host steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml index 43d530128c..3313ced690 100644 --- a/.github/workflows/ci-github-scripts.yml +++ b/.github/workflows/ci-github-scripts.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Run tests working-directory: .github/scripts/auto-label-pr diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 35cfce65f8..4bef082aab 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29d42330cd..e46c6e2fc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -74,7 +74,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -97,7 +97,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -124,7 +124,7 @@ jobs: if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -152,11 +152,11 @@ jobs: if: needs.determine-jobs.outputs.device-builder == 'true' steps: - name: Check out esphome (this PR) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: esphome - name: Check out esphome/device-builder - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: esphome/device-builder ref: main @@ -225,7 +225,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -285,7 +285,7 @@ jobs: benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -357,7 +357,7 @@ jobs: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python 3.13 id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -409,7 +409,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -438,7 +438,7 @@ jobs: (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -496,7 +496,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -579,7 +579,7 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -659,7 +659,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -743,7 +743,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -826,7 +826,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -956,7 +956,7 @@ jobs: TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -990,7 +990,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1016,7 +1016,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.base_ref }} @@ -1198,7 +1198,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1267,7 +1267,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 1bd60fd11d..9b1333734e 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 5ad0b02de1..da9c5f63d6 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e559472b60..5a448c4003 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 0e2efb1bcf..2bb6505b74 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8efc395951..3056d9e7d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read # actions/checkout to build the sdist/wheel id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index ab1ce2b587..05036f3500 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -28,10 +28,10 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Checkout Home Assistant - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: home-assistant/core path: lib/home-assistant From a39505f5ef0426fe21977a9cc8c7e9a7dad3d983 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:33:01 +0000 Subject: [PATCH 1691/2214] Bump CodSpeedHQ/action from 4.17.5 to 4.17.6 (#17047) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e46c6e2fc5..6774695e58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5 + uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6 with: run: | . venv/bin/activate From 1a553018bfa8a8e84da002a136643590e1c135eb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:38:57 -0400 Subject: [PATCH 1692/2214] [build] Skip target-platform deps when populating host unit-test config (#17039) --- script/build_helpers.py | 21 ++++++--- tests/script/test_build_helpers.py | 76 ++++++++++++++++++++++++++++++ tests/script/test_test_helpers.py | 2 + 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/script/test_build_helpers.py diff --git a/script/build_helpers.py b/script/build_helpers.py index eaf3a1f1a7..50830c221e 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -70,12 +70,15 @@ def populate_dependency_config( * ``domain.platform`` form (e.g. ``sensor.gpio``) appends ``{platform: }`` to ``config[domain]``, creating the list if needed. - * Bare components are looked up via ``get_component_fn``. Platform - components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are - initialised as ``[]`` so the sibling ``domain.platform`` branch can - ``append`` into them. Everything else is populated by running the - component's schema with ``{}`` so defaults exist; if the schema requires - explicit input, an empty ``{}`` is used as a fallback. + * Bare components are looked up via ``get_component_fn``. Target-platform + components (``is_target_platform``, e.g. ``esp32``) are skipped entirely: + a host build targets ``host``, so a foreign target platform's sources are + guarded out and its schema must not run here (it would mutate global CORE + state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``) + and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling + ``domain.platform`` branch can ``append`` into them. Everything else is + populated by running the component's schema with ``{}`` so defaults exist; + if the schema requires explicit input, an empty ``{}`` is used as a fallback. Platform components must always be a list here even when no ``domain.platform`` entry follows, because the ``domain.platform`` branch @@ -96,6 +99,12 @@ def populate_dependency_config( component = get_component_fn(component_name) if component is None: continue + # Skip target platforms (e.g. esp32): a host build targets `host`, so a + # foreign target's sources are guarded out, and running its schema with + # {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF), + # crashing the host compile. See #17035. + if component.is_target_platform: + continue if component.multi_conf or component.is_platform_component: config.setdefault(component_name, []) elif component_name not in config: diff --git a/tests/script/test_build_helpers.py b/tests/script/test_build_helpers.py new file mode 100644 index 0000000000..efa6a75483 --- /dev/null +++ b/tests/script/test_build_helpers.py @@ -0,0 +1,76 @@ +"""Unit tests for script/build_helpers.py.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to the path so we can import build_helpers. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import build_helpers # noqa: E402 + +from esphome.core import CORE # noqa: E402 + + +class _FakeComponent: + def __init__(self, config_schema, *, is_target_platform=False): + self.multi_conf = False + self.is_platform_component = False + self.is_target_platform = is_target_platform + self.config_schema = config_schema + + +@pytest.fixture(autouse=True) +def _restore_core_toolchain(): + """Keep CORE.toolchain changes from leaking between tests.""" + saved = CORE.toolchain + try: + yield + finally: + CORE.toolchain = saved + + +def test_populate_dependency_config_skips_target_platforms() -> None: + """Target-platform deps must be skipped, not config-populated, in a host build. + + Regression test for #17035: esp32 (a target platform) appears only as a + transitive dependency of a host C++ unit test. Running its schema with {} + set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation, + which crashed the host compile with KeyError('esp32'). The fix skips + target-platform components entirely so their schema never runs. + """ + CORE.toolchain = None # the state a host build starts from + schema_calls = [] + + def leaky_schema(value): + # If this ever runs for a target platform, the bug is back. + schema_calls.append(value) + CORE.toolchain = "esp-idf-leak" + raise ValueError("no board or variant") + + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["esp32"], + get_component_fn=lambda name: _FakeComponent( + leaky_schema, is_target_platform=True + ), + register_platform_fn=lambda domain: None, + ) + + assert "esp32" not in config # skipped: no synthesized entry + assert schema_calls == [] # schema never run + assert CORE.toolchain is None # no global side effect leaked + + +def test_populate_dependency_config_populates_defaults() -> None: + """A non-target-platform dep still has its schema defaults harvested.""" + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["ok"], + get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}), + register_platform_fn=lambda domain: None, + ) + assert config["ok"] == {"default": 1} diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index a8100252da..4b05cab376 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -266,11 +266,13 @@ def _make_component_stub( *, multi_conf: bool = False, is_platform_component: bool = False, + is_target_platform: bool = False, config_schema=None, ) -> MagicMock: stub = MagicMock() stub.multi_conf = multi_conf stub.is_platform_component = is_platform_component + stub.is_target_platform = is_target_platform stub.config_schema = config_schema return stub From 19cca9e177045dd95f86cc351a25c7d5a4fc89b1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:41:03 -0400 Subject: [PATCH 1693/2214] [esp32] Remove framework migration notice (#17023) --- esphome/components/esp32/__init__.py | 53 ---------------------------- 1 file changed, 53 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aee86a0554..ec33d9d271 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1589,65 +1589,12 @@ FRAMEWORK_SCHEMA = cv.Schema( ) -# Remove this class in 2026.7.0 -class _FrameworkMigrationWarning: - shown = False - - -def _show_framework_migration_message(name: str, variant: str) -> None: - """Show a message about the framework default change and how to switch back to Arduino.""" - # Remove this function in 2026.7.0 - if _FrameworkMigrationWarning.shown: - return - _FrameworkMigrationWarning.shown = True - - from esphome.log import AnsiFore, color - - message = ( - color( - AnsiFore.BOLD_CYAN, - f"💡 NOTICE: {name} does not have a framework specified.", - ) - + "\n\n" - + f"Starting with ESPHome 2026.1.0, the default framework for {variant} is ESP-IDF.\n" - + "(We've been warning about this change since ESPHome 2025.8.0)\n" - + "\n" - + "Why we made this change:\n" - + color(AnsiFore.GREEN, " ✨ Smaller firmware binaries\n") - + color(AnsiFore.GREEN, " ⚡ Faster compile times\n") - + color(AnsiFore.GREEN, " 🚀 Better performance and newer features\n") - + color(AnsiFore.GREEN, " 🔧 More actively maintained by ESPHome\n") - + "\n" - + "To continue using Arduino, add this to your YAML under 'esp32:':\n" - + color(AnsiFore.WHITE, " framework:\n") - + color(AnsiFore.WHITE, " type: arduino\n") - + "\n" - + "To silence this message with ESP-IDF, explicitly set:\n" - + color(AnsiFore.WHITE, " framework:\n") - + color(AnsiFore.WHITE, " type: esp-idf\n") - + "\n" - + "Migration guide: " - + color( - AnsiFore.BLUE, - "https://esphome.io/guides/esp32_arduino_to_idf/", - ) - ) - _LOGGER.warning(message) - - def _set_default_framework(config): config = config.copy() if CONF_FRAMEWORK not in config: config[CONF_FRAMEWORK] = FRAMEWORK_SCHEMA({}) if CONF_TYPE not in config[CONF_FRAMEWORK]: - variant = config[CONF_VARIANT] config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ESP_IDF - # Show migration message for variants that previously defaulted to Arduino - # Remove this message in 2026.7.0 - if variant in ARDUINO_ALLOWED_VARIANTS: - _show_framework_migration_message( - config.get(CONF_NAME, "This device"), variant - ) return config From f6c78f74154d8328b163bb053d170ebc7349924b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:43:17 -0400 Subject: [PATCH 1694/2214] [uptime] Revert timestamp sensor device_class to timestamp (#17037) --- esphome/components/uptime/sensor/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index 6ce0795cdb..e2a7aee1a2 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, DEVICE_CLASS_DURATION, - DEVICE_CLASS_UPTIME, + DEVICE_CLASS_TIMESTAMP, ENTITY_CATEGORY_DIAGNOSTIC, ICON_TIMER, STATE_CLASS_TOTAL_INCREASING, @@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend(cv.polling_component_schema("60s")), "timestamp": sensor.sensor_schema( UptimeTimestampSensor, + icon=ICON_TIMER, accuracy_decimals=0, - device_class=DEVICE_CLASS_UPTIME, + device_class=DEVICE_CLASS_TIMESTAMP, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ) .extend( From 0b37b9a202b9bf8d11040a25176bdc3f310ec351 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 14:51:04 -0500 Subject: [PATCH 1695/2214] [logger] Wait deterministically for buffered drain in test Replace the fixed asyncio.sleep(0.5) with a wait on an asyncio.Event that fires once all thread messages have arrived over the API. Every buffered message is delivered whether it survives intact or gets clobbered, so counting THREADMSG occurrences is a deterministic drain-complete signal with no arbitrary sleep and no dependence on the fix being present. --- .../test_logger_buffered_recursion_guard.py | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py index 152e204214..5bef915b28 100644 --- a/tests/integration/test_logger_buffered_recursion_guard.py +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -45,17 +45,11 @@ async def test_logger_buffered_recursion_guard( api_client_connected: APIClientConnectedFactory, ) -> None: """Buffered (non-main-thread) log messages survive a re-entrant main-task log.""" - console_lines: list[str] = [] api_messages: list[str] = [] - test_complete_event = asyncio.Event() - - def line_callback(line: str) -> None: - console_lines.append(line) - if "RACE_TEST_COMPLETE" in line: - test_complete_event.set() + all_drained = asyncio.Event() async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config), api_client_connected() as client, ): device_info = await client.device_info() @@ -65,8 +59,17 @@ async def test_logger_buffered_recursion_guard( # Subscribe over the API: this is the exact path that crashed in the field # (the API log callback runs during the buffered drain). The API message field # preserves embedded newlines, so it reliably exposes a clobbered buffer. + # + # Every buffered thread message is delivered here whether it survives intact or + # gets clobbered (a clobbered message still carries its THREADMSG payload), so + # counting THREADMSG occurrences is a deterministic "drain complete" signal: no + # arbitrary sleep, no dependence on the fix being present. def on_log(msg) -> None: - api_messages.append(msg.message.decode("utf-8", errors="replace")) + text = msg.message.decode("utf-8", errors="replace") + api_messages.append(text) + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + if received >= NUM_MESSAGES: + all_drained.set() client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE) @@ -75,19 +78,16 @@ async def test_logger_buffered_recursion_guard( assert buttons, "Could not find Start Race Test button" client.button_command(buttons[0].key) - # RACE_TEST_COMPLETE is logged from the main task, so it arrives even if the - # buffered path is corrupted. The buffered messages are drained afterwards. + # Wait until every buffered thread message has been delivered over the API. try: - await asyncio.wait_for(test_complete_event.wait(), timeout=30.0) + await asyncio.wait_for(all_drained.wait(), timeout=30.0) except TimeoutError: + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) pytest.fail( - "Test did not complete within timeout; device likely crashed or hung. " - f"Collected {len(console_lines)} console lines." + f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; " + "device likely crashed or hung." ) - # Allow the buffered messages to drain after the thread joined. - await asyncio.sleep(0.5) - intact: set[int] = set() contaminated: list[str] = [] for raw in api_messages: From 53e85e07d475abab906f6597e9d1b5a7c958dc84 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:56:21 -0400 Subject: [PATCH 1696/2214] [esp32] Support `esphome idedata` with the native ESP-IDF toolchain (#17040) --- esphome/__main__.py | 15 ++++++++++++ esphome/espidf/toolchain.py | 1 + tests/unit_tests/test_espidf_toolchain.py | 28 +++++++++++++++++++---- tests/unit_tests/test_main.py | 26 +++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 27dd878495..bda3dcbd05 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1771,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json + if CORE.using_toolchain_esp_idf: + # Native ESP-IDF derives idedata from the build's compile_commands.json, + # so the configuration must already be compiled. + from esphome.espidf import toolchain as espidf_toolchain + + idedata = espidf_toolchain.get_idedata() + if idedata is None: + _LOGGER.error( + "No idedata available; compile the configuration first", + ) + return 1 + + print(json.dumps(idedata, indent=2) + "\n") + return 0 + if not CORE.using_toolchain_platformio: _LOGGER.error( "The idedata command is not compatible with %s toolchain", diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index c622a2dd36..000ce739db 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -472,6 +472,7 @@ def get_idedata() -> dict | None: pass data = idedata_from_build(compile_commands) + data["prog_path"] = str(get_elf_path()) cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return data diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index b2309439f9..017d8c49b4 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "g++"} - assert json.loads(cache.read_text()) == {"cxx_path": "g++"} + prog_path = str(toolchain.get_elf_path()) + assert result == {"cxx_path": "g++", "prog_path": prog_path} + assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path} def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: @@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) - result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "fresh"} + assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())} def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: @@ -147,7 +148,26 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "regen"} + assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())} + + +def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None: + """The idedata exposes prog_path (the ELF) so consumers like build-action + can locate firmware.factory.bin / firmware.ota.bin as its siblings.""" + compile_commands, _ = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "g++"}, + ): + result = toolchain.get_idedata() + + # Use Path semantics so the contract holds on Windows too (backslashes). + prog_path = Path(result["prog_path"]) + assert prog_path.name == "firmware.elf" + assert prog_path.parent.name == "build" def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e44f746a75..acd39cedc6 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -32,6 +32,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_idedata, command_rename, command_run, command_update_all, @@ -6257,3 +6258,28 @@ def test_command_run_defaults_subscribe_states_true( mock_run_logs.assert_called_once_with( CORE.config, ["192.168.1.100"], subscribe_states=True ) + + +def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: + """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} + + with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get: + result = command_idedata(MagicMock(), CORE.config) + + assert result == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def test_command_idedata_esp_idf_no_build_errors() -> None: + """Under ESP-IDF, a missing build (no idedata) returns an error, not a crash.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + + with patch("esphome.espidf.toolchain.get_idedata", return_value=None): + result = command_idedata(MagicMock(), CORE.config) + + assert result == 1 From a0f546e375ae2c74c34b4ce5b25dd5e520c5ec14 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:08:22 -0400 Subject: [PATCH 1697/2214] [ci] Smoke-test Arduino framework in esp32 PlatformIO job (#17034) --- .github/workflows/ci.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6774695e58..10ace8c179 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -971,16 +971,17 @@ jobs: echo "Testing components: $TEST_COMPONENTS" echo "" - # Run config validation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio - - echo "" - echo "Config validation passed! Starting compilation..." - echo "" - - # Run compilation (auto-grouped by test_build_components.py) + # compile validates config first, so a separate config pass is + # redundant for this smoke test. ESP-IDF framework via PlatformIO: python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio + echo "" + echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..." + echo "" + + # Arduino framework via PlatformIO (only components with an esp32-ard test are built): + python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio + pre-commit-ci-lite: name: pre-commit.ci lite runs-on: ubuntu-latest From 8e7518fe9df898e487f15a570c55c1c09f8cd12e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:15:38 -0400 Subject: [PATCH 1698/2214] [esp32] Don't overwrite PlatformIO's factory.bin (#17042) --- esphome/components/esp32/post_build.py.script | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index b329f6b82b..f1a38f9e76 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env): flash_size = env.BoardConfig().get("upload.flash_size", "4MB") chip = env.BoardConfig().get("build.mcu", "esp32") + # PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right + # artifact names and partition offsets, including custom partition tables). The merge + # below is only a fallback and cannot honor custom layouts, so don't overwrite an image + # PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt, + # and PlatformIO's combined-image builder runs before us in that batch, so an existing + # file here is current. + output_path = firmware_path.with_suffix(".factory.bin") + if output_path.exists(): + print(f"{output_path.name} already created by PlatformIO - skipping merge") + return + sections = [] flasher_args_path = build_dir / "flasher_args.json" @@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env): print("No valid flash sections found — skipping .factory.bin creation.") return - output_path = firmware_path.with_suffix(".factory.bin") python_exe = f'"{env.subst("$PYTHONEXE")}"' cmd = [ python_exe, From b97182d302ef98da48fc26eb10149bf3d5b1b853 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 16:16:14 -0500 Subject: [PATCH 1699/2214] [logger] Hold recursion guard while draining the task log buffer (#17044) --- esphome/components/logger/logger.cpp | 4 + .../logger_buffered_recursion_guard.yaml | 61 +++++++++ .../test_logger_buffered_recursion_guard.py | 119 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/integration/fixtures/logger_buffered_recursion_guard.yaml create mode 100644 tests/integration/test_logger_buffered_recursion_guard.py diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index a035525101..684da0202e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -175,6 +175,10 @@ void Logger::process_messages_() { #ifdef USE_ESPHOME_TASK_LOG_BUFFER // Process any buffered messages when available if (this->log_buffer_.has_messages()) { + // Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering + // and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here. + // Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path. + RecursionGuard guard(this->main_task_recursion_guard_); logger::TaskLogBuffer::LogMessage *message; uint16_t text_length; while (this->log_buffer_.borrow_message_main_loop(message, text_length)) { diff --git a/tests/integration/fixtures/logger_buffered_recursion_guard.yaml b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml new file mode 100644 index 0000000000..058adbff99 --- /dev/null +++ b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml @@ -0,0 +1,61 @@ +esphome: + name: logger-recursion-test +host: +api: +logger: + level: DEBUG + on_message: + # Fires on the main loop for every message delivered to listeners, including + # messages drained from the task log buffer (i.e. logged from a non-main thread). + # The lambda logs again on the main task. Without a recursion guard on the buffered + # drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the + # buffered message that is still being delivered, corrupting its console output. + - level: VERY_VERBOSE + then: + - lambda: |- + ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER"); + +button: + - platform: template + name: "Start Race Test" + id: start_test_button + on_press: + - lambda: |- + // Keep the count well under the host task-log-buffer slot count so every + // message goes through the ring buffer (buffered drain path) instead of the + // emergency console fallback. The main loop is blocked in pthread_join while + // the thread logs, so all messages are drained together once it returns. + static const int NUM_MESSAGES = 30; + + struct ThreadTest { + static void *thread_func(void *arg) { + char thread_name[16]; + snprintf(thread_name, sizeof(thread_name), "LogThread"); + #ifdef __APPLE__ + pthread_setname_np(thread_name); + #else + pthread_setname_np(pthread_self(), thread_name); + #endif + + for (int i = 0; i < NUM_MESSAGES; i++) { + // Verifiable payload: data is a deterministic function of the message + // index, so a clobbered buffer shows up as a missing or mismatched line. + ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345); + } + return nullptr; + } + }; + + // RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the + // synchronous path, which already holds the recursion guard) so the test can + // always detect completion even when the buffered path is corrupted. + ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES); + + pthread_t thread; + if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) { + ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread"); + return; + } + pthread_join(thread, nullptr); + + ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES); diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py new file mode 100644 index 0000000000..5bef915b28 --- /dev/null +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -0,0 +1,119 @@ +"""Integration test for the recursion guard on the buffered logger drain path. + +Regression test for a crash where a log message drained from the task log buffer +(i.e. logged from a non-main thread) re-entered the logger on the main task while it +was still being delivered to listeners. The buffered drain in +``Logger::process_messages_`` did not hold the main-task recursion guard that the +synchronous logging path holds, so a listener callback that logged again on the main +task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused +the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed +up as a ``StoreProhibited`` panic inside the API send path. + +The fixture logs a small batch of verifiable messages from a non-main thread (kept +under the host task-log-buffer slot count so they all take the buffered drain path +rather than the emergency console fallback) while an ``on_message`` automation re-logs +``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message. + +Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while +the buffered thread message is still being delivered, so the message the API receives is +contaminated (it contains the marker and an embedded newline glued onto the thread +payload). With the guard the re-entrant log is dropped during the drain, the marker +never appears, and every thread message is delivered clean. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import LogLevel +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index +THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})") + +NUM_MESSAGES = 30 + + +@pytest.mark.asyncio +async def test_logger_buffered_recursion_guard( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Buffered (non-main-thread) log messages survive a re-entrant main-task log.""" + api_messages: list[str] = [] + all_drained = asyncio.Event() + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "logger-recursion-test" + + # Subscribe over the API: this is the exact path that crashed in the field + # (the API log callback runs during the buffered drain). The API message field + # preserves embedded newlines, so it reliably exposes a clobbered buffer. + # + # Every buffered thread message is delivered here whether it survives intact or + # gets clobbered (a clobbered message still carries its THREADMSG payload), so + # counting THREADMSG occurrences is a deterministic "drain complete" signal: no + # arbitrary sleep, no dependence on the fix being present. + def on_log(msg) -> None: + text = msg.message.decode("utf-8", errors="replace") + api_messages.append(text) + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + if received >= NUM_MESSAGES: + all_drained.set() + + client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE) + + entities, _ = await client.list_entities_services() + buttons = [e for e in entities if e.name == "Start Race Test"] + assert buttons, "Could not find Start Race Test button" + client.button_command(buttons[0].key) + + # Wait until every buffered thread message has been delivered over the API. + try: + await asyncio.wait_for(all_drained.wait(), timeout=30.0) + except TimeoutError: + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + pytest.fail( + f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; " + "device likely crashed or hung." + ) + + intact: set[int] = set() + contaminated: list[str] = [] + for raw in api_messages: + text = _ANSI.sub("", raw) + if "THREADMSG" not in text: + continue + # A clean thread message is a single line carrying only its own payload. A + # clobbered buffer glues the re-entrant marker (and an embedded newline) onto it. + if "REENTRANT" in text or "\n" in text: + contaminated.append(repr(raw)) + continue + match = THREAD_MSG_PATTERN.search(text) + assert match, f"Unexpected thread message format: {raw!r}" + msg_num = int(match.group(1)) + expected = f"{msg_num * 12345:08X}" + if match.group(2) != expected: + contaminated.append(repr(raw)) + continue + intact.add(msg_num) + + assert not contaminated, ( + "Buffered thread messages were clobbered by a re-entrant main-task log " + "(missing recursion guard on the buffered drain path):\n" + + "\n".join(contaminated[:10]) + ) + assert len(intact) == NUM_MESSAGES, ( + f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got " + f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}" + ) From a91a5766545a1f813cb849c551fdb8e61816df38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 16:27:15 -0500 Subject: [PATCH 1700/2214] merge --- esphome/components/time/real_time_clock.cpp | 31 +++++++++++++++++++++ esphome/components/time/real_time_clock.h | 28 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 894e7aed52..4e623942ac 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -107,4 +107,35 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { this->time_sync_callback_.call(); } +#ifdef USE_TIME_TIMEZONE +void RealTimeClock::apply_timezone_(const char *tz) { + ParsedTimezone parsed{}; + + // Handle null or empty input - use UTC + if (tz == nullptr || *tz == '\0') { + // Skip if already UTC + if (!get_global_tz().has_dst() && get_global_tz().std_offset_seconds == 0) { + return; + } + set_global_tz(parsed); + return; + } + +#ifdef USE_HOST + // On host platform, also set TZ environment variable for libc compatibility + setenv("TZ", tz, 1); + tzset(); +#endif + + // Parse the POSIX TZ string using our custom parser + if (!parse_posix_tz(tz, parsed)) { + ESP_LOGW(TAG, "Failed to parse timezone: %s", tz); + return; + } + + // Set global timezone for all time conversions + set_global_tz(parsed); +} +#endif + } // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 931cd1e946..06ee2ea5af 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -22,6 +22,30 @@ class RealTimeClock : public PollingComponent { public: explicit RealTimeClock(); +#ifdef USE_TIME_TIMEZONE + /// Set the time zone from a POSIX TZ string. + void set_timezone(const char *tz) { this->apply_timezone_(tz); } + + /// Set the time zone from a character buffer with known length. + /// The buffer does not need to be null-terminated. + void set_timezone(const char *tz, size_t len) { + if (tz == nullptr) { + this->apply_timezone_(nullptr); + return; + } + // Stack buffer - TZ strings from tzdata are typically short (< 50 chars) + char buf[128]; + if (len >= sizeof(buf)) + len = sizeof(buf) - 1; + memcpy(buf, tz, len); + buf[len] = '\0'; + this->apply_timezone_(buf); + } + + /// Set the time zone from a std::string. + void set_timezone(const std::string &tz) { this->apply_timezone_(tz.c_str()); } +#endif + /// Get the time in the currently defined timezone. ESPTime now(); @@ -41,6 +65,10 @@ class RealTimeClock : public PollingComponent { /// Report a unix epoch as current time. void synchronize_epoch_(uint32_t epoch); +#ifdef USE_TIME_TIMEZONE + void apply_timezone_(const char *tz); +#endif + LazyCallbackManager time_sync_callback_; }; From a497174da24cd864cade284a17e89c9732479ba6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:05:20 +1200 Subject: [PATCH 1701/2214] Bump bundled esphome-device-builder to 1.0.10 (#17051) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 18a9903735..221121c8d3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10 RUN \ platformio settings set enable_telemetry No \ From ac5a28301a51ad3a68a8e863465e618d494e87a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jun 2026 22:11:24 -0500 Subject: [PATCH 1702/2214] [core] Honor transferred address cache in has_resolvable_address (#17025) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 6 ++++++ tests/unit_tests/test_main.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index f7d3f8e834..27dd878495 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -504,6 +504,12 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True + # The dashboard pre-resolves the device and passes the IPs via + # --mdns-address-cache/--dns-address-cache; honor a cached address even when the + # device has mDNS disabled (e.g. a .local host found via ping). + if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address): + return True + if has_mdns(): return True diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 03c005dc27..e44f746a75 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -689,6 +689,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: assert result == ["192.168.1.100"] +def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None: + """A .local device with mDNS disabled resolves via the dashboard-supplied cache.""" + setup_core( + config={ + CONF_API: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + CONF_MDNS: {CONF_DISABLED: True}, + }, + address="esp32-a1s.local", + ) + CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]}) + + for purpose in (Purpose.LOGGING, Purpose.UPLOADING): + result = choose_upload_log_host( + default="OTA", check_default=None, purpose=purpose + ) + assert result == ["192.168.1.50"] + + def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") @@ -3135,6 +3154,22 @@ def test_has_resolvable_address() -> None: setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None) assert has_resolvable_address() is False + # mDNS disabled + .local, but the dashboard cached the address -> resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache( + mdns_cache={"esphome-device.local": ["192.168.1.100"]} + ) + assert has_resolvable_address() is True + + # mDNS disabled + .local, cache present but missing this host -> not resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]}) + assert has_resolvable_address() is False + def test_has_name_add_mac_suffix() -> None: """Test has_name_add_mac_suffix function.""" From 86096b96f583a1902de8c4b935826c4f69fdeac4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:38:57 -0400 Subject: [PATCH 1703/2214] [build] Skip target-platform deps when populating host unit-test config (#17039) --- script/build_helpers.py | 21 ++++++--- tests/script/test_build_helpers.py | 76 ++++++++++++++++++++++++++++++ tests/script/test_test_helpers.py | 2 + 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/script/test_build_helpers.py diff --git a/script/build_helpers.py b/script/build_helpers.py index eaf3a1f1a7..50830c221e 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -70,12 +70,15 @@ def populate_dependency_config( * ``domain.platform`` form (e.g. ``sensor.gpio``) appends ``{platform: }`` to ``config[domain]``, creating the list if needed. - * Bare components are looked up via ``get_component_fn``. Platform - components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are - initialised as ``[]`` so the sibling ``domain.platform`` branch can - ``append`` into them. Everything else is populated by running the - component's schema with ``{}`` so defaults exist; if the schema requires - explicit input, an empty ``{}`` is used as a fallback. + * Bare components are looked up via ``get_component_fn``. Target-platform + components (``is_target_platform``, e.g. ``esp32``) are skipped entirely: + a host build targets ``host``, so a foreign target platform's sources are + guarded out and its schema must not run here (it would mutate global CORE + state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``) + and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling + ``domain.platform`` branch can ``append`` into them. Everything else is + populated by running the component's schema with ``{}`` so defaults exist; + if the schema requires explicit input, an empty ``{}`` is used as a fallback. Platform components must always be a list here even when no ``domain.platform`` entry follows, because the ``domain.platform`` branch @@ -96,6 +99,12 @@ def populate_dependency_config( component = get_component_fn(component_name) if component is None: continue + # Skip target platforms (e.g. esp32): a host build targets `host`, so a + # foreign target's sources are guarded out, and running its schema with + # {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF), + # crashing the host compile. See #17035. + if component.is_target_platform: + continue if component.multi_conf or component.is_platform_component: config.setdefault(component_name, []) elif component_name not in config: diff --git a/tests/script/test_build_helpers.py b/tests/script/test_build_helpers.py new file mode 100644 index 0000000000..efa6a75483 --- /dev/null +++ b/tests/script/test_build_helpers.py @@ -0,0 +1,76 @@ +"""Unit tests for script/build_helpers.py.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to the path so we can import build_helpers. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import build_helpers # noqa: E402 + +from esphome.core import CORE # noqa: E402 + + +class _FakeComponent: + def __init__(self, config_schema, *, is_target_platform=False): + self.multi_conf = False + self.is_platform_component = False + self.is_target_platform = is_target_platform + self.config_schema = config_schema + + +@pytest.fixture(autouse=True) +def _restore_core_toolchain(): + """Keep CORE.toolchain changes from leaking between tests.""" + saved = CORE.toolchain + try: + yield + finally: + CORE.toolchain = saved + + +def test_populate_dependency_config_skips_target_platforms() -> None: + """Target-platform deps must be skipped, not config-populated, in a host build. + + Regression test for #17035: esp32 (a target platform) appears only as a + transitive dependency of a host C++ unit test. Running its schema with {} + set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation, + which crashed the host compile with KeyError('esp32'). The fix skips + target-platform components entirely so their schema never runs. + """ + CORE.toolchain = None # the state a host build starts from + schema_calls = [] + + def leaky_schema(value): + # If this ever runs for a target platform, the bug is back. + schema_calls.append(value) + CORE.toolchain = "esp-idf-leak" + raise ValueError("no board or variant") + + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["esp32"], + get_component_fn=lambda name: _FakeComponent( + leaky_schema, is_target_platform=True + ), + register_platform_fn=lambda domain: None, + ) + + assert "esp32" not in config # skipped: no synthesized entry + assert schema_calls == [] # schema never run + assert CORE.toolchain is None # no global side effect leaked + + +def test_populate_dependency_config_populates_defaults() -> None: + """A non-target-platform dep still has its schema defaults harvested.""" + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["ok"], + get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}), + register_platform_fn=lambda domain: None, + ) + assert config["ok"] == {"default": 1} diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index a8100252da..4b05cab376 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -266,11 +266,13 @@ def _make_component_stub( *, multi_conf: bool = False, is_platform_component: bool = False, + is_target_platform: bool = False, config_schema=None, ) -> MagicMock: stub = MagicMock() stub.multi_conf = multi_conf stub.is_platform_component = is_platform_component + stub.is_target_platform = is_target_platform stub.config_schema = config_schema return stub From a84ad7b1f8533138cb8552c3e6cd600875cb2607 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:43:17 -0400 Subject: [PATCH 1704/2214] [uptime] Revert timestamp sensor device_class to timestamp (#17037) --- esphome/components/uptime/sensor/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index 6ce0795cdb..e2a7aee1a2 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, DEVICE_CLASS_DURATION, - DEVICE_CLASS_UPTIME, + DEVICE_CLASS_TIMESTAMP, ENTITY_CATEGORY_DIAGNOSTIC, ICON_TIMER, STATE_CLASS_TOTAL_INCREASING, @@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend(cv.polling_component_schema("60s")), "timestamp": sensor.sensor_schema( UptimeTimestampSensor, + icon=ICON_TIMER, accuracy_decimals=0, - device_class=DEVICE_CLASS_UPTIME, + device_class=DEVICE_CLASS_TIMESTAMP, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ) .extend( From 129aebe8f42277772d4b7280108cb3fcf2a74be9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:56:21 -0400 Subject: [PATCH 1705/2214] [esp32] Support `esphome idedata` with the native ESP-IDF toolchain (#17040) --- esphome/__main__.py | 15 ++++++++++++ esphome/espidf/toolchain.py | 1 + tests/unit_tests/test_espidf_toolchain.py | 28 +++++++++++++++++++---- tests/unit_tests/test_main.py | 26 +++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 27dd878495..bda3dcbd05 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1771,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json + if CORE.using_toolchain_esp_idf: + # Native ESP-IDF derives idedata from the build's compile_commands.json, + # so the configuration must already be compiled. + from esphome.espidf import toolchain as espidf_toolchain + + idedata = espidf_toolchain.get_idedata() + if idedata is None: + _LOGGER.error( + "No idedata available; compile the configuration first", + ) + return 1 + + print(json.dumps(idedata, indent=2) + "\n") + return 0 + if not CORE.using_toolchain_platformio: _LOGGER.error( "The idedata command is not compatible with %s toolchain", diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index c622a2dd36..000ce739db 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -472,6 +472,7 @@ def get_idedata() -> dict | None: pass data = idedata_from_build(compile_commands) + data["prog_path"] = str(get_elf_path()) cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return data diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index b2309439f9..017d8c49b4 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "g++"} - assert json.loads(cache.read_text()) == {"cxx_path": "g++"} + prog_path = str(toolchain.get_elf_path()) + assert result == {"cxx_path": "g++", "prog_path": prog_path} + assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path} def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: @@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) - result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "fresh"} + assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())} def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: @@ -147,7 +148,26 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "regen"} + assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())} + + +def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None: + """The idedata exposes prog_path (the ELF) so consumers like build-action + can locate firmware.factory.bin / firmware.ota.bin as its siblings.""" + compile_commands, _ = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "g++"}, + ): + result = toolchain.get_idedata() + + # Use Path semantics so the contract holds on Windows too (backslashes). + prog_path = Path(result["prog_path"]) + assert prog_path.name == "firmware.elf" + assert prog_path.parent.name == "build" def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e44f746a75..acd39cedc6 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -32,6 +32,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_idedata, command_rename, command_run, command_update_all, @@ -6257,3 +6258,28 @@ def test_command_run_defaults_subscribe_states_true( mock_run_logs.assert_called_once_with( CORE.config, ["192.168.1.100"], subscribe_states=True ) + + +def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: + """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} + + with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get: + result = command_idedata(MagicMock(), CORE.config) + + assert result == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def test_command_idedata_esp_idf_no_build_errors() -> None: + """Under ESP-IDF, a missing build (no idedata) returns an error, not a crash.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + + with patch("esphome.espidf.toolchain.get_idedata", return_value=None): + result = command_idedata(MagicMock(), CORE.config) + + assert result == 1 From d27229a1c75774dd2ae279ae0dd5f43dd3634562 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:15:38 -0400 Subject: [PATCH 1706/2214] [esp32] Don't overwrite PlatformIO's factory.bin (#17042) --- esphome/components/esp32/post_build.py.script | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index b329f6b82b..f1a38f9e76 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env): flash_size = env.BoardConfig().get("upload.flash_size", "4MB") chip = env.BoardConfig().get("build.mcu", "esp32") + # PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right + # artifact names and partition offsets, including custom partition tables). The merge + # below is only a fallback and cannot honor custom layouts, so don't overwrite an image + # PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt, + # and PlatformIO's combined-image builder runs before us in that batch, so an existing + # file here is current. + output_path = firmware_path.with_suffix(".factory.bin") + if output_path.exists(): + print(f"{output_path.name} already created by PlatformIO - skipping merge") + return + sections = [] flasher_args_path = build_dir / "flasher_args.json" @@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env): print("No valid flash sections found — skipping .factory.bin creation.") return - output_path = firmware_path.with_suffix(".factory.bin") python_exe = f'"{env.subst("$PYTHONEXE")}"' cmd = [ python_exe, From 20cd6a1771794f1f20f6d9e39c2e4d6e45b04c07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 16:16:14 -0500 Subject: [PATCH 1707/2214] [logger] Hold recursion guard while draining the task log buffer (#17044) --- esphome/components/logger/logger.cpp | 4 + .../logger_buffered_recursion_guard.yaml | 61 +++++++++ .../test_logger_buffered_recursion_guard.py | 119 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/integration/fixtures/logger_buffered_recursion_guard.yaml create mode 100644 tests/integration/test_logger_buffered_recursion_guard.py diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index a035525101..684da0202e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -175,6 +175,10 @@ void Logger::process_messages_() { #ifdef USE_ESPHOME_TASK_LOG_BUFFER // Process any buffered messages when available if (this->log_buffer_.has_messages()) { + // Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering + // and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here. + // Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path. + RecursionGuard guard(this->main_task_recursion_guard_); logger::TaskLogBuffer::LogMessage *message; uint16_t text_length; while (this->log_buffer_.borrow_message_main_loop(message, text_length)) { diff --git a/tests/integration/fixtures/logger_buffered_recursion_guard.yaml b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml new file mode 100644 index 0000000000..058adbff99 --- /dev/null +++ b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml @@ -0,0 +1,61 @@ +esphome: + name: logger-recursion-test +host: +api: +logger: + level: DEBUG + on_message: + # Fires on the main loop for every message delivered to listeners, including + # messages drained from the task log buffer (i.e. logged from a non-main thread). + # The lambda logs again on the main task. Without a recursion guard on the buffered + # drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the + # buffered message that is still being delivered, corrupting its console output. + - level: VERY_VERBOSE + then: + - lambda: |- + ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER"); + +button: + - platform: template + name: "Start Race Test" + id: start_test_button + on_press: + - lambda: |- + // Keep the count well under the host task-log-buffer slot count so every + // message goes through the ring buffer (buffered drain path) instead of the + // emergency console fallback. The main loop is blocked in pthread_join while + // the thread logs, so all messages are drained together once it returns. + static const int NUM_MESSAGES = 30; + + struct ThreadTest { + static void *thread_func(void *arg) { + char thread_name[16]; + snprintf(thread_name, sizeof(thread_name), "LogThread"); + #ifdef __APPLE__ + pthread_setname_np(thread_name); + #else + pthread_setname_np(pthread_self(), thread_name); + #endif + + for (int i = 0; i < NUM_MESSAGES; i++) { + // Verifiable payload: data is a deterministic function of the message + // index, so a clobbered buffer shows up as a missing or mismatched line. + ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345); + } + return nullptr; + } + }; + + // RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the + // synchronous path, which already holds the recursion guard) so the test can + // always detect completion even when the buffered path is corrupted. + ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES); + + pthread_t thread; + if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) { + ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread"); + return; + } + pthread_join(thread, nullptr); + + ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES); diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py new file mode 100644 index 0000000000..5bef915b28 --- /dev/null +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -0,0 +1,119 @@ +"""Integration test for the recursion guard on the buffered logger drain path. + +Regression test for a crash where a log message drained from the task log buffer +(i.e. logged from a non-main thread) re-entered the logger on the main task while it +was still being delivered to listeners. The buffered drain in +``Logger::process_messages_`` did not hold the main-task recursion guard that the +synchronous logging path holds, so a listener callback that logged again on the main +task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused +the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed +up as a ``StoreProhibited`` panic inside the API send path. + +The fixture logs a small batch of verifiable messages from a non-main thread (kept +under the host task-log-buffer slot count so they all take the buffered drain path +rather than the emergency console fallback) while an ``on_message`` automation re-logs +``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message. + +Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while +the buffered thread message is still being delivered, so the message the API receives is +contaminated (it contains the marker and an embedded newline glued onto the thread +payload). With the guard the re-entrant log is dropped during the drain, the marker +never appears, and every thread message is delivered clean. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import LogLevel +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index +THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})") + +NUM_MESSAGES = 30 + + +@pytest.mark.asyncio +async def test_logger_buffered_recursion_guard( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Buffered (non-main-thread) log messages survive a re-entrant main-task log.""" + api_messages: list[str] = [] + all_drained = asyncio.Event() + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "logger-recursion-test" + + # Subscribe over the API: this is the exact path that crashed in the field + # (the API log callback runs during the buffered drain). The API message field + # preserves embedded newlines, so it reliably exposes a clobbered buffer. + # + # Every buffered thread message is delivered here whether it survives intact or + # gets clobbered (a clobbered message still carries its THREADMSG payload), so + # counting THREADMSG occurrences is a deterministic "drain complete" signal: no + # arbitrary sleep, no dependence on the fix being present. + def on_log(msg) -> None: + text = msg.message.decode("utf-8", errors="replace") + api_messages.append(text) + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + if received >= NUM_MESSAGES: + all_drained.set() + + client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE) + + entities, _ = await client.list_entities_services() + buttons = [e for e in entities if e.name == "Start Race Test"] + assert buttons, "Could not find Start Race Test button" + client.button_command(buttons[0].key) + + # Wait until every buffered thread message has been delivered over the API. + try: + await asyncio.wait_for(all_drained.wait(), timeout=30.0) + except TimeoutError: + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + pytest.fail( + f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; " + "device likely crashed or hung." + ) + + intact: set[int] = set() + contaminated: list[str] = [] + for raw in api_messages: + text = _ANSI.sub("", raw) + if "THREADMSG" not in text: + continue + # A clean thread message is a single line carrying only its own payload. A + # clobbered buffer glues the re-entrant marker (and an embedded newline) onto it. + if "REENTRANT" in text or "\n" in text: + contaminated.append(repr(raw)) + continue + match = THREAD_MSG_PATTERN.search(text) + assert match, f"Unexpected thread message format: {raw!r}" + msg_num = int(match.group(1)) + expected = f"{msg_num * 12345:08X}" + if match.group(2) != expected: + contaminated.append(repr(raw)) + continue + intact.add(msg_num) + + assert not contaminated, ( + "Buffered thread messages were clobbered by a re-entrant main-task log " + "(missing recursion guard on the buffered drain path):\n" + + "\n".join(contaminated[:10]) + ) + assert len(intact) == NUM_MESSAGES, ( + f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got " + f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}" + ) From e3d68deef904c11683b3d32eee9fe67c4fb7e920 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:05:20 +1200 Subject: [PATCH 1708/2214] Bump bundled esphome-device-builder to 1.0.10 (#17051) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 18a9903735..221121c8d3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10 RUN \ platformio settings set enable_telemetry No \ From 1b1c8d767d29674a4245d9c13af3df0f6df8a3b0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:06:13 +1200 Subject: [PATCH 1709/2214] Bump version to 2026.6.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 56879237d4..d8c6bdbcdc 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0 +PROJECT_NUMBER = 2026.6.1 # 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 c045e452f7..0bcf60a510 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0" +__version__ = "2026.6.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 1dbd9af6179bcae2c758758c5d7d70b2965ee06d Mon Sep 17 00:00:00 2001 From: Big Mike Date: Fri, 19 Jun 2026 00:04:11 -0500 Subject: [PATCH 1710/2214] [sen6x] Remove codeowner (#17056) --- CODEOWNERS | 2 +- esphome/components/sen6x/sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3265627c03..d425614582 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -445,7 +445,7 @@ esphome/components/select/* @esphome/core esphome/components/sen0321/* @notjj esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras -esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct +esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 19c0cb500e..5eb34add65 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -32,7 +32,7 @@ from esphome.const import ( UNIT_PERCENT, ) -CODEOWNERS = ["@martgras", "@mebner86", "@mikelawrence", "@tuct"] +CODEOWNERS = ["@martgras", "@mebner86", "@tuct"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] From 6a79dfb5c5a954cf3d0ba1da2cfd3351e165d3f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:07:32 +1200 Subject: [PATCH 1711/2214] Bump ruff from 0.15.17 to 0.15.18 (#17046) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index fc9681921a..b0e917566e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.17 # also change in .pre-commit-config.yaml when updating +ruff==0.15.18 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 4ae6dc355f1e525f0ebe1e2cd8d2965588ec4b3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jun 2026 00:08:07 -0500 Subject: [PATCH 1712/2214] [select] Remove deprecated state member (#17027) --- esphome/components/select/select.cpp | 4 ---- esphome/components/select/select.h | 7 ------- .../fixtures/multi_device_preferences.yaml | 12 ++++++------ 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 7c3dab15ad..17c6c811dd 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -27,10 +27,6 @@ void Select::publish_state(size_t index) { const char *option = this->option_at(index); this->set_has_state(true); this->active_index_ = index; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->state = option; // Update deprecated member for backward compatibility -#pragma GCC diagnostic pop ESP_LOGV(TAG, "'%s' >> %s (%zu)", this->get_name().c_str(), option, index); this->state_callback_.call(index); #if defined(USE_SELECT) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 465283d92a..34d9248523 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -30,15 +30,8 @@ class Select : public EntityBase { public: SelectTraits traits; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use current_option() instead. This member will be removed in ESPHome 2026.7.0. - ESPDEPRECATED("Use current_option() instead of .state. Will be removed in 2026.7.0", "2026.1.0") - std::string state{}; - Select() = default; ~Select() = default; -#pragma GCC diagnostic pop void publish_state(const std::string &state); void publish_state(const char *state); diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 634d7157b2..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -109,7 +109,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Device A Mode set to %s", x.c_str()); - id(mode_device_a).state = x; + id(mode_device_a).publish_state(x); - platform: template name: Mode @@ -124,7 +124,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Device B Mode set to %s", x.c_str()); - id(mode_device_b).state = x; + id(mode_device_b).publish_state(x); - platform: template name: Mode @@ -138,7 +138,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Main Mode set to %s", x.c_str()); - id(mode_main).state = x; + id(mode_main).publish_state(x); # Button to trigger preference logging test button: @@ -153,9 +153,9 @@ button: ESP_LOGI("test", "Device A Setpoint: %.1f", id(setpoint_device_a).state); ESP_LOGI("test", "Device B Setpoint: %.1f", id(setpoint_device_b).state); ESP_LOGI("test", "Main Setpoint: %.1f", id(setpoint_main).state); - ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).state.c_str()); - ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).state.c_str()); - ESP_LOGI("test", "Main Mode: %s", id(mode_main).state.c_str()); + ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); + ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); + ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); // Log preference hashes for entities that actually store preferences ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); From 350e7bb7638b0e8551d1e5f1244bbe62e952cb77 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:17:06 -0400 Subject: [PATCH 1713/2214] [espidf] Resolve IDF tools path to avoid unnormalized path warning (#17055) --- esphome/espidf/framework.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index c0e9a0051f..6f4aeef9f0 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -81,8 +81,13 @@ def _get_idf_tools_path() -> Path: Path object pointing to the ESP-IDF tools directory """ if "ESPHOME_ESP_IDF_PREFIX" in os.environ: - return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() - return CORE.data_dir / "idf" + path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + else: + path = CORE.data_dir / "idf" + # Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``) + # doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which + # otherwise warns that the venv interpreter path doesn't match the install. + return path.resolve() # Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply From 50994704a39397db0672f0fb16cdaa5fd4366c7c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:17 -0400 Subject: [PATCH 1714/2214] [fastled_base] Fix RMT5 intr_priority conflict (#17072) --- esphome/components/fastled_base/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index d99dffdc08..a26a235da7 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -50,6 +50,11 @@ async def new_fastled_light(config): ref="d44c800a9e876a8394caefc2ce4915dd96dac77b", ) cg.add_library("SPI", None) + # FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with + # esphome's RMT channels (remote_transmitter etc., priority 0): the IDF + # driver rejects FastLED's channel and show() then hangs ~3s with no + # output. Override to 0 so it shares the interrupt. See #17063. + cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0") else: cg.add_library("fastled/FastLED", "3.9.16") await light.register_light(var, config) From f57d31374e1cc88bdd5f16e16fface70ef9184fb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:30 -0400 Subject: [PATCH 1715/2214] [packet_transport] Mark encryption key as cv.sensitive (#17066) --- esphome/components/packet_transport/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 0b166bb65c..4293dffb15 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -69,7 +69,7 @@ ENCRYPTION_SCHEMA = { cv.Optional(CONF_ENCRYPTION): cv.maybe_simple_value( cv.Schema( { - cv.Required(CONF_KEY): cv.string, + cv.Required(CONF_KEY): cv.sensitive(cv.string), } ), key=CONF_KEY, From db6bd36cf90eccbec52cba89feddecb6178ca14f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:20:17 -0400 Subject: [PATCH 1716/2214] Bump py7zr from 1.1.0 to 1.1.3 (#17071) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index efb5ec8723..717f3b7e21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 -py7zr==1.1.0 +py7zr==1.1.3 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From d8bd80ef3888b9c30b0a5690c2b6c4ec018bb71a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:20:28 -0400 Subject: [PATCH 1717/2214] Bump resvg-py from 0.3.2 to 0.3.3 (#17070) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 717f3b7e21..06a383b00a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.2.0 -resvg-py==0.3.2 +resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From 3fb250133fe12af47c1b9d8d7b6e4e92363d4e0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:20:37 -0400 Subject: [PATCH 1718/2214] Bump pytest from 9.1.0 to 9.1.1 (#17069) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b0e917566e..4e498abc21 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.1.0 +pytest==9.1.1 pytest-cov==7.1.0 pytest-mock==3.15.1 pytest-asyncio==1.4.0 From 657d9bf4d094e13b7c7e5d3c7ff67566dfa53a8b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:30:30 -0500 Subject: [PATCH 1719/2214] Bump bundled esphome-device-builder to 1.0.11 (#17081) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 221121c8d3..aa0406320c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.11 RUN \ platformio settings set enable_telemetry No \ From d77c0d2bc544a9c94e4e317ce485c59081f48b5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Jun 2026 11:33:54 -0500 Subject: [PATCH 1720/2214] [ha-addon] Expose the device-builder public port only when port 6052 is mapped (#17076) --- .../etc/s6-overlay/s6-rc.d/esphome/run | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index a61f237a5a..d4628ffa83 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -49,7 +49,21 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then rm -rf /config/esphome/.esphome fi +# Only signal device-builder to expose the public LAN port when the operator +# mapped port 6052, matching the legacy dashboard where nginx listened on the +# fixed port 6052 only when it was configured. We use the mapping purely as a +# presence check and don't forward the published value; device-builder binds +# its default port 6052 (the fixed container port, as the legacy +# "listen 6052" did). --ha-addon-allow-public is inert on its own: the no-auth +# gate is the DISABLE_HA_AUTHENTICATION env var set above, so both opt-ins are +# required to bind 6052 unauthenticated; either alone stays ingress-only. +set -- +if bashio::var.has_value "$(bashio::addon.port 6052)"; then + set -- --ha-addon-allow-public +fi + bashio::log.info "Starting ESPHome Device Builder..." exec esphome-device-builder /config/esphome \ --ha-addon \ - --ingress-port "$(bashio::addon.ingress_port)" + --ingress-port "$(bashio::addon.ingress_port)" \ + "$@" From 59711b8e6a39bb5db7b8b79067298feb0a268a77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Jun 2026 11:51:42 -0500 Subject: [PATCH 1721/2214] Add THREAT_MODEL.md (#17089) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- THREAT_MODEL.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 THREAT_MODEL.md diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 0000000000..a4640467c9 --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,104 @@ +# ESPHome Threat Model + +This document defines the trust boundary for the **ESPHome** repository — the +Python compiler/CLI and the device firmware it generates — so that real security +bugs can be told apart from defense-in-depth improvements. It gives contributors, +reviewers, and security researchers a clear answer to one question: +**does this issue let an _unauthenticated_ attacker do something they shouldn't?** + +Related documents: + +- Deployment guidance for operators: + https://esphome.io/guides/security_best_practices/ +- The **Device Builder dashboard** (the web UI, its authentication, ingress, + Origin/Host gates, and peer-link pairing) lives in a separate repository and + has its own threat model. If your report concerns any of that, please read and + report there instead: + https://github.com/esphome/device-builder/blob/main/docs/THREAT_MODEL.md + +## The trust boundary + +For this repository there are two trusted inputs by design: + +1. **The configuration.** Anyone who can supply or edit a YAML config is trusted + (see below). +2. **Authenticated peers of a running device** — clients holding the device's + API encryption key / password, OTA password, or web server credentials. + +The security boundary is therefore **unauthenticated network traffic vs. those +trusted inputs.** A bug that lets an unauthenticated attacker cross it is a +security bug. + +## Config authors are host-equivalent by design + +Anyone who can supply or edit a configuration is **trusted with full code +execution on the host that runs `esphome`**, on purpose. This is what the product +does, not a flaw. A config author can already, through fully supported features: + +- Run arbitrary **Python** at validation/compile time via `external_components:` + (and other component-import mechanisms) — ESPHome imports those packages as + ordinary Python. +- Run arbitrary **shell** commands through the compile/validate/flash toolchain + that ESPHome invokes as subprocesses. +- Read and write arbitrary files reachable by the process (e.g. via `!include`, + `packages:`, `dashboard_import:`, and generated build output). + +Because of this, a malicious config author is equivalent to shell access on the +host running the build. + +## What is *not* a security vulnerability + +If exploiting an issue requires the ability to supply or edit configuration, it +is **not** a vulnerability in ESPHome, because that ability already grants host +code execution. This explicitly includes, among others: + +- Template / expression injection in substitutions or any YAML string value + (e.g. Jinja `${...}` evaluation reaching Python internals). This grants no + capability a config author lacks. +- `!include` / `packages:` / `dashboard_import:` reading or fetching content + from surprising or remote locations. +- The validator or compiler crashing or behaving unexpectedly on adversarial + YAML. +- ESPHome running as root in the official container — that is the documented + deployment posture, reachable by the same caller through the features above. + +These do not warrant a CVE or coordinated disclosure. Hardening in these areas +(for example, sandboxing template evaluation as least-surprise defense-in-depth) +is welcome as a normal enhancement PR, framed as cleanliness rather than a +security fix — not as a vulnerability remediation. + +## What we do defend + +These *are* security bugs in this repo, and we want to hear about them privately: + +- Memory-safety or protocol bugs in the generated **device firmware** that are + remotely triggerable over the network (native API, web server, OTA, BLE, + captive portal, etc.) **without** valid credentials. +- Authentication or encryption bypass on the device — reaching API calls, OTA + updates, or the web server without the configured key/password. +- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth + below their documented guarantees. + +## Explicitly out of scope + +- Local attackers who already have shell access on the host that runs `esphome`. +- Supply-chain attacks against ESPHome or its dependencies. +- Operator-supplied hostile YAML (covered above — config authoring is trusted). +- Attacks that require an already-authenticated device peer (someone who already + holds the API key / OTA / web credentials). +- Anything in the dashboard / device-builder — report that in its own repository + (linked at the top). +- The legacy bundled dashboard in this repo (`esphome/dashboard/`) — it is + deprecated and being replaced by Device Builder; report dashboard issues there. +- Deployments where the operator removed protections or exposed credentials. See + the security best practices guide: + https://esphome.io/guides/security_best_practices/ + +## Reporting a vulnerability + +If you believe you've found an issue that crosses the unauthenticated boundary +above, please report it privately via GitHub Security Advisories rather than a +public issue. For issues that require config-write access, please review this +document first — they are very likely out of scope by design. For dashboard / +device-builder issues, report against that repository and consult its threat +model (linked at the top). From 9609d370c09a1bdcdedffe7061625455e23fbf2a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:22:19 -0500 Subject: [PATCH 1722/2214] Bump bundled esphome-device-builder to 1.0.12 (#17091) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index aa0406320c..1d39644ab8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.11 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 RUN \ platformio settings set enable_telemetry No \ From 8d77051b9a2d80e07cade2971f95050892ee5339 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:17:06 -0400 Subject: [PATCH 1723/2214] [espidf] Resolve IDF tools path to avoid unnormalized path warning (#17055) --- esphome/espidf/framework.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index c0e9a0051f..6f4aeef9f0 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -81,8 +81,13 @@ def _get_idf_tools_path() -> Path: Path object pointing to the ESP-IDF tools directory """ if "ESPHOME_ESP_IDF_PREFIX" in os.environ: - return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() - return CORE.data_dir / "idf" + path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + else: + path = CORE.data_dir / "idf" + # Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``) + # doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which + # otherwise warns that the venv interpreter path doesn't match the install. + return path.resolve() # Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply From fe794a26e845c33a0e9ae86e8c7363fd78cca09e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:17 -0400 Subject: [PATCH 1724/2214] [fastled_base] Fix RMT5 intr_priority conflict (#17072) --- esphome/components/fastled_base/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index d99dffdc08..a26a235da7 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -50,6 +50,11 @@ async def new_fastled_light(config): ref="d44c800a9e876a8394caefc2ce4915dd96dac77b", ) cg.add_library("SPI", None) + # FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with + # esphome's RMT channels (remote_transmitter etc., priority 0): the IDF + # driver rejects FastLED's channel and show() then hangs ~3s with no + # output. Override to 0 so it shares the interrupt. See #17063. + cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0") else: cg.add_library("fastled/FastLED", "3.9.16") await light.register_light(var, config) From f5697b0ae574104bd69ec4fc8da5919c596918c1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:30 -0400 Subject: [PATCH 1725/2214] [packet_transport] Mark encryption key as cv.sensitive (#17066) --- esphome/components/packet_transport/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 0b166bb65c..4293dffb15 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -69,7 +69,7 @@ ENCRYPTION_SCHEMA = { cv.Optional(CONF_ENCRYPTION): cv.maybe_simple_value( cv.Schema( { - cv.Required(CONF_KEY): cv.string, + cv.Required(CONF_KEY): cv.sensitive(cv.string), } ), key=CONF_KEY, From 2354165e41e02205e465b17364f523ecd513b8f3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:30:30 -0500 Subject: [PATCH 1726/2214] Bump bundled esphome-device-builder to 1.0.11 (#17081) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 221121c8d3..aa0406320c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.11 RUN \ platformio settings set enable_telemetry No \ From 039a1f063e1e3754f8199c2bae6f9b2ed92ad06f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Jun 2026 11:33:54 -0500 Subject: [PATCH 1727/2214] [ha-addon] Expose the device-builder public port only when port 6052 is mapped (#17076) --- .../etc/s6-overlay/s6-rc.d/esphome/run | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index a61f237a5a..d4628ffa83 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -49,7 +49,21 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then rm -rf /config/esphome/.esphome fi +# Only signal device-builder to expose the public LAN port when the operator +# mapped port 6052, matching the legacy dashboard where nginx listened on the +# fixed port 6052 only when it was configured. We use the mapping purely as a +# presence check and don't forward the published value; device-builder binds +# its default port 6052 (the fixed container port, as the legacy +# "listen 6052" did). --ha-addon-allow-public is inert on its own: the no-auth +# gate is the DISABLE_HA_AUTHENTICATION env var set above, so both opt-ins are +# required to bind 6052 unauthenticated; either alone stays ingress-only. +set -- +if bashio::var.has_value "$(bashio::addon.port 6052)"; then + set -- --ha-addon-allow-public +fi + bashio::log.info "Starting ESPHome Device Builder..." exec esphome-device-builder /config/esphome \ --ha-addon \ - --ingress-port "$(bashio::addon.ingress_port)" + --ingress-port "$(bashio::addon.ingress_port)" \ + "$@" From b079be756f5d92076fd20bdb5ac53283e67503e2 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:22:19 -0500 Subject: [PATCH 1728/2214] Bump bundled esphome-device-builder to 1.0.12 (#17091) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index aa0406320c..1d39644ab8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.11 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 RUN \ platformio settings set enable_telemetry No \ From 99d1c4eb694e600914b17321a165516101e542b1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:33:41 -0400 Subject: [PATCH 1729/2214] Bump version to 2026.6.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d8c6bdbcdc..ea36d45fee 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.1 +PROJECT_NUMBER = 2026.6.2 # 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 0bcf60a510..7cc9b604d9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.1" +__version__ = "2026.6.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From c826293efc547895a3f2d1143a5fa12f6eac8ccc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 09:26:34 -0500 Subject: [PATCH 1730/2214] [core] Clarify resolve error when a device has no network log/OTA transport A device with a static IP, mDNS disabled, and no api: component failed logs with "All specified devices ['OTA'] could not be resolved" and a hint to set use_address; the hint is misleading since the static IP already resolves, the real gap is that network logs ride the native API. Name the missing transport instead: api: for logs, an ota: platform for uploads. The generic "could not be resolved" message stays for a genuinely unreachable address. --- esphome/__main__.py | 39 +++++++++++++++++++----- tests/unit_tests/test_main.py | 56 +++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index bda3dcbd05..680de02201 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -268,6 +268,36 @@ def _ota_hostnames_for_default(purpose: Purpose) -> list[str]: return _resolve_with_cache(CORE.address, purpose) +def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: + """Build the error when a default device target produced no usable host. + + When the OTA default was requested and the address resolves but the config + lacks the transport the purpose needs (``api:`` for logs, an ``ota:`` + platform for uploads), name that gap instead of the misleading + "could not be resolved" / set-use_address hint. + """ + if "OTA" in defaults and has_resolvable_address(): + if purpose == Purpose.LOGGING and not has_api(): + return ( + "Cannot view logs over the network: no 'api:' component is " + "configured. Network log streaming requires the native API; add " + "an 'api:' component, enable MQTT logging, or view logs over USB." + ) + if purpose == Purpose.UPLOADING and not has_ota(): + return ( + "Cannot upload over the network: no 'ota:' platform is " + "configured. Add an 'ota:' platform, or upload over USB." + ) + if CORE.dashboard: + hint = "If you know the IP, set 'use_address' in your network config." + else: + hint = "If you know the IP, try --device " + return ( + f"All specified devices {defaults} could not be resolved. " + f"Is the device connected to the network? {hint}" + ) + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -317,14 +347,7 @@ def choose_upload_log_host( else: resolved.append(device) if not resolved: - if CORE.dashboard: - hint = "If you know the IP, set 'use_address' in your network config." - else: - hint = "If you know the IP, try --device " - raise EsphomeError( - f"All specified devices {defaults} could not be resolved. " - f"Is the device connected to the network? {hint}" - ) + raise EsphomeError(_unresolved_default_error(purpose, defaults)) return resolved # No devices specified, show interactive chooser diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index acd39cedc6..bb06b6c930 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, choose_upload_log_host, @@ -713,9 +714,7 @@ def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") - with pytest.raises( - EsphomeError, match="All specified devices .* could not be resolved" - ): + with pytest.raises(EsphomeError, match="no 'ota:' platform is configured"): choose_upload_log_host( default="OTA", check_default=None, @@ -735,6 +734,57 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: + """A resolvable device with only ota: fails logs with a missing-api message.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_choose_upload_log_host_logging_no_transport_reports_missing_api() -> None: + """A resolvable device with neither api: nor MQTT logging fails clearly.""" + setup_core(address="192.168.1.100") + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: + """A .local host with mDNS disabled and no cache keeps the dashboard hint.""" + setup_core( + config={CONF_API: {}, CONF_MDNS: {CONF_DISABLED: True}}, + address="esp32-a1s.local", + ) + CORE.dashboard = True + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "could not be resolved" in msg + assert "set 'use_address'" in msg + + +def test_unresolved_default_error_upload_with_ota_is_generic() -> None: + """With ota: present the upload error stays generic, not transport-specific.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + CORE.dashboard = False + + msg = _unresolved_default_error(Purpose.UPLOADING, ["OTA"]) + assert "could not be resolved" in msg + assert "try --device " in msg + + @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: """Test OTA device fallback to MQTT when no OTA/API config.""" From d92f632daa65653e18adf76367c48020db19a224 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 09:57:53 -0500 Subject: [PATCH 1731/2214] [api] Remove pre-1.14 object_id backward-compat code --- esphome/components/api/api_connection.cpp | 12 +----------- esphome/components/api/api_connection.h | 12 +----------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2b1458e2ae..acdf24e747 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -375,7 +375,7 @@ void APIConnection::finalize_iterator_sync_() { void APIConnection::process_iterator_batch_(ComponentIterator &iterator) { size_t initial_size = this->deferred_batch_.size(); - size_t max_batch = this->get_max_batch_size_(); + size_t max_batch = MAX_INITIAL_PER_BATCH; while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) { iterator.advance(); } @@ -418,16 +418,6 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp // Set common fields that are shared by all entity types msg.key = entity->get_object_id_hash(); - // API 1.14+ clients compute object_id client-side from the entity name - // For older clients, we must send object_id for backward compatibility - // See: https://github.com/esphome/backlog/issues/76 - // TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then - // Buffer must remain in scope until encode_to_buffer is called - char object_id_buf[OBJECT_ID_MAX_LEN]; - if (!conn->client_supports_api_version(1, 14)) { - msg.object_id = entity->get_object_id_to(object_id_buf); - } - if (entity->has_own_name()) { msg.name = entity->get_name(); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 804cd9ddd1..92f7065730 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -43,10 +43,7 @@ class APIServer; // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending -// API 1.14+ clients compute object_id client-side, so messages are smaller and we can fit more per batch -// TODO: Remove MAX_INITIAL_PER_BATCH_LEGACY before 2026.7.0 - all clients should support API 1.14 by then -static constexpr size_t MAX_INITIAL_PER_BATCH_LEGACY = 24; // For clients < API 1.14 (includes object_id) -static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= API 1.14 (no object_id) +static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); @@ -481,13 +478,6 @@ class APIConnection final : public APIServerConnectionBase { inline bool check_voice_assistant_api_connection_() const; #endif - // Get the max batch size based on client API version - // API 1.14+ clients don't receive object_id, so messages are smaller and more fit per batch - // TODO: Remove this method before 2026.7.0 and use MAX_INITIAL_PER_BATCH directly - size_t get_max_batch_size_() const { - return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY; - } - // Send keepalive ping or disconnect unresponsive client. // Cold path — extracted from loop() to reduce instruction cache pressure. void __attribute__((noinline)) check_keepalive_(uint32_t now); From 87de984dcb55aa33c1ef2e1d7eb083c72b6be7a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 10:05:03 -0500 Subject: [PATCH 1732/2214] [web_server] Deprecate version 1 --- esphome/components/web_server/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index fd380a38dd..788bedec34 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import gzip +import logging import esphome.codegen as cg from esphome.components import web_server_base @@ -38,6 +39,8 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["json", "web_server_base"] CONF_SORTING_GROUP_ID = "sorting_group_id" @@ -71,6 +74,15 @@ def default_url(config: ConfigType) -> ConfigType: return config +def validate_version_deprecated(config: ConfigType) -> ConfigType: + if config[CONF_VERSION] == 1: + _LOGGER.warning( + "Version 1 of 'web_server' is deprecated and will be removed in " + "2027.1.0. Please migrate to version 2 (the default) or version 3." + ) + return config + + def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") @@ -220,6 +232,7 @@ CONFIG_SCHEMA = cv.All( ] ), default_url, + validate_version_deprecated, validate_local, validate_sorting_groups, validate_ota, From 8aa06c9d1544257a91c708cc26cca5a303f8e0eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 10:23:43 -0500 Subject: [PATCH 1733/2214] [core] Remove deprecated std::string scheduler/timer overloads --- esphome/core/component.cpp | 40 --- esphome/core/component.h | 70 +--- esphome/core/scheduler.cpp | 17 - esphome/core/scheduler.h | 12 - .../scheduler_bulk_cleanup_component.cpp | 19 +- .../rapid_cancellation_component.cpp | 14 +- .../simultaneous_callbacks_component.cpp | 14 +- .../__init__.py | 21 -- .../string_lifetime_component.cpp | 260 --------------- .../string_lifetime_component.h | 35 -- .../__init__.py | 21 -- .../string_name_stress_component.cpp | 108 ------ .../string_name_stress_component.h | 20 -- .../fixtures/scheduler_string_lifetime.yaml | 48 --- .../scheduler_string_name_stress.yaml | 39 --- .../fixtures/scheduler_string_test.yaml | 310 ------------------ .../test_scheduler_string_lifetime.py | 169 ---------- .../test_scheduler_string_name_stress.py | 116 ------- .../integration/test_scheduler_string_test.py | 202 ------------ 19 files changed, 43 insertions(+), 1492 deletions(-) delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h delete mode 100644 tests/integration/fixtures/scheduler_string_lifetime.yaml delete mode 100644 tests/integration/fixtures/scheduler_string_name_stress.yaml delete mode 100644 tests/integration/fixtures/scheduler_string_test.yaml delete mode 100644 tests/integration/test_scheduler_string_lifetime.py delete mode 100644 tests/integration/test_scheduler_string_name_stress.py delete mode 100644 tests/integration/test_scheduler_string_test.py diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 7ef5ff50a5..281d7aaecd 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -85,24 +85,10 @@ void Component::setup() {} void Component::loop() {} -void Component::set_interval(const std::string &name, uint32_t interval, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_interval(this, name, interval, std::move(f)); -#pragma GCC diagnostic pop -} - void Component::set_interval(const char *name, uint32_t interval, std::function &&f) { // NOLINT App.scheduler.set_interval(this, name, interval, std::move(f)); } -bool Component::cancel_interval(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_interval(this, name); -#pragma GCC diagnostic pop -} - bool Component::cancel_interval(const char *name) { // NOLINT return App.scheduler.cancel_interval(this, name); } @@ -137,24 +123,10 @@ bool Component::cancel_retry(const char *name) { // NOLINT #pragma GCC diagnostic pop } -void Component::set_timeout(const std::string &name, uint32_t timeout, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_timeout(this, name, timeout, std::move(f)); -#pragma GCC diagnostic pop -} - void Component::set_timeout(const char *name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } -bool Component::cancel_timeout(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_timeout(this, name); -#pragma GCC diagnostic pop -} - bool Component::cancel_timeout(const char *name) { // NOLINT return App.scheduler.cancel_timeout(this, name); } @@ -319,21 +291,9 @@ void Component::reset_to_construction_state() { void Component::defer(std::function &&f) { // NOLINT App.scheduler.set_timeout(this, static_cast(nullptr), 0, std::move(f)); } -bool Component::cancel_defer(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_timeout(this, name); -#pragma GCC diagnostic pop -} bool Component::cancel_defer(const char *name) { // NOLINT return App.scheduler.cancel_timeout(this, name); } -void Component::defer(const std::string &name, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_timeout(this, name, 0, std::move(f)); -#pragma GCC diagnostic pop -} void Component::defer(const char *name, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, 0, std::move(f)); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 299a5f72ea..caad1ff41e 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -372,18 +372,6 @@ class Component { * * Note also that the first call to f will not happen immediately, but after a random delay. This is * intended to prevent many interval functions from being called at the same time. - * - * @param name The identifier for this interval function. - * @param interval The interval in ms. - * @param f The function (or lambda) that should be called - * - * @see cancel_interval() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(const std::string &name, uint32_t interval, std::function &&f); // NOLINT - - /** Set an interval function with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. * This means the name should be: @@ -391,7 +379,7 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the scheduled task * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. * * @param name The identifier for this interval function (must have static lifetime) * @param interval The interval in ms @@ -416,12 +404,9 @@ class Component { * @param name The identifier for this interval function. * @return Whether an interval functions was deleted. */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_interval(const std::string &name); // NOLINT - bool cancel_interval(const char *name); // NOLINT - bool cancel_interval(uint32_t id); // NOLINT - bool cancel_interval(InternalSchedulerID id); // NOLINT + bool cancel_interval(const char *name); // NOLINT + bool cancel_interval(uint32_t id); // NOLINT + bool cancel_interval(InternalSchedulerID id); // NOLINT /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. // Remove before 2026.8.0 @@ -465,18 +450,6 @@ class Component { * IMPORTANT: Do not rely on this having correct timing. This is only called from * loop() and therefore can be significantly delay. If you need exact timing please * use hardware timers. - * - * @param name The identifier for this timeout function. - * @param timeout The timeout in ms. - * @param f The function (or lambda) that should be called - * - * @see cancel_timeout() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(const std::string &name, uint32_t timeout, std::function &&f); // NOLINT - - /** Set a timeout function with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. * This means the name should be: @@ -484,7 +457,9 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the timeout duration * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. + * + * @see cancel_timeout() * * @param name The identifier for this timeout function (must have static lifetime) * @param timeout The timeout in ms @@ -509,25 +484,13 @@ class Component { * @param name The identifier for this timeout function. * @return Whether a timeout functions was deleted. */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_timeout(const std::string &name); // NOLINT - bool cancel_timeout(const char *name); // NOLINT - bool cancel_timeout(uint32_t id); // NOLINT - bool cancel_timeout(InternalSchedulerID id); // NOLINT - - /** Defer a callback to the next loop() call. - * - * If name is specified and a defer() object with the same name exists, the old one is first removed. - * - * @param name The name of the defer function. - * @param f The callback. - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") - void defer(const std::string &name, std::function &&f); // NOLINT + bool cancel_timeout(const char *name); // NOLINT + bool cancel_timeout(uint32_t id); // NOLINT + bool cancel_timeout(InternalSchedulerID id); // NOLINT /** Defer a callback to the next loop() call with a const char* name. + * + * If name is specified and a defer() object with the same name exists, the old one is first removed. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the deferred task. * This means the name should be: @@ -535,7 +498,7 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the deferred execution * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. * * @param name The name of the defer function (must have static lifetime) * @param f The callback @@ -549,11 +512,8 @@ class Component { void defer(uint32_t id, std::function &&f); // NOLINT /// Cancel a defer callback using the specified name, name must not be empty. - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_defer(const std::string &name); // NOLINT - bool cancel_defer(const char *name); // NOLINT - bool cancel_defer(uint32_t id); // NOLINT + bool cancel_defer(const char *name); // NOLINT + bool cancel_defer(uint32_t id); // NOLINT void status_clear_warning_slow_path_(); void status_clear_error_slow_path_(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 15bb9ea239..9c5557bdfc 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -254,30 +254,16 @@ void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t std::move(func)); } -void HOT Scheduler::set_timeout(Component *component, const std::string &name, uint32_t timeout, - std::function &&func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - timeout, std::move(func)); -} void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function &&func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, std::move(func)); } -bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); -} bool HOT Scheduler::cancel_timeout(Component *component, const char *name) { return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT); } bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); } -void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval, - std::function &&func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - interval, std::move(func)); -} - void HOT Scheduler::set_interval(Component *component, const char *name, uint32_t interval, std::function &&func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval, @@ -287,9 +273,6 @@ void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t int this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, std::move(func)); } -bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); -} bool HOT Scheduler::cancel_interval(Component *component, const char *name) { return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL); } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 378c0fb94b..9aecc3e8c8 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -31,11 +31,6 @@ class Scheduler { template friend class DelayAction; public: - // std::string overload - deprecated, use const char* or uint32_t instead - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function &&func); - /** Set a timeout with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. @@ -53,8 +48,6 @@ class Scheduler { static_cast(id), timeout, std::move(func)); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_timeout(Component *component, const std::string &name); bool cancel_timeout(Component *component, const char *name); bool cancel_timeout(Component *component, uint32_t id); bool cancel_timeout(Component *component, InternalSchedulerID id) { @@ -62,9 +55,6 @@ class Scheduler { SchedulerItem::TIMEOUT); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(Component *component, const std::string &name, uint32_t interval, std::function &&func); - /** Set an interval with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. @@ -82,8 +72,6 @@ class Scheduler { static_cast(id), interval, std::move(func)); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_interval(Component *component, const std::string &name); bool cancel_interval(Component *component, const char *name); bool cancel_interval(Component *component, uint32_t id); bool cancel_interval(Component *component, InternalSchedulerID id) { diff --git a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp index f6fd1b1de7..c8a3d7c4bd 100644 --- a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp @@ -8,14 +8,23 @@ static const char *const TAG = "bulk_cleanup"; void SchedulerBulkCleanupComponent::setup() { ESP_LOGI(TAG, "Scheduler bulk cleanup test component loaded"); } +// Static name tables keep the const char* pointers valid for the lifetime of the scheduled tasks. +static const char *const BULK_TIMEOUT_NAMES[25] = { + "bulk_timeout_0", "bulk_timeout_1", "bulk_timeout_2", "bulk_timeout_3", "bulk_timeout_4", + "bulk_timeout_5", "bulk_timeout_6", "bulk_timeout_7", "bulk_timeout_8", "bulk_timeout_9", + "bulk_timeout_10", "bulk_timeout_11", "bulk_timeout_12", "bulk_timeout_13", "bulk_timeout_14", + "bulk_timeout_15", "bulk_timeout_16", "bulk_timeout_17", "bulk_timeout_18", "bulk_timeout_19", + "bulk_timeout_20", "bulk_timeout_21", "bulk_timeout_22", "bulk_timeout_23", "bulk_timeout_24"}; +static const char *const POST_CLEANUP_NAMES[5] = {"post_cleanup_0", "post_cleanup_1", "post_cleanup_2", + "post_cleanup_3", "post_cleanup_4"}; + void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { ESP_LOGI(TAG, "Starting bulk cleanup test..."); // Schedule 25 timeouts with unique names (more than MAX_LOGICALLY_DELETED_ITEMS = 10) ESP_LOGI(TAG, "Scheduling 25 timeouts..."); for (int i = 0; i < 25; i++) { - std::string name = "bulk_timeout_" + std::to_string(i); - App.scheduler.set_timeout(this, name, 2500, [i]() { + App.scheduler.set_timeout(this, BULK_TIMEOUT_NAMES[i], 2500, [i]() { // These should never execute as we'll cancel them ESP_LOGW(TAG, "Timeout %d executed - this should not happen!", i); }); @@ -25,8 +34,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { ESP_LOGI(TAG, "Cancelling all 25 timeouts to trigger bulk cleanup..."); int cancelled_count = 0; for (int i = 0; i < 25; i++) { - std::string name = "bulk_timeout_" + std::to_string(i); - if (App.scheduler.cancel_timeout(this, name)) { + if (App.scheduler.cancel_timeout(this, BULK_TIMEOUT_NAMES[i])) { cancelled_count++; } } @@ -56,8 +64,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { // Also schedule some normal timeouts to ensure scheduler keeps working after cleanup static int post_cleanup_count = 0; for (int i = 0; i < 5; i++) { - std::string name = "post_cleanup_" + std::to_string(i); - App.scheduler.set_timeout(this, name, 50 + i * 25, [i]() { + App.scheduler.set_timeout(this, POST_CLEANUP_NAMES[i], 50 + i * 25, [i]() { ESP_LOGI(TAG, "Post-cleanup timeout %d executed correctly", i); post_cleanup_count++; if (post_cleanup_count >= 5) { diff --git a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp index 0e5525d265..4971a15dbc 100644 --- a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp @@ -4,12 +4,18 @@ #include #include #include -#include namespace esphome::scheduler_rapid_cancellation_component { static const char *const TAG = "scheduler_rapid_cancellation"; +// Static name table keeps the const char* pointers valid for the lifetime of the scheduled tasks. +// Threads race over this fixed set of names; STATIC_STRING names match by content, so scheduling +// the same name replaces (implicitly cancels) the previous timeout, exactly as before. +static const char *const SHARED_TIMEOUT_NAMES[10] = { + "shared_timeout_0", "shared_timeout_1", "shared_timeout_2", "shared_timeout_3", "shared_timeout_4", + "shared_timeout_5", "shared_timeout_6", "shared_timeout_7", "shared_timeout_8", "shared_timeout_9"}; + void SchedulerRapidCancellationComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerRapidCancellationComponent setup"); } void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { @@ -32,14 +38,12 @@ void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { for (int i = 0; i < OPERATIONS_PER_THREAD; i++) { // Use modulo to ensure multiple threads use the same names int name_index = i % NUM_NAMES; - std::stringstream ss; - ss << "shared_timeout_" << name_index; - std::string name = ss.str(); + const char *name = SHARED_TIMEOUT_NAMES[name_index]; // All threads schedule timeouts - this will implicitly cancel existing ones this->set_timeout(name, 150, [this, name]() { this->total_executed_.fetch_add(1); - ESP_LOGI(TAG, "Executed callback '%s'", name.c_str()); + ESP_LOGI(TAG, "Executed callback '%s'", name); }); this->total_scheduled_.fetch_add(1); diff --git a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp index a817b9f508..a3d135527f 100644 --- a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp @@ -1,9 +1,9 @@ #include "simultaneous_callbacks_component.h" #include "esphome/core/log.h" +#include #include #include #include -#include namespace esphome::scheduler_simultaneous_callbacks_component { @@ -41,13 +41,11 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() std::this_thread::sleep_until(start_time + std::chrono::microseconds(100)); for (int i = 0; i < CALLBACKS_PER_THREAD; i++) { - // Create unique name for each callback - std::stringstream ss; - ss << "thread_" << thread_id << "_cb_" << i; - std::string name = ss.str(); + // Unique numeric ID for each callback (zero heap allocation, no name collisions) + uint32_t callback_id = static_cast(thread_id) * CALLBACKS_PER_THREAD + i; // Schedule callback for exactly DELAY_MS from now - this->set_timeout(name, DELAY_MS, [this, name]() { + this->set_timeout(callback_id, DELAY_MS, [this, callback_id]() { // Increment concurrent counter atomically int current = this->callbacks_at_once_.fetch_add(1) + 1; @@ -57,7 +55,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() // Loop until we successfully update or someone else set a higher value } - ESP_LOGV(TAG, "Callback executed: %s (concurrent: %d)", name.c_str(), current); + ESP_LOGV(TAG, "Callback executed: id=%" PRIu32 " (concurrent: %d)", callback_id, current); // Simulate some minimal work std::atomic work{0}; @@ -73,7 +71,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() }); this->total_scheduled_.fetch_add(1); - ESP_LOGV(TAG, "Scheduled callback %s", name.c_str()); + ESP_LOGV(TAG, "Scheduled callback id=%" PRIu32, callback_id); } ESP_LOGD(TAG, "Thread %d completed scheduling", thread_id); diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py deleted file mode 100644 index 3f29a839ef..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.const import CONF_ID - -scheduler_string_lifetime_component_ns = cg.esphome_ns.namespace( - "scheduler_string_lifetime_component" -) -SchedulerStringLifetimeComponent = scheduler_string_lifetime_component_ns.class_( - "SchedulerStringLifetimeComponent", cg.Component -) - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SchedulerStringLifetimeComponent), - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp deleted file mode 100644 index cc1b9f7814..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp +++ /dev/null @@ -1,260 +0,0 @@ -#include "string_lifetime_component.h" -#include "esphome/core/log.h" -#include -#include -#include - -namespace esphome::scheduler_string_lifetime_component { - -static const char *const TAG = "scheduler_string_lifetime"; - -void SchedulerStringLifetimeComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringLifetimeComponent setup"); } - -void SchedulerStringLifetimeComponent::run_string_lifetime_test() { - ESP_LOGI(TAG, "Starting string lifetime tests"); - - this->tests_passed_ = 0; - this->tests_failed_ = 0; - - // Run each test - test_temporary_string_lifetime(); - test_scope_exit_string(); - test_vector_reallocation(); - test_string_move_semantics(); - test_lambda_capture_lifetime(); -} - -void SchedulerStringLifetimeComponent::run_test1() { - test_temporary_string_lifetime(); - // Wait for all callbacks to execute - this->set_timeout("test1_complete", 10, []() { ESP_LOGI(TAG, "Test 1 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test2() { - test_scope_exit_string(); - // Wait for all callbacks to execute - this->set_timeout("test2_complete", 20, []() { ESP_LOGI(TAG, "Test 2 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test3() { - test_vector_reallocation(); - // Wait for all callbacks to execute - this->set_timeout("test3_complete", 60, []() { ESP_LOGI(TAG, "Test 3 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test4() { - test_string_move_semantics(); - // Wait for all callbacks to execute - this->set_timeout("test4_complete", 35, []() { ESP_LOGI(TAG, "Test 4 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test5() { - test_lambda_capture_lifetime(); - // Wait for all callbacks to execute - this->set_timeout("test5_complete", 50, []() { ESP_LOGI(TAG, "Test 5 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_final_check() { - ESP_LOGI(TAG, "Tests passed: %d", this->tests_passed_); - ESP_LOGI(TAG, "Tests failed: %d", this->tests_failed_); - - if (this->tests_failed_ == 0) { - ESP_LOGI(TAG, "SUCCESS: All string lifetime tests passed!"); - } else { - ESP_LOGE(TAG, "FAILURE: %d string lifetime tests failed!", this->tests_failed_); - } - ESP_LOGI(TAG, "String lifetime tests complete"); -} - -void SchedulerStringLifetimeComponent::test_temporary_string_lifetime() { - ESP_LOGI(TAG, "Test 1: Temporary string lifetime for timeout names"); - - // Test with a temporary string that goes out of scope immediately - { - std::string temp_name = "temp_callback_" + std::to_string(12345); - - // Schedule with temporary string name - scheduler must copy/store this - this->set_timeout(temp_name, 1, [this]() { - ESP_LOGD(TAG, "Callback for temp string name executed"); - this->tests_passed_++; - }); - - // String goes out of scope here, but scheduler should have made a copy - } - - // Test with rvalue string as name - this->set_timeout(std::string("rvalue_test"), 2, [this]() { - ESP_LOGD(TAG, "Rvalue string name callback executed"); - this->tests_passed_++; - }); - - // Test cancelling with reconstructed string - { - std::string cancel_name = "cancel_test_" + std::to_string(999); - this->set_timeout(cancel_name, 100, [this]() { - ESP_LOGE(TAG, "This should have been cancelled!"); - this->tests_failed_++; - }); - } // cancel_name goes out of scope - - // Reconstruct the same string to cancel - std::string cancel_name_2 = "cancel_test_" + std::to_string(999); - bool cancelled = this->cancel_timeout(cancel_name_2); - if (cancelled) { - ESP_LOGD(TAG, "Successfully cancelled with reconstructed string"); - this->tests_passed_++; - } else { - ESP_LOGE(TAG, "Failed to cancel with reconstructed string"); - this->tests_failed_++; - } -} - -void SchedulerStringLifetimeComponent::test_scope_exit_string() { - ESP_LOGI(TAG, "Test 2: Scope exit string names"); - - // Create string names in a limited scope - { - std::string scoped_name = "scoped_timeout_" + std::to_string(555); - - // Schedule with scoped string name - this->set_timeout(scoped_name, 3, [this]() { - ESP_LOGD(TAG, "Scoped name callback executed"); - this->tests_passed_++; - }); - - // scoped_name goes out of scope here - } - - // Test with dynamically allocated string name - { - auto *dynamic_name = new std::string("dynamic_timeout_" + std::to_string(777)); - - this->set_timeout(*dynamic_name, 4, [this, dynamic_name]() { - ESP_LOGD(TAG, "Dynamic string name callback executed"); - this->tests_passed_++; - delete dynamic_name; // Clean up in callback - }); - - // Pointer goes out of scope but string object remains until callback - } - - // Test multiple timeouts with same dynamically created name - for (int i = 0; i < 3; i++) { - std::string loop_name = "loop_timeout_" + std::to_string(i); - this->set_timeout(loop_name, 5 + i * 1, [this, i]() { - ESP_LOGD(TAG, "Loop timeout %d executed", i); - this->tests_passed_++; - }); - // loop_name destroyed and recreated each iteration - } -} - -void SchedulerStringLifetimeComponent::test_vector_reallocation() { - ESP_LOGI(TAG, "Test 3: Vector reallocation stress on timeout names"); - - // Create a vector that will reallocate - std::vector names; - names.reserve(2); // Small initial capacity to force reallocation - - // Schedule callbacks with string names from vector - for (int i = 0; i < 10; i++) { - names.push_back("vector_cb_" + std::to_string(i)); - // Use the string from vector as timeout name - this->set_timeout(names.back(), 8 + i * 1, [this, i]() { - ESP_LOGV(TAG, "Vector name callback %d executed", i); - this->tests_passed_++; - }); - } - - // Force reallocation by adding more elements - // This will move all strings to new memory locations - for (int i = 10; i < 50; i++) { - names.push_back("realloc_trigger_" + std::to_string(i)); - } - - // Add more timeouts after reallocation to ensure old names still work - for (int i = 50; i < 55; i++) { - names.push_back("post_realloc_" + std::to_string(i)); - this->set_timeout(names.back(), 20 + (i - 50), [this]() { - ESP_LOGV(TAG, "Post-reallocation callback executed"); - this->tests_passed_++; - }); - } - - // Clear the vector while timeouts are still pending - names.clear(); - ESP_LOGD(TAG, "Vector cleared - all string names destroyed"); -} - -void SchedulerStringLifetimeComponent::test_string_move_semantics() { - ESP_LOGI(TAG, "Test 4: String move semantics for timeout names"); - - // Test moving string names - std::string original = "move_test_original"; - std::string moved = std::move(original); - - // Schedule with moved string as name - this->set_timeout(moved, 30, [this]() { - ESP_LOGD(TAG, "Moved string name callback executed"); - this->tests_passed_++; - }); - - // original is now empty, try to use it as a different timeout name - original = "reused_after_move"; - this->set_timeout(original, 32, [this]() { - ESP_LOGD(TAG, "Reused string name callback executed"); - this->tests_passed_++; - }); -} - -void SchedulerStringLifetimeComponent::test_lambda_capture_lifetime() { - ESP_LOGI(TAG, "Test 5: Complex timeout name scenarios"); - - // Test scheduling with name built in lambda - [this]() { - std::string lambda_name = "lambda_built_name_" + std::to_string(888); - this->set_timeout(lambda_name, 38, [this]() { - ESP_LOGD(TAG, "Lambda-built name callback executed"); - this->tests_passed_++; - }); - }(); // Lambda executes and lambda_name is destroyed - - // Test with shared_ptr name - auto shared_name = std::make_shared("shared_ptr_timeout"); - this->set_timeout(*shared_name, 40, [this, shared_name]() { - ESP_LOGD(TAG, "Shared_ptr name callback executed"); - this->tests_passed_++; - }); - shared_name.reset(); // Release the shared_ptr - - // Test overwriting timeout with same name - std::string overwrite_name = "overwrite_test"; - this->set_timeout(overwrite_name, 1000, [this]() { - ESP_LOGE(TAG, "This should have been overwritten!"); - this->tests_failed_++; - }); - - // Overwrite with shorter timeout - this->set_timeout(overwrite_name, 42, [this]() { - ESP_LOGD(TAG, "Overwritten timeout executed"); - this->tests_passed_++; - }); - - // Test very long string name - std::string long_name; - for (int i = 0; i < 100; i++) { - long_name += "very_long_timeout_name_segment_" + std::to_string(i) + "_"; - } - this->set_timeout(long_name, 44, [this]() { - ESP_LOGD(TAG, "Very long name timeout executed"); - this->tests_passed_++; - }); - - // Test empty string as name - this->set_timeout("", 46, [this]() { - ESP_LOGD(TAG, "Empty string name timeout executed"); - this->tests_passed_++; - }); -} - -} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h deleted file mode 100644 index 20185f128d..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include -#include - -namespace esphome::scheduler_string_lifetime_component { - -class SchedulerStringLifetimeComponent : public Component { - public: - void setup() override; - float get_setup_priority() const override { return setup_priority::LATE; } - - void run_string_lifetime_test(); - - // Individual test methods exposed as services - void run_test1(); - void run_test2(); - void run_test3(); - void run_test4(); - void run_test5(); - void run_final_check(); - - private: - void test_temporary_string_lifetime(); - void test_scope_exit_string(); - void test_vector_reallocation(); - void test_string_move_semantics(); - void test_lambda_capture_lifetime(); - - int tests_passed_{0}; - int tests_failed_{0}; -}; - -} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py deleted file mode 100644 index 6cc564395c..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.const import CONF_ID - -scheduler_string_name_stress_component_ns = cg.esphome_ns.namespace( - "scheduler_string_name_stress_component" -) -SchedulerStringNameStressComponent = scheduler_string_name_stress_component_ns.class_( - "SchedulerStringNameStressComponent", cg.Component -) - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SchedulerStringNameStressComponent), - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp deleted file mode 100644 index 677d371f25..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include "string_name_stress_component.h" -#include "esphome/core/log.h" -#include -#include -#include -#include -#include -#include - -namespace esphome::scheduler_string_name_stress_component { - -static const char *const TAG = "scheduler_string_name_stress"; - -void SchedulerStringNameStressComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringNameStressComponent setup"); } - -void SchedulerStringNameStressComponent::run_string_name_stress_test() { - // Use member variables to reset state - this->total_callbacks_ = 0; - this->executed_callbacks_ = 0; - static constexpr int NUM_THREADS = 10; - static constexpr int CALLBACKS_PER_THREAD = 100; - - ESP_LOGI(TAG, "Starting string name stress test - multi-threaded set_timeout with std::string names"); - ESP_LOGI(TAG, "This test specifically uses dynamic string names to test memory management"); - - // Track start time - auto start_time = std::chrono::steady_clock::now(); - - // Create threads - std::vector threads; - - ESP_LOGI(TAG, "Creating %d threads, each will schedule %d callbacks with dynamic names", NUM_THREADS, - CALLBACKS_PER_THREAD); - - threads.reserve(NUM_THREADS); - for (int i = 0; i < NUM_THREADS; i++) { - threads.emplace_back([this, i]() { - ESP_LOGV(TAG, "Thread %d starting", i); - - // Each thread schedules callbacks with dynamically created string names - for (int j = 0; j < CALLBACKS_PER_THREAD; j++) { - int callback_id = this->total_callbacks_.fetch_add(1); - - // Create a dynamic string name - this will test memory management - std::stringstream ss; - ss << "thread_" << i << "_callback_" << j << "_id_" << callback_id; - std::string dynamic_name = ss.str(); - - ESP_LOGV(TAG, "Thread %d scheduling timeout with dynamic name: %s", i, dynamic_name.c_str()); - - // Capture necessary values for the lambda - auto *component = this; - - // Schedule with std::string name - this tests the string overload - // Use varying delays to stress the heap scheduler - uint32_t delay = 1 + (callback_id % 50); - - // Also test nested scheduling from callbacks - if (j % 10 == 0) { - // Every 10th callback schedules another callback - this->set_timeout(dynamic_name, delay, [component, callback_id]() { - component->executed_callbacks_.fetch_add(1); - ESP_LOGV(TAG, "Executed string-named callback %d (nested scheduler)", callback_id); - - // Schedule another timeout from within this callback with a new dynamic name - std::string nested_name = "nested_from_" + std::to_string(callback_id); - component->set_timeout(nested_name, 1, [callback_id]() { - ESP_LOGV(TAG, "Executed nested string-named callback from %d", callback_id); - }); - }); - } else { - // Regular callback - this->set_timeout(dynamic_name, delay, [component, callback_id]() { - component->executed_callbacks_.fetch_add(1); - ESP_LOGV(TAG, "Executed string-named callback %d", callback_id); - }); - } - - // Add some timing variations to increase race conditions - if (j % 5 == 0) { - std::this_thread::sleep_for(std::chrono::microseconds(100)); - } - } - ESP_LOGV(TAG, "Thread %d finished scheduling", i); - }); - } - - // Wait for all threads to complete scheduling - for (auto &t : threads) { - t.join(); - } - - auto end_time = std::chrono::steady_clock::now(); - auto thread_time = std::chrono::duration_cast(end_time - start_time).count(); - ESP_LOGI(TAG, "All threads finished scheduling in %lldms. Created %d callbacks with dynamic names", thread_time, - this->total_callbacks_.load()); - - // Give some time for callbacks to execute - ESP_LOGI(TAG, "Waiting for callbacks to execute..."); - - // Schedule a final callback to signal completion - this->set_timeout("test_complete", 2000, [this]() { - ESP_LOGI(TAG, "String name stress test complete. Executed %d of %d callbacks", this->executed_callbacks_.load(), - this->total_callbacks_.load()); - }); -} - -} // namespace esphome::scheduler_string_name_stress_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h deleted file mode 100644 index 121bda6204..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include - -namespace esphome::scheduler_string_name_stress_component { - -class SchedulerStringNameStressComponent : public Component { - public: - void setup() override; - float get_setup_priority() const override { return setup_priority::LATE; } - - void run_string_name_stress_test(); - - private: - std::atomic total_callbacks_{0}; - std::atomic executed_callbacks_{0}; -}; - -} // namespace esphome::scheduler_string_name_stress_component diff --git a/tests/integration/fixtures/scheduler_string_lifetime.yaml b/tests/integration/fixtures/scheduler_string_lifetime.yaml deleted file mode 100644 index 5ae5a1914e..0000000000 --- a/tests/integration/fixtures/scheduler_string_lifetime.yaml +++ /dev/null @@ -1,48 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: scheduler-string-lifetime-test - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - components: [scheduler_string_lifetime_component] - -host: - -logger: - level: DEBUG - -scheduler_string_lifetime_component: - id: string_lifetime - -api: - services: - - service: run_string_lifetime_test - then: - - lambda: |- - id(string_lifetime)->run_string_lifetime_test(); - - service: run_test1 - then: - - lambda: |- - id(string_lifetime)->run_test1(); - - service: run_test2 - then: - - lambda: |- - id(string_lifetime)->run_test2(); - - service: run_test3 - then: - - lambda: |- - id(string_lifetime)->run_test3(); - - service: run_test4 - then: - - lambda: |- - id(string_lifetime)->run_test4(); - - service: run_test5 - then: - - lambda: |- - id(string_lifetime)->run_test5(); - - service: run_final_check - then: - - lambda: |- - id(string_lifetime)->run_final_check(); diff --git a/tests/integration/fixtures/scheduler_string_name_stress.yaml b/tests/integration/fixtures/scheduler_string_name_stress.yaml deleted file mode 100644 index 8f68d1d102..0000000000 --- a/tests/integration/fixtures/scheduler_string_name_stress.yaml +++ /dev/null @@ -1,39 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: sched-string-name-stress - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - components: [scheduler_string_name_stress_component] - -host: - -logger: - level: VERBOSE - -scheduler_string_name_stress_component: - id: string_stress - -api: - services: - - service: run_string_name_stress_test - then: - - lambda: |- - id(string_stress)->run_string_name_stress_test(); - -event: - - platform: template - name: "Test Complete" - id: test_complete - device_class: button - event_types: - - "test_finished" - - platform: template - name: "Test Result" - id: test_result - device_class: button - event_types: - - "passed" - - "failed" diff --git a/tests/integration/fixtures/scheduler_string_test.yaml b/tests/integration/fixtures/scheduler_string_test.yaml deleted file mode 100644 index c53ec392df..0000000000 --- a/tests/integration/fixtures/scheduler_string_test.yaml +++ /dev/null @@ -1,310 +0,0 @@ -esphome: - name: scheduler-string-test - on_boot: - priority: -100 - then: - - logger.log: "Starting scheduler string tests" - debug_scheduler: true # Enable scheduler debug logging - -host: -api: -logger: - level: VERBOSE - -globals: - - id: timeout_counter - type: int - initial_value: '0' - - id: interval_counter - type: int - initial_value: '0' - - id: dynamic_counter - type: int - initial_value: '0' - - id: static_tests_done - type: bool - initial_value: 'false' - - id: dynamic_tests_done - type: bool - initial_value: 'false' - - id: results_reported - type: bool - initial_value: 'false' - - id: edge_tests_done - type: bool - initial_value: 'false' - - id: empty_cancel_failed - type: bool - initial_value: 'false' - -script: - - id: test_static_strings - then: - - logger.log: "Testing static string timeouts and intervals" - - lambda: |- - auto *component1 = id(test_sensor1); - // Test 1: Static string literals with set_timeout - App.scheduler.set_timeout(component1, "static_timeout_1", 50, []() { - ESP_LOGI("test", "Static timeout 1 fired"); - id(timeout_counter) += 1; - }); - - // Test 2: Static const char* with set_timeout - static const char* TIMEOUT_NAME = "static_timeout_2"; - App.scheduler.set_timeout(component1, TIMEOUT_NAME, 100, []() { - ESP_LOGI("test", "Static timeout 2 fired"); - id(timeout_counter) += 1; - }); - - // Test 3: Static string literal with set_interval - App.scheduler.set_interval(component1, "static_interval_1", 200, []() { - ESP_LOGI("test", "Static interval 1 fired, count: %d", id(interval_counter)); - id(interval_counter) += 1; - if (id(interval_counter) >= 3) { - App.scheduler.cancel_interval(id(test_sensor1), "static_interval_1"); - ESP_LOGI("test", "Cancelled static interval 1"); - } - }); - - // Test 4: Empty string (should be handled safely) - App.scheduler.set_timeout(component1, "", 150, []() { - ESP_LOGI("test", "Empty string timeout fired"); - }); - - // Test 5: Cancel timeout with const char* literal - App.scheduler.set_timeout(component1, "cancel_static_timeout", 5000, []() { - ESP_LOGI("test", "This static timeout should be cancelled"); - }); - // Cancel using const char* directly - App.scheduler.cancel_timeout(component1, "cancel_static_timeout"); - ESP_LOGI("test", "Cancelled static timeout using const char*"); - - // Test 6 & 7: Test defer with const char* overload using a test component - class TestDeferComponent : public Component { - public: - void test_static_defer() { - // Test 6: Static string literal with defer (const char* overload) - this->defer("static_defer_1", []() { - ESP_LOGI("test", "Static defer 1 fired"); - id(timeout_counter) += 1; - }); - - // Test 7: Static const char* with defer - static const char* DEFER_NAME = "static_defer_2"; - this->defer(DEFER_NAME, []() { - ESP_LOGI("test", "Static defer 2 fired"); - id(timeout_counter) += 1; - }); - } - }; - - static TestDeferComponent test_defer_component; - test_defer_component.test_static_defer(); - - - id: test_dynamic_strings - then: - - logger.log: "Testing dynamic string timeouts and intervals" - - lambda: |- - auto *component2 = id(test_sensor2); - - // Test 8: Dynamic string with set_timeout (std::string) - std::string dynamic_name = "dynamic_timeout_" + std::to_string(id(dynamic_counter)++); - App.scheduler.set_timeout(component2, dynamic_name, 100, []() { - ESP_LOGI("test", "Dynamic timeout fired"); - id(timeout_counter) += 1; - }); - - // Test 9: Dynamic string with set_interval - std::string interval_name = "dynamic_interval_" + std::to_string(id(dynamic_counter)++); - App.scheduler.set_interval(component2, interval_name, 250, [interval_name]() { - ESP_LOGI("test", "Dynamic interval fired: %s", interval_name.c_str()); - id(interval_counter) += 1; - if (id(interval_counter) >= 6) { - App.scheduler.cancel_interval(id(test_sensor2), interval_name); - ESP_LOGI("test", "Cancelled dynamic interval"); - } - }); - - // Test 10: Cancel with different string object but same content - std::string cancel_name = "cancel_test"; - App.scheduler.set_timeout(component2, cancel_name, 2000, []() { - ESP_LOGI("test", "This should be cancelled"); - }); - - // Cancel using a different string object - std::string cancel_name_2 = "cancel_test"; - App.scheduler.cancel_timeout(component2, cancel_name_2); - ESP_LOGI("test", "Cancelled timeout using different string object"); - - // Test 11: Dynamic string with defer (using std::string overload) - class TestDynamicDeferComponent : public Component { - public: - void test_dynamic_defer() { - std::string defer_name = "dynamic_defer_" + std::to_string(id(dynamic_counter)++); - this->defer(defer_name, [defer_name]() { - ESP_LOGI("test", "Dynamic defer fired: %s", defer_name.c_str()); - id(timeout_counter) += 1; - }); - } - }; - - static TestDynamicDeferComponent test_dynamic_defer_component; - test_dynamic_defer_component.test_dynamic_defer(); - - - id: test_cancellation_edge_cases - then: - - logger.log: "Testing cancellation edge cases" - - lambda: |- - auto *component1 = id(test_sensor1); - // Use a different component for empty string tests to avoid interference - auto *component2 = id(test_sensor2); - - // Test 12: Cancel with empty string - regression test for issue #9599 - // First create a timeout with empty name on component2 to avoid interference - App.scheduler.set_timeout(component2, "", 500, []() { - ESP_LOGE("test", "ERROR: Empty name timeout fired - it should have been cancelled!"); - id(empty_cancel_failed) = true; - }); - - // Now cancel it - this should work after our fix - bool cancelled_empty = App.scheduler.cancel_timeout(component2, ""); - ESP_LOGI("test", "Cancel empty string result: %s (should be true)", cancelled_empty ? "true" : "false"); - if (!cancelled_empty) { - ESP_LOGE("test", "ERROR: Failed to cancel empty string timeout!"); - id(empty_cancel_failed) = true; - } - - // Test 13: Cancel non-existent timeout - bool cancelled_nonexistent = App.scheduler.cancel_timeout(component1, "does_not_exist"); - ESP_LOGI("test", "Cancel non-existent timeout result: %s", - cancelled_nonexistent ? "true (unexpected!)" : "false (expected)"); - - // Test 14: Multiple timeouts with same name - only last should execute - for (int i = 0; i < 5; i++) { - App.scheduler.set_timeout(component1, "duplicate_timeout", 200 + i*10, [i]() { - ESP_LOGI("test", "Duplicate timeout %d fired", i); - id(timeout_counter) += 1; - }); - } - ESP_LOGI("test", "Created 5 timeouts with same name 'duplicate_timeout'"); - - // Test 15: Multiple intervals with same name - only last should run - for (int i = 0; i < 3; i++) { - App.scheduler.set_interval(component1, "duplicate_interval", 300, [i]() { - ESP_LOGI("test", "Duplicate interval %d fired", i); - id(interval_counter) += 10; // Large increment to detect multiple - // Cancel after first execution - App.scheduler.cancel_interval(id(test_sensor1), "duplicate_interval"); - }); - } - ESP_LOGI("test", "Created 3 intervals with same name 'duplicate_interval'"); - - // Test 16: Cancel with nullptr protection (via empty const char*) - const char* null_name = ""; - App.scheduler.set_timeout(component2, null_name, 600, []() { - ESP_LOGE("test", "ERROR: Const char* empty timeout fired - should have been cancelled!"); - id(empty_cancel_failed) = true; - }); - bool cancelled_const_empty = App.scheduler.cancel_timeout(component2, null_name); - ESP_LOGI("test", "Cancel const char* empty result: %s (should be true)", - cancelled_const_empty ? "true" : "false"); - if (!cancelled_const_empty) { - ESP_LOGE("test", "ERROR: Failed to cancel const char* empty timeout!"); - id(empty_cancel_failed) = true; - } - - // Test 17: Rapid create/cancel/create with same name - App.scheduler.set_timeout(component1, "rapid_test", 5000, []() { - ESP_LOGI("test", "First rapid timeout - should not fire"); - id(timeout_counter) += 100; - }); - App.scheduler.cancel_timeout(component1, "rapid_test"); - App.scheduler.set_timeout(component1, "rapid_test", 250, []() { - ESP_LOGI("test", "Second rapid timeout - should fire"); - id(timeout_counter) += 1; - }); - - // Test 18: Cancel all with a specific name (multiple instances) - // Create multiple with same name - App.scheduler.set_timeout(component1, "multi_cancel", 300, []() { - ESP_LOGI("test", "Multi-cancel timeout 1"); - }); - App.scheduler.set_timeout(component1, "multi_cancel", 350, []() { - ESP_LOGI("test", "Multi-cancel timeout 2"); - }); - App.scheduler.set_timeout(component1, "multi_cancel", 400, []() { - ESP_LOGI("test", "Multi-cancel timeout 3 - only this should fire"); - id(timeout_counter) += 1; - }); - // Note: Each set_timeout with same name cancels the previous one automatically - - - id: report_results - then: - - lambda: |- - ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d", - id(timeout_counter), id(interval_counter)); - - // Check if empty string cancellation test passed - if (id(empty_cancel_failed)) { - ESP_LOGE("test", "ERROR: Empty string cancellation test FAILED!"); - } else { - ESP_LOGI("test", "Empty string cancellation test PASSED"); - } - -sensor: - - platform: template - name: Test Sensor 1 - id: test_sensor1 - lambda: return 1.0; - update_interval: never - - - platform: template - name: Test Sensor 2 - id: test_sensor2 - lambda: return 2.0; - update_interval: never - -interval: - # Run static string tests after boot - using script to run once - - interval: 0.1s - then: - - if: - condition: - lambda: 'return id(static_tests_done) == false;' - then: - - lambda: 'id(static_tests_done) = true;' - - script.execute: test_static_strings - - logger.log: "Started static string tests" - - # Run dynamic string tests after static tests - - interval: 0.2s - then: - - if: - condition: - lambda: 'return id(static_tests_done) && !id(dynamic_tests_done);' - then: - - lambda: 'id(dynamic_tests_done) = true;' - - delay: 0.2s - - script.execute: test_dynamic_strings - - # Run cancellation edge case tests after dynamic tests - - interval: 0.2s - then: - - if: - condition: - lambda: 'return id(dynamic_tests_done) && !id(edge_tests_done);' - then: - - lambda: 'id(edge_tests_done) = true;' - - delay: 0.5s - - script.execute: test_cancellation_edge_cases - - # Report results after all tests - - interval: 0.2s - then: - - if: - condition: - lambda: 'return id(edge_tests_done) && !id(results_reported);' - then: - - lambda: 'id(results_reported) = true;' - - delay: 1s - - script.execute: report_results diff --git a/tests/integration/test_scheduler_string_lifetime.py b/tests/integration/test_scheduler_string_lifetime.py deleted file mode 100644 index bfa581129b..0000000000 --- a/tests/integration/test_scheduler_string_lifetime.py +++ /dev/null @@ -1,169 +0,0 @@ -"""String lifetime test - verify scheduler handles string destruction correctly.""" - -import asyncio -from pathlib import Path -import re - -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_lifetime( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that scheduler correctly handles string lifetimes when strings go out of scope.""" - - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - # Create events for synchronization - test1_complete = asyncio.Event() - test2_complete = asyncio.Event() - test3_complete = asyncio.Event() - test4_complete = asyncio.Event() - test5_complete = asyncio.Event() - all_tests_complete = asyncio.Event() - - # Track test progress - test_stats = { - "tests_passed": 0, - "tests_failed": 0, - "errors": [], - "current_test": None, - "test_callbacks_executed": {}, - } - - def on_log_line(line: str) -> None: - # Track test-specific events - if "Test 1 complete" in line: - test1_complete.set() - elif "Test 2 complete" in line: - test2_complete.set() - elif "Test 3 complete" in line: - test3_complete.set() - elif "Test 4 complete" in line: - test4_complete.set() - elif "Test 5 complete" in line: - test5_complete.set() - - # Track individual callback executions - callback_match = re.search(r"Callback '(.+?)' executed", line) - if callback_match: - callback_name = callback_match.group(1) - test_stats["test_callbacks_executed"][callback_name] = True - - # Track test results from the C++ test output - if "Tests passed:" in line and "string_lifetime" in line: - # Extract the number from "Tests passed: 32" - match = re.search(r"Tests passed:\s*(\d+)", line) - if match: - test_stats["tests_passed"] = int(match.group(1)) - elif "Tests failed:" in line and "string_lifetime" in line: - match = re.search(r"Tests failed:\s*(\d+)", line) - if match: - test_stats["tests_failed"] = int(match.group(1)) - elif "ERROR" in line and "string_lifetime" in line: - test_stats["errors"].append(line) - - # Check for memory corruption indicators - if any( - indicator in line.lower() - for indicator in [ - "use after free", - "heap corruption", - "segfault", - "abort", - "assertion", - "sanitizer", - "bad memory", - "invalid pointer", - ] - ): - pytest.fail(f"Memory corruption detected: {line}") - - # Check for completion - if "String lifetime tests complete" in line: - all_tests_complete.set() - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "scheduler-string-lifetime-test" - - # List entities and services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) - - # Find our test services - test_services = {} - for service in services: - if service.name == "run_test1": - test_services["test1"] = service - elif service.name == "run_test2": - test_services["test2"] = service - elif service.name == "run_test3": - test_services["test3"] = service - elif service.name == "run_test4": - test_services["test4"] = service - elif service.name == "run_test5": - test_services["test5"] = service - elif service.name == "run_final_check": - test_services["final"] = service - - # Ensure all services are found - required_services = ["test1", "test2", "test3", "test4", "test5", "final"] - for service_name in required_services: - assert service_name in test_services, f"{service_name} service not found" - - # Run tests sequentially, waiting for each to complete - try: - # Test 1 - await client.execute_service(test_services["test1"], {}) - await asyncio.wait_for(test1_complete.wait(), timeout=5.0) - - # Test 2 - await client.execute_service(test_services["test2"], {}) - await asyncio.wait_for(test2_complete.wait(), timeout=5.0) - - # Test 3 - await client.execute_service(test_services["test3"], {}) - await asyncio.wait_for(test3_complete.wait(), timeout=5.0) - - # Test 4 - await client.execute_service(test_services["test4"], {}) - await asyncio.wait_for(test4_complete.wait(), timeout=5.0) - - # Test 5 - await client.execute_service(test_services["test5"], {}) - await asyncio.wait_for(test5_complete.wait(), timeout=5.0) - - # Final check - await client.execute_service(test_services["final"], {}) - await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) - - except TimeoutError: - pytest.fail(f"String lifetime test timed out. Stats: {test_stats}") - - # Check for any errors - assert test_stats["tests_failed"] == 0, f"Tests failed: {test_stats['errors']}" - - # Verify we had the expected number of passing tests - assert test_stats["tests_passed"] == 30, ( - f"Expected exactly 30 tests to pass, but got {test_stats['tests_passed']}" - ) diff --git a/tests/integration/test_scheduler_string_name_stress.py b/tests/integration/test_scheduler_string_name_stress.py deleted file mode 100644 index 56b8998c56..0000000000 --- a/tests/integration/test_scheduler_string_name_stress.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Stress test for heap scheduler with std::string names from multiple threads.""" - -import asyncio -from pathlib import Path -import re - -from aioesphomeapi import UserService -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_name_stress( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that set_timeout/set_interval with std::string names doesn't crash when called from multiple threads.""" - - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - # Create a future to signal test completion - loop = asyncio.get_running_loop() - test_complete_future: asyncio.Future[None] = loop.create_future() - - # Track executed callbacks and any crashes - executed_callbacks: set[int] = set() - error_messages: list[str] = [] - - def on_log_line(line: str) -> None: - # Check for crash indicators - if any( - indicator in line.lower() - for indicator in [ - "segfault", - "abort", - "assertion", - "heap corruption", - "use after free", - ] - ): - error_messages.append(line) - if not test_complete_future.done(): - test_complete_future.set_exception(Exception(f"Crash detected: {line}")) - return - - # Track executed callbacks - match = re.search(r"Executed string-named callback (\d+)", line) - if match: - callback_id = int(match.group(1)) - executed_callbacks.add(callback_id) - - # Check for completion - if ( - "String name stress test complete" in line - and not test_complete_future.done() - ): - test_complete_future.set_result(None) - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "sched-string-name-stress" - - # List entities and services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) - - # Find our test service - run_stress_test_service: UserService | None = None - for service in services: - if service.name == "run_string_name_stress_test": - run_stress_test_service = service - break - - assert run_stress_test_service is not None, ( - "run_string_name_stress_test service not found" - ) - - # Call the service to start the test - await client.execute_service(run_stress_test_service, {}) - - # Wait for test to complete or crash - try: - await asyncio.wait_for(test_complete_future, timeout=30.0) - except TimeoutError: - pytest.fail( - f"String name stress test timed out. Executed {len(executed_callbacks)} callbacks. " - f"This might indicate a deadlock." - ) - - # Verify no errors occurred (crashes already handled by exception) - assert not error_messages, f"Errors detected during test: {error_messages}" - - # Verify we executed all 1000 callbacks (10 threads × 100 callbacks each) - assert len(executed_callbacks) == 1000, ( - f"Expected 1000 callbacks but got {len(executed_callbacks)}" - ) - - # Verify each callback ID was executed exactly once - for i in range(1000): - assert i in executed_callbacks, f"Callback {i} was not executed" diff --git a/tests/integration/test_scheduler_string_test.py b/tests/integration/test_scheduler_string_test.py deleted file mode 100644 index 783ed37c13..0000000000 --- a/tests/integration/test_scheduler_string_test.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Test scheduler string optimization with static and dynamic strings.""" - -import asyncio -import re - -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_test( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that scheduler handles both static and dynamic strings correctly.""" - # Track counts - timeout_count = 0 - interval_count = 0 - - # Events for each test completion - static_timeout_1_fired = asyncio.Event() - static_timeout_2_fired = asyncio.Event() - static_interval_fired = asyncio.Event() - static_interval_cancelled = asyncio.Event() - empty_string_timeout_fired = asyncio.Event() - static_timeout_cancelled = asyncio.Event() - static_defer_1_fired = asyncio.Event() - static_defer_2_fired = asyncio.Event() - dynamic_timeout_fired = asyncio.Event() - dynamic_interval_fired = asyncio.Event() - dynamic_defer_fired = asyncio.Event() - cancel_test_done = asyncio.Event() - final_results_logged = asyncio.Event() - - # Track interval counts - static_interval_count = 0 - dynamic_interval_count = 0 - - def on_log_line(line: str) -> None: - nonlocal \ - timeout_count, \ - interval_count, \ - static_interval_count, \ - dynamic_interval_count - - # Strip ANSI color codes - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - - # Check for static timeout completions - if "Static timeout 1 fired" in clean_line: - static_timeout_1_fired.set() - timeout_count += 1 - - elif "Static timeout 2 fired" in clean_line: - static_timeout_2_fired.set() - timeout_count += 1 - - # Check for static interval - elif "Static interval 1 fired" in clean_line: - match = re.search(r"count: (\d+)", clean_line) - if match: - static_interval_count = int(match.group(1)) - static_interval_fired.set() - - elif "Cancelled static interval 1" in clean_line: - static_interval_cancelled.set() - - # Check for empty string timeout - elif "Empty string timeout fired" in clean_line: - empty_string_timeout_fired.set() - - # Check for static timeout cancellation - elif "Cancelled static timeout using const char*" in clean_line: - static_timeout_cancelled.set() - - # Check for static defer tests - elif "Static defer 1 fired" in clean_line: - static_defer_1_fired.set() - timeout_count += 1 - - elif "Static defer 2 fired" in clean_line: - static_defer_2_fired.set() - timeout_count += 1 - - # Check for dynamic string tests - elif "Dynamic timeout fired" in clean_line: - dynamic_timeout_fired.set() - timeout_count += 1 - - elif "Dynamic interval fired" in clean_line: - dynamic_interval_count += 1 - dynamic_interval_fired.set() - - # Check for dynamic defer test - elif "Dynamic defer fired" in clean_line: - dynamic_defer_fired.set() - timeout_count += 1 - - # Check for cancel test - elif "Cancelled timeout using different string object" in clean_line: - cancel_test_done.set() - - # Check for final results - elif "Final results" in clean_line: - match = re.search(r"Timeouts: (\d+), Intervals: (\d+)", clean_line) - if match: - timeout_count = int(match.group(1)) - interval_count = int(match.group(2)) - final_results_logged.set() - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "scheduler-string-test" - - # Wait for static string tests - try: - await asyncio.wait_for(static_timeout_1_fired.wait(), timeout=0.5) - except TimeoutError: - pytest.fail("Static timeout 1 did not fire within 0.5 seconds") - - try: - await asyncio.wait_for(static_timeout_2_fired.wait(), timeout=0.5) - except TimeoutError: - pytest.fail("Static timeout 2 did not fire within 0.5 seconds") - - try: - await asyncio.wait_for(static_interval_fired.wait(), timeout=1.0) - except TimeoutError: - pytest.fail("Static interval did not fire within 1 second") - - try: - await asyncio.wait_for(static_interval_cancelled.wait(), timeout=2.0) - except TimeoutError: - pytest.fail("Static interval was not cancelled within 2 seconds") - - # Verify static interval ran at least 3 times - assert static_interval_count >= 2, ( - f"Expected static interval to run at least 3 times, got {static_interval_count + 1}" - ) - - # Verify static timeout was cancelled - assert static_timeout_cancelled.is_set(), ( - "Static timeout should have been cancelled" - ) - - # Wait for static defer tests - try: - await asyncio.wait_for(static_defer_1_fired.wait(), timeout=0.5) - except TimeoutError: - pytest.fail("Static defer 1 did not fire within 0.5 seconds") - - try: - await asyncio.wait_for(static_defer_2_fired.wait(), timeout=0.5) - except TimeoutError: - pytest.fail("Static defer 2 did not fire within 0.5 seconds") - - # Wait for dynamic string tests - try: - await asyncio.wait_for(dynamic_timeout_fired.wait(), timeout=1.0) - except TimeoutError: - pytest.fail("Dynamic timeout did not fire within 1 second") - - try: - await asyncio.wait_for(dynamic_interval_fired.wait(), timeout=1.5) - except TimeoutError: - pytest.fail("Dynamic interval did not fire within 1.5 seconds") - - # Wait for dynamic defer test - try: - await asyncio.wait_for(dynamic_defer_fired.wait(), timeout=1.0) - except TimeoutError: - pytest.fail("Dynamic defer did not fire within 1 second") - - # Wait for cancel test - try: - await asyncio.wait_for(cancel_test_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail("Cancel test did not complete within 1 second") - - # Wait for final results - try: - await asyncio.wait_for(final_results_logged.wait(), timeout=4.0) - except TimeoutError: - pytest.fail("Final results were not logged within 4 seconds") - - # Verify results - assert timeout_count >= 6, ( - f"Expected at least 6 timeouts (including defers), got {timeout_count}" - ) - assert interval_count >= 3, ( - f"Expected at least 3 interval fires, got {interval_count}" - ) - - # Empty string timeout DOES fire (scheduler accepts empty names) - assert empty_string_timeout_fired.is_set(), "Empty string timeout should fire" From d6aa5fa29e581942ee79f54f88d5ef4f05363e34 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 10:25:57 -0500 Subject: [PATCH 1734/2214] [core] Remove deprecated get_object_id() and get_compilation_time() --- esphome/core/application.h | 9 --------- esphome/core/entity_base.cpp | 7 ------- esphome/core/entity_base.h | 12 ------------ esphome/core/entity_helpers.py | 2 +- 4 files changed, 1 insertion(+), 29 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 7c12a66b2c..76af514511 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -194,15 +194,6 @@ class Application { /// Buffer must be BUILD_TIME_STR_SIZE bytes (compile-time enforced) void get_build_time_string(std::span buffer); - /// Get the build time as a string (deprecated, use get_build_time_string() instead) - // Remove before 2026.7.0 - ESPDEPRECATED("Use get_build_time_string() instead. Removed in 2026.7.0", "2026.1.0") - std::string get_compilation_time() { - char buf[BUILD_TIME_STR_SIZE]; - this->get_build_time_string(buf); - return std::string(buf); - } - /// Get the cached time in milliseconds from when the current component started its loop execution inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index a47af1dd93..32135860bb 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -147,13 +147,6 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Entity Object ID - computed on-demand from name -std::string EntityBase::get_object_id() const { - char buf[OBJECT_ID_MAX_LEN]; - size_t len = this->write_object_id_to(buf, sizeof(buf)); - return std::string(buf, len); -} - // Calculate Object ID Hash directly from name using snake_case + sanitize void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 2726a92c97..4f708209d4 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,18 +73,6 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the sanitized name of this Entity as an ID. - // Deprecated: object_id mangles names and all object_id methods are planned for removal. - // See https://github.com/esphome/backlog/issues/76 - // Now is the time to stop using object_id entirely. If you still need it temporarily, - // use get_object_id_to() which will remain available longer but will also eventually be removed. - ESPDEPRECATED("object_id mangles names and all object_id methods are planned for removal " - "(see https://github.com/esphome/backlog/issues/76). " - "Now is the time to stop using object_id. If still needed, use get_object_id_to() " - "which will remain available longer. get_object_id() will be removed in 2026.7.0", - "2025.12.0") - std::string get_object_id() const; - // Get the unique Object ID of this Entity uint32_t get_object_id_hash() const { return this->object_id_hash_; } diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index ff60260280..38c7f3ca43 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -337,7 +337,7 @@ def get_base_entity_object_id( This function calculates what object_id_c_str_ should be set to in C++. - The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: - If !has_own_name && is_name_add_mac_suffix_enabled(): return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic - Else: From ff56d66cedf78d13c8c78806794d689cdbd59b1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 10:29:04 -0500 Subject: [PATCH 1735/2214] [core] Keep scheduler string_test, migrate it to the const char* API --- .../fixtures/scheduler_string_test.yaml | 304 ++++++++++++++++++ .../integration/test_scheduler_string_test.py | 202 ++++++++++++ 2 files changed, 506 insertions(+) create mode 100644 tests/integration/fixtures/scheduler_string_test.yaml create mode 100644 tests/integration/test_scheduler_string_test.py diff --git a/tests/integration/fixtures/scheduler_string_test.yaml b/tests/integration/fixtures/scheduler_string_test.yaml new file mode 100644 index 0000000000..3e148ec202 --- /dev/null +++ b/tests/integration/fixtures/scheduler_string_test.yaml @@ -0,0 +1,304 @@ +esphome: + name: scheduler-string-test + on_boot: + priority: -100 + then: + - logger.log: "Starting scheduler string tests" + debug_scheduler: true # Enable scheduler debug logging + +host: +api: +logger: + level: VERBOSE + +globals: + - id: timeout_counter + type: int + initial_value: '0' + - id: interval_counter + type: int + initial_value: '0' + - id: static_tests_done + type: bool + initial_value: 'false' + - id: dynamic_tests_done + type: bool + initial_value: 'false' + - id: results_reported + type: bool + initial_value: 'false' + - id: edge_tests_done + type: bool + initial_value: 'false' + - id: empty_cancel_failed + type: bool + initial_value: 'false' + +script: + - id: test_static_strings + then: + - logger.log: "Testing static string timeouts and intervals" + - lambda: |- + auto *component1 = id(test_sensor1); + // Test 1: Static string literals with set_timeout + App.scheduler.set_timeout(component1, "static_timeout_1", 50, []() { + ESP_LOGI("test", "Static timeout 1 fired"); + id(timeout_counter) += 1; + }); + + // Test 2: Static const char* with set_timeout + static const char* TIMEOUT_NAME = "static_timeout_2"; + App.scheduler.set_timeout(component1, TIMEOUT_NAME, 100, []() { + ESP_LOGI("test", "Static timeout 2 fired"); + id(timeout_counter) += 1; + }); + + // Test 3: Static string literal with set_interval + App.scheduler.set_interval(component1, "static_interval_1", 200, []() { + ESP_LOGI("test", "Static interval 1 fired, count: %d", id(interval_counter)); + id(interval_counter) += 1; + if (id(interval_counter) >= 3) { + App.scheduler.cancel_interval(id(test_sensor1), "static_interval_1"); + ESP_LOGI("test", "Cancelled static interval 1"); + } + }); + + // Test 4: Empty string (should be handled safely) + App.scheduler.set_timeout(component1, "", 150, []() { + ESP_LOGI("test", "Empty string timeout fired"); + }); + + // Test 5: Cancel timeout with const char* literal + App.scheduler.set_timeout(component1, "cancel_static_timeout", 5000, []() { + ESP_LOGI("test", "This static timeout should be cancelled"); + }); + // Cancel using const char* directly + App.scheduler.cancel_timeout(component1, "cancel_static_timeout"); + ESP_LOGI("test", "Cancelled static timeout using const char*"); + + // Test 6 & 7: Test defer with const char* overload using a test component + class TestDeferComponent : public Component { + public: + void test_static_defer() { + // Test 6: Static string literal with defer (const char* overload) + this->defer("static_defer_1", []() { + ESP_LOGI("test", "Static defer 1 fired"); + id(timeout_counter) += 1; + }); + + // Test 7: Static const char* with defer + static const char* DEFER_NAME = "static_defer_2"; + this->defer(DEFER_NAME, []() { + ESP_LOGI("test", "Static defer 2 fired"); + id(timeout_counter) += 1; + }); + } + }; + + static TestDeferComponent test_defer_component; + test_defer_component.test_static_defer(); + + - id: test_dynamic_strings + then: + - logger.log: "Testing const char* timeouts and intervals" + - lambda: |- + auto *component2 = id(test_sensor2); + + // Test 8: const char* name with set_timeout + App.scheduler.set_timeout(component2, "dynamic_timeout", 100, []() { + ESP_LOGI("test", "Dynamic timeout fired"); + id(timeout_counter) += 1; + }); + + // Test 9: const char* name with set_interval, cancelled from inside the callback + App.scheduler.set_interval(component2, "dynamic_interval", 250, []() { + ESP_LOGI("test", "Dynamic interval fired"); + id(interval_counter) += 1; + if (id(interval_counter) >= 6) { + App.scheduler.cancel_interval(id(test_sensor2), "dynamic_interval"); + ESP_LOGI("test", "Cancelled dynamic interval"); + } + }); + + // Test 10: Cancel with a different pointer but identical content. + // STATIC_STRING names match by content, so a distinct static buffer with the + // same characters still cancels the scheduled timeout. + static const char CANCEL_NAME[] = "cancel_test"; + App.scheduler.set_timeout(component2, CANCEL_NAME, 2000, []() { + ESP_LOGI("test", "This should be cancelled"); + }); + static const char CANCEL_NAME_2[] = "cancel_test"; + App.scheduler.cancel_timeout(component2, CANCEL_NAME_2); + ESP_LOGI("test", "Cancelled timeout using different string object"); + + // Test 11: const char* name with defer + class TestDynamicDeferComponent : public Component { + public: + void test_dynamic_defer() { + this->defer("dynamic_defer", []() { + ESP_LOGI("test", "Dynamic defer fired"); + id(timeout_counter) += 1; + }); + } + }; + + static TestDynamicDeferComponent test_dynamic_defer_component; + test_dynamic_defer_component.test_dynamic_defer(); + + - id: test_cancellation_edge_cases + then: + - logger.log: "Testing cancellation edge cases" + - lambda: |- + auto *component1 = id(test_sensor1); + // Use a different component for empty string tests to avoid interference + auto *component2 = id(test_sensor2); + + // Test 12: Cancel with empty string - regression test for issue #9599 + // First create a timeout with empty name on component2 to avoid interference + App.scheduler.set_timeout(component2, "", 500, []() { + ESP_LOGE("test", "ERROR: Empty name timeout fired - it should have been cancelled!"); + id(empty_cancel_failed) = true; + }); + + // Now cancel it - this should work after our fix + bool cancelled_empty = App.scheduler.cancel_timeout(component2, ""); + ESP_LOGI("test", "Cancel empty string result: %s (should be true)", cancelled_empty ? "true" : "false"); + if (!cancelled_empty) { + ESP_LOGE("test", "ERROR: Failed to cancel empty string timeout!"); + id(empty_cancel_failed) = true; + } + + // Test 13: Cancel non-existent timeout + bool cancelled_nonexistent = App.scheduler.cancel_timeout(component1, "does_not_exist"); + ESP_LOGI("test", "Cancel non-existent timeout result: %s", + cancelled_nonexistent ? "true (unexpected!)" : "false (expected)"); + + // Test 14: Multiple timeouts with same name - only last should execute + for (int i = 0; i < 5; i++) { + App.scheduler.set_timeout(component1, "duplicate_timeout", 200 + i*10, [i]() { + ESP_LOGI("test", "Duplicate timeout %d fired", i); + id(timeout_counter) += 1; + }); + } + ESP_LOGI("test", "Created 5 timeouts with same name 'duplicate_timeout'"); + + // Test 15: Multiple intervals with same name - only last should run + for (int i = 0; i < 3; i++) { + App.scheduler.set_interval(component1, "duplicate_interval", 300, [i]() { + ESP_LOGI("test", "Duplicate interval %d fired", i); + id(interval_counter) += 10; // Large increment to detect multiple + // Cancel after first execution + App.scheduler.cancel_interval(id(test_sensor1), "duplicate_interval"); + }); + } + ESP_LOGI("test", "Created 3 intervals with same name 'duplicate_interval'"); + + // Test 16: Cancel with nullptr protection (via empty const char*) + const char* null_name = ""; + App.scheduler.set_timeout(component2, null_name, 600, []() { + ESP_LOGE("test", "ERROR: Const char* empty timeout fired - should have been cancelled!"); + id(empty_cancel_failed) = true; + }); + bool cancelled_const_empty = App.scheduler.cancel_timeout(component2, null_name); + ESP_LOGI("test", "Cancel const char* empty result: %s (should be true)", + cancelled_const_empty ? "true" : "false"); + if (!cancelled_const_empty) { + ESP_LOGE("test", "ERROR: Failed to cancel const char* empty timeout!"); + id(empty_cancel_failed) = true; + } + + // Test 17: Rapid create/cancel/create with same name + App.scheduler.set_timeout(component1, "rapid_test", 5000, []() { + ESP_LOGI("test", "First rapid timeout - should not fire"); + id(timeout_counter) += 100; + }); + App.scheduler.cancel_timeout(component1, "rapid_test"); + App.scheduler.set_timeout(component1, "rapid_test", 250, []() { + ESP_LOGI("test", "Second rapid timeout - should fire"); + id(timeout_counter) += 1; + }); + + // Test 18: Cancel all with a specific name (multiple instances) + // Create multiple with same name + App.scheduler.set_timeout(component1, "multi_cancel", 300, []() { + ESP_LOGI("test", "Multi-cancel timeout 1"); + }); + App.scheduler.set_timeout(component1, "multi_cancel", 350, []() { + ESP_LOGI("test", "Multi-cancel timeout 2"); + }); + App.scheduler.set_timeout(component1, "multi_cancel", 400, []() { + ESP_LOGI("test", "Multi-cancel timeout 3 - only this should fire"); + id(timeout_counter) += 1; + }); + // Note: Each set_timeout with same name cancels the previous one automatically + + - id: report_results + then: + - lambda: |- + ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d", + id(timeout_counter), id(interval_counter)); + + // Check if empty string cancellation test passed + if (id(empty_cancel_failed)) { + ESP_LOGE("test", "ERROR: Empty string cancellation test FAILED!"); + } else { + ESP_LOGI("test", "Empty string cancellation test PASSED"); + } + +sensor: + - platform: template + name: Test Sensor 1 + id: test_sensor1 + lambda: return 1.0; + update_interval: never + + - platform: template + name: Test Sensor 2 + id: test_sensor2 + lambda: return 2.0; + update_interval: never + +interval: + # Run static string tests after boot - using script to run once + - interval: 0.1s + then: + - if: + condition: + lambda: 'return id(static_tests_done) == false;' + then: + - lambda: 'id(static_tests_done) = true;' + - script.execute: test_static_strings + - logger.log: "Started static string tests" + + # Run dynamic string tests after static tests + - interval: 0.2s + then: + - if: + condition: + lambda: 'return id(static_tests_done) && !id(dynamic_tests_done);' + then: + - lambda: 'id(dynamic_tests_done) = true;' + - delay: 0.2s + - script.execute: test_dynamic_strings + + # Run cancellation edge case tests after dynamic tests + - interval: 0.2s + then: + - if: + condition: + lambda: 'return id(dynamic_tests_done) && !id(edge_tests_done);' + then: + - lambda: 'id(edge_tests_done) = true;' + - delay: 0.5s + - script.execute: test_cancellation_edge_cases + + # Report results after all tests + - interval: 0.2s + then: + - if: + condition: + lambda: 'return id(edge_tests_done) && !id(results_reported);' + then: + - lambda: 'id(results_reported) = true;' + - delay: 1s + - script.execute: report_results diff --git a/tests/integration/test_scheduler_string_test.py b/tests/integration/test_scheduler_string_test.py new file mode 100644 index 0000000000..783ed37c13 --- /dev/null +++ b/tests/integration/test_scheduler_string_test.py @@ -0,0 +1,202 @@ +"""Test scheduler string optimization with static and dynamic strings.""" + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_string_test( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that scheduler handles both static and dynamic strings correctly.""" + # Track counts + timeout_count = 0 + interval_count = 0 + + # Events for each test completion + static_timeout_1_fired = asyncio.Event() + static_timeout_2_fired = asyncio.Event() + static_interval_fired = asyncio.Event() + static_interval_cancelled = asyncio.Event() + empty_string_timeout_fired = asyncio.Event() + static_timeout_cancelled = asyncio.Event() + static_defer_1_fired = asyncio.Event() + static_defer_2_fired = asyncio.Event() + dynamic_timeout_fired = asyncio.Event() + dynamic_interval_fired = asyncio.Event() + dynamic_defer_fired = asyncio.Event() + cancel_test_done = asyncio.Event() + final_results_logged = asyncio.Event() + + # Track interval counts + static_interval_count = 0 + dynamic_interval_count = 0 + + def on_log_line(line: str) -> None: + nonlocal \ + timeout_count, \ + interval_count, \ + static_interval_count, \ + dynamic_interval_count + + # Strip ANSI color codes + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + + # Check for static timeout completions + if "Static timeout 1 fired" in clean_line: + static_timeout_1_fired.set() + timeout_count += 1 + + elif "Static timeout 2 fired" in clean_line: + static_timeout_2_fired.set() + timeout_count += 1 + + # Check for static interval + elif "Static interval 1 fired" in clean_line: + match = re.search(r"count: (\d+)", clean_line) + if match: + static_interval_count = int(match.group(1)) + static_interval_fired.set() + + elif "Cancelled static interval 1" in clean_line: + static_interval_cancelled.set() + + # Check for empty string timeout + elif "Empty string timeout fired" in clean_line: + empty_string_timeout_fired.set() + + # Check for static timeout cancellation + elif "Cancelled static timeout using const char*" in clean_line: + static_timeout_cancelled.set() + + # Check for static defer tests + elif "Static defer 1 fired" in clean_line: + static_defer_1_fired.set() + timeout_count += 1 + + elif "Static defer 2 fired" in clean_line: + static_defer_2_fired.set() + timeout_count += 1 + + # Check for dynamic string tests + elif "Dynamic timeout fired" in clean_line: + dynamic_timeout_fired.set() + timeout_count += 1 + + elif "Dynamic interval fired" in clean_line: + dynamic_interval_count += 1 + dynamic_interval_fired.set() + + # Check for dynamic defer test + elif "Dynamic defer fired" in clean_line: + dynamic_defer_fired.set() + timeout_count += 1 + + # Check for cancel test + elif "Cancelled timeout using different string object" in clean_line: + cancel_test_done.set() + + # Check for final results + elif "Final results" in clean_line: + match = re.search(r"Timeouts: (\d+), Intervals: (\d+)", clean_line) + if match: + timeout_count = int(match.group(1)) + interval_count = int(match.group(2)) + final_results_logged.set() + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + # Verify we can connect + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-string-test" + + # Wait for static string tests + try: + await asyncio.wait_for(static_timeout_1_fired.wait(), timeout=0.5) + except TimeoutError: + pytest.fail("Static timeout 1 did not fire within 0.5 seconds") + + try: + await asyncio.wait_for(static_timeout_2_fired.wait(), timeout=0.5) + except TimeoutError: + pytest.fail("Static timeout 2 did not fire within 0.5 seconds") + + try: + await asyncio.wait_for(static_interval_fired.wait(), timeout=1.0) + except TimeoutError: + pytest.fail("Static interval did not fire within 1 second") + + try: + await asyncio.wait_for(static_interval_cancelled.wait(), timeout=2.0) + except TimeoutError: + pytest.fail("Static interval was not cancelled within 2 seconds") + + # Verify static interval ran at least 3 times + assert static_interval_count >= 2, ( + f"Expected static interval to run at least 3 times, got {static_interval_count + 1}" + ) + + # Verify static timeout was cancelled + assert static_timeout_cancelled.is_set(), ( + "Static timeout should have been cancelled" + ) + + # Wait for static defer tests + try: + await asyncio.wait_for(static_defer_1_fired.wait(), timeout=0.5) + except TimeoutError: + pytest.fail("Static defer 1 did not fire within 0.5 seconds") + + try: + await asyncio.wait_for(static_defer_2_fired.wait(), timeout=0.5) + except TimeoutError: + pytest.fail("Static defer 2 did not fire within 0.5 seconds") + + # Wait for dynamic string tests + try: + await asyncio.wait_for(dynamic_timeout_fired.wait(), timeout=1.0) + except TimeoutError: + pytest.fail("Dynamic timeout did not fire within 1 second") + + try: + await asyncio.wait_for(dynamic_interval_fired.wait(), timeout=1.5) + except TimeoutError: + pytest.fail("Dynamic interval did not fire within 1.5 seconds") + + # Wait for dynamic defer test + try: + await asyncio.wait_for(dynamic_defer_fired.wait(), timeout=1.0) + except TimeoutError: + pytest.fail("Dynamic defer did not fire within 1 second") + + # Wait for cancel test + try: + await asyncio.wait_for(cancel_test_done.wait(), timeout=1.0) + except TimeoutError: + pytest.fail("Cancel test did not complete within 1 second") + + # Wait for final results + try: + await asyncio.wait_for(final_results_logged.wait(), timeout=4.0) + except TimeoutError: + pytest.fail("Final results were not logged within 4 seconds") + + # Verify results + assert timeout_count >= 6, ( + f"Expected at least 6 timeouts (including defers), got {timeout_count}" + ) + assert interval_count >= 3, ( + f"Expected at least 3 interval fires, got {interval_count}" + ) + + # Empty string timeout DOES fire (scheduler accepts empty names) + assert empty_string_timeout_fired.is_set(), "Empty string timeout should fire" From 39b6eafe8edbf7e515ffa2016947ee9fb14c6eca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 10:31:01 -0500 Subject: [PATCH 1736/2214] [web_server] Remove deprecated object ID URL matching --- esphome/components/web_server/web_server.cpp | 29 +------------------- esphome/components/web_server/web_server.h | 2 +- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 909a27c81c..cdb8544fbb 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -164,36 +164,9 @@ EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const { } #endif - // Try matching by entity name (new format) + // Match by entity name if (this->id == entity->get_name()) { result.matched = true; - return result; - } - - // Fall back to object_id (deprecated format) - char object_id_buf[OBJECT_ID_MAX_LEN]; - StringRef object_id = entity->get_object_id_to(object_id_buf); - if (this->id == object_id) { - result.matched = true; - // Log deprecation warning -#ifdef USE_DEVICES - Device *device = entity->get_device(); - if (device != nullptr) { - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s/%.*s - use entity name '/%.*s/%s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - (int) this->domain.size(), this->domain.c_str(), (int) this->device_name.size(), - this->device_name.c_str(), (int) this->id.size(), this->id.c_str(), (int) this->domain.size(), - this->domain.c_str(), device->get_name(), entity->get_name().c_str()); - } else -#endif - { - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - (int) this->domain.size(), this->domain.c_str(), (int) this->id.size(), this->id.c_str(), - (int) this->domain.size(), this->domain.c_str(), entity->get_name().c_str()); - } } return result; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 25f8f8212d..e4defdbd9a 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -76,7 +76,7 @@ struct UrlMatch { bool method_equals(const __FlashStringHelper *str) const { return this->method == str; } #endif - /// Match entity by name first, then fall back to object_id with deprecation warning + /// Match entity by name /// Returns EntityMatchResult with match status and whether action segment is empty EntityMatchResult match_entity(EntityBase *entity) const; }; From dea76e9236ac2b5843540ce9dd46eec56811caed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 10:32:09 -0500 Subject: [PATCH 1737/2214] [core] Tidy scheduler doc comments after std::string overload removal --- esphome/core/component.h | 6 +++--- esphome/core/scheduler.h | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index caad1ff41e..a0945e53aa 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -357,9 +357,9 @@ class Component { /// so once a flag is set, subsequent (potentially different) messages may be suppressed. bool set_status_flag_(uint8_t flag); - /** Set an interval function with a unique name. Empty name means no cancelling possible. + /** Set an interval function with a const char* name. Empty name means no cancelling possible. * - * This will call f every interval ms. Can be cancelled via CancelInterval(). + * This will call f every interval ms. Can be cancelled via cancel_interval(). * Similar to javascript's setInterval(). * * IMPORTANT NOTE: @@ -443,7 +443,7 @@ class Component { ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(uint32_t id); // NOLINT - /** Set a timeout function with a unique name. + /** Set a timeout function with a const char* name. * * Similar to javascript's setTimeout(). Empty name means no cancelling possible. * diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 9aecc3e8c8..c7743e5b2a 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -384,8 +384,8 @@ class Scheduler { inline bool HOT names_match_static_(const char *name1, const char *name2) const { // Check pointer equality first (common for static strings), then string contents // The core ESPHome codebase uses static strings (const char*) for component names, - // making pointer comparison effective. The std::string overloads exist only for - // compatibility with external components but are rarely used in practice. + // making pointer comparison effective. The strcmp fallback covers distinct pointers + // with identical content (e.g. names built into separate static buffers). return (name1 != nullptr && name2 != nullptr) && ((name1 == name2) || (strcmp(name1, name2) == 0)); } From a97f9e7cda6568e2fbd63eba3739dfd906e652ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 10:07:05 -0500 Subject: [PATCH 1738/2214] [core] Add esphome logs over web_server HTTP SSE Stream device logs over the web_server /events Server-Sent Events feed so 'esphome logs' works on devices that have web_server: but no api:. This is the logging counterpart to web_server OTA. Priority stays API, then MQTT, then web_server. Reconnects automatically when the stream drops. Factor the resolve-to-URLs step and the web_server port/auth lookup shared with web_server OTA into a new web_server_helpers module (resolve_web_server_urls and get_web_server_connection), with helpers.format_ip_url for IPv4/IPv6 URL formatting, and broaden the missing-transport log error to suggest web_server: alongside api:/MQTT/USB. --- 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 680de02201..416c1160b7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -28,13 +28,13 @@ from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, CONF_API, - CONF_AUTH, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, CONF_DISABLED, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -48,7 +48,7 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, @@ -280,8 +280,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 ( @@ -321,9 +321,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)) @@ -335,7 +338,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: @@ -397,7 +404,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(): @@ -490,6 +497,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.""" from esphome.components.mqtt import CONF_DISCOVER_IP @@ -1290,25 +1312,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 ef7e2d0b93..d5be1d607b 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -328,6 +328,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 70c4b90082..84ec479e67 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( @@ -151,6 +151,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 bb06b6c930..784255a9c0 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -49,6 +49,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, @@ -72,6 +73,7 @@ from esphome.const import ( CONF_DISABLED, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -86,6 +88,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, @@ -734,6 +737,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( @@ -773,6 +800,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( @@ -2376,6 +2414,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, @@ -2945,6 +3007,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 1d8b38b6b43bfead1e6fd9a868dbd124c65da482 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 10:52:46 -0500 Subject: [PATCH 1739/2214] [core] Migrate scheduler_pool fixture off std::string timer names --- tests/integration/fixtures/scheduler_pool.yaml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index 989c1535b0..f3e8a0a396 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -157,8 +157,7 @@ script: // Simulate a burst of defer operations like ratgdo does with state updates // These should execute immediately and recycle quickly to the pool for (int i = 0; i < 10; i++) { - std::string defer_name = "defer_" + std::to_string(i); - App.scheduler.set_timeout(component, defer_name, 0, [i]() { + App.scheduler.set_timeout(component, static_cast(i), 0, [i]() { ESP_LOGD("test", "Defer %d executed", i); // Force a small delay between defer executions to see recycling if (i == 5) { @@ -208,8 +207,7 @@ script: int reuse_test_count = 8; for (int i = 0; i < reuse_test_count; i++) { - std::string name = "reuse_test_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(i), 10 + i * 5, [i]() { ESP_LOGD("test", "Reuse test %d completed", i); }); } @@ -230,8 +228,7 @@ script: int full_reuse_count = 10; for (int i = 0; i < full_reuse_count; i++) { - std::string name = "full_reuse_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(i), 10 + i * 5, [i]() { ESP_LOGD("test", "Full reuse test %d completed", i); }); } From 404f098419532a8c63cd8b7a87d07c64f1e1656c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 10:53:14 -0500 Subject: [PATCH 1740/2214] [core] Update stale get_object_id() docstring reference in test --- tests/unit_tests/core/test_entity_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index e79ff850f9..0e4c0fc2cb 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -175,7 +175,7 @@ def test_name_add_mac_suffix_behavior() -> None: """Test behavior related to name_add_mac_suffix. In C++, when name_add_mac_suffix is enabled and entity has no name, - get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name())) + write_object_id_to() returns str_sanitize(str_snake_case(App.get_friendly_name())) dynamically. Our function always returns the same result since we're calculating the base for duplicate tracking. """ From 07624a2907dc0520e200c8e4abb54e7999b82ef3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 11:02:08 -0500 Subject: [PATCH 1741/2214] [core] Clarify object_id docstring to match C++ behavior --- tests/unit_tests/core/test_entity_helpers.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 0e4c0fc2cb..3ac4ce27af 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -174,10 +174,11 @@ def test_empty_name_fallback() -> None: def test_name_add_mac_suffix_behavior() -> None: """Test behavior related to name_add_mac_suffix. - In C++, when name_add_mac_suffix is enabled and entity has no name, - write_object_id_to() returns str_sanitize(str_snake_case(App.get_friendly_name())) - dynamically. Our function always returns the same result since we're - calculating the base for duplicate tracking. + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. """ # The function should always return the same result regardless of # name_add_mac_suffix setting, as we're calculating the base object_id From 7ef6b486d2988099c39983813065dd194ee9515b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 11:03:46 -0500 Subject: [PATCH 1742/2214] [core] Address review: per-phase scheduler ids, log wording, doc typo --- esphome/core/component.h | 2 +- tests/integration/fixtures/scheduler_pool.yaml | 7 +++++-- tests/integration/fixtures/scheduler_string_test.yaml | 2 +- tests/integration/test_scheduler_string_test.py | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index a0945e53aa..1ae70371a1 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -448,7 +448,7 @@ class Component { * Similar to javascript's setTimeout(). Empty name means no cancelling possible. * * IMPORTANT: Do not rely on this having correct timing. This is only called from - * loop() and therefore can be significantly delay. If you need exact timing please + * loop() and therefore can be significantly delayed. If you need exact timing please * use hardware timers. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index f3e8a0a396..a75d9dbcbc 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -156,6 +156,7 @@ script: // Simulate a burst of defer operations like ratgdo does with state updates // These should execute immediately and recycle quickly to the pool + // Phase-specific id range (0..9) so ids never collide with later phases for (int i = 0; i < 10; i++) { App.scheduler.set_timeout(component, static_cast(i), 0, [i]() { ESP_LOGD("test", "Defer %d executed", i); @@ -206,8 +207,9 @@ script: // Now create 8 new timeouts - they should reuse from pool when available int reuse_test_count = 8; + // Phase-specific id range (100..107) so ids never collide with other phases for (int i = 0; i < reuse_test_count; i++) { - App.scheduler.set_timeout(component, static_cast(i), 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(100 + i), 10 + i * 5, [i]() { ESP_LOGD("test", "Reuse test %d completed", i); }); } @@ -227,8 +229,9 @@ script: auto *component = id(test_sensor); int full_reuse_count = 10; + // Phase-specific id range (200..209) so ids never collide with other phases for (int i = 0; i < full_reuse_count; i++) { - App.scheduler.set_timeout(component, static_cast(i), 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(200 + i), 10 + i * 5, [i]() { ESP_LOGD("test", "Full reuse test %d completed", i); }); } diff --git a/tests/integration/fixtures/scheduler_string_test.yaml b/tests/integration/fixtures/scheduler_string_test.yaml index 3e148ec202..06e3a4c97c 100644 --- a/tests/integration/fixtures/scheduler_string_test.yaml +++ b/tests/integration/fixtures/scheduler_string_test.yaml @@ -129,7 +129,7 @@ script: }); static const char CANCEL_NAME_2[] = "cancel_test"; App.scheduler.cancel_timeout(component2, CANCEL_NAME_2); - ESP_LOGI("test", "Cancelled timeout using different string object"); + ESP_LOGI("test", "Cancelled timeout using different buffer with same content"); // Test 11: const char* name with defer class TestDynamicDeferComponent : public Component { diff --git a/tests/integration/test_scheduler_string_test.py b/tests/integration/test_scheduler_string_test.py index 783ed37c13..3bc3487432 100644 --- a/tests/integration/test_scheduler_string_test.py +++ b/tests/integration/test_scheduler_string_test.py @@ -99,7 +99,7 @@ async def test_scheduler_string_test( timeout_count += 1 # Check for cancel test - elif "Cancelled timeout using different string object" in clean_line: + elif "Cancelled timeout using different buffer with same content" in clean_line: cancel_test_done.set() # Check for final results From 1588a8cef9f273eda3ace0f759803958c86669d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 11:12:32 -0500 Subject: [PATCH 1743/2214] [ethernet] Defer clk_mode removal to 2026.9.0 --- 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 f6afc30ff2..6af68e4e3c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -324,7 +324,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.7.0.", + "Removal scheduled for 2026.9.0.", config[CONF_CLK_MODE], mode, pin, From ac17356039cf0023158d79f6d3d9f645f8e4b729 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 11:19:40 -0500 Subject: [PATCH 1744/2214] [core] Remove deprecated std::string GPIOPin::dump_summary() --- esphome/core/gpio.h | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/esphome/core/gpio.h b/esphome/core/gpio.h index f2f85e18bc..43db3b7c0c 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -1,8 +1,6 @@ #pragma once #include #include -#include -#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -80,11 +78,6 @@ class GPIOPin { /// which may exceed len-1 if truncation occurred (snprintf semantics) virtual size_t dump_summary(char *buffer, size_t len) const; - /// Get a summary of this pin as a string. - /// @deprecated Use dump_summary(char*, size_t) instead. Will be removed in 2026.7.0. - ESPDEPRECATED("Override dump_summary(char*, size_t) instead. Will be removed in 2026.7.0.", "2026.1.0") - virtual std::string dump_summary() const; - virtual bool is_internal() { return false; } }; @@ -122,28 +115,14 @@ class InternalGPIOPin : public GPIOPin { virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0; }; -// Inline default implementations for GPIOPin virtual methods. -// These provide bridge functionality for backwards compatibility with external components. - -// Default implementation bridges to old std::string method for backwards compatibility. +// Inline default implementation for GPIOPin::dump_summary. +// Writes an empty summary; subclasses override to provide pin details. inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { - if (len == 0) - return 0; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - std::string s = this->dump_summary(); -#pragma GCC diagnostic pop - size_t copy_len = std::min(s.size(), len - 1); - memcpy(buffer, s.c_str(), copy_len); - buffer[copy_len] = '\0'; - return s.size(); // Return would-be length (snprintf semantics) + if (len > 0) + buffer[0] = '\0'; + return 0; } -// Default implementation returns empty string. -// External components should override this if they haven't migrated to buffer-based version. -// Remove before 2026.7.0 -inline std::string GPIOPin::dump_summary() const { return {}; } - // Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { char buffer[GPIO_SUMMARY_MAX_LEN]; From 63d8a344c564d3ba67b802ee913c546d3de8214a Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 21 Jun 2026 11:32:35 -0700 Subject: [PATCH 1745/2214] [modbus] Fix parsing & split out server mode (#11969) --- esphome/components/modbus/__init__.py | 86 ++- esphome/components/modbus/modbus.cpp | 729 +++++++++++------- esphome/components/modbus/modbus.h | 244 ++++-- .../components/modbus/modbus_definitions.h | 26 +- esphome/components/modbus/modbus_helpers.cpp | 177 ++++- esphome/components/modbus/modbus_helpers.h | 98 ++- .../modbus_controller/modbus_controller.cpp | 2 +- .../modbus_controller/modbus_controller.h | 2 +- esphome/components/modbus_server/__init__.py | 9 +- .../modbus_server/modbus_server.cpp | 37 +- .../components/modbus_server/modbus_server.h | 24 +- .../components/modbus/modbus_helpers_test.cpp | 175 +++++ tests/components/modbus/modbus_test.cpp | 59 -- .../fixtures/uart_mock_modbus.yaml | 16 +- .../uart_mock_modbus_no_threshold.yaml | 11 +- .../uart_mock_modbus_server_controller.yaml | 6 +- ...ock_modbus_server_controller_multiple.yaml | 5 +- ...t_mock_modbus_server_controller_write.yaml | 4 +- .../fixtures/uart_mock_modbus_timing.yaml | 11 +- tests/integration/test_uart_mock_modbus.py | 22 +- 20 files changed, 1211 insertions(+), 532 deletions(-) delete mode 100644 tests/components/modbus/modbus_test.cpp diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index f6e0f98857..492dfcaafe 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -14,7 +14,11 @@ DEPENDENCIES = ["uart"] modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) +ModbusServer = modbus_ns.class_("ModbusServerHub", Modbus) +ModbusClient = modbus_ns.class_("ModbusClientHub", Modbus) ModbusDevice = modbus_ns.class_("ModbusDevice") +ModbusClientDevice = modbus_ns.class_("ModbusClientDevice") +ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") MULTI_CONF = True CONF_ROLE = "role" @@ -22,29 +26,43 @@ CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" CONF_TURNAROUND_TIME = "turnaround_time" -ModbusRole = modbus_ns.enum("ModbusRole") -MODBUS_ROLES = { - "client": ModbusRole.CLIENT, - "server": ModbusRole.SERVER, -} +MODBUS_ROLES = ["client", "server"] -CONFIG_SCHEMA = ( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(Modbus), - cv.Optional(CONF_ROLE, default="client"): cv.enum(MODBUS_ROLES), - cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - cv.Optional( - CONF_SEND_WAIT_TIME, default="250ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_TURNAROUND_TIME, default="100ms" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean, - } - ) - .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.typed_schema( + { + "client": cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusClient), + cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, + cv.Optional( + CONF_SEND_WAIT_TIME, default="2000ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_TURNAROUND_TIME, default="600ms" + ): cv.positive_time_period_milliseconds, + # Remove before 2026.10.0 + cv.Optional(CONF_DISABLE_CRC): cv.invalid( + "'disable_crc' has been removed. The parser no longer requires it — remove this option." + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + "server": cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusServer), + cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, + # Remove before 2026.10.0 + cv.Optional(CONF_DISABLE_CRC): cv.invalid( + "'disable_crc' has been removed. The parser no longer requires it — remove this option." + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + }, + key=CONF_ROLE, + default_type="client", ) @@ -55,19 +73,19 @@ async def to_code(config): await uart.register_uart_device(var, config) - cg.add(var.set_role(config[CONF_ROLE])) if CONF_FLOW_CONTROL_PIN in config: pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) cg.add(var.set_flow_control_pin(pin)) - cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) - cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) - cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC])) + if config[CONF_ROLE] == "client": + cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) + cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) -def modbus_device_schema(default_address): +def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"): + hub_type = ModbusClient if role == "client" else ModbusServer schema = { - cv.GenerateID(CONF_MODBUS_ID): cv.use_id(Modbus), + cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type), } if default_address is None: schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t @@ -98,8 +116,18 @@ def final_validate_modbus_device( ) -async def register_modbus_device(var, config): +async def register_modbus_client_device(var, config): + parent = await cg.get_variable(config[CONF_MODBUS_ID]) + cg.add(var.set_parent(parent)) + cg.add(var.set_address(config[CONF_ADDRESS])) + + +async def register_modbus_server_device(var, config): parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) + + +async def register_modbus_device(var, config): + return await register_modbus_client_device(var, config) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 679ec34c0f..136fc73db6 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -37,9 +37,36 @@ void Modbus::setup() { } void Modbus::loop() { - // First process all available incoming data. - this->receive_and_parse_modbus_bytes_(); + // Receive any available bytes from UART + this->receive_bytes_(); + // Parse bytes into frames and process them + this->parse_modbus_frames(); +} + +void ModbusClientHub::loop() { + // Call base class to receive bytes and parse frames + this->Modbus::loop(); + + // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response + if (this->waiting_for_response_.has_value()) { + ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); + uint8_t expected_address = wfr.frame.data.get()[0]; + if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && + (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { + ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, + this->last_receive_check_ - this->last_send_); + if (wfr.device) + wfr.device->on_modbus_no_response(); + this->waiting_for_response_.reset(); + } + } + + // If there's no response pending and there's commands in the buffer + this->send_next_frame_(); +} + +bool Modbus::timeout_() { // If the response frame is finished (including interframe delay) - we timeout. // The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts // when the buffer is filling the back half of the response @@ -47,250 +74,307 @@ void Modbus::loop() { (uint16_t) this->frame_delay_ms_, (uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_ : 0)); + + return this->last_receive_check_ - this->last_modbus_byte_ > timeout; +} + +int32_t Modbus::tx_delay_remaining() { // We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps // It's critical in all timestamp comparisons that the left timestamp comes before the right one in time // If we use a cached value in place of millis() and last_modbus_byte_ is updated inside our loop // then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout // So in this component we don't use any cached timestamp values to avoid these annoying bugs - if (millis() - this->last_modbus_byte_ > timeout) { - this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); - } + const uint32_t now = millis(); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ - (now - this->last_modbus_byte_))}); +} - // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response - if (this->waiting_for_response_ != 0 && - millis() - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && - (this->rx_buffer_.empty() || this->rx_buffer_[0] != this->waiting_for_response_)) { - ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", - this->waiting_for_response_, millis() - this->last_send_); - this->waiting_for_response_ = 0; - } - - // If there's no response pending and there's commands in the buffer - this->send_next_frame_(); +int32_t ModbusClientHub::tx_delay_remaining() { + const uint32_t now = millis(); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - + (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); } bool Modbus::tx_blocked() { - const uint32_t now = millis(); - - // We block transmission in any of these case: + // We block transmission in any of these cases: // 1. There are bytes in the UART Rx buffer // 2. There are bytes in our Rx buffer - // 3. We're waiting for a response - // 4. The last sent byte isn't more than frame_delay ms ago (i.e. wait to tell receivers that our previous Tx is done) - // 5. The last received byte isn't more than frame_delay ms ago (i.e. wait to be sure there isn't more Rx coming) - // 6. If we're a client - also wait for the turnaround delay, to give the servers time to process the previous message - return this->available() || !this->rx_buffer_.empty() || (this->waiting_for_response_ != 0) || - (now - this->last_send_ < this->last_send_tx_offset_ + this->frame_delay_ms_ + - (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)) || - (now - this->last_modbus_byte_ < - this->frame_delay_ms_ + (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)); + // 3. The last sent byte isn't more than tx_delay ms ago (i.e. wait to tell receivers that our previous Tx is done) + // 4. The last received byte isn't more than tx_delay ms ago (i.e. wait to be sure there isn't more Rx coming) + // N.B. We allow a small delay (MODBUS_TX_MAX_DELAY_MS) to avoid looping on small delays. This gets handled by + // send_frame_. + return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS; } -bool Modbus::tx_buffer_empty() { return this->tx_buffer_.empty(); } +bool ModbusClientHub::tx_blocked() { + // We block transmission in any of these case: + // 1. We're waiting for a response + // 2. Any of the base class tx_blocked conditions + return (this->waiting_for_response_.has_value()) || this->Modbus::tx_blocked(); +} -void Modbus::receive_and_parse_modbus_bytes_() { - // Read all available bytes in batches to reduce UART call overhead. - size_t avail = this->available(); - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; +bool ModbusClientHub::tx_buffer_empty() { return this->tx_buffer_.empty(); } + +void Modbus::receive_bytes_() { + this->last_receive_check_ = millis(); + size_t bytes = this->available(); + + if (bytes) { + size_t buffer_size = this->rx_buffer_.size(); + this->last_modbus_byte_ = this->last_receive_check_; + this->rx_buffer_.resize(buffer_size + bytes); + if (!this->read_array(this->rx_buffer_.data() + buffer_size, bytes)) { + this->rx_buffer_.resize(buffer_size); + return; } - avail -= to_read; - for (size_t i = 0; i < to_read; i++) { - if (this->rx_buffer_.empty()) { - ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i], - millis() - this->last_send_); - } else { - ESP_LOGVV(TAG, "Received byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i], - millis() - this->last_send_); - } - - // If the bytes in the rx buffer do not parse, clear out the buffer - if (!this->parse_modbus_byte_(buf[i])) { - this->clear_rx_buffer_(LOG_STR("parse failed"), true); - } - this->last_modbus_byte_ = millis(); + if (buffer_size == 0) { + ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "ms after last send", + this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), millis() - this->last_send_); } } } -bool Modbus::parse_modbus_byte_(uint8_t byte) { - size_t at = this->rx_buffer_.size(); - this->rx_buffer_.push_back(byte); - const uint8_t *raw = &this->rx_buffer_[0]; +void ModbusClientHub::parse_modbus_frames() { + if (!this->rx_buffer_.empty()) { + size_t size; + do { + size = this->rx_buffer_.size(); + if (!this->parse_modbus_server_frame_()) + this->clear_rx_buffer_(LOG_STR("parse failed"), true); + } while (!this->rx_buffer_.empty() && size > this->rx_buffer_.size()); + if (this->timeout_()) + this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); + } +} - // Byte 0: modbus address (match all) - if (at == 0) - return true; - // Byte 1: function code - if (at == 1) - return true; - // Byte 2: Size (with modbus rtu function code 4/3) - // See also https://en.wikipedia.org/wiki/Modbus - if (at == 2) - return true; - - uint8_t address = raw[0]; - uint8_t function_code = raw[1]; - - uint8_t data_len = raw[2]; - uint8_t data_offset = 3; - - // Per https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf Ch 5 User-Defined function codes - if (((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT) && - (function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END)) || - ((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT) && - (function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END))) { - // Handle user-defined function, since we don't know how big this ought to be, - // ideally we should delegate the entire length detection to whatever handler is - // installed, but wait, there is the CRC, and if we get a hit there is a good - // chance that this is a complete message ... admittedly there is a small chance is - // isn't but that is quite small given the purpose of the CRC in the first place - - data_len = at - 2; - data_offset = 1; - - uint16_t computed_crc = crc16(raw, data_offset + data_len); - uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8); - - if (computed_crc != remote_crc) - return true; - - ESP_LOGD(TAG, "User-defined function %02X found", function_code); - - } else { - // data starts at 2 and length is 4 for read registers commands - if (this->role == ModbusRole::SERVER) { - if (function_code == ModbusFunctionCode::READ_COILS || - function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS || - function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || - function_code == ModbusFunctionCode::READ_INPUT_REGISTERS || - function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - data_offset = 2; - data_len = 4; - } else if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - if (at < 6) { - return true; - } - data_offset = 2; - // starting address (2 bytes) + quantity of registers (2 bytes) + byte count itself (1 byte) + actual byte count - data_len = 2 + 2 + 1 + raw[6]; +void ModbusServerHub::parse_modbus_frames() { + while (!this->rx_buffer_.empty()) { + size_t size = this->rx_buffer_.size(); + ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); + bool retry_as_client = false; + if (this->expecting_peer_response_ != 0) { + if (!this->parse_modbus_server_frame_()) { + ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse", + this->expecting_peer_response_); + this->expecting_peer_response_ = 0; + retry_as_client = true; + } else if (this->timeout_() && size == this->rx_buffer_.size()) { + // If we timed out and the above parse attempt did not consume data, stop expecting a response + ESP_LOGV(TAG, + "Stop expecting peer response from %" PRIu8 " due to timeout after partial response, and retry parse", + this->expecting_peer_response_); + this->expecting_peer_response_ = 0; + retry_as_client = true; } } else { - // the response for write command mirrors the requests and data starts at offset 2 instead of 3 for read commands - if (function_code == ModbusFunctionCode::WRITE_SINGLE_COIL || - function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - data_offset = 2; - data_len = 4; - } - } - - // Error ( msb indicates error ) - // response format: Byte[0] = device address, Byte[1] function code | 0x80 , Byte[2] exception code, Byte[3-4] crc - if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) { - data_offset = 2; - data_len = 1; - } - - // Byte data_offset..data_offset+data_len-1: Data - if (at < data_offset + data_len) - return true; - - // Byte 3+data_len: CRC_LO (over all bytes) - if (at == data_offset + data_len) - return true; - - // Byte data_offset+len+1: CRC_HI (over all bytes) - uint16_t computed_crc = crc16(raw, data_offset + data_len); - uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8); - if (computed_crc != remote_crc) { - if (this->disable_crc_) { - ESP_LOGD(TAG, "CRC check failed %" PRIu32 "ms after last send; ignoring", millis() - this->last_send_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc, - format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size())); - } else { - ESP_LOGW(TAG, "CRC check failed %" PRIu32 "ms after last send", millis() - this->last_send_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc, - format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size())); - return false; - } + if (!this->parse_modbus_client_frame_()) + this->clear_rx_buffer_(LOG_STR("parse failed"), true); } + // Stop if the buffer didn't shrink (no frame consumed) and no mode switch triggered a retry + if (!retry_as_client && size <= this->rx_buffer_.size()) + break; } - std::vector data(this->rx_buffer_.begin() + data_offset, this->rx_buffer_.begin() + data_offset + data_len); - bool found = false; - for (auto *device : this->devices_) { - if (device->address_ == address) { - found = true; - if (this->role == ModbusRole::SERVER) { - if (function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || - function_code == ModbusFunctionCode::READ_INPUT_REGISTERS) { - device->on_modbus_read_registers(function_code, uint16_t(data[1]) | (uint16_t(data[0]) << 8), - uint16_t(data[3]) | (uint16_t(data[2]) << 8)); - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - device->on_modbus_write_registers(function_code, data); - } - } else { // We're a client - // Is it an error response? - if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) { - uint8_t exception = raw[2]; - ESP_LOGW(TAG, - "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 - "ms after last send", - function_code, exception, address, millis() - this->last_send_); - if (this->waiting_for_response_ == address) { - device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception); - } else { - // Ignore modbus exception not related to a pending command - ESP_LOGD(TAG, "Ignoring error - not expecting a response from %" PRIu8 "", address); - } - } else { // Not an error response - if (this->waiting_for_response_ == address) { - device->on_modbus_data(data); - } else { - // Ignore modbus response not related to a pending command - ESP_LOGW(TAG, "Ignoring response - not expecting a response from %" PRIu8 ", %" PRIu32 "ms after last send", - address, millis() - this->last_send_); - } - } - } - } + if (this->timeout_()) + this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); +} + +uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const { + // Custom functions could be any length - we have to rely on the CRC to determine completeness. + // If a CRC match is never found, the buffer will eventually overflow and be cleared. + const uint8_t *raw = &this->rx_buffer_[0]; + const size_t size = this->rx_buffer_.size(); + for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) { + if (crc16(raw, len) == 0) + return len; + } + return 0; +} + +bool Modbus::parse_modbus_server_frame_() { + size_t size = this->rx_buffer_.size(); + uint16_t frame_length = helpers::server_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size()); + + if (size < frame_length) + return true; + + uint8_t address = this->rx_buffer_[0]; + uint8_t function_code = this->rx_buffer_[1]; + + if (helpers::is_function_code_custom(function_code)) { + frame_length = this->find_custom_frame_end_(frame_length); + if (frame_length == 0) + return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size + ESP_LOGD(TAG, "User-defined function %02X found", function_code); + } else { + if (crc16(&this->rx_buffer_[0], frame_length) != 0) + return false; } - if (!found && this->role == ModbusRole::CLIENT) { - ESP_LOGW(TAG, "Got frame from unknown address %" PRIu8 ", %" PRIu32 "ms after last send", address, - millis() - this->last_send_); - } + // Process before clearing: process_modbus_server_frame (receiving a response or peer message) never sends a reply + // synchronously. We can safely point directly into rx_buffer_ and avoid a copy. + uint8_t data_offset = helpers::server_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); + const uint8_t *data = this->rx_buffer_.data() + data_offset; + uint16_t data_len = frame_length - 2 - data_offset; - this->clear_rx_buffer_(LOG_STR("parse succeeded")); - - if (this->waiting_for_response_ == address) - this->waiting_for_response_ = 0; + this->process_modbus_server_frame(address, function_code, data, data_len); + this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); return true; } -void Modbus::send_next_frame_() { - if (this->tx_buffer_.empty()) +bool ModbusServerHub::parse_modbus_client_frame_() { + size_t size = this->rx_buffer_.size(); + uint16_t frame_length = helpers::client_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size()); + + if (size < frame_length) + return true; + + uint8_t address = this->rx_buffer_[0]; + uint8_t function_code = this->rx_buffer_[1]; + + if (helpers::is_function_code_custom(function_code)) { + frame_length = this->find_custom_frame_end_(frame_length); + if (frame_length == 0) + return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size + ESP_LOGD(TAG, "User-defined function %02X found", function_code); + } else { + if (crc16(&this->rx_buffer_[0], frame_length) != 0) + return false; + } + + // Clear before processing: process_modbus_client_frame_ dispatches to a server device which sends + // a response immediately. We need to clear the rx buffer first so the response doesn't snag tx_blocked. + // This requires copying the frame data to a local buffer beforehand. + uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); + uint16_t data_len = frame_length - 2 - data_offset; + uint8_t data[MAX_FRAME_SIZE] = {}; + std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len); + this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); + + this->process_modbus_client_frame_(address, function_code, data, data_len); + + return true; +} + +void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) { + if (!this->waiting_for_response_.has_value()) { + ESP_LOGW(TAG, + "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send", + address, function_code, this->last_modbus_byte_ - this->last_send_); return; + } else { // We are waiting for a response + // Check if the response matches the expected address and function code - if (this->tx_blocked()) - return; + ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); + uint8_t expected_address = wfr.frame.data.get()[0]; + uint8_t expected_function_code = wfr.frame.data.get()[1]; + if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { + ESP_LOGW(TAG, + "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 + "ms after last send", + address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, + this->last_modbus_byte_ - this->last_send_); + // Invalidate the waiting device so it won't process this response. + if (wfr.device) + wfr.device->on_modbus_no_response(); + wfr.interrupted = true; + wfr.device = nullptr; + return; + } - const ModbusDeviceCommand &frame = this->tx_buffer_.front(); + if (wfr.interrupted) { + ESP_LOGW(TAG, + "Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32 + "ms after last send", + address, this->last_modbus_byte_ - this->last_send_); + return; + } else { // We have a valid device waiting for this response - if (this->role == ModbusRole::CLIENT) { - this->waiting_for_response_ = frame.data.get()[0]; + ModbusClientDevice *device = wfr.device; + this->waiting_for_response_.reset(); + // Is it an error response? + if (helpers::is_function_code_exception(function_code)) { + uint8_t exception = len > 0 ? data[0] : 0; + ESP_LOGW(TAG, + "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", + function_code, exception, address, this->last_modbus_byte_ - this->last_send_); + if (device) + device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception); + + } else if (device) { // Not an error response + // on_modbus_data is existing public API taking const std::vector& + device->on_modbus_data(std::vector(data, data + len)); + } else { // Not an error response, but no device to respond to + ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", + address, this->last_modbus_byte_ - this->last_send_); + } + } + } +} + +void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) { + for (auto *device : this->devices_) { + if (device->address_ == address) { + ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); + } + } + + if (this->expecting_peer_response_ == address) { + ESP_LOGV(TAG, "Expected response from peer %" PRIu8 " received", address); + } else { + ESP_LOGV(TAG, "Unexpected response from peer %" PRIu8 " received", address); + } + + // This always resets, even if the address doesn't match. + // If an unexpected response is received, we can't trust that a correct response will follow (it shouldn't). + this->expecting_peer_response_ = 0; +} + +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) { + bool found = false; + + for (auto *device : this->devices_) { + if (device->address_ == address) { + found = true; + + if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS || + static_cast(function_code) == ModbusFunctionCode::READ_INPUT_REGISTERS) { + device->on_modbus_read_registers(function_code, helpers::get_data(data, 0), + helpers::get_data(data, 2)); + } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER || + static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + device->on_modbus_write_registers(function_code, std::vector(data, data + len)); + } else { + ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); + device->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + } + } + } + + if (!found) { + this->expecting_peer_response_ = address; + ESP_LOGV(TAG, "Request to peer %" PRIu8 " received", address); + } +} + +bool Modbus::send_frame_(const ModbusFrame &frame) { + if (this->tx_blocked()) { + ESP_LOGE(TAG, "Attempted to send while transmission blocked"); + return false; + } + if (frame.size > MAX_FRAME_SIZE) { + ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE); + return false; + } + + const int32_t tx_delay_remaining = this->tx_delay_remaining(); + if (tx_delay_remaining > 0) { + delay(tx_delay_remaining); } if (this->flow_control_pin_ != nullptr) { @@ -304,123 +388,190 @@ void Modbus::send_next_frame_() { this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; } + uint32_t now = millis(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send", format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), - millis() - this->last_send_); - this->last_send_ = millis(); + ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive", + format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, + now - this->last_modbus_byte_); + this->last_send_ = now; + return true; +} + +void ModbusClientHub::send_next_frame_() { + if (this->tx_buffer_.empty()) { + return; + } + + if (this->tx_blocked()) { + return; + } + + ModbusDeviceCommand &command = this->tx_buffer_.front(); + + if (this->send_frame_(command.frame)) { + this->waiting_for_response_ = std::move(command); + } else { + if (command.device) + command.device->on_modbus_not_sent(); + } + this->tx_buffer_.pop_front(); + if (!this->tx_buffer_.empty()) { ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); } } -void Modbus::dump_config() { +void ModbusClientHub::dump_config() { ESP_LOGCONFIG(TAG, "Modbus:\n" - " Send Wait Time: %d ms\n" - " Turnaround Time: %d ms\n" - " Frame Delay: %d ms\n" - " Long Rx Buffer Delay: %d ms\n" - " CRC Disabled: %s", + " Send Wait Time: %" PRIu16 " ms\n" + " Turnaround Time: %" PRIu16 " ms\n" + " Frame Delay: %" PRIu16 " ms\n" + " Long Rx Buffer Delay: %" PRIu16 " ms", this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_, - this->long_rx_buffer_delay_ms_, YESNO(this->disable_crc_)); + this->long_rx_buffer_delay_ms_); LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); } +void ModbusServerHub::dump_config() { + ESP_LOGCONFIG(TAG, + "Modbus:\n" + " Frame Delay: %" PRIu16 " ms\n" + " Long Rx Buffer Delay: %" PRIu16 " ms", + this->frame_delay_ms_, this->long_rx_buffer_delay_ms_); + LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); +} + float Modbus::get_setup_priority() const { // After UART bus return setup_priority::BUS - 1.0f; } -void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len, const uint8_t *payload) { - static const size_t MAX_VALUES = 128; - - // Only check max number of registers for standard function codes - // Some devices use non standard codes like 0x43 - if (number_of_entities > MAX_VALUES && function_code <= ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - ESP_LOGE(TAG, "send too many values %d max=%zu", number_of_entities, MAX_VALUES); +void ModbusServerHub::send(uint8_t address, uint8_t function_code, const std::vector &payload) { + const uint16_t len = static_cast(2 + payload.size()); + if (len > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); return; } - - uint8_t data[MAX_FRAME_SIZE]; - size_t pos = 0; - - data[pos++] = address; - data[pos++] = function_code; - if (this->role == ModbusRole::CLIENT) { - data[pos++] = start_address >> 8; - data[pos++] = start_address >> 0; - if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && - function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - data[pos++] = number_of_entities >> 8; - data[pos++] = number_of_entities >> 0; - } - } - - if (payload != nullptr) { - if (this->role == ModbusRole::SERVER || function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { // Write multiple - data[pos++] = payload_len; // Byte count is required for write - } else { - payload_len = 2; // Write single register or coil - } - if (payload_len + pos + 2 > MAX_FRAME_SIZE) { // Check if payload fits (accounting for CRC) - ESP_LOGE(TAG, "Payload too large to send: %d bytes", payload_len); - return; - } - for (int i = 0; i < payload_len; i++) { - data[pos++] = payload[i]; - } - } - - this->queue_raw_(data, pos); + uint8_t raw_frame[MAX_RAW_SIZE]; + raw_frame[0] = address; + raw_frame[1] = function_code; + std::memcpy(raw_frame + 2, payload.data(), payload.size()); + this->send_raw_(raw_frame, len); } -// Helper function for lambdas -// Send raw command. Except CRC everything must be contained in payload -void Modbus::send_raw(const std::vector &payload) { - if (payload.empty()) { +// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. +void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) { + if (pdu_len == 0) { + if (device) + device->on_modbus_not_sent(); return; } - // Frame size: payload + CRC(2) - if (payload.size() + 2 > MAX_FRAME_SIZE) { - ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %d bytes", MAX_FRAME_SIZE); - return; - } - // Use stack buffer - Modbus frames are small and bounded - uint8_t data[MAX_FRAME_SIZE]; - std::memcpy(data, payload.data(), payload.size()); - - this->queue_raw_(data, payload.size()); -} - -// Assume data and length is valid and append CRC, then queue for sending. Used internally to avoid unnecessary copying -// of data into vectors -void Modbus::queue_raw_(const uint8_t *data, uint16_t len) { if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) { - this->tx_buffer_.emplace_back(data, len); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; +#endif + ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); + this->tx_buffer_.emplace_back(device, address, pdu, pdu_len); } else { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGE(TAG, "Write buffer full, dropped: %s", format_hex_pretty_to(hex_buf, data, len)); + ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); + if (device) + device->on_modbus_not_sent(); } } -void Modbus::clear_rx_buffer_(const LogString *reason, bool warn) { - size_t at = this->rx_buffer_.size(); - if (at > 0) { +void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { + // Remove any pending commands for this address from the tx buffer + auto &tx_buffer = this->tx_buffer_; + tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data[0] == address; }), + tx_buffer.end()); + + if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { + if (this->waiting_for_response_.value().frame.data[0] == address) { + ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address); + // Invalidate the waiting device so it won't process a response. + this->waiting_for_response_.value().device = nullptr; + } + } +} +void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) { + // Remove any pending commands for this address from the tx buffer + auto &tx_buffer = this->tx_buffer_; + tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [device](const ModbusDeviceCommand &cmd) { return cmd.device == device; }), + tx_buffer.end()); + + if (this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { + if (this->waiting_for_response_.value().device == device) { + ESP_LOGV(TAG, "Clearing waiting for response"); + // Invalidate the waiting device so it won't process a response. + this->waiting_for_response_.value().device = nullptr; + } + } +} + +void ModbusClientHub::send_raw(const std::vector &payload, ModbusClientDevice *device) { + if (payload.size() < 2) { + if (device) + device->on_modbus_not_sent(); + return; + } + this->queue_raw_(payload[0], payload.data() + 1, static_cast(payload.size() - 1), device); +} + +// Send raw command for server replies immediately. Except CRC everything must be contained in payload +void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { + if (len == 0) { + return; + } + if (len > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); + return; + } + + // In the rare case that the server is blocked (frame delay has not elapsed), we delay the send. + // This should only happen at low baud rates with long frame delays. + if (this->tx_blocked()) { + // Stash the raw payload in a single member buffer so the deferred callback can rebuild the frame + // without a heap allocation. Only one server reply is ever in flight, and the named timeout ensures + // only one deferred send is pending, so a single buffer is sufficient. + std::memcpy(this->deferred_payload_.data(), payload, len); + this->deferred_payload_len_ = len; + this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() { + ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, + this->deferred_payload_len_ - 1); + this->send_frame_(frame); + }); + } else { + ModbusFrame frame(payload[0], payload + 1, len - 1); + this->send_frame_(frame); + } +} + +void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) { + size_t bytes = this->rx_buffer_.size(); + if (bytes_to_clear > 0 && bytes >= bytes_to_clear) + bytes = bytes_to_clear; + if (bytes > 0) { if (warn) { - ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), + ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), millis() - this->last_send_); } else { - ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), + ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), millis() - this->last_send_); } - this->rx_buffer_.clear(); + if (bytes == this->rx_buffer_.size()) { + this->rx_buffer_.clear(); + } else { + this->rx_buffer_.erase(this->rx_buffer_.begin(), this->rx_buffer_.begin() + bytes); + } } } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 26f64401be..86337442c6 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -4,33 +4,32 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/modbus/modbus_definitions.h" +#include "esphome/components/modbus/modbus_helpers.h" +#include #include #include #include -#include +#include +#include namespace esphome::modbus { static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; +static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; -enum ModbusRole { - CLIENT, - SERVER, -}; - -class ModbusDevice; - -struct ModbusDeviceCommand { +struct ModbusFrame { // Frame with exact-size allocation to avoid std::vector overhead std::unique_ptr data; uint16_t size; // Modbus RTU max is 256 bytes - ModbusDeviceCommand(const uint8_t *src, uint16_t len) : data(std::make_unique(len + 2)), size(len + 2) { - std::memcpy(this->data.get(), src, len); - auto crc = crc16(data.get(), len); - data[len + 0] = crc >> 0; - data[len + 1] = crc >> 8; + ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) + : data(std::make_unique(pdu_len + 3)), size(pdu_len + 3) { + data[0] = address; + memcpy(data.get() + 1, pdu, pdu_len); + auto crc = crc16(data.get(), pdu_len + 1); + data[pdu_len + 1] = crc >> 0; + data[pdu_len + 2] = crc >> 8; } }; @@ -39,86 +38,197 @@ class Modbus : public uart::UARTDevice, public Component { Modbus() = default; void setup() override; - void loop() override; - void dump_config() override; - - void register_device(ModbusDevice *device) { this->devices_.push_back(device); } - float get_setup_priority() const override; - bool tx_buffer_empty(); - bool tx_blocked(); + virtual bool tx_blocked(); - void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len = 0, const uint8_t *payload = nullptr); - void send_raw(const std::vector &payload); - void set_role(ModbusRole role) { this->role = role; } void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } - void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; } - void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } - void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; } - - ModbusRole role; protected: - bool parse_modbus_byte_(uint8_t byte); - void receive_and_parse_modbus_bytes_(); - void clear_rx_buffer_(const LogString *reason, bool warn = false); - void send_next_frame_(); - void queue_raw_(const uint8_t *data, uint16_t len); + void receive_bytes_(); + bool timeout_(); + virtual int32_t tx_delay_remaining(); + virtual void parse_modbus_frames() = 0; + bool parse_modbus_server_frame_(); + virtual void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) = 0; + void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0); + bool send_frame_(const ModbusFrame &frame); + // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. + // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. + uint16_t find_custom_frame_end_(uint16_t min_length) const; uint32_t last_modbus_byte_{0}; + uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; uint16_t frame_delay_ms_{5}; uint16_t long_rx_buffer_delay_ms_{0}; - uint16_t send_wait_time_{250}; - uint16_t turnaround_delay_ms_{100}; - uint8_t waiting_for_response_{0}; - bool disable_crc_{false}; GPIOPin *flow_control_pin_{nullptr}; std::vector rx_buffer_; - std::vector devices_; +}; + +class ModbusClientDevice; +class ModbusServerDevice; + +struct ModbusDeviceCommand { + ModbusClientDevice *device; + ModbusFrame frame; + bool interrupted{false}; + + ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len) + : device(device), frame(address, src, len) {} +}; + +class ModbusClientHub : public Modbus { + public: + ModbusClientHub() = default; + void dump_config() override; + void loop() override; + void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; } + void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } + bool tx_buffer_empty(); + bool tx_blocked() override; + ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") + void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, + uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { + this->send_pdu(address, + helpers::create_client_pdu((ModbusFunctionCode) function_code, start_address, number_of_entities, + payload, payload_len), + device); + }; + void send_pdu(uint8_t address, const StaticVector &pdu, ModbusClientDevice *device = nullptr) { + this->queue_raw_(address, pdu.data(), pdu.size(), device); + } + void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); + void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true); + void clear_tx_queue_for_device(ModbusClientDevice *device); + + protected: + int32_t tx_delay_remaining() override; + void parse_modbus_frames() override; + // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. + void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void send_next_frame_(); + void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr); + + uint16_t send_wait_time_{2000}; + uint16_t turnaround_delay_ms_{0}; + std::optional waiting_for_response_; + // std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many // requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling // may change at run time. std::deque tx_buffer_; }; -class ModbusDevice { +class ModbusServerHub : public Modbus { public: - void set_parent(Modbus *parent) { parent_ = parent; } - void set_address(uint8_t address) { address_ = address; } - virtual void on_modbus_data(const std::vector &data) = 0; - virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} - virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; - virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; - void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, - const uint8_t *payload = nullptr) { - this->parent_->send(this->address_, function, start_address, number_of_entities, payload_len, payload); - } - void send_raw(const std::vector &payload) { this->parent_->send_raw(payload); } - void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { - std::vector error_response; - error_response.reserve(3); - error_response.push_back(this->address_); - error_response.push_back(function_code | FUNCTION_CODE_EXCEPTION_MASK); - error_response.push_back(static_cast(exception_code)); - this->send_raw(error_response); - } - // If more than one device is connected block sending a new command before a response is received - ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") - bool waiting_for_response() { return !ready_for_immediate_send(); } - bool ready_for_immediate_send() { return parent_->tx_buffer_empty() && !parent_->tx_blocked(); } + ModbusServerHub() = default; + void dump_config() override; + void send(uint8_t address, uint8_t function_code, const std::vector &payload); + ESPDEPRECATED("Use ModbusServerDevice::send_raw instead. Removed in 2026.10.0", "2026.4.0") + void send_raw(const std::vector &payload) { + this->send_raw_(payload.data(), static_cast(payload.size())); + }; + void register_device(ModbusServerDevice *device) { this->devices_.push_back(device); } protected: - friend Modbus; + friend class ModbusServerDevice; - Modbus *parent_; - uint8_t address_; + void parse_modbus_frames() override; + bool parse_modbus_client_frame_(); + // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. + void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len); + void send_raw_(const uint8_t *payload, uint16_t len); + uint8_t expecting_peer_response_{0}; + std::vector devices_; + + // Holds the raw payload of a single reply deferred for sending when tx was blocked at send time. + // Only one server reply can be in flight at once, so a single fixed buffer avoids heap allocation. + std::array deferred_payload_; + uint16_t deferred_payload_len_{0}; +}; + +class ModbusClientDevice { + public: + ModbusClientDevice() = default; + ModbusClientDevice(ModbusClientHub *parent, uint8_t address) : parent_(parent), address_(address) {} + virtual ~ModbusClientDevice() { + if (this->parent_ != nullptr) + this->clear_tx_queue_for_device(); + } + ModbusClientDevice(const ModbusClientDevice &) = delete; + ModbusClientDevice &operator=(const ModbusClientDevice &) = delete; + ModbusClientDevice(ModbusClientDevice &&) = delete; + ModbusClientDevice &operator=(ModbusClientDevice &&) = delete; + void set_parent(ModbusClientHub *parent) { this->parent_ = parent; } + void set_address(uint8_t address) { this->address_ = address; } + virtual void on_modbus_data(const std::vector &data) {} + virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} + virtual void on_modbus_not_sent() {} + virtual void on_modbus_no_response() {} + void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, + const uint8_t *payload = nullptr) { + this->parent_->send_pdu(this->address_, + helpers::create_client_pdu((ModbusFunctionCode) function, start_address, number_of_entities, + payload, payload_len), + this); + } + void send_pdu(const StaticVector &pdu) { this->parent_->send_pdu(this->address_, pdu, this); } + void send_raw(const std::vector &payload) { this->parent_->send_raw(payload, this); } + inline void clear_tx_queue_for_address(bool clear_sent = true) { + this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); + } + inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } + + // If more than one device is connected block sending a new command before a response is received + ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") + bool waiting_for_response() { return !this->ready_for_immediate_send(); } + bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); } + + protected: + ModbusClientHub *parent_{nullptr}; + uint8_t address_{0}; +}; + +// This is for compatibility with external components using the former class name +using ModbusDevice = ModbusClientDevice; + +class ModbusServerDevice { + public: + ModbusServerDevice() = default; + ModbusServerDevice(ModbusServerHub *parent, uint8_t address) : parent_(parent), address_(address) {} + virtual ~ModbusServerDevice() = default; + ModbusServerDevice(const ModbusServerDevice &) = delete; + ModbusServerDevice &operator=(const ModbusServerDevice &) = delete; + ModbusServerDevice(ModbusServerDevice &&) = delete; + ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; + void set_parent(ModbusServerHub *parent) { this->parent_ = parent; } + void set_address(uint8_t address) { this->address_ = address; } + virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; + virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; + void send(uint8_t function, const std::vector &payload) { + this->parent_->send(this->address_, function, payload); + } + void send_raw(const std::vector &payload) { + this->parent_->send_raw_(payload.data(), static_cast(payload.size())); + } + void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { + uint8_t error_response[3] = {this->address_, uint8_t(function_code | FUNCTION_CODE_EXCEPTION_MASK), + static_cast(exception_code)}; + this->parent_->send_raw_(error_response, 3); + } + + protected: + friend ModbusServerHub; + + ModbusServerHub *parent_{nullptr}; + uint8_t address_{0}; }; } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index fb8c011259..49172b9dca 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -14,7 +14,8 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT = 100; // 0x64 const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E enum class ModbusFunctionCode : uint8_t { - CUSTOM = 0x00, + INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions). + CUSTOM = 0x00, // The CUSTOM alias should be removed in future. READ_COILS = 0x01, READ_DISCRETE_INPUTS = 0x02, READ_HOLDING_REGISTERS = 0x03, @@ -35,19 +36,11 @@ enum class ModbusFunctionCode : uint8_t { READ_FIFO_QUEUE = 0x18, // not implemented }; -/*Allow comparison operators between ModbusFunctionCode and uint8_t*/ +/*Allow direct comparison operators between ModbusFunctionCode and uint8_t*/ inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) == rhs; } inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast(rhs); } inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast(lhs) == rhs); } inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast(rhs)); } -inline bool operator<(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) < rhs; } -inline bool operator<(uint8_t lhs, ModbusFunctionCode rhs) { return lhs < static_cast(rhs); } -inline bool operator<=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) <= rhs; } -inline bool operator<=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs <= static_cast(rhs); } -inline bool operator>(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) > rhs; } -inline bool operator>(uint8_t lhs, ModbusFunctionCode rhs) { return lhs > static_cast(rhs); } -inline bool operator>=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) >= rhs; } -inline bool operator>=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs >= static_cast(rhs); } // 4.3 MODBUS Data model enum class ModbusRegisterType : uint8_t { @@ -75,12 +68,21 @@ enum class ModbusExceptionCode : uint8_t { }; // 6.12 16 (0x10) Write Multiple registers: -const uint8_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B +static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B + +// 6.1 01 (0x01) Read Coils +// 6.2 02 (0x02) Read Discrete Inputs +static constexpr uint16_t MAX_NUM_OF_COILS_TO_READ = 2000; // 0x7D0 +static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0 // 6.3 03 (0x03) Read Holding Registers // 6.4 04 (0x04) Read Input Registers -const uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +// Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) +static constexpr uint16_t MIN_FRAME_SIZE = 4; +static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253 +static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 255 - CRC(2) = 254 static constexpr uint16_t MAX_FRAME_SIZE = 256; /// End of Modbus definitions } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 89dc3c08bc..4cddfca104 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -1,10 +1,83 @@ #include "modbus_helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; +uint16_t server_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; + if (is_function_code_exception(frame[1])) { + return 5; // address(1) + function(1) + exception(1) + CRC(2) + } + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. + case ModbusFunctionCode::READ_FILE_RECORD: + case ModbusFunctionCode::WRITE_FILE_RECORD: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); + case ModbusFunctionCode::MASK_WRITE_REGISTER: + return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) + case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + case ModbusFunctionCode::READ_FIFO_QUEUE: + // address(1) + function(1) + fifo address(2) CRC(2) + return 6; + default: + return MIN_FRAME_SIZE; // unknown length + } +} + +uint16_t client_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + // address(1) + function(1) + start address(2) + quantity(2) + CRC(2) + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + start address(2) + quantity(2) + byte count(1) + data + CRC(2) + return 9 + (size > 6 ? std::min(frame[6], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); + // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. + case ModbusFunctionCode::READ_FILE_RECORD: + case ModbusFunctionCode::WRITE_FILE_RECORD: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); + case ModbusFunctionCode::MASK_WRITE_REGISTER: + return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) + case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + read start address(2) + read quantity(2) + write start address(2) + + // write quantity(2) + byte count(1) + data + CRC(2) + return 13 + (size > 10 ? std::min(frame[10], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); + case ModbusFunctionCode::READ_FIFO_QUEUE: + // address(1) + function(1) + fifo address(2) CRC(2) + return 6; + default: + return MIN_FRAME_SIZE; // unknown length + } +} + static size_t required_payload_size(SensorValueType sensor_value_type) { switch (sensor_value_type) { case SensorValueType::U_WORD: @@ -67,7 +140,7 @@ void number_to_payload(std::vector &data, int64_t value, SensorValueTy } int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { + uint32_t bitmask, bool *error_return) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits // Validate offset against the buffer for all types, including RAW/unsupported, so @@ -75,6 +148,8 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens if (static_cast(offset) > data.size()) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), static_cast(offset), data.size()); + if (error_return) + *error_return = true; return value; } @@ -87,6 +162,8 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", static_cast(sensor_value_type), static_cast(offset), data.size(), required_size); + if (error_return) + *error_return = true; return value; } @@ -136,4 +213,102 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens } return value; } + +StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, + uint16_t number_of_entities, const uint8_t *values, + size_t values_len) { + if (is_function_code_read(static_cast(function_code))) { + if (values != nullptr || values_len > 0) { + ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", + static_cast(function_code)); + } + } else if (is_function_code_write(static_cast(function_code))) { + if (values == nullptr || values_len == 0) { + ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast(function_code)); + return {}; + } + } else { + ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast(function_code)); + return {}; + } + + if (number_of_entities == 0) { + ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); + return {}; + } + + switch (function_code) { + case ModbusFunctionCode::READ_COILS: + if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + break; // number_of_entities is ignored for single write, so no need to validate + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_WRITE) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to write %u for function code %02X", + number_of_entities, MAX_NUM_OF_REGISTERS_TO_WRITE, static_cast(function_code)); + return {}; + } + break; + default: + ESP_LOGE(TAG, "Unsupported function code %u for client PDU creation", static_cast(function_code)); + return {}; + } + + StaticVector pdu; + pdu.push_back(static_cast(function_code)); + pdu.push_back(start_address >> 8); + pdu.push_back(start_address >> 0); + if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && + function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + pdu.push_back(number_of_entities >> 8); + pdu.push_back(number_of_entities >> 0); + } + + if (is_function_code_write(static_cast(function_code))) { + if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || + function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + // 6 bytes of overhead (fc + start_addr×2 + qty×2 + byte_count) leave MAX_PDU_SIZE-6 bytes for values + static constexpr size_t MAX_WRITE_MULTIPLE_VALUES_LEN = MAX_PDU_SIZE - 6; + if (values_len > MAX_WRITE_MULTIPLE_VALUES_LEN) { + ESP_LOGE(TAG, "values_len %zu exceeds PDU capacity %zu, dropping request", values_len, + MAX_WRITE_MULTIPLE_VALUES_LEN); + return {}; + } + pdu.push_back(values_len); // Byte count is required for write multiple + for (size_t i = 0; i < values_len; i++) + pdu.push_back(values[i]); + } else { + // Write single register or coil (2 bytes) + if (values_len < 2) { + ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len); + return {}; + } + pdu.push_back(values[0]); + pdu.push_back(values[1]); + } + } + return pdu; +} } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 84897bcad3..b637d872cf 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -9,6 +9,58 @@ namespace esphome::modbus::helpers { +inline bool is_function_code_read(uint8_t function_code) { + ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == ModbusFunctionCode::READ_COILS || + masked_function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS || + masked_function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || + masked_function_code == ModbusFunctionCode::READ_INPUT_REGISTERS; +} + +inline bool is_function_code_write(uint8_t function_code) { + ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == ModbusFunctionCode::WRITE_SINGLE_COIL || + masked_function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || + masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || + masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS; +} + +inline bool is_function_code_exception(uint8_t function_code) { + return (static_cast(function_code) & FUNCTION_CODE_EXCEPTION_MASK) != 0; +} + +inline bool is_function_code_custom(uint8_t function_code) { + uint8_t masked_function_code = function_code & FUNCTION_CODE_MASK; + return (masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT && + masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END) || + (masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT && + masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); +} + +// Returns the expected length of a server response frame based on the function code +// If the frame is too short to determine the length, returns the minimum length +uint16_t server_frame_length(const uint8_t *frame, size_t size); + +// Returns the expected length of a client request frame based on the function code +// If the frame is too short to determine the length, returns the minimum length +uint16_t client_frame_length(const uint8_t *frame, size_t size); + +inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { + if (size < 2) + return 0; + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + return 3; // address(1) + function(1) + byte count(1) + data + CRC(2) + default: + return 2; + } +} + +inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; } + enum class SensorValueType : uint8_t { RAW = 0x00, // variable length U_WORD = 0x1, // 1 Register unsigned @@ -41,21 +93,21 @@ inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_t case ModbusRegisterType::READ: return ModbusFunctionCode::READ_INPUT_REGISTERS; default: - return ModbusFunctionCode::CUSTOM; + return ModbusFunctionCode::INVALID; } } -inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { +inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type, bool multiple = false) { switch (reg_type) { case ModbusRegisterType::COIL: - return ModbusFunctionCode::WRITE_SINGLE_COIL; - case ModbusRegisterType::DISCRETE_INPUT: - return ModbusFunctionCode::CUSTOM; + return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_COILS : ModbusFunctionCode::WRITE_SINGLE_COIL; case ModbusRegisterType::HOLDING: - return ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS; + return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER; + // These register types can't be written (per spec) case ModbusRegisterType::READ: + case ModbusRegisterType::DISCRETE_INPUT: default: - return ModbusFunctionCode::CUSTOM; + return ModbusFunctionCode::INVALID; } } @@ -112,31 +164,31 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { * @param buffer_offset offset in bytes. * @return value of type T extracted from buffer */ -template T get_data(const std::vector &data, size_t buffer_offset) { +template T get_data(const uint8_t *data, size_t buffer_offset) { if (sizeof(T) == sizeof(uint8_t)) { return T(data[buffer_offset]); } if (sizeof(T) == sizeof(uint16_t)) { return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); } - if (sizeof(T) == sizeof(uint32_t)) { return static_cast(get_data(data, buffer_offset)) << 16 | static_cast(get_data(data, buffer_offset + 2)); } - if (sizeof(T) == sizeof(uint64_t)) { return static_cast(get_data(data, buffer_offset)) << 32 | (static_cast(get_data(data, buffer_offset + 4))); } - static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) || sizeof(T) == sizeof(uint64_t), "Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported."); - return T{}; } +template T get_data(const std::vector &data, size_t buffer_offset) { + return get_data(data.data(), buffer_offset); +} + /** Extract coil data from modbus response buffer * Responses for coil are packed into bytes . * coil 3 is bit 3 of the first response byte @@ -188,7 +240,27 @@ void number_to_payload(std::vector &data, int64_t value, SensorValueTy * @return 64-bit number of the payload */ int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask); + uint32_t bitmask, bool *error_return = nullptr); + +/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. + * @param function_code the modbus function code to use. One of: + * READ_COILS + * READ_DISCRETE_INPUTS + * READ_HOLDING_REGISTERS + * READ_INPUT_REGISTERS + * WRITE_SINGLE_COIL + * WRITE_SINGLE_REGISTER + * WRITE_MULTIPLE_COILS + * WRITE_MULTIPLE_REGISTERS + * @param start_address coil/register/input starting address + * @param number_of_entities number of coils/registers/inputs to read/write + * @param values optional payload bytes to write (nullptr for read commands) + * @param values_len length of values array + * @return PDU (function code + data, no address, no CRC) + */ +StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, + uint16_t number_of_entities, const uint8_t *values = nullptr, + size_t values_len = 0); inline std::vector float_to_payload(float value, SensorValueType value_type) { int64_t val; diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 6604276cc2..9246239ef9 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -201,7 +201,7 @@ void ModbusController::update() { // walk through the sensors and determine the register ranges to read size_t ModbusController::create_register_ranges_() { this->register_ranges_.clear(); - if (this->parent_->role == modbus::ModbusRole::CLIENT && this->sensorset_.empty()) { + if (this->sensorset_.empty()) { ESP_LOGW(TAG, "No sensors registered"); return 0; } diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index ba86c2cd16..4f674b2675 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -279,7 +279,7 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController : public PollingComponent, public modbus::ModbusDevice { +class ModbusController : public PollingComponent, public modbus::ModbusClientDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 5182bc05d1..2ba7f41b83 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -27,7 +27,7 @@ MULTI_CONF = True modbus_server_ns = cg.esphome_ns.namespace("modbus_server") ModbusServer = modbus_server_ns.class_( - "ModbusServer", cg.Component, modbus.ModbusDevice + "ModbusServer", cg.Component, modbus.ModbusServerDevice ) ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") @@ -44,7 +44,7 @@ SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( ModbusServerRegisterSchema = cv.Schema( { cv.GenerateID(): cv.declare_id(ServerRegister), - cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All( CONF_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), } - ).extend(modbus.modbus_device_schema(0x01)), + ).extend(modbus.modbus_device_schema(0x01, role="server")), ) @@ -119,6 +119,5 @@ async def to_code(config): ) ) cg.add(var.add_server_register(server_register_var)) - cg.add(var.set_address(config[CONF_ADDRESS])) await cg.register_component(var, config) - return await modbus.register_modbus_device(var, config) + return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e5ea2efa4d..c294d08888 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -5,6 +5,7 @@ namespace esphome::modbus_server { using modbus::ModbusFunctionCode; using modbus::ModbusExceptionCode; +using modbus::helpers::payload_to_number; static const char *const TAG = "modbus_server"; @@ -16,7 +17,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star this->address_, function_code, start_address, number_of_registers); if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); return; } @@ -30,9 +31,10 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star break; } int64_t value = server_register->read_lambda(); + char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", server_register->address, static_cast(server_register->value_type), - server_register->register_count, server_register->format_value(value).c_str()); + server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); std::vector payload; payload.reserve(server_register->register_count * 2); @@ -49,7 +51,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star (current_address <= this->server_courtesy_response_.register_last_address)) { ESP_LOGV(TAG, "Could not match any register to address 0x%02X, but default allowed. " - "Returning default value: %d.", + "Returning default value: %" PRIu16 ".", current_address, this->server_courtesy_response_.register_value); sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); current_address += 1; // Just increment by 1, as the default response is a single register @@ -64,20 +66,22 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star } std::vector response; + if (number_of_registers != sixteen_bit_response.size()) + ESP_LOGW(TAG, "Response size not matched to request register count."); + response.push_back(sixteen_bit_response.size() * 2); // actual byte count for (auto v : sixteen_bit_response) { auto decoded_value = decode_value(v); response.push_back(decoded_value[0]); response.push_back(decoded_value[1]); } - - this->send(function_code, start_address, number_of_registers, response.size(), response.data()); + this->send(function_code, response); } void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { uint16_t number_of_registers; uint16_t payload_offset; - if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { if (data.size() < 5) { ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); @@ -85,13 +89,15 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v } number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); return; } uint16_t payload_size = data[4]; if (payload_size != number_of_registers * 2) { - ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", + ESP_LOGW(TAG, + "Payload size of %" PRIu16 " bytes is not 2 times the number of registers (%" PRIu16 + "). Sending exception response.", payload_size, number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); return; @@ -103,7 +109,7 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v return; } payload_offset = 5; - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { if (data.size() < 4) { ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); @@ -148,15 +154,22 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { return server_register->write_lambda != nullptr; })) { - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + ESP_LOGW(TAG, "Invalid register address. Sending exception response."); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); return; } // Actually write to the registers: if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); - return server_register->write_lambda(number); + bool error = false; + int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF, &error); + if (error) { + return false; + } else { + return server_register->write_lambda(number); + } })) { + ESP_LOGW(TAG, "Could not write all registers. Sending exception response."); this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); return; } diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index 0fc2e0bef5..fa1376542c 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -52,32 +52,34 @@ class ServerRegister { }; } - // Formats a raw value into a string representation based on the value type for debugging - std::string format_value(int64_t value) const { - // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) - // plus null terminator = 43, rounded to 44 for 4-byte alignment - char buf[44]; + // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) + // plus null terminator = 43, rounded to 44 for 4-byte alignment + static constexpr size_t FORMAT_VALUE_BUF_SIZE = 44; + + // Formats a raw value into a caller-provided buffer based on the value type for debugging. + // Returns buf for convenience. + const char *format_value(int64_t value, char *buf, size_t buf_size) const { switch (this->value_type) { case SensorValueType::U_WORD: case SensorValueType::U_DWORD: case SensorValueType::U_DWORD_R: case SensorValueType::U_QWORD: case SensorValueType::U_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(value)); + buf_append_printf(buf, buf_size, 0, "%" PRIu64, static_cast(value)); return buf; case SensorValueType::S_WORD: case SensorValueType::S_DWORD: case SensorValueType::S_DWORD_R: case SensorValueType::S_QWORD: case SensorValueType::S_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + buf_append_printf(buf, buf_size, 0, "%" PRId64, value); return buf; case SensorValueType::FP32_R: case SensorValueType::FP32: - buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast(static_cast(value))); + buf_append_printf(buf, buf_size, 0, "%.1f", bit_cast(static_cast(value))); return buf; default: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + buf_append_printf(buf, buf_size, 0, "%" PRId64, value); return buf; } } @@ -89,12 +91,10 @@ class ServerRegister { WriteLambda write_lambda; }; -class ModbusServer : public Component, public modbus::ModbusDevice { +class ModbusServer : public Component, public modbus::ModbusServerDevice { public: void dump_config() override; - /// Not used for ModbusServer. - void on_modbus_data(const std::vector &data) override{}; /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index e1b4fb2aa6..cd260f410a 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -4,6 +4,181 @@ namespace esphome::modbus::helpers { +using FC = ModbusFunctionCode; + +// --- server_frame_length --------------------------------------------------- +// Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from +// tests/integration/fixtures/uart_mock_modbus.yaml. + +TEST(ModbusServerFrameLength, TooShortReturnsMinimum) { + const uint8_t frame[] = {0x01}; + EXPECT_EQ(server_frame_length(frame, 1), MIN_FRAME_SIZE); +} + +TEST(ModbusServerFrameLength, ReadHoldingUsesByteCount) { + // inject_rx for basic_register: 2 data bytes -> 5 + 2 = 7 + const uint8_t frame[] = {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 7); +} + +TEST(ModbusServerFrameLength, ReadByteCountCappedAtMax) { + const uint8_t frame[] = {0x01, 0x03, 0xFF}; // claim 255 bytes + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5 + MAX_NUM_OF_REGISTERS_TO_READ * 2); +} + +TEST(ModbusServerFrameLength, ReadMissingByteCountReturnsHeaderOnly) { + const uint8_t frame[] = {0x01, 0x03}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5); +} + +TEST(ModbusServerFrameLength, ExceptionResponse) { + // exception_response fixture: function code 0x83 has the exception bit set + const uint8_t frame[] = {0x01, 0x83, 0x02, 0xC0, 0xF1}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5); +} + +TEST(ModbusServerFrameLength, WriteResponsesAreFixed) { + for (FC fc : + {FC::WRITE_SINGLE_COIL, FC::WRITE_SINGLE_REGISTER, FC::WRITE_MULTIPLE_COILS, FC::WRITE_MULTIPLE_REGISTERS}) { + const uint8_t frame[] = {0x01, static_cast(fc)}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast(fc); + } +} + +TEST(ModbusServerFrameLength, MiscFixedAndUnknown) { + const uint8_t mask[] = {0x01, static_cast(FC::MASK_WRITE_REGISTER)}; + const uint8_t fifo[] = {0x01, static_cast(FC::READ_FIFO_QUEUE)}; + const uint8_t unknown[] = {0x01, 0x42}; + EXPECT_EQ(server_frame_length(mask, sizeof(mask)), 10); + EXPECT_EQ(server_frame_length(fifo, sizeof(fifo)), 6); + EXPECT_EQ(server_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE); +} + +// --- client_frame_length --------------------------------------------------- + +TEST(ModbusClientFrameLength, TooShortReturnsMinimum) { + const uint8_t frame[] = {0x01}; + EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE); +} + +TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) { + // basic_register request fixture is a read-holding request -> 8 bytes + const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A}; + EXPECT_EQ(client_frame_length(read, sizeof(read)), 8); + for (FC fc : {FC::READ_COILS, FC::READ_DISCRETE_INPUTS, FC::READ_INPUT_REGISTERS, FC::WRITE_SINGLE_COIL, + FC::WRITE_SINGLE_REGISTER}) { + const uint8_t frame[] = {0x01, static_cast(fc)}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast(fc); + } +} + +TEST(ModbusClientFrameLength, WriteMultipleUsesByteCount) { + // write 2 registers (4 data bytes): addr(2)+qty(2)+count(1) then data; count is frame[6] + const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + 4); +} + +TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) { + const uint8_t frame[] = {0x01, 0x0F, 0x00, 0x00, 0x00, 0x02, 0xFF}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2); +} + +TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) { + const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9); +} + +TEST(ModbusClientFrameLength, MiscFixedAndUnknown) { + const uint8_t mask[] = {0x01, static_cast(FC::MASK_WRITE_REGISTER)}; + const uint8_t fifo[] = {0x01, static_cast(FC::READ_FIFO_QUEUE)}; + const uint8_t unknown[] = {0x01, 0x42}; + EXPECT_EQ(client_frame_length(mask, sizeof(mask)), 10); + EXPECT_EQ(client_frame_length(fifo, sizeof(fifo)), 6); + EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE); +} + +// --- create_client_pdu ----------------------------------------------------- +// PDU = function code + data (no address, no CRC). + +TEST(ModbusCreateClientPdu, ReadHolding) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0003, 1); + const std::vector expected{0x03, 0x00, 0x03, 0x00, 0x01}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteSingleOmitsQuantity) { + const uint8_t values[] = {0x00, 0x0B}; + auto pdu = create_client_pdu(FC::WRITE_SINGLE_REGISTER, 0x0003, 1, values, sizeof(values)); + const std::vector expected{0x06, 0x00, 0x03, 0x00, 0x0B}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteSingleTooFewValuesReturnsEmpty) { + const uint8_t values[] = {0x00}; + auto pdu = create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, values, sizeof(values)); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteMultipleIncludesByteCount) { + const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16}; + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, sizeof(values)); + const std::vector expected{0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteMultipleOverCapacityReturnsEmpty) { + std::vector values(MAX_PDU_SIZE - 6 + 1, 0xAA); + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, values.data(), values.size()); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, UnsupportedFunctionCodeReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_FIFO_QUEUE, 0x0000, 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ZeroEntitiesReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, 0); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteWithoutValuesReturnsEmpty) { + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, nullptr, 0); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ReadHoldingOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +// Regression: coils allow up to 2000 entities, well above the 125 register limit. +// A switch fall-through previously subjected coil/discrete reads to the register limit. +TEST(ModbusCreateClientPdu, ReadCoilsAboveRegisterLimitIsValid) { + const uint16_t quantity = MAX_NUM_OF_REGISTERS_TO_READ + 1; // 126: valid for coils, too many for registers + auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, quantity); + const std::vector expected{0x01, 0x00, 0x00, static_cast(quantity >> 8), + static_cast(quantity & 0xFF)}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, ReadCoilsOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, MAX_NUM_OF_COILS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ReadDiscreteInputsOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_DISCRETE_INPUTS, 0x0000, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) { + const uint8_t values[] = {0x00, 0x0B}; + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_WRITE + 1, values, + sizeof(values)); + EXPECT_TRUE(pdu.empty()); +} + TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { const std::vector data{0x12, 0x34}; EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); diff --git a/tests/components/modbus/modbus_test.cpp b/tests/components/modbus/modbus_test.cpp deleted file mode 100644 index afe5ced082..0000000000 --- a/tests/components/modbus/modbus_test.cpp +++ /dev/null @@ -1,59 +0,0 @@ -#include -#include "esphome/components/modbus/modbus.h" -#include "esphome/core/helpers.h" - -namespace esphome::modbus { - -// Exposes protected methods for testing. -class TestModbus : public Modbus { - public: - bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); } - void test_clear_rx_buffer() { this->rx_buffer_.clear(); } - void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; } -}; - -class MockDevice : public ModbusDevice { - public: - void on_modbus_data(const std::vector &data) override { this->data_received = true; } - bool data_received{false}; -}; - -TEST(ModbusTest, TwoByteRegressionTest) { - TestModbus modbus; - modbus.set_role(ModbusRole::CLIENT); - // First byte (at=0) - EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01)); - // Second byte (at=1) - // This used to reach raw[2] because it skipped the if(at==2) check, causing a - // buffer overflow. - EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03)); -} - -TEST(ModbusTest, TestValidFrame) { - TestModbus modbus; - modbus.set_role(ModbusRole::CLIENT); - - MockDevice device; - device.set_parent(&modbus); - device.set_address(0x01); - modbus.register_device(&device); - modbus.set_waiting(0x01); - - // Address 1, Function 3, Length 2, Data 0x1234 - uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34}; - uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data)); - - std::vector frame; - for (uint8_t b : frame_data) - frame.push_back(b); - frame.push_back(crc & 0xFF); - frame.push_back((crc >> 8) & 0xFF); - - for (size_t i = 0; i < frame.size(); i++) { - bool result = modbus.test_parse_modbus_byte(frame[i]); - EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")"; - } - EXPECT_TRUE(device.data_received); -} - -} // namespace esphome::modbus diff --git a/tests/integration/fixtures/uart_mock_modbus.yaml b/tests/integration/fixtures/uart_mock_modbus.yaml index da36da4de1..7e2bcff3ef 100644 --- a/tests/integration/fixtures/uart_mock_modbus.yaml +++ b/tests/integration/fixtures/uart_mock_modbus.yaml @@ -49,15 +49,16 @@ modbus_controller: - address: 1 id: modbus_controller_ok max_cmd_retries: 2 - update_interval: 1s + # Update interval is set to never to prevent automatic polling: the test will trigger requests by pressing the "Start Scenario" button + update_interval: never - address: 2 id: modbus_controller_slow max_cmd_retries: 0 - update_interval: 1s + update_interval: never - address: 3 id: modbus_controller_offline max_cmd_retries: 0 - update_interval: 1s + update_interval: never sensor: - platform: modbus_controller @@ -91,4 +92,11 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(modbus_controller_ok).set_update_interval(1000); + id(modbus_controller_ok).start_poller(); + id(modbus_controller_slow).set_update_interval(1000); + id(modbus_controller_slow).start_poller(); + id(modbus_controller_offline).set_update_interval(1000); + id(modbus_controller_offline).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml index 9bc4dc50e9..5a7c9b74dc 100644 --- a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml @@ -54,7 +54,11 @@ modbus: sensor: - platform: sdm_meter address: 2 - update_interval: 1s + id: sdm_meter_1 + # update_interval is set to never to avoid automatic polling before the test starts the scenario. + # The test will manually start the poller after subscribing to states, to ensure no state changes are missed. + # This also allows us to assert there are no modbus errors/warnings during the initial request/response. + update_interval: never phase_a: voltage: name: sdm_voltage @@ -64,4 +68,7 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(sdm_meter_1).set_update_interval(1000); + id(sdm_meter_1).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml index 1e5f5a3389..20306bd73a 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -53,8 +53,8 @@ modbus: modbus_controller: - address: 1 modbus_id: virtual_modbus_controller - update_interval: 1s id: modbus_controller_1 + update_interval: 1s modbus_server: - address: 1 @@ -176,6 +176,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml index e68edd2271..18423be6d5 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml @@ -113,7 +113,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_server_2).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml index 94890e90de..b3b5e76e31 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -326,6 +326,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_timing.yaml b/tests/integration/fixtures/uart_mock_modbus_timing.yaml index c670864085..c62e0188bb 100644 --- a/tests/integration/fixtures/uart_mock_modbus_timing.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_timing.yaml @@ -53,7 +53,11 @@ modbus: sensor: - platform: sdm_meter address: 2 - update_interval: 1s + id: sdm_meter_1 + # update_interval is set to never to avoid automatic polling before the test starts the scenario. + # The test will manually start the poller after subscribing to states, to ensure no state changes are missed. + # This also allows us to assert there are no modbus errors/warnings during the initial request/response. + update_interval: never phase_a: voltage: name: sdm_voltage @@ -63,4 +67,7 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(sdm_meter_1).set_update_interval(1000); + id(sdm_meter_1).start_poller(); diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index e8dfa1b822..2c437341c6 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -127,15 +127,18 @@ async def test_uart_mock_modbus_timing( ) -> None: """Test modbus timing with multi-register SDM meter response.""" + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + tracker = SensorTracker(["sdm_voltage"]) voltage_changed = tracker.expect_any("sdm_voltage") async with ( - run_compiled(yaml_config), + run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) await tracker.await_change(voltage_changed, "sdm_voltage") + _assert_no_modbus_errors(error_log_lines, warning_log_lines) @pytest.mark.asyncio @@ -148,26 +151,25 @@ async def test_uart_mock_modbus_no_threshold( Without the 50ms fallback timeout, the chunked response with a 40ms gap between USB packets would cause a false timeout and CRC failure cascade. - Bus-level warnings (CRC failures, buffer clears) are expected during - chunked reassembly — the test only verifies the final value arrives. + Bus-level warnings (CRC/parse failures, buffer clears) are NOT expected during + chunked reassembly, if timeouts are set properly — these warnings indicate undersized timeouts. """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + tracker = SensorTracker(["sdm_voltage"]) voltage_changed = tracker.expect_any("sdm_voltage") async with ( - run_compiled(yaml_config), + run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) await tracker.await_change(voltage_changed, "sdm_voltage") + _assert_no_modbus_errors(error_log_lines, warning_log_lines) @pytest.mark.asyncio -@pytest.mark.xfail( - reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", - strict=True, -) async def test_uart_mock_modbus_server( yaml_config: str, run_compiled: RunCompiledFunction, @@ -308,10 +310,6 @@ async def test_uart_mock_modbus_server_controller_write( @pytest.mark.asyncio -@pytest.mark.xfail( - reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", - strict=True, -) async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, run_compiled: RunCompiledFunction, From 1bd937d89c91050a9f77a735b2d31f54e1e7d327 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:44:00 -0500 Subject: [PATCH 1746/2214] [api] Remove pre-1.14 object_id backward-compat code (#17108) --- esphome/components/api/api_connection.cpp | 12 +----------- esphome/components/api/api_connection.h | 12 +----------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2b1458e2ae..acdf24e747 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -375,7 +375,7 @@ void APIConnection::finalize_iterator_sync_() { void APIConnection::process_iterator_batch_(ComponentIterator &iterator) { size_t initial_size = this->deferred_batch_.size(); - size_t max_batch = this->get_max_batch_size_(); + size_t max_batch = MAX_INITIAL_PER_BATCH; while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) { iterator.advance(); } @@ -418,16 +418,6 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp // Set common fields that are shared by all entity types msg.key = entity->get_object_id_hash(); - // API 1.14+ clients compute object_id client-side from the entity name - // For older clients, we must send object_id for backward compatibility - // See: https://github.com/esphome/backlog/issues/76 - // TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then - // Buffer must remain in scope until encode_to_buffer is called - char object_id_buf[OBJECT_ID_MAX_LEN]; - if (!conn->client_supports_api_version(1, 14)) { - msg.object_id = entity->get_object_id_to(object_id_buf); - } - if (entity->has_own_name()) { msg.name = entity->get_name(); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 804cd9ddd1..92f7065730 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -43,10 +43,7 @@ class APIServer; // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending -// API 1.14+ clients compute object_id client-side, so messages are smaller and we can fit more per batch -// TODO: Remove MAX_INITIAL_PER_BATCH_LEGACY before 2026.7.0 - all clients should support API 1.14 by then -static constexpr size_t MAX_INITIAL_PER_BATCH_LEGACY = 24; // For clients < API 1.14 (includes object_id) -static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= API 1.14 (no object_id) +static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); @@ -481,13 +478,6 @@ class APIConnection final : public APIServerConnectionBase { inline bool check_voice_assistant_api_connection_() const; #endif - // Get the max batch size based on client API version - // API 1.14+ clients don't receive object_id, so messages are smaller and more fit per batch - // TODO: Remove this method before 2026.7.0 and use MAX_INITIAL_PER_BATCH directly - size_t get_max_batch_size_() const { - return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY; - } - // Send keepalive ping or disconnect unresponsive client. // Cold path — extracted from loop() to reduce instruction cache pressure. void __attribute__((noinline)) check_keepalive_(uint32_t now); From 21aee91e6799164c39da486f45a7e971dde03496 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:45:03 -0500 Subject: [PATCH 1747/2214] [web_server] Remove deprecated object ID URL matching (#17113) --- esphome/components/web_server/web_server.cpp | 29 +------------------- esphome/components/web_server/web_server.h | 2 +- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 909a27c81c..cdb8544fbb 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -164,36 +164,9 @@ EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const { } #endif - // Try matching by entity name (new format) + // Match by entity name if (this->id == entity->get_name()) { result.matched = true; - return result; - } - - // Fall back to object_id (deprecated format) - char object_id_buf[OBJECT_ID_MAX_LEN]; - StringRef object_id = entity->get_object_id_to(object_id_buf); - if (this->id == object_id) { - result.matched = true; - // Log deprecation warning -#ifdef USE_DEVICES - Device *device = entity->get_device(); - if (device != nullptr) { - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s/%.*s - use entity name '/%.*s/%s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - (int) this->domain.size(), this->domain.c_str(), (int) this->device_name.size(), - this->device_name.c_str(), (int) this->id.size(), this->id.c_str(), (int) this->domain.size(), - this->domain.c_str(), device->get_name(), entity->get_name().c_str()); - } else -#endif - { - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - (int) this->domain.size(), this->domain.c_str(), (int) this->id.size(), this->id.c_str(), - (int) this->domain.size(), this->domain.c_str(), entity->get_name().c_str()); - } } return result; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 25f8f8212d..e4defdbd9a 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -76,7 +76,7 @@ struct UrlMatch { bool method_equals(const __FlashStringHelper *str) const { return this->method == str; } #endif - /// Match entity by name first, then fall back to object_id with deprecation warning + /// Match entity by name /// Returns EntityMatchResult with match status and whether action segment is empty EntityMatchResult match_entity(EntityBase *entity) const; }; From 7c2603d9bc764c0ff10053b81bf5edc48d9c90eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:47:15 -0500 Subject: [PATCH 1748/2214] [ethernet] Defer clk_mode removal to 2026.9.0 (#17114) --- esphome/components/ethernet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index f6afc30ff2..6af68e4e3c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -324,7 +324,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.7.0.", + "Removal scheduled for 2026.9.0.", config[CONF_CLK_MODE], mode, pin, From 03121d2efe6744df4ae3176a40f9c259f1e5162c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:49:27 -0500 Subject: [PATCH 1749/2214] [core] Remove deprecated std::string GPIOPin::dump_summary() (#17115) --- esphome/core/gpio.h | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/esphome/core/gpio.h b/esphome/core/gpio.h index f2f85e18bc..43db3b7c0c 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -1,8 +1,6 @@ #pragma once #include #include -#include -#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -80,11 +78,6 @@ class GPIOPin { /// which may exceed len-1 if truncation occurred (snprintf semantics) virtual size_t dump_summary(char *buffer, size_t len) const; - /// Get a summary of this pin as a string. - /// @deprecated Use dump_summary(char*, size_t) instead. Will be removed in 2026.7.0. - ESPDEPRECATED("Override dump_summary(char*, size_t) instead. Will be removed in 2026.7.0.", "2026.1.0") - virtual std::string dump_summary() const; - virtual bool is_internal() { return false; } }; @@ -122,28 +115,14 @@ class InternalGPIOPin : public GPIOPin { virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0; }; -// Inline default implementations for GPIOPin virtual methods. -// These provide bridge functionality for backwards compatibility with external components. - -// Default implementation bridges to old std::string method for backwards compatibility. +// Inline default implementation for GPIOPin::dump_summary. +// Writes an empty summary; subclasses override to provide pin details. inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { - if (len == 0) - return 0; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - std::string s = this->dump_summary(); -#pragma GCC diagnostic pop - size_t copy_len = std::min(s.size(), len - 1); - memcpy(buffer, s.c_str(), copy_len); - buffer[copy_len] = '\0'; - return s.size(); // Return would-be length (snprintf semantics) + if (len > 0) + buffer[0] = '\0'; + return 0; } -// Default implementation returns empty string. -// External components should override this if they haven't migrated to buffer-based version. -// Remove before 2026.7.0 -inline std::string GPIOPin::dump_summary() const { return {}; } - // Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { char buffer[GPIO_SUMMARY_MAX_LEN]; From f273221cf47fb2c80c9883fcb7681591e0977312 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:50:32 -0500 Subject: [PATCH 1750/2214] [core] Remove deprecated value_accuracy_to_string() (#17116) --- esphome/core/alloc_helpers.cpp | 8 -------- esphome/core/alloc_helpers.h | 8 -------- 2 files changed, 16 deletions(-) diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index 11c7abe3f7..27c50ebb2a 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -86,14 +86,6 @@ std::string str_sprintf(const char *fmt, ...) { return str; } -// --- Value formatting helpers --- - -std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { - char buf[VALUE_ACCURACY_MAX_LEN]; - value_accuracy_to_buf(buf, value, accuracy_decimals); - return std::string(buf); -} - // --- Base64 helpers --- static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" diff --git a/esphome/core/alloc_helpers.h b/esphome/core/alloc_helpers.h index fe350886b7..1da3162333 100644 --- a/esphome/core/alloc_helpers.h +++ b/esphome/core/alloc_helpers.h @@ -94,14 +94,6 @@ std::string format_hex_pretty(const std::string &data, char separator = '.', boo /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. std::string format_bin(const uint8_t *data, size_t length); -// --- Value formatting helpers (allocating) --- - -/// Format a float value with accuracy decimals to a string. -/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0. -__attribute__((deprecated("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0."))) -std::string -value_accuracy_to_string(float value, int8_t accuracy_decimals); - // --- Base64 helpers (allocating) --- /// Encode a byte buffer to base64 string. From d1d77fc217e51af4291ba63e33a2281ac35e5a79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:51:08 -0500 Subject: [PATCH 1751/2214] [remote_base] Remove deprecated MideaData::to_string() (#17117) --- esphome/components/remote_base/midea_protocol.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index f21dd40828..47bad6826f 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -28,9 +28,6 @@ class MideaData { bool is_valid() const { return this->data_[OFFSET_CS] == this->calc_cs_(); } void finalize() { this->data_[OFFSET_CS] = this->calc_cs_(); } bool is_compliment(const MideaData &rhs) const; - /// @deprecated Allocates heap memory. Use to_str() instead. Removed in 2026.7.0. - ESPDEPRECATED("Allocates heap memory. Use to_str() instead. Removed in 2026.7.0.", "2026.1.0") - std::string to_string() const { return format_hex_pretty(this->data_.data(), this->data_.size()); } // NOLINT /// Buffer size for to_str(): 6 bytes = "AA.BB.CC.DD.EE.FF\0" static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(6); /// Format to buffer, returns pointer to buffer From d8f883bd9d37399c9528ff39f47f6f7d92f85df9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:54:05 -0500 Subject: [PATCH 1752/2214] [core] Remove deprecated get_object_id() and get_compilation_time() (#17112) --- esphome/core/application.h | 9 --------- esphome/core/entity_base.cpp | 7 ------- esphome/core/entity_base.h | 12 ------------ esphome/core/entity_helpers.py | 2 +- tests/unit_tests/core/test_entity_helpers.py | 9 +++++---- 5 files changed, 6 insertions(+), 33 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 7c12a66b2c..76af514511 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -194,15 +194,6 @@ class Application { /// Buffer must be BUILD_TIME_STR_SIZE bytes (compile-time enforced) void get_build_time_string(std::span buffer); - /// Get the build time as a string (deprecated, use get_build_time_string() instead) - // Remove before 2026.7.0 - ESPDEPRECATED("Use get_build_time_string() instead. Removed in 2026.7.0", "2026.1.0") - std::string get_compilation_time() { - char buf[BUILD_TIME_STR_SIZE]; - this->get_build_time_string(buf); - return std::string(buf); - } - /// Get the cached time in milliseconds from when the current component started its loop execution inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index a47af1dd93..32135860bb 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -147,13 +147,6 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Entity Object ID - computed on-demand from name -std::string EntityBase::get_object_id() const { - char buf[OBJECT_ID_MAX_LEN]; - size_t len = this->write_object_id_to(buf, sizeof(buf)); - return std::string(buf, len); -} - // Calculate Object ID Hash directly from name using snake_case + sanitize void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 2726a92c97..4f708209d4 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,18 +73,6 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the sanitized name of this Entity as an ID. - // Deprecated: object_id mangles names and all object_id methods are planned for removal. - // See https://github.com/esphome/backlog/issues/76 - // Now is the time to stop using object_id entirely. If you still need it temporarily, - // use get_object_id_to() which will remain available longer but will also eventually be removed. - ESPDEPRECATED("object_id mangles names and all object_id methods are planned for removal " - "(see https://github.com/esphome/backlog/issues/76). " - "Now is the time to stop using object_id. If still needed, use get_object_id_to() " - "which will remain available longer. get_object_id() will be removed in 2026.7.0", - "2025.12.0") - std::string get_object_id() const; - // Get the unique Object ID of this Entity uint32_t get_object_id_hash() const { return this->object_id_hash_; } diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index ff60260280..38c7f3ca43 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -337,7 +337,7 @@ def get_base_entity_object_id( This function calculates what object_id_c_str_ should be set to in C++. - The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: - If !has_own_name && is_name_add_mac_suffix_enabled(): return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic - Else: diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index e79ff850f9..3ac4ce27af 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -174,10 +174,11 @@ def test_empty_name_fallback() -> None: def test_name_add_mac_suffix_behavior() -> None: """Test behavior related to name_add_mac_suffix. - In C++, when name_add_mac_suffix is enabled and entity has no name, - get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name())) - dynamically. Our function always returns the same result since we're - calculating the base for duplicate tracking. + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. """ # The function should always return the same result regardless of # name_add_mac_suffix setting, as we're calculating the base object_id From 78c6131bbf1c57eac765507fe274f99c9d716fc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:36 -0500 Subject: [PATCH 1753/2214] [web_server] Deprecate version 1 (#17109) --- esphome/components/web_server/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index fd380a38dd..788bedec34 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import gzip +import logging import esphome.codegen as cg from esphome.components import web_server_base @@ -38,6 +39,8 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["json", "web_server_base"] CONF_SORTING_GROUP_ID = "sorting_group_id" @@ -71,6 +74,15 @@ def default_url(config: ConfigType) -> ConfigType: return config +def validate_version_deprecated(config: ConfigType) -> ConfigType: + if config[CONF_VERSION] == 1: + _LOGGER.warning( + "Version 1 of 'web_server' is deprecated and will be removed in " + "2027.1.0. Please migrate to version 2 (the default) or version 3." + ) + return config + + def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") @@ -220,6 +232,7 @@ CONFIG_SCHEMA = cv.All( ] ), default_url, + validate_version_deprecated, validate_local, validate_sorting_groups, validate_ota, From c6ead57a9ef7a74556da54da6e2b0ebd93fac729 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:46 -0500 Subject: [PATCH 1754/2214] [packages] Remove deprecated single-package include syntax (#17119) --- esphome/components/packages/__init__.py | 61 +------- .../component_tests/packages/test_packages.py | 140 ++---------------- 2 files changed, 18 insertions(+), 183 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index c1c5bd2ae3..44a1ebf36e 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,7 +1,6 @@ from collections import UserDict from collections.abc import Callable from functools import reduce -import logging from pathlib import Path from typing import Any @@ -36,8 +35,6 @@ from esphome.const import ( ) from esphome.core import EsphomeError -_LOGGER = logging.getLogger(__name__) - DOMAIN = CONF_PACKAGES # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -53,18 +50,6 @@ def is_remote_package(package_config: dict) -> bool: return CONF_URL in package_config -def is_package_definition(value: object) -> bool: - """Returns True if the value looks like a package definition rather than a config fragment. - - Package definitions are IncludeFile objects, git URL shorthand strings, or - remote package dicts (containing a ``url:`` key). Config fragments are - plain dicts that represent component configuration. - """ - return isinstance(value, (yaml_util.IncludeFile, str)) or ( - isinstance(value, dict) and is_remote_package(value) - ) - - def valid_package_contents(package_config: dict) -> dict: """Validate that a package looks like a plausible ESPHome config fragment. @@ -134,22 +119,6 @@ def validate_source_shorthand(value): return REMOTE_PACKAGE_SCHEMA(conf) -def deprecate_single_package(config: dict) -> dict: - _LOGGER.warning( - """ - Including a single package under `packages:`, i.e., `packages: !include mypackage.yaml` is deprecated. - This method for including packages will go away in 2026.7.0 - Please use a list instead: - - packages: - - !include mypackage.yaml - - See https://github.com/esphome/esphome/pull/12116 - """ - ) - return config - - REMOTE_PACKAGE_SCHEMA = cv.All( cv.Schema( { @@ -198,10 +167,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either: str: PACKAGE_SCHEMA, # a named dict of package definitions, or } ), - [PACKAGE_SCHEMA], # a list of package definitions, or - cv.All( # a single package definition (deprecated) - cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package - ), + [PACKAGE_SCHEMA], # a list of package definitions ) @@ -348,7 +314,6 @@ def _walk_packages( config: dict, callback: PackageCallback, context: ContextVars | None = None, - validate_deprecated: bool = True, path: yaml_util.DocumentPath | None = None, ) -> dict: """Walks the packages structure in priority order, invoking ``callback`` on each package definition found. @@ -378,17 +343,7 @@ def _walk_packages( elif ( result := _walk_package_dict(packages, callback, context, packages_path) ) is not None: - if not validate_deprecated or any( - is_package_definition(v) for v in packages.values() - ): - raise result - # Fallback: treat the dict as a single deprecated package. - # This block can be removed once the single-package - # deprecation period (2026.7.0) is over. - config[CONF_PACKAGES] = [packages] - return _walk_packages( - deprecate_single_package(config), callback, context, path=path - ) + raise result config[CONF_PACKAGES] = packages return config @@ -588,9 +543,6 @@ class _PackageProcessor: path: yaml_util.DocumentPath, ) -> dict: """Resolve a single package and recurse into any nested packages.""" - from_remote = isinstance(package_config, dict) and is_remote_package( - package_config - ) package_config = self.resolve_package(package_config, context_vars, path) context_vars = self.collect_substitutions(package_config, context_vars) @@ -600,17 +552,10 @@ class _PackageProcessor: # Push context from !include vars on the packages key (the package root # was already pushed in collect_substitutions above). context_vars = push_context(package_config[CONF_PACKAGES], context_vars) - # Disable the deprecated single-package fallback for remote - # packages. _process_remote_package returns dicts with - # already-resolved values that is_package_definition cannot - # distinguish from config fragments, so the fallback would - # always fire and mask real errors with wrong paths - # (packages->0 instead of packages->). return _walk_packages( package_config, self.process_package, context_vars, - validate_deprecated=not from_remote, path=path, ) @@ -673,7 +618,7 @@ def merge_packages(config: dict) -> dict: merge_list.append(package_config) return _walk_packages(package_config, process_package_callback, path=path) - _walk_packages(config, process_package_callback, validate_deprecated=False) + _walk_packages(config, process_package_callback) # Merge all packages into the main config: config = reduce(lambda new, old: merge_config(old, new), merge_list, config) del config[CONF_PACKAGES] diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 66f946a5bd..6990c1c051 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -12,7 +12,6 @@ from esphome.components.packages import ( _substitute_package_definition, _walk_packages, do_packages_pass, - is_package_definition, merge_packages, resolve_packages, ) @@ -89,44 +88,6 @@ def packages_pass(config): return config -_INCLUDE_FILE = "INCLUDE_FILE" - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - # IncludeFile objects are package definitions - (_INCLUDE_FILE, True), - # Git URL shorthand strings are package definitions - ("github://esphome/firmware/base.yaml@main", True), - # Remote package dicts (with url key) are package definitions - ({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True), - # Plain config dicts are NOT package definitions (they are config fragments) - ({"wifi": {"ssid": "test"}}, False), - # None is not a package definition - (None, False), - # Lists are not package definitions - ([{"wifi": {"ssid": "test"}}], False), - # Empty dicts are not package definitions - ({}, False), - ], - ids=[ - "include_file", - "git_shorthand", - "remote_package", - "config_fragment", - "none", - "list", - "empty_dict", - ], -) -def test_is_package_definition(value: object, expected: bool) -> None: - """Test that is_package_definition correctly identifies package definitions.""" - if value is _INCLUDE_FILE: - value = MagicMock(spec=IncludeFile) - assert is_package_definition(value) is expected - - def test_package_unused(basic_esphome, basic_wifi) -> None: """ Ensures do_package_pass does not change a config if packages aren't used. @@ -210,30 +171,6 @@ def test_package_include(basic_wifi, basic_esphome) -> None: assert actual == expected -def test_single_package( - basic_esphome, - basic_wifi, - caplog: pytest.LogCaptureFixture, -) -> None: - """ - Tests the simple case where a single package is added to the top-level config as is. - In this test, the CONF_WIFI config is expected to be simply added to the top-level config. - This tests the case where the user just put packages: !include package.yaml, not - part of a list or mapping of packages. - This behavior is deprecated, the test also checks if a warning is issued. - """ - config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: {CONF_WIFI: basic_wifi}} - - expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi} - - with caplog.at_level("WARNING"): - actual = packages_pass(config) - - assert actual == expected - - assert "This method for including packages will go away in 2026.7.0" in caplog.text - - def test_package_append(basic_wifi, basic_esphome) -> None: """ Tests the case where a key is present in both a package and top-level config. @@ -1154,6 +1091,10 @@ def test_packages_include_file_resolves_to_invalid_type_raises( 6, "some string", True, + None, + ["some string"], + {"some_component": 8}, + {3: 2}, ], ) def test_invalid_package_contents_rejected(invalid_package: object) -> None: @@ -1167,28 +1108,15 @@ def test_invalid_package_contents_rejected(invalid_package: object) -> None: do_packages_pass(config) -@pytest.mark.xfail( - reason="Deprecated single-package fallback swallows these errors. " - "Remove xfail when single-package deprecation is removed (2026.7.0).", - strict=True, -) -@pytest.mark.parametrize( - "invalid_package", - [ - None, - ["some string"], - {"some_component": 8}, - {3: 2}, - ], -) -def test_invalid_package_contents_masked_by_deprecation( - invalid_package: object, -) -> None: - """These invalid packages are swallowed by the deprecated single-package fallback.""" +def test_single_package_fragment_form_rejected() -> None: + """The deprecated single-package form is removed and now raises. + + Previously ``packages: !include some_package.yaml`` resolving to a bare config + fragment dict was silently wrapped and merged via the single-package fallback. + That form must now raise instead of being accepted. + """ config = { - CONF_PACKAGES: { - "some_package": invalid_package, - }, + CONF_PACKAGES: {CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}}, } with pytest.raises(cv.Invalid): do_packages_pass(config) @@ -1231,14 +1159,10 @@ def test_named_dict_with_include_files_no_false_deprecation_warning( assert "deprecated" not in caplog.text.lower() -def test_validate_deprecated_false_raises_directly( +def test_named_package_errors_raise_directly( caplog: pytest.LogCaptureFixture, ) -> None: - """With validate_deprecated=False, errors raise directly without fallback. - - This is the codepath used for remote packages where _process_remote_package - returns already-resolved dicts that is_package_definition cannot detect. - """ + """Errors processing a named-dict package raise directly, with no deprecation warning.""" config = { CONF_PACKAGES: { "pkg_a": {CONF_WIFI: {CONF_SSID: "test"}}, @@ -1261,7 +1185,7 @@ def test_validate_deprecated_false_raises_directly( caplog.at_level(logging.WARNING), pytest.raises(cv.Invalid, match="nested error"), ): - _walk_packages(config, failing_callback, validate_deprecated=False) + _walk_packages(config, failing_callback) assert "deprecated" not in caplog.text.lower() @@ -1296,40 +1220,6 @@ def test_error_on_first_declared_package_still_detected() -> None: _walk_packages(config, fail_on_last) -def test_deprecated_single_package_fallback_still_works( - caplog: pytest.LogCaptureFixture, -) -> None: - """The deprecated single-package form still falls back at the top level. - - When a dict's values are plain config fragments (not package definitions) - and the callback fails, the deprecated fallback wraps the dict in a list - and retries with a deprecation warning. - """ - config = { - CONF_PACKAGES: { - CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}, - }, - } - - attempt = 0 - - def fail_then_succeed( - package_config: dict, context: object, path: DocumentPath | None = None - ) -> dict: - nonlocal attempt - attempt += 1 - if attempt == 1: - # First attempt: treating as named dict fails - raise cv.Invalid("not a valid package") - # Second attempt: after fallback wraps as list, succeeds - return package_config - - with caplog.at_level(logging.WARNING): - _walk_packages(config, fail_then_succeed) - - assert "deprecated" in caplog.text.lower() - - def test_merge_packages_invalid_nested_type_raises() -> None: """Invalid nested packages type during merge raises cv.Invalid.""" config = { From 921758f87dcdaf12cb40d12f1c5443094b5fc4e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:55 -0500 Subject: [PATCH 1755/2214] [core] Clarify resolve error when a device has no network log/OTA transport (#17107) --- esphome/__main__.py | 39 +++++++++++++++++++----- tests/unit_tests/test_main.py | 56 +++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index bda3dcbd05..680de02201 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -268,6 +268,36 @@ def _ota_hostnames_for_default(purpose: Purpose) -> list[str]: return _resolve_with_cache(CORE.address, purpose) +def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: + """Build the error when a default device target produced no usable host. + + When the OTA default was requested and the address resolves but the config + lacks the transport the purpose needs (``api:`` for logs, an ``ota:`` + platform for uploads), name that gap instead of the misleading + "could not be resolved" / set-use_address hint. + """ + if "OTA" in defaults and has_resolvable_address(): + if purpose == Purpose.LOGGING and not has_api(): + return ( + "Cannot view logs over the network: no 'api:' component is " + "configured. Network log streaming requires the native API; add " + "an 'api:' component, enable MQTT logging, or view logs over USB." + ) + if purpose == Purpose.UPLOADING and not has_ota(): + return ( + "Cannot upload over the network: no 'ota:' platform is " + "configured. Add an 'ota:' platform, or upload over USB." + ) + if CORE.dashboard: + hint = "If you know the IP, set 'use_address' in your network config." + else: + hint = "If you know the IP, try --device " + return ( + f"All specified devices {defaults} could not be resolved. " + f"Is the device connected to the network? {hint}" + ) + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -317,14 +347,7 @@ def choose_upload_log_host( else: resolved.append(device) if not resolved: - if CORE.dashboard: - hint = "If you know the IP, set 'use_address' in your network config." - else: - hint = "If you know the IP, try --device " - raise EsphomeError( - f"All specified devices {defaults} could not be resolved. " - f"Is the device connected to the network? {hint}" - ) + raise EsphomeError(_unresolved_default_error(purpose, defaults)) return resolved # No devices specified, show interactive chooser diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index acd39cedc6..bb06b6c930 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, choose_upload_log_host, @@ -713,9 +714,7 @@ def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") - with pytest.raises( - EsphomeError, match="All specified devices .* could not be resolved" - ): + with pytest.raises(EsphomeError, match="no 'ota:' platform is configured"): choose_upload_log_host( default="OTA", check_default=None, @@ -735,6 +734,57 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: + """A resolvable device with only ota: fails logs with a missing-api message.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_choose_upload_log_host_logging_no_transport_reports_missing_api() -> None: + """A resolvable device with neither api: nor MQTT logging fails clearly.""" + setup_core(address="192.168.1.100") + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: + """A .local host with mDNS disabled and no cache keeps the dashboard hint.""" + setup_core( + config={CONF_API: {}, CONF_MDNS: {CONF_DISABLED: True}}, + address="esp32-a1s.local", + ) + CORE.dashboard = True + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "could not be resolved" in msg + assert "set 'use_address'" in msg + + +def test_unresolved_default_error_upload_with_ota_is_generic() -> None: + """With ota: present the upload error stays generic, not transport-specific.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + CORE.dashboard = False + + msg = _unresolved_default_error(Purpose.UPLOADING, ["OTA"]) + assert "could not be resolved" in msg + assert "try --device " in msg + + @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: """Test OTA device fallback to MQTT when no OTA/API config.""" From 036768c399ae88c0c37b2ac0cf1d26f6b72538f6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 21 Jun 2026 16:19:13 -0400 Subject: [PATCH 1756/2214] [audio] Fix mono channel MP3 playback (#17106) --- esphome/components/audio/audio_decoder.cpp | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index f709c23fb6..fe9ad9c9ad 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -326,14 +326,8 @@ FileDecoderState AudioDecoder::decode_mp3_() { } else if (result == micro_mp3::MP3_NEED_MORE_DATA) { return FileDecoderState::MORE_TO_PROCESS; } else if (result == micro_mp3::MP3_OUTPUT_BUFFER_TOO_SMALL) { - // Reallocate to decode the frame on the next call - if (this->mp3_decoder_->get_channels() > 0) { - this->free_buffer_required_ = - this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t); - } else { - // Fallback to worst-case size if channel info isn't available - this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes(); - } + // Fallback to worst-case size + this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes(); if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) { return FileDecoderState::FAILED; } From 6c10fc1272ae17befe1f4b1275918f73791a1455 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:21:50 -0500 Subject: [PATCH 1757/2214] [hub75] Remove deprecated scan_wiring name aliases (#17118) --- esphome/components/hub75/display.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 0d1b87941d..a404fbbade 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -1,4 +1,3 @@ -import logging from typing import Any from esphome import automation, pins @@ -27,8 +26,6 @@ from esphome.types import ConfigType from . import boards, hub75_ns -_LOGGER = logging.getLogger(__name__) - DEPENDENCIES = ["esp32"] CODEOWNERS = ["@stuartparmenter"] @@ -133,30 +130,11 @@ SCAN_WIRINGS = { "SCAN_1_8_64PX_HIGH": Hub75ScanWiring.SCAN_1_8_64PX_HIGH, } -# Deprecated scan wiring names - mapped to new names -DEPRECATED_SCAN_WIRINGS = { - "FOUR_SCAN_16PX_HIGH": "SCAN_1_4_16PX_HIGH", - "FOUR_SCAN_32PX_HIGH": "SCAN_1_8_32PX_HIGH", - "FOUR_SCAN_64PX_HIGH": "SCAN_1_8_64PX_HIGH", -} - def _validate_scan_wiring(value): - """Validate scan_wiring with deprecation warnings for old names.""" + """Validate scan_wiring against the allowed names.""" value = cv.string(value).upper().replace(" ", "_") - # Check if using deprecated name - # Remove deprecated names in 2026.7.0 - if value in DEPRECATED_SCAN_WIRINGS: - new_name = DEPRECATED_SCAN_WIRINGS[value] - _LOGGER.warning( - "Scan wiring '%s' is deprecated and will be removed in ESPHome 2026.7.0. " - "Please use '%s' instead.", - value, - new_name, - ) - value = new_name - # Validate against allowed values if value not in SCAN_WIRINGS: raise cv.Invalid( From c4abc5476e11acfe3e81bbe4b3894bb881a34a4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:22:08 -0500 Subject: [PATCH 1758/2214] [core] Remove deprecated std::string scheduler/timer overloads (#17111) --- esphome/core/component.cpp | 40 --- esphome/core/component.h | 78 ++---- esphome/core/scheduler.cpp | 17 -- esphome/core/scheduler.h | 16 +- .../scheduler_bulk_cleanup_component.cpp | 19 +- .../rapid_cancellation_component.cpp | 14 +- .../simultaneous_callbacks_component.cpp | 14 +- .../__init__.py | 21 -- .../string_lifetime_component.cpp | 260 ------------------ .../string_lifetime_component.h | 35 --- .../__init__.py | 21 -- .../string_name_stress_component.cpp | 108 -------- .../string_name_stress_component.h | 20 -- .../integration/fixtures/scheduler_pool.yaml | 12 +- .../fixtures/scheduler_string_lifetime.yaml | 48 ---- .../scheduler_string_name_stress.yaml | 39 --- .../fixtures/scheduler_string_test.yaml | 42 ++- .../test_scheduler_string_lifetime.py | 169 ------------ .../test_scheduler_string_name_stress.py | 116 -------- .../integration/test_scheduler_string_test.py | 2 +- 20 files changed, 74 insertions(+), 1017 deletions(-) delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h delete mode 100644 tests/integration/fixtures/scheduler_string_lifetime.yaml delete mode 100644 tests/integration/fixtures/scheduler_string_name_stress.yaml delete mode 100644 tests/integration/test_scheduler_string_lifetime.py delete mode 100644 tests/integration/test_scheduler_string_name_stress.py diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 7ef5ff50a5..281d7aaecd 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -85,24 +85,10 @@ void Component::setup() {} void Component::loop() {} -void Component::set_interval(const std::string &name, uint32_t interval, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_interval(this, name, interval, std::move(f)); -#pragma GCC diagnostic pop -} - void Component::set_interval(const char *name, uint32_t interval, std::function &&f) { // NOLINT App.scheduler.set_interval(this, name, interval, std::move(f)); } -bool Component::cancel_interval(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_interval(this, name); -#pragma GCC diagnostic pop -} - bool Component::cancel_interval(const char *name) { // NOLINT return App.scheduler.cancel_interval(this, name); } @@ -137,24 +123,10 @@ bool Component::cancel_retry(const char *name) { // NOLINT #pragma GCC diagnostic pop } -void Component::set_timeout(const std::string &name, uint32_t timeout, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_timeout(this, name, timeout, std::move(f)); -#pragma GCC diagnostic pop -} - void Component::set_timeout(const char *name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } -bool Component::cancel_timeout(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_timeout(this, name); -#pragma GCC diagnostic pop -} - bool Component::cancel_timeout(const char *name) { // NOLINT return App.scheduler.cancel_timeout(this, name); } @@ -319,21 +291,9 @@ void Component::reset_to_construction_state() { void Component::defer(std::function &&f) { // NOLINT App.scheduler.set_timeout(this, static_cast(nullptr), 0, std::move(f)); } -bool Component::cancel_defer(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_timeout(this, name); -#pragma GCC diagnostic pop -} bool Component::cancel_defer(const char *name) { // NOLINT return App.scheduler.cancel_timeout(this, name); } -void Component::defer(const std::string &name, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_timeout(this, name, 0, std::move(f)); -#pragma GCC diagnostic pop -} void Component::defer(const char *name, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, 0, std::move(f)); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 299a5f72ea..1ae70371a1 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -357,9 +357,9 @@ class Component { /// so once a flag is set, subsequent (potentially different) messages may be suppressed. bool set_status_flag_(uint8_t flag); - /** Set an interval function with a unique name. Empty name means no cancelling possible. + /** Set an interval function with a const char* name. Empty name means no cancelling possible. * - * This will call f every interval ms. Can be cancelled via CancelInterval(). + * This will call f every interval ms. Can be cancelled via cancel_interval(). * Similar to javascript's setInterval(). * * IMPORTANT NOTE: @@ -372,18 +372,6 @@ class Component { * * Note also that the first call to f will not happen immediately, but after a random delay. This is * intended to prevent many interval functions from being called at the same time. - * - * @param name The identifier for this interval function. - * @param interval The interval in ms. - * @param f The function (or lambda) that should be called - * - * @see cancel_interval() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(const std::string &name, uint32_t interval, std::function &&f); // NOLINT - - /** Set an interval function with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. * This means the name should be: @@ -391,7 +379,7 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the scheduled task * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. * * @param name The identifier for this interval function (must have static lifetime) * @param interval The interval in ms @@ -416,12 +404,9 @@ class Component { * @param name The identifier for this interval function. * @return Whether an interval functions was deleted. */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_interval(const std::string &name); // NOLINT - bool cancel_interval(const char *name); // NOLINT - bool cancel_interval(uint32_t id); // NOLINT - bool cancel_interval(InternalSchedulerID id); // NOLINT + bool cancel_interval(const char *name); // NOLINT + bool cancel_interval(uint32_t id); // NOLINT + bool cancel_interval(InternalSchedulerID id); // NOLINT /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. // Remove before 2026.8.0 @@ -458,25 +443,13 @@ class Component { ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(uint32_t id); // NOLINT - /** Set a timeout function with a unique name. + /** Set a timeout function with a const char* name. * * Similar to javascript's setTimeout(). Empty name means no cancelling possible. * * IMPORTANT: Do not rely on this having correct timing. This is only called from - * loop() and therefore can be significantly delay. If you need exact timing please + * loop() and therefore can be significantly delayed. If you need exact timing please * use hardware timers. - * - * @param name The identifier for this timeout function. - * @param timeout The timeout in ms. - * @param f The function (or lambda) that should be called - * - * @see cancel_timeout() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(const std::string &name, uint32_t timeout, std::function &&f); // NOLINT - - /** Set a timeout function with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. * This means the name should be: @@ -484,7 +457,9 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the timeout duration * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. + * + * @see cancel_timeout() * * @param name The identifier for this timeout function (must have static lifetime) * @param timeout The timeout in ms @@ -509,25 +484,13 @@ class Component { * @param name The identifier for this timeout function. * @return Whether a timeout functions was deleted. */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_timeout(const std::string &name); // NOLINT - bool cancel_timeout(const char *name); // NOLINT - bool cancel_timeout(uint32_t id); // NOLINT - bool cancel_timeout(InternalSchedulerID id); // NOLINT - - /** Defer a callback to the next loop() call. - * - * If name is specified and a defer() object with the same name exists, the old one is first removed. - * - * @param name The name of the defer function. - * @param f The callback. - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") - void defer(const std::string &name, std::function &&f); // NOLINT + bool cancel_timeout(const char *name); // NOLINT + bool cancel_timeout(uint32_t id); // NOLINT + bool cancel_timeout(InternalSchedulerID id); // NOLINT /** Defer a callback to the next loop() call with a const char* name. + * + * If name is specified and a defer() object with the same name exists, the old one is first removed. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the deferred task. * This means the name should be: @@ -535,7 +498,7 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the deferred execution * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. * * @param name The name of the defer function (must have static lifetime) * @param f The callback @@ -549,11 +512,8 @@ class Component { void defer(uint32_t id, std::function &&f); // NOLINT /// Cancel a defer callback using the specified name, name must not be empty. - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_defer(const std::string &name); // NOLINT - bool cancel_defer(const char *name); // NOLINT - bool cancel_defer(uint32_t id); // NOLINT + bool cancel_defer(const char *name); // NOLINT + bool cancel_defer(uint32_t id); // NOLINT void status_clear_warning_slow_path_(); void status_clear_error_slow_path_(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 15bb9ea239..9c5557bdfc 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -254,30 +254,16 @@ void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t std::move(func)); } -void HOT Scheduler::set_timeout(Component *component, const std::string &name, uint32_t timeout, - std::function &&func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - timeout, std::move(func)); -} void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function &&func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, std::move(func)); } -bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); -} bool HOT Scheduler::cancel_timeout(Component *component, const char *name) { return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT); } bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); } -void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval, - std::function &&func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - interval, std::move(func)); -} - void HOT Scheduler::set_interval(Component *component, const char *name, uint32_t interval, std::function &&func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval, @@ -287,9 +273,6 @@ void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t int this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, std::move(func)); } -bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); -} bool HOT Scheduler::cancel_interval(Component *component, const char *name) { return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL); } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 378c0fb94b..c7743e5b2a 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -31,11 +31,6 @@ class Scheduler { template friend class DelayAction; public: - // std::string overload - deprecated, use const char* or uint32_t instead - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function &&func); - /** Set a timeout with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. @@ -53,8 +48,6 @@ class Scheduler { static_cast(id), timeout, std::move(func)); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_timeout(Component *component, const std::string &name); bool cancel_timeout(Component *component, const char *name); bool cancel_timeout(Component *component, uint32_t id); bool cancel_timeout(Component *component, InternalSchedulerID id) { @@ -62,9 +55,6 @@ class Scheduler { SchedulerItem::TIMEOUT); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(Component *component, const std::string &name, uint32_t interval, std::function &&func); - /** Set an interval with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. @@ -82,8 +72,6 @@ class Scheduler { static_cast(id), interval, std::move(func)); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_interval(Component *component, const std::string &name); bool cancel_interval(Component *component, const char *name); bool cancel_interval(Component *component, uint32_t id); bool cancel_interval(Component *component, InternalSchedulerID id) { @@ -396,8 +384,8 @@ class Scheduler { inline bool HOT names_match_static_(const char *name1, const char *name2) const { // Check pointer equality first (common for static strings), then string contents // The core ESPHome codebase uses static strings (const char*) for component names, - // making pointer comparison effective. The std::string overloads exist only for - // compatibility with external components but are rarely used in practice. + // making pointer comparison effective. The strcmp fallback covers distinct pointers + // with identical content (e.g. names built into separate static buffers). return (name1 != nullptr && name2 != nullptr) && ((name1 == name2) || (strcmp(name1, name2) == 0)); } diff --git a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp index f6fd1b1de7..d419694af7 100644 --- a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp @@ -8,14 +8,23 @@ static const char *const TAG = "bulk_cleanup"; void SchedulerBulkCleanupComponent::setup() { ESP_LOGI(TAG, "Scheduler bulk cleanup test component loaded"); } +// Static name tables keep the const char* pointers valid for the lifetime of the scheduled tasks. +static const char *const BULK_TIMEOUT_NAMES[25] = { + "bulk_timeout_0", "bulk_timeout_1", "bulk_timeout_2", "bulk_timeout_3", "bulk_timeout_4", + "bulk_timeout_5", "bulk_timeout_6", "bulk_timeout_7", "bulk_timeout_8", "bulk_timeout_9", + "bulk_timeout_10", "bulk_timeout_11", "bulk_timeout_12", "bulk_timeout_13", "bulk_timeout_14", + "bulk_timeout_15", "bulk_timeout_16", "bulk_timeout_17", "bulk_timeout_18", "bulk_timeout_19", + "bulk_timeout_20", "bulk_timeout_21", "bulk_timeout_22", "bulk_timeout_23", "bulk_timeout_24"}; +static const char *const POST_CLEANUP_NAMES[5] = {"post_cleanup_0", "post_cleanup_1", "post_cleanup_2", + "post_cleanup_3", "post_cleanup_4"}; + void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { ESP_LOGI(TAG, "Starting bulk cleanup test..."); // Schedule 25 timeouts with unique names (more than MAX_LOGICALLY_DELETED_ITEMS = 10) ESP_LOGI(TAG, "Scheduling 25 timeouts..."); for (int i = 0; i < 25; i++) { - std::string name = "bulk_timeout_" + std::to_string(i); - App.scheduler.set_timeout(this, name, 2500, [i]() { + App.scheduler.set_timeout(this, BULK_TIMEOUT_NAMES[i], 2500, [i]() { // These should never execute as we'll cancel them ESP_LOGW(TAG, "Timeout %d executed - this should not happen!", i); }); @@ -24,8 +33,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { // Cancel all of them to mark for removal ESP_LOGI(TAG, "Cancelling all 25 timeouts to trigger bulk cleanup..."); int cancelled_count = 0; - for (int i = 0; i < 25; i++) { - std::string name = "bulk_timeout_" + std::to_string(i); + for (const char *name : BULK_TIMEOUT_NAMES) { if (App.scheduler.cancel_timeout(this, name)) { cancelled_count++; } @@ -56,8 +64,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { // Also schedule some normal timeouts to ensure scheduler keeps working after cleanup static int post_cleanup_count = 0; for (int i = 0; i < 5; i++) { - std::string name = "post_cleanup_" + std::to_string(i); - App.scheduler.set_timeout(this, name, 50 + i * 25, [i]() { + App.scheduler.set_timeout(this, POST_CLEANUP_NAMES[i], 50 + i * 25, [i]() { ESP_LOGI(TAG, "Post-cleanup timeout %d executed correctly", i); post_cleanup_count++; if (post_cleanup_count >= 5) { diff --git a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp index 0e5525d265..4971a15dbc 100644 --- a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp @@ -4,12 +4,18 @@ #include #include #include -#include namespace esphome::scheduler_rapid_cancellation_component { static const char *const TAG = "scheduler_rapid_cancellation"; +// Static name table keeps the const char* pointers valid for the lifetime of the scheduled tasks. +// Threads race over this fixed set of names; STATIC_STRING names match by content, so scheduling +// the same name replaces (implicitly cancels) the previous timeout, exactly as before. +static const char *const SHARED_TIMEOUT_NAMES[10] = { + "shared_timeout_0", "shared_timeout_1", "shared_timeout_2", "shared_timeout_3", "shared_timeout_4", + "shared_timeout_5", "shared_timeout_6", "shared_timeout_7", "shared_timeout_8", "shared_timeout_9"}; + void SchedulerRapidCancellationComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerRapidCancellationComponent setup"); } void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { @@ -32,14 +38,12 @@ void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { for (int i = 0; i < OPERATIONS_PER_THREAD; i++) { // Use modulo to ensure multiple threads use the same names int name_index = i % NUM_NAMES; - std::stringstream ss; - ss << "shared_timeout_" << name_index; - std::string name = ss.str(); + const char *name = SHARED_TIMEOUT_NAMES[name_index]; // All threads schedule timeouts - this will implicitly cancel existing ones this->set_timeout(name, 150, [this, name]() { this->total_executed_.fetch_add(1); - ESP_LOGI(TAG, "Executed callback '%s'", name.c_str()); + ESP_LOGI(TAG, "Executed callback '%s'", name); }); this->total_scheduled_.fetch_add(1); diff --git a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp index a817b9f508..a3d135527f 100644 --- a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp @@ -1,9 +1,9 @@ #include "simultaneous_callbacks_component.h" #include "esphome/core/log.h" +#include #include #include #include -#include namespace esphome::scheduler_simultaneous_callbacks_component { @@ -41,13 +41,11 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() std::this_thread::sleep_until(start_time + std::chrono::microseconds(100)); for (int i = 0; i < CALLBACKS_PER_THREAD; i++) { - // Create unique name for each callback - std::stringstream ss; - ss << "thread_" << thread_id << "_cb_" << i; - std::string name = ss.str(); + // Unique numeric ID for each callback (zero heap allocation, no name collisions) + uint32_t callback_id = static_cast(thread_id) * CALLBACKS_PER_THREAD + i; // Schedule callback for exactly DELAY_MS from now - this->set_timeout(name, DELAY_MS, [this, name]() { + this->set_timeout(callback_id, DELAY_MS, [this, callback_id]() { // Increment concurrent counter atomically int current = this->callbacks_at_once_.fetch_add(1) + 1; @@ -57,7 +55,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() // Loop until we successfully update or someone else set a higher value } - ESP_LOGV(TAG, "Callback executed: %s (concurrent: %d)", name.c_str(), current); + ESP_LOGV(TAG, "Callback executed: id=%" PRIu32 " (concurrent: %d)", callback_id, current); // Simulate some minimal work std::atomic work{0}; @@ -73,7 +71,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() }); this->total_scheduled_.fetch_add(1); - ESP_LOGV(TAG, "Scheduled callback %s", name.c_str()); + ESP_LOGV(TAG, "Scheduled callback id=%" PRIu32, callback_id); } ESP_LOGD(TAG, "Thread %d completed scheduling", thread_id); diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py deleted file mode 100644 index 3f29a839ef..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.const import CONF_ID - -scheduler_string_lifetime_component_ns = cg.esphome_ns.namespace( - "scheduler_string_lifetime_component" -) -SchedulerStringLifetimeComponent = scheduler_string_lifetime_component_ns.class_( - "SchedulerStringLifetimeComponent", cg.Component -) - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SchedulerStringLifetimeComponent), - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp deleted file mode 100644 index cc1b9f7814..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp +++ /dev/null @@ -1,260 +0,0 @@ -#include "string_lifetime_component.h" -#include "esphome/core/log.h" -#include -#include -#include - -namespace esphome::scheduler_string_lifetime_component { - -static const char *const TAG = "scheduler_string_lifetime"; - -void SchedulerStringLifetimeComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringLifetimeComponent setup"); } - -void SchedulerStringLifetimeComponent::run_string_lifetime_test() { - ESP_LOGI(TAG, "Starting string lifetime tests"); - - this->tests_passed_ = 0; - this->tests_failed_ = 0; - - // Run each test - test_temporary_string_lifetime(); - test_scope_exit_string(); - test_vector_reallocation(); - test_string_move_semantics(); - test_lambda_capture_lifetime(); -} - -void SchedulerStringLifetimeComponent::run_test1() { - test_temporary_string_lifetime(); - // Wait for all callbacks to execute - this->set_timeout("test1_complete", 10, []() { ESP_LOGI(TAG, "Test 1 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test2() { - test_scope_exit_string(); - // Wait for all callbacks to execute - this->set_timeout("test2_complete", 20, []() { ESP_LOGI(TAG, "Test 2 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test3() { - test_vector_reallocation(); - // Wait for all callbacks to execute - this->set_timeout("test3_complete", 60, []() { ESP_LOGI(TAG, "Test 3 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test4() { - test_string_move_semantics(); - // Wait for all callbacks to execute - this->set_timeout("test4_complete", 35, []() { ESP_LOGI(TAG, "Test 4 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test5() { - test_lambda_capture_lifetime(); - // Wait for all callbacks to execute - this->set_timeout("test5_complete", 50, []() { ESP_LOGI(TAG, "Test 5 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_final_check() { - ESP_LOGI(TAG, "Tests passed: %d", this->tests_passed_); - ESP_LOGI(TAG, "Tests failed: %d", this->tests_failed_); - - if (this->tests_failed_ == 0) { - ESP_LOGI(TAG, "SUCCESS: All string lifetime tests passed!"); - } else { - ESP_LOGE(TAG, "FAILURE: %d string lifetime tests failed!", this->tests_failed_); - } - ESP_LOGI(TAG, "String lifetime tests complete"); -} - -void SchedulerStringLifetimeComponent::test_temporary_string_lifetime() { - ESP_LOGI(TAG, "Test 1: Temporary string lifetime for timeout names"); - - // Test with a temporary string that goes out of scope immediately - { - std::string temp_name = "temp_callback_" + std::to_string(12345); - - // Schedule with temporary string name - scheduler must copy/store this - this->set_timeout(temp_name, 1, [this]() { - ESP_LOGD(TAG, "Callback for temp string name executed"); - this->tests_passed_++; - }); - - // String goes out of scope here, but scheduler should have made a copy - } - - // Test with rvalue string as name - this->set_timeout(std::string("rvalue_test"), 2, [this]() { - ESP_LOGD(TAG, "Rvalue string name callback executed"); - this->tests_passed_++; - }); - - // Test cancelling with reconstructed string - { - std::string cancel_name = "cancel_test_" + std::to_string(999); - this->set_timeout(cancel_name, 100, [this]() { - ESP_LOGE(TAG, "This should have been cancelled!"); - this->tests_failed_++; - }); - } // cancel_name goes out of scope - - // Reconstruct the same string to cancel - std::string cancel_name_2 = "cancel_test_" + std::to_string(999); - bool cancelled = this->cancel_timeout(cancel_name_2); - if (cancelled) { - ESP_LOGD(TAG, "Successfully cancelled with reconstructed string"); - this->tests_passed_++; - } else { - ESP_LOGE(TAG, "Failed to cancel with reconstructed string"); - this->tests_failed_++; - } -} - -void SchedulerStringLifetimeComponent::test_scope_exit_string() { - ESP_LOGI(TAG, "Test 2: Scope exit string names"); - - // Create string names in a limited scope - { - std::string scoped_name = "scoped_timeout_" + std::to_string(555); - - // Schedule with scoped string name - this->set_timeout(scoped_name, 3, [this]() { - ESP_LOGD(TAG, "Scoped name callback executed"); - this->tests_passed_++; - }); - - // scoped_name goes out of scope here - } - - // Test with dynamically allocated string name - { - auto *dynamic_name = new std::string("dynamic_timeout_" + std::to_string(777)); - - this->set_timeout(*dynamic_name, 4, [this, dynamic_name]() { - ESP_LOGD(TAG, "Dynamic string name callback executed"); - this->tests_passed_++; - delete dynamic_name; // Clean up in callback - }); - - // Pointer goes out of scope but string object remains until callback - } - - // Test multiple timeouts with same dynamically created name - for (int i = 0; i < 3; i++) { - std::string loop_name = "loop_timeout_" + std::to_string(i); - this->set_timeout(loop_name, 5 + i * 1, [this, i]() { - ESP_LOGD(TAG, "Loop timeout %d executed", i); - this->tests_passed_++; - }); - // loop_name destroyed and recreated each iteration - } -} - -void SchedulerStringLifetimeComponent::test_vector_reallocation() { - ESP_LOGI(TAG, "Test 3: Vector reallocation stress on timeout names"); - - // Create a vector that will reallocate - std::vector names; - names.reserve(2); // Small initial capacity to force reallocation - - // Schedule callbacks with string names from vector - for (int i = 0; i < 10; i++) { - names.push_back("vector_cb_" + std::to_string(i)); - // Use the string from vector as timeout name - this->set_timeout(names.back(), 8 + i * 1, [this, i]() { - ESP_LOGV(TAG, "Vector name callback %d executed", i); - this->tests_passed_++; - }); - } - - // Force reallocation by adding more elements - // This will move all strings to new memory locations - for (int i = 10; i < 50; i++) { - names.push_back("realloc_trigger_" + std::to_string(i)); - } - - // Add more timeouts after reallocation to ensure old names still work - for (int i = 50; i < 55; i++) { - names.push_back("post_realloc_" + std::to_string(i)); - this->set_timeout(names.back(), 20 + (i - 50), [this]() { - ESP_LOGV(TAG, "Post-reallocation callback executed"); - this->tests_passed_++; - }); - } - - // Clear the vector while timeouts are still pending - names.clear(); - ESP_LOGD(TAG, "Vector cleared - all string names destroyed"); -} - -void SchedulerStringLifetimeComponent::test_string_move_semantics() { - ESP_LOGI(TAG, "Test 4: String move semantics for timeout names"); - - // Test moving string names - std::string original = "move_test_original"; - std::string moved = std::move(original); - - // Schedule with moved string as name - this->set_timeout(moved, 30, [this]() { - ESP_LOGD(TAG, "Moved string name callback executed"); - this->tests_passed_++; - }); - - // original is now empty, try to use it as a different timeout name - original = "reused_after_move"; - this->set_timeout(original, 32, [this]() { - ESP_LOGD(TAG, "Reused string name callback executed"); - this->tests_passed_++; - }); -} - -void SchedulerStringLifetimeComponent::test_lambda_capture_lifetime() { - ESP_LOGI(TAG, "Test 5: Complex timeout name scenarios"); - - // Test scheduling with name built in lambda - [this]() { - std::string lambda_name = "lambda_built_name_" + std::to_string(888); - this->set_timeout(lambda_name, 38, [this]() { - ESP_LOGD(TAG, "Lambda-built name callback executed"); - this->tests_passed_++; - }); - }(); // Lambda executes and lambda_name is destroyed - - // Test with shared_ptr name - auto shared_name = std::make_shared("shared_ptr_timeout"); - this->set_timeout(*shared_name, 40, [this, shared_name]() { - ESP_LOGD(TAG, "Shared_ptr name callback executed"); - this->tests_passed_++; - }); - shared_name.reset(); // Release the shared_ptr - - // Test overwriting timeout with same name - std::string overwrite_name = "overwrite_test"; - this->set_timeout(overwrite_name, 1000, [this]() { - ESP_LOGE(TAG, "This should have been overwritten!"); - this->tests_failed_++; - }); - - // Overwrite with shorter timeout - this->set_timeout(overwrite_name, 42, [this]() { - ESP_LOGD(TAG, "Overwritten timeout executed"); - this->tests_passed_++; - }); - - // Test very long string name - std::string long_name; - for (int i = 0; i < 100; i++) { - long_name += "very_long_timeout_name_segment_" + std::to_string(i) + "_"; - } - this->set_timeout(long_name, 44, [this]() { - ESP_LOGD(TAG, "Very long name timeout executed"); - this->tests_passed_++; - }); - - // Test empty string as name - this->set_timeout("", 46, [this]() { - ESP_LOGD(TAG, "Empty string name timeout executed"); - this->tests_passed_++; - }); -} - -} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h deleted file mode 100644 index 20185f128d..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include -#include - -namespace esphome::scheduler_string_lifetime_component { - -class SchedulerStringLifetimeComponent : public Component { - public: - void setup() override; - float get_setup_priority() const override { return setup_priority::LATE; } - - void run_string_lifetime_test(); - - // Individual test methods exposed as services - void run_test1(); - void run_test2(); - void run_test3(); - void run_test4(); - void run_test5(); - void run_final_check(); - - private: - void test_temporary_string_lifetime(); - void test_scope_exit_string(); - void test_vector_reallocation(); - void test_string_move_semantics(); - void test_lambda_capture_lifetime(); - - int tests_passed_{0}; - int tests_failed_{0}; -}; - -} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py deleted file mode 100644 index 6cc564395c..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.const import CONF_ID - -scheduler_string_name_stress_component_ns = cg.esphome_ns.namespace( - "scheduler_string_name_stress_component" -) -SchedulerStringNameStressComponent = scheduler_string_name_stress_component_ns.class_( - "SchedulerStringNameStressComponent", cg.Component -) - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SchedulerStringNameStressComponent), - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp deleted file mode 100644 index 677d371f25..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include "string_name_stress_component.h" -#include "esphome/core/log.h" -#include -#include -#include -#include -#include -#include - -namespace esphome::scheduler_string_name_stress_component { - -static const char *const TAG = "scheduler_string_name_stress"; - -void SchedulerStringNameStressComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringNameStressComponent setup"); } - -void SchedulerStringNameStressComponent::run_string_name_stress_test() { - // Use member variables to reset state - this->total_callbacks_ = 0; - this->executed_callbacks_ = 0; - static constexpr int NUM_THREADS = 10; - static constexpr int CALLBACKS_PER_THREAD = 100; - - ESP_LOGI(TAG, "Starting string name stress test - multi-threaded set_timeout with std::string names"); - ESP_LOGI(TAG, "This test specifically uses dynamic string names to test memory management"); - - // Track start time - auto start_time = std::chrono::steady_clock::now(); - - // Create threads - std::vector threads; - - ESP_LOGI(TAG, "Creating %d threads, each will schedule %d callbacks with dynamic names", NUM_THREADS, - CALLBACKS_PER_THREAD); - - threads.reserve(NUM_THREADS); - for (int i = 0; i < NUM_THREADS; i++) { - threads.emplace_back([this, i]() { - ESP_LOGV(TAG, "Thread %d starting", i); - - // Each thread schedules callbacks with dynamically created string names - for (int j = 0; j < CALLBACKS_PER_THREAD; j++) { - int callback_id = this->total_callbacks_.fetch_add(1); - - // Create a dynamic string name - this will test memory management - std::stringstream ss; - ss << "thread_" << i << "_callback_" << j << "_id_" << callback_id; - std::string dynamic_name = ss.str(); - - ESP_LOGV(TAG, "Thread %d scheduling timeout with dynamic name: %s", i, dynamic_name.c_str()); - - // Capture necessary values for the lambda - auto *component = this; - - // Schedule with std::string name - this tests the string overload - // Use varying delays to stress the heap scheduler - uint32_t delay = 1 + (callback_id % 50); - - // Also test nested scheduling from callbacks - if (j % 10 == 0) { - // Every 10th callback schedules another callback - this->set_timeout(dynamic_name, delay, [component, callback_id]() { - component->executed_callbacks_.fetch_add(1); - ESP_LOGV(TAG, "Executed string-named callback %d (nested scheduler)", callback_id); - - // Schedule another timeout from within this callback with a new dynamic name - std::string nested_name = "nested_from_" + std::to_string(callback_id); - component->set_timeout(nested_name, 1, [callback_id]() { - ESP_LOGV(TAG, "Executed nested string-named callback from %d", callback_id); - }); - }); - } else { - // Regular callback - this->set_timeout(dynamic_name, delay, [component, callback_id]() { - component->executed_callbacks_.fetch_add(1); - ESP_LOGV(TAG, "Executed string-named callback %d", callback_id); - }); - } - - // Add some timing variations to increase race conditions - if (j % 5 == 0) { - std::this_thread::sleep_for(std::chrono::microseconds(100)); - } - } - ESP_LOGV(TAG, "Thread %d finished scheduling", i); - }); - } - - // Wait for all threads to complete scheduling - for (auto &t : threads) { - t.join(); - } - - auto end_time = std::chrono::steady_clock::now(); - auto thread_time = std::chrono::duration_cast(end_time - start_time).count(); - ESP_LOGI(TAG, "All threads finished scheduling in %lldms. Created %d callbacks with dynamic names", thread_time, - this->total_callbacks_.load()); - - // Give some time for callbacks to execute - ESP_LOGI(TAG, "Waiting for callbacks to execute..."); - - // Schedule a final callback to signal completion - this->set_timeout("test_complete", 2000, [this]() { - ESP_LOGI(TAG, "String name stress test complete. Executed %d of %d callbacks", this->executed_callbacks_.load(), - this->total_callbacks_.load()); - }); -} - -} // namespace esphome::scheduler_string_name_stress_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h deleted file mode 100644 index 121bda6204..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include - -namespace esphome::scheduler_string_name_stress_component { - -class SchedulerStringNameStressComponent : public Component { - public: - void setup() override; - float get_setup_priority() const override { return setup_priority::LATE; } - - void run_string_name_stress_test(); - - private: - std::atomic total_callbacks_{0}; - std::atomic executed_callbacks_{0}; -}; - -} // namespace esphome::scheduler_string_name_stress_component diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index 989c1535b0..a75d9dbcbc 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -156,9 +156,9 @@ script: // Simulate a burst of defer operations like ratgdo does with state updates // These should execute immediately and recycle quickly to the pool + // Phase-specific id range (0..9) so ids never collide with later phases for (int i = 0; i < 10; i++) { - std::string defer_name = "defer_" + std::to_string(i); - App.scheduler.set_timeout(component, defer_name, 0, [i]() { + App.scheduler.set_timeout(component, static_cast(i), 0, [i]() { ESP_LOGD("test", "Defer %d executed", i); // Force a small delay between defer executions to see recycling if (i == 5) { @@ -207,9 +207,9 @@ script: // Now create 8 new timeouts - they should reuse from pool when available int reuse_test_count = 8; + // Phase-specific id range (100..107) so ids never collide with other phases for (int i = 0; i < reuse_test_count; i++) { - std::string name = "reuse_test_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(100 + i), 10 + i * 5, [i]() { ESP_LOGD("test", "Reuse test %d completed", i); }); } @@ -229,9 +229,9 @@ script: auto *component = id(test_sensor); int full_reuse_count = 10; + // Phase-specific id range (200..209) so ids never collide with other phases for (int i = 0; i < full_reuse_count; i++) { - std::string name = "full_reuse_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(200 + i), 10 + i * 5, [i]() { ESP_LOGD("test", "Full reuse test %d completed", i); }); } diff --git a/tests/integration/fixtures/scheduler_string_lifetime.yaml b/tests/integration/fixtures/scheduler_string_lifetime.yaml deleted file mode 100644 index 5ae5a1914e..0000000000 --- a/tests/integration/fixtures/scheduler_string_lifetime.yaml +++ /dev/null @@ -1,48 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: scheduler-string-lifetime-test - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - components: [scheduler_string_lifetime_component] - -host: - -logger: - level: DEBUG - -scheduler_string_lifetime_component: - id: string_lifetime - -api: - services: - - service: run_string_lifetime_test - then: - - lambda: |- - id(string_lifetime)->run_string_lifetime_test(); - - service: run_test1 - then: - - lambda: |- - id(string_lifetime)->run_test1(); - - service: run_test2 - then: - - lambda: |- - id(string_lifetime)->run_test2(); - - service: run_test3 - then: - - lambda: |- - id(string_lifetime)->run_test3(); - - service: run_test4 - then: - - lambda: |- - id(string_lifetime)->run_test4(); - - service: run_test5 - then: - - lambda: |- - id(string_lifetime)->run_test5(); - - service: run_final_check - then: - - lambda: |- - id(string_lifetime)->run_final_check(); diff --git a/tests/integration/fixtures/scheduler_string_name_stress.yaml b/tests/integration/fixtures/scheduler_string_name_stress.yaml deleted file mode 100644 index 8f68d1d102..0000000000 --- a/tests/integration/fixtures/scheduler_string_name_stress.yaml +++ /dev/null @@ -1,39 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: sched-string-name-stress - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - components: [scheduler_string_name_stress_component] - -host: - -logger: - level: VERBOSE - -scheduler_string_name_stress_component: - id: string_stress - -api: - services: - - service: run_string_name_stress_test - then: - - lambda: |- - id(string_stress)->run_string_name_stress_test(); - -event: - - platform: template - name: "Test Complete" - id: test_complete - device_class: button - event_types: - - "test_finished" - - platform: template - name: "Test Result" - id: test_result - device_class: button - event_types: - - "passed" - - "failed" diff --git a/tests/integration/fixtures/scheduler_string_test.yaml b/tests/integration/fixtures/scheduler_string_test.yaml index c53ec392df..06e3a4c97c 100644 --- a/tests/integration/fixtures/scheduler_string_test.yaml +++ b/tests/integration/fixtures/scheduler_string_test.yaml @@ -18,9 +18,6 @@ globals: - id: interval_counter type: int initial_value: '0' - - id: dynamic_counter - type: int - initial_value: '0' - id: static_tests_done type: bool initial_value: 'false' @@ -103,46 +100,43 @@ script: - id: test_dynamic_strings then: - - logger.log: "Testing dynamic string timeouts and intervals" + - logger.log: "Testing const char* timeouts and intervals" - lambda: |- auto *component2 = id(test_sensor2); - // Test 8: Dynamic string with set_timeout (std::string) - std::string dynamic_name = "dynamic_timeout_" + std::to_string(id(dynamic_counter)++); - App.scheduler.set_timeout(component2, dynamic_name, 100, []() { + // Test 8: const char* name with set_timeout + App.scheduler.set_timeout(component2, "dynamic_timeout", 100, []() { ESP_LOGI("test", "Dynamic timeout fired"); id(timeout_counter) += 1; }); - // Test 9: Dynamic string with set_interval - std::string interval_name = "dynamic_interval_" + std::to_string(id(dynamic_counter)++); - App.scheduler.set_interval(component2, interval_name, 250, [interval_name]() { - ESP_LOGI("test", "Dynamic interval fired: %s", interval_name.c_str()); + // Test 9: const char* name with set_interval, cancelled from inside the callback + App.scheduler.set_interval(component2, "dynamic_interval", 250, []() { + ESP_LOGI("test", "Dynamic interval fired"); id(interval_counter) += 1; if (id(interval_counter) >= 6) { - App.scheduler.cancel_interval(id(test_sensor2), interval_name); + App.scheduler.cancel_interval(id(test_sensor2), "dynamic_interval"); ESP_LOGI("test", "Cancelled dynamic interval"); } }); - // Test 10: Cancel with different string object but same content - std::string cancel_name = "cancel_test"; - App.scheduler.set_timeout(component2, cancel_name, 2000, []() { + // Test 10: Cancel with a different pointer but identical content. + // STATIC_STRING names match by content, so a distinct static buffer with the + // same characters still cancels the scheduled timeout. + static const char CANCEL_NAME[] = "cancel_test"; + App.scheduler.set_timeout(component2, CANCEL_NAME, 2000, []() { ESP_LOGI("test", "This should be cancelled"); }); + static const char CANCEL_NAME_2[] = "cancel_test"; + App.scheduler.cancel_timeout(component2, CANCEL_NAME_2); + ESP_LOGI("test", "Cancelled timeout using different buffer with same content"); - // Cancel using a different string object - std::string cancel_name_2 = "cancel_test"; - App.scheduler.cancel_timeout(component2, cancel_name_2); - ESP_LOGI("test", "Cancelled timeout using different string object"); - - // Test 11: Dynamic string with defer (using std::string overload) + // Test 11: const char* name with defer class TestDynamicDeferComponent : public Component { public: void test_dynamic_defer() { - std::string defer_name = "dynamic_defer_" + std::to_string(id(dynamic_counter)++); - this->defer(defer_name, [defer_name]() { - ESP_LOGI("test", "Dynamic defer fired: %s", defer_name.c_str()); + this->defer("dynamic_defer", []() { + ESP_LOGI("test", "Dynamic defer fired"); id(timeout_counter) += 1; }); } diff --git a/tests/integration/test_scheduler_string_lifetime.py b/tests/integration/test_scheduler_string_lifetime.py deleted file mode 100644 index bfa581129b..0000000000 --- a/tests/integration/test_scheduler_string_lifetime.py +++ /dev/null @@ -1,169 +0,0 @@ -"""String lifetime test - verify scheduler handles string destruction correctly.""" - -import asyncio -from pathlib import Path -import re - -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_lifetime( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that scheduler correctly handles string lifetimes when strings go out of scope.""" - - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - # Create events for synchronization - test1_complete = asyncio.Event() - test2_complete = asyncio.Event() - test3_complete = asyncio.Event() - test4_complete = asyncio.Event() - test5_complete = asyncio.Event() - all_tests_complete = asyncio.Event() - - # Track test progress - test_stats = { - "tests_passed": 0, - "tests_failed": 0, - "errors": [], - "current_test": None, - "test_callbacks_executed": {}, - } - - def on_log_line(line: str) -> None: - # Track test-specific events - if "Test 1 complete" in line: - test1_complete.set() - elif "Test 2 complete" in line: - test2_complete.set() - elif "Test 3 complete" in line: - test3_complete.set() - elif "Test 4 complete" in line: - test4_complete.set() - elif "Test 5 complete" in line: - test5_complete.set() - - # Track individual callback executions - callback_match = re.search(r"Callback '(.+?)' executed", line) - if callback_match: - callback_name = callback_match.group(1) - test_stats["test_callbacks_executed"][callback_name] = True - - # Track test results from the C++ test output - if "Tests passed:" in line and "string_lifetime" in line: - # Extract the number from "Tests passed: 32" - match = re.search(r"Tests passed:\s*(\d+)", line) - if match: - test_stats["tests_passed"] = int(match.group(1)) - elif "Tests failed:" in line and "string_lifetime" in line: - match = re.search(r"Tests failed:\s*(\d+)", line) - if match: - test_stats["tests_failed"] = int(match.group(1)) - elif "ERROR" in line and "string_lifetime" in line: - test_stats["errors"].append(line) - - # Check for memory corruption indicators - if any( - indicator in line.lower() - for indicator in [ - "use after free", - "heap corruption", - "segfault", - "abort", - "assertion", - "sanitizer", - "bad memory", - "invalid pointer", - ] - ): - pytest.fail(f"Memory corruption detected: {line}") - - # Check for completion - if "String lifetime tests complete" in line: - all_tests_complete.set() - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "scheduler-string-lifetime-test" - - # List entities and services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) - - # Find our test services - test_services = {} - for service in services: - if service.name == "run_test1": - test_services["test1"] = service - elif service.name == "run_test2": - test_services["test2"] = service - elif service.name == "run_test3": - test_services["test3"] = service - elif service.name == "run_test4": - test_services["test4"] = service - elif service.name == "run_test5": - test_services["test5"] = service - elif service.name == "run_final_check": - test_services["final"] = service - - # Ensure all services are found - required_services = ["test1", "test2", "test3", "test4", "test5", "final"] - for service_name in required_services: - assert service_name in test_services, f"{service_name} service not found" - - # Run tests sequentially, waiting for each to complete - try: - # Test 1 - await client.execute_service(test_services["test1"], {}) - await asyncio.wait_for(test1_complete.wait(), timeout=5.0) - - # Test 2 - await client.execute_service(test_services["test2"], {}) - await asyncio.wait_for(test2_complete.wait(), timeout=5.0) - - # Test 3 - await client.execute_service(test_services["test3"], {}) - await asyncio.wait_for(test3_complete.wait(), timeout=5.0) - - # Test 4 - await client.execute_service(test_services["test4"], {}) - await asyncio.wait_for(test4_complete.wait(), timeout=5.0) - - # Test 5 - await client.execute_service(test_services["test5"], {}) - await asyncio.wait_for(test5_complete.wait(), timeout=5.0) - - # Final check - await client.execute_service(test_services["final"], {}) - await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) - - except TimeoutError: - pytest.fail(f"String lifetime test timed out. Stats: {test_stats}") - - # Check for any errors - assert test_stats["tests_failed"] == 0, f"Tests failed: {test_stats['errors']}" - - # Verify we had the expected number of passing tests - assert test_stats["tests_passed"] == 30, ( - f"Expected exactly 30 tests to pass, but got {test_stats['tests_passed']}" - ) diff --git a/tests/integration/test_scheduler_string_name_stress.py b/tests/integration/test_scheduler_string_name_stress.py deleted file mode 100644 index 56b8998c56..0000000000 --- a/tests/integration/test_scheduler_string_name_stress.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Stress test for heap scheduler with std::string names from multiple threads.""" - -import asyncio -from pathlib import Path -import re - -from aioesphomeapi import UserService -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_name_stress( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that set_timeout/set_interval with std::string names doesn't crash when called from multiple threads.""" - - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - # Create a future to signal test completion - loop = asyncio.get_running_loop() - test_complete_future: asyncio.Future[None] = loop.create_future() - - # Track executed callbacks and any crashes - executed_callbacks: set[int] = set() - error_messages: list[str] = [] - - def on_log_line(line: str) -> None: - # Check for crash indicators - if any( - indicator in line.lower() - for indicator in [ - "segfault", - "abort", - "assertion", - "heap corruption", - "use after free", - ] - ): - error_messages.append(line) - if not test_complete_future.done(): - test_complete_future.set_exception(Exception(f"Crash detected: {line}")) - return - - # Track executed callbacks - match = re.search(r"Executed string-named callback (\d+)", line) - if match: - callback_id = int(match.group(1)) - executed_callbacks.add(callback_id) - - # Check for completion - if ( - "String name stress test complete" in line - and not test_complete_future.done() - ): - test_complete_future.set_result(None) - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "sched-string-name-stress" - - # List entities and services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) - - # Find our test service - run_stress_test_service: UserService | None = None - for service in services: - if service.name == "run_string_name_stress_test": - run_stress_test_service = service - break - - assert run_stress_test_service is not None, ( - "run_string_name_stress_test service not found" - ) - - # Call the service to start the test - await client.execute_service(run_stress_test_service, {}) - - # Wait for test to complete or crash - try: - await asyncio.wait_for(test_complete_future, timeout=30.0) - except TimeoutError: - pytest.fail( - f"String name stress test timed out. Executed {len(executed_callbacks)} callbacks. " - f"This might indicate a deadlock." - ) - - # Verify no errors occurred (crashes already handled by exception) - assert not error_messages, f"Errors detected during test: {error_messages}" - - # Verify we executed all 1000 callbacks (10 threads × 100 callbacks each) - assert len(executed_callbacks) == 1000, ( - f"Expected 1000 callbacks but got {len(executed_callbacks)}" - ) - - # Verify each callback ID was executed exactly once - for i in range(1000): - assert i in executed_callbacks, f"Callback {i} was not executed" diff --git a/tests/integration/test_scheduler_string_test.py b/tests/integration/test_scheduler_string_test.py index 783ed37c13..3bc3487432 100644 --- a/tests/integration/test_scheduler_string_test.py +++ b/tests/integration/test_scheduler_string_test.py @@ -99,7 +99,7 @@ async def test_scheduler_string_test( timeout_count += 1 # Check for cancel test - elif "Cancelled timeout using different string object" in clean_line: + elif "Cancelled timeout using different buffer with same content" in clean_line: cancel_test_done.set() # Check for final results From d0e3e98d552d03e7cc2f0896b48c26b6f32dc4bd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:33:27 +1200 Subject: [PATCH 1759/2214] [dashboard] Remove legacy web dashboard (#17124) --- .github/scripts/detect-tags.js | 1 - .../dashboard-deprecation-comment.yml | 119 -- AGENTS.md | 4 +- esphome/__main__.py | 68 +- .../components/dashboard_import/__init__.py | 1 - esphome/components/esp32/__init__.py | 16 +- esphome/components/esp8266/__init__.py | 16 +- esphome/components/libretiny/__init__.py | 16 +- esphome/components/rp2040/__init__.py | 16 +- esphome/dashboard/__init__.py | 0 esphome/dashboard/const.py | 32 - esphome/dashboard/core.py | 190 -- esphome/dashboard/dashboard.py | 153 -- esphome/dashboard/dns.py | 77 - esphome/dashboard/entries.py | 458 ---- esphome/dashboard/models.py | 76 - esphome/dashboard/settings.py | 101 - esphome/dashboard/status/__init__.py | 0 esphome/dashboard/status/mdns.py | 170 -- esphome/dashboard/status/mqtt.py | 78 - esphome/dashboard/status/ping.py | 151 -- esphome/dashboard/util/__init__.py | 0 esphome/dashboard/util/itertools.py | 22 - esphome/dashboard/util/password.py | 11 - esphome/dashboard/util/subprocess.py | 31 - esphome/dashboard/util/text.py | 15 - esphome/dashboard/web_server.py | 1645 -------------- esphome/helpers.py | 10 +- esphome/storage_json.py | 12 +- esphome/zeroconf.py | 24 +- requirements.txt | 3 - script/ci-custom.py | 9 +- tests/dashboard/__init__.py | 0 tests/dashboard/common.py | 6 - tests/dashboard/conftest.py | 43 - tests/dashboard/fixtures/conf/pico.yaml | 47 - tests/dashboard/status/__init__.py | 0 tests/dashboard/status/test_dns.py | 199 -- tests/dashboard/status/test_mdns.py | 240 --- tests/dashboard/test_entries.py | 288 --- tests/dashboard/test_settings.py | 287 --- tests/dashboard/test_web_server.py | 1889 ----------------- tests/dashboard/test_web_server_paths.py | 219 -- tests/dashboard/util/__init__.py | 0 tests/script/test_determine_jobs.py | 3 +- tests/unit_tests/test_helpers.py | 16 - tests/unit_tests/test_main.py | 40 + 47 files changed, 109 insertions(+), 6693 deletions(-) delete mode 100644 .github/workflows/dashboard-deprecation-comment.yml delete mode 100644 esphome/dashboard/__init__.py delete mode 100644 esphome/dashboard/const.py delete mode 100644 esphome/dashboard/core.py delete mode 100644 esphome/dashboard/dashboard.py delete mode 100644 esphome/dashboard/dns.py delete mode 100644 esphome/dashboard/entries.py delete mode 100644 esphome/dashboard/models.py delete mode 100644 esphome/dashboard/settings.py delete mode 100644 esphome/dashboard/status/__init__.py delete mode 100644 esphome/dashboard/status/mdns.py delete mode 100644 esphome/dashboard/status/mqtt.py delete mode 100644 esphome/dashboard/status/ping.py delete mode 100644 esphome/dashboard/util/__init__.py delete mode 100644 esphome/dashboard/util/itertools.py delete mode 100644 esphome/dashboard/util/password.py delete mode 100644 esphome/dashboard/util/subprocess.py delete mode 100644 esphome/dashboard/util/text.py delete mode 100644 esphome/dashboard/web_server.py delete mode 100644 tests/dashboard/__init__.py delete mode 100644 tests/dashboard/common.py delete mode 100644 tests/dashboard/conftest.py delete mode 100644 tests/dashboard/fixtures/conf/pico.yaml delete mode 100644 tests/dashboard/status/__init__.py delete mode 100644 tests/dashboard/status/test_dns.py delete mode 100644 tests/dashboard/status/test_mdns.py delete mode 100644 tests/dashboard/test_entries.py delete mode 100644 tests/dashboard/test_settings.py delete mode 100644 tests/dashboard/test_web_server.py delete mode 100644 tests/dashboard/test_web_server_paths.py delete mode 100644 tests/dashboard/util/__init__.py diff --git a/.github/scripts/detect-tags.js b/.github/scripts/detect-tags.js index 3933776c61..99caccc2f8 100644 --- a/.github/scripts/detect-tags.js +++ b/.github/scripts/detect-tags.js @@ -41,7 +41,6 @@ function hasCoreChanges(changedFiles) { */ function hasDashboardChanges(changedFiles) { return changedFiles.some(file => - file.startsWith('esphome/dashboard/') || file.startsWith('esphome/components/dashboard_import/') ); } diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml deleted file mode 100644 index ffd5ec7bd9..0000000000 --- a/.github/workflows/dashboard-deprecation-comment.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Add Dashboard Deprecation Comment - -on: - pull_request_target: - types: [opened, synchronize] - -# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with -# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes. -permissions: {} - -jobs: - dashboard-deprecation-comment: - name: Dashboard deprecation comment - runs-on: ubuntu-latest - # Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably - # roll up everything merged into dev since the last cut, which can - # include dashboard changes that have already been reviewed once. - # The bot's purpose is to warn new contributors before they invest - # time -- that only applies to PRs entering dev. - if: github.event.pull_request.base.ref == 'dev' - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - # pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources - # the issues.*Comment APIs require the pull-requests scope, not issues. - permission-pull-requests: write - - - name: Add dashboard deprecation comment - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const commentMarker = ""; - - const commentBody = `Thanks for opening this PR! - - Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository. - - What this means for your PR: - - - **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here. - - **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life. - - **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix. - - We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land. - - --- - (Added by the PR bot) - - ${commentMarker}`; - - async function getDashboardChanges(github, owner, repo, prNumber) { - const changedFiles = await github.paginate( - github.rest.pulls.listFiles, - { - owner: owner, - repo: repo, - pull_number: prNumber, - per_page: 100, - } - ); - - return changedFiles.filter(file => - file.filename.startsWith('esphome/dashboard/') || - file.filename.startsWith('tests/dashboard/') - ); - } - - async function findBotComment(github, owner, repo, prNumber) { - const comments = await github.paginate( - github.rest.issues.listComments, - { - owner: owner, - repo: repo, - issue_number: prNumber, - per_page: 100, - } - ); - - return comments.find(comment => - comment.body.includes(commentMarker) && comment.user.type === "Bot" - ); - } - - const prNumber = context.payload.pull_request.number; - const { owner, repo } = context.repo; - - const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber); - const existingComment = await findBotComment(github, owner, repo, prNumber); - - if (dashboardChanges.length === 0) { - // PR doesn't (or no longer) touches the legacy dashboard. If we previously - // commented (e.g. files were removed in a later push), leave the comment in - // place for history rather than thrash on edit/delete. - return; - } - - if (existingComment) { - if (existingComment.body === commentBody) { - return; - } - await github.rest.issues.updateComment({ - owner: owner, - repo: repo, - comment_id: existingComment.id, - body: commentBody, - }); - } else { - await github.rest.issues.createComment({ - owner: owner, - repo: repo, - issue_number: prNumber, - body: commentBody, - }); - } diff --git a/AGENTS.md b/AGENTS.md index 4346ffbdae..be2e912d48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,6 @@ This document provides essential context for AI models interacting with this pro 2. **Code Generation** (`esphome/codegen.py`, `esphome/cpp_generator.py`): Manages Python to C++ code generation, template processing, and build flag management. 3. **Component System** (`esphome/components/`): Contains modular hardware and software components with platform-specific implementations and dependency management. 4. **Core Framework** (`esphome/core/`): Manages the application lifecycle, hardware abstraction, and component registration. - 5. **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates. * **Platform Support:** 1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (Original, C2, C3, C5, C6, H2, P4, S2, S3) with ESP-IDF framework. Arduino framework supports only a subset of the variants (Original, C3, S2, S3). @@ -456,7 +455,6 @@ This document provides essential context for AI models interacting with this pro * **Debug Tools:** - `esphome config .yaml` to validate configuration. - `esphome compile .yaml` to compile without uploading. - - Check the Dashboard for real-time logs. - Use component-specific debug logging. * **Common Issues:** - **Import Errors**: Check component dependencies and `PYTHONPATH`. @@ -658,7 +656,7 @@ This document provides essential context for AI models interacting with this pro If you need a real-world example, search for components that use `@dataclass` with `CORE.data` in the codebase. Note: Some components may use `TypedDict` for dictionary-based storage; both patterns are acceptable depending on your needs. **Why this matters:** - - Module-level globals persist between compilation runs if the dashboard doesn't fork/exec + - Module-level globals persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec - `CORE.data` automatically clears between runs - Namespacing under `DOMAIN` prevents key collisions between components - `@dataclass` provides type safety and cleaner attribute access diff --git a/esphome/__main__.py b/esphome/__main__.py index 680de02201..35ab767cf7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -527,7 +527,7 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True - # The dashboard pre-resolves the device and passes the IPs via + # device-builder pre-resolves the device and passes the IPs via # --mdns-address-cache/--dns-address-cache; honor a cached address even when the # device has mDNS disabled (e.g. a .local host found via ping). if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address): @@ -1715,9 +1715,13 @@ def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None: def command_dashboard(args: ArgsProtocol) -> int | None: - from esphome.dashboard import dashboard - - return dashboard.start_dashboard(args) + raise EsphomeError( + "The built-in dashboard has been removed from ESPHome. " + "Install and run ESPHome Device Builder instead:\n" + " pip install esphome-device-builder\n" + " esphome-device-builder\n" + "See https://github.com/esphome/device-builder for more information." + ) def run_multiple_configs( @@ -2379,44 +2383,22 @@ def parse_args(argv): "configuration", help="Your YAML file or configuration directory.", nargs="*" ) - parser_dashboard = subparsers.add_parser( - "dashboard", help="Create a simple web server for a dashboard." + # The dashboard moved to ESPHome Device Builder; the command is kept only to + # print a redirect (see command_dashboard). Accept and ignore the old flags + # so legacy invocations reach that message instead of failing on argparse + # "unrecognized arguments". + parser_dashboard = subparsers.add_parser("dashboard") + parser_dashboard.add_argument("configuration", nargs="?", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--port", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--address", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--username", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--password", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--socket", help=argparse.SUPPRESS) + parser_dashboard.add_argument( + "--open-ui", action="store_true", help=argparse.SUPPRESS ) parser_dashboard.add_argument( - "configuration", help="Your YAML configuration file directory." - ) - parser_dashboard.add_argument( - "--port", - help="The HTTP port to open connections on. Defaults to 6052.", - type=int, - default=6052, - ) - parser_dashboard.add_argument( - "--address", - help="The address to bind to.", - type=str, - default="0.0.0.0", - ) - parser_dashboard.add_argument( - "--username", - help="The optional username to require for authentication.", - type=str, - default="", - ) - parser_dashboard.add_argument( - "--password", - help="The optional password to require for authentication.", - type=str, - default="", - ) - parser_dashboard.add_argument( - "--open-ui", help="Open the dashboard UI in a browser.", action="store_true" - ) - parser_dashboard.add_argument( - "--ha-addon", help=argparse.SUPPRESS, action="store_true" - ) - parser_dashboard.add_argument( - "--socket", help="Make the dashboard serve under a unix socket", type=str + "--ha-addon", action="store_true", help=argparse.SUPPRESS ) parser_vscode = subparsers.add_parser("vscode") @@ -2511,11 +2493,7 @@ def run_esphome(argv): elif args.quiet: args.log_level = "CRITICAL" - setup_log( - log_level=args.log_level, - # Show timestamp for dashboard access logs - include_timestamp=args.command == "dashboard", - ) + setup_log(log_level=args.log_level) if args.command in PRE_CONFIG_ACTIONS: try: diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 30b3394165..911fc387a0 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -92,7 +92,6 @@ def import_config( """Materialise a dashboard-imported device's YAML on disk. Used by: - - esphome.dashboard (legacy dashboard) - device-builder (esphome/device-builder) — called from the ``devices/import`` WS handler to seed the YAML for an adopted factory firmware. Coordinate before changing the kwargs or the diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ec33d9d271..8ba1ac4608 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -533,15 +533,13 @@ def get_board(core_obj=None): def get_download_types(storage_json): """Binary-download entries for a built ESP32 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index db94f0ec6d..db7120a9ef 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -97,15 +97,13 @@ def set_core_data(config): def get_download_types(storage_json): """Binary-download entries for a built ESP8266 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index afe0360c22..bcc393f3fd 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -158,15 +158,13 @@ def only_on_family(*, supported=None, unsupported=None): def get_download_types(storage_json: StorageJSON = None): """Binary-download entries for a built LibreTiny firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ types = [ { diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index f98cde7968..dd851b8e16 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -140,15 +140,13 @@ def only_on_variant( def get_download_types(storage_json): """Binary-download entries for a built RP2040 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { diff --git a/esphome/dashboard/__init__.py b/esphome/dashboard/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/esphome/dashboard/const.py b/esphome/dashboard/const.py deleted file mode 100644 index 9cadc442ef..0000000000 --- a/esphome/dashboard/const.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import sys - -from esphome.enum import StrEnum - - -class DashboardEvent(StrEnum): - """Dashboard WebSocket event types.""" - - # Server -> Client events (backend sends to frontend) - ENTRY_ADDED = "entry_added" - ENTRY_REMOVED = "entry_removed" - ENTRY_UPDATED = "entry_updated" - ENTRY_STATE_CHANGED = "entry_state_changed" - IMPORTABLE_DEVICE_ADDED = "importable_device_added" - IMPORTABLE_DEVICE_REMOVED = "importable_device_removed" - INITIAL_STATE = "initial_state" # Sent on WebSocket connection - PONG = "pong" # Response to client ping - - # Client -> Server events (frontend sends to backend) - PING = "ping" # WebSocket keepalive from client - REFRESH = "refresh" # Force backend to poll for changes - - -MAX_EXECUTOR_WORKERS = 48 - - -SENTINEL = object() - -ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] -DASHBOARD_COMMAND = [*ESPHOME_COMMAND, "--dashboard"] diff --git a/esphome/dashboard/core.py b/esphome/dashboard/core.py deleted file mode 100644 index b9ec56cd00..0000000000 --- a/esphome/dashboard/core.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Callable, Coroutine -import contextlib -from dataclasses import dataclass -from functools import partial -import json -import logging -import threading -from typing import Any - -from esphome.storage_json import ignored_devices_storage_path - -from ..zeroconf import DiscoveredImport -from .const import DashboardEvent -from .dns import DNSCache -from .entries import DashboardEntries -from .settings import DashboardSettings -from .status.mdns import MDNSStatus -from .status.ping import PingStatus - -_LOGGER = logging.getLogger(__name__) - -IGNORED_DEVICES_STORAGE_PATH = "ignored-devices.json" - -MDNS_BOOTSTRAP_TIME = 7.5 - - -@dataclass -class Event: - """Dashboard Event.""" - - event_type: DashboardEvent - data: dict[str, Any] - - -class EventBus: - """Dashboard event bus.""" - - def __init__(self) -> None: - """Initialize the Dashboard event bus.""" - self._listeners: dict[DashboardEvent, set[Callable[[Event], None]]] = {} - - def async_add_listener( - self, event_type: DashboardEvent, listener: Callable[[Event], None] - ) -> Callable[[], None]: - """Add a listener to the event bus.""" - self._listeners.setdefault(event_type, set()).add(listener) - return partial(self._async_remove_listener, event_type, listener) - - def _async_remove_listener( - self, event_type: DashboardEvent, listener: Callable[[Event], None] - ) -> None: - """Remove a listener from the event bus.""" - self._listeners[event_type].discard(listener) - - def async_fire( - self, event_type: DashboardEvent, event_data: dict[str, Any] - ) -> None: - """Fire an event.""" - event = Event(event_type, event_data) - - _LOGGER.debug("Firing event: %s", event) - - for listener in self._listeners.get(event_type, set()): - listener(event) - - -class ESPHomeDashboard: - """Class that represents the dashboard.""" - - __slots__ = ( - "bus", - "entries", - "loop", - "import_result", - "stop_event", - "ping_request", - "mqtt_ping_request", - "mdns_status", - "settings", - "dns_cache", - "_background_tasks", - "ignored_devices", - "_ping_status_task", - ) - - def __init__(self) -> None: - """Initialize the ESPHomeDashboard.""" - self.bus = EventBus() - self.entries: DashboardEntries | None = None - self.loop: asyncio.AbstractEventLoop | None = None - self.import_result: dict[str, DiscoveredImport] = {} - self.stop_event = threading.Event() - self.ping_request: asyncio.Event | None = None - self.mqtt_ping_request = threading.Event() - self.mdns_status: MDNSStatus | None = None - self.settings = DashboardSettings() - self.dns_cache = DNSCache() - self._background_tasks: set[asyncio.Task] = set() - self.ignored_devices: set[str] = set() - self._ping_status_task: asyncio.Task | None = None - - async def async_setup(self) -> None: - """Setup the dashboard.""" - self.loop = asyncio.get_running_loop() - self.ping_request = asyncio.Event() - self.entries = DashboardEntries(self) - await self.loop.run_in_executor(None, self.load_ignored_devices) - - def load_ignored_devices(self) -> None: - storage_path = ignored_devices_storage_path() - try: - with storage_path.open("r", encoding="utf-8") as f_handle: - data = json.load(f_handle) - self.ignored_devices = set(data.get("ignored_devices", set())) - except FileNotFoundError: - pass - - def save_ignored_devices(self) -> None: - storage_path = ignored_devices_storage_path() - with storage_path.open("w", encoding="utf-8") as f_handle: - json.dump( - {"ignored_devices": sorted(self.ignored_devices)}, indent=2, fp=f_handle - ) - - def _async_start_ping_status(self, ping_status: PingStatus) -> None: - self._ping_status_task = asyncio.create_task(ping_status.async_run()) - - async def async_run(self) -> None: - """Run the dashboard.""" - settings = self.settings - mdns_task: asyncio.Task | None = None - await self.entries.async_update_entries() - - mdns_status = MDNSStatus(self) - ping_status = PingStatus(self) - start_ping_timer: asyncio.TimerHandle | None = None - - self.mdns_status = mdns_status - if mdns_status.async_setup(): - mdns_task = asyncio.create_task(mdns_status.async_run()) - # Start ping MDNS_BOOTSTRAP_TIME seconds after startup to ensure - # MDNS has had a chance to resolve the devices - start_ping_timer = self.loop.call_later( - MDNS_BOOTSTRAP_TIME, self._async_start_ping_status, ping_status - ) - else: - # If mDNS is not available, start the ping status immediately - self._async_start_ping_status(ping_status) - - if settings.status_use_mqtt: - from .status.mqtt import MqttStatusThread - - status_thread_mqtt = MqttStatusThread(self) - status_thread_mqtt.start() - - try: - await asyncio.Event().wait() - finally: - _LOGGER.info("Shutting down...") - self.stop_event.set() - self.ping_request.set() - if start_ping_timer: - start_ping_timer.cancel() - if self._ping_status_task: - self._ping_status_task.cancel() - self._ping_status_task = None - if mdns_task: - mdns_task.cancel() - if settings.status_use_mqtt: - status_thread_mqtt.join() - self.mqtt_ping_request.set() - for task in self._background_tasks: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - await asyncio.sleep(0) - - def async_create_background_task( - self, coro: Coroutine[Any, Any, Any] - ) -> asyncio.Task: - """Create a background task.""" - task = self.loop.create_task(coro) - task.add_done_callback(self._background_tasks.discard) - return task - - -DASHBOARD = ESPHomeDashboard() diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py deleted file mode 100644 index 7fc21f8a44..0000000000 --- a/esphome/dashboard/dashboard.py +++ /dev/null @@ -1,153 +0,0 @@ -from __future__ import annotations - -import asyncio -from asyncio import events -from concurrent.futures import ThreadPoolExecutor -import contextlib -import logging -import os -from pathlib import Path -import socket -import threading -from time import monotonic -import traceback -from typing import Any - -from esphome.storage_json import EsphomeStorageJSON, esphome_storage_path - -from .const import MAX_EXECUTOR_WORKERS -from .core import DASHBOARD -from .web_server import make_app, start_web_server - -ENV_DEV = "ESPHOME_DASHBOARD_DEV" - -settings = DASHBOARD.settings - - -def can_use_pidfd() -> bool: - """Check if pidfd_open is available. - - Back ported from cpython 3.12 - """ - if not hasattr(os, "pidfd_open"): - return False - try: - pid = os.getpid() - os.close(os.pidfd_open(pid, 0)) - except OSError: - # blocked by security policy like SECCOMP - return False - return True - - -class DashboardEventLoopPolicy(asyncio.DefaultEventLoopPolicy): - """Event loop policy for Home Assistant.""" - - def __init__(self, debug: bool) -> None: - """Init the event loop policy.""" - super().__init__() - self.debug = debug - self._watcher: asyncio.AbstractChildWatcher | None = None - - def _init_watcher(self) -> None: - """Initialize the watcher for child processes. - - Back ported from cpython 3.12 - """ - with events._lock: # type: ignore[attr-defined] # pylint: disable=protected-access - if self._watcher is None: # pragma: no branch - if can_use_pidfd(): - self._watcher = asyncio.PidfdChildWatcher() - else: - self._watcher = asyncio.ThreadedChildWatcher() - if threading.current_thread() is threading.main_thread(): - self._watcher.attach_loop( - self._local._loop # type: ignore[attr-defined] # pylint: disable=protected-access - ) - - @property - def loop_name(self) -> str: - """Return name of the loop.""" - return self._loop_factory.__name__ # type: ignore[no-any-return,attr-defined] - - def new_event_loop(self) -> asyncio.AbstractEventLoop: - """Get the event loop.""" - loop: asyncio.AbstractEventLoop = super().new_event_loop() - loop.set_exception_handler(_async_loop_exception_handler) - - if self.debug: - loop.set_debug(True) - - executor = ThreadPoolExecutor( - thread_name_prefix="SyncWorker", max_workers=MAX_EXECUTOR_WORKERS - ) - loop.set_default_executor(executor) - # bind the built-in time.monotonic directly as loop.time to avoid the - # overhead of the additional method call since its the most called loop - # method and its roughly 10%+ of all the call time in base_events.py - loop.time = monotonic # type: ignore[method-assign] - return loop - - -def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None: - """Handle all exception inside the core loop.""" - kwargs = {} - if exception := context.get("exception"): - kwargs["exc_info"] = (type(exception), exception, exception.__traceback__) - - logger = logging.getLogger(__package__) - if source_traceback := context.get("source_traceback"): - stack_summary = "".join(traceback.format_list(source_traceback)) - logger.error( - "Error doing job: %s: %s", - context["message"], - stack_summary, - **kwargs, # type: ignore[arg-type] - ) - return - - logger.error( - "Error doing job: %s", - context["message"], - **kwargs, # type: ignore[arg-type] - ) - - -def start_dashboard(args) -> None: - """Start the dashboard.""" - settings.parse_args(args) - - if settings.using_auth: - path = esphome_storage_path() - storage = EsphomeStorageJSON.load(path) - if storage is None: - storage = EsphomeStorageJSON.get_default() - storage.save(path) - settings.cookie_secret = storage.cookie_secret - - asyncio.set_event_loop_policy(DashboardEventLoopPolicy(settings.verbose)) - - with contextlib.suppress(KeyboardInterrupt): - asyncio.run(async_start(args)) - - -async def async_start(args) -> None: - """Start the dashboard.""" - dashboard = DASHBOARD - await dashboard.async_setup() - sock: socket.socket | None = args.socket - address: str | None = args.address - port: int | None = args.port - - start_web_server(make_app(args.verbose), sock, address, port, settings.config_dir) - - if args.open_ui: - import webbrowser - - webbrowser.open(f"http://{args.address}:{args.port}") - - try: - await dashboard.async_run() - finally: - if sock: - Path(sock).unlink() diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py deleted file mode 100644 index eb4a87dbfb..0000000000 --- a/esphome/dashboard/dns.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -import asyncio -from contextlib import suppress -from ipaddress import ip_address -import logging - -from icmplib import NameLookupError, async_resolve - -RESOLVE_TIMEOUT = 3.0 - -_LOGGER = logging.getLogger(__name__) - -_RESOLVE_EXCEPTIONS = (TimeoutError, NameLookupError, UnicodeError) - - -async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception: - """Wrap the icmplib async_resolve function.""" - with suppress(ValueError): - return [str(ip_address(hostname))] - try: - async with asyncio.timeout(RESOLVE_TIMEOUT): - return await async_resolve(hostname) - except _RESOLVE_EXCEPTIONS as ex: - # If the hostname ends with .local and resolution failed, - # try the bare hostname as a fallback since mDNS may not be - # working on the system but unicast DNS might resolve it - if hostname.endswith(".local"): - bare_hostname = hostname[:-6] # Remove ".local" - try: - async with asyncio.timeout(RESOLVE_TIMEOUT): - result = await async_resolve(bare_hostname) - _LOGGER.debug( - "Bare hostname %s resolved to %s", bare_hostname, result - ) - return result - except _RESOLVE_EXCEPTIONS: - _LOGGER.debug("Bare hostname %s also failed to resolve", bare_hostname) - return ex - - -class DNSCache: - """DNS cache for the dashboard.""" - - def __init__(self, ttl: int | None = 120) -> None: - """Initialize the DNSCache.""" - self._cache: dict[str, tuple[float, list[str] | Exception]] = {} - self._ttl = ttl - - def get_cached_addresses( - self, hostname: str, now_monotonic: float - ) -> list[str] | None: - """Get cached addresses without triggering resolution. - - Returns None if not in cache, list of addresses if found. - """ - # Normalize hostname for consistent lookups - normalized = hostname.rstrip(".").lower() - if expire_time_addresses := self._cache.get(normalized): - expire_time, addresses = expire_time_addresses - if expire_time > now_monotonic and not isinstance(addresses, Exception): - return addresses - return None - - async def async_resolve( - self, hostname: str, now_monotonic: float - ) -> list[str] | Exception: - """Resolve a hostname to a list of IP address.""" - if expire_time_addresses := self._cache.get(hostname): - expire_time, addresses = expire_time_addresses - if expire_time > now_monotonic: - return addresses - - expires = now_monotonic + self._ttl - addresses = await _async_resolve_wrapper(hostname) - self._cache[hostname] = (expires, addresses) - return addresses diff --git a/esphome/dashboard/entries.py b/esphome/dashboard/entries.py deleted file mode 100644 index 95b8a7b2ae..0000000000 --- a/esphome/dashboard/entries.py +++ /dev/null @@ -1,458 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections import defaultdict -from dataclasses import dataclass -from functools import lru_cache -import logging -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from esphome import const, util -from esphome.enum import StrEnum -from esphome.storage_json import StorageJSON, ext_storage_path - -from .const import DASHBOARD_COMMAND, DashboardEvent -from .util.subprocess import async_run_system_command - -if TYPE_CHECKING: - from .core import ESPHomeDashboard - -_LOGGER = logging.getLogger(__name__) - - -DashboardCacheKeyType = tuple[int, int, float, int] - - -@dataclass(frozen=True) -class EntryState: - """Represents the state of an entry.""" - - reachable: ReachableState - source: EntryStateSource - - -class EntryStateSource(StrEnum): - MDNS = "mdns" - PING = "ping" - MQTT = "mqtt" - UNKNOWN = "unknown" - - -class ReachableState(StrEnum): - ONLINE = "online" - OFFLINE = "offline" - DNS_FAILURE = "dns_failure" - UNKNOWN = "unknown" - - -_BOOL_TO_REACHABLE_STATE = { - True: ReachableState.ONLINE, - False: ReachableState.OFFLINE, - None: ReachableState.UNKNOWN, -} -_REACHABLE_STATE_TO_BOOL = { - ReachableState.ONLINE: True, - ReachableState.OFFLINE: False, - ReachableState.DNS_FAILURE: False, - ReachableState.UNKNOWN: None, -} - -UNKNOWN_STATE = EntryState(ReachableState.UNKNOWN, EntryStateSource.UNKNOWN) - - -@lru_cache # creating frozen dataclass instances is expensive, so we cache them -def bool_to_entry_state(value: bool | None, source: EntryStateSource) -> EntryState: - """Convert a bool to an entry state.""" - return EntryState(_BOOL_TO_REACHABLE_STATE[value], source) - - -def entry_state_to_bool(value: EntryState) -> bool | None: - """Convert an entry state to a bool.""" - return _REACHABLE_STATE_TO_BOOL[value.reachable] - - -class DashboardEntries: - """Represents all dashboard entries.""" - - __slots__ = ( - "_dashboard", - "_loop", - "_config_dir", - "_entries", - "_entry_states", - "_loaded_entries", - "_update_lock", - "_name_to_entry", - ) - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the DashboardEntries.""" - self._dashboard = dashboard - self._loop = asyncio.get_running_loop() - self._config_dir = dashboard.settings.config_dir - # Entries are stored as - # { - # "path/to/file.yaml": DashboardEntry, - # ... - # } - self._entries: dict[Path, DashboardEntry] = {} - self._loaded_entries = False - self._update_lock = asyncio.Lock() - self._name_to_entry: dict[str, set[DashboardEntry]] = defaultdict(set) - - def get(self, path: Path) -> DashboardEntry | None: - """Get an entry by path.""" - return self._entries.get(path) - - def get_by_name(self, name: str) -> set[DashboardEntry] | None: - """Get an entry by name.""" - return self._name_to_entry.get(name) - - async def _async_all(self) -> list[DashboardEntry]: - """Return all entries.""" - return list(self._entries.values()) - - def all(self) -> list[DashboardEntry]: - """Return all entries.""" - return asyncio.run_coroutine_threadsafe(self._async_all(), self._loop).result() - - def async_all(self) -> list[DashboardEntry]: - """Return all entries.""" - return list(self._entries.values()) - - def set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state(entry, state), self._loop - ).result() - - async def _async_set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - self.async_set_state(entry, state) - - def set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state_if_online_or_source(entry, state), self._loop - ).result() - - async def _async_set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - self.async_set_state_if_online_or_source(entry, state) - - def async_set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - if ( - state.reachable is ReachableState.ONLINE - and entry.state.reachable is not ReachableState.ONLINE - ) or entry.state.source in ( - EntryStateSource.UNKNOWN, - state.source, - ): - self.async_set_state(entry, state) - - def set_state_if_source(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry if provided by the source or unknown.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state_if_source(entry, state), self._loop - ).result() - - async def _async_set_state_if_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if rovided by the source or unknown.""" - self.async_set_state_if_source(entry, state) - - def async_set_state_if_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if provided by the source or unknown.""" - if entry.state.source in ( - EntryStateSource.UNKNOWN, - state.source, - ): - self.async_set_state(entry, state) - - def async_set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - if entry.state == state: - return - entry.state = state - self._dashboard.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - async def async_request_update_entries(self) -> None: - """Request an update of the dashboard entries from disk. - - If an update is already in progress, this will do nothing. - """ - if self._update_lock.locked(): - _LOGGER.debug("Dashboard entries are already being updated") - return - await self.async_update_entries() - - async def async_update_entries(self) -> None: - """Update the dashboard entries from disk.""" - async with self._update_lock: - await self._async_update_entries() - - def _load_entries( - self, entries: dict[DashboardEntry, DashboardCacheKeyType] - ) -> None: - """Load all entries from disk.""" - for entry, cache_key in entries.items(): - _LOGGER.debug( - "Loading dashboard entry %s because cache key changed: %s", - entry.path, - cache_key, - ) - entry.load_from_disk(cache_key) - - async def _async_update_entries(self) -> list[DashboardEntry]: - """Sync the dashboard entries from disk.""" - _LOGGER.debug("Updating dashboard entries") - # At some point it would be nice to use watchdog to avoid polling - - path_to_cache_key = await self._loop.run_in_executor( - None, self._get_path_to_cache_key - ) - entries = self._entries - name_to_entry = self._name_to_entry - added: dict[DashboardEntry, DashboardCacheKeyType] = {} - updated: dict[DashboardEntry, DashboardCacheKeyType] = {} - removed: set[DashboardEntry] = { - entry - for filename, entry in entries.items() - if filename not in path_to_cache_key - } - original_names: dict[DashboardEntry, str] = {} - - for path, cache_key in path_to_cache_key.items(): - if not (entry := entries.get(path)): - entry = DashboardEntry(path, cache_key) - added[entry] = cache_key - continue - - if entry.cache_key != cache_key: - updated[entry] = cache_key - original_names[entry] = entry.name - - if added or updated: - await self._loop.run_in_executor( - None, self._load_entries, {**added, **updated} - ) - - bus = self._dashboard.bus - for entry in added: - entries[entry.path] = entry - name_to_entry[entry.name].add(entry) - bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": entry}) - - for entry in removed: - del entries[entry.path] - name_to_entry[entry.name].discard(entry) - bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": entry}) - - for entry in updated: - if (original_name := original_names[entry]) != (current_name := entry.name): - name_to_entry[original_name].discard(entry) - name_to_entry[current_name].add(entry) - bus.async_fire(DashboardEvent.ENTRY_UPDATED, {"entry": entry}) - - def _get_path_to_cache_key(self) -> dict[Path, DashboardCacheKeyType]: - """Return a dict of path to cache key.""" - path_to_cache_key: dict[Path, DashboardCacheKeyType] = {} - # - # The cache key is (inode, device, mtime, size) - # which allows us to avoid locking since it ensures - # every iteration of this call will always return the newest - # items from disk at the cost of a stat() call on each - # file which is much faster than reading the file - # for the cache hit case which is the common case. - # - for file in util.list_yaml_files([self._config_dir]): - try: - # Prefer the json storage path if it exists - stat = ext_storage_path(file.name).stat() - except OSError: - try: - # Fallback to the yaml file if the storage - # file does not exist or could not be generated - stat = file.stat() - except OSError: - # File was deleted, ignore - continue - path_to_cache_key[file] = ( - stat.st_ino, - stat.st_dev, - stat.st_mtime, - stat.st_size, - ) - return path_to_cache_key - - def async_schedule_storage_json_update(self, filename: str) -> None: - """Schedule a task to update the storage JSON file.""" - self._dashboard.async_create_background_task( - async_run_system_command( - [*DASHBOARD_COMMAND, "compile", "--only-generate", filename] - ) - ) - - -class DashboardEntry: - """Represents a single dashboard entry. - - This class is thread-safe and read-only. - """ - - __slots__ = ( - "path", - "filename", - "_storage_path", - "cache_key", - "storage", - "state", - "_to_dict", - ) - - def __init__(self, path: Path, cache_key: DashboardCacheKeyType) -> None: - """Initialize the DashboardEntry.""" - self.path = path - self.filename: str = path.name - self._storage_path = ext_storage_path(self.filename) - self.cache_key = cache_key - self.storage: StorageJSON | None = None - self.state = UNKNOWN_STATE - self._to_dict: dict[str, Any] | None = None - - def __repr__(self) -> str: - """Return the representation of this entry.""" - return ( - f"DashboardEntry(path={self.path} " - f"address={self.address} " - f"web_port={self.web_port} " - f"name={self.name} " - f"no_mdns={self.no_mdns} " - f"state={self.state} " - ")" - ) - - def to_dict(self) -> dict[str, Any]: - """Return a dict representation of this entry. - - The dict includes the loaded configuration but not - the current state of the entry. - """ - if self._to_dict is None: - self._to_dict = { - "name": self.name, - "friendly_name": self.friendly_name, - "configuration": self.filename, - "loaded_integrations": sorted(self.loaded_integrations), - "deployed_version": self.update_old, - "current_version": self.update_new, - "path": str(self.path), - "comment": self.comment, - "address": self.address, - "web_port": self.web_port, - "target_platform": self.target_platform, - } - return self._to_dict - - def load_from_disk(self, cache_key: DashboardCacheKeyType | None = None) -> None: - """Load this entry from disk.""" - self.storage = StorageJSON.load(self._storage_path) - self._to_dict = None - # - # Currently StorageJSON.load() will return None if the file does not exist - # - # StorageJSON currently does not provide an updated cache key so we use the - # one that is passed in. - # - # The cache key was read from the disk moments ago and may be stale but - # it does not matter since we are polling anyways, and the next call to - # async_update_entries() will load it again in the extremely rare case that - # it changed between the two calls. - # - if cache_key: - self.cache_key = cache_key - - @property - def address(self) -> str | None: - """Return the address of this entry.""" - if self.storage is None: - return None - return self.storage.address - - @property - def no_mdns(self) -> bool | None: - """Return the no_mdns of this entry.""" - if self.storage is None: - return None - return self.storage.no_mdns - - @property - def web_port(self) -> int | None: - """Return the web port of this entry.""" - if self.storage is None: - return None - return self.storage.web_port - - @property - def name(self) -> str: - """Return the name of this entry.""" - if self.storage is None: - return self.filename.replace(".yml", "").replace(".yaml", "") - return self.storage.name - - @property - def friendly_name(self) -> str: - """Return the friendly name of this entry.""" - if self.storage is None: - return self.name - return self.storage.friendly_name - - @property - def comment(self) -> str | None: - """Return the comment of this entry.""" - if self.storage is None: - return None - return self.storage.comment - - @property - def target_platform(self) -> str | None: - """Return the target platform of this entry.""" - if self.storage is None: - return None - return self.storage.target_platform - - @property - def update_available(self) -> bool: - """Return if an update is available for this entry.""" - if self.storage is None: - return True - return self.update_old != self.update_new - - @property - def update_old(self) -> str: - if self.storage is None: - return "" - return self.storage.esphome_version or "" - - @property - def update_new(self) -> str: - return const.__version__ - - @property - def loaded_integrations(self) -> set[str]: - if self.storage is None: - return [] - return self.storage.loaded_integrations diff --git a/esphome/dashboard/models.py b/esphome/dashboard/models.py deleted file mode 100644 index 47ddddd5ce..0000000000 --- a/esphome/dashboard/models.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Data models and builders for the dashboard.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, TypedDict - -if TYPE_CHECKING: - from esphome.zeroconf import DiscoveredImport - - from .core import ESPHomeDashboard - from .entries import DashboardEntry - - -class ImportableDeviceDict(TypedDict): - """Dictionary representation of an importable device.""" - - name: str - friendly_name: str | None - package_import_url: str - project_name: str - project_version: str - network: str - ignored: bool - - -class ConfiguredDeviceDict(TypedDict, total=False): - """Dictionary representation of a configured device.""" - - name: str - friendly_name: str | None - configuration: str - loaded_integrations: list[str] | None - deployed_version: str | None - current_version: str | None - path: str - comment: str | None - address: str | None - web_port: int | None - target_platform: str | None - - -class DeviceListResponse(TypedDict): - """Response for device list API.""" - - configured: list[ConfiguredDeviceDict] - importable: list[ImportableDeviceDict] - - -def build_importable_device_dict( - dashboard: ESPHomeDashboard, discovered: DiscoveredImport -) -> ImportableDeviceDict: - """Build the importable device dictionary.""" - return ImportableDeviceDict( - name=discovered.device_name, - friendly_name=discovered.friendly_name, - package_import_url=discovered.package_import_url, - project_name=discovered.project_name, - project_version=discovered.project_version, - network=discovered.network, - ignored=discovered.device_name in dashboard.ignored_devices, - ) - - -def build_device_list_response( - dashboard: ESPHomeDashboard, entries: list[DashboardEntry] -) -> DeviceListResponse: - """Build the device list response data.""" - configured = {entry.name for entry in entries} - return DeviceListResponse( - configured=[entry.to_dict() for entry in entries], - importable=[ - build_importable_device_dict(dashboard, res) - for res in dashboard.import_result.values() - if res.device_name not in configured - ], - ) diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py deleted file mode 100644 index 3b22180b1d..0000000000 --- a/esphome/dashboard/settings.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import hmac -import os -from pathlib import Path -from typing import Any - -from esphome.core import CORE -from esphome.helpers import get_bool_env - -from .util.password import password_hash - -# Sentinel file name used for CORE.config_path when dashboard initializes. -# This ensures .parent returns the config directory instead of root. -_DASHBOARD_SENTINEL_FILE = "___DASHBOARD_SENTINEL___.yaml" - - -class DashboardSettings: - """Settings for the dashboard.""" - - __slots__ = ( - "config_dir", - "password_hash", - "username", - "using_password", - "on_ha_addon", - "cookie_secret", - "absolute_config_dir", - "verbose", - ) - - def __init__(self) -> None: - """Initialize the dashboard settings.""" - self.config_dir: Path = None - self.password_hash: bytes = b"" - self.username: str = "" - self.using_password: bool = False - self.on_ha_addon: bool = False - self.cookie_secret: str | None = None - self.absolute_config_dir: Path | None = None - self.verbose: bool = False - - def parse_args(self, args: Any) -> None: - """Parse the arguments.""" - self.on_ha_addon: bool = args.ha_addon - password = args.password or os.getenv("PASSWORD") or "" - if not self.on_ha_addon: - self.username = args.username or os.getenv("USERNAME") or "" - self.using_password = bool(password) - if self.using_password: - self.password_hash = password_hash(password) - self.config_dir = Path(args.configuration) - self.absolute_config_dir = self.config_dir.resolve() - self.verbose = args.verbose - # Set to a sentinel file so .parent gives us the config directory. - # Previously this was `os.path.join(self.config_dir, ".")` which worked because - # os.path.dirname("/config/.") returns "/config", but Path("/config/.").parent - # normalizes to Path("/config") first, then .parent returns Path("/"), breaking - # secret resolution. Using a sentinel file ensures .parent gives the correct directory. - CORE.config_path = self.config_dir / _DASHBOARD_SENTINEL_FILE - - @property - def relative_url(self) -> str: - return os.getenv("ESPHOME_DASHBOARD_RELATIVE_URL") or "/" - - @property - def status_use_mqtt(self) -> bool: - return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT") - - @property - def using_ha_addon_auth(self) -> bool: - if not self.on_ha_addon: - return False - return not get_bool_env("DISABLE_HA_AUTHENTICATION") - - @property - def using_auth(self) -> bool: - return self.using_password or self.using_ha_addon_auth - - @property - def streamer_mode(self) -> bool: - return get_bool_env("ESPHOME_STREAMER_MODE") - - def check_password(self, username: str, password: str) -> bool: - if not self.using_auth: - return True - # Compare in constant running time (to prevent timing attacks) - username_matches = hmac.compare_digest( - username.encode("utf-8"), self.username.encode("utf-8") - ) - password_matches = hmac.compare_digest( - self.password_hash, password_hash(password) - ) - return username_matches and password_matches - - def rel_path(self, *args: Any) -> Path: - """Return a path relative to the ESPHome config folder.""" - joined_path = self.config_dir / Path(*args) - # Raises ValueError if not relative to ESPHome config folder - joined_path.resolve().relative_to(self.absolute_config_dir) - return joined_path diff --git a/esphome/dashboard/status/__init__.py b/esphome/dashboard/status/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py deleted file mode 100644 index 9da9bb8f01..0000000000 --- a/esphome/dashboard/status/mdns.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import typing - -from zeroconf import AddressResolver, IPVersion - -from esphome.address_cache import normalize_hostname -from esphome.zeroconf import ( - ESPHOME_SERVICE_TYPE, - AsyncEsphomeZeroconf, - DashboardBrowser, - DashboardImportDiscovery, - DashboardStatus, - DiscoveredImport, -) - -from ..const import SENTINEL, DashboardEvent -from ..entries import DashboardEntry, EntryStateSource, bool_to_entry_state -from ..models import build_importable_device_dict - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - -_LOGGER = logging.getLogger(__name__) - - -class MDNSStatus: - """Class that updates the mdns status.""" - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the MDNSStatus class.""" - super().__init__() - self.aiozc: AsyncEsphomeZeroconf | None = None - # This is the current mdns state for each host (True, False, None) - self.host_mdns_state: dict[str, bool | None] = {} - self._loop = asyncio.get_running_loop() - self.dashboard = dashboard - - def async_setup(self) -> bool: - """Set up the MDNSStatus class.""" - try: - self.aiozc = AsyncEsphomeZeroconf() - except OSError as e: - _LOGGER.warning( - "Failed to initialize zeroconf, will fallback to ping: %s", e - ) - return False - return True - - async def async_resolve_host(self, host_name: str) -> list[str] | None: - """Resolve a host name to an address in a thread-safe manner.""" - if aiozc := self.aiozc: - return await aiozc.async_resolve_host(host_name) - return None - - def get_cached_addresses(self, host_name: str) -> list[str] | None: - """Get cached addresses for a host without triggering resolution. - - Returns None if not in cache or no zeroconf available. - """ - if not self.aiozc: - _LOGGER.debug("No zeroconf instance available for %s", host_name) - return None - - # Normalize hostname and get the base name - normalized = normalize_hostname(host_name) - base_name = normalized.partition(".")[0] - - # Try to load from zeroconf cache without triggering resolution - resolver_name = f"{base_name}.local." - info = AddressResolver(resolver_name) - # Let zeroconf use its own current time for cache checking - if info.load_from_cache(self.aiozc.zeroconf): - addresses = info.parsed_scoped_addresses(IPVersion.All) - _LOGGER.debug("Found %s in zeroconf cache: %s", resolver_name, addresses) - return addresses - _LOGGER.debug("Not found in zeroconf cache: %s", resolver_name) - return None - - def _on_import_update(self, name: str, discovered: DiscoveredImport | None) -> None: - """Handle importable device updates.""" - if discovered is None: - # Device removed - self.dashboard.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": name} - ) - else: - # Device added - self.dashboard.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, - {"device": build_importable_device_dict(self.dashboard, discovered)}, - ) - - async def async_refresh_hosts(self) -> None: - """Refresh the hosts to track.""" - dashboard = self.dashboard - host_mdns_state = self.host_mdns_state - entries = dashboard.entries - poll_names: dict[str, set[DashboardEntry]] = {} - for entry in entries.async_all(): - if entry.no_mdns: - continue - # If we just adopted/imported this host, we likely - # already have a state for it, so we should make sure - # to set it so the dashboard shows it as online - if entry.loaded_integrations and "api" not in entry.loaded_integrations: - # No api available so we have to poll since - # the device won't respond to a request to ._esphomelib._tcp.local. - poll_names.setdefault(entry.name, set()).add(entry) - elif (online := host_mdns_state.get(entry.name, SENTINEL)) != SENTINEL: - self._async_set_state(entry, online) - if poll_names and self.aiozc: - results = await asyncio.gather( - *(self.aiozc.async_resolve_host(name) for name in poll_names) - ) - for name, address_list in zip(poll_names, results, strict=True): - result = bool(address_list) - host_mdns_state[name] = result - for entry in poll_names[name]: - self._async_set_state(entry, result) - - def _async_set_state(self, entry: DashboardEntry, result: bool | None) -> None: - """Set the state of an entry.""" - state = bool_to_entry_state(result, EntryStateSource.MDNS) - if result: - # If we can reach it via mDNS, we always set it online - # since its the fastest source if its working - self.dashboard.entries.async_set_state(entry, state) - else: - # However if we can't reach it via mDNS - # we only set it to offline if the state is unknown - # or from mDNS - self.dashboard.entries.async_set_state_if_source(entry, state) - - async def async_run(self) -> None: - """Run the mdns status.""" - dashboard = self.dashboard - entries = dashboard.entries - host_mdns_state = self.host_mdns_state - - def on_update(dat: dict[str, bool | None]) -> None: - """Update the entry state.""" - for name, result in dat.items(): - host_mdns_state[name] = result - if matching_entries := entries.get_by_name(name): - for entry in matching_entries: - self._async_set_state(entry, result) - - stat = DashboardStatus(on_update) - - imports = DashboardImportDiscovery(self._on_import_update) - dashboard.import_result = imports.import_state - - browser = DashboardBrowser( - self.aiozc.zeroconf, - ESPHOME_SERVICE_TYPE, - [stat.browser_callback, imports.browser_callback], - ) - - ping_request = dashboard.ping_request - while not dashboard.stop_event.is_set(): - await self.async_refresh_hosts() - await ping_request.wait() - ping_request.clear() - - await browser.async_cancel() - await self.aiozc.async_close() - self.aiozc = None diff --git a/esphome/dashboard/status/mqtt.py b/esphome/dashboard/status/mqtt.py deleted file mode 100644 index c3e4883849..0000000000 --- a/esphome/dashboard/status/mqtt.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import binascii -import json -import os -import threading -import typing - -from esphome import mqtt - -from ..entries import EntryStateSource, bool_to_entry_state - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - - -class MqttStatusThread(threading.Thread): - """Status thread to get the status of the devices via MQTT.""" - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the status thread.""" - super().__init__() - self.dashboard = dashboard - - def run(self) -> None: - """Run the status thread.""" - dashboard = self.dashboard - entries = dashboard.entries - current_entries = entries.all() - - config = mqtt.config_from_env() - topic = "esphome/discover/#" - - def on_message(client, userdata, msg): - payload = msg.payload.decode(errors="backslashreplace") - if len(payload) > 0: - data = json.loads(payload) - if "name" not in data: - return - if matching_entries := entries.get_by_name(data["name"]): - for entry in matching_entries: - # Only override state if we don't have a state from another source - # or we have a state from MQTT and the device is reachable - entries.set_state_if_online_or_source( - entry, bool_to_entry_state(True, EntryStateSource.MQTT) - ) - - def on_connect(client, userdata, flags, return_code): - client.publish("esphome/discover", None, retain=False) - - mqttid = str(binascii.hexlify(os.urandom(6)).decode()) - - client = mqtt.prepare( - config, - [topic], - on_message, - on_connect, - None, - None, - f"esphome-dashboard-{mqttid}", - ) - client.loop_start() - - while not dashboard.stop_event.wait(2): - current_entries = entries.all() - # will be set to true on on_message - for entry in current_entries: - # Only override state if we don't have a state from another source - entries.set_state_if_source( - entry, bool_to_entry_state(False, EntryStateSource.MQTT) - ) - - client.publish("esphome/discover", None, retain=False) - dashboard.mqtt_ping_request.wait() - dashboard.mqtt_ping_request.clear() - - client.disconnect() - client.loop_stop() diff --git a/esphome/dashboard/status/ping.py b/esphome/dashboard/status/ping.py deleted file mode 100644 index eb69fbb9b3..0000000000 --- a/esphome/dashboard/status/ping.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import time -import typing -from typing import cast - -from icmplib import Host, SocketPermissionError, async_ping - -from ..const import MAX_EXECUTOR_WORKERS -from ..entries import ( - DashboardEntry, - EntryState, - EntryStateSource, - ReachableState, - bool_to_entry_state, -) -from ..util.itertools import chunked - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - - -_LOGGER = logging.getLogger(__name__) - -GROUP_SIZE = int(MAX_EXECUTOR_WORKERS / 2) - -DNS_FAILURE_STATE = EntryState(ReachableState.DNS_FAILURE, EntryStateSource.PING) - -MIN_PING_INTERVAL = 5 # ensure we don't ping too often - - -class PingStatus: - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the PingStatus class.""" - super().__init__() - self._loop = asyncio.get_running_loop() - self.dashboard = dashboard - - async def async_run(self) -> None: - """Run the ping status.""" - dashboard = self.dashboard - entries = dashboard.entries - privileged = await _can_use_icmp_lib_with_privilege() - if privileged is None: - _LOGGER.warning("Cannot use icmplib because privileges are insufficient") - return - - while not dashboard.stop_event.is_set(): - # Only ping if the dashboard is open - await dashboard.ping_request.wait() - dashboard.ping_request.clear() - iteration_start = time.monotonic() - current_entries = dashboard.entries.async_all() - to_ping: list[DashboardEntry] = [] - - for entry in current_entries: - if entry.address is None: - # No address or we already have a state from another source - # so no need to ping - continue - if ( - entry.state.reachable is ReachableState.ONLINE - and entry.state.source - not in (EntryStateSource.PING, EntryStateSource.UNKNOWN) - ): - # If we already have a state from another source and - # it's online, we don't need to ping - continue - to_ping.append(entry) - - # Resolve DNS for all entries - entries_with_addresses: dict[DashboardEntry, list[str]] = {} - for ping_group in chunked(to_ping, GROUP_SIZE): - ping_group = cast(list[DashboardEntry], ping_group) - now_monotonic = time.monotonic() - dns_results = await asyncio.gather( - *( - dashboard.dns_cache.async_resolve(entry.address, now_monotonic) - for entry in ping_group - ), - return_exceptions=True, - ) - - for entry, result in zip(ping_group, dns_results, strict=True): - if isinstance(result, Exception): - # Only update state if its unknown or from ping - # so we don't mark it as offline if we have a state - # from mDNS or MQTT - entries.async_set_state_if_source(entry, DNS_FAILURE_STATE) - continue - if isinstance(result, BaseException): - raise result - entries_with_addresses[entry] = result - - # Ping all entries with valid addresses - for ping_group in chunked(entries_with_addresses.items(), GROUP_SIZE): - entry_addresses = cast(tuple[DashboardEntry, list[str]], ping_group) - - results = await asyncio.gather( - *( - async_ping(addresses[0], privileged=privileged) - for _, addresses in entry_addresses - ), - return_exceptions=True, - ) - - for entry_address, result in zip(entry_addresses, results, strict=True): - if isinstance(result, Exception): - ping_result = False - elif isinstance(result, BaseException): - raise result - else: - host: Host = result - ping_result = host.is_alive - entry: DashboardEntry = entry_address[0] - # If we can reach it via ping, we always set it - # online, however if we can't reach it via ping - # we only set it to offline if the state is unknown - # or from ping - entries.async_set_state_if_online_or_source( - entry, - bool_to_entry_state(ping_result, EntryStateSource.PING), - ) - - if not dashboard.stop_event.is_set(): - iteration_duration = time.monotonic() - iteration_start - if iteration_duration < MIN_PING_INTERVAL: - await asyncio.sleep(MIN_PING_INTERVAL - iteration_duration) - - -async def _can_use_icmp_lib_with_privilege() -> None | bool: - """Verify we can create a raw socket.""" - try: - await async_ping("127.0.0.1", count=0, timeout=0, privileged=True) - except SocketPermissionError: - try: - await async_ping("127.0.0.1", count=0, timeout=0, privileged=False) - except SocketPermissionError: - _LOGGER.debug( - "Cannot use icmplib because privileges are insufficient to create the" - " socket" - ) - return None - - _LOGGER.debug("Using icmplib in privileged=False mode") - return False - - _LOGGER.debug("Using icmplib in privileged=True mode") - return True diff --git a/esphome/dashboard/util/__init__.py b/esphome/dashboard/util/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/esphome/dashboard/util/itertools.py b/esphome/dashboard/util/itertools.py deleted file mode 100644 index 54e95ef802..0000000000 --- a/esphome/dashboard/util/itertools.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable -from functools import partial -from itertools import islice -from typing import Any - - -def take(take_num: int, iterable: Iterable) -> list[Any]: - """Return first n items of the iterable as a list. - - From itertools recipes - """ - return list(islice(iterable, take_num)) - - -def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: - """Break *iterable* into lists of length *n*. - - From more-itertools - """ - return iter(partial(take, chunked_num, iter(iterable)), []) diff --git a/esphome/dashboard/util/password.py b/esphome/dashboard/util/password.py deleted file mode 100644 index e7ea28c25d..0000000000 --- a/esphome/dashboard/util/password.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -import hashlib - - -def password_hash(password: str) -> bytes: - """Create a hash of a password to transform it to a fixed-length digest. - - Note this is not meant for secure storage, but for securely comparing passwords. - """ - return hashlib.sha256(password.encode()).digest() diff --git a/esphome/dashboard/util/subprocess.py b/esphome/dashboard/util/subprocess.py deleted file mode 100644 index 583dd116e3..0000000000 --- a/esphome/dashboard/util/subprocess.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Iterable - - -async def async_system_command_status(command: Iterable[str]) -> bool: - """Run a system command checking only the status.""" - process = await asyncio.create_subprocess_exec( - *command, - stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - close_fds=False, - ) - await process.wait() - return process.returncode == 0 - - -async def async_run_system_command(command: Iterable[str]) -> tuple[bool, bytes, bytes]: - """Run a system command and return a tuple of returncode, stdout, stderr.""" - process = await asyncio.create_subprocess_exec( - *command, - stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - close_fds=False, - ) - stdout, stderr = await process.communicate() - await process.wait() - return process.returncode, stdout, stderr diff --git a/esphome/dashboard/util/text.py b/esphome/dashboard/util/text.py deleted file mode 100644 index bdf9abfdb9..0000000000 --- a/esphome/dashboard/util/text.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Back-compat shim for ``friendly_name_slugify``. - -The function moved to :mod:`esphome.helpers` so it survives the legacy -dashboard's eventual removal — see the -``esphome.helpers.friendly_name_slugify`` docstring. This module -re-exports the name so existing -``from esphome.dashboard.util.text import friendly_name_slugify`` -imports keep working while downstream consumers migrate. -""" - -from __future__ import annotations - -from esphome.helpers import friendly_name_slugify - -__all__ = ["friendly_name_slugify"] diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py deleted file mode 100644 index f5203efe9c..0000000000 --- a/esphome/dashboard/web_server.py +++ /dev/null @@ -1,1645 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -import binascii -from collections.abc import Callable, Iterable -import contextlib -import datetime -import functools -from functools import partial -import gzip -import hashlib -import importlib -import json -import logging -import os -from pathlib import Path -import secrets -import shutil -import subprocess -import threading -import time -from typing import TYPE_CHECKING, Any, TypeVar -from urllib.parse import urlparse - -import tornado -import tornado.concurrent -import tornado.gen -import tornado.httpserver -import tornado.httputil -import tornado.ioloop -import tornado.iostream -from tornado.log import access_log -import tornado.netutil -import tornado.process -import tornado.queues -import tornado.web -import tornado.websocket -import voluptuous as vol -import yaml -from yaml.nodes import Node - -from esphome import const, yaml_util -from esphome.helpers import get_bool_env, mkdir_p, sort_ip_addresses -from esphome.platformio import toolchain -from esphome.storage_json import ( - StorageJSON, - archive_storage_path, - ext_storage_path, - trash_storage_path, -) -from esphome.util import get_serial_ports, shlex_quote -from esphome.yaml_util import FastestAvailableSafeLoader - -from ..helpers import write_file -from .const import DASHBOARD_COMMAND, ESPHOME_COMMAND, DashboardEvent -from .core import DASHBOARD, ESPHomeDashboard, Event -from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool -from .models import build_device_list_response -from .util.subprocess import async_run_system_command -from .util.text import friendly_name_slugify - -if TYPE_CHECKING: - from requests import Response - -_LOGGER = logging.getLogger(__name__) - -ENV_DEV = "ESPHOME_DASHBOARD_DEV" - -COOKIE_AUTHENTICATED_YES = b"yes" - -AUTH_COOKIE_NAME = "authenticated" - - -settings = DASHBOARD.settings - - -def template_args() -> dict[str, Any]: - version = const.__version__ - if "b" in version: - docs_link = "https://beta.esphome.io/" - elif "dev" in version: - docs_link = "https://next.esphome.io/" - else: - docs_link = "https://www.esphome.io/" - - return { - "version": version, - "docs_link": docs_link, - "get_static_file_url": get_static_file_url, - "relative_url": settings.relative_url, - "streamer_mode": settings.streamer_mode, - "config_dir": settings.config_dir, - } - - -T = TypeVar("T", bound=Callable[..., Any]) - - -def authenticated(func: T) -> T: - @functools.wraps(func) - def decorator(self, *args: Any, **kwargs: Any): - if not is_authenticated(self): - self.redirect("./login") - return None - return func(self, *args, **kwargs) - - return decorator - - -def is_authenticated(handler: BaseHandler) -> bool: - """Check if the request is authenticated.""" - if settings.on_ha_addon: - # Handle ingress - disable auth on ingress port - # X-HA-Ingress is automatically stripped on the non-ingress server in nginx - header = handler.request.headers.get("X-HA-Ingress", "NO") - if str(header) == "YES": - return True - - if settings.using_auth: - if auth_header := handler.request.headers.get("Authorization"): - assert isinstance(auth_header, str) - if auth_header.startswith("Basic "): - try: - auth_decoded = base64.b64decode(auth_header[6:]).decode() - username, password = auth_decoded.split(":", 1) - except (binascii.Error, ValueError, UnicodeDecodeError): - return False - return settings.check_password(username, password) - return handler.get_secure_cookie(AUTH_COOKIE_NAME) == COOKIE_AUTHENTICATED_YES - - return True - - -def bind_config(func): - def decorator(self, *args, **kwargs): - configuration = self.get_argument("configuration") - kwargs = kwargs.copy() - kwargs["configuration"] = configuration - return func(self, *args, **kwargs) - - return decorator - - -# pylint: disable=abstract-method -class BaseHandler(tornado.web.RequestHandler): - pass - - -def websocket_class(cls): - # pylint: disable=protected-access - if not hasattr(cls, "_message_handlers"): - cls._message_handlers = {} - - for method in cls.__dict__.values(): - if hasattr(method, "_message_handler"): - cls._message_handlers[method._message_handler] = method - - return cls - - -def websocket_method(name): - def wrap(fn): - # pylint: disable=protected-access - fn._message_handler = name - return fn - - return wrap - - -class CheckOriginMixin: - """Mixin to handle WebSocket origin checks for reverse proxy setups.""" - - def check_origin(self, origin: str) -> bool: - if "ESPHOME_TRUSTED_DOMAINS" not in os.environ: - return super().check_origin(origin) - trusted_domains = [ - s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",") - ] - url = urlparse(origin) - if url.hostname in trusted_domains: - return True - _LOGGER.info("check_origin %s, domain is not trusted", origin) - return False - - -@websocket_class -class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): - """Base class for ESPHome websocket commands.""" - - def __init__( - self, - application: tornado.web.Application, - request: tornado.httputil.HTTPServerRequest, - **kwargs: Any, - ) -> None: - """Initialize the websocket.""" - super().__init__(application, request, **kwargs) - self._proc = None - self._queue = None - self._is_closed = False - # Windows doesn't support non-blocking pipes, - # use Popen() with a reading thread instead - self._use_popen = os.name == "nt" - - def open(self, *args: str, **kwargs: str) -> None: - """Handle new WebSocket connection.""" - # Ensure messages from the subprocess are sent immediately - # to avoid a 200-500ms delay when nodelay is not set. - self.set_nodelay(True) - - @authenticated - async def on_message( # pylint: disable=invalid-overridden-method - self, message: str - ) -> None: - # Since tornado 4.5, on_message is allowed to be a coroutine - # Messages are always JSON, 500 when not - json_message = json.loads(message) - type_ = json_message["type"] - # pylint: disable=no-member - handlers = type(self)._message_handlers - if type_ not in handlers: - _LOGGER.warning("Requested unknown message type %s", type_) - return - - await handlers[type_](self, json_message) - - @websocket_method("spawn") - async def handle_spawn(self, json_message: dict[str, Any]) -> None: - if self._proc is not None: - # spawn can only be called once - return - command = await self.build_command(json_message) - _LOGGER.info("Running command '%s'", " ".join(shlex_quote(x) for x in command)) - - if self._use_popen: - self._queue = tornado.queues.Queue() - # pylint: disable=consider-using-with - self._proc = subprocess.Popen( - command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - close_fds=False, - ) - stdout_thread = threading.Thread(target=self._stdout_thread) - stdout_thread.daemon = True - stdout_thread.start() - else: - self._proc = tornado.process.Subprocess( - command, - stdout=tornado.process.Subprocess.STREAM, - stderr=subprocess.STDOUT, - stdin=tornado.process.Subprocess.STREAM, - close_fds=False, - ) - self._proc.set_exit_callback(self._proc_on_exit) - - tornado.ioloop.IOLoop.current().spawn_callback(self._redirect_stdout) - - @property - def is_process_active(self) -> bool: - return self._proc is not None and self._proc.returncode is None - - @websocket_method("stdin") - async def handle_stdin(self, json_message: dict[str, Any]) -> None: - if not self.is_process_active: - return - text: str = json_message["data"] - data = text.encode("utf-8", "replace") - _LOGGER.debug("< stdin: %s", data) - self._proc.stdin.write(data) - - @tornado.gen.coroutine - def _redirect_stdout(self) -> None: - reg = b"[\n\r]" - - while True: - try: - if self._use_popen: - data: bytes = yield self._queue.get() - if data is None: - self._proc_on_exit(self._proc.poll()) - break - else: - data: bytes = yield self._proc.stdout.read_until_regex(reg) - except tornado.iostream.StreamClosedError: - break - - text = data.decode("utf-8", "replace") - _LOGGER.debug("> stdout: %s", text) - self.write_message({"event": "line", "data": text}) - - def _stdout_thread(self) -> None: - if not self._use_popen: - return - line = b"" - cr = False - while True: - data = self._proc.stdout.read(1) - if data: - if data == b"\r": - cr = True - elif data == b"\n": - self._queue.put_nowait(line + b"\n") - line = b"" - cr = False - elif cr: - self._queue.put_nowait(line + b"\r") - line = data - cr = False - else: - line += data - if self._proc.poll() is not None: - break - self._proc.wait(1.0) - self._queue.put_nowait(None) - - def _proc_on_exit(self, returncode: int) -> None: - if not self._is_closed: - # Check if the proc was not forcibly closed - _LOGGER.info("Process exited with return code %s", returncode) - self.write_message({"event": "exit", "code": returncode}) - self.close() - - def on_close(self) -> None: - # Check if proc exists (if 'start' has been run) - if self.is_process_active: - _LOGGER.debug("Terminating process") - if self._use_popen: - self._proc.terminate() - else: - self._proc.proc.terminate() - # Shutdown proc on WS close - self._is_closed = True - - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - raise NotImplementedError - - -def build_cache_arguments( - entry: DashboardEntry | None, - dashboard: ESPHomeDashboard, - now: float, -) -> list[str]: - """Build cache arguments for passing to CLI. - - Args: - entry: Dashboard entry for the configuration - dashboard: Dashboard instance with cache access - now: Current monotonic time for DNS cache expiry checks - - Returns: - List of cache arguments to pass to CLI - """ - cache_args: list[str] = [] - - if not entry: - return cache_args - - _LOGGER.debug( - "Building cache for entry (address=%s, name=%s)", - entry.address, - entry.name, - ) - - def add_cache_entry(hostname: str, addresses: list[str], cache_type: str) -> None: - """Add a cache entry to the command arguments.""" - if not addresses: - return - normalized = hostname.rstrip(".").lower() - cache_args.extend( - [ - f"--{cache_type}-address-cache", - f"{normalized}={','.join(sort_ip_addresses(addresses))}", - ] - ) - - # Check entry.address for cached addresses - if use_address := entry.address: - if use_address.endswith(".local"): - # mDNS cache for .local addresses - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(use_address) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "mdns") - # DNS cache for non-.local addresses - elif cached := dashboard.dns_cache.get_cached_addresses(use_address, now): - _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "dns") - - # Check entry.name if we haven't already cached via address - # For mDNS devices, entry.name typically doesn't have .local suffix - if entry.name and not use_address: - mdns_name = ( - f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name - ) - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(mdns_name) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) - add_cache_entry(mdns_name, cached, "mdns") - - return cache_args - - -class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): - """Base class for commands that require a port.""" - - async def build_device_command( - self, args: list[str], json_message: dict[str, Any] - ) -> list[str]: - """Build the command to run.""" - dashboard = DASHBOARD - entries = dashboard.entries - configuration = json_message["configuration"] - config_file = settings.rel_path(configuration) - port = json_message["port"] - - # Build cache arguments to pass to CLI - cache_args: list[str] = [] - - if ( - port == "OTA" # pylint: disable=too-many-boolean-expressions - and (entry := entries.get(config_file)) - and entry.loaded_integrations - and "api" in entry.loaded_integrations - ): - cache_args = build_cache_arguments(entry, dashboard, time.monotonic()) - - # Cache arguments must come before the subcommand - cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] - _LOGGER.debug("Built command: %s", cmd) - return cmd - - -class EsphomeLogsHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - cmd = await self.build_device_command(["logs"], json_message) - if json_message.get("no_states"): - cmd.append("--no-states") - _LOGGER.debug("Built command: %s", cmd) - return cmd - - -class EsphomeRenameHandler(EsphomeCommandWebSocket): - old_name: str - - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - self.old_name = json_message["configuration"] - return [ - *DASHBOARD_COMMAND, - "rename", - config_file, - json_message["newName"], - ] - - def _proc_on_exit(self, returncode): - super()._proc_on_exit(returncode) - - if returncode != 0: - return - - # Remove the old ping result from the cache - entries = DASHBOARD.entries - if entry := entries.get(self.old_name): - entries.async_set_state(entry, UNKNOWN_STATE) - - -class EsphomeUploadHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - return await self.build_device_command(["upload"], json_message) - - -class EsphomeRunHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - return await self.build_device_command(["run"], json_message) - - -class EsphomeCompileHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - command = [*DASHBOARD_COMMAND, "compile"] - if json_message.get("only_generate", False): - command.append("--only-generate") - command.append(config_file) - return command - - -class EsphomeValidateHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - command = [*DASHBOARD_COMMAND, "config", config_file] - if not settings.streamer_mode: - command.append("--show-secrets") - return command - - -class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - return [*DASHBOARD_COMMAND, "clean-mqtt", config_file] - - -class EsphomeCleanAllHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - clean_build_dir = json_message.get("clean_build_dir", True) - if clean_build_dir: - return [*DASHBOARD_COMMAND, "clean-all", settings.config_dir] - return [*DASHBOARD_COMMAND, "clean-all"] - - -class EsphomeCleanHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - return [*DASHBOARD_COMMAND, "clean", config_file] - - -class EsphomeVscodeHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "-q", "vscode", "dummy"] - - -class EsphomeAceEditorHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "-q", "vscode", "--ace", settings.config_dir] - - -class EsphomeUpdateAllHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "update-all", settings.config_dir] - - -# Dashboard polling constants -DASHBOARD_POLL_INTERVAL = 2 # seconds -DASHBOARD_ENTRIES_UPDATE_INTERVAL = 10 # seconds -DASHBOARD_ENTRIES_UPDATE_ITERATIONS = ( - DASHBOARD_ENTRIES_UPDATE_INTERVAL // DASHBOARD_POLL_INTERVAL -) - - -class DashboardSubscriber: - """Manages dashboard event polling task lifecycle based on active subscribers.""" - - def __init__(self) -> None: - """Initialize the dashboard subscriber.""" - self._subscribers: set[DashboardEventsWebSocket] = set() - self._event_loop_task: asyncio.Task | None = None - self._refresh_event: asyncio.Event = asyncio.Event() - - def subscribe(self, subscriber: DashboardEventsWebSocket) -> Callable[[], None]: - """Subscribe to dashboard updates and start event loop if needed.""" - self._subscribers.add(subscriber) - if not self._event_loop_task or self._event_loop_task.done(): - self._event_loop_task = asyncio.create_task(self._event_loop()) - _LOGGER.info("Started dashboard event loop") - return partial(self._unsubscribe, subscriber) - - def _unsubscribe(self, subscriber: DashboardEventsWebSocket) -> None: - """Unsubscribe from dashboard updates and stop event loop if no subscribers.""" - self._subscribers.discard(subscriber) - if ( - not self._subscribers - and self._event_loop_task - and not self._event_loop_task.done() - ): - self._event_loop_task.cancel() - self._event_loop_task = None - _LOGGER.info("Stopped dashboard event loop - no subscribers") - - def request_refresh(self) -> None: - """Signal the polling loop to refresh immediately.""" - self._refresh_event.set() - - async def _event_loop(self) -> None: - """Run the event polling loop while there are subscribers.""" - dashboard = DASHBOARD - entries_update_counter = 0 - - while self._subscribers: - # Signal that we need ping updates (non-blocking) - dashboard.ping_request.set() - if settings.status_use_mqtt: - dashboard.mqtt_ping_request.set() - - # Check if it's time to update entries or if refresh was requested - entries_update_counter += 1 - if ( - entries_update_counter >= DASHBOARD_ENTRIES_UPDATE_ITERATIONS - or self._refresh_event.is_set() - ): - entries_update_counter = 0 - await dashboard.entries.async_request_update_entries() - # Clear the refresh event if it was set - self._refresh_event.clear() - - # Wait for either timeout or refresh event - try: - async with asyncio.timeout(DASHBOARD_POLL_INTERVAL): - await self._refresh_event.wait() - # If we get here, refresh was requested - continue loop immediately - except TimeoutError: - # Normal timeout - continue with regular polling - pass - - -# Global dashboard subscriber instance -DASHBOARD_SUBSCRIBER = DashboardSubscriber() - - -@websocket_class -class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): - """WebSocket handler for real-time dashboard events.""" - - _event_listeners: list[Callable[[], None]] | None = None - _dashboard_unsubscribe: Callable[[], None] | None = None - - async def get(self, *args: str, **kwargs: str) -> None: - """Handle WebSocket upgrade request.""" - if not is_authenticated(self): - self.set_status(401) - self.finish("Unauthorized") - return - await super().get(*args, **kwargs) - - async def open(self, *args: str, **kwargs: str) -> None: # pylint: disable=invalid-overridden-method - """Handle new WebSocket connection.""" - # Ensure messages are sent immediately to avoid - # a 200-500ms delay when nodelay is not set. - self.set_nodelay(True) - - # Update entries first - await DASHBOARD.entries.async_request_update_entries() - # Send initial state - self._send_initial_state() - # Subscribe to events - self._subscribe_to_events() - # Subscribe to dashboard updates - self._dashboard_unsubscribe = DASHBOARD_SUBSCRIBER.subscribe(self) - _LOGGER.debug("Dashboard status WebSocket opened") - - def _send_initial_state(self) -> None: - """Send initial device list and ping status.""" - entries = DASHBOARD.entries.async_all() - - # Send initial state - self._safe_send_message( - { - "event": DashboardEvent.INITIAL_STATE, - "data": { - "devices": build_device_list_response(DASHBOARD, entries), - "ping": { - entry.filename: entry_state_to_bool(entry.state) - for entry in entries - }, - }, - } - ) - - def _subscribe_to_events(self) -> None: - """Subscribe to dashboard events.""" - async_add_listener = DASHBOARD.bus.async_add_listener - # Subscribe to all events - self._event_listeners = [ - async_add_listener( - DashboardEvent.ENTRY_STATE_CHANGED, self._on_entry_state_changed - ), - async_add_listener( - DashboardEvent.ENTRY_ADDED, - self._make_entry_handler(DashboardEvent.ENTRY_ADDED), - ), - async_add_listener( - DashboardEvent.ENTRY_REMOVED, - self._make_entry_handler(DashboardEvent.ENTRY_REMOVED), - ), - async_add_listener( - DashboardEvent.ENTRY_UPDATED, - self._make_entry_handler(DashboardEvent.ENTRY_UPDATED), - ), - async_add_listener( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, self._on_importable_added - ), - async_add_listener( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, - self._on_importable_removed, - ), - ] - - def _on_entry_state_changed(self, event: Event) -> None: - """Handle entry state change event.""" - entry = event.data["entry"] - state = event.data["state"] - self._safe_send_message( - { - "event": DashboardEvent.ENTRY_STATE_CHANGED, - "data": { - "filename": entry.filename, - "name": entry.name, - "state": entry_state_to_bool(state), - }, - } - ) - - def _make_entry_handler( - self, event_type: DashboardEvent - ) -> Callable[[Event], None]: - """Create an entry event handler.""" - - def handler(event: Event) -> None: - self._safe_send_message( - {"event": event_type, "data": {"device": event.data["entry"].to_dict()}} - ) - - return handler - - def _on_importable_added(self, event: Event) -> None: - """Handle importable device added event.""" - # Don't send if device is already configured - device_name = event.data.get("device", {}).get("name") - if device_name and DASHBOARD.entries.get_by_name(device_name): - return - self._safe_send_message( - {"event": DashboardEvent.IMPORTABLE_DEVICE_ADDED, "data": event.data} - ) - - def _on_importable_removed(self, event: Event) -> None: - """Handle importable device removed event.""" - self._safe_send_message( - {"event": DashboardEvent.IMPORTABLE_DEVICE_REMOVED, "data": event.data} - ) - - def _safe_send_message(self, message: dict[str, Any]) -> None: - """Send a message to the WebSocket client, ignoring closed errors.""" - with contextlib.suppress(tornado.websocket.WebSocketClosedError): - self.write_message(json.dumps(message)) - - def on_message(self, message: str) -> None: - """Handle incoming WebSocket messages.""" - _LOGGER.debug("WebSocket received message: %s", message) - try: - data = json.loads(message) - except json.JSONDecodeError as err: - _LOGGER.debug("Failed to parse WebSocket message: %s", err) - return - - event = data.get("event") - _LOGGER.debug("WebSocket message event: %s", event) - if event == DashboardEvent.PING: - # Send pong response for client ping - _LOGGER.debug("Received client ping, sending pong") - self._safe_send_message({"event": DashboardEvent.PONG}) - elif event == DashboardEvent.REFRESH: - # Signal the polling loop to refresh immediately - _LOGGER.debug("Received refresh request, signaling polling loop") - DASHBOARD_SUBSCRIBER.request_refresh() - - def on_close(self) -> None: - """Handle WebSocket close.""" - # Unsubscribe from dashboard updates - if self._dashboard_unsubscribe: - self._dashboard_unsubscribe() - self._dashboard_unsubscribe = None - - # Unsubscribe from events - for remove_listener in self._event_listeners or []: - remove_listener() - - _LOGGER.debug("Dashboard status WebSocket closed") - - -class SerialPortRequestHandler(BaseHandler): - @authenticated - async def get(self) -> None: - ports = await asyncio.get_running_loop().run_in_executor(None, get_serial_ports) - data = [] - for port in ports: - desc = port.description - if port.path == "/dev/ttyAMA0": - desc = "UART pins on GPIO header" - split_desc = desc.split(" - ") - if len(split_desc) == 2 and split_desc[0] == split_desc[1]: - # Some serial ports repeat their values - desc = split_desc[0] - data.append({"port": port.path, "desc": desc}) - data.append({"port": "OTA", "desc": "Over-The-Air"}) - data.sort(key=lambda x: x["port"], reverse=True) - self.set_header("content-type", "application/json") - self.write(json.dumps(data)) - - -class WizardRequestHandler(BaseHandler): - @authenticated - def post(self) -> None: - from esphome import wizard - - kwargs = { - k: v - for k, v in json.loads(self.request.body.decode()).items() - if k - in ( - "type", - "name", - "platform", - "board", - "ssid", - "psk", - "password", - "file_content", - ) - } - if not kwargs["name"]: - self.set_status(422) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Name is required"})) - return - - if "type" not in kwargs: - # Default to basic wizard type for backwards compatibility - kwargs["type"] = "basic" - - kwargs["friendly_name"] = kwargs["name"] - kwargs["name"] = friendly_name_slugify(kwargs["friendly_name"]) - if kwargs["type"] == "basic": - kwargs["ota_password"] = secrets.token_hex(16) - noise_psk = secrets.token_bytes(32) - kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode() - elif kwargs["type"] == "upload": - try: - kwargs["file_text"] = base64.b64decode(kwargs["file_content"]).decode( - "utf-8" - ) - except (binascii.Error, UnicodeDecodeError): - self.set_status(422) - self.set_header("content-type", "application/json") - self.write( - json.dumps({"error": "The uploaded file is not correctly encoded."}) - ) - return - elif kwargs["type"] != "empty": - self.set_status(422) - self.set_header("content-type", "application/json") - self.write( - json.dumps( - {"error": f"Invalid wizard type specified: {kwargs['type']}"} - ) - ) - return - filename = f"{kwargs['name']}.yaml" - destination = settings.rel_path(filename) - - # Check if destination file already exists - if destination.exists(): - self.set_status(409) # Conflict status code - self.set_header("content-type", "application/json") - self.write( - json.dumps({"error": f"Configuration file '{filename}' already exists"}) - ) - self.finish() - return - - success = wizard.wizard_write(path=destination, **kwargs) - if success: - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": filename})) - self.finish() - else: - self.set_status(500) - self.set_header("content-type", "application/json") - self.write( - json.dumps( - {"error": "Failed to write configuration, see logs for details"} - ) - ) - self.finish() - - -class ImportRequestHandler(BaseHandler): - @authenticated - def post(self) -> None: - from esphome.components.dashboard_import import import_config - - dashboard = DASHBOARD - args = json.loads(self.request.body.decode()) - try: - name = args["name"] - friendly_name = args.get("friendly_name") - encryption = args.get("encryption", False) - - imported_device = next( - ( - res - for res in dashboard.import_result.values() - if res.device_name == name - ), - None, - ) - - if imported_device is not None: - network = imported_device.network - if friendly_name is None: - friendly_name = imported_device.friendly_name - else: - network = const.CONF_WIFI - - import_config( - settings.rel_path(f"{name}.yaml"), - name, - friendly_name, - args["project_name"], - args["package_import_url"], - network, - encryption, - ) - # Make sure the device gets marked online right away - dashboard.ping_request.set() - except FileExistsError: - self.set_status(500) - self.write("File already exists") - return - except ValueError as e: - _LOGGER.error(e) - self.set_status(422) - self.write("Invalid package url") - return - - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": f"{name}.yaml"})) - self.finish() - - -class IgnoreDeviceRequestHandler(BaseHandler): - @authenticated - async def post(self) -> None: - dashboard = DASHBOARD - try: - args = json.loads(self.request.body.decode()) - device_name = args["name"] - ignore = args["ignore"] - except (json.JSONDecodeError, KeyError): - self.set_status(400) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Invalid payload"})) - return - - ignored_device = next( - ( - res - for res in dashboard.import_result.values() - if res.device_name == device_name - ), - None, - ) - - if ignored_device is None: - self.set_status(404) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Device not found"})) - return - - if ignore: - dashboard.ignored_devices.add(ignored_device.device_name) - else: - dashboard.ignored_devices.discard(ignored_device.device_name) - - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, dashboard.save_ignored_devices) - - self.set_status(204) - self.finish() - - -class DownloadListRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - loop = asyncio.get_running_loop() - try: - downloads_json = await loop.run_in_executor(None, self._get, configuration) - except vol.Invalid as exc: - _LOGGER.exception("Error while fetching downloads", exc_info=exc) - self.send_error(404) - return - if downloads_json is None: - _LOGGER.error("Configuration %s not found", configuration) - self.send_error(404) - return - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(downloads_json) - self.finish() - - def _get(self, configuration: str | None = None) -> dict[str, Any] | None: - storage_path = ext_storage_path(configuration) - storage_json = StorageJSON.load(storage_path) - if storage_json is None: - return None - - try: - config = yaml_util.load_yaml(settings.rel_path(configuration)) - - if const.CONF_EXTERNAL_COMPONENTS in config: - from esphome.components.external_components import ( - do_external_components_pass, - ) - - do_external_components_pass(config) - except vol.Invalid: - _LOGGER.info("Could not parse `external_components`, skipping") - - from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS - - downloads: list[dict[str, Any]] = [] - platform: str = storage_json.target_platform.lower() - - if platform.upper() in ESP32_VARIANTS: - platform = "esp32" - elif platform in ( - const.PLATFORM_RTL87XX, - const.PLATFORM_BK72XX, - const.PLATFORM_LN882X, - ): - platform = "libretiny" - - try: - module = importlib.import_module(f"esphome.components.{platform}") - get_download_types = module.get_download_types - except AttributeError as exc: - raise ValueError(f"Unknown platform {platform}") from exc - downloads = get_download_types(storage_json) - return json.dumps(downloads) - - -class DownloadBinaryRequestHandler(BaseHandler): - def _load_file(self, path: str, compressed: bool) -> bytes: - """Load a file from disk and compress it if requested.""" - with Path(path).open("rb") as f: - data = f.read() - if compressed: - return gzip.compress(data, 9) - return data - - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - """Download a binary file.""" - loop = asyncio.get_running_loop() - compressed = self.get_argument("compressed", "0") == "1" - - storage_path = ext_storage_path(configuration) - storage_json = StorageJSON.load(storage_path) - if storage_json is None: - self.send_error(404) - return - - # fallback to type=, but prioritize file= - file_name = self.get_argument("type", None) - file_name = self.get_argument("file", file_name) - if file_name is None or not file_name.strip(): - self.send_error(400) - return - # get requested download name, or build it based on filename - download_name = self.get_argument( - "download", - f"{storage_json.name}-{file_name}", - ) - - if storage_json.firmware_bin_path is None: - self.send_error(404) - return - - base_dir = storage_json.firmware_bin_path.parent.resolve() - path = base_dir.joinpath(file_name).resolve() - try: - path.relative_to(base_dir) - except ValueError: - self.send_error(403) - return - - if not path.is_file(): - args = [*ESPHOME_COMMAND, "idedata", settings.rel_path(configuration)] - rc, stdout, _ = await async_run_system_command(args) - - if rc != 0: - self.send_error(404 if rc == 2 else 500) - return - - idedata = toolchain.IDEData(json.loads(stdout)) - - found = False - for image in idedata.extra_flash_images: - if image.path.as_posix().endswith(file_name): - path = image.path - download_name = file_name - found = True - break - - if not found: - self.send_error(404) - return - - download_name = download_name + ".gz" if compressed else download_name - - self.set_header("Content-Type", "application/octet-stream") - self.set_header( - "Content-Disposition", f'attachment; filename="{download_name}"' - ) - self.set_header("Cache-Control", "no-cache") - if not Path(path).is_file(): - self.send_error(404) - return - - data = await loop.run_in_executor(None, self._load_file, path, compressed) - self.write(data) - - self.finish() - - -class EsphomeVersionHandler(BaseHandler): - @authenticated - def get(self) -> None: - self.set_header("Content-Type", "application/json") - self.write(json.dumps({"version": const.__version__})) - self.finish() - - -class ListDevicesHandler(BaseHandler): - @authenticated - async def get(self) -> None: - dashboard = DASHBOARD - await dashboard.entries.async_request_update_entries() - entries = dashboard.entries.async_all() - self.set_header("content-type", "application/json") - self.write(json.dumps(build_device_list_response(dashboard, entries))) - - -class MainRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - begin = bool(self.get_argument("begin", False)) - if settings.using_password: - # Simply accessing the xsrf_token sets the cookie for us - self.xsrf_token # pylint: disable=pointless-statement # noqa: B018 - else: - self.clear_cookie("_xsrf") - - self.render( - "index.template.html", - begin=begin, - **template_args(), - login_enabled=settings.using_password, - ) - - -class PrometheusServiceDiscoveryHandler(BaseHandler): - @authenticated - async def get(self) -> None: - dashboard = DASHBOARD - await dashboard.entries.async_request_update_entries() - entries = dashboard.entries.async_all() - self.set_header("content-type", "application/json") - sd = [] - for entry in entries: - if entry.web_port is None: - continue - labels = { - "__meta_name": entry.name, - "__meta_esp_platform": entry.target_platform, - "__meta_esphome_version": entry.storage.esphome_version, - } - for integration in entry.storage.loaded_integrations: - labels[f"__meta_integration_{integration}"] = "true" - sd.append( - { - "targets": [ - f"{entry.address}:{entry.web_port}", - ], - "labels": labels, - } - ) - self.write(json.dumps(sd)) - - -class BoardsRequestHandler(BaseHandler): - @authenticated - def get(self, platform: str) -> None: - # filter all ESP32 variants by requested platform - if platform.startswith("esp32"): - from esphome.components.esp32.boards import BOARDS as ESP32_BOARDS - - boards = { - k: v - for k, v in ESP32_BOARDS.items() - if v[const.KEY_VARIANT] == platform.upper() - } - elif platform == const.PLATFORM_ESP8266: - from esphome.components.esp8266.boards import BOARDS as ESP8266_BOARDS - - boards = ESP8266_BOARDS - elif platform == const.PLATFORM_RP2040: - from esphome.components.rp2040.boards import BOARDS as RP2040_BOARDS - - boards = RP2040_BOARDS - elif platform == const.PLATFORM_BK72XX: - from esphome.components.bk72xx.boards import BOARDS as BK72XX_BOARDS - - boards = BK72XX_BOARDS - elif platform == const.PLATFORM_LN882X: - from esphome.components.ln882x.boards import BOARDS as LN882X_BOARDS - - boards = LN882X_BOARDS - elif platform == const.PLATFORM_RTL87XX: - from esphome.components.rtl87xx.boards import BOARDS as RTL87XX_BOARDS - - boards = RTL87XX_BOARDS - else: - raise ValueError(f"Unknown platform {platform}") - - # map to a {board_name: board_title} dict - platform_boards = {key: val[const.KEY_NAME] for key, val in boards.items()} - # sort by board title - boards_items = sorted(platform_boards.items(), key=lambda item: item[1]) - output = [{"items": dict(boards_items)}] - - self.set_header("content-type", "application/json") - self.write(json.dumps(output)) - - -class PingRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - dashboard = DASHBOARD - dashboard.ping_request.set() - if settings.status_use_mqtt: - dashboard.mqtt_ping_request.set() - self.set_header("content-type", "application/json") - - self.write( - json.dumps( - { - entry.filename: entry_state_to_bool(entry.state) - for entry in dashboard.entries.async_all() - } - ) - ) - - -class InfoRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - yaml_path = settings.rel_path(configuration) - dashboard = DASHBOARD - entry = dashboard.entries.get(yaml_path) - - if not entry or entry.storage is None: - self.set_status(404) - return - - self.set_header("content-type", "application/json") - self.write(entry.storage.to_json()) - - -class EditRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - """Get the content of a file.""" - if not configuration.endswith((".yaml", ".yml")): - self.send_error(404) - return - - filename = settings.rel_path(configuration) - if filename.resolve().parent != settings.absolute_config_dir: - self.send_error(404) - return - - loop = asyncio.get_running_loop() - content = await loop.run_in_executor( - None, self._read_file, filename, configuration - ) - if content is not None: - self.set_header("Content-Type", "application/yaml") - self.write(content) - - def _read_file(self, filename: str, configuration: str) -> bytes | None: - """Read a file and return the content as bytes.""" - try: - with Path(filename).open(encoding="utf-8") as f: - return f.read() - except FileNotFoundError: - if configuration in const.SECRETS_FILES: - return "" - self.set_status(404) - return None - - @authenticated - @bind_config - async def post(self, configuration: str | None = None) -> None: - """Write the content of a file.""" - if not configuration.endswith((".yaml", ".yml")): - self.send_error(404) - return - - filename = settings.rel_path(configuration) - if filename.resolve().parent != settings.absolute_config_dir: - self.send_error(404) - return - - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, write_file, filename, self.request.body) - # Ensure the StorageJSON is updated as well - DASHBOARD.entries.async_schedule_storage_json_update(filename) - self.set_status(200) - - -class ArchiveRequestHandler(BaseHandler): - @authenticated - @bind_config - def post(self, configuration: str | None = None) -> None: - config_file = settings.rel_path(configuration) - storage_path = ext_storage_path(configuration) - - archive_path = archive_storage_path() - mkdir_p(archive_path) - shutil.move(config_file, archive_path / configuration) - - storage_json = StorageJSON.load(storage_path) - if storage_json is not None and storage_json.build_path: - # Delete build folder (if exists) - shutil.rmtree(storage_json.build_path, ignore_errors=True) - - -class UnArchiveRequestHandler(BaseHandler): - @authenticated - @bind_config - def post(self, configuration: str | None = None) -> None: - config_file = settings.rel_path(configuration) - archive_path = archive_storage_path() - shutil.move(archive_path / configuration, config_file) - - -class LoginHandler(BaseHandler): - def get(self) -> None: - if is_authenticated(self): - self.redirect("./") - else: - self.render_login_page() - - def render_login_page(self, error: str | None = None) -> None: - self.render( - "login.template.html", - error=error, - ha_addon=settings.using_ha_addon_auth, - has_username=bool(settings.username), - **template_args(), - ) - - def _make_supervisor_auth_request(self) -> Response: - """Make a request to the supervisor auth endpoint.""" - import requests - - headers = {"X-Supervisor-Token": os.getenv("SUPERVISOR_TOKEN")} - data = { - "username": self.get_argument("username", ""), - "password": self.get_argument("password", ""), - } - return requests.post( - "http://supervisor/auth", headers=headers, json=data, timeout=30 - ) - - async def post_ha_addon_login(self) -> None: - loop = asyncio.get_running_loop() - try: - req = await loop.run_in_executor(None, self._make_supervisor_auth_request) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - _LOGGER.warning("Error during Hass.io auth request: %s", err) - self.set_status(500) - self.render_login_page(error="Internal server error") - return - - if req.status_code == 200: - self._set_authenticated() - self.redirect("/") - return - self.set_status(401) - self.render_login_page(error="Invalid username or password") - - def _set_authenticated(self) -> None: - """Set the authenticated cookie.""" - self.set_secure_cookie(AUTH_COOKIE_NAME, COOKIE_AUTHENTICATED_YES) - - def post_native_login(self) -> None: - username = self.get_argument("username", "") - password = self.get_argument("password", "") - if settings.check_password(username, password): - self._set_authenticated() - self.redirect("./") - return - error_str = ( - "Invalid username or password" if settings.username else "Invalid password" - ) - self.set_status(401) - self.render_login_page(error=error_str) - - async def post(self): - if settings.using_ha_addon_auth: - await self.post_ha_addon_login() - else: - self.post_native_login() - - -class LogoutHandler(BaseHandler): - @authenticated - def get(self) -> None: - self.clear_cookie(AUTH_COOKIE_NAME) - self.redirect("./login") - - -class SecretKeysRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - filename = None - - for secret_filename in const.SECRETS_FILES: - relative_filename = settings.rel_path(secret_filename) - if relative_filename.is_file(): - filename = relative_filename - break - - if filename is None: - self.send_error(404) - return - - secret_keys = list(yaml_util.load_yaml(filename, clear_secrets=False)) - - self.set_header("content-type", "application/json") - self.write(json.dumps(secret_keys)) - - -class SafeLoaderIgnoreUnknown(FastestAvailableSafeLoader): - def ignore_unknown(self, node: Node) -> str: - return f"{node.tag} {node.value}" - - def construct_yaml_binary(self, node: Node) -> str: - return super().construct_yaml_binary(node).decode("ascii") - - -SafeLoaderIgnoreUnknown.add_constructor(None, SafeLoaderIgnoreUnknown.ignore_unknown) -SafeLoaderIgnoreUnknown.add_constructor( - "tag:yaml.org,2002:binary", SafeLoaderIgnoreUnknown.construct_yaml_binary -) - - -class JsonConfigRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - filename = settings.rel_path(configuration) - if not filename.is_file(): - self.send_error(404) - return - - args = [*ESPHOME_COMMAND, "config", str(filename), "--show-secrets"] - - rc, stdout, stderr = await async_run_system_command(args) - - if rc != 0: - self.set_status(422) - self.write(stderr) - return - - data = yaml.load(stdout, Loader=SafeLoaderIgnoreUnknown) - self.set_header("content-type", "application/json") - self.write(json.dumps(data)) - self.finish() - - -def get_base_frontend_path() -> Path: - if ENV_DEV not in os.environ: - import esphome_dashboard - - return esphome_dashboard.where() - - static_path = os.environ[ENV_DEV] - if not static_path.endswith("/"): - static_path += "/" - - # This path can be relative, so resolve against the root or else templates don't work - path = Path.cwd() / static_path / "esphome_dashboard" - return path.resolve() - - -def get_static_path(*args: Iterable[str]) -> Path: - return get_base_frontend_path() / "static" / Path(*args) - - -@functools.cache -def get_static_file_url(name: str) -> str: - base = f"./static/{name}" - - if ENV_DEV in os.environ: - return base - - # Module imports can't deduplicate if stuff added to url - if name == "js/esphome/index.js": - import esphome_dashboard - - return base.replace("index.js", esphome_dashboard.entrypoint()) - - path = get_static_path(name) - hash_ = hashlib.md5(path.read_bytes()).hexdigest()[:8] - return f"{base}?hash={hash_}" - - -def make_app(debug: bool | None = None) -> tornado.web.Application: - if debug is None: - debug = get_bool_env(ENV_DEV) - - def log_function(handler: tornado.web.RequestHandler) -> None: - if handler.get_status() < 400: - log_method = access_log.info - - if isinstance(handler, SerialPortRequestHandler) and not debug: - return - if isinstance(handler, PingRequestHandler) and not debug: - return - elif handler.get_status() < 500: - log_method = access_log.warning - else: - log_method = access_log.error - - request_time = 1000.0 * handler.request.request_time() - # pylint: disable=protected-access - log_method( - "%d %s %.2fms", - handler.get_status(), - handler._request_summary(), - request_time, - ) - - class StaticFileHandler(tornado.web.StaticFileHandler): - def get_cache_time( - self, path: str, modified: datetime.datetime | None, mime_type: str - ) -> int: - """Override to customize cache control behavior.""" - if debug: - return 0 - # Assets that are hashed have ?hash= in the URL, all javascript - # filenames hashed so we can cache them for a long time - if "hash" in self.request.arguments or "/javascript" in mime_type: - return self.CACHE_MAX_AGE - return super().get_cache_time(path, modified, mime_type) - - app_settings = { - "debug": debug, - "cookie_secret": settings.cookie_secret, - "log_function": log_function, - "websocket_ping_interval": 30.0, - "template_path": get_base_frontend_path(), - "xsrf_cookies": settings.using_password, - } - rel = settings.relative_url - return tornado.web.Application( - [ - (f"{rel}", MainRequestHandler), - (f"{rel}login", LoginHandler), - (f"{rel}logout", LogoutHandler), - (f"{rel}logs", EsphomeLogsHandler), - (f"{rel}upload", EsphomeUploadHandler), - (f"{rel}run", EsphomeRunHandler), - (f"{rel}compile", EsphomeCompileHandler), - (f"{rel}validate", EsphomeValidateHandler), - (f"{rel}clean-mqtt", EsphomeCleanMqttHandler), - (f"{rel}clean-all", EsphomeCleanAllHandler), - (f"{rel}clean", EsphomeCleanHandler), - (f"{rel}vscode", EsphomeVscodeHandler), - (f"{rel}ace", EsphomeAceEditorHandler), - (f"{rel}update-all", EsphomeUpdateAllHandler), - (f"{rel}info", InfoRequestHandler), - (f"{rel}edit", EditRequestHandler), - (f"{rel}downloads", DownloadListRequestHandler), - (f"{rel}download.bin", DownloadBinaryRequestHandler), - (f"{rel}serial-ports", SerialPortRequestHandler), - (f"{rel}ping", PingRequestHandler), - (f"{rel}delete", ArchiveRequestHandler), - (f"{rel}undo-delete", UnArchiveRequestHandler), - (f"{rel}archive", ArchiveRequestHandler), - (f"{rel}unarchive", UnArchiveRequestHandler), - (f"{rel}wizard", WizardRequestHandler), - (f"{rel}static/(.*)", StaticFileHandler, {"path": get_static_path()}), - (f"{rel}devices", ListDevicesHandler), - (f"{rel}events", DashboardEventsWebSocket), - (f"{rel}import", ImportRequestHandler), - (f"{rel}secret_keys", SecretKeysRequestHandler), - (f"{rel}json-config", JsonConfigRequestHandler), - (f"{rel}rename", EsphomeRenameHandler), - (f"{rel}prometheus-sd", PrometheusServiceDiscoveryHandler), - (f"{rel}boards/([a-z0-9]+)", BoardsRequestHandler), - (f"{rel}version", EsphomeVersionHandler), - (f"{rel}ignore-device", IgnoreDeviceRequestHandler), - ], - **app_settings, - ) - - -def start_web_server( - app: tornado.web.Application, - socket: str | None, - address: str | None, - port: int | None, - config_dir: str, -) -> None: - """Start the web server listener.""" - - trash_path = trash_storage_path() - if trash_path.is_dir() and trash_path.exists(): - _LOGGER.info("Renaming 'trash' folder to 'archive'") - archive_path = archive_storage_path() - shutil.move(trash_path, archive_path) - - if socket is None: - _LOGGER.info( - "Starting dashboard web server on http://%s:%s and configuration dir %s...", - address, - port, - config_dir, - ) - app.listen(port, address) - return - - _LOGGER.info( - "Starting dashboard web server on unix socket %s and configuration dir %s...", - socket, - config_dir, - ) - server = tornado.httpserver.HTTPServer(app) - socket = tornado.netutil.bind_unix_socket(socket, mode=0o666) - server.add_socket(socket) diff --git a/esphome/helpers.py b/esphome/helpers.py index ef7e2d0b93..62dfd0fb09 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -124,14 +124,8 @@ def slugify(value: str) -> str: def friendly_name_slugify(value: str) -> str: """Convert a friendly name to a slug with dashes instead of underscores. - Used by: - - esphome.dashboard.web_server (legacy dashboard) - - device-builder (esphome/device-builder) — slugifies friendly names - into the YAML filename / device name during adoption + wizard flows. - - Lives here rather than in ``esphome.dashboard.util.text`` so it - survives the legacy dashboard's eventual removal. - The dashboard module re-exports this name as a back-compat shim. + Used by device-builder (esphome/device-builder), which slugifies friendly + names into the YAML filename / device name during adoption + wizard flows. Coordinate with the device-builder team before changing the slugification rules — the mapping must stay stable so existing on-disk filenames keep matching across releases. diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 3bdda1a9a1..f754673b79 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -71,14 +71,10 @@ def _to_path_if_not_none(value: str | None) -> Path | None: class StorageJSON: """Persisted device metadata sidecar. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — reads/writes the same - JSON file as the legacy dashboard so a single config_dir can be - shared between the two during the transition. The schema - (``storage_version``, field names, types) must stay backwards - compatible — coordinate with the device-builder team before - adding required fields or changing semantics of existing ones. + Used by device-builder (esphome/device-builder), which reads/writes this + JSON file. The schema (``storage_version``, field names, types) must stay + backwards compatible — coordinate with the device-builder team before + adding required fields or changing semantics of existing ones. """ def __init__( diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index e4b9abb976..04075ec4c1 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -62,14 +62,12 @@ TXT_RECORD_VERSION = b"version" class DiscoveredImport: """An importable device discovered via mDNS ``_esphomelib._tcp.local.``. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — surfaces these as - "discovered devices" on the new dashboard's adoption flow. + Used by device-builder (esphome/device-builder), which surfaces these as + "discovered devices" on its adoption flow. Fields are populated from TXT records on the broadcast service info (see :class:`DashboardImportDiscovery`). Coordinate before - adding/removing fields — both consumers persist them. + adding/removing fields — the consumer persists them. """ friendly_name: str | None @@ -87,11 +85,9 @@ class DashboardBrowser(AsyncServiceBrowser): class DashboardImportDiscovery: """Track importable devices announcing on ``_esphomelib._tcp.local.``. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — wired up alongside - the dashboard's own ``ServiceBrowser`` to populate the - "Discovered devices" panel and the adoption flow. + Used by device-builder (esphome/device-builder), which wires it up + alongside its own ``ServiceBrowser`` to populate the + "Discovered devices" panel and the adoption flow. The class maintains ``import_state: dict[str, DiscoveredImport]`` keyed by the mDNS service name. ``on_update`` is invoked with @@ -262,11 +258,9 @@ async def async_resolve_hosts( class AsyncEsphomeZeroconf(AsyncZeroconf): """ESPHome-tuned ``AsyncZeroconf`` with a hostname-resolve helper. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — drives both the live - mDNS browser and the per-sweep ``async_resolve_host`` fallback - for non-API devices that don't broadcast esphomelib. + Used by device-builder (esphome/device-builder), which drives both the live + mDNS browser and the per-sweep ``async_resolve_host`` fallback + for non-API devices that don't broadcast esphomelib. Coordinate before adding required constructor args or changing the ``async_resolve_host`` signature — device-builder calls it diff --git a/requirements.txt b/requirements.txt index 06a383b00a..b01b2a4c6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,15 +3,12 @@ voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 -icmplib==3.0.4 -tornado==6.5.7 tzlocal==5.4.3 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 esptool==5.3.0 click==8.3.3 -esphome-dashboard==20260425.0 aioesphomeapi==45.3.1 zeroconf==0.149.16 puremagic==1.30 diff --git a/script/ci-custom.py b/script/ci-custom.py index cbc54ce55d..6c5ad5bb69 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -259,14 +259,7 @@ def lint_executable_bit(fname: Path) -> str | None: return None -@lint_content_find_check( - "\t", - only_first=True, - exclude=[ - "esphome/dashboard/static/ace.js", - "esphome/dashboard/static/ext-searchbox.js", - ], -) +@lint_content_find_check("\t", only_first=True) def lint_tabs(fname, line, col, content): return "File contains tab character. Please convert tabs to spaces." diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/dashboard/common.py b/tests/dashboard/common.py deleted file mode 100644 index f84c03aad8..0000000000 --- a/tests/dashboard/common.py +++ /dev/null @@ -1,6 +0,0 @@ -import pathlib - - -def get_fixture_path(filename: str) -> pathlib.Path: - """Get path of fixture.""" - return pathlib.Path(__file__).parent.joinpath("fixtures", filename) diff --git a/tests/dashboard/conftest.py b/tests/dashboard/conftest.py deleted file mode 100644 index f95adef749..0000000000 --- a/tests/dashboard/conftest.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Common fixtures for dashboard tests.""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, Mock - -import pytest -import pytest_asyncio - -from esphome.dashboard.core import ESPHomeDashboard -from esphome.dashboard.entries import DashboardEntries - - -@pytest.fixture -def mock_settings(tmp_path: Path) -> MagicMock: - """Create mock dashboard settings.""" - settings = MagicMock() - settings.config_dir = str(tmp_path) - settings.absolute_config_dir = tmp_path - return settings - - -@pytest.fixture -def mock_dashboard(mock_settings: MagicMock) -> Mock: - """Create a mock dashboard.""" - dashboard = Mock(spec=ESPHomeDashboard) - dashboard.settings = mock_settings - dashboard.entries = Mock() - dashboard.entries.async_all.return_value = [] - dashboard.stop_event = Mock() - dashboard.stop_event.is_set.return_value = True - dashboard.ping_request = Mock() - dashboard.ignored_devices = set() - dashboard.bus = Mock() - dashboard.bus.async_fire = Mock() - return dashboard - - -@pytest_asyncio.fixture -async def dashboard_entries(mock_dashboard: Mock) -> DashboardEntries: - """Create a DashboardEntries instance for testing.""" - return DashboardEntries(mock_dashboard) diff --git a/tests/dashboard/fixtures/conf/pico.yaml b/tests/dashboard/fixtures/conf/pico.yaml deleted file mode 100644 index cf5b5b75bf..0000000000 --- a/tests/dashboard/fixtures/conf/pico.yaml +++ /dev/null @@ -1,47 +0,0 @@ -substitutions: - name: picoproxy - friendly_name: Pico Proxy - -esphome: - name: ${name} - friendly_name: ${friendly_name} - project: - name: esphome.bluetooth-proxy - version: "1.0" - -esp32: - board: esp32dev - framework: - type: esp-idf - -wifi: - ap: - -api: -logger: -ota: -improv_serial: - -dashboard_import: - package_import_url: github://esphome/firmware/bluetooth-proxy/esp32-generic.yaml@main - -button: - - platform: factory_reset - id: resetf - - platform: safe_mode - name: Safe Mode Boot - entity_category: diagnostic - -sensor: - - platform: template - id: pm11 - name: "pm 1.0µm" - lambda: return 1.0; - - platform: template - id: pm251 - name: "pm 2.5µm" - lambda: return 2.5; - - platform: template - id: pm101 - name: "pm 10µm" - lambda: return 10; diff --git a/tests/dashboard/status/__init__.py b/tests/dashboard/status/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/dashboard/status/test_dns.py b/tests/dashboard/status/test_dns.py deleted file mode 100644 index f7c4992079..0000000000 --- a/tests/dashboard/status/test_dns.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Unit tests for esphome.dashboard.dns module.""" - -from __future__ import annotations - -import time -from unittest.mock import AsyncMock, patch - -from icmplib import NameLookupError -import pytest - -from esphome.dashboard.dns import DNSCache, _async_resolve_wrapper - - -@pytest.fixture -def dns_cache_fixture() -> DNSCache: - """Create a DNSCache instance.""" - return DNSCache() - - -def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when hostname is not in cache.""" - now = time.monotonic() - result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now) - assert result is None - - -def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when cache entry is expired.""" - now = time.monotonic() - # Add entry that's already expired - dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"]) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result is None - # Expired entry should still be in cache (not removed by get_cached_addresses) - assert "example.com" in dns_cache_fixture._cache - - -def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with valid cache entry.""" - now = time.monotonic() - # Add entry that expires in 60 seconds - dns_cache_fixture._cache["example.com"] = ( - now + 60, - ["192.168.1.10", "192.168.1.11"], - ) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == ["192.168.1.10", "192.168.1.11"] - # Entry should still be in cache - assert "example.com" in dns_cache_fixture._cache - - -def test_get_cached_addresses_hostname_normalization( - dns_cache_fixture: DNSCache, -) -> None: - """Test get_cached_addresses normalizes hostname.""" - now = time.monotonic() - # Add entry with lowercase hostname - dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"]) - - # Test with various forms - assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [ - "192.168.1.10" - ] - assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [ - "192.168.1.10" - ] - assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [ - "192.168.1.10" - ] - - -def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with IPv6 addresses.""" - now = time.monotonic() - dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"]) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == ["2001:db8::1", "fe80::1"] - - -def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with empty address list.""" - now = time.monotonic() - dns_cache_fixture._cache["example.com"] = (now + 60, []) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == [] - - -def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when cache contains an exception.""" - now = time.monotonic() - # Store an exception (from failed resolution) - dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed")) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result is None # Should return None for exceptions - - -def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None: - """Test that get_cached_addresses never calls async_resolve.""" - now = time.monotonic() - - with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve: - # Test non-cached - result = dns_cache_fixture.get_cached_addresses("uncached.com", now) - assert result is None - mock_resolve.assert_not_called() - - # Test expired - dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"]) - result = dns_cache_fixture.get_cached_addresses("expired.com", now) - assert result is None - mock_resolve.assert_not_called() - - # Test valid - dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"]) - result = dns_cache_fixture.get_cached_addresses("valid.com", now) - assert result == ["192.168.1.10"] - mock_resolve.assert_not_called() - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_ip_address() -> None: - """Test _async_resolve_wrapper returns IP address directly.""" - result = await _async_resolve_wrapper("192.168.1.10") - assert result == ["192.168.1.10"] - - result = await _async_resolve_wrapper("2001:db8::1") - assert result == ["2001:db8::1"] - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_fallback_success() -> None: - """Test _async_resolve_wrapper falls back to bare hostname for .local.""" - mock_resolve = AsyncMock() - # First call (device.local) fails, second call (device) succeeds - mock_resolve.side_effect = [ - NameLookupError("device.local"), - ["192.168.1.50"], - ] - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - assert result == ["192.168.1.50"] - assert mock_resolve.call_count == 2 - mock_resolve.assert_any_call("device.local") - mock_resolve.assert_any_call("device") - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_fallback_both_fail() -> None: - """Test _async_resolve_wrapper returns exception when both fail.""" - mock_resolve = AsyncMock() - original_exception = NameLookupError("device.local") - mock_resolve.side_effect = [ - original_exception, - NameLookupError("device"), - ] - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - # Should return the original exception, not the fallback exception - assert result is original_exception - assert mock_resolve.call_count == 2 - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_non_local_no_fallback() -> None: - """Test _async_resolve_wrapper doesn't fallback for non-.local hostnames.""" - mock_resolve = AsyncMock() - original_exception = NameLookupError("device.example.com") - mock_resolve.side_effect = original_exception - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.example.com") - - assert result is original_exception - # Should only try the original hostname, no fallback - assert mock_resolve.call_count == 1 - mock_resolve.assert_called_once_with("device.example.com") - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_success_no_fallback() -> None: - """Test _async_resolve_wrapper doesn't fallback when .local succeeds.""" - mock_resolve = AsyncMock(return_value=["192.168.1.50"]) - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - assert result == ["192.168.1.50"] - # Should only try once since it succeeded - assert mock_resolve.call_count == 1 - mock_resolve.assert_called_once_with("device.local") diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py deleted file mode 100644 index 56c6d254cf..0000000000 --- a/tests/dashboard/status/test_mdns.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Unit tests for esphome.dashboard.status.mdns module.""" - -from __future__ import annotations - -from unittest.mock import Mock, patch - -import pytest -import pytest_asyncio -from zeroconf import AddressResolver, IPVersion - -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.status.mdns import MDNSStatus -from esphome.zeroconf import DiscoveredImport - - -@pytest_asyncio.fixture -async def mdns_status(mock_dashboard: Mock) -> MDNSStatus: - """Create an MDNSStatus instance in async context.""" - # We're in an async context so get_running_loop will work - return MDNSStatus(mock_dashboard) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when no zeroconf instance is available.""" - mdns_status.aiozc = None - result = mdns_status.get_cached_addresses("device.local") - assert result is None - - -@pytest.mark.asyncio -async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when address is not in cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = False - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result is None - mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when address is found in cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == ["192.168.1.10", "fe80::1"] - mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) - mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with hostname having trailing dot.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local.") - assert result == ["192.168.1.10"] - # Should normalize to device.local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with uppercase hostname.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("DEVICE.LOCAL") - assert result == ["192.168.1.10"] - # Should normalize to device.local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with simple hostname (no domain).""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device") - assert result == ["192.168.1.10"] - # Should append .local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses returning only IPv6 addresses.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == ["fe80::1", "2001:db8::1"] - - -@pytest.mark.asyncio -async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses returning empty list from cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = [] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == [] - - -@pytest.mark.asyncio -async def test_async_setup_success(mock_dashboard: Mock) -> None: - """Test successful async_setup.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.return_value = Mock() - result = mdns_status.async_setup() - assert result is True - assert mdns_status.aiozc is not None - - -@pytest.mark.asyncio -async def test_async_setup_failure(mock_dashboard: Mock) -> None: - """Test async_setup with OSError.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.side_effect = OSError("Network error") - result = mdns_status.async_setup() - assert result is False - assert mdns_status.aiozc is None - - -@pytest.mark.asyncio -async def test_on_import_update_device_added(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is added.""" - # Create a DiscoveredImport object - discovered = DiscoveredImport( - device_name="test_device", - friendly_name="Test Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="wifi", - ) - - # Call _on_import_update with a device - mdns_status._on_import_update("test_device", discovered) - - # Should fire IMPORTABLE_DEVICE_ADDED event - mock_dashboard = mdns_status.dashboard - mock_dashboard.bus.async_fire.assert_called_once() - call_args = mock_dashboard.bus.async_fire.call_args - assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED - assert "device" in call_args[0][1] - device_data = call_args[0][1]["device"] - assert device_data["name"] == "test_device" - assert device_data["friendly_name"] == "Test Device" - assert device_data["project_name"] == "test_project" - assert device_data["ignored"] is False - - -@pytest.mark.asyncio -async def test_on_import_update_device_ignored(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is ignored.""" - # Add device to ignored list - mdns_status.dashboard.ignored_devices.add("ignored_device") - - # Create a DiscoveredImport object for ignored device - discovered = DiscoveredImport( - device_name="ignored_device", - friendly_name="Ignored Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="ethernet", - ) - - # Call _on_import_update with an ignored device - mdns_status._on_import_update("ignored_device", discovered) - - # Should fire IMPORTABLE_DEVICE_ADDED event with ignored=True - mock_dashboard = mdns_status.dashboard - mock_dashboard.bus.async_fire.assert_called_once() - call_args = mock_dashboard.bus.async_fire.call_args - assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED - device_data = call_args[0][1]["device"] - assert device_data["name"] == "ignored_device" - assert device_data["ignored"] is True - - -@pytest.mark.asyncio -async def test_on_import_update_device_removed(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is removed.""" - # Call _on_import_update with None (device removed) - mdns_status._on_import_update("removed_device", None) - - # Should fire IMPORTABLE_DEVICE_REMOVED event - mdns_status.dashboard.bus.async_fire.assert_called_once_with( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": "removed_device"} - ) diff --git a/tests/dashboard/test_entries.py b/tests/dashboard/test_entries.py deleted file mode 100644 index 9a3a776b28..0000000000 --- a/tests/dashboard/test_entries.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Tests for dashboard entries Path-related functionality.""" - -from __future__ import annotations - -import os -from pathlib import Path -import tempfile -from unittest.mock import Mock - -import pytest - -from esphome.core import CORE -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.entries import DashboardEntries, DashboardEntry - - -def create_cache_key() -> tuple[int, int, float, int]: - """Helper to create a valid DashboardCacheKeyType.""" - return (0, 0, 0.0, 0) - - -@pytest.fixture(autouse=True) -def setup_core(): - """Set up CORE for testing.""" - with tempfile.TemporaryDirectory() as tmpdir: - CORE.config_path = Path(tmpdir) / "test.yaml" - yield - CORE.reset() - - -def test_dashboard_entry_path_initialization() -> None: - """Test DashboardEntry initializes with path correctly.""" - test_path = Path("/test/config/device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert entry.cache_key == cache_key - - -def test_dashboard_entry_path_with_absolute_path() -> None: - """Test DashboardEntry handles absolute paths.""" - # Use a truly absolute path for the platform - test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml" - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert entry.path.is_absolute() - - -def test_dashboard_entry_path_with_relative_path() -> None: - """Test DashboardEntry handles relative paths.""" - test_path = Path("configs/device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert not entry.path.is_absolute() - - -@pytest.mark.asyncio -async def test_dashboard_entries_get_by_path( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test getting entry by path.""" - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Verify the entry was loaded - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - assert entry.path == test_file - - # Also verify get() works with Path - result = dashboard_entries.get(test_file) - assert result == entry - - -@pytest.mark.asyncio -async def test_dashboard_entries_get_nonexistent_path( - dashboard_entries: DashboardEntries, -) -> None: - """Test getting non-existent entry returns None.""" - result = dashboard_entries.get("/nonexistent/path.yaml") - assert result is None - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_normalization( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test that paths are handled consistently.""" - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_with_spaces( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test handling paths with spaces.""" - # Create a test file with spaces in name - test_file = tmp_path / "my device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - assert result.path == test_file - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_with_special_chars( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test handling paths with special characters.""" - # Create a test file with special characters - test_file = tmp_path / "device-01_test.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - - -def test_dashboard_entries_windows_path() -> None: - """Test handling Windows-style paths.""" - test_path = Path(r"C:\Users\test\esphome\device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_to_cache_key_mapping( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test internal entries storage with paths and cache keys.""" - # Create test files - file1 = tmp_path / "device1.yaml" - file2 = tmp_path / "device2.yaml" - file1.write_text("test config 1") - file2.write_text("test config 2") - - # Update entries to load the files - await dashboard_entries.async_update_entries() - - # Get entries and verify they have different cache keys - entry1 = dashboard_entries.get(file1) - entry2 = dashboard_entries.get(file2) - - assert entry1 is not None - assert entry2 is not None - assert entry1.cache_key != entry2.cache_key - - -def test_dashboard_entry_path_property() -> None: - """Test that path property returns expected value.""" - test_path = Path("/test/config/device.yaml") - entry = DashboardEntry(test_path, create_cache_key()) - - assert entry.path == test_path - assert isinstance(entry.path, Path) - - -@pytest.mark.asyncio -async def test_dashboard_entries_all_returns_entries_with_paths( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test that all() returns entries with their paths intact.""" - # Create test files - files = [ - tmp_path / "device1.yaml", - tmp_path / "device2.yaml", - tmp_path / "device3.yaml", - ] - - for file in files: - file.write_text("test config") - - # Update entries to load the files - await dashboard_entries.async_update_entries() - - all_entries = dashboard_entries.async_all() - - assert len(all_entries) == len(files) - retrieved_paths = [entry.path for entry in all_entries] - assert set(retrieved_paths) == set(files) - - -@pytest.mark.asyncio -async def test_async_update_entries_removed_path( - dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path -) -> None: - """Test that removed files trigger ENTRY_REMOVED event.""" - - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # First update to add the entry - await dashboard_entries.async_update_entries() - - # Verify entry was added - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - - # Delete the file - test_file.unlink() - - # Second update to detect removal - await dashboard_entries.async_update_entries() - - # Verify entry was removed - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 0 - - # Verify ENTRY_REMOVED event was fired - mock_dashboard.bus.async_fire.assert_any_call( - DashboardEvent.ENTRY_REMOVED, {"entry": entry} - ) - - -@pytest.mark.asyncio -async def test_async_update_entries_updated_path( - dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path -) -> None: - """Test that modified files trigger ENTRY_UPDATED event.""" - - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # First update to add the entry - await dashboard_entries.async_update_entries() - - # Verify entry was added - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - original_cache_key = entry.cache_key - - # Modify the file to change its mtime - test_file.write_text("updated config") - # Explicitly change the mtime to ensure it's different - stat = test_file.stat() - os.utime(test_file, (stat.st_atime, stat.st_mtime + 1)) - - # Second update to detect modification - await dashboard_entries.async_update_entries() - - # Verify entry is still there with updated cache key - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - updated_entry = all_entries[0] - assert updated_entry == entry # Same entry object - assert updated_entry.cache_key != original_cache_key # But cache key updated - - # Verify ENTRY_UPDATED event was fired - mock_dashboard.bus.async_fire.assert_any_call( - DashboardEvent.ENTRY_UPDATED, {"entry": entry} - ) diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py deleted file mode 100644 index 55776ac7c4..0000000000 --- a/tests/dashboard/test_settings.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Tests for DashboardSettings (path resolution and authentication).""" - -from __future__ import annotations - -from argparse import Namespace -from pathlib import Path -import tempfile - -import pytest - -from esphome.core import CORE -from esphome.dashboard.settings import DashboardSettings -from esphome.dashboard.util.password import password_hash - - -@pytest.fixture -def dashboard_settings(tmp_path: Path) -> DashboardSettings: - """Create DashboardSettings instance with temp directory.""" - settings = DashboardSettings() - # Resolve symlinks to ensure paths match - resolved_dir = tmp_path.resolve() - settings.config_dir = resolved_dir - settings.absolute_config_dir = resolved_dir - return settings - - -def test_rel_path_simple(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with simple relative path.""" - result = dashboard_settings.rel_path("config.yaml") - - expected = dashboard_settings.config_dir / "config.yaml" - assert result == expected - - -def test_rel_path_multiple_components(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with multiple path components.""" - result = dashboard_settings.rel_path("subfolder", "device", "config.yaml") - - expected = dashboard_settings.config_dir / "subfolder" / "device" / "config.yaml" - assert result == expected - - -def test_rel_path_with_dots(dashboard_settings: DashboardSettings) -> None: - """Test rel_path prevents directory traversal.""" - # This should raise ValueError as it tries to go outside config_dir - with pytest.raises(ValueError): - dashboard_settings.rel_path("..", "outside.yaml") - - -def test_rel_path_absolute_path_within_config( - dashboard_settings: DashboardSettings, -) -> None: - """Test rel_path with absolute path that's within config dir.""" - internal_path = dashboard_settings.absolute_config_dir / "internal.yaml" - - internal_path.touch() - result = dashboard_settings.rel_path("internal.yaml") - expected = dashboard_settings.config_dir / "internal.yaml" - assert result == expected - - -def test_rel_path_absolute_path_outside_config( - dashboard_settings: DashboardSettings, -) -> None: - """Test rel_path with absolute path outside config dir raises error.""" - outside_path = "/tmp/outside/config.yaml" - - with pytest.raises(ValueError): - dashboard_settings.rel_path(outside_path) - - -def test_rel_path_empty_args(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with no arguments returns config_dir.""" - result = dashboard_settings.rel_path() - assert result == dashboard_settings.config_dir - - -def test_rel_path_with_pathlib_path(dashboard_settings: DashboardSettings) -> None: - """Test rel_path works with Path objects as arguments.""" - path_obj = Path("subfolder") / "config.yaml" - result = dashboard_settings.rel_path(path_obj) - - expected = dashboard_settings.config_dir / "subfolder" / "config.yaml" - assert result == expected - - -def test_rel_path_normalizes_slashes(dashboard_settings: DashboardSettings) -> None: - """Test rel_path normalizes path separators.""" - # os.path.join normalizes slashes on Windows but preserves them on Unix - # Test that providing components separately gives same result - result1 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml") - result2 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml") - assert result1 == result2 - - # Also test that the result is as expected - expected = dashboard_settings.config_dir / "folder" / "subfolder" / "file.yaml" - assert result1 == expected - - -def test_rel_path_handles_spaces(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles paths with spaces.""" - result = dashboard_settings.rel_path("my folder", "my config.yaml") - - expected = dashboard_settings.config_dir / "my folder" / "my config.yaml" - assert result == expected - - -def test_rel_path_handles_special_chars(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles paths with special characters.""" - result = dashboard_settings.rel_path("device-01_test", "config.yaml") - - expected = dashboard_settings.config_dir / "device-01_test" / "config.yaml" - assert result == expected - - -def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None: - """Test that config_dir can be accessed and used with Path operations.""" - config_path = dashboard_settings.config_dir - - assert config_path.exists() - assert config_path.is_dir() - assert config_path.is_absolute() - - -def test_absolute_config_dir_property(dashboard_settings: DashboardSettings) -> None: - """Test absolute_config_dir is a Path object.""" - assert isinstance(dashboard_settings.absolute_config_dir, Path) - assert dashboard_settings.absolute_config_dir.exists() - assert dashboard_settings.absolute_config_dir.is_dir() - assert dashboard_settings.absolute_config_dir.is_absolute() - - -def test_rel_path_symlink_inside_config(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with symlink that points inside config dir.""" - target = dashboard_settings.absolute_config_dir / "target.yaml" - target.touch() - symlink = dashboard_settings.absolute_config_dir / "link.yaml" - symlink.symlink_to(target) - result = dashboard_settings.rel_path("link.yaml") - expected = dashboard_settings.config_dir / "link.yaml" - assert result == expected - - -def test_rel_path_symlink_outside_config(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with symlink that points outside config dir.""" - with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp: - symlink = dashboard_settings.absolute_config_dir / "external_link.yaml" - symlink.symlink_to(tmp.name) - with pytest.raises(ValueError): - dashboard_settings.rel_path("external_link.yaml") - - -def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles None arguments gracefully.""" - result = dashboard_settings.rel_path("None") - expected = dashboard_settings.config_dir / "None" - assert result == expected - - -def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles numeric arguments.""" - result = dashboard_settings.rel_path("123", "456.789") - expected = dashboard_settings.config_dir / "123" / "456.789" - assert result == expected - - -def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None: - """Test that CORE.config_path.parent resolves to config_dir after parse_args. - - This is a regression test for issue #11280 where binary download failed - when using packages with secrets after the Path migration in 2025.10.0. - - The issue was that after switching from os.path to Path: - - Before: os.path.dirname("/config/.") → "/config" - - After: Path("/config/.").parent → Path("/") (normalized first!) - - The fix uses a sentinel file so .parent returns the correct directory: - - Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config") - """ - # Create test directory structure with secrets and packages - config_dir = tmp_path / "config" - config_dir.mkdir() - - # Create secrets.yaml with obviously fake test values - secrets_file = config_dir / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TEST-DUMMY-SSID\n" - "wifi_password: not-a-real-password-just-for-testing\n" - ) - - # Create package file that uses secrets - package_file = config_dir / "common.yaml" - package_file.write_text( - "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" - ) - - # Create main device config that includes the package - device_config = config_dir / "test-device.yaml" - device_config.write_text( - "esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n" - ) - - # Set up dashboard settings with our test config directory - settings = DashboardSettings() - args = Namespace( - configuration=str(config_dir), - password=None, - username=None, - ha_addon=False, - verbose=False, - ) - settings.parse_args(args) - - # Verify that CORE.config_path.parent correctly points to the config directory - # This is critical for secret resolution in yaml_util.py which does: - # main_config_dir = CORE.config_path.parent - # main_secret_yml = main_config_dir / "secrets.yaml" - assert CORE.config_path.parent == config_dir.resolve() - assert (CORE.config_path.parent / "secrets.yaml").exists() - assert (CORE.config_path.parent / "common.yaml").exists() - - # Verify that CORE.config_path itself uses the sentinel file - assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml" - assert not CORE.config_path.exists() # Sentinel file doesn't actually exist - - -@pytest.fixture -def auth_settings(dashboard_settings: DashboardSettings) -> DashboardSettings: - """Create DashboardSettings with auth configured, based on dashboard_settings.""" - dashboard_settings.username = "admin" - dashboard_settings.using_password = True - dashboard_settings.password_hash = password_hash("correctpassword") - return dashboard_settings - - -def test_check_password_correct_credentials(auth_settings: DashboardSettings) -> None: - """Test check_password returns True for correct username and password.""" - assert auth_settings.check_password("admin", "correctpassword") is True - - -def test_check_password_wrong_password(auth_settings: DashboardSettings) -> None: - """Test check_password returns False for wrong password.""" - assert auth_settings.check_password("admin", "wrongpassword") is False - - -def test_check_password_wrong_username(auth_settings: DashboardSettings) -> None: - """Test check_password returns False for wrong username.""" - assert auth_settings.check_password("notadmin", "correctpassword") is False - - -def test_check_password_both_wrong(auth_settings: DashboardSettings) -> None: - """Test check_password returns False when both are wrong.""" - assert auth_settings.check_password("notadmin", "wrongpassword") is False - - -def test_check_password_no_auth(dashboard_settings: DashboardSettings) -> None: - """Test check_password returns True when auth is not configured.""" - assert dashboard_settings.check_password("anyone", "anything") is True - - -def test_check_password_non_ascii_username( - dashboard_settings: DashboardSettings, -) -> None: - """Test check_password handles non-ASCII usernames without TypeError.""" - dashboard_settings.username = "\u00e9l\u00e8ve" - dashboard_settings.using_password = True - dashboard_settings.password_hash = password_hash("pass") - assert dashboard_settings.check_password("\u00e9l\u00e8ve", "pass") is True - assert dashboard_settings.check_password("\u00e9l\u00e8ve", "wrong") is False - assert dashboard_settings.check_password("other", "pass") is False - - -def test_check_password_ha_addon_no_password( - dashboard_settings: DashboardSettings, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test check_password doesn't crash in HA add-on mode without a password. - - In HA add-on mode, using_ha_addon_auth can be True while using_password - is False, leaving password_hash as b"". This must not raise TypeError - in hmac.compare_digest. - """ - monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False) - dashboard_settings.on_ha_addon = True - dashboard_settings.using_password = False - # password_hash stays as default b"" - assert dashboard_settings.check_password("anyone", "anything") is False diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py deleted file mode 100644 index 0ee841e68c..0000000000 --- a/tests/dashboard/test_web_server.py +++ /dev/null @@ -1,1889 +0,0 @@ -from __future__ import annotations - -from argparse import Namespace -import asyncio -import base64 -from collections.abc import Generator -from contextlib import asynccontextmanager -import gzip -import json -import os -from pathlib import Path -import sys -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -import pytest -import pytest_asyncio -from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse -from tornado.httpserver import HTTPServer -from tornado.ioloop import IOLoop -from tornado.testing import bind_unused_port -from tornado.websocket import WebSocketClientConnection, websocket_connect - -from esphome import yaml_util -from esphome.core import CORE -from esphome.dashboard import web_server -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.core import DASHBOARD -from esphome.dashboard.entries import ( - DashboardEntry, - EntryStateSource, - bool_to_entry_state, -) -from esphome.dashboard.models import build_importable_device_dict -from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket -from esphome.zeroconf import DiscoveredImport - -from .common import get_fixture_path - - -def get_build_path(base_path: Path, device_name: str) -> Path: - """Get the build directory path for a device. - - This is a test helper that constructs the standard ESPHome build directory - structure. Note: This helper does NOT perform path traversal sanitization - because it's only used in tests where we control the inputs. The actual - web_server.py code handles sanitization in DownloadBinaryRequestHandler.get() - via file_name.replace("..", "").lstrip("/"). - - Args: - base_path: The base temporary path (typically tmp_path from pytest) - device_name: The name of the device (should not contain path separators - in production use, but tests may use it for specific scenarios) - - Returns: - Path to the build directory (.esphome/build/device_name) - """ - return base_path / ".esphome" / "build" / device_name - - -class DashboardTestHelper: - def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None: - self.io_loop = io_loop - self.client = client - self.port = port - - async def fetch(self, path: str, **kwargs) -> HTTPResponse: - """Get a response for the given path.""" - if path.lower().startswith(("http://", "https://")): - url = path - else: - url = f"http://127.0.0.1:{self.port}{path}" - future = self.client.fetch(url, raise_error=True, **kwargs) - return await future - - -@pytest.fixture -def mock_async_run_system_command() -> Generator[MagicMock]: - """Fixture to mock async_run_system_command.""" - with patch("esphome.dashboard.web_server.async_run_system_command") as mock: - yield mock - - -@pytest.fixture -def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock trash_storage_path.""" - trash_dir = tmp_path / "trash" - with patch( - "esphome.dashboard.web_server.trash_storage_path", return_value=trash_dir - ) as mock: - yield mock - - -@pytest.fixture -def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock archive_storage_path.""" - archive_dir = tmp_path / "archive" - with patch( - "esphome.dashboard.web_server.archive_storage_path", - return_value=archive_dir, - ) as mock: - yield mock - - -@pytest.fixture -def mock_dashboard_settings() -> Generator[MagicMock]: - """Fixture to mock dashboard settings.""" - with patch("esphome.dashboard.web_server.settings") as mock_settings: - # Set default auth settings to avoid authentication issues - mock_settings.using_auth = False - mock_settings.on_ha_addon = False - yield mock_settings - - -@pytest.fixture -def mock_ext_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock ext_storage_path.""" - with patch("esphome.dashboard.web_server.ext_storage_path") as mock: - mock.return_value = str(tmp_path / "storage.json") - yield mock - - -@pytest.fixture -def mock_storage_json() -> Generator[MagicMock]: - """Fixture to mock StorageJSON.""" - with patch("esphome.dashboard.web_server.StorageJSON") as mock: - yield mock - - -@pytest.fixture -def mock_idedata() -> Generator[MagicMock]: - """Fixture to mock platformio toolchain.IDEData.""" - with patch("esphome.dashboard.web_server.toolchain.IDEData") as mock: - yield mock - - -@pytest_asyncio.fixture() -async def dashboard() -> DashboardTestHelper: - sock, port = bind_unused_port() - args = Mock( - ha_addon=True, - configuration=get_fixture_path("conf"), - port=port, - ) - DASHBOARD.settings.parse_args(args) - app = web_server.make_app() - http_server = HTTPServer(app) - http_server.add_sockets([sock]) - await DASHBOARD.async_setup() - os.environ["DISABLE_HA_AUTHENTICATION"] = "1" - assert DASHBOARD.settings.using_password is False - assert DASHBOARD.settings.on_ha_addon is True - assert DASHBOARD.settings.using_auth is False - task = asyncio.create_task(DASHBOARD.async_run()) - # Wait for initial device loading to complete - await DASHBOARD.entries.async_request_update_entries() - client = AsyncHTTPClient() - io_loop = IOLoop(make_current=False) - yield DashboardTestHelper(io_loop, client, port) - task.cancel() - sock.close() - client.close() - io_loop.close() - - -@asynccontextmanager -async def websocket_connection(dashboard: DashboardTestHelper): - """Async context manager for WebSocket connections.""" - url = f"ws://127.0.0.1:{dashboard.port}/events" - ws = await websocket_connect(url) - try: - yield ws - finally: - if ws: - ws.close() - - -@pytest_asyncio.fixture -async def websocket_client(dashboard: DashboardTestHelper) -> WebSocketClientConnection: - """Create a WebSocket connection for testing.""" - url = f"ws://127.0.0.1:{dashboard.port}/events" - ws = await websocket_connect(url) - - # Read and discard initial state message - await ws.read_message() - - yield ws - - if ws: - ws.close() - - -@pytest.mark.asyncio -async def test_main_page(dashboard: DashboardTestHelper) -> None: - response = await dashboard.fetch("/") - assert response.code == 200 - - -@pytest.mark.asyncio -async def test_devices_page(dashboard: DashboardTestHelper) -> None: - response = await dashboard.fetch("/devices") - assert response.code == 200 - assert response.headers["content-type"] == "application/json" - json_data = json.loads(response.body.decode()) - configured_devices = json_data["configured"] - assert len(configured_devices) != 0 - first_device = configured_devices[0] - assert first_device["name"] == "pico" - assert first_device["configuration"] == "pico.yaml" - - -@pytest.mark.asyncio -async def test_wizard_handler_invalid_input(dashboard: DashboardTestHelper) -> None: - """Test the WizardRequestHandler.post method with invalid inputs.""" - # Test with missing name (should fail with 422) - body_no_name = json.dumps( - { - "name": "", # Empty name - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body_no_name, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 422 - - # Test with invalid wizard type (should fail with 422) - body_invalid_type = json.dumps( - { - "name": "test_device", - "type": "invalid_type", - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body_invalid_type, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 422 - - -@pytest.mark.asyncio -async def test_wizard_handler_conflict(dashboard: DashboardTestHelper) -> None: - """Test the WizardRequestHandler.post when config already exists.""" - # Try to create a wizard for existing pico.yaml (should conflict) - body = json.dumps( - { - "name": "pico", # This already exists in fixtures - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 409 - - -@pytest.mark.asyncio -async def test_download_binary_handler_not_found( - dashboard: DashboardTestHelper, -) -> None: - """Test the DownloadBinaryRequestHandler.get with non-existent config.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=nonexistent.yaml", - method="GET", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_no_file_param( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get without file parameter.""" - # Mock storage to exist, but still should fail without file param - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = str(tmp_path / "firmware.bin") - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=pico.yaml", - method="GET", - ) - assert exc_info.value.code == 400 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_with_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with existing binary file.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"fake firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"fake firmware content" - assert response.headers["Content-Type"] == "application/octet-stream" - assert "attachment" in response.headers["Content-Disposition"] - assert "test_device-firmware.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_compressed( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with compression.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - original_content = b"fake firmware content for compression test" - firmware_file.write_bytes(original_content) - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin&compressed=1", - method="GET", - ) - assert response.code == 200 - # Decompress and verify content - decompressed = gzip.decompress(response.body) - assert decompressed == original_content - assert response.headers["Content-Type"] == "application/octet-stream" - assert "firmware.bin.gz" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_custom_download_name( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with custom download name.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin&download=custom_name.bin", - method="GET", - ) - assert response.code == 200 - assert "custom_name.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_idedata_fallback( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_async_run_system_command: MagicMock, - mock_storage_json: MagicMock, - mock_idedata: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get falling back to idedata for extra images.""" - # Create build directory but no bootloader file initially - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"firmware") - - # Create bootloader file that idedata will find - bootloader_file = tmp_path / "bootloader.bin" - bootloader_file.write_bytes(b"bootloader content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Mock idedata response - mock_image = Mock() - mock_image.path = bootloader_file - mock_idedata_instance = Mock() - mock_idedata_instance.extra_flash_images = [mock_image] - mock_idedata.return_value = mock_idedata_instance - - # Mock async_run_system_command to return idedata JSON - mock_async_run_system_command.return_value = (0, '{"extra_flash_images": []}', "") - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=bootloader.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"bootloader content" - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_subdirectory_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case). - - This is a regression test for issue #11343 where the Path migration broke - downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'. - - The issue was that with_name() doesn't accept path separators: - - Before: path = storage_json.firmware_bin_path.with_name(file_name) - ValueError: Invalid name 'zephyr/zephyr.uf2' - - After: path = storage_json.firmware_bin_path.parent.joinpath(file_name) - Works correctly with subdirectory paths - """ - # Create a fake nRF52 build structure with firmware in subdirectory - build_dir = get_build_path(tmp_path, "nrf52-device") - zephyr_dir = build_dir / "zephyr" - zephyr_dir.mkdir(parents=True) - - # Create the main firmware binary (would be in build root) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"main firmware") - - # Create the UF2 file in zephyr subdirectory (nRF52 specific) - uf2_file = zephyr_dir / "zephyr.uf2" - uf2_file.write_bytes(b"nRF52 UF2 firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "nrf52-device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Request the UF2 file with subdirectory path - response = await dashboard.fetch( - "/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2", - method="GET", - ) - assert response.code == 200 - assert response.body == b"nRF52 UF2 firmware content" - assert response.headers["Content-Type"] == "application/octet-stream" - assert "attachment" in response.headers["Content-Disposition"] - # Download name should be device-name + full file path - assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_subdirectory_file_url_encoded( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path. - - Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly - decoded and handled, and that custom download names work with subdirectories. - """ - # Create a fake build structure with firmware in subdirectory - build_dir = get_build_path(tmp_path, "test") - zephyr_dir = build_dir / "zephyr" - zephyr_dir.mkdir(parents=True) - - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"content") - - uf2_file = zephyr_dir / "zephyr.uf2" - uf2_file.write_bytes(b"content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Request with URL-encoded path and custom download name - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin", - method="GET", - ) - assert response.code == 200 - assert "custom_name.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -@pytest.mark.parametrize( - ("attack_path", "expected_code"), - [ - pytest.param("../../../secrets.yaml", 403, id="basic_traversal"), - pytest.param("..%2F..%2F..%2Fsecrets.yaml", 403, id="url_encoded"), - pytest.param("zephyr/../../../secrets.yaml", 403, id="traversal_with_prefix"), - pytest.param("/etc/passwd", 403, id="absolute_path"), - pytest.param("//etc/passwd", 403, id="double_slash_absolute"), - pytest.param( - "....//secrets.yaml", - # On Windows, Path.resolve() treats "..." and "...." as parent - # traversal (like ".."), so the path escapes base_dir -> 403. - # On Unix, "...." is a literal directory name that stays inside - # base_dir but doesn't exist -> 404. - 403 if sys.platform == "win32" else 404, - id="multiple_dots", - ), - ], -) -async def test_download_binary_handler_path_traversal_protection( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, - attack_path: str, - expected_code: int, -) -> None: - """Test that DownloadBinaryRequestHandler prevents path traversal attacks. - - Verifies that attempts to escape the build directory via '..' are rejected - using resolve()/relative_to() validation. Tests multiple attack vectors. - Real traversals that escape the base directory get 403. Paths like '....' - that resolve inside the base directory but don't exist get 404. - """ - # Create build structure - build_dir = get_build_path(tmp_path, "test") - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - - # Create a sensitive file outside the build directory that should NOT be accessible - sensitive_file = tmp_path / "secrets.yaml" - sensitive_file.write_bytes(b"secret: my_secret_password") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Mock async_run_system_command so paths that pass validation but don't exist - # return 404 deterministically without spawning a real subprocess. - with ( - patch( - "esphome.dashboard.web_server.async_run_system_command", - new_callable=AsyncMock, - return_value=(2, "", ""), - ), - pytest.raises(HTTPClientError) as exc_info, - ): - await dashboard.fetch( - f"/download.bin?configuration=test.yaml&file={attack_path}", - method="GET", - ) - assert exc_info.value.code == expected_code - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_no_firmware_bin_path( - dashboard: DashboardTestHelper, - mock_storage_json: MagicMock, -) -> None: - """Test that download returns 404 when firmware_bin_path is None. - - This covers configs created by StorageJSON.from_wizard() where no - firmware has been compiled yet. - """ - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = None - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin", - method="GET", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -@pytest.mark.parametrize("file_value", ["", "%20%20", "%20"]) -async def test_download_binary_handler_empty_file_name( - dashboard: DashboardTestHelper, - mock_storage_json: MagicMock, - file_value: str, -) -> None: - """Test that download returns 400 for empty or whitespace-only file names.""" - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = Path("/fake/firmware.bin") - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - f"/download.bin?configuration=test.yaml&file={file_value}", - method="GET", - ) - assert exc_info.value.code == 400 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_multiple_subdirectory_levels( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test downloading files from multiple subdirectory levels. - - Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'. - """ - # Create nested directory structure - build_dir = get_build_path(tmp_path, "test") - nested_dir = build_dir / "build" / "output" - nested_dir.mkdir(parents=True) - - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"main") - - nested_file = nested_dir / "firmware.bin" - nested_file.write_bytes(b"nested firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=build/output/firmware.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"nested firmware content" - - -@pytest.mark.asyncio -async def test_edit_request_handler_post_invalid_file( - dashboard: DashboardTestHelper, -) -> None: - """Test the EditRequestHandler.post with non-yaml file.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/edit?configuration=test.txt", - method="POST", - body=b"content", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_edit_request_handler_post_existing( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_dashboard_settings: MagicMock, -) -> None: - """Test the EditRequestHandler.post with existing yaml file.""" - # Create a temporary yaml file to edit (don't modify fixtures) - test_file = tmp_path / "test_edit.yaml" - test_file.write_text("esphome:\n name: original\n") - - # Configure the mock settings - mock_dashboard_settings.rel_path.return_value = test_file - mock_dashboard_settings.absolute_config_dir = test_file.parent - - new_content = "esphome:\n name: modified\n" - response = await dashboard.fetch( - "/edit?configuration=test_edit.yaml", - method="POST", - body=new_content.encode(), - ) - assert response.code == 200 - - # Verify the file was actually modified - assert test_file.read_text() == new_content - - -@pytest.mark.asyncio -async def test_unarchive_request_handler( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - tmp_path: Path, -) -> None: - """Test the UnArchiveRequestHandler.post method.""" - # Set up an archived file - archive_dir = mock_archive_storage_path.return_value - archive_dir.mkdir(parents=True, exist_ok=True) - archived_file = archive_dir / "archived.yaml" - archived_file.write_text("test content") - - # Set up the destination path where the file should be moved - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True, exist_ok=True) - destination_file = config_dir / "archived.yaml" - mock_dashboard_settings.rel_path.return_value = destination_file - - response = await dashboard.fetch( - "/unarchive?configuration=archived.yaml", - method="POST", - body=b"", - ) - assert response.code == 200 - - # Verify the file was actually moved from archive to config - assert not archived_file.exists() # File should be gone from archive - assert destination_file.exists() # File should now be in config - assert destination_file.read_text() == "test content" # Content preserved - - -@pytest.mark.asyncio -async def test_secret_keys_handler_no_file(dashboard: DashboardTestHelper) -> None: - """Test the SecretKeysRequestHandler.get when no secrets file exists.""" - # By default, there's no secrets file in the test fixtures - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/secret_keys", method="GET") - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_secret_keys_handler_with_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_dashboard_settings: MagicMock, -) -> None: - """Test the SecretKeysRequestHandler.get when secrets file exists.""" - # Create a secrets file in temp directory - secrets_file = tmp_path / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TestNetwork\nwifi_password: TestPass123\napi_key: test_key\n" - ) - - # Configure mock to return our temp secrets file - # Since the file actually exists, os.path.isfile will return True naturally - mock_dashboard_settings.rel_path.return_value = secrets_file - - response = await dashboard.fetch("/secret_keys", method="GET") - assert response.code == 200 - data = json.loads(response.body.decode()) - assert "wifi_ssid" in data - assert "wifi_password" in data - assert "api_key" in data - - -@pytest.mark.asyncio -async def test_json_config_handler( - dashboard: DashboardTestHelper, - mock_async_run_system_command: MagicMock, -) -> None: - """Test the JsonConfigRequestHandler.get method.""" - # This will actually run the esphome config command on pico.yaml - mock_output = json.dumps( - { - "esphome": {"name": "pico"}, - "esp32": {"board": "esp32dev"}, - } - ) - mock_async_run_system_command.return_value = (0, mock_output, "") - - response = await dashboard.fetch( - "/json-config?configuration=pico.yaml", method="GET" - ) - assert response.code == 200 - data = json.loads(response.body.decode()) - assert data["esphome"]["name"] == "pico" - - -@pytest.mark.asyncio -async def test_json_config_handler_invalid_config( - dashboard: DashboardTestHelper, - mock_async_run_system_command: MagicMock, -) -> None: - """Test the JsonConfigRequestHandler.get with invalid config.""" - # Simulate esphome config command failure - mock_async_run_system_command.return_value = (1, "", "Error: Invalid configuration") - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/json-config?configuration=pico.yaml", method="GET") - assert exc_info.value.code == 422 - - -@pytest.mark.asyncio -async def test_json_config_handler_not_found(dashboard: DashboardTestHelper) -> None: - """Test the JsonConfigRequestHandler.get with non-existent file.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/json-config?configuration=nonexistent.yaml", method="GET" - ) - assert exc_info.value.code == 404 - - -def test_start_web_server_with_address_port( - tmp_path: Path, - mock_trash_storage_path: MagicMock, - mock_archive_storage_path: MagicMock, -) -> None: - """Test the start_web_server function with address and port.""" - app = Mock() - trash_dir = mock_trash_storage_path.return_value - archive_dir = mock_archive_storage_path.return_value - - # Create trash dir to test migration - trash_dir.mkdir() - (trash_dir / "old.yaml").write_text("old") - - web_server.start_web_server(app, None, "127.0.0.1", 6052, str(tmp_path / "config")) - - # The function calls app.listen directly for non-socket mode - app.listen.assert_called_once_with(6052, "127.0.0.1") - - # Verify trash was moved to archive - assert not trash_dir.exists() - assert archive_dir.exists() - assert (archive_dir / "old.yaml").exists() - - -@pytest.mark.asyncio -async def test_edit_request_handler_get(dashboard: DashboardTestHelper) -> None: - """Test EditRequestHandler.get method.""" - # Test getting a valid yaml file - response = await dashboard.fetch("/edit?configuration=pico.yaml") - assert response.code == 200 - assert response.headers["content-type"] == "application/yaml" - content = response.body.decode() - assert "esphome:" in content # Verify it's a valid ESPHome config - - # Test getting a non-existent file - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=nonexistent.yaml") - assert exc_info.value.code == 404 - - # Test getting a non-yaml file - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=test.txt") - assert exc_info.value.code == 404 - - # Test path traversal attempt - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=../../../etc/passwd") - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_archive_request_handler_post( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post method without storage_json.""" - - # Set up temp directories - config_dir = Path(get_fixture_path("conf")) - archive_dir = tmp_path / "archive" - - # Create a test configuration file - test_config = config_dir / "test_archive.yaml" - test_config.write_text("esphome:\n name: test_archive\n") - - # Archive the configuration - response = await dashboard.fetch( - "/archive", - method="POST", - body="configuration=test_archive.yaml", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - # Verify file was moved to archive - assert not test_config.exists() - assert (archive_dir / "test_archive.yaml").exists() - assert ( - archive_dir / "test_archive.yaml" - ).read_text() == "esphome:\n name: test_archive\n" - - -@pytest.mark.asyncio -async def test_archive_handler_with_build_folder( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - mock_storage_json: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post with storage_json and build folder.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - archive_dir = tmp_path / "archive" - archive_dir.mkdir() - build_dir = tmp_path / "build" - build_dir.mkdir() - - configuration = "test_device.yaml" - test_config = config_dir / configuration - test_config.write_text("esphome:\n name: test_device\n") - - build_folder = build_dir / "test_device" - build_folder.mkdir() - (build_folder / "firmware.bin").write_text("binary content") - (build_folder / ".pioenvs").mkdir() - - mock_dashboard_settings.config_dir = str(config_dir) - mock_dashboard_settings.rel_path.return_value = test_config - mock_archive_storage_path.return_value = archive_dir - - mock_storage = MagicMock() - mock_storage.name = "test_device" - mock_storage.build_path = build_folder - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/archive", - method="POST", - body=f"configuration={configuration}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - assert not test_config.exists() - assert (archive_dir / configuration).exists() - - assert not build_folder.exists() - assert not (archive_dir / "test_device").exists() - - -@pytest.mark.asyncio -async def test_archive_handler_no_build_folder( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - mock_storage_json: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post with storage_json but no build folder.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - archive_dir = tmp_path / "archive" - archive_dir.mkdir() - - configuration = "test_device.yaml" - test_config = config_dir / configuration - test_config.write_text("esphome:\n name: test_device\n") - - mock_dashboard_settings.config_dir = str(config_dir) - mock_dashboard_settings.rel_path.return_value = test_config - mock_archive_storage_path.return_value = archive_dir - - mock_storage = MagicMock() - mock_storage.name = "test_device" - mock_storage.build_path = None - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/archive", - method="POST", - body=f"configuration={configuration}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - assert not test_config.exists() - assert (archive_dir / configuration).exists() - assert not (archive_dir / "test_device").exists() - - -@pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") -@pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") -def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: - """Test the start_web_server function with unix socket.""" - app = Mock() - socket_path = tmp_path / "test.sock" - - # Don't create trash_dir - it doesn't exist, so no migration needed - with ( - patch("tornado.httpserver.HTTPServer") as mock_server_class, - patch("tornado.netutil.bind_unix_socket") as mock_bind, - ): - server = Mock() - mock_server_class.return_value = server - mock_bind.return_value = Mock() - - web_server.start_web_server( - app, str(socket_path), None, None, str(tmp_path / "config") - ) - - mock_server_class.assert_called_once_with(app) - mock_bind.assert_called_once_with(str(socket_path), mode=0o666) - server.add_socket.assert_called_once() - - -def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None: - """Test with no entry returns empty list.""" - result = web_server.build_cache_arguments(None, mock_dashboard, 0.0) - assert result == [] - - -def test_build_cache_arguments_no_address_no_name(mock_dashboard: Mock) -> None: - """Test with entry but no address or name.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = None - entry.name = None - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - assert result == [] - - -def test_build_cache_arguments_mdns_address_cached(mock_dashboard: Mock) -> None: - """Test with .local address that has cached mDNS results.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = "device.local" - entry.name = None - mock_dashboard.mdns_status = Mock() - mock_dashboard.mdns_status.get_cached_addresses.return_value = [ - "192.168.1.10", - "fe80::1", - ] - - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - - assert result == [ - "--mdns-address-cache", - "device.local=192.168.1.10,fe80::1", - ] - mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( - "device.local" - ) - - -def test_build_cache_arguments_dns_address_cached(mock_dashboard: Mock) -> None: - """Test with non-.local address that has cached DNS results.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = "example.com" - entry.name = None - mock_dashboard.dns_cache = Mock() - mock_dashboard.dns_cache.get_cached_addresses.return_value = [ - "93.184.216.34", - "2606:2800:220:1:248:1893:25c8:1946", - ] - - now = 100.0 - result = web_server.build_cache_arguments(entry, mock_dashboard, now) - - # IPv6 addresses are sorted before IPv4 - assert result == [ - "--dns-address-cache", - "example.com=2606:2800:220:1:248:1893:25c8:1946,93.184.216.34", - ] - mock_dashboard.dns_cache.get_cached_addresses.assert_called_once_with( - "example.com", now - ) - - -def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> None: - """Test with name but no address - should check mDNS with .local suffix.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.name = "my-device" - entry.address = None - mock_dashboard.mdns_status = Mock() - mock_dashboard.mdns_status.get_cached_addresses.return_value = ["192.168.1.20"] - - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - - assert result == [ - "--mdns-address-cache", - "my-device.local=192.168.1.20", - ] - mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( - "my-device.local" - ) - - -@pytest.mark.asyncio -async def test_websocket_connection_initial_state( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket connection and initial state.""" - async with websocket_connection(dashboard) as ws: - # Should receive initial state with configured and importable devices - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - assert "devices" in data["data"] - assert "configured" in data["data"]["devices"] - assert "importable" in data["data"]["devices"] - - # Check configured devices - configured = data["data"]["devices"]["configured"] - assert len(configured) > 0 - assert configured[0]["name"] == "pico" # From test fixtures - - -@pytest.mark.asyncio -async def test_websocket_ping_pong( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket ping/pong mechanism.""" - # Send ping - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should receive pong - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_invalid_json( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket handling of invalid JSON.""" - # Send invalid JSON - await websocket_client.write_message("not valid json {]") - - # Send a valid ping to verify connection is still alive - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should receive pong, confirming the connection wasn't closed by invalid JSON - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_authentication_required( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket authentication when auth is required.""" - with patch( - "esphome.dashboard.web_server.is_authenticated" - ) as mock_is_authenticated: - mock_is_authenticated.return_value = False - - # Try to connect - should be rejected with 401 - url = f"ws://127.0.0.1:{dashboard.port}/events" - with pytest.raises(HTTPClientError) as exc_info: - await websocket_connect(url) - # Should get HTTP 401 Unauthorized - assert exc_info.value.code == 401 - - -@pytest.mark.asyncio -async def test_websocket_authentication_not_required( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket connection when no auth is required.""" - with patch( - "esphome.dashboard.web_server.is_authenticated" - ) as mock_is_authenticated: - mock_is_authenticated.return_value = True - - # Should be able to connect successfully - async with websocket_connection(dashboard) as ws: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - - -@pytest.mark.asyncio -async def test_websocket_entry_state_changed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry state changed event.""" - # Simulate entry state change - entry = DASHBOARD.entries.async_all()[0] - state = bool_to_entry_state(True, EntryStateSource.MDNS) - DASHBOARD.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - # Should receive state change event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_state_changed" - assert data["data"]["filename"] == entry.filename - assert data["data"]["name"] == entry.name - assert data["data"]["state"] is True - - -@pytest.mark.asyncio -async def test_websocket_entry_added( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry added event.""" - # Create a mock entry - mock_entry = Mock(spec=DashboardEntry) - mock_entry.filename = "test.yaml" - mock_entry.name = "test_device" - mock_entry.to_dict.return_value = { - "name": "test_device", - "filename": "test.yaml", - "configuration": "test.yaml", - } - - # Simulate entry added - DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": mock_entry}) - - # Should receive entry added event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_added" - assert data["data"]["device"]["name"] == "test_device" - assert data["data"]["device"]["filename"] == "test.yaml" - - -@pytest.mark.asyncio -async def test_websocket_entry_removed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry removed event.""" - # Create a mock entry - mock_entry = Mock(spec=DashboardEntry) - mock_entry.filename = "removed.yaml" - mock_entry.name = "removed_device" - mock_entry.to_dict.return_value = { - "name": "removed_device", - "filename": "removed.yaml", - "configuration": "removed.yaml", - } - - # Simulate entry removed - DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": mock_entry}) - - # Should receive entry removed event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_removed" - assert data["data"]["device"]["name"] == "removed_device" - assert data["data"]["device"]["filename"] == "removed.yaml" - - -@pytest.mark.asyncio -async def test_websocket_importable_device_added( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device added event with real DiscoveredImport.""" - # Create a real DiscoveredImport object - discovered = DiscoveredImport( - device_name="new_import_device", - friendly_name="New Import Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="wifi", - ) - - # Directly fire the event as the mDNS system would - device_dict = build_importable_device_dict(DASHBOARD, discovered) - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict} - ) - - # Should receive importable device added event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_added" - assert data["data"]["device"]["name"] == "new_import_device" - assert data["data"]["device"]["friendly_name"] == "New Import Device" - assert data["data"]["device"]["project_name"] == "test_project" - assert data["data"]["device"]["network"] == "wifi" - assert data["data"]["device"]["ignored"] is False - - -@pytest.mark.asyncio -async def test_websocket_importable_device_added_ignored( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device added event for ignored device.""" - # Add device to ignored list - DASHBOARD.ignored_devices.add("ignored_device") - - # Create a real DiscoveredImport object - discovered = DiscoveredImport( - device_name="ignored_device", - friendly_name="Ignored Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="ethernet", - ) - - # Directly fire the event as the mDNS system would - device_dict = build_importable_device_dict(DASHBOARD, discovered) - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict} - ) - - # Should receive importable device added event with ignored=True - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_added" - assert data["data"]["device"]["name"] == "ignored_device" - assert data["data"]["device"]["friendly_name"] == "Ignored Device" - assert data["data"]["device"]["network"] == "ethernet" - assert data["data"]["device"]["ignored"] is True - - -@pytest.mark.asyncio -async def test_websocket_importable_device_removed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device removed event.""" - # Simulate importable device removed - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, - {"name": "removed_import_device"}, - ) - - # Should receive importable device removed event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_removed" - assert data["data"]["name"] == "removed_import_device" - - -@pytest.mark.asyncio -async def test_websocket_importable_device_already_configured( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test that importable device event is not sent if device is already configured.""" - # Get an existing configured device name - existing_entry = DASHBOARD.entries.async_all()[0] - - # Simulate importable device added with same name as configured device - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, - { - "device": { - "name": existing_entry.name, - "friendly_name": "Should Not Be Sent", - "package_import_url": "https://example.com/package", - "project_name": "test_project", - "project_version": "1.0.0", - "network": "wifi", - } - }, - ) - - # Send a ping to ensure connection is still alive - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should only receive pong, not the importable device event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_multiple_connections(dashboard: DashboardTestHelper) -> None: - """Test multiple WebSocket connections.""" - async with ( - websocket_connection(dashboard) as ws1, - websocket_connection(dashboard) as ws2, - ): - # Both should receive initial state - msg1 = await ws1.read_message() - assert msg1 is not None - data1 = json.loads(msg1) - assert data1["event"] == "initial_state" - - msg2 = await ws2.read_message() - assert msg2 is not None - data2 = json.loads(msg2) - assert data2["event"] == "initial_state" - - # Fire an event - both should receive it - entry = DASHBOARD.entries.async_all()[0] - state = bool_to_entry_state(False, EntryStateSource.MDNS) - DASHBOARD.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - msg1 = await ws1.read_message() - assert msg1 is not None - data1 = json.loads(msg1) - assert data1["event"] == "entry_state_changed" - - msg2 = await ws2.read_message() - assert msg2 is not None - data2 = json.loads(msg2) - assert data2["event"] == "entry_state_changed" - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_lifecycle(dashboard: DashboardTestHelper) -> None: - """Test DashboardSubscriber lifecycle.""" - subscriber = DashboardSubscriber() - - # Initially no subscribers - assert len(subscriber._subscribers) == 0 - assert subscriber._event_loop_task is None - - # Add a subscriber - mock_websocket = Mock() - unsubscribe = subscriber.subscribe(mock_websocket) - - # Should have started the event loop task - assert len(subscriber._subscribers) == 1 - assert subscriber._event_loop_task is not None - - # Unsubscribe - unsubscribe() - - # Should have stopped the task - assert len(subscriber._subscribers) == 0 - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_entries_update_interval( - dashboard: DashboardTestHelper, -) -> None: - """Test DashboardSubscriber entries update interval.""" - # Patch the constants to make the test run faster - with ( - patch("esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 0.01), - patch("esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 2), - patch("esphome.dashboard.web_server.settings") as mock_settings, - patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard, - ): - mock_settings.status_use_mqtt = False - - # Mock dashboard dependencies - mock_dashboard.ping_request = Mock() - mock_dashboard.ping_request.set = Mock() - mock_dashboard.entries = Mock() - mock_dashboard.entries.async_request_update_entries = Mock() - - subscriber = DashboardSubscriber() - mock_websocket = Mock() - - # Subscribe to start the event loop - unsubscribe = subscriber.subscribe(mock_websocket) - - # Wait for a few iterations to ensure entries update is called - await asyncio.sleep(0.05) # Should be enough for 2+ iterations - - # Unsubscribe to stop the task - unsubscribe() - - # Verify entries update was called - assert mock_dashboard.entries.async_request_update_entries.call_count >= 1 - # Verify ping request was set multiple times - assert mock_dashboard.ping_request.set.call_count >= 2 - - -@pytest.mark.asyncio -async def test_websocket_refresh_command( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket refresh command triggers dashboard update.""" - with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: - # Signal an asyncio.Event when request_refresh is invoked so the - # test can deterministically wait for the server-side handler to run - # instead of relying on a fixed sleep (flaky on Windows CI under load). - called = asyncio.Event() - mock_subscriber.request_refresh = Mock(side_effect=called.set) - - # Send refresh command - await websocket_client.write_message(json.dumps({"event": "refresh"})) - - # Wait for the server to process the message and invoke request_refresh - async with asyncio.timeout(5): - await called.wait() - - # Verify request_refresh was called - mock_subscriber.request_refresh.assert_called_once() - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_refresh_event( - dashboard: DashboardTestHelper, -) -> None: - """Test DashboardSubscriber refresh event triggers immediate update.""" - # Patch the constants to make the test run faster - with ( - patch( - "esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 1.0 - ), # Long timeout - patch( - "esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 100 - ), # Won't reach naturally - patch("esphome.dashboard.web_server.settings") as mock_settings, - patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard, - ): - mock_settings.status_use_mqtt = False - - # Mock dashboard dependencies - mock_dashboard.ping_request = Mock() - mock_dashboard.ping_request.set = Mock() - mock_dashboard.entries = Mock() - mock_dashboard.entries.async_request_update_entries = AsyncMock() - - subscriber = DashboardSubscriber() - mock_websocket = Mock() - - # Subscribe to start the event loop - unsubscribe = subscriber.subscribe(mock_websocket) - - # Wait a bit to ensure loop is running - await asyncio.sleep(0.01) - - # Verify entries update hasn't been called yet (iterations not reached) - assert mock_dashboard.entries.async_request_update_entries.call_count == 0 - - # Request refresh - subscriber.request_refresh() - - # Wait for the refresh to be processed - await asyncio.sleep(0.01) - - # Now entries update should have been called - assert mock_dashboard.entries.async_request_update_entries.call_count == 1 - - # Unsubscribe to stop the task - unsubscribe() - - # Give it a moment to clean up - await asyncio.sleep(0.01) - - -@pytest.mark.asyncio -async def test_dashboard_yaml_loading_with_packages_and_secrets( - tmp_path: Path, -) -> None: - """Test dashboard YAML loading with packages referencing secrets. - - This is a regression test for issue #11280 where binary download failed - when using packages with secrets after the Path migration in 2025.10.0. - - This test verifies that CORE.config_path initialization in the dashboard - allows yaml_util.load_yaml() to correctly resolve secrets from packages. - """ - # Create test directory structure with secrets and packages - config_dir = tmp_path / "config" - config_dir.mkdir() - - # Create secrets.yaml with obviously fake test values - secrets_file = config_dir / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TEST-DUMMY-SSID\n" - "wifi_password: not-a-real-password-just-for-testing\n" - ) - - # Create package file that uses secrets - package_file = config_dir / "common.yaml" - package_file.write_text( - "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" - ) - - # Create main device config that includes the package - device_config = config_dir / "test-download-secrets.yaml" - device_config.write_text( - "esphome:\n name: test-download-secrets\n platform: ESP32\n board: esp32dev\n\n" - "packages:\n common: !include common.yaml\n" - ) - - # Initialize DASHBOARD settings with our test config directory - # This is what sets CORE.config_path - the critical code path for the bug - args = Namespace( - configuration=str(config_dir), - password=None, - username=None, - ha_addon=False, - verbose=False, - ) - DASHBOARD.settings.parse_args(args) - - # With the fix: CORE.config_path should be config_dir / "___DASHBOARD_SENTINEL___.yaml" - # so CORE.config_path.parent would be config_dir - # Without the fix: CORE.config_path is config_dir / "." which normalizes to config_dir - # so CORE.config_path.parent would be tmp_path (the parent of config_dir) - - # The fix ensures CORE.config_path.parent points to config_dir - assert CORE.config_path.parent == config_dir.resolve(), ( - f"CORE.config_path.parent should point to config_dir. " - f"Got {CORE.config_path.parent}, expected {config_dir.resolve()}. " - f"CORE.config_path is {CORE.config_path}" - ) - - # Now load the YAML with packages that reference secrets - # This is where the bug would manifest - yaml_util.load_yaml would fail - # to find secrets.yaml because CORE.config_path.parent pointed to the wrong place - config = yaml_util.load_yaml(device_config) - # If we get here, secret resolution worked! - assert "esphome" in config - assert config["esphome"]["name"] == "test-download-secrets" - - -@pytest.mark.asyncio -async def test_websocket_check_origin_default_same_origin( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket uses default same-origin check when ESPHOME_TRUSTED_DOMAINS not set.""" - # Ensure ESPHOME_TRUSTED_DOMAINS is not set - env = os.environ.copy() - env.pop("ESPHOME_TRUSTED_DOMAINS", None) - with patch.dict(os.environ, env, clear=True): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - # Same origin should work (default Tornado behavior) - request = HTTPRequest( - url, headers={"Origin": f"http://127.0.0.1:{dashboard.port}"} - ) - ws = await websocket_connect(request) - try: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -@pytest.mark.asyncio -async def test_websocket_check_origin_trusted_domain( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket accepts connections from trusted domains.""" - with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - request = HTTPRequest(url, headers={"Origin": "https://trusted.example.com"}) - ws = await websocket_connect(request) - try: - # Should receive initial state - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -@pytest.mark.asyncio -async def test_websocket_check_origin_untrusted_domain( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket rejects connections from untrusted domains.""" - with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - request = HTTPRequest(url, headers={"Origin": "https://untrusted.example.com"}) - with pytest.raises(HTTPClientError) as exc_info: - await websocket_connect(request) - # Should get HTTP 403 Forbidden due to origin check failure - assert exc_info.value.code == 403 - - -@pytest.mark.asyncio -async def test_websocket_check_origin_multiple_trusted_domains( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket accepts connections from multiple trusted domains.""" - with patch.dict( - os.environ, - {"ESPHOME_TRUSTED_DOMAINS": "first.example.com, second.example.com"}, - ): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - # Test second domain in list (with space after comma) - request = HTTPRequest(url, headers={"Origin": "https://second.example.com"}) - ws = await websocket_connect(request) - try: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -def test_proc_on_exit_calls_close() -> None: - """Test _proc_on_exit sends exit event and closes the WebSocket.""" - handler = Mock(spec=EsphomeCommandWebSocket) - handler._is_closed = False - - EsphomeCommandWebSocket._proc_on_exit(handler, 0) - - handler.write_message.assert_called_once_with({"event": "exit", "code": 0}) - handler.close.assert_called_once() - - -def test_proc_on_exit_skips_when_already_closed() -> None: - """Test _proc_on_exit does nothing when WebSocket is already closed.""" - handler = Mock(spec=EsphomeCommandWebSocket) - handler._is_closed = True - - EsphomeCommandWebSocket._proc_on_exit(handler, 0) - - handler.write_message.assert_not_called() - handler.close.assert_not_called() - - -@pytest.mark.asyncio -async def test_esphome_logs_handler_appends_no_states_when_set() -> None: - """Test --no-states is appended when no_states is truthy in the message.""" - handler = Mock(spec=web_server.EsphomeLogsHandler) - handler.build_device_command = AsyncMock( - return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] - ) - - json_message = { - "configuration": "device.yaml", - "port": "OTA", - "no_states": True, - } - cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message) - - assert cmd == [ - "esphome", - "logs", - "device.yaml", - "--device", - "OTA", - "--no-states", - ] - handler.build_device_command.assert_awaited_once_with(["logs"], json_message) - - -@pytest.mark.asyncio -async def test_esphome_logs_handler_omits_no_states_when_missing() -> None: - """Test --no-states is not added when no_states is absent from the message.""" - handler = Mock(spec=web_server.EsphomeLogsHandler) - handler.build_device_command = AsyncMock( - return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] - ) - - cmd = await web_server.EsphomeLogsHandler.build_command( - handler, {"configuration": "device.yaml", "port": "OTA"} - ) - - assert "--no-states" not in cmd - assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"] - - -@pytest.mark.asyncio -async def test_esphome_logs_handler_omits_no_states_when_false() -> None: - """Test --no-states is not added when no_states is explicitly False.""" - handler = Mock(spec=web_server.EsphomeLogsHandler) - handler.build_device_command = AsyncMock( - return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] - ) - - cmd = await web_server.EsphomeLogsHandler.build_command( - handler, - {"configuration": "device.yaml", "port": "OTA", "no_states": False}, - ) - - assert "--no-states" not in cmd - - -def _make_auth_handler(auth_header: str | None = None) -> Mock: - """Create a mock handler with the given Authorization header.""" - handler = Mock() - handler.request = Mock() - if auth_header is not None: - handler.request.headers = {"Authorization": auth_header} - else: - handler.request.headers = {} - handler.get_secure_cookie = Mock(return_value=None) - return handler - - -@pytest.fixture -def mock_auth_settings(mock_dashboard_settings: MagicMock) -> MagicMock: - """Fixture to configure mock dashboard settings with auth enabled.""" - mock_dashboard_settings.using_auth = True - mock_dashboard_settings.on_ha_addon = False - return mock_dashboard_settings - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_malformed_base64() -> None: - """Test that invalid base64 in Authorization header returns False.""" - handler = _make_auth_handler("Basic !!!not-valid-base64!!!") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_bad_base64_padding() -> None: - """Test that incorrect base64 padding (binascii.Error) returns False.""" - handler = _make_auth_handler("Basic abc") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_invalid_utf8() -> None: - """Test that base64 decoding to invalid UTF-8 returns False.""" - # \xff\xfe is invalid UTF-8 - bad_payload = base64.b64encode(b"\xff\xfe").decode("ascii") - handler = _make_auth_handler(f"Basic {bad_payload}") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_no_colon() -> None: - """Test that base64 payload without ':' separator returns False.""" - no_colon = base64.b64encode(b"nocolonhere").decode("ascii") - handler = _make_auth_handler(f"Basic {no_colon}") - assert web_server.is_authenticated(handler) is False - - -def test_is_authenticated_valid_credentials( - mock_auth_settings: MagicMock, -) -> None: - """Test that valid Basic auth credentials are checked.""" - creds = base64.b64encode(b"admin:secret").decode("ascii") - mock_auth_settings.check_password.return_value = True - handler = _make_auth_handler(f"Basic {creds}") - assert web_server.is_authenticated(handler) is True - mock_auth_settings.check_password.assert_called_once_with("admin", "secret") - - -def test_is_authenticated_wrong_credentials( - mock_auth_settings: MagicMock, -) -> None: - """Test that valid Basic auth with wrong credentials returns False.""" - creds = base64.b64encode(b"admin:wrong").decode("ascii") - mock_auth_settings.check_password.return_value = False - handler = _make_auth_handler(f"Basic {creds}") - assert web_server.is_authenticated(handler) is False - - -def test_is_authenticated_no_auth_configured( - mock_dashboard_settings: MagicMock, -) -> None: - """Test that requests pass when auth is not configured.""" - mock_dashboard_settings.using_auth = False - mock_dashboard_settings.on_ha_addon = False - handler = _make_auth_handler() - assert web_server.is_authenticated(handler) is True diff --git a/tests/dashboard/test_web_server_paths.py b/tests/dashboard/test_web_server_paths.py deleted file mode 100644 index efeafbf3b5..0000000000 --- a/tests/dashboard/test_web_server_paths.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Tests for dashboard web_server Path-related functionality.""" - -from __future__ import annotations - -import gzip -import os -from pathlib import Path -from unittest.mock import MagicMock, patch - -from esphome.dashboard import web_server - - -def test_get_base_frontend_path_production() -> None: - """Test get_base_frontend_path in production mode.""" - mock_module = MagicMock() - mock_module.where.return_value = Path("/usr/local/lib/esphome_dashboard") - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - ): - result = web_server.get_base_frontend_path() - assert result == Path("/usr/local/lib/esphome_dashboard") - mock_module.where.assert_called_once() - - -def test_get_base_frontend_path_dev_mode() -> None: - """Test get_base_frontend_path in development mode.""" - test_path = "/home/user/esphome/dashboard" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - # The actual function adds "/" to the path, so we simulate that - test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() - assert result == expected - - -def test_get_base_frontend_path_dev_mode_with_trailing_slash() -> None: - """Test get_base_frontend_path in dev mode with trailing slash.""" - test_path = "/home/user/esphome/dashboard/" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - expected = (Path.cwd() / test_path / "esphome_dashboard").resolve() - assert result == expected - - -def test_get_base_frontend_path_dev_mode_relative_path() -> None: - """Test get_base_frontend_path with relative dev path.""" - test_path = "./dashboard" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - # The actual function adds "/" to the path, so we simulate that - test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() - assert result == expected - assert result.is_absolute() - - -def test_get_static_path_single_component() -> None: - """Test get_static_path with single path component.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("file.js") - - assert result == Path("/base/frontend") / "static" / "file.js" - - -def test_get_static_path_multiple_components() -> None: - """Test get_static_path with multiple path components.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("js", "esphome", "index.js") - - assert ( - result == Path("/base/frontend") / "static" / "js" / "esphome" / "index.js" - ) - - -def test_get_static_path_empty_args() -> None: - """Test get_static_path with no arguments.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path() - - assert result == Path("/base/frontend") / "static" - - -def test_get_static_path_with_pathlib_path() -> None: - """Test get_static_path with Path objects.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - path_obj = Path("js") / "app.js" - result = web_server.get_static_path(str(path_obj)) - - assert result == Path("/base/frontend") / "static" / "js" / "app.js" - - -def test_get_static_file_url_production() -> None: - """Test get_static_file_url in production mode.""" - web_server.get_static_file_url.cache_clear() - mock_module = MagicMock() - mock_path = MagicMock(spec=Path) - mock_path.read_bytes.return_value = b"test content" - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - patch("esphome.dashboard.web_server.get_static_path") as mock_get_path, - ): - mock_get_path.return_value = mock_path - result = web_server.get_static_file_url("js/app.js") - assert result.startswith("./static/js/app.js?hash=") - - -def test_get_static_file_url_dev_mode() -> None: - """Test get_static_file_url in development mode.""" - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": "/dev/path"}): - web_server.get_static_file_url.cache_clear() - result = web_server.get_static_file_url("js/app.js") - - assert result == "./static/js/app.js" - - -def test_get_static_file_url_index_js_special_case() -> None: - """Test get_static_file_url replaces index.js with entrypoint.""" - web_server.get_static_file_url.cache_clear() - mock_module = MagicMock() - mock_module.entrypoint.return_value = "main.js" - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - ): - result = web_server.get_static_file_url("js/esphome/index.js") - assert result == "./static/js/esphome/main.js" - - -def test_load_file_path(tmp_path: Path) -> None: - """Test loading a file.""" - test_file = tmp_path / "test.txt" - test_file.write_bytes(b"test content") - - with test_file.open("rb") as f: - content = f.read() - assert content == b"test content" - - -def test_load_file_compressed_path(tmp_path: Path) -> None: - """Test loading a compressed file.""" - test_file = tmp_path / "test.txt.gz" - - with gzip.open(test_file, "wb") as gz: - gz.write(b"compressed content") - - with gzip.open(test_file, "rb") as gz: - content = gz.read() - assert content == b"compressed content" - - -def test_path_normalization_in_static_path() -> None: - """Test that paths are normalized correctly.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - # Test with separate components - result1 = web_server.get_static_path("js", "app.js") - result2 = web_server.get_static_path("js", "app.js") - - assert result1 == result2 - assert result1 == Path("/base/frontend") / "static" / "js" / "app.js" - - -def test_windows_path_handling() -> None: - """Test handling of Windows-style paths.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path(r"C:\Program Files\esphome\frontend") - - result = web_server.get_static_path("js", "app.js") - - # Path should handle this correctly on the platform - expected = ( - Path(r"C:\Program Files\esphome\frontend") / "static" / "js" / "app.js" - ) - assert result == expected - - -def test_path_with_special_characters() -> None: - """Test paths with special characters.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("js-modules", "app_v1.0.js") - - assert ( - result == Path("/base/frontend") / "static" / "js-modules" / "app_v1.0.js" - ) - - -def test_path_with_spaces() -> None: - """Test paths with spaces.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/my frontend") - - result = web_server.get_static_path("my js", "my app.js") - - assert result == Path("/base/my frontend") / "static" / "my js" / "my app.js" diff --git a/tests/dashboard/util/__init__.py b/tests/dashboard/util/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index a9876632bd..d4c13fd3fb 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -562,7 +562,7 @@ def test_determine_integration_tests( with patch.object( determine_jobs, "changed_files", - return_value=["esphome/dashboard/web_server.py"], + return_value=["esphome/analyze_memory/helpers.py"], ): run_all, test_files = determine_jobs.determine_integration_tests() assert run_all is False @@ -914,7 +914,6 @@ def test_should_run_core_ci_with_branch() -> None: # picks them up because esphome's pyproject sets # include-package-data = true. (["esphome/idf_component.yml"], True), - (["esphome/dashboard/templates/index.html"], True), (["esphome/components/api/api_pb2_service.json"], True), # Mixed: any triggering file is enough (["docs/README.md", "esphome/config.py"], True), diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 70c4b90082..fad249b0bb 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -121,22 +121,6 @@ def test_friendly_name_slugify(value, expected): assert helpers.friendly_name_slugify(value) == expected -def test_friendly_name_slugify_back_compat_shim(): - """``esphome.dashboard.util.text`` keeps re-exporting for back-compat. - - The function moved to ``esphome.helpers`` so the new - device-builder dashboard backend can import it without depending - on the legacy dashboard package, but downstream code that still - imports from the old path keeps working until the dashboard - module is removed. - """ - from esphome.dashboard.util.text import ( - friendly_name_slugify as legacy_friendly_name_slugify, - ) - - assert legacy_friendly_name_slugify is helpers.friendly_name_slugify - - @pytest.mark.parametrize( "host", ( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bb06b6c930..33888956b3 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -33,6 +33,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_dashboard, command_idedata, command_rename, command_run, @@ -3740,6 +3741,45 @@ def test_command_wizard(tmp_path: Path) -> None: mock_wizard.assert_called_once_with(config_file) +def test_command_dashboard_errors_with_device_builder_redirect() -> None: + """The removed dashboard command points users to ESPHome Device Builder.""" + args = MockArgs() + + with pytest.raises(EsphomeError, match="esphome-device-builder"): + command_dashboard(args) + + +@pytest.mark.parametrize( + "argv", + [ + ["esphome", "dashboard"], + ["esphome", "dashboard", "/config"], + # Legacy flags must be accepted so old invocations reach the redirect + # instead of failing on argparse "unrecognized arguments". + ["esphome", "dashboard", "--port", "6052", "/config"], + ["esphome", "dashboard", "--username", "u", "--password", "p", "--open-ui"], + [ + "esphome", + "dashboard", + "--address", + "0.0.0.0", + "--socket", + "/x", + "--ha-addon", + ], + ], +) +def test_run_esphome_dashboard_redirects_to_device_builder( + argv: list[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`esphome dashboard` still parses but fails with the redirect message.""" + result = run_esphome(argv) + + assert result == 1 + assert "esphome-device-builder" in caplog.text + + def test_command_config_hash( tmp_path: Path, capfd: CaptureFixture[str], From 1d5d5817340617489d2cb97fe32ccd511e80b788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:34:09 -0500 Subject: [PATCH 1760/2214] [esp8266] Drop stale esphome-docker-base reference (#17123) --- esphome/components/esp8266/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index db7120a9ef..4daf4549ef 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -131,7 +131,6 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) -# and platformio.ini/platformio-lint.ini in the esphome-docker-base repository # The default/recommended arduino framework version # - https://github.com/esp8266/Arduino/releases From 0d7130c49909d92c1567a2d52690d3842d0982f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:48:21 -0500 Subject: [PATCH 1761/2214] [docs] Remove leftover dashboard references after dashboard removal (#17125) --- AGENTS.md | 2 +- THREAT_MODEL.md | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index be2e912d48..21905ea356 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This document provides essential context for AI models interacting with this pro * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Configuration:** YAML. * **Key Libraries/Dependencies:** - * **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `tornado` (for the web server), `aioesphomeapi` (for the native API). + * **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `aioesphomeapi` (for the native API). * **C++:** `ArduinoJson` (for JSON serialization/deserialization), `AsyncMqttClient-esphome` (for MQTT), `ESPAsyncWebServer` (for the web server). * **Package Manager(s):** `pip` (for Python dependencies), `platformio` (for C++/PlatformIO dependencies). * **Communication Protocols:** Protobuf (for native API), MQTT, HTTP. diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index a4640467c9..a4355a5055 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -88,8 +88,6 @@ These *are* security bugs in this repo, and we want to hear about them privately holds the API key / OTA / web credentials). - Anything in the dashboard / device-builder — report that in its own repository (linked at the top). -- The legacy bundled dashboard in this repo (`esphome/dashboard/`) — it is - deprecated and being replaced by Device Builder; report dashboard issues there. - Deployments where the operator removed protections or exposed credentials. See the security best practices guide: https://esphome.io/guides/security_best_practices/ From 7d7cdb6c66b8692c8b2e2a1111a4df5b71bbfae5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:51:11 +1200 Subject: [PATCH 1762/2214] Mark configurable classes as final (21/21: zhlt01-zyaura) (#16972) --- esphome/components/zhlt01/zhlt01.h | 2 +- esphome/components/zigbee/automation.h | 2 +- esphome/components/zigbee/time/zigbee_time_zephyr.h | 2 +- esphome/components/zigbee/zigbee_attribute_esp32.h | 2 +- esphome/components/zigbee/zigbee_binary_sensor_zephyr.h | 2 +- esphome/components/zigbee/zigbee_esp32.h | 2 +- esphome/components/zigbee/zigbee_number_zephyr.h | 2 +- esphome/components/zigbee/zigbee_sensor_zephyr.h | 2 +- esphome/components/zigbee/zigbee_switch_zephyr.h | 2 +- esphome/components/zigbee/zigbee_zephyr.h | 2 +- esphome/components/zio_ultrasonic/zio_ultrasonic.h | 2 +- esphome/components/zwave_proxy/zwave_proxy.h | 2 +- esphome/components/zyaura/zyaura.h | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/esphome/components/zhlt01/zhlt01.h b/esphome/components/zhlt01/zhlt01.h index 61fc2cc16a..dba9ca8f3d 100644 --- a/esphome/components/zhlt01/zhlt01.h +++ b/esphome/components/zhlt01/zhlt01.h @@ -142,7 +142,7 @@ static const float AC1_TEMP_MIN = 16.0f; static const float AC1_TEMP_MAX = 32.0f; static const float AC1_TEMP_INC = 1.0f; -class ZHLT01Climate : public climate_ir::ClimateIR { +class ZHLT01Climate final : public climate_ir::ClimateIR { public: ZHLT01Climate() : climate_ir::ClimateIR( diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h index 55ee9746ea..1f953100d9 100644 --- a/esphome/components/zigbee/automation.h +++ b/esphome/components/zigbee/automation.h @@ -9,7 +9,7 @@ #endif namespace esphome::zigbee { -template class FactoryResetAction : public Action, public Parented { +template class FactoryResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->factory_reset(); } }; diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.h b/esphome/components/zigbee/time/zigbee_time_zephyr.h index 3c2adc4b5f..be2cff786e 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.h +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.h @@ -12,7 +12,7 @@ extern "C" { namespace esphome::zigbee { -class ZigbeeTime : public time::RealTimeClock, public ZigbeeEntity { +class ZigbeeTime final : public time::RealTimeClock, public ZigbeeEntity { public: void setup() override; void dump_config() override; diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 35aa60848f..e978fcf209 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -27,7 +27,7 @@ enum ZigbeeReportT { ZIGBEE_REPORT_FORCE, }; -class ZigbeeAttribute : public Component { +class ZigbeeAttribute final : public Component { public: ZigbeeAttribute(ZigbeeComponent *parent, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t attr_type, float scale, uint8_t max_size) diff --git a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h index aae79fa289..bc2718ff48 100644 --- a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h +++ b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h @@ -28,7 +28,7 @@ extern "C" { namespace esphome::zigbee { -class ZigbeeBinarySensor : public ZigbeeEntity, public Component { +class ZigbeeBinarySensor final : public ZigbeeEntity, public Component { public: explicit ZigbeeBinarySensor(binary_sensor::BinarySensor *binary_sensor); void set_cluster_attributes(BinaryAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 34b2b827b6..25f53a1d6e 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -35,7 +35,7 @@ uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = f class ZigbeeAttribute; -class ZigbeeComponent : public Component { +class ZigbeeComponent final : public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/zigbee/zigbee_number_zephyr.h b/esphome/components/zigbee/zigbee_number_zephyr.h index aabb0392be..886e6c3223 100644 --- a/esphome/components/zigbee/zigbee_number_zephyr.h +++ b/esphome/components/zigbee/zigbee_number_zephyr.h @@ -98,7 +98,7 @@ void zb_zcl_analog_output_init_client(); namespace esphome::zigbee { -class ZigbeeNumber : public ZigbeeEntity, public Component { +class ZigbeeNumber final : public ZigbeeEntity, public Component { public: ZigbeeNumber(number::Number *n) : number_(n) {} void set_cluster_attributes(AnalogAttrsOutput &cluster_attributes) { diff --git a/esphome/components/zigbee/zigbee_sensor_zephyr.h b/esphome/components/zigbee/zigbee_sensor_zephyr.h index 37406f21d0..cd03cf8a2b 100644 --- a/esphome/components/zigbee/zigbee_sensor_zephyr.h +++ b/esphome/components/zigbee/zigbee_sensor_zephyr.h @@ -69,7 +69,7 @@ void zb_zcl_analog_input_init_client(); namespace esphome::zigbee { -class ZigbeeSensor : public ZigbeeEntity, public Component { +class ZigbeeSensor final : public ZigbeeEntity, public Component { public: explicit ZigbeeSensor(sensor::Sensor *sensor); void set_cluster_attributes(AnalogAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_switch_zephyr.h b/esphome/components/zigbee/zigbee_switch_zephyr.h index b774c23b3c..d2f71ce665 100644 --- a/esphome/components/zigbee/zigbee_switch_zephyr.h +++ b/esphome/components/zigbee/zigbee_switch_zephyr.h @@ -63,7 +63,7 @@ void zb_zcl_binary_output_init_client(); namespace esphome::zigbee { -class ZigbeeSwitch : public ZigbeeEntity, public Component { +class ZigbeeSwitch final : public ZigbeeEntity, public Component { public: ZigbeeSwitch(switch_::Switch *s) : switch_(s) {} void set_cluster_attributes(BinaryAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index d462d2a403..3b4a465361 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -66,7 +66,7 @@ struct AnalogAttrsOutput : AnalogAttrs { float resolution; }; -class ZigbeeComponent : public Component { +class ZigbeeComponent final : public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.h b/esphome/components/zio_ultrasonic/zio_ultrasonic.h index d4d2ac974f..1dbce87307 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.h +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.h @@ -8,7 +8,7 @@ static const char *const TAG = "Zio Ultrasonic"; namespace esphome::zio_ultrasonic { -class ZioUltrasonicComponent : public i2c::I2CDevice, public PollingComponent, public sensor::Sensor { +class ZioUltrasonicComponent final : public i2c::I2CDevice, public PollingComponent, public sensor::Sensor { public: void dump_config() override; diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index dc5dc46abc..ec52b15cd9 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -49,7 +49,7 @@ enum ZWaveProxyFeature : uint32_t { FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0, }; -class ZWaveProxy : public uart::UARTDevice, public Component { +class ZWaveProxy final : public uart::UARTDevice, public Component { public: ZWaveProxy(); diff --git a/esphome/components/zyaura/zyaura.h b/esphome/components/zyaura/zyaura.h index 7c7954dec2..5a451ea463 100644 --- a/esphome/components/zyaura/zyaura.h +++ b/esphome/components/zyaura/zyaura.h @@ -57,7 +57,7 @@ class ZaSensorStore { }; /// Component for reading temperature/co2/humidity measurements from ZyAura sensors. -class ZyAuraSensor : public PollingComponent { +class ZyAuraSensor final : public PollingComponent { public: void set_pin_clock(InternalGPIOPin *pin) { pin_clock_ = pin; } void set_pin_data(InternalGPIOPin *pin) { pin_data_ = pin; } From 73dbc8214bbad4cdb068b5f8a25744d1a085989d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:51:31 +1200 Subject: [PATCH 1763/2214] Mark configurable classes as final (4/21: chsc6x-dfplayer) (#16955) --- esphome/components/chsc6x/chsc6x_touchscreen.h | 2 +- esphome/components/climate/automation.h | 6 +++--- .../components/climate_ir_lg/climate_ir_lg.h | 2 +- esphome/components/cm1106/cm1106.h | 4 ++-- .../color_temperature/ct_light_output.h | 2 +- esphome/components/combination/combination.h | 18 +++++++++--------- esphome/components/coolix/coolix.h | 2 +- .../copy/binary_sensor/copy_binary_sensor.h | 2 +- esphome/components/copy/button/copy_button.h | 2 +- esphome/components/copy/cover/copy_cover.h | 2 +- esphome/components/copy/fan/copy_fan.h | 2 +- esphome/components/copy/lock/copy_lock.h | 2 +- esphome/components/copy/number/copy_number.h | 2 +- esphome/components/copy/select/copy_select.h | 2 +- esphome/components/copy/sensor/copy_sensor.h | 2 +- esphome/components/copy/switch/copy_switch.h | 2 +- esphome/components/copy/text/copy_text.h | 2 +- .../copy/text_sensor/copy_text_sensor.h | 2 +- esphome/components/cover/automation.h | 18 +++++++++--------- esphome/components/cs5460a/cs5460a.h | 8 ++++---- esphome/components/cse7761/cse7761.h | 2 +- esphome/components/cse7766/cse7766.h | 2 +- .../cst226/binary_sensor/cs226_button.h | 8 ++++---- .../cst226/touchscreen/cst226_touchscreen.h | 2 +- .../cst816/touchscreen/cst816_touchscreen.h | 2 +- esphome/components/ct_clamp/ct_clamp_sensor.h | 2 +- .../current_based/current_based_cover.h | 2 +- esphome/components/cwww/cwww_light_output.h | 2 +- esphome/components/dac7678/dac7678_output.h | 4 ++-- esphome/components/daikin/daikin.h | 2 +- esphome/components/daikin_arc/daikin_arc.h | 2 +- esphome/components/daikin_brc/daikin_brc.h | 2 +- esphome/components/dallas_temp/dallas_temp.h | 2 +- esphome/components/daly_bms/daly_bms.h | 2 +- esphome/components/datetime/date_entity.h | 2 +- esphome/components/datetime/datetime_base.h | 2 +- esphome/components/datetime/datetime_entity.h | 4 ++-- esphome/components/datetime/time_entity.h | 4 ++-- esphome/components/debug/debug_component.h | 2 +- .../deep_sleep/deep_sleep_component.h | 9 +++++---- esphome/components/delonghi/delonghi.h | 2 +- .../components/demo/demo_alarm_control_panel.h | 2 +- esphome/components/demo/demo_binary_sensor.h | 2 +- esphome/components/demo/demo_button.h | 2 +- esphome/components/demo/demo_climate.h | 2 +- esphome/components/demo/demo_cover.h | 2 +- esphome/components/demo/demo_date.h | 2 +- esphome/components/demo/demo_datetime.h | 2 +- esphome/components/demo/demo_fan.h | 2 +- esphome/components/demo/demo_light.h | 2 +- esphome/components/demo/demo_lock.h | 2 +- esphome/components/demo/demo_number.h | 2 +- esphome/components/demo/demo_select.h | 2 +- esphome/components/demo/demo_sensor.h | 2 +- esphome/components/demo/demo_switch.h | 2 +- esphome/components/demo/demo_text.h | 2 +- esphome/components/demo/demo_text_sensor.h | 2 +- esphome/components/demo/demo_time.h | 2 +- esphome/components/demo/demo_valve.h | 2 +- esphome/components/dew_point/dew_point.h | 2 +- esphome/components/dfplayer/dfplayer.h | 16 ++++++++-------- 61 files changed, 100 insertions(+), 99 deletions(-) diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.h b/esphome/components/chsc6x/chsc6x_touchscreen.h index 32077b3d33..84e539e5f2 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.h +++ b/esphome/components/chsc6x/chsc6x_touchscreen.h @@ -17,7 +17,7 @@ static const uint8_t CHSC6X_REG_STATUS_Y_COR = 0x04; static const uint8_t CHSC6X_REG_STATUS_LEN = 0x05; static const uint8_t CHSC6X_CHIP_ID = 0x2e; -class CHSC6XTouchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CHSC6XTouchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index 6ac9bd8bae..a8d6d778ae 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -17,7 +17,7 @@ namespace esphome::climate { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(ClimateCall &, const std::remove_cvref_t &...); ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} @@ -33,14 +33,14 @@ template class ControlAction : public Action { ApplyFn apply_; }; -class ControlTrigger : public Trigger { +class ControlTrigger final : public Trigger { public: ControlTrigger(Climate *climate) { climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); }); } }; -class StateTrigger : public Trigger { +class StateTrigger final : public Trigger { public: StateTrigger(Climate *climate) { climate->add_on_state_callback([this](Climate &x) { this->trigger(x); }); diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.h b/esphome/components/climate_ir_lg/climate_ir_lg.h index a09da65ac6..341f0a4ef1 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.h +++ b/esphome/components/climate_ir_lg/climate_ir_lg.h @@ -10,7 +10,7 @@ namespace esphome::climate_ir_lg { const uint8_t TEMP_MIN = 18; // Celsius const uint8_t TEMP_MAX = 30; // Celsius -class LgIrClimate : public climate_ir::ClimateIR { +class LgIrClimate final : public climate_ir::ClimateIR { public: LgIrClimate() : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index 047e91d632..844bfdfa88 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -7,7 +7,7 @@ namespace esphome::cm1106 { -class CM1106Component : public PollingComponent, public uart::UARTDevice { +class CM1106Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -23,7 +23,7 @@ class CM1106Component : public PollingComponent, public uart::UARTDevice { bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); }; -template class CM1106CalibrateZeroAction : public Action { +template class CM1106CalibrateZeroAction final : public Action { public: CM1106CalibrateZeroAction(CM1106Component *cm1106) : cm1106_(cm1106) {} diff --git a/esphome/components/color_temperature/ct_light_output.h b/esphome/components/color_temperature/ct_light_output.h index a4da1011b0..51ca21465e 100644 --- a/esphome/components/color_temperature/ct_light_output.h +++ b/esphome/components/color_temperature/ct_light_output.h @@ -6,7 +6,7 @@ namespace esphome::color_temperature { -class CTLightOutput : public light::LightOutput { +class CTLightOutput final : public light::LightOutput { public: void set_color_temperature(output::FloatOutput *color_temperature) { color_temperature_ = color_temperature; } void set_brightness(output::FloatOutput *brightness) { brightness_ = brightness; } diff --git a/esphome/components/combination/combination.h b/esphome/components/combination/combination.h index 34e9e4e2c6..00745663e0 100644 --- a/esphome/components/combination/combination.h +++ b/esphome/components/combination/combination.h @@ -58,7 +58,7 @@ class CombinationOneParameterComponent : public CombinationComponent { FixedVector sensor_sources_; }; -class KalmanCombinationComponent : public CombinationOneParameterComponent { +class KalmanCombinationComponent final : public CombinationOneParameterComponent { public: void dump_config() override; void setup() override; @@ -85,7 +85,7 @@ class KalmanCombinationComponent : public CombinationOneParameterComponent { float variance_{INFINITY}; }; -class LinearCombinationComponent : public CombinationOneParameterComponent { +class LinearCombinationComponent final : public CombinationOneParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("linear")); } void setup() override; @@ -93,49 +93,49 @@ class LinearCombinationComponent : public CombinationOneParameterComponent { void handle_new_value(float value); }; -class MaximumCombinationComponent : public CombinationNoParameterComponent { +class MaximumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("max")); } void handle_new_value(float value) override; }; -class MeanCombinationComponent : public CombinationNoParameterComponent { +class MeanCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("mean")); } void handle_new_value(float value) override; }; -class MedianCombinationComponent : public CombinationNoParameterComponent { +class MedianCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("median")); } void handle_new_value(float value) override; }; -class MinimumCombinationComponent : public CombinationNoParameterComponent { +class MinimumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("min")); } void handle_new_value(float value) override; }; -class MostRecentCombinationComponent : public CombinationNoParameterComponent { +class MostRecentCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("most_recently_updated")); } void handle_new_value(float value) override; }; -class RangeCombinationComponent : public CombinationNoParameterComponent { +class RangeCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("range")); } void handle_new_value(float value) override; }; -class SumCombinationComponent : public CombinationNoParameterComponent { +class SumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("sum")); } diff --git a/esphome/components/coolix/coolix.h b/esphome/components/coolix/coolix.h index 2d8862e2b6..6a59a58a92 100644 --- a/esphome/components/coolix/coolix.h +++ b/esphome/components/coolix/coolix.h @@ -10,7 +10,7 @@ namespace esphome::coolix { const uint8_t COOLIX_TEMP_MIN = 17; // Celsius const uint8_t COOLIX_TEMP_MAX = 30; // Celsius -class CoolixClimate : public climate_ir::ClimateIR { +class CoolixClimate final : public climate_ir::ClimateIR { public: CoolixClimate() : climate_ir::ClimateIR(COOLIX_TEMP_MIN, COOLIX_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/copy/binary_sensor/copy_binary_sensor.h b/esphome/components/copy/binary_sensor/copy_binary_sensor.h index a6ce705a2a..b30ca9cb21 100644 --- a/esphome/components/copy/binary_sensor/copy_binary_sensor.h +++ b/esphome/components/copy/binary_sensor/copy_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyBinarySensor : public binary_sensor::BinarySensor, public Component { +class CopyBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_source(binary_sensor::BinarySensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/button/copy_button.h b/esphome/components/copy/button/copy_button.h index afd783375d..bdefcc512a 100644 --- a/esphome/components/copy/button/copy_button.h +++ b/esphome/components/copy/button/copy_button.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyButton : public button::Button, public Component { +class CopyButton final : public button::Button, public Component { public: void set_source(button::Button *source) { source_ = source; } void dump_config() override; diff --git a/esphome/components/copy/cover/copy_cover.h b/esphome/components/copy/cover/copy_cover.h index 0b493e4c3b..008cbdf28e 100644 --- a/esphome/components/copy/cover/copy_cover.h +++ b/esphome/components/copy/cover/copy_cover.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyCover : public cover::Cover, public Component { +class CopyCover final : public cover::Cover, public Component { public: void set_source(cover::Cover *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/fan/copy_fan.h b/esphome/components/copy/fan/copy_fan.h index 9090c91095..4f882ba43d 100644 --- a/esphome/components/copy/fan/copy_fan.h +++ b/esphome/components/copy/fan/copy_fan.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyFan : public fan::Fan, public Component { +class CopyFan final : public fan::Fan, public Component { public: void set_source(fan::Fan *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/lock/copy_lock.h b/esphome/components/copy/lock/copy_lock.h index c6c46467a9..0177db1708 100644 --- a/esphome/components/copy/lock/copy_lock.h +++ b/esphome/components/copy/lock/copy_lock.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyLock : public lock::Lock, public Component { +class CopyLock final : public lock::Lock, public Component { public: void set_source(lock::Lock *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/number/copy_number.h b/esphome/components/copy/number/copy_number.h index b4d8bb83e6..82af6cc8ea 100644 --- a/esphome/components/copy/number/copy_number.h +++ b/esphome/components/copy/number/copy_number.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyNumber : public number::Number, public Component { +class CopyNumber final : public number::Number, public Component { public: void set_source(number::Number *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/select/copy_select.h b/esphome/components/copy/select/copy_select.h index 1a17c7a55a..2c541d99bc 100644 --- a/esphome/components/copy/select/copy_select.h +++ b/esphome/components/copy/select/copy_select.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySelect : public select::Select, public Component { +class CopySelect final : public select::Select, public Component { public: void set_source(select::Select *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/sensor/copy_sensor.h b/esphome/components/copy/sensor/copy_sensor.h index d6e5026ce1..136c36de95 100644 --- a/esphome/components/copy/sensor/copy_sensor.h +++ b/esphome/components/copy/sensor/copy_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySensor : public sensor::Sensor, public Component { +class CopySensor final : public sensor::Sensor, public Component { public: void set_source(sensor::Sensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/switch/copy_switch.h b/esphome/components/copy/switch/copy_switch.h index 9ce6b48ed1..bef254093c 100644 --- a/esphome/components/copy/switch/copy_switch.h +++ b/esphome/components/copy/switch/copy_switch.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySwitch : public switch_::Switch, public Component { +class CopySwitch final : public switch_::Switch, public Component { public: void set_source(switch_::Switch *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/text/copy_text.h b/esphome/components/copy/text/copy_text.h index ad28936522..4dd3b5fe5e 100644 --- a/esphome/components/copy/text/copy_text.h +++ b/esphome/components/copy/text/copy_text.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyText : public text::Text, public Component { +class CopyText final : public text::Text, public Component { public: void set_source(text::Text *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/text_sensor/copy_text_sensor.h b/esphome/components/copy/text_sensor/copy_text_sensor.h index dc4ef7a29d..e27e3bc1d7 100644 --- a/esphome/components/copy/text_sensor/copy_text_sensor.h +++ b/esphome/components/copy/text_sensor/copy_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyTextSensor : public text_sensor::TextSensor, public Component { +class CopyTextSensor final : public text_sensor::TextSensor, public Component { public: void set_source(text_sensor::TextSensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index ee7a4f5f76..0a5a447ab9 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -6,7 +6,7 @@ namespace esphome::cover { -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Cover *cover) : cover_(cover) {} @@ -16,7 +16,7 @@ template class OpenAction : public Action { Cover *cover_; }; -template class CloseAction : public Action { +template class CloseAction final : public Action { public: explicit CloseAction(Cover *cover) : cover_(cover) {} @@ -26,7 +26,7 @@ template class CloseAction : public Action { Cover *cover_; }; -template class StopAction : public Action { +template class StopAction final : public Action { public: explicit StopAction(Cover *cover) : cover_(cover) {} @@ -36,7 +36,7 @@ template class StopAction : public Action { Cover *cover_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Cover *cover) : cover_(cover) {} @@ -59,7 +59,7 @@ template class ToggleAction : public Action { // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(CoverCall &, const std::remove_cvref_t &...); ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} @@ -75,7 +75,7 @@ template class ControlAction : public Action { ApplyFn apply_; }; -template class CoverPublishAction : public Action { +template class CoverPublishAction final : public Action { public: using ApplyFn = void (*)(Cover *, const std::remove_cvref_t &...); CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} @@ -90,7 +90,7 @@ template class CoverPublishAction : public Action { ApplyFn apply_; }; -template class CoverPositionCondition : public Condition { +template class CoverPositionCondition final : public Condition { public: CoverPositionCondition(Cover *cover) : cover_(cover) {} @@ -103,7 +103,7 @@ template class CoverPositionCondition : public Condit template using CoverIsOpenCondition = CoverPositionCondition; template using CoverIsClosedCondition = CoverPositionCondition; -template class CoverPositionTrigger : public Trigger<> { +template class CoverPositionTrigger final : public Trigger<> { public: CoverPositionTrigger(Cover *a_cover) : cover_(a_cover) { a_cover->add_on_state_callback([this]() { @@ -123,7 +123,7 @@ template class CoverPositionTrigger : public Trigger<> { using CoverOpenedTrigger = CoverPositionTrigger; using CoverClosedTrigger = CoverPositionTrigger; -template class CoverTrigger : public Trigger<> { +template class CoverTrigger final : public Trigger<> { public: CoverTrigger(Cover *a_cover) : cover_(a_cover) { a_cover->add_on_state_callback([this]() { diff --git a/esphome/components/cs5460a/cs5460a.h b/esphome/components/cs5460a/cs5460a.h index c6b02f53ee..87ea858c70 100644 --- a/esphome/components/cs5460a/cs5460a.h +++ b/esphome/components/cs5460a/cs5460a.h @@ -52,9 +52,9 @@ enum CS5460APGAGain { CS5460A_PGA_GAIN_50X = 0b1, }; -class CS5460AComponent : public Component, - public spi::SPIDevice { +class CS5460AComponent final : public Component, + public spi::SPIDevice { public: void set_samples(uint32_t samples) { samples_ = samples; } void set_phase_offset(int8_t phase_offset) { phase_offset_ = phase_offset; } @@ -108,7 +108,7 @@ class CS5460AComponent : public Component, uint32_t prev_raw_energy_{0}; }; -template class CS5460ARestartAction : public Action { +template class CS5460ARestartAction final : public Action { public: CS5460ARestartAction(CS5460AComponent *cs5460a) : cs5460a_(cs5460a) {} diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index 5f683f424b..e08ebf09cc 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -16,7 +16,7 @@ struct CSE7761DataStruct { }; /// This class implements support for the CSE7761 UART power sensor. -class CSE7761Component : public PollingComponent, public uart::UARTDevice { +class CSE7761Component final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; } diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index 77b80dd824..8a57816a59 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -9,7 +9,7 @@ namespace esphome::cse7766 { static constexpr size_t CSE7766_RAW_DATA_SIZE = 24; -class CSE7766Component : public Component, public uart::UARTDevice { +class CSE7766Component final : public Component, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/cst226/binary_sensor/cs226_button.h b/esphome/components/cst226/binary_sensor/cs226_button.h index e7e334b9bb..ec07341e21 100644 --- a/esphome/components/cst226/binary_sensor/cs226_button.h +++ b/esphome/components/cst226/binary_sensor/cs226_button.h @@ -6,10 +6,10 @@ namespace esphome::cst226 { -class CST226Button : public binary_sensor::BinarySensor, - public Component, - public CST226ButtonListener, - public Parented { +class CST226Button final : public binary_sensor::BinarySensor, + public Component, + public CST226ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.h b/esphome/components/cst226/touchscreen/cst226_touchscreen.h index 362eee5fc2..c68c50fb44 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.h +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.h @@ -15,7 +15,7 @@ class CST226ButtonListener { virtual void update_button(bool state) = 0; }; -class CST226Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CST226Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 19c169c3ec..84b561c734 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -37,7 +37,7 @@ class CST816ButtonListener { virtual void update_button(bool state) = 0; }; -class CST816Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CST816Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.h b/esphome/components/ct_clamp/ct_clamp_sensor.h index 2055edbd3e..ae77c04390 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.h +++ b/esphome/components/ct_clamp/ct_clamp_sensor.h @@ -7,7 +7,7 @@ namespace esphome::ct_clamp { -class CTClampSensor : public sensor::Sensor, public PollingComponent { +class CTClampSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void loop() override; diff --git a/esphome/components/current_based/current_based_cover.h b/esphome/components/current_based/current_based_cover.h index 531f8d5a4f..41dd9962b7 100644 --- a/esphome/components/current_based/current_based_cover.h +++ b/esphome/components/current_based/current_based_cover.h @@ -8,7 +8,7 @@ namespace esphome::current_based { -class CurrentBasedCover : public cover::Cover, public Component { +class CurrentBasedCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/cwww/cwww_light_output.h b/esphome/components/cwww/cwww_light_output.h index 6eed8de7cc..aea844008f 100644 --- a/esphome/components/cwww/cwww_light_output.h +++ b/esphome/components/cwww/cwww_light_output.h @@ -6,7 +6,7 @@ namespace esphome::cwww { -class CWWWLightOutput : public light::LightOutput { +class CWWWLightOutput final : public light::LightOutput { public: void set_cold_white(output::FloatOutput *cold_white) { cold_white_ = cold_white; } void set_warm_white(output::FloatOutput *warm_white) { warm_white_ = warm_white; } diff --git a/esphome/components/dac7678/dac7678_output.h b/esphome/components/dac7678/dac7678_output.h index a017325939..00021e947f 100644 --- a/esphome/components/dac7678/dac7678_output.h +++ b/esphome/components/dac7678/dac7678_output.h @@ -9,7 +9,7 @@ namespace esphome::dac7678 { class DAC7678Output; -class DAC7678Channel : public output::FloatOutput, public Parented { +class DAC7678Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint8_t channel) { channel_ = channel; } @@ -24,7 +24,7 @@ class DAC7678Channel : public output::FloatOutput, public Parented day_; }; -template class DateSetAction : public Action, public Parented { +template class DateSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, date) diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index 6c0a33c842..f99debb692 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -31,7 +31,7 @@ class DateTimeBase : public EntityBase { #endif }; -class DateTimeStateTrigger : public Trigger { +class DateTimeStateTrigger final : public Trigger { public: explicit DateTimeStateTrigger(DateTimeBase *parent) : parent_(parent) { parent->add_on_state_callback([this]() { this->trigger(this->parent_->state_as_esptime()); }); diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index b1b8a77846..159e4ccc6f 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -121,7 +121,7 @@ class DateTimeCall { optional second_; }; -template class DateTimeSetAction : public Action, public Parented { +template class DateTimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, datetime) @@ -136,7 +136,7 @@ template class DateTimeSetAction : public Action, public }; #ifdef USE_TIME -class OnDateTimeTrigger : public Trigger<>, public Component, public Parented { +class OnDateTimeTrigger final : public Trigger<>, public Component, public Parented { public: void loop() override; diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 3f224684bb..643f4bd176 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -98,7 +98,7 @@ class TimeCall { optional second_; }; -template class TimeSetAction : public Action, public Parented { +template class TimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, time) @@ -113,7 +113,7 @@ template class TimeSetAction : public Action, public Pare }; #ifdef USE_TIME -class OnTimeTrigger : public Trigger<>, public Component, public Parented { +class OnTimeTrigger final : public Trigger<>, public Component, public Parented { public: void loop() override; diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index 871b7cfd25..20798cf600 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -21,7 +21,7 @@ static constexpr size_t WAKEUP_CAUSE_BUFFER_SIZE = 128; // buf_append_printf is now provided by esphome/core/helpers.h -class DebugComponent : public PollingComponent { +class DebugComponent final : public PollingComponent { public: void loop() override; void update() override; diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 2df53f1540..8edda040d3 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -70,7 +70,7 @@ template class PreventDeepSleepAction; * and set_run_duration, then set how long the deep sleep should last using set_sleep_duration and optionally * on the ESP32 set_wakeup_pin. */ -class DeepSleepComponent : public Component { +class DeepSleepComponent final : public Component { public: /// Set the duration in ms the component should sleep once it's in deep sleep mode. void set_sleep_duration(uint32_t time_ms); @@ -161,7 +161,7 @@ class DeepSleepComponent : public Component { extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class EnterDeepSleepAction : public Action { +template class EnterDeepSleepAction final : public Action { public: EnterDeepSleepAction(DeepSleepComponent *deep_sleep) : deep_sleep_(deep_sleep) {} TEMPLATABLE_VALUE(uint32_t, sleep_duration); @@ -233,12 +233,13 @@ template class EnterDeepSleepAction : public Action { #endif }; -template class PreventDeepSleepAction : public Action, public Parented { +template +class PreventDeepSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->prevent_deep_sleep(); } }; -template class AllowDeepSleepAction : public Action, public Parented { +template class AllowDeepSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } }; diff --git a/esphome/components/delonghi/delonghi.h b/esphome/components/delonghi/delonghi.h index c2fbc36b4f..aee7ceedda 100644 --- a/esphome/components/delonghi/delonghi.h +++ b/esphome/components/delonghi/delonghi.h @@ -39,7 +39,7 @@ const uint32_t DELONGHI_ZERO_SPACE = 670; // State Frame size const uint8_t DELONGHI_STATE_FRAME_SIZE = 8; -class DelonghiClimate : public climate_ir::ClimateIR { +class DelonghiClimate final : public climate_ir::ClimateIR { public: DelonghiClimate() : climate_ir::ClimateIR(DELONGHI_TEMP_MIN, DELONGHI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index 7aaf3219cf..e85d2a17ba 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -13,7 +13,7 @@ enum class DemoAlarmControlPanelType { TYPE_3, }; -class DemoAlarmControlPanel : public AlarmControlPanel, public Component { +class DemoAlarmControlPanel final : public AlarmControlPanel, public Component { public: void setup() override {} diff --git a/esphome/components/demo/demo_binary_sensor.h b/esphome/components/demo/demo_binary_sensor.h index 4bc3737d5a..6a98a6781b 100644 --- a/esphome/components/demo/demo_binary_sensor.h +++ b/esphome/components/demo/demo_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoBinarySensor : public binary_sensor::BinarySensor, public PollingComponent { +class DemoBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: void setup() override { this->publish_initial_state(false); } void update() override { diff --git a/esphome/components/demo/demo_button.h b/esphome/components/demo/demo_button.h index a0ed92d3d8..907136cfc6 100644 --- a/esphome/components/demo/demo_button.h +++ b/esphome/components/demo/demo_button.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoButton : public button::Button { +class DemoButton final : public button::Button { protected: void press_action() override {} }; diff --git a/esphome/components/demo/demo_climate.h b/esphome/components/demo/demo_climate.h index d0cd2d553d..20affb909f 100644 --- a/esphome/components/demo/demo_climate.h +++ b/esphome/components/demo/demo_climate.h @@ -11,7 +11,7 @@ enum class DemoClimateType { TYPE_3, }; -class DemoClimate : public climate::Climate, public Component { +class DemoClimate final : public climate::Climate, public Component { public: void set_type(DemoClimateType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_cover.h b/esphome/components/demo/demo_cover.h index c1597a7565..aa12c885f4 100644 --- a/esphome/components/demo/demo_cover.h +++ b/esphome/components/demo/demo_cover.h @@ -12,7 +12,7 @@ enum class DemoCoverType { TYPE_4, }; -class DemoCover : public cover::Cover, public Component { +class DemoCover final : public cover::Cover, public Component { public: void set_type(DemoCoverType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_date.h b/esphome/components/demo/demo_date.h index 5a868342cd..f724c82435 100644 --- a/esphome/components/demo/demo_date.h +++ b/esphome/components/demo/demo_date.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoDate : public datetime::DateEntity, public Component { +class DemoDate final : public datetime::DateEntity, public Component { public: void setup() override { this->year_ = 2038; diff --git a/esphome/components/demo/demo_datetime.h b/esphome/components/demo/demo_datetime.h index 84869d1a9f..363592c554 100644 --- a/esphome/components/demo/demo_datetime.h +++ b/esphome/components/demo/demo_datetime.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoDateTime : public datetime::DateTimeEntity, public Component { +class DemoDateTime final : public datetime::DateTimeEntity, public Component { public: void setup() override { this->year_ = 2038; diff --git a/esphome/components/demo/demo_fan.h b/esphome/components/demo/demo_fan.h index 2e2fbce7d6..be44c06ca2 100644 --- a/esphome/components/demo/demo_fan.h +++ b/esphome/components/demo/demo_fan.h @@ -12,7 +12,7 @@ enum class DemoFanType { TYPE_4, }; -class DemoFan : public fan::Fan, public Component { +class DemoFan final : public fan::Fan, public Component { public: void set_type(DemoFanType type) { type_ = type; } fan::FanTraits get_traits() override { diff --git a/esphome/components/demo/demo_light.h b/esphome/components/demo/demo_light.h index 071adb0831..4a48a1796e 100644 --- a/esphome/components/demo/demo_light.h +++ b/esphome/components/demo/demo_light.h @@ -22,7 +22,7 @@ enum class DemoLightType { TYPE_7, }; -class DemoLight : public light::LightOutput, public Component { +class DemoLight final : public light::LightOutput, public Component { public: void set_type(DemoLightType type) { type_ = type; } light::LightTraits get_traits() override { diff --git a/esphome/components/demo/demo_lock.h b/esphome/components/demo/demo_lock.h index 85c1c238ef..473fe1a68e 100644 --- a/esphome/components/demo/demo_lock.h +++ b/esphome/components/demo/demo_lock.h @@ -4,7 +4,7 @@ namespace esphome::demo { -class DemoLock : public lock::Lock { +class DemoLock final : public lock::Lock { protected: void control(const lock::LockCall &call) override { auto state = call.get_state(); diff --git a/esphome/components/demo/demo_number.h b/esphome/components/demo/demo_number.h index 0059cdc2ee..f66aef1aff 100644 --- a/esphome/components/demo/demo_number.h +++ b/esphome/components/demo/demo_number.h @@ -11,7 +11,7 @@ enum class DemoNumberType { TYPE_3, }; -class DemoNumber : public number::Number, public Component { +class DemoNumber final : public number::Number, public Component { public: void set_type(DemoNumberType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_select.h b/esphome/components/demo/demo_select.h index 2ecb37db99..de57f2f024 100644 --- a/esphome/components/demo/demo_select.h +++ b/esphome/components/demo/demo_select.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoSelect : public select::Select, public Component { +class DemoSelect final : public select::Select, public Component { protected: void control(size_t index) override { this->publish_state(index); } }; diff --git a/esphome/components/demo/demo_sensor.h b/esphome/components/demo/demo_sensor.h index 867115f21b..6153c810e1 100644 --- a/esphome/components/demo/demo_sensor.h +++ b/esphome/components/demo/demo_sensor.h @@ -6,7 +6,7 @@ namespace esphome::demo { -class DemoSensor : public sensor::Sensor, public PollingComponent { +class DemoSensor final : public sensor::Sensor, public PollingComponent { public: void update() override { float val = random_float(); diff --git a/esphome/components/demo/demo_switch.h b/esphome/components/demo/demo_switch.h index b2d6e52c67..6846b8b663 100644 --- a/esphome/components/demo/demo_switch.h +++ b/esphome/components/demo/demo_switch.h @@ -6,7 +6,7 @@ namespace esphome::demo { -class DemoSwitch : public switch_::Switch, public Component { +class DemoSwitch final : public switch_::Switch, public Component { public: void setup() override { bool initial = random_float() < 0.5; diff --git a/esphome/components/demo/demo_text.h b/esphome/components/demo/demo_text.h index 56376c8c42..66dd5bc3eb 100644 --- a/esphome/components/demo/demo_text.h +++ b/esphome/components/demo/demo_text.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoText : public text::Text, public Component { +class DemoText final : public text::Text, public Component { public: void setup() override { this->publish_state("I am a text entity"); } diff --git a/esphome/components/demo/demo_text_sensor.h b/esphome/components/demo/demo_text_sensor.h index 03852a1e7f..fa728903d9 100644 --- a/esphome/components/demo/demo_text_sensor.h +++ b/esphome/components/demo/demo_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::demo { -class DemoTextSensor : public text_sensor::TextSensor, public PollingComponent { +class DemoTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: void update() override { float val = random_float(); diff --git a/esphome/components/demo/demo_time.h b/esphome/components/demo/demo_time.h index f94678fae4..90384b3216 100644 --- a/esphome/components/demo/demo_time.h +++ b/esphome/components/demo/demo_time.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoTime : public datetime::TimeEntity, public Component { +class DemoTime final : public datetime::TimeEntity, public Component { public: void setup() override { this->hour_ = 3; diff --git a/esphome/components/demo/demo_valve.h b/esphome/components/demo/demo_valve.h index 3f1342959a..22183b75e8 100644 --- a/esphome/components/demo/demo_valve.h +++ b/esphome/components/demo/demo_valve.h @@ -9,7 +9,7 @@ enum class DemoValveType { TYPE_2, }; -class DemoValve : public valve::Valve { +class DemoValve final : public valve::Valve { public: valve::ValveTraits get_traits() override { valve::ValveTraits traits; diff --git a/esphome/components/dew_point/dew_point.h b/esphome/components/dew_point/dew_point.h index 833c50fba2..0e97b22a04 100644 --- a/esphome/components/dew_point/dew_point.h +++ b/esphome/components/dew_point/dew_point.h @@ -5,7 +5,7 @@ namespace esphome::dew_point { -class DewPointComponent : public Component, public sensor::Sensor { +class DewPointComponent final : public Component, public sensor::Sensor { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 5936a06b60..1db6b394c5 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -24,7 +24,7 @@ enum Device { // See the datasheet here: // https://github.com/DFRobot/DFRobotDFPlayerMini/blob/master/doc/FN-M16P%2BEmbedded%2BMP3%2BAudio%2BModule%2BDatasheet.pdf -class DFPlayer : public uart::UARTDevice, public Component { +class DFPlayer final : public uart::UARTDevice, public Component { public: void loop() override; @@ -82,7 +82,7 @@ class DFPlayer : public uart::UARTDevice, public Component { DFPLAYER_SIMPLE_ACTION(NextAction, next) DFPLAYER_SIMPLE_ACTION(PreviousAction, previous) -template class PlayMp3Action : public Action, public Parented { +template class PlayMp3Action final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, file) @@ -92,7 +92,7 @@ template class PlayMp3Action : public Action, public Pare } }; -template class PlayFileAction : public Action, public Parented { +template class PlayFileAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, file) TEMPLATABLE_VALUE(bool, loop) @@ -108,7 +108,7 @@ template class PlayFileAction : public Action, public Par } }; -template class PlayFolderAction : public Action, public Parented { +template class PlayFolderAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, folder) TEMPLATABLE_VALUE(uint16_t, file) @@ -126,7 +126,7 @@ template class PlayFolderAction : public Action, public P } }; -template class SetDeviceAction : public Action, public Parented { +template class SetDeviceAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(Device, device) @@ -136,7 +136,7 @@ template class SetDeviceAction : public Action, public Pa } }; -template class SetVolumeAction : public Action, public Parented { +template class SetVolumeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, volume) @@ -146,7 +146,7 @@ template class SetVolumeAction : public Action, public Pa } }; -template class SetEqAction : public Action, public Parented { +template class SetEqAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(EqPreset, eq) @@ -165,7 +165,7 @@ DFPLAYER_SIMPLE_ACTION(RandomAction, random) DFPLAYER_SIMPLE_ACTION(VolumeUpAction, volume_up) DFPLAYER_SIMPLE_ACTION(VolumeDownAction, volume_down) -template class DFPlayerIsPlayingCondition : public Condition, public Parented { +template class DFPlayerIsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } }; From 77a91853beb6b448174a70cb210b20a09741c690 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 21 Jun 2026 17:52:05 -0400 Subject: [PATCH 1764/2214] [i2s_audio] Narrow wider streams to the speaker's configured bit depth (#16821) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/i2s_audio/speaker/__init__.py | 15 ++- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 2 + .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- .../i2s_audio/speaker/i2s_audio_speaker.h | 9 +- .../speaker/i2s_audio_speaker_standard.cpp | 116 ++++++++++++------ 5 files changed, 99 insertions(+), 45 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 5ba2f4b1a5..6d3c39c68e 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -104,23 +104,26 @@ def _set_stream_limits(config): # stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit. min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8 + # The configured bits per sample sets the I2S slot width, but the speaker narrows wider streams down to it + # in place before clocking them out (see start_i2s_driver). Advertise up to 32-bit so those wider streams + # are accepted rather than forcing an upstream conversion. + max_bits_per_sample = 32 + if config[CONF_I2S_MODE] == CONF_PRIMARY: - # Primary mode can reconfigure the bus to the incoming sample rate and channel count, but the - # configured bits per sample is a hard ceiling: the speaker rejects any stream that exceeds the - # slot bit width it was set up with (see start_i2s_driver), so advertise that as the maximum. + # Primary mode can reconfigure the bus to the incoming sample rate and channel count. audio.set_stream_limits( min_bits_per_sample=min_bits_per_sample, - max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=max_bits_per_sample, min_channels=1, max_channels=2, min_sample_rate=16000, max_sample_rate=48000, )(config) else: - # Secondary mode has unmodifiable max bits per sample and min/max sample rates + # Secondary mode has unmodifiable min/max sample rates audio.set_stream_limits( min_bits_per_sample=min_bits_per_sample, - max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=max_bits_per_sample, min_channels=1, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 989bcf2977..ed5145d4b0 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -404,6 +404,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { this->current_stream_info_ = audio_stream_info; + // SPDIF never narrows the bit depth; the encoder consumes the input format directly. + this->output_stream_info_ = audio_stream_info; // SPDIF mode validation if (this->sample_rate_ != audio_stream_info.get_sample_rate()) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 691f68e912..c6ff42495f 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -354,7 +354,7 @@ void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_rea void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { #ifdef USE_ESP32_VARIANT_ESP32 // For ESP32 16-bit mono mode, adjacent samples need to be swapped. - if (this->current_stream_info_.get_channels() == 1 && this->current_stream_info_.get_bits_per_sample() == 16) { + if (this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16) { int16_t *samples = reinterpret_cast(data); size_t sample_count = bytes_read / sizeof(int16_t); for (size_t i = 0; i + 1 < sample_count; i += 2) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 34792bdbea..adb6ca5e3f 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -134,7 +134,8 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public void apply_software_volume_(uint8_t *data, size_t bytes_read); /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. - /// Only applies when running on original ESP32 with 16-bit mono audio. + /// Only applies when running on original ESP32 with 16-bit mono output. Operates on the data that is + /// handed to the I2S peripheral, so the check uses the output (post-narrowing) stream info. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); @@ -156,7 +157,11 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public int32_t q31_volume_factor_{INT32_MAX}; - audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info + audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) + // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as + // current_stream_info_, but the bits per sample may be narrower when the incoming stream is wider than + // the speaker's configured slot bit width. Set by start_i2s_driver before the speaker task starts. + audio::AudioStreamInfo output_stream_info_; gpio_num_t dout_pin_; i2s_chan_handle_t tx_handle_{nullptr}; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 0afb67fb36..17c93763d6 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -13,6 +13,9 @@ #include "esp_timer.h" +// esp-audio-libs +#include + namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; @@ -62,6 +65,12 @@ void I2SAudioSpeaker::dump_config() { break; } ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + // The width of each I2S slot. It is also the narrowing ceiling: streams wider than this are narrowed to + // it. A stream narrower than the slot is left at its own width and clocked into the wider slot, so this + // is not necessarily the sample data width (which depends on the incoming stream). + ESP_LOGCONFIG(TAG, " Slot bit width: %u", (unsigned) static_cast(this->slot_bit_width_)); + } } void I2SAudioSpeaker::run_speaker_task() { @@ -71,12 +80,19 @@ void I2SAudioSpeaker::run_speaker_task() { // Ensure ring buffer duration is at least the duration of all DMA buffers const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); - // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info + // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; + + // Per-frame byte widths and whether the task must narrow the bit depth before writing to the I2S peripheral. + const uint8_t channels = this->current_stream_info_.get_channels(); + const uint8_t input_bytes_per_sample = this->current_stream_info_.get_bits_per_sample() / 8; + const uint8_t output_bytes_per_sample = this->output_stream_info_.get_bits_per_sample() / 8; + const bool narrowing = input_bytes_per_sample != output_bytes_per_sample; + // ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it // clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through // the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually @@ -89,9 +105,12 @@ void I2SAudioSpeaker::run_speaker_task() { dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; } else { // Should not happen for a READY channel; fall back to the requested size. - dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(dma_buffer_frames(this->current_stream_info_)); + dma_buffer_bytes = this->output_stream_info_.frames_to_bytes(dma_buffer_frames(this->output_stream_info_)); } - const uint32_t frames_per_dma_buffer = this->current_stream_info_.bytes_to_frames(dma_buffer_bytes); + // dma_buffer_bytes counts output-format bytes; convert with the output stream info. + const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); + // Soft cap for each source read: enough input-format bytes to fill one DMA buffer's worth of frames. + const size_t dma_buffer_input_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); bool successful_setup = false; @@ -105,8 +124,8 @@ void I2SAudioSpeaker::run_speaker_task() { memset(silence_buffer, 0, dma_buffer_bytes); std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); - audio_source = - audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_bytes, static_cast(bytes_per_frame)); + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_input_bytes, + static_cast(bytes_per_frame)); if (audio_source != nullptr) { // audio_source is nullptr if the ring buffer fails to allocate @@ -237,42 +256,61 @@ void I2SAudioSpeaker::run_speaker_task() { // Compose exactly one DMA buffer's worth: drain as much real audio as the source currently // exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any // remainder with silence. All writes pack into the next free DMA descriptor in order, so the - // descriptor ends up holding [real audio][silence padding]. + // descriptor ends up holding [real audio][silence padding]. ``bytes_written_total`` counts + // output-format bytes so it tracks how full the DMA buffer is regardless of any narrowing. size_t bytes_written_total = 0; - size_t real_bytes_total = 0; + uint32_t real_frames_total = 0; bool partial_write_failure = false; if (!this->pause_state_) { while (bytes_written_total < dma_buffer_bytes) { size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false); if (bytes_read > 0) { + // Apply volume at the input bit depth, before any narrowing, so the full precision is scaled. uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read; this->apply_software_volume_(new_data, bytes_read); - this->swap_esp32_mono_samples_(new_data, bytes_read); } - const size_t to_write = std::min(audio_source->available(), dma_buffer_bytes - bytes_written_total); - if (to_write == 0) { + // Convert as many whole frames as fit in the remaining DMA space, bounded by what the source + // currently exposes. Frame counts are shared between input and output; only the byte widths differ. + const uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + const uint32_t frames_room = + this->output_stream_info_.bytes_to_frames(dma_buffer_bytes - bytes_written_total); + const uint32_t frames_to_write = std::min(frames_available, frames_room); + if (frames_to_write == 0) { // Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer // with silence so the lockstep invariant (one write per iteration) is preserved. break; } + const size_t input_bytes = this->current_stream_info_.frames_to_bytes(frames_to_write); + const size_t output_bytes = this->output_stream_info_.frames_to_bytes(frames_to_write); + + uint8_t *chunk = audio_source->mutable_data(); + if (narrowing) { + // Narrow the bit depth in place: output exactly aliases input with the same channel count and a + // smaller width, which copy_frames handles as a single forward pass. Only the frames about to be + // consumed are overwritten, so any unprocessed tail stays intact for the next iteration. + esp_audio_libs::pcm_convert::copy_frames(chunk, chunk, input_bytes_per_sample, channels, + output_bytes_per_sample, channels, frames_to_write); + } + this->swap_esp32_mono_samples_(chunk, output_bytes); + size_t bw = 0; - i2s_channel_write(this->tx_handle_, audio_source->data(), to_write, &bw, WRITE_TIMEOUT_TICKS); - if (bw != to_write) { + i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (bw != output_bytes) { // A short real-audio write breaks DMA descriptor alignment for every subsequent event; // the only safe recovery is to restart the task. - ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) to_write); + ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); partial_write_failure = true; break; } - audio_source->consume(bw); - bytes_written_total += bw; - real_bytes_total += bw; + audio_source->consume(input_bytes); + bytes_written_total += output_bytes; + real_frames_total += frames_to_write; } - if (real_bytes_total > 0) { + if (real_frames_total > 0) { last_data_received_time = millis(); } } @@ -293,16 +331,15 @@ void I2SAudioSpeaker::run_speaker_task() { } } - const uint32_t real_frames_in_buffer = this->current_stream_info_.bytes_to_frames(real_bytes_total); // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep // invariant is broken and every subsequent timestamp would be silently wrong, so bail. - if (xQueueSend(this->write_records_queue_, &real_frames_in_buffer, 0) != pdTRUE) { + if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { ESP_LOGV(TAG, "Exiting: write records queue full"); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); break; } - if (real_frames_in_buffer > 0) { + if (real_frames_total > 0) { pending_real_buffers++; } } @@ -334,21 +371,28 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_NOT_SUPPORTED; } - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - // Currently can't handle the case when the incoming audio has more bits per sample than the configured value - ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); - return ESP_ERR_NOT_SUPPORTED; + // When the stream is wider than the configured slot bit width, the speaker task narrows each frame in place + // before handing it to the I2S peripheral. Compute the output format here so the driver, DMA buffers, and + // the task's conversion all agree on the clocked-out width. A stream no wider than the slot width is passed + // through unchanged (the slot may still be wider than the data, the existing behavior). + uint8_t output_bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + const uint8_t configured_bits = static_cast(this->slot_bit_width_); + if (output_bits_per_sample > configured_bits) { + output_bits_per_sample = configured_bits; + } } + this->output_stream_info_ = audio::AudioStreamInfo(output_bits_per_sample, audio_stream_info.get_channels(), + audio_stream_info.get_sample_rate()); #ifdef USE_ESP32_VARIANT_ESP32 // The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample // occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples // tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple - // of 16, so reject anything else rather than emit corrupted audio. - if (audio_stream_info.get_bits_per_sample() % 16 != 0) { - ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit audio, got %u-bit", - (unsigned) audio_stream_info.get_bits_per_sample()); + // of 16. The check is on the output width since that is what reaches the peripheral; a wider input is fine + // as long as it narrows to a 16- or 32-bit slot. + if (output_bits_per_sample % 16 != 0) { + ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit output, got %u-bit", (unsigned) output_bits_per_sample); return ESP_ERR_NOT_SUPPORTED; } #endif // USE_ESP32_VARIANT_ESP32 @@ -358,7 +402,8 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_INVALID_STATE; } - uint32_t dma_buffer_length = dma_buffer_frames(audio_stream_info); + // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. + uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); i2s_role_t i2s_role = this->i2s_role_; i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; @@ -398,19 +443,18 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream slot_mask = I2S_STD_SLOT_BOTH; } + // Configure the data bit width from the output (post-narrowing) format, which is what is clocked out. + const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) this->output_stream_info_.get_bits_per_sample(); i2s_std_slot_config_t slot_cfg; switch (this->i2s_comm_fmt_) { case I2SCommFmt::PCM: - slot_cfg = - I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + slot_cfg = I2S_STD_PCM_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; case I2SCommFmt::MSB: - slot_cfg = - I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; default: - slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), - slot_mode); + slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; } From cce7cfff29ca6702910d9c5cea49f8aea4e82624 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:52:32 +1200 Subject: [PATCH 1765/2214] Mark configurable classes as final (3/21: ble_scanner-ch423) (#16954) --- esphome/components/b_parasite/b_parasite.h | 2 +- esphome/components/ble_scanner/ble_scanner.h | 4 +- esphome/components/bm8563/bm8563.h | 8 ++-- esphome/components/bme280_i2c/bme280_i2c.h | 2 +- esphome/components/bme280_spi/bme280_spi.h | 6 +-- esphome/components/bme680/bme680.h | 2 +- esphome/components/bme680_bsec/bme680_bsec.h | 2 +- .../bme68x_bsec2_i2c/bme68x_bsec2_i2c.h | 2 +- esphome/components/bmi160/bmi160.h | 2 +- esphome/components/bmi270/bmi270.h | 2 +- esphome/components/bmp085/bmp085.h | 2 +- esphome/components/bmp280_i2c/bmp280_i2c.h | 2 +- esphome/components/bmp280_spi/bmp280_spi.h | 6 +-- esphome/components/bmp3xx_i2c/bmp3xx_i2c.h | 2 +- esphome/components/bmp3xx_spi/bmp3xx_spi.h | 6 +-- esphome/components/bmp581_i2c/bmp581_i2c.h | 2 +- esphome/components/bmp581_spi/bmp581_spi.h | 6 +-- esphome/components/bp1658cj/bp1658cj.h | 4 +- esphome/components/bp5758d/bp5758d.h | 4 +- .../bthome_mithermometer/bthome_ble.h | 2 +- esphome/components/button/automation.h | 4 +- .../camera_encoder/encoder_buffer_impl.h | 2 +- .../esp32_camera_jpeg_encoder.h | 2 +- esphome/components/canbus/canbus.h | 4 +- esphome/components/cap1188/cap1188.h | 4 +- .../captive_portal/captive_portal.h | 2 +- esphome/components/cc1101/cc1101.h | 43 ++++++++++--------- esphome/components/ccs811/ccs811.h | 2 +- esphome/components/cd74hc4067/cd74hc4067.h | 4 +- esphome/components/ch422g/ch422g.h | 4 +- esphome/components/ch423/ch423.h | 4 +- 31 files changed, 73 insertions(+), 70 deletions(-) diff --git a/esphome/components/b_parasite/b_parasite.h b/esphome/components/b_parasite/b_parasite.h index c719599b99..1d5ac6e702 100644 --- a/esphome/components/b_parasite/b_parasite.h +++ b/esphome/components/b_parasite/b_parasite.h @@ -8,7 +8,7 @@ namespace esphome::b_parasite { -class BParasite : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const std::string &bindkey); diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index c2d48741b1..c70ee637ef 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -12,7 +12,9 @@ namespace esphome::ble_scanner { -class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLEScanner final : public text_sensor::TextSensor, + public esp32_ble_tracker::ESPBTDeviceListener, + public Component { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index eda2d1b3c0..5ca9714091 100644 --- a/esphome/components/bm8563/bm8563.h +++ b/esphome/components/bm8563/bm8563.h @@ -5,7 +5,7 @@ namespace esphome::bm8563 { -class BM8563 : public time::RealTimeClock, public i2c::I2CDevice { +class BM8563 final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -34,17 +34,17 @@ class BM8563 : public time::RealTimeClock, public i2c::I2CDevice { uint8_t byte_to_bcd2_(uint8_t value); }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; -template class TimerAction : public Action, public Parented { +template class TimerAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint32_t, duration) diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index ad4a283fc7..501556d3c4 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -7,7 +7,7 @@ namespace esphome::bme280_i2c { static const char *const TAG = "bme280_i2c.sensor"; -class BME280I2CComponent : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { +class BME280I2CComponent final : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; diff --git a/esphome/components/bme280_spi/bme280_spi.h b/esphome/components/bme280_spi/bme280_spi.h index 4e842e9596..3879151ea1 100644 --- a/esphome/components/bme280_spi/bme280_spi.h +++ b/esphome/components/bme280_spi/bme280_spi.h @@ -5,9 +5,9 @@ namespace esphome::bme280_spi { -class BME280SPIComponent : public esphome::bme280_base::BME280Component, - public spi::SPIDevice { +class BME280SPIComponent final : public esphome::bme280_base::BME280Component, + public spi::SPIDevice { void setup() override; bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index e40daf8720..a274578fc1 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -65,7 +65,7 @@ struct BME680CalibrationData { int8_t ambient_temperature; }; -class BME680Component : public PollingComponent, public i2c::I2CDevice { +class BME680Component final : public PollingComponent, public i2c::I2CDevice { public: /// Set the temperature oversampling value. Defaults to 16X. void set_temperature_oversampling(BME680Oversampling temperature_oversampling); diff --git a/esphome/components/bme680_bsec/bme680_bsec.h b/esphome/components/bme680_bsec/bme680_bsec.h index 742b07b59b..ff974d1c6f 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.h +++ b/esphome/components/bme680_bsec/bme680_bsec.h @@ -34,7 +34,7 @@ enum SampleRate { #define BME680_BSEC_SAMPLE_RATE_LOG(r) (r == SAMPLE_RATE_DEFAULT ? "Default" : (r == SAMPLE_RATE_ULP ? "ULP" : "LP")) -class BME680BSECComponent : public Component, public i2c::I2CDevice { +class BME680BSECComponent final : public Component, public i2c::I2CDevice { public: void set_device_id(const std::string &devid) { this->device_id_.assign(devid); } void set_temperature_offset(float offset) { this->temperature_offset_ = offset; } diff --git a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h index 6d20b61390..896d00d096 100644 --- a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h +++ b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h @@ -11,7 +11,7 @@ namespace esphome::bme68x_bsec2_i2c { -class BME68xBSEC2I2CComponent : public bme68x_bsec2::BME68xBSEC2Component, public i2c::I2CDevice { +class BME68xBSEC2I2CComponent final : public bme68x_bsec2::BME68xBSEC2Component, public i2c::I2CDevice { void setup() override; void dump_config() override; diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index e86c353eaa..8af25a09ad 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -6,7 +6,7 @@ namespace esphome::bmi160 { -class BMI160Component : public PollingComponent, public i2c::I2CDevice { +class BMI160Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index 7c5a2db015..56d6a60952 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -78,7 +78,7 @@ enum BMI270GyroODR : uint8_t { // ---Data class // Main component class -class BMI270Component : public motion::MotionComponent, public i2c::I2CDevice { +class BMI270Component final : public motion::MotionComponent, public i2c::I2CDevice { public: // Lifecycle void setup() override; diff --git a/esphome/components/bmp085/bmp085.h b/esphome/components/bmp085/bmp085.h index a64f3936f0..7012152257 100644 --- a/esphome/components/bmp085/bmp085.h +++ b/esphome/components/bmp085/bmp085.h @@ -6,7 +6,7 @@ namespace esphome::bmp085 { -class BMP085Component : public PollingComponent, public i2c::I2CDevice { +class BMP085Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_pressure(sensor::Sensor *pressure) { pressure_ = pressure; } diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index bf1c2fd624..a19203ff0a 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -8,7 +8,7 @@ namespace esphome::bmp280_i2c { static const char *const TAG = "bmp280_i2c.sensor"; /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. -class BMP280I2CComponent : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { +class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { public: bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/bmp280_spi/bmp280_spi.h b/esphome/components/bmp280_spi/bmp280_spi.h index 17d3999884..449167811d 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.h +++ b/esphome/components/bmp280_spi/bmp280_spi.h @@ -5,9 +5,9 @@ namespace esphome::bmp280_spi { -class BMP280SPIComponent : public esphome::bmp280_base::BMP280Component, - public spi::SPIDevice { +class BMP280SPIComponent final : public esphome::bmp280_base::BMP280Component, + public spi::SPIDevice { void setup() override; bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; bool bmp_write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h index bec99cf9f8..93549fc890 100644 --- a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h +++ b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h @@ -4,7 +4,7 @@ namespace esphome::bmp3xx_i2c { -class BMP3XXI2CComponent : public bmp3xx_base::BMP3XXComponent, public i2c::I2CDevice { +class BMP3XXI2CComponent final : public bmp3xx_base::BMP3XXComponent, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; diff --git a/esphome/components/bmp3xx_spi/bmp3xx_spi.h b/esphome/components/bmp3xx_spi/bmp3xx_spi.h index fa0c0e1b47..7e101cc3a1 100644 --- a/esphome/components/bmp3xx_spi/bmp3xx_spi.h +++ b/esphome/components/bmp3xx_spi/bmp3xx_spi.h @@ -4,9 +4,9 @@ namespace esphome::bmp3xx_spi { -class BMP3XXSPIComponent : public bmp3xx_base::BMP3XXComponent, - public spi::SPIDevice { +class BMP3XXSPIComponent final : public bmp3xx_base::BMP3XXComponent, + public spi::SPIDevice { void setup() override; bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bmp581_i2c/bmp581_i2c.h b/esphome/components/bmp581_i2c/bmp581_i2c.h index a4e43daf64..126ffd6a60 100644 --- a/esphome/components/bmp581_i2c/bmp581_i2c.h +++ b/esphome/components/bmp581_i2c/bmp581_i2c.h @@ -8,7 +8,7 @@ namespace esphome::bmp581_i2c { static const char *const TAG = "bmp581_i2c.sensor"; /// This class implements support for the BMP581 Temperature+Pressure i2c sensor. -class BMP581I2CComponent : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { +class BMP581I2CComponent final : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { public: bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/bmp581_spi/bmp581_spi.h b/esphome/components/bmp581_spi/bmp581_spi.h index 57f75588d5..e5b6cf4476 100644 --- a/esphome/components/bmp581_spi/bmp581_spi.h +++ b/esphome/components/bmp581_spi/bmp581_spi.h @@ -6,9 +6,9 @@ namespace esphome::bmp581_spi { // BMP581 is technically compatible with SPI Mode0 and Mode3. Default to Mode3. -class BMP581SPIComponent : public esphome::bmp581_base::BMP581Component, - public spi::SPIDevice { +class BMP581SPIComponent final : public esphome::bmp581_base::BMP581Component, + public spi::SPIDevice { public: void setup() override; bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/bp1658cj/bp1658cj.h b/esphome/components/bp1658cj/bp1658cj.h index 8905642ec4..666a145804 100644 --- a/esphome/components/bp1658cj/bp1658cj.h +++ b/esphome/components/bp1658cj/bp1658cj.h @@ -7,7 +7,7 @@ namespace esphome::bp1658cj { -class BP1658CJ : public Component { +class BP1658CJ final : public Component { public: class Channel; @@ -29,7 +29,7 @@ class BP1658CJ : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(BP1658CJ *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/bp5758d/bp5758d.h b/esphome/components/bp5758d/bp5758d.h index f07d51fe51..572108b4e6 100644 --- a/esphome/components/bp5758d/bp5758d.h +++ b/esphome/components/bp5758d/bp5758d.h @@ -7,7 +7,7 @@ namespace esphome::bp5758d { -class BP5758D : public Component { +class BP5758D final : public Component { public: class Channel; @@ -23,7 +23,7 @@ class BP5758D : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(BP5758D *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index 9bec8ba7a1..924858e449 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -12,7 +12,7 @@ namespace esphome::bthome_mithermometer { -class BTHomeMiThermometer : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(std::initializer_list bindkey); diff --git a/esphome/components/button/automation.h b/esphome/components/button/automation.h index 6a54b141a3..d55d43ea37 100644 --- a/esphome/components/button/automation.h +++ b/esphome/components/button/automation.h @@ -6,7 +6,7 @@ namespace esphome::button { -template class PressAction : public Action { +template class PressAction final : public Action { public: explicit PressAction(Button *button) : button_(button) {} @@ -16,7 +16,7 @@ template class PressAction : public Action { Button *button_; }; -class ButtonPressTrigger : public Trigger<> { +class ButtonPressTrigger final : public Trigger<> { public: ButtonPressTrigger(Button *button) { button->add_on_press_callback([this]() { this->trigger(); }); diff --git a/esphome/components/camera_encoder/encoder_buffer_impl.h b/esphome/components/camera_encoder/encoder_buffer_impl.h index d394daff14..b506cb47e0 100644 --- a/esphome/components/camera_encoder/encoder_buffer_impl.h +++ b/esphome/components/camera_encoder/encoder_buffer_impl.h @@ -5,7 +5,7 @@ namespace esphome::camera_encoder { -class EncoderBufferImpl : public camera::EncoderBuffer { +class EncoderBufferImpl final : public camera::EncoderBuffer { public: // --- EncoderBuffer --- bool set_buffer_size(size_t size) override; diff --git a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h index 0ede366e73..5ec6a98cb9 100644 --- a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h +++ b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h @@ -11,7 +11,7 @@ namespace esphome::camera_encoder { /// Encoder that uses the software-based JPEG implementation from Espressif's esp32-camera component. -class ESP32CameraJPEGEncoder : public camera::Encoder { +class ESP32CameraJPEGEncoder final : public camera::Encoder { public: /// Constructs a ESP32CameraJPEGEncoder instance. /// @param quality Sets the quality of the encoded image (1-100). diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 691d7384f1..1bc4d6e345 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -106,7 +106,7 @@ class Canbus : public Component { virtual Error read_message(struct CanFrame *frame) = 0; }; -template class CanbusSendAction : public Action, public Parented { +template class CanbusSendAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers @@ -154,7 +154,7 @@ template class CanbusSendAction : public Action, public P } data_; }; -class CanbusTrigger : public Trigger, uint32_t, bool>, public Component { +class CanbusTrigger final : public Trigger, uint32_t, bool>, public Component { friend class Canbus; public: diff --git a/esphome/components/cap1188/cap1188.h b/esphome/components/cap1188/cap1188.h index 848e6fe430..a4abb270e7 100644 --- a/esphome/components/cap1188/cap1188.h +++ b/esphome/components/cap1188/cap1188.h @@ -26,7 +26,7 @@ enum { CAP1188_SENSITVITY = 0x1f, }; -class CAP1188Channel : public binary_sensor::BinarySensor { +class CAP1188Channel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint8_t data) { this->publish_state(static_cast(data & (1 << this->channel_))); } @@ -35,7 +35,7 @@ class CAP1188Channel : public binary_sensor::BinarySensor { uint8_t channel_{0}; }; -class CAP1188Component : public Component, public i2c::I2CDevice { +class CAP1188Component final : public Component, public i2c::I2CDevice { public: void register_channel(CAP1188Channel *channel) { this->channels_.push_back(channel); } void set_touch_threshold(uint8_t touch_threshold) { this->touch_threshold_ = touch_threshold; }; diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 8c8b43e608..b47af9d978 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -14,7 +14,7 @@ namespace esphome::captive_portal { -class CaptivePortal : public AsyncWebHandler, public Component { +class CaptivePortal final : public AsyncWebHandler, public Component { public: CaptivePortal(web_server_base::WebServerBase *base); void setup() override; diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 000a13d586..065ffd5250 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -16,9 +16,9 @@ class CC1101Listener { virtual void on_packet(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi) = 0; }; -class CC1101Component : public Component, - public spi::SPIDevice { +class CC1101Component final : public Component, + public spi::SPIDevice { public: CC1101Component(); @@ -119,27 +119,27 @@ class CC1101Component : public Component, }; // Action Wrappers -template class BeginTxAction : public Action, public Parented { +template class BeginTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->begin_tx(); } }; -template class BeginRxAction : public Action, public Parented { +template class BeginRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->begin_rx(); } }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; -template class SetIdleAction : public Action, public Parented { +template class SetIdleAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_idle(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } void set_data_static(const uint8_t *data, size_t len) { @@ -163,79 +163,80 @@ template class SendPacketAction : public Action, public P size_t data_static_len_{0}; }; -template class SetSymbolRateAction : public Action, public Parented { +template class SetSymbolRateAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, symbol_rate) void play(const Ts &...x) override { this->parent_->set_symbol_rate(this->symbol_rate_.value(x...)); } }; -template class SetFrequencyAction : public Action, public Parented { +template class SetFrequencyAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, frequency) void play(const Ts &...x) override { this->parent_->set_frequency(this->frequency_.value(x...)); } }; -template class SetOutputPowerAction : public Action, public Parented { +template class SetOutputPowerAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, output_power) void play(const Ts &...x) override { this->parent_->set_output_power(this->output_power_.value(x...)); } }; -template class SetModulationTypeAction : public Action, public Parented { +template class SetModulationTypeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(Modulation, modulation_type) void play(const Ts &...x) override { this->parent_->set_modulation_type(this->modulation_type_.value(x...)); } }; -template class SetRxAttenuationAction : public Action, public Parented { +template class SetRxAttenuationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(RxAttenuation, rx_attenuation) void play(const Ts &...x) override { this->parent_->set_rx_attenuation(this->rx_attenuation_.value(x...)); } }; -template class SetDcBlockingFilterAction : public Action, public Parented { +template +class SetDcBlockingFilterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, dc_blocking_filter) void play(const Ts &...x) override { this->parent_->set_dc_blocking_filter(this->dc_blocking_filter_.value(x...)); } }; -template class SetManchesterAction : public Action, public Parented { +template class SetManchesterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, manchester) void play(const Ts &...x) override { this->parent_->set_manchester(this->manchester_.value(x...)); } }; -template class SetFilterBandwidthAction : public Action, public Parented { +template class SetFilterBandwidthAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, filter_bandwidth) void play(const Ts &...x) override { this->parent_->set_filter_bandwidth(this->filter_bandwidth_.value(x...)); } }; -template class SetFskDeviationAction : public Action, public Parented { +template class SetFskDeviationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, fsk_deviation) void play(const Ts &...x) override { this->parent_->set_fsk_deviation(this->fsk_deviation_.value(x...)); } }; -template class SetMskDeviationAction : public Action, public Parented { +template class SetMskDeviationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, msk_deviation) void play(const Ts &...x) override { this->parent_->set_msk_deviation(this->msk_deviation_.value(x...)); } }; -template class SetChannelAction : public Action, public Parented { +template class SetChannelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) void play(const Ts &...x) override { this->parent_->set_channel(this->channel_.value(x...)); } }; -template class SetChannelSpacingAction : public Action, public Parented { +template class SetChannelSpacingAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, channel_spacing) void play(const Ts &...x) override { this->parent_->set_channel_spacing(this->channel_spacing_.value(x...)); } }; -template class SetIfFrequencyAction : public Action, public Parented { +template class SetIfFrequencyAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, if_frequency) void play(const Ts &...x) override { this->parent_->set_if_frequency(this->if_frequency_.value(x...)); } diff --git a/esphome/components/ccs811/ccs811.h b/esphome/components/ccs811/ccs811.h index fde2494753..fb83f842fd 100644 --- a/esphome/components/ccs811/ccs811.h +++ b/esphome/components/ccs811/ccs811.h @@ -8,7 +8,7 @@ namespace esphome::ccs811 { -class CCS811Component : public PollingComponent, public i2c::I2CDevice { +class CCS811Component final : public PollingComponent, public i2c::I2CDevice { public: void set_co2(sensor::Sensor *co2) { co2_ = co2; } void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } diff --git a/esphome/components/cd74hc4067/cd74hc4067.h b/esphome/components/cd74hc4067/cd74hc4067.h index f41b5e294a..3e773a3c8c 100644 --- a/esphome/components/cd74hc4067/cd74hc4067.h +++ b/esphome/components/cd74hc4067/cd74hc4067.h @@ -7,7 +7,7 @@ namespace esphome::cd74hc4067 { -class CD74HC4067Component : public Component { +class CD74HC4067Component final : public Component { public: /// Set up the internal sensor array. void setup() override; @@ -38,7 +38,7 @@ class CD74HC4067Component : public Component { uint32_t switch_delay_; }; -class CD74HC4067Sensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { +class CD74HC4067Sensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { public: CD74HC4067Sensor(CD74HC4067Component *parent); diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index f74e0c46a4..a8729225e8 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -6,7 +6,7 @@ namespace esphome::ch422g { -class CH422GComponent : public Component, public i2c::I2CDevice { +class CH422GComponent final : public Component, public i2c::I2CDevice { public: CH422GComponent() = default; @@ -42,7 +42,7 @@ class CH422GComponent : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH422G pin as a GPIO pin. -class CH422GGPIOPin : public GPIOPin { +class CH422GGPIOPin final : public GPIOPin { public: void setup() override{}; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/ch423/ch423.h b/esphome/components/ch423/ch423.h index d384971a72..fbfffb521d 100644 --- a/esphome/components/ch423/ch423.h +++ b/esphome/components/ch423/ch423.h @@ -6,7 +6,7 @@ namespace esphome::ch423 { -class CH423Component : public Component, public i2c::I2CDevice { +class CH423Component final : public Component, public i2c::I2CDevice { public: CH423Component() = default; @@ -41,7 +41,7 @@ class CH423Component : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH423 pin as a GPIO pin. -class CH423GPIOPin : public GPIOPin { +class CH423GPIOPin final : public GPIOPin { public: void setup() override{}; void pin_mode(gpio::Flags flags) override; From faabafad2b7818f6c40ad9392096f9e7766819a9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:54:05 +1000 Subject: [PATCH 1766/2214] [mipi_rgb] Fix offsets for Wave 5 1024x600 (#17057) --- esphome/components/mipi/__init__.py | 5 +++++ tests/components/mipi_rgb/test.esp32-s3-idf.yaml | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 129befe600..caa33cd834 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -322,6 +322,9 @@ class DriverChip: - defaults.get(CONF_OFFSET_WIDTH, 0) - defaults.get(CONF_PAD_WIDTH, 0) ) + elif defaults[CONF_WIDTH] > defaults[CONF_NATIVE_WIDTH]: + defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] + else: native_width = ( defaults.get(CONF_WIDTH, 0) @@ -337,6 +340,8 @@ class DriverChip: - defaults.get(CONF_OFFSET_HEIGHT, 0) - defaults.get(CONF_PAD_HEIGHT, 0) ) + elif defaults[CONF_HEIGHT] > defaults[CONF_NATIVE_HEIGHT]: + defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] else: native_height = ( defaults.get(CONF_HEIGHT, 0) diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index 399c25c1d0..b56ebee21e 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -1,7 +1,11 @@ packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + - !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml psram: mode: octal -<<: !include common.yaml +ch422g: + +display: + - platform: mipi_rgb + model: WAVESHARE-5-1024X600 From 44c54b3a756692618a4245b8384b3eecff412b30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 18:09:54 -0500 Subject: [PATCH 1767/2214] [json] Bump ArduinoJson to 7.4.3 (#17126) --- esphome/components/json/__init__.py | 4 ++-- esphome/idf_component.yml | 2 +- platformio.ini | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index 28fdcd41ef..3cb89a6cd9 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -15,8 +15,8 @@ async def to_code(config): if CORE.is_esp32: from esphome.components.esp32 import add_idf_component - add_idf_component(name="bblanchon/arduinojson", ref="7.4.2") + add_idf_component(name="bblanchon/arduinojson", ref="7.4.3") else: - cg.add_library("bblanchon/ArduinoJson", "7.4.2") + cg.add_library("bblanchon/ArduinoJson", "7.4.3") cg.add_define("USE_JSON") cg.add_global(json_ns.using) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b3b670d77b..f8f3df57cd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -1,6 +1,6 @@ dependencies: bblanchon/arduinojson: - version: "7.4.2" + version: "7.4.3" esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: diff --git a/platformio.ini b/platformio.ini index 862b7a7dbe..43a7474d35 100644 --- a/platformio.ini +++ b/platformio.ini @@ -104,7 +104,7 @@ build_unflags = [common:idf-component-libs] lib_deps = esphome/dlms_parser@1.1.0 ; dlms_meter - bblanchon/ArduinoJson@7.4.2 ; json + bblanchon/ArduinoJson@7.4.3 ; json lvgl/lvgl@9.5.0 ; lvgl ; This are common settings for the ESP8266 using Arduino. From 7fcc890e84093d3562c9b158d0476e62a8f89116 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 20:02:46 -0500 Subject: [PATCH 1768/2214] [rp2040] Bump arduino-pico framework to 5.6.1 (#17122) --- esphome/components/rp2040/__init__.py | 7 ++- esphome/components/rp2040/boards.py | 68 +++++++++++++++++++++++++++ platformio.ini | 2 +- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index dd851b8e16..e76ce6def8 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -187,12 +187,11 @@ def _parse_platform_version(value): # * The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) -# and platformio.ini/platformio-lint.ini in the esphome-docker-base repository # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases # - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 1) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags @@ -202,8 +201,8 @@ RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460" def _arduino_check_versions(value): value = value.copy() lookups = { - "dev": (cv.Version(5, 6, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(5, 6, 0), None), + "dev": (cv.Version(5, 6, 1), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(5, 6, 1), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2040/boards.py b/esphome/components/rp2040/boards.py index 1f2b3a93f4..0bc5c48d03 100644 --- a/esphome/components/rp2040/boards.py +++ b/esphome/components/rp2040/boards.py @@ -865,6 +865,30 @@ RP2040_BOARD_PINS = { "SS": 17, "TX": 0, }, + "pcbcupid_glyph_2040": { + "LED": 0, + "MISO": 8, + "MOSI": 7, + "RX": 13, + "SCK": 6, + "SCL": 21, + "SDA": 20, + "SS": 5, + "TX": 12, + }, + "pcbcupid_glyph_mini_2040": { + "LED": 16, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 9, + "SCL1": 27, + "SDA": 8, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, "picolume": { "LED": 25, "MISO": 16, @@ -1079,6 +1103,18 @@ RP2040_BOARD_PINS = { "SDA": 6, "TX": 0, }, + "seeed_xiao_rp2040_plus": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 7, + "SCL1": 21, + "SDA": 6, + "SDA1": 20, + "TX": 0, + }, "seeed_xiao_rp2350": { "LED": 25, "MISO": 4, @@ -1102,6 +1138,18 @@ RP2040_BOARD_PINS = { "SS": 21, "TX": 0, }, + "soldered_nula_ethernet_w55rp20": { + "MISO": 4, + "MOSI": 7, + "RX": 1, + "SCK": 6, + "SCL": 3, + "SCL1": 29, + "SDA": 2, + "SDA1": 28, + "SS": 5, + "TX": 0, + }, "soldered_nula_rp2350": { "MISO": 2, "MOSI": 3, @@ -1899,6 +1947,16 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "pcbcupid_glyph_2040": { + "name": "PCBCupid Glyph 2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "pcbcupid_glyph_mini_2040": { + "name": "PCBCupid Glyph Mini 2040", + "mcu": "rp2040", + "max_pin": 29, + }, "picolume": { "name": "PicoLume Transceiver", "mcu": "rp2040", @@ -2021,6 +2079,11 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "seeed_xiao_rp2040_plus": { + "name": "Seeed XIAO RP2040 Plus", + "mcu": "rp2040", + "max_pin": 29, + }, "seeed_xiao_rp2350": { "name": "Seeed XIAO RP2350", "mcu": "rp2350", @@ -2031,6 +2094,11 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "soldered_nula_ethernet_w55rp20": { + "name": "Soldered Electronics NULA Ethernet W55RP20", + "mcu": "rp2040", + "max_pin": 29, + }, "soldered_nula_rp2350": { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", diff --git a/platformio.ini b/platformio.ini index 43a7474d35..bca2910616 100644 --- a/platformio.ini +++ b/platformio.ini @@ -206,7 +206,7 @@ board_build.filesystem_size = 0.5m platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460 platform_packages = ; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.0/rp2040-5.6.0.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.1/rp2040-5.6.1.zip framework = arduino lib_deps = From 026bac4cd1efeed4971a41bcc381553d40e7c053 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:27:56 +1200 Subject: [PATCH 1769/2214] [ld2420] Mark configurable classes as final (#17130) --- .../ld2420/binary_sensor/ld2420_binary_sensor.h | 2 +- .../components/ld2420/button/reconfig_buttons.h | 8 ++++---- esphome/components/ld2420/ld2420.h | 2 +- .../ld2420/number/gate_config_number.h | 16 ++++++++-------- .../ld2420/select/operating_mode_select.h | 2 +- esphome/components/ld2420/sensor/ld2420_sensor.h | 2 +- .../ld2420/text_sensor/ld2420_text_sensor.h | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h index ec52312f92..47492e38c2 100644 --- a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h +++ b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420BinarySensor : public LD2420Listener, public Component, binary_sensor::BinarySensor { +class LD2420BinarySensor final : public LD2420Listener, public Component, public binary_sensor::BinarySensor { public: void dump_config() override; void set_presence_sensor(binary_sensor::BinarySensor *bsensor) { this->presence_bsensor_ = bsensor; }; diff --git a/esphome/components/ld2420/button/reconfig_buttons.h b/esphome/components/ld2420/button/reconfig_buttons.h index 72171ef386..b769e18a46 100644 --- a/esphome/components/ld2420/button/reconfig_buttons.h +++ b/esphome/components/ld2420/button/reconfig_buttons.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420ApplyConfigButton : public button::Button, public Parented { +class LD2420ApplyConfigButton final : public button::Button, public Parented { public: LD2420ApplyConfigButton() = default; @@ -13,7 +13,7 @@ class LD2420ApplyConfigButton : public button::Button, public Parented { +class LD2420RevertConfigButton final : public button::Button, public Parented { public: LD2420RevertConfigButton() = default; @@ -21,7 +21,7 @@ class LD2420RevertConfigButton : public button::Button, public Parented { +class LD2420RestartModuleButton final : public button::Button, public Parented { public: LD2420RestartModuleButton() = default; @@ -29,7 +29,7 @@ class LD2420RestartModuleButton : public button::Button, public Parented { +class LD2420FactoryResetButton final : public button::Button, public Parented { public: LD2420FactoryResetButton() = default; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 358793fe64..ae44b16065 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -40,7 +40,7 @@ class LD2420Listener { virtual void on_fw_version(std::string &fw){}; }; -class LD2420Component : public Component, public uart::UARTDevice { +class LD2420Component final : public Component, public uart::UARTDevice { public: struct CmdFrameT { uint32_t header{0}; diff --git a/esphome/components/ld2420/number/gate_config_number.h b/esphome/components/ld2420/number/gate_config_number.h index 8a8b9c61b1..e1c12e023a 100644 --- a/esphome/components/ld2420/number/gate_config_number.h +++ b/esphome/components/ld2420/number/gate_config_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420TimeoutNumber : public number::Number, public Parented { +class LD2420TimeoutNumber final : public number::Number, public Parented { public: LD2420TimeoutNumber() = default; @@ -13,7 +13,7 @@ class LD2420TimeoutNumber : public number::Number, public Parented { +class LD2420MinDistanceNumber final : public number::Number, public Parented { public: LD2420MinDistanceNumber() = default; @@ -21,7 +21,7 @@ class LD2420MinDistanceNumber : public number::Number, public Parented { +class LD2420MaxDistanceNumber final : public number::Number, public Parented { public: LD2420MaxDistanceNumber() = default; @@ -29,7 +29,7 @@ class LD2420MaxDistanceNumber : public number::Number, public Parented { +class LD2420GateSelectNumber final : public number::Number, public Parented { public: LD2420GateSelectNumber() = default; @@ -37,7 +37,7 @@ class LD2420GateSelectNumber : public number::Number, public Parented { +class LD2420MoveSensFactorNumber final : public number::Number, public Parented { public: LD2420MoveSensFactorNumber() = default; @@ -45,7 +45,7 @@ class LD2420MoveSensFactorNumber : public number::Number, public Parented { +class LD2420StillSensFactorNumber final : public number::Number, public Parented { public: LD2420StillSensFactorNumber() = default; @@ -53,7 +53,7 @@ class LD2420StillSensFactorNumber : public number::Number, public Parented { +class LD2420StillThresholdNumbers final : public number::Number, public Parented { public: LD2420StillThresholdNumbers() = default; LD2420StillThresholdNumbers(uint8_t gate); @@ -63,7 +63,7 @@ class LD2420StillThresholdNumbers : public number::Number, public Parented { +class LD2420MoveThresholdNumbers final : public number::Number, public Parented { public: LD2420MoveThresholdNumbers() = default; LD2420MoveThresholdNumbers(uint8_t gate); diff --git a/esphome/components/ld2420/select/operating_mode_select.h b/esphome/components/ld2420/select/operating_mode_select.h index c1b8e0b11b..e5eb5d82bd 100644 --- a/esphome/components/ld2420/select/operating_mode_select.h +++ b/esphome/components/ld2420/select/operating_mode_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420Select : public Component, public select::Select, public Parented { +class LD2420Select final : public Component, public select::Select, public Parented { public: LD2420Select() = default; diff --git a/esphome/components/ld2420/sensor/ld2420_sensor.h b/esphome/components/ld2420/sensor/ld2420_sensor.h index 4849cfa047..4ccfc19081 100644 --- a/esphome/components/ld2420/sensor/ld2420_sensor.h +++ b/esphome/components/ld2420/sensor/ld2420_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420Sensor : public LD2420Listener, public Component, sensor::Sensor { +class LD2420Sensor final : public LD2420Listener, public Component, public sensor::Sensor { public: void dump_config() override; void set_distance_sensor(sensor::Sensor *sensor) { this->distance_sensor_ = sensor; } diff --git a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h index 1932eaaf69..da295fe7ca 100644 --- a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420TextSensor : public LD2420Listener, public Component, text_sensor::TextSensor { +class LD2420TextSensor final : public LD2420Listener, public Component, public text_sensor::TextSensor { public: void dump_config() override; void set_fw_version_text_sensor(text_sensor::TextSensor *tsensor) { this->fw_version_text_sensor_ = tsensor; }; From 2982d7c83499a552089dc4dca35bd44087577467 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:16 +1200 Subject: [PATCH 1770/2214] Mark configurable classes as final (9/21) (#16960) --- .../internal_temperature.h | 2 +- esphome/components/interval/interval.h | 2 +- esphome/components/ir_rf_proxy/ir_rf_proxy.h | 4 ++-- esphome/components/jsn_sr04t/jsn_sr04t.h | 2 +- .../components/kamstrup_kmp/kamstrup_kmp.h | 2 +- .../components/key_collector/key_collector.h | 6 +++--- esphome/components/kmeteriso/kmeteriso.h | 2 +- esphome/components/kuntze/kuntze.h | 2 +- esphome/components/lc709203f/lc709203f.h | 2 +- .../components/lcd_gpio/gpio_lcd_display.h | 2 +- esphome/components/lcd_menu/lcd_menu.h | 2 +- .../components/lcd_pcf8574/pcf8574_display.h | 2 +- esphome/components/ld2410/automation.h | 2 +- .../ld2410/button/factory_reset_button.h | 2 +- .../components/ld2410/button/query_button.h | 2 +- .../components/ld2410/button/restart_button.h | 2 +- esphome/components/ld2410/ld2410.h | 2 +- .../ld2410/number/gate_threshold_number.h | 2 +- .../ld2410/number/light_threshold_number.h | 2 +- .../number/max_distance_timeout_number.h | 2 +- .../ld2410/select/baud_rate_select.h | 2 +- .../select/distance_resolution_select.h | 2 +- .../ld2410/select/light_out_control_select.h | 2 +- .../ld2410/switch/bluetooth_switch.h | 2 +- .../ld2410/switch/engineering_mode_switch.h | 2 +- .../ld2412/button/factory_reset_button.h | 2 +- .../components/ld2412/button/query_button.h | 2 +- .../components/ld2412/button/restart_button.h | 2 +- ...art_dynamic_background_correction_button.h | 2 +- esphome/components/ld2412/ld2412.h | 2 +- .../ld2412/number/gate_threshold_number.h | 2 +- .../ld2412/number/light_threshold_number.h | 2 +- .../number/max_distance_timeout_number.h | 2 +- .../ld2412/select/baud_rate_select.h | 2 +- .../select/distance_resolution_select.h | 2 +- .../ld2412/select/light_out_control_select.h | 2 +- .../ld2412/switch/bluetooth_switch.h | 2 +- .../ld2412/switch/engineering_mode_switch.h | 2 +- esphome/components/ledc/ledc_output.h | 4 ++-- esphome/components/libretiny/gpio_arduino.h | 2 +- esphome/components/libretiny/lt_component.h | 2 +- .../components/libretiny_pwm/libretiny_pwm.h | 4 ++-- esphome/components/light/addressable_light.h | 2 +- esphome/components/light/automation.h | 20 +++++++++---------- esphome/components/lightwaverf/lightwaverf.h | 4 ++-- .../touchscreen/lilygo_t5_47_touchscreen.h | 2 +- esphome/components/lm75b/lm75b.h | 2 +- esphome/components/lock/automation.h | 8 ++++---- esphome/components/lps22/lps22.h | 2 +- esphome/components/lsm6ds/lsm6ds.h | 2 +- esphome/components/ltr390/ltr390.h | 2 +- esphome/components/ltr501/ltr501.h | 2 +- esphome/components/lvgl/light/lvgl_light.h | 2 +- esphome/components/lvgl/lvgl_esphome.h | 16 +++++++-------- esphome/components/lvgl/number/lvgl_number.h | 2 +- esphome/components/lvgl/select/lvgl_select.h | 2 +- esphome/components/lvgl/switch/lvgl_switch.h | 2 +- esphome/components/lvgl/text/lvgl_text.h | 2 +- .../m5stack_8angle_binary_sensor.h | 6 +++--- .../light/m5stack_8angle_light.h | 2 +- .../m5stack_8angle/m5stack_8angle.h | 2 +- .../sensor/m5stack_8angle_sensor.h | 6 +++--- 62 files changed, 91 insertions(+), 91 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 41fea5a255..90831cf211 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -11,7 +11,7 @@ namespace esphome::internal_temperature { -class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent { +class InternalTemperatureSensor final : public sensor::Sensor, public PollingComponent { public: #if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; diff --git a/esphome/components/interval/interval.h b/esphome/components/interval/interval.h index c9d4e8ea3e..fd59d2a488 100644 --- a/esphome/components/interval/interval.h +++ b/esphome/components/interval/interval.h @@ -6,7 +6,7 @@ namespace esphome::interval { -class IntervalTrigger : public Trigger<>, public PollingComponent { +class IntervalTrigger final : public Trigger<>, public PollingComponent { public: void update() override { this->trigger(); } diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index d0467e822d..5fc683354b 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -18,7 +18,7 @@ namespace esphome::ir_rf_proxy { #ifdef USE_IR_RF /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend -class IrRfProxy : public infrared::Infrared { +class IrRfProxy final : public infrared::Infrared { public: IrRfProxy() = default; @@ -47,7 +47,7 @@ class IrRfProxy : public infrared::Infrared { /// Driver-agnostic: integration with specific RF front-end chips (CC1101, RFM69, etc.) is done /// in YAML by wiring their actions to `remote_transmitter`'s on_transmit/on_complete triggers and /// to this entity's on_control trigger (see radio_frequency component docs). -class RfProxy : public radio_frequency::RadioFrequency { +class RfProxy final : public radio_frequency::RadioFrequency { public: RfProxy() = default; diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.h b/esphome/components/jsn_sr04t/jsn_sr04t.h index f9d07ea539..5368ec683c 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.h +++ b/esphome/components/jsn_sr04t/jsn_sr04t.h @@ -13,7 +13,7 @@ enum Model { AJ_SR04M, }; -class Jsnsr04tComponent : public sensor::Sensor, public PollingComponent, public uart::UARTDevice { +class Jsnsr04tComponent final : public sensor::Sensor, public PollingComponent, public uart::UARTDevice { public: void set_model(Model model) { this->model_ = model; } diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.h b/esphome/components/kamstrup_kmp/kamstrup_kmp.h index a4eacec453..57a89f77a1 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.h +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.h @@ -73,7 +73,7 @@ static const char *const UNITS[] = { "mm:dd", "", "bar", "RTC", "ASCII", "m3 x 10", "ton x 10", "GJ x 10", "minutes", "Bitfield", "s", "ms", "days", "RTC-Q", "Datetime"}; -class KamstrupKMPComponent : public PollingComponent, public uart::UARTDevice { +class KamstrupKMPComponent final : public PollingComponent, public uart::UARTDevice { public: void set_heat_energy_sensor(sensor::Sensor *sensor) { this->heat_energy_sensor_ = sensor; } void set_power_sensor(sensor::Sensor *sensor) { this->power_sensor_ = sensor; } diff --git a/esphome/components/key_collector/key_collector.h b/esphome/components/key_collector/key_collector.h index 27209c50df..c9eeabeb2d 100644 --- a/esphome/components/key_collector/key_collector.h +++ b/esphome/components/key_collector/key_collector.h @@ -7,7 +7,7 @@ namespace esphome::key_collector { -class KeyCollector : public Component { +class KeyCollector final : public Component { public: void loop() override; void dump_config() override; @@ -54,11 +54,11 @@ class KeyCollector : public Component { bool enabled_{}; }; -template class EnableAction : public Action, public Parented { +template class EnableAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_enabled(true); } }; -template class DisableAction : public Action, public Parented { +template class DisableAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_enabled(false); } }; diff --git a/esphome/components/kmeteriso/kmeteriso.h b/esphome/components/kmeteriso/kmeteriso.h index d5a2f9a01b..bd92a6011f 100644 --- a/esphome/components/kmeteriso/kmeteriso.h +++ b/esphome/components/kmeteriso/kmeteriso.h @@ -8,7 +8,7 @@ namespace esphome::kmeteriso { /// This class implements support for the KMeterISO thermocouple sensor. -class KMeterISOComponent : public PollingComponent, public i2c::I2CDevice { +class KMeterISOComponent final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *t) { this->temperature_sensor_ = t; } void set_internal_temperature_sensor(sensor::Sensor *t) { this->internal_temperature_sensor_ = t; } diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index bbd93a22ce..99dd78e5b6 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -6,7 +6,7 @@ namespace esphome::kuntze { -class Kuntze : public PollingComponent, public modbus::ModbusDevice { +class Kuntze final : public PollingComponent, public modbus::ModbusDevice { public: void set_ph_sensor(sensor::Sensor *ph_sensor) { ph_sensor_ = ph_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 42aa9a15a1..46f773873a 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -19,7 +19,7 @@ enum LC709203FBatteryVoltage { LC709203F_BATTERY_VOLTAGE_3_7 = 0x0001, }; -class Lc709203f : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Lc709203f final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/lcd_gpio/gpio_lcd_display.h b/esphome/components/lcd_gpio/gpio_lcd_display.h index dd9ea5929c..17fcf7ea23 100644 --- a/esphome/components/lcd_gpio/gpio_lcd_display.h +++ b/esphome/components/lcd_gpio/gpio_lcd_display.h @@ -10,7 +10,7 @@ class GPIOLCDDisplay; using gpio_lcd_writer_t = display::DisplayWriter; -class GPIOLCDDisplay : public lcd_base::LCDDisplay { +class GPIOLCDDisplay final : public lcd_base::LCDDisplay { public: void set_writer(gpio_lcd_writer_t &&writer) { this->writer_ = std::move(writer); } void setup() override; diff --git a/esphome/components/lcd_menu/lcd_menu.h b/esphome/components/lcd_menu/lcd_menu.h index ae1c2502fe..6fa61fdf6a 100644 --- a/esphome/components/lcd_menu/lcd_menu.h +++ b/esphome/components/lcd_menu/lcd_menu.h @@ -11,7 +11,7 @@ namespace esphome::lcd_menu { /** Class to display a hierarchical menu. * */ -class LCDCharacterMenuComponent : public display_menu_base::DisplayMenuComponent { +class LCDCharacterMenuComponent final : public display_menu_base::DisplayMenuComponent { public: void set_display(lcd_base::LCDDisplay *display) { this->display_ = display; } void set_dimensions(uint8_t columns, uint8_t rows) { diff --git a/esphome/components/lcd_pcf8574/pcf8574_display.h b/esphome/components/lcd_pcf8574/pcf8574_display.h index 9ec5ad71af..5af087add5 100644 --- a/esphome/components/lcd_pcf8574/pcf8574_display.h +++ b/esphome/components/lcd_pcf8574/pcf8574_display.h @@ -11,7 +11,7 @@ class PCF8574LCDDisplay; using pcf8574_lcd_writer_t = display::DisplayWriter; -class PCF8574LCDDisplay : public lcd_base::LCDDisplay, public i2c::I2CDevice { +class PCF8574LCDDisplay final : public lcd_base::LCDDisplay, public i2c::I2CDevice { public: void set_writer(pcf8574_lcd_writer_t &&writer) { this->writer_ = std::move(writer); } void setup() override; diff --git a/esphome/components/ld2410/automation.h b/esphome/components/ld2410/automation.h index 614453b575..b0b9591d37 100644 --- a/esphome/components/ld2410/automation.h +++ b/esphome/components/ld2410/automation.h @@ -6,7 +6,7 @@ namespace esphome::ld2410 { -template class BluetoothPasswordSetAction : public Action { +template class BluetoothPasswordSetAction final : public Action { public: explicit BluetoothPasswordSetAction(LD2410Component *ld2410_comp) : ld2410_comp_(ld2410_comp) {} TEMPLATABLE_VALUE(std::string, password) diff --git a/esphome/components/ld2410/button/factory_reset_button.h b/esphome/components/ld2410/button/factory_reset_button.h index 715a8c4056..1da7c81337 100644 --- a/esphome/components/ld2410/button/factory_reset_button.h +++ b/esphome/components/ld2410/button/factory_reset_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class FactoryResetButton : public button::Button, public Parented { +class FactoryResetButton final : public button::Button, public Parented { public: FactoryResetButton() = default; diff --git a/esphome/components/ld2410/button/query_button.h b/esphome/components/ld2410/button/query_button.h index 7a786901ae..4f3f147e67 100644 --- a/esphome/components/ld2410/button/query_button.h +++ b/esphome/components/ld2410/button/query_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class QueryButton : public button::Button, public Parented { +class QueryButton final : public button::Button, public Parented { public: QueryButton() = default; diff --git a/esphome/components/ld2410/button/restart_button.h b/esphome/components/ld2410/button/restart_button.h index 9bf8639a8c..70e0a74c9a 100644 --- a/esphome/components/ld2410/button/restart_button.h +++ b/esphome/components/ld2410/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 31186b135f..a0cce36d16 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -38,7 +38,7 @@ using namespace ld24xx; static constexpr uint8_t MAX_LINE_LENGTH = 50; static constexpr uint8_t TOTAL_GATES = 9; // Total number of gates supported by the LD2410 -class LD2410Component : public Component, public uart::UARTDevice { +class LD2410Component final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(out_pin_presence_status) SUB_BINARY_SENSOR(moving_target) diff --git a/esphome/components/ld2410/number/gate_threshold_number.h b/esphome/components/ld2410/number/gate_threshold_number.h index 63491f18d3..68359a10d4 100644 --- a/esphome/components/ld2410/number/gate_threshold_number.h +++ b/esphome/components/ld2410/number/gate_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class GateThresholdNumber : public number::Number, public Parented { +class GateThresholdNumber final : public number::Number, public Parented { public: GateThresholdNumber(uint8_t gate); diff --git a/esphome/components/ld2410/number/light_threshold_number.h b/esphome/components/ld2410/number/light_threshold_number.h index 3c5e433416..6e1a5ca4a4 100644 --- a/esphome/components/ld2410/number/light_threshold_number.h +++ b/esphome/components/ld2410/number/light_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class LightThresholdNumber : public number::Number, public Parented { +class LightThresholdNumber final : public number::Number, public Parented { public: LightThresholdNumber() = default; diff --git a/esphome/components/ld2410/number/max_distance_timeout_number.h b/esphome/components/ld2410/number/max_distance_timeout_number.h index 35f4cbbfae..29b19c2022 100644 --- a/esphome/components/ld2410/number/max_distance_timeout_number.h +++ b/esphome/components/ld2410/number/max_distance_timeout_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class MaxDistanceTimeoutNumber : public number::Number, public Parented { +class MaxDistanceTimeoutNumber final : public number::Number, public Parented { public: MaxDistanceTimeoutNumber() = default; diff --git a/esphome/components/ld2410/select/baud_rate_select.h b/esphome/components/ld2410/select/baud_rate_select.h index fb1d016b1f..b06ce139ad 100644 --- a/esphome/components/ld2410/select/baud_rate_select.h +++ b/esphome/components/ld2410/select/baud_rate_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class BaudRateSelect : public select::Select, public Parented { +class BaudRateSelect final : public select::Select, public Parented { public: BaudRateSelect() = default; diff --git a/esphome/components/ld2410/select/distance_resolution_select.h b/esphome/components/ld2410/select/distance_resolution_select.h index be2389d36e..0c5409b7b1 100644 --- a/esphome/components/ld2410/select/distance_resolution_select.h +++ b/esphome/components/ld2410/select/distance_resolution_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class DistanceResolutionSelect : public select::Select, public Parented { +class DistanceResolutionSelect final : public select::Select, public Parented { public: DistanceResolutionSelect() = default; diff --git a/esphome/components/ld2410/select/light_out_control_select.h b/esphome/components/ld2410/select/light_out_control_select.h index 608c311af4..a8a16598b1 100644 --- a/esphome/components/ld2410/select/light_out_control_select.h +++ b/esphome/components/ld2410/select/light_out_control_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class LightOutControlSelect : public select::Select, public Parented { +class LightOutControlSelect final : public select::Select, public Parented { public: LightOutControlSelect() = default; diff --git a/esphome/components/ld2410/switch/bluetooth_switch.h b/esphome/components/ld2410/switch/bluetooth_switch.h index 07804e2292..cc56b2cda0 100644 --- a/esphome/components/ld2410/switch/bluetooth_switch.h +++ b/esphome/components/ld2410/switch/bluetooth_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class BluetoothSwitch : public switch_::Switch, public Parented { +class BluetoothSwitch final : public switch_::Switch, public Parented { public: BluetoothSwitch() = default; diff --git a/esphome/components/ld2410/switch/engineering_mode_switch.h b/esphome/components/ld2410/switch/engineering_mode_switch.h index 4dd8e16653..49243a73ad 100644 --- a/esphome/components/ld2410/switch/engineering_mode_switch.h +++ b/esphome/components/ld2410/switch/engineering_mode_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class EngineeringModeSwitch : public switch_::Switch, public Parented { +class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: EngineeringModeSwitch() = default; diff --git a/esphome/components/ld2412/button/factory_reset_button.h b/esphome/components/ld2412/button/factory_reset_button.h index 1ef6b23b80..a6ea8f7365 100644 --- a/esphome/components/ld2412/button/factory_reset_button.h +++ b/esphome/components/ld2412/button/factory_reset_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class FactoryResetButton : public button::Button, public Parented { +class FactoryResetButton final : public button::Button, public Parented { public: FactoryResetButton() = default; diff --git a/esphome/components/ld2412/button/query_button.h b/esphome/components/ld2412/button/query_button.h index 373e135802..71e2ab14e8 100644 --- a/esphome/components/ld2412/button/query_button.h +++ b/esphome/components/ld2412/button/query_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class QueryButton : public button::Button, public Parented { +class QueryButton final : public button::Button, public Parented { public: QueryButton() = default; diff --git a/esphome/components/ld2412/button/restart_button.h b/esphome/components/ld2412/button/restart_button.h index 80c79f5e7d..668ce1a8e6 100644 --- a/esphome/components/ld2412/button/restart_button.h +++ b/esphome/components/ld2412/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h index b1f2127896..3b24f5dcf8 100644 --- a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h +++ b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class StartDynamicBackgroundCorrectionButton : public button::Button, public Parented { +class StartDynamicBackgroundCorrectionButton final : public button::Button, public Parented { public: StartDynamicBackgroundCorrectionButton() = default; diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 306e7ae31d..f722f938ae 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -36,7 +36,7 @@ using namespace ld24xx; static constexpr uint8_t MAX_LINE_LENGTH = 54; // Max characters for serial buffer static constexpr uint8_t TOTAL_GATES = 14; // Total number of gates supported by the LD2412 -class LD2412Component : public Component, public uart::UARTDevice { +class LD2412Component final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(dynamic_background_correction_status) SUB_BINARY_SENSOR(moving_target) diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 78c2e54d82..918b6dfad1 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class GateThresholdNumber : public number::Number, public Parented { +class GateThresholdNumber final : public number::Number, public Parented { public: GateThresholdNumber(uint8_t gate); diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index 81fd73111c..f62d523af3 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class LightThresholdNumber : public number::Number, public Parented { +class LightThresholdNumber final : public number::Number, public Parented { public: LightThresholdNumber() = default; diff --git a/esphome/components/ld2412/number/max_distance_timeout_number.h b/esphome/components/ld2412/number/max_distance_timeout_number.h index c1e947fa19..4a3478d48a 100644 --- a/esphome/components/ld2412/number/max_distance_timeout_number.h +++ b/esphome/components/ld2412/number/max_distance_timeout_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class MaxDistanceTimeoutNumber : public number::Number, public Parented { +class MaxDistanceTimeoutNumber final : public number::Number, public Parented { public: MaxDistanceTimeoutNumber() = default; diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index 4666dd2fa0..46ec9be1d1 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class BaudRateSelect : public select::Select, public Parented { +class BaudRateSelect final : public select::Select, public Parented { public: BaudRateSelect() = default; diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index d3b7fad2f9..be8dba90b5 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class DistanceResolutionSelect : public select::Select, public Parented { +class DistanceResolutionSelect final : public select::Select, public Parented { public: DistanceResolutionSelect() = default; diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index 9f86189878..c8988fda78 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class LightOutControlSelect : public select::Select, public Parented { +class LightOutControlSelect final : public select::Select, public Parented { public: LightOutControlSelect() = default; diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 0c0d1fa550..8fd4a86e43 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class BluetoothSwitch : public switch_::Switch, public Parented { +class BluetoothSwitch final : public switch_::Switch, public Parented { public: BluetoothSwitch() = default; diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index 4e75a8a185..defeb4c76b 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class EngineeringModeSwitch : public switch_::Switch, public Parented { +class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: EngineeringModeSwitch() = default; diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index bf5cdb9305..b0a243f2e4 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -13,7 +13,7 @@ namespace esphome::ledc { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern uint8_t next_ledc_channel; -class LEDCOutput : public output::FloatOutput, public Component { +class LEDCOutput final : public output::FloatOutput, public Component { public: explicit LEDCOutput(InternalGPIOPin *pin) : pin_(pin) { this->channel_ = next_ledc_channel++; } @@ -43,7 +43,7 @@ class LEDCOutput : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(LEDCOutput *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/libretiny/gpio_arduino.h b/esphome/components/libretiny/gpio_arduino.h index 5f1fa3fec7..da477fde36 100644 --- a/esphome/components/libretiny/gpio_arduino.h +++ b/esphome/components/libretiny/gpio_arduino.h @@ -5,7 +5,7 @@ namespace esphome::libretiny { -class ArduinoInternalGPIOPin : public InternalGPIOPin { +class ArduinoInternalGPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/libretiny/lt_component.h b/esphome/components/libretiny/lt_component.h index 896f1901e3..3850a679b2 100644 --- a/esphome/components/libretiny/lt_component.h +++ b/esphome/components/libretiny/lt_component.h @@ -14,7 +14,7 @@ namespace esphome::libretiny { -class LTComponent : public Component { +class LTComponent final : public Component { public: float get_setup_priority() const override; void dump_config() override; diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.h b/esphome/components/libretiny_pwm/libretiny_pwm.h index f7737be386..f0ea0228b7 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.h +++ b/esphome/components/libretiny_pwm/libretiny_pwm.h @@ -9,7 +9,7 @@ namespace esphome::libretiny_pwm { -class LibreTinyPWM : public output::FloatOutput, public Component { +class LibreTinyPWM final : public output::FloatOutput, public Component { public: explicit LibreTinyPWM(InternalGPIOPin *pin) : pin_(pin) {} @@ -34,7 +34,7 @@ class LibreTinyPWM : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(LibreTinyPWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 0202ad380a..57e9caf289 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -21,7 +21,7 @@ Color color_from_light_color_values(LightColorValues val); /// Use a custom state class for addressable lights, to allow type system to discriminate between addressable and /// non-addressable lights. -class AddressableLightState : public LightState { +class AddressableLightState final : public LightState { using LightState::LightState; }; diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 260414f033..ced15dfc60 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -8,7 +8,7 @@ namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(LightState *state) : state_(state) {} @@ -43,7 +43,7 @@ template class ToggleAction : public A // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class LightControlAction : public Action { +template class LightControlAction final : public Action { public: using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t &...); LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} @@ -59,7 +59,7 @@ template class LightControlAction : public Action { ApplyFn apply_; }; -template class DimRelativeAction : public Action { +template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} @@ -108,7 +108,7 @@ template class DimRelativeAction : pub // at compile time so the chosen branch is the only one that gets instantiated // per action site. `include_none` is runtime so a single set of templates // covers both the "wrap through None" and "skip None" variants. -template class LightEffectCycleAction : public Action { +template class LightEffectCycleAction final : public Action { public: explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {} @@ -145,7 +145,7 @@ template class LightEffectCycleAction : public Act bool include_none_{false}; }; -template class LightIsOnCondition : public Condition { +template class LightIsOnCondition final : public Condition { public: explicit LightIsOnCondition(LightState *state) : state_(state) {} bool check(const Ts &...x) override { return this->state_->current_values.is_on(); } @@ -153,7 +153,7 @@ template class LightIsOnCondition : public Condition { protected: LightState *state_; }; -template class LightIsOffCondition : public Condition { +template class LightIsOffCondition final : public Condition { public: explicit LightIsOffCondition(LightState *state) : state_(state) {} bool check(const Ts &...x) override { return !this->state_->current_values.is_on(); } @@ -162,7 +162,7 @@ template class LightIsOffCondition : public Condition { LightState *state_; }; -class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { +class LightTurnOnTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightTurnOnTrigger(LightState *a_light) : light_(a_light) { a_light->add_remote_values_listener(this); @@ -187,7 +187,7 @@ class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { bool last_on_; }; -class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedListener { +class LightTurnOffTrigger final : public Trigger<>, public LightTargetStateReachedListener { public: explicit LightTurnOffTrigger(LightState *a_light) : light_(a_light) { a_light->add_target_state_reached_listener(this); @@ -205,7 +205,7 @@ class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedList LightState *light_; }; -class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { +class LightStateTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightStateTrigger(LightState *a_light) { a_light->add_remote_values_listener(this); } @@ -216,7 +216,7 @@ class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { // due to the template. It's just a temporary warning anyway. void addressableset_warn_about_scale(const char *field); -template class AddressableSet : public Action { +template class AddressableSet final : public Action { public: explicit AddressableSet(LightState *parent) : parent_(parent) {} diff --git a/esphome/components/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index 224da6315f..36dac3c86f 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -15,7 +15,7 @@ namespace esphome::lightwaverf { #ifdef USE_ESP8266 -class LightWaveRF : public PollingComponent { +class LightWaveRF final : public PollingComponent { public: void set_pin(InternalGPIOPin *pin_tx, InternalGPIOPin *pin_rx) { pin_tx_ = pin_tx; @@ -37,7 +37,7 @@ class LightWaveRF : public PollingComponent { LwTx lwtx_; }; -template class SendRawAction : public Action { +template class SendRawAction final : public Action { public: SendRawAction(LightWaveRF *parent) : parent_(parent){}; TEMPLATABLE_VALUE(int, repeat); diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h index 8b345515ab..ad82e6c3a0 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h @@ -12,7 +12,7 @@ namespace esphome::lilygo_t5_47 { using namespace touchscreen; -class LilygoT547Touchscreen : public Touchscreen, public i2c::I2CDevice { +class LilygoT547Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; diff --git a/esphome/components/lm75b/lm75b.h b/esphome/components/lm75b/lm75b.h index eaf1b46550..3d5b97ae5b 100644 --- a/esphome/components/lm75b/lm75b.h +++ b/esphome/components/lm75b/lm75b.h @@ -8,7 +8,7 @@ namespace esphome::lm75b { static const uint8_t LM75B_REG_TEMPERATURE = 0x00; -class LM75BComponent : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class LM75BComponent final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void dump_config() override; void update() override; diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index c140bc568f..ec6ead79f3 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -6,7 +6,7 @@ namespace esphome::lock { -template class LockAction : public Action { +template class LockAction final : public Action { public: explicit LockAction(Lock *a_lock) : lock_(a_lock) {} @@ -16,7 +16,7 @@ template class LockAction : public Action { Lock *lock_; }; -template class UnlockAction : public Action { +template class UnlockAction final : public Action { public: explicit UnlockAction(Lock *a_lock) : lock_(a_lock) {} @@ -26,7 +26,7 @@ template class UnlockAction : public Action { Lock *lock_; }; -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Lock *a_lock) : lock_(a_lock) {} @@ -36,7 +36,7 @@ template class OpenAction : public Action { Lock *lock_; }; -template class LockCondition : public Condition { +template class LockCondition final : public Condition { public: LockCondition(Lock *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { diff --git a/esphome/components/lps22/lps22.h b/esphome/components/lps22/lps22.h index c6746f2343..020c14296c 100644 --- a/esphome/components/lps22/lps22.h +++ b/esphome/components/lps22/lps22.h @@ -6,7 +6,7 @@ namespace esphome::lps22 { -class LPS22Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class LPS22Component final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index 75462ff1fb..47d6f55939 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -82,7 +82,7 @@ enum LSM6DSGyroODR : uint8_t { }; // ── Main component class ───────────────────────────────────────────────────── -class LSM6DSComponent : public motion::MotionComponent, public i2c::I2CDevice { +class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ltr390/ltr390.h b/esphome/components/ltr390/ltr390.h index 1ead84b4a8..1e3b6494bb 100644 --- a/esphome/components/ltr390/ltr390.h +++ b/esphome/components/ltr390/ltr390.h @@ -39,7 +39,7 @@ enum LTR390RESOLUTION { LTR390_RESOLUTION_13BIT, }; -class LTR390Component : public PollingComponent, public i2c::I2CDevice { +class LTR390Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ltr501/ltr501.h b/esphome/components/ltr501/ltr501.h index c7eccbeea9..d1f7648d4c 100644 --- a/esphome/components/ltr501/ltr501.h +++ b/esphome/components/ltr501/ltr501.h @@ -19,7 +19,7 @@ enum LtrType : uint8_t { LTR_TYPE_ALS_AND_PS = 3, }; -class LTRAlsPs501Component : public PollingComponent, public i2c::I2CDevice { +class LTRAlsPs501Component final : public PollingComponent, public i2c::I2CDevice { public: // // EspHome framework functions diff --git a/esphome/components/lvgl/light/lvgl_light.h b/esphome/components/lvgl/light/lvgl_light.h index bf019964c7..37dc4135ce 100644 --- a/esphome/components/lvgl/light/lvgl_light.h +++ b/esphome/components/lvgl/light/lvgl_light.h @@ -6,7 +6,7 @@ namespace esphome::lvgl { -class LVLight : public light::LightOutput { +class LVLight final : public light::LightOutput { public: light::LightTraits get_traits() override { auto traits = light::LightTraits(); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3f7f1dce14..8840b0ad30 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -155,7 +155,7 @@ class LvPageType : public Parented { using event_callback_t = void(lv_event_t *); -class LvLambdaComponent : public Component { +class LvLambdaComponent final : public Component { public: LvLambdaComponent(void (*callback)()) : callback_(callback) {} @@ -167,7 +167,7 @@ class LvLambdaComponent : public Component { void (*callback_)(); }; -template class ObjUpdateAction : public Action { +template class ObjUpdateAction final : public Action { public: explicit ObjUpdateAction(std::function &&lamb) : lamb_(std::move(lamb)) {} @@ -185,7 +185,7 @@ enum RotationType : uint8_t { ROTATION_HARDWARE, }; -class LvglComponent : public PollingComponent { +class LvglComponent final : public PollingComponent { constexpr static const char *const TAG = "lvgl"; public: @@ -339,7 +339,7 @@ class LvglComponent : public PollingComponent { #endif }; -class IdleTrigger : public Trigger<> { +class IdleTrigger final : public Trigger<> { public: explicit IdleTrigger(LvglComponent *parent, TemplatableFn timeout); @@ -348,7 +348,7 @@ class IdleTrigger : public Trigger<> { bool is_idle_{}; }; -template class LvglAction : public Action, public Parented { +template class LvglAction final : public Action, public Parented { public: explicit LvglAction(std::function &&lamb) : action_(std::move(lamb)) {} @@ -357,7 +357,7 @@ template class LvglAction : public Action, public Parente std::function action_{}; }; -template class LvglCondition : public Condition, public Parented { +template class LvglCondition final : public Condition, public Parented { public: LvglCondition(std::function &&condition_lambda) : condition_lambda_(std::move(condition_lambda)) {} bool check(const Ts &...x) override { return this->condition_lambda_(this->parent_); } @@ -367,7 +367,7 @@ template class LvglCondition : public Condition { +class LVTouchListener final : public touchscreen::TouchListener, public Parented { public: LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent); void update(const touchscreen::TouchPoints_t &tpoints) override; @@ -403,7 +403,7 @@ class IndicatorLine : public LvCompound { #endif #ifdef USE_LVGL_KEY_LISTENER -class LVEncoderListener : public Parented { +class LVEncoderListener final : public Parented { public: LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time); diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 3fda9427c5..eb2f70b4da 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -8,7 +8,7 @@ namespace esphome::lvgl { -class LVGLNumber : public number::Number, public Component { +class LVGLNumber final : public number::Number, public Component { public: LVGLNumber(std::function control_lambda, std::function value_lambda, bool restore) : control_lambda_(std::move(control_lambda)), value_lambda_(std::move(value_lambda)), restore_(restore) {} diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index ffbe29d701..e36357328c 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -10,7 +10,7 @@ namespace esphome::lvgl { -class LVGLSelect : public select::Select, public Component { +class LVGLSelect final : public select::Select, public Component { public: LVGLSelect(LvSelectable *widget, lv_anim_enable_t anim, bool restore) : widget_(widget), anim_(anim), restore_(restore) {} diff --git a/esphome/components/lvgl/switch/lvgl_switch.h b/esphome/components/lvgl/switch/lvgl_switch.h index 8f5502a7d5..ea15767ba8 100644 --- a/esphome/components/lvgl/switch/lvgl_switch.h +++ b/esphome/components/lvgl/switch/lvgl_switch.h @@ -9,7 +9,7 @@ namespace esphome::lvgl { -class LVGLSwitch : public switch_::Switch, public Component { +class LVGLSwitch final : public switch_::Switch, public Component { public: LVGLSwitch(std::function state_lambda) : state_lambda_(std::move(state_lambda)) {} diff --git a/esphome/components/lvgl/text/lvgl_text.h b/esphome/components/lvgl/text/lvgl_text.h index fead48d6fe..8d83f323ab 100644 --- a/esphome/components/lvgl/text/lvgl_text.h +++ b/esphome/components/lvgl/text/lvgl_text.h @@ -6,7 +6,7 @@ namespace esphome::lvgl { -class LVGLText : public text::Text { +class LVGLText final : public text::Text { public: void set_control_lambda(const std::function &control_lambda) { this->control_lambda_ = control_lambda; diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h index 14400bcea1..a49f6dbb54 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h @@ -7,9 +7,9 @@ namespace esphome::m5stack_8angle { -class M5Stack8AngleSwitchBinarySensor : public binary_sensor::BinarySensor, - public PollingComponent, - public Parented { +class M5Stack8AngleSwitchBinarySensor final : public binary_sensor::BinarySensor, + public PollingComponent, + public Parented { public: void update() override; }; diff --git a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h index 0a5a50f2a8..ee204c239b 100644 --- a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h +++ b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h @@ -10,7 +10,7 @@ namespace esphome::m5stack_8angle { static const uint8_t M5STACK_8ANGLE_NUM_LEDS = 9; static const uint8_t M5STACK_8ANGLE_BYTES_PER_LED = 4; -class M5Stack8AngleLightOutput : public light::AddressableLight, public Parented { +class M5Stack8AngleLightOutput final : public light::AddressableLight, public Parented { public: void setup() override; diff --git a/esphome/components/m5stack_8angle/m5stack_8angle.h b/esphome/components/m5stack_8angle/m5stack_8angle.h index ab2e232204..058949cad5 100644 --- a/esphome/components/m5stack_8angle/m5stack_8angle.h +++ b/esphome/components/m5stack_8angle/m5stack_8angle.h @@ -16,7 +16,7 @@ enum AnalogBits : uint8_t { BITS_12 = 12, }; -class M5Stack8AngleComponent : public i2c::I2CDevice, public Component { +class M5Stack8AngleComponent final : public i2c::I2CDevice, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h index 418503d7c8..a270661ad6 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h @@ -7,9 +7,9 @@ namespace esphome::m5stack_8angle { -class M5Stack8AngleKnobSensor : public sensor::Sensor, - public PollingComponent, - public Parented { +class M5Stack8AngleKnobSensor final : public sensor::Sensor, + public PollingComponent, + public Parented { public: void update() override; void set_channel(uint8_t channel) { this->channel_ = channel; }; From 089147328057c1dcb82d652bd7a6ac77f9537d3f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:34 +1200 Subject: [PATCH 1771/2214] Mark configurable classes as final (10/21: matrix_keypad-micronova) (#16961) --- .../matrix_keypad_binary_sensor.h | 2 +- .../components/matrix_keypad/matrix_keypad.h | 4 ++-- esphome/components/max17043/automation.h | 2 +- esphome/components/max17043/max17043.h | 2 +- esphome/components/max31855/max31855.h | 8 ++++---- esphome/components/max31856/max31856.h | 8 ++++---- esphome/components/max31865/max31865.h | 8 ++++---- esphome/components/max44009/max44009.h | 2 +- esphome/components/max6675/max6675.h | 8 ++++---- esphome/components/max6956/automation.h | 4 ++-- esphome/components/max6956/max6956.h | 4 ++-- .../max6956/output/max6956_led_output.h | 2 +- esphome/components/max7219/max7219.h | 6 +++--- esphome/components/max7219digit/automation.h | 8 ++++---- .../components/max7219digit/max7219digit.h | 6 +++--- esphome/components/max9611/max9611.h | 2 +- esphome/components/mcp23008/mcp23008.h | 2 +- esphome/components/mcp23016/mcp23016.h | 4 ++-- esphome/components/mcp23017/mcp23017.h | 2 +- esphome/components/mcp23s08/mcp23s08.h | 6 +++--- esphome/components/mcp23s17/mcp23s17.h | 6 +++--- .../components/mcp23xxx_base/mcp23xxx_base.h | 2 +- esphome/components/mcp2515/mcp2515.h | 6 +++--- esphome/components/mcp3008/mcp3008.h | 8 ++++---- .../mcp3008/sensor/mcp3008_sensor.h | 8 ++++---- esphome/components/mcp3204/mcp3204.h | 6 +++--- .../mcp3204/sensor/mcp3204_sensor.h | 8 ++++---- esphome/components/mcp3221/mcp3221_sensor.h | 8 ++++---- esphome/components/mcp4461/mcp4461.h | 2 +- .../mcp4461/output/mcp4461_output.h | 2 +- esphome/components/mcp4725/mcp4725.h | 2 +- esphome/components/mcp4728/mcp4728.h | 2 +- .../mcp4728/output/mcp4728_output.h | 2 +- esphome/components/mcp47a1/mcp47a1.h | 2 +- esphome/components/mcp9600/mcp9600.h | 2 +- esphome/components/mcp9808/mcp9808.h | 2 +- esphome/components/media_player/automation.h | 20 +++++++++---------- esphome/components/mhz19/mhz19.h | 11 +++++----- .../micronova/button/micronova_button.h | 2 +- esphome/components/micronova/micronova.h | 2 +- .../micronova/number/micronova_number.h | 2 +- .../micronova/sensor/micronova_sensor.h | 2 +- .../micronova/switch/micronova_switch.h | 2 +- .../text_sensor/micronova_text_sensor.h | 2 +- 44 files changed, 101 insertions(+), 100 deletions(-) diff --git a/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h b/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h index 53ae0b5c03..000a9e5de3 100644 --- a/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h +++ b/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::matrix_keypad { -class MatrixKeypadBinarySensor : public MatrixKeypadListener, public binary_sensor::BinarySensorInitiallyOff { +class MatrixKeypadBinarySensor final : public MatrixKeypadListener, public binary_sensor::BinarySensorInitiallyOff { public: MatrixKeypadBinarySensor(uint8_t key) : has_key_(true), key_(key){}; MatrixKeypadBinarySensor(const char *key) : has_key_(true), key_((uint8_t) key[0]){}; diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 1e263842ea..8c9acc8e0c 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -18,9 +18,9 @@ class MatrixKeypadListener { virtual void key_released(uint8_t key){}; }; -class MatrixKeyTrigger : public Trigger {}; +class MatrixKeyTrigger final : public Trigger {}; -class MatrixKeypad : public key_provider::KeyProvider, public Component { +class MatrixKeypad final : public key_provider::KeyProvider, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/max17043/automation.h b/esphome/components/max17043/automation.h index c98516d259..6b19e5bd5e 100644 --- a/esphome/components/max17043/automation.h +++ b/esphome/components/max17043/automation.h @@ -5,7 +5,7 @@ namespace esphome::max17043 { -template class SleepAction : public Action { +template class SleepAction final : public Action { public: explicit SleepAction(MAX17043Component *max17043) : max17043_(max17043) {} diff --git a/esphome/components/max17043/max17043.h b/esphome/components/max17043/max17043.h index dd2e35df55..ffe4d916ba 100644 --- a/esphome/components/max17043/max17043.h +++ b/esphome/components/max17043/max17043.h @@ -6,7 +6,7 @@ namespace esphome::max17043 { -class MAX17043Component : public PollingComponent, public i2c::I2CDevice { +class MAX17043Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max31855/max31855.h b/esphome/components/max31855/max31855.h index dd7a205268..527d26f99d 100644 --- a/esphome/components/max31855/max31855.h +++ b/esphome/components/max31855/max31855.h @@ -8,10 +8,10 @@ namespace esphome::max31855 { -class MAX31855Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31855Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void set_reference_sensor(sensor::Sensor *temperature_sensor) { temperature_reference_ = temperature_sensor; } diff --git a/esphome/components/max31856/max31856.h b/esphome/components/max31856/max31856.h index 0a983b72d9..83aa815aa0 100644 --- a/esphome/components/max31856/max31856.h +++ b/esphome/components/max31856/max31856.h @@ -68,10 +68,10 @@ enum MAX31856ConfigFilter { FILTER_50HZ = 1, }; -class MAX31856Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31856Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max31865/max31865.h b/esphome/components/max31865/max31865.h index 3362cd30de..27c107ba0b 100644 --- a/esphome/components/max31865/max31865.h +++ b/esphome/components/max31865/max31865.h @@ -22,10 +22,10 @@ enum MAX31865ConfigFilter { FILTER_50HZ = 1, }; -class MAX31865Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31865Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void set_reference_resistance(float reference_resistance) { reference_resistance_ = reference_resistance; } void set_nominal_resistance(float nominal_resistance) { rtd_nominal_resistance_ = nominal_resistance; } diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index 12fd0b1ce0..b62aed7a56 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -9,7 +9,7 @@ namespace esphome::max44009 { enum MAX44009Mode { MAX44009_MODE_AUTO, MAX44009_MODE_LOW_POWER, MAX44009_MODE_CONTINUOUS }; /// This class implements support for the MAX44009 Illuminance i2c sensor. -class MAX44009Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class MAX44009Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: MAX44009Sensor() {} diff --git a/esphome/components/max6675/max6675.h b/esphome/components/max6675/max6675.h index e7b5c4dbde..fc46c8c047 100644 --- a/esphome/components/max6675/max6675.h +++ b/esphome/components/max6675/max6675.h @@ -6,10 +6,10 @@ namespace esphome::max6675 { -class MAX6675Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX6675Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max6956/automation.h b/esphome/components/max6956/automation.h index 547ed5a865..f1db2e3240 100644 --- a/esphome/components/max6956/automation.h +++ b/esphome/components/max6956/automation.h @@ -6,7 +6,7 @@ namespace esphome::max6956 { -template class SetCurrentGlobalAction : public Action { +template class SetCurrentGlobalAction final : public Action { public: SetCurrentGlobalAction(MAX6956 *max6956) : max6956_(max6956) {} @@ -21,7 +21,7 @@ template class SetCurrentGlobalAction : public Action { MAX6956 *max6956_; }; -template class SetCurrentModeAction : public Action { +template class SetCurrentModeAction final : public Action { public: SetCurrentModeAction(MAX6956 *max6956) : max6956_(max6956) {} diff --git a/esphome/components/max6956/max6956.h b/esphome/components/max6956/max6956.h index 83ccfab559..4dbee16528 100644 --- a/esphome/components/max6956/max6956.h +++ b/esphome/components/max6956/max6956.h @@ -35,7 +35,7 @@ enum MAX6956GPIOFlag { FLAG_LED = 0x20 }; enum MAX6956CURRENTMODE { GLOBAL = 0x00, SEGMENT = 0x01 }; -class MAX6956 : public Component, public i2c::I2CDevice { +class MAX6956 final : public Component, public i2c::I2CDevice { public: MAX6956() = default; @@ -69,7 +69,7 @@ class MAX6956 : public Component, public i2c::I2CDevice { int8_t prev_bright_[28] = {0}; }; -class MAX6956GPIOPin : public GPIOPin { +class MAX6956GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/max6956/output/max6956_led_output.h b/esphome/components/max6956/output/max6956_led_output.h index 49e5b9ef84..c40e41371d 100644 --- a/esphome/components/max6956/output/max6956_led_output.h +++ b/esphome/components/max6956/output/max6956_led_output.h @@ -7,7 +7,7 @@ namespace esphome::max6956 { class MAX6956; -class MAX6956LedChannel : public output::FloatOutput, public Component { +class MAX6956LedChannel final : public output::FloatOutput, public Component { public: void set_parent(MAX6956 *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/max7219/max7219.h b/esphome/components/max7219/max7219.h index ef38628f28..3eb4b8e27f 100644 --- a/esphome/components/max7219/max7219.h +++ b/esphome/components/max7219/max7219.h @@ -12,9 +12,9 @@ class MAX7219Component; using max7219_writer_t = display::DisplayWriter; -class MAX7219Component : public PollingComponent, - public spi::SPIDevice { +class MAX7219Component final : public PollingComponent, + public spi::SPIDevice { public: explicit MAX7219Component(uint8_t num_chips); diff --git a/esphome/components/max7219digit/automation.h b/esphome/components/max7219digit/automation.h index 485a34075e..f06dfd5087 100644 --- a/esphome/components/max7219digit/automation.h +++ b/esphome/components/max7219digit/automation.h @@ -7,7 +7,7 @@ namespace esphome::max7219digit { -template class DisplayInvertAction : public Action, public Parented { +template class DisplayInvertAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -17,7 +17,7 @@ template class DisplayInvertAction : public Action, publi } }; -template class DisplayVisibilityAction : public Action, public Parented { +template class DisplayVisibilityAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -27,7 +27,7 @@ template class DisplayVisibilityAction : public Action, p } }; -template class DisplayReverseAction : public Action, public Parented { +template class DisplayReverseAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -37,7 +37,7 @@ template class DisplayReverseAction : public Action, publ } }; -template class DisplayIntensityAction : public Action, public Parented { +template class DisplayIntensityAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, state) diff --git a/esphome/components/max7219digit/max7219digit.h b/esphome/components/max7219digit/max7219digit.h index bbf43059dd..9e6db20444 100644 --- a/esphome/components/max7219digit/max7219digit.h +++ b/esphome/components/max7219digit/max7219digit.h @@ -24,9 +24,9 @@ class MAX7219Component; using max7219_writer_t = display::DisplayWriter; -class MAX7219Component : public display::DisplayBuffer, - public spi::SPIDevice { +class MAX7219Component final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_writer(max7219_writer_t &&writer) { this->writer_local_ = writer; }; diff --git a/esphome/components/max9611/max9611.h b/esphome/components/max9611/max9611.h index b6fb5d8127..54e6414c79 100644 --- a/esphome/components/max9611/max9611.h +++ b/esphome/components/max9611/max9611.h @@ -33,7 +33,7 @@ enum MAX9611RegisterMap { CONTROL_REGISTER_2_ADRR = 0x0B, }; -class MAX9611Component : public PollingComponent, public i2c::I2CDevice { +class MAX9611Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp23008/mcp23008.h b/esphome/components/mcp23008/mcp23008.h index ae2f9e1f3c..38bd9c1ac4 100644 --- a/esphome/components/mcp23008/mcp23008.h +++ b/esphome/components/mcp23008/mcp23008.h @@ -7,7 +7,7 @@ namespace esphome::mcp23008 { -class MCP23008 : public mcp23x08_base::MCP23X08Base, public i2c::I2CDevice { +class MCP23008 final : public mcp23x08_base::MCP23X08Base, public i2c::I2CDevice { public: MCP23008() = default; diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 4a936a5b02..14c0c9a2fc 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -24,7 +24,7 @@ enum MCP23016GPIORegisters { MCP23016_IOCON1 = 0x0B, }; -class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { +class MCP23016 final : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { public: MCP23016() = default; @@ -56,7 +56,7 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander:: InternalGPIOPin *interrupt_pin_{nullptr}; }; -class MCP23016GPIOPin : public GPIOPin { +class MCP23016GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mcp23017/mcp23017.h b/esphome/components/mcp23017/mcp23017.h index 86b84f9ad8..c745322bdf 100644 --- a/esphome/components/mcp23017/mcp23017.h +++ b/esphome/components/mcp23017/mcp23017.h @@ -7,7 +7,7 @@ namespace esphome::mcp23017 { -class MCP23017 : public mcp23x17_base::MCP23X17Base, public i2c::I2CDevice { +class MCP23017 final : public mcp23x17_base::MCP23X17Base, public i2c::I2CDevice { public: MCP23017() = default; diff --git a/esphome/components/mcp23s08/mcp23s08.h b/esphome/components/mcp23s08/mcp23s08.h index 441525469f..270d1467e2 100644 --- a/esphome/components/mcp23s08/mcp23s08.h +++ b/esphome/components/mcp23s08/mcp23s08.h @@ -7,9 +7,9 @@ namespace esphome::mcp23s08 { -class MCP23S08 : public mcp23x08_base::MCP23X08Base, - public spi::SPIDevice { +class MCP23S08 final : public mcp23x08_base::MCP23X08Base, + public spi::SPIDevice { public: MCP23S08() = default; diff --git a/esphome/components/mcp23s17/mcp23s17.h b/esphome/components/mcp23s17/mcp23s17.h index 0cc9321c88..5346b2c8e2 100644 --- a/esphome/components/mcp23s17/mcp23s17.h +++ b/esphome/components/mcp23s17/mcp23s17.h @@ -7,9 +7,9 @@ namespace esphome::mcp23s17 { -class MCP23S17 : public mcp23x17_base::MCP23X17Base, - public spi::SPIDevice { +class MCP23S17 final : public mcp23x17_base::MCP23X17Base, + public spi::SPIDevice { public: MCP23S17() = default; diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 5904a1eef6..1c45b0f4af 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -56,7 +56,7 @@ template class MCP23XXXBase : public Component, public gpio_expander: InternalGPIOPin *interrupt_pin_{nullptr}; }; -template class MCP23XXXGPIOPin : public GPIOPin { +template class MCP23XXXGPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mcp2515/mcp2515.h b/esphome/components/mcp2515/mcp2515.h index b77d9a2582..960e150800 100644 --- a/esphome/components/mcp2515/mcp2515.h +++ b/esphome/components/mcp2515/mcp2515.h @@ -51,9 +51,9 @@ enum STAT : uint8_t { STAT_RX0IF = (1 << 0), STAT_RX1IF = (1 << 1) }; static const uint8_t STAT_RXIF_MASK = STAT_RX0IF | STAT_RX1IF; static const uint8_t EFLG_ERRORMASK = EFLG_RX1OVR | EFLG_RX0OVR | EFLG_TXBO | EFLG_TXEP | EFLG_RXEP; -class MCP2515 : public canbus::Canbus, - public spi::SPIDevice { +class MCP2515 final : public canbus::Canbus, + public spi::SPIDevice { public: MCP2515(){}; void set_mcp_clock(CanClock clock) { this->mcp_clock_ = clock; }; diff --git a/esphome/components/mcp3008/mcp3008.h b/esphome/components/mcp3008/mcp3008.h index 1b1b50c793..d45d587ae8 100644 --- a/esphome/components/mcp3008/mcp3008.h +++ b/esphome/components/mcp3008/mcp3008.h @@ -6,10 +6,10 @@ namespace esphome::mcp3008 { -class MCP3008 : public Component, - public spi::SPIDevice { // Running at the slowest max speed supported by the - // mcp3008. 2.7v = 75ksps +class MCP3008 final : public Component, + public spi::SPIDevice { // Running at the slowest max speed supported by + // the mcp3008. 2.7v = 75ksps public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp3008/sensor/mcp3008_sensor.h b/esphome/components/mcp3008/sensor/mcp3008_sensor.h index 9267f80ea8..d72d521f65 100644 --- a/esphome/components/mcp3008/sensor/mcp3008_sensor.h +++ b/esphome/components/mcp3008/sensor/mcp3008_sensor.h @@ -8,10 +8,10 @@ namespace esphome::mcp3008 { -class MCP3008Sensor : public PollingComponent, - public sensor::Sensor, - public voltage_sampler::VoltageSampler, - public Parented { +class MCP3008Sensor final : public PollingComponent, + public sensor::Sensor, + public voltage_sampler::VoltageSampler, + public Parented { public: void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } void set_pin(uint8_t pin) { this->pin_ = pin; } diff --git a/esphome/components/mcp3204/mcp3204.h b/esphome/components/mcp3204/mcp3204.h index 8ce592f386..6b835b67df 100644 --- a/esphome/components/mcp3204/mcp3204.h +++ b/esphome/components/mcp3204/mcp3204.h @@ -6,9 +6,9 @@ namespace esphome::mcp3204 { -class MCP3204 : public Component, - public spi::SPIDevice { +class MCP3204 final : public Component, + public spi::SPIDevice { public: MCP3204() = default; diff --git a/esphome/components/mcp3204/sensor/mcp3204_sensor.h b/esphome/components/mcp3204/sensor/mcp3204_sensor.h index 5fe5f54d1b..54835c232b 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.h +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.h @@ -9,10 +9,10 @@ namespace esphome::mcp3204 { -class MCP3204Sensor : public PollingComponent, - public Parented, - public sensor::Sensor, - public voltage_sampler::VoltageSampler { +class MCP3204Sensor final : public PollingComponent, + public Parented, + public sensor::Sensor, + public voltage_sampler::VoltageSampler { public: MCP3204Sensor(uint8_t pin, bool differential_mode) : pin_(pin), differential_mode_(differential_mode) {} diff --git a/esphome/components/mcp3221/mcp3221_sensor.h b/esphome/components/mcp3221/mcp3221_sensor.h index deef14e14d..38b62c609f 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.h +++ b/esphome/components/mcp3221/mcp3221_sensor.h @@ -10,10 +10,10 @@ namespace esphome::mcp3221 { -class MCP3221Sensor : public sensor::Sensor, - public PollingComponent, - public voltage_sampler::VoltageSampler, - public i2c::I2CDevice { +class MCP3221Sensor final : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public i2c::I2CDevice { public: void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } void update() override; diff --git a/esphome/components/mcp4461/mcp4461.h b/esphome/components/mcp4461/mcp4461.h index 3a76f855b8..a577a4b482 100644 --- a/esphome/components/mcp4461/mcp4461.h +++ b/esphome/components/mcp4461/mcp4461.h @@ -57,7 +57,7 @@ enum class Mcp4461TerminalIdx : uint8_t { MCP4461_TERMINAL_0 = 0, MCP4461_TERMIN class Mcp4461Wiper; // Mcp4461Component -class Mcp4461Component : public Component, public i2c::I2CDevice { +class Mcp4461Component final : public Component, public i2c::I2CDevice { public: Mcp4461Component(bool disable_wiper_0, bool disable_wiper_1, bool disable_wiper_2, bool disable_wiper_3) : wiper_0_disabled_(disable_wiper_0), diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 73eadceb50..20d81d825a 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -7,7 +7,7 @@ namespace esphome::mcp4461 { -class Mcp4461Wiper : public output::FloatOutput, public Parented { +class Mcp4461Wiper final : public output::FloatOutput, public Parented { public: Mcp4461Wiper(Mcp4461Component *parent, Mcp4461WiperIdx wiper) : parent_(parent), wiper_(wiper) {} /// @brief Set level of wiper diff --git a/esphome/components/mcp4725/mcp4725.h b/esphome/components/mcp4725/mcp4725.h index 1acefc3ee4..4f1f128e52 100644 --- a/esphome/components/mcp4725/mcp4725.h +++ b/esphome/components/mcp4725/mcp4725.h @@ -8,7 +8,7 @@ static const uint8_t MCP4725_ADDR = 0x60; static const uint8_t MCP4725_RES = 12; namespace esphome::mcp4725 { -class MCP4725 : public Component, public output::FloatOutput, public i2c::I2CDevice { +class MCP4725 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp4728/mcp4728.h b/esphome/components/mcp4728/mcp4728.h index 13076b3c4c..e7511e5237 100644 --- a/esphome/components/mcp4728/mcp4728.h +++ b/esphome/components/mcp4728/mcp4728.h @@ -38,7 +38,7 @@ struct DACInputData { class MCP4728Channel; /// MCP4728 float output component. -class MCP4728Component : public Component, public i2c::I2CDevice { +class MCP4728Component final : public Component, public i2c::I2CDevice { public: MCP4728Component(bool store_in_eeprom) : store_in_eeprom_(store_in_eeprom) {} diff --git a/esphome/components/mcp4728/output/mcp4728_output.h b/esphome/components/mcp4728/output/mcp4728_output.h index 3ea65ecc7b..827ce1517d 100644 --- a/esphome/components/mcp4728/output/mcp4728_output.h +++ b/esphome/components/mcp4728/output/mcp4728_output.h @@ -7,7 +7,7 @@ namespace esphome::mcp4728 { -class MCP4728Channel : public output::FloatOutput { +class MCP4728Channel final : public output::FloatOutput { public: MCP4728Channel(MCP4728Component *parent, MCP4728ChannelIdx channel, MCP4728Vref vref, MCP4728Gain gain, MCP4728PwrDown pwrdown) diff --git a/esphome/components/mcp47a1/mcp47a1.h b/esphome/components/mcp47a1/mcp47a1.h index da9794e5aa..b72c125574 100644 --- a/esphome/components/mcp47a1/mcp47a1.h +++ b/esphome/components/mcp47a1/mcp47a1.h @@ -6,7 +6,7 @@ namespace esphome::mcp47a1 { -class MCP47A1 : public Component, public output::FloatOutput, public i2c::I2CDevice { +class MCP47A1 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void dump_config() override; void write_state(float state) override; diff --git a/esphome/components/mcp9600/mcp9600.h b/esphome/components/mcp9600/mcp9600.h index b7c0c834ab..523c9ace2f 100644 --- a/esphome/components/mcp9600/mcp9600.h +++ b/esphome/components/mcp9600/mcp9600.h @@ -17,7 +17,7 @@ enum MCP9600ThermocoupleType : uint8_t { MCP9600_THERMOCOUPLE_TYPE_R = 0b111, }; -class MCP9600Component : public PollingComponent, public i2c::I2CDevice { +class MCP9600Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp9808/mcp9808.h b/esphome/components/mcp9808/mcp9808.h index 89530d9ed0..b4ae51bf6f 100644 --- a/esphome/components/mcp9808/mcp9808.h +++ b/esphome/components/mcp9808/mcp9808.h @@ -6,7 +6,7 @@ namespace esphome::mcp9808 { -class MCP9808Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class MCP9808Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 9319335872..899acfefdf 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -6,7 +6,7 @@ namespace esphome::media_player { template -class MediaPlayerCommandAction : public Action, public Parented { +class MediaPlayerCommandAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, announcement); void play(const Ts &...x) override { @@ -54,7 +54,7 @@ template using ClearPlaylistAction = MediaPlayerCommandAction; template -class MediaPlayerMediaAction : public Action, public Parented { +class MediaPlayerMediaAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, media_url) TEMPLATABLE_VALUE(bool, announcement) void play(const Ts &...x) override { @@ -70,7 +70,7 @@ using PlayMediaAction = MediaPlayerMediaAction using EnqueueMediaAction = MediaPlayerMediaAction; -template class VolumeSetAction : public Action, public Parented { +template class VolumeSetAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, volume) void play(const Ts &...x) override { this->parent_->make_call().set_volume(this->volume_.value(x...)).perform(); } }; @@ -97,39 +97,39 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class IsIdleCondition : public Condition, public Parented { +template class IsIdleCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_IDLE; } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING; } }; -template class IsPausedCondition : public Condition, public Parented { +template class IsPausedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PAUSED; } }; -template class IsAnnouncingCondition : public Condition, public Parented { +template class IsAnnouncingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING; } }; -template class IsOnCondition : public Condition, public Parented { +template class IsOnCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ON; } }; -template class IsOffCondition : public Condition, public Parented { +template class IsOffCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_OFF; } }; -template class IsMutedCondition : public Condition, public Parented { +template class IsMutedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_muted(); } }; diff --git a/esphome/components/mhz19/mhz19.h b/esphome/components/mhz19/mhz19.h index e577b98537..3cef3a3930 100644 --- a/esphome/components/mhz19/mhz19.h +++ b/esphome/components/mhz19/mhz19.h @@ -20,7 +20,7 @@ enum MHZ19DetectionRange { MHZ19_DETECTION_RANGE_0_10000PPM, }; -class MHZ19Component : public PollingComponent, public uart::UARTDevice { +class MHZ19Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -49,22 +49,23 @@ class MHZ19Component : public PollingComponent, public uart::UARTDevice { MHZ19DetectionRange detection_range_{MHZ19_DETECTION_RANGE_DEFAULT}; }; -template class MHZ19CalibrateZeroAction : public Action, public Parented { +template class MHZ19CalibrateZeroAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_zero(); } }; -template class MHZ19ABCEnableAction : public Action, public Parented { +template class MHZ19ABCEnableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->abc_enable(); } }; -template class MHZ19ABCDisableAction : public Action, public Parented { +template class MHZ19ABCDisableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->abc_disable(); } }; -template class MHZ19DetectionRangeSetAction : public Action, public Parented { +template +class MHZ19DetectionRangeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(MHZ19DetectionRange, detection_range) diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 0258dbb53c..9f8f66ee02 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -6,7 +6,7 @@ namespace esphome::micronova { -class MicroNovaButton : public Component, public button::Button, public MicroNovaBaseListener { +class MicroNovaButton final : public Component, public button::Button, public MicroNovaBaseListener { public: MicroNovaButton(MicroNova *m) : MicroNovaBaseListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index 58cca30b83..c57286db6c 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -58,7 +58,7 @@ class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent ///////////////////////////////////////////////////////////////////// // Main component class -class MicroNova : public Component, public uart::UARTDevice { +class MicroNova final : public Component, public uart::UARTDevice { public: MicroNova(GPIOPin *enable_rx_pin) : enable_rx_pin_(enable_rx_pin) {} diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 73666b632b..91e4253b46 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -5,7 +5,7 @@ namespace esphome::micronova { -class MicroNovaNumber : public number::Number, public MicroNovaListener { +class MicroNovaNumber final : public number::Number, public MicroNovaListener { public: MicroNovaNumber(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index f3b06d140e..8263ad0948 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -5,7 +5,7 @@ namespace esphome::micronova { -class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { +class MicroNovaSensor final : public sensor::Sensor, public MicroNovaListener { public: MicroNovaSensor(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index fee3c73976..4a4d5eb721 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -6,7 +6,7 @@ namespace esphome::micronova { -class MicroNovaSwitch : public switch_::Switch, public MicroNovaListener { +class MicroNovaSwitch final : public switch_::Switch, public MicroNovaListener { public: MicroNovaSwitch(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index 6918a372e8..2de93404a5 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -17,7 +17,7 @@ static const char *const STOVE_STATES[11] = {"Off", "No ignition alarm", "Undefined alarm"}; -class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaListener { +class MicroNovaTextSensor final : public text_sensor::TextSensor, public MicroNovaListener { public: MicroNovaTextSensor(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; From 9f5ed6fdfd3d4fc68c4444c94bac71522e8c4c19 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:45 +1200 Subject: [PATCH 1772/2214] Mark configurable classes as final (6/21) (#16957) --- .../esp32_ble_client/ble_characteristic.h | 2 +- .../components/esp32_ble_client/ble_service.h | 2 +- .../esp32_ble_server/ble_characteristic.h | 2 +- .../components/esp32_ble_server/ble_server.h | 2 +- .../esp32_ble_server/ble_server_automations.h | 6 ++--- .../components/esp32_ble_server/ble_service.h | 2 +- .../components/esp32_ble_tracker/automation.h | 12 ++++----- .../esp32_ble_tracker/esp32_ble_tracker.h | 6 ++--- .../components/esp32_camera/esp32_camera.h | 8 +++--- .../camera_web_server.h | 2 +- esphome/components/esp32_can/esp32_can.h | 2 +- esphome/components/esp32_dac/esp32_dac.h | 2 +- .../esp32_hosted/update/esp32_hosted_update.h | 2 +- esphome/components/esp32_improv/automation.h | 10 +++---- .../esp32_improv/esp32_improv_component.h | 2 +- .../esp32_rmt_led_strip/led_strip.h | 2 +- esphome/components/esp32_touch/esp32_touch.h | 2 +- esphome/components/esp8266/gpio.h | 2 +- esphome/components/esp8266_pwm/esp8266_pwm.h | 4 +-- esphome/components/esp_ldo/esp_ldo.h | 4 +-- esphome/components/espnow/automation.h | 20 +++++++------- esphome/components/espnow/espnow_component.h | 2 +- .../packet_transport/espnow_transport.h | 8 +++--- esphome/components/ethernet/automation.h | 8 +++--- esphome/components/event/automation.h | 4 +-- .../exposure_notifications.h | 4 +-- esphome/components/ezo/ezo.h | 2 +- esphome/components/ezo_pmp/ezo_pmp.h | 26 +++++++++---------- .../button/factory_reset_button.h | 2 +- .../components/factory_reset/factory_reset.h | 2 +- .../switch/factory_reset_switch.h | 2 +- esphome/components/fan/automation.h | 26 +++++++++---------- .../components/fastled_base/fastled_light.h | 2 +- esphome/components/feedback/feedback_cover.h | 2 +- .../fingerprint_grow/fingerprint_grow.h | 17 +++++++----- esphome/components/font/font.h | 4 +-- esphome/components/fs3000/fs3000.h | 2 +- .../ft5x06/touchscreen/ft5x06_touchscreen.h | 2 +- esphome/components/ft63x6/ft63x6.h | 2 +- .../fujitsu_general/fujitsu_general.h | 2 +- 40 files changed, 109 insertions(+), 106 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_characteristic.h b/esphome/components/esp32_ble_client/ble_characteristic.h index 1428b42739..7834d99c9b 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.h +++ b/esphome/components/esp32_ble_client/ble_characteristic.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; class BLEService; -class BLECharacteristic { +class BLECharacteristic final { public: ~BLECharacteristic(); bool parsed = false; diff --git a/esphome/components/esp32_ble_client/ble_service.h b/esphome/components/esp32_ble_client/ble_service.h index 00ecc777e7..bb1fd2b9fa 100644 --- a/esphome/components/esp32_ble_client/ble_service.h +++ b/esphome/components/esp32_ble_client/ble_service.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; class BLEClientBase; -class BLEService { +class BLEService final { public: ~BLEService(); bool parsed = false; diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 933177a399..b7a3fae1a5 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -24,7 +24,7 @@ using namespace bytebuffer; class BLEService; -class BLECharacteristic { +class BLECharacteristic final { public: BLECharacteristic(ESPBTUUID uuid, uint32_t properties); ~BLECharacteristic(); diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 9ba108499e..fdd92812cd 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -23,7 +23,7 @@ namespace esphome::esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; -class BLEServer : public Component, public Parented { +class BLEServer final : public Component, public Parented { public: void setup() override; void loop() override; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index b4e9ed004e..c6cba14b9b 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -64,7 +64,7 @@ class BLECharacteristicSetValueActionManager { void remove_listener_(BLECharacteristic *characteristic); }; -template class BLECharacteristicSetValueAction : public Action { +template class BLECharacteristicSetValueAction final : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} TEMPLATABLE_VALUE(std::vector, buffer) @@ -92,7 +92,7 @@ template class BLECharacteristicSetValueAction : public Action class BLECharacteristicNotifyAction : public Action { +template class BLECharacteristicNotifyAction final : public Action { public: BLECharacteristicNotifyAction(BLECharacteristic *characteristic) : parent_(characteristic) {} void play(const Ts &...x) override { @@ -110,7 +110,7 @@ template class BLECharacteristicNotifyAction : public Action class BLEDescriptorSetValueAction : public Action { +template class BLEDescriptorSetValueAction final : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} TEMPLATABLE_VALUE(std::vector, buffer) diff --git a/esphome/components/esp32_ble_server/ble_service.h b/esphome/components/esp32_ble_server/ble_service.h index 03fa8093ac..a0592d0a80 100644 --- a/esphome/components/esp32_ble_server/ble_service.h +++ b/esphome/components/esp32_ble_server/ble_service.h @@ -19,7 +19,7 @@ class BLEServer; using namespace esp32_ble; -class BLEService { +class BLEService final { public: BLEService(ESPBTUUID uuid, uint16_t num_handles, uint8_t inst_id, bool advertise); ~BLEService(); diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 6d26040ccb..b653325f56 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -7,7 +7,7 @@ namespace esphome::esp32_ble_tracker { #ifdef USE_ESP32_BLE_DEVICE -class ESPBTAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_addresses(std::initializer_list addresses) { this->address_vec_ = addresses; } @@ -28,7 +28,7 @@ class ESPBTAdvertiseTrigger : public Trigger, public ESPBTD std::vector address_vec_; }; -class BLEServiceDataAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit BLEServiceDataAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_address(uint64_t address) { this->address_ = address; } @@ -54,7 +54,7 @@ class BLEServiceDataAdvertiseTrigger : public Trigger, publi ESPBTUUID uuid_; }; -class BLEManufacturerDataAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit BLEManufacturerDataAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_address(uint64_t address) { this->address_ = address; } @@ -82,7 +82,7 @@ class BLEManufacturerDataAdvertiseTrigger : public Trigger, #endif // USE_ESP32_BLE_DEVICE -class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { +class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { public: explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } @@ -92,7 +92,7 @@ class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { void on_scan_end() override { this->trigger(); } }; -template class ESP32BLEStartScanAction : public Action { +template class ESP32BLEStartScanAction final : public Action { public: ESP32BLEStartScanAction(ESP32BLETracker *parent) : parent_(parent) {} TEMPLATABLE_VALUE(bool, continuous) @@ -111,7 +111,7 @@ template class ESP32BLEStartScanAction : public Action { ESP32BLETracker *parent_; }; -template class ESP32BLEStopScanAction : public Action, public Parented { +template class ESP32BLEStopScanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_scan(); } }; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 78ff60f374..3415196a11 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -294,11 +294,11 @@ class ESPBTClient : public ESPBTDeviceListener { uint8_t *tracker_state_version_{nullptr}; }; -class ESP32BLETracker : public Component, +class ESP32BLETracker final : public Component, #ifdef USE_OTA_STATE_LISTENER - public ota::OTAGlobalStateListener, + public ota::OTAGlobalStateListener, #endif - public Parented { + public Parented { public: void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; } void set_scan_interval(uint32_t scan_interval) { scan_interval_ = scan_interval; } diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 7d020b5caf..83dab5f77a 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -119,7 +119,7 @@ class ESP32CameraImageReader : public camera::CameraImageReader { }; /* ---------------- ESP32Camera class ---------------- */ -class ESP32Camera : public camera::Camera { +class ESP32Camera final : public camera::Camera { public: ESP32Camera(); @@ -235,7 +235,7 @@ class ESP32Camera : public camera::Camera { RAMAllocator fb_allocator_{RAMAllocator::ALLOC_INTERNAL}; }; -class ESP32CameraImageTrigger : public Trigger, public camera::CameraListener { +class ESP32CameraImageTrigger final : public Trigger, public camera::CameraListener { public: explicit ESP32CameraImageTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_camera_image(const std::shared_ptr &image) override { @@ -246,13 +246,13 @@ class ESP32CameraImageTrigger : public Trigger, public camera:: } }; -class ESP32CameraStreamStartTrigger : public Trigger<>, public camera::CameraListener { +class ESP32CameraStreamStartTrigger final : public Trigger<>, public camera::CameraListener { public: explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_stream_start() override { this->trigger(); } }; -class ESP32CameraStreamStopTrigger : public Trigger<>, public camera::CameraListener { +class ESP32CameraStreamStopTrigger final : public Trigger<>, public camera::CameraListener { public: explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_stream_stop() override { this->trigger(); } diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.h b/esphome/components/esp32_camera_web_server/camera_web_server.h index 568dc68c46..76f8317248 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.h +++ b/esphome/components/esp32_camera_web_server/camera_web_server.h @@ -17,7 +17,7 @@ namespace esphome::esp32_camera_web_server { enum Mode { STREAM, SNAPSHOT }; -class CameraWebServer : public Component, public camera::CameraListener { +class CameraWebServer final : public Component, public camera::CameraListener { public: CameraWebServer(); ~CameraWebServer(); diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index 2e10d254e6..f224e10be3 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -14,7 +14,7 @@ enum CanMode : uint8_t { CAN_MODE_LISTEN_ONLY = 1, }; -class ESP32Can : public canbus::Canbus { +class ESP32Can final : public canbus::Canbus { public: void set_rx(int rx) { rx_ = rx; } void set_tx(int tx) { tx_ = tx; } diff --git a/esphome/components/esp32_dac/esp32_dac.h b/esphome/components/esp32_dac/esp32_dac.h index 108b96cd39..ee6b506211 100644 --- a/esphome/components/esp32_dac/esp32_dac.h +++ b/esphome/components/esp32_dac/esp32_dac.h @@ -11,7 +11,7 @@ namespace esphome::esp32_dac { -class ESP32DAC : public output::FloatOutput, public Component { +class ESP32DAC final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 005e6a6f21..4f9d04738d 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -13,7 +13,7 @@ namespace esphome::esp32_hosted { -class Esp32HostedUpdate : public update::UpdateEntity, public PollingComponent { +class Esp32HostedUpdate final : public update::UpdateEntity, public PollingComponent { public: void setup() override; void dump_config() override; diff --git a/esphome/components/esp32_improv/automation.h b/esphome/components/esp32_improv/automation.h index 19e1b6e7e3..b3b61f4778 100644 --- a/esphome/components/esp32_improv/automation.h +++ b/esphome/components/esp32_improv/automation.h @@ -9,7 +9,7 @@ namespace esphome::esp32_improv { -class ESP32ImprovProvisionedTrigger : public Trigger<> { +class ESP32ImprovProvisionedTrigger final : public Trigger<> { public: explicit ESP32ImprovProvisionedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -23,7 +23,7 @@ class ESP32ImprovProvisionedTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovProvisioningTrigger : public Trigger<> { +class ESP32ImprovProvisioningTrigger final : public Trigger<> { public: explicit ESP32ImprovProvisioningTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -37,7 +37,7 @@ class ESP32ImprovProvisioningTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStartTrigger : public Trigger<> { +class ESP32ImprovStartTrigger final : public Trigger<> { public: explicit ESP32ImprovStartTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -52,7 +52,7 @@ class ESP32ImprovStartTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStateTrigger : public Trigger { +class ESP32ImprovStateTrigger final : public Trigger { public: explicit ESP32ImprovStateTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -66,7 +66,7 @@ class ESP32ImprovStateTrigger : public Trigger { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStoppedTrigger : public Trigger<> { +class ESP32ImprovStoppedTrigger final : public Trigger<> { public: explicit ESP32ImprovStoppedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 400006cfb3..d948dba3b3 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -32,7 +32,7 @@ namespace esphome::esp32_improv { using namespace esp32_ble_server; -class ESP32ImprovComponent : public Component, public improv_base::ImprovBase { +class ESP32ImprovComponent final : public Component, public improv_base::ImprovBase { public: ESP32ImprovComponent(); void dump_config() override; diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 8fb6b63afe..d7ba2aafbf 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -30,7 +30,7 @@ struct LedParams { rmt_symbol_word_t reset; }; -class ESP32RMTLEDStripLightOutput : public light::AddressableLight { +class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/esp32_touch/esp32_touch.h b/esphome/components/esp32_touch/esp32_touch.h index d51b2d4922..55ac5c5b63 100644 --- a/esphome/components/esp32_touch/esp32_touch.h +++ b/esphome/components/esp32_touch/esp32_touch.h @@ -172,7 +172,7 @@ class ESP32TouchComponent final : public Component { }; /// Simple helper class to expose a touch pad value as a binary sensor. -class ESP32TouchBinarySensor : public binary_sensor::BinarySensor { +class ESP32TouchBinarySensor final : public binary_sensor::BinarySensor { public: ESP32TouchBinarySensor(int channel_id, uint32_t threshold, uint32_t wakeup_threshold) : channel_id_(channel_id), threshold_(threshold), wakeup_threshold_(wakeup_threshold) {} diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index ff149abfbe..57ef06106a 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -7,7 +7,7 @@ namespace esphome::esp8266 { -class ESP8266GPIOPin : public InternalGPIOPin { +class ESP8266GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 51c4ea1602..be58a098b6 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -9,7 +9,7 @@ namespace esphome::esp8266_pwm { -class ESP8266PWM : public output::FloatOutput, public Component { +class ESP8266PWM final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } @@ -34,7 +34,7 @@ class ESP8266PWM : public output::FloatOutput, public Component { float last_output_{0.0}; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(ESP8266PWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/esp_ldo/esp_ldo.h b/esphome/components/esp_ldo/esp_ldo.h index bb1579e83d..0451c338dd 100644 --- a/esphome/components/esp_ldo/esp_ldo.h +++ b/esphome/components/esp_ldo/esp_ldo.h @@ -6,7 +6,7 @@ namespace esphome::esp_ldo { -class EspLdo : public Component { +class EspLdo final : public Component { public: EspLdo(int channel) : channel_(channel) {} @@ -27,7 +27,7 @@ class EspLdo : public Component { esp_ldo_channel_handle_t handle_{}; }; -template class AdjustAction : public Action { +template class AdjustAction final : public Action { public: explicit AdjustAction(EspLdo *ldo) : ldo_(ldo) {} diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 9c3c55e4ef..5e995aff53 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -9,7 +9,7 @@ namespace esphome::espnow { -template class SendAction : public Action, public Parented { +template class SendAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); TEMPLATABLE_VALUE(std::vector, data); @@ -86,7 +86,7 @@ template class SendAction : public Action, public Parente } flags_{0}; }; -template class AddPeerAction : public Action, public Parented { +template class AddPeerAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); protected: @@ -96,7 +96,7 @@ template class AddPeerAction : public Action, public Pare } }; -template class DeletePeerAction : public Action, public Parented { +template class DeletePeerAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); protected: @@ -106,7 +106,7 @@ template class DeletePeerAction : public Action, public P } }; -template class SetChannelAction : public Action, public Parented { +template class SetChannelAction final : public Action, public Parented { TEMPLATABLE_VALUE(uint8_t, channel) protected: @@ -119,8 +119,8 @@ template class SetChannelAction : public Action, public P } }; -class OnReceiveTrigger : public Trigger, - public ESPNowReceivedPacketHandler { +class OnReceiveTrigger final : public Trigger, + public ESPNowReceivedPacketHandler { public: explicit OnReceiveTrigger(std::array address) : has_address_(true) { memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); @@ -141,16 +141,16 @@ class OnReceiveTrigger : public Trigger, - public ESPNowUnknownPeerHandler { +class OnUnknownPeerTrigger final : public Trigger, + public ESPNowUnknownPeerHandler { public: bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { this->trigger(info, data, size); return false; // Return false to continue processing other internal handlers } }; -class OnBroadcastTrigger : public Trigger, - public ESPNowBroadcastHandler { +class OnBroadcastTrigger final : public Trigger, + public ESPNowBroadcastHandler { public: explicit OnBroadcastTrigger(std::array address) : has_address_(true) { memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index ff9581ec2f..eacc3eb886 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -88,7 +88,7 @@ class ESPNowBroadcastHandler { virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; }; -class ESPNowComponent : public Component { +class ESPNowComponent final : public Component { public: ESPNowComponent(); void setup() override; diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 5916a7fa5f..7e1d08618b 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -11,10 +11,10 @@ namespace esphome::espnow { -class ESPNowTransport : public packet_transport::PacketTransport, - public Parented, - public ESPNowReceivedPacketHandler, - public ESPNowBroadcastHandler { +class ESPNowTransport final : public packet_transport::PacketTransport, + public Parented, + public ESPNowReceivedPacketHandler, + public ESPNowBroadcastHandler { public: void setup() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h index c16abc5bda..f975f52bff 100644 --- a/esphome/components/ethernet/automation.h +++ b/esphome/components/ethernet/automation.h @@ -6,22 +6,22 @@ namespace esphome::ethernet { -template class EthernetConnectedCondition : public Condition { +template class EthernetConnectedCondition final : public Condition { public: bool check(const Ts &...x) override { return global_eth_component->is_connected(); } }; -template class EthernetEnabledCondition : public Condition { +template class EthernetEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } }; -template class EthernetEnableAction : public Action { +template class EthernetEnableAction final : public Action { public: void play(const Ts &...x) override { global_eth_component->enable(); } }; -template class EthernetDisableAction : public Action { +template class EthernetDisableAction final : public Action { public: void play(const Ts &...x) override { global_eth_component->disable(); } }; diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h index 3444a7b1bb..73a6336f78 100644 --- a/esphome/components/event/automation.h +++ b/esphome/components/event/automation.h @@ -6,14 +6,14 @@ namespace esphome::event { -template class TriggerEventAction : public Action, public Parented { +template class TriggerEventAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, event_type) void play(const Ts &...x) override { this->parent_->trigger(this->event_type_.value(x...)); } }; -class EventTrigger : public Trigger { +class EventTrigger final : public Trigger { public: EventTrigger(Event *event) { event->add_on_event_callback([this](StringRef event_type) { this->trigger(event_type); }); diff --git a/esphome/components/exposure_notifications/exposure_notifications.h b/esphome/components/exposure_notifications/exposure_notifications.h index 80184f9cfd..6a703a9a92 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.h +++ b/esphome/components/exposure_notifications/exposure_notifications.h @@ -16,8 +16,8 @@ struct ExposureNotification { std::array associated_encrypted_metadata; }; -class ExposureNotificationTrigger : public Trigger, - public esp32_ble_tracker::ESPBTDeviceListener { +class ExposureNotificationTrigger final : public Trigger, + public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index aea276e001..a20419eceb 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -32,7 +32,7 @@ class EzoCommand { }; /// This class implements support for the EZO circuits in i2c mode -class EZOSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class EZOSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void loop() override; void dump_config() override; diff --git a/esphome/components/ezo_pmp/ezo_pmp.h b/esphome/components/ezo_pmp/ezo_pmp.h index 8a6da5fe74..55283f2d09 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.h +++ b/esphome/components/ezo_pmp/ezo_pmp.h @@ -19,7 +19,7 @@ namespace esphome::ezo_pmp { -class EzoPMP : public PollingComponent, public i2c::I2CDevice { +class EzoPMP final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; @@ -114,7 +114,7 @@ class EzoPMP : public PollingComponent, public i2c::I2CDevice { }; // Action Templates -template class EzoPMPFindAction : public Action { +template class EzoPMPFindAction final : public Action { public: EzoPMPFindAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -124,7 +124,7 @@ template class EzoPMPFindAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPDoseContinuouslyAction : public Action { +template class EzoPMPDoseContinuouslyAction final : public Action { public: EzoPMPDoseContinuouslyAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -134,7 +134,7 @@ template class EzoPMPDoseContinuouslyAction : public Action class EzoPMPDoseVolumeAction : public Action { +template class EzoPMPDoseVolumeAction final : public Action { public: EzoPMPDoseVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -145,7 +145,7 @@ template class EzoPMPDoseVolumeAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPDoseVolumeOverTimeAction : public Action { +template class EzoPMPDoseVolumeOverTimeAction final : public Action { public: EzoPMPDoseVolumeOverTimeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -159,7 +159,7 @@ template class EzoPMPDoseVolumeOverTimeAction : public Action class EzoPMPDoseWithConstantFlowRateAction : public Action { +template class EzoPMPDoseWithConstantFlowRateAction final : public Action { public: EzoPMPDoseWithConstantFlowRateAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -173,7 +173,7 @@ template class EzoPMPDoseWithConstantFlowRateAction : public Act EzoPMP *ezopmp_; }; -template class EzoPMPSetCalibrationVolumeAction : public Action { +template class EzoPMPSetCalibrationVolumeAction final : public Action { public: EzoPMPSetCalibrationVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -184,7 +184,7 @@ template class EzoPMPSetCalibrationVolumeAction : public Action< EzoPMP *ezopmp_; }; -template class EzoPMPClearTotalVolumeDispensedAction : public Action { +template class EzoPMPClearTotalVolumeDispensedAction final : public Action { public: EzoPMPClearTotalVolumeDispensedAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -194,7 +194,7 @@ template class EzoPMPClearTotalVolumeDispensedAction : public Ac EzoPMP *ezopmp_; }; -template class EzoPMPClearCalibrationAction : public Action { +template class EzoPMPClearCalibrationAction final : public Action { public: EzoPMPClearCalibrationAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -204,7 +204,7 @@ template class EzoPMPClearCalibrationAction : public Action class EzoPMPPauseDosingAction : public Action { +template class EzoPMPPauseDosingAction final : public Action { public: EzoPMPPauseDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -214,7 +214,7 @@ template class EzoPMPPauseDosingAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPStopDosingAction : public Action { +template class EzoPMPStopDosingAction final : public Action { public: EzoPMPStopDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -224,7 +224,7 @@ template class EzoPMPStopDosingAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPChangeI2CAddressAction : public Action { +template class EzoPMPChangeI2CAddressAction final : public Action { public: EzoPMPChangeI2CAddressAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -235,7 +235,7 @@ template class EzoPMPChangeI2CAddressAction : public Action class EzoPMPArbitraryCommandAction : public Action { +template class EzoPMPArbitraryCommandAction final : public Action { public: EzoPMPArbitraryCommandAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} diff --git a/esphome/components/factory_reset/button/factory_reset_button.h b/esphome/components/factory_reset/button/factory_reset_button.h index a8cb897614..0bb8a62f5e 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.h +++ b/esphome/components/factory_reset/button/factory_reset_button.h @@ -7,7 +7,7 @@ namespace esphome::factory_reset { -class FactoryResetButton : public button::Button, public Component { +class FactoryResetButton final : public button::Button, public Component { public: void dump_config() override; #ifdef USE_OPENTHREAD diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h index 41ee627c4b..d80d2d2406 100644 --- a/esphome/components/factory_reset/factory_reset.h +++ b/esphome/components/factory_reset/factory_reset.h @@ -10,7 +10,7 @@ #endif namespace esphome::factory_reset { -class FactoryResetComponent : public Component { +class FactoryResetComponent final : public Component { public: FactoryResetComponent(uint8_t required_count, uint16_t max_interval) : max_interval_(max_interval), required_count_(required_count) {} diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.h b/esphome/components/factory_reset/switch/factory_reset_switch.h index be80356b31..fb76b10cf3 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.h +++ b/esphome/components/factory_reset/switch/factory_reset_switch.h @@ -6,7 +6,7 @@ namespace esphome::factory_reset { -class FactoryResetSwitch : public switch_::Switch, public Component { +class FactoryResetSwitch final : public switch_::Switch, public Component { public: void dump_config() override; #ifdef USE_OPENTHREAD diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 964ebe77a0..cbd994e749 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -18,7 +18,7 @@ namespace esphome::fan { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t &...); TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} @@ -33,7 +33,7 @@ template class TurnOnAction : public Action { ApplyFn apply_; }; -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: explicit TurnOffAction(Fan *state) : state_(state) {} @@ -42,7 +42,7 @@ template class TurnOffAction : public Action { Fan *state_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Fan *state) : state_(state) {} @@ -51,7 +51,7 @@ template class ToggleAction : public Action { Fan *state_; }; -template class CycleSpeedAction : public Action { +template class CycleSpeedAction final : public Action { public: explicit CycleSpeedAction(Fan *state) : state_(state) {} @@ -95,7 +95,7 @@ template class CycleSpeedAction : public Action { Fan *state_; }; -template class FanIsOnCondition : public Condition { +template class FanIsOnCondition final : public Condition { public: explicit FanIsOnCondition(Fan *state) : state_(state) {} bool check(const Ts &...x) override { return this->state_->state; } @@ -103,7 +103,7 @@ template class FanIsOnCondition : public Condition { protected: Fan *state_; }; -template class FanIsOffCondition : public Condition { +template class FanIsOffCondition final : public Condition { public: explicit FanIsOffCondition(Fan *state) : state_(state) {} bool check(const Ts &...x) override { return !this->state_->state; } @@ -112,7 +112,7 @@ template class FanIsOffCondition : public Condition { Fan *state_; }; -class FanStateTrigger : public Trigger { +class FanStateTrigger final : public Trigger { public: FanStateTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { this->trigger(this->fan_); }); @@ -122,7 +122,7 @@ class FanStateTrigger : public Trigger { Fan *fan_; }; -class FanTurnOnTrigger : public Trigger<> { +class FanTurnOnTrigger final : public Trigger<> { public: FanTurnOnTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -141,7 +141,7 @@ class FanTurnOnTrigger : public Trigger<> { bool last_on_; }; -class FanTurnOffTrigger : public Trigger<> { +class FanTurnOffTrigger final : public Trigger<> { public: FanTurnOffTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -160,7 +160,7 @@ class FanTurnOffTrigger : public Trigger<> { bool last_on_; }; -class FanDirectionSetTrigger : public Trigger { +class FanDirectionSetTrigger final : public Trigger { public: FanDirectionSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -179,7 +179,7 @@ class FanDirectionSetTrigger : public Trigger { FanDirection last_direction_; }; -class FanOscillatingSetTrigger : public Trigger { +class FanOscillatingSetTrigger final : public Trigger { public: FanOscillatingSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -198,7 +198,7 @@ class FanOscillatingSetTrigger : public Trigger { bool last_oscillating_; }; -class FanSpeedSetTrigger : public Trigger { +class FanSpeedSetTrigger final : public Trigger { public: FanSpeedSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -217,7 +217,7 @@ class FanSpeedSetTrigger : public Trigger { int last_speed_; }; -class FanPresetSetTrigger : public Trigger { +class FanPresetSetTrigger final : public Trigger { public: FanPresetSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index f8535eb628..1261b742a1 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -17,7 +17,7 @@ namespace esphome::fastled_base { -class FastLEDLightOutput : public light::AddressableLight { +class FastLEDLightOutput final : public light::AddressableLight { public: /// Only for custom effects: Get the internal controller. CLEDController *get_controller() const { return this->controller_; } diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index ed6f7490f8..3e4600acd2 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -10,7 +10,7 @@ namespace esphome::feedback { -class FeedbackCover : public cover::Cover, public Component { +class FeedbackCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.h b/esphome/components/fingerprint_grow/fingerprint_grow.h index 7cecb7dc82..67662192ee 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.h +++ b/esphome/components/fingerprint_grow/fingerprint_grow.h @@ -92,7 +92,7 @@ enum GrowAuraLEDColor { WHITE = 0x07, }; -class FingerprintGrowComponent : public PollingComponent, public uart::UARTDevice { +class FingerprintGrowComponent final : public PollingComponent, public uart::UARTDevice { public: void update() override; void setup() override; @@ -209,7 +209,8 @@ class FingerprintGrowComponent : public PollingComponent, public uart::UARTDevic CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction : public Action, public Parented { +template +class EnrollmentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, finger_id) TEMPLATABLE_VALUE(uint8_t, num_scans) @@ -226,12 +227,12 @@ template class EnrollmentAction : public Action, public P }; template -class CancelEnrollmentAction : public Action, public Parented { +class CancelEnrollmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->finish_enrollment(1); } }; -template class DeleteAction : public Action, public Parented { +template class DeleteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, finger_id) @@ -241,12 +242,13 @@ template class DeleteAction : public Action, public Paren } }; -template class DeleteAllAction : public Action, public Parented { +template class DeleteAllAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->delete_all_fingerprints(); } }; -template class LEDControlAction : public Action, public Parented { +template +class LEDControlAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -256,7 +258,8 @@ template class LEDControlAction : public Action, public P } }; -template class AuraLEDControlAction : public Action, public Parented { +template +class AuraLEDControlAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, state) TEMPLATABLE_VALUE(uint8_t, speed) diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index 9c9cfa0f6d..fa24181bd0 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -14,7 +14,7 @@ namespace esphome::font { class Font; -class Glyph { +class Glyph final { public: constexpr Glyph(uint32_t code_point, const uint8_t *data, int advance, int offset_x, int offset_y, int width, int height) @@ -37,7 +37,7 @@ class Glyph { int height; }; -class Font +class Font final #ifdef USE_DISPLAY : public display::BaseFont #endif diff --git a/esphome/components/fs3000/fs3000.h b/esphome/components/fs3000/fs3000.h index c019b1366b..e98e72fa8e 100644 --- a/esphome/components/fs3000/fs3000.h +++ b/esphome/components/fs3000/fs3000.h @@ -11,7 +11,7 @@ namespace esphome::fs3000 { // 1015 has a max speed detection of 15 m/s enum FS3000Model { FIVE, FIFTEEN }; -class FS3000Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class FS3000Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void setup() override; void update() override; diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h index 7cf8769f7a..d788b2044c 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h @@ -34,7 +34,7 @@ enum FTMode : uint8_t { static const size_t MAX_TOUCHES = 5; // max number of possible touches reported -class FT5x06Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class FT5x06Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ft63x6/ft63x6.h b/esphome/components/ft63x6/ft63x6.h index efa03168d9..5e72cf77d8 100644 --- a/esphome/components/ft63x6/ft63x6.h +++ b/esphome/components/ft63x6/ft63x6.h @@ -17,7 +17,7 @@ using namespace touchscreen; static const uint8_t FT6X36_DEFAULT_THRESHOLD = 22; -class FT63X6Touchscreen : public Touchscreen, public i2c::I2CDevice { +class FT63X6Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index ca93e4b300..8d2ec883da 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -46,7 +46,7 @@ const uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius */ // clang-format on -class FujitsuGeneralClimate : public climate_ir::ClimateIR { +class FujitsuGeneralClimate final : public climate_ir::ClimateIR { public: FujitsuGeneralClimate() : ClimateIR(FUJITSU_GENERAL_TEMP_MIN, FUJITSU_GENERAL_TEMP_MAX, 1.0f, true, true, From c69cfd44be1676eb3898b1ea86a983deda96ea5d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:55 +1200 Subject: [PATCH 1773/2214] Mark configurable classes as final (5/21) (#16956) --- .../components/dfrobot_sen0395/automation.h | 4 +-- .../dfrobot_sen0395/dfrobot_sen0395.h | 2 +- .../switch/dfrobot_sen0395_switch.h | 8 +++--- esphome/components/dht/dht.h | 2 +- esphome/components/dht12/dht12.h | 2 +- esphome/components/display/display.h | 12 ++++---- .../components/display_menu_base/automation.h | 28 +++++++++---------- .../components/display_menu_base/menu_item.h | 12 ++++---- esphome/components/dlms_meter/dlms_meter.h | 2 +- esphome/components/dps310/dps310.h | 2 +- esphome/components/ds1307/ds1307.h | 6 ++-- esphome/components/ds2484/ds2484.h | 2 +- esphome/components/dsmr/dsmr.h | 2 +- .../components/duty_cycle/duty_cycle_sensor.h | 2 +- .../components/duty_time/duty_time_sensor.h | 4 +-- esphome/components/e131/e131.h | 2 +- esphome/components/ee895/ee895.h | 2 +- .../ektf2232/touchscreen/ektf2232.h | 2 +- esphome/components/emc2101/emc2101.h | 2 +- .../emc2101/output/emc2101_output.h | 2 +- .../emc2101/sensor/emc2101_sensor.h | 2 +- esphome/components/emmeti/emmeti.h | 2 +- esphome/components/emontx/emontx.h | 4 +-- .../components/emontx/sensor/emontx_sensor.h | 2 +- esphome/components/endstop/endstop_cover.h | 2 +- esphome/components/ens160_i2c/ens160_i2c.h | 2 +- esphome/components/ens160_spi/ens160_spi.h | 6 ++-- esphome/components/ens210/ens210.h | 2 +- esphome/components/es7210/es7210.h | 2 +- esphome/components/es7243e/es7243e.h | 2 +- esphome/components/es8156/es8156.h | 2 +- esphome/components/es8311/es8311.h | 2 +- esphome/components/es8388/es8388.h | 2 +- .../es8388/select/adc_input_mic_select.h | 2 +- .../es8388/select/dac_output_select.h | 2 +- esphome/components/esp32/gpio.h | 2 +- esphome/components/esp32_ble/ble.h | 8 +++--- .../esp32_ble_beacon/esp32_ble_beacon.h | 2 +- 38 files changed, 74 insertions(+), 74 deletions(-) diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h index bd91381d47..a5f4c99014 100644 --- a/esphome/components/dfrobot_sen0395/automation.h +++ b/esphome/components/dfrobot_sen0395/automation.h @@ -8,13 +8,13 @@ namespace esphome::dfrobot_sen0395 { template -class DfrobotSen0395ResetAction : public Action, public Parented { +class DfrobotSen0395ResetAction final : public Action, public Parented { public: void play(const Ts &...x) { this->parent_->enqueue(make_unique()); } }; template -class DfrobotSen0395SettingsAction : public Action, public Parented { +class DfrobotSen0395SettingsAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int8_t, factory_reset) TEMPLATABLE_VALUE(int8_t, start_after_power_on) diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h index 03e3b6b6ec..448a18a477 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h @@ -36,7 +36,7 @@ class CircularCommandQueue { std::unique_ptr commands_[COMMAND_QUEUE_SIZE]; }; -class DfrobotSen0395Component : public uart::UARTDevice, public Component { +class DfrobotSen0395Component final : public uart::UARTDevice, public Component { #ifdef USE_SWITCH SUB_SWITCH(sensor_active) SUB_SWITCH(turn_on_led) diff --git a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h index d83734b034..1c2e929b24 100644 --- a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h +++ b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h @@ -9,22 +9,22 @@ namespace esphome::dfrobot_sen0395 { class DfrobotSen0395Switch : public switch_::Switch, public Component, public Parented {}; -class Sen0395PowerSwitch : public DfrobotSen0395Switch { +class Sen0395PowerSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395LedSwitch : public DfrobotSen0395Switch { +class Sen0395LedSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395UartPresenceSwitch : public DfrobotSen0395Switch { +class Sen0395UartPresenceSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395StartAfterBootSwitch : public DfrobotSen0395Switch { +class Sen0395StartAfterBootSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; diff --git a/esphome/components/dht/dht.h b/esphome/components/dht/dht.h index 0c535f7cf6..86292e144f 100644 --- a/esphome/components/dht/dht.h +++ b/esphome/components/dht/dht.h @@ -18,7 +18,7 @@ enum DHTModel : uint8_t { }; /// Component for reading temperature/humidity measurements from DHT11/DHT22 sensors. -class DHT : public PollingComponent { +class DHT final : public PollingComponent { public: /** Manually select the DHT model. * diff --git a/esphome/components/dht12/dht12.h b/esphome/components/dht12/dht12.h index 5f4f822e70..b835ac1648 100644 --- a/esphome/components/dht12/dht12.h +++ b/esphome/components/dht12/dht12.h @@ -6,7 +6,7 @@ namespace esphome::dht12 { -class DHT12Component : public PollingComponent, public i2c::I2CDevice { +class DHT12Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 6d0b7acfe8..3a136937f6 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -796,7 +796,7 @@ class Display : public PollingComponent { bool show_test_card_{false}; }; -class DisplayPage { +class DisplayPage final { public: DisplayPage(display_writer_t writer); void show(); @@ -814,7 +814,7 @@ class DisplayPage { DisplayPage *next_{nullptr}; }; -template class DisplayPageShowAction : public Action { +template class DisplayPageShowAction final : public Action { public: TEMPLATABLE_VALUE(DisplayPage *, page) @@ -826,7 +826,7 @@ template class DisplayPageShowAction : public Action { } }; -template class DisplayPageShowNextAction : public Action { +template class DisplayPageShowNextAction final : public Action { public: DisplayPageShowNextAction(Display *buffer) : buffer_(buffer) {} @@ -835,7 +835,7 @@ template class DisplayPageShowNextAction : public Action Display *buffer_; }; -template class DisplayPageShowPrevAction : public Action { +template class DisplayPageShowPrevAction final : public Action { public: DisplayPageShowPrevAction(Display *buffer) : buffer_(buffer) {} @@ -844,7 +844,7 @@ template class DisplayPageShowPrevAction : public Action Display *buffer_; }; -template class DisplayIsDisplayingPageCondition : public Condition { +template class DisplayIsDisplayingPageCondition final : public Condition { public: DisplayIsDisplayingPageCondition(Display *parent) : parent_(parent) {} @@ -856,7 +856,7 @@ template class DisplayIsDisplayingPageCondition : public Conditi DisplayPage *page_; }; -class DisplayOnPageChangeTrigger : public Trigger { +class DisplayOnPageChangeTrigger final : public Trigger { public: explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } void process(DisplayPage *from, DisplayPage *to); diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h index d4f83055d1..be0044ffa4 100644 --- a/esphome/components/display_menu_base/automation.h +++ b/esphome/components/display_menu_base/automation.h @@ -5,7 +5,7 @@ namespace esphome::display_menu_base { -template class UpAction : public Action { +template class UpAction final : public Action { public: explicit UpAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -15,7 +15,7 @@ template class UpAction : public Action { DisplayMenuComponent *menu_; }; -template class DownAction : public Action { +template class DownAction final : public Action { public: explicit DownAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -25,7 +25,7 @@ template class DownAction : public Action { DisplayMenuComponent *menu_; }; -template class LeftAction : public Action { +template class LeftAction final : public Action { public: explicit LeftAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -35,7 +35,7 @@ template class LeftAction : public Action { DisplayMenuComponent *menu_; }; -template class RightAction : public Action { +template class RightAction final : public Action { public: explicit RightAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -45,7 +45,7 @@ template class RightAction : public Action { DisplayMenuComponent *menu_; }; -template class EnterAction : public Action { +template class EnterAction final : public Action { public: explicit EnterAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -55,7 +55,7 @@ template class EnterAction : public Action { DisplayMenuComponent *menu_; }; -template class ShowAction : public Action { +template class ShowAction final : public Action { public: explicit ShowAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -65,7 +65,7 @@ template class ShowAction : public Action { DisplayMenuComponent *menu_; }; -template class HideAction : public Action { +template class HideAction final : public Action { public: explicit HideAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -75,7 +75,7 @@ template class HideAction : public Action { DisplayMenuComponent *menu_; }; -template class ShowMainAction : public Action { +template class ShowMainAction final : public Action { public: explicit ShowMainAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -84,7 +84,7 @@ template class ShowMainAction : public Action { protected: DisplayMenuComponent *menu_; }; -template class IsActiveCondition : public Condition { +template class IsActiveCondition final : public Condition { public: explicit IsActiveCondition(DisplayMenuComponent *menu) : menu_(menu) {} bool check(const Ts &...x) override { return this->menu_->is_active(); } @@ -93,7 +93,7 @@ template class IsActiveCondition : public Condition { DisplayMenuComponent *menu_; }; -class DisplayMenuOnEnterTrigger : public Trigger { +class DisplayMenuOnEnterTrigger final : public Trigger { public: explicit DisplayMenuOnEnterTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_enter_callback([this]() { this->trigger(this->parent_); }); @@ -103,7 +103,7 @@ class DisplayMenuOnEnterTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnLeaveTrigger : public Trigger { +class DisplayMenuOnLeaveTrigger final : public Trigger { public: explicit DisplayMenuOnLeaveTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_leave_callback([this]() { this->trigger(this->parent_); }); @@ -113,7 +113,7 @@ class DisplayMenuOnLeaveTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnValueTrigger : public Trigger { +class DisplayMenuOnValueTrigger final : public Trigger { public: explicit DisplayMenuOnValueTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_value_callback([this]() { this->trigger(this->parent_); }); @@ -123,7 +123,7 @@ class DisplayMenuOnValueTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnNextTrigger : public Trigger { +class DisplayMenuOnNextTrigger final : public Trigger { public: explicit DisplayMenuOnNextTrigger(MenuItemCustom *parent) : parent_(parent) { parent->add_on_next_callback([this]() { this->trigger(this->parent_); }); @@ -133,7 +133,7 @@ class DisplayMenuOnNextTrigger : public Trigger { MenuItemCustom *parent_; }; -class DisplayMenuOnPrevTrigger : public Trigger { +class DisplayMenuOnPrevTrigger final : public Trigger { public: explicit DisplayMenuOnPrevTrigger(MenuItemCustom *parent) : parent_(parent) { parent->add_on_prev_callback([this]() { this->trigger(this->parent_); }); diff --git a/esphome/components/display_menu_base/menu_item.h b/esphome/components/display_menu_base/menu_item.h index f3c41583f7..d5732377e3 100644 --- a/esphome/components/display_menu_base/menu_item.h +++ b/esphome/components/display_menu_base/menu_item.h @@ -70,7 +70,7 @@ class MenuItem { CallbackManager on_value_callbacks_{}; }; -class MenuItemMenu : public MenuItem { +class MenuItemMenu final : public MenuItem { public: explicit MenuItemMenu() : MenuItem(MENU_ITEM_MENU) {} void add_item(MenuItem *item) { @@ -97,7 +97,7 @@ class MenuItemEditable : public MenuItem { }; #ifdef USE_SELECT -class MenuItemSelect : public MenuItemEditable { +class MenuItemSelect final : public MenuItemEditable { public: explicit MenuItemSelect() : MenuItemEditable(MENU_ITEM_SELECT) {} void set_select_variable(select::Select *var) { this->select_var_ = var; } @@ -114,7 +114,7 @@ class MenuItemSelect : public MenuItemEditable { #endif #ifdef USE_NUMBER -class MenuItemNumber : public MenuItemEditable { +class MenuItemNumber final : public MenuItemEditable { public: explicit MenuItemNumber() : MenuItemEditable(MENU_ITEM_NUMBER) {} void set_number_variable(number::Number *var) { this->number_var_ = var; } @@ -135,7 +135,7 @@ class MenuItemNumber : public MenuItemEditable { #endif #ifdef USE_SWITCH -class MenuItemSwitch : public MenuItemEditable { +class MenuItemSwitch final : public MenuItemEditable { public: explicit MenuItemSwitch() : MenuItemEditable(MENU_ITEM_SWITCH) {} void set_switch_variable(switch_::Switch *var) { this->switch_var_ = var; } @@ -158,7 +158,7 @@ class MenuItemSwitch : public MenuItemEditable { }; #endif -class MenuItemCommand : public MenuItem { +class MenuItemCommand final : public MenuItem { public: explicit MenuItemCommand() : MenuItem(MENU_ITEM_COMMAND) {} @@ -166,7 +166,7 @@ class MenuItemCommand : public MenuItem { bool select_prev() override; }; -class MenuItemCustom : public MenuItemEditable { +class MenuItemCustom final : public MenuItemEditable { public: explicit MenuItemCustom() : MenuItemEditable(MENU_ITEM_CUSTOM) {} template void add_on_next_callback(F &&cb) { this->on_next_callbacks_.add(std::forward(cb)); } diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index cdc53d5685..fc4721843f 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -98,7 +98,7 @@ struct CustomPattern { std::optional> default_obis; }; -class DlmsMeterComponent : public Component, public uart::UARTDevice { +class DlmsMeterComponent final : public Component, public uart::UARTDevice { public: DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, std::optional> decryption_key, diff --git a/esphome/components/dps310/dps310.h b/esphome/components/dps310/dps310.h index 09143bf6b8..4dd23985d7 100644 --- a/esphome/components/dps310/dps310.h +++ b/esphome/components/dps310/dps310.h @@ -35,7 +35,7 @@ static const uint8_t DPS310_INIT_TIMEOUT = 20; // How long to wait for DPS static const uint8_t DPS310_NUM_COEF_REGS = 18; // Number of coefficients we need to read from the device static const int32_t DPS310_SCALE_FACTOR = 1572864; // Measurement compensation scale factor -class DPS310Component : public PollingComponent, public i2c::I2CDevice { +class DPS310Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ds1307/ds1307.h b/esphome/components/ds1307/ds1307.h index 2004978cc6..238fc7b21a 100644 --- a/esphome/components/ds1307/ds1307.h +++ b/esphome/components/ds1307/ds1307.h @@ -6,7 +6,7 @@ namespace esphome::ds1307 { -class DS1307Component : public time::RealTimeClock, public i2c::I2CDevice { +class DS1307Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -55,12 +55,12 @@ class DS1307Component : public time::RealTimeClock, public i2c::I2CDevice { } ds1307_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/ds2484/ds2484.h b/esphome/components/ds2484/ds2484.h index 9e6bb08858..b3337539ce 100644 --- a/esphome/components/ds2484/ds2484.h +++ b/esphome/components/ds2484/ds2484.h @@ -8,7 +8,7 @@ namespace esphome::ds2484 { -class DS2484OneWireBus : public one_wire::OneWireBus, public i2c::I2CDevice, public Component { +class DS2484OneWireBus final : public one_wire::OneWireBus, public i2c::I2CDevice, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index 3642309c26..321fbab824 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -65,7 +65,7 @@ using MyData = dsmr_parser::ParsedData; #endif -class Dsmr : public Component, public uart::UARTDevice { +class Dsmr final : public Component, public uart::UARTDevice { public: Dsmr(uart::UARTComponent *uart, bool crc_check, size_t max_telegram_length, uint32_t request_interval, uint32_t receive_timeout, GPIOPin *request_pin, const char *decryption_key) diff --git a/esphome/components/duty_cycle/duty_cycle_sensor.h b/esphome/components/duty_cycle/duty_cycle_sensor.h index 58beee946a..564c47a2aa 100644 --- a/esphome/components/duty_cycle/duty_cycle_sensor.h +++ b/esphome/components/duty_cycle/duty_cycle_sensor.h @@ -16,7 +16,7 @@ struct DutyCycleSensorStore { static void gpio_intr(DutyCycleSensorStore *arg); }; -class DutyCycleSensor : public sensor::Sensor, public PollingComponent { +class DutyCycleSensor final : public sensor::Sensor, public PollingComponent { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index 9b1e10ea8c..a9e91de0b1 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -12,7 +12,7 @@ namespace esphome::duty_time_sensor { -class DutyTimeSensor : public sensor::Sensor, public PollingComponent { +class DutyTimeSensor final : public sensor::Sensor, public PollingComponent { public: void setup() override; void update() override; @@ -61,7 +61,7 @@ template class ResetAction : public BaseAction { void play(const Ts &...x) override { this->parent_->reset(); } }; -template class RunningCondition : public Condition, public Parented { +template class RunningCondition final : public Condition, public Parented { public: explicit RunningCondition(DutyTimeSensor *parent, bool state) : Parented(parent), state_(state) {} diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index 6574037efb..b0a8b4f83f 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -30,7 +30,7 @@ struct UniverseConsumer { uint16_t consumers; }; -class E131Component : public esphome::Component { +class E131Component final : public esphome::Component { public: E131Component(); ~E131Component(); diff --git a/esphome/components/ee895/ee895.h b/esphome/components/ee895/ee895.h index ba8e594fea..1682e33146 100644 --- a/esphome/components/ee895/ee895.h +++ b/esphome/components/ee895/ee895.h @@ -7,7 +7,7 @@ namespace esphome::ee895 { /// This class implements support for the ee895 of temperature i2c sensors. -class EE895Component : public PollingComponent, public i2c::I2CDevice { +class EE895Component final : public PollingComponent, public i2c::I2CDevice { public: void set_co2_sensor(sensor::Sensor *co2) { co2_sensor_ = co2; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/ektf2232/touchscreen/ektf2232.h b/esphome/components/ektf2232/touchscreen/ektf2232.h index 45da74a2a5..a4b9cbd574 100644 --- a/esphome/components/ektf2232/touchscreen/ektf2232.h +++ b/esphome/components/ektf2232/touchscreen/ektf2232.h @@ -10,7 +10,7 @@ namespace esphome::ektf2232 { using namespace touchscreen; -class EKTF2232Touchscreen : public Touchscreen, public i2c::I2CDevice { +class EKTF2232Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/emc2101/emc2101.h b/esphome/components/emc2101/emc2101.h index 1fe03a2630..b3ec0c5dc1 100644 --- a/esphome/components/emc2101/emc2101.h +++ b/esphome/components/emc2101/emc2101.h @@ -25,7 +25,7 @@ enum Emc2101DACConversionRate { /// This class includes support for the EMC2101 i2c fan controller. /// The device has an output (PWM or DAC) and several sensors and this /// class is for the EMC2101 configuration. -class Emc2101Component : public Component, public i2c::I2CDevice { +class Emc2101Component final : public Component, public i2c::I2CDevice { public: /** Sets the mode of the output. * diff --git a/esphome/components/emc2101/output/emc2101_output.h b/esphome/components/emc2101/output/emc2101_output.h index 95077f5524..9a7ab0659a 100644 --- a/esphome/components/emc2101/output/emc2101_output.h +++ b/esphome/components/emc2101/output/emc2101_output.h @@ -6,7 +6,7 @@ namespace esphome::emc2101 { /// This class allows to control the EMC2101 output. -class EMC2101Output : public output::FloatOutput { +class EMC2101Output final : public output::FloatOutput { public: EMC2101Output(Emc2101Component *parent) : parent_(parent) {} diff --git a/esphome/components/emc2101/sensor/emc2101_sensor.h b/esphome/components/emc2101/sensor/emc2101_sensor.h index 2336ac2f15..943e468e7d 100644 --- a/esphome/components/emc2101/sensor/emc2101_sensor.h +++ b/esphome/components/emc2101/sensor/emc2101_sensor.h @@ -7,7 +7,7 @@ namespace esphome::emc2101 { /// This class exposes the EMC2101 sensors. -class EMC2101Sensor : public PollingComponent { +class EMC2101Sensor final : public PollingComponent { public: EMC2101Sensor(Emc2101Component *parent) : parent_(parent) {} /** Used by ESPHome framework. */ diff --git a/esphome/components/emmeti/emmeti.h b/esphome/components/emmeti/emmeti.h index 9dc78ce07c..2203bfdec7 100644 --- a/esphome/components/emmeti/emmeti.h +++ b/esphome/components/emmeti/emmeti.h @@ -60,7 +60,7 @@ struct EmmetiState { uint8_t checksum = 0; }; -class EmmetiClimate : public climate_ir::ClimateIR { +class EmmetiClimate final : public climate_ir::ClimateIR { public: EmmetiClimate() : climate_ir::ClimateIR(EMMETI_TEMP_MIN, EMMETI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 67e7f5bffc..6db197a78c 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -26,7 +26,7 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * The EmonTx processes incoming data frames via UART, * extracts tags and values, and publishes them to registered sensors. */ -class EmonTx : public Component, public uart::UARTDevice { +class EmonTx final : public Component, public uart::UARTDevice { public: EmonTx() = default; @@ -59,7 +59,7 @@ class EmonTx : public Component, public uart::UARTDevice { }; // Action to send command to emonTx -template class EmonTxSendCommandAction : public Action, public Parented { +template class EmonTxSendCommandAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, command) diff --git a/esphome/components/emontx/sensor/emontx_sensor.h b/esphome/components/emontx/sensor/emontx_sensor.h index 9714acdf0d..88b396bbc4 100644 --- a/esphome/components/emontx/sensor/emontx_sensor.h +++ b/esphome/components/emontx/sensor/emontx_sensor.h @@ -5,7 +5,7 @@ namespace esphome::emontx { -class EmonTxSensor : public sensor::Sensor, public Component { +class EmonTxSensor final : public sensor::Sensor, public Component { public: void dump_config() override; }; diff --git a/esphome/components/endstop/endstop_cover.h b/esphome/components/endstop/endstop_cover.h index b910139bcd..5319c74d7b 100644 --- a/esphome/components/endstop/endstop_cover.h +++ b/esphome/components/endstop/endstop_cover.h @@ -7,7 +7,7 @@ namespace esphome::endstop { -class EndstopCover : public cover::Cover, public Component { +class EndstopCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/ens160_i2c/ens160_i2c.h b/esphome/components/ens160_i2c/ens160_i2c.h index 98318a7eca..d5a0d21c62 100644 --- a/esphome/components/ens160_i2c/ens160_i2c.h +++ b/esphome/components/ens160_i2c/ens160_i2c.h @@ -5,7 +5,7 @@ namespace esphome::ens160_i2c { -class ENS160I2CComponent : public esphome::ens160_base::ENS160Component, public i2c::I2CDevice { +class ENS160I2CComponent final : public esphome::ens160_base::ENS160Component, public i2c::I2CDevice { void dump_config() override; bool read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/ens160_spi/ens160_spi.h b/esphome/components/ens160_spi/ens160_spi.h index d4d3cf3ae9..821e89515f 100644 --- a/esphome/components/ens160_spi/ens160_spi.h +++ b/esphome/components/ens160_spi/ens160_spi.h @@ -5,9 +5,9 @@ namespace esphome::ens160_spi { -class ENS160SPIComponent : public esphome::ens160_base::ENS160Component, - public spi::SPIDevice { +class ENS160SPIComponent final : public esphome::ens160_base::ENS160Component, + public spi::SPIDevice { void setup() override; void dump_config() override; diff --git a/esphome/components/ens210/ens210.h b/esphome/components/ens210/ens210.h index f1520fc483..fca20133b8 100644 --- a/esphome/components/ens210/ens210.h +++ b/esphome/components/ens210/ens210.h @@ -7,7 +7,7 @@ namespace esphome::ens210 { /// This class implements support for the ENS210 relative humidity and temperature i2c sensor. -class ENS210Component : public PollingComponent, public i2c::I2CDevice { +class ENS210Component final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void setup() override; diff --git a/esphome/components/es7210/es7210.h b/esphome/components/es7210/es7210.h index 914fbd633b..42c667b658 100644 --- a/esphome/components/es7210/es7210.h +++ b/esphome/components/es7210/es7210.h @@ -16,7 +16,7 @@ enum ES7210BitsPerSample : uint8_t { ES7210_BITS_PER_SAMPLE_32 = 32, }; -class ES7210 : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { +class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { /* Class for configuring an ES7210 ADC for microphone input. * Based on code from: * - https://github.com/espressif/esp-bsp/ (accessed 20241219) diff --git a/esphome/components/es7243e/es7243e.h b/esphome/components/es7243e/es7243e.h index 6386ea529a..47dc6122c9 100644 --- a/esphome/components/es7243e/es7243e.h +++ b/esphome/components/es7243e/es7243e.h @@ -6,7 +6,7 @@ namespace esphome::es7243e { -class ES7243E : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { +class ES7243E final : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { /* Class for configuring an ES7243E ADC for microphone input. * Based on code from: * - https://github.com/espressif/esp-adf/ (accessed 20250116) diff --git a/esphome/components/es8156/es8156.h b/esphome/components/es8156/es8156.h index c3cec3dc14..d29e8d1685 100644 --- a/esphome/components/es8156/es8156.h +++ b/esphome/components/es8156/es8156.h @@ -6,7 +6,7 @@ namespace esphome::es8156 { -class ES8156 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8156 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: ///////////////////////// // Component overrides // diff --git a/esphome/components/es8311/es8311.h b/esphome/components/es8311/es8311.h index 1190bcb0aa..ecc4b1014d 100644 --- a/esphome/components/es8311/es8311.h +++ b/esphome/components/es8311/es8311.h @@ -42,7 +42,7 @@ struct ES8311Coefficient { uint8_t dac_osr; // dac osr }; -class ES8311 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8311 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: ///////////////////////// // Component overrides // diff --git a/esphome/components/es8388/es8388.h b/esphome/components/es8388/es8388.h index 1f744e25b3..b01acb69c1 100644 --- a/esphome/components/es8388/es8388.h +++ b/esphome/components/es8388/es8388.h @@ -25,7 +25,7 @@ enum AdcInputMicLine : uint8_t { ADC_INPUT_MIC_DIFFERENCE, }; -class ES8388 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { #ifdef USE_SELECT SUB_SELECT(dac_output) SUB_SELECT(adc_input_mic) diff --git a/esphome/components/es8388/select/adc_input_mic_select.h b/esphome/components/es8388/select/adc_input_mic_select.h index 29978f1623..2d4e8d72db 100644 --- a/esphome/components/es8388/select/adc_input_mic_select.h +++ b/esphome/components/es8388/select/adc_input_mic_select.h @@ -5,7 +5,7 @@ namespace esphome::es8388 { -class ADCInputMicSelect : public select::Select, public Parented { +class ADCInputMicSelect final : public select::Select, public Parented { protected: void control(size_t index) override; }; diff --git a/esphome/components/es8388/select/dac_output_select.h b/esphome/components/es8388/select/dac_output_select.h index 030f12406e..f63ee8d1ba 100644 --- a/esphome/components/es8388/select/dac_output_select.h +++ b/esphome/components/es8388/select/dac_output_select.h @@ -5,7 +5,7 @@ namespace esphome::es8388 { -class DacOutputSelect : public select::Select, public Parented { +class DacOutputSelect final : public select::Select, public Parented { protected: void control(size_t index) override; }; diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index a140eeef77..aeff5af51c 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -10,7 +10,7 @@ namespace esphome::esp32 { static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t"); static_assert(GPIO_DRIVE_CAP_MAX <= 4, "gpio_drive_cap_t has too many values for 2-bit field"); -class ESP32InternalGPIOPin : public InternalGPIOPin { +class ESP32InternalGPIOPin final : public InternalGPIOPin { public: void set_pin(gpio_num_t pin) { this->pin_ = static_cast(pin); } void set_inverted(bool inverted) { this->pin_flags_.inverted = inverted; } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index de8c8c2343..c85ddfc983 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -87,7 +87,7 @@ enum BLEComponentState : uint8_t { BLE_COMPONENT_STATE_ACTIVE, }; -class ESP32BLE : public Component { +class ESP32BLE final : public Component { public: void set_io_capability(IoCapability io_capability) { this->io_cap_ = (esp_ble_io_cap_t) io_capability; } @@ -236,12 +236,12 @@ class ESP32BLE : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern ESP32BLE *global_ble; -template class BLEEnabledCondition : public Condition { +template class BLEEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); } }; -template class BLEEnableAction : public Action { +template class BLEEnableAction final : public Action { public: void play(const Ts &...x) override { if (global_ble != nullptr) @@ -249,7 +249,7 @@ template class BLEEnableAction : public Action { } }; -template class BLEDisableAction : public Action { +template class BLEDisableAction final : public Action { public: void play(const Ts &...x) override { if (global_ble != nullptr) diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index 8b3899a681..986778de57 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -34,7 +34,7 @@ using esp_ble_ibeacon_t = struct { using namespace esp32_ble; -class ESP32BLEBeacon : public Component { +class ESP32BLEBeacon final : public Component { public: explicit ESP32BLEBeacon(const std::array &uuid) : uuid_(uuid) {} From e88f69b5f81550ecd50036e922014526a10d5629 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:05:05 +1200 Subject: [PATCH 1774/2214] Mark configurable classes as final (7/21: gcja5-hlw8032) (#16958) --- esphome/components/gcja5/gcja5.h | 2 +- esphome/components/gdk101/gdk101.h | 2 +- esphome/components/gl_r01_i2c/gl_r01_i2c.h | 2 +- .../components/globals/globals_component.h | 4 +-- .../components/gp2y1010au0f/gp2y1010au0f.h | 2 +- esphome/components/gp8403/gp8403.h | 2 +- .../components/gp8403/output/gp8403_output.h | 2 +- .../components/gpio/one_wire/gpio_one_wire.h | 2 +- .../gpio/output/gpio_binary_output.h | 2 +- esphome/components/gps/gps.h | 2 +- esphome/components/gps/time/gps_time.h | 2 +- esphome/components/graph/graph.h | 6 ++--- esphome/components/gree/gree.h | 2 +- esphome/components/gree/switch/gree_switch.h | 2 +- .../grove_gas_mc_v2/grove_gas_mc_v2.h | 2 +- .../grove_tb6612fng/grove_tb6612fng.h | 14 +++++----- .../components/growatt_solar/growatt_solar.h | 2 +- .../gt911/binary_sensor/gt911_button.h | 8 +++--- .../gt911/touchscreen/gt911_touchscreen.h | 2 +- esphome/components/haier/automation.h | 26 +++++++++---------- .../components/haier/button/self_cleaning.h | 2 +- .../components/haier/button/steri_cleaning.h | 2 +- esphome/components/haier/hon_climate.h | 2 +- esphome/components/haier/smartair2_climate.h | 2 +- esphome/components/haier/switch/beeper.h | 2 +- esphome/components/haier/switch/display.h | 2 +- esphome/components/haier/switch/health_mode.h | 2 +- esphome/components/haier/switch/quiet_mode.h | 2 +- .../components/havells_solar/havells_solar.h | 2 +- esphome/components/hbridge/fan/hbridge_fan.h | 4 +-- .../hbridge/light/hbridge_light_output.h | 2 +- .../hbridge/switch/hbridge_switch.h | 2 +- esphome/components/hc8/hc8.h | 4 +-- esphome/components/hdc1080/hdc1080.h | 2 +- esphome/components/hdc2010/hdc2010.h | 2 +- esphome/components/hdc2080/hdc2080.h | 2 +- esphome/components/hdc302x/hdc302x.h | 6 ++--- esphome/components/he60r/he60r.h | 2 +- esphome/components/heatpumpir/heatpumpir.h | 2 +- .../components/hitachi_ac344/hitachi_ac344.h | 2 +- .../components/hitachi_ac424/hitachi_ac424.h | 2 +- esphome/components/hlk_fm22x/hlk_fm22x.h | 12 ++++----- esphome/components/hlw8012/hlw8012.h | 2 +- esphome/components/hlw8032/hlw8032.h | 2 +- 44 files changed, 77 insertions(+), 77 deletions(-) diff --git a/esphome/components/gcja5/gcja5.h b/esphome/components/gcja5/gcja5.h index 30c9464b4a..f25d864f1a 100644 --- a/esphome/components/gcja5/gcja5.h +++ b/esphome/components/gcja5/gcja5.h @@ -7,7 +7,7 @@ namespace esphome::gcja5 { -class GCJA5Component : public Component, public uart::UARTDevice { +class GCJA5Component final : public Component, public uart::UARTDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/gdk101/gdk101.h b/esphome/components/gdk101/gdk101.h index 2ef7526294..5a91594081 100644 --- a/esphome/components/gdk101/gdk101.h +++ b/esphome/components/gdk101/gdk101.h @@ -22,7 +22,7 @@ static const uint8_t GDK101_REG_READ_MEASURING_TIME = 0xB1; // Mesuring time static const uint8_t GDK101_REG_READ_10MIN_AVG = 0xB2; // Average radiation dose per 10 min static const uint8_t GDK101_REG_READ_1MIN_AVG = 0xB3; // Average radiation dose per 1 min -class GDK101Component : public PollingComponent, public i2c::I2CDevice { +class GDK101Component final : public PollingComponent, public i2c::I2CDevice { #ifdef USE_SENSOR SUB_SENSOR(rad_1m) SUB_SENSOR(rad_10m) diff --git a/esphome/components/gl_r01_i2c/gl_r01_i2c.h b/esphome/components/gl_r01_i2c/gl_r01_i2c.h index 1d023c245a..23a1dec336 100644 --- a/esphome/components/gl_r01_i2c/gl_r01_i2c.h +++ b/esphome/components/gl_r01_i2c/gl_r01_i2c.h @@ -6,7 +6,7 @@ namespace esphome::gl_r01_i2c { -class GLR01I2CComponent : public sensor::Sensor, public i2c::I2CDevice, public PollingComponent { +class GLR01I2CComponent final : public sensor::Sensor, public i2c::I2CDevice, public PollingComponent { public: void setup() override; void dump_config() override; diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index 520c068e6f..78d2bc5910 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -7,7 +7,7 @@ namespace esphome::globals { -template class GlobalsComponent : public Component { +template class GlobalsComponent final : public Component { public: using value_type = T; explicit GlobalsComponent() = default; @@ -127,7 +127,7 @@ template class RestoringGlobalStringComponent : public P ESPPreferenceObject rtc_; }; -template class GlobalVarSetAction : public Action { +template class GlobalVarSetAction final : public Action { public: explicit GlobalVarSetAction(C *parent) : parent_(parent) {} diff --git a/esphome/components/gp2y1010au0f/gp2y1010au0f.h b/esphome/components/gp2y1010au0f/gp2y1010au0f.h index f3398ac4a3..648e66d2ff 100644 --- a/esphome/components/gp2y1010au0f/gp2y1010au0f.h +++ b/esphome/components/gp2y1010au0f/gp2y1010au0f.h @@ -7,7 +7,7 @@ namespace esphome::gp2y1010au0f { -class GP2Y1010AU0FSensor : public sensor::Sensor, public PollingComponent { +class GP2Y1010AU0FSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void loop() override; diff --git a/esphome/components/gp8403/gp8403.h b/esphome/components/gp8403/gp8403.h index d30d967479..5d969c20d2 100644 --- a/esphome/components/gp8403/gp8403.h +++ b/esphome/components/gp8403/gp8403.h @@ -15,7 +15,7 @@ enum GP8403Model : uint8_t { GP8413, }; -class GP8403Component : public Component, public i2c::I2CDevice { +class GP8403Component final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gp8403/output/gp8403_output.h b/esphome/components/gp8403/output/gp8403_output.h index 8b1f920680..ea3b7cd6f6 100644 --- a/esphome/components/gp8403/output/gp8403_output.h +++ b/esphome/components/gp8403/output/gp8403_output.h @@ -7,7 +7,7 @@ namespace esphome::gp8403 { -class GP8403Output : public Component, public output::FloatOutput, public Parented { +class GP8403Output final : public Component, public output::FloatOutput, public Parented { public: void dump_config() override; float get_setup_priority() const override { return setup_priority::DATA - 1; } diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.h b/esphome/components/gpio/one_wire/gpio_one_wire.h index 02797b5737..e457b599e5 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.h +++ b/esphome/components/gpio/one_wire/gpio_one_wire.h @@ -6,7 +6,7 @@ namespace esphome::gpio { -class GPIOOneWireBus : public one_wire::OneWireBus, public Component { +class GPIOOneWireBus final : public one_wire::OneWireBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gpio/output/gpio_binary_output.h b/esphome/components/gpio/output/gpio_binary_output.h index 4100cb94c2..496afd131b 100644 --- a/esphome/components/gpio/output/gpio_binary_output.h +++ b/esphome/components/gpio/output/gpio_binary_output.h @@ -6,7 +6,7 @@ namespace esphome::gpio { -class GPIOBinaryOutput : public output::BinaryOutput, public Component { +class GPIOBinaryOutput final : public output::BinaryOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/gps/gps.h b/esphome/components/gps/gps.h index 9cd79e25b4..7771286edf 100644 --- a/esphome/components/gps/gps.h +++ b/esphome/components/gps/gps.h @@ -22,7 +22,7 @@ class GPSListener { GPS *parent_; }; -class GPS : public PollingComponent, public uart::UARTDevice { +class GPS final : public PollingComponent, public uart::UARTDevice { public: void set_latitude_sensor(sensor::Sensor *latitude_sensor) { this->latitude_sensor_ = latitude_sensor; } void set_longitude_sensor(sensor::Sensor *longitude_sensor) { this->longitude_sensor_ = longitude_sensor; } diff --git a/esphome/components/gps/time/gps_time.h b/esphome/components/gps/time/gps_time.h index 3d6d870efc..bd2049c46c 100644 --- a/esphome/components/gps/time/gps_time.h +++ b/esphome/components/gps/time/gps_time.h @@ -6,7 +6,7 @@ namespace esphome::gps { -class GPSTime : public time::RealTimeClock, public GPSListener { +class GPSTime final : public time::RealTimeClock, public GPSListener { public: void update() override { this->from_tiny_gps_(this->get_tiny_gps()); }; void on_update(TinyGPSPlus &tiny_gps) override { diff --git a/esphome/components/graph/graph.h b/esphome/components/graph/graph.h index a601e9eeb1..dbedab6085 100644 --- a/esphome/components/graph/graph.h +++ b/esphome/components/graph/graph.h @@ -42,7 +42,7 @@ enum ValuePositionType { VALUE_POSITION_TYPE_BELOW }; -class GraphLegend { +class GraphLegend final { public: void init(Graph *g); void set_name_font(display::BaseFont *font) { this->font_label_ = font; } @@ -105,7 +105,7 @@ class HistoryData { std::vector samples_; }; -class GraphTrace { +class GraphTrace final { public: void init(Graph *g); void set_name(std::string name) { name_ = std::move(name); } @@ -134,7 +134,7 @@ class GraphTrace { friend GraphLegend; }; -class Graph : public Component { +class Graph final : public Component { public: void draw(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color); void draw_legend(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color); diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 1eb812ae46..2f10be3e6b 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -79,7 +79,7 @@ static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; // Model codes enum Model { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; -class GreeClimate : public climate_ir::ClimateIR { +class GreeClimate final : public climate_ir::ClimateIR { public: GreeClimate() : climate_ir::ClimateIR(GREE_TEMP_MIN, GREE_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/gree/switch/gree_switch.h b/esphome/components/gree/switch/gree_switch.h index 9d9f187f9d..1e82c83ae6 100644 --- a/esphome/components/gree/switch/gree_switch.h +++ b/esphome/components/gree/switch/gree_switch.h @@ -6,7 +6,7 @@ namespace esphome::gree { -class GreeModeBitSwitch : public switch_::Switch, public Component, public Parented { +class GreeModeBitSwitch final : public switch_::Switch, public Component, public Parented { public: GreeModeBitSwitch(const char *name, uint8_t bit_mask) : name_(name), bit_mask_(bit_mask) {} diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h index 38165ab68c..545b6df97a 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h @@ -7,7 +7,7 @@ namespace esphome::grove_gas_mc_v2 { -class GroveGasMultichannelV2Component : public PollingComponent, public i2c::I2CDevice { +class GroveGasMultichannelV2Component final : public PollingComponent, public i2c::I2CDevice { SUB_SENSOR(tvoc) SUB_SENSOR(carbon_monoxide) SUB_SENSOR(nitrogen_dioxide) diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.h b/esphome/components/grove_tb6612fng/grove_tb6612fng.h index c021680519..a8648025b9 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.h +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.h @@ -47,7 +47,7 @@ enum StepperModeTypeT { MICRO_STEPPING = 3, }; -class GroveMotorDriveTB6612FNG : public Component, public i2c::I2CDevice { +class GroveMotorDriveTB6612FNG final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; @@ -162,7 +162,7 @@ class GroveMotorDriveTB6612FNG : public Component, public i2c::I2CDevice { }; template -class GROVETB6612FNGMotorRunAction : public Action, public Parented { +class GROVETB6612FNGMotorRunAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) TEMPLATABLE_VALUE(uint16_t, speed) @@ -183,7 +183,7 @@ class GROVETB6612FNGMotorRunAction : public Action, public Parented -class GROVETB6612FNGMotorBrakeAction : public Action, public Parented { +class GROVETB6612FNGMotorBrakeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) @@ -191,7 +191,7 @@ class GROVETB6612FNGMotorBrakeAction : public Action, public Parented -class GROVETB6612FNGMotorStopAction : public Action, public Parented { +class GROVETB6612FNGMotorStopAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) @@ -199,19 +199,19 @@ class GROVETB6612FNGMotorStopAction : public Action, public Parented -class GROVETB6612FNGMotorStandbyAction : public Action, public Parented { +class GROVETB6612FNGMotorStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->standby(); } }; template -class GROVETB6612FNGMotorNoStandbyAction : public Action, public Parented { +class GROVETB6612FNGMotorNoStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->not_standby(); } }; template -class GROVETB6612FNGMotorChangeAddressAction : public Action, public Parented { +class GROVETB6612FNGMotorChangeAddressAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, address) diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 27ae32cc46..76d430737a 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -65,7 +65,7 @@ constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 -class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { +class GrowattSolar final : public PollingComponent, public modbus::ModbusDevice { public: void loop() override; void update() override; diff --git a/esphome/components/gt911/binary_sensor/gt911_button.h b/esphome/components/gt911/binary_sensor/gt911_button.h index 5aab457095..ccb725b50f 100644 --- a/esphome/components/gt911/binary_sensor/gt911_button.h +++ b/esphome/components/gt911/binary_sensor/gt911_button.h @@ -7,10 +7,10 @@ namespace esphome::gt911 { -class GT911Button : public binary_sensor::BinarySensor, - public Component, - public GT911ButtonListener, - public Parented { +class GT911Button final : public binary_sensor::BinarySensor, + public Component, + public GT911ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index 0f1eeae720..465df528e5 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -12,7 +12,7 @@ class GT911ButtonListener { virtual void update_button(uint8_t index, bool state) = 0; }; -class GT911Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class GT911Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: /// @brief Initialize the GT911 touchscreen. /// diff --git a/esphome/components/haier/automation.h b/esphome/components/haier/automation.h index e345867d6f..a81fd4bdb7 100644 --- a/esphome/components/haier/automation.h +++ b/esphome/components/haier/automation.h @@ -6,7 +6,7 @@ namespace esphome::haier { -template class DisplayOnAction : public Action { +template class DisplayOnAction final : public Action { public: DisplayOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_display_state(true); } @@ -15,7 +15,7 @@ template class DisplayOnAction : public Action { HaierClimateBase *parent_; }; -template class DisplayOffAction : public Action { +template class DisplayOffAction final : public Action { public: DisplayOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_display_state(false); } @@ -24,7 +24,7 @@ template class DisplayOffAction : public Action { HaierClimateBase *parent_; }; -template class BeeperOnAction : public Action { +template class BeeperOnAction final : public Action { public: BeeperOnAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_beeper_state(true); } @@ -33,7 +33,7 @@ template class BeeperOnAction : public Action { HonClimate *parent_; }; -template class BeeperOffAction : public Action { +template class BeeperOffAction final : public Action { public: BeeperOffAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_beeper_state(false); } @@ -42,7 +42,7 @@ template class BeeperOffAction : public Action { HonClimate *parent_; }; -template class VerticalAirflowAction : public Action { +template class VerticalAirflowAction final : public Action { public: VerticalAirflowAction(HonClimate *parent) : parent_(parent) {} TEMPLATABLE_VALUE(hon_protocol::VerticalSwingMode, direction) @@ -52,7 +52,7 @@ template class VerticalAirflowAction : public Action { HonClimate *parent_; }; -template class HorizontalAirflowAction : public Action { +template class HorizontalAirflowAction final : public Action { public: HorizontalAirflowAction(HonClimate *parent) : parent_(parent) {} TEMPLATABLE_VALUE(hon_protocol::HorizontalSwingMode, direction) @@ -62,7 +62,7 @@ template class HorizontalAirflowAction : public Action { HonClimate *parent_; }; -template class HealthOnAction : public Action { +template class HealthOnAction final : public Action { public: HealthOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_health_mode(true); } @@ -71,7 +71,7 @@ template class HealthOnAction : public Action { HaierClimateBase *parent_; }; -template class HealthOffAction : public Action { +template class HealthOffAction final : public Action { public: HealthOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_health_mode(false); } @@ -80,7 +80,7 @@ template class HealthOffAction : public Action { HaierClimateBase *parent_; }; -template class StartSelfCleaningAction : public Action { +template class StartSelfCleaningAction final : public Action { public: StartSelfCleaningAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->start_self_cleaning(); } @@ -89,7 +89,7 @@ template class StartSelfCleaningAction : public Action { HonClimate *parent_; }; -template class StartSteriCleaningAction : public Action { +template class StartSteriCleaningAction final : public Action { public: StartSteriCleaningAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->start_steri_cleaning(); } @@ -98,7 +98,7 @@ template class StartSteriCleaningAction : public Action { HonClimate *parent_; }; -template class PowerOnAction : public Action { +template class PowerOnAction final : public Action { public: PowerOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->send_power_on_command(); } @@ -107,7 +107,7 @@ template class PowerOnAction : public Action { HaierClimateBase *parent_; }; -template class PowerOffAction : public Action { +template class PowerOffAction final : public Action { public: PowerOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->send_power_off_command(); } @@ -116,7 +116,7 @@ template class PowerOffAction : public Action { HaierClimateBase *parent_; }; -template class PowerToggleAction : public Action { +template class PowerToggleAction final : public Action { public: PowerToggleAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->toggle_power(); } diff --git a/esphome/components/haier/button/self_cleaning.h b/esphome/components/haier/button/self_cleaning.h index 9d330e4dfe..fc5a73b1e8 100644 --- a/esphome/components/haier/button/self_cleaning.h +++ b/esphome/components/haier/button/self_cleaning.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class SelfCleaningButton : public button::Button, public Parented { +class SelfCleaningButton final : public button::Button, public Parented { public: SelfCleaningButton() = default; diff --git a/esphome/components/haier/button/steri_cleaning.h b/esphome/components/haier/button/steri_cleaning.h index cac02dd267..4799c0e2ae 100644 --- a/esphome/components/haier/button/steri_cleaning.h +++ b/esphome/components/haier/button/steri_cleaning.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class SteriCleaningButton : public button::Button, public Parented { +class SteriCleaningButton final : public button::Button, public Parented { public: SteriCleaningButton() = default; diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index 5b477a5cea..ba36e6a8fb 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -35,7 +35,7 @@ struct HonSettings { bool quiet_mode_state{false}; }; -class HonClimate : public HaierClimateBase { +class HonClimate final : public HaierClimateBase { #ifdef USE_SENSOR public: enum class SubSensorType { diff --git a/esphome/components/haier/smartair2_climate.h b/esphome/components/haier/smartair2_climate.h index 68b0e4a0db..dc9a60f06f 100644 --- a/esphome/components/haier/smartair2_climate.h +++ b/esphome/components/haier/smartair2_climate.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class Smartair2Climate : public HaierClimateBase { +class Smartair2Climate final : public HaierClimateBase { public: Smartair2Climate(); Smartair2Climate(const Smartair2Climate &) = delete; diff --git a/esphome/components/haier/switch/beeper.h b/esphome/components/haier/switch/beeper.h index 2d20f1cd83..f27b419a2e 100644 --- a/esphome/components/haier/switch/beeper.h +++ b/esphome/components/haier/switch/beeper.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class BeeperSwitch : public switch_::Switch, public Parented { +class BeeperSwitch final : public switch_::Switch, public Parented { public: BeeperSwitch() = default; diff --git a/esphome/components/haier/switch/display.h b/esphome/components/haier/switch/display.h index 9baf3b9fb8..bf60538e11 100644 --- a/esphome/components/haier/switch/display.h +++ b/esphome/components/haier/switch/display.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class DisplaySwitch : public switch_::Switch, public Parented { +class DisplaySwitch final : public switch_::Switch, public Parented { public: DisplaySwitch() = default; diff --git a/esphome/components/haier/switch/health_mode.h b/esphome/components/haier/switch/health_mode.h index ec77b1638a..f5d3dad0f2 100644 --- a/esphome/components/haier/switch/health_mode.h +++ b/esphome/components/haier/switch/health_mode.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class HealthModeSwitch : public switch_::Switch, public Parented { +class HealthModeSwitch final : public switch_::Switch, public Parented { public: HealthModeSwitch() = default; diff --git a/esphome/components/haier/switch/quiet_mode.h b/esphome/components/haier/switch/quiet_mode.h index 8ef7b5bb89..f1ab85f4e3 100644 --- a/esphome/components/haier/switch/quiet_mode.h +++ b/esphome/components/haier/switch/quiet_mode.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class QuietModeSwitch : public switch_::Switch, public Parented { +class QuietModeSwitch final : public switch_::Switch, public Parented { public: QuietModeSwitch() = default; diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index c54b0dcf14..ec6d5b5657 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -8,7 +8,7 @@ namespace esphome::havells_solar { -class HavellsSolar : public PollingComponent, public modbus::ModbusDevice { +class HavellsSolar final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index 62149d99cd..187b6d2a97 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -12,7 +12,7 @@ enum DecayMode { DECAY_MODE_FAST = 1, }; -class HBridgeFan : public Component, public fan::Fan { +class HBridgeFan final : public Component, public fan::Fan { public: HBridgeFan(int speed_count, DecayMode decay_mode) : speed_count_(speed_count), decay_mode_(decay_mode) {} @@ -46,7 +46,7 @@ class HBridgeFan : public Component, public fan::Fan { void set_hbridge_levels_(float a_level, float b_level, float enable); }; -template class BrakeAction : public Action { +template class BrakeAction final : public Action { public: explicit BrakeAction(HBridgeFan *parent) : parent_(parent) {} diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index 16408f24f1..c0107fdc0d 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -7,7 +7,7 @@ namespace esphome::hbridge { -class HBridgeLightOutput : public Component, public light::LightOutput { +class HBridgeLightOutput final : public Component, public light::LightOutput { public: void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } diff --git a/esphome/components/hbridge/switch/hbridge_switch.h b/esphome/components/hbridge/switch/hbridge_switch.h index de867271fe..5c03958991 100644 --- a/esphome/components/hbridge/switch/hbridge_switch.h +++ b/esphome/components/hbridge/switch/hbridge_switch.h @@ -16,7 +16,7 @@ enum RelayState : uint8_t { RELAY_STATE_UNKNOWN = 4, }; -class HBridgeSwitch : public switch_::Switch, public Component { +class HBridgeSwitch final : public switch_::Switch, public Component { public: void set_on_pin(GPIOPin *pin) { this->on_pin_ = pin; } void set_off_pin(GPIOPin *pin) { this->off_pin_ = pin; } diff --git a/esphome/components/hc8/hc8.h b/esphome/components/hc8/hc8.h index b060f38a80..681dffe4f6 100644 --- a/esphome/components/hc8/hc8.h +++ b/esphome/components/hc8/hc8.h @@ -9,7 +9,7 @@ namespace esphome::hc8 { -class HC8Component : public PollingComponent, public uart::UARTDevice { +class HC8Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -26,7 +26,7 @@ class HC8Component : public PollingComponent, public uart::UARTDevice { bool warmup_complete_{false}; }; -template class HC8CalibrateAction : public Action, public Parented { +template class HC8CalibrateAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, baseline) diff --git a/esphome/components/hdc1080/hdc1080.h b/esphome/components/hdc1080/hdc1080.h index 1e3bf77788..21580ff9ab 100644 --- a/esphome/components/hdc1080/hdc1080.h +++ b/esphome/components/hdc1080/hdc1080.h @@ -6,7 +6,7 @@ namespace esphome::hdc1080 { -class HDC1080Component : public PollingComponent, public i2c::I2CDevice { +class HDC1080Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } diff --git a/esphome/components/hdc2010/hdc2010.h b/esphome/components/hdc2010/hdc2010.h index ad6df3ff48..95c8c24e60 100644 --- a/esphome/components/hdc2010/hdc2010.h +++ b/esphome/components/hdc2010/hdc2010.h @@ -6,7 +6,7 @@ namespace esphome::hdc2010 { -class HDC2010Component : public PollingComponent, public i2c::I2CDevice { +class HDC2010Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } diff --git a/esphome/components/hdc2080/hdc2080.h b/esphome/components/hdc2080/hdc2080.h index daa10d371d..8d86a7d41c 100644 --- a/esphome/components/hdc2080/hdc2080.h +++ b/esphome/components/hdc2080/hdc2080.h @@ -6,7 +6,7 @@ namespace esphome::hdc2080 { -class HDC2080Component : public PollingComponent, public i2c::I2CDevice { +class HDC2080Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } void set_humidity(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } diff --git a/esphome/components/hdc302x/hdc302x.h b/esphome/components/hdc302x/hdc302x.h index 6afea0a8c0..cc5343ee89 100644 --- a/esphome/components/hdc302x/hdc302x.h +++ b/esphome/components/hdc302x/hdc302x.h @@ -20,7 +20,7 @@ enum HDC302XPowerMode : uint8_t { Datasheet: https://www.ti.com/lit/ds/symlink/hdc3020.pdf */ -class HDC302XComponent : public PollingComponent, public i2c::I2CDevice { +class HDC302XComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; @@ -48,7 +48,7 @@ class HDC302XComponent : public PollingComponent, public i2c::I2CDevice { uint32_t conversion_delay_ms_(); }; -template class HeaterOnAction : public Action, public Parented { +template class HeaterOnAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, power) TEMPLATABLE_VALUE(uint32_t, duration) @@ -60,7 +60,7 @@ template class HeaterOnAction : public Action, public Par } }; -template class HeaterOffAction : public Action, public Parented { +template class HeaterOffAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_heater(); } }; diff --git a/esphome/components/he60r/he60r.h b/esphome/components/he60r/he60r.h index e7b5c97969..ef8dde4804 100644 --- a/esphome/components/he60r/he60r.h +++ b/esphome/components/he60r/he60r.h @@ -7,7 +7,7 @@ namespace esphome::he60r { -class HE60rCover : public cover::Cover, public Component, public uart::UARTDevice { +class HE60rCover final : public cover::Cover, public Component, public uart::UARTDevice { public: void setup() override; void loop() override; diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index a277424df6..8e0668d59d 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -93,7 +93,7 @@ enum VerticalDirection { const float TEMP_MIN = 0; // Celsius const float TEMP_MAX = 100; // Celsius -class HeatpumpIRClimate : public climate_ir::ClimateIR { +class HeatpumpIRClimate final : public climate_ir::ClimateIR { public: HeatpumpIRClimate() : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.h b/esphome/components/hitachi_ac344/hitachi_ac344.h index b9d776cc59..c5773ac222 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.h +++ b/esphome/components/hitachi_ac344/hitachi_ac344.h @@ -75,7 +75,7 @@ const uint16_t HITACHI_AC344_BITS = HITACHI_AC344_STATE_LENGTH * 8; #define GETBIT8(a, b) ((a) & ((uint8_t) 1 << (b))) #define GETBITS8(data, offset, size) (((data) & (((uint8_t) UINT8_MAX >> (8 - (size))) << (offset))) >> (offset)) -class HitachiClimate : public climate_ir::ClimateIR { +class HitachiClimate final : public climate_ir::ClimateIR { public: HitachiClimate() : climate_ir::ClimateIR(HITACHI_AC344_TEMP_MIN, HITACHI_AC344_TEMP_MAX, 1.0F, true, true, diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.h b/esphome/components/hitachi_ac424/hitachi_ac424.h index ef7f128a5a..31efd98c3d 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.h +++ b/esphome/components/hitachi_ac424/hitachi_ac424.h @@ -77,7 +77,7 @@ const uint16_t HITACHI_AC424_BITS = HITACHI_AC424_STATE_LENGTH * 8; #define HITACHI_AC424_GETBITS8(data, offset, size) \ (((data) & (((uint8_t) UINT8_MAX >> (8 - (size))) << (offset))) >> (offset)) -class HitachiClimate : public climate_ir::ClimateIR { +class HitachiClimate final : public climate_ir::ClimateIR { public: HitachiClimate() : climate_ir::ClimateIR(HITACHI_AC424_TEMP_MIN, HITACHI_AC424_TEMP_MAX, 1.0F, true, true, diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index fd8257b435..34246f52f0 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -71,7 +71,7 @@ enum HlkFm22xFaceDirection { FACE_DIRECTION_UP = 0x10, }; -class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { +class HlkFm22xComponent final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -141,7 +141,7 @@ class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction : public Action, public Parented { +template class EnrollmentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, name) TEMPLATABLE_VALUE(uint8_t, direction) @@ -153,7 +153,7 @@ template class EnrollmentAction : public Action, public P } }; -template class DeleteAction : public Action, public Parented { +template class DeleteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int16_t, face_id) @@ -163,17 +163,17 @@ template class DeleteAction : public Action, public Paren } }; -template class DeleteAllAction : public Action, public Parented { +template class DeleteAllAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->delete_all_faces(); } }; -template class ScanAction : public Action, public Parented { +template class ScanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->scan_face(); } }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; diff --git a/esphome/components/hlw8012/hlw8012.h b/esphome/components/hlw8012/hlw8012.h index d1d340bf45..0691132498 100644 --- a/esphome/components/hlw8012/hlw8012.h +++ b/esphome/components/hlw8012/hlw8012.h @@ -23,7 +23,7 @@ enum HLW8012SensorModels { #define USE_PCNT false #endif -class HLW8012Component : public PollingComponent { +class HLW8012Component final : public PollingComponent { public: HLW8012Component() : cf_store_(*pulse_counter::get_storage(USE_PCNT)), cf1_store_(*pulse_counter::get_storage(USE_PCNT)) {} diff --git a/esphome/components/hlw8032/hlw8032.h b/esphome/components/hlw8032/hlw8032.h index d4c7dbd26c..56fd27a15a 100644 --- a/esphome/components/hlw8032/hlw8032.h +++ b/esphome/components/hlw8032/hlw8032.h @@ -6,7 +6,7 @@ namespace esphome::hlw8032 { -class HLW8032Component : public Component, public uart::UARTDevice { +class HLW8032Component final : public Component, public uart::UARTDevice { public: void loop() override; void dump_config() override; From 2fe67a6eda5b7a69746e7dcedd363acadb03d18a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:17:47 +1200 Subject: [PATCH 1775/2214] [graphical_display_menu] Mark configurable classes as final (#17129) --- .../graphical_display_menu.cpp | 12 ++++++------ .../graphical_display_menu/graphical_display_menu.h | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index 81971e457c..b3c3b27e06 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -118,7 +118,7 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const for (size_t i = 0; max_item_index >= 0 && i <= static_cast(max_item_index); i++) { const auto *item = this->displayed_item_->get_item(i); const bool selected = i == this->cursor_index_; - const display::Rect item_dimensions = this->measure_item(display, item, bounds, selected); + const display::Rect item_dimensions = this->measure_item_(display, item, bounds, selected); menu_dimensions.push_back(item_dimensions); total_height += item_dimensions.h + (i == 0 ? 0 : y_padding); @@ -181,7 +181,7 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const dimensions.y = y_offset; dimensions.x = bounds->x; - this->draw_item(display, item, &dimensions, selected); + this->draw_item_(display, item, &dimensions, selected); y_offset += dimensions.h + y_padding; } @@ -189,8 +189,8 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const display->end_clipping(); } -display::Rect GraphicalDisplayMenu::measure_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, const bool selected) { +display::Rect GraphicalDisplayMenu::measure_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, const bool selected) { display::Rect dimensions(0, 0, 0, 0); if (selected) { @@ -218,8 +218,8 @@ display::Rect GraphicalDisplayMenu::measure_item(display::Display *display, cons return dimensions; } -inline void GraphicalDisplayMenu::draw_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, const bool selected) { +inline void GraphicalDisplayMenu::draw_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, const bool selected) { const auto background_color = selected ? this->foreground_color_ : this->background_color_; const auto foreground_color = selected ? this->background_color_ : this->foreground_color_; diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index ce1db18525..ccdf3d304c 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -33,7 +33,7 @@ struct MenuItemValueArguments { bool is_menu_editing; }; -class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { +class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponent { public: void setup() override; void dump_config() override; @@ -53,10 +53,10 @@ class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { void draw_menu() override; void draw_menu_internal_(display::Display *display, const display::Rect *bounds); void draw_item(const display_menu_base::MenuItem *item, uint8_t row, bool selected) override; - virtual display::Rect measure_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, bool selected); - virtual void draw_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, bool selected); + display::Rect measure_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, bool selected); + void draw_item_(display::Display *display, const display_menu_base::MenuItem *item, const display::Rect *bounds, + bool selected); void update() override; void on_before_show() override; @@ -73,7 +73,7 @@ class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { CallbackManager on_redraw_callbacks_{}; }; -class GraphicalDisplayMenuOnRedrawTrigger : public Trigger { +class GraphicalDisplayMenuOnRedrawTrigger final : public Trigger { public: explicit GraphicalDisplayMenuOnRedrawTrigger(GraphicalDisplayMenu *parent) : parent_(parent) { parent->add_on_redraw_callback([this]() { this->trigger(this->parent_); }); From 614eae7a3b29915d24130e81bf4055b1268cf7f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 23:18:06 -0500 Subject: [PATCH 1776/2214] [dashboard_import] Store package_import_url in flash on ESP8266 (#17127) --- esphome/components/dashboard_import/__init__.py | 2 +- esphome/components/dashboard_import/dashboard_import.cpp | 7 ++++--- esphome/components/dashboard_import/dashboard_import.h | 6 ++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 911fc387a0..000db307b9 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -77,7 +77,7 @@ async def to_code(config): url = config[CONF_PACKAGE_IMPORT_URL] if config[CONF_IMPORT_FULL_CONFIG]: url += "?full_config" - cg.add(dashboard_import_ns.set_package_import_url(url)) + cg.add(dashboard_import_ns.set_package_import_url(cg.FlashStringLiteral(url))) def import_config( diff --git a/esphome/components/dashboard_import/dashboard_import.cpp b/esphome/components/dashboard_import/dashboard_import.cpp index f553adf273..adc01cc0a8 100644 --- a/esphome/components/dashboard_import/dashboard_import.cpp +++ b/esphome/components/dashboard_import/dashboard_import.cpp @@ -2,9 +2,10 @@ namespace esphome::dashboard_import { -static const char *g_package_import_url = ""; // NOLINT +static const char EMPTY_URL[] PROGMEM = ""; // NOLINT +static ProgmemStr g_package_import_url = reinterpret_cast(EMPTY_URL); // NOLINT -const char *get_package_import_url() { return g_package_import_url; } -void set_package_import_url(const char *url) { g_package_import_url = url; } +ProgmemStr get_package_import_url() { return g_package_import_url; } +void set_package_import_url(ProgmemStr url) { g_package_import_url = url; } } // namespace esphome::dashboard_import diff --git a/esphome/components/dashboard_import/dashboard_import.h b/esphome/components/dashboard_import/dashboard_import.h index 19f69b8546..166fd8b7be 100644 --- a/esphome/components/dashboard_import/dashboard_import.h +++ b/esphome/components/dashboard_import/dashboard_import.h @@ -1,8 +1,10 @@ #pragma once +#include "esphome/core/progmem.h" + namespace esphome::dashboard_import { -const char *get_package_import_url(); -void set_package_import_url(const char *url); +ProgmemStr get_package_import_url(); +void set_package_import_url(ProgmemStr url); } // namespace esphome::dashboard_import From 0df1db62057c0d35c91cf07b2c834bc364fd1099 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:01:22 -0500 Subject: [PATCH 1777/2214] Bump bundled esphome-device-builder to 1.0.13 (#17132) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1d39644ab8..214d6c7841 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.13 RUN \ platformio settings set enable_telemetry No \ From 24835769098daa93e16a72247d6c2b3a4efa2d46 Mon Sep 17 00:00:00 2001 From: "Joseph C. Lehner" Date: Mon, 22 Jun 2026 16:51:10 +0200 Subject: [PATCH 1778/2214] [sx126x] Add data whitening options (#17102) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/sx126x/__init__.py | 8 ++++++++ esphome/components/sx126x/sx126x.cpp | 16 +++++++++++++++- esphome/components/sx126x/sx126x.h | 4 ++++ esphome/components/sx126x/sx126x_reg.h | 1 + tests/components/sx126x/common.yaml | 2 ++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index a4ba5c34f3..29e3ad5359 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -41,6 +41,8 @@ CONF_SPREADING_FACTOR = "spreading_factor" CONF_SYNC_VALUE = "sync_value" CONF_TCXO_VOLTAGE = "tcxo_voltage" CONF_TCXO_DELAY = "tcxo_delay" +CONF_WHITENING_ENABLE = "whitening_enable" +CONF_WHITENING_INITIAL = "whitening_initial" sx126x_ns = cg.esphome_ns.namespace("sx126x") SX126x = sx126x_ns.class_("SX126x", cg.Component, spi.SPIDevice) @@ -232,6 +234,10 @@ CONFIG_SCHEMA = ( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=262144000)), ), + cv.Optional(CONF_WHITENING_ENABLE, default=False): cv.boolean, + cv.Optional(CONF_WHITENING_INITIAL, default=0x0100): cv.All( + cv.hex_int, cv.Range(min=0, max=0x1FF) + ), }, ) .extend(cv.COMPONENT_SCHEMA) @@ -285,6 +291,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_rf_switch(config[CONF_RF_SWITCH])) cg.add(var.set_tcxo_voltage(config[CONF_TCXO_VOLTAGE])) cg.add(var.set_tcxo_delay(config[CONF_TCXO_DELAY])) + cg.add(var.set_whitening_enable(config[CONF_WHITENING_ENABLE])) + cg.add(var.set_whitening_initial(config[CONF_WHITENING_INITIAL])) NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index aed0105e1f..af42c63bf4 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -251,6 +251,16 @@ void SX126x::configure() { this->write_register_(REG_CRC_POLYNOMIAL, buf, 2); } + // set whitening params + if (this->whitening_enable_) { + // according to the datasheet, section 12 table 12-1 "The user should not + // change the value of the 7 MSB of this register" + this->read_register_(REG_WHITENING_INITIAL, buf, 1); + buf[0] = (buf[0] & 0xFE) | ((this->whitening_initial_ >> 8) & 0x01); + buf[1] = this->whitening_initial_ & 0xFF; + this->write_register_(REG_WHITENING_INITIAL, buf, 2); + } + // set packet params and sync word this->set_packet_params_(this->get_max_packet_size()); if (!this->sync_value_.empty()) { @@ -297,7 +307,7 @@ void SX126x::set_packet_params_(uint8_t payload_length) { } else { buf[7] = 0x01; } - buf[8] = 0x00; + buf[8] = (this->whitening_enable_) ? 0x01 : 0x00; this->write_opcode_(RADIO_SET_PACKETPARAMS, buf, 9); } } @@ -541,6 +551,10 @@ void SX126x::dump_config() { ESP_LOGCONFIG(TAG, " Sync Value: 0x%s", format_hex_to(hex_buf, this->sync_value_.data(), this->sync_value_.size())); } + ESP_LOGCONFIG(TAG, " Whitening Enable: %s", TRUEFALSE(this->whitening_enable_)); + if (this->whitening_enable_) { + ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->whitening_initial_); + } if (this->is_failed()) { ESP_LOGE(TAG, "Configuring SX126x failed"); } diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 8298beb36e..6816084df0 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -71,6 +71,8 @@ class SX126x : public Component, void set_crc_size(uint8_t crc_size) { this->crc_size_ = crc_size; } void set_crc_polynomial(uint16_t crc_polynomial) { this->crc_polynomial_ = crc_polynomial; } void set_crc_initial(uint16_t crc_initial) { this->crc_initial_ = crc_initial; } + void set_whitening_enable(bool whitening_enable) { this->whitening_enable_ = whitening_enable; } + void set_whitening_initial(uint16_t whitening_initial) { this->whitening_initial_ = whitening_initial; } void set_deviation(uint32_t deviation) { this->deviation_ = deviation; } void set_dio1_pin(GPIOPin *dio1_pin) { this->dio1_pin_ = dio1_pin; } void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } @@ -128,6 +130,8 @@ class SX126x : public Component, uint8_t crc_size_{0}; uint16_t crc_polynomial_{0}; uint16_t crc_initial_{0}; + bool whitening_enable_{false}; + uint16_t whitening_initial_{0}; uint32_t deviation_{0}; uint32_t frequency_{0}; uint32_t payload_length_{0}; diff --git a/esphome/components/sx126x/sx126x_reg.h b/esphome/components/sx126x/sx126x_reg.h index c70817364f..197a2aaadb 100644 --- a/esphome/components/sx126x/sx126x_reg.h +++ b/esphome/components/sx126x/sx126x_reg.h @@ -52,6 +52,7 @@ enum SX126xOpCode : uint8_t { enum SX126xRegister : uint16_t { REG_VERSION_STRING = 0x0320, + REG_WHITENING_INITIAL = 0x06B8, REG_CRC_INITIAL = 0x06BC, REG_CRC_POLYNOMIAL = 0x06BE, REG_GFSK_SYNCWORD = 0x06C0, diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index a4a24d8da7..05794ad1a8 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -21,6 +21,8 @@ sx126x: coding_rate: CR_4_6 tcxo_voltage: 1_8V tcxo_delay: 5ms + whitening_enable: false + whitening_initial: 0x1FF on_packet: then: - lambda: |- From 6c1724874b9ad35e921e18980444b581704d3043 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:04:22 -0500 Subject: [PATCH 1779/2214] Bump zeroconf from 0.149.16 to 0.150.0 (#17137) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b01b2a4c6b..462438016e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ platformio==6.1.19 esptool==5.3.0 click==8.3.3 aioesphomeapi==45.3.1 -zeroconf==0.149.16 +zeroconf==0.150.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 3a4831bd7e4aa74d800c67274236c574b921ed05 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Tue, 23 Jun 2026 02:34:11 +0530 Subject: [PATCH 1780/2214] [ble_nus] Atomic log-line framing (no partial ring-buffer writes) (#17105) Co-authored-by: Claude Opus 4.8 Co-authored-by: tomaszduda23 --- esphome/components/ble_nus/ble_nus.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index 71d98332e0..b566122f8a 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -25,11 +25,14 @@ void BLENUS::write_array(const uint8_t *data, size_t len) { if (atomic_get(&this->tx_status_) == TX_DISABLED) { return; } - auto sent = ring_buf_put(&global_ble_tx_ring_buf, data, len); - if (sent < len) { - ESP_LOGE(TAG, "TX dropping %u bytes", len - sent); + // ring_buf_put() performs a partial write when the buffer is nearly full, which would commit a + // truncated fragment and corrupt the stream. Only write when the whole payload fits, so the byte + // stream never contains a partial message. + if (ring_buf_space_get(&global_ble_tx_ring_buf) < len) { + ESP_LOGE(TAG, "TX dropping %u bytes", len); return; } + ring_buf_put(&global_ble_tx_ring_buf, data, len); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(uart::UART_DIRECTION_TX, data[i]); @@ -197,6 +200,10 @@ void BLENUS::setup() { void BLENUS::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; + // make sure there is space for '\n' or entire message is dropped + if (ring_buf_space_get(&global_ble_tx_ring_buf) < message_len + 1) { + return; + } this->write_array(reinterpret_cast(message), message_len); const char c = '\n'; this->write_array(reinterpret_cast(&c), 1); From 1ace836744572002a305cc14b439e9f783ca066f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:41:21 -0400 Subject: [PATCH 1781/2214] [espidf] Don't fail framework check on broken unrelated PATH tools (#17053) --- esphome/espidf/framework.py | 16 +++++++++------- tests/unit_tests/test_espidf_framework.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6f4aeef9f0..4053898a8e 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -609,14 +609,16 @@ def _check_esphome_idf_framework_install( install = True if _check_stamp(env_stamp_file, stamp_info): _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) - cmd = [ - get_system_python_path(), - str(idf_tools_path), - "--non-interactive", - "check", - ] - if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env): + # Validate via the managed tool-path resolution, not ``idf_tools.py check``: + # ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a + # broken Homebrew openocd), which forced a toolchain reinstall on every build. + try: + _get_idf_tool_paths(framework_path, env) install = False + except RuntimeError as err: + _LOGGER.debug( + "ESP-IDF %s tool resolution failed, reinstalling: %s", version, err + ) # 4. Install framework tools if not installed or needs update if install: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index d89b93f478..525cd55146 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -298,6 +298,9 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.archive_extract_all") as extract, patch("esphome.espidf.framework.create_venv") as venv, patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, + patch( + "esphome.espidf.framework._get_idf_tool_paths", return_value=([], {}) + ) as tool_paths, patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), @@ -308,7 +311,12 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): yield SimpleNamespace( - download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone + download=download, + extract=extract, + venv=venv, + run_ok=run_ok, + tool_paths=tool_paths, + clone=clone, ) @@ -403,10 +411,10 @@ def test_check_esp_idf_install_stamp_mismatch_reinstalls( def test_check_esp_idf_install_check_command_failure_reinstalls( espidf_mocks: SimpleNamespace, ) -> None: - """A failing idf_tools check reinstalls tools (marker present, no re-extract).""" + """A failing tool-path resolution reinstalls tools (marker present, no re-extract).""" _mark_installed() - # idf_tools check fails -> install stays True; the later installs succeed. - espidf_mocks.run_ok.side_effect = [False, True, True, True] + # Managed tool resolution fails -> install stays True; the later installs succeed. + espidf_mocks.tool_paths.side_effect = RuntimeError("missing ESP-IDF tool") check_esp_idf_install(_IDF_VERSION, features=["fb"]) espidf_mocks.extract.assert_not_called() From 5fcf656806e93667b9a268cf81513f1847d76c64 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:45:22 -0500 Subject: [PATCH 1782/2214] Bump bundled esphome-device-builder to 1.0.14 (#17139) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 214d6c7841..bf37d6d88b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.13 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.14 RUN \ platformio settings set enable_telemetry No \ From 69d700727d6a0456a03ea30a8a0728ebffc85b4c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:25:24 +1200 Subject: [PATCH 1783/2214] [docker] Remove dead HA addon env exports (streamer_mode, relative_url) (#17140) --- docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index d4628ffa83..dff61fd2f3 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -19,14 +19,6 @@ if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true fi -if bashio::config.true 'streamer_mode'; then - export ESPHOME_STREAMER_MODE=true -fi - -if bashio::config.has_value 'relative_url'; then - export ESPHOME_DASHBOARD_RELATIVE_URL=$(bashio::config 'relative_url') -fi - if bashio::config.has_value 'default_compile_process_limit'; then export ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT=$(bashio::config 'default_compile_process_limit') else From c70d56807fd5fe7eb42fb551cf18ac173066f0e7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:44:58 +1200 Subject: [PATCH 1784/2214] [motion] Make motion test configs mergeable in CI (#17149) --- tests/components/bmi270/common.yaml | 34 ++++++++++++++++++--------- tests/components/lsm6ds/common.yaml | 36 +++++++++++++++++++---------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 0ffb1c6281..0f9b70631c 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -3,52 +3,64 @@ sensor: name: "BMI270 Temperature" - platform: motion + motion_id: bmi270_motion type: acceleration_x - name: "Accel X" + name: "BMI270 Accel X" accuracy_decimals: 4 filters: - sliding_window_moving_average: window_size: 4 send_every: 1 - platform: motion + motion_id: bmi270_motion type: acceleration_y - name: "Accel Y" + name: "BMI270 Accel Y" accuracy_decimals: 4 - platform: motion + motion_id: bmi270_motion type: acceleration_z - name: "Accel Z" + name: "BMI270 Accel Z" accuracy_decimals: 4 # Gyroscope axes (unit: °/s) - platform: motion + motion_id: bmi270_motion type: gyroscope_x - name: "Gyro X" + name: "BMI270 Gyro X" - platform: motion + motion_id: bmi270_motion type: gyroscope_y - name: "Gyro Y" + name: "BMI270 Gyro Y" - platform: motion + motion_id: bmi270_motion type: gyroscope_z - name: "Gyro Z" + name: "BMI270 Gyro Z" - platform: motion + motion_id: bmi270_motion type: angular_rate_x - name: "Angular Rate X" + name: "BMI270 Angular Rate X" - platform: motion + motion_id: bmi270_motion type: angular_rate_y - name: "Angular Rate Y" + name: "BMI270 Angular Rate Y" - platform: motion + motion_id: bmi270_motion type: angular_rate_z - name: "Angular Rate Z" + name: "BMI270 Angular Rate Z" - platform: motion + motion_id: bmi270_motion type: pitch - name: "Pitch" + name: "BMI270 Pitch" - platform: motion + motion_id: bmi270_motion type: roll - name: "Roll" + name: "BMI270 Roll" motion: - platform: bmi270 + id: bmi270_motion # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/lsm6ds/common.yaml b/tests/components/lsm6ds/common.yaml index 832254781f..aeacd31448 100644 --- a/tests/components/lsm6ds/common.yaml +++ b/tests/components/lsm6ds/common.yaml @@ -1,54 +1,66 @@ sensor: - platform: lsm6ds - name: "lsm6ds Temperature" + name: "LSM6DS Temperature" - platform: motion + motion_id: lsm6ds_motion type: acceleration_x - name: "Accel X" + name: "LSM6DS Accel X" accuracy_decimals: 4 filters: - sliding_window_moving_average: window_size: 4 send_every: 1 - platform: motion + motion_id: lsm6ds_motion type: acceleration_y - name: "Accel Y" + name: "LSM6DS Accel Y" accuracy_decimals: 4 - platform: motion + motion_id: lsm6ds_motion type: acceleration_z - name: "Accel Z" + name: "LSM6DS Accel Z" accuracy_decimals: 4 # Gyroscope axes (unit: °/s) - platform: motion + motion_id: lsm6ds_motion type: gyroscope_x - name: "Gyro X" + name: "LSM6DS Gyro X" - platform: motion + motion_id: lsm6ds_motion type: gyroscope_y - name: "Gyro Y" + name: "LSM6DS Gyro Y" - platform: motion + motion_id: lsm6ds_motion type: gyroscope_z - name: "Gyro Z" + name: "LSM6DS Gyro Z" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_x - name: "Angular Rate X" + name: "LSM6DS Angular Rate X" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_y - name: "Angular Rate Y" + name: "LSM6DS Angular Rate Y" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_z - name: "Angular Rate Z" + name: "LSM6DS Angular Rate Z" - platform: motion + motion_id: lsm6ds_motion type: pitch - name: "Pitch" + name: "LSM6DS Pitch" - platform: motion + motion_id: lsm6ds_motion type: roll - name: "Roll" + name: "LSM6DS Roll" motion: - platform: lsm6ds + id: lsm6ds_motion # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G From 41747c2de736f8f84d8970c69e7e788a3b5f5f82 Mon Sep 17 00:00:00 2001 From: arunderwood Date: Mon, 22 Jun 2026 23:50:02 -0700 Subject: [PATCH 1785/2214] [epaper_spi] Add support for the Inkplate 2 (#16856) --- esphome/components/epaper_spi/colorconv.h | 17 ++ .../epaper_spi/epaper_spi_inkplate2.cpp | 148 ++++++++++++++++++ .../epaper_spi/epaper_spi_inkplate2.h | 33 ++++ .../components/epaper_spi/models/inkplate2.py | 52 ++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++ 5 files changed, 271 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_spi_inkplate2.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_inkplate2.h create mode 100644 esphome/components/epaper_spi/models/inkplate2.py diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index a2ea28f4b6..d4ffd034a1 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -64,4 +64,21 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_ } } +/** Map RGB color to discrete BWR (black/white/red) 3 color key + * + * Convenience wrapper over color_to_bwyr for panels without a yellow ink; the yellow corner is + * folded into white. + * + * @tparam NATIVE_COLOR Type of native hardware color values + * @param color RGB color to convert from + * @param hw_black Native value for black + * @param hw_white Native value for white + * @param hw_red Native value for red + * @return Converted native hardware color value + */ +template +constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_red) { + return color_to_bwyr(color, hw_black, hw_white, /*hw_yellow=*/hw_white, hw_red); +} + } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp new file mode 100644 index 0000000000..fc0d674246 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp @@ -0,0 +1,148 @@ +// Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate2) + +#include "epaper_spi_inkplate2.h" +#include "colorconv.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.inkplate2"; + +// Map RGB to the panel's black/white/red via the shared converter. +enum class Inkplate2Color : uint8_t { BLACK, WHITE, RED }; + +static Inkplate2Color to_inkplate2_color(Color color) { + return color_to_bwr(color, Inkplate2Color::BLACK, Inkplate2Color::WHITE, Inkplate2Color::RED); +} + +void EPaperInkplate2::power_on() { + // Power-on (0x04) leads the init sequence, so there is nothing to do here. + ESP_LOGV(TAG, "Power on"); +} + +void EPaperInkplate2::power_off() { + ESP_LOGV(TAG, "Power off"); + this->cmd_data(0x50, {0xF7}); // VCOM and data interval + this->command(0x02); // power off +} + +void EPaperInkplate2::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); // full refresh only; partial is unused + // Send 0x11 then 0x12 back-to-back: 0x11 raises busy until the refresh finishes, so waiting for idle + // between them (as the state machine does between states) would add a ~16s stall. + this->cmd_data(0x11, {0x00}); // stop data transfer + this->command(0x12); // display refresh +} + +void EPaperInkplate2::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(0x07, {0xA5}); +} + +void EPaperInkplate2::fill(Color color) { + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); // clipping active: defer to the base per-pixel path + return; + } + + const size_t half_buffer = this->buffer_length_ / 2; + + // Plane encoding: B/W plane 1=white, 0=black; red plane 0=red, 1=no-red. + uint8_t bw_byte; + uint8_t red_byte; + switch (to_inkplate2_color(color)) { + case Inkplate2Color::BLACK: + bw_byte = 0x00; + red_byte = 0xFF; + break; + case Inkplate2Color::RED: + bw_byte = 0xFF; + red_byte = 0x00; + break; + case Inkplate2Color::WHITE: + default: + bw_byte = 0xFF; + red_byte = 0xFF; + break; + } + + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = bw_byte; + for (size_t i = half_buffer; i < this->buffer_length_; i++) + this->buffer_[i] = red_byte; + + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void EPaperInkplate2::clear() { this->fill(COLOR_ON); } + +void HOT EPaperInkplate2::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + + const size_t half_buffer = this->buffer_length_ / 2; + const size_t pos = y * this->row_width_ + x / 8; + const uint8_t mask = 0x80 >> (x & 0x07); // MSB first; see fill() for plane encoding + + switch (to_inkplate2_color(color)) { + case Inkplate2Color::BLACK: + this->buffer_[pos] &= ~mask; + this->buffer_[pos + half_buffer] |= mask; + break; + case Inkplate2Color::RED: + this->buffer_[pos] |= mask; + this->buffer_[pos + half_buffer] &= ~mask; + break; + case Inkplate2Color::WHITE: + default: + this->buffer_[pos] |= mask; + this->buffer_[pos + half_buffer] |= mask; + break; + } +} + +bool HOT EPaperInkplate2::send_buffer_range_(size_t end, uint32_t start_time) { + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + size_t buf_idx = 0; + while (this->current_data_index_ < end) { + bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++]; + if (buf_idx == sizeof bytes_to_send) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + buf_idx = 0; + if (millis() - start_time > MAX_TRANSFER_TIME) + return false; // yield; resume next loop + } + } + if (buf_idx != 0) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + } + return true; +} + +bool HOT EPaperInkplate2::transfer_data() { + const uint32_t start_time = millis(); + const size_t half_buffer = this->buffer_length_ / 2; + + // Black/white plane (first half) then red plane (second half). + if (this->current_data_index_ == 0) + this->command(0x10); + if (this->current_data_index_ < half_buffer && !this->send_buffer_range_(half_buffer, start_time)) + return false; + + if (this->current_data_index_ == half_buffer) + this->command(0x13); + if (!this->send_buffer_range_(this->buffer_length_, start_time)) + return false; + + this->current_data_index_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.h b/esphome/components/epaper_spi/epaper_spi_inkplate2.h new file mode 100644 index 0000000000..657eca4759 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.h @@ -0,0 +1,33 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +// Soldered Inkplate 2: 104x212 black/white/red (BWR) e-paper, UC8xxx-family controller. +class EPaperInkplate2 final : public EPaperBase { + public: + EPaperInkplate2(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { + // Dual-plane buffer: black/white plane followed by red plane, 1 bit per pixel each. + this->buffer_length_ = this->row_width_ * this->height_ * 2; + } + + void fill(Color color) override; + void clear() override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; + + bool transfer_data() override; + + // Streams buffer_[current_data_index_ .. end) in chunks; returns false if it yields on MAX_TRANSFER_TIME. + bool send_buffer_range_(size_t end, uint32_t start_time); +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/inkplate2.py b/esphome/components/epaper_spi/models/inkplate2.py new file mode 100644 index 0000000000..f1a952ce10 --- /dev/null +++ b/esphome/components/epaper_spi/models/inkplate2.py @@ -0,0 +1,52 @@ +# Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library + +from . import EpaperModel + + +class Inkplate2Model(EpaperModel): + def __init__(self, name, class_name="EPaperInkplate2", **kwargs): + super().__init__(name, class_name, **kwargs) + + def get_init_sequence(self, config: dict): + width, height = self.get_dimensions(config) + return ( + (0x04,), # power on + ( + 0x00, # panel setting + 0x0F, # LUT from OTP + 0x89, # temperature/boost/timing + ), + ( + 0x61, # resolution + width, # width: 1 byte + height >> 8, # height: 2 bytes, high byte first ... + height & 0xFF, # ... then low byte + ), + ( + 0x50, # VCOM and data interval + 0x77, + ), + ) + + +# Native orientation is portrait (104x212); use `rotation: 90` for the board's landscape orientation. +inkplate2 = Inkplate2Model( + "inkplate2", + width=104, + height=212, + data_rate="10MHz", + # A full 3-color refresh takes ~20s, so don't allow updates faster than that. + minimum_update_interval="30s", + # Default GPIO pins for the on-board Inkplate 2 wiring. + reset_pin=19, + dc_pin=33, + cs_pin=15, + busy_pin={ + "number": 32, + "inverted": True, # hardware: LOW=busy, HIGH=idle + "mode": { + "input": True, + "pullup": True, + }, + }, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 8a420f299a..6d5e276582 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -161,3 +161,24 @@ display: busy_pin: allow_other_uses: true number: GPIO4 + + # Soldered Inkplate 2 3-color e-paper (104x212, BWR) + - platform: epaper_spi + spi_id: spi_bus + model: inkplate2 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); From 9614bc20a0c4b08216d33223b9c50f9f3b457966 Mon Sep 17 00:00:00 2001 From: Zach Isbach Date: Tue, 23 Jun 2026 04:00:19 -0700 Subject: [PATCH 1786/2214] [epaper_spi] Add support for Waveshare 2.13" V4 series B (R/B/W) (#16828) --- .../epaper_spi/epaper_waveshare_b.cpp | 13 ++++++++++ .../epaper_spi/epaper_waveshare_b.h | 19 ++++++++++++++ .../components/epaper_spi/epaper_weact_3c.cpp | 2 +- .../components/epaper_spi/epaper_weact_3c.h | 3 +++ .../epaper_spi/models/waveshare_b.py | 26 +++++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++++++++++++++ 6 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 esphome/components/epaper_spi/epaper_waveshare_b.cpp create mode 100644 esphome/components/epaper_spi/epaper_waveshare_b.h create mode 100644 esphome/components/epaper_spi/models/waveshare_b.py diff --git a/esphome/components/epaper_spi/epaper_waveshare_b.cpp b/esphome/components/epaper_spi/epaper_waveshare_b.cpp new file mode 100644 index 0000000000..6875811b9b --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_b.cpp @@ -0,0 +1,13 @@ +#include "epaper_waveshare_b.h" + +namespace esphome::epaper_spi { + +bool EpaperWaveshareB::reset() { + if (EPaperBase::reset()) { + this->command(0x12); + return true; + } + return false; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_b.h b/esphome/components/epaper_spi/epaper_waveshare_b.h new file mode 100644 index 0000000000..3a391731d8 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_b.h @@ -0,0 +1,19 @@ +#pragma once +#include "epaper_weact_3c.h" + +namespace esphome::epaper_spi { + +/** + * Waveshare (B) series BWR e-paper displays using SSD1680-compatible controllers. + * Waveshare uses 0=red, 1=no-red, the inverse of EPaperWeAct3C + */ +class EpaperWaveshareB : public EPaperWeAct3C { + public: + using EPaperWeAct3C::EPaperWeAct3C; + + protected: + bool reset() override; + uint8_t transform_red_byte(uint8_t byte) const override { return static_cast(~byte); } +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_weact_3c.cpp b/esphome/components/epaper_spi/epaper_weact_3c.cpp index d4dac7076c..ad2021ed64 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.cpp +++ b/esphome/components/epaper_spi/epaper_weact_3c.cpp @@ -144,7 +144,7 @@ bool HOT EPaperWeAct3C::transfer_data() { size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_); for (size_t i = 0; i < bytes_to_copy; i++) { - bytes_to_send[i] = this->buffer_[red_offset + this->current_data_index_ + i]; + bytes_to_send[i] = this->transform_red_byte(this->buffer_[red_offset + this->current_data_index_ + i]); } this->write_array(bytes_to_send, bytes_to_copy); diff --git a/esphome/components/epaper_spi/epaper_weact_3c.h b/esphome/components/epaper_spi/epaper_weact_3c.h index 2df6f1ba09..a31c2be817 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.h +++ b/esphome/components/epaper_spi/epaper_weact_3c.h @@ -34,6 +34,9 @@ class EPaperWeAct3C : public EPaperBase { void draw_pixel_at(int x, int y, Color color) override; bool transfer_data() override; + + // Hook for subclasses to transform red plane bytes before they go on the wire. + virtual uint8_t transform_red_byte(uint8_t byte) const { return byte; } }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py new file mode 100644 index 0000000000..688e716456 --- /dev/null +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -0,0 +1,26 @@ +from . import EpaperModel + + +class WaveshareB(EpaperModel): + def __init__(self, name, **defaults): + super().__init__(name, "EpaperWaveshareB", **defaults) + + def get_init_sequence(self, config): + _, height = self.get_dimensions(config) + h = height - 1 + return ( + (0x01, h & 0xFF, h >> 8, 0x00), # Driver output control + (0x11, 0x03), # Data entry mode + (0x3C, 0x05), # Border waveform + (0x18, 0x80), # Internal temperature sensor + (0x21, 0x80, 0x80), # Display update control + ) + + +WaveshareB( + "waveshare-2.13in-bv4", + width=122, + height=250, + data_rate="10MHz", + minimum_update_interval="1s", +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 6d5e276582..60e4008f4f 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -162,6 +162,27 @@ display: allow_other_uses: true number: GPIO4 + # Waveshare 2.13" V4 B series 3-color e-paper (122x250, BWR, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-bv4 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Soldered Inkplate 2 3-color e-paper (104x212, BWR) - platform: epaper_spi spi_id: spi_bus From 225d426d95426bd756b8c0c9c69251c298bcc50e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:40:16 +1200 Subject: [PATCH 1787/2214] [core] Use CORE.is_* platform helpers in __main__ (#17144) --- esphome/__main__.py | 20 +++------- tests/unit_tests/test_main.py | 70 +++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 35ab767cf7..48fee1e97e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -52,11 +52,6 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, - KEY_CORE, - KEY_TARGET_PLATFORM, - PLATFORM_ESP32, - PLATFORM_ESP8266, - PLATFORM_RP2040, SECRETS_FILES, Toolchain, ) @@ -359,7 +354,7 @@ def choose_upload_log_host( bootsel_permission_error = False if ( purpose == Purpose.UPLOADING - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + and CORE.is_rp2040 and (picotool := _find_picotool()) is not None ): bootsel = detect_rp2040_bootsel(picotool) @@ -406,7 +401,7 @@ def choose_upload_log_host( # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found if ( purpose == Purpose.UPLOADING - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + and CORE.is_rp2040 and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) ): if bootsel_permission_error: @@ -984,7 +979,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int: # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for # the upload target, but 'nobuild' skips the build phase that creates it. # Create it here so the upload doesn't fail. - if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040: + if CORE.is_rp2040: idedata = toolchain.get_idedata(config) build_dir = Path(idedata.firmware_elf_path).parent firmware_bin = build_dir / "firmware.bin" @@ -1169,10 +1164,10 @@ def upload_program( check_permissions(host) exit_code = 1 - if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): + if CORE.is_esp32 or CORE.is_esp8266: file = getattr(args, "file", None) exit_code = upload_using_esptool(config, host, file, args.upload_speed) - elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny: + elif CORE.is_rp2040 or CORE.is_libretiny: exit_code = upload_using_platformio(config, host) # else: Unknown target platform, exit_code remains 1 @@ -1629,10 +1624,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: # After BOOTSEL upload, wait for a new serial port to appear # so it shows up in the log chooser - if ( - successful_device is None - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 - ): + if successful_device is None and CORE.is_rp2040: _wait_for_serial_port(known_ports=pre_upload_ports) # If exactly one new serial port appeared, use it directly serial_ports = get_serial_ports() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 33888956b3..b2011259c1 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -159,9 +159,12 @@ def setup_core( CORE.config = config CORE.toolchain = Toolchain.PLATFORMIO - if platform is not None: - CORE.data[KEY_CORE] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + # Production always populates CORE.data[KEY_CORE] before upload/logs run + # (the platform validator sets it during read_config, and + # StorageJSON.apply_to_core sets it on the cache fast path), so mirror + # that here. Tests that exercise platform-specific behavior pass a + # platform explicitly; the rest get a platform-agnostic None. + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} if tmp_path is not None: CORE.config_path = str(tmp_path / f"{name}.yaml") @@ -1660,6 +1663,29 @@ def test_upload_program_serial_platformio_platforms( mock_upload_using_platformio.assert_called_once_with(config, device) +@patch("esphome.__main__.importlib.import_module") +def test_upload_program_serial_unknown_platform( + mock_import: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Serial upload on an unsupported platform falls through to exit_code 1.""" + setup_core(platform="custom_platform") + # Module has no upload_program handler, so the SERIAL branch is reached. + mock_import.return_value = MagicMock(spec=[]) + mock_get_port_type.return_value = "SERIAL" + + config = {} + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 1 + assert host is None + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + + def test_upload_using_platformio_creates_signed_bin_for_rp2040( tmp_path: Path, ) -> None: @@ -6350,6 +6376,44 @@ def test_command_run_defaults_subscribe_states_true( ) +def test_command_run_rp2040_bootsel_redetects_serial_port() -> None: + """After a BOOTSEL upload (no device) on RP2040, command_run waits for and + picks up the newly enumerated serial port before showing logs.""" + setup_core( + config={"logger": {}, CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}}, + platform=PLATFORM_RP2040, + ) + + args = MockArgs() + args.no_logs = False + args.device = None + + new_port = MockSerialPort("/dev/ttyACM0", "RP2040 Serial") + + with ( + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + patch( + "esphome.__main__.choose_upload_log_host", + side_effect=[[], ["/dev/ttyACM0"]], + ) as mock_choose, + patch("esphome.__main__.upload_program", return_value=(0, None)), + patch( + "esphome.__main__.get_serial_ports", + side_effect=[[], [new_port]], + ), + patch("esphome.__main__._wait_for_serial_port") as mock_wait, + patch("esphome.__main__.show_logs", return_value=0) as mock_show_logs, + ): + result = command_run(args, CORE.config) + + assert result == 0 + mock_wait.assert_called_once_with(known_ports=set()) + # The re-detected serial port is used as the preferred logging device. + assert mock_choose.call_args_list[-1].kwargs["default"] == "/dev/ttyACM0" + mock_show_logs.assert_called_once_with(CORE.config, args, ["/dev/ttyACM0"]) + + def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" setup_core() From eae65a6b881388bf9f478a700e98dd2f9671d117 Mon Sep 17 00:00:00 2001 From: Berik Visschers Date: Tue, 23 Jun 2026 17:44:48 +0200 Subject: [PATCH 1788/2214] [bme680_bsec][bme68x_bsec2][const] Move BME sensor constants to shared component consts (#17160) --- esphome/components/bme680_bsec/__init__.py | 2 +- esphome/components/bme680_bsec/sensor.py | 8 +++++--- esphome/components/bme68x_bsec2/__init__.py | 2 +- esphome/components/bme68x_bsec2/sensor.py | 8 +++++--- esphome/components/const/__init__.py | 4 ++++ 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 2365f8d107..e1e01facd0 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import esp32, i2c +from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework @@ -12,7 +13,6 @@ MULTI_CONF = True CONF_BME680_BSEC_ID = "bme680_bsec_id" CONF_IAQ_MODE = "iaq_mode" CONF_SUPPLY_VOLTAGE = "supply_voltage" -CONF_STATE_SAVE_INTERVAL = "state_save_interval" bme680_bsec_ns = cg.esphome_ns.namespace("bme680_bsec") diff --git a/esphome/components/bme680_bsec/sensor.py b/esphome/components/bme680_bsec/sensor.py index 8d3ae76e3f..bdc8d8f2d3 100644 --- a/esphome/components/bme680_bsec/sensor.py +++ b/esphome/components/bme680_bsec/sensor.py @@ -1,5 +1,10 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import ( + CONF_BREATH_VOC_EQUIVALENT, + CONF_CO2_EQUIVALENT, + CONF_IAQ, +) import esphome.config_validation as cv from esphome.const import ( CONF_GAS_RESISTANCE, @@ -29,9 +34,6 @@ from . import CONF_BME680_BSEC_ID, SAMPLE_RATE_OPTIONS, BME680BSECComponent DEPENDENCIES = ["bme680_bsec"] -CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" -CONF_CO2_EQUIVALENT = "co2_equivalent" -CONF_IAQ = "iaq" ICON_ACCURACY = "mdi:checkbox-marked-circle-outline" UNIT_IAQ = "IAQ" diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 62cd9e2e36..63f63c5da2 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path from esphome import core, external_files import esphome.codegen as cg +from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -24,7 +25,6 @@ CONF_ALGORITHM_OUTPUT = "algorithm_output" CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id" CONF_IAQ_MODE = "iaq_mode" CONF_OPERATING_AGE = "operating_age" -CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_SUPPLY_VOLTAGE = "supply_voltage" bme68x_bsec2_ns = cg.esphome_ns.namespace("bme68x_bsec2") diff --git a/esphome/components/bme68x_bsec2/sensor.py b/esphome/components/bme68x_bsec2/sensor.py index f21a9b8138..52587dba99 100644 --- a/esphome/components/bme68x_bsec2/sensor.py +++ b/esphome/components/bme68x_bsec2/sensor.py @@ -1,5 +1,10 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import ( + CONF_BREATH_VOC_EQUIVALENT, + CONF_CO2_EQUIVALENT, + CONF_IAQ, +) import esphome.config_validation as cv from esphome.const import ( CONF_GAS_RESISTANCE, @@ -29,9 +34,6 @@ from . import CONF_BME68X_BSEC2_ID, SAMPLE_RATE_OPTIONS, BME68xBSEC2Component DEPENDENCIES = ["bme68x_bsec2"] -CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" -CONF_CO2_EQUIVALENT = "co2_equivalent" -CONF_IAQ = "iaq" CONF_IAQ_STATIC = "iaq_static" ICON_ACCURACY = "mdi:checkbox-marked-circle-outline" UNIT_IAQ = "IAQ" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 9951243f0d..85878a6306 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -8,8 +8,10 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" +CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CLIMATE_ID = "climate_id" +CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" @@ -17,6 +19,7 @@ CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" @@ -28,6 +31,7 @@ CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" CONF_SHA256 = "sha256" +CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_USE_PSRAM = "use_psram" CONF_VOLUME_INCREMENT = "volume_increment" From e0377bbbd31cd9d043d44e01a721a50fa3fb409f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:04:23 -0400 Subject: [PATCH 1789/2214] [ci] Enable ccache for component batch builds (~7% faster) (#17136) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10ace8c179..c4149e2049 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -822,8 +822,8 @@ jobs: - name: Cache apt packages uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 with: - packages: libsdl2-dev - version: 1.0 + packages: libsdl2-dev ccache + version: 1.1 - name: Check out code from GitHub uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -941,6 +941,11 @@ jobs: echo "All components in this batch are validate-only -- skipping compile stage." fi + - name: Print ccache statistics + # esphome stores the cache under the IDF tools path; expand the leading + # ~ in ESPHOME_ESP_IDF_PREFIX so ccache reads the dir the build used. + run: CCACHE_DIR="${ESPHOME_ESP_IDF_PREFIX/#\~/$HOME}/ccache" ccache -s + test-esp32-platformio: name: Test esp32 components with PlatformIO runs-on: ubuntu-24.04 From c2d79c972c9d5e64c540e3714e8ae1557ca27d18 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:04:42 -0400 Subject: [PATCH 1790/2214] [docker] Install ccache in the image (#17157) --- docker/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index bf37d6d88b..1fe380552a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -17,8 +17,11 @@ RUN git config --system --add safe.directory "*" \ # validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without # it idf_tools.py rejects the openocd install with exit 127 and aborts # the whole framework setup. +# ccache speeds up repeat ESP-IDF compiles (enabled via IDF_CCACHE_ENABLE); +# ESP-IDF silently skips it when the binary isn't on PATH, so it must be +# present in the image for the dashboard/Device Builder to benefit. RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 ccache \ && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 From ff001b9e4570e229b24c47bd28c37c2e83688f5c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:26 -0400 Subject: [PATCH 1791/2214] [esp32_ble_server] Fix set_value action with by-reference triggers (#17156) --- .../esp32_ble_server/ble_server_automations.h | 6 ++-- tests/components/esp32_ble_server/common.yaml | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index c6cba14b9b..e5463847fa 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -77,13 +77,15 @@ template class BLECharacteristicSetValueAction final : public Ac // Set initial value this->parent_->set_value(this->buffer_.value(x...)); // Set the listener for read events - this->parent_->on_read([this, x...](uint16_t id) { + // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference + // (e.g. climate on_control's ClimateCall&). See #17142. + this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read this->parent_->set_value(this->buffer_.value(x...)); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); } protected: diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index 4e34049038..c617a73f87 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -77,3 +77,34 @@ esp32_ble_server: id: test_change_descriptor value: data: [0x01, 0x02, 0x03] + +# Regression test for #17142: the set_value action used from a trigger that passes +# its argument by reference (climate on_control supplies ClimateCall&) previously +# failed to compile. +sensor: + - platform: template + id: ble_test_temp + lambda: "return 20.0;" + +output: + - platform: template + id: ble_test_output + type: float + write_action: + - logger.log: "out" + +climate: + - platform: pid + name: "BLE Test Climate" + id: ble_test_climate + sensor: ble_test_temp + default_target_temperature: 20 + heat_output: ble_test_output + control_parameters: + kp: 0.1 + ki: 0.001 + kd: 0.1 + on_control: + - ble_server.characteristic.set_value: + id: test_notify_characteristic + value: !lambda "return std::vector{0, 1, 2};" From 7763ce958d3cd92eb5b0b7348976d42323efcbdb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:40 -0400 Subject: [PATCH 1792/2214] [tests] Disable Hypothesis deadline on IP validation property tests (#17138) --- tests/unit_tests/test_config_validation.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 74d9a5047a..f1a6118870 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,6 +1,6 @@ import string -from hypothesis import example, given +from hypothesis import example, given, settings from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol @@ -276,6 +276,10 @@ def test_boolean__invalid(value): config_validation.boolean(value) +# deadline disabled: the validator is trivially fast, but Hypothesis's per-example +# deadline can spuriously trip on slow/loaded CI runners (e.g. one example hitting +# a GC pause), making this a flaky failure. Matches test_helpers.py. +@settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_ipv4__valid(value): config_validation.ipv4address(value) @@ -287,6 +291,7 @@ def test_ipv4__invalid(value): config_validation.ipv4address(value) +@settings(deadline=None) @given(value=ip_addresses(v=6).map(str)) def test_ipv6__valid(value): config_validation.ipaddress(value) From e3b644c2a0d85fd58e6ce3a5d48119bc8548dd60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:03:16 -0500 Subject: [PATCH 1793/2214] Bump actions/cache from 5.0.5 to 6.0.0 in /.github/actions/restore-python (#17169) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 66d016b42d..96a3be53c6 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv # yamllint disable-line rule:line-length From a24a63e61b588a00aceb325f9e12c73ae8534edb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:03:28 -0500 Subject: [PATCH 1794/2214] Bump actions/cache from 5.0.5 to 6.0.0 (#17168) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4149e2049..0a7233b3a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv # yamllint disable-line rule:line-length @@ -250,7 +250,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -295,7 +295,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -339,7 +339,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -365,7 +365,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -509,14 +509,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -1098,7 +1098,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1122,7 +1122,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -1164,7 +1164,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1211,7 +1211,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} From 49536693b71e9f9a5cd1cd81ccf0478414e2befd Mon Sep 17 00:00:00 2001 From: mnewton25 <83018731+mnewton25@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:40:49 +0100 Subject: [PATCH 1795/2214] [esp32] Use POSIX path for secure-boot signing/verification keys Fixes #17164 (#17166) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8ba1ac4608..945eda3912 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2368,14 +2368,14 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_SIGNING_KEY", - str(signed_ota[CONF_SIGNING_KEY].resolve()), + signed_ota[CONF_SIGNING_KEY].resolve().as_posix(), ) else: # Public key mode — verification only, external signing required add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_VERIFICATION_KEY", - str(signed_ota[CONF_VERIFICATION_KEY].resolve()), + signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), ) cg.add_define("USE_OTA_SIGNED_VERIFICATION") From 1d32b6c9e0789f9662b021e8a9d351e2edbf181e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:41:13 -0400 Subject: [PATCH 1796/2214] [espidf] Enable ccache by default for ESP-IDF builds (#17163) --- esphome/espidf/framework.py | 55 +++++++++++++- tests/unit_tests/test_espidf_framework.py | 87 +++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 4053898a8e..f0715ce3b2 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -23,7 +23,7 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, ) -from esphome.helpers import get_str_env, write_file_if_changed +from esphome.helpers import get_bool_env, get_str_env, write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -814,6 +814,56 @@ def check_esp_idf_install( return framework_path, python_env_path +def _ccache_env() -> dict[str, str]: + """Return ccache settings for ESP-IDF compiles. + + Enabled by default whenever the ``ccache`` binary is on PATH; set + ``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under + the IDF tools path. How widely it is shared depends on where that resolves: + across projects (and surviving ``clean-all``) when it is a common location + (``ESPHOME_ESP_IDF_PREFIX`` or the add-on ``/data``), but per-project under + ``.esphome/idf`` for a default pip install, where ``clean-all`` clears it + along with the framework. + + Depend mode keeps cache-miss overhead low (hashes the compiler's depfiles + instead of preprocessing). ``CCACHE_BASEDIR`` rewrites the per-build + absolute paths (generated ``sdkconfig`` include, etc.) so different devices + share framework cache entries; it is scoped to the build dir on purpose -- + a broader base would also rewrite the shared IDF path under the cache dir + and lose those hits. + + Only values the user has not already set in the environment are returned, so + a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected. + """ + # Honor an explicit choice already in the environment (opt-out or opt-in). + if "IDF_CCACHE_ENABLE" in os.environ: + if not get_bool_env("IDF_CCACHE_ENABLE"): + return {} + elif shutil.which("ccache") is None: + # ESP-IDF silently skips ccache without the binary; don't enable it. + return {} + + # ccache is enabled past here. build_path is set during preload for every + # config-loading command, so it being unset means a caller built the IDF env + # too early -- fail loudly rather than silently drop CCACHE_BASEDIR (which + # would quietly cost cross-device cache hits). + if CORE.build_path is None: + raise ValueError( + "CORE.build_path must be set before constructing the ESP-IDF build " + "environment" + ) + + defaults = { + "IDF_CCACHE_ENABLE": "1", + "CCACHE_DIR": str(_get_idf_tools_path() / "ccache"), + "CCACHE_NOHASHDIR": "true", + "CCACHE_DEPEND": "1", + "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), + } + # Don't override CCACHE_* values the user already set in their environment. + return {k: v for k, v in defaults.items() if k not in os.environ} + + def get_framework_env( framework_path: PathType, python_env_path: PathType | None = None, @@ -856,4 +906,7 @@ def get_framework_env( env.update(export_vars) env["PATH"] = os.pathsep.join(paths_to_export + path_list) + # 6. Enable ccache for the compile toolchain (default on when available). + env.update(_ccache_env()) + return env diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 525cd55146..b5fa0e2698 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -15,6 +15,7 @@ from unittest.mock import patch import pytest from esphome.espidf.framework import ( + _ccache_env, _check_stamp, _check_windows_path_length, _clone_idf_with_submodules, @@ -620,6 +621,8 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None: "esphome.espidf.framework._get_idf_tool_paths", return_value=(["/tool/bin"], {"IDF_X": "1"}), ), + # ccache env is covered separately; keep this test host-independent. + patch("esphome.espidf.framework._ccache_env", return_value={}), ): env = get_framework_env( tmp_path / "fw", tmp_path / "penv", {"PATH": "/usr/bin"} @@ -640,6 +643,8 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), + # ccache env is covered separately; keep this test host-independent. + patch("esphome.espidf.framework._ccache_env", return_value={}), ): env = get_framework_env(tmp_path / "fw") @@ -647,6 +652,88 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No assert env["PATH"] # taken from os.environ +# --------------------------------------------------------------------------- +# _ccache_env +# --------------------------------------------------------------------------- + + +def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None): + return ( + patch("esphome.espidf.framework.shutil.which", return_value=which), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=tmp_path / "tools", + ), + patch( + "esphome.espidf.framework.CORE", + SimpleNamespace(build_path=build_path), + ), + ) + + +def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None: + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + with patch.dict("os.environ", {}, clear=True), p1, p2, p3: + env = _ccache_env() + assert env["IDF_CCACHE_ENABLE"] == "1" + assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") + assert env["CCACHE_NOHASHDIR"] == "true" + assert env["CCACHE_DEPEND"] == "1" + assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + + +def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None: + # build_path is None here too: a disabled cache must not require it. + p1, p2, p3 = _ccache_patches(tmp_path, None, None) + with patch.dict("os.environ", {}, clear=True), p1, p2, p3: + assert _ccache_env() == {} + + +def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: + # Explicit IDF_CCACHE_ENABLE=0 wins even when the binary is present, and + # short-circuits before build_path is needed. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) + with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3: + assert _ccache_env() == {} + + +def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None: + # Explicit IDF_CCACHE_ENABLE=1 forces it on without probing PATH. It's + # already in the environment, so it isn't re-emitted, but the rest is. + p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") + with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3: + env = _ccache_env() + assert "IDF_CCACHE_ENABLE" not in env + assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") + assert env["CCACHE_DEPEND"] == "1" + + +def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None: + # User-set CCACHE_* values must not be clobbered; unset ones still default. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + user_env = {"CCACHE_DIR": "/my/cache", "CCACHE_MAXSIZE": "9G"} + with patch.dict("os.environ", user_env, clear=True), p1, p2, p3: + env = _ccache_env() + assert "CCACHE_DIR" not in env + assert "CCACHE_MAXSIZE" not in env + assert env["IDF_CCACHE_ENABLE"] == "1" + assert env["CCACHE_DEPEND"] == "1" + + +def test_ccache_env_raises_without_build_path(tmp_path: Path) -> None: + # Enabled but no build_path means the IDF env was built too early -- fail + # loudly instead of silently dropping CCACHE_BASEDIR. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) + with ( + patch.dict("os.environ", {}, clear=True), + p1, + p2, + p3, + pytest.raises(ValueError, match="build_path"), + ): + _ccache_env() + + # --------------------------------------------------------------------------- # _check_stamp / _write_idf_version_txt / _get_idf_tools_path # --------------------------------------------------------------------------- From 84de814e6f7236696da9787ac027e21b2aaf80db Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:05:39 +1200 Subject: [PATCH 1797/2214] [config_validation] Make bind_key a sensitive dual-mode validator (#17146) --- esphome/components/dlms_meter/__init__.py | 8 +- esphome/components/dsmr/__init__.py | 4 +- esphome/config_validation.py | 67 +++++++++++++---- tests/unit_tests/test_config_validation.py | 86 ++++++++++++++++++++++ 4 files changed, 142 insertions(+), 23 deletions(-) diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 7094699b0b..b747f73a14 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -136,12 +136,8 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), - cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( - value, name="Decryption key" - ), - cv.Optional(CONF_AUTH_KEY): lambda value: cv.bind_key( - value, name="Authentication key" - ), + cv.Optional(CONF_DECRYPTION_KEY): cv.bind_key(name="Decryption key"), + cv.Optional(CONF_AUTH_KEY): cv.bind_key(name="Authentication key"), cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, cv.Optional(CONF_PROVIDER): cv.string, diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 1dc3664602..34f37ace35 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -40,9 +40,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(Dsmr), - cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( - value, name="Decryption key" - ), + cv.Optional(CONF_DECRYPTION_KEY): cv.bind_key(name="Decryption key"), cv.Optional(CONF_CRC_CHECK, default=True): cv.boolean, cv.Optional(CONF_GAS_MBUS_ID, default=1): cv.int_, cv.Optional(CONF_WATER_MBUS_ID, default=2): cv.int_, diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0ef6d212fe..0fdce85dc3 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1220,21 +1220,60 @@ def mac_address(value): return core.MACAddress(*parts_int) -def bind_key(value, *, name="Bind key"): - value = string_strict(value) - parts = [value[i : i + 2] for i in range(0, len(value), 2)] - if len(parts) != 16: - raise Invalid(f"{name} must consist of 16 hexadecimal numbers") - parts_int = [] - if any(len(part) != 2 for part in parts): - raise Invalid(f"{name} must be format XX") - for part in parts: - try: - parts_int.append(int(part, 16)) - except ValueError: - raise Invalid(f"{name} must be hex values from 00 to FF") from None +_BIND_KEY_MISSING = object() - return "".join(f"{part:02X}" for part in parts_int) + +class BindKeyValidator(SensitiveValidator): + """Sensitive validator for a 16-byte hex bind/encryption key. + + Use bare as a validator (``cv.bind_key``) for the default error wording, or + call it with a custom ``name`` (``cv.bind_key(name="Decryption key")``) to + get a validator with tailored error messages. Either way the value is marked + sensitive so frontends mask it and dump tooling redacts it. + """ + + def __init__(self, name: str = "Bind key") -> None: + self._name = name + super().__init__(self._validate) + + def _validate(self, value: typing.Any) -> str: + value = string_strict(value) + parts = [value[i : i + 2] for i in range(0, len(value), 2)] + if len(parts) != 16: + raise Invalid(f"{self._name} must consist of 16 hexadecimal numbers") + parts_int = [] + if any(len(part) != 2 for part in parts): + raise Invalid(f"{self._name} must be format XX") + for part in parts: + try: + parts_int.append(int(part, 16)) + except ValueError: + raise Invalid( + f"{self._name} must be hex values from 00 to FF" + ) from None + + return "".join(f"{part:02X}" for part in parts_int) + + def __call__( + self, value: typing.Any = _BIND_KEY_MISSING, *, name: str | None = None + ) -> typing.Any: + if value is _BIND_KEY_MISSING: + # Factory usage: return a validator with customized error wording. + return BindKeyValidator(name if name is not None else self._name) + if name is not None and name != self._name: + # Direct validation with a one-off custom name. + return BindKeyValidator(name)(value) + return super().__call__(value) + + def __repr__(self) -> str: + # ``self.inner`` is a bound method of this instance, so the inherited + # ``SensitiveValidator.__repr__`` (which returns ``repr(self.inner)``) + # would recurse infinitely. Provide a stable, name-keyed repr instead so + # ``build_language_schema`` dedup and voluptuous errors stay sane. + return f"bind_key({self._name!r})" + + +bind_key = BindKeyValidator() def uuid(value): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index f1a6118870..9b9f003b0d 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -188,6 +188,92 @@ def test_sensitive__is_detectable_via_isinstance() -> None: assert isinstance(validator, config_validation.SensitiveValidator) +def test_bind_key__bare_usage_validates_and_is_sensitive() -> None: + # Used bare (cv.bind_key) it is itself a sensitive validator: detectable for + # frontend masking and validating a value directly tags the result. + assert isinstance(config_validation.bind_key, config_validation.SensitiveValidator) + + result = config_validation.bind_key("0123456789ABCDEF0123456789ABCDEF") + + assert isinstance(result, SensitiveStr) + assert result == "0123456789ABCDEF0123456789ABCDEF" + + +def test_bind_key__bare_usage_in_schema() -> None: + # Voluptuous calls the bare validator with the config value; the result must + # come through tagged sensitive. + schema = config_validation.Schema( + {config_validation.Required("key"): config_validation.bind_key} + ) + out = schema({"key": "0123456789ABCDEF0123456789ABCDEF"}) + + assert isinstance(out["key"], SensitiveStr) + + +def test_bind_key__factory_returns_sensitive_validator() -> None: + # Called with a name (cv.bind_key(name=...)) it returns a new sensitive + # validator rather than validating. + validator = config_validation.bind_key(name="Decryption key") + + assert isinstance(validator, config_validation.SensitiveValidator) + assert validator is not config_validation.bind_key + assert isinstance(validator("0123456789ABCDEF0123456789ABCDEF"), SensitiveStr) + + +@pytest.mark.parametrize( + ("value", "error"), + ( + ("00", "Decryption key must consist of 16 hexadecimal numbers"), + ("0123456789ABCDEF0123456789ABCDEG", "Decryption key must be hex values"), + ), +) +def test_bind_key__custom_name_in_error(value: str, error: str) -> None: + # The ``name`` argument (used by dsmr/dlms_meter) customizes error messages. + validator = config_validation.bind_key(name="Decryption key") + with pytest.raises(Invalid, match=error): + validator(value) + + +def test_bind_key__rejects_non_hex_pair_length() -> None: + # Odd-length input yields a trailing single-char part, hitting the + # "format XX" branch rather than the hex-value branch. + with pytest.raises(Invalid, match="Bind key must be format XX"): + config_validation.bind_key("0123456789ABCDEF0123456789ABCDE") + + +def test_bind_key__direct_call_with_name_validates_with_that_name() -> None: + # Passing both a value and a name validates immediately using the custom + # name for error wording, and still tags the result sensitive. + result = config_validation.bind_key( + "0123456789ABCDEF0123456789ABCDEF", name="Decryption key" + ) + assert isinstance(result, SensitiveStr) + + with pytest.raises(Invalid, match="Decryption key must consist of"): + config_validation.bind_key("00", name="Decryption key") + + +def test_bind_key__factory_without_name_keeps_existing_name() -> None: + # Re-invoking a named validator without a name preserves its name rather + # than resetting to the default. + named = config_validation.bind_key(name="Decryption key") + rederived = named() + + with pytest.raises(Invalid, match="Decryption key must consist of"): + rederived("00") + + +def test_bind_key__repr_is_name_keyed_and_non_recursive() -> None: + # ``self.inner`` is a bound method of the instance, so the inherited + # ``repr(self.inner)`` would recurse infinitely; the override keeps repr + # finite and keyed on the name for schema-dump dedup. + assert repr(config_validation.bind_key) == "bind_key('Bind key')" + assert ( + repr(config_validation.bind_key(name="Decryption key")) + == "bind_key('Decryption key')" + ) + + def test_sensitive__repr_mirrors_inner() -> None: # The schema dump dedups on ``repr(schema)``; mirroring the inner # validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers From 344da7c4f4a8a2e32478792a26647a5511392a90 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:35:07 -0400 Subject: [PATCH 1798/2214] [docker] Move build deps to base image, drop app apt step (#17167) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- docker/Dockerfile | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1fe380552a..c1baa51ae3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2026.06.0 +ARG BUILD_BASE_VERSION=2026.06.1 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -11,19 +11,6 @@ FROM base-source-${BUILD_TYPE} AS base RUN git config --system --add safe.directory "*" \ && git config --system advice.detachedHead false -# Install build tools for Python packages that require compilation -# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager). -# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can -# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without -# it idf_tools.py rejects the openocd install with exit 127 and aborts -# the whole framework setup. -# ccache speeds up repeat ESP-IDF compiles (enabled via IDF_CCACHE_ENABLE); -# ESP-IDF silently skips it when the binary isn't on PATH, so it must be -# present in the image for the dashboard/Device Builder to benefit. -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 ccache \ - && rm -rf /var/lib/apt/lists/* - ENV PIP_DISABLE_PIP_VERSION_CHECK=1 RUN pip install --no-cache-dir -U pip uv==0.10.1 From 72686bd4aff439b5b303b09b1b9940bc9126937b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:43:39 +1000 Subject: [PATCH 1799/2214] [mipi_spi] Warn on MODE3 default for display without CS pin (#17153) --- esphome/components/mipi_spi/display.py | 10 ++++- tests/component_tests/mipi_spi/test_init.py | 44 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index abb7eaa458..d613d0a1ab 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -172,13 +172,19 @@ def model_schema(config): if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. - spi_mode = model.get_default(CONF_SPI_MODE) + spi_mode = ( + cv.UNDEFINED if CONF_SPI_MODE in config else model.get_default(CONF_SPI_MODE) + ) if not spi_mode: if bus_mode == TYPE_OCTAL or ( bus_mode == TYPE_SINGLE - and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + and config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) is False ): spi_mode = "MODE3" + if bus_mode == TYPE_SINGLE: + LOGGER.warning( + "No SPI mode specified, defaulting to MODE3 due to lack of CS pin. If you experience issues, try setting SPI mode explicitly to MODE0 or MODE3." + ) else: spi_mode = "MODE0" diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index d681908027..dbd8e15702 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -306,6 +306,50 @@ def test_all_predefined_models( run_schema_validation(config) +def test_single_bus_no_cs_no_mode_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A single-bus display with no CS pin and no explicit SPI mode warns about MODE3 default.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + run_schema_validation({"model": "ili9488", "dc_pin": 14}) + + assert "defaulting to MODE3 due to lack of CS pin" in caplog.text + + +@pytest.mark.parametrize( + "config", + [ + pytest.param( + {"model": "ili9488", "dc_pin": 14, "cs_pin": 0}, + id="cs_pin_provided", + ), + pytest.param( + {"model": "ili9488", "dc_pin": 14, "spi_mode": "mode0"}, + id="spi_mode_provided", + ), + ], +) +def test_single_bus_no_mode_warning_suppressed( + config: ConfigType, + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """No MODE3 warning when a CS pin or an explicit SPI mode is provided.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + run_schema_validation(config) + + assert "defaulting to MODE3 due to lack of CS pin" not in caplog.text + + def test_native_generation( generate_main: Callable[[str | Path], str], component_fixture_path: Callable[[str], Path], From dae078fc56a8350c6260997af4fcddada4589e21 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:06:15 +1200 Subject: [PATCH 1800/2214] Bump bundled esphome-device-builder to 1.0.15 (#17170) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c1baa51ae3..1cd3372255 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.14 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.15 RUN \ platformio settings set enable_telemetry No \ From 2b8916fc4e40f33ad908e7d2c2bfa0c3159edfb6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:13:34 +1200 Subject: [PATCH 1801/2214] [ci] Exclude test changes from small-pr/medium-pr size labels (#17172) --- .github/scripts/auto-label-pr/detectors.js | 31 +++++--- .github/scripts/auto-label-pr/index.js | 2 +- .../auto-label-pr/tests/detectors.test.js | 78 ++++++++++++++++++- 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 81bb77843d..4406370a27 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -147,19 +147,9 @@ async function detectCoreChanges(changedFiles) { } // Strategy: PR size detection -async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) { +async function detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) { const labels = new Set(); - if (totalChanges <= SMALL_PR_THRESHOLD) { - labels.add('small-pr'); - return labels; - } - - if (totalChanges <= MEDIUM_PR_THRESHOLD) { - labels.add('medium-pr'); - return labels; - } - const testAdditions = prFiles .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.additions || 0), 0); @@ -167,7 +157,24 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.deletions || 0), 0); - const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); + const nonTestAdditions = totalAdditions - testAdditions; + const nonTestDeletions = totalDeletions - testDeletions; + + // small/medium count churn (additions + deletions) so a balanced refactor isn't undersized. + const nonTestChurn = nonTestAdditions + nonTestDeletions; + + if (nonTestChurn <= SMALL_PR_THRESHOLD) { + labels.add('small-pr'); + return labels; + } + + if (nonTestChurn <= MEDIUM_PR_THRESHOLD) { + labels.add('medium-pr'); + return labels; + } + + // too-big uses net line delta (additions - deletions), matching the review message in reviews.js. + const nonTestChanges = nonTestAdditions - nonTestDeletions; // Don't add too-big if mega-pr label is already present if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) { diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 9769cd8060..c8bdcfb2f3 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -123,7 +123,7 @@ module.exports = async ({ github, context }) => { detectNewComponents(github, context, prFiles), detectNewPlatforms(github, context, prFiles, apiData), detectCoreChanges(changedFiles), - detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), + detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), detectDashboardChanges(changedFiles), detectGitHubActionsChanges(changedFiles), detectCodeOwner(github, context, changedFiles), diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index 02d69ca95e..aab1827c44 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -1,6 +1,6 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -const { detectNewPlatforms, detectNewComponents } = require('../detectors'); +const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors'); // Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents // to check for CONFIG_SCHEMA in newly added files. @@ -145,3 +145,79 @@ describe('detectNewComponents', () => { assert.equal(result.labels.size, 0); }); }); + +// --------------------------------------------------------------------------- +// detectPRSize +// --------------------------------------------------------------------------- + +describe('detectPRSize', () => { + const SMALL = 30; + const MEDIUM = 100; + const TOO_BIG = 1000; + + function size(prFiles, isMegaPR = false) { + const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0); + const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0); + return detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL, MEDIUM, TOO_BIG); + } + + it('counts only non-test changes toward small-pr', async () => { + // 10 source + 5000 test lines -> non-test churn of 10 is still small. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 10, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('small-pr')); + assert.equal(labels.size, 1); + }); + + it('counts additions and deletions as churn (not net delta)', async () => { + // A balanced refactor (40 added, 40 removed) is 80 lines of churn -> medium, not small. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 40, deletions: 40 }, + ]); + assert.ok(labels.has('medium-pr')); + assert.equal(labels.size, 1); + }); + + it('labels medium-pr when non-test changes exceed small threshold', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 60, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('medium-pr')); + assert.equal(labels.size, 1); + }); + + it('uses net delta (not churn) for too-big', async () => { + // 600 added + 600 removed: 1200 churn (above too-big) but 0 net delta -> not too-big. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 600, deletions: 600 }, + ]); + assert.equal(labels.size, 0); + }); + + it('labels too-big when non-test changes exceed the big threshold', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('too-big')); + assert.equal(labels.size, 1); + }); + + it('does not label too-big when mega-pr is set', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 }, + ], true); + assert.equal(labels.size, 0); + }); + + it('produces no size label for a large mega-pr in the gap above medium', async () => { + // Non-test changes land between MEDIUM and TOO_BIG: not small/medium, and mega-pr suppresses too-big. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 500, deletions: 0 }, + ], true); + assert.equal(labels.size, 0); + }); +}); From e6455c5b448296f3e99de5e50deda6702f0b9ced Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:27:07 +1200 Subject: [PATCH 1802/2214] Mark configurable classes as final (12/21: msa3xx-pm2005) (#16963) --- esphome/components/msa3xx/msa3xx.h | 2 +- esphome/components/my9231/my9231.h | 4 ++-- esphome/components/nau7802/nau7802.h | 8 ++++---- esphome/components/network/network_component.h | 2 +- esphome/components/nextion/automation.h | 8 ++++---- .../nextion/binary_sensor/nextion_binarysensor.h | 6 +++--- esphome/components/nextion/nextion.h | 2 +- esphome/components/nextion/sensor/nextion_sensor.h | 2 +- esphome/components/nextion/switch/nextion_switch.h | 2 +- .../nextion/text_sensor/nextion_textsensor.h | 2 +- esphome/components/nfc/automation.h | 2 +- .../components/nfc/binary_sensor/nfc_binary_sensor.h | 8 ++++---- esphome/components/noblex/noblex.h | 2 +- esphome/components/npi19/npi19.h | 2 +- esphome/components/nrf52/dfu.h | 2 +- esphome/components/ntc/ntc.h | 2 +- esphome/components/number/automation.h | 10 +++++----- esphome/components/number/sensor/number_sensor.h | 2 +- esphome/components/online_image/online_image.h | 10 +++++----- esphome/components/opentherm/automation.h | 4 ++-- esphome/components/opentherm/hub.h | 2 +- esphome/components/opentherm/number/opentherm_number.h | 2 +- esphome/components/opentherm/output/opentherm_output.h | 2 +- esphome/components/opentherm/switch/opentherm_switch.h | 2 +- esphome/components/openthread/openthread.h | 4 ++-- esphome/components/opt3001/opt3001.h | 2 +- esphome/components/output/automation.h | 10 +++++----- esphome/components/output/button/output_button.h | 2 +- esphome/components/output/lock/output_lock.h | 2 +- esphome/components/output/switch/output_switch.h | 2 +- esphome/components/partition/light_partition.h | 2 +- esphome/components/pca6416a/pca6416a.h | 8 ++++---- esphome/components/pca9554/pca9554.h | 8 ++++---- esphome/components/pca9685/pca9685_output.h | 4 ++-- esphome/components/pcd8544/pcd_8544.h | 6 +++--- esphome/components/pcf85063/pcf85063.h | 6 +++--- esphome/components/pcf8563/pcf8563.h | 6 +++--- esphome/components/pcf8574/pcf8574.h | 8 ++++---- esphome/components/pcm5122/pcm5122.h | 2 +- esphome/components/pcm5122/pcm5122_gpio.h | 2 +- esphome/components/pi4ioe5v6408/pi4ioe5v6408.h | 8 ++++---- esphome/components/pid/pid_climate.h | 8 ++++---- esphome/components/pid/sensor/pid_climate_sensor.h | 2 +- esphome/components/pipsolar/output/pipsolar_output.h | 4 ++-- esphome/components/pipsolar/pipsolar.h | 2 +- esphome/components/pipsolar/switch/pipsolar_switch.h | 2 +- esphome/components/pm1006/pm1006.h | 2 +- esphome/components/pm2005/pm2005.h | 2 +- 48 files changed, 97 insertions(+), 97 deletions(-) diff --git a/esphome/components/msa3xx/msa3xx.h b/esphome/components/msa3xx/msa3xx.h index 345afc50ab..212ee10a48 100644 --- a/esphome/components/msa3xx/msa3xx.h +++ b/esphome/components/msa3xx/msa3xx.h @@ -211,7 +211,7 @@ union RegTapDuration { uint8_t raw{0x04}; }; -class MSA3xxComponent : public PollingComponent, public i2c::I2CDevice { +class MSA3xxComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/my9231/my9231.h b/esphome/components/my9231/my9231.h index 60b113079e..ababfd7fc7 100644 --- a/esphome/components/my9231/my9231.h +++ b/esphome/components/my9231/my9231.h @@ -8,7 +8,7 @@ namespace esphome::my9231 { /// MY9231 float output component. -class MY9231OutputComponent : public Component { +class MY9231OutputComponent final : public Component { public: class Channel; void set_pin_di(GPIOPin *pin_di) { pin_di_ = pin_di; } @@ -26,7 +26,7 @@ class MY9231OutputComponent : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(MY9231OutputComponent *parent) { parent_ = parent; } void set_channel(uint16_t channel) { channel_ = channel; } diff --git a/esphome/components/nau7802/nau7802.h b/esphome/components/nau7802/nau7802.h index 67f36ca677..c53a018234 100644 --- a/esphome/components/nau7802/nau7802.h +++ b/esphome/components/nau7802/nau7802.h @@ -47,7 +47,7 @@ enum NAU7802CalibrationModes { NAU7802_CALIBRATE_GAIN = 0b11, }; -class NAU7802Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class NAU7802Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void set_samples_per_second(NAU7802SPS sps) { this->sps_ = sps; } void set_ldo_voltage(NAU7802LDO ldo) { this->ldo_ = ldo; } @@ -97,18 +97,18 @@ class NAU7802Sensor : public sensor::Sensor, public PollingComponent, public i2c }; template -class NAU7802CalbrateExternalOffsetAction : public Action, public Parented { +class NAU7802CalbrateExternalOffsetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_external_offset(); } }; template -class NAU7802CalbrateInternalOffsetAction : public Action, public Parented { +class NAU7802CalbrateInternalOffsetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_internal_offset(); } }; -template class NAU7802CalbrateGainAction : public Action, public Parented { +template class NAU7802CalbrateGainAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_gain(); } }; diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index dde15940e4..2e76a95673 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -4,7 +4,7 @@ #include "esphome/core/component.h" namespace esphome::network { -class NetworkComponent : public Component { +class NetworkComponent final : public Component { public: void setup() override; // AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance. diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h index e039dae615..0226c65be6 100644 --- a/esphome/components/nextion/automation.h +++ b/esphome/components/nextion/automation.h @@ -7,7 +7,7 @@ namespace esphome::nextion { -template class NextionSetBrightnessAction : public Action { +template class NextionSetBrightnessAction final : public Action { public: explicit NextionSetBrightnessAction(Nextion *component) : component_(component) {} @@ -24,7 +24,7 @@ template class NextionSetBrightnessAction : public Action Nextion *component_; }; -template class NextionPublishFloatAction : public Action { +template class NextionPublishFloatAction final : public Action { public: explicit NextionPublishFloatAction(NextionComponent *component) : component_(component) {} @@ -47,7 +47,7 @@ template class NextionPublishFloatAction : public Action NextionComponent *component_; }; -template class NextionPublishTextAction : public Action { +template class NextionPublishTextAction final : public Action { public: explicit NextionPublishTextAction(NextionComponent *component) : component_(component) {} @@ -70,7 +70,7 @@ template class NextionPublishTextAction : public Action { NextionComponent *component_; }; -template class NextionPublishBoolAction : public Action { +template class NextionPublishBoolAction final : public Action { public: explicit NextionPublishBoolAction(NextionComponent *component) : component_(component) {} diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h index 7637957222..9970db1c01 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h @@ -8,9 +8,9 @@ namespace esphome::nextion { class NextionBinarySensor; -class NextionBinarySensor : public NextionComponent, - public binary_sensor::BinarySensorInitiallyOff, - public PollingComponent { +class NextionBinarySensor final : public NextionComponent, + public binary_sensor::BinarySensorInitiallyOff, + public PollingComponent { public: NextionBinarySensor(NextionBase *nextion) { this->nextion_ = nextion; } diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index ef030e71da..d361d9725b 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -76,7 +76,7 @@ class NextionCommandPacer { }; #endif // USE_NEXTION_COMMAND_SPACING -class Nextion : public NextionBase, public PollingComponent, public uart::UARTDevice { +class Nextion final : public NextionBase, public PollingComponent, public uart::UARTDevice { public: #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP /** diff --git a/esphome/components/nextion/sensor/nextion_sensor.h b/esphome/components/nextion/sensor/nextion_sensor.h index 72e3982b3a..bc0875fdff 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.h +++ b/esphome/components/nextion/sensor/nextion_sensor.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionSensor; -class NextionSensor : public NextionComponent, public sensor::Sensor, public PollingComponent { +class NextionSensor final : public NextionComponent, public sensor::Sensor, public PollingComponent { public: NextionSensor(NextionBase *nextion) { this->nextion_ = nextion; } void send_state_to_nextion() override { this->set_state(this->state, false, true); }; diff --git a/esphome/components/nextion/switch/nextion_switch.h b/esphome/components/nextion/switch/nextion_switch.h index 7e0593d217..2cac733b49 100644 --- a/esphome/components/nextion/switch/nextion_switch.h +++ b/esphome/components/nextion/switch/nextion_switch.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionSwitch; -class NextionSwitch : public NextionComponent, public switch_::Switch, public PollingComponent { +class NextionSwitch final : public NextionComponent, public switch_::Switch, public PollingComponent { public: NextionSwitch(NextionBase *nextion) { this->nextion_ = nextion; } diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.h b/esphome/components/nextion/text_sensor/nextion_textsensor.h index 42cd5dcef4..5ef2bb222f 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.h +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionTextSensor; -class NextionTextSensor : public NextionComponent, public text_sensor::TextSensor, public PollingComponent { +class NextionTextSensor final : public NextionComponent, public text_sensor::TextSensor, public PollingComponent { public: NextionTextSensor(NextionBase *nextion) { this->nextion_ = nextion; } void update() override; diff --git a/esphome/components/nfc/automation.h b/esphome/components/nfc/automation.h index 0ac3e3b8b6..ec3a979b64 100644 --- a/esphome/components/nfc/automation.h +++ b/esphome/components/nfc/automation.h @@ -7,7 +7,7 @@ namespace esphome::nfc { -class NfcOnTagTrigger : public Trigger { +class NfcOnTagTrigger final : public Trigger { public: void process(const std::unique_ptr &tag); }; diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h index b3448a57cc..6354e16967 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h @@ -8,10 +8,10 @@ namespace esphome::nfc { -class NfcTagBinarySensor : public binary_sensor::BinarySensor, - public Component, - public NfcTagListener, - public Parented { +class NfcTagBinarySensor final : public binary_sensor::BinarySensor, + public Component, + public NfcTagListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/noblex/noblex.h b/esphome/components/noblex/noblex.h index 62070e5dee..e505a5ba3f 100644 --- a/esphome/components/noblex/noblex.h +++ b/esphome/components/noblex/noblex.h @@ -8,7 +8,7 @@ namespace esphome::noblex { const uint8_t NOBLEX_TEMP_MIN = 16; // Celsius const uint8_t NOBLEX_TEMP_MAX = 30; // Celsius -class NoblexClimate : public climate_ir::ClimateIR { +class NoblexClimate final : public climate_ir::ClimateIR { public: NoblexClimate() : climate_ir::ClimateIR(NOBLEX_TEMP_MIN, NOBLEX_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/npi19/npi19.h b/esphome/components/npi19/npi19.h index d1f74141ac..f18a0989de 100644 --- a/esphome/components/npi19/npi19.h +++ b/esphome/components/npi19/npi19.h @@ -7,7 +7,7 @@ namespace esphome::npi19 { /// This class implements support for the npi19 pressure and temperature i2c sensors. -class NPI19Component : public PollingComponent, public i2c::I2CDevice { +class NPI19Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_raw_pressure_sensor(sensor::Sensor *raw_pressure_sensor) { diff --git a/esphome/components/nrf52/dfu.h b/esphome/components/nrf52/dfu.h index 82c7d9f54e..4f7ad89b18 100644 --- a/esphome/components/nrf52/dfu.h +++ b/esphome/components/nrf52/dfu.h @@ -6,7 +6,7 @@ #include "esphome/core/gpio.h" namespace esphome::nrf52 { -class DeviceFirmwareUpdate : public Component { +class DeviceFirmwareUpdate final : public Component { public: void setup() override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } diff --git a/esphome/components/ntc/ntc.h b/esphome/components/ntc/ntc.h index 466d03f789..25fbf3c85d 100644 --- a/esphome/components/ntc/ntc.h +++ b/esphome/components/ntc/ntc.h @@ -5,7 +5,7 @@ namespace esphome::ntc { -class NTC : public Component, public sensor::Sensor { +class NTC final : public Component, public sensor::Sensor { public: void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_a(double a) { a_ = a; } diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index 2843aa6bf5..4efcfd30d8 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -6,14 +6,14 @@ namespace esphome::number { -class NumberStateTrigger : public Trigger { +class NumberStateTrigger final : public Trigger { public: explicit NumberStateTrigger(Number *parent) { parent->add_on_state_callback([this](float value) { this->trigger(value); }); } }; -template class NumberSetAction : public Action { +template class NumberSetAction final : public Action { public: NumberSetAction(Number *number) : number_(number) {} TEMPLATABLE_VALUE(float, value) @@ -28,7 +28,7 @@ template class NumberSetAction : public Action { Number *number_; }; -template class NumberOperationAction : public Action { +template class NumberOperationAction final : public Action { public: explicit NumberOperationAction(Number *number) : number_(number) {} TEMPLATABLE_VALUE(NumberOperation, operation) @@ -47,7 +47,7 @@ template class NumberOperationAction : public Action { Number *number_; }; -class ValueRangeTrigger : public Trigger, public Component { +class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} @@ -67,7 +67,7 @@ class ValueRangeTrigger : public Trigger, public Component { TemplatableFn max_{[](float) -> float { return NAN; }}; }; -template class NumberInRangeCondition : public Condition { +template class NumberInRangeCondition final : public Condition { public: NumberInRangeCondition(Number *parent) : parent_(parent) {} diff --git a/esphome/components/number/sensor/number_sensor.h b/esphome/components/number/sensor/number_sensor.h index 2d6825a298..ba3cec150c 100644 --- a/esphome/components/number/sensor/number_sensor.h +++ b/esphome/components/number/sensor/number_sensor.h @@ -6,7 +6,7 @@ namespace esphome::number { -class NumberSensor : public sensor::Sensor, public Component { +class NumberSensor final : public sensor::Sensor, public Component { public: explicit NumberSensor(Number *source) : source_(source) {} void setup() override; diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index a967bb6c0e..3e386f8cc8 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -21,9 +21,9 @@ using t_http_codes = enum { * The image will then be stored in a buffer, so that it can be re-displayed without the * need to re-download or re-decode. */ -class OnlineImage : public PollingComponent, - public runtime_image::RuntimeImage, - public Parented { +class OnlineImage final : public PollingComponent, + public runtime_image::RuntimeImage, + public Parented { public: /** * @brief Construct a new OnlineImage object. @@ -104,7 +104,7 @@ class OnlineImage : public PollingComponent, uint32_t start_time_{0}; }; -template class OnlineImageSetUrlAction : public Action { +template class OnlineImageSetUrlAction final : public Action { public: OnlineImageSetUrlAction(OnlineImage *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, url) @@ -120,7 +120,7 @@ template class OnlineImageSetUrlAction : public Action { OnlineImage *parent_; }; -template class OnlineImageReleaseAction : public Action { +template class OnlineImageReleaseAction final : public Action { public: OnlineImageReleaseAction(OnlineImage *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->release(); } diff --git a/esphome/components/opentherm/automation.h b/esphome/components/opentherm/automation.h index aa20a4ec5a..365992b280 100644 --- a/esphome/components/opentherm/automation.h +++ b/esphome/components/opentherm/automation.h @@ -6,14 +6,14 @@ namespace esphome::opentherm { -class BeforeSendTrigger : public Trigger { +class BeforeSendTrigger final : public Trigger { public: BeforeSendTrigger(OpenthermHub *hub) { hub->add_on_before_send_callback([this](OpenthermData &x) { this->trigger(x); }); } }; -class BeforeProcessResponseTrigger : public Trigger { +class BeforeProcessResponseTrigger final : public Trigger { public: BeforeProcessResponseTrigger(OpenthermHub *hub) { hub->add_on_before_process_response_callback([this](OpenthermData &x) { this->trigger(x); }); diff --git a/esphome/components/opentherm/hub.h b/esphome/components/opentherm/hub.h index 2638137668..268c6210f0 100644 --- a/esphome/components/opentherm/hub.h +++ b/esphome/components/opentherm/hub.h @@ -41,7 +41,7 @@ static const uint8_t REPEATING_MESSAGE_ORDER = 255; static const uint8_t INITIAL_UNORDERED_MESSAGE_ORDER = 254; // OpenTherm component for ESPHome -class OpenthermHub : public Component { +class OpenthermHub final : public Component { protected: // Communication pins for the OpenTherm interface InternalGPIOPin *in_pin_, *out_pin_; diff --git a/esphome/components/opentherm/number/opentherm_number.h b/esphome/components/opentherm/number/opentherm_number.h index c110bed2eb..c97692ce2a 100644 --- a/esphome/components/opentherm/number/opentherm_number.h +++ b/esphome/components/opentherm/number/opentherm_number.h @@ -8,7 +8,7 @@ namespace esphome::opentherm { // Just a simple number, which stores the number -class OpenthermNumber : public number::Number, public Component, public OpenthermInput { +class OpenthermNumber final : public number::Number, public Component, public OpenthermInput { protected: void control(float value) override; void setup() override; diff --git a/esphome/components/opentherm/output/opentherm_output.h b/esphome/components/opentherm/output/opentherm_output.h index e789d72702..24d5052076 100644 --- a/esphome/components/opentherm/output/opentherm_output.h +++ b/esphome/components/opentherm/output/opentherm_output.h @@ -6,7 +6,7 @@ namespace esphome::opentherm { -class OpenthermOutput : public output::FloatOutput, public Component, public OpenthermInput { +class OpenthermOutput final : public output::FloatOutput, public Component, public OpenthermInput { protected: bool has_state_ = false; const char *id_ = nullptr; diff --git a/esphome/components/opentherm/switch/opentherm_switch.h b/esphome/components/opentherm/switch/opentherm_switch.h index ca930d4f7c..235bc23401 100644 --- a/esphome/components/opentherm/switch/opentherm_switch.h +++ b/esphome/components/opentherm/switch/opentherm_switch.h @@ -6,7 +6,7 @@ namespace esphome::opentherm { -class OpenthermSwitch : public switch_::Switch, public Component { +class OpenthermSwitch final : public switch_::Switch, public Component { protected: void write_state(bool state) override; diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index f1c79fb9cb..488aad1166 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -19,7 +19,7 @@ namespace esphome::openthread { class InstanceLock; -class OpenThreadComponent : public Component { +class OpenThreadComponent final : public Component { public: OpenThreadComponent(); ~OpenThreadComponent(); @@ -68,7 +68,7 @@ class OpenThreadComponent : public Component { extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class OpenThreadSrpComponent : public Component { +class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns); // This has to run after the mdns component or else no services are available to advertise diff --git a/esphome/components/opt3001/opt3001.h b/esphome/components/opt3001/opt3001.h index e5de536353..92f5136bf7 100644 --- a/esphome/components/opt3001/opt3001.h +++ b/esphome/components/opt3001/opt3001.h @@ -7,7 +7,7 @@ namespace esphome::opt3001 { /// This class implements support for the i2c-based OPT3001 ambient light sensor. -class OPT3001Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class OPT3001Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void update() override; diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h index 301f568388..efe775ba57 100644 --- a/esphome/components/output/automation.h +++ b/esphome/components/output/automation.h @@ -8,7 +8,7 @@ namespace esphome::output { -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: TurnOffAction(BinaryOutput *output) : output_(output) {} @@ -18,7 +18,7 @@ template class TurnOffAction : public Action { BinaryOutput *output_; }; -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: TurnOnAction(BinaryOutput *output) : output_(output) {} @@ -28,7 +28,7 @@ template class TurnOnAction : public Action { BinaryOutput *output_; }; -template class SetLevelAction : public Action { +template class SetLevelAction final : public Action { public: SetLevelAction(FloatOutput *output) : output_(output) {} @@ -41,7 +41,7 @@ template class SetLevelAction : public Action { }; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING -template class SetMinPowerAction : public Action { +template class SetMinPowerAction final : public Action { public: SetMinPowerAction(FloatOutput *output) : output_(output) {} @@ -53,7 +53,7 @@ template class SetMinPowerAction : public Action { FloatOutput *output_; }; -template class SetMaxPowerAction : public Action { +template class SetMaxPowerAction final : public Action { public: SetMaxPowerAction(FloatOutput *output) : output_(output) {} diff --git a/esphome/components/output/button/output_button.h b/esphome/components/output/button/output_button.h index 1a2997bdcf..bf6be8afe1 100644 --- a/esphome/components/output/button/output_button.h +++ b/esphome/components/output/button/output_button.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputButton : public button::Button, public Component { +class OutputButton final : public button::Button, public Component { public: void dump_config() override; diff --git a/esphome/components/output/lock/output_lock.h b/esphome/components/output/lock/output_lock.h index 7be96e1e82..8e5f4ff7df 100644 --- a/esphome/components/output/lock/output_lock.h +++ b/esphome/components/output/lock/output_lock.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputLock : public lock::Lock, public Component { +class OutputLock final : public lock::Lock, public Component { public: void set_output(BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/output/switch/output_switch.h b/esphome/components/output/switch/output_switch.h index b0d85678be..878104f14c 100644 --- a/esphome/components/output/switch/output_switch.h +++ b/esphome/components/output/switch/output_switch.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputSwitch : public switch_::Switch, public Component { +class OutputSwitch final : public switch_::Switch, public Component { public: void set_output(BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/partition/light_partition.h b/esphome/components/partition/light_partition.h index 7a2f3678c1..adadde068c 100644 --- a/esphome/components/partition/light_partition.h +++ b/esphome/components/partition/light_partition.h @@ -31,7 +31,7 @@ class AddressableSegment { bool reversed_; }; -class PartitionLightOutput : public light::AddressableLight { +class PartitionLightOutput final : public light::AddressableLight { public: explicit PartitionLightOutput(std::vector segments) : segments_(std::move(segments)) { int32_t off = 0; diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 3170033b28..39011d53ab 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -7,9 +7,9 @@ namespace esphome::pca6416a { -class PCA6416AComponent : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA6416AComponent final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA6416AComponent() = default; @@ -49,7 +49,7 @@ class PCA6416AComponent : public Component, }; /// Helper class to expose a PCA6416A pin as an internal input GPIO pin. -class PCA6416AGPIOPin : public GPIOPin { +class PCA6416AGPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 9fa398cf29..05e945d176 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -7,9 +7,9 @@ namespace esphome::pca9554 { -class PCA9554Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA9554Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA9554Component() = default; @@ -53,7 +53,7 @@ class PCA9554Component : public Component, }; /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. -class PCA9554GPIOPin : public GPIOPin { +class PCA9554GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pca9685/pca9685_output.h b/esphome/components/pca9685/pca9685_output.h index 33819f23ee..dad722888f 100644 --- a/esphome/components/pca9685/pca9685_output.h +++ b/esphome/components/pca9685/pca9685_output.h @@ -24,7 +24,7 @@ inline constexpr uint8_t PCA9685_MODE_OUTNE_LOW = 0x01; class PCA9685Output; -class PCA9685Channel : public output::FloatOutput { +class PCA9685Channel final : public output::FloatOutput { public: void set_channel(uint8_t channel) { channel_ = channel; } void set_parent(PCA9685Output *parent) { parent_ = parent; } @@ -39,7 +39,7 @@ class PCA9685Channel : public output::FloatOutput { }; /// PCA9685 float output component. -class PCA9685Output : public Component, public i2c::I2CDevice { +class PCA9685Output final : public Component, public i2c::I2CDevice { public: PCA9685Output(uint8_t mode = PCA9685_MODE_OUTPUT_ONACK | PCA9685_MODE_OUTPUT_TOTEM_POLE) : mode_(mode) {} diff --git a/esphome/components/pcd8544/pcd_8544.h b/esphome/components/pcd8544/pcd_8544.h index 9e4ee93035..3368c39551 100644 --- a/esphome/components/pcd8544/pcd_8544.h +++ b/esphome/components/pcd8544/pcd_8544.h @@ -6,9 +6,9 @@ namespace esphome::pcd8544 { -class PCD8544 : public display::DisplayBuffer, - public spi::SPIDevice { +class PCD8544 final : public display::DisplayBuffer, + public spi::SPIDevice { public: const uint8_t PCD8544_POWERDOWN = 0x04; const uint8_t PCD8544_ENTRYMODE = 0x02; diff --git a/esphome/components/pcf85063/pcf85063.h b/esphome/components/pcf85063/pcf85063.h index 1c6b6bf36d..659260ba5e 100644 --- a/esphome/components/pcf85063/pcf85063.h +++ b/esphome/components/pcf85063/pcf85063.h @@ -6,7 +6,7 @@ namespace esphome::pcf85063 { -class PCF85063Component : public time::RealTimeClock, public i2c::I2CDevice { +class PCF85063Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -81,12 +81,12 @@ class PCF85063Component : public time::RealTimeClock, public i2c::I2CDevice { } pcf85063_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/pcf8563/pcf8563.h b/esphome/components/pcf8563/pcf8563.h index 72b600d9ba..e208774c2c 100644 --- a/esphome/components/pcf8563/pcf8563.h +++ b/esphome/components/pcf8563/pcf8563.h @@ -6,7 +6,7 @@ namespace esphome::pcf8563 { -class PCF8563Component : public time::RealTimeClock, public i2c::I2CDevice { +class PCF8563Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -109,12 +109,12 @@ class PCF8563Component : public time::RealTimeClock, public i2c::I2CDevice { } pcf8563_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index ece472c4bb..e8f78bae50 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -9,9 +9,9 @@ namespace esphome::pcf8574 { // PCF8574(8 pins)/PCF8575(16 pins) always read/write all pins in a single I2C transaction // so we use uint16_t as bank type to ensure all pins are in one bank and cached together -class PCF8574Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCF8574Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCF8574Component() = default; @@ -49,7 +49,7 @@ class PCF8574Component : public Component, }; /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. -class PCF8574GPIOPin : public GPIOPin { +class PCF8574GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h index f86b096c82..3c42e4d8d2 100644 --- a/esphome/components/pcm5122/pcm5122.h +++ b/esphome/components/pcm5122/pcm5122.h @@ -41,7 +41,7 @@ enum PCM5122BitsPerSample : uint8_t { PCM5122_BITS_PER_SAMPLE_32 = 32, }; -class PCM5122 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/pcm5122/pcm5122_gpio.h b/esphome/components/pcm5122/pcm5122_gpio.h index 8edaa6d3e8..0c750ab278 100644 --- a/esphome/components/pcm5122/pcm5122_gpio.h +++ b/esphome/components/pcm5122/pcm5122_gpio.h @@ -6,7 +6,7 @@ namespace esphome::pcm5122 { -class PCM5122GPIOPin : public GPIOPin, public Parented { +class PCM5122GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 6225956430..9909dc2217 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -6,9 +6,9 @@ #include "esphome/core/hal.h" namespace esphome::pi4ioe5v6408 { -class PI4IOE5V6408Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PI4IOE5V6408Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PI4IOE5V6408Component() = default; @@ -49,7 +49,7 @@ class PI4IOE5V6408Component : public Component, bool read_gpio_outputs_(); }; -class PI4IOE5V6408GPIOPin : public GPIOPin, public Parented { +class PI4IOE5V6408GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index 9e3c89ca4d..7269709ab9 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -11,7 +11,7 @@ namespace esphome::pid { -class PIDClimate : public climate::Climate, public Component { +class PIDClimate final : public climate::Climate, public Component { public: PIDClimate() = default; void setup() override; @@ -108,7 +108,7 @@ class PIDClimate : public climate::Climate, public Component { bool do_publish_ = false; }; -template class PIDAutotuneAction : public Action { +template class PIDAutotuneAction final : public Action { public: PIDAutotuneAction(PIDClimate *parent) : parent_(parent) {} @@ -131,7 +131,7 @@ template class PIDAutotuneAction : public Action { PIDClimate *parent_; }; -template class PIDResetIntegralTermAction : public Action { +template class PIDResetIntegralTermAction final : public Action { public: PIDResetIntegralTermAction(PIDClimate *parent) : parent_(parent) {} @@ -141,7 +141,7 @@ template class PIDResetIntegralTermAction : public Action PIDClimate *parent_; }; -template class PIDSetControlParametersAction : public Action { +template class PIDSetControlParametersAction final : public Action { public: PIDSetControlParametersAction(PIDClimate *parent) : parent_(parent) {} diff --git a/esphome/components/pid/sensor/pid_climate_sensor.h b/esphome/components/pid/sensor/pid_climate_sensor.h index d6bdc66a46..b62d597780 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.h +++ b/esphome/components/pid/sensor/pid_climate_sensor.h @@ -18,7 +18,7 @@ enum PIDClimateSensorType { PID_SENSOR_TYPE_KD, }; -class PIDClimateSensor : public sensor::Sensor, public Component { +class PIDClimateSensor final : public sensor::Sensor, public Component { public: void setup() override; void set_parent(PIDClimate *parent) { parent_ = parent; } diff --git a/esphome/components/pipsolar/output/pipsolar_output.h b/esphome/components/pipsolar/output/pipsolar_output.h index 4a6e4c29d7..6fc013c276 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.h +++ b/esphome/components/pipsolar/output/pipsolar_output.h @@ -10,7 +10,7 @@ namespace esphome::pipsolar { class Pipsolar; -class PipsolarOutput : public output::FloatOutput { +class PipsolarOutput final : public output::FloatOutput { public: PipsolarOutput() {} void set_parent(Pipsolar *parent) { this->parent_ = parent; } @@ -27,7 +27,7 @@ class PipsolarOutput : public output::FloatOutput { std::vector possible_values_; }; -template class SetOutputAction : public Action { +template class SetOutputAction final : public Action { public: SetOutputAction(PipsolarOutput *output) : output_(output) {} diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 59332080cf..06c920a6e4 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -56,7 +56,7 @@ struct QFLAGValues { PIPSOLAR_ENTITY_(binary_sensor::BinarySensor, name, polling_command) #define PIPSOLAR_TEXT_SENSOR(name, polling_command) PIPSOLAR_ENTITY_(text_sensor::TextSensor, name, polling_command) -class Pipsolar : public uart::UARTDevice, public PollingComponent { +class Pipsolar final : public uart::UARTDevice, public PollingComponent { // QPIGS values PIPSOLAR_SENSOR(grid_voltage, QPIGS) PIPSOLAR_SENSOR(grid_frequency, QPIGS) diff --git a/esphome/components/pipsolar/switch/pipsolar_switch.h b/esphome/components/pipsolar/switch/pipsolar_switch.h index 20d2640d90..2b8cda9d59 100644 --- a/esphome/components/pipsolar/switch/pipsolar_switch.h +++ b/esphome/components/pipsolar/switch/pipsolar_switch.h @@ -6,7 +6,7 @@ namespace esphome::pipsolar { class Pipsolar; -class PipsolarSwitch : public switch_::Switch, public Component { +class PipsolarSwitch final : public switch_::Switch, public Component { public: void set_parent(Pipsolar *parent) { this->parent_ = parent; } void set_on_command(const char *command) { this->on_command_ = command; } diff --git a/esphome/components/pm1006/pm1006.h b/esphome/components/pm1006/pm1006.h index 38ab284f47..b32bb2ba8e 100644 --- a/esphome/components/pm1006/pm1006.h +++ b/esphome/components/pm1006/pm1006.h @@ -7,7 +7,7 @@ namespace esphome::pm1006 { -class PM1006Component : public PollingComponent, public uart::UARTDevice { +class PM1006Component final : public PollingComponent, public uart::UARTDevice { public: PM1006Component() = default; diff --git a/esphome/components/pm2005/pm2005.h b/esphome/components/pm2005/pm2005.h index 9661d082d1..e4ab9ff328 100644 --- a/esphome/components/pm2005/pm2005.h +++ b/esphome/components/pm2005/pm2005.h @@ -11,7 +11,7 @@ enum SensorType { PM2105, }; -class PM2005Component : public PollingComponent, public i2c::I2CDevice { +class PM2005Component final : public PollingComponent, public i2c::I2CDevice { public: void set_sensor_type(SensorType sensor_type) { this->sensor_type_ = sensor_type; } From cbcf23426d8d64069d4da7f0e96a0065aa9750d6 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Wed, 24 Jun 2026 10:08:59 +0000 Subject: [PATCH 1803/2214] [waveshare_io_ch32v003] Waveshare I/O Expander component (#10071) --- CODEOWNERS | 1 + .../waveshare_io_ch32v003/__init__.py | 84 +++++++++ .../waveshare_io_ch32v003/output/__init__.py | 70 ++++++++ .../output/waveshare_io_ch32v003_output.cpp | 19 ++ .../output/waveshare_io_ch32v003_output.h | 22 +++ .../waveshare_io_ch32v003/sensor/__init__.py | 55 ++++++ .../sensor/waveshare_io_ch32v003_sensor.cpp | 27 +++ .../sensor/waveshare_io_ch32v003_sensor.h | 29 +++ .../waveshare_io_ch32v003.cpp | 168 ++++++++++++++++++ .../waveshare_io_ch32v003.h | 65 +++++++ .../waveshare_io_ch32v003/common.yaml | 33 ++++ .../waveshare_io_ch32v003/test.esp32-idf.yaml | 4 + 12 files changed, 577 insertions(+) create mode 100644 esphome/components/waveshare_io_ch32v003/__init__.py create mode 100644 esphome/components/waveshare_io_ch32v003/output/__init__.py create mode 100644 esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp create mode 100644 esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h create mode 100644 esphome/components/waveshare_io_ch32v003/sensor/__init__.py create mode 100644 esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp create mode 100644 esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h create mode 100644 esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp create mode 100644 esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h create mode 100644 tests/components/waveshare_io_ch32v003/common.yaml create mode 100644 tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d425614582..70ad580e77 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -578,6 +578,7 @@ esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher esphome/components/water_heater/* @dhoeben esphome/components/waveshare_epaper/* @clydebarrow +esphome/components/waveshare_io_ch32v003/* @latonita esphome/components/web_server/ota/* @esphome/core esphome/components/web_server_base/* @esphome/core esphome/components/web_server_idf/* @dentra diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py new file mode 100644 index 0000000000..b692b858a3 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -0,0 +1,84 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) + +CODEOWNERS = ["@latonita"] + +AUTO_LOAD = ["gpio_expander"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True + +waveshare_io_ch32v003_ns = cg.esphome_ns.namespace("waveshare_io_ch32v003") + +WaveshareIOCH32V003Component = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Component", cg.Component, i2c.I2CDevice +) +WaveshareIOCH32V003GPIOPin = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003GPIOPin", + cg.GPIOPin, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONF_WAVESHARE_IO_CH32V003 = "waveshare_io_ch32v003" +CONF_WAVESHARE_IO_CH32V003_ID = "waveshare_io_ch32v003_id" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(WaveshareIOCH32V003Component), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x24)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + +def validate_mode(value): + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( + WaveshareIOCH32V003GPIOPin, + cv.int_range(min=0, max=7), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=validate_mode, + invertible=True, +).extend( + { + cv.Required(CONF_WAVESHARE_IO_CH32V003): cv.use_id( + WaveshareIOCH32V003Component + ), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_WAVESHARE_IO_CH32V003, WAVESHARE_IO_PIN_SCHEMA) +async def waveshare_io_pin_to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + parent = await cg.get_variable(config[CONF_WAVESHARE_IO_CH32V003]) + + cg.add(var.set_parent(parent)) + + num = config[CONF_NUMBER] + cg.add(var.set_pin(num)) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output/__init__.py new file mode 100644 index 0000000000..9af9ce7e4b --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/__init__.py @@ -0,0 +1,70 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MAX_VALUE, CONF_MIN_VALUE + +from .. import ( + CONF_WAVESHARE_IO_CH32V003_ID, + WaveshareIOCH32V003Component, + waveshare_io_ch32v003_ns, +) + +DEPENDENCIES = ["waveshare_io_ch32v003"] + +WaveshareIOCH32V003Output = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Output", + output.FloatOutput, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONF_SAFE_PWM_LEVELS = "safe_pwm_levels" + +DUTY_DEFAULT_MIN = 1 +DUTY_DEFAULT_MAX = 247 + + +def validate_pwm_limits(config): + """Validate that safe_pwm_levels.min_value <= safe_pwm_levels.max_value.""" + + min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) + max_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MAX_VALUE, DUTY_DEFAULT_MAX) + if min_val > max_val: + raise cv.Invalid( + f"safe_pwm_levels.min_value ({min_val}) cannot be greater than " + f"safe_pwm_levels.max_value ({max_val})" + ) + return config + + +CONF_SAFE_PWM_LEVELS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_MIN_VALUE, default=DUTY_DEFAULT_MIN): cv.int_range( + min=0, max=255 + ), + cv.Optional(CONF_MAX_VALUE, default=DUTY_DEFAULT_MAX): cv.int_range( + min=0, max=255 + ), + } +) + +CONFIG_SCHEMA = cv.All( + output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(WaveshareIOCH32V003Output), + cv.GenerateID(CONF_WAVESHARE_IO_CH32V003_ID): cv.use_id( + WaveshareIOCH32V003Component + ), + cv.Optional(CONF_SAFE_PWM_LEVELS): CONF_SAFE_PWM_LEVELS_SCHEMA, + } + ), + validate_pwm_limits, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await output.register_output(var, config) + await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) + min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) + max_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MAX_VALUE, DUTY_DEFAULT_MAX) + cg.add(var.set_pwm_safe_range(min_val, max_val)) diff --git a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp new file mode 100644 index 0000000000..30458310ce --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp @@ -0,0 +1,19 @@ +#include "waveshare_io_ch32v003_output.h" +#include "esphome/core/log.h" +#include + +namespace esphome::waveshare_io_ch32v003 { + +static const char *const TAG = "waveshare_io_ch32v003.output"; + +void WaveshareIOCH32V003Output::write_state(float state) { + uint8_t pwm_value = static_cast(state * 255.0f); + uint8_t final_pwm_value = std::clamp(pwm_value, this->pwm_min_value_, this->pwm_max_value_); + if (final_pwm_value != pwm_value) { + ESP_LOGVV(TAG, "Clamping PWM value %u to safe range [%u, %u]", pwm_value, this->pwm_min_value_, + this->pwm_max_value_); + } + this->parent_->set_pwm_value(final_pwm_value); +} + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h new file mode 100644 index 0000000000..abe8183692 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h @@ -0,0 +1,22 @@ +#pragma once + +#include "../waveshare_io_ch32v003.h" +#include "esphome/components/output/float_output.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Output : public output::FloatOutput, public Parented { + public: + void set_pwm_safe_range(uint8_t min_value, uint8_t max_value) { + this->pwm_min_value_ = min_value; + this->pwm_max_value_ = max_value; + } + + protected: + void write_state(float state) override; + + uint8_t pwm_min_value_{1}; + uint8_t pwm_max_value_{247}; +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/sensor/__init__.py b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py new file mode 100644 index 0000000000..1e060bdfe4 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +from esphome.components import sensor, voltage_sampler +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_REFERENCE_VOLTAGE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_VOLT, +) + +from .. import ( + CONF_WAVESHARE_IO_CH32V003_ID, + WaveshareIOCH32V003Component, + waveshare_io_ch32v003_ns, +) + +AUTO_LOAD = ["voltage_sampler"] +DEPENDENCIES = ["waveshare_io_ch32v003"] + +WaveshareIOCH32V003Sensor = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Sensor", + sensor.Sensor, + cg.PollingComponent, + voltage_sampler.VoltageSampler, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + WaveshareIOCH32V003Sensor, + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.GenerateID(CONF_WAVESHARE_IO_CH32V003_ID): cv.use_id( + WaveshareIOCH32V003Component + ), + cv.Optional(CONF_REFERENCE_VOLTAGE, default="9.9V"): cv.voltage, + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + + cg.add(var.set_reference_voltage(config[CONF_REFERENCE_VOLTAGE])) diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp new file mode 100644 index 0000000000..82da7451f9 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp @@ -0,0 +1,27 @@ +#include "waveshare_io_ch32v003_sensor.h" + +#include "esphome/core/log.h" + +namespace esphome::waveshare_io_ch32v003 { + +static const char *const TAG = "waveshare_io_ch32v003.sensor"; + +float WaveshareIOCH32V003Sensor::get_setup_priority() const { return setup_priority::DATA; } + +void WaveshareIOCH32V003Sensor::dump_config() { + ESP_LOGCONFIG(TAG, + "WaveshareIOCH32V003Sensor:\n" + " Reference Voltage: %.2fV", + this->reference_voltage_); +} + +float WaveshareIOCH32V003Sensor::sample() { + uint16_t adc_value = this->parent_->get_adc_value(); + // Convert the ADC value to voltage. 10-bit ADC + float voltage = adc_value * this->reference_voltage_ / 1023.0f; + return voltage; +} + +void WaveshareIOCH32V003Sensor::update() { this->publish_state(this->sample()); } + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h new file mode 100644 index 0000000000..01beab5137 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/voltage_sampler/voltage_sampler.h" + +#include "../waveshare_io_ch32v003.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Sensor : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public Parented { + public: + void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } + + void update() override; + void dump_config() override; + float get_setup_priority() const override; + float sample() override; + + protected: + float reference_voltage_{9.9f}; // Default reference voltage for ADC calculations, can be overridden by user config +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp new file mode 100644 index 0000000000..8a58c7e7bb --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -0,0 +1,168 @@ +#include "waveshare_io_ch32v003.h" +#include "esphome/core/log.h" + +namespace esphome::waveshare_io_ch32v003 { + +static const uint8_t IO_EXTENSION_DIRECTION = 0x02; +static const uint8_t IO_EXTENSION_IO_OUTPUT_ADDR = 0x03; +static const uint8_t IO_EXTENSION_IO_INPUT_ADDR = 0x04; +static const uint8_t IO_EXTENSION_PWM_ADDR = 0x05; +static const uint8_t IO_EXTENSION_ADC_ADDR = 0x06; +static const uint8_t IO_EXTENSION_RTC_INT_ADDR = 0x07; + +static const char *const TAG = "waveshare_io_ch32v003"; + +void WaveshareIOCH32V003Component::setup() { + this->mode_mask_ = 0xFF; // Set all pins to output mode + this->output_mask_ = 0xFF; // Set all pins to high (output mode) + + bool step1 = this->write_gpio_modes_(); + bool step2 = this->write_gpio_outputs_(); + + if (!step1 || !step2) { + ESP_LOGE(TAG, "Failed to initialize Waveshare IO expander"); + this->mark_failed(); + return; + } + + this->disable_loop(); +} + +void WaveshareIOCH32V003Component::pin_mode(uint8_t pin, gpio::Flags flags) { + // bits: 0 = input, 1 = output + if (flags == gpio::FLAG_INPUT) { + // Clear mode mask bit + this->mode_mask_ &= ~(1 << pin); + this->enable_loop(); + } else if (flags == gpio::FLAG_OUTPUT) { + // Set mode mask bit + this->mode_mask_ |= 1 << pin; + } + this->write_gpio_modes_(); +} + +void WaveshareIOCH32V003Component::loop() { this->reset_pin_cache_(); } + +void WaveshareIOCH32V003Component::dump_config() { + ESP_LOGCONFIG(TAG, "WaveshareIO:"); + LOG_I2C_DEVICE(this) + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +uint16_t WaveshareIOCH32V003Component::get_adc_value() { + if (this->is_failed()) + return 0; + + uint8_t data[2]; + if (!this->read_bytes(IO_EXTENSION_ADC_ADDR, data, 2)) { + this->status_set_warning(LOG_STR("Failed to read ADC register")); + return 0; + } + uint16_t adc_value = (data[1] << 8) | data[0]; + this->status_clear_warning(); + return adc_value; +} + +uint8_t WaveshareIOCH32V003Component::get_rtc_interrupt_status() { + if (this->is_failed()) + return 0; + + uint8_t data = 0; + if (!this->read_bytes(IO_EXTENSION_RTC_INT_ADDR, &data, 1)) { + this->status_set_warning(LOG_STR("Failed to read RTC interrupt register")); + return 0; + } + this->status_clear_warning(); + return data; +} + +void WaveshareIOCH32V003Component::set_pwm_value(uint8_t value) { + if (this->is_failed()) + return; + + // PWM limits are enforced at the output component level to protect hardware + // based on circuit schematic requirements. This follows the pattern from the + // original Waveshare IO library function "void IO_EXTENSION_Pwm_Output(uint8_t Value)". + + if (!this->write_byte(IO_EXTENSION_PWM_ADDR, value)) { + this->status_set_warning(LOG_STR("Failed to set PWM duty cycle")); + return; + } + + this->status_clear_warning(); +} + +bool WaveshareIOCH32V003Component::write_gpio_modes_() { + if (this->is_failed()) + return false; + if (!this->write_byte(IO_EXTENSION_DIRECTION, this->mode_mask_)) { + this->status_set_warning(LOG_STR("Failed to write mode register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool WaveshareIOCH32V003Component::write_gpio_outputs_() { + if (this->is_failed()) + return false; + if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, this->output_mask_)) { + this->status_set_warning(LOG_STR("Failed to write output register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { + if (this->is_failed()) + return false; + + uint8_t data = 0; + if (!this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1)) { + this->status_set_warning(LOG_STR("Failed to read input register")); + return false; + } + this->input_mask_ = data; + + this->status_clear_warning(); + return true; +} + +void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { + if (this->is_failed()) + return; + + if (value) { + this->output_mask_ |= (1 << pin); + } else { + this->output_mask_ &= ~(1 << pin); + } + + uint8_t data = this->output_mask_; + if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data)) { + this->status_set_warning(LOG_STR("Failed to write output register")); + return; + } + + this->status_clear_warning(); +} + +bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } +float WaveshareIOCH32V003Component::get_setup_priority() const { return setup_priority::IO; } + +void WaveshareIOCH32V003GPIOPin::setup() { this->pin_mode(this->flags_); } +void WaveshareIOCH32V003GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } +bool WaveshareIOCH32V003GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } + +void WaveshareIOCH32V003GPIOPin::digital_write(bool value) { + this->parent_->digital_write(this->pin_, value ^ this->inverted_); +} + +size_t WaveshareIOCH32V003GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "EXIO%u via WaveshareIO", this->pin_); +} + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h new file mode 100644 index 0000000000..4a31602fa4 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h @@ -0,0 +1,65 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Component : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { + public: + WaveshareIOCH32V003Component() = default; + + void setup() override; + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + + void dump_config() override; + + void loop() override; + + uint16_t get_adc_value(); + uint8_t get_rtc_interrupt_status(); + void set_pwm_value(uint8_t value); // 0 - 255 + + protected: + friend class WaveshareIOCH32V003GPIOPin; + + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + + uint8_t mode_mask_{0x00}; // Mask for the pin mode - 1 means output, 0 means input + uint8_t output_mask_{0x00}; // The mask to write as output state - 1 means HIGH, 0 means LOW + uint8_t input_mask_{0x00}; // The state read in digital_read_hw - 1 means HIGH, 0 means LOW + + bool write_gpio_modes_(); + bool write_gpio_outputs_(); +}; + +/// Helper class to expose a WaveshareIO pin as a GPIO pin. +class WaveshareIOCH32V003GPIOPin : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{}; + bool inverted_{}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/tests/components/waveshare_io_ch32v003/common.yaml b/tests/components/waveshare_io_ch32v003/common.yaml new file mode 100644 index 0000000000..086b27ab96 --- /dev/null +++ b/tests/components/waveshare_io_ch32v003/common.yaml @@ -0,0 +1,33 @@ +waveshare_io_ch32v003: + - id: wave_io + address: 0x24 + +binary_sensor: + - platform: gpio + id: wave_io_binary_sensor + pin: + waveshare_io_ch32v003: wave_io + number: 3 + mode: INPUT + inverted: false + +output: + - platform: gpio + id: wave_io_output + pin: + waveshare_io_ch32v003: wave_io + number: 0 + mode: OUTPUT + inverted: false + + - platform: waveshare_io_ch32v003 + id: wave_io_pwm_output + inverted: true + zero_means_zero: true + safe_pwm_levels: + min_value: 0 + max_value: 247 + +sensor: + - platform: waveshare_io_ch32v003 + id: wave_io_adc diff --git a/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml b/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From 18f29f8d2b78f2e6fbceb1a2bab34c95dce73032 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:05:23 +1000 Subject: [PATCH 1804/2214] [mipi_spi] Suppress sequence errors when page selection used (#17176) --- esphome/components/mipi/__init__.py | 1 + esphome/components/mipi_spi/display.py | 20 ++-- esphome/components/mipi_spi/mipi_spi.h | 8 -- .../mipi_spi/test_page_selection.py | 113 ++++++++++++++++++ 4 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_page_selection.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index caa33cd834..2244a316b7 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -120,6 +120,7 @@ CSCON = 0xF0 PWCTR6 = 0xF6 ADJCTL3 = 0xF7 PAGESEL = 0xFE +PAGESEL1 = 0xFF MADCTL_MY = 0x80 # Bit 7 Bottom to top MADCTL_MX = 0x40 # Bit 6 Right to left diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index d613d0a1ab..0231d12529 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -17,6 +17,8 @@ from esphome.components.mipi import ( MADCTL, MODE_BGR, MODE_RGB, + PAGESEL, + PAGESEL1, PIXFMT, DriverChip, dimension_schema, @@ -276,14 +278,16 @@ def customise_schema(config): # Check for invalid combinations of MADCTL config if init_sequence := config.get(CONF_INIT_SEQUENCE): commands = [x[0] for x in init_sequence] - if MADCTL in commands and CONF_TRANSFORM in config: - raise cv.Invalid( - f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" - ) - if PIXFMT in commands: - raise cv.Invalid( - f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" - ) + # If there is page swapping, we can't rely on recognising common commands + if PAGESEL not in commands and PAGESEL1 not in commands: + if MADCTL in commands and CONF_TRANSFORM in config: + raise cv.Invalid( + f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" + ) + if PIXFMT in commands: + raise cv.Invalid( + f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" + ) if bus_mode == TYPE_QUAD and CONF_DC_PIN in config: raise cv.Invalid("DC pin is not supported in quad mode") diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index a594e48209..d9627899e0 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -176,7 +176,6 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - auto arg_byte = vec[index]; switch (cmd) { case SLEEP_OUT: { // are we ready, boots? @@ -187,13 +186,6 @@ class MipiSpi : public display::Display, } } break; - case INVERT_ON: - this->invert_colors_ = true; - break; - case BRIGHTNESS: - this->brightness_ = arg_byte; - break; - default: break; } diff --git a/tests/component_tests/mipi_spi/test_page_selection.py b/tests/component_tests/mipi_spi/test_page_selection.py new file mode 100644 index 0000000000..4b1ec22271 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_page_selection.py @@ -0,0 +1,113 @@ +"""Combined tests for PAGESEL/PAGESEL1 behaviour with MADCTL/PIXFMT. + +Covers both the suppression behaviour (when PAGESEL or PAGESEL1 are present) +and the error behaviour when neither page-selection command is present. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi import MADCTL, PAGESEL, PAGESEL1, PIXFMT +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: dict[str, Any]) -> dict[str, Any]: + """Run schema + final validation and return the validated config.""" + cfg = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(cfg) + return cfg + + +def test_madctl_error_suppressed_when_pagesel_present( + set_core_config: SetCoreConfigCallable, +) -> None: + """If PAGESEL is present in init_sequence, MADCTL presence must not raise an error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False}, + "init_sequence": [[PAGESEL, 0x00], [MADCTL, 0x01]], + } + + # Should not raise + validated = validated_config(cfg) + assert validated is not None + + +def test_pixfmt_error_suppressed_when_pagesel1_present( + set_core_config: SetCoreConfigCallable, +) -> None: + """If PAGESEL1 is present in init_sequence, PIXFMT presence must not raise an error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[PAGESEL1, 0x00], [PIXFMT, 0x01]], + } + + # Should not raise + validated = validated_config(cfg) + assert validated is not None + + +def test_madctl_raises_without_pagesel( + set_core_config: SetCoreConfigCallable, +) -> None: + """MADCTL in the init_sequence should raise when a transform is configured and + no PAGESEL/PAGESEL1 is present. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg: dict[str, Any] = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False}, + "init_sequence": [[MADCTL, 0x01]], + } + + with pytest.raises(cv.Invalid, match=r"MADCTL .* in the init sequence"): + CONFIG_SCHEMA(cfg) + + +def test_pixfmt_raises_without_pagesel1( + set_core_config: SetCoreConfigCallable, +) -> None: + """PIXFMT in the init_sequence should raise when no PAGESEL/PAGESEL1 is present.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg: dict[str, Any] = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[PIXFMT, 0x01]], + } + + with pytest.raises( + cv.Invalid, match=r"PIXFMT .* should not be in the init sequence" + ): + CONFIG_SCHEMA(cfg) From e8acd24fd9f2d8f111195a6d62996065f1cb2b49 Mon Sep 17 00:00:00 2001 From: Geoffrey Frogeye Date: Wed, 24 Jun 2026 15:29:57 +0200 Subject: [PATCH 1805/2214] [opentherm] Support power scaling disabled (#17183) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/opentherm/output/opentherm_output.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 2735c85d06..4092358d75 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -7,9 +7,13 @@ static const char *const TAG = "opentherm.output"; void opentherm::OpenthermOutput::write_state(float state) { ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_); - this->state = state < 0.003 && this->zero_means_zero_ - ? 0.0 - : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING + bool zero_means_zero = this->zero_means_zero_; +#else + bool zero_means_zero = false; +#endif + this->state = + state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); this->has_state_ = true; ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state); } From f471329d606017e0f1df58a3d153486c45de22d0 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:15:00 +0200 Subject: [PATCH 1806/2214] Bump bundled esphome-device-builder to 1.0.16 (#17182) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1cd3372255..c4a49b778f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.15 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.16 RUN \ platformio settings set enable_telemetry No \ From 72b663fc40b6e7fe647ccf111c7e2ce0b48a9ce9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:22 +0200 Subject: [PATCH 1807/2214] Bump bundled esphome-device-builder to 1.0.17 (#17199) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c4a49b778f..c02aba093c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.16 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.17 RUN \ platformio settings set enable_telemetry No \ From b68847444440bd6986d2d5ac48a7156bfdfc63a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:37 +0200 Subject: [PATCH 1808/2214] Bump ruff from 0.15.18 to 0.15.19 (#17195) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 4e498abc21..6e53a4c14f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.18 # also change in .pre-commit-config.yaml when updating +ruff==0.15.19 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From fa34c679500f4b076e12c9908204bfb34a8d723d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:52 +0200 Subject: [PATCH 1809/2214] Bump CodSpeedHQ/action from 4.17.6 to 4.18.1 (#17198) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a7233b3a8..f8c1410cec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6 + uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 with: run: | . venv/bin/activate From 155439be74710c8227ccf8b9442415b93d7e11bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:05:13 +0200 Subject: [PATCH 1810/2214] Bump actions/setup-python from 6.2.0 to 6.3.0 (#17197) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 4 ++-- .github/workflows/sync-device-classes.yml | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 2155b67b25..17234e811a 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - name: Set up uv diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 8301f8e9e3..9678831b50 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -63,7 +63,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - name: Set up Docker Buildx @@ -147,7 +147,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - name: Set up Docker Buildx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c1410cec..eaa04ceca6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment @@ -162,7 +162,7 @@ jobs: ref: main path: device-builder - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - name: Set up uv @@ -360,7 +360,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python 3.13 id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - name: Restore Python virtual environment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3056d9e7d6..2b23b561bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.x" - name: Build @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 05036f3500..0501d6d364 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -37,7 +37,7 @@ jobs: path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.14" From aff5e248edea456706daad0f713dda9a185d6608 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:05:30 +0200 Subject: [PATCH 1811/2214] Bump actions/setup-python from 6.2.0 to 6.3.0 in /.github/actions/restore-python (#17194) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 96a3be53c6..6290e25d7c 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment From e96717f6cd12e6371bf83fb8e143f494d44973f7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:16:53 -0400 Subject: [PATCH 1812/2214] [waveshare_io_ch32v003] Pin i2c_id in test to avoid grouping conflict (#17191) --- tests/components/waveshare_io_ch32v003/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/waveshare_io_ch32v003/common.yaml b/tests/components/waveshare_io_ch32v003/common.yaml index 086b27ab96..c8805583c7 100644 --- a/tests/components/waveshare_io_ch32v003/common.yaml +++ b/tests/components/waveshare_io_ch32v003/common.yaml @@ -1,5 +1,6 @@ waveshare_io_ch32v003: - id: wave_io + i2c_id: i2c_bus address: 0x24 binary_sensor: From 538f554bdb0d384d7627cbdcc2e7772845004a88 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:38:55 -0400 Subject: [PATCH 1813/2214] [psram] Support ESP32-S31/H4 (#17192) --- esphome/components/psram/__init__.py | 12 ++++++++---- tests/component_tests/psram/test_psram.py | 10 +++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 296ea6c08c..84683e9a25 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -10,9 +10,11 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C5, VARIANT_ESP32C61, + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, idf_version, @@ -57,8 +59,10 @@ SPIRAM_MODES = { VARIANT_ESP32: (TYPE_QUAD,), VARIANT_ESP32C5: (TYPE_QUAD,), VARIANT_ESP32C61: (TYPE_QUAD,), + VARIANT_ESP32H4: (TYPE_QUAD,), VARIANT_ESP32S2: (TYPE_QUAD,), VARIANT_ESP32S3: (TYPE_QUAD, TYPE_OCTAL), + VARIANT_ESP32S31: (TYPE_OCTAL,), VARIANT_ESP32P4: (TYPE_HEX,), } @@ -67,8 +71,10 @@ SPIRAM_SPEEDS = { VARIANT_ESP32: (40, 80, 120), VARIANT_ESP32C5: (40, 80, 120), VARIANT_ESP32C61: (40, 80), + VARIANT_ESP32H4: (32, 64), VARIANT_ESP32S2: (40, 80, 120), VARIANT_ESP32S3: (40, 80, 120), + VARIANT_ESP32S31: (40, 100, 200, 250), VARIANT_ESP32P4: (20, 100, 200), } @@ -145,10 +151,8 @@ def validate_psram_mode(config): raise cv.Invalid("ECC is only available in octal mode.") if config[CONF_MODE] == TYPE_OCTAL: variant = get_esp32_variant() - if variant != VARIANT_ESP32S3: - raise cv.Invalid( - f"Octal PSRAM is only supported on ESP32-S3, not {variant}" - ) + if TYPE_OCTAL not in SPIRAM_MODES.get(variant, ()): + raise cv.Invalid(f"Octal PSRAM is not supported on {variant}") return config diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index ea4adc69a9..4a1ed2c72a 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -12,9 +12,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ) import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, PlatformFramework @@ -25,21 +28,26 @@ UNSUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H21, ] SUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32, VARIANT_ESP32C5, + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ] SUPPORTED_PSRAM_MODES = { VARIANT_ESP32: ["quad"], VARIANT_ESP32C5: ["quad"], + VARIANT_ESP32H4: ["quad"], VARIANT_ESP32P4: ["hex"], VARIANT_ESP32S2: ["quad"], VARIANT_ESP32S3: ["quad", "octal"], + VARIANT_ESP32S31: ["octal"], } @@ -187,7 +195,7 @@ def _setup_psram_final_validation_test( {"mode": "octal"}, {"variant": "ESP32"}, True, - r"Octal PSRAM is only supported on ESP32-S3", + r"Octal PSRAM is not supported on ESP32", id="octal_mode_only_esp32s3", ), pytest.param( From 91e515ca7cccb749645551038990911c4cbaf717 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:40:28 -0400 Subject: [PATCH 1814/2214] [esp32] Accept '#' as ESP-IDF source ref separator (#17193) --- esphome/espidf/framework.py | 9 ++++++--- tests/unit_tests/test_espidf_framework.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f0715ce3b2..c994ce2410 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -337,16 +337,19 @@ print(".".join([str(x) for x in sys.version_info])) _GITHUB_SHORTHAND_RE = re.compile( - r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" + r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$" ) _GITHUB_HTTPS_RE = re.compile( - r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$" + r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$" ) def _parse_git_source(source_url: str) -> tuple[str, str | None] | None: """Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or - ``https://github.com/owner/repo.git[@ref]``, else ``None``.""" + ``https://github.com/owner/repo.git[@ref]``, else ``None``. + + The ref may be separated with ``@`` or ``#``; ``#`` matches the PlatformIO + convention used for ``platform_version`` URLs.""" if m := _GITHUB_SHORTHAND_RE.match(source_url): owner, repo, ref = m.group(1), m.group(2), m.group(3) # Tolerate a trailing ".git" on the shorthand repo so the diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index b5fa0e2698..fe888ac8b9 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -65,6 +65,19 @@ from esphome.framework_helpers import _tar_extract_all, get_python_env_executabl "https://github.com/espressif/esp-idf.git@v6.0.1", ("https://github.com/espressif/esp-idf.git", "v6.0.1"), ), + # '#' ref separator (PlatformIO/git-web convention) works on both forms + ( + "https://github.com/espressif/esp-idf.git#release/v6.1", + ("https://github.com/espressif/esp-idf.git", "release/v6.1"), + ), + ( + "github://espressif/esp-idf#release/v6.1", + ("https://github.com/espressif/esp-idf.git", "release/v6.1"), + ), + ( + "github://espressif/esp-idf.git#master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), # Tolerate a trailing ".git" on the shorthand so the user doesn't # silently end up with a doubled "...esp-idf.git.git" URL. ( From 23aff5202b1a73e63cecd0c13f394e1266fff1b6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:42:41 -0400 Subject: [PATCH 1815/2214] [wifi][openthread] Wire ESP32-S31/H4/H21 radio support (#17186) --- esphome/components/openthread/__init__.py | 12 +++++++++++- esphome/components/wifi/__init__.py | 12 +++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 215f921229..2dc8a783df 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -3,6 +3,9 @@ from esphome.components.esp32 import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, include_builtin_idf_component, @@ -187,7 +190,14 @@ def _validate_platform(config): if CORE.using_zephyr: return config return only_on_variant( - supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2] + supported=[ + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, + ] )(config) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1cfd2b9821..512fd63e12 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -76,14 +76,20 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["network"] -NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] +NO_WIFI_VARIANTS = [ + const.VARIANT_ESP32H2, + const.VARIANT_ESP32H4, + const.VARIANT_ESP32H21, + const.VARIANT_ESP32P4, +] def variant_has_wifi(variant: str) -> bool: """Return True if *variant* has a native WiFi PHY. - Variants without a native PHY (ESP32-H2, ESP32-P4) need the - ``esp32_hosted`` co-processor to use ``wifi:``. + Variants without a native PHY (see ``NO_WIFI_VARIANTS`` — currently + ESP32-H2, ESP32-H4, ESP32-H21, ESP32-P4) need the ``esp32_hosted`` + co-processor to use ``wifi:``. Case-insensitive on *variant* so external callers can pass either the upstream uppercase form (e.g. ``"ESP32H2"`` from From abbcfd213fa66093a680fc955dfc2d4050e1ae28 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:51:03 -0400 Subject: [PATCH 1816/2214] [tinyusb][usb_cdc_acm][usb_host][usb_uart] Support ESP32-S31/H4 (#17190) --- esphome/components/tinyusb/__init__.py | 15 ++++++++++++--- esphome/components/tinyusb/tinyusb_component.cpp | 6 ++++-- esphome/components/tinyusb/tinyusb_component.h | 6 ++++-- esphome/components/usb_cdc_acm/__init__.py | 10 +++++++++- esphome/components/usb_cdc_acm/usb_cdc_acm.cpp | 3 ++- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 3 ++- .../components/usb_cdc_acm/usb_cdc_acm_esp32.cpp | 3 ++- esphome/components/usb_host/__init__.py | 14 ++++++++++++-- esphome/components/usb_host/usb_host.h | 6 ++++-- esphome/components/usb_host/usb_host_client.cpp | 6 ++++-- .../components/usb_host/usb_host_component.cpp | 6 ++++-- esphome/components/usb_uart/ch34x.cpp | 6 ++++-- esphome/components/usb_uart/cp210x.cpp | 6 ++++-- esphome/components/usb_uart/ft23xx.cpp | 6 ++++-- esphome/components/usb_uart/pl2303.cpp | 6 ++++-- esphome/components/usb_uart/usb_uart.cpp | 6 ++++-- esphome/components/usb_uart/usb_uart.h | 6 ++++-- esphome/idf_component.yml | 8 ++++---- 18 files changed, 87 insertions(+), 35 deletions(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 0e02ff8724..9e1ad3afc4 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -2,9 +2,11 @@ from esphome import final_validate as fv import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, ) @@ -44,7 +46,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( - supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], ), ) @@ -64,7 +72,8 @@ def _final_validate(config): "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " "because both share the USB OTG peripheral. Set " "'logger.hardware_uart' to a hardware UART (e.g. UART0), or to " - "USB_SERIAL_JTAG on variants that support it (ESP32-S3, ESP32-P4)" + "USB_SERIAL_JTAG on variants that support it " + "(ESP32-S3, ESP32-S31, ESP32-P4, ESP32-H4)" ) return config @@ -85,7 +94,7 @@ async def to_code(config): if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) - add_idf_component(name="espressif/esp_tinyusb", ref="2.1.1") + add_idf_component(name="espressif/esp_tinyusb", ref="2.2.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 567a84f8c3..b748959571 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "tinyusb_component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -61,4 +62,5 @@ void TinyUSB::dump_config() { } } // namespace esphome::tinyusb -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 56c33a708f..7ec3da118c 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,5 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "tinyusb.h" @@ -69,4 +70,5 @@ class TinyUSB : public Component { }; } // namespace esphome::tinyusb -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index bfe177a4da..8cd078ab49 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -1,9 +1,11 @@ import esphome.codegen as cg from esphome.components import esp32, uart from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, ) import esphome.config_validation as cv @@ -48,7 +50,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( - supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], ), ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index 40f7f2e28b..454c049da3 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_cdc_acm.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 89405ab893..10692fd436 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -1,5 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "esphome/core/event_pool.h" diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 592207efa8..859d6cbaea 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_cdc_acm.h" #include "esphome/core/application.h" #include "esphome/core/log.h" diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 8e591bd80c..70425c27ca 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,8 +1,10 @@ import esphome.codegen as cg from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, idf_version, @@ -70,7 +72,15 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DEVICES): cv.ensure_list(usb_device_schema()), } ), - only_on_variant(supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3]), + only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ] + ), _set_max_packet_size, ) @@ -84,7 +94,7 @@ async def register_usb_client(config): async def to_code(config: ConfigType) -> None: # IDF 6.0 moved USB host to an external component if idf_version() >= cv.Version(6, 0, 0): - add_idf_component(name="espressif/usb", ref="1.3.0") + add_idf_component(name="espressif/usb", ref="1.4.1") add_idf_sdkconfig_option("CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE", 1024) if config.get(CONF_ENABLE_HUBS): add_idf_sdkconfig_option("CONFIG_USB_HOST_HUBS_SUPPORTED", True) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index a9f07a5422..57640e8691 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -1,7 +1,8 @@ #pragma once // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/defines.h" #include "esphome/core/component.h" #include @@ -195,4 +196,5 @@ class USBHost : public Component { } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 45e2be17c7..7bc2b0a16b 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_host.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -581,4 +582,5 @@ void USBClient::release_trq(TransferRequest *trq) { } } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 8ce0a70dc9..102311348a 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_host.h" #include #include "esphome/core/log.h" @@ -29,4 +30,5 @@ void USBHost::loop() { } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index c5f904ead1..e84384be5d 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -170,4 +171,5 @@ std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_ } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 67fd03a813..c4edaed038 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -122,4 +123,5 @@ void USBUartTypeCP210X::enable_channels() { } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index c2c8993805..3b0e05ba53 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -455,4 +456,5 @@ void USBUartTypeFT23XX::enable_channels() { } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index a50f1cf2d4..3685debef4 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -295,4 +296,5 @@ void USBUartTypePL2303::enable_channels() { } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 3fdf35a472..b8749b6a76 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "esphome/core/log.h" #include "esphome/core/application.h" @@ -541,4 +542,5 @@ void USBUartTypeCdcAcm::start_channels_() { } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index d0dccf42b9..c4fb77bdb2 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -1,6 +1,7 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" @@ -286,4 +287,5 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm { } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f8f3df57cd..81c16f2e38 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -84,9 +84,9 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/esp_tinyusb: - version: "2.1.1" + version: "2.2.1" rules: - - if: "target in [esp32s2, esp32s3, esp32p4]" + - if: "target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esphome/esp-hub75: version: 0.3.5 rules: @@ -96,9 +96,9 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/usb: - version: "1.3.0" + version: "1.4.1" rules: - - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]" + - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: From 1dfafce06a55d87e5d83ab326f0e48e6f3aa4b09 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:05:29 -0400 Subject: [PATCH 1817/2214] [i2c][spi] Wire ESP32-S31/H4/H21 bus capabilities (#17188) --- esphome/components/esp32/gpio_esp32_s31.py | 17 +++++++++++++++-- esphome/components/i2c/__init__.py | 8 ++++++++ esphome/components/spi/__init__.py | 2 ++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py index 6a19e3fee4..d49240723b 100644 --- a/esphome/components/esp32/gpio_esp32_s31.py +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -2,13 +2,16 @@ import logging from typing import Any import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin # Per the ESP32-S31 datasheet (page 96): # https://documentation.espressif.com/esp32-s31_datasheet_en.pdf _ESP32S31_SPI_FLASH_PINS: set[int] = {27, 28, 29, 31, 32, 33} -_ESP32S31_STRAPPING_PINS: set[int] = {60, 61} +# GPIO60/GPIO61 set the boot mode; GPIO37 selects the JTAG signal source. +_ESP32S31_STRAPPING_PINS: set[int] = {37, 60, 61} +# LP I2C is fixed to GPIO6 (SCL) / GPIO7 (SDA) per the datasheet IO MUX table. +_ESP32S31_I2C_LP_PINS = {"SDA": 7, "SCL": 6} _LOGGER = logging.getLogger(__name__) @@ -36,3 +39,13 @@ def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER) return value + + +def esp32_s31_validate_lp_i2c(value): + lp_sda_pin = _ESP32S31_I2C_LP_PINS["SDA"] + lp_scl_pin = _ESP32S31_I2C_LP_PINS["SCL"] + if int(value[CONF_SDA]) != lp_sda_pin or int(value[CONF_SCL]) != lp_scl_pin: + raise cv.Invalid( + f"Low power i2c interface is only supported on GPIO{lp_sda_pin} SDA and GPIO{lp_scl_pin} SCL for ESP32-S31" + ) + return value diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index d9dd6d5ee2..eec2211a96 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -13,14 +13,18 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, get_esp32_variant, ) from esphome.components.esp32.gpio_esp32_c5 import esp32_c5_validate_lp_i2c from esphome.components.esp32.gpio_esp32_c6 import esp32_c6_validate_lp_i2c from esphome.components.esp32.gpio_esp32_p4 import esp32_p4_validate_lp_i2c +from esphome.components.esp32.gpio_esp32_s31 import esp32_s31_validate_lp_i2c from esphome.components.zephyr import ( zephyr_add_overlay, zephyr_add_prj_conf, @@ -72,14 +76,18 @@ ESP32_I2C_CAPABILITIES = { VARIANT_ESP32C6: {"NUM": 2, "HP": 1, "LP": 1}, VARIANT_ESP32C61: {"NUM": 1, "HP": 1}, VARIANT_ESP32H2: {"NUM": 2, "HP": 2}, + VARIANT_ESP32H4: {"NUM": 2, "HP": 2}, + VARIANT_ESP32H21: {"NUM": 2, "HP": 2}, VARIANT_ESP32P4: {"NUM": 3, "HP": 2, "LP": 1}, VARIANT_ESP32S2: {"NUM": 2, "HP": 2}, VARIANT_ESP32S3: {"NUM": 2, "HP": 2}, + VARIANT_ESP32S31: {"NUM": 3, "HP": 2, "LP": 1}, } VALIDATE_LP_I2C = { VARIANT_ESP32C5: esp32_c5_validate_lp_i2c, VARIANT_ESP32C6: esp32_c6_validate_lp_i2c, VARIANT_ESP32P4: esp32_p4_validate_lp_i2c, + VARIANT_ESP32S31: esp32_s31_validate_lp_i2c, } LP_I2C_VARIANT = list(VALIDATE_LP_I2C.keys()) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 33ccfbb5ee..d1961cec59 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -11,6 +11,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -174,6 +175,7 @@ def get_hw_interface_list(): VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H21, ]: return [["spi", "spi2"]] return [["spi", "spi2"], ["spi3"]] From 92554f4e67176e950ff98e5af16b95bc2ff920fe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:28:06 -0400 Subject: [PATCH 1818/2214] [network] Set IPv4 type tag on all lwIP platforms, not just esp32 (#17200) --- esphome/components/network/ip_address.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 55bb2a1c89..d8a127f4a0 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -119,7 +119,7 @@ struct IPAddress { IPAddress(const std::string &in_address) { ipaddr_aton(in_address.c_str(), &ip_addr_); } IPAddress(ip4_addr_t *other_ip) { memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip4_addr_t)); -#if USE_ESP32 && LWIP_IPV6 +#if LWIP_IPV6 ip_addr_.type = IPADDR_TYPE_V4; #endif } From 8c9f4fba8fdeb5a942f764e14f0e0194ec8a42fd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:28:15 -0400 Subject: [PATCH 1819/2214] [wifi] Report STA IP, not SoftAP IP, in wifi_info on ESP8266 (#17185) --- esphome/components/wifi/wifi_component_esp8266.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 717d542fbe..84b864c0c5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -218,9 +218,18 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { return {}; network::IPAddresses addresses; uint8_t index = 0; + // addrList enumerates all lwIP netifs, including the SoftAP / fallback hotspot. Filter out + // the AP address so the STA address is reported as the device IP (see issue #17181). + struct ip_info ap_ip {}; + wifi_get_ip_info(SOFTAP_IF, &ap_ip); + network::IPAddress ap_address(&ap_ip.ip); + bool filter_ap = ap_address.is_set(); for (auto &addr : addrList) { + network::IPAddress ip(addr.ipFromNetifNum()); + if (filter_ap && ip == ap_address) + continue; assert(index < addresses.size()); - addresses[index++] = addr.ipFromNetifNum(); + addresses[index++] = ip; } return addresses; } From 23933c1b58065bd600f9159f2a8f29728b19b976 Mon Sep 17 00:00:00 2001 From: Julian Lunz <117189+jlunz@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:21:41 +0200 Subject: [PATCH 1820/2214] [adc] Only call cyw43_thread_enter/exit for VSYS when WiFi is active on RP2040 (#17203) --- esphome/components/adc/adc_sensor_rp2040.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 8d41edb814..894c346588 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -66,15 +66,18 @@ float ADCSensor::sample() { } uint8_t pin = this->pin_->get_pin(); -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { // Measuring VSYS on Raspberry Pico W needs to be wrapped with // `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in // https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and - // VSYS ADC both share GPIO29 + // VSYS ADC both share GPIO29. + // The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined + // transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43 + // driver is never initialized; calling cyw43_thread_enter() there hard-faults. cyw43_thread_enter(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) adc_gpio_init(pin); adc_select_input(pin - 26); @@ -84,11 +87,11 @@ float ADCSensor::sample() { aggr.add_sample(raw); } -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { cyw43_thread_exit(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (this->output_raw_) { return aggr.aggregate(); From d8eee03556dcd6e61e014f6ae63ca6ae537d8877 Mon Sep 17 00:00:00 2001 From: Fae Date: Thu, 25 Jun 2026 11:20:15 +0100 Subject: [PATCH 1821/2214] [host] Fix handling of directory for preferences (#11160) --- esphome/components/host/preference_backend.h | 4 +- esphome/components/host/preferences.cpp | 52 ++++-- esphome/components/host/preferences.h | 8 +- tests/components/host/preferences_test.cpp | 174 +++++++++++++++++++ 4 files changed, 214 insertions(+), 24 deletions(-) create mode 100644 tests/components/host/preferences_test.cpp diff --git a/esphome/components/host/preference_backend.h b/esphome/components/host/preference_backend.h index 68537cad28..ab1443ed49 100644 --- a/esphome/components/host/preference_backend.h +++ b/esphome/components/host/preference_backend.h @@ -10,8 +10,8 @@ class HostPreferenceBackend final { public: explicit HostPreferenceBackend(uint32_t key) : key_(key) {} - bool save(const uint8_t *data, size_t len); - bool load(uint8_t *data, size_t len); + bool save(const uint8_t *data, size_t len) const; + bool load(uint8_t *data, size_t len) const; protected: uint32_t key_{}; diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index c0be270062..497b9d11e5 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -14,21 +14,31 @@ static const char *const TAG = "preferences"; void HostPreferences::setup_() { if (this->setup_complete_) return; - const char *home = getenv("HOME"); - if (home == nullptr) { - ESP_LOGE(TAG, "HOME environment variable is not set"); - abort(); + const char *prefdir = getenv("ESPHOME_PREFDIR"); + std::string pref_path; + if (prefdir != nullptr) { + pref_path = prefdir; + } else { + const char *home = getenv("HOME"); + if (home == nullptr) { + ESP_LOGE(TAG, "ESPHOME_PREFDIR and HOME environment variables not set, unable to save preferences"); + return; + } + pref_path = std::string(home) + "/.esphome/prefs"; } - this->filename_.append(home); - this->filename_.append("/.esphome"); - this->filename_.append("/prefs"); - fs::create_directories(this->filename_); + std::error_code ec; + fs::create_directories(pref_path, ec); + if (ec) { + ESP_LOGE(TAG, "Failed to create preferences directory: %s (%s)", pref_path.c_str(), ec.message().c_str()); + return; + } + this->filename_ = pref_path; this->filename_.append("/"); this->filename_.append(App.get_name()); this->filename_.append(".prefs"); FILE *fp = fopen(this->filename_.c_str(), "rb"); if (fp != nullptr) { - while (!feof((fp))) { + while (!feof(fp)) { uint32_t key; uint8_t len; if (fread(&key, sizeof(key), 1, fp) != 1) @@ -39,7 +49,7 @@ void HostPreferences::setup_() { if (fread(data, sizeof(uint8_t), len, fp) != len) break; std::vector vec(data, data + len); - this->data[key] = vec; + this->data_[key] = vec; } fclose(fp); } @@ -48,29 +58,33 @@ void HostPreferences::setup_() { bool HostPreferences::sync() { this->setup_(); + if (this->filename_.empty()) { + ESP_LOGE(TAG, "Preferences filename not set, unable to save preferences"); + return false; + } FILE *fp = fopen(this->filename_.c_str(), "wb"); if (fp == nullptr) { ESP_LOGE(TAG, "Failed to open preferences file for writing: %s", this->filename_.c_str()); return false; } - for (auto it = this->data.begin(); it != this->data.end(); ++it) { - fwrite(&it->first, sizeof(uint32_t), 1, fp); - uint8_t len = it->second.size(); + for (auto &it : this->data_) { + fwrite(&it.first, sizeof(uint32_t), 1, fp); + uint8_t len = it.second.size(); fwrite(&len, sizeof(len), 1, fp); - fwrite(it->second.data(), sizeof(uint8_t), it->second.size(), fp); + fwrite(it.second.data(), sizeof(uint8_t), it.second.size(), fp); } fclose(fp); return true; } bool HostPreferences::reset() { - host_preferences->data.clear(); + host_preferences->data_.clear(); return true; } ESPPreferenceObject HostPreferences::make_preference(size_t length, uint32_t type, bool in_flash) { - auto backend = new HostPreferenceBackend(type); + auto *backend = new HostPreferenceBackend(type); return ESPPreferenceObject(backend); }; @@ -83,11 +97,13 @@ void setup_preferences() { global_preferences = &s_preferences; } -bool HostPreferenceBackend::save(const uint8_t *data, size_t len) { +bool HostPreferenceBackend::save(const uint8_t *data, size_t len) const { return host_preferences->save(this->key_, data, len); } -bool HostPreferenceBackend::load(uint8_t *data, size_t len) { return host_preferences->load(this->key_, data, len); } +bool HostPreferenceBackend::load(uint8_t *data, size_t len) const { + return host_preferences->load(this->key_, data, len); +} HostPreferences *host_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/host/preferences.h b/esphome/components/host/preferences.h index 25858799ff..5f723e0675 100644 --- a/esphome/components/host/preferences.h +++ b/esphome/components/host/preferences.h @@ -23,7 +23,7 @@ class HostPreferences final : public PreferencesMixin { return false; this->setup_(); std::vector vec(data, data + len); - this->data[key] = vec; + this->data_[key] = vec; return true; } @@ -31,8 +31,8 @@ class HostPreferences final : public PreferencesMixin { if (len > 255) return false; this->setup_(); - auto it = this->data.find(key); - if (it == this->data.end()) + auto it = this->data_.find(key); + if (it == this->data_.end()) return false; const auto &vec = it->second; if (vec.size() != len) @@ -45,7 +45,7 @@ class HostPreferences final : public PreferencesMixin { void setup_(); bool setup_complete_{}; std::string filename_{}; - std::map> data{}; + std::map> data_{}; }; void setup_preferences(); diff --git a/tests/components/host/preferences_test.cpp b/tests/components/host/preferences_test.cpp new file mode 100644 index 0000000000..8e79db04f5 --- /dev/null +++ b/tests/components/host/preferences_test.cpp @@ -0,0 +1,174 @@ +#ifdef USE_HOST +#include +#include +#include +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" + +namespace esphome::host::testing { +namespace fs = std::filesystem; + +/// RAII helper to save and restore an environment variable. +class ScopedEnvVar { + public: + explicit ScopedEnvVar(const char *name) : name_(name) { + const char *val = getenv(name); + if (val != nullptr) { + saved_value_ = val; + was_set_ = true; + } + } + ~ScopedEnvVar() { + if (this->was_set_) { + setenv(this->name_.c_str(), this->saved_value_.c_str(), 1); + } else { + unsetenv(this->name_.c_str()); + } + } + ScopedEnvVar(const ScopedEnvVar &) = delete; + ScopedEnvVar &operator=(const ScopedEnvVar &) = delete; + + private: + std::string name_; + std::string saved_value_; + bool was_set_{false}; +}; + +class HostPreferencesTest : public ::testing::Test { + protected: + void SetUp() override { + // Create a unique temp directory for this test + this->temp_dir_ = fs::temp_directory_path() / "esphome_prefs_test"; + fs::create_directories(this->temp_dir_); + + // Set up App name — string literal has static storage so StringRef is safe + App.pre_setup("test_prefs", 10, "", 0); + } + + void TearDown() override { + std::error_code ec; + fs::remove_all(this->temp_dir_, ec); + } + + fs::path temp_dir_; +}; + +TEST_F(HostPreferencesTest, BothVarsUnset_SyncReturnsFalse) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + unsetenv("HOME"); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + EXPECT_FALSE(prefs.sync()); +} + +TEST_F(HostPreferencesTest, BothVarsUnset_SaveSucceedsInMemory) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + unsetenv("HOME"); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + uint32_t value = 42; + // save() stores in memory even without a valid file path + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + + // But sync to disk should fail + EXPECT_FALSE(prefs.sync()); +} + +TEST_F(HostPreferencesTest, PrefDirSet_SaveAndSync) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "prefdir"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + unsetenv("HOME"); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // Verify file was created in ESPHOME_PREFDIR + auto expected_file = prefdir / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(expected_file)); +} + +TEST_F(HostPreferencesTest, HomeSet_SaveAndSync) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto home = this->temp_dir_ / "home"; + setenv("HOME", home.c_str(), 1); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // Verify file was created in HOME/.esphome/prefs + auto expected_file = home / ".esphome" / "prefs" / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(expected_file)); +} + +TEST_F(HostPreferencesTest, PrefDirTakesPrecedenceOverHome) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "prefdir"; + auto home = this->temp_dir_ / "home"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + setenv("HOME", home.c_str(), 1); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // File should be in ESPHOME_PREFDIR, not HOME + auto prefdir_file = prefdir / "test_prefs.prefs"; + auto home_file = home / ".esphome" / "prefs" / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(prefdir_file)); + EXPECT_FALSE(fs::exists(home_file)); +} + +TEST_F(HostPreferencesTest, SaveAndLoadRoundTrip) { + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "roundtrip"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + + // Save data with one instance + { + HostPreferences prefs; + uint32_t value = 0xDEADBEEF; + EXPECT_TRUE(prefs.save(0xABCD, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + } + + // Load with a fresh instance (reads from file) + { + HostPreferences prefs; + uint32_t loaded = 0; + EXPECT_TRUE(prefs.load(0xABCD, reinterpret_cast(&loaded), sizeof(loaded))); + EXPECT_EQ(loaded, 0xDEADBEEFu); + } +} + +TEST_F(HostPreferencesTest, LoadNonExistentKeyReturnsFalse) { + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "nokey"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + + HostPreferences prefs; + uint32_t loaded = 0; + EXPECT_FALSE(prefs.load(0x9999, reinterpret_cast(&loaded), sizeof(loaded))); +} + +} // namespace esphome::host::testing + +#endif From 8c68e9556872b85010e2622033609f376cd56225 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:33:28 +1200 Subject: [PATCH 1822/2214] [config_validation] Add tests for 100% validator coverage (#17204) --- tests/unit_tests/test_config_validation.py | 1908 +++++++++++++++++--- 1 file changed, 1694 insertions(+), 214 deletions(-) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 9b9f003b0d..2715f9c644 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +from pathlib import Path import string from hypothesis import example, given, settings @@ -5,7 +6,7 @@ from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol -from esphome import config_validation +from esphome import config_validation as cv from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, @@ -17,6 +18,22 @@ from esphome.components.esp32 import ( ) from esphome.config_validation import Invalid from esphome.const import ( + CONF_DAY, + CONF_HOUR, + CONF_ID, + CONF_INTERNAL, + CONF_MINUTE, + CONF_MONTH, + CONF_NAME, + CONF_REF, + CONF_SECOND, + CONF_TYPE, + CONF_VALUE, + CONF_YEAR, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -25,19 +42,35 @@ from esphome.const import ( PLATFORM_RP2040, PLATFORM_RTL87XX, SCHEDULER_DONT_RUN, + TYPE_GIT, + TYPE_LOCAL, + Framework, ) -from esphome.core import CORE, HexInt, Lambda -from esphome.yaml_util import SensitiveStr +from esphome.core import ( + CORE, + ID, + HexInt, + Lambda, + MACAddress, + TimePeriod, + TimePeriodMicroseconds, + TimePeriodMinutes, + TimePeriodNanoseconds, + TimePeriodSeconds, +) +from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.util import Registry +from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base def test_check_not_templatable__invalid(): with pytest.raises(Invalid, match="This option is not templatable!"): - config_validation.check_not_templatable(Lambda("")) + cv.check_not_templatable(Lambda("")) @pytest.mark.parametrize("value", ("foo", 1, "D12", False)) def test_alphanumeric__valid(value): - actual = config_validation.alphanumeric(value) + actual = cv.alphanumeric(value) assert actual == str(value) @@ -45,12 +78,12 @@ def test_alphanumeric__valid(value): @pytest.mark.parametrize("value", ("£23", "Foo!")) def test_alphanumeric__invalid(value): with pytest.raises(Invalid): - config_validation.alphanumeric(value) + cv.alphanumeric(value) @given(value=text(alphabet=string.ascii_lowercase + string.digits + "-_")) def test_valid_name__valid(value): - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) assert actual == value @@ -58,29 +91,29 @@ def test_valid_name__valid(value): @pytest.mark.parametrize("value", ("foo bar", "FooBar", "foo::bar")) def test_valid_name__invalid(value): with pytest.raises(Invalid): - config_validation.valid_name(value) + cv.valid_name(value) @pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name")) def test_valid_name__substitution_valid(value): CORE.vscode = True - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) assert actual == value CORE.vscode = False with pytest.raises(Invalid): - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) @pytest.mark.parametrize("value", ("{NAME}", "${A NAME}")) def test_valid_name__substitution_like_invalid(value): with pytest.raises(Invalid): - config_validation.valid_name(value) + cv.valid_name(value) @pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99")) def test_validate_id_name__valid(value): - actual = config_validation.validate_id_name(value) + actual = cv.validate_id_name(value) assert actual == value @@ -88,23 +121,23 @@ def test_validate_id_name__valid(value): @pytest.mark.parametrize("value", ("id of mine", "id-4", "{name_id}", "id::name")) def test_validate_id_name__invalid(value): with pytest.raises(Invalid): - config_validation.validate_id_name(value) + cv.validate_id_name(value) @pytest.mark.parametrize("value", ("${id}", "${ID}", "${ID}_test_1", "$MYID")) def test_validate_id_name__substitution_valid(value): CORE.vscode = True - actual = config_validation.validate_id_name(value) + actual = cv.validate_id_name(value) assert actual == value CORE.vscode = False with pytest.raises(Invalid): - config_validation.validate_id_name(value) + cv.validate_id_name(value) @given(one_of(integers(), text())) def test_string__valid(value): - actual = config_validation.string(value) + actual = cv.string(value) assert actual == str(value) @@ -112,12 +145,12 @@ def test_string__valid(value): @pytest.mark.parametrize("value", ({}, [], True, False, None)) def test_string__invalid(value): with pytest.raises(Invalid): - config_validation.string(value) + cv.string(value) @given(text()) def test_strict_string__valid(value): - actual = config_validation.string_strict(value) + actual = cv.string_strict(value) assert actual == value @@ -125,29 +158,29 @@ def test_strict_string__valid(value): @pytest.mark.parametrize("value", (None, 123)) def test_string_string__invalid(value): with pytest.raises(Invalid, match="Must be string, got"): - config_validation.string_strict(value) + cv.string_strict(value) def test_sensitive__default_delegates_to_string() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() - assert isinstance(validator, config_validation.SensitiveValidator) - assert validator.inner is config_validation.string + assert isinstance(validator, cv.SensitiveValidator) + assert validator.inner is cv.string assert validator("hunter2") == "hunter2" assert validator(42) == "42" def test_sensitive__custom_inner_delegates_validation() -> None: - validator = config_validation.sensitive(config_validation.string_strict) + validator = cv.sensitive(cv.string_strict) - assert validator.inner is config_validation.string_strict + assert validator.inner is cv.string_strict assert validator("abc") == "abc" with pytest.raises(Invalid, match="Must be string, got"): validator(123) def test_sensitive__wraps_string_result_in_sensitive_str() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() result = validator("hunter2") assert isinstance(result, SensitiveStr) @@ -164,7 +197,7 @@ def test_sensitive__does_not_double_tag_already_sensitive() -> None: def inner(_value): return pre_tagged - validator = config_validation.sensitive(inner) + validator = cv.sensitive(inner) result = validator("anything") assert result is pre_tagged @@ -178,22 +211,22 @@ def test_sensitive__non_string_result_passes_through() -> None: def inner(_value): return sentinel - validator = config_validation.sensitive(inner) + validator = cv.sensitive(inner) assert validator("anything") is sentinel def test_sensitive__is_detectable_via_isinstance() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() - assert isinstance(validator, config_validation.SensitiveValidator) + assert isinstance(validator, cv.SensitiveValidator) def test_bind_key__bare_usage_validates_and_is_sensitive() -> None: # Used bare (cv.bind_key) it is itself a sensitive validator: detectable for # frontend masking and validating a value directly tags the result. - assert isinstance(config_validation.bind_key, config_validation.SensitiveValidator) + assert isinstance(cv.bind_key, cv.SensitiveValidator) - result = config_validation.bind_key("0123456789ABCDEF0123456789ABCDEF") + result = cv.bind_key("0123456789ABCDEF0123456789ABCDEF") assert isinstance(result, SensitiveStr) assert result == "0123456789ABCDEF0123456789ABCDEF" @@ -202,9 +235,7 @@ def test_bind_key__bare_usage_validates_and_is_sensitive() -> None: def test_bind_key__bare_usage_in_schema() -> None: # Voluptuous calls the bare validator with the config value; the result must # come through tagged sensitive. - schema = config_validation.Schema( - {config_validation.Required("key"): config_validation.bind_key} - ) + schema = cv.Schema({cv.Required("key"): cv.bind_key}) out = schema({"key": "0123456789ABCDEF0123456789ABCDEF"}) assert isinstance(out["key"], SensitiveStr) @@ -213,10 +244,10 @@ def test_bind_key__bare_usage_in_schema() -> None: def test_bind_key__factory_returns_sensitive_validator() -> None: # Called with a name (cv.bind_key(name=...)) it returns a new sensitive # validator rather than validating. - validator = config_validation.bind_key(name="Decryption key") + validator = cv.bind_key(name="Decryption key") - assert isinstance(validator, config_validation.SensitiveValidator) - assert validator is not config_validation.bind_key + assert isinstance(validator, cv.SensitiveValidator) + assert validator is not cv.bind_key assert isinstance(validator("0123456789ABCDEF0123456789ABCDEF"), SensitiveStr) @@ -229,7 +260,7 @@ def test_bind_key__factory_returns_sensitive_validator() -> None: ) def test_bind_key__custom_name_in_error(value: str, error: str) -> None: # The ``name`` argument (used by dsmr/dlms_meter) customizes error messages. - validator = config_validation.bind_key(name="Decryption key") + validator = cv.bind_key(name="Decryption key") with pytest.raises(Invalid, match=error): validator(value) @@ -238,25 +269,23 @@ def test_bind_key__rejects_non_hex_pair_length() -> None: # Odd-length input yields a trailing single-char part, hitting the # "format XX" branch rather than the hex-value branch. with pytest.raises(Invalid, match="Bind key must be format XX"): - config_validation.bind_key("0123456789ABCDEF0123456789ABCDE") + cv.bind_key("0123456789ABCDEF0123456789ABCDE") def test_bind_key__direct_call_with_name_validates_with_that_name() -> None: # Passing both a value and a name validates immediately using the custom # name for error wording, and still tags the result sensitive. - result = config_validation.bind_key( - "0123456789ABCDEF0123456789ABCDEF", name="Decryption key" - ) + result = cv.bind_key("0123456789ABCDEF0123456789ABCDEF", name="Decryption key") assert isinstance(result, SensitiveStr) with pytest.raises(Invalid, match="Decryption key must consist of"): - config_validation.bind_key("00", name="Decryption key") + cv.bind_key("00", name="Decryption key") def test_bind_key__factory_without_name_keeps_existing_name() -> None: # Re-invoking a named validator without a name preserves its name rather # than resetting to the default. - named = config_validation.bind_key(name="Decryption key") + named = cv.bind_key(name="Decryption key") rederived = named() with pytest.raises(Invalid, match="Decryption key must consist of"): @@ -267,11 +296,8 @@ def test_bind_key__repr_is_name_keyed_and_non_recursive() -> None: # ``self.inner`` is a bound method of the instance, so the inherited # ``repr(self.inner)`` would recurse infinitely; the override keeps repr # finite and keyed on the name for schema-dump dedup. - assert repr(config_validation.bind_key) == "bind_key('Bind key')" - assert ( - repr(config_validation.bind_key(name="Decryption key")) - == "bind_key('Decryption key')" - ) + assert repr(cv.bind_key) == "bind_key('Bind key')" + assert repr(cv.bind_key(name="Decryption key")) == "bind_key('Decryption key')" def test_sensitive__repr_mirrors_inner() -> None: @@ -279,18 +305,14 @@ def test_sensitive__repr_mirrors_inner() -> None: # validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers # interchangeable for that purpose and avoids leaking the wrapper as # noise in voluptuous error messages. - assert repr(config_validation.sensitive(config_validation.string)) == repr( - config_validation.string - ) - assert repr(config_validation.sensitive(config_validation.string)) == repr( - config_validation.sensitive(config_validation.string) - ) + assert repr(cv.sensitive(cv.string)) == repr(cv.string) + assert repr(cv.sensitive(cv.string)) == repr(cv.sensitive(cv.string)) def test_sensitive_key_fragments__covers_common_terms() -> None: - assert isinstance(config_validation.SENSITIVE_KEY_FRAGMENTS, frozenset) + assert isinstance(cv.SENSITIVE_KEY_FRAGMENTS, frozenset) for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"): - assert term in config_validation.SENSITIVE_KEY_FRAGMENTS + assert term in cv.SENSITIVE_KEY_FRAGMENTS @given( @@ -305,31 +327,31 @@ def test_sensitive_key_fragments__covers_common_terms() -> None: ) @example("") def test_icon__valid(value): - actual = config_validation.icon(value) + actual = cv.icon(value) assert actual == value def test_icon__invalid(): with pytest.raises(Invalid, match="Icons must match the format "): - config_validation.icon("foo") + cv.icon("foo") def test_icon__max_length(): """Test that icons exceeding 63 bytes are rejected.""" # Exactly 63 bytes should pass max_icon = "mdi:" + "a" * 59 # 63 bytes total - assert config_validation.icon(max_icon) == max_icon + assert cv.icon(max_icon) == max_icon # 64 bytes should fail too_long = "mdi:" + "a" * 60 # 64 bytes total with pytest.raises(Invalid, match="Icon string is too long"): - config_validation.icon(too_long) + cv.icon(too_long) def test_byte_length() -> None: """Test ByteLength validator checks UTF-8 byte length, not char count.""" - validator = config_validation.ByteLength(max=10) # pylint: disable=no-member + validator = cv.ByteLength(max=10) # pylint: disable=no-member # ASCII: 10 chars = 10 bytes, should pass assert validator("a" * 10) == "a" * 10 @@ -348,18 +370,18 @@ def test_byte_length() -> None: @pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) def test_boolean__valid_true(value): - assert config_validation.boolean(value) is True + assert cv.boolean(value) is True @pytest.mark.parametrize("value", ("False", "NO", "off", "disAblE", False)) def test_boolean__valid_false(value): - assert config_validation.boolean(value) is False + assert cv.boolean(value) is False @pytest.mark.parametrize("value", (None, 1, 0, "foo")) def test_boolean__invalid(value): with pytest.raises(Invalid, match="Expected boolean value"): - config_validation.boolean(value) + cv.boolean(value) # deadline disabled: the validator is trivially fast, but Hypothesis's per-example @@ -368,31 +390,31 @@ def test_boolean__invalid(value): @settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_ipv4__valid(value): - config_validation.ipv4address(value) + cv.ipv4address(value) @pytest.mark.parametrize("value", ("127.0.0", "localhost", "")) def test_ipv4__invalid(value): with pytest.raises(Invalid, match="is not a valid IPv4 address"): - config_validation.ipv4address(value) + cv.ipv4address(value) @settings(deadline=None) @given(value=ip_addresses(v=6).map(str)) def test_ipv6__valid(value): - config_validation.ipaddress(value) + cv.ipaddress(value) @pytest.mark.parametrize("value", ("127.0.0", "localhost", "", "2001:db8::2::3")) def test_ipv6__invalid(value): with pytest.raises(Invalid, match="is not a valid IP address"): - config_validation.ipaddress(value) + cv.ipaddress(value) # TODO: ensure_list @given(integers()) def hex_int__valid(value): - actual = config_validation.hex_int(value) + actual = cv.hex_int(value) assert isinstance(actual, HexInt) assert actual == value @@ -472,18 +494,14 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "esp32_h2_idf": "19", } - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.SplitDefault( + cv.SplitDefault( "full", **common_mappings, **idf_mappings, **arduino_mappings ): str, - config_validation.SplitDefault( - "idf", **common_mappings, **idf_mappings - ): str, - config_validation.SplitDefault( - "arduino", **common_mappings, **arduino_mappings - ): str, - config_validation.SplitDefault("simple", **common_mappings): str, + cv.SplitDefault("idf", **common_mappings, **idf_mappings): str, + cv.SplitDefault("arduino", **common_mappings, **arduino_mappings): str, + cv.SplitDefault("simple", **common_mappings): str, } ) @@ -515,16 +533,16 @@ def test_require_framework_version(framework, platform, message): CORE.data[KEY_CORE] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = config_validation.Version(1, 0, 0) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(1, 0, 0) assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(0, 5, 0), + esp32_arduino=cv.Version(0, 5, 0), + esp8266_arduino=cv.Version(0, 5, 0), + rp2040_arduino=cv.Version(0, 5, 0), + bk72xx_arduino=cv.Version(0, 5, 0), + host=cv.Version(0, 5, 0), extra_message="test 1", )("test") == "test" @@ -534,24 +552,24 @@ def test_require_framework_version(framework, platform, message): vol.error.Invalid, match="This feature requires at least framework version 2.0.0. test 2", ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(2, 0, 0), - esp32_arduino=config_validation.Version(2, 0, 0), - esp8266_arduino=config_validation.Version(2, 0, 0), - rp2040_arduino=config_validation.Version(2, 0, 0), - bk72xx_arduino=config_validation.Version(2, 0, 0), - host=config_validation.Version(2, 0, 0), + cv.require_framework_version( + esp_idf=cv.Version(2, 0, 0), + esp32_arduino=cv.Version(2, 0, 0), + esp8266_arduino=cv.Version(2, 0, 0), + rp2040_arduino=cv.Version(2, 0, 0), + bk72xx_arduino=cv.Version(2, 0, 0), + host=cv.Version(2, 0, 0), extra_message="test 2", )("test") assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(1, 5, 0), - esp32_arduino=config_validation.Version(1, 5, 0), - esp8266_arduino=config_validation.Version(1, 5, 0), - rp2040_arduino=config_validation.Version(1, 5, 0), - bk72xx_arduino=config_validation.Version(1, 5, 0), - host=config_validation.Version(1, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(1, 5, 0), + esp32_arduino=cv.Version(1, 5, 0), + esp8266_arduino=cv.Version(1, 5, 0), + rp2040_arduino=cv.Version(1, 5, 0), + bk72xx_arduino=cv.Version(1, 5, 0), + host=cv.Version(1, 5, 0), max_version=True, extra_message="test 3", )("test") @@ -562,13 +580,13 @@ def test_require_framework_version(framework, platform, message): vol.error.Invalid, match="This feature requires framework version 0.5.0 or lower. test 4", ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(0, 5, 0), + esp32_arduino=cv.Version(0, 5, 0), + esp8266_arduino=cv.Version(0, 5, 0), + rp2040_arduino=cv.Version(0, 5, 0), + bk72xx_arduino=cv.Version(0, 5, 0), + host=cv.Version(0, 5, 0), max_version=True, extra_message="test 4", )("test") @@ -576,7 +594,7 @@ def test_require_framework_version(framework, platform, message): with pytest.raises( vol.error.Invalid, match=f"This feature is incompatible with {message}. test 5" ): - config_validation.require_framework_version( + cv.require_framework_version( extra_message="test 5", )("test") @@ -585,9 +603,9 @@ def test_only_with_single_component_loaded() -> None: """Test OnlyWith with single component when component is loaded.""" CORE.loaded_integrations = {"mqtt"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, } ) @@ -599,9 +617,9 @@ def test_only_with_single_component_not_loaded() -> None: """Test OnlyWith with single component when component is not loaded.""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, } ) @@ -613,11 +631,9 @@ def test_only_with_list_all_components_loaded() -> None: """Test OnlyWith with list when all components are loaded.""" CORE.loaded_integrations = {"zigbee", "nrf52"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -629,11 +645,9 @@ def test_only_with_list_partial_components_loaded() -> None: """Test OnlyWith with list when only some components are loaded.""" CORE.loaded_integrations = {"zigbee"} # Only zigbee, not nrf52 - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -645,11 +659,9 @@ def test_only_with_list_no_components_loaded() -> None: """Test OnlyWith with list when no components are loaded.""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -661,9 +673,9 @@ def test_only_with_list_multiple_components() -> None: """Test OnlyWith with list requiring three components.""" CORE.loaded_integrations = {"comp1", "comp2", "comp3"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( + cv.OnlyWith( "test_id", ["comp1", "comp2", "comp3"], default="test_value" ): str, } @@ -682,9 +694,9 @@ def test_only_with_empty_list() -> None: """Test OnlyWith with empty list (edge case).""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("test_id", [], default="test_value"): str, + cv.OnlyWith("test_id", [], default="test_value"): str, } ) @@ -697,9 +709,9 @@ def test_only_with_user_value_overrides_default() -> None: """Test OnlyWith respects user-provided values over defaults.""" CORE.loaded_integrations = {"mqtt"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="default_id"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="default_id"): str, } ) @@ -709,7 +721,7 @@ def test_only_with_user_value_overrides_default() -> None: @pytest.mark.parametrize("value", ("hello", "Hello World", "test_name", "温度")) def test_string_no_slash__valid(value: str) -> None: - actual = config_validation.string_no_slash(value) + actual = cv.string_no_slash(value) assert actual == value @@ -726,7 +738,7 @@ def test_string_no_slash__slash_replaced_with_warning( value: str, expected: str, caplog: pytest.LogCaptureFixture ) -> None: """Test that '/' is auto-replaced with fraction slash and warning is logged.""" - actual = config_validation.string_no_slash(value) + actual = cv.string_no_slash(value) assert actual == expected assert "reserved as a URL path separator" in caplog.text assert "will become an error in ESPHome 2026.7.0" in caplog.text @@ -735,16 +747,16 @@ def test_string_no_slash__slash_replaced_with_warning( def test_string_no_slash__long_string_allowed() -> None: # string_no_slash doesn't enforce length - use cv.Length() separately long_value = "x" * 200 - assert config_validation.string_no_slash(long_value) == long_value + assert cv.string_no_slash(long_value) == long_value def test_string_no_slash__empty() -> None: - assert config_validation.string_no_slash("") == "" + assert cv.string_no_slash("") == "" @pytest.mark.parametrize("value", ("Temperature", "Living Room Light", "温度传感器")) def test_validate_entity_name__valid(value: str) -> None: - actual = config_validation._validate_entity_name(value) + actual = cv._validate_entity_name(value) assert actual == value @@ -752,40 +764,40 @@ def test_validate_entity_name__slash_replaced_with_warning( caplog: pytest.LogCaptureFixture, ) -> None: """Test that '/' in entity names is auto-replaced with fraction slash.""" - actual = config_validation._validate_entity_name("has/slash") + actual = cv._validate_entity_name("has/slash") assert actual == "has⁄slash" assert "reserved as a URL path separator" in caplog.text def test_validate_entity_name__max_length() -> None: # 120 bytes should pass - assert config_validation._validate_entity_name("x" * 120) == "x" * 120 + assert cv._validate_entity_name("x" * 120) == "x" * 120 # 121 bytes should fail with pytest.raises(Invalid, match="too long.*121 bytes.*Maximum.*120"): - config_validation._validate_entity_name("x" * 121) + cv._validate_entity_name("x" * 121) def test_validate_entity_name__multibyte_byte_length() -> None: # 40 chars of 3-byte UTF-8 = 120 bytes, should pass - assert config_validation._validate_entity_name("温" * 40) == "温" * 40 + assert cv._validate_entity_name("温" * 40) == "温" * 40 # 41 chars of 3-byte UTF-8 = 123 bytes, should fail (over 120 byte limit) with pytest.raises(Invalid, match="too long.*123 bytes.*Maximum.*120"): - config_validation._validate_entity_name("温" * 41) + cv._validate_entity_name("温" * 41) def test_validate_entity_name__none_without_friendly_name() -> None: # When name is "None" and friendly_name is not set, it should fail CORE.friendly_name = None with pytest.raises(Invalid, match="friendly_name is not set"): - config_validation._validate_entity_name("None") + cv._validate_entity_name("None") def test_validate_entity_name__none_with_friendly_name() -> None: # When name is "None" but friendly_name is set, it should return None CORE.friendly_name = "My Device" - result = config_validation._validate_entity_name("None") + result = cv._validate_entity_name("None") assert result is None CORE.friendly_name = None # Reset @@ -808,7 +820,7 @@ def test_validate_entity_name__none_with_friendly_name() -> None: ), ) def test_percentage__valid(value: object, expected: float) -> None: - assert config_validation.percentage(value) == expected + assert cv.percentage(value) == expected @pytest.mark.parametrize( @@ -826,7 +838,7 @@ def test_percentage__valid(value: object, expected: float) -> None: ) def test_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.percentage(value) + cv.percentage(value) @pytest.mark.parametrize( @@ -845,7 +857,7 @@ def test_percentage__invalid(value: object) -> None: ), ) def test_possibly_negative_percentage__valid(value: object, expected: float) -> None: - assert config_validation.possibly_negative_percentage(value) == expected + assert cv.possibly_negative_percentage(value) == expected @pytest.mark.parametrize( @@ -861,7 +873,7 @@ def test_possibly_negative_percentage__valid(value: object, expected: float) -> ) def test_possibly_negative_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.possibly_negative_percentage(value) + cv.possibly_negative_percentage(value) @pytest.mark.parametrize( @@ -878,7 +890,7 @@ def test_possibly_negative_percentage__invalid(value: object) -> None: ), ) def test_unbounded_percentage__valid(value: object, expected: float) -> None: - assert config_validation.unbounded_percentage(value) == expected + assert cv.unbounded_percentage(value) == expected @pytest.mark.parametrize( @@ -893,7 +905,7 @@ def test_unbounded_percentage__valid(value: object, expected: float) -> None: ) def test_unbounded_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.unbounded_percentage(value) + cv.unbounded_percentage(value) @pytest.mark.parametrize( @@ -916,13 +928,13 @@ def test_unbounded_percentage__invalid(value: object) -> None: def test_unbounded_possibly_negative_percentage__valid( value: object, expected: float ) -> None: - assert config_validation.unbounded_possibly_negative_percentage(value) == expected + assert cv.unbounded_possibly_negative_percentage(value) == expected @pytest.mark.parametrize("value", ("foo", None)) def test_unbounded_possibly_negative_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.unbounded_possibly_negative_percentage(value) + cv.unbounded_possibly_negative_percentage(value) @pytest.mark.parametrize( @@ -934,9 +946,9 @@ def test_percentage_validators__raw_number_above_one_without_percent_sign( ) -> None: """Raw numeric values outside [-1, 1] must use a percent sign.""" with pytest.raises(Invalid, match="percent sign"): - config_validation.unbounded_percentage(value) + cv.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): - config_validation.unbounded_possibly_negative_percentage(value) + cv.unbounded_possibly_negative_percentage(value) def test_update_interval__coerces_zero_to_one_ms( @@ -947,7 +959,7 @@ def test_update_interval__coerces_zero_to_one_ms( existing configs compiling on upgrade while emitting a user-facing warning that directs them to set a non-zero value.""" with caplog.at_level("WARNING"): - result = config_validation.update_interval("0ms") + result = cv.update_interval("0ms") assert result.total_milliseconds == 1 assert "update_interval of 0ms is not supported" in caplog.text assert "1ms" in caplog.text @@ -955,14 +967,14 @@ def test_update_interval__coerces_zero_to_one_ms( def test_update_interval__preserves_nonzero_values() -> None: """Non-zero update_interval values must pass through unchanged.""" - assert config_validation.update_interval("1ms").total_milliseconds == 1 - assert config_validation.update_interval("50ms").total_milliseconds == 50 - assert config_validation.update_interval("60s").total_milliseconds == 60000 + assert cv.update_interval("1ms").total_milliseconds == 1 + assert cv.update_interval("50ms").total_milliseconds == 50 + assert cv.update_interval("60s").total_milliseconds == 60000 def test_update_interval__never_passes_through() -> None: """update_interval: never must still map to SCHEDULER_DONT_RUN.""" - result = config_validation.update_interval("never") + result = cv.update_interval("never") assert result.total_milliseconds == SCHEDULER_DONT_RUN @@ -978,24 +990,20 @@ def test_optional_default_visibility_is_none() -> None: access; absence (``None``) means "render on the editor's main form." """ - o = config_validation.Optional("foo") + o = cv.Optional("foo") assert o.visibility is None def test_optional_visibility_advanced() -> None: """``visibility=Visibility.ADVANCED`` is recorded on the marker.""" - o = config_validation.Optional( - "foo", visibility=config_validation.Visibility.ADVANCED - ) - assert o.visibility is config_validation.Visibility.ADVANCED + o = cv.Optional("foo", visibility=cv.Visibility.ADVANCED) + assert o.visibility is cv.Visibility.ADVANCED def test_optional_visibility_yaml_only() -> None: """``visibility=Visibility.YAML_ONLY`` is recorded on the marker.""" - o = config_validation.Optional( - "foo", visibility=config_validation.Visibility.YAML_ONLY - ) - assert o.visibility is config_validation.Visibility.YAML_ONLY + o = cv.Optional("foo", visibility=cv.Visibility.YAML_ONLY) + assert o.visibility is cv.Visibility.YAML_ONLY def test_visibility_str_values_match_dump_emission() -> None: @@ -1007,8 +1015,8 @@ def test_visibility_str_values_match_dump_emission() -> None: field — pinning the on-the-wire spelling here keeps the dump contract stable. """ - assert str(config_validation.Visibility.ADVANCED) == "advanced" - assert str(config_validation.Visibility.YAML_ONLY) == "yaml_only" + assert str(cv.Visibility.ADVANCED) == "advanced" + assert str(cv.Visibility.YAML_ONLY) == "yaml_only" def test_optional_visibility_does_not_affect_validation() -> None: @@ -1016,16 +1024,14 @@ def test_optional_visibility_does_not_affect_validation() -> None: validator behaves. A schema with ``visibility`` applied must accept and reject the same values it would without it. """ - plain = config_validation.Schema( - {config_validation.Optional("foo", default=42): config_validation.int_} - ) - flagged = config_validation.Schema( + plain = cv.Schema({cv.Optional("foo", default=42): cv.int_}) + flagged = cv.Schema( { - config_validation.Optional( + cv.Optional( "foo", default=42, - visibility=config_validation.Visibility.YAML_ONLY, - ): config_validation.int_ + visibility=cv.Visibility.YAML_ONLY, + ): cv.int_ } ) # Same accept / default-fill behavior. @@ -1040,7 +1046,7 @@ def test_optional_visibility_does_not_affect_validation() -> None: def test_required_default_visibility_is_none() -> None: """``Required`` mirrors ``Optional`` for the ``visibility`` kwarg.""" - r = config_validation.Required("foo") + r = cv.Required("foo") assert r.visibility is None @@ -1050,10 +1056,8 @@ def test_required_visibility_kwarg() -> None: Required fields rarely need the kwarg, but exposing it lets consumers apply uniform logic across key markers. """ - r = config_validation.Required( - "foo", visibility=config_validation.Visibility.ADVANCED - ) - assert r.visibility is config_validation.Visibility.ADVANCED + r = cv.Required("foo", visibility=cv.Visibility.ADVANCED) + assert r.visibility is cv.Visibility.ADVANCED def test_polling_component_schema_visibility_opt_in() -> None: @@ -1062,28 +1066,17 @@ def test_polling_component_schema_visibility_opt_in() -> None: Time platforms pass ``Visibility.ADVANCED``; sensors and other polling components leave it ``None`` and keep the un-flagged shape. """ - default = config_validation.polling_component_schema("15min") - advanced = config_validation.polling_component_schema( - "15min", visibility=config_validation.Visibility.ADVANCED - ) + default = cv.polling_component_schema("15min") + advanced = cv.polling_component_schema("15min", visibility=cv.Visibility.ADVANCED) default_keys = {str(k): k for k in default.schema} advanced_keys = {str(k): k for k in advanced.schema} assert default_keys["update_interval"].visibility is None - assert ( - advanced_keys["update_interval"].visibility - is config_validation.Visibility.ADVANCED - ) + assert advanced_keys["update_interval"].visibility is cv.Visibility.ADVANCED # The opt-in only touches update_interval — setup_priority # still inherits its YAML_ONLY visibility from COMPONENT_SCHEMA # in both shapes. - assert ( - default_keys["setup_priority"].visibility - is config_validation.Visibility.YAML_ONLY - ) - assert ( - advanced_keys["setup_priority"].visibility - is config_validation.Visibility.YAML_ONLY - ) + assert default_keys["setup_priority"].visibility is cv.Visibility.YAML_ONLY + assert advanced_keys["setup_priority"].visibility is cv.Visibility.YAML_ONLY def test_polling_component_schema_no_default_ignores_visibility() -> None: @@ -1096,11 +1089,9 @@ def test_polling_component_schema_no_default_ignores_visibility() -> None: required field. The helper accepts the kwarg unconditionally for caller ergonomics but doesn't honour it on this branch. """ - schema = config_validation.polling_component_schema( - None, visibility=config_validation.Visibility.ADVANCED - ) + schema = cv.polling_component_schema(None, visibility=cv.Visibility.ADVANCED) keys = {str(k): k for k in schema.schema} - assert isinstance(keys["update_interval"], config_validation.Required) + assert isinstance(keys["update_interval"], cv.Required) assert keys["update_interval"].visibility is None @@ -1123,28 +1114,1517 @@ def test_visibility_marker_is_per_field_no_mutation() -> None: detail this test deliberately doesn't pin, since it's a consumer concern). """ - inner_unset = config_validation.Optional("baz") - inner_yaml_only = config_validation.Optional( - "qux", visibility=config_validation.Visibility.YAML_ONLY - ) - parent = config_validation.Optional( - "foo", visibility=config_validation.Visibility.ADVANCED - ) + inner_unset = cv.Optional("baz") + inner_yaml_only = cv.Optional("qux", visibility=cv.Visibility.YAML_ONLY) + parent = cv.Optional("foo", visibility=cv.Visibility.ADVANCED) # Wire them into a nested schema — none of the markers' own # ``visibility`` should change as a result. - schema = config_validation.Schema( + schema = cv.Schema( { - parent: config_validation.Schema( + parent: cv.Schema( { - inner_unset: config_validation.int_, - inner_yaml_only: config_validation.string, + inner_unset: cv.int_, + inner_yaml_only: cv.string, } ) } ) assert schema # touch the schema so any deferred mutation runs - assert parent.visibility is config_validation.Visibility.ADVANCED + assert parent.visibility is cv.Visibility.ADVANCED assert inner_unset.visibility is None - assert inner_yaml_only.visibility is config_validation.Visibility.YAML_ONLY + assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY + + +def _wrap_str(value: str) -> ESPHomeDataBase: + """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" + return make_data_base(value) + + +def _set_core_target(platform: str, framework: str) -> None: + """Set CORE target platform/framework for validators that depend on them.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + } + + +def _set_framework_version(platform: str, framework: str, version: cv.Version) -> None: + """Set CORE target platform/framework and framework version.""" + _set_core_target(platform, framework) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = version + + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- + + +def test_version_str_with_extra() -> None: + assert str(cv.Version(1, 2, 3, "b1")) == "1.2.3-b1" + + +def test_version_str_without_extra() -> None: + assert str(cv.Version(1, 2, 3)) == "1.2.3" + + +def test_version_parse_valid() -> None: + version = cv.Version.parse("2024.5.1") + assert (version.major, version.minor, version.patch, version.extra) == ( + 2024, + 5, + 1, + "", + ) + + +def test_version_parse_with_extra() -> None: + version = cv.Version.parse("2024.5.1-dev20240101") + assert version.extra == "dev20240101" + + +def test_version_parse_invalid() -> None: + with pytest.raises(ValueError, match="Not a valid version number"): + cv.Version.parse("not.a.version") + + +def test_version_is_beta() -> None: + assert cv.Version.parse("2024.5.0b1").is_beta is True + assert cv.Version.parse("2024.5.0").is_beta is False + + +def test_version_is_dev() -> None: + assert cv.Version.parse("2024.5.0-dev").is_dev is True + assert cv.Version.parse("2024.5.0").is_dev is False + + +# --------------------------------------------------------------------------- +# alphanumeric / valid_name / validate_id_name +# --------------------------------------------------------------------------- + + +def test_alphanumeric_none() -> None: + with pytest.raises(Invalid, match="string value is None"): + cv.alphanumeric(None) + + +def test_valid_name_vscode_no_substitution() -> None: + CORE.vscode = True + assert cv.valid_name("plainname") == "plainname" + + +def test_validate_id_name_empty() -> None: + with pytest.raises(Invalid, match="ID must not be empty"): + cv.validate_id_name("") + + +def test_validate_id_name_digit_first() -> None: + with pytest.raises(Invalid, match="First character in ID cannot be a digit"): + cv.validate_id_name("1abc") + + +def test_validate_id_name_vscode_no_substitution() -> None: + CORE.vscode = True + assert cv.validate_id_name("validid") == "validid" + + +def test_validate_id_name_reserved() -> None: + with pytest.raises(Invalid, match="reserved internally"): + cv.validate_id_name("alarm") + + +def test_validate_id_name_integration_conflict() -> None: + CORE.loaded_integrations = {"mqtt"} + with pytest.raises( + Invalid, match="conflicts with the name of an esphome integration" + ): + cv.validate_id_name("mqtt") + + +# --------------------------------------------------------------------------- +# sub_device_id +# --------------------------------------------------------------------------- + + +def test_sub_device_id_schema_extract() -> None: + from esphome.core.config import Device + + assert cv.sub_device_id(SCHEMA_EXTRACT) is Device + + +def test_sub_device_id_empty() -> None: + assert cv.sub_device_id(None) is None + assert cv.sub_device_id("") is None + + +def test_sub_device_id_valid() -> None: + result = cv.sub_device_id("my_device") + assert isinstance(result, ID) + assert result.id == "my_device" + + +# --------------------------------------------------------------------------- +# boolean_false / ensure_list +# --------------------------------------------------------------------------- + + +def test_boolean_false_valid() -> None: + assert cv.boolean_false(False) is False + assert cv.boolean_false("no") is False + + +def test_boolean_false_invalid() -> None: + with pytest.raises(Invalid, match="Expected boolean value to be false"): + cv.boolean_false(True) + + +def test_ensure_list_none() -> None: + assert cv.ensure_list(cv.int_)(None) == [] + + +def test_ensure_list_empty_dict() -> None: + assert cv.ensure_list(cv.int_)({}) == [] + + +def test_ensure_list_single_value() -> None: + assert cv.ensure_list(cv.int_)(5) == [5] + + +def test_ensure_list_actual_list() -> None: + assert cv.ensure_list(cv.int_)([1, 2, 3]) == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# hex_int / int_to_hex_string / int_ +# --------------------------------------------------------------------------- + + +def test_hex_int() -> None: + result = cv.hex_int(255) + assert result == 255 + assert isinstance(result, HexInt) + + +def test_int_to_hex_string_int() -> None: + assert cv.int_to_hex_string(64) == "0x40" + + +def test_int_to_hex_string_passthrough() -> None: + assert cv.int_to_hex_string("already") == "already" + + +def test_int_float_whole() -> None: + assert cv.int_(5.0) == 5 + + +def test_int_float_fractional() -> None: + with pytest.raises(Invalid, match="only accepts integers with no fractional part"): + cv.int_(5.5) + + +def test_int_hex_string() -> None: + assert cv.int_("0xFF") == 255 + + +# --------------------------------------------------------------------------- +# int_range / float_range no-min branches +# --------------------------------------------------------------------------- + + +def test_int_range_no_min() -> None: + validator = cv.int_range(max=10) + assert validator(5) == 5 + + +def test_float_range_no_min() -> None: + validator = cv.float_range(max=10.0) + assert validator(5.0) == 5.0 + + +# --------------------------------------------------------------------------- +# use_id / declare_id / templatable +# --------------------------------------------------------------------------- + + +def test_use_id_schema_extract() -> None: + assert cv.use_id(int)(SCHEMA_EXTRACT) is int + + +def test_use_id_none() -> None: + result = cv.use_id(int)(None) + assert isinstance(result, ID) + assert result.is_declaration is False + + +def test_use_id_existing_id_passthrough() -> None: + existing = ID("foo", is_declaration=False, type=int) + assert cv.use_id(int)(existing) is existing + + +def test_use_id_from_string() -> None: + result = cv.use_id(int)("foo") + assert isinstance(result, ID) + assert result.id == "foo" + assert result.is_declaration is False + + +def test_declare_id_schema_extract() -> None: + assert cv.declare_id(int)(SCHEMA_EXTRACT) is int + + +def test_declare_id_none() -> None: + result = cv.declare_id(int)(None) + assert isinstance(result, ID) + assert result.is_declaration is True + + +def test_declare_id_from_string() -> None: + result = cv.declare_id(int)("foo") + assert result.id == "foo" + assert result.is_declaration is True + + +def test_templatable_schema_extract() -> None: + assert cv.templatable(cv.int_)(SCHEMA_EXTRACT) is cv.int_ + + +def test_templatable_lambda() -> None: + result = cv.templatable(cv.int_)(Lambda("return 5;")) + assert isinstance(result, Lambda) + + +def test_templatable_plain_value() -> None: + assert cv.templatable(cv.int_)(5) == 5 + + +def test_templatable_dict_validators() -> None: + validator = cv.templatable({cv.Required("x"): cv.int_}) + assert validator({"x": 5}) == {"x": 5} + + +# --------------------------------------------------------------------------- +# only_on / only_with_framework +# --------------------------------------------------------------------------- + + +def test_only_on_list_platform_match() -> None: + _set_core_target(PLATFORM_ESP32, "arduino") + validator = cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]) + assert validator("x") == "x" + + +def test_only_on_wrong_platform() -> None: + _set_core_target(PLATFORM_ESP8266, "arduino") + validator = cv.only_on(PLATFORM_ESP32) + with pytest.raises(Invalid, match="only available on"): + validator("x") + + +def test_only_with_framework_match() -> None: + _set_core_target(PLATFORM_ESP32, "arduino") + validator = cv.only_with_framework([Framework.ARDUINO]) + assert validator("x") == "x" + + +def test_only_with_framework_mismatch_with_suggestion() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework( + Framework.ARDUINO, + suggestions={Framework.ESP_IDF: ("some_component", "some/path")}, + ) + with pytest.raises(Invalid, match="some/path"): + validator("x") + + +def test_only_with_framework_mismatch_no_suggestion() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework(Framework.ARDUINO) + with pytest.raises(Invalid, match="only available with framework"): + validator("x") + + +def test_only_with_framework_suggestion_without_docs_path() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework( + Framework.ARDUINO, + suggestions={Framework.ESP_IDF: ("some_component", None)}, + ) + with pytest.raises(Invalid, match="Please use 'some_component'"): + validator("x") + + +# --------------------------------------------------------------------------- +# has_*_key helpers +# --------------------------------------------------------------------------- + + +def test_has_at_least_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_at_least_one_key("a", "b")([]) + + +def test_has_at_least_one_key_none() -> None: + with pytest.raises(Invalid, match="at least one of"): + cv.has_at_least_one_key("a", "b")({"c": 1}) + + +def test_has_at_least_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_at_least_one_key("a", "b")(obj) is obj + + +def test_has_exactly_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_exactly_one_key("a", "b")("notdict") + + +def test_has_exactly_one_key_too_many() -> None: + with pytest.raises(Invalid, match="Cannot specify more than one"): + cv.has_exactly_one_key("a", "b")({"a": 1, "b": 2}) + + +def test_has_exactly_one_key_too_few() -> None: + with pytest.raises(Invalid, match="Must contain exactly one"): + cv.has_exactly_one_key("a", "b")({"c": 1}) + + +def test_has_exactly_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_exactly_one_key("a", "b")(obj) is obj + + +def test_has_at_most_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_at_most_one_key("a", "b")(5) + + +def test_has_at_most_one_key_too_many() -> None: + with pytest.raises(vol.MultipleInvalid, match="Cannot specify more than one"): + cv.has_at_most_one_key("a", "b")({"a": 1, "b": 2}) + + +def test_has_at_most_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_at_most_one_key("a", "b")(obj) is obj + + +def test_has_none_or_all_keys_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_none_or_all_keys("a", "b")(5) + + +def test_has_none_or_all_keys_partial() -> None: + with pytest.raises(Invalid, match="none or all"): + cv.has_none_or_all_keys("a", "b")({"a": 1}) + + +def test_has_none_or_all_keys_all() -> None: + obj = {"a": 1, "b": 2} + assert cv.has_none_or_all_keys("a", "b")(obj) is obj + + +def test_has_none_or_all_keys_none() -> None: + obj = {"c": 3} + assert cv.has_none_or_all_keys("a", "b")(obj) is obj + + +# --------------------------------------------------------------------------- +# time_period_str_colon / time_period_str_unit +# --------------------------------------------------------------------------- + + +def test_time_period_str_colon_int() -> None: + with pytest.raises(Invalid, match="wrap time values in quotes"): + cv.time_period_str_colon(5) + + +def test_time_period_str_colon_not_str() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon([1, 2]) + + +def test_time_period_str_colon_bad_value() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon("aa:bb") + + +def test_time_period_str_colon_hh_mm() -> None: + assert cv.time_period_str_colon("01:30") == TimePeriod(hours=1, minutes=30) + + +def test_time_period_str_colon_hh_mm_ss() -> None: + assert cv.time_period_str_colon("01:30:15") == TimePeriod( + hours=1, minutes=30, seconds=15 + ) + + +def test_time_period_str_colon_too_many_parts() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon("1:2:3:4") + + +def test_time_period_str_unit_int() -> None: + with pytest.raises(Invalid, match=r"no time \*unit\*"): + cv.time_period_str_unit(5) + + +def test_time_period_str_unit_timeperiod_input() -> None: + assert cv.time_period_str_unit(TimePeriod(seconds=5)) == TimePeriod(seconds=5) + + +def test_time_period_str_unit_not_str() -> None: + with pytest.raises(Invalid, match="Expected string for time period"): + cv.time_period_str_unit([1]) + + +def test_time_period_str_unit_no_match() -> None: + with pytest.raises(Invalid, match="Expected time period with unit"): + cv.time_period_str_unit("5/3") + + +def test_time_period_str_unit_empty_mantissa() -> None: + with pytest.raises(Invalid): + cv.time_period_str_unit("s") + + +# --------------------------------------------------------------------------- +# time_period_in_* converters +# --------------------------------------------------------------------------- + + +def test_time_period_in_milliseconds_too_precise() -> None: + with pytest.raises(Invalid, match="Maximum precision is milliseconds"): + cv.time_period_in_milliseconds_(TimePeriod(microseconds=5)) + + +def test_time_period_in_microseconds_too_precise() -> None: + with pytest.raises(Invalid, match="Maximum precision is microseconds"): + cv.time_period_in_microseconds_(TimePeriod(nanoseconds=5)) + + +def test_time_period_in_microseconds_ok() -> None: + assert cv.time_period_in_microseconds_( + TimePeriod(microseconds=5) + ) == TimePeriodMicroseconds(microseconds=5) + + +def test_time_period_in_nanoseconds_ok() -> None: + assert cv.time_period_in_nanoseconds_( + TimePeriod(nanoseconds=5) + ) == TimePeriodNanoseconds(nanoseconds=5) + + +@pytest.mark.parametrize( + "value", + [ + TimePeriod(nanoseconds=1), + TimePeriod(microseconds=1), + TimePeriod(milliseconds=1), + ], +) +def test_time_period_in_seconds_too_precise(value: TimePeriod) -> None: + with pytest.raises(Invalid, match="Maximum precision is seconds"): + cv.time_period_in_seconds_(value) + + +def test_time_period_in_seconds_ok() -> None: + assert cv.time_period_in_seconds_(TimePeriod(seconds=5)) == TimePeriodSeconds( + seconds=5 + ) + + +@pytest.mark.parametrize( + "value", + [ + TimePeriod(nanoseconds=1), + TimePeriod(microseconds=1), + TimePeriod(milliseconds=1), + TimePeriod(seconds=1), + ], +) +def test_time_period_in_minutes_too_precise(value: TimePeriod) -> None: + with pytest.raises(Invalid, match="Maximum precision is minutes"): + cv.time_period_in_minutes_(value) + + +def test_time_period_in_minutes_ok() -> None: + assert cv.time_period_in_minutes_(TimePeriod(minutes=5)) == TimePeriodMinutes( + minutes=5 + ) + + +# --------------------------------------------------------------------------- +# time_of_day / date_time +# --------------------------------------------------------------------------- + + +def test_time_of_day_valid() -> None: + assert cv.time_of_day("12:34:56") == { + CONF_HOUR: 12, + CONF_MINUTE: 34, + CONF_SECOND: 56, + } + + +def test_date_time_dict_input() -> None: + validator = cv.date_time(date=True, time=False) + result = validator({CONF_YEAR: 2024, CONF_MONTH: 5, CONF_DAY: 1}) + assert result[CONF_YEAR] == 2024 + + +def test_date_time_date_only_string() -> None: + validator = cv.date_time(date=True, time=False) + assert validator("2024-5-1") == {CONF_YEAR: 2024, CONF_MONTH: 5, CONF_DAY: 1} + + +def test_date_time_date_and_time_string() -> None: + validator = cv.date_time(date=True, time=True) + result = validator("2024-05-01 13:30:00") + assert result[CONF_HOUR] == 13 + assert result[CONF_YEAR] == 2024 + + +def test_date_time_invalid_format() -> None: + validator = cv.date_time(date=False, time=True) + with pytest.raises(Invalid, match="Invalid time"): + validator("notatime") + + +def test_date_time_ampm() -> None: + validator = cv.date_time(date=False, time=True) + assert validator("1:30 PM")[CONF_HOUR] == 13 + + +def test_date_time_no_seconds() -> None: + validator = cv.date_time(date=False, time=True) + assert validator("13:30")[CONF_SECOND] == 0 + + +def test_date_time_strptime_error() -> None: + validator = cv.date_time(date=False, time=True) + with pytest.raises(Invalid, match="Invalid time"): + validator("25:99") + + +# --------------------------------------------------------------------------- +# mac_address / uuid +# --------------------------------------------------------------------------- + + +def test_mac_address_valid() -> None: + result = cv.mac_address("AA:BB:CC:DD:EE:FF") + assert isinstance(result, MACAddress) + + +def test_mac_address_wrong_parts() -> None: + with pytest.raises(Invalid, match="6 : .colon. separated parts"): + cv.mac_address("AA:BB:CC") + + +def test_mac_address_wrong_length() -> None: + with pytest.raises(Invalid, match="format XX:XX"): + cv.mac_address("A:BB:CC:DD:EE:FF") + + +def test_mac_address_non_hex() -> None: + with pytest.raises(Invalid, match="hexadecimal values"): + cv.mac_address("GG:BB:CC:DD:EE:FF") + + +def test_uuid_valid() -> None: + result = cv.uuid("12345678-1234-5678-1234-567812345678") + assert str(result) == "12345678-1234-5678-1234-567812345678" + + +# --------------------------------------------------------------------------- +# float_with_unit family +# --------------------------------------------------------------------------- + + +def test_float_with_unit_optional_unit_plain_float() -> None: + assert cv.angle("1.5") == 1.5 + + +def test_float_with_unit_optional_unit_with_suffix() -> None: + assert cv.angle("45deg") == 45.0 + + +def test_float_with_unit_with_suffix() -> None: + assert cv.frequency("10kHz") == 10000.0 + + +def test_float_with_unit_no_match() -> None: + with pytest.raises(Invalid, match="Expected frequency with unit"): + cv.frequency("!!") + + +def test_float_with_unit_invalid_suffix() -> None: + with pytest.raises(Invalid, match="Invalid frequency suffix"): + cv.frequency("10xHz") + + +def test_temperature_celsius() -> None: + assert cv.temperature("25°C") == 25.0 + + +def test_temperature_kelvin() -> None: + assert cv.temperature("300K") == pytest.approx(300 - 273.15) + + +def test_temperature_fahrenheit() -> None: + assert cv.temperature("32°F") == pytest.approx(0.0) + + +def test_temperature_invalid() -> None: + with pytest.raises(Invalid, match="Invalid temperature suffix"): + cv.temperature("5x") + + +def test_temperature_delta_celsius() -> None: + assert cv.temperature_delta("5°C") == 5.0 + + +def test_temperature_delta_kelvin() -> None: + assert cv.temperature_delta("5K") == 5.0 + + +def test_temperature_delta_fahrenheit() -> None: + assert cv.temperature_delta("9°F") == pytest.approx(5.0) + + +def test_temperature_delta_invalid() -> None: + with pytest.raises(Invalid, match="Invalid temperature suffix"): + cv.temperature_delta("5x") + + +def test_color_temperature_mireds() -> None: + assert cv.color_temperature("153 mireds") == pytest.approx(153.0) + + +def test_color_temperature_kelvin() -> None: + assert cv.color_temperature("6536 K") == pytest.approx(1000000.0 / 6536) + + +def test_color_temperature_negative() -> None: + with pytest.raises(Invalid, match="cannot be negative"): + cv.color_temperature("-1 mireds") + + +# --------------------------------------------------------------------------- +# validate_bytes +# --------------------------------------------------------------------------- + + +def test_validate_bytes_plain() -> None: + assert cv.validate_bytes("100") == 100 + + +def test_validate_bytes_with_unit() -> None: + assert cv.validate_bytes("2kB") == 2000 + + +def test_validate_bytes_no_match() -> None: + with pytest.raises(Invalid, match="Expected number of bytes"): + cv.validate_bytes("abc") + + +def test_validate_bytes_invalid_suffix() -> None: + with pytest.raises(Invalid, match="Invalid metric suffix"): + cv.validate_bytes("5xx") + + +def test_validate_bytes_negative_exponent() -> None: + with pytest.raises(Invalid, match="positive exponents"): + cv.validate_bytes("5m") + + +# --------------------------------------------------------------------------- +# hostname / domain / domain_name / ssid +# --------------------------------------------------------------------------- + + +def test_hostname_valid() -> None: + assert cv.hostname("my-host01") == "my-host01" + + +def test_hostname_invalid() -> None: + with pytest.raises(Invalid, match="Invalid hostname"): + cv.hostname("invalid_host!") + + +def test_domain_valid_name() -> None: + assert cv.domain("example.com") == "example.com" + + +def test_domain_ip_fallback() -> None: + assert cv.domain("::1") == "::1" + + +def test_domain_invalid() -> None: + with pytest.raises(Invalid, match="Invalid domain"): + cv.domain("::not::valid::") + + +def test_domain_name_empty() -> None: + assert cv.domain_name("") == "" + + +def test_domain_name_valid() -> None: + assert cv.domain_name(".local") == ".local" + + +def test_domain_name_no_leading_dot() -> None: + with pytest.raises(Invalid, match="must start with"): + cv.domain_name("local") + + +def test_domain_name_double_dot() -> None: + with pytest.raises(Invalid, match="single"): + cv.domain_name("..local") + + +def test_domain_name_invalid_char() -> None: + with pytest.raises(Invalid, match="alphanumeric"): + cv.domain_name(".local!") + + +def test_ssid_valid() -> None: + assert cv.ssid("MyNetwork") == "MyNetwork" + + +def test_ssid_empty() -> None: + with pytest.raises(Invalid, match="can't be empty"): + cv.ssid("") + + +def test_ssid_too_long() -> None: + with pytest.raises(Invalid, match="longer than 32"): + cv.ssid("x" * 33) + + +# --------------------------------------------------------------------------- +# IP address / network validators +# --------------------------------------------------------------------------- + + +def test_ipv6address_valid() -> None: + assert str(cv.ipv6address("::1")) == "::1" + + +def test_ipv6address_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv6 address"): + cv.ipv6address("not-ipv6") + + +def test_ipv4address_multi_broadcast_multicast() -> None: + assert str(cv.ipv4address_multi_broadcast("224.0.0.1")) == "224.0.0.1" + + +def test_ipv4address_multi_broadcast_broadcast() -> None: + assert str(cv.ipv4address_multi_broadcast("255.255.255.255")) == "255.255.255.255" + + +def test_ipv4address_multi_broadcast_invalid() -> None: + with pytest.raises(Invalid, match="not a multicasst"): + cv.ipv4address_multi_broadcast("192.168.0.1") + + +def test_ipv4network_valid() -> None: + assert str(cv.ipv4network("192.168.0.0/24")) == "192.168.0.0/24" + + +def test_ipv4network_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv4 network"): + cv.ipv4network("notanetwork") + + +def test_ipv6network_valid() -> None: + assert str(cv.ipv6network("2001:db8::/32")) == "2001:db8::/32" + + +def test_ipv6network_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv6 network"): + cv.ipv6network("notanetwork") + + +def test_ipnetwork_valid() -> None: + assert str(cv.ipnetwork("10.0.0.0/8")) == "10.0.0.0/8" + + +def test_ipnetwork_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IP network"): + cv.ipnetwork("notanetwork") + + +# --------------------------------------------------------------------------- +# MQTT topic validators +# --------------------------------------------------------------------------- + + +def test_valid_topic_none() -> None: + assert cv._valid_topic(None) == "" + + +def test_valid_topic_dict() -> None: + with pytest.raises(Invalid, match="dictionary with topic"): + cv._valid_topic({"a": 1}) + + +def test_valid_topic_unicode_error() -> None: + with pytest.raises(Invalid, match="valid UTF-8"): + cv._valid_topic("\ud800") + + +def test_valid_topic_empty() -> None: + with pytest.raises(Invalid, match="must not be empty"): + cv._valid_topic("") + + +def test_valid_topic_too_long() -> None: + with pytest.raises(Invalid, match="not be longer than 65535"): + cv._valid_topic("x" * 65536) + + +def test_valid_topic_null_char() -> None: + with pytest.raises(Invalid, match="null character"): + cv._valid_topic("a\0b") + + +def test_subscribe_topic_valid() -> None: + assert cv.subscribe_topic("home/+/temp") == "home/+/temp" + + +def test_subscribe_topic_multilevel() -> None: + assert cv.subscribe_topic("home/#") == "home/#" + + +def test_subscribe_topic_bad_plus() -> None: + with pytest.raises(Invalid, match="Single-level wildcard"): + cv.subscribe_topic("home/a+/temp") + + +def test_subscribe_topic_hash_not_last() -> None: + with pytest.raises(Invalid, match="Multi-level wildcard must be the last"): + cv.subscribe_topic("home/#/temp") + + +def test_subscribe_topic_hash_not_after_separator() -> None: + with pytest.raises(Invalid, match="must be after a topic level separator"): + cv.subscribe_topic("home#") + + +def test_publish_topic_valid() -> None: + assert cv.publish_topic("home/temp") == "home/temp" + + +def test_publish_topic_wildcard() -> None: + with pytest.raises(Invalid, match="Wildcards can not be used"): + cv.publish_topic("home/+") + + +def test_mqtt_payload_none() -> None: + assert cv.mqtt_payload(None) == "" + + +def test_mqtt_payload_value() -> None: + assert cv.mqtt_payload("hello") == "hello" + + +def test_mqtt_qos_valid() -> None: + assert cv.mqtt_qos("1") == 1 + + +def test_mqtt_qos_not_int() -> None: + with pytest.raises(Invalid, match="must be integer"): + cv.mqtt_qos("abc") + + +def test_mqtt_qos_out_of_range() -> None: + with pytest.raises(Invalid): + cv.mqtt_qos(5) + + +# --------------------------------------------------------------------------- +# requires_component / conflicts_with_component +# --------------------------------------------------------------------------- + + +def test_requires_component_loaded() -> None: + CORE.loaded_integrations = {"mqtt"} + assert cv.requires_component("mqtt")("x") == "x" + + +def test_requires_component_not_loaded() -> None: + CORE.loaded_integrations = set() + with pytest.raises(Invalid, match="requires component mqtt"): + cv.requires_component("mqtt")("x") + + +def test_conflicts_with_component_loaded() -> None: + CORE.loaded_integrations = {"mqtt"} + with pytest.raises(Invalid, match="not compatible with component mqtt"): + cv.conflicts_with_component("mqtt")("x") + + +def test_conflicts_with_component_not_loaded() -> None: + CORE.loaded_integrations = set() + assert cv.conflicts_with_component("mqtt")("x") == "x" + + +# --------------------------------------------------------------------------- +# percentage_int / invalid / valid +# --------------------------------------------------------------------------- + + +def test_percentage_int_with_percent() -> None: + assert cv.percentage_int("50%") == 50 + + +def test_percentage_int_plain() -> None: + assert cv.percentage_int(50) == 50 + + +def test_invalid_always_raises() -> None: + with pytest.raises(Invalid, match="my message"): + cv.invalid("my message")("anything") + + +def test_valid_returns_value() -> None: + obj = object() + assert cv.valid(obj) is obj + + +# --------------------------------------------------------------------------- +# prepend_path / remove_prepend_path +# --------------------------------------------------------------------------- + + +def test_prepend_path_single() -> None: + with pytest.raises(Invalid) as exc_info, cv.prepend_path("foo"): + raise Invalid("bad") + assert list(exc_info.value.path) == ["foo"] + + +def test_prepend_path_list() -> None: + with pytest.raises(Invalid) as exc_info, cv.prepend_path(["a", "b"]): + raise Invalid("bad") + assert list(exc_info.value.path) == ["a", "b"] + + +def test_remove_prepend_path_matching() -> None: + with pytest.raises(Invalid) as exc_info, cv.remove_prepend_path(["a"]): + raise Invalid("bad", path=["a", "b"]) + assert list(exc_info.value.path) == ["b"] + + +def test_remove_prepend_path_non_matching() -> None: + with pytest.raises(Invalid) as exc_info, cv.remove_prepend_path("x"): + raise Invalid("bad", path=["a", "b"]) + assert list(exc_info.value.path) == ["a", "b"] + + +# --------------------------------------------------------------------------- +# one_of / enum +# --------------------------------------------------------------------------- + + +def test_one_of_extra_kwargs() -> None: + with pytest.raises(ValueError): + cv.one_of(1, 2, bogus=True) + + +def test_one_of_schema_extract() -> None: + assert cv.one_of("a", "b")(SCHEMA_EXTRACT) == ("a", "b") + + +def test_one_of_string_and_space() -> None: + assert cv.one_of("a_b", string=True, space="_")("a b") == "a_b" + + +def test_one_of_int() -> None: + assert cv.one_of(1, 2, int=True)("2") == 2 + + +def test_one_of_float() -> None: + assert cv.one_of(1.0, 2.0, float=True)("2.0") == 2.0 + + +def test_one_of_lower() -> None: + assert cv.one_of("abc", lower=True)("ABC") == "abc" + + +def test_one_of_upper() -> None: + assert cv.one_of("ABC", upper=True)("abc") == "ABC" + + +def test_one_of_unknown_with_suggestion() -> None: + with pytest.raises(Invalid, match="did you mean"): + cv.one_of("apple", "banana")("aple") + + +def test_one_of_unknown_no_suggestion() -> None: + with pytest.raises(Invalid, match="valid options are"): + cv.one_of("apple", "banana")("zzzzzz") + + +def test_enum_schema_extract() -> None: + mapping = {"a": 1, "b": 2} + assert cv.enum(mapping)(SCHEMA_EXTRACT) == mapping + + +def test_enum_valid() -> None: + mapping = {"a": 10, "b": 20} + result = cv.enum(mapping)("a") + assert result == "a" + assert result.enum_value == 10 + + +# --------------------------------------------------------------------------- +# lambda_ / returning_lambda +# --------------------------------------------------------------------------- + + +def test_lambda_from_string() -> None: + result = cv.lambda_(_wrap_str("return 5;")) + assert isinstance(result, Lambda) + assert result.value == "return 5;" + + +def test_lambda_existing_lambda() -> None: + lam = Lambda("x") + assert cv.lambda_(lam) is lam + + +def test_lambda_entity_id_reference() -> None: + with pytest.raises(Invalid, match="entity-id-style ID"): + cv.lambda_(Lambda("return id(light.living_room);")) + + +def test_returning_lambda_valid() -> None: + assert isinstance(cv.returning_lambda(_wrap_str("return 5;")), Lambda) + + +def test_returning_lambda_no_return() -> None: + with pytest.raises(Invalid, match="return statement"): + cv.returning_lambda(Lambda("int x = 5;")) + + +# --------------------------------------------------------------------------- +# dimensions +# --------------------------------------------------------------------------- + + +def test_dimensions_list_valid() -> None: + assert cv.dimensions([320, 240]) == [320, 240] + + +def test_dimensions_list_wrong_length() -> None: + with pytest.raises(Invalid, match="length of two"): + cv.dimensions([1, 2, 3]) + + +def test_dimensions_list_non_int() -> None: + with pytest.raises(Invalid, match="must be integers"): + cv.dimensions(["a", "b"]) + + +def test_dimensions_list_non_positive() -> None: + with pytest.raises(Invalid, match="at least be 1"): + cv.dimensions([0, 240]) + + +def test_dimensions_string_valid() -> None: + assert cv.dimensions("320x240") == [320, 240] + + +def test_dimensions_number_invalid() -> None: + with pytest.raises(Invalid, match="must be a string"): + cv.dimensions(320) + + +def test_dimensions_string_invalid() -> None: + with pytest.raises(Invalid, match="Only WIDTHxHEIGHT"): + cv.dimensions("notdimensions") + + +# --------------------------------------------------------------------------- +# entity_id +# --------------------------------------------------------------------------- + + +def test_entity_id_valid() -> None: + assert cv.entity_id("Light.Living_Room") == "light.living_room" + + +def test_entity_id_no_dot() -> None: + with pytest.raises(Invalid, match="exactly one dot"): + cv.entity_id("nodot") + + +def test_entity_id_invalid_char() -> None: + with pytest.raises(Invalid, match="Invalid character"): + cv.entity_id("light.living!room") + + +# --------------------------------------------------------------------------- +# extract_keys / typed_schema +# --------------------------------------------------------------------------- + + +def test_extract_keys_from_schema() -> None: + schema = cv.Schema({cv.Optional("b"): cv.int_, cv.Required("a"): cv.int_}) + assert cv.extract_keys(schema) == ["a", "b"] + + +def test_extract_keys_from_dict() -> None: + assert cv.extract_keys({"x": cv.int_, cv.Optional("y"): cv.int_}) == ["x", "y"] + + +def test_extract_keys_invalid_key() -> None: + with pytest.raises(ValueError): + cv.extract_keys({1: cv.int_}) + + +def test_typed_schema_basic() -> None: + schema = cv.typed_schema({"foo": cv.Schema({cv.Optional("x"): cv.int_})}) + assert schema({"type": "foo", "x": 5}) == {"type": "foo", "x": 5} + + +def test_typed_schema_not_dict() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}) + with pytest.raises(Invalid, match="must be dict"): + schema("notdict") + + +def test_typed_schema_missing_key() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}) + with pytest.raises(Invalid, match="type not specified"): + schema({"x": 5}) + + +def test_typed_schema_default_type() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}, default_type="foo") + assert schema({}) == {"type": "foo"} + + +def test_typed_schema_with_enum() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}, enum={"foo": 42}) + result = schema({"type": "foo"}) + assert result["type"] == "foo" + assert result["type"].enum_value == 42 + + +# --------------------------------------------------------------------------- +# SplitDefault / OnlyWithout +# --------------------------------------------------------------------------- + + +def test_split_default_no_match() -> None: + _set_core_target(PLATFORM_ESP8266, "arduino") + schema = cv.Schema({cv.SplitDefault("key", esp32="value"): cv.string}) + assert "key" not in schema({}) + + +def test_only_without_component_absent() -> None: + CORE.loaded_integrations = set() + schema = cv.Schema({cv.OnlyWithout("key", "mqtt", default="dval"): cv.string}) + assert schema({})["key"] == "dval" + + +def test_only_without_component_present() -> None: + CORE.loaded_integrations = {"mqtt"} + schema = cv.Schema({cv.OnlyWithout("key", "mqtt", default="dval"): cv.string}) + assert "key" not in schema({}) + + +# --------------------------------------------------------------------------- +# _entity_base_validator / ensure_schema +# --------------------------------------------------------------------------- + + +def test_entity_base_validator_name_present() -> None: + result = cv._entity_base_validator({CONF_NAME: "My Name"}) + assert result[CONF_NAME] == "My Name" + + +def test_entity_base_validator_neither() -> None: + with pytest.raises(Invalid, match="'id:' or 'name:' is required"): + cv._entity_base_validator({}) + + +def test_entity_base_validator_id_not_manual() -> None: + config = {CONF_ID: ID("auto", is_declaration=True, type=int, is_manual=False)} + with pytest.raises(Invalid, match="'id:' or 'name:' is required"): + cv._entity_base_validator(config) + + +def test_entity_base_validator_id_manual() -> None: + config = {CONF_ID: ID("myid", is_declaration=True, type=int, is_manual=True)} + result = cv._entity_base_validator(config) + assert result[CONF_NAME] == "myid" + assert result[CONF_INTERNAL] is True + + +def test_entity_base_validator_name_none() -> None: + result = cv._entity_base_validator({CONF_NAME: None}) + assert result[CONF_NAME] == "" + + +def test_ensure_schema_passthrough() -> None: + schema = cv.Schema({}) + assert cv.ensure_schema(schema) is schema + + +def test_ensure_schema_wraps() -> None: + result = cv.ensure_schema({cv.Optional("x"): cv.int_}) + assert isinstance(result, cv.Schema) + + +# --------------------------------------------------------------------------- +# validate_registry_entry +# --------------------------------------------------------------------------- + + +def _make_registry(*names: str, type_id: object = int) -> Registry: + registry = Registry() + for name in names: + registry.register(name, type_id, cv.Schema({cv.Optional("param"): cv.int_}))( + lambda: None + ) + return registry + + +def test_validate_registry_entry_string_shorthand() -> None: + registry = _make_registry("foo") + result = cv.validate_registry_entry("action", registry)("foo") + assert "foo" in result + + +def test_validate_registry_entry_not_mapping() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="must consist of key-value mapping"): + cv.validate_registry_entry("action", registry)(5) + + +def test_validate_registry_entry_missing_key() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="Key missing"): + cv.validate_registry_entry("action", registry)({}) + + +def test_validate_registry_entry_unknown_key() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="Unable to find action"): + cv.validate_registry_entry("action", registry)({"unknown": {}}) + + +def test_validate_registry_entry_two_keys() -> None: + registry = _make_registry("foo", "bar") + with pytest.raises(Invalid, match="Cannot have two action"): + cv.validate_registry_entry("action", registry)({"foo": {}, "bar": {}}) + + +def test_validate_registry_entry_none_value() -> None: + registry = _make_registry("foo") + result = cv.validate_registry_entry("action", registry)({"foo": None}) + assert "foo" in result + + +def test_validate_registry_entry_no_type_id() -> None: + registry = _make_registry("foo", type_id=None) + result = cv.validate_registry_entry("action", registry)({"foo": {}}) + assert "foo" in result + + +# --------------------------------------------------------------------------- +# maybe_simple_value / entity_category +# --------------------------------------------------------------------------- + + +def test_maybe_simple_value_schema_extract() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + validator, key = cv.maybe_simple_value(schema)(SCHEMA_EXTRACT) + assert key == CONF_VALUE + + +def test_maybe_simple_value_dict_with_key() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + assert cv.maybe_simple_value(schema)({"value": "x"}) == {"value": "x"} + + +def test_maybe_simple_value_plain() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + assert cv.maybe_simple_value(schema)("x") == {"value": "x"} + + +def test_maybe_simple_value_custom_key() -> None: + schema = cv.Schema({cv.Required("name"): cv.string}) + assert cv.maybe_simple_value(schema, key="name")({"name": "x"}) == {"name": "x"} + + +def test_entity_category_valid() -> None: + assert cv.entity_category("config") == "config" + + +def test_entity_category_invalid() -> None: + with pytest.raises(Invalid): + cv.entity_category("bogus") + + +# --------------------------------------------------------------------------- +# url / git_ref / source_refresh / version helpers +# --------------------------------------------------------------------------- + + +def test_url_valid() -> None: + assert cv.url("https://example.com/path") == "https://example.com/path" + + +def test_url_file_scheme() -> None: + assert cv.url("file:///tmp/x") == "file:///tmp/x" + + +def test_url_invalid_value_error() -> None: + with pytest.raises(Invalid, match="Not a valid URL"): + cv.url("http://[::1") + + +def test_url_no_host() -> None: + with pytest.raises(Invalid, match="Expected a file scheme"): + cv.url("notaurl") + + +def test_git_ref_valid() -> None: + assert cv.git_ref("v1.2.3") == "v1.2.3" + + +def test_git_ref_invalid() -> None: + with pytest.raises(Invalid, match="Not a valid git ref"): + cv.git_ref("!!!") + + +def test_source_refresh_always() -> None: + assert cv.source_refresh("always").total_seconds == 0 + + +def test_source_refresh_never() -> None: + assert cv.source_refresh("never").total_seconds == 365250 * 24 * 3600 + + +def test_source_refresh_value() -> None: + assert cv.source_refresh("60s").total_seconds == 60 + + +def test_version_number_valid() -> None: + assert cv.version_number("2024.5.1") == "2024.5.1" + + +def test_version_number_invalid() -> None: + with pytest.raises(Invalid, match="Not a valid version number"): + cv.version_number("notaversion") + + +def test_validate_esphome_version_ok() -> None: + assert cv.validate_esphome_version("1.0.0") == "1.0.0" + + +def test_validate_esphome_version_too_old() -> None: + with pytest.raises(Invalid, match="ESPHome version is too old"): + cv.validate_esphome_version("9999.0.0") + + +def test_platformio_version_constraint_no_op() -> None: + assert cv.platformio_version_constraint("1.2.3") == [(None, "1.2.3")] + + +def test_platformio_version_constraint_with_ops() -> None: + assert cv.platformio_version_constraint(">=1.2.3,<2.0.0") == [ + (">=", "1.2.3"), + ("<", "2.0.0"), + ] + + +# --------------------------------------------------------------------------- +# require_framework_version (no extra_message) / require_esphome_version +# --------------------------------------------------------------------------- + + +def test_require_framework_version_incompatible_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(1, 0, 0)) + with pytest.raises(Invalid, match="incompatible with ESP32"): + cv.require_framework_version()("test") + + +def test_require_framework_version_too_low_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(1, 0, 0)) + with pytest.raises(Invalid, match="at least framework version 2.0.0"): + cv.require_framework_version(esp32_arduino=cv.Version(2, 0, 0))("test") + + +def test_require_framework_version_too_high_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(2, 0, 0)) + with pytest.raises(Invalid, match="version 1.0.0 or lower"): + cv.require_framework_version( + esp32_arduino=cv.Version(1, 0, 0), max_version=True + )("test") + + +def test_require_esphome_version_ok() -> None: + assert cv.require_esphome_version(1, 0, 0)("test") == "test" + + +def test_require_esphome_version_too_old() -> None: + with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"): + cv.require_esphome_version(9999, 0, 0)("test") + + +# --------------------------------------------------------------------------- +# suppress_invalid / validate_source_shorthand / rename_key +# --------------------------------------------------------------------------- + + +def test_suppress_invalid() -> None: + with cv.suppress_invalid(): + raise Invalid("suppressed") + + +def test_validate_source_shorthand_not_string() -> None: + with pytest.raises(Invalid, match="Shorthand only for strings"): + cv.validate_source_shorthand(123) + + +def test_validate_source_shorthand_local_path(setup_core: Path) -> None: + (setup_core / "mydir").mkdir() + result = cv.validate_source_shorthand("mydir") + assert result[CONF_TYPE] == TYPE_LOCAL + + +def test_validate_source_shorthand_github(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://user/repo@main") + assert result[CONF_TYPE] == TYPE_GIT + assert result[CONF_REF] == "main" + + +def test_validate_source_shorthand_github_no_ref(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://user/repo") + assert result[CONF_TYPE] == TYPE_GIT + assert CONF_REF not in result + + +def test_validate_source_shorthand_github_pr(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://pr#1234") + assert result[CONF_REF] == "pull/1234/head" + + +def test_validate_source_shorthand_invalid(setup_core: Path) -> None: + with pytest.raises(Invalid, match="not a file system path"): + cv.validate_source_shorthand("notvalid") + + +def test_rename_key_present() -> None: + assert cv.rename_key("old", "new")({"old": 5}) == {"new": 5} + + +def test_rename_key_absent() -> None: + assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} From 0fcf512148ac2e948e2e03d43738fa139923099b Mon Sep 17 00:00:00 2001 From: Tomasz Witke Date: Thu, 25 Jun 2026 13:03:50 +0200 Subject: [PATCH 1823/2214] [image] Use LVGL 9 color formats (#16871) --- esphome/components/image/image.cpp | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index c95b693cf0..9b603683ab 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -123,26 +123,18 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { break; case IMAGE_TYPE_RGB: -#if LV_COLOR_DEPTH == 32 switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_ALPHA; + this->dsc_.header.cf = LV_COLOR_FORMAT_ARGB8888; break; case TRANSPARENCY_CHROMA_KEY: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_CHROMA_KEYED; - break; default: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR; + this->dsc_.header.cf = LV_COLOR_FORMAT_RGB888; break; } -#else - this->dsc_.header.cf = - this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_COLOR_FORMAT_ARGB8888 : LV_COLOR_FORMAT_RGB888; -#endif break; case IMAGE_TYPE_RGB565: -#if LV_COLOR_DEPTH == 16 switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: this->dsc_.header.cf = LV_COLOR_FORMAT_RGB565A8; @@ -150,10 +142,6 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { default: this->dsc_.header.cf = LV_COLOR_FORMAT_RGB565; } -#else - this->dsc_.header.cf = - this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_IMG_CF_RGB565A8 : LV_IMG_CF_RGB565; -#endif break; } } From f769457bb0e37ef6163ee593058e640146653d5a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:19:30 +1200 Subject: [PATCH 1824/2214] Mark configurable classes as final (15/21: script-slow_pwm) (#16966) --- esphome/components/script/script.h | 8 ++++---- esphome/components/sdl/sdl_esphome.h | 2 +- esphome/components/sdl/touchscreen/sdl_touchscreen.h | 2 +- esphome/components/sdm_meter/sdm_meter.h | 2 +- esphome/components/sdp3x/sdp3x.h | 4 +++- esphome/components/sds011/sds011.h | 2 +- .../seeed_mr24hpc1/button/custom_mode_end_button.h | 2 +- .../seeed_mr24hpc1/button/restart_button.h | 2 +- .../seeed_mr24hpc1/number/custom_mode_number.h | 2 +- .../seeed_mr24hpc1/number/custom_unman_time_number.h | 2 +- .../number/existence_threshold_number.h | 2 +- .../seeed_mr24hpc1/number/motion_threshold_number.h | 2 +- .../number/motion_trigger_time_number.h | 2 +- .../seeed_mr24hpc1/number/motiontorest_time_number.h | 2 +- .../seeed_mr24hpc1/number/sensitivity_number.h | 2 +- esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h | 4 ++-- .../select/existence_boundary_select.h | 2 +- .../seeed_mr24hpc1/select/motion_boundary_select.h | 2 +- .../seeed_mr24hpc1/select/scene_mode_select.h | 2 +- .../seeed_mr24hpc1/select/unman_time_select.h | 2 +- .../seeed_mr24hpc1/switch/underlyFuc_switch.h | 2 +- esphome/components/seeed_mr60bha2/seeed_mr60bha2.h | 4 ++-- .../button/get_radar_parameters_button.h | 2 +- .../seeed_mr60fda2/button/reset_radar_button.h | 2 +- esphome/components/seeed_mr60fda2/seeed_mr60fda2.h | 4 ++-- .../seeed_mr60fda2/select/height_threshold_select.h | 2 +- .../seeed_mr60fda2/select/install_height_select.h | 2 +- .../seeed_mr60fda2/select/sensitivity_select.h | 2 +- esphome/components/selec_meter/selec_meter.h | 2 +- esphome/components/select/automation.h | 12 ++++++------ esphome/components/sen0321/sen0321.h | 2 +- esphome/components/sen21231/sen21231.h | 2 +- esphome/components/sen5x/automation.h | 2 +- esphome/components/sen5x/sen5x.h | 2 +- esphome/components/sen6x/sen6x.h | 2 +- esphome/components/sendspin/automation.h | 2 +- .../sendspin/media_player/sendspin_media_player.h | 2 +- .../components/sendspin/media_source/automations.h | 4 ++-- .../sendspin/media_source/sendspin_media_source.h | 6 +++--- esphome/components/sendspin/sensor/sendspin_sensor.h | 4 ++-- .../sendspin/text_sensor/sendspin_text_sensor.h | 2 +- esphome/components/senseair/senseair.h | 12 ++++++------ esphome/components/sensor/automation.h | 10 +++++----- esphome/components/serial_proxy/serial_proxy.h | 2 +- esphome/components/servo/servo.h | 6 +++--- esphome/components/sfa30/sfa30.h | 2 +- esphome/components/sgp30/sgp30.h | 2 +- esphome/components/sgp4x/sgp4x.h | 4 +++- esphome/components/shelly_dimmer/shelly_dimmer.h | 2 +- esphome/components/sht3xd/sht3xd.h | 2 +- esphome/components/sht4x/sht4x.h | 2 +- esphome/components/shtcx/shtcx.h | 2 +- esphome/components/shutdown/button/shutdown_button.h | 2 +- esphome/components/shutdown/switch/shutdown_switch.h | 2 +- .../sigma_delta_output/sigma_delta_output.h | 2 +- esphome/components/sim800l/sim800l.h | 12 ++++++------ esphome/components/slow_pwm/slow_pwm_output.h | 2 +- 57 files changed, 92 insertions(+), 88 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 6cd33e566c..790ac107c5 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -216,7 +216,7 @@ template class ParallelScript : public Script { template class ScriptExecuteAction; -template class ScriptExecuteAction, Ts...> : public Action { +template class ScriptExecuteAction, Ts...> final : public Action { public: ScriptExecuteAction(Script *script) : script_(script) {} @@ -254,7 +254,7 @@ template class ScriptExecuteAction, T Args args_; }; -template class ScriptStopAction : public Action { +template class ScriptStopAction final : public Action { public: ScriptStopAction(C *script) : script_(script) {} @@ -264,7 +264,7 @@ template class ScriptStopAction : public Action C *script_; }; -template class IsRunningCondition : public Condition { +template class IsRunningCondition final : public Condition { public: explicit IsRunningCondition(C *parent) : parent_(parent) {} @@ -281,7 +281,7 @@ template class IsRunningCondition : public Condition class ScriptWaitAction : public Action, public Component { +template class ScriptWaitAction final : public Action, public Component { public: ScriptWaitAction(C *script) : script_(script) {} diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index a5ebf44c38..635eb1e3f8 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -13,7 +13,7 @@ namespace esphome::sdl { constexpr static const char *const TAG = "sdl"; -class Sdl : public display::Display { +class Sdl final : public display::Display { public: display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; } void update() override; diff --git a/esphome/components/sdl/touchscreen/sdl_touchscreen.h b/esphome/components/sdl/touchscreen/sdl_touchscreen.h index cf2fd65088..50a584949b 100644 --- a/esphome/components/sdl/touchscreen/sdl_touchscreen.h +++ b/esphome/components/sdl/touchscreen/sdl_touchscreen.h @@ -6,7 +6,7 @@ namespace esphome::sdl { -class SdlTouchscreen : public touchscreen::Touchscreen, public Parented { +class SdlTouchscreen final : public touchscreen::Touchscreen, public Parented { public: void setup() override { this->x_raw_max_ = this->display_->get_width(); diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index e729e29d6c..a4dbde016c 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -8,7 +8,7 @@ namespace esphome::sdm_meter { -class SDMMeter : public PollingComponent, public modbus::ModbusDevice { +class SDMMeter final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/sdp3x/sdp3x.h b/esphome/components/sdp3x/sdp3x.h index c4ef6a4a1e..19c8d0f678 100644 --- a/esphome/components/sdp3x/sdp3x.h +++ b/esphome/components/sdp3x/sdp3x.h @@ -8,7 +8,9 @@ namespace esphome::sdp3x { enum MeasurementMode { MASS_FLOW_AVG, DP_AVG }; -class SDP3XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice, public sensor::Sensor { +class SDP3XComponent final : public PollingComponent, + public sensirion_common::SensirionI2CDevice, + public sensor::Sensor { public: /// Schedule temperature+pressure readings. void update() override; diff --git a/esphome/components/sds011/sds011.h b/esphome/components/sds011/sds011.h index 56d46d118f..4f4571ab69 100644 --- a/esphome/components/sds011/sds011.h +++ b/esphome/components/sds011/sds011.h @@ -7,7 +7,7 @@ namespace esphome::sds011 { -class SDS011Component : public Component, public uart::UARTDevice { +class SDS011Component final : public Component, public uart::UARTDevice { public: SDS011Component() = default; diff --git a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h index bc98bb93b6..fc0cbbdc76 100644 --- a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h +++ b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomSetEndButton : public button::Button, public Parented { +class CustomSetEndButton final : public button::Button, public Parented { public: CustomSetEndButton() = default; diff --git a/esphome/components/seeed_mr24hpc1/button/restart_button.h b/esphome/components/seeed_mr24hpc1/button/restart_button.h index 49a4f46138..c6c530004b 100644 --- a/esphome/components/seeed_mr24hpc1/button/restart_button.h +++ b/esphome/components/seeed_mr24hpc1/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h index f51e592fc0..842530a379 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h +++ b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomModeNumber : public number::Number, public Parented { +class CustomModeNumber final : public number::Number, public Parented { public: CustomModeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h b/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h index 281e727a36..0ef2073195 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomUnmanTimeNumber : public number::Number, public Parented { +class CustomUnmanTimeNumber final : public number::Number, public Parented { public: CustomUnmanTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h b/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h index c811b2d6b6..11aa45a6dc 100644 --- a/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h +++ b/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class ExistenceThresholdNumber : public number::Number, public Parented { +class ExistenceThresholdNumber final : public number::Number, public Parented { public: ExistenceThresholdNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h b/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h index 748119f198..01f62f67fb 100644 --- a/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionThresholdNumber : public number::Number, public Parented { +class MotionThresholdNumber final : public number::Number, public Parented { public: MotionThresholdNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h b/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h index dd7947b2a5..44cf89837e 100644 --- a/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionTriggerTimeNumber : public number::Number, public Parented { +class MotionTriggerTimeNumber final : public number::Number, public Parented { public: MotionTriggerTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h b/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h index 47493e7954..c12f14e79f 100644 --- a/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionToRestTimeNumber : public number::Number, public Parented { +class MotionToRestTimeNumber final : public number::Number, public Parented { public: MotionToRestTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h b/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h index c1d5435151..954c004e67 100644 --- a/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h +++ b/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class SensitivityNumber : public number::Number, public Parented { +class SensitivityNumber final : public number::Number, public Parented { public: SensitivityNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index b62504ba0e..b231bab33e 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -92,8 +92,8 @@ static const char *const S_BOUNDARY_STR[10] = {"0.5m", "1.0m", "1.5m", "2.0m", " "3.0m", "3.5m", "4.0m", "4.5m", "5.0m"}; // uint: m static const float S_PRESENCE_OF_DETECTION_RANGE_STR[7] = {0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f}; // uint: m -class MR24HPC1Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR24HPC1Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_TEXT_SENSOR SUB_TEXT_SENSOR(heartbeat_state) SUB_TEXT_SENSOR(product_model) diff --git a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h index 878d0525c9..1fce716ed6 100644 --- a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h +++ b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class ExistenceBoundarySelect : public select::Select, public Parented { +class ExistenceBoundarySelect final : public select::Select, public Parented { public: ExistenceBoundarySelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h b/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h index eecdef2019..721bc67f69 100644 --- a/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h +++ b/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionBoundarySelect : public select::Select, public Parented { +class MotionBoundarySelect final : public select::Select, public Parented { public: MotionBoundarySelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h b/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h index 377c61b32f..40e365aa7b 100644 --- a/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h +++ b/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class SceneModeSelect : public select::Select, public Parented { +class SceneModeSelect final : public select::Select, public Parented { public: SceneModeSelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/unman_time_select.h b/esphome/components/seeed_mr24hpc1/select/unman_time_select.h index e68ae5e54f..bba5363565 100644 --- a/esphome/components/seeed_mr24hpc1/select/unman_time_select.h +++ b/esphome/components/seeed_mr24hpc1/select/unman_time_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class UnmanTimeSelect : public select::Select, public Parented { +class UnmanTimeSelect final : public select::Select, public Parented { public: UnmanTimeSelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h b/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h index 3224640ce7..8b8dbdf5de 100644 --- a/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h +++ b/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class UnderlyOpenFunctionSwitch : public switch_::Switch, public Parented { +class UnderlyOpenFunctionSwitch final : public switch_::Switch, public Parented { public: UnderlyOpenFunctionSwitch() = default; diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h index 008acc6a57..0ce25790cc 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h @@ -21,8 +21,8 @@ static const uint16_t HEART_RATE_TYPE_BUFFER = 0x0A15; static const uint16_t DISTANCE_TYPE_BUFFER = 0x0A16; static const uint16_t PRINT_CLOUD_BUFFER = 0x0A04; -class MR60BHA2Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR60BHA2Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(has_target); #endif diff --git a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h index c1b96d5f08..7a604592c0 100644 --- a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h +++ b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class GetRadarParametersButton : public button::Button, public Parented { +class GetRadarParametersButton final : public button::Button, public Parented { public: GetRadarParametersButton() = default; diff --git a/esphome/components/seeed_mr60fda2/button/reset_radar_button.h b/esphome/components/seeed_mr60fda2/button/reset_radar_button.h index 174ef5425e..cdfb259909 100644 --- a/esphome/components/seeed_mr60fda2/button/reset_radar_button.h +++ b/esphome/components/seeed_mr60fda2/button/reset_radar_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class ResetRadarButton : public button::Button, public Parented { +class ResetRadarButton final : public button::Button, public Parented { public: ResetRadarButton() = default; diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h index 0e97447074..f231de5eec 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h @@ -56,8 +56,8 @@ static const char *const INSTALL_HEIGHT_STR[7] = {"2.4m", "2.5m", "2.6", "2.7m", static const char *const HEIGHT_THRESHOLD_STR[7] = {"0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"}; static const char *const SENSITIVITY_STR[3] = {"1", "2", "3"}; -class MR60FDA2Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR60FDA2Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(people_exist) SUB_BINARY_SENSOR(fall_detected) diff --git a/esphome/components/seeed_mr60fda2/select/height_threshold_select.h b/esphome/components/seeed_mr60fda2/select/height_threshold_select.h index 0e49576658..0c93085337 100644 --- a/esphome/components/seeed_mr60fda2/select/height_threshold_select.h +++ b/esphome/components/seeed_mr60fda2/select/height_threshold_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class HeightThresholdSelect : public select::Select, public Parented { +class HeightThresholdSelect final : public select::Select, public Parented { public: HeightThresholdSelect() = default; diff --git a/esphome/components/seeed_mr60fda2/select/install_height_select.h b/esphome/components/seeed_mr60fda2/select/install_height_select.h index c1e2a3eeb1..964edfa127 100644 --- a/esphome/components/seeed_mr60fda2/select/install_height_select.h +++ b/esphome/components/seeed_mr60fda2/select/install_height_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class InstallHeightSelect : public select::Select, public Parented { +class InstallHeightSelect final : public select::Select, public Parented { public: InstallHeightSelect() = default; diff --git a/esphome/components/seeed_mr60fda2/select/sensitivity_select.h b/esphome/components/seeed_mr60fda2/select/sensitivity_select.h index f2e0307dc1..1d96257871 100644 --- a/esphome/components/seeed_mr60fda2/select/sensitivity_select.h +++ b/esphome/components/seeed_mr60fda2/select/sensitivity_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class SensitivitySelect : public select::Select, public Parented { +class SensitivitySelect final : public select::Select, public Parented { public: SensitivitySelect() = default; diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 159acab124..6b5552a098 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -15,7 +15,7 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } -class SelecMeter : public PollingComponent, public modbus::ModbusDevice { +class SelecMeter final : public PollingComponent, public modbus::ModbusDevice { public: SELEC_METER_SENSOR(total_active_energy) SELEC_METER_SENSOR(import_active_energy) diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index ffdabd5f7c..8e5da893ad 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -6,7 +6,7 @@ namespace esphome::select { -class SelectStateTrigger : public Trigger { +class SelectStateTrigger final : public Trigger { public: explicit SelectStateTrigger(Select *parent) : parent_(parent) { parent->add_on_state_callback( @@ -17,7 +17,7 @@ class SelectStateTrigger : public Trigger { Select *parent_; }; -template class SelectSetAction : public Action { +template class SelectSetAction final : public Action { public: explicit SelectSetAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(std::string, option) @@ -32,7 +32,7 @@ template class SelectSetAction : public Action { Select *select_; }; -template class SelectSetIndexAction : public Action { +template class SelectSetIndexAction final : public Action { public: explicit SelectSetIndexAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(size_t, index) @@ -47,7 +47,7 @@ template class SelectSetIndexAction : public Action { Select *select_; }; -template class SelectOperationAction : public Action { +template class SelectOperationAction final : public Action { public: explicit SelectOperationAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(bool, cycle) @@ -66,7 +66,7 @@ template class SelectOperationAction : public Action { Select *select_; }; -template class SelectIsCondition : public Condition { +template class SelectIsCondition final : public Condition { public: SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {} @@ -85,7 +85,7 @@ template class SelectIsCondition : public Condition class SelectIsCondition<0, Ts...> : public Condition { +template class SelectIsCondition<0, Ts...> final : public Condition { public: SelectIsCondition(Select *parent, std::function &&f) : parent_(parent), f_(f) {} diff --git a/esphome/components/sen0321/sen0321.h b/esphome/components/sen0321/sen0321.h index 6d5aa20a61..ed7df3fcaf 100644 --- a/esphome/components/sen0321/sen0321.h +++ b/esphome/components/sen0321/sen0321.h @@ -20,7 +20,7 @@ static const uint8_t SET_REGISTER = 0x04; static const uint8_t SENSOR_PASS_READ_REG = 0x07; static const uint8_t SENSOR_AUTO_READ_REG = 0x09; -class Sen0321Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Sen0321Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; diff --git a/esphome/components/sen21231/sen21231.h b/esphome/components/sen21231/sen21231.h index 486a9473d2..ad05966011 100644 --- a/esphome/components/sen21231/sen21231.h +++ b/esphome/components/sen21231/sen21231.h @@ -63,7 +63,7 @@ using person_sensor_results_t = struct __attribute__((__packed__)) { uint16_t checksum; // Bytes 38-39. }; -class Sen21231Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Sen21231Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; diff --git a/esphome/components/sen5x/automation.h b/esphome/components/sen5x/automation.h index e6111f4a8f..21d938c4fe 100644 --- a/esphome/components/sen5x/automation.h +++ b/esphome/components/sen5x/automation.h @@ -6,7 +6,7 @@ namespace esphome::sen5x { -template class StartFanAction : public Action { +template class StartFanAction final : public Action { public: explicit StartFanAction(SEN5XComponent *sen5x) : sen5x_(sen5x) {} diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index ec8f9cc544..6b5a1f8510 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -44,7 +44,7 @@ struct TemperatureCompensation { // Prevents wear of the flash because of too many write operations static const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 2 * 60 * 60 * 1000; -class SEN5XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SEN5XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index bc44611882..041bf3b1aa 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -6,7 +6,7 @@ namespace esphome::sen6x { -class SEN6XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SEN6XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(pm_1_0) SUB_SENSOR(pm_2_5) SUB_SENSOR(pm_4_0) diff --git a/esphome/components/sendspin/automation.h b/esphome/components/sendspin/automation.h index be3b1eb39d..0b408b1235 100644 --- a/esphome/components/sendspin/automation.h +++ b/esphome/components/sendspin/automation.h @@ -10,7 +10,7 @@ namespace esphome::sendspin_ { #ifdef USE_SENDSPIN_CONTROLLER -template class SendspinSwitchCommandAction : public Action, public Parented { +template class SendspinSwitchCommandAction final : public Action, public Parented { public: void play(const Ts &...x) override { // Clear any EXTERNAL_SOURCE state so the switch command is followed diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index 52786d6d7b..651e1562be 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -9,7 +9,7 @@ namespace esphome::sendspin_ { -class SendspinMediaPlayer : public SendspinChild, public media_player::MediaPlayer { +class SendspinMediaPlayer final : public SendspinChild, public media_player::MediaPlayer { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sendspin/media_source/automations.h b/esphome/components/sendspin/media_source/automations.h index 08d2b2004b..f5c35f107a 100644 --- a/esphome/components/sendspin/media_source/automations.h +++ b/esphome/components/sendspin/media_source/automations.h @@ -10,13 +10,13 @@ namespace esphome::sendspin_ { template -class EnableStaticDelayAdjustmentAction : public Action, public Parented { +class EnableStaticDelayAdjustmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } }; template -class DisableStaticDelayAdjustmentAction : public Action, public Parented { +class DisableStaticDelayAdjustmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } }; diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h index 843578783e..1c5cb625bf 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.h +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -17,9 +17,9 @@ namespace esphome::sendspin_ { /// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's /// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards /// transport commands to the hub's controller role. -class SendspinMediaSource : public SendspinChild, - public media_source::MediaSource, - public sendspin::PlayerRoleListener { +class SendspinMediaSource final : public SendspinChild, + public media_source::MediaSource, + public sendspin::PlayerRoleListener { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index cbfe1742c9..5b29fff55f 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -11,7 +11,7 @@ namespace esphome::sendspin_ { -class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild { +class SendspinTrackProgressSensor final : public sensor::Sensor, public SendspinPollingChild { public: void dump_config() override; void setup() override; @@ -24,7 +24,7 @@ enum class SendspinNumericMetadataTypes { TRACK, }; -class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild { +class SendspinMetadataSensor final : public sensor::Sensor, public SendspinChild { public: void dump_config() override; void setup() override; diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index 203b01d024..d38f360d94 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -18,7 +18,7 @@ enum class SendspinTextMetadataTypes { ALBUM_ARTIST, }; -class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor { +class SendspinTextSensor final : public SendspinChild, public text_sensor::TextSensor { public: void dump_config() override; void setup() override; diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 333c003f48..48154a53d9 100644 --- a/esphome/components/senseair/senseair.h +++ b/esphome/components/senseair/senseair.h @@ -18,7 +18,7 @@ enum SenseAirStatus : uint8_t { RESERVED = 1 << 7 }; -class SenseAirComponent : public PollingComponent, public uart::UARTDevice { +class SenseAirComponent final : public PollingComponent, public uart::UARTDevice { public: void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } @@ -37,7 +37,7 @@ class SenseAirComponent : public PollingComponent, public uart::UARTDevice { sensor::Sensor *co2_sensor_{nullptr}; }; -template class SenseAirBackgroundCalibrationAction : public Action { +template class SenseAirBackgroundCalibrationAction final : public Action { public: SenseAirBackgroundCalibrationAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -47,7 +47,7 @@ template class SenseAirBackgroundCalibrationAction : public Acti SenseAirComponent *senseair_; }; -template class SenseAirBackgroundCalibrationResultAction : public Action { +template class SenseAirBackgroundCalibrationResultAction final : public Action { public: SenseAirBackgroundCalibrationResultAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -57,7 +57,7 @@ template class SenseAirBackgroundCalibrationResultAction : publi SenseAirComponent *senseair_; }; -template class SenseAirABCEnableAction : public Action { +template class SenseAirABCEnableAction final : public Action { public: SenseAirABCEnableAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -67,7 +67,7 @@ template class SenseAirABCEnableAction : public Action { SenseAirComponent *senseair_; }; -template class SenseAirABCDisableAction : public Action { +template class SenseAirABCDisableAction final : public Action { public: SenseAirABCDisableAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -77,7 +77,7 @@ template class SenseAirABCDisableAction : public Action { SenseAirComponent *senseair_; }; -template class SenseAirABCGetPeriodAction : public Action { +template class SenseAirABCGetPeriodAction final : public Action { public: SenseAirABCGetPeriodAction(SenseAirComponent *senseair) : senseair_(senseair) {} diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 37578f5320..35a4a29e0d 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -6,21 +6,21 @@ namespace esphome::sensor { -class SensorStateTrigger : public Trigger { +class SensorStateTrigger final : public Trigger { public: explicit SensorStateTrigger(Sensor *parent) { parent->add_on_state_callback([this](float value) { this->trigger(value); }); } }; -class SensorRawStateTrigger : public Trigger { +class SensorRawStateTrigger final : public Trigger { public: explicit SensorRawStateTrigger(Sensor *parent) { parent->add_on_raw_state_callback([this](float value) { this->trigger(value); }); } }; -template class SensorPublishAction : public Action { +template class SensorPublishAction final : public Action { public: SensorPublishAction(Sensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(float, state) @@ -31,7 +31,7 @@ template class SensorPublishAction : public Action { Sensor *sensor_; }; -class ValueRangeTrigger : public Trigger, public Component { +class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Sensor *parent) : parent_(parent) {} @@ -83,7 +83,7 @@ class ValueRangeTrigger : public Trigger, public Component { TemplatableFn max_{[](float) -> float { return NAN; }}; }; -template class SensorInRangeCondition : public Condition { +template class SensorInRangeCondition final : public Condition { public: SensorInRangeCondition(Sensor *parent) : parent_(parent) {} diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index c435787a61..e35fab3d42 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -41,7 +41,7 @@ enum SerialProxyLineStateFlag : uint32_t { /// Maximum bytes to read from UART in a single loop iteration inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; -class SerialProxy : public uart::UARTDevice, public Component { +class SerialProxy final : public uart::UARTDevice, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/servo/servo.h b/esphome/components/servo/servo.h index 31e9357947..156dab6dc1 100644 --- a/esphome/components/servo/servo.h +++ b/esphome/components/servo/servo.h @@ -10,7 +10,7 @@ namespace esphome::servo { extern uint32_t global_servo_id; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class Servo : public Component { +class Servo final : public Component { public: void set_output(output::FloatOutput *output) { output_ = output; } void loop() override; @@ -51,7 +51,7 @@ class Servo : public Component { }; }; -template class ServoWriteAction : public Action { +template class ServoWriteAction final : public Action { public: ServoWriteAction(Servo *servo) : servo_(servo) {} TEMPLATABLE_VALUE(float, value) @@ -62,7 +62,7 @@ template class ServoWriteAction : public Action { Servo *servo_; }; -template class ServoDetachAction : public Action { +template class ServoDetachAction final : public Action { public: ServoDetachAction(Servo *servo) : servo_(servo) {} diff --git a/esphome/components/sfa30/sfa30.h b/esphome/components/sfa30/sfa30.h index d2f2520a57..13985b1a29 100644 --- a/esphome/components/sfa30/sfa30.h +++ b/esphome/components/sfa30/sfa30.h @@ -6,7 +6,7 @@ namespace esphome::sfa30 { -class SFA30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SFA30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { enum ErrorCode { DEVICE_MARKING_READ_FAILED, MEASUREMENT_INIT_FAILED, UNKNOWN }; public: diff --git a/esphome/components/sgp30/sgp30.h b/esphome/components/sgp30/sgp30.h index cb4aa1c1bb..fac3c01b58 100644 --- a/esphome/components/sgp30/sgp30.h +++ b/esphome/components/sgp30/sgp30.h @@ -16,7 +16,7 @@ struct SGP30Baselines { } PACKED; /// This class implements support for the Sensirion SGP30 i2c GAS (VOC and CO2eq) sensors. -class SGP30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SGP30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_eco2_sensor(sensor::Sensor *eco2) { eco2_sensor_ = eco2; } void set_tvoc_sensor(sensor::Sensor *tvoc) { tvoc_sensor_ = tvoc; } diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 23bf6319a9..a40188e629 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -54,7 +54,9 @@ const float MAXIMUM_STORAGE_DIFF = 50.0f; class SGP4xComponent; /// This class implements support for the Sensirion sgp4x i2c GAS (VOC) sensors. -class SGP4xComponent : public PollingComponent, public sensor::Sensor, public sensirion_common::SensirionI2CDevice { +class SGP4xComponent final : public PollingComponent, + public sensor::Sensor, + public sensirion_common::SensirionI2CDevice { enum ErrorCode { COMMUNICATION_FAILED, MEASUREMENT_INIT_FAILED, diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.h b/esphome/components/shelly_dimmer/shelly_dimmer.h index c6d0e20afe..e3ddd7f268 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.h +++ b/esphome/components/shelly_dimmer/shelly_dimmer.h @@ -12,7 +12,7 @@ namespace esphome::shelly_dimmer { -class ShellyDimmer : public PollingComponent, public light::LightOutput, public uart::UARTDevice { +class ShellyDimmer final : public PollingComponent, public light::LightOutput, public uart::UARTDevice { private: static constexpr uint16_t SHELLY_DIMMER_BUFFER_SIZE = 256; diff --git a/esphome/components/sht3xd/sht3xd.h b/esphome/components/sht3xd/sht3xd.h index 6df5587507..93663118e5 100644 --- a/esphome/components/sht3xd/sht3xd.h +++ b/esphome/components/sht3xd/sht3xd.h @@ -7,7 +7,7 @@ namespace esphome::sht3xd { /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. -class SHT3XDComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHT3XDComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index d1fa9033df..0d5723f72a 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -14,7 +14,7 @@ enum SHT4XHEATERPOWER { SHT4X_HEATERPOWER_HIGH, SHT4X_HEATERPOWER_MED, SHT4X_HEA enum SHT4XHEATERTIME : uint16_t { SHT4X_HEATERTIME_LONG = 1100, SHT4X_HEATERTIME_SHORT = 110 }; -class SHT4XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHT4XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/shtcx/shtcx.h b/esphome/components/shtcx/shtcx.h index a86b204e2b..ea50a084ef 100644 --- a/esphome/components/shtcx/shtcx.h +++ b/esphome/components/shtcx/shtcx.h @@ -13,7 +13,7 @@ enum SHTCXType : uint8_t { }; /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. -class SHTCXComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHTCXComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/shutdown/button/shutdown_button.h b/esphome/components/shutdown/button/shutdown_button.h index d4247ec0f9..4fc534030e 100644 --- a/esphome/components/shutdown/button/shutdown_button.h +++ b/esphome/components/shutdown/button/shutdown_button.h @@ -5,7 +5,7 @@ namespace esphome::shutdown { -class ShutdownButton : public button::Button, public Component { +class ShutdownButton final : public button::Button, public Component { public: void dump_config() override; diff --git a/esphome/components/shutdown/switch/shutdown_switch.h b/esphome/components/shutdown/switch/shutdown_switch.h index 933345915f..bb7fea7e03 100644 --- a/esphome/components/shutdown/switch/shutdown_switch.h +++ b/esphome/components/shutdown/switch/shutdown_switch.h @@ -5,7 +5,7 @@ namespace esphome::shutdown { -class ShutdownSwitch : public switch_::Switch, public Component { +class ShutdownSwitch final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/sigma_delta_output/sigma_delta_output.h b/esphome/components/sigma_delta_output/sigma_delta_output.h index a5df3c6c7c..71aedf9b07 100644 --- a/esphome/components/sigma_delta_output/sigma_delta_output.h +++ b/esphome/components/sigma_delta_output/sigma_delta_output.h @@ -6,7 +6,7 @@ namespace esphome::sigma_delta_output { -class SigmaDeltaOutput : public PollingComponent, public output::FloatOutput { +class SigmaDeltaOutput final : public PollingComponent, public output::FloatOutput { public: Trigger<> *get_turn_on_trigger() { if (!this->turn_on_trigger_) diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index 0b3259ede0..276131cfed 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -46,7 +46,7 @@ enum State { STATE_RECEIVED_USSD }; -class Sim800LComponent : public uart::UARTDevice, public PollingComponent { +class Sim800LComponent final : public uart::UARTDevice, public PollingComponent { public: /// Retrieve the latest sensor values. This operation takes approximately 16ms. void update() override; @@ -120,7 +120,7 @@ class Sim800LComponent : public uart::UARTDevice, public PollingComponent { CallbackManager ussd_received_callback_; }; -template class Sim800LSendSmsAction : public Action { +template class Sim800LSendSmsAction final : public Action { public: Sim800LSendSmsAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, recipient) @@ -136,7 +136,7 @@ template class Sim800LSendSmsAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LSendUssdAction : public Action { +template class Sim800LSendUssdAction final : public Action { public: Sim800LSendUssdAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, ussd) @@ -150,7 +150,7 @@ template class Sim800LSendUssdAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LDialAction : public Action { +template class Sim800LDialAction final : public Action { public: Sim800LDialAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, recipient) @@ -163,7 +163,7 @@ template class Sim800LDialAction : public Action { protected: Sim800LComponent *parent_; }; -template class Sim800LConnectAction : public Action { +template class Sim800LConnectAction final : public Action { public: Sim800LConnectAction(Sim800LComponent *parent) : parent_(parent) {} @@ -173,7 +173,7 @@ template class Sim800LConnectAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LDisconnectAction : public Action { +template class Sim800LDisconnectAction final : public Action { public: Sim800LDisconnectAction(Sim800LComponent *parent) : parent_(parent) {} diff --git a/esphome/components/slow_pwm/slow_pwm_output.h b/esphome/components/slow_pwm/slow_pwm_output.h index d866435af1..aa517a3bc5 100644 --- a/esphome/components/slow_pwm/slow_pwm_output.h +++ b/esphome/components/slow_pwm/slow_pwm_output.h @@ -6,7 +6,7 @@ namespace esphome::slow_pwm { -class SlowPWMOutput : public output::FloatOutput, public Component { +class SlowPWMOutput final : public output::FloatOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; }; void set_period(unsigned int period) { period_ = period; }; From eb9ca517e32d09e68979080bafe959c33af98aa0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:19:39 +1200 Subject: [PATCH 1825/2214] Mark configurable classes as final (14/21: rc522_i2c-scd4x) (#16965) --- esphome/components/rc522_i2c/rc522_i2c.h | 2 +- esphome/components/rc522_spi/rc522_spi.h | 6 +++--- esphome/components/rd03d/rd03d.h | 2 +- esphome/components/rdm6300/rdm6300.h | 6 +++--- esphome/components/remote_base/raw_protocol.h | 2 +- esphome/components/remote_base/remote_base.h | 2 +- .../remote_receiver/remote_receiver.h | 6 +++--- .../components/remote_transmitter/automation.h | 3 ++- .../remote_transmitter/remote_transmitter.h | 6 +++--- .../resampler/speaker/resampler_speaker.h | 2 +- .../components/resistance/resistance_sensor.h | 2 +- .../components/restart/switch/restart_switch.h | 2 +- esphome/components/rf_bridge/rf_bridge.h | 18 +++++++++--------- esphome/components/rgb/rgb_light_output.h | 2 +- esphome/components/rgbct/rgbct_light_output.h | 2 +- esphome/components/rgbw/rgbw_light_output.h | 2 +- esphome/components/rgbww/rgbww_light_output.h | 2 +- .../components/rotary_encoder/rotary_encoder.h | 4 ++-- .../components/router/speaker/router_speaker.h | 4 ++-- esphome/components/rp2040/gpio.h | 2 +- esphome/components/rp2040_ble/rp2040_ble.h | 2 +- .../rp2040_pio_led_strip/led_strip.h | 2 +- esphome/components/rp2040_pwm/rp2040_pwm.h | 4 ++-- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 2 +- esphome/components/rtttl/rtttl.h | 8 ++++---- .../components/runtime_stats/runtime_stats.h | 2 +- esphome/components/ruuvi_ble/ruuvi_ble.h | 2 +- esphome/components/ruuvitag/ruuvitag.h | 2 +- esphome/components/rx8130/rx8130.h | 6 +++--- esphome/components/safe_mode/automation.h | 2 +- .../safe_mode/switch/safe_mode_switch.h | 2 +- esphome/components/scd30/automation.h | 3 ++- esphome/components/scd30/scd30.h | 2 +- esphome/components/scd4x/automation.h | 5 +++-- esphome/components/scd4x/scd4x.h | 2 +- 35 files changed, 63 insertions(+), 60 deletions(-) diff --git a/esphome/components/rc522_i2c/rc522_i2c.h b/esphome/components/rc522_i2c/rc522_i2c.h index bd6f2269d8..9144241fe7 100644 --- a/esphome/components/rc522_i2c/rc522_i2c.h +++ b/esphome/components/rc522_i2c/rc522_i2c.h @@ -6,7 +6,7 @@ namespace esphome::rc522_i2c { -class RC522I2C : public rc522::RC522, public i2c::I2CDevice { +class RC522I2C final : public rc522::RC522, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/rc522_spi/rc522_spi.h b/esphome/components/rc522_spi/rc522_spi.h index 54caf5c117..2809718308 100644 --- a/esphome/components/rc522_spi/rc522_spi.h +++ b/esphome/components/rc522_spi/rc522_spi.h @@ -14,9 +14,9 @@ */ namespace esphome::rc522_spi { -class RC522Spi : public rc522::RC522, - public spi::SPIDevice { +class RC522Spi final : public rc522::RC522, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/rd03d/rd03d.h b/esphome/components/rd03d/rd03d.h index 8bf7b423be..e4ec6aafb2 100644 --- a/esphome/components/rd03d/rd03d.h +++ b/esphome/components/rd03d/rd03d.h @@ -37,7 +37,7 @@ struct TargetSensor { }; #endif -class RD03DComponent : public Component, public uart::UARTDevice { +class RD03DComponent final : public Component, public uart::UARTDevice { public: void setup() override; void loop() override; diff --git a/esphome/components/rdm6300/rdm6300.h b/esphome/components/rdm6300/rdm6300.h index f088f7de4c..4aa31b8d60 100644 --- a/esphome/components/rdm6300/rdm6300.h +++ b/esphome/components/rdm6300/rdm6300.h @@ -13,7 +13,7 @@ namespace esphome::rdm6300 { class RDM6300BinarySensor; class RDM6300Trigger; -class RDM6300Component : public Component, public uart::UARTDevice { +class RDM6300Component final : public Component, public uart::UARTDevice { public: void loop() override; @@ -28,7 +28,7 @@ class RDM6300Component : public Component, public uart::UARTDevice { uint32_t last_id_{0}; }; -class RDM6300BinarySensor : public binary_sensor::BinarySensorInitiallyOff { +class RDM6300BinarySensor final : public binary_sensor::BinarySensorInitiallyOff { public: void set_id(uint32_t id) { id_ = id; } @@ -46,7 +46,7 @@ class RDM6300BinarySensor : public binary_sensor::BinarySensorInitiallyOff { uint32_t id_; }; -class RDM6300Trigger : public Trigger { +class RDM6300Trigger final : public Trigger { public: void process(uint32_t uid) { this->trigger(uid); } }; diff --git a/esphome/components/remote_base/raw_protocol.h b/esphome/components/remote_base/raw_protocol.h index 1bcf390b62..f043d95eb4 100644 --- a/esphome/components/remote_base/raw_protocol.h +++ b/esphome/components/remote_base/raw_protocol.h @@ -31,7 +31,7 @@ class RawBinarySensor : public RemoteReceiverBinarySensorBase { size_t len_; }; -class RawTrigger : public Trigger, public Component, public RemoteReceiverListener { +class RawTrigger final : public Trigger, public Component, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { this->trigger(src.get_raw_data()); diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 0b1109267f..4e2ed4b71c 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -256,7 +256,7 @@ template class RemoteReceiverBinarySensor : public RemoteReceiverBin }; template -class RemoteReceiverTrigger : public Trigger, public RemoteReceiverListener { +class RemoteReceiverTrigger final : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { auto proto = T(); diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index cc707346eb..2ed6a4c251 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -55,11 +55,11 @@ struct RemoteReceiverComponentStore { }; #endif -class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, - public Component +class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, + public Component #if defined(USE_ESP32) && SOC_RMT_SUPPORTED , - public remote_base::RemoteRMTChannel + public remote_base::RemoteRMTChannel #endif { diff --git a/esphome/components/remote_transmitter/automation.h b/esphome/components/remote_transmitter/automation.h index 8da4cfd95d..a1b0926451 100644 --- a/esphome/components/remote_transmitter/automation.h +++ b/esphome/components/remote_transmitter/automation.h @@ -7,7 +7,8 @@ namespace esphome::remote_transmitter { -template class DigitalWriteAction : public Action, public Parented { +template +class DigitalWriteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, value) void play(const Ts &...x) override { this->parent_->digital_write(this->value_.value(x...)); } diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index d30966e3da..bcb07038ea 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -33,11 +33,11 @@ struct RemoteTransmitterComponentStore { #endif #endif -class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, - public Component +class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBase, + public Component #if defined(USE_ESP32) && SOC_RMT_SUPPORTED , - public remote_base::RemoteRMTChannel + public remote_base::RemoteRMTChannel #endif { public: diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index f482ce4b88..3255bf1fe8 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -14,7 +14,7 @@ namespace esphome::resampler { -class ResamplerSpeaker : public Component, public speaker::Speaker { +class ResamplerSpeaker final : public Component, public speaker::Speaker { public: float get_setup_priority() const override { return esphome::setup_priority::DATA; } void dump_config() override; diff --git a/esphome/components/resistance/resistance_sensor.h b/esphome/components/resistance/resistance_sensor.h index b646fb509a..ecb77795ff 100644 --- a/esphome/components/resistance/resistance_sensor.h +++ b/esphome/components/resistance/resistance_sensor.h @@ -10,7 +10,7 @@ enum ResistanceConfiguration { DOWNSTREAM, }; -class ResistanceSensor : public Component, public sensor::Sensor { +class ResistanceSensor final : public Component, public sensor::Sensor { public: void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_configuration(ResistanceConfiguration configuration) { configuration_ = configuration; } diff --git a/esphome/components/restart/switch/restart_switch.h b/esphome/components/restart/switch/restart_switch.h index 67b4a2bfd1..dc9ec8eadc 100644 --- a/esphome/components/restart/switch/restart_switch.h +++ b/esphome/components/restart/switch/restart_switch.h @@ -5,7 +5,7 @@ namespace esphome::restart { -class RestartSwitch : public switch_::Switch, public Component { +class RestartSwitch final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index 2f91459076..5ad75650ab 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -44,7 +44,7 @@ struct RFBridgeAdvancedData { std::string code; }; -class RFBridgeComponent : public uart::UARTDevice, public Component { +class RFBridgeComponent final : public uart::UARTDevice, public Component { public: void loop() override; void dump_config() override; @@ -76,7 +76,7 @@ class RFBridgeComponent : public uart::UARTDevice, public Component { CallbackManager advanced_data_callback_; }; -template class RFBridgeSendCodeAction : public Action { +template class RFBridgeSendCodeAction final : public Action { public: RFBridgeSendCodeAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, sync) @@ -97,7 +97,7 @@ template class RFBridgeSendCodeAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeSendAdvancedCodeAction : public Action { +template class RFBridgeSendAdvancedCodeAction final : public Action { public: RFBridgeSendAdvancedCodeAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint8_t, length) @@ -116,7 +116,7 @@ template class RFBridgeSendAdvancedCodeAction : public Action class RFBridgeLearnAction : public Action { +template class RFBridgeLearnAction final : public Action { public: RFBridgeLearnAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -126,7 +126,7 @@ template class RFBridgeLearnAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeStartAdvancedSniffingAction : public Action { +template class RFBridgeStartAdvancedSniffingAction final : public Action { public: RFBridgeStartAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -136,7 +136,7 @@ template class RFBridgeStartAdvancedSniffingAction : public Acti RFBridgeComponent *parent_; }; -template class RFBridgeStopAdvancedSniffingAction : public Action { +template class RFBridgeStopAdvancedSniffingAction final : public Action { public: RFBridgeStopAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -146,7 +146,7 @@ template class RFBridgeStopAdvancedSniffingAction : public Actio RFBridgeComponent *parent_; }; -template class RFBridgeStartBucketSniffingAction : public Action { +template class RFBridgeStartBucketSniffingAction final : public Action { public: RFBridgeStartBucketSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -156,7 +156,7 @@ template class RFBridgeStartBucketSniffingAction : public Action RFBridgeComponent *parent_; }; -template class RFBridgeSendRawAction : public Action { +template class RFBridgeSendRawAction final : public Action { public: RFBridgeSendRawAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, raw) @@ -167,7 +167,7 @@ template class RFBridgeSendRawAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeBeepAction : public Action { +template class RFBridgeBeepAction final : public Action { public: RFBridgeBeepAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, duration) diff --git a/esphome/components/rgb/rgb_light_output.h b/esphome/components/rgb/rgb_light_output.h index f0d599cf57..5893abf1d7 100644 --- a/esphome/components/rgb/rgb_light_output.h +++ b/esphome/components/rgb/rgb_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgb { -class RGBLightOutput : public light::LightOutput { +class RGBLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbct/rgbct_light_output.h b/esphome/components/rgbct/rgbct_light_output.h index 84ecb232cc..d6f7aaef78 100644 --- a/esphome/components/rgbct/rgbct_light_output.h +++ b/esphome/components/rgbct/rgbct_light_output.h @@ -7,7 +7,7 @@ namespace esphome::rgbct { -class RGBCTLightOutput : public light::LightOutput { +class RGBCTLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbw/rgbw_light_output.h b/esphome/components/rgbw/rgbw_light_output.h index ae96eb2024..a957104457 100644 --- a/esphome/components/rgbw/rgbw_light_output.h +++ b/esphome/components/rgbw/rgbw_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgbw { -class RGBWLightOutput : public light::LightOutput { +class RGBWLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbww/rgbww_light_output.h b/esphome/components/rgbww/rgbww_light_output.h index de5ee993f8..6608c65d9c 100644 --- a/esphome/components/rgbww/rgbww_light_output.h +++ b/esphome/components/rgbww/rgbww_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgbww { -class RGBWWLightOutput : public light::LightOutput { +class RGBWWLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rotary_encoder/rotary_encoder.h b/esphome/components/rotary_encoder/rotary_encoder.h index 8a56da4fe2..286267baed 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.h +++ b/esphome/components/rotary_encoder/rotary_encoder.h @@ -41,7 +41,7 @@ struct RotaryEncoderSensorStore { static void gpio_intr(RotaryEncoderSensorStore *arg); }; -class RotaryEncoderSensor : public sensor::Sensor, public Component { +class RotaryEncoderSensor final : public sensor::Sensor, public Component { public: void set_pin_a(InternalGPIOPin *pin_a) { pin_a_ = pin_a; } void set_pin_b(InternalGPIOPin *pin_b) { pin_b_ = pin_b; } @@ -106,7 +106,7 @@ class RotaryEncoderSensor : public sensor::Sensor, public Component { CallbackManager listeners_{}; }; -template class RotaryEncoderSetValueAction : public Action { +template class RotaryEncoderSetValueAction final : public Action { public: RotaryEncoderSetValueAction(RotaryEncoderSensor *encoder) : encoder_(encoder) {} TEMPLATABLE_VALUE(int, value) diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 13b58a1c72..801d0906ce 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -13,7 +13,7 @@ namespace esphome::router { -class Router : public Component, public speaker::Speaker { +class Router final : public Component, public speaker::Speaker { public: float get_setup_priority() const override { return setup_priority::DATA; } @@ -77,7 +77,7 @@ class Router : public Component, public speaker::Speaker { std::atomic active_output_idx_{0}; }; -template class SwitchOutputAction : public Action { +template class SwitchOutputAction final : public Action { public: explicit SwitchOutputAction(Router *parent) : parent_(parent) {} TEMPLATABLE_VALUE(speaker::Speaker *, target) diff --git a/esphome/components/rp2040/gpio.h b/esphome/components/rp2040/gpio.h index da97cff9b1..b9aa497b47 100644 --- a/esphome/components/rp2040/gpio.h +++ b/esphome/components/rp2040/gpio.h @@ -7,7 +7,7 @@ namespace esphome::rp2040 { -class RP2040GPIOPin : public InternalGPIOPin { +class RP2040GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 24b3860cc1..885e49f690 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -18,7 +18,7 @@ enum class BLEComponentState : uint8_t { DISABLED, }; -class RP2040BLE : public Component { +class RP2040BLE final : public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index ebc3bbbaa5..aaa5b0842d 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -57,7 +57,7 @@ inline const char *rgb_order_to_string(RGBOrder order) { using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); -class RP2040PIOLEDStripLightOutput : public light::AddressableLight { +class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index 58d3955a31..49980a7d76 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -9,7 +9,7 @@ namespace esphome::rp2040_pwm { -class RP2040PWM : public output::FloatOutput, public Component { +class RP2040PWM final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } @@ -39,7 +39,7 @@ class RP2040PWM : public output::FloatOutput, public Component { bool frequency_changed_{false}; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(RP2040PWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index 8b1457926c..df0e2b0b16 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -18,7 +18,7 @@ namespace esphome::rpi_dpi_rgb { constexpr static const char *const TAG = "rpi_dpi_rgb"; -class RpiDpiRgb : public display::Display { +class RpiDpiRgb final : public display::Display { public: void update() override { this->do_update_(); } void setup() override; diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index d060b6b024..256bdce5f2 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -27,7 +27,7 @@ enum class State : uint8_t { STOPPING, }; -class Rtttl : public Component { +class Rtttl final : public Component { public: #ifdef USE_OUTPUT void set_output(output::FloatOutput *output) { this->output_ = output; } @@ -116,7 +116,7 @@ class Rtttl : public Component { #endif }; -template class PlayAction : public Action { +template class PlayAction final : public Action { public: PlayAction(Rtttl *rtttl) : rtttl_(rtttl) {} TEMPLATABLE_VALUE(std::string, value) @@ -127,12 +127,12 @@ template class PlayAction : public Action { Rtttl *rtttl_; }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } }; diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 1e4910453a..2c783a0df3 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -17,7 +17,7 @@ namespace runtime_stats { static const char *const TAG = "runtime_stats"; -class RuntimeStatsCollector { +class RuntimeStatsCollector final { public: RuntimeStatsCollector(); diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.h b/esphome/components/ruuvi_ble/ruuvi_ble.h index 80b07d410b..e372b24944 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.h +++ b/esphome/components/ruuvi_ble/ruuvi_ble.h @@ -25,7 +25,7 @@ bool parse_ruuvi_data_byte(uint8_t data_type, const uint8_t *data, uint8_t data_ optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &device); -class RuuviListener : public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/ruuvitag/ruuvitag.h b/esphome/components/ruuvitag/ruuvitag.h index 259675835d..9602b82afc 100644 --- a/esphome/components/ruuvitag/ruuvitag.h +++ b/esphome/components/ruuvitag/ruuvitag.h @@ -9,7 +9,7 @@ namespace esphome::ruuvitag { -class RuuviTag : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviTag final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/rx8130/rx8130.h b/esphome/components/rx8130/rx8130.h index 152bd10f27..0c738a9b78 100644 --- a/esphome/components/rx8130/rx8130.h +++ b/esphome/components/rx8130/rx8130.h @@ -6,7 +6,7 @@ namespace esphome::rx8130 { -class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { +class RX8130Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -18,12 +18,12 @@ class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { void stop_(bool stop); }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts... x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts... x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/safe_mode/automation.h b/esphome/components/safe_mode/automation.h index 79b53c0881..e2858dff34 100644 --- a/esphome/components/safe_mode/automation.h +++ b/esphome/components/safe_mode/automation.h @@ -4,7 +4,7 @@ namespace esphome::safe_mode { -template class MarkSuccessfulAction : public Action, public Parented { +template class MarkSuccessfulAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->mark_successful(); } }; diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.h b/esphome/components/safe_mode/switch/safe_mode_switch.h index c73a2087d7..cbd79cd520 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.h +++ b/esphome/components/safe_mode/switch/safe_mode_switch.h @@ -6,7 +6,7 @@ namespace esphome::safe_mode { -class SafeModeSwitch : public switch_::Switch, public Component { +class SafeModeSwitch final : public switch_::Switch, public Component { public: void dump_config() override; void set_safe_mode(SafeModeComponent *safe_mode_component); diff --git a/esphome/components/scd30/automation.h b/esphome/components/scd30/automation.h index 1f04739893..a816ae1f26 100644 --- a/esphome/components/scd30/automation.h +++ b/esphome/components/scd30/automation.h @@ -6,7 +6,8 @@ namespace esphome::scd30 { -template class ForceRecalibrationWithReference : public Action, public Parented { +template +class ForceRecalibrationWithReference final : public Action, public Parented { public: void play(const Ts &...x) override { if (this->value_.has_value()) { diff --git a/esphome/components/scd30/scd30.h b/esphome/components/scd30/scd30.h index a5a5df1903..0605ab4175 100644 --- a/esphome/components/scd30/scd30.h +++ b/esphome/components/scd30/scd30.h @@ -7,7 +7,7 @@ namespace esphome::scd30 { /// This class implements support for the Sensirion scd30 i2c GAS (VOC and CO2eq) sensors. -class SCD30Component : public Component, public sensirion_common::SensirionI2CDevice { +class SCD30Component final : public Component, public sensirion_common::SensirionI2CDevice { public: void set_co2_sensor(sensor::Sensor *co2) { co2_sensor_ = co2; } void set_humidity_sensor(sensor::Sensor *humidity) { humidity_sensor_ = humidity; } diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h index e485289c95..4746c0c879 100644 --- a/esphome/components/scd4x/automation.h +++ b/esphome/components/scd4x/automation.h @@ -6,7 +6,8 @@ namespace esphome::scd4x { -template class PerformForcedCalibrationAction : public Action, public Parented { +template +class PerformForcedCalibrationAction final : public Action, public Parented { public: void play(const Ts &...x) override { if (this->value_.has_value()) { @@ -18,7 +19,7 @@ template class PerformForcedCalibrationAction : public Action class FactoryResetAction : public Action, public Parented { +template class FactoryResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->factory_reset(); } }; diff --git a/esphome/components/scd4x/scd4x.h b/esphome/components/scd4x/scd4x.h index 3e4827ef14..4d5dedb5e9 100644 --- a/esphome/components/scd4x/scd4x.h +++ b/esphome/components/scd4x/scd4x.h @@ -22,7 +22,7 @@ enum MeasurementMode : uint8_t { SINGLE_SHOT_RHT_ONLY, }; -class SCD4XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SCD4XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; From d511f0614d1c151313928b819c06b0f4d4e643da Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:19:51 +1200 Subject: [PATCH 1826/2214] Mark configurable classes as final (18/21: template-tx20) (#16969) --- esphome/components/template/lock/automation.h | 2 +- esphome/components/template/valve/automation.h | 2 +- .../template/water_heater/automation.h | 2 +- .../water_heater/template_water_heater.h | 2 +- esphome/components/text/automation.h | 4 ++-- .../text/text_sensor/text_text_sensor.h | 2 +- esphome/components/text_sensor/automation.h | 8 ++++---- esphome/components/thermopro_ble/thermopro_ble.h | 2 +- .../components/thermostat/thermostat_climate.h | 2 +- esphome/components/time/automation.h | 4 ++-- esphome/components/time/real_time_clock.h | 2 +- .../time_based/cover/time_based_cover.h | 2 +- esphome/components/tinyusb/tinyusb_component.h | 2 +- esphome/components/tlc59208f/tlc59208f_output.h | 4 ++-- .../components/tlc5947/output/tlc5947_output.h | 2 +- esphome/components/tlc5947/tlc5947.h | 2 +- .../components/tlc5971/output/tlc5971_output.h | 2 +- esphome/components/tlc5971/tlc5971.h | 2 +- esphome/components/tm1621/tm1621.h | 2 +- esphome/components/tm1637/tm1637.h | 4 ++-- .../components/tm1638/binary_sensor/tm1638_key.h | 2 +- .../components/tm1638/output/tm1638_output_led.h | 2 +- .../components/tm1638/switch/tm1638_switch_led.h | 2 +- esphome/components/tm1638/tm1638.h | 2 +- esphome/components/tm1651/tm1651.h | 12 ++++++------ esphome/components/tmp102/tmp102.h | 2 +- esphome/components/tmp1075/tmp1075.h | 2 +- esphome/components/tmp117/tmp117.h | 2 +- esphome/components/tof10120/tof10120_sensor.h | 2 +- esphome/components/tormatic/tormatic_cover.h | 2 +- esphome/components/toshiba/toshiba.h | 2 +- .../total_daily_energy/total_daily_energy.h | 2 +- .../binary_sensor/touchscreen_binary_sensor.h | 8 ++++---- esphome/components/tsl2561/tsl2561.h | 2 +- esphome/components/tsl2591/tsl2591.h | 2 +- .../tt21100/binary_sensor/tt21100_button.h | 8 ++++---- esphome/components/tt21100/touchscreen/tt21100.h | 2 +- esphome/components/ttp229_bsf/ttp229_bsf.h | 4 ++-- esphome/components/ttp229_lsf/ttp229_lsf.h | 4 ++-- esphome/components/tuya/automation.h | 16 ++++++++-------- .../tuya/binary_sensor/tuya_binary_sensor.h | 2 +- esphome/components/tuya/climate/tuya_climate.h | 2 +- esphome/components/tuya/cover/tuya_cover.h | 2 +- esphome/components/tuya/fan/tuya_fan.h | 2 +- esphome/components/tuya/light/tuya_light.h | 2 +- esphome/components/tuya/number/tuya_number.h | 2 +- esphome/components/tuya/select/tuya_select.h | 2 +- esphome/components/tuya/sensor/tuya_sensor.h | 2 +- esphome/components/tuya/switch/tuya_switch.h | 2 +- .../tuya/text_sensor/tuya_text_sensor.h | 2 +- esphome/components/tuya/tuya.h | 2 +- esphome/components/tx20/tx20.h | 2 +- 52 files changed, 79 insertions(+), 79 deletions(-) diff --git a/esphome/components/template/lock/automation.h b/esphome/components/template/lock/automation.h index 42a2a826e2..a979291b78 100644 --- a/esphome/components/template/lock/automation.h +++ b/esphome/components/template/lock/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { -template class TemplateLockPublishAction : public Action, public Parented { +template class TemplateLockPublishAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(lock::LockState, state) diff --git a/esphome/components/template/valve/automation.h b/esphome/components/template/valve/automation.h index a27e98b25c..ec9d784ab6 100644 --- a/esphome/components/template/valve/automation.h +++ b/esphome/components/template/valve/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { -template class TemplateValvePublishAction : public Action, public Parented { +template class TemplateValvePublishAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, position) TEMPLATABLE_VALUE(valve::ValveOperation, current_operation) diff --git a/esphome/components/template/water_heater/automation.h b/esphome/components/template/water_heater/automation.h index d19542db41..3301a15af1 100644 --- a/esphome/components/template/water_heater/automation.h +++ b/esphome/components/template/water_heater/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { template -class TemplateWaterHeaterPublishAction : public Action, public Parented { +class TemplateWaterHeaterPublishAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, current_temperature) TEMPLATABLE_VALUE(float, target_temperature) diff --git a/esphome/components/template/water_heater/template_water_heater.h b/esphome/components/template/water_heater/template_water_heater.h index 045a142e40..7d5f198553 100644 --- a/esphome/components/template/water_heater/template_water_heater.h +++ b/esphome/components/template/water_heater/template_water_heater.h @@ -13,7 +13,7 @@ enum TemplateWaterHeaterRestoreMode { WATER_HEATER_RESTORE_AND_CALL, }; -class TemplateWaterHeater : public Component, public water_heater::WaterHeater { +class TemplateWaterHeater final : public Component, public water_heater::WaterHeater { public: TemplateWaterHeater(); diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h index ac8166d0be..916d86340d 100644 --- a/esphome/components/text/automation.h +++ b/esphome/components/text/automation.h @@ -6,14 +6,14 @@ namespace esphome::text { -class TextStateTrigger : public Trigger { +class TextStateTrigger final : public Trigger { public: explicit TextStateTrigger(Text *parent) { parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -template class TextSetAction : public Action { +template class TextSetAction final : public Action { public: explicit TextSetAction(Text *text) : text_(text) {} TEMPLATABLE_VALUE(std::string, value) diff --git a/esphome/components/text/text_sensor/text_text_sensor.h b/esphome/components/text/text_sensor/text_text_sensor.h index fd70ea3451..59fa04a75e 100644 --- a/esphome/components/text/text_sensor/text_text_sensor.h +++ b/esphome/components/text/text_sensor/text_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::text { -class TextTextSensor : public text_sensor::TextSensor, public Component { +class TextTextSensor final : public text_sensor::TextSensor, public Component { public: explicit TextTextSensor(Text *source) : source_(source) {} void setup() override; diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h index ab30362774..628b9b84a0 100644 --- a/esphome/components/text_sensor/automation.h +++ b/esphome/components/text_sensor/automation.h @@ -8,21 +8,21 @@ namespace esphome::text_sensor { -class TextSensorStateTrigger : public Trigger { +class TextSensorStateTrigger final : public Trigger { public: explicit TextSensorStateTrigger(TextSensor *parent) { parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -class TextSensorStateRawTrigger : public Trigger { +class TextSensorStateRawTrigger final : public Trigger { public: explicit TextSensorStateRawTrigger(TextSensor *parent) { parent->add_on_raw_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -template class TextSensorStateCondition : public Condition { +template class TextSensorStateCondition final : public Condition { public: explicit TextSensorStateCondition(TextSensor *parent) : parent_(parent) {} @@ -34,7 +34,7 @@ template class TextSensorStateCondition : public Condition class TextSensorPublishAction : public Action { +template class TextSensorPublishAction final : public Action { public: TextSensorPublishAction(TextSensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(std::string, state) diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index 38bed82102..2d7523e07a 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -17,7 +17,7 @@ struct ParseResult { using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); -class ThermoProBLE : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; }; diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 4268d5c582..f30659a8a6 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -81,7 +81,7 @@ struct ThermostatCustomPresetEntry { ThermostatClimateTargetTempConfig config; }; -class ThermostatClimate : public climate::Climate, public Component { +class ThermostatClimate final : public climate::Climate, public Component { public: using PresetEntry = ThermostatPresetEntry; using CustomPresetEntry = ThermostatCustomPresetEntry; diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index 546c4a10de..7be195903a 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -10,7 +10,7 @@ namespace esphome::time { -class CronTrigger : public Trigger<>, public Component { +class CronTrigger final : public Trigger<>, public Component { public: explicit CronTrigger(RealTimeClock *rtc); void add_second(uint8_t second); @@ -41,7 +41,7 @@ class CronTrigger : public Trigger<>, public Component { optional last_check_; }; -class SyncTrigger : public Trigger<>, public Component { +class SyncTrigger final : public Trigger<>, public Component { public: explicit SyncTrigger(RealTimeClock *rtc); diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 06ee2ea5af..7a9175f39c 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -72,7 +72,7 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; -template class TimeHasTimeCondition : public Condition { +template class TimeHasTimeCondition final : public Condition { public: TimeHasTimeCondition(RealTimeClock *parent) : parent_(parent) {} bool check(const Ts &...x) override { return this->parent_->now().is_valid(); } diff --git a/esphome/components/time_based/cover/time_based_cover.h b/esphome/components/time_based/cover/time_based_cover.h index ce0b105ceb..1e1f51ff23 100644 --- a/esphome/components/time_based/cover/time_based_cover.h +++ b/esphome/components/time_based/cover/time_based_cover.h @@ -6,7 +6,7 @@ namespace esphome::time_based { -class TimeBasedCover : public cover::Cover, public Component { +class TimeBasedCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 7ec3da118c..e85fea9d21 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -20,7 +20,7 @@ enum USBDStringDescriptor : uint8_t { static const char *const DEFAULT_USB_STR = "ESPHome"; -class TinyUSB : public Component { +class TinyUSB final : public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tlc59208f/tlc59208f_output.h b/esphome/components/tlc59208f/tlc59208f_output.h index 46f88de01f..678e309f59 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.h +++ b/esphome/components/tlc59208f/tlc59208f_output.h @@ -21,7 +21,7 @@ inline constexpr uint8_t TLC59208F_MODE2_WDT_35MS = (3 << 0); class TLC59208FOutput; -class TLC59208FChannel : public output::FloatOutput, public Parented { +class TLC59208FChannel final : public output::FloatOutput, public Parented { public: void set_channel(uint8_t channel) { channel_ = channel; } @@ -34,7 +34,7 @@ class TLC59208FChannel : public output::FloatOutput, public Parented { +class TLC5947Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint16_t channel) { this->channel_ = channel; } diff --git a/esphome/components/tlc5947/tlc5947.h b/esphome/components/tlc5947/tlc5947.h index 18acffa25f..a9519d784f 100644 --- a/esphome/components/tlc5947/tlc5947.h +++ b/esphome/components/tlc5947/tlc5947.h @@ -9,7 +9,7 @@ namespace esphome::tlc5947 { -class TLC5947 : public Component { +class TLC5947 final : public Component { public: const uint8_t N_CHANNELS_PER_CHIP = 24; diff --git a/esphome/components/tlc5971/output/tlc5971_output.h b/esphome/components/tlc5971/output/tlc5971_output.h index 2a24a19b6c..fd0f5e82b5 100644 --- a/esphome/components/tlc5971/output/tlc5971_output.h +++ b/esphome/components/tlc5971/output/tlc5971_output.h @@ -8,7 +8,7 @@ namespace esphome::tlc5971 { -class TLC5971Channel : public output::FloatOutput, public Parented { +class TLC5971Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint16_t channel) { this->channel_ = channel; } diff --git a/esphome/components/tlc5971/tlc5971.h b/esphome/components/tlc5971/tlc5971.h index 080249c89c..75e4c57027 100644 --- a/esphome/components/tlc5971/tlc5971.h +++ b/esphome/components/tlc5971/tlc5971.h @@ -9,7 +9,7 @@ namespace esphome::tlc5971 { -class TLC5971 : public Component { +class TLC5971 final : public Component { public: const uint8_t N_CHANNELS_PER_CHIP = 12; diff --git a/esphome/components/tm1621/tm1621.h b/esphome/components/tm1621/tm1621.h index 7708ee6c98..806e69f993 100644 --- a/esphome/components/tm1621/tm1621.h +++ b/esphome/components/tm1621/tm1621.h @@ -11,7 +11,7 @@ class TM1621Display; using tm1621_writer_t = display::DisplayWriter; -class TM1621Display : public PollingComponent { +class TM1621Display final : public PollingComponent { public: void set_writer(tm1621_writer_t &&writer) { this->writer_ = writer; } diff --git a/esphome/components/tm1637/tm1637.h b/esphome/components/tm1637/tm1637.h index 1ad56ae75a..a3dd50fb37 100644 --- a/esphome/components/tm1637/tm1637.h +++ b/esphome/components/tm1637/tm1637.h @@ -21,7 +21,7 @@ class TM1637Key; using tm1637_writer_t = display::DisplayWriter; -class TM1637Display : public PollingComponent { +class TM1637Display final : public PollingComponent { public: void set_writer(tm1637_writer_t &&writer) { this->writer_ = writer; } @@ -92,7 +92,7 @@ class TM1637Display : public PollingComponent { }; #ifdef USE_BINARY_SENSOR -class TM1637Key : public binary_sensor::BinarySensor { +class TM1637Key final : public binary_sensor::BinarySensor { friend class TM1637Display; public: diff --git a/esphome/components/tm1638/binary_sensor/tm1638_key.h b/esphome/components/tm1638/binary_sensor/tm1638_key.h index fba1e43bde..1e6336a1f4 100644 --- a/esphome/components/tm1638/binary_sensor/tm1638_key.h +++ b/esphome/components/tm1638/binary_sensor/tm1638_key.h @@ -5,7 +5,7 @@ namespace esphome::tm1638 { -class TM1638Key : public binary_sensor::BinarySensor, public KeyListener { +class TM1638Key final : public binary_sensor::BinarySensor, public KeyListener { public: void set_keycode(uint8_t key_code) { key_code_ = key_code; }; void keys_update(uint8_t keys) override; diff --git a/esphome/components/tm1638/output/tm1638_output_led.h b/esphome/components/tm1638/output/tm1638_output_led.h index b1c1090447..e0bf5d31d9 100644 --- a/esphome/components/tm1638/output/tm1638_output_led.h +++ b/esphome/components/tm1638/output/tm1638_output_led.h @@ -6,7 +6,7 @@ namespace esphome::tm1638 { -class TM1638OutputLed : public output::BinaryOutput, public Component { +class TM1638OutputLed final : public output::BinaryOutput, public Component { public: void dump_config() override; diff --git a/esphome/components/tm1638/switch/tm1638_switch_led.h b/esphome/components/tm1638/switch/tm1638_switch_led.h index c7154eefb3..8df4678d62 100644 --- a/esphome/components/tm1638/switch/tm1638_switch_led.h +++ b/esphome/components/tm1638/switch/tm1638_switch_led.h @@ -6,7 +6,7 @@ namespace esphome::tm1638 { -class TM1638SwitchLed : public switch_::Switch, public Component { +class TM1638SwitchLed final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/tm1638/tm1638.h b/esphome/components/tm1638/tm1638.h index 24d49f4a9f..9ebea05089 100644 --- a/esphome/components/tm1638/tm1638.h +++ b/esphome/components/tm1638/tm1638.h @@ -20,7 +20,7 @@ class TM1638Component; using tm1638_writer_t = display::DisplayWriter; -class TM1638Component : public PollingComponent { +class TM1638Component final : public PollingComponent { public: void set_writer(tm1638_writer_t &&writer) { this->writer_ = writer; } void setup() override; diff --git a/esphome/components/tm1651/tm1651.h b/esphome/components/tm1651/tm1651.h index f1abbcc792..2021f90266 100644 --- a/esphome/components/tm1651/tm1651.h +++ b/esphome/components/tm1651/tm1651.h @@ -12,7 +12,7 @@ enum TM1651Brightness : uint8_t { TM1651_BRIGHTEST = 3, }; -class TM1651Display : public Component { +class TM1651Display final : public Component { public: void set_clk_pin(InternalGPIOPin *pin) { clk_pin_ = pin; } void set_dio_pin(InternalGPIOPin *pin) { dio_pin_ = pin; } @@ -56,7 +56,7 @@ class TM1651Display : public Component { uint8_t level_{0}; }; -template class SetBrightnessAction : public Action, public Parented { +template class SetBrightnessAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, brightness) @@ -66,7 +66,7 @@ template class SetBrightnessAction : public Action, publi } }; -template class SetLevelAction : public Action, public Parented { +template class SetLevelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level) @@ -76,7 +76,7 @@ template class SetLevelAction : public Action, public Par } }; -template class SetLevelPercentAction : public Action, public Parented { +template class SetLevelPercentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level_percent) @@ -86,12 +86,12 @@ template class SetLevelPercentAction : public Action, pub } }; -template class TurnOnAction : public Action, public Parented { +template class TurnOnAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->turn_on(); } }; -template class TurnOffAction : public Action, public Parented { +template class TurnOffAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->turn_off(); } }; diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index aedfefd052..f9eda32e98 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -6,7 +6,7 @@ namespace esphome::tmp102 { -class TMP102Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void dump_config() override; void update() override; diff --git a/esphome/components/tmp1075/tmp1075.h b/esphome/components/tmp1075/tmp1075.h index 4dc9449597..519d48ad29 100644 --- a/esphome/components/tmp1075/tmp1075.h +++ b/esphome/components/tmp1075/tmp1075.h @@ -52,7 +52,7 @@ enum EAlertFunction { ALERT_INTERRUPT = 1, }; -class TMP1075Sensor : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { +class TMP1075Sensor final : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/tmp117/tmp117.h b/esphome/components/tmp117/tmp117.h index a8fe7ac7ce..a42a14ca73 100644 --- a/esphome/components/tmp117/tmp117.h +++ b/esphome/components/tmp117/tmp117.h @@ -6,7 +6,7 @@ namespace esphome::tmp117 { -class TMP117Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TMP117Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tof10120/tof10120_sensor.h b/esphome/components/tof10120/tof10120_sensor.h index 8bf92b50a0..932b89ce49 100644 --- a/esphome/components/tof10120/tof10120_sensor.h +++ b/esphome/components/tof10120/tof10120_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tof10120 { -class TOF10120Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TOF10120Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; diff --git a/esphome/components/tormatic/tormatic_cover.h b/esphome/components/tormatic/tormatic_cover.h index 2a83213ffe..bde5525d10 100644 --- a/esphome/components/tormatic/tormatic_cover.h +++ b/esphome/components/tormatic/tormatic_cover.h @@ -9,7 +9,7 @@ namespace esphome::tormatic { using namespace esphome::cover; -class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingComponent { +class Tormatic final : public cover::Cover, public uart::UARTDevice, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index 4525d6bffe..a853730f31 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -23,7 +23,7 @@ const float TOSHIBA_RAC_PT1411HWRU_TEMP_F_MAX = 86.0; const float TOSHIBA_RAS_2819T_TEMP_C_MIN = 18.0; const float TOSHIBA_RAS_2819T_TEMP_C_MAX = 30.0; -class ToshibaClimate : public climate_ir::ClimateIR { +class ToshibaClimate final : public climate_ir::ClimateIR { public: ToshibaClimate() : climate_ir::ClimateIR(TOSHIBA_GENERIC_TEMP_C_MIN, TOSHIBA_GENERIC_TEMP_C_MAX, 1.0f, true, true, diff --git a/esphome/components/total_daily_energy/total_daily_energy.h b/esphome/components/total_daily_energy/total_daily_energy.h index 9a20ecea01..a683d43d0f 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.h +++ b/esphome/components/total_daily_energy/total_daily_energy.h @@ -14,7 +14,7 @@ enum TotalDailyEnergyMethod { TOTAL_DAILY_ENERGY_METHOD_RIGHT, }; -class TotalDailyEnergy : public sensor::Sensor, public Component { +class TotalDailyEnergy final : public sensor::Sensor, public Component { public: void set_restore(bool restore) { restore_ = restore; } void set_time(time::RealTimeClock *time) { time_ = time; } diff --git a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h index 2f86bc9749..f95c7a82b1 100644 --- a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h +++ b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h @@ -10,10 +10,10 @@ namespace esphome::touchscreen { -class TouchscreenBinarySensor : public binary_sensor::BinarySensor, - public Component, - public TouchListener, - public Parented { +class TouchscreenBinarySensor final : public binary_sensor::BinarySensor, + public Component, + public TouchListener, + public Parented { public: void setup() override; diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 0fbb59c648..8997d19f53 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -26,7 +26,7 @@ enum TSL2561Gain { }; /// This class includes support for the TSL2561 i2c ambient light sensor. -class TSL2561Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: /** Set the time that sensor values should be accumulated for. * diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 4b63c8ec40..3fde340412 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -63,7 +63,7 @@ enum TSL2591SensorChannel { /// light. They are reported as separate sensors, and the difference /// between the values is reported as a third sensor as a convenience /// for visible light only. -class TSL2591Component : public PollingComponent, public i2c::I2CDevice { +class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { public: /** Set device integration time and gain. * diff --git a/esphome/components/tt21100/binary_sensor/tt21100_button.h b/esphome/components/tt21100/binary_sensor/tt21100_button.h index a1f5946447..f4073caf3d 100644 --- a/esphome/components/tt21100/binary_sensor/tt21100_button.h +++ b/esphome/components/tt21100/binary_sensor/tt21100_button.h @@ -7,10 +7,10 @@ namespace esphome::tt21100 { -class TT21100Button : public binary_sensor::BinarySensor, - public Component, - public TT21100ButtonListener, - public Parented { +class TT21100Button final : public binary_sensor::BinarySensor, + public Component, + public TT21100ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tt21100/touchscreen/tt21100.h b/esphome/components/tt21100/touchscreen/tt21100.h index 3c6030c9c1..31af9085b5 100644 --- a/esphome/components/tt21100/touchscreen/tt21100.h +++ b/esphome/components/tt21100/touchscreen/tt21100.h @@ -16,7 +16,7 @@ class TT21100ButtonListener { virtual void update_button(uint8_t index, uint16_t state) = 0; }; -class TT21100Touchscreen : public Touchscreen, public i2c::I2CDevice { +class TT21100Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ttp229_bsf/ttp229_bsf.h b/esphome/components/ttp229_bsf/ttp229_bsf.h index 07f0c638c2..109764e51d 100644 --- a/esphome/components/ttp229_bsf/ttp229_bsf.h +++ b/esphome/components/ttp229_bsf/ttp229_bsf.h @@ -8,7 +8,7 @@ namespace esphome::ttp229_bsf { -class TTP229BSFChannel : public binary_sensor::BinarySensor { +class TTP229BSFChannel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint16_t data) { this->publish_state(data & (1 << this->channel_)); } @@ -17,7 +17,7 @@ class TTP229BSFChannel : public binary_sensor::BinarySensor { uint8_t channel_; }; -class TTP229BSFComponent : public Component { +class TTP229BSFComponent final : public Component { public: void set_sdo_pin(GPIOPin *sdo_pin) { sdo_pin_ = sdo_pin; } void set_scl_pin(GPIOPin *scl_pin) { scl_pin_ = scl_pin; } diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.h b/esphome/components/ttp229_lsf/ttp229_lsf.h index 09e7745d25..50e2baa7f7 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.h +++ b/esphome/components/ttp229_lsf/ttp229_lsf.h @@ -8,7 +8,7 @@ namespace esphome::ttp229_lsf { -class TTP229Channel : public binary_sensor::BinarySensor { +class TTP229Channel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint16_t data) { this->publish_state(data & (1 << this->channel_)); } @@ -17,7 +17,7 @@ class TTP229Channel : public binary_sensor::BinarySensor { uint8_t channel_; }; -class TTP229LSFComponent : public Component, public i2c::I2CDevice { +class TTP229LSFComponent final : public Component, public i2c::I2CDevice { public: void register_channel(TTP229Channel *channel) { this->channels_.push_back(channel); } void setup() override; diff --git a/esphome/components/tuya/automation.h b/esphome/components/tuya/automation.h index f5c806b013..0cd63a76be 100644 --- a/esphome/components/tuya/automation.h +++ b/esphome/components/tuya/automation.h @@ -8,44 +8,44 @@ namespace esphome::tuya { -class TuyaDatapointUpdateTrigger : public Trigger { +class TuyaDatapointUpdateTrigger final : public Trigger { public: explicit TuyaDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { this->trigger(dp); }); } }; -class TuyaRawDatapointUpdateTrigger : public Trigger> { +class TuyaRawDatapointUpdateTrigger final : public Trigger> { public: explicit TuyaRawDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaBoolDatapointUpdateTrigger : public Trigger { +class TuyaBoolDatapointUpdateTrigger final : public Trigger { public: explicit TuyaBoolDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaIntDatapointUpdateTrigger : public Trigger { +class TuyaIntDatapointUpdateTrigger final : public Trigger { public: explicit TuyaIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaUIntDatapointUpdateTrigger : public Trigger { +class TuyaUIntDatapointUpdateTrigger final : public Trigger { public: explicit TuyaUIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaStringDatapointUpdateTrigger : public Trigger { +class TuyaStringDatapointUpdateTrigger final : public Trigger { public: explicit TuyaStringDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaEnumDatapointUpdateTrigger : public Trigger { +class TuyaEnumDatapointUpdateTrigger final : public Trigger { public: explicit TuyaEnumDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaBitmaskDatapointUpdateTrigger : public Trigger { +class TuyaBitmaskDatapointUpdateTrigger final : public Trigger { public: explicit TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; diff --git a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h index f92652d087..76d7da4604 100644 --- a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h +++ b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaBinarySensor : public binary_sensor::BinarySensor, public Component { +class TuyaBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/climate/tuya_climate.h b/esphome/components/tuya/climate/tuya_climate.h index b9fb45257a..015da1930c 100644 --- a/esphome/components/tuya/climate/tuya_climate.h +++ b/esphome/components/tuya/climate/tuya_climate.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaClimate : public climate::Climate, public Component { +class TuyaClimate final : public climate::Climate, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/tuya/cover/tuya_cover.h b/esphome/components/tuya/cover/tuya_cover.h index ab63975683..fb38c81377 100644 --- a/esphome/components/tuya/cover/tuya_cover.h +++ b/esphome/components/tuya/cover/tuya_cover.h @@ -12,7 +12,7 @@ enum TuyaCoverRestoreMode { COVER_RESTORE_AND_CALL, }; -class TuyaCover : public cover::Cover, public Component { +class TuyaCover final : public cover::Cover, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/fan/tuya_fan.h b/esphome/components/tuya/fan/tuya_fan.h index bfb6bdeca0..70b127c10e 100644 --- a/esphome/components/tuya/fan/tuya_fan.h +++ b/esphome/components/tuya/fan/tuya_fan.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaFan : public Component, public fan::Fan { +class TuyaFan final : public Component, public fan::Fan { public: TuyaFan(Tuya *parent, int speed_count) : parent_(parent), speed_count_(speed_count) {} void setup() override; diff --git a/esphome/components/tuya/light/tuya_light.h b/esphome/components/tuya/light/tuya_light.h index d990eea72a..c921efc145 100644 --- a/esphome/components/tuya/light/tuya_light.h +++ b/esphome/components/tuya/light/tuya_light.h @@ -8,7 +8,7 @@ namespace esphome::tuya { enum TuyaColorType { RGB, HSV, RGBHSV }; -class TuyaLight : public Component, public light::LightOutput { +class TuyaLight final : public Component, public light::LightOutput { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/number/tuya_number.h b/esphome/components/tuya/number/tuya_number.h index 51c53a4442..a7289bb803 100644 --- a/esphome/components/tuya/number/tuya_number.h +++ b/esphome/components/tuya/number/tuya_number.h @@ -8,7 +8,7 @@ namespace esphome::tuya { -class TuyaNumber : public number::Number, public Component { +class TuyaNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/select/tuya_select.h b/esphome/components/tuya/select/tuya_select.h index f8d2d89ea8..4da01411b7 100644 --- a/esphome/components/tuya/select/tuya_select.h +++ b/esphome/components/tuya/select/tuya_select.h @@ -8,7 +8,7 @@ namespace esphome::tuya { -class TuyaSelect : public select::Select, public Component { +class TuyaSelect final : public select::Select, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/sensor/tuya_sensor.h b/esphome/components/tuya/sensor/tuya_sensor.h index b700fc8bd7..65f9dc599a 100644 --- a/esphome/components/tuya/sensor/tuya_sensor.h +++ b/esphome/components/tuya/sensor/tuya_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaSensor : public sensor::Sensor, public Component { +class TuyaSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/switch/tuya_switch.h b/esphome/components/tuya/switch/tuya_switch.h index 7e0109c34c..4cd137a6c8 100644 --- a/esphome/components/tuya/switch/tuya_switch.h +++ b/esphome/components/tuya/switch/tuya_switch.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaSwitch : public switch_::Switch, public Component { +class TuyaSwitch final : public switch_::Switch, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.h b/esphome/components/tuya/text_sensor/tuya_text_sensor.h index c9ac64deb8..2969bbf74b 100644 --- a/esphome/components/tuya/text_sensor/tuya_text_sensor.h +++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaTextSensor : public text_sensor::TextSensor, public Component { +class TuyaTextSensor final : public text_sensor::TextSensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index 470b97e7e7..4e7ab5c7f9 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -84,7 +84,7 @@ struct TuyaCommand { std::vector payload; }; -class Tuya : public Component, public uart::UARTDevice { +class Tuya final : public Component, public uart::UARTDevice { public: float get_setup_priority() const override { return setup_priority::LATE; } void setup() override; diff --git a/esphome/components/tx20/tx20.h b/esphome/components/tx20/tx20.h index 7ca29eaf3b..e4dc7dfab0 100644 --- a/esphome/components/tx20/tx20.h +++ b/esphome/components/tx20/tx20.h @@ -21,7 +21,7 @@ struct Tx20ComponentStore { }; /// This class implements support for the Tx20 Wind sensor. -class Tx20Component : public Component { +class Tx20Component final : public Component { public: /// Get the textual representation of the wind direction ('N', 'SSE', ..). std::string get_wind_cardinal_direction() const; From 64acb358a515d26ed2f4e4cad4d4745a110e7e36 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:20:00 +1200 Subject: [PATCH 1827/2214] Mark configurable classes as final (8/21: hm3301-integration) (#16959) --- esphome/components/hm3301/hm3301.h | 2 +- esphome/components/hmc5883l/hmc5883l.h | 2 +- .../binary_sensor/homeassistant_binary_sensor.h | 2 +- .../components/homeassistant/number/homeassistant_number.h | 2 +- .../components/homeassistant/sensor/homeassistant_sensor.h | 2 +- .../components/homeassistant/switch/homeassistant_switch.h | 2 +- .../homeassistant/text_sensor/homeassistant_text_sensor.h | 2 +- esphome/components/honeywell_hih_i2c/honeywell_hih.h | 2 +- esphome/components/honeywellabp/honeywellabp.h | 6 +++--- esphome/components/honeywellabp2_i2c/honeywellabp2.h | 2 +- esphome/components/host/gpio.h | 2 +- esphome/components/host/time/host_time.h | 2 +- esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h | 2 +- esphome/components/hte501/hte501.h | 2 +- esphome/components/http_request/http_request.h | 4 ++-- esphome/components/http_request/http_request_arduino.h | 2 +- esphome/components/http_request/http_request_host.h | 2 +- esphome/components/http_request/http_request_idf.h | 2 +- esphome/components/http_request/ota/automation.h | 2 +- esphome/components/htu21d/htu21d.h | 6 +++--- esphome/components/htu31d/htu31d.h | 2 +- esphome/components/hub75/hub75_component.h | 4 ++-- esphome/components/hx711/hx711.h | 2 +- esphome/components/hydreon_rgxx/hydreon_rgxx.h | 4 ++-- esphome/components/hyt271/hyt271.h | 2 +- esphome/components/i2c/i2c_bus_arduino.h | 2 +- esphome/components/i2c/i2c_bus_esp_idf.h | 2 +- esphome/components/i2c/i2c_bus_host.h | 2 +- esphome/components/i2c/i2c_bus_zephyr.h | 2 +- esphome/components/i2c_device/i2c_device.h | 2 +- esphome/components/i2s_audio/i2s_audio.h | 2 +- .../components/i2s_audio/microphone/i2s_audio_microphone.h | 2 +- .../i2s_audio/speaker/i2s_audio_speaker_standard.h | 2 +- esphome/components/iaqcore/iaqcore.h | 2 +- esphome/components/improv_serial/improv_serial_component.h | 2 +- esphome/components/ina219/ina219.h | 2 +- esphome/components/ina226/ina226.h | 2 +- esphome/components/ina260/ina260.h | 2 +- esphome/components/ina2xx_i2c/ina2xx_i2c.h | 2 +- esphome/components/ina2xx_spi/ina2xx_spi.h | 6 +++--- esphome/components/ina3221/ina3221.h | 2 +- .../components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h | 2 +- esphome/components/inkplate/inkplate.h | 2 +- esphome/components/integration/integration_sensor.h | 6 +++--- 44 files changed, 55 insertions(+), 55 deletions(-) diff --git a/esphome/components/hm3301/hm3301.h b/esphome/components/hm3301/hm3301.h index 55e708e34a..adbd8450ed 100644 --- a/esphome/components/hm3301/hm3301.h +++ b/esphome/components/hm3301/hm3301.h @@ -9,7 +9,7 @@ namespace esphome::hm3301 { static const uint8_t SELECT_COMM_CMD = 0x88; -class HM3301Component : public PollingComponent, public i2c::I2CDevice { +class HM3301Component final : public PollingComponent, public i2c::I2CDevice { public: HM3301Component() = default; diff --git a/esphome/components/hmc5883l/hmc5883l.h b/esphome/components/hmc5883l/hmc5883l.h index 4f170d7401..d23eb1a0f4 100644 --- a/esphome/components/hmc5883l/hmc5883l.h +++ b/esphome/components/hmc5883l/hmc5883l.h @@ -34,7 +34,7 @@ enum HMC5883LRange { HMC5883L_RANGE_810_UT = 0b111, }; -class HMC5883LComponent : public PollingComponent, public i2c::I2CDevice { +class HMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h index 6d95ea2c60..c713b143af 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantBinarySensor : public binary_sensor::BinarySensor, public Component { +class HomeassistantBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/homeassistant/number/homeassistant_number.h b/esphome/components/homeassistant/number/homeassistant_number.h index a1e351fdf4..c9673234ee 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.h +++ b/esphome/components/homeassistant/number/homeassistant_number.h @@ -6,7 +6,7 @@ namespace esphome::homeassistant { -class HomeassistantNumber : public number::Number, public Component { +class HomeassistantNumber final : public number::Number, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.h b/esphome/components/homeassistant/sensor/homeassistant_sensor.h index afc4935537..e787039ee3 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.h +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantSensor : public sensor::Sensor, public Component { +class HomeassistantSensor final : public sensor::Sensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.h b/esphome/components/homeassistant/switch/homeassistant_switch.h index c6c178c205..3dd1ab1525 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.h +++ b/esphome/components/homeassistant/switch/homeassistant_switch.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantSwitch : public switch_::Switch, public Component { +class HomeassistantSwitch final : public switch_::Switch, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void setup() override; diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h index 8af81cefcb..63ec136b57 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantTextSensor : public text_sensor::TextSensor, public Component { +class HomeassistantTextSensor final : public text_sensor::TextSensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.h b/esphome/components/honeywell_hih_i2c/honeywell_hih.h index d9ea6401ce..6d02044cfc 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.h +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.h @@ -7,7 +7,7 @@ namespace esphome::honeywell_hih_i2c { -class HoneywellHIComponent : public PollingComponent, public i2c::I2CDevice { +class HoneywellHIComponent final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/honeywellabp/honeywellabp.h b/esphome/components/honeywellabp/honeywellabp.h index 3c31968c49..067311b8d4 100644 --- a/esphome/components/honeywellabp/honeywellabp.h +++ b/esphome/components/honeywellabp/honeywellabp.h @@ -8,9 +8,9 @@ namespace esphome::honeywellabp { -class HONEYWELLABPSensor : public PollingComponent, - public spi::SPIDevice { +class HONEYWELLABPSensor final : public PollingComponent, + public spi::SPIDevice { public: void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.h b/esphome/components/honeywellabp2_i2c/honeywellabp2.h index 41ea21344b..70f435f8b0 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.h +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.h @@ -11,7 +11,7 @@ namespace esphome::honeywellabp2_i2c { enum ABP2TRANFERFUNCTION { ABP2_TRANS_FUNC_A = 0, ABP2_TRANS_FUNC_B = 1 }; -class HONEYWELLABP2Sensor : public PollingComponent, public i2c::I2CDevice { +class HONEYWELLABP2Sensor final : public PollingComponent, public i2c::I2CDevice { public: void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; }; void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }; diff --git a/esphome/components/host/gpio.h b/esphome/components/host/gpio.h index 6f2bccf102..bd2d09257b 100644 --- a/esphome/components/host/gpio.h +++ b/esphome/components/host/gpio.h @@ -6,7 +6,7 @@ namespace esphome::host { -class HostGPIOPin : public InternalGPIOPin { +class HostGPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/host/time/host_time.h b/esphome/components/host/time/host_time.h index 19e1af99d1..4462108b6d 100644 --- a/esphome/components/host/time/host_time.h +++ b/esphome/components/host/time/host_time.h @@ -5,7 +5,7 @@ namespace esphome::host { -class HostTime : public time::RealTimeClock { +class HostTime final : public time::RealTimeClock { public: void update() override {} }; diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h index e98eeea723..36346c8293 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h @@ -6,7 +6,7 @@ namespace esphome::hrxl_maxsonar_wr { -class HrxlMaxsonarWrComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class HrxlMaxsonarWrComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/hte501/hte501.h b/esphome/components/hte501/hte501.h index 310073f88b..403d3c1de6 100644 --- a/esphome/components/hte501/hte501.h +++ b/esphome/components/hte501/hte501.h @@ -7,7 +7,7 @@ namespace esphome::hte501 { /// This class implements support for the hte501 of temperature i2c sensors. -class HTE501Component : public PollingComponent, public i2c::I2CDevice { +class HTE501Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 2477e26bc1..5025a5c12d 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -311,7 +311,7 @@ inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer, return {HttpReadStatus::OK, 0}; } -class HttpRequestResponseTrigger : public Trigger, std::string &> { +class HttpRequestResponseTrigger final : public Trigger, std::string &> { public: void process(const std::shared_ptr &container, std::string &response_body) { this->trigger(container, response_body); @@ -447,7 +447,7 @@ class HttpRequestComponent : public Component { uint32_t watchdog_timeout_{0}; }; -template class HttpRequestSendAction : public Action { +template class HttpRequestSendAction final : public Action { public: HttpRequestSendAction(HttpRequestComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, url) diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index b009d45b1c..8da40798ec 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -46,7 +46,7 @@ class HttpContainerArduino : public HttpContainer { size_t chunk_remaining_{0}; ///< Bytes remaining in current chunk }; -class HttpRequestArduino : public HttpRequestComponent { +class HttpRequestArduino final : public HttpRequestComponent { public: #ifdef USE_ESP8266 void set_tls_buffer_size_rx(uint16_t size) { this->tls_buffer_size_rx_ = size; } diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 52be0e8a16..9045702f46 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -16,7 +16,7 @@ class HttpContainerHost : public HttpContainer { std::vector response_body_{}; }; -class HttpRequestHost : public HttpRequestComponent { +class HttpRequestHost final : public HttpRequestComponent { public: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::vector
&request_headers, diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 9ed1a97b1a..8a803b5469 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -26,7 +26,7 @@ class HttpContainerIDF : public HttpContainer { esp_http_client_handle_t client_; }; -class HttpRequestIDF : public HttpRequestComponent { +class HttpRequestIDF final : public HttpRequestComponent { public: void dump_config() override; diff --git a/esphome/components/http_request/ota/automation.h b/esphome/components/http_request/ota/automation.h index f6f49b14b1..487f6b70a1 100644 --- a/esphome/components/http_request/ota/automation.h +++ b/esphome/components/http_request/ota/automation.h @@ -5,7 +5,7 @@ namespace esphome::http_request { -template class OtaHttpRequestComponentFlashAction : public Action { +template class OtaHttpRequestComponentFlashAction final : public Action { public: OtaHttpRequestComponentFlashAction(OtaHttpRequestComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, md5_url) diff --git a/esphome/components/htu21d/htu21d.h b/esphome/components/htu21d/htu21d.h index a111722dc7..f86d62c5e8 100644 --- a/esphome/components/htu21d/htu21d.h +++ b/esphome/components/htu21d/htu21d.h @@ -9,7 +9,7 @@ namespace esphome::htu21d { enum HTU21DSensorModels { HTU21D_SENSOR_MODEL_HTU21D = 0, HTU21D_SENSOR_MODEL_SI7021, HTU21D_SENSOR_MODEL_SHT21 }; -class HTU21DComponent : public PollingComponent, public i2c::I2CDevice { +class HTU21DComponent final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -34,7 +34,7 @@ class HTU21DComponent : public PollingComponent, public i2c::I2CDevice { HTU21DSensorModels sensor_model_{HTU21D_SENSOR_MODEL_HTU21D}; }; -template class SetHeaterLevelAction : public Action, public Parented { +template class SetHeaterLevelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level) @@ -45,7 +45,7 @@ template class SetHeaterLevelAction : public Action, publ } }; -template class SetHeaterAction : public Action, public Parented { +template class SetHeaterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, status) diff --git a/esphome/components/htu31d/htu31d.h b/esphome/components/htu31d/htu31d.h index 451918cb3b..c25a979600 100644 --- a/esphome/components/htu31d/htu31d.h +++ b/esphome/components/htu31d/htu31d.h @@ -7,7 +7,7 @@ namespace esphome::htu31d { -class HTU31DComponent : public PollingComponent, public i2c::I2CDevice { +class HTU31DComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; /// Setup (reset) the sensor and check connection. void update() override; /// Update the sensor values (temperature+humidity). diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index ab7e3fc5b1..98bc2e52e6 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -16,7 +16,7 @@ namespace esphome::hub75 { using esphome::display::ColorBitness; using esphome::display::ColorOrder; -class HUB75Display : public display::Display { +class HUB75Display final : public display::Display { public: // Constructor accepting config explicit HUB75Display(const Hub75Config &config); @@ -51,7 +51,7 @@ class HUB75Display : public display::Display { bool enabled_{false}; }; -template class SetBrightnessAction : public Action, public Parented { +template class SetBrightnessAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, brightness) diff --git a/esphome/components/hx711/hx711.h b/esphome/components/hx711/hx711.h index 43ab4c0f56..62d0171d8f 100644 --- a/esphome/components/hx711/hx711.h +++ b/esphome/components/hx711/hx711.h @@ -14,7 +14,7 @@ enum HX711Gain : uint8_t { HX711_GAIN_64 = 3, }; -class HX711Sensor : public sensor::Sensor, public PollingComponent { +class HX711Sensor final : public sensor::Sensor, public PollingComponent { public: void set_dout_pin(GPIOPin *dout_pin) { dout_pin_ = dout_pin; } void set_sck_pin(GPIOPin *sck_pin) { sck_pin_ = sck_pin; } diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.h b/esphome/components/hydreon_rgxx/hydreon_rgxx.h index 2ae46907c1..a7bde6105c 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.h +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.h @@ -32,7 +32,7 @@ static const uint8_t NUM_SENSORS = 1; #define HYDREON_RGXX_IGNORE_LIST(F, SEP) F("Emitters") SEP F("Event") SEP F("Reset") -class HydreonRGxxComponent : public PollingComponent, public uart::UARTDevice { +class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDevice { public: void set_sensor(sensor::Sensor *sensor, int index) { this->sensors_[index] = sensor; } #ifdef USE_BINARY_SENSOR @@ -86,7 +86,7 @@ class HydreonRGxxComponent : public PollingComponent, public uart::UARTDevice { int sensors_received_ = -1; }; -class HydreonRGxxBinaryComponent : public Component { +class HydreonRGxxBinaryComponent final : public Component { public: HydreonRGxxBinaryComponent(HydreonRGxxComponent *parent) {} }; diff --git a/esphome/components/hyt271/hyt271.h b/esphome/components/hyt271/hyt271.h index d08b3779ad..b373c26466 100644 --- a/esphome/components/hyt271/hyt271.h +++ b/esphome/components/hyt271/hyt271.h @@ -6,7 +6,7 @@ namespace esphome::hyt271 { -class HYT271Component : public PollingComponent, public i2c::I2CDevice { +class HYT271Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index edc14af7bc..ded28dd80c 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -14,7 +14,7 @@ enum RecoveryCode { RECOVERY_COMPLETED, }; -class ArduinoI2CBus : public InternalI2CBus, public Component { +class ArduinoI2CBus final : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index c23f9f0c54..92e96f649b 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -14,7 +14,7 @@ enum RecoveryCode { RECOVERY_COMPLETED, }; -class IDFI2CBus : public InternalI2CBus, public Component { +class IDFI2CBus final : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h index 8e3aff7977..8064016a52 100644 --- a/esphome/components/i2c/i2c_bus_host.h +++ b/esphome/components/i2c/i2c_bus_host.h @@ -8,7 +8,7 @@ namespace esphome::i2c { -class HostI2CBus : public I2CBus, public Component { +class HostI2CBus final : public I2CBus, public Component { public: ~HostI2CBus() override; diff --git a/esphome/components/i2c/i2c_bus_zephyr.h b/esphome/components/i2c/i2c_bus_zephyr.h index 3c4aa9ed1d..3ada1e0a0f 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.h +++ b/esphome/components/i2c/i2c_bus_zephyr.h @@ -9,7 +9,7 @@ struct device; // NOLINT(readability-identifier-naming) - forward decl of Zephy namespace esphome::i2c { -class ZephyrI2CBus : public InternalI2CBus, public Component { +class ZephyrI2CBus final : public InternalI2CBus, public Component { public: explicit ZephyrI2CBus(const device *i2c_dev) : i2c_dev_(i2c_dev) {} void setup() override; diff --git a/esphome/components/i2c_device/i2c_device.h b/esphome/components/i2c_device/i2c_device.h index aeae622c2e..d5a49a2caa 100644 --- a/esphome/components/i2c_device/i2c_device.h +++ b/esphome/components/i2c_device/i2c_device.h @@ -5,7 +5,7 @@ namespace esphome::i2c_device { -class I2CDeviceComponent : public Component, public i2c::I2CDevice { +class I2CDeviceComponent final : public Component, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index 6b32b556d9..00a9705807 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -36,7 +36,7 @@ class I2SAudioIn : public I2SAudioBase {}; class I2SAudioOut : public I2SAudioBase {}; -class I2SAudioComponent : public Component { +class I2SAudioComponent final : public Component { public: i2s_std_gpio_config_t get_pin_config() const { return {.mclk = (gpio_num_t) this->mclk_pin_, diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 06f2de7610..65ad7df1af 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -14,7 +14,7 @@ namespace esphome::i2s_audio { -class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, public Component { +class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphone, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h index 7b7f8b647d..4b52dcd52a 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -14,7 +14,7 @@ enum class I2SCommFmt : uint8_t { /// @brief Standard I2S speaker implementation. /// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol. -class I2SAudioSpeaker : public I2SAudioSpeakerBase { +class I2SAudioSpeaker final : public I2SAudioSpeakerBase { public: void dump_config() override; diff --git a/esphome/components/iaqcore/iaqcore.h b/esphome/components/iaqcore/iaqcore.h index 39f290e120..6fdf9cbce8 100644 --- a/esphome/components/iaqcore/iaqcore.h +++ b/esphome/components/iaqcore/iaqcore.h @@ -6,7 +6,7 @@ namespace esphome::iaqcore { -class IAQCore : public PollingComponent, public i2c::I2CDevice { +class IAQCore final : public PollingComponent, public i2c::I2CDevice { public: void set_co2(sensor::Sensor *co2) { co2_ = co2; } void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 70f9214e2d..4df6f6df2d 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -44,7 +44,7 @@ enum ImprovSerialType : uint8_t { static const uint16_t IMPROV_SERIAL_TIMEOUT = 100; static const uint8_t IMPROV_SERIAL_VERSION = 1; -class ImprovSerialComponent : public Component, public improv_base::ImprovBase { +class ImprovSerialComponent final : public Component, public improv_base::ImprovBase { public: void setup() override; void loop() override; diff --git a/esphome/components/ina219/ina219.h b/esphome/components/ina219/ina219.h index 7462c07272..a78c1653f4 100644 --- a/esphome/components/ina219/ina219.h +++ b/esphome/components/ina219/ina219.h @@ -8,7 +8,7 @@ namespace esphome::ina219 { -class INA219Component : public PollingComponent, public i2c::I2CDevice { +class INA219Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina226/ina226.h b/esphome/components/ina226/ina226.h index 7d6b526f40..00d62fad76 100644 --- a/esphome/components/ina226/ina226.h +++ b/esphome/components/ina226/ina226.h @@ -40,7 +40,7 @@ union ConfigurationRegister { } __attribute__((packed)); }; -class INA226Component : public PollingComponent, public i2c::I2CDevice { +class INA226Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina260/ina260.h b/esphome/components/ina260/ina260.h index 856e715774..bbcb7a7acb 100644 --- a/esphome/components/ina260/ina260.h +++ b/esphome/components/ina260/ina260.h @@ -6,7 +6,7 @@ namespace esphome::ina260 { -class INA260Component : public PollingComponent, public i2c::I2CDevice { +class INA260Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina2xx_i2c/ina2xx_i2c.h b/esphome/components/ina2xx_i2c/ina2xx_i2c.h index 783723b396..d9945be5ef 100644 --- a/esphome/components/ina2xx_i2c/ina2xx_i2c.h +++ b/esphome/components/ina2xx_i2c/ina2xx_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ina2xx_i2c { -class INA2XXI2C : public ina2xx_base::INA2XX, public i2c::I2CDevice { +class INA2XXI2C final : public ina2xx_base::INA2XX, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina2xx_spi/ina2xx_spi.h b/esphome/components/ina2xx_spi/ina2xx_spi.h index 8e065de816..efe9cf257d 100644 --- a/esphome/components/ina2xx_spi/ina2xx_spi.h +++ b/esphome/components/ina2xx_spi/ina2xx_spi.h @@ -6,9 +6,9 @@ namespace esphome::ina2xx_spi { -class INA2XXSPI : public ina2xx_base::INA2XX, - public spi::SPIDevice { +class INA2XXSPI final : public ina2xx_base::INA2XX, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina3221/ina3221.h b/esphome/components/ina3221/ina3221.h index 9d9762caf3..48226c743a 100644 --- a/esphome/components/ina3221/ina3221.h +++ b/esphome/components/ina3221/ina3221.h @@ -6,7 +6,7 @@ namespace esphome::ina3221 { -class INA3221Component : public PollingComponent, public i2c::I2CDevice { +class INA3221Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h index 37e50943f3..4c90d6d35b 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h @@ -8,7 +8,7 @@ namespace esphome::inkbird_ibsth1_mini { -class InkbirdIbstH1Mini : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/inkplate/inkplate.h b/esphome/components/inkplate/inkplate.h index 40e32c4cc4..4f9f4109ee 100644 --- a/esphome/components/inkplate/inkplate.h +++ b/esphome/components/inkplate/inkplate.h @@ -31,7 +31,7 @@ static constexpr uint8_t LUTB[16] = {0xFF, 0xFD, 0xF7, 0xF5, 0xDF, 0xDD, 0xD7, 0 static constexpr uint8_t PIXEL_MASK_LUT[8] = {0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80}; static constexpr uint8_t PIXEL_MASK_GLUT[2] = {0x0F, 0xF0}; -class Inkplate : public display::DisplayBuffer, public i2c::I2CDevice { +class Inkplate final : public display::DisplayBuffer, public i2c::I2CDevice { public: void set_greyscale(bool greyscale) { this->greyscale_ = greyscale; diff --git a/esphome/components/integration/integration_sensor.h b/esphome/components/integration/integration_sensor.h index 1c5edfcba5..019c3ee074 100644 --- a/esphome/components/integration/integration_sensor.h +++ b/esphome/components/integration/integration_sensor.h @@ -22,7 +22,7 @@ enum IntegrationMethod { INTEGRATION_METHOD_RIGHT, }; -class IntegrationSensor : public sensor::Sensor, public Component { +class IntegrationSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; @@ -71,12 +71,12 @@ class IntegrationSensor : public sensor::Sensor, public Component { float last_value_{0.0f}; }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; -template class SetValueAction : public Action, public Parented { +template class SetValueAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, value) From e5d8c22b47ae2d15ba5ea84c4499d1e8b963332b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:20:11 +1200 Subject: [PATCH 1828/2214] Mark configurable classes as final (13/21: pmsa003i-rc522) (#16964) --- esphome/components/pmsa003i/pmsa003i.h | 2 +- esphome/components/pmsx003/pmsx003.h | 2 +- esphome/components/pmwcs3/pmwcs3.h | 8 +++---- esphome/components/pn532/pn532.h | 4 ++-- esphome/components/pn532_i2c/pn532_i2c.h | 2 +- esphome/components/pn532_spi/pn532_spi.h | 6 ++--- esphome/components/pn7150/automation.h | 22 +++++++++---------- esphome/components/pn7150_i2c/pn7150_i2c.h | 2 +- esphome/components/pn7160/automation.h | 22 +++++++++---------- esphome/components/pn7160_i2c/pn7160_i2c.h | 2 +- esphome/components/pn7160_spi/pn7160_spi.h | 6 ++--- .../components/power_supply/power_supply.h | 2 +- .../prometheus/prometheus_handler.h | 2 +- esphome/components/psram/psram.h | 2 +- esphome/components/pulse_counter/automation.h | 2 +- .../pulse_counter/pulse_counter_sensor.h | 2 +- esphome/components/pulse_meter/automation.h | 2 +- .../pulse_meter/pulse_meter_sensor.h | 2 +- esphome/components/pulse_width/pulse_width.h | 2 +- .../pvvx_mithermometer/display/pvvx_display.h | 2 +- .../pvvx_mithermometer/pvvx_mithermometer.h | 2 +- esphome/components/pylontech/pylontech.h | 2 +- .../pylontech/sensor/pylontech_sensor.h | 2 +- .../text_sensor/pylontech_text_sensor.h | 2 +- esphome/components/pzem004t/pzem004t.h | 2 +- esphome/components/pzemac/pzemac.h | 4 ++-- esphome/components/pzemdc/pzemdc.h | 4 ++-- esphome/components/qmc5883l/qmc5883l.h | 2 +- esphome/components/qmp6988/qmp6988.h | 2 +- esphome/components/qr_code/qr_code.h | 2 +- esphome/components/qspi_dbi/qspi_dbi.h | 6 ++--- esphome/components/qwiic_pir/qwiic_pir.h | 2 +- .../radon_eye_ble/radon_eye_listener.h | 2 +- .../radon_eye_rd200/radon_eye_rd200.h | 2 +- esphome/components/rc522/rc522.h | 4 ++-- 35 files changed, 68 insertions(+), 68 deletions(-) diff --git a/esphome/components/pmsa003i/pmsa003i.h b/esphome/components/pmsa003i/pmsa003i.h index aebe80b711..908b073be1 100644 --- a/esphome/components/pmsa003i/pmsa003i.h +++ b/esphome/components/pmsa003i/pmsa003i.h @@ -26,7 +26,7 @@ struct PM25AQIData { uint16_t checksum; ///< Packet checksum }; -class PMSA003IComponent : public PollingComponent, public i2c::I2CDevice { +class PMSA003IComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index d559f2dec0..c62960e7c3 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -29,7 +29,7 @@ enum class State : uint8_t { WAITING, }; -class PMSX003Component : public uart::UARTDevice, public Component { +class PMSX003Component final : public uart::UARTDevice, public Component { public: PMSX003Component() = default; void setup() override; diff --git a/esphome/components/pmwcs3/pmwcs3.h b/esphome/components/pmwcs3/pmwcs3.h index d669147819..4ce4a5ce9c 100644 --- a/esphome/components/pmwcs3/pmwcs3.h +++ b/esphome/components/pmwcs3/pmwcs3.h @@ -9,7 +9,7 @@ namespace esphome::pmwcs3 { -class PMWCS3Component : public PollingComponent, public i2c::I2CDevice { +class PMWCS3Component final : public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; @@ -32,7 +32,7 @@ class PMWCS3Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *vwc_sensor_{nullptr}; }; -template class PMWCS3AirCalibrationAction : public Action { +template class PMWCS3AirCalibrationAction final : public Action { public: PMWCS3AirCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} @@ -42,7 +42,7 @@ template class PMWCS3AirCalibrationAction : public Action PMWCS3Component *parent_; }; -template class PMWCS3WaterCalibrationAction : public Action { +template class PMWCS3WaterCalibrationAction final : public Action { public: PMWCS3WaterCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} @@ -52,7 +52,7 @@ template class PMWCS3WaterCalibrationAction : public Action class PMWCS3NewI2cAddressAction : public Action { +template class PMWCS3NewI2cAddressAction final : public Action { public: PMWCS3NewI2cAddressAction(PMWCS3Component *parent) : parent_(parent) {} TEMPLATABLE_VALUE(int, new_address) diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index a26f27ed54..629a697aa5 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -114,7 +114,7 @@ class PN532 : public PollingComponent { CallbackManager on_finished_write_callback_; }; -class PN532BinarySensor : public binary_sensor::BinarySensor { +class PN532BinarySensor final : public binary_sensor::BinarySensor { public: void set_uid(const nfc::NfcTagUid &uid) { uid_ = uid; } @@ -132,7 +132,7 @@ class PN532BinarySensor : public binary_sensor::BinarySensor { bool found_{false}; }; -template class PN532IsWritingCondition : public Condition, public Parented { +template class PN532IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; diff --git a/esphome/components/pn532_i2c/pn532_i2c.h b/esphome/components/pn532_i2c/pn532_i2c.h index b2a2ac2e18..6495f17599 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.h +++ b/esphome/components/pn532_i2c/pn532_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn532_i2c { -class PN532I2C : public pn532::PN532, public i2c::I2CDevice { +class PN532I2C final : public pn532::PN532, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn532_spi/pn532_spi.h b/esphome/components/pn532_spi/pn532_spi.h index 2bfd4accf7..f29950c423 100644 --- a/esphome/components/pn532_spi/pn532_spi.h +++ b/esphome/components/pn532_spi/pn532_spi.h @@ -8,9 +8,9 @@ namespace esphome::pn532_spi { -class PN532Spi : public pn532::PN532, - public spi::SPIDevice { +class PN532Spi final : public pn532::PN532, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/pn7150/automation.h b/esphome/components/pn7150/automation.h index 0b2e5f5d24..c3f8d3e5d3 100644 --- a/esphome/components/pn7150/automation.h +++ b/esphome/components/pn7150/automation.h @@ -6,40 +6,40 @@ namespace esphome::pn7150 { -template class PN7150IsWritingCondition : public Condition, public Parented { +template class PN7150IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; -template class EmulationOffAction : public Action, public Parented { +template class EmulationOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } }; -template class EmulationOnAction : public Action, public Parented { +template class EmulationOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } }; -template class PollingOffAction : public Action, public Parented { +template class PollingOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_off(); } }; -template class PollingOnAction : public Action, public Parented { +template class PollingOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_on(); } }; -template class SetCleanModeAction : public Action, public Parented { +template class SetCleanModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->clean_mode(); } }; -template class SetFormatModeAction : public Action, public Parented { +template class SetFormatModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->format_mode(); } }; -template class SetReadModeAction : public Action, public Parented { +template class SetReadModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->read_mode(); } }; -template class SetEmulationMessageAction : public Action, public Parented { +template class SetEmulationMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -49,7 +49,7 @@ template class SetEmulationMessageAction : public Action, } }; -template class SetWriteMessageAction : public Action, public Parented { +template class SetWriteMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -59,7 +59,7 @@ template class SetWriteMessageAction : public Action, pub } }; -template class SetWriteModeAction : public Action, public Parented { +template class SetWriteModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->write_mode(); } }; diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.h b/esphome/components/pn7150_i2c/pn7150_i2c.h index 2ea8c8f75c..25b0f3b855 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.h +++ b/esphome/components/pn7150_i2c/pn7150_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn7150_i2c { -class PN7150I2C : public pn7150::PN7150, public i2c::I2CDevice { +class PN7150I2C final : public pn7150::PN7150, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn7160/automation.h b/esphome/components/pn7160/automation.h index 7300c4a8d6..9f03a5a3d6 100644 --- a/esphome/components/pn7160/automation.h +++ b/esphome/components/pn7160/automation.h @@ -6,40 +6,40 @@ namespace esphome::pn7160 { -template class PN7160IsWritingCondition : public Condition, public Parented { +template class PN7160IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; -template class EmulationOffAction : public Action, public Parented { +template class EmulationOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } }; -template class EmulationOnAction : public Action, public Parented { +template class EmulationOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } }; -template class PollingOffAction : public Action, public Parented { +template class PollingOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_off(); } }; -template class PollingOnAction : public Action, public Parented { +template class PollingOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_on(); } }; -template class SetCleanModeAction : public Action, public Parented { +template class SetCleanModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->clean_mode(); } }; -template class SetFormatModeAction : public Action, public Parented { +template class SetFormatModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->format_mode(); } }; -template class SetReadModeAction : public Action, public Parented { +template class SetReadModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->read_mode(); } }; -template class SetEmulationMessageAction : public Action, public Parented { +template class SetEmulationMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -49,7 +49,7 @@ template class SetEmulationMessageAction : public Action, } }; -template class SetWriteMessageAction : public Action, public Parented { +template class SetWriteMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -59,7 +59,7 @@ template class SetWriteMessageAction : public Action, pub } }; -template class SetWriteModeAction : public Action, public Parented { +template class SetWriteModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->write_mode(); } }; diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.h b/esphome/components/pn7160_i2c/pn7160_i2c.h index d29fd04fac..2a3b767765 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.h +++ b/esphome/components/pn7160_i2c/pn7160_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn7160_i2c { -class PN7160I2C : public pn7160::PN7160, public i2c::I2CDevice { +class PN7160I2C final : public pn7160::PN7160, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn7160_spi/pn7160_spi.h b/esphome/components/pn7160_spi/pn7160_spi.h index 2d9c1fda11..4f22e5edec 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.h +++ b/esphome/components/pn7160_spi/pn7160_spi.h @@ -12,9 +12,9 @@ namespace esphome::pn7160_spi { static constexpr uint8_t TDD_SPI_READ = 0xFF; static constexpr uint8_t TDD_SPI_WRITE = 0x0A; -class PN7160Spi : public pn7160::PN7160, - public spi::SPIDevice { +class PN7160Spi final : public pn7160::PN7160, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/power_supply/power_supply.h b/esphome/components/power_supply/power_supply.h index e096f69e3b..eaf77af32e 100644 --- a/esphome/components/power_supply/power_supply.h +++ b/esphome/components/power_supply/power_supply.h @@ -7,7 +7,7 @@ namespace esphome::power_supply { -class PowerSupply : public Component { +class PowerSupply final : public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } void set_enable_time(uint32_t enable_time) { enable_time_ = enable_time; } diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 008081f586..bc256c6885 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -14,7 +14,7 @@ namespace esphome::prometheus { -class PrometheusHandler : public AsyncWebHandler, public Component { +class PrometheusHandler final : public AsyncWebHandler, public Component { public: PrometheusHandler(web_server_base::WebServerBase *base) : base_(base) {} diff --git a/esphome/components/psram/psram.h b/esphome/components/psram/psram.h index 22a49588b4..8549ef2959 100644 --- a/esphome/components/psram/psram.h +++ b/esphome/components/psram/psram.h @@ -6,7 +6,7 @@ namespace esphome::psram { -class PsramComponent : public Component { +class PsramComponent final : public Component { void dump_config() override; }; diff --git a/esphome/components/pulse_counter/automation.h b/esphome/components/pulse_counter/automation.h index 14264e87b3..380ef02304 100644 --- a/esphome/components/pulse_counter/automation.h +++ b/esphome/components/pulse_counter/automation.h @@ -6,7 +6,7 @@ namespace esphome::pulse_counter { -template class SetTotalPulsesAction : public Action { +template class SetTotalPulsesAction final : public Action { public: SetTotalPulsesAction(PulseCounterSensor *pulse_counter) : pulse_counter_(pulse_counter) {} diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.h b/esphome/components/pulse_counter/pulse_counter_sensor.h index 4f23ef1548..6704d3dc31 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.h +++ b/esphome/components/pulse_counter/pulse_counter_sensor.h @@ -59,7 +59,7 @@ struct HwPulseCounterStorage : public PulseCounterStorageBase { PulseCounterStorageBase *get_storage(bool hw_pcnt = false); -class PulseCounterSensor : public sensor::Sensor, public PollingComponent { +class PulseCounterSensor final : public sensor::Sensor, public PollingComponent { public: explicit PulseCounterSensor(bool hw_pcnt = false) : storage_(*get_storage(hw_pcnt)) {} diff --git a/esphome/components/pulse_meter/automation.h b/esphome/components/pulse_meter/automation.h index 1def89c3d3..885922a22a 100644 --- a/esphome/components/pulse_meter/automation.h +++ b/esphome/components/pulse_meter/automation.h @@ -6,7 +6,7 @@ namespace esphome::pulse_meter { -template class SetTotalPulsesAction : public Action { +template class SetTotalPulsesAction final : public Action { public: SetTotalPulsesAction(PulseMeterSensor *pulse_meter) : pulse_meter_(pulse_meter) {} diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.h b/esphome/components/pulse_meter/pulse_meter_sensor.h index 243a64bf05..9fc99a440b 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.h +++ b/esphome/components/pulse_meter/pulse_meter_sensor.h @@ -9,7 +9,7 @@ namespace esphome::pulse_meter { -class PulseMeterSensor : public sensor::Sensor, public Component { +class PulseMeterSensor final : public sensor::Sensor, public Component { public: enum InternalFilterMode { FILTER_EDGE = 0, diff --git a/esphome/components/pulse_width/pulse_width.h b/esphome/components/pulse_width/pulse_width.h index f77766a961..7a79b80678 100644 --- a/esphome/components/pulse_width/pulse_width.h +++ b/esphome/components/pulse_width/pulse_width.h @@ -26,7 +26,7 @@ class PulseWidthSensorStore { volatile uint32_t last_rise_{0}; }; -class PulseWidthSensor : public sensor::Sensor, public PollingComponent { +class PulseWidthSensor final : public sensor::Sensor, public PollingComponent { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void setup() override { this->store_.setup(this->pin_); } diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index e1aebae7a5..d231111c58 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -31,7 +31,7 @@ enum UNIT { using pvvx_writer_t = display::DisplayWriter; -class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { +class PVVXDisplay final : public ble_client::BLEClientNode, public PollingComponent { public: void set_writer(pvvx_writer_t &&writer) { this->writer_ = writer; } void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; } diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h index b5d6da21ef..382e41d210 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h @@ -18,7 +18,7 @@ struct ParseResult { int raw_offset; }; -class PVVXMiThermometer : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class PVVXMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index 1d86803cc2..eae5e6e7bc 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -21,7 +21,7 @@ class PylontechListener { virtual void dump_config(); }; -class PylontechComponent : public PollingComponent, public uart::UARTDevice { +class PylontechComponent final : public PollingComponent, public uart::UARTDevice { public: PylontechComponent(); diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 36576e8332..1403d3445d 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -5,7 +5,7 @@ namespace esphome::pylontech { -class PylontechSensor : public PylontechListener { +class PylontechSensor final : public PylontechListener { public: PylontechSensor(int8_t bat_num); void dump_config() override; diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h index 30921b13f4..3ba4f1fd4e 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::pylontech { -class PylontechTextSensor : public PylontechListener { +class PylontechTextSensor final : public PylontechListener { public: PylontechTextSensor(int8_t bat_num); void dump_config() override; diff --git a/esphome/components/pzem004t/pzem004t.h b/esphome/components/pzem004t/pzem004t.h index 71fc1e70ad..42135f0fbd 100644 --- a/esphome/components/pzem004t/pzem004t.h +++ b/esphome/components/pzem004t/pzem004t.h @@ -6,7 +6,7 @@ namespace esphome::pzem004t { -class PZEM004T : public PollingComponent, public uart::UARTDevice { +class PZEM004T final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index 264604fedc..a25a8cb631 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -11,7 +11,7 @@ namespace esphome::pzemac { template class ResetEnergyAction; -class PZEMAC : public PollingComponent, public modbus::ModbusDevice { +class PZEMAC final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } @@ -38,7 +38,7 @@ class PZEMAC : public PollingComponent, public modbus::ModbusDevice { void reset_energy_(); }; -template class ResetEnergyAction : public Action { +template class ResetEnergyAction final : public Action { public: ResetEnergyAction(PZEMAC *pzemac) : pzemac_(pzemac) {} diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index 6a7e840448..e398330cd3 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -9,7 +9,7 @@ namespace esphome::pzemdc { -class PZEMDC : public PollingComponent, public modbus::ModbusDevice { +class PZEMDC final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } @@ -31,7 +31,7 @@ class PZEMDC : public PollingComponent, public modbus::ModbusDevice { sensor::Sensor *energy_sensor_{nullptr}; }; -template class ResetEnergyAction : public Action { +template class ResetEnergyAction final : public Action { public: ResetEnergyAction(PZEMDC *pzemdc) : pzemdc_(pzemdc) {} diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index 6b8ffa0f40..faef423f8c 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -26,7 +26,7 @@ enum QMC5883LOversampling { QMC5883L_SAMPLING_64 = 0b11, }; -class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { +class QMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/qmp6988/qmp6988.h b/esphome/components/qmp6988/qmp6988.h index 41759478b8..ffea32eb18 100644 --- a/esphome/components/qmp6988/qmp6988.h +++ b/esphome/components/qmp6988/qmp6988.h @@ -67,7 +67,7 @@ using qmp6988_data_t = struct Qmp6988Data { qmp6988_ik_data_t ik; }; -class QMP6988Component : public PollingComponent, public i2c::I2CDevice { +class QMP6988Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/qr_code/qr_code.h b/esphome/components/qr_code/qr_code.h index ab4c587b6d..f8ca1660f6 100644 --- a/esphome/components/qr_code/qr_code.h +++ b/esphome/components/qr_code/qr_code.h @@ -13,7 +13,7 @@ class Display; } // namespace display namespace qr_code { -class QrCode : public Component { +class QrCode final : public Component { public: void draw(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color, int scale); diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index fa77cc5f76..8a1bf0d4c2 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -53,9 +53,9 @@ enum Model { RM67162, }; -class QspiDbi : public display::DisplayBuffer, - public spi::SPIDevice { +class QspiDbi final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_model(const char *model) { this->model_ = model; } void update() override; diff --git a/esphome/components/qwiic_pir/qwiic_pir.h b/esphome/components/qwiic_pir/qwiic_pir.h index 339632a508..8d3b8fb321 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.h +++ b/esphome/components/qwiic_pir/qwiic_pir.h @@ -29,7 +29,7 @@ enum DebounceMode { static const uint8_t QWIIC_PIR_DEVICE_ID = 0x72; -class QwiicPIRComponent : public Component, public i2c::I2CDevice, public binary_sensor::BinarySensor { +class QwiicPIRComponent final : public Component, public i2c::I2CDevice, public binary_sensor::BinarySensor { public: void setup() override; void loop() override; diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.h b/esphome/components/radon_eye_ble/radon_eye_listener.h index ceca736e78..30e3ccc1ea 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.h +++ b/esphome/components/radon_eye_ble/radon_eye_listener.h @@ -7,7 +7,7 @@ namespace esphome::radon_eye_ble { -class RadonEyeListener : public esp32_ble_tracker::ESPBTDeviceListener { +class RadonEyeListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h index 48e075c2d6..401402a137 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.h +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -13,7 +13,7 @@ namespace esphome::radon_eye_rd200 { -class RadonEyeRD200 : public PollingComponent, public ble_client::BLEClientNode { +class RadonEyeRD200 final : public PollingComponent, public ble_client::BLEClientNode { public: RadonEyeRD200(); diff --git a/esphome/components/rc522/rc522.h b/esphome/components/rc522/rc522.h index 45473e04b0..fd3c819696 100644 --- a/esphome/components/rc522/rc522.h +++ b/esphome/components/rc522/rc522.h @@ -251,7 +251,7 @@ class RC522 : public PollingComponent { } error_code_{NONE}; }; -class RC522BinarySensor : public binary_sensor::BinarySensor { +class RC522BinarySensor final : public binary_sensor::BinarySensor { public: void set_uid(const std::vector &uid) { uid_ = uid; } @@ -269,7 +269,7 @@ class RC522BinarySensor : public binary_sensor::BinarySensor { bool found_{false}; }; -class RC522Trigger : public Trigger { +class RC522Trigger final : public Trigger { public: void process(std::vector &data); }; From 46cf052ec5b677e93152169682c115f5a9ec2e7d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:28:43 +1200 Subject: [PATCH 1829/2214] [config_validation] Fix multicast typo in error message (#17206) --- esphome/config_validation.py | 4 +--- tests/unit_tests/test_config_validation.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0fdce85dc3..b77e22a6fb 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1494,9 +1494,7 @@ def ipv6address(value): def ipv4address_multi_broadcast(value): address = ipv4address(value) if not (address.is_multicast or (address == IPv4Address("255.255.255.255"))): - raise Invalid( - f"{value} is not a multicasst address nor local broadcast address" - ) + raise Invalid(f"{value} is not a multicast address nor local broadcast address") return address diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2715f9c644..ea3a4ecb53 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1926,7 +1926,7 @@ def test_ipv4address_multi_broadcast_broadcast() -> None: def test_ipv4address_multi_broadcast_invalid() -> None: - with pytest.raises(Invalid, match="not a multicasst"): + with pytest.raises(Invalid, match="not a multicast"): cv.ipv4address_multi_broadcast("192.168.0.1") From 29a610573037682afab5cd67b3f2321094392d0b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:31:15 +1200 Subject: [PATCH 1830/2214] [ms8607] Mark configurable classes as final (#17147) --- esphome/components/ms8607/ms8607.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/ms8607/ms8607.h b/esphome/components/ms8607/ms8607.h index 8f9cc9cb88..f2c4d65f13 100644 --- a/esphome/components/ms8607/ms8607.h +++ b/esphome/components/ms8607/ms8607.h @@ -10,7 +10,7 @@ namespace esphome::ms8607 { Class for I2CDevice used to communicate with the Humidity sensor on the chip. See MS8607Component instead */ -class MS8607HumidityDevice : public i2c::I2CDevice { +class MS8607HumidityDevice final : public i2c::I2CDevice { public: uint8_t get_address() { return address_; } }; @@ -30,9 +30,9 @@ class MS8607HumidityDevice : public i2c::I2CDevice { - https://github.com/adafruit/Adafruit_MS8607 - https://github.com/sparkfun/SparkFun_PHT_MS8607_Arduino_Library */ -class MS8607Component : public PollingComponent, public i2c::I2CDevice { +class MS8607Component final : public PollingComponent, public i2c::I2CDevice { public: - virtual ~MS8607Component() = default; + ~MS8607Component() = default; void setup() override; void update() override; void dump_config() override; From 4f70f6b2a6e5f4aef59eb2ee179e1aef199abd71 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:36:36 +1000 Subject: [PATCH 1831/2214] [mipi][mipi_spi] Swap native dimensions for swap_xy hardware transform (#17201) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 36 ++-- esphome/components/mipi_spi/display.py | 16 +- .../mipi_spi/test_padding_and_offsets.py | 167 +++++++++++++++++- 3 files changed, 191 insertions(+), 28 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 2244a316b7..1d6c8277e8 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -393,6 +393,16 @@ class DriverChip: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} return {CONF_MIRROR_X, CONF_MIRROR_Y} + def has_hardware_transform(self, config) -> bool: + """ + Check if the model supports hardware transforms for the given configuration. + """ + return config.get(CONF_TRANSFORM) != CONF_DISABLED and self.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + def option(self, name, fallback=False) -> cv.Optional: return cv.Optional(name, default=self.get_default(name, fallback)) @@ -423,10 +433,15 @@ class DriverChip: :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + transform = self.get_transform(config) if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] if isinstance(dimensions, dict): + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if transform.get(CONF_SWAP_XY) is True: + native_width, native_height = native_height, native_width width = dimensions[CONF_WIDTH] height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] @@ -434,23 +449,19 @@ class DriverChip: if CONF_PAD_WIDTH in dimensions: pad_width = dimensions[CONF_PAD_WIDTH] native_width = width + offset_width + pad_width + elif native_width == 0: + pad_width = 0 + native_width = width + offset_width else: - native_width = self.get_default(CONF_NATIVE_WIDTH, 0) - if native_width == 0: - pad_width = 0 - native_width = width + offset_width - else: - pad_width = native_width - width - offset_width + pad_width = native_width - width - offset_width if CONF_PAD_HEIGHT in dimensions: pad_height = dimensions[CONF_PAD_HEIGHT] native_height = height + offset_height + pad_height + elif native_height == 0: + pad_height = 0 + native_height = height + offset_height else: - native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) - if native_height == 0: - pad_height = 0 - native_height = height + offset_height - else: - pad_height = native_height - height - offset_height + pad_height = native_height - height - offset_height if ( pad_width + offset_width >= native_width or pad_height + offset_height >= native_height @@ -466,7 +477,6 @@ class DriverChip: return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults - transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 0231d12529..4162459058 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -295,13 +295,7 @@ def customise_schema(config): raise cv.Invalid(f"DC pin is required in {bus_mode} mode") denominator(config) model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) @@ -366,13 +360,7 @@ def get_instance(config): :return: type, template arguments """ model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, offset_width, offset_height, pad_width, pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index 82adf88b7e..7ae6f0e61f 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -13,6 +13,16 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32S3, ) +from esphome.components.mipi import ( + CONF_DIMENSIONS, + CONF_HEIGHT, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_HEIGHT, + CONF_OFFSET_WIDTH, + CONF_SWAP_XY, + CONF_WIDTH, +) from esphome.components.mipi_spi.display import ( CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, @@ -20,7 +30,13 @@ from esphome.components.mipi_spi.display import ( get_instance, ) from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE -from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.const import ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_DISABLED, + CONF_TRANSFORM, + PlatformFramework, +) from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -432,3 +448,152 @@ class TestUserConfiguredPadding: assert config["dimensions"]["width"] == 240 assert config["dimensions"]["height"] == 240 assert config["dimensions"]["pad_height"] == 16 + + +class TestHasHardwareTransform: + """Test DriverChip.has_hardware_transform().""" + + def test_full_transform_model_without_transform_key(self) -> None: + """A model supporting swap_xy uses a hardware transform by default.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({}) is True + + def test_full_transform_model_with_transform_dict(self) -> None: + """A configured (non-disabled) transform still uses the hardware path.""" + model = MODELS["ST7789V"] + assert ( + model.has_hardware_transform({CONF_TRANSFORM: {CONF_SWAP_XY: True}}) is True + ) + + def test_full_transform_model_with_transform_disabled(self) -> None: + """Disabling the transform falls back to software transforms.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({CONF_TRANSFORM: CONF_DISABLED}) is False + + def test_model_without_swap_xy_support(self) -> None: + """Models that cannot swap axes never use a hardware transform.""" + # AXS15231 only supports mirror_x/mirror_y, not swap_xy. + model = MODELS["AXS15231"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + assert model.has_hardware_transform({}) is False + + +class TestSwapXYNativeDimensions: + """Test that native dimensions are swapped when a swap_xy transform is active. + + When explicit dimensions are given in the swapped (rotated) orientation and the + model applies a hardware swap_xy transform, the model's native_width/native_height + defaults must be swapped to match, otherwise padding is computed against the wrong + axis and validation fails. + """ + + def test_explicit_swapped_dimensions_with_swap_xy_transform( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Explicit landscape dimensions on a portrait-native model with swap_xy.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ST7789V is natively 240x320 (portrait). Provide landscape dimensions + # together with a swap_xy transform. + model = MODELS["ST7789V"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 320, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + # swap=False because the buffer is laid out in the requested orientation. + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + # Native dims are swapped to 320x240, so padding works out to zero rather + # than going negative (which previously raised "Invalid offsets"). + assert (width, height) == (320, 240) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_explicit_dimensions_without_swap_keeps_native_orientation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Without swap_xy the native dimensions keep their original orientation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + model = MODELS["ST7789V"] + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 240, + CONF_HEIGHT: 320, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: False, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + assert (width, height) == (240, 320) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_swapped_native_dimensions_compute_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Padding is derived from the swapped native size when swap_xy is active.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ILI9341 is natively 240x320. Request a 300x240 area in landscape; the + # swapped native size is 320x240, leaving 20px of horizontal padding. + model = MODELS["ILI9341"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ILI9341", + CONF_DIMENSIONS: { + CONF_WIDTH: 300, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, _, _, pad_w, pad_h = model.get_dimensions(config, swap=False) + assert (width, height) == (300, 240) + # native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20 + assert pad_w == 20 + assert pad_h == 0 From 18c7f604108bfa7aa49afa905144e5e9c6f2f056 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:19:05 -0400 Subject: [PATCH 1832/2214] [uart] Validate fixed UART settings at config time for fixed-baud components (#17207) --- esphome/components/bl0940/sensor.py | 11 +++++++++++ esphome/components/midea/climate.py | 5 +++++ esphome/components/pzem004t/sensor.py | 4 ++++ esphome/components/rdm6300/__init__.py | 4 ++++ esphome/components/rf_bridge/__init__.py | 10 ++++++++++ esphome/components/rf_bridge/rf_bridge.cpp | 5 +---- esphome/components/sds011/sds011.cpp | 1 - esphome/components/sds011/sensor.py | 18 ++++++++++++++++++ esphome/components/senseair/senseair.cpp | 1 - esphome/components/senseair/sensor.py | 10 ++++++++++ esphome/components/shelly_dimmer/light.py | 4 ++++ esphome/components/sm300d2/sensor.py | 4 ++++ esphome/components/sm300d2/sm300d2.cpp | 1 - tests/components/bl0940/test.esp32-idf.yaml | 2 +- tests/components/bl0940/test.esp8266-ard.yaml | 2 +- tests/components/bl0940/test.rp2040-ard.yaml | 2 +- tests/components/rf_bridge/test.esp32-idf.yaml | 2 +- .../components/rf_bridge/test.esp8266-ard.yaml | 2 +- .../components/rf_bridge/test.rp2040-ard.yaml | 2 +- 19 files changed, 77 insertions(+), 13 deletions(-) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 992064943b..96445d5c38 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -211,6 +211,17 @@ CONFIG_SCHEMA = ( .add_extra(set_reference_values) ) +# BL0940 datasheet: 4800 baud, 8 data bits, no parity (stop bits are 1.5 -- not +# representable in the uart schema, so it isn't asserted). +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "bl0940", + baud_rate=4800, + data_bits=8, + parity="NONE", + require_rx=True, + require_tx=True, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index c954b45033..4a75464b90 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -260,6 +260,11 @@ async def power_inv_to_code(var, config, args): pass +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "midea", baud_rate=9600, require_rx=True, require_tx=True +) + + async def to_code(config): var = await climate.new_climate(config) await cg.register_component(var, config) diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index 51b1ab2d80..7e55fd9e7e 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -58,6 +58,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "pzem004t", baud_rate=9600, require_rx=True, require_tx=True +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rdm6300/__init__.py b/esphome/components/rdm6300/__init__.py index cbc54ad02b..a65213d576 100644 --- a/esphome/components/rdm6300/__init__.py +++ b/esphome/components/rdm6300/__init__.py @@ -29,6 +29,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "rdm6300", baud_rate=9600, require_rx=True +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 9ca47fe862..9863379b79 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -80,6 +80,16 @@ _CALLBACK_AUTOMATIONS = ( ), ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "rf_bridge", + baud_rate=19200, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index cec32e0406..549cce72df 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -195,10 +195,7 @@ void RFBridgeComponent::learn() { this->flush(); } -void RFBridgeComponent::dump_config() { - ESP_LOGCONFIG(TAG, "RF_Bridge:"); - this->check_uart_settings(19200); -} +void RFBridgeComponent::dump_config() { ESP_LOGCONFIG(TAG, "RF_Bridge:"); } void RFBridgeComponent::start_advanced_sniffing() { ESP_LOGI(TAG, "Advanced Sniffing on"); diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index b1f89f18bf..1c222e5e80 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -73,7 +73,6 @@ void SDS011Component::dump_config() { this->update_interval_min_, ONOFF(this->rx_mode_only_)); LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_); - this->check_uart_settings(9600); } void SDS011Component::loop() { diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index 76abc70bb7..2d7b6b07e5 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -63,6 +63,24 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config): + # In the default mode setup() writes config commands, so tx is required; + # rx_only mode never writes, so tx is optional. + uart.final_validate_device_schema( + "sds011", + baud_rate=9600, + require_rx=True, + require_tx=not config.get(CONF_RX_ONLY, False), + data_bits=8, + parity="NONE", + stop_bits=1, + )(config) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): # Pop update_interval before register_component so it doesn't generate # a set_update_interval call — sds011 handles this via set_update_interval_min diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index 8ed9fbb53b..0e8e4cef97 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -146,7 +146,6 @@ bool SenseAirComponent::senseair_write_command_(const uint8_t *command, uint8_t void SenseAirComponent::dump_config() { ESP_LOGCONFIG(TAG, "SenseAir:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); - this->check_uart_settings(9600); } } // namespace esphome::senseair diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index c5bef76741..277648137a 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -51,6 +51,16 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "senseair", + baud_rate=9600, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index ddf7fa161b..f2ab5a4bc1 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -186,6 +186,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "shelly_dimmer", baud_rate=115200, require_rx=True, require_tx=True +) + async def to_code(config): fw_hex = get_firmware(config[CONF_FIRMWARE]) diff --git a/esphome/components/sm300d2/sensor.py b/esphome/components/sm300d2/sensor.py index 60c9ccc40d..29e0cfe9b1 100644 --- a/esphome/components/sm300d2/sensor.py +++ b/esphome/components/sm300d2/sensor.py @@ -88,6 +88,10 @@ CONFIG_SCHEMA = cv.All( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "sm300d2", baud_rate=9600, require_rx=True, data_bits=8, parity="NONE", stop_bits=1 +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 391cc0ac11..882959a454 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -100,7 +100,6 @@ void SM300D2Sensor::dump_config() { LOG_SENSOR(" ", "PM10", this->pm_10_0_sensor_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); - this->check_uart_settings(9600); } } // namespace esphome::sm300d2 diff --git a/tests/components/bl0940/test.esp32-idf.yaml b/tests/components/bl0940/test.esp32-idf.yaml index 64baa4ec9d..e74af834d4 100644 --- a/tests/components/bl0940/test.esp32-idf.yaml +++ b/tests/components/bl0940/test.esp32-idf.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO14 packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/bl0940/test.esp8266-ard.yaml b/tests/components/bl0940/test.esp8266-ard.yaml index 89ca3ab5ae..f614b0a395 100644 --- a/tests/components/bl0940/test.esp8266-ard.yaml +++ b/tests/components/bl0940/test.esp8266-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO3 packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/bl0940/test.rp2040-ard.yaml b/tests/components/bl0940/test.rp2040-ard.yaml index b28f2b5e05..c8e2e3b55a 100644 --- a/tests/components/bl0940/test.rp2040-ard.yaml +++ b/tests/components/bl0940/test.rp2040-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO5 packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.esp32-idf.yaml b/tests/components/rf_bridge/test.esp32-idf.yaml index 2d29656c94..76222997a8 100644 --- a/tests/components/rf_bridge/test.esp32-idf.yaml +++ b/tests/components/rf_bridge/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.esp8266-ard.yaml b/tests/components/rf_bridge/test.esp8266-ard.yaml index 5a05efa259..aaedec5aaa 100644 --- a/tests/components/rf_bridge/test.esp8266-ard.yaml +++ b/tests/components/rf_bridge/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.rp2040-ard.yaml b/tests/components/rf_bridge/test.rp2040-ard.yaml index f1df2daf83..ed0cd431e3 100644 --- a/tests/components/rf_bridge/test.rp2040-ard.yaml +++ b/tests/components/rf_bridge/test.rp2040-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/rp2040-ard.yaml <<: !include common.yaml From 1d5490fd910b18565842e801f612b73de6bd60e2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:38:34 -0400 Subject: [PATCH 1833/2214] [modbus] Only apply turnaround delay after broadcasts (#17209) --- esphome/components/modbus/modbus.cpp | 16 ++++-- esphome/components/modbus/modbus.h | 1 + .../fixtures/uart_mock_modbus_broadcast.yaml | 56 +++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 25 +++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_broadcast.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 136fc73db6..c9ba2e837e 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -92,10 +92,14 @@ int32_t Modbus::tx_delay_remaining() { int32_t ModbusClientHub::tx_delay_remaining() { const uint32_t now = millis(); - return std::max({(int32_t) 0, - (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - - (now - this->last_send_)), - (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); + // Turnaround delay only applies after a broadcast: no response is expected, so we must give listening devices + // quiet time to process it before the next request. For normal unicast request/response the received reply already + // provides the inter-frame timing, so adding turnaround there just throttles throughput. + const uint16_t turnaround = this->last_send_was_broadcast_ ? this->turnaround_delay_ms_ : 0; + return std::max( + {(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + turnaround - (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ + turnaround - (now - this->last_modbus_byte_))}); } bool Modbus::tx_blocked() { @@ -396,6 +400,7 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; + this->last_send_was_broadcast_ = frame.size > 0 && frame.data[0] == 0; return true; } @@ -411,7 +416,8 @@ void ModbusClientHub::send_next_frame_() { ModbusDeviceCommand &command = this->tx_buffer_.front(); if (this->send_frame_(command.frame)) { - this->waiting_for_response_ = std::move(command); + if (!this->last_send_was_broadcast_) + this->waiting_for_response_ = std::move(command); } else { if (command.device) command.device->on_modbus_not_sent(); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 86337442c6..da0db13a07 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -63,6 +63,7 @@ class Modbus : public uart::UARTDevice, public Component { uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; + bool last_send_was_broadcast_{false}; uint16_t frame_delay_ms_{5}; uint16_t long_rx_buffer_delay_ms_{0}; diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml new file mode 100644 index 0000000000..a5ce02b342 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml @@ -0,0 +1,56 @@ +esphome: + name: uart-mock-modbus-bcast + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# No on_tx injection: a broadcast (address 0) gets no reply on a real bus. +uart_mock: + - id: virtual_uart + baud_rate: 9600 + auto_start: true + debug: + +modbus: + - uart_id: virtual_uart + id: virtual_modbus + role: client + send_wait_time: 200ms + turnaround_time: 10ms + +modbus_controller: + - address: 0 + modbus_id: virtual_modbus + update_interval: 60s + id: modbus_controller_bcast + +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_bcast + id: bcast_write + name: "bcast_write" + address: 0x01 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 65535 + +interval: + - interval: 400ms + then: + - number.set: + id: bcast_write + value: 42 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 2c437341c6..385707d849 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -330,3 +330,28 @@ async def test_uart_mock_modbus_server_controller_multiple( await tracker.setup_and_start_scenario(client) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_broadcast( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that broadcast writes (address 0) don't wait for a response. + + A controller at address 0 sends broadcast writes that get no reply. The + client must not arm the response timeout for them: otherwise every write + blocks for send_wait_time and logs a spurious "no response from 0" warning. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected(), + ): + # Several broadcast writes fire on the 400ms interval; send_wait_time is + # 200ms, so the old behaviour would have warned on each one by now. + await asyncio.sleep(3.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) From 6f36ce6429f690811e9d8b872eadc005cf521251 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 25 Jun 2026 12:35:15 -0400 Subject: [PATCH 1834/2214] [openthread] Provide action to control poll_period when device MTD (#11766) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- esphome/components/openthread/__init__.py | 37 ++++++++++- esphome/components/openthread/automation.cpp | 37 +++++++++++ esphome/components/openthread/automation.h | 61 +++++++++++++++++++ esphome/components/openthread/openthread.cpp | 29 +++++++++ esphome/components/openthread/openthread.h | 13 ++++ .../components/openthread/openthread_esp.cpp | 27 +------- tests/components/openthread/common.yaml | 2 + .../openthread/test-tlv.esp32-c6-idf.yaml | 20 ++++++ .../openthread/test.esp32-c6-idf.yaml | 13 +--- 9 files changed, 200 insertions(+), 39 deletions(-) create mode 100644 esphome/components/openthread/automation.cpp create mode 100644 esphome/components/openthread/automation.h create mode 100644 tests/components/openthread/common.yaml create mode 100644 tests/components/openthread/test-tlv.esp32-c6-idf.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 2dc8a783df..b54fe2b218 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,4 @@ +from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import ( VARIANT_ESP32C5, @@ -226,11 +227,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FORCE_DATASET): cv.boolean, cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, - cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, cv.Optional(CONF_OUTPUT_POWER): cv.All( cv.decibel, _validate_txpower, ), + cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), @@ -309,3 +310,37 @@ async def to_code(config): ) zephyr_add_prj_conf(f"OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) zephyr_add_prj_conf("MAIN_STACK_SIZE", 4096) + + +# Actions +OpenThreadComponentPollPeriodAction = openthread_ns.class_( + "OpenThreadComponentPollPeriodAction", + automation.Action, + cg.Parented.template(OpenThreadComponent), +) + +POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( + CONF_POLL_PERIOD, + cv.Schema( + { + cv.GenerateID(): cv.use_id(OpenThreadComponent), + cv.Required(CONF_POLL_PERIOD): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), +) + + +@automation.register_action( + "openthread.set_poll_period", + OpenThreadComponentPollPeriodAction, + POLL_PERIOD_ACTION_SCHEMA, + synchronous=True, +) +async def openthread_poll_period_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = await cg.templatable(config[CONF_POLL_PERIOD], args, cg.uint32) + cg.add(var.set_poll_period(template_)) + return var diff --git a/esphome/components/openthread/automation.cpp b/esphome/components/openthread/automation.cpp new file mode 100644 index 0000000000..770bf124c5 --- /dev/null +++ b/esphome/components/openthread/automation.cpp @@ -0,0 +1,37 @@ +#include "esphome/core/defines.h" + +#ifdef USE_OPENTHREAD + +#include "automation.h" +#include "esphome/core/log.h" + +namespace esphome::openthread { + +static const char *const TAG = "openthread.automation"; + +void OpenThreadComponentBaseAction::warn_ftd_no_op_() { + ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); +} + +void OpenThreadComponentBaseAction::lock_and_apply_() { + if (this->parent_->is_ready()) { + if (auto lock = InstanceLock::try_acquire(LOCK_ACQUIRE_TIMEOUT_MS); lock) { + if (auto *instance = lock.get_instance(); instance != nullptr) { + this->apply_locked(instance); + } + } else { + ESP_LOGW(TAG, "Failed to acquire lock in action"); + } + } else { + // Action may trigger early before setup, e.g. due to enabled "restore mode". + // Trying to acquire lock would fail! + // + // But default component values already have been overwritten. + // It is sufficient to let component apply those later during setup. + ESP_LOGD(TAG, "Not (yet) ready to apply"); + } +} + +} // namespace esphome::openthread + +#endif diff --git a/esphome/components/openthread/automation.h b/esphome/components/openthread/automation.h new file mode 100644 index 0000000000..3706499fda --- /dev/null +++ b/esphome/components/openthread/automation.h @@ -0,0 +1,61 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_OPENTHREAD +#include "openthread.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +namespace esphome::openthread { + +/** Base class allowing to fetch OpenThread lock from parent component + * while applying action + * + * - Nontemplate aspects belong here to avoid template bloat. + * - Subclasses implement virtual action method that is called under lock. + * - Seal leaf subclasses via @a final to support devirtualization. + */ +class OpenThreadComponentBaseAction : public Parented { + public: + // Enforce ctor with parent argument (not without args) + explicit OpenThreadComponentBaseAction(OpenThreadComponent *ot) : Parented(ot) {} + + protected: + /** Handler to implement in subclass for applying action parts that need lock */ + virtual void apply_locked(otInstance *instance) = 0; + + /** Fetch OT lock and then call @a apply_locked */ + void lock_and_apply_(); + + /** Log a warning that this action has no effect on FTD devices */ + void warn_ftd_no_op_(); + + /** Timeout (ms) for acquiring OT lock */ + static constexpr uint32_t LOCK_ACQUIRE_TIMEOUT_MS = 100; +}; + +/** Action to set single poll period parameter */ +template +class OpenThreadComponentPollPeriodAction final : public Action, public OpenThreadComponentBaseAction { + TEMPLATABLE_VALUE(uint32_t, poll_period) + + public: + /* Passthrough ctor */ + using OpenThreadComponentBaseAction::OpenThreadComponentBaseAction; + + protected: + void play(const Ts &...x) override { +#if CONFIG_OPENTHREAD_MTD + this->parent_->set_poll_period(this->poll_period_.value(x...)); + + this->lock_and_apply_(); +#else + this->warn_ftd_no_op_(); +#endif + } + + void apply_locked(otInstance *instance) override { this->parent_->apply_linkmode_(instance); } +}; + +} // namespace esphome::openthread +#endif diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 102424c62e..8bfc16b2e0 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -266,5 +266,34 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); } +void OpenThreadComponent::apply_linkmode_(otInstance *instance) { + otLinkModeConfig link_mode_config{}; +#if CONFIG_OPENTHREAD_FTD + link_mode_config.mRxOnWhenIdle = true; + link_mode_config.mDeviceType = true; + link_mode_config.mNetworkData = true; +#elif CONFIG_OPENTHREAD_MTD + if (this->poll_period_ > 0) { + if (otLinkSetPollPeriod(instance, this->poll_period_) != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set pollperiod"); + } + ESP_LOGD(TAG, "Link Polling Period: %" PRIu32, otLinkGetPollPeriod(instance)); + } + link_mode_config.mRxOnWhenIdle = this->poll_period_ == 0; + link_mode_config.mDeviceType = false; + link_mode_config.mNetworkData = false; +#endif + + if (otThreadSetLinkMode(instance, link_mode_config) != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set linkmode"); + } +#ifdef ESPHOME_LOG_HAS_DEBUG // Fetch link mode from OT only when DEBUG + link_mode_config = otThreadGetLinkMode(instance); + ESP_LOGD(TAG, "Link Mode Device Type: %s, Network Data: %s, RX On When Idle: %s", + TRUEFALSE(link_mode_config.mDeviceType), TRUEFALSE(link_mode_config.mNetworkData), + TRUEFALSE(link_mode_config.mRxOnWhenIdle)); +#endif +} + } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 488aad1166..a96941325c 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -19,6 +19,8 @@ namespace esphome::openthread { class InstanceLock; +template class OpenThreadComponentPollPeriodAction; + class OpenThreadComponent final : public Component { public: OpenThreadComponent(); @@ -41,12 +43,23 @@ class OpenThreadComponent final : public Component { void set_use_address(const char *use_address) { this->use_address_ = use_address; } #if CONFIG_OPENTHREAD_MTD void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } + uint32_t get_poll_period() const { return this->poll_period_; } #endif void set_output_power(int8_t output_power) { this->output_power_ = output_power; } void set_connected(bool connected) { this->connected_ = connected; } static void on_state_changed(otChangedFlags flags, void *context); protected: + // Actions re-apply link mode under the OT lock; allow them to call apply_linkmode_() + // without exposing this lock-sensitive, raw-instance method on the public API. + template friend class OpenThreadComponentPollPeriodAction; + + /** Apply Link Mode settings (incl poll period). + * Callers running outside the OpenThread task must hold InstanceLock. + * ot_main() runs on the OpenThread task itself and must not acquire the lock. + */ + void apply_linkmode_(otInstance *instance); + std::optional get_omr_address_(InstanceLock &lock); otInstance *get_openthread_instance_(); int openthread_stop_(); diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 6edaa98524..4f6e618f49 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -111,32 +111,7 @@ void OpenThreadComponent::ot_main() { ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); - otLinkModeConfig link_mode_config{}; -#if CONFIG_OPENTHREAD_FTD - link_mode_config.mRxOnWhenIdle = true; - link_mode_config.mDeviceType = true; - link_mode_config.mNetworkData = true; -#elif CONFIG_OPENTHREAD_MTD - if (this->poll_period_ > 0) { - if (otLinkSetPollPeriod(instance, this->poll_period_) != OT_ERROR_NONE) { - ESP_LOGE(TAG, "Failed to set pollperiod"); - } - ESP_LOGD(TAG, "Link Polling Period: %" PRIu32, otLinkGetPollPeriod(instance)); - } - link_mode_config.mRxOnWhenIdle = this->poll_period_ == 0; - link_mode_config.mDeviceType = false; - link_mode_config.mNetworkData = false; -#endif - - if (otThreadSetLinkMode(instance, link_mode_config) != OT_ERROR_NONE) { - ESP_LOGE(TAG, "Failed to set linkmode"); - } -#ifdef ESPHOME_LOG_HAS_DEBUG // Fetch link mode from OT only when DEBUG - link_mode_config = otThreadGetLinkMode(instance); - ESP_LOGD(TAG, "Link Mode Device Type: %s, Network Data: %s, RX On When Idle: %s", - TRUEFALSE(link_mode_config.mDeviceType), TRUEFALSE(link_mode_config.mNetworkData), - TRUEFALSE(link_mode_config.mRxOnWhenIdle)); -#endif + this->apply_linkmode_(instance); if (this->output_power_.has_value()) { if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { diff --git a/tests/components/openthread/common.yaml b/tests/components/openthread/common.yaml new file mode 100644 index 0000000000..d9eeab89ea --- /dev/null +++ b/tests/components/openthread/common.yaml @@ -0,0 +1,2 @@ +network: + enable_ipv6: true diff --git a/tests/components/openthread/test-tlv.esp32-c6-idf.yaml b/tests/components/openthread/test-tlv.esp32-c6-idf.yaml new file mode 100644 index 0000000000..c61efd4d3c --- /dev/null +++ b/tests/components/openthread/test-tlv.esp32-c6-idf.yaml @@ -0,0 +1,20 @@ +<<: !include common.yaml + +openthread: + device_type: MTD + force_dataset: false + use_address: open-thread-test.local + tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8 + poll_period: 5s + +switch: + - platform: template + name: "Radio Always On" + optimistic: true + restore_mode: ALWAYS_OFF + turn_on_action: + then: + - openthread.set_poll_period: 0s + turn_off_action: + then: + - openthread.set_poll_period: 5s diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 008edd5397..92d120e5d1 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -1,14 +1,6 @@ -esp32: - board: esp32-c6-devkitc-1 - framework: - type: esp-idf - log_level: DEBUG - -network: - enable_ipv6: true +<<: !include common.yaml openthread: - device_type: MTD channel: 13 network_name: OpenThread-8f28 network_key: 0xdfd34f0f05cad978ec4e32b0413038ff @@ -16,7 +8,4 @@ openthread: ext_pan_id: 0xd63e8e3e495ebbc3 pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 mesh_local_prefix: fd53:145f:ed22:ad81::/64 - force_dataset: true - use_address: open-thread-test.local - poll_period: 20sec output_power: 1dBm From e27390bddb87508ad04595055e328a7c1bead5b6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:36:10 -0400 Subject: [PATCH 1835/2214] [hbridge] Fix light stuck on one polarity (#17162) --- esphome/components/hbridge/light/__init__.py | 6 ++-- .../hbridge/light/hbridge_light_output.h | 30 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index ccb47237b6..f9451e2594 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -1,14 +1,14 @@ import esphome.codegen as cg from esphome.components import light, output import esphome.config_validation as cv -from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B +from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL from .. import hbridge_ns CODEOWNERS = ["@DotNetDann"] HBridgeLightOutput = hbridge_ns.class_( - "HBridgeLightOutput", cg.Component, light.LightOutput + "HBridgeLightOutput", cg.PollingComponent, light.LightOutput ) CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( @@ -16,12 +16,14 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(HBridgeLightOutput), cv.Required(CONF_PIN_A): cv.use_id(output.FloatOutput), cv.Required(CONF_PIN_B): cv.use_id(output.FloatOutput), + cv.Optional(CONF_UPDATE_INTERVAL, default="8ms"): cv.update_interval, } ) async def to_code(config): var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL))) await cg.register_component(var, config) await light.register_light(var, config) diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index c0107fdc0d..9dcf7adfd8 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -3,11 +3,10 @@ #include "esphome/components/light/light_output.h" #include "esphome/components/output/float_output.h" #include "esphome/core/component.h" -#include "esphome/core/helpers.h" namespace esphome::hbridge { -class HBridgeLightOutput final : public Component, public light::LightOutput { +class HBridgeLightOutput final : public PollingComponent, public light::LightOutput { public: void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } @@ -20,11 +19,12 @@ class HBridgeLightOutput final : public Component, public light::LightOutput { return traits; } - void setup() override { this->disable_loop(); } + void setup() override { this->stop_poller(); } - void loop() override { - // Only called when both channels are active — alternate H-bridge direction - // each iteration to multiplex cold and warm white. + void update() override { + // Flip the H-bridge direction to multiplex cold/warm white. update_interval must stay + // slower than the output's PWM period (flipping faster collapses the output onto one + // channel) but fast enough to avoid flicker (issue #17030). if (!this->forward_direction_) { this->pina_pin_->set_level(this->pina_duty_); this->pinb_pin_->set_level(0); @@ -46,13 +46,17 @@ class HBridgeLightOutput final : public Component, public light::LightOutput { this->pinb_duty_ = new_pinb; if (new_pina != 0.0f && new_pinb != 0.0f) { - // Both channels active — need loop to alternate H-bridge direction - this->high_freq_.start(); - this->enable_loop(); + // Both channels active — multiplex the H-bridge direction via the poller. + if (!this->multiplexing_) { + this->multiplexing_ = true; + this->start_poller(); + } } else { - // Zero or one channel active — drive pins directly, no multiplexing needed - this->high_freq_.stop(); - this->disable_loop(); + // Zero or one channel active — drive pins directly, no multiplexing needed. + if (this->multiplexing_) { + this->multiplexing_ = false; + this->stop_poller(); + } this->pina_pin_->set_level(new_pina); this->pinb_pin_->set_level(new_pinb); } @@ -64,7 +68,7 @@ class HBridgeLightOutput final : public Component, public light::LightOutput { float pina_duty_{0}; float pinb_duty_{0}; bool forward_direction_{false}; - HighFrequencyLoopRequester high_freq_; + bool multiplexing_{false}; }; } // namespace esphome::hbridge From e304c318fb75d168dff3de74c394ddf80e5f4cdb Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:47:24 +0200 Subject: [PATCH 1836/2214] Bump bundled esphome-device-builder to 1.0.18 (#17212) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c02aba093c..8159f1d32e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.17 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.18 RUN \ platformio settings set enable_telemetry No \ From ddf075a2dd399c22f450f1fbb921c111442f77c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:00:52 +0000 Subject: [PATCH 1837/2214] Bump aioesphomeapi from 45.3.1 to 45.5.2 (#17211) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 462438016e..956f3633dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.0 click==8.3.3 -aioesphomeapi==45.3.1 +aioesphomeapi==45.5.2 zeroconf==0.150.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From cc646b22135d2cbc72a76045543f5496e4ba6b24 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Jun 2026 21:34:33 +0200 Subject: [PATCH 1838/2214] [core] Defer requests import in framework_helpers to speed up config validation (#17215) --- esphome/framework_helpers.py | 6 ++-- tests/unit_tests/test_framework_helpers.py | 37 ++++++++++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6bf389240b..a8e5cf75a8 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -11,8 +11,6 @@ import sys import time from typing import IO -import requests - from esphome.helpers import ProgressBar, rmtree PathType = str | os.PathLike @@ -635,6 +633,10 @@ def download_from_mirrors( ValueError: If mirrors list is empty. Exception: If all download attempts fail. """ + # Imported lazily: requests is a heavy import (~85ms) and is only needed + # when actually downloading a toolchain, never during config validation. + import requests + # 1. Open target file for writing if path given with ExitStack() as stack: if isinstance(target, (str, os.PathLike)): diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index f6e783b5e8..fd807ed05d 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -526,7 +526,7 @@ class TestDownloadFromMirrors: def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None: target = tmp_path / "out.bin" with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"filedata"), ): url = download_from_mirrors(["https://example.com/f"], {}, target) @@ -535,7 +535,7 @@ class TestDownloadFromMirrors: def test_substitutions_applied_to_url(self, tmp_path: Path) -> None: with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"x"), ) as mock_get: download_from_mirrors( @@ -547,7 +547,7 @@ class TestDownloadFromMirrors: def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: with patch( - "esphome.framework_helpers.requests.get", + "requests.get", side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")], ): url = download_from_mirrors( @@ -561,7 +561,7 @@ class TestDownloadFromMirrors: def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None: with ( patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"", ok=False), ), pytest.raises(req.HTTPError), @@ -579,7 +579,7 @@ class TestDownloadFromMirrors: def test_file_like_target_written(self) -> None: buf = io.BytesIO() with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"bytes"), ): download_from_mirrors(["https://example.com/f"], {}, buf) @@ -590,7 +590,7 @@ class TestDownloadFromMirrors: r = _mock_response(b"1234567890") r.headers = {"content-length": "10"} with ( - patch("esphome.framework_helpers.requests.get", return_value=r), + patch("requests.get", return_value=r), patch("esphome.framework_helpers.ProgressBar") as mock_pb, ): download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") @@ -606,12 +606,35 @@ class TestDownloadFromMirrors: r.headers = {"content-length": "0"} r.iter_content.return_value = [b""] # one empty chunk target = tmp_path / "out.bin" - with patch("esphome.framework_helpers.requests.get", return_value=r): + with patch("requests.get", return_value=r): download_from_mirrors(["https://example.com/f"], {}, target) assert target.exists() assert target.read_bytes() == b"" +def test_importing_framework_helpers_does_not_import_requests() -> None: + """Importing framework_helpers must not drag in requests. + + requests is a heavy import (~85ms) only needed by download_from_mirrors to + fetch toolchains during a build. framework_helpers is loaded during config + validation (esp-idf framework, host platform), so the import is deferred to + the function that uses it. A fresh interpreter is required because the test + process has already imported requests. + """ + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys\nimport esphome.framework_helpers\n" + "print('\\n'.join(sys.modules))", + ], + capture_output=True, + text=True, + check=True, + ) + assert "requests" not in result.stdout.split() + + # --------------------------------------------------------------------------- # get_python_env_executable_path — Windows branch # --------------------------------------------------------------------------- From 239211e5210dd2eb111c30e9520201e241908be7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Jun 2026 21:34:44 +0200 Subject: [PATCH 1839/2214] [time] Defer aioesphomeapi import to speed up config validation (#17214) --- esphome/components/time/__init__.py | 32 +++++++++++------- tests/unit_tests/components/test_time.py | 42 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index b3bf2d44d7..35fad0a450 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,11 +1,8 @@ import errno +import functools from importlib import resources import logging -from aioesphomeapi.posix_tz import ( - DSTRuleType as PyDSTRuleType, - parse_posix_tz as parse_posix_tz_python, -) import tzlocal from esphome import automation @@ -57,13 +54,20 @@ DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) DSTRule_cpp = time_ns.struct("DSTRule") ParsedTimezone_cpp = time_ns.struct("ParsedTimezone") -# Map Python DSTRuleType enum values to C++ enum expressions -_DST_RULE_TYPE_MAP = { - PyDSTRuleType.NONE: DSTRuleType_cpp.NONE, - PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY, - PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP, - PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR, -} + +# Map Python DSTRuleType enum values to C++ enum expressions. Built lazily to +# avoid importing aioesphomeapi (a heavy import) when the time component is only +# auto-loaded for its schema and never reaches code generation. +@functools.cache +def _dst_rule_type_map() -> dict: + from aioesphomeapi.posix_tz import DSTRuleType as PyDSTRuleType + + return { + PyDSTRuleType.NONE: DSTRuleType_cpp.NONE, + PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY, + PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP, + PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR, + } def _load_tzdata(iana_key: str) -> bytes | None: @@ -317,6 +321,8 @@ def validate_tz(value: str) -> str: # Validate that the POSIX TZ string is parseable (skip empty strings) if value: + from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python + try: parse_posix_tz_python(value) except ValueError as e: @@ -372,7 +378,7 @@ def _emit_dst_rule_fields(prefix, rule): """Emit field-by-field assignments for a DSTRule to avoid rodata struct blob.""" cg.add(cg.RawExpression(f"{prefix}.time_seconds = {rule.time_seconds}")) cg.add(cg.RawExpression(f"{prefix}.day = {rule.day}")) - cg.add(cg.RawExpression(f"{prefix}.type = {_DST_RULE_TYPE_MAP[rule.type]}")) + cg.add(cg.RawExpression(f"{prefix}.type = {_dst_rule_type_map()[rule.type]}")) cg.add(cg.RawExpression(f"{prefix}.month = {rule.month}")) cg.add(cg.RawExpression(f"{prefix}.week = {rule.week}")) cg.add(cg.RawExpression(f"{prefix}.day_of_week = {rule.day_of_week}")) @@ -409,6 +415,8 @@ async def setup_time_core_(time_var, config): cg.add(time_var.set_timezone(timezone)) else: # Embedded: pre-parse at codegen time, emit struct directly + from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python + try: parsed = parse_posix_tz_python(timezone) _emit_parsed_timezone_fields(parsed) diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 5ae9d787d6..6f3b4bb14f 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -1,6 +1,8 @@ """Tests for time component cron expression parsing.""" import errno +import subprocess +import sys from unittest.mock import MagicMock, patch import pytest @@ -143,3 +145,43 @@ def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> Non _mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")), ): assert validate_tz("<+08>-8") == "<+08>-8" + + +def _modules_after(code: str) -> set[str]: + """Run code in a fresh interpreter and return the imported module names. + + A subprocess is required because the test process itself has already + imported aioesphomeapi via other tests, so sys.modules here is useless. + """ + result = subprocess.run( + [sys.executable, "-c", f"import sys\n{code}\nprint('\\n'.join(sys.modules))"], + capture_output=True, + text=True, + check=True, + ) + return set(result.stdout.split()) + + +def test_importing_time_does_not_import_aioesphomeapi() -> None: + """Importing the time component must not drag in aioesphomeapi. + + aioesphomeapi is a heavy import (it builds a large number of dataclasses at + import time). The time component is auto-loaded by many components, so + importing it for its schema during config validation must not pay that + cost. The import is deferred to the functions that actually need it. + """ + modules = _modules_after("import esphome.components.time") + assert "aioesphomeapi" not in modules + + +def test_validate_tz_imports_aioesphomeapi_lazily() -> None: + """Validating a non-empty timezone is what triggers the lazy import. + + Documents the boundary: the cost is only paid when a timezone is actually + validated, not merely by loading the component. + """ + modules = _modules_after( + "from esphome.components.time import validate_tz\n" + "validate_tz('EST5EDT,M3.2.0,M11.1.0')" + ) + assert "aioesphomeapi" in modules From f49bed47de91fdac0954e714970aa8a530a98511 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:07:55 -0400 Subject: [PATCH 1840/2214] Bump ruff from 0.15.19 to 0.15.20 (#17216) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 6e53a4c14f..ebd93ea390 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.19 # also change in .pre-commit-config.yaml when updating +ruff==0.15.20 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From be8523a73c30efe3df499c5f968742f3e942f55a Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:33:38 +0100 Subject: [PATCH 1841/2214] [mdns] Add mDNS to Zephyr and nRF52 (#16924) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mdns/mdns_component.cpp | 5 ++++- esphome/components/mdns/mdns_zephyr.cpp | 13 ++++++++++--- tests/components/mdns/test.nrf52-adafruit.yaml | 4 ++++ 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 tests/components/mdns/test.nrf52-adafruit.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 9bf27e71e4..e11cb1abaa 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -100,7 +100,7 @@ void MDNSComponent::compile_records_(StaticVector &) {} -void MDNSComponent::setup() { ESP_LOGW(TAG, "mDNS is not implemented for Zephyr"); } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_zephyr); } +#else +// No responder and nothing consuming the records, so skip the boot-time compile. +void MDNSComponent::setup() {} +#endif void MDNSComponent::on_shutdown() {} diff --git a/tests/components/mdns/test.nrf52-adafruit.yaml b/tests/components/mdns/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..6aff688ff4 --- /dev/null +++ b/tests/components/mdns/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +network: + enable_ipv6: true + +mdns: From f9f28a6a007a99ad2594204dc45c2c5a3fb4e30e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:48:52 +0200 Subject: [PATCH 1842/2214] Bump bundled esphome-device-builder to 1.0.19 (#17217) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8159f1d32e..66dad179bb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.18 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19 RUN \ platformio settings set enable_telemetry No \ From 75cdabee3d59cca25a788bb28873533130f41a4e Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:30:07 +0100 Subject: [PATCH 1843/2214] [socket] Add BSD socket support for nRF52 (#16699) Co-authored-by: tomaszduda23 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/socket/__init__.py | 6 +++ .../components/socket/bsd_sockets_impl.cpp | 6 ++- esphome/components/socket/bsd_sockets_impl.h | 28 +++++++++- esphome/components/socket/headers.h | 6 +++ esphome/components/socket/socket.cpp | 52 ++++++++++++++++++- esphome/components/socket/socket.h | 4 +- .../socket/test.nrf52-adafruit.yaml | 1 + .../components/socket/test.nrf52-mcumgr.yaml | 1 + .../socket/test.nrf52-xiao-ble.yaml | 1 + 9 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 tests/components/socket/test.nrf52-adafruit.yaml create mode 100644 tests/components/socket/test.nrf52-mcumgr.yaml create mode 100644 tests/components/socket/test.nrf52-xiao-ble.yaml diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index abbbb0f056..38d787c20a 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -149,6 +149,7 @@ CONFIG_SCHEMA = cv.Schema( ln882x=IMPLEMENTATION_LWIP_SOCKETS, rtl87xx=IMPLEMENTATION_LWIP_SOCKETS, host=IMPLEMENTATION_BSD_SOCKETS, + nrf52=IMPLEMENTATION_BSD_SOCKETS, ): cv.one_of( IMPLEMENTATION_LWIP_TCP, IMPLEMENTATION_LWIP_SOCKETS, @@ -168,6 +169,11 @@ async def to_code(config): cg.add_define("USE_SOCKET_IMPL_LWIP_SOCKETS") elif impl == IMPLEMENTATION_BSD_SOCKETS: cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + if CORE.using_zephyr: + from esphome.components.zephyr import zephyr_add_prj_conf + + zephyr_add_prj_conf("NET_SOCKETS", True) + zephyr_add_prj_conf("POSIX_API", True) # ESP32 and LibreTiny both have LwIP >= 2.1.3 with lwip_socket_dbg_get_socket() # and FreeRTOS task notifications — enable fast select to bypass lwip_select(). # Only when not using lwip_tcp, which does not provide select() support. diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index ee22e4b97b..0d4284f145 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -22,11 +22,13 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { if (flags >= 0) ::fcntl(this->fd_, F_SETFD, flags | FD_CLOEXEC); #endif + // Guard structure matches socket_ready_fd(): non-HOST platforms (nRF52/OpenThread) + // do not register fds with the esphome select loop, so monitor_loop is a no-op there. if (!monitor_loop) return; #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); -#else +#elif defined(USE_HOST) this->loop_monitored_ = wake_register_fd(this->fd_); #endif } @@ -45,7 +47,7 @@ int BSDSocketImpl::close() { // touch an unrelated socket's pcb. No per-socket callback unhook is needed — // all LwIP sockets share the same static event_callback. this->cached_sock_ = nullptr; -#else +#elif defined(USE_HOST) if (this->loop_monitored_) { wake_unregister_fd(this->fd_); } diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 57c1a430a2..1b5ea9ebcd 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -76,7 +76,7 @@ class BSDSocketImpl { #endif } ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) { -#if defined(USE_ESP32) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) return ::recvfrom(this->fd_, buf, len, 0, addr, addr_len); #else return ::lwip_recvfrom(this->fd_, buf, len, 0, addr, addr_len); @@ -85,6 +85,19 @@ class BSDSocketImpl { ssize_t readv(const struct iovec *iov, int iovcnt) { #if defined(USE_ESP32) return ::lwip_readv(this->fd_, iov, iovcnt); +#elif defined(USE_ZEPHYR) + // Zephyr does not provide readv(); emulate with a read() loop. Stream sockets only: + // on a datagram socket each read() would consume a separate datagram, not scatter one. + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) { + ssize_t n = ::read(this->fd_, iov[i].iov_base, iov[i].iov_len); + if (n < 0) + return total > 0 ? total : n; + total += n; + if (static_cast(n) < iov[i].iov_len) + break; + } + return total; #else return ::readv(this->fd_, iov, iovcnt); #endif @@ -100,6 +113,19 @@ class BSDSocketImpl { ssize_t writev(const struct iovec *iov, int iovcnt) { #if defined(USE_ESP32) return ::lwip_writev(this->fd_, iov, iovcnt); +#elif defined(USE_ZEPHYR) + // Zephyr does not provide writev(); emulate with a write() loop. Stream sockets only: + // on a datagram socket each write() would emit a separate datagram, not gather one. + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) { + ssize_t n = ::write(this->fd_, iov[i].iov_base, iov[i].iov_len); + if (n < 0) + return total > 0 ? total : n; + total += n; + if (static_cast(n) < iov[i].iov_len) + break; // partial write: stop so caller resumes from the correct stream offset + } + return total; #else return ::writev(this->fd_, iov, iovcnt); #endif diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 0eece6480f..f9b652f14a 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -158,7 +158,9 @@ using socklen_t = uint32_t; #include #include #include +#ifndef USE_ZEPHYR #include +#endif #include #ifdef USE_HOST @@ -167,6 +169,10 @@ using socklen_t = uint32_t; #include #include #endif // USE_HOST +#ifdef USE_ZEPHYR +#include +#include +#endif // USE_ZEPHYR #ifdef USE_ARDUINO // arduino-esp32 declares a global var called INADDR_NONE which is replaced diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index f14ac1e2d5..212da80312 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -12,9 +12,19 @@ namespace esphome::socket { #ifdef USE_HOST -// Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets). -// Checks if the host wake select() loop has marked this fd as ready. +// Host: ready when the wake select() loop has flagged this fd (or it isn't monitored). bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || wake_fd_ready(fd); } +#elif defined(USE_ZEPHYR) +// Zephyr (nRF52): fd monitoring isn't wired into the esphome select loop +// (wake_register_fd is USE_HOST-only), so loop_monitored is always false. Always +// return true — the caller handles EAGAIN/EWOULDBLOCK on read. +// +// Cost (known trade-off, not an oversight): loop-monitored sockets (API, web_server) +// are read every loop() iteration and bail on EAGAIN; there is no event-driven wake, +// so the main loop busy-polls at loop frequency and cannot idle between packets. +// TODO: wire Zephyr fds into an event-driven wake source (e.g. zsock_poll/k_poll) so +// the loop can sleep between packets on battery/OpenThread targets. +bool socket_ready_fd(int /*fd*/, bool /*loop_monitored*/) { return true; } #endif // Platform-specific inet_ntop wrappers @@ -40,6 +50,19 @@ static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t return lwip_inet_ntop(AF_INET6, addr, buf, size); } #endif +#elif defined(USE_ZEPHYR) +// Zephyr BSD sockets — use Zephyr native address formatting via POSIX-subset wrappers. +// is already included transitively through . +static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) { + return zsock_inet_ntop(AF_INET, addr, buf, size); +} +// IPv6 is always enabled on nRF52 (config validation enforces enable_ipv6=True), +// but the guard is retained for consistency with other platform blocks. +#if USE_NETWORK_IPV6 +static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) { + return zsock_inet_ntop(AF_INET6, addr, buf, size); +} +#endif #else // BSD sockets (host, ESP32-IDF) static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) { @@ -68,6 +91,15 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s esphome_inet_ntop4(&addr->sin6_addr.s6_addr[12], buf.data(), buf.size()) != nullptr) { return strlen(buf.data()); } +#elif defined(USE_ZEPHYR) + // Format IPv4-mapped IPv6 addresses as regular IPv4. Zephyr uses the standard POSIX + // s6_addr layout (not the LWIP union) but provides no IN6_IS_ADDR_V4MAPPED macro, so + // detect the ::ffff:0:0/96 prefix directly on the address words. + if (addr->sin6_addr.s6_addr32[0] == 0 && addr->sin6_addr.s6_addr32[1] == 0 && + addr->sin6_addr.s6_addr32[2] == htonl(0xFFFF) && + esphome_inet_ntop4(&addr->sin6_addr.s6_addr32[3], buf.data(), buf.size()) != nullptr) { + return strlen(buf.data()); + } #elif !defined(USE_SOCKET_IMPL_LWIP_TCP) // Format IPv4-mapped IPv6 addresses as regular IPv4 (LWIP layout) if (addr->sin6_addr.un.u32_addr[0] == 0 && addr->sin6_addr.un.u32_addr[1] == 0 && @@ -117,11 +149,19 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ server->sin6_port = htons(port); #ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#if defined(USE_ZEPHYR) + // Zephyr BSD sockets: use native address conversion + if (zsock_inet_pton(AF_INET6, ip_address, &server->sin6_addr) != 1) { + errno = EINVAL; + return 0; + } +#else // Use standard inet_pton for BSD sockets if (inet_pton(AF_INET6, ip_address, &server->sin6_addr) != 1) { errno = EINVAL; return 0; } +#endif #else // Use LWIP-specific functions ip6_addr_t ip6; @@ -138,7 +178,15 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ auto *server = reinterpret_cast(addr); memset(server, 0, sizeof(sockaddr_in)); server->sin_family = AF_INET; +#if defined(USE_ZEPHYR) + // Zephyr BSD sockets: use native address conversion + if (zsock_inet_pton(AF_INET, ip_address, &server->sin_addr) != 1) { + errno = EINVAL; + return 0; + } +#else server->sin_addr.s_addr = inet_addr(ip_address); +#endif server->sin_port = htons(port); return sizeof(sockaddr_in); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 204113e4b2..eb8870786d 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -60,11 +60,11 @@ inline struct lwip_sock *hook_fd_for_fast_select(int fd) { } return sock; } -#elif defined(USE_HOST) +#elif defined(USE_HOST) || defined(USE_ZEPHYR) /// Shared ready() helper for fd-based socket implementations. /// Checks if the Application's select() loop has marked this fd as ready. bool socket_ready_fd(int fd, bool loop_monitored); -#endif +#endif // USE_LWIP_FAST_SELECT // Inline ready() — defined here because it depends on socket_ready/socket_ready_fd // declared above, while the impl headers are included before those declarations. diff --git a/tests/components/socket/test.nrf52-adafruit.yaml b/tests/components/socket/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..d55dfd1557 --- /dev/null +++ b/tests/components/socket/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +socket: diff --git a/tests/components/socket/test.nrf52-mcumgr.yaml b/tests/components/socket/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..d55dfd1557 --- /dev/null +++ b/tests/components/socket/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +socket: diff --git a/tests/components/socket/test.nrf52-xiao-ble.yaml b/tests/components/socket/test.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..d55dfd1557 --- /dev/null +++ b/tests/components/socket/test.nrf52-xiao-ble.yaml @@ -0,0 +1 @@ +socket: From da5e11d1966cc26bbe9c2a614914d7a8c86f9e39 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 15:49:56 +0200 Subject: [PATCH 1844/2214] [core] Fix area saved as null in storage.json (#17219) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/core/config.py | 12 +++++++++++- tests/unit_tests/core/test_config.py | 29 +++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index b925f0b7d9..59c96035b8 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -407,6 +407,17 @@ def preload_core_config(config, result) -> str: CORE.name = conf[CONF_NAME] CORE.friendly_name = conf.get(CONF_FRIENDLY_NAME) + # Record the node's area name now (substitutions are already resolved at this + # point). storage.json is written before to_code() runs, so deferring this to + # to_code() left the area as null in storage.json. The value here is the raw + # post-substitution form (a plain string or a {name: ...} mapping). Assign + # unconditionally (like friendly_name) so a config without an area never + # inherits a stale value from a previous load in a long-running process, and + # use .get() so a malformed mapping surfaces later as a proper validation + # error rather than a KeyError here. to_code() sets it again from the + # validated config, which yields the same name. + area = conf.get(CONF_AREA) + CORE.area = area.get(CONF_NAME) if isinstance(area, dict) else area CORE.data[KEY_CORE] = {} if CONF_BUILD_PATH not in conf: @@ -760,7 +771,6 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: - CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e2b34d92d8..b3d87f6857 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -152,15 +152,21 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: ("multiple_areas_devices.yaml", "Main Area"), ], ) -async def test_to_code_records_core_area( +async def test_core_area_recorded_at_config_load( yaml_file: Callable[[str], Path], fixture: str, expected_area: str, ) -> None: - """``to_code`` records the node's area name on CORE for StorageJSON.""" + """The node's area name is recorded on CORE for StorageJSON. + + It must be set during config load (preload_core_config), not deferred to + to_code(): storage.json is written before to_code() runs, so a late + assignment left the area as null in storage.json (regression #17218). + """ result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) assert result is not None - assert CORE.area is None + # Recorded already at config-load time, before any code generation. + assert CORE.area == expected_area with patch("esphome.core.config.cg") as mock_cg: mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() @@ -170,6 +176,23 @@ async def test_to_code_records_core_area( assert CORE.area == expected_area +def test_config_load_without_area_clears_stale_core_area( + yaml_file: Callable[[str], Path], +) -> None: + """A config without an area must not inherit a stale CORE.area. + + preload_core_config assigns CORE.area unconditionally, so the area from a + previous load in a long-running process cannot leak into a config that + omits it. + """ + CORE.area = "Stale Area From Previous Load" + result = load_config_from_fixture( + yaml_file, "device_without_area.yaml", FIXTURES_DIR + ) + assert result is not None + assert CORE.area is None + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: From 7811781a9608898774d34b35265257b84044790a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 16:21:36 +0200 Subject: [PATCH 1845/2214] [es8388] Fix DAC unable to unmute once muted (#17221) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/es8388/es8388.cpp | 8 +++++++- esphome/components/es8388/es8388_const.h | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index c015393e14..0b97240230 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -173,8 +173,14 @@ bool ES8388::set_mute_state_(bool mute_state) { ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL3, &value)); ESP_LOGV(TAG, "Read ES8388_DACCONTROL3: 0x%02X", value); + // Only toggle the DACMute bit; the other bits of this register hold unrelated + // DAC settings that must be preserved. Previously muting overwrote the whole + // register with 0x3C and unmuting never cleared the bit, so once muted the DAC + // could not be unmuted again. if (mute_state) { - value = 0x3C; + value |= ES8388_DACCONTROL3_DAC_MUTE; + } else { + value &= ~ES8388_DACCONTROL3_DAC_MUTE; } ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state)); diff --git a/esphome/components/es8388/es8388_const.h b/esphome/components/es8388/es8388_const.h index 451c9cc026..e081c55dbd 100644 --- a/esphome/components/es8388/es8388_const.h +++ b/esphome/components/es8388/es8388_const.h @@ -38,6 +38,7 @@ static const uint8_t ES8388_ADCCONTROL14 = 0x16; static const uint8_t ES8388_DACCONTROL1 = 0x17; static const uint8_t ES8388_DACCONTROL2 = 0x18; static const uint8_t ES8388_DACCONTROL3 = 0x19; +static const uint8_t ES8388_DACCONTROL3_DAC_MUTE = 0x04; // DACMute, bit 2 of DACCONTROL3 static const uint8_t ES8388_DACCONTROL4 = 0x1a; static const uint8_t ES8388_DACCONTROL5 = 0x1b; static const uint8_t ES8388_DACCONTROL6 = 0x1c; From 88875daf52f3e72daf1a467e4e093547e80ab236 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:32:39 -0400 Subject: [PATCH 1846/2214] Bump actions/cache/restore from 6.0.0 to 6.1.0 in /.github/actions/restore-python (#17228) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 6290e25d7c..1364e95602 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv # yamllint disable-line rule:line-length From 7ad4cbf46fc5d9df6feb7d2d3b8d9c01f3f6544d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:32:50 -0400 Subject: [PATCH 1847/2214] Bump actions/cache/save from 6.0.0 to 6.1.0 (#17229) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaa04ceca6..8700060198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,7 +250,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -339,7 +339,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -1164,7 +1164,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} From 063c4371dee33c594cb311d448654029a15d06c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:33:01 -0400 Subject: [PATCH 1848/2214] Bump actions/cache from 6.0.0 to 6.1.0 (#17230) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8700060198..7a4c1ebc23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv # yamllint disable-line rule:line-length @@ -365,7 +365,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -509,7 +509,7 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} From 436938b931771ab726afbfc476bc494da05cf26f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:33:13 -0400 Subject: [PATCH 1849/2214] Bump actions/cache/restore from 6.0.0 to 6.1.0 (#17231) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a4c1ebc23..72519e421a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -295,7 +295,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -516,7 +516,7 @@ jobs: - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -1098,7 +1098,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1122,7 +1122,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -1211,7 +1211,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} From 24ec65e68eb56b5e56a0bc007047af2ce7a3a034 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 17:26:47 +0200 Subject: [PATCH 1850/2214] [esp32] Only warn about S3 PSRAM pins (GPIO33-37) in octal mode (#17222) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 4 ++ esphome/components/esp32/gpio.py | 16 ++++++- esphome/components/esp32/gpio_esp32_s3.py | 45 ++++++++++++++++--- .../config/psram_octal_disabled_gpio34.yaml | 16 +++++++ .../esp32/config/psram_octal_gpio34.yaml | 15 +++++++ .../esp32/config/psram_quad_gpio34.yaml | 15 +++++++ tests/component_tests/esp32/test_esp32.py | 26 +++++++++++ 7 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml create mode 100644 tests/component_tests/esp32/config/psram_octal_gpio34.yaml create mode 100644 tests/component_tests/esp32/config/psram_quad_gpio34.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 945eda3912..a5528da672 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1102,6 +1102,8 @@ def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN + from .gpio import final_validate_pins + errs = [] conf_fw = config[CONF_FRAMEWORK] advanced = conf_fw[CONF_ADVANCED] @@ -1185,6 +1187,8 @@ def final_validate(config): ) ) + final_validate_pins(full_config) + if ( config[CONF_FLASH_SIZE] == "32MB" and "ota" in full_config diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 2ff39cab69..321dd3d498 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -18,6 +18,7 @@ from esphome.const import ( PLATFORM_ESP32, ) from esphome.core import CORE +from esphome.types import ConfigType from . import boards from .const import ( @@ -50,7 +51,11 @@ from .gpio_esp32_h4 import esp32_h4_validate_gpio_pin, esp32_h4_validate_support from .gpio_esp32_h21 import esp32_h21_validate_gpio_pin, esp32_h21_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports -from .gpio_esp32_s3 import esp32_s3_validate_gpio_pin, esp32_s3_validate_supports +from .gpio_esp32_s3 import ( + esp32_s3_final_validate_pins, + esp32_s3_validate_gpio_pin, + esp32_s3_validate_supports, +) from .gpio_esp32_s31 import esp32_s31_validate_gpio_pin, esp32_s31_validate_supports ESP32InternalGPIOPin = esp32_ns.class_("ESP32InternalGPIOPin", cg.InternalGPIOPin) @@ -96,6 +101,7 @@ def _translate_pin(value): class ESP32ValidationFunctions: pin_validation: Callable[[int], int] usage_validation: Callable[[dict[str, Any]], dict[str, Any]] + final_validate: Callable[[ConfigType], None] | None = None _esp32_validations = { @@ -145,6 +151,7 @@ _esp32_validations = { VARIANT_ESP32S3: ESP32ValidationFunctions( pin_validation=esp32_s3_validate_gpio_pin, usage_validation=esp32_s3_validate_supports, + final_validate=esp32_s3_final_validate_pins, ), VARIANT_ESP32S31: ESP32ValidationFunctions( pin_validation=esp32_s31_validate_gpio_pin, @@ -261,3 +268,10 @@ async def esp32_pin_to_code(config): cg.add(var.set_drive_strength(config[CONF_DRIVE_STRENGTH])) cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) return var + + +def final_validate_pins(full_config: ConfigType) -> None: + """Run the active variant's pin final-validation, if it defines one.""" + funcs = _esp32_validations.get(CORE.data[KEY_ESP32][KEY_VARIANT]) + if funcs is not None and funcs.final_validate is not None: + funcs.final_validate(full_config) diff --git a/esphome/components/esp32/gpio_esp32_s3.py b/esphome/components/esp32/gpio_esp32_s3.py index f528de4ccd..db8c520533 100644 --- a/esphome/components/esp32/gpio_esp32_s3.py +++ b/esphome/components/esp32/gpio_esp32_s3.py @@ -2,8 +2,15 @@ import logging from typing import Any import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER -from esphome.pins import check_strapping_pin +from esphome.const import ( + CONF_DISABLED, + CONF_INPUT, + CONF_MODE, + CONF_NUMBER, + PLATFORM_ESP32, +) +from esphome.pins import PIN_SCHEMA_REGISTRY, check_strapping_pin +from esphome.types import ConfigType _ESP32S3_SPI_PSRAM_PINS = { 26: "SPICS1", @@ -38,11 +45,9 @@ def esp32_s3_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"This pin cannot be used on ESP32-S3s and is already used by the SPI/PSRAM interface(function: {_ESP32S3_SPI_PSRAM_PINS[value]})" ) - if value in _ESP32S3R8_PSRAM_PINS: - _LOGGER.warning( - "GPIO%d is used by the PSRAM interface on ESP32-S3R8 / ESP32-S3R8V and should be avoided on these models", - value, - ) + # GPIO33-37 (_ESP32S3R8_PSRAM_PINS) are only taken by the PSRAM interface in + # octal mode -- whether that applies isn't known here, so the warning is + # deferred to final_validate_pins() in gpio.py once the PSRAM mode is resolved. if value in (22, 23, 24, 25): # These pins are not exposed in GPIO mux (reason unknown) @@ -71,3 +76,29 @@ def esp32_s3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: check_strapping_pin(value, _ESP32S3_STRAPPING_PINS, _LOGGER) return value + + +def esp32_s3_final_validate_pins(full_config: ConfigType) -> None: + """Warn about GPIO33-37 usage, but only when octal PSRAM (which uses them) is set. + + These pins are only taken by the PSRAM interface in octal mode (ESP32-S3R8 / + S3R8V); on quad-PSRAM variants -- or when the psram block is disabled, so the + octal interface is never configured -- they are free. The per-pin validator + can't know the PSRAM mode, so the check is deferred here, where + PIN_SCHEMA_REGISTRY.pins_used already lists every used pin. + """ + # Imported locally to avoid circular import issues + from esphome.components.psram import DOMAIN as PSRAM_DOMAIN, TYPE_OCTAL + + psram_config = full_config.get(PSRAM_DOMAIN, {}) + if psram_config.get(CONF_DISABLED) or psram_config.get(CONF_MODE) != TYPE_OCTAL: + return + for number in sorted( + number + for key, _client_id, number in PIN_SCHEMA_REGISTRY.pins_used + if key == PLATFORM_ESP32 and number in _ESP32S3R8_PSRAM_PINS + ): + _LOGGER.warning( + "GPIO%d is used by the PSRAM interface in octal mode and should be avoided", + number, + ) diff --git a/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml b/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml new file mode 100644 index 0000000000..450e1bb345 --- /dev/null +++ b/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + disabled: true + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/config/psram_octal_gpio34.yaml b/tests/component_tests/esp32/config/psram_octal_gpio34.yaml new file mode 100644 index 0000000000..b385057d79 --- /dev/null +++ b/tests/component_tests/esp32/config/psram_octal_gpio34.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/config/psram_quad_gpio34.yaml b/tests/component_tests/esp32/config/psram_quad_gpio34.yaml new file mode 100644 index 0000000000..9612edb75b --- /dev/null +++ b/tests/component_tests/esp32/config/psram_quad_gpio34.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: quad + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bdba981c44..cea34bef7c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -213,6 +213,32 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +@pytest.mark.parametrize( + ("fixture", "expect_warning"), + [ + ("psram_quad_gpio34.yaml", False), + ("psram_octal_gpio34.yaml", True), + ("psram_octal_disabled_gpio34.yaml", False), + ], +) +def test_s3_psram_pin_warning_only_for_octal( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, + fixture: str, + expect_warning: bool, +) -> None: + """GPIO33-37 are only used by the PSRAM interface in octal mode. + + Using such a pin must only warn when octal PSRAM is configured; on quad + PSRAM the pins are free and warning would be a false positive (#16857). + """ + with caplog.at_level("WARNING"): + generate_main(component_config_path(fixture)) + warned = "GPIO34 is used by the PSRAM interface in octal mode" in caplog.text + assert warned == expect_warning + + def test_ignore_pin_validation_error_on_clean_pin_warns( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture, From ccc57475b76a928b0aaaefbc95019a405b0fac1f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:04:45 -0400 Subject: [PATCH 1851/2214] [deep_sleep] Add ESP32-C5 support (#17237) --- .../deep_sleep/deep_sleep_component.h | 3 ++- .../components/deep_sleep/deep_sleep_esp32.cpp | 17 ++++++++++------- .../deep_sleep/test.esp32-c5-idf.yaml | 5 +++++ .../deep_sleep/test.esp32-c61-idf.yaml | 5 +++++ 4 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 tests/components/deep_sleep/test.esp32-c5-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-c61-idf.yaml diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 8edda040d3..896ed092aa 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -96,7 +96,8 @@ class DeepSleepComponent final : public Component { #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void set_touch_wakeup(bool touch_wakeup); #endif diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index c905b8fcbc..7cb8e53efd 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -16,7 +16,7 @@ namespace esphome::deep_sleep { // | ESP32-S3 | ✓ | ✓ | ✓ | | // | ESP32-C2 | | | | ✓ | // | ESP32-C3 | | | | ✓ | -// | ESP32-C5 | | (✓) | | (✓) | +// | ESP32-C5 | | ✓ | | ✓ | // | ESP32-C6 | | ✓ | | ✓ | // | ESP32-C61 | | ✓ | | ✓ | // | ESP32-H2 | | ✓ | | | @@ -56,7 +56,8 @@ void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wa #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif @@ -99,7 +100,8 @@ void DeepSleepComponent::deep_sleep_() { // Single pin wakeup (ext0) - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -122,9 +124,9 @@ void DeepSleepComponent::deep_sleep_() { } #endif - // GPIO wakeup - C2, C3, C6, C61 only -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32C61) + // GPIO wakeup - C2, C3, C5, C6, C61 only +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); // Make sure GPIO is in input mode, not all RTC GPIO pins are input by default @@ -154,7 +156,8 @@ void DeepSleepComponent::deep_sleep_() { // Touch wakeup - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->touch_wakeup_.has_value() && *(this->touch_wakeup_)) { esp_sleep_enable_touchpad_wakeup(); esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); diff --git a/tests/components/deep_sleep/test.esp32-c5-idf.yaml b/tests/components/deep_sleep/test.esp32-c5-idf.yaml new file mode 100644 index 0000000000..11abe70711 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c5-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32-ext1.yaml diff --git a/tests/components/deep_sleep/test.esp32-c61-idf.yaml b/tests/components/deep_sleep/test.esp32-c61-idf.yaml new file mode 100644 index 0000000000..11abe70711 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c61-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32-ext1.yaml From a0742a953558a25e86597e747b44787c74a2a7b3 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:22:34 +0100 Subject: [PATCH 1852/2214] [api] Add nRF52 support (#17226) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/api/__init__.py | 2 ++ esphome/components/api/api_pb2_includes.h | 7 +++++++ esphome/components/network/__init__.py | 6 ++++++ tests/components/api/test.nrf52-adafruit.yaml | 4 ++++ 4 files changed, 19 insertions(+) create mode 100644 tests/components/api/test.nrf52-adafruit.yaml diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 932702d47a..0f5cd936f5 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -305,6 +305,7 @@ CONFIG_SCHEMA = cv.All( rtl87xx=4, # Moderate RAM, BSD-style sockets host=4, # Abundant resources ln882x=4, # Moderate RAM + nrf52=4, # ~256KB RAM, BSD sockets ): cv.int_range(min=1, max=10), cv.SplitDefault( CONF_MAX_CONNECTIONS, @@ -315,6 +316,7 @@ CONFIG_SCHEMA = cv.All( rtl87xx=5, # Moderate RAM host=8, # Abundant resources ln882x=5, # Moderate RAM + nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller) ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index f45e091c6f..70ba579fcc 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -31,6 +31,13 @@ #include #include +#if defined(LOG_LEVEL_NONE) +// Zephyr defines LOG_LEVEL_NONE as a logging macro that collides with the LogLevel enum value of +// the same name in the generated api_pb2.h. Undefine it for the rest of this translation unit so +// the enum parses; nothing below needs Zephyr's logging macro. +#undef LOG_LEVEL_NONE +#endif + namespace esphome::api { // This file only provides includes, no actual code diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index b662293ab5..846c3afc59 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -221,6 +221,12 @@ async def to_code(config): zephyr_add_prj_conf("NET_IPV6", True) zephyr_add_prj_conf("NET_TCP", True) zephyr_add_prj_conf("NET_UDP", True) + # The nRF Connect SDK replaces mbedTLS with PSA/Oberon crypto and does not provide the + # legacy mbedtls_md5() symbol that Zephyr's RFC 6528 TCP ISN generator links against + # (selecting MBEDTLS_MAC_MD5_ENABLED does not bring in the legacy C API here). Disable it so + # TCP links; Zephyr falls back to sys_rand32_get() for the ISN (randomized, but not the + # RFC 6528 keyed hash). + zephyr_add_prj_conf("NET_TCP_ISN_RFC6528", False) if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) diff --git a/tests/components/api/test.nrf52-adafruit.yaml b/tests/components/api/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..9229d68aa3 --- /dev/null +++ b/tests/components/api/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +network: + enable_ipv6: true + +api: From 690e8c3fb964d82b2b3a42f5a1007f825d0a4d3c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 27 Jun 2026 21:50:28 +0200 Subject: [PATCH 1853/2214] [nrf52] add upload for native build (#17100) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 173 ++++++++++++- esphome/components/nrf52/framework.py | 5 +- esphome/components/nrf52/requirements.txt | 1 + esphome/storage_json.py | 16 ++ tests/unit_tests/test_nrf52_upload.py | 292 ++++++++++++++++++++++ tests/unit_tests/test_storage_json.py | 96 +++++++ 6 files changed, 571 insertions(+), 12 deletions(-) create mode 100644 tests/unit_tests/test_nrf52_upload.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index d87318b03d..00271c97c7 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -4,6 +4,7 @@ import asyncio import logging from pathlib import Path import re +import shutil import subprocess from esphome import pins @@ -486,6 +487,16 @@ def upload_program(config: ConfigType, args, host: str) -> bool: from esphome.__main__ import check_permissions from esphome.upload_targets import PortType, get_port_type + if KEY_ZEPHYR not in CORE.data: + platform_config = config.get(CORE.target_platform) + if not platform_config: + raise EsphomeError( + "nRF52 platform configuration is missing; " + "please re-validate and recompile." + ) + set_core_data(platform_config) + set_framework(platform_config) + mcumgr_device: str | None = None if get_port_type(host) == PortType.SERIAL: @@ -494,17 +505,122 @@ def upload_program(config: ConfigType, args, host: str) -> bool: mcumgr_device = host else: if not CORE.using_toolchain_platformio: - raise EsphomeError("Not implemented yet") - result = _upload_using_platformio(config, host, ["-t", "upload"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: platformio serial upload + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader not in ( + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ): + raise EsphomeError("Not implemented yet") + check_and_install() + paths = get_build_paths() + env = get_build_env() + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + if not dfu_package.is_file(): + raise EsphomeError("Firmware not found. Please compile first.") + import time as _time + + import serial as _serial + import serial.tools.list_ports as _list_ports + + try: + ser = _serial.Serial(host, baudrate=1200, timeout=1) + ser.close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + + # Wait for device to reset (port disappears) + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host not in {p.device for p in _list_ports.comports()}: + break + else: + _LOGGER.warning( + "Device did not leave %s within 5 s; " + "it may not have entered bootloader mode", + host, + ) + + # Wait for DFU port to reappear + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host in {p.device for p in _list_ports.comports()}: + break + else: + raise EsphomeError( + f"DFU port {host!r} did not reappear within 10 s. " + "Check that the device entered DFU mode." + ) + + # Wait for udev to finish setting up device permissions + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + try: + check_permissions(host) + break + except EsphomeError: + _time.sleep(0.05) + else: + check_permissions(host) # raises with helpful message + + python = str(paths["python_executable"]) + if not run_command_ok( + [ + python, + "-m", + "nordicsemi.__main__", + "dfu", + "serial", + "-pkg", + str(dfu_package), + "-p", + host, + "-b", + "115200", + "--singlebank", + ], + env=env, + stream_output=True, + ): + raise EsphomeError("nRF52 serial DFU upload failed") + else: + result = _upload_using_platformio(config, host, ["-t", "upload"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True # Handled: serial upload if host == "PYOCD": - result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: platformio PYOCD upload + if not CORE.using_toolchain_platformio: + check_and_install() + paths = get_build_paths() + env = get_build_env() + build_dir = CORE.relative_pioenvs_path(CORE.name) + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "flash", + "--runner", + "pyocd", + "-d", + str(build_dir), + ] + if not run_command_ok( + west_cmd, + env=env, + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 pyocd flash failed") + else: + result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True # Handled: PYOCD upload # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths from .ble_logger import is_mac_address @@ -662,4 +778,43 @@ def run_compile(args, config: ConfigType) -> bool: ): raise EsphomeError("nRF52 native build failed") + # Zephyr's cmake places kernel artifacts in build_dir/zephyr/zephyr/ and + # merged.hex at build_dir/. Normalize to build_dir/zephyr/ so paths match + # get_download_types (which mirrors the platformio build output layout). + zephyr_dir = build_dir / "zephyr" + west_out = zephyr_dir / "zephyr" + for filename in ["zephyr.uf2"]: + src = west_out / filename + if src.is_file(): + shutil.copy2(src, zephyr_dir / filename) + + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + _GENPKG_PARAMS = { + BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + } + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader in ( + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ): + hex_file = west_out / "zephyr.hex" + dfu_package = build_dir / "firmware.zip" + genpkg_cmd = [ + str(paths["python_executable"]), + "-m", + "nordicsemi.__main__", + "dfu", + "genpkg", + ] + if bootloader in _GENPKG_PARAMS: + dev_type, sd_req = _GENPKG_PARAMS[bootloader] + genpkg_cmd += ["--dev-type", dev_type, "--sd-req", sd_req] + genpkg_cmd += ["--application", str(hex_file), str(dfu_package)] + if not run_command_ok(genpkg_cmd, env=env, stream_output=True): + raise EsphomeError("Failed to create adafruit DFU package") + return True diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index a35ba3ef85..05feadb001 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -111,10 +111,9 @@ def _get_version_str() -> str: def get_build_paths() -> dict: version = _get_version_str() + env_path = _get_python_env_path(version) return { - "python_executable": get_python_env_executable_path( - _get_python_env_path(version), "python" - ), + "python_executable": get_python_env_executable_path(env_path, "python"), "framework_path": _get_framework_path(version), } diff --git a/esphome/components/nrf52/requirements.txt b/esphome/components/nrf52/requirements.txt index 250d3a29cf..c55d35b2b1 100644 --- a/esphome/components/nrf52/requirements.txt +++ b/esphome/components/nrf52/requirements.txt @@ -1,3 +1,4 @@ west==1.5.0 ninja==1.13.0 cmake==4.3.2 +adafruit-nrfutil @ git+https://github.com/adafruit/Adafruit_nRF52_nrfutil.git@7fdfe15feee5f304fb7d9b031721dcefa1f72b58 diff --git a/esphome/storage_json.py b/esphome/storage_json.py index f754673b79..9d662df8f8 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_DISABLED, CONF_MDNS, KEY_CORE, + KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, Toolchain, @@ -179,6 +180,8 @@ class StorageJSON: hardware = esp32.get_esp32_variant(esph) framework_version = str(esp32.idf_version()) + elif esph.is_nrf52: + framework_version = str(esph.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) return StorageJSON( storage_version=1, name=esph.name, @@ -334,6 +337,19 @@ class StorageJSON: f"Please clean the build files and recompile." ) from err CORE.data[KEY_ESP32] = esp32_data + elif target_platform == const.PLATFORM_NRF52 and self.framework_version: + import esphome.config_validation as cv + + try: + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + self.framework_version + ) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{self.framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py new file mode 100644 index 0000000000..a60e23a337 --- /dev/null +++ b/tests/unit_tests/test_nrf52_upload.py @@ -0,0 +1,292 @@ +"""Tests for esphome.components.nrf52 upload_program and run_compile.""" + +from contextlib import ExitStack +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.zephyr.const import ( + KEY_BOARD, + KEY_BOOTLOADER, + KEY_EXTRA_BUILD_FILES, + KEY_KCONFIG, + KEY_OVERLAY, + KEY_PM_STATIC, + KEY_PRJ_CONF, + KEY_USER, + KEY_ZEPHYR, +) +import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_NRF52, + Toolchain, +) +from esphome.core import CORE, EsphomeError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _setup_nrf52_core( + bootloader: str = BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + toolchain: Toolchain = Toolchain.SDK_NRF, + build_path: Path | None = None, +) -> None: + CORE.name = "test_device" + if build_path is not None: + CORE.build_path = build_path + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_NRF52, + KEY_TARGET_FRAMEWORK: KEY_ZEPHYR, + KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2), + } + CORE.toolchain = toolchain + CORE.data[KEY_ZEPHYR] = { + KEY_BOARD: "adafruit_feather_nrf52840", + KEY_BOOTLOADER: bootloader, + KEY_PRJ_CONF: {}, + KEY_OVERLAY: {"": ""}, + KEY_EXTRA_BUILD_FILES: {}, + KEY_PM_STATIC: [], + KEY_USER: {}, + KEY_KCONFIG: "", + } + + +def _make_paths(tmp_path: Path) -> dict: + return { + "python_executable": tmp_path / "penv" / "python", + "framework_path": tmp_path / "framework", + } + + +# --------------------------------------------------------------------------- +# Config-reconstruction guard +# --------------------------------------------------------------------------- + + +class TestUploadProgramConfigGuard: + def test_missing_platform_config_raises(self, setup_core: Path) -> None: + """upload_program raises EsphomeError when the platform config section is absent.""" + from esphome.components.nrf52 import upload_program + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_NRF52, + KEY_TARGET_FRAMEWORK: KEY_ZEPHYR, + } + # KEY_ZEPHYR absent → reconstruction branch is entered + assert KEY_ZEPHYR not in CORE.data + + with pytest.raises(EsphomeError, match="platform configuration"): + upload_program(config={}, args=None, host="PYOCD") + + +# --------------------------------------------------------------------------- +# PYOCD upload path +# --------------------------------------------------------------------------- + + +class TestUploadProgramPyocd: + def test_pyocd_assembles_west_command( + self, setup_core: Path, tmp_path: Path + ) -> None: + """West flash command must include --runner pyocd and the build dir.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + + with ( + patch("esphome.components.nrf52.check_and_install"), + patch("esphome.components.nrf52.get_build_paths", return_value=paths), + patch("esphome.components.nrf52.get_build_env", return_value={}), + patch( + "esphome.components.nrf52.run_command_ok", return_value=True + ) as mock_run, + ): + result = upload_program(config={}, args=None, host="PYOCD") + + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert str(paths["python_executable"]) == cmd[0] + assert "west" in cmd + assert "flash" in cmd + assert "--runner" in cmd + assert "pyocd" in cmd + assert "-d" in cmd + assert str(build_dir) in cmd + + def test_pyocd_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: + """A failed west flash must raise EsphomeError.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.components.nrf52.check_and_install"), + patch( + "esphome.components.nrf52.get_build_paths", + return_value=_make_paths(tmp_path), + ), + patch("esphome.components.nrf52.get_build_env", return_value={}), + patch("esphome.components.nrf52.run_command_ok", return_value=False), + pytest.raises(EsphomeError, match="pyocd"), + ): + upload_program(config={}, args=None, host="PYOCD") + + +# --------------------------------------------------------------------------- +# Serial DFU upload path +# --------------------------------------------------------------------------- + + +def _enter_serial_dfu_patches( + stack: ExitStack, host: str, tmp_path: Path, paths: dict +) -> MagicMock: + """Enter all context managers needed for the serial DFU happy path. + + Returns the mock for ``run_command_ok`` so callers can inspect calls. + comports() returns [] on the first call (port disappeared) and a list + containing the host on every subsequent call (port reappeared). Patches + are applied directly on the real pyserial module attributes so they are + visible to the deferred ``import serial[.tools.list_ports] as _x`` + statements inside upload_program. + """ + import serial + import serial.tools.list_ports + + from esphome.upload_targets import PortType + + _comports_calls = [0] + + def _comports(): + _comports_calls[0] += 1 + if _comports_calls[0] == 1: + return [] # port disappeared → disappear loop breaks + return [MagicMock(device=host)] # port back → reappear loop breaks + + stack.enter_context( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL) + ) + stack.enter_context(patch("esphome.__main__.check_permissions")) + stack.enter_context(patch("esphome.components.nrf52.check_and_install")) + stack.enter_context( + patch("esphome.components.nrf52.get_build_paths", return_value=paths) + ) + stack.enter_context( + patch("esphome.components.nrf52.get_build_env", return_value={}) + ) + stack.enter_context(patch("time.sleep")) + # Patch directly on the real pyserial module so the deferred imports inside + # upload_program see our mocks regardless of how sys.modules is cached. + stack.enter_context(patch.object(serial, "Serial")) + stack.enter_context( + patch.object(serial.tools.list_ports, "comports", side_effect=_comports) + ) + return stack.enter_context( + patch("esphome.components.nrf52.run_command_ok", return_value=True) + ) + + +class TestUploadProgramSerialDfu: + def test_unsupported_bootloader_raises( + self, setup_core: Path, tmp_path: Path + ) -> None: + """An unknown bootloader must raise EsphomeError before touching the port.""" + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core( + bootloader="unknown_bootloader", build_path=tmp_path / "build" + ) + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + pytest.raises(EsphomeError, match="Not implemented"), + ): + upload_program(config={}, args=None, host="/dev/ttyACM0") + + def test_missing_firmware_raises(self, setup_core: Path, tmp_path: Path) -> None: + """Missing firmware.zip must raise EsphomeError before opening the serial port.""" + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + patch("esphome.components.nrf52.check_and_install"), + patch( + "esphome.components.nrf52.get_build_paths", + return_value=_make_paths(tmp_path), + ), + patch("esphome.components.nrf52.get_build_env", return_value={}), + pytest.raises(EsphomeError, match="Firmware not found"), + ): + # firmware.zip does not exist on disk → is_file() returns False + upload_program(config={}, args=None, host="/dev/ttyACM0") + + def test_serial_dfu_assembles_nordicsemi_command( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Nordicsemi DFU command must include pkg path, port, and --singlebank.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + result = upload_program(config={}, args=None, host=host) + + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "nordicsemi.__main__" in cmd + assert "dfu" in cmd + assert "serial" in cmd + assert "-pkg" in cmd + assert str(dfu_package) in cmd + assert "-p" in cmd + assert host in cmd + assert "--singlebank" in cmd + + def test_serial_dfu_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: + """A failed nordicsemi DFU must raise EsphomeError.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + mock_run.return_value = False + with pytest.raises(EsphomeError, match="serial DFU upload failed"): + upload_program(config={}, args=None, host=host) diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 7ba56b05f4..01683507c1 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -352,6 +352,7 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: mock_core.web_port = None mock_core.target_platform = "esp8266" mock_core.is_esp32 = False + mock_core.is_nrf52 = False mock_core.build_path = "/build" mock_core.firmware_bin = "/build/firmware.bin" mock_core.loaded_integrations = set() @@ -366,6 +367,34 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: assert result.toolchain is None +def test_storage_json_from_esphome_core_nrf52(setup_core: Path) -> None: + """Test from_esphome_core captures the framework version on nRF52.""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + mock_core = MagicMock() + mock_core.name = "nrf_device" + mock_core.friendly_name = "nRF Device" + mock_core.comment = None + mock_core.address = "nrf.local" + mock_core.web_port = None + mock_core.target_platform = "nrf52" + mock_core.is_esp32 = False + mock_core.is_nrf52 = True + mock_core.data = {KEY_CORE: {KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2)}} + mock_core.build_path = "/build/nrf_device" + mock_core.firmware_bin = "/build/nrf_device/firmware.bin" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} + mock_core.target_framework = "zephyr" + mock_core.toolchain = None + + result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) + + assert result.target_platform == "NRF52" + assert result.framework_version == "2.9.2" + + def test_storage_json_load_valid_file(tmp_path: Path) -> None: """Test StorageJSON.load with valid JSON file.""" storage_data = { @@ -787,6 +816,73 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: assert result.esphome_version == "1.14.0" # Should map to esphome_version +def _make_nrf52_storage( + framework_version: str | None = None, +) -> storage_json.StorageJSON: + return storage_json.StorageJSON( + storage_version=1, + name="dev", + friendly_name=None, + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="dev.local", + web_port=None, + target_platform="NRF52", + build_path=Path("/build"), + firmware_bin_path=Path("/build/zephyr/zephyr.bin"), + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="zephyr", + core_platform="nrf52", + framework_version=framework_version, + ) + + +def test_storage_json_nrf52_framework_version_round_trip(setup_core: Path) -> None: + """Sidecar framework_version restores CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION].""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + storage = _make_nrf52_storage("2.9.2") + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + assert json.loads(path.read_text())["framework_version"] == "2.9.2" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.framework_version == "2.9.2" + + loaded.apply_to_core() + assert CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] == cv.Version(2, 9, 2) + + +def test_storage_json_nrf52_apply_to_core_without_framework_version( + setup_core: Path, +) -> None: + """Older sidecars lacking framework_version don't populate KEY_FRAMEWORK_VERSION.""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + loaded = _make_nrf52_storage(framework_version=None) + assert loaded.framework_version is None + + loaded.apply_to_core() + assert KEY_FRAMEWORK_VERSION not in CORE.data[KEY_CORE] + + +def test_storage_json_nrf52_apply_to_core_raises_on_invalid_framework_version( + setup_core: Path, +) -> None: + """A malformed version string fails with an actionable error at parse time.""" + from esphome.core import EsphomeError + + loaded = _make_nrf52_storage(framework_version="not-a-version") + + with pytest.raises(EsphomeError, match="clean the build"): + loaded.apply_to_core() + + def test_storage_json_load_area(tmp_path: Path) -> None: """``area`` round-trips through load; absence loads as None.""" file_path = tmp_path / "with_area.json" From fd7fc6b8e8f398c96b230db8f01b4d03135d37d2 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:00:23 -0700 Subject: [PATCH 1854/2214] Bump bundled esphome-device-builder to 1.0.20 (#17244) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 66dad179bb..5626d18fcc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.20 RUN \ platformio settings set enable_telemetry No \ From 0fb100f2d12a5453abbaade229bd9fca419ef163 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:05:00 -0400 Subject: [PATCH 1855/2214] [core] Suppress unactionable legacy-redaction warning for substitutions (#17242) --- esphome/__main__.py | 27 ++++++++++++++++++++++----- tests/unit_tests/test_main.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 48fee1e97e..1062df7167 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1488,12 +1488,29 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0" def _redact_with_legacy_fallback(output: str) -> str: unmarked: set[str] = set() + # Track the top-level ``substitutions:`` block. Its keys are arbitrary + # user-chosen names with no schema validator, so the ``cv.sensitive(...)`` + # migration named in the warning can't be applied to them. Their values are + # still redacted, but emitting the (unactionable) deprecation warning would + # only confuse users. + in_substitutions = False - def _replace(m: re.Match[str]) -> str: - unmarked.add(m.group("key")) - return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" - - output = _LEGACY_REDACTION_RE.sub(_replace, output) + lines = output.split("\n") + for i, line in enumerate(lines): + # A non-indented, non-blank line is a top-level key that opens or + # closes the substitutions block. + if line and not line[0].isspace(): + in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:") + m = _LEGACY_REDACTION_RE.search(line) + if m is None: + continue + if not in_substitutions: + unmarked.add(m.group("key")) + lines[i] = ( + f"{line[: m.start()]}{m.group('key')}: " + f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}" + ) + output = "\n".join(lines) for key in sorted(unmarked): _LOGGER.warning( "Field '%s' is being redacted by a legacy substring heuristic. " diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index b2011259c1..65bf4a583e 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -442,6 +442,36 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( assert not any("legacy substring" in rec.message for rec in caplog.records) +def test_redact_with_legacy_fallback__substitutions_redacted_without_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Substitution keys have no schema validator, so their values are still + redacted but the unactionable cv.sensitive migration warning is suppressed + (see issue #17225).""" + text = "substitutions:\n ota_password: apolloautomation\nesphome:\n name: x\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ota_password: \\033[8mapolloautomation\\033[28m" in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__warns_after_substitutions_block( + caplog: pytest.LogCaptureFixture, +) -> None: + """The suppression ends at the next top-level key; a sensitive-shaped field + in a later block (a real schema field) still warns, while the substitution + above it does not.""" + text = ( + "substitutions:\n ota_password: apolloautomation\nwifi:\n password: hunter2\n" + ) + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ota_password: \\033[8mapolloautomation\\033[28m" in out + assert "password: \\033[8mhunter2\\033[28m" in out + assert any("'password'" in rec.message for rec in caplog.records) + assert not any("ota_password" in rec.message for rec in caplog.records) + + def test_command_config__invokes_legacy_fallback_when_redacting( tmp_path: Path, capfd: CaptureFixture[str] ) -> None: From bda789052d67332ea89c811a0b64a77b475e9b3f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:17:09 -0400 Subject: [PATCH 1856/2214] [espnow] Don't throttle ESP-NOW RX when deep_sleep is present (#17240) --- esphome/components/espnow/espnow_component.cpp | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 403e6f4944..f89b4a2ff1 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -28,9 +28,6 @@ namespace esphome::espnow { static constexpr const char *TAG = "espnow"; -static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50; -static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100; - ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const LogString *espnow_error_to_str(esp_err_t error) { @@ -204,11 +201,6 @@ void ESPNowComponent::enable_() { esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); -#ifdef USE_DEEP_SLEEP - esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW); - esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL); -#endif - this->state_ = ESPNOW_STATE_ENABLED; for (auto peer : this->peers_) { From d3892b8399c7f015bbbced0b50e943dcbacd2b17 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:03:09 -0400 Subject: [PATCH 1857/2214] [platformio] Extract toolchain-agnostic PlatformIO library converter (#17243) --- esphome/espidf/component.py | 724 ++------------------ esphome/platformio/library.py | 717 +++++++++++++++++++ tests/unit_tests/test_espidf_component.py | 83 +-- tests/unit_tests/test_platformio_library.py | 229 +++++++ 4 files changed, 1023 insertions(+), 730 deletions(-) create mode 100644 esphome/platformio/library.py create mode 100644 tests/unit_tests/test_platformio_library.py diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index cfd42916b2..5029e014a4 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -1,163 +1,42 @@ -from collections import deque -from collections.abc import Callable -from dataclasses import dataclass, field -import glob -import hashlib -import itertools -import json +"""ESP-IDF backend for the shared PlatformIO library converter. + +The toolchain-agnostic resolution/download/caching pipeline lives in +``esphome.platformio.library``; this module only adds the ESP-IDF specifics: +emitting an ``idf_component_register`` ``CMakeLists.txt`` + ``idf_component.yml`` +for each resolved library, running any PlatformIO ``extraScript``, and the +ESP-IDF platform/framework compatibility defaults. +""" + import logging import os from pathlib import Path -import re -import tempfile -from typing import Any, TypeVar -from urllib.parse import urlparse, urlsplit, urlunsplit -from esphome import git, yaml_util from esphome.core import CORE, Library -from esphome.espidf.framework import archive_extract_all, download_from_mirrors, rmdir from esphome.helpers import write_file_if_changed +from esphome.platformio.library import ( + DEFAULT_BUILD_FLAGS, + DEFAULT_BUILD_INCLUDE_DIR, + DEFAULT_BUILD_SRC_FILTER, + ESPHOME_DATA_EXTRA_CMAKE_KEY, + ESPHOME_DATA_KEY, + SRC_FILE_EXTENSIONS, + ConvertedLibrary as IDFComponent, + LibraryBackend, + PathType, + collect_filtered_files, + convert_libraries, + ensure_list, + split_list_by_condition, +) _LOGGER = logging.getLogger(__name__) -PathType = str | os.PathLike - -# -# Constants from platformio -# - -FILTER_REGEX = re.compile(r"([+-])<([^>]+)>") -DEFAULT_BUILD_SRC_FILTER = ( - "+<*> -<.git/> -<.svn/> - - - -" -) -DEFAULT_BUILD_SRC_DIRS = "src" -DEFAULT_BUILD_INCLUDE_DIR = "include" -DEFAULT_BUILD_FLAGS = [] -SRC_FILE_EXTENSIONS = [ - ".c", - ".cpp", - ".cc", - ".cxx", - ".c++", - ".S", - ".spp", - ".SPP", - ".sx", - ".s", - ".asm", - ".ASM", -] - ESP32_PLATFORM = "espressif32" -DOMAIN = "pio_components" - -ESPHOME_DATA_KEY = "ESPHOME" -ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" -class Source: - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: - raise NotImplementedError - - -class URLSource(Source): - def __init__(self, url: str): - self.url = url - - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: - base_dir = Path(CORE.data_dir) / DOMAIN - h = hashlib.new("sha256") - h.update(self.url.encode()) - if salt: - h.update(salt.encode()) - path = base_dir / h.hexdigest()[:8] / dir_suffix - # Marker file written last to signal a complete extraction. Using a - # marker (instead of just `path.is_dir()`) means an interrupted - # extraction is correctly detected and re-run on the next invocation, - # and lets us extract directly into ``path`` — avoiding a - # post-extraction rename that races with antivirus on Windows. - extracted_marker = path / ".esphome_extracted" - if not extracted_marker.is_file() or force: - rmdir(path, msg=f"Clean up library directory {path}") - - # Download in temporary file - with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading %s ...", self.url) - _LOGGER.debug("Location: %s", path) - - download_from_mirrors([self.url], {}, tmp.file) - - _LOGGER.debug("Extracting archive to %s ...", path) - archive_extract_all(tmp.file, path) - extracted_marker.touch() - return path - - def __str__(self): - return self.url - - -class GitSource(Source): - def __init__(self, url: str, ref: str | None): - self.url = url - self.ref = ref - - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: - path, _ = git.clone_or_update( - url=self.url, - ref=self.ref, - refresh=git.NEVER_REFRESH if not force else None, - domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, - submodules=[], - subpath=Path(dir_suffix), - ) - return path - - def __str__(self): - return f"{self.url}#{self.ref}" if self.ref else self.url - - -class InvalidIDFComponent(Exception): - pass - - -class IDFComponent: - def __init__(self, name: str, version: str, source: Source | None): - self.name = name - self.version = version - self.source = source - self.data = {} - self.dependencies: list[IDFComponent] = [] - self._path: Path | None = None - - def __str__(self): - return f"{self.name}@{self.version}={self.source}" - - @property - def path(self) -> Path: - if self._path is None: - raise RuntimeError(f"path not set for component {self}") - return self._path - - @path.setter - def path(self, value: Path) -> None: - self._path = value - - def get_sanitized_name(self): - return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) - - def get_require_name(self): - return self.get_sanitized_name().replace("/", "__") - - def download(self, force: bool = False, salt: str = ""): - """ - The dependency name should match the directory name at the end of the override path. - The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. - If you want to specify the full name of the component with the namespace, replace / in the component name with __. - @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html - """ - self.path = self.source.download( - self.get_sanitized_name(), force=force, salt=salt - ) +def _idf_framework() -> str: + """The framework token an ESP-IDF library manifest is expected to declare.""" + return "arduino" if CORE.using_arduino else "espidf" def _apply_extra_script(component: IDFComponent) -> None: @@ -190,119 +69,6 @@ def _apply_extra_script(component: IDFComponent) -> None: component.data["build"]["flags"] = flags -T = TypeVar("T") - - -def _ensure_list(obj: T | list[T]) -> list[T]: - """ - Convert an object to a list if it isn't already a list. - - Args: - obj: Object that may or may not already be a list. - - Returns: - list[T]: The original list if ``obj`` is a list, otherwise a single-item - list containing ``obj``. - """ - return [obj] if not isinstance(obj, list) else obj - - -def _owner_pkgname_to_name(owner: str | None, pkgname: str) -> str: - """ - Convert owner and package name to a standardized component name. - - This function combines owner and package name with a forward slash when - both are provided, otherwise returns just the package name. - - Args: - owner: The owner/username of the package (can be None) - pkgname: The name of the package - - Returns: - str: The standardized component name in "owner/pkgname" format or just "pkgname" - """ - return f"{owner}/{pkgname}" if owner else pkgname - - -def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[str]: - """ - Recursively match files in a directory according to include/exclude patterns. - - This function processes a list of filter strings that indicate which files - to include or exclude. Each filter is parsed into patterns with a sign: - '+' for inclusion and '-' for exclusion. Directory patterns ending with '/' - are normalized to include all their contents recursively. - - Args: - src_dir (PathType): Root directory to search within. - src_filters (list[str]): List of filter strings, which may contain multiple - patterns. Each pattern can start with '+' or '-' to indicate inclusion - or exclusion. - - Returns: - list[str]: List of matched file paths as strings. Only files (not directories) - are returned, even if a directory matches a pattern. - """ - matches = list( - itertools.chain.from_iterable( - FILTER_REGEX.findall(src_filter) for src_filter in src_filters - ) - ) - - selected = set() - - for sign, pattern in matches: - pattern = pattern.strip() - - if pattern.endswith("/"): - pattern = pattern.rstrip("/") + "/**" - - # glob.escape has no pathlib equivalent and the matcher works on raw - # path strings, so PTH118/PTH207 don't apply here. - full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 - - matched = [] - for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 - if not Path(item).is_dir(): - matched.append(item) - else: - # PlatformIO quirk: a directory matched with "*" should include all its - # nested files and subdirectories, not just the directory itself. - for root, _, files in os.walk(item): - matched.extend([str(Path(root) / f) for f in files]) - - if sign == "+": - selected.update(matched) - elif sign == "-": - selected.difference_update(matched) - - return [r for r in selected if Path(r).is_file()] - - -def _split_list_by_condition( - items: list[str], match_fn: Callable[[str], str | None] -) -> tuple[list[str], list[str]]: - """ - Splits a list into two lists based on a matching function. - - Args: - items: List of items to split. - match_fn: Function that returns a value for items that should go into the "matched" list. - - Returns: - A tuple (matched, non_matched) - """ - matched = [] - non_matched = [] - for item in items: - result = match_fn(item) - if result: - matched.append(result) - else: - non_matched.append(item) - return matched, non_matched - - def generate_cmakelists_txt(component: IDFComponent) -> str: """ Generate a CMakeLists.txt file for an ESP-IDF component. @@ -333,15 +99,15 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: build_include_dir = component.data.get("build", {}).get( "includeDir", DEFAULT_BUILD_INCLUDE_DIR ) - build_src_filter = _ensure_list( + build_src_filter = ensure_list( component.data.get("build", {}).get("srcFilter", DEFAULT_BUILD_SRC_FILTER) ) - build_flags = _ensure_list( + build_flags = ensure_list( component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS) ) # List all sources files - build_src_files = _collect_filtered_files( + build_src_files = collect_filtered_files( component.path / Path(build_src_dir), build_src_filter ) @@ -361,13 +127,13 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: ] # Handle build flags - include_dir_flags, build_flags = _split_list_by_condition( + include_dir_flags, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None ) - link_directories, build_flags = _split_list_by_condition( + link_directories, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None ) - link_libraries, build_flags = _split_list_by_condition( + link_libraries, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None ) @@ -379,7 +145,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: ] # Split build_flags list into private and public lists - private_build_flags, public_build_flags = _split_list_by_condition( + private_build_flags, public_build_flags = split_list_by_condition( build_flags, lambda a: a if a.startswith("-W") else None ) @@ -453,6 +219,8 @@ def generate_idf_component_yml(component: IDFComponent) -> str: Returns: YAML string representation of ESP-IDF component configuration """ + from esphome import yaml_util + data = {} description = component.data.get("description") @@ -477,410 +245,24 @@ def generate_idf_component_yml(component: IDFComponent) -> str: return yaml_util.dump(data) -def _check_library_data(data: dict): - """ - Check if a library data is compatible with the ESP-IDF framework. - - A platform mismatch (e.g. an AVR-only library on ESP32) raises - ``InvalidIDFComponent`` so the caller skips the library. A framework - mismatch only logs a warning — PIO manifests often understate the - frameworks they actually compile under, and IDF (unlike PIO's - ``lib_compat_mode``) has no opt-out, so we include the library anyway. - - Args: - data: PIO library manifest dict being processed. - - Raises: - InvalidIDFComponent: If the library does not support the ESP32 platform. - """ - platforms = data.get("platforms", "*") - if isinstance(platforms, str): - platforms = [a.strip() for a in platforms.split(",")] - platforms = _ensure_list(platforms) - - # Check if library supports ESP-IDF platform - valid_platforms = "*" in platforms or ESP32_PLATFORM in platforms - - if not valid_platforms: - raise InvalidIDFComponent(f"Unsupported library platforms: {platforms}") - - frameworks = data.get("frameworks", "*") - if isinstance(frameworks, str): - frameworks = [a.strip() for a in frameworks.split(",")] - frameworks = _ensure_list(frameworks) - - # Check if library declares the active framework. PIO library manifests - # often list only "arduino" even when the library actually compiles fine - # under ESP-IDF, and IDF (unlike PIO with `lib_compat_mode`) has no way to - # opt out of the check. Warn instead of failing so the user isn't forced to - # fork the library to fix the manifest. - framework = "arduino" if CORE.using_arduino else "espidf" - valid_framework = "*" in frameworks or framework in frameworks - - if not valid_framework: - _LOGGER.warning( - "Library %s declares frameworks %s that do not include '%s'; including anyway", - data.get("name", ""), - frameworks, - framework, - ) - - -def _parse_library_json(library_json_path: PathType): - """ - Load and parse a JSON file describing a library. - - Args: - library_json_path (PathType): Path to the JSON file. - - Returns: - dict: Parsed JSON content as a Python dictionary. - """ - with Path(library_json_path).open(encoding="utf8") as fp: - return json.load(fp) - - -def _parse_library_properties(library_properties_path: PathType): - """ - Parse a key-value platformio .properties style file into a dictionary. - - Args: - library_properties_path (PathType): Path to the properties file. - - Returns: - dict[str, str]: Mapping of parsed property keys to values. - """ - with Path(library_properties_path).open(encoding="utf8") as fp: - data = {} - for line in fp.read().splitlines(): - line = line.strip() - if not line or "=" not in line: - continue - # skip comments - if line.startswith("#"): - continue - key, value = line.split("=", 1) - if not value.strip(): - continue - data[key.strip()] = value.strip() - return data - - -def _make_registry_client() -> Any: - """Create a minimal PlatformIO registry client with no system filtering. - - ``is_system_compatible`` is forced True so version selection is driven purely - by the requested version requirements -- ESP-IDF/target compatibility is - handled elsewhere, not by the PlatformIO registry. - """ - from platformio.package.manager._registry import PackageManagerRegistryMixin - - class _Registry(PackageManagerRegistryMixin): - def __init__(self) -> None: - self._registry_client = None - self.pkg_type = "library" - - @staticmethod - def is_system_compatible(value: Any, custom_system: Any = None) -> bool: - return True - - return _Registry() - - -def _resolve_registry_version( - owner: str | None, pkgname: str, requirements: set[str] -) -> tuple[str, str, str, str]: - """Resolve a registry package to the single highest version satisfying ALL - the given requirements; return ``(owner, name, version, download_url)``. - - Intersecting every requirement (rather than resolving each consumer in - isolation) makes the result independent of processing order and guarantees - no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as - both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. - """ - from platformio.package.meta import PackageSpec - - registry = _make_registry_client() - package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) - owner = package["owner"]["username"] - name = package["name"] - - # Chaining the per-requirement filter intersects all constraints. - versions = package.get("versions") or [] - for requirement in sorted(requirements): - versions = registry.get_compatible_registry_versions( - versions, PackageSpec(owner=owner, name=name, requirements=requirement) - ) - if not versions: - raise RuntimeError( - f"No version of {owner}/{name} satisfies all requirements " - f"{sorted(requirements)} requested across the library tree" - ) - - best = registry.pick_best_registry_version(versions) - pkgfile = registry.pick_compatible_pkg_file(best["files"]) - if not pkgfile: - raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") - return owner, name, best["name"], pkgfile["download_url"] - - -def _normalize_dependencies(dependencies: Any) -> list[dict]: - """Normalize a library manifest's ``dependencies`` to a list of dicts. - - PIO's library.json accepts both the list-of-dicts form and the shorthand - dict form (``{"owner/Name": "version_spec"}``); normalize the latter so - callers see a uniform list. - """ - if not dependencies: - return [] - if isinstance(dependencies, dict): - normalized = [] - for raw_name, spec in dependencies.items(): - if "/" in raw_name: - owner, pkgname = raw_name.split("/", 1) - else: - owner, pkgname = None, raw_name - entry = {"name": pkgname, "owner": owner} - if isinstance(spec, dict): - entry.update(spec) - else: - entry["version"] = spec - normalized.append(entry) - return normalized - return [d for d in dependencies if isinstance(d, dict)] - - -@dataclass -class _LibNode: - """A node in the library dependency graph being resolved as a batch.""" - - key: str - is_git: bool - owner: str | None = None - pkgname: str | None = None - requirements: set[str] = field(default_factory=set) - url: str | None = None - ref: str | None = None - edges: set[str] = field(default_factory=set) - - -def _node_key( - name: str | None, version: str | None, repository: str | None -) -> tuple[str, bool, tuple[str | None, str | None]]: - """Return ``(key, is_git, locator)`` for a library or dependency spec. - - The key is derived from the *input* spec (the registry name as written, or - the git URL path), not the resolved canonical name. So a package referenced - inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps - to distinct keys and isn't deduplicated; ``generate_idf_components`` warns - about that after resolution rather than merging the nodes. - """ - if repository: - split_result = urlsplit(repository) - key = str(split_result.path).strip("/").removesuffix(".git") - ref = split_result.fragment.strip() or None - url = urlunsplit(split_result._replace(fragment="")) - return key, True, (url, ref) - if name and "/" in name: - owner, pkgname = name.split("/", 1) - else: - owner, pkgname = None, name - return name, False, (owner, pkgname) +def _emit_idf_component(component: IDFComponent) -> None: + """Write the ESP-IDF build files for a resolved library into its cache dir.""" + _apply_extra_script(component) + write_file_if_changed( + component.path / "CMakeLists.txt", + generate_cmakelists_txt(component), + ) + write_file_if_changed( + component.path / "idf_component.yml", + generate_idf_component_yml(component), + ) def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: - """Resolve and convert a batch of PlatformIO libraries to IDF components. - - Resolves the whole set together rather than each library independently: it - walks the dependency graph collecting every version *requirement* per - component name, then resolves each name once to a single version satisfying - all of them. So a transitive dependency shared under - different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and - ``esp_wireguard``) becomes one component instead of two clashing - ``override_path`` entries -- order-independently, and without ever violating - a stated constraint. - - The returned list holds the top-level components (those directly requested); - transitive dependencies are converted too and wired into each component's - generated manifest. - - ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by - short name (part after the ``/``), matched against both the top-level - libraries and every dependency discovered during the graph walk. - """ - nodes: dict[str, _LibNode] = {} - - lib_ignore = { - name.split("/")[-1].lower() - for name in CORE.platformio_options.get("lib_ignore", []) - } - - # The generated CMakeLists.txt/idf_component.yml inside the shared cache - # bake in the dependency wiring, which lib_ignore changes; salt the cache - # path so configs with different lib_ignore values don't fight over (and - # constantly rewrite) the same converted component files. - salt = ( - hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] - if lib_ignore - else "" + """Resolve and convert a batch of PlatformIO libraries to IDF components.""" + backend = LibraryBackend( + platform=ESP32_PLATFORM, + framework=_idf_framework(), + emit=_emit_idf_component, ) - - def is_ignored(name: str | None) -> bool: - if not lib_ignore or name is None: - return False - return name.split("/")[-1].lower() in lib_ignore - - def add_spec(name: str | None, version: str | None, repository: str | None) -> str: - key, is_git, locator = _node_key(name, version, repository) - node = nodes.get(key) or _LibNode(key=key, is_git=is_git) - nodes[key] = node - if is_git: - node.is_git = True - node.url, node.ref = locator - else: - node.owner, node.pkgname = locator - if version: - node.requirements.add(version) - return key - - top_level = [ - add_spec(library.name, library.version, library.repository) - for library in libraries - if not is_ignored(library.name) - ] - - # Collect + resolve to a fixpoint: a node is (re)resolved whenever its - # requirement set has grown since the last time, so every requirement in the - # graph is accounted for before conversion. - components: dict[str, IDFComponent] = {} - resolved_requirements: dict[str, frozenset[str]] = {} - top_level_keys = set(top_level) - worklist = deque(dict.fromkeys(top_level)) - while worklist: - key = worklist.popleft() - node = nodes[key] - - # A node is queued once per referring edge; skip the (uncached) registry - # lookup + download + dependency walk unless its requirement set grew - # since the last resolve. Requirements only ever grow, so this still - # converges the fixpoint and terminates dependency cycles. - requirements = frozenset(node.requirements) - if resolved_requirements.get(key) == requirements: - continue - resolved_requirements[key] = requirements - - if node.is_git: - component = IDFComponent(key, "*", GitSource(node.url, node.ref)) - else: - owner, name, version, url = _resolve_registry_version( - node.owner, node.pkgname, node.requirements - ) - component = IDFComponent( - _owner_pkgname_to_name(owner, name), version, URLSource(url) - ) - component.download(salt=salt) - - library_json_path = component.path / "library.json" - library_properties_path = component.path / "library.properties" - if library_json_path.is_file(): - component.data = _parse_library_json(library_json_path) - elif library_properties_path.is_file(): - component.data = _parse_library_properties(library_properties_path) - else: - raise RuntimeError( - f"Invalid PIO library {key}: missing library.json and " - "library.properties" - ) - - try: - _check_library_data(component.data) - except InvalidIDFComponent as e: - # Skip an incompatible transitive dependency, but fail fast if a - # top-level library the build explicitly requested is incompatible. - if key in top_level_keys: - raise RuntimeError( - f"Requested library {key} is not compatible with ESP-IDF: {e}" - ) from e - _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) - continue - components[key] = component - - # Requirements changed (we got past the short-circuit above), so - # (re)walk this component's dependencies. - node.edges = set() - for dependency in _normalize_dependencies(component.data.get("dependencies")): - if "name" not in dependency or "version" not in dependency: - continue - try: - _check_library_data(dependency) - except InvalidIDFComponent as e: - _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) - continue - dep_name = _owner_pkgname_to_name( - dependency.get("owner"), dependency.get("name") - ) - if is_ignored(dep_name): - _LOGGER.debug("Skip ignored dependency %s", dep_name) - continue - # The version field may actually be a URL (git/archive dependency). - dep_version = dependency["version"] - dep_url = None - try: - parsed = urlparse(dep_version) - if all([parsed.scheme, parsed.netloc]): - dep_url, dep_version = dep_version, None - except (TypeError, ValueError): - pass - dep_key = add_spec(dep_name, dep_version, dep_url) - node.edges.add(dep_key) - worklist.append(dep_key) - - # A git source wins over any registry version requested for the same - # component. That's intentional, but warn so a dropped registry pin isn't a - # silent surprise. - for node in nodes.values(): - if node.is_git and node.requirements: - _LOGGER.warning( - "Library %s is requested both from a git source (%s) and as " - "registry version(s) %s; using the git source.", - node.key, - node.url, - sorted(node.requirements), - ) - - # Two graph nodes that resolve to the same component name (e.g. a package - # referenced both bare and as ``owner/name``) are not deduplicated and can - # produce conflicting component definitions. Warn so it's not silent. - canonical_keys: dict[str, str] = {} - for node_key, component in components.items(): - canonical = component.get_sanitized_name() - if canonical_keys.setdefault(canonical, node_key) != node_key: - _LOGGER.warning( - "Library %s is referenced under multiple names (%s and %s); these " - "are not deduplicated. Reference it consistently as %s.", - canonical, - canonical_keys[canonical], - node_key, - canonical, - ) - - # Wire each component's dependencies to the single resolved instances, then - # regenerate build files. - for key, component in components.items(): - component.dependencies = [ - components[dep_key] - for dep_key in sorted(nodes[key].edges) - if dep_key in components - ] - for component in components.values(): - _apply_extra_script(component) - write_file_if_changed( - component.path / "CMakeLists.txt", - generate_cmakelists_txt(component), - ) - write_file_if_changed( - component.path / "idf_component.yml", - generate_idf_component_yml(component), - ) - - return [components[key] for key in top_level if key in components] + return convert_libraries(libraries, backend) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py new file mode 100644 index 0000000000..43282c7aa0 --- /dev/null +++ b/esphome/platformio/library.py @@ -0,0 +1,717 @@ +"""Toolchain-agnostic PlatformIO library converter. + +Resolves a batch of PlatformIO/Arduino library specs (added via +``cg.add_library(...)``) into local, build-ready directories: it fetches each +library (registry/git/url), parses its ``library.json`` / ``library.properties`` +manifest, resolves the whole dependency graph to a single version per name, and +caches the result under ``/pio_components``. + +The toolchain-specific part — turning a resolved library into build files +(ESP-IDF ``idf_component_register`` CMakeLists, or a Zephyr module) — is supplied +by a :class:`LibraryBackend`. This module owns everything that is the same +regardless of which toolchain consumes the result. +""" + +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass, field +import glob +import hashlib +import itertools +import json +import logging +import os +from pathlib import Path +import re +import tempfile +from typing import Any, TypeVar +from urllib.parse import urlparse, urlsplit, urlunsplit + +from esphome import git +from esphome.core import CORE, Library +from esphome.framework_helpers import archive_extract_all, download_from_mirrors, rmdir + +_LOGGER = logging.getLogger(__name__) + +PathType = str | os.PathLike + +# +# Constants from platformio +# + +FILTER_REGEX = re.compile(r"([+-])<([^>]+)>") +DEFAULT_BUILD_SRC_FILTER = ( + "+<*> -<.git/> -<.svn/> - - - -" +) +DEFAULT_BUILD_SRC_DIRS = "src" +DEFAULT_BUILD_INCLUDE_DIR = "include" +DEFAULT_BUILD_FLAGS = [] +SRC_FILE_EXTENSIONS = [ + ".c", + ".cpp", + ".cc", + ".cxx", + ".c++", + ".S", + ".spp", + ".SPP", + ".sx", + ".s", + ".asm", + ".ASM", +] + +DOMAIN = "pio_components" + +ESPHOME_DATA_KEY = "ESPHOME" +ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" + + +class Source: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + raise NotImplementedError + + +class URLSource(Source): + def __init__(self, url: str): + self.url = url + + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + base_dir = Path(CORE.data_dir) / DOMAIN + h = hashlib.new("sha256") + h.update(self.url.encode()) + if salt: + h.update(salt.encode()) + path = base_dir / h.hexdigest()[:8] / dir_suffix + # Marker file written last to signal a complete extraction. Using a + # marker (instead of just `path.is_dir()`) means an interrupted + # extraction is correctly detected and re-run on the next invocation, + # and lets us extract directly into ``path`` — avoiding a + # post-extraction rename that races with antivirus on Windows. + extracted_marker = path / ".esphome_extracted" + if not extracted_marker.is_file() or force: + rmdir(path, msg=f"Clean up library directory {path}") + + # Download in temporary file + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading %s ...", self.url) + _LOGGER.debug("Location: %s", path) + + download_from_mirrors([self.url], {}, tmp.file) + + _LOGGER.debug("Extracting archive to %s ...", path) + archive_extract_all(tmp.file, path) + extracted_marker.touch() + return path + + def __str__(self): + return self.url + + +class GitSource(Source): + def __init__(self, url: str, ref: str | None): + self.url = url + self.ref = ref + + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + path, _ = git.clone_or_update( + url=self.url, + ref=self.ref, + refresh=git.NEVER_REFRESH if not force else None, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, + submodules=[], + subpath=Path(dir_suffix), + ) + return path + + def __str__(self): + return f"{self.url}#{self.ref}" if self.ref else self.url + + +class InvalidLibrary(Exception): + pass + + +class ConvertedLibrary: + """A resolved PlatformIO library plus its parsed manifest and on-disk path. + + Toolchain-neutral: ESP-IDF treats it as a component, Zephyr as a module. The + backend reads ``name``/``version``/``data``/``dependencies``/``path`` to emit + its build files. + """ + + def __init__(self, name: str, version: str, source: Source | None): + self.name = name + self.version = version + self.source = source + self.data = {} + self.dependencies: list[ConvertedLibrary] = [] + self._path: Path | None = None + + def __str__(self): + return f"{self.name}@{self.version}={self.source}" + + @property + def path(self) -> Path: + if self._path is None: + raise RuntimeError(f"path not set for library {self}") + return self._path + + @path.setter + def path(self, value: Path) -> None: + self._path = value + + def get_sanitized_name(self): + return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) + + def get_require_name(self): + return self.get_sanitized_name().replace("/", "__") + + def download(self, force: bool = False, salt: str = ""): + """Fetch the library into the shared cache and record its ``path``. + + The cache directory is named after the sanitized library name; backends + rely on that name to identify the unit they build (e.g. ESP-IDF uses the + directory name as the component name, replacing ``/`` with ``__`` via + ``get_require_name``). + """ + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) + + +@dataclass +class LibraryBackend: + """Toolchain hooks for :func:`convert_libraries`. + + ``platform``/``framework`` drive the manifest compatibility check. + ``emit`` writes the toolchain-specific build files into a resolved library's + ``path`` (e.g. the ESP-IDF ``CMakeLists.txt`` + ``idf_component.yml``, or a + Zephyr ``module.yml`` + ``CMakeLists.txt``). + """ + + platform: str + framework: str + emit: Callable[["ConvertedLibrary"], None] + + +T = TypeVar("T") + + +def ensure_list(obj: T | list[T]) -> list[T]: + """ + Convert an object to a list if it isn't already a list. + + Args: + obj: Object that may or may not already be a list. + + Returns: + list[T]: The original list if ``obj`` is a list, otherwise a single-item + list containing ``obj``. + """ + return [obj] if not isinstance(obj, list) else obj + + +def _owner_pkgname_to_name(owner: str | None, pkgname: str) -> str: + """ + Convert owner and package name to a standardized component name. + + This function combines owner and package name with a forward slash when + both are provided, otherwise returns just the package name. + + Args: + owner: The owner/username of the package (can be None) + pkgname: The name of the package + + Returns: + str: The standardized component name in "owner/pkgname" format or just "pkgname" + """ + return f"{owner}/{pkgname}" if owner else pkgname + + +def collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[str]: + """ + Recursively match files in a directory according to include/exclude patterns. + + This function processes a list of filter strings that indicate which files + to include or exclude. Each filter is parsed into patterns with a sign: + '+' for inclusion and '-' for exclusion. Directory patterns ending with '/' + are normalized to include all their contents recursively. + + Args: + src_dir (PathType): Root directory to search within. + src_filters (list[str]): List of filter strings, which may contain multiple + patterns. Each pattern can start with '+' or '-' to indicate inclusion + or exclusion. + + Returns: + list[str]: List of matched file paths as strings. Only files (not directories) + are returned, even if a directory matches a pattern. + """ + matches = list( + itertools.chain.from_iterable( + FILTER_REGEX.findall(src_filter) for src_filter in src_filters + ) + ) + + selected = set() + + for sign, pattern in matches: + pattern = pattern.strip() + + if pattern.endswith("/"): + pattern = pattern.rstrip("/") + "/**" + + # glob.escape has no pathlib equivalent and the matcher works on raw + # path strings, so PTH118/PTH207 don't apply here. + full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 + + matched = [] + for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 + if not Path(item).is_dir(): + matched.append(item) + else: + # PlatformIO quirk: a directory matched with "*" should include all its + # nested files and subdirectories, not just the directory itself. + for root, _, files in os.walk(item): + matched.extend([str(Path(root) / f) for f in files]) + + # FILTER_REGEX only ever captures "+" or "-", so the else is the "-" case. + if sign == "+": + selected.update(matched) + else: + selected.difference_update(matched) + + return [r for r in selected if Path(r).is_file()] + + +def split_list_by_condition( + items: list[str], match_fn: Callable[[str], str | None] +) -> tuple[list[str], list[str]]: + """ + Splits a list into two lists based on a matching function. + + Args: + items: List of items to split. + match_fn: Function that returns a value for items that should go into the "matched" list. + + Returns: + A tuple (matched, non_matched) + """ + matched = [] + non_matched = [] + for item in items: + result = match_fn(item) + if result: + matched.append(result) + else: + non_matched.append(item) + return matched, non_matched + + +def check_library_data(data: dict, platform: str, framework: str): + """ + Check whether a library manifest is compatible with the target toolchain. + + A platform mismatch (e.g. an AVR-only library on ESP32) raises + ``InvalidLibrary`` so the caller skips the library. A framework mismatch only + logs a warning — PIO manifests often understate the frameworks they actually + compile under, and there's no opt-out at this layer, so we include the library + anyway. + + Args: + data: PIO library manifest dict being processed. + platform: The PlatformIO platform token the build targets (e.g. + ``espressif32``). + framework: The active framework name (e.g. ``espidf``, ``arduino``, + ``zephyr``) the manifest is expected to declare. + + Raises: + InvalidLibrary: If the library does not support the target platform. + """ + platforms = data.get("platforms", "*") + if isinstance(platforms, str): + platforms = [a.strip() for a in platforms.split(",")] + platforms = ensure_list(platforms) + + # Check if library supports the target platform + valid_platforms = "*" in platforms or platform in platforms + + if not valid_platforms: + raise InvalidLibrary(f"Unsupported library platforms: {platforms}") + + frameworks = data.get("frameworks", "*") + if isinstance(frameworks, str): + frameworks = [a.strip() for a in frameworks.split(",")] + frameworks = ensure_list(frameworks) + + # Check if library declares the active framework. PIO library manifests + # often list only "arduino" even when the library actually compiles fine + # under the target framework, and there's no way to opt out of the check at + # this layer. Warn instead of failing so the user isn't forced to fork the + # library to fix the manifest. + valid_framework = "*" in frameworks or framework in frameworks + + if not valid_framework: + _LOGGER.warning( + "Library %s declares frameworks %s that do not include '%s'; including anyway", + data.get("name", ""), + frameworks, + framework, + ) + + +def _parse_library_json(library_json_path: PathType): + """ + Load and parse a JSON file describing a library. + + Args: + library_json_path (PathType): Path to the JSON file. + + Returns: + dict: Parsed JSON content as a Python dictionary. + """ + with Path(library_json_path).open(encoding="utf8") as fp: + return json.load(fp) + + +def _parse_library_properties(library_properties_path: PathType): + """ + Parse a key-value platformio .properties style file into a dictionary. + + Args: + library_properties_path (PathType): Path to the properties file. + + Returns: + dict[str, str]: Mapping of parsed property keys to values. + """ + with Path(library_properties_path).open(encoding="utf8") as fp: + data = {} + for line in fp.read().splitlines(): + line = line.strip() + if not line or "=" not in line: + continue + # skip comments + if line.startswith("#"): + continue + key, value = line.split("=", 1) + if not value.strip(): + continue + data[key.strip()] = value.strip() + return data + + +def _make_registry_client() -> Any: + """Create a minimal PlatformIO registry client with no system filtering. + + ``is_system_compatible`` is forced True so version selection is driven purely + by the requested version requirements -- target compatibility is handled + elsewhere, not by the PlatformIO registry. + """ + from platformio.package.manager._registry import PackageManagerRegistryMixin + + class _Registry(PackageManagerRegistryMixin): + def __init__(self) -> None: + self._registry_client = None + self.pkg_type = "library" + + @staticmethod + def is_system_compatible(value: Any, custom_system: Any = None) -> bool: + return True + + return _Registry() + + +def _resolve_registry_version( + owner: str | None, pkgname: str, requirements: set[str] +) -> tuple[str, str, str, str]: + """Resolve a registry package to the single highest version satisfying ALL + the given requirements; return ``(owner, name, version, download_url)``. + + Intersecting every requirement (rather than resolving each consumer in + isolation) makes the result independent of processing order and guarantees + no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as + both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. + """ + from platformio.package.meta import PackageSpec + + registry = _make_registry_client() + package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) + owner = package["owner"]["username"] + name = package["name"] + + # Chaining the per-requirement filter intersects all constraints. + versions = package.get("versions") or [] + for requirement in sorted(requirements): + versions = registry.get_compatible_registry_versions( + versions, PackageSpec(owner=owner, name=name, requirements=requirement) + ) + if not versions: + raise RuntimeError( + f"No version of {owner}/{name} satisfies all requirements " + f"{sorted(requirements)} requested across the library tree" + ) + + best = registry.pick_best_registry_version(versions) + pkgfile = registry.pick_compatible_pkg_file(best["files"]) + if not pkgfile: + raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") + return owner, name, best["name"], pkgfile["download_url"] + + +def _normalize_dependencies(dependencies: Any) -> list[dict]: + """Normalize a library manifest's ``dependencies`` to a list of dicts. + + PIO's library.json accepts both the list-of-dicts form and the shorthand + dict form (``{"owner/Name": "version_spec"}``); normalize the latter so + callers see a uniform list. + """ + if not dependencies: + return [] + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + return normalized + return [d for d in dependencies if isinstance(d, dict)] + + +@dataclass +class _LibNode: + """A node in the library dependency graph being resolved as a batch.""" + + key: str + is_git: bool + owner: str | None = None + pkgname: str | None = None + requirements: set[str] = field(default_factory=set) + url: str | None = None + ref: str | None = None + edges: set[str] = field(default_factory=set) + + +def _node_key( + name: str | None, version: str | None, repository: str | None +) -> tuple[str, bool, tuple[str | None, str | None]]: + """Return ``(key, is_git, locator)`` for a library or dependency spec. + + The key is derived from the *input* spec (the registry name as written, or + the git URL path), not the resolved canonical name. So a package referenced + inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps + to distinct keys and isn't deduplicated; ``convert_libraries`` warns about + that after resolution rather than merging the nodes. + """ + if repository: + split_result = urlsplit(repository) + key = str(split_result.path).strip("/").removesuffix(".git") + ref = split_result.fragment.strip() or None + url = urlunsplit(split_result._replace(fragment="")) + return key, True, (url, ref) + if name and "/" in name: + owner, pkgname = name.split("/", 1) + else: + owner, pkgname = None, name + return name, False, (owner, pkgname) + + +def convert_libraries( + libraries: list[Library], backend: LibraryBackend +) -> list[ConvertedLibrary]: + """Resolve and convert a batch of PlatformIO libraries for ``backend``. + + Resolves the whole set together rather than each library independently: it + walks the dependency graph collecting every version *requirement* per + component name, then resolves each name once to a single version satisfying + all of them. So a transitive dependency shared under + different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and + ``esp_wireguard``) becomes one component instead of two clashing + ``override_path`` entries -- order-independently, and without ever violating + a stated constraint. + + The returned list holds the top-level components (those directly requested); + transitive dependencies are converted too and wired into each component's + generated manifest. ``backend.emit`` is called once per converted library to + write its toolchain-specific build files. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. + """ + nodes: dict[str, _LibNode] = {} + + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated build files inside the shared cache bake in the dependency + # wiring, which lib_ignore changes; salt the cache path so configs with + # different lib_ignore values don't fight over (and constantly rewrite) the + # same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: + key, is_git, locator = _node_key(name, version, repository) + node = nodes.get(key) or _LibNode(key=key, is_git=is_git) + nodes[key] = node + if is_git: + node.is_git = True + node.url, node.ref = locator + else: + node.owner, node.pkgname = locator + if version: + node.requirements.add(version) + return key + + top_level = [ + add_spec(library.name, library.version, library.repository) + for library in libraries + if not is_ignored(library.name) + ] + + # Collect + resolve to a fixpoint: a node is (re)resolved whenever its + # requirement set has grown since the last time, so every requirement in the + # graph is accounted for before conversion. + components: dict[str, ConvertedLibrary] = {} + resolved_requirements: dict[str, frozenset[str]] = {} + top_level_keys = set(top_level) + worklist = deque(dict.fromkeys(top_level)) + while worklist: + key = worklist.popleft() + node = nodes[key] + + # A node is queued once per referring edge; skip the (uncached) registry + # lookup + download + dependency walk unless its requirement set grew + # since the last resolve. Requirements only ever grow, so this still + # converges the fixpoint and terminates dependency cycles. + requirements = frozenset(node.requirements) + if resolved_requirements.get(key) == requirements: + continue + resolved_requirements[key] = requirements + + if node.is_git: + component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref)) + else: + owner, name, version, url = _resolve_registry_version( + node.owner, node.pkgname, node.requirements + ) + component = ConvertedLibrary( + _owner_pkgname_to_name(owner, name), version, URLSource(url) + ) + component.download(salt=salt) + + library_json_path = component.path / "library.json" + library_properties_path = component.path / "library.properties" + if library_json_path.is_file(): + component.data = _parse_library_json(library_json_path) + elif library_properties_path.is_file(): + component.data = _parse_library_properties(library_properties_path) + else: + raise RuntimeError( + f"Invalid PIO library {key}: missing library.json and " + "library.properties" + ) + + try: + check_library_data(component.data, backend.platform, backend.framework) + except InvalidLibrary as e: + # Skip an incompatible transitive dependency, but fail fast if a + # top-level library the build explicitly requested is incompatible. + if key in top_level_keys: + raise RuntimeError( + f"Requested library {key} is not compatible with " + f"{backend.framework}: {e}" + ) from e + _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) + continue + components[key] = component + + # Requirements changed (we got past the short-circuit above), so + # (re)walk this component's dependencies. + node.edges = set() + for dependency in _normalize_dependencies(component.data.get("dependencies")): + if "name" not in dependency or "version" not in dependency: + continue + try: + check_library_data(dependency, backend.platform, backend.framework) + except InvalidLibrary as e: + _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) + continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue + # The version field may actually be a URL (git/archive dependency). + dep_version = dependency["version"] + dep_url = None + try: + parsed = urlparse(dep_version) + if all([parsed.scheme, parsed.netloc]): + dep_url, dep_version = dep_version, None + except (TypeError, ValueError): + pass + dep_key = add_spec(dep_name, dep_version, dep_url) + node.edges.add(dep_key) + worklist.append(dep_key) + + # A git source wins over any registry version requested for the same + # component. That's intentional, but warn so a dropped registry pin isn't a + # silent surprise. + for node in nodes.values(): + if node.is_git and node.requirements: + _LOGGER.warning( + "Library %s is requested both from a git source (%s) and as " + "registry version(s) %s; using the git source.", + node.key, + node.url, + sorted(node.requirements), + ) + + # Two graph nodes that resolve to the same component name (e.g. a package + # referenced both bare and as ``owner/name``) are not deduplicated and can + # produce conflicting component definitions. Warn so it's not silent. + canonical_keys: dict[str, str] = {} + for node_key, component in components.items(): + canonical = component.get_sanitized_name() + if canonical_keys.setdefault(canonical, node_key) != node_key: + _LOGGER.warning( + "Library %s is referenced under multiple names (%s and %s); these " + "are not deduplicated. Reference it consistently as %s.", + canonical, + canonical_keys[canonical], + node_key, + canonical, + ) + + # Wire each component's dependencies to the single resolved instances, then + # emit build files. + for key, component in components.items(): + component.dependencies = [ + components[dep_key] + for dep_key in sorted(nodes[key].edges) + if dep_key in components + ] + for component in components.values(): + backend.emit(component) + + return [components[key] for key in top_level if key in components] diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 87e168dc94..d43a1d5276 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -14,23 +14,23 @@ from esphome.const import ( Platform, ) from esphome.core import CORE, Library -import esphome.espidf.component from esphome.espidf.component import ( + generate_cmakelists_txt, + generate_idf_component_yml, + generate_idf_components, +) +import esphome.platformio.library +from esphome.platformio.library import ( + ConvertedLibrary as IDFComponent, GitSource, - IDFComponent, - InvalidIDFComponent, URLSource, - _check_library_data, - _collect_filtered_files, _node_key, _normalize_dependencies, _parse_library_json, _parse_library_properties, _resolve_registry_version, - _split_list_by_condition, - generate_cmakelists_txt, - generate_idf_component_yml, - generate_idf_components, + collect_filtered_files, + split_list_by_condition, ) @@ -70,7 +70,7 @@ def test_collect_filtered_files_basic(tmp_path): f2.parent.mkdir(parents=True) f2.write_text("int b;") - result = _collect_filtered_files(tmp_path, ["+<*>"]) + result = collect_filtered_files(tmp_path, ["+<*>"]) assert str(f1) in result assert str(f2) in result @@ -81,7 +81,7 @@ def test_collect_filtered_files_exclude(tmp_path): f1.write_text("int a;") f2.write_text("int b;") - result = _collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"]) + result = collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"]) assert str(f1) in result assert str(f2) not in result @@ -89,7 +89,7 @@ def test_collect_filtered_files_exclude(tmp_path): def test_split_list_by_condition(): items = ["-Iinclude", "-Llib", "-Wall"] - matched, rest = _split_list_by_condition( + matched, rest = split_list_by_condition( items, lambda x: x[2:] if x.startswith("-I") else None ) @@ -202,41 +202,6 @@ def test_generate_idf_component_yml_missing_path_raises(tmp_component): generate_idf_component_yml(tmp_component) -def test_check_library_data_valid(esp32_idf_core): - _check_library_data({"platforms": "*", "frameworks": "*"}) - - -def test_check_library_data_valid2(esp32_idf_core): - _check_library_data({"platforms": "*"}) - - -def test_check_library_data_valid3(esp32_idf_core): - _check_library_data({}) - - -def test_check_library_data_valid4(esp32_idf_core): - _check_library_data({"platforms": "espressif32", "frameworks": "*"}) - - -def test_check_library_data_valid5(esp32_idf_core): - _check_library_data({"platforms": "*", "frameworks": "espidf"}) - - -def test_check_library_data_invalid_platform(esp32_idf_core): - with pytest.raises(InvalidIDFComponent): - _check_library_data({"platforms": ["other"], "frameworks": "*"}) - - -def test_check_library_data_invalid_framework( - esp32_idf_core: None, caplog: pytest.LogCaptureFixture -) -> None: - # Framework mismatch is a warning, not a hard skip: the library is still - # included so that PIO manifests that only list "arduino" (but actually - # compile under IDF) can be used without forking them. - _check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]}) - assert "do not include 'espidf'" in caplog.text - - def test_extra_script_captures_libpath_libs_and_defines(tmp_path): from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script @@ -453,7 +418,7 @@ def _patch_registry(monkeypatch, versions): ``get_compatible_registry_versions`` / ``pick_best_registry_version`` run on the canned data so the intersection logic is exercised for real. """ - registry = esphome.espidf.component._make_registry_client() + registry = esphome.platformio.library._make_registry_client() monkeypatch.setattr( registry, "fetch_registry_package", @@ -467,7 +432,7 @@ def _patch_registry(monkeypatch, versions): }, ) monkeypatch.setattr( - esphome.espidf.component, "_make_registry_client", lambda: registry + esphome.platformio.library, "_make_registry_client", lambda: registry ) @@ -535,7 +500,7 @@ def test_generate_idf_components_dedupes_shared_dependency( return owner, pkgname, version, f"http://x/{pkgname}.tar.gz" monkeypatch.setattr( - esphome.espidf.component, "_resolve_registry_version", fake_resolve + esphome.platformio.library, "_resolve_registry_version", fake_resolve ) top = generate_idf_components( @@ -594,7 +559,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" monkeypatch.setattr( - esphome.espidf.component, "_resolve_registry_version", fake_resolve + esphome.platformio.library, "_resolve_registry_version", fake_resolve ) # lib_ignore is read from CORE.platformio_options (stored there by # _add_platformio_options); matched by lowercase short name. @@ -640,7 +605,7 @@ def test_generate_idf_components_handles_dependency_cycle( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -697,7 +662,7 @@ def test_generate_idf_components_git_overrides_registry_warns( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -733,7 +698,7 @@ def test_generate_idf_components_missing_manifest_raises( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -777,7 +742,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( monkeypatch.setattr(IDFComponent, "download", fake_download) # Bare "shared" and "owner/shared" both resolve to canonical owner/shared. monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner or "owner", @@ -810,7 +775,7 @@ def test_generate_idf_components_incompatible_top_level_raises( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -820,7 +785,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ), ) - with pytest.raises(RuntimeError, match="not compatible with ESP-IDF"): + with pytest.raises(RuntimeError, match="not compatible with espidf"): generate_idf_components([Library("esphome/A", "1.0.0", None)]) @@ -846,7 +811,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -892,7 +857,7 @@ def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: return Path("/cloned"), None monkeypatch.setattr( - esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + esphome.platformio.library.git, "clone_or_update", fake_clone_or_update ) source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py new file mode 100644 index 0000000000..55bc396c25 --- /dev/null +++ b/tests/unit_tests/test_platformio_library.py @@ -0,0 +1,229 @@ +"""Tests for the toolchain-agnostic PlatformIO library converter. + +Covers the shared download/parse/resolve/dependency-walk paths in +``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are +exercised in their own test modules).""" + +import json +import logging +from pathlib import Path + +import pytest + +from esphome.core import Library +import esphome.platformio.library as lib +from esphome.platformio.library import ( + ConvertedLibrary, + GitSource, + InvalidLibrary, + LibraryBackend, + Source, + URLSource, + _resolve_registry_version, + check_library_data, + convert_libraries, +) + + +def _backend(emit=lambda component: None) -> LibraryBackend: + return LibraryBackend(platform="espressif32", framework="espidf", emit=emit) + + +def test_check_library_data_accepts_wildcards(): + check_library_data({"platforms": "*", "frameworks": "*"}, "espressif32", "espidf") + + +def test_check_library_data_accepts_missing_frameworks(): + check_library_data({"platforms": "*"}, "espressif32", "espidf") + + +def test_check_library_data_accepts_empty_manifest(): + check_library_data({}, "espressif32", "espidf") + + +def test_check_library_data_accepts_matching_platform(): + check_library_data( + {"platforms": "espressif32", "frameworks": "*"}, "espressif32", "espidf" + ) + + +def test_check_library_data_accepts_matching_framework(): + check_library_data( + {"platforms": "*", "frameworks": "espidf"}, "espressif32", "espidf" + ) + + +def test_check_library_data_rejects_unsupported_platform(): + with pytest.raises(InvalidLibrary): + check_library_data( + {"platforms": ["other"], "frameworks": "*"}, "espressif32", "espidf" + ) + + +def test_check_library_data_warns_on_framework_mismatch( + caplog: pytest.LogCaptureFixture, +): + # Framework mismatch is a warning, not a hard skip: the library is still + # included so manifests that only list "arduino" (but compile fine under the + # target framework) can be used without forking them. + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + check_library_data( + {"name": "lib", "platforms": "*", "frameworks": ["other"]}, + "espressif32", + "espidf", + ) + assert "do not include 'espidf'" in caplog.text + + +def test_source_download_not_implemented(): + with pytest.raises(NotImplementedError): + Source().download("x") + + +def test_gitsource_str_includes_ref_when_present(): + assert str(GitSource("http://git/repo.git", "main")) == "http://git/repo.git#main" + assert str(GitSource("http://git/repo.git", None)) == "http://git/repo.git" + + +def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch): + monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None) + dl_calls: list[list[str]] = [] + monkeypatch.setattr( + lib, "download_from_mirrors", lambda urls, headers, f: dl_calls.append(urls) + ) + + def fake_extract(fileobj, path): + Path(path).mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(lib, "archive_extract_all", fake_extract) + + src = URLSource("http://example.test/lib.tar.gz") + out = src.download("mylib") + + assert (out / ".esphome_extracted").is_file() + assert dl_calls == [["http://example.test/lib.tar.gz"]] + + # The completion marker means a second download is skipped (cache hit). + out2 = src.download("mylib") + assert out2 == out + assert len(dl_calls) == 1 + + +def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): + registry = lib._make_registry_client() + monkeypatch.setattr( + registry, + "fetch_registry_package", + lambda spec: { + "owner": {"username": spec.owner or "owner"}, + "name": spec.name, + "versions": [{"name": "1.0.0", "files": [{}]}], + }, + ) + # A best version exists but none of its files is a compatible package. + monkeypatch.setattr( + registry, "pick_best_registry_version", lambda versions: versions[0] + ) + monkeypatch.setattr(registry, "pick_compatible_pkg_file", lambda files: None) + monkeypatch.setattr(lib, "_make_registry_client", lambda: registry) + + with pytest.raises(RuntimeError, match="No package file"): + _resolve_registry_version("owner", "pkg", set()) + + +def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): + """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" + + def fake_download(self, force=False, salt=""): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + self.path.mkdir(parents=True, exist_ok=True) + if self.name in properties: + (self.path / "library.properties").write_text(manifests[self.name]) + else: + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(ConvertedLibrary, "download", fake_download) + monkeypatch.setattr( + lib, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + +def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch): + # A manifest provided as library.properties (Arduino style) instead of + # library.json must still be parsed and converted. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + {"esphome/A": "name=A\nversion=1.0\n"}, + properties=("esphome/A",), + ) + + emitted: list[ConvertedLibrary] = [] + top = convert_libraries( + [Library("esphome/A", "1.0.0", None)], _backend(emitted.append) + ) + + assert [c.name for c in top] == ["esphome/A"] + assert top[0].data["name"] == "A" + assert emitted[0].data["version"] == "1.0" + + +def test_convert_libraries_skips_dependency_without_version(tmp_path, monkeypatch): + # A dependency entry lacking a version is malformed and silently skipped. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + {"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]}}, + ) + + # No version on the top-level spec exercises the "no requirement" path too. + top = convert_libraries([Library("esphome/A", None, None)], _backend()) + + assert top[0].dependencies == [] + + +def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monkeypatch): + # If the git/archive URL probe (urlparse) raises on a malformed value, the + # dependency is still kept and treated as a plain version spec. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + { + "esphome/A": { + "name": "A", + # An unterminated IPv6 URL makes urlparse raise ValueError. + "dependencies": [{"name": "C", "version": "http://[::1"}], + }, + "C": {"name": "C"}, + }, + ) + + top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert [d.name for d in top[0].dependencies] == ["C"] + + +def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch): + # A dependency that declares an incompatible platform is skipped (the + # top-level library still builds). + _patch_download_with_manifests( + monkeypatch, + tmp_path, + { + "esphome/A": { + "name": "A", + "dependencies": [{"name": "C", "version": "1.0", "platforms": ["avr"]}], + } + }, + ) + + top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert top[0].dependencies == [] From 8e23065b86798ad3a96216c05d9da76f4a1ac7ba Mon Sep 17 00:00:00 2001 From: alorente Date: Sun, 28 Jun 2026 13:14:05 +0200 Subject: [PATCH 1858/2214] [it8951] Add IT8951 e-paper controller support to epaper_spi (#15346) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot Co-authored-by: Citric Li <37475446+limengdu@users.noreply.github.com> Co-authored-by: koosoli Co-authored-by: Cursor Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- CODEOWNERS | 1 + esphome/components/it8951/__init__.py | 1 + esphome/components/it8951/display.py | 433 +++++++ esphome/components/it8951/it8951.cpp | 1091 +++++++++++++++++ esphome/components/it8951/it8951.h | 373 ++++++ esphome/components/it8951/it8951_defs.h | 168 +++ .../components/it8951/test.esp32-s3-idf.yaml | 109 ++ tests/components/ld2450/common.h | 3 + 8 files changed, 2179 insertions(+) create mode 100644 esphome/components/it8951/__init__.py create mode 100644 esphome/components/it8951/display.py create mode 100644 esphome/components/it8951/it8951.cpp create mode 100644 esphome/components/it8951/it8951.h create mode 100644 esphome/components/it8951/it8951_defs.h create mode 100644 tests/components/it8951/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 70ad580e77..21121ff476 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -266,6 +266,7 @@ esphome/components/integration/* @OttoWinter esphome/components/internal_temperature/* @Mat931 esphome/components/interval/* @esphome/core esphome/components/ir_rf_proxy/* @kbx81 +esphome/components/it8951/* @koosoli @limengdu @Passific esphome/components/jsn_sr04t/* @Mafus1 esphome/components/json/* @esphome/core esphome/components/kamstrup_kmp/* @cfeenstra1024 diff --git a/esphome/components/it8951/__init__.py b/esphome/components/it8951/__init__.py new file mode 100644 index 0000000000..7fc4ae2cd0 --- /dev/null +++ b/esphome/components/it8951/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@Passific", "@koosoli", "@limengdu"] diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py new file mode 100644 index 0000000000..51c5fc6118 --- /dev/null +++ b/esphome/components/it8951/display.py @@ -0,0 +1,433 @@ +""" +ESPHome configuration for the IT8951 e-paper controller. +""" + +from esphome import automation, core, pins +import esphome.codegen as cg +from esphome.components import display, spi +from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation +import esphome.config_validation as cv +from esphome.config_validation import update_interval +from esphome.const import ( + CONF_BUSY_PIN, + CONF_CS_PIN, + CONF_DATA_RATE, + CONF_DIMENSIONS, + CONF_ENABLE_PIN, + CONF_FULL_UPDATE_EVERY, + CONF_HEIGHT, + CONF_ID, + CONF_INVERT_COLORS, + CONF_LAMBDA, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_MODE, + CONF_MODEL, + CONF_PAGES, + CONF_RESET_DURATION, + CONF_RESET_PIN, + CONF_ROTATION, + CONF_SLEEP_WHEN_DONE, + CONF_SWAP_XY, + CONF_TRANSFORM, + CONF_UPDATE_INTERVAL, + CONF_WIDTH, +) +from esphome.cpp_generator import RawExpression +from esphome.final_validate import full_config + +AUTO_LOAD = ["split_buffer"] +DEPENDENCIES = ["spi"] + +CONF_VCOM = "vcom" +CONF_VCOM_REGISTER = "vcom_register" +CONF_FORCE_TEMPERATURE = "force_temperature" +CONF_GRAYSCALE = "grayscale" +CONF_DITHERING = "dithering" +CONF_UPDATE_MODE = "update_mode" +CONF_USE_LEGACY_DPY_AREA = "use_legacy_dpy_area" + +# VCOM SET sub-command selectors. The IT8951 firmware accepts different +# values across panels; most respond to 0x0001, but a few — e.g. the Seeed +# reTerminal E1003 — only respond to 0x0002 and silently drop 0x0001. +VCOM_REGISTER_DEFAULT = 0x0001 +VCOM_REGISTER_ALT = 0x0002 +VCOM_REGISTER_OPTIONS = (VCOM_REGISTER_DEFAULT, VCOM_REGISTER_ALT) + +it8951_ns = cg.esphome_ns.namespace("it8951") +IT8951Display = it8951_ns.class_("IT8951Display", display.Display, spi.SPIDevice) +IT8951UpdateAction = it8951_ns.class_("IT8951UpdateAction", automation.Action) + +# Hardware waveform modes exposed to YAML. Strings are mapped to the C++ +# UpdateMode enum so the runtime can store the mode as a uint16_t rather +# than a std::string (avoiding a heap-resident member; see ESPHome +# CLAUDE.md "STL Container Guidelines"). "fast" and "full" are +# convenience aliases for DU and GC16 respectively. +UpdateMode = it8951_ns.enum("UpdateMode") +UPDATE_MODE_OPTIONS = { + "INIT": UpdateMode.UPDATE_MODE_INIT, + "DU": UpdateMode.UPDATE_MODE_DU, + "GC16": UpdateMode.UPDATE_MODE_GC16, + "GL16": UpdateMode.UPDATE_MODE_GL16, + "GLR16": UpdateMode.UPDATE_MODE_GLR16, + "GLD16": UpdateMode.UPDATE_MODE_GLD16, + "DU4": UpdateMode.UPDATE_MODE_DU4, + "A2": UpdateMode.UPDATE_MODE_A2, + "FAST": UpdateMode.UPDATE_MODE_DU, + "FULL": UpdateMode.UPDATE_MODE_GC16, +} +# Maps the YAML mode string directly to the C++ UpdateMode enum value, so the +# config option and the it8951.update action share one validator. +update_mode = cv.enum(UPDATE_MODE_OPTIONS, upper=True) + +# Transform flag values mirror the C++ TRANSFORM_* constants. +_TRANSFORM_NONE = 0 +_TRANSFORM_MIRROR_X = 1 +_TRANSFORM_MIRROR_Y = 2 +_TRANSFORM_SWAP_XY = 4 +_TRANSFORM_FLAGS = { + CONF_MIRROR_X: _TRANSFORM_MIRROR_X, + CONF_MIRROR_Y: _TRANSFORM_MIRROR_Y, + CONF_SWAP_XY: _TRANSFORM_SWAP_XY, +} + + +class IT8951Model: + """A specific board / panel preset for the IT8951 controller.""" + + models: dict[str, "IT8951Model"] = {} + + def __init__(self, name: str, **defaults): + name = name.upper() + self.name = name + self.defaults = defaults + IT8951Model.models[name] = self + + def get_default(self, key, fallback=None): + return self.defaults.get(key, fallback) + + def get_dimensions(self, config) -> tuple[int, int]: + # If dimensions are in config, use them; otherwise fall back to model defaults. + if CONF_DIMENSIONS in config: + dimensions = config[CONF_DIMENSIONS] + if isinstance(dimensions, dict): + return dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT] + return tuple(dimensions) + # Model must have defaults if dimensions not in config. + return self.get_default(CONF_WIDTH), self.get_default(CONF_HEIGHT) + + +# --- Model presets ---------------------------------------------------------- +# The generic model leaves dimensions and pin choices up to the user. +IT8951Model("it8951", vcom=2300, sleep_when_done=True, data_rate=12_000_000) + +IT8951Model( + "m5stack-m5paper", + width=960, + height=540, + busy_pin=27, + reset_pin=23, + cs_pin=15, + vcom=2300, + sleep_when_done=True, + data_rate=20_000_000, +) + +IT8951Model( + "seeed-reterminal-e1003", + width=1872, + height=1404, + busy_pin=13, + reset_pin=12, + cs_pin=10, + # Board power-enable rails: 1.8V logic supply (GPIO21) and the EPD supply + # (GPIO11). Driven high during setup so no separate power_supply is needed. + enable_pin=[21, 11], + vcom=1400, + # reTerminal E1003 panel firmware only accepts the 0x0002 VCOM SET + # selector; using the default 0x0001 leaves VCOM unchanged and breaks + # grayscale waveforms (GC16/GL16) — INIT still works because it does + # not depend on VCOM accuracy. + vcom_register=VCOM_REGISTER_ALT, + # The reTerminal E1003 ships with on-die temperature sensing disabled, + # so the host must declare an operating temperature; otherwise the + # waveform LUT defaults to a value that produces no visible change + # for grayscale modes. + force_temperature=25, + sleep_when_done=False, + data_rate=20_000_000, + mirror_x=True, +) + +IT8951Model( + "seeed-ee03", + width=1872, + height=1404, + busy_pin=4, + reset_pin=38, + cs_pin=44, + vcom=1400, + sleep_when_done=False, + data_rate=4_000_000, +) + +# --------------------------------------------------------------------------- + +DIMENSION_SCHEMA = cv.Schema( + { + cv.Required(CONF_WIDTH): cv.int_, + cv.Required(CONF_HEIGHT): cv.int_, + } +) + + +def _model_pin_option(model, key, schema): + default = model.get_default(key) + if default is None: + return cv.Required(key), schema + return cv.Optional(key, default=default), schema + + +def _model_schema(config): + model = IT8951Model.models[config[CONF_MODEL]] + has_default_dimensions = ( + model.get_default(CONF_WIDTH) is not None + and model.get_default(CONF_HEIGHT) is not None + ) + dimensions_key = ( + cv.Optional( + CONF_DIMENSIONS, + default={ + CONF_WIDTH: model.get_default(CONF_WIDTH), + CONF_HEIGHT: model.get_default(CONF_HEIGHT), + }, + ) + if has_default_dimensions + else cv.Required(CONF_DIMENSIONS) + ) + + schema = display.FULL_DISPLAY_SCHEMA.extend( + spi.spi_device_schema( + cs_pin_required=False, + default_mode="MODE0", + default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), + ) + ).extend( + { + cv.GenerateID(): cv.declare_id(IT8951Display), + cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True, space="-"), + cv.Optional(CONF_ROTATION, default=0): validate_rotation, + cv.Optional(CONF_UPDATE_INTERVAL, default=cv.UNDEFINED): update_interval, + cv.Optional(CONF_FULL_UPDATE_EVERY, default=30): cv.int_range(1, 255), + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Required(CONF_MIRROR_X): cv.boolean, + cv.Required(CONF_MIRROR_Y): cv.boolean, + cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, + } + ), + cv.Optional( + CONF_INVERT_COLORS, default=model.get_default(CONF_INVERT_COLORS, False) + ): cv.boolean, + cv.Optional( + CONF_SLEEP_WHEN_DONE, + default=model.get_default(CONF_SLEEP_WHEN_DONE, False), + ): cv.boolean, + # Pixel format: true = 4bpp grayscale, false = packed 1bpp + # monochrome. Monochrome halves the framebuffer and enables fast DU + # partial refreshes; grayscale gives 16 levels but always uses GC16. + cv.Optional( + CONF_GRAYSCALE, default=model.get_default(CONF_GRAYSCALE, True) + ): cv.boolean, + # Monochrome only: ordered-dither pale colours so they render as + # visible stipple. Disable for a crisp hard black/white threshold + # (better for purely black/white text). No effect in grayscale mode. + cv.Optional( + CONF_DITHERING, default=model.get_default(CONF_DITHERING, True) + ): cv.boolean, + cv.Optional( + CONF_VCOM, default=model.get_default(CONF_VCOM, 2300) + ): cv.int_range(0, 5000), + cv.Optional( + CONF_VCOM_REGISTER, + default=model.get_default(CONF_VCOM_REGISTER, VCOM_REGISTER_DEFAULT), + ): cv.one_of(*VCOM_REGISTER_OPTIONS, int=True), + **( + { + cv.Optional( + CONF_FORCE_TEMPERATURE, + default=model.get_default(CONF_FORCE_TEMPERATURE), + ): cv.int_range(min=-40, max=85) + } + if model.get_default(CONF_FORCE_TEMPERATURE) is not None + else {} + ), + cv.Optional( + CONF_USE_LEGACY_DPY_AREA, + default=model.get_default(CONF_USE_LEGACY_DPY_AREA, False), + ): cv.boolean, + cv.Optional(CONF_UPDATE_MODE): update_mode, + # One or more GPIOs driven high during setup to power on the panel + # (e.g. board power-enable rails), before reset and init. + cv.Optional( + CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, []) + ): cv.ensure_list(pins.gpio_output_pin_schema), + cv.Optional(CONF_RESET_DURATION): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=core.TimePeriod(milliseconds=500)), + ), + dimensions_key: DIMENSION_SCHEMA, + } + ) + + # Pin options: required if the model doesn't supply a default. + pin_specs = ( + (CONF_BUSY_PIN, pins.gpio_input_pin_schema), + (CONF_RESET_PIN, pins.gpio_output_pin_schema), + (CONF_CS_PIN, pins.gpio_output_pin_schema), + ) + pin_extra = {} + for key, schema_value in pin_specs: + opt, sv = _model_pin_option(model, key, schema_value) + pin_extra[opt] = sv + return schema.extend(pin_extra) + + +def _customise_schema(config): + config = cv.Schema( + { + cv.Required(CONF_MODEL): cv.one_of( + *IT8951Model.models, upper=True, space="-" + ) + }, + extra=cv.ALLOW_EXTRA, + )(config) + + model_config = _model_schema(config)(config) + + model = IT8951Model.models[config[CONF_MODEL].upper()] + width, height = model.get_dimensions(model_config) + + display.add_metadata( + model_config[CONF_ID], + width, + height, + # Rotation is applied per-pixel in draw_pixel_at at no extra cost, so we + # advertise hardware rotation: LVGL routes its rotation to the driver via + # set_rotation rather than rotating the framebuffer in software. + has_hardware_rotation=True, + has_writer=any( + model_config.get(key) + for key in (CONF_LAMBDA, CONF_PAGES, CONF_SHOW_TEST_CARD) + ), + # Report the configured rotation so LVGL can detect (and reject) a + # rotation set in the display config instead of the LVGL config. + rotation=model_config.get(CONF_ROTATION, 0), + # The IT8951 snaps partial display refreshes to a 32-pixel X boundary + # (see prepare_update_region_), so have LVGL round its redraw areas to + # 32px too — this keeps flush rectangles aligned with what the panel + # actually refreshes and avoids redundant re-rounding/over-draw. + draw_rounding=32, + ) + + return model_config + + +CONFIG_SCHEMA = _customise_schema + + +def _final_validate(config): + # IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required. + spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)( + config + ) + + global_config = full_config.get() + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + if CONF_LAMBDA not in config and CONF_PAGES not in config: + if LVGL_DOMAIN in global_config: + if CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = update_interval("never") + else: + config[CONF_SHOW_TEST_CARD] = True + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + model = IT8951Model.models[config[CONF_MODEL]] + width, height = model.get_dimensions(config) + + var = cg.new_Pvariable(config[CONF_ID], model.name, width, height) + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=False) + + if lambda_config := config.get(CONF_LAMBDA): + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + if busy_pin := config.get(CONF_BUSY_PIN): + cg.add(var.set_busy_pin(await cg.gpio_pin_expression(busy_pin))) + if enable_pins := config.get(CONF_ENABLE_PIN): + cg.add( + var.set_enable_pins( + [await cg.gpio_pin_expression(pin) for pin in enable_pins] + ) + ) + cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) + if (reset_duration := config.get(CONF_RESET_DURATION)) is not None: + cg.add(var.set_reset_duration(reset_duration)) + if config.get(CONF_INVERT_COLORS): + cg.add(var.set_invert_colors(True)) + if config.get(CONF_SLEEP_WHEN_DONE): + cg.add(var.set_sleep_when_done(True)) + cg.add(var.set_vcom(config[CONF_VCOM])) + cg.add(var.set_vcom_register(config[CONF_VCOM_REGISTER])) + if CONF_FORCE_TEMPERATURE in config: + cg.add(var.set_force_temperature(config[CONF_FORCE_TEMPERATURE])) + if config.get(CONF_USE_LEGACY_DPY_AREA): + cg.add(var.set_use_legacy_dpy_area(True)) + cg.add(var.set_grayscale(config[CONF_GRAYSCALE])) + cg.add(var.set_dithering(config[CONF_DITHERING])) + if (mode := config.get(CONF_UPDATE_MODE)) is not None: + cg.add(var.set_update_mode(mode)) + + transform = config.get( + CONF_TRANSFORM, + { + CONF_MIRROR_X: model.get_default(CONF_MIRROR_X), + CONF_MIRROR_Y: model.get_default(CONF_MIRROR_Y), + }, + ) + + transform_value = sum( + flag for key, flag in _TRANSFORM_FLAGS.items() if transform.get(key) + ) + if transform_value: + cg.add(var.set_transform(RawExpression(str(transform_value)))) + + +@automation.register_action( + "it8951.update", + IT8951UpdateAction, + automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(IT8951Display), + cv.Optional(CONF_MODE): cv.templatable(update_mode), + } + ), + synchronous=True, +) +async def it8951_update_action_to_code(config, action_id, template_arg, args): + display_var = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, display_var) + if mode := config.get(CONF_MODE): + mode = await cg.templatable(mode, args, UpdateMode) + cg.add(var.set_mode(mode)) + return var diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp new file mode 100644 index 0000000000..cc2bddeda7 --- /dev/null +++ b/esphome/components/it8951/it8951.cpp @@ -0,0 +1,1091 @@ +#include "it8951.h" + +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::it8951 { + +static const char *const TAG = "it8951"; + +// Soft cap for time spent in a single XFER_ROWS Op so we yield back to the +// loop within one tick budget. +static constexpr uint32_t MAX_TRANSFER_TIME_MS = 20; + +// --- Loop / scheduling ------------------------------------------------------- + +void IT8951Display::enqueue_(OpType type, uint16_t a, uint16_t b) { + if (!this->queue_.push_back(Op{type, a, b})) { + ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), + static_cast(type)); + } +} + +void IT8951Display::prepend_(OpType type, uint16_t a, uint16_t b) { + if (!this->queue_.push_front(Op{type, a, b})) { + ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), + static_cast(type)); + } +} + +bool IT8951Display::is_busy_() const { + // IT8951 Hardware Ready (HW_RDY): HIGH = ready, LOW = busy. + return !this->busy_pin_->digital_read(); +} + +void IT8951Display::loop() { + const uint32_t now = millis(); + if (static_cast(now - this->delay_until_) < 0) + return; + + // Nothing queued — either the current phase has more work to enqueue, or + // we're done. + if (this->queue_.empty()) { + if (this->phase_ == Phase::IDLE) { + this->disable_loop(); + return; + } + this->advance_phase_(); + if (this->queue_.empty()) + return; + } + + // Gate SPI ops on HW_RDY. GPIO/DELAY ops run unconditionally — they're how + // we get the controller out of a stuck-busy state in the first place + // (e.g. during reset, HW_RDY is undefined/low until ROM boot completes). + Op queued_op = this->queue_.front(); + const bool needs_hardware_ready = queued_op.type != OpType::GPIO_RESET_LOW && + queued_op.type != OpType::GPIO_RESET_HIGH && queued_op.type != OpType::DELAY_MS; + if (needs_hardware_ready && this->is_busy_()) { + // Signed elapsed: any pending DELAY_MS or scheduled work in the near + // future shows up as <= 0 elapsed and won't trigger a false timeout. + const int32_t elapsed = static_cast(now - this->phase_started_at_); + ESP_LOGV(TAG, "HW_RDY is LOW (busy) in phase %u, elapsed=%" PRId32 "ms", static_cast(this->phase_), + elapsed); + if (elapsed > static_cast(BUSY_TIMEOUT_MS)) { + ESP_LOGW(TAG, "Busy timeout (%" PRIu32 "ms) in phase %u, recovering", elapsed, + static_cast(this->phase_)); + this->recover_(); + } + return; + } + + this->queue_.pop_front(); + this->process_op_(queued_op); +} + +void IT8951Display::process_op_(const Op &op) { + ESP_LOGV(TAG, "Processing op type=%u a=0x%04X b=0x%04X", static_cast(op.type), op.a, op.b); + switch (op.type) { + case OpType::CMD: + this->spi_cmd_(op.a); + break; + case OpType::WRITE_W: + this->spi_write_word_(op.a); + break; + case OpType::WRITE_REG: + this->spi_write_reg_(op.a, op.b); + break; + case OpType::READ_DEV_INFO: + this->spi_read_dev_info_(); + break; + case OpType::READ_WORD: + this->read_result_ = this->spi_read_word_(); + break; + case OpType::CHECK_LUT_IDLE: + this->op_check_lut_idle_(); + break; + case OpType::SET_1BPP: + this->op_set_1bpp_(); + break; + case OpType::XFER_LISAR: + this->op_xfer_lisar_(); + break; + case OpType::XFER_AREA_CMD: + this->spi_cmd_(TCON_LD_IMG_AREA); + break; + case OpType::XFER_AREA_ARGS: + this->op_xfer_area_args_(); + break; + case OpType::XFER_ROWS: + // Stream rows into the single open LD_IMG_AREA load. The load stays open + // across loop iterations (CS toggles between bursts, matching the + // reference driver), so a partial slice just re-queues another XFER_ROWS + // pass to resume; only when all rows are sent do we close it with one + // LD_IMG_END. This avoids an LD_IMG_END / LD_IMG_AREA round-trip per slice. + if (this->op_xfer_rows_()) { + this->enqueue_(OpType::XFER_AREA_END); + } else { + this->enqueue_(OpType::XFER_ROWS); + } + break; + case OpType::XFER_AREA_END: + this->op_xfer_area_end_(); + break; + case OpType::DPY_BUF_CMD: + // Some panel firmwares (notably Seeed reTerminal E1003) silently drop + // I80_CMD_DPY_BUF_AREA (0x0037) — the LUT engine never starts and the + // host eventually times out after ~12s. Fall back to the basic + // I80_CMD_DPY_AREA (0x0034) for those panels; the buffer address is + // already programmed via LISAR during the transfer phase. + this->spi_cmd_(this->use_legacy_dpy_area_ ? I80_CMD_DPY_AREA : I80_CMD_DPY_BUF_AREA); + break; + case OpType::DPY_BUF_ARGS: + this->op_dpy_buf_args_(); + break; + case OpType::GPIO_RESET_LOW: + if (this->reset_pin_ != nullptr) + this->reset_pin_->digital_write(false); + break; + case OpType::GPIO_RESET_HIGH: + if (this->reset_pin_ != nullptr) + this->reset_pin_->digital_write(true); + break; + case OpType::DELAY_MS: + this->delay_until_ = millis() + op.a; + break; + } +} + +void IT8951Display::set_phase_(Phase next) { + ESP_LOGV(TAG, "Phase %u -> %u", static_cast(this->phase_), static_cast(next)); + // Run the loop continuously for the whole active sequence, returning to normal + // throttling only at IDLE. Each queued op is processed one per loop iteration, + // so at the default ~16ms loop interval the dozens of small ops in the refresh + // and restore phases (register polls, 1bpp enable/restore, DPY) would dominate + // a partial update's latency. The LUT-idle polls are DELAY_MS-paced, so this + // doesn't hammer SPI — it only spends a little extra CPU during the (short, + // infrequent) update instead of sleeping between ops. start()/stop() are + // idempotent, so driving them off the transition is safe. + if (next == Phase::IDLE) { + this->high_freq_.stop(); + } else { + this->high_freq_.start(); + } + this->phase_ = next; + this->phase_started_at_ = millis(); +} + +void IT8951Display::advance_phase_() { + switch (this->phase_) { + case Phase::IDLE: + if (this->initialised_ && this->update_pending_) { + this->update_pending_ = false; + this->active_mode_ = this->pending_update_mode_; + this->update_started_at_ = millis(); + this->set_phase_(Phase::UPDATE_PREPARE); + this->advance_phase_(); + } else { + this->disable_loop(); + } + break; + + case Phase::INIT_RESET: + this->set_phase_(Phase::INIT_DEV_INFO); + this->enqueue_init_dev_info_(); + break; + + case Phase::INIT_DEV_INFO: + if (this->dev_info_.panel_width == 0 || this->dev_info_.panel_width > 2048 || this->dev_info_.panel_height == 0 || + this->dev_info_.panel_height > 2048 || this->dev_info_.panel_width == 0xFFFF || + this->dev_info_.panel_height == 0xFFFF) { + if (++this->dev_info_attempts_ < 5) { + ESP_LOGW(TAG, "DevInfo attempt %u returned invalid data (W=%u H=%u), retrying...", this->dev_info_attempts_, + this->dev_info_.panel_width, this->dev_info_.panel_height); + // Give the controller more time, then re-read. + this->enqueue_(OpType::DELAY_MS, 100); + this->enqueue_init_dev_info_(); + return; + } + ESP_LOGE(TAG, "DevInfo invalid after %u attempts (W=%u H=%u)", this->dev_info_attempts_, + this->dev_info_.panel_width, this->dev_info_.panel_height); + this->mark_failed(LOG_STR("Failed to read IT8951 device info")); + this->set_phase_(Phase::IDLE); + return; + } + + if (this->dev_info_.panel_width != this->width_ || this->dev_info_.panel_height != this->height_) { + ESP_LOGE(TAG, "Panel dimension mismatch: configured=%ux%u, DevInfo=%ux%u. Check model/dimensions settings.", + this->width_, this->height_, this->dev_info_.panel_width, this->dev_info_.panel_height); + this->mark_failed(LOG_STR("IT8951 panel dimensions do not match DevInfo")); + this->set_phase_(Phase::IDLE); + return; + } + + this->dev_info_attempts_ = 0; + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); + this->img_buf_addr_l_ = this->dev_info_.img_buf_addr_l; + this->img_buf_addr_h_ = this->dev_info_.img_buf_addr_h; + ESP_LOGI(TAG, "DevInfo: %ux%u, ImgBuf 0x%04X%04X", this->width_, this->height_, this->img_buf_addr_h_, + this->img_buf_addr_l_); + this->set_phase_(Phase::INIT_VCOM); + this->enqueue_init_vcom_(); + break; + + case Phase::INIT_VCOM: + this->set_phase_(Phase::INIT_TEMP); + if (this->force_temperature_set_) { + this->enqueue_init_temp_(); + } else { + this->advance_phase_(); + } + break; + + case Phase::INIT_TEMP: + this->set_phase_(Phase::INIT_DONE); + this->advance_phase_(); + break; + + case Phase::INIT_DONE: + if (this->configured_data_rate_ != 0 && this->configured_data_rate_ != this->data_rate_) { + this->spi_teardown(); + this->set_data_rate(this->configured_data_rate_); + this->spi_setup(); + } + this->initialised_ = true; + this->recovery_attempts_ = 0; + ESP_LOGCONFIG(TAG, "IT8951 setup complete"); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + break; + + case Phase::UPDATE_PREPARE: { + this->do_update_(); + UpdateMode mode = this->active_mode_; + if (!this->prepare_update_region_(mode)) { + ESP_LOGD(TAG, "Nothing to update"); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + return; + } + this->active_mode_ = mode; + this->set_phase_(Phase::UPDATE_TRANSFER); + this->enqueue_update_transfer_(); + break; + } + + case Phase::UPDATE_TRANSFER: + this->set_phase_(Phase::UPDATE_REFRESH); + this->enqueue_update_refresh_(); + break; + + case Phase::UPDATE_REFRESH: + // Fire-and-forget: don't block here waiting for the refresh to complete. + // The next update's pre-display LUT-idle poll (and the HW_RDY-gated + // TCON_SLEEP) wait as needed, so the refresh time stays off this update's + // critical path. The 1bpp display mode is left enabled rather than + // restored after every update: on a monochrome display every update + // (DU partials and the periodic GC16 cleans) runs in 1bpp mode, so the + // bit never needs clearing — and clearing it required a full + // refresh-length LUT-idle wait. + this->set_phase_(Phase::UPDATE_SLEEP); + this->enqueue_update_sleep_(); + break; + + case Phase::UPDATE_SLEEP: + ESP_LOGV(TAG, "Update took %" PRIu32 "ms (mode=%u area=%ux%u@%u,%u)", millis() - this->update_started_at_, + static_cast(this->active_mode_), this->area_w_, this->area_h_, this->area_x_, this->area_y_); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + break; + } +} + +// --- Setup ------------------------------------------------------------------- + +void IT8951Display::setup() { + ESP_LOGCONFIG(TAG, "Setting up IT8951..."); + this->configured_data_rate_ = this->data_rate_; + this->data_rate_ = SPI_PROBE_FREQUENCY; + this->spi_setup(); + + // Power on the panel before reset and the init handshake. + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + if (this->busy_pin_ != nullptr) { + this->busy_pin_->setup(); + } + + this->update_effective_transform_(); + this->reset_dirty_region_(); + + // Allocate the framebuffer now: its size is fixed by the configured pixel + // format and dimensions, so there's no need to defer to the async controller + // init. LVGL (and other writers) can push pixels via draw_pixels_at as soon + // as the component is set up — before init completes — and without a buffer + // those writes would dereference a null pointer and crash. + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); + RAMAllocator allocator{}; + this->buffer_ = allocator.allocate(this->buffer_length_); + if (this->buffer_ == nullptr) { + this->mark_failed(LOG_STR("Failed to allocate IT8951 framebuffer")); + return; + } + // The allocator does not zero memory; start blank (white) so undrawn regions + // (e.g. with auto_clear disabled) don't show garbage on the first update. + this->fill(Color::WHITE); + + // Kick off async init via the queue. Reset pulse + boot delay + wake + + // packed-write enable; everything blocking lives as DELAY_MS Ops gated by + // the loop scheduler. + this->set_phase_(Phase::INIT_RESET); + this->enqueue_init_reset_(); + this->enable_loop(); +} + +void IT8951Display::on_safe_shutdown() { + // Best-effort synchronous sleep — runs during shutdown so we don't queue. + this->spi_cmd_(TCON_SLEEP); +} + +// --- Init op enqueuers ------------------------------------------------------- + +void IT8951Display::enqueue_init_reset_() { + // A reset (including recovery) re-runs SYS_RUN below, so the controller is + // awake once this sequence completes. + this->asleep_ = false; + // Reset pulse: high -> low (reset_duration) -> high -> wait for ROM boot. + this->enqueue_(OpType::GPIO_RESET_HIGH); + this->enqueue_(OpType::GPIO_RESET_LOW); + this->enqueue_(OpType::DELAY_MS, static_cast(this->reset_duration_)); + this->enqueue_(OpType::GPIO_RESET_HIGH); + // SPI ROM boot. HW_RDY gating in loop() handles the actual wait, but a small + // floor avoids hammering SPI before HW_RDY has settled high. 300ms matches + // what most IT8951 reference drivers use for safety. + this->enqueue_(OpType::DELAY_MS, 300); + this->enqueue_(OpType::CMD, TCON_SYS_RUN); + this->enqueue_(OpType::DELAY_MS, 10); // clocks settle after SYS_RUN + this->enqueue_(OpType::CMD, TCON_REG_WR); // packed write mode + this->enqueue_(OpType::WRITE_REG, I80CPCR, 0x0001); +} + +void IT8951Display::enqueue_init_dev_info_() { + // CMD triggers the controller to prepare DevInfo. HW_RDY drops while it works. + // The loop-level HW_RDY gate non-blockingly waits before dispatching READ_DEV_INFO. + this->enqueue_(OpType::CMD, I80_CMD_GET_DEV_INFO); + this->enqueue_(OpType::READ_DEV_INFO); +} + +void IT8951Display::enqueue_init_vcom_() { + // Always write configured VCOM. The IT8951 stores it in OTP-backed RAM; + // rewriting the same value is harmless. The VCOM SET selector is + // panel-specific (see I80_CMD_VCOM_WRITE / I80_CMD_VCOM_WRITE_ALT in + // it8951_defs.h) and is supplied via the model preset. + this->enqueue_(OpType::CMD, I80_CMD_VCOM); + this->enqueue_(OpType::WRITE_W, this->vcom_register_); + this->enqueue_(OpType::WRITE_W, this->vcom_); +} + +void IT8951Display::enqueue_init_temp_() { + // Force panel temperature (in degrees C) so the controller selects the + // correct waveform LUT. Some panels (e.g. Seeed reTerminal E1003) ship + // with auto-temperature disabled and rely on the host to declare the + // operating temperature; without this, grayscale waveforms run against + // a mismatched LUT and pixels do not visibly change even though the LUT + // engine completes a full cycle. + this->enqueue_(OpType::CMD, I80_CMD_FORCE_TEMP); + this->enqueue_(OpType::WRITE_W, I80_CMD_FORCE_TEMP_WRITE); + this->enqueue_(OpType::WRITE_W, static_cast(this->force_temperature_)); +} + +// --- Update op enqueuers ----------------------------------------------------- + +void IT8951Display::enqueue_update_transfer_() { + // If the controller was put to sleep after the previous update, wake it + // before touching the display engine. TCON_SLEEP gates off all clocks; a + // register read (e.g. the LUTAFSR poll in UPDATE_REFRESH) returns a frozen + // value while asleep, so without this the next update stalls forever in + // op_check_lut_idle_(). SRAM/registers (packed-write mode, VCOM, LUT) are + // retained across sleep, so SYS_RUN + a short settle is all that's needed. + if (this->asleep_) { + this->enqueue_(OpType::CMD, TCON_SYS_RUN); + this->enqueue_(OpType::DELAY_MS, 10); // clocks settle after SYS_RUN + this->asleep_ = false; + } + this->transfer_row_ = 0; + // Open a single LD_IMG_AREA load for the whole region. XFER_ROWS streams into + // it across as many time-sliced passes as needed and emits the one matching + // LD_IMG_END when the last row is sent (see the XFER_ROWS handler). + this->enqueue_(OpType::XFER_LISAR); + this->enqueue_(OpType::XFER_AREA_CMD); + this->enqueue_(OpType::XFER_AREA_ARGS); + this->enqueue_(OpType::XFER_ROWS); +} + +void IT8951Display::enqueue_update_refresh_() { + ESP_LOGV(TAG, "Enqueueing refresh ops: grayscale=%u", this->grayscale_); + // Poll LUT idle: CMD(REG_RD) → WRITE_W(LUTAFSR) → READ_WORD → CHECK_LUT_IDLE + this->enqueue_(OpType::CMD, TCON_REG_RD); + this->enqueue_(OpType::WRITE_W, LUTAFSR); + this->enqueue_(OpType::READ_WORD); + this->enqueue_(OpType::CHECK_LUT_IDLE); + if (!this->grayscale_) { + // Read UP1SR+2: CMD(REG_RD) → WRITE_W(UP1SR+2) → READ_WORD → SET_1BPP + this->enqueue_(OpType::CMD, TCON_REG_RD); + this->enqueue_(OpType::WRITE_W, static_cast(UP1SR + 2)); + this->enqueue_(OpType::READ_WORD); + this->enqueue_(OpType::SET_1BPP); + } + this->enqueue_(OpType::DPY_BUF_CMD); + this->enqueue_(OpType::DPY_BUF_ARGS); +} + +void IT8951Display::enqueue_update_sleep_() { + if (this->sleep_when_done_) { + this->enqueue_(OpType::CMD, TCON_SLEEP); + // Remember that the controller is now asleep so the next update wakes it + // (see enqueue_update_transfer_) before polling any register. + this->asleep_ = true; + } +} + +// --- SPI primitives ---------------------------------------------------------- +// +// IT8951 SPI protocol: no DC pin. 16-bit preamble word identifies whether +// the transaction is command (0x6000), write-data (0x0000), or read-data +// (0x1000). +// +// All ops are fully non-blocking at the loop level. The loop-level HW_RDY gate +// guarantees the controller is ready before any op is dispatched. +// +// Within a single CS-asserted transaction, the IT8951 requires HW_RDY to be +// checked after the preamble word before sending the first data word. This +// is a hardware protocol requirement — the controller needs a few clock +// cycles to latch the preamble and configure its internal bus direction. +// In practice this completes in <1µs for write ops; we use a short spin +// (max ~50µs) that never triggers under normal operation. + +static constexpr uint32_t INTRA_CS_READY_TIMEOUT_US = 50; + +static inline void wait_for_hardware_ready(GPIOPin *busy_pin) { + if (busy_pin == nullptr) + return; + uint32_t waited = 0; + while (!busy_pin->digital_read()) { + if (waited >= INTRA_CS_READY_TIMEOUT_US) + return; + delayMicroseconds(1); + waited += 1; + } +} + +void IT8951Display::spi_cmd_(uint16_t cmd) { + this->enable(); + this->write_byte16(PACKET_TYPE_CMD); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(cmd); + this->disable(); +} + +void IT8951Display::spi_write_word_(uint16_t value) { + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(value); + this->disable(); +} + +void IT8951Display::spi_write_reg_(uint16_t addr, uint16_t value) { + // Single CS transaction: WRITE preamble + addr + value. + // Caller must have already sent CMD(TCON_REG_WR) as a prior op. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(addr); + this->write_byte16(value); + this->disable(); +} + +void IT8951Display::spi_write_args_(const uint16_t *args, uint16_t count) { + // Single CS transaction: WRITE preamble + N data words. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + for (uint16_t i = 0; i < count; i++) + this->write_byte16(args[i]); + this->disable(); +} + +uint16_t IT8951Display::spi_read_word_() { + // Single CS read transaction. HW_RDY was confirmed HIGH by the loop gate + // before this op was dispatched, so data is ready. + this->enable(); + this->write_byte16(PACKET_TYPE_READ); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(0x0000); // dummy — provides clock cycles for controller + wait_for_hardware_ready(this->busy_pin_); + // Read byte-by-byte: a 2-byte transfer_array can lose the low byte on + // ESP-IDF SPI DMA due to 4-byte alignment requirements. + const uint8_t hi = this->transfer_byte(0); + const uint8_t lo = this->transfer_byte(0); + this->disable(); + return encode_uint16(hi, lo); +} + +void IT8951Display::spi_read_dev_info_() { + // Read DevInfo struct. The CMD(GET_DEV_INFO) was already sent as a prior op, + // and the loop HW_RDY gate waited for the controller to prepare data. + std::memset(&this->dev_info_, 0, sizeof(this->dev_info_)); + this->enable(); + this->write_byte16(PACKET_TYPE_READ); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(0x0000); // dummy + wait_for_hardware_ready(this->busy_pin_); + auto *words = reinterpret_cast(&this->dev_info_); + constexpr uint32_t word_count = sizeof(this->dev_info_) / sizeof(uint16_t); + for (uint32_t i = 0; i < word_count; i++) { + const uint8_t hi = this->transfer_byte(0); + const uint8_t lo = this->transfer_byte(0); + words[i] = encode_uint16(hi, lo); + } + this->disable(); +} + +// --- Compound Ops ------------------------------------------------------------ + +void IT8951Display::op_xfer_lisar_() { + // Set image-buffer target address. Two register writes = 4 CS transactions. + // Push to FRONT in reverse order so they execute before the rest of the queue. + this->prepend_(OpType::WRITE_REG, LISAR, this->img_buf_addr_l_); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); + this->prepend_(OpType::WRITE_REG, static_cast(LISAR + 2), this->img_buf_addr_h_); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); +} + +void IT8951Display::op_xfer_area_args_() { + // Single CS transaction: WRITE preamble + 5 area-parameter words describing + // the full update region. Sent once when the load is opened (transfer_row_ is + // 0); XFER_ROWS then streams every row into this one area. + uint16_t args[5]; + if (this->grayscale_) { + args[0] = static_cast((LDIMG_B_ENDIAN << 8) | (PIXEL_4BPP << 4)); + args[1] = this->area_x_; + args[2] = this->area_y_; + args[3] = this->area_w_; + args[4] = this->area_h_; + } else { + // Monochrome is loaded via the 8bpp-packed trick: x and width are expressed + // in bytes (8 pixels each) and the controller unpacks one bit per pixel. + args[0] = static_cast((LDIMG_L_ENDIAN << 8) | (PIXEL_8BPP << 4)); + args[1] = static_cast(this->area_x_ / 8); + args[2] = this->area_y_; + args[3] = static_cast(this->area_w_ / 8); + args[4] = this->area_h_; + } + this->spi_write_args_(args, 5); +} + +void IT8951Display::op_xfer_area_end_() { this->spi_cmd_(TCON_LD_IMG_END); } + +bool IT8951Display::op_xfer_rows_() { + const uint32_t start_time = millis(); + const uint16_t area_y = this->area_y_; + const uint16_t area_h = this->area_h_; + + // Bytes per source row, and the byte offset of area_x within a row, in the + // framebuffer's native packing. These match the per-row byte count the + // controller expects from op_xfer_area_args_: area_w/2 for 4bpp grayscale, + // area_w/8 for the 1bpp-packed monochrome trick. area_x / area_w are + // 16-pixel aligned (see prepare_update_region_), so both divisions are exact. + const uint16_t bytes_per_row = + this->grayscale_ ? static_cast(this->area_w_ >> 1) : static_cast(this->area_w_ >> 3); + const uint16_t row_x_bytes = + this->grayscale_ ? static_cast(this->area_x_ >> 1) : static_cast(this->area_x_ >> 3); + + // Single CS write transaction — HW_RDY was confirmed high by the loop gate. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + + // Each source row is a contiguous slice of the framebuffer in both formats — + // the buffer already holds the wire bytes — so stream it straight to SPI with + // no per-pixel packing or temporary buffer. + while (this->transfer_row_ < area_h) { + const uint32_t offset = (static_cast(area_y) + this->transfer_row_) * this->row_width_ + row_x_bytes; + this->write_array(&this->buffer_[offset], bytes_per_row); + this->transfer_row_++; + if (millis() - start_time >= MAX_TRANSFER_TIME_MS) + break; + } + + this->disable(); + return this->transfer_row_ >= area_h; +} + +void IT8951Display::op_dpy_buf_args_() { + // I80_CMD_DPY_BUF_AREA (0x0037) takes 7 args (with explicit buffer addr). + // I80_CMD_DPY_AREA (0x0034) takes 5 args; the buffer address is taken + // from LISAR which we program during the transfer phase, so this is safe. + if (this->use_legacy_dpy_area_) { + const uint16_t args[5] = { + this->area_x_, this->area_y_, this->area_w_, this->area_h_, static_cast(this->active_mode_), + }; + this->spi_write_args_(args, 5); + return; + } + const uint16_t args[7] = { + this->area_x_, + this->area_y_, + this->area_w_, + this->area_h_, + static_cast(this->active_mode_), + this->img_buf_addr_l_, + this->img_buf_addr_h_, + }; + this->spi_write_args_(args, 7); +} + +void IT8951Display::op_check_lut_idle_() { + ESP_LOGV(TAG, "Checking LUT idle, read_result_=0x%04X", this->read_result_); + // read_result_ holds LUTAFSR value from the preceding READ_WORD op. + if (this->read_result_ != 0) { + // LUT still busy — re-enqueue the full read sequence after a short delay. + this->prepend_(OpType::CHECK_LUT_IDLE, 0, 0); + this->prepend_(OpType::READ_WORD, 0, 0); + this->prepend_(OpType::WRITE_W, LUTAFSR, 0); + this->prepend_(OpType::CMD, TCON_REG_RD, 0); + this->prepend_(OpType::DELAY_MS, 5, 0); + } +} + +void IT8951Display::op_set_1bpp_() { + // read_result_ holds UP1SR+2 value. Set bit 2 and write back, then set BGVR. + // Push to FRONT in reverse order so they execute before DPY_BUF_CMD/ARGS + // that are already in the queue. + const uint16_t modified = static_cast(this->read_result_ | (1U << 2)); + this->prepend_(OpType::WRITE_REG, BGVR, 0xFF00); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); + this->prepend_(OpType::WRITE_REG, UP1SR + 2, modified); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); +} + +// --- Update prep / public API ------------------------------------------------ + +bool IT8951Display::prepare_update_region_(UpdateMode &mode) { + this->partial_update_count_++; + const bool full_update = this->partial_update_count_ >= this->full_update_every_; + if (full_update) { + this->partial_update_count_ = 0; + mode = UPDATE_MODE_GC16; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } else { + // Align the partial region's X extent to 32 pixels. The IT8951's partial + // display refresh snaps the X start/width to a 32-pixel boundary (the panel + // source driver fetches 32-pixel chunks); refreshing a region whose X is + // only 16-aligned makes the panel snap it down to the previous boundary, + // shifting that update ~16px to the left. 32-alignment also satisfies the + // load constraints (4bpp X must be a multiple of 4; the 8bpp-packed mono + // load needs x/8 even, i.e. X a multiple of 16). + this->x_low_ &= 0xFFE0; + uint16_t temp_max = this->x_high_ > 0 ? static_cast(this->x_high_ - 1) : 0; + temp_max = static_cast(temp_max | 0x001F); + if (temp_max >= this->width_) + temp_max = static_cast(this->width_ - 1); + this->x_high_ = static_cast(temp_max + 1); + } + + if (this->x_high_ <= this->x_low_ || this->y_high_ <= this->y_low_) { + this->reset_dirty_region_(); + return false; + } + + const uint16_t x = this->x_low_; + const uint16_t y = this->y_low_; + const uint16_t width = static_cast(this->x_high_ - this->x_low_); + const uint16_t height = static_cast(this->y_high_ - this->y_low_); + + if (x >= this->width_ || y >= this->height_ || (x + width) > this->width_ || (y + height) > this->height_) { + ESP_LOGE(TAG, "Dirty region (%u,%u %ux%u) out of bounds", x, y, width, height); + this->reset_dirty_region_(); + return false; + } + + this->area_x_ = x; + this->area_y_ = y; + this->area_w_ = width; + this->area_h_ = height; + this->transfer_row_ = 0; + + // On non-full updates, downgrade monochrome frames from the full, flashy GC16 + // clear to DU — a fast, low-flash absolute waveform — so full_update_every + // buys cheaper refreshes between the periodic GC16 cleans that clear + // accumulated ghosting. + // + // Grayscale frames are deliberately left on GC16: every reduced grayscale + // waveform this controller exposes (the non-flashing GL family GL16/GLR16/ + // GLD16, and the 4-tone DU4) renders incorrectly on the supported panels — + // a white background is driven to grey rather than staying white. GC16 is the + // only waveform that reproduces grayscale faithfully, so we keep it. + // + // An explicitly configured non-GC16 update_mode is honoured as-is. + if (!full_update && mode == UPDATE_MODE_GC16 && !this->grayscale_) + mode = UPDATE_MODE_DU; + + this->reset_dirty_region_(); + + ESP_LOGV(TAG, "Update: %ux%u@%u,%u mode=%u (%s)", width, height, x, y, static_cast(mode), + this->grayscale_ ? "grayscale" : "mono"); + return true; +} + +void IT8951Display::reset_dirty_region_() { + this->x_low_ = this->width_; + this->x_high_ = 0; + this->y_low_ = this->height_; + this->y_high_ = 0; +} + +void IT8951Display::start_update_(UpdateMode mode) { + if (this->phase_ == Phase::IDLE && this->initialised_) { + this->update_started_at_ = millis(); + this->active_mode_ = mode; + this->set_phase_(Phase::UPDATE_PREPARE); + this->enable_loop(); + this->advance_phase_(); + } else { + // Coalesce: latest pending mode wins. + this->update_pending_ = true; + this->pending_update_mode_ = mode; + this->enable_loop(); + } +} + +void IT8951Display::update() { + if (!this->is_ready()) + return; + if (this->default_update_mode_ != UPDATE_MODE_NONE) { + this->start_update_(this->default_update_mode_); + return; + } + this->start_update_(UPDATE_MODE_GC16); +} + +void IT8951Display::update_mode(UpdateMode mode) { + if (!this->is_ready()) + return; + if (mode == UPDATE_MODE_NONE) { + ESP_LOGW(TAG, "Unknown update mode"); + return; + } + this->start_update_(mode); +} + +// --- Recovery ---------------------------------------------------------------- + +void IT8951Display::recover_() { + if (++this->recovery_attempts_ > 3) { + ESP_LOGE(TAG, "Recovery failed after %u attempts; giving up. Check BUSY pin wiring and power.", + this->recovery_attempts_); + this->mark_failed(LOG_STR("IT8951 recovery exhausted")); + this->queue_.clear(); + this->set_phase_(Phase::IDLE); + this->disable_loop(); + return; + } + ESP_LOGW(TAG, "Recovering (attempt %u): hardware-resetting controller (was in phase %u)", this->recovery_attempts_, + static_cast(this->phase_)); + this->queue_.clear(); + this->update_pending_ = false; + this->transfer_row_ = 0; + this->initialised_ = false; + this->dev_info_attempts_ = 0; + + // Drop SPI clock back to the safe probe rate for the re-init handshake. + if (this->configured_data_rate_ != 0 && this->data_rate_ != SPI_PROBE_FREQUENCY) { + this->spi_teardown(); + this->set_data_rate(SPI_PROBE_FREQUENCY); + this->spi_setup(); + } + + // Force a full redraw on next opportunity. + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + + this->set_phase_(Phase::INIT_RESET); + this->enqueue_init_reset_(); + this->update_pending_ = true; + this->pending_update_mode_ = UPDATE_MODE_GC16; + this->enable_loop(); +} + +// --- Coordinate transform ---------------------------------------------------- + +void IT8951Display::update_effective_transform_() { + switch (this->rotation_) { + case DISPLAY_ROTATION_90_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_SWAP_XY | TRANSFORM_MIRROR_X); + break; + case DISPLAY_ROTATION_180_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_MIRROR_Y | TRANSFORM_MIRROR_X); + break; + case DISPLAY_ROTATION_270_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_SWAP_XY | TRANSFORM_MIRROR_Y); + break; + default: + this->effective_transform_ = this->transform_; + break; + } +} + +void IT8951Display::apply_transform_(int &x, int &y) const { + if (this->effective_transform_ & TRANSFORM_SWAP_XY) + std::swap(x, y); + if (this->effective_transform_ & TRANSFORM_MIRROR_X) + x = this->width_ - x - 1; + if (this->effective_transform_ & TRANSFORM_MIRROR_Y) + y = this->height_ - y - 1; +} + +bool IT8951Display::rotate_coordinates_(int &x, int &y) { + if (!this->get_clipping().inside(x, y)) + return false; + this->apply_transform_(x, y); + if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) + return false; + this->x_low_ = clamp_at_most(this->x_low_, x); + this->x_high_ = clamp_at_least(this->x_high_, x + 1); + this->y_low_ = clamp_at_most(this->y_low_, y); + this->y_high_ = clamp_at_least(this->y_high_, y + 1); + return true; +} + +// --- Color / drawing --------------------------------------------------------- + +static uint8_t quantize_8bit_to_nibble(uint8_t value) { + uint8_t nibble = static_cast((static_cast(value) + 8) >> 4); + return nibble > 0x0F ? 0x0F : nibble; +} + +static uint8_t color_to_nibble(const Color &color) { + // Grayscale images are emitted as Color(gray, gray, gray, 0xFF). + // Handle this shape first so endpoint values don't alias COLOR_ON/OFF. + if (color.w == 0xFF && color.r == color.g && color.g == color.b) + return quantize_8bit_to_nibble(color.r); + + if (color.raw_32 == 0) + return 0x00; // black + if (color.raw_32 == 0xFFFFFFFF) + return 0x0F; // white + + // Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by + // 256). Rec.601 is the standard for converting SDR images to grayscale and + // spreads saturated colours across the mid-range; Rec.709 instead crams them + // against white/black where the 16 panel levels are hard to tell apart. + auto luma = static_cast((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); + return quantize_8bit_to_nibble(luma); +} + +// 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535). +// A pixel whose luma is below the threshold renders black, so lighter pixels +// produce progressively sparser black dots instead of vanishing to white. The +// matrix averages to 32768, matching the conventional monochrome cut, while the +// per-pixel variation reproduces intermediate gray levels. +static uint16_t dither_threshold(uint16_t x, uint16_t y) { + static const uint8_t BAYER4[16] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5}; + return static_cast(BAYER4[((y & 3) << 2) | (x & 3)] * 4096u + 2048u); +} + +void IT8951Display::fill(Color color) { + if (this->buffer_ == nullptr) + return; + if (this->get_clipping().is_set()) { + Display::fill(color); + return; + } + uint8_t packed = color_to_nibble(color); + if (this->invert_colors_) + packed = 0x0F - packed; + uint8_t fill_byte; + if (this->grayscale_) { + fill_byte = static_cast((packed << 4) | packed); + } else { + fill_byte = (packed <= 0x07) ? 0xFF : 0x00; + } + memset(this->buffer_, fill_byte, this->buffer_length_); + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { + if (this->buffer_ == nullptr) + return; + App.feed_wdt(); + if (!this->rotate_coordinates_(x, y)) + return; + this->write_pixel_native_(static_cast(x), static_cast(y), color); +} + +void HOT IT8951Display::write_pixel_native_(uint16_t x, uint16_t y, const Color &color) const { + if (this->grayscale_) { + uint8_t nibble = color_to_nibble(color); + if (this->invert_colors_) + nibble = static_cast(0x0F - nibble); + this->set_gray_pixel_(x, y, nibble); + } else { + // Rec.601 luma (see color_to_nibble). Weights sum to 257 so white maps to + // exactly 65535, using the full 16-bit range without overflow. + auto lum = static_cast(77u * color.r + 151u * color.g + 29u * color.b); + if (this->invert_colors_) + lum = static_cast(65535u - lum); + // Set the bit (foreground/black) when this pixel is darker than its + // threshold. With dithering the threshold varies per pixel so pale colours + // render as visible texture; otherwise it's the fixed ~50% cut (r+g+b<32768). + const uint16_t threshold = this->dithering_ ? dither_threshold(x, y) : 32768; + this->set_mono_pixel_(x, y, lum < threshold); + } +} + +void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // A writer (e.g. LVGL) may push pixels before the framebuffer is ready or + // after an allocation failure; ignore those rather than dereferencing null. + if (this->buffer_ == nullptr) + return; + // A clipping rectangle would need a per-pixel test; that's rare for the bulk + // blit callers (LVGL, images), so fall back to the base per-pixel path then. + if (this->get_clipping().is_set()) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + + const size_t line_stride = static_cast(x_offset) + w + x_pad; // source line length in pixels + for (int y = 0; y < h; y++) { + App.feed_wdt(); + size_t source_idx = (static_cast(y_offset) + y) * line_stride + x_offset; + for (int x = 0; x < w; x++, source_idx++) { + uint32_t color_value; + switch (bitness) { + case COLOR_BITNESS_565: { + const size_t i = source_idx * 2; + color_value = big_endian ? (static_cast(ptr[i]) << 8) | ptr[i + 1] + : ptr[i] | (static_cast(ptr[i + 1]) << 8); + break; + } + case COLOR_BITNESS_888: { + const size_t i = source_idx * 3; + color_value = + big_endian + ? (static_cast(ptr[i]) << 16) | (static_cast(ptr[i + 1]) << 8) | ptr[i + 2] + : ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + break; + } + default: + color_value = ptr[source_idx]; + break; + } + int nx = x_start + x; + int ny = y_start + y; + this->apply_transform_(nx, ny); + if (nx < 0 || ny < 0 || nx >= this->width_ || ny >= this->height_) + continue; + this->write_pixel_native_(static_cast(nx), static_cast(ny), + ColorUtil::to_color(color_value, order, bitness)); + } + } + + // Expand the dirty bounding box once from the transformed block corners: the + // image of an axis-aligned rectangle under swap/mirror is still axis-aligned, + // so its two opposite corners bound it. + int x0 = x_start, y0 = y_start; + int x1 = x_start + w - 1, y1 = y_start + h - 1; + this->apply_transform_(x0, y0); + this->apply_transform_(x1, y1); + const int nx_lo = std::max(0, std::min(x0, x1)); + const int ny_lo = std::max(0, std::min(y0, y1)); + const int nx_hi = std::min(this->width_ - 1, std::max(x0, x1)); + const int ny_hi = std::min(this->height_ - 1, std::max(y0, y1)); + if (nx_hi >= nx_lo && ny_hi >= ny_lo) { + this->x_low_ = clamp_at_most(this->x_low_, nx_lo); + this->x_high_ = clamp_at_least(this->x_high_, nx_hi + 1); + this->y_low_ = clamp_at_most(this->y_low_, ny_lo); + this->y_high_ = clamp_at_least(this->y_high_, ny_hi + 1); + } +} + +void IT8951Display::set_mono_pixel_(uint16_t x, uint16_t y, bool value) const { + // The monochrome framebuffer holds the exact bytes streamed to the + // controller for the 8bpp-load / 1bpp-display trick (L_ENDIAN). Pixels are + // grouped in 16s; on the wire the high byte (pixels 8..15) precedes the low + // byte (pixels 0..7), and the bit index within a byte is the pixel's offset + // (LSB = lowest x). Storing in that order lets op_xfer_rows_ copy rows + // verbatim with no packing or byte-swapping. + const uint16_t group = static_cast(x >> 4); + const uint8_t sub = static_cast(x & 0x0F); + const uint16_t byte_index = static_cast(group * 2u + (sub < 8u ? 1u : 0u)); + const uint8_t mask = static_cast(1u << (sub & 0x07)); + const uint32_t index = static_cast(y) * this->row_width_ + byte_index; + if (value) { + this->buffer_[index] |= mask; + } else { + this->buffer_[index] &= static_cast(~mask); + } +} + +void IT8951Display::set_gray_pixel_(uint16_t x, uint16_t y, uint8_t nibble) const { + const uint32_t index = static_cast(y) * this->row_width_ + (static_cast(x) >> 1); + uint8_t buf = this->buffer_[index]; + if (x & 0x1) { + buf = (buf & 0xF0) | nibble; + } else { + buf = (buf & 0x0F) | static_cast(nibble << 4); + } + this->buffer_[index] = buf; +} + +// --- Diagnostics ------------------------------------------------------------- + +void IT8951Display::dump_config() { + LOG_DISPLAY("", "IT8951 E-Paper", this); + char force_temperature[24]; + if (this->force_temperature_set_) { + snprintf(force_temperature, sizeof(force_temperature), "%d °C", this->force_temperature_); + } else { + strncpy(force_temperature, "(controller default)", sizeof(force_temperature)); + force_temperature[sizeof(force_temperature) - 1] = '\0'; + } + ESP_LOGCONFIG(TAG, + " Model preset: %s" + "\n Dimensions: %dx%d" + "\n Buffer: %u bytes" + "\n Image buffer addr: 0x%04X%04X" + "\n VCOM: %.02fV (set selector 0x%04X)" + "\n Force temperature: %s" + "\n Display command: %s" + "\n Sleep when done: %s" + "\n Full update every: %u" + "\n Inverted colors: %s" + "\n Pixel format: %s" + "\n Reset duration: %" PRIu32 "ms", + this->name_ != nullptr ? this->name_ : "(unknown)", this->get_width_internal(), + this->get_height_internal(), static_cast(this->buffer_length_), this->img_buf_addr_h_, + this->img_buf_addr_l_, static_cast(this->vcom_) / 1000.0f, this->vcom_register_, + force_temperature, this->use_legacy_dpy_area_ ? "DPY_AREA (0x0034, legacy)" : "DPY_BUF_AREA (0x0037)", + YESNO(this->sleep_when_done_), this->full_update_every_, YESNO(this->invert_colors_), + this->grayscale_ ? "4bpp grayscale" : "1bpp monochrome", this->reset_duration_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_PIN(" Busy Pin: ", this->busy_pin_); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::it8951 diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h new file mode 100644 index 0000000000..a5ed03e8c4 --- /dev/null +++ b/esphome/components/it8951/it8951.h @@ -0,0 +1,373 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/display/display.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "it8951_defs.h" + +namespace esphome::it8951 { + +using namespace display; + +// --- Bounded op queue -------------------------------------------------------- +// Fixed-capacity ring buffer used by the loop scheduler. Replaces std::deque +// to comply with ESPHome's STL container guidelines (std::deque allocates in +// 512-byte blocks regardless of element size). Size analysis: the deepest +// observed scenario is UPDATE_REFRESH (10 enqueued ops) + CHECK_LUT_IDLE's +// 5 push_front rescheduling = 14 simultaneous entries. We use 32 for a +// comfortable margin while keeping RAM cost low (~192 bytes per instance vs +// 512+ bytes for std::deque). +template class StaticOpQueue { + public: + bool empty() const { return this->count_ == 0; } + size_t size() const { return this->count_; } + static constexpr size_t capacity() { return N; } + + bool push_back(const T &value) { + if (this->count_ >= N) + return false; + this->data_[(this->head_ + this->count_) % N] = value; + ++this->count_; + return true; + } + + bool push_front(const T &value) { + if (this->count_ >= N) + return false; + this->head_ = (this->head_ + N - 1) % N; + this->data_[this->head_] = value; + ++this->count_; + return true; + } + + void pop_front() { + if (this->count_ == 0) + return; + this->head_ = (this->head_ + 1) % N; + --this->count_; + } + + const T &front() const { return this->data_[this->head_]; } + T &front() { return this->data_[this->head_]; } + + void clear() { + this->head_ = 0; + this->count_ = 0; + } + + private: + T data_[N]{}; + size_t head_{0}; + size_t count_{0}; +}; + +// Op queue capacity. See StaticOpQueue comment for sizing analysis. +static constexpr size_t OP_QUEUE_SIZE = 32; + +// --- Op queue --------------------------------------------------------------- +// Each Op is a single CS-asserted SPI transaction (or a tiny bookkeeping +// step). The loop processes one Op per iteration after gating on HW_RDY, so +// the natural ESPHome loop cadence (~8-16 ms) provides inter-op pacing +// without any blocking waits. +// +// Compound Ops (READ_DEV_INFO, XFER_*, DPY_BUF_AREA, ENABLE_1BPP, ...) are +// short self-contained methods that do all their SPI work inside a single +// CS cycle (or a small handful of cycles) and complete well under 2ms, so +// they don't break the no-blocking budget. +// +// Each write-type op is a SINGLE CS-asserted transaction. The loop-level +// HW_RDY gate ensures the controller is ready before dispatching any op, so +// no blocking waits are needed within write ops. +// +// Read ops are decomposed: the command/address that triggers data preparation +// is sent as write ops (CMD, WRITE_W), then a separate read op runs only +// after the loop confirms HW_RDY is back HIGH (data ready). No blocking. +enum class OpType : uint8_t { + CMD, // single CS: CMD preamble + command word (a) + WRITE_W, // single CS: WRITE preamble + data word (a) + WRITE_REG, // single CS: WRITE preamble + addr(a) + value(b) + // (caller must enqueue CMD(TCON_REG_WR) before this) + READ_DEV_INFO, // single CS: READ preamble + dummy + read DevInfo struct + // (caller enqueues CMD(GET_DEV_INFO) first; loop HW_RDY gate + // ensures data is ready before this op runs) + READ_WORD, // single CS: READ preamble + dummy + read one 16-bit word + // into read_result_. Loop HW_RDY gate ensures data ready. + CHECK_LUT_IDLE, // checks read_result_; if non-zero, re-enqueues read sequence + SET_1BPP, // uses read_result_ to set UP1SR bit 2, enqueues writes + XFER_LISAR, // set image-buffer target address (2× reg write: 4 CS transactions) + XFER_AREA_CMD, // single CS: CMD preamble + TCON_LD_IMG_AREA + XFER_AREA_ARGS, // single CS: WRITE preamble + 5 area-parameter words + XFER_ROWS, // single CS: WRITE preamble + row pixel data (time-sliced) + XFER_AREA_END, // single CS: CMD preamble + TCON_LD_IMG_END + DPY_BUF_CMD, // single CS: CMD preamble + I80_CMD_DPY_BUF_AREA + DPY_BUF_ARGS, // single CS: WRITE preamble + 7 display-area words + GPIO_RESET_LOW, // drive RESET pin low + GPIO_RESET_HIGH, // drive RESET pin high + DELAY_MS, // park `delay_until_` for a few ms (no SPI) +}; + +struct Op { + OpType type; + uint16_t a{0}; + uint16_t b{0}; +}; + +// High-level controller phases. Each phase enqueues a sequence of Ops; when +// the queue drains, advance_phase_() runs the next phase. +// This separation keeps per-Op work tiny and predictable. +enum class Phase : uint8_t { + IDLE, + // Initialisation + INIT_RESET, // reset pulse + wake controller + packed-write enable + INIT_DEV_INFO, // GET_DEV_INFO and validate + INIT_VCOM, // write configured VCOM + INIT_TEMP, // force temperature for waveform LUT selection + INIT_DONE, // allocate framebuffer; transition to IDLE + // Update flow + UPDATE_PREPARE, // do_update_, compute dirty region, decide 4bpp/1bpp + UPDATE_TRANSFER, // one LD_IMG_AREA, time-sliced row streaming, one LD_IMG_END + UPDATE_REFRESH, // wait LUT idle, optionally enable 1bpp, send DPY_BUF_AREA + UPDATE_SLEEP, // optional deep sleep +}; + +class IT8951Display : public Display, + public spi::SPIDevice { + public: + IT8951Display(const char *name, uint16_t width, uint16_t height) : name_(name), width_(width), height_(height) { + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(height); + } + + // --- Component lifecycle --- + void setup() override; + void loop() override; + void dump_config() override; + void on_safe_shutdown() override; + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + // --- Config setters (called from generated code) --- + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + void set_busy_pin(GPIOPin *pin) { this->busy_pin_ = pin; } + void set_enable_pins(std::vector pins) { this->enable_pins_ = std::move(pins); } + void set_reset_duration(uint32_t ms) { this->reset_duration_ = ms; } + void set_full_update_every(uint8_t n) { + this->full_update_every_ = n; + // Seed the counter so the very first update trips the full-update branch in + // prepare_update_region_, giving a freshly-booted panel a clean GC16 refresh + // before any partial (fast-waveform) updates begin. + this->partial_update_count_ = n; + } + void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } + void set_sleep_when_done(bool s) { this->sleep_when_done_ = s; } + void set_vcom(uint16_t vcom_mv) { this->vcom_ = vcom_mv; } + void set_vcom_register(uint16_t selector) { this->vcom_register_ = selector; } + void set_force_temperature(int16_t celsius) { + this->force_temperature_ = celsius; + this->force_temperature_set_ = true; + } + void set_use_legacy_dpy_area(bool use) { this->use_legacy_dpy_area_ = use; } + // Pixel format: true = 4bpp grayscale framebuffer, false = packed 1bpp + // monochrome framebuffer. Chosen at config time; the framebuffer is stored + // in this native format and every update uses the matching transfer path. + void set_grayscale(bool g) { this->grayscale_ = g; } + // Monochrome only: ordered-dither pale colours (true) vs a hard 50% threshold. + void set_dithering(bool d) { this->dithering_ = d; } + void set_update_mode(uint16_t m) { this->default_update_mode_ = static_cast(m); } + void set_transform(uint8_t t) { + this->transform_ = t; + this->update_effective_transform_(); + } + void set_rotation(DisplayRotation rotation) override { + Display::set_rotation(rotation); + this->update_effective_transform_(); + } + + // --- Display API --- + void update() override; + void update_mode(UpdateMode mode); + DisplayType get_display_type() override { return this->grayscale_ ? DISPLAY_TYPE_GRAYSCALE : DISPLAY_TYPE_BINARY; } + void fill(Color color) override; + void clear() override { this->fill(Color::WHITE); } + void draw_pixel_at(int x, int y, Color color) override; + // Bulk pixel blit (used by LVGL and image rendering). Overridden to write + // straight into the framebuffer, avoiding the base class's per-pixel + // draw_pixel_at overhead (watchdog feed, clipping test, dirty-box clamps). + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; } + int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; } + + protected: + int get_height_internal() override { return this->height_; } + int get_width_internal() override { return this->width_; } + + // --- Coord transform / dirty region --- + void update_effective_transform_(); + // Map display (logical) coordinates to native framebuffer coordinates by + // applying effective_transform_ (swap/mirror). Shared by rotate_coordinates_ + // and the bulk draw_pixels_at path. + void apply_transform_(int &x, int &y) const; + bool rotate_coordinates_(int &x, int &y); + void reset_dirty_region_(); + + // --- Framebuffer geometry / monochrome packing --- + // Bytes per row for the configured pixel format: 4bpp grayscale packs two + // pixels per byte; monochrome packs eight bits per byte, rounded up to a + // whole 16-pixel group (matching the controller's 8bpp-load / 1bpp trick). + uint16_t compute_row_width_() const { + return this->grayscale_ ? static_cast((static_cast(this->width_) + 1) / 2) + : static_cast(((static_cast(this->width_) + 15) / 16) * 2); + } + void set_mono_pixel_(uint16_t x, uint16_t y, bool value) const; + // Write a 4bpp grayscale nibble into the framebuffer (two pixels per byte). + void set_gray_pixel_(uint16_t x, uint16_t y, uint8_t nibble) const; + // Convert a color and write it at native framebuffer coordinates: a 4bpp + // nibble in grayscale mode, or an ordered-dithered bit in monochrome mode. + void write_pixel_native_(uint16_t x, uint16_t y, const Color &color) const; + + // --- Op queue / loop machinery --- + void enqueue_(OpType type, uint16_t a = 0, uint16_t b = 0); + void prepend_(OpType type, uint16_t a = 0, uint16_t b = 0); + bool is_busy_() const; + void process_op_(const Op &op); + void advance_phase_(); + void set_phase_(Phase next); + void start_update_(UpdateMode mode); + + // --- SPI primitives (each is one CS-asserted burst, fully non-blocking) --- + void spi_cmd_(uint16_t cmd); + void spi_write_word_(uint16_t value); + void spi_write_reg_(uint16_t addr, uint16_t value); + void spi_write_args_(const uint16_t *args, uint16_t count); + uint16_t spi_read_word_(); // non-blocking: HW_RDY confirmed by loop gate + void spi_read_dev_info_(); // non-blocking: HW_RDY confirmed by loop gate + + // --- Compound Ops (small bounded helpers) --- + void op_xfer_lisar_(); + void op_xfer_area_args_(); + void op_xfer_area_end_(); + bool op_xfer_rows_(); // returns true when current update area fully sent + void op_dpy_buf_args_(); + void op_check_lut_idle_(); + void op_set_1bpp_(); + + // --- Phase enqueuers --- + void enqueue_init_reset_(); + void enqueue_init_dev_info_(); + void enqueue_init_vcom_(); + void enqueue_init_temp_(); + void enqueue_update_transfer_(); + void enqueue_update_refresh_(); + void enqueue_update_sleep_(); + + bool prepare_update_region_(UpdateMode &mode); + + // --- Recovery --- + void recover_(); + + // --- State --- + static constexpr uint32_t BUSY_TIMEOUT_MS = 5000; + + StaticOpQueue queue_; + Phase phase_{Phase::IDLE}; + uint32_t delay_until_{0}; + uint32_t phase_started_at_{0}; + // Requests a continuous (non-throttled) main loop while streaming image data + // so 20ms transfer slices aren't separated by the ~16ms default loop interval. + HighFrequencyLoopRequester high_freq_; + + // Pending update bookkeeping + bool update_pending_{false}; + UpdateMode pending_update_mode_{UPDATE_MODE_NONE}; + UpdateMode active_mode_{UPDATE_MODE_NONE}; + uint16_t area_x_{0}, area_y_{0}, area_w_{0}, area_h_{0}; + uint16_t transfer_row_{0}; + bool initialised_{false}; + // True once TCON_SLEEP has been sent and the controller has not been woken + // since. The next update must issue TCON_SYS_RUN before any SPI op. + bool asleep_{false}; + uint32_t partial_update_count_{0}; + uint32_t update_started_at_{0}; + + // Read result storage for decomposed read-modify-write op sequences + uint16_t read_result_{0}; + + // Device info + DevInfo dev_info_{}; + uint16_t img_buf_addr_l_{0}; + uint16_t img_buf_addr_h_{0}; + + // Configured properties + const char *name_; + uint16_t width_; + uint16_t height_; + uint16_t row_width_; + size_t buffer_length_{}; + uint8_t *buffer_{}; + uint8_t transform_{0}; + uint8_t effective_transform_{0}; + uint8_t full_update_every_{1}; + uint32_t reset_duration_{10}; + uint16_t vcom_{2300}; + uint16_t vcom_register_{I80_CMD_VCOM_WRITE}; + int16_t force_temperature_{DEFAULT_FORCE_TEMP_C}; + bool force_temperature_set_{false}; + bool use_legacy_dpy_area_{false}; + bool invert_colors_{false}; + bool sleep_when_done_{false}; + // Pixel format selector (see set_grayscale): true = 4bpp grayscale, + // false = packed 1bpp monochrome. + bool grayscale_{true}; + // Monochrome dithering (see set_dithering): true = ordered dither. + bool dithering_{true}; + UpdateMode default_update_mode_{UPDATE_MODE_NONE}; + GPIOPin *reset_pin_{nullptr}; + GPIOPin *busy_pin_{nullptr}; + // GPIOs driven high during setup to power on the panel (empty if unused). + std::vector enable_pins_; + + // Dirty region (pixel coordinates of bounding box of changes since last update) + uint16_t x_low_{0}, y_low_{0}, x_high_{0}, y_high_{0}; + + // Saved data rate so we can probe slow then run fast + uint32_t configured_data_rate_{0}; + + // Consecutive recovery attempts; used to give up rather than infinite-loop + // when the controller is unresponsive (e.g. wiring issue). + uint8_t recovery_attempts_{0}; + + // DevInfo read retry counter (controller often returns garbage on the first + // read after reset; the original driver retried up to 3 times with 100ms + // between attempts). + uint8_t dev_info_attempts_{0}; +}; + +// --- Automation action --- +template class IT8951UpdateAction : public Action { + public: + explicit IT8951UpdateAction(IT8951Display *display) : display_(display) {} + TEMPLATABLE_VALUE(UpdateMode, mode) + + protected: + void play(const Ts &...x) override { + if (!this->display_->is_ready()) + return; + if (this->mode_.has_value()) { + this->display_->update_mode(this->mode_.value(x...)); + } else { + this->display_->update(); + } + } + + IT8951Display *display_; +}; + +} // namespace esphome::it8951 diff --git a/esphome/components/it8951/it8951_defs.h b/esphome/components/it8951/it8951_defs.h new file mode 100644 index 0000000000..9a7291eb4a --- /dev/null +++ b/esphome/components/it8951/it8951_defs.h @@ -0,0 +1,168 @@ +#pragma once + +#include + +namespace esphome::it8951 { + +struct DevInfo { + uint16_t panel_width{0}; + uint16_t panel_height{0}; + uint16_t img_buf_addr_l{0}; + uint16_t img_buf_addr_h{0}; + uint16_t fw_version[8]{}; + uint16_t lut_version[8]{}; +}; + +// --- IT8951 SPI packet preambles --- +static constexpr uint16_t PACKET_TYPE_CMD = 0x6000; +static constexpr uint16_t PACKET_TYPE_WRITE = 0x0000; +static constexpr uint16_t PACKET_TYPE_READ = 0x1000; + +// --- Built-in I80 commands --- +static constexpr uint16_t TCON_SYS_RUN = 0x0001; +static constexpr uint16_t TCON_STANDBY = 0x0002; +static constexpr uint16_t TCON_SLEEP = 0x0003; +static constexpr uint16_t TCON_REG_RD = 0x0010; +static constexpr uint16_t TCON_REG_WR = 0x0011; + +static constexpr uint16_t TCON_LD_IMG = 0x0020; +static constexpr uint16_t TCON_LD_IMG_AREA = 0x0021; +static constexpr uint16_t TCON_LD_IMG_END = 0x0022; + +// --- I80 user-defined commands --- +static constexpr uint16_t I80_CMD_DPY_AREA = 0x0034; +static constexpr uint16_t I80_CMD_GET_DEV_INFO = 0x0302; +static constexpr uint16_t I80_CMD_DPY_BUF_AREA = 0x0037; +static constexpr uint16_t I80_CMD_VCOM = 0x0039; +static constexpr uint16_t I80_CMD_VCOM_READ = 0x0000; +// VCOM write selectors. Different IT8951-driven panels accept different +// selector values for the VCOM SET sub-command. Most panels (m5stack-m5paper, +// generic dev kits) accept 0x0001. Some panels — notably the Seeed +// reTerminal E1003 — only respond to selector 0x0002 and silently ignore +// 0x0001, leaving VCOM at its default and making grayscale waveforms +// (GC16/GL16) ineffective even though INIT still works. +static constexpr uint16_t I80_CMD_VCOM_WRITE = 0x0001; +static constexpr uint16_t I80_CMD_VCOM_WRITE_ALT = 0x0002; + +// Force temperature command. The IT8951 selects waveform LUTs based on +// panel temperature; if it is left at the controller default, panels with +// auto-temperature disabled (notably the Seeed reTerminal E1003) will +// run waveforms against a mismatched LUT, leaving pixels visually +// unchanged even though the LUT engine completes a full cycle. The +// selector word selects the operation (0x0001 = write); the value word +// is the temperature in degrees Celsius. +static constexpr uint16_t I80_CMD_FORCE_TEMP = 0x0040; +static constexpr uint16_t I80_CMD_FORCE_TEMP_WRITE = 0x0001; +static constexpr int16_t DEFAULT_FORCE_TEMP_C = 25; + +// --- Pixel mode (bits per pixel encoding) --- +static constexpr uint8_t PIXEL_2BPP = 0; +static constexpr uint8_t PIXEL_3BPP = 1; +static constexpr uint8_t PIXEL_4BPP = 2; +static constexpr uint8_t PIXEL_8BPP = 3; + +// --- Endian flags for LD_IMG_AREA --- +static constexpr uint8_t LDIMG_L_ENDIAN = 0; +static constexpr uint8_t LDIMG_B_ENDIAN = 1; + +// --- SPI probe frequency used for initial controller handshake --- +static constexpr uint32_t SPI_PROBE_FREQUENCY = 1'000'000; + +// --- Refresh modes --- +/* + INIT The initialization (INIT) mode is + used to completely erase the display and leave it in the white state. It is + useful for situations where the display information in memory is not a faithful + representation of the optical state of the display, for example, after the + device receives power after it has been fully powered down. This waveform + switches the display several times and leaves it in the white state. + + DU + The direct update (DU) is a very fast, non-flashy update. This mode supports + transitions from any graytone to black or white only. It cannot be used to + update to any graytone other than black or white. The fast update time for this + mode makes it useful for response to touch sensor or pen input or menu selection + indictors. + + GC16 + The grayscale clearing (GC16) mode is used to update the full display and + provide a high image quality. When GC16 is used with Full Display Update the + entire display will update as the new image is written. If a Partial Update + command is used the only pixels with changing graytone values will update. The + GC16 mode has 16 unique gray levels. + + GL16 + The GL16 waveform is primarily used to update sparse content on a white + background, such as a page of anti-aliased text, with reduced flash. The + GL16 waveform has 16 unique gray levels. + + GLR16 + The GLR16 mode is used in conjunction with an image preprocessing algorithm to + update sparse content on a white background with reduced flash and reduced image + artifacts. The GLR16 mode supports 16 graytones. If only the even pixel states + are used (0, 2, 4, … 30), the mode will behave exactly as a traditional GL16 + waveform mode. If a separately-supplied image preprocessing algorithm is used, + the transitions invoked by the pixel states 29 and 31 are used to improve + display quality. For the AF waveform, it is assured that the GLR16 waveform data + will point to the same voltage lists as the GL16 data and does not need to be + stored in a separate memory. + + GLD16 + The GLD16 mode is used in conjunction with an image preprocessing algorithm to + update sparse content on a white background with reduced flash and reduced image + artifacts. It is recommended to be used only with the full display update. The + GLD16 mode supports 16 graytones. If only the even pixel states are used (0, 2, + 4, … 30), the mode will behave exactly as a traditional GL16 waveform mode. If a + separately-supplied image preprocessing algorithm is used, the transitions + invoked by the pixel states 29 and 31 are used to refresh the background with a + lighter flash compared to GC16 mode following a predetermined pixel map as + encoded in the waveform file, and reduce image artifacts even more compared to + the GLR16 mode. For the AF waveform, it is assured that the GLD16 waveform data + will point to the same voltage lists as the GL16 data and does not need to be + stored in a separate memory. + + DU4 + The DU4 is a fast update time (similar to DU), non-flashy waveform. This mode + supports transitions from any gray tone to gray tones 1,6,11,16 represented by + pixel states [0 10 20 30]. The combination of fast update time and four gray + tones make it useful for anti-aliased text in menus. There is a moderate + increase in ghosting compared with GC16. + + A2 + The A2 mode is a fast, non-flash update mode designed for fast paging turning or + simple black/white animation. This mode supports transitions from and to black + or white only. It cannot be used to update to any graytone other than black or + white. The recommended update sequence to transition into repeated A2 updates is + shown in Figure 1. The use of a white image in the transition from 4-bit to + 1-bit images will reduce ghosting and improve image quality for A2 updates. + */ +enum UpdateMode : uint16_t { + UPDATE_MODE_INIT = 0, + UPDATE_MODE_DU = 1, + UPDATE_MODE_GC16 = 2, + UPDATE_MODE_GL16 = 3, + UPDATE_MODE_GLR16 = 4, + UPDATE_MODE_GLD16 = 5, + UPDATE_MODE_DU4 = 6, + UPDATE_MODE_A2 = 7, + UPDATE_MODE_NONE = 8, +}; + +// --- Registers --- +static constexpr uint16_t DISPLAY_REG_BASE = 0x1000; +static constexpr uint16_t UP1SR = DISPLAY_REG_BASE + 0x138; +static constexpr uint16_t LUTAFSR = DISPLAY_REG_BASE + 0x224; +static constexpr uint16_t BGVR = DISPLAY_REG_BASE + 0x250; + +static constexpr uint16_t I80CPCR = 0x0004; + +static constexpr uint16_t MCSR_BASE_ADDR = 0x0200; +static constexpr uint16_t LISAR = MCSR_BASE_ADDR + 0x0008; + +// Display orientation flags +static constexpr uint8_t TRANSFORM_NONE = 0; +static constexpr uint8_t TRANSFORM_MIRROR_X = 1; +static constexpr uint8_t TRANSFORM_MIRROR_Y = 2; +static constexpr uint8_t TRANSFORM_SWAP_XY = 4; + +} // namespace esphome::it8951 diff --git a/tests/components/it8951/test.esp32-s3-idf.yaml b/tests/components/it8951/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..c362f7f28c --- /dev/null +++ b/tests/components/it8951/test.esp32-s3-idf.yaml @@ -0,0 +1,109 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +display: + # Generic IT8951 with explicit dimensions + - platform: it8951 + spi_id: spi_bus + model: it8951 + dimensions: + width: 1872 + height: 1404 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + enable_pin: + - GPIO17 + - GPIO18 + vcom: 1500 + update_interval: 60s + # Exercise an alias for the update_mode config option. + update_mode: fast + lambda: |- + it.circle(64, 64, 50, Color::BLACK); + + # m5stack-m5paper (960x540) — model supplies pin defaults + - platform: it8951 + id: m5epd_display + spi_id: spi_bus + model: m5stack-m5paper + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + full_update_every: 30 + invert_colors: false + sleep_when_done: true + grayscale: true + update_mode: GC16 + rotation: 270 + transform: + mirror_x: false + mirror_y: false + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); + + # seeed-reterminal-e1003 (1872x1404) + - platform: it8951 + spi_id: spi_bus + model: seeed-reterminal-e1003 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + vcom: 1400 + sleep_when_done: false + lambda: |- + it.filled_rectangle(0, 0, 128, 128, Color::BLACK); + + # seeed-ee03 (1872x1404), monochrome fast path + - platform: it8951 + spi_id: spi_bus + model: seeed-ee03 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + grayscale: false + dithering: false + update_mode: DU + lambda: |- + it.circle(128, 128, 64, Color::BLACK); + +# Exercise the it8951.update automation: alias modes, a direct enum-name mode, +# and the bare (default-mode) form. +interval: + - interval: 30s + then: + - it8951.update: + id: m5epd_display + mode: fast + - it8951.update: + id: m5epd_display + mode: full + - it8951.update: + id: m5epd_display + mode: A2 + - it8951.update: m5epd_display diff --git a/tests/components/ld2450/common.h b/tests/components/ld2450/common.h index 304634edca..de912ddcbc 100644 --- a/tests/components/ld2450/common.h +++ b/tests/components/ld2450/common.h @@ -18,6 +18,9 @@ class MockUARTComponent : public uart::UARTComponent { MOCK_METHOD(size_t, available, (), (override)); MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // USE_ESP8266 || USE_ESP32 }; // Expose protected members for testing. From 45c712b17be94d71b309678f97549a7dfe224278 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:41:49 -0700 Subject: [PATCH 1859/2214] Bump bundled esphome-device-builder to 1.0.21 (#17257) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5626d18fcc..1085076137 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.20 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21 RUN \ platformio settings set enable_telemetry No \ From 6210dfb4d099651ea1731a19bd569eef0970109f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:01 -0400 Subject: [PATCH 1860/2214] [core] Use single-precision float math to avoid double promotion (#17252) --- esphome/core/helpers.cpp | 10 +++++----- esphome/core/scheduler.cpp | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 112dde7c45..a7b63643a4 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -669,11 +669,11 @@ void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, if (delta == 0) { hue = 0; } else if (max_color_value == red) { - hue = int(fmod(((60 * ((green - blue) / delta)) + 360), 360)); + hue = int(fmodf((60.0f * ((green - blue) / delta)) + 360.0f, 360.0f)); } else if (max_color_value == green) { - hue = int(fmod(((60 * ((blue - red) / delta)) + 120), 360)); + hue = int(fmodf((60.0f * ((blue - red) / delta)) + 120.0f, 360.0f)); } else if (max_color_value == blue) { - hue = int(fmod(((60 * ((red - green) / delta)) + 240), 360)); + hue = int(fmodf((60.0f * ((red - green) / delta)) + 240.0f, 360.0f)); } if (max_color_value == 0) { @@ -686,8 +686,8 @@ void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, } void hsv_to_rgb(int hue, float saturation, float value, float &red, float &green, float &blue) { float chroma = value * saturation; - float hue_prime = fmod(hue / 60.0, 6); - float intermediate = chroma * (1 - fabs(fmod(hue_prime, 2) - 1)); + float hue_prime = fmodf(hue / 60.0f, 6.0f); + float intermediate = chroma * (1.0f - fabsf(fmodf(hue_prime, 2.0f) - 1.0f)); float delta = value - chroma; if (0 <= hue_prime && hue_prime < 1) { diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 9c5557bdfc..8449cba5e8 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -356,7 +356,7 @@ void HOT Scheduler::set_retry_common_(Component *component, NameType name_type, } #endif - if (backoff_increase_factor < 0.0001) { + if (backoff_increase_factor < 0.0001f) { ESP_LOGE(TAG, "set_retry: backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, (name_type == NameType::STATIC_STRING && static_name) ? static_name : ""); backoff_increase_factor = 1; From 40820287f17e51b74fbb091d6829f9df6c7ae7c7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:13 -0400 Subject: [PATCH 1861/2214] [multiple] Single-precision float math, avoid double promotion (batch 1/4) (#17253) --- esphome/components/daikin_brc/daikin_brc.cpp | 2 +- .../components/dfrobot_sen0395/commands.cpp | 54 +++++++++---------- esphome/components/display/display.cpp | 2 +- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp | 2 +- esphome/components/light/transformers.h | 2 +- .../mcp4461/output/mcp4461_output.cpp | 4 +- esphome/components/opentherm/opentherm.cpp | 2 +- esphome/components/qmp6988/qmp6988.cpp | 2 +- .../shelly_dimmer/shelly_dimmer.cpp | 2 +- .../speaker_source_media_player.cpp | 2 +- esphome/components/veml7700/veml7700.cpp | 2 +- .../xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp | 2 +- 12 files changed, 40 insertions(+), 38 deletions(-) diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index 5fe3d30a85..1b085013f1 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -151,7 +151,7 @@ uint8_t DaikinBrcClimate::temperature_() { // Temperature in remote is in F if (this->fahrenheit_) { temperature = (uint8_t) roundf( - clamp(((this->target_temperature * 1.8) + 32), DAIKIN_BRC_TEMP_MIN_F, DAIKIN_BRC_TEMP_MAX_F)); + clamp(((this->target_temperature * 1.8f) + 32), DAIKIN_BRC_TEMP_MIN_F, DAIKIN_BRC_TEMP_MAX_F)); } else { temperature = ((uint8_t) roundf(this->target_temperature) - 9) << 1; } diff --git a/esphome/components/dfrobot_sen0395/commands.cpp b/esphome/components/dfrobot_sen0395/commands.cpp index 29ee166f51..570bfef943 100644 --- a/esphome/components/dfrobot_sen0395/commands.cpp +++ b/esphome/components/dfrobot_sen0395/commands.cpp @@ -121,51 +121,51 @@ DetRangeCfgCommand::DetRangeCfgCommand(float min1, float max1, float min2, float this->cmd_ = "detRangeCfg -1 0 0"; } else if (min2 < 0 || max2 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; this->min2_ = min2 = this->max2_ = max2 = this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f", min1 / 0.15, max1 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f", min1 / 0.15f, max1 / 0.15f); this->cmd_ = buf; } else if (min3 < 0 || max3 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, - max2 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, min2 / 0.15f, + max2 / 0.15f); this->cmd_ = buf; } else if (min4 < 0 || max4 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; - this->min3_ = min3 = round(min3 / 0.15) * 0.15; - this->max3_ = max3 = round(max3 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; + this->min3_ = min3 = roundf(min3 / 0.15f) * 0.15f; + this->max3_ = max3 = roundf(max3 / 0.15f) * 0.15f; this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, - max2 / 0.15, min3 / 0.15, max3 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, min2 / 0.15f, + max2 / 0.15f, min3 / 0.15f, max3 / 0.15f); this->cmd_ = buf; } else { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; - this->min3_ = min3 = round(min3 / 0.15) * 0.15; - this->max3_ = max3 = round(max3 / 0.15) * 0.15; - this->min4_ = min4 = round(min4 / 0.15) * 0.15; - this->max4_ = max4 = round(max4 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; + this->min3_ = min3 = roundf(min3 / 0.15f) * 0.15f; + this->max3_ = max3 = roundf(max3 / 0.15f) * 0.15f; + this->min4_ = min4 = roundf(min4 / 0.15f) * 0.15f; + this->max4_ = max4 = roundf(max4 / 0.15f) * 0.15f; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, - min2 / 0.15, max2 / 0.15, min3 / 0.15, max3 / 0.15, min4 / 0.15, max4 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, + min2 / 0.15f, max2 / 0.15f, min3 / 0.15f, max3 / 0.15f, min4 / 0.15f, max4 / 0.15f); this->cmd_ = buf; } diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index b24c099bce..b30f444d6d 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -228,7 +228,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int e2max, e2min; progress = std::max(0, std::min(progress, 100)); // 0..100 int draw_progress = progress > 50 ? (100 - progress) : progress; - float tan_a = (progress == 50) ? 65535 : tan(float(draw_progress) * M_PI / 100); // slope + float tan_a = (progress == 50) ? 65535 : tanf(float(draw_progress) * std::numbers::pi_v / 100); // slope do { // outer dots diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 0b3a746c34..270bb2709d 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -55,7 +55,7 @@ void HrxlMaxsonarWrComponent::check_buffer_() { millimeters = millimeters * 10; } - float meters = float(millimeters) / 1000.0; + float meters = float(millimeters) / 1000.0f; ESP_LOGV(TAG, "Distance from sensor: %d mm, %f m", millimeters, meters); this->publish_state(meters); } else { diff --git a/esphome/components/light/transformers.h b/esphome/components/light/transformers.h index 61fe098ad7..34e192a034 100644 --- a/esphome/components/light/transformers.h +++ b/esphome/components/light/transformers.h @@ -47,7 +47,7 @@ class LightTransitionTransformer : public LightTransformer { LightColorValues &start = this->changing_color_mode_ && p > 0.5f ? this->intermediate_values_ : this->start_values_; LightColorValues &end = this->changing_color_mode_ && p < 0.5f ? this->intermediate_values_ : this->end_values_; if (this->changing_color_mode_) - p = p < 0.5f ? p * 2 : (p - 0.5) * 2; + p = p < 0.5f ? p * 2 : (p - 0.5f) * 2; float v = LightTransformer::smoothed_progress(p); return LightColorValues::lerp(start, end, v); diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 6912ad5f36..3892372cab 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -29,7 +29,9 @@ void Mcp4461Wiper::write_state(float state) { } } -float Mcp4461Wiper::read_state() { return (static_cast(this->parent_->get_wiper_level_(this->wiper_)) / 256.0); } +float Mcp4461Wiper::read_state() { + return (static_cast(this->parent_->get_wiper_level_(this->wiper_)) / 256.0f); +} float Mcp4461Wiper::update_state() { this->state_ = this->read_state(); diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 1ee4c9191b..5cf7c19880 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -541,7 +541,7 @@ void OpenTherm::debug_error(OpenThermError &error) const { error.capture, error.bit_pos); } -float OpenthermData::f88() { return ((float) this->s16()) / 256.0; } +float OpenthermData::f88() { return ((float) this->s16()) / 256.0f; } void OpenthermData::f88(float value) { this->s16((int16_t) (value * 256)); } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index bb47e7b0f5..547991f75e 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -276,7 +276,7 @@ void QMP6988Component::write_oversampling_temperature_(QMP6988Oversampling overs void QMP6988Component::calculate_altitude_(float pressure, float temp) { float altitude; - altitude = (pow((101325 / pressure), 1 / 5.257) - 1) * (temp + 273.15) / 0.0065; + altitude = (powf((101325 / pressure), 1 / 5.257f) - 1) * (temp + 273.15f) / 0.0065f; this->qmp6988_data_.altitude = altitude; } diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index b0f43f0ffc..b69e417591 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -207,7 +207,7 @@ bool ShellyDimmer::upgrade_firmware_() { uint16_t ShellyDimmer::convert_brightness_(float brightness) { // Special case for zero as only zero means turn off completely. - if (brightness == 0.0) { + if (brightness == 0.0f) { return 0; } diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 87fd4fe9ed..a33a1a1650 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -831,7 +831,7 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { // Turn on the mute state if the volume is effectively zero, off otherwise. // Pass publish=false to avoid saving twice. - if (volume < 0.001) { + if (volume < 0.001f) { this->set_mute_state_(true, false); } else { this->set_mute_state_(false, false); diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 80e6f872ab..594c9da170 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -380,7 +380,7 @@ void VEML7700Component::apply_lux_compensation_(Readings &data) { // if this light level is exceeded" auto compensate = [&local_data](float &lux) { auto calculate_high_lux_compensation = [](float lux_veml) -> float { - return (((6.0135e-13 * lux_veml - 9.3924e-9) * lux_veml + 8.1488e-5) * lux_veml + 1.0023) * lux_veml; + return (((6.0135e-13f * lux_veml - 9.3924e-9f) * lux_veml + 8.1488e-5f) * lux_veml + 1.0023f) * lux_veml; }; if (lux > 1000.0f || local_data.actual_gain == Gain::X_1_8 || local_data.actual_gain == Gain::X_1_4) { diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index a4303b055a..c2b3ec1437 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -49,7 +49,7 @@ bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; From b7803cf9b5a29613fef70f253281ffc168330452 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:20 -0400 Subject: [PATCH 1862/2214] [multiple] Single-precision float math, avoid double promotion (batch 2/4) (#17254) --- esphome/components/a01nyub/a01nyub.cpp | 2 +- .../binary_sensor_map/binary_sensor_map.cpp | 2 +- esphome/components/bl0942/bl0942.cpp | 4 ++-- .../components/dallas_temp/dallas_temp.cpp | 2 +- esphome/components/ds2484/ds2484.h | 2 +- .../grove_tb6612fng/grove_tb6612fng.cpp | 4 ++-- .../components/honeywellabp/honeywellabp.cpp | 8 ++++--- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- esphome/components/ltr390/ltr390.cpp | 2 +- esphome/components/mcp4725/mcp4725.cpp | 2 +- esphome/components/mics_4514/mics_4514.cpp | 22 +++++++++---------- .../opentherm/output/opentherm_output.cpp | 5 +++-- .../runtime_stats/runtime_stats.cpp | 2 +- .../components/sound_level/sound_level.cpp | 2 +- esphome/components/sx127x/sx127x.cpp | 6 ++--- .../thermopro_ble/thermopro_ble.cpp | 2 +- esphome/components/x9c/x9c.cpp | 2 +- 17 files changed, 37 insertions(+), 34 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp index 344456854b..6111af2b7e 100644 --- a/esphome/components/a01nyub/a01nyub.cpp +++ b/esphome/components/a01nyub/a01nyub.cpp @@ -25,7 +25,7 @@ void A01nyubComponent::check_buffer_() { if (this->buffer_[3] == checksum) { float distance = (this->buffer_[1] << 8) + this->buffer_[2]; if (distance > 280) { - float meters = distance / 1000.0; + float meters = distance / 1000.0f; ESP_LOGV(TAG, "Distance from sensor: %f mm, %f m", distance, meters); this->publish_state(meters); } else { diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.cpp b/esphome/components/binary_sensor_map/binary_sensor_map.cpp index 316d44ba59..3185f15697 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.cpp +++ b/esphome/components/binary_sensor_map/binary_sensor_map.cpp @@ -112,7 +112,7 @@ float BinarySensorMap::bayesian_predicate_(bool sensor_state, float prior, float prob_state_source_false = 1 - prob_given_false; } - return prob_state_source_true / (prior * prob_state_source_true + (1.0 - prior) * prob_state_source_false); + return prob_state_source_true / (prior * prob_state_source_true + (1.0f - prior) * prob_state_source_false); } void BinarySensorMap::add_channel(binary_sensor::BinarySensor *sensor, float value) { diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 1c57616c82..e952df21be 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -124,14 +124,14 @@ void BL0942::setup() { // If either current or voltage references are set explicitly by the user, // calculate the power reference from it unless that is also explicitly set. if ((this->current_reference_set_ || this->voltage_reference_set_) && !this->power_reference_set_) { - this->power_reference_ = (this->voltage_reference_ * this->current_reference_ * 3537.0 / 305978.0) / 73989.0; + this->power_reference_ = (this->voltage_reference_ * this->current_reference_ * 3537.0f / 305978.0f) / 73989.0f; this->power_reference_set_ = true; } // Similarly for energy reference, if the power reference was set by the user // either implicitly or explicitly. if (this->power_reference_set_ && !this->energy_reference_set_) { - this->energy_reference_ = this->power_reference_ * 3600000 / 419430.4; + this->energy_reference_ = this->power_reference_ * 3600000 / 419430.4f; this->energy_reference_set_ = true; } diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 35488eab03..ab4a8c458f 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -138,7 +138,7 @@ float DallasTemperatureSensor::get_temp_c_() { if (this->scratch_pad_[7] == 0) { return NAN; } - return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25; + return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25f; } switch (this->resolution_) { case 9: diff --git a/esphome/components/ds2484/ds2484.h b/esphome/components/ds2484/ds2484.h index b3337539ce..819b9456c1 100644 --- a/esphome/components/ds2484/ds2484.h +++ b/esphome/components/ds2484/ds2484.h @@ -12,7 +12,7 @@ class DS2484OneWireBus final : public one_wire::OneWireBus, public i2c::I2CDevic public: void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::BUS - 1.0; } + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } bool reset_device(); int reset_int() override; diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index eaa1440c4d..2c68eef623 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -122,7 +122,7 @@ void GroveMotorDriveTB6612FNG::stepper_run(StepperModeTypeT mode, int16_t steps, rpm = clamp(rpm, 1, 300); - ms_per_step = (uint16_t) (3000.0 / (float) rpm); + ms_per_step = (uint16_t) (3000.0f / (float) rpm); buffer_[0] = mode; buffer_[1] = cw; //(cw=1) => cw; (cw=0) => ccw buffer_[2] = steps; @@ -153,7 +153,7 @@ void GroveMotorDriveTB6612FNG::stepper_keep_run(StepperModeTypeT mode, uint16_t uint16_t ms_per_step = 0; rpm = clamp(rpm, 1, 300); - ms_per_step = (uint16_t) (3000.0 / (float) rpm); + ms_per_step = (uint16_t) (3000.0f / (float) rpm); buffer_[0] = mode; buffer_[1] = cw; //(cw=1) => cw; (cw=0) => ccw diff --git a/esphome/components/honeywellabp/honeywellabp.cpp b/esphome/components/honeywellabp/honeywellabp.cpp index 8bfc5e4f4f..dd86b95c78 100644 --- a/esphome/components/honeywellabp/honeywellabp.cpp +++ b/esphome/components/honeywellabp/honeywellabp.cpp @@ -55,7 +55,9 @@ float HONEYWELLABPSensor::countstopressure_(const int counts, const float min_pr // Converts a digital temperature measurement in counts to temperature in C // This will be invalid if sensore daoes not have temperature measurement capability -float HONEYWELLABPSensor::countstotemperatures_(const int counts) { return (((float) counts / 2047.0) * 200.0) - 50.0; } +float HONEYWELLABPSensor::countstotemperatures_(const int counts) { + return (((float) counts / 2047.0f) * 200.0f) - 50.0f; +} // Pressure value from the most recent reading in units float HONEYWELLABPSensor::read_pressure_() { @@ -69,9 +71,9 @@ void HONEYWELLABPSensor::update() { ESP_LOGV(TAG, "Update Honeywell ABP Sensor"); if (readsensor_() == 0) { if (this->pressure_sensor_ != nullptr) - this->pressure_sensor_->publish_state(read_pressure_() * 1.0); + this->pressure_sensor_->publish_state(read_pressure_() * 1.0f); if (this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(read_temperature_() * 1.0); + this->temperature_sensor_->publish_state(read_temperature_() * 1.0f); } } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index c6ff42495f..5e271e671e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -139,7 +139,7 @@ void I2SAudioSpeakerBase::set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { - if (volume > 0.0) { + if (volume > 0.0f) { this->audio_dac_->set_mute_off(); } this->audio_dac_->set_volume(volume); diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index 62a0d2290a..dd78b20f2c 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -75,7 +75,7 @@ void LTR390Component::read_als_() { uint32_t als = *val; if (this->light_sensor_ != nullptr) { - float lux = ((0.6 * als) / (GAINVALUES[this->gain_als_] * RESOLUTIONVALUE[this->res_als_])) * this->wfac_; + float lux = ((0.6f * als) / (GAINVALUES[this->gain_als_] * RESOLUTIONVALUE[this->res_als_])) * this->wfac_; this->light_sensor_->publish_state(lux); } diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index a32527c725..21aff90fae 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -24,7 +24,7 @@ void MCP4725::dump_config() { // https://learn.sparkfun.com/tutorials/mcp4725-digital-to-analog-converter-hookup-guide?_ga=2.176055202.1402343014.1607953301-893095255.1606753886 void MCP4725::write_state(float state) { - const uint16_t value = (uint16_t) round(state * (pow(2, MCP4725_RES) - 1)); + const uint16_t value = (uint16_t) roundf(state * (powf(2, MCP4725_RES) - 1)); this->write_byte_16(64, value << 4); } diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index d99d4fd772..14a73bc15f 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -71,10 +71,10 @@ void MICS4514Component::update() { float co = 0.0f; if (red_f > 3.4f) { co = 0.0; - } else if (red_f < 0.01) { + } else if (red_f < 0.01f) { co = 1000.0; } else { - co = 4.2 / pow(red_f, 1.2); + co = 4.2f / powf(red_f, 1.2f); } this->carbon_monoxide_sensor_->publish_state(co); } @@ -84,47 +84,47 @@ void MICS4514Component::update() { if (ox_f < 0.3f) { nitrogendioxide = 0.0; } else { - nitrogendioxide = 0.164 * pow(ox_f, 0.975); + nitrogendioxide = 0.164f * powf(ox_f, 0.975f); } this->nitrogen_dioxide_sensor_->publish_state(nitrogendioxide); } if (this->methane_sensor_ != nullptr) { float methane = 0.0f; - if (red_f > 0.9f || red_f < 0.5) { // outside the range->unlikely + if (red_f > 0.9f || red_f < 0.5f) { // outside the range->unlikely methane = 0.0; } else { - methane = 630 / pow(red_f, 4.4); + methane = 630 / powf(red_f, 4.4f); } this->methane_sensor_->publish_state(methane); } if (this->ethanol_sensor_ != nullptr) { float ethanol = 0.0f; - if (red_f > 1.0f || red_f < 0.02) { // outside the range->unlikely + if (red_f > 1.0f || red_f < 0.02f) { // outside the range->unlikely ethanol = 0.0; } else { - ethanol = 1.52 / pow(red_f, 1.55); + ethanol = 1.52f / powf(red_f, 1.55f); } this->ethanol_sensor_->publish_state(ethanol); } if (this->hydrogen_sensor_ != nullptr) { float hydrogen = 0.0f; - if (red_f > 0.9f || red_f < 0.02) { // outside the range->unlikely + if (red_f > 0.9f || red_f < 0.02f) { // outside the range->unlikely hydrogen = 0.0; } else { - hydrogen = 0.85 / pow(red_f, 1.75); + hydrogen = 0.85f / powf(red_f, 1.75f); } this->hydrogen_sensor_->publish_state(hydrogen); } if (this->ammonia_sensor_ != nullptr) { float ammonia = 0.0f; - if (red_f > 0.98f || red_f < 0.2532) { // outside the ammonia range->unlikely + if (red_f > 0.98f || red_f < 0.2532f) { // outside the ammonia range->unlikely ammonia = 0.0; } else { - ammonia = 0.9 / pow(red_f, 4.6); + ammonia = 0.9f / powf(red_f, 4.6f); } this->ammonia_sensor_->publish_state(ammonia); } diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 4092358d75..9b87cd8d12 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -12,8 +12,9 @@ void opentherm::OpenthermOutput::write_state(float state) { #else bool zero_means_zero = false; #endif - this->state = - state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); + this->state = state < 0.003f && zero_means_zero + ? 0.0f + : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); this->has_state_ = true; ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state); } diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index d733394b78..12e4d14ba2 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -95,7 +95,7 @@ void RuntimeStatsCollector::log_stats_() { ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, - stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0f); } } diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index a93e396367..99ab7932d6 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -121,7 +121,7 @@ void SoundLevelComponent::loop() { if (this->sample_count_ == samples_in_window) { // Processed enough samples for the measurement window, compute and publish the sensor values if (this->peak_sensor_ != nullptr) { - const float peak_db = 10.0f * log10(static_cast(this->squared_peak_) / MAX_SAMPLE_SQUARED_DENOMINATOR); + const float peak_db = 10.0f * log10f(static_cast(this->squared_peak_) / MAX_SAMPLE_SQUARED_DENOMINATOR); this->peak_sensor_->publish_state(peak_db); this->squared_peak_ = 0; // reset accumulator diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 0596e91ccc..040a3064bc 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -201,8 +201,8 @@ void SX127x::configure_fsk_ook_() { this->write_register_(REG_OOK_AVG, OOK_AVG_RESERVED | OOK_THRESH_DEC_1_8); // set rx floor - this->write_register_(REG_OOK_FIX, 256 + int(this->rx_floor_ * 2.0)); - this->write_register_(REG_RSSI_THRESH, std::abs(int(this->rx_floor_ * 2.0))); + this->write_register_(REG_OOK_FIX, 256 + int(this->rx_floor_ * 2.0f)); + this->write_register_(REG_RSSI_THRESH, std::abs(int(this->rx_floor_ * 2.0f))); } void SX127x::configure_lora_() { @@ -225,7 +225,7 @@ void SX127x::configure_lora_() { } // optimize detection - float duration = 1000.0f * std::pow(2, this->spreading_factor_) / BW_HZ[this->bandwidth_]; + float duration = 1000.0f * (1UL << this->spreading_factor_) / BW_HZ[this->bandwidth_]; if (duration > 16) { this->write_register_(REG_MODEM_CONFIG3, MODEM_AGC_AUTO_ON | LOW_DATA_RATE_OPTIMIZE_ON); } else { diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 1ccf59a2f6..2a950d3664 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -196,7 +196,7 @@ static optional parse_tp3(const uint8_t *data, std::size_t data_siz result.humidity = static_cast(data[3]); // battery level, 2 bits (0-2) - result.battery_level = static_cast(data[4] & 0x3) * 50.0; + result.battery_level = static_cast(data[4] & 0x3) * 50.0f; return result; } diff --git a/esphome/components/x9c/x9c.cpp b/esphome/components/x9c/x9c.cpp index 52ce328b3c..b0ad79e51c 100644 --- a/esphome/components/x9c/x9c.cpp +++ b/esphome/components/x9c/x9c.cpp @@ -44,7 +44,7 @@ void X9cOutput::setup() { this->ud_pin_->get_pin(); this->ud_pin_->setup(); - if (this->initial_value_ <= 0.50) { + if (this->initial_value_ <= 0.50f) { this->trim_value(-101); // Set min value (beyond 0) this->trim_value(lroundf(this->initial_value_ * 100)); } else { From 556def78aaaec597f7bd737de77c7c89e46e08a3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:30 -0400 Subject: [PATCH 1863/2214] [multiple] Single-precision float math, avoid double promotion (batch 3/4) (#17255) --- esphome/components/anova/anova_base.cpp | 4 ++-- esphome/components/bl0906/bl0906.cpp | 2 +- esphome/components/combination/combination.cpp | 2 +- esphome/components/demo/demo_sensor.h | 2 +- esphome/components/demo/demo_text_sensor.h | 4 ++-- esphome/components/es7243e/es7243e.cpp | 8 ++++---- esphome/components/haier/hon_climate.cpp | 2 +- esphome/components/ina219/ina219.cpp | 2 +- esphome/components/light/light_color_values.h | 4 ++-- esphome/components/ltr501/ltr501.cpp | 12 ++++++------ esphome/components/max17043/max17043.cpp | 2 +- esphome/components/msa3xx/msa3xx.cpp | 2 +- esphome/components/openthread/openthread.h | 2 +- esphome/components/spa06_base/spa06_base.cpp | 2 +- esphome/components/tcs34725/tcs34725.cpp | 4 ++-- esphome/components/toshiba/toshiba.cpp | 2 +- esphome/components/ufire_ise/ufire_ise.cpp | 8 ++++---- .../xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp | 2 +- 18 files changed, 33 insertions(+), 33 deletions(-) diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp index 84dd4393eb..806a441dcd 100644 --- a/esphome/components/anova/anova_base.cpp +++ b/esphome/components/anova/anova_base.cpp @@ -6,9 +6,9 @@ namespace esphome::anova { -float ftoc(float f) { return (f - 32.0) * (5.0f / 9.0f); } +float ftoc(float f) { return (f - 32.0f) * (5.0f / 9.0f); } -float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0; } +float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0f; } AnovaPacket *AnovaCodec::clean_packet_() { this->packet_.length = strlen((char *) this->packet_.data); diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index d387757051..9a27cffd04 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -205,7 +205,7 @@ void BL0906::read_data_(const uint8_t address, const float reference, sensor::Se // Chip temperature if (reference == BL0906_TREF) { value = (float) to_int32_t(data_s24); - value = (value - 64) * 12.5 / 59 - 40; + value = (value - 64) * 12.5f / 59 - 40; } sensor->publish_state(value); } diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index ddf1a105e0..8ef0976e3b 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -204,7 +204,7 @@ void MedianCombinationComponent::handle_new_value(float value) { median = sensor_states[sensor_states_size / 2]; } else { // Even number of measurements, use the average of the two middle measurements - median = (sensor_states[sensor_states_size / 2] + sensor_states[sensor_states_size / 2 - 1]) / 2.0; + median = (sensor_states[sensor_states_size / 2] + sensor_states[sensor_states_size / 2 - 1]) / 2.0f; } } diff --git a/esphome/components/demo/demo_sensor.h b/esphome/components/demo/demo_sensor.h index 6153c810e1..ff2163776c 100644 --- a/esphome/components/demo/demo_sensor.h +++ b/esphome/components/demo/demo_sensor.h @@ -15,7 +15,7 @@ class DemoSensor final : public sensor::Sensor, public PollingComponent { float base = std::isnan(this->state) ? 0.0f : this->state; this->publish_state(base + val * 10); } else { - if (val < 0.1) { + if (val < 0.1f) { this->publish_state(NAN); } else { this->publish_state(val * 100); diff --git a/esphome/components/demo/demo_text_sensor.h b/esphome/components/demo/demo_text_sensor.h index fa728903d9..8eaa6c6b46 100644 --- a/esphome/components/demo/demo_text_sensor.h +++ b/esphome/components/demo/demo_text_sensor.h @@ -10,9 +10,9 @@ class DemoTextSensor final : public text_sensor::TextSensor, public PollingCompo public: void update() override { float val = random_float(); - if (val < 0.33) { + if (val < 0.33f) { this->publish_state("foo"); - } else if (val < 0.66) { + } else if (val < 0.66f) { this->publish_state("bar"); } else { this->publish_state("foobar"); diff --git a/esphome/components/es7243e/es7243e.cpp b/esphome/components/es7243e/es7243e.cpp index b4d9fba4c5..fc3cba7ae4 100644 --- a/esphome/components/es7243e/es7243e.cpp +++ b/esphome/components/es7243e/es7243e.cpp @@ -105,14 +105,14 @@ bool ES7243E::configure_mic_gain_() { uint8_t ES7243E::es7243e_gain_reg_value_(float mic_gain) { // reg: 12 - 34.5dB, 13 - 36dB, 14 - 37.5dB - mic_gain += 0.5; - if (mic_gain <= 33.0) { + mic_gain += 0.5f; + if (mic_gain <= 33.0f) { return (uint8_t) mic_gain / 3; } - if (mic_gain < 36.0) { + if (mic_gain < 36.0f) { return 12; } - if (mic_gain < 37.0) { + if (mic_gain < 37.0f) { return 13; } return 14; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 0ad9b00ce4..f68404afd9 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -607,7 +607,7 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { if (climate_control.target_temperature.has_value()) { float target_temp = climate_control.target_temperature.value(); out_data->set_point = ((int) target_temp) - 16; // set the temperature with offset 16 - out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49) ? 1 : 0; + out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49f) ? 1 : 0; } if (out_data->ac_power == 0) { // If AC is off - no presets allowed diff --git a/esphome/components/ina219/ina219.cpp b/esphome/components/ina219/ina219.cpp index 85da196584..833d1989c8 100644 --- a/esphome/components/ina219/ina219.cpp +++ b/esphome/components/ina219/ina219.cpp @@ -119,7 +119,7 @@ void INA219Component::setup() { } this->calibration_lsb_ = lsb; - auto calibration = uint32_t(0.04096f / (0.000001 * lsb * this->shunt_resistance_ohm_)); + auto calibration = uint32_t(0.04096f / (0.000001f * lsb * this->shunt_resistance_ohm_)); ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); if (!this->write_byte_16(INA219_REGISTER_CALIBRATION, calibration)) { this->mark_failed(); diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 5cafa9fe82..e431a06df6 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -315,14 +315,14 @@ class LightColorValues { if (this->color_temperature_ <= 0) { return this->color_temperature_; } - return 1000000.0 / this->color_temperature_; + return 1000000.0f / this->color_temperature_; } /// Set the color temperature property of these light color values in kelvin. void set_color_temperature_kelvin(float color_temperature) { if (color_temperature <= 0) { return; } - this->color_temperature_ = 1000000.0 / color_temperature; + this->color_temperature_ = 1000000.0f / color_temperature; } /// Get the cold white property of these light color values. In range 0.0 to 1.0. diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index 9cba06e483..afdc271167 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -500,12 +500,12 @@ void LTRAlsPs501Component::apply_lux_calculation_(AlsReadings &data) { // method from // https://github.com/fards/Ainol_fire_kernel/blob/83832cf8a3082fd8e963230f4b1984479d1f1a84/customer/drivers/lightsensor/ltr501als.c#L295 - if (ratio < 0.45) { - lux = 1.7743 * ch0 + 1.1059 * ch1; - } else if (ratio < 0.64) { - lux = 3.7725 * ch0 - 1.3363 * ch1; - } else if (ratio < 0.85) { - lux = 1.6903 * ch0 - 0.1693 * ch1; + if (ratio < 0.45f) { + lux = 1.7743f * ch0 + 1.1059f * ch1; + } else if (ratio < 0.64f) { + lux = 3.7725f * ch0 - 1.3363f * ch1; + } else if (ratio < 0.85f) { + lux = 1.6903f * ch0 - 0.1693f * ch1; } else { ESP_LOGW(TAG, "Impossible ch1/(ch0 + ch1) ratio"); lux = 0.0f; diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index b59bac7ebf..8776bb5558 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -23,7 +23,7 @@ void MAX17043Component::update() { if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) { this->status_set_warning(LOG_STR("Unable to read MAX17043_VCELL")); } else { - float voltage = (1.25 * (float) (raw_voltage >> 4)) / 1000.0; + float voltage = (1.25f * (float) (raw_voltage >> 4)) / 1000.0f; this->voltage_sensor_->publish_state(voltage); this->status_clear_warning(); } diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index f23fcfc8ea..ecde0cb117 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "msa3xx"; const uint8_t MSA_3XX_PART_ID = 0x13; const float GRAVITY_EARTH = 9.80665f; -const float LSB_COEFF = 1000.0f / (GRAVITY_EARTH * 3.9); // LSB to 1 LSB = 3.9mg = 0.0039g +const float LSB_COEFF = 1000.0f / (GRAVITY_EARTH * 3.9f); // LSB to 1 LSB = 3.9mg = 0.0039g const float G_OFFSET_MIN = -4.5f; // -127...127 LSB = +- 0.4953g = +- 4.857 m/s^2 => +- 4.5 for the safe const float G_OFFSET_MAX = 4.5f; // -127...127 LSB = +- 0.4953g = +- 4.857 m/s^2 => +- 4.5 for the safe diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index a96941325c..eb48d8a74a 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -85,7 +85,7 @@ class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns); // This has to run after the mdns component or else no services are available to advertise - float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0; } + float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0f; } void setup() override; static void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrpClientService *services, const otSrpClientService *removed_services, void *context); diff --git a/esphome/components/spa06_base/spa06_base.cpp b/esphome/components/spa06_base/spa06_base.cpp index b0490628cb..d3de5168e4 100644 --- a/esphome/components/spa06_base/spa06_base.cpp +++ b/esphome/components/spa06_base/spa06_base.cpp @@ -224,7 +224,7 @@ bool SPA06Component::soft_reset_() { } // Temperature conversion formula. See datasheet pg. 14 -float SPA06Component::convert_temperature_(const float &t_raw_sc) { return this->c0_ * 0.5 + this->c1_ * t_raw_sc; } +float SPA06Component::convert_temperature_(const float &t_raw_sc) { return this->c0_ * 0.5f + this->c1_ * t_raw_sc; } // Pressure conversion formula. See datasheet pg. 14 float SPA06Component::convert_pressure_(const float &p_raw_sc, const float &t_raw_sc) { float p2_raw_sc = p_raw_sc * p_raw_sc; diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 40c65e9f84..b585392790 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -256,7 +256,7 @@ void TCS34725Component::update() { // increase only if not already maximum // do not use max gain, as ist will not get better if (this->gain_reg_ < 3) { - if (((float) raw_c / 655.35 < 20.f) && (this->integration_time_ > 600.f)) { + if (((float) raw_c / 655.35f < 20.f) && (this->integration_time_ > 600.f)) { gain_reg_val_new = this->gain_reg_ + 1; // update integration time to new situation integration_time_ideal = integration_time_ideal / 4; @@ -265,7 +265,7 @@ void TCS34725Component::update() { // decrease gain, if very high clear values and integration times alreadey low if (this->gain_reg_ > 0) { - if (70 < ((float) raw_c / 655.35) && (this->integration_time_ < 200)) { + if (70 < ((float) raw_c / 655.35f) && (this->integration_time_ < 200)) { gain_reg_val_new = this->gain_reg_ - 1; // update integration time to new situation integration_time_ideal = integration_time_ideal * 4; diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 1b37c6897d..19950bbd15 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -593,7 +593,7 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { message[3] = ~message[2]; // Byte 4u: Temp if (this->model_ == MODEL_RAC_PT1411HWRU_F) { - temperature = (temperature * 1.8) + 32; + temperature = (temperature * 1.8f) + 32; temp_adjd = temperature - TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN; } diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index bd2dc2836e..d595b37a83 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -70,19 +70,19 @@ float UFireISEComponent::measure_ph_(float temperature) { if (mv == -1) return -1; - ph = fabs(7.0 - (mv / PROBE_MV_TO_PH)); + ph = fabsf(7.0f - (mv / PROBE_MV_TO_PH)); // Determine the temperature correction float distance_from_7 = std::abs(7 - roundf(ph)); float distance_from_25 = std::floor(std::abs(25 - roundf(temperature)) / 10); float temp_multiplier = (distance_from_25 * distance_from_7) * PROBE_TMP_CORRECTION; - if ((ph >= 8.0) && (temperature >= 35)) + if ((ph >= 8.0f) && (temperature >= 35)) temp_multiplier *= -1; - if ((ph <= 6.0) && (temperature <= 15)) + if ((ph <= 6.0f) && (temperature <= 15)) temp_multiplier *= -1; ph += temp_multiplier; - if ((ph <= 0.0) || (ph > 14.0)) + if ((ph <= 0.0f) || (ph > 14.0f)) ph = -1; if (std::isinf(ph)) ph = -1; diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index a0a9260156..7aa4809e24 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -49,7 +49,7 @@ bool XiaomiLYWSD03MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; From 95449068e72b10be110a0d4fc070e4b2ae87c1f9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:41 -0400 Subject: [PATCH 1864/2214] [multiple] Single-precision float math, avoid double promotion (batch 4/4) (#17256) --- esphome/components/am43/cover/am43_cover.cpp | 10 +++++----- esphome/components/bl0940/bl0940.cpp | 2 +- .../components/current_based/current_based_cover.cpp | 2 +- esphome/components/demo/demo_switch.h | 2 +- esphome/components/es7210/es7210.cpp | 8 ++++---- esphome/components/graph/graph.cpp | 4 ++-- esphome/components/haier/smartair2_climate.cpp | 2 +- esphome/components/ina226/ina226.cpp | 2 +- esphome/components/ltr_als_ps/ltr_als_ps.cpp | 12 ++++++------ esphome/components/mcp3204/mcp3204.cpp | 2 +- esphome/components/mpl3115a2/mpl3115a2.cpp | 6 +++--- esphome/components/mqtt/mqtt_climate.cpp | 4 ++-- esphome/components/nextion/nextion_commands.cpp | 2 +- esphome/components/pid/pid_autotuner.cpp | 2 +- esphome/components/servo/servo.cpp | 2 +- .../speaker/media_player/speaker_media_player.cpp | 2 +- esphome/components/veml3235/veml3235.cpp | 2 +- esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp | 2 +- 18 files changed, 34 insertions(+), 34 deletions(-) diff --git a/esphome/components/am43/cover/am43_cover.cpp b/esphome/components/am43/cover/am43_cover.cpp index 35366dbaa6..4b096983a4 100644 --- a/esphome/components/am43/cover/am43_cover.cpp +++ b/esphome/components/am43/cover/am43_cover.cpp @@ -114,13 +114,13 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->decoder_->decode(param->notify.value, param->notify.value_len); if (this->decoder_->has_position()) { - this->position = ((float) this->decoder_->position_ / 100.0); + this->position = ((float) this->decoder_->position_ / 100.0f); if (!this->invert_position_) this->position = 1 - this->position; - if (this->position > 0.97) - this->position = 1.0; - if (this->position < 0.02) - this->position = 0.0; + if (this->position > 0.97f) + this->position = 1.0f; + if (this->position < 0.02f) + this->position = 0.0f; this->publish_state(); } diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index b7df603f2f..642368d93f 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -120,7 +120,7 @@ float BL0940::calculate_power_reference_() { float BL0940::calculate_energy_reference_() { // formula: 3600000 * 4046 * RL * R1 * 1000 / (1638.4 * 256) / Vref² / (R1 + R2) // or: power_reference_ * 3600000 / (1638.4 * 256) - return this->power_reference_cal_ * 3600000 / (1638.4 * 256); + return this->power_reference_cal_ * 3600000 / (1638.4f * 256); } float BL0940::calculate_calibration_value_(float state) { return (100 + state) / 100; } diff --git a/esphome/components/current_based/current_based_cover.cpp b/esphome/components/current_based/current_based_cover.cpp index 5a499d54a4..d15b310a37 100644 --- a/esphome/components/current_based/current_based_cover.cpp +++ b/esphome/components/current_based/current_based_cover.cpp @@ -39,7 +39,7 @@ void CurrentBasedCover::control(const CoverCall &call) { auto opt_pos = call.get_position(); if (opt_pos.has_value()) { auto pos = *opt_pos; - if (fabsf(this->position - pos) < 0.01) { + if (fabsf(this->position - pos) < 0.01f) { // already at target } else { auto op = pos < this->position ? COVER_OPERATION_CLOSING : COVER_OPERATION_OPENING; diff --git a/esphome/components/demo/demo_switch.h b/esphome/components/demo/demo_switch.h index 6846b8b663..dea975a770 100644 --- a/esphome/components/demo/demo_switch.h +++ b/esphome/components/demo/demo_switch.h @@ -9,7 +9,7 @@ namespace esphome::demo { class DemoSwitch final : public switch_::Switch, public Component { public: void setup() override { - bool initial = random_float() < 0.5; + bool initial = random_float() < 0.5f; this->publish_state(initial); } diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index bbd966fbe0..892b67b270 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -169,14 +169,14 @@ bool ES7210::configure_mic_gain_() { uint8_t ES7210::es7210_gain_reg_value_(float mic_gain) { // reg: 12 - 34.5dB, 13 - 36dB, 14 - 37.5dB - mic_gain += 0.5; - if (mic_gain <= 33.0) { + mic_gain += 0.5f; + if (mic_gain <= 33.0f) { return (uint8_t) (mic_gain / 3); } - if (mic_gain < 36.0) { + if (mic_gain < 36.0f) { return 12; } - if (mic_gain < 37.0) { + if (mic_gain < 37.0f) { return 13; } return 14; diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index 5d59f60509..9ceb2f2ba0 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -139,7 +139,7 @@ void Graph::draw(Display *buff, uint16_t x_offset, uint16_t y_offset, Color colo /// Draw grid if (!std::isnan(this->gridspacing_y_)) { for (int y = yn; y <= ym; y++) { - int16_t py = (int16_t) roundf((this->height_ - 1) * (1.0 - (float) (y - yn) / (ym - yn))); + int16_t py = (int16_t) roundf((this->height_ - 1) * (1.0f - (float) (y - yn) / (ym - yn))); for (uint32_t x = 0; x < this->width_; x += 2) { buff->draw_pixel_at(x_offset + x, y_offset + py, color); } @@ -177,7 +177,7 @@ void Graph::draw(Display *buff, uint16_t x_offset, uint16_t y_offset, Color colo uint8_t bit = 1 << ((i % (thick * LineType::PATTERN_LENGTH)) / thick); bool b = (trace->get_line_type() & bit) == bit; if (b) { - int16_t y = (int16_t) roundf((this->height_ - 1) * (1.0 - v)) - thick / 2 + y_offset; + int16_t y = (int16_t) roundf((this->height_ - 1) * (1.0f - v)) - thick / 2 + y_offset; auto draw_pixel_at = [&buff, c, y_offset, this](int16_t x, int16_t y) { if (y >= y_offset && static_cast(y) < y_offset + this->height_) buff->draw_pixel_at(x, y, c); diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index bd5678a425..a013371649 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -341,7 +341,7 @@ haier_protocol::HaierMessage Smartair2Climate::get_control_message() { if (climate_control.target_temperature.has_value()) { float target_temp = climate_control.target_temperature.value(); out_data->set_point = ((int) target_temp) - 16; // set the temperature with offset 16 - out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49) ? 1 : 0; + out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49f) ? 1 : 0; } if (out_data->ac_power == 0) { // If AC is off - no presets allowed diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index 695de57c61..c22237d144 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -70,7 +70,7 @@ void INA226Component::setup() { this->calibration_lsb_ = lsb; - auto calibration = uint32_t(0.00512 / (lsb * this->shunt_resistance_ohm_ / 1000000.0f)); + auto calibration = uint32_t(0.00512f / (lsb * this->shunt_resistance_ohm_ / 1000000.0f)); ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index b7fad2e876..0d43aac20e 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -480,12 +480,12 @@ void LTRAlsPsComponent::apply_lux_calculation_(AlsReadings &data) { float inv_pfactor = this->glass_attenuation_factor_; float lux = 0.0f; - if (ratio < 0.45) { - lux = (1.7743 * ch0 + 1.1059 * ch1); - } else if (ratio < 0.64 && ratio >= 0.45) { - lux = (4.2785 * ch0 - 1.9548 * ch1); - } else if (ratio < 0.85 && ratio >= 0.64) { - lux = (0.5926 * ch0 + 0.1185 * ch1); + if (ratio < 0.45f) { + lux = (1.7743f * ch0 + 1.1059f * ch1); + } else if (ratio < 0.64f && ratio >= 0.45f) { + lux = (4.2785f * ch0 - 1.9548f * ch1); + } else if (ratio < 0.85f && ratio >= 0.64f) { + lux = (0.5926f * ch0 + 0.1185f * ch1); } else { ESP_LOGW(TAG, "Impossible ch1/(ch0 + ch1) ratio"); lux = 0.0f; diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index 5351d6a2cb..33abbe847a 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -31,7 +31,7 @@ float MCP3204::read_data(uint8_t pin, bool differential) { this->disable(); uint16_t digital_value = encode_uint16(b0, b1) >> 4; - return float(digital_value) / 4096.000 * this->reference_voltage_; // in V + return float(digital_value) / 4096.000f * this->reference_voltage_; // in V } } // namespace esphome::mcp3204 diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index d7994327b1..238e37aff0 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -75,16 +75,16 @@ void MPL3115A2Component::update() { float altitude = 0, pressure = 0; if (this->altitude_ != nullptr) { int32_t alt = encode_uint32(buffer[0], buffer[1], buffer[2], 0); - altitude = float(alt) / 65536.0; + altitude = float(alt) / 65536.0f; this->altitude_->publish_state(altitude); } else { uint32_t p = encode_uint32(0, buffer[0], buffer[1], buffer[2]); - pressure = float(p) / 6400.0; + pressure = float(p) / 6400.0f; if (this->pressure_ != nullptr) this->pressure_->publish_state(pressure); } int16_t t = encode_uint16(buffer[3], buffer[4]); - float temperature = float(t) / 256.0; + float temperature = float(t) / 256.0f; if (this->temperature_ != nullptr) this->temperature_->publish_state(temperature); diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index 443c983efe..d5ee4c6a9b 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -115,9 +115,9 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo // max_temp root[MQTT_MAX_TEMP] = traits.get_visual_max_temperature(); // target_temp_step - root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1; + root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1f; // current_temp_step - root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1; + root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1f; // temperature units are always coerced to Celsius internally root[MQTT_TEMPERATURE_UNIT] = "C"; diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index a332d342ee..a356d54e2f 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -176,7 +176,7 @@ void Nextion::goto_page(const char *page) { this->add_no_result_to_queue_with_pr void Nextion::goto_page(uint8_t page) { this->add_no_result_to_queue_with_printf_("page", "page %i", page); } void Nextion::set_backlight_brightness(float brightness) { - if (brightness < 0 || brightness > 1.0) { + if (brightness < 0 || brightness > 1.0f) { ESP_LOGD(TAG, "Brightness out of bounds (0-1.0)"); return; } diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index 1988f574db..3672d164c4 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -300,7 +300,7 @@ bool PIDAutotuner::OscillationFrequencyDetector::is_increase_decrease_symmetrica min_interval = std::min(min_interval, interval); } float ratio = min_interval / float(max_interval); - return ratio >= 0.66; + return ratio >= 0.66f; } // ================== OscillationAmplitudeDetector ================== diff --git a/esphome/components/servo/servo.cpp b/esphome/components/servo/servo.cpp index d2028ce9bd..8d5344cf44 100644 --- a/esphome/components/servo/servo.cpp +++ b/esphome/components/servo/servo.cpp @@ -86,7 +86,7 @@ void Servo::write(float value) { void Servo::internal_write(float value) { value = clamp(value, -1.0f, 1.0f); float level; - if (value < 0.0) { + if (value < 0.0f) { level = std::lerp(this->idle_level_, this->min_level_, -value); } else { level = std::lerp(this->idle_level_, this->max_level_, value); diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 7d9cfecfdf..fe994f440d 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -612,7 +612,7 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { } // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < 0.001) { + if (volume < 0.001f) { this->set_mute_state_(true); } else { this->set_mute_state_(false); diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index fd6cf1e2ed..59892936b0 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -215,7 +215,7 @@ void VEML3235Sensor::dump_config() { " Auto-gain upper threshold: %f%%\n" " Auto-gain lower threshold: %f%%\n" " Values below will be used as initial values only", - this->auto_gain_threshold_high_ * 100.0, this->auto_gain_threshold_low_ * 100.0); + this->auto_gain_threshold_high_ * 100.0f, this->auto_gain_threshold_low_ * 100.0f); } ESP_LOGCONFIG(TAG, " Digital gain: %uX\n" diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index 1cf0de14d3..958ac59bde 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -49,7 +49,7 @@ bool XiaomiMHOC401::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; From 2f32c88ae51d29d52dc508543792434d0ddaf2d3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:16:42 -0400 Subject: [PATCH 1865/2214] [wifi] Fix crash when WiFi is enabled late alongside ESP-NOW (#17239) --- .../wifi/wifi_component_esp_idf.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index b395c77141..2ade015a25 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,12 +179,53 @@ void WiFiComponent::wifi_lazy_init_() { // nor re-register the default WiFi handlers. if (s_sta_netif == nullptr) s_sta_netif = esp_netif_create_default_wifi_sta(); + if (s_sta_netif == nullptr) { + // Allocation failed; leave wifi_initialized_ false so a later enable() retries. + ESP_LOGE(TAG, "esp_netif_create_default_wifi_sta failed"); + return; + } #ifdef USE_WIFI_AP if (s_ap_netif == nullptr) s_ap_netif = esp_netif_create_default_wifi_ap(); #endif // USE_WIFI_AP + // The WiFi driver was started (e.g. by ESP-NOW with the wifi component disabled at + // boot) before our STA netif existed. The default WIFI_EVENT_STA_START handler + // therefore ran with no netif and never called esp_wifi_register_if_rxcb() -- the + // only thing that points the driver's RX path at a netif (it sets + // s_wifi_netifs[WIFI_IF_STA]). A bare esp_netif_action_start() would stop the + // immediate crash (#17232) but leaves RX unbound, so the first association + // associates at L2 yet never receives DHCP replies and times out (#17239). Restart + // the driver now that the netif exists so STA_START re-runs the default handler and + // wires RX correctly. ESP-NOW survives the stop/start (its peer state persists). + // This also matches a self-retry: if esp_wifi_set_storage() below failed on a + // previous wifi_lazy_init_() it returned without setting wifi_initialized_, and + // esp_wifi_init() has since run, so esp_wifi_get_mode() now succeeds here too. + wifi_mode_t mode; + if (esp_wifi_get_mode(&mode) == ESP_OK) { + ESP_LOGD(TAG, "WiFi driver already started without STA netif; restarting to bind it"); + esp_err_t err = esp_wifi_stop(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_stop failed: %s", esp_err_to_name(err)); + } + // Re-apply RAM storage; the normal init path does this, but it is skipped on + // the self-retry case above, which would otherwise let the driver persist + // credentials to NVS for the rest of the boot. + err = esp_wifi_set_storage(WIFI_STORAGE_RAM); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); + } + err = esp_wifi_start(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_start failed: %s", esp_err_to_name(err)); + return; + } + s_wifi_started = true; + this->wifi_initialized_ = true; + return; + } + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); if (global_preferences->nvs_handle == 0) { ESP_LOGW(TAG, "starting wifi without nvs"); From 4ebecf514a6ef85f36aa9af99c490dded191ad1d Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 28 Jun 2026 12:41:47 -0700 Subject: [PATCH 1866/2214] [modbus_server] Simplify server response handling (#12376) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/modbus/__init__.py | 1 - esphome/components/modbus/modbus.cpp | 161 +++++++++++++---- esphome/components/modbus/modbus.h | 60 ++++--- .../components/modbus/modbus_definitions.h | 2 +- esphome/components/modbus/modbus_helpers.cpp | 74 ++++---- esphome/components/modbus/modbus_helpers.h | 71 +++++++- .../modbus_server/modbus_server.cpp | 167 ++++++------------ .../components/modbus_server/modbus_server.h | 6 +- .../components/modbus/modbus_helpers_test.cpp | 36 ++++ .../modbus_server/modbus_server_test.cpp | 124 +++++++++++++ 10 files changed, 476 insertions(+), 226 deletions(-) create mode 100644 tests/components/modbus_server/modbus_server_test.cpp diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 492dfcaafe..cf1d409393 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -124,7 +124,6 @@ async def register_modbus_client_device(var, config): async def register_modbus_server_device(var, config): parent = await cg.get_variable(config[CONF_MODBUS_ID]) - cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index c9ba2e837e..5b771c6282 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -258,7 +258,7 @@ bool ModbusServerHub::parse_modbus_client_frame_() { std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len); this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); - this->process_modbus_client_frame_(address, function_code, data, data_len); + this->process_modbus_client_frame_(address, function_code, data); return true; } @@ -321,10 +321,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct } void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) { - for (auto *device : this->devices_) { - if (device->address_ == address) { - ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); - } + if (this->find_device_(address) != nullptr) { + ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); } if (this->expecting_peer_response_ == address) { @@ -338,31 +336,124 @@ void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t funct this->expecting_peer_response_ = 0; } -void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, - uint16_t len) { - bool found = false; - +ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { for (auto *device : this->devices_) { - if (device->address_ == address) { - found = true; - - if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS || - static_cast(function_code) == ModbusFunctionCode::READ_INPUT_REGISTERS) { - device->on_modbus_read_registers(function_code, helpers::get_data(data, 0), - helpers::get_data(data, 2)); - } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - device->on_modbus_write_registers(function_code, std::vector(data, data + len)); - } else { - ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); - device->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - } + if (device->get_address() == address) { + return device; } } + return nullptr; +} - if (!found) { +bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers) { + if ((uint32_t) start_address + number_of_registers > 0x10000u) { + ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, + number_of_registers); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + return false; + } + return true; +} + +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) { + ModbusServerDevice *device = this->find_device_(address); + if (device == nullptr) { this->expecting_peer_response_ = address; ESP_LOGV(TAG, "Request to peer %" PRIu8 " received", address); + return; + } + + ServerResponseStatus status; + uint8_t response_buffer[modbus::MAX_RAW_SIZE]; + const uint8_t *response_data = response_buffer; + uint16_t response_len = 0; + + switch (static_cast(function_code)) { + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: { + // PDU data: start address(2) + quantity(2). + uint16_t start_address = helpers::get_data(data, 0); + uint16_t number_of_registers = helpers::get_data(data, 2); + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + return; + } + RegisterValues registers; + if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS) { + status = device->on_modbus_read_holding_registers(start_address, number_of_registers, registers); + } else { + status = device->on_modbus_read_input_registers(start_address, number_of_registers, registers); + } + + // A handler that returns an exception leaves registers partially filled, so check the exception + // first and forward it before validating the register count on the success path. + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + return; + } + + if (registers.size() != number_of_registers) { + ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); + this->send_exception_(address, function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + return; + } + + response_buffer[response_len++] = static_cast(number_of_registers * 2); // actual byte count + for (auto r : registers) { + auto register_bytes = decode_value(r); + response_buffer[response_len++] = register_bytes[0]; + response_buffer[response_len++] = register_bytes[1]; + } + break; + } + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: { + // PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values. + // A single-register write always targets one register; for a multiple-register write the + // quantity is in the frame and its byte count must equal quantity * 2. The register values are + // assembled into registers below so the handler doesn't have to know the request framing. + uint16_t start_address = helpers::get_data(data, 0); + uint16_t number_of_registers = 1; + uint16_t values_offset = 2; // single write: values follow the 2-byte start address + if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + number_of_registers = helpers::get_data(data, 2); + uint8_t number_of_bytes = helpers::get_data(data, 4); + values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1) + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE || + number_of_registers * 2 != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, + number_of_bytes); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + return; + } + } + // Assemble the register values (host byte order) so the handler never sees wire framing. + RegisterValues registers; + for (uint16_t i = 0; i < number_of_registers; i++) { + registers.push_back(helpers::get_data(data, values_offset + i * 2)); + } + status = device->on_modbus_write_registers(start_address, registers); + response_data = data; // echo the request header per Modbus 6.6, 6.12 + response_len = 4; + break; + } + default: + ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + return; + } + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + } else { + this->send_response_(address, function_code, response_data, response_len); } } @@ -455,17 +546,27 @@ float Modbus::get_setup_priority() const { return setup_priority::BUS - 1.0f; } -void ModbusServerHub::send(uint8_t address, uint8_t function_code, const std::vector &payload) { - const uint16_t len = static_cast(2 + payload.size()); - if (len > MAX_RAW_SIZE) { - ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); +void ModbusServerHub::send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, + uint16_t payload_len) { + // Build the raw frame (address + function code + payload) in a stack buffer; it's consumed + // immediately by send_raw_ and a full raw frame never exceeds MAX_RAW_SIZE. + if (payload_len + 2 > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server response too large (%" PRIu16 " bytes)", static_cast(payload_len + 2)); return; } uint8_t raw_frame[MAX_RAW_SIZE]; raw_frame[0] = address; raw_frame[1] = function_code; - std::memcpy(raw_frame + 2, payload.data(), payload.size()); - this->send_raw_(raw_frame, len); + std::memcpy(raw_frame + 2, payload, payload_len); + this->send_raw_(raw_frame, payload_len + 2); +} + +void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code) { + uint8_t raw_frame[3]; + raw_frame[0] = address; + raw_frame[1] = function_code | FUNCTION_CODE_EXCEPTION_MASK; + raw_frame[2] = static_cast(exception_code); + this->send_raw_(raw_frame, 3); } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index da0db13a07..95b7a770b6 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -130,22 +130,22 @@ class ModbusServerHub : public Modbus { public: ModbusServerHub() = default; void dump_config() override; - void send(uint8_t address, uint8_t function_code, const std::vector &payload); - ESPDEPRECATED("Use ModbusServerDevice::send_raw instead. Removed in 2026.10.0", "2026.4.0") - void send_raw(const std::vector &payload) { - this->send_raw_(payload.data(), static_cast(payload.size())); - }; void register_device(ModbusServerDevice *device) { this->devices_.push_back(device); } protected: - friend class ModbusServerDevice; - void parse_modbus_frames() override; bool parse_modbus_client_frame_(); // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; - void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len); + void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data); + ModbusServerDevice *find_device_(uint8_t address); + // Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space. + // On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client. + bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers); void send_raw_(const uint8_t *payload, uint16_t len); + void send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code); + void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); uint8_t expecting_peer_response_{0}; std::vector devices_; @@ -200,35 +200,41 @@ class ModbusClientDevice { // This is for compatibility with external components using the former class name using ModbusDevice = ModbusClientDevice; +// Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return. +using ServerResponseStatus = std::optional; +// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol +// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by +// the capacity of this type. +using RegisterValues = StaticVector; + class ModbusServerDevice { public: - ModbusServerDevice() = default; - ModbusServerDevice(ModbusServerHub *parent, uint8_t address) : parent_(parent), address_(address) {} virtual ~ModbusServerDevice() = default; + ModbusServerDevice() = default; + // Polymorphic base: non-copyable and non-movable to prevent slicing (Rule of Five). ModbusServerDevice(const ModbusServerDevice &) = delete; ModbusServerDevice &operator=(const ModbusServerDevice &) = delete; ModbusServerDevice(ModbusServerDevice &&) = delete; ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; - void set_parent(ModbusServerHub *parent) { this->parent_ = parent; } void set_address(uint8_t address) { this->address_ = address; } - virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; - virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; - void send(uint8_t function, const std::vector &payload) { - this->parent_->send(this->address_, function, payload); - } - void send_raw(const std::vector &payload) { - this->parent_->send_raw_(payload.data(), static_cast(payload.size())); - } - void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { - uint8_t error_response[3] = {this->address_, uint8_t(function_code | FUNCTION_CODE_EXCEPTION_MASK), - static_cast(exception_code)}; - this->parent_->send_raw_(error_response, 3); - } + uint8_t get_address() const { return this->address_; } + virtual ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return ModbusExceptionCode::ILLEGAL_FUNCTION; + }; + virtual ServerResponseStatus on_modbus_read_input_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_modbus_read_registers(start_address, number_of_registers, registers); + }; + virtual ServerResponseStatus on_modbus_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_modbus_read_registers(start_address, number_of_registers, registers); + }; + virtual ServerResponseStatus on_modbus_write_registers(uint16_t start_address, const RegisterValues ®isters) { + return ModbusExceptionCode::ILLEGAL_FUNCTION; + }; protected: - friend ModbusServerHub; - - ModbusServerHub *parent_{nullptr}; uint8_t address_{0}; }; diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 49172b9dca..1c03498f1d 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -82,7 +82,7 @@ static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D // Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) static constexpr uint16_t MIN_FRAME_SIZE = 4; static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253 -static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 255 - CRC(2) = 254 +static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 256 - CRC(2) = 254 static constexpr uint16_t MAX_FRAME_SIZE = 256; /// End of Modbus definitions } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 4cddfca104..53fa6afacb 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -101,53 +101,19 @@ static size_t required_payload_size(SensorValueType sensor_value_type) { } } -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - switch (value_type) { - case SensorValueType::U_WORD: - case SensorValueType::S_WORD: - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD: - case SensorValueType::S_DWORD: - case SensorValueType::FP32: - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD_R: - case SensorValueType::S_DWORD_R: - case SensorValueType::FP32_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - break; - case SensorValueType::U_QWORD: - case SensorValueType::S_QWORD: - data.push_back((value & 0xFFFF000000000000) >> 48); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_QWORD_R: - case SensorValueType::S_QWORD_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF000000000000) >> 48); - break; - default: - ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); - break; - } +void log_unsupported_value_type(SensorValueType value_type) { + ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); } -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, +int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, uint32_t bitmask, bool *error_return) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits // Validate offset against the buffer for all types, including RAW/unsupported, so // a malformed or misconfigured frame still produces an error log. - if (static_cast(offset) > data.size()) { + if (static_cast(offset) > size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), - static_cast(offset), data.size()); + static_cast(offset), size); if (error_return) *error_return = true; return value; @@ -158,10 +124,9 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens return value; } - if (data.size() - offset < required_size) { + if (size - offset < required_size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", - static_cast(sensor_value_type), static_cast(offset), data.size(), - required_size); + static_cast(sensor_value_type), static_cast(offset), size, required_size); if (error_return) *error_return = true; return value; @@ -214,6 +179,31 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens return value; } +int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, + bool *error_return) { + const size_t required_size = required_payload_size(sensor_value_type); + if (required_size == 0) { + return 0; // RAW/unsupported: nothing to read + } + const size_t required_words = required_size / 2; + if (required_words > count) { + ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu", + static_cast(sensor_value_type), count, required_words); + if (error_return) + *error_return = true; + return 0; + } + // Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the + // sign-extension behaviour stays identical to the wire path. + uint8_t bytes[8]; // at most 4 registers (QWORD) + for (size_t i = 0; i < required_words; i++) { + uint16_t reg = registers[i]; + bytes[i * 2] = static_cast(reg >> 8); + bytes[i * 2 + 1] = static_cast(reg & 0xFF); + } + return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF, error_return); +} + StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, const uint8_t *values, size_t values_len) { diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index b637d872cf..b7b9020945 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -224,24 +224,77 @@ template N mask_and_shift_by_rightbit(N data, uint32_t mask) { return 0; } -/** Convert float value to vector suitable for sending - * @param data target for payload - * @param value float value to convert - * @param value_type defines if 16/32 or FP32 is used - * @return vector containing the modbus register words in correct order - */ -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type); +// Logs an error for an unsupported value type. Defined in the .cpp so logging stays out of headers. +void log_unsupported_value_type(SensorValueType value_type); -/** Convert vector response payload to number. +/** Append the Modbus register words for value to data. + * Works with any container exposing push_back(uint16_t) (e.g. std::vector or StaticVector). + */ +template void number_to_payload(Container &data, int64_t value, SensorValueType value_type) { + switch (value_type) { + case SensorValueType::U_WORD: + case SensorValueType::S_WORD: + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD: + case SensorValueType::S_DWORD: + case SensorValueType::FP32: + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD_R: + case SensorValueType::S_DWORD_R: + case SensorValueType::FP32_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + break; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + data.push_back((value & 0xFFFF000000000000) >> 48); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF000000000000) >> 48); + break; + default: + log_unsupported_value_type(value_type); + break; + } +} + +/** Convert a raw response payload to a number. * @param data payload with the data to convert + * @param size number of bytes available in data * @param sensor_value_type defines if 16/32/64 bits or FP32 is used * @param offset offset to the data in data * @param bitmask bitmask used for masking and shifting * @return 64-bit number of the payload */ -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, +int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, uint32_t bitmask, bool *error_return = nullptr); +/** Convert vector response payload to number. */ +inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, + uint32_t bitmask, bool *error_return = nullptr) { + return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask, error_return); +} + +/** Reconstruct a number from register words (host byte order). Inverse of number_to_payload. + * Decodes the value at the start of the given span; advance the pointer to read successive values. + * @param registers register values in host byte order + * @param count number of registers available in registers + * @param sensor_value_type defines if 16/32/64 bits or FP32 is used + * @return 64-bit number of the registers + */ +int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, + bool *error_return = nullptr); + /** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. * @param function_code the modbus function code to use. One of: * READ_COILS diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index c294d08888..bb264eb993 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -3,26 +3,18 @@ #include "esphome/core/log.h" namespace esphome::modbus_server { -using modbus::ModbusFunctionCode; using modbus::ModbusExceptionCode; -using modbus::helpers::payload_to_number; +using modbus::helpers::registers_to_number; static const char *const TAG = "modbus_server"; -void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers) { +modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t start_address, + uint16_t number_of_registers, + modbus::RegisterValues ®isters) { ESP_LOGV(TAG, - "Received read holding/input registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); + "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", + this->address_, start_address, number_of_registers); - if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - - std::vector sixteen_bit_response; for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { bool found = false; for (auto *server_register : this->server_registers_) { @@ -36,10 +28,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star server_register->address, static_cast(server_register->value_type), server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); - std::vector payload; - payload.reserve(server_register->register_count * 2); - modbus::helpers::number_to_payload(payload, value, server_register->value_type); - sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); + modbus::helpers::number_to_payload(registers, value, server_register->value_type); current_address += server_register->register_count; found = true; break; @@ -53,92 +42,37 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star "Could not match any register to address 0x%02X, but default allowed. " "Returning default value: %" PRIu16 ".", current_address, this->server_courtesy_response_.register_value); - sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); + registers.push_back(this->server_courtesy_response_.register_value); current_address += 1; // Just increment by 1, as the default response is a single register } else { ESP_LOGW(TAG, "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", current_address); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; } } } - std::vector response; - if (number_of_registers != sixteen_bit_response.size()) - ESP_LOGW(TAG, "Response size not matched to request register count."); - response.push_back(sixteen_bit_response.size() * 2); // actual byte count - for (auto v : sixteen_bit_response) { - auto decoded_value = decode_value(v); - response.push_back(decoded_value[0]); - response.push_back(decoded_value[1]); - } - this->send(function_code, response); + return {}; } -void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { - uint16_t number_of_registers; - uint16_t payload_offset; +modbus::ServerResponseStatus ModbusServer::on_modbus_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { + // registers holds the values to write in host byte order; its size is the register count. + ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", + this->address_, start_address, registers.size()); - if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - if (data.size() < 5) { - ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); - if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - uint16_t payload_size = data[4]; - if (payload_size != number_of_registers * 2) { - ESP_LOGW(TAG, - "Payload size of %" PRIu16 " bytes is not 2 times the number of registers (%" PRIu16 - "). Sending exception response.", - payload_size, number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - if (data.size() < 5 + payload_size) { - ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(), - 5 + payload_size); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - payload_offset = 5; - } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - if (data.size() < 4) { - ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - number_of_registers = 1; - payload_offset = 2; - } else { - ESP_LOGW(TAG, "Invalid function code 0x%X. Sending exception response.", function_code); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - return; - } - - uint16_t start_address = uint16_t(data[1]) | (uint16_t(data[0]) << 8); - ESP_LOGD(TAG, - "Received write holding registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); - - auto for_each_register = [this, start_address, number_of_registers, payload_offset]( - const std::function &callback) -> bool { - uint16_t offset = payload_offset; - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { + auto for_each_register = + [this, start_address, + ®isters](const std::function &callback) -> bool { + uint16_t register_offset = 0; + for (uint32_t current_address = start_address; current_address < start_address + registers.size();) { bool ok = false; for (auto *server_register : this->server_registers_) { if (server_register->address == current_address) { - ok = callback(server_register, offset); + ok = callback(server_register, register_offset); current_address += server_register->register_count; - offset += server_register->register_count * sizeof(uint16_t); + register_offset += server_register->register_count; break; } } @@ -150,36 +84,41 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v return true; }; - // check all registers are writable before writing to any of them: - if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { - return server_register->write_lambda != nullptr; - })) { - ESP_LOGW(TAG, "Invalid register address. Sending exception response."); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - - // Actually write to the registers: - if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - bool error = false; - int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF, &error); - if (error) { - return false; - } else { - return server_register->write_lambda(number); + // Pre-flight: every targeted register must be writable AND have its full value present in the request, + // so we never apply a partial write before discovering a problem. The commit pass below re-runs + // registers_to_number rather than caching the decoded values: using the same function for the check and + // the write keeps a single source of truth for the decode bound, independent of how register_count was set. + ModbusExceptionCode precheck = ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register + if (!for_each_register([&precheck, ®isters](ServerRegister *server_register, uint16_t register_offset) -> bool { + if (server_register->write_lambda == nullptr) { + return false; // unwritable -> ILLEGAL_DATA_ADDRESS } + bool error = false; + registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type, &error); + if (error) { + precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value + return false; + } + return true; })) { - ESP_LOGW(TAG, "Could not write all registers. Sending exception response."); - this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); - return; + ESP_LOGW(TAG, "Write request rejected before applying any register. Sending exception response."); + return precheck; } - std::vector response; - response.reserve(6); - response.push_back(this->address_); - response.push_back(function_code); - response.insert(response.end(), data.begin(), data.begin() + 4); - this->send_raw(response); + // Commit: every value is known writable and decodable, so the only failure now is a user write callback + // rejecting the value at runtime -- which cannot be rolled back. + if (!for_each_register([®isters](ServerRegister *server_register, uint16_t register_offset) { + int64_t number = registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type); + return server_register->write_lambda(number); + })) { + ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied."); + return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; + } + + // Success: the caller builds the write response (an echo of the request header). + return {}; } void ModbusServer::dump_config() { diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index fa1376542c..0c22454528 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -98,9 +98,11 @@ class ModbusServer : public Component, public modbus::ModbusServerDevice { /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors - void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers) final; + modbus::ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors - void on_modbus_write_registers(uint8_t function_code, const std::vector &data) final; + modbus::ServerResponseStatus on_modbus_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) final; /// Called by esphome generated code to set the server courtesy response object void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { this->server_courtesy_response_ = server_courtesy_response; diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index cd260f410a..ecdca4df6d 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -194,4 +194,40 @@ TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); } +// --- registers_to_number --------------------------------------------------- +// Register words are host byte order; results must match the byte-based payload_to_number. + +TEST(ModbusHelpersTest, RegistersToNumberDecodesWord) { + const uint16_t registers[] = {0x1234}; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_WORD), 0x1234); +} + +TEST(ModbusHelpersTest, RegistersToNumberDecodesDwordHighWordFirst) { + const uint16_t registers[] = {0x1234, 0x5678}; + EXPECT_EQ(registers_to_number(registers, 2, SensorValueType::U_DWORD), 0x12345678); +} + +TEST(ModbusHelpersTest, RegistersToNumberDecodesAtSpanStart) { + // The function decodes the value at the start of the span; the caller advances the pointer. + const uint16_t registers[] = {0xAAAA, 0x1234}; + EXPECT_EQ(registers_to_number(registers + 1, 1, SensorValueType::U_WORD), 0x1234); +} + +TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) { + // Same value via both decoders: registers (host order) vs big-endian bytes. + const uint16_t registers[] = {0x8001, 0x0002}; + const std::vector bytes{0x80, 0x01, 0x00, 0x02}; + for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) { + EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF)) + << "value_type=" << static_cast(value_type); + } +} + +TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { + const uint16_t registers[] = {0x1234}; + bool error = false; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0); + EXPECT_TRUE(error); +} + } // namespace esphome::modbus::helpers diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp new file mode 100644 index 0000000000..0c8f5d04cf --- /dev/null +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -0,0 +1,124 @@ +#include + +#include "esphome/components/modbus_server/modbus_server.h" + +namespace esphome::modbus_server { + +using modbus::ModbusExceptionCode; +using modbus::RegisterValues; + +namespace { + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +} // namespace + +// A single writable WORD register is applied and the handler reports success (nullopt). +TEST(ModbusServerWrite, SingleWordSucceeds) { + ModbusServer server; + int64_t written = -1; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.write_lambda = [&written](int64_t value) { + written = value; + return true; + }; + server.add_server_register(®); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + EXPECT_FALSE(status.has_value()); // nullopt == success + EXPECT_EQ(written, 0x1234); +} + +// A multi-register value is decoded high word first and applied as a single number. +TEST(ModbusServerWrite, DwordSucceeds) { + ModbusServer server; + int64_t written = -1; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.write_lambda = [&written](int64_t value) { + written = value; + return true; + }; + server.add_server_register(®); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234, 0x5678})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(written, 0x12345678); +} + +// Regression: a request that under-supplies a multi-register value is rejected before any +// write_lambda runs, so no register is partially written. +TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) { + ModbusServer server; + bool word_written = false; + ServerRegister word_reg(0x0000, SensorValueType::U_WORD, 1); + word_reg.write_lambda = [&word_written](int64_t) { + word_written = true; + return true; + }; + bool dword_written = false; + ServerRegister dword_reg(0x0001, SensorValueType::U_DWORD, 2); // needs two registers + dword_reg.write_lambda = [&dword_written](int64_t) { + dword_written = true; + return true; + }; + server.add_server_register(&word_reg); + server.add_server_register(&dword_reg); + + // Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs. + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1111, 0x2222})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE); + EXPECT_FALSE(word_written); // the writable WORD must NOT have been applied + EXPECT_FALSE(dword_written); +} + +// A read-only register (no write_lambda) yields ILLEGAL_DATA_ADDRESS and applies nothing. +TEST(ModbusServerWrite, UnwritableRegisterRejected) { + ModbusServer server; + ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set + server.add_server_register(&read_only); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// An address with no registered register yields ILLEGAL_DATA_ADDRESS. +TEST(ModbusServerWrite, UnmatchedAddressRejected) { + ModbusServer server; + auto status = server.on_modbus_write_registers(0x0005, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already +// applied, and the handler reports SERVICE_DEVICE_FAILURE. +TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { + ModbusServer server; + bool first_written = false; + ServerRegister first(0x0000, SensorValueType::U_WORD, 1); + first.write_lambda = [&first_written](int64_t) { + first_written = true; + return true; + }; + ServerRegister second(0x0001, SensorValueType::U_WORD, 1); + second.write_lambda = [](int64_t) { return false; }; // rejects at runtime + server.add_server_register(&first); + server.add_server_register(&second); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure +} + +} // namespace esphome::modbus_server From b62f7a41c92bca055ace95fce303db06b1dbf64c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:15:38 -0400 Subject: [PATCH 1867/2214] [multiple] Single-precision float math, avoid double promotion (stragglers) (#17260) --- esphome/components/ac_dimmer/ac_dimmer.cpp | 2 +- esphome/components/display/display.cpp | 14 +++++++------- esphome/components/hmc5883l/hmc5883l.cpp | 4 +++- esphome/components/mmc5603/mmc5603.cpp | 4 +++- esphome/components/pid/pid_autotuner.cpp | 7 ++----- esphome/components/qmc5883l/qmc5883l.cpp | 3 ++- esphome/components/rd03d/rd03d.cpp | 3 ++- esphome/components/sx126x/sx126x.cpp | 2 +- 8 files changed, 21 insertions(+), 18 deletions(-) diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp index 3e21d6981d..477962a040 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.cpp +++ b/esphome/components/ac_dimmer/ac_dimmer.cpp @@ -216,7 +216,7 @@ void AcDimmer::setup() { } void AcDimmer::write_state(float state) { - state = std::acos(1 - (2 * state)) / std::numbers::pi; // RMS power compensation + state = std::acos(1 - (2 * state)) / std::numbers::pi_v; // RMS power compensation auto new_value = static_cast(roundf(state * 65535)); if (new_value != 0 && this->store_.value == 0) this->store_.init_cycle = this->init_with_half_cycle_; diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index b30f444d6d..115adf503a 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -42,10 +42,10 @@ void Display::line_at_angle(int x, int y, int angle, int length, Color color) { void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_radius, Color color) { // Calculate start and end points - int x1 = (start_radius * cos(angle * M_PI / 180)) + x; - int y1 = (start_radius * sin(angle * M_PI / 180)) + y; - int x2 = (stop_radius * cos(angle * M_PI / 180)) + x; - int y2 = (stop_radius * sin(angle * M_PI / 180)) + y; + int x1 = (start_radius * std::cos(angle * std::numbers::pi_v / 180)) + x; + int y1 = (start_radius * std::sin(angle * std::numbers::pi_v / 180)) + y; + int x2 = (stop_radius * std::cos(angle * std::numbers::pi_v / 180)) + x; + int y2 = (stop_radius * std::sin(angle * std::numbers::pi_v / 180)) + y; // Draw line this->line(x1, y1, x2, y2, color); @@ -444,15 +444,15 @@ void HOT Display::get_regular_polygon_vertex(int vertex_id, int *vertex_x, int * // hence we rotate the shape by 270° to orient the polygon up. rotation_degrees += ROTATION_270_DEGREES; // Convert the rotation to radians, easier to use in trigonometrical calculations - float rotation_radians = rotation_degrees * std::numbers::pi / 180; + float rotation_radians = rotation_degrees * std::numbers::pi_v / 180; // A pointy top variation means the first vertex of the polygon is at the top center of the shape, this requires no // additional rotation of the shape. // A flat top variation means the first point of the polygon has to be rotated so that the first edge is horizontal, // this requires to rotate the shape by π/edges radians counter-clockwise so that the first point is located on the // left side of the first horizontal edge. - rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi / edges : 0.0; + rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi_v / edges : 0.0f; - float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi + rotation_radians; + float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi_v + rotation_radians; *vertex_x = (int) std::round(std::cos(vertex_angle) * radius) + center_x; *vertex_y = (int) std::round(std::sin(vertex_angle) * radius) + center_y; } diff --git a/esphome/components/hmc5883l/hmc5883l.cpp b/esphome/components/hmc5883l/hmc5883l.cpp index 7930df7a38..c6b7da6610 100644 --- a/esphome/components/hmc5883l/hmc5883l.cpp +++ b/esphome/components/hmc5883l/hmc5883l.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" +#include + namespace esphome::hmc5883l { static const char *const TAG = "hmc5883l"; @@ -126,7 +128,7 @@ void HMC5883LComponent::update() { const float y = int16_t(raw_y) * mg_per_bit * 0.1f; const float z = int16_t(raw_z) * mg_per_bit * 0.1f; - float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f°", x, y, z, heading); if (this->x_sensor_ != nullptr) diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index 79c580c6b7..15e715e675 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -1,6 +1,8 @@ #include "mmc5603.h" #include "esphome/core/log.h" +#include + namespace esphome::mmc5603 { static const char *const TAG = "mmc5603"; @@ -143,7 +145,7 @@ void MMC5603Component::update() { const float z = 0.00625 * (raw_z - 524288); - const float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + const float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f°", x, y, z, heading); if (this->x_sensor_ != nullptr) diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index 3672d164c4..a7ae631956 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -1,10 +1,7 @@ #include "pid_autotuner.h" #include "esphome/core/log.h" #include - -#ifndef M_PI -#define M_PI 3.1415926535897932384626433 -#endif +#include namespace esphome::pid { @@ -126,7 +123,7 @@ PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float proce float osc_ampl = this->amplitude_detector_.get_mean_oscillation_amplitude(); float d = (this->relay_function_.output_positive - this->relay_function_.output_negative) / 2.0f; ESP_LOGVV(TAG, " Relay magnitude: %f", d); - this->ku_ = 4.0f * d / float(M_PI * osc_ampl); + this->ku_ = 4.0f * d / (std::numbers::pi_v * osc_ampl); this->pu_ = this->frequency_detector_.get_mean_oscillation_period(); this->state_ = AUTOTUNE_SUCCEEDED; diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index 5b04a904b5..ba6a71f97d 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" #include +#include namespace esphome::qmc5883l { @@ -173,7 +174,7 @@ void QMC5883LComponent::read_sensor_() { const float y = int16_t(raw[1]) * mg_per_bit * 0.1f; const float z = int16_t(raw[2]) * mg_per_bit * 0.1f; - float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; float temp = NAN; if (this->temperature_sensor_ != nullptr) { diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index c9c6a546ab..2eb76a1087 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace esphome::rd03d { @@ -233,7 +234,7 @@ void RD03DComponent::publish_target_(uint8_t target_num, int16_t x, int16_t y, i // Angle is measured from the Y axis (radar forward direction) if (target.angle != nullptr) { if (valid) { - float angle = std::atan2(static_cast(x), static_cast(y)) * 180.0f / M_PI; + float angle = std::atan2(static_cast(x), static_cast(y)) * 180.0f / std::numbers::pi_v; target.angle->publish_state(angle); } else { target.angle->publish_state(NAN); diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index af42c63bf4..376676ce85 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -215,7 +215,7 @@ void SX126x::configure() { // configure modem if (this->modulation_ == PACKET_TYPE_LORA) { // set modulation params - float duration = 1000.0f * std::pow(2, this->spreading_factor_) / BW_HZ[this->bandwidth_]; + float duration = 1000.0f * (1UL << this->spreading_factor_) / BW_HZ[this->bandwidth_]; buf[0] = this->spreading_factor_; buf[1] = BW_LORA[this->bandwidth_ - SX126X_BW_7810]; buf[2] = this->coding_rate_; From 8434d54cc785808494b1d0834a4611cc6896e83c Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 28 Jun 2026 14:07:25 -0700 Subject: [PATCH 1868/2214] [modbus] Reinstate turnaround delay after broadcasts (Revert #17209) (#17263) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/modbus/modbus.cpp | 16 ++---- esphome/components/modbus/modbus.h | 1 - .../fixtures/uart_mock_modbus_broadcast.yaml | 56 ------------------- tests/integration/test_uart_mock_modbus.py | 25 --------- 4 files changed, 5 insertions(+), 93 deletions(-) delete mode 100644 tests/integration/fixtures/uart_mock_modbus_broadcast.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5b771c6282..488bcf1459 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -92,14 +92,10 @@ int32_t Modbus::tx_delay_remaining() { int32_t ModbusClientHub::tx_delay_remaining() { const uint32_t now = millis(); - // Turnaround delay only applies after a broadcast: no response is expected, so we must give listening devices - // quiet time to process it before the next request. For normal unicast request/response the received reply already - // provides the inter-frame timing, so adding turnaround there just throttles throughput. - const uint16_t turnaround = this->last_send_was_broadcast_ ? this->turnaround_delay_ms_ : 0; - return std::max( - {(int32_t) 0, - (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + turnaround - (now - this->last_send_)), - (int32_t) (this->frame_delay_ms_ + turnaround - (now - this->last_modbus_byte_))}); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - + (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); } bool Modbus::tx_blocked() { @@ -491,7 +487,6 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; - this->last_send_was_broadcast_ = frame.size > 0 && frame.data[0] == 0; return true; } @@ -507,8 +502,7 @@ void ModbusClientHub::send_next_frame_() { ModbusDeviceCommand &command = this->tx_buffer_.front(); if (this->send_frame_(command.frame)) { - if (!this->last_send_was_broadcast_) - this->waiting_for_response_ = std::move(command); + this->waiting_for_response_ = std::move(command); } else { if (command.device) command.device->on_modbus_not_sent(); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 95b7a770b6..4aa3a16c3a 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -63,7 +63,6 @@ class Modbus : public uart::UARTDevice, public Component { uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; - bool last_send_was_broadcast_{false}; uint16_t frame_delay_ms_{5}; uint16_t long_rx_buffer_delay_ms_{0}; diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml deleted file mode 100644 index a5ce02b342..0000000000 --- a/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml +++ /dev/null @@ -1,56 +0,0 @@ -esphome: - name: uart-mock-modbus-bcast - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -# No on_tx injection: a broadcast (address 0) gets no reply on a real bus. -uart_mock: - - id: virtual_uart - baud_rate: 9600 - auto_start: true - debug: - -modbus: - - uart_id: virtual_uart - id: virtual_modbus - role: client - send_wait_time: 200ms - turnaround_time: 10ms - -modbus_controller: - - address: 0 - modbus_id: virtual_modbus - update_interval: 60s - id: modbus_controller_bcast - -number: - - platform: modbus_controller - modbus_controller_id: modbus_controller_bcast - id: bcast_write - name: "bcast_write" - address: 0x01 - register_type: holding - value_type: U_WORD - min_value: 0 - max_value: 65535 - -interval: - - interval: 400ms - then: - - number.set: - id: bcast_write - value: 42 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 385707d849..2c437341c6 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -330,28 +330,3 @@ async def test_uart_mock_modbus_server_controller_multiple( await tracker.setup_and_start_scenario(client) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) - - -@pytest.mark.asyncio -async def test_uart_mock_modbus_broadcast( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that broadcast writes (address 0) don't wait for a response. - - A controller at address 0 sends broadcast writes that get no reply. The - client must not arm the response timeout for them: otherwise every write - blocks for send_wait_time and logs a spurious "no response from 0" warning. - """ - - line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - - async with ( - run_compiled(yaml_config, line_callback=line_callback), - api_client_connected(), - ): - # Several broadcast writes fire on the 400ms interval; send_wait_time is - # 200ms, so the old behaviour would have warned on each one by now. - await asyncio.sleep(3.0) - _assert_no_modbus_errors(error_log_lines, warning_log_lines) From a336ad6732ef0ddf5f76abacc0b52e2b8566102c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Jun 2026 14:07:51 -0700 Subject: [PATCH 1869/2214] [mcp4725] Use constexpr bit shift instead of powf for full-scale value (#17261) --- esphome/components/mcp4725/mcp4725.cpp | 3 ++- esphome/components/mcp4725/mcp4725.h | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index 21aff90fae..8e94623de3 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -24,7 +24,8 @@ void MCP4725::dump_config() { // https://learn.sparkfun.com/tutorials/mcp4725-digital-to-analog-converter-hookup-guide?_ga=2.176055202.1402343014.1607953301-893095255.1606753886 void MCP4725::write_state(float state) { - const uint16_t value = (uint16_t) roundf(state * (powf(2, MCP4725_RES) - 1)); + constexpr uint16_t max_value = (1U << MCP4725_RES) - 1; + const uint16_t value = (uint16_t) roundf(state * max_value); this->write_byte_16(64, value << 4); } diff --git a/esphome/components/mcp4725/mcp4725.h b/esphome/components/mcp4725/mcp4725.h index 4f1f128e52..a0838dc33e 100644 --- a/esphome/components/mcp4725/mcp4725.h +++ b/esphome/components/mcp4725/mcp4725.h @@ -4,10 +4,11 @@ #include "esphome/core/component.h" #include "esphome/components/i2c/i2c.h" -static const uint8_t MCP4725_ADDR = 0x60; -static const uint8_t MCP4725_RES = 12; - namespace esphome::mcp4725 { + +static constexpr uint8_t MCP4725_ADDR = 0x60; +static constexpr uint8_t MCP4725_RES = 12; + class MCP4725 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void setup() override; From 5f311d281e78ef38b621eddfc41ab1459754afe1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 29 Jun 2026 07:17:01 +1000 Subject: [PATCH 1870/2214] [esphome] Warn when a YAML merge (`<<:`) drops a key (#17246) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/config.py | 19 +++++++ esphome/const.py | 1 + esphome/core/config.py | 2 + esphome/yaml_util.py | 27 +++++++++ script/ci-custom.py | 2 +- tests/unit_tests/test_config_normalization.py | 55 +++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 45 +++++++++++++++ 7 files changed, 150 insertions(+), 1 deletion(-) diff --git a/esphome/config.py b/esphome/config.py index 33e687137f..fc8f46909f 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -20,6 +20,7 @@ from esphome.const import ( CONF_ESPHOME, CONF_EXTERNAL_COMPONENTS, CONF_ID, + CONF_MERGE_WARNINGS, CONF_MIN_VERSION, CONF_PACKAGES, CONF_PLATFORM, @@ -1184,6 +1185,24 @@ def validate_config( ) return result + # Warn about any keys silently dropped by `<<` merge includes (shallow, + # first-wins). The esphome: section is now known, so we can honor its + # `merge_warnings:` opt-out. Always drain the queue to keep it from leaking + # into a later run. + if (dropped := yaml_util.take_dropped_merge_keys()) and ( + not isinstance(esphome_conf := config[CONF_ESPHOME], dict) + or esphome_conf.get(CONF_MERGE_WARNINGS, True) + ): + for key, location in dict.fromkeys(dropped): + _LOGGER.warning( + "Key '%s' (%s) was dropped while processing a '<<' merge because it " + "is already defined. Merge keys don't combine sections - the first " + "definition wins. Use 'packages:' to merge sections, or set " + "'esphome: { merge_warnings: false }' to silence this.", + key, + location, + ) + # Snapshot the user's config before any schema validation defaults are # applied. preload_core_config and later validation steps rewrite entries # in-place with defaulted values; deep-copying here preserves the diff --git a/esphome/const.py b/esphome/const.py index 3ca7b2e618..5fa6f00b59 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -613,6 +613,7 @@ CONF_MEASUREMENT_SEQUENCE_NUMBER = "measurement_sequence_number" CONF_MEDIA_PLAYER = "media_player" CONF_MEDIUM = "medium" CONF_MEMORY_BLOCKS = "memory_blocks" +CONF_MERGE_WARNINGS = "merge_warnings" CONF_MESSAGE = "message" CONF_METHANE = "methane" CONF_METHOD = "method" diff --git a/esphome/core/config.py b/esphome/core/config.py index 59c96035b8..0670fde0ff 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -26,6 +26,7 @@ from esphome.const import ( CONF_INCLUDES, CONF_INCLUDES_C, CONF_LIBRARIES, + CONF_MERGE_WARNINGS, CONF_MIN_VERSION, CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, @@ -316,6 +317,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_INCLUDES_C, default=[]): cv.ensure_list(valid_include), cv.Optional(CONF_LIBRARIES, default=[]): cv.ensure_list(cv.string_strict), cv.Optional(CONF_NAME_ADD_MAC_SUFFIX, default=False): cv.boolean, + cv.Optional(CONF_MERGE_WARNINGS, default=True): cv.boolean, cv.Optional(CONF_DEBUG_SCHEDULER, default=False): cv.boolean, cv.Optional(CONF_PROJECT): cv.Schema( { diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index bfe1fb0136..0009cde551 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -51,6 +51,29 @@ _load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] +# Key under CORE.data used to accumulate keys that a `<<` merge silently +# dropped. The warning is emitted later (see esphome.config.validate_config), +# because the esphome: option that suppresses it isn't known while parsing. +_MERGE_WARNINGS_KEY = "yaml_dropped_merge_keys" + + +def _record_dropped_merge_key(parent_file: Path, key: Any) -> None: + """Record a mapping key that a ``<<`` merge silently dropped. + + Merge keys follow the YAML spec's shallow, first-wins semantics: a key that + already exists in the mapping (or came from an earlier merge) is discarded + rather than deep-merged the way ``packages:`` would combine it. We collect + these so a single warning can be emitted once the config is loaded. + """ + esp_range = getattr(key, "esp_range", None) + location = str(esp_range.start_mark) if esp_range is not None else str(parent_file) + CORE.data.setdefault(_MERGE_WARNINGS_KEY, []).append((str(key), location)) + + +def take_dropped_merge_keys() -> list[tuple[str, str]]: + """Return and clear the keys dropped during ``<<`` merges so far.""" + return CORE.data.pop(_MERGE_WARNINGS_KEY, []) + class SensitiveStr(str): """Marker subclass for validated strings that should be masked in @@ -551,6 +574,10 @@ class ESPHomeLoaderMixin: # is expected to contain mapping nodes and each of these nodes is merged in # turn according to its order in the sequence. Keys in mapping nodes earlier # in the sequence override keys specified in later mapping nodes." + # + # This is a silent shallow drop (unlike `packages:`, which deep-merges). + # Record it so a warning can be emitted after the config loads. + _record_dropped_merge_key(self.name, key) continue pairs.append((key, value)) # Add key node to seen keys, for sequence merge values. diff --git a/script/ci-custom.py b/script/ci-custom.py index 6c5ad5bb69..4568732b88 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -555,7 +555,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1013 +CONST_PY_MAX_CONF = 1014 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index 4ec17b3c7c..a06b2da621 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -1,6 +1,7 @@ """Unit tests for esphome.config module.""" from collections.abc import Generator +import logging from pathlib import Path from unittest.mock import MagicMock, Mock, patch @@ -113,3 +114,57 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: platforms = {p.get("platform") for p in result["ota"]} assert "esphome" in platforms, f"Expected esphome platform in {platforms}" assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: + """Create a config where two `<<` includes both define `logger:`. + + The second `logger:` is dropped by the shallow merge. Returns the main file. + """ + (tmp_path / "a.yaml").write_text("logger:\n level: DEBUG\n") + (tmp_path / "b.yaml").write_text("logger:\n level: INFO\n") + esphome_section = "esphome:\n name: test\n" + if suppress: + esphome_section += " merge_warnings: false\n" + main = tmp_path / "main.yaml" + main.write_text(f"{esphome_section}<<: !include a.yaml\n<<: !include b.yaml\n") + return main + + +def test_validate_config_warns_on_dropped_merge_key( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """By default, a `<<` merge that drops a key logs a warning.""" + main = _write_merge_conflict_config(tmp_path, suppress=False) + CORE.config_path = main + raw_config = yaml_util.load_yaml(main) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + config.validate_config(raw_config, {}) + + assert any( + "was dropped while processing a '<<' merge" in record.message + and "logger" in record.message + for record in caplog.records + ) + # The queue is drained so the warning cannot leak into a later run. + assert yaml_util.take_dropped_merge_keys() == [] + + +def test_validate_config_suppresses_merge_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """`esphome: merge_warnings: false` hides the warning but still drains the queue.""" + main = _write_merge_conflict_config(tmp_path, suppress=True) + CORE.config_path = main + raw_config = yaml_util.load_yaml(main) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + config.validate_config(raw_config, {}) + + assert not any( + "was dropped while processing a '<<' merge" in record.message + for record in caplog.records + ) + # The queue is drained even when the warning is suppressed. + assert yaml_util.take_dropped_merge_keys() == [] diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 6be090b869..fa1c0fcce2 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1395,3 +1395,48 @@ def test_dump__redaction_flag_does_not_leak_between_calls() -> None: assert "\\033[8m" in redacted assert "\\033[8m" not in raw assert "\\033[8m" in redacted_again + + +@pytest.fixture(autouse=True) +def clear_dropped_merge_keys() -> None: + """Reset the dropped-merge-key queue between tests.""" + core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None) + yield + core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None) + + +def test_merge_include_records_dropped_keys(tmp_path: Path) -> None: + """A `<<` merge that overlaps an existing key records it (shallow first-wins).""" + (tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n") + (tmp_path / "b.yaml").write_text("api:\n password: secret\n") + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n") + + with patch.object(core.CORE, "config_path", test_yaml): + result = yaml_util.load_yaml(test_yaml) + + # First definition wins; the second `api` block is dropped entirely. + assert result["api"] == {"reboot_timeout": "5min"} + + dropped = yaml_util.take_dropped_merge_keys() + assert len(dropped) == 1 + key, location = dropped[0] + assert key == "api" + assert "b.yaml" in location + # Queue is drained after being taken. + assert yaml_util.take_dropped_merge_keys() == [] + + +def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: + """A `<<` merge with distinct top-level keys drops nothing.""" + (tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n") + (tmp_path / "b.yaml").write_text("logger:\n level: DEBUG\n") + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n") + + with patch.object(core.CORE, "config_path", test_yaml): + result = yaml_util.load_yaml(test_yaml) + + assert result["api"] == {"reboot_timeout": "5min"} + assert result["logger"] == {"level": "DEBUG"} + assert yaml_util.take_dropped_merge_keys() == [] From 9e8261056cae22002ab84f245faef71540bae01e Mon Sep 17 00:00:00 2001 From: Tom <7723105+thomasfw@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:06:12 +0100 Subject: [PATCH 1871/2214] [espnow] Fix espnow crash when send() is called without a callback (#17266) --- esphome/components/espnow/espnow_component.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index f89b4a2ff1..2756b615a1 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -303,7 +303,9 @@ void ESPNowComponent::loop() { ESP_LOGV(TAG, ">>> [%s] %s", addr_buf, LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status))); #endif if (this->current_send_packet_ != nullptr) { - this->current_send_packet_->callback_(packet->packet_.sent.status); + if (this->current_send_packet_->callback_ != nullptr) { + this->current_send_packet_->callback_(packet->packet_.sent.status); + } this->send_packet_pool_.release(this->current_send_packet_); this->current_send_packet_ = nullptr; // Reset current packet after sending } From d8ffb732b7217685fee617adabe23dd5518a7507 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:04:22 -0500 Subject: [PATCH 1872/2214] Bump zeroconf from 0.149.16 to 0.150.0 (#17137) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a825cd9bff..2510ca61e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.3.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.3.1 -zeroconf==0.149.16 +zeroconf==0.150.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From bf0d31b3abdda74f79761a7af35c6d6a4a7e27a9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:41:21 -0400 Subject: [PATCH 1873/2214] [espidf] Don't fail framework check on broken unrelated PATH tools (#17053) --- esphome/espidf/framework.py | 16 +++++++++------- tests/unit_tests/test_espidf_framework.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6f4aeef9f0..4053898a8e 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -609,14 +609,16 @@ def _check_esphome_idf_framework_install( install = True if _check_stamp(env_stamp_file, stamp_info): _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) - cmd = [ - get_system_python_path(), - str(idf_tools_path), - "--non-interactive", - "check", - ] - if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env): + # Validate via the managed tool-path resolution, not ``idf_tools.py check``: + # ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a + # broken Homebrew openocd), which forced a toolchain reinstall on every build. + try: + _get_idf_tool_paths(framework_path, env) install = False + except RuntimeError as err: + _LOGGER.debug( + "ESP-IDF %s tool resolution failed, reinstalling: %s", version, err + ) # 4. Install framework tools if not installed or needs update if install: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index d89b93f478..525cd55146 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -298,6 +298,9 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.archive_extract_all") as extract, patch("esphome.espidf.framework.create_venv") as venv, patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, + patch( + "esphome.espidf.framework._get_idf_tool_paths", return_value=([], {}) + ) as tool_paths, patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), @@ -308,7 +311,12 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): yield SimpleNamespace( - download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone + download=download, + extract=extract, + venv=venv, + run_ok=run_ok, + tool_paths=tool_paths, + clone=clone, ) @@ -403,10 +411,10 @@ def test_check_esp_idf_install_stamp_mismatch_reinstalls( def test_check_esp_idf_install_check_command_failure_reinstalls( espidf_mocks: SimpleNamespace, ) -> None: - """A failing idf_tools check reinstalls tools (marker present, no re-extract).""" + """A failing tool-path resolution reinstalls tools (marker present, no re-extract).""" _mark_installed() - # idf_tools check fails -> install stays True; the later installs succeed. - espidf_mocks.run_ok.side_effect = [False, True, True, True] + # Managed tool resolution fails -> install stays True; the later installs succeed. + espidf_mocks.tool_paths.side_effect = RuntimeError("missing ESP-IDF tool") check_esp_idf_install(_IDF_VERSION, features=["fb"]) espidf_mocks.extract.assert_not_called() From 6d559a32df2fe427355a7c9f5cdb8c1e4e7a047e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:45:22 -0500 Subject: [PATCH 1874/2214] Bump bundled esphome-device-builder to 1.0.14 (#17139) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1d39644ab8..bf37d6d88b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.14 RUN \ platformio settings set enable_telemetry No \ From 8d36167e114eb3bca37d88388eb8dde1b426bde4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:26 -0400 Subject: [PATCH 1875/2214] [esp32_ble_server] Fix set_value action with by-reference triggers (#17156) --- .../esp32_ble_server/ble_server_automations.h | 6 ++-- tests/components/esp32_ble_server/common.yaml | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index b4e9ed004e..b1f887e2fa 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -77,13 +77,15 @@ template class BLECharacteristicSetValueAction : public Actionparent_->set_value(this->buffer_.value(x...)); // Set the listener for read events - this->parent_->on_read([this, x...](uint16_t id) { + // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference + // (e.g. climate on_control's ClimateCall&). See #17142. + this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read this->parent_->set_value(this->buffer_.value(x...)); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); } protected: diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index 4e34049038..c617a73f87 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -77,3 +77,34 @@ esp32_ble_server: id: test_change_descriptor value: data: [0x01, 0x02, 0x03] + +# Regression test for #17142: the set_value action used from a trigger that passes +# its argument by reference (climate on_control supplies ClimateCall&) previously +# failed to compile. +sensor: + - platform: template + id: ble_test_temp + lambda: "return 20.0;" + +output: + - platform: template + id: ble_test_output + type: float + write_action: + - logger.log: "out" + +climate: + - platform: pid + name: "BLE Test Climate" + id: ble_test_climate + sensor: ble_test_temp + default_target_temperature: 20 + heat_output: ble_test_output + control_parameters: + kp: 0.1 + ki: 0.001 + kd: 0.1 + on_control: + - ble_server.characteristic.set_value: + id: test_notify_characteristic + value: !lambda "return std::vector{0, 1, 2};" From ee118d384a9aac9687fa6d79df3a92e08813f3e6 Mon Sep 17 00:00:00 2001 From: mnewton25 <83018731+mnewton25@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:40:49 +0100 Subject: [PATCH 1876/2214] [esp32] Use POSIX path for secure-boot signing/verification keys Fixes #17164 (#17166) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5d4b3b8b47..9c68fec69b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2316,14 +2316,14 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_SIGNING_KEY", - str(signed_ota[CONF_SIGNING_KEY].resolve()), + signed_ota[CONF_SIGNING_KEY].resolve().as_posix(), ) else: # Public key mode — verification only, external signing required add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_VERIFICATION_KEY", - str(signed_ota[CONF_VERIFICATION_KEY].resolve()), + signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), ) cg.add_define("USE_OTA_SIGNED_VERIFICATION") From b3dcaac2626f777b0fd07cafb46e9e748b1b97d5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:43:39 +1000 Subject: [PATCH 1877/2214] [mipi_spi] Warn on MODE3 default for display without CS pin (#17153) --- esphome/components/mipi_spi/display.py | 10 ++++- tests/component_tests/mipi_spi/test_init.py | 44 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index abb7eaa458..d613d0a1ab 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -172,13 +172,19 @@ def model_schema(config): if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. - spi_mode = model.get_default(CONF_SPI_MODE) + spi_mode = ( + cv.UNDEFINED if CONF_SPI_MODE in config else model.get_default(CONF_SPI_MODE) + ) if not spi_mode: if bus_mode == TYPE_OCTAL or ( bus_mode == TYPE_SINGLE - and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + and config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) is False ): spi_mode = "MODE3" + if bus_mode == TYPE_SINGLE: + LOGGER.warning( + "No SPI mode specified, defaulting to MODE3 due to lack of CS pin. If you experience issues, try setting SPI mode explicitly to MODE0 or MODE3." + ) else: spi_mode = "MODE0" diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index d681908027..dbd8e15702 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -306,6 +306,50 @@ def test_all_predefined_models( run_schema_validation(config) +def test_single_bus_no_cs_no_mode_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A single-bus display with no CS pin and no explicit SPI mode warns about MODE3 default.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + run_schema_validation({"model": "ili9488", "dc_pin": 14}) + + assert "defaulting to MODE3 due to lack of CS pin" in caplog.text + + +@pytest.mark.parametrize( + "config", + [ + pytest.param( + {"model": "ili9488", "dc_pin": 14, "cs_pin": 0}, + id="cs_pin_provided", + ), + pytest.param( + {"model": "ili9488", "dc_pin": 14, "spi_mode": "mode0"}, + id="spi_mode_provided", + ), + ], +) +def test_single_bus_no_mode_warning_suppressed( + config: ConfigType, + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """No MODE3 warning when a CS pin or an explicit SPI mode is provided.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + run_schema_validation(config) + + assert "defaulting to MODE3 due to lack of CS pin" not in caplog.text + + def test_native_generation( generate_main: Callable[[str | Path], str], component_fixture_path: Callable[[str], Path], From 4f7faa771274d3e274012ca5a17c4f45669a9209 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:06:15 +1200 Subject: [PATCH 1878/2214] Bump bundled esphome-device-builder to 1.0.15 (#17170) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index bf37d6d88b..9b2519b426 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.14 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.15 RUN \ platformio settings set enable_telemetry No \ From 2ec24505d07361ab417fc8493651f5a041a77402 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:05:23 +1000 Subject: [PATCH 1879/2214] [mipi_spi] Suppress sequence errors when page selection used (#17176) --- esphome/components/mipi/__init__.py | 1 + esphome/components/mipi_spi/display.py | 20 ++-- esphome/components/mipi_spi/mipi_spi.h | 8 -- .../mipi_spi/test_page_selection.py | 113 ++++++++++++++++++ 4 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_page_selection.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 129befe600..3c59345162 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -120,6 +120,7 @@ CSCON = 0xF0 PWCTR6 = 0xF6 ADJCTL3 = 0xF7 PAGESEL = 0xFE +PAGESEL1 = 0xFF MADCTL_MY = 0x80 # Bit 7 Bottom to top MADCTL_MX = 0x40 # Bit 6 Right to left diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index d613d0a1ab..0231d12529 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -17,6 +17,8 @@ from esphome.components.mipi import ( MADCTL, MODE_BGR, MODE_RGB, + PAGESEL, + PAGESEL1, PIXFMT, DriverChip, dimension_schema, @@ -276,14 +278,16 @@ def customise_schema(config): # Check for invalid combinations of MADCTL config if init_sequence := config.get(CONF_INIT_SEQUENCE): commands = [x[0] for x in init_sequence] - if MADCTL in commands and CONF_TRANSFORM in config: - raise cv.Invalid( - f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" - ) - if PIXFMT in commands: - raise cv.Invalid( - f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" - ) + # If there is page swapping, we can't rely on recognising common commands + if PAGESEL not in commands and PAGESEL1 not in commands: + if MADCTL in commands and CONF_TRANSFORM in config: + raise cv.Invalid( + f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" + ) + if PIXFMT in commands: + raise cv.Invalid( + f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" + ) if bus_mode == TYPE_QUAD and CONF_DC_PIN in config: raise cv.Invalid("DC pin is not supported in quad mode") diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index a594e48209..d9627899e0 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -176,7 +176,6 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - auto arg_byte = vec[index]; switch (cmd) { case SLEEP_OUT: { // are we ready, boots? @@ -187,13 +186,6 @@ class MipiSpi : public display::Display, } } break; - case INVERT_ON: - this->invert_colors_ = true; - break; - case BRIGHTNESS: - this->brightness_ = arg_byte; - break; - default: break; } diff --git a/tests/component_tests/mipi_spi/test_page_selection.py b/tests/component_tests/mipi_spi/test_page_selection.py new file mode 100644 index 0000000000..4b1ec22271 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_page_selection.py @@ -0,0 +1,113 @@ +"""Combined tests for PAGESEL/PAGESEL1 behaviour with MADCTL/PIXFMT. + +Covers both the suppression behaviour (when PAGESEL or PAGESEL1 are present) +and the error behaviour when neither page-selection command is present. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi import MADCTL, PAGESEL, PAGESEL1, PIXFMT +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: dict[str, Any]) -> dict[str, Any]: + """Run schema + final validation and return the validated config.""" + cfg = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(cfg) + return cfg + + +def test_madctl_error_suppressed_when_pagesel_present( + set_core_config: SetCoreConfigCallable, +) -> None: + """If PAGESEL is present in init_sequence, MADCTL presence must not raise an error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False}, + "init_sequence": [[PAGESEL, 0x00], [MADCTL, 0x01]], + } + + # Should not raise + validated = validated_config(cfg) + assert validated is not None + + +def test_pixfmt_error_suppressed_when_pagesel1_present( + set_core_config: SetCoreConfigCallable, +) -> None: + """If PAGESEL1 is present in init_sequence, PIXFMT presence must not raise an error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[PAGESEL1, 0x00], [PIXFMT, 0x01]], + } + + # Should not raise + validated = validated_config(cfg) + assert validated is not None + + +def test_madctl_raises_without_pagesel( + set_core_config: SetCoreConfigCallable, +) -> None: + """MADCTL in the init_sequence should raise when a transform is configured and + no PAGESEL/PAGESEL1 is present. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg: dict[str, Any] = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False}, + "init_sequence": [[MADCTL, 0x01]], + } + + with pytest.raises(cv.Invalid, match=r"MADCTL .* in the init sequence"): + CONFIG_SCHEMA(cfg) + + +def test_pixfmt_raises_without_pagesel1( + set_core_config: SetCoreConfigCallable, +) -> None: + """PIXFMT in the init_sequence should raise when no PAGESEL/PAGESEL1 is present.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg: dict[str, Any] = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[PIXFMT, 0x01]], + } + + with pytest.raises( + cv.Invalid, match=r"PIXFMT .* should not be in the init sequence" + ): + CONFIG_SCHEMA(cfg) From 94ccddf176b4fd6521358bba3cba33c9fa6daa7f Mon Sep 17 00:00:00 2001 From: Geoffrey Frogeye Date: Wed, 24 Jun 2026 15:29:57 +0200 Subject: [PATCH 1880/2214] [opentherm] Support power scaling disabled (#17183) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/opentherm/output/opentherm_output.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 2735c85d06..4092358d75 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -7,9 +7,13 @@ static const char *const TAG = "opentherm.output"; void opentherm::OpenthermOutput::write_state(float state) { ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_); - this->state = state < 0.003 && this->zero_means_zero_ - ? 0.0 - : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING + bool zero_means_zero = this->zero_means_zero_; +#else + bool zero_means_zero = false; +#endif + this->state = + state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); this->has_state_ = true; ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state); } From 26cf373ae72f0a1f07563483ec2fcd1dc071013c Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:15:00 +0200 Subject: [PATCH 1881/2214] Bump bundled esphome-device-builder to 1.0.16 (#17182) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9b2519b426..5b9c7b8b55 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.15 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.16 RUN \ platformio settings set enable_telemetry No \ From dfe14f9c3ae0750beba518f1d2e3ecc27e75d45d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:22 +0200 Subject: [PATCH 1882/2214] Bump bundled esphome-device-builder to 1.0.17 (#17199) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5b9c7b8b55..c0795de60f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.16 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.17 RUN \ platformio settings set enable_telemetry No \ From 7a64163c4fec2fd221aa7fb241284b9f1e16c150 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:40:28 -0400 Subject: [PATCH 1883/2214] [esp32] Accept '#' as ESP-IDF source ref separator (#17193) --- esphome/espidf/framework.py | 9 ++++++--- tests/unit_tests/test_espidf_framework.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 4053898a8e..7213f2dfc0 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -337,16 +337,19 @@ print(".".join([str(x) for x in sys.version_info])) _GITHUB_SHORTHAND_RE = re.compile( - r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" + r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$" ) _GITHUB_HTTPS_RE = re.compile( - r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$" + r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$" ) def _parse_git_source(source_url: str) -> tuple[str, str | None] | None: """Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or - ``https://github.com/owner/repo.git[@ref]``, else ``None``.""" + ``https://github.com/owner/repo.git[@ref]``, else ``None``. + + The ref may be separated with ``@`` or ``#``; ``#`` matches the PlatformIO + convention used for ``platform_version`` URLs.""" if m := _GITHUB_SHORTHAND_RE.match(source_url): owner, repo, ref = m.group(1), m.group(2), m.group(3) # Tolerate a trailing ".git" on the shorthand repo so the diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 525cd55146..e0f63e89b4 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -64,6 +64,19 @@ from esphome.framework_helpers import _tar_extract_all, get_python_env_executabl "https://github.com/espressif/esp-idf.git@v6.0.1", ("https://github.com/espressif/esp-idf.git", "v6.0.1"), ), + # '#' ref separator (PlatformIO/git-web convention) works on both forms + ( + "https://github.com/espressif/esp-idf.git#release/v6.1", + ("https://github.com/espressif/esp-idf.git", "release/v6.1"), + ), + ( + "github://espressif/esp-idf#release/v6.1", + ("https://github.com/espressif/esp-idf.git", "release/v6.1"), + ), + ( + "github://espressif/esp-idf.git#master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), # Tolerate a trailing ".git" on the shorthand so the user doesn't # silently end up with a doubled "...esp-idf.git.git" URL. ( From 8bc5b97298be6f25f411c267c450e670861aeb82 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:28:06 -0400 Subject: [PATCH 1884/2214] [network] Set IPv4 type tag on all lwIP platforms, not just esp32 (#17200) --- esphome/components/network/ip_address.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 55bb2a1c89..d8a127f4a0 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -119,7 +119,7 @@ struct IPAddress { IPAddress(const std::string &in_address) { ipaddr_aton(in_address.c_str(), &ip_addr_); } IPAddress(ip4_addr_t *other_ip) { memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip4_addr_t)); -#if USE_ESP32 && LWIP_IPV6 +#if LWIP_IPV6 ip_addr_.type = IPADDR_TYPE_V4; #endif } From 29dfd820c68b803a1255ef812533ac0485bbf481 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:28:15 -0400 Subject: [PATCH 1885/2214] [wifi] Report STA IP, not SoftAP IP, in wifi_info on ESP8266 (#17185) --- esphome/components/wifi/wifi_component_esp8266.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 717d542fbe..84b864c0c5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -218,9 +218,18 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { return {}; network::IPAddresses addresses; uint8_t index = 0; + // addrList enumerates all lwIP netifs, including the SoftAP / fallback hotspot. Filter out + // the AP address so the STA address is reported as the device IP (see issue #17181). + struct ip_info ap_ip {}; + wifi_get_ip_info(SOFTAP_IF, &ap_ip); + network::IPAddress ap_address(&ap_ip.ip); + bool filter_ap = ap_address.is_set(); for (auto &addr : addrList) { + network::IPAddress ip(addr.ipFromNetifNum()); + if (filter_ap && ip == ap_address) + continue; assert(index < addresses.size()); - addresses[index++] = addr.ipFromNetifNum(); + addresses[index++] = ip; } return addresses; } From f3d61ca3e12703d076be6b71e0565edad7fe25a4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:36:36 +1000 Subject: [PATCH 1886/2214] [mipi][mipi_spi] Swap native dimensions for swap_xy hardware transform (#17201) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 36 ++-- esphome/components/mipi_spi/display.py | 16 +- .../mipi_spi/test_padding_and_offsets.py | 167 +++++++++++++++++- 3 files changed, 191 insertions(+), 28 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 3c59345162..63c3ddf9c2 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -388,6 +388,16 @@ class DriverChip: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} return {CONF_MIRROR_X, CONF_MIRROR_Y} + def has_hardware_transform(self, config) -> bool: + """ + Check if the model supports hardware transforms for the given configuration. + """ + return config.get(CONF_TRANSFORM) != CONF_DISABLED and self.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + def option(self, name, fallback=False) -> cv.Optional: return cv.Optional(name, default=self.get_default(name, fallback)) @@ -418,10 +428,15 @@ class DriverChip: :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + transform = self.get_transform(config) if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] if isinstance(dimensions, dict): + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if transform.get(CONF_SWAP_XY) is True: + native_width, native_height = native_height, native_width width = dimensions[CONF_WIDTH] height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] @@ -429,23 +444,19 @@ class DriverChip: if CONF_PAD_WIDTH in dimensions: pad_width = dimensions[CONF_PAD_WIDTH] native_width = width + offset_width + pad_width + elif native_width == 0: + pad_width = 0 + native_width = width + offset_width else: - native_width = self.get_default(CONF_NATIVE_WIDTH, 0) - if native_width == 0: - pad_width = 0 - native_width = width + offset_width - else: - pad_width = native_width - width - offset_width + pad_width = native_width - width - offset_width if CONF_PAD_HEIGHT in dimensions: pad_height = dimensions[CONF_PAD_HEIGHT] native_height = height + offset_height + pad_height + elif native_height == 0: + pad_height = 0 + native_height = height + offset_height else: - native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) - if native_height == 0: - pad_height = 0 - native_height = height + offset_height - else: - pad_height = native_height - height - offset_height + pad_height = native_height - height - offset_height if ( pad_width + offset_width >= native_width or pad_height + offset_height >= native_height @@ -461,7 +472,6 @@ class DriverChip: return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults - transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 0231d12529..4162459058 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -295,13 +295,7 @@ def customise_schema(config): raise cv.Invalid(f"DC pin is required in {bus_mode} mode") denominator(config) model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) @@ -366,13 +360,7 @@ def get_instance(config): :return: type, template arguments """ model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, offset_width, offset_height, pad_width, pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index 82adf88b7e..7ae6f0e61f 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -13,6 +13,16 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32S3, ) +from esphome.components.mipi import ( + CONF_DIMENSIONS, + CONF_HEIGHT, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_HEIGHT, + CONF_OFFSET_WIDTH, + CONF_SWAP_XY, + CONF_WIDTH, +) from esphome.components.mipi_spi.display import ( CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, @@ -20,7 +30,13 @@ from esphome.components.mipi_spi.display import ( get_instance, ) from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE -from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.const import ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_DISABLED, + CONF_TRANSFORM, + PlatformFramework, +) from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -432,3 +448,152 @@ class TestUserConfiguredPadding: assert config["dimensions"]["width"] == 240 assert config["dimensions"]["height"] == 240 assert config["dimensions"]["pad_height"] == 16 + + +class TestHasHardwareTransform: + """Test DriverChip.has_hardware_transform().""" + + def test_full_transform_model_without_transform_key(self) -> None: + """A model supporting swap_xy uses a hardware transform by default.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({}) is True + + def test_full_transform_model_with_transform_dict(self) -> None: + """A configured (non-disabled) transform still uses the hardware path.""" + model = MODELS["ST7789V"] + assert ( + model.has_hardware_transform({CONF_TRANSFORM: {CONF_SWAP_XY: True}}) is True + ) + + def test_full_transform_model_with_transform_disabled(self) -> None: + """Disabling the transform falls back to software transforms.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({CONF_TRANSFORM: CONF_DISABLED}) is False + + def test_model_without_swap_xy_support(self) -> None: + """Models that cannot swap axes never use a hardware transform.""" + # AXS15231 only supports mirror_x/mirror_y, not swap_xy. + model = MODELS["AXS15231"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + assert model.has_hardware_transform({}) is False + + +class TestSwapXYNativeDimensions: + """Test that native dimensions are swapped when a swap_xy transform is active. + + When explicit dimensions are given in the swapped (rotated) orientation and the + model applies a hardware swap_xy transform, the model's native_width/native_height + defaults must be swapped to match, otherwise padding is computed against the wrong + axis and validation fails. + """ + + def test_explicit_swapped_dimensions_with_swap_xy_transform( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Explicit landscape dimensions on a portrait-native model with swap_xy.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ST7789V is natively 240x320 (portrait). Provide landscape dimensions + # together with a swap_xy transform. + model = MODELS["ST7789V"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 320, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + # swap=False because the buffer is laid out in the requested orientation. + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + # Native dims are swapped to 320x240, so padding works out to zero rather + # than going negative (which previously raised "Invalid offsets"). + assert (width, height) == (320, 240) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_explicit_dimensions_without_swap_keeps_native_orientation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Without swap_xy the native dimensions keep their original orientation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + model = MODELS["ST7789V"] + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 240, + CONF_HEIGHT: 320, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: False, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + assert (width, height) == (240, 320) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_swapped_native_dimensions_compute_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Padding is derived from the swapped native size when swap_xy is active.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ILI9341 is natively 240x320. Request a 300x240 area in landscape; the + # swapped native size is 320x240, leaving 20px of horizontal padding. + model = MODELS["ILI9341"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ILI9341", + CONF_DIMENSIONS: { + CONF_WIDTH: 300, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, _, _, pad_w, pad_h = model.get_dimensions(config, swap=False) + assert (width, height) == (300, 240) + # native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20 + assert pad_w == 20 + assert pad_h == 0 From 9a1daa5247372902d15413eff0b8d0d9e0804670 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:36:10 -0400 Subject: [PATCH 1887/2214] [hbridge] Fix light stuck on one polarity (#17162) --- esphome/components/hbridge/light/__init__.py | 6 ++-- .../hbridge/light/hbridge_light_output.h | 30 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index ccb47237b6..f9451e2594 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -1,14 +1,14 @@ import esphome.codegen as cg from esphome.components import light, output import esphome.config_validation as cv -from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B +from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL from .. import hbridge_ns CODEOWNERS = ["@DotNetDann"] HBridgeLightOutput = hbridge_ns.class_( - "HBridgeLightOutput", cg.Component, light.LightOutput + "HBridgeLightOutput", cg.PollingComponent, light.LightOutput ) CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( @@ -16,12 +16,14 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(HBridgeLightOutput), cv.Required(CONF_PIN_A): cv.use_id(output.FloatOutput), cv.Required(CONF_PIN_B): cv.use_id(output.FloatOutput), + cv.Optional(CONF_UPDATE_INTERVAL, default="8ms"): cv.update_interval, } ) async def to_code(config): var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL))) await cg.register_component(var, config) await light.register_light(var, config) diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index 16408f24f1..9dcf7adfd8 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -3,11 +3,10 @@ #include "esphome/components/light/light_output.h" #include "esphome/components/output/float_output.h" #include "esphome/core/component.h" -#include "esphome/core/helpers.h" namespace esphome::hbridge { -class HBridgeLightOutput : public Component, public light::LightOutput { +class HBridgeLightOutput final : public PollingComponent, public light::LightOutput { public: void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } @@ -20,11 +19,12 @@ class HBridgeLightOutput : public Component, public light::LightOutput { return traits; } - void setup() override { this->disable_loop(); } + void setup() override { this->stop_poller(); } - void loop() override { - // Only called when both channels are active — alternate H-bridge direction - // each iteration to multiplex cold and warm white. + void update() override { + // Flip the H-bridge direction to multiplex cold/warm white. update_interval must stay + // slower than the output's PWM period (flipping faster collapses the output onto one + // channel) but fast enough to avoid flicker (issue #17030). if (!this->forward_direction_) { this->pina_pin_->set_level(this->pina_duty_); this->pinb_pin_->set_level(0); @@ -46,13 +46,17 @@ class HBridgeLightOutput : public Component, public light::LightOutput { this->pinb_duty_ = new_pinb; if (new_pina != 0.0f && new_pinb != 0.0f) { - // Both channels active — need loop to alternate H-bridge direction - this->high_freq_.start(); - this->enable_loop(); + // Both channels active — multiplex the H-bridge direction via the poller. + if (!this->multiplexing_) { + this->multiplexing_ = true; + this->start_poller(); + } } else { - // Zero or one channel active — drive pins directly, no multiplexing needed - this->high_freq_.stop(); - this->disable_loop(); + // Zero or one channel active — drive pins directly, no multiplexing needed. + if (this->multiplexing_) { + this->multiplexing_ = false; + this->stop_poller(); + } this->pina_pin_->set_level(new_pina); this->pinb_pin_->set_level(new_pinb); } @@ -64,7 +68,7 @@ class HBridgeLightOutput : public Component, public light::LightOutput { float pina_duty_{0}; float pinb_duty_{0}; bool forward_direction_{false}; - HighFrequencyLoopRequester high_freq_; + bool multiplexing_{false}; }; } // namespace esphome::hbridge From eb711381d33749bb291170f333c88cb2750fb438 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:47:24 +0200 Subject: [PATCH 1888/2214] Bump bundled esphome-device-builder to 1.0.18 (#17212) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c0795de60f..24710365cf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.17 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.18 RUN \ platformio settings set enable_telemetry No \ From f78cbf920033cb72ce965ad14d69bb71c53f0510 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:48:52 +0200 Subject: [PATCH 1889/2214] Bump bundled esphome-device-builder to 1.0.19 (#17217) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 24710365cf..4a375ed15b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.18 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19 RUN \ platformio settings set enable_telemetry No \ From 84d1c34c28cb6d45545c0803659f124ac23b8242 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 15:49:56 +0200 Subject: [PATCH 1890/2214] [core] Fix area saved as null in storage.json (#17219) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/core/config.py | 12 +++++++++++- tests/unit_tests/core/test_config.py | 29 +++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index b925f0b7d9..59c96035b8 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -407,6 +407,17 @@ def preload_core_config(config, result) -> str: CORE.name = conf[CONF_NAME] CORE.friendly_name = conf.get(CONF_FRIENDLY_NAME) + # Record the node's area name now (substitutions are already resolved at this + # point). storage.json is written before to_code() runs, so deferring this to + # to_code() left the area as null in storage.json. The value here is the raw + # post-substitution form (a plain string or a {name: ...} mapping). Assign + # unconditionally (like friendly_name) so a config without an area never + # inherits a stale value from a previous load in a long-running process, and + # use .get() so a malformed mapping surfaces later as a proper validation + # error rather than a KeyError here. to_code() sets it again from the + # validated config, which yields the same name. + area = conf.get(CONF_AREA) + CORE.area = area.get(CONF_NAME) if isinstance(area, dict) else area CORE.data[KEY_CORE] = {} if CONF_BUILD_PATH not in conf: @@ -760,7 +771,6 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: - CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e2b34d92d8..b3d87f6857 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -152,15 +152,21 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: ("multiple_areas_devices.yaml", "Main Area"), ], ) -async def test_to_code_records_core_area( +async def test_core_area_recorded_at_config_load( yaml_file: Callable[[str], Path], fixture: str, expected_area: str, ) -> None: - """``to_code`` records the node's area name on CORE for StorageJSON.""" + """The node's area name is recorded on CORE for StorageJSON. + + It must be set during config load (preload_core_config), not deferred to + to_code(): storage.json is written before to_code() runs, so a late + assignment left the area as null in storage.json (regression #17218). + """ result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) assert result is not None - assert CORE.area is None + # Recorded already at config-load time, before any code generation. + assert CORE.area == expected_area with patch("esphome.core.config.cg") as mock_cg: mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() @@ -170,6 +176,23 @@ async def test_to_code_records_core_area( assert CORE.area == expected_area +def test_config_load_without_area_clears_stale_core_area( + yaml_file: Callable[[str], Path], +) -> None: + """A config without an area must not inherit a stale CORE.area. + + preload_core_config assigns CORE.area unconditionally, so the area from a + previous load in a long-running process cannot leak into a config that + omits it. + """ + CORE.area = "Stale Area From Previous Load" + result = load_config_from_fixture( + yaml_file, "device_without_area.yaml", FIXTURES_DIR + ) + assert result is not None + assert CORE.area is None + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: From 62e19bcb274c5deef15a8a2d05b4dc6236c0ea77 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:00:23 -0700 Subject: [PATCH 1891/2214] Bump bundled esphome-device-builder to 1.0.20 (#17244) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a375ed15b..99b4b1b39d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.20 RUN \ platformio settings set enable_telemetry No \ From 1793ca5eac9e055c3dd19a882f45485e777b627b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:05:00 -0400 Subject: [PATCH 1892/2214] [core] Suppress unactionable legacy-redaction warning for substitutions (#17242) --- esphome/__main__.py | 27 ++++++++++++++++++++++----- tests/unit_tests/test_main.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index bda3dcbd05..bec00cca60 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1470,12 +1470,29 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0" def _redact_with_legacy_fallback(output: str) -> str: unmarked: set[str] = set() + # Track the top-level ``substitutions:`` block. Its keys are arbitrary + # user-chosen names with no schema validator, so the ``cv.sensitive(...)`` + # migration named in the warning can't be applied to them. Their values are + # still redacted, but emitting the (unactionable) deprecation warning would + # only confuse users. + in_substitutions = False - def _replace(m: re.Match[str]) -> str: - unmarked.add(m.group("key")) - return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" - - output = _LEGACY_REDACTION_RE.sub(_replace, output) + lines = output.split("\n") + for i, line in enumerate(lines): + # A non-indented, non-blank line is a top-level key that opens or + # closes the substitutions block. + if line and not line[0].isspace(): + in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:") + m = _LEGACY_REDACTION_RE.search(line) + if m is None: + continue + if not in_substitutions: + unmarked.add(m.group("key")) + lines[i] = ( + f"{line[: m.start()]}{m.group('key')}: " + f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}" + ) + output = "\n".join(lines) for key in sorted(unmarked): _LOGGER.warning( "Field '%s' is being redacted by a legacy substring heuristic. " diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index acd39cedc6..eb39ceab2a 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -437,6 +437,36 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( assert not any("legacy substring" in rec.message for rec in caplog.records) +def test_redact_with_legacy_fallback__substitutions_redacted_without_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Substitution keys have no schema validator, so their values are still + redacted but the unactionable cv.sensitive migration warning is suppressed + (see issue #17225).""" + text = "substitutions:\n ota_password: apolloautomation\nesphome:\n name: x\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ota_password: \\033[8mapolloautomation\\033[28m" in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__warns_after_substitutions_block( + caplog: pytest.LogCaptureFixture, +) -> None: + """The suppression ends at the next top-level key; a sensitive-shaped field + in a later block (a real schema field) still warns, while the substitution + above it does not.""" + text = ( + "substitutions:\n ota_password: apolloautomation\nwifi:\n password: hunter2\n" + ) + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ota_password: \\033[8mapolloautomation\\033[28m" in out + assert "password: \\033[8mhunter2\\033[28m" in out + assert any("'password'" in rec.message for rec in caplog.records) + assert not any("ota_password" in rec.message for rec in caplog.records) + + def test_command_config__invokes_legacy_fallback_when_redacting( tmp_path: Path, capfd: CaptureFixture[str] ) -> None: From 14b6a0ede1a90f6a67e4b3d93e192fef808b6560 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:17:09 -0400 Subject: [PATCH 1893/2214] [espnow] Don't throttle ESP-NOW RX when deep_sleep is present (#17240) --- esphome/components/espnow/espnow_component.cpp | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 403e6f4944..f89b4a2ff1 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -28,9 +28,6 @@ namespace esphome::espnow { static constexpr const char *TAG = "espnow"; -static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50; -static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100; - ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const LogString *espnow_error_to_str(esp_err_t error) { @@ -204,11 +201,6 @@ void ESPNowComponent::enable_() { esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); -#ifdef USE_DEEP_SLEEP - esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW); - esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL); -#endif - this->state_ = ESPNOW_STATE_ENABLED; for (auto peer : this->peers_) { From 24d8e99c507e7b44019c427af8987350fbba4ee6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:41:49 -0700 Subject: [PATCH 1894/2214] Bump bundled esphome-device-builder to 1.0.21 (#17257) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 99b4b1b39d..8dce7861df 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.20 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21 RUN \ platformio settings set enable_telemetry No \ From 4fbe0d87ec3ff43e32f68ad515b359511c0e5863 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:16:42 -0400 Subject: [PATCH 1895/2214] [wifi] Fix crash when WiFi is enabled late alongside ESP-NOW (#17239) --- .../wifi/wifi_component_esp_idf.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index b395c77141..2ade015a25 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,12 +179,53 @@ void WiFiComponent::wifi_lazy_init_() { // nor re-register the default WiFi handlers. if (s_sta_netif == nullptr) s_sta_netif = esp_netif_create_default_wifi_sta(); + if (s_sta_netif == nullptr) { + // Allocation failed; leave wifi_initialized_ false so a later enable() retries. + ESP_LOGE(TAG, "esp_netif_create_default_wifi_sta failed"); + return; + } #ifdef USE_WIFI_AP if (s_ap_netif == nullptr) s_ap_netif = esp_netif_create_default_wifi_ap(); #endif // USE_WIFI_AP + // The WiFi driver was started (e.g. by ESP-NOW with the wifi component disabled at + // boot) before our STA netif existed. The default WIFI_EVENT_STA_START handler + // therefore ran with no netif and never called esp_wifi_register_if_rxcb() -- the + // only thing that points the driver's RX path at a netif (it sets + // s_wifi_netifs[WIFI_IF_STA]). A bare esp_netif_action_start() would stop the + // immediate crash (#17232) but leaves RX unbound, so the first association + // associates at L2 yet never receives DHCP replies and times out (#17239). Restart + // the driver now that the netif exists so STA_START re-runs the default handler and + // wires RX correctly. ESP-NOW survives the stop/start (its peer state persists). + // This also matches a self-retry: if esp_wifi_set_storage() below failed on a + // previous wifi_lazy_init_() it returned without setting wifi_initialized_, and + // esp_wifi_init() has since run, so esp_wifi_get_mode() now succeeds here too. + wifi_mode_t mode; + if (esp_wifi_get_mode(&mode) == ESP_OK) { + ESP_LOGD(TAG, "WiFi driver already started without STA netif; restarting to bind it"); + esp_err_t err = esp_wifi_stop(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_stop failed: %s", esp_err_to_name(err)); + } + // Re-apply RAM storage; the normal init path does this, but it is skipped on + // the self-retry case above, which would otherwise let the driver persist + // credentials to NVS for the rest of the boot. + err = esp_wifi_set_storage(WIFI_STORAGE_RAM); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); + } + err = esp_wifi_start(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_start failed: %s", esp_err_to_name(err)); + return; + } + s_wifi_started = true; + this->wifi_initialized_ = true; + return; + } + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); if (global_preferences->nvs_handle == 0) { ESP_LOGW(TAG, "starting wifi without nvs"); From 6251c26cc6c16f0b4318a4d6d96f90ce5706bf94 Mon Sep 17 00:00:00 2001 From: Tom <7723105+thomasfw@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:06:12 +0100 Subject: [PATCH 1896/2214] [espnow] Fix espnow crash when send() is called without a callback (#17266) --- esphome/components/espnow/espnow_component.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index f89b4a2ff1..2756b615a1 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -303,7 +303,9 @@ void ESPNowComponent::loop() { ESP_LOGV(TAG, ">>> [%s] %s", addr_buf, LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status))); #endif if (this->current_send_packet_ != nullptr) { - this->current_send_packet_->callback_(packet->packet_.sent.status); + if (this->current_send_packet_->callback_ != nullptr) { + this->current_send_packet_->callback_(packet->packet_.sent.status); + } this->send_packet_pool_.release(this->current_send_packet_); this->current_send_packet_ = nullptr; // Reset current packet after sending } From a618ee11b41a9baab68ac718fd7124382a39f598 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:30:24 +1200 Subject: [PATCH 1897/2214] Bump version to 2026.6.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ea36d45fee..bc92241937 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.2 +PROJECT_NUMBER = 2026.6.3 # 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 7cc9b604d9..b7ffb9121d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.2" +__version__ = "2026.6.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 2778c62d07ba56f6dcb9db3e8aafd600a6b0b910 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 29 Jun 2026 11:33:56 -0400 Subject: [PATCH 1898/2214] [audio] Bump microMP3 to v0.4.0 (#17279) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 091f496e33..d87f32fc36 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.3.0") + add_idf_component(name="esphome/micro-mp3", ref="0.4.0") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MICRO_MP3_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 81c16f2e38..4f36e4dbe6 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.3.0 + version: 0.4.0 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From b8690c8e31600201439a6ffb8325c436cde8a46e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:32:28 -0400 Subject: [PATCH 1899/2214] [core] Drop Python 3.11 support (#17280) --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 4 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 2 +- .pre-commit-config.yaml | 2 +- AGENTS.md | 2 +- esphome/async_thread.py | 9 +- esphome/components/nrf52/framework.py | 17 +-- esphome/framework_helpers.py | 124 ++++-------------- esphome/helpers.py | 10 +- esphome/platformio/library.py | 7 +- pyproject.toml | 6 +- script/lint-python | 2 +- tests/integration/state_utils.py | 5 +- tests/unit_tests/test_framework_helpers.py | 10 -- 16 files changed, 58 insertions(+), 152 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 17234e811a..4c0c330a19 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 9678831b50..d6ad28dffe 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -65,7 +65,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 @@ -149,7 +149,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 4bef082aab..ac0322e2fa 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -60,7 +60,7 @@ jobs: if: steps.pr.outputs.skip != 'true' uses: ./.github/actions/restore-python with: - python-version: "3.11" + python-version: "3.12" cache-key: ${{ hashFiles('.cache-key') }} - name: Download memory analysis artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72519e421a..751241f563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ permissions: contents: read # actions/checkout for all jobs; individual jobs add their own scopes when they need to write env: - DEFAULT_PYTHON: "3.11" - PYUPGRADE_TARGET: "--py311-plus" + DEFAULT_PYTHON: "3.12" + PYUPGRADE_TARGET: "--py312-plus" concurrency: # yamllint disable-line rule:line-length @@ -203,7 +203,7 @@ jobs: fail-fast: false matrix: python-version: - - "3.11" + - "3.12" - "3.13" - "3.14" os: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b23b561bd..20a77b152d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba74aff07c..da424f516f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: rev: v3.21.2 hooks: - id: pyupgrade - args: [--py311-plus] + args: [--py312-plus] - repo: https://github.com/adrienverge/yamllint.git rev: v1.37.1 hooks: diff --git a/AGENTS.md b/AGENTS.md index 21905ea356..46caea3aec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ This document provides essential context for AI models interacting with this pro ## 2. Core Technologies & Stack -* **Languages:** Python (>=3.11), C++ (gnu++20) +* **Languages:** Python (>=3.12), C++ (gnu++20) * **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF. * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Configuration:** YAML. diff --git a/esphome/async_thread.py b/esphome/async_thread.py index c5225a7a14..3972d735f5 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -12,12 +12,9 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable import threading -from typing import Generic, TypeVar - -_T = TypeVar("_T") -class AsyncThreadRunner(threading.Thread, Generic[_T]): +class AsyncThreadRunner[T](threading.Thread): """Run an async coroutine in a daemon thread and expose its result. The runner catches all exceptions from the coroutine and stores them in @@ -35,10 +32,10 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]): result = runner.result """ - def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None: + def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None: super().__init__(daemon=True) self._coro_factory = coro_factory - self.result: _T | None = None + self.result: T | None = None self.exception: BaseException | None = None self.event = threading.Event() diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 05feadb001..7aec6b088e 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -54,22 +54,15 @@ def _get_toolchain_path(version: str) -> Path: return _get_tools_path() / "toolchains" / version -# onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. _SITECUSTOMIZE = """\ -import os, stat, shutil, sys +import os, stat, shutil _orig = shutil.rmtree def _handler(func, path, exc): os.chmod(path, stat.S_IWRITE); func(path) -if sys.version_info >= (3, 12): - def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): - if onerror is None and onexc is None: - onexc = _handler - return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) -else: - def _rmtree(path, ignore_errors=False, onerror=None): - if onerror is None: - onerror = _handler - return _orig(path, ignore_errors=ignore_errors, onerror=onerror) +def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): + if onerror is None and onexc is None: + onexc = _handler + return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) shutil.rmtree = _rmtree """ diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index a8e5cf75a8..69cecc58e2 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -239,22 +239,19 @@ def _tar_extract_all( """ Extract a TAR archive to the specified directory. - Implementation is inspired by Python 3.12's tarfile data filtering logic. - This can be replaced with the standard library implementation once - support for Python 3.11 is no longer required. + Path-traversal, link, permission and ownership sanitization is delegated to + the stdlib ``tarfile.data_filter`` (PEP 706). We keep the wrapper-directory + stripping (no stdlib equivalent) and the absolute-path reject (data_filter's + check is os.path-dependent and would miss a Windows drive path when + extracting on POSIX). Args: data: File-like object containing the TAR archive extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header """ - import stat import tarfile - # Tar extraction safety: os.path.realpath / commonpath / normpath have no - # pathlib equivalents and Path.resolve() would follow symlinks unsafely. - # Use os.path for the security-sensitive parts; the simple checks move to - # Path. extract_dir = os.fspath(extract_dir) abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 @@ -269,18 +266,14 @@ def _tar_extract_all( safe_members = [] for member in all_members: - name = member.name - - # 1. Strip leading slashes - name = name.lstrip("/" + os.sep) - - # 2. Reject absolute paths (incl. Windows drive) + # Strip leading slashes, then reject absolute / Windows-drive paths + name = member.name.lstrip("/" + os.sep) if Path(name).is_absolute() or ( os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 ): continue - # 3. Strip wrapper directory if one was detected + # Strip wrapper directory if one was detected if strip_prefix is not None: norm = name.replace("\\", "/") if norm in (strip_root, strip_prefix): @@ -288,88 +281,29 @@ def _tar_extract_all( if not norm.startswith(strip_prefix): continue name = norm[len(strip_prefix) :] - - # 4. Compute final path - target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 - if os.path.commonpath([abs_dest, target_path]) != abs_dest: - continue - - # 5. Validate links properly - if member.issym() or member.islnk(): - linkname = member.linkname - - # Reject absolute link targets - if Path(linkname).is_absolute(): - continue - - if member.islnk() and strip_prefix is not None: - # Hard-link linknames reference another archive member - # by its archive name. We've stripped the wrapper prefix - # from member.name above (step 3); strip it here too so - # tarfile._find_link_target can resolve the target during - # extraction. Symlink linknames are filesystem-relative - # paths, not archive-member references, so they don't - # need this treatment. - norm_link = linkname.replace("\\", "/") - if norm_link in (strip_root, strip_prefix): - continue - if not norm_link.startswith(strip_prefix): - continue - linkname = norm_link[len(strip_prefix) :] - - # Strip leading slashes - linkname = os.path.normpath(linkname) - - if member.issym(): - link_target = os.path.join( # noqa: PTH118 - abs_dest, - os.path.dirname(name), # noqa: PTH120 - linkname, - ) - else: - link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 - link_target = os.path.realpath(link_target) - - if os.path.commonpath([abs_dest, link_target]) != abs_dest: - continue - - # write back normalized linkname - member.linkname = linkname - - # 6. Sanitize permissions - mode = member.mode - if mode is not None: - # Strip high bits & group/other write bits - mode &= ( - stat.S_IRWXU - | stat.S_IRGRP - | stat.S_IXGRP - | stat.S_IROTH - | stat.S_IXOTH - ) - if member.isfile() or member.islnk(): - # remove exec bits unless explicitly user-executable - if not (mode & stat.S_IXUSR): - mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - mode |= stat.S_IRUSR | stat.S_IWUSR - elif not (member.isdir() or member.issym()): - # Block special files. Directories and symlinks keep - # their masked-original mode — passing None here would - # crash tarfile.extract on Python <3.12 (its chmod - # path calls os.chmod unconditionally). - continue - - member.mode = mode - - # 7. Strip ownership - member.uid = None - member.gid = None - member.uname = None - member.gname = None - - # 8. Assign sanitized name back member.name = name + # Hard-link linknames reference another archive member by its + # archive name; strip the wrapper prefix here too so + # tarfile._find_link_target can resolve the target during + # extraction. Symlink linknames are filesystem-relative paths, + # not archive-member references, so they don't need this. + if member.islnk() and strip_prefix is not None: + norm_link = member.linkname.replace("\\", "/") + if norm_link in (strip_root, strip_prefix): + continue + if not norm_link.startswith(strip_prefix): + continue + member.linkname = norm_link[len(strip_prefix) :] + + # Delegate traversal, link, permission and ownership sanitization + # to the stdlib data filter; it raises FilterError for unsafe + # members (path traversal, links outside dest, special files). + try: + member = tarfile.data_filter(member, abs_dest) + except tarfile.FilterError: + continue + safe_members.append(member) total = len(safe_members) diff --git a/esphome/helpers.py b/esphome/helpers.py index 62dfd0fb09..631bcb6f39 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -397,17 +397,13 @@ def rmtree(path: Path | str) -> None: read-only flag and retrying. """ - def _onerror(func, path, exc_info): + def _onexc(func, path, exc): if os.access(path, os.W_OK): - raise exc_info[1].with_traceback(exc_info[2]) + raise exc Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR) func(path) - # ``onerror`` is deprecated in 3.12 in favour of ``onexc`` (different - # callable signature); keep the existing handler shape for now and - # silence the lint locally so this PR doesn't bundle an unrelated - # migration. - shutil.rmtree(path, onerror=_onerror) # pylint: disable=deprecated-argument + shutil.rmtree(path, onexc=_onexc) def walk_files(path: Path): diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 43282c7aa0..c2d783ecbe 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -24,7 +24,7 @@ import os from pathlib import Path import re import tempfile -from typing import Any, TypeVar +from typing import Any from urllib.parse import urlparse, urlsplit, urlunsplit from esphome import git @@ -195,10 +195,7 @@ class LibraryBackend: emit: Callable[["ConvertedLibrary"], None] -T = TypeVar("T") - - -def ensure_list(obj: T | list[T]) -> list[T]: +def ensure_list[T](obj: T | list[T]) -> list[T]: """ Convert an object to a list if it isn't already a list. diff --git a/pyproject.toml b/pyproject.toml index a292377835..e959578553 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Home Automation", ] -requires-python = ">=3.11.0,<3.15" +requires-python = ">=3.12.0,<3.15" dynamic = ["dependencies", "optional-dependencies", "version"] @@ -62,7 +62,7 @@ addopts = [ ] [tool.pylint.MAIN] -py-version = "3.11" +py-version = "3.12" ignore = [ "api_pb2.py", ] @@ -106,7 +106,7 @@ expected-line-ending-format = "LF" [tool.ruff] required-version = ">=0.5.0" -target-version = "py311" +target-version = "py312" exclude = ['generated'] [tool.ruff.lint] diff --git a/script/lint-python b/script/lint-python index e4b3314d2a..6bd95778fa 100755 --- a/script/lint-python +++ b/script/lint-python @@ -139,7 +139,7 @@ def main(): print() print("Running pyupgrade...") print() - PYUPGRADE_TARGET = "--py311-plus" + PYUPGRADE_TARGET = "--py312-plus" for files in filesets: cmd = ["pyupgrade", PYUPGRADE_TARGET] + files log = get_err(*cmd) diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index c8517aff09..65af57b944 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -19,7 +19,6 @@ from aioesphomeapi import ( _LOGGER = logging.getLogger(__name__) -T = TypeVar("T", bound=EntityInfo) S = TypeVar("S", bound=EntityState) @@ -58,7 +57,7 @@ async def wait_for_state( return await asyncio.wait_for(future, timeout=timeout) -def find_entity( +def find_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, entity_type: type[T] | None = None, @@ -86,7 +85,7 @@ def find_entity( return None -def require_entity( +def require_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, entity_type: type[T] | None = None, diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index fd807ed05d..6fe62dcc8c 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -658,11 +658,6 @@ def test_get_python_env_executable_path_nt() -> None: class TestTarExtractAllBranches: - @pytest.mark.skipif( - sys.version_info < (3, 12), - reason="patching os.name makes pathlib build a WindowsPath, which only " - "instantiates on POSIX in 3.12+", - ) def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" info = tarfile.TarInfo(name="C:/secret.txt") @@ -755,11 +750,6 @@ class TestTarExtractAllBranches: class TestZipExtractAllBranches: - @pytest.mark.skipif( - sys.version_info < (3, 12), - reason="patching os.name makes pathlib build a WindowsPath, which only " - "instantiates on POSIX in 3.12+", - ) def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" buf = _make_zip([("C:/secret.txt", "bad")]) From 136e343988cd12311d1b04e5a18d0bb0e7b82f00 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:13:11 -0400 Subject: [PATCH 1900/2214] [ethernet] Generic and YT8531 PHY over RGMII (gigabit) for ESP32-S31 (#17277) --- esphome/components/ethernet/__init__.py | 55 ++++++++ .../components/ethernet/ethernet_component.h | 7 ++ .../ethernet/ethernet_component_esp32.cpp | 117 +++++++++++++++++- esphome/core/defines.h | 2 + 4 files changed, 177 insertions(+), 4 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 6af68e4e3c..8f927cf3e9 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -126,6 +126,8 @@ ETHERNET_TYPES = { "ENC28J60": EthernetType.ETHERNET_TYPE_ENC28J60, "W6100": EthernetType.ETHERNET_TYPE_W6100, "W6300": EthernetType.ETHERNET_TYPE_W6300, + "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, + "YT8531": EthernetType.ETHERNET_TYPE_YT8531, } # PHY types that need compile-time defines for conditional compilation @@ -145,6 +147,8 @@ _PHY_TYPE_TO_DEFINE = { "ENC28J60": "USE_ETHERNET_ENC28J60", "W6100": "USE_ETHERNET_W6100", "W6300": "USE_ETHERNET_W6300", + "GENERIC": "USE_ETHERNET_GENERIC", + "YT8531": "USE_ETHERNET_YT8531", } @@ -309,6 +313,24 @@ def _validate(config): f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " f"'{CONF_INTERRUPT_PIN}' is a required option for [ethernet]." ) + elif config[CONF_TYPE] in ("GENERIC", "YT8531"): + from esphome.components.esp32 import ( + VARIANT_ESP32S31, + get_esp32_variant, + idf_version, + ) + + eth_type = config[CONF_TYPE] + variant = get_esp32_variant() + if variant != VARIANT_ESP32S31: + raise cv.Invalid( + f"The '{eth_type}' (RGMII) PHY is only supported on gigabit-capable " + f"variants (ESP32-S31), not {variant}" + ) + if idf_version() < cv.Version(6, 0, 0): + raise cv.Invalid( + f"The '{eth_type}' (RGMII) PHY requires ESP-IDF 6.0 or newer." + ) elif config[CONF_TYPE] != "OPENETH": from esphome.components.esp32 import ( VARIANT_ESP32, @@ -392,6 +414,23 @@ RMII_SCHEMA = cv.All( cv.only_on([Platform.ESP32]), ) +# Generic IEEE 802.3 PHY over the internal EMAC RGMII interface (e.g. ESP32-S31). +# RGMII data pins come from the IDF per-target default config. +GENERIC_SCHEMA = cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), + } + ) + ), + cv.only_on([Platform.ESP32]), +) + SPI_SCHEMA = cv.All( BASE_SCHEMA.extend( cv.Schema( @@ -442,6 +481,8 @@ CONFIG_SCHEMA = cv.All( "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), "LAN8670": RMII_SCHEMA, + "GENERIC": GENERIC_SCHEMA, + "YT8531": GENERIC_SCHEMA, }, upper=True, ), @@ -571,6 +612,20 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: elif config[CONF_TYPE] == "OPENETH": cg.add_define("USE_ETHERNET_OPENETH") add_idf_sdkconfig_option("CONFIG_ETH_USE_OPENETH", True) + elif config[CONF_TYPE] in ("GENERIC", "YT8531"): + # RGMII data pins come from the IDF default config; set MDC/MDIO + PHY addr. + cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) + cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) + cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) + if CONF_POWER_PIN in config: + cg.add(var.set_power_pin(config[CONF_POWER_PIN])) + for register_value in config.get(CONF_PHY_REGISTERS, []): + reg = phy_register( + register_value.get(CONF_ADDRESS), + register_value.get(CONF_VALUE), + register_value.get(CONF_PAGE_ID), + ) + cg.add(var.add_phy_register(reg)) else: cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 7d06377f90..e0fe920ea1 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -86,6 +86,8 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_ENC28J60, ETHERNET_TYPE_W6100, ETHERNET_TYPE_W6300, + ETHERNET_TYPE_GENERIC, + ETHERNET_TYPE_YT8531, }; struct ManualIP { @@ -229,6 +231,11 @@ class EthernetComponent final : public Component { #ifdef USE_ETHERNET_KSZ8081 /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); +#endif +#ifdef USE_ETHERNET_YT8531 + /// @brief Apply YT8531-specific config: re-enable auto-negotiation (disabled on + /// reset) and set the RGMII Tx/Rx clock delays needed for reliable data sampling. + void yt8531_phy_init_(); #endif /// @brief Set arbitratry PHY registers from config. void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 544ec79c32..7a1bcae42f 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -254,9 +254,14 @@ void EthernetComponent::ethernet_lazy_init_() { esp32_emac_config.smi_mdc_gpio_num = this->mdc_pin_; esp32_emac_config.smi_mdio_gpio_num = this->mdio_pin_; #endif - esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; - esp32_emac_config.clock_config.rmii.clock_gpio = - static_cast(this->clk_pin_); + // The RGMII types (GENERIC, YT8531) use the RGMII interface and default GPIO map from + // eth_esp32_emac_default_config(); writing the RMII clock config would clobber that + // union, so skip the RMII clock override for them. + if (this->type_ != ETHERNET_TYPE_GENERIC && this->type_ != ETHERNET_TYPE_YT8531) { + esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; + esp32_emac_config.clock_config.rmii.clock_gpio = + static_cast(this->clk_pin_); + } esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&esp32_emac_config, &mac_config); #endif @@ -319,6 +324,20 @@ void EthernetComponent::ethernet_lazy_init_() { break; } #endif +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // GENERIC and YT8531 both use the built-in generic 802.3 PHY driver; YT8531 gets + // extra chip-specific tuning applied later in ethernet_lazy_init_(). +#ifdef USE_ETHERNET_GENERIC + case ETHERNET_TYPE_GENERIC: +#endif +#ifdef USE_ETHERNET_YT8531 + case ETHERNET_TYPE_YT8531: +#endif +#if defined(USE_ETHERNET_GENERIC) || defined(USE_ETHERNET_YT8531) + this->phy_ = esp_eth_phy_new_generic(&phy_config); + break; +#endif +#endif #endif #ifdef USE_ETHERNET_SPI #if defined(USE_ETHERNET_W5500) @@ -363,7 +382,30 @@ void EthernetComponent::ethernet_lazy_init_() { for (const auto &phy_register : this->phy_registers_) { this->write_phy_register_(mac, phy_register); } + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#ifdef USE_ETHERNET_GENERIC + // The generic 802.3 PHY driver only resets the PHY in its init; it never enables + // auto-negotiation. A PHY that resets into a forced-speed mode (BMCR auto-nego bit + // clear) therefore stays there, and esp_eth_start() skips negotiation because the + // driver cached auto_nego_en=false at install time. Force auto-negotiation on here + // (which also updates that cached state) so esp_eth_start() restarts a proper + // negotiation. (YT8531 does this as part of its own chip-specific init below.) + if (this->type_ == ETHERNET_TYPE_GENERIC) { + bool autoneg_enable = true; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_AUTONEGO, &autoneg_enable); + ESPHL_ERROR_CHECK(err, "Enable auto-negotiation failed"); + } #endif +#ifdef USE_ETHERNET_YT8531 + if (this->type_ == ETHERNET_TYPE_YT8531) { + this->yt8531_phy_init_(); + if (this->is_failed()) + return; + } +#endif +#endif // ESP_IDF_VERSION >= 6.0.0 +#endif // !USE_ETHERNET_SPI // use ESP internal eth mac uint8_t mac_addr[6]; @@ -486,6 +528,16 @@ void EthernetComponent::dump_config() { eth_type = "LAN8670"; break; #endif +#ifdef USE_ETHERNET_GENERIC + case ETHERNET_TYPE_GENERIC: + eth_type = "Generic (RGMII)"; + break; +#endif +#ifdef USE_ETHERNET_YT8531 + case ETHERNET_TYPE_YT8531: + eth_type = "YT8531 (RGMII)"; + break; +#endif default: eth_type = "Unknown"; @@ -782,6 +834,19 @@ void EthernetComponent::dump_connect_params_() { char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + uint16_t link_speed = 10; + switch (this->get_link_speed()) { + case ETH_SPEED_100M: + link_speed = 100; + break; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + case ETH_SPEED_1000M: + link_speed = 1000; + break; +#endif + default: + break; + } ESP_LOGCONFIG(TAG, " IP Address: %s\n" " Hostname: '%s'\n" @@ -796,7 +861,7 @@ void EthernetComponent::dump_connect_params_() { network::IPAddress(&ip.netmask).str_to(subnet_buf), network::IPAddress(&ip.gw).str_to(gateway_buf), network::IPAddress(dns_ip1).str_to(dns1_buf), network::IPAddress(dns_ip2).str_to(dns2_buf), this->get_eth_mac_address_pretty_into_buffer(mac_buf), - YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), this->get_link_speed() == ETH_SPEED_100M ? 100 : 10); + YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), link_speed); #if USE_NETWORK_IPV6 struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; @@ -958,6 +1023,50 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi #endif } +#ifdef USE_ETHERNET_YT8531 +void EthernetComponent::yt8531_phy_init_() { + esp_err_t err; + + // The YT8531 disables auto-negotiation on hardware reset (undocumented behavior), and the + // generic 802.3 driver only resets the PHY, so re-enable it (this also updates the driver's + // cached auto-nego state used by esp_eth_start()). + bool autoneg_enable = true; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_AUTONEGO, &autoneg_enable); + ESPHL_ERROR_CHECK(err, "YT8531 enable auto-negotiation failed"); + + // RGMII needs ~2 ns Tx and Rx clock delays for reliable data sampling. These are set through + // the YT8531 extended-register interface: write the ext-register address to 0x1E, then + // read/modify/write its value via 0x1F. + esp_eth_phy_reg_rw_data_t phy_reg; + uint32_t reg_val; + phy_reg.reg_value_p = ®_val; + + // RX ~2 ns coarse delay: EXT_CHIP_CONFIG (0xA001), set rxc_dly_en (bit 8). + reg_val = 0xA001; + phy_reg.reg_addr = 0x1E; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 select Chip_Config failed"); + phy_reg.reg_addr = 0x1F; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_READ_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 read Chip_Config failed"); + reg_val |= (1U << 8); + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 write Chip_Config failed"); + + // TX ~2 ns delay: EXT_RGMII_CONFIG1 (0xA003), tx_delay_sel[3:0] and tx_delay_sel_fe[7:4] = 13. + reg_val = 0xA003; + phy_reg.reg_addr = 0x1E; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 select RGMII_Config1 failed"); + phy_reg.reg_addr = 0x1F; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_READ_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 read RGMII_Config1 failed"); + reg_val = (reg_val & ~0x00FFU) | (13U << 4) | (13U << 0); + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 write RGMII_Config1 failed"); +} +#endif + #endif } // namespace esphome::ethernet diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 17b5e64862..1c0138f9d1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -327,6 +327,8 @@ #define USE_ETHERNET_JL1101 #define USE_ETHERNET_KSZ8081 #define USE_ETHERNET_LAN8670 +#define USE_ETHERNET_GENERIC +#define USE_ETHERNET_YT8531 #define USE_ETHERNET_SPI #define USE_ETHERNET_SPI_POLLING_SUPPORT #define USE_ETHERNET_OPENETH From 8780c7e0ac26251d213ccf7232ab95b317f8f6c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:01:41 -0400 Subject: [PATCH 1901/2214] Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.2 (#17286) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 751241f563..73c93bc336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@5513791f75b039e2a79653b1a92238d3fb8d99b4 # v1.6.2 with: packages: libsdl2-dev ccache version: 1.1 From 797ed237655ec03165789d068ea630c7095ee617 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:02:11 -0400 Subject: [PATCH 1902/2214] Bump tzlocal from 5.4.3 to 5.4.4 (#17283) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 956f3633dc..9f485db38e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 -tzlocal==5.4.3 # from time +tzlocal==5.4.4 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 From 405607e9d29d408bc35e766450df679130c110c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:06:36 -0400 Subject: [PATCH 1903/2214] Bump esptool from 5.3.0 to 5.3.1 (#17284) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9f485db38e..d39d52caf4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 -esptool==5.3.0 +esptool==5.3.1 click==8.3.3 aioesphomeapi==45.5.2 zeroconf==0.150.0 From e308075e3fb027db56c39cdb61f2ab9499ce5120 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:12:23 -0400 Subject: [PATCH 1904/2214] Bump puremagic from 1.30 to 2.2.0 (#17285) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d39d52caf4..85f4b56c07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ esptool==5.3.1 click==8.3.3 aioesphomeapi==45.5.2 zeroconf==0.150.0 -puremagic==1.30 +puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 From 93eb6f78e0e651b5ff8ab54cdc86ebb043deb2a6 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:32:57 +0100 Subject: [PATCH 1905/2214] [network] Enlarge Zephyr net buffer pool and TCP windows on nRF52/Zephyr plataform (#17278) --- esphome/components/network/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 846c3afc59..d2683e4bba 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -227,6 +227,21 @@ async def to_code(config): # TCP links; Zephyr falls back to sys_rand32_get() for the ISN (randomized, but not the # RFC 6528 keyed hash). zephyr_add_prj_conf("NET_TCP_ISN_RFC6528", False) + # Enlarge the Zephyr network buffer pool and TCP windows for the Thread path. + # Zephyr's defaults are tiny: NET_BUF_TX_COUNT=16 * NET_BUF_DATA_SIZE=128 is only + # ~2 KB of TX data -- barely one 1280-byte IPv6 packet once 6LoWPAN fragments it. + # The ESPHome API entity-sync burst overruns that instantly, so socket writes fail + # with ENOBUFS ("Buffer full") and the connection is dropped. ESP32 sidesteps this + # by enlarging the lwIP TCP window (CONFIG_LWIP_TCP_* above); give Zephyr the + # equivalent headroom, sized to RAM and the Thread 1280-byte MTU (not ESP32's 64 KB). + # The bounded send window also provides flow control so TCP stops queueing past + # what the buffer pool can hold instead of erroring. + zephyr_add_prj_conf("NET_PKT_RX_COUNT", 24) + zephyr_add_prj_conf("NET_PKT_TX_COUNT", 24) + zephyr_add_prj_conf("NET_BUF_RX_COUNT", 48) + zephyr_add_prj_conf("NET_BUF_TX_COUNT", 48) + zephyr_add_prj_conf("NET_TCP_MAX_RECV_WINDOW_SIZE", 2280) + zephyr_add_prj_conf("NET_TCP_MAX_SEND_WINDOW_SIZE", 2280) if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) From b36e20d60b20777daed1fd7a256e0d1027dd01a3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:25:16 -0400 Subject: [PATCH 1906/2214] Revert "Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.2 (#17286)" (#17289) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c93bc336..4ac55aa006 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@5513791f75b039e2a79653b1a92238d3fb8d99b4 # v1.6.2 + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 with: packages: libsdl2-dev ccache version: 1.1 From 1611345c5520818b09738c6a1f900bb5a2867054 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:37:17 +1000 Subject: [PATCH 1907/2214] [agents] Add English language AI guidelines for documentation (#17290) --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 46caea3aec..9a01626ee4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -709,3 +709,9 @@ This document provides essential context for AI models interacting with this pro _LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0") config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate ``` +## 9. English Language + +The project uses English for non-code content. When drafting documentation, code comments, commit messages, +PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English, +using standard technical terms only when required. Ensure the text is readily comprehensible to a wide +audience, including non-native English speakers. From 5c7245dfcd5766cb737f28878ab2a7c857cc2ecd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:04:25 +1000 Subject: [PATCH 1908/2214] [qmi8658] Motion platform for QMI8658 IMU (#16889) --- CODEOWNERS | 1 + esphome/components/qmi8658/__init__.py | 13 ++ esphome/components/qmi8658/motion.py | 93 ++++++++++++ esphome/components/qmi8658/qmi8658.cpp | 136 ++++++++++++++++++ esphome/components/qmi8658/qmi8658.h | 112 +++++++++++++++ esphome/components/qmi8658/sensor.py | 39 +++++ tests/components/qmi8658/common.yaml | 69 +++++++++ tests/components/qmi8658/test.esp32-idf.yaml | 4 + .../components/qmi8658/test.esp8266-ard.yaml | 4 + 9 files changed, 471 insertions(+) create mode 100644 esphome/components/qmi8658/__init__.py create mode 100644 esphome/components/qmi8658/motion.py create mode 100644 esphome/components/qmi8658/qmi8658.cpp create mode 100644 esphome/components/qmi8658/qmi8658.h create mode 100644 esphome/components/qmi8658/sensor.py create mode 100644 tests/components/qmi8658/common.yaml create mode 100644 tests/components/qmi8658/test.esp32-idf.yaml create mode 100644 tests/components/qmi8658/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 21121ff476..8fc7d4a0a7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -405,6 +405,7 @@ esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz esphome/components/pylontech/* @functionpointer +esphome/components/qmi8658/* @clydebarrow esphome/components/qmp6988/* @andrewpc esphome/components/qr_code/* @wjtje esphome/components/qspi_dbi/* @clydebarrow diff --git a/esphome/components/qmi8658/__init__.py b/esphome/components/qmi8658/__init__.py new file mode 100644 index 0000000000..67838dbc3c --- /dev/null +++ b/esphome/components/qmi8658/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["i2c", "motion"] + +CONF_QMI8658_ID = "qmi8658_id" +# C++ namespace / class +qmi8658_ns = cg.esphome_ns.namespace("qmi8658") +QMI8658Component = qmi8658_ns.class_("QMI8658Component", MotionComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = {} diff --git a/esphome/components/qmi8658/motion.py b/esphome/components/qmi8658/motion.py new file mode 100644 index 0000000000..26169189c2 --- /dev/null +++ b/esphome/components/qmi8658/motion.py @@ -0,0 +1,93 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_GYROSCOPE_ODR, + CONF_GYROSCOPE_RANGE, +) +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv + +from . import QMI8658Component, qmi8658_ns + +# Enum proxies (must match the C++ enum values exactly) +QMI8658AccelRange = qmi8658_ns.enum("QMI8658AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_2G, + "4G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_4G, + "8G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_8G, + "16G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_16G, +} + +QMI8658GyroRange = qmi8658_ns.enum("QMI8658GyroRange") +GYRO_RANGE_OPTIONS = { + "16DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_16, + "32DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_32, + "64DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_64, + "128DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_128, + "256DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_256, + "512DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_512, + "1024DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_1024, + "2048DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_2048, +} + +QMI8658AccelODR = qmi8658_ns.enum("QMI8658AccelODR") +ACCEL_ODR_OPTIONS = { + "31_25HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_31_25, + "62_5HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_62_5, + "125HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_125, + "250HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_250, + "500HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_500, + "1000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_1000, + "2000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_2000, + "4000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_4000, + "8000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_8000, +} + +QMI8658GyroODR = qmi8658_ns.enum("QMI8658GyroODR") +GYRO_ODR_OPTIONS = { + "31_25HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_31_25, + "62_5HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_62_5, + "125HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_125, + "250HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_250, + "500HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_500, + "1000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_1000, + "2000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_2000, + "4000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_4000, + "8000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_8000, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(QMI8658Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="1000HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2048DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="1000HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x6B)) +) + + +# Code generation +async def to_code(config): + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + # Hardware configuration + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) + cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp new file mode 100644 index 0000000000..2fd457d290 --- /dev/null +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -0,0 +1,136 @@ +#include "qmi8658.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::qmi8658 { + +static const char *const TAG = "qmi8658"; + +// Acceleration scale (g per LSB), indexed by accel_range_ >> 4. +// Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts). +static constexpr float ACCEL_SCALE[] = { + 2.0f / 32768.0f, + 4.0f / 32768.0f, + 8.0f / 32768.0f, + 16.0f / 32768.0f, +}; + +// Angular rate scale (°/s per LSB), indexed by gyro_range_ >> 4. +static constexpr float GYRO_SCALE[] = { + 16.0f / 32768.0f, 32.0f / 32768.0f, 64.0f / 32768.0f, 128.0f / 32768.0f, + 256.0f / 32768.0f, 512.0f / 32768.0f, 1024.0f / 32768.0f, 2048.0f / 32768.0f, +}; + +void QMI8658Component::setup() { + MotionComponent::setup(); + + // 1. Verify chip ID + uint8_t who_am_i = 0; + if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) { + ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); + this->mark_failed(); + return; + } + if (who_am_i != QMI8658_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, QMI8658_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + // 2. Soft reset + if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) { + this->mark_failed(); + return; + } + delay(15); // spec: wait for reset to complete + + // 3. Serial interface: enable register address auto-increment + if (!this->write_byte(QMI8658_REG_CTRL1, QMI8658_CTRL1_VALUE)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL1")); + return; + } + + // 4. Configure accelerometer (CTRL2 = range | ODR) + if (!this->write_byte(QMI8658_REG_CTRL2, (uint8_t) (this->accel_range_) | (uint8_t) (this->accel_odr_))) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL2")); + return; + } + + // 5. Configure gyroscope (CTRL3 = range | ODR) + if (!this->write_byte(QMI8658_REG_CTRL3, (uint8_t) (this->gyro_range_) | (uint8_t) (this->gyro_odr_))) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL3")); + return; + } + + // 6. Disable the built-in low-pass filters (leave raw data to the motion pipeline) + if (!this->write_byte(QMI8658_REG_CTRL5, 0x00)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL5")); + this->mark_failed(); + return; + } + + // 7. Enable accelerometer and gyroscope + if (!this->write_byte(QMI8658_REG_CTRL7, QMI8658_CTRL7_ACC_EN | QMI8658_CTRL7_GYR_EN)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL7")); + return; + } + + ESP_LOGCONFIG(TAG, "QMI8658 initialised successfully"); +} + +void QMI8658Component::dump_config() { + ESP_LOGCONFIG(TAG, "QMI8658 IMU:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, " Communication failed!"); + return; + } + + static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; + static constexpr const char *const GYRO_RANGE_STRS[] = {"±16°/s", "±32°/s", "±64°/s", "±128°/s", + "±256°/s", "±512°/s", "±1024°/s", "±2048°/s"}; + + ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[this->accel_range_ >> 4]); + ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[this->gyro_range_ >> 4]); + MotionComponent::dump_config(); +} + +bool QMI8658Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Read temperature + accel + gyro in one contiguous block starting at TEMP_L. + uint8_t raw_data[REG_READ_LEN]; + if (!this->read_bytes(QMI8658_REG_TEMP_L, raw_data, REG_READ_LEN)) { + ESP_LOGW(TAG, "Failed to read IMU data"); + return false; + } + + // Data is little-endian (low byte first). + float scale = ACCEL_SCALE[this->accel_range_ >> 4]; + int16_t raw_x = encode_uint16(raw_data[ACC_OFFS + 1], raw_data[ACC_OFFS + 0]); + int16_t raw_y = encode_uint16(raw_data[ACC_OFFS + 3], raw_data[ACC_OFFS + 2]); + int16_t raw_z = encode_uint16(raw_data[ACC_OFFS + 5], raw_data[ACC_OFFS + 4]); + ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); + data.acceleration[motion::X_AXIS] = raw_x * scale; + data.acceleration[motion::Y_AXIS] = raw_y * scale; + data.acceleration[motion::Z_AXIS] = raw_z * scale; + + scale = GYRO_SCALE[this->gyro_range_ >> 4]; + raw_x = encode_uint16(raw_data[GYR_OFFS + 1], raw_data[GYR_OFFS + 0]); + raw_y = encode_uint16(raw_data[GYR_OFFS + 3], raw_data[GYR_OFFS + 2]); + raw_z = encode_uint16(raw_data[GYR_OFFS + 5], raw_data[GYR_OFFS + 4]); + ESP_LOGV(TAG, "Read raw gyro data: %d, %d, %d", raw_x, raw_y, raw_z); + data.angular_rate[motion::X_AXIS] = raw_x * scale; + data.angular_rate[motion::Y_AXIS] = raw_y * scale; + data.angular_rate[motion::Z_AXIS] = raw_z * scale; + + if (this->temperature_callback_.empty()) + return true; + // Temperature: signed 16-bit, °C = raw / 256 + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + this->temperature_callback_.call(raw_t / 256.0f); + return true; +} + +} // namespace esphome::qmi8658 diff --git a/esphome/components/qmi8658/qmi8658.h b/esphome/components/qmi8658/qmi8658.h new file mode 100644 index 0000000000..ce31a2b7a9 --- /dev/null +++ b/esphome/components/qmi8658/qmi8658.h @@ -0,0 +1,112 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::qmi8658 { + +// Register map +static constexpr uint8_t QMI8658_REG_WHO_AM_I = 0x00; +static constexpr uint8_t QMI8658_REG_REVISION = 0x01; +static constexpr uint8_t QMI8658_REG_CTRL1 = 0x02; // serial interface / auto-increment +static constexpr uint8_t QMI8658_REG_CTRL2 = 0x03; // accelerometer ODR / range +static constexpr uint8_t QMI8658_REG_CTRL3 = 0x04; // gyroscope ODR / range +static constexpr uint8_t QMI8658_REG_CTRL5 = 0x06; // low-pass filter +static constexpr uint8_t QMI8658_REG_CTRL7 = 0x08; // sensor enable +static constexpr uint8_t QMI8658_REG_STATUS0 = 0x2E; +static constexpr uint8_t QMI8658_REG_TEMP_BASE = 0x33; // start of the data block +static constexpr uint8_t QMI8658_REG_TEMP_L = 0x33; // Low byte of temperature +static constexpr uint8_t QMI8658_REG_AX_L = 0x35; +static constexpr uint8_t QMI8658_REG_GX_L = 0x3B; +static constexpr uint8_t QMI8658_REG_RESET = 0x60; + +// One contiguous read covers temperature (2) + accel (6) + gyro (6) starting at TEMP_L. +static constexpr uint8_t REG_READ_LEN = QMI8658_REG_GX_L + 6 - QMI8658_REG_TEMP_BASE; // 0x41 - 0x33 = 14 +static constexpr uint8_t TEMP_OFFS = QMI8658_REG_TEMP_L - QMI8658_REG_TEMP_BASE; // 0 +static constexpr uint8_t ACC_OFFS = QMI8658_REG_AX_L - QMI8658_REG_TEMP_BASE; // 2 +static constexpr uint8_t GYR_OFFS = QMI8658_REG_GX_L - QMI8658_REG_TEMP_BASE; // 8 + +static constexpr uint8_t QMI8658_WHO_AM_I_VALUE = 0x05; +static constexpr uint8_t QMI8658_RESET_CMD = 0xB0; +// CTRL1: bit6 ADDR_AI (register address auto-increment); little-endian, 4-wire SPI +static constexpr uint8_t QMI8658_CTRL1_VALUE = 0x40; +// CTRL7: aEN (bit0) | gEN (bit1) +static constexpr uint8_t QMI8658_CTRL7_ACC_EN = 0x01; +static constexpr uint8_t QMI8658_CTRL7_GYR_EN = 0x02; + +// Accelerometer range options (CTRL2 bits 6:4) +enum QMI8658AccelRange : uint8_t { + QMI8658_ACCEL_RANGE_2G = 0x00, + QMI8658_ACCEL_RANGE_4G = 0x10, + QMI8658_ACCEL_RANGE_8G = 0x20, + QMI8658_ACCEL_RANGE_16G = 0x30, +}; + +// Accelerometer ODR options (CTRL2 bits 3:0) +enum QMI8658AccelODR : uint8_t { + QMI8658_ACCEL_ODR_8000 = 0x00, + QMI8658_ACCEL_ODR_4000 = 0x01, + QMI8658_ACCEL_ODR_2000 = 0x02, + QMI8658_ACCEL_ODR_1000 = 0x03, + QMI8658_ACCEL_ODR_500 = 0x04, + QMI8658_ACCEL_ODR_250 = 0x05, + QMI8658_ACCEL_ODR_125 = 0x06, + QMI8658_ACCEL_ODR_62_5 = 0x07, + QMI8658_ACCEL_ODR_31_25 = 0x08, +}; + +// Gyroscope range options (CTRL3 bits 6:4) +enum QMI8658GyroRange : uint8_t { + QMI8658_GYRO_RANGE_16 = 0x00, + QMI8658_GYRO_RANGE_32 = 0x10, + QMI8658_GYRO_RANGE_64 = 0x20, + QMI8658_GYRO_RANGE_128 = 0x30, + QMI8658_GYRO_RANGE_256 = 0x40, + QMI8658_GYRO_RANGE_512 = 0x50, + QMI8658_GYRO_RANGE_1024 = 0x60, + QMI8658_GYRO_RANGE_2048 = 0x70, +}; + +// Gyroscope ODR options (CTRL3 bits 3:0) +enum QMI8658GyroODR : uint8_t { + QMI8658_GYRO_ODR_8000 = 0x00, + QMI8658_GYRO_ODR_4000 = 0x01, + QMI8658_GYRO_ODR_2000 = 0x02, + QMI8658_GYRO_ODR_1000 = 0x03, + QMI8658_GYRO_ODR_500 = 0x04, + QMI8658_GYRO_ODR_250 = 0x05, + QMI8658_GYRO_ODR_125 = 0x06, + QMI8658_GYRO_ODR_62_5 = 0x07, + QMI8658_GYRO_ODR_31_25 = 0x08, +}; + +// Main component class +class QMI8658Component : public motion::MotionComponent, public i2c::I2CDevice { + public: + // Lifecycle + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters + void set_accel_range(QMI8658AccelRange r) { this->accel_range_ = r; } + void set_accel_odr(QMI8658AccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(QMI8658GyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(QMI8658GyroODR o) { this->gyro_odr_ = o; } + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + + // Config + QMI8658AccelRange accel_range_{QMI8658_ACCEL_RANGE_4G}; + QMI8658AccelODR accel_odr_{QMI8658_ACCEL_ODR_1000}; + QMI8658GyroRange gyro_range_{QMI8658_GYRO_RANGE_2048}; + QMI8658GyroODR gyro_odr_{QMI8658_GYRO_ODR_1000}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::qmi8658 diff --git a/esphome/components/qmi8658/sensor.py b/esphome/components/qmi8658/sensor.py new file mode 100644 index 0000000000..80b0512361 --- /dev/null +++ b/esphome/components/qmi8658/sensor.py @@ -0,0 +1,39 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj + +from . import CONF_QMI8658_ID, QMI8658Component + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_QMI8658_ID): cv.use_id(QMI8658Component), + } +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_QMI8658_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/components/qmi8658/common.yaml b/tests/components/qmi8658/common.yaml new file mode 100644 index 0000000000..cfb0f3e129 --- /dev/null +++ b/tests/components/qmi8658/common.yaml @@ -0,0 +1,69 @@ +sensor: + - platform: qmi8658 + name: "QMI8658 Temperature" + + - platform: motion + type: acceleration_x + name: "Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + type: acceleration_y + name: "Accel Y" + accuracy_decimals: 4 + - platform: motion + type: acceleration_z + name: "Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + type: gyroscope_x + name: "Gyro X" + - platform: motion + type: gyroscope_y + name: "Gyro Y" + - platform: motion + type: gyroscope_z + name: "Gyro Z" + + - platform: motion + type: angular_rate_x + name: "Angular Rate X" + - platform: motion + type: angular_rate_y + name: "Angular Rate Y" + - platform: motion + type: angular_rate_z + name: "Angular Rate Z" + + - platform: motion + type: pitch + name: "Pitch" + - platform: motion + type: roll + name: "Roll" + +motion: + - platform: qmi8658 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + + # Accelerometer output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ | + # 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ + accelerometer_odr: 1000HZ + + # Gyroscope full-scale range: 16DPS | 32DPS | 64DPS | 128DPS | + # 256DPS | 512DPS | 1024DPS | 2048DPS + gyroscope_range: 2048DPS + + # Gyroscope output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ | + # 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ + gyroscope_odr: 1000HZ + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/qmi8658/test.esp32-idf.yaml b/tests/components/qmi8658/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/qmi8658/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/qmi8658/test.esp8266-ard.yaml b/tests/components/qmi8658/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/qmi8658/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml From 3e1a6b4e11c9783139a85a27bc900b4ec649d734 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:18:01 +1000 Subject: [PATCH 1909/2214] [cst9220] Add CST9220 and CST9217 touchscreen support (#16888) --- CODEOWNERS | 1 + esphome/components/cst9220/__init__.py | 6 + .../cst9220/touchscreen/__init__.py | 36 +++++ .../touchscreen/cst9220_touchscreen.cpp | 141 ++++++++++++++++++ .../cst9220/touchscreen/cst9220_touchscreen.h | 50 +++++++ tests/components/cst9220/common.yaml | 16 ++ tests/components/cst9220/test.esp32-idf.yaml | 12 ++ 7 files changed, 262 insertions(+) create mode 100644 esphome/components/cst9220/__init__.py create mode 100644 esphome/components/cst9220/touchscreen/__init__.py create mode 100644 esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp create mode 100644 esphome/components/cst9220/touchscreen/cst9220_touchscreen.h create mode 100644 tests/components/cst9220/common.yaml create mode 100644 tests/components/cst9220/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8fc7d4a0a7..467b1b7326 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -123,6 +123,7 @@ esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger esphome/components/cst226/* @clydebarrow esphome/components/cst816/* @clydebarrow +esphome/components/cst9220/* @clydebarrow esphome/components/ct_clamp/* @jesserockz esphome/components/current_based/* @djwmarcx esphome/components/dac7678/* @NickB1 diff --git a/esphome/components/cst9220/__init__.py b/esphome/components/cst9220/__init__.py new file mode 100644 index 0000000000..f97c8944ef --- /dev/null +++ b/esphome/components/cst9220/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["i2c"] + +cst9220_ns = cg.esphome_ns.namespace("cst9220") diff --git a/esphome/components/cst9220/touchscreen/__init__.py b/esphome/components/cst9220/touchscreen/__init__.py new file mode 100644 index 0000000000..6d8fc5e2f6 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/__init__.py @@ -0,0 +1,36 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import cst9220_ns + +CST9220Touchscreen = cst9220_ns.class_( + "CST9220Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema("100ms") + .extend( + { + cv.GenerateID(): cv.declare_id(CST9220Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(i2c.i2c_device_schema(0x5A)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp new file mode 100644 index 0000000000..366b1846d7 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -0,0 +1,141 @@ +#include "cst9220_touchscreen.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::cst9220 { + +void CST9220Touchscreen::setup() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(5); + this->reset_pin_->digital_write(false); + delay(10); + this->reset_pin_->digital_write(true); + } + // Wait for the controller to leave its bootloader before talking to it. + this->set_timeout(30, [this] { this->continue_setup_(); }); +} + +void CST9220Touchscreen::continue_setup_() { + uint8_t buffer[4]; + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + // Enter command mode so the configuration registers can be read. + if (this->write_register16(REG_CMD_MODE, buffer, 0) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to enter command mode")); + this->mark_failed(); + return; + } + delay(10); + + // The firmware check code confirms that valid firmware is loaded. + if (this->read_register16(REG_CHECKCODE, buffer, 4) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to read check code")); + this->mark_failed(); + return; + } + uint32_t checkcode = encode_uint32(buffer[3], buffer[2], buffer[1], buffer[0]); + if ((checkcode & 0xFFFF0000) != 0xCACA0000) { + ESP_LOGE(TAG, "Invalid firmware check code: 0x%08" PRIX32, checkcode); + this->status_set_error(LOG_STR("Invalid firmware check code")); + this->mark_failed(); + return; + } + + // Read the panel resolution unless the user supplied calibration values. + if (this->read_register16(REG_RESOLUTION, buffer, 4) == i2c::ERROR_OK) { + if (this->x_raw_max_ == this->x_raw_min_) + this->x_raw_max_ = encode_uint16(buffer[1], buffer[0]); + if (this->y_raw_max_ == this->y_raw_min_) + this->y_raw_max_ = encode_uint16(buffer[3], buffer[2]); + } + + // Read the chip type and project id and validate the controller. + if (this->read_register16(REG_CHIP_INFO, buffer, 4) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to read chip ID")); + this->mark_failed(); + return; + } + this->chip_id_ = encode_uint16(buffer[3], buffer[2]); + this->project_id_ = encode_uint16(buffer[1], buffer[0]); + if (this->chip_id_ != CST9220_CHIP_ID && this->chip_id_ != CST9217_CHIP_ID) { + ESP_LOGE(TAG, "Unknown chip ID: 0x%04X", this->chip_id_); + this->status_set_error(LOG_STR("Unknown chip ID")); + this->mark_failed(); + return; + } + + // Fall back to the display dimensions if the resolution read failed. + if (this->x_raw_max_ == this->x_raw_min_) + this->x_raw_max_ = this->display_->get_native_width(); + if (this->y_raw_max_ == this->y_raw_min_) + this->y_raw_max_ = this->display_->get_native_height(); + + this->setup_complete_ = true; +} + +void CST9220Touchscreen::update_touches() { + if (!this->setup_complete_) + return; + uint8_t data[CST9220_DATA_LENGTH]; + // Only an actual I2C failure should skip the update; a successful read with no + // touches is a real "all fingers lifted" state that must flow through so the + // base class can generate the release event. + if (this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + return; + } + this->status_clear_warning(); + + // Acknowledge the report so the controller can prepare the next one. + uint8_t ack = TOUCH_ACK; + this->write_register16(REG_TOUCH_DATA, &ack, 1); + + // A valid report carries the ACK marker at offset 6; offset 0 holds the first + // point and must be neither the ACK marker nor empty. Anything else means no + // valid touch data this cycle, which we report as zero touches (not a skip). + if (data[0] == TOUCH_ACK || data[0] == 0x00 || data[6] != TOUCH_ACK) + return; + + uint8_t num_touches = data[5] & 0x7F; + if (num_touches > CST9220_MAX_TOUCHES) + num_touches = CST9220_MAX_TOUCHES; + + for (uint8_t i = 0; i < num_touches; i++) { + // The first point starts at offset 0; subsequent points are offset by the + // two status bytes that follow it. + const uint8_t *p = data + i * 5 + (i == 0 ? 0 : 2); + uint8_t id = p[0] >> 4; + uint8_t event = p[0] & 0x0F; + if (event != TOUCH_EVENT_DOWN) + continue; + // p[3] is shared: high nibble holds the X LSBs, low nibble the Y LSBs. + uint16_t x = (p[1] << 4) | (p[3] >> 4); + uint16_t y = (p[2] << 4) | (p[3] & 0x0F); + ESP_LOGV(TAG, "Read touch %d: %d/%d", id, x, y); + this->add_raw_touch_position_(id, x, y); + } +} + +void CST9220Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "CST9220 Touchscreen:\n" + " Chip ID: 0x%04X\n" + " Project ID: 0x%04X\n" + " X Raw Min: %d, X Raw Max: %d\n" + " Y Raw Min: %d, Y Raw Max: %d", + this->chip_id_, this->project_id_, this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, + this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::cst9220 diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h new file mode 100644 index 0000000000..17050e2429 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::cst9220 { + +static const char *const TAG = "cst9220.touchscreen"; + +// The CST92xx family uses 16-bit (big-endian) register addresses. +static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report +static const uint16_t REG_CMD_MODE = 0xD101; // enter command mode +static const uint16_t REG_CHECKCODE = 0xD1FC; // firmware check code +static const uint16_t REG_RESOLUTION = 0xD1F8; // panel resolution +static const uint16_t REG_CHIP_INFO = 0xD204; // chip type + project id + +static const uint8_t TOUCH_ACK = 0xAB; +static const uint8_t TOUCH_EVENT_DOWN = 0x06; + +static const uint16_t CST9220_CHIP_ID = 0x9220; +static const uint16_t CST9217_CHIP_ID = 0x9217; + +// Maximum simultaneous touch points reported by the family. +static const uint8_t CST9220_MAX_TOUCHES = 5; +// Report layout: 5 bytes per touch point plus 5 bytes of status/ack overhead. +static const size_t CST9220_DATA_LENGTH = CST9220_MAX_TOUCHES * 5 + 5; + +class CST9220Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + void continue_setup_(); + + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{}; + uint16_t chip_id_{}; + uint16_t project_id_{}; + bool setup_complete_{}; +}; + +} // namespace esphome::cst9220 diff --git a/tests/components/cst9220/common.yaml b/tests/components/cst9220/common.yaml new file mode 100644 index 0000000000..99e14f47ae --- /dev/null +++ b/tests/components/cst9220/common.yaml @@ -0,0 +1,16 @@ +display: + - id: cst9220_display + platform: ili9xxx + model: ili9342 + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + reset_pin: ${disp_reset_pin} + invert_colors: false + +touchscreen: + - id: ts_cst9220 + i2c_id: i2c_bus + platform: cst9220 + display: cst9220_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} diff --git a/tests/components/cst9220/test.esp32-idf.yaml b/tests/components/cst9220/test.esp32-idf.yaml new file mode 100644 index 0000000000..984f08db47 --- /dev/null +++ b/tests/components/cst9220/test.esp32-idf.yaml @@ -0,0 +1,12 @@ +substitutions: + cs_pin: GPIO4 + dc_pin: GPIO5 + disp_reset_pin: GPIO12 + interrupt_pin: GPIO15 + reset_pin: GPIO25 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml From cf9d97d5ae3c6967647723bbfd51da21de7b2328 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:20:48 +1200 Subject: [PATCH 1910/2214] [pixoo] Add Divoom Pixoo display component (#16974) --- CODEOWNERS | 1 + esphome/components/pixoo/__init__.py | 1 + esphome/components/pixoo/display.py | 43 ++++ esphome/components/pixoo/light/__init__.py | 24 +++ esphome/components/pixoo/light/pixoo_light.h | 26 +++ esphome/components/pixoo/pixoo.cpp | 201 +++++++++++++++++++ esphome/components/pixoo/pixoo.h | 64 ++++++ tests/components/pixoo/common.yaml | 26 +++ tests/components/pixoo/test.esp32-idf.yaml | 4 + 9 files changed, 390 insertions(+) create mode 100644 esphome/components/pixoo/__init__.py create mode 100644 esphome/components/pixoo/display.py create mode 100644 esphome/components/pixoo/light/__init__.py create mode 100644 esphome/components/pixoo/light/pixoo_light.h create mode 100644 esphome/components/pixoo/pixoo.cpp create mode 100644 esphome/components/pixoo/pixoo.h create mode 100644 tests/components/pixoo/common.yaml create mode 100644 tests/components/pixoo/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 467b1b7326..d2c92f44ce 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -387,6 +387,7 @@ esphome/components/pcm5122/* @remcom esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 +esphome/components/pixoo/* @jesserockz esphome/components/pm1006/* @habbie esphome/components/pm2005/* @andrewjswan esphome/components/pmsa003i/* @sjtrny diff --git a/esphome/components/pixoo/__init__.py b/esphome/components/pixoo/__init__.py new file mode 100644 index 0000000000..b1de57df8f --- /dev/null +++ b/esphome/components/pixoo/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@jesserockz"] diff --git a/esphome/components/pixoo/display.py b/esphome/components/pixoo/display.py new file mode 100644 index 0000000000..764f06d603 --- /dev/null +++ b/esphome/components/pixoo/display.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import display, spi +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_LAMBDA, CONF_MODEL +from esphome.types import ConfigType + +DEPENDENCIES = ["spi"] +AUTO_LOAD = ["split_buffer"] + +CONF_PIXOO_ID = "pixoo_id" + +pixoo_ns = cg.esphome_ns.namespace("pixoo") +Pixoo = pixoo_ns.class_("Pixoo", cg.PollingComponent, display.Display, spi.SPIDevice) +PixooModel = pixoo_ns.enum("PixooModel") + +# Only the 64x64 panel is hardware-verified. Smaller Pixoo panels are assumed to share the +# same protocol; add them here once confirmed. +MODELS = { + "64X64": PixooModel.PIXOO_64, +} + +CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(Pixoo), + cv.Optional(CONF_MODEL, default="64X64"): cv.enum(MODELS, upper=True), + } +).extend(spi.spi_device_schema(cs_pin_required=True, default_data_rate=8e6)) + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "pixoo", require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_MODEL]) + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=True) + + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/pixoo/light/__init__.py b/esphome/components/pixoo/light/__init__.py new file mode 100644 index 0000000000..7151cdde0b --- /dev/null +++ b/esphome/components/pixoo/light/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID +from esphome.types import ConfigType + +from ..display import CONF_PIXOO_ID, Pixoo, pixoo_ns + +PixooLight = pixoo_ns.class_("PixooLight", light.LightOutput) + +CONFIG_SCHEMA = light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(PixooLight), + cv.GenerateID(CONF_PIXOO_ID): cv.use_id(Pixoo), + # The LED board applies its own gamma, so default to no gamma correction here. + cv.Optional(CONF_GAMMA_CORRECT, default=0.0): cv.positive_float, + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await light.register_light(var, config) + await cg.register_parented(var, config[CONF_PIXOO_ID]) diff --git a/esphome/components/pixoo/light/pixoo_light.h b/esphome/components/pixoo/light/pixoo_light.h new file mode 100644 index 0000000000..67f3cd5024 --- /dev/null +++ b/esphome/components/pixoo/light/pixoo_light.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" +#include "esphome/components/pixoo/pixoo.h" +#include "esphome/core/helpers.h" + +namespace esphome::pixoo { + +// Brightness-only light that drives the Pixoo panel's LIGHT command. +class PixooLight : public light::LightOutput, public Parented { + public: + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; + } + + void write_state(light::LightState *state) override { + float brightness; + state->current_values_as_brightness(&brightness); + this->parent_->set_panel_brightness(brightness); + } +}; + +} // namespace esphome::pixoo diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp new file mode 100644 index 0000000000..4436b1fb17 --- /dev/null +++ b/esphome/components/pixoo/pixoo.cpp @@ -0,0 +1,201 @@ +#include "pixoo.h" + +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::pixoo { + +static const char *const TAG = "pixoo"; + +// Divoom LED-board packet protocol. +static constexpr uint8_t PACKET_HEAD = 0xAA; +static constexpr uint8_t PACKET_TAIL = 0xBB; +static constexpr uint8_t CMD_DATA = 0x00; +static constexpr uint8_t CMD_LIGHT = 0x01; +static constexpr uint8_t CMD_UNUSED = 0x21; +static constexpr uint8_t CMD_SET_RGB_IOUT = 0x22; +static constexpr size_t PACKET_HEADER_LEN = 4; // head + len(2) + cmd +static constexpr size_t PACKET_STATIC_LEN = 5; // header + tail +static constexpr uint8_t DEFAULT_IOUT = 75; // per-channel LED current / white balance default + +// Pack a `0xAA len cmd data 0xBB` packet into buf; returns the packet length. +static inline size_t build_packet(uint8_t *buf, uint8_t cmd, const uint8_t *data, uint16_t len) { + buf[0] = PACKET_HEAD; + buf[1] = static_cast(len & 0xFF); + buf[2] = static_cast((len >> 8) & 0xFF); + buf[3] = cmd; + if (data != nullptr && len > 0) + std::memcpy(buf + PACKET_HEADER_LEN, data, len); + buf[PACKET_HEADER_LEN + len] = PACKET_TAIL; + return len + PACKET_STATIC_LEN; +} + +// Fill `total` bytes at buf with a single UNUSED padding packet. +static inline void pad_unused(uint8_t *buf, size_t total) { + const uint16_t len = static_cast(total - PACKET_STATIC_LEN); + buf[0] = PACKET_HEAD; + buf[1] = static_cast(len & 0xFF); + buf[2] = static_cast((len >> 8) & 0xFF); + buf[3] = CMD_UNUSED; + buf[total - 1] = PACKET_TAIL; +} + +float Pixoo::get_setup_priority() const { return setup_priority::PROCESSOR; } + +void Pixoo::setup() { + const uint32_t num_pixels = static_cast(this->model_) * this->model_; + this->data_size_ = num_pixels * 3; + // The frame is a DATA packet (header + RGB888 + tail) followed by a DMA-chunk-sized UNUSED + // packet, so the LED board completes its final DMA block. + this->frame_size_ = this->data_size_ + PACKET_STATIC_LEN + DMA_CHUNK; + + if (!this->buffer_.init(this->data_size_)) { + this->mark_failed(LOG_STR("Failed to allocate draw buffer")); + return; + } + + // The frame is shipped in one SPI transfer, so keep it in DMA-capable internal RAM. + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->frame_buffer_ = allocator.allocate(this->frame_size_); + if (this->frame_buffer_ == nullptr) { + this->buffer_.free(); + this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + return; + } + std::memset(this->frame_buffer_, 0, this->frame_size_); + // Pre-build the constant DATA-packet framing; only the RGB888 payload changes per frame. + this->frame_buffer_[0] = PACKET_HEAD; + this->frame_buffer_[1] = static_cast(this->data_size_ & 0xFF); + this->frame_buffer_[2] = static_cast((this->data_size_ >> 8) & 0xFF); + this->frame_buffer_[3] = CMD_DATA; + this->frame_buffer_[PACKET_HEADER_LEN + this->data_size_] = PACKET_TAIL; + pad_unused(this->frame_buffer_ + this->data_size_ + PACKET_STATIC_LEN, DMA_CHUNK); + + this->spi_setup(); + + this->buffer_.fill(0x00); + + // Set the per-channel LED current. Brightness is controlled separately via the light platform. + const uint8_t iout[3] = {DEFAULT_IOUT, DEFAULT_IOUT, DEFAULT_IOUT}; + this->send_command_(CMD_SET_RGB_IOUT, iout, 3); + + // Frames are pushed synchronously inside update(), so there is no loop() work to do and the + // component is idle between updates. Marking it done (LOOP_DONE) lets LVGL's + // update_when_display_idle option treat the panel as idle and drive frames on demand. + this->disable_loop(); +} + +void Pixoo::send_command_(uint8_t cmd, const uint8_t *data, uint16_t len) { + std::memset(this->cmd_buffer_, 0, DMA_CHUNK); + const size_t used = build_packet(this->cmd_buffer_, cmd, data, len); + if (DMA_CHUNK - used >= PACKET_STATIC_LEN) + pad_unused(this->cmd_buffer_ + used, DMA_CHUNK - used); + this->enable(); + this->write_array(this->cmd_buffer_, DMA_CHUNK); + this->disable(); +} + +void Pixoo::set_panel_brightness(float brightness) { + const uint8_t pct = static_cast(lroundf(clamp(brightness, 0.0f, 1.0f) * 100.0f)); + this->send_command_(CMD_LIGHT, &pct, 1); +} + +void Pixoo::update() { + this->do_update_(); + for (size_t i = 0; i < this->data_size_; i++) + this->frame_buffer_[PACKET_HEADER_LEN + i] = this->buffer_[i]; + this->enable(); + this->write_array(this->frame_buffer_, this->frame_size_); + this->disable(); +} + +void Pixoo::set_pixel_(uint32_t index, Color color) { + const size_t off = static_cast(index) * 3; + this->buffer_[off] = color.r; + this->buffer_[off + 1] = color.g; + this->buffer_[off + 2] = color.b; +} + +void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { + if (!this->get_clipping().inside(x, y)) + return; + const int side = static_cast(this->model_); + switch (this->rotation_) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = side - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = side - x - 1; + y = side - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = side - y - 1; + break; + } + if (x < 0 || x >= side || y < 0 || y >= side) + return; + this->set_pixel_(static_cast(y) * side + x, color); +} + +void Pixoo::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // Fast path for the common LVGL/image blit: RGB565, RGB order, no rotation, no active clipping. + // Anything else defers to the base implementation, which decodes per pixel and routes through + // draw_pixel_at() so rotation, clipping and other color formats stay correct. + // NOTE: the stride/index math and 565->888 expansion below mirror Display::draw_pixels_at (the + // source of truth) -- keep them in sync if the base ever changes its source layout or decoding. + if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; + } + const int side = static_cast(this->model_); + const size_t line_stride = static_cast(x_offset) + w + x_pad; + for (int y = 0; y != h; y++) { + const int dst_y = y_start + y; + if (dst_y < 0 || dst_y >= side) + continue; + size_t source_idx = (static_cast(y_offset) + y) * line_stride + x_offset; + for (int x = 0; x != w; x++, source_idx++) { + const int dst_x = x_start + x; + if (dst_x < 0 || dst_x >= side) + continue; + const size_t byte_idx = source_idx * 2; + const uint16_t rgb565 = + big_endian ? (ptr[byte_idx] << 8) | ptr[byte_idx + 1] : ptr[byte_idx] | (ptr[byte_idx + 1] << 8); + const uint8_t r5 = (rgb565 >> 11) & 0x1F; + const uint8_t g6 = (rgb565 >> 5) & 0x3F; + const uint8_t b5 = rgb565 & 0x1F; + this->set_pixel_(static_cast(dst_y) * side + dst_x, + Color((r5 << 3) | (r5 >> 2), (g6 << 2) | (g6 >> 4), (b5 << 3) | (b5 >> 2))); + } + } +} + +void Pixoo::fill(Color color) { + if (this->is_clipping()) { + display::Display::fill(color); + return; + } + for (size_t i = 0; i < this->data_size_; i += 3) { + this->buffer_[i] = color.r; + this->buffer_[i + 1] = color.g; + this->buffer_[i + 2] = color.b; + } +} + +void Pixoo::dump_config() { + LOG_DISPLAY("", "Divoom Pixoo", this); + ESP_LOGCONFIG(TAG, " Model: %ux%u", (unsigned) this->model_, (unsigned) this->model_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::pixoo diff --git a/esphome/components/pixoo/pixoo.h b/esphome/components/pixoo/pixoo.h new file mode 100644 index 0000000000..4913ef85db --- /dev/null +++ b/esphome/components/pixoo/pixoo.h @@ -0,0 +1,64 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include "esphome/components/spi/spi.h" +#include "esphome/components/split_buffer/split_buffer.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::pixoo { + +// The Pixoo's main board (where ESPHome runs) talks to a separate LED-driver board (a GD32/AT32 +// MCU) over SPI using Divoom's packet protocol: +// 0xAA, len_lo, len_hi, cmd, , 0xBB +// The image is sent as a DATA (0x00) packet carrying width*height*3 bytes of RGB888; brightness is +// a separate LIGHT (0x01) command; the LED current is set once via SET_RGB_IOUT (0x22). Command +// packets are padded out to the LED board's 240-byte DMA chunk with an UNUSED (0x21) packet. +// The model selects the (square) panel side length. +enum PixooModel : uint8_t { + PIXOO_64 = 64, +}; + +class Pixoo : public display::Display, + public spi::SPIDevice { + public: + explicit Pixoo(PixooModel model) : model_(model) {} + + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override; + + // Brightness is controlled exclusively via the light platform: send a LIGHT command to the LED + // board (brightness 0..1 -> 0..100%). + void set_panel_brightness(float brightness); + + display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; } + + void fill(Color color) override; + void draw_pixel_at(int x, int y, Color color) override; + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + + protected: + int get_width_internal() override { return static_cast(this->model_); } + int get_height_internal() override { return static_cast(this->model_); } + + void set_pixel_(uint32_t index, Color color); + void send_command_(uint8_t cmd, const uint8_t *data, uint16_t len); + + // Size of the LED board's SPI DMA chunk; the command scratch buffer is one chunk. + static constexpr size_t DMA_CHUNK = 240; + + PixooModel model_; + + size_t data_size_{0}; // RGB888 image bytes: model^2 * 3 + size_t frame_size_{0}; // full SPI frame: DATA packet + trailing UNUSED packet + + split_buffer::SplitBuffer buffer_{}; + uint8_t *frame_buffer_{nullptr}; + uint8_t cmd_buffer_[DMA_CHUNK]{}; +}; + +} // namespace esphome::pixoo diff --git a/tests/components/pixoo/common.yaml b/tests/components/pixoo/common.yaml new file mode 100644 index 0000000000..e854ce8863 --- /dev/null +++ b/tests/components/pixoo/common.yaml @@ -0,0 +1,26 @@ +display: + - platform: pixoo + id: pixoo_display + model: 64x64 + cs_pin: GPIO5 + data_rate: 10MHz + update_interval: 1s + lambda: |- + it.fill(Color(0, 0, 0)); + it.filled_rectangle(0, 0, 16, 16, Color(255, 0, 0)); + it.line(0, 0, 63, 63, Color(0, 255, 0)); + + - platform: pixoo + id: pixoo_display_pages + model: 64x64 + cs_pin: GPIO21 + rotation: 90 + pages: + - id: pixoo_page + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height(), Color(0, 0, 255)); + +light: + - platform: pixoo + pixoo_id: pixoo_display + name: Pixoo Brightness diff --git a/tests/components/pixoo/test.esp32-idf.yaml b/tests/components/pixoo/test.esp32-idf.yaml new file mode 100644 index 0000000000..a8e18ca503 --- /dev/null +++ b/tests/components/pixoo/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml From 091b6a0ba0d2da50a63658f2f7f34969772c85fc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:07:06 +0000 Subject: [PATCH 1911/2214] Bump bundled esphome-device-builder to 1.0.22 --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1085076137..04e7998f77 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22 RUN \ platformio settings set enable_telemetry No \ From 359c6a7265c23f814d442c451a3d870137dcc7c8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:34:32 -0400 Subject: [PATCH 1912/2214] [libretiny] Update LibreTiny to v1.13.0 (#17288) --- docker/test_configs/ln882x-arduino.yaml | 2 +- esphome/components/bk72xx/boards.py | 2178 ++++++------- esphome/components/libretiny/__init__.py | 10 +- .../libretiny/generate_components.py | 4 +- esphome/components/ln882x/boards.py | 497 ++- esphome/components/rtl87xx/boards.py | 2742 ++++++++--------- platformio.ini | 4 +- .../build_components_base.ln882x-ard.yaml | 2 +- 8 files changed, 2922 insertions(+), 2517 deletions(-) diff --git a/docker/test_configs/ln882x-arduino.yaml b/docker/test_configs/ln882x-arduino.yaml index 4cff3a4883..38e96630ba 100644 --- a/docker/test_configs/ln882x-arduino.yaml +++ b/docker/test_configs/ln882x-arduino.yaml @@ -2,6 +2,6 @@ esphome: name: docker-test-ln882x-arduino ln882x: - board: generic-ln882hki + board: generic-ln882h logger: diff --git a/esphome/components/bk72xx/boards.py b/esphome/components/bk72xx/boards.py index f8bedce329..6054b03f78 100644 --- a/esphome/components/bk72xx/boards.py +++ b/esphome/components/bk72xx/boards.py @@ -21,38 +21,6 @@ from esphome.components.libretiny.const import ( ) BK72XX_BOARDS = { - "wb2l-m1": { - "name": "WB2L_M1 Wi-Fi Module", - "family": FAMILY_BK7231N, - }, - "xh-wb3s": { - "name": "NiceMCU XH-WB3S", - "family": FAMILY_BK7238, - }, - "cbu": { - "name": "CBU Wi-Fi Module", - "family": FAMILY_BK7231N, - }, - "t1-u": { - "name": "T1-U Wi-Fi Module", - "family": FAMILY_BK7238, - }, - "generic-bk7238-tuya": { - "name": "Generic - BK7238 (Tuya T1)", - "family": FAMILY_BK7238, - }, - "t1-m": { - "name": "T1-M Wi-Fi Module", - "family": FAMILY_BK7238, - }, - "generic-bk7231t-qfn32-tuya": { - "name": "Generic - BK7231T (Tuya)", - "family": FAMILY_BK7231T, - }, - "generic-bk7231n-qfn32-tuya": { - "name": "Generic - BK7231N (Tuya)", - "family": FAMILY_BK7231N, - }, "cb1s": { "name": "CB1S Wi-Fi Module", "family": FAMILY_BK7231N, @@ -61,623 +29,117 @@ BK72XX_BOARDS = { "name": "CB2L Wi-Fi Module", "family": FAMILY_BK7231N, }, - "cblc5": { - "name": "CBLC5 Wi-Fi Module", + "cb2s": { + "name": "CB2S Wi-Fi Module", + "family": FAMILY_BK7231N, + }, + "cb3l": { + "name": "CB3L Wi-Fi Module", "family": FAMILY_BK7231N, }, "cb3s": { "name": "CB3S Wi-Fi Module", "family": FAMILY_BK7231N, }, - "wb3s": { - "name": "WB3S Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "lsc-lma35": { - "name": "LSC LMA35 BK7231N", + "cb3se": { + "name": "CB3SE Wi-Fi Module", "family": FAMILY_BK7231N, }, - "generic-bk7252": { - "name": "Generic - BK7252", - "family": FAMILY_BK7251, - }, - "t1-3s": { - "name": "T1-3S Wi-Fi Module", - "family": FAMILY_BK7238, - }, - "wb2l": { - "name": "WB2L Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "wb1s": { - "name": "WB1S Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "wblc5": { - "name": "WBLC5 Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "cb2s": { - "name": "CB2S Wi-Fi Module", + "cblc5": { + "name": "CBLC5 Wi-Fi Module", "family": FAMILY_BK7231N, }, + "cbu": { + "name": "CBU Wi-Fi Module", + "family": FAMILY_BK7231N, + }, + "generic-bk7231n-qfn32": { + "name": "Generic - BK7231N", + "family": FAMILY_BK7231N, + }, + "generic-bk7231n-qfn32-tuya": { + "name": "Generic - BK7231N (Tuya)", + "family": FAMILY_BK7231N, + }, + "generic-bk7231t-qfn32-tuya": { + "name": "Generic - BK7231T (Tuya)", + "family": FAMILY_BK7231T, + }, "generic-bk7238": { "name": "Generic - BK7238", "family": FAMILY_BK7238, }, - "wa2": { - "name": "WA2 Wi-Fi Module", - "family": FAMILY_BK7231Q, + "generic-bk7238-tuya": { + "name": "Generic - BK7238 (Tuya T1)", + "family": FAMILY_BK7238, }, - "cb3l": { - "name": "CB3L Wi-Fi Module", + "generic-bk7252": { + "name": "Generic - BK7252", + "family": FAMILY_BK7251, + }, + "lsc-lma35": { + "name": "LSC LMA35 BK7231N", "family": FAMILY_BK7231N, }, "lsc-lma35-t": { "name": "LSC LMA35 BK7231T", "family": FAMILY_BK7231T, }, - "cb3se": { - "name": "CB3SE Wi-Fi Module", - "family": FAMILY_BK7231N, - }, - "wb3l": { - "name": "WB3L Wi-Fi Module", - "family": FAMILY_BK7231T, - }, "t1-2s": { "name": "T1-2S Wi-Fi Module", "family": FAMILY_BK7238, }, + "t1-3s": { + "name": "T1-3S Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "t1-m": { + "name": "T1-M Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "t1-u": { + "name": "T1-U Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "wa2": { + "name": "WA2 Wi-Fi Module", + "family": FAMILY_BK7231Q, + }, + "wb1s": { + "name": "WB1S Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb2l": { + "name": "WB2L Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb2l-m1": { + "name": "WB2L_M1 Wi-Fi Module", + "family": FAMILY_BK7231N, + }, "wb2s": { "name": "WB2S Wi-Fi Module", "family": FAMILY_BK7231T, }, + "wb3l": { + "name": "WB3L Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb3s": { + "name": "WB3S Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wblc5": { + "name": "WBLC5 Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "xh-wb3s": { + "name": "NiceMCU XH-WB3S", + "family": FAMILY_BK7238, + }, } BK72XX_BOARD_PINS = { - "wb2l-m1": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 8, - "D1": 7, - "D2": 6, - "D3": 26, - "D4": 24, - "D5": 10, - "D6": 11, - "D7": 1, - "D8": 0, - "D9": 20, - "D10": 21, - "D11": 23, - "D12": 22, - "A0": 23, - }, - "xh-wb3s": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 7, - "D1": 23, - "D2": 14, - "D3": 26, - "D4": 24, - "D5": 6, - "D6": 9, - "D7": 0, - "D8": 1, - "D9": 8, - "D10": 10, - "D11": 11, - "D12": 16, - "D13": 20, - "D14": 21, - "D15": 22, - "D16": 15, - "D17": 17, - "A0": 28, - "A1": 26, - "A2": 24, - "A3": 1, - "A4": 10, - "A5": 20, - }, - "cbu": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 14, - "D1": 16, - "D2": 20, - "D3": 22, - "D4": 23, - "D5": 1, - "D6": 0, - "D7": 8, - "D8": 7, - "D9": 6, - "D10": 26, - "D11": 24, - "D12": 11, - "D13": 10, - "D14": 28, - "D15": 9, - "D16": 17, - "D17": 15, - "D18": 21, - "A0": 23, - }, - "t1-u": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 14, - "D1": 16, - "D2": 23, - "D3": 22, - "D4": 20, - "D5": 1, - "D6": 0, - "D7": 24, - "D8": 9, - "D9": 26, - "D10": 6, - "D11": 8, - "D12": 11, - "D13": 10, - "D14": 28, - "D15": 21, - "D16": 17, - "D17": 15, - "A0": 20, - "A1": 1, - "A2": 24, - "A3": 26, - "A4": 10, - "A5": 28, - }, - "generic-bk7238-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 1, - "A1": 10, - "A2": 20, - "A3": 24, - "A4": 26, - "A5": 28, - }, - "t1-m": { - "WIRE2_SCL": 24, - "WIRE2_SDA": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC5": 1, - "ADC6": 10, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 24, - "SDA2": 26, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 6, - "D2": 8, - "D3": 1, - "D4": 10, - "D5": 11, - "D6": 9, - "D7": 24, - "D11": 0, - "A0": 26, - "A1": 10, - "A2": 1, - "A3": 24, - }, - "generic-bk7231t-qfn32-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 23, - }, - "generic-bk7231n-qfn32-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 23, - }, "cb1s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -765,22 +227,28 @@ BK72XX_BOARD_PINS = { "D7": 11, "D8": 21, }, - "cblc5": { + "cb2s": { "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, "SERIAL1_TX": 11, "SERIAL2_RX": 1, "SERIAL2_TX": 0, + "ADC3": 23, "P0": 0, "P1": 1, "P6": 6, + "P7": 7, + "P8": 8, "P10": 10, "P11": 11, "P21": 21, + "P23": 23, "P24": 24, "P26": 26, "PWM0": 6, + "PWM1": 7, + "PWM2": 8, "PWM4": 24, "PWM5": 26, "RX1": 10, @@ -790,14 +258,61 @@ BK72XX_BOARD_PINS = { "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 24, - "D1": 6, - "D2": 26, - "D3": 11, + "D0": 6, + "D1": 7, + "D2": 8, + "D3": 23, "D4": 10, - "D5": 1, + "D5": 11, + "D6": 24, + "D7": 26, + "D8": 0, + "D9": 1, + "D10": 21, + "A0": 23, + }, + "cb3l": { + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P21": 21, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "TX1": 11, + "TX2": 0, + "D0": 23, + "D1": 14, + "D2": 26, + "D3": 24, + "D4": 6, + "D5": 9, "D6": 0, "D7": 21, + "D8": 8, + "D9": 7, + "D10": 10, + "D11": 11, + "A0": 23, }, "cb3s": { "WIRE1_SCL": 20, @@ -849,9 +364,11 @@ BK72XX_BOARD_PINS = { "D13": 20, "A0": 23, }, - "wb3s": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, + "cb3se": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, @@ -859,6 +376,9 @@ BK72XX_BOARD_PINS = { "SERIAL2_RX": 1, "SERIAL2_TX": 0, "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, "P0": 0, "P1": 1, "P6": 6, @@ -868,8 +388,10 @@ BK72XX_BOARD_PINS = { "P10": 10, "P11": 11, "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, "P20": 20, - "P21": 21, "P22": 22, "P23": 23, "P24": 24, @@ -885,7 +407,6 @@ BK72XX_BOARD_PINS = { "SCK": 14, "SCL1": 20, "SCL2": 0, - "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, @@ -894,19 +415,61 @@ BK72XX_BOARD_PINS = { "D2": 26, "D3": 24, "D4": 6, - "D5": 7, + "D5": 9, "D6": 0, "D7": 1, - "D8": 9, - "D9": 8, + "D8": 8, + "D9": 7, "D10": 10, "D11": 11, - "D12": 22, - "D13": 21, + "D12": 15, + "D13": 22, "D14": 20, + "D15": 17, + "D16": 16, "A0": 23, }, - "lsc-lma35": { + "cblc5": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "P0": 0, + "P1": 1, + "P6": 6, + "P10": 10, + "P11": 11, + "P21": 21, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 24, + "D1": 6, + "D2": 26, + "D3": 11, + "D4": 10, + "D5": 1, + "D6": 0, + "D7": 21, + }, + "cbu": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, @@ -914,6 +477,8 @@ BK72XX_BOARD_PINS = { "SERIAL2_RX": 1, "SERIAL2_TX": 0, "ADC3": 23, + "CS": 15, + "MISO": 17, "MOSI": 16, "P0": 0, "P1": 1, @@ -924,12 +489,16 @@ BK72XX_BOARD_PINS = { "P10": 10, "P11": 11, "P14": 14, + "P15": 15, "P16": 16, + "P17": 17, + "P20": 20, "P21": 21, "P22": 22, "P23": 23, "P24": 24, "P26": 26, + "P28": 28, "PWM0": 6, "PWM1": 7, "PWM2": 8, @@ -939,28 +508,405 @@ BK72XX_BOARD_PINS = { "RX1": 10, "RX2": 1, "SCK": 14, + "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 26, - "D1": 14, - "D2": 16, - "D3": 24, - "D4": 22, - "D5": 0, - "D6": 23, + "D0": 14, + "D1": 16, + "D2": 20, + "D3": 22, + "D4": 23, + "D5": 1, + "D6": 0, "D7": 8, - "D8": 9, - "D9": 21, - "D10": 6, - "D11": 7, - "D12": 10, - "D13": 11, - "D14": 1, + "D8": 7, + "D9": 6, + "D10": 26, + "D11": 24, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 9, + "D16": 17, + "D17": 15, + "D18": 21, "A0": 23, }, + "generic-bk7231n-qfn32": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 23, + }, + "generic-bk7231n-qfn32-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 23, + }, + "generic-bk7231t-qfn32-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 23, + }, + "generic-bk7238": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, + "generic-bk7238-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, "generic-bk7252": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -1085,6 +1031,161 @@ BK72XX_BOARD_PINS = { "A6": 12, "A7": 13, }, + "lsc-lma35": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P16": 16, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 14, + "D2": 16, + "D3": 24, + "D4": 22, + "D5": 0, + "D6": 23, + "D7": 8, + "D8": 9, + "D9": 21, + "D10": 6, + "D11": 7, + "D12": 10, + "D13": 11, + "D14": 1, + "A0": 23, + }, + "lsc-lma35-t": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P16": 16, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 14, + "D2": 16, + "D3": 24, + "D4": 22, + "D5": 0, + "D6": 23, + "D7": 8, + "D8": 9, + "D9": 21, + "D10": 6, + "D11": 7, + "D12": 10, + "D13": 11, + "D14": 1, + "A0": 23, + }, + "t1-2s": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "t1-3s": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -1154,6 +1255,217 @@ BK72XX_BOARD_PINS = { "A3": 26, "A4": 10, }, + "t1-m": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, + "t1-u": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 14, + "D1": 16, + "D2": 23, + "D3": 22, + "D4": 20, + "D5": 1, + "D6": 0, + "D7": 24, + "D8": 9, + "D9": 26, + "D10": 6, + "D11": 8, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 21, + "D16": 17, + "D17": 15, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + "A5": 28, + }, + "wa2": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_TX": 0, + "ADC1": 4, + "ADC3": 23, + "P0": 0, + "P4": 4, + "P6": 6, + "P7": 7, + "P8": 8, + "P10": 10, + "P11": 11, + "P18": 18, + "P19": 19, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM4": 18, + "PWM5": 19, + "RX1": 10, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "TX1": 11, + "TX2": 0, + "D0": 8, + "D1": 7, + "D2": 6, + "D3": 23, + "D4": 10, + "D5": 11, + "D6": 18, + "D7": 19, + "D8": 20, + "D9": 4, + "D10": 0, + "D11": 21, + "D12": 22, + "A0": 23, + }, + "wb1s": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 0, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 11, + "D1": 10, + "D2": 26, + "D3": 24, + "D4": 0, + "D5": 8, + "D6": 7, + "D7": 1, + "D8": 9, + "D9": 6, + "D10": 23, + "A0": 23, + }, "wb2l": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1205,51 +1517,7 @@ BK72XX_BOARD_PINS = { "D12": 22, "A0": 23, }, - "wb1s": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 0, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 11, - "D1": 10, - "D2": 26, - "D3": 24, - "D4": 0, - "D5": 8, - "D6": 7, - "D7": 1, - "D8": 9, - "D9": 6, - "D10": 23, - "A0": 23, - }, - "wblc5": { + "wb2l-m1": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, "WIRE2_SCL": 0, @@ -1262,6 +1530,8 @@ BK72XX_BOARD_PINS = { "P0": 0, "P1": 1, "P6": 6, + "P7": 7, + "P8": 8, "P10": 10, "P11": 11, "P20": 20, @@ -1271,95 +1541,43 @@ BK72XX_BOARD_PINS = { "P24": 24, "P26": 26, "PWM0": 6, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 24, - "D1": 6, - "D2": 26, - "D3": 10, - "D4": 11, - "D5": 1, - "D6": 0, - "D7": 20, - "D8": 21, - "D9": 22, - "D10": 23, - "A0": 23, - }, - "cb2s": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P21": 21, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, "PWM1": 7, "PWM2": 8, "PWM4": 24, "PWM5": 26, "RX1": 10, "RX2": 1, + "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 6, + "D0": 8, "D1": 7, - "D2": 8, - "D3": 23, - "D4": 10, - "D5": 11, - "D6": 24, - "D7": 26, + "D2": 6, + "D3": 26, + "D4": 24, + "D5": 10, + "D6": 11, + "D7": 1, "D8": 0, - "D9": 1, + "D9": 20, "D10": 21, + "D11": 23, + "D12": 22, "A0": 23, }, - "generic-bk7238": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, + "wb2s": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, "SERIAL1_RX": 10, "SERIAL1_TX": 11, "SERIAL2_RX": 1, "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, + "ADC3": 23, "P0": 0, "P1": 1, "P6": 6, @@ -1368,17 +1586,12 @@ BK72XX_BOARD_PINS = { "P9": 9, "P10": 10, "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, "P20": 20, "P21": 21, "P22": 22, "P23": 23, "P24": 24, "P26": 26, - "P28": 28, "PWM0": 6, "PWM1": 7, "PWM2": 8, @@ -1387,65 +1600,10 @@ BK72XX_BOARD_PINS = { "PWM5": 26, "RX1": 10, "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 1, - "A1": 10, - "A2": 20, - "A3": 24, - "A4": 26, - "A5": 28, - }, - "wa2": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_TX": 0, - "ADC1": 4, - "ADC3": 23, - "P0": 0, - "P4": 4, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P18": 18, - "P19": 19, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM4": 18, - "PWM5": 19, - "RX1": 10, "SCL1": 20, "SCL2": 0, "SDA1": 21, + "SDA2": 1, "TX1": 11, "TX2": 0, "D0": 8, @@ -1454,176 +1612,14 @@ BK72XX_BOARD_PINS = { "D3": 23, "D4": 10, "D5": 11, - "D6": 18, - "D7": 19, + "D6": 24, + "D7": 26, "D8": 20, - "D9": 4, - "D10": 0, - "D11": 21, - "D12": 22, - "A0": 23, - }, - "cb3l": { - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P21": 21, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "SCK": 14, - "SCL2": 0, - "SDA1": 21, - "TX1": 11, - "TX2": 0, - "D0": 23, - "D1": 14, - "D2": 26, - "D3": 24, - "D4": 6, - "D5": 9, - "D6": 0, - "D7": 21, - "D8": 8, - "D9": 7, - "D10": 10, - "D11": 11, - "A0": 23, - }, - "lsc-lma35-t": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P16": 16, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 14, - "D2": 16, - "D3": 24, - "D4": 22, - "D5": 0, - "D6": 23, - "D7": 8, - "D8": 9, - "D9": 21, - "D10": 6, - "D11": 7, - "D12": 10, - "D13": 11, - "D14": 1, - "A0": 23, - }, - "cb3se": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 23, - "D1": 14, - "D2": 26, - "D3": 24, - "D4": 6, - "D5": 9, - "D6": 0, - "D7": 1, - "D8": 8, - "D9": 7, - "D10": 10, - "D11": 11, - "D12": 15, + "D9": 9, + "D10": 1, + "D11": 0, + "D12": 21, "D13": 22, - "D14": 20, - "D15": 17, - "D16": 16, "A0": 23, }, "wb3l": { @@ -1686,52 +1682,7 @@ BK72XX_BOARD_PINS = { "D15": 1, "A0": 23, }, - "t1-2s": { - "WIRE2_SCL": 24, - "WIRE2_SDA": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC5": 1, - "ADC6": 10, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 24, - "SDA2": 26, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 6, - "D2": 8, - "D3": 1, - "D4": 10, - "D5": 11, - "D6": 9, - "D7": 24, - "D11": 0, - "A0": 26, - "A1": 10, - "A2": 1, - "A3": 24, - }, - "wb2s": { + "wb3s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, "WIRE2_SCL": 0, @@ -1749,6 +1700,7 @@ BK72XX_BOARD_PINS = { "P9": 9, "P10": 10, "P11": 11, + "P14": 14, "P20": 20, "P21": 21, "P22": 22, @@ -1763,28 +1715,152 @@ BK72XX_BOARD_PINS = { "PWM5": 26, "RX1": 10, "RX2": 1, + "SCK": 14, "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 8, - "D1": 7, - "D2": 6, - "D3": 23, - "D4": 10, - "D5": 11, - "D6": 24, - "D7": 26, - "D8": 20, - "D9": 9, - "D10": 1, - "D11": 0, - "D12": 21, - "D13": 22, + "D0": 23, + "D1": 14, + "D2": 26, + "D3": 24, + "D4": 6, + "D5": 7, + "D6": 0, + "D7": 1, + "D8": 9, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 22, + "D13": 21, + "D14": 20, "A0": 23, }, + "wblc5": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P1": 1, + "P6": 6, + "P10": 10, + "P11": 11, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 24, + "D1": 6, + "D2": 26, + "D3": 10, + "D4": 11, + "D5": 1, + "D6": 0, + "D7": 20, + "D8": 21, + "D9": 22, + "D10": 23, + "A0": 23, + }, + "xh-wb3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 7, + "D1": 23, + "D2": 14, + "D3": 26, + "D4": 24, + "D5": 6, + "D6": 9, + "D7": 0, + "D8": 1, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 16, + "D13": 20, + "D14": 21, + "D15": 22, + "D16": 15, + "D17": 17, + "A0": 28, + "A1": 26, + "A2": 24, + "A3": 1, + "A4": 10, + "A5": 20, + }, } BOARDS = BK72XX_BOARDS diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index bcc393f3fd..079bb32aab 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -211,14 +211,14 @@ def _notify_old_style(config): # The dev and latest branches will be at *least* this version, which is what matters. # Use GitHub releases directly to avoid PlatformIO moderation delays. ARDUINO_VERSIONS = { - "dev": (cv.Version(1, 12, 1), "https://github.com/libretiny-eu/libretiny.git"), + "dev": (cv.Version(1, 13, 0), "https://github.com/libretiny-eu/libretiny.git"), "latest": ( - cv.Version(1, 12, 1), - "https://github.com/libretiny-eu/libretiny.git#v1.12.1", + cv.Version(1, 13, 0), + "https://github.com/libretiny-eu/libretiny.git#v1.13.0", ), "recommended": ( - cv.Version(1, 12, 1), - "https://github.com/libretiny-eu/libretiny.git#v1.12.1", + cv.Version(1, 13, 0), + "https://github.com/libretiny-eu/libretiny.git#v1.13.0", ), } diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 6ca16f277f..791a2659a9 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -359,7 +359,9 @@ if __name__ == "__main__": check_base_code(BASE_CODE_INIT) # list all boards from ltchiptool components_dir = Path(__file__).parent.parent - boards = [Board(b) for b in Board.get_list()] + # Board.get_list() returns glob (filesystem) order, which is non-deterministic + # and produces noisy diffs on regeneration; sort by board id for stable output. + boards = sorted((Board(b) for b in Board.get_list()), key=lambda b: b.name) # keep track of all supported root- and chip-families components = set() families = {} diff --git a/esphome/components/ln882x/boards.py b/esphome/components/ln882x/boards.py index df44419ed2..bcd3ffbd9e 100644 --- a/esphome/components/ln882x/boards.py +++ b/esphome/components/ln882x/boards.py @@ -15,26 +15,38 @@ Any manual changes WILL BE LOST on regeneration. from esphome.components.libretiny.const import FAMILY_LN882H LN882X_BOARDS = { - "generic-ln882hki": { - "name": "Generic - LN882HKI", + "generic-ln882h": { + "name": "Generic - LN882H", "family": FAMILY_LN882H, }, - "wb02a": { - "name": "WB02A Wi-Fi/BLE Module", - "family": FAMILY_LN882H, - }, - "wl2s": { - "name": "WL2S Wi-Fi/BLE Module", + "generic-ln882h-tuya": { + "name": "Generic - LN882H (Tuya)", "family": FAMILY_LN882H, }, "ln-02": { "name": "LN-02 Wi-Fi/BLE Module", "family": FAMILY_LN882H, }, + "ln-cb3s-v1.0": { + "name": "LN-CB3S V1.0", + "family": FAMILY_LN882H, + }, + "wb02a": { + "name": "WB02A Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "wl2h-u": { + "name": "WL2H-U Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "wl2s": { + "name": "WL2S Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, } LN882X_BOARD_PINS = { - "generic-ln882hki": { + "generic-ln882h": { "WIRE0_SCL_0": 0, "WIRE0_SCL_1": 1, "WIRE0_SCL_2": 2, @@ -153,27 +165,292 @@ LN882X_BOARD_PINS = { "A6": 20, "A7": 21, }, + "generic-ln882h-tuya": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 7, + "WIRE0_SCL_8": 8, + "WIRE0_SCL_9": 9, + "WIRE0_SCL_10": 10, + "WIRE0_SCL_11": 11, + "WIRE0_SCL_12": 12, + "WIRE0_SCL_13": 19, + "WIRE0_SCL_14": 20, + "WIRE0_SCL_15": 21, + "WIRE0_SCL_16": 22, + "WIRE0_SCL_17": 23, + "WIRE0_SCL_18": 24, + "WIRE0_SCL_19": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 7, + "WIRE0_SDA_8": 8, + "WIRE0_SDA_9": 9, + "WIRE0_SDA_10": 10, + "WIRE0_SDA_11": 11, + "WIRE0_SDA_12": 12, + "WIRE0_SDA_13": 19, + "WIRE0_SDA_14": 20, + "WIRE0_SDA_15": 21, + "WIRE0_SDA_16": 22, + "WIRE0_SDA_17": 23, + "WIRE0_SDA_18": 24, + "WIRE0_SDA_19": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC5": 19, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB07": 23, + "PB7": 23, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "TX0": 2, + "TX1": 25, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 5, + "D6": 6, + "D7": 7, + "D8": 8, + "D9": 9, + "D10": 10, + "D11": 11, + "D12": 12, + "D13": 19, + "D14": 20, + "D15": 21, + "D16": 22, + "D17": 23, + "D18": 24, + "D19": 25, + "A2": 0, + "A3": 1, + "A4": 4, + "A5": 19, + "A6": 20, + "A7": 21, + }, + "ln-02": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 9, + "WIRE0_SCL_5": 11, + "WIRE0_SCL_6": 19, + "WIRE0_SCL_7": 24, + "WIRE0_SCL_8": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 9, + "WIRE0_SDA_5": 11, + "WIRE0_SDA_6": 19, + "WIRE0_SDA_7": 24, + "WIRE0_SDA_8": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC5": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA09": 9, + "PA9": 9, + "PA11": 11, + "PB03": 19, + "PB3": 19, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "SCL0": 9, + "SDA0": 9, + "TX0": 2, + "TX1": 25, + "D0": 11, + "D1": 19, + "D2": 3, + "D3": 24, + "D4": 2, + "D5": 25, + "D6": 1, + "D7": 0, + "D8": 9, + "A0": 19, + "A1": 1, + "A2": 0, + }, + "ln-cb3s-v1.0": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 9, + "WIRE0_SCL_8": 11, + "WIRE0_SCL_9": 20, + "WIRE0_SCL_10": 21, + "WIRE0_SCL_11": 22, + "WIRE0_SCL_12": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 9, + "WIRE0_SDA_8": 11, + "WIRE0_SDA_9": 20, + "WIRE0_SDA_10": 21, + "WIRE0_SDA_11": 22, + "WIRE0_SDA_12": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA09": 9, + "PA9": 9, + "PA11": 11, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "TX0": 2, + "TX1": 25, + "D0": 0, + "D1": 1, + "D2": 4, + "D3": 5, + "D4": 6, + "D5": 20, + "D6": 25, + "D7": 9, + "D8": 21, + "D9": 22, + "D10": 3, + "D11": 2, + "D12": 11, + "A0": 0, + "A1": 1, + "A2": 4, + "A3": 20, + "A4": 21, + }, "wb02a": { "WIRE0_SCL_0": 1, "WIRE0_SCL_1": 2, "WIRE0_SCL_2": 3, "WIRE0_SCL_3": 4, "WIRE0_SCL_4": 5, - "WIRE0_SCL_5": 7, - "WIRE0_SCL_6": 9, - "WIRE0_SCL_7": 10, - "WIRE0_SCL_8": 24, - "WIRE0_SCL_9": 25, + "WIRE0_SCL_5": 6, + "WIRE0_SCL_6": 7, + "WIRE0_SCL_7": 9, + "WIRE0_SCL_8": 10, + "WIRE0_SCL_9": 24, + "WIRE0_SCL_10": 25, "WIRE0_SDA_0": 1, "WIRE0_SDA_1": 2, "WIRE0_SDA_2": 3, "WIRE0_SDA_3": 4, "WIRE0_SDA_4": 5, - "WIRE0_SDA_5": 7, - "WIRE0_SDA_6": 9, - "WIRE0_SDA_7": 10, - "WIRE0_SDA_8": 24, - "WIRE0_SDA_9": 25, + "WIRE0_SDA_5": 6, + "WIRE0_SDA_6": 7, + "WIRE0_SDA_7": 9, + "WIRE0_SDA_8": 10, + "WIRE0_SDA_9": 24, + "WIRE0_SDA_10": 25, "SERIAL0_RX": 3, "SERIAL0_TX": 2, "SERIAL1_RX": 24, @@ -190,6 +467,8 @@ LN882X_BOARD_PINS = { "PA4": 4, "PA05": 5, "PA5": 5, + "PA06": 6, + "PA6": 6, "PA07": 7, "PA7": 7, "PA09": 9, @@ -206,18 +485,128 @@ LN882X_BOARD_PINS = { "TX0": 2, "TX1": 25, "D0": 7, - "D1": 5, + "D1": 6, "D2": 3, "D3": 10, "D4": 2, "D5": 1, "D6": 4, - "D7": 9, - "D8": 24, - "D9": 25, + "D7": 5, + "D8": 9, + "D9": 24, + "D10": 25, "A0": 1, "A1": 4, }, + "wl2h-u": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 7, + "WIRE0_SCL_8": 10, + "WIRE0_SCL_9": 11, + "WIRE0_SCL_10": 12, + "WIRE0_SCL_11": 19, + "WIRE0_SCL_12": 20, + "WIRE0_SCL_13": 21, + "WIRE0_SCL_14": 22, + "WIRE0_SCL_15": 23, + "WIRE0_SCL_16": 24, + "WIRE0_SCL_17": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 7, + "WIRE0_SDA_8": 10, + "WIRE0_SDA_9": 11, + "WIRE0_SDA_10": 12, + "WIRE0_SDA_11": 19, + "WIRE0_SDA_12": 20, + "WIRE0_SDA_13": 21, + "WIRE0_SDA_14": 22, + "WIRE0_SDA_15": 23, + "WIRE0_SDA_16": 24, + "WIRE0_SDA_17": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC5": 19, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB07": 23, + "PB7": 23, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "TX0": 2, + "TX1": 25, + "D0": 5, + "D1": 6, + "D2": 4, + "D3": 1, + "D4": 0, + "D5": 24, + "D6": 25, + "D7": 7, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 19, + "D12": 2, + "D13": 3, + "D14": 20, + "D15": 21, + "D16": 22, + "D17": 23, + "A0": 4, + "A1": 1, + "A2": 0, + "A3": 19, + "A4": 20, + "A5": 21, + }, "wl2s": { "WIRE0_SCL_0": 0, "WIRE0_SCL_1": 1, @@ -298,68 +687,6 @@ LN882X_BOARD_PINS = { "A1": 19, "A2": 1, }, - "ln-02": { - "WIRE0_SCL_0": 0, - "WIRE0_SCL_1": 1, - "WIRE0_SCL_2": 2, - "WIRE0_SCL_3": 3, - "WIRE0_SCL_4": 9, - "WIRE0_SCL_5": 11, - "WIRE0_SCL_6": 19, - "WIRE0_SCL_7": 24, - "WIRE0_SCL_8": 25, - "WIRE0_SDA_0": 0, - "WIRE0_SDA_1": 1, - "WIRE0_SDA_2": 2, - "WIRE0_SDA_3": 3, - "WIRE0_SDA_4": 9, - "WIRE0_SDA_5": 11, - "WIRE0_SDA_6": 19, - "WIRE0_SDA_7": 24, - "WIRE0_SDA_8": 25, - "SERIAL0_RX": 3, - "SERIAL0_TX": 2, - "SERIAL1_RX": 24, - "SERIAL1_TX": 25, - "ADC2": 0, - "ADC3": 1, - "ADC5": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA09": 9, - "PA9": 9, - "PA11": 11, - "PB03": 19, - "PB3": 19, - "PB08": 24, - "PB8": 24, - "PB09": 25, - "PB9": 25, - "RX0": 3, - "RX1": 24, - "SCL0": 9, - "SDA0": 9, - "TX0": 2, - "TX1": 25, - "D0": 11, - "D1": 19, - "D2": 3, - "D3": 24, - "D4": 2, - "D5": 25, - "D6": 1, - "D7": 0, - "D8": 9, - "A0": 19, - "A1": 1, - "A2": 0, - }, } BOARDS = LN882X_BOARDS diff --git a/esphome/components/rtl87xx/boards.py b/esphome/components/rtl87xx/boards.py index 3a5ee853f2..23d220a91e 100644 --- a/esphome/components/rtl87xx/boards.py +++ b/esphome/components/rtl87xx/boards.py @@ -15,40 +15,24 @@ Any manual changes WILL BE LOST on regeneration. from esphome.components.libretiny.const import FAMILY_RTL8710B, FAMILY_RTL8720C RTL87XX_BOARDS = { - "wr3le": { - "name": "WR3LE Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr2": { - "name": "WR2 Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wbr3": { - "name": "WBR3 Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "generic-rtl8710bn-2mb-468k": { - "name": "Generic - RTL8710BN (2M/468k)", - "family": FAMILY_RTL8710B, - }, - "wr1e": { - "name": "WR1E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr3e": { - "name": "WR3E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr3": { - "name": "WR3 Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, "afw121t": { "name": "AFW121T", "family": FAMILY_RTL8710B, }, - "wr3n": { - "name": "WR3N Wi-Fi Module", + "bw12": { + "name": "BW12", + "family": FAMILY_RTL8710B, + }, + "bw15": { + "name": "BW15", + "family": FAMILY_RTL8720C, + }, + "cr3l": { + "name": "CR3L Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, + "generic-rtl8710bn-2mb-468k": { + "name": "Generic - RTL8710BN (2M/468k)", "family": FAMILY_RTL8710B, }, "generic-rtl8710bn-2mb-788k": { @@ -59,42 +43,6 @@ RTL87XX_BOARDS = { "name": "Generic - RTL8710BX (4M/980k)", "family": FAMILY_RTL8710B, }, - "wr2e": { - "name": "WR2E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "t112-v1.1": { - "name": "T112_V1.1", - "family": FAMILY_RTL8710B, - }, - "wr3l": { - "name": "WR3L Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wbru": { - "name": "WBRU Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "wr2le": { - "name": "WR2LE Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "bw15": { - "name": "BW15", - "family": FAMILY_RTL8720C, - }, - "t103-v1.0": { - "name": "T103_V1.0", - "family": FAMILY_RTL8710B, - }, - "cr3l": { - "name": "CR3L Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "generic-rtl8720cm-4mb-1712k": { - "name": "Generic - RTL8720CM (4M/1712k)", - "family": FAMILY_RTL8720C, - }, "generic-rtl8720cf-2mb-896k": { "name": "Generic - RTL8720CF (2M/896k)", "family": FAMILY_RTL8720C, @@ -103,521 +51,81 @@ RTL87XX_BOARDS = { "name": "Generic - RTL8720CF (2M/992k)", "family": FAMILY_RTL8720C, }, - "bw12": { - "name": "BW12", - "family": FAMILY_RTL8710B, + "generic-rtl8720cm-4mb-1712k": { + "name": "Generic - RTL8720CM (4M/1712k)", + "family": FAMILY_RTL8720C, }, "t102-v1.1": { "name": "T102_V1.1", "family": FAMILY_RTL8710B, }, - "wr2l": { - "name": "WR2L Wi-Fi Module", + "t103-v1.0": { + "name": "T103_V1.0", + "family": FAMILY_RTL8710B, + }, + "t112-v1.1": { + "name": "T112_V1.1", "family": FAMILY_RTL8710B, }, "wbr1": { "name": "WBR1 Wi-Fi Module", "family": FAMILY_RTL8720C, }, + "wbr3": { + "name": "WBR3 Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, + "wbru": { + "name": "WBRU Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, "wr1": { "name": "WR1 Wi-Fi Module", "family": FAMILY_RTL8710B, }, + "wr1e": { + "name": "WR1E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2": { + "name": "WR2 Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2e": { + "name": "WR2E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2l": { + "name": "WR2L Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2le": { + "name": "WR2LE Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3": { + "name": "WR3 Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3e": { + "name": "WR3E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3l": { + "name": "WR3L Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3le": { + "name": "WR3LE Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3n": { + "name": "WR3N Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, } RTL87XX_BOARD_PINS = { - "wr3le": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 22, - "D4": 0, - "D5": 30, - "D6": 19, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "wr2": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC2": 41, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 29, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL0": 29, - "SCL1": 18, - "SDA0": 30, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 12, - "D1": 0, - "D2": 5, - "D4": 18, - "D5": 23, - "D6": 14, - "D7": 15, - "D8": 30, - "D9": 29, - "A1": 41, - }, - "wbr3": { - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS1": 4, - "CTS2": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PWM5": 17, - "PWM6": 18, - "RX2": 15, - "SDA0": 16, - "TX2": 16, - "D0": 7, - "D1": 11, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 12, - "D6": 16, - "D7": 17, - "D8": 18, - "D9": 19, - "D10": 13, - "D11": 14, - "D12": 15, - "D13": 0, - "D14": 1, - }, - "generic-rtl8710bn-2mb-468k": { - "SPI0_CS": 19, - "SPI0_FCS": 6, - "SPI0_FD0": 9, - "SPI0_FD1": 7, - "SPI0_FD2": 8, - "SPI0_FD3": 11, - "SPI0_FSCK": 10, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "FCS": 6, - "FD0": 9, - "FD1": 7, - "FD2": 8, - "FD3": 11, - "FSCK": 10, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA06": 6, - "PA6": 6, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 30, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 0, - "D1": 5, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 12, - "D9": 14, - "D10": 15, - "D11": 18, - "D12": 19, - "D13": 22, - "D14": 23, - "D15": 29, - "D16": 30, - "A0": 19, - "A1": 41, - }, - "wr1e": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM3": 12, - "PWM4": 29, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 23, - "D1": 18, - "D2": 14, - "D3": 15, - "D4": 30, - "D5": 12, - "D6": 5, - "D7": 29, - "D8": 19, - "D9": 22, - "A0": 19, - "A1": 41, - }, - "wr3e": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 22, - "D4": 0, - "D5": 30, - "D6": 19, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "wr3": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 22, - "D1": 19, - "D2": 14, - "D3": 15, - "D4": 0, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, "afw121t": { "SPI0_CS": 19, "SPI0_MISO": 22, @@ -686,16 +194,33 @@ RTL87XX_BOARD_PINS = { "D9": 23, "D10": 30, }, - "wr3n": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, + "bw12": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, "WIRE1_SCL": 18, "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, "SERIAL0_RX": 18, "SERIAL0_TX": 23, "SERIAL2_RX": 29, "SERIAL2_TX": 30, - "ADC2": 41, + "ADC1": 19, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, "MOSI0": 23, "MOSI1": 23, "PA00": 0, @@ -706,32 +231,269 @@ RTL87XX_BOARD_PINS = { "PA14": 14, "PA15": 15, "PA18": 18, + "PA19": 19, + "PA22": 22, "PA23": 23, "PA29": 29, "PA30": 30, "PWM1": 15, "PWM2": 0, "PWM3": 12, - "PWM4": 5, + "PWM4": 30, + "PWM5": 22, + "RTS0": 22, "RX0": 18, "RX2": 29, "SCK0": 18, "SCK1": 18, - "SCL0": 29, "SCL1": 18, - "SDA0": 30, "SDA1": 23, "TX0": 23, "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 0, - "D4": 30, - "D5": 5, - "D6": 12, + "D0": 5, + "D1": 29, + "D2": 0, + "D3": 19, + "D4": 22, + "D5": 30, + "D6": 14, + "D7": 12, + "D8": 15, + "D9": 18, + "D10": 23, + "A0": 19, + }, + "bw15": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 15, + "SPI0_MISO": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 15, + "WIRE0_SCL_2": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 16, + "WIRE0_SDA_2": 20, + "SERIAL0_RX": 13, + "SERIAL0_TX": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "CTS2": 19, + "MISO0": 20, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PWM1": 1, + "PWM5": 17, + "PWM6": 18, + "RTS2": 20, + "RX0": 13, + "RX2": 15, + "SCL0": 19, + "SDA0": 3, + "TX0": 14, + "TX2": 16, + "D0": 17, + "D1": 18, + "D2": 2, + "D3": 15, + "D4": 4, + "D5": 19, + "D6": 20, + "D7": 16, + "D8": 0, + "D9": 3, + "D10": 1, + "D11": 13, + "D12": 14, + }, + "cr3l": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 15, + "SPI0_MISO": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 15, + "WIRE0_SCL_2": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 16, + "WIRE0_SDA_2": 20, + "SERIAL0_RX": 13, + "SERIAL0_TX": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX": 2, + "SERIAL1_TX": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "CTS2": 19, + "MISO0": 20, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "RTS2": 20, + "RX0": 13, + "RX1": 2, + "RX2": 15, + "SCL0": 19, + "SDA0": 16, + "TX0": 14, + "TX1": 3, + "TX2": 16, + "D0": 20, + "D1": 2, + "D2": 3, + "D3": 4, + "D4": 15, + "D5": 16, + "D6": 17, "D7": 18, - "D8": 23, + "D8": 19, + "D9": 13, + "D10": 14, + }, + "generic-rtl8710bn-2mb-468k": { + "SPI0_CS": 19, + "SPI0_FCS": 6, + "SPI0_FD0": 9, + "SPI0_FD1": 7, + "SPI0_FD2": 8, + "SPI0_FD3": 11, + "SPI0_FSCK": 10, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "FCS": 6, + "FD0": 9, + "FD1": 7, + "FD2": 8, + "FD3": 11, + "FSCK": 10, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 30, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 0, + "D1": 5, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 12, + "D9": 14, + "D10": 15, + "D11": 18, + "D12": 19, + "D13": 22, + "D14": 23, + "D15": 29, + "D16": 30, + "A0": 19, "A1": 41, }, "generic-rtl8710bn-2mb-788k": { @@ -930,13 +692,363 @@ RTL87XX_BOARD_PINS = { "D16": 30, "A0": 19, }, - "wr2e": { + "generic-rtl8720cf-2mb-896k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "generic-rtl8720cf-2mb-992k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "generic-rtl8720cm-4mb-1712k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "t102-v1.1": { "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 0, + "D2": 5, + "D3": 30, + "D4": 29, + "D5": 18, + "D6": 23, + "D7": 14, + "D8": 15, + }, + "t103-v1.0": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, "WIRE0_SDA_0": 19, "WIRE0_SDA_1": 30, "WIRE1_SCL": 18, "WIRE1_SDA": 23, "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, "SERIAL0_RX": 18, "SERIAL0_TX": 23, "SERIAL2_RX": 29, @@ -946,8 +1058,12 @@ RTL87XX_BOARD_PINS = { "CS0": 19, "CS1": 19, "CTS0": 19, + "MISO0": 22, + "MISO1": 22, "MOSI0": 23, "MOSI1": 23, + "PA00": 0, + "PA0": 0, "PA05": 5, "PA5": 5, "PA12": 12, @@ -955,30 +1071,35 @@ RTL87XX_BOARD_PINS = { "PA15": 15, "PA18": 18, "PA19": 19, + "PA22": 22, "PA23": 23, "PA29": 29, "PA30": 30, "PWM1": 15, + "PWM2": 0, "PWM3": 12, - "PWM4": 29, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, "RX0": 18, "RX2": 29, "SCK0": 18, "SCK1": 18, - "SCL0": 29, "SCL1": 18, "SDA1": 23, "TX0": 23, "TX2": 30, - "D0": 12, - "D1": 19, - "D2": 5, - "D3": 18, - "D4": 23, - "D5": 14, - "D6": 15, - "D7": 30, - "D8": 29, + "D0": 19, + "D1": 14, + "D2": 15, + "D3": 0, + "D4": 22, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, "A0": 19, "A1": 41, }, @@ -1051,76 +1172,129 @@ RTL87XX_BOARD_PINS = { "D10": 30, "A0": 19, }, - "wr3l": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, + "wbr1": { + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "MOSI0": 4, "PA00": 0, "PA0": 0, - "PA05": 5, - "PA5": 5, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA11": 11, "PA12": 12, + "PA13": 13, "PA14": 14, "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PWM5": 17, + "PWM6": 18, + "PWM7": 13, + "RX2": 15, + "SCL0": 15, + "SDA0": 12, + "TX2": 16, + "D0": 14, + "D1": 13, + "D2": 2, + "D3": 3, + "D4": 16, + "D5": 4, + "D6": 11, + "D7": 15, + "D8": 12, + "D9": 17, + "D10": 18, + "D11": 0, + "D12": 1, + }, + "wbr3": { + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS1": 4, + "CTS2": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, "PA18": 18, "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 22, - "D1": 19, - "D2": 14, - "D3": 15, - "D4": 0, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, + "PWM5": 17, + "PWM6": 18, + "RX2": 15, + "SDA0": 16, + "TX2": 16, + "D0": 7, + "D1": 11, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 12, + "D6": 16, + "D7": 17, + "D8": 18, + "D9": 19, + "D10": 13, + "D11": 14, + "D12": 15, + "D13": 0, + "D14": 1, }, "wbru": { "SPI0_CS_0": 2, @@ -1215,724 +1389,6 @@ RTL87XX_BOARD_PINS = { "D16": 10, "D17": 7, }, - "wr2le": { - "MISO0": 22, - "MISO1": 22, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA22": 22, - "PWM0": 14, - "PWM1": 15, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "SCL0": 22, - "D0": 15, - "D1": 14, - "D2": 5, - "D3": 22, - "D4": 12, - }, - "bw15": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 15, - "SPI0_MISO": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 15, - "WIRE0_SCL_2": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 16, - "WIRE0_SDA_2": 20, - "SERIAL0_RX": 13, - "SERIAL0_TX": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "CTS2": 19, - "MISO0": 20, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PWM1": 1, - "PWM5": 17, - "PWM6": 18, - "RTS2": 20, - "RX0": 13, - "RX2": 15, - "SCL0": 19, - "SDA0": 3, - "TX0": 14, - "TX2": 16, - "D0": 17, - "D1": 18, - "D2": 2, - "D3": 15, - "D4": 4, - "D5": 19, - "D6": 20, - "D7": 16, - "D8": 0, - "D9": 3, - "D10": 1, - "D11": 13, - "D12": 14, - }, - "t103-v1.0": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 19, - "D1": 14, - "D2": 15, - "D3": 0, - "D4": 22, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "cr3l": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 15, - "SPI0_MISO": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 15, - "WIRE0_SCL_2": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 16, - "WIRE0_SDA_2": 20, - "SERIAL0_RX": 13, - "SERIAL0_TX": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX": 2, - "SERIAL1_TX": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "CTS2": 19, - "MISO0": 20, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "RTS2": 20, - "RX0": 13, - "RX1": 2, - "RX2": 15, - "SCL0": 19, - "SDA0": 16, - "TX0": 14, - "TX1": 3, - "TX2": 16, - "D0": 20, - "D1": 2, - "D2": 3, - "D3": 4, - "D4": 15, - "D5": 16, - "D6": 17, - "D7": 18, - "D8": 19, - "D9": 13, - "D10": 14, - }, - "generic-rtl8720cm-4mb-1712k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "generic-rtl8720cf-2mb-896k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "generic-rtl8720cf-2mb-992k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "bw12": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 30, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 5, - "D1": 29, - "D2": 0, - "D3": 19, - "D4": 22, - "D5": 30, - "D6": 14, - "D7": 12, - "D8": 15, - "D9": 18, - "D10": 23, - "A0": 19, - }, - "t102-v1.1": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 29, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL0": 29, - "SCL1": 18, - "SDA0": 30, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 12, - "D1": 0, - "D2": 5, - "D3": 30, - "D4": 29, - "D5": 18, - "D6": 23, - "D7": 14, - "D8": 15, - }, - "wr2l": { - "ADC1": 19, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA19": 19, - "PWM0": 14, - "PWM1": 15, - "PWM3": 12, - "PWM4": 5, - "SDA0": 19, - "D0": 15, - "D1": 14, - "D2": 5, - "D3": 19, - "D4": 12, - "A0": 19, - }, - "wbr1": { - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "MOSI0": 4, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PWM5": 17, - "PWM6": 18, - "PWM7": 13, - "RX2": 15, - "SCL0": 15, - "SDA0": 12, - "TX2": 16, - "D0": 14, - "D1": 13, - "D2": 2, - "D3": 3, - "D4": 16, - "D5": 4, - "D6": 11, - "D7": 15, - "D8": 12, - "D9": 17, - "D10": 18, - "D11": 0, - "D12": 1, - }, "wr1": { "SPI0_CS": 19, "SPI0_MISO": 22, @@ -2001,6 +1457,550 @@ RTL87XX_BOARD_PINS = { "A0": 19, "A1": 41, }, + "wr1e": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM3": 12, + "PWM4": 29, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 23, + "D1": 18, + "D2": 14, + "D3": 15, + "D4": 30, + "D5": 12, + "D6": 5, + "D7": 29, + "D8": 19, + "D9": 22, + "A0": 19, + "A1": 41, + }, + "wr2": { + "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC2": 41, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 0, + "D2": 5, + "D4": 18, + "D5": 23, + "D6": 14, + "D7": 15, + "D8": 30, + "D9": 29, + "A1": 41, + }, + "wr2e": { + "WIRE0_SCL": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MOSI0": 23, + "MOSI1": 23, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 19, + "D2": 5, + "D3": 18, + "D4": 23, + "D5": 14, + "D6": 15, + "D7": 30, + "D8": 29, + "A0": 19, + "A1": 41, + }, + "wr2l": { + "ADC1": 19, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA19": 19, + "PWM0": 14, + "PWM1": 15, + "PWM3": 12, + "PWM4": 5, + "SDA0": 19, + "D0": 15, + "D1": 14, + "D2": 5, + "D3": 19, + "D4": 12, + "A0": 19, + }, + "wr2le": { + "MISO0": 22, + "MISO1": 22, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA22": 22, + "PWM0": 14, + "PWM1": 15, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "SCL0": 22, + "D0": 15, + "D1": 14, + "D2": 5, + "D3": 22, + "D4": 12, + }, + "wr3": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 22, + "D1": 19, + "D2": 14, + "D3": 15, + "D4": 0, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3e": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 22, + "D4": 0, + "D5": 30, + "D6": 19, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3l": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 22, + "D1": 19, + "D2": 14, + "D3": 15, + "D4": 0, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3le": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 22, + "D4": 0, + "D5": 30, + "D6": 19, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3n": { + "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC2": 41, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 0, + "D4": 30, + "D5": 5, + "D6": 12, + "D7": 18, + "D8": 23, + "A1": 41, + }, } BOARDS = RTL87XX_BOARDS diff --git a/platformio.ini b/platformio.ini index bca2910616..061e92a64a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -224,7 +224,7 @@ build_unflags = ; This are common settings for the LibreTiny (all variants) using Arduino. [common:libretiny-arduino] extends = common:arduino -platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 +platform = https://github.com/libretiny-eu/libretiny.git#v1.13.0 framework = arduino lib_compat_mode = soft lib_deps = @@ -525,7 +525,7 @@ build_unflags = [env:ln882h-arduino] extends = common:libretiny-arduino -board = generic-ln882hki +board = generic-ln882h build_flags = ${common:libretiny-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/tests/test_build_components/build_components_base.ln882x-ard.yaml b/tests/test_build_components/build_components_base.ln882x-ard.yaml index 80fc6690f9..34abcb5a77 100644 --- a/tests/test_build_components/build_components_base.ln882x-ard.yaml +++ b/tests/test_build_components/build_components_base.ln882x-ard.yaml @@ -3,7 +3,7 @@ esphome: friendly_name: $component_name ln882x: - board: generic-ln882hki + board: generic-ln882h logger: level: VERY_VERBOSE From faa5f72500c341c6ec12d8711c32ff8455c84852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 30 Jun 2026 15:16:18 +0300 Subject: [PATCH 1913/2214] [mqtt] Add LN882X (LN882H) platform support (#17297) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mqtt/__init__.py | 11 ++++++++++- tests/components/mqtt/test.ln882x-ard.yaml | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/components/mqtt/test.ln882x-ard.yaml diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 86bba11a60..4a5eacf449 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -57,6 +57,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RTL87XX, PlatformFramework, ) @@ -318,7 +319,15 @@ CONFIG_SCHEMA = cv.All( } ), validate_config, - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), + cv.only_on( + [ + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + ), _consume_mqtt_sockets, ) diff --git a/tests/components/mqtt/test.ln882x-ard.yaml b/tests/components/mqtt/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/mqtt/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml From 9e72027b6455a90bc41498942e45ee28c962ae85 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 30 Jun 2026 05:33:33 -0700 Subject: [PATCH 1914/2214] [devcontainer] Align base image with production, fix Python venv and build tools (#17296) Co-authored-by: Claude Opus 4.8 --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 7 +++++-- script/setup | 15 +++++++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 51e2232d24..6f7e892284 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.1 FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 29f63b54b5..9181275269 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,7 +15,6 @@ // uncomment and edit the path in order to pass through local USB serial to the container // , "--device=/dev/ttyACM0" ], - "appPort": 6052, // if you are using avahi in the host device, uncomment these to allow the // devcontainer to find devices via mdns //"mounts": [ @@ -41,7 +40,11 @@ ], "settings": { "python.languageServer": "Pylance", - "python.pythonPath": "/usr/bin/python3", + // Use the container's pre-provisioned venv (built by the Dockerfile, outside the + // bind-mounted workspace) rather than a ./venv that may leak in from the host and + // mismatch the container's Python. See .devcontainer/Dockerfile (esphome-venv). + "python.defaultInterpreterPath": "/home/esphome/.local/esphome-venv/bin/python", + "python.terminal.activateEnvironment": true, "pylint.args": [ "--rcfile=${workspaceFolder}/pyproject.toml" ], diff --git a/script/setup b/script/setup index 8cad7017ff..709eaee0f3 100755 --- a/script/setup +++ b/script/setup @@ -4,7 +4,12 @@ set -e cd "$(dirname "$0")/.." -if [ ! -n "$VIRTUAL_ENV" ]; then +if [ -n "$VIRTUAL_ENV" ]; then + # A virtual environment is already active (e.g. the devcontainer's pre-provisioned + # esphome-venv). Install into it rather than creating a ./venv in the workspace. + created_venv=false +else + created_venv=true if [ -x "$(command -v uv)" ]; then uv venv --seed venv else @@ -26,4 +31,10 @@ mkdir -p .temp echo echo -echo "Virtual environment created. Run 'source venv/bin/activate' to use it." +if [ "$created_venv" = true ]; then + echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it." +else + echo "Dependencies installed into the active virtual environment:" + echo " $VIRTUAL_ENV" + echo "It is already active in this shell, so no 'source venv/bin/activate' is needed." +fi From fb5d8b5d4c07818fd75ae4e2306d96a8ba42164c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:43:01 +1000 Subject: [PATCH 1915/2214] [mipi_spi] Bug fixes (#17247) --- esphome/components/mipi_spi/display.py | 2 ++ esphome/components/mipi_spi/mipi_spi.h | 3 +++ esphome/components/mipi_spi/models/ili.py | 7 +------ tests/component_tests/mipi_spi/test_init.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 4162459058..871736abd1 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -425,6 +425,8 @@ async def to_code(config): dc_pin = await cg.gpio_pin_expression(dc_pin) cg.add(var.set_dc_pin(dc_pin)) + if config.get(CONF_INVERT_COLORS): + cg.add(var.set_invert_colors(True)) if lamb := config.get(CONF_LAMBDA): lambda_ = await cg.process_lambda( lamb, [(display.DisplayRef, "it")], return_type=cg.void diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index d9627899e0..48184fa5c1 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -151,6 +151,9 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); + } else { + // no reset pin, send software reset command + this->write_command_(SW_RESET_CMD); } // need to know when the display is ready for SLPOUT command - will be 120ms after reset diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 5df7a275df..5598a51073 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -24,13 +24,11 @@ from esphome.components.mipi import ( PWSET, PWSETN, SETEXTC, - SWRESET, VMCTR, VMCTR1, VMCTR2, VSCRSADD, DriverChip, - delay, ) from esphome.components.spi import TYPE_OCTAL @@ -367,7 +365,6 @@ ST7796 = DriverChip( width=320, height=480, initsequence=( - (SWRESET,), (CSCON, 0xC3), (CSCON, 0x96), (VMCTR1, 0x1C), @@ -728,8 +725,6 @@ DriverChip( width=128, height=160, initsequence=( - SWRESET, - delay(10), (FRMCTR1, 0x01, 0x2C, 0x2D), (FRMCTR2, 0x01, 0x2C, 0x2D), (FRMCTR3, 0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D), @@ -786,7 +781,7 @@ ST7796.extend( bus_mode=TYPE_OCTAL, mirror_x=True, reset_pin=4, - dc_pin=0, + dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, ) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index dbd8e15702..8edbe095b7 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -377,6 +377,6 @@ def test_lvgl_generation( "mipi_spi::MipiSpi();" in main_cpp ) - assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp + assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp From 12b78e7c47abcae5dd518ca07c7c0439bae3232d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:37:17 -0400 Subject: [PATCH 1916/2214] [qmi8658] Pin i2c_id in test config to fix grouped component test conflict (#17303) --- tests/components/qmi8658/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/qmi8658/common.yaml b/tests/components/qmi8658/common.yaml index cfb0f3e129..7d4de0f97e 100644 --- a/tests/components/qmi8658/common.yaml +++ b/tests/components/qmi8658/common.yaml @@ -49,6 +49,7 @@ sensor: motion: - platform: qmi8658 + i2c_id: i2c_bus # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G From 43b3aa0712dd654495abb0243b95837e379ae6f7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:37:59 -0400 Subject: [PATCH 1917/2214] [ci] Fix nRF52 zigbee/network test-grouping conflict (#17295) --- script/helpers.py | 99 +++++++++++++++---- script/test_build_components.py | 38 +++++-- tests/components/api/test.nrf52-adafruit.yaml | 3 + .../components/mdns/test.nrf52-adafruit.yaml | 3 + .../network/test.nrf52-adafruit.yaml | 4 + .../components/network/test.nrf52-mcumgr.yaml | 4 + .../network/test.nrf52-xiao-ble.yaml | 4 + tests/script/test_helpers.py | 45 +++++++++ 8 files changed, 171 insertions(+), 29 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index fc2a3607fb..0086a00e85 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -238,6 +238,72 @@ class _ConflictWalk: rejects: set[str] +@cache +def _get_test_config_components(component: str, platform: str) -> frozenset[str]: + """Return the components referenced by a component's test config for a platform. + + Loads ``tests/components//test..yaml`` and extracts the + top-level component keys (and list ``platform:`` values). This lets the + conflict splitter see components that are only pulled in via a test config + (e.g. nRF52 ``network`` tests that also enable ``openthread``), which a + purely static AUTO_LOAD/CONFLICTS_WITH parse cannot discover -- notably for + components like ``api`` whose ``AUTO_LOAD`` is a callable. + + Failures (missing file, parse error) are treated as empty so the splitter + never crashes on a malformed or absent test config. + """ + from esphome import yaml_util + + test_file = ( + Path(root_path) / "tests" / "components" / component / f"test.{platform}.yaml" + ) + if not test_file.exists(): + return frozenset() + try: + config = yaml_util.load_yaml(test_file) + except Exception: # noqa: BLE001 - never let a bad test config crash grouping + # Matches analyze_component_buses, which loads these same files and + # silently tolerates parse failures; surfacing it only here would be + # inconsistent and noisy. + return frozenset() + if not isinstance(config, dict): + return frozenset() + return frozenset(_extract_components_from_yaml(config)) + + +@cache +def _conflict_walk(comp: str, platform: str) -> _ConflictWalk: + """Build the platform-aware conflict walk for a single component. + + Seeds the walk with the component itself plus any components pulled in via + its ``test..yaml`` config, then folds in each seed's static + AUTO_LOAD closure and CONFLICTS_WITH declarations. Cached per + ``(component, platform)`` since the test-config seeds are platform-specific. + """ + seeds = {comp} | set(_get_test_config_components(comp, platform)) + walk = _ConflictWalk(loaded=set(seeds), rejects=set()) + stack = list(seeds) + while stack: + metadata = parse_component_metadata(stack.pop()) + walk.rejects |= metadata.conflicts_with + new = metadata.auto_load - walk.loaded + walk.loaded |= new + stack.extend(new) + return walk + + +def components_conflict(a: str, b: str, platform: str) -> bool: + """Return True if components ``a`` and ``b`` cannot share a build on ``platform``. + + Uses the same platform-aware conflict walk as :func:`split_conflicting_groups` + so callers (e.g. the no-bus redistribution in ``test_build_components.py``) + agree with how groups were originally split. The conflict relation is + symmetric even when only one side declares CONFLICTS_WITH. + """ + wa, wb = _conflict_walk(a, platform), _conflict_walk(b, platform) + return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint(wa.loaded) + + def split_conflicting_groups( grouped_components: dict[tuple[str, str], list[str]], ) -> dict[tuple[str, str], list[str]]: @@ -250,33 +316,24 @@ def split_conflicting_groups( conflict relation is treated as symmetric even when only one side declares it (e.g. ethernet rejects wifi but wifi does not declare the reverse). + + The walk is platform-aware: in addition to the static AUTO_LOAD closure, + each ``(component, platform)`` walk is seeded with the components found in + that component's ``test..yaml`` config. This catches conflicts + that only exist on a given platform and are expressed through the test + config rather than static metadata -- e.g. on nRF52 the ``network``/``api`` + test configs also enable ``openthread``, which ``zigbee`` declares a + conflict with, so ``api`` and ``zigbee`` end up split there. On ESP32 those + test configs have no ``openthread``, so the components still group together. """ - batch = {c for comps in grouped_components.values() for c in comps} - - walks: dict[str, _ConflictWalk] = {} - for comp in batch: - walk = _ConflictWalk(loaded={comp}, rejects=set()) - stack = [comp] - while stack: - metadata = parse_component_metadata(stack.pop()) - walk.rejects |= metadata.conflicts_with - new = metadata.auto_load - walk.loaded - walk.loaded |= new - stack.extend(new) - walks[comp] = walk - - def conflicts(a: str, b: str) -> bool: - wa, wb = walks[a], walks[b] - return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint( - wa.loaded - ) - result: dict[tuple[str, str], list[str]] = {} for (platform, signature), components in grouped_components.items(): buckets: list[list[str]] = [] for comp in components: for bucket in buckets: - if not any(conflicts(comp, other) for other in bucket): + if not any( + components_conflict(comp, other, platform) for other in bucket + ): bucket.append(comp) break else: diff --git a/script/test_build_components.py b/script/test_build_components.py index 651268609e..ce2a35add3 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -40,6 +40,7 @@ from script.analyze_component_buses import ( uses_local_file_references, ) from script.helpers import ( + components_conflict, get_component_test_files, is_validate_only_file, parse_test_filename, @@ -788,14 +789,35 @@ def run_grouped_component_tests( if plat == platform and sig != NO_BUSES_SIGNATURE ] - if platform_groups: - # Distribute no_buses components round-robin across existing groups - for i, comp in enumerate(no_buses_comps): - sig, _ = platform_groups[i % len(platform_groups)] - grouped_components[(platform, sig)].append(comp) - else: - # No other groups for this platform - keep no_buses components together - grouped_components[(platform, NO_BUSES_SIGNATURE)] = no_buses_comps + # Distribute no_buses components round-robin across existing groups, + # but never place a component into a group it conflicts with. Conflict + # splitting (split_conflicting_groups) may have created sibling groups + # like "no_buses__conflict1" precisely to keep incompatible components + # apart (e.g. on nRF52, network pulls in openthread which zigbee + # conflicts with); redistribution must not silently undo that split. + leftover: list[str] = [] + for i, comp in enumerate(no_buses_comps): + placed = False + # Try groups starting at the round-robin offset to keep the spread. + for offset in range(len(platform_groups)): + sig, comps = platform_groups[(i + offset) % len(platform_groups)] + if any(components_conflict(comp, other, platform) for other in comps): + continue + # comps is the same list object stored in grouped_components, so + # this also extends the group in grouped_components. + comps.append(comp) + placed = True + break + if not placed: + leftover.append(comp) + + if leftover: + # Components that conflict with every existing group stay together in + # their own no_buses group (they were grouped before, so they don't + # conflict with each other). + grouped_components.setdefault((platform, NO_BUSES_SIGNATURE), []).extend( + leftover + ) groups_to_test = [] individual_tests = set() # Use set to avoid duplicates diff --git a/tests/components/api/test.nrf52-adafruit.yaml b/tests/components/api/test.nrf52-adafruit.yaml index 9229d68aa3..18bf23d710 100644 --- a/tests/components/api/test.nrf52-adafruit.yaml +++ b/tests/components/api/test.nrf52-adafruit.yaml @@ -1,4 +1,7 @@ network: enable_ipv6: true +openthread: + tlv: 0E080000000000010000 + api: diff --git a/tests/components/mdns/test.nrf52-adafruit.yaml b/tests/components/mdns/test.nrf52-adafruit.yaml index 6aff688ff4..c24d0a1908 100644 --- a/tests/components/mdns/test.nrf52-adafruit.yaml +++ b/tests/components/mdns/test.nrf52-adafruit.yaml @@ -1,4 +1,7 @@ network: enable_ipv6: true +openthread: + tlv: 0E080000000000010000 + mdns: diff --git a/tests/components/network/test.nrf52-adafruit.yaml b/tests/components/network/test.nrf52-adafruit.yaml index 61889b0361..ac2fe63739 100644 --- a/tests/components/network/test.nrf52-adafruit.yaml +++ b/tests/components/network/test.nrf52-adafruit.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/network/test.nrf52-mcumgr.yaml b/tests/components/network/test.nrf52-mcumgr.yaml index 61889b0361..ac2fe63739 100644 --- a/tests/components/network/test.nrf52-mcumgr.yaml +++ b/tests/components/network/test.nrf52-mcumgr.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/network/test.nrf52-xiao-ble.yaml b/tests/components/network/test.nrf52-xiao-ble.yaml index 61889b0361..ac2fe63739 100644 --- a/tests/components/network/test.nrf52-xiao-ble.yaml +++ b/tests/components/network/test.nrf52-xiao-ble.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 82ff5e1411..886d413ccf 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -35,6 +35,8 @@ def clear_helpers_cache() -> None: helpers._get_github_event_data.cache_clear() helpers._get_changed_files_github_actions.cache_clear() helpers.get_components_per_integration_fixture.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() @pytest.mark.parametrize( @@ -1504,6 +1506,8 @@ def fake_components(tmp_path: Path) -> Path: write("callable_auto", "def AUTO_LOAD():\n return ['beta']\n") write("broken", "this is not valid python !!!") helpers.parse_component_metadata.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() return tmp_path @@ -1624,6 +1628,47 @@ def test_split_conflicting_groups_preserves_original_signature_for_first_bucket( assert signature.startswith("i2c__conflict") +def test_split_conflicting_groups_seeds_from_test_config( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """A conflict reachable only via a component's test config splits the group. + + ``host_user`` declares no static conflict with ``beta``, but its + ``test..yaml`` pulls in ``beta_variant`` (which AUTO_LOADs + ``beta``). On that platform the group must split; on another platform + (no such test config) it must stay together. + """ + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + + # host_user has no static metadata, but its esp32 test config references + # beta_variant -> AUTO_LOAD beta, which conflicts with alpha. + tests_dir = fake_components / "tests" / "components" / "host_user" + tests_dir.mkdir(parents=True) + (tests_dir / "test.esp32.yaml").write_text("beta_variant:\n") + (fake_components / "esphome" / "components" / "host_user").mkdir() + ( + fake_components / "esphome" / "components" / "host_user" / "__init__.py" + ).write_text("") + + helpers.parse_component_metadata.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() + + # On esp32, host_user pulls in beta (via its test config) -> conflicts with alpha. + result = helpers.split_conflicting_groups( + {("esp32", "no_buses"): ["alpha", "host_user"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"alpha", "host_user"} <= set(bucket)) + + # On a platform without that test config, they stay grouped together. + result_other = helpers.split_conflicting_groups( + {("rp2040", "no_buses"): ["alpha", "host_user"]} + ) + assert result_other == {("rp2040", "no_buses"): ["alpha", "host_user"]} + + # --------------------------------------------------------------------------- # get_component_test_files / is_validate_only_file # --------------------------------------------------------------------------- From 3035355c0ade9c2f9d6ac885cf95582c3e19c50d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:38:31 -0400 Subject: [PATCH 1918/2214] [ci] Widen import-time margin for CI runner variance (#17287) --- script/import_time_budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/import_time_budget.json b/script/import_time_budget.json index af3aa83511..855d89c56d 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", - "margin_pct": 15, + "margin_pct": 20, "cumulative_us": 91000 } From afb5922f3748bbade779fbee840a57cc3fd5e7ea Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 30 Jun 2026 11:26:51 -0700 Subject: [PATCH 1919/2214] [modbus] Update client components to use ModbusClientDevice (#11987) --- esphome/components/growatt_solar/growatt_solar.h | 2 +- esphome/components/growatt_solar/sensor.py | 12 ++++++++++-- esphome/components/havells_solar/havells_solar.h | 2 +- esphome/components/havells_solar/sensor.py | 12 ++++++++++-- esphome/components/kuntze/kuntze.h | 2 +- esphome/components/kuntze/sensor.py | 12 ++++++++++-- esphome/components/modbus/__init__.py | 8 ++++++++ esphome/components/modbus/modbus.h | 4 +++- esphome/components/modbus_controller/__init__.py | 7 ++++--- esphome/components/pzemac/pzemac.h | 2 +- esphome/components/pzemac/sensor.py | 12 ++++++++++-- esphome/components/pzemdc/pzemdc.h | 2 +- esphome/components/pzemdc/sensor.py | 12 ++++++++++-- esphome/components/sdm_meter/sdm_meter.h | 2 +- esphome/components/sdm_meter/sensor.py | 14 ++++++++++++-- esphome/components/selec_meter/selec_meter.h | 2 +- esphome/components/selec_meter/sensor.py | 12 ++++++++++-- 17 files changed, 94 insertions(+), 25 deletions(-) diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 76d430737a..18a7c917d5 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -65,7 +65,7 @@ constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 -class GrowattSolar final : public PollingComponent, public modbus::ModbusDevice { +class GrowattSolar final : public PollingComponent, public modbus::ModbusClientDevice { public: void loop() override; void update() override; diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 7458b88b72..d1f0069341 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -25,6 +25,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType CONF_ENERGY_PRODUCTION_DAY = "energy_production_day" CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production" @@ -47,7 +48,7 @@ CODEOWNERS = ["@leeuwte"] growatt_solar_ns = cg.esphome_ns.namespace("growatt_solar") GrowattSolar = growatt_solar_ns.class_( - "GrowattSolar", cg.PollingComponent, modbus.ModbusDevice + "GrowattSolar", cg.PollingComponent, modbus.ModbusClientDevice ) PHASE_SENSORS = { @@ -162,10 +163,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("growatt_solar", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION])) diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index ec6d5b5657..02e999c56c 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -8,7 +8,7 @@ namespace esphome::havells_solar { -class HavellsSolar final : public PollingComponent, public modbus::ModbusDevice { +class HavellsSolar final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index f0683e1d9c..d18ae0d9af 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -28,6 +28,7 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType CONF_ENERGY_PRODUCTION_DAY = "energy_production_day" CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production" @@ -58,7 +59,7 @@ CODEOWNERS = ["@sourabhjaiswal"] havells_solar_ns = cg.esphome_ns.namespace("havells_solar") HavellsSolar = havells_solar_ns.class_( - "HavellsSolar", cg.PollingComponent, modbus.ModbusDevice + "HavellsSolar", cg.PollingComponent, modbus.ModbusClientDevice ) PHASE_SENSORS = { @@ -216,10 +217,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("havells_solar", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_FREQUENCY in config: sens = await sensor.new_sensor(config[CONF_FREQUENCY]) diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index 99dd78e5b6..46681843d2 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -6,7 +6,7 @@ namespace esphome::kuntze { -class Kuntze final : public PollingComponent, public modbus::ModbusDevice { +class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_ph_sensor(sensor::Sensor *ph_sensor) { ph_sensor_ = ph_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index 96b6334730..c11ede9db6 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -15,13 +15,14 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PH, ) +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] AUTO_LOAD = ["modbus"] kuntze_ns = cg.esphome_ns.namespace("kuntze") -Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusDevice) +Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusClientDevice) CONF_DIS1 = "dis1" CONF_DIS2 = "dis2" @@ -88,10 +89,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("kuntze", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_PH in config: conf = config[CONF_PH] diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index cf1d409393..9e64540382 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import Literal from esphome import pins @@ -10,6 +11,8 @@ from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ["uart"] modbus_ns = cg.esphome_ns.namespace("modbus") @@ -129,4 +132,9 @@ async def register_modbus_server_device(var, config): async def register_modbus_device(var, config): + # Remove before 2026.12.0 + _LOGGER.warning( + "'register_modbus_device' is deprecated, use 'register_modbus_client_device' " + "instead. Will be removed in 2026.12.0" + ) return await register_modbus_client_device(var, config) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 4aa3a16c3a..b0f2aed9f8 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -197,7 +197,9 @@ class ModbusClientDevice { }; // This is for compatibility with external components using the former class name -using ModbusDevice = ModbusClientDevice; +// Remove before 2026.12.0 +using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0", + "2026.6.0") = ModbusClientDevice; // Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return. using ServerResponseStatus = std::optional; diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 67e5757397..cdbba54c1f 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -11,6 +11,7 @@ from esphome.components.modbus.helpers import ( import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET from esphome.cpp_helpers import logging +from esphome.types import ConfigType from .const import ( CONF_ALLOW_DUPLICATE_COMMANDS, @@ -42,7 +43,7 @@ MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") ModbusController = modbus_controller_ns.class_( - "ModbusController", cg.PollingComponent, modbus.ModbusDevice + "ModbusController", cg.PollingComponent, modbus.ModbusClientDevice ) SensorItem = modbus_controller_ns.struct("SensorItem") @@ -117,7 +118,7 @@ def validate_modbus_register(config): return config -def _final_validate(config): +def _final_validate(config: ConfigType) -> ConfigType: return modbus.final_validate_modbus_device("modbus_controller", role="client")( config ) @@ -211,7 +212,7 @@ async def to_code(config): async def register_modbus_device(var, config): cg.add(var.set_address(config[CONF_ADDRESS])) await cg.register_component(var, config) - return await modbus.register_modbus_device(var, config) + return await modbus.register_modbus_client_device(var, config) def function_code_to_register(function_code): diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index a25a8cb631..a3ad7e1167 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -11,7 +11,7 @@ namespace esphome::pzemac { template class ResetEnergyAction; -class PZEMAC final : public PollingComponent, public modbus::ModbusDevice { +class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index c134bc19c1..4e228f6aa3 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -26,11 +26,12 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] pzemac_ns = cg.esphome_ns.namespace("pzemac") -PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusDevice) +PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusClientDevice) # Actions ResetEnergyAction = pzemac_ns.class_("ResetEnergyAction", automation.Action) @@ -97,10 +98,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("pzemac", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_VOLTAGE in config: conf = config[CONF_VOLTAGE] diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index e398330cd3..7d14a5ed4b 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -9,7 +9,7 @@ namespace esphome::pzemdc { -class PZEMDC final : public PollingComponent, public modbus::ModbusDevice { +class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 3291be4c34..40cfe7b08a 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -20,11 +20,12 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] pzemdc_ns = cg.esphome_ns.namespace("pzemdc") -PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusDevice) +PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusClientDevice) # Actions ResetEnergyAction = pzemdc_ns.class_("ResetEnergyAction", automation.Action) @@ -79,10 +80,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("pzemdc", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_VOLTAGE in config: conf = config[CONF_VOLTAGE] diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index a4dbde016c..aa71fcaa47 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -8,7 +8,7 @@ namespace esphome::sdm_meter { -class SDMMeter final : public PollingComponent, public modbus::ModbusDevice { +class SDMMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 8006d0b4ba..46f5025080 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -41,12 +41,15 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] CODEOWNERS = ["@polyfaces", "@jesserockz"] sdm_meter_ns = cg.esphome_ns.namespace("sdm_meter") -SDMMeter = sdm_meter_ns.class_("SDMMeter", cg.PollingComponent, modbus.ModbusDevice) +SDMMeter = sdm_meter_ns.class_( + "SDMMeter", cg.PollingComponent, modbus.ModbusClientDevice +) PHASE_SENSORS = { CONF_VOLTAGE: sensor.sensor_schema( @@ -145,10 +148,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("sdm_meter", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_TOTAL_POWER in config: sens = await sensor.new_sensor(config[CONF_TOTAL_POWER]) diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 6b5552a098..c367d1d15d 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -15,7 +15,7 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } -class SelecMeter final : public PollingComponent, public modbus::ModbusDevice { +class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: SELEC_METER_SENSOR(total_active_energy) SELEC_METER_SENSOR(import_active_energy) diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 1a53eb5c37..ef4929c375 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -32,6 +32,7 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] CODEOWNERS = ["@sourabhjaiswal"] @@ -49,7 +50,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kVARh" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( - "SelecMeter", cg.PollingComponent, modbus.ModbusDevice + "SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice ) SENSORS = { @@ -163,10 +164,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("selec_meter", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) for name in SENSORS: if name in config: sens = await sensor.new_sensor(config[name]) From b79cbcbde77dce3582c5d4ae9dd0045b08a596e2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:56:18 -0400 Subject: [PATCH 1920/2214] [espidf] Install native ESP-IDF into a machine-global cache dir (#17306) --- docker/docker_entrypoint.sh | 4 ++ .../etc/s6-overlay/s6-rc.d/esphome/run | 4 ++ esphome/__main__.py | 5 +- esphome/espidf/clang_tidy.py | 6 +-- esphome/espidf/framework.py | 35 +++++++++----- esphome/writer.py | 9 ++++ requirements.txt | 1 + tests/unit_tests/test_espidf_framework.py | 47 +++++++++++++++++++ tests/unit_tests/test_writer.py | 38 +++++++++++++-- 9 files changed, 129 insertions(+), 20 deletions(-) diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 18baf40c29..598b553c08 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -21,6 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" +# Keep the native ESP-IDF install on the persistent cache root, not the +# container's ephemeral user cache dir (re-downloaded on every restart). +export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf" + # If /build is mounted, use that as the build path # otherwise use path in /config (so that builds aren't lost on container restart) if [[ -d /build ]]; then diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index dff61fd2f3..f50de659b9 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -15,6 +15,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" +# Keep the native ESP-IDF install on the persistent /data volume, not the +# container's ephemeral user cache dir (wiped on every add-on update/restart). +export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf + if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true fi diff --git a/esphome/__main__.py b/esphome/__main__.py index 1062df7167..1767d3b7ca 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2386,7 +2386,10 @@ def parse_args(argv): ) parser_clean_all = subparsers.add_parser( - "clean-all", help="Clean all build and platform files." + "clean-all", + help="Clean all build and platform files, including machine-global " + "toolchain caches shared by all configurations, so other projects will " + "re-download them on next build.", ) parser_clean_all.add_argument( "configuration", help="Your YAML file or configuration directory.", nargs="*" diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index d3f4d151c2..88ecda60b9 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -147,9 +147,9 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: from esphome.core import CORE CORE.name = TIDY_PROJECT_NAME - # config_path's parent is the data dir root: the IDF install lives at - # ``/.esphome/idf`` -- keep it beside (not inside) the per-run - # project dir so clearing the project doesn't force an IDF re-download. + # config_path's parent is the data dir root for per-run artifacts (idedata, + # converted pio_components). The IDF install is in the global cache dir, + # independent of this path. CORE.config_path = work_dir.parent / "tidy.yaml" CORE.build_path = work_dir esp32 = CORE.data.setdefault(KEY_ESP32, {}) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index c994ce2410..25283e3c99 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -9,6 +9,8 @@ import re import shutil import tempfile +import platformdirs + from esphome.config_validation import Version from esphome.core import CORE from esphome.framework_helpers import ( @@ -80,10 +82,18 @@ def _get_idf_tools_path() -> Path: Returns: Path object pointing to the ESP-IDF tools directory """ - if "ESPHOME_ESP_IDF_PREFIX" in os.environ: - path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + # Treat an empty/whitespace ESPHOME_ESP_IDF_PREFIX as unset: Path("") + # resolves to the CWD, which would install into (and let clean-all delete) + # the working directory by accident. + if prefix := get_str_env("ESPHOME_ESP_IDF_PREFIX", "").strip(): + path = Path(prefix).expanduser() else: - path = CORE.data_dir / "idf" + # Machine-global so all projects share the multi-GB install instead of + # a per-config-directory copy. The user cache dir (not ~/.esphome) + # avoids colliding with data_dir when configs live in the home dir. + # appauthor=False drops the redundant \ segment on Windows + # (which otherwise repeats "esphome\esphome\") to keep the path short. + path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" # Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``) # doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which # otherwise warns that the venv interpreter path doesn't match the install. @@ -145,10 +155,11 @@ def _check_windows_path_length() -> None: " fatal error: bits/c++config.h: No such file or directory\n" " cannot execute 'as': CreateProcess: No such file or directory\n" "To fix, either:\n" - " - Enable Windows long path support: set\n" - " HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled\n" - " to 1 and reboot, or\n" - " - Move your ESPHome project to a shorter path\n" + " - Enable Windows long path support, then reboot. In an elevated\n" + " PowerShell run:\n" + " Set-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem' LongPathsEnabled 1\n" + " Details: https://learn.microsoft.com/windows/win32/fileio/maximum-file-path-limitation\n" + " - Or set ESPHOME_ESP_IDF_PREFIX to a shorter path (e.g. C:\\ESPHome\\idf)\n" "Then delete the ESP-IDF tools directory above so the toolchain " "reinstalls cleanly.", tools_path, @@ -553,7 +564,7 @@ def _check_esphome_idf_framework_install( # Logged every invocation (not just on install) so the user can verify the # override. A changed URL needs ``esphome clean-all`` to force a re-download # (``esphome clean`` only wipes the build dir, not the extracted framework - # under /idf/frameworks/). + # under the global install dir's ``frameworks/``). if source_url: _LOGGER.info("Using framework source override: %s", source_url) @@ -822,11 +833,9 @@ def _ccache_env() -> dict[str, str]: Enabled by default whenever the ``ccache`` binary is on PATH; set ``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under - the IDF tools path. How widely it is shared depends on where that resolves: - across projects (and surviving ``clean-all``) when it is a common location - (``ESPHOME_ESP_IDF_PREFIX`` or the add-on ``/data``), but per-project under - ``.esphome/idf`` for a default pip install, where ``clean-all`` clears it - along with the framework. + the IDF tools path (the machine-global cache dir, or + ``ESPHOME_ESP_IDF_PREFIX``), so it is shared across all projects and removed + by ``esphome clean-all`` along with the framework. Depend mode keeps cache-miss overhead low (hashes the compiler's depfiles instead of preprocessing). ``CCACHE_BASEDIR`` rewrites the per-build diff --git a/esphome/writer.py b/esphome/writer.py index a9c072f156..52f2d169b3 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -653,6 +653,15 @@ def clean_all(configuration: list[str]): elif item.is_dir() and item.name != "storage": rmtree(item) + # The native ESP-IDF install lives in a machine-global cache dir, outside + # any .esphome data dir, so the per-config loop above won't reach it. + from esphome.espidf.framework import _get_idf_tools_path + + idf_install_path = _get_idf_tools_path() + if idf_install_path.is_dir(): + _LOGGER.info("Deleting %s", idf_install_path) + rmtree(idf_install_path) + # Clean PlatformIO project files try: from platformio.project.config import ProjectConfig diff --git a/requirements.txt b/requirements.txt index 85f4b56c07..3832045cfc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,7 @@ bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 py7zr==1.1.3 +platformdirs==4.9.4 # native esp-idf toolchain global cache dir # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index fe888ac8b9..f3e160925a 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -36,6 +36,19 @@ from esphome.espidf.framework import ( from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path +@pytest.fixture(autouse=True) +def _isolate_idf_install_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the ESP-IDF install root to a tmp dir for every test. + + The default location is the OS user cache dir, so without this any test + that builds framework paths or pre-creates the framework dir would touch + the real ``~/.cache/esphome`` on the developer's machine. Tests that need + to exercise the override or default-resolution logic clear/override the env + themselves. + """ + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(tmp_path / "idf_install")) + + @pytest.mark.parametrize( ("source", "expected"), [ @@ -791,6 +804,38 @@ def test_get_idf_tools_path_env_override(tmp_path: Path) -> None: assert _get_idf_tools_path() == Path(override) +@pytest.mark.parametrize("value", ["", " "]) +def test_get_idf_tools_path_blank_env_falls_back_to_default( + value: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD. + + Path("") would resolve to the working directory, which clean-all could then + delete by accident. + """ + import platformdirs + + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", value) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" + ).resolve() + assert _get_idf_tools_path() == expected + + +def test_get_idf_tools_path_default_uses_user_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Without the env override the install root is the machine-global OS user + cache dir, not the per-config ``/idf``.""" + import platformdirs + + monkeypatch.delenv("ESPHOME_ESP_IDF_PREFIX", raising=False) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" + ).resolve() + assert _get_idf_tools_path() == expected + + def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: with patch("pathlib.Path.write_text", side_effect=OSError("denied")): # write failure is caught and warned, not raised @@ -908,3 +953,5 @@ def test_check_windows_path_length_long_path_warns( message = caplog.records[0].getMessage() assert _LONG_IDF_PATH in message assert "long path support" in message + # The install is global now; the remedy is the prefix env, not moving the project. + assert "ESPHOME_ESP_IDF_PREFIX" in message diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index c8cf68ff3e..18d08e7cb1 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -67,15 +67,23 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: want to verify the PIO-cleanup branch (e.g. test_clean_all, test_clean_all_partial_exists) install their own inner patch which stacks on top of this one and wins for the duration of their block. + + Also pin ``ESPHOME_ESP_IDF_PREFIX`` to a nonexistent tmp dir for the + same reason: ``clean_all`` removes the now machine-global ESP-IDF + install, which otherwise defaults to the real ``~/.cache/esphome``. """ pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" + idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent" mock_cfg = MagicMock() mock_cfg.get.side_effect = lambda section, option: ( str(pio_root / option) if section == "platformio" else "" ) - with patch( - "platformio.project.config.ProjectConfig.get_instance", - return_value=mock_cfg, + with ( + patch( + "platformio.project.config.ProjectConfig.get_instance", + return_value=mock_cfg, + ), + patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": str(idf_root)}), ): yield @@ -990,6 +998,30 @@ def test_clean_all_with_yaml_file( assert str(build_dir) in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_removes_global_idf_install( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the machine-global native ESP-IDF install dir.""" + idf_install = tmp_path / "idf_install" + (idf_install / "frameworks").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(idf_install)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not idf_install.exists() + assert str(idf_install.resolve()) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_with_yaml_build_path( mock_core: MagicMock, From 990431aa5bf201c02d8560ddc30efe34d195d748 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:56:30 -0400 Subject: [PATCH 1921/2214] [bluetooth_proxy] Fix -Wtype-limits warning with active: false (#17273) --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 10449f21f1..2b6d29da43 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -68,11 +68,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, void loop() override; esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; - void register_connection(BluetoothConnection *connection) { + // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. + void register_connection([[maybe_unused]] BluetoothConnection *connection) { + // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; } +#endif } void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); From 9468ad628cdf848590ea8238cf5a77e604b54c9f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:57:34 -0400 Subject: [PATCH 1922/2214] [espnow] Drop oversized received frames to prevent buffer overflow (#17271) --- esphome/components/espnow/espnow_component.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 2756b615a1..91f2c067ca 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -94,6 +94,15 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) } void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a + // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), + // but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger + // frame would overflow packet_.receive.data. + if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) { + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + // Allocate an event from the pool ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); if (packet == nullptr) { @@ -327,13 +336,13 @@ void ESPNowComponent::loop() { // Log dropped received packets periodically uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count(); if (received_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped); + ESP_LOGW(TAG, "Dropped %u received packets (queue full or oversized frame)", received_dropped); } // Log dropped send packets periodically uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count(); if (send_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped); + ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped); } } From 8a3d0aeafb61c8a10f8f118918b983c7f0279bbc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:33:17 -0400 Subject: [PATCH 1923/2214] [tests] Add esp32-c61-idf base file for grouped component tests (#17293) --- .../build_components_base.esp32-c61-idf.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/test_build_components/build_components_base.esp32-c61-idf.yaml diff --git a/tests/test_build_components/build_components_base.esp32-c61-idf.yaml b/tests/test_build_components/build_components_base.esp32-c61-idf.yaml new file mode 100644 index 0000000000..e1bd4645cc --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-c61-idf.yaml @@ -0,0 +1,18 @@ +esphome: + name: componenttestesp32c61idf + friendly_name: $component_name + +esp32: + variant: ESP32C61 + flash_size: 8MB + framework: + type: esp-idf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From 1b556f5d0cd45c8d3ae790aaead09676a9858137 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:17:47 -0400 Subject: [PATCH 1924/2214] [ethernet] Fix ETH_SPEED_1000M build on IDF 6.0 (enum added in 6.1) (#17311) --- esphome/components/ethernet/ethernet_component_esp32.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 7a1bcae42f..5ad1e7d483 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -839,7 +839,7 @@ void EthernetComponent::dump_connect_params_() { case ETH_SPEED_100M: link_speed = 100; break; -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) case ETH_SPEED_1000M: link_speed = 1000; break; From c8b37fb1c8bb735707988b65c96f913358d1b189 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:18:33 -0400 Subject: [PATCH 1925/2214] Bump platformdirs from 4.9.4 to 4.10.0 (#17309) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3832045cfc..4237ad0f81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.9.4 # native esp-idf toolchain global cache dir +platformdirs==4.10.0 # native esp-idf toolchain global cache dir # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 4c9ed129cfb825a30ecd989c2d76f265f6332cb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:28:04 -0400 Subject: [PATCH 1926/2214] Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.3 (#17310) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ac55aa006..2016739c4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: packages: libsdl2-dev ccache version: 1.1 From 848defedd87eb9943afacec249b9a4f6b6300a1e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:09:48 +1200 Subject: [PATCH 1927/2214] Bump bundled esphome-device-builder to 1.0.23 (#17316) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 04e7998f77..af80d01496 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23 RUN \ platformio settings set enable_telemetry No \ From 3b2be021b23bd0b2a31817026264517b6771331b Mon Sep 17 00:00:00 2001 From: Julian Lunz <117189+jlunz@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:21:41 +0200 Subject: [PATCH 1928/2214] [adc] Only call cyw43_thread_enter/exit for VSYS when WiFi is active on RP2040 (#17203) --- esphome/components/adc/adc_sensor_rp2040.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 8d41edb814..894c346588 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -66,15 +66,18 @@ float ADCSensor::sample() { } uint8_t pin = this->pin_->get_pin(); -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { // Measuring VSYS on Raspberry Pico W needs to be wrapped with // `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in // https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and - // VSYS ADC both share GPIO29 + // VSYS ADC both share GPIO29. + // The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined + // transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43 + // driver is never initialized; calling cyw43_thread_enter() there hard-faults. cyw43_thread_enter(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) adc_gpio_init(pin); adc_select_input(pin - 26); @@ -84,11 +87,11 @@ float ADCSensor::sample() { aggr.add_sample(raw); } -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { cyw43_thread_exit(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (this->output_raw_) { return aggr.aggregate(); From 054c8ba48593774ce83fe7d4d497fa267a030311 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:28:43 +1200 Subject: [PATCH 1929/2214] [config_validation] Fix multicast typo in error message (#17206) --- esphome/config_validation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0ef6d212fe..3ff2c975a1 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1455,9 +1455,7 @@ def ipv6address(value): def ipv4address_multi_broadcast(value): address = ipv4address(value) if not (address.is_multicast or (address == IPv4Address("255.255.255.255"))): - raise Invalid( - f"{value} is not a multicasst address nor local broadcast address" - ) + raise Invalid(f"{value} is not a multicast address nor local broadcast address") return address From 5de508ad8caa279ba7b2ba5bcbd474f42f5a9612 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 16:21:36 +0200 Subject: [PATCH 1930/2214] [es8388] Fix DAC unable to unmute once muted (#17221) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/es8388/es8388.cpp | 8 +++++++- esphome/components/es8388/es8388_const.h | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index c015393e14..0b97240230 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -173,8 +173,14 @@ bool ES8388::set_mute_state_(bool mute_state) { ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL3, &value)); ESP_LOGV(TAG, "Read ES8388_DACCONTROL3: 0x%02X", value); + // Only toggle the DACMute bit; the other bits of this register hold unrelated + // DAC settings that must be preserved. Previously muting overwrote the whole + // register with 0x3C and unmuting never cleared the bit, so once muted the DAC + // could not be unmuted again. if (mute_state) { - value = 0x3C; + value |= ES8388_DACCONTROL3_DAC_MUTE; + } else { + value &= ~ES8388_DACCONTROL3_DAC_MUTE; } ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state)); diff --git a/esphome/components/es8388/es8388_const.h b/esphome/components/es8388/es8388_const.h index 451c9cc026..e081c55dbd 100644 --- a/esphome/components/es8388/es8388_const.h +++ b/esphome/components/es8388/es8388_const.h @@ -38,6 +38,7 @@ static const uint8_t ES8388_ADCCONTROL14 = 0x16; static const uint8_t ES8388_DACCONTROL1 = 0x17; static const uint8_t ES8388_DACCONTROL2 = 0x18; static const uint8_t ES8388_DACCONTROL3 = 0x19; +static const uint8_t ES8388_DACCONTROL3_DAC_MUTE = 0x04; // DACMute, bit 2 of DACCONTROL3 static const uint8_t ES8388_DACCONTROL4 = 0x1a; static const uint8_t ES8388_DACCONTROL5 = 0x1b; static const uint8_t ES8388_DACCONTROL6 = 0x1c; From 782b58bbeb6a7b2c0386ae3f3cd38ef6f0ee54f8 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:07:06 +0000 Subject: [PATCH 1931/2214] Bump bundled esphome-device-builder to 1.0.22 --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8dce7861df..079fd0602b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22 RUN \ platformio settings set enable_telemetry No \ From b127363fa0a8391473bdff934b0aaab264d332e8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:43:01 +1000 Subject: [PATCH 1932/2214] [mipi_spi] Bug fixes (#17247) --- esphome/components/mipi_spi/display.py | 2 ++ esphome/components/mipi_spi/mipi_spi.h | 3 +++ esphome/components/mipi_spi/models/ili.py | 7 +------ tests/component_tests/mipi_spi/test_init.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 4162459058..871736abd1 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -425,6 +425,8 @@ async def to_code(config): dc_pin = await cg.gpio_pin_expression(dc_pin) cg.add(var.set_dc_pin(dc_pin)) + if config.get(CONF_INVERT_COLORS): + cg.add(var.set_invert_colors(True)) if lamb := config.get(CONF_LAMBDA): lambda_ = await cg.process_lambda( lamb, [(display.DisplayRef, "it")], return_type=cg.void diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index d9627899e0..48184fa5c1 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -151,6 +151,9 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); + } else { + // no reset pin, send software reset command + this->write_command_(SW_RESET_CMD); } // need to know when the display is ready for SLPOUT command - will be 120ms after reset diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 5df7a275df..5598a51073 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -24,13 +24,11 @@ from esphome.components.mipi import ( PWSET, PWSETN, SETEXTC, - SWRESET, VMCTR, VMCTR1, VMCTR2, VSCRSADD, DriverChip, - delay, ) from esphome.components.spi import TYPE_OCTAL @@ -367,7 +365,6 @@ ST7796 = DriverChip( width=320, height=480, initsequence=( - (SWRESET,), (CSCON, 0xC3), (CSCON, 0x96), (VMCTR1, 0x1C), @@ -728,8 +725,6 @@ DriverChip( width=128, height=160, initsequence=( - SWRESET, - delay(10), (FRMCTR1, 0x01, 0x2C, 0x2D), (FRMCTR2, 0x01, 0x2C, 0x2D), (FRMCTR3, 0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D), @@ -786,7 +781,7 @@ ST7796.extend( bus_mode=TYPE_OCTAL, mirror_x=True, reset_pin=4, - dc_pin=0, + dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, ) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index dbd8e15702..8edbe095b7 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -377,6 +377,6 @@ def test_lvgl_generation( "mipi_spi::MipiSpi();" in main_cpp ) - assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp + assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp From 6c44775bf594ad1d8f51fda1dc0bd71eef91b4a2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:56:30 -0400 Subject: [PATCH 1933/2214] [bluetooth_proxy] Fix -Wtype-limits warning with active: false (#17273) --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 10449f21f1..2b6d29da43 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -68,11 +68,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, void loop() override; esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; - void register_connection(BluetoothConnection *connection) { + // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. + void register_connection([[maybe_unused]] BluetoothConnection *connection) { + // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; } +#endif } void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); From 06c5bcbc668bcf4591fe69569e0558917be389d2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:57:34 -0400 Subject: [PATCH 1934/2214] [espnow] Drop oversized received frames to prevent buffer overflow (#17271) --- esphome/components/espnow/espnow_component.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 2756b615a1..91f2c067ca 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -94,6 +94,15 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) } void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a + // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), + // but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger + // frame would overflow packet_.receive.data. + if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) { + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + // Allocate an event from the pool ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); if (packet == nullptr) { @@ -327,13 +336,13 @@ void ESPNowComponent::loop() { // Log dropped received packets periodically uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count(); if (received_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped); + ESP_LOGW(TAG, "Dropped %u received packets (queue full or oversized frame)", received_dropped); } // Log dropped send packets periodically uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count(); if (send_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped); + ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped); } } From 4472d3b61bd45f8b8b8ff3c04a90c3fba1410879 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:09:48 +1200 Subject: [PATCH 1935/2214] Bump bundled esphome-device-builder to 1.0.23 (#17316) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 079fd0602b..0c3b27a04d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23 RUN \ platformio settings set enable_telemetry No \ From e47feace11b22d4d7fc30068f18fa983ee828668 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:18:54 +1200 Subject: [PATCH 1936/2214] Bump version to 2026.6.4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index bc92241937..e38f280006 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.3 +PROJECT_NUMBER = 2026.6.4 # 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 b7ffb9121d..81bde6dfa2 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.3" +__version__ = "2026.6.4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0e260e5cbbe14e5a8aa7f0f8aeeac8ea20e1a931 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:52:09 -0500 Subject: [PATCH 1937/2214] Bump bundled esphome-device-builder to 1.0.24 (#17332) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index af80d01496..064ba2a358 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.24 RUN \ platformio settings set enable_telemetry No \ From d25d1606867972060c22d2c7dea9118ae89a408d Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Wed, 1 Jul 2026 13:01:48 -0700 Subject: [PATCH 1938/2214] [modbus_server] Fix register range issues and allow partial reads (#17205) Co-authored-by: Claude Opus 4.8 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/modbus_server/__init__.py | 55 +++++- esphome/components/modbus_server/const.py | 1 + .../modbus_server/modbus_server.cpp | 107 ++++++++---- .../components/modbus_server/modbus_server.h | 6 + .../modbus_server/test_modbus_server.py | 84 +++++++++ tests/components/modbus_server/common.yaml | 1 + .../modbus_server/modbus_server_test.cpp | 161 ++++++++++++++++++ 7 files changed, 382 insertions(+), 33 deletions(-) create mode 100644 tests/component_tests/modbus_server/test_modbus_server.py diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 2ba7f41b83..14f4ca8a4d 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -8,8 +8,10 @@ from esphome.components.modbus.helpers import ( ) import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.types import ConfigType from .const import ( + CONF_ALLOW_PARTIAL_READ, CONF_COURTESY_RESPONSE, CONF_READ_LAMBDA, CONF_REGISTER_LAST_ADDRESS, @@ -41,17 +43,62 @@ SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( } ) +# RAW has no numeric encoding, so it is not a valid server register type: a server value is produced by a +# lambda and encoded into registers, and on the server a RAW register would just be a single 16-bit word -- +# use U_WORD for that. Restrict the choices to the encodable types. +SERVER_SENSOR_VALUE_TYPE = { + key: value for key, value in SENSOR_VALUE_TYPE.items() if key != "RAW" +} + ModbusServerRegisterSchema = cv.Schema( { cv.GenerateID(): cv.declare_id(ServerRegister), cv.Required(CONF_ADDRESS): cv.hex_uint16_t, - cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), + cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum( + SERVER_SENSOR_VALUE_TYPE + ), cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_ALLOW_PARTIAL_READ, default=False): cv.boolean, } ) +def _validate_register_ranges(config: ConfigType) -> ConfigType: + # Each register occupies [address, address + register_count); the whole span must fit inside the 16-bit + # Modbus address space (0x0000-0xFFFF). + for register in config.get(CONF_REGISTERS, []): + address = register[CONF_ADDRESS] + register_count = TYPE_REGISTER_MAP[register[CONF_VALUE_TYPE]] + if address + register_count > 0x10000: + raise cv.Invalid( + f"Register at 0x{address:04X} spans {register_count} register(s) and runs past " + "the end of the 16-bit address space (0xFFFF)", + path=[CONF_REGISTERS], + ) + return config + + +def _validate_no_overlapping_registers(config: ConfigType) -> ConfigType: + # Each register occupies [address, address + register_count). Reject configs where any two ranges + # overlap -- the same address twice, or a multi-register value straddling a neighbour -- since the + # server resolves a request by the value containing an address and overlaps are ambiguous. + spans = sorted( + (register[CONF_ADDRESS], TYPE_REGISTER_MAP[register[CONF_VALUE_TYPE]]) + for register in config.get(CONF_REGISTERS, []) + ) + for (address, register_count), (next_address, _) in zip( + spans, spans[1:], strict=False + ): + if next_address < address + register_count: + raise cv.Invalid( + f"Register address 0x{next_address:04X} overlaps the register at 0x{address:04X}, " + f"which spans {register_count} register(s); each register's address range must be unique", + path=[CONF_REGISTERS], + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -62,10 +109,12 @@ CONFIG_SCHEMA = cv.All( ): cv.ensure_list(ModbusServerRegisterSchema), } ).extend(modbus.modbus_device_schema(0x01, role="server")), + _validate_register_ranges, + _validate_no_overlapping_registers, ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> ConfigType: return modbus.final_validate_modbus_device("modbus_server", role="server")(config) @@ -118,6 +167,8 @@ async def to_code(config): ), ) ) + if server_register[CONF_ALLOW_PARTIAL_READ]: + cg.add(server_register_var.set_allow_partial_read(True)) cg.add(var.add_server_register(server_register_var)) await cg.register_component(var, config) return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index f83211c207..f2a8c53f45 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -5,3 +5,4 @@ CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" CONF_REGISTERS = "registers" +CONF_ALLOW_PARTIAL_READ = "allow_partial_read" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index bb264eb993..44b1b160a5 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -8,6 +8,25 @@ using modbus::helpers::registers_to_number; static const char *const TAG = "modbus_server"; +// The widest Modbus value type (QWORD) spans four registers. +static constexpr uint8_t MAX_REGISTERS_PER_VALUE = 4; +// number_to_payload() encodes the 64-bit value returned by read_lambda() into 16-bit registers, so the +// widest possible value spans exactly sizeof(int64_t) / sizeof(uint16_t) registers. Tie the bound to that +// source so a future wider value type -- which would require widening the encoded value itself -- can't +// silently overflow the value_words buffer below (StaticVector::push_back drops words past capacity). +static_assert(MAX_REGISTERS_PER_VALUE == sizeof(int64_t) / sizeof(uint16_t), + "MAX_REGISTERS_PER_VALUE must match the register span of the widest encodable value"); + +ServerRegister *ModbusServer::find_containing_register_(uint32_t address) const { + for (auto *server_register : this->server_registers_) { + if (address >= server_register->address && + address < static_cast(server_register->address) + server_register->register_count) { + return server_register; + } + } + return nullptr; +} + modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, modbus::RegisterValues ®isters) { @@ -15,42 +34,68 @@ modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t sta "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { - bool found = false; - for (auto *server_register : this->server_registers_) { - if (server_register->address == current_address) { - if (!server_register->read_lambda) { - break; - } - int64_t value = server_register->read_lambda(); - char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; - ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", - server_register->address, static_cast(server_register->value_type), - server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); + const uint32_t end_address = static_cast(start_address) + number_of_registers; + uint32_t current_address = start_address; + while (current_address < end_address) { + ServerRegister *server_register = this->find_containing_register_(current_address); - modbus::helpers::number_to_payload(registers, value, server_register->value_type); - current_address += server_register->register_count; - found = true; - break; - } - } - - if (!found) { + if (server_register == nullptr) { + // Unregistered address: optionally answer with the courtesy default, otherwise reject. if (this->server_courtesy_response_.enabled && - (current_address <= this->server_courtesy_response_.register_last_address)) { - ESP_LOGV(TAG, - "Could not match any register to address 0x%02X, but default allowed. " - "Returning default value: %" PRIu16 ".", - current_address, this->server_courtesy_response_.register_value); + current_address <= this->server_courtesy_response_.register_last_address) { + ESP_LOGV(TAG, "No register at 0x%04X; returning courtesy default %" PRIu16 ".", + static_cast(current_address), this->server_courtesy_response_.register_value); registers.push_back(this->server_courtesy_response_.register_value); - current_address += 1; // Just increment by 1, as the default response is a single register - } else { - ESP_LOGW(TAG, - "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", - current_address); - return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + current_address += 1; // the courtesy default is always a single register + continue; } + ESP_LOGW(TAG, "No register at 0x%04X and courtesy default not allowed. Sending exception response.", + static_cast(current_address)); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; } + + if (!server_register->read_lambda) { + // Registered but not readable (write-only); don't mask it with the courtesy default. + ESP_LOGW(TAG, "Register at 0x%04X is not readable. Sending exception response.", server_register->address); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + // A multi-register value is normally atomic: the request must start at its first register and cover all of + // it. A value may opt in to partial reads, in which case the request may start inside it or stop short of + // its end and we return only the covered words. + const uint16_t value_offset = static_cast(current_address - server_register->address); + const uint16_t words_available = static_cast(server_register->register_count - value_offset); + const uint16_t words_wanted = static_cast(end_address - current_address); + const uint16_t take = words_available < words_wanted ? words_available : words_wanted; + const bool clipped = value_offset != 0 || take != server_register->register_count; + if (clipped && !server_register->allow_partial_read) { + ESP_LOGW(TAG, + "Read clips the multi-register value at 0x%04X, which does not allow partial reads. " + "Sending exception response.", + server_register->address); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + int64_t value = server_register->read_lambda(); + char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; + ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", + server_register->address, static_cast(server_register->value_type), + server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); + + // Encode the whole value once (wire word order) and emit only the covered words. Slicing the encoded words + // handles the reversed value types for free, since number_to_payload already emits in wire order. + StaticVector value_words; + modbus::helpers::number_to_payload(value_words, value, server_register->value_type); + if (value_offset + take > value_words.size()) { + // The value encoded to fewer words than its register span (e.g. a RAW register); treat as a device fault. + ESP_LOGE(TAG, "Register at 0x%04X did not encode to %u registers", server_register->address, + server_register->register_count); + return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; + } + for (uint16_t i = 0; i < take; i++) { + registers.push_back(value_words[value_offset + i]); + } + current_address += take; } return {}; diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index 0c22454528..f68d1c4a30 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -84,9 +84,13 @@ class ServerRegister { } } + void set_allow_partial_read(bool allow_partial_read) { this->allow_partial_read = allow_partial_read; } + uint16_t address{0}; SensorValueType value_type{SensorValueType::RAW}; uint8_t register_count{0}; + // When true, a read may cover only part of this multi-register value; otherwise it must read the whole value. + bool allow_partial_read{false}; ReadLambda read_lambda; WriteLambda write_lambda; }; @@ -111,6 +115,8 @@ class ModbusServer : public Component, public modbus::ModbusServerDevice { ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; } protected: + /// Find the registered value whose register span contains address, or nullptr if none does. + ServerRegister *find_containing_register_(uint32_t address) const; /// Collection of all server registers for this component std::vector server_registers_{}; /// Server courtesy response diff --git a/tests/component_tests/modbus_server/test_modbus_server.py b/tests/component_tests/modbus_server/test_modbus_server.py new file mode 100644 index 0000000000..7c978a5cd5 --- /dev/null +++ b/tests/component_tests/modbus_server/test_modbus_server.py @@ -0,0 +1,84 @@ +"""Tests for modbus_server configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.modbus_server import ( + SERVER_SENSOR_VALUE_TYPE, + _validate_no_overlapping_registers, + _validate_register_ranges, +) +from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE +from esphome.const import CONF_ADDRESS + + +def _config(registers: list[tuple[int, str]]) -> dict: + return { + CONF_REGISTERS: [ + {CONF_ADDRESS: address, CONF_VALUE_TYPE: value_type} + for address, value_type in registers + ] + } + + +def test_non_overlapping_registers_pass() -> None: + # Values that tile the address space without gaps or overlaps are accepted. + config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")]) + assert _validate_no_overlapping_registers(config) is config + + +def test_registers_with_gaps_pass() -> None: + config = _config([(0x00, "U_WORD"), (0x05, "U_QWORD"), (0x20, "U_WORD")]) + assert _validate_no_overlapping_registers(config) is config + + +def test_no_registers_pass() -> None: + assert _validate_no_overlapping_registers({}) == {} + + +def test_duplicate_address_rejected() -> None: + config = _config([(0x10, "U_WORD"), (0x10, "U_WORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_multi_register_value_overlapping_neighbour_rejected() -> None: + # U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word. + config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_overlap_detected_regardless_of_order() -> None: + # The U_DWORD at 0x10 covers 0x10-0x11 and overlaps the U_WORD at 0x11 even when declared after it. + config = _config([(0x11, "U_WORD"), (0x10, "U_DWORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_register_span_within_address_space_pass() -> None: + # A value whose span ends exactly at 0xFFFF is fine (U_QWORD at 0xFFFC covers 0xFFFC-0xFFFF). + config = _config([(0xFFFF, "U_WORD"), (0xFFFC, "U_QWORD")]) + assert _validate_register_ranges(config) is config + + +def test_register_span_past_end_rejected() -> None: + # U_QWORD at 0xFFFE would need 0xFFFE-0x10001, running off the 16-bit address space. + config = _config([(0xFFFE, "U_QWORD")]) + with pytest.raises(cv.Invalid, match="past the end"): + _validate_register_ranges(config) + + +def test_multi_register_value_at_last_address_rejected() -> None: + # A U_DWORD at 0xFFFF needs a second register at 0x10000, which does not exist. + config = _config([(0xFFFF, "U_DWORD")]) + with pytest.raises(cv.Invalid, match="past the end"): + _validate_register_ranges(config) + + +def test_raw_value_type_rejected() -> None: + # RAW has no numeric encoding, so it is not offered as a server register type. + validator = cv.enum(SERVER_SENSOR_VALUE_TYPE) + with pytest.raises(cv.Invalid): + validator("RAW") + assert validator("U_WORD") == "U_WORD" diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 2e4a81a1aa..8b2316b6e3 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -18,6 +18,7 @@ modbus_server: registers: - address: 0x9 value_type: S_DWORD + allow_partial_read: true read_lambda: |- return 31; write_lambda: |- diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 0c8f5d04cf..419bb9cf25 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -121,4 +121,165 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure } +// --- on_modbus_read_registers -------------------------------------------------- + +TEST(ModbusServerRead, SingleWordSucceeds) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x1234); +} + +TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 2, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0], 0x1234); + EXPECT_EQ(out[1], 0x5678); +} + +// Starting inside a multi-register value is rejected with ILLEGAL_DATA_ADDRESS -- not masked by the courtesy +// default -- and the read_lambda is never invoked. +TEST(ModbusServerRead, StartInsideValueRejected) { + ModbusServer server; + bool read_called = false; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); // occupies 0x0010 and 0x0011 + reg.read_lambda = [&read_called]() -> int64_t { + read_called = true; + return 0; + }; + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0011, 1, out); // the second cell of the DWORD + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(read_called); +} + +// A read that stops short of a value's end clips it -> ILLEGAL_DATA_ADDRESS, and the read_lambda is not invoked. +TEST(ModbusServerRead, ClippedTailRejected) { + ModbusServer server; + bool read_called = false; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.read_lambda = [&read_called]() -> int64_t { + read_called = true; + return 0; + }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(read_called); +} + +// A write-only register (no read_lambda) is not readable -> ILLEGAL_DATA_ADDRESS, not a courtesy default. +TEST(ModbusServerRead, WriteOnlyRegisterRejected) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); // no read_lambda set + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// An unregistered address with courtesy enabled returns the default value for each cell. +TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { + ModbusServer server; + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0005, 2, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0], 0xABCD); + EXPECT_EQ(out[1], 0xABCD); +} + +// An unregistered address with courtesy disabled is rejected. +TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { + ModbusServer server; + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0005, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// --- partial reads (opt-in) ---------------------------------------------------- + +// With allow_partial_read, reading only the first register of a DWORD returns its high word. +TEST(ModbusServerRead, PartialReadHighWord) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0010, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x1234); +} + +// With allow_partial_read, starting at the interior cell returns the low word. +TEST(ModbusServerRead, PartialReadLowWordFromInterior) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0011, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x5678); +} + +// Slicing is in wire order, so a reversed value type partials correctly: U_DWORD_R emits the low word +// first, so 0x0010 holds 0x5678 and 0x0011 holds 0x1234. +TEST(ModbusServerRead, PartialReadReversedType) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD_R, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues first; + ASSERT_FALSE(server.on_modbus_read_registers(0x0010, 1, first).has_value()); + ASSERT_EQ(first.size(), 1u); + EXPECT_EQ(first[0], 0x5678); + + RegisterValues second; + ASSERT_FALSE(server.on_modbus_read_registers(0x0011, 1, second).has_value()); + ASSERT_EQ(second.size(), 1u); + EXPECT_EQ(second[0], 0x1234); +} + } // namespace esphome::modbus_server From 0427d20c5b87e808801f95c98427635a441e8762 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:02:01 -0500 Subject: [PATCH 1939/2214] Bump bundled esphome-device-builder to 1.0.25 (#17333) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 064ba2a358..543f17db56 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.24 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.25 RUN \ platformio settings set enable_telemetry No \ From e4a68c2da3461663ce4dcd24409e5e5494469a48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:25:55 -0500 Subject: [PATCH 1940/2214] Bump pillow from 12.2.0 to 12.3.0 (#17335) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4237ad0f81..baa8b5efd2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -pillow==12.2.0 +pillow==12.3.0 resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 From 7522780c67c7d4846526c91bd11bf8f9d8153a8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Jul 2026 18:25:34 -0500 Subject: [PATCH 1941/2214] [esp8266] Strip dead libstdc++ throw message strings from DRAM (#17341) --- esphome/components/esp8266/__init__.py | 8 +++++ esphome/components/esp8266/throw_stubs.h | 41 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 esphome/components/esp8266/throw_stubs.h diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 4daf4549ef..b658feb76a 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -310,6 +310,14 @@ async def to_code(config): # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` cg.add_build_flag("-DNEW_OOM_ABORT") + # Force-include inline std::__throw_* overrides so GCC dead-strips the unused + # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. + # See throw_stubs.h for details. Must be prepended before , so this + # uses build_src_flags with -include. + cg.add_platformio_option( + "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" + ) + # In testing mode, fake larger memory to allow linking grouped component tests # Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing # we pretend it has much larger memory to test that components compile together diff --git a/esphome/components/esp8266/throw_stubs.h b/esphome/components/esp8266/throw_stubs.h new file mode 100644 index 0000000000..a650935a5e --- /dev/null +++ b/esphome/components/esp8266/throw_stubs.h @@ -0,0 +1,41 @@ +#pragma once +/* + * Inline overrides for std::__throw_* helpers (ESP8266). + * + * ESP8266 Arduino compiles with -fno-exceptions and ships a libstdc++ whose + * std::__throw_* functions already just call abort() -- they never read their + * const char* message argument. But the compiler still emits the message load + * at every throw site (inside header-instantiated std::string / std::vector + * code), so --gc-sections keeps those libstdc++ error strings alive. On + * ESP8266 .rodata lives in DRAM, so each one wastes scarce RAM (e.g. + * "basic_string::_M_construct null not valid", "basic_string::_M_create", + * "cannot create std::vector larger than max_size()", "array::at: ..."). + * + * Providing inline definitions here lets GCC see the message argument is + * unused, dead-strip the load, and drop the string entirely -- no LTO needed. + * Behavior is identical to today: a bare abort() (the message was never + * printed). This header MUST be force-included before , so it is + * wired up via build_src_flags "-include ..." in this component's __init__.py. + * + * Note: this defines functions in namespace std (technically UB). It is safe + * here because the definitions match the existing abort() behavior exactly. + */ + +#ifdef __cplusplus + +// Empty namespace so the CI namespace check is satisfied; the overrides below +// must live in namespace std, so they cannot go in the component namespace. +namespace esphome::esp8266 {} // namespace esphome::esp8266 + +// NOLINTBEGIN(bugprone-reserved-identifier,bugprone-std-namespace-modification,cert-dcl37-c,cert-dcl51-cpp,cert-dcl58-cpp,readability-identifier-naming) +namespace std { + +__attribute__((__noreturn__)) inline void __throw_logic_error(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_length_error(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_out_of_range(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_out_of_range_fmt(const char *, ...) { __builtin_abort(); } + +} // namespace std +// NOLINTEND(bugprone-reserved-identifier,bugprone-std-namespace-modification,cert-dcl37-c,cert-dcl51-cpp,cert-dcl58-cpp,readability-identifier-naming) + +#endif // __cplusplus From 4a7c58d5aed36741527ae890aec3ae6cb9d96b11 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 2 Jul 2026 03:26:56 +0200 Subject: [PATCH 1942/2214] [usb_uart] Fix format specifier warnings for uint32_t in ft23xx and pl2303 (#17342) --- esphome/components/usb_uart/ft23xx.cpp | 7 ++++--- esphome/components/usb_uart/pl2303.cpp | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 3b0e05ba53..2e8ff8bcb5 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -6,6 +6,7 @@ #include "esphome/components/uart/uart_debugger.h" #include "esphome/components/bytebuffer/bytebuffer.h" +#include namespace esphome::usb_uart { @@ -288,16 +289,16 @@ int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); } else { - ESP_LOGD(TAG, "Baudrate %d set, setting line properties...", channel->baud_rate_); + ESP_LOGD(TAG, "Baudrate %" PRIu32 " set, setting line properties...", channel->baud_rate_); this->set_line_properties_(channel); } }; if (baudrate == 0) { baudrate = channel->baud_rate_; } - uint16_t value, ftdi_index; + uint16_t value = 0, ftdi_index = 0; ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); - ESP_LOGD(TAG, "Baudrate: %d, value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); + ESP_LOGD(TAG, "Baudrate: %" PRIu32 ", value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback); if (!ok) { diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 3685debef4..134c51198d 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -3,6 +3,7 @@ #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" +#include namespace esphome::usb_uart { @@ -282,8 +283,8 @@ void USBUartTypePL2303::enable_channels() { // Data bits line_coding[6] = channel->get_data_bits(); - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], - line_coding[6]); + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], + line_coding[5], line_coding[6]); std::vector lc_vec(line_coding, line_coding + 7); uint16_t iface = channel->cdc_dev_.bulk_interface_number; From 5b8bf510226d47c8b33fe0e4a7342e1c81001e77 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:56:01 +1000 Subject: [PATCH 1943/2214] [power_supply] Make enable_on_boot high priority (#16914) --- .../components/power_supply/power_supply.cpp | 6 ++- esphome/core/component.h | 2 + .../power_supply/test_setup_priority.cpp | 47 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/components/power_supply/test_setup_priority.cpp diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index 4da73e76ae..f094f6e2e9 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -21,7 +21,11 @@ void PowerSupply::dump_config() { LOG_PIN(" Pin: ", this->pin_); } -float PowerSupply::get_setup_priority() const { return setup_priority::IO; } +float PowerSupply::get_setup_priority() const { + if (this->pin_->is_internal() && this->enable_on_boot_) + return setup_priority::POWER; + return setup_priority::IO; +} bool PowerSupply::is_enabled() const { return this->active_requests_ != 0; } diff --git a/esphome/core/component.h b/esphome/core/component.h index 1ae70371a1..70a051ca0b 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -33,6 +33,8 @@ class RuntimeStatsCollector; */ namespace setup_priority { +/// For power supply components that must be on before buses like i2c can work. +inline constexpr float POWER = 1200.0f; /// For communication buses like i2c/spi inline constexpr float BUS = 1000.0f; /// For components that represent GPIO pins like PCF8573 diff --git a/tests/components/power_supply/test_setup_priority.cpp b/tests/components/power_supply/test_setup_priority.cpp new file mode 100644 index 0000000000..401fc72654 --- /dev/null +++ b/tests/components/power_supply/test_setup_priority.cpp @@ -0,0 +1,47 @@ +#include + +#include "esphome/components/power_supply/power_supply.h" +#include "esphome/core/gpio.h" +#include "esphome/core/component.h" + +namespace esphome::power_supply::testing { + +// Minimal dummy internal GPIO pin implementation for testing +class DummyInternalPin : public InternalGPIOPin { + public: + DummyInternalPin() = default; + void setup() override {} + void pin_mode(esphome::gpio::Flags) override {} + esphome::gpio::Flags get_flags() const override { return esphome::gpio::FLAG_NONE; } + bool digital_read() override { return false; } + void digital_write(bool) override {} + void detach_interrupt() const override {} + ISRInternalGPIOPin to_isr() const override { return ISRInternalGPIOPin(); } + uint8_t get_pin() const override { return 0; } + bool is_inverted() const override { return false; } + + protected: + // Implement protected attach_interrupt required by InternalGPIOPin + void attach_interrupt(void (*func)(void *), void *arg, esphome::gpio::InterruptType type) const override {} +}; + +TEST(PowerSupply, HasHigherPriorityThanBusWhenInternalAndEnableOnBoot) { + power_supply::PowerSupply ps; + DummyInternalPin pin; + ps.set_pin(&pin); + ps.set_enable_on_boot(true); + + // POWER priority should be greater than BUS priority + EXPECT_GT(ps.get_setup_priority(), setup_priority::BUS); +} + +TEST(PowerSupply, FallsBackToIOWhenNotEnableOnBoot) { + power_supply::PowerSupply ps; + DummyInternalPin pin; + ps.set_pin(&pin); + ps.set_enable_on_boot(false); + + EXPECT_EQ(ps.get_setup_priority(), setup_priority::IO); +} + +} // namespace esphome::power_supply::testing From 0666cb86355731ebf6bf429c6f7e06c7b8e11b35 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 2 Jul 2026 05:44:09 +0200 Subject: [PATCH 1944/2214] [usb_uart] Add per-device-type maximum baud rate cap (#17259) --- esphome/components/usb_uart/__init__.py | 70 ++++++++++++++++--------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index e42a2c092b..a921b6fbf0 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -46,42 +46,59 @@ DEFAULT_BAUD_RATE = 9600 class Type: - def __init__(self, name, vid, pid, cls, max_channels=1, baud_rate_required=True): + def __init__( + self, + name, + vid, + pid, + cls, + max_channels=1, + baud_rate_required=True, + max_baud=1_000_000, + ): self.name = name cls = cls or name self.vid = vid self.pid = pid self.cls = usb_uart_ns.class_(f"USBUartType{cls}", USBUartComponent) - self.max_channels = max_channels + self._max_channels = max_channels self.baud_rate_required = baud_rate_required + self.max_baud = max_baud + + @property + def max_channels(self) -> int: + return ( + 3 + if ( + CORE.is_esp32 + and get_esp32_variant() != VARIANT_ESP32P4 + and self._max_channels > 3 + ) + else self._max_channels + ) uart_types = ( Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), - Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4), - Type("CH340", 0x1A86, 0x7523, "CH34X", 1), - Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), + Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4, max_baud=2_000_000), + Type("CH340", 0x1A86, 0x7523, "CH34X", 1, max_baud=2_000_000), + Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3, max_baud=2_000_000), Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), - Type("FT232", 0x0403, 0x6001, "FT23XX", 1), - Type("FT2232", 0x0403, 0x6010, "FT23XX", 2), - Type("FT4232", 0x0403, 0x6011, "FT23XX", 4), - Type("PL2303", 0x067B, 0x2303, "PL2303", 1), - Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1), - Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1), - Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1), - Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1), - Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1), - Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1), + Type("FT232", 0x0403, 0x6001, "FT23XX", 1, max_baud=3_000_000), + Type("FT2232", 0x0403, 0x6010, "FT23XX", 2, max_baud=12_000_000), + Type("FT4232", 0x0403, 0x6011, "FT23XX", 4, max_baud=12_000_000), + Type("PL2303", 0x067B, 0x2303, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1, max_baud=6_000_000), Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), ) -def channel_schema(channels, baud_rate_required): - # For now S3 is restricted to 3 channels since each needs 2 endpoints, plus the control endpoint, and - # there are only a total of 8 endpoints available. - # This will need updating when the 8 channel devices that multiplex over an endpoint are added. - if CORE.is_esp32 and get_esp32_variant() != VARIANT_ESP32P4 and channels > 3: - channels = 3 +def channel_schema(type_: "Type") -> cv.Schema: return cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( @@ -94,11 +111,11 @@ def channel_schema(channels, baud_rate_required): ), ( cv.Required(CONF_BAUD_RATE) - if baud_rate_required + if type_.baud_rate_required else cv.Optional( CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE ) - ): cv.int_range(min=300, max=1000000), + ): cv.int_range(min=300, max=type_.max_baud), cv.Optional(CONF_STOP_BITS, default="1"): cv.enum( UART_STOP_BITS_OPTIONS, upper=True ), @@ -117,7 +134,10 @@ def channel_schema(channels, baud_rate_required): } ) ), - cv.Length(max=channels), + cv.Length( + max=type_.max_channels, + msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels", + ), ) } ) @@ -127,7 +147,7 @@ CONFIG_SCHEMA = cv.ensure_list( cv.typed_schema( { it.name: usb_device_schema(it.cls, it.vid, it.pid).extend( - channel_schema(it.max_channels, it.baud_rate_required) + channel_schema(it) ) for it in uart_types }, From 06c7ac37d13eaeadfbe16db9150d9b28aa66b3d9 Mon Sep 17 00:00:00 2001 From: Twisterss Date: Thu, 2 Jul 2026 09:33:58 +0200 Subject: [PATCH 1945/2214] [epaper_spi] Add Waveshare 7.5" V2 BWR support (#15719) --- .../epaper_spi/epaper_waveshare_bwr.cpp | 146 ++++++++++++++++++ .../epaper_spi/epaper_waveshare_bwr.h | 40 +++++ .../epaper_spi/models/waveshare_bwr.py | 56 +++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++ 4 files changed, 263 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_waveshare_bwr.cpp create mode 100644 esphome/components/epaper_spi/epaper_waveshare_bwr.h create mode 100644 esphome/components/epaper_spi/models/waveshare_bwr.py diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp new file mode 100644 index 0000000000..004597b72b --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp @@ -0,0 +1,146 @@ +#include "epaper_waveshare_bwr.h" + +#include + +namespace esphome::epaper_spi { + +enum class BwrState : uint8_t { + BWR_BLACK, + BWR_WHITE, + BWR_RED, +}; + +static BwrState color_to_bwr(Color color) { + if (color.r > color.g + color.b && color.r > 127) { + return BwrState::BWR_RED; + } + if (color.r + color.g + color.b >= 382) { + return BwrState::BWR_WHITE; + } + return BwrState::BWR_BLACK; +} + +// UC8179 3-color display buffer layout: +// - 1 bit per pixel, 8 pixels per byte +// - Buffer first half: Black/White plane (1=black, 0=white) +// - Buffer second half: Red plane (1=red, 0=white) +// - Total: row_width * height * 2 bytes + +void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + + const uint32_t pos = (x / 8) + (y * this->row_width_); + const uint8_t bit = 0x80 >> (x & 0x07); + const uint32_t red_offset = this->buffer_length_ / 2u; + + const auto bwr = color_to_bwr(color); + + if (bwr == BwrState::BWR_BLACK) { + this->buffer_[pos] |= bit; + } else { + this->buffer_[pos] &= ~bit; + } + + if (bwr == BwrState::BWR_RED) { + this->buffer_[red_offset + pos] |= bit; + } else { + this->buffer_[red_offset + pos] &= ~bit; + } +} + +void EPaperWaveshareBWR::fill(Color color) { + const size_t half_buffer = this->buffer_length_ / 2u; + const auto bwr = color_to_bwr(color); + + if (bwr == BwrState::BWR_BLACK) { + // Black plane: 0xFF (black), Red plane: 0x00 (no red) + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = 0xFF; + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[half_buffer + i] = 0x00; + } else if (bwr == BwrState::BWR_RED) { + // Black plane: 0x00 (no black), Red plane: 0xFF (red) + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = 0x00; + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[half_buffer + i] = 0xFF; + } else { + // Black plane: 0x00 (no black), Red plane: 0x00 (no red) + this->buffer_.fill(0x00); + } +} + +bool HOT EPaperWaveshareBWR::transfer_data() { + const uint32_t start_time = millis(); + const size_t buffer_length = this->buffer_length_; + const size_t half_buffer = buffer_length / 2u; + + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + + // Phase 1: send Black/White plane (first half) via command 0x10 (DTM1) + // UC8179 DTM1 (0x10): inverted to get 0=black, 1=white + if (this->current_data_index_ < half_buffer) { + if (this->current_data_index_ == 0) { + this->command(0x10); // DATA START TRANSMISSION 1 (black channel) + } + this->start_data_(); + while (this->current_data_index_ < half_buffer) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_); + for (size_t i = 0; i < bytes_to_copy; i++) { + bytes_to_send[i] = ~this->buffer_[this->current_data_index_ + i]; + } + this->write_array(bytes_to_send, bytes_to_copy); + this->current_data_index_ += bytes_to_copy; + if (millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + } + + // Phase 2: send Red plane (second half) via command 0x13 (DTM2) + // UC8179 DTM2 (0x13): 1=red, 0=white + if (this->current_data_index_ < buffer_length) { + if (this->current_data_index_ == half_buffer) { + this->command(0x13); // DATA START TRANSMISSION 2 (red channel) + } + this->start_data_(); + while (this->current_data_index_ < buffer_length) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, buffer_length - this->current_data_index_); + for (size_t i = 0; i < bytes_to_copy; i++) { + bytes_to_send[i] = this->buffer_[this->current_data_index_ + i]; + } + this->write_array(bytes_to_send, bytes_to_copy); + this->current_data_index_ += bytes_to_copy; + if (millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + } + + this->current_data_index_ = 0; + return true; +} + +void EPaperWaveshareBWR::power_on() { + this->cmd_data(0x01, {0x07, 0x17, 0x3F, 0x3F}); // POWER SETTING + this->command(0x04); // POWER ON +} + +void EPaperWaveshareBWR::refresh_screen(bool /*partial*/) { + this->command(0x12); // DISPLAY REFRESH +} + +void EPaperWaveshareBWR::power_off() { + this->command(0x02); // POWER OFF +} + +void EPaperWaveshareBWR::deep_sleep() { + this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h new file mode 100644 index 0000000000..a090faa14d --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -0,0 +1,40 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Waveshare 3-color e-paper displays (UC8179 controller). + * Supports: 7.5" V2 BWR (EDP_7in5b_V2), 800x480 pixels. + * + * Color scheme: Black, White, Red (BWR) + * Buffer layout: 1 bit per pixel, separate planes + * - Buffer first half: Black/White plane (1=black, 0=white) + * - Buffer second half: Red plane (1=red, 0=no red) + * - Total buffer: width * height / 4 bytes (2 * width * height / 8) + * + * The init sequence (INITIALISE state) sends panel configuration only. + * Power-on (0x01 + 0x04) is sent in the POWER_ON state after data transfer; + * the state machine then busy-waits before triggering REFRESH_SCREEN (0x12). + */ +class EPaperWaveshareBWR : public EPaperBase { + public: + EPaperWaveshareBWR(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { + this->buffer_length_ = this->row_width_ * height * 2; + } + + void fill(Color color) override; + + protected: + void draw_pixel_at(int x, int y, Color color) override; + bool transfer_data() override; + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/waveshare_bwr.py b/esphome/components/epaper_spi/models/waveshare_bwr.py new file mode 100644 index 0000000000..e124ea7083 --- /dev/null +++ b/esphome/components/epaper_spi/models/waveshare_bwr.py @@ -0,0 +1,56 @@ +"""Waveshare Black/White/Red e-paper displays using UC8179 controller. + +Supported models: +- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EDP_7in5b_V2) + +These displays use the UC8179 controller. Panel configuration is sent during +the INITIALISE state. Power-on is handled in the POWER_ON state, after data +transfer, so the state machine's built-in busy wait covers the power-on delay. +""" + +from . import EpaperModel + + +class WaveshareBWR(EpaperModel): + """EpaperModel class for Waveshare Black/White/Red displays using UC8179 controller.""" + + def __init__(self, name, **defaults): + super().__init__(name, "EPaperWaveshareBWR", **defaults) + + def get_init_sequence(self, config): + """Generate initialization sequence for UC8179 BWR displays. + + Panel configuration only — power-on is handled separately in power_on() + after data transfer, with the state machine busy-waiting before refresh. + """ + width, height = self.get_dimensions(config) + return ( + # PANEL SETTING (KWR mode) + (0x00, 0x0F), + # RESOLUTION SETTING (width x height) + ( + 0x61, + (width >> 8) & 0xFF, + width & 0xFF, + (height >> 8) & 0xFF, + height & 0xFF, + ), + # DUAL SPI MODE (disabled) + (0x15, 0x00), + # VCOM AND DATA INTERVAL SETTING + (0x50, 0x11, 0x07), + # TCON SETTING + (0x60, 0x22), + # RESOLUTION GATE SETTING + (0x65, 0x00, 0x00, 0x00, 0x00), + ) + + +# Model: Waveshare 7.5" V2 BWR (EDP_7in5b_V2) — 800x480, UC8179 controller +WaveshareBWR( + "waveshare-7.5in-bv2-bwr", + width=800, + height=480, + data_rate="10MHz", + minimum_update_interval="30s", +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 60e4008f4f..bb771f2132 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -203,3 +203,24 @@ display: it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + + # Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-7.5in-bv2-bwr + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 60, Color(255, 0, 0)); From 792dfbcbbf116002468ed3692596fa5aa88b9448 Mon Sep 17 00:00:00 2001 From: Sven Kocksch Date: Thu, 2 Jul 2026 10:40:59 +0200 Subject: [PATCH 1946/2214] [st7123] add ST7123 touch controller component (M5Stack Tab5) (#12075) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/st7123/__init__.py | 6 + .../components/st7123/touchscreen/__init__.py | 32 ++++++ .../st7123/touchscreen/st7123_touchscreen.cpp | 108 ++++++++++++++++++ .../st7123/touchscreen/st7123_touchscreen.h | 48 ++++++++ tests/components/st7123/common.yaml | 18 +++ tests/components/st7123/test.esp32-idf.yaml | 9 ++ 7 files changed, 222 insertions(+) create mode 100644 esphome/components/st7123/__init__.py create mode 100644 esphome/components/st7123/touchscreen/__init__.py create mode 100644 esphome/components/st7123/touchscreen/st7123_touchscreen.cpp create mode 100644 esphome/components/st7123/touchscreen/st7123_touchscreen.h create mode 100644 tests/components/st7123/common.yaml create mode 100644 tests/components/st7123/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d2c92f44ce..b222c44214 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -501,6 +501,7 @@ esphome/components/ssd1331_base/* @kbx81 esphome/components/ssd1331_spi/* @kbx81 esphome/components/ssd1351_base/* @kbx81 esphome/components/ssd1351_spi/* @kbx81 +esphome/components/st7123/* @miniskipper esphome/components/st7567_base/* @latonita esphome/components/st7567_i2c/* @latonita esphome/components/st7567_spi/* @latonita diff --git a/esphome/components/st7123/__init__.py b/esphome/components/st7123/__init__.py new file mode 100644 index 0000000000..335bc238be --- /dev/null +++ b/esphome/components/st7123/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@miniskipper"] +DEPENDENCIES = ["i2c"] + +st7123_ns = cg.esphome_ns.namespace("st7123") diff --git a/esphome/components/st7123/touchscreen/__init__.py b/esphome/components/st7123/touchscreen/__init__.py new file mode 100644 index 0000000000..5ebd08066f --- /dev/null +++ b/esphome/components/st7123/touchscreen/__init__.py @@ -0,0 +1,32 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import st7123_ns + +ST7123Touchscreen = st7123_ns.class_( + "ST7123Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(ST7123Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } +).extend(i2c.i2c_device_schema(0x55)) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp new file mode 100644 index 0000000000..117f975264 --- /dev/null +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp @@ -0,0 +1,108 @@ +#include "st7123_touchscreen.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::st7123 { + +static const char *const TAG = "st7123.touchscreen"; + +void ST7123Touchscreen::setup() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(5); + this->reset_pin_->digital_write(false); // TP_RESX is active low, assert for at least tRSTW (2ms) + delay(5); + this->reset_pin_->digital_write(true); + // The controller needs up to 20ms to initialize after reset before it can be accessed. + this->setup_time_ = millis() + 30; + } +} + +void ST7123Touchscreen::update() { + // check if setup is complete + if (this->setup_time_ != 0) { + if (this->setup_time_ > millis()) + return; + + uint8_t status; + if (this->read_register16(ST7123_REG_STATUS, &status, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to read status register")); // will stop updates + return; + } + if ((status & 0x0F) == ST7123_STATUS_INIT) { + ESP_LOGD(TAG, "Controller still initializing"); + return; + } + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // INT is held high when idle and pulses low when touch data is ready. + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + ESP_LOGD(TAG, "Status is %X", status); + + uint8_t data; + if (this->read_register16(ST7123_REG_MAX_TOUCHES, &data, 1) == i2c::ERROR_OK && data != 0 && + data <= ST7123_MAX_TOUCHES) { + this->max_touches_ = data; + } + + // If no calibration was supplied, read the native coordinate resolution from the controller. + if (this->x_raw_max_ == this->x_raw_min_ || this->y_raw_max_ == this->y_raw_min_) { + uint8_t res[4]; + if (this->read_register16(ST7123_REG_MAX_X, res, sizeof(res)) == i2c::ERROR_OK) { + this->x_raw_max_ = encode_uint16(res[0] & ST7123_COORD_HIGH_MASK, res[1]); + this->y_raw_max_ = encode_uint16(res[2] & ST7123_COORD_HIGH_MASK, res[3]); + if (this->swap_x_y_) + std::swap(this->x_raw_max_, this->y_raw_max_); + } else { + this->mark_failed(LOG_STR("Failed to read calibration")); + return; + } + ESP_LOGD(TAG, "Read dimensions %d/%d", this->x_raw_max_, this->y_raw_max_); + } + this->setup_time_ = 0; // flag setup complete + } + Touchscreen::update(); +} + +void ST7123Touchscreen::update_touches() { + // Read the reporting table from the advanced touch info register through the last touch point. + // Reading from this register also clears the INT pin so the controller can report the next frame. + uint8_t data[(ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + ST7123_MAX_TOUCHES * ST7123_TOUCH_STRIDE]; + const size_t len = (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + this->max_touches_ * ST7123_TOUCH_STRIDE; + if (this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) != i2c::ERROR_OK) { + this->skip_update_ = true; + this->status_set_warning(); + return; + } + this->status_clear_warning(); + + const uint8_t *points = data + (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO); + for (uint8_t i = 0; i != this->max_touches_; i++) { + const uint8_t *p = points + i * ST7123_TOUCH_STRIDE; + if ((p[0] & ST7123_TOUCH_VALID) == 0) + continue; + uint16_t x = encode_uint16(p[0] & ST7123_COORD_HIGH_MASK, p[1]); + uint16_t y = encode_uint16(p[2] & ST7123_COORD_HIGH_MASK, p[3]); + uint8_t intensity = p[5]; + ESP_LOGV(TAG, "Touch %u: x=%u, y=%u, intensity=%u", i, x, y, intensity); + this->add_raw_touch_position_(i, x, y, intensity); + } +} + +void ST7123Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "ST7123 Touchscreen:\n" + " Max touches: %u\n" + " X Raw Min: %d, X Raw Max: %d\n" + " Y Raw Min: %d, Y Raw Max: %d", + this->max_touches_, this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::st7123 diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.h b/esphome/components/st7123/touchscreen/st7123_touchscreen.h new file mode 100644 index 0000000000..633eba7a82 --- /dev/null +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.h @@ -0,0 +1,48 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::st7123 { + +// Sitronix ST7123 capacitive touch controller. +// Registers are addressed with a 16-bit big-endian address (sent MSB first). +static constexpr uint16_t ST7123_REG_STATUS = 0x0001; // [7:4] error code, [3:0] device status +static constexpr uint16_t ST7123_REG_MAX_X = 0x0005; // 0x0005..0x0006 X resolution, 0x0007..0x0008 Y resolution +static constexpr uint16_t ST7123_REG_MAX_TOUCHES = 0x0009; +static constexpr uint16_t ST7123_REG_ADV_TOUCH_INFO = 0x0010; // start of the reporting table +static constexpr uint16_t ST7123_REG_TOUCH_DATA = 0x0014; // first touch point + +// Device status field of the status register. +static constexpr uint8_t ST7123_STATUS_INIT = 0x1; + +// Each touch point occupies 7 bytes: X high, X low, Y high, Y low, area, intensity, reserved. +static constexpr uint8_t ST7123_TOUCH_STRIDE = 7; +// Bit 7 of the X high byte indicates a valid touch point. +static constexpr uint8_t ST7123_TOUCH_VALID = 0x80; +// The X and Y high bytes only use the low 6 bits. +static constexpr uint8_t ST7123_COORD_HIGH_MASK = 0x3F; +// The ST7123 can report at most 10 touch points. +static constexpr uint8_t ST7123_MAX_TOUCHES = 10; + +class ST7123Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + uint8_t max_touches_{ST7123_MAX_TOUCHES}; + uint32_t setup_time_{1}; +}; + +} // namespace esphome::st7123 diff --git a/tests/components/st7123/common.yaml b/tests/components/st7123/common.yaml new file mode 100644 index 0000000000..b34eb669e0 --- /dev/null +++ b/tests/components/st7123/common.yaml @@ -0,0 +1,18 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + id: st7123_ssd1306_i2c_display + model: SSD1306_128X64 + reset_pin: ${display_reset_pin} + pages: + - id: st7123_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: st7123 + i2c_id: i2c_bus + id: st7123_touchscreen + display: st7123_ssd1306_i2c_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} diff --git a/tests/components/st7123/test.esp32-idf.yaml b/tests/components/st7123/test.esp32-idf.yaml new file mode 100644 index 0000000000..3bce86d9a3 --- /dev/null +++ b/tests/components/st7123/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + display_reset_pin: "10" + interrupt_pin: "20" + reset_pin: "21" + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From 41cf842d5d9ca377c6338760f91bcb4e7755080f Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 2 Jul 2026 16:13:56 +0200 Subject: [PATCH 1947/2214] [zephyr][nrf52] Rebuild native build when config inputs change (#17318) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 20 ++++++++++--- esphome/components/zephyr/__init__.py | 41 +++++++++++++++++++++------ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 00271c97c7..64946e3cd1 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -58,7 +58,7 @@ from esphome.framework_helpers import ( get_project_link_flags, run_command_ok, ) -from esphome.helpers import write_file_if_changed +from esphome.helpers import rmtree, write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -697,7 +697,8 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False -def _generate_cmake_lists() -> None: +def _generate_cmake_lists() -> bool: + """Write the project CMakeLists.txt, returning True if it changed.""" compile_flags = get_project_compile_flags() link_flags = get_project_link_flags() @@ -732,7 +733,7 @@ def _generate_cmake_lists() -> None: ")", ] - write_file_if_changed( + return write_file_if_changed( CORE.relative_build_path("zephyr", "CMakeLists.txt"), "\n".join(lines) + "\n", ) @@ -751,12 +752,23 @@ def run_compile(args, config: ConfigType) -> bool: paths = get_build_paths() env = get_build_env() - _generate_cmake_lists() + cmake_lists_changed = _generate_cmake_lists() board = zephyr_data()[KEY_BOARD] build_dir = CORE.relative_pioenvs_path(CORE.name) source_dir = CORE.relative_build_path("zephyr") + # A missing CMake cache (dropped by zephyr's copy_files() on config + # change) or a changed CMakeLists.txt requires a pristine build: Zephyr + # caches Kconfig/devicetree state that survives a plain cmake re-run. + # West can't do the wipe — its pristine modes only recognize a build dir + # by reading ZEPHYR_BASE from the very cache that was dropped. + if ( + cmake_lists_changed or not (build_dir / "CMakeCache.txt").is_file() + ) and build_dir.is_dir(): + _LOGGER.info("Build inputs changed, cleaning %s", build_dir) + rmtree(build_dir) + west_cmd = [ str(paths["python_executable"]), "-m", diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index bd5f01aa3a..cd077a142f 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -8,6 +8,7 @@ from esphome.const import CONF_BOARD, KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_cmake_cache from .const import ( CONF_CDC_ACM, @@ -203,7 +204,20 @@ def zephyr_add_user(key, value): user[key] += [value] -def copy_files(): +def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> bool: + """Write content to path, or remove a stale file when content is empty. + + Returns True if the file changed on disk. + """ + if content: + return write_file_if_changed(path, content) + if path.is_file(): + path.unlink() + return True + return False + + +def copy_files() -> None: user = zephyr_data()[KEY_USER] if user: entries = " ".join( @@ -219,6 +233,8 @@ def copy_files(): """ ) + changed = False + for image, want_opts in zephyr_data()[KEY_PRJ_CONF].items(): prj_conf = ( "\n".join( @@ -233,26 +249,25 @@ def copy_files(): else: path = CORE.relative_build_path("zephyr/prj.conf") - write_file_if_changed(CORE.relative_build_path(path), prj_conf) + changed |= write_file_if_changed(path, prj_conf) for image, content in zephyr_data()[KEY_OVERLAY].items(): if image: path = CORE.relative_build_path(f"sysbuild/{image}.overlay") else: path = CORE.relative_build_path("zephyr/app.overlay") - write_file_if_changed(path, content) + changed |= write_file_if_changed(path, content) for filename, path in zephyr_data()[KEY_EXTRA_BUILD_FILES].items(): - copy_file_if_changed( + changed |= copy_file_if_changed( path, CORE.relative_build_path(filename), ) pm_static = "\n".join(str(item) for item in zephyr_data()[KEY_PM_STATIC]) - if pm_static: - write_file_if_changed( - CORE.relative_build_path("zephyr/pm_static.yml"), pm_static - ) + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/pm_static.yml"), pm_static + ) kconfig = zephyr_data()[KEY_KCONFIG] if kconfig: @@ -267,4 +282,12 @@ def copy_files(): + "\n" + kconfig ) - write_file_if_changed(CORE.relative_build_path("zephyr/Kconfig"), kconfig) + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/Kconfig"), kconfig + ) + + if changed: + # A configure-time input changed; drop the CMake cache so the build + # can't reuse stale configure results (the native sdk-nrf toolchain + # rebuilds pristine when the cache is missing). + clean_cmake_cache() From 65fc10d627f0ab8343694521562a4cd0edbdca4c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:26:34 -0400 Subject: [PATCH 1948/2214] [nrf52] Build PlatformIO libraries as Zephyr modules (sdk-nrf) (#17250) --- esphome/components/nrf52/__init__.py | 26 +++ esphome/components/zephyr/library.py | 180 ++++++++++++++++++++ esphome/espidf/component.py | 1 + esphome/platformio/library.py | 45 +++-- tests/unit_tests/test_espidf_component.py | 38 +++-- tests/unit_tests/test_platformio_library.py | 6 +- tests/unit_tests/test_zephyr_library.py | 117 +++++++++++++ 7 files changed, 388 insertions(+), 25 deletions(-) create mode 100644 esphome/components/zephyr/library.py create mode 100644 tests/unit_tests/test_zephyr_library.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 64946e3cd1..184d41e0f3 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -411,6 +411,17 @@ async def _dfu_to_code(dfu_config): def copy_files() -> None: """Copy files to the build directory.""" + # Library conversion to Zephyr modules is wired into the sdk-nrf + # CMakeLists only; the PlatformIO toolchain's forked platform package + # cannot compile external libraries at all, so the build would fail at + # link time anyway. Fail fast with a clear message instead. + if CORE.using_toolchain_platformio and CORE.platformio_libraries: + raise EsphomeError( + f"Libraries ({', '.join(sorted(CORE.platformio_libraries))}) are " + "not supported on the nRF52 'platformio' toolchain; use toolchain " + "'sdk-nrf' to build them as Zephyr modules." + ) + if CORE.using_toolchain_platformio and ( zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT or zephyr_data()[KEY_BOARD] == "xiao_ble" @@ -702,11 +713,26 @@ def _generate_cmake_lists() -> bool: compile_flags = get_project_compile_flags() link_flags = get_project_link_flags() + # Convert any PlatformIO libraries added via cg.add_library() into Zephyr + # modules and discover them through EXTRA_ZEPHYR_MODULES (a CMake list, set + # before find_package(Zephyr) so the modules are picked up). Only + # framework-agnostic libraries actually compile under Zephyr. + from esphome.components.zephyr.library import generate_zephyr_modules + + module_dirs = generate_zephyr_modules(list(CORE.platformio_libraries.values())) + lines = [ "cmake_minimum_required(VERSION 3.20.0)", "", 'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")', "", + ] + + if module_dirs: + modules = ";".join(str(d).replace("\\", "/") for d in module_dirs) + lines += [f'set(EXTRA_ZEPHYR_MODULES "{modules}")', ""] + + lines += [ "find_package(Zephyr REQUIRED)", "", f"project({CORE.name})", diff --git a/esphome/components/zephyr/library.py b/esphome/components/zephyr/library.py new file mode 100644 index 0000000000..7654e63700 --- /dev/null +++ b/esphome/components/zephyr/library.py @@ -0,0 +1,180 @@ +"""Zephyr backend for the shared PlatformIO library converter. + +For each PlatformIO library added via ``cg.add_library()``, emit a Zephyr +external module (``zephyr/module.yml`` + ``zephyr/CMakeLists.txt`` built with the +``zephyr_library*`` API) into the shared ``pio_components`` cache. The caller +wires the resulting module directories into the build via +``EXTRA_ZEPHYR_MODULES``; Zephyr then compiles each module and links it into the +final image. + +Only framework-agnostic libraries (plain C/C++ that doesn't depend on the Arduino +API) will actually compile under Zephyr — this converter shares the +fetch/parse/cache plumbing, not API compatibility. +""" + +from pathlib import Path + +from esphome import yaml_util +from esphome.core import EsphomeError, Library +from esphome.helpers import write_file_if_changed +from esphome.platformio.library import ( + DEFAULT_BUILD_FLAGS, + DEFAULT_BUILD_INCLUDE_DIR, + DEFAULT_BUILD_SRC_FILTER, + SRC_FILE_EXTENSIONS, + ConvertedLibrary, + LibraryBackend, + PathType, + collect_filtered_files, + convert_libraries, + ensure_list, + split_list_by_condition, +) + +# Zephyr libraries declare frameworks rarely and the PIO ``platforms`` token for +# nRF is seldom present, so the platform check is disabled (None) and only the +# framework mismatch warning fires. +ZEPHYR_FRAMEWORK = "zephyr" + + +def _escape(p: PathType) -> str: + # In CMakeLists.txt, backslashes need to be escaped (mirrors the ESP-IDF + # backend's escape_entry). Doubling -- rather than rewriting '\' -> '/' -- + # preserves content, so it's safe for arbitrary build flags (e.g. a -D value + # containing a backslash) as well as Windows paths. + return f'"{str(p)}"'.replace("\\", "\\\\") + + +def generate_module_yml(component: ConvertedLibrary) -> str: + """Render the ``zephyr/module.yml`` manifest for a converted library.""" + return yaml_util.dump( + { + "name": component.get_require_name(), + "build": {"cmake": "zephyr"}, + } + ) + + +def generate_cmakelists_txt(component: ConvertedLibrary) -> str: + """Render the ``zephyr/CMakeLists.txt`` that builds a converted library. + + Sources/includes are emitted as absolute paths since the CMakeLists lives in + the library's ``zephyr/`` subdir while its sources sit alongside it. Include + dirs are published globally so the app (and sibling libraries) can include the + library's headers, mirroring ESP-IDF's public ``INCLUDE_DIRS``. + """ + build = component.data.get("build", {}) + + build_src_dir = build.get("srcDir") + if not build_src_dir: + for d in ["src", "Src", "."]: + if (component.path / Path(d)).is_dir(): + build_src_dir = d + break + + build_include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR) + build_src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER)) + build_flags = ensure_list(build.get("flags", DEFAULT_BUILD_FLAGS)) + + src_files = collect_filtered_files( + component.path / Path(build_src_dir), build_src_filter + ) + src_files = sorted( + str(Path(p).resolve()) + for p in src_files + if Path(p).suffix in SRC_FILE_EXTENSIONS + ) + + include_dir_flags, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None + ) + link_directories, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None + ) + link_libraries, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None + ) + + include_dirs = [build_include_dir, build_src_dir, *include_dir_flags] + include_dirs = [ + str((component.path / Path(d)).resolve()) + for d in include_dirs + if (component.path / Path(d)).is_dir() + ] + + lines = [f"zephyr_library_named({component.get_require_name()})"] + if src_files: + lines += [ + "zephyr_library_sources(", + *[f" {_escape(p)}" for p in src_files], + ")", + ] + if include_dirs: + lines += [ + "zephyr_include_directories(", + *[f" {_escape(p)}" for p in include_dirs], + ")", + ] + if build_flags: + lines += [ + "zephyr_library_compile_options(", + *[f" {_escape(f)}" for f in build_flags], + ")", + ] + # Best-effort link wiring; most Zephyr-portable libraries don't need it. + link_flags = [f"-L{d}" for d in link_directories] + [ + f"-l{lib}" for lib in link_libraries + ] + if link_flags: + lines += [ + "zephyr_link_libraries(", + *[f" {_escape(f)}" for f in link_flags], + ")", + ] + + return "\n".join(lines) + "\n" + + +def _emit_zephyr_module(component: ConvertedLibrary) -> None: + zephyr_dir = component.path / "zephyr" + write_file_if_changed(zephyr_dir / "module.yml", generate_module_yml(component)) + write_file_if_changed( + zephyr_dir / "CMakeLists.txt", generate_cmakelists_txt(component) + ) + + +def generate_zephyr_modules(libraries: list[Library]) -> list[Path]: + """Convert ``libraries`` to Zephyr modules and return all module directories. + + The returned list includes transitive dependencies (each converted library is + its own module). Every directory should be added to ``EXTRA_ZEPHYR_MODULES``; + Zephyr links all module libraries into the image, so cross-library symbols + resolve without explicit dependency declarations. + + Raises ``EsphomeError`` if two libraries resolve to the same Zephyr module + name -- each module's CMakeLists calls ``zephyr_library_named()``, so a + duplicate would otherwise fail the build with a CMake "target already exists". + The converter already warns when a library is referenced under inconsistent + specs (bare ``name`` vs ``owner/name``, git vs registry); this turns that into + an actionable error at the Zephyr boundary where it is fatal. + """ + module_dirs: list[Path] = [] + by_name: dict[str, Path] = {} + + def emit(component: ConvertedLibrary) -> None: + name = component.get_require_name() + if name in by_name: + raise EsphomeError( + f"Two libraries resolve to the same Zephyr module '{name}' " + f"({by_name[name]} and {component.path}). Reference the library " + f"consistently (e.g. always as 'owner/name') so it resolves once." + ) + by_name[name] = component.path + _emit_zephyr_module(component) + module_dirs.append(component.path) + + backend = LibraryBackend( + platform=None, framework=ZEPHYR_FRAMEWORK, emit=emit, cache_key="zephyr" + ) + convert_libraries(libraries, backend) + return module_dirs diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 5029e014a4..e9ec170a5e 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -264,5 +264,6 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: platform=ESP32_PLATFORM, framework=_idf_framework(), emit=_emit_idf_component, + cache_key="idf", ) return convert_libraries(libraries, backend) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index c2d783ecbe..291bedb5cd 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -68,7 +68,9 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: raise NotImplementedError @@ -76,8 +78,14 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + # Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so + # the build files each backend writes into the library dir can't collide. base_dir = Path(CORE.data_dir) / DOMAIN + if namespace: + base_dir = base_dir / namespace h = hashlib.new("sha256") h.update(self.url.encode()) if salt: @@ -113,12 +121,19 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + domain = DOMAIN + if namespace: + domain = f"{domain}/{namespace}" + if salt: + domain = f"{domain}/{salt}" path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, + domain=domain, submodules=[], subpath=Path(dir_suffix), ) @@ -167,16 +182,16 @@ class ConvertedLibrary: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False, salt: str = ""): + def download(self, force: bool = False, salt: str = "", namespace: str = ""): """Fetch the library into the shared cache and record its ``path``. The cache directory is named after the sanitized library name; backends rely on that name to identify the unit they build (e.g. ESP-IDF uses the directory name as the component name, replacing ``/`` with ``__`` via - ``get_require_name``). + ``get_require_name``). ``namespace`` keeps each backend's cache separate. """ self.path = self.source.download( - self.get_sanitized_name(), force=force, salt=salt + self.get_sanitized_name(), force=force, salt=salt, namespace=namespace ) @@ -188,11 +203,15 @@ class LibraryBackend: ``emit`` writes the toolchain-specific build files into a resolved library's ``path`` (e.g. the ESP-IDF ``CMakeLists.txt`` + ``idf_component.yml``, or a Zephyr ``module.yml`` + ``CMakeLists.txt``). + ``cache_key`` namespaces the download cache (``pio_components//``) + so the differing build files two backends emit into a library dir never + collide when the same config dir hosts both an ESP-IDF and a Zephyr build. """ - platform: str + platform: str | None framework: str emit: Callable[["ConvertedLibrary"], None] + cache_key: str def ensure_list[T](obj: T | list[T]) -> list[T]: @@ -306,7 +325,7 @@ def split_list_by_condition( return matched, non_matched -def check_library_data(data: dict, platform: str, framework: str): +def check_library_data(data: dict, platform: str | None, framework: str): """ Check whether a library manifest is compatible with the target toolchain. @@ -319,7 +338,9 @@ def check_library_data(data: dict, platform: str, framework: str): Args: data: PIO library manifest dict being processed. platform: The PlatformIO platform token the build targets (e.g. - ``espressif32``). + ``espressif32``). ``None`` skips the platform check entirely — useful + for targets (e.g. Zephyr) where PIO manifests rarely declare the + platform yet portable libraries still build. framework: The active framework name (e.g. ``espidf``, ``arduino``, ``zephyr``) the manifest is expected to declare. @@ -332,7 +353,7 @@ def check_library_data(data: dict, platform: str, framework: str): platforms = ensure_list(platforms) # Check if library supports the target platform - valid_platforms = "*" in platforms or platform in platforms + valid_platforms = platform is None or "*" in platforms or platform in platforms if not valid_platforms: raise InvalidLibrary(f"Unsupported library platforms: {platforms}") @@ -613,7 +634,7 @@ def convert_libraries( component = ConvertedLibrary( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download(salt=salt) + component.download(salt=salt, namespace=backend.cache_key) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index d43a1d5276..a50024b8e9 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -481,7 +481,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -543,7 +543,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( download_salts: list[str] = [] - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): download_salts.append(salt) self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) @@ -597,7 +597,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -654,7 +654,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -691,7 +691,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> None: # A library with neither library.json nor library.properties is invalid; # fail loudly rather than silently generating build files for it. - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -733,7 +733,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -766,7 +766,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> None: # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, # not be silently dropped. - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -804,7 +804,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text(json.dumps(manifests[self.name])) @@ -847,6 +847,13 @@ def test_url_source_salt_changes_cache_path( assert source.download("lib") == expected[""] assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + # A backend namespace adds a pio_components// subdir. + digest = hashlib.sha256(url.encode()).hexdigest()[:8] + ns_expected = base / "idf" / digest / "lib" + ns_expected.mkdir(parents=True) + (ns_expected / ".esphome_extracted").touch() + assert source.download("lib", namespace="idf") == ns_expected + def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: """The salt becomes a subdirectory of the git clone domain.""" @@ -863,7 +870,14 @@ def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") source.download("noise-c") source.download("noise-c", salt="abcd1234") - assert domains == ["pio_components", "pio_components/abcd1234"] + source.download("noise-c", namespace="idf") + source.download("noise-c", namespace="zephyr", salt="abcd1234") + assert domains == [ + "pio_components", + "pio_components/abcd1234", + "pio_components/idf", + "pio_components/zephyr/abcd1234", + ] def test_idf_component_download_passes_salt() -> None: @@ -873,7 +887,9 @@ def test_idf_component_download_passes_salt() -> None: source.download.return_value = Path("/converted/owner/name") c = IDFComponent("owner/name", "1.0", source=source) - c.download(force=True, salt="abcd1234") + c.download(force=True, salt="abcd1234", namespace="idf") - source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + source.download.assert_called_once_with( + "owner/name", force=True, salt="abcd1234", namespace="idf" + ) assert c.path == Path("/converted/owner/name") diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 55bc396c25..03360eab37 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -26,7 +26,9 @@ from esphome.platformio.library import ( def _backend(emit=lambda component: None) -> LibraryBackend: - return LibraryBackend(platform="espressif32", framework="espidf", emit=emit) + return LibraryBackend( + platform="espressif32", framework="espidf", emit=emit, cache_key="idf" + ) def test_check_library_data_accepts_wildcards(): @@ -134,7 +136,7 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") self.path.mkdir(parents=True, exist_ok=True) if self.name in properties: diff --git a/tests/unit_tests/test_zephyr_library.py b/tests/unit_tests/test_zephyr_library.py new file mode 100644 index 0000000000..0ba3577fa7 --- /dev/null +++ b/tests/unit_tests/test_zephyr_library.py @@ -0,0 +1,117 @@ +"""Tests for the Zephyr backend of the shared PlatformIO library converter.""" + +from pathlib import Path + +import pytest + +import esphome.components.zephyr.library as zlib +from esphome.components.zephyr.library import ( + generate_cmakelists_txt, + generate_module_yml, + generate_zephyr_modules, +) +from esphome.core import EsphomeError, Library +from esphome.platformio.library import ConvertedLibrary, URLSource + + +def _make_component(path: Path, name: str = "mylib") -> ConvertedLibrary: + c = ConvertedLibrary(name, "1.0", source=URLSource("http://dummy")) + c.path = path + return c + + +def test_generate_module_yml_uses_sanitized_name(): + c = ConvertedLibrary("owner/My Lib", "1.0", source=URLSource("http://dummy")) + out = generate_module_yml(c) + # "/" -> "__" and " " -> "_" so it's a valid Zephyr module name. + assert "name: owner__My_Lib" in out + assert "cmake: zephyr" in out + + +def test_generate_cmakelists_txt_basic(tmp_path): + c = _make_component(tmp_path) + src = tmp_path / "src" + src.mkdir() + (src / "main.c").write_text("int main() {}") + c.data = {} + + out = generate_cmakelists_txt(c) + + assert "zephyr_library_named(mylib)" in out + assert "zephyr_library_sources(" in out + # Sources are emitted as absolute paths (CMakeLists lives in zephyr/ subdir), + # backslash-escaped for CMake (matching the output on Windows). + assert str((src / "main.c").resolve()).replace("\\", "\\\\") in out + + +def test_generate_cmakelists_txt_flags_and_includes(tmp_path): + c = _make_component(tmp_path) + (tmp_path / "src").mkdir() + (tmp_path / "src" / "a.c").write_text("") + (tmp_path / "include").mkdir() + c.data = {"build": {"flags": ["-Iinclude", "-DFOO", "-Wall", "-Llibdir", "-lm"]}} + + out = generate_cmakelists_txt(c) + + assert "zephyr_include_directories(" in out + assert str((tmp_path / "include").resolve()).replace("\\", "\\\\") in out + assert "zephyr_library_compile_options(" in out + assert "-DFOO" in out + assert "-Wall" in out + assert "zephyr_link_libraries(" in out + assert "-Llibdir" in out + assert "-lm" in out + + +def test_generate_zephyr_modules_collects_all_dirs_and_writes(tmp_path, monkeypatch): + # Two converted libraries: one top-level, one transitive dependency. The + # converter calls backend.emit for both; generate_zephyr_modules must return + # *all* module dirs (not just top-level) so every module is discoverable. + top = _make_component(tmp_path / "top", "top") + (top.path / "src").mkdir(parents=True) + (top.path / "src" / "t.c").write_text("") + dep = _make_component(tmp_path / "dep", "dep") + (dep.path / "src").mkdir(parents=True) + (dep.path / "src" / "d.c").write_text("") + + captured = {} + + def fake_convert(libraries, backend): + captured["platform"] = backend.platform + captured["framework"] = backend.framework + backend.emit(top) + backend.emit(dep) + return [top] + + monkeypatch.setattr(zlib, "convert_libraries", fake_convert) + + dirs = generate_zephyr_modules([Library("top", "1.0", None)]) + + assert dirs == [top.path, dep.path] + # Platform check disabled for Zephyr; framework declared as zephyr. + assert captured["platform"] is None + assert captured["framework"] == "zephyr" + for comp in (top, dep): + assert (comp.path / "zephyr" / "module.yml").is_file() + assert (comp.path / "zephyr" / "CMakeLists.txt").is_file() + + +def test_generate_zephyr_modules_errors_on_duplicate_module_name(tmp_path, monkeypatch): + # The same library referenced under inconsistent specs (e.g. bare vs + # owner-qualified, or git vs registry) resolves to two components with the + # same Zephyr module name, which would collide in zephyr_library_named(). + a = _make_component(tmp_path / "a", "esphome/noise-c") + a.path.mkdir(parents=True) + b = _make_component(tmp_path / "b", "esphome/noise-c") + b.path.mkdir(parents=True) + assert a.get_require_name() == b.get_require_name() + + def fake_convert(libraries, backend): + backend.emit(a) + backend.emit(b) + return [a] + + monkeypatch.setattr(zlib, "convert_libraries", fake_convert) + + with pytest.raises(EsphomeError, match="same Zephyr module"): + generate_zephyr_modules([Library("esphome/noise-c", "1.0", None)]) From 648f5e1b068201c64d5382dc9b035395e997226b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:42:59 -0400 Subject: [PATCH 1949/2214] [nrf52] Install native sdk-nrf into a machine-global cache dir (#17353) --- docker/docker_entrypoint.sh | 3 +- .../etc/s6-overlay/s6-rc.d/esphome/run | 3 +- esphome/components/nrf52/framework.py | 21 ++++-- esphome/espidf/framework.py | 16 ++--- esphome/writer.py | 21 ++++-- tests/unit_tests/test_espidf_framework.py | 28 ++++---- tests/unit_tests/test_nrf52_framework.py | 61 ++++++++++++++++- tests/unit_tests/test_writer.py | 68 +++++++++++++++++-- 8 files changed, 180 insertions(+), 41 deletions(-) diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 598b553c08..c88a78f97e 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -21,9 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" -# Keep the native ESP-IDF install on the persistent cache root, not the +# Keep the native toolchain installs on the persistent cache root, not the # container's ephemeral user cache dir (re-downloaded on every restart). export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf" +export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf" # If /build is mounted, use that as the build path # otherwise use path in /config (so that builds aren't lost on container restart) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index f50de659b9..20fada5f13 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -15,9 +15,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" -# Keep the native ESP-IDF install on the persistent /data volume, not the +# Keep the native toolchain installs on the persistent /data volume, not the # container's ephemeral user cache dir (wiped on every add-on update/restart). export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf +export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 7aec6b088e..7cb1164482 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -4,6 +4,8 @@ from pathlib import Path import platform import tempfile +import platformdirs + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( @@ -15,6 +17,7 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, ) +from esphome.helpers import get_str_env _LOGGER = logging.getLogger(__name__) @@ -38,20 +41,28 @@ SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str( ) -def _get_tools_path() -> Path: - return CORE.data_dir / "sdk-nrf" +def get_sdk_nrf_tools_path() -> Path: + # A blank ESPHOME_SDK_NRF_PREFIX must be treated as unset: Path("") + # resolves to the CWD, which clean-all would then delete. + if prefix := get_str_env("ESPHOME_SDK_NRF_PREFIX", "").strip(): + path = Path(prefix).expanduser() + else: + # Machine-global (OS user cache dir) so all projects share one install; + # see espidf.framework.get_idf_tools_path for the location rationale. + path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + return path.resolve() def _get_python_env_path(version: str) -> Path: - return _get_tools_path() / "penvs" / version + return get_sdk_nrf_tools_path() / "penvs" / version def _get_framework_path(version: str) -> Path: - return _get_tools_path() / "frameworks" / version + return get_sdk_nrf_tools_path() / "frameworks" / version def _get_toolchain_path(version: str) -> Path: - return _get_tools_path() / "toolchains" / version + return get_sdk_nrf_tools_path() / "toolchains" / version _SITECUSTOMIZE = """\ diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 25283e3c99..810a63476f 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -75,7 +75,7 @@ ESP_IDF_CONSTRAINTS_MIRRORS = str_to_lst_of_str( ) -def _get_idf_tools_path() -> Path: +def get_idf_tools_path() -> Path: """ Get the path to the ESP-IDF tools directory. @@ -141,7 +141,7 @@ def _check_windows_path_length() -> None: """ if platform.system() != "Windows" or _windows_long_paths_enabled(): return - tools_path = str(_get_idf_tools_path()) + tools_path = str(get_idf_tools_path()) projected = len(tools_path) + _TOOLCHAIN_NESTED_PATH_LEN if projected <= _WINDOWS_MAX_PATH: return @@ -180,7 +180,7 @@ def _get_framework_path(version: str) -> Path: Returns: Path object pointing to the framework directory """ - return _get_idf_tools_path() / "frameworks" / f"{version}" + return get_idf_tools_path() / "frameworks" / f"{version}" def _get_python_env_path(version: str) -> Path: @@ -193,7 +193,7 @@ def _get_python_env_path(version: str) -> Path: Returns: Path object pointing to the Python environment directory """ - return _get_idf_tools_path() / "penvs" / f"{version}" + return get_idf_tools_path() / "penvs" / f"{version}" def _check_stamp(file: PathType, data: dict[str, str]) -> bool: @@ -707,7 +707,7 @@ def _check_esp_idf_python_env_install( esp_idf_version = _get_idf_version(framework_path, env=env) constraint_file_path = ( - _get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt" + get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt" ) _LOGGER.debug("ESP-IDF version %s", esp_idf_version) @@ -798,7 +798,7 @@ def check_esp_idf_install( _check_windows_path_length() env = {} - env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) + env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" targets = targets or ESPHOME_IDF_DEFAULT_TARGETS @@ -867,7 +867,7 @@ def _ccache_env() -> dict[str, str]: defaults = { "IDF_CCACHE_ENABLE": "1", - "CCACHE_DIR": str(_get_idf_tools_path() / "ccache"), + "CCACHE_DIR": str(get_idf_tools_path() / "ccache"), "CCACHE_NOHASHDIR": "true", "CCACHE_DEPEND": "1", "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), @@ -894,7 +894,7 @@ def get_framework_env( """ # 1. Initialize base environment with extra ESP-IDF environment variables env = env.copy() if env else {} - env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) + env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" # 2. Get existing PATH from env or os.environ diff --git a/esphome/writer.py b/esphome/writer.py index 52f2d169b3..b7eeec916d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -653,14 +653,21 @@ def clean_all(configuration: list[str]): elif item.is_dir() and item.name != "storage": rmtree(item) - # The native ESP-IDF install lives in a machine-global cache dir, outside - # any .esphome data dir, so the per-config loop above won't reach it. - from esphome.espidf.framework import _get_idf_tools_path + # The native toolchain installs live in a machine-global cache dir that + # the per-config loop above can't reach. Wipe the default cache root + # (also catches leftovers from older install layouts), then the resolved + # install paths for the ESPHOME_*_PREFIX overrides (docker/add-on/CI) + # that live outside it. + import platformdirs - idf_install_path = _get_idf_tools_path() - if idf_install_path.is_dir(): - _LOGGER.info("Deleting %s", idf_install_path) - rmtree(idf_install_path) + from esphome.components.nrf52.framework import get_sdk_nrf_tools_path + from esphome.espidf.framework import get_idf_tools_path + + cache_root = Path(platformdirs.user_cache_dir("esphome", appauthor=False)).resolve() + for install_path in (cache_root, get_idf_tools_path(), get_sdk_nrf_tools_path()): + if install_path.is_dir(): + _LOGGER.info("Deleting %s", install_path) + rmtree(install_path) # Clean PlatformIO project files try: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index f3e160925a..c5d9ddbaf1 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -21,7 +21,6 @@ from esphome.espidf.framework import ( _clone_idf_with_submodules, _get_framework_path, _get_idf_tool_paths, - _get_idf_tools_path, _get_idf_version, _get_python_env_path, _get_python_version, @@ -32,6 +31,7 @@ from esphome.espidf.framework import ( _write_stamp, check_esp_idf_install, get_framework_env, + get_idf_tools_path, ) from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path @@ -639,7 +639,7 @@ def test_write_stamp_writes_json(tmp_path: Path) -> None: def test_get_framework_env_with_python_env(tmp_path: Path) -> None: with ( patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), @@ -664,7 +664,7 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None: def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> None: with ( patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), @@ -687,7 +687,7 @@ def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None): return ( patch("esphome.espidf.framework.shutil.which", return_value=which), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch( @@ -761,7 +761,7 @@ def test_ccache_env_raises_without_build_path(tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# _check_stamp / _write_idf_version_txt / _get_idf_tools_path +# _check_stamp / _write_idf_version_txt / get_idf_tools_path # --------------------------------------------------------------------------- @@ -798,14 +798,14 @@ def test_write_idf_version_txt_skips_when_present(tmp_path: Path) -> None: assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "existing\n" -def test_get_idf_tools_path_env_override(tmp_path: Path) -> None: +def testget_idf_tools_path_env_override(tmp_path: Path) -> None: override = str(tmp_path / "custom-idf") with patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": override}): - assert _get_idf_tools_path() == Path(override) + assert get_idf_tools_path() == Path(override) @pytest.mark.parametrize("value", ["", " "]) -def test_get_idf_tools_path_blank_env_falls_back_to_default( +def testget_idf_tools_path_blank_env_falls_back_to_default( value: str, monkeypatch: pytest.MonkeyPatch ) -> None: """A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD. @@ -819,10 +819,10 @@ def test_get_idf_tools_path_blank_env_falls_back_to_default( expected = ( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" ).resolve() - assert _get_idf_tools_path() == expected + assert get_idf_tools_path() == expected -def test_get_idf_tools_path_default_uses_user_cache( +def testget_idf_tools_path_default_uses_user_cache( monkeypatch: pytest.MonkeyPatch, ) -> None: """Without the env override the install root is the machine-global OS user @@ -833,7 +833,7 @@ def test_get_idf_tools_path_default_uses_user_cache( expected = ( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" ).resolve() - assert _get_idf_tools_path() == expected + assert get_idf_tools_path() == expected def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: @@ -908,7 +908,7 @@ def test_check_windows_path_length_noop_when_long_paths_enabled( patch( "esphome.espidf.framework._windows_long_paths_enabled", return_value=True ), - patch("esphome.espidf.framework._get_idf_tools_path") as get_path_mock, + patch("esphome.espidf.framework.get_idf_tools_path") as get_path_mock, caplog.at_level(logging.WARNING), ): _check_windows_path_length() @@ -925,7 +925,7 @@ def test_check_windows_path_length_short_path_silent( "esphome.espidf.framework._windows_long_paths_enabled", return_value=False ), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=_SHORT_IDF_PATH, ), caplog.at_level(logging.WARNING), @@ -943,7 +943,7 @@ def test_check_windows_path_length_long_path_warns( "esphome.espidf.framework._windows_long_paths_enabled", return_value=False ), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=_LONG_IDF_PATH, ), caplog.at_level(logging.WARNING), diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 04c712f0b7..2b3d1f6db8 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -10,12 +10,28 @@ from esphome.components.nrf52.framework import ( _TOOLCHAIN_VERSION, _get_toolchain_platform_info, check_and_install, + get_sdk_nrf_tools_path, ) from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError +@pytest.fixture(autouse=True) +def _isolate_sdk_nrf_install_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pin the sdk-nrf install root to a tmp dir for every test. + + The default location is the OS user cache dir, so without this any test + that builds framework paths or pre-creates the install dir would touch + the real ``~/.cache/esphome`` on the developer's machine. Tests that need + to exercise the override or default-resolution logic clear/override the + env themselves. + """ + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(tmp_path / "sdk_nrf_install")) + + @pytest.mark.parametrize( ("system", "machine", "expected"), [ @@ -52,7 +68,7 @@ _TEST_SDK_VERSION = "2.9.0" def nrf52_dirs(setup_core: Path) -> SimpleNamespace: """Populate CORE and pre-create SDK directories so sentinel.touch() succeeds.""" CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(_TEST_SDK_VERSION)} - tools = CORE.data_dir / "sdk-nrf" + tools = get_sdk_nrf_tools_path() python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}" framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}" toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION @@ -226,3 +242,46 @@ class TestCheckAndInstall: assert substitutions["sysname"] == "linux" assert substitutions["machine"] == "x86_64" assert substitutions["extension"] == "tar.xz" + + +# --------------------------------------------------------------------------- +# get_sdk_nrf_tools_path tests +# --------------------------------------------------------------------------- + + +def testget_tools_path_env_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + override = tmp_path / "custom" / "sdk-nrf" + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(override)) + assert get_sdk_nrf_tools_path() == override.resolve() + + +@pytest.mark.parametrize("value", ["", " "]) +def testget_tools_path_blank_env_falls_back_to_default( + value: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """A blank ESPHOME_SDK_NRF_PREFIX is treated as unset, not as CWD. + + Path("") would resolve to the working directory, which clean-all could + then delete by accident. + """ + import platformdirs + + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + ).resolve() + assert get_sdk_nrf_tools_path() == expected + + +def testget_tools_path_default_is_global_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import platformdirs + + monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + ).resolve() + assert get_sdk_nrf_tools_path() == expected diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 18d08e7cb1..07f334d350 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -68,12 +68,16 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: test_clean_all_partial_exists) install their own inner patch which stacks on top of this one and wins for the duration of their block. - Also pin ``ESPHOME_ESP_IDF_PREFIX`` to a nonexistent tmp dir for the - same reason: ``clean_all`` removes the now machine-global ESP-IDF - install, which otherwise defaults to the real ``~/.cache/esphome``. + Also pin ``ESPHOME_ESP_IDF_PREFIX`` and ``ESPHOME_SDK_NRF_PREFIX`` to + nonexistent tmp dirs, and patch ``platformdirs.user_cache_dir``, for the + same reason: ``clean_all`` removes the machine-global toolchain installs + and their default cache root, which otherwise resolve to the real + ``~/.cache/esphome``. """ pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent" + sdk_nrf_root = tmp_path_factory.mktemp("isolated_sdk_nrf") / "nonexistent" + cache_root = tmp_path_factory.mktemp("isolated_cache") / "nonexistent" mock_cfg = MagicMock() mock_cfg.get.side_effect = lambda section, option: ( str(pio_root / option) if section == "platformio" else "" @@ -83,7 +87,14 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: "platformio.project.config.ProjectConfig.get_instance", return_value=mock_cfg, ), - patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": str(idf_root)}), + patch.dict( + "os.environ", + { + "ESPHOME_ESP_IDF_PREFIX": str(idf_root), + "ESPHOME_SDK_NRF_PREFIX": str(sdk_nrf_root), + }, + ), + patch("platformdirs.user_cache_dir", return_value=str(cache_root)), ): yield @@ -1022,6 +1033,55 @@ def test_clean_all_removes_global_idf_install( assert str(idf_install.resolve()) in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_removes_global_sdk_nrf_install( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the machine-global native sdk-nrf install dir.""" + sdk_nrf_install = tmp_path / "sdk_nrf_install" + (sdk_nrf_install / "frameworks").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(sdk_nrf_install)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not sdk_nrf_install.exists() + assert str(sdk_nrf_install.resolve()) in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_all_removes_default_cache_root( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the default cache root (stale/orphaned installs).""" + cache_root = tmp_path / "cache_root" + (cache_root / "some-old-toolchain").mkdir(parents=True) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with ( + patch("platformdirs.user_cache_dir", return_value=str(cache_root)), + caplog.at_level("INFO"), + ): + clean_all([str(config_dir)]) + + assert not cache_root.exists() + assert str(cache_root.resolve()) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_with_yaml_build_path( mock_core: MagicMock, From 4f0968f1df0b57751133f4aab4f7d54aea85b537 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:35:46 -0400 Subject: [PATCH 1950/2214] Bump github/codeql-action/analyze from 4.36.2 to 4.36.3 (#17363) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5a448c4003..6ca3e065cc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: category: "/language:${{matrix.language}}" From bef6773281f2fa0e0047df26f1eea597791945fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:36:01 -0400 Subject: [PATCH 1951/2214] Bump github/codeql-action/init from 4.36.2 to 4.36.3 (#17362) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6ca3e065cc..610e6ed020 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 0b48ca00278f692dbc5236743f73aa995807557e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:36:33 -0400 Subject: [PATCH 1952/2214] Bump the docker-actions group with 2 updates (#17361) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 8 ++++---- .github/workflows/release.yml | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index d6ad28dffe..07a792df08 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -67,7 +67,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Determine tag and whether to push id: tag @@ -96,7 +96,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -151,10 +151,10 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20a77b152d..d00c6523c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,15 +99,15 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -178,17 +178,17 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} From f447c88b4c032a3461ba3f7ee93f55263f605cad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:37:12 -0400 Subject: [PATCH 1953/2214] Bump docker/build-push-action from 7.2.0 to 7.3.0 in /.github/actions/build-image (#17336) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/build-image/action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 494c0cebe8..133d7ca8d8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -42,7 +42,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -67,7 +67,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false From 5417a16f9dc6d67f175ddbc5a5c8b02fb8674fce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:37:22 -0400 Subject: [PATCH 1954/2214] Update argcomplete requirement from >=3.6.3 to >=3.7.0 (#17334) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index baa8b5efd2..95388f278f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,4 +29,4 @@ platformdirs==4.10.0 # native esp-idf toolchain global cache dir pyparsing >= 3.3.2 # For autocompletion -argcomplete>=3.6.3 +argcomplete>=3.7.0 From 9f589ec4fcad48e61e2d6ccbf74889e089a6640e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:47:12 -0400 Subject: [PATCH 1955/2214] [api] Register homeassistant.action with synchronous=False to fix stale trigger args in response callbacks (#17367) --- esphome/components/api/__init__.py | 9 +++- tests/component_tests/api/__init__.py | 0 .../api/test_homeassistant_action.py | 28 ++++++++++++ .../api/test_homeassistant_action.yaml | 43 +++++++++++++++++++ tests/components/api/common-base.yaml | 28 ++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/api/__init__.py create mode 100644 tests/component_tests/api/test_homeassistant_action.py create mode 100644 tests/component_tests/api/test_homeassistant_action.yaml diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 0f5cd936f5..1146b43596 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -540,17 +540,20 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) +# synchronous=False: when on_success/on_error is configured, play() stores the +# trigger args until the HomeassistantActionResponse arrives, so non-owning args +# (StringRef into the API receive buffer) must not be used. @automation.register_action( "homeassistant.action", HomeAssistantServiceCallAction, HOMEASSISTANT_ACTION_ACTION_SCHEMA, - synchronous=True, + synchronous=False, ) @automation.register_action( "homeassistant.service", HomeAssistantServiceCallAction, HOMEASSISTANT_ACTION_ACTION_SCHEMA, - synchronous=True, + synchronous=False, ) async def homeassistant_service_to_code( config: ConfigType, @@ -644,6 +647,8 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( ) +# synchronous=True is safe here: the event schema has no on_success/on_error, +# so play() never stores the trigger args. @automation.register_action( "homeassistant.event", HomeAssistantServiceCallAction, diff --git a/tests/component_tests/api/__init__.py b/tests/component_tests/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/api/test_homeassistant_action.py b/tests/component_tests/api/test_homeassistant_action.py new file mode 100644 index 0000000000..611353e7c5 --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_action.py @@ -0,0 +1,28 @@ +"""Tests for arg-type selection of api user-defined services with homeassistant.action.""" + +CONFIG = "tests/component_tests/api/test_homeassistant_action.yaml" + + +def test_synchronous_chain_keeps_zero_copy_args(generate_main): + """A chain of synchronous actions keeps the non-owning StringRef arg type.""" + main_cpp = generate_main(CONFIG) + + assert ( + "api::UserServiceTrigger" + '("zero_copy_args", {"message"})' in main_cpp + ) + + +def test_response_callback_args_are_owning(generate_main): + """homeassistant.action with on_success/on_error stores the trigger args + until the HomeassistantActionResponse arrives, so string args must fall + back to owning std::string; StringRef would point into the connection's + receive buffer, which is reused before the response arrives.""" + main_cpp = generate_main(CONFIG) + + assert ( + "api::UserServiceTrigger" + '("response_args", {"message"})' in main_cpp + ) + assert "api::HomeAssistantServiceCallAction" in main_cpp + assert "api::HomeAssistantServiceCallAction" not in main_cpp diff --git a/tests/component_tests/api/test_homeassistant_action.yaml b/tests/component_tests/api/test_homeassistant_action.yaml new file mode 100644 index 0000000000..4561494c9e --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_action.yaml @@ -0,0 +1,43 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + actions: + # Chain of synchronous actions that never store the args: + # keeps the zero-copy StringRef arg type. + - action: zero_copy_args + variables: + message: string + then: + - logger.log: + format: "%s" + args: [message.c_str()] + # homeassistant.action with on_success/on_error stores the trigger args + # until the action response arrives, so the codegen must fall back to + # owning std::string args (StringRef would dangle once the receive + # buffer is reused). + - action: response_args + variables: + message: string + then: + - homeassistant.action: + action: notify.notify + data: + message: !lambda return message; + on_success: + - logger.log: + format: "sent %s" + args: [message.c_str()] + on_error: + - logger.log: + format: "failed (%s): %s" + args: [error.c_str(), message.c_str()] diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 060254990d..d7470ee4b3 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -109,6 +109,34 @@ api: - name.c_str() - int_arr.size() - string_arr.size() + # Test string + array args used by homeassistant.action's deferred + # on_success/on_error response callback. homeassistant.action registers + # synchronous=False, so the api codegen must fall back to owning + # std::string / std::vector args here: the non-owning defaults would + # dangle once rx_buf_ is reused before the response arrives, and the + # non-copyable FixedVector would fail to compile when captured into + # the response callback. + - action: action_response_args + variables: + name: string + int_arr: int[] + then: + - homeassistant.action: + action: notify.notify + data: + message: !lambda 'return name;' + on_success: + - logger.log: + format: "Notified %s (%u ints)" + args: + - name.c_str() + - int_arr.size() + on_error: + - logger.log: + format: "Notify failed (%s): %s" + args: + - error.c_str() + - name.c_str() # Test ContinuationAction (IfAction with then/else branches) - action: test_if_action variables: From 0725157bf50521298866ee7e7a22c8e222f51be9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:48:08 -0500 Subject: [PATCH 1956/2214] Bump bundled esphome-device-builder to 1.0.26 (#17369) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 543f17db56..7d56b04041 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.25 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.26 RUN \ platformio settings set enable_telemetry No \ From 5fe36a45edf7309a65260fb181ea3c845705a15c Mon Sep 17 00:00:00 2001 From: Joseph Spiros Date: Thu, 2 Jul 2026 19:48:55 -0400 Subject: [PATCH 1957/2214] [core] Skip MAC-suffix mDNS discovery for non-mDNS addresses (#16874) --- esphome/__main__.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 1767d3b7ca..2cc904ff4b 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -225,8 +225,9 @@ def _discover_mac_suffix_devices() -> list[str] | None: Returns: - ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off, - mDNS disabled, or ``CORE.address`` is already an IP). Callers should - then fall back to whatever default OTA address they normally use. + mDNS disabled, or ``CORE.address`` isn't a ``.local`` mDNS address). + Callers should then fall back to whatever default OTA address they + normally use. - ``[]`` when discovery ran but found nothing. Callers should NOT fall back to the base name: with ``name_add_mac_suffix`` enabled, the base name by definition doesn't exist on the network. @@ -236,7 +237,7 @@ def _discover_mac_suffix_devices() -> list[str] | None: ``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we already have without opening a second Zeroconf client. """ - if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()): + if not (has_name_add_mac_suffix() and has_mdns() and has_mdns_address()): return None from esphome.zeroconf import discover_mdns_devices @@ -503,17 +504,22 @@ def has_mdns() -> bool: def has_non_ip_address() -> bool: - """Check if CORE.address is set and is not an IP address.""" + """Check if ``CORE.address`` is set and is not an IP address.""" return CORE.address is not None and not is_ip_address(CORE.address) +def has_mdns_address() -> bool: + """Check if ``CORE.address`` is a ``.local`` mDNS hostname.""" + return CORE.address is not None and CORE.address.endswith(".local") + + def has_ip_address() -> bool: - """Check if CORE.address is a valid IP address.""" + """Check if ``CORE.address`` is a valid IP address.""" return CORE.address is not None and is_ip_address(CORE.address) def has_resolvable_address() -> bool: - """Check if CORE.address is resolvable (via mDNS, DNS, or is an IP address).""" + """Check if ``CORE.address`` is resolvable (via mDNS, DNS, or is an IP address).""" # Any address (IP, mDNS hostname, or regular DNS hostname) is resolvable # The resolve_ip_address() function in helpers.py handles all types via AsyncResolver if CORE.address is None: @@ -532,7 +538,7 @@ def has_resolvable_address() -> bool: return True # .local mDNS hostnames are only resolvable if mDNS is enabled - return not CORE.address.endswith(".local") + return not has_mdns_address() def has_name_add_mac_suffix() -> bool: From c3233739c591d321cb7277ee6665beb8a5a85967 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Fri, 3 Jul 2026 15:13:21 +0530 Subject: [PATCH 1958/2214] [zephyr] Implement GPIO interrupts (ISRInternalGPIOPin) (#17077) Co-authored-by: Claude Opus 4.8 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: tomaszduda23 --- .../components/gpio/binary_sensor/__init__.py | 3 +- esphome/components/zephyr/gpio.cpp | 82 ++++++++++++++++++- esphome/components/zephyr/gpio.h | 15 ++++ .../components/gpio/test.nrf52-adafruit.yaml | 24 ++++++ 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index f14a920c24..2f1aa936a3 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -39,7 +39,6 @@ CONFIG_SCHEMA = ( # due to hardware limitations or lack of reliable interrupt support. This ensures # stable operation on these platforms. Future maintainers should verify platform # capabilities before changing this default behavior. - # nrf52 has no gpio interrupts implemented yet cv.SplitDefault( CONF_USE_INTERRUPT, bk72xx=False, @@ -47,7 +46,7 @@ CONFIG_SCHEMA = ( esp8266=True, host=True, ln882x=False, - nrf52=False, + nrf52=True, rp2040=True, rtl87xx=False, ): cv.boolean, diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 1d5b0f282b..1e4201d8f5 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -1,6 +1,7 @@ #ifdef USE_ZEPHYR #include "gpio.h" #include +#include #include "esphome/core/log.h" namespace esphome { @@ -33,20 +34,80 @@ static gpio_flags_t flags_to_mode(gpio::Flags flags, bool inverted, bool value) return ret; } +// ESPHome's InterruptType is expressed in logical levels, but the pin is configured active-high in Zephyr (inversion is +// applied in software by digital_read()/digital_write(), see the `!= inverted_` convention below). So when the pin is +// inverted we must swap the physical edge/level the interrupt arms on: a logical rising edge is a physical falling +// edge, etc. GPIO_INT_EDGE_BOTH is symmetric and needs no swap. +static gpio_flags_t interrupt_type_to_flags(gpio::InterruptType type, bool inverted) { + switch (type) { + case gpio::INTERRUPT_RISING_EDGE: + return inverted ? GPIO_INT_EDGE_FALLING : GPIO_INT_EDGE_RISING; + case gpio::INTERRUPT_FALLING_EDGE: + return inverted ? GPIO_INT_EDGE_RISING : GPIO_INT_EDGE_FALLING; + case gpio::INTERRUPT_ANY_EDGE: + return GPIO_INT_EDGE_BOTH; + case gpio::INTERRUPT_LOW_LEVEL: + return inverted ? GPIO_INT_LEVEL_HIGH : GPIO_INT_LEVEL_LOW; + case gpio::INTERRUPT_HIGH_LEVEL: + return inverted ? GPIO_INT_LEVEL_LOW : GPIO_INT_LEVEL_HIGH; + } + return inverted ? GPIO_INT_EDGE_FALLING : GPIO_INT_EDGE_RISING; +} + +// Zephyr calls this with a pointer to the gpio_callback the interrupt fired on. +// Recover the owning ZephyrGPIOInterrupt and dispatch to the ESPHome ISR. +static void gpio_interrupt_handler(const device * /*dev*/, gpio_callback *cb, uint32_t /*pins*/) { + auto *interrupt = CONTAINER_OF(cb, ZephyrGPIOInterrupt, callback); + if (interrupt->func != nullptr) { + interrupt->func(interrupt->arg); + } +} + struct ISRPinArg { + const device *gpio; uint8_t pin; + uint8_t gpio_size; bool inverted; }; ISRInternalGPIOPin ZephyrGPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) + arg->gpio = this->gpio_; arg->pin = this->pin_; + arg->gpio_size = this->gpio_size_; arg->inverted = this->inverted_; return ISRInternalGPIOPin((void *) arg); } void ZephyrGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { - // TODO + if (!device_is_ready(this->gpio_)) { + ESP_LOGE(TAG, "Cannot attach interrupt: GPIO device not ready"); + return; + } + + // Drop any interrupt previously attached to this pin before re-registering. + this->detach_interrupt(); + + this->interrupt_.func = func; + this->interrupt_.arg = arg; + + uint8_t port_pin = this->pin_ % this->gpio_size_; + gpio_init_callback(&this->interrupt_.callback, gpio_interrupt_handler, BIT(port_pin)); + + int ret = gpio_add_callback(this->gpio_, &this->interrupt_.callback); + if (ret != 0) { + ESP_LOGE(TAG, "gpio_add_callback failed for pin %u: %d", this->pin_, ret); + return; + } + + ret = gpio_pin_interrupt_configure(this->gpio_, port_pin, interrupt_type_to_flags(type, this->inverted_)); + if (ret != 0) { + ESP_LOGE(TAG, "gpio_pin_interrupt_configure failed for pin %u: %d", this->pin_, ret); + gpio_remove_callback(this->gpio_, &this->interrupt_.callback); + return; + } + + ESP_LOGD(TAG, "Interrupt attached to pin %u (type=%d)", this->pin_, (int) type); } void ZephyrGPIOPin::setup() { @@ -88,15 +149,28 @@ void ZephyrGPIOPin::digital_write(bool value) { } gpio_pin_set(this->gpio_, this->pin_ % this->gpio_size_, value != this->inverted_ ? 1 : 0); } + void ZephyrGPIOPin::detach_interrupt() const { - // TODO + if (this->gpio_ == nullptr) { + return; + } + + uint8_t port_pin = this->pin_ % this->gpio_size_; + gpio_pin_interrupt_configure(this->gpio_, port_pin, GPIO_INT_DISABLE); + gpio_remove_callback(this->gpio_, &this->interrupt_.callback); + + this->interrupt_.func = nullptr; + this->interrupt_.arg = nullptr; } } // namespace zephyr bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { - // TODO - return false; + auto *arg = (zephyr::ISRPinArg *) this->arg_; + if (arg == nullptr || arg->gpio == nullptr) { + return false; + } + return bool(gpio_pin_get(arg->gpio, arg->pin % arg->gpio_size) != arg->inverted); } } // namespace esphome diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 907fbe9f9c..19d68cfb2b 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -3,8 +3,19 @@ #ifdef USE_ZEPHYR #include "esphome/core/hal.h" #include +#include namespace esphome::zephyr { +// Bundles the Zephyr gpio_callback together with the ESPHome ISR function and +// argument. Keeping them in one POD struct lets the static handler recover the +// owning data straight from the callback pointer via CONTAINER_OF, so no global +// pin->instance lookup table is needed. +struct ZephyrGPIOInterrupt { + struct gpio_callback callback; + void (*func)(void *){nullptr}; + void *arg{nullptr}; +}; + class ZephyrGPIOPin : public InternalGPIOPin { public: ZephyrGPIOPin(const device *gpio, int gpio_size, const char *pin_name_prefix) { @@ -36,6 +47,10 @@ class ZephyrGPIOPin : public InternalGPIOPin { uint8_t gpio_size_{}; bool inverted_{}; bool value_{false}; + + // attach_interrupt()/detach_interrupt() are const (matching the base class), so + // the interrupt state they manage has to be mutable. + mutable ZephyrGPIOInterrupt interrupt_{}; }; } // namespace esphome::zephyr diff --git a/tests/components/gpio/test.nrf52-adafruit.yaml b/tests/components/gpio/test.nrf52-adafruit.yaml index fb3f368e03..d034736524 100644 --- a/tests/components/gpio/test.nrf52-adafruit.yaml +++ b/tests/components/gpio/test.nrf52-adafruit.yaml @@ -1,7 +1,31 @@ +# P0.2, P0.4 and P0.5 all live on the same Zephyr port device (gpio0) and each +# attaches its own interrupt. This locks in shared-port behavior: every pin owns +# a separate gpio_callback initialized with its own BIT(pin) mask, so Zephyr +# dispatches to each pin independently even though the port device is shared. binary_sensor: - platform: gpio pin: 2 id: gpio_binary_sensor + use_interrupt: true + interrupt_type: ANY + + # Inverted pin with an edge-specific interrupt: exercises the inversion-aware + # interrupt-arming path (logical RISING must arm on the physical falling edge). + - platform: gpio + pin: + number: P0.4 + inverted: true + id: gpio_binary_sensor_inverted + use_interrupt: true + interrupt_type: RISING + + # Second non-inverted interrupt on the same port (gpio0) as P0.2 above: verifies + # multiple pins sharing one port device each get their own callback/pin_mask. + - platform: gpio + pin: P0.5 + id: gpio_binary_sensor_shared_port + use_interrupt: true + interrupt_type: FALLING output: - platform: gpio From 711d8bb0ade3d32361e9e129cfaa99347b0a1675 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:33:00 -0400 Subject: [PATCH 1959/2214] Synchronise Device Classes from Home Assistant (#17372) Co-authored-by: esphomebot --- esphome/components/number/__init__.py | 2 ++ esphome/components/sensor/__init__.py | 2 ++ esphome/const.py | 1 + 3 files changed, 5 insertions(+) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ee2d53c65a..bcc609de65 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -59,6 +59,7 @@ from esphome.const import ( DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, @@ -131,6 +132,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5a2ebf03c0..da8a540d8d 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -87,6 +87,7 @@ from esphome.const import ( DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, @@ -166,6 +167,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, diff --git a/esphome/const.py b/esphome/const.py index 5fa6f00b59..331eb5011d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1351,6 +1351,7 @@ DEVICE_CLASS_PRECIPITATION_INTENSITY = "precipitation_intensity" DEVICE_CLASS_PRESENCE = "presence" DEVICE_CLASS_PRESSURE = "pressure" DEVICE_CLASS_PROBLEM = "problem" +DEVICE_CLASS_RADON = "radon" DEVICE_CLASS_REACTIVE_ENERGY = "reactive_energy" DEVICE_CLASS_REACTIVE_POWER = "reactive_power" DEVICE_CLASS_RESTART = "restart" From c456fc98ab59f6fbbafecb53c41156c782aac545 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:31:21 -0400 Subject: [PATCH 1960/2214] Bump bundled esphome-device-builder to 1.0.27 (#17370) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7d56b04041..9dec23db1b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.26 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27 RUN \ platformio settings set enable_telemetry No \ From ea14a93e67c7be20610920aeeb616f5bc12c5529 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 3 Jul 2026 15:33:42 +0200 Subject: [PATCH 1961/2214] [nrf52] fix crash report for native build (#17371) --- esphome/components/nrf52/__init__.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 184d41e0f3..7ce973a2a9 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -697,10 +697,22 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> addr2line = find_tool("addr2line") if addr2line is None: return False - elf = CORE.relative_pioenvs_path(CORE.name, "firmware.elf") - if not elf.exists(): - _LOGGER.warning("%s does not exists", elf) + + candidates = [ + CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf"), + CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf"), + CORE.relative_pioenvs_path(CORE.name, "firmware.elf"), + ] + + elf = next((path for path in candidates if path.exists()), None) + + if elf is None: + _LOGGER.warning( + "None of the expected ELF files exist:\n%s", + "\n".join(str(p) for p in candidates), + ) return False + _LOGGER.error("=== CRASH ===") _LOGGER.error("PC: %s", _addr2line(addr2line, elf, pc)) _LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr)) From fd16eec416d29b17c9e85e7744a27fef3e1bd4fe Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 3 Jul 2026 18:16:55 +0200 Subject: [PATCH 1962/2214] [nrf52] switch nrf52 builds to native sdk by default (#17319) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: ESPHome Device Builder --- esphome/components/nrf52/__init__.py | 30 ++++++--- esphome/components/nrf52/framework.py | 22 ++++++ esphome/components/zephyr/__init__.py | 13 ++++ esphome/components/zephyr/const.py | 1 + .../components/zephyr_mcumgr/ota/__init__.py | 17 ++++- script/ci_memory_impact_extract.py | 67 +++++++++++++++---- tests/components/api/test.nrf52-adafruit.yaml | 4 +- .../components/nrf52/test.nrf52-adafruit.yaml | 2 - 8 files changed, 126 insertions(+), 30 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 7ce973a2a9..7c17eadd1a 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -117,7 +117,7 @@ def set_core_data(config: ConfigType) -> ConfigType: def _resolve_toolchain(config: ConfigType) -> ConfigType: if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF) return config @@ -439,8 +439,8 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: types = [] UF2_PATH = "zephyr/zephyr.uf2" DFU_PATH = "firmware.zip" - HEX_PATH = "zephyr/zephyr.hex" - HEX_MERGED_PATH = "zephyr/merged.hex" + HEX_PATH = "zephyr/zephyr.hex" # SDK 2.6.1, only generated when OTA is disabled + HEX_MERGED_PATH = "zephyr/merged.hex" # SDK 2.9.2, always generated APP_IMAGE_PATH = "zephyr/app_update.bin" build_dir = Path(storage_json.firmware_bin_path).parent if (build_dir / UF2_PATH).is_file(): @@ -777,6 +777,11 @@ def _generate_cmake_lists() -> bool: ) +def _copy_if_exists(src: Path, dst: Path) -> None: + if src.is_file(): + shutil.copy2(src, dst) + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: return False @@ -828,15 +833,18 @@ def run_compile(args, config: ConfigType) -> bool: ): raise EsphomeError("nRF52 native build failed") - # Zephyr's cmake places kernel artifacts in build_dir/zephyr/zephyr/ and - # merged.hex at build_dir/. Normalize to build_dir/zephyr/ so paths match - # get_download_types (which mirrors the platformio build output layout). zephyr_dir = build_dir / "zephyr" - west_out = zephyr_dir / "zephyr" - for filename in ["zephyr.uf2"]: - src = west_out / filename - if src.is_file(): - shutil.copy2(src, zephyr_dir / filename) + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + # SDK < 2.9.2 places artifacts directly in build_dir/zephyr/. + # SDK >= 2.9.2 nests them one level deeper (build_dir/zephyr/zephyr/); + # copy files to match get_download_types layout. + if framework_ver < cv.Version(2, 9, 2): + west_out = zephyr_dir + else: + west_out = zephyr_dir / "zephyr" + _copy_if_exists(west_out / "zephyr.uf2", zephyr_dir / "zephyr.uf2") + _copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin") + _copy_if_exists(build_dir / "merged.hex", zephyr_dir / "merged.hex") # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes _GENPKG_PARAMS = { diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 7cb1164482..640aa07fbf 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -2,10 +2,12 @@ import logging import os from pathlib import Path import platform +import shutil import tempfile import platformdirs +import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( @@ -134,6 +136,23 @@ def get_build_env() -> dict: return env +def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: + # SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that + # Python 3.12+ flags with SyntaxWarning (a future version will reject it). + uf2conv = framework_path / "zephyr" / "scripts" / "build" / "uf2conv.py" + if not uf2conv.exists(): + return + content = uf2conv.read_text(encoding="utf-8") + patched = content.replace("re.split('\\s+', line)", "re.split('\\\\s+', line)") + if patched == content: + return + # Write atomically so a concurrent build never sees a truncated file + tmp = uf2conv.with_suffix(".py.tmp") + tmp.write_text(patched, encoding="utf-8") + shutil.copymode(uf2conv, tmp) + tmp.replace(uf2conv) + + def check_and_install() -> None: version = _get_version_str() python_env_path = _get_python_env_path(version) @@ -195,6 +214,9 @@ def check_and_install() -> None: ] if not run_command_ok(cmd, cwd=framework_path): raise EsphomeError(f"Can't update nRF Connect SDK {version}") + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver < cv.Version(2, 9, 2): + _patch_uf2conv_escape_sequences(framework_path) sentinel.touch() zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index cd077a142f..d6c45a744c 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -18,6 +18,7 @@ from .const import ( KEY_OVERLAY, KEY_PM_STATIC, KEY_PRJ_CONF, + KEY_SYSBUILD, KEY_USER, KEY_ZEPHYR, zephyr_ns, @@ -55,6 +56,7 @@ class ZephyrData(TypedDict): pm_static: list[Section] user: dict[str, list[str]] kconfig: str + sysbuild: bool def zephyr_set_core_data(config: ConfigType) -> None: @@ -69,6 +71,10 @@ def zephyr_set_core_data(config: ConfigType) -> None: pm_static=[], user={}, kconfig="", + # When OTA is disabled, the image is built without a bootloader even if the + # config says `bootloader: mcuboot`, so the image can be smaller. This was + # the default behaviour in SDK 2.6.1. + sysbuild=False, ) @@ -286,6 +292,13 @@ def copy_files() -> None: CORE.relative_build_path("zephyr/Kconfig"), kconfig ) + sysbuild_conf = "" + if zephyr_data()[KEY_SYSBUILD]: + sysbuild_conf = "SB_CONFIG_BOOTLOADER_MCUBOOT=y\n" + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/sysbuild.conf"), sysbuild_conf + ) + if changed: # A configure-time input changed; drop the CMake cache so the build # can't reuse stale configure results (the native sdk-nrf toolchain diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index f2de861e31..497e5f3ce5 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -13,6 +13,7 @@ KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" KEY_BOARD: Final = "board" KEY_USER: Final = "user" +KEY_SYSBUILD: Final = "sysbuild" zephyr_ns = cg.esphome_ns.namespace("zephyr") CdcAcm = zephyr_ns.class_("CdcAcm", cg.Component) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index b0d86190b8..0ff1825bd1 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -6,9 +6,19 @@ from esphome.components.zephyr import ( zephyr_add_prj_conf, zephyr_data, ) -from esphome.components.zephyr.const import BOOTLOADER_MCUBOOT, KEY_BOOTLOADER +from esphome.components.zephyr.const import ( + BOOTLOADER_MCUBOOT, + KEY_BOOTLOADER, + KEY_SYSBUILD, +) import esphome.config_validation as cv -from esphome.const import CONF_HARDWARE_UART, CONF_ID, Framework +from esphome.const import ( + CONF_HARDWARE_UART, + CONF_ID, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + Framework, +) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -139,3 +149,6 @@ async def to_code(config: ConfigType) -> None: }}; """ ) + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver >= cv.Version(2, 9, 2): + zephyr_data()[KEY_SYSBUILD] = True diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index feacc2b1af..20a737cdbf 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Extract memory usage statistics from ESPHome build output. -This script parses the PlatformIO build output to extract RAM and flash -usage statistics for a compiled component. It's used by the CI workflow to +This script parses the build output to extract RAM and flash usage +statistics for a compiled component. It's used by the CI workflow to compare memory usage between branches. The script reads compile output from stdin and looks for the standard @@ -10,6 +10,13 @@ PlatformIO output format: RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) +or the linker memory usage table printed by Zephyr native builds +(e.g. nRF52 with the sdk-nrf toolchain): + Memory region Used Size Region Size %age Used + FLASH: 90624 B 796 KB 11.12% + RAM: 22432 B 256 KB 8.56% + IDT_LIST: 0 GB 32 KB 0.00% + Optionally performs detailed memory analysis if a build directory is provided. """ @@ -34,20 +41,43 @@ _RAM_PATTERN = re.compile(r"RAM:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes" _FLASH_PATTERN = re.compile(r"Flash:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes") _BUILD_PATH_PATTERN = re.compile(r"Build path: (.+)") +# Zephyr native builds print the GNU ld --print-memory-usage table instead of +# the PlatformIO summary. Only the FLASH and RAM regions are real memory +# (IDT_LIST is a build-time pseudo-region discarded from the final image). +# Each cell is humanized to the largest unit that divides evenly, so used +# sizes are not always plain bytes (zero prints as "0 GB"). +_ZEPHYR_RAM_PATTERN = re.compile( + r"^\s*RAM:\s+(\d+)\s*([KMG]?B)\s+\d+\s*[KMG]?B\s+\d+\.\d+%", re.MULTILINE +) +_ZEPHYR_FLASH_PATTERN = re.compile( + r"^\s*FLASH:\s+(\d+)\s*([KMG]?B)\s+\d+\s*[KMG]?B\s+\d+\.\d+%", re.MULTILINE +) +_ZEPHYR_UNIT_MULTIPLIERS = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3} + + +def _zephyr_bytes(matches: list[tuple[str, str]]) -> int: + """Sum humanized (value, unit) pairs from the Zephyr memory table.""" + return sum(int(value) * _ZEPHYR_UNIT_MULTIPLIERS[unit] for value, unit in matches) + def extract_from_compile_output( output_text: str, ) -> tuple[int | None, int | None, str | None]: - """Extract memory usage and build directory from PlatformIO compile output. + """Extract memory usage and build directory from compile output. Supports multiple builds (for component groups or isolated components). When test_build_components.py creates multiple builds, this sums the memory usage across all builds. - Looks for lines like: + Looks for PlatformIO lines like: RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) + and Zephyr (native west build) linker table rows like: + Memory region Used Size Region Size %age Used + FLASH: 90624 B 796 KB 11.12% + RAM: 22432 B 256 KB 8.56% + Also extracts build directory from lines like: INFO Compiling app... Build path: /path/to/build @@ -61,12 +91,20 @@ def extract_from_compile_output( ram_matches = _RAM_PATTERN.findall(output_text) flash_matches = _FLASH_PATTERN.findall(output_text) - if not ram_matches or not flash_matches: + # Zephyr native builds print the linker memory table instead + zephyr_ram_matches = _ZEPHYR_RAM_PATTERN.findall(output_text) + zephyr_flash_matches = _ZEPHYR_FLASH_PATTERN.findall(output_text) + + if not (ram_matches or zephyr_ram_matches) or not ( + flash_matches or zephyr_flash_matches + ): return None, None, None # Sum all builds (handles multiple component groups) total_ram = sum(int(match) for match in ram_matches) total_flash = sum(int(match) for match in flash_matches) + total_ram += _zephyr_bytes(zephyr_ram_matches) + total_flash += _zephyr_bytes(zephyr_flash_matches) # Extract build directory from ESPHome's explicit build path output # Look for: INFO Compiling app... Build path: /path/to/build @@ -202,20 +240,23 @@ def main() -> int: ) if ram_bytes is None or flash_bytes is None: - print("Failed to extract memory usage from compile output", file=sys.stderr) - print("Expected lines like:", file=sys.stderr) print( - " RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)", - file=sys.stderr, - ) - print( - " Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)", + "Failed to extract memory usage from compile output\n" + "Expected lines like:\n" + " RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n" + " Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n" + "or a Zephyr linker memory usage table like:\n" + " Memory region Used Size Region Size %age Used\n" + " FLASH: 90624 B 796 KB 11.12%\n" + " RAM: 22432 B 256 KB 8.56%", file=sys.stderr, ) return 1 # Count how many builds were found - num_builds = len(_RAM_PATTERN.findall(compile_output)) + num_builds = len(_RAM_PATTERN.findall(compile_output)) + len( + _ZEPHYR_RAM_PATTERN.findall(compile_output) + ) if num_builds > 1: print( diff --git a/tests/components/api/test.nrf52-adafruit.yaml b/tests/components/api/test.nrf52-adafruit.yaml index 18bf23d710..347480bab6 100644 --- a/tests/components/api/test.nrf52-adafruit.yaml +++ b/tests/components/api/test.nrf52-adafruit.yaml @@ -1,7 +1,7 @@ +<<: !include common.yaml + network: enable_ipv6: true openthread: tlv: 0E080000000000010000 - -api: diff --git a/tests/components/nrf52/test.nrf52-adafruit.yaml b/tests/components/nrf52/test.nrf52-adafruit.yaml index 3ae48b2a5f..5fa0d6e88f 100644 --- a/tests/components/nrf52/test.nrf52-adafruit.yaml +++ b/tests/components/nrf52/test.nrf52-adafruit.yaml @@ -19,5 +19,3 @@ nrf52: reg0: voltage: 2.1V uicr_erase: true - framework: - version: "2.6.1-b" From 187cd51867387475431dcb17c87d3e7cd3da9e11 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:47:08 -0400 Subject: [PATCH 1963/2214] [ci] Carry native-toolchain needs on component test batches (#17359) --- .github/workflows/ci.yml | 14 ++++++++------ script/determine-jobs.py | 20 +++++++++++++++----- tests/script/test_determine_jobs.py | 18 ++++++++++-------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2016739c4f..9310b45b4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -795,7 +795,7 @@ jobs: if: always() test-build-components-split: - name: Test components batch (${{ matrix.components }}) + name: Test components batch (${{ matrix.batch.components }}) runs-on: ubuntu-24.04 needs: - common @@ -809,7 +809,7 @@ jobs: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} matrix: - components: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} + batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: - name: Show disk space run: | @@ -817,7 +817,7 @@ jobs: df -h - name: List components - run: echo ${{ matrix.components }} + run: echo ${{ matrix.batch.components }} - name: Cache apt packages uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 @@ -833,8 +833,10 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install (restore-only) - # A batch may contain no esp32 build, so never save -- just reuse the - # shared install the dev tidy jobs already cached when present. + # Only batches whose test platforms include esp32 need the native + # ESP-IDF install; never save -- just reuse the shared install the + # dev tidy jobs already cached when present. + if: matrix.batch.needs_idf uses: ./.github/actions/cache-esp-idf with: restore-only: true @@ -868,7 +870,7 @@ jobs: fi # Convert space-separated components to comma-separated for Python script - components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',') + components_csv=$(echo "${{ matrix.batch.components }}" | tr ' ' ',') # Only isolate directly changed components when targeting dev branch # For beta/release branches, group everything for faster CI diff --git a/script/determine-jobs.py b/script/determine-jobs.py index af3e83f96b..756f3884b8 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -1338,7 +1338,7 @@ def main() -> None: # Split components into batches for CI testing # This intelligently groups components with similar bus configurations - component_test_batches: list[str] + component_test_batches: list[dict[str, Any]] = [] if changed_components_with_tests: tests_dir = Path(root_path) / ESPHOME_TESTS_COMPONENTS_PATH @@ -1363,10 +1363,20 @@ def main() -> None: batch_size=COMPONENT_TEST_BATCH_SIZE, directly_changed=batch_directly_changed, ) - # Convert batches to space-separated strings for CI matrix - component_test_batches = [" ".join(batch) for batch in batches] - else: - component_test_batches = [] + # Convert batches to CI matrix entries: the component list plus which + # native toolchain installs the batch's test platforms need, so the + # workflow only restores the matching multi-GB toolchain caches. + for batch in batches: + platforms: set[str] = set() + for component in batch: + platforms.update(get_component_test_platforms(component)) + component_test_batches.append( + { + "components": " ".join(batch), + "needs_idf": any(p.startswith("esp32") for p in platforms), + "needs_nrf": any(p.startswith("nrf52") for p in platforms), + } + ) output: dict[str, Any] = { "core_ci": run_core_ci, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index d4c13fd3fb..2f038155c0 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -231,14 +231,16 @@ def test_main_all_tests_should_run( assert output["memory_impact"]["should_run"] == "false" assert output["cpp_unit_tests_run_all"] is False assert output["cpp_unit_tests_components"] == ["wifi", "api", "sensor"] - # component_test_batches should be present and be a list of space-separated strings + # component_test_batches should be a list of matrix entries carrying the + # space-separated component list and the toolchain-need flags assert "component_test_batches" in output assert isinstance(output["component_test_batches"], list) - # Each batch should be a space-separated string of component names for batch in output["component_test_batches"]: - assert isinstance(batch, str) + assert isinstance(batch, dict) # Should contain at least one component (no empty batches) - assert len(batch) > 0 + assert len(batch["components"]) > 0 + assert isinstance(batch["needs_idf"], bool) + assert isinstance(batch["needs_nrf"], bool) def test_main_no_tests_should_run( @@ -2417,16 +2419,16 @@ def test_component_batching_beta_branch_40_per_batch( assert len(batches) == 3, f"Expected 3 batches, got {len(batches)}" # Each batch should have approximately 40 components (all weight=1, groupable) - for i, batch_str in enumerate(batches): - batch_components = batch_str.split() + for i, batch in enumerate(batches): + batch_components = batch["components"].split() assert len(batch_components) == 40, ( f"Batch {i} should have 40 components, got {len(batch_components)}" ) # Verify all 120 components are in batches all_components = [] - for batch_str in batches: - all_components.extend(batch_str.split()) + for batch in batches: + all_components.extend(batch["components"].split()) assert len(all_components) == 120 assert set(all_components) == set(component_names) From 7ad43358c2e6001656f3181ac6dec11609fcc51b Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:28:29 +0200 Subject: [PATCH 1964/2214] [zigbee] Bump zigbee sdk to 2.0.2 (#16869) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/zigbee/__init__.py | 16 + esphome/components/zigbee/const.py | 15 +- esphome/components/zigbee/const_esp32.py | 30 +- .../zigbee/zigbee_attribute_esp32.cpp | 75 ++-- .../zigbee/zigbee_attribute_esp32.h | 7 +- esphome/components/zigbee/zigbee_ep_esp32.py | 12 +- esphome/components/zigbee/zigbee_esp32.cpp | 353 +++++++++--------- esphome/components/zigbee/zigbee_esp32.h | 57 +-- esphome/components/zigbee/zigbee_esp32.py | 36 +- .../components/zigbee/zigbee_helpers_esp32.c | 99 ++--- .../components/zigbee/zigbee_helpers_esp32.h | 13 +- esphome/components/zigbee/zigbee_zephyr.py | 2 +- esphome/idf_component.yml | 6 +- sdkconfig.defaults.esp32c6 | 1 - tests/components/zigbee/common_esp32.yaml | 2 +- 15 files changed, 358 insertions(+), 366 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index c75b0773d2..444012bcd8 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -8,6 +8,9 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, ) import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME @@ -52,11 +55,21 @@ CODEOWNERS = ["@luar123", "@tomaszduda23"] CONFLICTS_WITH = ["openthread"] + +def _check_report_deprecation(value: str) -> str: + if str(value).lower() in ("coordinator", "enable"): + _LOGGER.warning( + "Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead." + ) + return value + + BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( cv.requires_component("zigbee"), cv.requires_component("esp32"), + _check_report_deprecation, cv.enum(REPORT, lower=True), ) } @@ -111,7 +124,10 @@ CONFIG_SCHEMA = cv.All( cv.only_on_esp32, only_on_variant( supported=[ + VARIANT_ESP32S31, VARIANT_ESP32H2, + VARIANT_ESP32H21, + VARIANT_ESP32H4, VARIANT_ESP32C5, VARIANT_ESP32C6, ] diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 7d0e14c67a..dd36f815ab 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -55,6 +55,7 @@ REPORT = { "coordinator": report.ZIGBEE_REPORT_COORDINATOR, "enable": report.ZIGBEE_REPORT_ENABLE, "force": report.ZIGBEE_REPORT_FORCE, + "default": report.ZIGBEE_REPORT_DEFAULT, } CONF_ON_JOIN = "on_join" @@ -63,13 +64,13 @@ CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" POWER_SOURCE = { - "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", - "MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE", - "MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE", - "BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY", - "DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE", - "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", - "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", + "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN + "MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE + "MAINS_THREE_PHASE": 0x02, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE + "BATTERY": 0x03, # ZB_ZCL_BASIC_POWER_SOURCE_BATTERY + "DC_SOURCE": 0x04, # ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE + "EMERGENCY_MAINS_CONST": 0x05, # ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST + "EMERGENCY_MAINS_TRANSF": 0x06, # ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF } KEY_ZIGBEE = "zigbee" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index bb507320eb..81a8fc52cd 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -13,27 +13,25 @@ CONF_ATTRIBUTE_ID = "attribute_id" KEY_BS_EP = "binary_sensor_ep" KEY_SENSOR_EP = "sensor_ep" -ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e") DEVICE_ID = { - "RANGE_EXTENDER": ha_standard_devices.ZB_HA_RANGE_EXTENDER_DEVICE_ID, - "SIMPLE_SENSOR": ha_standard_devices.ZB_HA_SIMPLE_SENSOR_DEVICE_ID, - "CUSTOM_ATTR": ha_standard_devices.ZB_HA_CUSTOM_ATTR_DEVICE_ID, + "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), + "SIMPLE_SENSOR": cg.RawExpression("EZB_ZHA_SIMPLE_SENSOR_DEVICE_ID"), + "CUSTOM_ATTR": 0xFFF2, } -cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t") +cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e") CLUSTER_ID = { - "BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC, - "BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, - "ANALOG_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, + "BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC, + "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, + "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, } -cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t") CLUSTER_ROLE = { - "SERVER": cluster_role.ESP_ZB_ZCL_CLUSTER_SERVER_ROLE, + "SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"), } -attr_type = cg.esphome_ns.enum("esp_zb_zcl_attr_type_t") +attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e") ATTR_TYPE = { - "BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL, - "8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP, - "CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING, - "SINGLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_SINGLE, - "DOUBLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_DOUBLE, + "BOOL": attr_type.EZB_ZCL_ATTR_TYPE_BOOL, + "MAP8": attr_type.EZB_ZCL_ATTR_TYPE_MAP8, + "STRING": attr_type.EZB_ZCL_ATTR_TYPE_STRING, + "SINGLE": attr_type.EZB_ZCL_ATTR_TYPE_SINGLE, + "DOUBLE": attr_type.EZB_ZCL_ATTR_TYPE_DOUBLE, } diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index 0a06792c59..c6f2aa0af6 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -12,63 +12,68 @@ void ZigbeeAttribute::set_attr_() { if (!this->zb_->is_connected()) { return; } - if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { - esp_zb_zcl_status_t state = esp_zb_zcl_set_attribute_val(this->endpoint_id_, this->cluster_id_, this->role_, - this->attr_id_, this->value_p_, false); + if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + ezb_zcl_status_t state = ezb_zcl_set_attr_value(this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, + EZB_ZCL_STD_MANUF_CODE, this->value_p_, false); if (this->force_report_) { this->report_(true); } this->set_attr_requested_ = false; // Check for error - if (state != ESP_ZB_ZCL_STATUS_SUCCESS) { + if (state != EZB_ZCL_STATUS_SUCCESS) { ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); } - esp_zb_lock_release(); + esp_zigbee_lock_release(); } } void ZigbeeAttribute::report_(bool has_lock) { - if (!this->zb_->is_connected()) { + if (!this->zb_->is_connected() || !this->report_enabled) { return; } - if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { - esp_zb_zcl_report_attr_cmd_t cmd = {}; - cmd.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT; - cmd.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI; - cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000; - cmd.zcl_basic_cmd.dst_endpoint = 1; - cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_; - cmd.clusterID = this->cluster_id_; - cmd.attributeID = this->attr_id_; + if (has_lock or esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + ezb_zcl_report_attr_cmd_t cmd = {}; + cmd.cmd_ctrl.fc.direction = EZB_ZCL_CMD_DIRECTION_TO_CLI; + cmd.cmd_ctrl.fc.dis_default_rsp = 1; + cmd.cmd_ctrl.dst_addr.addr_mode = EZB_ADDR_MODE_SHORT; + cmd.cmd_ctrl.dst_addr.u.short_addr = 0x0000; + cmd.cmd_ctrl.dst_ep = 1; + cmd.cmd_ctrl.src_ep = this->endpoint_id_; + cmd.cmd_ctrl.cluster_id = this->cluster_id_; + cmd.cmd_ctrl.fc.manuf_specific = 0; + cmd.payload.attr_id = this->attr_id_; - esp_zb_zcl_report_attr_cmd_req(&cmd); + ezb_zcl_report_attr_cmd_req(&cmd); if (!has_lock) { - esp_zb_lock_release(); + esp_zigbee_lock_release(); } } } -esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() { - esp_zb_zcl_reporting_info_t reporting_info = {}; - reporting_info.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV; - reporting_info.ep = this->endpoint_id_; - reporting_info.cluster_id = this->cluster_id_; - reporting_info.cluster_role = this->role_; - reporting_info.attr_id = this->attr_id_; - reporting_info.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC; - reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID; - reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */ - reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */ - reporting_info.u.send_info.def_min_interval = 10; /*!< Default minimum reporting interval */ - reporting_info.u.send_info.def_max_interval = 0; /*!< Default maximum reporting interval */ - reporting_info.u.send_info.delta.s16 = 0; /*!< Actual reportable change */ - - return reporting_info; +void ZigbeeAttribute::setup_reporting() { + ezb_zcl_reporting_info_t reporting_info = ezb_zcl_reporting_info_find( + this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, EZB_ZCL_STD_MANUF_CODE); + if (reporting_info == EZB_ZCL_INVALID_REPORTING_INFO) { + ESP_LOGD(TAG, "Could not find reporting info for attribute 0x%04X in cluster 0x%04X in endpoint %u", this->attr_id_, + this->cluster_id_, this->endpoint_id_); + this->report_enabled = false; + this->force_report_ = false; + } else { + ESP_LOGD(TAG, "Found reporting info for attr 0x%04X in cluster 0x%04X", this->attr_id_, this->cluster_id_); + ezb_zcl_attr_variable_t delta = {.u64 = 0}; + ezb_zcl_reporting_info_update_default_interval(reporting_info, 0, 65000); + ezb_zcl_reporting_info_update(reporting_info, 0, 65000, &delta); + if (ezb_zcl_reporting_start_attr_report(reporting_info) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not start reporting for attribute"); + } + } } -void ZigbeeAttribute::set_report(bool force) { +void ZigbeeAttribute::set_report(ZigbeeReportT report) { this->report_enabled = true; - this->force_report_ = force; + if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { + this->force_report_ = true; + } } void ZigbeeAttribute::loop() { diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index e978fcf209..b5afb57910 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -9,7 +9,7 @@ #ifdef USE_ESP32 #ifdef USE_ZIGBEE -#include "esp_zigbee_core.h" +#include "esp_zigbee.h" #include "zigbee_esp32.h" #ifdef USE_SENSOR @@ -22,6 +22,7 @@ namespace esphome::zigbee { enum ZigbeeReportT { + ZIGBEE_REPORT_DEFAULT, ZIGBEE_REPORT_COORDINATOR, ZIGBEE_REPORT_ENABLE, ZIGBEE_REPORT_FORCE, @@ -41,10 +42,10 @@ class ZigbeeAttribute final : public Component { scale_(scale) {} void loop() override; template void add_attr(T value); - esp_zb_zcl_reporting_info_t get_reporting_info(); + void setup_reporting(); template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } - void set_report(bool force); + void set_report(ZigbeeReportT report); #ifdef USE_SENSOR template void connect(sensor::Sensor *sensor); #endif diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 5dd76e9903..f4efa7bf4e 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -27,7 +27,7 @@ ep_configs: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: REPORT["enable"], + CONF_REPORT: REPORT["default"], CONF_DEVICE: None, }, { @@ -36,11 +36,11 @@ ep_configs: dict[str, dict[str, Any]] = { }, { CONF_ATTRIBUTE_ID: 0x6F, - CONF_TYPE: "8BITMAP", + CONF_TYPE: "MAP8", }, { CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "CHAR_STRING", + CONF_TYPE: "STRING", }, ], }, @@ -56,7 +56,7 @@ ep_configs: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: REPORT["enable"], + CONF_REPORT: REPORT["default"], CONF_DEVICE: None, }, { @@ -65,11 +65,11 @@ ep_configs: dict[str, dict[str, Any]] = { }, { CONF_ATTRIBUTE_ID: 0x6F, - CONF_TYPE: "8BITMAP", + CONF_TYPE: "MAP8", }, { CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "CHAR_STRING", + CONF_TYPE: "STRING", }, ], }, diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 1809f181be..03457312be 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -36,121 +36,143 @@ uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { return zcl_str; } -static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) { - if (esp_zb_bdb_start_top_level_commissioning(mode_mask) != ESP_OK) { - ESP_LOGE(TAG, "Start network steering failed!"); +void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { + if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + return; } + if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Start top level commissioning failed!"); + } + esp_zigbee_lock_release(); } -extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { +bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { static uint8_t steering_retry_count = 0; - uint32_t *p_sg_p = signal_struct->p_app_signal; - esp_err_t err_status = signal_struct->esp_err_status; - esp_zb_app_signal_type_t sig_type = (esp_zb_app_signal_type_t) *p_sg_p; - esp_zb_zdo_signal_leave_params_t *leave_params = NULL; - switch (sig_type) { - case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP: + ezb_app_signal_type_t signal_type = ezb_app_signal_get_type(app_signal); + switch (signal_type) { + case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); - esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION); + if (ezb_bdb_is_factory_new()) { + global_zigbee->defer([]() { global_zigbee->setup_reporting(); }); + } else { + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); + } break; - case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START: - case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT: - if (err_status == ESP_OK) { - ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non "); + case EZB_BDB_SIGNAL_DEVICE_FIRST_START: + case EZB_BDB_SIGNAL_DEVICE_REBOOT: { + ezb_bdb_comm_status_t status = *((ezb_bdb_comm_status_t *) ezb_app_signal_get_params(app_signal)); + if (status == EZB_BDB_STATUS_SUCCESS) { + ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", ezb_bdb_is_factory_new() ? "" : "non "); global_zigbee->started = true; - if (esp_zb_bdb_is_factory_new()) { + if (ezb_bdb_is_factory_new()) { global_zigbee->factory_new = true; ESP_LOGD(TAG, "Start network steering"); - esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING); + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_NETWORK_STEERING); } else { ESP_LOGD(TAG, "Device rebooted"); global_zigbee->joined = true; global_zigbee->enable_loop_soon_any_context(); } } else { - ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)", - esp_zb_bdb_is_factory_new() ? "" : "non ", err_status, esp_err_to_name(err_status)); - ESP_LOGW(TAG, "Failed to initialize Zigbee stack (status: %s)", esp_err_to_name(err_status)); - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, ESP_ZB_BDB_MODE_INITIALIZATION, - 1000); + ESP_LOGW(TAG, "The %s failed with status(0x%02x), please retry", ezb_app_signal_to_string(signal_type), status); + global_zigbee->set_timeout("zb_init", 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); + }); } - break; - case ESP_ZB_BDB_SIGNAL_STEERING: - if (err_status == ESP_OK) { + } break; + case EZB_BDB_SIGNAL_STEERING: { + ezb_bdb_comm_status_t status = *((ezb_bdb_comm_status_t *) ezb_app_signal_get_params(app_signal)); + if (status == EZB_BDB_STATUS_SUCCESS) { steering_retry_count = 0; - ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(), - esp_zb_get_current_channel()); + ezb_extpanid_t extended_pan_id; + ezb_nwk_get_extended_panid(&extended_pan_id); + ESP_LOGD(TAG, "Joined network successfully: PAN ID(0x%04hx, EXT: 0x%llx), Channel(%d), Short Address(0x%04hx)", + ezb_nwk_get_panid(), extended_pan_id.u64, ezb_nwk_get_current_channel(), ezb_nwk_get_short_address()); global_zigbee->joined = true; global_zigbee->enable_loop_soon_any_context(); } else { - ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status)); + ESP_LOGD(TAG, "Failed to join network with status(0x%02x)", status); if (steering_retry_count < 10) { steering_retry_count++; - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, - ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000); + global_zigbee->set_timeout("zb_init", 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); + }); } else { - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, - ESP_ZB_BDB_MODE_NETWORK_STEERING, 600 * 1000); + global_zigbee->set_timeout("zb_init", 600 * 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); + }); } } - break; - case ESP_ZB_ZDO_SIGNAL_LEAVE: - leave_params = (esp_zb_zdo_signal_leave_params_t *) esp_zb_app_signal_get_params(p_sg_p); - if (leave_params->leave_type == ESP_ZB_NWK_LEAVE_TYPE_RESET) { - esp_zb_factory_reset(); + } break; + case EZB_ZDO_SIGNAL_LEAVE: { + const ezb_zdo_signal_leave_params_t *leave_params = + (const ezb_zdo_signal_leave_params_t *) ezb_app_signal_get_params(app_signal); + if (leave_params->leave_type == EZB_ZDO_LEAVE_TYPE_RESET) { + esp_zigbee_factory_reset(); } - break; + } break; default: - ESP_LOGD(TAG, "ZDO signal: %s (0x%x), status: %s", esp_zb_zdo_signal_to_string(sig_type), sig_type, - esp_err_to_name(err_status)); + ESP_LOGD(TAG, "Zigbee APP Signal: %s(type: 0x%02x)", ezb_app_signal_to_string(signal_type), signal_type); break; } + return true; } -static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message) { - esp_err_t ret = ESP_OK; - ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message"); - ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG, - "Received message: error status(%d)", message->info.status); - ESP_LOGD(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)", - message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size); - return ret; +static void zb_attribute_handler(ezb_zcl_set_attr_value_message_t *message) { + ESP_RETURN_ON_FALSE(message, , TAG, "Empty message"); + ESP_RETURN_ON_FALSE(message->info.status == EZB_ZCL_STATUS_SUCCESS, , TAG, "Received message: error status(%d)", + message->info.status); + ESP_LOGD(TAG, "ZCL SetAttributeValue message for endpoint(%d) cluster(0x%04x) %s with status(0x%02x)", + message->info.dst_ep, message->info.cluster_id, + message->info.cluster_role == EZB_ZCL_CLUSTER_SERVER ? "server" : "client", message->info.status); } -static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) { - esp_err_t ret = ESP_OK; +static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, void *message) { switch (callback_id) { - case ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID: - ret = zb_attribute_handler((esp_zb_zcl_set_attr_value_message_t *) message); + case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: + zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message); break; +#ifdef ESPHOME_LOG_HAS_VERBOSE + case EZB_ZCL_CORE_DEFAULT_RSP_CB_ID: { + ezb_zcl_cmd_default_rsp_message_t *default_rsp = (ezb_zcl_cmd_default_rsp_message_t *) message; + ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code); + } break; +#endif default: - ESP_LOGD(TAG, "Receive Zigbee action(0x%x) callback", callback_id); + ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast(callback_id)); break; } - return ret; } -void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id) { - esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create(); - this->endpoint_list_[endpoint_id] = - std::tuple(device_id, cluster_list); - // Add basic cluster - this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_BASIC, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); - // Add identify cluster if not already present - if (esp_zb_cluster_list_get_cluster(cluster_list, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE) == - nullptr) { - this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); +void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, uint16_t device_id) { + ezb_af_ep_config_t config = { + .ep_id = endpoint_id, + .app_profile_id = EZB_AF_HA_PROFILE_ID, + .app_device_id = device_id, + .app_device_version = 0, + }; + ezb_af_ep_desc_t ep_desc = ezb_af_create_endpoint_desc(&config); + if (ezb_af_device_add_endpoint_desc(this->dev_desc_, ep_desc) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not create endpoint %u", endpoint_id); } + // Add basic cluster + this->update_basic_cluster_(ep_desc); + // Add identify cluster if not already present + this->add_cluster(endpoint_id, EZB_ZCL_CLUSTER_ID_IDENTIFY, EZB_ZCL_CLUSTER_SERVER); } void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role) { - esp_zb_attribute_list_t *attr_list; - if (cluster_id == 0) { - attr_list = create_basic_cluster_(); - } else { - attr_list = esphome_zb_default_attr_list_create(cluster_id); + if (cluster_id == EZB_ZCL_CLUSTER_ID_BASIC) { + return; } - this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list; + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + ESP_LOGE(TAG, "Endpoint %u does not exist, cannot add cluster 0x%04X", endpoint_id, cluster_id); + return; + } + esphome_zb_add_or_update_cluster(cluster_id, ep_desc, role); + ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", endpoint_id, cluster_id, role); } void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source) { @@ -166,131 +188,117 @@ void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufactu }; } -esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() { - esp_zb_basic_cluster_cfg_t basic_cluster_cfg = { - .zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, - .power_source = this->basic_cluster_data_.power_source, - }; - esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, - this->basic_cluster_data_.manufacturer); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); - return attr_list; +void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { + ezb_zcl_cluster_desc_t cluster_desc = + ezb_af_endpoint_get_cluster_desc(ep_desc, EZB_ZCL_CLUSTER_ID_BASIC, EZB_ZCL_CLUSTER_SERVER); + if (cluster_desc == NULL) { + ezb_zcl_basic_cluster_config_t basic_cluster_cfg = { + .zcl_version = EZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, + .power_source = this->basic_cluster_data_.power_source, + }; + cluster_desc = ezb_zcl_basic_create_cluster_desc(&basic_cluster_cfg, EZB_ZCL_CLUSTER_SERVER); + } + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, + this->basic_cluster_data_.manufacturer); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, + this->basic_cluster_data_.model); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } -esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, - esp_zb_cluster_list_t *esp_zb_cluster_list) { - esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id, - .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, - .app_device_id = static_cast(device_id), - .app_device_version = 0}; - return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config); +void ZigbeeComponent::setup_reporting() { + ESP_LOGD(TAG, "Setting up reporting for all attributes"); + esp_zigbee_lock_acquire(portMAX_DELAY); + for (auto &[_, attribute] : this->attributes_) { + attribute->setup_reporting(); + } + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); + esp_zigbee_lock_release(); } -static void esp_zb_task(void *pv_parameters) { - if (esp_zb_start(false) != ESP_OK) { +static void ezb_task(void *pv_parameters) { + if (esp_zigbee_start(false) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); vTaskDelete(NULL); } - if (global_zigbee->is_battery_powered()) { - ESP_LOGD(TAG, "Battery powered!"); - esp_zb_set_node_descriptor_power_source(false); - } else { - esp_zb_set_node_descriptor_power_source(true); + esp_zigbee_launch_mainloop(); + + esp_zigbee_deinit(); + + vTaskDelete(NULL); +} + +ZigbeeComponent::ZigbeeComponent() { + esp_zigbee_platform_config_t platform_config = { + .storage_partition_name = "nvs", + .radio_config = EZB_DEFAULT_RADIO_CONFIG(), + }; + esp_zigbee_device_config_t device_config = { + .device_type = this->device_role_, + .install_code_policy = false, + }; +#ifdef CONFIG_ZB_ZCZR + esp_zigbee_zczr_config_s zb_zczr_cfg = { + .max_children = MAX_CHILDREN, + }; + device_config.zczr_config = zb_zczr_cfg; +#else + esp_zigbee_zed_config_s zb_zed_cfg = { + .ed_timeout = EZB_NWK_ED_TIMEOUT_64MIN, + .keep_alive = ED_KEEP_ALIVE, + }; + device_config.zed_config = zb_zed_cfg; +#endif + esp_zigbee_config_t config = {.device_config = device_config, .platform_config = platform_config}; + if (esp_zigbee_init(&config) != ESP_OK) { + ESP_LOGE(TAG, "Could not initialize Zigbee"); + this->mark_failed(); + return; } - esp_zb_stack_main_loop(); + this->dev_desc_ = ezb_af_create_device_desc(); } void ZigbeeComponent::setup() { global_zigbee = this; - esp_zb_platform_config_t config = {}; - config.radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(); - config.host_config = ESP_ZB_DEFAULT_HOST_CONFIG(); #ifdef USE_WIFI if (esp_coex_wifi_i154_enable() != ESP_OK) { this->mark_failed(); return; } #endif - if (esp_zb_platform_config(&config) != ESP_OK) { + ezb_aps_secur_enable_distributed_security(false); + ezb_nwk_set_min_join_lqi(32); + if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { + ESP_LOGE(TAG, "Could not set application signal handler"); this->mark_failed(); return; } - esp_zb_cfg_t zb_nwk_cfg = { - .esp_zb_role = this->device_role_, - .install_code_policy = false, - }; -#ifdef ZB_ROUTER_ROLE - esp_zb_zczr_cfg_t zb_zczr_cfg = { - .max_children = MAX_CHILDREN, - }; - zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg; -#else - esp_zb_zed_cfg_t zb_zed_cfg = { - .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, - }; - zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg; -#endif - esp_zb_init(&zb_nwk_cfg); - - esp_err_t ret; - for (auto const &[key, val] : this->attribute_list_) { - esp_zb_cluster_list_t *esp_zb_cluster_list = std::get<1>(this->endpoint_list_[std::get<0>(key)]); - ret = esphome_zb_cluster_list_add_or_update_cluster(std::get<1>(key), esp_zb_cluster_list, val, std::get<2>(key)); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "Could not create cluster 0x%04X with role %u: %s", std::get<1>(key), std::get<2>(key), - esp_err_to_name(ret)); - } else { - ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", std::get<0>(key), std::get<1>(key), - std::get<2>(key)); -#ifdef ESPHOME_LOG_HAS_VERBOSE - // Dump cluster attributes in verbose log - ESP_LOGV(TAG, "Cluster 0x%04X attributes:", std::get<1>(key)); - esp_zb_attribute_list_t *attr_list = val; - while (attr_list) { - esp_zb_zcl_attr_t *attr = &attr_list->attribute; - ESP_LOGV(TAG, " Attr ID: 0x%04X, Type: 0x%02X, Access: 0x%02X", attr->id, attr->type, attr->access); - attr_list = attr_list->next; - } -#endif - } - } - this->attribute_list_.clear(); - - for (auto const &[ep_id, dev_id] : this->endpoint_list_) { - if (create_endpoint(ep_id, std::get<0>(dev_id), std::get<1>(dev_id)) != ESP_OK) { - ESP_LOGE(TAG, "Could not create endpoint %u", ep_id); - } - } - this->endpoint_list_.clear(); - - if (esp_zb_device_register(this->esp_zb_ep_list_) != ESP_OK) { + if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { ESP_LOGE(TAG, "Could not register the endpoint list"); this->mark_failed(); return; } - esp_zb_core_action_handler_register(zb_action_handler); + ezb_zcl_core_action_handler_register(zb_action_handler); - if (esp_zb_set_primary_network_channel_set(ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK) != ESP_OK) { + if (ezb_bdb_set_primary_channel_set(EZB_PRIMARY_CHANNEL_MASK) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); this->mark_failed(); return; } - for (auto &[_, attribute] : this->attributes_) { - if (attribute->report_enabled) { - esp_zb_zcl_reporting_info_t reporting_info = attribute->get_reporting_info(); - ESP_LOGD(TAG, "set reporting for cluster: %u", reporting_info.cluster_id); - if (esp_zb_zcl_update_reporting_info(&reporting_info) != ESP_OK) { - ESP_LOGE(TAG, "Could not configure reporting for attribute 0x%04X in cluster 0x%04X in endpoint %u", - reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep); - } - } - } - xTaskCreate(esp_zb_task, "Zigbee_main", 4096, NULL, 24, NULL); + + uint8_t power_source = static_cast(this->is_battery_powered() ? EZB_AF_NODE_POWER_SOURCE_RECHARGEABLE_BATTERY + : EZB_AF_NODE_POWER_SOURCE_CONSTANT_POWER); + ezb_af_node_power_desc_t desc = { + .current_power_mode = EZB_AF_NODE_POWER_MODE_SYNC_ON_WHEN_IDLE, + .available_power_sources = power_source, + .current_power_source = power_source, + .current_power_source_level = EZB_AF_NODE_POWER_SOURCE_LEVEL_100_PERCENT, + }; + ezb_af_set_node_power_desc(&desc); + + xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 24, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } @@ -303,25 +311,28 @@ void ZigbeeComponent::loop() { } void ZigbeeComponent::dump_config() { - if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { ESP_LOGCONFIG(TAG, "Zigbee\n" - " Model: %s\n" + " Model: %.*s\n" " Router: %s\n" " Device is joined to the network: %s\n" " Current channel: %d\n" " Short addr: 0x%04X\n" " Short pan id: 0x%04X", - this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER), - YESNO(esp_zb_bdb_dev_joined()), esp_zb_get_current_channel(), esp_zb_get_short_address(), - esp_zb_get_pan_id()); - esp_zb_lock_release(); + this->basic_cluster_data_.model[0], + reinterpret_cast(this->basic_cluster_data_.model + 1), + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), YESNO(ezb_bdb_dev_joined()), + ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); + esp_zigbee_lock_release(); } else { ESP_LOGCONFIG(TAG, "Zigbee\n" - " Model: %s\n" + " Model: %.*s\n" " Router: %s\n", - this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER)); + this->basic_cluster_data_.model[0], + reinterpret_cast(this->basic_cluster_data_.model + 1), + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); } } } // namespace esphome::zigbee diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 25f53a1d6e..11289843a8 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -8,9 +8,8 @@ #include #include -#include "esp_zigbee_core.h" -#include "zboss_api.h" -#include "ha/esp_zigbee_ha_standard.h" +#include "esp_zigbee.h" +#include "ezbee/zha.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "zigbee_helpers_esp32.h" @@ -24,12 +23,10 @@ namespace esphome::zigbee { /* Zigbee configuration */ static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ static const uint8_t MAX_CHILDREN = 10; +static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 */ -#define ESP_ZB_DEFAULT_RADIO_CONFIG() \ - { .radio_mode = ZB_RADIO_MODE_NATIVE, } - -#define ESP_ZB_DEFAULT_HOST_CONFIG() \ - { .host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, } +#define EZB_DEFAULT_RADIO_CONFIG() \ + { .radio_mode = ESP_ZIGBEE_RADIO_MODE_NATIVE, } uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); @@ -37,14 +34,15 @@ class ZigbeeAttribute; class ZigbeeComponent final : public Component { public: + ZigbeeComponent(); void setup() override; void loop() override; void dump_config() override; - esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, - esp_zb_cluster_list_t *esp_zb_cluster_list); + void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); - void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id); + void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); + void setup_reporting(); template void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, @@ -53,15 +51,18 @@ class ZigbeeComponent final : public Component { template void add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value); + static bool app_signal_handler(const ezb_app_signal_t *app_signal); + static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); + void factory_reset() { - esp_zb_lock_acquire(portMAX_DELAY); - esp_zb_factory_reset(); // triggers a reboot - esp_zb_lock_release(); + esp_zigbee_lock_acquire(portMAX_DELAY); + esp_zigbee_factory_reset(); // triggers a reboot + esp_zigbee_lock_release(); } template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } - bool is_battery_powered() { return this->basic_cluster_data_.power_source == ESP_ZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } + bool is_battery_powered() { return this->basic_cluster_data_.power_source == EZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } bool is_started() { return this->started; } bool is_connected() { return this->connected_; } std::atomic started = false; @@ -76,25 +77,20 @@ class ZigbeeComponent final : public Component { uint8_t power_source; } basic_cluster_data_; bool connected_ = false; -#ifdef ZB_ED_ROLE - esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED; +#ifdef CONFIG_ZB_ZED + ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; #else - esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ROUTER; + ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_ROUTER; #endif - esp_zb_attribute_list_t *create_basic_cluster_(); + void update_basic_cluster_(ezb_af_ep_desc_t ep_desc); template void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p); - // endpoint_list_ and attribute_list_ are only used during setup and are cleared afterwards - // value tuple could be replaced by struct - std::map> endpoint_list_; - // key tuple could be replaced by single 32 bit int with bit fields for endpoint, cluster and role - std::map, esp_zb_attribute_list_t *> attribute_list_; // attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create(); + ezb_af_device_desc_t dev_desc_; CallbackManager join_cb_{}; }; @@ -125,8 +121,15 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 template void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p) { - esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}]; - esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + return; + } + ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); + if (cluster_desc == NULL) { + return; + } + esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 086cdcc267..f19bc97be7 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -9,7 +9,6 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, add_partition, - require_libc_picolibc_newlib_compat, require_vfs_select, ) import esphome.config_validation as cv @@ -41,7 +40,6 @@ from .const import ( CONF_ROUTER, KEY_ZIGBEE, POWER_SOURCE, - REPORT, ZigbeeAttribute, ) from .const_esp32 import ( @@ -76,7 +74,7 @@ def get_c_type(attr_type: str) -> Any | None: return cg.double if "STRING" in attr_type: return cg.std_string - test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) if test and test.group(2): return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64])) return None @@ -89,14 +87,14 @@ def get_cv_by_type(attr_type: str) -> Any | None: return cv.float_ if "STRING" in attr_type: return cv.string - test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) if test and test.group(2): return cv.positive_int raise cv.Invalid(f"Zigbee: type {attr_type} not supported or implemented") def get_default_by_type(attr_type: str) -> str | bool | int | float: - if attr_type == "CHAR_STRING": + if attr_type == "STRING": return "" if attr_type == "BOOL": return False @@ -134,7 +132,6 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: ) as f: partitions_tab = f.read() for partition, types in [ - ("zb_storage", {"type": "data", "subtype": "fat", "size": 0x4000}), ("zb_fct", {"type": "data", "subtype": "fat", "size": 0x1000}), ]: if partition not in partitions_tab: @@ -191,14 +188,14 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: { CONF_ATTRIBUTE_ID: 0x100, CONF_VALUE: (apptype << 16) | 0xFFFF, - CONF_TYPE: "U32", + CONF_TYPE: "UINT32", }, ) ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( { CONF_ATTRIBUTE_ID: 0x75, CONF_VALUE: bacunit, - CONF_TYPE: "16BIT_ENUM", + CONF_TYPE: "ENUM16", }, ) setup_attributes(config, ep[CONF_CLUSTERS]) @@ -233,15 +230,8 @@ async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_ZB_ZCZR", True) else: add_idf_sdkconfig_option("CONFIG_ZB_ZED", True) - add_idf_sdkconfig_option("CONFIG_ZB_RADIO_NATIVE", True) if CONF_WIFI in CORE.config: add_idf_sdkconfig_option("CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE", 4096) - # The pre-built Zigbee library uses esp_log_default_level which requires - # dynamic log level control to be enabled - add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) - # The pre-built Zigbee library is compiled against newlib which requires newlib - # reentrancy to be enabled with picolibc compatibility (IDF 6.0+ only). - require_libc_picolibc_newlib_compat() async def attributes_to_code( @@ -274,11 +264,8 @@ async def attributes_to_code( await cg.register_component(attr_var, attr) cg.add(attr_var.add_attr(attr[CONF_VALUE])) - if CONF_REPORT in attr and attr[CONF_REPORT] in [ - REPORT["enable"], - REPORT["force"], - ]: - cg.add(attr_var.set_report(attr[CONF_REPORT] == REPORT["force"])) + if CONF_REPORT in attr: + cg.add(attr_var.set_report(attr[CONF_REPORT])) if CONF_DEVICE in attr: device = await cg.get_variable(attr[CONF_DEVICE]) @@ -287,20 +274,15 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": - add_idf_component( - name="espressif/esp-zboss-lib", - ref="1.6.4", - ) add_idf_component( name="espressif/esp-zigbee-lib", - ref="1.6.8", + ref="2.0.2", ) # add sdkconfigs later so they can overwrite esp32 defaults CORE.add_job(_zigbee_add_sdkconfigs, config) # add partitions for zigbee - add_partition("zb_storage", "data", "fat", 0x4000) # 16KB add_partition("zb_fct", "data", "fat", 0x1000) # 4KB, minimum size # create endpoints @@ -316,7 +298,7 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": var.set_basic_cluster( config[CONF_MODEL], "esphome", - cg.RawExpression(POWER_SOURCE[config[CONF_POWER_SOURCE]]), + POWER_SOURCE[config[CONF_POWER_SOURCE]], ) ) for ep in ep_list: diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 5254818df4..150be612f6 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -2,78 +2,59 @@ #ifdef USE_ESP32 #ifdef USE_ZIGBEE -#include "ha/esp_zigbee_ha_standard.h" #include "zigbee_helpers_esp32.h" +#include "ezbee/zha.h" -esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, +ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p) { - esp_err_t ret; - ret = esp_zb_cluster_update_attr(attr_list, attr_id, value_p); - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Ignore previous attribute not found error"); - ret = esphome_zb_cluster_add_attr(cluster_id, attr_list, attr_id, value_p); + ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); + if (attr_desc != NULL) { + return ezb_zcl_attr_desc_set_value(attr_desc, value_p); } - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Could not add attribute 0x%04X to cluster 0x%04X: %s", attr_id, cluster_id, - esp_err_to_name(ret)); - } - return ret; + return esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); } -esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, - esp_zb_attribute_list_t *attr_list, uint8_t role_mask) { - esp_err_t ret; - ret = esp_zb_cluster_list_update_cluster(cluster_list, attr_list, cluster_id, role_mask); - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Ignore previous cluster not found error"); - switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - ret = esp_zb_cluster_list_add_basic_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - ret = esp_zb_cluster_list_add_analog_input_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask); - break; - default: - ret = esp_zb_cluster_list_add_custom_cluster(cluster_list, attr_list, role_mask); +ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask) { + if (ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role_mask) != NULL) { + // Cluster already exists, nothing to do + return EZB_ERR_NONE; + } + ezb_zcl_cluster_desc_t cluster_desc; + cluster_desc = esphome_zb_default_cluster_dscr_create(cluster_id, role_mask); + return ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); +} + +ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask) { + switch (cluster_id) { + case EZB_ZCL_CLUSTER_ID_BASIC: + return ezb_zcl_basic_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_IDENTIFY: + return ezb_zcl_identify_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return ezb_zcl_binary_input_create_cluster_desc(NULL, role_mask); + default: { + ezb_zcl_custom_cluster_config_t config = {0}; + config.cluster_id = cluster_id; + return ezb_zcl_custom_create_cluster_desc(&config, role_mask); } } - return ret; } -esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id) { - switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - return esp_zb_basic_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - return esp_zb_identify_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - return esp_zb_analog_input_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - return esp_zb_binary_input_cluster_create(NULL); - default: - return esp_zb_zcl_attr_list_create(cluster_id); - } -} - -esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, +ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p) { switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - return esp_zb_analog_input_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_BASIC: + return ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_IDENTIFY: + return ezb_zcl_identify_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return ezb_zcl_binary_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); default: - return ESP_FAIL; + return EZB_ERR_NOT_FOUND; } } diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.h b/esphome/components/zigbee/zigbee_helpers_esp32.h index 0650c1689f..6898068b44 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.h +++ b/esphome/components/zigbee/zigbee_helpers_esp32.h @@ -8,15 +8,14 @@ extern "C" { #endif -#include "esp_zigbee_core.h" +#include "esp_zigbee.h" -esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, - esp_zb_attribute_list_t *attr_list, uint8_t role_mask); -esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id); -esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, - void *value_p); -esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, +ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p); +ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask); +ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask); +ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, + void *value_p); #ifdef __cplusplus } diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 39ecadfddf..1647fb28ae 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -168,7 +168,7 @@ async def _attr_to_code(config: ConfigType) -> None: ), zigbee_assign( basic_attrs.power_source, - cg.RawExpression(POWER_SOURCE[config[CONF_POWER_SOURCE]]), + POWER_SOURCE[config[CONF_POWER_SOURCE]], ), zigbee_set_string(basic_attrs.location_id, ""), zigbee_assign( diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4f36e4dbe6..7ad41fa978 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -47,12 +47,8 @@ dependencies: version: "2.0.0" rules: - if: "target in [esp32, esp32p4]" - espressif/esp-zboss-lib: - version: 1.6.4 - rules: - - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/esp-zigbee-lib: - version: 1.6.8 + version: 2.0.2 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 index 6dd5f4f329..63dbeffd77 100644 --- a/sdkconfig.defaults.esp32c6 +++ b/sdkconfig.defaults.esp32c6 @@ -11,4 +11,3 @@ CONFIG_OPENTHREAD_RADIO_NATIVE=y # zigbee CONFIG_ZB_ENABLED=y CONFIG_ZB_ZED=y -CONFIG_ZB_RADIO_NATIVE=y diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 82a523fc7c..787afc4476 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -4,7 +4,7 @@ packages: binary_sensor: - platform: template name: "Garage Door Open 10" - report: "enable" + report: "default" - platform: template name: "Garage Door Open 12" report: "force" From a035d844749a6c9d4f128fd0c923b0a3522e07a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:55:47 -0400 Subject: [PATCH 1965/2214] Bump docker/login-action from 4.3.0 to 4.4.0 in the docker-actions group (#17380) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 07a792df08..2740ca76ca 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -96,7 +96,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -154,7 +154,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d00c6523c7..b63067ab4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} From 5738c60206b2792634ac4dfe05712d675235d0ec Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:09:01 -0400 Subject: [PATCH 1966/2214] [nrf52] Run clang-tidy against the native sdk-nrf toolchain (#17364) --- .github/actions/cache-sdk-nrf/action.yml | 49 ++++ .github/workflows/ci.yml | 19 +- .../components/http_request/http_request.h | 2 +- esphome/components/logger/logger_zephyr.cpp | 2 +- esphome/components/nrf52/__init__.py | 11 +- esphome/components/nrf52/clang_tidy.py | 249 ++++++++++++++++++ esphome/components/nrf52/framework.py | 24 +- esphome/core/defines.h | 2 +- script/clang-tidy | 19 +- script/clang_tidy_hash.py | 2 + script/helpers_zephyr.py | 149 ++++------- tests/unit_tests/test_nrf52_framework.py | 26 +- 12 files changed, 432 insertions(+), 122 deletions(-) create mode 100644 .github/actions/cache-sdk-nrf/action.yml create mode 100644 esphome/components/nrf52/clang_tidy.py diff --git a/.github/actions/cache-sdk-nrf/action.yml b/.github/actions/cache-sdk-nrf/action.yml new file mode 100644 index 0000000000..71c09bfe14 --- /dev/null +++ b/.github/actions/cache-sdk-nrf/action.yml @@ -0,0 +1,49 @@ +name: Cache sdk-nrf +description: > + Resolve the pinned sdk-nrf version and cache the native sdk-nrf install + (west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf. + Every job that installs sdk-nrf natively (the nrf52 clang-tidy job and, + once the component tests build natively, their batches) shares one cache. + Callers must set env ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf and have + the Python venv already restored. +inputs: + restore-only: + description: > + When "true", only restore -- never save the cache, even on dev. Use from + jobs that may not produce a complete install (e.g. a component batch + that fails mid-install), so a partial install is never written. + default: "false" +runs: + using: composite + steps: + - name: Resolve sdk-nrf and toolchain versions for cache key + # Both versions are pinned in code, not in any file that feeds the + # other cache keys, so resolve them explicitly. Keying on them means + # the cache invalidates when either is bumped (actions/cache never + # overwrites a key). + id: version + shell: bash + run: | + . venv/bin/activate + version=$(python -c ' + from esphome.components.nrf52 import RECOMMENDED_SDK_NRF_VERSION + from esphome.components.nrf52.framework import TOOLCHAIN_VERSION + print(f"{RECOMMENDED_SDK_NRF_VERSION}-{TOOLCHAIN_VERSION}")') + echo "version=$version" >> "$GITHUB_OUTPUT" + # Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it + # lives in the default-branch scope readable by all PRs); PRs are + # restore-only and never push multi-GB artifacts into their own scope. + - name: Cache sdk-nrf install (write on dev) + if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.esphome-sdk-nrf + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} + - name: Cache sdk-nrf install (restore-only off dev) + if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.esphome-sdk-nrf + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9310b45b4a..caf6453c1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -475,6 +475,8 @@ jobs: GH_TOKEN: ${{ github.token }} # esp32-arduino-tidy installs ESP-IDF natively; share the native IDF cache. ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + # nrf52-tidy installs sdk-nrf natively; pin it to a cacheable path. + ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false max-parallel: 2 @@ -491,7 +493,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ZEPHYR options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 - pio_cache_key: tidy-zephyr + cache_sdk_nrf: true ignore_errors: false steps: @@ -527,6 +529,10 @@ jobs: with: framework: arduino + - name: Cache sdk-nrf install + if: matrix.cache_sdk_nrf + uses: ./.github/actions/cache-sdk-nrf + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -805,6 +811,9 @@ jobs: # esp32 component builds use the native ESP-IDF toolchain (default), so # share the tidy jobs' install location -- the restore below lands here. ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + # nrf52 component builds install sdk-nrf natively; pin it to the shared + # cacheable path so the restore below lands where the build looks. + ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} @@ -840,6 +849,14 @@ jobs: uses: ./.github/actions/cache-esp-idf with: restore-only: true + - name: Cache sdk-nrf install (restore-only) + # Only batches whose test platforms include nrf52 need the native + # sdk-nrf install; never save -- just reuse the shared install the + # dev nrf52 tidy job cached when present. + if: matrix.batch.needs_nrf + uses: ./.github/actions/cache-sdk-nrf + with: + restore-only: true - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 5025a5c12d..df1bb462ab 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -510,9 +510,9 @@ template class HttpRequestSendAction final : public Actionmax_response_buffer_size_; #ifdef USE_HTTP_REQUEST_RESPONSE if (this->capture_response_.value(x...)) { + size_t max_length = this->max_response_buffer_size_; std::string response_body; RAMAllocator allocator; uint8_t *buf = allocator.allocate(max_length); diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 240bcc57c7..b7884b702b 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -57,7 +57,7 @@ void Logger::pre_setup() { if (this->baud_rate_ > 0) { static const struct device *uart_dev = nullptr; switch (this->uart_) { - case UART_SELECTION_UART0: + case UART_SELECTION_UART0: // NOLINT(bugprone-branch-clone) uart_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(uart0)); break; case UART_SELECTION_UART1: diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 7c17eadd1a..a5f2018d55 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -79,6 +79,11 @@ AUTO_LOAD = ["zephyr", "preferences"] IS_TARGET_PLATFORM = True _LOGGER = logging.getLogger(__name__) +# Default framework versions per toolchain. The sdk-nrf one also keys the CI +# sdk-nrf install cache and pins the clang-tidy project's SDK. +RECOMMENDED_PLATFORMIO_VERSION = "2.6.1-b" +RECOMMENDED_SDK_NRF_VERSION = "2.9.2" + FAKE_BOARD_MANIFEST = """ { "frameworks": [ @@ -123,7 +128,11 @@ def _resolve_toolchain(config: ConfigType) -> ConfigType: def set_framework(config: ConfigType) -> ConfigType: if CONF_VERSION not in config[CONF_FRAMEWORK]: - default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" + default_version = ( + RECOMMENDED_PLATFORMIO_VERSION + if CORE.using_toolchain_platformio + else RECOMMENDED_SDK_NRF_VERSION + ) config = { **config, CONF_FRAMEWORK: {**config[CONF_FRAMEWORK], CONF_VERSION: default_version}, diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py new file mode 100644 index 0000000000..2dd4b7bd09 --- /dev/null +++ b/esphome/components/nrf52/clang_tidy.py @@ -0,0 +1,249 @@ +"""Generate clang-tidy compile commands via the native sdk-nrf toolchain. + +Produces a ``compile_commands.json`` for the nrf52/Zephyr clang-tidy +environment **without an ESPHome YAML config**, mirroring +``esphome.espidf.clang_tidy``: generate a minimal Zephyr application, run a +configure-only west build with the native sdk-nrf toolchain, and let +``script/helpers_zephyr.py`` extract idedata from the resulting compile +commands. + +* the stub app is C++ so the compile commands carry C++ flags, matching how + clang-tidy analyzes ESPHome's sources; +* ``prj.conf`` enables the Kconfig superset ESPHome components need (BT, ADC, + mcumgr, zigbee) so their include paths land in the compile commands; +* the platform defines (USE_ZEPHYR, USE_NRF52) match what a real ESPHome + nrf52 build adds via its generated project. + +``ESPHOME_ZEPHYR_COMPILE_COMMANDS`` may point at an existing build's +``compile_commands.json`` to skip generation (fast iteration). +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +TIDY_PROJECT_NAME = "esphome_tidy" + +# Analyzed against the native toolchain's default SDK version +# (RECOMMENDED_SDK_NRF_VERSION), which also keys the CI install cache. +_TIDY_BOARD = "adafruit_itsybitsy_nrf52840" + +# Never compiled (the build is configure-only): the file exists only so the +# app target emits a C++ compile command to harvest flags/includes from. +_TIDY_MAIN_CPP = "int main() { return 0; }\n" + +# Kconfig superset enabling every subsystem an ESPHome nrf52 component may +# use, so the compile commands carry all of their include paths. +_TIDY_PRJ_CONF = """\ +CONFIG_CPP=y +CONFIG_STD_CPP20=y +CONFIG_REQUIRES_FULL_LIBCPP=y +CONFIG_NEWLIB_LIBC=y +CONFIG_BT=y +CONFIG_ADC=y +# posix (time sets POSIX_CLOCK, socket sets POSIX_API); without it the +# Zephyr POSIX headers clash with the libc ones under analysis +CONFIG_POSIX_API=y +#mcumgr begin +CONFIG_NET_BUF=y +CONFIG_ZCBOR=y +CONFIG_MCUMGR=y +CONFIG_MCUMGR_GRP_IMG=y +CONFIG_IMG_MANAGER=y +CONFIG_STREAM_FLASH=y +CONFIG_FLASH_MAP=y +CONFIG_FLASH=y +CONFIG_IMG_ERASE_PROGRESSIVELY=y +CONFIG_BOOTLOADER_MCUBOOT=y +CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y +CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y +CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y +CONFIG_MCUMGR_TRANSPORT_UART=y +#mcumgr end +#zigbee begin +CONFIG_ZIGBEE=y +CONFIG_CRYPTO=y +CONFIG_NVS=y +CONFIG_SETTINGS=y +#zigbee end +""" + + +def _tidy_cmakelists(library_include_dirs: str) -> str: + # The defines a real ESPHome nrf52 build puts on the app target. + # ESPHOME_LOG_LEVEL must be set up front -- otherwise log.h's ``#ifndef`` + # sets it to NONE, a macro-redefined warning across nearly every source. + return f"""\ +# Auto-generated by ESPHome (clang-tidy compile-commands project) +cmake_minimum_required(VERSION 3.20.0) +set(Zephyr_DIR "$ENV{{ZEPHYR_BASE}}/share/zephyr-package/cmake/") +find_package(Zephyr REQUIRED) +project({TIDY_PROJECT_NAME}) +target_sources(app PRIVATE main.cpp) +target_compile_definitions(app PRIVATE + USE_ZEPHYR + USE_NRF52 + ESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE +) +target_include_directories(app PRIVATE +{library_include_dirs} +) +""" + + +def _parse_lib_deps(platformio_ini: Path) -> list: + """Parse the nrf52 env's ``lib_deps`` from platformio.ini into Library specs. + + These are the PlatformIO libraries ESPHome components pull in via + ``cg.add_library`` (ArduinoJson, dlms_parser, ...); their headers must be + on the tidy translation unit's include path. Mirrors the pio nrf52 env's + ``lib_deps`` composition (``common.lib_deps_base`` + + ``common:idf-component-libs``). + """ + import configparser + + from esphome.core import Library + + parser = configparser.ConfigParser(interpolation=None, strict=False) + parser.read(platformio_ini) + + tokens: list[str] = [] + for section, key in ( + ("common", "lib_deps_base"), + ("common:idf-component-libs", "lib_deps"), + ): + if parser.has_option(section, key): + tokens += parser.get(section, key).splitlines() + + libs: list[Library] = [] + for token in tokens: + token = token.split(";", 1)[0].strip() # drop trailing ; comment + if not token or token.startswith(("${", "+<")): + continue + if "://" in token or ".git" in token: + libs.append(Library(token, None, token)) # git repository (with #ref) + elif "@" in token: + name, _, version = token.partition("@") + libs.append(Library(name, version)) + return libs + + +def _library_include_dirs(platformio_ini: Path) -> list[str]: + """Resolve the pio libraries and return their include roots.""" + from esphome.platformio.library import LibraryBackend, convert_libraries + + dirs: list[str] = [] + + def emit(component) -> None: + build = component.data.get("build", {}) + candidates = {build.get("includeDir", "include"), build.get("srcDir", "src")} + candidates.update({"src", "."}) + for candidate in sorted(candidates): + path = (component.path / candidate).resolve() + if path.is_dir(): + dirs.append(str(path)) + + backend = LibraryBackend( + platform="nordicnrf52", framework="zephyr", emit=emit, cache_key="zephyr" + ) + convert_libraries(_parse_lib_deps(platformio_ini), backend) + return sorted(set(dirs)) + + +def _setup_core(work_dir: Path) -> None: + """Point CORE at the tidy project + SDK version, without any YAML config.""" + from esphome.components.zephyr.const import KEY_ZEPHYR + import esphome.config_validation as cv + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_NRF52, + Toolchain, + ) + from esphome.core import CORE + + from . import RECOMMENDED_SDK_NRF_VERSION + + CORE.name = TIDY_PROJECT_NAME + # config_path's parent is the data-dir root for per-run artifacts. The + # sdk-nrf install is in the global cache dir, independent of this path. + CORE.config_path = work_dir.parent / "tidy.yaml" + CORE.build_path = work_dir + CORE.toolchain = Toolchain.SDK_NRF + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_NRF52 + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + RECOMMENDED_SDK_NRF_VERSION + ) + + +def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: + """Generate the tidy Zephyr project and run a configure-only west build. + + Returns the path to the generated ``compile_commands.json``. + """ + from esphome.core import EsphomeError + from esphome.framework_helpers import run_command_ok + from esphome.helpers import rmtree + + from .framework import check_and_install, get_build_env, get_build_paths + + # Surface ESPHome's INFO logs (sdk-nrf download/west update) -- they go + # through logging, which the clang-tidy script otherwise leaves at + # WARNING, so the first-run installation looks silent without this. + logging.basicConfig(level=logging.INFO, format="%(message)s") + + _setup_core(work_dir) + check_and_install() + + library_include_dirs = "\n".join( + f' "{d}"' for d in _library_include_dirs(platformio_ini) + ) + source_dir = work_dir / "zephyr" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "CMakeLists.txt").write_text( + _tidy_cmakelists(library_include_dirs), encoding="utf-8" + ) + (source_dir / "main.cpp").write_text(_TIDY_MAIN_CPP, encoding="utf-8") + (source_dir / "prj.conf").write_text(_TIDY_PRJ_CONF, encoding="utf-8") + + # Always configure from scratch: west can't pristine a dir whose CMake + # cache is stale/missing, and a configure-only run is cheap. + build_dir = work_dir / "build" + if build_dir.is_dir(): + rmtree(build_dir) + + paths = get_build_paths() + # Build only the generated-headers target (syscall_list.h, offsets.h, ...) + # on top of the configure: clang-tidy needs those headers to exist, but a + # full firmware build would be wasted work. --no-sysbuild keeps sdk-nrf + # 2.9+ from wrapping the build in a multi-image sysbuild project, which + # would nest the compile commands and hide the headers target. + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "build", + "--no-sysbuild", + "-b", + _TIDY_BOARD, + "-d", + str(build_dir), + str(source_dir), + "-t", + "zephyr_generated_headers", + "--", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + ] + if not run_command_ok( + west_cmd, + env=get_build_env(), + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 clang-tidy configure failed") + + return build_dir / "compile_commands.json" diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 640aa07fbf..fa6f7d57ad 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -1,3 +1,4 @@ +import hashlib import logging import os from pathlib import Path @@ -24,7 +25,7 @@ from esphome.helpers import get_str_env _LOGGER = logging.getLogger(__name__) _REQUIREMENTS = Path(__file__).parent / "requirements.txt" -_TOOLCHAIN_VERSION = "0.17.4" +TOOLCHAIN_VERSION = "0.17.4" SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( os.environ.get( @@ -132,7 +133,7 @@ def get_build_env() -> dict: env = os.environ.copy() env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") - env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(_TOOLCHAIN_VERSION) / "cmake") + env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION) / "cmake") return env @@ -158,9 +159,10 @@ def check_and_install() -> None: python_env_path = _get_python_env_path(version) env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() install_venv = ( not sentinel.exists() - or _REQUIREMENTS.stat().st_mtime > sentinel.stat().st_mtime + or sentinel.read_text(encoding="utf-8") != requirements_hash ) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") @@ -182,7 +184,7 @@ def check_and_install() -> None: raise EsphomeError( f"Install requirements for {version} Python environment failure" ) - sentinel.touch() + sentinel.write_text(requirements_hash, encoding="utf-8") framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" @@ -238,19 +240,17 @@ def check_and_install() -> None: raise EsphomeError(f"Install Zephyr requirements for {version} failure") zephyr_sentinel.touch() - toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) + toolchains_dir = _get_toolchain_path(TOOLCHAIN_VERSION) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): - rmdir( - toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" - ) + rmdir(toolchains_dir, msg=f"Clean up {TOOLCHAIN_VERSION} toolchain environment") sysname, machine, extension = _get_toolchain_platform_info() with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading Zephyr SDK %s minimal ...", _TOOLCHAIN_VERSION) + _LOGGER.info("Downloading Zephyr SDK %s minimal ...", TOOLCHAIN_VERSION) download_from_mirrors( SDK_NG_MINIMAL_MIRRORS, { - "VERSION": _TOOLCHAIN_VERSION, + "VERSION": TOOLCHAIN_VERSION, "sysname": sysname, "machine": machine, "extension": extension, @@ -259,11 +259,11 @@ def check_and_install() -> None: ) archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) + _LOGGER.info("Downloading %s toolchain ...", TOOLCHAIN_VERSION) download_from_mirrors( SDK_NG_TOOLCHAIN_MIRRORS, { - "VERSION": _TOOLCHAIN_VERSION, + "VERSION": TOOLCHAIN_VERSION, "sysname": sysname, "machine": machine, "extension": extension, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1c0138f9d1..ff4bccc693 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -148,6 +148,7 @@ #define USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR #define USE_NEXTION_WAVEFORM #define USE_NUMBER +#define USE_OTA_STATE_LISTENER #define USE_OUTPUT #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY @@ -211,7 +212,6 @@ #define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_PASSWORD -#define USE_OTA_STATE_LISTENER #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE #define USE_WIFI diff --git a/script/clang-tidy b/script/clang-tidy index 1416b9b332..7df46cb2d2 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -145,14 +145,16 @@ def clang_options(idedata): # defines cmd.extend(f"-D{define}" for define in idedata["defines"]) - # add toolchain include directories using -isystem to suppress their errors + # toolchain include directories, using -isystem to suppress their errors # idedata contains include directories for all toolchains of this platform, only use those from the one in use toolchain_dir = os.path.normpath(f"{idedata['cxx_path']}/../../") + toolchain_includes = [] for directory in idedata["includes"]["toolchain"]: if directory.startswith(toolchain_dir) and "picolibc" not in directory: - cmd.extend(["-isystem", directory]) + toolchain_includes.extend(["-isystem", directory]) - # add library include directories using -isystem to suppress their errors + # library include directories, using -isystem to suppress their errors + build_includes = [] for directory in list(idedata["includes"]["build"]): # skip our own directories, we add those later if ( @@ -166,7 +168,16 @@ def clang_options(idedata): ) or (directory.startswith(f"{root_path}") and "/.pio/" in directory) ): - cmd.extend(["-isystem", directory]) + build_includes.extend(["-isystem", directory]) + + if "zephyr" in triplet: + # Zephyr's POSIX layer shadows libc headers (sys/select.h, ...) with + # coherently-guarded versions; the real build searches the Zephyr + # include dirs before the toolchain's, and the shadowed headers clash + # (e.g. newlib's sigset_t vs Zephyr's) in the opposite order. + cmd.extend(build_includes + toolchain_includes) + else: + cmd.extend(toolchain_includes + build_includes) # add the esphome include directory using -I cmd.extend(["-I", root_path]) diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index 00bcaf45b0..57ca90711c 100644 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -21,6 +21,8 @@ CLANG_TIDY_GLOBAL_FILES = ( "platformio.ini", "requirements_dev.txt", "esphome/idf_component.yml", + "esphome/components/esp32/__init__.py", + "esphome/components/nrf52/__init__.py", ) # sdkconfig.defaults and per-target sdkconfig.defaults. files flip the diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index 66ef6ffc98..c26ad7f2cd 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -1,59 +1,32 @@ +"""Load clang-tidy idedata for the nrf52/Zephyr environment. + +The compile commands come from a configure-only build of a minimal Zephyr +project using the native sdk-nrf toolchain (see +``esphome.components.nrf52.clang_tidy``); this module extracts the include +paths, defines and compiler flags clang-tidy needs from them. +""" + import json +import os from pathlib import Path import re +import shlex import subprocess def load_idedata(environment, temp_folder, platformio_ini): - build_environment = environment.replace("-tidy", "") - build_dir = Path(temp_folder) / f"build-{build_environment}" - Path(build_dir).mkdir(exist_ok=True) - Path(build_dir / "platformio.ini").write_text( - Path(platformio_ini).read_text(encoding="utf-8"), encoding="utf-8" - ) - esphome_dir = Path(build_dir / "esphome") - esphome_dir.mkdir(exist_ok=True) - Path(esphome_dir / "main.cpp").write_text( - """ -#include -int main() { return 0;} -extern "C" void zboss_signal_handler() {}; -""", - encoding="utf-8", - ) - zephyr_dir = Path(build_dir / "zephyr") - zephyr_dir.mkdir(exist_ok=True) - Path(zephyr_dir / "prj.conf").write_text( - """ -CONFIG_NEWLIB_LIBC=y -CONFIG_BT=y -CONFIG_ADC=y -#mcumgr begin -CONFIG_NET_BUF=y -CONFIG_ZCBOR=y -CONFIG_MCUMGR=y -CONFIG_MCUMGR_GRP_IMG=y -CONFIG_IMG_MANAGER=y -CONFIG_STREAM_FLASH=y -CONFIG_FLASH_MAP=y -CONFIG_FLASH=y -CONFIG_IMG_ERASE_PROGRESSIVELY=y -CONFIG_BOOTLOADER_MCUBOOT=y -CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y -CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y -CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y -CONFIG_MCUMGR_TRANSPORT_UART=y -#mcumgr end -#zigbee begin -CONFIG_ZIGBEE=y -CONFIG_CRYPTO=y -CONFIG_NVS=y -CONFIG_SETTINGS=y -#zigbee end -""", - encoding="utf-8", - ) - subprocess.run(["pio", "run", "-e", build_environment, "-d", build_dir], check=True) + if explicit := os.environ.get("ESPHOME_ZEPHYR_COMPILE_COMMANDS"): + compile_commands_path = Path(explicit) + else: + from esphome.components.nrf52.clang_tidy import generate_compile_commands + + work_dir = (Path(temp_folder) / f"zephyr-{environment}").resolve() + compile_commands_path = generate_compile_commands( + work_dir, Path(platformio_ini) + ) + + if not compile_commands_path.is_file(): + raise RuntimeError(f"compile_commands.json not found: {compile_commands_path}") def extract_include_paths(command): include_paths = [] @@ -62,7 +35,7 @@ CONFIG_SETTINGS=y split_strings = re.split( r"\s*-\s*(?:I|isystem)", list(filter(lambda x: x, match))[0] ) - include_paths.append(split_strings[1]) + include_paths.append(split_strings[1].strip()) return include_paths def extract_defines(command): @@ -74,15 +47,6 @@ CONFIG_SETTINGS=y if not any(match.startswith(prefix) for prefix in ignore_prefixes) ] - def find_cxx_path(commands): - for entry in commands: - command = entry["command"] - cxx_path = command.split()[0] - if not cxx_path.endswith("++"): - continue - return cxx_path - return None - def get_builtin_include_paths(compiler): result = subprocess.run( [compiler, "-E", "-x", "c++", "-", "-v"], @@ -105,47 +69,48 @@ CONFIG_SETTINGS=y return include_paths def extract_cxx_flags(command): - # Extracts CXXFLAGS from the command string, excluding includes and defines. + # Extracts CXXFLAGS from the command string, excluding includes and + # defines. Anchored per token: a substring match would extract a bogus + # "-format-zero-length" from -Wno-format-zero-length. flag_pattern = re.compile( - r"(-O[0-3s]|-g|-std=[^\s]+|-Wall|-Wextra|-Werror|--[^\s]+|-f[^\s]+|-m[^\s]+|-imacros\s*[^\s]+)" + r"^(-O[0-3s]|-g|-std=.+|-Wall|-Wextra|-Werror|--.+|-f.+|-m.+|-imacros.+)$" ) - return [ - match.replace("-imacros ", "-imacros") - for match in flag_pattern.findall(command) - ] + flags = [] + tokens = shlex.split(command) + for i, token in enumerate(tokens): + if token == "-imacros" and i + 1 < len(tokens): + flags.append(f"-imacros{tokens[i + 1]}") + elif flag_pattern.match(token): + flags.append(token) + return flags def transform_to_idedata_format(compile_commands): - cxx_path = find_cxx_path(compile_commands) - idedata = { + # Use only the tidy app TU (main.cpp): as the app target, its compile + # command already carries the full Zephyr include set. Unioning every + # TU instead would drag in per-library internal include dirs (e.g. the + # Zephyr POSIX shim, whose signal.h redefines newlib's sigset_t) that + # no ESPHome source compiles against. + entry = next( + (e for e in compile_commands if e["file"].endswith("main.cpp")), None + ) + if entry is None: + raise RuntimeError("tidy main.cpp not found in compile_commands.json") + command = entry["command"] + # Find the compiler by name: the command may be prefixed with a + # launcher (Zephyr auto-enables ccache when present). + cxx_path = next((t for t in shlex.split(command) if t.endswith("++")), None) + if cxx_path is None: + raise RuntimeError(f"no C++ compiler in compile command: {command}") + + return { "includes": { "toolchain": get_builtin_include_paths(cxx_path), - "build": set(), + "build": extract_include_paths(command), }, - "defines": set(), + "defines": extract_defines(command), "cxx_path": cxx_path, - "cxx_flags": set(), + "cxx_flags": extract_cxx_flags(command), } - for entry in compile_commands: - command = entry["command"] - exec = command.split()[0] - if exec != cxx_path: - continue - - idedata["includes"]["build"].update(extract_include_paths(command)) - idedata["defines"].update(extract_defines(command)) - idedata["cxx_flags"].update(extract_cxx_flags(command)) - - # Convert sets to lists for JSON serialization - idedata["includes"]["build"] = list(idedata["includes"]["build"]) - idedata["defines"] = list(idedata["defines"]) - idedata["cxx_flags"] = list(idedata["cxx_flags"]) - - return idedata - - compile_commands = json.loads( - Path( - build_dir / ".pio" / "build" / build_environment / "compile_commands.json" - ).read_text(encoding="utf-8") - ) + compile_commands = json.loads(compile_commands_path.read_text(encoding="utf-8")) return transform_to_idedata_format(compile_commands) diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 2b3d1f6db8..bb5bc8c064 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1,5 +1,6 @@ """Tests for esphome.components.nrf52.framework helpers.""" +import hashlib from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -7,7 +8,8 @@ from unittest.mock import patch import pytest from esphome.components.nrf52.framework import ( - _TOOLCHAIN_VERSION, + _REQUIREMENTS, + TOOLCHAIN_VERSION, _get_toolchain_platform_info, check_and_install, get_sdk_nrf_tools_path, @@ -71,7 +73,7 @@ def nrf52_dirs(setup_core: Path) -> SimpleNamespace: tools = get_sdk_nrf_tools_path() python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}" framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}" - toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION + toolchain_dir = tools / "toolchains" / TOOLCHAIN_VERSION for d in (python_env, framework, toolchain_dir): d.mkdir(parents=True, exist_ok=True) zephyr_scripts = framework / "zephyr" / "scripts" @@ -113,6 +115,12 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _mark_venv_ready(python_env: Path) -> None: + """Write the venv sentinel with the current requirements hash.""" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() + (python_env / ".ready").write_text(requirements_hash, encoding="utf-8") + + class TestCheckAndInstall: def test_all_installed_skips_all_steps( self, @@ -120,7 +128,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """All three sentinels present → nothing downloaded or compiled.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.toolchain / ".ready").touch() @@ -157,7 +165,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Venv ready but framework missing → skip venv creation, run SDK init+update.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) check_and_install() @@ -173,7 +181,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Venv and framework ready → only toolchain downloaded and extracted.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() @@ -202,7 +210,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Failing west init raises EsphomeError.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) mock_nrf52_ops.run_command_ok.return_value = False with pytest.raises(EsphomeError, match="Can't initialize"): @@ -214,7 +222,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Failing west update raises EsphomeError.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) # init succeeds, update fails mock_nrf52_ops.run_command_ok.side_effect = [True, False] @@ -227,7 +235,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """download_from_mirrors receives VERSION + platform triple from _get_toolchain_platform_info.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) (nrf52_dirs.framework / ".ready").touch() with patch( @@ -238,7 +246,7 @@ class TestCheckAndInstall: args, _ = mock_nrf52_ops.download_from_mirrors.call_args substitutions = args[1] - assert substitutions["VERSION"] == _TOOLCHAIN_VERSION + assert substitutions["VERSION"] == TOOLCHAIN_VERSION assert substitutions["sysname"] == "linux" assert substitutions["machine"] == "x86_64" assert substitutions["extension"] == "tar.xz" From e94fcda8b7df65797274a97527dfcc0b89533485 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Sat, 4 Jul 2026 02:13:22 +0000 Subject: [PATCH 1967/2214] [cst328] Touch screen (Waveshare ESP32-S3-Touch-LCD-2.8) (#8011) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/cst328/__init__.py | 6 + .../cst328/binary_sensor/__init__.py | 28 +++ .../cst328/binary_sensor/cst328_button.cpp | 16 ++ .../cst328/binary_sensor/cst328_button.h | 20 +++ .../components/cst328/touchscreen/__init__.py | 38 ++++ .../cst328/touchscreen/cst328_touchscreen.cpp | 168 ++++++++++++++++++ .../cst328/touchscreen/cst328_touchscreen.h | 61 +++++++ tests/components/cst328/common.yaml | 22 +++ tests/components/cst328/test.esp32-idf.yaml | 8 + 10 files changed, 368 insertions(+) create mode 100644 esphome/components/cst328/__init__.py create mode 100644 esphome/components/cst328/binary_sensor/__init__.py create mode 100644 esphome/components/cst328/binary_sensor/cst328_button.cpp create mode 100644 esphome/components/cst328/binary_sensor/cst328_button.h create mode 100644 esphome/components/cst328/touchscreen/__init__.py create mode 100644 esphome/components/cst328/touchscreen/cst328_touchscreen.cpp create mode 100644 esphome/components/cst328/touchscreen/cst328_touchscreen.h create mode 100644 tests/components/cst328/common.yaml create mode 100644 tests/components/cst328/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b222c44214..571f8492f1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -122,6 +122,7 @@ esphome/components/cover/* @esphome/core esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger esphome/components/cst226/* @clydebarrow +esphome/components/cst328/* @latonita esphome/components/cst816/* @clydebarrow esphome/components/cst9220/* @clydebarrow esphome/components/ct_clamp/* @jesserockz diff --git a/esphome/components/cst328/__init__.py b/esphome/components/cst328/__init__.py new file mode 100644 index 0000000000..374df64898 --- /dev/null +++ b/esphome/components/cst328/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] + +cst328_ns = cg.esphome_ns.namespace("cst328") diff --git a/esphome/components/cst328/binary_sensor/__init__.py b/esphome/components/cst328/binary_sensor/__init__.py new file mode 100644 index 0000000000..6d881cc6c1 --- /dev/null +++ b/esphome/components/cst328/binary_sensor/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv + +from .. import cst328_ns +from ..touchscreen import CST328ButtonListener, CST328Touchscreen + +CONF_CST328_ID = "cst328_id" + +CST328Button = cst328_ns.class_( + "CST328Button", + binary_sensor.BinarySensor, + cg.Component, + CST328ButtonListener, + cg.Parented.template(CST328Touchscreen), +) + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(CST328Button).extend( + { + cv.GenerateID(CONF_CST328_ID): cv.use_id(CST328Touchscreen), + } +) + + +async def to_code(config): + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_CST328_ID]) diff --git a/esphome/components/cst328/binary_sensor/cst328_button.cpp b/esphome/components/cst328/binary_sensor/cst328_button.cpp new file mode 100644 index 0000000000..b58f4b4b9f --- /dev/null +++ b/esphome/components/cst328/binary_sensor/cst328_button.cpp @@ -0,0 +1,16 @@ +#include "cst328_button.h" +#include "esphome/core/log.h" + +namespace esphome::cst328 { +static const char *const TAG = "cst328.binary_sensor"; + +void CST328Button::setup() { + this->parent_->register_button_listener(this); + this->publish_initial_state(false); +} + +void CST328Button::dump_config() { LOG_BINARY_SENSOR("", "CST328 Button", this); } + +void CST328Button::update_button(bool state) { this->publish_state(state); } + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/binary_sensor/cst328_button.h b/esphome/components/cst328/binary_sensor/cst328_button.h new file mode 100644 index 0000000000..a9ed4785e5 --- /dev/null +++ b/esphome/components/cst328/binary_sensor/cst328_button.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "../touchscreen/cst328_touchscreen.h" + +namespace esphome::cst328 { + +class CST328Button : public binary_sensor::BinarySensor, + public Component, + public CST328ButtonListener, + public Parented { + public: + void setup() override; + void dump_config() override; + void update_button(bool state) override; +}; + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/touchscreen/__init__.py b/esphome/components/cst328/touchscreen/__init__.py new file mode 100644 index 0000000000..18c00bb6c5 --- /dev/null +++ b/esphome/components/cst328/touchscreen/__init__.py @@ -0,0 +1,38 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import cst328_ns + +CST328Touchscreen = cst328_ns.class_( + "CST328Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CST328ButtonListener = cst328_ns.class_("CST328ButtonListener") + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema("100ms") + .extend( + { + cv.GenerateID(): cv.declare_id(CST328Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(i2c.i2c_device_schema(0x1A)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp new file mode 100644 index 0000000000..5e1a2ebf72 --- /dev/null +++ b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp @@ -0,0 +1,168 @@ +#include "cst328_touchscreen.h" +#include "esphome/core/log.h" + +namespace esphome::cst328 { + +static const char *const TAG = "cst328.touchscreen"; + +static const uint32_t CST328_BEFORE_RESET_TIMEOUT = 50; // 50 ms from datasheet +static const uint32_t CST328_TRANSITION_TIMEOUT = 300; // 200 ms from datasheet, but typically much less +static const uint16_t CST328_FW_CRC = 0xCACA; // Expected firmware CRC value +static const uint8_t CST328_SYNC_BYTE = 0xAB; // Sync byte used in communication + +static const uint8_t ZERO_BYTE = 0; + +#define I2C_WARN_ON_ERROR(x, log_tag, format, ...) \ + do { \ + i2c::ErrorCode err_rc_ = (x); \ + if (err_rc_ != i2c::ERROR_OK) { \ + ESP_LOGW(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \ + this->status_set_warning(format); \ + } \ + } while (0) + +#define I2C_FAIL_ON_ERROR(x, log_tag, format, ...) \ + do { \ + i2c::ErrorCode err_rc_ = (x); \ + if (err_rc_ != i2c::ERROR_OK) { \ + ESP_LOGE(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \ + this->mark_failed(); \ + return; \ + } \ + } while (0) + +void CST328Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up CST328 Touchscreen..."); + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + this->set_timeout(CST328_BEFORE_RESET_TIMEOUT, [this] { this->reset_device_(); }); + } else { + this->continue_setup_(); + } +} + +void CST328Touchscreen::reset_device_() { + this->reset_pin_->digital_write(false); + delay(5); + this->reset_pin_->digital_write(true); + this->set_timeout(CST328_TRANSITION_TIMEOUT, [this] { this->continue_setup_(); }); +} + +void CST328Touchscreen::continue_setup_() { + ESP_LOGV(TAG, "Continuing CST328 setup..."); + + uint8_t data_byte{0}; + uint8_t buf[24]{}; + + I2C_FAIL_ON_ERROR(this->write_register16(CST_WM_DEBUG_INFO, buf, 0), TAG, "Failed to enter debug/info mode"); + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_CRC_AND_BOOT_TIME, buf, 4), TAG, + "Failed to read FW CRC and boot time"); + + uint16_t fw_crc = buf[2] + (buf[3] << 8); + if (fw_crc != CST328_FW_CRC) { + ESP_LOGE(TAG, "Error: Firmware CRC mismatch, expected 0x%04X but got 0x%04X", CST328_FW_CRC, fw_crc); + this->mark_failed(); + return; + } + + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_CHIP_TYPE_AND_PROJECT_ID, buf, 4), TAG, + "Failed to read chip and project ID"); + + this->chip_id_ = buf[2] + (buf[3] << 8); + this->project_id_ = buf[0] + (buf[1] << 8); + ESP_LOGD(TAG, "Chip ID %X, project ID %X", this->chip_id_, this->project_id_); + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_REVISION, buf, 4), TAG, "Failed to read FW version"); + + this->fw_ver_major_ = buf[3]; + this->fw_ver_minor_ = buf[2]; + this->fw_build_ = buf[0] + (buf[1] << 8); + ESP_LOGV(TAG, "FW version %d.%d.%d", this->fw_ver_major_, this->fw_ver_minor_, this->fw_build_); + + if (i2c::ERROR_OK == this->read_register16(CST_REG_X_Y_RESOLUTION, buf, 4)) { + this->x_raw_max_ = buf[0] + (buf[1] << 8); + this->y_raw_max_ = buf[2] + (buf[3] << 8); + } else { + this->x_raw_max_ = this->display_->get_native_width(); + this->y_raw_max_ = this->display_->get_native_height(); + } + + I2C_WARN_ON_ERROR(this->write_register16(CST_WM_NORMAL, buf, 0), TAG, "Failed to enter normal mode"); + I2C_WARN_ON_ERROR(this->read_register16(CST_REG_TOUCH_INFORMATION, &data_byte, 1), TAG, "Failed to read sync"); + I2C_WARN_ON_ERROR(this->write_register16(CST_REG_TOUCH_INFORMATION, &CST328_SYNC_BYTE, 1), TAG, + "Failed to write sync"); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + this->setup_complete_ = true; + ESP_LOGV(TAG, "CST328 setup complete"); +} + +void CST328Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, "CST328 Touchscreen:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + ESP_LOGCONFIG(TAG, " Chip ID: 0x%04X, Project ID: 0x%04X", this->chip_id_, this->project_id_); + ESP_LOGCONFIG(TAG, " FW version: %d.%d.%d", this->fw_ver_major_, this->fw_ver_minor_, this->fw_build_); + ESP_LOGCONFIG(TAG, " X/Y resolution: %d/%d", this->x_raw_max_, this->y_raw_max_); +} + +void CST328Touchscreen::update_button_state_(bool state) { + if (this->button_touched_ == state) { + return; + } + this->button_touched_ = state; + for (auto *listener : this->button_listeners_) { + listener->update_button(state); + } +} + +void CST328Touchscreen::update_touches() { + if (!this->setup_complete_) { + this->skip_update_ = true; + return; + } + + uint8_t touch_data[CST328_TOUCH_DATA_SIZE]; + + this->status_clear_warning(); + + if (i2c::ERROR_OK != this->read_register16(CST_REG_TOUCH_INFORMATION, touch_data, CST328_TOUCH_DATA_SIZE)) { + ESP_LOGW(TAG, "Failed to read touch data"); + this->status_set_warning(); + this->skip_update_ = true; + return; + } + + uint8_t touch_cnt = touch_data[CST_REG_FINGER_COUNT_IDX] & 0x0F; + if (touch_cnt == 0 || touch_cnt > CST328_TOUCH_MAX_POINTS) { + this->update_button_state_(false); + } else { + this->update_button_state_(true); + + uint8_t data_idx = 0; + for (uint8_t i = 0; i < touch_cnt; i++) { + uint8_t id = touch_data[data_idx] >> 4; + int16_t x = (touch_data[data_idx + 1] << 4) | ((touch_data[data_idx + 3] >> 4) & 0x0F); + int16_t y = (touch_data[data_idx + 2] << 4) | (touch_data[data_idx + 3] & 0x0F); + int16_t z = touch_data[data_idx + 4]; + + this->add_raw_touch_position_(id, x, y, z); + data_idx += (i == 0) ? 7 : 5; + } + } + + bool cleanup_error = false; + cleanup_error |= (i2c::ERROR_OK != this->write_register16(CST_REG_TOUCH_FINGER_NUMBER, &ZERO_BYTE, 1)); + cleanup_error |= (i2c::ERROR_OK != this->write_register16(CST_REG_TOUCH_INFORMATION, &CST328_SYNC_BYTE, 1)); + + if (cleanup_error) { + ESP_LOGW(TAG, "Failed to clean up touch registers"); + } +} + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/touchscreen/cst328_touchscreen.h b/esphome/components/cst328/touchscreen/cst328_touchscreen.h new file mode 100644 index 0000000000..234ec6eee0 --- /dev/null +++ b/esphome/components/cst328/touchscreen/cst328_touchscreen.h @@ -0,0 +1,61 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::cst328 { + +static const uint8_t CST328_TOUCH_MAX_POINTS = 5; +static const uint8_t CST328_TOUCH_DATA_SIZE = CST328_TOUCH_MAX_POINTS * 5 + 2; + +static const uint16_t CST_REG_TOUCH_INFORMATION = 0xD000; +static const uint16_t CST_REG_TOUCH_FINGER_NUMBER = 0xD005; + +static const uint16_t CST_REG_FINGER_COUNT_IDX = CST_REG_TOUCH_FINGER_NUMBER - CST_REG_TOUCH_INFORMATION; + +static const uint16_t CST_REG_X_Y_RESOLUTION = 0xD1F8; +static const uint16_t CST_REG_FW_CRC_AND_BOOT_TIME = 0xD1FC; +static const uint16_t CST_REG_CHIP_TYPE_AND_PROJECT_ID = 0xD204; +static const uint16_t CST_REG_FW_REVISION = 0xD208; + +static const uint16_t CST_WM_DEBUG_INFO = 0xD101; +static const uint16_t CST_WM_NORMAL = 0xD109; + +class CST328ButtonListener { + public: + virtual void update_button(bool state) = 0; +}; + +class CST328Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void register_button_listener(CST328ButtonListener *listener) { this->button_listeners_.push_back(listener); } + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + void reset_device_(); + void continue_setup_(); + void update_button_state_(bool state); + + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{}; + + std::vector button_listeners_; + bool button_touched_{}; + + uint16_t chip_id_{}; + uint16_t project_id_{}; + uint8_t fw_ver_major_{}; + uint8_t fw_ver_minor_{}; + uint16_t fw_build_{}; + + bool setup_complete_{}; +}; + +} // namespace esphome::cst328 diff --git a/tests/components/cst328/common.yaml b/tests/components/cst328/common.yaml new file mode 100644 index 0000000000..286dbf587f --- /dev/null +++ b/tests/components/cst328/common.yaml @@ -0,0 +1,22 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + id: cst328_ssd1306_i2c_display + model: SSD1306_128X64 + reset_pin: ${display_reset_pin} + pages: + - id: cst328_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: cst328 + i2c_id: i2c_bus + id: cst328_touchscreen + display: cst328_ssd1306_i2c_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} + +binary_sensor: + - platform: cst328 + id: touch_key_cst328 diff --git a/tests/components/cst328/test.esp32-idf.yaml b/tests/components/cst328/test.esp32-idf.yaml new file mode 100644 index 0000000000..3dc184e328 --- /dev/null +++ b/tests/components/cst328/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + display_reset_pin: "4" + interrupt_pin: "20" + reset_pin: "21" + +packages: + - !include ../../test_build_components/common/i2c/esp32-idf.yaml + - !include common.yaml From 787805253393551a4a5cfa09a4e351a8cc548ed8 Mon Sep 17 00:00:00 2001 From: Citric Li <37475446+limengdu@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:50:54 +0800 Subject: [PATCH 1968/2214] [epaper_spi] Add T133A01 6-color e-paper driver for reTerminal E1004 (#16706) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/display.py | 2 + .../epaper_spi/epaper_spi_t133a01.cpp | 367 ++++++++++++++++++ .../epaper_spi/epaper_spi_t133a01.h | 77 ++++ .../components/epaper_spi/models/__init__.py | 23 ++ .../components/epaper_spi/models/t133a01.py | 71 ++++ tests/component_tests/epaper_spi/test_init.py | 8 + .../epaper_spi/test.esp32-s3-idf.yaml | 8 + .../validate-e1004.esp32-s3-idf.yaml | 38 ++ 8 files changed, 594 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_spi_t133a01.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_t133a01.h create mode 100644 esphome/components/epaper_spi/models/t133a01.py create mode 100644 tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index ce28fb0d67..0b82850f1e 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -112,6 +112,7 @@ def model_schema(config): cv.positive_time_period_milliseconds, cv.Range(max=core.TimePeriod(milliseconds=500)), ), + **model.get_config_options(), } ) @@ -198,6 +199,7 @@ async def to_code(config): ) await display.register_display(var, config) + config = await model.to_code(var, config) await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp new file mode 100644 index 0000000000..5735333761 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -0,0 +1,367 @@ +#include "epaper_spi_t133a01.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.t133a01"; + +// Color indices used in the 4bpp buffer (sprite-side) +// These MUST match the Arduino GFX TFT_eSPI.h color definitions and +// the remap_color()/COLOR_GET mapping: +// 0x0F=BLACK, 0x00=WHITE, 0x02=GREEN, 0x06=RED, 0x0B=YELLOW, 0x0D=BLUE +static constexpr uint8_t T133A01_BLACK = 0x0F; +static constexpr uint8_t T133A01_WHITE = 0x00; +static constexpr uint8_t T133A01_GREEN = 0x02; +static constexpr uint8_t T133A01_RED = 0x06; +static constexpr uint8_t T133A01_YELLOW = 0x0B; +static constexpr uint8_t T133A01_BLUE = 0x0D; + +// T133A01 register addresses +static constexpr uint8_t R00_PSR = 0x00; +static constexpr uint8_t R01_PWR = 0x01; +static constexpr uint8_t R02_POF = 0x02; +static constexpr uint8_t R04_PON = 0x04; +static constexpr uint8_t R05_BTST_N = 0x05; +static constexpr uint8_t R06_BTST_P = 0x06; +static constexpr uint8_t R10_DTM = 0x10; +static constexpr uint8_t R12_DRF = 0x12; +static constexpr uint8_t R50_CDI = 0x50; +static constexpr uint8_t R61_TRES = 0x61; +static constexpr uint8_t RA5_DCDC = 0xA5; +static constexpr uint8_t RE0_CCSET = 0xE0; +static constexpr uint8_t RE3_PWS = 0xE3; + +/** + * COLOR_GET remap table from T133A01_Defines.h. + * Translates 4bpp sprite color index to the hardware pixel encoding. + * Sprite: 0x0F=BLACK 0x00=WHITE 0x02=GREEN 0x06=RED 0x0B=YELLOW 0x0D=BLUE + * HW: 0x00=BLACK 0x01=WHITE 0x06=GREEN 0x03=RED 0x02=YELLOW 0x05=BLUE + */ +uint8_t EPaperT133A01::remap_color(uint8_t index) { + switch (index & 0x0F) { + case 0x0F: + return 0x00; // Black + case 0x00: + return 0x01; // White + case 0x02: + return 0x06; // Green + case 0x06: + return 0x03; // Red + case 0x0B: + return 0x02; // Yellow + case 0x0D: + return 0x05; // Blue + default: + return 0x01; // White fallback + } +} + +/** + * Map an ESPHome Color to a 4-bit sprite color index. + * Index values match the Arduino GFX TFT_eSPI color definitions: + * 0x00=WHITE, 0x02=GREEN, 0x06=RED, 0x0B=YELLOW, 0x0D=BLUE, 0x0F=BLACK + */ +uint8_t EPaperT133A01::color_to_index(Color color) { + unsigned char max_rgb = std::max({color.r, color.g, color.b}); + unsigned char min_rgb = std::min({color.r, color.g, color.b}); + + // Check for grayscale + if ((max_rgb - min_rgb) < 50) { + if ((static_cast(color.r) + color.g + color.b) > 382) { + return T133A01_WHITE; + } + return T133A01_BLACK; + } + + bool r_on = (color.r > 128); + bool g_on = (color.g > 128); + bool b_on = (color.b > 128); + + if (r_on && g_on && !b_on) + return T133A01_YELLOW; + if (r_on && !g_on && !b_on) + return T133A01_RED; + if (!r_on && g_on && !b_on) + return T133A01_GREEN; + if (!r_on && !g_on && b_on) + return T133A01_BLUE; + // Handle mixed colors: map to nearest primary + if (!r_on && g_on && b_on) + return T133A01_GREEN; // Cyan -> Green + if (r_on && !g_on) + return T133A01_RED; // Magenta -> Red + if (r_on) + return T133A01_WHITE; + return T133A01_BLACK; +} + +void EPaperT133A01::setup() { + // Base setup initialises the buffer, the standard pins and the SPI bus. + EPaperBase::setup(); + + // Both chip-selects are driven directly by this driver (the dual-CS + // protocol needs CS held HIGH while CS1 receives data, which the SPI + // bus cannot do). Start both deselected (HIGH). + this->cs_pin_->setup(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->setup(); + this->cs1_pin_->digital_write(true); +} + +bool EPaperT133A01::reset() { + for (auto *enable_pin : this->enable_pins_) { + enable_pin->digital_write(true); + } + if (this->reset_pin_ != nullptr) { + if (this->state_ == EPaperState::RESET) { + this->reset_pin_->digital_write(false); + return false; + } + this->reset_pin_->digital_write(true); + } + return true; +} + +/** + * Initialise the T133A01 display. + * + * The init sequence uses a mix of CS and CS1 commands as per the Arduino driver. + * The base class init_sequence is NOT used for T133A01 because the dual-CS + * protocol requires per-command routing. + */ +bool EPaperT133A01::initialise(bool partial) { + // Init sequence mirrors the Arduino GFX library's EPD_INIT() macro + // (T133A01_Defines.h). Commands routed to CS only leave CS1 deselected; + // commands routed to both controllers assert CS and CS1 together. + + // 0x74 - panel config (CS only) + this->write_command_(0x74, {0x00, 0x0C, 0x0C, 0xD9, 0xDD, 0xDD, 0x15, 0x15, 0x55}, true, false); + delay(10); + + // 0xF0 - panel config (CS + CS1) + this->write_command_(0xF0, {0x49, 0x55, 0x13, 0x5D, 0x05, 0x10}, true, true); + delay(10); + + // PSR - Panel Setting Register (CS + CS1) + this->write_command_(0x00, {0xDF, 0x69}, true, true); + delay(10); + + // DCDC (CS only) + this->write_command_(RA5_DCDC, {0x44, 0x54, 0x00}, true, false); + delay(10); + + // CDI (CS + CS1) + this->write_command_(R50_CDI, {0x37}, true, true); + delay(10); + + // 0x60 (CS + CS1) + this->write_command_(0x60, {0x03, 0x03}, true, true); + delay(10); + + // 0x86 (CS + CS1) + this->write_command_(0x86, {0x10}, true, true); + delay(10); + + // PWS - Phase Width Setting (CS + CS1) + this->write_command_(RE3_PWS, {0x22}, true, true); + delay(10); + + // TRES - Resolution Setting (CS + CS1). + // With width=1200, height=1600: first word = width = 1200, second word = height/2 = 800. + this->write_command_(R61_TRES, + {(uint8_t) (this->width_ >> 8), (uint8_t) (this->width_ & 0xFF), + (uint8_t) ((this->height_ / 2) >> 8), (uint8_t) ((this->height_ / 2) & 0xFF)}, + true, true); + delay(10); + + // PWR - Power Setting (CS only) + this->write_command_(R01_PWR, {0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38}, true, false); + delay(10); + + // 0xB6 (CS only) + this->write_command_(0xB6, {0x07}, true, false); + delay(10); + + // BTST_P (CS only) + this->write_command_(R06_BTST_P, {0xE0, 0x20}, true, false); + delay(10); + + // 0xB7 (CS only) + this->write_command_(0xB7, {0x01}, true, false); + delay(10); + + // BTST_N (CS only) + this->write_command_(R05_BTST_N, {0xE0, 0x20}, true, false); + delay(10); + + // 0xB0 (CS only) + this->write_command_(0xB0, {0x01}, true, false); + delay(10); + + // 0xB1 (CS only) + this->write_command_(0xB1, {0x02}, true, false); + delay(10); + + return true; +} + +void EPaperT133A01::write_command_(uint8_t command, const uint8_t *data, size_t length, bool use_cs, bool use_cs1) { + ESP_LOGV(TAG, "Command: 0x%02X, Length: %u, CS: %d, CS1: %d", command, (unsigned) length, use_cs, use_cs1); + // Chip-selects are active-low: assert the requested controllers. + this->cs_pin_->digital_write(!use_cs); + this->cs1_pin_->digital_write(!use_cs1); + this->dc_pin_->digital_write(false); + this->enable(); + this->write_byte(command); + if (length > 0) { + this->dc_pin_->digital_write(true); + this->write_array(data, length); + } + this->disable(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->digital_write(true); +} + +void EPaperT133A01::fill(Color color) { + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); + return; + } + auto pixel_color = color_to_index(color); + this->buffer_.fill(pixel_color + (pixel_color << 4)); +} + +void EPaperT133A01::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + auto pixel_bits = color_to_index(color); + uint32_t pixel_position = x + y * this->get_width_internal(); + uint32_t byte_position = pixel_position / 2; + auto original = this->buffer_[byte_position]; + if ((pixel_position & 1) != 0) { + this->buffer_[byte_position] = (original & 0xF0) | pixel_bits; + } else { + this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4); + } +} + +void EPaperT133A01::power_on() { + ESP_LOGV(TAG, "Power on"); + this->write_command_(R04_PON, true, true); +} + +void EPaperT133A01::power_off() { + ESP_LOGV(TAG, "Power off"); + this->write_command_(R02_POF, {0x00}, true, true); +} + +void EPaperT133A01::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); + // Display Refresh + this->write_command_(R12_DRF, {0x01}, true, true); +} + +void EPaperT133A01::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->write_command_(0x07, {0xA5}, true, true); +} + +bool HOT EPaperT133A01::transfer_data() { + const uint32_t start_time = millis(); + const uint16_t bytes_per_half_row = this->width_ / 4; + const uint16_t total_rows = this->height_; + const uint16_t bytes_per_row = this->width_ / 2; + uint8_t line_data[400] = {}; + + size_t half = this->current_data_index_; + + // --- CCSET: select color set before data transfer (CS + CS1) --- + if (half == 0) { + this->write_command_(RE0_CCSET, {0x01}, true, true); + this->wait_for_idle_(true); + delay(10); + } + + // --- CS phase: left half of each row via CS --- + // T133A01 requires CS to stay LOW for the ENTIRE DTM data stream. + // Toggling CS between chunks resets the controller's data pointer, + // causing only the last chunk to be retained. Keep CS asserted + // across timeout boundaries by NOT deselecting on yield. + if (half < total_rows) { + if (half == 0) { + this->cs_pin_->digital_write(false); // select CS + this->cs1_pin_->digital_write(true); // deselect CS1 + this->dc_pin_->digital_write(false); + this->enable(); + this->write_byte(R10_DTM); + this->dc_pin_->digital_write(true); + } + + while (half < total_rows) { + size_t buf_offset = half * bytes_per_row; + for (uint16_t col = 0; col < bytes_per_half_row; col++) { + uint8_t b = this->buffer_[buf_offset + col]; + line_data[col] = (remap_color(b >> 4) << 4) | remap_color(b & 0x0F); + } + this->write_array(line_data, bytes_per_half_row); + half++; + this->current_data_index_ = half; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + return false; + } + } + ESP_LOGD(TAG, "CS phase done"); + this->disable(); + this->cs_pin_->digital_write(true); // deselect CS + } + + // --- CS1 phase: right half of each row via CS1 --- + // Same continuous-transaction requirement as the CS phase. + // CS is held HIGH so only CS1 receives the data. + if (half >= total_rows && half < total_rows * 2) { + size_t cs1_row = half - total_rows; + + if (cs1_row == 0) { + this->cs_pin_->digital_write(true); // deselect CS + this->cs1_pin_->digital_write(false); // select CS1 + this->enable(); + this->dc_pin_->digital_write(false); + this->write_byte(R10_DTM); + this->dc_pin_->digital_write(true); + } + + while (half < total_rows * 2) { + size_t row = half - total_rows; + size_t buf_offset = row * bytes_per_row + bytes_per_half_row; + for (uint16_t col = 0; col < bytes_per_half_row; col++) { + uint8_t b = this->buffer_[buf_offset + col]; + line_data[col] = (remap_color(b >> 4) << 4) | remap_color(b & 0x0F); + } + this->write_array(line_data, bytes_per_half_row); + half++; + this->current_data_index_ = half; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + return false; + } + } + ESP_LOGD(TAG, "CS1 phase done"); + this->disable(); + this->cs1_pin_->digital_write(true); // deselect CS1 + } + + this->current_data_index_ = 0; + return true; +} + +void EPaperT133A01::dump_config() { + EPaperBase::dump_config(); + LOG_PIN(" CS Pin: ", this->cs_pin_); + LOG_PIN(" CS1 Pin: ", this->cs1_pin_); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h new file mode 100644 index 0000000000..0d07fc03ae --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -0,0 +1,77 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * T133A01-based 6-color e-paper display driver. + * + * The T133A01 controller uses a dual-CS SPI architecture: + * - CS (primary): Controls the first half of pixel data transfer + * - CS1 (secondary): Controls panel commands (init, power, refresh) and + * the second half of pixel data transfer + * + * Color depth: 4 bits per pixel, supporting 6 colors: + * White, Green, Red, Yellow, Blue, Black + * + * Buffer layout: 2 pixels per byte (4bpp packed), total buffer size + * is width * height / 2 bytes. + */ +class EPaperT133A01 : public EPaperBase { + public: + EPaperT133A01(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { + this->buffer_length_ = (size_t) width * height / 2; // 2 pixels per byte at 4bpp + } + + void set_cs_pins(GPIOPin *cs, GPIOPin *cs1) { + this->cs_pin_ = cs; + this->cs1_pin_ = cs1; + } + + void fill(Color color) override; + + void setup() override; + void dump_config() override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + bool reset() override; + bool initialise(bool partial) override; + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; + + bool transfer_data() override; + + /** + * Send a command (and optional data) selecting one or both controllers. + * Both chip-selects are active-low and managed directly by this driver. + * @param command The command byte to send + * @param data Optional pointer to data bytes to send after the command + * @param length Number of data bytes to send after the command + * @param use_cs assert CS (left controller) for this transaction + * @param use_cs1 assert CS1 (right controller) for this transaction + */ + void write_command_(uint8_t command, const uint8_t *data, size_t length, bool use_cs, bool use_cs1); + void write_command_(uint8_t command, std::initializer_list data, bool use_cs, bool use_cs1) { + this->write_command_(command, data.begin(), data.size(), use_cs, use_cs1); + } + void write_command_(uint8_t command, bool use_cs, bool use_cs1) { + this->write_command_(command, nullptr, 0, use_cs, use_cs1); + } + + /// Convert Color to 4-bit T133A01 color index + static uint8_t color_to_index(Color color); + + /// Apply COLOR_GET remap table to translate sprite indices to hardware values + static uint8_t remap_color(uint8_t index); + + GPIOPin *cs_pin_{nullptr}; + GPIOPin *cs1_pin_{nullptr}; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 3fcf3217ec..2360b090ff 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -2,11 +2,15 @@ from typing import Any, Self import esphome.config_validation as cv from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.cpp_generator import MockObj class EpaperModel: models: dict[str, Self] = {} + # Whether the driver manages chip-select itself instead of via the SPI bus. + manages_cs: bool = False + def __init__( self, name: str, @@ -35,6 +39,25 @@ class EpaperModel: def get_constructor_args(self, config) -> tuple: return () + def get_config_options(self) -> dict: + """ + Return model-specific configuration schema options. + The base implementation adds nothing; specific models override this to + declare extra options without cluttering the shared schema. + :return: A mapping suitable for cv.Schema.extend() + """ + return {} + + async def to_code(self, var: MockObj, config: dict) -> dict: + """ + Generate model-specific code for the options added by add_options(). + The base implementation does nothing; specific models override this. + The config can be updated in place to add or remove options. + :param var: The component variable + :param config: The validated configuration + """ + return config + def get_dimensions(self, config) -> tuple[int, int]: if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is diff --git a/esphome/components/epaper_spi/models/t133a01.py b/esphome/components/epaper_spi/models/t133a01.py new file mode 100644 index 0000000000..0a57b95795 --- /dev/null +++ b/esphome/components/epaper_spi/models/t133a01.py @@ -0,0 +1,71 @@ +"""T133A01-based e-paper displays. + +The T133A01 is a 6-color e-paper controller IC that drives large panels +(1200x1600 portrait). It uses a dual-CS SPI architecture where CS +controls one half of the pixel data and CS1 controls the other half, +as well as panel-level commands (power on, refresh, power off). + +Supported models: +- Seeed-reTerminal-E1004: 1200x1600 pixels, 6-color (T133A01 panel) +""" + +from esphome import pins +import esphome.codegen as cg +from esphome.const import CONF_CS_PIN +from esphome.cpp_generator import MockObj + +from . import EpaperModel + +CONF_CS1_PIN = "cs1_pin" + + +class T133A01Model(EpaperModel): + """EpaperModel subclass for T133A01-based 6-color e-paper displays.""" + + # The driver drives CS and CS1 directly for the dual-CS protocol. + manages_cs = True + + def __init__(self, name, class_name="EPaperT133A01", **defaults): + super().__init__(name, class_name, **defaults) + + def get_config_options(self) -> dict: + # CS1 is the second chip-select required by the dual-CS architecture. + # fallback=None makes it required unless the model provides a default. + return { + self.option(CONF_CS1_PIN, fallback=None): pins.gpio_output_pin_schema, + } + + async def to_code(self, var: MockObj, config: dict) -> dict: + cs = await cg.gpio_pin_expression(config[CONF_CS_PIN]) + cs1 = await cg.gpio_pin_expression(config[CONF_CS1_PIN]) + cg.add(var.set_cs_pins(cs, cs1)) + # Remove CS and CS1 from the config so that the base class doesn't try to handle them. + return {k: v for k, v in config.items() if k not in (CONF_CS_PIN, CONF_CS1_PIN)} + + +t133a01_base = T133A01Model( + "t133a01", + minimum_update_interval="30s", + data_rate="10MHz", +) + +# Seeed reTerminal E1004 - 13.3" 6-color e-paper (1200x1600, T133A01) +# Portrait orientation (1200 wide × 1600 tall), matching the Arduino +# Setup523 defines TFT_WIDTH=1200, TFT_HEIGHT=1600. +# CS and CS1 each receive half of each row's pixel data +# (300 bytes = 600 pixels per controller, for all 1600 rows). +Seeed_reTerminal_E1004 = t133a01_base.extend( + "Seeed-reTerminal-E1004", + width=1200, + height=1600, + cs_pin=10, + cs1_pin=2, + dc_pin=11, + reset_pin=38, + busy_pin={ + "number": 13, + "inverted": True, + "mode": {"input": True}, + }, + enable_pin=12, +) diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index c7f34d7dd2..1396c18e3b 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -154,6 +154,10 @@ def test_all_predefined_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Dual-CS models (e.g. T133A01) require a second chip-select pin + if model.manages_cs and not model.get_default("cs1_pin"): + config["cs1_pin"] = 4 + # Select an ESP32 variant on which all of this model's pins are valid # (some models default to high-numbered pins only present on the S3). choose_variant_with_pins(_pins_for(model, config)) @@ -204,6 +208,10 @@ def test_individual_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Dual-CS models (e.g. T133A01) require a second chip-select pin + if model.manages_cs and not model.get_default("cs1_pin"): + config["cs1_pin"] = 4 + # Select an ESP32 variant on which all of this model's pins are valid # (some models default to high-numbered pins only present on the S3). choose_variant_with_pins(_pins_for(model, config)) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index bb771f2132..fb43b06567 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -76,6 +76,14 @@ display: - platform: epaper_spi model: seeed-reterminal-e1002 + - platform: epaper_spi + model: seeed-reterminal-e1004 + cs_pin: 33 + cs1_pin: 34 + dc_pin: 35 + reset_pin: 36 + busy_pin: 37 + enable_pin: 39 - platform: epaper_spi model: seeed-ee04-mono-4.26 full_update_every: 10 diff --git a/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml b/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml new file mode 100644 index 0000000000..27066710e0 --- /dev/null +++ b/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml @@ -0,0 +1,38 @@ +esphome: + name: e1004-test + friendly_name: E1004 Test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + +spi: + - id: epaper_spi_bus + clk_pin: GPIO7 + mosi_pin: GPIO9 + +display: + - platform: epaper_spi + spi_id: epaper_spi_bus + model: seeed-reterminal-e1004 + update_interval: never + lambda: |- + it.fill(Color::WHITE); + it.rectangle(10, 10, it.get_width() - 20, it.get_height() - 20, Color::BLACK); + it.print(it.get_width() / 2, it.get_height() / 2, id(my_font), Color::BLACK, TextAlign::CENTER, "E1004 Test"); + it.circle(100, 100, 30, Color(255, 0, 0)); + it.circle(200, 100, 30, Color(0, 255, 0)); + it.circle(300, 100, 30, Color(0, 0, 255)); + it.circle(400, 100, 30, Color(255, 255, 0)); + +font: + - file: "gfonts://Roboto" + id: my_font + size: 20 + +logger: From 4c8e45a222cbc505b0f102541dcbd091ef3918ae Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:17:49 -0500 Subject: [PATCH 1969/2214] Bump bundled esphome-device-builder to 1.0.28 (#17382) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9dec23db1b..9367831a4c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.28 RUN \ platformio settings set enable_telemetry No \ From fcfaa43e1eb9662179e12c375a57dda0959440d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:46:16 -0400 Subject: [PATCH 1970/2214] [ci] Name the sdk-nrf cache steps after the nRF Connect SDK (#17392) --- .github/actions/cache-sdk-nrf/action.yml | 6 +++--- .github/workflows/ci.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/cache-sdk-nrf/action.yml b/.github/actions/cache-sdk-nrf/action.yml index 71c09bfe14..6cbb87cc66 100644 --- a/.github/actions/cache-sdk-nrf/action.yml +++ b/.github/actions/cache-sdk-nrf/action.yml @@ -1,4 +1,4 @@ -name: Cache sdk-nrf +name: Cache nRF Connect SDK description: > Resolve the pinned sdk-nrf version and cache the native sdk-nrf install (west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf. @@ -33,14 +33,14 @@ runs: # Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it # lives in the default-branch scope readable by all PRs); PRs are # restore-only and never push multi-GB artifacts into their own scope. - - name: Cache sdk-nrf install (write on dev) + - name: Cache nRF Connect SDK install (write on dev) if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.esphome-sdk-nrf # yamllint disable-line rule:line-length key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} - - name: Cache sdk-nrf install (restore-only off dev) + - name: Cache nRF Connect SDK install (restore-only off dev) if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caf6453c1b..34f8ed4878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -529,7 +529,7 @@ jobs: with: framework: arduino - - name: Cache sdk-nrf install + - name: Cache nRF Connect SDK install if: matrix.cache_sdk_nrf uses: ./.github/actions/cache-sdk-nrf @@ -849,7 +849,7 @@ jobs: uses: ./.github/actions/cache-esp-idf with: restore-only: true - - name: Cache sdk-nrf install (restore-only) + - name: Cache nRF Connect SDK install (restore-only) # Only batches whose test platforms include nrf52 need the native # sdk-nrf install; never save -- just reuse the shared install the # dev nrf52 tidy job cached when present. From 2c24e82ba3ea058a1a5a3752c213f64cb62f6163 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:20:48 -0500 Subject: [PATCH 1971/2214] Bump bundled esphome-device-builder to 1.0.29 (#17384) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9367831a4c..b325a42436 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.28 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.29 RUN \ platformio settings set enable_telemetry No \ From 1e54afedd82a13a44f8721fd083061f1afaf99b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Jul 2026 16:21:29 -0500 Subject: [PATCH 1972/2214] [analyze_memory] Report aliased RAM symbols once in the RAM strings report --- esphome/analyze_memory/ram_strings.py | 31 +++++-- .../analyze_memory/test_ram_strings.py | 86 +++++++++++++++++++ 2 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 tests/unit_tests/analyze_memory/test_ram_strings.py diff --git a/esphome/analyze_memory/ram_strings.py b/esphome/analyze_memory/ram_strings.py index fbcbeeca61..4366668208 100644 --- a/esphome/analyze_memory/ram_strings.py +++ b/esphome/analyze_memory/ram_strings.py @@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266. from __future__ import annotations from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field import logging from pathlib import Path import re @@ -65,6 +65,7 @@ class RamSymbol: size: int section: str demangled: str = "" # Demangled name, set after batch demangling + aliases: list[str] = field(default_factory=list) # Other names at same address class RamStringsAnalyzer: @@ -235,6 +236,11 @@ class RamStringsAnalyzer: except (subprocess.CalledProcessError, FileNotFoundError): return + # Track symbols by address so aliases (multiple names for the same + # object, e.g. the newlib __lock___* mutexes that all alias one + # StaticSemaphore_t) are reported once instead of once per name. + symbols_by_addr: dict[int, RamSymbol] = {} + for line in output.split("\n"): parts = line.split() if len(parts) < 4: @@ -253,6 +259,11 @@ class RamStringsAnalyzer: if sym_type not in DATA_SYMBOL_TYPES: continue + if (existing := symbols_by_addr.get(addr)) is not None: + existing.aliases.append(name) + existing.size = max(existing.size, size) + continue + # Check if symbol is in a RAM section for section_name in self.ram_sections: if section_name not in self.sections: @@ -260,15 +271,15 @@ class RamStringsAnalyzer: section = self.sections[section_name] if section.address <= addr < section.address + section.size: - self.ram_symbols.append( - RamSymbol( - name=name, - sym_type=sym_type, - address=addr, - size=size, - section=section_name, - ) + symbol = RamSymbol( + name=name, + sym_type=sym_type, + address=addr, + size=size, + section=section_name, ) + symbols_by_addr[addr] = symbol + self.ram_symbols.append(symbol) break def _demangle_symbols(self) -> None: @@ -436,6 +447,8 @@ class RamStringsAnalyzer: for symbol in largest_symbols: # Use demangled name if available, otherwise raw name display_name = symbol.demangled or symbol.name + if symbol.aliases: + display_name += f" (+{len(symbol.aliases)} aliases)" name_display = display_name[:49] if len(display_name) > 49 else display_name lines.append( f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}" diff --git a/tests/unit_tests/analyze_memory/test_ram_strings.py b/tests/unit_tests/analyze_memory/test_ram_strings.py new file mode 100644 index 0000000000..33f769b881 --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_ram_strings.py @@ -0,0 +1,86 @@ +"""Tests for RAM symbol analysis in the RAM strings analyzer.""" + +from unittest.mock import patch + +from esphome.analyze_memory.ram_strings import RamStringsAnalyzer, SectionInfo + +# nm -S --size-sort output with the newlib lock mutexes: nine global +# symbols that are all aliases of two local StaticSemaphore_t objects. +NM_OUTPUT_WITH_ALIASES = """\ +3ffb4400 00000010 B small_symbol +3ffb43c8 00000054 B __lock___atexit_recursive_mutex +3ffb43c8 00000054 B __lock___env_recursive_mutex +3ffb43c8 00000054 B __lock___malloc_recursive_mutex +3ffb43c8 00000054 B __lock___sfp_recursive_mutex +3ffb43c8 00000054 B __lock___sinit_recursive_mutex +3ffb43c8 00000054 b s_common_recursive_mutex +3ffb441c 00000054 B __lock___arc4random_mutex +3ffb441c 00000054 B __lock___at_quick_exit_mutex +3ffb441c 00000054 B __lock___dd_hash_mutex +3ffb441c 00000054 B __lock___tz_mutex +3ffb441c 00000054 b s_common_mutex +""" + + +def _make_analyzer(tmp_path) -> RamStringsAnalyzer: + """Create an analyzer with a dummy ELF and a .dram0.bss section.""" + elf = tmp_path / "firmware.elf" + elf.write_bytes(b"\x7fELF") + analyzer = RamStringsAnalyzer(str(elf), platform="esp32") + analyzer.sections[".dram0.bss"] = SectionInfo(".dram0.bss", 0x3FFB0000, 0x10000) + return analyzer + + +def _run_symbol_analysis(analyzer: RamStringsAnalyzer, nm_output: str) -> None: + """Run _analyze_symbols with mocked nm output.""" + with ( + patch( + "esphome.analyze_memory.ram_strings.find_tool", + return_value="nm", + ), + patch.object(analyzer, "_run_command", return_value=nm_output), + ): + analyzer._analyze_symbols() + + +def test_aliased_symbols_counted_once(tmp_path) -> None: + """Symbols sharing an address are one object, not one per name.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + # Three distinct addresses, so three symbols + assert len(analyzer.ram_symbols) == 3 + total = sum(s.size for s in analyzer.ram_symbols) + assert total == 0x10 + 0x54 + 0x54 + + +def test_aliases_recorded_on_first_symbol(tmp_path) -> None: + """Extra names at the same address are kept as aliases.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + by_addr = {s.address: s for s in analyzer.ram_symbols} + assert len(by_addr[0x3FFB43C8].aliases) == 5 + assert len(by_addr[0x3FFB441C].aliases) == 4 + assert by_addr[0x3FFB4400].aliases == [] + assert "s_common_mutex" in by_addr[0x3FFB441C].aliases + + +def test_alias_count_shown_in_report(tmp_path) -> None: + """The large symbols table notes how many aliases were merged.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + report = analyzer.generate_report() + assert "(+5 aliases)" in report + assert "(+4 aliases)" in report + # Each lock name appears at most once in the report + assert report.count("__lock___") == 2 + + +def test_symbols_outside_ram_sections_skipped(tmp_path) -> None: + """Symbols outside known RAM sections are ignored entirely.""" + analyzer = _make_analyzer(tmp_path) + nm_output = "40080000 00000100 B not_in_ram\n" + _run_symbol_analysis(analyzer, nm_output) + assert analyzer.ram_symbols == [] From 690563c78b08129c506f16ba7fca25de7183bcb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Jul 2026 16:49:22 -0500 Subject: [PATCH 1973/2214] [web_server] Use known message length in SSE send path --- esphome/components/web_server/web_server.cpp | 23 +++++++------ esphome/components/web_server/web_server.h | 6 ++-- .../web_server_idf/web_server_idf.cpp | 34 +++++++++++-------- .../web_server_idf/web_server_idf.h | 6 ++-- 4 files changed, 39 insertions(+), 30 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index cdb8544fbb..96195a8270 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -257,8 +257,10 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * } // used for logs plus the initial ping/config -void DeferredUpdateEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, - uint32_t reconnect) { +void DeferredUpdateEventSource::try_send_nodefer(const char *message, size_t message_len, const char *event, + uint32_t id, uint32_t reconnect) { + // ESPAsyncWebServer's send() only accepts null-terminated strings + (void) message_len; this->send(message, event, id, reconnect); } @@ -279,10 +281,10 @@ void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const ch } } -void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, const char *event, uint32_t id, - uint32_t reconnect) { +void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, size_t message_len, const char *event, + uint32_t id, uint32_t reconnect) { for (DeferredUpdateEventSource *dues : *this) { - dues->try_send_nodefer(message, event, id, reconnect); + dues->try_send_nodefer(message, message_len, event, id, reconnect); } } @@ -304,7 +306,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource // Configure reconnect timeout and send config // this should always go through since the AsyncEventSourceClient event queue is empty on connect auto message = ws->get_config_json(); - source->try_send_nodefer(message.c_str(), "ping", millis(), 30000); + source->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -315,7 +317,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource auto group_msg = builder.serialize(); // up to 31 groups should be able to be queued initially without defer - source->try_send_nodefer(group_msg.c_str(), "sorting_group"); + source->try_send_nodefer(group_msg.c_str(), group_msg.size(), "sorting_group"); } #endif @@ -395,8 +397,8 @@ void WebServer::setup() { return; char buf[32]; auto uptime = static_cast(millis_64() / 1000); - buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); - this->events_.try_send_nodefer(buf, "ping", millis(), 30000); + size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); + this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); } void WebServer::loop() { @@ -414,8 +416,7 @@ void WebServer::loop() { void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; - (void) message_len; - this->events_.try_send_nodefer(message, "log", millis()); + this->events_.try_send_nodefer(message, message_len, "log", millis()); } #endif diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index e4defdbd9a..42182fe510 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -160,7 +160,8 @@ class DeferredUpdateEventSource final : public AsyncEventSource { void loop(); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); - void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); }; class DeferredUpdateEventSourceList final : public std::list { @@ -173,7 +174,8 @@ class DeferredUpdateEventSourceList final : public std::listsessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions - ses->try_send_nodefer(message, event, id, reconnect); + ses->try_send_nodefer(message, message_len, event, id, reconnect); } } } @@ -600,7 +601,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { // tcp send buffer is empty on connect, so these should always go through auto message = ws->get_config_json(); - this->try_send_nodefer(message.c_str(), "ping", millis(), 30000); + this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -612,7 +613,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { // a (very) large number of these should be able to be queued initially without defer // since the only thing in the send buffer at this point is the initial ping/config - this->try_send_nodefer(message.c_str(), "sorting_group"); + this->try_send_nodefer(message.c_str(), message.size(), "sorting_group"); } #endif @@ -647,7 +648,7 @@ void AsyncEventSourceResponse::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); auto message = de.message_generator_(web_server_, de.source_); - if (this->try_send_nodefer(message.c_str(), "state")) { + if (this->try_send_nodefer(message.c_str(), message.size(), "state")) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); } else { @@ -718,7 +719,7 @@ void AsyncEventSourceResponse::loop() { this->entities_iterator_.advance(); } -bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char *event, uint32_t id, +bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id, uint32_t reconnect) { if (this->fd_.load() == 0) { return false; @@ -734,6 +735,10 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char const char chunk_len_header[] = " " CRLF_STR; const int chunk_len_header_len = sizeof(chunk_len_header) - 1; + // Upper bound for a single-line message: chunk header + retry/id/event lines + "data: " prefix + // + line terminators. Avoids growth reallocations while appending; multi-line messages may still grow. + event_buffer_.reserve(message_len + 96); + event_buffer_.append(chunk_len_header); // Use stack buffer for formatting numeric fields to avoid temporary string allocations @@ -764,19 +769,18 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char // Fast path: check if message contains any newlines at all // Most SSE messages (JSON state updates) have no newlines - const char *first_n = strchr(message, '\n'); - const char *first_r = strchr(message, '\r'); + const char *first_n = static_cast(memchr(message, '\n', message_len)); + const char *first_r = static_cast(memchr(message, '\r', message_len)); if (first_n == nullptr && first_r == nullptr) { // No newlines - fast path (most common case) event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(message); + event_buffer_.append(message, message_len); event_buffer_.append(CRLF_STR CRLF_STR, CRLF_LEN * 2); // data line + blank line terminator } else { // Has newlines - handle multi-line message const char *line_start = message; - size_t msg_len = strlen(message); - const char *msg_end = message + msg_len; + const char *msg_end = message + message_len; // Reuse the first search results const char *next_n = first_n; @@ -789,7 +793,7 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char if (next_n == nullptr && next_r == nullptr) { // No more line breaks - output remaining text as final line event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(line_start); + event_buffer_.append(line_start, msg_end - line_start); event_buffer_.append(CRLF_STR, CRLF_LEN); break; } @@ -828,8 +832,8 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char } // Search for next newlines only in remaining string - next_n = strchr(line_start, '\n'); - next_r = strchr(line_start, '\r'); + next_n = static_cast(memchr(line_start, '\n', msg_end - line_start)); + next_r = static_cast(memchr(line_start, '\r', msg_end - line_start)); } // Terminate message with blank line @@ -884,7 +888,7 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e deq_push_back_with_dedup_(source, message_generator); } else { auto message = message_generator(web_server_, source); - if (!this->try_send_nodefer(message.c_str(), "state")) { + if (!this->try_send_nodefer(message.c_str(), message.size(), "state")) { deq_push_back_with_dedup_(source, message_generator); } } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index c622d53e89..c631cd1453 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -291,7 +291,8 @@ class AsyncEventSourceResponse { friend class AsyncEventSource; public: - bool try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + bool try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); void loop(); @@ -343,7 +344,8 @@ class AsyncEventSource : public AsyncWebHandler { // NOLINTNEXTLINE(readability-identifier-naming) void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } - void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); /// Returns true if there are sessions remaining (including pending cleanup). bool loop(); From 6f4a66c26062f0e990e5e4ebae931a16de0681fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Jul 2026 16:53:11 -0500 Subject: [PATCH 1974/2214] [web_server] Drop reserve, event buffer capacity persists across events --- esphome/components/web_server_idf/web_server_idf.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index eef8cf109b..cd06f80687 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -735,10 +735,6 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t mess const char chunk_len_header[] = " " CRLF_STR; const int chunk_len_header_len = sizeof(chunk_len_header) - 1; - // Upper bound for a single-line message: chunk header + retry/id/event lines + "data: " prefix - // + line terminators. Avoids growth reallocations while appending; multi-line messages may still grow. - event_buffer_.reserve(message_len + 96); - event_buffer_.append(chunk_len_header); // Use stack buffer for formatting numeric fields to avoid temporary string allocations From 8ab19c0242a8af2eb3c3d8b04bfbffa33986a9c9 Mon Sep 17 00:00:00 2001 From: Chris Boot Date: Sun, 5 Jul 2026 02:48:30 +0100 Subject: [PATCH 1975/2214] [esp32] Add RTC-backed preferences (honor in_flash flag) (#17073) Co-authored-by: Claude Opus 4.8 --- esphome/components/esp32/preference_backend.h | 7 +- esphome/components/esp32/preferences.cpp | 103 ++++++++++++ esphome/components/esp32/preferences.h | 21 ++- esphome/components/esp8266/preferences.cpp | 28 +--- esphome/components/preferences/__init__.py | 10 ++ esphome/components/safe_mode/__init__.py | 5 +- esphome/components/safe_mode/safe_mode.cpp | 6 +- esphome/components/safe_mode/safe_mode.h | 2 +- esphome/components/wifi/__init__.py | 31 +++- esphome/components/wifi/wifi_component.cpp | 8 +- esphome/const.py | 1 + esphome/core/defines.h | 2 + esphome/core/preferences_rtc.h | 54 +++++++ esphome/preferences.py | 106 +++++++++++++ script/ci-custom.py | 2 +- .../validate-rtc-storage.esp32-idf.yaml | 4 + .../safe_mode/test-rtc.esp32-idf.yaml | 4 + ...lidate-fast-connect-storage.esp32-idf.yaml | 7 + ...date-fast-connect-storage.esp8266-ard.yaml | 7 + tests/unit_tests/test_preferences.py | 149 ++++++++++++++++++ 20 files changed, 520 insertions(+), 37 deletions(-) create mode 100644 esphome/core/preferences_rtc.h create mode 100644 esphome/preferences.py create mode 100644 tests/components/preferences/validate-rtc-storage.esp32-idf.yaml create mode 100644 tests/components/safe_mode/test-rtc.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml create mode 100644 tests/unit_tests/test_preferences.py diff --git a/esphome/components/esp32/preference_backend.h b/esphome/components/esp32/preference_backend.h index 893bc35f0c..b0771b3128 100644 --- a/esphome/components/esp32/preference_backend.h +++ b/esphome/components/esp32/preference_backend.h @@ -11,8 +11,11 @@ class ESP32PreferenceBackend final { bool save(const uint8_t *data, size_t len); bool load(uint8_t *data, size_t len); - uint32_t key; - uint32_t nvs_handle; + uint32_t key{0}; + uint32_t nvs_handle{0}; // NVS (flash) path + uint16_t rtc_offset{0}; // RTC path: word offset into the RTC storage region + uint8_t length_words{0}; // RTC path: data length in 32-bit words + bool in_flash{true}; // true: store in NVS (flash); false: store in RTC memory }; class ESP32Preferences; diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 09835385ac..dc2b40455c 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -3,7 +3,10 @@ #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences_rtc.h" +#include #include +#include #include #include @@ -18,6 +21,48 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// RTC memory backend for preferences requested with in_flash=false. Survives deep sleep and +// software/CPU resets, but not power loss; integrity is guarded by a per-record checksum so +// power-on garbage is detected on load. Keep this small: RTC memory is scarce and shared. +// +// Only compiled in when USE_ESP32_RTC_PREFERENCES_STORAGE is set (see preferences.h): the storage +// buffer reserves RTC memory, so it exists only when some config option actually selected RTC +// storage AND the variant has RTC memory (the ESP32-C2 and -C61 have none, so RTC_NOINIT_ATTR would +// have no section to land in and fail to link). Otherwise in_flash=false transparently falls back +// to NVS (see make_preference below). +// +// On variants with only RTC fast memory (C3/C6/H2/P4/C5/...) RTC_NOINIT_ATTR lands in RTC fast memory. +// This is still safe: the linker reserves .rtc_noinit ahead of any RTC-fast-as-heap pool +// (CONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP), and IDF keeps the RTC fast power domain on in deep +// sleep (forced on whether or not it is used as heap), so the data is retained across both resets and +// deep sleep -- only power loss clears it. +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE +static constexpr size_t RTC_PREF_SIZE_WORDS = 64; // 256 bytes +static constexpr size_t RTC_PREF_MAX_WORDS = 255; // length_words field is a uint8_t + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static RTC_NOINIT_ATTR uint32_t s_rtc_storage[RTC_PREF_SIZE_WORDS]; + +static bool save_to_rtc(uint16_t offset, uint32_t key, uint8_t length_words, const uint8_t *data, size_t len) { + if (rtc_pref_bytes_to_words(len) != length_words) + return false; + const size_t buffer_size = static_cast(length_words) + 1; + if (static_cast(offset) + buffer_size > RTC_PREF_SIZE_WORDS) + return false; + rtc_pref_encode(&s_rtc_storage[offset], key, length_words, data, len); + return true; +} + +static bool load_from_rtc(uint16_t offset, uint32_t key, uint8_t length_words, uint8_t *data, size_t len) { + if (rtc_pref_bytes_to_words(len) != length_words) + return false; + const size_t buffer_size = static_cast(length_words) + 1; + if (static_cast(offset) + buffer_size > RTC_PREF_SIZE_WORDS) + return false; + return rtc_pref_decode(&s_rtc_storage[offset], key, length_words, data, len); +} +#endif // USE_ESP32_RTC_PREFERENCES_STORAGE + // open() runs from app_main() before the logger is initialized, so any failure // must be deferred until after global_logger is set. This is emitted from the // first make_preference() call, which runs from the generated setup() after @@ -25,6 +70,10 @@ static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-n static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!this->in_flash) + return save_to_rtc(this->rtc_offset, this->key, this->length_words, data, len); +#endif // try find in pending saves and update that for (auto &obj : s_pending_save) { if (obj.key == this->key) { @@ -41,6 +90,10 @@ bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { } bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!this->in_flash) + return load_from_rtc(this->rtc_offset, this->key, this->length_words, data, len); +#endif // try find in pending saves and load from that for (auto &obj : s_pending_save) { if (obj.key == this->key) { @@ -94,6 +147,26 @@ void ESP32Preferences::open() { } } +ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type, bool in_flash) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!in_flash) + return this->make_rtc_preference_(length, type); +#else + if (!in_flash) { + // RTC storage is not compiled in (no config option selected it), so this request + // falls back to NVS -- the historic ESP32 behavior. Warn once so callers explicitly + // asking for RTC storage can discover the fallback. + static bool warned = false; + if (!warned) { + ESP_LOGW(TAG, "RTC preference storage not compiled in; using NVS (enable with 'preferences: rtc_storage: true')"); + warned = true; + } + } +#endif + // in_flash, or RTC storage not compiled in: fall back to NVS. + return this->make_preference(length, type); +} + ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { if (s_open_err != ESP_OK) { if (this->nvs_handle == 0) { @@ -106,10 +179,34 @@ ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t ty auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; + pref->in_flash = true; return ESPPreferenceObject(pref); } +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE +ESPPreferenceObject ESP32Preferences::make_rtc_preference_(size_t length, uint32_t type) { + const uint32_t length_words = rtc_pref_bytes_to_words(length); + if (length_words > RTC_PREF_MAX_WORDS) { + ESP_LOGE(TAG, "RTC preference too large: %" PRIu32 " words", length_words); + return {}; + } + const uint32_t total_words = length_words + 1; // +1 for checksum + if (static_cast(this->current_rtc_offset_) + total_words > RTC_PREF_SIZE_WORDS) { + ESP_LOGE(TAG, "RTC preference storage full, cannot allocate %" PRIu32 " words", total_words); + return {}; + } + auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) + pref->key = type; + pref->in_flash = false; + pref->rtc_offset = this->current_rtc_offset_; + pref->length_words = static_cast(length_words); + this->current_rtc_offset_ += static_cast(total_words); + + return ESPPreferenceObject(pref); +} +#endif // USE_ESP32_RTC_PREFERENCES_STORAGE + bool ESP32Preferences::sync() { if (s_pending_save.empty()) return true; @@ -186,6 +283,12 @@ bool ESP32Preferences::is_changed_(uint32_t nvs_handle, const NVSData &to_save, bool ESP32Preferences::reset() { ESP_LOGD(TAG, "Erasing storage"); s_pending_save.clear(); +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + // Invalidate RTC-backed preferences too (checksum will no longer match). current_rtc_offset_ is + // deliberately left alone: existing backends keep pointing at their allocated slots, and reset() + // is always followed by a restart (same reason nvs_handle is zeroed below). + memset(s_rtc_storage, 0, sizeof(s_rtc_storage)); +#endif nvs_flash_deinit(); nvs_flash_erase(); diff --git a/esphome/components/esp32/preferences.h b/esphome/components/esp32/preferences.h index 0e187d87a9..864d22312b 100644 --- a/esphome/components/esp32/preferences.h +++ b/esphome/components/esp32/preferences.h @@ -2,6 +2,15 @@ #ifdef USE_ESP32 #include "esphome/core/preference_backend.h" +#include + +// RTC-backed preference storage is compiled in only when a config option actually selects it +// (USE_ESP32_RTC_PREFERENCES, emitted during code generation) and the variant has RTC memory +// (SOC_RTC_MEM_SUPPORTED; the ESP32-C2 and -C61 have none). Otherwise in_flash=false falls +// back to NVS and no RTC memory is reserved. +#if defined(USE_ESP32_RTC_PREFERENCES) && SOC_RTC_MEM_SUPPORTED +#define USE_ESP32_RTC_PREFERENCES_STORAGE +#endif namespace esphome::esp32 { @@ -11,9 +20,8 @@ class ESP32Preferences final : public PreferencesMixin { public: using PreferencesMixin::make_preference; void open(); - ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { - return this->make_preference(length, type); - } + ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash); + // Two-argument form defaults to NVS (flash) storage, preserving historic ESP32 behavior. ESPPreferenceObject make_preference(size_t length, uint32_t type); bool sync(); bool reset(); @@ -22,6 +30,13 @@ class ESP32Preferences final : public PreferencesMixin { protected: bool is_changed_(uint32_t nvs_handle, const NVSData &to_save, const char *key_str); + +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + // RTC-backed storage (in_flash=false). + ESPPreferenceObject make_rtc_preference_(size_t length, uint32_t type); + // Next free word offset in the RTC storage region (bump allocated in make_preference order). + uint16_t current_rtc_offset_{0}; +#endif }; void setup_preferences(); diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index 696f83bce1..d954ae4a0f 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -8,6 +8,7 @@ extern "C" { #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences_rtc.h" #include @@ -80,16 +81,6 @@ static uint32_t get_esp8266_flash_sector() { } static uint32_t get_esp8266_flash_address() { return get_esp8266_flash_sector() * SPI_FLASH_SEC_SIZE; } -static inline size_t bytes_to_words(size_t bytes) { return (bytes + 3) / 4; } - -template uint32_t calculate_crc(It first, It last, uint32_t type) { - uint32_t crc = type; - while (first != last) { - crc ^= (*first++ * 2654435769UL) >> 1; - } - return crc; -} - static bool save_to_flash(size_t offset, const uint32_t *data, size_t len) { for (uint32_t i = 0; i < len; i++) { uint32_t j = offset + i; @@ -137,21 +128,19 @@ static constexpr size_t PREF_MAX_BUFFER_WORDS = ESP8266_FLASH_STORAGE_SIZE > RTC_NORMAL_REGION_WORDS ? ESP8266_FLASH_STORAGE_SIZE : RTC_NORMAL_REGION_WORDS; bool ESP8266PreferenceBackend::save(const uint8_t *data, size_t len) { - if (bytes_to_words(len) != this->length_words) + if (rtc_pref_bytes_to_words(len) != this->length_words) return false; const size_t buffer_size = static_cast(this->length_words) + 1; if (buffer_size > PREF_MAX_BUFFER_WORDS) return false; uint32_t buffer[PREF_MAX_BUFFER_WORDS]; - memset(buffer, 0, buffer_size * sizeof(uint32_t)); - memcpy(buffer, data, len); - buffer[this->length_words] = calculate_crc(buffer, buffer + this->length_words, this->type); + rtc_pref_encode(buffer, this->type, this->length_words, data, len); return this->in_flash ? save_to_flash(this->offset, buffer, buffer_size) : save_to_rtc(this->offset, buffer, buffer_size); } bool ESP8266PreferenceBackend::load(uint8_t *data, size_t len) { - if (bytes_to_words(len) != this->length_words) + if (rtc_pref_bytes_to_words(len) != this->length_words) return false; const size_t buffer_size = static_cast(this->length_words) + 1; if (buffer_size > PREF_MAX_BUFFER_WORDS) @@ -161,10 +150,7 @@ bool ESP8266PreferenceBackend::load(uint8_t *data, size_t len) { : load_from_rtc(this->offset, buffer, buffer_size); if (!ret) return false; - if (buffer[this->length_words] != calculate_crc(buffer, buffer + this->length_words, this->type)) - return false; - memcpy(data, buffer, len); - return true; + return rtc_pref_decode(buffer, this->type, this->length_words, data, len); } void ESP8266Preferences::setup() { @@ -177,13 +163,13 @@ void ESP8266Preferences::setup() { } ESPPreferenceObject ESP8266Preferences::make_preference(size_t length, uint32_t type, bool in_flash) { - const uint32_t length_words = bytes_to_words(length); + const uint32_t length_words = rtc_pref_bytes_to_words(length); if (length_words > MAX_PREFERENCE_WORDS) { ESP_LOGE(TAG, "Preference too large: %u words", static_cast(length_words)); return {}; } - const uint32_t total_words = length_words + 1; // +1 for CRC + const uint32_t total_words = length_words + 1; // +1 for checksum uint16_t offset; if (in_flash) { diff --git a/esphome/components/preferences/__init__.py b/esphome/components/preferences/__init__.py index c426872728..f3f2f632c9 100644 --- a/esphome/components/preferences/__init__.py +++ b/esphome/components/preferences/__init__.py @@ -1,3 +1,4 @@ +from esphome import preferences import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID @@ -10,10 +11,17 @@ preferences_ns = cg.esphome_ns.namespace("preferences") IntervalSyncer = preferences_ns.class_("IntervalSyncer", cg.Component) CONF_FLASH_WRITE_INTERVAL = "flash_write_interval" +CONF_RTC_STORAGE = "rtc_storage" CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(IntervalSyncer), cv.Optional(CONF_FLASH_WRITE_INTERVAL, default="60s"): cv.update_interval, + # Compile the RTC-backed storage into the ESP32 preferences backend even + # when no other option selects it, so components (including external + # ones) requesting in_flash=false are honoured instead of falling back + # to NVS. No default: absence means "no request" (see + # preferences.validate_rtc_storage for the per-platform rules). + cv.Optional(CONF_RTC_STORAGE): preferences.validate_rtc_storage, } ).extend(cv.COMPONENT_SCHEMA) @@ -26,4 +34,6 @@ async def to_code(config): cg.add_define("USE_PREFERENCES_SYNC_EVERY_LOOP") else: cg.add(var.set_write_interval(write_interval)) + if config.get(CONF_RTC_STORAGE): + preferences.request_rtc_storage() await cg.register_component(var, config) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 578376258a..c11447e604 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation +from esphome import automation, preferences import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -7,6 +7,7 @@ from esphome.const import ( CONF_NUM_ATTEMPTS, CONF_REBOOT_TIMEOUT, CONF_SAFE_MODE, + CONF_STORAGE, KEY_PAST_SAFE_MODE, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -42,6 +43,7 @@ CONFIG_SCHEMA = cv.All( CONF_REBOOT_TIMEOUT, default="5min" ): cv.positive_time_period_milliseconds, cv.Optional(CONF_ON_SAFE_MODE): automation.validate_automation({}), + **preferences.storage_schema(), } ).extend(cv.COMPONENT_SCHEMA), _remove_id_if_disabled, @@ -87,6 +89,7 @@ async def to_code(config): config[CONF_NUM_ATTEMPTS], config[CONF_REBOOT_TIMEOUT], config[CONF_BOOT_IS_GOOD_AFTER], + preferences.is_in_flash(config[CONF_STORAGE]), ) cg.add(RawExpression(f"if ({condition}) return")) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 5c0047dca0..2eb1085ee5 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -162,13 +162,13 @@ bool SafeModeComponent::get_safe_mode_pending() { return this->read_rtc_() == SafeModeComponent::ENTER_SAFE_MODE_MAGIC; } -bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, - uint32_t boot_is_good_after) { +bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, + bool in_flash) { this->safe_mode_start_time_ = millis(); this->safe_mode_enable_time_ = enable_time; this->safe_mode_boot_is_good_after_ = boot_is_good_after; this->safe_mode_num_attempts_ = num_attempts; - this->rtc_ = global_preferences->make_preference(RTC_KEY, false); + this->rtc_ = global_preferences->make_preference(RTC_KEY, in_flash); #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) // Check partition state to detect if bootloader supports rollback diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 94db4357eb..d81b8a42d1 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -17,7 +17,7 @@ constexpr uint32_t RTC_KEY = 233825507UL; /// SafeModeComponent provides a safe way to recover from repeated boot failures class SafeModeComponent final : public Component { public: - bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after); + bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, bool in_flash); /// Set to true if the next startup will enter safe mode void set_safe_mode_pending(const bool &pending); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 512fd63e12..111f4cfc84 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,10 +1,10 @@ import logging import math -from esphome import automation +from esphome import automation, preferences from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_ENABLED, CONF_USE_PSRAM from esphome.components.esp32 import ( add_idf_sdkconfig_option, const, @@ -50,6 +50,7 @@ from esphome.const import ( CONF_REBOOT_TIMEOUT, CONF_SSID, CONF_STATIC_IP, + CONF_STORAGE, CONF_SUBNET, CONF_TIMEOUT, CONF_TTLS_PHASE_2, @@ -434,6 +435,22 @@ def _validate(config): CONF_PASSIVE_SCAN = "passive_scan" + +FAST_CONNECT_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + **preferences.storage_schema(), + } +) + + +def _fast_connect_schema(value): + """Accept the historic plain boolean or a dict with enabled/storage keys.""" + if isinstance(value, bool): + value = {CONF_ENABLED: value} + return FAST_CONNECT_SCHEMA(value) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -459,7 +476,7 @@ CONFIG_SCHEMA = cv.All( rtl87xx="none", ln882x="light", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), - cv.Optional(CONF_FAST_CONNECT, default=False): cv.boolean, + cv.Optional(CONF_FAST_CONNECT, default=False): _fast_connect_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, @@ -619,8 +636,14 @@ async def to_code(config): cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) if CONF_MIN_AUTH_MODE in config: cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) - if config[CONF_FAST_CONNECT]: + fast_connect = config[CONF_FAST_CONNECT] + if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") + # The storage default preserves this preference's historic location: + # ESP8266 has always used RTC memory; every other platform effectively + # used flash (the in_flash flag was previously ignored outside ESP8266). + if preferences.is_in_flash(fast_connect[CONF_STORAGE]): + cg.add_define("USE_WIFI_FAST_CONNECT_IN_FLASH") # passive_scan defaults to false in C++ - only set if true if config[CONF_PASSIVE_SCAN]: cg.add(var.set_passive_scan(True)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index ffc6ea8e14..2f6bec6bb2 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -649,7 +649,13 @@ void WiFiComponent::start() { this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT - this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, false); +#ifdef USE_WIFI_FAST_CONNECT_IN_FLASH + const bool fast_connect_in_flash = true; +#else + const bool fast_connect_in_flash = false; +#endif + this->fast_connect_pref_ = + global_preferences->make_preference(hash + 1, fast_connect_in_flash); #endif SavedWifiSettings save{}; diff --git a/esphome/const.py b/esphome/const.py index 331eb5011d..24bb4ea31f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -976,6 +976,7 @@ CONF_STEP_PIN = "step_pin" CONF_STILL_THRESHOLD = "still_threshold" CONF_STOP = "stop" CONF_STOP_ACTION = "stop_action" +CONF_STORAGE = "storage" CONF_STORE_BASELINE = "store_baseline" CONF_SUBNET = "subnet" CONF_SUBSCRIBE_QOS = "subscribe_qos" diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ff4bccc693..987e2d7a2a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -240,6 +240,7 @@ #define USE_OTA_ROLLBACK #define USE_OTA_SIGNED_VERIFICATION #define USE_ESP32_MIN_CHIP_REVISION_SET +#define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM #define USE_BLUETOOTH_PROXY @@ -300,6 +301,7 @@ #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT +#define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS diff --git a/esphome/core/preferences_rtc.h b/esphome/core/preferences_rtc.h new file mode 100644 index 0000000000..30b004f994 --- /dev/null +++ b/esphome/core/preferences_rtc.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +namespace esphome { + +// Shared storage format for word-addressable preference backends. +// +// Several platforms persist preferences as a buffer of 32-bit words followed by a +// single checksum word, seeded with the preference's `type` (its hashed key). This +// format is used for RTC user memory (ESP8266, ESP32) and for the ESP8266 +// flash-emulation buffer. The helpers here are platform independent; each backend +// supplies its own word read/write primitives and offset allocation. + +/// Round a byte count up to whole 32-bit words. +inline size_t rtc_pref_bytes_to_words(size_t bytes) { return (bytes + 3) / 4; } + +/// Compute the integrity checksum over [first, last), seeded with `type`. +/// Iterates over 32-bit words; the result is stored as the trailing word of a record. +/// (Not a true CRC -- it XORs each word after a Fibonacci-hash multiply -- but the +/// algorithm is kept as-is for compatibility with records written by old firmware.) +template uint32_t rtc_pref_calculate_checksum(It first, It last, uint32_t type) { + uint32_t checksum = type; + while (first != last) { + // UINT32_C keeps the multiply wrapping at 32 bits regardless of the width of + // unsigned long, so 64-bit host builds compute the same value as the devices. + checksum ^= (*first++ * UINT32_C(2654435769)) >> 1; + } + return checksum; +} + +/// Encode `len` data bytes into `buffer` (length_words data words + 1 trailing checksum word). +/// `buffer` must have capacity for at least `length_words + 1` words. Trailing padding in +/// the final data word is zeroed so the checksum is deterministic. +inline void rtc_pref_encode(uint32_t *buffer, uint32_t type, uint8_t length_words, const uint8_t *data, size_t len) { + memset(buffer, 0, (static_cast(length_words) + 1) * sizeof(uint32_t)); + memcpy(buffer, data, len); + buffer[length_words] = rtc_pref_calculate_checksum(buffer, buffer + length_words, type); +} + +/// Verify the checksum of a record held in `buffer` (length_words data words + 1 checksum +/// word) and, on success, copy `len` bytes out to `data`. Returns false on checksum mismatch +/// (e.g. the record was never written or RTC memory holds power-on garbage). +inline bool rtc_pref_decode(const uint32_t *buffer, uint32_t type, uint8_t length_words, uint8_t *data, size_t len) { + if (buffer[length_words] != rtc_pref_calculate_checksum(buffer, buffer + length_words, type)) { + return false; + } + memcpy(data, buffer, len); + return true; +} + +} // namespace esphome diff --git a/esphome/preferences.py b/esphome/preferences.py new file mode 100644 index 0000000000..fce8519130 --- /dev/null +++ b/esphome/preferences.py @@ -0,0 +1,106 @@ +"""Helpers for letting a component choose where a preference is persisted. + +Preferences can be stored either in flash (durable across power loss) or in RTC +memory (fast, survives deep sleep and soft resets but not power loss). The +flash-vs-RTC choice is only meaningful on platforms whose preferences backend +honors the ``in_flash`` flag — currently ESP32 and ESP8266. On other platforms +the value is accepted only as ``flash`` (the sole supported backend). + +Components include :func:`storage_schema` in their config and convert the chosen +value with :func:`is_in_flash` when calling ``make_preference``. +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_STORAGE +from esphome.core import CORE + +STORAGE_FLASH = "flash" +STORAGE_RTC = "rtc" + + +def _rtc_supported() -> bool: + """Whether the active platform has an RTC-backed preferences backend. + + Mirrors the C++ ``SOC_RTC_MEM_SUPPORTED`` guard in the ESP32 backend: the ESP32-C2 + and -C61 have no RTC memory at all, so RTC storage is unavailable there. + """ + if CORE.is_esp8266: + return True + if CORE.is_esp32: + from esphome.components.esp32 import get_esp32_variant + from esphome.components.esp32.const import VARIANT_ESP32C2, VARIANT_ESP32C61 + + return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) + return False + + +def _default_storage() -> str: + """Default that preserves each platform's historic behavior. + + ESP8266 has always stored these preferences in RTC memory; every other + platform effectively used flash. Evaluated at validation time. + """ + return STORAGE_RTC if CORE.is_esp8266 else STORAGE_FLASH + + +def _validate_storage(value): + value = cv.one_of(STORAGE_FLASH, STORAGE_RTC, lower=True)(value) + if value == STORAGE_RTC and not _rtc_supported(): + raise cv.Invalid( + f"'{STORAGE_RTC}' storage is not supported on this platform; only " + f"'{STORAGE_FLASH}' is available" + ) + return value + + +def storage_schema(): + """Return an Optional(CONF_STORAGE) entry for merging into a component schema.""" + return {cv.Optional(CONF_STORAGE, default=_default_storage): _validate_storage} + + +def request_rtc_storage() -> None: + """Compile the RTC-backed storage into the ESP32 preferences backend. + + The RTC storage region is left out of ESP32 builds unless something asks for + it, so unused builds don't reserve RTC memory. Call this from ``to_code`` + when a config option selects RTC storage. No-op on other platforms (ESP8266 + always has its RTC backend). + """ + if CORE.is_esp32: + cg.add_define("USE_ESP32_RTC_PREFERENCES") + + +def validate_rtc_storage(value): + """Validate a boolean option that requests RTC-backed preference storage. + + ``false`` means "no request", not "disable": it never turns RTC storage off + (another option selecting ``storage: rtc`` still compiles it in). On ESP8266 + the backend is integral and always enabled, so ``false`` is rejected rather + than silently ignored; ``true`` is a tolerated no-op there so shared config + packages work across mixed fleets. + """ + value = cv.boolean(value) + if not value: + if CORE.is_esp8266: + raise cv.Invalid( + "RTC preference storage is always enabled on ESP8266 and cannot " + "be disabled" + ) + return value + if not _rtc_supported(): + raise cv.Invalid("RTC preference storage is not supported on this platform") + return value + + +def is_in_flash(value: str) -> bool: + """Map a CONF_STORAGE value to the ``in_flash`` argument of make_preference. + + Call this from ``to_code``: when RTC storage is selected on ESP32 it also emits + the define that compiles the RTC storage buffer into the ESP32 backend (see + :func:`request_rtc_storage`). + """ + in_flash = value == STORAGE_FLASH + if not in_flash: + request_rtc_storage() + return in_flash diff --git a/script/ci-custom.py b/script/ci-custom.py index 4568732b88..75f4d71ba4 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -555,7 +555,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1014 +CONST_PY_MAX_CONF = 1015 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml b/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml new file mode 100644 index 0000000000..1808e09f5d --- /dev/null +++ b/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Exercises the opt-in that compiles the RTC-backed preference storage into +# the ESP32 backend without any other option selecting it. +preferences: + rtc_storage: true diff --git a/tests/components/safe_mode/test-rtc.esp32-idf.yaml b/tests/components/safe_mode/test-rtc.esp32-idf.yaml new file mode 100644 index 0000000000..113a2b6ab5 --- /dev/null +++ b/tests/components/safe_mode/test-rtc.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Exercises the ESP32 RTC-backed preferences path (storage: rtc) for safe_mode. +safe_mode: + num_attempts: 3 + storage: rtc diff --git a/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml b/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml new file mode 100644 index 0000000000..93d223b908 --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml @@ -0,0 +1,7 @@ +# Exercises the dict form of fast_connect with RTC-backed preference storage. +wifi: + ssid: MySSID + password: password1 + fast_connect: + enabled: true + storage: rtc diff --git a/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml b/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml new file mode 100644 index 0000000000..070e22fd5b --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml @@ -0,0 +1,7 @@ +# Exercises the dict form of fast_connect overriding the ESP8266 default (rtc) +# back to flash storage. +wifi: + ssid: MySSID + password: password1 + fast_connect: + storage: flash diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py new file mode 100644 index 0000000000..677eeee7f2 --- /dev/null +++ b/tests/unit_tests/test_preferences.py @@ -0,0 +1,149 @@ +"""Tests for esphome.preferences storage backend selection.""" + +import pytest + +from esphome import preferences +from esphome.components.esp32 import KEY_ESP32 +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C61, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_STORAGE, + KEY_CORE, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2040, +) +from esphome.core import CORE + + +def _set_platform(platform: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + + +def _set_esp32(variant: str) -> None: + _set_platform(PLATFORM_ESP32) + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + + +def _validate(value: dict): + return cv.Schema(preferences.storage_schema())(value) + + +def _define_names() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_is_in_flash() -> None: + _set_platform(PLATFORM_ESP8266) + assert preferences.is_in_flash(preferences.STORAGE_FLASH) is True + assert preferences.is_in_flash(preferences.STORAGE_RTC) is False + # The RTC storage define is ESP32-specific. + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_is_in_flash_esp32_rtc_emits_define() -> None: + _set_esp32(VARIANT_ESP32) + assert preferences.is_in_flash(preferences.STORAGE_FLASH) is True + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + assert preferences.is_in_flash(preferences.STORAGE_RTC) is False + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +def test_request_rtc_storage_esp32_only() -> None: + _set_platform(PLATFORM_ESP8266) + preferences.request_rtc_storage() + # ESP8266 always has its RTC backend; no define is needed or emitted. + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_request_rtc_storage_esp32_emits_define() -> None: + _set_esp32(VARIANT_ESP32) + preferences.request_rtc_storage() + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_validate_rtc_storage_accepted(variant: str) -> None: + _set_esp32(variant) + assert preferences.validate_rtc_storage(True) is True + assert preferences.validate_rtc_storage(False) is False + + +def test_validate_rtc_storage_esp8266() -> None: + _set_platform(PLATFORM_ESP8266) + # Tolerated no-op: the ESP8266 backend always has RTC storage. + assert preferences.validate_rtc_storage(True) is True + # But it cannot be disabled, so an explicit false is an error. + with pytest.raises(cv.Invalid, match="always enabled on ESP8266"): + preferences.validate_rtc_storage(False) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_validate_rtc_storage_rejected_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_rtc_storage(True) + # Disabling it is always fine. + assert preferences.validate_rtc_storage(False) is False + + +def test_validate_rtc_storage_rejected_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_rtc_storage(True) + + +@pytest.mark.parametrize( + ("platform", "expected"), + [ + # Defaults preserve each platform's historic behavior. + (PLATFORM_ESP8266, preferences.STORAGE_RTC), + (PLATFORM_RP2040, preferences.STORAGE_FLASH), + ], +) +def test_default_storage_per_platform(platform: str, expected: str) -> None: + _set_platform(platform) + assert _validate({})[CONF_STORAGE] == expected + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C2]) +def test_default_storage_esp32_is_flash(variant: str) -> None: + # ESP32 defaults to flash on every variant, including those without RTC memory. + _set_esp32(variant) + assert _validate({})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +def test_rtc_allowed_on_esp8266() -> None: + _set_platform(PLATFORM_ESP8266) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_rtc_allowed_on_esp32_with_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_rtc_rejected_on_esp32_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_rtc_rejected_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_flash_allowed_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + assert _validate({CONF_STORAGE: "flash"})[CONF_STORAGE] == preferences.STORAGE_FLASH From c720186170c45425910e1cf1d604aaef25f244bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:50:13 +0200 Subject: [PATCH 1976/2214] Bump smpclient from 6.0.0 to 7.2.0 (#16928) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 95388f278f..8b028554a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 -smpclient==6.0.0 +smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.10.0 # native esp-idf toolchain global cache dir From f6c260a2c5050902aa48ffac85102d927e89aa4b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:01:48 +1000 Subject: [PATCH 1977/2214] [ci] Make import time budget more realistic (#17406) --- script/import_time_budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/import_time_budget.json b/script/import_time_budget.json index 855d89c56d..e810817507 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", "margin_pct": 20, - "cumulative_us": 91000 + "cumulative_us": 95000 } From 105d1362a20d52ffe251a93de368bd93b625f1e4 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:31:07 -0500 Subject: [PATCH 1978/2214] Bump bundled esphome-device-builder to 1.1.0 (#17412) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b325a42436..a54bf3e79e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.29 +RUN uv pip install --no-cache-dir esphome-device-builder==1.1.0 RUN \ platformio settings set enable_telemetry No \ From b9588a898497d407be1c263dffae07542d5ed01b Mon Sep 17 00:00:00 2001 From: crimike Date: Mon, 6 Jul 2026 01:29:03 +0200 Subject: [PATCH 1979/2214] [mipi_spi] Add Waveshare-ESP32-S3-TOUCH-AMOLED-1.64 (#17386) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/mipi_spi/models/amoled.py | 4 ++++ esphome/components/mipi_spi/models/waveshare.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 32cad70ac0..30e815d68e 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -16,6 +16,7 @@ from esphome.components.mipi import ( delay, ) from esphome.components.spi import TYPE_QUAD +from esphome.config_validation import UNDEFINED DriverChip( "T-DISPLAY-S3-AMOLED", @@ -97,6 +98,9 @@ CO5300 = DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, no_slpout=True, + swap_xy=UNDEFINED, + width=480, + height=480, initsequence=( (SLPOUT,), # Requires early SLPOUT (PAGESEL, 0x00), diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 3c719b0f5e..8fc5b2acc5 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -282,3 +282,13 @@ ST7789V.extend( invert_colors=True, data_rate="40MHz", ) + +CO5300.extend( + "WAVESHARE-ESP32-S3-TOUCH-AMOLED-1.64", + width=280, + height=456, + offset_width=20, + cs_pin=9, + reset_pin=21, + enable_pin=1, +) From 3f94e6dcbbec9d3b6c9e8f83308c605b19216b4c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 6 Jul 2026 01:52:39 +0200 Subject: [PATCH 1980/2214] [nrf52] let user select libc version (#17408) --- esphome/components/nrf52/__init__.py | 13 +++++++++++++ esphome/components/zephyr/__init__.py | 2 -- tests/components/nrf52/test.nrf52-xiao-ble.yaml | 2 ++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index a5f2018d55..661fc0758e 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -200,6 +200,7 @@ DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component) CONF_DFU = "dfu" CONF_DCDC = "dcdc" +CONF_LIBC_NANO = "libc_nano" CONF_REG0 = "reg0" CONF_UICR_ERASE = "uicr_erase" @@ -248,6 +249,7 @@ CONFIG_SCHEMA = cv.All( ): cv.Schema( { cv.Optional(CONF_VERSION): cv.string_strict, + cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean, cv.Optional(CONF_ADVANCED, default={}): cv.Schema( { cv.Optional( @@ -273,6 +275,7 @@ def _validate_mcumgr(config): def _final_validate(config): + if CONF_DFU in config: _validate_mcumgr(config) if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT: @@ -283,6 +286,13 @@ def _final_validate(config): conf = config[CONF_FRAMEWORK] advanced = conf[CONF_ADVANCED] + if conf[CONF_LIBC_NANO] and "logger" in CORE.loaded_integrations: + _LOGGER.warning( + "Logger is enabled with newlib-nano (libc_nano: true). Some format specifiers " + "such as %%zu are not supported and will print incorrectly. " + "Set 'libc_nano: false' under 'framework:' to use the full newlib." + ) + if advanced[CONF_ENABLE_OTA_ROLLBACK]: # "disabled: false" means safe mode *is* enabled. safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True}) @@ -379,6 +389,9 @@ async def to_code(config: ConfigType) -> None: # Enable OTA rollback support if advanced[CONF_ENABLE_OTA_ROLLBACK]: cg.add_define("USE_OTA_ROLLBACK") + zephyr_add_prj_conf("NEWLIB_LIBC", True) + zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True) + zephyr_add_prj_conf("NEWLIB_LIBC_NANO", conf[CONF_LIBC_NANO]) # c++ support if framework_ver < cv.Version(2, 9, 2): zephyr_add_prj_conf("CPLUSPLUS", True) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index d6c45a744c..b98f94d37a 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -134,9 +134,7 @@ def zephyr_to_code(config: ConfigType) -> None: cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") # c++ support - zephyr_add_prj_conf("NEWLIB_LIBC", True) zephyr_add_prj_conf("FPU", True) - zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True) zephyr_add_prj_conf("STD_CPP20", True) # random_bytes() uses sys_rand_get() which requires the entropy subsystem zephyr_add_prj_conf("ENTROPY_GENERATOR", True) diff --git a/tests/components/nrf52/test.nrf52-xiao-ble.yaml b/tests/components/nrf52/test.nrf52-xiao-ble.yaml index de4c0c6e00..e1b5f088bb 100644 --- a/tests/components/nrf52/test.nrf52-xiao-ble.yaml +++ b/tests/components/nrf52/test.nrf52-xiao-ble.yaml @@ -2,3 +2,5 @@ nrf52: dfu: true reg0: voltage: 1.8V + framework: + libc_nano: false From 39c0f9cc848a68c18b0e4d61149ec82fcf15df36 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:57:56 +1200 Subject: [PATCH 1981/2214] [cst328] Use dict-style packages so batch grouping deduplicates the i2c bus (#17413) --- tests/components/cst328/test.esp32-idf.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/cst328/test.esp32-idf.yaml b/tests/components/cst328/test.esp32-idf.yaml index 3dc184e328..9c4594510f 100644 --- a/tests/components/cst328/test.esp32-idf.yaml +++ b/tests/components/cst328/test.esp32-idf.yaml @@ -4,5 +4,6 @@ substitutions: reset_pin: "21" packages: - - !include ../../test_build_components/common/i2c/esp32-idf.yaml - - !include common.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From e095c457ff831c36d3c2ece3f1d4148a348e6f07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jul 2026 22:02:12 -0500 Subject: [PATCH 1982/2214] [esp32] Suppress -Wvolatile in the direct ESP-IDF build (#17404) --- esphome/build_gen/espidf.py | 16 +++++++++- esphome/build_gen/platformio.py | 5 ++-- esphome/codegen.py | 1 + esphome/core/__init__.py | 9 ++++++ esphome/core/config.py | 9 ++++++ esphome/cpp_generator.py | 9 ++++++ esphome/framework_helpers.py | 7 +++++ tests/unit_tests/build_gen/test_espidf.py | 23 +++++++++++++++ tests/unit_tests/build_gen/test_platformio.py | 29 +++++++++++++++++++ tests/unit_tests/test_framework_helpers.py | 23 +++++++++++++++ 10 files changed, 127 insertions(+), 4 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index dec6ea04de..cc2fc5c4cd 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,7 +6,11 @@ from pathlib import Path from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE -from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags +from esphome.framework_helpers import ( + get_project_compile_flags, + get_project_cxx_compile_flags, + get_project_link_flags, +) from esphome.helpers import mkdir_p, write_file_if_changed # Replaces the IDF default C++ standard (-std=gnu++2b appended to @@ -91,6 +95,14 @@ def get_project_cmakelists(minimal: bool = False) -> str: for flag in project_compile_opts ) + # Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS + # (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as + # -Wno-volatile is passed on a C compile. + cxx_compile_options = "\n".join( + f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)' + for flag in get_project_cxx_compile_flags() + ) + cpp_standard_options = ( CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard) if CORE.cpp_standard @@ -155,6 +167,8 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {cpp_standard_options} +{cxx_compile_options} + {extra_compile_options} {managed_components_property} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index a583279ea7..b63c4b733d 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -108,7 +108,6 @@ Import("env") def write_cxx_flags_script() -> None: path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME) contents = CXX_FLAGS_FILE_CONTENTS - if not CORE.is_host: - contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])' - contents += "\n" + for flag in sorted(CORE.cxx_build_flags): + contents += f'env.Append(CXXFLAGS=["{flag}"])\n' write_file_if_changed(path, contents) diff --git a/esphome/codegen.py b/esphome/codegen.py index a5b5abe447..56a47d146e 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401 add, add_build_flag, add_build_unflag, + add_cxx_build_flag, add_define, add_global, add_library, diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 21ff7ef07c..89ce27a8b9 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -591,6 +591,9 @@ class EsphomeCore: self.platformio_libraries: dict[str, Library] = {} # A set of build flags to set in the platformio project self.build_flags: set[str] = set() + # A set of build flags that apply to C++ compiles only (CXXFLAGS / + # CXX_COMPILE_OPTIONS), for flags GCC rejects or warns about on C + self.cxx_build_flags: set[str] = set() # A set of build unflags to set in the platformio project self.build_unflags: set[str] = set() # The C++ language standard for the build (e.g. "gnu++20"), set via cg.set_cpp_standard() @@ -650,6 +653,7 @@ class EsphomeCore: self.global_statements = [] self.platformio_libraries = {} self.build_flags = set() + self.cxx_build_flags = set() self.build_unflags = set() self.cpp_standard = None self.defines = set() @@ -957,6 +961,11 @@ class EsphomeCore: _LOGGER.debug("Adding build flag: %s", build_flag) return build_flag + def add_cxx_build_flag(self, build_flag: str) -> str: + self.cxx_build_flags.add(build_flag) + _LOGGER.debug("Adding C++ build flag: %s", build_flag) + return build_flag + def add_build_unflag(self, build_unflag: str) -> None: if self.using_toolchain_esp_idf: # The native ESP-IDF build generator does not consume build_unflags diff --git a/esphome/core/config.py b/esphome/core/config.py index 0670fde0ff..ebad5cf165 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -723,6 +723,15 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") cg.add_build_flag("-Wno-sign-compare") + # C++20 deprecated ++/--, compound assignment, and chained assignment on + # volatile lvalues; GCC warns via -Wvolatile, on by default at gnu++20. + # C++23 (P2327R1) removed the deprecation for compound assignment, so the + # warning flags patterns that are valid again under newer standards. + # C++-only flag: GCC warns when it is passed on a C compile, hence + # add_cxx_build_flag. Skipped for host builds, where the compiler may be + # clang, which does not know this GCC option. + if not CORE.is_host: + cg.add_cxx_build_flag("-Wno-volatile") if config[CONF_DEBUG_SCHEDULER]: cg.add_define("ESPHOME_DEBUG_SCHEDULER") diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 582b8fc74d..6bcf4eed77 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -699,6 +699,15 @@ def add_build_flag(build_flag: str): CORE.add_build_flag(build_flag) +def add_cxx_build_flag(build_flag: str) -> None: + """Add a global build flag that applies to C++ compiles only. + + Use for flags GCC rejects or warns about when passed on C compiles + (e.g. ``-Wno-volatile``). + """ + CORE.add_cxx_build_flag(build_flag) + + def add_build_unflag(build_unflag: str) -> None: """Add a global build unflag to the compiler flags.""" CORE.add_build_unflag(build_unflag) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 69cecc58e2..70d440d995 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -37,6 +37,13 @@ def get_project_compile_flags() -> list[str]: ] +def get_project_cxx_compile_flags() -> list[str]: + """Return the sorted flags that apply to C++ compiles only.""" + from esphome.core import CORE # local import to avoid circular dependency + + return sorted(CORE.cxx_build_flags) + + def str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 0f4444f719..bcd9fa655a 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -243,6 +243,7 @@ def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), patch.object(CORE, "name", "test"), patch.object(CORE, "cpp_standard", None), + patch.object(CORE, "cxx_build_flags", set()), ): from esphome.build_gen.espidf import get_project_cmakelists @@ -251,6 +252,28 @@ def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: assert "CXX_COMPILE_OPTIONS" not in content +def test_get_project_cmakelists_cxx_build_flags(tmp_path: Path) -> None: + """Flags registered via cg.add_cxx_build_flag() are appended to + CXX_COMPILE_OPTIONS (C++-only, GCC warns if they reach C compiles) + between include(project.cmake) and project().""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", None), + patch.object(CORE, "cxx_build_flags", {"-Wno-volatile"}), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + flag_line = 'idf_build_set_property(CXX_COMPILE_OPTIONS "-Wno-volatile" APPEND)' + assert flag_line in content + include_pos = content.index("tools/cmake/project.cmake") + flag_pos = content.index(flag_line) + project_pos = content.index("project(test)") + assert include_pos < flag_pos < project_pos + + def test_get_component_cmakelists_no_compile_features() -> None: """The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the top-level CMakeLists; the src component must not set its own.""" diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index 2ae3836a25..3df2fb1036 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -200,3 +200,32 @@ def test_get_ini_content_no_cpp_standard( content = platformio.get_ini_content() assert "-std=" not in content + + +def test_write_cxx_flags_script_emits_registered_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Flags registered via cg.add_cxx_build_flag() are emitted as CXXFLAGS, + sorted, so they apply to C++ compiles only.""" + CORE.build_path = str(tmp_path) + monkeypatch.setattr(CORE, "cxx_build_flags", {"-Wno-volatile", "-Wno-deprecated"}) + + platformio.write_cxx_flags_script() + + content = (tmp_path / platformio.CXX_FLAGS_FILE_NAME).read_text() + assert ( + 'env.Append(CXXFLAGS=["-Wno-deprecated"])\n' + 'env.Append(CXXFLAGS=["-Wno-volatile"])\n' + ) in content + + +def test_write_cxx_flags_script_no_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + CORE.build_path = str(tmp_path) + monkeypatch.setattr(CORE, "cxx_build_flags", set()) + + platformio.write_cxx_flags_script() + + content = (tmp_path / platformio.CXX_FLAGS_FILE_NAME).read_text() + assert "CXXFLAGS" not in content diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 6fe62dcc8c..69b9f20eaa 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -26,6 +26,7 @@ from esphome.framework_helpers import ( create_venv, download_from_mirrors, get_project_compile_flags, + get_project_cxx_compile_flags, get_project_link_flags, get_python_env_executable_path, get_system_python_path, @@ -1048,3 +1049,25 @@ class TestGetProjectLinkFlags: ): result = get_project_link_flags() assert result == sorted(result) + + +def _make_core_cxx(flags: set[str]) -> MagicMock: + core = MagicMock() + core.cxx_build_flags = flags + return core + + +class TestGetProjectCxxCompileFlags: + def test_returns_sorted_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core_cxx({"-Wno-volatile", "-Wno-deprecated"}), + ): + assert get_project_cxx_compile_flags() == [ + "-Wno-deprecated", + "-Wno-volatile", + ] + + def test_empty_flags(self) -> None: + with patch("esphome.core.CORE", _make_core_cxx(set())): + assert get_project_cxx_compile_flags() == [] From ac6d2830d94ea0a8c5457705a985c51cd50a00a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jul 2026 22:49:51 -0500 Subject: [PATCH 1983/2214] [wifi] Accept boolean-like strings for fast_connect again --- esphome/components/wifi/__init__.py | 8 +++++--- .../validate-fast-connect-substitution.esp8266-ard.yaml | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 111f4cfc84..abce1fd5c0 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,5 +1,6 @@ import logging import math +from typing import Any from esphome import automation, preferences from esphome.automation import Condition @@ -444,9 +445,10 @@ FAST_CONNECT_SCHEMA = cv.Schema( ) -def _fast_connect_schema(value): - """Accept the historic plain boolean or a dict with enabled/storage keys.""" - if isinstance(value, bool): +def _fast_connect_schema(value: Any) -> ConfigType: + """Accept the historic plain boolean (including boolean-like strings from + substitutions) or a dict with enabled/storage keys.""" + if not isinstance(value, dict): value = {CONF_ENABLED: value} return FAST_CONNECT_SCHEMA(value) diff --git a/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml b/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml new file mode 100644 index 0000000000..f9fab8261a --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml @@ -0,0 +1,9 @@ +# fast_connect passed through a substitution arrives as a string ("false"), +# which must be accepted like the historic plain boolean form. +substitutions: + fast_connect_value: "false" + +wifi: + ssid: MySSID + password: password1 + fast_connect: ${fast_connect_value} From fe9d1ea4c91e3c1c0a8734cb1b791cb7a42dcdd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jul 2026 22:56:38 -0500 Subject: [PATCH 1984/2214] Prefer global names when merging aliases and keep the alias note visible under truncation --- esphome/analyze_memory/ram_strings.py | 19 +++++++--- .../analyze_memory/test_ram_strings.py | 36 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/esphome/analyze_memory/ram_strings.py b/esphome/analyze_memory/ram_strings.py index 4366668208..03da86de94 100644 --- a/esphome/analyze_memory/ram_strings.py +++ b/esphome/analyze_memory/ram_strings.py @@ -260,7 +260,14 @@ class RamStringsAnalyzer: continue if (existing := symbols_by_addr.get(addr)) is not None: - existing.aliases.append(name) + # Prefer a global (uppercase type) name as the primary so + # nm output order can't hide it behind a local alias. + if sym_type.isupper() and existing.sym_type.islower(): + existing.aliases.append(existing.name) + existing.name = name + existing.sym_type = sym_type + else: + existing.aliases.append(name) existing.size = max(existing.size, size) continue @@ -447,9 +454,13 @@ class RamStringsAnalyzer: for symbol in largest_symbols: # Use demangled name if available, otherwise raw name display_name = symbol.demangled or symbol.name - if symbol.aliases: - display_name += f" (+{len(symbol.aliases)} aliases)" - name_display = display_name[:49] if len(display_name) > 49 else display_name + # Truncate the name, not the alias note, so merged aliases stay + # visible even for long demangled C++ names. + alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else "" + max_name_len = 49 - len(alias_note) + if len(display_name) > max_name_len: + display_name = display_name[:max_name_len] + name_display = display_name + alias_note lines.append( f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}" ) diff --git a/tests/unit_tests/analyze_memory/test_ram_strings.py b/tests/unit_tests/analyze_memory/test_ram_strings.py index 33f769b881..fc0395c8be 100644 --- a/tests/unit_tests/analyze_memory/test_ram_strings.py +++ b/tests/unit_tests/analyze_memory/test_ram_strings.py @@ -78,6 +78,42 @@ def test_alias_count_shown_in_report(tmp_path) -> None: assert report.count("__lock___") == 2 +def test_global_name_preferred_over_local_alias(tmp_path) -> None: + """A global name becomes the primary even when nm lists a local first.""" + analyzer = _make_analyzer(tmp_path) + nm_output = """\ +3ffb43c8 00000054 b s_common_recursive_mutex +3ffb43c8 00000054 B __lock___atexit_recursive_mutex +3ffb43c8 00000054 B __lock___malloc_recursive_mutex +""" + _run_symbol_analysis(analyzer, nm_output) + + (symbol,) = analyzer.ram_symbols + assert symbol.name == "__lock___atexit_recursive_mutex" + assert symbol.sym_type == "B" + assert sorted(symbol.aliases) == [ + "__lock___malloc_recursive_mutex", + "s_common_recursive_mutex", + ] + + +def test_alias_note_survives_name_truncation(tmp_path) -> None: + """Long names are truncated but the alias note is kept intact.""" + analyzer = _make_analyzer(tmp_path) + long_name = "a_very_long_symbol_name_that_exceeds_the_column_width_by_far" + nm_output = f"""\ +3ffb43c8 00000054 B {long_name} +3ffb43c8 00000054 B other_name +""" + _run_symbol_analysis(analyzer, nm_output) + + report = analyzer.generate_report() + row = next(line for line in report.splitlines() if "(+1 aliases)" in line) + name_column = row[:50].rstrip() + assert name_column.endswith("(+1 aliases)") + assert name_column.startswith("a_very_long_symbol_name") + + def test_symbols_outside_ram_sections_skipped(tmp_path) -> None: """Symbols outside known RAM sections are ignored entirely.""" analyzer = _make_analyzer(tmp_path) From b10514c1a8f264add0b2bb8a33efdd10cd40dd95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jul 2026 22:57:21 -0500 Subject: [PATCH 1985/2214] Type the tmp_path test fixture --- tests/unit_tests/analyze_memory/test_ram_strings.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/unit_tests/analyze_memory/test_ram_strings.py b/tests/unit_tests/analyze_memory/test_ram_strings.py index fc0395c8be..dda793a7f1 100644 --- a/tests/unit_tests/analyze_memory/test_ram_strings.py +++ b/tests/unit_tests/analyze_memory/test_ram_strings.py @@ -1,5 +1,6 @@ """Tests for RAM symbol analysis in the RAM strings analyzer.""" +from pathlib import Path from unittest.mock import patch from esphome.analyze_memory.ram_strings import RamStringsAnalyzer, SectionInfo @@ -43,7 +44,7 @@ def _run_symbol_analysis(analyzer: RamStringsAnalyzer, nm_output: str) -> None: analyzer._analyze_symbols() -def test_aliased_symbols_counted_once(tmp_path) -> None: +def test_aliased_symbols_counted_once(tmp_path: Path) -> None: """Symbols sharing an address are one object, not one per name.""" analyzer = _make_analyzer(tmp_path) _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) @@ -54,7 +55,7 @@ def test_aliased_symbols_counted_once(tmp_path) -> None: assert total == 0x10 + 0x54 + 0x54 -def test_aliases_recorded_on_first_symbol(tmp_path) -> None: +def test_aliases_recorded_on_first_symbol(tmp_path: Path) -> None: """Extra names at the same address are kept as aliases.""" analyzer = _make_analyzer(tmp_path) _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) @@ -66,7 +67,7 @@ def test_aliases_recorded_on_first_symbol(tmp_path) -> None: assert "s_common_mutex" in by_addr[0x3FFB441C].aliases -def test_alias_count_shown_in_report(tmp_path) -> None: +def test_alias_count_shown_in_report(tmp_path: Path) -> None: """The large symbols table notes how many aliases were merged.""" analyzer = _make_analyzer(tmp_path) _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) @@ -78,7 +79,7 @@ def test_alias_count_shown_in_report(tmp_path) -> None: assert report.count("__lock___") == 2 -def test_global_name_preferred_over_local_alias(tmp_path) -> None: +def test_global_name_preferred_over_local_alias(tmp_path: Path) -> None: """A global name becomes the primary even when nm lists a local first.""" analyzer = _make_analyzer(tmp_path) nm_output = """\ @@ -97,7 +98,7 @@ def test_global_name_preferred_over_local_alias(tmp_path) -> None: ] -def test_alias_note_survives_name_truncation(tmp_path) -> None: +def test_alias_note_survives_name_truncation(tmp_path: Path) -> None: """Long names are truncated but the alias note is kept intact.""" analyzer = _make_analyzer(tmp_path) long_name = "a_very_long_symbol_name_that_exceeds_the_column_width_by_far" @@ -114,7 +115,7 @@ def test_alias_note_survives_name_truncation(tmp_path) -> None: assert name_column.startswith("a_very_long_symbol_name") -def test_symbols_outside_ram_sections_skipped(tmp_path) -> None: +def test_symbols_outside_ram_sections_skipped(tmp_path: Path) -> None: """Symbols outside known RAM sections are ignored entirely.""" analyzer = _make_analyzer(tmp_path) nm_output = "40080000 00000100 B not_in_ram\n" From fd86417bf56a587aefae62adb82ab527db35cf64 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:29:45 +1200 Subject: [PATCH 1986/2214] [cst328] Update test package (#17415) --- tests/components/cst328/test.esp32-idf.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/cst328/test.esp32-idf.yaml b/tests/components/cst328/test.esp32-idf.yaml index 9c4594510f..ac4ad140a8 100644 --- a/tests/components/cst328/test.esp32-idf.yaml +++ b/tests/components/cst328/test.esp32-idf.yaml @@ -5,5 +5,4 @@ substitutions: packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + cst328: !include common.yaml From af7b6e35895bca7d8b92951a374e7f4da2ffa243 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:32:26 +1200 Subject: [PATCH 1987/2214] Mark configurable classes as final (17/21: ssd1351_spi-tem3200) (#16968) --- esphome/components/ssd1351_spi/ssd1351_spi.h | 6 +++--- esphome/components/st7567_i2c/st7567_i2c.h | 2 +- esphome/components/st7567_spi/st7567_spi.h | 6 +++--- esphome/components/st7701s/st7701s.h | 6 +++--- esphome/components/st7735/st7735.h | 6 +++--- esphome/components/st7789v/st7789v.h | 6 +++--- esphome/components/st7920/st7920.h | 6 +++--- esphome/components/statsd/statsd.h | 2 +- .../components/status/status_binary_sensor.h | 2 +- .../status_led/light/status_led_light.h | 2 +- esphome/components/status_led/status_led.h | 2 +- esphome/components/stepper/stepper.h | 10 +++++----- esphome/components/sts3x/sts3x.h | 4 +++- esphome/components/stts22h/stts22h.h | 2 +- esphome/components/sun/sensor/sun_sensor.h | 2 +- esphome/components/sun/sun.h | 6 +++--- .../sun/text_sensor/sun_text_sensor.h | 2 +- esphome/components/sun_gtil2/sun_gtil2.h | 2 +- esphome/components/switch/automation.h | 18 +++++++++--------- .../binary_sensor/switch_binary_sensor.h | 2 +- esphome/components/sx126x/automation.h | 12 ++++++------ .../sx126x/packet_transport/sx126x_transport.h | 2 +- esphome/components/sx126x/sx126x.h | 6 +++--- esphome/components/sx127x/automation.h | 12 ++++++------ .../sx127x/packet_transport/sx127x_transport.h | 2 +- esphome/components/sx127x/sx127x.h | 6 +++--- .../sx1509_binary_keypad_sensor.h | 2 +- .../sx1509/output/sx1509_float_output.h | 2 +- esphome/components/sx1509/sx1509.h | 10 +++++----- esphome/components/sx1509/sx1509_gpio_pin.h | 2 +- .../binary_sensor/sy6970_binary_sensor.h | 4 ++-- .../components/sy6970/sensor/sy6970_sensor.h | 2 +- esphome/components/sy6970/sy6970.h | 2 +- .../sy6970/text_sensor/sy6970_text_sensor.h | 6 +++--- esphome/components/syslog/esphome_syslog.h | 2 +- esphome/components/t6615/t6615.h | 2 +- esphome/components/tc74/tc74.h | 2 +- esphome/components/tca9548a/tca9548a.h | 4 ++-- esphome/components/tca9555/tca9555.h | 8 ++++---- esphome/components/tcl112/tcl112.h | 2 +- esphome/components/tcs34725/tcs34725.h | 2 +- esphome/components/tee501/tee501.h | 2 +- .../teleinfo/sensor/teleinfo_sensor.h | 2 +- esphome/components/teleinfo/teleinfo.h | 2 +- .../text_sensor/teleinfo_text_sensor.h | 2 +- esphome/components/tem3200/tem3200.h | 2 +- 46 files changed, 99 insertions(+), 97 deletions(-) diff --git a/esphome/components/ssd1351_spi/ssd1351_spi.h b/esphome/components/ssd1351_spi/ssd1351_spi.h index 5ce41c1f9e..307807d19f 100644 --- a/esphome/components/ssd1351_spi/ssd1351_spi.h +++ b/esphome/components/ssd1351_spi/ssd1351_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1351_spi { -class SPISSD1351 : public ssd1351_base::SSD1351, - public spi::SPIDevice { +class SPISSD1351 final : public ssd1351_base::SSD1351, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/st7567_i2c/st7567_i2c.h b/esphome/components/st7567_i2c/st7567_i2c.h index 49489d79e6..eea3068e03 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.h +++ b/esphome/components/st7567_i2c/st7567_i2c.h @@ -6,7 +6,7 @@ namespace esphome::st7567_i2c { -class I2CST7567 : public st7567_base::ST7567, public i2c::I2CDevice { +class I2CST7567 final : public st7567_base::ST7567, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/st7567_spi/st7567_spi.h b/esphome/components/st7567_spi/st7567_spi.h index fb6f9501a9..e4699437ad 100644 --- a/esphome/components/st7567_spi/st7567_spi.h +++ b/esphome/components/st7567_spi/st7567_spi.h @@ -6,9 +6,9 @@ namespace esphome::st7567_spi { -class SPIST7567 : public st7567_base::ST7567, - public spi::SPIDevice { +class SPIST7567 final : public st7567_base::ST7567, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index c65a213929..d44f8c6859 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -26,9 +26,9 @@ const uint8_t CMD2_BKSEL = 0xFF; const uint8_t CMD2_BK0[5] = {0x77, 0x01, 0x00, 0x00, 0x10}; const uint8_t ST7701S_DELAY_FLAG = 0xFF; -class ST7701S : public display::Display, - public spi::SPIDevice { +class ST7701S final : public display::Display, + public spi::SPIDevice { public: void update() override { this->do_update_(); } void setup() override; diff --git a/esphome/components/st7735/st7735.h b/esphome/components/st7735/st7735.h index 7fa0ad7335..28bc0916f9 100644 --- a/esphome/components/st7735/st7735.h +++ b/esphome/components/st7735/st7735.h @@ -31,9 +31,9 @@ enum ST7735Model { ST7735_INITR_18REDTAB = INITR_18REDTAB }; -class ST7735 : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7735 final : public display::DisplayBuffer, + public spi::SPIDevice { public: ST7735(ST7735Model model, int width, int height, int colstart, int rowstart, bool eightbitcolor, bool usebgr, bool invert_colors); diff --git a/esphome/components/st7789v/st7789v.h b/esphome/components/st7789v/st7789v.h index 3f9942b117..1b7ba318a6 100644 --- a/esphome/components/st7789v/st7789v.h +++ b/esphome/components/st7789v/st7789v.h @@ -106,9 +106,9 @@ static const uint8_t ST7789_MADCTL_GS = 0x01; static const uint8_t ST7789_MADCTL_COLOR_ORDER = ST7789_MADCTL_BGR; -class ST7789V : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7789V final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_model_str(const char *model_str); void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } diff --git a/esphome/components/st7920/st7920.h b/esphome/components/st7920/st7920.h index 71fe7aa89c..0160c5270f 100644 --- a/esphome/components/st7920/st7920.h +++ b/esphome/components/st7920/st7920.h @@ -10,9 +10,9 @@ class ST7920; using st7920_writer_t = display::DisplayWriter; -class ST7920 : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7920 final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_writer(st7920_writer_t &&writer) { this->writer_local_ = writer; } void set_height(uint16_t height) { this->height_ = height; } diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 77f3d797c5..7cbde6d743 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -27,7 +27,7 @@ namespace esphome::statsd { -class StatsdComponent : public PollingComponent { +class StatsdComponent final : public PollingComponent { public: ~StatsdComponent(); diff --git a/esphome/components/status/status_binary_sensor.h b/esphome/components/status/status_binary_sensor.h index 7e8c31d741..28cf4cd083 100644 --- a/esphome/components/status/status_binary_sensor.h +++ b/esphome/components/status/status_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::status { -class StatusBinarySensor : public binary_sensor::BinarySensor, public PollingComponent { +class StatusBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: void update() override; diff --git a/esphome/components/status_led/light/status_led_light.h b/esphome/components/status_led/light/status_led_light.h index 0483669d0a..5eb0d3c085 100644 --- a/esphome/components/status_led/light/status_led_light.h +++ b/esphome/components/status_led/light/status_led_light.h @@ -7,7 +7,7 @@ namespace esphome::status_led { -class StatusLEDLightOutput : public light::LightOutput, public Component { +class StatusLEDLightOutput final : public light::LightOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } void set_output(output::BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index bda144d2cd..3688dba8d6 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -5,7 +5,7 @@ namespace esphome::status_led { -class StatusLED : public Component { +class StatusLED final : public Component { public: explicit StatusLED(GPIOPin *pin); diff --git a/esphome/components/stepper/stepper.h b/esphome/components/stepper/stepper.h index 9fbd0d92e6..06ef3bab37 100644 --- a/esphome/components/stepper/stepper.h +++ b/esphome/components/stepper/stepper.h @@ -37,7 +37,7 @@ class Stepper { uint32_t last_step_{0}; }; -template class SetTargetAction : public Action { +template class SetTargetAction final : public Action { public: explicit SetTargetAction(Stepper *parent) : parent_(parent) {} @@ -49,7 +49,7 @@ template class SetTargetAction : public Action { Stepper *parent_; }; -template class ReportPositionAction : public Action { +template class ReportPositionAction final : public Action { public: explicit ReportPositionAction(Stepper *parent) : parent_(parent) {} @@ -61,7 +61,7 @@ template class ReportPositionAction : public Action { Stepper *parent_; }; -template class SetSpeedAction : public Action { +template class SetSpeedAction final : public Action { public: explicit SetSpeedAction(Stepper *parent) : parent_(parent) {} @@ -77,7 +77,7 @@ template class SetSpeedAction : public Action { Stepper *parent_; }; -template class SetAccelerationAction : public Action { +template class SetAccelerationAction final : public Action { public: explicit SetAccelerationAction(Stepper *parent) : parent_(parent) {} @@ -92,7 +92,7 @@ template class SetAccelerationAction : public Action { Stepper *parent_; }; -template class SetDecelerationAction : public Action { +template class SetDecelerationAction final : public Action { public: explicit SetDecelerationAction(Stepper *parent) : parent_(parent) {} diff --git a/esphome/components/sts3x/sts3x.h b/esphome/components/sts3x/sts3x.h index 038fa0dd80..6752cf689b 100644 --- a/esphome/components/sts3x/sts3x.h +++ b/esphome/components/sts3x/sts3x.h @@ -9,7 +9,9 @@ namespace esphome::sts3x { /// This class implements support for the ST3x-DIS family of temperature i2c sensors. -class STS3XComponent : public sensor::Sensor, public PollingComponent, public sensirion_common::SensirionI2CDevice { +class STS3XComponent final : public sensor::Sensor, + public PollingComponent, + public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/stts22h/stts22h.h b/esphome/components/stts22h/stts22h.h index 442a263e49..d8d7a485cf 100644 --- a/esphome/components/stts22h/stts22h.h +++ b/esphome/components/stts22h/stts22h.h @@ -6,7 +6,7 @@ namespace esphome::stts22h { -class STTS22HComponent : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class STTS22HComponent final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/sun/sensor/sun_sensor.h b/esphome/components/sun/sensor/sun_sensor.h index 148e5297d9..bec1a1af67 100644 --- a/esphome/components/sun/sensor/sun_sensor.h +++ b/esphome/components/sun/sensor/sun_sensor.h @@ -11,7 +11,7 @@ enum SensorType { SUN_SENSOR_AZIMUTH, }; -class SunSensor : public sensor::Sensor, public PollingComponent { +class SunSensor final : public sensor::Sensor, public PollingComponent { public: void set_parent(Sun *parent) { parent_ = parent; } void set_type(SensorType type) { type_ = type; } diff --git a/esphome/components/sun/sun.h b/esphome/components/sun/sun.h index 2999c93c71..ea9e05042d 100644 --- a/esphome/components/sun/sun.h +++ b/esphome/components/sun/sun.h @@ -51,7 +51,7 @@ struct HorizontalCoordinate { } // namespace internal -class Sun { +class Sun final { public: void set_time(time::RealTimeClock *time) { time_ = time; } time::RealTimeClock *get_time() const { return time_; } @@ -78,7 +78,7 @@ class Sun { internal::GeoLocation location_; }; -class SunTrigger : public Trigger<>, public PollingComponent, public Parented { +class SunTrigger final : public Trigger<>, public PollingComponent, public Parented { public: SunTrigger() : PollingComponent(60000) {} @@ -109,7 +109,7 @@ class SunTrigger : public Trigger<>, public PollingComponent, public Parented class SunCondition : public Condition, public Parented { +template class SunCondition final : public Condition, public Parented { public: TEMPLATABLE_VALUE(double, elevation); void set_above(bool above) { above_ = above; } diff --git a/esphome/components/sun/text_sensor/sun_text_sensor.h b/esphome/components/sun/text_sensor/sun_text_sensor.h index 65b0e358d0..a247a95e06 100644 --- a/esphome/components/sun/text_sensor/sun_text_sensor.h +++ b/esphome/components/sun/text_sensor/sun_text_sensor.h @@ -8,7 +8,7 @@ namespace esphome::sun { -class SunTextSensor : public text_sensor::TextSensor, public PollingComponent { +class SunTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: void set_parent(Sun *parent) { parent_ = parent; } void set_elevation(double elevation) { elevation_ = elevation; } diff --git a/esphome/components/sun_gtil2/sun_gtil2.h b/esphome/components/sun_gtil2/sun_gtil2.h index e774fefcf8..dc3516f2b5 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.h +++ b/esphome/components/sun_gtil2/sun_gtil2.h @@ -15,7 +15,7 @@ namespace esphome::sun_gtil2 { -class SunGTIL2 : public Component, public uart::UARTDevice { +class SunGTIL2 final : public Component, public uart::UARTDevice { public: float get_setup_priority() const override { return setup_priority::LATE; } void setup() override; diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index ed1f056c8b..158fb08baf 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -6,7 +6,7 @@ namespace esphome::switch_ { -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: explicit TurnOnAction(Switch *a_switch) : switch_(a_switch) {} @@ -16,7 +16,7 @@ template class TurnOnAction : public Action { Switch *switch_; }; -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: explicit TurnOffAction(Switch *a_switch) : switch_(a_switch) {} @@ -26,7 +26,7 @@ template class TurnOffAction : public Action { Switch *switch_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Switch *a_switch) : switch_(a_switch) {} @@ -36,7 +36,7 @@ template class ToggleAction : public Action { Switch *switch_; }; -template class ControlAction : public Action { +template class ControlAction final : public Action { public: explicit ControlAction(Switch *a_switch) : switch_(a_switch) {} @@ -53,7 +53,7 @@ template class ControlAction : public Action { Switch *switch_; }; -template class SwitchCondition : public Condition { +template class SwitchCondition final : public Condition { public: SwitchCondition(Switch *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { return this->parent_->state == this->state_; } @@ -63,14 +63,14 @@ template class SwitchCondition : public Condition { bool state_; }; -class SwitchStateTrigger : public Trigger { +class SwitchStateTrigger final : public Trigger { public: SwitchStateTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { this->trigger(state); }); } }; -class SwitchTurnOnTrigger : public Trigger<> { +class SwitchTurnOnTrigger final : public Trigger<> { public: SwitchTurnOnTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { @@ -81,7 +81,7 @@ class SwitchTurnOnTrigger : public Trigger<> { } }; -class SwitchTurnOffTrigger : public Trigger<> { +class SwitchTurnOffTrigger final : public Trigger<> { public: SwitchTurnOffTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { @@ -92,7 +92,7 @@ class SwitchTurnOffTrigger : public Trigger<> { } }; -template class SwitchPublishAction : public Action { +template class SwitchPublishAction final : public Action { public: SwitchPublishAction(Switch *a_switch) : switch_(a_switch) {} TEMPLATABLE_VALUE(bool, state) diff --git a/esphome/components/switch/binary_sensor/switch_binary_sensor.h b/esphome/components/switch/binary_sensor/switch_binary_sensor.h index 0b77cdd920..5c4184ecfa 100644 --- a/esphome/components/switch/binary_sensor/switch_binary_sensor.h +++ b/esphome/components/switch/binary_sensor/switch_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::switch_ { -class SwitchBinarySensor : public binary_sensor::BinarySensor, public Component { +class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_source(Switch *source) { source_ = source; } void setup() override; diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index 2721cbfbbf..4eb33abaa1 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -6,12 +6,12 @@ namespace esphome::sx126x { -template class RunImageCalAction : public Action, public Parented { +template class RunImageCalAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->run_image_cal(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -43,23 +43,23 @@ template class SendPacketAction : public Action, public P } data_; }; -template class SetModeTxAction : public Action, public Parented { +template class SetModeTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_tx(); } }; -template class SetModeRxAction : public Action, public Parented { +template class SetModeRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_rx(); } }; -template class SetModeSleepAction : public Action, public Parented { +template class SetModeSleepAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, cold) void play(const Ts &...x) override { this->parent_->set_mode_sleep(this->cold_.value(x...)); } }; -template class SetModeStandbyAction : public Action, public Parented { +template class SetModeStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_standby(STDBY_XOSC); } }; diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.h b/esphome/components/sx126x/packet_transport/sx126x_transport.h index 7590e35c28..ccd20755e5 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.h +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.h @@ -7,7 +7,7 @@ namespace esphome::sx126x { -class SX126xTransport : public packet_transport::PacketTransport, public Parented, public SX126xListener { +class SX126xTransport final : public packet_transport::PacketTransport, public Parented, public SX126xListener { public: void setup() override; void on_packet(const std::vector &packet, float rssi, float snr) override; diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 6816084df0..b3dfe6590a 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -53,9 +53,9 @@ class SX126xListener { virtual void on_packet(const std::vector &packet, float rssi, float snr) = 0; }; -class SX126x : public Component, - public spi::SPIDevice { +class SX126x final : public Component, + public spi::SPIDevice { public: size_t get_max_packet_size(); float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h index 7a2eb7ee8d..f6a4537e23 100644 --- a/esphome/components/sx127x/automation.h +++ b/esphome/components/sx127x/automation.h @@ -6,12 +6,12 @@ namespace esphome::sx127x { -template class RunImageCalAction : public Action, public Parented { +template class RunImageCalAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->run_image_cal(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -43,22 +43,22 @@ template class SendPacketAction : public Action, public P } data_; }; -template class SetModeTxAction : public Action, public Parented { +template class SetModeTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_tx(); } }; -template class SetModeRxAction : public Action, public Parented { +template class SetModeRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_rx(); } }; -template class SetModeSleepAction : public Action, public Parented { +template class SetModeSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_sleep(); } }; -template class SetModeStandbyAction : public Action, public Parented { +template class SetModeStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_standby(); } }; diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.h b/esphome/components/sx127x/packet_transport/sx127x_transport.h index 5dcfe02c33..fb38fc15bc 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.h +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.h @@ -7,7 +7,7 @@ namespace esphome::sx127x { -class SX127xTransport : public packet_transport::PacketTransport, public Parented, public SX127xListener { +class SX127xTransport final : public packet_transport::PacketTransport, public Parented, public SX127xListener { public: void setup() override; void on_packet(const std::vector &packet, float rssi, float snr) override; diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 376c987ed1..070a6eeb96 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -41,9 +41,9 @@ class SX127xListener { virtual void on_packet(const std::vector &packet, float rssi, float snr) = 0; }; -class SX127x : public Component, - public spi::SPIDevice { +class SX127x final : public Component, + public spi::SPIDevice { public: size_t get_max_packet_size(); float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h b/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h index bcd8901530..5d26a37283 100644 --- a/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h +++ b/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h @@ -5,7 +5,7 @@ namespace esphome::sx1509 { -class SX1509BinarySensor : public sx1509::SX1509Processor, public binary_sensor::BinarySensor { +class SX1509BinarySensor final : public sx1509::SX1509Processor, public binary_sensor::BinarySensor { public: void set_row_col(uint8_t row, uint8_t col) { this->key_ = (1 << (col + 8)) | (1 << row); } void process(uint16_t data) override { this->publish_state(static_cast(data == key_)); } diff --git a/esphome/components/sx1509/output/sx1509_float_output.h b/esphome/components/sx1509/output/sx1509_float_output.h index ee53cef637..8790b2fcd7 100644 --- a/esphome/components/sx1509/output/sx1509_float_output.h +++ b/esphome/components/sx1509/output/sx1509_float_output.h @@ -7,7 +7,7 @@ namespace esphome::sx1509 { class SX1509Component; -class SX1509FloatOutputChannel : public output::FloatOutput, public Component { +class SX1509FloatOutputChannel final : public output::FloatOutput, public Component { public: void set_parent(SX1509Component *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index 35883eed5b..c7aed2cddd 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -28,12 +28,12 @@ class SX1509Processor { virtual void process(uint16_t data){}; }; -class SX1509KeyTrigger : public Trigger {}; +class SX1509KeyTrigger final : public Trigger {}; -class SX1509Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander, - public key_provider::KeyProvider { +class SX1509Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander, + public key_provider::KeyProvider { public: SX1509Component() = default; diff --git a/esphome/components/sx1509/sx1509_gpio_pin.h b/esphome/components/sx1509/sx1509_gpio_pin.h index 9dcad37b27..3bd3d90bd9 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.h +++ b/esphome/components/sx1509/sx1509_gpio_pin.h @@ -6,7 +6,7 @@ namespace esphome::sx1509 { class SX1509Component; -class SX1509GPIOPin : public GPIOPin { +class SX1509GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h b/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h index 4a374d7e3d..b94c89d123 100644 --- a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h +++ b/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sy6970 { template -class StatusBinarySensor : public SY6970Listener, public binary_sensor::BinarySensor { +class StatusBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { public: void on_data(const SY6970Data &data) override { uint8_t value = (data.registers[REG] >> SHIFT) & MASK; @@ -24,7 +24,7 @@ class InverseStatusBinarySensor : public SY6970Listener, public binary_sensor::B }; // Custom binary sensor for charging (true when pre-charge or fast charge) -class SY6970ChargingBinarySensor : public SY6970Listener, public binary_sensor::BinarySensor { +class SY6970ChargingBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { public: void on_data(const SY6970Data &data) override { uint8_t chrg_stat = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; diff --git a/esphome/components/sy6970/sensor/sy6970_sensor.h b/esphome/components/sy6970/sensor/sy6970_sensor.h index f912d726b2..61abbc3e36 100644 --- a/esphome/components/sy6970/sensor/sy6970_sensor.h +++ b/esphome/components/sy6970/sensor/sy6970_sensor.h @@ -34,7 +34,7 @@ using SY6970SystemVoltageSensor = VoltageSensor; // Precharge current sensor needs special handling (bit shift) -class SY6970PrechargeCurrentSensor : public SY6970Listener, public sensor::Sensor { +class SY6970PrechargeCurrentSensor final : public SY6970Listener, public sensor::Sensor { public: void on_data(const SY6970Data &data) override { uint8_t iprechg = (data.registers[SY6970_REG_PRECHARGE_CURRENT] >> 4) & 0x0F; diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index 2225dd781b..06f0615ab4 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -73,7 +73,7 @@ class SY6970Listener { virtual void on_data(const SY6970Data &data) = 0; }; -class SY6970Component : public PollingComponent, public i2c::I2CDevice { +class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current, uint16_t precharge_current, bool charge_enabled, bool enable_adc) diff --git a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h b/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h index 665c5eca64..e569bd0b90 100644 --- a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h +++ b/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sy6970 { // Bus status text sensor -class SY6970BusStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970BusStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = (data.registers[SY6970_REG_STATUS] >> 5) & 0x07; @@ -40,7 +40,7 @@ class SY6970BusStatusTextSensor : public SY6970Listener, public text_sensor::Tex }; // Charge status text sensor -class SY6970ChargeStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970ChargeStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; @@ -66,7 +66,7 @@ class SY6970ChargeStatusTextSensor : public SY6970Listener, public text_sensor:: }; // NTC status text sensor -class SY6970NtcStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970NtcStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = data.registers[SY6970_REG_FAULT] & 0x07; diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index be4fa91436..4a76f9ac62 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -7,7 +7,7 @@ #ifdef USE_NETWORK namespace esphome::syslog { -class Syslog : public Component, public Parented { +class Syslog final : public Component, public Parented { public: Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} void setup() override; diff --git a/esphome/components/t6615/t6615.h b/esphome/components/t6615/t6615.h index 0c2088f7b0..7ad2ae23c7 100644 --- a/esphome/components/t6615/t6615.h +++ b/esphome/components/t6615/t6615.h @@ -19,7 +19,7 @@ enum class T6615Command : uint8_t { SET_ELEVATION, }; -class T6615Component : public PollingComponent, public uart::UARTDevice { +class T6615Component final : public PollingComponent, public uart::UARTDevice { public: void loop() override; void update() override; diff --git a/esphome/components/tc74/tc74.h b/esphome/components/tc74/tc74.h index 4a53f39bc1..c48303c009 100644 --- a/esphome/components/tc74/tc74.h +++ b/esphome/components/tc74/tc74.h @@ -6,7 +6,7 @@ namespace esphome::tc74 { -class TC74Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TC74Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: /// Setup the sensor and check connection. void setup() override; diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index f0417ac7f7..a98c226d32 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -8,7 +8,7 @@ namespace esphome::tca9548a { static const uint8_t TCA9548A_DISABLE_CHANNELS_COMMAND = 0x00; class TCA9548AComponent; -class TCA9548AChannel : public i2c::I2CBus { +class TCA9548AChannel final : public i2c::I2CBus { public: void set_channel(uint8_t channel) { channel_ = channel; } void set_parent(TCA9548AComponent *parent) { parent_ = parent; } @@ -21,7 +21,7 @@ class TCA9548AChannel : public i2c::I2CBus { TCA9548AComponent *parent_; }; -class TCA9548AComponent : public Component, public i2c::I2CDevice { +class TCA9548AComponent final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 19773a0e93..50037cbe92 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -7,9 +7,9 @@ namespace esphome::tca9555 { -class TCA9555Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class TCA9555Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: TCA9555Component() = default; @@ -47,7 +47,7 @@ class TCA9555Component : public Component, }; /// Helper class to expose a TCA9555 pin as an internal input GPIO pin. -class TCA9555GPIOPin : public GPIOPin, public Parented { +class TCA9555GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/tcl112/tcl112.h b/esphome/components/tcl112/tcl112.h index 0aef2decc8..21eb618947 100644 --- a/esphome/components/tcl112/tcl112.h +++ b/esphome/components/tcl112/tcl112.h @@ -8,7 +8,7 @@ namespace esphome::tcl112 { const float TCL112_TEMP_MAX = 31.0; const float TCL112_TEMP_MIN = 16.0; -class Tcl112Climate : public climate_ir::ClimateIR { +class Tcl112Climate final : public climate_ir::ClimateIR { public: Tcl112Climate() : climate_ir::ClimateIR(TCL112_TEMP_MIN, TCL112_TEMP_MAX, .5f, true, true, diff --git a/esphome/components/tcs34725/tcs34725.h b/esphome/components/tcs34725/tcs34725.h index 15e4fae52f..79b49bc810 100644 --- a/esphome/components/tcs34725/tcs34725.h +++ b/esphome/components/tcs34725/tcs34725.h @@ -35,7 +35,7 @@ enum TCS34725Gain { TCS34725_GAIN_60X = 0x03, }; -class TCS34725Component : public PollingComponent, public i2c::I2CDevice { +class TCS34725Component final : public PollingComponent, public i2c::I2CDevice { public: void set_integration_time(TCS34725IntegrationTime integration_time); void set_gain(TCS34725Gain gain); diff --git a/esphome/components/tee501/tee501.h b/esphome/components/tee501/tee501.h index 4a08291318..bbd63a4e2b 100644 --- a/esphome/components/tee501/tee501.h +++ b/esphome/components/tee501/tee501.h @@ -7,7 +7,7 @@ namespace esphome::tee501 { /// This class implements support for the tee501 of temperature i2c sensors. -class TEE501Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TEE501Component final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.h b/esphome/components/teleinfo/sensor/teleinfo_sensor.h index 37736c4e73..f4a27fa08b 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.h +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.h @@ -4,7 +4,7 @@ namespace esphome::teleinfo { -class TeleInfoSensor : public TeleInfoListener, public sensor::Sensor, public Component { +class TeleInfoSensor final : public TeleInfoListener, public sensor::Sensor, public Component { public: TeleInfoSensor(const char *tag); void publish_val(const std::string &val) override; diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index eeab3b5103..83ea1474f2 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -20,7 +20,7 @@ class TeleInfoListener { std::string tag; virtual void publish_val(const std::string &val){}; }; -class TeleInfo : public PollingComponent, public uart::UARTDevice { +class TeleInfo final : public PollingComponent, public uart::UARTDevice { public: TeleInfo(bool historical_mode); void register_teleinfo_listener(TeleInfoListener *listener); diff --git a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h index f4c04a03a0..24ec00e671 100644 --- a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h +++ b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h @@ -3,7 +3,7 @@ #include "esphome/components/text_sensor/text_sensor.h" namespace esphome::teleinfo { -class TeleInfoTextSensor : public TeleInfoListener, public text_sensor::TextSensor, public Component { +class TeleInfoTextSensor final : public TeleInfoListener, public text_sensor::TextSensor, public Component { public: TeleInfoTextSensor(const char *tag); void publish_val(const std::string &val) override; diff --git a/esphome/components/tem3200/tem3200.h b/esphome/components/tem3200/tem3200.h index 5c73a25fbb..ad8d0154f3 100644 --- a/esphome/components/tem3200/tem3200.h +++ b/esphome/components/tem3200/tem3200.h @@ -7,7 +7,7 @@ namespace esphome::tem3200 { /// This class implements support for the tem3200 pressure and temperature i2c sensors. -class TEM3200Component : public PollingComponent, public i2c::I2CDevice { +class TEM3200Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_raw_pressure_sensor(sensor::Sensor *raw_pressure_sensor) { From bdd51bd4768e174e8e6ccb097530b1aab9f795f0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:32:35 +1200 Subject: [PATCH 1988/2214] Mark configurable classes as final (16/21: sm10bit_base-ssd1331_spi) (#16967) --- .../components/sm10bit_base/sm10bit_base.h | 2 +- esphome/components/sm16716/sm16716.h | 4 +-- esphome/components/sm2135/sm2135.h | 4 +-- esphome/components/sm2235/sm2235.h | 2 +- esphome/components/sm2335/sm2335.h | 2 +- esphome/components/sm300d2/sm300d2.h | 2 +- esphome/components/sml/sensor/sml_sensor.h | 2 +- esphome/components/sml/sml.h | 2 +- .../sml/text_sensor/sml_text_sensor.h | 2 +- esphome/components/smt100/smt100.h | 2 +- esphome/components/sn74hc165/sn74hc165.h | 4 +-- esphome/components/sn74hc595/sn74hc595.h | 10 +++---- esphome/components/sntp/sntp_component.h | 2 +- esphome/components/sonoff_d1/sonoff_d1.h | 2 +- esphome/components/sound_level/sound_level.h | 6 ++-- esphome/components/spa06_i2c/spa06_i2c.h | 2 +- esphome/components/spa06_spi/spa06_spi.h | 6 ++-- esphome/components/speaker/automation.h | 16 +++++----- .../speaker/media_player/audio_pipeline.h | 2 +- .../speaker/media_player/automation.h | 3 +- .../media_player/speaker_media_player.h | 6 ++-- .../components/speaker_source/automation.h | 2 +- .../speaker_source_media_player.h | 2 +- esphome/components/speed/fan/speed_fan.h | 2 +- esphome/components/spi/spi.h | 2 +- esphome/components/spi_device/spi_device.h | 6 ++-- .../components/spi_led_strip/spi_led_strip.h | 6 ++-- esphome/components/sprinkler/automation.h | 30 +++++++++---------- esphome/components/sprinkler/sprinkler.h | 6 ++-- esphome/components/sps30/automation.h | 6 ++-- esphome/components/sps30/sps30.h | 2 +- esphome/components/ssd1306_i2c/ssd1306_i2c.h | 2 +- esphome/components/ssd1306_spi/ssd1306_spi.h | 6 ++-- esphome/components/ssd1322_spi/ssd1322_spi.h | 6 ++-- esphome/components/ssd1325_spi/ssd1325_spi.h | 6 ++-- esphome/components/ssd1327_i2c/ssd1327_i2c.h | 2 +- esphome/components/ssd1327_spi/ssd1327_spi.h | 6 ++-- esphome/components/ssd1331_spi/ssd1331_spi.h | 6 ++-- 38 files changed, 91 insertions(+), 90 deletions(-) diff --git a/esphome/components/sm10bit_base/sm10bit_base.h b/esphome/components/sm10bit_base/sm10bit_base.h index b419b86dbf..a22c4da36e 100644 --- a/esphome/components/sm10bit_base/sm10bit_base.h +++ b/esphome/components/sm10bit_base/sm10bit_base.h @@ -27,7 +27,7 @@ class Sm10BitBase : public Component { void dump_config() override; void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(Sm10BitBase *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm16716/sm16716.h b/esphome/components/sm16716/sm16716.h index 09deb2e8bf..8a76fd86f0 100644 --- a/esphome/components/sm16716/sm16716.h +++ b/esphome/components/sm16716/sm16716.h @@ -7,7 +7,7 @@ namespace esphome::sm16716 { -class SM16716 : public Component { +class SM16716 final : public Component { public: class Channel; @@ -25,7 +25,7 @@ class SM16716 : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(SM16716 *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm2135/sm2135.h b/esphome/components/sm2135/sm2135.h index 040ec14b7f..6bf77cf554 100644 --- a/esphome/components/sm2135/sm2135.h +++ b/esphome/components/sm2135/sm2135.h @@ -21,7 +21,7 @@ enum SM2135Current : uint8_t { SM2135_CURRENT_60MA = 0x0A, }; -class SM2135 : public Component { +class SM2135 final : public Component { public: class Channel; @@ -49,7 +49,7 @@ class SM2135 : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(SM2135 *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm2235/sm2235.h b/esphome/components/sm2235/sm2235.h index cdb754e298..dbb51945f6 100644 --- a/esphome/components/sm2235/sm2235.h +++ b/esphome/components/sm2235/sm2235.h @@ -6,7 +6,7 @@ namespace esphome::sm2235 { -class SM2235 : public sm10bit_base::Sm10BitBase { +class SM2235 final : public sm10bit_base::Sm10BitBase { public: SM2235() = default; diff --git a/esphome/components/sm2335/sm2335.h b/esphome/components/sm2335/sm2335.h index 44e0e5b03f..7c4f0269aa 100644 --- a/esphome/components/sm2335/sm2335.h +++ b/esphome/components/sm2335/sm2335.h @@ -6,7 +6,7 @@ namespace esphome::sm2335 { -class SM2335 : public sm10bit_base::Sm10BitBase { +class SM2335 final : public sm10bit_base::Sm10BitBase { public: SM2335() = default; diff --git a/esphome/components/sm300d2/sm300d2.h b/esphome/components/sm300d2/sm300d2.h index 629e758e30..87c60e92a1 100644 --- a/esphome/components/sm300d2/sm300d2.h +++ b/esphome/components/sm300d2/sm300d2.h @@ -6,7 +6,7 @@ namespace esphome::sm300d2 { -class SM300D2Sensor : public PollingComponent, public uart::UARTDevice { +class SM300D2Sensor final : public PollingComponent, public uart::UARTDevice { public: void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } void set_formaldehyde_sensor(sensor::Sensor *formaldehyde_sensor) { formaldehyde_sensor_ = formaldehyde_sensor; } diff --git a/esphome/components/sml/sensor/sml_sensor.h b/esphome/components/sml/sensor/sml_sensor.h index d2f8a7743f..a73af28f66 100644 --- a/esphome/components/sml/sensor/sml_sensor.h +++ b/esphome/components/sml/sensor/sml_sensor.h @@ -4,7 +4,7 @@ namespace esphome::sml { -class SmlSensor : public SmlListener, public sensor::Sensor, public Component { +class SmlSensor final : public SmlListener, public sensor::Sensor, public Component { public: SmlSensor(std::string server_id, std::string obis_code); void publish_val(const ObisInfo &obis_info) override; diff --git a/esphome/components/sml/sml.h b/esphome/components/sml/sml.h index 60a80e3ad8..b59526648d 100644 --- a/esphome/components/sml/sml.h +++ b/esphome/components/sml/sml.h @@ -17,7 +17,7 @@ class SmlListener { virtual void publish_val(const ObisInfo &obis_info){}; }; -class Sml : public Component, public uart::UARTDevice { +class Sml final : public Component, public uart::UARTDevice { public: void register_sml_listener(SmlListener *listener); void loop() override; diff --git a/esphome/components/sml/text_sensor/sml_text_sensor.h b/esphome/components/sml/text_sensor/sml_text_sensor.h index 6194f22349..d445d514e9 100644 --- a/esphome/components/sml/text_sensor/sml_text_sensor.h +++ b/esphome/components/sml/text_sensor/sml_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sml { -class SmlTextSensor : public SmlListener, public text_sensor::TextSensor, public Component { +class SmlTextSensor final : public SmlListener, public text_sensor::TextSensor, public Component { public: SmlTextSensor(std::string server_id, std::string obis_code, SmlType format); void publish_val(const ObisInfo &obis_info) override; diff --git a/esphome/components/smt100/smt100.h b/esphome/components/smt100/smt100.h index b68151eeb4..55977a5caf 100644 --- a/esphome/components/smt100/smt100.h +++ b/esphome/components/smt100/smt100.h @@ -6,7 +6,7 @@ namespace esphome::smt100 { -class SMT100Component : public PollingComponent, public uart::UARTDevice { +class SMT100Component final : public PollingComponent, public uart::UARTDevice { static const uint16_t MAX_LINE_LENGTH = 31; public: diff --git a/esphome/components/sn74hc165/sn74hc165.h b/esphome/components/sn74hc165/sn74hc165.h index 596f2eb4f5..9e80aa67bf 100644 --- a/esphome/components/sn74hc165/sn74hc165.h +++ b/esphome/components/sn74hc165/sn74hc165.h @@ -8,7 +8,7 @@ namespace esphome::sn74hc165 { -class SN74HC165Component : public Component { +class SN74HC165Component final : public Component { public: SN74HC165Component() = default; @@ -40,7 +40,7 @@ class SN74HC165Component : public Component { }; /// Helper class to expose a SC74HC165 pin as an internal input GPIO pin. -class SN74HC165GPIOPin : public GPIOPin, public Parented { +class SN74HC165GPIOPin final : public GPIOPin, public Parented { public: void setup() override {} void pin_mode(gpio::Flags flags) override {} diff --git a/esphome/components/sn74hc595/sn74hc595.h b/esphome/components/sn74hc595/sn74hc595.h index 23977e3d04..0b291b9ee5 100644 --- a/esphome/components/sn74hc595/sn74hc595.h +++ b/esphome/components/sn74hc595/sn74hc595.h @@ -47,7 +47,7 @@ class SN74HC595Component : public Component { }; /// Helper class to expose a SC74HC595 pin as an internal output GPIO pin. -class SN74HC595GPIOPin : public GPIOPin, public Parented { +class SN74HC595GPIOPin final : public GPIOPin, public Parented { public: void setup() override {} void pin_mode(gpio::Flags flags) override {} @@ -66,7 +66,7 @@ class SN74HC595GPIOPin : public GPIOPin, public Parented { bool inverted_; }; -class SN74HC595GPIOComponent : public SN74HC595Component { +class SN74HC595GPIOComponent final : public SN74HC595Component { public: void setup() override; void set_data_pin(GPIOPin *pin) { data_pin_ = pin; } @@ -80,9 +80,9 @@ class SN74HC595GPIOComponent : public SN74HC595Component { }; #ifdef USE_SPI -class SN74HC595SPIComponent : public SN74HC595Component, - public spi::SPIDevice { +class SN74HC595SPIComponent final : public SN74HC595Component, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/sntp/sntp_component.h b/esphome/components/sntp/sntp_component.h index ef737c1978..686fb30d25 100644 --- a/esphome/components/sntp/sntp_component.h +++ b/esphome/components/sntp/sntp_component.h @@ -15,7 +15,7 @@ namespace esphome::sntp { /// The C library (newlib) available on ESPs only supports TZ strings that specify an offset and DST info; /// you cannot specify zone names or paths to zoneinfo files. /// \see https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html -class SNTPComponent : public time::RealTimeClock { +class SNTPComponent final : public time::RealTimeClock { public: SNTPComponent(const std::array &servers) : servers_(servers) {} diff --git a/esphome/components/sonoff_d1/sonoff_d1.h b/esphome/components/sonoff_d1/sonoff_d1.h index a92877e6c8..b7fcb1efa7 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.h +++ b/esphome/components/sonoff_d1/sonoff_d1.h @@ -41,7 +41,7 @@ namespace esphome::sonoff_d1 { -class SonoffD1Output : public light::LightOutput, public uart::UARTDevice, public Component { +class SonoffD1Output final : public light::LightOutput, public uart::UARTDevice, public Component { public: // LightOutput methods light::LightTraits get_traits() override; diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index aabea62ca4..94c18421ba 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -12,7 +12,7 @@ namespace esphome::sound_level { -class SoundLevelComponent : public Component { +class SoundLevelComponent final : public Component { public: void dump_config() override; void setup() override; @@ -59,12 +59,12 @@ class SoundLevelComponent : public Component { uint32_t measurement_duration_ms_; }; -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; diff --git a/esphome/components/spa06_i2c/spa06_i2c.h b/esphome/components/spa06_i2c/spa06_i2c.h index 6b4bce3a4e..05e60cbb5d 100644 --- a/esphome/components/spa06_i2c/spa06_i2c.h +++ b/esphome/components/spa06_i2c/spa06_i2c.h @@ -4,7 +4,7 @@ namespace esphome::spa06_i2c { -class SPA06I2CComponent : public spa06_base::SPA06Component, public i2c::I2CDevice { +class SPA06I2CComponent final : public spa06_base::SPA06Component, public i2c::I2CDevice { public: bool spa_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool spa_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/spa06_spi/spa06_spi.h b/esphome/components/spa06_spi/spa06_spi.h index ffbc162d6f..56d72df620 100644 --- a/esphome/components/spa06_spi/spa06_spi.h +++ b/esphome/components/spa06_spi/spa06_spi.h @@ -5,9 +5,9 @@ namespace esphome::spa06_spi { -class SPA06SPIComponent : public spa06_base::SPA06Component, - public spi::SPIDevice { +class SPA06SPIComponent final : public spa06_base::SPA06Component, + public spi::SPIDevice { void setup() override; bool spa_read_byte(uint8_t a_register, uint8_t *data) override; bool spa_write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/speaker/automation.h b/esphome/components/speaker/automation.h index 9997b064d5..443588a04c 100644 --- a/esphome/components/speaker/automation.h +++ b/esphome/components/speaker/automation.h @@ -7,7 +7,7 @@ namespace esphome::speaker { -template class PlayAction : public Action, public Parented { +template class PlayAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -38,12 +38,12 @@ template class PlayAction : public Action, public Parente } data_; }; -template class VolumeSetAction : public Action, public Parented { +template class VolumeSetAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, volume) void play(const Ts &...x) override { this->parent_->set_volume(this->volume_.value(x...)); } }; -template class MuteOnAction : public Action { +template class MuteOnAction final : public Action { public: explicit MuteOnAction(Speaker *speaker) : speaker_(speaker) {} @@ -53,7 +53,7 @@ template class MuteOnAction : public Action { Speaker *speaker_; }; -template class MuteOffAction : public Action { +template class MuteOffAction final : public Action { public: explicit MuteOffAction(Speaker *speaker) : speaker_(speaker) {} @@ -63,22 +63,22 @@ template class MuteOffAction : public Action { Speaker *speaker_; }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class FinishAction : public Action, public Parented { +template class FinishAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->finish(); } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class IsStoppedCondition : public Condition, public Parented { +template class IsStoppedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_stopped(); } }; diff --git a/esphome/components/speaker/media_player/audio_pipeline.h b/esphome/components/speaker/media_player/audio_pipeline.h index 89f4707ab3..02dad15de9 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.h +++ b/esphome/components/speaker/media_player/audio_pipeline.h @@ -56,7 +56,7 @@ struct InfoErrorEvent { optional decoding_err; }; -class AudioPipeline { +class AudioPipeline final { public: /// @param speaker ESPHome speaker component for pipeline's audio output /// @param buffer_size Size of the buffer in bytes between the reader and decoder diff --git a/esphome/components/speaker/media_player/automation.h b/esphome/components/speaker/media_player/automation.h index 7843399866..f9e2127993 100644 --- a/esphome/components/speaker/media_player/automation.h +++ b/esphome/components/speaker/media_player/automation.h @@ -9,7 +9,8 @@ namespace esphome::speaker { -template class PlayOnDeviceMediaAction : public Action, public Parented { +template +class PlayOnDeviceMediaAction final : public Action, public Parented { TEMPLATABLE_VALUE(audio::AudioFile *, audio_file) TEMPLATABLE_VALUE(bool, announcement) TEMPLATABLE_VALUE(bool, enqueue) diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 2d80377312..6470fb925c 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -42,11 +42,11 @@ struct VolumeRestoreState { bool is_muted; }; -class SpeakerMediaPlayer : public Component, - public media_player::MediaPlayer +class SpeakerMediaPlayer final : public Component, + public media_player::MediaPlayer #ifdef USE_OTA_STATE_LISTENER , - public ota::OTAGlobalStateListener + public ota::OTAGlobalStateListener #endif { public: diff --git a/esphome/components/speaker_source/automation.h b/esphome/components/speaker_source/automation.h index b436149a03..a03fa42477 100644 --- a/esphome/components/speaker_source/automation.h +++ b/esphome/components/speaker_source/automation.h @@ -9,7 +9,7 @@ namespace esphome::speaker_source { -template class SetPlaylistDelayAction : public Action { +template class SetPlaylistDelayAction final : public Action { public: explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {} diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index 652390edd2..ab1f8edfed 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -146,7 +146,7 @@ struct VolumeRestoreState { bool is_muted; }; -class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPlayer { +class SpeakerSourceMediaPlayer final : public Component, public media_player::MediaPlayer { friend struct SourceBinding; public: diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index c618d6bc5f..510b3e9621 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -7,7 +7,7 @@ namespace esphome::speed { -class SpeedFan : public Component, public fan::Fan { +class SpeedFan final : public Component, public fan::Fan { public: SpeedFan(int speed_count) : speed_count_(speed_count) {} void setup() override; diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index e6f592c6e4..cada29b0d7 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -334,7 +334,7 @@ class SPIBus { class SPIClient; -class SPIComponent : public Component { +class SPIComponent final : public Component { public: SPIDelegate *register_device(SPIClient *device, SPIMode mode, SPIBitOrder bit_order, uint32_t data_rate, GPIOPin *cs_pin, bool release_device, bool write_only); diff --git a/esphome/components/spi_device/spi_device.h b/esphome/components/spi_device/spi_device.h index 3a2523fbab..506090fc58 100644 --- a/esphome/components/spi_device/spi_device.h +++ b/esphome/components/spi_device/spi_device.h @@ -5,9 +5,9 @@ namespace esphome::spi_device { -class SPIDeviceComponent : public Component, - public spi::SPIDevice { +class SPIDeviceComponent final : public Component, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index e2bcd5af63..20b9c25c2e 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -8,9 +8,9 @@ namespace esphome::spi_led_strip { static const char *const TAG = "spi_led_strip"; -class SpiLedStrip : public light::AddressableLight, - public spi::SPIDevice { +class SpiLedStrip final : public light::AddressableLight, + public spi::SPIDevice { public: SpiLedStrip(uint16_t num_leds); void setup() override; diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index c6fe2e4e02..beeec96b98 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,7 +6,7 @@ namespace esphome::sprinkler { -template class SetDividerAction : public Action { +template class SetDividerAction final : public Action { public: explicit SetDividerAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -18,7 +18,7 @@ template class SetDividerAction : public Action { Sprinkler *sprinkler_; }; -template class SetMultiplierAction : public Action { +template class SetMultiplierAction final : public Action { public: explicit SetMultiplierAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -30,7 +30,7 @@ template class SetMultiplierAction : public Action { Sprinkler *sprinkler_; }; -template class QueueValveAction : public Action { +template class QueueValveAction final : public Action { public: explicit QueueValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -46,7 +46,7 @@ template class QueueValveAction : public Action { Sprinkler *sprinkler_; }; -template class ClearQueuedValvesAction : public Action { +template class ClearQueuedValvesAction final : public Action { public: explicit ClearQueuedValvesAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -56,7 +56,7 @@ template class ClearQueuedValvesAction : public Action { Sprinkler *sprinkler_; }; -template class SetRepeatAction : public Action { +template class SetRepeatAction final : public Action { public: explicit SetRepeatAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -68,7 +68,7 @@ template class SetRepeatAction : public Action { Sprinkler *sprinkler_; }; -template class SetRunDurationAction : public Action { +template class SetRunDurationAction final : public Action { public: explicit SetRunDurationAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -84,7 +84,7 @@ template class SetRunDurationAction : public Action { Sprinkler *sprinkler_; }; -template class StartFromQueueAction : public Action { +template class StartFromQueueAction final : public Action { public: explicit StartFromQueueAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -94,7 +94,7 @@ template class StartFromQueueAction : public Action { Sprinkler *sprinkler_; }; -template class StartFullCycleAction : public Action { +template class StartFullCycleAction final : public Action { public: explicit StartFullCycleAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -104,7 +104,7 @@ template class StartFullCycleAction : public Action { Sprinkler *sprinkler_; }; -template class StartSingleValveAction : public Action { +template class StartSingleValveAction final : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -122,7 +122,7 @@ template class StartSingleValveAction : public Action { TemplatableValue valve_to_start_{}; }; -template class ShutdownAction : public Action { +template class ShutdownAction final : public Action { public: explicit ShutdownAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -132,7 +132,7 @@ template class ShutdownAction : public Action { Sprinkler *sprinkler_; }; -template class NextValveAction : public Action { +template class NextValveAction final : public Action { public: explicit NextValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -142,7 +142,7 @@ template class NextValveAction : public Action { Sprinkler *sprinkler_; }; -template class PreviousValveAction : public Action { +template class PreviousValveAction final : public Action { public: explicit PreviousValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -152,7 +152,7 @@ template class PreviousValveAction : public Action { Sprinkler *sprinkler_; }; -template class PauseAction : public Action { +template class PauseAction final : public Action { public: explicit PauseAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -162,7 +162,7 @@ template class PauseAction : public Action { Sprinkler *sprinkler_; }; -template class ResumeAction : public Action { +template class ResumeAction final : public Action { public: explicit ResumeAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -172,7 +172,7 @@ template class ResumeAction : public Action { Sprinkler *sprinkler_; }; -template class ResumeOrStartAction : public Action { +template class ResumeOrStartAction final : public Action { public: explicit ResumeOrStartAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index 2598a5606a..bd610f7ad3 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -70,7 +70,7 @@ struct SprinklerValve { std::unique_ptr> valve_turn_on_automation; }; -class SprinklerControllerNumber : public number::Number, public Component { +class SprinklerControllerNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; @@ -89,7 +89,7 @@ class SprinklerControllerNumber : public number::Number, public Component { ESPPreferenceObject pref_; }; -class SprinklerControllerSwitch : public switch_::Switch, public Component { +class SprinklerControllerSwitch final : public switch_::Switch, public Component { public: SprinklerControllerSwitch(); @@ -173,7 +173,7 @@ class SprinklerValveRunRequest { SprinklerValveRunRequestOrigin origin_{USER}; }; -class Sprinkler : public Component { +class Sprinkler final : public Component { public: Sprinkler(); Sprinkler(const char *name); diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h index e58f857eb3..ba978e7770 100644 --- a/esphome/components/sps30/automation.h +++ b/esphome/components/sps30/automation.h @@ -6,17 +6,17 @@ namespace esphome::sps30 { -template class StartFanAction : public Action, public Parented { +template class StartFanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start_fan_cleaning(); } }; -template class StartMeasurementAction : public Action, public Parented { +template class StartMeasurementAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start_measurement(); } }; -template class StopMeasurementAction : public Action, public Parented { +template class StopMeasurementAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_measurement(); } }; diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index ccb3e8ff41..10b89c844b 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -8,7 +8,7 @@ namespace esphome::sps30 { /// This class implements support for the Sensirion SPS30 i2c/UART Particulate Matter /// PM1.0, PM2.5, PM4, PM10 Air Quality sensors. -class SPS30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SPS30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_pm_1_0_sensor(sensor::Sensor *pm_1_0) { pm_1_0_sensor_ = pm_1_0; } void set_pm_2_5_sensor(sensor::Sensor *pm_2_5) { pm_2_5_sensor_ = pm_2_5; } diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.h b/esphome/components/ssd1306_i2c/ssd1306_i2c.h index 0316da0e77..54c7d86287 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.h +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ssd1306_i2c { -class I2CSSD1306 : public ssd1306_base::SSD1306, public i2c::I2CDevice { +class I2CSSD1306 final : public ssd1306_base::SSD1306, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.h b/esphome/components/ssd1306_spi/ssd1306_spi.h index f8346033b3..948d099d0f 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.h +++ b/esphome/components/ssd1306_spi/ssd1306_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1306_spi { -class SPISSD1306 : public ssd1306_base::SSD1306, - public spi::SPIDevice { +class SPISSD1306 final : public ssd1306_base::SSD1306, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1322_spi/ssd1322_spi.h b/esphome/components/ssd1322_spi/ssd1322_spi.h index 31d17d0ef1..1ac9654109 100644 --- a/esphome/components/ssd1322_spi/ssd1322_spi.h +++ b/esphome/components/ssd1322_spi/ssd1322_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1322_spi { -class SPISSD1322 : public ssd1322_base::SSD1322, - public spi::SPIDevice { +class SPISSD1322 final : public ssd1322_base::SSD1322, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1325_spi/ssd1325_spi.h b/esphome/components/ssd1325_spi/ssd1325_spi.h index 32cbb28fd8..3202eabec5 100644 --- a/esphome/components/ssd1325_spi/ssd1325_spi.h +++ b/esphome/components/ssd1325_spi/ssd1325_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1325_spi { -class SPISSD1325 : public ssd1325_base::SSD1325, - public spi::SPIDevice { +class SPISSD1325 final : public ssd1325_base::SSD1325, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.h b/esphome/components/ssd1327_i2c/ssd1327_i2c.h index f08ef94fef..75f854d3da 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.h +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ssd1327_i2c { -class I2CSSD1327 : public ssd1327_base::SSD1327, public i2c::I2CDevice { +class I2CSSD1327 final : public ssd1327_base::SSD1327, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ssd1327_spi/ssd1327_spi.h b/esphome/components/ssd1327_spi/ssd1327_spi.h index fd1ed0357f..cb7d5e2181 100644 --- a/esphome/components/ssd1327_spi/ssd1327_spi.h +++ b/esphome/components/ssd1327_spi/ssd1327_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1327_spi { -class SPISSD1327 : public ssd1327_base::SSD1327, - public spi::SPIDevice { +class SPISSD1327 final : public ssd1327_base::SSD1327, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1331_spi/ssd1331_spi.h b/esphome/components/ssd1331_spi/ssd1331_spi.h index acdc004b26..add010712c 100644 --- a/esphome/components/ssd1331_spi/ssd1331_spi.h +++ b/esphome/components/ssd1331_spi/ssd1331_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1331_spi { -class SPISSD1331 : public ssd1331_base::SSD1331, - public spi::SPIDevice { +class SPISSD1331 final : public ssd1331_base::SSD1331, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } From cfdd6d383f3d074a9730ed41f1d55baf5d9534ee Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:32:42 +1200 Subject: [PATCH 1989/2214] Mark configurable classes as final (19/21: uart-wl_134) (#16970) --- esphome/components/uart/automation.h | 2 +- esphome/components/uart/button/uart_button.h | 2 +- esphome/components/uart/event/uart_event.h | 2 +- .../uart/packet_transport/uart_transport.h | 2 +- esphome/components/uart/switch/uart_switch.h | 2 +- .../components/uart/uart_component_esp8266.h | 2 +- .../components/uart/uart_component_esp_idf.h | 2 +- esphome/components/uart/uart_component_host.h | 2 +- .../components/uart/uart_component_libretiny.h | 2 +- .../components/uart/uart_component_rp2040.h | 2 +- esphome/components/uart/uart_debugger.h | 4 ++-- esphome/components/udp/automation.h | 2 +- .../udp/packet_transport/udp_transport.h | 2 +- esphome/components/udp/udp_component.h | 2 +- esphome/components/ufire_ec/ufire_ec.h | 6 +++--- esphome/components/ufire_ise/ufire_ise.h | 8 ++++---- esphome/components/uln2003/uln2003.h | 2 +- .../components/ultrasonic/ultrasonic_sensor.h | 2 +- esphome/components/update/automation.h | 6 +++--- .../climate/uponor_smatrix_climate.h | 2 +- .../sensor/uponor_smatrix_sensor.h | 2 +- .../components/uponor_smatrix/uponor_smatrix.h | 2 +- .../uptime/sensor/uptime_seconds_sensor.h | 2 +- .../uptime/sensor/uptime_timestamp_sensor.h | 2 +- .../uptime/text_sensor/uptime_text_sensor.h | 2 +- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 4 ++-- esphome/components/usb_host/usb_host.h | 2 +- esphome/components/usb_uart/usb_uart.h | 2 +- esphome/components/valve/automation.h | 18 +++++++++--------- .../vbus/binary_sensor/vbus_binary_sensor.h | 18 +++++++++--------- esphome/components/vbus/vbus.h | 2 +- esphome/components/veml3235/veml3235.h | 2 +- esphome/components/veml7700/veml7700.h | 2 +- esphome/components/vl53l0x/vl53l0x_sensor.h | 2 +- .../voice_assistant/voice_assistant.h | 12 ++++++------ esphome/components/wake_on_lan/wake_on_lan.h | 2 +- .../web_server_base/web_server_base.h | 2 +- esphome/components/weikai_i2c/weikai_i2c.h | 2 +- esphome/components/weikai_spi/weikai_spi.h | 6 +++--- esphome/components/whirlpool/whirlpool.h | 2 +- esphome/components/whynter/whynter.h | 2 +- esphome/components/wiegand/wiegand.h | 8 ++++---- esphome/components/wifi/automation.h | 12 ++++++------ .../wifi_signal/wifi_signal_sensor.h | 4 ++-- esphome/components/wireguard/wireguard.h | 11 ++++++----- esphome/components/wl_134/wl_134.h | 2 +- 46 files changed, 92 insertions(+), 91 deletions(-) diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index c99caac97b..e5a9fa7c7b 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -7,7 +7,7 @@ namespace esphome::uart { -template class UARTWriteAction : public Action, public Parented { +template class UARTWriteAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 2b530d3c4b..47f45d4899 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -8,7 +8,7 @@ namespace esphome::uart { -class UARTButton : public button::Button, public UARTDevice, public Component { +class UARTButton final : public button::Button, public UARTDevice, public Component { public: void set_data(std::vector &&data) { this->data_ = std::move(data); } void set_data(std::initializer_list data) { this->data_ = std::vector(data); } diff --git a/esphome/components/uart/event/uart_event.h b/esphome/components/uart/event/uart_event.h index 8a00b5894b..3960ffd5bb 100644 --- a/esphome/components/uart/event/uart_event.h +++ b/esphome/components/uart/event/uart_event.h @@ -7,7 +7,7 @@ namespace esphome::uart { -class UARTEvent : public event::Event, public UARTDevice, public Component { +class UARTEvent final : public event::Event, public UARTDevice, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index 1c92af536e..b1ce8ac590 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -20,7 +20,7 @@ static const uint16_t MAX_PACKET_SIZE = 508; static const uint8_t FLAG_BYTE = 0x7E; static const uint8_t CONTROL_BYTE = 0x7D; -class UARTTransport : public packet_transport::PacketTransport, public UARTDevice { +class UARTTransport final : public packet_transport::PacketTransport, public UARTDevice { public: void loop() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index 5730fc9b4b..c924c7d4e5 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -9,7 +9,7 @@ namespace esphome::uart { -class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { +class UARTSwitch final : public switch_::Switch, public UARTDevice, public Component { public: void loop() override; diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 7f844d9b65..ee3be3cd3a 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -46,7 +46,7 @@ class ESP8266SoftwareSerial { ISRInternalGPIOPin rx_pin_; }; -class ESP8266UartComponent : public UARTComponent, public Component { +class ESP8266UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index ec4f2884b2..3b86368797 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -16,7 +16,7 @@ namespace esphome::uart { /// Thread safety: All public methods must only be called from the main loop. /// The ESP-IDF UART driver API does not guarantee thread safety, and ESPHome's /// peek byte state (has_peek_/peek_byte_) is not synchronized. -class IDFUARTComponent : public UARTComponent, public Component { +class IDFUARTComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index a47e5649be..bca62debf1 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -8,7 +8,7 @@ namespace esphome::uart { -class HostUartComponent : public UARTComponent, public Component { +class HostUartComponent final : public UARTComponent, public Component { public: virtual ~HostUartComponent(); void setup() override; diff --git a/esphome/components/uart/uart_component_libretiny.h b/esphome/components/uart/uart_component_libretiny.h index 872ea86601..aa13a01392 100644 --- a/esphome/components/uart/uart_component_libretiny.h +++ b/esphome/components/uart/uart_component_libretiny.h @@ -10,7 +10,7 @@ namespace esphome::uart { -class LibreTinyUARTComponent : public UARTComponent, public Component { +class LibreTinyUARTComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_component_rp2040.h b/esphome/components/uart/uart_component_rp2040.h index 198c698af9..b16d8b12d9 100644 --- a/esphome/components/uart/uart_component_rp2040.h +++ b/esphome/components/uart/uart_component_rp2040.h @@ -13,7 +13,7 @@ namespace esphome::uart { -class RP2040UartComponent : public UARTComponent, public Component { +class RP2040UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_debugger.h b/esphome/components/uart/uart_debugger.h index da33bea70c..b69dcf0676 100644 --- a/esphome/components/uart/uart_debugger.h +++ b/esphome/components/uart/uart_debugger.h @@ -18,7 +18,7 @@ namespace esphome::uart { /// 'appropriate time' means exactly, is determined by a number of /// configurable constraints. E.g. when a given number of bytes is gathered /// and/or when no more data has been seen for a given time interval. -class UARTDebugger : public Component, public Trigger, StringRef> { +class UARTDebugger final : public Component, public Trigger, StringRef> { public: explicit UARTDebugger(UARTComponent *parent); void loop() override; @@ -73,7 +73,7 @@ class UARTDebugger : public Component, public Trigger class UDPWriteAction : public Action, public Parented { +template class UDPWriteAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; diff --git a/esphome/components/udp/packet_transport/udp_transport.h b/esphome/components/udp/packet_transport/udp_transport.h index 8621ddca48..e91a3e2a5a 100644 --- a/esphome/components/udp/packet_transport/udp_transport.h +++ b/esphome/components/udp/packet_transport/udp_transport.h @@ -8,7 +8,7 @@ namespace esphome::udp { -class UDPTransport : public packet_transport::PacketTransport, public Parented { +class UDPTransport final : public packet_transport::PacketTransport, public Parented { public: void setup() override; diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index fb0edf2ebd..274e0119ee 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -18,7 +18,7 @@ namespace esphome::udp { static const size_t MAX_PACKET_SIZE = 508; -class UDPComponent : public Component { +class UDPComponent final : public Component { public: void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } /// Prevent accidental use of std::string which would dangle diff --git a/esphome/components/ufire_ec/ufire_ec.h b/esphome/components/ufire_ec/ufire_ec.h index fce6258632..0928fda9ee 100644 --- a/esphome/components/ufire_ec/ufire_ec.h +++ b/esphome/components/ufire_ec/ufire_ec.h @@ -24,7 +24,7 @@ static const uint8_t COMMAND_CALIBRATE_PROBE = 20; static const uint8_t COMMAND_MEASURE_TEMP = 40; static const uint8_t COMMAND_MEASURE_EC = 80; -class UFireECComponent : public PollingComponent, public i2c::I2CDevice { +class UFireECComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -58,7 +58,7 @@ class UFireECComponent : public PollingComponent, public i2c::I2CDevice { float temperature_coefficient_{0.0}; }; -template class UFireECCalibrateProbeAction : public Action { +template class UFireECCalibrateProbeAction final : public Action { public: UFireECCalibrateProbeAction(UFireECComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -72,7 +72,7 @@ template class UFireECCalibrateProbeAction : public Action class UFireECResetAction : public Action { +template class UFireECResetAction final : public Action { public: UFireECResetAction(UFireECComponent *parent) : parent_(parent) {} diff --git a/esphome/components/ufire_ise/ufire_ise.h b/esphome/components/ufire_ise/ufire_ise.h index bff8eeff9d..85916f227e 100644 --- a/esphome/components/ufire_ise/ufire_ise.h +++ b/esphome/components/ufire_ise/ufire_ise.h @@ -29,7 +29,7 @@ static const uint8_t COMMAND_CALIBRATE_LOW = 10; static const uint8_t COMMAND_MEASURE_TEMP = 40; static const uint8_t COMMAND_MEASURE_MV = 80; -class UFireISEComponent : public PollingComponent, public i2c::I2CDevice { +class UFireISEComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -58,7 +58,7 @@ class UFireISEComponent : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *ph_sensor_{nullptr}; }; -template class UFireISECalibrateProbeLowAction : public Action { +template class UFireISECalibrateProbeLowAction final : public Action { public: UFireISECalibrateProbeLowAction(UFireISEComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -69,7 +69,7 @@ template class UFireISECalibrateProbeLowAction : public Action class UFireISECalibrateProbeHighAction : public Action { +template class UFireISECalibrateProbeHighAction final : public Action { public: UFireISECalibrateProbeHighAction(UFireISEComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -80,7 +80,7 @@ template class UFireISECalibrateProbeHighAction : public Action< UFireISEComponent *parent_; }; -template class UFireISEResetAction : public Action { +template class UFireISEResetAction final : public Action { public: UFireISEResetAction(UFireISEComponent *parent) : parent_(parent) {} diff --git a/esphome/components/uln2003/uln2003.h b/esphome/components/uln2003/uln2003.h index 70f55f72bf..1b1a16f95e 100644 --- a/esphome/components/uln2003/uln2003.h +++ b/esphome/components/uln2003/uln2003.h @@ -12,7 +12,7 @@ enum ULN2003StepMode { ULN2003_STEP_MODE_WAVE_DRIVE, }; -class ULN2003 : public stepper::Stepper, public Component { +class ULN2003 final : public stepper::Stepper, public Component { public: void set_pin_a(GPIOPin *pin_a) { pin_a_ = pin_a; } void set_pin_b(GPIOPin *pin_b) { pin_b_ = pin_b; } diff --git a/esphome/components/ultrasonic/ultrasonic_sensor.h b/esphome/components/ultrasonic/ultrasonic_sensor.h index 7d333a1b24..ea8fcbf72e 100644 --- a/esphome/components/ultrasonic/ultrasonic_sensor.h +++ b/esphome/components/ultrasonic/ultrasonic_sensor.h @@ -18,7 +18,7 @@ struct UltrasonicSensorStore { volatile bool echo_end{false}; }; -class UltrasonicSensorComponent : public sensor::Sensor, public PollingComponent { +class UltrasonicSensorComponent final : public sensor::Sensor, public PollingComponent { public: void set_trigger_pin(InternalGPIOPin *trigger_pin) { this->trigger_pin_ = trigger_pin; } void set_echo_pin(InternalGPIOPin *echo_pin) { this->echo_pin_ = echo_pin; } diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h index 821151f67c..8ba7b71a9c 100644 --- a/esphome/components/update/automation.h +++ b/esphome/components/update/automation.h @@ -6,19 +6,19 @@ namespace esphome::update { -template class PerformAction : public Action, public Parented { +template class PerformAction final : public Action, public Parented { TEMPLATABLE_VALUE(bool, force) public: void play(const Ts &...x) override { this->parent_->perform(this->force_.value(x...)); } }; -template class CheckAction : public Action, public Parented { +template class CheckAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->check(); } }; -template class IsAvailableCondition : public Condition, public Parented { +template class IsAvailableCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } }; diff --git a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h index 4cc5a4a3bc..4755655747 100644 --- a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h +++ b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h @@ -6,7 +6,7 @@ namespace esphome::uponor_smatrix { -class UponorSmatrixClimate : public climate::Climate, public Component, public UponorSmatrixDevice { +class UponorSmatrixClimate final : public climate::Climate, public Component, public UponorSmatrixDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h index 346fe1e3d6..b507642fce 100644 --- a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h +++ b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h @@ -6,7 +6,7 @@ namespace esphome::uponor_smatrix { -class UponorSmatrixSensor : public sensor::Sensor, public Component, public UponorSmatrixDevice { +class UponorSmatrixSensor final : public sensor::Sensor, public Component, public UponorSmatrixDevice { SUB_SENSOR(temperature) SUB_SENSOR(external_temperature) SUB_SENSOR(humidity) diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.h b/esphome/components/uponor_smatrix/uponor_smatrix.h index e9e772feab..8476c6bac2 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.h +++ b/esphome/components/uponor_smatrix/uponor_smatrix.h @@ -62,7 +62,7 @@ struct UponorSmatrixData { class UponorSmatrixDevice; -class UponorSmatrixComponent : public uart::UARTDevice, public Component { +class UponorSmatrixComponent final : public uart::UARTDevice, public Component { public: UponorSmatrixComponent() = default; diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.h b/esphome/components/uptime/sensor/uptime_seconds_sensor.h index 1b80a4480a..b0b12954b2 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.h +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.h @@ -5,7 +5,7 @@ namespace esphome::uptime { -class UptimeSecondsSensor : public sensor::Sensor, public PollingComponent { +class UptimeSecondsSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void dump_config() override; diff --git a/esphome/components/uptime/sensor/uptime_timestamp_sensor.h b/esphome/components/uptime/sensor/uptime_timestamp_sensor.h index 912c0b7655..5b837cbce1 100644 --- a/esphome/components/uptime/sensor/uptime_timestamp_sensor.h +++ b/esphome/components/uptime/sensor/uptime_timestamp_sensor.h @@ -10,7 +10,7 @@ namespace esphome::uptime { -class UptimeTimestampSensor : public sensor::Sensor, public Component { +class UptimeTimestampSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.h b/esphome/components/uptime/text_sensor/uptime_text_sensor.h index a97ba332bb..0bdc7fe404 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.h +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.h @@ -7,7 +7,7 @@ namespace esphome::uptime { -class UptimeTextSensor : public text_sensor::TextSensor, public PollingComponent { +class UptimeTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: UptimeTextSensor(const char *days_text, const char *hours_text, const char *minutes_text, const char *seconds_text, const char *separator, bool expand) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 10692fd436..2251c600e7 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -51,7 +51,7 @@ struct CDCEvent { class USBCDCACMComponent; /// Represents a single CDC ACM interface instance -class USBCDCACMInstance : public uart::UARTComponent, public Parented { +class USBCDCACMInstance final : public uart::UARTComponent, public Parented { public: void setup(); void loop(); @@ -112,7 +112,7 @@ class USBCDCACMInstance : public uart::UARTComponent, public Parented { +class USBUartChannel final : public uart::UARTComponent, public Parented { friend class USBUartComponent; friend class USBUartTypeCdcAcm; friend class USBUartTypeCP210X; diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 08c9f4e011..63d03a889b 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -6,7 +6,7 @@ namespace esphome::valve { -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Valve *valve) : valve_(valve) {} @@ -16,7 +16,7 @@ template class OpenAction : public Action { Valve *valve_; }; -template class CloseAction : public Action { +template class CloseAction final : public Action { public: explicit CloseAction(Valve *valve) : valve_(valve) {} @@ -26,7 +26,7 @@ template class CloseAction : public Action { Valve *valve_; }; -template class StopAction : public Action { +template class StopAction final : public Action { public: explicit StopAction(Valve *valve) : valve_(valve) {} @@ -36,7 +36,7 @@ template class StopAction : public Action { Valve *valve_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Valve *valve) : valve_(valve) {} @@ -58,7 +58,7 @@ template class ToggleAction : public Action { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(ValveCall &, const std::remove_cvref_t &...); ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} @@ -74,7 +74,7 @@ template class ControlAction : public Action { ApplyFn apply_; }; -template class ValveIsOpenCondition : public Condition { +template class ValveIsOpenCondition final : public Condition { public: ValveIsOpenCondition(Valve *valve) : valve_(valve) {} bool check(const Ts &...x) override { return this->valve_->is_fully_open(); } @@ -83,7 +83,7 @@ template class ValveIsOpenCondition : public Condition { Valve *valve_; }; -template class ValveIsClosedCondition : public Condition { +template class ValveIsClosedCondition final : public Condition { public: ValveIsClosedCondition(Valve *valve) : valve_(valve) {} bool check(const Ts &...x) override { return this->valve_->is_fully_closed(); } @@ -92,7 +92,7 @@ template class ValveIsClosedCondition : public Condition Valve *valve_; }; -class ValveOpenTrigger : public Trigger<> { +class ValveOpenTrigger final : public Trigger<> { public: ValveOpenTrigger(Valve *a_valve) : valve_(a_valve) { a_valve->add_on_state_callback([this]() { @@ -106,7 +106,7 @@ class ValveOpenTrigger : public Trigger<> { Valve *valve_; }; -class ValveClosedTrigger : public Trigger<> { +class ValveClosedTrigger final : public Trigger<> { public: ValveClosedTrigger(Valve *a_valve) : valve_(a_valve) { a_valve->add_on_state_callback([this]() { diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h index 8d372f45d6..a77fc7f56a 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::vbus { -class DeltaSolBSPlusBSensor : public VBusListener, public Component { +class DeltaSolBSPlusBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_relay1_bsensor(binary_sensor::BinarySensor *bsensor) { this->relay1_bsensor_ = bsensor; } @@ -38,7 +38,7 @@ class DeltaSolBSPlusBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolBS2009BSensor : public VBusListener, public Component { +class DeltaSolBS2009BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -59,7 +59,7 @@ class DeltaSolBS2009BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCBSensor : public VBusListener, public Component { +class DeltaSolCBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -76,7 +76,7 @@ class DeltaSolCBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCS2BSensor : public VBusListener, public Component { +class DeltaSolCS2BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -93,7 +93,7 @@ class DeltaSolCS2BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCS4BSensor : public VBusListener, public Component { +class DeltaSolCS4BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -110,7 +110,7 @@ class DeltaSolCS4BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCSPlusBSensor : public VBusListener, public Component { +class DeltaSolCSPlusBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -127,7 +127,7 @@ class DeltaSolCSPlusBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolBS2BSensor : public VBusListener, public Component { +class DeltaSolBS2BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -146,7 +146,7 @@ class DeltaSolBS2BSensor : public VBusListener, public Component { class VBusCustomSubBSensor; -class VBusCustomBSensor : public VBusListener, public Component { +class VBusCustomBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_bsensors(std::vector bsensors) { this->bsensors_ = std::move(bsensors); }; @@ -156,7 +156,7 @@ class VBusCustomBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class VBusCustomSubBSensor : public binary_sensor::BinarySensor, public Component { +class VBusCustomSubBSensor final : public binary_sensor::BinarySensor, public Component { public: void set_message_parser(message_parser_t parser) { this->message_parser_ = std::move(parser); }; void parse_message(std::vector &message); diff --git a/esphome/components/vbus/vbus.h b/esphome/components/vbus/vbus.h index ff523178ef..c8cd0cb4a4 100644 --- a/esphome/components/vbus/vbus.h +++ b/esphome/components/vbus/vbus.h @@ -25,7 +25,7 @@ class VBusListener { virtual void handle_message(std::vector &message) = 0; }; -class VBus : public uart::UARTDevice, public Component { +class VBus final : public uart::UARTDevice, public Component { public: void dump_config() override; void loop() override; diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index df88bc6ff5..cda6d177aa 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -59,7 +59,7 @@ enum VEML3235ComponentGain { VEML3235_GAIN_4X = 0b11, }; -class VEML3235Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/veml7700/veml7700.h b/esphome/components/veml7700/veml7700.h index a036bdf002..4a1e25fb8a 100644 --- a/esphome/components/veml7700/veml7700.h +++ b/esphome/components/veml7700/veml7700.h @@ -95,7 +95,7 @@ union PSMRegister { } __attribute__((packed)); }; -class VEML7700Component : public PollingComponent, public i2c::I2CDevice { +class VEML7700Component final : public PollingComponent, public i2c::I2CDevice { public: // // EspHome framework functions diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.h b/esphome/components/vl53l0x/vl53l0x_sensor.h index 7c916f4fde..0aa01685c4 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.h +++ b/esphome/components/vl53l0x/vl53l0x_sensor.h @@ -22,7 +22,7 @@ struct SequenceStepTimeouts { enum VcselPeriodType { VCSEL_PERIOD_PRE_RANGE, VCSEL_PERIOD_FINAL_RANGE }; -class VL53L0XSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class VL53L0XSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: VL53L0XSensor(); diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 76b076a366..dd9d205aff 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -110,7 +110,7 @@ enum class MediaPlayerResponseState { }; #endif -class VoiceAssistant : public Component { +class VoiceAssistant final : public Component { public: VoiceAssistant(); @@ -353,7 +353,7 @@ class VoiceAssistant : public Component { #endif }; -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, wake_word); public: @@ -368,22 +368,22 @@ template class StartAction : public Action, public Parent bool silence_detection_; }; -template class StartContinuousAction : public Action, public Parented { +template class StartContinuousAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->request_start(true, true); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->request_stop(); } }; -template class IsRunningCondition : public Condition, public Parented { +template class IsRunningCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running() || this->parent_->is_continuous(); } }; -template class ConnectedCondition : public Condition, public Parented { +template class ConnectedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->get_api_connection() != nullptr; } }; diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index 84bc26e064..ddf3433e7d 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -11,7 +11,7 @@ namespace esphome::wake_on_lan { -class WakeOnLanButton : public button::Button, public Component { +class WakeOnLanButton final : public button::Button, public Component { public: void set_macaddr(uint8_t a, uint8_t b, uint8_t c, uint8_t d, uint8_t e, uint8_t f); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c7162c139a..19c2185fb9 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -88,7 +88,7 @@ class AuthMiddlewareHandler : public MiddlewareHandler { } // namespace internal -class WebServerBase { +class WebServerBase final { public: void init() { if (this->initialized_) { diff --git a/esphome/components/weikai_i2c/weikai_i2c.h b/esphome/components/weikai_i2c/weikai_i2c.h index 940dbad9f2..6d8da031ac 100644 --- a/esphome/components/weikai_i2c/weikai_i2c.h +++ b/esphome/components/weikai_i2c/weikai_i2c.h @@ -38,7 +38,7 @@ class WeikaiRegisterI2C : public weikai::WeikaiRegister { /// @brief The WeikaiComponentI2C class stores the information to the WeiKai component /// connected through an I2C bus. //////////////////////////////////////////////////////////////////////////////////// -class WeikaiComponentI2C : public weikai::WeikaiComponent, public i2c::I2CDevice { +class WeikaiComponentI2C final : public weikai::WeikaiComponent, public i2c::I2CDevice { public: weikai::WeikaiRegister ®(uint8_t reg, uint8_t channel) override { reg_i2c_.register_ = reg; diff --git a/esphome/components/weikai_spi/weikai_spi.h b/esphome/components/weikai_spi/weikai_spi.h index 3b581ef44c..cdfa148c24 100644 --- a/esphome/components/weikai_spi/weikai_spi.h +++ b/esphome/components/weikai_spi/weikai_spi.h @@ -31,9 +31,9 @@ class WeikaiRegisterSPI : public weikai::WeikaiRegister { /// @brief The WeikaiComponentSPI class stores the information to the WeiKai component /// connected through an SPI bus. //////////////////////////////////////////////////////////////////////////////////// -class WeikaiComponentSPI : public weikai::WeikaiComponent, - public spi::SPIDevice { +class WeikaiComponentSPI final : public weikai::WeikaiComponent, + public spi::SPIDevice { public: weikai::WeikaiRegister ®(uint8_t reg, uint8_t channel) override { reg_spi_.register_ = reg; diff --git a/esphome/components/whirlpool/whirlpool.h b/esphome/components/whirlpool/whirlpool.h index 03b4cf21a8..b705ee95fa 100644 --- a/esphome/components/whirlpool/whirlpool.h +++ b/esphome/components/whirlpool/whirlpool.h @@ -16,7 +16,7 @@ const float WHIRLPOOL_DG11J1_3A_TEMP_MIN = 18.0; const float WHIRLPOOL_DG11J1_91_TEMP_MAX = 30.0; const float WHIRLPOOL_DG11J1_91_TEMP_MIN = 16.0; -class WhirlpoolClimate : public climate_ir::ClimateIR { +class WhirlpoolClimate final : public climate_ir::ClimateIR { public: WhirlpoolClimate(); diff --git a/esphome/components/whynter/whynter.h b/esphome/components/whynter/whynter.h index d67bfa8fa0..fa8f201b05 100644 --- a/esphome/components/whynter/whynter.h +++ b/esphome/components/whynter/whynter.h @@ -12,7 +12,7 @@ const uint8_t TEMP_MAX_C = 32; // Celsius const uint8_t TEMP_MIN_F = 61; // Fahrenheit const uint8_t TEMP_MAX_F = 89; // Fahrenheit -class Whynter : public climate_ir::ClimateIR { +class Whynter final : public climate_ir::ClimateIR { public: Whynter() : climate_ir::ClimateIR(TEMP_MIN_C, TEMP_MAX_C, 1.0, true, true, diff --git a/esphome/components/wiegand/wiegand.h b/esphome/components/wiegand/wiegand.h index 33d81ba086..079f02ed68 100644 --- a/esphome/components/wiegand/wiegand.h +++ b/esphome/components/wiegand/wiegand.h @@ -21,13 +21,13 @@ struct WiegandStore { static void d1_gpio_intr(WiegandStore *arg); }; -class WiegandTagTrigger : public Trigger {}; +class WiegandTagTrigger final : public Trigger {}; -class WiegandRawTrigger : public Trigger {}; +class WiegandRawTrigger final : public Trigger {}; -class WiegandKeyTrigger : public Trigger {}; +class WiegandKeyTrigger final : public Trigger {}; -class Wiegand : public key_provider::KeyProvider, public Component { +class Wiegand final : public key_provider::KeyProvider, public Component { public: float get_setup_priority() const override { return setup_priority::HARDWARE; } void setup() override; diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index 1ad69b3992..e63faa18ab 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -6,32 +6,32 @@ namespace esphome::wifi { -template class WiFiConnectedCondition : public Condition { +template class WiFiConnectedCondition final : public Condition { public: bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } }; -template class WiFiEnabledCondition : public Condition { +template class WiFiEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } }; -template class WiFiAPActiveCondition : public Condition { +template class WiFiAPActiveCondition final : public Condition { public: bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } }; -template class WiFiEnableAction : public Action { +template class WiFiEnableAction final : public Action { public: void play(const Ts &...x) override { global_wifi_component->enable(); } }; -template class WiFiDisableAction : public Action { +template class WiFiDisableAction final : public Action { public: void play(const Ts &...x) override { global_wifi_component->disable(); } }; -template class WiFiConfigureAction : public Action, public Component { +template class WiFiConfigureAction final : public Action, public Component { public: TEMPLATABLE_VALUE(std::string, ssid) TEMPLATABLE_VALUE(std::string, password) diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index 9ff4cc54a0..af41465e71 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -10,9 +10,9 @@ namespace esphome::wifi_signal { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS -class WiFiSignalSensor : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { +class WiFiSignalSensor final : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { #else -class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { +class WiFiSignalSensor final : public sensor::Sensor, public PollingComponent { #endif public: #ifdef USE_WIFI_CONNECT_STATE_LISTENERS diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index c11d592cd1..1fda802415 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -32,7 +32,7 @@ struct AllowedIP { }; /// Main Wireguard component class. -class Wireguard : public PollingComponent { +class Wireguard final : public PollingComponent { public: void setup() override; void loop() override; @@ -165,25 +165,26 @@ static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; void mask_key_to(char *buffer, size_t len, const char *key); /// Condition to check if remote peer is online. -template class WireguardPeerOnlineCondition : public Condition, public Parented { +template +class WireguardPeerOnlineCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_peer_up(); } }; /// Condition to check if Wireguard component is enabled. -template class WireguardEnabledCondition : public Condition, public Parented { +template class WireguardEnabledCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_enabled(); } }; /// Action to enable Wireguard component. -template class WireguardEnableAction : public Action, public Parented { +template class WireguardEnableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->enable(); } }; /// Action to disable Wireguard component. -template class WireguardDisableAction : public Action, public Parented { +template class WireguardDisableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->disable(); } }; diff --git a/esphome/components/wl_134/wl_134.h b/esphome/components/wl_134/wl_134.h index 973e5a1e7c..fad64bd8ff 100644 --- a/esphome/components/wl_134/wl_134.h +++ b/esphome/components/wl_134/wl_134.h @@ -8,7 +8,7 @@ namespace esphome::wl_134 { -class Wl134Component : public text_sensor::TextSensor, public Component, public uart::UARTDevice { +class Wl134Component final : public text_sensor::TextSensor, public Component, public uart::UARTDevice { public: enum Rfid134Error { RFID134_ERROR_NONE, From 2067da4ff5aa43a6aa6596265bc3d0a446027397 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:33:07 +1200 Subject: [PATCH 1990/2214] Mark configurable classes as final (11/21: microphone-ms8607) (#16962) --- .../components/micro_wake_word/automation.h | 12 +++++------ .../micro_wake_word/micro_wake_word.h | 4 ++-- esphome/components/microphone/automation.h | 14 ++++++------- .../components/microphone/microphone_source.h | 2 +- esphome/components/mics_4514/mics_4514.h | 2 +- esphome/components/midea/air_conditioner.h | 2 +- esphome/components/midea_ir/midea_ir.h | 2 +- esphome/components/mipi_dsi/mipi_dsi.h | 2 +- esphome/components/mipi_rgb/mipi_rgb.h | 6 +++--- esphome/components/mitsubishi/mitsubishi.h | 2 +- esphome/components/mixer/speaker/automation.h | 2 +- .../components/mixer/speaker/mixer_speaker.h | 4 ++-- esphome/components/mlx90393/sensor_mlx90393.h | 2 +- esphome/components/mlx90614/mlx90614.h | 2 +- esphome/components/mmc5603/mmc5603.h | 2 +- esphome/components/mmc5983/mmc5983.h | 2 +- .../binary_sensor/modbus_binarysensor.h | 2 +- .../modbus_controller/modbus_controller.h | 2 +- .../modbus_controller/number/modbus_number.h | 2 +- .../modbus_controller/output/modbus_output.h | 4 ++-- .../modbus_controller/select/modbus_select.h | 2 +- .../modbus_controller/sensor/modbus_sensor.h | 2 +- .../modbus_controller/switch/modbus_switch.h | 2 +- .../text_sensor/modbus_textsensor.h | 2 +- .../components/modbus_server/modbus_server.h | 2 +- .../monochromatic_light_output.h | 2 +- esphome/components/mopeka_ble/mopeka_ble.h | 2 +- .../mopeka_pro_check/mopeka_pro_check.h | 2 +- .../mopeka_std_check/mopeka_std_check.h | 2 +- esphome/components/motion/motion_component.h | 6 +++--- esphome/components/mpl3115a2/mpl3115a2.h | 2 +- .../binary_sensor/mpr121_binary_sensor.h | 4 +++- esphome/components/mpr121/mpr121.h | 4 ++-- esphome/components/mpu6050/mpu6050.h | 2 +- esphome/components/mpu6886/mpu6886.h | 2 +- .../mqtt/mqtt_alarm_control_panel.h | 2 +- esphome/components/mqtt/mqtt_binary_sensor.h | 2 +- esphome/components/mqtt/mqtt_button.h | 2 +- esphome/components/mqtt/mqtt_client.h | 20 +++++++++---------- esphome/components/mqtt/mqtt_climate.h | 2 +- esphome/components/mqtt/mqtt_cover.h | 2 +- esphome/components/mqtt/mqtt_date.h | 2 +- esphome/components/mqtt/mqtt_datetime.h | 2 +- esphome/components/mqtt/mqtt_event.h | 2 +- esphome/components/mqtt/mqtt_fan.h | 2 +- esphome/components/mqtt/mqtt_light.h | 2 +- esphome/components/mqtt/mqtt_lock.h | 2 +- esphome/components/mqtt/mqtt_number.h | 2 +- esphome/components/mqtt/mqtt_select.h | 2 +- esphome/components/mqtt/mqtt_sensor.h | 2 +- esphome/components/mqtt/mqtt_switch.h | 2 +- esphome/components/mqtt/mqtt_text.h | 2 +- esphome/components/mqtt/mqtt_text_sensor.h | 2 +- esphome/components/mqtt/mqtt_time.h | 2 +- esphome/components/mqtt/mqtt_update.h | 2 +- esphome/components/mqtt/mqtt_valve.h | 2 +- .../sensor/mqtt_subscribe_sensor.h | 2 +- .../text_sensor/mqtt_subscribe_text_sensor.h | 2 +- esphome/components/ms5611/ms5611.h | 2 +- 59 files changed, 89 insertions(+), 87 deletions(-) diff --git a/esphome/components/micro_wake_word/automation.h b/esphome/components/micro_wake_word/automation.h index e3b35583fb..59dfc624fa 100644 --- a/esphome/components/micro_wake_word/automation.h +++ b/esphome/components/micro_wake_word/automation.h @@ -7,22 +7,22 @@ namespace esphome::micro_wake_word { -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class IsRunningCondition : public Condition, public Parented { +template class IsRunningCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class EnableModelAction : public Action { +template class EnableModelAction final : public Action { public: explicit EnableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} void play(const Ts &...x) override { this->wake_word_model_->enable(); } @@ -31,7 +31,7 @@ template class EnableModelAction : public Action { WakeWordModel *wake_word_model_; }; -template class DisableModelAction : public Action { +template class DisableModelAction final : public Action { public: explicit DisableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} void play(const Ts &...x) override { this->wake_word_model_->disable(); } @@ -40,7 +40,7 @@ template class DisableModelAction : public Action { WakeWordModel *wake_word_model_; }; -template class ModelIsEnabledCondition : public Condition { +template class ModelIsEnabledCondition final : public Condition { public: explicit ModelIsEnabledCondition(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} bool check(const Ts &...x) override { return this->wake_word_model_->is_enabled(); } diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index e4c590a423..aebb5b2595 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -31,10 +31,10 @@ enum State { STOPPED, }; -class MicroWakeWord : public Component +class MicroWakeWord final : public Component #ifdef USE_OTA_STATE_LISTENER , - public ota::OTAGlobalStateListener + public ota::OTAGlobalStateListener #endif { public: diff --git a/esphome/components/microphone/automation.h b/esphome/components/microphone/automation.h index 1dfd91f903..c28616a290 100644 --- a/esphome/components/microphone/automation.h +++ b/esphome/components/microphone/automation.h @@ -7,34 +7,34 @@ namespace esphome::microphone { -template class CaptureAction : public Action, public Parented { +template class CaptureAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopCaptureAction : public Action, public Parented { +template class StopCaptureAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->stop(); } }; -template class MuteAction : public Action, public Parented { +template class MuteAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_mute_state(true); } }; -template class UnmuteAction : public Action, public Parented { +template class UnmuteAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_mute_state(false); } }; -class DataTrigger : public Trigger &> { +class DataTrigger final : public Trigger &> { public: explicit DataTrigger(Microphone *mic) { mic->add_data_callback([this](const std::vector &data) { this->trigger(data); }); } }; -template class IsCapturingCondition : public Condition, public Parented { +template class IsCapturingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class IsMutedCondition : public Condition, public Parented { +template class IsMutedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->get_mute_state(); } }; diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index c3c675e854..7be3b8cdb5 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -13,7 +13,7 @@ namespace esphome::microphone { static const int32_t MAX_GAIN_FACTOR = 64; -class MicrophoneSource { +class MicrophoneSource final { /* * @brief Helper class that handles converting raw microphone data to a requested format. * Components requesting microphone audio should register a callback through this class instead of registering a diff --git a/esphome/components/mics_4514/mics_4514.h b/esphome/components/mics_4514/mics_4514.h index 4f8b970f06..d8c422808a 100644 --- a/esphome/components/mics_4514/mics_4514.h +++ b/esphome/components/mics_4514/mics_4514.h @@ -7,7 +7,7 @@ namespace esphome::mics_4514 { -class MICS4514Component : public PollingComponent, public i2c::I2CDevice { +class MICS4514Component final : public PollingComponent, public i2c::I2CDevice { SUB_SENSOR(carbon_monoxide) SUB_SENSOR(nitrogen_dioxide) SUB_SENSOR(methane) diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 6ed5a82ff5..bea6c2eadb 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -21,7 +21,7 @@ using climate::ClimateModeMask; using climate::ClimateSwingModeMask; using climate::ClimatePresetMask; -class AirConditioner : public ApplianceBase, public climate::Climate { +class AirConditioner final : public ApplianceBase, public climate::Climate { public: void dump_config() override; void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } diff --git a/esphome/components/midea_ir/midea_ir.h b/esphome/components/midea_ir/midea_ir.h index dd883172d4..e89eaf0110 100644 --- a/esphome/components/midea_ir/midea_ir.h +++ b/esphome/components/midea_ir/midea_ir.h @@ -11,7 +11,7 @@ const uint8_t MIDEA_TEMPC_MAX = 30; // Celsius const uint8_t MIDEA_TEMPF_MIN = 62; // Fahrenheit const uint8_t MIDEA_TEMPF_MAX = 86; // Fahrenheit -class MideaIR : public climate_ir::ClimateIR { +class MideaIR final : public climate_ir::ClimateIR { public: MideaIR() : climate_ir::ClimateIR( diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index c99f69989a..7bf2feb73c 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -35,7 +35,7 @@ const uint8_t MADCTL_MV = 0x20; // row/column swap const uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally const uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically -class MipiDsi : public display::Display { +class MipiDsi final : public display::Display { public: MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index dfa8a36e1a..1480004833 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -98,9 +98,9 @@ class MipiRgb : public display::Display { }; #ifdef USE_SPI -class MipiRgbSpi : public MipiRgb, - public spi::SPIDevice { +class MipiRgbSpi final : public MipiRgb, + public spi::SPIDevice { public: MipiRgbSpi(int width, int height) : MipiRgb(width, height) {} diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 769390ce3a..7925b7ce44 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -38,7 +38,7 @@ enum VerticalDirection { VERTICAL_DIRECTION_DOWN = 0x28, }; -class MitsubishiClimate : public climate_ir::ClimateIR { +class MitsubishiClimate final : public climate_ir::ClimateIR { public: MitsubishiClimate() : climate_ir::ClimateIR(MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h index cdfda0c700..ea51b6b889 100644 --- a/esphome/components/mixer/speaker/automation.h +++ b/esphome/components/mixer/speaker/automation.h @@ -6,7 +6,7 @@ #ifdef USE_ESP32 namespace esphome::mixer_speaker { -template class DuckingApplyAction : public Action, public Parented { +template class DuckingApplyAction final : public Action, public Parented { TEMPLATABLE_VALUE(uint8_t, decibel_reduction); TEMPLATABLE_VALUE(uint32_t, duration); void play(const Ts &...x) override { diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index f1ae919b50..00e89d1782 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -44,7 +44,7 @@ namespace esphome::mixer_speaker { class MixerSpeaker; -class SourceSpeaker : public speaker::Speaker, public Component { +class SourceSpeaker final : public speaker::Speaker, public Component { public: void dump_config() override; void setup() override; @@ -118,7 +118,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { uint32_t stopping_start_ms_{0}; }; -class MixerSpeaker : public Component { +class MixerSpeaker final : public Component { public: void dump_config() override; void setup() override; diff --git a/esphome/components/mlx90393/sensor_mlx90393.h b/esphome/components/mlx90393/sensor_mlx90393.h index 28053216e2..e3b7ae5d93 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.h +++ b/esphome/components/mlx90393/sensor_mlx90393.h @@ -20,7 +20,7 @@ enum MLX90393Setting { MLX90393_LAST, }; -class MLX90393Cls : public PollingComponent, public i2c::I2CDevice, public MLX90393Hal { +class MLX90393Cls final : public PollingComponent, public i2c::I2CDevice, public MLX90393Hal { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index 12081f20ac..882ee45186 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -6,7 +6,7 @@ namespace esphome::mlx90614 { -class MLX90614Component : public PollingComponent, public i2c::I2CDevice { +class MLX90614Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mmc5603/mmc5603.h b/esphome/components/mmc5603/mmc5603.h index 0d8eb152a7..d291e6d272 100644 --- a/esphome/components/mmc5603/mmc5603.h +++ b/esphome/components/mmc5603/mmc5603.h @@ -12,7 +12,7 @@ enum MMC5603Datarate { MMC5603_DATARATE_255_0_HZ, }; -class MMC5603Component : public PollingComponent, public i2c::I2CDevice { +class MMC5603Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mmc5983/mmc5983.h b/esphome/components/mmc5983/mmc5983.h index 020d3b2e4c..3ab9e86dcd 100644 --- a/esphome/components/mmc5983/mmc5983.h +++ b/esphome/components/mmc5983/mmc5983.h @@ -6,7 +6,7 @@ namespace esphome::mmc5983 { -class MMC5983Component : public PollingComponent, public i2c::I2CDevice { +class MMC5983Component final : public PollingComponent, public i2c::I2CDevice { public: void update() override; void setup() override; diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 98c6840e15..3f7c6b4dd6 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusBinarySensor : public Component, public binary_sensor::BinarySensor, public SensorItem { +class ModbusBinarySensor final : public Component, public binary_sensor::BinarySensor, public SensorItem { public: ModbusBinarySensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 4f674b2675..501fadbcf1 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -279,7 +279,7 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController : public PollingComponent, public modbus::ModbusClientDevice { +class ModbusController final : public PollingComponent, public modbus::ModbusClientDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index dd8f418bfc..ce64099170 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { using value_to_data_t = std::function(float); -class ModbusNumber : public number::Number, public Component, public SensorItem { +class ModbusNumber final : public number::Number, public Component, public SensorItem { public: ModbusNumber(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index c5323e3bf3..d904e58bd7 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusFloatOutput : public output::FloatOutput, public Component, public SensorItem { +class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem { public: ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) { this->register_type = ModbusRegisterType::HOLDING; @@ -41,7 +41,7 @@ class ModbusFloatOutput : public output::FloatOutput, public Component, public S bool use_write_multiple_{false}; }; -class ModbusBinaryOutput : public output::BinaryOutput, public Component, public SensorItem { +class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem { public: ModbusBinaryOutput(uint16_t start_address, uint8_t offset) { this->register_type = ModbusRegisterType::COIL; diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index a736abd0db..fb9283305c 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -9,7 +9,7 @@ namespace esphome::modbus_controller { -class ModbusSelect : public Component, public select::Select, public SensorItem { +class ModbusSelect final : public Component, public select::Select, public SensorItem { public: ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, uint16_t skip_updates, bool force_new_range, std::vector mapping) { diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index 2e6967b07c..ea4f560b9c 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusSensor : public Component, public sensor::Sensor, public SensorItem { +class ModbusSensor final : public Component, public sensor::Sensor, public SensorItem { public: ModbusSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 541a23706d..d6e991582d 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusSwitch : public Component, public switch_::Switch, public SensorItem { +class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem { public: ModbusSwitch(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index a99fea5860..e9130c98d4 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 }; -class ModbusTextSensor : public Component, public text_sensor::TextSensor, public SensorItem { +class ModbusTextSensor final : public Component, public text_sensor::TextSensor, public SensorItem { public: ModbusTextSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, uint16_t response_bytes, RawEncoding encode, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index f68d1c4a30..a5d193cb41 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -95,7 +95,7 @@ class ServerRegister { WriteLambda write_lambda; }; -class ModbusServer : public Component, public modbus::ModbusServerDevice { +class ModbusServer final : public Component, public modbus::ModbusServerDevice { public: void dump_config() override; diff --git a/esphome/components/monochromatic/monochromatic_light_output.h b/esphome/components/monochromatic/monochromatic_light_output.h index 458140ef09..eb81a10ee4 100644 --- a/esphome/components/monochromatic/monochromatic_light_output.h +++ b/esphome/components/monochromatic/monochromatic_light_output.h @@ -6,7 +6,7 @@ namespace esphome::monochromatic { -class MonochromaticLightOutput : public light::LightOutput { +class MonochromaticLightOutput final : public light::LightOutput { public: void set_output(output::FloatOutput *output) { output_ = output; } light::LightTraits get_traits() override { diff --git a/esphome/components/mopeka_ble/mopeka_ble.h b/esphome/components/mopeka_ble/mopeka_ble.h index cc91ef17d6..e6fae23aee 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.h +++ b/esphome/components/mopeka_ble/mopeka_ble.h @@ -9,7 +9,7 @@ namespace esphome::mopeka_ble { -class MopekaListener : public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; void set_show_sensors_without_sync(bool show_sensors_without_sync) { diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h index bfdfe80c48..40fb338350 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -27,7 +27,7 @@ enum SensorType { // measurement may be inaccurate. enum SensorReadQuality { QUALITY_HIGH = 0x3, QUALITY_MED = 0x2, QUALITY_LOW = 0x1, QUALITY_ZERO = 0x0 }; -class MopekaProCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaProCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.h b/esphome/components/mopeka_std_check/mopeka_std_check.h index a38abeabf0..2f1681f6ea 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.h +++ b/esphome/components/mopeka_std_check/mopeka_std_check.h @@ -42,7 +42,7 @@ struct mopeka_std_package { // NOLINT(readability-identifier-naming,altera-stru mopeka_std_values val[3]; } __attribute__((packed)); -class MopekaStdCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaStdCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index 00310c16fe..b0a074a17c 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -85,7 +85,7 @@ class MotionComponent : public PollingComponent { // --- Actions --- -template class CalibrateLevelAction : public Action { +template class CalibrateLevelAction final : public Action { public: explicit CalibrateLevelAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } @@ -110,7 +110,7 @@ template class CalibrateLevelAction : public Action { bool save_{false}; }; -template class CalibrateHeadingAction : public Action { +template class CalibrateHeadingAction final : public Action { public: explicit CalibrateHeadingAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } @@ -135,7 +135,7 @@ template class CalibrateHeadingAction : public Action { bool save_{false}; }; -template class ClearCalibrationAction : public Action { +template class ClearCalibrationAction final : public Action { public: explicit ClearCalibrationAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } diff --git a/esphome/components/mpl3115a2/mpl3115a2.h b/esphome/components/mpl3115a2/mpl3115a2.h index d78c9d571c..a6163673cb 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.h +++ b/esphome/components/mpl3115a2/mpl3115a2.h @@ -80,7 +80,7 @@ enum { MPL3115A2_CTRL_REG1_OS128 = 0x38, }; -class MPL3115A2Component : public PollingComponent, public i2c::I2CDevice { +class MPL3115A2Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_altitude(sensor::Sensor *altitude) { altitude_ = altitude; } diff --git a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h index 5fa10bf598..c0a4a36f1f 100644 --- a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h +++ b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h @@ -6,7 +6,9 @@ namespace esphome::mpr121 { -class MPR121BinarySensor : public binary_sensor::BinarySensor, public MPR121Channel, public Parented { +class MPR121BinarySensor final : public binary_sensor::BinarySensor, + public MPR121Channel, + public Parented { public: void set_channel(uint8_t channel) { this->channel_ = channel; } void set_touch_threshold(uint8_t touch_threshold) { this->touch_threshold_ = touch_threshold; }; diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 54b5c8abf4..64c4b291b3 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -57,7 +57,7 @@ class MPR121Channel { virtual void process(uint16_t data) = 0; }; -class MPR121Component : public Component, public i2c::I2CDevice { +class MPR121Component final : public Component, public i2c::I2CDevice { public: void register_channel(MPR121Channel *channel) { this->channels_.push_back(channel); } void set_touch_debounce(uint8_t debounce); @@ -102,7 +102,7 @@ class MPR121Component : public Component, public i2c::I2CDevice { }; /// Helper class to expose a MPR121 pin as an internal input GPIO pin. -class MPR121GPIOPin : public GPIOPin { +class MPR121GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mpu6050/mpu6050.h b/esphome/components/mpu6050/mpu6050.h index bac07cb4a5..4410bf0164 100644 --- a/esphome/components/mpu6050/mpu6050.h +++ b/esphome/components/mpu6050/mpu6050.h @@ -6,7 +6,7 @@ namespace esphome::mpu6050 { -class MPU6050Component : public PollingComponent, public i2c::I2CDevice { +class MPU6050Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mpu6886/mpu6886.h b/esphome/components/mpu6886/mpu6886.h index a23858a7b7..b795d5f690 100644 --- a/esphome/components/mpu6886/mpu6886.h +++ b/esphome/components/mpu6886/mpu6886.h @@ -6,7 +6,7 @@ namespace esphome::mpu6886 { -class MPU6886Component : public PollingComponent, public i2c::I2CDevice { +class MPU6886Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.h b/esphome/components/mqtt/mqtt_alarm_control_panel.h index 89a0ff1be8..b2da7ed6a2 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.h +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTAlarmControlPanelComponent : public mqtt::MQTTComponent { +class MQTTAlarmControlPanelComponent final : public mqtt::MQTTComponent { public: explicit MQTTAlarmControlPanelComponent(alarm_control_panel::AlarmControlPanel *alarm_control_panel); diff --git a/esphome/components/mqtt/mqtt_binary_sensor.h b/esphome/components/mqtt/mqtt_binary_sensor.h index 5917a9966c..75c224c65a 100644 --- a/esphome/components/mqtt/mqtt_binary_sensor.h +++ b/esphome/components/mqtt/mqtt_binary_sensor.h @@ -9,7 +9,7 @@ namespace esphome::mqtt { -class MQTTBinarySensorComponent : public mqtt::MQTTComponent { +class MQTTBinarySensorComponent final : public mqtt::MQTTComponent { public: /** Construct a MQTTBinarySensorComponent. * diff --git a/esphome/components/mqtt/mqtt_button.h b/esphome/components/mqtt/mqtt_button.h index a2db64d39d..7e2c77e29b 100644 --- a/esphome/components/mqtt/mqtt_button.h +++ b/esphome/components/mqtt/mqtt_button.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTButtonComponent : public mqtt::MQTTComponent { +class MQTTButtonComponent final : public mqtt::MQTTComponent { public: explicit MQTTButtonComponent(button::Button *button); diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 14473f737a..f741be561c 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -99,7 +99,7 @@ enum MQTTClientState { class MQTTComponent; -class MQTTClientComponent : public Component { +class MQTTClientComponent final : public Component { public: MQTTClientComponent(); @@ -340,7 +340,7 @@ class MQTTClientComponent : public Component { extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class MQTTMessageTrigger : public Trigger, public Component { +class MQTTMessageTrigger final : public Trigger, public Component { public: explicit MQTTMessageTrigger(std::string topic); @@ -356,7 +356,7 @@ class MQTTMessageTrigger : public Trigger, public Component { optional payload_; }; -class MQTTJsonMessageTrigger : public Trigger { +class MQTTJsonMessageTrigger final : public Trigger { public: explicit MQTTJsonMessageTrigger(const std::string &topic, uint8_t qos) { global_mqtt_client->subscribe_json( @@ -364,21 +364,21 @@ class MQTTJsonMessageTrigger : public Trigger { } }; -class MQTTConnectTrigger : public Trigger { +class MQTTConnectTrigger final : public Trigger { public: explicit MQTTConnectTrigger(MQTTClientComponent *client) { client->set_on_connect([this](bool session_present) { this->trigger(session_present); }); } }; -class MQTTDisconnectTrigger : public Trigger { +class MQTTDisconnectTrigger final : public Trigger { public: explicit MQTTDisconnectTrigger(MQTTClientComponent *client) { client->set_on_disconnect([this](MQTTClientDisconnectReason reason) { this->trigger(reason); }); } }; -template class MQTTPublishAction : public Action { +template class MQTTPublishAction final : public Action { public: MQTTPublishAction(MQTTClientComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, topic) @@ -395,7 +395,7 @@ template class MQTTPublishAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTPublishJsonAction : public Action { +template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, topic) @@ -417,7 +417,7 @@ template class MQTTPublishJsonAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTConnectedCondition : public Condition { +template class MQTTConnectedCondition final : public Condition { public: MQTTConnectedCondition(MQTTClientComponent *parent) : parent_(parent) {} bool check(const Ts &...x) override { return this->parent_->is_connected(); } @@ -426,7 +426,7 @@ template class MQTTConnectedCondition : public Condition MQTTClientComponent *parent_; }; -template class MQTTEnableAction : public Action { +template class MQTTEnableAction final : public Action { public: MQTTEnableAction(MQTTClientComponent *parent) : parent_(parent) {} @@ -436,7 +436,7 @@ template class MQTTEnableAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTDisableAction : public Action { +template class MQTTDisableAction final : public Action { public: MQTTDisableAction(MQTTClientComponent *parent) : parent_(parent) {} diff --git a/esphome/components/mqtt/mqtt_climate.h b/esphome/components/mqtt/mqtt_climate.h index f0715929d4..b862db85ae 100644 --- a/esphome/components/mqtt/mqtt_climate.h +++ b/esphome/components/mqtt/mqtt_climate.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTClimateComponent : public mqtt::MQTTComponent { +class MQTTClimateComponent final : public mqtt::MQTTComponent { public: MQTTClimateComponent(climate::Climate *device); void send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) override; diff --git a/esphome/components/mqtt/mqtt_cover.h b/esphome/components/mqtt/mqtt_cover.h index f801af5d12..3b07733993 100644 --- a/esphome/components/mqtt/mqtt_cover.h +++ b/esphome/components/mqtt/mqtt_cover.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTCoverComponent : public mqtt::MQTTComponent { +class MQTTCoverComponent final : public mqtt::MQTTComponent { public: explicit MQTTCoverComponent(cover::Cover *cover); diff --git a/esphome/components/mqtt/mqtt_date.h b/esphome/components/mqtt/mqtt_date.h index 4a626becb2..1c24422856 100644 --- a/esphome/components/mqtt/mqtt_date.h +++ b/esphome/components/mqtt/mqtt_date.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTDateComponent : public mqtt::MQTTComponent { +class MQTTDateComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTDateComponent instance with the provided friendly_name and date * diff --git a/esphome/components/mqtt/mqtt_datetime.h b/esphome/components/mqtt/mqtt_datetime.h index d02d6f579c..09af806fe3 100644 --- a/esphome/components/mqtt/mqtt_datetime.h +++ b/esphome/components/mqtt/mqtt_datetime.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTDateTimeComponent : public mqtt::MQTTComponent { +class MQTTDateTimeComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTDateTimeComponent instance with the provided friendly_name and time * diff --git a/esphome/components/mqtt/mqtt_event.h b/esphome/components/mqtt/mqtt_event.h index e6d5b6f278..424de3f603 100644 --- a/esphome/components/mqtt/mqtt_event.h +++ b/esphome/components/mqtt/mqtt_event.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTEventComponent : public mqtt::MQTTComponent { +class MQTTEventComponent final : public mqtt::MQTTComponent { public: explicit MQTTEventComponent(event::Event *event); diff --git a/esphome/components/mqtt/mqtt_fan.h b/esphome/components/mqtt/mqtt_fan.h index 43ef67e733..ff984bb77d 100644 --- a/esphome/components/mqtt/mqtt_fan.h +++ b/esphome/components/mqtt/mqtt_fan.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTFanComponent : public mqtt::MQTTComponent { +class MQTTFanComponent final : public mqtt::MQTTComponent { public: explicit MQTTFanComponent(fan::Fan *state); diff --git a/esphome/components/mqtt/mqtt_light.h b/esphome/components/mqtt/mqtt_light.h index 41981655ef..2ca8d70dd4 100644 --- a/esphome/components/mqtt/mqtt_light.h +++ b/esphome/components/mqtt/mqtt_light.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTJSONLightComponent : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { +class MQTTJSONLightComponent final : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { public: explicit MQTTJSONLightComponent(light::LightState *state); diff --git a/esphome/components/mqtt/mqtt_lock.h b/esphome/components/mqtt/mqtt_lock.h index 666882c73d..7f36a51789 100644 --- a/esphome/components/mqtt/mqtt_lock.h +++ b/esphome/components/mqtt/mqtt_lock.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTLockComponent : public mqtt::MQTTComponent { +class MQTTLockComponent final : public mqtt::MQTTComponent { public: explicit MQTTLockComponent(lock::Lock *a_lock); diff --git a/esphome/components/mqtt/mqtt_number.h b/esphome/components/mqtt/mqtt_number.h index 021a539988..5e21544691 100644 --- a/esphome/components/mqtt/mqtt_number.h +++ b/esphome/components/mqtt/mqtt_number.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTNumberComponent : public mqtt::MQTTComponent { +class MQTTNumberComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTNumberComponent instance with the provided friendly_name and number * diff --git a/esphome/components/mqtt/mqtt_select.h b/esphome/components/mqtt/mqtt_select.h index aaf174ff72..46140ad456 100644 --- a/esphome/components/mqtt/mqtt_select.h +++ b/esphome/components/mqtt/mqtt_select.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSelectComponent : public mqtt::MQTTComponent { +class MQTTSelectComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTSelectComponent instance with the provided friendly_name and select * diff --git a/esphome/components/mqtt/mqtt_sensor.h b/esphome/components/mqtt/mqtt_sensor.h index e8202aa8e2..1d5ee8095c 100644 --- a/esphome/components/mqtt/mqtt_sensor.h +++ b/esphome/components/mqtt/mqtt_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSensorComponent : public mqtt::MQTTComponent { +class MQTTSensorComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTSensorComponent instance with the provided friendly_name and sensor * diff --git a/esphome/components/mqtt/mqtt_switch.h b/esphome/components/mqtt/mqtt_switch.h index 5f6cb841fd..f35784ed5c 100644 --- a/esphome/components/mqtt/mqtt_switch.h +++ b/esphome/components/mqtt/mqtt_switch.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSwitchComponent : public mqtt::MQTTComponent { +class MQTTSwitchComponent final : public mqtt::MQTTComponent { public: explicit MQTTSwitchComponent(switch_::Switch *a_switch); diff --git a/esphome/components/mqtt/mqtt_text.h b/esphome/components/mqtt/mqtt_text.h index 8ae0b9e29a..d42eefc690 100644 --- a/esphome/components/mqtt/mqtt_text.h +++ b/esphome/components/mqtt/mqtt_text.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTextComponent : public mqtt::MQTTComponent { +class MQTTTextComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTTextComponent instance with the provided friendly_name and text * diff --git a/esphome/components/mqtt/mqtt_text_sensor.h b/esphome/components/mqtt/mqtt_text_sensor.h index d8f9315c1e..1fe9651fa1 100644 --- a/esphome/components/mqtt/mqtt_text_sensor.h +++ b/esphome/components/mqtt/mqtt_text_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTextSensor : public mqtt::MQTTComponent { +class MQTTTextSensor final : public mqtt::MQTTComponent { public: explicit MQTTTextSensor(text_sensor::TextSensor *sensor); diff --git a/esphome/components/mqtt/mqtt_time.h b/esphome/components/mqtt/mqtt_time.h index cf5780da2d..3e60176e90 100644 --- a/esphome/components/mqtt/mqtt_time.h +++ b/esphome/components/mqtt/mqtt_time.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTimeComponent : public mqtt::MQTTComponent { +class MQTTTimeComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTTimeComponent instance with the provided friendly_name and time * diff --git a/esphome/components/mqtt/mqtt_update.h b/esphome/components/mqtt/mqtt_update.h index ec1adb1fcd..04b0b09da1 100644 --- a/esphome/components/mqtt/mqtt_update.h +++ b/esphome/components/mqtt/mqtt_update.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTUpdateComponent : public mqtt::MQTTComponent { +class MQTTUpdateComponent final : public mqtt::MQTTComponent { public: explicit MQTTUpdateComponent(update::UpdateEntity *update); diff --git a/esphome/components/mqtt/mqtt_valve.h b/esphome/components/mqtt/mqtt_valve.h index d3b724a8ba..dd2cca514a 100644 --- a/esphome/components/mqtt/mqtt_valve.h +++ b/esphome/components/mqtt/mqtt_valve.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTValveComponent : public mqtt::MQTTComponent { +class MQTTValveComponent final : public mqtt::MQTTComponent { public: explicit MQTTValveComponent(valve::Valve *valve); diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h index 229c0586ab..739e8456ee 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt_subscribe { -class MQTTSubscribeSensor : public sensor::Sensor, public Component { +class MQTTSubscribeSensor final : public sensor::Sensor, public Component { public: void set_parent(mqtt::MQTTClientComponent *parent) { parent_ = parent; } void set_topic(const std::string &topic) { topic_ = topic; } diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h index f218bf2a8a..8641825fca 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt_subscribe { -class MQTTSubscribeTextSensor : public text_sensor::TextSensor, public Component { +class MQTTSubscribeTextSensor final : public text_sensor::TextSensor, public Component { public: void set_parent(mqtt::MQTTClientComponent *parent) { parent_ = parent; } void set_topic(const std::string &topic) { topic_ = topic; } diff --git a/esphome/components/ms5611/ms5611.h b/esphome/components/ms5611/ms5611.h index c6ad5b231a..535acdd357 100644 --- a/esphome/components/ms5611/ms5611.h +++ b/esphome/components/ms5611/ms5611.h @@ -6,7 +6,7 @@ namespace esphome::ms5611 { -class MS5611Component : public PollingComponent, public i2c::I2CDevice { +class MS5611Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; From 66ab807596555b5516561abe774995ff25b3a119 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 6 Jul 2026 09:43:31 -0700 Subject: [PATCH 1991/2214] [modbus] API naming (#17378) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/helpers.py | 2 +- esphome/components/modbus/modbus.cpp | 8 ++--- esphome/components/modbus/modbus.h | 23 ++++++------ .../components/modbus/modbus_definitions.h | 7 +++- esphome/components/modbus/modbus_helpers.h | 4 +-- .../components/modbus_controller/__init__.py | 2 +- .../modbus_server/modbus_server.cpp | 9 +++-- .../components/modbus_server/modbus_server.h | 7 ++-- .../modbus_server/modbus_server_test.cpp | 36 +++++++++---------- 9 files changed, 51 insertions(+), 47 deletions(-) diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index 6f97f1e605..9d7dc71547 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -29,7 +29,7 @@ MODBUS_WRITE_REGISTER_TYPE = { MODBUS_REGISTER_TYPE = { **MODBUS_WRITE_REGISTER_TYPE, "discrete_input": ModbusRegisterType.DISCRETE_INPUT, - "read": ModbusRegisterType.READ, + "read": ModbusRegisterType.INPUT_REGISTER, } SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 488bcf1459..eefab7967f 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -360,7 +360,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func return; } - ServerResponseStatus status; + ResponseStatus status; uint8_t response_buffer[modbus::MAX_RAW_SIZE]; const uint8_t *response_data = response_buffer; uint16_t response_len = 0; @@ -381,9 +381,9 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func } RegisterValues registers; if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS) { - status = device->on_modbus_read_holding_registers(start_address, number_of_registers, registers); + status = device->on_read_holding_registers(start_address, number_of_registers, registers); } else { - status = device->on_modbus_read_input_registers(start_address, number_of_registers, registers); + status = device->on_read_input_registers(start_address, number_of_registers, registers); } // A handler that returns an exception leaves registers partially filled, so check the exception @@ -436,7 +436,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func for (uint16_t i = 0; i < number_of_registers; i++) { registers.push_back(helpers::get_data(data, values_offset + i * 2)); } - status = device->on_modbus_write_registers(start_address, registers); + status = device->on_write_registers(start_address, registers); response_data = data; // echo the request header per Modbus 6.6, 6.12 response_len = 4; break; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index b0f2aed9f8..d995c441ad 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -201,8 +201,9 @@ class ModbusClientDevice { using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0", "2026.6.0") = ModbusClientDevice; -// Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return. -using ServerResponseStatus = std::optional; +// Transaction status: std::nullopt on success, otherwise the Modbus exception code. Server handlers return it; +// (future) client response callbacks receive it. Named without a side prefix so both directions share it. +using ResponseStatus = std::optional; // Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol // maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by // the capacity of this type. @@ -219,19 +220,19 @@ class ModbusServerDevice { ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; void set_address(uint8_t address) { this->address_ = address; } uint8_t get_address() const { return this->address_; } - virtual ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, - RegisterValues ®isters) { + virtual ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { return ModbusExceptionCode::ILLEGAL_FUNCTION; }; - virtual ServerResponseStatus on_modbus_read_input_registers(uint16_t start_address, uint16_t number_of_registers, - RegisterValues ®isters) { - return this->on_modbus_read_registers(start_address, number_of_registers, registers); + virtual ResponseStatus on_read_input_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_read_registers(start_address, number_of_registers, registers); }; - virtual ServerResponseStatus on_modbus_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, - RegisterValues ®isters) { - return this->on_modbus_read_registers(start_address, number_of_registers, registers); + virtual ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_read_registers(start_address, number_of_registers, registers); }; - virtual ServerResponseStatus on_modbus_write_registers(uint16_t start_address, const RegisterValues ®isters) { + virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) { return ModbusExceptionCode::ILLEGAL_FUNCTION; }; diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 1c03498f1d..a5bcc1e3fc 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome::modbus { @@ -48,7 +49,11 @@ enum class ModbusRegisterType : uint8_t { COIL = 0x01, DISCRETE_INPUT = 0x02, HOLDING = 0x03, - READ = 0x04, + // Named INPUT_REGISTER (not INPUT) because Arduino cores define INPUT as a macro. + INPUT_REGISTER = 0x04, + // Remove before 2027.2.0 + READ ESPDEPRECATED("Use ModbusRegisterType::INPUT_REGISTER instead. Removed in 2027.2.0", "2026.7.0") = + INPUT_REGISTER, }; // 7 MODBUS Exception Responses: diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index b7b9020945..fef0f915ea 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -90,7 +90,7 @@ inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_t return ModbusFunctionCode::READ_DISCRETE_INPUTS; case ModbusRegisterType::HOLDING: return ModbusFunctionCode::READ_HOLDING_REGISTERS; - case ModbusRegisterType::READ: + case ModbusRegisterType::INPUT_REGISTER: return ModbusFunctionCode::READ_INPUT_REGISTERS; default: return ModbusFunctionCode::INVALID; @@ -104,7 +104,7 @@ inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_ case ModbusRegisterType::HOLDING: return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER; // These register types can't be written (per spec) - case ModbusRegisterType::READ: + case ModbusRegisterType::INPUT_REGISTER: case ModbusRegisterType::DISCRETE_INPUT: default: return ModbusFunctionCode::INVALID; diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index cdbba54c1f..527e9b047f 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -220,7 +220,7 @@ def function_code_to_register(function_code): "read_coils": ModbusRegisterType.COIL, "read_discrete_inputs": ModbusRegisterType.DISCRETE_INPUT, "read_holding_registers": ModbusRegisterType.HOLDING, - "read_input_registers": ModbusRegisterType.READ, + "read_input_registers": ModbusRegisterType.INPUT_REGISTER, "write_single_coil": ModbusRegisterType.COIL, "write_single_register": ModbusRegisterType.HOLDING, "write_multiple_coils": ModbusRegisterType.COIL, diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 44b1b160a5..1f787a0b61 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -27,9 +27,8 @@ ServerRegister *ModbusServer::find_containing_register_(uint32_t address) const return nullptr; } -modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t start_address, - uint16_t number_of_registers, - modbus::RegisterValues ®isters) { +modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) { ESP_LOGV(TAG, "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); @@ -101,8 +100,8 @@ modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t sta return {}; } -modbus::ServerResponseStatus ModbusServer::on_modbus_write_registers(uint16_t start_address, - const modbus::RegisterValues ®isters) { +modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { // registers holds the values to write in host byte order; its size is the register count. ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", this->address_, start_address, registers.size()); diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index a5d193cb41..4fddd9854d 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -102,11 +102,10 @@ class ModbusServer final : public Component, public modbus::ModbusServerDevice { /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors - modbus::ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, - modbus::RegisterValues ®isters) final; + modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors - modbus::ServerResponseStatus on_modbus_write_registers(uint16_t start_address, - const modbus::RegisterValues ®isters) final; + modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) final; /// Called by esphome generated code to set the server courtesy response object void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { this->server_courtesy_response_ = server_courtesy_response; diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 419bb9cf25..d95bb473c9 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -29,7 +29,7 @@ TEST(ModbusServerWrite, SingleWordSucceeds) { }; server.add_server_register(®); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); EXPECT_FALSE(status.has_value()); // nullopt == success EXPECT_EQ(written, 0x1234); } @@ -45,7 +45,7 @@ TEST(ModbusServerWrite, DwordSucceeds) { }; server.add_server_register(®); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234, 0x5678})); + auto status = server.on_write_registers(0x0000, make_registers({0x1234, 0x5678})); EXPECT_FALSE(status.has_value()); EXPECT_EQ(written, 0x12345678); } @@ -70,7 +70,7 @@ TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) { server.add_server_register(&dword_reg); // Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs. - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1111, 0x2222})); + auto status = server.on_write_registers(0x0000, make_registers({0x1111, 0x2222})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE); @@ -84,7 +84,7 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set server.add_server_register(&read_only); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -93,7 +93,7 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { // An address with no registered register yields ILLEGAL_DATA_ADDRESS. TEST(ModbusServerWrite, UnmatchedAddressRejected) { ModbusServer server; - auto status = server.on_modbus_write_registers(0x0005, make_registers({0x1234})); + auto status = server.on_write_registers(0x0005, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -114,14 +114,14 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { server.add_server_register(&first); server.add_server_register(&second); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); + auto status = server.on_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE); EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure } -// --- on_modbus_read_registers -------------------------------------------------- +// --- on_read_registers -------------------------------------------------- TEST(ModbusServerRead, SingleWordSucceeds) { ModbusServer server; @@ -130,7 +130,7 @@ TEST(ModbusServerRead, SingleWordSucceeds) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 1, out); + auto status = server.on_read_registers(0x0000, 1, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 1u); EXPECT_EQ(out[0], 0x1234); @@ -143,7 +143,7 @@ TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 2, out); + auto status = server.on_read_registers(0x0000, 2, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 2u); EXPECT_EQ(out[0], 0x1234); @@ -165,7 +165,7 @@ TEST(ModbusServerRead, StartInsideValueRejected) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0011, 1, out); // the second cell of the DWORD + auto status = server.on_read_registers(0x0011, 1, out); // the second cell of the DWORD ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -184,7 +184,7 @@ TEST(ModbusServerRead, ClippedTailRejected) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers + auto status = server.on_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -200,7 +200,7 @@ TEST(ModbusServerRead, WriteOnlyRegisterRejected) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 1, out); + auto status = server.on_read_registers(0x0000, 1, out); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -213,7 +213,7 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0005, 2, out); + auto status = server.on_read_registers(0x0005, 2, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 2u); EXPECT_EQ(out[0], 0xABCD); @@ -224,7 +224,7 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { ModbusServer server; RegisterValues out; - auto status = server.on_modbus_read_registers(0x0005, 1, out); + auto status = server.on_read_registers(0x0005, 1, out); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -241,7 +241,7 @@ TEST(ModbusServerRead, PartialReadHighWord) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0010, 1, out); + auto status = server.on_read_registers(0x0010, 1, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 1u); EXPECT_EQ(out[0], 0x1234); @@ -256,7 +256,7 @@ TEST(ModbusServerRead, PartialReadLowWordFromInterior) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0011, 1, out); + auto status = server.on_read_registers(0x0011, 1, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 1u); EXPECT_EQ(out[0], 0x5678); @@ -272,12 +272,12 @@ TEST(ModbusServerRead, PartialReadReversedType) { server.add_server_register(®); RegisterValues first; - ASSERT_FALSE(server.on_modbus_read_registers(0x0010, 1, first).has_value()); + ASSERT_FALSE(server.on_read_registers(0x0010, 1, first).has_value()); ASSERT_EQ(first.size(), 1u); EXPECT_EQ(first[0], 0x5678); RegisterValues second; - ASSERT_FALSE(server.on_modbus_read_registers(0x0011, 1, second).has_value()); + ASSERT_FALSE(server.on_read_registers(0x0011, 1, second).has_value()); ASSERT_EQ(second.size(), 1u); EXPECT_EQ(second[0], 0x1234); } From b79db760999ae6bcaeacf56bfc6d32dc9205e493 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Mon, 6 Jul 2026 20:54:20 +0200 Subject: [PATCH 1992/2214] [core] helpers.h - Implement pop_back method for vector (#17390) --- esphome/core/helpers.h | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 07bcb7a74f..a212019628 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -683,6 +683,15 @@ template class FixedVector { T &back() { return data_[size_ - 1]; } const T &back() const { return data_[size_ - 1]; } + /// Remove the last element in place (no reallocation, keeps capacity) + /// Caller must ensure vector is not empty (size() > 0) + void pop_back() { + if constexpr (!std::is_trivially_destructible::value) { + data_[size_ - 1].~T(); + } + size_--; + } + size_t size() const { return size_; } bool empty() const { return size_ == 0; } size_t capacity() const { return capacity_; } From e64a79f43137627c32fc60b6f0ef34b27f44ffa6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:29 -0500 Subject: [PATCH 1993/2214] Bump setuptools from 82.0.1 to 83.0.0 (#17426) Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e959578553..f38633b4ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==82.0.1", "wheel>=0.43,<0.48"] +requires = ["setuptools==83.0.0", "wheel>=0.43,<0.48"] build-backend = "setuptools.build_meta" [project] From 104c2f86f6de5eefd49e281ae1926feda700e829 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:43 -0500 Subject: [PATCH 1994/2214] Bump astral-sh/setup-uv from 8.2.0 to 8.3.0 in /.github/actions/restore-python (#17427) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 1364e95602..8ef0bca2ec 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 27d4b63a8a36e09b404505a18e5a1e176c8aeb44 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:55 -0500 Subject: [PATCH 1995/2214] Bump astral-sh/setup-uv from 8.2.0 to 8.3.0 (#17428) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 4c0c330a19..721585a44d 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34f8ed4878..11e29db94a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -171,7 +171,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -372,7 +372,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 0501d6d364..2efaec4e94 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 51fa25856d68300862fc5668e1705536d3b03b2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 14:42:14 -0500 Subject: [PATCH 1996/2214] [bluetooth_proxy] Take over stale advertisement subscription instead of rejecting the new subscriber (#17423) --- .../components/bluetooth_proxy/bluetooth_proxy.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index ca30aab943..37ebcad8b4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -379,9 +379,17 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn } void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); - return; + if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { + // A previous subscriber still holds the slot. This is almost always a stale + // connection from a client that dropped without a clean disconnect and has + // not yet hit the keepalive timeout; rejecting the new subscriber would + // silently starve it of advertisements until it reconnects, so the newest + // subscriber wins instead. + char old_peername[socket::SOCKADDR_STR_LEN]; + char new_peername[socket::SOCKADDR_STR_LEN]; + ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), + api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), + this->api_connection_->get_peername_to(old_peername)); } this->api_connection_ = api_connection; this->parent_->recalculate_advertisement_parser_types(); From 90403576c407a1610c71261b93be08b2eb3d2cef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 14:42:30 -0500 Subject: [PATCH 1997/2214] [wifi] Accept boolean-like strings for fast_connect again (#17414) --- esphome/components/wifi/__init__.py | 8 +++++--- .../validate-fast-connect-substitution.esp8266-ard.yaml | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 111f4cfc84..abce1fd5c0 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,5 +1,6 @@ import logging import math +from typing import Any from esphome import automation, preferences from esphome.automation import Condition @@ -444,9 +445,10 @@ FAST_CONNECT_SCHEMA = cv.Schema( ) -def _fast_connect_schema(value): - """Accept the historic plain boolean or a dict with enabled/storage keys.""" - if isinstance(value, bool): +def _fast_connect_schema(value: Any) -> ConfigType: + """Accept the historic plain boolean (including boolean-like strings from + substitutions) or a dict with enabled/storage keys.""" + if not isinstance(value, dict): value = {CONF_ENABLED: value} return FAST_CONNECT_SCHEMA(value) diff --git a/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml b/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml new file mode 100644 index 0000000000..f9fab8261a --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml @@ -0,0 +1,9 @@ +# fast_connect passed through a substitution arrives as a string ("false"), +# which must be accepted like the historic plain boolean form. +substitutions: + fast_connect_value: "false" + +wifi: + ssid: MySSID + password: password1 + fast_connect: ${fast_connect_value} From 39ad583b39f8f14fb3abf5063659488f80abf6ad Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 6 Jul 2026 21:48:24 +0200 Subject: [PATCH 1998/2214] [nrf52] allow to build for non nrf52840 boards (#17373) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 23 ++++++++++--------- .../components/nrf52/test.nrf52-microbit.yaml | 1 + .../build_components_base.nrf52-microbit.yaml | 16 +++++++++++++ 3 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 tests/components/nrf52/test.nrf52-microbit.yaml create mode 100644 tests/test_build_components/build_components_base.nrf52-microbit.yaml diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 661fc0758e..692b2637b2 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -233,7 +233,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), cv.Optional(CONF_DFU): _dfu_schema, - cv.Optional(CONF_DCDC, default=True): cv.boolean, + cv.Optional(CONF_DCDC): cv.boolean, cv.Optional(CONF_REG0): cv.Schema( { cv.Required(CONF_VOLTAGE): cv.All( @@ -367,16 +367,17 @@ async def to_code(config: ConfigType) -> None: if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - if framework_ver < cv.Version(2, 9, 2): - zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) - else: - zephyr_add_overlay( - f""" - ®1 {{ - regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>; - }}; - """ - ) + if CONF_DCDC in config: + if framework_ver < cv.Version(2, 9, 2): + zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) + else: + zephyr_add_overlay( + f""" + ®1 {{ + regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>; + }}; + """ + ) if reg0_config := config.get(CONF_REG0): value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE]) diff --git a/tests/components/nrf52/test.nrf52-microbit.yaml b/tests/components/nrf52/test.nrf52-microbit.yaml new file mode 100644 index 0000000000..d27f9ff699 --- /dev/null +++ b/tests/components/nrf52/test.nrf52-microbit.yaml @@ -0,0 +1 @@ +nrf52: diff --git a/tests/test_build_components/build_components_base.nrf52-microbit.yaml b/tests/test_build_components/build_components_base.nrf52-microbit.yaml new file mode 100644 index 0000000000..37728b4b64 --- /dev/null +++ b/tests/test_build_components/build_components_base.nrf52-microbit.yaml @@ -0,0 +1,16 @@ +esphome: + name: componenttestnrf52 + friendly_name: $component_name + +nrf52: + board: bbc_microbit + +logger: + level: VERY_VERBOSE + hardware_uart: UART0 + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From 468b32b9865989a349eab2712dc5bb1dda2ea941 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 6 Jul 2026 21:50:57 +0200 Subject: [PATCH 1999/2214] [nrf52] add better error message for OTA error (#17407) --- esphome/components/nrf52/ota.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index 5d608acbac..cafeda6478 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -5,7 +5,7 @@ import logging from pathlib import Path from bleak import BleakScanner -from bleak.exc import BleakDeviceNotFoundError +from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient from smpclient.generics import error, success @@ -98,6 +98,12 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: await smp_client.connect() except BleakDeviceNotFoundError as exc: raise EsphomeError(f"Device {device} not found") from exc + except BleakDBusError as exc: + if "NotPermitted" in exc.dbus_error: + raise EsphomeError( + f"Cannot connect to {device}: Make sure the device is paired." + ) from exc + raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc except SMPBLETransportException as exc: raise EsphomeError(f"Connection error with {device}") from exc From 9caf4317403bb7bfeae4ab31801532f4895e98a5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:55:53 +1200 Subject: [PATCH 2000/2214] [tests] Document dict-style packages requirement for batch grouping (#17420) --- AGENTS.md | 9 +++++---- tests/test_build_components/common/README.md | 5 ++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a01626ee4..75a9cdb2bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -427,13 +427,14 @@ This document provides essential context for AI models interacting with this pro When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes. - * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`: + * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`. + + All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`): ```yaml - # test.esp32-idf.yaml — use packages for buses + # test.esp32-idf.yaml — everything included via named packages packages: uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml - - <<: !include common.yaml + my_component: !include common.yaml ``` ```yaml # common.yaml — component config only, NO bus definitions diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 5e925d0067..a3c6f476e0 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -45,14 +45,13 @@ common/ ## How It Works ### Component Test Structure -Each component test includes the common bus config: +Each component test includes the common bus config and its own `common.yaml` through dict-style `packages:`. Always use packages for every include — the grouping scripts only understand dict-style packages, so list-style packages or top-level `<<:` merge keys prevent correct batch grouping. Key the bus package by the bus name and the component's `common.yaml` by the component name: ```yaml # tests/components/bh1750/test.esp32-idf.yaml packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + bh1750: !include common.yaml ``` The common config provides: From cdd334284ecb99fb127c79d45e10104f99ca49ae Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:20:24 +1200 Subject: [PATCH 2001/2214] [rp2] Rename rp2040 platform to rp2 (#17145) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- CODEOWNERS | 2 +- esphome/__main__.py | 10 +- esphome/components/__init__.py | 6 + esphome/components/adc/__init__.py | 8 +- esphome/components/adc/adc_sensor.h | 8 +- ...c_sensor_rp2040.cpp => adc_sensor_rp2.cpp} | 6 +- esphome/components/adc/sensor.py | 2 +- esphome/components/api/__init__.py | 6 +- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_connection.h | 8 +- esphome/components/async_tcp/__init__.py | 6 +- esphome/components/async_tcp/async_tcp.h | 2 +- .../components/async_tcp/async_tcp_socket.cpp | 2 +- .../components/async_tcp/async_tcp_socket.h | 2 +- esphome/components/captive_portal/__init__.py | 6 +- esphome/components/debug/__init__.py | 2 +- .../debug/{debug_rp2040.cpp => debug_rp2.cpp} | 10 +- esphome/components/esp8266/helpers.cpp | 2 +- esphome/components/esphome/ota/__init__.py | 2 +- .../components/esphome/ota/ota_esphome.cpp | 2 +- esphome/components/ethernet/__init__.py | 28 +- .../components/ethernet/ethernet_component.h | 10 +- ..._rp2040.cpp => ethernet_component_rp2.cpp} | 8 +- .../factory_reset/factory_reset.cpp | 4 +- .../components/factory_reset/factory_reset.h | 4 +- .../components/gpio/binary_sensor/__init__.py | 2 +- .../components/hmac_sha256/hmac_sha256.cpp | 2 +- esphome/components/hmac_sha256/hmac_sha256.h | 2 +- esphome/components/http_request/__init__.py | 12 +- .../http_request/http_request_arduino.cpp | 2 +- .../http_request/http_request_arduino.h | 2 +- .../components/http_request/ota/__init__.py | 2 +- esphome/components/i2c/__init__.py | 16 +- esphome/components/i2c/i2c_bus_arduino.cpp | 8 +- ...p2040.cpp => internal_temperature_rp2.cpp} | 6 +- .../components/internal_temperature/sensor.py | 6 +- esphome/components/logger/__init__.py | 18 +- esphome/components/logger/logger.cpp | 2 +- esphome/components/logger/logger.h | 16 +- .../{logger_rp2040.cpp => logger_rp2.cpp} | 12 +- .../logger/{logger_rp2040.h => logger_rp2.h} | 2 +- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- esphome/components/md5/md5.cpp | 8 +- esphome/components/md5/md5.h | 2 +- esphome/components/mdns/__init__.py | 12 +- esphome/components/mdns/mdns_component.cpp | 8 +- esphome/components/mdns/mdns_component.h | 4 +- .../mdns/{mdns_rp2040.cpp => mdns_rp2.cpp} | 6 +- esphome/components/mqtt/mqtt_component.cpp | 2 +- esphome/components/network/__init__.py | 6 +- esphome/components/nextion/__init__.py | 2 +- esphome/components/online_image/__init__.py | 2 +- esphome/components/ota/__init__.py | 4 +- ...rp2040.cpp => ota_backend_arduino_rp2.cpp} | 26 +- ...ino_rp2040.h => ota_backend_arduino_rp2.h} | 8 +- esphome/components/ota/ota_backend_factory.h | 4 +- .../components/remote_receiver/__init__.py | 4 +- .../remote_receiver/remote_receiver.cpp | 2 +- .../remote_receiver/remote_receiver.h | 6 +- .../components/remote_transmitter/__init__.py | 2 +- .../remote_transmitter/remote_transmitter.cpp | 2 +- .../remote_transmitter/remote_transmitter.h | 2 +- .../components/{rp2040 => rp2}/__init__.py | 59 +- .../components/{rp2040 => rp2}/boards.jinja2 | 13 +- esphome/components/{rp2040 => rp2}/boards.py | 13 +- .../{rp2040 => rp2}/build_pio.py.script | 0 esphome/components/{rp2040 => rp2}/const.py | 4 +- esphome/components/rp2/core.cpp | 6 + esphome/components/{rp2040 => rp2}/core.h | 6 +- .../{rp2040 => rp2}/crash_handler.cpp | 14 +- .../{rp2040 => rp2}/crash_handler.h | 12 +- .../{rp2040 => rp2}/generate_boards.py | 2 +- esphome/components/{rp2040 => rp2}/gpio.cpp | 28 +- esphome/components/{rp2040 => rp2}/gpio.h | 10 +- esphome/components/{rp2040 => rp2}/gpio.py | 26 +- esphome/components/{rp2040 => rp2}/hal.cpp | 22 +- esphome/components/{rp2040 => rp2}/hal.h | 6 +- .../components/{rp2040 => rp2}/helpers.cpp | 4 +- .../inject_lwip_include.py.script | 0 .../{rp2040 => rp2}/lwipopts.h.jinja | 0 .../{rp2040 => rp2}/post_build.py.script | 0 esphome/components/rp2/preference_backend.h | 27 + .../{rp2040 => rp2}/preferences.cpp | 28 +- .../components/{rp2040 => rp2}/preferences.h | 16 +- .../{rp2040 => rp2}/printf_stubs.cpp | 6 +- esphome/components/rp2040/core.cpp | 6 - .../components/rp2040/preference_backend.h | 27 - esphome/components/rp2040_ble/__init__.py | 2 +- .../rp2040_pio_led_strip/led_strip.cpp | 2 +- .../rp2040_pio_led_strip/led_strip.h | 4 +- .../components/rp2040_pio_led_strip/light.py | 10 +- esphome/components/rp2040_pwm/output.py | 2 +- esphome/components/rp2040_pwm/rp2040_pwm.cpp | 2 +- esphome/components/rp2040_pwm/rp2040_pwm.h | 4 +- .../{rp2040_pio => rp2_pio}/__init__.py | 2 +- esphome/components/sha256/sha256.cpp | 4 +- esphome/components/sha256/sha256.h | 6 +- esphome/components/sntp/time.py | 4 +- esphome/components/socket/__init__.py | 2 +- esphome/components/socket/headers.h | 2 +- .../components/socket/lwip_raw_tcp_impl.cpp | 2 +- esphome/components/spi/__init__.py | 14 +- esphome/components/spi/spi.h | 2 +- esphome/components/spi/spi_arduino.cpp | 6 +- esphome/components/time/real_time_clock.cpp | 2 +- esphome/components/uart/__init__.py | 10 +- ...nent_rp2040.cpp => uart_component_rp2.cpp} | 24 +- ...omponent_rp2040.h => uart_component_rp2.h} | 6 +- esphome/components/wake_on_lan/button.py | 2 +- esphome/components/watchdog/watchdog.cpp | 6 +- esphome/components/web_server/__init__.py | 4 +- .../components/web_server_base/__init__.py | 2 +- esphome/components/wifi/__init__.py | 18 +- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 6 +- .../components/wifi/wifi_component_pico_w.cpp | 2 +- esphome/config_validation.py | 118 +++- esphome/const.py | 13 +- esphome/core/__init__.py | 38 +- esphome/core/config.py | 4 +- esphome/core/defines.h | 13 +- esphome/core/hal.h | 4 +- esphome/core/helpers.h | 10 +- esphome/core/preference_backend.h | 6 +- esphome/core/preferences.h | 4 +- esphome/core/wake.h | 6 +- .../wake/{wake_rp2040.cpp => wake_rp2.cpp} | 4 +- .../core/wake/{wake_rp2040.h => wake_rp2.h} | 6 +- esphome/storage_json.py | 2 +- esphome/wizard.py | 56 +- script/build_language_schema.py | 23 + script/ci-custom.py | 7 +- script/determine-jobs.py | 19 +- ...p2040-boards.py => generate-rp2-boards.py} | 12 +- ...40-pico2-ard.yaml => test.rp2350-ard.yaml} | 0 .../{rp2040 => rp2}/test.rp2040-ard.yaml | 2 +- .../test.rp2350-ard.yaml} | 2 +- ...40-pico2-ard.yaml => test.rp2350-ard.yaml} | 0 tests/script/test_determine_jobs.py | 27 +- .../build_components_base.rp2040-ard.yaml | 2 +- ... => build_components_base.rp2350-ard.yaml} | 2 +- ...{rp2040-pico2-ard.yaml => rp2350-ard.yaml} | 0 tests/unit_tests/components/test_rp2.py | 95 +++ tests/unit_tests/components/test_rp2040.py | 92 --- ..._boards.py => test_rp2_generate_boards.py} | 4 +- tests/unit_tests/components/test_wifi.py | 6 +- tests/unit_tests/test_config_validation.py | 204 ++++++- tests/unit_tests/test_core.py | 30 + tests/unit_tests/test_loader.py | 544 ++++-------------- tests/unit_tests/test_main.py | 81 ++- tests/unit_tests/test_wizard.py | 56 +- tests/unit_tests/test_writer.py | 4 +- 153 files changed, 1297 insertions(+), 1062 deletions(-) rename esphome/components/adc/{adc_sensor_rp2040.cpp => adc_sensor_rp2.cpp} (97%) rename esphome/components/debug/{debug_rp2040.cpp => debug_rp2.cpp} (93%) rename esphome/components/ethernet/{ethernet_component_rp2040.cpp => ethernet_component_rp2.cpp} (98%) rename esphome/components/internal_temperature/{internal_temperature_rp2040.cpp => internal_temperature_rp2.cpp} (85%) rename esphome/components/logger/{logger_rp2040.cpp => logger_rp2.cpp} (85%) rename esphome/components/logger/{logger_rp2040.h => logger_rp2.h} (94%) rename esphome/components/mdns/{mdns_rp2040.cpp => mdns_rp2.cpp} (94%) rename esphome/components/ota/{ota_backend_arduino_rp2040.cpp => ota_backend_arduino_rp2.cpp} (70%) rename esphome/components/ota/{ota_backend_arduino_rp2040.h => ota_backend_arduino_rp2.h} (78%) rename esphome/components/{rp2040 => rp2}/__init__.py (92%) rename esphome/components/{rp2040 => rp2}/boards.jinja2 (56%) rename esphome/components/{rp2040 => rp2}/boards.py (99%) rename esphome/components/{rp2040 => rp2}/build_pio.py.script (100%) rename esphome/components/{rp2040 => rp2}/const.py (91%) create mode 100644 esphome/components/rp2/core.cpp rename esphome/components/{rp2040 => rp2}/core.h (53%) rename esphome/components/{rp2040 => rp2}/crash_handler.cpp (97%) rename esphome/components/{rp2040 => rp2}/crash_handler.h (66%) rename esphome/components/{rp2040 => rp2}/generate_boards.py (98%) rename esphome/components/{rp2040 => rp2}/gpio.cpp (81%) rename esphome/components/{rp2040 => rp2}/gpio.h (85%) rename esphome/components/{rp2040 => rp2}/gpio.py (82%) rename esphome/components/{rp2040 => rp2}/hal.cpp (58%) rename esphome/components/{rp2040 => rp2}/hal.h (96%) rename esphome/components/{rp2040 => rp2}/helpers.cpp (98%) rename esphome/components/{rp2040 => rp2}/inject_lwip_include.py.script (100%) rename esphome/components/{rp2040 => rp2}/lwipopts.h.jinja (100%) rename esphome/components/{rp2040 => rp2}/post_build.py.script (100%) create mode 100644 esphome/components/rp2/preference_backend.h rename esphome/components/{rp2040 => rp2}/preferences.cpp (82%) rename esphome/components/{rp2040 => rp2}/preferences.h (59%) rename esphome/components/{rp2040 => rp2}/printf_stubs.cpp (94%) delete mode 100644 esphome/components/rp2040/core.cpp delete mode 100644 esphome/components/rp2040/preference_backend.h rename esphome/components/{rp2040_pio => rp2_pio}/__init__.py (98%) rename esphome/components/uart/{uart_component_rp2040.cpp => uart_component_rp2.cpp} (91%) rename esphome/components/uart/{uart_component_rp2040.h => uart_component_rp2.h} (88%) rename esphome/core/wake/{wake_rp2040.cpp => wake_rp2.cpp} (97%) rename esphome/core/wake/{wake_rp2040.h => wake_rp2.h} (88%) rename script/{generate-rp2040-boards.py => generate-rp2-boards.py} (77%) rename tests/components/adc/{test.rp2040-pico2-ard.yaml => test.rp2350-ard.yaml} (100%) rename tests/components/{rp2040 => rp2}/test.rp2040-ard.yaml (97%) rename tests/components/{rp2040/test.rp2040-pico2-ard.yaml => rp2/test.rp2350-ard.yaml} (90%) rename tests/components/spi/{test.rp2040-pico2-ard.yaml => test.rp2350-ard.yaml} (100%) rename tests/test_build_components/{build_components_base.rp2040-pico2-ard.yaml => build_components_base.rp2350-ard.yaml} (97%) rename tests/test_build_components/common/spi/{rp2040-pico2-ard.yaml => rp2350-ard.yaml} (100%) create mode 100644 tests/unit_tests/components/test_rp2.py delete mode 100644 tests/unit_tests/components/test_rp2040.py rename tests/unit_tests/components/{test_rp2040_generate_boards.py => test_rp2_generate_boards.py} (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e29db94a..0fd6a79cb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ jobs: script/build_codeowners.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check - script/generate-rp2040-boards.py --check + script/generate-rp2-boards.py --check script/ci_check_duplicate_test_ids.py import-time: diff --git a/CODEOWNERS b/CODEOWNERS index 571f8492f1..34ec4bc2bd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -426,7 +426,7 @@ esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/ring_buffer/* @kahrendt esphome/components/router/speaker/* @kahrendt -esphome/components/rp2040/* @jesserockz +esphome/components/rp2/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz diff --git a/esphome/__main__.py b/esphome/__main__.py index 2cc904ff4b..4abd18d239 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -355,7 +355,7 @@ def choose_upload_log_host( bootsel_permission_error = False if ( purpose == Purpose.UPLOADING - and CORE.is_rp2040 + and CORE.is_rp2 and (picotool := _find_picotool()) is not None ): bootsel = detect_rp2040_bootsel(picotool) @@ -402,7 +402,7 @@ def choose_upload_log_host( # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found if ( purpose == Purpose.UPLOADING - and CORE.is_rp2040 + and CORE.is_rp2 and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) ): if bootsel_permission_error: @@ -985,7 +985,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int: # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for # the upload target, but 'nobuild' skips the build phase that creates it. # Create it here so the upload doesn't fail. - if CORE.is_rp2040: + if CORE.is_rp2: idedata = toolchain.get_idedata(config) build_dir = Path(idedata.firmware_elf_path).parent firmware_bin = build_dir / "firmware.bin" @@ -1173,7 +1173,7 @@ def upload_program( if CORE.is_esp32 or CORE.is_esp8266: file = getattr(args, "file", None) exit_code = upload_using_esptool(config, host, file, args.upload_speed) - elif CORE.is_rp2040 or CORE.is_libretiny: + elif CORE.is_rp2 or CORE.is_libretiny: exit_code = upload_using_platformio(config, host) # else: Unknown target platform, exit_code remains 1 @@ -1647,7 +1647,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: # After BOOTSEL upload, wait for a new serial port to appear # so it shows up in the log chooser - if successful_device is None and CORE.is_rp2040: + if successful_device is None and CORE.is_rp2: _wait_for_serial_port(known_ports=pre_upload_ports) # If exactly one new serial port appeared, use it directly serial_ports = get_serial_ports() diff --git a/esphome/components/__init__.py b/esphome/components/__init__.py index e69de29bb2..3d7a546253 100644 --- a/esphome/components/__init__.py +++ b/esphome/components/__init__.py @@ -0,0 +1,6 @@ +# Importing `esphome.loader` here installs the component-alias +# ``sys.meta_path`` finder before any submodule lookup runs. Without this, +# `from esphome.components import ` from a fresh interpreter +# can race the finder install and raise ImportError, since the legacy +# alias dir no longer exists on disk. +from esphome import loader as _loader # noqa: F401 diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 96c8334a6d..555d511f6e 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -227,12 +227,12 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { def validate_adc_pin(value): if str(value).upper() == "VCC": - if CORE.is_rp2040: + if CORE.is_rp2: return pins.internal_gpio_input_pin_schema(29) return cv.only_on([PLATFORM_ESP8266])("VCC") if str(value).upper() == "TEMPERATURE": - return cv.only_on_rp2040("TEMPERATURE") + return cv.only_on_rp2("TEMPERATURE") if CORE.is_esp32: conf = pins.internal_gpio_input_pin_schema(value) @@ -261,11 +261,11 @@ def validate_adc_pin(value): raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC") return conf - if CORE.is_rp2040: + if CORE.is_rp2: conf = pins.internal_gpio_input_pin_schema(value) number = conf[CONF_NUMBER] if number not in (26, 27, 28, 29): - raise cv.Invalid("RP2040: Only pins 26, 27, 28 and 29 support ADC") + raise cv.Invalid("RP2: Only pins 26, 27, 28 and 29 support ADC") return conf if CORE.is_libretiny: diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 03de6f8b4b..7131898747 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -123,9 +123,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v void set_autorange(bool autorange) { this->autorange_ = autorange; } #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void set_is_temperature() { this->is_temperature_ = true; } -#endif // USE_RP2040 +#endif // USE_RP2 protected: uint8_t sample_count_{1}; @@ -152,9 +152,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v static adc_oneshot_unit_handle_t shared_adc_handles[2]; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 bool is_temperature_{false}; -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_ZEPHYR const struct adc_dt_spec *channel_ = nullptr; diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2.cpp similarity index 97% rename from esphome/components/adc/adc_sensor_rp2040.cpp rename to esphome/components/adc/adc_sensor_rp2.cpp index 894c346588..6cb9ef113f 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "adc_sensor.h" #include "esphome/core/log.h" @@ -17,7 +17,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.rp2040"; +static const char *const TAG = "adc.rp2"; void ADCSensor::setup() { static bool initialized = false; @@ -102,4 +102,4 @@ float ADCSensor::sample() { } // namespace esphome::adc -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 09e09f0dc1..86e2b771ab 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -201,7 +201,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "adc_sensor_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "adc_sensor_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 1146b43596..11ada7e970 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -300,7 +300,7 @@ CONFIG_SCHEMA = cv.All( CONF_LISTEN_BACKLOG, esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets esp32=4, # More RAM (520KB), BSD sockets - rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 + rp2=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 bk72xx=4, # Moderate RAM, BSD-style sockets rtl87xx=4, # Moderate RAM, BSD-style sockets host=4, # Abundant resources @@ -311,7 +311,7 @@ CONFIG_SCHEMA = cv.All( CONF_MAX_CONNECTIONS, esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes esp32=5, # 520KB RAM available - rp2040=4, # 264KB RAM but LWIP constraints + rp2=4, # 264KB RAM but LWIP constraints bk72xx=5, # Moderate RAM rtl87xx=5, # Moderate RAM host=8, # Abundant resources @@ -326,7 +326,7 @@ CONFIG_SCHEMA = cv.All( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast esp32=8, # More RAM, can buffer more - rp2040=8, # Moderate RAM + rp2=8, # Moderate RAM bk72xx=8, # Moderate RAM nrf52=8, # Moderate RAM rtl87xx=8, # Moderate RAM diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index acdf24e747..cb7d1b9d1e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1759,7 +1759,7 @@ bool APIConnection::send_device_info_response_() { // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) #define ESPHOME_MANUFACTURER "Espressif" -#elif defined(USE_RP2040) +#elif defined(USE_RP2) #define ESPHOME_MANUFACTURER "Raspberry Pi" #elif defined(USE_BK72XX) #define ESPHOME_MANUFACTURER "Beken" diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 92f7065730..dae5fc92fd 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -18,8 +18,8 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "esphome/components/esp32/crash_handler.h" #endif -#ifdef USE_RP2040_CRASH_HANDLER -#include "esphome/components/rp2040/crash_handler.h" +#ifdef USE_RP2_CRASH_HANDLER +#include "esphome/components/rp2/crash_handler.h" #endif #ifdef USE_ESP8266_CRASH_HANDLER #include "esphome/components/esp8266/crash_handler.h" @@ -279,8 +279,8 @@ class APIConnection final : public APIServerConnectionBase { esp32::crash_handler_log(); esp32::crash_handler_clear(); #endif -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_log(); +#ifdef USE_RP2_CRASH_HANDLER + rp2::crash_handler_log(); #endif #ifdef USE_ESP8266_CRASH_HANDLER esp8266::crash_handler_log(); diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index 2a07903b68..22d544ba37 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -13,7 +13,7 @@ def AUTO_LOAD() -> list[str]: if ( not CORE.is_esp32 and not CORE.is_esp8266 - and not CORE.is_rp2040 + and not CORE.is_rp2 and not CORE.is_libretiny ): return ["socket"] @@ -37,7 +37,7 @@ async def to_code(config): elif CORE.is_esp8266: # https://github.com/ESP32Async/ESPAsyncTCP cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0") - elif CORE.is_rp2040: + elif CORE.is_rp2: # https://github.com/ayushsharma82/RPAsyncTCP # RPAsyncTCP is a drop-in replacement for AsyncTCP_RP2040W with better # ESPAsyncWebServer compatibility @@ -47,6 +47,6 @@ async def to_code(config): def FILTER_SOURCE_FILES() -> list[str]: # Exclude socket implementation for platforms that use AsyncTCP libraries - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2 or CORE.is_libretiny: return ["async_tcp_socket.cpp"] return [] diff --git a/esphome/components/async_tcp/async_tcp.h b/esphome/components/async_tcp/async_tcp.h index 21fcfe239f..0906a07844 100644 --- a/esphome/components/async_tcp/async_tcp.h +++ b/esphome/components/async_tcp/async_tcp.h @@ -7,7 +7,7 @@ #elif defined(USE_ESP8266) // Use ESPAsyncTCP library for ESP8266 (always Arduino) #include -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // Use RPAsyncTCP library for RP2040 #include #else diff --git a/esphome/components/async_tcp/async_tcp_socket.cpp b/esphome/components/async_tcp/async_tcp_socket.cpp index e8c0f163b3..10cbc981c7 100644 --- a/esphome/components/async_tcp/async_tcp_socket.cpp +++ b/esphome/components/async_tcp/async_tcp_socket.cpp @@ -1,6 +1,6 @@ #include "async_tcp_socket.h" -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ (defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)) #include "esphome/components/network/util.h" diff --git a/esphome/components/async_tcp/async_tcp_socket.h b/esphome/components/async_tcp/async_tcp_socket.h index 28714a7752..3b17fe14df 100644 --- a/esphome/components/async_tcp/async_tcp_socket.h +++ b/esphome/components/async_tcp/async_tcp_socket.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ (defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)) #include "esphome/components/socket/socket.h" diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index cd877fc879..703ae98392 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, PlatformFramework, ) @@ -54,7 +54,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ] ), @@ -105,7 +105,7 @@ async def to_code(config): if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_CAPTIVE_PORTAL_GZIP") - if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2040): + if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): cg.add_library("DNSServer", None) diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index dc032f442e..3e94d04f21 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -70,7 +70,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "debug_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "debug_host.cpp": {PlatformFramework.HOST_NATIVE}, - "debug_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "debug_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "debug_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2.cpp similarity index 93% rename from esphome/components/debug/debug_rp2040.cpp rename to esphome/components/debug/debug_rp2.cpp index adc23dbf51..ba6081963f 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -1,5 +1,5 @@ #include "debug_component.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" #include "esphome/core/log.h" #include @@ -9,8 +9,8 @@ #else #include #endif -#ifdef USE_RP2040_CRASH_HANDLER -#include "esphome/components/rp2040/crash_handler.h" +#ifdef USE_RP2_CRASH_HANDLER +#include "esphome/components/rp2/crash_handler.h" #endif namespace esphome::debug { @@ -41,8 +41,8 @@ const char *DebugComponent::get_reset_reason_(std::span None: cg.add(var.set_reset_pin(config[CONF_RESET_PIN])) cg.add_define("USE_ETHERNET_SPI") - cg.add_library(_RP2040_SPI_LIBRARIES[config[CONF_TYPE]], None) + cg.add_library(_RP2_SPI_LIBRARIES[config[CONF_TYPE]], None) def _final_validate_rmii_pins(config: ConfigType) -> None: @@ -752,7 +754,7 @@ _platform_filter = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, }, - "ethernet_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "ethernet_component_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "esp_eth_phy_jl1101.c": { PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index e0fe920ea1..16f09a45f0 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -25,7 +25,7 @@ extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); #endif #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 #if defined(USE_ETHERNET_W5500) #include #elif defined(USE_ETHERNET_W5100) @@ -182,14 +182,14 @@ class EthernetComponent final : public Component { #endif // USE_ETHERNET_SPI #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void set_clk_pin(uint8_t clk_pin); void set_miso_pin(uint8_t miso_pin); void set_mosi_pin(uint8_t mosi_pin); void set_cs_pin(uint8_t cs_pin); void set_interrupt_pin(int8_t interrupt_pin); void set_reset_pin(int8_t reset_pin); -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_ETHERNET_IP_STATE_LISTENERS void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } @@ -272,7 +272,7 @@ class EthernetComponent final : public Component { esp_eth_phy_t *phy_{nullptr}; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 static constexpr uint32_t LINK_CHECK_INTERVAL = 500; // ms between link/IP polls #if defined(USE_ETHERNET_W5100) static constexpr uint32_t RESET_DELAY_MS = 150; // W5100S PLL lock time @@ -301,7 +301,7 @@ class EthernetComponent final : public Component { uint8_t cs_pin_; int8_t interrupt_pin_{-1}; int8_t reset_pin_{-1}; -#endif // USE_RP2040 +#endif // USE_RP2 // Common members #ifdef USE_ETHERNET_MANUAL_IP diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp similarity index 98% rename from esphome/components/ethernet/ethernet_component_rp2040.cpp rename to esphome/components/ethernet/ethernet_component_rp2.cpp index 250297ddb5..d2e3f14e02 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -1,12 +1,12 @@ #include "ethernet_component.h" -#if defined(USE_ETHERNET) && defined(USE_RP2040) +#if defined(USE_ETHERNET) && defined(USE_RP2) #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/components/rp2040/gpio.h" +#include "esphome/components/rp2/gpio.h" #include #include @@ -29,7 +29,7 @@ void EthernetComponent::setup() { // Toggle reset pin if configured if (this->reset_pin_ >= 0) { - rp2040::RP2040GPIOPin reset_pin; + rp2::RP2GPIOPin reset_pin; reset_pin.set_pin(this->reset_pin_); reset_pin.set_flags(gpio::FLAG_OUTPUT); reset_pin.setup(); @@ -380,4 +380,4 @@ void EthernetComponent::disable() { } // namespace esphome::ethernet -#endif // USE_ETHERNET && USE_RP2040 +#endif // USE_ETHERNET && USE_RP2 diff --git a/esphome/components/factory_reset/factory_reset.cpp b/esphome/components/factory_reset/factory_reset.cpp index cd4134e9ae..bceaf6e40f 100644 --- a/esphome/components/factory_reset/factory_reset.cpp +++ b/esphome/components/factory_reset/factory_reset.cpp @@ -7,7 +7,7 @@ #include -#if !defined(USE_RP2040) && !defined(USE_HOST) +#if !defined(USE_RP2) && !defined(USE_HOST) namespace esphome::factory_reset { @@ -73,4 +73,4 @@ void FactoryResetComponent::setup() { } // namespace esphome::factory_reset -#endif // !defined(USE_RP2040) && !defined(USE_HOST) +#endif // !defined(USE_RP2) && !defined(USE_HOST) diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h index d80d2d2406..b0a899c719 100644 --- a/esphome/components/factory_reset/factory_reset.h +++ b/esphome/components/factory_reset/factory_reset.h @@ -3,7 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" #include "esphome/core/preferences.h" -#if !defined(USE_RP2040) && !defined(USE_HOST) +#if !defined(USE_RP2) && !defined(USE_HOST) #ifdef USE_ESP32 #include @@ -32,4 +32,4 @@ class FactoryResetComponent final : public Component { } // namespace esphome::factory_reset -#endif // !defined(USE_RP2040) && !defined(USE_HOST) +#endif // !defined(USE_RP2) && !defined(USE_HOST) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 2f1aa936a3..43358baedb 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -47,7 +47,7 @@ CONFIG_SCHEMA = ( host=True, ln882x=False, nrf52=True, - rp2040=True, + rp2=True, rtl87xx=False, ): cv.boolean, cv.Optional(CONF_INTERRUPT_TYPE, default="ANY"): cv.enum( diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp index c113cb48a6..d8e1f059a6 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.cpp +++ b/esphome/components/hmac_sha256/hmac_sha256.cpp @@ -1,6 +1,6 @@ #include #include "hmac_sha256.h" -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include "esphome/core/helpers.h" namespace esphome::hmac_sha256 { diff --git a/esphome/components/hmac_sha256/hmac_sha256.h b/esphome/components/hmac_sha256/hmac_sha256.h index 22129b1182..74ac4c23de 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.h +++ b/esphome/components/hmac_sha256/hmac_sha256.h @@ -1,7 +1,7 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index fd033dac7f..54d7f5c77b 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -73,7 +73,7 @@ def validate_url(value): def validate_ssl_verification(config): error_message = "" - if CORE.is_rp2040 and config[CONF_VERIFY_SSL]: + if CORE.is_rp2 and config[CONF_VERIFY_SSL]: error_message = "ESPHome does not support certificate verification on RP2040" if ( @@ -96,7 +96,7 @@ def _declare_request_class(value): return cv.declare_id(HttpRequestHost)(value) if CORE.is_esp32: return cv.declare_id(HttpRequestIDF)(value) - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: return cv.declare_id(HttpRequestArduino)(value) return NotImplementedError @@ -118,7 +118,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, cv.Optional(CONF_WATCHDOG_TIMEOUT): cv.All( - cv.Any(cv.only_on_esp32, cv.only_on_rp2040), + cv.Any(cv.only_on_esp32, cv.only_on_rp2), cv.positive_not_null_time_period, cv.positive_time_period_milliseconds, ), @@ -144,7 +144,7 @@ CONFIG_SCHEMA = cv.All( esp8266_arduino=cv.Version(2, 5, 1), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), ), validate_ssl_verification, @@ -204,7 +204,7 @@ async def to_code(config): ) if CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) - if CORE.is_rp2040 and CORE.using_arduino: + if CORE.is_rp2 and CORE.using_arduino: cg.add_library("HTTPClient", None) if CORE.is_host: if IS_MACOS: @@ -368,7 +368,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "http_request_host.cpp": {PlatformFramework.HOST_NATIVE}, "http_request_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index bb5e9427dd..1760cb9395 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -72,7 +72,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur bool status = container->client_.begin(*stream_ptr, url.c_str()); -#elif defined(USE_RP2040) +#elif defined(USE_RP2) if (secure) { container->client_.setInsecure(); } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 8da40798ec..c109de8a39 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -4,7 +4,7 @@ #if defined(USE_ARDUINO) && !defined(USE_ESP32) -#if defined(USE_RP2040) +#if defined(USE_RP2) #include #include #endif diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index 1bb54599dc..b7026e0f55 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -36,7 +36,7 @@ CONFIG_SCHEMA = cv.All( esp8266_arduino=cv.Version(2, 5, 1), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), ), ) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index eec2211a96..7b163d065e 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -49,7 +49,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_HOST, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -130,7 +130,7 @@ def validate_config(config): return cv.require_framework_version( esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) )(config) - if CORE.is_rp2040: + if CORE.is_rp2: sda_controller = _rp2040_i2c_controller(config[CONF_SDA]) scl_controller = _rp2040_i2c_controller(config[CONF_SCL]) if sda_controller != scl_controller: @@ -171,7 +171,7 @@ CONFIG_SCHEMA = cv.All( CONF_SDA, esp32="SDA", esp8266="SDA", - rp2040="SDA", + rp2="SDA", nrf52="SDA", ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All( @@ -181,7 +181,7 @@ CONFIG_SCHEMA = cv.All( CONF_SCL, esp32="SCL", esp8266="SCL", - rp2040="SCL", + rp2="SCL", nrf52="SCL", ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All( @@ -191,7 +191,7 @@ CONFIG_SCHEMA = cv.All( CONF_FREQUENCY, esp32="50kHz", esp8266="50kHz", - rp2040="50kHz", + rp2="50kHz", nrf52="100kHz", host="50kHz", ): cv.All( @@ -219,7 +219,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_NRF52, PLATFORM_HOST, ] @@ -233,7 +233,7 @@ def _final_validate(config): full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") - if CORE.is_rp2040: + if CORE.is_rp2: if len(full_config) > 2: raise cv.Invalid( "The maximum number of I2C interfaces for RP2040/RP2350 is 2" @@ -443,7 +443,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( { "i2c_bus_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 47a06abe9e..871f67a4c8 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -19,7 +19,7 @@ void ArduinoI2CBus::setup() { #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // Select Wire instance based on pin assignment, not definition order. // I2C controller = (gpio / 2) % 2: even pairs (0-1,4-5,...) → I2C0, odd pairs (2-3,6-7,...) → I2C1 // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf @@ -41,7 +41,7 @@ void ArduinoI2CBus::setup() { } void ArduinoI2CBus::set_pins_and_clock_() { -#ifdef USE_RP2040 +#ifdef USE_RP2 wire_->setSDA(this->sda_pin_); wire_->setSCL(this->scl_pin_); wire_->begin(); @@ -52,7 +52,7 @@ void ArduinoI2CBus::set_pins_and_clock_() { #if defined(USE_ESP8266) // https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h wire_->setClockStretchLimit(timeout_); // unit: us -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // https://github.com/earlephilhower/ArduinoCore-API/blob/e37df85425e0ac020bfad226d927f9b00d2e0fb7/api/Stream.h wire_->setTimeout(timeout_ / 1000); // unit: ms #endif @@ -70,7 +70,7 @@ void ArduinoI2CBus::dump_config() { if (timeout_ > 0) { #if defined(USE_ESP8266) ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_); -#elif defined(USE_RP2040) +#elif defined(USE_RP2) ESP_LOGCONFIG(TAG, " Timeout: %u ms", this->timeout_ / 1000); #endif } diff --git a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp similarity index 85% rename from esphome/components/internal_temperature/internal_temperature_rp2040.cpp rename to esphome/components/internal_temperature/internal_temperature_rp2.cpp index 66dee9faf7..11f8e27fc3 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/log.h" #include "internal_temperature.h" @@ -7,7 +7,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.rp2040"; +static const char *const TAG = "internal_temperature.rp2"; void InternalTemperatureSensor::update() { float temperature = NAN; @@ -28,4 +28,4 @@ void InternalTemperatureSensor::update() { } // namespace esphome::internal_temperature -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 02730b6862..805138071e 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -10,7 +10,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, PlatformFramework, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = cv.All( cv.only_on( [ PLATFORM_ESP32, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_NRF52, PLATFORM_LN882X, @@ -58,7 +58,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, - "internal_temperature_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "internal_temperature_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "internal_temperature_bk72xx.cpp": { PlatformFramework.BK72XX_ARDUINO, }, diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 9629dce0bf..77a875dd8f 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -54,7 +54,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, PlatformFramework, ) @@ -154,7 +154,7 @@ HARDWARE_UART_TO_SERIAL = { UART2: cg.global_ns.Serial2, DEFAULT: cg.global_ns.Serial, }, - PLATFORM_RP2040: { + PLATFORM_RP2: { UART0: cg.global_ns.Serial1, UART1: cg.global_ns.Serial2, USB_CDC: cg.global_ns.Serial, @@ -171,7 +171,7 @@ def uart_selection(value): return cv.one_of(*UART_SELECTION_ESP32[variant], upper=True)(value) if CORE.is_esp8266: return cv.one_of(*UART_SELECTION_ESP8266, upper=True)(value) - if CORE.is_rp2040: + if CORE.is_rp2: return cv.one_of(*UART_SELECTION_RP2040, upper=True)(value) if CORE.is_libretiny: family = get_libretiny_family() @@ -282,7 +282,7 @@ CONFIG_SCHEMA = cv.All( esp32_s2=USB_CDC, esp32_s3=USB_SERIAL_JTAG, esp32_s31=USB_SERIAL_JTAG, - rp2040=USB_CDC, + rp2=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, rtl87xx=DEFAULT, @@ -292,7 +292,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP8266, PLATFORM_ESP32, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX, @@ -417,11 +417,7 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_define("USE_ESP8266_LOGGER_SERIAL1") enable_serial1() - if ( - (CORE.is_esp8266 or CORE.is_rp2040) - and has_serial_logging - and is_at_least_verbose - ): + if (CORE.is_esp8266 or CORE.is_rp2) and has_serial_logging and is_at_least_verbose: debug_serial_port = HARDWARE_UART_TO_SERIAL[CORE.target_platform][ config.get(CONF_HARDWARE_UART) ] @@ -605,7 +601,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "logger_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "logger_host.cpp": {PlatformFramework.HOST_NATIVE}, - "logger_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "logger_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "logger_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 684da0202e..6527b6aa8c 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -206,7 +206,7 @@ void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_levels_[tag] = log_level; } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) UARTSelection Logger::get_uart() const { return this->uart_; } #endif diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 784cbea67e..69d8e6d32a 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -23,10 +23,10 @@ #if defined(USE_ESP8266) #include #endif // USE_ESP8266 -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO #ifdef USE_ESP32 @@ -96,7 +96,7 @@ struct CStrCompare { // macOS allows up to 64 bytes, Linux up to 16 static constexpr size_t THREAD_NAME_BUF_SIZE = 64; -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) /** Enum for logging UART selection * * Advanced configuration (pin selection, etc) is not supported. @@ -122,7 +122,7 @@ enum UARTSelection : uint8_t { UART_SELECTION_UART0_SWAP, #endif // USE_ESP8266 }; -#endif // USE_ESP32 || USE_ESP8266 || USE_RP2040 || USE_LIBRETINY || USE_ZEPHYR +#endif // USE_ESP32 || USE_ESP8266 || USE_RP2 || USE_LIBRETINY || USE_ZEPHYR /** * @brief Logger component for all ESPHome logging. @@ -160,7 +160,7 @@ class Logger final : public Component { #ifdef USE_HOST void create_pthread_key() { pthread_key_create(&log_recursion_key_, nullptr); } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) void set_uart_selection(UARTSelection uart_selection) { uart_ = uart_selection; } /// Get the UART used by the logger. UARTSelection get_uart() const; @@ -351,7 +351,7 @@ class Logger final : public Component { #endif // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) UARTSelection uart_{UART_SELECTION_UART0}; #endif #ifdef USE_LIBRETINY @@ -505,8 +505,8 @@ class LoggerMessageTrigger final : public Triggerdigest_, 0, 16); MD5Init(&this->ctx_); @@ -14,7 +14,7 @@ void MD5Digest::init() { void MD5Digest::add(const uint8_t *data, size_t len) { MD5Update(&this->ctx_, data, len); } void MD5Digest::calculate() { MD5Final(this->digest_, &this->ctx_); } -#endif // USE_ARDUINO && !USE_RP2040 +#endif // USE_ARDUINO && !USE_RP2 #ifdef USE_ESP32 void MD5Digest::init() { @@ -27,7 +27,7 @@ void MD5Digest::add(const uint8_t *data, size_t len) { esp_rom_md5_update(&this- void MD5Digest::calculate() { esp_rom_md5_final(this->digest_, &this->ctx_); } #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void MD5Digest::init() { memset(this->digest_, 0, 16); br_md5_init(&this->ctx_); @@ -36,7 +36,7 @@ void MD5Digest::init() { void MD5Digest::add(const uint8_t *data, size_t len) { br_md5_update(&this->ctx_, data, len); } void MD5Digest::calculate() { br_md5_out(&this->ctx_, this->digest_); } -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_HOST MD5Digest::~MD5Digest() { diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index 5e841edd83..ff0f2852c8 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -19,7 +19,7 @@ #define MD5_CTX_TYPE md5_context_t #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #define MD5_CTX_TYPE br_md5_context #endif diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 2de67542b2..3670098bcf 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -70,11 +70,11 @@ def _require_network_interface(config: ConfigType) -> ConfigType: window. Reject at config time rather than silently producing a component that never initializes. """ - if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2040): + if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2): return config full_config = fv.full_config.get() has_wifi = "wifi" in full_config - has_ethernet = CORE.is_rp2040 and "ethernet" in full_config + has_ethernet = CORE.is_rp2 and "ethernet" in full_config if not (has_wifi or has_ethernet): options = "'wifi'" if CORE.is_esp8266 else "'wifi' or 'ethernet'" raise cv.Invalid( @@ -192,18 +192,18 @@ async def to_code(config): if CORE.using_arduino: if CORE.is_esp8266: cg.add_library("ESP8266mDNS", None) - elif CORE.is_rp2040: + elif CORE.is_rp2: cg.add_library("LEAmDNS", None) # Subscribe to the network IP state listener(s) so MDNS.update() is only # scheduled during the probe+announce phase. Same on_ip_state() override # serves both WiFi and Ethernet (signatures match). - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: if "wifi" in CORE.config: from esphome.components import wifi wifi.request_wifi_ip_state_listener() - if CORE.is_rp2040 and "ethernet" in CORE.config: + if CORE.is_rp2 and "ethernet" in CORE.config: from esphome.components import ethernet ethernet.request_ethernet_ip_state_listener() @@ -274,7 +274,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "mdns_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "mdns_host.cpp": {PlatformFramework.HOST_NATIVE}, - "mdns_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "mdns_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "mdns_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index e11cb1abaa..02b825605c 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -100,7 +100,7 @@ void MDNSComponent::compile_records_(StaticVector services_{}; #endif -#if defined(USE_RP2040) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) +#if defined(USE_RP2) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2.cpp similarity index 94% rename from esphome/components/mdns/mdns_rp2040.cpp rename to esphome/components/mdns/mdns_rp2.cpp index f5848893a3..7eaac594fb 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -1,5 +1,5 @@ #include "esphome/core/defines.h" -#if defined(USE_RP2040) && defined(USE_MDNS) +#if defined(USE_RP2) && defined(USE_MDNS) #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" @@ -17,7 +17,7 @@ namespace esphome::mdns { -static void register_rp2040(MDNSComponent *, StaticVector &services) { +static void register_rp2(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -82,7 +82,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: return; } if (!this->initialized_) { - this->setup_buffers_and_register_(register_rp2040); + this->setup_buffers_and_register_(register_rp2); this->initialized_ = true; } else { MDNS.notifyAPChange(); diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index afc514609c..3bbc1cdfa3 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -319,7 +319,7 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) device_info[MQTT_DEVICE_MANUFACTURER] = "Espressif"; -#elif defined(USE_RP2040) +#elif defined(USE_RP2) device_info[MQTT_DEVICE_MANUFACTURER] = "Raspberry Pi"; #elif defined(USE_BK72XX) device_info[MQTT_DEVICE_MANUFACTURER] = "Beken"; diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index d2683e4bba..616a189226 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -124,7 +124,7 @@ CONFIG_SCHEMA = cv.Schema( esp32=False, esp8266=False, host=False, - rp2040=False, + rp2=False, nrf52=True, ): cv.All( cv.boolean, @@ -135,7 +135,7 @@ CONFIG_SCHEMA = cv.Schema( esp32_arduino=cv.Version(0, 0, 0), esp8266_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), nrf52_zephyr=cv.Version(0, 0, 0), ), cv.boolean_false, @@ -263,7 +263,7 @@ async def to_code(config): cg.add_build_flag("-DCONFIG_IPV6") if CORE.is_esp8266: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") - if CORE.is_rp2040: + if CORE.is_rp2: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") # Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it # emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index 38f449dc03..d51155b0a4 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -19,7 +19,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index ee4d5abb1c..d47c2e8b44 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.Schema( # esp8266_arduino=cv.Version(2, 7, 0), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(4, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), ), runtime_image.validate_runtime_image_settings, diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 83d8c611d5..8296410f2f 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -99,7 +99,7 @@ async def to_code(config): cg.add_define("USE_OTA") CORE.add_job(final_step) - if CORE.is_rp2040 and CORE.using_arduino: + if CORE.is_rp2 and CORE.using_arduino: cg.add_library("Updater", None) @@ -158,7 +158,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "ota_backend_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "ota_backend_arduino_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "ota_backend_arduino_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "ota_backend_arduino_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp similarity index 70% rename from esphome/components/ota/ota_backend_arduino_rp2040.cpp rename to esphome/components/ota/ota_backend_arduino_rp2.cpp index 0ca0602519..b35eb38c12 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -1,9 +1,9 @@ #ifdef USE_ARDUINO -#ifdef USE_RP2040 -#include "ota_backend_arduino_rp2040.h" +#ifdef USE_RP2 +#include "ota_backend_arduino_rp2.h" #include "ota_backend.h" -#include "esphome/components/rp2040/preferences.h" +#include "esphome/components/rp2/preferences.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" @@ -11,11 +11,11 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_rp2040"; +static const char *const TAG = "ota.arduino_rp2"; -std::unique_ptr make_ota_backend() { return make_unique(); } +std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_type) { +OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type) { if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -23,7 +23,7 @@ OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_t // web_server is not supported for RP2040, so this is not an issue. bool ret = Update.begin(image_size, U_FLASH); if (ret) { - rp2040::preferences_prevent_write(true); + rp2::preferences_prevent_write(true); return OTA_RESPONSE_OK; } @@ -42,12 +42,12 @@ OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_t return OTA_RESPONSE_ERROR_UNKNOWN; } -void ArduinoRP2040OTABackend::set_update_md5(const char *md5) { +void ArduinoRP2OTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); this->md5_set_ = true; } -OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) { +OTAResponseTypes ArduinoRP2OTABackend::write(uint8_t *data, size_t len) { size_t written = Update.write(data, len); if (written == len) { return OTA_RESPONSE_OK; @@ -59,7 +59,7 @@ OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } -OTAResponseTypes ArduinoRP2040OTABackend::end() { +OTAResponseTypes ArduinoRP2OTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { @@ -72,11 +72,11 @@ OTAResponseTypes ArduinoRP2040OTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoRP2040OTABackend::abort() { +void ArduinoRP2OTABackend::abort() { Update.end(); - rp2040::preferences_prevent_write(false); + rp2::preferences_prevent_write(false); } } // namespace esphome::ota -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2.h similarity index 78% rename from esphome/components/ota/ota_backend_arduino_rp2040.h rename to esphome/components/ota/ota_backend_arduino_rp2.h index d04d5c1a84..f7c0037bd2 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -1,6 +1,6 @@ #pragma once #ifdef USE_ARDUINO -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "ota_backend.h" #include "esphome/core/defines.h" @@ -8,7 +8,7 @@ namespace esphome::ota { -class ArduinoRP2040OTABackend final { +class ArduinoRP2OTABackend final { public: OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); @@ -21,8 +21,8 @@ class ArduinoRP2040OTABackend final { bool md5_set_{false}; }; -std::unique_ptr make_ota_backend(); +std::unique_ptr make_ota_backend(); } // namespace esphome::ota -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_factory.h b/esphome/components/ota/ota_backend_factory.h index 7c79f02702..c543983d8d 100644 --- a/esphome/components/ota/ota_backend_factory.h +++ b/esphome/components/ota/ota_backend_factory.h @@ -8,8 +8,8 @@ #include "ota_backend_esp8266.h" #elif defined(USE_ESP32) #include "ota_backend_esp_idf.h" -#elif defined(USE_RP2040) -#include "ota_backend_arduino_rp2040.h" +#elif defined(USE_RP2) +#include "ota_backend_arduino_rp2.h" #elif defined(USE_LIBRETINY) #include "ota_backend_arduino_libretiny.h" #elif defined(USE_HOST) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 53a0f8fb77..ad9c4b5a18 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -118,7 +118,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( bk72xx="1000b", ln882x="1000b", rtl87xx="1000b", - rp2040="1000b", + rp2="1000b", ): cv.validate_bytes, cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, @@ -248,7 +248,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, }, } ) diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index 222dae8f7f..36152d8854 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -3,7 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_receiver { diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 2ed6a4c251..f9ec054fe3 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -14,7 +14,7 @@ namespace esphome::remote_receiver { -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) struct RemoteReceiverComponentStore { static void gpio_intr(RemoteReceiverComponentStore *arg); @@ -93,11 +93,11 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, std::string error_string_; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32) RemoteReceiverComponentStore store_; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) HighFrequencyLoopRequester high_freq_; #endif diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 1163fc86eb..521c3daf87 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -185,7 +185,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, }, } ) diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 51a3c0b1d4..49c711330b 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_transmitter { diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index bcb07038ea..e2d33d13cc 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -64,7 +64,7 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period); void mark_(uint32_t on_time, uint32_t off_time, uint32_t usec); diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2/__init__.py similarity index 92% rename from esphome/components/rp2040/__init__.py rename to esphome/components/rp2/__init__.py index e76ce6def8..21a885a7cf 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -21,7 +21,7 @@ from esphome.const import ( KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, - PLATFORM_RP2040, + PLATFORM_RP2, ThreadModel, ) from esphome.core import ( @@ -40,27 +40,34 @@ from .const import ( KEY_BOARD, KEY_LWIP_OPTS, KEY_PIO_FILES, - KEY_RP2040, + KEY_RP2, KEY_VARIANT, MCU_TO_VARIANT, STANDARD_BOARDS, VARIANT_FRIENDLY, VARIANTS, - rp2040_ns, + rp2_ns, ) # force import gpio to register pin schema -from .gpio import rp2040_pin_to_code # noqa: F401 +from .gpio import rp2_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@jesserockz"] AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True +# Legacy top-level YAML keys that route here. The framework +# (esphome/loader.py + esphome/config.py) handles both the deprecation +# warning and the key-rename pass; this declaration is the only place a +# component needs to opt in. See ComponentManifest.aliases for details. +ALIASES = ["rp2040"] +ALIAS_REMOVAL_VERSION = "2027.7.0" + def get_board() -> str: """Return the configured board name.""" - return CORE.data[KEY_RP2040][KEY_BOARD] + return CORE.data[KEY_RP2][KEY_BOARD] def board_has_wifi() -> bool: @@ -90,22 +97,22 @@ def board_id_has_wifi(board_id: str) -> bool: def set_core_data(config: ConfigType) -> ConfigType: - CORE.data[KEY_RP2040] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2040 + CORE.data[KEY_RP2] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( config[CONF_FRAMEWORK][CONF_VERSION] ) - CORE.data[KEY_RP2040][KEY_BOARD] = config[CONF_BOARD] - CORE.data[KEY_RP2040][KEY_VARIANT] = config[CONF_VARIANT] + CORE.data[KEY_RP2][KEY_BOARD] = config[CONF_BOARD] + CORE.data[KEY_RP2][KEY_VARIANT] = config[CONF_VARIANT] - CORE.data[KEY_RP2040][KEY_PIO_FILES] = {} + CORE.data[KEY_RP2][KEY_PIO_FILES] = {} return config def get_rp2040_variant(core_obj: EsphomeCore | None = None) -> str: - return (core_obj or CORE).data[KEY_RP2040][KEY_VARIANT] + return (core_obj or CORE).data[KEY_RP2][KEY_VARIANT] def only_on_variant( @@ -121,7 +128,7 @@ def only_on_variant( unsupported = [unsupported] def validator_(obj: Any) -> Any: - if not CORE.is_rp2040: + if not CORE.is_rp2: raise cv.Invalid(f"{msg_prefix} is only available on RP2040") variant = get_rp2040_variant() if supported is not None and variant not in supported: @@ -306,13 +313,18 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config): - cg.add(rp2040_ns.setup_preferences()) + cg.add(rp2_ns.setup_preferences()) # Allow LDF to properly discover dependency including those in preprocessor # conditionals cg.add_platformio_option("lib_ldf_mode", "chain+") cg.add_platformio_option("lib_compat_mode", "strict") cg.add_platformio_option("board", config[CONF_BOARD]) + cg.add_build_flag("-DUSE_RP2") + # USE_RP2040 kept defined as a backwards-compat alias for external + # custom components that may still test for it. Internal code uses + # USE_RP2 (the canonical name for the RP2 chip family — covers + # RP2040, RP2350, and any future RP2-series chips). cg.add_build_flag("-DUSE_RP2040") cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") @@ -327,7 +339,8 @@ async def to_code(config): conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") cg.add_build_flag("-DUSE_ARDUINO") - cg.add_build_flag("-DUSE_RP2040_FRAMEWORK_ARDUINO") + cg.add_build_flag("-DUSE_RP2_FRAMEWORK_ARDUINO") + cg.add_build_flag("-DUSE_RP2040_FRAMEWORK_ARDUINO") # back-compat alias # cg.add_build_flag("-DPICO_BOARD=pico_w") cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) cg.add_platformio_option( @@ -359,8 +372,12 @@ async def to_code(config): cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"), ) - cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) - cg.add_define("USE_RP2040_CRASH_HANDLER") + cg.add_define("USE_RP2_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) + cg.add_define( + "USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT] + ) # back-compat alias + cg.add_define("USE_RP2_CRASH_HANDLER") + cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias _configure_lwip() @@ -465,7 +482,7 @@ def _configure_lwip() -> None: } # Store for copy_files() to generate the header - CORE.data[KEY_RP2040][KEY_LWIP_OPTS] = lwip_defines + CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines # Add a pre-build extra script that injects our lwip_override directory # into CCFLAGS so our lwipopts.h shadows the framework's version. @@ -500,7 +517,7 @@ def _generate_lwipopts_h() -> None: """ from jinja2 import Environment - lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) + lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS) if not lwip_defines: return @@ -527,7 +544,7 @@ def add_pio_file(component: str, key: str, data: str): raise EsphomeError( f"[{component}] Invalid PIO key: {key}. Allowed characters: [{ascii_letters}{digits}_]\nPlease report an issue https://github.com/esphome/esphome/issues" ) from e - CORE.data[KEY_RP2040][KEY_PIO_FILES][key] = data + CORE.data[KEY_RP2][KEY_PIO_FILES][key] = data def generate_pio_files() -> bool: @@ -536,7 +553,7 @@ def generate_pio_files() -> bool: shutil.rmtree(CORE.relative_build_path("src/pio"), ignore_errors=True) includes: list[str] = [] - files = CORE.data[KEY_RP2040][KEY_PIO_FILES] + files = CORE.data[KEY_RP2][KEY_PIO_FILES] if not files: return False for key, data in files.items(): @@ -581,7 +598,7 @@ def copy_files(): # RP2040 crash handler stacktrace decoding -# Matches output from esphome/components/rp2040/crash_handler.cpp +# Matches output from esphome/components/rp2/crash_handler.cpp _CRASH_RE = re.compile(r"CRASH DETECTED ON PREVIOUS BOOT") _CRASH_ADDR_RE = re.compile( r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)" diff --git a/esphome/components/rp2040/boards.jinja2 b/esphome/components/rp2/boards.jinja2 similarity index 56% rename from esphome/components/rp2040/boards.jinja2 rename to esphome/components/rp2/boards.jinja2 index 989fb83701..9223009c26 100644 --- a/esphome/components/rp2040/boards.jinja2 +++ b/esphome/components/rp2/boards.jinja2 @@ -1,14 +1,14 @@ # Auto-generated by generate_boards.py — do not edit manually -# To regenerate: python esphome/components/rp2040/generate_boards.py +# To regenerate: python esphome/components/rp2/generate_boards.py # arduino-pico maps pins >= {{ cyw43_gpio_offset }} to CYW43 wireless chip GPIOs CYW43_GPIO_OFFSET = {{ cyw43_gpio_offset }} CYW43_MAX_GPIO = {{ cyw43_max_gpio }} DEFAULT_MAX_PIN = {{ default_max_pin }} -RP2040_BASE_PINS = {} +RP2_BASE_PINS = {} -RP2040_BOARD_PINS = { +RP2_BOARD_PINS = { {%- for name, pins in board_pins %} {{ name | repr }}: {{ pins | format_pins }}, {%- endfor %} @@ -23,3 +23,10 @@ BOARDS = { }, {%- endfor %} } + +# Deprecated: use RP2_BASE_PINS / RP2_BOARD_PINS instead. Kept as back-compat +# aliases so external custom components / tooling that imported the legacy +# names via the ``rp2040`` package alias keep working. +# Scheduled for removal in 2027.7.0. +RP2040_BASE_PINS = RP2_BASE_PINS +RP2040_BOARD_PINS = RP2_BOARD_PINS diff --git a/esphome/components/rp2040/boards.py b/esphome/components/rp2/boards.py similarity index 99% rename from esphome/components/rp2040/boards.py rename to esphome/components/rp2/boards.py index 0bc5c48d03..94d0ebbb60 100644 --- a/esphome/components/rp2040/boards.py +++ b/esphome/components/rp2/boards.py @@ -1,14 +1,14 @@ # Auto-generated by generate_boards.py — do not edit manually -# To regenerate: python esphome/components/rp2040/generate_boards.py +# To regenerate: python esphome/components/rp2/generate_boards.py # arduino-pico maps pins >= 64 to CYW43 wireless chip GPIOs CYW43_GPIO_OFFSET = 64 CYW43_MAX_GPIO = 66 DEFAULT_MAX_PIN = 29 -RP2040_BASE_PINS = {} +RP2_BASE_PINS = {} -RP2040_BOARD_PINS = { +RP2_BOARD_PINS = { "0xcb_helios": { "LED": 17, "MISO": 20, @@ -2299,3 +2299,10 @@ BOARDS = { "max_pin": 29, }, } + +# Deprecated: use RP2_BASE_PINS / RP2_BOARD_PINS instead. Kept as back-compat +# aliases so external custom components / tooling that imported the legacy +# names via the ``rp2040`` package alias keep working. +# Scheduled for removal in 2027.7.0. +RP2040_BASE_PINS = RP2_BASE_PINS +RP2040_BOARD_PINS = RP2_BOARD_PINS diff --git a/esphome/components/rp2040/build_pio.py.script b/esphome/components/rp2/build_pio.py.script similarity index 100% rename from esphome/components/rp2040/build_pio.py.script rename to esphome/components/rp2/build_pio.py.script diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2/const.py similarity index 91% rename from esphome/components/rp2040/const.py rename to esphome/components/rp2/const.py index 959753d95b..515f9f007c 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2/const.py @@ -2,7 +2,7 @@ import esphome.codegen as cg KEY_BOARD = "board" KEY_LWIP_OPTS = "lwip_opts" -KEY_RP2040 = "rp2040" +KEY_RP2 = "rp2" KEY_PIO_FILES = "pio_files" KEY_VARIANT = "variant" @@ -31,4 +31,4 @@ STANDARD_BOARDS = { VARIANT_RP2350: "rpipico2w", } -rp2040_ns = cg.esphome_ns.namespace("rp2040") +rp2_ns = cg.esphome_ns.namespace("rp2") diff --git a/esphome/components/rp2/core.cpp b/esphome/components/rp2/core.cpp new file mode 100644 index 0000000000..2509f47a86 --- /dev/null +++ b/esphome/components/rp2/core.cpp @@ -0,0 +1,6 @@ +#ifdef USE_RP2 + +// HAL functions live in hal.cpp. core.cpp is intentionally empty for +// rp2 — there is no extra component bootstrap to keep here. + +#endif // USE_RP2 diff --git a/esphome/components/rp2040/core.h b/esphome/components/rp2/core.h similarity index 53% rename from esphome/components/rp2040/core.h rename to esphome/components/rp2/core.h index db8937a8a3..c53c3719eb 100644 --- a/esphome/components/rp2040/core.h +++ b/esphome/components/rp2/core.h @@ -1,12 +1,12 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include extern "C" unsigned long ulMainGetRunTimeCounterValue(); -namespace esphome::rp2040 {} // namespace esphome::rp2040 +namespace esphome::rp2 {} // namespace esphome::rp2 -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp similarity index 97% rename from esphome/components/rp2040/crash_handler.cpp rename to esphome/components/rp2/crash_handler.cpp index f9eb42a0f8..5553a24a60 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -1,7 +1,7 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER #include "crash_handler.h" #include "esphome/core/log.h" @@ -51,9 +51,9 @@ static inline bool is_code_addr(uint32_t val) { static constexpr size_t MAX_BACKTRACE = 4; -namespace esphome::rp2040 { +namespace esphome::rp2 { -static const char *const TAG = "rp2040.crash"; +static const char *const TAG = "rp2.crash"; // Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). // The valid field is explicitly cleared in crash_handler_read_and_clear() instead. @@ -117,7 +117,7 @@ void crash_handler_log() { ESP_LOGE(TAG, "%s", hint); } -} // namespace esphome::rp2040 +} // namespace esphome::rp2 // --- HardFault handler --- // Overrides the weak isr_hardfault from arduino-pico's crt0.S. @@ -236,5 +236,5 @@ extern "C" void __attribute__((naked, used)) isr_hardfault() { : "i"(hard_fault_handler_c)); } -#endif // USE_RP2040_CRASH_HANDLER -#endif // USE_RP2040 +#endif // USE_RP2_CRASH_HANDLER +#endif // USE_RP2 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2/crash_handler.h similarity index 66% rename from esphome/components/rp2040/crash_handler.h rename to esphome/components/rp2/crash_handler.h index 78e8ede08c..8c43d9fd3b 100644 --- a/esphome/components/rp2040/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -1,12 +1,12 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER -namespace esphome::rp2040 { +namespace esphome::rp2 { /// Read crash data from watchdog scratch registers and clear them. void crash_handler_read_and_clear(); @@ -17,7 +17,7 @@ void crash_handler_log(); /// Returns true if crash data was found this boot. bool crash_handler_has_data(); -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -#endif // USE_RP2040_CRASH_HANDLER -#endif // USE_RP2040 +#endif // USE_RP2_CRASH_HANDLER +#endif // USE_RP2 diff --git a/esphome/components/rp2040/generate_boards.py b/esphome/components/rp2/generate_boards.py similarity index 98% rename from esphome/components/rp2040/generate_boards.py rename to esphome/components/rp2/generate_boards.py index b1a0b17ca3..33eb1b3058 100644 --- a/esphome/components/rp2040/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -1,6 +1,6 @@ """Generate boards.py from arduino-pico board definitions. -Usage: python esphome/components/rp2040/generate_boards.py +Usage: python esphome/components/rp2/generate_boards.py """ import json diff --git a/esphome/components/rp2040/gpio.cpp b/esphome/components/rp2/gpio.cpp similarity index 81% rename from esphome/components/rp2040/gpio.cpp rename to esphome/components/rp2/gpio.cpp index 4b3c98104c..0dbb124a26 100644 --- a/esphome/components/rp2040/gpio.cpp +++ b/esphome/components/rp2/gpio.cpp @@ -1,12 +1,12 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "gpio.h" #include "esphome/core/log.h" namespace esphome { -namespace rp2040 { +namespace rp2 { -static const char *const TAG = "rp2040"; +static const char *const TAG = "rp2"; static int flags_to_mode(gpio::Flags flags, uint8_t pin) { if (flags == gpio::FLAG_INPUT) { // NOLINT(bugprone-branch-clone) @@ -30,7 +30,7 @@ struct ISRPinArg { bool inverted; }; -ISRInternalGPIOPin RP2040GPIOPin::to_isr() const { +ISRInternalGPIOPin RP2GPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) arg->pin = this->pin_; arg->inverted = this->inverted_; @@ -38,7 +38,7 @@ ISRInternalGPIOPin RP2040GPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void RP2040GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void RP2GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { PinStatus arduino_mode = LOW; switch (type) { case gpio::INTERRUPT_RISING_EDGE: @@ -60,25 +60,23 @@ void RP2040GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::Inte attachInterrupt(pin_, func, arduino_mode, arg); } -void RP2040GPIOPin::pin_mode(gpio::Flags flags) { +void RP2GPIOPin::pin_mode(gpio::Flags flags) { pinMode(pin_, flags_to_mode(flags, pin_)); // NOLINT } -size_t RP2040GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "GPIO%u", this->pin_); -} +size_t RP2GPIOPin::dump_summary(char *buffer, size_t len) const { return snprintf(buffer, len, "GPIO%u", this->pin_); } -bool RP2040GPIOPin::digital_read() { +bool RP2GPIOPin::digital_read() { return bool(digitalRead(pin_)) != inverted_; // NOLINT } -void RP2040GPIOPin::digital_write(bool value) { +void RP2GPIOPin::digital_write(bool value) { digitalWrite(pin_, value != inverted_ ? 1 : 0); // NOLINT } -void RP2040GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } +void RP2GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } -} // namespace rp2040 +} // namespace rp2 -using namespace rp2040; +using namespace rp2; bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { auto *arg = reinterpret_cast(this->arg_); @@ -115,4 +113,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/gpio.h b/esphome/components/rp2/gpio.h similarity index 85% rename from esphome/components/rp2040/gpio.h rename to esphome/components/rp2/gpio.h index b9aa497b47..538fef619a 100644 --- a/esphome/components/rp2040/gpio.h +++ b/esphome/components/rp2/gpio.h @@ -1,13 +1,13 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include "esphome/core/hal.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { -class RP2040GPIOPin final : public InternalGPIOPin { +class RP2GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -32,6 +32,6 @@ class RP2040GPIOPin final : public InternalGPIOPin { gpio::Flags flags_{}; }; -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/gpio.py b/esphome/components/rp2/gpio.py similarity index 82% rename from esphome/components/rp2040/gpio.py rename to esphome/components/rp2/gpio.py index 18fb09f76a..e4db6a831c 100644 --- a/esphome/components/rp2040/gpio.py +++ b/esphome/components/rp2/gpio.py @@ -16,22 +16,22 @@ from esphome.const import ( from esphome.core import CORE from . import boards -from .const import KEY_BOARD, KEY_RP2040, rp2040_ns +from .const import KEY_BOARD, KEY_RP2, rp2_ns -RP2040GPIOPin = rp2040_ns.class_("RP2040GPIOPin", cg.InternalGPIOPin) +RP2GPIOPin = rp2_ns.class_("RP2GPIOPin", cg.InternalGPIOPin) def _lookup_pin(value): - board = CORE.data[KEY_RP2040][KEY_BOARD] - board_pins = boards.RP2040_BOARD_PINS.get(board, {}) + board = CORE.data[KEY_RP2][KEY_BOARD] + board_pins = boards.RP2_BOARD_PINS.get(board, {}) while isinstance(board_pins, str): - board_pins = boards.RP2040_BOARD_PINS[board_pins] + board_pins = boards.RP2_BOARD_PINS[board_pins] if value in board_pins: return board_pins[value] - if value in boards.RP2040_BASE_PINS: - return boards.RP2040_BASE_PINS[value] + if value in boards.RP2_BASE_PINS: + return boards.RP2_BASE_PINS[value] raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") @@ -61,7 +61,7 @@ def _board_max_virtual_pin(board): def validate_gpio_pin(value): value = _translate_pin(value) - board = CORE.data[KEY_RP2040][KEY_BOARD] + board = CORE.data[KEY_RP2][KEY_BOARD] max_virtual = _board_max_virtual_pin(board) if max_virtual is not None and boards.CYW43_GPIO_OFFSET <= value <= max_virtual: return value @@ -72,7 +72,7 @@ def validate_gpio_pin(value): def validate_supports(value): - board = CORE.data[KEY_RP2040][KEY_BOARD] + board = CORE.data[KEY_RP2][KEY_BOARD] if ( _board_max_virtual_pin(board) is None or value[CONF_NUMBER] < boards.CYW43_GPIO_OFFSET @@ -89,9 +89,9 @@ def validate_supports(value): return value -RP2040_PIN_SCHEMA = cv.All( +RP2_PIN_SCHEMA = cv.All( pins.gpio_base_schema( - RP2040GPIOPin, + RP2GPIOPin, validate_gpio_pin, modes=pins.GPIO_STANDARD_MODES + (CONF_ANALOG,), ), @@ -99,8 +99,8 @@ RP2040_PIN_SCHEMA = cv.All( ) -@pins.PIN_SCHEMA_REGISTRY.register("rp2040", RP2040_PIN_SCHEMA) -async def rp2040_pin_to_code(config): +@pins.PIN_SCHEMA_REGISTRY.register("rp2", RP2_PIN_SCHEMA) +async def rp2_pin_to_code(config): var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/rp2040/hal.cpp b/esphome/components/rp2/hal.cpp similarity index 58% rename from esphome/components/rp2040/hal.cpp rename to esphome/components/rp2/hal.cpp index e71d3fd54d..28535cacbb 100644 --- a/esphome/components/rp2040/hal.cpp +++ b/esphome/components/rp2/hal.cpp @@ -1,23 +1,23 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "core.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER #include "crash_handler.h" #endif #include "hardware/watchdog.h" -// Empty rp2040 namespace block to satisfy ci-custom's lint_namespace check. +// Empty rp2 namespace block to satisfy ci-custom's lint_namespace check. // HAL functions live in namespace esphome (root) — they are not part of the -// rp2040 component's API. -namespace esphome::rp2040 {} // namespace esphome::rp2040 +// rp2 component's API. +namespace esphome::rp2 {} // namespace esphome::rp2 namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), -// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2040/hal.h. +// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2/hal.h. void arch_restart() { watchdog_reboot(0, 0, 10); while (1) { @@ -26,11 +26,11 @@ void arch_restart() { } void arch_init() { -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_read_and_clear(); +#ifdef USE_RP2_CRASH_HANDLER + rp2::crash_handler_read_and_clear(); #endif -#if USE_RP2040_WATCHDOG_TIMEOUT > 0 - watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); +#if USE_RP2_WATCHDOG_TIMEOUT > 0 + watchdog_enable(USE_RP2_WATCHDOG_TIMEOUT, false); #endif } @@ -38,4 +38,4 @@ uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/hal.h b/esphome/components/rp2/hal.h similarity index 96% rename from esphome/components/rp2040/hal.h rename to esphome/components/rp2/hal.h index c9c61c921d..b16f31d797 100644 --- a/esphome/components/rp2040/hal.h +++ b/esphome/components/rp2/hal.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include @@ -25,7 +25,7 @@ extern "C" uint64_t time_us_64(void); extern "C" void watchdog_update(void); extern "C" unsigned long ulMainGetRunTimeCounterValue(void); -namespace esphome::rp2040 {} +namespace esphome::rp2 {} namespace esphome { @@ -58,4 +58,4 @@ uint32_t arch_get_cpu_freq_hz(); } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2/helpers.cpp similarity index 98% rename from esphome/components/rp2040/helpers.cpp rename to esphome/components/rp2/helpers.cpp index 6e5ddad236..a54bcf80f7 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2/helpers.cpp @@ -1,7 +1,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" @@ -89,4 +89,4 @@ void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parame } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/inject_lwip_include.py.script b/esphome/components/rp2/inject_lwip_include.py.script similarity index 100% rename from esphome/components/rp2040/inject_lwip_include.py.script rename to esphome/components/rp2/inject_lwip_include.py.script diff --git a/esphome/components/rp2040/lwipopts.h.jinja b/esphome/components/rp2/lwipopts.h.jinja similarity index 100% rename from esphome/components/rp2040/lwipopts.h.jinja rename to esphome/components/rp2/lwipopts.h.jinja diff --git a/esphome/components/rp2040/post_build.py.script b/esphome/components/rp2/post_build.py.script similarity index 100% rename from esphome/components/rp2040/post_build.py.script rename to esphome/components/rp2/post_build.py.script diff --git a/esphome/components/rp2/preference_backend.h b/esphome/components/rp2/preference_backend.h new file mode 100644 index 0000000000..c5e8a757da --- /dev/null +++ b/esphome/components/rp2/preference_backend.h @@ -0,0 +1,27 @@ +#pragma once +#ifdef USE_RP2 + +#include +#include + +namespace esphome::rp2 { + +class RP2PreferenceBackend final { + public: + bool save(const uint8_t *data, size_t len); + bool load(uint8_t *data, size_t len); + + size_t offset = 0; + uint32_t type = 0; +}; + +class RP2Preferences; +RP2Preferences *get_preferences(); + +} // namespace esphome::rp2 + +namespace esphome { +using PreferenceBackend = rp2::RP2PreferenceBackend; +} // namespace esphome + +#endif // USE_RP2 diff --git a/esphome/components/rp2040/preferences.cpp b/esphome/components/rp2/preferences.cpp similarity index 82% rename from esphome/components/rp2040/preferences.cpp rename to esphome/components/rp2/preferences.cpp index cfc802b28f..778ce070a9 100644 --- a/esphome/components/rp2040/preferences.cpp +++ b/esphome/components/rp2/preferences.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include @@ -12,7 +12,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { static const char *const TAG = "preferences"; @@ -37,7 +37,7 @@ template uint8_t calculate_crc(It first, It last, uint32_t type) { return crc; } -bool RP2040PreferenceBackend::save(const uint8_t *data, size_t len) { +bool RP2PreferenceBackend::save(const uint8_t *data, size_t len) { const size_t buffer_size = len + 1; if (buffer_size > PREF_MAX_BUFFER_SIZE) return false; @@ -58,7 +58,7 @@ bool RP2040PreferenceBackend::save(const uint8_t *data, size_t len) { return true; } -bool RP2040PreferenceBackend::load(uint8_t *data, size_t len) { +bool RP2PreferenceBackend::load(uint8_t *data, size_t len) { const size_t buffer_size = len + 1; if (buffer_size > PREF_MAX_BUFFER_SIZE) return false; @@ -80,27 +80,27 @@ bool RP2040PreferenceBackend::load(uint8_t *data, size_t len) { return true; } -RP2040Preferences::RP2040Preferences() : eeprom_sector_(&_EEPROM_start) {} +RP2Preferences::RP2Preferences() : eeprom_sector_(&_EEPROM_start) {} -void RP2040Preferences::setup() { +void RP2Preferences::setup() { ESP_LOGVV(TAG, "Loading preferences from flash"); memcpy(s_flash_storage, this->eeprom_sector_, RP2040_FLASH_STORAGE_SIZE); } -ESPPreferenceObject RP2040Preferences::make_preference(size_t length, uint32_t type) { +ESPPreferenceObject RP2Preferences::make_preference(size_t length, uint32_t type) { uint32_t start = this->current_flash_offset; uint32_t end = start + length + 1; if (end > RP2040_FLASH_STORAGE_SIZE) { return {}; } - auto *pref = new RP2040PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) + auto *pref = new RP2PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->offset = start; pref->type = type; this->current_flash_offset = end; return ESPPreferenceObject(pref); } -bool RP2040Preferences::sync() { +bool RP2Preferences::sync() { if (!s_flash_dirty) return true; if (s_prevent_write) @@ -121,7 +121,7 @@ bool RP2040Preferences::sync() { return true; } -bool RP2040Preferences::reset() { +bool RP2Preferences::reset() { ESP_LOGD(TAG, "Erasing storage"); { InterruptLock lock; @@ -133,9 +133,9 @@ bool RP2040Preferences::reset() { return true; } -static RP2040Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static RP2Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -RP2040Preferences *get_preferences() { return &s_preferences; } +RP2Preferences *get_preferences() { return &s_preferences; } void setup_preferences() { s_preferences.setup(); @@ -143,10 +143,10 @@ void setup_preferences() { } void preferences_prevent_write(bool prevent) { s_prevent_write = prevent; } -} // namespace esphome::rp2040 +} // namespace esphome::rp2 namespace esphome { ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/preferences.h b/esphome/components/rp2/preferences.h similarity index 59% rename from esphome/components/rp2040/preferences.h rename to esphome/components/rp2/preferences.h index eb8c3e5f64..95f7263883 100644 --- a/esphome/components/rp2040/preferences.h +++ b/esphome/components/rp2/preferences.h @@ -1,14 +1,14 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/preference_backend.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { -class RP2040Preferences final : public PreferencesMixin { +class RP2Preferences final : public PreferencesMixin { public: - using PreferencesMixin::make_preference; - RP2040Preferences(); + using PreferencesMixin::make_preference; + RP2Preferences(); void setup(); ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { return this->make_preference(length, type); @@ -26,8 +26,8 @@ class RP2040Preferences final : public PreferencesMixin { void setup_preferences(); void preferences_prevent_write(bool prevent); -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -DECLARE_PREFERENCE_ALIASES(esphome::rp2040::RP2040Preferences) +DECLARE_PREFERENCE_ALIASES(esphome::rp2::RP2Preferences) -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/printf_stubs.cpp b/esphome/components/rp2/printf_stubs.cpp similarity index 94% rename from esphome/components/rp2040/printf_stubs.cpp rename to esphome/components/rp2/printf_stubs.cpp index c2174a1dec..bf03565f30 100644 --- a/esphome/components/rp2040/printf_stubs.cpp +++ b/esphome/components/rp2/printf_stubs.cpp @@ -13,12 +13,12 @@ * Saves ~8.9 KB of flash. */ -#if defined(USE_RP2040) && !defined(USE_FULL_PRINTF) +#if defined(USE_RP2) && !defined(USE_FULL_PRINTF) #include #include #include -namespace esphome::rp2040 {} +namespace esphome::rp2 {} static constexpr size_t PRINTF_BUFFER_SIZE = 512; @@ -71,4 +71,4 @@ int __wrap_fprintf(FILE *stream, const char *fmt, ...) { } // extern "C" // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -#endif // USE_RP2040 && !USE_FULL_PRINTF +#endif // USE_RP2 && !USE_FULL_PRINTF diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp deleted file mode 100644 index 11f23ccfef..0000000000 --- a/esphome/components/rp2040/core.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#ifdef USE_RP2040 - -// HAL functions live in hal.cpp. core.cpp is intentionally empty for -// rp2040 — there is no extra component bootstrap to keep here. - -#endif // USE_RP2040 diff --git a/esphome/components/rp2040/preference_backend.h b/esphome/components/rp2040/preference_backend.h deleted file mode 100644 index 790ee8831d..0000000000 --- a/esphome/components/rp2040/preference_backend.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#ifdef USE_RP2040 - -#include -#include - -namespace esphome::rp2040 { - -class RP2040PreferenceBackend final { - public: - bool save(const uint8_t *data, size_t len); - bool load(uint8_t *data, size_t len); - - size_t offset = 0; - uint32_t type = 0; -}; - -class RP2040Preferences; -RP2040Preferences *get_preferences(); - -} // namespace esphome::rp2040 - -namespace esphome { -using PreferenceBackend = rp2040::RP2040PreferenceBackend; -} // namespace esphome - -#endif // USE_RP2040 diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index 648f22691c..ac012b5e85 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID from esphome.types import ConfigType -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] CODEOWNERS = ["@bdraco"] rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 8afba6ba1d..b9c0a9c257 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -1,6 +1,6 @@ #include "led_strip.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index aaa5b0842d..b74dd14108 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -128,4 +128,4 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { } // namespace esphome::rp2040_pio_led_strip -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 274f059bd5..b3f816102a 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg -from esphome.components import light, rp2040 +from esphome.components import light, rp2 import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -130,9 +130,9 @@ def time_to_cycles(time_us): CONF_PIO = "pio" -AUTO_LOAD = ["rp2040_pio"] +AUTO_LOAD = ["rp2_pio"] CODEOWNERS = ["@Papa-DMan"] -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] rp2040_pio_led_strip_ns = cg.esphome_ns.namespace("rp2040_pio_led_strip") RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( @@ -250,7 +250,7 @@ async def to_code(config): if chipset := config.get(CONF_CHIPSET): cg.add(var.set_chipset(chipset)) _LOGGER.info("Generating PIO assembly code") - rp2040.add_pio_file( + rp2.add_pio_file( __name__, key, generate_assembly_code( @@ -265,7 +265,7 @@ async def to_code(config): else: cg.add(var.set_chipset(Chipset.CHIPSET_CUSTOM)) _LOGGER.info("Generating custom PIO assembly code") - rp2040.add_pio_file( + rp2.add_pio_file( __name__, key, generate_assembly_code( diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index ad37926954..a2fda58c9e 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -5,7 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] rp2040_pwm_ns = cg.esphome_ns.namespace("rp2040_pwm") diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.cpp b/esphome/components/rp2040_pwm/rp2040_pwm.cpp index c9b9e6739d..270cc33551 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.cpp +++ b/esphome/components/rp2040_pwm/rp2040_pwm.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "rp2040_pwm.h" #include "esphome/core/defines.h" diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index 49980a7d76..8263113168 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/components/output/float_output.h" #include "esphome/core/automation.h" @@ -54,4 +54,4 @@ template class SetFrequencyAction final : public Action { } // namespace esphome::rp2040_pwm -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040_pio/__init__.py b/esphome/components/rp2_pio/__init__.py similarity index 98% rename from esphome/components/rp2040_pio/__init__.py rename to esphome/components/rp2_pio/__init__.py index eecfedaa75..9046d2ae6b 100644 --- a/esphome/components/rp2040_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -3,7 +3,7 @@ import platform import esphome.codegen as cg import esphome.config_validation as cv -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] PIOASM_REPO_VERSION = "1.5.0-b" diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 079665c959..136d0f1d58 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -1,7 +1,7 @@ #include "sha256.h" // Only compile SHA256 implementation on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include "esphome/core/helpers.h" #include @@ -76,7 +76,7 @@ void SHA256::add(const uint8_t *data, size_t len) { mbedtls_sha256_update(&this- void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_, this->digest_); } -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) SHA256::~SHA256() = default; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index d10d418c7a..26afe9e33e 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -3,7 +3,7 @@ #include "esphome/core/defines.h" // Only define SHA256 on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include #include @@ -25,7 +25,7 @@ #elif defined(USE_LIBRETINY) #define USE_SHA256_MBEDTLS #include "mbedtls/sha256.h" -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) #include #elif defined(USE_HOST) #include @@ -70,7 +70,7 @@ class SHA256 final : public esphome::HashBase { // The mbedtls context for ESP32-S3 hardware SHA requires proper alignment and stack frame constraints. // See class documentation above for critical requirements. mbedtls_sha256_context ctx_{}; -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) br_sha256_context ctx_{}; bool calculated_{false}; #elif defined(USE_HOST) diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index 69a2436d3d..7d592f8ef8 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -13,7 +13,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import CORE @@ -98,7 +98,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX, diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 38d787c20a..cd002d9eb0 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -144,7 +144,7 @@ CONFIG_SCHEMA = cv.Schema( CONF_IMPLEMENTATION, esp8266=IMPLEMENTATION_LWIP_TCP, esp32=IMPLEMENTATION_BSD_SOCKETS, - rp2040=IMPLEMENTATION_LWIP_TCP, + rp2=IMPLEMENTATION_LWIP_TCP, bk72xx=IMPLEMENTATION_LWIP_SOCKETS, ln882x=IMPLEMENTATION_LWIP_SOCKETS, rtl87xx=IMPLEMENTATION_LWIP_SOCKETS, diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index f9b652f14a..528d201799 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -104,7 +104,7 @@ struct iovec { size_t iov_len; }; -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // arduino-esp8266 declares a global vars called INADDR_NONE/ANY which are invalid with the define #ifdef INADDR_ANY #undef INADDR_ANY diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index c6692b0165..4fcec553fa 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -17,7 +17,7 @@ extern "C" void esphome_wake_ota_component_any_context(); #ifdef USE_ESP8266 #include // For esp_schedule() -#elif defined(USE_RP2040) +#elif defined(USE_RP2) #include // For __sev(), __wfe() #include // For add_alarm_in_ms(), cancel_alarm() #endif diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index d1961cec59..608adc7514 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -35,7 +35,7 @@ from esphome.const import ( KEY_VARIANT, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -54,7 +54,7 @@ SPIMode = spi_ns.enum("SPIMode") PLATFORM_SPI_CLOCKS = { PLATFORM_ESP8266: 40e6, PLATFORM_ESP32: 80e6, - PLATFORM_RP2040: 62.5e6, + PLATFORM_RP2: 62.5e6, } MAX_DATA_RATE_ERROR = 0.05 # Max allowable actual data rate difference from requested @@ -179,7 +179,7 @@ def get_hw_interface_list(): ]: return [["spi", "spi2"]] return [["spi", "spi2"], ["spi3"]] - if target_platform == PLATFORM_RP2040: + if target_platform == PLATFORM_RP2: return [["spi"], ["spi1"]] return [] @@ -247,7 +247,7 @@ def validate_hw_pins(spi, index=-1): if target_platform == PLATFORM_ESP32: return clk_pin_no >= 0 - if target_platform == PLATFORM_RP2040: + if target_platform == PLATFORM_RP2: if index == -1: matches = list( filter(lambda s: clk_pin_no in s[CONF_CLK_PIN], RP_SPI_PINSETS) @@ -323,7 +323,7 @@ def get_spi_interface(index): # ESP32 uses ESP-IDF SPI driver for both Arduino and IDF frameworks return ["SPI2_HOST", "SPI3_HOST"][index] # Arduino code follows - if platform == PLATFORM_RP2040: + if platform == PLATFORM_RP2: return ["&SPI", "&SPI1"][index] if index == 0: return "&SPI" @@ -349,7 +349,7 @@ SPI_SINGLE_SCHEMA = cv.All( } ), cv.has_at_least_one_key(CONF_MISO_PIN, CONF_MOSI_PIN), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2]), ) @@ -500,7 +500,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( { "spi_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index cada29b0d7..c038426f61 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -17,7 +17,7 @@ using SPIInterface = spi_host_device_t; #include -#ifdef USE_RP2040 +#ifdef USE_RP2 using SPIInterface = SPIClassRP2040 *; #else using SPIInterface = SPIClass *; diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index 4267fe63ce..a3e09d2800 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -11,7 +11,7 @@ class SPIDelegateHw : public SPIDelegate { : SPIDelegate(data_rate, bit_order, mode, cs_pin), channel_(channel) {} void begin_transaction() override { -#ifdef USE_RP2040 +#ifdef USE_RP2 SPISettings const settings(this->data_rate_, static_cast(this->bit_order_), this->mode_); #elif defined(ESP8266) // Arduino ESP8266 library has mangled values for SPI modes :-( @@ -41,7 +41,7 @@ class SPIDelegateHw : public SPIDelegate { this->channel_->transfer(*ptr); return; } -#ifdef USE_RP2040 +#ifdef USE_RP2 this->channel_->transfer(ptr, nullptr, length); #elif defined(USE_ESP8266) // ESP8266 SPI library requires the pointer to be word aligned, but the data may not be @@ -75,7 +75,7 @@ class SPIBusHw : public SPIBus { #ifdef USE_ESP32 channel->begin(Utility::get_pin_no(clk), Utility::get_pin_no(sdi), Utility::get_pin_no(sdo), -1); #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 if (Utility::get_pin_no(sdi) != -1) channel->setRX(Utility::get_pin_no(sdi)); if (Utility::get_pin_no(sdo) != -1) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 4e623942ac..6a52348ae9 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -10,7 +10,7 @@ #ifdef USE_ESP8266 #include "sys/time.h" #endif -#if defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_RP2) || defined(USE_ZEPHYR) #include #endif #include diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 4ea32e26a3..7e3701bb07 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -49,7 +49,7 @@ IDFUARTComponent = uart_ns.class_("IDFUARTComponent", UARTComponent, cg.Componen ESP8266UartComponent = uart_ns.class_( "ESP8266UartComponent", UARTComponent, cg.Component ) -RP2040UartComponent = uart_ns.class_("RP2040UartComponent", UARTComponent, cg.Component) +RP2UartComponent = uart_ns.class_("RP2UartComponent", UARTComponent, cg.Component) LibreTinyUARTComponent = uart_ns.class_( "LibreTinyUARTComponent", UARTComponent, cg.Component ) @@ -59,7 +59,7 @@ HostUartComponent = uart_ns.class_("HostUartComponent", UARTComponent, cg.Compon NATIVE_UART_CLASSES = ( str(IDFUARTComponent), str(ESP8266UartComponent), - str(RP2040UartComponent), + str(RP2UartComponent), str(LibreTinyUARTComponent), ) @@ -157,8 +157,8 @@ def _uart_declare_type(value): return cv.declare_id(ESP8266UartComponent)(value) if CORE.is_esp32: return cv.declare_id(IDFUARTComponent)(value) - if CORE.is_rp2040: - return cv.declare_id(RP2040UartComponent)(value) + if CORE.is_rp2: + return cv.declare_id(RP2UartComponent)(value) if CORE.is_libretiny: return cv.declare_id(LibreTinyUARTComponent)(value) if CORE.is_host: @@ -529,7 +529,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "uart_component_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "uart_component_host.cpp": {PlatformFramework.HOST_NATIVE}, - "uart_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "uart_component_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "uart_component_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2.cpp similarity index 91% rename from esphome/components/uart/uart_component_rp2040.cpp rename to esphome/components/uart/uart_component_rp2.cpp index 1aaf98dc84..9cc3009a22 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2.cpp @@ -1,5 +1,5 @@ -#ifdef USE_RP2040 -#include "uart_component_rp2040.h" +#ifdef USE_RP2 +#include "uart_component_rp2.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -13,9 +13,9 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_rp2040"; +static const char *const TAG = "uart.arduino_rp2"; -uint16_t RP2040UartComponent::get_config() { +uint16_t RP2UartComponent::get_config() { uint16_t config = 0; if (this->parity_ == UART_CONFIG_PARITY_NONE) { @@ -50,7 +50,7 @@ uint16_t RP2040UartComponent::get_config() { return config; } -void RP2040UartComponent::setup() { +void RP2UartComponent::setup() { auto setup_pin_if_needed = [](InternalGPIOPin *pin) { if (!pin) { return; @@ -162,7 +162,7 @@ void RP2040UartComponent::setup() { } } -void RP2040UartComponent::dump_config() { +void RP2UartComponent::dump_config() { ESP_LOGCONFIG(TAG, "UART Bus:"); LOG_PIN(" TX Pin: ", tx_pin_); LOG_PIN(" RX Pin: ", rx_pin_); @@ -182,7 +182,7 @@ void RP2040UartComponent::dump_config() { } } -void RP2040UartComponent::write_array(const uint8_t *data, size_t len) { +void RP2UartComponent::write_array(const uint8_t *data, size_t len) { this->serial_->write(data, len); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { @@ -190,13 +190,13 @@ void RP2040UartComponent::write_array(const uint8_t *data, size_t len) { } #endif } -bool RP2040UartComponent::peek_byte(uint8_t *data) { +bool RP2UartComponent::peek_byte(uint8_t *data) { if (!this->check_read_timeout_()) return false; *data = this->serial_->peek(); return true; } -bool RP2040UartComponent::read_array(uint8_t *data, size_t len) { +bool RP2UartComponent::read_array(uint8_t *data, size_t len) { if (!this->check_read_timeout_(len)) return false; this->serial_->readBytes(data, len); @@ -207,12 +207,12 @@ bool RP2040UartComponent::read_array(uint8_t *data, size_t len) { #endif return true; } -size_t RP2040UartComponent::available() { return this->serial_->available(); } -UARTFlushResult RP2040UartComponent::flush() { +size_t RP2UartComponent::available() { return this->serial_->available(); } +UARTFlushResult RP2UartComponent::flush() { ESP_LOGVV(TAG, " Flushing"); this->serial_->flush(); return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } } // namespace esphome::uart -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/uart/uart_component_rp2040.h b/esphome/components/uart/uart_component_rp2.h similarity index 88% rename from esphome/components/uart/uart_component_rp2040.h rename to esphome/components/uart/uart_component_rp2.h index b16d8b12d9..734bc6022e 100644 --- a/esphome/components/uart/uart_component_rp2040.h +++ b/esphome/components/uart/uart_component_rp2.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include @@ -13,7 +13,7 @@ namespace esphome::uart { -class RP2040UartComponent final : public UARTComponent, public Component { +class RP2UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; @@ -40,4 +40,4 @@ class RP2040UartComponent final : public UARTComponent, public Component { }; } // namespace esphome::uart -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/wake_on_lan/button.py b/esphome/components/wake_on_lan/button.py index b09e87e811..e1a4e4f4b0 100644 --- a/esphome/components/wake_on_lan/button.py +++ b/esphome/components/wake_on_lan/button.py @@ -8,7 +8,7 @@ DEPENDENCIES = ["network"] def AUTO_LOAD(): - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: return [] return ["socket"] diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index b05d7d4f6d..2063faeb91 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -9,7 +9,7 @@ #include "esp_idf_version.h" #include "esp_task_wdt.h" #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "hardware/watchdog.h" #include "pico/stdlib.h" #endif @@ -53,7 +53,7 @@ void WatchdogManager::set_timeout_(uint32_t timeout_ms) { esp_task_wdt_reconfigure(&wdt_config); #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 watchdog_enable(timeout_ms, true); #endif } @@ -65,7 +65,7 @@ uint32_t WatchdogManager::get_timeout_() { timeout_ms = (uint32_t) CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 timeout_ms = watchdog_get_count() / 1000; #endif diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 788bedec34..f4e9eae763 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -32,7 +32,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -227,7 +227,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ] ), diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index b587841dfd..fc575d1c06 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -63,7 +63,7 @@ async def to_code(config): cg.add_library("ESP8266WiFi", None) if CORE.is_libretiny: CORE.add_platformio_option("lib_ignore", ["ESPAsyncTCP", "RPAsyncTCP"]) - if CORE.is_rp2040: + if CORE.is_rp2: # Ignore bundled AsyncTCP libraries - we use RPAsyncTCP from async_tcp component CORE.add_platformio_option( "lib_ignore", ["ESPAsyncTCP", "AsyncTCP", "AsyncTCP_RP2040W"] diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index abce1fd5c0..af600647c1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -143,8 +143,8 @@ def has_native_wifi( """ if platform == Platform.ESP32: return variant_has_wifi(variant) if variant else True - if platform == Platform.RP2040: - from esphome.components.rp2040 import board_id_has_wifi + if platform == Platform.RP2: + from esphome.components.rp2 import board_id_has_wifi return board_id_has_wifi(board) if board else True return platform in _WIFI_FIRST_PLATFORMS @@ -301,7 +301,7 @@ def wifi_network_ap(value): if value is None: value = {} config = WIFI_NETWORK_AP(value) - if CONF_MANUAL_IP in config and CORE.is_rp2040: + if CONF_MANUAL_IP in config and CORE.is_rp2: raise cv.Invalid( "Manual AP IP configuration is not supported on RP2040. " "The AP uses the default IP 192.168.4.1" @@ -324,8 +324,8 @@ def validate_variant(_): variant = get_esp32_variant() if variant in NO_WIFI_VARIANTS and "esp32_hosted" not in fv.full_config.get(): raise cv.Invalid(f"WiFi requires component esp32_hosted on {variant}") - if CORE.is_rp2040: - from esphome.components.rp2040 import board_has_wifi, get_board + if CORE.is_rp2: + from esphome.components.rp2 import board_has_wifi, get_board if not board_has_wifi(): raise cv.Invalid( @@ -369,7 +369,7 @@ def _consume_wifi_sockets(config: ConfigType) -> ConfigType: DHCP/DNS). On ESP32, CONFIG_LWIP_MAX_SOCKETS only controls the POSIX socket layer — DHCP/DNS use raw udp_new() which bypasses it entirely. """ - if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x or CORE.is_rp2040): + if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x or CORE.is_rp2): return config from esphome.components import socket @@ -473,7 +473,7 @@ CONFIG_SCHEMA = cv.All( CONF_POWER_SAVE_MODE, esp8266="none", esp32="light", - rp2040="light", + rp2="light", bk72xx="none", rtl87xx="none", ln882x="light", @@ -676,7 +676,7 @@ async def to_code(config): if CONF_PHY_MODE in config: cg.add_define("USE_WIFI_PHY_MODE") cg.add(var.set_phy_mode(config[CONF_PHY_MODE])) - elif CORE.is_rp2040: + elif CORE.is_rp2: cg.add_library("WiFi", None) if CORE.is_esp32: @@ -944,7 +944,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO}, + "wifi_component_pico_w.cpp": {PlatformFramework.RP2_ARDUINO}, } ) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 2f6bec6bb2..c951e74358 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2388,7 +2388,7 @@ void WiFiComponent::clear_roaming_state_() { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { -#if defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_RP2) || defined(USE_ESP32) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c774e3a68e..0db85c4d75 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -46,7 +46,7 @@ extern "C" { #endif #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 extern "C" { #include "cyw43.h" #include "cyw43_country.h" @@ -181,7 +181,7 @@ static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8; // Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API) // Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible -#if defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_RP2) || defined(USE_ESP32) template using wifi_scan_vector_t = std::vector; #else template using wifi_scan_vector_t = FixedVector; @@ -815,7 +815,7 @@ class WiFiComponent final : public Component { friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); void wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); #endif diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 596fd2729b..1a70f81a2b 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -1,7 +1,7 @@ #include "wifi_component.h" #ifdef USE_WIFI -#ifdef USE_RP2040 +#ifdef USE_RP2 #include diff --git a/esphome/config_validation.py b/esphome/config_validation.py index b77e22a6fb..45fd94fd1a 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -71,7 +71,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, @@ -859,7 +859,38 @@ def only_with_framework( only_on_esp32 = only_on(PLATFORM_ESP32) only_on_esp8266 = only_on(PLATFORM_ESP8266) only_on_nrf52 = only_on(PLATFORM_NRF52) -only_on_rp2040 = only_on(PLATFORM_RP2040) +only_on_rp2 = only_on(PLATFORM_RP2) + +# CORE.data key for the "deprecation warning already fired this run" flag. +# Deduped via CORE.data (cleared between runs) to match the framework-alias +# pattern; one warning per `esphome config|compile|run` invocation is enough. +_ONLY_ON_RP2040_DEPRECATED_KEY = "_cv_only_on_rp2040_deprecated_warned" + + +def only_on_rp2040(obj): + """Deprecated — kept as a back-compat shim for external custom components. + + Pre-RP2350, this was the family check for the RP2 platform; with RP2350 + landing under the same target platform, the variant axis is now exposed + by the rp2 component itself. New code should use one of: + + * :func:`only_on_rp2` — family-level gate (matches the esp32 pattern; + same semantics as the pre-RP2350 ``only_on_rp2040``). + * ``rp2.only_on_variant(supported=[VARIANT_RP2040])`` — variant-level + gate, rejects RP2350 boards on the rp2 platform. + + Scheduled for removal in 2027.7.0. + """ + if not CORE.data.get(_ONLY_ON_RP2040_DEPRECATED_KEY): + _LOGGER.warning( + "cv.only_on_rp2040 is deprecated; use cv.only_on_rp2 for the " + "family gate, or rp2.only_on_variant(supported=[VARIANT_RP2040]) " + "for the variant gate. Removed in 2027.7.0." + ) + CORE.data[_ONLY_ON_RP2040_DEPRECATED_KEY] = True + return only_on_rp2(obj) + + only_with_arduino = only_with_framework(Framework.ARDUINO) @@ -1990,7 +2021,24 @@ def _get_default_key(*args): class SplitDefault(Optional): - """Mark this key to have a split default for ESP8266/ESP32.""" + """Mark this key to have a split default per target platform / variant / framework. + + Defaults are passed as kwargs keyed on the platform identifier; the most + specific match wins. Lookup order (first hit wins): + + 1. ``__`` — e.g. ``esp32_c3_arduino``, + ``rp2_2040_arduino`` + 2. ``_`` — e.g. ``esp32_c3``, ``rp2_2040`` + 3. ``_`` — e.g. ``esp32_arduino``, + ``rp2_arduino`` + 4. ```` — e.g. ``esp32``, ``rp2`` + + For ESP32 the variant strips the ``ESP32`` prefix from + :data:`esp32.VARIANT_*` constants (``ESP32C3`` → ``c3``). For RP2 the + variant strips just ``RP`` (``RP2040`` → ``2040``, ``RP2350`` → ``2350``) + so kwargs read naturally — `rp2_2040=...` is the override for the + Pico / Pico W and `rp2_2350=...` is the override for the Pico 2. + """ def __init__(self, key, **kwargs): super().__init__(key) @@ -2012,6 +2060,22 @@ class SplitDefault(Optional): keys += _get_default_key(variant, framework) keys += _get_default_key(variant) keys += _get_default_key(framework) + elif CORE.is_rp2: + # Strip the "RP" prefix to leave the chip number, mirroring + # the ESP32 "platform stripped from variant" convention so + # kwargs stay short (``rp2_2040`` rather than ``rp2_rp2040``). + # Variant lookup is defensive: validators may run before the + # rp2 component's ``set_core_data`` (or in tests that wire a + # partial ``CORE.data``); in that case we just skip the + # variant-specific keys and fall through to the base + # platform/framework defaults. + raw_variant = CORE.data.get("rp2", {}).get("variant") + framework = CORE.target_framework + if raw_variant: + variant = raw_variant.removeprefix("RP").lower() + keys += _get_default_key(variant, framework) + keys += _get_default_key(variant) + keys += _get_default_key(framework) keys += _get_default_key() for key in keys: if self._defaults.get(key) is not None: @@ -2443,18 +2507,58 @@ def require_framework_version( extra_message=None, **kwargs, ): + """Constrain the configured framework version per target platform / variant. + + Kwargs are keyed by ``_`` (e.g. ``esp32_arduino``, + ``rp2_arduino``) with optional variant-specific overrides keyed by + ``__`` (e.g. ``esp32_c3_arduino``, + ``rp2_2040_arduino``, ``rp2_2350_arduino``). Variant overrides win when + the configured variant matches; otherwise the base platform key is used. + + Special cases: ``host`` (with host framework) and ``esp_idf`` (any ESP32 + on ESP-IDF) bypass variant lookup. + """ + def validator(value): core_data = CORE.data[KEY_CORE] framework = core_data[KEY_TARGET_FRAMEWORK] + keys_to_try: list[str] = [] if CORE.is_host and framework == "host": - key = "host" + keys_to_try.append("host") elif framework == "esp-idf": - key = "esp_idf" + keys_to_try.append("esp_idf") else: - key = CORE.target_platform + "_" + framework + # Try variant-specific key first (mirrors the SplitDefault + # precedence). ESP32 strips its platform prefix from variant + # constants; RP2 strips just ``RP`` to keep chip-number kwargs + # (``rp2_2040``, ``rp2_2350``). + if CORE.is_esp32: + from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant - if key not in kwargs: + # Guard against tests that wire CORE.data without an + # esp32 variant block; same defensive intent as the rp2 + # branch below. + try: + variant = get_esp32_variant().replace(VARIANT_ESP32, "").lower() + except (KeyError, AttributeError): + variant = "" + if variant: + keys_to_try.append(f"{CORE.target_platform}_{variant}_{framework}") + elif CORE.is_rp2: + # Defensive lookup — see the matching block in + # ``SplitDefault.default``: the rp2 component's + # ``set_core_data`` may not have populated + # ``CORE.data["rp2"]["variant"]`` yet (validators run + # during schema validation, before code-gen). + raw_variant = CORE.data.get("rp2", {}).get("variant") + if raw_variant: + variant = raw_variant.removeprefix("RP").lower() + keys_to_try.append(f"{CORE.target_platform}_{variant}_{framework}") + keys_to_try.append(f"{CORE.target_platform}_{framework}") + + key = next((k for k in keys_to_try if k in kwargs), None) + if key is None: msg = f"This feature is incompatible with {CORE.target_platform.upper()} using {framework} framework" if extra_message: msg += f". {extra_message}" diff --git a/esphome/const.py b/esphome/const.py index 24bb4ea31f..16d11d3a18 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -33,7 +33,12 @@ class Platform(StrEnum): LIBRETINY_OLDSTYLE = "libretiny" LN882X = "ln882x" NRF52 = "nrf52" - RP2040 = "rp2040" + RP2 = "rp2" # canonical name for the RP2 family (RP2040, RP2350, …) + # Deprecated: use Platform.RP2 instead. Python enum aliasing makes this + # the same member as RP2 (same string value), so ``Platform.RP2040`` and + # ``Platform.RP2`` remain interchangeable for external custom components. + # Scheduled for removal in 2027.7.0. + RP2040 = "rp2" RTL87XX = "rtl87xx" @@ -86,6 +91,9 @@ class PlatformFramework(Enum): # Arduino framework platforms ESP8266_ARDUINO = (Platform.ESP8266, Framework.ARDUINO) + RP2_ARDUINO = (Platform.RP2, Framework.ARDUINO) + # Deprecated: use PlatformFramework.RP2_ARDUINO instead. Kept as an + # alias for backwards compatibility; scheduled for removal in 2027.7.0. RP2040_ARDUINO = (Platform.RP2040, Framework.ARDUINO) BK72XX_ARDUINO = (Platform.BK72XX, Framework.ARDUINO) RTL87XX_ARDUINO = (Platform.RTL87XX, Framework.ARDUINO) @@ -106,6 +114,9 @@ PLATFORM_HOST = Platform.HOST PLATFORM_LIBRETINY_OLDSTYLE = Platform.LIBRETINY_OLDSTYLE PLATFORM_LN882X = Platform.LN882X PLATFORM_NRF52 = Platform.NRF52 +PLATFORM_RP2 = Platform.RP2 +# Deprecated: use PLATFORM_RP2 instead. Kept as a back-compat alias; +# scheduled for removal in 2027.7.0. PLATFORM_RP2040 = Platform.RP2040 PLATFORM_RTL87XX = Platform.RTL87XX diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 89ce27a8b9..803ddba6b7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -25,7 +25,7 @@ from esphome.const import ( PLATFORM_HOST, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, Toolchain, ) @@ -52,6 +52,11 @@ _LOGGER = logging.getLogger(__name__) # Key for tracking controller count in CORE.data for ControllerRegistry StaticVector sizing KEY_CONTROLLER_REGISTRY_COUNT = "controller_registry_count" +# CORE.data key for the "is_rp2040 deprecation warning already fired this +# run" flag. Mirrors the ``cv.only_on_rp2040`` dedupe pattern; cleared +# between runs so each fresh invocation warns once. +_IS_RP2040_DEPRECATED_KEY = "_core_is_rp2040_deprecated_warned" + class EsphomeError(Exception): """General ESPHome exception occurred.""" @@ -830,9 +835,38 @@ class EsphomeCore: def is_esp32(self): return self.target_platform == PLATFORM_ESP32 + @property + def is_rp2(self): + """Return True if the target platform is the RP2 chip family. + + Canonical umbrella check covering RP2040, RP2350, and any future + RP2-series chip. Mirrors :attr:`is_esp32` for the ESP32 family. + For variant-specific gating (RP2040 vs RP2350), use + ``rp2.get_rp2040_variant()`` or ``rp2.only_on_variant(...)`` from + the rp2 component — variant detection doesn't belong on ``CORE``. + """ + return self.target_platform == PLATFORM_RP2 + @property def is_rp2040(self): - return self.target_platform == PLATFORM_RP2040 + """Deprecated: use :attr:`is_rp2` for the family check, or + ``rp2.get_rp2040_variant() == rp2.VARIANT_RP2040`` for the + variant-specific check. Kept as an alias since pre-RP2350 + callers used it as a family check, identical to ``is_rp2``. + + Scheduled for removal in 2027.7.0. Logs a one-shot deprecation + warning per run (deduped via ``self.data`` so repeated reads in + the same invocation don't spam) to match the parallel + ``cv.only_on_rp2040`` shim. + """ + if not self.data.get(_IS_RP2040_DEPRECATED_KEY): + _LOGGER.warning( + "CORE.is_rp2040 is deprecated; use CORE.is_rp2 for the family " + "gate, or rp2.get_rp2040_variant() == rp2.VARIANT_RP2040 for " + "the variant-specific check. Removed in 2027.7.0." + ) + self.data[_IS_RP2040_DEPRECATED_KEY] = True + return self.is_rp2 @property def is_bk72xx(self): diff --git a/esphome/core/config.py b/esphome/core/config.py index ebad5cf165..5b95ac3a50 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -868,8 +868,8 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wake/wake_esp8266.cpp": { PlatformFramework.ESP8266_ARDUINO, }, - "wake/wake_rp2040.cpp": { - PlatformFramework.RP2040_ARDUINO, + "wake/wake_rp2.cpp": { + PlatformFramework.RP2_ARDUINO, }, "wake/wake_host.cpp": { PlatformFramework.HOST_NATIVE, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 987e2d7a2a..3e8b0829c5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -19,13 +19,13 @@ // Threading model for static analysis. Match what the real codegen picks per // platform (see esphome/components//__init__.py ThreadModel.*): -// USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE +// USE_ESP8266 / USE_RP2 / USE_NRF52 → SINGLE // USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS // everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS // Without this the clang-tidy envs end up with USE_ // + MULTI_ATOMICS simultaneously, a combination that can never occur in a // real build. -#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_NRF52) +#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_NRF52) #define ESPHOME_THREAD_SINGLE #elif defined(USE_BK72XX) #define ESPHOME_THREAD_MULTI_NO_ATOMICS @@ -227,7 +227,7 @@ #endif // Platforms with native 64-bit time sources (no rollover tracking needed) -#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) || defined(USE_RP2040) +#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) || defined(USE_RP2) #define USE_NATIVE_64BIT_TIME #endif @@ -405,9 +405,12 @@ #define USE_WEBSERVER_PORT 80 // NOLINT #endif -#ifdef USE_RP2040 +// USE_RP2 is the canonical platform define for the RP2 chip family. The +// rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias +// for external custom components that may still test for it. +#ifdef USE_RP2 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) -#define USE_RP2040_CRASH_HANDLER +#define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC diff --git a/esphome/core/hal.h b/esphome/core/hal.h index b44a422836..4c5a19c6d1 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -19,8 +19,8 @@ #include "esphome/components/esp8266/hal.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/hal.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/hal.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/hal.h" #elif defined(USE_HOST) #include "esphome/components/host/hal.h" #elif defined(USE_ZEPHYR) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a212019628..f39b5aa4d0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -33,7 +33,7 @@ #include #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #endif @@ -1895,7 +1895,7 @@ class Mutex { Mutex(const Mutex &) = delete; Mutex &operator=(const Mutex &) = delete; -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // Single-threaded platforms: inline no-ops so the compiler eliminates all call overhead. Mutex() = default; ~Mutex() = default; @@ -1964,7 +1964,7 @@ class InterruptLock { ~InterruptLock(); protected: -#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) uint32_t state_; #endif }; @@ -1982,7 +1982,7 @@ class LwIPLock { LwIPLock(const LwIPLock &) = delete; LwIPLock &operator=(const LwIPLock &) = delete; -#if defined(USE_ESP32) || defined(USE_RP2040) +#if defined(USE_ESP32) || defined(USE_RP2) // Platforms with potential lwIP core locking — out-of-line implementations in helpers.cpp LwIPLock(); ~LwIPLock(); @@ -2132,7 +2132,7 @@ template class RAMAllocator { auto max_external = this->flags_ & ALLOC_EXTERNAL ? heap_caps_get_free_size(MALLOC_CAP_8BIT | MALLOC_CAP_SPIRAM) : 0; return max_internal + max_external; -#elif defined(USE_RP2040) +#elif defined(USE_RP2) return ::rp2040.getFreeHeap(); #elif defined(USE_LIBRETINY) return lt_heap_get_free(); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 431de205af..34bf84409d 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -12,8 +12,8 @@ #include "esphome/components/esp32/preference_backend.h" #elif defined(USE_ESP8266) #include "esphome/components/esp8266/preference_backend.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/preference_backend.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/preference_backend.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/preference_backend.h" #elif defined(USE_HOST) @@ -24,7 +24,7 @@ namespace esphome { -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ !defined(USE_HOST) && !(defined(USE_ZEPHYR) && defined(CONFIG_SETTINGS)) // Stub for static analysis when no platform is defined. struct PreferenceBackend { diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index 64a0a927e6..1efce5af51 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -9,8 +9,8 @@ #include "esphome/components/esp32/preferences.h" #elif defined(USE_ESP8266) #include "esphome/components/esp8266/preferences.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/preferences.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/preferences.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/preferences.h" #elif defined(USE_HOST) diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 5a5d27ceff..a48e52fb73 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -18,7 +18,7 @@ namespace esphome { // === Wake flag for ESP8266/RP2040 === -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern volatile bool g_main_loop_woke; #endif @@ -65,8 +65,8 @@ __attribute__((always_inline)) inline bool wake_request_take() { #include "esphome/core/wake/wake_freertos.h" #elif defined(USE_ESP8266) #include "esphome/core/wake/wake_esp8266.h" -#elif defined(USE_RP2040) -#include "esphome/core/wake/wake_rp2040.h" +#elif defined(USE_RP2) +#include "esphome/core/wake/wake_rp2.h" #elif defined(USE_HOST) #include "esphome/core/wake/wake_host.h" #elif defined(USE_ZEPHYR) diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2.cpp similarity index 97% rename from esphome/core/wake/wake_rp2040.cpp rename to esphome/core/wake/wake_rp2.cpp index bdcbb1ad00..101c87c818 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2.cpp @@ -1,6 +1,6 @@ #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" #include "esphome/core/wake.h" @@ -59,4 +59,4 @@ void wakeable_delay(uint32_t ms) { } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/core/wake/wake_rp2040.h b/esphome/core/wake/wake_rp2.h similarity index 88% rename from esphome/core/wake/wake_rp2040.h rename to esphome/core/wake/wake_rp2.h index ea1242f535..715e5aca0c 100644 --- a/esphome/core/wake/wake_rp2040.h +++ b/esphome/core/wake/wake_rp2.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" @@ -21,11 +21,11 @@ inline void wake_loop_any_context() { inline void wake_loop_threadsafe() { wake_loop_any_context(); } -/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp. +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2.cpp. namespace internal { void wakeable_delay(uint32_t ms); } // namespace internal } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 9d662df8f8..6376e573c4 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -133,7 +133,7 @@ class StorageJSON: self.no_mdns = no_mdns # The framework used to compile the firmware self.framework = framework - # The core platform of this firmware. Like "esp32", "rp2040", "host" etc. + # The core platform of this firmware. Like "esp32", "rp2", "host" etc. self.core_platform = core_platform # The toolchain used for the build ("platformio" / "esp-idf") self.toolchain = toolchain diff --git a/esphome/wizard.py b/esphome/wizard.py index f83342cc6a..f7706928e9 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -75,8 +75,8 @@ esp32: type: esp-idf """ -RP2040_CONFIG = """ -rp2040: +RP2_CONFIG = """ +rp2: board: {board} """ @@ -98,7 +98,7 @@ rtl87xx: HARDWARE_BASE_CONFIGS = { "ESP8266": ESP8266_CONFIG, "ESP32": ESP32_CONFIG, - "RP2040": RP2040_CONFIG, + "RP2": RP2_CONFIG, "BK72XX": BK72XX_CONFIG, "LN882X": LN882X_CONFIG, "RTL87XX": RTL87XX_CONFIG, @@ -113,7 +113,7 @@ class WizardFileKwargs(TypedDict): """Keyword arguments for wizard_file function.""" name: str - platform: Literal["ESP8266", "ESP32", "RP2040", "BK72XX", "LN882X", "RTL87XX"] + platform: Literal["ESP8266", "ESP32", "RP2", "BK72XX", "LN882X", "RTL87XX"] board: str ssid: NotRequired[str] psk: NotRequired[str] @@ -213,7 +213,7 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards from esphome.components.ln882x import boards as ln882x_boards - from esphome.components.rp2040 import boards as rp2040_boards + from esphome.components.rp2 import boards as rp2_boards from esphome.components.rtl87xx import boards as rtl87xx_boards name = kwargs["name"] @@ -235,8 +235,8 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: platform = "ESP8266" elif board in esp32_boards.BOARDS: platform = "ESP32" - elif board in rp2040_boards.BOARDS: - platform = "RP2040" + elif board in rp2_boards.BOARDS: + platform = "RP2" elif board in bk72xx_boards.BOARDS: platform = "BK72XX" elif board in ln882x_boards.BOARDS: @@ -301,7 +301,7 @@ def wizard(path: Path) -> int: from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards from esphome.components.ln882x import boards as ln882x_boards - from esphome.components.rp2040 import boards as rp2040_boards + from esphome.components.rp2 import boards as rp2_boards from esphome.components.rtl87xx import boards as rtl87xx_boards if path.suffix not in (".yaml", ".yml"): @@ -373,7 +373,7 @@ def wizard(path: Path) -> int: "firmwares for it." ) - wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2040"] + wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2"] safe_print( "Please choose one of the supported microcontrollers " "(Use ESP8266 for Sonoff devices)." @@ -405,7 +405,7 @@ def wizard(path: Path) -> int: board_link = ( "https://docs.platformio.org/en/latest/platforms/espressif8266.html#boards" ) - elif platform == "RP2040": + elif platform == "RP2": board_link = "https://www.raspberrypi.com/documentation/microcontrollers/silicon.html#rp2040" elif platform in ["BK72XX", "LN882X", "RTL87XX"]: board_link = "https://docs.libretiny.eu/docs/status/supported/" @@ -421,27 +421,21 @@ def wizard(path: Path) -> int: safe_print(f"(Type {color(AnsiFore.GREEN, 'esp01_1m')} for Sonoff devices)") safe_print() # Don't sleep because user needs to copy link - if platform == "ESP32": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "nodemcu-32s")}".') - boards_list = esp32_boards.BOARDS.items() - elif platform == "ESP8266": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "nodemcuv2")}".') - boards_list = esp8266_boards.BOARDS.items() - elif platform == "BK72XX": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "cb2s")}".') - boards_list = bk72xx_boards.BOARDS.items() - elif platform == "LN882X": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wl2s")}".') - boards_list = ln882x_boards.BOARDS.items() - elif platform == "RTL87XX": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wr3")}".') - boards_list = rtl87xx_boards.BOARDS.items() - elif platform == "RP2040": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "rpipicow")}".') - boards_list = rp2040_boards.BOARDS.items() - - else: - raise NotImplementedError("Unknown platform!") + # Platform-to-(example board, boards module) lookup. Dict-driven so the + # set of supported platforms has a single source of truth and the elif + # chain — which left the last entry's "False" branch structurally + # unreachable in tests — is gone. + example_boards = { + "ESP32": ("nodemcu-32s", esp32_boards), + "ESP8266": ("nodemcuv2", esp8266_boards), + "BK72XX": ("cb2s", bk72xx_boards), + "LN882X": ("wl2s", ln882x_boards), + "RTL87XX": ("wr3", rtl87xx_boards), + "RP2": ("rpipicow", rp2_boards), + } + example, boards_module = example_boards[platform] + safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, example)}".') + boards_list = boards_module.BOARDS.items() boards = [] safe_print("Options:") diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 974957245a..bc97a0d603 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -785,6 +785,29 @@ def build_schema(): # bundle core inside esphome data["esphome"]["core"] = data.pop("core")["core"] + # Surface deprecated component aliases (declared via ``ALIASES = [...]`` + # on the canonical component) so language servers / dashboard + # autocomplete still accept legacy top-level keys instead of flagging + # them as unknown. Each alias gets its own bundle that mirrors the + # canonical schema; ``alias_of`` and the optional ``removal_version`` + # metadata let consumers render a deprecation hint and point users at + # the canonical name. Without this, configs migrated only at runtime + # (via the ``_resolve_component_aliases`` pre-pass) would still light + # up as errors in the editor. + for domain, manifest in components.items(): + aliases = manifest.aliases + if not aliases or domain not in data: + continue + canonical_bundle = data[domain].get(domain) + if canonical_bundle is None: + continue + for alias in aliases: + alias_entry = dict(canonical_bundle) + alias_entry["alias_of"] = domain + if manifest.alias_removal_version is not None: + alias_entry["removal_version"] = manifest.alias_removal_version + data[alias] = {alias: alias_entry} + if GENERATED_ID_TYPES: print( "Unconsumed id_type matchers:", diff --git a/script/ci-custom.py b/script/ci-custom.py index 75f4d71ba4..4b16734ebe 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -621,6 +621,9 @@ def convert_path_to_relative(abspath, current): "esphome/components/web_server/__init__.py", # const.py has absolute import in docstring example for external components "esphome/components/esp8266/const.py", + # rp2040/__init__.py is the deprecation shim that documents the canonical + # rp2 module path and its own legacy import paths in docstrings/comments. + "esphome/components/rp2040/__init__.py", ], ) def lint_relative_py_import(fname: Path, line, col, content): @@ -650,13 +653,13 @@ def lint_relative_py_import(fname: Path, line, col, content): "esphome/components/async_tcp/async_tcp.h", "esphome/components/esp32/core.cpp", "esphome/components/esp8266/core.cpp", - "esphome/components/rp2040/core.cpp", + "esphome/components/rp2/core.cpp", "esphome/components/libretiny/core.cpp", "esphome/components/host/core.cpp", "esphome/components/zephyr/core.cpp", "esphome/components/esp32/helpers.cpp", "esphome/components/esp8266/helpers.cpp", - "esphome/components/rp2040/helpers.cpp", + "esphome/components/rp2/helpers.cpp", "esphome/components/libretiny/helpers.cpp", "esphome/components/host/helpers.cpp", "esphome/components/zephyr/helpers.cpp", diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 756f3884b8..061485c76c 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -160,7 +160,8 @@ class Platform(StrEnum): BK72XX_ARD = "bk72xx-ard" # LibreTiny BK7231N RTL87XX_ARD = "rtl87xx-ard" # LibreTiny RTL8720x LN882X_ARD = "ln882x-ard" # LibreTiny LN882x - RP2040_ARD = "rp2040-ard" # Raspberry Pi Pico + RP2040_ARD = "rp2040-ard" # RP2 family, RP2040 chip (Pico / Pico W) + RP2350_ARD = "rp2350-ard" # RP2 family, RP2350 chip (Pico 2 / Pico 2 W) NRF52_ZEPHYR = "nrf52-adafruit" # Nordic nRF52 (Zephyr) @@ -190,7 +191,8 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [ Platform.BK72XX_ARD, # LibreTiny BK7231N Platform.RTL87XX_ARD, # LibreTiny RTL8720x Platform.LN882X_ARD, # LibreTiny LN882x - Platform.RP2040_ARD, # Raspberry Pi Pico + Platform.RP2040_ARD, # Raspberry Pi Pico (RP2040) + Platform.RP2350_ARD, # Raspberry Pi Pico 2 (RP2350) Platform.NRF52_ZEPHYR, # Nordic nRF52 (Zephyr) ] @@ -859,7 +861,8 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: - *_libretiny.cpp, *_bk72*.* -> BK72XX (LibreTiny) - *_rtl87*.* -> RTL87XX (LibreTiny Realtek) - *_ln882*.* -> LN882X (LibreTiny Lightning) - - *_pico.cpp, *_rp2040.* -> RP2040_ARD + - *_rp2350*.*, *_pico2*.* -> RP2350_ARD (RP2 family, RP2350 chip) + - *_rp2040*.*, *_pico*.* -> RP2040_ARD (RP2 family, RP2040 chip) Args: filename: File path to check @@ -901,8 +904,14 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: if "libretiny" in filename_lower or "bk72" in filename_lower: return Platform.BK72XX_ARD - # RP2040 / Raspberry Pi Pico - if "pico" in filename_lower or "rp2040" in filename_lower: + # RP2 family (Raspberry Pi Pico): explicit chip names only. Family- + # wide files (named ``_rp2.*``) are shared between RP2040 and RP2350 + # and intentionally don't preferentially route to either chip. + # Check the RP2350 patterns first since ``pico2`` substring-matches + # ``pico``. + if "rp2350" in filename_lower or "pico2" in filename_lower: + return Platform.RP2350_ARD + if "rp2040" in filename_lower or "pico" in filename_lower: return Platform.RP2040_ARD # nRF52 / Zephyr diff --git a/script/generate-rp2040-boards.py b/script/generate-rp2-boards.py similarity index 77% rename from script/generate-rp2040-boards.py rename to script/generate-rp2-boards.py index 1b4846fd2b..94a5cc018a 100755 --- a/script/generate-rp2040-boards.py +++ b/script/generate-rp2-boards.py @@ -8,14 +8,14 @@ import subprocess import sys import tempfile -from esphome.components.rp2040 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION -from esphome.components.rp2040.generate_boards import generate +from esphome.components.rp2 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION +from esphome.components.rp2.generate_boards import generate from esphome.helpers import write_file_if_changed ver = RECOMMENDED_ARDUINO_FRAMEWORK_VERSION version_tag: str = f"{ver.major}.{ver.minor}.{ver.patch}" root: Path = Path(__file__).parent.parent -boards_file_path: Path = root / "esphome" / "components" / "rp2040" / "boards.py" +boards_file_path: Path = root / "esphome" / "components" / "rp2" / "boards.py" def main(check: bool) -> None: @@ -42,10 +42,10 @@ def main(check: bool) -> None: if check: existing_content: str = boards_file_path.read_text(encoding="utf-8") if existing_content != content: - print("esphome/components/rp2040/boards.py is not up to date.") - print("Please run `script/generate-rp2040-boards.py`") + print("esphome/components/rp2/boards.py is not up to date.") + print("Please run `script/generate-rp2-boards.py`") sys.exit(1) - print("esphome/components/rp2040/boards.py is up to date") + print("esphome/components/rp2/boards.py is up to date") elif write_file_if_changed(boards_file_path, content): print("RP2040 boards updated successfully.") diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2350-ard.yaml similarity index 100% rename from tests/components/adc/test.rp2040-pico2-ard.yaml rename to tests/components/adc/test.rp2350-ard.yaml diff --git a/tests/components/rp2040/test.rp2040-ard.yaml b/tests/components/rp2/test.rp2040-ard.yaml similarity index 97% rename from tests/components/rp2040/test.rp2040-ard.yaml rename to tests/components/rp2/test.rp2040-ard.yaml index 09531f914e..eaa494a01a 100644 --- a/tests/components/rp2040/test.rp2040-ard.yaml +++ b/tests/components/rp2/test.rp2040-ard.yaml @@ -1,4 +1,4 @@ -rp2040: +rp2: variant: rp2040 enable_full_printf: false diff --git a/tests/components/rp2040/test.rp2040-pico2-ard.yaml b/tests/components/rp2/test.rp2350-ard.yaml similarity index 90% rename from tests/components/rp2040/test.rp2040-pico2-ard.yaml rename to tests/components/rp2/test.rp2350-ard.yaml index c9d795840d..84ee39a81e 100644 --- a/tests/components/rp2040/test.rp2040-pico2-ard.yaml +++ b/tests/components/rp2/test.rp2350-ard.yaml @@ -1,4 +1,4 @@ -rp2040: +rp2: variant: rp2350 enable_full_printf: false diff --git a/tests/components/spi/test.rp2040-pico2-ard.yaml b/tests/components/spi/test.rp2350-ard.yaml similarity index 100% rename from tests/components/spi/test.rp2040-pico2-ard.yaml rename to tests/components/spi/test.rp2350-ard.yaml diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 2f038155c0..d018c6dbd0 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2225,15 +2225,33 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> "esphome/components/libretiny/wifi_ln882x.cpp", determine_jobs.Platform.LN882X_ARD, ), - # RP2040 / Raspberry Pi Pico detection + # RP2 family detection — explicit chip names only. + # RP2040 chip: _rp2040.*, _pico.* (Pico / Pico W) ("esphome/components/gpio/gpio_rp2040.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/wifi/wifi_rp2040.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/i2c/i2c_pico.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/spi/spi_pico.cpp", determine_jobs.Platform.RP2040_ARD), ( - "tests/components/rp2040/test.rp2040-ard.yaml", + "tests/components/rp2/test.rp2040-ard.yaml", determine_jobs.Platform.RP2040_ARD, ), + # RP2350 chip: _rp2350.*, _pico2.* (Pico 2 / Pico 2 W) + ( + "esphome/components/foo/foo_rp2350.cpp", + determine_jobs.Platform.RP2350_ARD, + ), + ( + "esphome/components/wifi/wifi_pico2.cpp", + determine_jobs.Platform.RP2350_ARD, + ), + ( + "tests/components/rp2/test.rp2350-ard.yaml", + determine_jobs.Platform.RP2350_ARD, + ), + # Family-wide files (_rp2.*) intentionally do NOT get a hint — + # they apply to both RP2040 and RP2350 chips. + ("esphome/components/debug/debug_rp2.cpp", None), + ("esphome/components/logger/logger_rp2.h", None), # nRF52 / Zephyr detection ( "tests/components/logger/test.nrf52-adafruit.yaml", @@ -2280,6 +2298,11 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> "pico_i2c", "pico_spi", "rp2040_test_yaml", + "rp2350_cpp", + "pico2_cpp", + "rp2350_test_yaml", + "rp2_family_debug_no_hint", + "rp2_family_logger_h_no_hint", "nrf52_test_yaml", "nrf52_gpio", "zephyr_core", diff --git a/tests/test_build_components/build_components_base.rp2040-ard.yaml b/tests/test_build_components/build_components_base.rp2040-ard.yaml index 4fb8d51333..4d26a38b69 100644 --- a/tests/test_build_components/build_components_base.rp2040-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2040-ard.yaml @@ -2,7 +2,7 @@ esphome: name: componenttestrp2040ard friendly_name: $component_name -rp2040: +rp2: board: rpipicow logger: diff --git a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml b/tests/test_build_components/build_components_base.rp2350-ard.yaml similarity index 97% rename from tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml rename to tests/test_build_components/build_components_base.rp2350-ard.yaml index 0922a5238e..5df1670862 100644 --- a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2350-ard.yaml @@ -2,7 +2,7 @@ esphome: name: componenttestrp2040pico2ard friendly_name: $component_name -rp2040: +rp2: board: rpipico2 logger: diff --git a/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml b/tests/test_build_components/common/spi/rp2350-ard.yaml similarity index 100% rename from tests/test_build_components/common/spi/rp2040-pico2-ard.yaml rename to tests/test_build_components/common/spi/rp2350-ard.yaml diff --git a/tests/unit_tests/components/test_rp2.py b/tests/unit_tests/components/test_rp2.py new file mode 100644 index 0000000000..023d926dc4 --- /dev/null +++ b/tests/unit_tests/components/test_rp2.py @@ -0,0 +1,95 @@ +"""Tests for the ``rp2`` target-platform component. + +``rp2`` is the canonical name for the Raspberry Pi RP-series target +platform. ``rp2040`` is a deprecated alias declared via +``ALIASES = ["rp2040"]`` on the rp2 component — the framework +(see ``esphome/loader.py`` and ``esphome/config.py``) handles both +Python-import aliasing (via a ``sys.meta_path`` finder) and YAML-key +aliasing (via a pre-pass in ``validate_config``), so there is no +hand-rolled shim in ``esphome/components/rp2040/``. + +These tests pin down the canonical board helpers; the alias contract +itself (Python imports, YAML key rename, deprecation warning) is covered +by the framework tests under ``tests/unit_tests/``. +""" + + +def test_board_id_has_wifi_for_known_wifi_board() -> None: + """``rpipicow`` is the canonical Pico W → True.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipicow") is True + + +def test_board_id_has_wifi_for_known_non_wifi_board() -> None: + """Plain ``rpipico`` has no CYW43 → False.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipico") is False + + +def test_board_id_has_wifi_for_rp2350_w_variant() -> None: + """``rpipico2w`` is the RP2350 Pico 2 W → True.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipico2w") is True + + +def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: + """Unknown ids fail open so a custom board is not rejected. + + The validator falls back to ESPHome's compile-time check; the + helper returning True here means the wizard emits a ``wifi:`` + block and any genuinely-unsupported config trips the existing + "no CYW43" guard at compile time. + """ + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("not-a-real-board-id") is True + + +def test_rp2_declares_rp2040_as_alias() -> None: + """The framework-level deprecation hook is on the ``rp2`` component. + + The legacy ``rp2040:`` YAML key works because the rp2 component + opts in via ``ALIASES``; without this declaration the rename + framework wouldn't route legacy configs. + """ + from esphome.components import rp2 + + assert "rp2040" in rp2.ALIASES + assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0" + + +def test_rp2040_python_import_resolves_to_rp2() -> None: + """``from esphome.components import rp2040`` must work for external + custom components and external tooling (device-builder, the dashboard + wizard, etc.) that still import from the legacy module path. + + The ``_AliasFinder`` on ``sys.meta_path`` rewrites the lookup to + the canonical module — both should be the same object. + """ + from esphome.components import ( + rp2, + rp2040, # routed via _AliasFinder + ) + + assert rp2040 is rp2 + + +def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None: + """Submodule imports (e.g. ``esphome.components.rp2040.boards``) must + also route to the canonical equivalents — the board-generator script + and the dashboard wizard both rely on this path. + """ + from esphome.components.rp2 import ( + boards as rp2_boards, + generate_boards as rp2_generate, + ) + from esphome.components.rp2040 import ( + boards as rp2040_boards, + generate_boards as rp2040_generate, + ) + + assert rp2040_boards is rp2_boards + assert rp2040_generate is rp2_generate diff --git a/tests/unit_tests/components/test_rp2040.py b/tests/unit_tests/components/test_rp2040.py deleted file mode 100644 index 8e726933ed..0000000000 --- a/tests/unit_tests/components/test_rp2040.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Tests for RP2040 component public helpers and variant detection.""" - -import pytest - -from esphome.components.rp2040 import _detect_variant, board_id_has_wifi -from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350 -import esphome.config_validation as cv -from esphome.const import CONF_BOARD, CONF_VARIANT - - -def test_board_id_has_wifi_for_known_wifi_board() -> None: - """``rpipicow`` is the canonical Pico W → True.""" - assert board_id_has_wifi("rpipicow") is True - - -def test_board_id_has_wifi_for_known_non_wifi_board() -> None: - """Plain ``rpipico`` has no CYW43 → False.""" - assert board_id_has_wifi("rpipico") is False - - -def test_board_id_has_wifi_for_rp2350_w_variant() -> None: - """``rpipico2w`` is the RP2350 Pico 2 W → True.""" - assert board_id_has_wifi("rpipico2w") is True - - -def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: - """Unknown ids fail open so a custom board is not rejected. - - The validator falls back to ESPHome's compile-time check; the - helper returning True here means the wizard emits a ``wifi:`` - block and any genuinely-unsupported config trips the existing - "no CYW43" guard at compile time. - """ - assert board_id_has_wifi("not-a-real-board-id") is True - - -def test_detect_variant_derives_variant_from_board() -> None: - """Board alone resolves to the matching variant.""" - result = _detect_variant({CONF_BOARD: "rpipicow"}) - assert result[CONF_BOARD] == "rpipicow" - assert result[CONF_VARIANT] == VARIANT_RP2040 - - -def test_detect_variant_derives_variant_from_rp2350_board() -> None: - """An RP2350 board resolves to ``RP2350``.""" - result = _detect_variant({CONF_BOARD: "rpipico2"}) - assert result[CONF_BOARD] == "rpipico2" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_only_picks_default_board_rp2040() -> None: - """Variant alone picks Pico W as the canonical RP2040 board.""" - result = _detect_variant({CONF_VARIANT: VARIANT_RP2040}) - assert result[CONF_BOARD] == "rpipicow" - assert result[CONF_VARIANT] == VARIANT_RP2040 - - -def test_detect_variant_only_picks_default_board_rp2350() -> None: - """Variant alone picks Pico 2 W as the canonical RP2350 board.""" - result = _detect_variant({CONF_VARIANT: VARIANT_RP2350}) - assert result[CONF_BOARD] == "rpipico2w" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_matching_explicit_variant_passes() -> None: - """Specifying both a board and the matching variant is allowed.""" - result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350}) - assert result[CONF_BOARD] == "rpipico2" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_mismatched_variant_raises() -> None: - """Board/variant mismatch must be rejected and name the offending board.""" - with pytest.raises( - cv.Invalid, match=r"does not match the selected board 'rpipicow'" - ): - _detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350}) - - -def test_detect_variant_unknown_board_without_variant_raises() -> None: - """Unknown board with no variant tells the user how to recover.""" - with pytest.raises(cv.Invalid, match="please specify the chip variant"): - _detect_variant({CONF_BOARD: "not-a-real-board"}) - - -def test_detect_variant_unknown_board_with_variant_passes() -> None: - """Unknown board + explicit variant is accepted (with a warning).""" - result = _detect_variant( - {CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040} - ) - assert result[CONF_BOARD] == "not-a-real-board" - assert result[CONF_VARIANT] == VARIANT_RP2040 diff --git a/tests/unit_tests/components/test_rp2040_generate_boards.py b/tests/unit_tests/components/test_rp2_generate_boards.py similarity index 98% rename from tests/unit_tests/components/test_rp2040_generate_boards.py rename to tests/unit_tests/components/test_rp2_generate_boards.py index 551e88f6f6..68bbada59b 100644 --- a/tests/unit_tests/components/test_rp2040_generate_boards.py +++ b/tests/unit_tests/components/test_rp2_generate_boards.py @@ -1,4 +1,4 @@ -"""Tests for rp2040 generate_boards.py.""" +"""Tests for rp2 generate_boards.py.""" from __future__ import annotations @@ -8,7 +8,7 @@ import textwrap import pytest -from esphome.components.rp2040.generate_boards import load_boards, parse_variant_pins +from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins PICO_PINS_HEADER = textwrap.dedent("""\ #pragma once diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 9598c1bdd8..3899b3d854 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -87,8 +87,8 @@ def test_has_native_wifi_esp32_variant_case_insensitive() -> None: def test_has_native_wifi_dispatches_rp2040_to_board_check() -> None: """RP2040 platform routes through ``rp2040.board_id_has_wifi``.""" - assert has_native_wifi(platform=Platform.RP2040, board="rpipicow") is True - assert has_native_wifi(platform=Platform.RP2040, board="rpipico") is False + assert has_native_wifi(platform=Platform.RP2, board="rpipicow") is True + assert has_native_wifi(platform=Platform.RP2, board="rpipico") is False def test_has_native_wifi_returns_false_for_nrf52() -> None: @@ -134,7 +134,7 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None: def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: """RP2040 without a board id falls open to True (custom-board default).""" - assert has_native_wifi(platform=Platform.RP2040) is True + assert has_native_wifi(platform=Platform.RP2) is True def _wifi_config( diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ea3a4ecb53..6580564c65 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -39,7 +39,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_HOST, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, SCHEDULER_DONT_RUN, TYPE_GIT, @@ -438,7 +438,7 @@ def hex_int__valid(value): ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"), ("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"), ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"), - ("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"), + ("arduino", PLATFORM_RP2, None, "20", "20", "20", "20"), ("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"), ("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"), ("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"), @@ -469,7 +469,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "esp32_c3": "11", "esp32_c6": "14", "esp32_h2": "17", - "rp2040": "20", + "rp2": "20", "bk72xx": "21", "rtl87xx": "22", "ln882x": "23", @@ -517,7 +517,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) ("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"), ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), ("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"), - ("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"), + ("arduino", PLATFORM_RP2, "RP2 using arduino framework"), ("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"), ("host", PLATFORM_HOST, "HOST using host framework"), ], @@ -540,7 +540,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(0, 5, 0), esp32_arduino=cv.Version(0, 5, 0), esp8266_arduino=cv.Version(0, 5, 0), - rp2040_arduino=cv.Version(0, 5, 0), + rp2_arduino=cv.Version(0, 5, 0), bk72xx_arduino=cv.Version(0, 5, 0), host=cv.Version(0, 5, 0), extra_message="test 1", @@ -556,7 +556,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(2, 0, 0), esp32_arduino=cv.Version(2, 0, 0), esp8266_arduino=cv.Version(2, 0, 0), - rp2040_arduino=cv.Version(2, 0, 0), + rp2_arduino=cv.Version(2, 0, 0), bk72xx_arduino=cv.Version(2, 0, 0), host=cv.Version(2, 0, 0), extra_message="test 2", @@ -567,7 +567,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(1, 5, 0), esp32_arduino=cv.Version(1, 5, 0), esp8266_arduino=cv.Version(1, 5, 0), - rp2040_arduino=cv.Version(1, 5, 0), + rp2_arduino=cv.Version(1, 5, 0), bk72xx_arduino=cv.Version(1, 5, 0), host=cv.Version(1, 5, 0), max_version=True, @@ -584,7 +584,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(0, 5, 0), esp32_arduino=cv.Version(0, 5, 0), esp8266_arduino=cv.Version(0, 5, 0), - rp2040_arduino=cv.Version(0, 5, 0), + rp2_arduino=cv.Version(0, 5, 0), bk72xx_arduino=cv.Version(0, 5, 0), host=cv.Version(0, 5, 0), max_version=True, @@ -599,6 +599,194 @@ def test_require_framework_version(framework, platform, message): )("test") +def _setup_core_for_framework(platform: str, framework: str) -> None: + """Wire CORE.data with the minimum keys for require_framework_version / + SplitDefault to evaluate without raising KeyError.""" + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + KEY_FRAMEWORK_VERSION: cv.Version(1, 0, 0), + } + + +def test_only_on_rp2_passes_on_rp2_platform() -> None: + """``cv.only_on_rp2`` is the canonical family gate. It accepts any value + untouched when the configured platform is rp2.""" + _setup_core_for_framework(PLATFORM_RP2, "arduino") + assert cv.only_on_rp2("anything") == "anything" + + +def test_only_on_rp2_rejects_other_platforms() -> None: + """The same gate raises ``Invalid`` outside the rp2 platform.""" + _setup_core_for_framework(PLATFORM_ESP32, "arduino") + with pytest.raises(Invalid, match="rp2"): + cv.only_on_rp2("anything") + + +def test_only_on_rp2040_delegates_and_warns_once(caplog) -> None: + """``cv.only_on_rp2040`` is a deprecation shim — it logs a one-shot + warning, dedupes via CORE.data, and delegates to ``only_on_rp2``. + Repeated calls in the same run must not log again.""" + import logging + + _setup_core_for_framework(PLATFORM_RP2, "arduino") + # Reset the dedupe flag so this test is independent of order. + CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None) + + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + assert cv.only_on_rp2040("ok") == "ok" + first_warnings = [r for r in caplog.records if "only_on_rp2040" in r.message] + assert len(first_warnings) == 1 + assert "2027.7.0" in first_warnings[0].message + + # Second call dedupes — no additional warning is emitted. + assert cv.only_on_rp2040("ok") == "ok" + warnings_after_second = [ + r for r in caplog.records if "only_on_rp2040" in r.message + ] + assert len(warnings_after_second) == 1 + + +def test_only_on_rp2040_still_gates_on_non_rp2(caplog) -> None: + """The deprecation shim must still raise on non-rp2 platforms — it + delegates to ``only_on_rp2``, so the gating behavior is preserved.""" + import logging + + _setup_core_for_framework(PLATFORM_ESP32, "arduino") + CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None) + + with ( + caplog.at_level(logging.WARNING, logger="esphome.config_validation"), + pytest.raises(Invalid, match="rp2"), + ): + cv.only_on_rp2040("anything") + + +def test_require_framework_version_esp32_variant_specific_key() -> None: + """ESP32 variant-specific kwargs (``esp32_c3_arduino``) must win over + the base ``esp32_arduino`` key when the configured variant matches.""" + from esphome.components.esp32 import KEY_ESP32 + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_ESP32, + KEY_TARGET_FRAMEWORK: "arduino", + KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0), + } + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32C3} + + # Variant-specific entry permits this version; base key would reject it. + assert ( + cv.require_framework_version( + esp32_arduino=cv.Version(5, 0, 0), # would reject + esp32_c3_arduino=cv.Version(1, 0, 0), # wins, ok + )("test") + == "test" + ) + + +def test_require_framework_version_rp2_variant_specific_key() -> None: + """RP2 variant kwargs (``rp2_2040_arduino``) must win over the base + ``rp2_arduino`` key when ``CORE.data['rp2']['variant']`` is wired.""" + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_RP2, + KEY_TARGET_FRAMEWORK: "arduino", + KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0), + } + CORE.data["rp2"] = {"variant": "RP2040"} + + # Variant key wins — base ``rp2_arduino`` (which would reject) is ignored. + assert ( + cv.require_framework_version( + rp2_arduino=cv.Version(5, 0, 0), # would reject + rp2_2040_arduino=cv.Version(1, 0, 0), # wins, ok + )("test") + == "test" + ) + + # Without a variant kwarg the base ``rp2_arduino`` is used (fallback). + CORE.data["rp2"] = {"variant": "RP2350"} + assert ( + cv.require_framework_version( + rp2_arduino=cv.Version(1, 0, 0), + )("test") + == "test" + ) + + +def test_split_default_rp2_variant_keys() -> None: + """``SplitDefault`` resolves ``rp2__`` first, falling + back to ``rp2_`` and ``rp2_`` before the base key.""" + from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_RP2, + KEY_TARGET_FRAMEWORK: "arduino", + } + CORE.data["rp2"] = {"variant": "RP2040"} + + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="variant-only", + rp2_2040_arduino="variant-framework", + ): str, + } + ) + # Most specific (variant + framework) wins. + assert schema({}).get("full") == "variant-framework" + + # Drop the most-specific kwarg → variant-only wins. + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="variant-only", + ): str, + } + ) + assert schema({}).get("full") == "variant-only" + + # RP2350 variant — no rp2_2350_* kwargs → fall through to base framework. + CORE.data["rp2"] = {"variant": "RP2350"} + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="not-this", + ): str, + } + ) + assert schema({}).get("full") == "base-framework" + + def test_only_with_single_component_loaded() -> None: """Test OnlyWith with single component when component is loaded.""" CORE.loaded_integrations = {"mqtt"} diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index a61b6ae7ae..0cb0c1f62d 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -591,6 +591,36 @@ class TestEsphomeCore: assert target.is_esp32 is False assert target.is_esp8266 is True + def test_is_rp2(self, target): + """The canonical RP2 family gate flips on for the rp2 platform.""" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"} + + assert target.is_rp2 is True + assert target.is_esp32 is False + assert target.is_esp8266 is False + + def test_is_rp2040_deprecated_alias_matches_is_rp2(self, target, caplog): + """``is_rp2040`` is kept as a deprecation shim that returns whatever + ``is_rp2`` returns; both must agree across platform values. A + one-shot deprecation warning is emitted on first access and + deduped via ``CORE.data`` for the rest of the run.""" + import logging + + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"} + with caplog.at_level(logging.WARNING, logger="esphome.core"): + assert target.is_rp2040 is True + assert target.is_rp2040 == target.is_rp2 + + warnings = [r for r in caplog.records if "is_rp2040" in r.message] + assert len(warnings) == 1 + assert "2027.7.0" in warnings[0].message + + # Reset the dedupe so the False-platform branch also runs the shim. + target.data.pop("_core_is_rp2040_deprecated_warned", None) + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"} + assert target.is_rp2040 is False + assert target.is_rp2040 == target.is_rp2 + def test_firmware_bin__default(self, target): """Default platforms produce //firmware.bin.""" target.name = "test-device" diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 42e5203a73..41dd462678 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -1,19 +1,13 @@ """Unit tests for esphome.loader module.""" import ast -import logging from pathlib import Path import sys import textwrap -from types import ModuleType -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch import pytest -import voluptuous as vol -from esphome import config as esphome_config, config_validation as cv -from esphome.core import CORE -import esphome.loader as loader_mod from esphome.loader import ( AliasMeta, ComponentManifest, @@ -21,6 +15,7 @@ from esphome.loader import ( _build_alias_map, _read_aliases, _replace_component_manifest, + get_alias_metadata, get_component, ) from tests.testing_helpers import ComponentManifestOverride @@ -348,17 +343,12 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub # Component aliases (renamed-platform back-compat) # --------------------------------------------------------------------------- # -# These tests pin down the substrate behind `ALIASES = [...]` on component -# `__init__.py` files: the AST scanner, the resulting global alias map, the -# Python-import `sys.meta_path` finder, the `get_component` integration, and -# the YAML pre-pass that rewrites legacy top-level keys. -# -# The framework is component-agnostic, so the integration tests inject a -# synthetic alias map (pointing a fake legacy name at the real `esp32` -# component) rather than depending on any specific renamed component. - -# A legacy name that is NOT a real component, used as a synthetic alias. -_FAKE_ALIAS = "esp32_legacy_alias" +# The framework here is the substrate behind `ALIASES = [...]` on component +# `__init__.py` files. These tests pin down the AST scanner, the resulting +# global alias map, the Python-import `sys.meta_path` finder, and the +# integration with `get_component`. The rp2 → rp2040 actual mapping in this +# repo is used as a real-world fixture; other cases use temp dirs / mocks so +# the framework's behavior is testable in isolation. def _write_component(root: Path, name: str, body: str) -> None: @@ -383,12 +373,12 @@ def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None: init.write_text( textwrap.dedent("""\ ALIASES = ['old'] - ALIAS_REMOVAL_VERSION = "2027.6.0" + ALIAS_REMOVAL_VERSION = "2027.7.0" """) ) aliases, removal = _read_aliases(init, ast) assert aliases == ["old"] - assert removal == "2027.6.0" + assert removal == "2027.7.0" def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None: @@ -409,28 +399,19 @@ def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> N assert removal is None -def test_read_aliases_handles_syntax_error( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: +def test_read_aliases_handles_syntax_error(tmp_path: Path) -> None: """A broken __init__.py shouldn't crash the alias scanner — it'll - surface as an ImportError elsewhere, but the scanner logs a warning and - yields nothing so other components keep working. The substring pre-filter - only skips files with no ``ALIASES`` token, so this file (which has one) - still reaches the parse.""" + surface as an ImportError elsewhere, but the scanner just yields + nothing so other components keep working. + + The source must contain the substring ``ALIASES`` so the scanner + actually attempts to parse the file; otherwise the early-return + optimization would short-circuit before reaching the parser and + this test would not exercise the syntax-error branch. + """ init = tmp_path / "__init__.py" - init.write_text("ALIASES = ['x']\ndef broken( :\n") + init.write_text("ALIASES = ['oops'\ndef broken( :\n") assert _read_aliases(init, ast) == ([], None) - assert "Could not parse" in caplog.text - - -def test_read_aliases_handles_read_error( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - """An unreadable __init__.py logs a warning and yields nothing rather - than aborting the whole component scan.""" - missing = tmp_path / "nope" / "__init__.py" - assert _read_aliases(missing, ast) == ([], None) - assert "Could not read" in caplog.text def test_build_alias_map_aggregates_components(tmp_path: Path) -> None: @@ -480,96 +461,64 @@ def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None: but possible in some test contexts), we want an empty map rather than a crash — the rest of the loader can still function.""" fake = tmp_path / "does-not-exist" + assert not fake.exists() with patch("esphome.loader.CORE_COMPONENTS_PATH", fake): alias_map, meta_map = _build_alias_map() assert alias_map == {} assert meta_map == {} -def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None: - """An alias that names an existing component package is refused: it would - hijack a live domain, and a self-alias (alias == canonical) would send - ``_lookup_module`` into infinite recursion.""" - # `newcomp` declares itself as an alias — its own package already exists. - _write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n") - - from esphome.core import EsphomeError - - with ( - patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), - pytest.raises(EsphomeError, match="shadows an existing component"), - ): - _build_alias_map() +# ---- Live integration against the real rp2/rp2040 mapping in this repo ---- -# ---- Integration against a synthetic alias map (fake legacy -> esp32) ---- +def test_real_alias_map_includes_rp2040() -> None: + """The rp2 component declares ``ALIASES = ['rp2040']`` in this repo; + the live alias map should surface it. This guards against future + refactors silently dropping the declaration.""" + meta = get_alias_metadata() + assert "rp2040" in meta + assert meta["rp2040"].canonical == "rp2" + assert meta["rp2040"].removal_version == "2027.7.0" -def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None: - """Force the loader's alias map (used by the finder and get_component). - - Patches the lazily-built caches so both ``_get_alias_map`` and the - installed meta-path finder resolve against ``mapping`` regardless of - what the real on-disk scan would produce. - """ - monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping) - - -def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None: - """``get_component()`` should return the canonical manifest — every +def test_get_component_resolves_alias() -> None: + """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits the canonical component without knowing about the alias.""" - import esphome.loader as loader_mod - - _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) - loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None) - - canonical = get_component("esp32") - aliased = get_component(_FAKE_ALIAS) - assert canonical is not None - assert aliased is canonical + rp2 = get_component("rp2") + rp2040 = get_component("rp2040") + assert rp2 is not None + assert rp2040 is rp2 -def test_alias_finder_resolves_top_level_import( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``import esphome.components.`` resolves to the canonical - module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" - _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) - sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) - +def test_alias_finder_resolves_top_level_import() -> None: + """``import esphome.components.rp2040`` resolves to the canonical + module via the meta-path finder.""" + # Remove any cached entry so we exercise the finder, not sys.modules cache. + sys.modules.pop("esphome.components.rp2040", None) finder = _AliasFinder() - spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None) + spec = finder.find_spec("esphome.components.rp2040", None) assert spec is not None - import esphome.components.esp32 - import esphome.components.esp32_legacy_alias + import esphome.components.rp2 + import esphome.components.rp2040 - assert esphome.components.esp32_legacy_alias is esphome.components.esp32 + assert esphome.components.rp2040 is esphome.components.rp2 -def test_alias_finder_resolves_submodule_import( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``from esphome.components. import boards`` routes through to - ``esphome.components.esp32.boards`` — same submodule object on both paths. - - The canonical submodule is imported first so its parent module carries - the ``boards`` attribute; ``from import boards`` then resolves - the aliased parent (via the finder) and reads that same attribute, - rather than triggering a fresh file load under the alias name. - ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" - _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) - sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) - +def test_alias_finder_resolves_submodule_import() -> None: + """``from esphome.components.rp2040 import boards`` routes through to + ``esphome.components.rp2.boards`` — same submodule object on both + paths.""" + sys.modules.pop("esphome.components.rp2040.boards", None) finder = _AliasFinder() - spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None) + spec = finder.find_spec("esphome.components.rp2040.boards", None) assert spec is not None - from esphome.components.esp32 import boards as canonical_boards - from esphome.components.esp32_legacy_alias import boards as aliased_boards + from esphome.components.rp2 import boards as rp2_boards + from esphome.components.rp2040 import boards as rp2040_boards - assert aliased_boards is canonical_boards + assert rp2040_boards is rp2_boards def test_alias_finder_ignores_non_components_path() -> None: @@ -581,9 +530,6 @@ def test_alias_finder_ignores_non_components_path() -> None: assert finder.find_spec("os.path", None) is None # `esphome.components` itself (no domain segment) is not a candidate. assert finder.find_spec("esphome.components", None) is None - # A real, non-aliased component domain defers to normal import machinery - # (no component declares an alias in this repo, so the live map is empty). - assert finder.find_spec("esphome.components.logger", None) is None # --------------------------------------------------------------------------- @@ -593,391 +539,121 @@ def test_alias_finder_ignores_non_components_path() -> None: # The companion to the loader-side alias map: ``esphome.config`` runs a # pre-pass over the user's parsed YAML that rewrites legacy top-level keys # to their canonical names, surfacing a one-shot deprecation warning. These -# tests inject a synthetic alias-metadata map so the rewrite behavior, the -# warning text, and the both-keys-present conflict can be tested in isolation. - - -def _patch_alias_metadata( - monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta] -) -> None: - monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping) +# tests pin down the rewrite behavior, the warning text, and the +# both-keys-present conflict. def test_resolve_component_aliases_renames_legacy_key( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + caplog: pytest.LogCaptureFixture, ) -> None: - """A legacy alias key should be renamed to the canonical key and a - deprecation warning citing the removal version logged.""" + """A legacy alias key ``rp2040:`` should be renamed to the canonical + ``rp2:`` and a deprecation warning citing the removal version logged.""" + import logging + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires - config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}} + config = {"esphome": {"name": "test"}, "rp2040": {"board": "rpipicow"}} with caplog.at_level(logging.WARNING, logger="esphome.config"): _resolve_component_aliases(config) - assert "oldcomp" not in config - assert config["newcomp"] == {"board": "x"} + assert "rp2040" not in config + assert config["rp2"] == {"board": "rpipicow"} assert any( - "'oldcomp:' top-level key is deprecated" in record.message - and "rename it to 'newcomp:'" in record.message - and "2027.6.0" in record.message + "'rp2040:' top-level key is deprecated" in record.message + and "rename it to 'rp2:'" in record.message + and "2027.7.0" in record.message for record in caplog.records ) def test_resolve_component_aliases_dedupes_warning_within_a_run( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + caplog: pytest.LogCaptureFixture, ) -> None: """Schema validators can run twice (auto-load discovery + final pass) so the rename pass must emit the warning only once per alias per run. Deduped via ``CORE.data``; cleared between runs.""" + import logging + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) with caplog.at_level(logging.WARNING, logger="esphome.config"): - _resolve_component_aliases({"oldcomp": {"board": "a"}}) - _resolve_component_aliases({"oldcomp": {"board": "b"}}) + _resolve_component_aliases({"rp2040": {"board": "rpipicow"}}) + _resolve_component_aliases({"rp2040": {"board": "rpipico2w"}}) matches = [ r for r in caplog.records - if "'oldcomp:' top-level key is deprecated" in r.message + if "'rp2040:' top-level key is deprecated" in r.message ] assert len(matches) == 1 -def test_resolve_component_aliases_rejects_both_keys_present( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_resolve_component_aliases_rejects_both_keys_present() -> None: """If the user has BOTH legacy and canonical keys, silently dropping one would hide a real misconfiguration. Raise instead.""" + import voluptuous as vol + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}} - with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + config = { + "rp2": {"board": "rpipicow"}, + "rp2040": {"board": "rpipicow"}, + } + with pytest.raises(vol.Invalid, match="Both 'rp2040:'"): _resolve_component_aliases(config) -def test_resolve_component_aliases_rejects_canonical_key_after_legacy( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The both-keys conflict must be detected even when the canonical key - appears *after* the legacy key in the config (the up-front conflict - scan, not a position-dependent check).""" - from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases - from esphome.core import CORE - - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) - CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}} - with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): - _resolve_component_aliases(config) - - -def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Two different deprecated aliases of the same canonical component is - ambiguous — silently keeping one would hide a misconfiguration.""" - from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases - from esphome.core import CORE - - _patch_alias_metadata( - monkeypatch, - { - "oldcomp": AliasMeta(canonical="newcomp", removal_version=None), - "legacycomp": AliasMeta(canonical="newcomp", removal_version=None), - }, - ) - CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}} - with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"): - _resolve_component_aliases(config) - - -def test_resolve_component_aliases_preserves_key_position( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The renamed canonical key keeps the legacy key's original position - rather than being moved to the end of the config.""" - from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases - from esphome.core import CORE - - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) - CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}} - - _resolve_component_aliases(config) - - assert list(config) == ["esphome", "newcomp", "logger"] - - -def test_resolve_component_aliases_no_op_when_no_legacy_keys( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: +def test_resolve_component_aliases_no_op_when_no_legacy_keys() -> None: """The pre-pass must be a no-op (no warning, no mutation) for configs that already use canonical keys.""" + import logging + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}} + config = {"esphome": {"name": "test"}, "rp2": {"board": "rpipicow"}} original = dict(config) - with caplog.at_level(logging.WARNING, logger="esphome.config"): + with caplog_at_warning() as records: _resolve_component_aliases(config) assert config == original - assert not any("deprecated" in r.message for r in caplog.records) + assert not any("deprecated" in r.message for r in records) + _ = logging # silence unused-import in branches that don't read records -# --------------------------------------------------------------------------- -# ComponentManifest alias properties -# --------------------------------------------------------------------------- +# Helper context manager — small enough to inline rather than pull in +# caplog for the simple "did anything warn?" case above. +import contextlib # noqa: E402 -def test_component_manifest_alias_properties_default_empty() -> None: - """``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None`` - when the component module declares neither. +@contextlib.contextmanager +def caplog_at_warning(): + """Minimal in-test caplog substitute: collect WARNING records on a + dedicated handler attached to ``esphome.config``.""" + import logging - Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the - ``getattr(..., default)`` fallback is actually exercised — a bare mock - auto-creates any attribute on access and would never hit the default.""" - mod = ModuleType("fake_component") - manifest = ComponentManifest(mod) - assert manifest.aliases == [] - assert manifest.alias_removal_version is None + logger = logging.getLogger("esphome.config") + records: list[logging.LogRecord] = [] + class _Handler(logging.Handler): + def emit(self, record): # noqa: D401 + records.append(record) -def test_component_manifest_alias_properties_read_module_values() -> None: - """The properties surface the module's declared values verbatim.""" - mod = MagicMock() - mod.ALIASES = ["legacy"] - mod.ALIAS_REMOVAL_VERSION = "2027.6.0" - manifest = ComponentManifest(mod) - assert manifest.aliases == ["legacy"] - assert manifest.alias_removal_version == "2027.6.0" - - -# --------------------------------------------------------------------------- -# Real (unpatched) lazy build + cache and remaining scanner branches -# --------------------------------------------------------------------------- - - -def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None: - """Exercise the real lazy build over the actual components dir (no patch): - the first call scans and caches, the second returns the cached object.""" - monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None) - first = loader_mod._get_alias_map() - second = loader_mod._get_alias_map() - assert isinstance(first, dict) - assert first is second # cached, not rebuilt on the second call - - -def test_get_alias_metadata_real_build_and_caches( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None) - first = loader_mod.get_alias_metadata() - second = loader_mod.get_alias_metadata() - assert isinstance(first, dict) - assert first is second - - -def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None: - """Loose files and directories without an ``__init__.py`` are ignored; - only real component packages contribute to the map.""" - (tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n") - (tmp_path / "initless").mkdir() # a dir, but no __init__.py - _write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n") - - with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): - alias_map, _ = _build_alias_map() - - assert alias_map == {"legacy": "realcomp"} - - -def test_read_aliases_ignores_non_assignment_and_complex_targets( - tmp_path: Path, -) -> None: - """Non-assignment statements and assignments to non-Name targets are - skipped; only simple ``NAME = ...`` assignments are read.""" - init = tmp_path / "__init__.py" - init.write_text( - "import os\n" # non-Assign (Import) node -> skipped - "obj.attr = 'v'\n" # Assign with an Attribute target -> skipped - "ALIASES = ['legacy']\n" - ) - aliases, _ = _read_aliases(init, ast) - assert aliases == ["legacy"] - - -# --------------------------------------------------------------------------- -# Finder / loader edge branches -# --------------------------------------------------------------------------- - - -def test_alias_finder_returns_none_when_canonical_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If an alias points at a canonical *target* that doesn't exist, the - finder declines (returns None) and lets normal import machinery report - the missing module.""" - _patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"}) - finder = _AliasFinder() - assert finder.find_spec("esphome.components.broken_alias", None) is None - - -def test_alias_finder_reraises_when_canonical_dependency_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If the canonical module exists but fails to import one of its own - dependencies, the finder surfaces that real error instead of masking it - as an unresolved alias (which would silently fall through to a confusing - 'no module named ').""" - _patch_alias_map(monkeypatch, {"some_alias": "real_canonical"}) - - def boom(name: str) -> None: - raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep") - - monkeypatch.setattr("esphome.loader.importlib.import_module", boom) - finder = _AliasFinder() - with pytest.raises(ModuleNotFoundError, match="missing_dep"): - finder.find_spec("esphome.components.some_alias", None) - - -def test_install_alias_finder_is_idempotent() -> None: - """The finder is installed once at import; calling the installer again is - a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``).""" - before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] - assert len(before) == 1 # installed at module import time - loader_mod._install_alias_finder() - after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] - assert len(after) == 1 - - -def test_get_component_alias_to_missing_canonical_returns_none( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If an alias resolves to a canonical component that can't be loaded, - ``get_component`` returns None and caches no bogus manifest.""" - _patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"}) - loader_mod._COMPONENT_CACHE.pop("ghost_alias", None) - - assert get_component("ghost_alias") is None - assert "ghost_alias" not in loader_mod._COMPONENT_CACHE - - -# --------------------------------------------------------------------------- -# YAML pre-pass: empty-map fast path + validate_config integration -# --------------------------------------------------------------------------- - - -def test_resolve_component_aliases_noop_when_no_aliases_declared( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """When no component declares an alias, the pre-pass returns immediately - without inspecting or mutating the config.""" - from esphome.config import _resolve_component_aliases - - monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map - config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}} - original = dict(config) - _resolve_component_aliases(config) - assert config == original - - -def _default_component_mock() -> Mock: - """A permissive component mock that validates any config (ALLOW_EXTRA).""" - return Mock( - auto_load=[], - is_platform_component=False, - is_platform=False, - multi_conf=False, - multi_conf_no_default=False, - dependencies=[], - conflicts_with=[], - config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA), - ) - - -@pytest.mark.usefixtures("setup_core") -def test_validate_config_renames_alias_key( - mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch -) -> None: - """End-to-end: a legacy top-level key is renamed to its canonical name - before the rest of ``validate_config`` runs, and validation succeeds. - - A real ``esp32`` target platform is included so ``preload_core_config`` - is satisfied and validation runs to completion (the renamed canonical - key is loaded via the mocked, permissive component).""" - mock_get_component.side_effect = lambda name: _default_component_mock() - monkeypatch.setattr( - "esphome.loader.get_alias_metadata", - lambda: { - "legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0") - }, - ) - CORE.data.pop("_component_aliases_warned", None) - - raw_config = { - "esphome": {"name": "test"}, - "esp32": {"board": "esp32dev"}, - "legacyfoo": {"opt": 1}, - } - result = esphome_config.validate_config(raw_config, {}) - - assert not result.errors, f"unexpected errors: {result.errors}" - assert "newcomp" in result - assert "legacyfoo" not in result - - -@pytest.mark.usefixtures("setup_core") -def test_validate_config_reports_alias_conflict_as_error( - mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch -) -> None: - """If both the legacy and canonical keys are present, ``validate_config`` - surfaces the conflict as a config error (the ``vol.Invalid`` path).""" - mock_get_component.return_value = _default_component_mock() - monkeypatch.setattr( - "esphome.loader.get_alias_metadata", - lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)}, - ) - CORE.data.pop("_component_aliases_warned", None) - - raw_config = { - "esphome": {"name": "test"}, - "newcomp": {"opt": 1}, - "legacyfoo": {"opt": 2}, - } - result = esphome_config.validate_config(raw_config, {}) - - assert result.errors - assert "Both 'legacyfoo:'" in str(result.errors) + handler = _Handler(level=logging.WARNING) + logger.addHandler(handler) + prev_level = logger.level + logger.setLevel(logging.WARNING) + try: + yield records + finally: + logger.removeHandler(handler) + logger.setLevel(prev_level) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 65bf4a583e..0442c1db16 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -94,7 +94,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, Toolchain, ) from esphome.core import CORE, EsphomeError @@ -1226,7 +1226,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( mock_choose_prompt: Mock, ) -> None: """Test interactive mode shows RP2040 BOOTSEL option via picotool.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1249,7 +1249,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None: """Test BOOTSEL instructions shown when no RP2040 device found.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1271,7 +1271,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota( ) -> None: """Test BOOTSEL tip shown when only OTA options exist for RP2040.""" setup_core( - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100", ) @@ -1300,7 +1300,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports( mock_choose_prompt: Mock, ) -> None: """Test BOOTSEL tip shown when serial ports exist but no BOOTSEL device.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")] with ( @@ -1325,7 +1325,7 @@ def test_choose_upload_log_host_rp2040_permission_error_no_options( caplog: pytest.LogCaptureFixture, ) -> None: """Test permission warning shown when BOOTSEL device found but not accessible.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1355,7 +1355,7 @@ def test_choose_upload_log_host_rp2040_permission_error_with_ota( ) -> None: """Test permission warning shown with OTA fallback available.""" setup_core( - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100", ) @@ -1412,7 +1412,7 @@ def test_choose_upload_log_host_rp2040_serial_and_bootsel( mock_choose_prompt: Mock, ) -> None: """Test both serial ports and BOOTSEL option shown for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")] with ( @@ -1665,7 +1665,7 @@ def test_upload_using_esptool_with_file_path( @pytest.mark.parametrize( "platform,device", [ - (PLATFORM_RP2040, "/dev/ttyACM0"), + (PLATFORM_RP2, "/dev/ttyACM0"), (PLATFORM_BK72XX, "/dev/ttyUSB0"), # LibreTiny platform ], ) @@ -1720,7 +1720,7 @@ def test_upload_using_platformio_creates_signed_bin_for_rp2040( tmp_path: Path, ) -> None: """Test that upload_using_platformio creates firmware.bin.signed for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1756,6 +1756,53 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040( assert result == 0 +def test_upload_using_platformio_skips_signed_bin_when_already_present( + tmp_path: Path, +) -> None: + """The signed-bin copy is idempotent: if ``firmware.bin.signed`` already + exists on the RP2 build path, the upload step must not overwrite it + (and must not fail when the unsigned ``firmware.bin`` is absent).""" + setup_core(platform=PLATFORM_RP2) + + build_dir = tmp_path / "build" + build_dir.mkdir() + # Pre-existing signed bin with distinct content — must be preserved. + signed_bin = build_dir / "firmware.bin.signed" + signed_bin.write_bytes(b"already signed") + # No unsigned firmware.bin on disk — the `is_file()` guard must hold. + firmware_elf = build_dir / "firmware.elf" + firmware_elf.write_bytes(b"elf") + + mock_idedata = MagicMock() + mock_idedata.firmware_elf_path = str(firmware_elf) + + with ( + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0), + ): + result = upload_using_platformio({}, "/dev/ttyACM0") + + assert result == 0 + # Pre-existing signed bin is untouched. + assert signed_bin.read_bytes() == b"already signed" + + +def test_upload_using_platformio_handles_port_none(tmp_path: Path) -> None: + """The upload step must work without a serial port (PlatformIO picks the + target itself); the ``--upload-port`` flag is only appended when a port + is provided.""" + setup_core(platform=PLATFORM_ESP32) + + with patch( + "esphome.platformio.toolchain.run_platformio_cli_run", return_value=0 + ) as mock_run: + result = upload_using_platformio({}, None) + + assert result == 0 + args = mock_run.call_args.args + assert "--upload-port" not in args + + def test_upload_program_serial_upload_failed( mock_upload_using_esptool: Mock, mock_get_port_type: Mock, @@ -1783,7 +1830,7 @@ def test_upload_program_bootsel( mock_get_port_type: Mock, ) -> None: """Test upload_program with BOOTSEL for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_get_port_type.return_value = "BOOTSEL" mock_upload_using_picotool.return_value = 0 @@ -1804,7 +1851,7 @@ def test_upload_program_bootsel_failed( mock_get_port_type: Mock, ) -> None: """Test upload_program when BOOTSEL upload fails.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_get_port_type.return_value = "BOOTSEL" mock_upload_using_picotool.return_value = 1 @@ -1821,7 +1868,7 @@ def test_upload_program_bootsel_failed( def test_upload_using_picotool_success(tmp_path: Path) -> None: """Test upload_using_picotool succeeds.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1858,7 +1905,7 @@ def test_upload_using_picotool_success(tmp_path: Path) -> None: def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: """Test upload_using_picotool when ELF file is missing.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1876,7 +1923,7 @@ def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: def test_upload_using_picotool_not_found(tmp_path: Path) -> None: """Test upload_using_picotool when picotool binary not found.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1896,7 +1943,7 @@ def test_upload_using_picotool_not_found(tmp_path: Path) -> None: def test_upload_using_picotool_permission_error(tmp_path: Path) -> None: """Test upload_using_picotool shows helpful message on permission error.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -6411,7 +6458,7 @@ def test_command_run_rp2040_bootsel_redetects_serial_port() -> None: picks up the newly enumerated serial port before showing logs.""" setup_core( config={"logger": {}, CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}}, - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, ) args = MockArgs() diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index 0ce89230d8..244e4eb5a1 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -11,6 +11,7 @@ from esphome.components.bk72xx.boards import BK72XX_BOARD_PINS from esphome.components.esp32.boards import ESP32_BOARD_PINS from esphome.components.esp8266.boards import ESP8266_BOARD_PINS from esphome.components.ln882x.boards import LN882X_BOARD_PINS +from esphome.components.rp2.boards import RP2_BOARD_PINS from esphome.components.rtl87xx.boards import RTL87XX_BOARD_PINS from esphome.core import CORE import esphome.wizard as wz @@ -300,6 +301,31 @@ def test_wizard_write_defaults_platform_from_board_rtl87xx( assert "rtl87xx:" in generated_config +def test_wizard_write_defaults_platform_from_board_rp2( + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch +): + """ + If the platform is not explicitly set, use "RP2" when the board is in + the RP2 boards list. The generated config must use the canonical + ``rp2:`` top-level key (not the deprecated ``rp2040:`` alias). + """ + # Given + del default_config["platform"] + default_config["board"] = [*RP2_BOARD_PINS][0] + + monkeypatch.setattr(wz, "write_file", MagicMock()) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) + + # When + wz.wizard_write(tmp_path, **default_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert "rp2:" in generated_config + # Guard against regressing to the legacy alias key. + assert "rp2040:" not in generated_config + + def test_safe_print_step_prints_step_number_and_description(monkeypatch: MonkeyPatch): """ The safe_print_step function prints the step number and the passed description @@ -450,6 +476,34 @@ def test_wizard_accepts_default_answers_esp32( assert retval == 0 +def test_wizard_accepts_default_answers_bk72xx( + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): + """ + The wizard should accept the given default answers for bk72xx. The + libretiny branch also exercises the False side of the + ``elif platform == "RP2":`` checks in the platform / board-link + elif chain (without this, those branches show as partial coverage + because only the rpipico interactive test reaches them with platform + == "RP2"). + """ + # Given + wizard_answers[1] = "BK72XX" + wizard_answers[2] = next(iter(BK72XX_BOARD_PINS)) + config_file = tmp_path / "test.yaml" + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock()) + + # When + retval = wz.wizard(config_file) + + # Then + assert retval == 0 + + def test_wizard_offers_better_node_name( tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): @@ -612,7 +666,7 @@ def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch): # Given wizard_answers_rp2040 = [ "test-node", # Name of the node - "RP2040", # platform + "RP2", # platform (canonical name; ``RP2040`` was the legacy alias) "rpipico", # board (no WiFi support) ] config_file = tmp_path / "test.yaml" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 07f334d350..46e60ebd8e 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -18,7 +18,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import EsphomeError @@ -338,7 +338,7 @@ def test_storage_should_not_update_cmake_cache_when_nothing_changes( @pytest.mark.parametrize( "core_platform", - [PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_BK72XX, PLATFORM_RTL87XX], + [PLATFORM_ESP8266, PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_RTL87XX], ) def test_storage_should_not_update_cmake_cache_for_non_esp32( create_storage: Callable[..., StorageJSON], From b22def399f7c1fc47f6827ad9c15636ba7711d45 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:08:56 -0400 Subject: [PATCH 2002/2214] [espnow] Add max_payload_size option for ESP-NOW v2 frames (#17360) --- esphome/components/espnow/__init__.py | 32 +++++++++++++++-- esphome/components/espnow/automation.h | 12 +++---- .../components/espnow/espnow_component.cpp | 14 +++++--- esphome/components/espnow/espnow_component.h | 6 ++-- esphome/components/espnow/espnow_packet.h | 35 ++++++++++++++----- .../packet_transport/espnow_transport.cpp | 14 ++++---- .../packet_transport/espnow_transport.h | 6 ++-- esphome/core/defines.h | 2 ++ tests/components/espnow/common.yaml | 1 + 9 files changed, 86 insertions(+), 36 deletions(-) diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 13f278d3bc..c6c90ed67a 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -41,7 +41,7 @@ DeletePeerAction = espnow_ns.class_("DeletePeerAction", automation.Action) ESPNowHandlerTrigger = automation.Trigger.template( ESPNowRecvInfoConstRef, cg.uint8.operator("const").operator("ptr"), - cg.uint8, + cg.uint16, ) OnUnknownPeerTrigger = espnow_ns.class_( @@ -56,6 +56,20 @@ OnBroadcastTrigger = espnow_ns.class_( CONF_AUTO_ADD_PEER = "auto_add_peer" +CONF_MAX_PAYLOAD_SIZE = "max_payload_size" + +# Payload limits of ESP-NOW v1 and v2 frames. The radio negotiates the +# protocol version per peer on its own; the option only sizes this device's +# packet buffers, whose static RAM cost is proportional to it (~8 KB at 250 +# bytes, ~44 KB at 1470). +ESPNOW_PAYLOAD_V1 = 250 +ESPNOW_PAYLOAD_V2 = 1470 + +# Config-time cap for action payloads. The per-device limit is the +# ``max_payload_size`` option, which the action schema cannot see; send() +# enforces it at runtime. +MAX_ESPNOW_PACKET_SIZE = ESPNOW_PAYLOAD_V2 + CONF_PEERS = "peers" CONF_ON_SENT = "on_sent" CONF_ON_UNKNOWN_PEER = "on_unknown_peer" @@ -63,7 +77,15 @@ CONF_ON_BROADCAST = "on_broadcast" CONF_CONTINUE_ON_ERROR = "continue_on_error" CONF_WAIT_FOR_SENT = "wait_for_sent" -MAX_ESPNOW_PACKET_SIZE = 250 # Maximum size of the payload in bytes + +def _validate_max_payload_size(value: int) -> int: + if value > ESPNOW_PAYLOAD_V1: + return cv.require_framework_version( + esp_idf=cv.Version(5, 4, 0), + esp32_arduino=cv.Version(3, 2, 0), + extra_message="ESP-NOW v2 frames need an ESP-NOW v2 capable framework", + )(value) + return value def validate_channel(value): @@ -78,6 +100,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(ESPNowComponent), cv.OnlyWithout(CONF_CHANNEL, CONF_WIFI): validate_channel, cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + cv.Optional(CONF_MAX_PAYLOAD_SIZE, default=ESPNOW_PAYLOAD_V1): cv.All( + cv.int_range(min=1, max=ESPNOW_PAYLOAD_V2), _validate_max_payload_size + ), cv.Optional(CONF_AUTO_ADD_PEER, default=False): cv.boolean, cv.Optional(CONF_PEERS): cv.ensure_list(cv.mac_address), cv.Optional(CONF_ON_UNKNOWN_PEER): automation.validate_automation( @@ -113,7 +138,7 @@ async def _trigger_to_code(config): [ (ESPNowRecvInfoConstRef, "info"), (cg.uint8.operator("const").operator("ptr"), "data"), - (cg.uint8, "size"), + (cg.uint16, "size"), ], config, ) @@ -125,6 +150,7 @@ async def to_code(config): await cg.register_component(var, config) cg.add_define("USE_ESPNOW") + cg.add_define("USE_ESPNOW_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE]) if wifi_channel := config.get(CONF_CHANNEL): cg.add(var.set_wifi_channel(wifi_channel)) diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 5e995aff53..e4d01bb1a8 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -119,7 +119,7 @@ template class SetChannelAction final : public Action, pu } }; -class OnReceiveTrigger final : public Trigger, +class OnReceiveTrigger final : public Trigger, public ESPNowReceivedPacketHandler { public: explicit OnReceiveTrigger(std::array address) : has_address_(true) { @@ -128,7 +128,7 @@ class OnReceiveTrigger final : public Triggerhas_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; @@ -141,15 +141,15 @@ class OnReceiveTrigger final : public Trigger, +class OnUnknownPeerTrigger final : public Trigger, public ESPNowUnknownPeerHandler { public: - bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override { this->trigger(info, data, size); return false; // Return false to continue processing other internal handlers } }; -class OnBroadcastTrigger final : public Trigger, +class OnBroadcastTrigger final : public Trigger, public ESPNowBroadcastHandler { public: explicit OnBroadcastTrigger(std::array address) : has_address_(true) { @@ -157,7 +157,7 @@ class OnBroadcastTrigger final : public Triggerhas_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 91f2c067ca..f28d7f3354 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -4,6 +4,7 @@ #include "espnow_err.h" +#include #include #include "esphome/core/application.h" @@ -96,9 +97,9 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), - // but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger - // frame would overflow packet_.receive.data. - if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) { + // but the receive buffer only fits v2 frames with ``max_payload_size``; copying a + // larger frame would overflow packet_.receive.data. + if (size < 0 || size > ESPNOW_MAX_DATA_LEN) { global_esp_now->receive_packet_queue_.increment_dropped_count(); return; } @@ -285,11 +286,14 @@ void ESPNowComponent::loop() { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char src_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char dst_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + // Cap the hex dump at a v1 frame: a full v2 frame would need a + // ~4.4 KB stack buffer. char hex_buf[format_hex_pretty_size(ESP_NOW_MAX_DATA_LEN)]; format_mac_addr_upper(info.src_addr, src_buf); format_mac_addr_upper(info.des_addr, dst_buf); ESP_LOGV(TAG, "<<< [%s -> %s] %s", src_buf, dst_buf, - format_hex_pretty_to(hex_buf, packet->packet_.receive.data, packet->packet_.receive.size)); + format_hex_pretty_to(hex_buf, packet->packet_.receive.data, + std::min(packet->packet_.receive.size, ESP_NOW_MAX_DATA_LEN))); #endif if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { for (auto *handler : this->broadcast_handlers_) { @@ -362,7 +366,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl return ESP_ERR_ESPNOW_PEER_NOT_SET; } else if (memcmp(peer_address, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { return ESP_ERR_ESPNOW_OWN_ADDRESS; - } else if (size > ESP_NOW_MAX_DATA_LEN) { + } else if (size > ESPNOW_MAX_DATA_LEN) { return ESP_ERR_ESPNOW_DATA_SIZE; } else if (!esp_now_is_peer_exist(peer_address)) { if (memcmp(peer_address, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0 || this->auto_add_peer_) { diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index eacc3eb886..d95255c5df 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -62,7 +62,7 @@ class ESPNowUnknownPeerHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; /// Handler interface for receiving ESPNow packets @@ -74,7 +74,7 @@ class ESPNowReceivedPacketHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; /// Handler interface for receiving ESPNow broadcast packets /// Components should inherit from this class to handle incoming ESPNow data @@ -85,7 +85,7 @@ class ESPNowBroadcastHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; class ESPNowComponent final : public Component { diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index b6192a0d41..fb125864fb 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -19,6 +19,23 @@ namespace esphome::espnow { static const uint8_t ESPNOW_BROADCAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; static const uint8_t ESPNOW_MULTICAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE}; +// Maximum payload this component sends and receives, from the +// ``max_payload_size`` option. The radio stack speaks ESP-NOW v2 regardless +// (negotiated per peer); payloads beyond the v1 limit (250 bytes) are opt-in +// because the packet pools are statically sized from this, so their RAM cost +// is proportional (~8 KB at 250 bytes, ~44 KB at the v2 limit of 1470). +#ifndef USE_ESPNOW_MAX_PAYLOAD_SIZE +#define USE_ESPNOW_MAX_PAYLOAD_SIZE ESP_NOW_MAX_DATA_LEN +#endif +static constexpr uint16_t ESPNOW_MAX_DATA_LEN = USE_ESPNOW_MAX_PAYLOAD_SIZE; +#ifdef ESP_NOW_MAX_DATA_LEN_V2 +static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN_V2, + "espnow max_payload_size cannot exceed the ESP-NOW v2 frame limit"); +#else +static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN, + "espnow max_payload_size beyond 250 bytes requires an ESP-IDF with ESP-NOW v2 support (5.4+)"); +#endif + struct WifiPacketRxControl { int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or @@ -78,10 +95,10 @@ class ESPNowPacket { union { // NOLINTNEXTLINE(readability-identifier-naming) struct received_data { - ESPNowRecvInfo info; // Information about the received packet - uint8_t data[ESP_NOW_MAX_DATA_LEN]; // Data received in the packet - uint8_t size; // Size of the received data - WifiPacketRxControl rx_ctrl; // Status of the received packet + ESPNowRecvInfo info; // Information about the received packet + uint8_t data[ESPNOW_MAX_DATA_LEN]; // Data received in the packet + uint16_t size; // Size of the received data + WifiPacketRxControl rx_ctrl; // Status of the received packet } receive; // NOLINTNEXTLINE(readability-identifier-naming) @@ -144,15 +161,15 @@ class ESPNowSendPacket { this->callback_ = nullptr; // Reset callback } - uint8_t address_[ESP_NOW_ETH_ALEN]{0}; // MAC address of the peer to send the packet to - uint8_t data_[ESP_NOW_MAX_DATA_LEN]{0}; // Data to send - uint8_t size_{0}; // Size of the data to send, must be <= ESP_NOW_MAX_DATA_LEN - send_callback_t callback_{nullptr}; // Callback to call when the send operation is complete + uint8_t address_[ESP_NOW_ETH_ALEN]{0}; // MAC address of the peer to send the packet to + uint8_t data_[ESPNOW_MAX_DATA_LEN]{0}; // Data to send + uint16_t size_{0}; // Size of the data to send, must be <= ESPNOW_MAX_DATA_LEN + send_callback_t callback_{nullptr}; // Callback to call when the send operation is complete private: void init_data_(const uint8_t *peer_address, const uint8_t *payload, size_t size) { memcpy(this->address_, peer_address, ESP_NOW_ETH_ALEN); - if (size > ESP_NOW_MAX_DATA_LEN) { + if (size > ESPNOW_MAX_DATA_LEN) { this->size_ = 0; return; } diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index 1e37073321..b7686f23d6 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -42,8 +42,8 @@ void ESPNowTransport::send_packet(const std::vector &buf) const { return; } - if (buf.size() > ESP_NOW_MAX_DATA_LEN) { - ESP_LOGE(TAG, "Packet too large: %zu bytes (max %d)", buf.size(), ESP_NOW_MAX_DATA_LEN); + if (buf.size() > ESPNOW_MAX_DATA_LEN) { + ESP_LOGE(TAG, "Packet too large: %zu bytes (max %u)", buf.size(), (unsigned) ESPNOW_MAX_DATA_LEN); return; } @@ -55,8 +55,8 @@ void ESPNowTransport::send_packet(const std::vector &buf) const { }); } -bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { - ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], +bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) { + ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", (unsigned) size, info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); if (data == nullptr || size == 0) { @@ -70,9 +70,9 @@ bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data return false; // Allow other handlers to run } -bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { - ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], - info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); +bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) { + ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", (unsigned) size, + info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); if (data == nullptr || size == 0) { ESP_LOGW(TAG, "Received empty or null broadcast packet"); diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 7e1d08618b..51069b6415 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -24,12 +24,12 @@ class ESPNowTransport final : public packet_transport::PacketTransport, } // ESPNow handler interface - bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; - bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; + bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override; + bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override; protected: void send_packet(const std::vector &buf) const override; - size_t get_max_packet_size() override { return ESP_NOW_MAX_DATA_LEN; } + size_t get_max_packet_size() override { return ESPNOW_MAX_DATA_LEN; } bool should_send() override; peer_address_t peer_address_{{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3e8b0829c5..cdc26c9222 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -242,6 +242,8 @@ #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM +#define USE_ESPNOW +#define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470 #define USE_BLUETOOTH_PROXY #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index f05735e8f4..ae43baa41a 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -2,6 +2,7 @@ espnow: id: espnow_component auto_add_peer: false channel: 1 + max_payload_size: 1470 peers: - 11:22:33:44:55:66 on_receive: From 0b311962b5ff529c1eb854233ac80d6e7b291475 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:09:51 -0500 Subject: [PATCH 2003/2214] Bump bundled esphome-device-builder to 1.2.0 (#17430) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a54bf3e79e..c01a2069f7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.1.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.2.0 RUN \ platformio settings set enable_telemetry No \ From ebff49072e0acf19102991c6521ae8038abbe952 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 6 Jul 2026 16:12:25 -0700 Subject: [PATCH 2004/2214] [modbus] Store ModbusFrame inline to cut per-frame heap churn (#17282) Co-authored-by: Claude --- esphome/components/modbus/modbus.cpp | 25 ++--- esphome/components/modbus/modbus.h | 30 ++++-- esphome/core/helpers.h | 11 +- tests/components/modbus/heap_probe_test.cpp | 106 ++++++++++++++++++++ 4 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 tests/components/modbus/heap_probe_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index eefab7967f..527d57fcd7 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -51,7 +51,7 @@ void ModbusClientHub::loop() { // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response if (this->waiting_for_response_.has_value()) { ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.data.get()[0]; + uint8_t expected_address = wfr.frame.data.data()[0]; if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, @@ -270,8 +270,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct // Check if the response matches the expected address and function code ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.data.get()[0]; - uint8_t expected_function_code = wfr.frame.data.get()[1]; + uint8_t expected_address = wfr.frame.data.data()[0]; + uint8_t expected_function_code = wfr.frame.data.data()[1]; if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { ESP_LOGW(TAG, "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 @@ -458,7 +458,7 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { ESP_LOGE(TAG, "Attempted to send while transmission blocked"); return false; } - if (frame.size > MAX_FRAME_SIZE) { + if (frame.size() > MAX_FRAME_SIZE) { ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE); return false; } @@ -470,13 +470,13 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->digital_write(true); - this->write_array(frame.data.get(), frame.size); + this->write_array(frame.data.data(), frame.size()); this->flush(); this->flow_control_pin_->digital_write(false); this->last_send_tx_offset_ = 0; } else { - this->write_array(frame.data.get(), frame.size); - this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; + this->write_array(frame.data.data(), frame.size()); + this->last_send_tx_offset_ = frame.size() * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; } uint32_t now = millis(); @@ -484,7 +484,7 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive", - format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, + format_hex_pretty_to(hex_buf, frame.data.data(), frame.size()), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; return true; @@ -590,12 +590,13 @@ void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t p void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { // Remove any pending commands for this address from the tx buffer auto &tx_buffer = this->tx_buffer_; - tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), - [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data[0] == address; }), - tx_buffer.end()); + tx_buffer.erase( + std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data.data()[0] == address; }), + tx_buffer.end()); if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { - if (this->waiting_for_response_.value().frame.data[0] == address) { + if (this->waiting_for_response_.value().frame.data.data()[0] == address) { ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address); // Invalidate the waiting device so it won't process a response. this->waiting_for_response_.value().device = nullptr; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index d995c441ad..e48c8c298a 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -18,19 +18,27 @@ namespace esphome::modbus { static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; -struct ModbusFrame { - // Frame with exact-size allocation to avoid std::vector overhead - std::unique_ptr data; - uint16_t size; // Modbus RTU max is 256 bytes +// Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes +// (address + 5-byte PDU + 2-byte CRC) and fit inline with no heap allocation. +static constexpr uint16_t MODBUS_FRAME_INLINE_SIZE = 8; - ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) - : data(std::make_unique(pdu_len + 3)), size(pdu_len + 3) { - data[0] = address; - memcpy(data.get() + 1, pdu, pdu_len); - auto crc = crc16(data.get(), pdu_len + 1); - data[pdu_len + 1] = crc >> 0; - data[pdu_len + 2] = crc >> 8; +struct ModbusFrame { + // Frame held in a small-buffer-optimized buffer. Typical frames fit inline; only larger + // multi-register or custom frames spill to a single heap allocation. This keeps the common, + // high-frequency tx traffic off the heap entirely, avoiding per-frame alloc/free churn. + // The buffer tracks its own length, so no separate size field is needed. + SmallInlineBuffer data; // Modbus RTU max is 256 bytes + + ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) { + uint8_t *buf = this->data.init(pdu_len + 3); + buf[0] = address; + memcpy(buf + 1, pdu, pdu_len); + auto crc = crc16(buf, pdu_len + 1); + buf[pdu_len + 1] = crc >> 0; + buf[pdu_len + 2] = crc >> 8; } + + uint16_t size() const { return static_cast(this->data.size()); } }; class Modbus : public uart::UARTDevice, public Component { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index f39b5aa4d0..e862d015da 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -184,8 +184,10 @@ template class SmallInlineBuffer { SmallInlineBuffer(const SmallInlineBuffer &) = delete; SmallInlineBuffer &operator=(const SmallInlineBuffer &) = delete; - /// Set buffer contents, allocating heap if needed - void set(const uint8_t *src, size_t size) { + /// Resize to `size` bytes of (uninitialized) storage and return a writable pointer to fill. + /// Allocates heap only when `size` exceeds the inline capacity. Use this when the contents are + /// built in place (e.g. assembling a frame and appending a checksum) to avoid a staging copy. + uint8_t *init(size_t size) { // Free existing heap allocation if switching from heap to inline or different heap size if (!this->is_inline_() && (size <= InlineSize || size != this->len_)) { delete[] this->heap_; @@ -196,9 +198,12 @@ template class SmallInlineBuffer { this->heap_ = new uint8_t[size]; // NOLINT(cppcoreguidelines-owning-memory) } this->len_ = size; - memcpy(this->data(), src, size); + return this->data(); } + /// Set buffer contents, allocating heap if needed + void set(const uint8_t *src, size_t size) { memcpy(this->init(size), src, size); } + uint8_t *data() { return this->is_inline_() ? this->inline_ : this->heap_; } const uint8_t *data() const { return this->is_inline_() ? this->inline_ : this->heap_; } size_t size() const { return this->len_; } diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp new file mode 100644 index 0000000000..af43c6e5e3 --- /dev/null +++ b/tests/components/modbus/heap_probe_test.cpp @@ -0,0 +1,106 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/modbus/modbus.h" + +// The allocation counters rely on AddressSanitizer's malloc hooks. The cpp_unit_test harness always +// builds with ASan, so this is exercised in CI; the fallback only applies to out-of-harness builds. +#ifndef __has_feature +#define __has_feature(x) 0 +#endif +#if defined(__SANITIZE_ADDRESS__) || __has_feature(address_sanitizer) +#define HEAP_PROBE_HAS_ASAN +#endif + +#ifdef HEAP_PROBE_HAS_ASAN + +// Allocation counters fed by ASan's malloc hooks; sampled tightly around the calls under test. +static std::atomic g_alloc_count{0}; +static std::atomic g_alloc_bytes{0}; + +static void malloc_hook(const volatile void *, size_t size) { + g_alloc_count++; + g_alloc_bytes += size; +} +static void free_hook(const volatile void *) {} + +extern "C" int __sanitizer_install_malloc_and_free_hooks(void (*malloc_hook)(const volatile void *, size_t), + void (*free_hook)(const volatile void *)); + +[[maybe_unused]] static const int g_hooks_installed = __sanitizer_install_malloc_and_free_hooks(malloc_hook, free_hook); + +namespace esphome::modbus::testing { + +namespace { + +struct Sample { + size_t count; + size_t bytes; +}; + +template Sample sample(F &&f) { + size_t c0 = g_alloc_count.load(), b0 = g_alloc_bytes.load(); + f(); + return {g_alloc_count.load() - c0, g_alloc_bytes.load() - b0}; +} + +} // namespace + +// Typical frames (reads and single-register/coil writes are exactly address + 5-byte PDU + CRC = 8 +// bytes) fit the SmallInlineBuffer and are built with zero heap allocations; only larger frames spill +// to a single allocation. +TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // 5 bytes -> 8-byte frame, inline + Sample typical = sample([&] { + ModbusFrame frame(0x02, read_pdu, sizeof(read_pdu)); + (void) frame; + }); + printf("HEAPPROBE frame_typical count=%zu bytes=%zu\n", typical.count, typical.bytes); + EXPECT_EQ(typical.count, 0u); + + uint8_t large_pdu[250] = {0x10}; // multi-register write -> 253-byte frame, spills once + Sample large = sample([&] { + ModbusFrame frame(0x02, large_pdu, sizeof(large_pdu)); + (void) frame; + }); + printf("HEAPPROBE frame_large count=%zu bytes=%zu\n", large.count, large.bytes); + EXPECT_EQ(large.count, 1u); +} + +// Queueing typical commands is fully allocation-free: the frame fits the inline buffer and the tx +// deque's first block is already allocated when the hub is constructed. (A queue deeper than one +// deque block - roughly a dozen commands - would allocate further blocks.) +TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { + ModbusClientHub hub; + ModbusClientDevice device(&hub, 0x02); + + StaticVector req; + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + req.assign(read_pdu, read_pdu + sizeof(read_pdu)); + + constexpr int n = 12; + size_t total = 0; + for (int i = 0; i != n; i++) { + total += sample([&] { device.send_pdu(req); }).count; + } + printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total); + EXPECT_EQ(total, 0u); +} + +} // namespace esphome::modbus::testing + +#else // !HEAP_PROBE_HAS_ASAN + +namespace esphome::modbus::testing { +TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { + GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; +} +} // namespace esphome::modbus::testing + +#endif // HEAP_PROBE_HAS_ASAN From c4689989c78aee50f8e065d9d7e0e31f0dcb2acd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:42:18 +1200 Subject: [PATCH 2005/2214] Mark configurable classes as final (20/21: wts01-zephyr_ble_server) (#16971) --- esphome/components/wts01/wts01.h | 2 +- esphome/components/x9c/x9c.h | 2 +- esphome/components/xdb401/xdb401.h | 2 +- esphome/components/xgzp68xx/xgzp68xx.h | 2 +- esphome/components/xiaomi_ble/xiaomi_ble.h | 2 +- esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h | 2 +- esphome/components/xiaomi_cgg1/xiaomi_cgg1.h | 2 +- esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h | 6 +++--- esphome/components/xiaomi_gcls002/xiaomi_gcls002.h | 2 +- esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h | 2 +- esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h | 2 +- esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h | 2 +- esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h | 2 +- esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h | 2 +- esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h | 2 +- esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h | 2 +- esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h | 2 +- esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h | 2 +- esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h | 2 +- esphome/components/xiaomi_miscale/xiaomi_miscale.h | 2 +- esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h | 6 +++--- esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h | 6 +++--- esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h | 2 +- esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h | 6 +++--- esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h | 2 +- esphome/components/xl9535/xl9535.h | 4 ++-- esphome/components/xpt2046/touchscreen/xpt2046.h | 6 +++--- esphome/components/yashima/yashima.h | 2 +- esphome/components/zephyr/cdc_acm.h | 2 +- esphome/components/zephyr/gpio.h | 2 +- esphome/components/zephyr_ble_server/ble_server.h | 4 ++-- 31 files changed, 43 insertions(+), 43 deletions(-) diff --git a/esphome/components/wts01/wts01.h b/esphome/components/wts01/wts01.h index 17d4dc57a2..2a284ac86e 100644 --- a/esphome/components/wts01/wts01.h +++ b/esphome/components/wts01/wts01.h @@ -8,7 +8,7 @@ namespace esphome::wts01 { constexpr uint8_t PACKET_SIZE = 9; -class WTS01Sensor : public sensor::Sensor, public uart::UARTDevice, public Component { +class WTS01Sensor final : public sensor::Sensor, public uart::UARTDevice, public Component { public: void loop() override; void dump_config() override; diff --git a/esphome/components/x9c/x9c.h b/esphome/components/x9c/x9c.h index 112f0405d7..1cea15c26f 100644 --- a/esphome/components/x9c/x9c.h +++ b/esphome/components/x9c/x9c.h @@ -6,7 +6,7 @@ namespace esphome::x9c { -class X9cOutput : public output::FloatOutput, public Component { +class X9cOutput final : public output::FloatOutput, public Component { public: void set_cs_pin(InternalGPIOPin *pin) { cs_pin_ = pin; } void set_inc_pin(InternalGPIOPin *pin) { inc_pin_ = pin; } diff --git a/esphome/components/xdb401/xdb401.h b/esphome/components/xdb401/xdb401.h index 674d26fe8e..670425e69e 100644 --- a/esphome/components/xdb401/xdb401.h +++ b/esphome/components/xdb401/xdb401.h @@ -6,7 +6,7 @@ namespace esphome::xdb401 { -class XDB401Component : public PollingComponent, public i2c::I2CDevice { +class XDB401Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/xgzp68xx/xgzp68xx.h b/esphome/components/xgzp68xx/xgzp68xx.h index 1bab9b091a..d9aec6e5cc 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.h +++ b/esphome/components/xgzp68xx/xgzp68xx.h @@ -20,7 +20,7 @@ enum XGZP68XXOversampling : uint8_t { XGZP68XX_OVERSAMPLING_UNKNOWN = (uint8_t) -1, }; -class XGZP68XXComponent : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { +class XGZP68XXComponent final : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { public: SUB_SENSOR(temperature) SUB_SENSOR(pressure) diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.h b/esphome/components/xiaomi_ble/xiaomi_ble.h index a4ecca0c66..1ebcf0e2f5 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.h +++ b/esphome/components/xiaomi_ble/xiaomi_ble.h @@ -72,7 +72,7 @@ optional parse_xiaomi_header(const esp32_ble_tracker::Service bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address); bool report_xiaomi_results(const optional &result, const char *address); -class XiaomiListener : public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h index 02d098c31b..36068ae227 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_cgdk2 { -class XiaomiCGDK2 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGDK2 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h index d49e3a08d1..7633458cb8 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_cgg1 { -class XiaomiCGG1 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGG1 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h index 28a7a3ae2d..0fa6c76e54 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_cgpr1 { -class XiaomiCGPR1 : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGPR1 final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h index e14077adb0..668133f364 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_gcls002 { -class XiaomiGCLS002 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiGCLS002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h index 8bc6399065..cb53b47f6f 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_hhccjcy01 { -class XiaomiHHCCJCY01 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY01 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h index 812e3a7d8f..fa2f461534 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h @@ -8,7 +8,7 @@ namespace esphome::xiaomi_hhccjcy10 { -class XiaomiHHCCJCY10 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY10 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h index 2bdd6102be..3eda1b9859 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_hhccpot002 { -class XiaomiHHCCPOT002 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCPOT002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h index aaf34f899f..122c6776c9 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_jqjcy01ym { -class XiaomiJQJCY01YM : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiJQJCY01YM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h index e45596f966..09256047ae 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd02 { -class XiaomiLYWSD02 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h index 23efcbf8fc..efd758b972 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd02mmc { -class XiaomiLYWSD02MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h index 03462b850f..ecdbd412cb 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd03mmc { -class XiaomiLYWSD03MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD03MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h index e169afc651..86afef4571 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsdcgq { -class XiaomiLYWSDCGQ : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSDCGQ final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h index daacd6be86..042a5034f1 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_mhoc303 { -class XiaomiMHOC303 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC303 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h index 225c9ff189..3570f70a16 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_mhoc401 { -class XiaomiMHOC401 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC401 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.h b/esphome/components/xiaomi_miscale/xiaomi_miscale.h index c75a22c9fb..3213f5d6de 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.h +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.h @@ -16,7 +16,7 @@ struct ParseResult { optional impedance; }; -class XiaomiMiscale : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h index ee4ed52520..da02dee003 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_mjyd02yla { -class XiaomiMJYD02YLA : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMJYD02YLA final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h index a6d8abc5bf..4751e35e65 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h @@ -9,9 +9,9 @@ namespace esphome::xiaomi_mue4094rt { -class XiaomiMUE4094RT : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMUE4094RT final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h index cc6a334a20..0d3427cc4d 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h @@ -15,7 +15,7 @@ namespace esphome::xiaomi_rtcgq02lm { -class XiaomiRTCGQ02LM : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiRTCGQ02LM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h index 0b0cb8db0b..0573959473 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_wx08zm { -class XiaomiWX08ZM : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiWX08ZM final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h index 9bab943ab9..c7d20aa356 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_xmwsdj04mmc { -class XiaomiXMWSDJ04MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiXMWSDJ04MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xl9535/xl9535.h b/esphome/components/xl9535/xl9535.h index 253ce76273..11fb3acc8d 100644 --- a/esphome/components/xl9535/xl9535.h +++ b/esphome/components/xl9535/xl9535.h @@ -17,7 +17,7 @@ enum { XL9535_CONFIG_PORT_1_REGISTER = 0x07, }; -class XL9535Component : public Component, public i2c::I2CDevice { +class XL9535Component final : public Component, public i2c::I2CDevice { public: bool digital_read(uint8_t pin); void digital_write(uint8_t pin, bool value); @@ -28,7 +28,7 @@ class XL9535Component : public Component, public i2c::I2CDevice { float get_setup_priority() const override { return setup_priority::IO; } }; -class XL9535GPIOPin : public GPIOPin { +class XL9535GPIOPin final : public GPIOPin { public: void set_parent(XL9535Component *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.h b/esphome/components/xpt2046/touchscreen/xpt2046.h index f619e06fb7..8fe9b7cc43 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.h +++ b/esphome/components/xpt2046/touchscreen/xpt2046.h @@ -11,9 +11,9 @@ namespace esphome::xpt2046 { using namespace touchscreen; -class XPT2046Component : public Touchscreen, - public spi::SPIDevice { +class XPT2046Component final : public Touchscreen, + public spi::SPIDevice { public: /// Set the threshold for the touch detection. void set_threshold(int16_t threshold) { this->threshold_ = threshold; } diff --git a/esphome/components/yashima/yashima.h b/esphome/components/yashima/yashima.h index 336b28f5c5..864b3fce66 100644 --- a/esphome/components/yashima/yashima.h +++ b/esphome/components/yashima/yashima.h @@ -9,7 +9,7 @@ namespace esphome::yashima { -class YashimaClimate : public climate::Climate, public Component { +class YashimaClimate final : public climate::Climate, public Component { public: void setup() override; void set_transmitter(remote_transmitter::RemoteTransmitterComponent *transmitter) { diff --git a/esphome/components/zephyr/cdc_acm.h b/esphome/components/zephyr/cdc_acm.h index 4dc14397d8..9d11d4b575 100644 --- a/esphome/components/zephyr/cdc_acm.h +++ b/esphome/components/zephyr/cdc_acm.h @@ -7,7 +7,7 @@ namespace esphome::zephyr { -class CdcAcm : public Component { +class CdcAcm final : public Component { public: CdcAcm(); void setup() override; diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 19d68cfb2b..71d1620a67 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -16,7 +16,7 @@ struct ZephyrGPIOInterrupt { void *arg{nullptr}; }; -class ZephyrGPIOPin : public InternalGPIOPin { +class ZephyrGPIOPin final : public InternalGPIOPin { public: ZephyrGPIOPin(const device *gpio, int gpio_size, const char *pin_name_prefix) { this->gpio_ = gpio; diff --git a/esphome/components/zephyr_ble_server/ble_server.h b/esphome/components/zephyr_ble_server/ble_server.h index bf69c52b12..223dbf7ac9 100644 --- a/esphome/components/zephyr_ble_server/ble_server.h +++ b/esphome/components/zephyr_ble_server/ble_server.h @@ -6,7 +6,7 @@ namespace esphome::zephyr_ble_server { -class BLEServer : public Component { +class BLEServer final : public Component { public: void setup() override; void dump_config() override; @@ -21,7 +21,7 @@ class BLEServer : public Component { CallbackManager passkey_cb_; }; -template class BLENumericComparisonReplyAction : public Action { +template class BLENumericComparisonReplyAction final : public Action { public: explicit BLENumericComparisonReplyAction(BLEServer *parent) : parent_(parent) {} From 2dd7ac090f66212357cf4d1dbff4e08ccaace2bf Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:37:44 +1000 Subject: [PATCH 2006/2214] [mipi_spi] Add M5STACK ATOM3SR display (#17344) --- esphome/components/mipi_spi/models/ili.py | 72 ------------------- esphome/components/mipi_spi/models/m5stack.py | 71 ++++++++++++++++++ 2 files changed, 71 insertions(+), 72 deletions(-) create mode 100644 esphome/components/mipi_spi/models/m5stack.py diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 5598a51073..812e491c62 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -10,7 +10,6 @@ from esphome.components.mipi import ( GMCTR, GMCTRN1, GMCTRP1, - IDMOFF, IFCTR, IFMODE, INVCTR, @@ -23,7 +22,6 @@ from esphome.components.mipi import ( PWCTR5, PWSET, PWSETN, - SETEXTC, VMCTR, VMCTR1, VMCTR2, @@ -32,60 +30,6 @@ from esphome.components.mipi import ( ) from esphome.components.spi import TYPE_OCTAL -DriverChip( - "M5CORE", - width=320, - height=240, - cs_pin=14, - dc_pin=27, - reset_pin=33, - initsequence=( - (SETEXTC, 0xFF, 0x93, 0x42), - (PWCTR1, 0x12, 0x12), - (PWCTR2, 0x03), - (VMCTR1, 0xF2), - (IFMODE, 0xE0), - (0xF6, 0x01, 0x00, 0x00), - ( - GMCTRP1, - 0x00, - 0x0C, - 0x11, - 0x04, - 0x11, - 0x08, - 0x37, - 0x89, - 0x4C, - 0x06, - 0x0C, - 0x0A, - 0x2E, - 0x34, - 0x0F, - ), - ( - GMCTRN1, - 0x00, - 0x0B, - 0x11, - 0x05, - 0x13, - 0x09, - 0x33, - 0x67, - 0x48, - 0x07, - 0x0E, - 0x0B, - 0x2E, - 0x33, - 0x0F, - ), - (DFUNCTR, 0x08, 0x82, 0x1D, 0x04), - (IDMOFF,), - ), -) ILI9341 = DriverChip( "ILI9341", mirror_x=True, @@ -174,22 +118,6 @@ ILI9342 = DriverChip( ), ) -# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation -ILI9341.extend( - "M5CORE2", - # Reset native dimensions due to axis swap. - native_width=320, - native_height=240, - width=320, - height=240, - mirror_x=False, - cs_pin=5, - dc_pin=15, - invert_colors=True, - pixel_mode="18bit", - data_rate="40MHz", -) - DriverChip( "ILI9481", mirror_x=True, diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py new file mode 100644 index 0000000000..81bb186278 --- /dev/null +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -0,0 +1,71 @@ +from esphome.components.mipi import ( + DFUNCTR, + GMCTRN1, + GMCTRP1, + IDMOFF, + IFMODE, + PWCTR1, + PWCTR2, + SETEXTC, + VMCTR1, + DriverChip, +) + +from .ili import ILI9341, ST7789V + +# fmt: off +DriverChip( + "M5CORE", + width=320, + height=240, + cs_pin=14, + dc_pin=27, + reset_pin=33, + initsequence=( + (SETEXTC, 0xFF, 0x93, 0x42), + (PWCTR1, 0x12, 0x12), + (PWCTR2, 0x03), + (VMCTR1, 0xF2), + (IFMODE, 0xE0), + (0xF6, 0x01, 0x00, 0x00), + (GMCTRP1, 0x00, 0x0C, 0x11, 0x04, 0x11, 0x08, 0x37, 0x89, 0x4C, 0x06, 0x0C, 0x0A, 0x2E, 0x34, 0x0F,), + (GMCTRN1, 0x00, 0x0B, 0x11, 0x05, 0x13, 0x09, 0x33, 0x67, 0x48, 0x07, 0x0E, 0x0B, 0x2E, 0x33, 0x0F,), + (DFUNCTR, 0x08, 0x82, 0x1D, 0x04), + (IDMOFF,), + ), +) + +# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation +ILI9341.extend( + "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, + width=320, + height=240, + mirror_x=False, + cs_pin=5, + dc_pin=15, + invert_colors=True, + pixel_mode="18bit", + data_rate="40MHz", +) + +GC9107 = ST7789V.extend( + "GC9107", + width=128, + height=128, + offset_width=2, + offset_height=1, + pad_width=2, + pad_height=1, +) + +GC9107.extend( + "M5STACK-ATOMS3R-GC9107", + data_rate="40MHz", + invert_colors=True, + reset_pin=48, + dc_pin=42, + cs_pin=14, +) From d9998eff20fbc02f21d41484fa68ddcb891305ab Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 6 Jul 2026 19:39:18 -0500 Subject: [PATCH 2007/2214] [esp32] Add software OTA downgrade protection (#17315) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/const/__init__.py | 1 + esphome/components/esp32/__init__.py | 67 +++++++++++++++++++ esphome/components/ota/ota_backend.cpp | 28 ++++++++ esphome/components/ota/ota_backend.h | 15 +++++ .../components/ota/ota_backend_esp_idf.cpp | 20 ++++++ esphome/core/defines.h | 1 + esphome/espota2.py | 6 ++ tests/component_tests/esp32/test_esp32.py | 33 +++++++++ ...ota_downgrade_protection.esp32-s3-idf.yaml | 22 ++++++ tests/components/md5/__init__.py | 9 +++ tests/components/ota/test_version_compare.cpp | 52 ++++++++++++++ 11 files changed, 254 insertions(+) create mode 100644 tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml create mode 100644 tests/components/md5/__init__.py create mode 100644 tests/components/ota/test_version_compare.cpp diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 85878a6306..6f4fa9aaa7 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -16,6 +16,7 @@ CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DRAW_ROUNDING = "draw_rounding" +CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a5528da672..5a7ddb6c76 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -11,6 +11,7 @@ from typing import Any from esphome import yaml_util import esphome.codegen as cg +from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -29,6 +30,7 @@ from esphome.const import ( CONF_PATH, CONF_PLATFORM_VERSION, CONF_PLATFORMIO_OPTIONS, + CONF_PROJECT, CONF_REF, CONF_SAFE_MODE, CONF_SIZE, @@ -1098,6 +1100,50 @@ def _detect_variant(value): return value +def _ota_downgrade_protection_errors( + project_version: str | None, signed_ota_enabled: bool +) -> list[cv.Invalid]: + """Validate prerequisites for OTA downgrade protection. + + Called only when the feature is enabled. Returns a ``cv.Invalid`` for each + unmet requirement: a dotted-numeric project version (the firmware version + compared on-device) and signed OTA (so the embedded version cannot be + forged). + """ + path = [CONF_FRAMEWORK, CONF_ADVANCED, CONF_ENABLE_OTA_DOWNGRADE_PROTECTION] + errs: list[cv.Invalid] = [] + if not project_version: + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires a " + f"'{CONF_PROJECT}' with a '{CONF_VERSION}' to be set in the " + f"'{CONF_ESPHOME}' section; this version is the firmware version " + "compared during OTA.", + path=path, + ) + ) + elif not re.fullmatch(r"\d+(\.\d+)*", project_version): + # The on-device comparison parses dotted-numeric versions only. + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires the " + f"'{CONF_PROJECT}' '{CONF_VERSION}' to be dotted-numeric (such " + f"as '1.2.3'), got '{project_version}'.", + path=path, + ) + ) + if not signed_ota_enabled: + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires " + f"'{CONF_SIGNED_OTA_VERIFICATION}' to be enabled; without signed " + "OTA the embedded version cannot be trusted.", + path=path, + ) + ) + return errs + + def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN @@ -1303,6 +1349,14 @@ def final_validate(config): "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) + if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: + project = full_config[CONF_ESPHOME].get(CONF_PROJECT) + errs.extend( + _ota_downgrade_protection_errors( + project[CONF_VERSION] if project else None, + bool(advanced.get(CONF_SIGNED_OTA_VERIFICATION)), + ) + ) if errs: raise cv.MultipleInvalid(errs) @@ -1540,6 +1594,9 @@ FRAMEWORK_SCHEMA = cv.Schema( min=8192, max=32768 ), cv.Optional(CONF_ENABLE_OTA_ROLLBACK, default=True): cv.boolean, + cv.Optional( + CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False + ): cv.boolean, cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All( cv.Schema( { @@ -2358,6 +2415,16 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE", True) cg.add_define("USE_OTA_ROLLBACK") + # Enable software OTA downgrade protection. Embed the project version into + # the image's esp_app_desc_t so the OTA backend can compare it against the + # running version (final_validate guarantees a dotted-numeric project + # version and that signed OTA is enabled). + if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: + project_version = CORE.config[CONF_ESPHOME][CONF_PROJECT][CONF_VERSION] + add_idf_sdkconfig_option("CONFIG_APP_PROJECT_VER_FROM_CONFIG", True) + add_idf_sdkconfig_option("CONFIG_APP_PROJECT_VER", project_version) + cg.add_define("USE_OTA_DOWNGRADE_PROTECTION") + # Enable signed app verification without hardware secure boot if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", True) diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 17949de642..0447b968a3 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -2,6 +2,34 @@ namespace esphome::ota { +bool version_is_older(const char *candidate, const char *reference) { + if (candidate == nullptr || reference == nullptr) + return false; + while (true) { + uint32_t a = 0; + while (*candidate >= '0' && *candidate <= '9') { + a = a * 10 + static_cast(*candidate - '0'); + candidate++; + } + uint32_t b = 0; + while (*reference >= '0' && *reference <= '9') { + b = b * 10 + static_cast(*reference - '0'); + reference++; + } + if (a != b) + return a < b; + // Components equal so far; advance past a single separator on each side. + const bool a_more = (*candidate == '.'); + const bool b_more = (*reference == '.'); + if (a_more) + candidate++; + if (b_more) + reference++; + if (!a_more && !b_more) + return false; // Both strings exhausted with all components equal. + } +} + #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index de236c1951..01be46a518 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -46,9 +46,24 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90, OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91, OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, + OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; +/** Compare two dotted-numeric version strings (such as "1.2.3"). + * + * Returns true when @p candidate represents a strictly older (lower) firmware + * version than @p reference. Each dot-separated component is parsed as an + * integer and compared left-to-right; absent trailing components count as 0, + * so "1.2" and "1.2.0" are equal. Equal versions return false so that + * re-flashing the same version is permitted. + * + * Used for software OTA downgrade protection. Inputs come from the project + * version embedded in the signed firmware image, which is validated to be + * dotted-numeric at config time. Non-digit characters terminate a component. + */ +bool version_is_older(const char *candidate, const char *reference); + enum OTAState { OTA_COMPLETED = 0, OTA_STARTED, diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ac765d8018..8fd21f42bd 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -9,6 +9,9 @@ #include #include #include +#ifdef USE_OTA_DOWNGRADE_PROTECTION +#include +#endif namespace esphome::ota { @@ -159,6 +162,23 @@ OTAResponseTypes IDFOTABackend::end() { } #endif if (err == ESP_OK) { +#ifdef USE_OTA_DOWNGRADE_PROTECTION + // The image is written and (when signing is enabled) signature-verified by + // esp_ota_end(), so its embedded project version can be trusted. Reject the + // update if it is older than the running version by leaving the boot + // partition unchanged -- the staged image simply never boots. + esp_app_desc_t incoming; + esp_err_t desc_err = esp_ota_get_partition_description(this->partition_, &incoming); + if (desc_err != ESP_OK) { + // Couldn't read the staged image's version, so the comparison is skipped. + // Warn so the bypassed check is observable rather than silent. + ESP_LOGW(TAG, "Downgrade protection: could not read image version (err=0x%X); allowing update", desc_err); + } else if (version_is_older(incoming.version, ESPHOME_PROJECT_VERSION)) { + ESP_LOGE(TAG, "Rejecting downgrade: image version '%s' is older than running version '%s'", incoming.version, + ESPHOME_PROJECT_VERSION); + return OTA_RESPONSE_ERROR_VERSION_DOWNGRADE; + } +#endif err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { return OTA_RESPONSE_OK; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cdc26c9222..1d09bb5c5c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -239,6 +239,7 @@ #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_OTA_ROLLBACK #define USE_OTA_SIGNED_VERIFICATION +#define USE_OTA_DOWNGRADE_PROTECTION #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM diff --git a/esphome/espota2.py b/esphome/espota2.py index 266702c142..fa15c1dda2 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -52,6 +52,7 @@ RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90 RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91 RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92 +RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -157,6 +158,11 @@ _ERROR_MESSAGES: dict[int, str] = { "the bootloader update without rebooting the device. If the device " "fails to boot, recover it via a serial flash." ), + RESPONSE_ERROR_VERSION_DOWNGRADE: ( + "The device rejected the update because it has OTA downgrade protection " + "enabled: the new firmware's version must be newer than the version the " + "device is currently running." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index cea34bef7c..1b189c6331 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -13,6 +13,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, + _ota_downgrade_protection_errors, _reconcile_network_sdkconfig, ) from esphome.components.esp32.const import ( @@ -560,3 +561,35 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False # WiFi present alongside BT -> WiFi stack must stay enabled. assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig + + +def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None: + assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == [] + + +def test_downgrade_protection_accepts_calendar_version() -> None: + assert _ota_downgrade_protection_errors("2024.12.0", signed_ota_enabled=True) == [] + + +def test_downgrade_protection_requires_project_version() -> None: + errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=True) + assert len(errs) == 1 + assert "version" in str(errs[0]) + + +def test_downgrade_protection_rejects_non_numeric_version() -> None: + errs = _ota_downgrade_protection_errors("1.0-beta", signed_ota_enabled=True) + assert len(errs) == 1 + assert "dotted-numeric" in str(errs[0]) + + +def test_downgrade_protection_requires_signed_ota() -> None: + errs = _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=False) + assert len(errs) == 1 + assert "signed_ota_verification" in str(errs[0]) + + +def test_downgrade_protection_reports_all_unmet_requirements() -> None: + # No project version and no signing -> two distinct errors. + errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False) + assert len(errs) == 2 diff --git a/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml b/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml new file mode 100644 index 0000000000..5d6ab455ac --- /dev/null +++ b/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml @@ -0,0 +1,22 @@ +esphome: + project: + name: esphome.downgrade_test + version: "1.2.3" + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + enable_ota_downgrade_protection: true + signed_ota_verification: + signing_key: ../../components/esp32/dummy_signing_key.pem + signing_scheme: rsa3072 + +# wifi + ota so the IDF OTA backend compiles with USE_OTA_DOWNGRADE_PROTECTION. +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome diff --git a/tests/components/md5/__init__.py b/tests/components/md5/__init__.py new file mode 100644 index 0000000000..cf4ad47363 --- /dev/null +++ b/tests/components/md5/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # md5's to_code calls cg.add_define("USE_MD5"), which gates md5.h. C++ unit + # test builds that pull md5 in transitively (e.g. ota's host backend, which + # has an md5::MD5Digest member) need that define, otherwise md5.h compiles to + # nothing and the dependent headers fail to find md5::MD5Digest. + manifest.enable_codegen() diff --git a/tests/components/ota/test_version_compare.cpp b/tests/components/ota/test_version_compare.cpp new file mode 100644 index 0000000000..4072a45792 --- /dev/null +++ b/tests/components/ota/test_version_compare.cpp @@ -0,0 +1,52 @@ +#include + +#include "esphome/components/ota/ota_backend.h" + +namespace esphome::ota::testing { + +// version_is_older(candidate, reference) == true means candidate is a downgrade +// and should be rejected. + +TEST(VersionIsOlder, PatchOlder) { + EXPECT_TRUE(version_is_older("1.2.3", "1.2.4")); + EXPECT_FALSE(version_is_older("1.2.4", "1.2.3")); +} + +TEST(VersionIsOlder, NumericNotLexical) { + // "1.10.0" is newer than "1.9.0" even though '1' < '9' lexically. + EXPECT_TRUE(version_is_older("1.9.0", "1.10.0")); + EXPECT_FALSE(version_is_older("1.10.0", "1.9.0")); +} + +TEST(VersionIsOlder, MajorMinor) { + EXPECT_TRUE(version_is_older("1.9.9", "2.0.0")); + EXPECT_TRUE(version_is_older("1.2.9", "1.3.0")); + EXPECT_FALSE(version_is_older("2.0.0", "1.9.9")); +} + +TEST(VersionIsOlder, EqualVersionsAllowed) { + // Re-flashing the same version must be permitted. + EXPECT_FALSE(version_is_older("1.2.3", "1.2.3")); + EXPECT_FALSE(version_is_older("2024.1.0", "2024.1.0")); +} + +TEST(VersionIsOlder, DifferingComponentCounts) { + // Missing trailing components count as 0. + EXPECT_FALSE(version_is_older("1.2", "1.2.0")); + EXPECT_FALSE(version_is_older("1.2.0", "1.2")); + EXPECT_TRUE(version_is_older("1.2", "1.2.1")); + EXPECT_FALSE(version_is_older("1.2.1", "1.2")); +} + +TEST(VersionIsOlder, CalendarVersions) { + EXPECT_TRUE(version_is_older("2024.12.0", "2025.1.0")); + EXPECT_FALSE(version_is_older("2025.1.0", "2024.12.0")); +} + +TEST(VersionIsOlder, NullInputsAreSafe) { + EXPECT_FALSE(version_is_older(nullptr, "1.2.3")); + EXPECT_FALSE(version_is_older("1.2.3", nullptr)); + EXPECT_FALSE(version_is_older(nullptr, nullptr)); +} + +} // namespace esphome::ota::testing From a10e005bb44cebb933b3c5f0263006f950d8ff15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:54:44 -0500 Subject: [PATCH 2008/2214] [esp8266] Strip lwIP glue dhcp stub message strings from DRAM (#17395) --- esphome/components/esp8266/__init__.py | 6 ++++ .../components/esp8266/lwip_glue_stubs.cpp | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 esphome/components/esp8266/lwip_glue_stubs.cpp diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index b658feb76a..ab742db065 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -332,6 +332,12 @@ async def to_code(config): for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap the lwIP2 glue's do-nothing dhcp_cleanup()/dhcp_release() stubs so the + # linker can drop their "STUB: ..." message strings from DRAM. + # See lwip_glue_stubs.cpp for implementation. + for symbol in ("dhcp_cleanup", "dhcp_release"): + cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap Arduino's millis() so all callers (including Arduino libraries and ISR # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply # implementation in the Arduino ESP8266 core. diff --git a/esphome/components/esp8266/lwip_glue_stubs.cpp b/esphome/components/esp8266/lwip_glue_stubs.cpp new file mode 100644 index 0000000000..a86c8d75a2 --- /dev/null +++ b/esphome/components/esp8266/lwip_glue_stubs.cpp @@ -0,0 +1,35 @@ +/* + * Linker wrap stubs for the lwIP2 glue's dead DHCP entry points. + * + * The ESP8266 SDK blobs call dhcp_cleanup() and dhcp_release() when the + * station leaves an access point (cnx_sta_leave, wifi_station_dhcpc_stop). + * In the prebuilt lwIP2 glue (liblwip2-*.a, glue-esp/lwip-esp.c) these are + * stubs whose only effect is printing "STUB: dhcp_cleanup" and + * "STUB: dhcp_release"; the real DHCP teardown happens through lwIP2's + * renamed dhcp_cleanup_LWIP2()/dhcp_release_LWIP2() functions. + * + * On ESP8266 .rodata lives in DRAM, so those message strings waste scarce + * RAM. Wrapping the stubs with silent equivalents lets the linker garbage + * collect the glue stub bodies together with their strings. + * + * Saves 38 bytes of RAM and removes the "STUB:" log noise on Wi-Fi + * disconnect. Behavior is otherwise unchanged. + */ + +#if defined(USE_ESP8266) + +namespace esphome::esp8266 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +// The callers are closed-source SDK blobs; the netif argument is unused. +void __wrap_dhcp_cleanup(void * /*netif*/) {} + +// The glue stub returns ERR_ABRT (-8; lwIP 1.4 err_t is a signed char). +signed char __wrap_dhcp_release(void * /*netif*/) { return -8; } + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP8266 From 902cf6a67967ce8bf03ab90ca646712666778c30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:54:53 -0500 Subject: [PATCH 2009/2214] [analyze_memory] Report aliased RAM symbols once in the RAM strings report (#17397) --- esphome/analyze_memory/ram_strings.py | 44 +++++-- .../analyze_memory/test_ram_strings.py | 123 ++++++++++++++++++ 2 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/analyze_memory/test_ram_strings.py diff --git a/esphome/analyze_memory/ram_strings.py b/esphome/analyze_memory/ram_strings.py index fbcbeeca61..03da86de94 100644 --- a/esphome/analyze_memory/ram_strings.py +++ b/esphome/analyze_memory/ram_strings.py @@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266. from __future__ import annotations from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field import logging from pathlib import Path import re @@ -65,6 +65,7 @@ class RamSymbol: size: int section: str demangled: str = "" # Demangled name, set after batch demangling + aliases: list[str] = field(default_factory=list) # Other names at same address class RamStringsAnalyzer: @@ -235,6 +236,11 @@ class RamStringsAnalyzer: except (subprocess.CalledProcessError, FileNotFoundError): return + # Track symbols by address so aliases (multiple names for the same + # object, e.g. the newlib __lock___* mutexes that all alias one + # StaticSemaphore_t) are reported once instead of once per name. + symbols_by_addr: dict[int, RamSymbol] = {} + for line in output.split("\n"): parts = line.split() if len(parts) < 4: @@ -253,6 +259,18 @@ class RamStringsAnalyzer: if sym_type not in DATA_SYMBOL_TYPES: continue + if (existing := symbols_by_addr.get(addr)) is not None: + # Prefer a global (uppercase type) name as the primary so + # nm output order can't hide it behind a local alias. + if sym_type.isupper() and existing.sym_type.islower(): + existing.aliases.append(existing.name) + existing.name = name + existing.sym_type = sym_type + else: + existing.aliases.append(name) + existing.size = max(existing.size, size) + continue + # Check if symbol is in a RAM section for section_name in self.ram_sections: if section_name not in self.sections: @@ -260,15 +278,15 @@ class RamStringsAnalyzer: section = self.sections[section_name] if section.address <= addr < section.address + section.size: - self.ram_symbols.append( - RamSymbol( - name=name, - sym_type=sym_type, - address=addr, - size=size, - section=section_name, - ) + symbol = RamSymbol( + name=name, + sym_type=sym_type, + address=addr, + size=size, + section=section_name, ) + symbols_by_addr[addr] = symbol + self.ram_symbols.append(symbol) break def _demangle_symbols(self) -> None: @@ -436,7 +454,13 @@ class RamStringsAnalyzer: for symbol in largest_symbols: # Use demangled name if available, otherwise raw name display_name = symbol.demangled or symbol.name - name_display = display_name[:49] if len(display_name) > 49 else display_name + # Truncate the name, not the alias note, so merged aliases stay + # visible even for long demangled C++ names. + alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else "" + max_name_len = 49 - len(alias_note) + if len(display_name) > max_name_len: + display_name = display_name[:max_name_len] + name_display = display_name + alias_note lines.append( f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}" ) diff --git a/tests/unit_tests/analyze_memory/test_ram_strings.py b/tests/unit_tests/analyze_memory/test_ram_strings.py new file mode 100644 index 0000000000..dda793a7f1 --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_ram_strings.py @@ -0,0 +1,123 @@ +"""Tests for RAM symbol analysis in the RAM strings analyzer.""" + +from pathlib import Path +from unittest.mock import patch + +from esphome.analyze_memory.ram_strings import RamStringsAnalyzer, SectionInfo + +# nm -S --size-sort output with the newlib lock mutexes: nine global +# symbols that are all aliases of two local StaticSemaphore_t objects. +NM_OUTPUT_WITH_ALIASES = """\ +3ffb4400 00000010 B small_symbol +3ffb43c8 00000054 B __lock___atexit_recursive_mutex +3ffb43c8 00000054 B __lock___env_recursive_mutex +3ffb43c8 00000054 B __lock___malloc_recursive_mutex +3ffb43c8 00000054 B __lock___sfp_recursive_mutex +3ffb43c8 00000054 B __lock___sinit_recursive_mutex +3ffb43c8 00000054 b s_common_recursive_mutex +3ffb441c 00000054 B __lock___arc4random_mutex +3ffb441c 00000054 B __lock___at_quick_exit_mutex +3ffb441c 00000054 B __lock___dd_hash_mutex +3ffb441c 00000054 B __lock___tz_mutex +3ffb441c 00000054 b s_common_mutex +""" + + +def _make_analyzer(tmp_path) -> RamStringsAnalyzer: + """Create an analyzer with a dummy ELF and a .dram0.bss section.""" + elf = tmp_path / "firmware.elf" + elf.write_bytes(b"\x7fELF") + analyzer = RamStringsAnalyzer(str(elf), platform="esp32") + analyzer.sections[".dram0.bss"] = SectionInfo(".dram0.bss", 0x3FFB0000, 0x10000) + return analyzer + + +def _run_symbol_analysis(analyzer: RamStringsAnalyzer, nm_output: str) -> None: + """Run _analyze_symbols with mocked nm output.""" + with ( + patch( + "esphome.analyze_memory.ram_strings.find_tool", + return_value="nm", + ), + patch.object(analyzer, "_run_command", return_value=nm_output), + ): + analyzer._analyze_symbols() + + +def test_aliased_symbols_counted_once(tmp_path: Path) -> None: + """Symbols sharing an address are one object, not one per name.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + # Three distinct addresses, so three symbols + assert len(analyzer.ram_symbols) == 3 + total = sum(s.size for s in analyzer.ram_symbols) + assert total == 0x10 + 0x54 + 0x54 + + +def test_aliases_recorded_on_first_symbol(tmp_path: Path) -> None: + """Extra names at the same address are kept as aliases.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + by_addr = {s.address: s for s in analyzer.ram_symbols} + assert len(by_addr[0x3FFB43C8].aliases) == 5 + assert len(by_addr[0x3FFB441C].aliases) == 4 + assert by_addr[0x3FFB4400].aliases == [] + assert "s_common_mutex" in by_addr[0x3FFB441C].aliases + + +def test_alias_count_shown_in_report(tmp_path: Path) -> None: + """The large symbols table notes how many aliases were merged.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + report = analyzer.generate_report() + assert "(+5 aliases)" in report + assert "(+4 aliases)" in report + # Each lock name appears at most once in the report + assert report.count("__lock___") == 2 + + +def test_global_name_preferred_over_local_alias(tmp_path: Path) -> None: + """A global name becomes the primary even when nm lists a local first.""" + analyzer = _make_analyzer(tmp_path) + nm_output = """\ +3ffb43c8 00000054 b s_common_recursive_mutex +3ffb43c8 00000054 B __lock___atexit_recursive_mutex +3ffb43c8 00000054 B __lock___malloc_recursive_mutex +""" + _run_symbol_analysis(analyzer, nm_output) + + (symbol,) = analyzer.ram_symbols + assert symbol.name == "__lock___atexit_recursive_mutex" + assert symbol.sym_type == "B" + assert sorted(symbol.aliases) == [ + "__lock___malloc_recursive_mutex", + "s_common_recursive_mutex", + ] + + +def test_alias_note_survives_name_truncation(tmp_path: Path) -> None: + """Long names are truncated but the alias note is kept intact.""" + analyzer = _make_analyzer(tmp_path) + long_name = "a_very_long_symbol_name_that_exceeds_the_column_width_by_far" + nm_output = f"""\ +3ffb43c8 00000054 B {long_name} +3ffb43c8 00000054 B other_name +""" + _run_symbol_analysis(analyzer, nm_output) + + report = analyzer.generate_report() + row = next(line for line in report.splitlines() if "(+1 aliases)" in line) + name_column = row[:50].rstrip() + assert name_column.endswith("(+1 aliases)") + assert name_column.startswith("a_very_long_symbol_name") + + +def test_symbols_outside_ram_sections_skipped(tmp_path: Path) -> None: + """Symbols outside known RAM sections are ignored entirely.""" + analyzer = _make_analyzer(tmp_path) + nm_output = "40080000 00000100 B not_in_ram\n" + _run_symbol_analysis(analyzer, nm_output) + assert analyzer.ram_symbols == [] From a36c3063b2c8e302092f4440070b7d194e5f8c4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:55:02 -0500 Subject: [PATCH 2010/2214] [web_server] Use known message length in SSE send path (#17400) --- esphome/components/web_server/web_server.cpp | 23 +++++++------- esphome/components/web_server/web_server.h | 6 ++-- .../web_server_idf/web_server_idf.cpp | 30 +++++++++---------- .../web_server_idf/web_server_idf.h | 6 ++-- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index cdb8544fbb..96195a8270 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -257,8 +257,10 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * } // used for logs plus the initial ping/config -void DeferredUpdateEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, - uint32_t reconnect) { +void DeferredUpdateEventSource::try_send_nodefer(const char *message, size_t message_len, const char *event, + uint32_t id, uint32_t reconnect) { + // ESPAsyncWebServer's send() only accepts null-terminated strings + (void) message_len; this->send(message, event, id, reconnect); } @@ -279,10 +281,10 @@ void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const ch } } -void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, const char *event, uint32_t id, - uint32_t reconnect) { +void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, size_t message_len, const char *event, + uint32_t id, uint32_t reconnect) { for (DeferredUpdateEventSource *dues : *this) { - dues->try_send_nodefer(message, event, id, reconnect); + dues->try_send_nodefer(message, message_len, event, id, reconnect); } } @@ -304,7 +306,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource // Configure reconnect timeout and send config // this should always go through since the AsyncEventSourceClient event queue is empty on connect auto message = ws->get_config_json(); - source->try_send_nodefer(message.c_str(), "ping", millis(), 30000); + source->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -315,7 +317,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource auto group_msg = builder.serialize(); // up to 31 groups should be able to be queued initially without defer - source->try_send_nodefer(group_msg.c_str(), "sorting_group"); + source->try_send_nodefer(group_msg.c_str(), group_msg.size(), "sorting_group"); } #endif @@ -395,8 +397,8 @@ void WebServer::setup() { return; char buf[32]; auto uptime = static_cast(millis_64() / 1000); - buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); - this->events_.try_send_nodefer(buf, "ping", millis(), 30000); + size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); + this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); } void WebServer::loop() { @@ -414,8 +416,7 @@ void WebServer::loop() { void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; - (void) message_len; - this->events_.try_send_nodefer(message, "log", millis()); + this->events_.try_send_nodefer(message, message_len, "log", millis()); } #endif diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index e4defdbd9a..42182fe510 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -160,7 +160,8 @@ class DeferredUpdateEventSource final : public AsyncEventSource { void loop(); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); - void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); }; class DeferredUpdateEventSourceList final : public std::list { @@ -173,7 +174,8 @@ class DeferredUpdateEventSourceList final : public std::listsessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions - ses->try_send_nodefer(message, event, id, reconnect); + ses->try_send_nodefer(message, message_len, event, id, reconnect); } } } @@ -600,7 +601,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { // tcp send buffer is empty on connect, so these should always go through auto message = ws->get_config_json(); - this->try_send_nodefer(message.c_str(), "ping", millis(), 30000); + this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -612,7 +613,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { // a (very) large number of these should be able to be queued initially without defer // since the only thing in the send buffer at this point is the initial ping/config - this->try_send_nodefer(message.c_str(), "sorting_group"); + this->try_send_nodefer(message.c_str(), message.size(), "sorting_group"); } #endif @@ -647,7 +648,7 @@ void AsyncEventSourceResponse::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); auto message = de.message_generator_(web_server_, de.source_); - if (this->try_send_nodefer(message.c_str(), "state")) { + if (this->try_send_nodefer(message.c_str(), message.size(), "state")) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); } else { @@ -718,7 +719,7 @@ void AsyncEventSourceResponse::loop() { this->entities_iterator_.advance(); } -bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char *event, uint32_t id, +bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id, uint32_t reconnect) { if (this->fd_.load() == 0) { return false; @@ -764,19 +765,18 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char // Fast path: check if message contains any newlines at all // Most SSE messages (JSON state updates) have no newlines - const char *first_n = strchr(message, '\n'); - const char *first_r = strchr(message, '\r'); + const char *first_n = static_cast(memchr(message, '\n', message_len)); + const char *first_r = static_cast(memchr(message, '\r', message_len)); if (first_n == nullptr && first_r == nullptr) { // No newlines - fast path (most common case) event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(message); + event_buffer_.append(message, message_len); event_buffer_.append(CRLF_STR CRLF_STR, CRLF_LEN * 2); // data line + blank line terminator } else { // Has newlines - handle multi-line message const char *line_start = message; - size_t msg_len = strlen(message); - const char *msg_end = message + msg_len; + const char *msg_end = message + message_len; // Reuse the first search results const char *next_n = first_n; @@ -789,7 +789,7 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char if (next_n == nullptr && next_r == nullptr) { // No more line breaks - output remaining text as final line event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(line_start); + event_buffer_.append(line_start, msg_end - line_start); event_buffer_.append(CRLF_STR, CRLF_LEN); break; } @@ -828,8 +828,8 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char } // Search for next newlines only in remaining string - next_n = strchr(line_start, '\n'); - next_r = strchr(line_start, '\r'); + next_n = static_cast(memchr(line_start, '\n', msg_end - line_start)); + next_r = static_cast(memchr(line_start, '\r', msg_end - line_start)); } // Terminate message with blank line @@ -884,7 +884,7 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e deq_push_back_with_dedup_(source, message_generator); } else { auto message = message_generator(web_server_, source); - if (!this->try_send_nodefer(message.c_str(), "state")) { + if (!this->try_send_nodefer(message.c_str(), message.size(), "state")) { deq_push_back_with_dedup_(source, message_generator); } } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index c622d53e89..c631cd1453 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -291,7 +291,8 @@ class AsyncEventSourceResponse { friend class AsyncEventSource; public: - bool try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + bool try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); void loop(); @@ -343,7 +344,8 @@ class AsyncEventSource : public AsyncWebHandler { // NOLINTNEXTLINE(readability-identifier-naming) void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } - void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); /// Returns true if there are sessions remaining (including pending cleanup). bool loop(); From e8d37e5bd362bb49710dd90485b45200b6efa31c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:55:12 -0500 Subject: [PATCH 2011/2214] [libretiny] Use standard logger tag names (#17431) --- esphome/components/libretiny/gpio_arduino.cpp | 2 +- esphome/components/libretiny/lt_component.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index 1af0dce16d..b1a37cb225 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -5,7 +5,7 @@ namespace esphome::libretiny { -static const char *const TAG = "lt.gpio"; +static const char *const TAG = "libretiny.gpio"; static int IRAM_ATTR flags_to_mode(gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index c01661b3a6..9bbbd66be4 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -6,7 +6,7 @@ namespace esphome::libretiny { -static const char *const TAG = "lt.component"; +static const char *const TAG = "libretiny"; void LTComponent::dump_config() { ESP_LOGCONFIG(TAG, From e62c6ce81ed4d7cffc7e9d4d6068c793f5684e9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 21:03:07 -0500 Subject: [PATCH 2012/2214] [network] Fix logged use_address with MAC suffix and build it at runtime --- esphome/components/api/api_server.cpp | 3 +- .../components/esphome/ota/ota_esphome.cpp | 3 +- esphome/components/ethernet/__init__.py | 4 +- .../components/ethernet/ethernet_component.h | 4 +- esphome/components/network/__init__.py | 13 ++++ esphome/components/network/util.cpp | 23 ++++++ esphome/components/network/util.h | 31 ++------ esphome/components/openthread/__init__.py | 3 +- esphome/components/openthread/openthread.h | 4 +- esphome/components/web_server/web_server.cpp | 3 +- esphome/components/wifi/__init__.py | 3 +- esphome/components/wifi/wifi_component.h | 4 +- .../fixtures/use_address_runtime.yaml | 8 ++ .../use_address_runtime_mac_suffix.yaml | 9 +++ tests/integration/test_use_address_runtime.py | 73 +++++++++++++++++++ 15 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 tests/integration/fixtures/use_address_runtime.yaml create mode 100644 tests/integration/fixtures/use_address_runtime_mac_suffix.yaml create mode 100644 tests/integration/test_use_address_runtime.py diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ddd03ace4a..efdeb6991b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -240,12 +240,13 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { } void APIServer::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Server:\n" " Address: %s:%u\n" " Listen backlog: %u\n" " Max connections: %u", - network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); + network::get_use_address_to(addr_buf), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); if (!this->noise_ctx_.has_psk()) { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index db4a2015a7..cab725f704 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -94,11 +94,12 @@ void ESPHomeOTAComponent::setup() { } void ESPHomeOTAComponent::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Over-The-Air updates:\n" " Address: %s:%u\n" " Version: %d", - network::get_use_address(), this->port_, USE_OTA_VERSION); + network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION); #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { ESP_LOGCONFIG(TAG, " Password configured"); diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index dc4cbda45c..03fba7164d 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -4,7 +4,7 @@ import logging from esphome import automation, pins from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.network import ip_address_literal +from esphome.components.network import add_use_address, ip_address_literal from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -543,7 +543,7 @@ async def to_code(config): await _to_code_rp2040(var, config) cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(var, config[CONF_USE_ADDRESS]) # enable_on_boot defaults to true in C++ - only set if false if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 16f09a45f0..7160351727 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -145,6 +145,8 @@ class EthernetComponent final : public Component { network::IPAddresses get_ip_addresses(); network::IPAddress get_dns_address(uint8_t num); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } void get_eth_mac_address_raw(uint8_t *mac); @@ -346,7 +348,7 @@ class EthernetComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 616a189226..b7dfb8d6d2 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -59,6 +59,19 @@ def ip_address_literal(ip: str | int | None) -> cg.MockObj: return IPAddress(str(ip)) +def add_use_address(var: cg.MockObj, use_address: str) -> None: + """Generate a set_use_address() call only when the address must be baked in. + + The default ".local" is not stored in the firmware; it is rebuilt at + runtime from the device name (see network::get_use_address_to()), which also + picks up the MAC suffix when name_add_mac_suffix is enabled. A compile-time + string could never include that suffix, so baking it in would log the wrong + address. + """ + if use_address != f"{CORE.name}.local": + cg.add(var.set_use_address(use_address)) + + def require_high_performance_networking() -> None: """Request high performance networking for network and WiFi. diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 79ddd3844c..ae250c6a1f 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -1,5 +1,7 @@ #include "util.h" +#include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_NETWORK namespace esphome::network { @@ -20,6 +22,27 @@ bool is_disabled() { return false; } +const char *get_use_address_to(std::span buf) { + // Global component pointers are guaranteed to be set by component constructors when USE_* is defined + const char *addr = nullptr; +#if defined(USE_ETHERNET) + addr = ethernet::global_eth_component->get_use_address(); +#elif defined(USE_MODEM) + addr = modem::global_modem_component->get_use_address(); +#elif defined(USE_WIFI) + addr = wifi::global_wifi_component->get_use_address(); +#elif defined(USE_OPENTHREAD) + addr = openthread::global_openthread_component->get_use_address(); +#endif + if (addr != nullptr && addr[0] != '\0') + return addr; + // No explicit use_address configured: the address is the runtime device name + // (which includes the MAC suffix when name_add_mac_suffix is enabled) plus ".local" + const auto &name = App.get_name(); + make_name_with_suffix_to(buf.data(), buf.size(), name.c_str(), name.size(), '.', "local", 5); + return buf.data(); +} + network::IPAddresses get_ip_addresses() { #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index e4e8a01f8c..17a2ff0977 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_NETWORK +#include #include #include "esphome/core/helpers.h" #include "ip_address.h" @@ -53,30 +54,12 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { /// Return whether the network is disabled (only wifi for now) bool is_disabled(); -/// Get the active network hostname -ESPHOME_ALWAYS_INLINE inline const char *get_use_address() { - // Global component pointers are guaranteed to be set by component constructors when USE_* is defined -#ifdef USE_ETHERNET - return ethernet::global_eth_component->get_use_address(); -#endif - -#ifdef USE_MODEM - return modem::global_modem_component->get_use_address(); -#endif - -#ifdef USE_WIFI - return wifi::global_wifi_component->get_use_address(); -#endif - -#ifdef USE_OPENTHREAD - return openthread::global_openthread_component->get_use_address(); -#endif - -#if !defined(USE_ETHERNET) && !defined(USE_MODEM) && !defined(USE_WIFI) && !defined(USE_OPENTHREAD) - // Fallback when no network component is defined (e.g., host platform) - return ""; -#endif -} +/// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator +static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70; +/// Get the active network address for logging. Returns the explicitly configured +/// use_address when one was set, otherwise formats ".local" from the runtime +/// device name into buf (so it includes the MAC suffix from name_add_mac_suffix). +const char *get_use_address_to(std::span buf); IPAddresses get_ip_addresses(); } // namespace esphome::network diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index b54fe2b218..4018ad81e7 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage +from esphome.components.network import add_use_address from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -288,7 +289,7 @@ async def to_code(config): enable_mdns_storage() ot = cg.new_Pvariable(config[CONF_ID]) - cg.add(ot.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(ot, config[CONF_USE_ADDRESS]) await cg.register_component(ot, config) if (poll_period := config.get(CONF_POLL_PERIOD)) is not None: cg.add(ot.set_poll_period(poll_period)) diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index eb48d8a74a..b4654af21f 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -39,6 +39,8 @@ class OpenThreadComponent final : public Component { void on_factory_reset(std::function callback); void defer_factory_reset_external_callback(); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } #if CONFIG_OPENTHREAD_MTD @@ -76,7 +78,7 @@ class OpenThreadComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 96195a8270..c8f66755bc 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -421,10 +421,11 @@ void WebServer::on_log(uint8_t level, const char *tag, const char *message, size #endif void WebServer::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Web Server:\n" " Address: %s:%u", - network::get_use_address(), this->base_->get_port()); + network::get_use_address_to(addr_buf), this->base_->get_port()); } float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f; } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index af600647c1..dc5c8be4d7 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( request_wifi, ) from esphome.components.network import ( + add_use_address, has_high_performance_networking, ip_address_literal, ) @@ -585,7 +586,7 @@ def wifi_network(config, ap, static_ip): @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(var, config[CONF_USE_ADDRESS]) # Track if any network uses Enterprise authentication has_eap = False diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 0db85c4d75..23b7558564 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -501,6 +501,8 @@ class WiFiComponent final : public Component { network::IPAddress get_dns_address(int num); network::IPAddresses get_ip_addresses(); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } @@ -996,7 +998,7 @@ class WiFiComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/tests/integration/fixtures/use_address_runtime.yaml b/tests/integration/fixtures/use_address_runtime.yaml new file mode 100644 index 0000000000..29f3369285 --- /dev/null +++ b/tests/integration/fixtures/use_address_runtime.yaml @@ -0,0 +1,8 @@ +esphome: + name: use-address-runtime + +host: + +api: + +logger: diff --git a/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml b/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml new file mode 100644 index 0000000000..9785724cd5 --- /dev/null +++ b/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml @@ -0,0 +1,9 @@ +esphome: + name: use-address-mac + name_add_mac_suffix: true + +host: + +api: + +logger: diff --git a/tests/integration/test_use_address_runtime.py b/tests/integration/test_use_address_runtime.py new file mode 100644 index 0000000000..a4cbbb9c5f --- /dev/null +++ b/tests/integration/test_use_address_runtime.py @@ -0,0 +1,73 @@ +"""Integration tests for the runtime-built use_address. + +The default ".local" address is no longer stored as a compile-time string; +it is built at runtime from the device name. This also fixes the logged address +when name_add_mac_suffix is enabled: the baked string used to miss the MAC +suffix, so it never matched the actual mDNS hostname. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" +MAC_SUFFIX = "abf679" + + +@pytest.mark.asyncio +async def test_use_address_runtime( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The API dump_config logs ".local" built from the device name.""" + address_seen = asyncio.Event() + + def check_output(line: str) -> None: + if "Address: use-address-runtime.local:" in line: + address_seen.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "use-address-runtime" + + try: + await asyncio.wait_for(address_seen.wait(), timeout=10.0) + except TimeoutError: + pytest.fail("Did not log 'Address: use-address-runtime.local:'") + + +@pytest.mark.asyncio +async def test_use_address_runtime_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With name_add_mac_suffix the logged address includes the MAC suffix.""" + address_seen = asyncio.Event() + expected = f"Address: use-address-mac-{MAC_SUFFIX}.local:" + + def check_output(line: str) -> None: + if expected in line: + address_seen.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == f"use-address-mac-{MAC_SUFFIX}" + + try: + await asyncio.wait_for(address_seen.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Did not log '{expected}'") From ad7c980c4b46b1464f68bea405e94b412f02bd2c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:16:29 +1000 Subject: [PATCH 2013/2214] [lvgl] Continue activity while display busy (#17374) --- esphome/components/lvgl/__init__.py | 3 ++ esphome/components/lvgl/defines.py | 1 + esphome/components/lvgl/lvgl_esphome.cpp | 60 ++++++++++++++--------- esphome/components/lvgl/lvgl_esphome.h | 20 +++++++- tests/components/lvgl/lvgl-package.yaml | 16 +----- tests/components/lvgl/test.esp32-idf.yaml | 5 +- 6 files changed, 64 insertions(+), 41 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 9137412abe..08369927b9 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -414,6 +414,8 @@ async def to_code(configs): await cg.register_component(lv_component, config) if rotation := config.get(CONF_ROTATION): cg.add(lv_component.set_rotation(rotation)) + if refr_time := config.get(df.CONF_REFRESH_INTERVAL): + cg.add(lv_component.set_refresh_interval(refr_time.total_milliseconds)) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) lv_scr_act = get_screen_active(lv_component) @@ -598,6 +600,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(df.CONF_REFRESH_INTERVAL): cv.positive_time_period_milliseconds, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, cv.Optional(CONF_ROTATION): validate_rotation, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index d9be881a7f..53499503d4 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -764,6 +764,7 @@ CONF_PLACEHOLDER_TEXT = "placeholder_text" CONF_POINTS = "points" CONF_PREVIOUS = "previous" CONF_RADIUS = "radius" +CONF_REFRESH_INTERVAL = "refresh_interval" CONF_REPEAT_COUNT = "repeat_count" CONF_RECOLOR = "recolor" CONF_RESUME_ON_INPUT = "resume_on_input" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 15c2d238be..1db5992389 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -401,7 +401,10 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { } void LvglComponent::flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p) { - if (!this->is_paused()) { + // no guard here for display busy, since LVGL will not call flush_cb until the refresh timer fires, + // and while the display is busy this is reset to 5 minutes. If that expires and the display is still + // busy there are bigger problems. + if (!this->paused_) { auto now = millis(); this->draw_buffer_(area, reinterpret_cast(color_p)); ESP_LOGV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", (int) area->x1, (int) area->y1, @@ -620,20 +623,20 @@ void LvKeyboardType::set_obj(lv_obj_t *lv_obj) { void LvglComponent::draw_end_() { if (this->draw_end_callback_ != nullptr) this->draw_end_callback_->trigger(); + // Only reachable once the display is idle again: while busy, the display's refr_timer_ is + // paused (see loop()), so LVGL never renders/flushes and this event never fires. if (this->update_when_display_idle_) { for (auto *disp : this->displays_) disp->update(); } } -bool LvglComponent::is_paused() const { - if (this->paused_) - return true; - if (this->update_when_display_idle_) { - for (auto *disp : this->displays_) { - if (!disp->is_idle()) - return true; - } +bool LvglComponent::displays_busy_() const { + if (!this->update_when_display_idle_) + return false; + for (auto *disp : this->displays_) { + if (!disp->is_idle()) + return true; } return false; } @@ -777,6 +780,8 @@ void LvglComponent::setup() { if (this->draw_end_callback_ != nullptr || this->update_when_display_idle_) { lv_display_add_event_cb(this->disp_, render_end_cb, LV_EVENT_REFR_READY, this); } + this->refr_timer_ = lv_display_get_refr_timer(this->disp_); + lv_timer_set_period(this->refr_timer_, this->refr_timer_period_); #if LV_USE_LOG lv_log_register_print_cb([](lv_log_level_t level, const char *buf) { auto next = strchr(buf, ')'); @@ -802,21 +807,32 @@ void LvglComponent::update() { } void LvglComponent::loop() { - if (this->is_paused()) { - if (this->paused_ && this->show_snow_) + if (this->paused_) { + if (this->show_snow_) this->write_random_(); - } else { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - auto now = millis(); - lv_timer_handler(); - auto elapsed = millis() - now; - if (elapsed > 15) { - ESP_LOGV(TAG, "lv_timer_handler took %dms", (int) (millis() - now)); - } -#else - lv_timer_handler(); -#endif + return; } + // Pause/resume the display's own refresh timer to track its busy state. While paused, LVGL + // still keeps track of invalidated areas but won't render or flush them, so nothing needs to + // be discarded or replayed: once resumed, the accumulated areas are simply drawn as normal. + // Input events and other timers keep being processed below regardless of this state. + if (this->update_when_display_idle_) { + bool busy = this->displays_busy_(); + if (busy && !this->refr_timer_paused_) { + this->refr_timer_paused_ = true; + // calling lv_timer_pause() here would be ineffective; LVGL pauses and resumes the timer based on its own internal + // state, which is not aware of the display's busy state. Instead, we extend the timer period to avoid it firing + // while the display is busy. + lv_timer_set_period(this->refr_timer_, 5 * 60 * 1000); + } else if (!busy && this->refr_timer_paused_) { + this->refr_timer_paused_ = false; + lv_timer_set_period(this->refr_timer_, this->refr_timer_period_); + // Don't wait for the timer's next natural period: refresh right away now that the + // display is idle again. + lv_timer_ready(this->refr_timer_); + } + } + lv_timer_handler(); } #ifdef USE_LVGL_ANIMIMG diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8840b0ad30..dcbf490bce 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -214,9 +214,14 @@ class LvglComponent final : public PollingComponent { // @param paused If true, pause the display. If false, resume the display. // @param show_snow If true, show the snow effect when paused. void set_paused(bool paused, bool show_snow); + void set_refresh_interval(uint32_t period) { + this->refr_timer_period_ = period; + if (this->refr_timer_ != nullptr) + lv_timer_set_period(this->refr_timer_, period); + } - // Returns true if the display is explicitly paused, or a blocking display update is in progress. - bool is_paused() const; + // Returns true if the display has been explicitly paused via set_paused(). + bool is_paused() const { return this->paused_; } // If the display is paused and we have resume_on_input_ set to true, resume the display. void maybe_wakeup() { if (this->paused_ && this->resume_on_input_) { @@ -299,6 +304,9 @@ class LvglComponent final : public PollingComponent { // Not checking for non-null callback since the // LVGL callback that calls it is not set in that case void draw_start_() const { this->draw_start_callback_->trigger(); } + // Returns true if update_when_display_idle is enabled and at least one underlying display + // component is currently busy (e.g. mid-refresh). + bool displays_busy_() const; void write_random_(); void draw_buffer_(const lv_area_t *area, lv_color_data *ptr); @@ -316,6 +324,14 @@ class LvglComponent final : public PollingComponent { uint8_t *draw_buf_{}; lv_display_t *disp_{}; + // The display's own periodic refresh timer, effectively paused while the display is busy (see + // displays_busy_()) so LVGL neither renders nor flushes to it, without losing track of + // invalidated areas. Other timers (indev reading, animations, ...) keep running as normal. + lv_timer_t *refr_timer_{}; + // Tracks whether refr_timer_ is currently paused, so loop() can detect the busy -> idle edge + // and kick off an immediate refresh instead of waiting for the timer's next natural period. + bool refr_timer_paused_{}; + uint32_t refr_timer_period_{16}; uint16_t width_{}; uint16_t height_{}; bool paused_{}; diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 7af058e6b8..4f043db7cb 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -24,20 +24,6 @@ binary_sensor: name: Button A checked widget: button_a state: checked - - platform: lvgl - id: button_checker - name: LVGL button - widget: button_button - state: checked - on_state: - then: - - lvgl.checkbox.update: - id: checkbox_id - state: - checked: !lambda |- - auto y = x; // block inlining of one line return - return y; - - platform: lvgl id: button_presser name: Button pressed @@ -49,6 +35,8 @@ lvgl: rotation: 90 log_level: debug resume_on_input: true + update_when_display_idle: true + refresh_interval: 30ms on_pause: - logger.log: LVGL is Paused - lvgl.display.set_rotation: 90 diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index 79ea06f16a..d938017fd9 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -1,7 +1,8 @@ packages: - lvgl: !include lvgl-package.yaml + lvgl_package: !include lvgl-package.yaml spi: !include ../../test_build_components/common/spi/esp32-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lvgl: !include common.yaml sensor: - platform: rotary_encoder @@ -77,5 +78,3 @@ lvgl: - component.update: tft_display - delay: 60s - lvgl.resume: - -<<: !include common.yaml From 9857d508d95efb7403e882cfccd7fc6053ce4e7e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:57:06 +1000 Subject: [PATCH 2014/2214] [light] Preserve brightness on turn-off. (#17103) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/light/light_call.cpp | 18 ++++++----- tests/integration/test_light_calls.py | 43 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 7b28065e4e..2b13b40a16 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -213,17 +213,19 @@ LightColorValues LightCall::validate_() { // Flag whether an explicit turn off was requested, in which case we'll also stop the effect. bool explicit_turn_off_request = this->has_state() && !this->state_; - // Turn off when brightness is set to zero, and reset brightness (so that it has nonzero brightness when turned on). - if (this->has_brightness() && this->brightness_ == 0.0f) { + // Treat zero brightness as an implicit turn-off when no state was explicitly requested. + if (this->has_brightness() && this->brightness_ == 0.0f && !this->has_state()) { this->state_ = false; this->set_flag_(FLAG_HAS_STATE); - if (color_mode & ColorCapability::BRIGHTNESS) { - // Reset brightness so the light has nonzero brightness when turned back on. + } + + // Make sure a turn-on makes the light visible: if the resulting brightness would be zero + // (e.g. restored from a brightness=0 turn-off), reset it to full brightness. + if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) { + float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness(); + if (brightness == 0.0f) { this->brightness_ = 1.0f; - } else { - // Light doesn't support brightness; clear the flag to avoid a spurious - // "brightness not supported" warning during capability validation. - this->clear_flag_(FLAG_HAS_BRIGHTNESS); + this->set_flag_(FLAG_HAS_BRIGHTNESS); } } diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 0eaf5af91b..a3a4103f5c 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -322,6 +322,49 @@ async def test_light_calls( assert state.state is True assert state.brightness == pytest.approx(0.75) + # Test 31: Setting brightness to 0 without an explicit state implicitly turns + # the light off; turning it back on (without an explicit brightness) then + # restores full brightness so the light is visible again. + client.light_command(key=rgbcw_light.key, state=True, brightness=0.5) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(0.5) + + # Brightness 0 with no explicit state -> implicit turn-off + client.light_command(key=rgbcw_light.key, brightness=0.0) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is False + assert state.brightness == pytest.approx(0.0) + # Turning on without an explicit brightness restores it to full brightness + client.light_command(key=rgbcw_light.key, state=True) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(1.0) + + # Test 31b: An explicit turn-on with brightness 0 still resets to full + # brightness - a turn-on must never leave the light on-but-invisible. This + # is the same path the restore logic exercises (set_state(true) + + # set_brightness(0) from a persisted brightness=0 turn-off). + client.light_command(key=rgbcw_light.key, state=True, brightness=0.0) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(1.0) + + # Test 32: Turning a light on when it already has nonzero brightness leaves + # the brightness unchanged (the reset only happens when brightness is 0). + client.light_command(key=rgbcw_light.key, state=True, brightness=0.4) + state = await wait_for_state_change(rgbcw_light.key) + assert state.brightness == pytest.approx(0.4) + + client.light_command(key=rgbcw_light.key, state=False) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is False + + client.light_command(key=rgbcw_light.key, state=True) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(0.4) + # Final cleanup - turn all lights off for light in lights: client.light_command( From 9aed1d2700681390cfe0df5301cb82f4744faaff Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 6 Jul 2026 23:04:38 -0500 Subject: [PATCH 2015/2214] [esp32] Add NVS encryption (HMAC scheme) (#17004) --- esphome/components/esp32/__init__.py | 62 +++++++++++++++++++ .../esp32/config/nvs_encryption_s3.yaml | 10 +++ tests/component_tests/esp32/test_esp32.py | 38 ++++++++++++ .../test-nvs_encryption.esp32-s3-idf.yaml | 9 +++ 4 files changed, 119 insertions(+) create mode 100644 tests/component_tests/esp32/config/nvs_encryption_s3.yaml create mode 100644 tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5a7ddb6c76..e8d1fe73c7 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -109,7 +109,9 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" +CONF_NVS_ENCRYPTION = "nvs_encryption" CONF_RELEASE = "release" CONF_SIGNED_OTA_VERIFICATION = "signed_ota_verification" CONF_SIGNING_KEY = "signing_key" @@ -167,6 +169,20 @@ SIGNED_OTA_V1_ECDSA_VARIANTS = { VARIANT_ESP32, } +# NVS encryption (HMAC peripheral scheme) is only available on variants that +# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original +# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral +# should be added here. +NVS_ENCRYPTION_HMAC_VARIANTS = { + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32P4, +} + COMPILER_OPTIMIZATIONS = { "DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG", "NONE": "CONFIG_COMPILER_OPTIMIZATION_NONE", @@ -1349,6 +1365,29 @@ def final_validate(config): "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) + if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None: + variant = config[CONF_VARIANT] + if variant in NVS_ENCRYPTION_HMAC_VARIANTS: + _LOGGER.warning( + "NVS encryption will burn an HMAC key into eFuse key block %d on the " + "first boot of each device. This is PERMANENT and IRREVERSIBLE: " + "the block cannot be erased or reused afterwards. Enabling (or " + "later disabling) encryption also wipes any previously saved " + "preferences once, because the older data can no longer be read.", + nvs_enc[CONF_KEY_ID], + ) + else: + supported = ", ".join( + sorted(VARIANT_FRIENDLY[v] for v in NVS_ENCRYPTION_HMAC_VARIANTS) + ) + errs.append( + cv.Invalid( + f"NVS encryption (HMAC scheme) is not supported on " + f"{VARIANT_FRIENDLY[variant]} (it has no HMAC peripheral). " + f"Supported variants: {supported}.", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_ENCRYPTION], + ) + ) if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: project = full_config[CONF_ESPHOME].get(CONF_PROJECT) errs.extend( @@ -1609,6 +1648,15 @@ FRAMEWORK_SCHEMA = cv.Schema( ), cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), ), + cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema( + { + # eFuse key block (0-5) that stores the HMAC key from + # which the NVS encryption keys are derived. The block is + # written on first boot if empty -- an irreversible + # operation -- so it must be chosen explicitly. + cv.Required(CONF_KEY_ID): cv.int_range(min=0, max=5), + } + ), cv.Optional( CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False ): cv.boolean, @@ -2451,6 +2499,20 @@ async def to_code(config): cg.add_define("USE_OTA_SIGNED_VERIFICATION") + # Encrypt NVS using the HMAC peripheral scheme. The NVS encryption keys are + # derived at runtime from an HMAC key stored in the configured eFuse block + # (no flash encryption required). The HMAC key is generated and burned into + # the eFuse block on first boot if it is empty. With the scheme selected, + # nvs_sec_provider registers it at startup and the default nvs_flash_init() + # (used in esp32/preferences.cpp) transparently performs the secure init, so + # no C++ changes are needed. + if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None: + add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True) + add_idf_sdkconfig_option("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC", True) + add_idf_sdkconfig_option( + "CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID", nvs_enc[CONF_KEY_ID] + ) + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( diff --git a/tests/component_tests/esp32/config/nvs_encryption_s3.yaml b/tests/component_tests/esp32/config/nvs_encryption_s3.yaml new file mode 100644 index 0000000000..371f2e28ca --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_encryption_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 1b189c6331..d53e119e9f 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -175,6 +175,29 @@ def test_esp32_default_toolchain_is_esp_idf( r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]", id="ignore_efuse_mac_crc_only_on_esp32", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_encryption": {"key_id": 0}}, + }, + }, + r"NVS encryption \(HMAC scheme\) is not supported on ESP32 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]", + id="nvs_encryption_unsupported_on_esp32", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_encryption": {"key_id": 6}}, + }, + }, + r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", + id="nvs_encryption_key_id_out_of_range", + ), ], ) def test_esp32_configuration_errors( @@ -214,6 +237,21 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_nvs_encryption_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that nvs_encryption sets the HMAC scheme sdkconfig options.""" + generate_main(component_config_path("nvs_encryption_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ENCRYPTION") is True + assert sdkconfig.get("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC") is True + assert sdkconfig.get("CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID") == 0 + # The permanent/irreversible eFuse burn is warned about at config time. + assert "PERMANENT and IRREVERSIBLE" in caplog.text + + @pytest.mark.parametrize( ("fixture", "expect_warning"), [ diff --git a/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml b/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml new file mode 100644 index 0000000000..ab9001efec --- /dev/null +++ b/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml @@ -0,0 +1,9 @@ +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 + +<<: !include common.yaml From f823a23ea412be94c87bd0f8204747a25076f6cf Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Jul 2026 07:15:37 +0200 Subject: [PATCH 2016/2214] [pcm5122] Add analog gain, channel mixing, volume range, standby/powerdown switch, and XSMT enable pin support (#17313) --- esphome/components/pcm5122/audio_dac.py | 51 ++++++++- esphome/components/pcm5122/pcm5122.cpp | 104 +++++++++++++++++- esphome/components/pcm5122/pcm5122.h | 49 ++++++++- esphome/components/pcm5122/switch/__init__.py | 32 ++++++ .../pcm5122/switch/power_switch.cpp | 12 ++ .../components/pcm5122/switch/power_switch.h | 24 ++++ tests/components/pcm5122/common.yaml | 11 ++ 7 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 esphome/components/pcm5122/switch/__init__.py create mode 100644 esphome/components/pcm5122/switch/power_switch.cpp create mode 100644 esphome/components/pcm5122/switch/power_switch.h diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index 0017a1ef5a..c18fb3993e 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -5,6 +5,7 @@ from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, + CONF_ENABLE_PIN, CONF_ID, CONF_INPUT, CONF_INVERTED, @@ -16,6 +17,11 @@ from esphome.const import ( CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] +CONF_ANALOG_GAIN = "analog_gain" +CONF_CHANNEL_MIX = "channel_mix" +CONF_VOLUME_MIN_DB = "volume_min_db" +CONF_VOLUME_MAX_DB = "volume_max_db" + pcm5122_ns = cg.esphome_ns.namespace("pcm5122") PCM5122 = pcm5122_ns.class_("PCM5122", AudioDac, cg.Component, i2c.I2CDevice) CONF_PCM5122 = "pcm5122" @@ -27,26 +33,60 @@ PCM5122_BITS_PER_SAMPLE_ENUM = { 32: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_32, } +pcm5122_analog_gain = pcm5122_ns.enum("PCM5122AnalogGain") +PCM5122_ANALOG_GAIN_ENUM = { + "0db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_0DB, + "-6db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_MINUS_6DB, +} + +pcm5122_channel_mix = pcm5122_ns.enum("PCM5122ChannelMix") +PCM5122_CHANNEL_MIX_ENUM = { + "stereo": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_STEREO, + "left": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_LEFT_ONLY, + "right": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_RIGHT_ONLY, + "swapped": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_SWAPPED, +} + _validate_bits = cv.float_with_unit("bits", "bit") +def _validate_volume_range(config): + if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]: + raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}") + return config + + PCM5122GPIOPin = pcm5122_ns.class_( "PCM5122GPIOPin", cg.GPIOPin, cg.Parented.template(PCM5122), ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(PCM5122), cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( _validate_bits, cv.enum(PCM5122_BITS_PER_SAMPLE_ENUM) ), + cv.Optional(CONF_ANALOG_GAIN, default="0db"): cv.enum( + PCM5122_ANALOG_GAIN_ENUM, lower=True + ), + cv.Optional(CONF_CHANNEL_MIX, default="stereo"): cv.enum( + PCM5122_CHANNEL_MIX_ENUM, lower=True + ), + cv.Optional(CONF_VOLUME_MIN_DB, default="-52.5dB"): cv.All( + cv.decibel, cv.float_range(min=-103.0, max=24.0) + ), + cv.Optional(CONF_VOLUME_MAX_DB, default="0dB"): cv.All( + cv.decibel, cv.float_range(min=-103.0, max=24.0) + ), + cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) - .extend(i2c.i2c_device_schema(0x4D)) + .extend(i2c.i2c_device_schema(0x4D)), + _validate_volume_range, ) @@ -96,3 +136,10 @@ async def to_code(config): await i2c.register_i2c_device(var, config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) + cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) + cg.add(var.set_channel_mix(config[CONF_CHANNEL_MIX])) + cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) + cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) + if enable_pin_config := config.get(CONF_ENABLE_PIN): + enable_pin = await cg.gpio_pin_expression(enable_pin_config) + cg.add(var.set_enable_pin(enable_pin)) diff --git a/esphome/components/pcm5122/pcm5122.cpp b/esphome/components/pcm5122/pcm5122.cpp index 68bbd50e4f..d178cb83b8 100644 --- a/esphome/components/pcm5122/pcm5122.cpp +++ b/esphome/components/pcm5122/pcm5122.cpp @@ -10,6 +10,12 @@ namespace esphome::pcm5122 { static const char *const TAG = "pcm5122"; void PCM5122::setup() { + // Hold XSMT low (soft mute asserted) until init completes + if (this->enable_pin_ != nullptr) { + this->enable_pin_->setup(); + this->enable_pin_->digital_write(false); + } + // Select page 0 and verify chip presence via I2C ACK if (!this->select_page_(0)) { ESP_LOGE(TAG, "Write failed"); @@ -51,7 +57,22 @@ void PCM5122::setup() { } this->reg(PCM5122_REG_AUDIO_FORMAT) = PCM5122_AUDIO_FORMAT_I2S | alen; + if (!this->write_channel_mix_()) { + this->mark_failed(); + return; + } + + if (!this->write_analog_gain_()) { + this->mark_failed(); + return; + } + // PLL reference clock: BCK + if (!this->select_page_(0)) { + ESP_LOGE(TAG, "Write failed"); + this->mark_failed(); + return; + } optional pll_ref = this->read_byte(PCM5122_REG_PLL_REF); if (!pll_ref.has_value()) { ESP_LOGE(TAG, "Failed to read PLL_REF"); @@ -67,15 +88,40 @@ void PCM5122::setup() { this->mark_failed(); return; } + + // Release XSMT (soft un-mute) now that init has completed + if (this->enable_pin_ != nullptr) { + this->enable_pin_->digital_write(true); + } } void PCM5122::dump_config() { + const char *channel_mix_str; + switch (this->channel_mix_) { + case PCM5122_CHANNEL_MIX_LEFT_ONLY: + channel_mix_str = "left only"; + break; + case PCM5122_CHANNEL_MIX_RIGHT_ONLY: + channel_mix_str = "right only"; + break; + case PCM5122_CHANNEL_MIX_SWAPPED: + channel_mix_str = "swapped"; + break; + default: + channel_mix_str = "stereo"; + break; + } ESP_LOGCONFIG(TAG, "Audio DAC:"); LOG_I2C_DEVICE(this); ESP_LOGCONFIG(TAG, " Bits per sample: %u\n" + " Analog gain: %s\n" + " Channel mix: %s\n" + " Volume range: %.1f dB to %.1f dB\n" " Muted: %s", - this->bits_per_sample_, YESNO(this->is_muted_)); + this->bits_per_sample_, this->analog_gain_ == PCM5122_ANALOG_GAIN_0DB ? "0 dB" : "-6 dB", + channel_mix_str, this->volume_min_db_, this->volume_max_db_, YESNO(this->is_muted_)); + LOG_PIN(" Enable Pin: ", this->enable_pin_); } bool PCM5122::set_mute_off() { @@ -118,11 +164,11 @@ bool PCM5122::write_mute_() { } bool PCM5122::write_volume_() { - // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFF = mute (-0.5 dB/step). - // Note: volume=0.0 maps to -52.5 dB (still audible), not true silence. + // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFE = -103 dB, 0xFF = mute (-0.5 dB/step). + // Note: volume=0.0 maps to volume_min_db_, which is not true silence unless set to -103 dB. // Use set_mute_on() for silence. - const uint8_t dvol_max_volume = 0x30; // 0 dB at full scale - const uint8_t dvol_min_volume = 0x99; // -52.5 dB at minimum + const uint8_t dvol_max_volume = static_cast(lroundf(0x30 - this->volume_max_db_ * 2.0f)); + const uint8_t dvol_min_volume = static_cast(lroundf(0x30 - this->volume_min_db_ * 2.0f)); const uint8_t volume_byte = dvol_max_volume + static_cast(lroundf((1.0f - this->volume_) * (dvol_min_volume - dvol_max_volume))); @@ -137,4 +183,52 @@ bool PCM5122::write_volume_() { return true; } +bool PCM5122::write_analog_gain_() { + uint8_t gain_byte = this->analog_gain_; + if (!this->select_page_(1) || !this->write_byte(PCM5122_REG_ANALOG_GAIN, gain_byte)) { + ESP_LOGE(TAG, "Writing analog gain failed"); + return false; + } + return true; +} + +bool PCM5122::write_channel_mix_() { + uint8_t channel_mix_byte = this->channel_mix_; + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_DAC_DATA_PATH, channel_mix_byte)) { + ESP_LOGE(TAG, "Writing channel mix failed"); + return false; + } + return true; +} + +bool PCM5122::set_standby(bool enable) { + bool prev_standby = this->standby_; + this->standby_ = enable; + if (!this->write_power_control_()) { + this->standby_ = prev_standby; + return false; + } + return true; +} + +bool PCM5122::set_powerdown(bool enable) { + bool prev_powerdown = this->powerdown_; + this->powerdown_ = enable; + if (!this->write_power_control_()) { + this->powerdown_ = prev_powerdown; + return false; + } + return true; +} + +bool PCM5122::write_power_control_() { + uint8_t power_byte = + (this->standby_ ? PCM5122_POWER_CONTROL_RQST : 0) | (this->powerdown_ ? PCM5122_POWER_CONTROL_RQPD : 0); + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_POWER_CONTROL, power_byte)) { + ESP_LOGE(TAG, "Writing power control failed"); + return false; + } + return true; +} + } // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h index 3c42e4d8d2..199818b06e 100644 --- a/esphome/components/pcm5122/pcm5122.h +++ b/esphome/components/pcm5122/pcm5122.h @@ -3,6 +3,7 @@ #include "esphome/components/audio_dac/audio_dac.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include "esphome/core/hal.h" namespace esphome::pcm5122 { @@ -10,11 +11,13 @@ namespace esphome::pcm5122 { // Page 0 register addresses static const uint8_t PCM5122_REG_PAGE_SELECT = 0x00; static const uint8_t PCM5122_REG_RESET = 0x01; +static const uint8_t PCM5122_REG_POWER_CONTROL = 0x02; static const uint8_t PCM5122_REG_MUTE = 0x03; static const uint8_t PCM5122_REG_GPIO_ENABLE = 0x08; static const uint8_t PCM5122_REG_PLL_REF = 0x0D; static const uint8_t PCM5122_REG_ERROR_DETECT = 0x25; static const uint8_t PCM5122_REG_AUDIO_FORMAT = 0x28; +static const uint8_t PCM5122_REG_DAC_DATA_PATH = 0x2A; static const uint8_t PCM5122_REG_DVOL_LEFT = 0x3D; static const uint8_t PCM5122_REG_DVOL_RIGHT = 0x3E; static const uint8_t PCM5122_REG_GPIO_OUTPUT_SELECT = 0x50; // Base address; GPIO n uses offset n-1 @@ -23,6 +26,9 @@ static const uint8_t PCM5122_REG_GPIO_OUTPUT = 0x56; static const uint8_t PCM5122_REG_GPIO_INVERT = 0x57; static const uint8_t PCM5122_REG_GPIO_INPUT = 0x77; +// Page 1 register addresses +static const uint8_t PCM5122_REG_ANALOG_GAIN = 0x02; + // Register values for init sequence static const uint8_t PCM5122_RESET_MODULES = 0x10; // RSTM: reset audio modules static const uint8_t PCM5122_AUDIO_FORMAT_I2S = 0x00; // AFMT = I2S (bits [5:4] = 00) @@ -35,12 +41,33 @@ static const uint8_t PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET = (1 << 1); static const uint8_t PCM5122_PLL_REF_MASK = (7 << 4); // SREF bits [6:4] static const uint8_t PCM5122_PLL_REF_SOURCE_BCK = (1 << 4); // SREF = 001 (BCK) +// Page 0, Register 2 (Power Control): RQST = standby request, RQPD = powerdown request (§10.5.3) +static const uint8_t PCM5122_POWER_CONTROL_RQST = (1 << 4); +static const uint8_t PCM5122_POWER_CONTROL_RQPD = (1 << 0); + +// Page 1, Register 2 (Analog Gain Control): LAGN/RAGN select 0 dB or -6 dB analog gain (§8.3.5.5) +static const uint8_t PCM5122_ANALOG_GAIN_LAGN = (1 << 4); +static const uint8_t PCM5122_ANALOG_GAIN_RAGN = (1 << 0); + enum PCM5122BitsPerSample : uint8_t { PCM5122_BITS_PER_SAMPLE_16 = 16, PCM5122_BITS_PER_SAMPLE_24 = 24, PCM5122_BITS_PER_SAMPLE_32 = 32, }; +enum PCM5122AnalogGain : uint8_t { + PCM5122_ANALOG_GAIN_0DB = 0x00, + PCM5122_ANALOG_GAIN_MINUS_6DB = PCM5122_ANALOG_GAIN_LAGN | PCM5122_ANALOG_GAIN_RAGN, +}; + +// Page 0, Register 0x2A (DAC Data Path): AUPL/AUPR select which channel's data feeds each output (§7.4.2.42) +enum PCM5122ChannelMix : uint8_t { + PCM5122_CHANNEL_MIX_STEREO = 0x11, // Left data -> left out, right data -> right out + PCM5122_CHANNEL_MIX_LEFT_ONLY = 0x12, // Left data -> both outputs + PCM5122_CHANNEL_MIX_RIGHT_ONLY = 0x21, // Right data -> both outputs + PCM5122_CHANNEL_MIX_SWAPPED = 0x22, // Left/right outputs swapped +}; + class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; @@ -48,6 +75,11 @@ class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c:: float get_setup_priority() const override { return setup_priority::IO; } void set_bits_per_sample(PCM5122BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + void set_analog_gain(PCM5122AnalogGain analog_gain) { this->analog_gain_ = analog_gain; } + void set_channel_mix(PCM5122ChannelMix channel_mix) { this->channel_mix_ = channel_mix; } + void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; } + void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; } + void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; } bool set_mute_off() override; bool set_mute_on() override; @@ -56,17 +88,30 @@ class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c:: bool is_muted() override; float volume() override; + bool set_standby(bool enable); + bool set_powerdown(bool enable); + friend class PCM5122GPIOPin; protected: bool select_page_(uint8_t page); bool write_mute_(); bool write_volume_(); + bool write_analog_gain_(); + bool write_channel_mix_(); + bool write_power_control_(); - float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) - int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes + GPIOPin *enable_pin_{nullptr}; + float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) + float volume_min_db_{-52.5f}; // Matches the previous hardcoded minimum (0x99) + float volume_max_db_{0.0f}; // Matches the previous hardcoded maximum (0x30) + int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes bool is_muted_{false}; + bool standby_{false}; + bool powerdown_{false}; PCM5122BitsPerSample bits_per_sample_{PCM5122_BITS_PER_SAMPLE_16}; + PCM5122AnalogGain analog_gain_{PCM5122_ANALOG_GAIN_0DB}; + PCM5122ChannelMix channel_mix_{PCM5122_CHANNEL_MIX_STEREO}; }; } // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch/__init__.py new file mode 100644 index 0000000000..10519da895 --- /dev/null +++ b/esphome/components/pcm5122/switch/__init__.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG + +from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns + +PCM5122PowerSwitch = pcm5122_ns.class_("PCM5122PowerSwitch", switch.Switch) + +pcm5122_power_switch_mode = pcm5122_ns.enum("PCM5122PowerSwitchMode") +PCM5122_POWER_SWITCH_MODE_ENUM = { + "standby": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_STANDBY, + "powerdown": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_POWERDOWN, +} + +CONFIG_SCHEMA = switch.switch_schema( + PCM5122PowerSwitch, + entity_category=ENTITY_CATEGORY_CONFIG, +).extend( + { + cv.GenerateID(CONF_PCM5122): cv.use_id(PCM5122), + cv.Optional(CONF_POWER_MODE, default="powerdown"): cv.enum( + PCM5122_POWER_SWITCH_MODE_ENUM, lower=True + ), + } +) + + +async def to_code(config): + var = await switch.new_switch(config) + await cg.register_parented(var, config[CONF_PCM5122]) + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) diff --git a/esphome/components/pcm5122/switch/power_switch.cpp b/esphome/components/pcm5122/switch/power_switch.cpp new file mode 100644 index 0000000000..45f0be715d --- /dev/null +++ b/esphome/components/pcm5122/switch/power_switch.cpp @@ -0,0 +1,12 @@ +#include "power_switch.h" + +namespace esphome::pcm5122 { + +void PCM5122PowerSwitch::write_state(bool state) { + bool ok = (this->mode_ == PCM5122_POWER_SWITCH_MODE_STANDBY) ? this->parent_->set_standby(state) + : this->parent_->set_powerdown(state); + if (ok) + this->publish_state(state); +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/switch/power_switch.h b/esphome/components/pcm5122/switch/power_switch.h new file mode 100644 index 0000000000..47d30f1a9f --- /dev/null +++ b/esphome/components/pcm5122/switch/power_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/switch/switch.h" + +#include "../pcm5122.h" + +namespace esphome::pcm5122 { + +enum PCM5122PowerSwitchMode : uint8_t { + PCM5122_POWER_SWITCH_MODE_STANDBY, + PCM5122_POWER_SWITCH_MODE_POWERDOWN, +}; + +class PCM5122PowerSwitch final : public switch_::Switch, public Parented { + public: + void set_power_mode(PCM5122PowerSwitchMode mode) { this->mode_ = mode; } + + protected: + void write_state(bool state) override; + + PCM5122PowerSwitchMode mode_{PCM5122_POWER_SWITCH_MODE_POWERDOWN}; +}; + +} // namespace esphome::pcm5122 diff --git a/tests/components/pcm5122/common.yaml b/tests/components/pcm5122/common.yaml index cf96f57464..a8ae1e6975 100644 --- a/tests/components/pcm5122/common.yaml +++ b/tests/components/pcm5122/common.yaml @@ -4,6 +4,11 @@ audio_dac: i2c_id: i2c_bus address: 0x4D bits_per_sample: 32bit + analog_gain: -6db + channel_mix: swapped + volume_min_db: -60dB + volume_max_db: -3dB + enable_pin: GPIO12 output: - platform: gpio @@ -22,3 +27,9 @@ binary_sensor: number: 4 mode: input: true + +switch: + - platform: pcm5122 + pcm5122: pcm5122_dac + name: PCM5122 Power Down + power_mode: powerdown From 3c2dad67f4b81447f7330aa11a2eae9b454325ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jul 2026 02:07:34 -0500 Subject: [PATCH 2017/2214] [network] Fix logged use_address with MAC suffix and build it at runtime (#17432) --- esphome/components/api/api_server.cpp | 3 +- .../components/esphome/ota/ota_esphome.cpp | 3 +- esphome/components/ethernet/__init__.py | 4 +- .../components/ethernet/ethernet_component.h | 4 +- esphome/components/network/__init__.py | 13 ++++ esphome/components/network/util.cpp | 23 ++++++ esphome/components/network/util.h | 31 ++------ esphome/components/openthread/__init__.py | 3 +- esphome/components/openthread/openthread.h | 4 +- esphome/components/web_server/web_server.cpp | 3 +- esphome/components/wifi/__init__.py | 3 +- esphome/components/wifi/wifi_component.h | 4 +- .../fixtures/use_address_runtime.yaml | 8 ++ .../use_address_runtime_mac_suffix.yaml | 9 +++ tests/integration/test_use_address_runtime.py | 73 +++++++++++++++++++ 15 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 tests/integration/fixtures/use_address_runtime.yaml create mode 100644 tests/integration/fixtures/use_address_runtime_mac_suffix.yaml create mode 100644 tests/integration/test_use_address_runtime.py diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ddd03ace4a..efdeb6991b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -240,12 +240,13 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { } void APIServer::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Server:\n" " Address: %s:%u\n" " Listen backlog: %u\n" " Max connections: %u", - network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); + network::get_use_address_to(addr_buf), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); if (!this->noise_ctx_.has_psk()) { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index db4a2015a7..cab725f704 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -94,11 +94,12 @@ void ESPHomeOTAComponent::setup() { } void ESPHomeOTAComponent::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Over-The-Air updates:\n" " Address: %s:%u\n" " Version: %d", - network::get_use_address(), this->port_, USE_OTA_VERSION); + network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION); #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { ESP_LOGCONFIG(TAG, " Password configured"); diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index dc4cbda45c..03fba7164d 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -4,7 +4,7 @@ import logging from esphome import automation, pins from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.network import ip_address_literal +from esphome.components.network import add_use_address, ip_address_literal from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -543,7 +543,7 @@ async def to_code(config): await _to_code_rp2040(var, config) cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(var, config[CONF_USE_ADDRESS]) # enable_on_boot defaults to true in C++ - only set if false if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 16f09a45f0..7160351727 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -145,6 +145,8 @@ class EthernetComponent final : public Component { network::IPAddresses get_ip_addresses(); network::IPAddress get_dns_address(uint8_t num); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } void get_eth_mac_address_raw(uint8_t *mac); @@ -346,7 +348,7 @@ class EthernetComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 616a189226..b7dfb8d6d2 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -59,6 +59,19 @@ def ip_address_literal(ip: str | int | None) -> cg.MockObj: return IPAddress(str(ip)) +def add_use_address(var: cg.MockObj, use_address: str) -> None: + """Generate a set_use_address() call only when the address must be baked in. + + The default ".local" is not stored in the firmware; it is rebuilt at + runtime from the device name (see network::get_use_address_to()), which also + picks up the MAC suffix when name_add_mac_suffix is enabled. A compile-time + string could never include that suffix, so baking it in would log the wrong + address. + """ + if use_address != f"{CORE.name}.local": + cg.add(var.set_use_address(use_address)) + + def require_high_performance_networking() -> None: """Request high performance networking for network and WiFi. diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 79ddd3844c..ae250c6a1f 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -1,5 +1,7 @@ #include "util.h" +#include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_NETWORK namespace esphome::network { @@ -20,6 +22,27 @@ bool is_disabled() { return false; } +const char *get_use_address_to(std::span buf) { + // Global component pointers are guaranteed to be set by component constructors when USE_* is defined + const char *addr = nullptr; +#if defined(USE_ETHERNET) + addr = ethernet::global_eth_component->get_use_address(); +#elif defined(USE_MODEM) + addr = modem::global_modem_component->get_use_address(); +#elif defined(USE_WIFI) + addr = wifi::global_wifi_component->get_use_address(); +#elif defined(USE_OPENTHREAD) + addr = openthread::global_openthread_component->get_use_address(); +#endif + if (addr != nullptr && addr[0] != '\0') + return addr; + // No explicit use_address configured: the address is the runtime device name + // (which includes the MAC suffix when name_add_mac_suffix is enabled) plus ".local" + const auto &name = App.get_name(); + make_name_with_suffix_to(buf.data(), buf.size(), name.c_str(), name.size(), '.', "local", 5); + return buf.data(); +} + network::IPAddresses get_ip_addresses() { #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index e4e8a01f8c..17a2ff0977 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_NETWORK +#include #include #include "esphome/core/helpers.h" #include "ip_address.h" @@ -53,30 +54,12 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { /// Return whether the network is disabled (only wifi for now) bool is_disabled(); -/// Get the active network hostname -ESPHOME_ALWAYS_INLINE inline const char *get_use_address() { - // Global component pointers are guaranteed to be set by component constructors when USE_* is defined -#ifdef USE_ETHERNET - return ethernet::global_eth_component->get_use_address(); -#endif - -#ifdef USE_MODEM - return modem::global_modem_component->get_use_address(); -#endif - -#ifdef USE_WIFI - return wifi::global_wifi_component->get_use_address(); -#endif - -#ifdef USE_OPENTHREAD - return openthread::global_openthread_component->get_use_address(); -#endif - -#if !defined(USE_ETHERNET) && !defined(USE_MODEM) && !defined(USE_WIFI) && !defined(USE_OPENTHREAD) - // Fallback when no network component is defined (e.g., host platform) - return ""; -#endif -} +/// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator +static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70; +/// Get the active network address for logging. Returns the explicitly configured +/// use_address when one was set, otherwise formats ".local" from the runtime +/// device name into buf (so it includes the MAC suffix from name_add_mac_suffix). +const char *get_use_address_to(std::span buf); IPAddresses get_ip_addresses(); } // namespace esphome::network diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index b54fe2b218..4018ad81e7 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage +from esphome.components.network import add_use_address from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -288,7 +289,7 @@ async def to_code(config): enable_mdns_storage() ot = cg.new_Pvariable(config[CONF_ID]) - cg.add(ot.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(ot, config[CONF_USE_ADDRESS]) await cg.register_component(ot, config) if (poll_period := config.get(CONF_POLL_PERIOD)) is not None: cg.add(ot.set_poll_period(poll_period)) diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index eb48d8a74a..b4654af21f 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -39,6 +39,8 @@ class OpenThreadComponent final : public Component { void on_factory_reset(std::function callback); void defer_factory_reset_external_callback(); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } #if CONFIG_OPENTHREAD_MTD @@ -76,7 +78,7 @@ class OpenThreadComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 96195a8270..c8f66755bc 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -421,10 +421,11 @@ void WebServer::on_log(uint8_t level, const char *tag, const char *message, size #endif void WebServer::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Web Server:\n" " Address: %s:%u", - network::get_use_address(), this->base_->get_port()); + network::get_use_address_to(addr_buf), this->base_->get_port()); } float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f; } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index af600647c1..dc5c8be4d7 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( request_wifi, ) from esphome.components.network import ( + add_use_address, has_high_performance_networking, ip_address_literal, ) @@ -585,7 +586,7 @@ def wifi_network(config, ap, static_ip): @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(var, config[CONF_USE_ADDRESS]) # Track if any network uses Enterprise authentication has_eap = False diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 0db85c4d75..23b7558564 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -501,6 +501,8 @@ class WiFiComponent final : public Component { network::IPAddress get_dns_address(int num); network::IPAddresses get_ip_addresses(); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } @@ -996,7 +998,7 @@ class WiFiComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/tests/integration/fixtures/use_address_runtime.yaml b/tests/integration/fixtures/use_address_runtime.yaml new file mode 100644 index 0000000000..29f3369285 --- /dev/null +++ b/tests/integration/fixtures/use_address_runtime.yaml @@ -0,0 +1,8 @@ +esphome: + name: use-address-runtime + +host: + +api: + +logger: diff --git a/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml b/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml new file mode 100644 index 0000000000..9785724cd5 --- /dev/null +++ b/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml @@ -0,0 +1,9 @@ +esphome: + name: use-address-mac + name_add_mac_suffix: true + +host: + +api: + +logger: diff --git a/tests/integration/test_use_address_runtime.py b/tests/integration/test_use_address_runtime.py new file mode 100644 index 0000000000..a4cbbb9c5f --- /dev/null +++ b/tests/integration/test_use_address_runtime.py @@ -0,0 +1,73 @@ +"""Integration tests for the runtime-built use_address. + +The default ".local" address is no longer stored as a compile-time string; +it is built at runtime from the device name. This also fixes the logged address +when name_add_mac_suffix is enabled: the baked string used to miss the MAC +suffix, so it never matched the actual mDNS hostname. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" +MAC_SUFFIX = "abf679" + + +@pytest.mark.asyncio +async def test_use_address_runtime( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The API dump_config logs ".local" built from the device name.""" + address_seen = asyncio.Event() + + def check_output(line: str) -> None: + if "Address: use-address-runtime.local:" in line: + address_seen.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "use-address-runtime" + + try: + await asyncio.wait_for(address_seen.wait(), timeout=10.0) + except TimeoutError: + pytest.fail("Did not log 'Address: use-address-runtime.local:'") + + +@pytest.mark.asyncio +async def test_use_address_runtime_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With name_add_mac_suffix the logged address includes the MAC suffix.""" + address_seen = asyncio.Event() + expected = f"Address: use-address-mac-{MAC_SUFFIX}.local:" + + def check_output(line: str) -> None: + if expected in line: + address_seen.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == f"use-address-mac-{MAC_SUFFIX}" + + try: + await asyncio.wait_for(address_seen.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Did not log '{expected}'") From 40c3a4320f1a44c18cd9f3d883ecbdf152383d89 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:44:44 +0200 Subject: [PATCH 2018/2214] [core] add const for litre per hour (#17389) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/kamstrup_kmp/sensor.py | 2 +- esphome/const.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index 134ac245bf..75ec432ad9 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -21,6 +21,7 @@ from esphome.const import ( UNIT_EMPTY, UNIT_KELVIN, UNIT_KILOWATT, + UNIT_LITRE_PER_HOUR, ) CODEOWNERS = ["@cfeenstra1024"] @@ -37,7 +38,6 @@ CONF_TEMP2 = "temp2" CONF_TEMP_DIFF = "temp_diff" UNIT_GIGA_JOULE = "GJ" -UNIT_LITRE_PER_HOUR = "l/h" # Note: The sensor units are set automatically based un the received data from the meter CONFIG_SCHEMA = ( diff --git a/esphome/const.py b/esphome/const.py index 16d11d3a18..988134fa46 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1255,6 +1255,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kvarh" UNIT_KILOWATT = "kW" UNIT_KILOWATT_HOURS = "kWh" UNIT_LITRE = "L" +UNIT_LITRE_PER_HOUR = "L/h" UNIT_LITRE_PER_SECOND = "L/s" UNIT_LUX = "lx" UNIT_MEGAJOULE = "MJ" From af4a6e7ec3d5ec05139f7f3df2d6475d5600dadb Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Tue, 7 Jul 2026 13:55:49 +0200 Subject: [PATCH 2019/2214] [usb_uart] Fix FTDI RX data stall / corruption and input restart reliability (#17348) Co-authored-by: Oliver Kleinecke Co-authored-by: Claude Sonnet 4.6 --- esphome/components/usb_uart/ft23xx.cpp | 52 +++++++++++++++++------- esphome/components/usb_uart/usb_uart.cpp | 5 +++ esphome/components/usb_uart/usb_uart.h | 9 +++- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 2e8ff8bcb5..25e4cc524f 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -3,6 +3,7 @@ #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" +#include "esphome/core/application.h" #include "esphome/components/uart/uart_debugger.h" #include "esphome/components/bytebuffer/bytebuffer.h" @@ -396,7 +397,14 @@ int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) { } void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { - if (!channel->initialised_.load() || channel->input_started_.load()) + if (!channel->initialised_.load()) + return; + + // Use compare_exchange_strong to avoid a check-then-act race: start_input() is called + // from both the USB task (self-restart on success) and the main loop (backpressure + // restart), so a plain load()/store() pair can let both threads submit a transfer. + auto started = false; + if (!channel->input_started_.compare_exchange_strong(started, true)) return; const auto *ep = channel->cdc_dev_.in_ep; @@ -408,39 +416,55 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { return; } + // FTDI prepends a 2-byte modem/line status header to every bulk IN packet. size_t uart_data_len = (status.data_len > 2) ? (status.data_len - 2) : 0; if (uart_data_len > 0) { ESP_LOGV(TAG, "RX callback: Received %zu bytes, channel=%d", uart_data_len, channel->index_); if (!channel->dummy_receiver_) { - // Copy the entire received UART payload into the ring buffer in one - // operation to avoid per-byte overhead and reduce the chance of - // heap activity in hot paths. - channel->input_buffer_.push(status.data + 2, uart_data_len); + UsbDataChunk *chunk = this->chunk_pool_.allocate(); + if (chunk == nullptr) { + this->usb_data_queue_.increment_dropped_count(); + channel->input_started_.store(false); + // Queue is full — wake the main loop to drain it, then let read_array() + // retrigger start_input() rather than spinning here in the USB task. + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); + return; + } + // Strip the 2-byte FTDI header before queuing. + memcpy(chunk->data, status.data + 2, uart_data_len); + chunk->length = static_cast(uart_data_len); + chunk->channel = channel; + this->usb_data_queue_.push(chunk); #ifdef USE_UART_DEBUGGER if (channel->debug_) { - // Debug path creates a temporary vector for logging only; this is - // acceptable because debug mode is opt-in and not used in release. uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, std::vector(status.data + 2, status.data + 2 + uart_data_len), ',', channel->debug_prefix_); } #endif + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); } - } else { + } else if (status.data_len >= 2) { ESP_LOGVV(TAG, "RX: Status packet, modem=0x%02X line=0x%02X, ch=%d", status.data[0], status.data[1], channel->index_); } channel->input_started_.store(false); - if (channel->dummy_receiver_ || - channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) { - this->start_input(channel); - } + this->start_input(channel); }; - channel->input_started_.store(true); - this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); + if (!this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize)) { + ESP_LOGE(TAG, "RX transfer submission failed for ep=0x%02X", ep->bEndpointAddress); + channel->input_started_.store(false); + } +} + +void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) { + ESP_LOGW(TAG, "RX buffer overflow on channel %d, clearing to resync", channel->index_); + channel->input_buffer_.clear(); } void USBUartTypeFT23XX::enable_channels() { diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index b8749b6a76..a995e93e15 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -228,6 +228,11 @@ void USBUartComponent::loop() { } #endif + // If there is not enough space for the full chunk, let the device subclass + // handle it (e.g. FTDI clears the buffer to prevent mid-telegram corruption). + if (channel->input_buffer_.get_free_space() < chunk->length) { + this->on_rx_overflow(channel); + } // Push data to ring buffer (now safe in main loop) channel->input_buffer_.push(chunk->data, chunk->length); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index a3501fc8cf..6d60809b38 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -192,9 +192,13 @@ class USBUartComponent : public usb_host::USBClient { void add_channel(USBUartChannel *channel) { this->channels_.push_back(channel); } - void start_input(USBUartChannel *channel); + virtual void start_input(USBUartChannel *channel); void start_output(USBUartChannel *channel); + // Called from loop() when input_buffer_ has insufficient space for the incoming chunk. + // Default is a no-op; override in device-specific subclasses that need resync on overflow. + virtual void on_rx_overflow(USBUartChannel *channel) {} + // Lock-free data transfer from USB task to main loop static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; @@ -248,7 +252,8 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { public: USBUartTypeFT23XX(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} - void start_input(USBUartChannel *channel); + void start_input(USBUartChannel *channel) override; + void on_rx_overflow(USBUartChannel *channel) override; protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; From 76ee3fe8875764bc6755e6dba413254fec9b33c3 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Jul 2026 14:56:48 +0200 Subject: [PATCH 2020/2214] [audio_file] Accept mp1/mp2 puremagic detections as MP3 (#17436) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/audio_file/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 53193c8008..d59ed7411a 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -113,7 +113,9 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type == "wav": media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"] - elif file_type in ("mp3", "mpeg", "mpga"): + elif file_type in ("mp1", "mp2", "mp3", "mpeg", "mpga"): + # With puremagic >=2.0 this can cause some MP3 (Layer III) files to be labeled as "mp1"/"mp2". + # Treat those labels as MP3 so we still pick the MP3 decoder. media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"] elif file_type == "flac": media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"] From 7f0e826c323772a3e146693d41ea66b68427e556 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:50:36 +1000 Subject: [PATCH 2021/2214] [lvgl] Add paused option to suppress updates on boot (#16973) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 3 ++ esphome/components/lvgl/defines.py | 1 + .../lvgl/config/not_paused.yaml | 26 ++++++++++++++ tests/component_tests/lvgl/config/paused.yaml | 27 ++++++++++++++ tests/component_tests/lvgl/test_paused.py | 35 +++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 1 + 6 files changed, 93 insertions(+) create mode 100644 tests/component_tests/lvgl/config/not_paused.yaml create mode 100644 tests/component_tests/lvgl/config/paused.yaml create mode 100644 tests/component_tests/lvgl/test_paused.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 08369927b9..ecc4b0a777 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -414,6 +414,8 @@ async def to_code(configs): await cg.register_component(lv_component, config) if rotation := config.get(CONF_ROTATION): cg.add(lv_component.set_rotation(rotation)) + if paused := config[df.CONF_PAUSED]: + cg.add(lv_component.set_paused(paused, False)) if refr_time := config.get(df.CONF_REFRESH_INTERVAL): cg.add(lv_component.set_refresh_interval(refr_time.total_milliseconds)) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) @@ -645,6 +647,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + cv.Optional(df.CONF_PAUSED, default=False): cv.boolean, } ) .extend(DISP_BG_SCHEMA) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 53499503d4..15e593b3f6 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -758,6 +758,7 @@ CONF_PAD_COLUMN = "pad_column" CONF_PAGE = "page" CONF_PAGE_WRAP = "page_wrap" CONF_PASSWORD_MODE = "password_mode" +CONF_PAUSED = "paused" CONF_PIVOT_X = "pivot_x" CONF_PIVOT_Y = "pivot_y" CONF_PLACEHOLDER_TEXT = "placeholder_text" diff --git a/tests/component_tests/lvgl/config/not_paused.yaml b/tests/component_tests/lvgl/config/not_paused.yaml new file mode 100644 index 0000000000..1dfe8f4ee9 --- /dev/null +++ b/tests/component_tests/lvgl/config/not_paused.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-not-paused + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + - obj: + id: root_obj diff --git a/tests/component_tests/lvgl/config/paused.yaml b/tests/component_tests/lvgl/config/paused.yaml new file mode 100644 index 0000000000..ea747ec75b --- /dev/null +++ b/tests/component_tests/lvgl/config/paused.yaml @@ -0,0 +1,27 @@ +esphome: + name: test-paused + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + paused: true + widgets: + - obj: + id: root_obj diff --git a/tests/component_tests/lvgl/test_paused.py b/tests/component_tests/lvgl/test_paused.py new file mode 100644 index 0000000000..eede17ec19 --- /dev/null +++ b/tests/component_tests/lvgl/test_paused.py @@ -0,0 +1,35 @@ +"""Tests for the LVGL ``paused`` option code generation.""" + +from __future__ import annotations + +import re + +_SET_PAUSED_RE = re.compile(r"->set_paused\((.+?)\);") + + +def _extract_set_paused(main_cpp: str) -> list[str]: + """Return the normalised argument text of every set_paused() call found. + + Whitespace within and around the arguments is collapsed so unrelated + code-generation formatting changes don't break these tests. + """ + return [" ".join(m.group(1).split()) for m in _SET_PAUSED_RE.finditer(main_cpp)] + + +class TestPausedCodeGeneration: + """Verify that the ``paused`` option drives the set_paused() call.""" + + def test_paused_true_generates_set_paused( + self, generate_main, component_config_path + ): + """``paused: true`` emits a set_paused(true, false) call.""" + main_cpp = generate_main(component_config_path("paused.yaml")) + calls = _extract_set_paused(main_cpp) + assert calls == ["true, false"] + + def test_paused_default_omits_set_paused( + self, generate_main, component_config_path + ): + """Without ``paused`` (default false) no set_paused call is generated.""" + main_cpp = generate_main(component_config_path("not_paused.yaml")) + assert _extract_set_paused(main_cpp) == [] diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4f043db7cb..4ec4eb3bd6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -35,6 +35,7 @@ lvgl: rotation: 90 log_level: debug resume_on_input: true + paused: true update_when_display_idle: true refresh_interval: 30ms on_pause: From b4ad0eb86bab936163ab90cb1b1f659f1032c8f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jul 2026 09:59:18 -0500 Subject: [PATCH 2022/2214] [esp32_ble] Fix boot loop when the hosted co-processor does not answer BT bring-up (#17429) --- esphome/components/esp32_ble/ble.cpp | 52 +++++++++++++++++++-- esphome/components/esp32_hosted/__init__.py | 2 + 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6bbf0d6a26..a2d19f1042 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -9,6 +9,8 @@ #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include #else +#include "esphome/components/watchdog/watchdog.h" +#include extern "C" { #include #include @@ -33,6 +35,19 @@ namespace esphome::esp32_ble { static const char *const TAG = "esp32_ble"; +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +// Bringing up the remote BT controller issues synchronous RPCs to the +// co-processor with 5 second response timeouts, and the default task watchdog +// is also 5 seconds. If the co-processor firmware does not answer (for example +// factory firmware without Bluetooth support), the watchdog would reboot the +// device before the RPC could return an error, causing a boot loop. Raise the +// watchdog for the duration of the bring-up so failures surface as error +// returns instead. 60 seconds covers the worst case: transport reconnect +// (up to ~20s), version preflight (1s), controller init/enable (5s each) and +// the bluedroid host bring-up over the hosted HCI transport. +static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; +#endif + // GAP event groups for deduplication across gap_event_handler and dispatch_gap_event_ #define GAP_SCAN_COMPLETE_EVENTS \ case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: \ @@ -164,6 +179,9 @@ void ESP32BLE::advertising_init_() { bool ESP32BLE::ble_setup_() { esp_err_t err; +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID + watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); +#endif #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) { // start bt controller @@ -192,15 +210,35 @@ bool ESP32BLE::ble_setup_() { esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); #else - esp_hosted_connect_to_slave(); // NOLINT + if (esp_hosted_connect_to_slave() != ESP_OK) { // NOLINT + ESP_LOGE(TAG, "Co-processor transport failed; BLE disabled"); + return false; + } + + // Fast preflight (1 second RPC timeout): verifies the co-processor answers + // RPCs at all before the 5 second timeout BT controller RPCs below, and + // before hosted_hci_bluedroid_open(), which aborts if the transport is down. + esp_hosted_coprocessor_fwver_t fw_ver{}; + if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) { + ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted " + "update component"); + return false; + } + ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1); if (esp_hosted_bt_controller_init() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_init failed"); + ESP_LOGE(TAG, + "BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 + " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", + fw_ver.major1, fw_ver.minor1, fw_ver.patch1); return false; } if (esp_hosted_bt_controller_enable() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_enable failed"); + ESP_LOGE(TAG, + "BT controller enable failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 + " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", + fw_ver.major1, fw_ver.minor1, fw_ver.patch1); return false; } @@ -332,6 +370,10 @@ bool ESP32BLE::ble_setup_() { } bool ESP32BLE::ble_dismantle_() { +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID + // Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS + watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); +#endif esp_err_t err = esp_bluedroid_disable(); if (err != ESP_OK) { // ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine @@ -377,12 +419,12 @@ bool ESP32BLE::ble_dismantle_() { } #else if (esp_hosted_bt_controller_disable() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_disable failed"); + ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed"); return false; } if (esp_hosted_bt_controller_deinit(false) != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_deinit failed"); + ESP_LOGE(TAG, "esp_hosted_bt_controller_deinit failed"); return false; } diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 7f420f27d8..16e9d49782 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -18,6 +18,8 @@ from esphome.const import ( from esphome.cpp_generator import add_define CODEOWNERS = ["@swoboda1337"] +# esp32_ble raises the task watchdog around the remote BT controller bring-up +AUTO_LOAD = ["watchdog"] CONF_ACTIVE_HIGH = "active_high" CONF_BUS_WIDTH = "bus_width" From 1913818b1cd2b8a39001ed6d456e1a7b4fa490dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:16 -0500 Subject: [PATCH 2023/2214] Bump astral-sh/setup-uv from 8.3.0 to 8.3.1 in /.github/actions/restore-python (#17442) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 8ef0bca2ec..64b1cabea1 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 71b746b6fadac7d51b89cd05f180d4476df2e15c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:26 -0500 Subject: [PATCH 2024/2214] Bump astral-sh/setup-uv from 8.3.0 to 8.3.1 (#17444) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 721585a44d..1757959a51 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fd6a79cb5..c7e1c67fb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -171,7 +171,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -372,7 +372,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 2efaec4e94..7e0047ee0d 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 35c7496cd7ed39d28fb4286dd7adfff44c650354 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:38 -0500 Subject: [PATCH 2025/2214] Bump CodSpeedHQ/action from 4.18.1 to 4.18.2 (#17445) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e1c67fb6..e08241681b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 + uses: CodSpeedHQ/action@4e969336ab9acd4f6f8d025fdd793292b0835df0 # v4.18.2 with: run: | . venv/bin/activate From 731e9fda031e5e0f4d1ddf93fad2ff8572cf364b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:12:57 -0400 Subject: [PATCH 2026/2214] [internal_temperature] Support all ESP32 variants with a temperature sensor (#17438) --- .../internal_temperature_esp32.cpp | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 1c44a9a238..64fe3707b1 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -3,17 +3,16 @@ #include "esphome/core/log.h" #include "internal_temperature.h" +#include + #if defined(USE_ESP32_VARIANT_ESP32) // there is no official API available on the original ESP32 extern "C" { uint8_t temprature_sens_read(); } -#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) +#elif SOC_TEMP_SENSOR_SUPPORTED #include "driver/temperature_sensor.h" -#endif // USE_ESP32_VARIANT +#endif namespace esphome::internal_temperature { @@ -27,10 +26,7 @@ void InternalTemperatureSensor::update() { ESP_LOGV(TAG, "Raw temperature value: %d", raw); temperature = (raw - 32) / 1.8f; success = (raw != 128); -#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) +#elif SOC_TEMP_SENSOR_SUPPORTED esp_err_t result = temperature_sensor_get_celsius(this->tsens_, &temperature); success = (result == ESP_OK); if (!success) { @@ -49,9 +45,7 @@ void InternalTemperatureSensor::update() { } void InternalTemperatureSensor::setup() { -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if SOC_TEMP_SENSOR_SUPPORTED temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); esp_err_t result = temperature_sensor_install(&tsens_config, &this->tsens_); From 731486d9b0fdc23d89a2264745008052c78ffd00 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 7 Jul 2026 17:20:14 -0700 Subject: [PATCH 2027/2214] [modbus] Finalize unreleased API surface before 2026.7 (#17434) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/modbus.cpp | 34 +++- esphome/components/modbus/modbus.h | 13 +- .../components/modbus/modbus_definitions.h | 2 +- esphome/components/modbus/modbus_helpers.cpp | 21 +-- esphome/components/modbus/modbus_helpers.h | 35 ++-- .../binary_sensor/modbus_binarysensor.cpp | 2 +- .../modbus_controller/modbus_controller.h | 13 +- .../select/modbus_select.cpp | 4 +- .../switch/modbus_switch.cpp | 2 +- .../modbus_server/modbus_server.cpp | 10 +- .../modbus/modbus_client_hub_test.cpp | 178 ++++++++++++++++++ .../components/modbus/modbus_helpers_test.cpp | 13 +- 12 files changed, 272 insertions(+), 55 deletions(-) create mode 100644 tests/components/modbus/modbus_client_hub_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 527d57fcd7..ecb2e4461c 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -56,8 +56,7 @@ void ModbusClientHub::loop() { (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, this->last_receive_check_ - this->last_send_); - if (wfr.device) - wfr.device->on_modbus_no_response(); + this->notify_no_response_(wfr); this->waiting_for_response_.reset(); } } @@ -278,11 +277,10 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct "ms after last send", address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, this->last_modbus_byte_ - this->last_send_); - // Invalidate the waiting device so it won't process this response. - if (wfr.device) - wfr.device->on_modbus_no_response(); + // Invalidate the device; the entry survives as an interrupted shell so the late response is ignored. + // A retry requested here stays queued behind the shell until the send-wait timeout clears it. + this->notify_no_response_(wfr); wfr.interrupted = true; - wfr.device = nullptr; return; } @@ -564,6 +562,30 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Mo } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. +void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) { + if (wfr.device == nullptr) + return; + const bool retry = wfr.device->on_modbus_no_response(); + // The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach + // over the retry request rather than re-queueing a frame that can no longer be routed. + if (retry && wfr.device != nullptr) + this->requeue_waiting_frame_(wfr); + // The old transaction is over either way; never deliver anything else to the device through it. + wfr.device = nullptr; +} + +void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) { + const ModbusFrame &frame = wfr.frame; + if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) { + ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]); + if (wfr.device != nullptr) + wfr.device->on_modbus_not_sent(); + return; + } + // Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell. + this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3); +} + void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) { if (pdu_len == 0) { if (device) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index e48c8c298a..eeba00f6b1 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -108,7 +108,7 @@ class ModbusClientHub : public Modbus { payload, payload_len), device); }; - void send_pdu(uint8_t address, const StaticVector &pdu, ModbusClientDevice *device = nullptr) { + void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr) { this->queue_raw_(address, pdu.data(), pdu.size(), device); } void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); @@ -121,6 +121,10 @@ class ModbusClientHub : public Modbus { // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; void send_next_frame_(); + // Notify the waiting device of no response; re-queues the frame if on_modbus_no_response() returns true. + // wfr is the caller's checked reference to waiting_for_response_. + void notify_no_response_(ModbusDeviceCommand &wfr); + void requeue_waiting_frame_(ModbusDeviceCommand &wfr); void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr); uint16_t send_wait_time_{2000}; @@ -179,7 +183,10 @@ class ModbusClientDevice { virtual void on_modbus_data(const std::vector &data) {} virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} virtual void on_modbus_not_sent() {} - virtual void on_modbus_no_response() {} + /// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry. + /// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and + /// return false when exhausted), or an unresponsive peer will starve other traffic on the bus. + virtual bool on_modbus_no_response() { return false; } void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { this->parent_->send_pdu(this->address_, @@ -187,7 +194,7 @@ class ModbusClientDevice { payload, payload_len), this); } - void send_pdu(const StaticVector &pdu) { this->parent_->send_pdu(this->address_, pdu, this); } + void send_pdu(std::span pdu) { this->parent_->send_pdu(this->address_, pdu, this); } void send_raw(const std::vector &payload) { this->parent_->send_raw(payload, this); } inline void clear_tx_queue_for_address(bool clear_sent = true) { this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index a5bcc1e3fc..d11748bcd9 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -82,7 +82,7 @@ static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0 // 6.3 03 (0x03) Read Holding Registers // 6.4 04 (0x04) Read Input Registers -static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D // Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) static constexpr uint16_t MIN_FRAME_SIZE = 4; diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 53fa6afacb..de109606cb 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -105,8 +105,8 @@ void log_unsupported_value_type(SensorValueType value_type) { ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); } -int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask, bool *error_return) { +std::optional payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits // Validate offset against the buffer for all types, including RAW/unsupported, so @@ -114,9 +114,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens if (static_cast(offset) > size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), static_cast(offset), size); - if (error_return) - *error_return = true; - return value; + return std::nullopt; } const size_t required_size = required_payload_size(sensor_value_type); @@ -127,9 +125,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens if (size - offset < required_size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", static_cast(sensor_value_type), static_cast(offset), size, required_size); - if (error_return) - *error_return = true; - return value; + return std::nullopt; } switch (sensor_value_type) { @@ -179,8 +175,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens return value; } -int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, - bool *error_return) { +std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type) { const size_t required_size = required_payload_size(sensor_value_type); if (required_size == 0) { return 0; // RAW/unsupported: nothing to read @@ -189,9 +184,7 @@ int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValue if (required_words > count) { ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu", static_cast(sensor_value_type), count, required_words); - if (error_return) - *error_return = true; - return 0; + return std::nullopt; } // Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the // sign-extension behaviour stays identical to the wire path. @@ -201,7 +194,7 @@ int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValue bytes[i * 2] = static_cast(reg >> 8); bytes[i * 2 + 1] = static_cast(reg & 0xFF); } - return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF, error_return); + return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); } StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index fef0f915ea..45a13f7582 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -1,8 +1,10 @@ #pragma once +#include +#include +#include #include #include -#include #include "esphome/core/helpers.h" #include "esphome/components/modbus/modbus_definitions.h" @@ -197,11 +199,15 @@ template T get_data(const std::vector &data, size_t buffer_ * @param data modbus response buffer (uint8_t) * @return content of coil register */ -inline bool coil_from_vector(int coil, const std::vector &data) { - auto data_byte = coil / 8; - return (data[data_byte] & (1 << (coil % 8))) > 0; +inline bool bit_from_packed(int bit, std::span data) { + auto data_byte = bit / 8; + return (data[data_byte] & (1 << (bit % 8))) > 0; } +// Remove before 2027.2.0 +ESPDEPRECATED("Use bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0") +inline bool coil_from_vector(int coil, std::span data) { return bit_from_packed(coil, data); } + /** Extract bits from value and shift right according to the bitmask * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. * the result is then shifted right by the position if the first right set bit in the mask @@ -276,13 +282,21 @@ template void number_to_payload(Container &data, int64_t val * @param bitmask bitmask used for masking and shifting * @return 64-bit number of the payload */ -int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask, bool *error_return = nullptr); +std::optional payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask); -/** Convert vector response payload to number. */ +/** Convert a response payload span to number; std::nullopt if the payload is too short. */ +inline std::optional payload_to_number(std::span data, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask) { + return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask); +} + +// Remove before 2027.2.0 +ESPDEPRECATED("Use the std::span overload returning std::optional instead. Removed in 2027.2.0", "2026.8.0") inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask, bool *error_return = nullptr) { - return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask, error_return); + uint32_t bitmask) { + // Released behavior: a too-short payload logs an error and decodes to 0. + return payload_to_number(std::span(data), sensor_value_type, offset, bitmask).value_or(0); } /** Reconstruct a number from register words (host byte order). Inverse of number_to_payload. @@ -292,8 +306,7 @@ inline int64_t payload_to_number(const std::vector &data, SensorValueTy * @param sensor_value_type defines if 16/32/64 bits or FP32 is used * @return 64-bit number of the registers */ -int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, - bool *error_return = nullptr); +std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type); /** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. * @param function_code the modbus function code to use. One of: diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index 60c19bb66a..9656013a5f 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -14,7 +14,7 @@ void ModbusBinarySensor::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::coil_from_vector(this->offset, data); + value = modbus::helpers::bit_from_packed(this->offset, data); break; default: value = modbus::helpers::get_data(data, this->offset) & this->bitmask; diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 501fadbcf1..484b59ede3 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -64,9 +64,10 @@ T get_data(const std::vector &data, size_t buffer_offset) { return modbus::helpers::get_data(data, buffer_offset); } -ESPDEPRECATED("Use modbus::helpers::coil_from_vector() instead. Removed in 2026.10.0", "2026.4.0") +// Remove before 2027.2.0 (window restarted when the migration target changed to bit_from_packed()) +ESPDEPRECATED("Use modbus::helpers::bit_from_packed() instead. Removed in 2027.2.0", "2026.4.0") inline bool coil_from_vector(int coil, const std::vector &data) { - return modbus::helpers::coil_from_vector(coil, data); + return modbus::helpers::bit_from_packed(coil, data); } template @@ -83,7 +84,8 @@ inline void number_to_payload(std::vector &data, int64_t value, Sensor ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, uint32_t bitmask) { - return modbus::helpers::payload_to_number(data, sensor_value_type, offset, bitmask); + return modbus::helpers::payload_to_number(std::span(data), sensor_value_type, offset, bitmask) + .value_or(0); } ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") @@ -377,8 +379,9 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli * @param item SensorItem object * @return float value of data */ -inline float payload_to_float(const std::vector &data, const SensorItem &item) { - int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask); +inline float payload_to_float(std::span data, const SensorItem &item) { + int64_t number = + modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask).value_or(0); float float_value; if (modbus::helpers::value_type_is_float(item.sensor_value_type)) { diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index 859828f5f6..c650ca7641 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -8,7 +8,9 @@ static const char *const TAG = "modbus_controller.select"; void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); } void ModbusSelect::parse_and_publish(const std::vector &data) { - int64_t value = modbus::helpers::payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask); + int64_t value = modbus::helpers::payload_to_number(std::span(data), this->sensor_value_type, + this->offset, this->bitmask) + .value_or(0); ESP_LOGD(TAG, "New select value %lld from payload", value); diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 044ca2f8cc..c8b3868bdc 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -33,7 +33,7 @@ void ModbusSwitch::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::coil_from_vector(this->offset, data); + value = modbus::helpers::bit_from_packed(this->offset, data); break; default: value = modbus::helpers::get_data(data, this->offset) & this->bitmask; diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 1f787a0b61..4c4e72a086 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -137,10 +137,9 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, if (server_register->write_lambda == nullptr) { return false; // unwritable -> ILLEGAL_DATA_ADDRESS } - bool error = false; - registers_to_number(registers.data() + register_offset, registers.size() - register_offset, - server_register->value_type, &error); - if (error) { + if (!registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type) + .has_value()) { precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value return false; } @@ -154,7 +153,8 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, // rejecting the value at runtime -- which cannot be rolled back. if (!for_each_register([®isters](ServerRegister *server_register, uint16_t register_offset) { int64_t number = registers_to_number(registers.data() + register_offset, registers.size() - register_offset, - server_register->value_type); + server_register->value_type) + .value_or(0); return server_register->write_lambda(number); })) { ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied."); diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp new file mode 100644 index 0000000000..d04c4fe10c --- /dev/null +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -0,0 +1,178 @@ +#include + +#include +#include + +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Exposes the protected tx queue and waiting-for-response slot so tests can drive the +// no-response path without a UART: force_send_front() mimics send_next_frame_() moving the +// front frame in flight, timeout_waiting() mimics the loop() no-response timeout handling. +class NoResponseProbeHub : public ModbusClientHub { + public: + size_t queued_frames() const { return this->tx_buffer_.size(); } + const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); } + bool waiting() const { return this->waiting_for_response_.has_value(); } + const ModbusDeviceCommand &waiting_command() const { + EXPECT_TRUE(this->waiting_for_response_.has_value()); + return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access) + } + + void force_send_front() { + this->waiting_for_response_ = std::move(this->tx_buffer_.front()); + this->tx_buffer_.pop_front(); + } + // Drives the real unexpected-frame branch in process_modbus_server_frame(). + void receive_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) { + this->process_modbus_server_frame(address, function_code, data, len); + } + void timeout_waiting() { + if (this->waiting_for_response_.has_value()) + this->notify_no_response_(*this->waiting_for_response_); + this->waiting_for_response_.reset(); + } +}; + +// A device with a scripted answer to on_modbus_no_response(). +class RetryingDevice : public ModbusClientDevice { + public: + RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {} + bool on_modbus_no_response() override { + this->no_response_count_++; + return this->retry_; + } + int no_response_count_{0}; + + protected: + bool retry_{false}; +}; + +// A device that clears its own queued traffic from inside the no-response callback, then asks for a retry. +class ClearingRetryDevice : public ModbusClientDevice { + public: + ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + bool on_modbus_no_response() override { + this->no_response_count_++; + this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback + return true; // and still requests a retry + } + int no_response_count_{0}; +}; + +constexpr uint8_t READ_PDU[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 holding registers at 0x100 + +StaticVector read_pdu() { + StaticVector pdu; + pdu.assign(READ_PDU, READ_PDU + sizeof(READ_PDU)); + return pdu; +} + +} // namespace + +// A device that requests a retry gets the frame the hub was holding re-queued on its behalf, +// byte-identical and still routed to the same device. +TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.send_pdu(read_pdu()); + ASSERT_EQ(hub.queued_frames(), 1u); + hub.force_send_front(); + ASSERT_EQ(hub.queued_frames(), 0u); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_FALSE(hub.waiting()); + ASSERT_EQ(hub.queued_frames(), 1u); + const ModbusDeviceCommand &requeued = hub.front(); + EXPECT_EQ(requeued.device, &device); + // address + PDU + CRC + ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3); + EXPECT_EQ(requeued.frame.data.data()[0], 0x02); + EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU))); +} + +// A device that declines the retry has the frame dropped. +TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// After the device is detached from the waiting frame (e.g. clear_tx_queue_for_device on +// destruction), a timeout must not deliver a callback or re-queue anything. +TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { + NoResponseProbeHub hub; + { + RetryingDevice device(&hub, 0x02, /*retry=*/true); + device.send_pdu(read_pdu()); + hub.force_send_front(); + // device destructor clears its queue entries, including the waiting frame's device pointer + } + ASSERT_TRUE(hub.waiting()); + EXPECT_EQ(hub.waiting_command().device, nullptr); + + hub.timeout_waiting(); + + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// An unexpected frame interrupts the transaction: the retry is re-queued immediately, but the +// waiting entry survives as an interrupted shell (device detached) that keeps tx blocked until the +// send-wait timeout clears it - without a second no-response callback or a duplicate requeue. +TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + + // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. + const uint8_t stray_payload[] = {0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, 0x03, stray_payload, sizeof(stray_payload)); + + EXPECT_EQ(device.no_response_count_, 1); + ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue... + EXPECT_EQ(hub.front().device, &device); + ASSERT_TRUE(hub.waiting()); // ...while the shell stays in the waiting slot + EXPECT_TRUE(hub.waiting_command().interrupted); + EXPECT_EQ(hub.waiting_command().device, nullptr); + + // The send-wait timeout clears the shell without a second callback or another requeue. + hub.timeout_waiting(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(hub.queued_frames(), 1u); +} + +// A callback that detaches the device (clear_tx_queue_for_device()) wins over its own retry request: +// no orphaned frame with a null device is re-queued. +TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { + NoResponseProbeHub hub; + ClearingRetryDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(hub.queued_frames(), 0u); // the retry was not re-queued for a detached device + EXPECT_FALSE(hub.waiting()); +} + +} // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index ecdca4df6d..1c57a81e6f 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -181,17 +181,17 @@ TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) { TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { const std::vector data{0x12, 0x34}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); + EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value()); } TEST(ModbusHelpersTest, PayloadToNumberRejectsTruncatedMultiRegisterValue) { const std::vector data{0x12, 0x34, 0x56}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_DWORD, 0, 0xFFFFFFFF), 0); + EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_DWORD, 0, 0xFFFFFFFF).has_value()); } TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { const std::vector data{0x12, 0x34}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); } // --- registers_to_number --------------------------------------------------- @@ -218,16 +218,15 @@ TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) { const uint16_t registers[] = {0x8001, 0x0002}; const std::vector bytes{0x80, 0x01, 0x00, 0x02}; for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) { - EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF)) + EXPECT_EQ(registers_to_number(registers, 2, value_type), + payload_to_number(std::span(bytes), value_type, 0, 0xFFFFFFFF)) << "value_type=" << static_cast(value_type); } } TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { const uint16_t registers[] = {0x1234}; - bool error = false; - EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0); - EXPECT_TRUE(error); + EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value()); } } // namespace esphome::modbus::helpers From 65ef05dd1f388c7ff9793e8a074c0c4babecfb4d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:51:00 +1200 Subject: [PATCH 2028/2214] [web_server_idf] Deliver raw POST bodies to custom handlers via handleBody() (#17433) --- .../web_server_idf/web_server_idf.cpp | 65 ++++++++++++++++--- .../web_server_idf/web_server_idf.h | 1 + 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index cd06f80687..69b27e90ed 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -41,6 +41,11 @@ namespace esphome::web_server_idf { static const char *const TAG = "web_server_idf"; +// Chunk size for streaming request bodies; matches the Arduino AsyncWebServer buffer size. +// Buffers of this size must live on the heap - the httpd task stack is too small. +static constexpr size_t RECV_CHUNK_SIZE = 1460; +static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog + // Global instance to avoid guard variable (saves 8 bytes) // This is initialized at program startup before any threads namespace { @@ -184,9 +189,10 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return server->handle_multipart_upload_(r, content_type_char); #endif } else { - ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type_char); - // fallback to get handler to support backward compatibility - return AsyncWebServer::request_handler(r); + // Other content types (e.g. application/json) are delivered raw to a matching + // custom handler via handleBody(), like the Arduino AsyncWebServer does + auto *server = static_cast(r->user_ctx); + return server->handle_raw_body_(r, content_type_char); } } @@ -237,6 +243,51 @@ esp_err_t AsyncWebServer::request_handler_(AsyncWebServerRequest *request) const return ESP_ERR_NOT_FOUND; } +esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_type) { + AsyncWebServerRequest req(r); + AsyncWebHandler *handler = nullptr; + for (auto *h : this->handlers_) { + if (h->canHandle(&req)) { + handler = h; + break; + } + } + + if (handler == nullptr) { + ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type); + // fallback to get handler to support backward compatibility + return this->request_handler_(&req); + } + + const size_t total = r->content_len; + if (total > 0) { + auto buffer = std::make_unique_for_overwrite(RECV_CHUNK_SIZE); + size_t bytes_since_yield = 0; + + for (size_t index = 0; index < total;) { + int recv_len = httpd_req_recv(r, buffer.get(), std::min(total - index, RECV_CHUNK_SIZE)); + + if (recv_len <= 0) { + httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, + nullptr); + return recv_len == HTTPD_SOCK_ERR_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL; + } + + handler->handleBody(&req, reinterpret_cast(buffer.get()), recv_len, index, total); + index += recv_len; + bytes_since_yield += recv_len; + + if (bytes_since_yield > YIELD_INTERVAL_BYTES) { + vTaskDelay(1); + bytes_since_yield = 0; + } + } + } + + handler->handleRequest(&req); + return ESP_OK; +} + AsyncWebServerRequest::~AsyncWebServerRequest() { delete this->rsp_; for (auto *param : this->params_) { @@ -893,9 +944,6 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e #ifdef USE_WEBSERVER_OTA esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *content_type) { - static constexpr size_t MULTIPART_CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size - static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog - // Parse boundary and create reader const char *boundary_start; size_t boundary_len; @@ -949,12 +997,11 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c } }); - // Use heap buffer - 1460 bytes is too large for the httpd task stack - auto buffer = std::make_unique_for_overwrite(MULTIPART_CHUNK_SIZE); + auto buffer = std::make_unique_for_overwrite(RECV_CHUNK_SIZE); size_t bytes_since_yield = 0; for (size_t remaining = r->content_len; remaining > 0;) { - int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, MULTIPART_CHUNK_SIZE)); + int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, RECV_CHUNK_SIZE)); if (recv_len <= 0) { httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index c631cd1453..8b5fd5b726 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -233,6 +233,7 @@ class AsyncWebServer { static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; static void safe_close_with_shutdown(httpd_handle_t hd, int sockfd); + esp_err_t handle_raw_body_(httpd_req_t *r, const char *content_type); #ifdef USE_WEBSERVER_OTA esp_err_t handle_multipart_upload_(httpd_req_t *r, const char *content_type); #endif From b8af90750fde582cf1f109d0ab14e94b482a76bc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:18:46 +1200 Subject: [PATCH 2029/2214] [web_server_idf] Map more common HTTP status codes in responses (#17447) --- .../web_server_idf/web_server_idf.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 69b27e90ed..46a389f359 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -32,9 +32,16 @@ namespace esphome::web_server_idf { +// Status strings not provided by esp_http_server.h +#ifndef HTTPD_401 +#define HTTPD_401 "401 Unauthorized" +#endif #ifndef HTTPD_409 #define HTTPD_409 "409 Conflict" #endif +#ifndef HTTPD_422 +#define HTTPD_422 "422 Unprocessable Entity" +#endif #define CRLF_STR "\r\n" #define CRLF_LEN (sizeof(CRLF_STR) - 1) @@ -327,12 +334,24 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code case 200: status = HTTPD_200; break; + case 204: + status = HTTPD_204; + break; + case 400: + status = HTTPD_400; + break; + case 401: + status = HTTPD_401; + break; case 404: status = HTTPD_404; break; case 409: status = HTTPD_409; break; + case 422: + status = HTTPD_422; + break; default: status = HTTPD_500; break; From 93bc02b3085b8c6e9c7c330164a6d34dd8120828 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:39:55 -0500 Subject: [PATCH 2030/2214] Bump bundled esphome-device-builder to 1.3.0 (#17448) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c01a2069f7..3a7d5e8bbe 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.2.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.3.0 RUN \ platformio settings set enable_telemetry No \ From 9c40ed5d711e7720567a6ccfae5f6db31ea3b99d Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 8 Jul 2026 01:01:20 -0500 Subject: [PATCH 2031/2214] [provisioning] Add provisioning window (#17152) Co-authored-by: Claude Opus 4.8 (1M context) --- CODEOWNERS | 1 + esphome/components/api/__init__.py | 18 +++ esphome/components/api/api.proto | 14 +++ esphome/components/api/api_connection.cpp | 28 ++++- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_pb2.cpp | 20 ++++ esphome/components/api/api_pb2.h | 12 +- esphome/components/api/api_pb2_dump.cpp | 13 ++- esphome/components/api/api_pb2_service.cpp | 6 +- esphome/components/api/api_pb2_service.h | 2 +- esphome/components/api/api_server.cpp | 61 ++++++++-- esphome/components/api/api_server.h | 21 +++- .../esp32_improv/esp32_improv_component.cpp | 31 ++++++ esphome/components/network/__init__.py | 75 ++++++++----- esphome/components/provisioning/__init__.py | 104 ++++++++++++++++++ .../components/provisioning/provisioning.cpp | 92 ++++++++++++++++ .../components/provisioning/provisioning.h | 96 ++++++++++++++++ esphome/components/wifi/__init__.py | 16 +++ esphome/components/wifi/wifi_component.cpp | 20 +++- esphome/core/defines.h | 1 + .../provisioning/test_provisioning.py | 84 ++++++++++++++ .../provisioning/test.esp32-idf.yaml | 25 +++++ .../provisioning/test.esp8266-ard.yaml | 16 +++ .../provisioning/validate.esp32-idf.yaml | 15 +++ 24 files changed, 724 insertions(+), 49 deletions(-) create mode 100644 esphome/components/provisioning/__init__.py create mode 100644 esphome/components/provisioning/provisioning.cpp create mode 100644 esphome/components/provisioning/provisioning.h create mode 100644 tests/component_tests/provisioning/test_provisioning.py create mode 100644 tests/components/provisioning/test.esp32-idf.yaml create mode 100644 tests/components/provisioning/test.esp8266-ard.yaml create mode 100644 tests/components/provisioning/validate.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 34ec4bc2bd..821d2e5e74 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -404,6 +404,7 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81 esphome/components/pn7160_spi/* @jesserockz @kbx81 esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core +esphome/components/provisioning/* @esphome/core esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 11ada7e970..64b025fee1 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -112,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" +def _register_provisioning_source(config: ConfigType) -> ConfigType: + """Register the API as a provisioning source when encryption is enabled. + + With no ``key`` the device boots unprovisioned and is set up on first + connection; a YAML ``key`` means it is born provisioned. Either way the API + drives the provisioning manager, so it counts as a source for `provisioning:`. + A hardcoded ``key`` is reported so `provisioning:` can warn about it. + """ + if (encryption := config.get(CONF_ENCRYPTION)) is not None: + from esphome.components import provisioning + + provisioning.register_source("api") + if CONF_KEY in encryption: + provisioning.report_hardcoded_credentials("api") + return config + + def validate_encryption_key(value): value = cv.string_strict(value) try: @@ -337,6 +354,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), _consume_api_sockets, + _register_provisioning_source, ) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f4f15c1042..86707d9810 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -158,6 +158,16 @@ message AuthenticationResponse { bool invalid_password = 1; } +// Reason a party is requesting the connection be closed. +enum DisconnectReason { + // No specific reason / not provided (default for older peers). + DISCONNECT_REASON_UNSPECIFIED = 0; + // The device's provisioning window has expired. The device must be reset + // (power-cycled) to reopen the provisioning window before it will accept a + // connection again. + DISCONNECT_REASON_PROVISIONING_CLOSED = 1; +} + // Request to close the connection. // Can be sent by both the client and server message DisconnectRequest { @@ -166,6 +176,10 @@ message DisconnectRequest { option (no_delay) = true; // Do not close the connection before the acknowledgement arrives + + // Optional reason the connection is being closed. Older peers that do not + // send this field will report DISCONNECT_REASON_UNSPECIFIED (0). + DisconnectReason reason = 1; } message DisconnectResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cb7d1b9d1e..dcb1478ec8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -25,6 +25,9 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/version.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #ifdef USE_DEEP_SLEEP #include "esphome/components/deep_sleep/deep_sleep_component.h" @@ -1724,6 +1727,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + // The provisioning window has closed without the device being provisioned. + // Acknowledge the hello so the client can read the server name, then request + // disconnect with the reason. Authentication is intentionally not completed. + this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection")); + this->send_message(resp); + DisconnectRequest req; + req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; + return this->send_message(req); + } +#endif + // Auto-authenticate - password auth was removed in ESPHome 2026.1.0 this->complete_authentication_(); @@ -1874,7 +1890,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) { this->on_fatal_error(); } } -void APIConnection::on_disconnect_request() { +void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) { + // The reason is informational when a client disconnects us; we always ack and close. if (!this->send_disconnect_response_()) { this->on_fatal_error(); } @@ -2002,6 +2019,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio NoiseEncryptionSetKeyResponse resp; resp.success = false; +#ifdef USE_PROVISIONING + // Refuse to set a key once the provisioning window has closed (defense in depth; + // such connections are already rejected at hello). + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + ESP_LOGW(TAG, "Provisioning closed; rejecting key set"); + return this->send_message(resp); + } +#endif + psk_t psk{}; if (msg.key_len == 0) { if (this->parent_->clear_noise_psk(true)) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index dae5fc92fd..d6d3e4d26b 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -259,7 +259,7 @@ class APIConnection final : public APIServerConnectionBase { void on_get_time_response(const GetTimeResponse &value); #endif void on_hello_request(const HelloRequest &msg); - void on_disconnect_request(); + void on_disconnect_request(const DisconnectRequest &msg); void on_ping_request(); void on_device_info_request(); void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c711ef167c..de6ae4751e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const { size += 2 + this->name.size(); return size; } +bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { + switch (field_id) { + case 1: + this->reason = static_cast(value); + break; + default: + return false; + } + return true; +} +uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason)); + return pos; +} +uint32_t DisconnectRequest::calculate_size() const { + uint32_t size = 0; + size += this->reason ? 2 : 0; + return size; +} #ifdef USE_AREAS uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7e926ee0d4..d268a40c56 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -11,6 +11,10 @@ namespace esphome::api { namespace enums { +enum DisconnectReason : uint32_t { + DISCONNECT_REASON_UNSPECIFIED = 0, + DISCONNECT_REASON_PROVISIONING_CLOSED = 1, +}; enum SerialProxyPortType : uint32_t { SERIAL_PROXY_PORT_TYPE_TTL = 0, SERIAL_PROXY_PORT_TYPE_RS232 = 1, @@ -427,18 +431,22 @@ class HelloResponse final : public ProtoMessage { protected: }; -class DisconnectRequest final : public ProtoMessage { +class DisconnectRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 5; - static constexpr uint8_t ESTIMATED_SIZE = 0; + static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("disconnect_request"); } #endif + enums::DisconnectReason reason{}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class DisconnectResponse final : public ProtoMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 850ad37bc9..3a1ceba95f 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint } #pragma GCC diagnostic pop +template<> const char *proto_enum_to_string(enums::DisconnectReason value) { + switch (value) { + case enums::DISCONNECT_REASON_UNSPECIFIED: + return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED"); + case enums::DISCONNECT_REASON_PROVISIONING_CLOSED: + return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} template<> const char *proto_enum_to_string(enums::SerialProxyPortType value) { switch (value) { case enums::SERIAL_PROXY_PORT_TYPE_TTL: @@ -864,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } const char *DisconnectRequest::dump_to(DumpBuffer &out) const { - out.append_p(ESPHOME_PSTR("DisconnectRequest {}")); + MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest")); + dump_field(out, ESPHOME_PSTR("reason"), static_cast(this->reason)); return out.c_str(); } const char *DisconnectResponse::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 0ba2961a13..5c9df433dd 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -51,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } case DisconnectRequest::MESSAGE_TYPE: { + DisconnectRequest msg; + msg.decode(msg_data, msg_size); #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_disconnect_request")); + this->log_receive_message_(LOG_STR("on_disconnect_request"), msg); #endif - this->on_disconnect_request(); + this->on_disconnect_request(msg); break; } case DisconnectResponse::MESSAGE_TYPE: { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index aca42ca303..d1b51f4846 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -21,7 +21,7 @@ class APIServerConnectionBase { void on_hello_request(const HelloRequest &value){}; - void on_disconnect_request(){}; + void on_disconnect_request(const DisconnectRequest &value){}; void on_disconnect_response(){}; void on_ping_request(){}; void on_ping_response(){}; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index efdeb6991b..1062dfeb39 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -107,8 +107,30 @@ void APIServer::setup() { // Initialize last_connected_ for reboot timeout tracking this->last_connected_ = App.get_loop_component_start_time(); - // Set warning status if reboot timeout is enabled - if (this->reboot_timeout_ != 0) { +#if defined(USE_PROVISIONING) && defined(USE_API_NOISE) + // Register with the provisioning manager (provisioning:) as a source and + // report our current state (provisioned == an encryption key is set). When the + // window closes, disconnect any client still attempting to provision so it learns + // the reason. The manager owns the timeout, window state and on_timeout automation. + if (provisioning::global_provisioning_manager != nullptr) { + this->provisioning_source_ = provisioning::global_provisioning_manager->register_source(); + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, + this->noise_ctx_.has_psk()); + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + for (auto &c : this->active_clients()) { + DisconnectRequest req; + req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; + // Best-effort: if the send buffer is full the reason is dropped, but the + // client still learns the window is closed when it reconnects (rejected at + // hello) or via the socket close. + c->send_message(req); + } + }); + } +#endif + // Set warning status if reboot timeout is enabled (suppressed while provisioning + // is pending so the device waits to be onboarded instead of rebooting). + if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); } } @@ -121,8 +143,10 @@ void APIServer::loop() { if (this->api_connection_count_ == 0) { // Check reboot timeout - done in loop to avoid scheduler heap churn - // (cancelled scheduler items sit in heap memory until their scheduled time) - if (this->reboot_timeout_ != 0) { + // (cancelled scheduler items sit in heap memory until their scheduled time). + // Suppressed while a provisioning window is pending so the device waits to be + // onboarded / reset instead of rebooting itself; resumes once provisioned. + if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_connected_ > this->reboot_timeout_) { ESP_LOGE(TAG, "No clients; rebooting"); @@ -194,7 +218,8 @@ void APIServer::remove_client_(uint8_t client_index) { this->clients_[last_index].reset(); // Last client disconnected - set warning and start tracking for reboot timeout - if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { + // (suppressed while provisioning is pending - see loop()). + if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); this->last_connected_ = App.get_loop_component_start_time(); } @@ -232,7 +257,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { conn->start(); // First client connected - clear warning and update timestamp - if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_clear_warning(); this->last_connected_ = App.get_loop_component_start_time(); } @@ -572,8 +597,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { } SavedNoisePsk new_saved_psk{psk}; - return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"), - make_active); + bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"), + make_active); +#ifdef USE_PROVISIONING + // The device now has a key; report provisioned so the provisioning window is + // satisfied and the reboot timeout resumes normal operation. + if (result && provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true); + } +#endif + return result; #endif } bool APIServer::clear_noise_psk(bool make_active) { @@ -584,8 +617,16 @@ bool APIServer::clear_noise_psk(bool make_active) { return false; #else SavedNoisePsk empty_psk{}; - return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), - make_active); + bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), + make_active); +#ifdef USE_PROVISIONING + // The key was cleared; report unprovisioned so a subsequent reboot reopens the + // provisioning window. + if (result && provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false); + } +#endif + return result; #endif } #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 16b5762f68..248b83a0ff 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -14,6 +14,9 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -255,6 +258,19 @@ class APIServer final : public Component, // Remove a disconnected client by index. Swaps with the last populated slot and resets it. void __attribute__((noinline)) remove_client_(uint8_t client_index); +#ifdef USE_PROVISIONING + // True while a configured provisioning window is still pending (the device is + // unprovisioned). Suppresses the reboot timeout and its warning so the device is + // not auto-rebooted while waiting to be provisioned. False when no provisioning + // window is configured. + bool provisioning_pending_() const { + return provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); + } +#else + bool provisioning_pending_() const { return false; } +#endif + #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active); @@ -332,7 +348,10 @@ class APIServer final : public Component, uint8_t listen_backlog_{4}; bool shutting_down_ = false; uint8_t api_connection_count_{0}; - // 7 bytes used, 1 byte padding +#if defined(USE_PROVISIONING) && defined(USE_API_NOISE) + // Index assigned by the provisioning manager for reporting this transport's state. + uint8_t provisioning_source_{0}; +#endif #ifdef USE_API_NOISE APINoiseContext noise_ctx_; diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index e6fcc018d9..6e3a4ef526 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -7,6 +7,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif + #ifdef USE_ESP32 namespace esphome::esp32_improv { @@ -41,6 +45,15 @@ void ESP32ImprovComponent::setup() { #endif global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + ESP_LOGD(TAG, "Provisioning window closed; stopping Improv"); + this->stop(); + }); + } +#endif + // Start with loop disabled - will be enabled by start() when needed this->disable_loop(); } @@ -282,6 +295,15 @@ void ESP32ImprovComponent::start() { if (this->should_start_ || this->state_ != improv::STATE_STOPPED) return; +#ifdef USE_PROVISIONING + // Don't (re)start advertising once the provisioning window has closed - e.g. when + // wifi tries to restart Improv after the window expired at runtime. + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + ESP_LOGD(TAG, "Provisioning window closed; not starting Improv"); + return; + } +#endif + ESP_LOGD(TAG, "Setting Improv to start"); this->should_start_ = true; this->enable_loop(); @@ -338,6 +360,15 @@ void ESP32ImprovComponent::process_incoming_data_() { this->incoming_data_.clear(); return; } +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->closed()) { + ESP_LOGW(TAG, "Provisioning window closed; refusing settings"); + this->set_error_(improv::ERROR_NOT_AUTHORIZED); + this->incoming_data_.clear(); + return; + } +#endif if (wifi::global_wifi_component->is_disabled()) { // Wi-Fi is disabled, so we can't provision. Respond immediately // instead of letting the client wait out its provisioning timeout. diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index b7dfb8d6d2..0f4bcb3e16 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -25,6 +25,20 @@ NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) IPAddress = network_ns.class_("IPAddress") +def _register_provisioning_source(config: ConfigType) -> ConfigType: + """Register network connectivity as a provisioning source. + + The network component is auto-loaded whenever an interface (wifi, ethernet, ...) + is configured, so a device with connectivity always has this source: it is + considered provisioned once it has connected via any interface, and + `provisioning:` is valid without another source. + """ + from esphome.components import provisioning + + provisioning.register_source("network") + return config + + def ip_address_literal(ip: str | int | None) -> cg.MockObj: """Generate an IPAddress with compile-time initialization instead of runtime parsing. @@ -128,36 +142,41 @@ def validate_ipv6(value: bool) -> bool: return value -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(NetworkComponent), - cv.SplitDefault( - CONF_ENABLE_IPV6, - bk72xx=False, - esp32=False, - esp8266=False, - host=False, - rp2=False, - nrf52=True, - ): cv.All( - cv.boolean, - cv.Any( - cv.require_framework_version( - bk72xx_arduino=cv.Version(1, 7, 0), - esp_idf=cv.Version(0, 0, 0), - esp32_arduino=cv.Version(0, 0, 0), - esp8266_arduino=cv.Version(0, 0, 0), - host=cv.Version(0, 0, 0), - rp2_arduino=cv.Version(0, 0, 0), - nrf52_zephyr=cv.Version(0, 0, 0), +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(NetworkComponent), + cv.SplitDefault( + CONF_ENABLE_IPV6, + bk72xx=False, + esp32=False, + esp8266=False, + host=False, + rp2=False, + nrf52=True, + ): cv.All( + cv.boolean, + cv.Any( + cv.require_framework_version( + bk72xx_arduino=cv.Version(1, 7, 0), + esp_idf=cv.Version(0, 0, 0), + esp32_arduino=cv.Version(0, 0, 0), + esp8266_arduino=cv.Version(0, 0, 0), + host=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), + nrf52_zephyr=cv.Version(0, 0, 0), + ), + cv.boolean_false, ), - cv.boolean_false, + validate_ipv6, ), - validate_ipv6, - ), - cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, - cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32), - } + cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, + cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All( + cv.boolean, cv.only_on_esp32 + ), + } + ), + _register_provisioning_source, ) diff --git a/esphome/components/provisioning/__init__.py b/esphome/components/provisioning/__init__.py new file mode 100644 index 0000000000..36fa69357a --- /dev/null +++ b/esphome/components/provisioning/__init__.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass, field +import logging + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ON_TIMEOUT, CONF_TIMEOUT +from esphome.core import CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] +DOMAIN = "provisioning" + +_LOGGER = logging.getLogger(__name__) + +provisioning_ns = cg.esphome_ns.namespace("provisioning") +ProvisioningManager = provisioning_ns.class_("ProvisioningManager", cg.Component) + + +@dataclass +class ProvisioningData: + # Names of the components that registered as a provisioning source this run. + sources: set[str] = field(default_factory=set) + # Names of source components that have their credentials set in the config. + hardcoded_credentials: set[str] = field(default_factory=set) + + +def _get_data() -> ProvisioningData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = ProvisioningData() + return CORE.data[DOMAIN] + + +def register_source(name: str) -> None: + """Record that ``name`` is a provisioning source for this configuration. + + A provisioning-capable component (a transport that boots unprovisioned and is + set up by the controller on first connection, or a network interface that + provisions once connected) calls this while its own config is being processed, + typically from a schema validator. `provisioning:` then confirms at least one + source is present without inspecting the full config or knowing about any + specific component. State lives in CORE.data, which is cleared between runs. + """ + _get_data().sources.add(name) + + +def report_hardcoded_credentials(name: str) -> None: + """Record that source component ``name`` has its credentials set in the config. + + A source component calls this from its own validator when it finds baked-in + credentials (a WiFi SSID/password, an API encryption key, ...). `provisioning:` + warns about these, since a device that ships with credentials does not need a + provisioning window. The warning is emitted here, by `provisioning:`, so the + source components stay unaware of it. + """ + _get_data().hardcoded_credentials.add(name) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ProvisioningManager), + cv.Required(CONF_TIMEOUT): cv.All( + cv.positive_not_null_time_period, cv.positive_time_period_milliseconds + ), + cv.Optional(CONF_ON_TIMEOUT): automation.validate_automation(single=True), + } +).extend(cv.COMPONENT_SCHEMA) + + +def _final_validate(config: ConfigType) -> ConfigType: + """Validate the provisioning setup once every component has been processed. + + Sources register during their own config validation, so by final validation + both the source set and the hardcoded-credentials set are complete. + """ + data = _get_data() + if not data.sources: + raise cv.Invalid( + "'provisioning' requires at least one provisioning-capable component: " + "configure a network interface such as 'wifi:' or 'ethernet:', or enable " + "'api:' with 'encryption:' and no 'key:' so the device boots " + "unprovisioned and is configured on first connection." + ) + if data.hardcoded_credentials: + _LOGGER.warning( + "'provisioning' is configured, but credentials are set in the " + "configuration for: %s. A device that uses a provisioning window should " + "ship without credentials so they are set on first connection; " + "hardcoding them makes the window pointless.", + ", ".join(sorted(data.hardcoded_credentials)), + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_PROVISIONING") + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add(var.set_timeout(config[CONF_TIMEOUT])) + if on_timeout := config.get(CONF_ON_TIMEOUT): + await automation.build_automation(var.get_timeout_trigger(), [], on_timeout) diff --git a/esphome/components/provisioning/provisioning.cpp b/esphome/components/provisioning/provisioning.cpp new file mode 100644 index 0000000000..02c089bfed --- /dev/null +++ b/esphome/components/provisioning/provisioning.cpp @@ -0,0 +1,92 @@ +#include "esphome/components/provisioning/provisioning.h" +#ifdef USE_PROVISIONING +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#ifdef USE_NETWORK +#include "esphome/components/network/util.h" +#endif + +#include + +namespace esphome::provisioning { + +static const char *const TAG = "provisioning"; + +ProvisioningManager *global_provisioning_manager = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + nullptr; + +ProvisioningManager::ProvisioningManager() { + global_provisioning_manager = this; +#ifdef USE_NETWORK + // Network connectivity is a built-in provisioning source. Registered here rather + // than from a source's setup() because connectivity is universal, not a pluggable + // transport; loop() latches it provisioned once the device has connected. + this->network_source_ = this->register_source(); +#endif +} + +uint8_t ProvisioningManager::register_source() { + if (this->source_count_ >= MAX_SOURCES) { + // Defensive: only a handful of sources exist in practice. Fail loudly rather + // than shifting past the mask width (undefined behavior). The returned index is + // ignored by set_source_provisioned()'s bounds check. + ESP_LOGE(TAG, "Too many provisioning sources (max %u)", MAX_SOURCES); + return this->source_count_; + } + uint8_t source = this->source_count_++; + this->registered_mask_ |= (1UL << source); + return source; +} + +void ProvisioningManager::loop() { + // Sources register during their own setup() (at various priorities), and this + // loop() also runs while waiting on a slow component during setup. Evaluating the + // provisioning state before every source has registered could conclude + // "provisioned" prematurely and disable_loop() for good, defeating the window -- + // so do nothing until all setup() calls are done. + if (!App.is_setup_complete()) + return; + +#ifdef USE_NETWORK + // Latch the built-in connectivity source once the device has been reachable via + // any interface. network::is_connected() aggregates wifi/ethernet/modem/... (OR + // across interfaces), and a disabled interface never connects so it never + // contributes. Latched: a later link drop does not un-provision -- the RAM-only + // window still reopens only on reboot. + if ((this->provisioned_mask_ & (1UL << this->network_source_)) == 0 && network::is_connected()) + this->set_source_provisioned(this->network_source_, true); +#endif + + // The window is resolved once the device is provisioned or the window has closed; + // there is nothing left to track, so stop running entirely. Config validation + // guarantees at least one source, so is_provisioned() is never vacuously true here. + if (this->closed_ || this->is_provisioned()) { + this->disable_loop(); + return; + } + // The window timer runs from boot (millis since boot). The closed state is not + // persisted, so a reboot reopens the window. + if (this->timeout_ != 0 && App.get_loop_component_start_time() > this->timeout_) { + this->close_window_(); + } +} + +void ProvisioningManager::close_window_() { + this->closed_ = true; + ESP_LOGW(TAG, "Window expired; cycle power to reopen window"); + // Notify internal consumers first (transports disconnect clients, Improv stops), + // then fire the user-facing automation. + this->closed_callback_.call(); + this->timeout_trigger_.trigger(); +} + +void ProvisioningManager::dump_config() { + ESP_LOGCONFIG(TAG, + "Provisioning:\n" + " Timeout: %" PRIu32 "ms\n" + " Provisioned: %s", + this->timeout_, YESNO(this->is_provisioned())); +} + +} // namespace esphome::provisioning +#endif // USE_PROVISIONING diff --git a/esphome/components/provisioning/provisioning.h b/esphome/components/provisioning/provisioning.h new file mode 100644 index 0000000000..e21b8f3ef0 --- /dev/null +++ b/esphome/components/provisioning/provisioning.h @@ -0,0 +1,96 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_PROVISIONING +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::provisioning { + +// Central provisioning-window manager (EN18031). A device that ships unprovisioned +// (secure transports enabled with no credentials, configured by the controller on +// first connection) opens a provisioning window at boot. Each transport that needs +// provisioning registers as a "source" and reports its state; the device is +// considered provisioned once every registered source is provisioned. +// +// Network connectivity is a built-in source: a device with a network interface but +// no other provisioning-capable component (no api encryption, etc.) is still +// considered provisioned once it has connected via any interface -- so an +// Improv-only device reports its state correctly. +// +// If the window times out while still unprovisioned it closes: the closed state is +// RAM-only (a power cycle / reset reopens it) and the `on_timeout` automation fires. +// Components query window_pending()/closed() to suppress reboot timeouts and refuse +// further provisioning. This manager owns no transport knowledge; transports +// (api, and later mqtt/wireguard/...) drive it through the source API. +class ProvisioningManager : public Component { + public: + // Maximum number of provisioning sources, limited by the width of the state masks. + static constexpr uint8_t MAX_SOURCES = 32; + + ProvisioningManager(); + + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BEFORE_CONNECTION; } + + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } + + // Register a provisioning source. Returns a bit index the source uses to report + // its state via set_source_provisioned(). Call once, from the source's setup(). + uint8_t register_source(); + // Report whether the given source currently holds valid credentials. + void set_source_provisioned(uint8_t source, bool provisioned) { + if (source >= MAX_SOURCES) + return; + if (provisioned) { + this->provisioned_mask_ |= (1UL << source); + } else { + this->provisioned_mask_ &= ~(1UL << source); + } + } + + // True once every registered source is provisioned. Config validation guarantees + // at least one source, and the built-in connectivity source registers in the + // constructor, so registered_mask_ is never zero in practice. + bool is_provisioned() const { return (this->provisioned_mask_ & this->registered_mask_) == this->registered_mask_; } + // True while provisioning is still pending: the device is unprovisioned, whether + // the window is still open or has already closed. Reboot timeouts are suppressed + // while this holds so the device never auto-reboots (and silently reopens the + // window) while unprovisioned. + bool window_pending() const { return !this->is_provisioned(); } + // True once the window has expired without the device being provisioned. + bool closed() const { return this->closed_; } + + // Register a callback fired once when the window closes (runtime expiry). Used + // internally by transports/Improv to stop accepting provisioning. The user-facing + // on_timeout automation is wired to get_timeout_trigger() instead. + template void add_on_closed_callback(F &&callback) { + this->closed_callback_.add(std::forward(callback)); + } + Trigger<> *get_timeout_trigger() { return &this->timeout_trigger_; } + + protected: + void close_window_(); + + Trigger<> timeout_trigger_; + LazyCallbackManager closed_callback_; + uint32_t timeout_{0}; + uint32_t registered_mask_{0}; + uint32_t provisioned_mask_{0}; + uint8_t source_count_{0}; + bool closed_{false}; +#ifdef USE_NETWORK + // Built-in connectivity source (see loop()): registered in the constructor and + // latched provisioned once the device has connected via any network interface. + uint8_t network_source_{0}; +#endif +}; + +extern ProvisioningManager *global_provisioning_manager; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::provisioning +#endif // USE_PROVISIONING diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index dc5c8be4d7..137304c807 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -436,6 +436,21 @@ def _validate(config): return config +def _report_provisioning_credentials(config): + """Report baked-in STA credentials to the provisioning component (if used). + + `_validate` has already folded any ``ssid``/``password`` into ``networks``, so a + non-empty list means credentials are set in the config. `provisioning:` warns + about this, since a device that uses a provisioning window should get its + credentials on first connection instead. + """ + if config.get(CONF_NETWORKS): + from esphome.components import provisioning + + provisioning.report_hardcoded_credentials("wifi") + return config + + CONF_PASSIVE_SCAN = "passive_scan" FAST_CONNECT_SCHEMA = cv.Schema( @@ -517,6 +532,7 @@ CONFIG_SCHEMA = cv.All( ), _apply_min_auth_mode_default, _validate, + _report_provisioning_credentials, ) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index c951e74358..44e3cb6af9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -45,6 +45,10 @@ #include "esphome/components/improv_serial/improv_serial_component.h" #endif +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif + namespace esphome::wifi { static const char *const TAG = "wifi"; @@ -872,8 +876,20 @@ void WiFiComponent::loop() { if (!this->has_ap() && this->reboot_timeout_ != 0) { if (now - this->last_connected_ > this->reboot_timeout_) { - ESP_LOGE(TAG, "Can't connect; rebooting"); - App.reboot(); + bool suppress = false; +#ifdef USE_PROVISIONING + // Don't reboot while a provisioning window is pending (device unprovisioned). + // The device is legitimately waiting to be onboarded (Wi-Fi must come up + // before the controller can set credentials), and an auto-reboot would reopen + // the window without the deliberate power cycle / reset that is meant to be + // required. Resumes normal reboot behavior once provisioned. + suppress = provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); +#endif + if (!suppress) { + ESP_LOGE(TAG, "Can't connect; rebooting"); + App.reboot(); + } } } } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1d09bb5c5c..639508a7b2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -153,6 +153,7 @@ #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP +#define USE_PROVISIONING #define USE_QR_CODE #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 diff --git a/tests/component_tests/provisioning/test_provisioning.py b/tests/component_tests/provisioning/test_provisioning.py new file mode 100644 index 0000000000..07f5065241 --- /dev/null +++ b/tests/component_tests/provisioning/test_provisioning.py @@ -0,0 +1,84 @@ +"""Tests for the provisioning component config validation.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome import config_validation as cv +from esphome.components.provisioning import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + register_source, + report_hardcoded_credentials, +) +from esphome.const import CONF_TIMEOUT, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_provisioning_requires_a_source( + set_core_config: SetCoreConfigCallable, +) -> None: + """Provisioning with no registered source is a config error. + + Sources register themselves during their own config validation; with none + registered the window could never resolve, so validation fails. + """ + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="provisioning-capable component"): + FINAL_VALIDATE_SCHEMA({}) + + +def test_provisioning_accepts_a_registered_source( + set_core_config: SetCoreConfigCallable, +) -> None: + """A component that registered as a provisioning source satisfies validation.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + # Should not raise. + assert FINAL_VALIDATE_SCHEMA({}) == {} + + +def test_provisioning_warns_on_hardcoded_credentials( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A source with credentials set in the config triggers a warning.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + report_hardcoded_credentials("wifi") + with caplog.at_level(logging.WARNING): + assert FINAL_VALIDATE_SCHEMA({}) == {} + assert "wifi" in caplog.text + assert "credentials" in caplog.text + + +def test_provisioning_no_warning_without_hardcoded_credentials( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """No credentials warning when no source reports hardcoded credentials.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + with caplog.at_level(logging.WARNING): + assert FINAL_VALIDATE_SCHEMA({}) == {} + assert "credentials" not in caplog.text + + +def test_provisioning_rejects_zero_timeout( + set_core_config: SetCoreConfigCallable, +) -> None: + """A zero timeout would leave the window open forever, so it is rejected.""" + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_TIMEOUT: "0s"}) + + +def test_provisioning_accepts_positive_timeout( + set_core_config: SetCoreConfigCallable, +) -> None: + """A positive timeout is accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({CONF_TIMEOUT: "5min"}) + assert config[CONF_TIMEOUT].total_milliseconds == 300000 diff --git a/tests/components/provisioning/test.esp32-idf.yaml b/tests/components/provisioning/test.esp32-idf.yaml new file mode 100644 index 0000000000..24168881fc --- /dev/null +++ b/tests/components/provisioning/test.esp32-idf.yaml @@ -0,0 +1,25 @@ +# Exercises the provisioning window: api registers as a provisioning source +# (encryption enabled, no key), the on_timeout automation, and the wifi + +# esp32_improv cross-component guards. improv_serial is intentionally NOT gated. +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + +improv_serial: + +binary_sensor: + - platform: gpio + pin: 0 + id: io0_button + +esp32_improv: + authorizer: io0_button diff --git a/tests/components/provisioning/test.esp8266-ard.yaml b/tests/components/provisioning/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4188c00bef --- /dev/null +++ b/tests/components/provisioning/test.esp8266-ard.yaml @@ -0,0 +1,16 @@ +# Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source +# and the wifi reboot guard. improv_serial is present and intentionally NOT gated. +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + +improv_serial: diff --git a/tests/components/provisioning/validate.esp32-idf.yaml b/tests/components/provisioning/validate.esp32-idf.yaml new file mode 100644 index 0000000000..1fd3d67882 --- /dev/null +++ b/tests/components/provisioning/validate.esp32-idf.yaml @@ -0,0 +1,15 @@ +# A device provisioned over the network (wifi / Improv) with no api: network +# connectivity alone satisfies provisioning, so `provisioning:` is valid without an +# api encryption source. Config-only -- exercises the network provisioning-source +# validation path (the Improv-only case from the review). +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +wifi: + ssid: MySSID + password: password1 + +improv_serial: From 2f5465c0e85effce2792df0a8f8f3e1317591d4c Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 8 Jul 2026 12:07:42 -0400 Subject: [PATCH 2032/2214] [sendspin] Suppress WiFi roam scanning while playing (#17133) --- esphome/components/sendspin/__init__.py | 1 + esphome/components/sendspin/sendspin_hub.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index e8c643f9b9..97e7f4e22c 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -138,6 +138,7 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType: socket.consume_sockets(1, "sendspin_websocket_client")(config) wifi.enable_runtime_power_save_control() + wifi.enable_runtime_roaming_suppression() return config diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 57709306cd..b95d95b2bc 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -129,6 +129,7 @@ void SendspinHub::on_request_high_performance() { #ifdef USE_WIFI if (wifi::global_wifi_component != nullptr) { wifi::global_wifi_component->request_high_performance(); + wifi::global_wifi_component->request_roaming_suppression(); } #endif } @@ -137,6 +138,7 @@ void SendspinHub::on_release_high_performance() { #ifdef USE_WIFI if (wifi::global_wifi_component != nullptr) { wifi::global_wifi_component->release_high_performance(); + wifi::global_wifi_component->release_roaming_suppression(); } #endif } From bba3a9657bae2ac8edf3e1cc63c0b58154f9ac28 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:17:57 +1000 Subject: [PATCH 2033/2214] [lvgl] Add animations (#16796) Co-authored-by: clydeps Co-authored-by: Claude Opus 4.8 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 7 +- esphome/components/lvgl/animation.h | 197 +++++++++++++ esphome/components/lvgl/animation.py | 295 +++++++++++++++++++ esphome/components/lvgl/defines.py | 23 +- esphome/components/lvgl/lv_validation.py | 62 ++-- esphome/components/lvgl/types.py | 1 + esphome/core/defines.h | 1 + tests/component_tests/lvgl/test_animation.py | 201 +++++++++++++ tests/components/lvgl/lvgl-package.yaml | 57 ++++ tests/components/lvgl/test.host.yaml | 39 ++- 10 files changed, 854 insertions(+), 29 deletions(-) create mode 100644 esphome/components/lvgl/animation.h create mode 100644 esphome/components/lvgl/animation.py create mode 100644 tests/component_tests/lvgl/test_animation.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index ecc4b0a777..b758390f0d 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -52,9 +52,11 @@ from esphome.writer import clean_build from esphome.yaml_util import load_yaml from . import defines as df, lv_validation as lvalid, widgets +from .animation import ANIMATION_SCHEMA, add_animation_triggers, animations_to_code from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, + CONF_ANIMATIONS, LOGGER, add_lv_use, get_focused_widgets, @@ -435,7 +437,8 @@ async def to_code(configs): await layers_to_code(lv_component, config) await lvgl_update(lv_component, config) await msgboxes_to_code(lv_component, config) - # await disp_update(lv_component.get_disp(), config) + await animations_to_code(config.get(CONF_ANIMATIONS, [])) + # Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed. set_widgets_completed(True) async with LvContext(): @@ -443,6 +446,7 @@ async def to_code(configs): await generate_align_tos(configs[0]) for config in configs: lv_component = await cg.get_variable(config[CONF_ID]) + await add_animation_triggers(config.get(CONF_ANIMATIONS, [])) await generate_page_triggers(config) await initial_focus_to_code(config) for conf in config.get(CONF_ON_IDLE, ()): @@ -636,6 +640,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( for x in SIMPLE_TRIGGERS }, cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_ANIMATIONS): cv.ensure_list(ANIMATION_SCHEMA), cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h new file mode 100644 index 0000000000..1e0abce358 --- /dev/null +++ b/esphome/components/lvgl/animation.h @@ -0,0 +1,197 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_LVGL_ANIMATION +#include "lvgl_esphome.h" +#include "esphome/core/hal.h" + +namespace esphome::lvgl { + +enum class AnimationState { + STOPPED, + STARTED, + RUNNING, +}; + +class LvAnimationTiming { + public: + // Map progress in the range [0, 1] + virtual float map_progress(float value) = 0; +}; + +class LvAnimationTimingRoundTrip : public LvAnimationTiming { + public: + float map_progress(float value) override { + value *= 2.0f; + if (value > 1.0f) + return 2.0f - value; + return value; + } +}; + +class LvAnimationTimingGravity : public LvAnimationTiming { + public: + LvAnimationTimingGravity(float acceleration, float bounce) : acceleration_(acceleration), bounce_(bounce) {} + float map_progress(float value) override { + if (value == 0.0f) { + this->initial_position_ = 0.0f; + this->initial_speed_ = 0.0f; + this->initial_time_ = 0.0f; + } + auto position = this->calc_pos_(value); + if (position > 1.0f) { + auto initial_time = this->calc_end_time_(); + this->initial_speed_ = -this->calc_speed_(initial_time) * this->bounce_; + this->initial_position_ = 1.0f; + this->initial_time_ = initial_time; + position = calc_pos_(value); + if (position > 1.0f) { + position = 1.0f; + } + } + return position; + } + + protected: + float calc_pos_(float value) const { + value -= this->initial_time_; + return (0.5 * value * this->acceleration_ + this->initial_speed_) * value + this->initial_position_; + } + + float calc_speed_(float value) const { + value -= this->initial_time_; + return this->acceleration_ * value + this->initial_speed_; + } + + float calc_end_time_() const { + return (-this->initial_speed_ + std::sqrt(this->initial_speed_ * this->initial_speed_ - + 4.0f * this->acceleration_ / 2.0 * (this->initial_position_ - 1.0f))) / + this->acceleration_ + + this->initial_time_; + } + + float acceleration_; + float bounce_; + float initial_position_{0.0f}; + float initial_time_{0.0f}; + float initial_speed_{0.0f}; +}; + +class LvAnimationTimingEaseInOut : public LvAnimationTiming { + public: + LvAnimationTimingEaseInOut(float slope) : slope_(slope) {} + float map_progress(float value) override { + float sqr = value * value; + sqr = sqr / (2.0f * (sqr - value) + 1.0f); + return this->slope_ * sqr + (1.0 - this->slope_) * value; + } + + protected: + float slope_; +}; + +template class LvAnimation : public Component { + public: + LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector> from, + std::vector> to) + : update_callback_(update_callback) { + std::copy(from.begin(), from.end(), this->from_); + std::copy(to.begin(), to.end(), this->to_); + } + + void start() { + if (this->state_ > AnimationState::STOPPED) + this->stop(); + if (this->duration_ == 0) + return; + // evaluate any lambdas + for (size_t i = 0; i != DATA_SIZE; i++) { + this->data_from_[i] = this->from_[i].value(); + this->data_to_[i] = this->to_[i].value(); + } + this->start_time_ = millis(); + this->state_ = AnimationState::STARTED; + this->loop(); + this->start_callback_.call(); + } + + void stop() { + // Only fire the stop callback on a genuine running -> stopped transition, so that + // repeated stop() calls (e.g. start() pre-clearing a stopped animation) don't re-fire it. + if (this->state_ == AnimationState::STOPPED) + return; + this->state_ = AnimationState::STOPPED; + this->stop_callback_.call(); + } + + void setup() override { + if constexpr (AUTO_START) + this->start(); + } + + void loop() override { + if (this->state_ == AnimationState::STOPPED) + return; + uint32_t elapsed = millis() - this->start_time_; + float progress = static_cast(elapsed) / static_cast(this->duration_); + switch (this->state_) { + case AnimationState::STARTED: + if (elapsed < this->start_delay_) + return; + this->state_ = AnimationState::RUNNING; + this->start_time_ = millis(); + progress = 0.0f; + break; + case AnimationState::RUNNING: + if (progress >= 1.0f) { + progress = 1.0f; + this->stop(); + if (this->loop_) + this->start(); + } + break; + default: + return; + } + + for (auto *timing : this->timings_) { + progress = timing->map_progress(progress); + } + lv_coord_t data[DATA_SIZE]; + for (size_t i = 0; i != DATA_SIZE; i++) { + data[i] = static_cast( + roundf(this->data_from_[i] + static_cast(this->data_to_[i] - this->data_from_[i]) * progress)); + } + this->update_callback_(data); + } + + float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0; } + void set_duration(uint32_t duration) { this->duration_ = duration; } + void set_start_delay(uint32_t start_delay) { this->start_delay_ = start_delay; } + void add_timing(LvAnimationTiming *timing) { this->timings_.push_back(timing); } + void set_loop(bool loop) { this->loop_ = loop; } + + template void add_on_start_callback(F &&callback) { + this->start_callback_.add(std::forward(callback)); + } + template void add_on_stop_callback(F &&callback) { this->stop_callback_.add(std::forward(callback)); } + + protected: + void (*const update_callback_)(const lv_coord_t *data); + LazyCallbackManager start_callback_{}; + LazyCallbackManager stop_callback_{}; + TemplatableValue from_[DATA_SIZE]{}; + TemplatableValue to_[DATA_SIZE]{}; + uint32_t duration_{0}; + uint32_t start_delay_{0}; + uint32_t start_time_{0}; + lv_coord_t data_from_[DATA_SIZE]{0}; + lv_coord_t data_to_[DATA_SIZE]{0}; + AnimationState state_{AnimationState::STOPPED}; + std::vector timings_{}; + bool loop_{false}; +}; + +} // namespace esphome::lvgl + +#endif // USE_LVGL_ANIMATION diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py new file mode 100644 index 0000000000..2b1500f2c4 --- /dev/null +++ b/esphome/components/lvgl/animation.py @@ -0,0 +1,295 @@ +from esphome import automation, codegen as cg, config_validation as cv +from esphome.automation import Trigger, build_automation +from esphome.config_validation import COMPONENT_SCHEMA +from esphome.const import ( + CONF_ACCELERATION, + CONF_DURATION, + CONF_FROM, + CONF_ID, + CONF_ON_START, + CONF_TIMING, + CONF_TO, + CONF_TRIGGER_ID, + CONF_TYPE, + CONF_WEIGHT, +) +from esphome.cpp_generator import MockObj, TemplateArguments + +from ..const import CONF_LOOP +from .defines import ( + CONF_AUTO_START, + CONF_LVGL_ID, + CONF_ON_STOP, + CONF_WIDGETS, + LValidator, + add_define, + literal, +) +from .lv_validation import ( + color, + get_component_colors, + lv_color, + lv_milliseconds, + lv_positive_float, + lv_zero_to_one_float, +) +from .lvcode import LVGL_COMP_ARG, LambdaContext, LvglComponent, lv_add +from .schemas import STYLE_PROPS +from .types import LvAnimation, LvglAction, lv_color_t, lv_coord_t, lv_obj_t, lvgl_ns +from .widgets import get_widgets + +LvAnimationTimingRoundTrip = lvgl_ns.class_("LvAnimationTimingRoundTrip") +LvAnimationTimingEaseInOut = lvgl_ns.class_("LvAnimationTimingEaseInOut") + +CONF_BOUNCE = "bounce" + + +def timing_class(name, extras=None): + # Convert config option to camel case + cls_name = "LvAnimationTiming" + "".join([w.capitalize() for w in name.split("_")]) + cls = lvgl_ns.class_(cls_name) + schema = cv.Schema({cv.GenerateID(): cv.declare_id(cls)}) + if extras: + schema = schema.extend(extras) + return name, schema + + +# TODO - currently the order of arguments to timing classes is expected to be alphabetical, but this is not enforced. +# It would be better to have a more robust way of passing arguments to the timing classes. +TIMING_SCHEMA = cv.maybe_simple_value( + cv.typed_schema( + dict( + [ + timing_class("round_trip"), + timing_class( + "ease_in_out", + {cv.Optional(CONF_WEIGHT, default=2.0): lv_positive_float}, + ), + timing_class( + "gravity", + { + cv.Optional(CONF_ACCELERATION, default=0.5): lv_positive_float, + cv.Optional(CONF_BOUNCE, default=0.5): lv_zero_to_one_float, + }, + ), + ] + ), + default_type="ease_in_out", + ), + key=CONF_TYPE, +) + +CONF_START_DELAY = "start_delay" + + +class LiteralColorValidator(LValidator): + def __init__(self): + super().__init__( + color, lv_color_t, retmapper=get_component_colors, animatable=True + ) + + def __call__(self, value): + if isinstance(value, cv.Lambda): + raise cv.Invalid( + "An animated color may not be set with a lambda, only a literal color value." + ) + return super().__call__(value) + + +literal_color = LiteralColorValidator() + + +def from_to(validator): + return cv.Schema( + { + cv.Required(CONF_FROM): validator, + cv.Required(CONF_TO): validator, + } + ) + + +# Colors can only be animated between constants, not lambdas. +def map_v(validator): + if validator == lv_color: + return literal_color + return validator + + +ANIMABLE_STYLES = { + k: map_v(v) + for k, v in STYLE_PROPS.items() + if isinstance(v, LValidator) and v.animatable +} + +ANIMATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_AUTO_START, default=False): cv.boolean, + cv.Optional(CONF_LOOP, default=False): cv.boolean, + cv.Optional(CONF_DURATION, default="5s"): lv_milliseconds, + cv.Optional(CONF_START_DELAY, default="0s"): lv_milliseconds, + cv.Optional(CONF_TIMING, default=[]): cv.ensure_list(TIMING_SCHEMA), + cv.Required(CONF_ID): cv.declare_id(LvAnimation), + cv.Optional(CONF_ON_START): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()), + } + ), + cv.Optional(CONF_ON_STOP): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()), + } + ), + cv.Required(CONF_WIDGETS): cv.ensure_list( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_obj_t), + } + ).extend({cv.Optional(k): from_to(v) for k, v in ANIMABLE_STYLES.items()}) + ), + } +).extend(COMPONENT_SCHEMA) + + +async def _process_arg(validator, arg) -> list: + # from/to values are evaluated at animation start with no arguments, so the + # generated lambda must be parameterless rather than inheriting the enclosing + # update-callback's `values` parameter. + value = await validator.process(arg, args=[], raw_lambda=True) + value = list(value) if isinstance(value, tuple) else [value] + return [literal(f"TemplatableValue({v})") for v in value] + + +async def animations_to_code(config): + for animation in config: + add_define("USE_LVGL_ANIMATION") + widgets = animation[CONF_WIDGETS] + async with LambdaContext( + [(lv_coord_t.operator("const").operator("ptr"), "values")] + ) as ctx: + froms = [] + tos = [] + for widget in widgets: + w = (await get_widgets(widget))[0] + props = [(k, v) for k, v in widget.items() if k in ANIMABLE_STYLES] + for prop, value_range in props: + # prop is the style property, value_range is a dict with from: and to: values + validator = ANIMABLE_STYLES[prop] + from_value = await _process_arg(validator, value_range[CONF_FROM]) + to_value = await _process_arg(validator, value_range[CONF_TO]) + index = len(froms) + if len(from_value) == 1: + value = f"values[{index}]" + else: + value = f"lv_color_make(values[{index}+0], values[{index}+1], values[{index}+2])" + w.set_style(prop, literal(value), 0) + # The value arrays are extended by 1 item for scalar properties, 3 for colors + froms.extend(from_value) + tos.extend(to_value) + + data_size = len(froms) + loop = animation[CONF_LOOP] + start_delay = await lv_milliseconds.process(animation.get(CONF_START_DELAY)) + var = cg.new_Pvariable( + animation[CONF_ID], + TemplateArguments(data_size, animation[CONF_AUTO_START]), + await ctx.get_lambda(), + froms, + tos, + ) + for timing in animation[CONF_TIMING]: + timing_id = timing[CONF_ID] + args = sorted( + [(k, v) for k, v in timing.items() if k not in [CONF_ID, CONF_TYPE]] + ) + args = [v for k, v in args] + timing_var = cg.new_Pvariable(timing_id, *args) + cg.add(var.add_timing(timing_var)) + + if start_delay: + cg.add(var.set_start_delay(start_delay)) + if loop: + cg.add(var.set_loop(loop)) + cg.add( + var.set_duration(await lv_milliseconds.process(animation[CONF_DURATION])) + ) + await cg.register_component(var, animation) + + +async def add_animation_triggers(config): + async def add_triggers(animation: MockObj, event: str, config: dict) -> None: + for conf in config: + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await build_automation(trigger, [], conf) + async with LambdaContext([]) as context: + lv_add(trigger.trigger()) + lv_add( + getattr( + animation, + f"add_{event}_callback", + )(await context.get_lambda()) + ) + + for animation in config: + var = await cg.get_variable(animation[CONF_ID]) + await add_triggers(var, CONF_ON_START, animation.get(CONF_ON_START, [])) + await add_triggers(var, CONF_ON_STOP, animation.get(CONF_ON_STOP, [])) + + +@automation.register_action( + "lvgl.animation.start", + LvglAction, + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)), + cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent), + cv.Optional(CONF_DURATION): lv_milliseconds, + cv.Optional(CONF_START_DELAY): lv_milliseconds, + cv.Optional(CONF_LOOP): cv.boolean, + }, + key=CONF_ID, + ), + synchronous=True, +) +async def start_animation(config, action_id, template_arg, args): + animations = config[CONF_ID] + loop = config.get(CONF_LOOP) + async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + for animation in animations: + anim_var = await cg.get_variable(animation) + if loop is not None: + context.add(anim_var.set_loop(loop)) + if (duration := config.get(CONF_DURATION)) is not None: + context.add( + anim_var.set_duration(await lv_milliseconds.process(duration)) + ) + if (start_delay := config.get(CONF_START_DELAY)) is not None: + context.add( + anim_var.set_start_delay(await lv_milliseconds.process(start_delay)) + ) + context.add(anim_var.start()) + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + await cg.register_parented(var, config[CONF_LVGL_ID]) + return var + + +@automation.register_action( + "lvgl.animation.stop", + LvglAction, + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)), + cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent), + }, + key=CONF_ID, + ), + synchronous=True, +) +async def stop_animation(config, action_id, template_arg, args): + animations = config[CONF_ID] + async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + for animation in animations: + anim_var = await cg.get_variable(animation) + context.add(anim_var.stop()) + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + await cg.register_parented(var, config[CONF_LVGL_ID]) + return var diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 15e593b3f6..5c75269c64 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -214,11 +214,14 @@ class LValidator: has `process()` to convert a value during code generation """ - def __init__(self, validator, rtype: MockObj, retmapper=None, requires=None): + def __init__( + self, validator, rtype: MockObj, retmapper=None, requires=None, animatable=False + ): self.validator = validator self.rtype = rtype self.retmapper = retmapper self.requires = requires + self.animatable = animatable def __call__(self, value): if self.requires: @@ -228,7 +231,10 @@ class LValidator: return self.validator(value) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: if value is None: return None @@ -236,11 +242,15 @@ class LValidator: # Local import to avoid circular import from .lvcode import get_lambda_context_args - args = args or get_lambda_context_args() + # `args is None` means "inherit the enclosing lambda context"; an explicit + # empty list means "no parameters" and must be preserved as-is. + if args is None: + args = get_lambda_context_args() - return call_lambda( - await cg.process_lambda(value, args, return_type=self.rtype) - ) + lamb = await cg.process_lambda(value, args, return_type=self.rtype) + if raw_lambda: + return lamb + return call_lambda(lamb) if self.retmapper is not None: return self.retmapper(value) if isinstance(value, ID): @@ -751,6 +761,7 @@ CONF_ON_DRAW_END = "on_draw_end" CONF_ON_PAUSE = "on_pause" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" +CONF_ON_STOP = "on_stop" CONF_OPA = "opa" CONF_NEXT = "next" CONF_PAD_ROW = "pad_row" diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 27cbfff694..d31c8324db 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -60,6 +60,7 @@ opacity = LValidator( opacity_validator, lv_opa_t, retmapper=lambda opa: StaticCastExpression(cg.uint8, opa * 255.0), + animatable=True, ) COLOR_NAMES = { @@ -223,35 +224,33 @@ def color(value): ) -def color_retmapper(value): - if isinstance(value, cv.Lambda): - return cv.returning_lambda(value) +def get_component_colors(value): if isinstance(value, str) and value in COLOR_NAMES: value = COLOR_NAMES[value] if isinstance(value, int): - return literal( - f"lv_color_make({(value >> 16) & 0xFF}, {(value >> 8) & 0xFF}, {value & 0xFF})" - ) + return value >> 16, value >> 8 & 0xFF, value & 0xFF if isinstance(value, ID): cval = [x for x in CORE.config[CONF_COLOR] if x[CONF_ID] == value][0] if CONF_HEX in cval: r, g, b = cval[CONF_HEX] else: r, g, b, _ = from_rgbw(cval) - return literal(f"lv_color_make({r}, {g}, {b})") + return r, g, b raise AssertionError(f"Unhandled lv_color value: {value!r}") -def option_string(value): - value = cv.string(value).strip() - if value.find("\n") != -1: - raise cv.Invalid("Options strings must not contain newlines") - return value +def color_retmapper(value): + if isinstance(value, cv.Lambda): + return cv.returning_lambda(value) + r, g, b = get_component_colors(value) + return literal(f"lv_color_make({r}, {g}, {b})") class LvColor(LValidator): def __init__(self): - super().__init__(color, ty.lv_color_t, retmapper=color_retmapper) + super().__init__( + color, ty.lv_color_t, retmapper=color_retmapper, animatable=True + ) def __getattr__(self, item): if item in COLOR_NAMES: @@ -262,6 +261,13 @@ class LvColor(LValidator): lv_color = LvColor() +def option_string(value): + value = cv.string(value).strip() + if value.find("\n") != -1: + raise cv.Invalid("Options strings must not contain newlines") + return value + + def pixels_or_percent_validator(value): """A length in one axis - either a number (pixels) or a percentage""" if value == SCHEMA_EXTRACT: @@ -277,6 +283,7 @@ pixels_or_percent = LValidator( pixels_or_percent_validator, lv_coord_t, retmapper=lambda x: x if isinstance(x, int) else literal(f"lv_pct({int(x * 100)})"), + animatable=True, ) @@ -315,10 +322,10 @@ def angle(value): # Validator for angles in LVGL expressed in 1/10 degree units. -lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10)) +lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable=True) # Validator for angles in LVGL expressed in whole degrees -lv_angle_degrees = LValidator(angle, uint32, retmapper=int) +lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True) @schema_extractor("one_of") @@ -410,7 +417,10 @@ class TextValidator(LValidator): return super().__call__(value) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: # Local import to avoid circular import at module level from .lvcode import get_lambda_context_args @@ -455,13 +465,18 @@ class TextValidator(LValidator): return value # Either a std::string or a lambda call returning that. We need const char* return MockObj(f"({value}).c_str()") - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_text = TextValidator() lv_float = LValidator(cv.float_, cg.float_) -lv_int = LValidator(cv.int_, cg.int_) -lv_positive_int = LValidator(cv.positive_int, cg.int_) +lv_positive_float = LValidator(cv.positive_float, cg.float_) +lv_zero_to_one_float = LValidator(cv.zero_to_one_float, cg.float_) +lv_int = LValidator(cv.int_, cg.int_, animatable=True) +lv_positive_int = LValidator(cv.positive_int, cg.int_, animatable=True) +lv_brightness = LValidator( + cv.percentage, cg.float_, retmapper=lambda x: int(x * 255), animatable=True +) def _percentage_validator(value): @@ -508,12 +523,17 @@ class LvFont(LValidator): # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* super().__init__(validator, Font.operator("ptr")) - async def process(self, value, args=()): + async def process( + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, + ): if is_lv_font(value): return literal(f"&lv_font_{value}") if isinstance(value, str): return literal(f"{value}") - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_font = LvFont() diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 509d5cc782..61efe385e6 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -67,6 +67,7 @@ lv_obj_t = LvType("lv_obj_t") lv_page_t = LvType("LvPageType", parents=(LvCompound,)) lv_image_t = LvType("lv_image_t") lv_gradient_t = LvType("lv_grad_dsc_t") +LvAnimation = lvgl_ns.class_("LvAnimation", cg.Component) lv_event_t = LvType("lv_event_t") RotationType = lvgl_ns.enum("RotationType") lv_point_t = cg.global_ns.struct("lv_point_t") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 639508a7b2..bdb0f27f45 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -89,6 +89,7 @@ #define USE_LOGGER_LEVEL_LISTENERS #define USE_LOGGER_RUNTIME_TAG_LEVELS #define USE_LVGL +#define USE_LVGL_ANIMATION #define USE_LVGL_ANIMIMG #define USE_LVGL_ARC #define USE_LVGL_BINARY_SENSOR diff --git a/tests/component_tests/lvgl/test_animation.py b/tests/component_tests/lvgl/test_animation.py new file mode 100644 index 0000000000..1a2cde632c --- /dev/null +++ b/tests/component_tests/lvgl/test_animation.py @@ -0,0 +1,201 @@ +"""Tests for the LVGL animation schema and configuration validation.""" + +from __future__ import annotations + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.lvgl.animation import ( + ANIMABLE_STYLES, + ANIMATION_SCHEMA, + TIMING_SCHEMA, + from_to, + literal_color, +) +from esphome.components.lvgl.defines import LValidator +from esphome.core import Lambda + + +def _animation(**overrides) -> dict: + """A minimal valid animation config, with optional overrides applied.""" + config = { + "id": "anim_id", + "widgets": [{"id": "widget_id", "x": {"from": 0, "to": 100}}], + } + config.update(overrides) + return config + + +# --------------------------------------------------------------------------- +# Animatable property set +# --------------------------------------------------------------------------- + + +class TestAnimableStyles: + def test_all_entries_are_animatable_validators(self) -> None: + """Every animatable style must be an LValidator marked animatable.""" + assert ANIMABLE_STYLES + assert all( + isinstance(v, LValidator) and v.animatable for v in ANIMABLE_STYLES.values() + ) + + def test_known_animatable_present(self) -> None: + for prop in ("x", "y", "opa", "bg_color", "transform_rotation"): + assert prop in ANIMABLE_STYLES + + def test_non_animatable_absent(self) -> None: + # width/height set size but are not animatable; layout/padding never are. + for prop in ("width", "height", "radius", "pad_all", "align"): + assert prop not in ANIMABLE_STYLES + + +# --------------------------------------------------------------------------- +# Animation schema +# --------------------------------------------------------------------------- + + +class TestAnimationSchema: + def test_defaults(self) -> None: + config = ANIMATION_SCHEMA(_animation()) + assert config["duration"].total_milliseconds == 5000 + assert config["start_delay"].total_milliseconds == 0 + assert config["auto_start"] is False + assert config["loop"] is False + assert config["timing"] == [] + + def test_values_preserved(self) -> None: + config = ANIMATION_SCHEMA( + _animation(duration="2s", start_delay="250ms", auto_start=True, loop=True) + ) + assert config["duration"].total_milliseconds == 2000 + assert config["start_delay"].total_milliseconds == 250 + assert config["auto_start"] is True + assert config["loop"] is True + + def test_id_required(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA({"widgets": [{"id": "widget_id"}]}) + + def test_widgets_required(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA({"id": "anim_id"}) + + def test_multiple_properties_and_widgets(self) -> None: + config = ANIMATION_SCHEMA( + _animation( + widgets=[ + { + "id": "w1", + "x": {"from": 0, "to": 100}, + "opa": {"from": "0%", "to": "100%"}, + }, + {"id": "w2", "y": {"from": 10, "to": 50}}, + ] + ) + ) + assert len(config["widgets"]) == 2 + + def test_unknown_property_rejected(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA( + _animation(widgets=[{"id": "w1", "not_a_style": {"from": 0, "to": 1}}]) + ) + + +class TestAnimatedColorLiteral: + """A color animated via from/to must be a literal, not a lambda.""" + + def test_color_lambda_rejected_directly(self) -> None: + with pytest.raises(Invalid, match="lambda"): + literal_color(Lambda("return lv_color_hex(0xFF0000);")) + + def test_color_literal_accepted_directly(self) -> None: + # A literal color value validates without error. + literal_color(0xFF0000) + + def test_color_lambda_rejected_in_animation(self) -> None: + with pytest.raises((Invalid, MultipleInvalid), match="lambda"): + ANIMATION_SCHEMA( + _animation( + widgets=[ + { + "id": "w1", + "text_color": { + "from": Lambda("return lv_color_hex(0xFF0000);"), + "to": 0x00FF00, + }, + } + ] + ) + ) + + def test_color_literals_accepted_in_animation(self) -> None: + config = ANIMATION_SCHEMA( + _animation( + widgets=[{"id": "w1", "text_color": {"from": 0xFF0000, "to": 0x00FF00}}] + ) + ) + assert config["widgets"][0]["id"].id == "w1" + + def test_non_color_property_allows_lambda(self) -> None: + # Only colors are restricted; numeric properties may use lambdas. + config = ANIMATION_SCHEMA( + _animation( + widgets=[{"id": "w1", "x": {"from": Lambda("return 5;"), "to": 100}}] + ) + ) + assert config["widgets"][0]["id"].id == "w1" + + +class TestFromTo: + def test_requires_both(self) -> None: + validator = from_to(lambda value: value) + with pytest.raises((Invalid, MultipleInvalid)): + validator({"from": 1}) + with pytest.raises((Invalid, MultipleInvalid)): + validator({"to": 1}) + + def test_accepts_both(self) -> None: + validator = from_to(lambda value: value) + assert validator({"from": 1, "to": 2}) == {"from": 1, "to": 2} + + +# --------------------------------------------------------------------------- +# Timing schema +# --------------------------------------------------------------------------- + + +class TestTimingSchema: + def test_round_trip_string(self) -> None: + assert TIMING_SCHEMA("round_trip")["type"] == "round_trip" + + def test_ease_in_out_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_in_out") + assert result["type"] == "ease_in_out" + assert result["weight"] == pytest.approx(2.0) + + def test_ease_in_out_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 3}) + assert result["weight"] == pytest.approx(3.0) + + def test_gravity_defaults(self) -> None: + result = TIMING_SCHEMA("gravity") + assert result["type"] == "gravity" + assert result["bounce"] == pytest.approx(0.5) + assert result["acceleration"] == pytest.approx(0.5) + + def test_gravity_custom(self) -> None: + result = TIMING_SCHEMA({"type": "gravity", "bounce": 0.3, "acceleration": 0.8}) + assert result["bounce"] == pytest.approx(0.3) + assert result["acceleration"] == pytest.approx(0.8) + + def test_unknown_type_rejected(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + TIMING_SCHEMA({"type": "not_a_timing"}) + + def test_timing_list_in_animation(self) -> None: + config = ANIMATION_SCHEMA( + _animation(timing=["round_trip", {"type": "gravity", "bounce": 0.3}]) + ) + types = [t["type"] for t in config["timing"]] + assert types == ["round_trip", "gravity"] diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4ec4eb3bd6..4b18b99848 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -53,6 +53,12 @@ lvgl: id: meter_arc_indicator start_value: 0 end_value: 180 + - lvgl.animation.start: + id: + - anim_slide + - anim_color + duration: 3s + loop: true on_invalidate_area: logger.log: Invalidate area on_resolution_change: @@ -97,6 +103,52 @@ lvgl: - obj: bg_color: 0x000000 bg_opa: cover + top_layer: + widgets: + - obj: + id: anim_box + x: 0 + y: 0 + width: 50 + height: 50 + bg_color: 0xFF0000 + - label: + id: anim_label + text: anim + animations: + - id: anim_slide + duration: 1s + start_delay: 100ms + auto_start: true + loop: true + timing: ease_in_out + on_start: + - logger.log: anim started + on_stop: + - logger.log: anim stopped + widgets: + - id: anim_box + x: + from: 0 + to: 100 + y: + from: 0 + to: !lambda "return 80;" + opa: + from: 50% + to: 100% + - id: anim_color + duration: 2s + timing: + - round_trip + - type: gravity + bounce: 0.3 + acceleration: 0.8 + widgets: + - id: anim_label + text_color: + from: 0xFF0000 + to: color_id theme: dark_mode: true obj: @@ -199,6 +251,11 @@ lvgl: on_click: then: - lvgl.display.set_rotation: 0 + - lvgl.animation.stop: anim_slide + - lvgl.animation.stop: + id: + - anim_slide + - anim_color - lvgl.widget.hide: message_box - lvgl.style.update: id: style_test diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 6328648fe3..90cbb3c0a5 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -22,6 +22,36 @@ lvgl: displays: sdl0 rotation: 180 top_layer: + widgets: + - obj: + id: anim_box + x: 0 + y: 0 + width: 40 + height: 40 + bg_color: 0xFF0000 + animations: + - id: anim_slide + duration: 1s + start_delay: 100ms + auto_start: true + loop: true + timing: + - round_trip + - type: ease_in_out + weight: 3 + on_start: + - logger.log: anim started + on_stop: + - logger.log: anim stopped + widgets: + - id: anim_box + x: + from: 0 + to: !lambda "return 100;" + opa: + from: 50% + to: 100% - id: lvgl_1 displays: sdl1 @@ -42,7 +72,14 @@ lvgl: - label: text: Click ME on_click: - logger.log: Clicked + then: + - logger.log: Clicked + - lvgl.animation.stop: + id: anim_slide + lvgl_id: lvgl_0 + - lvgl.animation.start: + id: anim_slide + lvgl_id: lvgl_0 font: - file: "gfonts://Roboto" From b787281388ff9edce49ef4c15ea396dd79cfe62a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:00:42 +1000 Subject: [PATCH 2034/2214] [lvgl] Add direct use of `mapping` (#15863) --- esphome/components/lvgl/defines.py | 2 + esphome/components/lvgl/lv_validation.py | 70 +++++++++++++++++++++--- esphome/components/lvgl/schemas.py | 19 +++++++ esphome/components/lvgl/widgets/img.py | 17 +++++- tests/components/lvgl/common.yaml | 4 +- tests/components/lvgl/lvgl-package.yaml | 31 ++++++++++- 6 files changed, 128 insertions(+), 15 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 5c75269c64..480ba515d1 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -739,6 +739,7 @@ CONF_GRID_ROWS = "grid_rows" CONF_HEADER_BUTTONS = "header_buttons" CONF_HEADER_MODE = "header_mode" CONF_HOME = "home" +CONF_IMAGE = "image" CONF_INDICATORS = "indicators" CONF_INITIAL_FOCUS = "initial_focus" CONF_SELECTED_DIGIT = "selected_digit" @@ -752,6 +753,7 @@ CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time" CONF_LVGL_ID = "lvgl_id" CONF_LONG_MODE = "long_mode" CONF_MAJOR_TICKS_STYLE = "major_ticks_style" +CONF_MAPPING = "mapping" CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index d31c8324db..56ee3b47af 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -22,9 +22,12 @@ from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType +from ..mapping import INDEX_TYPES, get_mapping_metadata from . import types as ty from .defines import ( CONF_END_VALUE, + CONF_IMAGE, + CONF_MAPPING, CONF_START_VALUE, CONF_TIME_FORMAT, LV_FONTS, @@ -375,21 +378,54 @@ def stop_value(value): return cv.int_range(0, 255)(value) -def image_validator(value): - value = cv.requires_component("image")(value) +def _image_validator(value): + if isinstance(value, dict) and CONF_MAPPING in value: + from .schemas import MAPPING_IMAGE_SCHEMA + + return MAPPING_IMAGE_SCHEMA(value) value = cv.use_id(Image_)(value) get_lv_images_used().add(value) add_lv_use("label") return value -lv_image = LValidator( - image_validator, - image.Image_.operator("ptr"), - requires="image", -) +class ImageValidator(LValidator): + def __init__(self): + super().__init__( + validator=_image_validator, + rtype=image.Image_.operator("ptr"), + requires=CONF_IMAGE, + ) + + async def process( + self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + ) -> Expression: + # Local import to avoid circular import at module level + from .lvcode import get_lambda_context_args + + args = args or get_lambda_context_args() + if isinstance(value, dict) and CONF_MAPPING in value: + mapping_id = value[CONF_MAPPING] + mapping_var = await cg.get_variable(mapping_id) + metadata = get_mapping_metadata(mapping_id.id) + index = value[CONF_VALUE] + if isinstance(index, Lambda): + index = call_lambda( + await cg.process_lambda( + index, args, return_type=metadata.from_.data_type + ) + ) + else: + index = await metadata.from_.convert_value(index) + return mapping_var.get(index) + + return await super().process(value, args) + + +lv_image = ImageValidator() + lv_image_list = LValidator( - cv.ensure_list(image_validator), + cv.ensure_list(_image_validator), cg.std_vector.template(image.Image_.operator("ptr")), requires="image", ) @@ -440,6 +476,24 @@ class TextValidator(LValidator): f"(std::isfinite({arg_expr}) ? {sprintf_str} : {nanval})" ) return literal(sprintf_str) + if mapping_id := value.get(CONF_MAPPING): + mapping_var = await cg.get_variable(mapping_id) + metadata = get_mapping_metadata(mapping_id.id) + if metadata.to_ != INDEX_TYPES["string"]: + raise ValueError( + f"Mapping {mapping_id} does not map to strings, cannot use in text" + ) + index = value[CONF_VALUE] + if isinstance(index, Lambda): + index = call_lambda( + await cg.process_lambda( + index, args, return_type=metadata.from_.data_type + ) + ) + else: + index = await metadata.from_.convert_value(index) + return mapping_var.get(index).c_str() + if time_format := value.get(CONF_TIME_FORMAT): source = value[CONF_TIME] if isinstance(source, Lambda): diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index d7df628907..13214d459d 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -3,6 +3,7 @@ from typing import Any from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation +from esphome.components.mapping import mapping_class from esphome.components.time import RealTimeClock from esphome.config_validation import prepend_path from esphome.const import ( @@ -17,6 +18,7 @@ from esphome.const import ( CONF_TEXT, CONF_TIME, CONF_TRIGGER_ID, + CONF_VALUE, CONF_X, CONF_Y, ) @@ -31,6 +33,7 @@ from esphome.schema_extractors import ( from . import defines as df, lv_validation as lvalid from .defines import ( CONF_EXT_CLICK_AREA, + CONF_MAPPING, CONF_SCROLL_DIR, CONF_SCROLL_SNAP_X, CONF_SCROLL_SNAP_Y, @@ -89,6 +92,20 @@ PRINTF_TEXT_SCHEMA = cv.All( validate_printf, ) +MAPPING_TEXT_SCHEMA = cv.Schema( + { + cv.Required(CONF_MAPPING): cv.use_id(mapping_class), + cv.Required(CONF_VALUE): cv.templatable(cv.string), + } +) + +MAPPING_IMAGE_SCHEMA = cv.Schema( + { + cv.Required(CONF_MAPPING): cv.use_id(mapping_class), + cv.Required(CONF_VALUE): cv.templatable(cv.string), + } +) + def _validate_text(value): """ @@ -100,6 +117,8 @@ def _validate_text(value): if isinstance(value, dict): if CONF_TIME_FORMAT in value: return TIME_TEXT_SCHEMA(value) + if CONF_MAPPING in value: + return MAPPING_TEXT_SCHEMA(value) return PRINTF_TEXT_SCHEMA(value) return cv.templatable(cv.string)(value) diff --git a/esphome/components/lvgl/widgets/img.py b/esphome/components/lvgl/widgets/img.py index 8a046fea33..da81ab7737 100644 --- a/esphome/components/lvgl/widgets/img.py +++ b/esphome/components/lvgl/widgets/img.py @@ -1,3 +1,5 @@ +from esphome.components.image import INSTANCE_TYPE as IMAGE_TYPE +from esphome.components.mapping import get_mapping_metadata import esphome.config_validation as cv from esphome.const import ( CONF_ANGLE, @@ -9,7 +11,9 @@ from esphome.const import ( from ..defines import ( CONF_ANTIALIAS, + CONF_IMAGE, CONF_MAIN, + CONF_MAPPING, CONF_PIVOT_X, CONF_PIVOT_Y, CONF_SCALE, @@ -21,8 +25,6 @@ from ..types import lv_image_t from . import Widget, WidgetType from .label import CONF_LABEL -CONF_IMAGE = "image" - BASE_IMG_SCHEMA = cv.Schema( { cv.Optional(CONF_PIVOT_X): size, @@ -69,5 +71,16 @@ class ImgType(WidgetType): for prop, validator in BASE_IMG_SCHEMA.schema.items(): await w.set_property(prop, config, processor=validator) + def final_validate(self, widget, update_config, widget_config, path): + src = update_config.get(CONF_SRC) + if isinstance(src, dict) and CONF_MAPPING in src: + mapping_id = src[CONF_MAPPING] + metadata = get_mapping_metadata(mapping_id.id) + if str(metadata.to_.data_type) != str(IMAGE_TYPE): + raise cv.Invalid( + f"Mapping '{mapping_id}' does not map to an image type, but '{metadata.to_.data_type}'", + path=path + [CONF_SRC, CONF_MAPPING], + ) + img_spec = ImgType() diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index f500002f40..b4d5fe0387 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -91,8 +91,8 @@ binary_sensor: animation: move_right time: 600ms - platform: lvgl - id: button_checker - name: LVGL button + id: common_button_checker + name: Common button widget: spin_up on_state: then: diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4b18b99848..d6cd3821f9 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -263,6 +263,9 @@ lvgl: bg_opa: !lambda return 0.5; - lvgl.image.update: id: lv_image + src: + mapping: image_map + value: !lambda return round(1.0); scale: !lambda return 512; rotation: !lambda return 100; pivot_x: !lambda return 20; @@ -388,9 +391,16 @@ lvgl: text_font: montserrat_40 border_post: true on_press: - lvgl.label.update: - id: hello_label - text: Goodbye + - lvgl.label.update: + id: hello_label + text: + mapping: lvgl_string_map + value: !lambda return 2; + - lvgl.label.update: + id: hello_label + text: + mapping: lvgl_string_map + value: 2 on_click: then: - lvgl.animimg.stop: anim_img @@ -1496,6 +1506,21 @@ image: invert_alpha: true transparency: alpha_channel +mapping: + - id: image_map + from: int + to: image + entries: + 0: cat_image + 1: dog_image + - id: lvgl_string_map + from: int + to: string + entries: + 0: "First" + 1: "Second" + 2: "Third" + color: - id: light_blue hex: "3340FF" From e7933a5387fea9a67bd5a99cd7687e5f10f556f9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:06:38 -0400 Subject: [PATCH 2035/2214] Bump bundled esphome-device-builder to 1.3.1 (#17450) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 3a7d5e8bbe..db2e01742c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.3.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.3.1 RUN \ platformio settings set enable_telemetry No \ From ce468952708d24ea2094758ac9640f1be499922d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:30:49 +1000 Subject: [PATCH 2036/2214] [uart][usb_uart] Implement runtime settings update (#16990) Co-authored-by: Claude Opus 4.8 Co-authored-by: Keith Burzinski --- esphome/components/uart/uart_component.h | 4 +- .../components/uart/uart_component_esp8266.h | 2 +- .../components/uart/uart_component_esp_idf.h | 2 +- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 6 + esphome/components/usb_uart/ch34x.cpp | 184 +++++++------- esphome/components/usb_uart/cp210x.cpp | 46 ++-- esphome/components/usb_uart/ft23xx.cpp | 236 ++++++------------ esphome/components/usb_uart/pl2303.cpp | 184 +++++++------- esphome/components/usb_uart/usb_uart.cpp | 208 +++++++++++---- esphome/components/usb_uart/usb_uart.h | 66 +++-- esphome/components/weikai/weikai.h | 9 + tests/components/mitsubishi_cn105/common.h | 3 + tests/components/uart/common.h | 3 + 13 files changed, 534 insertions(+), 419 deletions(-) diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index afd3ad5777..3e52531791 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -178,7 +178,7 @@ class UARTComponent { * * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ - virtual void load_settings(bool dump_config){}; + virtual void load_settings(bool dump_config) = 0; /** * Load the UART settings. @@ -190,7 +190,7 @@ class UARTComponent { * * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ - virtual void load_settings(){}; + void load_settings() { this->load_settings(true); } #endif // USE_ESP8266 || USE_ESP32 #ifdef USE_UART_DEBUGGER diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index ee3be3cd3a..469885b6b6 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -75,7 +75,7 @@ class ESP8266UartComponent final : public UARTComponent, public Component { * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ void load_settings(bool dump_config) override; - void load_settings() override { this->load_settings(true); } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience protected: void check_logger_conflict() override; diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 3b86368797..649dd3aa46 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -50,7 +50,7 @@ class IDFUARTComponent final : public UARTComponent, public Component { * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ void load_settings(bool dump_config) override; - void load_settings() override { this->load_settings(true); } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience protected: void check_logger_conflict() override; diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 2251c600e7..8e71fc61b2 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -84,6 +84,12 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parenteddefer([this, error_code = status.error_code]() { - ESP_LOGE(TAG, "CH34x chip detection failed: %s", esp_err_to_name(error_code)); - this->apply_line_settings_(); - }); - return; - } - CH34xChipType chiptype = CHIP_UNKNOWN; - uint8_t num_ports = 1; - for (const auto &e : CH34X_TABLE) { - if (e.pid != this->pid_) - continue; - if (e.match != 0xFF && (status.data[e.byte_idx] & e.mask) != e.match) - continue; - chiptype = e.chiptype; - num_ports = e.num_ports; +bool USBUartTypeCH34X::config_device_step(uint8_t step, bool ok, const uint8_t *response) { + if (step == 0) { + // Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes + // used to distinguish CH34x variants sharing the same PID. + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + return true; + } + // step 1: parse the chip-version response (falling back to "unknown" on failure). + if (!ok) { + ESP_LOGE(TAG, "CH34x chip detection failed"); + return false; + } + CH34xChipType chiptype = CHIP_UNKNOWN; + uint8_t num_ports = 1; + for (const auto &e : CH34X_TABLE) { + if (e.pid != this->pid_) + continue; + if (e.match != 0xFF && (response[e.byte_idx] & e.mask) != e.match) + continue; + chiptype = e.chiptype; + num_ports = e.num_ports; + break; + } + // CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1]) + if (chiptype == CHIP_CH344L && (response[0] & 0xF0) != 0x40) + chiptype = CHIP_CH344L_V2; + const char *name = "unknown"; + for (const auto &e : CH34X_TABLE) { + if (e.chiptype == chiptype) { + name = e.name; break; } - // CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1]) - if (chiptype == CHIP_CH344L && (status.data[0] & 0xF0) != 0x40) - chiptype = CHIP_CH344L_V2; - const char *name = "unknown"; - for (const auto &e : CH34X_TABLE) { - if (e.chiptype == chiptype) { - name = e.name; - break; - } - } - this->defer([this, chiptype, num_ports, name]() { - this->chiptype_ = chiptype; - this->chip_name_ = name; - this->num_ports_ = num_ports; - ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_); - this->apply_line_settings_(); - }); - }; - // Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes - // used to distinguish CH34x variants sharing the same PID. - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, cb, {0, 0, 0, 0, 0, 0, 0, 0}); + } + this->chiptype_ = chiptype; + this->chip_name_ = name; + this->num_ports_ = num_ports; + ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_); + return false; } void USBUartTypeCH34X::dump_config() { @@ -98,67 +95,64 @@ void USBUartTypeCH34X::dump_config() { ESP_LOGCONFIG(TAG, " CH34x chip: %s", this->chip_name_); } -void USBUartTypeCH34X::apply_line_settings_() { - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); +bool USBUartTypeCH34X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + uint8_t cmd = 0xA1 + channel->index_; + if (channel->index_ >= 2) + cmd += 0xE; + switch (step) { + case 0: { + uint8_t divisor = 7; + uint32_t clk = 12000000; + + auto baud_rate = channel->baud_rate_; + if (baud_rate < 256000) { + if (baud_rate > 6000000 / 255) { + divisor = 3; + clk = 6000000; + } else if (baud_rate > 750000 / 255) { + divisor = 2; + clk = 750000; + } else if (baud_rate > 93750 / 255) { + divisor = 1; + clk = 93750; + } else { + divisor = 0; + clk = 11719; + } } - }; - - uint8_t divisor = 7; - uint32_t clk = 12000000; - - auto baud_rate = channel->baud_rate_; - if (baud_rate < 256000) { - if (baud_rate > 6000000 / 255) { - divisor = 3; - clk = 6000000; - } else if (baud_rate > 750000 / 255) { - divisor = 2; - clk = 750000; - } else if (baud_rate > 93750 / 255) { - divisor = 1; - clk = 93750; - } else { - divisor = 0; - clk = 11719; + ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk); + auto factor = static_cast(clk / baud_rate); + if (factor == 0 || factor == 0xFF) { + ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); + return false; } - } - ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk); - auto factor = static_cast(clk / baud_rate); - if (factor == 0 || factor == 0xFF) { - ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); - channel->initialised_.store(false); - continue; - } - if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) - factor++; - factor = 256 - factor; + if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) + factor++; + factor = 256 - factor; - uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) - value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; + uint16_t value = 0xC0; + if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + value |= 4; + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + break; + default: + value |= 8 | ((channel->parity_ - 1) << 4); + break; + } + value |= channel->data_bits_ - 5; + value <<= 8; + value |= 0x8C; + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor); + return true; } - value |= channel->data_bits_ - 5; - value <<= 8; - value |= 0x8C; - uint8_t cmd = 0xA1 + channel->index_; - if (channel->index_ >= 2) - cmd += 0xE; - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); + case 1: + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0); + return true; + default: + return false; } - this->start_channels_(); } std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index c4edaed038..2722ec8555 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -97,29 +97,31 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypeCP210X::enable_channels() { - // enable the channels - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } - }; - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_, callback); - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; - line_control |= channel->data_bits_ << 8; - ESP_LOGD(TAG, "Line control value 0x%X", line_control); - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_, - callback); - auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE); - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, callback, - baud.get_data()); +bool USBUartTypeCP210X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + // On reload, skip the one-time IFC_ENABLE step (the interface is already enabled). + if (reload) + step++; + switch (step) { + case 0: + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); + return true; + case 1: { + uint16_t line_control = channel->stop_bits_; + line_control |= static_cast(channel->parity_) << 4; + line_control |= channel->data_bits_ << 8; + ESP_LOGD(TAG, "Line control value 0x%X", line_control); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); + return true; + } + case 2: { + auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, baud.get_data()); + return true; + } + default: + return false; } - this->start_channels_(); } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 25e4cc524f..79aa107d72 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -112,40 +112,46 @@ static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, uint32_t return best_baud; } -static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, uint16_t *value, - uint16_t *index) { +struct FtdiConfig { + uint16_t value; + uint16_t ftdi_index; int best_baud; +}; + +static FtdiConfig ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index) { uint32_t encoded_divisor; + FtdiConfig config{}; + if (baudrate <= 0) { - return -1; + return config; } static constexpr uint32_t H_CLK = 120000000; static constexpr uint32_t C_CLK = 48000000; if ((chip_type == TYPE_2232H) || (chip_type == TYPE_4232H) || (chip_type == TYPE_232H)) { if (baudrate * 10 > H_CLK / 0x3fff) { - best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); encoded_divisor |= 0x20000; /* switch on CLK/10*/ } else { - best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } } else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) { - best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } else { - best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); + config.best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); } - *value = (uint16_t) (encoded_divisor & 0xFFFF); + config.value = (uint16_t) (encoded_divisor & 0xFFFF); if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) { - *index = (uint16_t) (encoded_divisor >> 8); - *index &= 0xFF00; - *index |= (channel_index + 1); + config.ftdi_index = (uint16_t) (encoded_divisor >> 8); + config.ftdi_index &= 0xFF00; + config.ftdi_index |= (channel_index + 1); } else { - *index = (uint16_t) (encoded_divisor >> 16); + config.ftdi_index = (uint16_t) (encoded_divisor >> 16); } - return best_baud; + return config; } static optional get_uart(const usb_config_desc_t *config_desc, uint8_t intf_idx) { @@ -264,138 +270,6 @@ std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -int USBUartTypeFT23XX::reset_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } else { - ESP_LOGD(TAG, "Reset successful, setting baudrate..."); - this->set_baudrate_(channel); - } - }; - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Reset control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } else { - ESP_LOGD(TAG, "Baudrate %" PRIu32 " set, setting line properties...", channel->baud_rate_); - this->set_line_properties_(channel); - } - }; - if (baudrate == 0) { - baudrate = channel->baud_rate_; - } - uint16_t value = 0, ftdi_index = 0; - ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); - ESP_LOGD(TAG, "Baudrate: %" PRIu32 ", value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); - uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback); - if (!ok) { - ESP_LOGE(TAG, "Set baudrate control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_line_properties_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - return; - } - ESP_LOGD(TAG, "Line properties set, setting modem control..."); - this->set_dtr_rts_(channel); - }; - - uint16_t value = channel->data_bits_; - - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - - switch (channel->stop_bits_) { - case UART_CONFIG_STOP_BITS_1: - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } - - value |= (0x00 << 14); - - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Set line properties control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - return; - } - ESP_LOGD(TAG, "Modem control set for channel %d, starting input...", channel->index_); - channel->initialised_.store(true); - this->start_input(channel); - uint8_t next_index = channel->index_ + 1; - if (next_index < this->channels_.size()) { - USBUartChannel *next_channel = this->channels_[next_index]; - ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_); - this->reset_(next_channel); - return; - } else { - ESP_LOGI(TAG, "All channels configured"); - } - }; - - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Set modem control control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { if (!channel->initialised_.load()) return; @@ -467,16 +341,68 @@ void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) { channel->input_buffer_.clear(); } -void USBUartTypeFT23XX::enable_channels() { - if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) { - this->reset_(this->channels_[0]); - } - - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - channel->input_started_.store(false); - channel->output_started_.store(false); +bool USBUartTypeFT23XX::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + // On reload (settings change on an open channel) skip the SIO reset; the FTDI set_termios + // path only re-applies baud + line properties and does not re-assert DTR/RTS. + if (reload) + step++; + switch (step) { + case 0: // SIO reset (init only) + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + case 1: { // set baudrate + auto config = ftdi_convert_baudrate(channel->baud_rate_, this->chip_type_, channel->index_); + uint16_t usb_index = (config.ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); + ESP_LOGD(TAG, "Baudrate: %u, value=0x%04X, ftdi_index=0x%04X", (unsigned) channel->baud_rate_, config.value, + config.ftdi_index); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, config.value, usb_index); + return true; + } + case 2: { // set line properties (data bits / parity / stop bits) + uint16_t value = channel->data_bits_; + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + value |= (0x00 << 8); + break; + case UART_CONFIG_PARITY_ODD: + value |= (0x01 << 8); + break; + case UART_CONFIG_PARITY_EVEN: + value |= (0x02 << 8); + break; + case UART_CONFIG_PARITY_MARK: + value |= (0x03 << 8); + break; + case UART_CONFIG_PARITY_SPACE: + value |= (0x04 << 8); + break; + } + switch (channel->stop_bits_) { + default: // 1 bit + value |= (0x00 << 11); + break; + case UART_CONFIG_STOP_BITS_1_5: + value |= (0x01 << 11); + break; + case UART_CONFIG_STOP_BITS_2: + value |= (0x02 << 11); + break; + } + value |= (0x00 << 14); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + } + case 3: // set modem control DTR+RTS (init only) + if (reload) + return false; + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + default: + return false; } } diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 134c51198d..3c7ecd9a83 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -200,100 +200,114 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypePL2303::enable_channels() { - if (this->channels_.empty()) - return; +// Vendor init sequence for non-HXN chips (mirrors pl2303_startup in the Linux driver): +// read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, write 0x0404=1, +// read 0x8484, read 0x8383, write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+). +// The final entry's wIndex is patched at runtime depending on the chip type. +struct Pl2303InitStep { + uint8_t type; + uint8_t request; + uint16_t value; + uint16_t index; + bool read; // reads need a 1-byte buffer to set wLength=1 so the IN data stage runs +}; +static const Pl2303InitStep PL2303_INIT[] = { + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0x0404, 0, false}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8383, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0x0404, 1, false}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8383, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0, 1, false}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 1, 0, false}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 2, 0, false}, +}; +static constexpr uint8_t PL2303_INIT_COUNT = sizeof(PL2303_INIT) / sizeof(PL2303_INIT[0]); - auto *channel = this->channels_[0]; +bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { bool is_legacy = (this->chip_type_ == PL2303_TYPE_H); bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN); - usb_host::transfer_cb_t nop_cb = [](const usb_host::TransferStatus &status) { - if (!status.success) - ESP_LOGW(TAG, "PL2303: vendor init transfer failed"); - }; - - // Init sequence for non-HXN chips (mirrors pl2303_startup in Linux driver): - // Read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, - // write 0x0404=1, read 0x8484, read 0x8383, - // write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+) - if (!is_hxn) { - uint8_t req = VENDOR_READ_REQUEST; - uint8_t wreq = VENDOR_WRITE_REQUEST; - - // Fire-and-forget vendor reads: result discarded, chip requires this sequence. - // Pass a 1-byte buffer to set wLength=1 so the IN data stage is performed. - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 0, nop_cb); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 1, nop_cb); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0, 1, nop_cb); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 1, 0, nop_cb); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 2, is_legacy ? 0x24 : 0x44, nop_cb); + // Vendor init burst runs only on full init for non-HXN chips. + uint8_t init_count = (!reload && !is_hxn) ? PL2303_INIT_COUNT : 0; + if (step < init_count) { + const auto &e = PL2303_INIT[step]; + uint16_t index = (step == PL2303_INIT_COUNT - 1) ? (is_legacy ? 0x24 : 0x44) : e.index; + this->config_transfer_(e.type, e.request, e.value, index, + e.read ? std::vector{0} : std::vector{}); + return true; } + step -= init_count; - // Build 7-byte line coding structure: - // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits - uint8_t line_coding[7] = {}; - uint32_t baud = channel->get_baud_rate(); - - // Choose baud encoding based on chip type - uint32_t nearest = nearest_supported_baud(baud); - if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { - encode_baud_direct(line_coding, baud); - } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { - encode_baud_divisor_alt(line_coding, baud); - } else { - encode_baud_divisor(line_coding, baud); - } - - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } - - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } - - // Data bits - line_coding[6] = channel->get_data_bits(); - - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], - line_coding[5], line_coding[6]); - - std::vector lc_vec(line_coding, line_coding + 7); uint16_t iface = channel->cdc_dev_.bulk_interface_number; - this->control_transfer(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, nop_cb, lc_vec); + switch (step) { + case 0: { + // Build 7-byte line coding structure: + // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits + uint8_t line_coding[7] = {}; + uint32_t baud = channel->get_baud_rate(); - // Assert DTR + RTS - this->control_transfer(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface, nop_cb); + // Choose baud encoding based on chip type + uint32_t nearest = nearest_supported_baud(baud); + if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { + encode_baud_direct(line_coding, baud); + } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { + encode_baud_divisor_alt(line_coding, baud); + } else { + encode_baud_divisor(line_coding, baud); + } - this->start_channels_(); + // Stop bits: 0=1, 1=1.5, 2=2 + switch (channel->get_stop_bits()) { + case 2: + line_coding[4] = 2; + break; + default: + line_coding[4] = 0; + break; + } + + // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space + switch (channel->parity_) { + case UART_CONFIG_PARITY_ODD: + line_coding[5] = 1; + break; + case UART_CONFIG_PARITY_EVEN: + line_coding[5] = 2; + break; + case UART_CONFIG_PARITY_MARK: + line_coding[5] = 3; + break; + case UART_CONFIG_PARITY_SPACE: + line_coding[5] = 4; + break; + default: + line_coding[5] = 0; + break; + } + + // Data bits + line_coding[6] = channel->get_data_bits(); + + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], + line_coding[6]); + + std::vector lc_vec(line_coding, line_coding + 7); + this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); + return true; + } + case 1: + // Assert DTR + RTS (init only) + if (reload) + return false; + this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface); + return true; + default: + return false; + } } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index a995e93e15..482b209a3f 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -6,6 +6,7 @@ #include "esphome/core/application.h" #include +#include namespace esphome::usb_uart { @@ -213,6 +214,7 @@ bool USBUartChannel::read_array(uint8_t *data, size_t len) { void USBUartComponent::setup() { USBClient::setup(); } void USBUartComponent::loop() { bool had_work = this->process_usb_events_(); + had_work |= this->run_config_machine_(); // Process USB data from the lock-free queue UsbDataChunk *chunk; @@ -489,60 +491,182 @@ void USBUartTypeCdcAcm::on_disconnected() { USBClient::on_disconnected(); } -void USBUartTypeCdcAcm::enable_channels() { +bool USBUartTypeCdcAcm::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; static constexpr uint8_t CDC_SET_LINE_CODING = 0x20; static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22; static constexpr uint16_t CDC_DTR_RTS = 0x0003; // D0=DTR, D1=RTS - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - // Configure the bridge's UART parameters. A USB-UART bridge will not forward data - // at the correct speed until SET_LINE_CODING is sent; without it the UART may run - // at an indeterminate default rate so the NCP receives garbled bytes and never - // sends RSTACK. - uint32_t baud = channel->baud_rate_; - std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits - }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); - this->control_transfer( - CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, - [](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGW(TAG, "SET_LINE_CODING failed: %X", status.error_code); - } else { - ESP_LOGD(TAG, "SET_LINE_CODING OK"); - } - }, - line_coding); - // Assert DTR+RTS to signal DTE is present. - this->control_transfer(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, - channel->cdc_dev_.interrupt_interface_number, [](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGW(TAG, "SET_CONTROL_LINE_STATE failed: %X", status.error_code); - } else { - ESP_LOGD(TAG, "SET_CONTROL_LINE_STATE (DTR+RTS) OK"); - } - }); + switch (step) { + case 0: { + // Configure the bridge's UART parameters. A USB-UART bridge will not forward data + // at the correct speed until SET_LINE_CODING is sent; without it the UART may run + // at an indeterminate default rate so the NCP receives garbled bytes and never + // sends RSTACK. + uint32_t baud = channel->baud_rate_; + std::vector line_coding = { + static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), + static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop + static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space + static_cast(channel->data_bits_), // bDataBits + }; + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, + (unsigned) channel->parity_, channel->data_bits_); + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, + line_coding); + return true; + } + case 1: + // Assert DTR+RTS to signal DTE is present (init only). + if (reload) + return false; + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, + channel->cdc_dev_.interrupt_interface_number); + return true; + default: + return false; } - this->start_channels_(); } -void USBUartTypeCdcAcm::start_channels_() { - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; +void USBUartComponent::enable_channels() { + this->cfg_single_ = nullptr; + this->cfg_pending_reload_ = nullptr; + this->cfg_channel_idx_ = 0; + this->start_config_(false); +} + +void USBUartComponent::apply_channel_settings(USBUartChannel *channel) { + if (this->cfg_active_) { + // A config sequence is already running. Defer this reload until it finishes to preserve + // the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an + // in-flight callback complete against fresh state). The pending slot coalesces multiple + // requests; the channel's live settings are read when the reload eventually runs. + // Note: multiple channel reloads are not queued; only one pending reload is supported at a time. + this->cfg_pending_reload_ = channel; + return; + } + this->cfg_single_ = channel; + this->start_config_(true); +} + +void USBUartComponent::start_config_(bool reload) { + this->cfg_reload_ = reload; + this->cfg_device_phase_ = !reload; + this->cfg_step_ = 0; + this->cfg_ok_ = true; + this->cfg_in_flight_ = false; + this->cfg_done_.store(false); + this->cfg_active_ = true; + this->enable_loop(); +} + +void USBUartComponent::config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index, + const std::vector &data) { + this->cfg_done_.store(false); + // The completion callback runs in the USB-task context: it only records the result and + // wakes the loop. The next transfer is issued from run_config_machine_() on the loop thread. + bool submitted = this->control_transfer( + type, request, value, index, + [this](const usb_host::TransferStatus &status) { + this->cfg_ok_ = status.success; + if (!status.success) { + ESP_LOGW(TAG, "Config control transfer failed: %s", esp_err_to_name(status.error_code)); + } else if (status.data_len > 0) { + memcpy(this->cfg_response_, status.data, std::min(status.data_len, sizeof(this->cfg_response_))); + } + // Release: publishes cfg_ok_/cfg_response_ before the loop observes cfg_done_. + this->cfg_done_.store(true, std::memory_order_release); + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); + }, + data); + if (!submitted) { + // Submission failed (e.g. no free transfer request). No callback will fire, so synthesize + // a failed completion here so the state machine advances/aborts instead of hanging. + ESP_LOGW(TAG, "Config control transfer submit failed"); + this->cfg_ok_ = false; + this->cfg_done_.store(true, std::memory_order_release); + } +} + +bool USBUartComponent::run_config_machine_() { + if (!this->cfg_active_) + return false; + + if (this->cfg_in_flight_) { + // Acquire: pairs with the release in config_transfer_'s callback. + if (!this->cfg_done_.load(std::memory_order_acquire)) + return false; // still waiting; the callback will re-wake the loop (no busy spin) + this->cfg_in_flight_ = false; + this->cfg_done_.store(false); + this->cfg_step_++; + } + + // cfg_ok_ is now synchronized (we only get here on the initial entry or after observing + // cfg_done_ with acquire ordering), so it is safe to read. + ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d reload=%d ok=%d", this->cfg_device_phase_, + this->cfg_channel_idx_, this->cfg_step_, this->cfg_reload_, this->cfg_ok_); + + // One-time device-level phase (init only). config_device_step() inspects cfg_ok_ itself. + if (this->cfg_device_phase_) { + if (this->config_device_step(this->cfg_step_, this->cfg_ok_, this->cfg_response_)) { + this->cfg_in_flight_ = true; + return true; + } + this->cfg_device_phase_ = false; + this->cfg_step_ = 0; + this->cfg_ok_ = true; + } + + USBUartChannel *channel = + this->cfg_single_ != nullptr + ? this->cfg_single_ + : (this->cfg_channel_idx_ < this->channels_.size() ? this->channels_[this->cfg_channel_idx_] : nullptr); + + if (channel != nullptr && channel->initialised_.load()) { + if (!this->cfg_ok_) { + // A previous step in this channel's sequence failed. Abort the rest. On a full init, + // mark the channel uninitialised so data flow isn't started on a misconfigured channel; + // on a reload, leave the already-working channel as it was. + if (!this->cfg_reload_) + channel->initialised_.store(false); + } else if (this->config_step(channel, this->cfg_step_, this->cfg_reload_, this->cfg_ok_, this->cfg_response_)) { + this->cfg_in_flight_ = true; + return true; + } + } + + // Channel finished (or aborted). On full init, kick off data flow if still initialised. + if (channel != nullptr && !this->cfg_reload_ && channel->initialised_.load()) { channel->input_started_.store(false); channel->output_started_.store(false); this->start_input(channel); } + + // Advance to the next channel (or finish). + this->cfg_step_ = 0; + this->cfg_ok_ = true; + if (this->cfg_single_ != nullptr) { + this->cfg_active_ = false; + this->cfg_single_ = nullptr; + } else if (++this->cfg_channel_idx_ >= this->channels_.size()) { + this->cfg_active_ = false; + } + + // If the machine just went idle and a reload was requested while it was busy, start it now. + if (!this->cfg_active_ && this->cfg_pending_reload_ != nullptr) { + this->cfg_single_ = this->cfg_pending_reload_; + this->cfg_pending_reload_ = nullptr; + this->start_config_(true); + } + return true; +} + +void USBUartChannel::load_settings(bool /*dump_config*/) { + // The per-channel control transfers already log their values at debug level. + this->parent_->apply_channel_settings(this); } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 6d60809b38..5bb4c97796 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -146,7 +146,9 @@ class USBUartChannel final : public uart::UARTComponent, public Parentedinput_buffer_.get_available(); } bool is_connected() override { return this->initialised_.load(); } uart::UARTFlushResult flush() override; - void check_logger_conflict() override {} + // Re-apply the current line settings (baud, parity, etc) to this already-open channel. + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience void set_parity(UARTParityOptions parity) { this->parity_ = parity; } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } @@ -160,6 +162,7 @@ class USBUartChannel final : public uart::UARTComponent, public Parented cb) { this->rx_callback_ = std::move(cb); } protected: + void check_logger_conflict() override {} // Larger structures first (8+ bytes) RingBuffer input_buffer_; LockFreeQueue output_queue_; @@ -195,6 +198,12 @@ class USBUartComponent : public usb_host::USBClient { virtual void start_input(USBUartChannel *channel); void start_output(USBUartChannel *channel); + // Begin configuring all channels (full initialisation). Called from on_connected(). + void enable_channels(); + // Re-apply line settings to a single, already-open channel (used by + // USBUartChannel::load_settings()). + void apply_channel_settings(USBUartChannel *channel); + // Called from loop() when input_buffer_ has insufficient space for the incoming chunk. // Default is a no-op; override in device-specific subclasses that need resync on overflow. virtual void on_rx_overflow(USBUartChannel *channel) {} @@ -206,7 +215,41 @@ class USBUartComponent : public usb_host::USBClient { EventPool chunk_pool_; protected: + // Issue one control transfer as part of the setup state machine. The completion + // callback (USB-task context) records the result/IN data, marks the step done and + // wakes the loop so run_config_machine_() advances on the loop thread. Call exactly + // once from config_step_()/config_device_step_() when issuing a step. + void config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index, + const std::vector &data = {}); + // (Re)start the config state machine. reload=false runs full init over all channels; + // reload=true re-applies settings to cfg_single_ only. + void start_config_(bool reload); + // Advance the config state machine; called from loop(). Returns true if it did work. + bool run_config_machine_(); + + // Per-subclass per-channel settings sequence. For the given zero-based step, issue the + // next control transfer via config_transfer_() and return true, or return false when the + // channel has no more steps. reload=true ⇒ apply only baud/parity/stop/data (skip + // enable/reset/DTR-RTS). ok/response carry the previous step's result and IN data. + virtual bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) = 0; + // Optional one-time device-level setup run before the per-channel phase on init only + // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. + virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + std::vector channels_{}; + + // Config state machine + USBUartChannel *cfg_single_{nullptr}; // non-null: reload of a single channel + USBUartChannel *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy + std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads + uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) + uint8_t cfg_channel_idx_{0}; + uint8_t cfg_step_{0}; + bool cfg_active_{false}; + bool cfg_reload_{false}; + bool cfg_device_phase_{false}; + bool cfg_in_flight_{false}; + bool cfg_ok_{true}; }; class USBUartTypeCdcAcm : public USBUartComponent { @@ -217,11 +260,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; - virtual void enable_channels(); - /// Resets per-channel transfer flags and posts the first bulk IN transfer. - /// Called by enable_channels() and by vendor-specific subclass overrides that - /// handle their own line-coding setup before starting data flow. - void start_channels_(); + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { @@ -230,7 +269,7 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: @@ -238,11 +277,11 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { void dump_config() override; protected: - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; private: - void apply_line_settings_(); CH34xChipType chiptype_{CHIP_UNKNOWN}; const char *chip_name_{"unknown"}; uint8_t num_ports_{1}; @@ -257,12 +296,7 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; - - int reset_(USBUartChannel *channel); - int set_baudrate_(USBUartChannel *channel, uint32_t baudrate = 0); - int set_line_properties_(USBUartChannel *channel); - int set_dtr_rts_(USBUartChannel *channel); + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; uint8_t chip_type_{255}; }; @@ -285,7 +319,7 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; }; diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index 6f38f58318..02a39d3c84 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -381,6 +381,15 @@ class WeikaiChannel : public uart::UARTComponent { /// we wait until all bytes are gone with a timeout of 100 ms uart::UARTFlushResult flush() override; +#if defined(USE_ESP8266) || defined(USE_ESP32) + /// @brief Re-apply the current line settings (baud, parity, etc) to the channel. + void load_settings(bool dump_config) override { + this->set_line_param_(); + this->set_baudrate_(); + } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience +#endif + protected: friend class WeikaiComponent; diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 798f7283f6..45f7b65289 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -37,6 +37,9 @@ class MockUARTComponent : public uart::UARTComponent { MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) }; class TestableMitsubishiCN105 : public MitsubishiCN105 { diff --git a/tests/components/uart/common.h b/tests/components/uart/common.h index de3ea3029e..5c4ba1130e 100644 --- a/tests/components/uart/common.h +++ b/tests/components/uart/common.h @@ -32,6 +32,9 @@ class MockUARTComponent : public UARTComponent { MOCK_METHOD(size_t, available, (), (override)); MOCK_METHOD(UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + MOCK_METHOD(void, load_settings, (bool dump_config), (override)); +#endif }; } // namespace esphome::uart::testing From 84f4fbeaa80900f52d2854511a85cafb227e0aab Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:41:36 +0200 Subject: [PATCH 2037/2214] [zigbee] Allow to combine and merge endpoints on esp32 (#17402) --- esphome/components/zigbee/__init__.py | 21 ++- esphome/components/zigbee/const.py | 3 + esphome/components/zigbee/const_esp32.py | 7 +- esphome/components/zigbee/const_zephyr.py | 2 +- esphome/components/zigbee/zigbee_ep_esp32.py | 157 +++++++++++++++--- esphome/components/zigbee/zigbee_esp32.py | 36 ++-- tests/components/zigbee/common_esp32.yaml | 12 +- .../zigbee/test-router.esp32-c6-idf.yaml | 7 + 8 files changed, 190 insertions(+), 55 deletions(-) create mode 100644 tests/components/zigbee/test-router.esp32-c6-idf.yaml diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 444012bcd8..775fb35140 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -18,10 +18,13 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType from .const import ( + CONF_ENDPOINT, + CONF_MAX_EP_NUMBER, CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, POWER_SOURCE, @@ -31,7 +34,7 @@ from .const import ( ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, - CONF_MAX_EP_NUMBER, + CONF_MAX_EP_NUMBER_ZEPHYR, CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, @@ -71,7 +74,17 @@ BASE_SCHEMA = cv.Schema( cv.requires_component("esp32"), _check_report_deprecation, cv.enum(REPORT, lower=True), - ) + ), + cv.Optional(CONF_ENDPOINT): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.int_range(1, CONF_MAX_EP_NUMBER), + ), + cv.Optional(CONF_USE_DEVICE_TYPE): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.boolean, + ), } ) BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_binary_sensor) @@ -148,8 +161,8 @@ def validate_number_of_ep(config: ConfigType) -> ConfigType: _LOGGER.warning( "Single endpoint requires ZHA or at leatst Zigbee2MQTT 2.8.0. For older versions of Zigbee2MQTT use multiple endpoints" ) - if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode: - raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}") + if count > CONF_MAX_EP_NUMBER_ZEPHYR and not CORE.testing_mode: + raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER_ZEPHYR}") return config diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index dd36f815ab..cfd23b9eb2 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -58,11 +58,14 @@ REPORT = { "default": report.ZIGBEE_REPORT_DEFAULT, } +CONF_ENDPOINT = "endpoint" +CONF_MAX_EP_NUMBER = 239 CONF_ON_JOIN = "on_join" CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" +CONF_USE_DEVICE_TYPE = "use_device_type" POWER_SOURCE = { "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN "MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index 81a8fc52cd..bfc4d93d5b 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -2,16 +2,13 @@ import esphome.codegen as cg DEVICE_TYPE = "device_type" ROLE = "role" -CONF_MAX_EP_NUMBER = 239 -CONF_NUM = "num" CONF_CLUSTERS = "clusters" CONF_ATTRIBUTES = "attributes" -CONF_ENDPOINT = "endpoint" CONF_CLUSTER = "cluster" SCALE = "scale" CONF_ATTRIBUTE_ID = "attribute_id" -KEY_BS_EP = "binary_sensor_ep" -KEY_SENSOR_EP = "sensor_ep" +KEY_ZIGBEE_EP = "zigbee_ep" +KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num" DEVICE_ID = { "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 63d03c7952..bf8e8287c4 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -1,4 +1,4 @@ -CONF_MAX_EP_NUMBER = 8 +CONF_MAX_EP_NUMBER_ZEPHYR = 8 CONF_ZIGBEE_ID = "zigbee_id" CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index f4efa7bf4e..ca96e4364f 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -2,16 +2,22 @@ from typing import Any import esphome.config_validation as cv from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE +from esphome.core import CORE -from .const import CONF_REPORT, REPORT +from .const import ( + CONF_MAX_EP_NUMBER, + CONF_REPORT, + CONF_USE_DEVICE_TYPE, + KEY_ZIGBEE, + REPORT, +) from .const_esp32 import ( - CLUSTER_ROLE, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, - CONF_MAX_EP_NUMBER, - CONF_NUM, DEVICE_TYPE, + KEY_ZIGBEE_EP, + KEY_ZIGBEE_EP_NO_NUM, ROLE, ) @@ -22,12 +28,12 @@ ep_configs: dict[str, dict[str, Any]] = { CONF_CLUSTERS: [ { CONF_ID: "BINARY_INPUT", - ROLE: CLUSTER_ROLE["SERVER"], + ROLE: "SERVER", CONF_ATTRIBUTES: [ { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: REPORT["default"], + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_DEVICE: None, }, { @@ -47,16 +53,15 @@ ep_configs: dict[str, dict[str, Any]] = { ], }, "analog_input": { - DEVICE_TYPE: "CUSTOM_ATTR", CONF_CLUSTERS: [ { CONF_ID: "ANALOG_INPUT", - ROLE: CLUSTER_ROLE["SERVER"], + ROLE: "SERVER", CONF_ATTRIBUTES: [ { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: REPORT["default"], + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_DEVICE: None, }, { @@ -78,22 +83,126 @@ ep_configs: dict[str, dict[str, Any]] = { } -def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]: +def get_next_ep_num(eps: list[int]) -> int: + try: + ep_num = [i for i in range(1, CONF_MAX_EP_NUMBER + 1) if i not in eps][0] + eps.append(ep_num) + except IndexError as e: + raise cv.Invalid( + f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." + ) from e + return ep_num + + +def merge_endpoint( + existing_ep: dict[str, Any], + ep_num: int | None, + ep: dict[str, Any], + use_type: bool | None, + skip_error: bool, +) -> bool: + add = True + existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]] + for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]: + if cl in existing_clusters: + if not skip_error: + raise cv.Invalid( + f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." + ) + add = False + break + if not add: + return False + if ( + use_type + and existing_ep.get(CONF_USE_DEVICE_TYPE) + and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) + ): + if not skip_error: + raise cv.Invalid( + f"Endpoint {ep_num} has a conflicting device type {existing_ep.get(DEVICE_TYPE, 'CUSTOM_ATTR')} and use_type is set for both." + ) + return False + if use_type: + existing_ep[CONF_USE_DEVICE_TYPE] = use_type + if ep.get(DEVICE_TYPE): + existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] + else: + existing_ep.pop(DEVICE_TYPE, None) + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + return True + if existing_ep.get(CONF_USE_DEVICE_TYPE): + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + return True + if ( + ep.get(DEVICE_TYPE) + and existing_ep.get(DEVICE_TYPE) + and ep[DEVICE_TYPE] != existing_ep[DEVICE_TYPE] + ): + if not skip_error: + raise cv.Invalid( + f"Endpoint {ep_num} has already a conflicting device type {existing_ep[DEVICE_TYPE]} and use_type is not set for both." + ) + return False + if ep.get(DEVICE_TYPE): + existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + return True + + +def create_ep(router: bool) -> None: + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) + ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) # create dummy endpoint if list is empty - if not ep_list: + if not ep_dict and not ep_list: ep_type = "CUSTOM_ATTR" if router: ep_type = "RANGE_EXTENDER" - ep_list = [ - { - DEVICE_TYPE: ep_type, - } - ] - # enumerate endpoints - for i, ep in enumerate(ep_list, 1): - ep[CONF_NUM] = i - if len(ep_list) > CONF_MAX_EP_NUMBER: - raise cv.Invalid( - f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." - ) - return ep_list + ep_dict[1] = {DEVICE_TYPE: ep_type} + if ep_list: + # merge endpoint with different clusters + ep_list_new: list[dict] = [] + for ep in ep_list: + added = False + for existing_ep in ep_list_new: + if merge_endpoint( + existing_ep, None, ep, ep.get(CONF_USE_DEVICE_TYPE), True + ): + added = True + break + if not added: + ep_list_new.append(ep) + + # Add endpoints with no number to the endpoint dict with a new number + eps = list(ep_dict.keys()) + for ep in ep_list_new: + ep_num = get_next_ep_num(eps) + ep_dict[ep_num] = ep + + # clear list so that it is not processed again + del zb_data[KEY_ZIGBEE_EP_NO_NUM] + + # Add default device type to endpoints that have none + for ep in ep_dict.values(): + if not ep.get(DEVICE_TYPE): + ep[DEVICE_TYPE] = "CUSTOM_ATTR" + + +def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + if ep_num is None: + if use_type: + ep[CONF_USE_DEVICE_TYPE] = use_type + ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) + ep_list.append(ep) + else: + ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) + if ep_num in ep_dict: + # check if the existing endpoint has same clusters + existing_ep = ep_dict[ep_num] + merge_endpoint(existing_ep, ep_num, ep, use_type, False) + else: + if use_type is not None: + ep[CONF_USE_DEVICE_TYPE] = use_type + ep_dict[ep_num] = ep diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index f19bc97be7..73dcd07029 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -35,9 +35,11 @@ from .const import ( ANALOG_INPUT_APPTYPE, BACNET_UNIT_NO_UNITS, BACNET_UNITS, + CONF_ENDPOINT, CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, POWER_SOURCE, ZigbeeAttribute, @@ -45,18 +47,17 @@ from .const import ( from .const_esp32 import ( ATTR_TYPE, CLUSTER_ID, + CLUSTER_ROLE, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, - CONF_NUM, DEVICE_ID, DEVICE_TYPE, - KEY_BS_EP, - KEY_SENSOR_EP, + KEY_ZIGBEE_EP, ROLE, SCALE, ) -from .zigbee_ep_esp32 import create_ep, ep_configs +from .zigbee_ep_esp32 import add_ep, create_ep, ep_configs _LOGGER = logging.getLogger(__name__) @@ -146,6 +147,7 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: raise cv.Invalid( f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'" ) + create_ep(config.get(CONF_ROUTER)) return config @@ -199,18 +201,14 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: }, ) setup_attributes(config, ep[CONF_CLUSTERS]) - zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) - sensor_ep: list[dict] = zb_data.setdefault(KEY_SENSOR_EP, []) - sensor_ep.append(ep) + add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: ep = copy.deepcopy(ep_configs["binary_input"]) setup_attributes(config, ep[CONF_CLUSTERS]) - zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) - binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, []) - binary_sensor_ep.append(ep) + add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config @@ -243,7 +241,7 @@ async def attributes_to_code( var.add_attr( ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], attr[CONF_ATTRIBUTE_ID], attr.get(CONF_MAX_LENGTH, 0), attr[CONF_VALUE], @@ -255,7 +253,7 @@ async def attributes_to_code( var, ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], attr[CONF_ATTRIBUTE_ID], ATTR_TYPE[attr[CONF_TYPE]], attr.get(SCALE, 1), @@ -287,9 +285,7 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": # create endpoints zb_data = CORE.data.get(KEY_ZIGBEE, {}) - sensor_ep: list[dict] = zb_data.get(KEY_SENSOR_EP, []) - binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, []) - ep_list = create_ep(sensor_ep + binary_sensor_ep, config.get(CONF_ROUTER)) + ep_dict: dict[int, dict] = zb_data.get(KEY_ZIGBEE_EP, {}) # setup zigbee components var = cg.new_Pvariable(config[CONF_ID]) @@ -301,15 +297,15 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": POWER_SOURCE[config[CONF_POWER_SOURCE]], ) ) - for ep in ep_list: - cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]])) + for ep_num, ep in ep_dict.items(): + cg.add(var.create_default_cluster(ep_num, DEVICE_ID[ep[DEVICE_TYPE]])) for cl in ep.get(CONF_CLUSTERS, []): cg.add( var.add_cluster( - ep[CONF_NUM], + ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], ) ) - await attributes_to_code(var, ep[CONF_NUM], cl) + await attributes_to_code(var, ep_num, cl) return var diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 787afc4476..8e00e4471e 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -8,10 +8,20 @@ binary_sensor: - platform: template name: "Garage Door Open 12" report: "force" + endpoint: 1 + +sensor: + - platform: template + name: "Temperature Sensor" + lambda: return 10.0; + device_class: temperature + unit_of_measurement: "°C" + endpoint: 1 + use_device_type: true zigbee: model: zigbee_test - router: true + router: false power_source: MAINS_SINGLE_PHASE on_join: then: diff --git a/tests/components/zigbee/test-router.esp32-c6-idf.yaml b/tests/components/zigbee/test-router.esp32-c6-idf.yaml new file mode 100644 index 0000000000..228fe331e5 --- /dev/null +++ b/tests/components/zigbee/test-router.esp32-c6-idf.yaml @@ -0,0 +1,7 @@ +zigbee: + model: zigbee_test + router: true + power_source: MAINS_SINGLE_PHASE + on_join: + then: + - logger.log: "Joined network" From 26f48ee9ea1626a4cc1b2bfdda440e699707dcc5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:21:23 -0400 Subject: [PATCH 2038/2214] [lvgl] Fix ImageValidator.process signature to match base (#17451) --- esphome/components/lvgl/lv_validation.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 56ee3b47af..b588e865d2 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -398,7 +398,10 @@ class ImageValidator(LValidator): ) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: # Local import to avoid circular import at module level from .lvcode import get_lambda_context_args @@ -419,7 +422,7 @@ class ImageValidator(LValidator): index = await metadata.from_.convert_value(index) return mapping_var.get(index) - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_image = ImageValidator() From dd0d0942f5867d0fa44352d6599ce66ba26d101f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:38:58 +1200 Subject: [PATCH 2039/2214] [image] Restructure into a platform component (#17416) --- .gitattributes | 2 + CODEOWNERS | 1 + esphome/components/animation/__init__.py | 118 +--- esphome/components/animation/image.py | 115 ++++ esphome/components/file/__init__.py | 1 + esphome/components/file/image.py | 315 +++++++++ esphome/components/image/__init__.py | 616 ++++++------------ esphome/components/online_image/__init__.py | 157 +---- esphome/components/online_image/image.py | 152 +++++ esphome/config.py | 12 + esphome/loader.py | 13 + script/build_language_schema.py | 11 - tests/component_tests/animation/__init__.py | 0 .../animation/config/anim.apng | Bin 0 -> 12626 bytes .../component_tests/animation/config/anim.gif | Bin 0 -> 9735 bytes .../config/animation_platform_test.yaml | 30 + .../animation/config/animation_test.yaml | 25 + tests/component_tests/animation/test_init.py | 81 +++ tests/component_tests/image/test_init.py | 446 +++++++++---- .../component_tests/online_image/__init__.py | 0 .../config/online_image_platform_test.yaml | 30 + .../config/online_image_test.yaml | 29 + .../component_tests/online_image/test_init.py | 76 +++ tests/components/animation/common.yaml | 19 +- tests/components/animation/validate.host.yaml | 16 + tests/components/file/common.yaml | 17 + tests/components/file/test.esp32-idf.yaml | 14 + tests/components/file/test.host.yaml | 9 + tests/components/image/common.yaml | 57 +- tests/components/image/test.esp8266-ard.yaml | 7 +- tests/components/image/test.host.yaml | 97 +-- .../image/validate-defaults.host.yaml | 25 + .../image/validate-grouped-single.host.yaml | 24 + .../image/validate-grouped.host.yaml | 25 + .../image/validate-single.host.yaml | 16 + tests/components/image/validate.host.yaml | 18 + tests/components/online_image/common.yaml | 29 +- .../online_image/validate.host.yaml | 22 + tests/unit_tests/test_config_normalization.py | 85 ++- 39 files changed, 1827 insertions(+), 883 deletions(-) create mode 100644 esphome/components/animation/image.py create mode 100644 esphome/components/file/__init__.py create mode 100644 esphome/components/file/image.py create mode 100644 esphome/components/online_image/image.py create mode 100644 tests/component_tests/animation/__init__.py create mode 100644 tests/component_tests/animation/config/anim.apng create mode 100644 tests/component_tests/animation/config/anim.gif create mode 100644 tests/component_tests/animation/config/animation_platform_test.yaml create mode 100644 tests/component_tests/animation/config/animation_test.yaml create mode 100644 tests/component_tests/animation/test_init.py create mode 100644 tests/component_tests/online_image/__init__.py create mode 100644 tests/component_tests/online_image/config/online_image_platform_test.yaml create mode 100644 tests/component_tests/online_image/config/online_image_test.yaml create mode 100644 tests/component_tests/online_image/test_init.py create mode 100644 tests/components/animation/validate.host.yaml create mode 100644 tests/components/file/common.yaml create mode 100644 tests/components/file/test.esp32-idf.yaml create mode 100644 tests/components/file/test.host.yaml create mode 100644 tests/components/image/validate-defaults.host.yaml create mode 100644 tests/components/image/validate-grouped-single.host.yaml create mode 100644 tests/components/image/validate-grouped.host.yaml create mode 100644 tests/components/image/validate-single.host.yaml create mode 100644 tests/components/image/validate.host.yaml create mode 100644 tests/components/online_image/validate.host.yaml diff --git a/.gitattributes b/.gitattributes index 1b3fd332b4..8171cd910f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ # Normalize line endings to LF in the repository * text eol=lf *.png binary +*.gif binary +*.apng binary diff --git a/CODEOWNERS b/CODEOWNERS index 821d2e5e74..619fc14087 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -187,6 +187,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi +esphome/components/file/* @esphome/core esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core esphome/components/fs3000/* @kahrendt diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 9c9c7e3871..0df7c56313 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -1,114 +1,36 @@ -import logging +# --------------------------------------------------------------------------- +# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after +# 2027.1.0. +# +# Animations are now a platform of the `image:` component (`platform: +# animation`); the real schema, actions and codegen live in `image.py`. This +# module only keeps the deprecated top-level `animation:` key working during the +# deprecation window: it reuses that schema/codegen and adds a one-shot +# deprecation warning (with a pasteable migrated `image:` block) at validation +# time. Deleting this file drops the top-level form entirely. +# --------------------------------------------------------------------------- -from esphome import automation -import esphome.codegen as cg -from esphome.components.const import CONF_LOOP import esphome.components.image as espImage import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_REPEAT -_LOGGER = logging.getLogger(__name__) +from .image import ANIMATION_CONFIG_SCHEMA, setup_animation -AUTO_LOAD = ["image"] +AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True -CONF_START_FRAME = "start_frame" -CONF_END_FRAME = "end_frame" -CONF_FRAME = "frame" +DOMAIN = "animation" -animation_ns = cg.esphome_ns.namespace("animation") +LEGACY_REMOVAL_VERSION = "2027.1.0" -Animation_ = animation_ns.class_("Animation", espImage.Image_) - -# Actions -NextFrameAction = animation_ns.class_( - "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) -) -PrevFrameAction = animation_ns.class_( - "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) -) -SetFrameAction = animation_ns.class_( - "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) +_capture_legacy_entry, _warn_legacy_animation = ( + espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION) ) -CONFIG_SCHEMA = cv.All( - espImage.IMAGE_SCHEMA.extend( - { - cv.Required(CONF_ID): cv.declare_id(Animation_), - cv.Optional(CONF_LOOP): cv.All( - { - cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, - cv.Optional(CONF_END_FRAME): cv.positive_int, - cv.Optional(CONF_REPEAT): cv.positive_int, - } - ), - }, - ), - espImage.validate_settings, -) +CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA) +FINAL_VALIDATE_SCHEMA = _warn_legacy_animation -NEXT_FRAME_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Animation_), - } -) -PREV_FRAME_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Animation_), - } -) -SET_FRAME_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(Animation_), - cv.Required(CONF_FRAME): cv.uint16_t, - } -) - - -@automation.register_action( - "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True -) -@automation.register_action( - "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True -) -@automation.register_action( - "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True -) -async def animation_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - if (frame := config.get(CONF_FRAME)) is not None: - template_ = await cg.templatable(frame, args, cg.uint16) - cg.add(var.set_frame(template_)) - return var - - -async def to_code(config): - ( - prog_arr, - width, - height, - image_type, - trans_value, - frame_count, - ) = await espImage.write_image(config, all_frames=True) - - var = cg.new_Pvariable( - config[CONF_ID], - prog_arr, - width, - height, - frame_count, - image_type, - trans_value, - ) - if loop_config := config.get(CONF_LOOP): - start = loop_config[CONF_START_FRAME] - end = loop_config.get(CONF_END_FRAME, frame_count) - count = loop_config.get(CONF_REPEAT, -1) - cg.add(var.set_loop(start, end, count)) +to_code = setup_animation diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py new file mode 100644 index 0000000000..95875fe2b0 --- /dev/null +++ b/esphome/components/animation/image.py @@ -0,0 +1,115 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_LOOP +from esphome.components.file.image import image_schema, write_image +from esphome.components.image import Image_, validate_settings +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_REPEAT +from esphome.types import ConfigType + +CODEOWNERS = ["@syndlex"] +AUTO_LOAD = ["file"] +DEPENDENCIES = ["display"] + +CONF_START_FRAME = "start_frame" +CONF_END_FRAME = "end_frame" +CONF_FRAME = "frame" + +animation_ns = cg.esphome_ns.namespace("animation") + +Animation_ = animation_ns.class_("Animation", Image_) + +# Actions +NextFrameAction = animation_ns.class_( + "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) +) +PrevFrameAction = animation_ns.class_( + "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) +) +SetFrameAction = animation_ns.class_( + "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) +) + +ANIMATION_SCHEMA = image_schema(Animation_).extend( + { + cv.Optional(CONF_LOOP): cv.All( + { + cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, + cv.Optional(CONF_END_FRAME): cv.positive_int, + cv.Optional(CONF_REPEAT): cv.positive_int, + } + ), + }, +) + +# Shared schema used by both the (deprecated) top-level `animation:` key and the +# `image:` `platform: animation` entry. +ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings) + + +NEXT_FRAME_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Animation_), + } +) +PREV_FRAME_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Animation_), + } +) +SET_FRAME_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(Animation_), + cv.Required(CONF_FRAME): cv.uint16_t, + } +) + + +@automation.register_action( + "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True +) +async def animation_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + + if (frame := config.get(CONF_FRAME)) is not None: + template_ = await cg.templatable(frame, args, cg.uint16) + cg.add(var.set_frame(template_)) + return var + + +async def setup_animation(config: ConfigType) -> None: + ( + prog_arr, + width, + height, + image_type, + trans_value, + frame_count, + ) = await write_image(config, all_frames=True) + + var = cg.new_Pvariable( + config[CONF_ID], + prog_arr, + width, + height, + frame_count, + image_type, + trans_value, + ) + if loop_config := config.get(CONF_LOOP): + start = loop_config[CONF_START_FRAME] + end = loop_config.get(CONF_END_FRAME, frame_count) + count = loop_config.get(CONF_REPEAT, -1) + cg.add(var.set_loop(start, end, count)) + + +CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA + +to_code = setup_animation diff --git a/esphome/components/file/__init__.py b/esphome/components/file/__init__.py new file mode 100644 index 0000000000..f70ffa9520 --- /dev/null +++ b/esphome/components/file/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/core"] diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py new file mode 100644 index 0000000000..9a7c762a79 --- /dev/null +++ b/esphome/components/file/image.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import logging +from pathlib import Path +import re + +from PIL import Image, UnidentifiedImageError + +from esphome import core, external_files +import esphome.codegen as cg +from esphome.components.const import CONF_BYTE_ORDER +from esphome.components.image import ( + CONF_INVERT_ALPHA, + CONF_OPAQUE, + CONF_TRANSPARENCY, + DOMAIN, + IMAGE_TYPE, + Image_, + ImageEncoder, + add_metadata, + get_image_type_enum, + get_transparency_enum, + is_svg_file, + validate_settings, + validate_transparency, + validate_type, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_DITHER, + CONF_FILE, + CONF_ICON, + CONF_ID, + CONF_PATH, + CONF_RAW_DATA_ID, + CONF_RESIZE, + CONF_SOURCE, + CONF_TYPE, + CONF_URL, +) +from esphome.core import CORE, HexInt +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] + +_LOGGER = logging.getLogger(__name__) + +# If the MDI file cannot be downloaded within this time, abort. +IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds + +SOURCE_LOCAL = "local" +SOURCE_WEB = "web" + +SOURCE_MDI = "mdi" +SOURCE_MDIL = "mdil" +SOURCE_MEMORY = "memory" + +MDI_SOURCES = { + SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/", + SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/", + SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", +} + + +def compute_local_image_path(value) -> Path: + url = value[CONF_URL] if isinstance(value, dict) else value + h = hashlib.new("sha256") + h.update(url.encode()) + key = h.hexdigest()[:8] + # Downloaded files are cached under the shared `image` domain directory so + # the cache location is unaffected by which platform requested the file. + base_dir = external_files.compute_local_file_dir(DOMAIN) + return base_dir / key + + +def local_path(value): + value = value[CONF_PATH] if isinstance(value, dict) else value + return str(CORE.relative_config_path(value)) + + +def download_file(url, path): + external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) + return str(path) + + +def download_gh_svg(value, source): + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + base_dir = external_files.compute_local_file_dir(DOMAIN) / source + path = base_dir / f"{mdi_id}.svg" + + url = MDI_SOURCES[source] + mdi_id + ".svg" + return download_file(url, path) + + +def download_image(value): + value = value[CONF_URL] if isinstance(value, dict) else value + return download_file(value, compute_local_image_path(value)) + + +def validate_file_shorthand(value): + value = cv.string_strict(value) + parts = value.strip().split(":") + if len(parts) == 2 and parts[0] in MDI_SOURCES: + match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) + if match is None: + raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") + return download_gh_svg(parts[1], parts[0]) + + if value.startswith(("http://", "https://")): + return download_image(value) + + value = cv.file_(value) + return local_path(value) + + +LOCAL_SCHEMA = cv.All( + { + cv.Required(CONF_PATH): cv.file_, + }, + local_path, +) + + +def mdi_schema(source): + def validate_mdi(value): + return download_gh_svg(value, source) + + return cv.All( + cv.Schema( + { + cv.Required(CONF_ICON): cv.string, + } + ), + validate_mdi, + ) + + +WEB_SCHEMA = cv.All( + { + cv.Required(CONF_URL): cv.string, + }, + download_image, +) + + +TYPED_FILE_SCHEMA = cv.typed_schema( + { + SOURCE_LOCAL: LOCAL_SCHEMA, + SOURCE_WEB: WEB_SCHEMA, + } + | {source: mdi_schema(source) for source in MDI_SOURCES}, + key=CONF_SOURCE, +) + + +OPTIONS_SCHEMA = { + cv.Optional(CONF_RESIZE): cv.dimensions, + cv.Optional(CONF_DITHER, default="NONE"): cv.one_of( + "NONE", "FLOYDSTEINBERG", upper=True + ), + cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, + cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), +} + + +def image_schema(class_: MockObjClass = Image_) -> cv.Schema: + """Build the validation schema for a single file-backed image entry. + + Shared by the built-in ``file`` image platform and the ``animation`` + platform (which extends it). Platforms that source their pixels elsewhere + (e.g. ``online_image``) provide their own schema instead. + + :param class_: The declared C++ class for the generated image instance. + """ + return cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(class_), + cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA), + cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + **OPTIONS_SCHEMA, + cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), + } + ) + + +def validate_image_final(config: ConfigType) -> ConfigType: + """Per-entry final validation, shared by file-backed image platforms. + + For LVGL 9 the default byte order for RGB565 images is little-endian, so + fill in that default when the user did not specify a byte order and warn + when big-endian was explicitly requested. + """ + if byte_order := config.get(CONF_BYTE_ORDER): + if byte_order == "BIG_ENDIAN": + _LOGGER.warning( + "The image '%s' is configured with big-endian byte order, little-endian is expected", + config.get(CONF_FILE), + ) + else: + config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + return config + + +async def new_image(config: ConfigType) -> MockObj: + """Generate a single file-backed ``image::Image`` instance. + + Used by the built-in ``file`` platform; encodes the image data, registers + the C++ variable and records its metadata for other components to consume. + """ + prog_arr, width, height, image_type, trans_value, _ = await write_image(config) + var = cg.new_Pvariable( + config[CONF_ID], prog_arr, width, height, image_type, trans_value + ) + add_metadata( + config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY] + ) + return var + + +async def write_image(config, all_frames=False): + path = Path(config[CONF_FILE]) + if not path.is_file(): + raise core.EsphomeError(f"Could not load image file {path}") + + resize = config.get(CONF_RESIZE) + try: + if is_svg_file(path): + import resvg_py + + resize = resize or (None, None) + image_data = resvg_py.svg_to_bytes( + svg_path=str(path), width=resize[0], height=resize[1], dpi=100 + ) + + # Convert bytes to Pillow Image + image = Image.open(io.BytesIO(image_data)) + width, height = image.size + + else: + image = Image.open(path) + width, height = image.size + if resize: + # Preserve aspect ratio + new_width_max = min(width, resize[0]) + new_height_max = min(height, resize[1]) + ratio = min(new_width_max / width, new_height_max / height) + width, height = int(width * ratio), int(height * ratio) + except (OSError, UnidentifiedImageError, ValueError) as exc: + raise core.EsphomeError(f"Could not read image file {path}: {exc}") from exc + + if not resize and (width > 500 or height > 500): + _LOGGER.warning( + 'The image "%s" you requested is very big. Please consider' + " using the resize parameter.", + path, + ) + + dither = ( + Image.Dither.NONE + if config[CONF_DITHER] == "NONE" + else Image.Dither.FLOYDSTEINBERG + ) + type = config[CONF_TYPE] + transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) + invert_alpha = config[CONF_INVERT_ALPHA] + frame_count = 1 + if all_frames: + with contextlib.suppress(AttributeError): + frame_count = image.n_frames + if frame_count <= 1: + _LOGGER.warning("Image file %s has no animation frames", path) + + # Encode each frame with its own encoder and concatenate. This keeps every + # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] + # per frame) so animation frame stepping in image.cpp / animation.cpp stays + # correct without needing to know the total frame count. + byte_order = config.get(CONF_BYTE_ORDER) + combined_data: list[int] = [] + encoder: ImageEncoder | None = None + for frame_index in range(frame_count): + image.seek(frame_index) + encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) + if byte_order is not None: + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") + pixels = encoder.convert(image.resize((width, height)), path).getdata() + for row in range(height): + for col in range(width): + encoder.encode(pixels[row * width + col]) + encoder.end_row() + encoder.end_image() + combined_data.extend(encoder.data) + + rhs = [HexInt(x) for x in combined_data] + prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) + image_type = get_image_type_enum(type) + trans_value = get_transparency_enum(encoder.transparency) + + return prog_arr, width, height, image_type, trans_value, frame_count + + +# The built-in static-image platform: pixels embedded at compile time from a +# local file, a downloaded web image, or a Material Design Icon. +CONFIG_SCHEMA = cv.All(image_schema(Image_), validate_settings) + +FINAL_VALIDATE_SCHEMA = validate_image_final + + +async def to_code(config: ConfigType) -> None: + await new_image(config) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 5f8e5ca132..37a9afb84d 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -1,38 +1,27 @@ from __future__ import annotations -import contextlib +from collections.abc import Callable from dataclasses import dataclass -import hashlib -import io import logging from pathlib import Path -import re from PIL import Image, UnidentifiedImageError -from esphome import core, external_files import esphome.codegen as cg from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv -from esphome.const import ( - CONF_DEFAULTS, - CONF_DITHER, - CONF_FILE, - CONF_ICON, - CONF_ID, - CONF_PATH, - CONF_RAW_DATA_ID, - CONF_RESIZE, - CONF_SOURCE, - CONF_TYPE, - CONF_URL, -) -from esphome.core import CORE, HexInt +from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.core import CORE +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) DOMAIN = "image" DEPENDENCIES = ["display"] +IS_PLATFORM_COMPONENT = True + +# Name of the built-in static-image platform (local file / web / MDI sources). +PLATFORM_FILE = "file" image_ns = cg.esphome_ns.namespace("image") @@ -135,17 +124,6 @@ class ImageEncoder: """ return False - @classmethod - def get_options(cls) -> list[str]: - """ - Get the available options for this image encoder - """ - options = [*OPTIONS] - if not cls.is_endian(): - options.remove(CONF_BYTE_ORDER) - options.append(CONF_RAW_DATA_ID) - return options - def is_alpha_only(image: Image): """ @@ -338,60 +316,11 @@ TransparencyType = image_ns.enum("TransparencyType") CONF_TRANSPARENCY = "transparency" -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - -SOURCE_LOCAL = "local" -SOURCE_WEB = "web" - -SOURCE_MDI = "mdi" -SOURCE_MDIL = "mdil" -SOURCE_MEMORY = "memory" - -MDI_SOURCES = { - SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/", - SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/", - SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", -} - Image_ = image_ns.class_("Image") INSTANCE_TYPE = Image_ -def compute_local_image_path(value) -> Path: - url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key - - -def local_path(value): - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) - - -def download_file(url, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) - return str(path) - - -def download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value - base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" - - url = MDI_SOURCES[source] + mdi_id + ".svg" - return download_file(url, path) - - -def download_image(value): - value = value[CONF_URL] if isinstance(value, dict) else value - return download_file(value, compute_local_image_path(value)) - - def is_svg_file(file): if not file: return False @@ -399,62 +328,6 @@ def is_svg_file(file): return " 500 or height > 500): - _LOGGER.warning( - 'The image "%s" you requested is very big. Please consider' - " using the resize parameter.", - path, - ) - - dither = ( - Image.Dither.NONE - if config[CONF_DITHER] == "NONE" - else Image.Dither.FLOYDSTEINBERG - ) - type = config[CONF_TYPE] - transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) - invert_alpha = config[CONF_INVERT_ALPHA] - frame_count = 1 - if all_frames: - with contextlib.suppress(AttributeError): - frame_count = image.n_frames - if frame_count <= 1: - _LOGGER.warning("Image file %s has no animation frames", path) - - # Encode each frame with its own encoder and concatenate. This keeps every - # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] - # per frame) so animation frame stepping in image.cpp / animation.cpp stays - # correct without needing to know the total frame count. - byte_order = config.get(CONF_BYTE_ORDER) - combined_data: list[int] = [] - encoder: ImageEncoder | None = None - for frame_index in range(frame_count): - image.seek(frame_index) - encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) - if byte_order is not None: - # Check for valid type has already been done in validate_settings - encoder.set_big_endian(byte_order == "BIG_ENDIAN") - pixels = encoder.convert(image.resize((width, height)), path).getdata() - for row in range(height): - for col in range(width): - encoder.encode(pixels[row * width + col]) - encoder.end_row() - encoder.end_image() - combined_data.extend(encoder.data) - - rhs = [HexInt(x) for x in combined_data] - prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) - image_type = get_image_type_enum(type) - trans_value = get_transparency_enum(encoder.transparency) - - return prog_arr, width, height, image_type, trans_value, frame_count - - def add_metadata(id: str, width: int, height: int, image_type: str, transparency): all_metadata = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) all_metadata[str(id)] = ImageMetaData( @@ -780,17 +388,10 @@ def add_metadata(id: str, width: int, height: int, image_type: str, transparency ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: + # Base platform-component codegen: each entry is generated by its platform's + # own ``to_code``; here we only need the feature define to be present. cg.add_define("USE_IMAGE") - # By now the config will be a simple list. - for entry in config: - prog_arr, width, height, image_type, trans_value, _ = await write_image(entry) - cg.new_Pvariable( - entry[CONF_ID], prog_arr, width, height, image_type, trans_value - ) - add_metadata( - entry[CONF_ID], width, height, entry[CONF_TYPE], entry[CONF_TRANSPARENCY] - ) def get_all_image_metadata() -> dict[str, ImageMetaData]: @@ -801,3 +402,198 @@ def get_all_image_metadata() -> dict[str, ImageMetaData]: def get_image_metadata(image_id: str) -> ImageMetaData | None: """Get image metadata by ID for use by other components.""" return get_all_image_metadata().get(image_id) + + +# --------------------------------------------------------------------------- +# Legacy top-level component -> `image:` platform deprecation helpers +# -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims. +# +# `animation:` and `online_image:` used to be standalone top-level components and +# are now platforms of `image:`. Their deprecated top-level shims use this helper +# to (1) record each raw entry as it is validated and (2) print a single, +# pasteable migrated `image:` block once every entry has been seen. The block is +# emitted from FINAL_VALIDATE_SCHEMA, which always runs after every per-entry +# CONFIG_SCHEMA step, so all entries are captured before it fires. +# --------------------------------------------------------------------------- + + +def legacy_platform_migration_warning( + domain: str, platform: str, removal_version: str +) -> tuple[ + Callable[[ConfigType], ConfigType], + Callable[[ConfigType], ConfigType], +]: + """Build the per-entry capture and one-shot warning validators for a + deprecated top-level component that is now an ``image:`` platform. + + Returns ``(capture, finalize)``: + * ``capture`` is a ``CONFIG_SCHEMA`` validator placed *before* the real + schema so it sees the raw user entry; it records a copy of each entry. + * ``finalize`` is a ``FINAL_VALIDATE_SCHEMA`` validator that warns exactly + once with the migrated, pasteable ``image:`` block. + """ + entries_key = "legacy_entries" + shown_key = "legacy_warning_shown" + + def capture(config: ConfigType) -> ConfigType: + data = CORE.data.setdefault(domain, {}) + data.setdefault(entries_key, []).append(dict(config)) + return config + + def finalize(config: ConfigType) -> ConfigType: + data = CORE.data.setdefault(domain, {}) + if not data.get(shown_key): + data[shown_key] = True + + from esphome import yaml_util + + migrated = [ + {CONF_PLATFORM: platform, **entry} + for entry in data.get(entries_key, []) + ] + _LOGGER.warning( + "The top-level '%s:' configuration is deprecated and will be " + "removed in ESPHome %s. '%s' is now a platform of the 'image' " + "component. Replace your '%s:' block with:\n\n%s", + domain, + removal_version, + domain, + domain, + yaml_util.dump({DOMAIN: migrated}), + ) + return config + + return capture, finalize + + +# --------------------------------------------------------------------------- +# Legacy `image:` config migration -- REMOVE after 2027.1.0 +# +# Before `image` became a platform component, its top-level config was either a +# bare list of image dicts, a single image dict, or a dict with `defaults:`, +# `images:` and per-type group keys. This block transparently rewrites those +# forms into the new ``platform: file`` list and prints the migrated YAML. +# It is intentionally self-contained so it can be deleted in one piece together +# with the ``LEGACY_CONFIG_MIGRATE`` assignment below. +# --------------------------------------------------------------------------- + +LEGACY_REMOVAL_VERSION = "2027.1.0" + + +def _is_new_image_format(config: object) -> bool: + """True when the config is already the new ``platform:``-tagged list.""" + return isinstance(config, list) and all( + isinstance(entry, dict) and CONF_PLATFORM in entry for entry in config + ) + + +def _is_legacy_image_format(config: object) -> bool: + """True when ``config`` matches a shape the pre-platform schema accepted. + + Only these shapes are migrated. Anything else -- a list containing a + non-dict (or already platform-tagged) entry, or a dict with no recognised + image keys -- is left untouched so the platform validation surfaces a + proper error instead of the migration silently dropping the input. + """ + if isinstance(config, list): + # A bare list of (not-yet-platform-tagged) image dicts. + return bool(config) and all( + isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config + ) + if not isinstance(config, dict): + return False + # A single image dict, or the grouped `defaults:`/`images:`/type-key form. + return ( + CONF_ID in config + or CONF_FILE in config + or any( + key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() in IMAGE_TYPE + for key in config + ) + ) + + +def _flatten_legacy_image_config(config: object) -> list[dict]: + """Structurally flatten a legacy ``image:`` config into image dicts. + + No validation or file IO is performed -- the ``file`` platform schema + validates the resulting entries. Unrecognised shapes yield no entries so the + normal platform validation surfaces the error. + """ + if isinstance(config, list): + return [dict(entry) for entry in config if isinstance(entry, dict)] + if not isinstance(config, dict): + return [] + if CONF_ID in config or CONF_FILE in config: + return [dict(config)] + + defaults = config.get(CONF_DEFAULTS) or {} + result: list[dict] = [] + + def _add(entry: dict, extra: dict) -> None: + merged = {**defaults, **extra, **entry} + # The legacy `defaults:`/type-grouped forms only applied `byte_order` to + # types that support it. Replicate that so an endian default merged into + # e.g. a binary image stays valid. + type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) + if ( + CONF_BYTE_ORDER in merged + and isinstance(type_class, type) + and issubclass(type_class, ImageEncoder) + and not type_class.is_endian() + ): + del merged[CONF_BYTE_ORDER] + result.append(merged) + + def _add_entries(entries: object, extra: dict) -> None: + # `entries` may be a single image dict or a list of them; non-dict + # members are silently skipped, mirroring the old `ensure_list` leniency. + for entry in [entries] if isinstance(entries, dict) else entries: + if isinstance(entry, dict): + _add(entry, extra) + + _add_entries(config.get(CONF_IMAGES, []), {}) + + for key, value in config.items(): + if key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() not in IMAGE_TYPE: + continue + type_extra = {CONF_TYPE: key} + if isinstance(value, dict) and ( + transparency_keys := [k for k in value if k in TRANSPARENCY_TYPES] + ): + for trans in transparency_keys: + _add_entries(value[trans], {**type_extra, CONF_TRANSPARENCY: trans}) + elif isinstance(value, (list, dict)): + _add_entries(value, type_extra) + return result + + +def _migrate_legacy_image_config(config: object) -> list[dict] | None: + """Rewrite a legacy ``image:`` config into the ``platform: file`` list. + + Returns None for the already-migrated platform form and for any shape the + pre-platform schema never accepted, so normal platform validation can + surface a proper error instead of the migration silently discarding input. + """ + if _is_new_image_format(config) or not _is_legacy_image_format(config): + return None + migrated = [ + {CONF_PLATFORM: PLATFORM_FILE, **entry} + for entry in _flatten_legacy_image_config(config) + ] + + from esphome import yaml_util + + _LOGGER.warning( + "The 'image:' configuration format is deprecated and will be removed in " + "ESPHome %s. Images are now platforms of the 'image' component. Replace " + "your 'image:' block with:\n\n%s", + LEGACY_REMOVAL_VERSION, + yaml_util.dump({DOMAIN: migrated}), + ) + return migrated + + +LEGACY_CONFIG_MIGRATE = _migrate_legacy_image_config + +# --------------------------- end legacy migration -------------------------- diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index d47c2e8b44..552a43acad 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -1,150 +1,35 @@ -import logging +# --------------------------------------------------------------------------- +# Legacy top-level `online_image:` deprecation shim -- REMOVE this whole file +# after 2027.1.0. +# +# Online images are now a platform of the `image:` component (`platform: +# online_image`); the real schema, actions and codegen live in `image.py`. This +# module only keeps the deprecated top-level `online_image:` key working during +# the deprecation window: it reuses that schema/codegen and adds a one-shot +# deprecation warning (with a pasteable migrated `image:` block) at validation +# time. Deleting this file drops the top-level form entirely. +# --------------------------------------------------------------------------- -from esphome import automation -import esphome.codegen as cg -from esphome.components import runtime_image -from esphome.components.const import CONF_REQUEST_HEADERS -from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent -from esphome.components.image import CONF_TRANSPARENCY, add_metadata +import esphome.components.image as espImage import esphome.config_validation as cv -from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL -from esphome.core import Lambda + +from .image import ONLINE_IMAGE_CONFIG_SCHEMA, setup_online_image AUTO_LOAD = ["image", "runtime_image"] DEPENDENCIES = ["display", "http_request"] CODEOWNERS = ["@guillempages", "@clydebarrow"] MULTI_CONF = True -CONF_ON_DOWNLOAD_FINISHED = "on_download_finished" -CONF_UPDATE = "update" +DOMAIN = "online_image" -_LOGGER = logging.getLogger(__name__) +LEGACY_REMOVAL_VERSION = "2027.1.0" -online_image_ns = cg.esphome_ns.namespace("online_image") - -OnlineImage = online_image_ns.class_( - "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage +_capture_legacy_entry, _warn_legacy_online_image = ( + espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION) ) -# Actions -SetUrlAction = online_image_ns.class_( - "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) -) -ReleaseImageAction = online_image_ns.class_( - "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) -) +CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ONLINE_IMAGE_CONFIG_SCHEMA) +FINAL_VALIDATE_SCHEMA = _warn_legacy_online_image -ONLINE_IMAGE_SCHEMA = ( - runtime_image.runtime_image_schema(OnlineImage) - .extend( - { - # Online Image specific options - cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), - cv.Required(CONF_URL): cv.url, - cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536), - cv.Optional(CONF_REQUEST_HEADERS): cv.All( - cv.Schema({cv.string: cv.templatable(cv.string)}) - ), - cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}), - cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), - } - ) - .extend(cv.polling_component_schema("never")) -) - -CONFIG_SCHEMA = cv.Schema( - cv.All( - ONLINE_IMAGE_SCHEMA, - cv.require_framework_version( - # esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed - # esp8266_arduino=cv.Version(2, 7, 0), - esp32_arduino=cv.Version(0, 0, 0), - esp_idf=cv.Version(4, 0, 0), - rp2_arduino=cv.Version(0, 0, 0), - host=cv.Version(0, 0, 0), - ), - runtime_image.validate_runtime_image_settings, - ) -) - -SET_URL_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(OnlineImage), - cv.Required(CONF_URL): cv.templatable(cv.url), - cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool), - } -) - -RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(OnlineImage), - } -) - - -@automation.register_action( - "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True -) -@automation.register_action( - "online_image.release", - ReleaseImageAction, - RELEASE_IMAGE_SCHEMA, - synchronous=True, -) -async def online_image_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - if CONF_URL in config: - template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) - cg.add(var.set_url(template_)) - if CONF_UPDATE in config: - template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) - cg.add(var.set_update(template_)) - return var - - -_CALLBACK_AUTOMATIONS = ( - automation.CallbackAutomation( - CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")] - ), - automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"), -) - - -async def to_code(config): - # Use the enhanced helper function to get all runtime image parameters - settings = await runtime_image.process_runtime_image_config(config) - add_metadata( - config[CONF_ID], - settings.width, - settings.height, - config[CONF_TYPE], - config[CONF_TRANSPARENCY], - ) - - url = config[CONF_URL] - var = cg.new_Pvariable( - config[CONF_ID], - url, - settings.width, - settings.height, - settings.format_enum, - settings.image_type_enum, - settings.transparent, - settings.placeholder or cg.nullptr, - config[CONF_BUFFER_SIZE], - settings.byte_order_big_endian, - ) - await cg.register_component(var, config) - await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID]) - - for key, value in config.get(CONF_REQUEST_HEADERS, {}).items(): - if isinstance(value, Lambda): - template_ = await cg.templatable(value, [], cg.std_string) - cg.add(var.add_request_header(key, template_)) - else: - cg.add(var.add_request_header(key, value)) - - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) +to_code = setup_online_image diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py new file mode 100644 index 0000000000..cb86f93e29 --- /dev/null +++ b/esphome/components/online_image/image.py @@ -0,0 +1,152 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import runtime_image +from esphome.components.const import CONF_REQUEST_HEADERS +from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent +from esphome.components.image import CONF_TRANSPARENCY, add_metadata +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL +from esphome.core import Lambda +from esphome.types import ConfigType + +AUTO_LOAD = ["runtime_image"] +DEPENDENCIES = ["http_request"] +CODEOWNERS = ["@guillempages", "@clydebarrow"] + +CONF_ON_DOWNLOAD_FINISHED = "on_download_finished" +CONF_UPDATE = "update" + +online_image_ns = cg.esphome_ns.namespace("online_image") + +OnlineImage = online_image_ns.class_( + "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage +) + +# Actions +SetUrlAction = online_image_ns.class_( + "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) +) +ReleaseImageAction = online_image_ns.class_( + "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) +) + + +ONLINE_IMAGE_SCHEMA = ( + runtime_image.runtime_image_schema(OnlineImage) + .extend( + { + # Online Image specific options + cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), + cv.Required(CONF_URL): cv.url, + cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536), + cv.Optional(CONF_REQUEST_HEADERS): cv.All( + cv.Schema({cv.string: cv.templatable(cv.string)}) + ), + cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}), + cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), + } + ) + .extend(cv.polling_component_schema("never")) +) + +# Shared schema used by both the (deprecated) top-level `online_image:` key and +# the `image:` `platform: online_image` entry. +ONLINE_IMAGE_CONFIG_SCHEMA = cv.All( + ONLINE_IMAGE_SCHEMA, + cv.require_framework_version( + # esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed + # esp8266_arduino=cv.Version(2, 7, 0), + esp32_arduino=cv.Version(0, 0, 0), + esp_idf=cv.Version(4, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), + host=cv.Version(0, 0, 0), + ), + runtime_image.validate_runtime_image_settings, +) + + +SET_URL_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(OnlineImage), + cv.Required(CONF_URL): cv.templatable(cv.url), + cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool), + } +) + +RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(OnlineImage), + } +) + + +@automation.register_action( + "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True +) +@automation.register_action( + "online_image.release", + ReleaseImageAction, + RELEASE_IMAGE_SCHEMA, + synchronous=True, +) +async def online_image_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + + if CONF_URL in config: + template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) + cg.add(var.set_url(template_)) + if CONF_UPDATE in config: + template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) + cg.add(var.set_update(template_)) + return var + + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")] + ), + automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"), +) + + +async def setup_online_image(config: ConfigType) -> None: + # Use the enhanced helper function to get all runtime image parameters + settings = await runtime_image.process_runtime_image_config(config) + add_metadata( + config[CONF_ID], + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) + + url = config[CONF_URL] + var = cg.new_Pvariable( + config[CONF_ID], + url, + settings.width, + settings.height, + settings.format_enum, + settings.image_type_enum, + settings.transparent, + settings.placeholder or cg.nullptr, + config[CONF_BUFFER_SIZE], + settings.byte_order_big_endian, + ) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID]) + + for key, value in config.get(CONF_REQUEST_HEADERS, {}).items(): + if isinstance(value, Lambda): + template_ = await cg.templatable(value, [], cg.std_string) + cg.add(var.add_request_header(key, template_)) + else: + cg.add(var.add_request_header(key, value)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +CONFIG_SCHEMA = ONLINE_IMAGE_CONFIG_SCHEMA + +to_code = setup_online_image diff --git a/esphome/config.py b/esphome/config.py index fc8f46909f..976faed447 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -599,6 +599,18 @@ class LoadValidationStep(ConfigValidationStep): CORE.loaded_integrations.add(self.domain) # For platform components, normalize conf before creating MetadataValidationStep if component.is_platform_component: + # Legacy config migration: allow a platform component to rewrite a + # pre-platform-format top-level config (e.g. a bare list or legacy + # dict form) into the normalized list of `platform:` tagged entries. + # Removable deprecation shim hook; no-op for components that do not + # define LEGACY_CONFIG_MIGRATE. + if ( + (migrate := component.legacy_config_migrate) is not None + and self.conf + and not isinstance(self.conf, core.AutoLoad) + and (migrated := migrate(self.conf)) is not None + ): + result[self.domain] = self.conf = migrated if not self.conf: result[self.domain] = self.conf = [] elif not isinstance(self.conf, list): diff --git a/esphome/loader.py b/esphome/loader.py index a9287abf86..22db8b156a 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -135,6 +135,19 @@ class ComponentManifest: """ return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None) + @property + def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None: + """Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module. + + Called once, before platform entries are processed, with the raw top-level + config for this domain. It may transform a pre-platform-format config (e.g. + a bare list or legacy dict form) into the normalized list of `platform:` + tagged entries and return it. Returning ``None`` means "already in the new + format, leave untouched". This is an intentionally removable deprecation + shim hook. + """ + return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None) + @property def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. diff --git a/script/build_language_schema.py b/script/build_language_schema.py index bc97a0d603..f6dcf00851 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -390,16 +390,6 @@ def fix_mapping(): output["mapping"][S_SCHEMAS][S_CONFIG_SCHEMA] = config -def fix_image(): - if "image" not in output: - return - from esphome.components.image import IMAGE_SCHEMA - - config = convert_config(IMAGE_SCHEMA, "image/CONFIG_SCHEMA") - config["is_list"] = True - output["image"][S_SCHEMAS][S_CONFIG_SCHEMA] = config - - def fix_menu(): if "display_menu_base" not in output: return @@ -763,7 +753,6 @@ def build_schema(): fix_font() fix_globals() fix_mapping() - fix_image() add_logger_tags() shrink() fix_menu() diff --git a/tests/component_tests/animation/__init__.py b/tests/component_tests/animation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/animation/config/anim.apng b/tests/component_tests/animation/config/anim.apng new file mode 100644 index 0000000000000000000000000000000000000000..927af5eb05a94ea8b1cdab493d2bfd8feffb7eac GIT binary patch literal 12626 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tsI14-?iy0t*k)fqhyqJN3 zu_)8oIUqARnSr5VPU*zm-pq~y?e@a17dx87#KasIO%?1F*dpjNL4$?Uuxb6XqDsz6 znQ}qF=!0ep6mI>{`l5d!Y=an!tKboX zTYVdCnLB5)#olv&U!|$4R-2Gyh7oa6UMEQpWhlN26m)D)jSgdQSAQ{apO?Pi4`h z)m_(bIHk{3(fq{Iy-V@x<4tNV{ii)Pr+)pPAK!aq$MT@N51Xf%U;ZP}a?Msl)aUc> zD<FnGE+hE&XXJ2!HJOsM4X<>^&bX;r(O zZlq{?DX>Hy(Q@=WqJF_BOmK6+QhypCsV0jM@i286ML2<_lHSt%=NQ=wN3n6|NjYGsjE&+&8Yr*(1*e3YXjHR!`4Bz zD*cm$Oph^KH29QxkaynU8$oM76z|z7P-fawvUvMT{t2#}8edPiEq

o;kxmZTaNd1mhcgW+Dkv z%NQ>Ey}#?$sBxZwXZp6|W~v>=78^fnZtR|wR#14F$1YduAlEIv%SD@K=&2rl73FvA z+~R}(1Ao0Od-!GZl+dsxwS}ua%_eP&(#pB6AZ^AnL-L{SCP@QRbq`jiBHM2~2P}?G zIC6e*i=8WHoLgsX-nQncUw(SsTDR!_^$X8Sr-`mnWp-Hob9?AozYnRGwKRe=OwR>7 z9lflrH^p~qXlCz;?W#GSj&AcbQ;PoB%5AXW;Y#1gr3bf}Z9nh4dX3oRVyR8DtY+SQ ze`wjBR}4v_s^3Jt-egbde63@=qj8)0#rOAfq8H|ES(5i+%60QK+XT+dcVE0LTqWMa zdunO(RnF5rf3IImzwvB!?u}Di>lWzdeCK2;77I_@pu0+M_03ax&I~LpjzU+jRK~E@ zH2Up0m&5v6(^;D1)Y&ON)=rsu+j{N;_POgW^zZt6{)gu-aq3dFs_l$JM$yU4Gm-cMOY|9Bs>uwo9&9_CWIWidDa?=Ki_O z!ovB##pt8)sRw(fH0s`s`*1og{6+X5*-1XXV@9v#6o5v-s=?us6b_wwXxY;e}kzLf2)cyYdVxD)OQtPzojCd zcFaDdxzBo&kH&1R&oZ_4Z+;*AUBc7HIz={S??PWkb%t%u+beiE&0@2*Es~pb?ewL_ z1TBWe{yY^1=Nz{4?d^8&{89Wy);gB+F-N7dL-f+aQ!HiAsd&a#xWD+^zE7NG&Eb7_ z*!>%)|Cy>6Ve)VPf~?o=ww7U$*<~vd^Sd+WOBWtx3nJ*dd@sq!r}0M*WD#@FQe=uDaLJ%&iv5_(_Z8zGIZYL`Le~%uZ(qq zlU&g{HkOjYpgA(JHeZ(g^9}g+>8jA73n~o($2P=YpYz^DF3>|2NR z)-eApaND&hF4uRG$`rdE{;j&-4y(N=w9VJ%-+06$E+q2pv=0+fuOwP^9uQ$#v|TY{ zLWuOIi0QWlUfo(`UfOV*XQm@3b9g|S^rPEBrWxX0+Y~>%sn}ZD^8NU2fh$@Y=CWK8 zGqY{awGk5dBEljN&G+kqxchZ6TfWk(G8d&wn7-}Pua5rtg!j~3&wqO-d|EbN@5_QK zVeLg7bK(?5>QJ6tYM%oun#&S&BM1I5NK4%vEf zCT{6s6xvns`-bp^hSg@w_f>iP-<)5(M6Yn_Dy6-(JeE|IHw9=3%p*UzNQ5Tys_< znG^OhKO62J{*#d{-!W6T!;sY|-a+rFc(;))i@&|ZpFa6GLF@MTmdLaHzppcT$j*B$ z#l}*c&2{4PUW*B3h6+MnwT(;e9$hUw!S;{JbL}#b6W>4Pom62wxoc}wxE8XFd!pth%U7z}XHU%vUyr1J6us!_lq0nRTD^_Xoo|t29gYcTKu%q%^sE_0Oe$TG@icpE7>x%I+8M znDsU`$DypU&V~7y?nPZaqubG(6Fhgw3$agRO^b2Z7H0Z4E?(hg^Ijc^#ZD)8Ihwv+ zV)G;+wBY!Ly6pw8`Jd#L_k6z+{-MadR^i$qrjNUWOj(<189rqx3HV4~D>?&({XEI50Esj!PH*R=Pdf?OEHLg4= z+^6OlGFl$V^^!RuUm;_}I!V}V=F91UjVq?x^-C#u23$&bQ}%oE)XIF$=d7RB=r*o6 zn%?K7P`TWx?cj+fBi10p34sYdjXlEiZr@L1yjVY(`S8Ej9PM{a6k-`!xY1Qjm3)t)i9 z|GoCDV*5jfWz2^SD}#UB{oeG~xaj9f{+gXBiMMjzi+FT7uC_R@7w^S1v3v8`vp-ml z@bOvraXisIzEXd}UsHx4Q4ZM?cJb>PmfNs!y_cxESpDjIK}*N%GhFK8SCbt)9m;l} z{LXge&>`;J3#%I6D6eOjP&YNzy+3r}8Vsa}DipK5f1q zd_3r}DDz9VhBw8NY*jK>emuG?(cz0*?Tq%8yZ5xNSvfMdOnz`OcPoclgNklbCu`fn z71N(Y=3F{o=(DR!FKcGCp^we3SqiMoPcMtN{A)OGdpb}tVeyY!@0A%(ZueQEev8ZD zK+wvhnGyGUOIW-0Pg}j(w83?H?_1s_-Gywsw!QFWY|xW!i+OsB|8&piJ>F02dc#$x zo<5S*{jqq;_K6K^o~Bn{z4g9BwrT3xjS(NJZBB}<`|@Vz!$pTqEZBA3W`5JbOCir} zgeL6Su*hJ}iB;FyE9XafNSamcnZ9Sjrl{@Pm$aK4JvmA2@o`y;iY7T#hkh}G``*3h z3&Tx(i(Nl8KVIo>TEO?Mr()*SpvNoTZQ%Ls&GMWxuIGQBfQL3qQ)x9fli}}+f@}IB z_RBDCIFj_|$)t#aI(uowd4^9_xx6GrjpUvy@4HYx>5Js89alnjdF`+Fvo7j8Vf1*_ zpLx=eJ^xj+oD;Nf@N9FwvaK{N`iNPG+$HVk)J8o&DL3~A2KLo9H)p7~<^?#sdL;YM zq&?mJ-*V>8`NDH2tE_0R-5d5I{CaVmj#X1L1KYF@0xf?c+?x+SH4(cwT}qxMDJ3*= zk3{|Tt}Tik)>reL=P*jl?EVNY|WA84p%NtUY9w)ai zNe=n-yZwpp{9l33J$Ef%x9a*2-c=_oQl{)&^Vsr}VVt`|z=mwgCe?O4PPaojLurA{$)$s>4g0@Myq|P-UEXY^8r>Yu+4JsYa7jP^ zB+y+IEB}yT*NWGAhPMxS3nYl=o;)ja=jpk;Xck|W#ijPKhDrt-IX)^^CrAHKy*RO% zvHZ!mylKDwz7x6YoViTXU*Jh&ven+kWJB2^`>FhgESO_&|K7QW0n*aG-uZ6b7e3KB z5{>f(8E#3nOk`Lh$a}xm?s$+pN0!4*H=d$fN^@T1$}E$PTfC%w)4emn=TpD#jku9B zr&##>YRyF5RW@GSN7tU4S@N!eQUjiFr1^q%;tB+^YB_HQ&zB`?_NWb}3xrrF3-Ns65-5|>Yu}K+aZyPb0|Vn6&{#l6Rgc5&hUV`#3?DdNaLLfEk3aif zecJTMsMWUuzkdIEzJGVQ>!-!?>rd&N>W%np!W4h{sq6JJ)#D4?XEV;8tDkdYQQ85K zz(uxs7tY#*PSf1ua_q+JM2!ViZ0vj3C0Z7^Ii0SY>f0jBs-enLw`9$S8A^vlL&IDz zzj|&O8F@V3?QVom`-=A`n0IXxFJ#^~C6`@V?s#td*}2P?O=R|%+}N<-aH;I7^}?El zCSN$t^;JCJaO_=iyyG>O;v5a72&D|AM_+UwA54{9`l)PNVoyomjw^OoG#Uz|Zho9H zjis0osLb+M2!8dkK<>y$X+uhS`uf(zYvH`2amwkPrOQe-GuJV;gOqdT{YMUM*}t4`xrU}igh?1Aa+pL|_=XUV9TD>@Jb>Meg z(p&9>^lAyWYy7JGnZ5>_eDse=Kl!=rUBmTui!=6Lr1u@n{-f;voy)0b;J4u60mb(S-vFzLVN5HV_^FRkLe=wix zC5JHMd8@xIV_;GDp5fHN^w8X6@53chx8^>6Dqm4K@2lec>c;hv0g1AQ{zVFgUVGVe z{`PF{-vw`Mr02LkpL57ERo}3>r(Sm6rOPWePiJF3xjS{{R|UQdo|bfmHv3ld#_94F zl?>kn=l?f+Eo_&6QuNo9lARt}GV@hi%Z}#qMVZY0GxNIsrhg}X+2+sRbm8B!wz`)h zXG|+RN-s1OsH)E{Kk)Q{WP_qyp=R~N509ru&fmCh)4x-{?aP(cbZ_`SOE*1ms@dsJ zt8PBg=Q5(BvcGBVUE6XOaJuBh3$a7@hY7K!&_oh@F-}AJY->C87L&oqQ?)#od z*B(8+{^t+gxc}QGd1YvKBHr@%;|-v|A*yjCB9ZYd$rc#kt}0}wZoJ~++Vw!J?cNY&3*Qc+)Hj- z%(FJj?9ShoC|!Bu)uu|$oDLyXc-8o&(&N0_q8%@Gl;uveR*9@l)9c*+V4od_ z?UQ2p&%cbaU8isQd*bBAnV0_O{5)}M$DwT%K9}-t)@A4`=X{o3xi(>Qzv=%j-Tu|N zsYmX!b?|)rnIhSGF4|t^uH_el30wAE3{c7Ek>lh4+VXw<$6vvA+m7AN-m^FB__Vt@ zoVihonVYwKUAb-cyol;V;cp`K-NoA+)o5yVyH~YHazCB;6+rMm@)!@P7^3UNt z=fhXqcCFbX+OY9l72C&;>3;-X3(ir`OKbUNQT=hrS?M*;mz@6UbG|ZXn`7MGmsiug zvkp&WoY;7NQt8JDXC|HzSU2sg@sGwkpPha+=(QVpcO`mM*DqakHakuD#)^nWY0=eW$9_ zE06zeYdv>TYwD+tmSv^E_tI>|EsxlrNYQzgFCie^-NVAMcey;*Q)}KT9*LDn2`z=k zl&c^7Si4f~%Y_Azf%kTQa@D(OaQcfTBG5F`WG&Nx8H*9z3tsy z-?5QPS$o&M%}a!)SZx0hv^_k>gioRN@r3oUl`@;&c=1Pk>WP$X4_WXe_gEog()@@I zxvy8<(qr^=5SqZE0;bk!GQON4G@%bawNgSkjLFDV!MFaPx5F}qlpFIyg(q+_WqvMy z$Fd~ZL5))tY#2+5OM=h@J%*E70cz0)kZm$pDfVREyu%?hy~CL>jiNM{Xi zhht03;)29a2~GIOn8~ZkI8$lDn*HqCCN-1@cTFY=dZz#i|Xe-#s0o7z)2S z1o6#W)Xu}SIaT7zB9le_4%?Vg1$uJrf|)k)YAF9L5poD-GLq6z-u1j|t3Kn+K1X+s z$cB_fEK35f6vex)owuItL{@&0!=HU~IZ|Tgg)0(b@&MH zUfsy^%Idg-+LgC=?>e*{IACe2Tsm`=CgV&Imizx>a~3%m&2QlOy=4EYw>k|MENoM% zJ#A~kUf4}ITiq6a&db3|IOQ7SPxfARj39Z>q@WS>}GiuYcJt&E7PzY;V>)%a$iU-Yd*_ZM((N z$GERpKCp7ujqJs2O<5^s->dB=8g*?8QGdMs<@Qp!+g_VqFJWUaTzuT=T*vj#or`nJ zvNtlW+kEoJ(gnUVJSuk^M=$;ta?*L0+GGBQS3XrKO?y?a9IxD?(++u`cnvSws6DRF;r}t`)6V=W{^ll*V1bt?1*v)EFtS++}LdzO99 z(2@(cNj4Vvuf3?{>6EVamLk!`pC?SXa@P6L$seZe6Vxn3idJ%lA5N-Vthj7mGMh%r z+OGnGGBrYi;881N3F;{RP^Fn+oFU^aay%ysL7r$pIbEST^TQqA^WgPeY?cXMZ@13^5tuR((+MbNW*Iw&2bbij=wTwsP{ipsr z#gon~5#QE%Xi~}QH8uaguls6yY<=99Jxk7a9jOf$-tnYIzGmeHmB>U-&legmYK*jW zIv>qlYooI2i{{q@6K|aVvgE?LD`l~pxTg8s{~!A}C9X|G`Eq`)wDK>rrQz#;M|qyy znRwmL`Rb+B%N&=62VRg|8oqSS_9<~^+os2r8VcA&Rz)qe_?Wr=Zv2&!y(w1&_pPzm z*v<8Im;MXaxH&tfZerWIw~lL9T5d|>9L40`3u!l=Y;qHB{HU(|X3xtVmzWLMD6>ec#se3)%o=Obgp3J+uPU=wc)v53Qx#TLP zW{GdPVwsSlzE|yYveddyO5dG}Ic$!;TB)4F>8bL4`*s~xx|*XJ>oA6s(0OeXwsA&%N6Wd zK7H0Z+2Sal!*tu@Wihkj(hGvQEN*Jsj(Xb*bkw{wRroN8!SxeoRqPLydXMhS-ZD0e z4y`EOu5Ep)tn%!?l*BSYju*b19K69Fcs6S8+TG!@Pcem6!cb%mPgl{#itfwv7^@O^ zJlM=6a@D?i=4_776@4S-;9+&*l$kzb;W~K_M%TTn9(T?tbXI+{RnrXL;BBz)#_IHM z#kZ_&2 z?auA$%$H?r|KIw(=h>N=*Jp;$`YTIX%Ty;xhbV33+vTNlich8_=UVnI!6|MAN2Cgj+9u4HDDV9~ zr1Om4uZxzV=XF-NY*U)Fa;yHo_cM!mH?wR@J!yHY{&Ka#h0rYRgEu)0Hdf5EDixd? zvtSEHi#^XQo@O1ziMM(tGdtw1_j+cx=+VypRVj7zy~NfFXx7+76op@U&c|}%-^3{) z2k+nW&zN|<^HAN?6Y+eqs+;659C*f{HZ4YfF;6r%Z{od$+E<*4k|Y~8%BuUMeP0#w z&gf6Zsvn+HH`%QXTI#Gaf9e1A!7D!{y8qZJXjiwgGjsO(vnL;Iz2Ld2qSs4&J=>0u zcSQ}I>EBjJycDuJDlqN(za=yI6<;URA3Q7)&6e=Qjw9Pc)UH+DXS)6ROOxYOc6z2R z>sxVWS9D0(udx0UwY~=tX|GoJ_w{T^QC}*X8DG6Qc>BYMs#k88PV+8Vtj{6yIx8Yf z>9=B&(ljP{pD?ESOEMg_8lHSMmd%M;Gh?SL?DRWh%V=*_JNwj{|GQ4*{aAJ?*J7EG z@$t~IXL`{_((jH`&zza8wfo=hhnpT+FIFzuyKL{JXSvHrJ|p4Rr4>{`2jwrg$w z$~g7@p9QD#|8ATnthN2A%E@M>lA0;1(qH~fW54Kk`Pm(_yU*5szj$BX(VW43?lj%D zg-g1_C+6?$S#ahsufy>-cV;slym2g0bWQSlkR>;aIM}{({JVJQ+Wb|2zQwON^zHc6 zt)I8A+VQwt#edQtwxAmySx#!ote<@IxLf#-Un_H$&ApVCX)dpwr#v^OR>CSNcIoo` zaLr4yZTe-uHcX2AG0CjKhg+aaG=h-Jmu|Iw@9y#6yH6sEwAWyJzMNjbGgY6yGuR74q5dsEz7i@ zl+WrBs9sylk!v|CiT&KOs0|i)&G6U&i-dA zvs$PjQWMu8vqy60|M zC#d>WCo#@A}P#PRwr=Y4Y;8kI9{=tU6 z#hMlcQqDJ1mWHnp;$YR()%;!0uxf9#I?sC9ho6}%4(znDj2BUw>whb9U2Ms%cw2od zMeRh{rzd`HJ|`gLe}B#7hC+F29~y)~^!9g*=CK zCwp_*vLBy*Zn3-NCawCN>8660r7tRP72UP=;-@*=w-%Qv=SjT#_9;92BU1^}{6hzM ze+pik$*?nYsdsznlB?@lTbL?^9Bwpj?)1}(UDF(xuE{)Qo#GD#bEZRw=BLh$m}(x9 zc)t6>RB4XaLJo^IOt5K~Gl8S~ICqD~kG1cpuuN|g4WZXG?=SrLP#9pQU3uZ{$wJg@`S$WW)qNluVMd`iH|V zNa*ri)?fXt@{Z7x;4a(G^3R|AGMVxzPI8uSwdw>*T}8v60xHL&{aoHSzfhT>v1Ib6 zDU6#!{C-)8J-;wbuSq}6>$}v>1K*D*>QB71>bZ^4!(aC^mvn^2Htx9p*yBunpWCCK zasH`iO;x0ypZk~Rs&xKm_{8oHKCH|7E2L6iWrzn(ljk^Ja!%&5gP$kcJFey@ylvhQEFrrymgd9u7#)M`3k zwXXG>T9ZW^t}~pJKj1k-FU{(2CC8+tspng7ChvT?b89)%(yKf6U;J)u8*fs1qAOQ3 zNmA|oqC3k!<(nG*7wT2o?=Cm#ZjpXk*Kht7r)c&Qt4!aF@aDb1iB;#g!`K!W==EukwDO^*1eZ`%pLq~*HkN=}{ zhuNP}=AAeE6+X){&y2CTZ?3TSr0FciNNJV{&%}b({bd(F_+2H@GBo|waOpjs@?Y$ztqRob1l3CJ7;oRSK z@5L1ygVk$Ui$u0>>S@flc=`Qdc9;9pLrb13`ZkoTYFS|I%eJj2yJ1IO>|5yxTaG&9 zZESp!p6V-m-a#ioZqpCHyIqOO3c1cJc)CmHl>L?8=Mc*O#7=yI^QMgsQ^YQ5PH6n{ zWS&R4Lr-hM?%ov*B0nA;`#C*V;aQ$*o!uec6p3qB7SE5$?|x!~2&qU`+X1+f#a6)^lPV^&yh&1PiDc4=;F%Jw_=4>;Z9WeHkWlBYOV zW9@c_m|Vlo#L}~Ve6|iBSf(<5YN%^k)$e!riQ-+RkI_E*4K9zo?}#n3PEO{2X(&)H zC&ad7>TbWPg*)asb22pj=J@kk@wWVW=1E#LYpNCINIWw)@!(l~m+_ zy|~B0=umNOk>&(#29=q&MBZNMOkL)u#B|BKQ>>4{u(7B5rj$cPv6Q{Qb?5m!E@gUe z#V0V{&$(Nz!KCuRaNjM-zY!(H3br%rzB8<9>y*iNjQ_}+h>cp4SL7*J`(0)1 zOtEG0yPI?VirR#GITKAhyjO0Yu#cHD{!nQ{g;;jm1*fKlDPrfYwBCLAz31etDvJr> z#~pTU6!`x;S)qB`q`roj4UHefLO1R0FisIIw-tVI<+#JG4O8-JI^;RZH0FMd>?pk} z;o;x7BkxT@_rZ?_3d!7=d`Iv6xclqueYO*;n2*>r8M0ln&g414dgRQPu=BSa)GUJU zf8M0Ev+;+T_;H6tV!TIqqWfBPRvY>65E95vmv2a^bx4`~aPz7F(a)vV*sg^Xe!RpG zw1zG6)TUW6F`pR@=K9;P+;aH0Voh%U>O0SVbDk*VeWFkzB_Hi^Wm8PL;#Y?hz3#>* z$DPGaXX*yVHe1GLJDjq<Z= zwVK?kXq|8&UjC~&y=nKVY!?BAqRpmi zF_o{5upZg1{j!m-VaDH0U+%qMAuGDUS7#N=i`=Bw+kIj$?=Ih_{5I$6t#>=4f1Yi4 ztmm*Jd}r0UR7vq%K?m(krFzdc%{%^=-EYcMhR*cpJKN3Qu|K-!G1L6@Fabodh3yj z^w(wEr25<6{jJ-6@AuukSARr);rYMq_MXSadRtmwv8L{SS^XsGaq_pja%UfYEO_*0 z)7s|?D#WiY{XDO@a+`Mf-8~Pc6}@fZP1yA8b&Fc`^R>sy_b&K!d;WpE2R~<8fAnBy zei*1eZ=KtIN&E0=56_-X34E2*-1{SR#i6rJVg5&#=PY+%h|m6^_HRz3`74coMWU*) zyi5~s{MoM&Jbmx|;%K>5>-#R;414UoA|bP(Ak#vB!u8T4f%%H>PI2&7=gv1f_uVP7 zw2^cDvI`Q&SVPTCiYxn~_O^0Nn9L`TtC-8sY3X3`#?&t5m09eCqc?lzXX`CAzw+w4 zqq*^dxBXxDepa2Aczo+j`}h9x&%$4QpAmnV@6JY{HpR>8s|#K&Tfkj&;@RYRXMap= zzv18|%M;0ya_PQ`TQHl?J|V<;LmK!|xw5^buj}5V5Jgou~b8 zQ)*e;Sueng4g^dW{>7B2kYQCFEXT@F;25imEfMW$O73FU>V)e=mr=x#!TK z?4Tn{KDDjRxwksW^|SuY6b`R@nFqIiFtLnXenPu6Xil}~ro8??zINxko^RX4?0(Zm zQA6qS)ylnpr_Qmis|Xf67M2@#R(Y2RPf&UJL|f!PF;U`uIB4!J2ib)zONFq zWNVQzcMmRjQ1L!8f{kluPBqK5gWbVzns;tc5r642F=<_Uw)nIh#*UR=PNq67ExD&C za_j7x|Eu`w-y5V;e!G4zX!D;dx}{;;c-k5l9lacXkYn-|J7vZEJbkbAYQmGfywH4OI2))#LOb`rkxt| zZyXP3`Kjpn&x1Wf_;pL0rp|4no|XEmj~q$d*!L$jC}dLi-QvPWZdVsQ{~w`b}{v&dAuEdKSe*`JeVozU4`DX%m39vd8|H-_&1xjaO54ny2%==Um76lx8t; ze3OiEo~zTWti5h=-=ZU&-Z9KCcd04_J=xb$$fus*z3>FXPEPBpu6+VR!j(MfPk#J7 zt~$|1Vg1j)6DKg|*3V1NiF{M`xy0k3%zgD;Cu<|xG|pLCI3&pytvJ2)RqksKQ}!nQ zqmo{H@lQ7L1&ZrOF`m(R)u?X#`OyLiAO!o4os%EV)8BV+&pGdTi6!nz zcFIdjWwKKg;}qU{$0iw1vogOh&8}I*ChW(9hTiHw5+@?0n19-zIwEpO-qZRBXfoE* L)z4*}Q$iB}JU+F8 literal 0 HcmV?d00001 diff --git a/tests/component_tests/animation/config/anim.gif b/tests/component_tests/animation/config/anim.gif new file mode 100644 index 0000000000000000000000000000000000000000..9932e774483eb3516bec26187591b997e6d41859 GIT binary patch literal 9735 zcmZ?wbhEHbOkqf2_|5 z$!u&{Y;0K^?Ah#`rR=QLZ0r>r?Dgy%bsQYc?CgE)>`ff(ogD0~>>Mo|9G#pTv)S3F zaj?(kV4us*Ih~VZ4hP3V4z|^t>`OVhmT+(`;^bJ(!MTouV?8J5I!?}Q9PHb;*fw)= zZs+9K#>KIXi)$+v*G^8(9bBBdxVZLkaqZ&bJj}&$gq!mSH|Gg%&ePl+r?@yzb90^J z<~+;IeU6L!BoEhlZl2TJTo<{yE^%>P<>tJ^!+nj1>moPLC2sD!++6p$x$g0B-{t1H z%gyzWoAVJj=Rm)%kzns z=MN9}4<4SMyu818d4KWq{N?5O&&%_NkLN!h&p$ri|9rgv`Fa2H^8MlC`^C!xih@x* zKp~*`pWDwhB-q(8z|~04fSHkjfkE*n3j>(`&+Y5(ZQtmB7#J+RGB7Yt zK!~Z#XJFuOVPM#HI3hAEN&##rL(+5xhVAVP3=%gB5{nYSV$2K-3`}Wh3=E%^GcfQ* zGB5~VU|`^iPR=OGh08N6zRti9Qq92NbC!W&<}3yVwKN6>{ul-wkP1+_&A{=WfssST zW5a@j%^bp7F()=GJlrmz>@~+@n{q4@7#ioZaB;|TZCP<~vBzYs*k8pOfeUP-n^JxRI-Fm?wn^^B zsjaK8uTMDKCF?yc`x=uyyGLxq)pSN?b^(`1KNbfQ4l^;SY>QG@*K|TyBjUmWMdoa7F@qEH ze0OhqdwWOm{kF4FhqJgvHC)0z%yf8h#Ew_N!uH@HR(>Uy88hVe?)v)r#^&t%=l1^k z^8TWJ#Qzx&8g?);WeID^1z0sUKa=xW(s3}Ut@gL!r`Pw-@8AFbKLb<6{aV8%2~8Z5 z6Q&t3=`kMYS0z(=wmU%viSa*{qZ<%QT5#nMP+WMUL$Y3PY<(o-!_vRB+<4 zHV}L!(<1ch*}@LBRWBCxm{nygEbcjZgwJqG#689eI}L}-<(VIz^Tlss%v_k{6s5It zsa@8qm5bJCy;|9J=)YCg!UDTYCbsE|rl&i06~1a^B=vsCj422}mo* zY~C$&MSDJ9iohF@!&BLF1l+ zgPWgay;{B8x^Cv1MMqa@z24s;$F06sEn~v#o~mE3()Tx?d-dXoNVv_0g zcEDhL^*7B!eNA1n+EtIqob^L#n&tY5RvK|qN&1+O3QHxqVHR$$Lw0Vm6OoTe8Ob`ySH+ z%h+OHUUz#P`8hL{z3SVo3iVrGZwF=nw=%!!nLhjb1Ln;S%dWV#_rAMPn!MNKVfJ=j z(>qYEV-Re;_N5T#zT85$d`#v_IfFLL92WJ+z+r2cr(A3K`f9bmr%L|P9l}s!P zS9z4~V4k+2^vCYmT+SL!;{WoRG<+WN$XD$BT4>OAYPRC_|1KFe-*WXkoOC1vl`|}5 zrk$zTm3{eCg@oO2$&aO0DN*OoY_S)+bgrHAjly@W6{ZGzSNg>EO!>9u$6@7Xc?G## zlO>l&9FW^&*pu>^f%W1vbIo__`f8F7$o^k`Ag8tKXq02 z#3~zST5#+unlyikD#ywfUY=$fC*=D);#z*)Np04J&g3VHjkvF$iI@94d8N-H-MO63 z=3JMXK53_^H*y}y$r7IMsOBN(XLDcGZJ(y)@+>g9z0)__Zt|r2SMDnv=KSw(Q#HBA zIOfSo9#htMsn7FnoO%8sL(^h=>4J`rDswHrrXMrV5$ej8d2X7#GT^TxmruM+hWS&= z^O>I}&rbe)SfSzIA@=Qc;M zH%~fbwajncHty(;U8_P$+14$cmc86??=;2br$Y12a(jLKED`p5$<=kUu7>%2d|Fid?mVMS_8s_uQw&3kt(XHt4FX}ZwKWyh?( zZH%{?Jo|via?7hH8M%d4-_O~?z!j10?*8K9B(7OYF3tM3#qF11>g*J4&BSS5vt19g z2vsZc{$IGlWyjgId5$GYd@DOxrZoxX2s+JT-jU8EIl+&Mtwdgo<;Bhmz3b{{SU3NX z>0sv43g={Wka}uxoJo?Sv1FMsld!{b>+6h+RoA;^zec6AJZ;UdI2Jrl;$@aePb6Pf z$mGU<|I(Ou9puen^LfB@c$pcS+J+pqH?yz)Xk$9GQoq?`m&JAyHlxXn2hz;WJmkwy zW1Kg0L-Uo*a%)W3^cy@2yPh9P5?wvxXtO3ymD6X-lBp8f1)AM`SnLxq9U{3D5ZAaw=Cv#y_cX14n$$Jv6u&2t{h2;gY0{RcJInBRgbOa=i4P{W6jfeA)~5rNR)*QEXvLRYBn)5Lc~C91_n^0hmnDS$pM0)>UqKHSr`}?D!}?h;Cx0fUlPuj z1M?NZd|w6z21ZbWh@ZicA%!8IAqm`A31G-z$Y&^F$Y&@9vkDj(7znF?x)QZ%st9kJ zg00m6TMO!HFxh_qvl&L)tcbSkXqz>X=55w?P-ljRfk~8+QHGIGfss*%kqHp@o65 zg@K`!fuV(wp^br|oq@5PfuW6&p`DSjg^8h+iK&&Dv7L#bjhUgHnW2e=v4w@9gMp!w zfuVzuv6GRpi-DnoiJ_B;v6GppiwRmHj8e1=0m6N@jf;=>^D}oq`)t9hVon+zaGAuE z1nINQwkUpe#M5Yr$4urXAqg*7pY2ph#)Soj?pA*^9WokMM{Lfz3+c0Mw~|$Gcy(xg z7`KE$!Gg87w--DRPjQk&sJP%TcrHRjZVpq~EMK zyzkVStt;%LUv0Zow)*w<2Sr+Xt5arG=Iz?Jt~z`5=|j@**5A^4y>{>QN7n21z5ld( z<(`XtCTqK1aNSWm_(Up4?+}-LSMH`S@1!@*=V9jgc(gVxrtpBw=9h)Lcjn0$O&5Ot z+mK_~yq-<_-^jhvJAHN69IZ2-C#{h^%X!=8@d=~pcQ&1OW71u-TTQt3^D0aB*!7oO zrOxVJa(32TeC6r6n5|b0lHP8(W?Fo9(RC)-JzH<+PU3C2nYj4vYMz__eivTK>+dZ- zm}*~Bb~pd}f35B31U{YlzNXxKj`PZTxBBum|NYfp zAIDoI&MHm+x$^#_^tl?dGSyCQofUtnzwWo)CE@=IzNy9k)GuN`%s+u^g$dg!PL`5i z^Zy2^I5-HNZER|CU=g&?(em}E&zmeinJ>d;o9g;9r9%@tPna~YuYDIS+qIA$B zhp_hKTKc4_IiGNn`eZm&QY9`ySo-nW+`i^O?&x<;C%vL3P0HTM6)*Ku{J@`y8i|}s zHQYVD7EM^%&nxMfFz?couw9>~uB++tNqH>n|L)1O9cPy6rC*xdvE|A113b$NyqP8? zNqwGi!e^P$^pj`OtUk}Y(Bk1RIzr08ATl~aI=Dwj+rd37ZblX{MrKJSb~Pq8EhY{f zW)4$kP8$|(R~8;07TzFMzDQR76gK`WR)J(T!E6qpTsGliHj!#}(Pj?O7Iv{tPO%OS z$$oap=^PRh*`;Q4NYCSxTEr={hD&ZMxBPA%g}vPJ2l(XAaw{I?QaZ~6Lg#pu&+#ao zRn#d`#fs*c~tN7sy^gV zeZ;Hwm`n8ux7t%~wdY*w&v{gz@TfoIQGLv#{)%7yHILdGUiG)U>hE~f-|%X@=Fxb| zr}>6g^F4^I{)tchGmrWg9<{H$>fiX(Kl5sQ;e41Z)wZ8CZ zeB;&p#;f_AU*j8}<~Kf#@BA9yc{RRpYyRZc{LQ2BgIDt>zvd4FW+t%FNoN;+o z?CEK_O)RNLl~fHDq%yQIvIyuJm^C)BHZXI``Rv&6@NkDPcNoL>16Oy+i`J=RF*r7{ z^2j+f2qb<^W1r{T4QXvIQFo~L(7>3?(!bs7TJ(WWP3*!&;R_NLZfE5>(9A9Gw`a%4 z$H&V*zmv5oZ)Po6f68yh^aG8jcW7sv*s!qq`+Hfl4p?jRa{qHXxjp3{rL=r182&Qc zJ;A>E`aau(PUZaaEecE=EMh+nHY&)hc+kXS_ToXafL6qV7WS$QjjW$_8KhGIsWLe%C^B_gcvR=pseW#f&72A)3l7$B{iz7Ab5dHsz#^RTfss@H zLxPJ~McT#|K{uU;&*wL=X}ws`BK9kNe%-gu32bDM?VJ^c81v~h;>UGRc=~W z?!}TUarA|ubX!38bfd-M_H64+%>wInRFZd>SSZQWt?nyJxg)V~$8qIvhMx0&g^4on z+~&CJLI<05%5s~ zl})P3dG=GTZ2TP?58oG+{(8iH+Q+J*prxGkJNCuAn$%nMN>#?1b6bL6(TqEv6E3Um zuyEJ=+VOjh>CgK|vJ{ zwxrm(@BDx0duHyu313un-)FA+x>k7l%A)7im1ZvWlRk$%dn{rltaY~RU2y9op9l8O zj~x%OGoIofo#CjzN=xToF++6Aie*77O)`WgEU~y9v*t?cC5v-KIl{4vt!Ho3f0_AZ zq3qRXT8CEzR8+l5Pnoq+pWD)V;jSrD4X(-sIWD-8%_h?Mf_bGVcSS&mVfLy})))GT zGp;h;+BoC*m+0FKVqtx!F82GsRp5$Vxw-1+m4)6@RU`LnoXQisyt@9+;n0;E zTo-jW%k_0t$TqQS%c_^A#Q*(uZoQS*iu|SWpVguR+S<5ETaM~*UX%zw_snymyKHdA z=P17ftF2p>on_j$#9;5t|5^uDCA%tR3QI=^p53;sKlPc2>h0T;+PbzGN;qC~k&dh{ z+`OaWlX%g`uG_YCddxzq8YQ;3BVKh)EuHTzakC}VvCU{=rG53mBL8VYhdRqEpFGvu z^rKSeKG*BS=c}}ivVGV47-W>fz!(0=iFG4ij6%Y^!#Ap#&pfdIw7SpDLVSzBoqJ3% zYSI5(bDO2x)+aV3)f;`&%q~k_neqG13qSL024UG< z3wjddvb^H|Et*kv`}e(uh2IlJKFng4IB@sJLccvvZKrLM`163ne@En>zxn0+cgH$i zz<+GcW`7A`@DK26*G=c&rejx*_g WpJ(0v^UUJ>&U5AKJ~uEhSOWldK6Y9F literal 0 HcmV?d00001 diff --git a/tests/component_tests/animation/config/animation_platform_test.yaml b/tests/component_tests/animation/config/animation_platform_test.yaml new file mode 100644 index 0000000000..380434dcc3 --- /dev/null +++ b/tests/component_tests/animation/config/animation_platform_test.yaml @@ -0,0 +1,30 @@ +# New `image:` `platform: animation` form. Exercises animation/image.py through +# the real platform loader and codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +image: + - platform: animation + id: test_animation + file: anim.gif + type: rgb565 + loop: + start_frame: 0 + end_frame: 2 + repeat: 3 + - platform: animation + id: test_animation_no_loop + file: anim.gif + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/animation/config/animation_test.yaml b/tests/component_tests/animation/config/animation_test.yaml new file mode 100644 index 0000000000..9d8fd15276 --- /dev/null +++ b/tests/component_tests/animation/config/animation_test.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `animation:` form. Exercises the deprecation shim and the +# shared codegen path through the real read_config/codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +animation: + - id: test_animation + file: anim.gif + type: rgb565 + loop: + start_frame: 0 + end_frame: 2 + repeat: 3 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/animation/test_init.py b/tests/component_tests/animation/test_init.py new file mode 100644 index 0000000000..1b5dd0d54c --- /dev/null +++ b/tests/component_tests/animation/test_init.py @@ -0,0 +1,81 @@ +"""Tests for the animation image platform and the legacy `animation:` shim.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +from esphome.components.animation import ( + DOMAIN, + LEGACY_REMOVAL_VERSION, + _capture_legacy_entry, + _warn_legacy_animation, +) +from esphome.core import CORE +from esphome.types import ConfigType + +# --------------------------------------------------------------------------- +# Legacy top-level `animation:` deprecation shim -- REMOVE these tests after +# 2027.1.0 together with the shim in esphome/components/animation/__init__.py. +# --------------------------------------------------------------------------- + + +def test_warn_legacy_animation_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecation warning fires exactly once and never mutates the config.""" + config: ConfigType = {"id": "test_animation", "file": "anim.gif", "type": "rgb565"} + + # A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the + # one-shot warning can print a pasteable migrated block. + assert _capture_legacy_entry(config) is config + + with caplog.at_level(logging.WARNING): + # First call: flag not yet set -> warns and records the flag. + assert _warn_legacy_animation(config) is config + # Second call: flag already set -> stays silent (the dedup branch). + assert _warn_legacy_animation(config) is config + + assert CORE.data[DOMAIN]["legacy_warning_shown"] is True + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "deprecated" in caplog.text + assert "platform: animation" in caplog.text + assert LEGACY_REMOVAL_VERSION in caplog.text + + +def test_legacy_animation_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """The legacy `animation:` block validates, warns, and generates codegen + through the real read_config/codegen pipeline.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(component_config_path("animation_test.yaml")) + + # Deprecation warning surfaced through the real validation pipeline. + assert "animation" in caplog.text + assert "deprecated" in caplog.text + + # setup_animation ran: Animation object constructed and loop configured. + assert "new(test_animation) animation::Animation(" in main_cpp + assert "test_animation->set_loop(0, 2, 3);" in main_cpp + + +def test_animation_platform_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The `image:` `platform: animation` form generates codegen through the + real platform loader (animation/image.py) without any deprecation warning.""" + main_cpp = generate_main(component_config_path("animation_platform_test.yaml")) + + assert "new(test_animation) animation::Animation(" in main_cpp + assert "test_animation->set_loop(0, 2, 3);" in main_cpp + # The loop-less entry constructs the object but never configures a loop. + assert "new(test_animation_no_loop) animation::Animation(" in main_cpp + assert "test_animation_no_loop->set_loop(" not in main_cpp diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index f7f60a1f4d..78462463b1 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +import logging from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -11,28 +12,36 @@ from PIL import Image as PILImage import pytest from esphome import config_validation as cv +from esphome.components.const import CONF_BYTE_ORDER +from esphome.components.file import image as file_image +from esphome.components.file.image import validate_image_final, write_image from esphome.components.image import ( CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, - CONFIG_SCHEMA, + PLATFORM_FILE, + _flatten_legacy_image_config, + _is_legacy_image_format, + _is_new_image_format, + _migrate_legacy_image_config, get_all_image_metadata, get_image_metadata, - write_image, ) -from esphome.const import CONF_DITHER, CONF_FILE, CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE +from esphome.const import ( + CONF_DITHER, + CONF_FILE, + CONF_ID, + CONF_PLATFORM, + CONF_RAW_DATA_ID, + CONF_TYPE, +) from esphome.core import CORE @pytest.mark.parametrize( ("config", "error_match"), [ - pytest.param( - "a string", - "Badly formed image configuration, expected a list or a dictionary", - id="invalid_string_config", - ), pytest.param( {"id": "image_id", "type": "rgb565"}, r"required key not provided @ data\['file'\]", @@ -43,6 +52,11 @@ from esphome.core import CORE r"required key not provided @ data\['id'\]", id="missing_id", ), + pytest.param( + {"id": "image_id", "file": "image.png"}, + r"required key not provided @ data\['type'\]", + id="missing_type", + ), pytest.param( {"id": "mdi_id", "file": "mdi:weather-##", "type": "rgb565"}, "Could not parse mdi icon name", @@ -84,155 +98,301 @@ from esphome.core import CORE "File can't be opened as image", id="invalid_image_file", ), - pytest.param( - {"defaults": {}, "images": [{"id": "image_id", "file": "image.png"}]}, - "Type is required either in the image config or in the defaults", - id="missing_type_in_defaults", - ), ], ) -def test_image_configuration_errors( +def test_file_platform_configuration_errors( config: Any, error_match: str, ) -> None: - """Test detection of invalid configuration.""" + """Invalid single-entry ``platform: file`` configs are rejected.""" with pytest.raises(cv.Invalid, match=error_match): - CONFIG_SCHEMA(config) + file_image.CONFIG_SCHEMA(config) + + +def test_file_platform_configuration_success() -> None: + """A fully-specified ``platform: file`` entry validates and keeps its keys.""" + result = file_image.CONFIG_SCHEMA( + { + "id": "image_id", + "file": "image.png", + "type": "rgb565", + "transparency": "chroma_key", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + } + ) + for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID): + assert key in result, f"Missing key {key} in validated image configuration" + + +# --------------------------------------------------------------------------- +# Legacy `image:` config migration -- REMOVE these tests after 2027.1.0 together +# with the migration shim in esphome/components/image/__init__.py. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + pytest.param( + [{CONF_PLATFORM: "file", "id": "a"}], True, id="new_platform_list" + ), + pytest.param([], True, id="empty_list"), + pytest.param([{"id": "a", "file": "x.png"}], False, id="legacy_bare_list"), + pytest.param([{CONF_PLATFORM: "file"}, {"id": "a"}], False, id="mixed_list"), + pytest.param( + [{CONF_PLATFORM: "file"}, "not-a-dict"], False, id="non_dict_entry" + ), + pytest.param({"defaults": {}}, False, id="legacy_dict"), + ], +) +def test_is_new_image_format(config: object, expected: bool) -> None: + assert _is_new_image_format(config) is expected + + +def test_flatten_bare_list_filters_non_dicts() -> None: + out = _flatten_legacy_image_config( + [{"id": "a", "file": "x.png", "type": "binary"}, "not-a-dict"] + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_non_dict_non_list_yields_nothing() -> None: + assert _flatten_legacy_image_config("a string") == [] + + +def test_flatten_single_dict_with_id() -> None: + config = {"id": "a", "file": "x.png", "type": "binary"} + assert _flatten_legacy_image_config(config) == [config] + + +def test_flatten_single_dict_with_file_only() -> None: + config = {"file": "x.png", "type": "binary"} + assert _flatten_legacy_image_config(config) == [config] + + +def test_flatten_defaults_images_list() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "rgb565", "byte_order": "little_endian"}, + "images": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "byte_order": "little_endian", + } + ] + + +def test_flatten_defaults_images_single_dict() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "rgb565"}, + "images": {"id": "a", "file": "x.png"}, + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "rgb565"}] + + +def test_flatten_type_grouped_list() -> None: + out = _flatten_legacy_image_config({"binary": [{"id": "a", "file": "x.png"}]}) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_transparency_list() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}]}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_flatten_type_grouped_transparency_single_dict() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": {"id": "a", "file": "x.png"}}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_flatten_type_grouped_dict_without_transparency() -> None: + out = _flatten_legacy_image_config({"binary": {"id": "a", "file": "x.png"}}) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_drops_byte_order_for_non_endian_type() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"byte_order": "little_endian"}, + "binary": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + assert CONF_BYTE_ORDER not in out[0] + + +def test_flatten_keeps_byte_order_for_endian_type() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"byte_order": "little_endian"}, + "rgb565": [{"id": "a", "file": "x.png"}], + } + ) + assert out[0][CONF_BYTE_ORDER] == "little_endian" + + +def test_flatten_skips_meta_and_unknown_keys() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "binary"}, + "images": [], + "not_a_type": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [] + + +def test_flatten_images_list_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "binary"}, + "images": [{"id": "a", "file": "x.png"}, "not-a-dict"], + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_list_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + {"binary": [{"id": "a", "file": "x.png"}, "not-a-dict"]} + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_scalar_value_is_ignored() -> None: + # A known type key whose value is neither a list nor a dict yields nothing. + assert _flatten_legacy_image_config({"binary": "not-a-list-or-dict"}) == [] + + +def test_flatten_type_grouped_transparency_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}, "not-a-dict"]}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_migrate_returns_none_for_new_format() -> None: + assert _migrate_legacy_image_config([{CONF_PLATFORM: "file", "id": "a"}]) is None + + +def test_migrate_legacy_warns_and_prepends_platform( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = _migrate_legacy_image_config( + [{"id": "a", "file": "x.png", "type": "binary"}] + ) + assert out == [ + {CONF_PLATFORM: PLATFORM_FILE, "id": "a", "file": "x.png", "type": "binary"} + ] + assert "deprecated" in caplog.text + assert f"platform: {PLATFORM_FILE}" in caplog.text + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + # Recognised legacy shapes -> migrate. + pytest.param([{"id": "a", "file": "x.png"}], True, id="bare_list_of_dicts"), + pytest.param({"id": "a", "file": "x.png"}, True, id="single_image_dict"), + pytest.param({"file": "x.png"}, True, id="single_dict_file_only"), + pytest.param({"defaults": {}, "images": []}, True, id="defaults_images"), + pytest.param({"rgb565": [{"id": "a"}]}, True, id="type_grouped"), + # Shapes the legacy schema never accepted -> not migrated. + pytest.param([], False, id="empty_list"), + pytest.param(["bad"], False, id="list_with_non_dict"), + pytest.param([{"id": "a"}, "bad"], False, id="list_mixed_dict_and_non_dict"), + pytest.param( + [{CONF_PLATFORM: "file", "id": "a"}], False, id="already_platform_tagged" + ), + pytest.param({"foo": 1}, False, id="dict_unknown_keys"), + pytest.param("a string", False, id="scalar"), + ], +) +def test_is_legacy_image_format(config: object, expected: bool) -> None: + assert _is_legacy_image_format(config) is expected @pytest.mark.parametrize( "config", [ - pytest.param( - { - "id": "image_id", - "file": "image.png", - "type": "rgb565", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - id="single_image_all_options", - ), - pytest.param( - [ - { - "id": "image_id", - "file": "image.png", - "type": "binary", - } - ], - id="list_of_images", - ), - pytest.param( - { - "defaults": { - "type": "rgb565", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - "images": [ - { - "id": "image_id", - "file": "image.png", - } - ], - }, - id="images_with_defaults", - ), - pytest.param( - { - "rgb565": { - "alpha_channel": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "alpha_channel", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - } - ] - }, - "binary": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "opaque", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - } - ], - }, - id="type_based_organization", - ), - pytest.param( - { - "defaults": { - "type": "binary", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - "rgb565": { - "alpha_channel": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "alpha_channel", - "dither": "none", - } - ] - }, - "binary": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "opaque", - } - ], - }, - id="type_based_with_defaults", - ), - pytest.param( - { - "defaults": { - "type": "rgb565", - "transparency": "alpha_channel", - }, - "binary": { - "opaque": [ - { - "id": "image_id", - "file": "image.png", - } - ], - }, - }, - id="binary_with_defaults", - ), + pytest.param(["bad"], id="list_with_non_dict"), + pytest.param([{"id": "a"}, "bad"], id="list_mixed"), + pytest.param({"foo": 1}, id="dict_unknown_keys"), ], ) -def test_image_configuration_success( - config: dict[str, Any] | list[dict[str, Any]], +def test_migrate_returns_none_for_invalid_legacy_shapes( + config: object, caplog: pytest.LogCaptureFixture ) -> None: - """Test successful configuration validation.""" - result = CONFIG_SCHEMA(config) - # All valid configurations should return a list of images - assert isinstance(result, list) - for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID): - assert all(key in x for x in result), ( - f"Missing key {key} in image configuration" + """Unrecognised shapes are not migrated (and emit no warning) so normal + platform validation surfaces a proper error instead of silently dropping + the offending input.""" + with caplog.at_level(logging.WARNING): + assert _migrate_legacy_image_config(config) is None + assert "deprecated" not in caplog.text + + +# --------------------------- end legacy migration -------------------------- + + +def test_validate_image_final_defaults_to_little_endian() -> None: + out = validate_image_final({CONF_FILE: "x.png"}) + assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + + +def test_validate_image_final_keeps_little_endian( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = validate_image_final( + {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"} ) + assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + assert "big-endian" not in caplog.text + + +def test_validate_image_final_warns_on_big_endian( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}) + assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN" + assert "big-endian" in caplog.text def test_image_generation( @@ -369,7 +529,7 @@ def test_get_all_image_metadata_empty() -> None: @pytest.fixture def mock_progmem_array(): """Mock progmem_array to avoid needing a proper ID object in tests.""" - with patch("esphome.components.image.cg.progmem_array") as mock_progmem: + with patch("esphome.components.file.image.cg.progmem_array") as mock_progmem: mock_progmem.return_value = MagicMock() yield mock_progmem diff --git a/tests/component_tests/online_image/__init__.py b/tests/component_tests/online_image/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/online_image/config/online_image_platform_test.yaml b/tests/component_tests/online_image/config/online_image_platform_test.yaml new file mode 100644 index 0000000000..883876e401 --- /dev/null +++ b/tests/component_tests/online_image/config/online_image_platform_test.yaml @@ -0,0 +1,30 @@ +# New `image:` `platform: online_image` form. Exercises online_image/image.py +# through the real platform loader and codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +wifi: + ssid: MySSID + password: password1 + +http_request: + verify_ssl: false + +image: + - platform: online_image + id: test_online_image + url: http://example.com/image.png + format: png + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/online_image/config/online_image_test.yaml b/tests/component_tests/online_image/config/online_image_test.yaml new file mode 100644 index 0000000000..ab0ad472f9 --- /dev/null +++ b/tests/component_tests/online_image/config/online_image_test.yaml @@ -0,0 +1,29 @@ +# Legacy top-level `online_image:` form. Exercises the deprecation shim and the +# shared codegen path through the real read_config/codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +wifi: + ssid: MySSID + password: password1 + +http_request: + verify_ssl: false + +online_image: + - id: test_online_image + url: http://example.com/image.png + format: png + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/online_image/test_init.py b/tests/component_tests/online_image/test_init.py new file mode 100644 index 0000000000..76b00ff5ff --- /dev/null +++ b/tests/component_tests/online_image/test_init.py @@ -0,0 +1,76 @@ +"""Tests for the online_image platform and the legacy `online_image:` shim.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +from esphome.components.online_image import ( + DOMAIN, + LEGACY_REMOVAL_VERSION, + _capture_legacy_entry, + _warn_legacy_online_image, +) +from esphome.core import CORE +from esphome.types import ConfigType + +# --------------------------------------------------------------------------- +# Legacy top-level `online_image:` deprecation shim -- REMOVE these tests after +# 2027.1.0 together with the shim in esphome/components/online_image/__init__.py. +# --------------------------------------------------------------------------- + + +def test_warn_legacy_online_image_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecation warning fires exactly once and never mutates the config.""" + config: ConfigType = {"id": "test_online_image", "url": "http://example.com/i.png"} + + # A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the + # one-shot warning can print a pasteable migrated block. + assert _capture_legacy_entry(config) is config + + with caplog.at_level(logging.WARNING): + # First call: flag not yet set -> warns and records the flag. + assert _warn_legacy_online_image(config) is config + # Second call: flag already set -> stays silent (the dedup branch). + assert _warn_legacy_online_image(config) is config + + assert CORE.data[DOMAIN]["legacy_warning_shown"] is True + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "deprecated" in caplog.text + assert "platform: online_image" in caplog.text + assert LEGACY_REMOVAL_VERSION in caplog.text + + +def test_legacy_online_image_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """The legacy `online_image:` block validates, warns, and generates codegen + through the real read_config/codegen pipeline.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(component_config_path("online_image_test.yaml")) + + # Deprecation warning surfaced through the real validation pipeline. + assert "online_image" in caplog.text + assert "deprecated" in caplog.text + + # setup_online_image ran: OnlineImage object constructed and parented. + assert "new(test_online_image) online_image::OnlineImage(" in main_cpp + + +def test_online_image_platform_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The `image:` `platform: online_image` form generates codegen through the + real platform loader (online_image/image.py) without a deprecation warning.""" + main_cpp = generate_main(component_config_path("online_image_platform_test.yaml")) + + assert "new(test_online_image) online_image::OnlineImage(" in main_cpp diff --git a/tests/components/animation/common.yaml b/tests/components/animation/common.yaml index 8bb2a2f4d8..6790e8439b 100644 --- a/tests/components/animation/common.yaml +++ b/tests/components/animation/common.yaml @@ -1,23 +1,26 @@ -animation: - - id: rgb565_animation +image: + - platform: animation + id: rgb565_animation file: $component_dir/anim.gif type: RGB565 transparency: opaque resize: 50x50 - - id: rgb_animation + - platform: animation + id: rgb_animation file: $component_dir/anim.apng type: RGB transparency: chroma_key resize: 50x50 - - id: grayscale_animation + - platform: animation + id: grayscale_animation file: $component_dir/anim.apng type: grayscale display: lambda: |- id(rgb565_animation).next_frame(); - id(rgb_animation1).next_frame(); - id(grayscale_animation2).next_frame(); + id(rgb_animation).next_frame(); + id(grayscale_animation).next_frame(); it.image(0, 0, rgb565_animation); - it.image(120, 0, rgb_animation1); - it.image(240, 0, grayscale_animation2); + it.image(120, 0, rgb_animation); + it.image(240, 0, grayscale_animation); diff --git a/tests/components/animation/validate.host.yaml b/tests/components/animation/validate.host.yaml new file mode 100644 index 0000000000..d754f34688 --- /dev/null +++ b/tests/components/animation/validate.host.yaml @@ -0,0 +1,16 @@ +# Legacy top-level `animation:` form (deprecated; migrates to +# `platform: animation`). Config-only test exercising the deprecation path. +display: + - platform: sdl + id: animation_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +animation: + - id: legacy_animation + file: $component_dir/anim.gif + type: RGB565 + transparency: opaque + resize: 50x50 diff --git a/tests/components/file/common.yaml b/tests/components/file/common.yaml new file mode 100644 index 0000000000..e95c6b01f6 --- /dev/null +++ b/tests/components/file/common.yaml @@ -0,0 +1,17 @@ +image: + - platform: file + id: file_binary_image + file: ../../pnglogo.png + type: BINARY + dither: FloydSteinberg + - platform: file + id: file_rgb565_image + file: ../../pnglogo.png + type: RGB565 + transparency: alpha_channel + resize: 50x50 + - platform: file + id: file_mdi_image + file: mdi:alert-circle-outline + type: BINARY + resize: 24x24 diff --git a/tests/components/file/test.esp32-idf.yaml b/tests/components/file/test.esp32-idf.yaml new file mode 100644 index 0000000000..29822d7b4f --- /dev/null +++ b/tests/components/file/test.esp32-idf.yaml @@ -0,0 +1,14 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: ili9xxx + id: file_main_lcd + spi_id: spi_bus + model: ili9342 + cs_pin: 15 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + +<<: !include common.yaml diff --git a/tests/components/file/test.host.yaml b/tests/components/file/test.host.yaml new file mode 100644 index 0000000000..76f9e5af85 --- /dev/null +++ b/tests/components/file/test.host.yaml @@ -0,0 +1,9 @@ +display: + - platform: sdl + id: file_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +<<: !include common.yaml diff --git a/tests/components/image/common.yaml b/tests/components/image/common.yaml index 9819068970..5a8f938319 100644 --- a/tests/components/image/common.yaml +++ b/tests/components/image/common.yaml @@ -1,85 +1,104 @@ image: - - id: binary_image + - platform: file + id: binary_image file: ../../pnglogo.png type: BINARY dither: FloydSteinberg - - id: transparent_transparent_image + - platform: file + id: transparent_transparent_image file: ../../pnglogo.png type: BINARY transparency: chroma_key - - id: rgba_image + - platform: file + id: rgba_image file: ../../pnglogo.png type: RGB transparency: alpha_channel resize: 50x50 - - id: rgb24_image + - platform: file + id: rgb24_image file: ../../pnglogo.png type: RGB transparency: chroma_key - - id: rgb_image + - platform: file + id: rgb_image file: ../../pnglogo.png type: RGB transparency: opaque - - id: rgb565_image + - platform: file + id: rgb565_image file: ../../pnglogo.png type: RGB565 transparency: opaque - - id: rgb565_ck_image + - platform: file + id: rgb565_ck_image file: ../../pnglogo.png type: RGB565 transparency: chroma_key - - id: rgb565_alpha_image + - platform: file + id: rgb565_alpha_image file: ../../pnglogo.png type: RGB565 transparency: alpha_channel - - id: grayscale_alpha_image + - platform: file + id: grayscale_alpha_image file: ../../pnglogo.png type: grayscale transparency: alpha_channel resize: 50x50 - - id: grayscale_ck_image + - platform: file + id: grayscale_ck_image file: ../../pnglogo.png type: grayscale transparency: chroma_key - - id: grayscale_image + - platform: file + id: grayscale_image file: ../../pnglogo.png type: grayscale transparency: opaque - - id: web_svg_image + - platform: file + id: web_svg_image file: https://media.esphome.io/logo/logo.svg resize: 256x48 type: BINARY transparency: chroma_key - - id: web_tiff_image + - platform: file + id: web_tiff_image file: https://media.esphome.io/tests/images/SIPI_Jelly_Beans_4.1.07.tiff type: RGB resize: 48x48 - - id: web_redirect_image + - platform: file + id: web_redirect_image file: https://media.esphome.io/logo/logo.png type: RGB resize: 48x48 - - id: mdi_alert + - platform: file + id: mdi_alert type: BINARY file: mdi:alert-circle-outline resize: 50x50 - - id: another_alert_icon + - platform: file + id: another_alert_icon file: mdi:alert-outline type: BINARY - - file: mdil:arrange-bring-to-front + - platform: file + file: mdil:arrange-bring-to-front id: mdil_id resize: 50x50 type: binary transparency: chroma_key - - file: mdi:beer + - platform: file + file: mdi:beer id: mdi_id resize: 50x50 type: binary transparency: chroma_key - - file: memory:alert-octagon + - platform: file + file: memory:alert-octagon id: memory_id resize: 50x50 type: binary diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 492b57c449..939a3ac39b 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -12,12 +12,11 @@ display: invert_colors: true image: - defaults: + - platform: file + id: test_image + file: ../../pnglogo.png type: rgb565 transparency: opaque byte_order: little_endian resize: 50x50 dither: FloydSteinberg - images: - - id: test_image - file: ../../pnglogo.png diff --git a/tests/components/image/test.host.yaml b/tests/components/image/test.host.yaml index aa45497088..455d41d0c2 100644 --- a/tests/components/image/test.host.yaml +++ b/tests/components/image/test.host.yaml @@ -7,43 +7,60 @@ display: height: 480 image: - binary: - - id: binary_image - file: ../../pnglogo.png - dither: FloydSteinberg - - id: transparent_transparent_image - file: ../../pnglogo.png - transparency: chroma_key - rgb: - alpha_channel: - - id: rgba_image - file: ../../pnglogo.png - resize: 50x50 - chroma_key: - - id: rgb24_image - file: ../../pnglogo.png - type: RGB - opaque: - - id: rgb_image - file: ../../pnglogo.png - rgb565: - - id: rgb565_image - file: ../../pnglogo.png - transparency: opaque - - id: rgb565_ck_image - file: ../../pnglogo.png - transparency: chroma_key - - id: rgb565_alpha_image - file: ../../pnglogo.png - transparency: alpha_channel - grayscale: - - id: grayscale_alpha_image - file: ../../pnglogo.png - transparency: alpha_channel - resize: 50x50 - - id: grayscale_ck_image - file: ../../pnglogo.png - transparency: chroma_key - - id: grayscale_image - file: ../../pnglogo.png - transparency: opaque + - platform: file + id: binary_image + file: ../../pnglogo.png + type: binary + dither: FloydSteinberg + - platform: file + id: transparent_transparent_image + file: ../../pnglogo.png + type: binary + transparency: chroma_key + - platform: file + id: rgba_image + file: ../../pnglogo.png + type: rgb + transparency: alpha_channel + resize: 50x50 + - platform: file + id: rgb24_image + file: ../../pnglogo.png + type: RGB + transparency: chroma_key + - platform: file + id: rgb_image + file: ../../pnglogo.png + type: rgb + transparency: opaque + - platform: file + id: rgb565_image + file: ../../pnglogo.png + type: rgb565 + transparency: opaque + - platform: file + id: rgb565_ck_image + file: ../../pnglogo.png + type: rgb565 + transparency: chroma_key + - platform: file + id: rgb565_alpha_image + file: ../../pnglogo.png + type: rgb565 + transparency: alpha_channel + - platform: file + id: grayscale_alpha_image + file: ../../pnglogo.png + type: grayscale + transparency: alpha_channel + resize: 50x50 + - platform: file + id: grayscale_ck_image + file: ../../pnglogo.png + type: grayscale + transparency: chroma_key + - platform: file + id: grayscale_image + file: ../../pnglogo.png + type: grayscale + transparency: opaque diff --git a/tests/components/image/validate-defaults.host.yaml b/tests/components/image/validate-defaults.host.yaml new file mode 100644 index 0000000000..16ea9e7b62 --- /dev/null +++ b/tests/components/image/validate-defaults.host.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `image:` defaults/images form (deprecated; migrates to +# `platform: file`). Config-only test exercising the deprecation/migration path, +# including the per-type byte_order drop when an entry overrides to a non-endian +# type (binary). +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + images: + - id: legacy_defaults_image + file: ../../pnglogo.png + - id: legacy_defaults_binary + file: ../../pnglogo.png + type: binary diff --git a/tests/components/image/validate-grouped-single.host.yaml b/tests/components/image/validate-grouped-single.host.yaml new file mode 100644 index 0000000000..0b6ff3d576 --- /dev/null +++ b/tests/components/image/validate-grouped-single.host.yaml @@ -0,0 +1,24 @@ +# Legacy top-level `image:` structured form using single-dict (non-list) values +# for `images:`, a type group, and a transparency group -- the old `ensure_list` +# accepted a bare dict in each of these places. Deprecated; migrates to +# `platform: file`. Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + images: + id: legacy_images_single_dict + file: ../../pnglogo.png + type: rgb565 + rgb565: + id: legacy_grouped_type_single_dict + file: ../../pnglogo.png + rgb: + alpha_channel: + id: legacy_grouped_transparency_single_dict + file: ../../pnglogo.png diff --git a/tests/components/image/validate-grouped.host.yaml b/tests/components/image/validate-grouped.host.yaml new file mode 100644 index 0000000000..8f85aa7ca5 --- /dev/null +++ b/tests/components/image/validate-grouped.host.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `image:` type-grouped form (deprecated; migrates to +# `platform: file`). Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + binary: + - id: legacy_grouped_binary + file: ../../pnglogo.png + rgb: + alpha_channel: + - id: legacy_grouped_rgba + file: ../../pnglogo.png + opaque: + - id: legacy_grouped_rgb + file: ../../pnglogo.png + rgb565: + - id: legacy_grouped_rgb565 + file: ../../pnglogo.png + transparency: chroma_key diff --git a/tests/components/image/validate-single.host.yaml b/tests/components/image/validate-single.host.yaml new file mode 100644 index 0000000000..52a945fb67 --- /dev/null +++ b/tests/components/image/validate-single.host.yaml @@ -0,0 +1,16 @@ +# Legacy top-level `image:` single-dict form (a bare image dict instead of a +# list; deprecated, migrates to `platform: file`). Config-only test exercising +# the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + id: legacy_single_image + file: ../../pnglogo.png + type: RGB565 + transparency: opaque diff --git a/tests/components/image/validate.host.yaml b/tests/components/image/validate.host.yaml new file mode 100644 index 0000000000..aa821ea7e2 --- /dev/null +++ b/tests/components/image/validate.host.yaml @@ -0,0 +1,18 @@ +# Legacy top-level `image:` list form (deprecated; migrates to `platform: file`). +# Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - id: legacy_list_binary + file: ../../pnglogo.png + type: BINARY + - id: legacy_list_rgb565 + file: ../../pnglogo.png + type: RGB565 + transparency: alpha_channel diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index fc3cc94217..f71cf63de9 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -2,11 +2,9 @@ wifi: ssid: MySSID password: password1 -# Purposely test that `online_image:` does auto-load `image:` -# Keep the `image:` undefined. -# image: -online_image: - - id: online_binary_image +image: + - platform: online_image + id: online_binary_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: BINARY @@ -21,34 +19,41 @@ online_image: } else { ESP_LOGD("online_image", "Cache miss: fresh download"); } - - id: online_binary_transparent_image + - platform: online_image + id: online_binary_transparent_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png type: BINARY transparency: chroma_key format: png - - id: online_rgba_image + - platform: online_image + id: online_rgba_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: RGB transparency: alpha_channel - - id: online_rgb24_image + - platform: online_image + id: online_rgb24_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: RGB transparency: chroma_key - - id: online_binary_bmp + - platform: online_image + id: online_binary_bmp url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp format: BMP type: BINARY - - id: online_rgb_bmp_8bit + - platform: online_image + id: online_rgb_bmp_8bit url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp format: BMP type: RGB - - id: online_jpeg_image + - platform: online_image + id: online_jpeg_image url: http://www.faqs.org/images/library.jpg format: JPEG type: RGB - - id: online_jpg_image + - platform: online_image + id: online_jpg_image url: http://www.faqs.org/images/library.jpg format: JPG type: RGB565 diff --git a/tests/components/online_image/validate.host.yaml b/tests/components/online_image/validate.host.yaml new file mode 100644 index 0000000000..f0ba98c65d --- /dev/null +++ b/tests/components/online_image/validate.host.yaml @@ -0,0 +1,22 @@ +# Legacy top-level `online_image:` form (deprecated; migrates to +# `platform: online_image`). Config-only test exercising the deprecation path. +wifi: + ssid: MySSID + password: password1 + +http_request: + +display: + - platform: sdl + id: online_image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +online_image: + - id: legacy_online_image + url: http://www.example.org/example.png + format: PNG + type: RGB565 + resize: 50x50 diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index a06b2da621..c8b7b63094 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -1,6 +1,6 @@ """Unit tests for esphome.config module.""" -from collections.abc import Generator +from collections.abc import Callable, Generator import logging from pathlib import Path from unittest.mock import MagicMock, Mock, patch @@ -8,7 +8,8 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config, yaml_util -from esphome.core import CORE +from esphome.core import CORE, AutoLoad +from esphome.types import ConfigType @pytest.fixture @@ -116,6 +117,86 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: assert "web_server" in platforms, f"Expected web_server platform in {platforms}" +# --------------------------------------------------------------------------- +# LEGACY_CONFIG_MIGRATE hook on LoadValidationStep -- the removable shim that +# lets a platform component rewrite a pre-platform top-level config. +# --------------------------------------------------------------------------- + + +def _run_load_step( + domain: str, + conf: object, + migrate: Callable[[ConfigType], list | None] | None, +) -> config.Config: + """Run a LoadValidationStep for a platform component with a given migrate hook.""" + component = Mock() + component.is_platform_component = True + component.multi_conf_no_default = False + component.legacy_config_migrate = migrate + + result = config.Config() + with ( + patch("esphome.config.get_component", return_value=component), + patch("esphome.config._process_auto_load"), + patch("esphome.config._process_platform_config"), + ): + config.LoadValidationStep(domain, conf).run(result) + return result + + +def test_legacy_migrate_rewrites_conf() -> None: + """A legacy config that the hook migrates is replaced with the new list.""" + migrated = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=migrated) + + result = _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate) + + migrate.assert_called_once_with([{"id": "a", "file": "x.png"}]) + assert result["image"] == migrated + + +def test_legacy_migrate_none_keeps_new_format() -> None: + """When the hook returns None the already-new config is left untouched.""" + new_format = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=None) + + result = _run_load_step("image", new_format, migrate) + + migrate.assert_called_once_with(new_format) + assert result["image"] == new_format + + +def test_legacy_migrate_absent_hook_is_noop() -> None: + """A platform component without the hook normalizes without migration.""" + result = _run_load_step("image", {"id": "a"}, None) + + # Bare dict still gets wrapped into a list by the normal normalization path. + assert result["image"] == [{"id": "a"}] + + +def test_legacy_migrate_skipped_for_empty_conf() -> None: + """An empty config short-circuits before the hook is consulted.""" + migrate = Mock(return_value=[{"platform": "file"}]) + + result = _run_load_step("image", [], migrate) + + migrate.assert_not_called() + assert result["image"] == [] + + +def test_legacy_migrate_skipped_for_autoload() -> None: + """An auto-loaded (AutoLoad) config is never migrated.""" + migrate = Mock(return_value=[{"platform": "file"}]) + auto = AutoLoad() + auto["id"] = "a" + + result = _run_load_step("image", auto, migrate) + + migrate.assert_not_called() + # AutoLoad is dict-like, so normalization wraps it into a single-entry list. + assert result["image"] == [auto] + + def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: """Create a config where two `<<` includes both define `logger:`. From 2db001710c3ba2c086c3f26420445d17a8a64a71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:41:50 -0400 Subject: [PATCH 2040/2214] Bump astral-sh/setup-uv from 8.3.1 to 8.3.2 in /.github/actions/restore-python (#17452) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 64b1cabea1..9d78b2d843 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the From d4bb20d34b32bf7fa66c1c6369d3b087b9f3668d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:05 -0400 Subject: [PATCH 2041/2214] Bump github/codeql-action/init from 4.36.3 to 4.37.0 (#17453) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 610e6ed020..ed6523d7d8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 99ff7e198aab14ec1cd06f39d70b779c4e66d053 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:18 -0400 Subject: [PATCH 2042/2214] Bump astral-sh/setup-uv from 8.3.1 to 8.3.2 (#17454) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 1757959a51..ebbe720463 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e08241681b..583e8203ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -171,7 +171,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -372,7 +372,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 7e0047ee0d..2f350d09b3 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 99ec2cc00ad8bc5c22d6a03a1ff9728e0f68dbb6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:38 -0400 Subject: [PATCH 2043/2214] Bump CodSpeedHQ/action from 4.18.2 to 4.18.4 (#17455) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 583e8203ef..6e93b6ece8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@4e969336ab9acd4f6f8d025fdd793292b0835df0 # v4.18.2 + uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4 with: run: | . venv/bin/activate From 9088875491377ca2f960b64ebeb641ebad500893 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:48 -0400 Subject: [PATCH 2044/2214] Bump github/codeql-action/analyze from 4.36.3 to 4.37.0 (#17456) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ed6523d7d8..e718b481e0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{matrix.language}}" From 640e0973acc23667e562cf0db1149bc19c7e0d20 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:54:28 +1000 Subject: [PATCH 2045/2214] [lvgl] Dynamic rotation features (#16773) --- esphome/components/lvgl/__init__.py | 2 + esphome/components/lvgl/automation.py | 27 ++- esphome/components/lvgl/defines.py | 2 + esphome/components/lvgl/layout.py | 56 +++++ esphome/components/lvgl/lv_validation.py | 13 ++ esphome/components/lvgl/lvgl_esphome.cpp | 28 ++- esphome/components/lvgl/lvgl_esphome.h | 16 ++ esphome/components/lvgl/schemas.py | 2 + esphome/components/lvgl/widgets/__init__.py | 99 ++++++--- .../lvgl/config/layout_update_test.yaml | 92 ++++++++ .../lvgl/test_layout_update.py | 208 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 34 +++ 12 files changed, 538 insertions(+), 41 deletions(-) create mode 100644 tests/component_tests/lvgl/config/layout_update_test.yaml create mode 100644 tests/component_tests/lvgl/test_layout_update.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b758390f0d..256bf4bb3a 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -148,6 +148,8 @@ SIMPLE_TRIGGERS = ( df.CONF_ON_RESUME, df.CONF_ON_DRAW_START, df.CONF_ON_DRAW_END, + df.CONF_ON_LANDSCAPE, + df.CONF_ON_PORTRAIT, ) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index bf9a3d74ad..b7c90a5c51 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -4,7 +4,6 @@ from typing import Any from esphome import automation from esphome.automation import StatelessLambdaAction import esphome.codegen as cg -from esphome.components.display import validate_rotation import esphome.config_validation as cv from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT from esphome.core import Lambda @@ -16,6 +15,7 @@ from .defines import ( CONF_BOTTOM_LAYER, CONF_EDITING, CONF_FREEZE, + CONF_LAYOUT, CONF_LVGL_ID, CONF_MAIN, CONF_OBJ, @@ -29,7 +29,8 @@ from .defines import ( get_options, get_refreshed_widgets, ) -from .lv_validation import lv_bool, lv_milliseconds +from .layout import layout_validator +from .lv_validation import lv_bool, lv_milliseconds, lv_rotation from .lvcode import ( LVGL_COMP_ARG, UPDATE_EVENT, @@ -199,7 +200,7 @@ async def lvgl_is_idle(config, condition_id, template_arg, args): def _validate_rotation(value): # Note that we need rotation get_options()[CONF_ROTATION] = True - return validate_rotation(value) + return lv_rotation(value) @automation.register_action( @@ -218,7 +219,8 @@ def _validate_rotation(value): async def lvgl_set_rotation(config, action_id, template_arg, args): lv_comp = await cg.get_variable(config[CONF_LVGL_ID]) async with LambdaContext(args, where=action_id) as context: - lv_add(lv_comp.set_rotation(config[CONF_ROTATION])) + rotation = await lv_rotation.process(config[CONF_ROTATION]) + lv_add(lv_comp.set_rotation(rotation)) return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) @@ -254,6 +256,13 @@ layer_spec = WidgetType(CONF_OBJ, lv_obj_t, (CONF_MAIN, CONF_SCROLLBAR), is_mock DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema} +def _layer_update_schema() -> cv.Schema: + """Schema for updating a display layer's styling and layout options.""" + return part_schema(layer_spec.parts).extend( + {cv.Optional(CONF_LAYOUT): layout_validator} + ) + + @automation.register_action( "lvgl.update", LvglAction, @@ -262,8 +271,9 @@ DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema} .extend(DISP_BG_SCHEMA) .extend( { - cv.Optional(CONF_TOP_LAYER): part_schema(layer_spec.parts), - cv.Optional(CONF_BOTTOM_LAYER): part_schema(layer_spec.parts), + cv.Optional(CONF_LAYOUT): layout_validator, + cv.Optional(CONF_TOP_LAYER): _layer_update_schema(), + cv.Optional(CONF_BOTTOM_LAYER): _layer_update_schema(), } ), synchronous=True, @@ -272,7 +282,12 @@ async def lvgl_update_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config, CONF_LVGL_ID) w = widgets[0] async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + # Apply the top-level properties (styles and layout) to the active screen... + await set_obj_properties(get_screen_active(w.var), config) + # ...the deprecated flat `disp_*` background properties... await lvgl_update(w.var, config) + # ...and the `top_layer`/`bottom_layer` keys (styling and layout updates). + await layers_to_code(w.var, config) var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) await cg.register_parented(var, w.var) return var diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 480ba515d1..4f734fe20c 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -760,7 +760,9 @@ CONF_ONE_CHECKED = "one_checked" CONF_ONE_LINE = "one_line" CONF_ON_DRAW_START = "on_draw_start" CONF_ON_DRAW_END = "on_draw_end" +CONF_ON_LANDSCAPE = "on_landscape" CONF_ON_PAUSE = "on_pause" +CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" CONF_ON_STOP = "on_stop" diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index 32304276d3..fd1f242d86 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -34,6 +34,7 @@ from .defines import ( TYPE_GRID, TYPE_NONE, LvConstant, + add_lv_use, ) from .lv_validation import padding, size @@ -401,6 +402,61 @@ LAYOUT_CLASSES = ( LAYOUT_CHOICES = [x.get_type() for x in LAYOUT_CLASSES] +# Layout properties that may be changed at runtime via an update action. These +# are limited to simple style properties (set via ``lv_obj_set_style_...``). +# Structural properties are deliberately excluded: +# - the layout ``type``, which determines which options are available to child +# widgets, and +# - the grid ``grid_rows``/``grid_columns`` descriptors, which define the cells +# that child widgets are placed into. +# Both are fixed at widget creation. +_GRID_LAYOUT_KEYS = ( + CONF_GRID_COLUMN_ALIGN, + CONF_GRID_ROW_ALIGN, +) +_FLEX_LAYOUT_KEYS = ( + CONF_FLEX_FLOW, + CONF_FLEX_ALIGN_MAIN, + CONF_FLEX_ALIGN_CROSS, + CONF_FLEX_ALIGN_TRACK, +) + +LAYOUT_UPDATE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_FLEX_FLOW): FLEX_FLOWS.one_of, + cv.Optional(CONF_FLEX_ALIGN_MAIN): flex_alignments, + cv.Optional(CONF_FLEX_ALIGN_CROSS): LV_FLEX_CROSS_ALIGNMENTS.one_of, + cv.Optional(CONF_FLEX_ALIGN_TRACK): flex_alignments, + cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments, + cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments, + cv.Optional(CONF_PAD_ROW): padding, + cv.Optional(CONF_PAD_COLUMN): padding, + } +) + + +def layout_validator(value): + """ + Validate a ``layout:`` value for an update action. Only the layout options + may be changed (not the layout ``type``, which is fixed at widget creation). + :param value: The value of the ``layout:`` key + :return: The validated layout options dict + """ + result = LAYOUT_UPDATE_SCHEMA(value) + if not result: + raise cv.Invalid( + "A layout update must specify at least one layout option", [CONF_LAYOUT] + ) + # Register the relevant layout feature so its LV_USE_* define is emitted even + # when the option is set solely via an update action (whose code generation + # may run after LVGL has finished collecting its used features). + if any(key in result for key in _GRID_LAYOUT_KEYS): + add_lv_use(TYPE_GRID) + if any(key in result for key in _FLEX_LAYOUT_KEYS): + add_lv_use(TYPE_FLEX) + return result + + def append_layout_schema(schema, config: dict): """ Get the child layout schema for a given widget based on its layout type. diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index b588e865d2..42352b9602 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -331,6 +331,19 @@ lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True) +def rotation_degrees(value): + """Validate a display rotation, returning the angle in whole degrees. + + Accepts the four supported rotations, optionally suffixed with "°". + """ + value = cv.string(value).removesuffix("°") + return cv.one_of(0, 90, 180, 270, int=True)(value) + + +# Validator for a display rotation expressed in whole degrees (templatable) +lv_rotation = LValidator(rotation_degrees, cg.int_) + + @schema_extractor("one_of") def size_validator(value): """A size in one axis - one of "size_content", a number (pixels) or a percentage""" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 1db5992389..b66a904437 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -91,11 +91,24 @@ void LvglComponent::set_rotation(display::DisplayRotation rotation) { this->rotation_ = rotation; if (this->is_ready()) { this->set_resolution_(); + this->update_orientation_(); lv_obj_update_layout(this->get_screen_active()); lv_obj_invalidate(this->get_screen_active()); } } +void LvglComponent::set_rotation(int angle) { + // Normalize to [0, 360). The DisplayRotation enum values are the angles in degrees. + angle %= 360; + if (angle < 0) + angle += 360; + if (angle % 90 != 0) { + ESP_LOGW(TAG, "Invalid rotation angle %d; must be a multiple of 90 degrees.", angle); + return; + } + this->set_rotation(static_cast(angle)); +} + void LvglComponent::rotate_coordinates(int32_t &x, int32_t &y) const { switch (this->rotation_) { default: @@ -719,6 +732,18 @@ void LvglComponent::set_resolution_() const { } lv_display_set_resolution(this->disp_, width, height); } + +void LvglComponent::update_orientation_() { + // A square display is treated as landscape. + auto orientation = this->get_width() >= this->get_height() ? Orientation::LANDSCAPE : Orientation::PORTRAIT; + if (orientation == this->orientation_) + return; + this->orientation_ = orientation; + auto *trigger = orientation == Orientation::LANDSCAPE ? this->landscape_callback_ : this->portrait_callback_; + if (trigger != nullptr) + trigger->trigger(); +} + void LvglComponent::setup() { auto *display = this->displays_[0]; auto rounding = this->draw_rounding; @@ -757,7 +782,7 @@ void LvglComponent::setup() { lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); lv_display_set_buffers(this->disp_, this->draw_buf_, nullptr, buf_bytes, this->full_refresh_ ? LV_DISPLAY_RENDER_MODE_FULL : LV_DISPLAY_RENDER_MODE_PARTIAL); - if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) { + if (this->rotation_type_ == ROTATION_SOFTWARE) { this->rotate_buf_ = static_cast(lv_alloc_draw_buf(buf_bytes, false)); // NOLINT if (this->rotate_buf_ == nullptr) { this->status_set_error(LOG_STR("Memory allocation failure")); @@ -796,6 +821,7 @@ void LvglComponent::setup() { #endif this->show_page(0, LV_SCREEN_LOAD_ANIM_NONE, 0); lv_display_trigger_activity(this->disp_); + this->update_orientation_(); } void LvglComponent::update() { diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index dcbf490bce..9221ab9542 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -185,6 +185,12 @@ enum RotationType : uint8_t { ROTATION_HARDWARE, }; +enum class Orientation : uint8_t { + UNKNOWN, + LANDSCAPE, + PORTRAIT, +}; + class LvglComponent final : public PollingComponent { constexpr static const char *const TAG = "lvgl"; @@ -291,7 +297,11 @@ class LvglComponent final : public PollingComponent { void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; } void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; } void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; } + void set_landscape_trigger(Trigger<> *trigger) { this->landscape_callback_ = trigger; } + void set_portrait_trigger(Trigger<> *trigger) { this->portrait_callback_ = trigger; } void set_rotation(display::DisplayRotation rotation); + /// Set the rotation from an angle in degrees. Must be a multiple of 90. + void set_rotation(int angle); display::DisplayRotation get_rotation() const { return this->rotation_; } void rotate_coordinates(int32_t &x, int32_t &y) const; @@ -300,6 +310,9 @@ class LvglComponent final : public PollingComponent { protected: void set_resolution_() const; + // Determine the current orientation from the effective resolution and fire the + // landscape/portrait trigger if it has changed since the last check. + void update_orientation_(); void draw_end_(); // Not checking for non-null callback since the // LVGL callback that calls it is not set in that case @@ -347,6 +360,9 @@ class LvglComponent final : public PollingComponent { Trigger<> *resume_callback_{}; Trigger<> *draw_start_callback_{}; Trigger<> *draw_end_callback_{}; + Trigger<> *landscape_callback_{}; + Trigger<> *portrait_callback_{}; + Orientation orientation_{Orientation::UNKNOWN}; void *rotate_buf_{}; display::DisplayRotation rotation_{display::DISPLAY_ROTATION_0_DEGREES}; RotationType rotation_type_; diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 13214d459d..dd4f71a346 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -55,6 +55,7 @@ from .layout import ( GRID_CELL_SCHEMA, append_layout_schema, grid_alignments, + layout_validator, ) from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr @@ -523,6 +524,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): ) ), cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + cv.Optional(df.CONF_LAYOUT): layout_validator, } ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 4d62c3de05..968db46adc 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -36,11 +36,10 @@ from ..defines import ( CONF_SCALE, CONF_STYLES, CONF_WIDGETS, + LOGGER, OBJ_FLAGS, PARTS, STATES, - TYPE_FLEX, - TYPE_GRID, LValidator, add_lv_use, call_lambda, @@ -541,44 +540,76 @@ def _size_to_str(value): return str(value) +def _grid_descriptor_array(name: str, specs) -> MockObj: + """Generate a file-scope ``static const`` grid row/column descriptor array + and return a reference to it.""" + values = ",".join(_size_to_str(x) for x in specs) + initializer = "{" + values + ", LV_GRID_TEMPLATE_LAST}" + arr_id = ID(name, is_declaration=True, type=lv_coord_t) + return cg.static_const_array(arr_id, cg.RawExpression(initializer)) + + +def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None: + """Apply the layout options present in ``layout`` to ``w``. + + Only options actually present are applied, so this works both for widget + creation (where every option is supplied) and for update actions (where the + layout ``type`` and grid structure are fixed and only the style options are + changed). ``base_name`` names the generated grid descriptor arrays and is + only required at creation, when ``grid_rows``/``grid_columns`` are present. + """ + if (pad_row := layout.get(CONF_PAD_ROW)) is not None: + w.set_style(CONF_PAD_ROW, pad_row) + if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None: + w.set_style(CONF_PAD_COLUMN, pad_column) + if (rows := layout.get(CONF_GRID_ROWS)) is not None: + w.set_style( + "grid_row_dsc_array", _grid_descriptor_array(f"{base_name}_row_dsc", rows) + ) + if (columns := layout.get(CONF_GRID_COLUMNS)) is not None: + w.set_style( + "grid_column_dsc_array", + _grid_descriptor_array(f"{base_name}_column_dsc", columns), + ) + if (align := layout.get(CONF_GRID_COLUMN_ALIGN)) is not None: + w.set_style(CONF_GRID_COLUMN_ALIGN, literal(align)) + if (align := layout.get(CONF_GRID_ROW_ALIGN)) is not None: + w.set_style(CONF_GRID_ROW_ALIGN, literal(align)) + if (flow := layout.get(CONF_FLEX_FLOW)) is not None: + lv_obj.set_flex_flow(w.obj, literal(flow)) + if (main := layout.get(CONF_FLEX_ALIGN_MAIN)) is not None: + w.set_style("flex_main_place", literal(main)) + if (cross := layout.get(CONF_FLEX_ALIGN_CROSS)) is not None: + # Stretch is implemented at creation time by sizing the children; at + # runtime we can only fall back to centering. + if cross == "LV_FLEX_ALIGN_STRETCH": + LOGGER.warning( + "Flex cross alignment 'stretch' is not supported at runtime; using 'center' instead" + ) + cross = "LV_FLEX_ALIGN_CENTER" + w.set_style("flex_cross_place", literal(cross)) + if (track := layout.get(CONF_FLEX_ALIGN_TRACK)) is not None: + w.set_style("flex_track_place", literal(track)) + + async def set_obj_properties(w: Widget, config): """Generate a list of C++ statements to apply properties to an lv_obj_t""" from ..schemas import ALL_STYLES, OBJ_PROPERTIES, remap_property if layout := config.get(CONF_LAYOUT): - layout_type: str = layout[CONF_TYPE] - add_lv_use(layout_type) - lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}")) - if (pad_row := layout.get(CONF_PAD_ROW)) is not None: - w.set_style(CONF_PAD_ROW, pad_row) - if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None: - w.set_style(CONF_PAD_COLUMN, pad_column) - if layout_type == TYPE_GRID: - wid = config[CONF_ID] - rows = [_size_to_str(x) for x in layout[CONF_GRID_ROWS]] - rows = "{" + ",".join(rows) + ", LV_GRID_TEMPLATE_LAST}" - row_id = ID(f"{wid}_row_dsc", is_declaration=True, type=lv_coord_t) - row_array = cg.static_const_array(row_id, cg.RawExpression(rows)) - w.set_style("grid_row_dsc_array", row_array) - columns = [_size_to_str(x) for x in layout[CONF_GRID_COLUMNS]] - columns = "{" + ",".join(columns) + ", LV_GRID_TEMPLATE_LAST}" - column_id = ID(f"{wid}_column_dsc", is_declaration=True, type=lv_coord_t) - column_array = cg.static_const_array(column_id, cg.RawExpression(columns)) - w.set_style("grid_column_dsc_array", column_array) - w.set_style( - CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN)) - ) - w.set_style(CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN))) - if layout_type == TYPE_FLEX: - lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW])) - main = literal(layout[CONF_FLEX_ALIGN_MAIN]) - cross = layout[CONF_FLEX_ALIGN_CROSS] - if cross == "LV_FLEX_ALIGN_STRETCH": - cross = "LV_FLEX_ALIGN_CENTER" - cross = literal(cross) - track = literal(layout[CONF_FLEX_ALIGN_TRACK]) - lv_obj.set_flex_align(w.obj, main, cross, track) + # The layout `type` (and the grid row/column structure) is only present + # when a widget is created; update actions only change the layout style + # options, leaving the type and grid structure unchanged. + layout_type = layout.get(CONF_TYPE) + if layout_type is not None: + add_lv_use(layout_type) + lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}")) + # The widget's own id gives the grid descriptor arrays stable names. + base_name = str(config[CONF_ID]) + else: + base_name = None + _set_layout_options(w, layout, base_name) parts = collect_parts(config) for part, states in parts.items(): part = "LV_PART_" + part.upper() diff --git a/tests/component_tests/lvgl/config/layout_update_test.yaml b/tests/component_tests/lvgl/config/layout_update_test.yaml new file mode 100644 index 0000000000..84765a60cf --- /dev/null +++ b/tests/component_tests/lvgl/config/layout_update_test.yaml @@ -0,0 +1,92 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + id: lvgl_id + displays: tft_display + pages: + - id: main_page + widgets: + # A flex container whose layout options are changed at runtime. + - obj: + id: flex_box + layout: + type: flex + flex_flow: row + widgets: + - label: + text: a + - label: + text: b + + # A grid container whose alignment options are changed at runtime. + # The grid structure (rows/columns) is fixed here at creation. + - obj: + id: grid_box + layout: + type: grid + grid_rows: [content, content] + grid_columns: [fr(1), fr(1)] + widgets: + - label: + text: c + - label: + text: d + + # Button hosting all of the update actions under test. + - button: + id: btn_actions + on_click: + # Update flex container options (type unchanged). + - lvgl.widget.update: + id: flex_box + layout: + flex_flow: column + flex_align_main: center + flex_align_cross: end + pad_row: 7px + # Update grid container alignment options (structure unchanged). + - lvgl.widget.update: + id: grid_box + layout: + grid_column_align: space_between + grid_row_align: center + # Top-level layout applies to the active screen. + - lvgl.update: + layout: + flex_flow: column + pad_column: 5px + # Layout applied to the top display layer. + - lvgl.update: + top_layer: + layout: + flex_flow: row + # Styling applied to the bottom display layer (exercises the + # layers code path that previously generated no code). + - lvgl.update: + bottom_layer: + bg_color: 0x123456 diff --git a/tests/component_tests/lvgl/test_layout_update.py b/tests/component_tests/lvgl/test_layout_update.py new file mode 100644 index 0000000000..b9730df379 --- /dev/null +++ b/tests/component_tests/lvgl/test_layout_update.py @@ -0,0 +1,208 @@ +"""Tests for updating LVGL layout options via the update actions. + +The ``lvgl.update`` and ``lvgl.widget.update`` (and per-widget +``lvgl..update``) actions can change a container's layout *options* at +runtime. The layout ``type`` and the grid ``grid_rows``/``grid_columns`` +structure are fixed at widget creation (they determine the cells/options +available to child widgets), so only the simple style options - those applied +via ``lv_obj_set_style_...`` calls - may be changed. + +These tests cover both the ``layout_validator`` (schema/normalisation) and the +generated C++ for each target: a widget, the active screen (top-level +``lvgl.update``) and the display layers. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from voluptuous import Invalid + +from esphome.__main__ import generate_cpp_contents +from esphome.components.lvgl.defines import TYPE_FLEX, TYPE_GRID, get_lv_uses +from esphome.components.lvgl.layout import layout_validator +from esphome.config import read_config +from esphome.core import CORE + +# --------------------------------------------------------------------------- +# layout_validator - schema and normalisation +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value,expected", + [ + ({"flex_flow": "row"}, {"flex_flow": "LV_FLEX_FLOW_ROW"}), + ({"flex_align_main": "center"}, {"flex_align_main": "LV_FLEX_ALIGN_CENTER"}), + ({"flex_align_cross": "end"}, {"flex_align_cross": "LV_FLEX_ALIGN_END"}), + ( + {"grid_column_align": "space_between"}, + {"grid_column_align": "LV_GRID_ALIGN_SPACE_BETWEEN"}, + ), + ({"grid_row_align": "center"}, {"grid_row_align": "LV_GRID_ALIGN_CENTER"}), + ({"pad_row": "7px"}, {"pad_row": 7}), + ({"pad_column": "5px"}, {"pad_column": 5}), + ], +) +def test_layout_validator_normalises_options(value: dict, expected: dict) -> None: + """Each supported option is accepted and normalised to its LVGL form.""" + assert layout_validator(value) == expected + + +def test_layout_validator_accepts_multiple_options() -> None: + """Several options may be combined in one update.""" + result = layout_validator( + {"flex_flow": "column", "flex_align_main": "center", "pad_row": "4px"} + ) + assert result == { + "flex_flow": "LV_FLEX_FLOW_COLUMN", + "flex_align_main": "LV_FLEX_ALIGN_CENTER", + "pad_row": 4, + } + + +@pytest.mark.parametrize( + "value", + [ + {"type": "flex"}, + {"type": "grid", "grid_column_align": "center"}, + {"grid_rows": 3}, + {"grid_columns": ["fr(1)"]}, + {"grid_rows": [1, 2], "flex_flow": "row"}, + ], +) +def test_layout_validator_rejects_structural_keys(value: dict) -> None: + """The layout type and grid structure are fixed at creation and must not + be changeable via an update action.""" + with pytest.raises(Invalid, match="extra keys not allowed"): + layout_validator(value) + + +def test_layout_validator_rejects_empty() -> None: + """An update must specify at least one layout option.""" + with pytest.raises(Invalid, match="at least one layout option"): + layout_validator({}) + + +def test_layout_validator_registers_flex_use() -> None: + """Validating a flex option registers the flex feature so LV_USE_FLEX is + emitted even when the option is set solely via an update action.""" + layout_validator({"flex_flow": "row"}) + assert TYPE_FLEX in get_lv_uses() + + +def test_layout_validator_registers_grid_use() -> None: + """Validating a grid option registers the grid feature.""" + layout_validator({"grid_column_align": "center"}) + assert TYPE_GRID in get_lv_uses() + + +def test_pad_only_update_registers_no_layout_use() -> None: + """Padding options belong to both layout types, so they alone do not force + either feature on.""" + layout_validator({"pad_row": "4px"}) + uses = get_lv_uses() + assert TYPE_FLEX not in uses + assert TYPE_GRID not in uses + + +# --------------------------------------------------------------------------- +# Generated C++ for the update actions +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared layout-update YAML config once + per module (codegen is relatively expensive).""" + config_path = Path(request.fspath).parent / "config" / "layout_update_test.yaml" + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_global_section + CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_widget_flex_update_applies_partial_options(main_cpp: str) -> None: + """``lvgl.widget.update`` changes only the flex options that are specified, + via the appropriate ``lv_obj_set_style_...``/``lv_obj_set_flex_flow`` + calls on the target widget.""" + assert "lv_obj_set_flex_flow(flex_box, LV_FLEX_FLOW_COLUMN)" in main_cpp + assert ( + "lv_obj_set_style_flex_main_place(flex_box, LV_FLEX_ALIGN_CENTER, LV_STATE_DEFAULT)" + in main_cpp + ) + assert ( + "lv_obj_set_style_flex_cross_place(flex_box, LV_FLEX_ALIGN_END, LV_STATE_DEFAULT)" + in main_cpp + ) + assert "lv_obj_set_style_pad_row(flex_box, 7, LV_STATE_DEFAULT)" in main_cpp + + +def test_widget_flex_update_does_not_change_type(main_cpp: str) -> None: + """The update must not re-establish the layout type: ``lv_obj_set_layout`` + is emitted once (at creation) and never from the update action.""" + assert main_cpp.count("lv_obj_set_layout(flex_box,") == 1 + + +def test_widget_flex_update_is_partial(main_cpp: str) -> None: + """An option that was not specified in the update (the track placement) is + only set at creation, not by the partial update.""" + assert main_cpp.count("lv_obj_set_style_flex_track_place(flex_box,") == 1 + + +def test_widget_grid_update_applies_alignments(main_cpp: str) -> None: + """``lvgl.widget.update`` on a grid container changes its alignment + options without touching the grid structure.""" + assert ( + "lv_obj_set_style_grid_column_align(grid_box, LV_GRID_ALIGN_SPACE_BETWEEN, " + "LV_STATE_DEFAULT)" in main_cpp + ) + assert ( + "lv_obj_set_style_grid_row_align(grid_box, LV_GRID_ALIGN_CENTER, LV_STATE_DEFAULT)" + in main_cpp + ) + + +def test_grid_update_does_not_regenerate_descriptor_arrays(main_cpp: str) -> None: + """The grid row/column descriptor arrays are structural and generated once + at creation; an update must not regenerate them.""" + assert main_cpp.count("grid_box_row_dsc") != 0 + # The descriptor array is declared once and referenced once at creation. + assert main_cpp.count("grid_box_row_dsc") == main_cpp.count("grid_box_column_dsc") + assert "lv_obj_set_layout(grid_box," in main_cpp + assert main_cpp.count("lv_obj_set_layout(grid_box,") == 1 + + +def test_top_level_layout_targets_active_screen(main_cpp: str) -> None: + """A top-level ``lvgl.update: { layout: ... }`` applies to the active + screen, not to the LVGL component object.""" + assert ( + "lv_obj_set_flex_flow(lvgl_id->get_screen_active(), LV_FLEX_FLOW_COLUMN)" + in main_cpp + ) + assert ( + "lv_obj_set_style_pad_column(lvgl_id->get_screen_active(), 5, LV_STATE_DEFAULT)" + in main_cpp + ) + + +def test_top_layer_layout_applied(main_cpp: str) -> None: + """A layout under ``top_layer`` is applied to the display's top layer.""" + assert "lv_display_get_layer_top(lvgl_id->get_disp())" in main_cpp + assert "lv_obj_set_flex_flow(top_layer_VAR_, LV_FLEX_FLOW_ROW)" in main_cpp + + +def test_bottom_layer_styling_applied(main_cpp: str) -> None: + """A ``bottom_layer`` style update generates code (previously the layer + keys of ``lvgl.update`` were silently ignored).""" + assert "lv_display_get_layer_bottom(lvgl_id->get_disp())" in main_cpp + assert ( + "lv_obj_set_style_bg_color(bottom_layer_VAR_, lv_color_make(18, 52, 86), " + "LV_PART_MAIN)" in main_cpp + ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d6cd3821f9..f085b62cb6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -46,6 +46,40 @@ lvgl: - lvgl.display.set_rotation: rotation: 0 lvgl_id: lvgl_id + - lvgl.display.set_rotation: + rotation: !lambda "return 180;" + lvgl_id: lvgl_id + on_landscape: + - logger.log: LVGL display is now landscape + # Re-layout a container in response to orientation changes. The layout type + # and grid structure are fixed at creation; only the style options change. + - lvgl.widget.update: + id: grid_rows_only_shorthand + layout: + grid_column_align: center + grid_row_align: space_between + pad_row: 4px + - lvgl.update: + top_layer: + layout: + flex_flow: row + on_portrait: + - logger.log: LVGL display is now portrait + - lvgl.widget.update: + id: grid_rows_only_shorthand + layout: + grid_column_align: start + pad_row: 2px + # Top-level layout applies to the active screen + - lvgl.update: + layout: + flex_flow: column + pad_row: 8px + - lvgl.update: + top_layer: + layout: + flex_flow: column + flex_align_main: center on_boot: - logger.log: LVGL has started From 7c130fc9706da963904d170cefaf035e7d301ac4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:58:40 +1200 Subject: [PATCH 2046/2214] [core] Hide build & framework internals from the visual editor (#17449) --- esphome/components/esp32/__init__.py | 28 +++++++++----- esphome/components/esp8266/__init__.py | 8 +++- esphome/components/libretiny/__init__.py | 5 ++- esphome/components/nrf52/__init__.py | 4 +- esphome/components/rp2/__init__.py | 8 +++- esphome/core/config.py | 46 +++++++++++++++++------ tests/component_tests/esp32/test_esp32.py | 34 +++++++++++++++++ tests/unit_tests/core/test_config.py | 31 +++++++++++++++ 8 files changed, 136 insertions(+), 28 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e8d1fe73c7..7c926fe28e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1578,16 +1578,20 @@ FRAMEWORK_SCHEMA = cv.Schema( { cv.Optional(CONF_TYPE): cv.one_of(FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO), cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_RELEASE): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_pio_platform_version, - cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { - cv.string_strict: cv.string_strict - }, + cv.Optional(CONF_RELEASE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict, + cv.Optional(CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_pio_platform_version, + cv.Optional( + CONF_SDKCONFIG_OPTIONS, default={}, visibility=cv.Visibility.YAML_ONLY + ): {cv.string_strict: cv.string_strict}, cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( *LOG_LEVELS_IDF, upper=True ), - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + cv.Optional( + CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY + ): cv.Schema( { cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( *ASSERTION_LEVELS, upper=True @@ -1677,7 +1681,9 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), - cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( + cv.Optional( + CONF_COMPONENTS, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list( cv.All( cv.Any( cv.All(cv.string_strict, _parse_idf_component), @@ -1777,7 +1783,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( *FLASH_FREQUENCIES, upper=True ), - cv.Optional(CONF_PARTITIONS): cv.Any( + cv.Optional(CONF_PARTITIONS, visibility=cv.Visibility.YAML_ONLY): cv.Any( cv.file_, cv.ensure_list( cv.All( @@ -1801,7 +1807,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA, - cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, + cv.Optional( + CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED + ): _validate_toolchain, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All( cv.positive_time_period_seconds, cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)), diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index ab742db065..0e0e2f77d7 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -202,8 +202,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_platform_version, } ), _arduino_check_versions, diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 079bb32aab..3fde11b1eb 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -257,7 +257,10 @@ FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, + # Raw PlatformIO package source — build internal, not a UI field. + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, cv.Optional(CONF_LOGLEVEL, default="warn"): ( cv.one_of(*LT_LOGLEVELS, upper=True) ), diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 692b2637b2..8d522a8740 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -250,7 +250,9 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_VERSION): cv.string_strict, cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean, - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + cv.Optional( + CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY + ): cv.Schema( { cv.Optional( CONF_ENABLE_OTA_ROLLBACK, default=True diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 21a885a7cf..fad9d3d25b 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -244,8 +244,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_platform_version, } ), _arduino_check_versions, diff --git a/esphome/core/config.py b/esphome/core/config.py index 5b95ac3a50..6b24a55487 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -284,14 +284,24 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_COMMENT): cv.All( cv.string, cv.ByteLength(max=COMMENT_MAX_LEN) ), - cv.Required(CONF_BUILD_PATH): cv.string, - cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema( + cv.Required(CONF_BUILD_PATH, visibility=cv.Visibility.YAML_ONLY): cv.string, + cv.Optional( + CONF_PLATFORMIO_OPTIONS, + default={}, + visibility=cv.Visibility.YAML_ONLY, + ): cv.Schema( { cv.string_strict: cv.Any([cv.string], cv.string), } ), - cv.Optional(CONF_BUILD_FLAGS, default=[]): cv.ensure_list(cv.string_strict), - cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema( + cv.Optional( + CONF_BUILD_FLAGS, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(cv.string_strict), + cv.Optional( + CONF_ENVIRONMENT_VARIABLES, + default={}, + visibility=cv.Visibility.YAML_ONLY, + ): cv.Schema( { cv.string_strict: cv.string, } @@ -313,12 +323,20 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LoopTrigger), } ), - cv.Optional(CONF_INCLUDES, default=[]): cv.ensure_list(valid_include), - cv.Optional(CONF_INCLUDES_C, default=[]): cv.ensure_list(valid_include), - cv.Optional(CONF_LIBRARIES, default=[]): cv.ensure_list(cv.string_strict), + cv.Optional( + CONF_INCLUDES, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(valid_include), + cv.Optional( + CONF_INCLUDES_C, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(valid_include), + cv.Optional( + CONF_LIBRARIES, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(cv.string_strict), cv.Optional(CONF_NAME_ADD_MAC_SUFFIX, default=False): cv.boolean, cv.Optional(CONF_MERGE_WARNINGS, default=True): cv.boolean, - cv.Optional(CONF_DEBUG_SCHEDULER, default=False): cv.boolean, + cv.Optional( + CONF_DEBUG_SCHEDULER, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.boolean, cv.Optional(CONF_PROJECT): cv.Schema( { cv.Required(CONF_NAME): cv.All( @@ -338,11 +356,15 @@ CONFIG_SCHEMA = cv.All( ), } ), - cv.Optional(CONF_MIN_VERSION, default=ESPHOME_VERSION): cv.All( - cv.version_number, cv.validate_esphome_version - ), cv.Optional( - CONF_COMPILE_PROCESS_LIMIT, default=_compile_process_limit_default + CONF_MIN_VERSION, + default=ESPHOME_VERSION, + visibility=cv.Visibility.ADVANCED, + ): cv.All(cv.version_number, cv.validate_esphome_version), + cv.Optional( + CONF_COMPILE_PROCESS_LIMIT, + default=_compile_process_limit_default, + visibility=cv.Visibility.ADVANCED, ): cv.int_range(min=1, max=get_usable_cpu_count()), cv.Optional(CONF_AREAS, default=[]): cv.ensure_list(AREA_SCHEMA), cv.Optional(CONF_DEVICES, default=[]): cv.ensure_list(DEVICE_SCHEMA), diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index d53e119e9f..dd8881e46f 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -601,6 +601,40 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig +def test_esp32_build_internals_are_yaml_only() -> None: + """ESP32 raw framework / build inputs are ``YAML_ONLY``. + + The framework block's PlatformIO package pins, raw ESP-IDF + sdkconfig options, the low-level ``advanced`` block, extra IDF + component sources, plus the partition table and toolchain override + on the main schema are build internals — never UI form fields. + User-facing choices (framework type/version, board, variant, …) + stay on the main form. + """ + from esphome.components.esp32 import CONFIG_SCHEMA, FRAMEWORK_SCHEMA + + fw_markers = {str(k): k for k in FRAMEWORK_SCHEMA.schema} + for field in ( + "release", + "source", + "platform_version", + "sdkconfig_options", + "advanced", + "components", + ): + assert fw_markers[field].visibility is cv.Visibility.YAML_ONLY, field + # Framework type/version remain user-facing. + assert fw_markers["type"].visibility is None + assert fw_markers["version"].visibility is None + + main_markers = {str(k): k for k in CONFIG_SCHEMA.validators[0].schema} + assert main_markers["partitions"].visibility is cv.Visibility.YAML_ONLY + # toolchain is a real but rarely-touched override -> advanced disclosure. + assert main_markers["toolchain"].visibility is cv.Visibility.ADVANCED + assert main_markers["board"].visibility is None + assert main_markers["flash_size"].visibility is None + + def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None: assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == [] diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index b3d87f6857..6fd9f4c22c 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1307,3 +1307,34 @@ async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: mock_cg.add_library.assert_any_call( "noise-c", None, "https://github.com/esphome/noise-c.git" ) + + +def test_esphome_build_internals_are_yaml_only() -> None: + """Raw build-system inputs in the ``esphome:`` block are ``YAML_ONLY``. + + These knobs (compiler flags, raw PlatformIO options, C/C++ includes, + libraries, build host parallelism, the min-version gate, …) are not + meaningful as visual-editor form fields and a wrong value breaks the + build, so they must never render in a schema-aware UI. + """ + # CONFIG_SCHEMA is cv.All(cv.Schema({...}), validate_hostname). + inner = config.CONFIG_SCHEMA.validators[0].schema + markers = {str(k): k for k in inner} + yaml_only_fields = { + CONF_BUILD_PATH, + "platformio_options", + "build_flags", + "environment_variables", + "includes", + "includes_c", + "libraries", + "debug_scheduler", + } + for field in yaml_only_fields: + assert markers[field].visibility is cv.Visibility.YAML_ONLY, field + # Packaging / build-host knobs are real but rarely-touched overrides: + # surface them under the editor's advanced disclosure, not yaml-only. + for field in ("min_version", "compile_process_limit"): + assert markers[field].visibility is cv.Visibility.ADVANCED, field + # A regular device-config field stays on the main form. + assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None From 8ccf0dbd37f0febd2bc990465a11d2af5bbfb9e2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:04:43 +1000 Subject: [PATCH 2047/2214] [gsl3670] Add new touchscreen component (#16285) --- CODEOWNERS | 1 + esphome/components/gsl3670/__init__.py | 1 + .../gsl3670/gsl3670_touchscreen.cpp | 167 +++++++++++ .../components/gsl3670/gsl3670_touchscreen.h | 50 ++++ esphome/components/gsl3670/touchscreen.py | 209 ++++++++++++++ esphome/components/touchscreen/__init__.py | 89 ++++-- tests/component_tests/gsl3670/__init__.py | 0 tests/component_tests/gsl3670/test_init.py | 260 ++++++++++++++++++ .../components/gsl3670/test.esp32-s3-idf.yaml | 28 ++ 9 files changed, 780 insertions(+), 25 deletions(-) create mode 100644 esphome/components/gsl3670/__init__.py create mode 100644 esphome/components/gsl3670/gsl3670_touchscreen.cpp create mode 100644 esphome/components/gsl3670/gsl3670_touchscreen.h create mode 100644 esphome/components/gsl3670/touchscreen.py create mode 100644 tests/component_tests/gsl3670/__init__.py create mode 100644 tests/component_tests/gsl3670/test_init.py create mode 100644 tests/components/gsl3670/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 619fc14087..0f43cd9749 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -209,6 +209,7 @@ esphome/components/gree/switch/* @nagyrobi esphome/components/grove_gas_mc_v2/* @YorkshireIoT esphome/components/grove_tb6612fng/* @max246 esphome/components/growatt_solar/* @leeuwte +esphome/components/gsl3670/* @clydebarrow esphome/components/gt911/* @clydebarrow @jesserockz esphome/components/haier/* @paveldn esphome/components/haier/binary_sensor/* @paveldn diff --git a/esphome/components/gsl3670/__init__.py b/esphome/components/gsl3670/__init__.py new file mode 100644 index 0000000000..c58ce8a01e --- /dev/null +++ b/esphome/components/gsl3670/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/gsl3670/gsl3670_touchscreen.cpp b/esphome/components/gsl3670/gsl3670_touchscreen.cpp new file mode 100644 index 0000000000..9115130f4a --- /dev/null +++ b/esphome/components/gsl3670/gsl3670_touchscreen.cpp @@ -0,0 +1,167 @@ +#include "gsl3670_touchscreen.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::gsl3670 { + +static const char *const TAG = "gsl3670.touchscreen"; +static const size_t MAX_TOUCHES = 3; +// --------------------------------------------------------------------------- +// setup() – mirrors esp_lcd_touch_gsl3670_init() in the Seeed BSP: +// clear_reg → reset → load_fw → startup_chip → reset → startup_chip +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up GSL3670 touchscreen..."); + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } + + this->clear_reg_(); + this->reset_(); + this->load_firmware_(); + this->startup_chip_(); + this->reset_(); + this->startup_chip_(); + + ESP_LOGCONFIG(TAG, "GSL3670 initialised OK"); +} + +void GSL3670Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "GSL3670 Touchscreen:\n" + " X-raw-max: %d\n" + " Y-raw-max: %d\n", + this->x_raw_max_, this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + ESP_LOGCONFIG(TAG, " Firmware records: %zu", this->firmware_len_); +} + +// --------------------------------------------------------------------------- +// update_touches() – mirrors esp_lcd_touch_gsl3670_read_data() in Seeed BSP +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::update_touches() { + uint8_t buf[44] = {}; + auto err = this->read_register(0x80, buf, sizeof(buf)); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C read failed (%d)", err); + return; + } + uint8_t finger_num = clamp_at_most(buf[0], MAX_TOUCHES); + + // Build gsl_touch_info exactly as the Seeed driver does + for (uint8_t j = 0; j != finger_num; j++) { + // buf[(j+1)*4 + 0..3]: byte0=y_lo, byte1=y_hi, byte2=x_lo, byte3=id|x_hi + auto x = (uint16_t) (((buf[(j + 1) * 4 + 3] & 0x0f) << 8) | buf[(j + 1) * 4 + 2]); + auto y = (uint16_t) ((buf[(j + 1) * 4 + 1] << 8) | buf[(j + 1) * 4 + 0]); + auto id = (buf[(j + 1) * 4 + 3] >> 4) & 0x0f; + ESP_LOGV(TAG, "Touch id=%u, x=%u y=%u", id, x, y); + if (x <= 8192 && y <= 8192) + this->add_raw_touch_position_(id, x, y); + } +} + +// --------------------------------------------------------------------------- +// clear_reg_() – mirrors esp_lcd_touch_gsl3670_clear_reg() +// GPIO reset → write 0x01 to 0x88 → write 0x04 to 0xe4 → write 0x00 to 0xe0 +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::clear_reg_() { + ESP_LOGD(TAG, "clear_reg"); + + // GPIO reset pulse + if (this->reset_pin_ != nullptr) { + this->reset_pin_->digital_write(false); + delay(1); + this->reset_pin_->digital_write(true); + delay(5); + } + + this->write_reg8_(0x88, 0x01); + // delay(5); + this->write_reg8_(0xe4, 0x04); + // delay(5); + this->write_reg8_(0xe0, 0x00); + // delay(5); +} + +// --------------------------------------------------------------------------- +// reset_() – mirrors touch_gsl3670_reset() +// GPIO reset → write 0x04 to 0xe4 → write 4×0x00 to 0xbc +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::reset_() { + ESP_LOGD(TAG, "reset"); + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->digital_write(false); + delay(1); + this->reset_pin_->digital_write(true); + delay(5); + } + + this->write_reg8_(0xe4, 0x04); + + uint8_t zeros[4] = {0, 0, 0, 0}; + this->write_reg_(0xbc, zeros, 4); +} + +void GSL3670Touchscreen::load_firmware_() { + if (firmware_ == nullptr || firmware_len_ == 0) { + ESP_LOGW(TAG, "No firmware supplied – skipping"); + return; + } + + ESP_LOGD(TAG, "Loading firmware (%zu blocks)...", firmware_len_); + + static constexpr size_t FW_BLK_SIZE = 128 + 4; + + for (size_t i = 0; i != this->firmware_len_; i++) { + auto offset = i * FW_BLK_SIZE; + uint8_t val = this->firmware_[offset + 0]; + ESP_LOGV(TAG, "Firmware address 0x%02X", val); + this->write_reg_(0xf0, &val, 1); + this->write_reg_(0, this->firmware_ + offset + 4, 128); + } + ESP_LOGD(TAG, "Firmware load complete"); +} + +// --------------------------------------------------------------------------- +// startup_chip_() – mirrors esp_lcd_touch_gsl3670_startup_chip() +// write 0x00 to 0xe0 +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::startup_chip_() { + ESP_LOGD(TAG, "startup_chip"); + this->write_reg8_(0xe0, 0x00); + delay(5); +} + +// --------------------------------------------------------------------------- +// I2C helpers +// --------------------------------------------------------------------------- + +bool GSL3670Touchscreen::write_reg_(uint8_t reg, const uint8_t *data, size_t len) { + auto err = this->write_register(reg, data, len); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C write reg 0x%02X len %zu failed (%d)", reg, len, err); + return false; + } + return true; +} + +bool GSL3670Touchscreen::write_reg8_(uint8_t reg, uint8_t val) { return write_reg_(reg, &val, 1); } + +} // namespace esphome::gsl3670 diff --git a/esphome/components/gsl3670/gsl3670_touchscreen.h b/esphome/components/gsl3670/gsl3670_touchscreen.h new file mode 100644 index 0000000000..3cce074f9b --- /dev/null +++ b/esphome/components/gsl3670/gsl3670_touchscreen.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::gsl3670 { + +// --------------------------------------------------------------------------- +// GSL3670 touchscreen ESPHome component +// --------------------------------------------------------------------------- +class GSL3670Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + /// Supply the firmware table (generated by codegen from the YAML) + void set_firmware(const uint8_t *fw, size_t len) { + this->firmware_ = fw; + this->firmware_len_ = len; + } + + void set_interrupt_pin(InternalGPIOPin *pin) { interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { reset_pin_ = pin; } + + // touchscreen::Touchscreen / Component interface + void setup() override; + void dump_config() override; + + protected: + void update_touches() override; + + private: + // ---------- init steps (mirrors esp_lcd_touch_gsl3670_init) ---------- + void clear_reg_(); // GPIO reset + 0x88/0xe4/0xe0 sequence + void reset_(); // GPIO reset + 0xe4/0xbc sequence + void load_firmware_(); // write GSLX670_FW table + void startup_chip_(); // 0x00→0xe0 + gsl_DataInit + + // ---------- I2C helpers ---------- + bool write_reg_(uint8_t reg, const uint8_t *data, size_t len); + bool write_reg8_(uint8_t reg, uint8_t val); + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + + const uint8_t *firmware_{nullptr}; + size_t firmware_len_{0}; +}; + +} // namespace esphome::gsl3670 diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py new file mode 100644 index 0000000000..11bb24ce44 --- /dev/null +++ b/esphome/components/gsl3670/touchscreen.py @@ -0,0 +1,209 @@ +"""ESPHome codegen for the gsl3670 touchscreen sub-platform.""" + +import hashlib +import logging +from pathlib import Path + +from esphome import external_files, pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +from esphome.components.const import CONF_SHA256 +from esphome.components.touchscreen import ( + CONF_X_MAX, + CONF_X_MIN, + CONF_Y_MAX, + CONF_Y_MIN, + option_with_default, + touchscreen_schema, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_ID, + CONF_INTERRUPT_PIN, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_MODEL, + CONF_RESET_PIN, + CONF_SWAP_XY, + CONF_URL, +) +from esphome.core import ID + +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["touchscreen"] +LOGGER = logging.getLogger(__name__) + +DOMAIN = "gsl3670" + +gsl3670_ns = cg.esphome_ns.namespace("gsl3670") +GSL3670Touchscreen = gsl3670_ns.class_( + "GSL3670Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONF_FIRMWARE = "firmware" + +# Firmware blobs are published as release assets of the companion repository +# rather than vendored into the ESPHome source tree. The default URL/SHA-256 +# for each model point at a pinned release artifact; users may override them +# (or supply a local file via `firmware: { file: ... }`). +FIRMWARE_RELEASE = "v1.0.0" +FIRMWARE_BASE_URL = f"https://github.com/esphome-libs/gsl3670-firmware/releases/download/{FIRMWARE_RELEASE}" + +MODELS = { + "SEEED-RETERMINAL-D1001": { + CONF_SWAP_XY: True, + CONF_MIRROR_X: True, + CONF_MIRROR_Y: True, + CONF_X_MIN: 20, + CONF_Y_MIN: 20, + CONF_X_MAX: 872, + CONF_Y_MAX: 1644, + CONF_RESET_PIN: {"xl9535": None, "number": 14}, + CONF_INTERRUPT_PIN: 16, + CONF_FIRMWARE: { + CONF_URL: f"{FIRMWARE_BASE_URL}/seeed-d1001-fw.bin", + CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4", + }, + }, + "CUSTOM": {}, +} + +_FW_BLK_SIZE = 128 + 4 + + +def _validate_firmware_data(data: bytes, source: str) -> None: + """Validate the structure of a decoded GSL3670 firmware blob.""" + blk_cnt = len(data) // _FW_BLK_SIZE + if blk_cnt == 0 or blk_cnt * _FW_BLK_SIZE != len(data): + raise cv.Invalid(f"Firmware file length is incorrect: {source}") + for i in range(0, len(data), _FW_BLK_SIZE): + if data[i] > 0xEF or data[i + 1] != 1 or data[i + 2] != 2 or data[i + 3] != 3: + raise cv.Invalid( + f"Corrupted firmware at block {i // _FW_BLK_SIZE} in: {source}" + ) + + +def _cache_path(url: str) -> Path: + """Cache path for a downloaded firmware blob, keyed by URL.""" + key = hashlib.sha256(url.encode()).hexdigest()[:8] + return external_files.compute_local_file_dir(DOMAIN) / key + + +def firmware_path(firmware: dict) -> Path: + """Return the path the firmware bytes will be read from at codegen time.""" + if path := firmware.get(CONF_FILE): + return path + return _cache_path(firmware[CONF_URL]) + + +def _validate_firmware(firmware: dict) -> dict: + """Require a single source, download (with caching), verify and validate.""" + if (CONF_FILE in firmware) == (CONF_URL in firmware): + raise cv.Invalid( + f"Exactly one of '{CONF_URL}' or '{CONF_FILE}' must be provided" + ) + + if path := firmware.get(CONF_FILE): + _validate_firmware_data(path.read_bytes(), str(path.absolute())) + return firmware + + url = firmware[CONF_URL] + data = external_files.download_content(url, _cache_path(url)) + + if expected := firmware.get(CONF_SHA256): + actual = hashlib.sha256(data).hexdigest() + if actual.lower() != expected.lower(): + raise cv.Invalid( + f"Firmware SHA-256 mismatch for {url}: " + f"expected {expected.lower()}, got {actual}", + [CONF_SHA256], + ) + else: + LOGGER.warning( + "No SHA256 provided for gsl3670 firmware - firmware integrity can not be checked" + ) + _validate_firmware_data(data, url) + return firmware + + +FIRMWARE_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_URL): cv.url, + cv.Optional(CONF_SHA256): cv.string_strict, + cv.Optional(CONF_FILE): cv.file_, + } + ), + _validate_firmware, +) + + +def _config_schema(config): + model_option = { + cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True) + } + config = cv.Schema(model_option, extra=True)(config) + defaults = MODELS[config[CONF_MODEL]] + schema = ( + touchscreen_schema(cv.UNDEFINED, False, defaults) + .extend( + { + cv.GenerateID(): cv.declare_id(GSL3670Touchscreen), + option_with_default( + CONF_INTERRUPT_PIN, defaults + ): pins.internal_gpio_input_pin_schema, + option_with_default( + CONF_RESET_PIN, defaults + ): pins.gpio_output_pin_schema, + **model_option, + option_with_default( + CONF_FIRMWARE, defaults, required=True + ): FIRMWARE_SCHEMA, + } + ) + .extend(i2c.i2c_device_schema(0x40)) + .extend(cv.COMPONENT_SCHEMA) + ) + return schema(config) + + +CONFIG_SCHEMA = _config_schema + + +def _read_firmware(config) -> bytes: + path = firmware_path(config[CONF_FIRMWARE]) + data = path.read_bytes() + LOGGER.info( + "Read gsl3670 touchscreen firmware file %s: %d bytes, %d blocks", + path.absolute(), + len(data), + len(data) // _FW_BLK_SIZE, + ) + return data + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if CONF_INTERRUPT_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) + cg.add(var.set_interrupt_pin(pin)) + + if CONF_RESET_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) + cg.add(var.set_reset_pin(pin)) + + # Firmware table + data = _read_firmware(config) + fw_array = cg.progmem_array( + ID(config[CONF_ID].id + "_fw", type=cg.uint8), list(data) + ) + cg.add(var.set_firmware(fw_array, len(data) // _FW_BLK_SIZE)) diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index 4a5c03ace4..cf0c5fca19 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -60,40 +60,79 @@ def validate_calibration(calibration_config): return calibration_config -CALIBRATION_SCHEMA = cv.All( - cv.Schema( - { - cv.Required(CONF_X_MIN): cv.int_range(min=0, max=4095), - cv.Required(CONF_X_MAX): cv.int_range(min=0, max=4095), - cv.Required(CONF_Y_MIN): cv.int_range(min=0, max=4095), - cv.Required(CONF_Y_MAX): cv.int_range(min=0, max=4095), - } - ), - validate_calibration, -) +def option_with_default(option: str, defaults: dict, required: bool = False): + if option in defaults or not required: + return cv.Optional(option, default=defaults.get(option, cv.UNDEFINED)) + return cv.Required(option) -def touchscreen_schema(default_touch_timeout=cv.UNDEFINED, calibration_required=False): - calibration = ( - cv.Required(CONF_CALIBRATION) - if calibration_required - else cv.Optional(CONF_CALIBRATION) - ) +_CALIBRATION_KEYS = {CONF_X_MIN, CONF_X_MAX, CONF_Y_MIN, CONF_Y_MAX} +_TRANSFORM_KEYS = {CONF_SWAP_XY, CONF_MIRROR_X, CONF_MIRROR_Y} + + +def _calibration_schema(defaults: dict, required: bool) -> dict: + """ + Generate Calibration schema. If defaults are provided for all suboptions, + the entire calibration config is optional with a populated default value. + Otherwise, it's optional or required as specified. + """ + if _CALIBRATION_KEYS.issubset(defaults): + key = cv.Optional( + CONF_CALIBRATION, + default={k: v for k, v in defaults.items() if k in _CALIBRATION_KEYS}, + ) + elif required: + key = cv.Required(CONF_CALIBRATION) + else: + key = cv.Optional(CONF_CALIBRATION) + return { + key: cv.All( + cv.Schema( + { + option_with_default(x, defaults, True): cv.int_range( + min=0, max=4095 + ) + for x in _CALIBRATION_KEYS + } + ), + validate_calibration, + ) + } + + +def _transform_schema(defaults: dict) -> dict: + if _TRANSFORM_KEYS.issubset(defaults): + key = cv.Optional( + CONF_TRANSFORM, + default={k: v for k, v in defaults.items() if k in _TRANSFORM_KEYS}, + ) + else: + key = cv.Optional(CONF_TRANSFORM) + return { + key: cv.Schema( + { + cv.Optional(x, default=defaults.get(x, False)): cv.boolean + for x in _TRANSFORM_KEYS + } + ) + } + + +def touchscreen_schema( + default_touch_timeout=cv.UNDEFINED, + calibration_required=False, + defaults: dict = None, +) -> cv.Schema: + defaults = defaults or {} return cv.Schema( { cv.GenerateID(CONF_DISPLAY): cv.use_id(display.Display), - cv.Optional(CONF_TRANSFORM): cv.Schema( - { - cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, - cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, - cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, - } - ), cv.Optional(CONF_TOUCH_TIMEOUT, default=default_touch_timeout): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), - calibration: CALIBRATION_SCHEMA, + **_transform_schema(defaults), + **_calibration_schema(defaults, calibration_required), cv.Optional(CONF_ON_TOUCH): automation.validate_automation(single=True), cv.Optional(CONF_ON_UPDATE): automation.validate_automation(single=True), cv.Optional(CONF_ON_RELEASE): automation.validate_automation(single=True), diff --git a/tests/component_tests/gsl3670/__init__.py b/tests/component_tests/gsl3670/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py new file mode 100644 index 0000000000..3778cf8aa5 --- /dev/null +++ b/tests/component_tests/gsl3670/test_init.py @@ -0,0 +1,260 @@ +"""Tests for the gsl3670 touchscreen configuration validation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gsl3670 import touchscreen as gsl +from esphome.const import ( + CONF_CALIBRATION, + CONF_INTERRUPT_PIN, + CONF_MODEL, + CONF_RESET_PIN, + CONF_TRANSFORM, + PlatformFramework, +) +from tests.component_tests.types import SetCoreConfigCallable + +VALID_URL = "https://example.com/fw.bin" + + +def _make_firmware(blocks: int = 2) -> bytes: + """Build a structurally valid firmware blob with ``blocks`` blocks. + + Each block is ``_FW_BLK_SIZE`` bytes: a 4-byte header (page address <= 0xEF + followed by the 1/2/3 marker bytes) and a 128-byte payload. + """ + out = bytearray() + for i in range(blocks): + out += bytes([i, 1, 2, 3]) + bytes(gsl._FW_BLK_SIZE - 4) + return bytes(out) + + +def _write_firmware(tmp_path: Path, data: bytes | None = None) -> Path: + """Write firmware bytes to a temp file and return its path.""" + path = tmp_path / "fw.bin" + path.write_bytes(_make_firmware() if data is None else data) + return path + + +# --------------------------------------------------------------------------- +# _validate_firmware_data - blob structure +# --------------------------------------------------------------------------- + + +def test_validate_firmware_data_accepts_valid_blob() -> None: + """A correctly structured blob passes validation.""" + gsl._validate_firmware_data(_make_firmware(3), "test") + + +@pytest.mark.parametrize("length", [0, gsl._FW_BLK_SIZE - 1, gsl._FW_BLK_SIZE + 1]) +def test_validate_firmware_data_rejects_bad_length(length: int) -> None: + """The blob length must be a non-zero multiple of the block size.""" + with pytest.raises(cv.Invalid, match="length is incorrect"): + gsl._validate_firmware_data(bytes(length), "test") + + +@pytest.mark.parametrize( + "index,value", + [ + (0, 0xF0), # page address must be <= 0xEF + (1, 0x00), # marker byte must be 1 + (2, 0x00), # marker byte must be 2 + (3, 0x00), # marker byte must be 3 + ], +) +def test_validate_firmware_data_rejects_corrupted_header( + index: int, value: int +) -> None: + """A block whose header bytes are wrong is reported as corrupted.""" + data = bytearray(_make_firmware(2)) + # Corrupt the header of the second block. + data[gsl._FW_BLK_SIZE + index] = value + with pytest.raises(cv.Invalid, match="Corrupted firmware at block 1"): + gsl._validate_firmware_data(bytes(data), "test") + + +# --------------------------------------------------------------------------- +# _cache_path / firmware_path +# --------------------------------------------------------------------------- + + +def test_cache_path_is_deterministic_per_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The cache path is derived from (and stable for) the URL.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + first = gsl._cache_path(VALID_URL) + assert first == gsl._cache_path(VALID_URL) + assert first != gsl._cache_path("https://example.com/other.bin") + assert first.parent == tmp_path + + +def test_firmware_path_prefers_local_file(tmp_path: Path) -> None: + """A ``file`` source is returned as-is, without consulting the cache.""" + path = _write_firmware(tmp_path) + assert gsl.firmware_path({"file": path}) == path + + +def test_firmware_path_uses_cache_for_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A ``url`` source resolves to the cache path for that URL.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL) + + +# --------------------------------------------------------------------------- +# _validate_firmware / FIRMWARE_SCHEMA +# --------------------------------------------------------------------------- + + +def test_firmware_requires_exactly_one_source(tmp_path: Path) -> None: + """Supplying both, or neither, of url/file is an error.""" + path = _write_firmware(tmp_path) + with pytest.raises(cv.Invalid, match="Exactly one"): + gsl._validate_firmware({"url": VALID_URL, "file": path}) + with pytest.raises(cv.Invalid, match="Exactly one"): + gsl._validate_firmware({}) + + +def test_firmware_file_valid(tmp_path: Path) -> None: + """A valid firmware file passes the full FIRMWARE_SCHEMA.""" + path = _write_firmware(tmp_path) + result = gsl.FIRMWARE_SCHEMA({"file": str(path)}) + assert result["file"] == path + + +def test_firmware_file_corrupt_rejected(tmp_path: Path) -> None: + """A file whose contents fail the structural check is rejected.""" + path = _write_firmware(tmp_path, data=b"\x00" * (gsl._FW_BLK_SIZE * 2)) + with pytest.raises(cv.Invalid, match="Corrupted firmware"): + gsl._validate_firmware({"file": path}) + + +def test_firmware_url_downloads_and_validates( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A url source downloads the content and validates its structure.""" + data = _make_firmware() + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) + assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL} + + +def test_firmware_url_sha256_mismatch_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A configured SHA-256 that does not match the download is rejected.""" + data = _make_firmware() + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) + with pytest.raises(cv.Invalid, match="SHA-256 mismatch"): + gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32}) + + +def test_firmware_url_invalid_structure_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Downloaded content that is not a valid blob is rejected.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr( + gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02" + ) + with pytest.raises(cv.Invalid, match="length is incorrect"): + gsl._validate_firmware({"url": VALID_URL}) + + +# --------------------------------------------------------------------------- +# CONFIG_SCHEMA +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _esp32_core(set_core_config: SetCoreConfigCallable) -> None: + """Configure the core as an ESP32 target for the schema tests.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_config_custom_model_minimal(tmp_path: Path) -> None: + """The CUSTOM model validates with an explicit firmware file and pins.""" + fw = _write_firmware(tmp_path) + result = gsl.CONFIG_SCHEMA( + { + "model": "custom", + "interrupt_pin": 16, + "reset_pin": 4, + "firmware": {"file": str(fw)}, + } + ) + assert result[CONF_MODEL] == "CUSTOM" + assert "id" in result + # The CUSTOM model supplies no transform/calibration defaults. + assert CONF_TRANSFORM not in result + assert CONF_CALIBRATION not in result + + +def test_config_custom_model_requires_firmware() -> None: + """The firmware option is required for the CUSTOM model (no default).""" + with pytest.raises(cv.Invalid, match=r"required key not provided.*firmware"): + gsl.CONFIG_SCHEMA({"model": "custom", "interrupt_pin": 16, "reset_pin": 4}) + + +def test_config_invalid_model_rejected() -> None: + """An unknown model name is rejected.""" + with pytest.raises(cv.Invalid, match="model"): + gsl.CONFIG_SCHEMA({"model": "nonexistent"}) + + +def test_config_seeed_model_applies_defaults(tmp_path: Path) -> None: + """The SEEED model populates transform and calibration defaults. + + ``reset_pin`` is overridden with a plain GPIO so the test does not depend on + the model's default IO-expander pin. + """ + fw = _write_firmware(tmp_path) + result = gsl.CONFIG_SCHEMA( + { + "model": "seeed-reterminal-d1001", + "reset_pin": 4, + "firmware": {"file": str(fw)}, + } + ) + assert result[CONF_MODEL] == "SEEED-RETERMINAL-D1001" + # Transform defaults from the model. + assert result[CONF_TRANSFORM] == { + "swap_xy": True, + "mirror_x": True, + "mirror_y": True, + } + # Calibration defaults from the model. + assert result[CONF_CALIBRATION]["x_min"] == 20 + assert result[CONF_CALIBRATION]["x_max"] == 872 + assert result[CONF_CALIBRATION]["y_min"] == 20 + assert result[CONF_CALIBRATION]["y_max"] == 1644 + # The interrupt pin default (16) is applied without being specified. + assert CONF_INTERRUPT_PIN in result + assert CONF_RESET_PIN in result + + +def test_config_rejects_non_dict() -> None: + """A non-dict configuration is rejected.""" + with pytest.raises(cv.Invalid, match="expected a dictionary"): + gsl.CONFIG_SCHEMA("not a dict") diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..2565d57f13 --- /dev/null +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -0,0 +1,28 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +xl9535: + id: expander + +display: + - platform: mipi_spi + spi_id: spi_bus + model: t-display-s3-pro + +psram: + mode: quad + +touchscreen: + # Firmware downloaded from the model's default release URL and cached. + - platform: gsl3670 + model: seeed-reterminal-d1001 + interrupt_pin: 18 + # Explicit firmware URL + SHA-256 override. + - platform: gsl3670 + model: seeed-reterminal-d1001 + reset_pin: 10 + interrupt_pin: 11 + firmware: + url: https://github.com/esphome-libs/gsl3670-firmware/releases/download/v1.0.0/seeed-d1001-fw.bin + sha256: 2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4 From 0512dd23392e403f19ea1fbc89053b517adb64d0 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:52:09 -0500 Subject: [PATCH 2048/2214] Bump bundled esphome-device-builder to 1.0.24 (#17332) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0c3b27a04d..3ecdd50008 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.24 RUN \ platformio settings set enable_telemetry No \ From 42ddf0870c9777d1a9e1d1e1a1ba37032931198a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:02:01 -0500 Subject: [PATCH 2049/2214] Bump bundled esphome-device-builder to 1.0.25 (#17333) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 3ecdd50008..a7e9717c68 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.24 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.25 RUN \ platformio settings set enable_telemetry No \ From 7b92fe95af99e308caf03bb4d4be7bd51666fea1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:48:08 -0500 Subject: [PATCH 2050/2214] Bump bundled esphome-device-builder to 1.0.26 (#17369) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a7e9717c68..7cf0a3ceb6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.25 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.26 RUN \ platformio settings set enable_telemetry No \ From ca7f50f37f85df0f2d37b299a2dd16673a6f5a9a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:31:21 -0400 Subject: [PATCH 2051/2214] Bump bundled esphome-device-builder to 1.0.27 (#17370) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7cf0a3ceb6..ce2edf31cb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.26 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27 RUN \ platformio settings set enable_telemetry No \ From a1f819e9b840d2a8a27036f386e8fa3bbea3a127 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:17:49 -0500 Subject: [PATCH 2052/2214] Bump bundled esphome-device-builder to 1.0.28 (#17382) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ce2edf31cb..683cf33cd4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.28 RUN \ platformio settings set enable_telemetry No \ From 263b3750886a5e22fba6c1496c293daa9a719d16 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:20:48 -0500 Subject: [PATCH 2053/2214] Bump bundled esphome-device-builder to 1.0.29 (#17384) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 683cf33cd4..08f8ab9931 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.28 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.29 RUN \ platformio settings set enable_telemetry No \ From f6221f000790d310076c44def6d9b6f51cf6fe50 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:31:07 -0500 Subject: [PATCH 2054/2214] Bump bundled esphome-device-builder to 1.1.0 (#17412) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 08f8ab9931..d0f2f4d1a1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.29 +RUN uv pip install --no-cache-dir esphome-device-builder==1.1.0 RUN \ platformio settings set enable_telemetry No \ From 98b79b132af0d1cc34bf6ef3049358e31e64b3dc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:09:05 +1200 Subject: [PATCH 2055/2214] Bump version to 2026.6.5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index e38f280006..c5cae055e1 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.4 +PROJECT_NUMBER = 2026.6.5 # 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 81bde6dfa2..7c7f0d0d5f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.4" +__version__ = "2026.6.5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From f1622ac96a68af1cd077e41f31f5b64b5450a924 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:30:53 +1200 Subject: [PATCH 2056/2214] Bump version to 2026.7.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 9f4e20b977..6f8b6e6664 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0-dev +PROJECT_NUMBER = 2026.7.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 988134fa46..faa716bdd7 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0-dev" +__version__ = "2026.7.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0a8a7e22d299fd0db8e2cf8fc18d3f01f98c24d0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:30:54 +1200 Subject: [PATCH 2057/2214] Bump version to 2026.8.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 9f4e20b977..3bb08e5b06 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0-dev +PROJECT_NUMBER = 2026.8.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 988134fa46..9dfa5cb835 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0-dev" +__version__ = "2026.8.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From acc8381cbb26402b19d3ba77a222e8cd8550f029 Mon Sep 17 00:00:00 2001 From: Elvin Luff Date: Thu, 9 Jul 2026 03:29:39 +0200 Subject: [PATCH 2058/2214] [epaper_spi] Remove noop deep sleep command (#15595) --- esphome/components/epaper_spi/epaper_spi_mono.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index ee117304c4..fffb2b5e84 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -14,10 +14,9 @@ void EPaperMono::refresh_screen(bool partial) { } void EPaperMono::deep_sleep() { - ESP_LOGV(TAG, "Deep sleep"); - if (this->is_using_partial_update_()) { - this->cmd_data(0x10, {0x00}); // sleep in power on mode - } else { + // Deep sleep loses RAM so cannot be used with partial update + if (!this->is_using_partial_update_()) { + ESP_LOGV(TAG, "Deep sleep"); this->cmd_data(0x10, {0x03}); // deep sleep } } From 9c92ab63fbc0bf3a324a2b276bf9d96bf59d3723 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:43:56 -0400 Subject: [PATCH 2059/2214] [gsl3670] Reference the test display explicitly so grouped CI builds validate (#17462) --- tests/components/gsl3670/test.esp32-s3-idf.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 2565d57f13..48bb9982d9 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -7,6 +7,7 @@ xl9535: display: - platform: mipi_spi + id: gsl3670_display spi_id: spi_bus model: t-display-s3-pro @@ -17,10 +18,12 @@ touchscreen: # Firmware downloaded from the model's default release URL and cached. - platform: gsl3670 model: seeed-reterminal-d1001 + display: gsl3670_display interrupt_pin: 18 # Explicit firmware URL + SHA-256 override. - platform: gsl3670 model: seeed-reterminal-d1001 + display: gsl3670_display reset_pin: 10 interrupt_pin: 11 firmware: From 19e89aa7f222e74256f5630f607935720e668552 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:43:56 -0400 Subject: [PATCH 2060/2214] [gsl3670] Reference the test display explicitly so grouped CI builds validate (#17462) --- tests/components/gsl3670/test.esp32-s3-idf.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 2565d57f13..48bb9982d9 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -7,6 +7,7 @@ xl9535: display: - platform: mipi_spi + id: gsl3670_display spi_id: spi_bus model: t-display-s3-pro @@ -17,10 +18,12 @@ touchscreen: # Firmware downloaded from the model's default release URL and cached. - platform: gsl3670 model: seeed-reterminal-d1001 + display: gsl3670_display interrupt_pin: 18 # Explicit firmware URL + SHA-256 override. - platform: gsl3670 model: seeed-reterminal-d1001 + display: gsl3670_display reset_pin: 10 interrupt_pin: 11 firmware: From 435dde67d09f34b5bd33d047eb6983d2fe653a04 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:29:23 -0400 Subject: [PATCH 2061/2214] [ci] Stop per-PR cache copies from crowding the 10GB Actions cache quota (#17463) --- .github/actions/restore-python/action.yml | 3 ++ .github/workflows/ci-api-proto.yml | 3 ++ .github/workflows/ci.yml | 65 ++++++++++++++++++++--- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 9d78b2d843..9d6dc5301c 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -35,6 +35,9 @@ runs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index ebbe720463..58fc83e3f5 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -32,6 +32,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull-request-only workflow: a save could never be shared and + # would only consume quota. + save-cache: "false" # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e93b6ece8..adf98478fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -174,6 +177,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -375,6 +381,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -828,11 +837,12 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 - with: - packages: libsdl2-dev ccache - version: 1.1 + - 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: Check out code from GitHub uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -1006,6 +1016,36 @@ jobs: # Arduino framework via PlatformIO (only components with an esp32-ard test are built): python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio + pre-commit-seed-cache: + name: Seed pre-commit cache + runs-on: ubuntu-latest + needs: + - common + # Saves a dev-scoped pre-commit cache that pull request runs can + # restore, since pre-commit.ci lite itself never runs on dev pushes. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + steps: + - name: Check out code from GitHub + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache pre-commit environments + id: cache-pre-commit + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the restore key in pre-commit-ci-lite + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Install pre-commit hook environments + if: steps.cache-pre-commit.outputs.cache-hit != 'true' + run: | + python -m pip install pre-commit + pre-commit install-hooks + pre-commit-ci-lite: name: pre-commit.ci lite runs-on: ubuntu-latest @@ -1021,9 +1061,22 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache + # Inlined from esphome/pre-commit-action with a restore-only cache + # step: the pre-commit-seed-cache job owns saving this cache, so + # pull request runs never write per-PR copies. + - name: Restore pre-commit cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the key pre-commit-seed-cache saves + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Run pre-commit env: SKIP: pylint,ci-custom + run: | + python -m pip install pre-commit + pre-commit run --show-diff-on-failure --color=always --all-files - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() From 9f21fd0b55a9da2673a1b46d709cde1c6b196180 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:04:42 -0400 Subject: [PATCH 2062/2214] [usb_uart] Fix output chunk length truncated to 8 bits (#17480) --- esphome/components/usb_uart/usb_uart.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 482b209a3f..c289625f1a 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -160,7 +160,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { } uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE); memcpy(chunk->data, data, chunk_len); - chunk->length = static_cast(chunk_len); + chunk->length = chunk_len; // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. this->output_queue_.push(chunk); From 4292e7988e578bf011082e75458b398684b2b3c1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:27:05 -0400 Subject: [PATCH 2063/2214] [mcp4461] Fix wiper increment/decrement write length (#17487) --- esphome/components/mcp4461/mcp4461.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 4573553664..e83a6847d6 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -342,7 +342,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::INCREMENT); - auto err = this->write(&this->address_, reg); + auto err = this->write(®, 1); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); @@ -373,7 +373,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::DECREMENT); - auto err = this->write(&this->address_, reg); + auto err = this->write(®, 1); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); From 4b19de0c1a85ba86197e3775a8e6c9fdf6865aba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:35:16 +0000 Subject: [PATCH 2064/2214] Bump CodSpeedHQ/action from 4.18.4 to 4.18.5 (#17489) 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 adf98478fd..4e98999741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -465,7 +465,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4 + uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5 with: run: | . venv/bin/activate From ba84f2ec552a9994a968a0d6ab5d9ff5789386ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:53:42 +0000 Subject: [PATCH 2065/2214] Bump aioesphomeapi from 45.5.2 to 45.6.0 (#17490) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8b028554a8..b36e70ef5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.5.2 +aioesphomeapi==45.6.0 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From b2226b91ff0a28ad299972686d2108eaf82fad5b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:51:04 -0400 Subject: [PATCH 2066/2214] [web_server] Serialize entity state strings without a copy buffer (#17488) --- esphome/components/web_server/web_server.cpp | 52 +++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index c8f66755bc..3f4d598d48 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -56,9 +56,8 @@ namespace esphome::web_server { static const char *const TAG = "web_server"; -// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up) -static constexpr size_t PSTR_LOCAL_SIZE = 18; -#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1) +// View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. +static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): @@ -578,9 +577,9 @@ static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix root[ESPHOME_F("value")] = value; } -template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const char *state, - const T &value, JsonDetail start_config) { +template +static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, + JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; } @@ -1073,8 +1072,7 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - char buf[PSTR_LOCAL_SIZE]; - root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root[ESPHOME_F("position")] = obj->position; @@ -1530,17 +1528,16 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json const auto traits = obj->get_traits(); int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); - char buf[PSTR_LOCAL_SIZE]; char temp_buf[VALUE_ACCURACY_MAX_LEN]; if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) - opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); + opt.add(json_state_str(climate::climate_mode_to_string(m))); if (traits.get_supports_fan_modes()) { JsonArray opt = root[ESPHOME_F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) - opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); + opt.add(json_state_str(climate::climate_fan_mode_to_string(m))); } if (!traits.get_supported_custom_fan_modes().empty()) { @@ -1551,12 +1548,12 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json if (traits.get_supports_swing_modes()) { JsonArray opt = root[ESPHOME_F("swing_modes")].to(); for (auto swing_mode : traits.get_supported_swing_modes()) - opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); + opt.add(json_state_str(climate::climate_swing_mode_to_string(swing_mode))); } if (traits.get_supports_presets()) { JsonArray opt = root[ESPHOME_F("presets")].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) - opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); + opt.add(json_state_str(climate::climate_preset_to_string(m))); } if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); @@ -1572,26 +1569,26 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json } bool has_state = false; - root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root[ESPHOME_F("mode")] = json_state_str(climate_mode_to_string(obj->mode)); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { - root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root[ESPHOME_F("action")] = json_state_str(climate_action_to_string(obj->action)); root[ESPHOME_F("state")] = root[ESPHOME_F("action")]; has_state = true; } if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); } if (traits.get_supports_presets() && obj->preset.has_value()) { - root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); } if (traits.get_supports_swing_modes()) { - root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode)); } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { root[ESPHOME_F("current_temperature")] = @@ -1695,8 +1692,7 @@ json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockSta json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config); + set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1777,8 +1773,7 @@ json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - char buf[PSTR_LOCAL_SIZE]; - root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root[ESPHOME_F("position")] = obj->position; @@ -1863,9 +1858,8 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)), - value, start_config); + set_json_icon_state_value(root, obj, "alarm-control-panel", + json_state_str(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1937,10 +1931,9 @@ json::SerializationBuffer<> WebServer::water_heater_all_json_generator(WebServer json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) { json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; const auto mode = obj->get_mode(); - const char *mode_s = PSTR_LOCAL(water_heater::water_heater_mode_to_string(mode)); + ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode)); set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); @@ -1949,7 +1942,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea if (start_config == DETAIL_ALL) { JsonArray modes = root[ESPHOME_F("modes")].to(); for (auto m : traits.get_supported_modes()) - modes.add(PSTR_LOCAL(water_heater::water_heater_mode_to_string(m))); + modes.add(json_state_str(water_heater::water_heater_mode_to_string(m))); root[ESPHOME_F("min_temp")] = traits.get_min_temperature(); root[ESPHOME_F("max_temp")] = traits.get_max_temperature(); root[ESPHOME_F("step")] = traits.get_target_temperature_step(); @@ -2277,8 +2270,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)), + set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), obj->update_info.latest_version, start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; From 7a1e0bbbeca958a4ecddcd0d3a816e40c34480a0 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:36:42 -1000 Subject: [PATCH 2067/2214] Bump bundled esphome-device-builder to 1.4.0 (#17495) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index db2e01742c..e0b44fb7b6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.3.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.0 RUN \ platformio settings set enable_telemetry No \ From 88ca0d44e0b0cc7d6c91b2d638ebc1ee773438e2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:38:23 +1200 Subject: [PATCH 2068/2214] [docs] Document web server as an open HTTP API by design in threat model (#17465) --- THREAT_MODEL.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index a4355a5055..24a7fed4f2 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -79,6 +79,44 @@ These *are* security bugs in this repo, and we want to hear about them privately - Flaws that weaken the device's API encryption (Noise), OTA, or web server auth below their documented guarantees. +## The web server is an open HTTP API by design + +The `web_server` component exposes a plain HTTP interface for viewing and +controlling entities, and, when the `web_server` OTA platform is enabled, for +uploading firmware at `/update`. Its only access controls are the optional +`web_server` `auth:` credentials and the network the device sits on. + +When `auth:` is not configured, every endpoint is reachable by any client that +can reach the device. This is intentional; enabling `web_server` without `auth:` +is choosing an open control surface, in the same way that running native OTA +without a password leaves OTA open. The API is documented and is meant to be +called by other devices, scripts, and pages. + +The device performs no CSRF token, `Origin`, or `Referer` validation and returns +a permissive CORS policy. Cross-origin requests are handled the same as any other +network request, including requests a browser is induced to make by a page the +operator visits (the "confused deputy", or CSRF, pattern). The following are +therefore **not** vulnerabilities in this repository: + +- Cross-origin or CSRF requests to the control endpoints (for example, a page the + operator opens toggling a switch), whether or not `web_server` `auth:` is set. +- Cross-origin reads of device state permitted by the CORS policy. +- Cross-origin firmware upload through the web OTA endpoint (`/update`) when web + OTA is enabled without `web_server` `auth:`. This is the same exposure as + running OTA without a password. + +The supported defenses are `web_server` `auth:`, protecting OTA (a web password or +a native OTA password), and keeping devices on a trusted, segmented network. See +the security best practices guide linked above. + +What remains in scope is bypassing `web_server` `auth:` when it *is* configured, +and any memory-safety or protocol bug in the server reachable without credentials. + +This section documents the current design and scope; it is not a judgment that the +design is optimal or that it will not change. Optional hardening (for example an +origin allowlist or opt-in CSRF checks) is welcome as a normal enhancement PR, +framed as defense-in-depth rather than a security fix. + ## Explicitly out of scope - Local attackers who already have shell access on the host that runs `esphome`. @@ -86,6 +124,9 @@ These *are* security bugs in this repo, and we want to hear about them privately - Operator-supplied hostile YAML (covered above — config authoring is trusted). - Attacks that require an already-authenticated device peer (someone who already holds the API key / OTA / web credentials). +- Cross-site (CSRF), cross-origin, or CORS behavior of the device web server and + its web OTA endpoint. The web server is an open HTTP API by design (see above); + gate it with `web_server` `auth:` and network isolation. - Anything in the dashboard / device-builder — report that in its own repository (linked at the top). - Deployments where the operator removed protections or exposed credentials. See From 83aaed71e1fb8b5d33fad76f4fc7ca0cbb6aaf65 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:30:19 -1000 Subject: [PATCH 2069/2214] Bump bundled esphome-device-builder to 1.4.1 (#17507) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e0b44fb7b6..e7f8fceb12 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.4.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.1 RUN \ platformio settings set enable_telemetry No \ From e1719cd85d74f12fde4e3e0d2c99aaedea70b33e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:06:40 +1000 Subject: [PATCH 2070/2214] [mipi][mipi_spi][mipi_dsi][mipi_rgb] Transform cleanup (#17405) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 73 ++++++++-- esphome/components/mipi_dsi/display.py | 22 +-- .../components/mipi_dsi/models/__init__.py | 14 ++ esphome/components/mipi_dsi/models/guition.py | 12 +- esphome/components/mipi_dsi/models/m5stack.py | 9 +- esphome/components/mipi_dsi/models/seeed.py | 7 +- .../components/mipi_dsi/models/waveshare.py | 22 +-- esphome/components/mipi_rgb/display.py | 31 ++-- .../components/mipi_rgb/models/__init__.py | 14 ++ esphome/components/mipi_rgb/models/guition.py | 1 + esphome/components/mipi_rgb/models/lilygo.py | 6 +- esphome/components/mipi_rgb/models/rpi.py | 6 +- esphome/components/mipi_rgb/models/st7701s.py | 18 +-- esphome/components/mipi_rgb/models/sunton.py | 8 +- .../components/mipi_rgb/models/waveshare.py | 10 +- esphome/components/mipi_spi/display.py | 15 +- .../components/mipi_spi/models/adafruit.py | 2 + esphome/components/mipi_spi/models/amoled.py | 7 +- esphome/components/mipi_spi/models/ili.py | 3 + esphome/components/mipi_spi/models/jc.py | 14 +- esphome/components/mipi_spi/models/lanbon.py | 1 + esphome/components/mipi_spi/models/lilygo.py | 3 + esphome/components/mipi_spi/models/m5stack.py | 2 + .../components/mipi_spi/models/waveshare.py | 9 +- esphome/core/__init__.py | 4 + .../config/animation_platform_test.yaml | 3 + .../animation/config/animation_test.yaml | 3 + .../image/config/image_test.yaml | 3 + .../mipi_dsi/test_mipi_dsi_config.py | 39 +++++ tests/component_tests/mipi_rgb/__init__.py | 0 tests/component_tests/mipi_rgb/test_init.py | 89 ++++++++++++ .../mipi_rgb/test_mipi_rgb_config.py | 137 ++++++++++++++++++ .../mipi_spi/test_display_metadata.py | 84 ++++++++++- .../mipi_spi/test_final_validate.py | 77 ++++++++++ tests/component_tests/mipi_spi/test_init.py | 2 +- .../mipi_spi/test_padding_and_offsets.py | 4 + .../config/online_image_platform_test.yaml | 3 + .../config/online_image_test.yaml | 3 + 38 files changed, 630 insertions(+), 130 deletions(-) create mode 100644 esphome/components/mipi_rgb/models/__init__.py create mode 100644 tests/component_tests/mipi_rgb/__init__.py create mode 100644 tests/component_tests/mipi_rgb/test_init.py create mode 100644 tests/component_tests/mipi_rgb/test_mipi_rgb_config.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 1d6c8277e8..ab59d5ce5f 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -31,11 +31,16 @@ from esphome.const import ( CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.core import TimePeriod +from esphome.core import CORE, TimePeriod from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor LOGGER = cv.logging.getLogger(__name__) +CONF_TRANSFORMS = "transforms" + +# All axis transforms a model may support, in the order they appear in the schema. +ALL_TRANSFORMS = (CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY) + ColorOrder = display_ns.enum("ColorMode") NOP = 0x00 @@ -302,7 +307,8 @@ class DriverChip: """ A class representing a MIPI DBI driver chip model. The parameters supplied as defaults will be used to provide default values for the display configuration. - Setting swap_xy to cv.UNDEFINED will indicate that the model does not support swapping X and Y axes. + Pass a ``transforms`` set to restrict which axis transforms (mirror_x, mirror_y, swap_xy) the model + supports; by default all three are available. """ models: dict[str, Self] = {} @@ -387,11 +393,15 @@ class DriverChip: """ Return the available transforms for this model. """ + if (transforms := self.get_default(CONF_TRANSFORMS, None)) is not None: + return transforms if self.get_default("no_transform", False): return set() if self.get_default(CONF_SWAP_XY) != cv.UNDEFINED: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} - return {CONF_MIRROR_X, CONF_MIRROR_Y} + raise ValueError( + "Setting 'swap_xy' to 'cv.UNDEFINED' is no longer supported; set 'transforms' instead" + ) def has_hardware_transform(self, config) -> bool: """ @@ -533,17 +543,31 @@ class DriverChip: transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform - def swap_xy_schema(self): - uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED + def transform_schema(self): + """ + Build the schema for the ``transform`` config option of this model. - def validator(value): - if value: - raise cv.Invalid("Axis swapping not supported by this model") - return cv.boolean(value) + Each transform the model supports is a required boolean. A transform the model does not + support may be omitted or set to ``false``; setting it to ``true`` reports a clear error + naming the unsupported transform instead of a generic "extra keys not allowed". + """ + supported = self.transforms - if uses_swap: - return {cv.Required(CONF_SWAP_XY): cv.boolean} - return {cv.Optional(CONF_SWAP_XY, default=False): validator} + def unsupported(name): + def validator(value): + if cv.boolean(value): + raise cv.Invalid(f"'{name}' is not supported by this model") + return False + + return validator + + schema = {} + for name in ALL_TRANSFORMS: + if name in supported: + schema[cv.Required(name)] = cv.boolean + else: + schema[cv.Optional(name, default=False)] = unsupported(name) + return cv.Any(cv.Schema(schema), cv.one_of(CONF_DISABLED, lower=True)) def get_madctl(self, transform: dict, config: dict) -> int: """ @@ -618,6 +642,31 @@ class DriverChip: # or the delay flag inserted where needed return flatten_sequence(sequence) + def check_requirements(self) -> None: + """ + Raise a friendly error if any component this model requires is not configured. + + This runs during schema validation (before ID references are resolved) so that a + model whose default pins live on a pin expander reports the missing expander clearly + instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + """ + requirements = self.get_default("requires", set()) + if not requirements: + return + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) + def requires_buffer(config) -> bool: """ diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 896140b4b1..e5bb3d413d 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -41,24 +41,21 @@ from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, CONF_COLOR_ORDER, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, - CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, ) from esphome.final_validate import full_config from . import mipi_dsi_ns, models +from .models import DsiDriverChip # Currently only ESP32-P4 is supported, so esp_ldo and psram are required DEPENDENCIES = ["esp32", "esp_ldo", "psram"] @@ -73,7 +70,7 @@ ColorBitness = display.display_ns.enum("ColorBitness") CONF_LANE_BIT_RATE = "lane_bit_rate" CONF_LANES = "lanes" -DriverChip("CUSTOM") +DsiDriverChip("CUSTOM") # Import all models dynamically from the models package @@ -90,19 +87,7 @@ COLOR_DEPTHS = { def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - model.defaults[CONF_SWAP_XY] = cv.UNDEFINED - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - cv.Optional(CONF_SWAP_XY): cv.invalid( - "Axis swapping not supported by DSI displays" - ), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) @@ -172,6 +157,7 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] + model.check_requirements() width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) ) diff --git a/esphome/components/mipi_dsi/models/__init__.py b/esphome/components/mipi_dsi/models/__init__.py index e69de29bb2..3f7f8370a3 100644 --- a/esphome/components/mipi_dsi/models/__init__.py +++ b/esphome/components/mipi_dsi/models/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.mipi import DriverChip +from esphome.const import CONF_SWAP_XY + + +class DsiDriverChip(DriverChip): + """A driver chip for MIPI DSI displays.""" + + @property + def transforms(self) -> set[str]: + """ + Return the set of transformations supported by this driver chip. + DSI displays do not support axis swapping, so this method removes CONF_SWAP_XY + """ + return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index db13c7f6cc..31a2b0ce1a 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -1,8 +1,7 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off -DriverChip( +DsiDriverChip( "JC1060P470", width=1024, height=600, @@ -14,7 +13,6 @@ DriverChip( vsync_front_porch=12, pclk_frequency="54MHz", lane_bit_rate="750Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00), @@ -46,7 +44,7 @@ DriverChip( # * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42) # * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166) # ---------------------------------------------------------------------------------------------------------------------- -DriverChip( +DsiDriverChip( "JC4880P443", width=480, height=800, @@ -58,7 +56,6 @@ DriverChip( vsync_front_porch=166, pclk_frequency="34MHz", lane_bit_rate="500Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=5, initsequence=[ @@ -111,7 +108,7 @@ DriverChip( # * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40) # * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=8, vsync_front_porch=20) # ---------------------------------------------------------------------------------------------------------------------- -DriverChip( +DsiDriverChip( "JC8012P4A1", width=800, height=1280, @@ -123,7 +120,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="60MHz", lane_bit_rate="1Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=27, initsequence=[ diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 53fac9b534..b947b9ac8a 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -1,8 +1,7 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off -DriverChip( +DsiDriverChip( "M5STACK-TAB5", height=1280, width=720, @@ -14,7 +13,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="60MHz", lane_bit_rate="730Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xFF, 0x98, 0x81, 0x01), # Select Page 1 @@ -56,7 +54,7 @@ DriverChip( ], ) -DriverChip( +DsiDriverChip( "M5STACK-TAB5-V2", height=1280, width=720, @@ -68,7 +66,6 @@ DriverChip( vsync_front_porch=220, pclk_frequency="80MHz", lane_bit_rate="960Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0x01,), diff --git a/esphome/components/mipi_dsi/models/seeed.py b/esphome/components/mipi_dsi/models/seeed.py index 290b0e07ee..84593b40e6 100644 --- a/esphome/components/mipi_dsi/models/seeed.py +++ b/esphome/components/mipi_dsi/models/seeed.py @@ -1,9 +1,8 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # Standalone display # Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html -DriverChip( +DsiDriverChip( "SEEED-RETERMINAL-D1001", height=1280, width=800, @@ -15,10 +14,10 @@ DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}], reset_pin={"xl9535": None, "number": 2}, + requires={"psram", "xl9535"}, initsequence=( (0xE0, 0x00), (0xE1, 0x93), diff --git a/esphome/components/mipi_dsi/models/waveshare.py b/esphome/components/mipi_dsi/models/waveshare.py index c97a0bbd02..a1702fb6a1 100644 --- a/esphome/components/mipi_dsi/models/waveshare.py +++ b/esphome/components/mipi_dsi/models/waveshare.py @@ -1,12 +1,11 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1 # Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage -JD9365_10_1_DSI_TOUCH_A = DriverChip( +JD9365_10_1_DSI_TOUCH_A = DsiDriverChip( "WAVESHARE-P4-NANO-10.1", height=1280, width=800, @@ -18,7 +17,6 @@ JD9365_10_1_DSI_TOUCH_A = DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -65,7 +63,7 @@ JD9365_10_1_DSI_TOUCH_A.extend( # Source for parameters and initsequence: # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703 # Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO -DriverChip( +DsiDriverChip( "WAVESHARE-P4-86-PANEL", height=720, width=720, @@ -77,7 +75,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="38MHz", lane_bit_rate="480Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=27, initsequence=[ @@ -109,7 +106,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B -DriverChip( +DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B", height=600, width=1024, @@ -139,7 +136,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C -JD9365_3_4_DSI_TOUCH_C = DriverChip( +JD9365_3_4_DSI_TOUCH_C = DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C", height=800, width=800, @@ -151,7 +148,6 @@ JD9365_3_4_DSI_TOUCH_C = DriverChip( vsync_front_porch=24, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -197,7 +193,7 @@ JD9365_3_4_DSI_TOUCH_C.extend( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C -JD9365_4_DSI_TOUCH_C = DriverChip( +JD9365_4_DSI_TOUCH_C = DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C", height=720, width=720, @@ -209,7 +205,6 @@ JD9365_4_DSI_TOUCH_C = DriverChip( vsync_front_porch=24, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -255,7 +250,7 @@ JD9365_4_DSI_TOUCH_C.extend( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A -DriverChip( +DsiDriverChip( "WAVESHARE-8-DSI-TOUCH-A", height=1280, width=800, @@ -267,7 +262,6 @@ DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -304,7 +298,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c # Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A -DriverChip( +DsiDriverChip( "WAVESHARE-7-DSI-TOUCH-A", height=1280, width=720, diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 1eacc31fc5..ebe930d37a 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -18,6 +18,8 @@ from esphome.components.mipi import ( CONF_HSYNC_BACK_PORCH, CONF_HSYNC_FRONT_PORCH, CONF_HSYNC_PULSE_WIDTH, + CONF_PCLK_FREQUENCY, + CONF_PCLK_INVERTED, CONF_PCLK_PIN, CONF_PIXEL_MODE, CONF_USE_AXIS_FLIPS, @@ -34,9 +36,11 @@ from esphome.components.mipi import ( power_of_two, requires_buffer, ) -from esphome.components.rpi_dpi_rgb.display import ( - CONF_PCLK_FREQUENCY, - CONF_PCLK_INVERTED, +from esphome.components.spi import ( + CONF_SPI_MODE, + SPI_DATA_RATE_SCHEMA, + SPI_MODE_OPTIONS, + SPIComponent, ) import esphome.config_validation as cv from esphome.const import ( @@ -48,7 +52,6 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_GREEN, CONF_HSYNC_PIN, @@ -57,8 +60,6 @@ from esphome.const import ( CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_NUMBER, CONF_RED, @@ -72,10 +73,10 @@ from esphome.const import ( ) from esphome.final_validate import full_config -from ..spi import CONF_SPI_MODE, SPI_DATA_RATE_SCHEMA, SPI_MODE_OPTIONS, SPIComponent from . import models +from .models import RgbDriverChip -DEPENDENCIES = ["esp32", "psram"] +DEPENDENCIES = ["esp32"] mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb") mipi_rgb = mipi_rgb_ns.class_("MipiRgb", display.Display, cg.Component) @@ -86,7 +87,7 @@ ColorOrder = display.display_ns.enum("ColorMode") DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -DriverChip("CUSTOM") +RgbDriverChip("CUSTOM") # Import all models dynamically from the models package @@ -120,16 +121,7 @@ def data_pin_set(length): def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **model.swap_xy_schema(), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # RPI model does not use an init sequence, indicates with empty list if model.initsequence is None: # Custom model requires an init sequence @@ -235,6 +227,7 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] + model.check_requirements() width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) ) diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py new file mode 100644 index 0000000000..9e3fe2a476 --- /dev/null +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.mipi import DriverChip +from esphome.const import CONF_SWAP_XY + + +class RgbDriverChip(DriverChip): + """A driver chip for MIPI RGB displays.""" + + @property + def transforms(self) -> set[str]: + """ + Return the set of transformations supported by this driver chip. + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + """ + return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_rgb/models/guition.py b/esphome/components/mipi_rgb/models/guition.py index 915b8beda0..c0aaf0a3d2 100644 --- a/esphome/components/mipi_rgb/models/guition.py +++ b/esphome/components/mipi_rgb/models/guition.py @@ -5,6 +5,7 @@ st7701s.extend( width=480, height=480, data_rate="2MHz", + requires={"psram"}, cs_pin=39, de_pin=18, hsync_pin=16, diff --git a/esphome/components/mipi_rgb/models/lilygo.py b/esphome/components/mipi_rgb/models/lilygo.py index c0e91cd8ae..4e0615b439 100644 --- a/esphome/components/mipi_rgb/models/lilygo.py +++ b/esphome/components/mipi_rgb/models/lilygo.py @@ -1,5 +1,3 @@ -from esphome.config_validation import UNDEFINED - from .st7701s import ST7701S # fmt: off @@ -8,10 +6,10 @@ ST7701S( width=480, height=480, invert_colors=False, - swap_xy=UNDEFINED, spi_mode="MODE3", cs_pin={"xl9535": None, "number": 17}, reset_pin={"xl9535": None, "number": 5}, + requires={"psram", "xl9535"}, hsync_pin=39, vsync_pin=40, pclk_pin=41, @@ -57,9 +55,9 @@ t_rgb = ST7701S( height=480, pixel_mode="18bit", invert_colors=False, - swap_xy=UNDEFINED, spi_mode="MODE3", cs_pin={"xl9535": None, "number": 3}, + requires={"psram", "xl9535"}, de_pin=45, hsync_pin=47, vsync_pin=41, diff --git a/esphome/components/mipi_rgb/models/rpi.py b/esphome/components/mipi_rgb/models/rpi.py index 076d96b658..1e2a6600ee 100644 --- a/esphome/components/mipi_rgb/models/rpi.py +++ b/esphome/components/mipi_rgb/models/rpi.py @@ -1,9 +1,7 @@ -from esphome.components.mipi import DriverChip -from esphome.config_validation import UNDEFINED +from . import RgbDriverChip # A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence -DriverChip( +RgbDriverChip( "RPI", - swap_xy=UNDEFINED, initsequence=(), ) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index 990a1ca4f3..a20e9d1c01 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -1,17 +1,12 @@ -from esphome.components.mipi import ( - MADCTL, - MADCTL_ML, - MADCTL_XFLIP, - MODE_BGR, - DriverChip, -) -from esphome.config_validation import UNDEFINED +from esphome.components.mipi import MADCTL, MADCTL_ML, MADCTL_XFLIP, MODE_BGR from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y +from . import RgbDriverChip + SDIR_CMD = 0xC7 -class ST7701S(DriverChip): +class ST7701S(RgbDriverChip): # The ST7701s does not use the standard MADCTL bits for x/y mirroring def add_madctl(self, sequence: list, config: dict): transform = self.get_transform(config) @@ -45,7 +40,6 @@ st7701s = ST7701S( "ST7701S", width=480, height=864, - swap_xy=UNDEFINED, hsync_front_porch=20, hsync_back_porch=10, hsync_pulse_width=10, @@ -85,6 +79,7 @@ st7701s.extend( height=480, invert_colors=True, pixel_mode="18bit", + requires={"psram"}, cs_pin=1, de_pin={ "number": 45, @@ -117,6 +112,7 @@ st7701s.extend( vsync_pulse_width=8, vsync_back_porch=20, cs_pin={"pca9554": None, "number": 4}, + requires={"psram", "pca9554"}, de_pin=18, hsync_pin=16, vsync_pin=17, @@ -134,6 +130,7 @@ st7701s.extend( width=480, height=480, pixel_mode="18bit", + requires={"psram"}, cs_pin=18, reset_pin=8, de_pin=17, @@ -177,6 +174,7 @@ st7701s.extend( width=480, height=480, pixel_mode="18bit", + requires={"psram"}, cs_pin=21, de_pin=39, vsync_pin=48, diff --git a/esphome/components/mipi_rgb/models/sunton.py b/esphome/components/mipi_rgb/models/sunton.py index a33625dfe4..a87d5f3c38 100644 --- a/esphome/components/mipi_rgb/models/sunton.py +++ b/esphome/components/mipi_rgb/models/sunton.py @@ -1,14 +1,13 @@ -from esphome.components.mipi import DriverChip -from esphome.config_validation import UNDEFINED +from . import RgbDriverChip # fmt: off -sunton = DriverChip( +sunton = RgbDriverChip( "ESP32-8048S070", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, pclk_frequency="12.5MHz", + requires={"psram"}, de_pin=41, hsync_pin=39, vsync_pin=40, @@ -28,7 +27,6 @@ sunton = DriverChip( sunton.extend( "ESP32-8048S050", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index cd1fc341ef..ef1a5cd2d6 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -1,18 +1,18 @@ -from esphome.components.mipi import DriverChip, delay -from esphome.config_validation import UNDEFINED +from esphome.components.mipi import delay +from . import RgbDriverChip from .st7701s import st7701s # fmt: off -wave_4_3 = DriverChip( +wave_4_3 = RgbDriverChip( "ESP32-S3-TOUCH-LCD-4.3", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, pclk_frequency="16MHz", reset_pin={"ch422g": None, "number": 3}, enable_pin={"ch422g": None, "number": 2}, + requires={"psram", "ch422g"}, de_pin=5, hsync_pin={"number": 46, "ignore_strapping_warning": True}, vsync_pin={"number": 3, "ignore_strapping_warning": True}, @@ -69,6 +69,7 @@ st7701s.extend( pclk_pin=41, pclk_frequency="12MHz", pclk_inverted=False, + requires={"psram"}, data_pins={ "red": [46, 3, 8, 18, 17], "green": [14, 13, 12, 11, 10, 9], @@ -80,6 +81,7 @@ st7701s.extend( "WAVESHARE-3.16-320X820", width=320, height=820, + requires={"psram"}, de_pin=40, hsync_pin=38, vsync_pin=39, diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 871736abd1..f472e12a76 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -41,14 +41,11 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -138,16 +135,7 @@ def denominator(config): def model_schema(config): model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **model.swap_xy_schema(), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) @@ -265,6 +253,7 @@ def customise_schema(config): extra=ALLOW_EXTRA, )(config) model = MODELS[config[CONF_MODEL]] + model.check_requirements() bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL) config = cv.Schema( { diff --git a/esphome/components/mipi_spi/models/adafruit.py b/esphome/components/mipi_spi/models/adafruit.py index 26790b1493..cc295487eb 100644 --- a/esphome/components/mipi_spi/models/adafruit.py +++ b/esphome/components/mipi_spi/models/adafruit.py @@ -13,6 +13,7 @@ ST7789V.extend( mirror_x=True, mirror_y=True, data_rate="80MHz", + requires={"psram"}, ) ST7789V.extend( @@ -25,4 +26,5 @@ ST7789V.extend( dc_pin=39, reset_pin=40, invert_colors=True, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 30e815d68e..8a869f2284 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -16,7 +16,7 @@ from esphome.components.mipi import ( delay, ) from esphome.components.spi import TYPE_QUAD -from esphome.config_validation import UNDEFINED +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y DriverChip( "T-DISPLAY-S3-AMOLED", @@ -29,6 +29,7 @@ DriverChip( brightness=0xD0, color_order=MODE_RGB, no_slpout=True, # SLPOUT is in the init sequence, early + requires={"psram"}, initsequence=(SLPOUT,), ) @@ -43,6 +44,7 @@ DriverChip( data_rate="40MHz", brightness=0xD0, color_order=MODE_RGB, + requires={"psram"}, initsequence=( (PAGESEL, 4), (0x6A, 0x00), @@ -90,6 +92,7 @@ T4_S3_AMOLED = RM690B0.extend( reset_pin=13, enable_pin=9, bus_mode=TYPE_QUAD, + requires={"psram"}, ) CO5300 = DriverChip( @@ -98,7 +101,7 @@ CO5300 = DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, no_slpout=True, - swap_xy=UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, width=480, height=480, initsequence=( diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 812e491c62..187fcfd8c0 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -314,6 +314,7 @@ DriverChip( data_rate="40MHz", dc_pin=4, cs_pin=5, + requires={"psram"}, # reset_pin={CONF_INVERTED: True, CONF_NUMBER: 48}, initsequence=( (0xEF, 0x03, 0x80, 0x02), @@ -379,6 +380,7 @@ DriverChip( cs_pin=5, dc_pin=4, reset_pin=48, + requires={"psram"}, initsequence=( (0xEF, 0x03, 0x80, 0x02), (0xCF, 0x00, 0xC1, 0x30), @@ -711,6 +713,7 @@ ST7796.extend( reset_pin=4, dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, + requires={"psram"}, ) ST7789V.extend( diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 854814f572..d24ca5db58 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -1,12 +1,16 @@ from esphome.components.mipi import MODE_RGB, DriverChip from esphome.components.spi import TYPE_QUAD -import esphome.config_validation as cv -from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER +from esphome.const import ( + CONF_IGNORE_STRAPPING_WARNING, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_NUMBER, +) AXS15231 = DriverChip( "AXS15231", draw_rounding=8, - swap_xy=cv.UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, color_order=MODE_RGB, bus_mode=TYPE_QUAD, initsequence=( @@ -22,6 +26,7 @@ AXS15231.extend( height=480, cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True}, data_rate="40MHz", + requires={"psram"}, ) DriverChip( @@ -36,6 +41,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x08), (0xF2, 0x08), @@ -267,6 +273,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x28), (0xF2, 0x28), @@ -495,6 +502,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="20MHz", + requires={"psram"}, initsequence=( (0xFF, 0xA5), (0x41, 0x03), diff --git a/esphome/components/mipi_spi/models/lanbon.py b/esphome/components/mipi_spi/models/lanbon.py index 8cec3c8317..1188300136 100644 --- a/esphome/components/mipi_spi/models/lanbon.py +++ b/esphome/components/mipi_spi/models/lanbon.py @@ -10,4 +10,5 @@ ST7789V.extend( cs_pin=22, dc_pin=21, reset_pin=18, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/lilygo.py b/esphome/components/mipi_spi/models/lilygo.py index 46ec809029..84f44a3dae 100644 --- a/esphome/components/mipi_spi/models/lilygo.py +++ b/esphome/components/mipi_spi/models/lilygo.py @@ -15,6 +15,7 @@ ST7789V.extend( dc_pin=13, reset_pin=9, data_rate="80MHz", + requires={"psram"}, ) ST7789V.extend( @@ -42,6 +43,7 @@ ST7789V.extend( enable_pin=[9, 15], data_rate="10MHz", bus_mode=TYPE_OCTAL, + requires={"psram"}, ) ST7796.extend( @@ -55,4 +57,5 @@ ST7796.extend( dc_pin=9, backlight_pin=48, invert_colors=True, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index 81bb186278..a54bd19d88 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -49,6 +49,7 @@ ILI9341.extend( invert_colors=True, pixel_mode="18bit", data_rate="40MHz", + requires={"psram"}, ) GC9107 = ST7789V.extend( @@ -68,4 +69,5 @@ GC9107.extend( reset_pin=48, dc_pin=42, cs_pin=14, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 8fc5b2acc5..0caae5b939 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -12,7 +12,7 @@ from esphome.components.mipi import ( PWSET, DriverChip, ) -import esphome.config_validation as cv +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y from .amoled import CO5300 from .ili import ILI9488_A, ST7789V @@ -155,7 +155,7 @@ ST7789P = DriverChip( ILI9488_A.extend( "PICO-RESTOUCH-LCD-3.5", - swap_xy=cv.UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, spi_16=True, pixel_mode="16bit", mirror_x=True, @@ -175,6 +175,7 @@ CO5300.extend( offset_width=6, cs_pin=12, reset_pin=39, + requires={"psram"}, ) # Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller) @@ -189,6 +190,7 @@ CO5300.extend( cs_pin=12, reset_pin=39, data_rate="40MHz", + requires={"psram"}, ) AXS15231.extend( @@ -198,6 +200,7 @@ AXS15231.extend( data_rate="80MHz", cs_pin=9, reset_pin=21, + requires={"psram"}, ) # Waveshare 1.83-v2 @@ -281,6 +284,7 @@ ST7789V.extend( offset_height=40, invert_colors=True, data_rate="40MHz", + requires={"psram"}, ) CO5300.extend( @@ -291,4 +295,5 @@ CO5300.extend( cs_pin=9, reset_pin=21, enable_pin=1, + requires={"psram"}, ) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 803ddba6b7..bfdd2de7c7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -574,6 +574,9 @@ class EsphomeCore: self.build_path: Path | None = None # The validated configuration, this is None until the config has been validated self.config: ConfigType | None = None + # The raw configuration as read from YAML (after packages/substitutions), + # available during validation before the config is fully validated + self.raw_config: ConfigType | None = None # YAML frontmatter loaded from user YAML files. Frontmatter is a leading # YAML document separated by `---` from the actual configuration. It is # ignored by config validation and code generation, but kept here so it @@ -650,6 +653,7 @@ class EsphomeCore: self.config_path = None self.build_path = None self.config = None + self.raw_config = None self.frontmatter = {} self.event_loop = _FakeEventLoop() self.task_counter = 0 diff --git a/tests/component_tests/animation/config/animation_platform_test.yaml b/tests/component_tests/animation/config/animation_platform_test.yaml index 380434dcc3..8de32ed593 100644 --- a/tests/component_tests/animation/config/animation_platform_test.yaml +++ b/tests/component_tests/animation/config/animation_platform_test.yaml @@ -24,6 +24,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/animation/config/animation_test.yaml b/tests/component_tests/animation/config/animation_test.yaml index 9d8fd15276..1fe6ddf9a4 100644 --- a/tests/component_tests/animation/config/animation_test.yaml +++ b/tests/component_tests/animation/config/animation_test.yaml @@ -19,6 +19,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/image/config/image_test.yaml b/tests/component_tests/image/config/image_test.yaml index c34e0993a5..31c29de21b 100644 --- a/tests/component_tests/image/config/image_test.yaml +++ b/tests/component_tests/image/config/image_test.yaml @@ -4,6 +4,9 @@ esphome: esp32: board: esp32s3box +psram: + mode: octal + image: defaults: type: rgb565 diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index c14abdb4fd..100366b135 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -71,6 +71,18 @@ def test_configuration_errors(set_core_config: SetCoreConfigCallable) -> None: } ) + # DSI displays cannot swap axes; enabling swap_xy reports a clear error. + with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"): + CONFIG_SCHEMA( + { + "model": "custom", + "init_sequence": [[0xA0, 0x01]], + "lane_bit_rate": "1.5Gbps", + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": True}, + } + ) + def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: """Test successful configuration validation.""" @@ -116,6 +128,33 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: CONFIG_SCHEMA(config) +def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None: + """A configured display rotation is recorded in the metadata. + + LVGL relies on this to flag a rotation set in the display config (see the + mipi_spi tests for the end-to-end LVGL rejection). + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4}, + ) + + from esphome.components.display import get_display_metadata + from esphome.components.mipi_dsi.display import CONFIG_SCHEMA + + base = { + "model": "custom", + "init_sequence": [[0xA0, 0x01]], + "lane_bit_rate": "1.5Gbps", + "dimensions": {"width": 320, "height": 240}, + } + config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90}) + assert get_display_metadata(config["id"]).rotation == 90 + + config = CONFIG_SCHEMA({**base, "id": "unrotated"}) + assert get_display_metadata(config["id"]).rotation == 0 + + def test_code_generation( generate_main: Callable[[str | Path], str], component_fixture_path: Callable[[str], Path], diff --git a/tests/component_tests/mipi_rgb/__init__.py b/tests/component_tests/mipi_rgb/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/mipi_rgb/test_init.py b/tests/component_tests/mipi_rgb/test_init.py new file mode 100644 index 0000000000..0ab6c022e6 --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_init.py @@ -0,0 +1,89 @@ +"""Tests for mipi_rgb configuration validation, in particular the per-model +``requires`` component check (see esphome.components.mipi.DriverChip.check_requirements).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3 +from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + +# Importing pca9554 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that +# models (e.g. SEEED-INDICATOR-D1) that reference pca9554-backed pins in their +# defaults can be validated by the mipi_rgb CONFIG_SCHEMA in this test. +import esphome.components.pca9554 # noqa: F401 +from esphome.const import PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def test_model_requires_psram(set_core_config: SetCoreConfigCallable) -> None: + """A model known to have PSRAM on its board rejects a config without it. + + RGB parallel displays always need a full framebuffer, so every model in this + component is expected to carry ``requires={"psram", ...}``. This board has no + other requirements, so its check is exercised in isolation here. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + CORE.raw_config = {} + + with pytest.raises( + cv.Invalid, + match=r"ESP32-8048S070 requires component 'psram' to be configured", + ): + _validated({"model": "ESP32-8048S070"}) + + +def test_model_requires_psram_satisfied( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """The same board model validates once PSRAM is configured.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + config = _validated({"model": "ESP32-8048S070"}) + assert config["model"] == "ESP32-8048S070" + + +def test_model_requires_psram_and_expander( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """A model that also depends on an I2C GPIO expander lists both when missing.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + # Only satisfy one of the two requirements. + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + with pytest.raises( + cv.Invalid, + match=r"SEEED-INDICATOR-D1 requires component 'pca9554' to be configured", + ): + _validated( + { + "model": "SEEED-INDICATOR-D1", + "spi_id": "spi_bus", + } + ) diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py new file mode 100644 index 0000000000..e85327c0ab --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -0,0 +1,137 @@ +"""Tests for mipi_rgb configuration validation.""" + +import pytest + +from esphome import config_validation as cv + +# Importing these registers their pin schemas with pins.PIN_SCHEMA_REGISTRY so that +# models referencing IO-expander-backed pins in their defaults (e.g. the LilyGO +# T-RGB boards via xl9535, SEEED-INDICATOR-D1 via pca9554, or the Waveshare panels +# via ch422g) can be validated by the mipi_rgb CONFIG_SCHEMA in this test. +import esphome.components.ch422g # noqa: F401 +from esphome.components.display import get_display_metadata +from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32S3 +import esphome.components.pca9554 # noqa: F401 +import esphome.components.xl9535 # noqa: F401 +from esphome.const import ( + CONF_BLUE, + CONF_DIMENSIONS, + CONF_GREEN, + CONF_HEIGHT, + CONF_INIT_SEQUENCE, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_RED, + CONF_SWAP_XY, + CONF_WIDTH, + KEY_VARIANT, + PlatformFramework, +) +from tests.component_tests.types import SetCoreConfigCallable + +# A generic set of data pins so that models without a default pin assignment +# (e.g. CUSTOM and RPI) still validate. +DATA_PINS = { + CONF_RED: [1, 2, 3, 4, 5], + CONF_GREEN: [6, 7, 8, 9, 10, 11], + CONF_BLUE: [12, 13, 14, 15, 16], +} + + +def _set_s3(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + +def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: + """Every predefined model validates once required defaults are supplied.""" + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + for name, model in MODELS.items(): + config = {"model": name, "data_pins": DATA_PINS, "pclk_pin": 21} + if model.initsequence is None: + config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]] + if not model.get_default(CONF_WIDTH): + config[CONF_DIMENSIONS] = {CONF_WIDTH: 480, CONF_HEIGHT: 480} + CONFIG_SCHEMA(config) + + +def test_transform_matches_model_support( + set_core_config: SetCoreConfigCallable, +) -> None: + """The transform schema only accepts the axes a model actually supports.""" + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # never support axis swapping). + model = MODELS["ESP32-8048S070"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) + + # An unsupported axis may be explicitly disabled (a harmless no-op)... + CONFIG_SCHEMA( + {**base, "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": False}} + ) + + # ...but enabling it reports a clear, model-specific error. + with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"): + CONFIG_SCHEMA( + { + **base, + "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": True}, + } + ) + + +def test_st7701s_only_supports_mirror_x( + set_core_config: SetCoreConfigCallable, +) -> None: + """ST7701S panels shorter than full height only expose mirror_x. + + mirror_y only works at full height (864px), so the LilyGO 480px panels must + reject a mirror_y transform. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["T-RGB-2.1"] + assert model.transforms == {CONF_MIRROR_X} + assert CONF_SWAP_XY not in model.transforms + + base = {"model": "T-RGB-2.1"} + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + with pytest.raises(cv.Invalid, match="'mirror_y' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) + + +def test_metadata_records_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A configured display rotation is recorded in the metadata. + + LVGL relies on this to flag a rotation set in the display config (see the + mipi_spi tests for the end-to-end LVGL rejection). + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90}) + assert get_display_metadata(config["id"]).rotation == 90 + + config = CONFIG_SCHEMA({**base, "id": "unrotated"}) + assert get_display_metadata(config["id"]).rotation == 0 diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py index e7f5143d91..06cc8ee09a 100644 --- a/tests/component_tests/mipi_spi/test_display_metadata.py +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -3,6 +3,9 @@ from collections.abc import Callable from pathlib import Path +import pytest + +from esphome import config_validation as cv from esphome.components.const import BYTE_ORDER_BIG from esphome.components.display import get_all_display_metadata, get_display_metadata from esphome.components.esp32 import ( @@ -13,6 +16,7 @@ from esphome.components.esp32 import ( ) from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import PlatformFramework +from esphome.core import ID from tests.component_tests.types import SetCoreConfigCallable @@ -23,6 +27,18 @@ def validated_config(config): return config +def _lvgl_config(display_id: str) -> dict: + """Build a minimal LVGL config dict referencing the given display id.""" + return { + "displays": [ID(display_id, True)], + "log_level": "WARN", + "color_depth": 16, + "transparency_key": 0x000400, + "draw_rounding": 2, + "buffer_size": 0, + } + + def test_metadata_native_quad_default_test_card( set_core_config: SetCoreConfigCallable, ) -> None: @@ -91,7 +107,7 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) - # JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only + # JC3248W535 has transforms={mirror_x, mirror_y} only config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"}) meta = get_display_metadata(config["id"]) assert meta is not None @@ -166,3 +182,69 @@ def test_metadata_via_code_generation_lvgl( assert meta.height == 160 assert meta.has_hardware_rotation is True assert meta.byte_order == BYTE_ORDER_BIG + + +def test_metadata_records_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A configured display rotation is recorded in the metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = CONFIG_SCHEMA( + {"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90} + ) + meta = get_display_metadata(config["id"]) + assert meta is not None + assert meta.rotation == 90 + + +def test_metadata_rotation_defaults_to_zero( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display without a rotation reports rotation 0 in its metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"}) + meta = get_display_metadata(config["id"]) + assert meta is not None + assert meta.rotation == 0 + + +def test_rotation_flagged_when_used_with_lvgl( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display with a rotation is rejected when driven by LVGL. + + LVGL manages its own rotation, so a rotation set in the display config must be + flagged and the user directed to configure it in the LVGL block instead. This + exercises the full chain: the mipi_spi schema records the rotation in the + display metadata, and LVGL's final validation reports it. + """ + from esphome.components.lvgl import final_validation + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90}) + with pytest.raises(cv.Invalid, match="rotation.*not compatible with LVGL"): + final_validation([_lvgl_config("rotated")]) + + +def test_no_rotation_accepted_with_lvgl( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display without a rotation validates cleanly when driven by LVGL.""" + from esphome.components.lvgl import final_validation + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"}) + # Should not raise. + final_validation([_lvgl_config("unrotated")]) diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py index 8c45b47752..77111ae867 100644 --- a/tests/component_tests/mipi_spi/test_final_validate.py +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -6,10 +6,13 @@ from typing import Any import pytest +from esphome import config_validation as cv from esphome.components.display import CONF_SHOW_TEST_CARD from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi import DriverChip from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.core import CORE from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -183,3 +186,77 @@ def test_buffer_size_selected_when_lvgl_with_test_card( ) assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) + + +def test_requires_missing_single_component_raises() -> None: + """A model that requires a single component raises when it is absent.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-PSRAM", requires={"psram"}) + + with pytest.raises( + cv.Invalid, + match=r"TEST-REQUIRES-PSRAM requires component 'psram' to be configured", + ): + chip.check_requirements() + + +def test_requires_missing_multiple_components_raises() -> None: + """A model that requires several components lists all the missing ones, pluralized.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-MULTI", requires={"psram", "pca9554"}) + + with pytest.raises( + cv.Invalid, + match=r"TEST-REQUIRES-MULTI requires components '.*' to be configured", + ) as excinfo: + chip.check_requirements() + assert "psram" in str(excinfo.value) + assert "pca9554" in str(excinfo.value) + + +def test_requires_satisfied_does_not_raise() -> None: + """No error is raised once all the required components are configured.""" + CORE.raw_config = {"psram": True, "pca9554": []} + chip = DriverChip("TEST-REQUIRES-SATISFIED", requires={"psram", "pca9554"}) + + chip.check_requirements() # Should not raise + + +def test_requires_absent_does_not_raise() -> None: + """Models without a requires set are unaffected by the check.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-NONE") + + chip.check_requirements() # Should not raise + + +def test_predefined_model_requires_psram( + set_core_config: SetCoreConfigCallable, +) -> None: + """A predefined board model known to have PSRAM rejects a config without it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CORE.raw_config = {} + + with pytest.raises( + cv.Invalid, match=r"S3BOX requires component 'psram' to be configured" + ): + _validated({"model": "s3box"}) + + +def test_predefined_model_requires_psram_satisfied( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """The same board model validates once PSRAM is configured.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + config = _validated({"model": "s3box"}) + assert config["model"] == "S3BOX" diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 8edbe095b7..dcecd89617 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -136,7 +136,7 @@ def test_dimension_validation( "model": "JC3248W535", "transform": {"mirror_x": False, "mirror_y": True, "swap_xy": True}, }, - "Axis swapping not supported by this model", + "'swap_xy' is not supported by this model", id="axis_swapping_not_supported", ), pytest.param( diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index 7ae6f0e61f..b2b421c1e2 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from pathlib import Path +from typing import Any import pytest @@ -222,6 +223,7 @@ class TestNewModelVariants: def test_m5core2_with_native_dimensions( self, set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], ) -> None: """Test M5CORE2 variant with reset native_width and native_height.""" set_core_config( @@ -231,6 +233,8 @@ class TestNewModelVariants: KEY_VARIANT: VARIANT_ESP32S3, }, ) + # M5CORE2 has PSRAM on board and requires it to be configured + set_component_config("psram", True) # M5CORE2 should validate successfully config = validated_config({"model": "M5CORE2"}) diff --git a/tests/component_tests/online_image/config/online_image_platform_test.yaml b/tests/component_tests/online_image/config/online_image_platform_test.yaml index 883876e401..9b92bf75d0 100644 --- a/tests/component_tests/online_image/config/online_image_platform_test.yaml +++ b/tests/component_tests/online_image/config/online_image_platform_test.yaml @@ -24,6 +24,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/online_image/config/online_image_test.yaml b/tests/component_tests/online_image/config/online_image_test.yaml index ab0ad472f9..4af398cdff 100644 --- a/tests/component_tests/online_image/config/online_image_test.yaml +++ b/tests/component_tests/online_image/config/online_image_test.yaml @@ -23,6 +23,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display From 478bca026cefa922e7a40ca8edb71b4167410d6f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:37:00 -1000 Subject: [PATCH 2071/2214] Bump bundled esphome-device-builder to 1.4.2 (#17512) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e7f8fceb12..fadf3f0685 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.4.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.2 RUN \ platformio settings set enable_telemetry No \ From 9f62cf924338addcb1f677e58c41720d135216b2 Mon Sep 17 00:00:00 2001 From: Raymond Date: Sat, 11 Jul 2026 13:24:00 +0200 Subject: [PATCH 2072/2214] [mipi_dsi] Add JC8012P4A1-V2 (#17457) --- esphome/components/mipi_dsi/models/guition.py | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index 31a2b0ce1a..914361a4ac 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -318,4 +318,232 @@ DsiDriverChip( (0xE0, 0x00), ] ) + +# JC8012P4A1 V2 Driver Configuration (jd9365) +# Some units of this model have a different LCD panel but still use the same JD9365 driver chip. +# Using parameters from esp_lcd_jd9365.h and the working full init sequence +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 800x1280 +# * PCLK Frequency: 70 MHz +# * DSI Lane Bit Rate: 1.5 Gbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40) +# * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=10, vsync_front_porch=20) +# ---------------------------------------------------------------------------------------------------------------------- +DsiDriverChip( + "JC8012P4A1-V2", + width=800, + height=1280, + hsync_back_porch=20, + hsync_pulse_width=20, + hsync_front_porch=40, + vsync_back_porch=10, + vsync_pulse_width=4, + vsync_front_porch=20, + pclk_frequency="70MHz", + lane_bit_rate="1500Mbps", + color_order="RGB", + reset_pin=27, + initsequence=[ + (0xE0, 0x00), + (0xE1, 0x93), + (0xE2, 0x65), + (0xE3, 0xF8), + (0x80, 0x01), + (0xE0, 0x01), + (0x00, 0x00), + (0x01, 0x44), + (0x03, 0x10), + (0x04, 0x38), + (0x0C, 0x74), + (0x17, 0x00), + (0x18, 0xAF), + (0x19, 0x00), + (0x1A, 0x00), + (0x1B, 0xAF), + (0x1C, 0x00), + (0x35, 0x26), + (0x37, 0x09), + (0x38, 0x04), + (0x39, 0x00), + (0x3A, 0x01), + (0x3C, 0x78), + (0x3D, 0xFF), + (0x3E, 0xFF), + (0x3F, 0x7F), + (0x40, 0x06), + (0x41, 0xA0), + (0x42, 0x81), + (0x43, 0x1E), + (0x44, 0x0D), + (0x45, 0x28), + (0x55, 0x02), + (0x57, 0x69), + (0x59, 0x0A), + (0x5A, 0x2A), + (0x5B, 0x17), + (0x5D, 0x7F), + (0x5E, 0x6B), + (0x5F, 0x5C), + (0x60, 0x50), + (0x61, 0x4C), + (0x62, 0x3E), + (0x63, 0x41), + (0x64, 0x2B), + (0x65, 0x43), + (0x66, 0x42), + (0x67, 0x43), + (0x68, 0x62), + (0x69, 0x52), + (0x6A, 0x5A), + (0x6B, 0x4C), + (0x6C, 0x48), + (0x6D, 0x3A), + (0x6E, 0x28), + (0x6F, 0x10), + (0x70, 0x7F), + (0x71, 0x6B), + (0x72, 0x5C), + (0x73, 0x50), + (0x74, 0x4C), + (0x75, 0x3E), + (0x76, 0x41), + (0x77, 0x2B), + (0x78, 0x43), + (0x79, 0x42), + (0x7A, 0x43), + (0x7B, 0x62), + (0x7C, 0x52), + (0x7D, 0x5A), + (0x7E, 0x4C), + (0x7F, 0x48), + (0x80, 0x3A), + (0x81, 0x28), + (0x82, 0x10), + (0xE0, 0x02), + (0x00, 0x42), + (0x01, 0x42), + (0x02, 0x40), + (0x03, 0x40), + (0x04, 0x5E), + (0x05, 0x5E), + (0x06, 0x5F), + (0x07, 0x5F), + (0x08, 0x5F), + (0x09, 0x57), + (0x0A, 0x57), + (0x0B, 0x77), + (0x0C, 0x77), + (0x0D, 0x47), + (0x0E, 0x47), + (0x0F, 0x45), + (0x10, 0x45), + (0x11, 0x4B), + (0x12, 0x4B), + (0x13, 0x49), + (0x14, 0x49), + (0x15, 0x5F), + (0x16, 0x41), + (0x17, 0x41), + (0x18, 0x40), + (0x19, 0x40), + (0x1A, 0x5E), + (0x1B, 0x5E), + (0x1C, 0x5F), + (0x1D, 0x5F), + (0x1E, 0x5F), + (0x1F, 0x57), + (0x20, 0x57), + (0x21, 0x77), + (0x22, 0x77), + (0x23, 0x46), + (0x24, 0x46), + (0x25, 0x44), + (0x26, 0x44), + (0x27, 0x4A), + (0x28, 0x4A), + (0x29, 0x48), + (0x2A, 0x48), + (0x2B, 0x5F), + (0x2C, 0x01), + (0x2D, 0x01), + (0x2E, 0x00), + (0x2F, 0x00), + (0x30, 0x1F), + (0x31, 0x1F), + (0x32, 0x1E), + (0x33, 0x1E), + (0x34, 0x1F), + (0x35, 0x17), + (0x36, 0x17), + (0x37, 0x37), + (0x38, 0x37), + (0x39, 0x08), + (0x3A, 0x08), + (0x3B, 0x0A), + (0x3C, 0x0A), + (0x3D, 0x04), + (0x3E, 0x04), + (0x3F, 0x06), + (0x40, 0x06), + (0x41, 0x1F), + (0x42, 0x02), + (0x43, 0x02), + (0x44, 0x00), + (0x45, 0x00), + (0x46, 0x1F), + (0x47, 0x1F), + (0x48, 0x1E), + (0x49, 0x1E), + (0x4A, 0x1F), + (0x4B, 0x17), + (0x4C, 0x17), + (0x4D, 0x37), + (0x4E, 0x37), + (0x4F, 0x09), + (0x50, 0x09), + (0x51, 0x0B), + (0x52, 0x0B), + (0x53, 0x05), + (0x54, 0x05), + (0x55, 0x07), + (0x56, 0x07), + (0x57, 0x1F), + (0x58, 0x40), + (0x5B, 0x30), + (0x5C, 0x00), + (0x5D, 0x34), + (0x5E, 0x05), + (0x5F, 0x02), + (0x63, 0x00), + (0x64, 0x6A), + (0x67, 0x73), + (0x68, 0x07), + (0x69, 0x08), + (0x6A, 0x6A), + (0x6B, 0x08), + (0x6C, 0x00), + (0x6D, 0x00), + (0x6E, 0x00), + (0x6F, 0x88), + (0x75, 0xFF), + (0x77, 0xDD), + (0x78, 0x2C), + (0x79, 0x15), + (0x7A, 0x17), + (0x7D, 0x14), + (0x7E, 0x82), + (0xE0, 0x04), + (0x00, 0x0E), + (0x02, 0xB3), + (0x09, 0x60), + (0x0E, 0x48), + (0x37, 0x58), + (0x2B, 0x0F), + (0xE0, 0x05), + (0x15, 0x1D), + (0xE0, 0x00), + (0xE6, 0x02), + (0xE7, 0x0C) + ] +) # fmt: on From e6525b5d930b3d1960ef4d78bcb8f488d6cc4fba Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:36:26 +1000 Subject: [PATCH 2073/2214] [mipi][mipi_spi] SWRESET handling improved (#17504) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 20 +++++- esphome/components/mipi_spi/display.py | 2 +- esphome/components/mipi_spi/mipi_spi.h | 25 ++----- esphome/components/mipi_spi/models/jc.py | 1 + tests/component_tests/mipi_spi/test_init.py | 75 ++++++++++++++++++++- 5 files changed, 100 insertions(+), 23 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index ab59d5ce5f..2b9a150419 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -26,6 +26,7 @@ from esphome.const import ( CONF_OFFSET_HEIGHT, CONF_OFFSET_WIDTH, CONF_PAGES, + CONF_RESET_PIN, CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, @@ -601,12 +602,15 @@ class DriverChip: """ return self.get_default(f"no_{command.lower()}", False) - def get_sequence(self, config, add_madctl=True) -> tuple[int, ...]: + def get_sequence(self, config, add_madctl=True, add_reset=False) -> tuple[int, ...]: """ Create the init sequence for the display. Use the default sequence from the model, if any, and append any custom sequence provided in the config. Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence MADCTL will be set if add_madctl is True + If add_reset is True, a reset is prepended: a software reset when no reset pin + is configured (and the model doesn't skip it), followed by a settling delay that + both a software and a hardware reset require. Returns the init sequence """ sequence = list(self.initsequence or ()) @@ -615,6 +619,15 @@ class DriverChip: # Ensure each command is a tuple sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] + if add_reset: + reset: list = [] + # A software reset is only needed when there is no hardware reset pin. + if CONF_RESET_PIN not in config and not self.skip_command("SWRESET"): + reset.append((SWRESET,)) + # Both a software and a hardware reset need a settling delay before further commands. + reset.append(delay(10)) + sequence = reset + sequence + # Set pixel format if not already in the custom sequence pixel_mode = config[CONF_PIXEL_MODE] if not isinstance(pixel_mode, int): @@ -635,8 +648,13 @@ class DriverChip: sequence.append((BRIGHTNESS, brightness)) # Add a SLPOUT command if required. if not self.skip_command("SLPOUT"): + # A zero delay will delay until 120ms after reset + sequence.append(delay(0)) sequence.append((SLPOUT,)) + sequence.append(delay(10)) sequence.append((DISPON,)) + # Add a delay here because additional commands may be added after this at runtime. + sequence.append(delay(10)) # Flatten the sequence into a list of bytes, with the length of each command # or the delay flag inserted where needed diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index f472e12a76..246db237b1 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -397,7 +397,7 @@ def get_instance(config): async def to_code(config): model = MODELS[config[CONF_MODEL]] var_id = config[CONF_ID] - init_sequence = model.get_sequence(config, False) + init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) cg.add(var.set_init_sequence(init_sequence)) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 48184fa5c1..701bcd7169 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -13,6 +13,8 @@ constexpr static const char *const TAG = "display.mipi_spi"; // Maximum bytes to log for commands (truncated if larger) static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; + +// Command codes for MIPI SPI displays. Not all currently used, kept here for reference. static constexpr uint8_t SW_RESET_CMD = 0x01; static constexpr uint8_t SLEEP_OUT = 0x11; static constexpr uint8_t NORON = 0x13; @@ -151,14 +153,11 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); - } else { - // no reset pin, send software reset command - this->write_command_(SW_RESET_CMD); + // required delay after reset is already in the init sequence, don't duplicate } // need to know when the display is ready for SLPOUT command - will be 120ms after reset auto when = millis() + 120; - delay(10); size_t index = 0; auto &vec = this->init_sequence_; while (index != vec.size()) { @@ -170,6 +169,9 @@ class MipiSpi : public display::Display, uint8_t cmd = vec[index++]; uint8_t x = vec[index++]; if (x == DELAY_FLAG) { + if (cmd == 0) { + cmd = clamp_at_least((int) (when - millis()), 0); + } esph_log_d(TAG, "Delay %dms", cmd); delay(cmd); } else { @@ -179,24 +181,9 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - switch (cmd) { - case SLEEP_OUT: { - // are we ready, boots? - int duration = when - millis(); - if (duration > 0) { - esph_log_d(TAG, "Sleep %dms", duration); - delay(duration); - } - } break; - - default: - break; - } const auto *ptr = vec.data() + index; this->write_command_(cmd, ptr, num_args); index += num_args; - if (cmd == SLEEP_OUT) - delay(10); } } this->reset_params_(); diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index d24ca5db58..ca9adb4a72 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -13,6 +13,7 @@ AXS15231 = DriverChip( transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, color_order=MODE_RGB, bus_mode=TYPE_QUAD, + no_swreset=True, initsequence=( (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5), (0xC1, 0x33), diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index dcecd89617..f29883684c 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -361,7 +361,8 @@ def test_native_generation( "mipi_spi::MipiSpiBuffer()" in main_cpp ) - assert "set_init_sequence({240, 1, 8, 242" in main_cpp + # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. + assert "set_init_sequence({10, 255, 240, 1, 8, 242" in main_cpp assert "show_test_card();" in main_cpp assert "set_write_only(true);" in main_cpp @@ -377,6 +378,76 @@ def test_lvgl_generation( "mipi_spi::MipiSpi();" in main_cpp ) - assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp + # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. + assert "set_init_sequence({10, 255, 177, 3, 1, 44, 45, 178" in main_cpp assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp + + +# A 10ms delay (flattened to {10, 0xFF}, where 0xFF is the delay marker byte) is +# always prepended to the init sequence, since both a software and a hardware reset +# need to settle before further commands. A custom model has no reset_pin default +# and does not set no_swreset, so when no reset pin is configured the SWRESET command +# ({1, 0}: command 0x01 with no parameters) is prepended ahead of that delay. +_SWRESET_YAML = """ +esphome: + name: swreset-test +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf +spi: + clk_pin: 1 + mosi_pin: 2 +display: + - platform: mipi_spi + model: custom + id: {display_id} + dc_pin: 4 + cs_pin: 8 + dimensions: + width: 320 + height: 240 + init_sequence: + - [0xA0, 0x01] +{reset_line} +""" + + +def test_swreset_prepended_without_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A model with no reset pin (and no no_swreset) gets SWRESET prepended.""" + yaml_file = tmp_path / "swreset.yaml" + yaml_file.write_text( + _SWRESET_YAML.format(display_id="swreset_display", reset_line="") + ) + + main_cpp = generate_main(yaml_file) + + # SWRESET ({1, 0}) followed by a 10ms delay ({10, 255}) is inserted ahead of + # the model's own commands. + assert "swreset_display->set_init_sequence({1, 0, 10, 255, 160, 1, 1," in main_cpp + + +def test_swreset_not_prepended_with_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A hardware reset pin performs the reset, so SWRESET must not be prepended. + + The post-reset delay is still required, so the sequence starts with the delay. + """ + yaml_file = tmp_path / "hwreset.yaml" + yaml_file.write_text( + _SWRESET_YAML.format( + display_id="hwreset_display", reset_line=" reset_pin: 5" + ) + ) + + main_cpp = generate_main(yaml_file) + + # The delay ({10, 255}) is still present, but no leading SWRESET ({1, 0}). + assert "hwreset_display->set_init_sequence({10, 255, 160, 1, 1," in main_cpp + assert "hwreset_display->set_init_sequence({1, 0," not in main_cpp From 54529412dcc920af79ba9d32cdf6520123f65122 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:38:01 +1000 Subject: [PATCH 2074/2214] [mipi_dsi] New model for M5Stack Tab5 (#17500) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 34 ++++++----- esphome/components/mipi_dsi/models/m5stack.py | 59 ++++++++++++++++++- .../mipi_dsi/test_mipi_dsi_config.py | 27 +++++++++ 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 2b9a150419..3f73f96327 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -667,23 +667,27 @@ class DriverChip: This runs during schema validation (before ID references are resolved) so that a model whose default pins live on a pin expander reports the missing expander clearly instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + + Also logs a warning if the model is deprecated. """ - requirements = self.get_default("requires", set()) - if not requirements: - return - # ``raw_config`` is populated before any component schema runs during a real - # validation, so presence of a required component is simply a top-level key. - # When it is absent (e.g. a unit test that invokes the schema directly) there - # is no config to check against, so skip. - global_config = CORE.raw_config - if global_config is None: - return - missing = {x for x in requirements if x not in global_config} - if missing: - reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) - raise cv.Invalid( - f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + if deprecation_reason := self.get_default("deprecation_reason"): + LOGGER.warning( + "Display model %s is deprecated: %s", self.name, deprecation_reason ) + if requirements := self.get_default("requires", set()): + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) def requires_buffer(config) -> bool: diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index b947b9ac8a..5b07229ec7 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -54,8 +54,8 @@ DsiDriverChip( ], ) -DsiDriverChip( - "M5STACK-TAB5-V2", +TAB5_ST7123 = DsiDriverChip( + "M5STACK-TAB5-ST7123", height=1280, width=720, hsync_back_porch=40, @@ -94,3 +94,58 @@ DsiDriverChip( (0xC9, 0x00, 0x00, 0x10, 0x1F, 0x36, 0x00, 0x5D, 0x04, 0x9D, 0x05, 0x10, 0xF2, 0x06, 0x60, 0x03, 0x11, 0xAD, 0x00, 0xEF, 0x01, 0x22, 0x2E, 0x0E, 0x74, 0x08, 0x32, 0xDC, 0x09, 0x33, 0x0F, 0xF3, 0x77, 0x0D, 0xB0, 0xDC, 0x03, 0xFF), ], ) + +TAB5_ST7123.extend( + "M5STACK-TAB5-V2", + deprecation_reason="Use 'M5STACK-TAB5-ST7123' or 'M5STACK-TAB5-ST7121' instead." +) + +# Some Tab5 "v2" units ship with an ST7121 controller instead of the ST7123. +# The two are distinguishable at runtime by the touch controller firmware version (the M5 +# factory firmware branches on it), but ESPHome selects the panel at compile time, so ST7121 +# units must select this model explicitly. Values taken from M5's factory source +# (m5stack/M5Tab5-UserDemo: m5stack_tab5.c is_st7121 path + esp_lcd_st7121.c default table). +DsiDriverChip( + "M5STACK-TAB5-ST7121", + height=1280, + width=720, + hsync_back_porch=40, + hsync_pulse_width=2, + hsync_front_porch=40, + vsync_back_porch=24, + vsync_pulse_width=20, + vsync_front_porch=200, + pclk_frequency="70MHz", + lane_bit_rate="965Mbps", + color_order="RGB", + initsequence=[ + (0x01,), + (0x60, 0x71, 0x21, 0xA2), + (0x60, 0x71, 0x21, 0xA3), + (0x60, 0x71, 0x21, 0xA4), + (0x78, 0x21), + (0x79, 0xEF), + (0xA4, 0x31), + (0xB7, 0x00, 0x00, 0x5F, 0x5F, 0x44, 0x1A), + (0xB0, 0x22, 0x6B, 0x11, 0x89, 0x25, 0x43, 0x43), + (0xBF, 0xA7, 0xA7), + (0xA5, 0xF0, 0x03), + (0xD7, 0x10, 0x2C, 0x14, 0x2A, 0x80, 0x80), + (0x90, 0x71, 0x23, 0x5A, 0x20, 0x24, 0x11, 0x21), + (0xA3, 0x80, 0x01, 0x8C, 0xFF, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0xEF, 0x58, 0x00, 0x00, 0x00, 0xFF), + (0xA6, 0x0A, 0x00, 0x24, 0x71, 0x36, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x37, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x00, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x2C, 0x71, 0x00, 0x01, 0x00, 0x00, 0x68, 0x68, 0xFF, 0xFF, 0x00, 0x08, 0x80, 0x08, 0x80, 0x06, 0x00, 0x00, 0x00, 0x00), + (0xA7, 0x1A, 0x1A, 0xC0, 0x64, 0x40, 0x04, 0x15, 0x40, 0x00, 0x40, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x26, 0x37, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x8C, 0x9D, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0xAE, 0xBF, 0x00, 0x00, 0x20, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x79), + (0xAC, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x04, 0x1C, 0x1D, 0x08, 0x0A, 0x10, 0x12, 0x0C, 0x0E, 0x14, 0x16, 0x00, 0x1D, 0x1D, 0x1D, 0x1D, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x06, 0x1C, 0x1D, 0x09, 0x0B, 0x11, 0x13, 0x0D, 0x0F, 0x15, 0x17, 0x02, 0x1D, 0x1D, 0x1D, 0x1D), + (0xAD, 0x0C, 0x40, 0x46, 0x00, 0x07, 0x4B, 0x4B, 0xFF, 0xFF, 0xF0, 0x40, 0x0E, 0x01, 0x07, 0x42, 0x42, 0xFF, 0xFF, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF), + (0xAE, 0xF0, 0xFF, 0x03, 0xF0, 0xFF, 0x03, 0x00), + (0xB2, 0x15, 0x19, 0x05, 0x23, 0x49, 0x2D, 0x03, 0x2E, 0x5C, 0xD2, 0xFF, 0x10, 0x60, 0xFD, 0x20, 0xC0, 0x00), + (0xE8, 0x20, 0x60, 0x04, 0x8E, 0x8E, 0x3E, 0x04, 0xDC, 0xDC, 0x3E, 0x06, 0xFA, 0x26, 0x3E), + (0x75, 0x03, 0x04), + (0xE7, 0x4B, 0x00, 0x00, 0xBE, 0x4B, 0x8C, 0x20, 0x1A, 0xF0, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0xFF, 0x00, 0x32, 0x30, 0x73, 0x00, 0x00, 0xC8, 0x6A, 0xFF, 0x5A, 0x64, 0x38, 0x88, 0x15, 0xB1, 0x01, 0x01, 0x64, 0x01, 0x01, 0x7C, 0xFF, 0x1A, 0x51), + (0xE1, 0x0C, 0x0C), + (0xEA, 0x15, 0x00, 0x01), + (0xC8, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF), + (0xC9, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF), + (0x60, 0x71, 0x21, 0x00), + ], +) diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 100366b135..6259d85184 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -1,6 +1,7 @@ """Tests for mpi_dsi configuration validation.""" from collections.abc import Callable +import logging from pathlib import Path import pytest @@ -128,6 +129,32 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: CONFIG_SCHEMA(config) +def test_deprecated_model_warning( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecated M5Stack-Tab5-v2 alias warns and points at the replacement models.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4}, + ) + + from esphome.components.mipi_dsi.display import CONFIG_SCHEMA + + with caplog.at_level(logging.WARNING): + CONFIG_SCHEMA({"id": "deprecated_display", "model": "M5Stack-Tab5-v2"}) + assert "M5STACK-TAB5-V2 is deprecated" in caplog.text + # The warning names the replacement models so users know what to switch to. + assert "M5STACK-TAB5-ST7123" in caplog.text + + # The replacement models validate without emitting a deprecation warning. + caplog.clear() + with caplog.at_level(logging.WARNING): + CONFIG_SCHEMA({"id": "st7123_display", "model": "M5Stack-Tab5-ST7123"}) + CONFIG_SCHEMA({"id": "st7121_display", "model": "M5Stack-Tab5-ST7121"}) + assert "deprecated" not in caplog.text + + def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None: """A configured display rotation is recorded in the metadata. From 5020179210fe636481d3ed727fa4de109d3484a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:45:02 -0400 Subject: [PATCH 2075/2214] Bump ruff from 0.15.20 to 0.15.21 (#17508) 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 ebd93ea390..7aa8dab534 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.20 # also change in .pre-commit-config.yaml when updating +ruff==0.15.21 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 0ef85783dc3985888e1162257b366e3817fd9fb6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:45:23 -0400 Subject: [PATCH 2076/2214] Bump actions/stale from 10.3.0 to 10.4.0 (#17509) 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 7003f6c482..ef79b2705a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Stale - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch remove-stale-when-updated: true From b6a4dd237e627e0b66ae4a7afb2624f2d00b88d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:45:42 -0400 Subject: [PATCH 2077/2214] Update tzdata requirement from >=2026.2 to >=2026.3 (#17510) 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 b36e70ef5d..5f98111445 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 tzlocal==5.4.4 # from time -tzdata>=2026.2 # from time +tzdata>=2026.3 # from time pyserial==3.5 platformio==6.1.19 esptool==5.3.1 From 35a99f478eb79b03b2a4c3b5ba97d8f9514b7e9b Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 11 Jul 2026 15:48:11 +0200 Subject: [PATCH 2078/2214] [deep_sleep] feed watchdog in deep sleep (#17516) --- .../deep_sleep/deep_sleep_zephyr.cpp | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp index f77b73cd58..cadf7bf42d 100644 --- a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -1,13 +1,36 @@ #include "deep_sleep_component.h" #ifdef USE_ZEPHYR +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/wake.h" #include +#include namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +// The Zephyr watchdog has a short window (2s, or 10s with Zigbee) and +// WDT_OPT_PAUSE_IN_SLEEP only pauses it during true hardware sleep — not while a +// radio thread (e.g. the Zigbee stack) keeps the CPU busy in k_sem_take(). Feed +// it at least this often while waiting so it does not reset the device. +static const uint32_t WDT_FEED_INTERVAL_MS = 1000; + +static bool wakeable_delay_feed_wdt(uint32_t ms) { + while (ms > 0) { + const uint32_t step = std::min(ms, WDT_FEED_INTERVAL_MS); + esphome::internal::wakeable_delay(step); + esphome::arch_feed_wdt(); + if (esphome::wake_request_take()) { + return true; + } + if (ms != UINT32_MAX) { + ms -= step; + } + } + return false; +} + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} @@ -15,8 +38,9 @@ void DeepSleepComponent::dump_config_platform_() {} bool DeepSleepComponent::prepare_to_sleep_() { return true; } void DeepSleepComponent::deep_sleep_() { + bool woke = false; if (this->sleep_duration_.has_value()) { - esphome::internal::wakeable_delay(static_cast(*this->sleep_duration_ / 1000)); + woke = wakeable_delay_feed_wdt(static_cast(*this->sleep_duration_ / 1000)); } else { #ifndef USE_ZIGBEE // the device can be woken up through one of the following signals: @@ -29,10 +53,9 @@ void DeepSleepComponent::deep_sleep_() { // The system is reset when it wakes up from System OFF mode. sys_poweroff(); #else - esphome::internal::wakeable_delay(UINT32_MAX); + woke = wakeable_delay_feed_wdt(UINT32_MAX); #endif } - const bool woke = esphome::wake_request_take(); if (woke) { ESP_LOGD(TAG, "Woken up by another thread"); } else { From 65353006c80cb9256189dcaa315c72d5332f50d7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:32:45 -1000 Subject: [PATCH 2079/2214] Bump bundled esphome-device-builder to 1.4.3 (#17522) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index fadf3f0685..f09280a50e 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.4.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.3 RUN \ platformio settings set enable_telemetry No \ From c0636e2bf7585db6e98835c4670f5cd037dd626b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:30:47 -1000 Subject: [PATCH 2080/2214] [core] Make config-hash independent of machine-local paths (#17523) --- esphome/core/__init__.py | 19 ++++++++++- esphome/yaml_util.py | 36 ++++++++++++++++---- tests/unit_tests/core/test_config.py | 42 +++++++++++++++++++++++ tests/unit_tests/test_main.py | 4 +-- tests/unit_tests/test_yaml_util.py | 51 ++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 9 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index bfdd2de7c7..bf637d4c1f 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -8,6 +8,7 @@ import re from typing import TYPE_CHECKING, Any from esphome.const import ( + CONF_BUILD_PATH, CONF_COMMENT, CONF_ESPHOME, CONF_ETHERNET, @@ -731,12 +732,28 @@ class EsphomeCore: The hash is computed lazily and cached for performance. Uses sort_keys=True to ensure deterministic ordering. + + The hash must be reproducible across machines so the device builder + can compare a locally computed hash against the one a device + advertises. Machine-local data is kept out of the input: build_path + (which embeds ESPHOME_BUILD_PATH and OS path separators) is excluded, + and Path values are dumped relative to the config directory. """ if self._config_hash is None: from esphome import yaml_util from esphome.helpers import fnv1a_32bit_hash - config_str = yaml_util.dump(self.config, show_secrets=True, sort_keys=True) + config = dict(self.config) + if (esphome_conf := config.get(CONF_ESPHOME)) is not None: + esphome_conf = dict(esphome_conf) + esphome_conf.pop(CONF_BUILD_PATH, None) + config[CONF_ESPHOME] = esphome_conf + config_str = yaml_util.dump( + config, + show_secrets=True, + sort_keys=True, + relative_to=self.config_dir if self.config_path is not None else None, + ) self._config_hash = fnv1a_32bit_hash(config_str) return self._config_hash diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 0009cde551..c2db9b97ed 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -840,17 +840,22 @@ def _load_yaml_internal_with_type( loader.dispose() -def dump(dict_, show_secrets=False, sort_keys=False): - """Dump YAML to a string and remove null.""" +def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None): + """Dump YAML to a string and remove null. + + When ``relative_to`` is given, Path values are dumped relative to that + directory (POSIX form) so the output is machine independent. + """ if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() - # Per-call subclass so the redaction flag doesn't leak across calls. + # Per-call subclass so the flags don't leak across calls. # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML - # processing is single-threaded today, so this isolates only the flag.) + # processing is single-threaded today, so this isolates only the flags.) class _Dumper(ESPHomeDumper): _redact_sensitive = not show_secrets + _relative_to = relative_to return yaml.dump( dict_, @@ -1002,9 +1007,13 @@ def format_path(path: DocumentPath, current_obj: Any) -> str: class ESPHomeDumper(yaml.SafeDumper): - # Default for the base class; per-call subclass in ``dump()`` overrides. + # Defaults for the base class; per-call subclass in ``dump()`` overrides. # When True, ``represent_sensitive`` wraps values in ANSI conceal codes. _redact_sensitive: bool = False + # When set, ``represent_path`` dumps Path values relative to this + # directory (in POSIX form) so the output does not depend on where the + # config lives on the machine that produced it. + _relative_to: Path | None = None def represent_mapping(self, tag, mapping, flow_style=None): value = [] @@ -1040,6 +1049,21 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + def represent_path(self, value: Path) -> yaml.ScalarNode: + if self._relative_to is not None: + # Normalize both sides lexically (no symlink resolution) so ".." + # segments do not defeat the prefix match, and walk up so files + # referenced outside the anchor directory stay relative too. A + # path that still cannot be relativized (e.g. a different drive) + # keeps its POSIX form so separators stay stable across OSes. + path = Path(os.path.normpath(value)) + with suppress(ValueError): + path = path.relative_to( + os.path.normpath(self._relative_to), walk_up=True + ) + return self.represent_stringify(path.as_posix()) + return self.represent_stringify(value) + def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: # Only the redact-and-not-a-secret branch is unique to sensitive # values; otherwise let ``represent_stringify`` handle ``!secret`` @@ -1138,5 +1162,5 @@ ESPHomeDumper.add_multi_representer(Extend, ESPHomeDumper.represent_extend) ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) -ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify) +ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_path) ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 6fd9f4c22c..0362c40bce 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None: assert hash1 != hash2 +def test_config_hash_ignores_build_path() -> None: + """Test that config_hash does not depend on the build_path value. + + build_path embeds ESPHOME_BUILD_PATH and OS path separators, so it must + not make the hash differ between machines. + """ + CORE.reset() + CORE.config = {"esphome": {"name": "test", "build_path": "build\\test"}} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config = {"esphome": {"name": "test", "build_path": "/build/test"}} + hash2 = CORE.config_hash + + assert hash1 == hash2 + + +def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None: + """Test that Path values under the config dir hash the same everywhere. + + Simulates the same project checked out at two different locations; the + absolute paths differ but the layout relative to the config dir is the + same, so the hashes must match. + """ + dir1 = tmp_path / "machine_a" / "project" + dir2 = tmp_path / "machine_b" / "somewhere" / "else" + dir1.mkdir(parents=True) + dir2.mkdir(parents=True) + + CORE.reset() + CORE.config_path = dir1 / "device.yaml" + CORE.config = {"esphome": {"name": "test"}, "file": dir1 / "fonts" / "arial.ttf"} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config_path = dir2 / "device.yaml" + CORE.config = {"esphome": {"name": "test"}, "file": dir2 / "fonts" / "arial.ttf"} + hash2 = CORE.config_hash + + assert hash1 == hash2 + + def test_make_app_name_cpp_no_mac_simple() -> None: """Test simple name without MAC suffix returns string literal.""" cpp_expr, global_decl, byte_len = make_app_name_cpp( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 0442c1db16..9a9aafec43 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -167,9 +167,9 @@ def setup_core( CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} if tmp_path is not None: - CORE.config_path = str(tmp_path / f"{name}.yaml") + CORE.config_path = tmp_path / f"{name}.yaml" CORE.name = name - CORE.build_path = str(tmp_path / ".esphome" / "build" / name) + CORE.build_path = tmp_path / ".esphome" / "build" / name @pytest.fixture diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index fa1c0fcce2..5c38fce105 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None: assert value == "hunter2" +def test_dump_path_without_relative_to_is_unchanged() -> None: + """Test that Path values dump as str(path) when relative_to is not given.""" + path = Path("some") / "dir" / "file.ttf" + output = yaml_util.dump({"file": path}) + assert output.strip() == f"file: {path}" + + +def test_dump_path_relative_to_anchor_dir() -> None: + """Test that Path values under relative_to dump as relative POSIX paths.""" + anchor = Path("/config/esphome").absolute() + data = {"file": anchor / "fonts" / "arial.ttf"} + output = yaml_util.dump(data, relative_to=anchor) + assert output.strip() == "file: fonts/arial.ttf" + + +def test_dump_path_outside_anchor_dir_walks_up() -> None: + """Test that Path values outside relative_to walk up with ".." segments.""" + anchor = Path("/config/esphome").absolute() + outside = Path("/config/fonts/file.ttf").absolute() + output = yaml_util.dump({"file": outside}, relative_to=anchor) + assert output.strip() == "file: ../fonts/file.ttf" + + +def test_dump_path_with_dotdot_segments_is_normalized() -> None: + """Test that ".." segments do not defeat relativization. + + A path like /config/other/../esphome/fonts/x.ttf is under the anchor + once normalized, so it must dump as a plain relative path. + """ + anchor = Path("/config/esphome").absolute() + path = Path("/config/other/../esphome/fonts/x.ttf").absolute() + output = yaml_util.dump({"file": path}, relative_to=anchor) + assert output.strip() == "file: fonts/x.ttf" + + +def test_dump_path_dotdot_reference_outside_anchor() -> None: + """Test the relative_config_path("../...") shape stays relative.""" + anchor = Path("/config/esphome").absolute() + path = anchor / ".." / "shared" / "font.ttf" + output = yaml_util.dump({"file": path}, relative_to=anchor) + assert output.strip() == "file: ../shared/font.ttf" + + +def test_dump_relative_to_does_not_leak_between_calls() -> None: + """Test that the relative_to flag is scoped to a single dump call.""" + anchor = Path("/config/esphome").absolute() + path = anchor / "fonts" / "arial.ttf" + assert "fonts/arial.ttf" in yaml_util.dump({"file": path}, relative_to=anchor) + assert yaml_util.dump({"file": path}).strip() == f"file: {path}" + + def test_dump__redacts_sensitive_str_by_default() -> None: out = yaml_util.dump({"password": SensitiveStr("hunter2")}) assert "\\033[8mhunter2\\033[28m" in out From 614fd888297aecedd5060f38b38b0f6a4592fe9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:31:01 -1000 Subject: [PATCH 2081/2214] [mdns] Fix missing device info TXT records when native API is not enabled (#17520) --- esphome/components/mdns/mdns_component.cpp | 27 +++++++++++++------ esphome/components/mdns/mdns_component.h | 15 ++++++++--- esphome/components/mdns/mdns_host.cpp | 2 +- .../mdns/test-fallback.esp32-idf.yaml | 7 +++++ .../mdns/test-webserver-no-api.esp32-idf.yaml | 9 +++++++ 5 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 tests/components/mdns/test-fallback.esp32-idf.yaml create mode 100644 tests/components/mdns/test-webserver-no-api.esp32-idf.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 02b825605c..bb4271a6ca 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -47,7 +47,7 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi auto &services = services_storage; #endif -#ifdef USE_API +#ifdef USE_MDNS_DEVICE_INFO_TXT #ifdef USE_MDNS_STORE_SERVICES get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; @@ -70,17 +70,20 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi platform_register(this, services); } -void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf, - char *config_hash_buf) { +void MDNSComponent::compile_records_(StaticVector &services, + const char *mac_address_buf, const char *config_hash_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. +#ifdef USE_MDNS_DEVICE_INFO_TXT + MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); + MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); + MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash"); +#endif + #ifdef USE_API MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib"); MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name"); - MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); - MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash"); - MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform"); MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board"); MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network"); @@ -212,12 +215,18 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; +#ifndef USE_API + // Without the native API there is no _esphomelib service, so publish the + // device info here for the device builder to discover. + web_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}, + {MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}, + {MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}}; +#endif #endif #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \ !defined(USE_MDNS_EXTRA_SERVICES) MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http"); - MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); // Publish "http" service if not using native API or any other services // This is just to have *some* mDNS service so that .local resolution works @@ -225,7 +234,9 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; - fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; + fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}, + {MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}, + {MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}}; #endif } diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 9d525abc43..4f97e8cb99 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -22,6 +22,15 @@ #endif #endif +// Device info TXT records (version, mac, config_hash) are published on the _esphomelib service +// when the native API is enabled, otherwise on the _http service (web_server's or the fallback one). +// When neither applies (only prometheus, sendspin or user-defined services are configured), no +// device info records are published and the buffers below are not needed. +#if defined(USE_API) || defined(USE_WEBSERVER) || \ + (!defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_MDNS_EXTRA_SERVICES)) +#define USE_MDNS_DEVICE_INFO_TXT +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -136,7 +145,7 @@ class MDNSComponent final : public Component StaticVector dynamic_txt_values_; #endif -#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) +#if defined(USE_MDNS_DEVICE_INFO_TXT) && defined(USE_MDNS_STORE_SERVICES) /// Fixed buffer for MAC address (only needed when services are stored) char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; /// Fixed buffer for config hash hex string (only needed when services are stored) @@ -149,8 +158,8 @@ class MDNSComponent final : public Component // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif - void compile_records_(StaticVector &services, char *mac_address_buf, - char *config_hash_buf); + void compile_records_(StaticVector &services, const char *mac_address_buf, + const char *config_hash_buf); }; } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 1e66a10df0..c5d849df26 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -12,7 +12,7 @@ namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES -#ifdef USE_API +#ifdef USE_MDNS_DEVICE_INFO_TXT get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; format_hex_to(this->config_hash_str_, App.get_config_hash()); diff --git a/tests/components/mdns/test-fallback.esp32-idf.yaml b/tests/components/mdns/test-fallback.esp32-idf.yaml new file mode 100644 index 0000000000..b51dbb443f --- /dev/null +++ b/tests/components/mdns/test-fallback.esp32-idf.yaml @@ -0,0 +1,7 @@ +# No api, web_server or extra services so the fallback _http service +# (with version, mac and config_hash TXT records) is compiled. +wifi: + ssid: MySSID + password: password1 + +mdns: diff --git a/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml b/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml new file mode 100644 index 0000000000..23f3abdeb2 --- /dev/null +++ b/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml @@ -0,0 +1,9 @@ +# web_server without the native api so the version, mac and config_hash +# TXT records are attached to the web_server _http service. +wifi: + ssid: MySSID + password: password1 + +web_server: + +mdns: From b098571a6f83bbd38615cb46dc26a17cff314704 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:31:14 -1000 Subject: [PATCH 2082/2214] [web_server] Fix unused function warning for json_state_str (#17524) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3f4d598d48..3bba879823 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -57,7 +57,7 @@ namespace esphome::web_server { static const char *const TAG = "web_server"; // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. -static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +[[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): From a39607476a74b844f8e32b3eb3486d187d29c35d Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:31:39 +0200 Subject: [PATCH 2083/2214] [zigbee] Fix merge endpoint (#17511) --- esphome/components/zigbee/zigbee_ep_esp32.py | 108 +++++++++++-------- tests/components/zigbee/common_esp32.yaml | 1 + 2 files changed, 64 insertions(+), 45 deletions(-) diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index ca96e4364f..2ed3dddb67 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -94,66 +94,73 @@ def get_next_ep_num(eps: list[int]) -> int: return ep_num -def merge_endpoint( +def compare_clusters( existing_ep: dict[str, Any], - ep_num: int | None, ep: dict[str, Any], - use_type: bool | None, - skip_error: bool, -) -> bool: - add = True +) -> tuple[str | int, str] | None: existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]] for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]: if cl in existing_clusters: - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." - ) - add = False - break - if not add: + return cl + return None + + +def merge_endpoints( + existing_ep: dict[str, Any], + ep: dict[str, Any], + use_type: bool | None, +) -> bool: + if compare_clusters(existing_ep, ep): return False - if ( - use_type - and existing_ep.get(CONF_USE_DEVICE_TYPE) - and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) - ): - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has a conflicting device type {existing_ep.get(DEVICE_TYPE, 'CUSTOM_ATTR')} and use_type is set for both." - ) - return False - if use_type: - existing_ep[CONF_USE_DEVICE_TYPE] = use_type - if ep.get(DEVICE_TYPE): - existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] - else: - existing_ep.pop(DEVICE_TYPE, None) - existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) - return True - if existing_ep.get(CONF_USE_DEVICE_TYPE): - existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) - return True if ( ep.get(DEVICE_TYPE) and existing_ep.get(DEVICE_TYPE) - and ep[DEVICE_TYPE] != existing_ep[DEVICE_TYPE] + and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) ): - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has already a conflicting device type {existing_ep[DEVICE_TYPE]} and use_type is not set for both." - ) return False + if ( + ep.get(DEVICE_TYPE) + and not existing_ep.get(DEVICE_TYPE) + and existing_ep.get(CONF_USE_DEVICE_TYPE) + ): + return False + if existing_ep.get(DEVICE_TYPE) and not ep.get(DEVICE_TYPE) and use_type: + return False + if use_type: + existing_ep[CONF_USE_DEVICE_TYPE] = use_type if ep.get(DEVICE_TYPE): existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) return True +def validate_endpoints(ep_dict: dict[int, dict]) -> None: + for num, ep in ep_dict.items(): + types_dict = ep.get(CONF_USE_DEVICE_TYPE) + if not types_dict: + continue + if len(types_dict) == 1: + ep[DEVICE_TYPE] = list(types_dict.keys())[0] + del ep[CONF_USE_DEVICE_TYPE] + continue + types_list = [t[0] for t in types_dict.items() if t[1]] + if len(types_list) > 1: + raise cv.Invalid( + f"There is more than one component with endpoint: {num} and {CONF_USE_DEVICE_TYPE}: True" + ) + if not types_list: + raise cv.Invalid( + f"Multiple device types on endpoint: {num}. Set {CONF_USE_DEVICE_TYPE}: True on one component." + ) + ep[DEVICE_TYPE] = types_list[0] + del ep[CONF_USE_DEVICE_TYPE] + + def create_ep(router: bool) -> None: zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) + validate_endpoints(ep_dict) # create dummy endpoint if list is empty if not ep_dict and not ep_list: ep_type = "CUSTOM_ATTR" @@ -166,9 +173,7 @@ def create_ep(router: bool) -> None: for ep in ep_list: added = False for existing_ep in ep_list_new: - if merge_endpoint( - existing_ep, None, ep, ep.get(CONF_USE_DEVICE_TYPE), True - ): + if merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)): added = True break if not added: @@ -191,6 +196,8 @@ def create_ep(router: bool) -> None: def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + if use_type is False: + ep.pop(DEVICE_TYPE, None) if ep_num is None: if use_type: ep[CONF_USE_DEVICE_TYPE] = use_type @@ -201,8 +208,19 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non if ep_num in ep_dict: # check if the existing endpoint has same clusters existing_ep = ep_dict[ep_num] - merge_endpoint(existing_ep, ep_num, ep, use_type, False) + if cl := compare_clusters( + existing_ep, + ep, + ): + raise cv.Invalid( + f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." + ) + if ep.get(DEVICE_TYPE) or use_type: + types_dict = existing_ep.setdefault(CONF_USE_DEVICE_TYPE, {}) + if not types_dict.get(ep.get(DEVICE_TYPE)) or use_type: + types_dict[ep.get(DEVICE_TYPE)] = use_type + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) else: - if use_type is not None: - ep[CONF_USE_DEVICE_TYPE] = use_type + if use_type or ep.get(DEVICE_TYPE): + ep[CONF_USE_DEVICE_TYPE] = {ep.get(DEVICE_TYPE): use_type} ep_dict[ep_num] = ep diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 8e00e4471e..6cac9c9e2a 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -5,6 +5,7 @@ binary_sensor: - platform: template name: "Garage Door Open 10" report: "default" + use_device_type: false - platform: template name: "Garage Door Open 12" report: "force" From 91c42381f649832c285e89f5bf161e9f72560f3a Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:32:01 +0200 Subject: [PATCH 2084/2214] [zigbee] prevent task watchdog trigger with large configs. (#17506) --- .../zigbee/zigbee_attribute_esp32.cpp | 19 --------- .../zigbee/zigbee_attribute_esp32.h | 1 - esphome/components/zigbee/zigbee_esp32.cpp | 42 +++++++++---------- esphome/components/zigbee/zigbee_esp32.h | 2 +- 4 files changed, 22 insertions(+), 42 deletions(-) diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index c6f2aa0af6..d7176e6ca5 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -50,25 +50,6 @@ void ZigbeeAttribute::report_(bool has_lock) { } } -void ZigbeeAttribute::setup_reporting() { - ezb_zcl_reporting_info_t reporting_info = ezb_zcl_reporting_info_find( - this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, EZB_ZCL_STD_MANUF_CODE); - if (reporting_info == EZB_ZCL_INVALID_REPORTING_INFO) { - ESP_LOGD(TAG, "Could not find reporting info for attribute 0x%04X in cluster 0x%04X in endpoint %u", this->attr_id_, - this->cluster_id_, this->endpoint_id_); - this->report_enabled = false; - this->force_report_ = false; - } else { - ESP_LOGD(TAG, "Found reporting info for attr 0x%04X in cluster 0x%04X", this->attr_id_, this->cluster_id_); - ezb_zcl_attr_variable_t delta = {.u64 = 0}; - ezb_zcl_reporting_info_update_default_interval(reporting_info, 0, 65000); - ezb_zcl_reporting_info_update(reporting_info, 0, 65000, &delta); - if (ezb_zcl_reporting_start_attr_report(reporting_info) != EZB_ERR_NONE) { - ESP_LOGE(TAG, "Could not start reporting for attribute"); - } - } -} - void ZigbeeAttribute::set_report(ZigbeeReportT report) { this->report_enabled = true; if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index b5afb57910..e5f8c8b1cf 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -42,7 +42,6 @@ class ZigbeeAttribute final : public Component { scale_(scale) {} void loop() override; template void add_attr(T value); - void setup_reporting(); template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(ZigbeeReportT report); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 03457312be..3e0f6cd745 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -53,11 +53,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { switch (signal_type) { case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); - if (ezb_bdb_is_factory_new()) { - global_zigbee->defer([]() { global_zigbee->setup_reporting(); }); - } else { - ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); - } + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); break; case EZB_BDB_SIGNAL_DEVICE_FIRST_START: case EZB_BDB_SIGNAL_DEVICE_REBOOT: { @@ -133,12 +129,12 @@ static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, voi case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message); break; -#ifdef ESPHOME_LOG_HAS_VERBOSE case EZB_ZCL_CORE_DEFAULT_RSP_CB_ID: { +#ifdef ESPHOME_LOG_HAS_VERBOSE ezb_zcl_cmd_default_rsp_message_t *default_rsp = (ezb_zcl_cmd_default_rsp_message_t *) message; ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code); - } break; #endif + } break; default: ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast(callback_id)); break; @@ -206,21 +202,30 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } -void ZigbeeComponent::setup_reporting() { - ESP_LOGD(TAG, "Setting up reporting for all attributes"); - esp_zigbee_lock_acquire(portMAX_DELAY); - for (auto &[_, attribute] : this->attributes_) { - attribute->setup_reporting(); +bool ZigbeeComponent::register_device() { + if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not register the endpoint list"); + this->mark_failed(); + return false; } - ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); - esp_zigbee_lock_release(); + return true; } static void ezb_task(void *pv_parameters) { + if (!global_zigbee->register_device()) { + vTaskDelete(NULL); + return; + } if (esp_zigbee_start(false) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); + global_zigbee->mark_failed(); vTaskDelete(NULL); + return; // vTaskDelete(NULL) never returns, but keep intent explicit } + + // Increase priority to 5 to align with openthread or BLE + vTaskPrioritySet(NULL, 5); + esp_zigbee_launch_mainloop(); esp_zigbee_deinit(); @@ -274,12 +279,6 @@ void ZigbeeComponent::setup() { return; } - if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { - ESP_LOGE(TAG, "Could not register the endpoint list"); - this->mark_failed(); - return; - } - ezb_zcl_core_action_handler_register(zb_action_handler); if (ezb_bdb_set_primary_channel_set(EZB_PRIMARY_CHANNEL_MASK) != ESP_OK) { @@ -298,7 +297,8 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); - xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 24, NULL); + // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy + xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 11289843a8..f4bafac294 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -42,7 +42,7 @@ class ZigbeeComponent final : public Component { void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); - void setup_reporting(); + bool register_device(); template void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, From e27a14ec709ec9cc6f8a756d2940eddb119515c7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:33:29 +1200 Subject: [PATCH 2085/2214] [core] Classify entity metadata visibility for the visual editor (#17503) --- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/button/__init__.py | 4 +- esphome/components/cover/__init__.py | 4 +- esphome/components/event/__init__.py | 4 +- esphome/components/number/__init__.py | 12 ++- esphome/components/sensor/__init__.py | 26 +++-- esphome/components/switch/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/update/__init__.py | 8 +- esphome/components/valve/__init__.py | 4 +- esphome/components/web_server/__init__.py | 4 +- esphome/config_validation.py | 102 ++++++++++++------- tests/unit_tests/test_config_validation.py | 70 ++++++++++++- 13 files changed, 193 insertions(+), 57 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index a9a09363fc..5800e0bd9e 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Exclusive( CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE ): cv.boolean, - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index dd4fde5705..a4245f43e6 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -50,7 +50,9 @@ _BUTTON_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), } ) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 839ca532e6..7639e15334 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -131,7 +131,9 @@ _COVER_SCHEMA = ( cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All( cv.requires_component("mqtt"), cv.boolean ), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 4cab1bff9b..e205e4b910 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -50,7 +50,9 @@ _EVENT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent), cv.GenerateID(): cv.declare_id(Event), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_ON_EVENT): automation.validate_automation({}), } ) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index bcc609de65..ea0c2d77f6 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -212,9 +212,15 @@ _NUMBER_SCHEMA = ( }, cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), ), - cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, - cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED + ): validate_unit_of_measurement, + cv.Optional( + CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + ): cv.enum(NUMBER_MODES, upper=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, } ) ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index da8a540d8d..6ad76046a1 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -321,13 +321,25 @@ _SENSOR_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent), cv.GenerateID(): cv.declare_id(Sensor), - cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, - cv.Optional(CONF_ACCURACY_DECIMALS): validate_accuracy_decimals, - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, - cv.Optional(CONF_STATE_CLASS): validate_state_class, - cv.Optional(CONF_ENTITY_CATEGORY): sensor_entity_category, - cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, - cv.Optional(CONF_EXPIRE_AFTER): cv.All( + cv.Optional( + CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED + ): validate_unit_of_measurement, + cv.Optional( + CONF_ACCURACY_DECIMALS, visibility=cv.Visibility.ADVANCED + ): validate_accuracy_decimals, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, + cv.Optional( + CONF_STATE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_state_class, + cv.Optional( + CONF_ENTITY_CATEGORY, visibility=cv.Visibility.ADVANCED + ): sensor_entity_category, + cv.Optional( + CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED + ): cv.boolean, + cv.Optional(CONF_EXPIRE_AFTER, visibility=cv.Visibility.ADVANCED): cv.All( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 1108652e99..18b95113cc 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -78,7 +78,9 @@ _SWITCH_SCHEMA = ( cv.Optional(CONF_ON_STATE): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, } ) ) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 01a57cbaa1..a3f4999a8f 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -144,7 +144,9 @@ _TEXT_SENSOR_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor), cv.GenerateID(): cv.declare_id(TextSensor), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index ddb471be18..18d333a5ef 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -54,7 +54,9 @@ _UPDATE_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTUpdateComponent), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_ON_UPDATE_AVAILABLE): automation.validate_automation( single=True ), @@ -136,7 +138,9 @@ async def to_code(config): automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(UpdateEntity), - cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean), + cv.Optional( + CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED + ): cv.templatable(cv.boolean), } ), synchronous=True, diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index d82a9fdec2..7d98af402d 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -87,7 +87,9 @@ _VALVE_SCHEMA = ( { cv.GenerateID(): cv.declare_id(Valve), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTValveComponent), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index f4e9eae763..d9fd27dbc2 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -172,7 +172,9 @@ sorting_group = { WEBSERVER_SORTING_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEB_SERVER): cv.Schema( + # The per-entity web_server block is cosmetic dashboard ordering — + # mark the whole block advanced; the children inherit via the cascade. + cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema( { cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer), cv.Optional(CONF_SORTING_WEIGHT): cv.All( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 45fd94fd1a..16f0a63aa0 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -292,10 +292,14 @@ class Visibility(StrEnum): the same way. ESPHome itself ignores the value at runtime; consumers downstream of the schema dump act on it. - A field with no ``visibility`` set (the default) renders on the - editor's main form. The two values below are points along a - single axis of "how prominently to surface this": + Three points along a single axis of "how prominently to surface + this", from least to most hidden: + - ``UI`` — always render on the editor's main form. Use to + promote an ``Optional`` that would otherwise fall through to + the advanced disclosure (see the default rule below): the + "headline" config a user reaches for first (e.g. a sensor's + ``name`` or its primary pin/address). - ``ADVANCED`` — render under the editor's "advanced settings" disclosure. Use for fields whose default is right for ~all users (e.g. ``update_interval`` on time platforms — 15 min is @@ -307,25 +311,35 @@ class Visibility(StrEnum): tweaks can break boot). The YAML escape hatch stays available for the rare power-user override. - The single-axis shape encodes "yaml-only is strictly stronger - than advanced" at the type level — there's no way to ask for - both at once, and no way to set a contradictory state like - "advanced=False, yaml_only=True". + Default when unset (``visibility=None``): resolved by the + consumer, not encoded on the marker. A schema-aware editor + treats an ``Optional`` with no setting as ``ADVANCED`` (most + optional knobs have sensible defaults and would clutter the + form), and a ``Required`` with no setting as ``UI`` (a required + field needs the user's attention). Pass an explicit value to + override either default — most commonly ``UI`` to keep a + high-value ``Optional`` on the main form. + + The single-axis shape encodes the strictness ladder + (``UI`` < ``ADVANCED`` < ``YAML_ONLY``) at the type level — + there's no way to set a contradictory state. Per-field; the dumper walks recursively into nested schemas - and emits each field's setting independently. Cascading - semantics — "a stricter parent makes its descendants at-least - as strict" — belong on the consumer side: the schema marker - is faithfully what the field author wrote, and a consumer that - cares about effective visibility walks the parent chain and - takes the strictest setting. ``YAML_ONLY`` is strictly stronger - than ``ADVANCED``, which is strictly stronger than no setting. - Inner fields can declare their own visibility; an inner + and emits each field's setting independently, omitting the key + when unset so the dump stays compact and the per-field default + is the consumer's to apply. Cascading semantics — "a stricter + parent makes its descendants at-least as strict" — belong on the + consumer side: the schema marker is faithfully what the field + author wrote, and a consumer that cares about effective + visibility walks the parent chain and takes the strictest + setting. Inner fields can declare their own visibility; an inner ``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``, - and the consumer's cascade keeps siblings under the parent at - ``ADVANCED`` regardless of their own (less-strict) setting. + and the consumer's cascade keeps a ``UI`` sibling under an + ``ADVANCED`` parent at ``ADVANCED`` regardless of its own + (less-strict) setting. """ + UI = "ui" ADVANCED = "advanced" YAML_ONLY = "yaml_only" @@ -347,6 +361,9 @@ class Optional(vol.Optional): See :class:`Visibility` for the ``visibility`` kwarg — a UI hint for schema-driven editors that doesn't affect validation. + Left unset, an ``Optional`` is treated as ``Visibility.ADVANCED`` + by schema-aware editors; pass ``Visibility.UI`` to keep it on the + main form. """ def __init__( @@ -369,9 +386,11 @@ class Required(vol.Required): See :class:`Visibility` for the ``visibility`` kwarg — a UI hint for schema-driven editors that doesn't affect validation. - Required fields rarely need it (a required field by definition - needs the user's attention) but the kwarg is exposed for - symmetry so consumers can apply uniform logic across key markers. + Required fields rarely need it: left unset, a ``Required`` is + treated as on the main form (``Visibility.UI``) by schema-aware + editors, since a required field needs the user's attention. The + kwarg is exposed for symmetry so consumers can apply uniform + logic across key markers. """ def __init__( @@ -2274,16 +2293,25 @@ MQTT_COMPONENT_AVAILABILITY_SCHEMA = Schema( } ) +# Per-entity MQTT plumbing — integration metadata, never a primary UI field. MQTT_COMPONENT_SCHEMA = Schema( { - Optional(CONF_QOS): All(requires_component("mqtt"), mqtt_qos), - Optional(CONF_RETAIN): All(requires_component("mqtt"), boolean), - Optional(CONF_DISCOVERY): All(requires_component("mqtt"), boolean), - Optional(CONF_SUBSCRIBE_QOS): All(requires_component("mqtt"), mqtt_qos), - Optional(CONF_STATE_TOPIC): All( + Optional(CONF_QOS, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), mqtt_qos + ), + Optional(CONF_RETAIN, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), + Optional(CONF_DISCOVERY, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), + Optional(CONF_SUBSCRIBE_QOS, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), mqtt_qos + ), + Optional(CONF_STATE_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(publish_topic) ), - Optional(CONF_AVAILABILITY): All( + Optional(CONF_AVAILABILITY, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), Any(None, MQTT_COMPONENT_AVAILABILITY_SCHEMA) ), } @@ -2291,10 +2319,12 @@ MQTT_COMPONENT_SCHEMA = Schema( MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend( { - Optional(CONF_COMMAND_TOPIC): All( + Optional(CONF_COMMAND_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(subscribe_topic) ), - Optional(CONF_COMMAND_RETAIN): All(requires_component("mqtt"), boolean), + Optional(CONF_COMMAND_RETAIN, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), } ) @@ -2369,12 +2399,16 @@ def string_no_slash(value): ENTITY_BASE_SCHEMA = Schema( { - Optional(CONF_NAME): _validate_entity_name, - Optional(CONF_INTERNAL): boolean, - Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, - Optional(CONF_ICON): icon, - Optional(CONF_ENTITY_CATEGORY): entity_category, - Optional(CONF_DEVICE_ID): sub_device_id, + # The name is every entity's headline field — keep it on the + # main form rather than letting it fall through to advanced. + Optional(CONF_NAME, visibility=Visibility.UI): _validate_entity_name, + Optional(CONF_INTERNAL, visibility=Visibility.ADVANCED): boolean, + Optional( + CONF_DISABLED_BY_DEFAULT, default=False, visibility=Visibility.ADVANCED + ): boolean, + Optional(CONF_ICON, visibility=Visibility.ADVANCED): icon, + Optional(CONF_ENTITY_CATEGORY, visibility=Visibility.ADVANCED): entity_category, + Optional(CONF_DEVICE_ID, visibility=Visibility.ADVANCED): sub_device_id, } ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 6580564c65..17dfaad9b8 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1174,9 +1174,10 @@ def test_update_interval__never_passes_through() -> None: def test_optional_default_visibility_is_none() -> None: """An ``Optional`` with no ``visibility`` kwarg reports ``None``. - Consumers can read the attribute directly with plain attribute - access; absence (``None``) means "render on the editor's main - form." + The marker stays faithful to what the author wrote: ESPHome does + not encode the default on it. Resolving ``None`` to an effective + visibility is the consumer's job — a schema-aware editor treats an + unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`). """ o = cv.Optional("foo") assert o.visibility is None @@ -1194,6 +1195,17 @@ def test_optional_visibility_yaml_only() -> None: assert o.visibility is cv.Visibility.YAML_ONLY +def test_optional_visibility_ui() -> None: + """``visibility=Visibility.UI`` is recorded on the marker. + + ``UI`` promotes an ``Optional`` onto the editor's main form, + overriding the consumer's default of ``ADVANCED`` for unset + optionals. + """ + o = cv.Optional("foo", visibility=cv.Visibility.UI) + assert o.visibility is cv.Visibility.UI + + def test_visibility_str_values_match_dump_emission() -> None: """``Visibility`` is a ``StrEnum`` whose values are the literal strings the schema dumper emits. @@ -1203,6 +1215,7 @@ def test_visibility_str_values_match_dump_emission() -> None: field — pinning the on-the-wire spelling here keeps the dump contract stable. """ + assert str(cv.Visibility.UI) == "ui" assert str(cv.Visibility.ADVANCED) == "advanced" assert str(cv.Visibility.YAML_ONLY) == "yaml_only" @@ -1325,6 +1338,57 @@ def test_visibility_marker_is_per_field_no_mutation() -> None: assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY +def test_entity_metadata_visibility_hints() -> None: + """Entity and value-describing metadata is classified for visual editors. + + The headline ``name`` stays on the main form (``UI``); descriptive + metadata (device_class, unit, …), presentation options, and per-entity + integration plumbing (MQTT, web_server ordering) fall to the advanced + disclosure (``ADVANCED``). + """ + advanced = cv.Visibility.ADVANCED + + entity_base = {str(k): k for k in cv.ENTITY_BASE_SCHEMA.schema} + assert entity_base["name"].visibility is cv.Visibility.UI + for field in ( + "icon", + "internal", + "disabled_by_default", + "entity_category", + "device_id", + ): + assert entity_base[field].visibility is advanced, field + + mqtt = {str(k): k for k in cv.MQTT_COMPONENT_SCHEMA.schema} + for field in ("qos", "retain", "discovery", "state_topic", "availability"): + assert mqtt[field].visibility is advanced, field + + from esphome.components import binary_sensor, number, sensor + from esphome.components.web_server import WEBSERVER_SORTING_SCHEMA + + sensor_markers = {str(k): k for k in sensor.sensor_schema().schema} + for field in ( + "unit_of_measurement", + "accuracy_decimals", + "device_class", + "state_class", + "force_update", + ): + assert sensor_markers[field].visibility is advanced, field + + binary = {str(k): k for k in binary_sensor.binary_sensor_schema().schema} + assert binary["device_class"].visibility is advanced + + number_markers = {str(k): k for k in number.number_schema(number.Number).schema} + assert number_markers["mode"].visibility is advanced + assert number_markers["device_class"].visibility is advanced + + # The whole per-entity web_server block is advanced; children inherit + # via the consumer cascade, so only the parent key carries the hint. + web = {str(k): k for k in WEBSERVER_SORTING_SCHEMA.schema} + assert web["web_server"].visibility is advanced + + def _wrap_str(value: str) -> ESPHomeDataBase: """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" return make_data_base(value) From 2b3027a7fdb39a117078adeb77badf47da38461a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jul 2026 10:55:30 -1000 Subject: [PATCH 2086/2214] [api] Provision encryption keys over an encrypted zero-PSK noise connection (#17482) --- esphome/components/api/__init__.py | 7 +- esphome/components/api/api.proto | 5 + esphome/components/api/api_connection.cpp | 49 +++++++ esphome/components/api/api_connection.h | 5 + esphome/components/api/api_frame_helper.cpp | 2 + esphome/components/api/api_frame_helper.h | 11 ++ .../components/api/api_frame_helper_noise.cpp | 51 +++++-- .../components/api/api_frame_helper_noise.h | 8 ++ .../api/api_frame_helper_plaintext.cpp | 11 ++ .../api/api_frame_helper_plaintext.h | 9 ++ esphome/components/api/api_noise_context.h | 17 ++- esphome/components/api/api_pb2.cpp | 6 + esphome/components/api/api_pb2.h | 5 +- esphome/components/api/api_pb2_dump.cpp | 3 + esphome/components/mdns/mdns_component.cpp | 19 ++- .../test-dynamic-encryption.esp32-idf.yaml | 8 +- .../fixtures/api_zero_psk_provisioning.yaml | 6 + .../api_zero_psk_provisioning_plaintext.yaml | 6 + .../test_api_zero_psk_provisioning.py | 127 ++++++++++++++++++ 19 files changed, 334 insertions(+), 21 deletions(-) create mode 100644 tests/integration/fixtures/api_zero_psk_provisioning.yaml create mode 100644 tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml create mode 100644 tests/integration/test_api_zero_psk_provisioning.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 64b025fee1..0719cee352 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -488,8 +488,11 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired - # This will allow a plaintext client to provide a noise key, - # send it to the device, and then switch to noise. + # Until a key is set, the device accepts both Noise connections + # using the well-known all-zeros PSK (preferred: the key travels + # encrypted, protecting against passive sniffing) and plaintext + # connections (deprecated, remove after 2027.2.0) so a client can + # provide a noise key and the device then switches to noise only. # The key will be saved in flash and used for future connections # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 86707d9810..4b3df62ec4 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -310,6 +310,11 @@ message DeviceInfoResponse { // Serial proxy instance metadata 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 + // all-zeros PSK, so the api encryption key can be provisioned without being + // sent in plaintext (protects against passive sniffing, not active MITM) + bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index dcb1478ec8..2efdf0bc03 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -198,6 +198,29 @@ APIConnection::~APIConnection() { #endif } +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) +void APIConnection::upgrade_helper_to_noise_() { + // The client opened with a Noise hello while this device has no encryption + // key set. Replace the plaintext helper with a Noise helper so the key can + // be provisioned over an encrypted channel: the noise context PSK is all + // zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519 + // exchange, so a passive listener cannot read the session. A publicly known + // PSK authenticates nobody; this protects against sniffing only. + auto *plaintext = static_cast(this->helper_.get()); + uint8_t header[3]; + uint8_t header_len = plaintext->get_consumed_header(header); + auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx()); + // Carry over the peername-based client name (Hello has not arrived yet) + const char *name = plaintext->get_client_name(); + noise->set_client_name(name, strlen(name)); + this->helper_.reset(noise); // destroys the plaintext helper + APIError err = noise->init_from_handoff(header, header_len); + if (err != APIError::OK) { + this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err); + } +} +#endif // USE_API_NOISE && USE_API_PLAINTEXT + void APIConnection::destroy_active_iterator_() { switch (this->active_iterator_) { case ActiveIterator::LIST_ENTITIES: @@ -256,6 +279,15 @@ void APIConnection::loop() { // No more data available break; } else if (err != APIError::OK) { +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Checked inside the error branch to keep the hot err == OK path + // free of it; this can only fire on the first bytes of a plaintext + // helper on an unprovisioned device + if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) { + this->upgrade_helper_to_noise_(); + return; + } +#endif this->fatal_error_with_log_(LOG_STR("Reading failed"), err); return; } else { @@ -1860,6 +1892,12 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; +#ifndef USE_API_NOISE_PSK_FROM_YAML + // No key from YAML: while no key is set, the key can be provisioned over a + // zero-PSK Noise connection. Gated on the YAML define (not the plaintext + // one) so this advertisement survives the plaintext removal in 2027.2.0. + resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk(); +#endif #endif #ifdef USE_DEVICES size_t device_index = 0; @@ -2037,10 +2075,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } } else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); + } else if (APINoiseContext::is_all_zeros(psk)) { + // Accepting the reserved provisioning PSK would report success without + // enabling encryption (or silently clear an existing key) + ESP_LOGW(TAG, "Rejecting all-zero encryption key"); } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); } else { resp.success = true; +#ifdef USE_API_PLAINTEXT + if (this->helper_->frame_footer_size() == 0) { + // Plaintext transport has no frame footer; Noise always has the MAC footer. + // Remove after 2027.2.0 together with plaintext support on keyless devices. + ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0"); + } +#endif } return this->send_message(resp); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index d6d3e4d26b..144973fa9d 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -626,6 +626,11 @@ class APIConnection final : public APIServerConnectionBase { void destroy_active_iterator_(); void begin_iterator_(ActiveIterator type); void finalize_iterator_sync_(); +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Swap the plaintext helper for a Noise helper after the client opened + // with a Noise hello on an unprovisioned device (zero-PSK provisioning). + void upgrade_helper_to_noise_(); +#endif #ifdef USE_CAMERA std::unique_ptr image_reader_; #endif diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 90353b6402..7425304766 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE"); } #endif + // PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before + // any logging can happen, so it intentionally has no entry here. return LOG_STR("UNKNOWN"); } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f98eca8076..9cae6ba92e 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -88,6 +88,11 @@ enum class APIError : uint16_t { HANDSHAKESTATE_SPLIT_FAILED = 1020, BAD_HANDSHAKE_ERROR_BYTE = 1021, #endif +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Not an error: an unprovisioned device received a Noise client hello on a + // plaintext connection; the caller must hand the socket off to a Noise helper. + PROTOCOL_SWITCH_TO_NOISE = 1023, +#endif }; const LogString *api_error_to_logstr(APIError err); @@ -200,6 +205,12 @@ class APIFrameHelper { // or track that they stopped early and retry without this check. // See Socket::ready() for details. bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Move the socket out of this helper so a replacement helper can take it + // over (plaintext to Noise handoff on unprovisioned devices). The drained + // helper must be destroyed right after. + std::unique_ptr release_socket_for_switch() { return std::move(this->socket_); } +#endif // Release excess memory from internal buffers after initial sync void release_buffers() { // rx_buf_: Safe to clear only if no partial read in progress. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 6dba64a7f8..225bac51a6 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() { state_ = State::CLIENT_HELLO; return APIError::OK; } +#ifdef USE_API_PLAINTEXT +APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) { + APIError err = this->init(); + if (err != APIError::OK) { + return err; + } + // Seed the header bytes the plaintext helper consumed before detecting the + // Noise indicator; try_read_frame_ resumes from rx_header_buf_len_. + std::memcpy(this->rx_header_buf_, header, header_len); + this->rx_header_buf_len_ = header_len; + // Pump the handshake without gating on socket_->ready(): on LWIP the + // plaintext helper's partial read can drain rcvevent while the rest of the + // client hello sits in the lastdata cache, so ready() may report false even + // though data is available. + return this->pump_handshake_(); +} +#endif // USE_API_PLAINTEXT + +/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal +/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK +/// and resume on the next loop(). +APIError APINoiseFrameHelper::pump_handshake_() { + while (this->state_ != State::DATA) { + APIError err = this->state_action_(); + if (err == APIError::WOULD_BLOCK) { + break; + } + if (err != APIError::OK) { + return err; + } + } + return APIError::OK; +} + // Helper for handling handshake frame errors APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) { if (aerr == APIError::BAD_INDICATOR) { @@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func /// Run through handshake messages (if in that phase) APIError APINoiseFrameHelper::loop() { - // Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once - // the rx buffer is consumed. Re-checking each iteration would block handshake writes - // that must follow reads, deadlocking the handshake. state_action() will return - // WOULD_BLOCK when no more data is available to read. - bool socket_ready = this->socket_->ready(); - while (state_ != State::DATA && socket_ready) { - APIError err = state_action_(); - if (err == APIError::WOULD_BLOCK) { - break; - } + // Check ready() once, not per state transition. On ESP8266 LWIP raw TCP, + // ready() returns false once the rx buffer is consumed. Re-checking each + // iteration would block handshake writes that must follow reads, + // deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when + // no more data is available to read. + if (state_ != State::DATA && this->socket_->ready()) { + APIError err = this->pump_handshake_(); if (err != APIError::OK) { return err; } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 0676eab78d..b0ba9fd01c 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper { } ~APINoiseFrameHelper() override; APIError init() override; +#ifdef USE_API_PLAINTEXT + // Take over a connection whose first bytes were consumed by a plaintext + // helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE). + // Seeds the already-read header bytes and pumps the handshake state machine + // until it would block. + APIError init_from_handoff(const uint8_t *header, uint8_t header_len); +#endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: + APIError pump_handshake_(); APIError state_action_(); APIError state_action_client_hello_(); APIError state_action_server_hello_(); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index fa611a6e33..9359f568fb 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { // If this was the first read, validate the indicator byte if (rx_header_buf_pos_ == 0 && received > 0) { if (rx_header_buf_[0] != 0x00) { +#ifdef USE_API_NOISE + // Dual build (encryption supported but no key set): a 0x01 first byte + // is a Noise client hello. Hand the connection off to a Noise helper + // running the all-zeros provisioning PSK so the encryption key can be + // set without crossing the wire in plaintext. Preserve the bytes we + // already consumed; they are the start of the Noise 3-byte header. + if (rx_header_buf_[0] == 0x01) { + rx_header_buf_pos_ = static_cast(received); + return APIError::PROTOCOL_SWITCH_TO_NOISE; + } +#endif state_ = State::FAILED; HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); return APIError::BAD_INDICATOR; diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index 8314754715..ea3f6d7280 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; +#ifdef USE_API_NOISE + // After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the + // header bytes already consumed from the socket (at most 3, the size of the + // Noise fixed header) so the replacement Noise helper can be seeded with them. + uint8_t get_consumed_header(uint8_t out[3]) const { + memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_); + return this->rx_header_buf_pos_; + } +#endif protected: APIError try_read_frame_(); diff --git a/esphome/components/api/api_noise_context.h b/esphome/components/api/api_noise_context.h index b5f7016689..44484ffa2c 100644 --- a/esphome/components/api/api_noise_context.h +++ b/esphome/components/api/api_noise_context.h @@ -10,13 +10,20 @@ using psk_t = std::array; class APINoiseContext { public: + // The all-zeros PSK is reserved: it marks the device as unprovisioned and + // doubles as the well-known provisioning PSK that unprovisioned devices + // accept for Noise handshakes (passive-sniffing protection only, no + // authentication). It is never a valid real key. + static bool is_all_zeros(const psk_t &psk) { + uint8_t acc = 0; + for (uint8_t b : psk) { + acc |= b; + } + return acc == 0; + } void set_psk(psk_t psk) { this->psk_ = psk; - bool has_psk = false; - for (auto i : psk) { - has_psk |= i; - } - this->has_psk_ = has_psk; + this->has_psk_ = !is_all_zeros(psk); } const psk_t &get_psk() const { return this->psk_; } bool has_psk() const { return this->has_psk_; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index de6ae4751e..190bd32425 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -170,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ for (const auto &it : this->serial_proxies) { ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); } +#endif +#ifdef USE_API_NOISE + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); #endif return pos; } @@ -232,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const { for (const auto &it : this->serial_proxies) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } +#endif +#ifdef USE_API_NOISE + size += ProtoSize::calc_bool(2, this->api_encryption_provisionable); #endif return size; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d268a40c56..4d5866da0b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -533,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 309; + static constexpr uint16_t ESTIMATED_SIZE = 312; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } #endif @@ -588,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; +#endif +#ifdef USE_API_NOISE + bool api_encryption_provisionable{false}; #endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3a1ceba95f..09570b09e4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -982,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { it.dump_to(out); out.append("\n"); } +#endif +#ifdef USE_API_NOISE + dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable); #endif return out.c_str(); } diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index bb4271a6ca..fa39e86ed0 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -110,7 +110,13 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx().has_psk(); txt_count++; // api_encryption or api_encryption_supported +#ifndef USE_API_NOISE_PSK_FROM_YAML + if (!api_has_psk) { + txt_count++; // api_provisioning + } +#endif #endif #ifdef ESPHOME_PROJECT_NAME txt_count += 2; // project_name and project_version @@ -166,9 +172,18 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx().has_psk(); - const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; + const char *encryption_key = api_has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)}); +#ifndef USE_API_NOISE_PSK_FROM_YAML + if (!api_has_psk) { + // Unprovisioned device without a YAML key: advertise that the encryption + // key can be provisioned over a zero-PSK Noise connection. Gated on the + // YAML define so this survives the plaintext removal in 2027.2.0. + MDNS_STATIC_CONST_CHAR(TXT_API_PROVISIONING, "api_provisioning"); + MDNS_STATIC_CONST_CHAR(VALUE_ZERO_PSK, "zero-psk"); + txt_records.push_back({MDNS_STR(TXT_API_PROVISIONING), MDNS_STR(VALUE_ZERO_PSK)}); + } +#endif #endif #ifdef ESPHOME_PROJECT_NAME diff --git a/tests/components/api/test-dynamic-encryption.esp32-idf.yaml b/tests/components/api/test-dynamic-encryption.esp32-idf.yaml index 504871716b..7563e3e9df 100644 --- a/tests/components/api/test-dynamic-encryption.esp32-idf.yaml +++ b/tests/components/api/test-dynamic-encryption.esp32-idf.yaml @@ -1,5 +1,11 @@ -<<: !include common-base.yaml +packages: + common: !include common-base.yaml wifi: ssid: MySSID password: password1 + +# Encryption enabled without a key: compiles both frame helpers so the key +# can be provisioned at runtime (zero-PSK noise or deprecated plaintext) +api: + encryption: diff --git a/tests/integration/fixtures/api_zero_psk_provisioning.yaml b/tests/integration/fixtures/api_zero_psk_provisioning.yaml new file mode 100644 index 0000000000..1bb2a43e71 --- /dev/null +++ b/tests/integration/fixtures/api_zero_psk_provisioning.yaml @@ -0,0 +1,6 @@ +esphome: + name: zero-psk-provision-test +host: +api: + encryption: +logger: diff --git a/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml b/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml new file mode 100644 index 0000000000..a798c038d7 --- /dev/null +++ b/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml @@ -0,0 +1,6 @@ +esphome: + name: zero-psk-plaintext-test +host: +api: + encryption: +logger: diff --git a/tests/integration/test_api_zero_psk_provisioning.py b/tests/integration/test_api_zero_psk_provisioning.py new file mode 100644 index 0000000000..bcea2a2471 --- /dev/null +++ b/tests/integration/test_api_zero_psk_provisioning.py @@ -0,0 +1,127 @@ +"""Integration tests for provisioning the encryption key over a zero-PSK connection. + +A device with `api: encryption:` but no key accepts Noise handshakes using the +well-known all-zeros PSK. The ephemeral X25519 exchange protects the key from +passive sniffing while it is provisioned; plaintext provisioning still works +but is deprecated. +""" + +from __future__ import annotations + +import asyncio +import base64 + +from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The well-known provisioning PSK: base64 of 32 zero bytes +ZERO_PSK = base64.b64encode(bytes(32)).decode() +# A real key to provision +NEW_KEY = base64.b64encode(b"n" * 32) +# Time for the device to activate a newly saved key (100ms timer plus margin) +KEY_ACTIVATION_DELAY = 0.5 + + +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Keep host preferences per-test so every run starts unprovisioned.""" + monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) + + +@pytest.mark.asyncio +async def test_api_zero_psk_provisioning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Exercise the reject paths, then provision a key over the zero-PSK channel.""" + async with run_compiled(yaml_config): + # --- Pre-provisioning reject paths (device state is unchanged) --- + + # A wrong (non-zero) PSK fails against the zero provisioning PSK + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected( + noise_psk=base64.b64encode(b"w" * 32).decode(), timeout=5 + ) as client: + await client.device_info() + + # A plaintext client and a zero-PSK client can be connected at the + # same time while the device is unprovisioned + async with ( + api_client_connected() as plaintext_client, + api_client_connected(noise_psk=ZERO_PSK) as noise_client, + ): + plaintext_info = await plaintext_client.device_info() + noise_info = await noise_client.device_info() + # Both transports advertise provisioning support so old and new + # clients can decide how to provision + assert plaintext_info.api_encryption_provisionable is True + assert noise_info.api_encryption_provisionable is True + + # The all-zeros key is reserved as the provisioning PSK and is + # rejected on both transports + zero_key = base64.b64encode(bytes(32)) + assert await noise_client.noise_encryption_set_key(zero_key) is False + assert await plaintext_client.noise_encryption_set_key(zero_key) is False + + # --- Provision over the zero-PSK channel --- + + # The unprovisioned device accepts the all-zeros PSK; the handshake's + # ephemeral-ephemeral DH encrypts everything that follows + async with api_client_connected(noise_psk=ZERO_PSK) as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-provision-test" + assert device_info.api_encryption_supported is True + assert device_info.api_encryption_provisionable is True + + assert await client.noise_encryption_set_key(NEW_KEY) is True + + # The device activates the new key shortly after responding + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + # The new key now works, and the device is no longer provisionable + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-provision-test" + assert device_info.api_encryption_provisionable is False + + # The zero PSK no longer works + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() + + # Plaintext no longer works + with pytest.raises(RequiresEncryptionAPIError): + async with api_client_connected(timeout=5) as client: + await client.device_info() + + +@pytest.mark.asyncio +async def test_api_zero_psk_provisioning_plaintext( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The legacy plaintext provisioning path still works and warns.""" + log_lines: list[str] = [] + async with run_compiled(yaml_config, line_callback=log_lines.append): + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-plaintext-test" + + assert await client.noise_encryption_set_key(NEW_KEY) is True + + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + # The deprecation warning was logged + assert any("deprecated" in line for line in log_lines) + + # The new key works; the zero PSK does not + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + assert (await client.device_info()).name == "zero-psk-plaintext-test" + + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() From 434cffb74531e70d82fef911996471aa3bf59299 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sun, 12 Jul 2026 15:58:17 -0500 Subject: [PATCH 2087/2214] [zwave_proxy] Fix parser gaps and harden frame and subscription handling (#17461) --- esphome/components/api/api_connection.cpp | 2 +- .../components/zwave_proxy/zwave_proxy.cpp | 125 ++++++++++++++---- esphome/components/zwave_proxy/zwave_proxy.h | 14 +- .../components/zwave_proxy/zwave_proxy.h | 2 +- 4 files changed, 109 insertions(+), 34 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2efdf0bc03..880b7cc404 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1383,7 +1383,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet #ifdef USE_ZWAVE_PROXY void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { - zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len); + zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len); } void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 8a24bd57d6..5f56861e6d 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -18,13 +18,22 @@ static const char *const TAG = "zwave_proxy"; static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168; static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; -// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] +// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...] +// We only read the home ID, so the node ID (1 byte in 8-bit mode, 2 bytes in 16-bit mode) and +// anything after it are not required to be present static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value -static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum +static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 7; // TYPE + CMD + HOME_ID(4) + checksum +static constexpr uint8_t ZWAVE_MIN_FRAME_LENGTH = 3; // TYPE + CMD + checksum (zero-payload frame) +static constexpr uint32_t ZWAVE_FRAME_TIMEOUT_MS = 1500; // Abandon a frame this long after its start (SOF) byte static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup static constexpr uint32_t RECONNECT_DELAY_MS = 500; // Delay between home ID query attempts after reconnect static constexpr uint8_t MAX_QUERY_RETRIES = 5; // Max attempts to query home ID after reconnect +static constexpr bool is_bootloader_menu_byte(uint8_t byte) { + // Bootloader menu output is printable ASCII plus CR/LF, ending with a NUL terminator + return byte == 0 || byte == '\r' || byte == '\n' || (byte >= 0x20 && byte <= 0x7E); +} + static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { // Calculate Z-Wave frame checksum // XOR all bytes between SOF and checksum position (exclusive) @@ -74,6 +83,11 @@ bool ZWaveProxy::can_proceed() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) { ESP_LOGW(TAG, "Timeout reading Home ID during setup"); + // The modem may simply still be booting; keep querying from loop() using the same retry + // machinery as a reconnect. This adds no setup delay — clients are notified of the home ID + // via the HOME_ID_CHANGE message whenever it finally arrives. + this->reconnect_time_ = now; + this->query_retries_ = 0; return true; // Proceed anyway after timeout } @@ -98,7 +112,18 @@ void ZWaveProxy::loop() { } this->process_uart_(); - this->status_clear_warning(); + + // Abandon a stalled frame reception. The Z-Wave API specification requires a receiver to abort + // a data frame reception lasting more than 1500 ms after the SOF byte, without sending a NAK. + // Without this, the stale bytes would silently corrupt the next frame. Any SEND_* state was + // already resolved by response_handler_() above, so a state other than WAIT_START here always + // means we are mid-frame. + if (this->parsing_state_ != ZWAVE_PARSING_STATE_WAIT_START && + App.get_loop_component_start_time() - this->frame_start_time_ > ZWAVE_FRAME_TIMEOUT_MS) { + ESP_LOGW(TAG, "Timeout waiting for frame data; resetting parser"); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->buffer_index_ = 0; + } } void ZWaveProxy::process_uart_slow_() { @@ -112,19 +137,24 @@ void ZWaveProxy::process_uart_slow_() { } if (this->parse_byte_(byte)) { // Check if this is a GET_NETWORK_IDS response frame - // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] + // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...] + // Bootloader output is excluded up front: a completed bootloader "frame" is menu text, so + // buffer_[1..3] would be meaningless (and possibly never written). Outside bootloader mode, + // the parser guarantees a completed frame starts with SOF, so buffer_[0] needs no check. // We verify: - // - buffer_[0]: Start of frame marker (0x01) - // - buffer_[1]: Length field must be >= 9 to contain all required data + // - buffer_[1]: Length field must be >= 7 so the frame contains the full home ID // - buffer_[2]: Command type (0x01 for response) // - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS) - if (this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS && this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && - this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) { + if (!this->in_bootloader_ && this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && + this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS) { // Store the 4-byte Home ID, which starts at offset 4, and notify connected clients if it changed // The frame parser has already validated the checksum and ensured all bytes are present if (this->set_home_id_(&this->buffer_[4])) { + char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; + ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); this->send_homeid_changed_msg_(); } + this->home_id_ready_ = true; } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { @@ -140,14 +170,19 @@ void ZWaveProxy::process_uart_slow_() { } } } while (this->available()); + // Reaching here means every read succeeded, so clear any earlier read-failure warning. + // (An early return on read failure skips this, leaving the warning visible until the + // next successful drain.) + this->status_clear_warning(); } void ZWaveProxy::dump_config() { char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; - ESP_LOGCONFIG(TAG, - "Z-Wave Proxy:\n" - " Home ID: %s", - format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); + ESP_LOGCONFIG( + TAG, + "Z-Wave Proxy:\n" + " Home ID: %s", + this->home_id_ready_ ? format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size()) : "unknown"); } void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { @@ -160,10 +195,20 @@ void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) { switch (type) { case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE: - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + if (this->api_connection_ == api_connection) { + ESP_LOGV(TAG, "API connection is already subscribed"); return; } + if (this->api_connection_ != nullptr) { + // A living subscriber keeps exclusive access. Its connection may be dead without + // loop() having noticed yet (e.g. the client crashed and reconnected quickly); + // in that case let the new client take over instead of locking it out. + if (this->api_connection_->is_connection_setup()) { + ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + return; + } + ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + } this->api_connection_ = api_connection; ESP_LOGV(TAG, "API connection is now subscribed"); break; @@ -222,6 +267,7 @@ void ZWaveProxy::retry_home_id_query_() { void ZWaveProxy::clear_home_id_() { static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {}; if (this->set_home_id_(ZERO_HOME_ID)) { + ESP_LOGV(TAG, "Home ID cleared"); this->send_homeid_changed_msg_(); } this->home_id_ready_ = false; @@ -237,13 +283,20 @@ bool ZWaveProxy::set_home_id_(const uint8_t *new_home_id) { return false; // No change } std::memcpy(this->home_id_.data(), new_home_id, this->home_id_.size()); - char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; - ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); - this->home_id_ready_ = true; return true; // Home ID was changed } -void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { +void ZWaveProxy::send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) { + // Only the subscribed client may talk to the Z-Wave module; a frame from any other + // (authenticated but unsubscribed) client would interleave with the subscriber's traffic + if (api_connection != this->api_connection_) { + ESP_LOGW(TAG, "Ignoring frame from unsubscribed client"); + return; + } + this->send_frame_(data, length); +} + +void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) { // Safety: validate pointer before any access if (data == nullptr) { ESP_LOGE(TAG, "Null data pointer"); @@ -289,7 +342,7 @@ void ZWaveProxy::send_simple_command_(const uint8_t command_id) { // Where LENGTH=0x03 (3 bytes: TYPE + CMD + CHECKSUM) uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00}; cmd[4] = calculate_frame_checksum(cmd, sizeof(cmd)); - this->send_frame(cmd, sizeof(cmd)); + this->send_frame_(cmd, sizeof(cmd)); } bool ZWaveProxy::parse_byte_(uint8_t byte) { @@ -300,9 +353,12 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { this->parse_start_(byte); break; case ZWAVE_PARSING_STATE_WAIT_LENGTH: - if (!byte) { + if (byte < ZWAVE_MIN_FRAME_LENGTH) { ESP_LOGW(TAG, "Invalid LENGTH: %u", byte); this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; + // Send the NAK now; otherwise any bytes already buffered behind this one would be + // silently discarded by the SEND_NAK case below until the next loop() iteration + this->response_handler_(); return false; } ESP_LOGVV(TAG, "Received LENGTH: %u", byte); @@ -319,7 +375,9 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID: this->buffer_[this->buffer_index_++] = byte; ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte); - this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD; + // A zero-payload frame (LENGTH == 3) has its checksum immediately after the command ID + this->parsing_state_ = this->buffer_index_ >= this->end_frame_after_ ? ZWAVE_PARSING_STATE_WAIT_CHECKSUM + : ZWAVE_PARSING_STATE_WAIT_PAYLOAD; break; case ZWAVE_PARSING_STATE_WAIT_PAYLOAD: this->buffer_[this->buffer_index_++] = byte; @@ -347,12 +405,24 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { break; } case ZWAVE_PARSING_STATE_READ_BL_MENU: - if (this->buffer_index_ >= this->buffer_.size()) { + // This state is tentative (see parse_start_): bootloader mode is committed only when a + // plausible menu — printable text ending in a NUL terminator — completes. A byte that + // cannot be menu text means the 0x0D that started this state was not a menu after all, + // so re-parse that byte as a frame start; it may be the SOF/ACK/NAK of real traffic. + if (this->buffer_index_ >= this->buffer_.size() || !is_bootloader_menu_byte(byte)) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->parse_start_(byte); break; } this->buffer_[this->buffer_index_++] = byte; if (!byte) { + if (!this->in_bootloader_) { + ESP_LOGD(TAG, "Entered bootloader mode"); + this->in_bootloader_ = true; + // Reset response deduplication: in bootloader mode, single-byte client writes (XMODEM + // ACK/NAK/CAN) are raw data and must never be suppressed as duplicate responses + this->last_response_ = 0; + } this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; frame_completed = true; } @@ -378,15 +448,16 @@ void ZWaveProxy::parse_start_(uint8_t byte) { ESP_LOGD(TAG, "Exited bootloader mode"); this->in_bootloader_ = false; } + this->frame_start_time_ = App.get_loop_component_start_time(); this->buffer_[this->buffer_index_++] = byte; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH; return; case ZWAVE_FRAME_TYPE_BL_MENU: ESP_LOGV(TAG, "Received BL_MENU"); - if (!this->in_bootloader_) { - ESP_LOGD(TAG, "Entered bootloader mode"); - this->in_bootloader_ = true; - } + // Read the menu tentatively: a stray 0x0D can equally appear in garbled data after the + // parser loses frame alignment, so bootloader mode is only committed once a plausible + // menu completes (see READ_BL_MENU handling in parse_byte_) + this->frame_start_time_ = App.get_loop_component_start_time(); this->buffer_[this->buffer_index_++] = byte; this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU; return; @@ -403,7 +474,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { ESP_LOGV(TAG, "Received CAN"); break; default: - ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte); + ESP_LOGV(TAG, "Unrecognized START: 0x%02X", byte); return; } // Forward response (ACK/NAK/CAN) back to client for processing diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index ec52b15cd9..cb60139ef8 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -68,13 +68,16 @@ class ZWaveProxy final : public uart::UARTDevice, public Component { return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]); } - void send_frame(const uint8_t *data, size_t length); + // Send a frame from an API client to the Z-Wave module. Frames from any connection other + // than the currently subscribed one are ignored. + void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length); protected: - bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. - void clear_home_id_(); // Clear home ID and notify API clients - void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions - void retry_home_id_query_(); // Retry home ID query after reconnect + void send_frame_(const uint8_t *data, size_t length); // Write a frame to the Z-Wave module + bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. + void clear_home_id_(); // Clear home ID and notify API clients + void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions + void retry_home_id_query_(); // Retry home ID query after reconnect void send_homeid_changed_msg_(api::APIConnection *conn = nullptr); void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) @@ -114,6 +117,7 @@ class ZWaveProxy final : public uart::UARTDevice, public Component { api::APIConnection *api_connection_{nullptr}; // Current subscribed client uint32_t setup_time_{0}; // Time when setup() was called uint32_t reconnect_time_{0}; // Timestamp of reconnect detection (0 = no pending query) + uint32_t frame_start_time_{0}; // Timestamp of the current frame's start byte (reception timeout) // Small values (grouped by size to minimize padding) uint16_t buffer_index_{0}; // Index for populating the data buffer diff --git a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h index ba97e81236..b4ccd8fd00 100644 --- a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h @@ -16,7 +16,7 @@ class ZWaveProxy { public: api::APIConnection *get_api_connection() { return nullptr; } void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {} - void send_frame(const uint8_t *data, size_t length) {} + void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {} void api_connection_authenticated(api::APIConnection *conn) {} uint32_t get_feature_flags() const { return 0; } uint32_t get_home_id() { return 0; } From 196b979df87a707d99da1ddbd94b93533adcb37f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:04:42 -0400 Subject: [PATCH 2088/2214] [usb_uart] Fix output chunk length truncated to 8 bits (#17480) --- esphome/components/usb_uart/usb_uart.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 482b209a3f..c289625f1a 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -160,7 +160,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { } uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE); memcpy(chunk->data, data, chunk_len); - chunk->length = static_cast(chunk_len); + chunk->length = chunk_len; // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. this->output_queue_.push(chunk); From 020a6a8fd111e92a068b05b70f3addee3f5d1fa8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:27:05 -0400 Subject: [PATCH 2089/2214] [mcp4461] Fix wiper increment/decrement write length (#17487) --- esphome/components/mcp4461/mcp4461.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 4573553664..e83a6847d6 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -342,7 +342,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::INCREMENT); - auto err = this->write(&this->address_, reg); + auto err = this->write(®, 1); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); @@ -373,7 +373,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::DECREMENT); - auto err = this->write(&this->address_, reg); + auto err = this->write(®, 1); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); From 284fe85271db003701c3e3899a4cb851fd667c83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:53:42 +0000 Subject: [PATCH 2090/2214] Bump aioesphomeapi from 45.5.2 to 45.6.0 (#17490) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8b028554a8..b36e70ef5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.5.2 +aioesphomeapi==45.6.0 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From 8518d0633b5acc32e4a4c2b0c045c4ded0ba6de0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:51:04 -0400 Subject: [PATCH 2091/2214] [web_server] Serialize entity state strings without a copy buffer (#17488) --- esphome/components/web_server/web_server.cpp | 52 +++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index c8f66755bc..3f4d598d48 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -56,9 +56,8 @@ namespace esphome::web_server { static const char *const TAG = "web_server"; -// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up) -static constexpr size_t PSTR_LOCAL_SIZE = 18; -#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1) +// View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. +static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): @@ -578,9 +577,9 @@ static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix root[ESPHOME_F("value")] = value; } -template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const char *state, - const T &value, JsonDetail start_config) { +template +static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, + JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; } @@ -1073,8 +1072,7 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - char buf[PSTR_LOCAL_SIZE]; - root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root[ESPHOME_F("position")] = obj->position; @@ -1530,17 +1528,16 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json const auto traits = obj->get_traits(); int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); - char buf[PSTR_LOCAL_SIZE]; char temp_buf[VALUE_ACCURACY_MAX_LEN]; if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) - opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); + opt.add(json_state_str(climate::climate_mode_to_string(m))); if (traits.get_supports_fan_modes()) { JsonArray opt = root[ESPHOME_F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) - opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); + opt.add(json_state_str(climate::climate_fan_mode_to_string(m))); } if (!traits.get_supported_custom_fan_modes().empty()) { @@ -1551,12 +1548,12 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json if (traits.get_supports_swing_modes()) { JsonArray opt = root[ESPHOME_F("swing_modes")].to(); for (auto swing_mode : traits.get_supported_swing_modes()) - opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); + opt.add(json_state_str(climate::climate_swing_mode_to_string(swing_mode))); } if (traits.get_supports_presets()) { JsonArray opt = root[ESPHOME_F("presets")].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) - opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); + opt.add(json_state_str(climate::climate_preset_to_string(m))); } if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); @@ -1572,26 +1569,26 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json } bool has_state = false; - root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root[ESPHOME_F("mode")] = json_state_str(climate_mode_to_string(obj->mode)); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { - root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root[ESPHOME_F("action")] = json_state_str(climate_action_to_string(obj->action)); root[ESPHOME_F("state")] = root[ESPHOME_F("action")]; has_state = true; } if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); } if (traits.get_supports_presets() && obj->preset.has_value()) { - root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); } if (traits.get_supports_swing_modes()) { - root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode)); } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { root[ESPHOME_F("current_temperature")] = @@ -1695,8 +1692,7 @@ json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockSta json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config); + set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1777,8 +1773,7 @@ json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - char buf[PSTR_LOCAL_SIZE]; - root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root[ESPHOME_F("position")] = obj->position; @@ -1863,9 +1858,8 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)), - value, start_config); + set_json_icon_state_value(root, obj, "alarm-control-panel", + json_state_str(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1937,10 +1931,9 @@ json::SerializationBuffer<> WebServer::water_heater_all_json_generator(WebServer json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) { json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; const auto mode = obj->get_mode(); - const char *mode_s = PSTR_LOCAL(water_heater::water_heater_mode_to_string(mode)); + ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode)); set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); @@ -1949,7 +1942,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea if (start_config == DETAIL_ALL) { JsonArray modes = root[ESPHOME_F("modes")].to(); for (auto m : traits.get_supported_modes()) - modes.add(PSTR_LOCAL(water_heater::water_heater_mode_to_string(m))); + modes.add(json_state_str(water_heater::water_heater_mode_to_string(m))); root[ESPHOME_F("min_temp")] = traits.get_min_temperature(); root[ESPHOME_F("max_temp")] = traits.get_max_temperature(); root[ESPHOME_F("step")] = traits.get_target_temperature_step(); @@ -2277,8 +2270,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)), + set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), obj->update_info.latest_version, start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; From 050a0064592b70ca7e1ed647d3b5375b5d4d3a2f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:36:42 -1000 Subject: [PATCH 2092/2214] Bump bundled esphome-device-builder to 1.4.0 (#17495) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index db2e01742c..e0b44fb7b6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.3.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.0 RUN \ platformio settings set enable_telemetry No \ From 262ee421f6c42ce6a03e30cd30210e122f6a32e3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:30:19 -1000 Subject: [PATCH 2093/2214] Bump bundled esphome-device-builder to 1.4.1 (#17507) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e0b44fb7b6..e7f8fceb12 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.4.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.1 RUN \ platformio settings set enable_telemetry No \ From f0afd9e660c940dc48c9732d6789a10b545e8b30 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:06:40 +1000 Subject: [PATCH 2094/2214] [mipi][mipi_spi][mipi_dsi][mipi_rgb] Transform cleanup (#17405) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 73 ++++++++-- esphome/components/mipi_dsi/display.py | 22 +-- .../components/mipi_dsi/models/__init__.py | 14 ++ esphome/components/mipi_dsi/models/guition.py | 12 +- esphome/components/mipi_dsi/models/m5stack.py | 9 +- esphome/components/mipi_dsi/models/seeed.py | 7 +- .../components/mipi_dsi/models/waveshare.py | 22 +-- esphome/components/mipi_rgb/display.py | 31 ++-- .../components/mipi_rgb/models/__init__.py | 14 ++ esphome/components/mipi_rgb/models/guition.py | 1 + esphome/components/mipi_rgb/models/lilygo.py | 6 +- esphome/components/mipi_rgb/models/rpi.py | 6 +- esphome/components/mipi_rgb/models/st7701s.py | 18 +-- esphome/components/mipi_rgb/models/sunton.py | 8 +- .../components/mipi_rgb/models/waveshare.py | 10 +- esphome/components/mipi_spi/display.py | 15 +- .../components/mipi_spi/models/adafruit.py | 2 + esphome/components/mipi_spi/models/amoled.py | 7 +- esphome/components/mipi_spi/models/ili.py | 3 + esphome/components/mipi_spi/models/jc.py | 14 +- esphome/components/mipi_spi/models/lanbon.py | 1 + esphome/components/mipi_spi/models/lilygo.py | 3 + esphome/components/mipi_spi/models/m5stack.py | 2 + .../components/mipi_spi/models/waveshare.py | 9 +- esphome/core/__init__.py | 4 + .../config/animation_platform_test.yaml | 3 + .../animation/config/animation_test.yaml | 3 + .../image/config/image_test.yaml | 3 + .../mipi_dsi/test_mipi_dsi_config.py | 39 +++++ tests/component_tests/mipi_rgb/__init__.py | 0 tests/component_tests/mipi_rgb/test_init.py | 89 ++++++++++++ .../mipi_rgb/test_mipi_rgb_config.py | 137 ++++++++++++++++++ .../mipi_spi/test_display_metadata.py | 84 ++++++++++- .../mipi_spi/test_final_validate.py | 77 ++++++++++ tests/component_tests/mipi_spi/test_init.py | 2 +- .../mipi_spi/test_padding_and_offsets.py | 4 + .../config/online_image_platform_test.yaml | 3 + .../config/online_image_test.yaml | 3 + 38 files changed, 630 insertions(+), 130 deletions(-) create mode 100644 esphome/components/mipi_rgb/models/__init__.py create mode 100644 tests/component_tests/mipi_rgb/__init__.py create mode 100644 tests/component_tests/mipi_rgb/test_init.py create mode 100644 tests/component_tests/mipi_rgb/test_mipi_rgb_config.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 1d6c8277e8..ab59d5ce5f 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -31,11 +31,16 @@ from esphome.const import ( CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.core import TimePeriod +from esphome.core import CORE, TimePeriod from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor LOGGER = cv.logging.getLogger(__name__) +CONF_TRANSFORMS = "transforms" + +# All axis transforms a model may support, in the order they appear in the schema. +ALL_TRANSFORMS = (CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY) + ColorOrder = display_ns.enum("ColorMode") NOP = 0x00 @@ -302,7 +307,8 @@ class DriverChip: """ A class representing a MIPI DBI driver chip model. The parameters supplied as defaults will be used to provide default values for the display configuration. - Setting swap_xy to cv.UNDEFINED will indicate that the model does not support swapping X and Y axes. + Pass a ``transforms`` set to restrict which axis transforms (mirror_x, mirror_y, swap_xy) the model + supports; by default all three are available. """ models: dict[str, Self] = {} @@ -387,11 +393,15 @@ class DriverChip: """ Return the available transforms for this model. """ + if (transforms := self.get_default(CONF_TRANSFORMS, None)) is not None: + return transforms if self.get_default("no_transform", False): return set() if self.get_default(CONF_SWAP_XY) != cv.UNDEFINED: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} - return {CONF_MIRROR_X, CONF_MIRROR_Y} + raise ValueError( + "Setting 'swap_xy' to 'cv.UNDEFINED' is no longer supported; set 'transforms' instead" + ) def has_hardware_transform(self, config) -> bool: """ @@ -533,17 +543,31 @@ class DriverChip: transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform - def swap_xy_schema(self): - uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED + def transform_schema(self): + """ + Build the schema for the ``transform`` config option of this model. - def validator(value): - if value: - raise cv.Invalid("Axis swapping not supported by this model") - return cv.boolean(value) + Each transform the model supports is a required boolean. A transform the model does not + support may be omitted or set to ``false``; setting it to ``true`` reports a clear error + naming the unsupported transform instead of a generic "extra keys not allowed". + """ + supported = self.transforms - if uses_swap: - return {cv.Required(CONF_SWAP_XY): cv.boolean} - return {cv.Optional(CONF_SWAP_XY, default=False): validator} + def unsupported(name): + def validator(value): + if cv.boolean(value): + raise cv.Invalid(f"'{name}' is not supported by this model") + return False + + return validator + + schema = {} + for name in ALL_TRANSFORMS: + if name in supported: + schema[cv.Required(name)] = cv.boolean + else: + schema[cv.Optional(name, default=False)] = unsupported(name) + return cv.Any(cv.Schema(schema), cv.one_of(CONF_DISABLED, lower=True)) def get_madctl(self, transform: dict, config: dict) -> int: """ @@ -618,6 +642,31 @@ class DriverChip: # or the delay flag inserted where needed return flatten_sequence(sequence) + def check_requirements(self) -> None: + """ + Raise a friendly error if any component this model requires is not configured. + + This runs during schema validation (before ID references are resolved) so that a + model whose default pins live on a pin expander reports the missing expander clearly + instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + """ + requirements = self.get_default("requires", set()) + if not requirements: + return + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) + def requires_buffer(config) -> bool: """ diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 896140b4b1..e5bb3d413d 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -41,24 +41,21 @@ from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, CONF_COLOR_ORDER, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, - CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, ) from esphome.final_validate import full_config from . import mipi_dsi_ns, models +from .models import DsiDriverChip # Currently only ESP32-P4 is supported, so esp_ldo and psram are required DEPENDENCIES = ["esp32", "esp_ldo", "psram"] @@ -73,7 +70,7 @@ ColorBitness = display.display_ns.enum("ColorBitness") CONF_LANE_BIT_RATE = "lane_bit_rate" CONF_LANES = "lanes" -DriverChip("CUSTOM") +DsiDriverChip("CUSTOM") # Import all models dynamically from the models package @@ -90,19 +87,7 @@ COLOR_DEPTHS = { def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - model.defaults[CONF_SWAP_XY] = cv.UNDEFINED - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - cv.Optional(CONF_SWAP_XY): cv.invalid( - "Axis swapping not supported by DSI displays" - ), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) @@ -172,6 +157,7 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] + model.check_requirements() width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) ) diff --git a/esphome/components/mipi_dsi/models/__init__.py b/esphome/components/mipi_dsi/models/__init__.py index e69de29bb2..3f7f8370a3 100644 --- a/esphome/components/mipi_dsi/models/__init__.py +++ b/esphome/components/mipi_dsi/models/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.mipi import DriverChip +from esphome.const import CONF_SWAP_XY + + +class DsiDriverChip(DriverChip): + """A driver chip for MIPI DSI displays.""" + + @property + def transforms(self) -> set[str]: + """ + Return the set of transformations supported by this driver chip. + DSI displays do not support axis swapping, so this method removes CONF_SWAP_XY + """ + return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index db13c7f6cc..31a2b0ce1a 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -1,8 +1,7 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off -DriverChip( +DsiDriverChip( "JC1060P470", width=1024, height=600, @@ -14,7 +13,6 @@ DriverChip( vsync_front_porch=12, pclk_frequency="54MHz", lane_bit_rate="750Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00), @@ -46,7 +44,7 @@ DriverChip( # * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42) # * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166) # ---------------------------------------------------------------------------------------------------------------------- -DriverChip( +DsiDriverChip( "JC4880P443", width=480, height=800, @@ -58,7 +56,6 @@ DriverChip( vsync_front_porch=166, pclk_frequency="34MHz", lane_bit_rate="500Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=5, initsequence=[ @@ -111,7 +108,7 @@ DriverChip( # * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40) # * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=8, vsync_front_porch=20) # ---------------------------------------------------------------------------------------------------------------------- -DriverChip( +DsiDriverChip( "JC8012P4A1", width=800, height=1280, @@ -123,7 +120,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="60MHz", lane_bit_rate="1Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=27, initsequence=[ diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 53fac9b534..b947b9ac8a 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -1,8 +1,7 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off -DriverChip( +DsiDriverChip( "M5STACK-TAB5", height=1280, width=720, @@ -14,7 +13,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="60MHz", lane_bit_rate="730Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xFF, 0x98, 0x81, 0x01), # Select Page 1 @@ -56,7 +54,7 @@ DriverChip( ], ) -DriverChip( +DsiDriverChip( "M5STACK-TAB5-V2", height=1280, width=720, @@ -68,7 +66,6 @@ DriverChip( vsync_front_porch=220, pclk_frequency="80MHz", lane_bit_rate="960Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0x01,), diff --git a/esphome/components/mipi_dsi/models/seeed.py b/esphome/components/mipi_dsi/models/seeed.py index 290b0e07ee..84593b40e6 100644 --- a/esphome/components/mipi_dsi/models/seeed.py +++ b/esphome/components/mipi_dsi/models/seeed.py @@ -1,9 +1,8 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # Standalone display # Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html -DriverChip( +DsiDriverChip( "SEEED-RETERMINAL-D1001", height=1280, width=800, @@ -15,10 +14,10 @@ DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}], reset_pin={"xl9535": None, "number": 2}, + requires={"psram", "xl9535"}, initsequence=( (0xE0, 0x00), (0xE1, 0x93), diff --git a/esphome/components/mipi_dsi/models/waveshare.py b/esphome/components/mipi_dsi/models/waveshare.py index c97a0bbd02..a1702fb6a1 100644 --- a/esphome/components/mipi_dsi/models/waveshare.py +++ b/esphome/components/mipi_dsi/models/waveshare.py @@ -1,12 +1,11 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1 # Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage -JD9365_10_1_DSI_TOUCH_A = DriverChip( +JD9365_10_1_DSI_TOUCH_A = DsiDriverChip( "WAVESHARE-P4-NANO-10.1", height=1280, width=800, @@ -18,7 +17,6 @@ JD9365_10_1_DSI_TOUCH_A = DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -65,7 +63,7 @@ JD9365_10_1_DSI_TOUCH_A.extend( # Source for parameters and initsequence: # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703 # Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO -DriverChip( +DsiDriverChip( "WAVESHARE-P4-86-PANEL", height=720, width=720, @@ -77,7 +75,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="38MHz", lane_bit_rate="480Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=27, initsequence=[ @@ -109,7 +106,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B -DriverChip( +DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B", height=600, width=1024, @@ -139,7 +136,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C -JD9365_3_4_DSI_TOUCH_C = DriverChip( +JD9365_3_4_DSI_TOUCH_C = DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C", height=800, width=800, @@ -151,7 +148,6 @@ JD9365_3_4_DSI_TOUCH_C = DriverChip( vsync_front_porch=24, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -197,7 +193,7 @@ JD9365_3_4_DSI_TOUCH_C.extend( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C -JD9365_4_DSI_TOUCH_C = DriverChip( +JD9365_4_DSI_TOUCH_C = DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C", height=720, width=720, @@ -209,7 +205,6 @@ JD9365_4_DSI_TOUCH_C = DriverChip( vsync_front_porch=24, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -255,7 +250,7 @@ JD9365_4_DSI_TOUCH_C.extend( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A -DriverChip( +DsiDriverChip( "WAVESHARE-8-DSI-TOUCH-A", height=1280, width=800, @@ -267,7 +262,6 @@ DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -304,7 +298,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c # Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A -DriverChip( +DsiDriverChip( "WAVESHARE-7-DSI-TOUCH-A", height=1280, width=720, diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 1eacc31fc5..ebe930d37a 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -18,6 +18,8 @@ from esphome.components.mipi import ( CONF_HSYNC_BACK_PORCH, CONF_HSYNC_FRONT_PORCH, CONF_HSYNC_PULSE_WIDTH, + CONF_PCLK_FREQUENCY, + CONF_PCLK_INVERTED, CONF_PCLK_PIN, CONF_PIXEL_MODE, CONF_USE_AXIS_FLIPS, @@ -34,9 +36,11 @@ from esphome.components.mipi import ( power_of_two, requires_buffer, ) -from esphome.components.rpi_dpi_rgb.display import ( - CONF_PCLK_FREQUENCY, - CONF_PCLK_INVERTED, +from esphome.components.spi import ( + CONF_SPI_MODE, + SPI_DATA_RATE_SCHEMA, + SPI_MODE_OPTIONS, + SPIComponent, ) import esphome.config_validation as cv from esphome.const import ( @@ -48,7 +52,6 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_GREEN, CONF_HSYNC_PIN, @@ -57,8 +60,6 @@ from esphome.const import ( CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_NUMBER, CONF_RED, @@ -72,10 +73,10 @@ from esphome.const import ( ) from esphome.final_validate import full_config -from ..spi import CONF_SPI_MODE, SPI_DATA_RATE_SCHEMA, SPI_MODE_OPTIONS, SPIComponent from . import models +from .models import RgbDriverChip -DEPENDENCIES = ["esp32", "psram"] +DEPENDENCIES = ["esp32"] mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb") mipi_rgb = mipi_rgb_ns.class_("MipiRgb", display.Display, cg.Component) @@ -86,7 +87,7 @@ ColorOrder = display.display_ns.enum("ColorMode") DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -DriverChip("CUSTOM") +RgbDriverChip("CUSTOM") # Import all models dynamically from the models package @@ -120,16 +121,7 @@ def data_pin_set(length): def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **model.swap_xy_schema(), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # RPI model does not use an init sequence, indicates with empty list if model.initsequence is None: # Custom model requires an init sequence @@ -235,6 +227,7 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] + model.check_requirements() width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) ) diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py new file mode 100644 index 0000000000..9e3fe2a476 --- /dev/null +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.mipi import DriverChip +from esphome.const import CONF_SWAP_XY + + +class RgbDriverChip(DriverChip): + """A driver chip for MIPI RGB displays.""" + + @property + def transforms(self) -> set[str]: + """ + Return the set of transformations supported by this driver chip. + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + """ + return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_rgb/models/guition.py b/esphome/components/mipi_rgb/models/guition.py index 915b8beda0..c0aaf0a3d2 100644 --- a/esphome/components/mipi_rgb/models/guition.py +++ b/esphome/components/mipi_rgb/models/guition.py @@ -5,6 +5,7 @@ st7701s.extend( width=480, height=480, data_rate="2MHz", + requires={"psram"}, cs_pin=39, de_pin=18, hsync_pin=16, diff --git a/esphome/components/mipi_rgb/models/lilygo.py b/esphome/components/mipi_rgb/models/lilygo.py index c0e91cd8ae..4e0615b439 100644 --- a/esphome/components/mipi_rgb/models/lilygo.py +++ b/esphome/components/mipi_rgb/models/lilygo.py @@ -1,5 +1,3 @@ -from esphome.config_validation import UNDEFINED - from .st7701s import ST7701S # fmt: off @@ -8,10 +6,10 @@ ST7701S( width=480, height=480, invert_colors=False, - swap_xy=UNDEFINED, spi_mode="MODE3", cs_pin={"xl9535": None, "number": 17}, reset_pin={"xl9535": None, "number": 5}, + requires={"psram", "xl9535"}, hsync_pin=39, vsync_pin=40, pclk_pin=41, @@ -57,9 +55,9 @@ t_rgb = ST7701S( height=480, pixel_mode="18bit", invert_colors=False, - swap_xy=UNDEFINED, spi_mode="MODE3", cs_pin={"xl9535": None, "number": 3}, + requires={"psram", "xl9535"}, de_pin=45, hsync_pin=47, vsync_pin=41, diff --git a/esphome/components/mipi_rgb/models/rpi.py b/esphome/components/mipi_rgb/models/rpi.py index 076d96b658..1e2a6600ee 100644 --- a/esphome/components/mipi_rgb/models/rpi.py +++ b/esphome/components/mipi_rgb/models/rpi.py @@ -1,9 +1,7 @@ -from esphome.components.mipi import DriverChip -from esphome.config_validation import UNDEFINED +from . import RgbDriverChip # A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence -DriverChip( +RgbDriverChip( "RPI", - swap_xy=UNDEFINED, initsequence=(), ) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index 990a1ca4f3..a20e9d1c01 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -1,17 +1,12 @@ -from esphome.components.mipi import ( - MADCTL, - MADCTL_ML, - MADCTL_XFLIP, - MODE_BGR, - DriverChip, -) -from esphome.config_validation import UNDEFINED +from esphome.components.mipi import MADCTL, MADCTL_ML, MADCTL_XFLIP, MODE_BGR from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y +from . import RgbDriverChip + SDIR_CMD = 0xC7 -class ST7701S(DriverChip): +class ST7701S(RgbDriverChip): # The ST7701s does not use the standard MADCTL bits for x/y mirroring def add_madctl(self, sequence: list, config: dict): transform = self.get_transform(config) @@ -45,7 +40,6 @@ st7701s = ST7701S( "ST7701S", width=480, height=864, - swap_xy=UNDEFINED, hsync_front_porch=20, hsync_back_porch=10, hsync_pulse_width=10, @@ -85,6 +79,7 @@ st7701s.extend( height=480, invert_colors=True, pixel_mode="18bit", + requires={"psram"}, cs_pin=1, de_pin={ "number": 45, @@ -117,6 +112,7 @@ st7701s.extend( vsync_pulse_width=8, vsync_back_porch=20, cs_pin={"pca9554": None, "number": 4}, + requires={"psram", "pca9554"}, de_pin=18, hsync_pin=16, vsync_pin=17, @@ -134,6 +130,7 @@ st7701s.extend( width=480, height=480, pixel_mode="18bit", + requires={"psram"}, cs_pin=18, reset_pin=8, de_pin=17, @@ -177,6 +174,7 @@ st7701s.extend( width=480, height=480, pixel_mode="18bit", + requires={"psram"}, cs_pin=21, de_pin=39, vsync_pin=48, diff --git a/esphome/components/mipi_rgb/models/sunton.py b/esphome/components/mipi_rgb/models/sunton.py index a33625dfe4..a87d5f3c38 100644 --- a/esphome/components/mipi_rgb/models/sunton.py +++ b/esphome/components/mipi_rgb/models/sunton.py @@ -1,14 +1,13 @@ -from esphome.components.mipi import DriverChip -from esphome.config_validation import UNDEFINED +from . import RgbDriverChip # fmt: off -sunton = DriverChip( +sunton = RgbDriverChip( "ESP32-8048S070", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, pclk_frequency="12.5MHz", + requires={"psram"}, de_pin=41, hsync_pin=39, vsync_pin=40, @@ -28,7 +27,6 @@ sunton = DriverChip( sunton.extend( "ESP32-8048S050", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index cd1fc341ef..ef1a5cd2d6 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -1,18 +1,18 @@ -from esphome.components.mipi import DriverChip, delay -from esphome.config_validation import UNDEFINED +from esphome.components.mipi import delay +from . import RgbDriverChip from .st7701s import st7701s # fmt: off -wave_4_3 = DriverChip( +wave_4_3 = RgbDriverChip( "ESP32-S3-TOUCH-LCD-4.3", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, pclk_frequency="16MHz", reset_pin={"ch422g": None, "number": 3}, enable_pin={"ch422g": None, "number": 2}, + requires={"psram", "ch422g"}, de_pin=5, hsync_pin={"number": 46, "ignore_strapping_warning": True}, vsync_pin={"number": 3, "ignore_strapping_warning": True}, @@ -69,6 +69,7 @@ st7701s.extend( pclk_pin=41, pclk_frequency="12MHz", pclk_inverted=False, + requires={"psram"}, data_pins={ "red": [46, 3, 8, 18, 17], "green": [14, 13, 12, 11, 10, 9], @@ -80,6 +81,7 @@ st7701s.extend( "WAVESHARE-3.16-320X820", width=320, height=820, + requires={"psram"}, de_pin=40, hsync_pin=38, vsync_pin=39, diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 871736abd1..f472e12a76 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -41,14 +41,11 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -138,16 +135,7 @@ def denominator(config): def model_schema(config): model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **model.swap_xy_schema(), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) @@ -265,6 +253,7 @@ def customise_schema(config): extra=ALLOW_EXTRA, )(config) model = MODELS[config[CONF_MODEL]] + model.check_requirements() bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL) config = cv.Schema( { diff --git a/esphome/components/mipi_spi/models/adafruit.py b/esphome/components/mipi_spi/models/adafruit.py index 26790b1493..cc295487eb 100644 --- a/esphome/components/mipi_spi/models/adafruit.py +++ b/esphome/components/mipi_spi/models/adafruit.py @@ -13,6 +13,7 @@ ST7789V.extend( mirror_x=True, mirror_y=True, data_rate="80MHz", + requires={"psram"}, ) ST7789V.extend( @@ -25,4 +26,5 @@ ST7789V.extend( dc_pin=39, reset_pin=40, invert_colors=True, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 30e815d68e..8a869f2284 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -16,7 +16,7 @@ from esphome.components.mipi import ( delay, ) from esphome.components.spi import TYPE_QUAD -from esphome.config_validation import UNDEFINED +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y DriverChip( "T-DISPLAY-S3-AMOLED", @@ -29,6 +29,7 @@ DriverChip( brightness=0xD0, color_order=MODE_RGB, no_slpout=True, # SLPOUT is in the init sequence, early + requires={"psram"}, initsequence=(SLPOUT,), ) @@ -43,6 +44,7 @@ DriverChip( data_rate="40MHz", brightness=0xD0, color_order=MODE_RGB, + requires={"psram"}, initsequence=( (PAGESEL, 4), (0x6A, 0x00), @@ -90,6 +92,7 @@ T4_S3_AMOLED = RM690B0.extend( reset_pin=13, enable_pin=9, bus_mode=TYPE_QUAD, + requires={"psram"}, ) CO5300 = DriverChip( @@ -98,7 +101,7 @@ CO5300 = DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, no_slpout=True, - swap_xy=UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, width=480, height=480, initsequence=( diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 812e491c62..187fcfd8c0 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -314,6 +314,7 @@ DriverChip( data_rate="40MHz", dc_pin=4, cs_pin=5, + requires={"psram"}, # reset_pin={CONF_INVERTED: True, CONF_NUMBER: 48}, initsequence=( (0xEF, 0x03, 0x80, 0x02), @@ -379,6 +380,7 @@ DriverChip( cs_pin=5, dc_pin=4, reset_pin=48, + requires={"psram"}, initsequence=( (0xEF, 0x03, 0x80, 0x02), (0xCF, 0x00, 0xC1, 0x30), @@ -711,6 +713,7 @@ ST7796.extend( reset_pin=4, dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, + requires={"psram"}, ) ST7789V.extend( diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 854814f572..d24ca5db58 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -1,12 +1,16 @@ from esphome.components.mipi import MODE_RGB, DriverChip from esphome.components.spi import TYPE_QUAD -import esphome.config_validation as cv -from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER +from esphome.const import ( + CONF_IGNORE_STRAPPING_WARNING, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_NUMBER, +) AXS15231 = DriverChip( "AXS15231", draw_rounding=8, - swap_xy=cv.UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, color_order=MODE_RGB, bus_mode=TYPE_QUAD, initsequence=( @@ -22,6 +26,7 @@ AXS15231.extend( height=480, cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True}, data_rate="40MHz", + requires={"psram"}, ) DriverChip( @@ -36,6 +41,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x08), (0xF2, 0x08), @@ -267,6 +273,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x28), (0xF2, 0x28), @@ -495,6 +502,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="20MHz", + requires={"psram"}, initsequence=( (0xFF, 0xA5), (0x41, 0x03), diff --git a/esphome/components/mipi_spi/models/lanbon.py b/esphome/components/mipi_spi/models/lanbon.py index 8cec3c8317..1188300136 100644 --- a/esphome/components/mipi_spi/models/lanbon.py +++ b/esphome/components/mipi_spi/models/lanbon.py @@ -10,4 +10,5 @@ ST7789V.extend( cs_pin=22, dc_pin=21, reset_pin=18, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/lilygo.py b/esphome/components/mipi_spi/models/lilygo.py index 46ec809029..84f44a3dae 100644 --- a/esphome/components/mipi_spi/models/lilygo.py +++ b/esphome/components/mipi_spi/models/lilygo.py @@ -15,6 +15,7 @@ ST7789V.extend( dc_pin=13, reset_pin=9, data_rate="80MHz", + requires={"psram"}, ) ST7789V.extend( @@ -42,6 +43,7 @@ ST7789V.extend( enable_pin=[9, 15], data_rate="10MHz", bus_mode=TYPE_OCTAL, + requires={"psram"}, ) ST7796.extend( @@ -55,4 +57,5 @@ ST7796.extend( dc_pin=9, backlight_pin=48, invert_colors=True, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index 81bb186278..a54bd19d88 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -49,6 +49,7 @@ ILI9341.extend( invert_colors=True, pixel_mode="18bit", data_rate="40MHz", + requires={"psram"}, ) GC9107 = ST7789V.extend( @@ -68,4 +69,5 @@ GC9107.extend( reset_pin=48, dc_pin=42, cs_pin=14, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 8fc5b2acc5..0caae5b939 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -12,7 +12,7 @@ from esphome.components.mipi import ( PWSET, DriverChip, ) -import esphome.config_validation as cv +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y from .amoled import CO5300 from .ili import ILI9488_A, ST7789V @@ -155,7 +155,7 @@ ST7789P = DriverChip( ILI9488_A.extend( "PICO-RESTOUCH-LCD-3.5", - swap_xy=cv.UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, spi_16=True, pixel_mode="16bit", mirror_x=True, @@ -175,6 +175,7 @@ CO5300.extend( offset_width=6, cs_pin=12, reset_pin=39, + requires={"psram"}, ) # Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller) @@ -189,6 +190,7 @@ CO5300.extend( cs_pin=12, reset_pin=39, data_rate="40MHz", + requires={"psram"}, ) AXS15231.extend( @@ -198,6 +200,7 @@ AXS15231.extend( data_rate="80MHz", cs_pin=9, reset_pin=21, + requires={"psram"}, ) # Waveshare 1.83-v2 @@ -281,6 +284,7 @@ ST7789V.extend( offset_height=40, invert_colors=True, data_rate="40MHz", + requires={"psram"}, ) CO5300.extend( @@ -291,4 +295,5 @@ CO5300.extend( cs_pin=9, reset_pin=21, enable_pin=1, + requires={"psram"}, ) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 803ddba6b7..bfdd2de7c7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -574,6 +574,9 @@ class EsphomeCore: self.build_path: Path | None = None # The validated configuration, this is None until the config has been validated self.config: ConfigType | None = None + # The raw configuration as read from YAML (after packages/substitutions), + # available during validation before the config is fully validated + self.raw_config: ConfigType | None = None # YAML frontmatter loaded from user YAML files. Frontmatter is a leading # YAML document separated by `---` from the actual configuration. It is # ignored by config validation and code generation, but kept here so it @@ -650,6 +653,7 @@ class EsphomeCore: self.config_path = None self.build_path = None self.config = None + self.raw_config = None self.frontmatter = {} self.event_loop = _FakeEventLoop() self.task_counter = 0 diff --git a/tests/component_tests/animation/config/animation_platform_test.yaml b/tests/component_tests/animation/config/animation_platform_test.yaml index 380434dcc3..8de32ed593 100644 --- a/tests/component_tests/animation/config/animation_platform_test.yaml +++ b/tests/component_tests/animation/config/animation_platform_test.yaml @@ -24,6 +24,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/animation/config/animation_test.yaml b/tests/component_tests/animation/config/animation_test.yaml index 9d8fd15276..1fe6ddf9a4 100644 --- a/tests/component_tests/animation/config/animation_test.yaml +++ b/tests/component_tests/animation/config/animation_test.yaml @@ -19,6 +19,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/image/config/image_test.yaml b/tests/component_tests/image/config/image_test.yaml index c34e0993a5..31c29de21b 100644 --- a/tests/component_tests/image/config/image_test.yaml +++ b/tests/component_tests/image/config/image_test.yaml @@ -4,6 +4,9 @@ esphome: esp32: board: esp32s3box +psram: + mode: octal + image: defaults: type: rgb565 diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index c14abdb4fd..100366b135 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -71,6 +71,18 @@ def test_configuration_errors(set_core_config: SetCoreConfigCallable) -> None: } ) + # DSI displays cannot swap axes; enabling swap_xy reports a clear error. + with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"): + CONFIG_SCHEMA( + { + "model": "custom", + "init_sequence": [[0xA0, 0x01]], + "lane_bit_rate": "1.5Gbps", + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": True}, + } + ) + def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: """Test successful configuration validation.""" @@ -116,6 +128,33 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: CONFIG_SCHEMA(config) +def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None: + """A configured display rotation is recorded in the metadata. + + LVGL relies on this to flag a rotation set in the display config (see the + mipi_spi tests for the end-to-end LVGL rejection). + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4}, + ) + + from esphome.components.display import get_display_metadata + from esphome.components.mipi_dsi.display import CONFIG_SCHEMA + + base = { + "model": "custom", + "init_sequence": [[0xA0, 0x01]], + "lane_bit_rate": "1.5Gbps", + "dimensions": {"width": 320, "height": 240}, + } + config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90}) + assert get_display_metadata(config["id"]).rotation == 90 + + config = CONFIG_SCHEMA({**base, "id": "unrotated"}) + assert get_display_metadata(config["id"]).rotation == 0 + + def test_code_generation( generate_main: Callable[[str | Path], str], component_fixture_path: Callable[[str], Path], diff --git a/tests/component_tests/mipi_rgb/__init__.py b/tests/component_tests/mipi_rgb/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/mipi_rgb/test_init.py b/tests/component_tests/mipi_rgb/test_init.py new file mode 100644 index 0000000000..0ab6c022e6 --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_init.py @@ -0,0 +1,89 @@ +"""Tests for mipi_rgb configuration validation, in particular the per-model +``requires`` component check (see esphome.components.mipi.DriverChip.check_requirements).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3 +from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + +# Importing pca9554 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that +# models (e.g. SEEED-INDICATOR-D1) that reference pca9554-backed pins in their +# defaults can be validated by the mipi_rgb CONFIG_SCHEMA in this test. +import esphome.components.pca9554 # noqa: F401 +from esphome.const import PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def test_model_requires_psram(set_core_config: SetCoreConfigCallable) -> None: + """A model known to have PSRAM on its board rejects a config without it. + + RGB parallel displays always need a full framebuffer, so every model in this + component is expected to carry ``requires={"psram", ...}``. This board has no + other requirements, so its check is exercised in isolation here. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + CORE.raw_config = {} + + with pytest.raises( + cv.Invalid, + match=r"ESP32-8048S070 requires component 'psram' to be configured", + ): + _validated({"model": "ESP32-8048S070"}) + + +def test_model_requires_psram_satisfied( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """The same board model validates once PSRAM is configured.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + config = _validated({"model": "ESP32-8048S070"}) + assert config["model"] == "ESP32-8048S070" + + +def test_model_requires_psram_and_expander( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """A model that also depends on an I2C GPIO expander lists both when missing.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + # Only satisfy one of the two requirements. + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + with pytest.raises( + cv.Invalid, + match=r"SEEED-INDICATOR-D1 requires component 'pca9554' to be configured", + ): + _validated( + { + "model": "SEEED-INDICATOR-D1", + "spi_id": "spi_bus", + } + ) diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py new file mode 100644 index 0000000000..e85327c0ab --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -0,0 +1,137 @@ +"""Tests for mipi_rgb configuration validation.""" + +import pytest + +from esphome import config_validation as cv + +# Importing these registers their pin schemas with pins.PIN_SCHEMA_REGISTRY so that +# models referencing IO-expander-backed pins in their defaults (e.g. the LilyGO +# T-RGB boards via xl9535, SEEED-INDICATOR-D1 via pca9554, or the Waveshare panels +# via ch422g) can be validated by the mipi_rgb CONFIG_SCHEMA in this test. +import esphome.components.ch422g # noqa: F401 +from esphome.components.display import get_display_metadata +from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32S3 +import esphome.components.pca9554 # noqa: F401 +import esphome.components.xl9535 # noqa: F401 +from esphome.const import ( + CONF_BLUE, + CONF_DIMENSIONS, + CONF_GREEN, + CONF_HEIGHT, + CONF_INIT_SEQUENCE, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_RED, + CONF_SWAP_XY, + CONF_WIDTH, + KEY_VARIANT, + PlatformFramework, +) +from tests.component_tests.types import SetCoreConfigCallable + +# A generic set of data pins so that models without a default pin assignment +# (e.g. CUSTOM and RPI) still validate. +DATA_PINS = { + CONF_RED: [1, 2, 3, 4, 5], + CONF_GREEN: [6, 7, 8, 9, 10, 11], + CONF_BLUE: [12, 13, 14, 15, 16], +} + + +def _set_s3(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + +def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: + """Every predefined model validates once required defaults are supplied.""" + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + for name, model in MODELS.items(): + config = {"model": name, "data_pins": DATA_PINS, "pclk_pin": 21} + if model.initsequence is None: + config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]] + if not model.get_default(CONF_WIDTH): + config[CONF_DIMENSIONS] = {CONF_WIDTH: 480, CONF_HEIGHT: 480} + CONFIG_SCHEMA(config) + + +def test_transform_matches_model_support( + set_core_config: SetCoreConfigCallable, +) -> None: + """The transform schema only accepts the axes a model actually supports.""" + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # never support axis swapping). + model = MODELS["ESP32-8048S070"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) + + # An unsupported axis may be explicitly disabled (a harmless no-op)... + CONFIG_SCHEMA( + {**base, "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": False}} + ) + + # ...but enabling it reports a clear, model-specific error. + with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"): + CONFIG_SCHEMA( + { + **base, + "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": True}, + } + ) + + +def test_st7701s_only_supports_mirror_x( + set_core_config: SetCoreConfigCallable, +) -> None: + """ST7701S panels shorter than full height only expose mirror_x. + + mirror_y only works at full height (864px), so the LilyGO 480px panels must + reject a mirror_y transform. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["T-RGB-2.1"] + assert model.transforms == {CONF_MIRROR_X} + assert CONF_SWAP_XY not in model.transforms + + base = {"model": "T-RGB-2.1"} + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + with pytest.raises(cv.Invalid, match="'mirror_y' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) + + +def test_metadata_records_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A configured display rotation is recorded in the metadata. + + LVGL relies on this to flag a rotation set in the display config (see the + mipi_spi tests for the end-to-end LVGL rejection). + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90}) + assert get_display_metadata(config["id"]).rotation == 90 + + config = CONFIG_SCHEMA({**base, "id": "unrotated"}) + assert get_display_metadata(config["id"]).rotation == 0 diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py index e7f5143d91..06cc8ee09a 100644 --- a/tests/component_tests/mipi_spi/test_display_metadata.py +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -3,6 +3,9 @@ from collections.abc import Callable from pathlib import Path +import pytest + +from esphome import config_validation as cv from esphome.components.const import BYTE_ORDER_BIG from esphome.components.display import get_all_display_metadata, get_display_metadata from esphome.components.esp32 import ( @@ -13,6 +16,7 @@ from esphome.components.esp32 import ( ) from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import PlatformFramework +from esphome.core import ID from tests.component_tests.types import SetCoreConfigCallable @@ -23,6 +27,18 @@ def validated_config(config): return config +def _lvgl_config(display_id: str) -> dict: + """Build a minimal LVGL config dict referencing the given display id.""" + return { + "displays": [ID(display_id, True)], + "log_level": "WARN", + "color_depth": 16, + "transparency_key": 0x000400, + "draw_rounding": 2, + "buffer_size": 0, + } + + def test_metadata_native_quad_default_test_card( set_core_config: SetCoreConfigCallable, ) -> None: @@ -91,7 +107,7 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) - # JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only + # JC3248W535 has transforms={mirror_x, mirror_y} only config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"}) meta = get_display_metadata(config["id"]) assert meta is not None @@ -166,3 +182,69 @@ def test_metadata_via_code_generation_lvgl( assert meta.height == 160 assert meta.has_hardware_rotation is True assert meta.byte_order == BYTE_ORDER_BIG + + +def test_metadata_records_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A configured display rotation is recorded in the metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = CONFIG_SCHEMA( + {"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90} + ) + meta = get_display_metadata(config["id"]) + assert meta is not None + assert meta.rotation == 90 + + +def test_metadata_rotation_defaults_to_zero( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display without a rotation reports rotation 0 in its metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"}) + meta = get_display_metadata(config["id"]) + assert meta is not None + assert meta.rotation == 0 + + +def test_rotation_flagged_when_used_with_lvgl( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display with a rotation is rejected when driven by LVGL. + + LVGL manages its own rotation, so a rotation set in the display config must be + flagged and the user directed to configure it in the LVGL block instead. This + exercises the full chain: the mipi_spi schema records the rotation in the + display metadata, and LVGL's final validation reports it. + """ + from esphome.components.lvgl import final_validation + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90}) + with pytest.raises(cv.Invalid, match="rotation.*not compatible with LVGL"): + final_validation([_lvgl_config("rotated")]) + + +def test_no_rotation_accepted_with_lvgl( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display without a rotation validates cleanly when driven by LVGL.""" + from esphome.components.lvgl import final_validation + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"}) + # Should not raise. + final_validation([_lvgl_config("unrotated")]) diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py index 8c45b47752..77111ae867 100644 --- a/tests/component_tests/mipi_spi/test_final_validate.py +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -6,10 +6,13 @@ from typing import Any import pytest +from esphome import config_validation as cv from esphome.components.display import CONF_SHOW_TEST_CARD from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi import DriverChip from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.core import CORE from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -183,3 +186,77 @@ def test_buffer_size_selected_when_lvgl_with_test_card( ) assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) + + +def test_requires_missing_single_component_raises() -> None: + """A model that requires a single component raises when it is absent.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-PSRAM", requires={"psram"}) + + with pytest.raises( + cv.Invalid, + match=r"TEST-REQUIRES-PSRAM requires component 'psram' to be configured", + ): + chip.check_requirements() + + +def test_requires_missing_multiple_components_raises() -> None: + """A model that requires several components lists all the missing ones, pluralized.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-MULTI", requires={"psram", "pca9554"}) + + with pytest.raises( + cv.Invalid, + match=r"TEST-REQUIRES-MULTI requires components '.*' to be configured", + ) as excinfo: + chip.check_requirements() + assert "psram" in str(excinfo.value) + assert "pca9554" in str(excinfo.value) + + +def test_requires_satisfied_does_not_raise() -> None: + """No error is raised once all the required components are configured.""" + CORE.raw_config = {"psram": True, "pca9554": []} + chip = DriverChip("TEST-REQUIRES-SATISFIED", requires={"psram", "pca9554"}) + + chip.check_requirements() # Should not raise + + +def test_requires_absent_does_not_raise() -> None: + """Models without a requires set are unaffected by the check.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-NONE") + + chip.check_requirements() # Should not raise + + +def test_predefined_model_requires_psram( + set_core_config: SetCoreConfigCallable, +) -> None: + """A predefined board model known to have PSRAM rejects a config without it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CORE.raw_config = {} + + with pytest.raises( + cv.Invalid, match=r"S3BOX requires component 'psram' to be configured" + ): + _validated({"model": "s3box"}) + + +def test_predefined_model_requires_psram_satisfied( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """The same board model validates once PSRAM is configured.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + config = _validated({"model": "s3box"}) + assert config["model"] == "S3BOX" diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 8edbe095b7..dcecd89617 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -136,7 +136,7 @@ def test_dimension_validation( "model": "JC3248W535", "transform": {"mirror_x": False, "mirror_y": True, "swap_xy": True}, }, - "Axis swapping not supported by this model", + "'swap_xy' is not supported by this model", id="axis_swapping_not_supported", ), pytest.param( diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index 7ae6f0e61f..b2b421c1e2 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from pathlib import Path +from typing import Any import pytest @@ -222,6 +223,7 @@ class TestNewModelVariants: def test_m5core2_with_native_dimensions( self, set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], ) -> None: """Test M5CORE2 variant with reset native_width and native_height.""" set_core_config( @@ -231,6 +233,8 @@ class TestNewModelVariants: KEY_VARIANT: VARIANT_ESP32S3, }, ) + # M5CORE2 has PSRAM on board and requires it to be configured + set_component_config("psram", True) # M5CORE2 should validate successfully config = validated_config({"model": "M5CORE2"}) diff --git a/tests/component_tests/online_image/config/online_image_platform_test.yaml b/tests/component_tests/online_image/config/online_image_platform_test.yaml index 883876e401..9b92bf75d0 100644 --- a/tests/component_tests/online_image/config/online_image_platform_test.yaml +++ b/tests/component_tests/online_image/config/online_image_platform_test.yaml @@ -24,6 +24,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/online_image/config/online_image_test.yaml b/tests/component_tests/online_image/config/online_image_test.yaml index ab0ad472f9..4af398cdff 100644 --- a/tests/component_tests/online_image/config/online_image_test.yaml +++ b/tests/component_tests/online_image/config/online_image_test.yaml @@ -23,6 +23,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display From 692cf7abd1d406e6833a53f62ee0c0993f35806b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:37:00 -1000 Subject: [PATCH 2095/2214] Bump bundled esphome-device-builder to 1.4.2 (#17512) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e7f8fceb12..fadf3f0685 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.4.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.2 RUN \ platformio settings set enable_telemetry No \ From 1a573919d15d41d97c94e64167ef3a992e217135 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:36:26 +1000 Subject: [PATCH 2096/2214] [mipi][mipi_spi] SWRESET handling improved (#17504) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 20 +++++- esphome/components/mipi_spi/display.py | 2 +- esphome/components/mipi_spi/mipi_spi.h | 25 ++----- esphome/components/mipi_spi/models/jc.py | 1 + tests/component_tests/mipi_spi/test_init.py | 75 ++++++++++++++++++++- 5 files changed, 100 insertions(+), 23 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index ab59d5ce5f..2b9a150419 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -26,6 +26,7 @@ from esphome.const import ( CONF_OFFSET_HEIGHT, CONF_OFFSET_WIDTH, CONF_PAGES, + CONF_RESET_PIN, CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, @@ -601,12 +602,15 @@ class DriverChip: """ return self.get_default(f"no_{command.lower()}", False) - def get_sequence(self, config, add_madctl=True) -> tuple[int, ...]: + def get_sequence(self, config, add_madctl=True, add_reset=False) -> tuple[int, ...]: """ Create the init sequence for the display. Use the default sequence from the model, if any, and append any custom sequence provided in the config. Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence MADCTL will be set if add_madctl is True + If add_reset is True, a reset is prepended: a software reset when no reset pin + is configured (and the model doesn't skip it), followed by a settling delay that + both a software and a hardware reset require. Returns the init sequence """ sequence = list(self.initsequence or ()) @@ -615,6 +619,15 @@ class DriverChip: # Ensure each command is a tuple sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] + if add_reset: + reset: list = [] + # A software reset is only needed when there is no hardware reset pin. + if CONF_RESET_PIN not in config and not self.skip_command("SWRESET"): + reset.append((SWRESET,)) + # Both a software and a hardware reset need a settling delay before further commands. + reset.append(delay(10)) + sequence = reset + sequence + # Set pixel format if not already in the custom sequence pixel_mode = config[CONF_PIXEL_MODE] if not isinstance(pixel_mode, int): @@ -635,8 +648,13 @@ class DriverChip: sequence.append((BRIGHTNESS, brightness)) # Add a SLPOUT command if required. if not self.skip_command("SLPOUT"): + # A zero delay will delay until 120ms after reset + sequence.append(delay(0)) sequence.append((SLPOUT,)) + sequence.append(delay(10)) sequence.append((DISPON,)) + # Add a delay here because additional commands may be added after this at runtime. + sequence.append(delay(10)) # Flatten the sequence into a list of bytes, with the length of each command # or the delay flag inserted where needed diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index f472e12a76..246db237b1 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -397,7 +397,7 @@ def get_instance(config): async def to_code(config): model = MODELS[config[CONF_MODEL]] var_id = config[CONF_ID] - init_sequence = model.get_sequence(config, False) + init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) cg.add(var.set_init_sequence(init_sequence)) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 48184fa5c1..701bcd7169 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -13,6 +13,8 @@ constexpr static const char *const TAG = "display.mipi_spi"; // Maximum bytes to log for commands (truncated if larger) static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; + +// Command codes for MIPI SPI displays. Not all currently used, kept here for reference. static constexpr uint8_t SW_RESET_CMD = 0x01; static constexpr uint8_t SLEEP_OUT = 0x11; static constexpr uint8_t NORON = 0x13; @@ -151,14 +153,11 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); - } else { - // no reset pin, send software reset command - this->write_command_(SW_RESET_CMD); + // required delay after reset is already in the init sequence, don't duplicate } // need to know when the display is ready for SLPOUT command - will be 120ms after reset auto when = millis() + 120; - delay(10); size_t index = 0; auto &vec = this->init_sequence_; while (index != vec.size()) { @@ -170,6 +169,9 @@ class MipiSpi : public display::Display, uint8_t cmd = vec[index++]; uint8_t x = vec[index++]; if (x == DELAY_FLAG) { + if (cmd == 0) { + cmd = clamp_at_least((int) (when - millis()), 0); + } esph_log_d(TAG, "Delay %dms", cmd); delay(cmd); } else { @@ -179,24 +181,9 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - switch (cmd) { - case SLEEP_OUT: { - // are we ready, boots? - int duration = when - millis(); - if (duration > 0) { - esph_log_d(TAG, "Sleep %dms", duration); - delay(duration); - } - } break; - - default: - break; - } const auto *ptr = vec.data() + index; this->write_command_(cmd, ptr, num_args); index += num_args; - if (cmd == SLEEP_OUT) - delay(10); } } this->reset_params_(); diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index d24ca5db58..ca9adb4a72 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -13,6 +13,7 @@ AXS15231 = DriverChip( transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, color_order=MODE_RGB, bus_mode=TYPE_QUAD, + no_swreset=True, initsequence=( (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5), (0xC1, 0x33), diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index dcecd89617..f29883684c 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -361,7 +361,8 @@ def test_native_generation( "mipi_spi::MipiSpiBuffer()" in main_cpp ) - assert "set_init_sequence({240, 1, 8, 242" in main_cpp + # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. + assert "set_init_sequence({10, 255, 240, 1, 8, 242" in main_cpp assert "show_test_card();" in main_cpp assert "set_write_only(true);" in main_cpp @@ -377,6 +378,76 @@ def test_lvgl_generation( "mipi_spi::MipiSpi();" in main_cpp ) - assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp + # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. + assert "set_init_sequence({10, 255, 177, 3, 1, 44, 45, 178" in main_cpp assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp + + +# A 10ms delay (flattened to {10, 0xFF}, where 0xFF is the delay marker byte) is +# always prepended to the init sequence, since both a software and a hardware reset +# need to settle before further commands. A custom model has no reset_pin default +# and does not set no_swreset, so when no reset pin is configured the SWRESET command +# ({1, 0}: command 0x01 with no parameters) is prepended ahead of that delay. +_SWRESET_YAML = """ +esphome: + name: swreset-test +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf +spi: + clk_pin: 1 + mosi_pin: 2 +display: + - platform: mipi_spi + model: custom + id: {display_id} + dc_pin: 4 + cs_pin: 8 + dimensions: + width: 320 + height: 240 + init_sequence: + - [0xA0, 0x01] +{reset_line} +""" + + +def test_swreset_prepended_without_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A model with no reset pin (and no no_swreset) gets SWRESET prepended.""" + yaml_file = tmp_path / "swreset.yaml" + yaml_file.write_text( + _SWRESET_YAML.format(display_id="swreset_display", reset_line="") + ) + + main_cpp = generate_main(yaml_file) + + # SWRESET ({1, 0}) followed by a 10ms delay ({10, 255}) is inserted ahead of + # the model's own commands. + assert "swreset_display->set_init_sequence({1, 0, 10, 255, 160, 1, 1," in main_cpp + + +def test_swreset_not_prepended_with_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A hardware reset pin performs the reset, so SWRESET must not be prepended. + + The post-reset delay is still required, so the sequence starts with the delay. + """ + yaml_file = tmp_path / "hwreset.yaml" + yaml_file.write_text( + _SWRESET_YAML.format( + display_id="hwreset_display", reset_line=" reset_pin: 5" + ) + ) + + main_cpp = generate_main(yaml_file) + + # The delay ({10, 255}) is still present, but no leading SWRESET ({1, 0}). + assert "hwreset_display->set_init_sequence({10, 255, 160, 1, 1," in main_cpp + assert "hwreset_display->set_init_sequence({1, 0," not in main_cpp From eb0848d5382aadf436165358805f864b1c026efd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:38:01 +1000 Subject: [PATCH 2097/2214] [mipi_dsi] New model for M5Stack Tab5 (#17500) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 34 ++++++----- esphome/components/mipi_dsi/models/m5stack.py | 59 ++++++++++++++++++- .../mipi_dsi/test_mipi_dsi_config.py | 27 +++++++++ 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 2b9a150419..3f73f96327 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -667,23 +667,27 @@ class DriverChip: This runs during schema validation (before ID references are resolved) so that a model whose default pins live on a pin expander reports the missing expander clearly instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + + Also logs a warning if the model is deprecated. """ - requirements = self.get_default("requires", set()) - if not requirements: - return - # ``raw_config`` is populated before any component schema runs during a real - # validation, so presence of a required component is simply a top-level key. - # When it is absent (e.g. a unit test that invokes the schema directly) there - # is no config to check against, so skip. - global_config = CORE.raw_config - if global_config is None: - return - missing = {x for x in requirements if x not in global_config} - if missing: - reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) - raise cv.Invalid( - f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + if deprecation_reason := self.get_default("deprecation_reason"): + LOGGER.warning( + "Display model %s is deprecated: %s", self.name, deprecation_reason ) + if requirements := self.get_default("requires", set()): + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) def requires_buffer(config) -> bool: diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index b947b9ac8a..5b07229ec7 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -54,8 +54,8 @@ DsiDriverChip( ], ) -DsiDriverChip( - "M5STACK-TAB5-V2", +TAB5_ST7123 = DsiDriverChip( + "M5STACK-TAB5-ST7123", height=1280, width=720, hsync_back_porch=40, @@ -94,3 +94,58 @@ DsiDriverChip( (0xC9, 0x00, 0x00, 0x10, 0x1F, 0x36, 0x00, 0x5D, 0x04, 0x9D, 0x05, 0x10, 0xF2, 0x06, 0x60, 0x03, 0x11, 0xAD, 0x00, 0xEF, 0x01, 0x22, 0x2E, 0x0E, 0x74, 0x08, 0x32, 0xDC, 0x09, 0x33, 0x0F, 0xF3, 0x77, 0x0D, 0xB0, 0xDC, 0x03, 0xFF), ], ) + +TAB5_ST7123.extend( + "M5STACK-TAB5-V2", + deprecation_reason="Use 'M5STACK-TAB5-ST7123' or 'M5STACK-TAB5-ST7121' instead." +) + +# Some Tab5 "v2" units ship with an ST7121 controller instead of the ST7123. +# The two are distinguishable at runtime by the touch controller firmware version (the M5 +# factory firmware branches on it), but ESPHome selects the panel at compile time, so ST7121 +# units must select this model explicitly. Values taken from M5's factory source +# (m5stack/M5Tab5-UserDemo: m5stack_tab5.c is_st7121 path + esp_lcd_st7121.c default table). +DsiDriverChip( + "M5STACK-TAB5-ST7121", + height=1280, + width=720, + hsync_back_porch=40, + hsync_pulse_width=2, + hsync_front_porch=40, + vsync_back_porch=24, + vsync_pulse_width=20, + vsync_front_porch=200, + pclk_frequency="70MHz", + lane_bit_rate="965Mbps", + color_order="RGB", + initsequence=[ + (0x01,), + (0x60, 0x71, 0x21, 0xA2), + (0x60, 0x71, 0x21, 0xA3), + (0x60, 0x71, 0x21, 0xA4), + (0x78, 0x21), + (0x79, 0xEF), + (0xA4, 0x31), + (0xB7, 0x00, 0x00, 0x5F, 0x5F, 0x44, 0x1A), + (0xB0, 0x22, 0x6B, 0x11, 0x89, 0x25, 0x43, 0x43), + (0xBF, 0xA7, 0xA7), + (0xA5, 0xF0, 0x03), + (0xD7, 0x10, 0x2C, 0x14, 0x2A, 0x80, 0x80), + (0x90, 0x71, 0x23, 0x5A, 0x20, 0x24, 0x11, 0x21), + (0xA3, 0x80, 0x01, 0x8C, 0xFF, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0xEF, 0x58, 0x00, 0x00, 0x00, 0xFF), + (0xA6, 0x0A, 0x00, 0x24, 0x71, 0x36, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x37, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x00, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x2C, 0x71, 0x00, 0x01, 0x00, 0x00, 0x68, 0x68, 0xFF, 0xFF, 0x00, 0x08, 0x80, 0x08, 0x80, 0x06, 0x00, 0x00, 0x00, 0x00), + (0xA7, 0x1A, 0x1A, 0xC0, 0x64, 0x40, 0x04, 0x15, 0x40, 0x00, 0x40, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x26, 0x37, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x8C, 0x9D, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0xAE, 0xBF, 0x00, 0x00, 0x20, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x79), + (0xAC, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x04, 0x1C, 0x1D, 0x08, 0x0A, 0x10, 0x12, 0x0C, 0x0E, 0x14, 0x16, 0x00, 0x1D, 0x1D, 0x1D, 0x1D, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x06, 0x1C, 0x1D, 0x09, 0x0B, 0x11, 0x13, 0x0D, 0x0F, 0x15, 0x17, 0x02, 0x1D, 0x1D, 0x1D, 0x1D), + (0xAD, 0x0C, 0x40, 0x46, 0x00, 0x07, 0x4B, 0x4B, 0xFF, 0xFF, 0xF0, 0x40, 0x0E, 0x01, 0x07, 0x42, 0x42, 0xFF, 0xFF, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF), + (0xAE, 0xF0, 0xFF, 0x03, 0xF0, 0xFF, 0x03, 0x00), + (0xB2, 0x15, 0x19, 0x05, 0x23, 0x49, 0x2D, 0x03, 0x2E, 0x5C, 0xD2, 0xFF, 0x10, 0x60, 0xFD, 0x20, 0xC0, 0x00), + (0xE8, 0x20, 0x60, 0x04, 0x8E, 0x8E, 0x3E, 0x04, 0xDC, 0xDC, 0x3E, 0x06, 0xFA, 0x26, 0x3E), + (0x75, 0x03, 0x04), + (0xE7, 0x4B, 0x00, 0x00, 0xBE, 0x4B, 0x8C, 0x20, 0x1A, 0xF0, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0xFF, 0x00, 0x32, 0x30, 0x73, 0x00, 0x00, 0xC8, 0x6A, 0xFF, 0x5A, 0x64, 0x38, 0x88, 0x15, 0xB1, 0x01, 0x01, 0x64, 0x01, 0x01, 0x7C, 0xFF, 0x1A, 0x51), + (0xE1, 0x0C, 0x0C), + (0xEA, 0x15, 0x00, 0x01), + (0xC8, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF), + (0xC9, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF), + (0x60, 0x71, 0x21, 0x00), + ], +) diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 100366b135..6259d85184 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -1,6 +1,7 @@ """Tests for mpi_dsi configuration validation.""" from collections.abc import Callable +import logging from pathlib import Path import pytest @@ -128,6 +129,32 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: CONFIG_SCHEMA(config) +def test_deprecated_model_warning( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecated M5Stack-Tab5-v2 alias warns and points at the replacement models.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4}, + ) + + from esphome.components.mipi_dsi.display import CONFIG_SCHEMA + + with caplog.at_level(logging.WARNING): + CONFIG_SCHEMA({"id": "deprecated_display", "model": "M5Stack-Tab5-v2"}) + assert "M5STACK-TAB5-V2 is deprecated" in caplog.text + # The warning names the replacement models so users know what to switch to. + assert "M5STACK-TAB5-ST7123" in caplog.text + + # The replacement models validate without emitting a deprecation warning. + caplog.clear() + with caplog.at_level(logging.WARNING): + CONFIG_SCHEMA({"id": "st7123_display", "model": "M5Stack-Tab5-ST7123"}) + CONFIG_SCHEMA({"id": "st7121_display", "model": "M5Stack-Tab5-ST7121"}) + assert "deprecated" not in caplog.text + + def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None: """A configured display rotation is recorded in the metadata. From 312f6f2049487571f9981d2094a71083e3c0c2e0 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 11 Jul 2026 15:48:11 +0200 Subject: [PATCH 2098/2214] [deep_sleep] feed watchdog in deep sleep (#17516) --- .../deep_sleep/deep_sleep_zephyr.cpp | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp index f77b73cd58..cadf7bf42d 100644 --- a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -1,13 +1,36 @@ #include "deep_sleep_component.h" #ifdef USE_ZEPHYR +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/wake.h" #include +#include namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +// The Zephyr watchdog has a short window (2s, or 10s with Zigbee) and +// WDT_OPT_PAUSE_IN_SLEEP only pauses it during true hardware sleep — not while a +// radio thread (e.g. the Zigbee stack) keeps the CPU busy in k_sem_take(). Feed +// it at least this often while waiting so it does not reset the device. +static const uint32_t WDT_FEED_INTERVAL_MS = 1000; + +static bool wakeable_delay_feed_wdt(uint32_t ms) { + while (ms > 0) { + const uint32_t step = std::min(ms, WDT_FEED_INTERVAL_MS); + esphome::internal::wakeable_delay(step); + esphome::arch_feed_wdt(); + if (esphome::wake_request_take()) { + return true; + } + if (ms != UINT32_MAX) { + ms -= step; + } + } + return false; +} + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} @@ -15,8 +38,9 @@ void DeepSleepComponent::dump_config_platform_() {} bool DeepSleepComponent::prepare_to_sleep_() { return true; } void DeepSleepComponent::deep_sleep_() { + bool woke = false; if (this->sleep_duration_.has_value()) { - esphome::internal::wakeable_delay(static_cast(*this->sleep_duration_ / 1000)); + woke = wakeable_delay_feed_wdt(static_cast(*this->sleep_duration_ / 1000)); } else { #ifndef USE_ZIGBEE // the device can be woken up through one of the following signals: @@ -29,10 +53,9 @@ void DeepSleepComponent::deep_sleep_() { // The system is reset when it wakes up from System OFF mode. sys_poweroff(); #else - esphome::internal::wakeable_delay(UINT32_MAX); + woke = wakeable_delay_feed_wdt(UINT32_MAX); #endif } - const bool woke = esphome::wake_request_take(); if (woke) { ESP_LOGD(TAG, "Woken up by another thread"); } else { From 5afe418a8eca5e252aa66c8d5545a76ca1a7bd93 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:32:45 -1000 Subject: [PATCH 2099/2214] Bump bundled esphome-device-builder to 1.4.3 (#17522) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index fadf3f0685..f09280a50e 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.4.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.3 RUN \ platformio settings set enable_telemetry No \ From d89b4c0b5993e0936987a19e6376e48814b97b2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:30:47 -1000 Subject: [PATCH 2100/2214] [core] Make config-hash independent of machine-local paths (#17523) --- esphome/core/__init__.py | 19 ++++++++++- esphome/yaml_util.py | 36 ++++++++++++++++---- tests/unit_tests/core/test_config.py | 42 +++++++++++++++++++++++ tests/unit_tests/test_main.py | 4 +-- tests/unit_tests/test_yaml_util.py | 51 ++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 9 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index bfdd2de7c7..bf637d4c1f 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -8,6 +8,7 @@ import re from typing import TYPE_CHECKING, Any from esphome.const import ( + CONF_BUILD_PATH, CONF_COMMENT, CONF_ESPHOME, CONF_ETHERNET, @@ -731,12 +732,28 @@ class EsphomeCore: The hash is computed lazily and cached for performance. Uses sort_keys=True to ensure deterministic ordering. + + The hash must be reproducible across machines so the device builder + can compare a locally computed hash against the one a device + advertises. Machine-local data is kept out of the input: build_path + (which embeds ESPHOME_BUILD_PATH and OS path separators) is excluded, + and Path values are dumped relative to the config directory. """ if self._config_hash is None: from esphome import yaml_util from esphome.helpers import fnv1a_32bit_hash - config_str = yaml_util.dump(self.config, show_secrets=True, sort_keys=True) + config = dict(self.config) + if (esphome_conf := config.get(CONF_ESPHOME)) is not None: + esphome_conf = dict(esphome_conf) + esphome_conf.pop(CONF_BUILD_PATH, None) + config[CONF_ESPHOME] = esphome_conf + config_str = yaml_util.dump( + config, + show_secrets=True, + sort_keys=True, + relative_to=self.config_dir if self.config_path is not None else None, + ) self._config_hash = fnv1a_32bit_hash(config_str) return self._config_hash diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 0009cde551..c2db9b97ed 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -840,17 +840,22 @@ def _load_yaml_internal_with_type( loader.dispose() -def dump(dict_, show_secrets=False, sort_keys=False): - """Dump YAML to a string and remove null.""" +def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None): + """Dump YAML to a string and remove null. + + When ``relative_to`` is given, Path values are dumped relative to that + directory (POSIX form) so the output is machine independent. + """ if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() - # Per-call subclass so the redaction flag doesn't leak across calls. + # Per-call subclass so the flags don't leak across calls. # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML - # processing is single-threaded today, so this isolates only the flag.) + # processing is single-threaded today, so this isolates only the flags.) class _Dumper(ESPHomeDumper): _redact_sensitive = not show_secrets + _relative_to = relative_to return yaml.dump( dict_, @@ -1002,9 +1007,13 @@ def format_path(path: DocumentPath, current_obj: Any) -> str: class ESPHomeDumper(yaml.SafeDumper): - # Default for the base class; per-call subclass in ``dump()`` overrides. + # Defaults for the base class; per-call subclass in ``dump()`` overrides. # When True, ``represent_sensitive`` wraps values in ANSI conceal codes. _redact_sensitive: bool = False + # When set, ``represent_path`` dumps Path values relative to this + # directory (in POSIX form) so the output does not depend on where the + # config lives on the machine that produced it. + _relative_to: Path | None = None def represent_mapping(self, tag, mapping, flow_style=None): value = [] @@ -1040,6 +1049,21 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + def represent_path(self, value: Path) -> yaml.ScalarNode: + if self._relative_to is not None: + # Normalize both sides lexically (no symlink resolution) so ".." + # segments do not defeat the prefix match, and walk up so files + # referenced outside the anchor directory stay relative too. A + # path that still cannot be relativized (e.g. a different drive) + # keeps its POSIX form so separators stay stable across OSes. + path = Path(os.path.normpath(value)) + with suppress(ValueError): + path = path.relative_to( + os.path.normpath(self._relative_to), walk_up=True + ) + return self.represent_stringify(path.as_posix()) + return self.represent_stringify(value) + def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: # Only the redact-and-not-a-secret branch is unique to sensitive # values; otherwise let ``represent_stringify`` handle ``!secret`` @@ -1138,5 +1162,5 @@ ESPHomeDumper.add_multi_representer(Extend, ESPHomeDumper.represent_extend) ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) -ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify) +ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_path) ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 6fd9f4c22c..0362c40bce 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None: assert hash1 != hash2 +def test_config_hash_ignores_build_path() -> None: + """Test that config_hash does not depend on the build_path value. + + build_path embeds ESPHOME_BUILD_PATH and OS path separators, so it must + not make the hash differ between machines. + """ + CORE.reset() + CORE.config = {"esphome": {"name": "test", "build_path": "build\\test"}} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config = {"esphome": {"name": "test", "build_path": "/build/test"}} + hash2 = CORE.config_hash + + assert hash1 == hash2 + + +def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None: + """Test that Path values under the config dir hash the same everywhere. + + Simulates the same project checked out at two different locations; the + absolute paths differ but the layout relative to the config dir is the + same, so the hashes must match. + """ + dir1 = tmp_path / "machine_a" / "project" + dir2 = tmp_path / "machine_b" / "somewhere" / "else" + dir1.mkdir(parents=True) + dir2.mkdir(parents=True) + + CORE.reset() + CORE.config_path = dir1 / "device.yaml" + CORE.config = {"esphome": {"name": "test"}, "file": dir1 / "fonts" / "arial.ttf"} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config_path = dir2 / "device.yaml" + CORE.config = {"esphome": {"name": "test"}, "file": dir2 / "fonts" / "arial.ttf"} + hash2 = CORE.config_hash + + assert hash1 == hash2 + + def test_make_app_name_cpp_no_mac_simple() -> None: """Test simple name without MAC suffix returns string literal.""" cpp_expr, global_decl, byte_len = make_app_name_cpp( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 0442c1db16..9a9aafec43 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -167,9 +167,9 @@ def setup_core( CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} if tmp_path is not None: - CORE.config_path = str(tmp_path / f"{name}.yaml") + CORE.config_path = tmp_path / f"{name}.yaml" CORE.name = name - CORE.build_path = str(tmp_path / ".esphome" / "build" / name) + CORE.build_path = tmp_path / ".esphome" / "build" / name @pytest.fixture diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index fa1c0fcce2..5c38fce105 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None: assert value == "hunter2" +def test_dump_path_without_relative_to_is_unchanged() -> None: + """Test that Path values dump as str(path) when relative_to is not given.""" + path = Path("some") / "dir" / "file.ttf" + output = yaml_util.dump({"file": path}) + assert output.strip() == f"file: {path}" + + +def test_dump_path_relative_to_anchor_dir() -> None: + """Test that Path values under relative_to dump as relative POSIX paths.""" + anchor = Path("/config/esphome").absolute() + data = {"file": anchor / "fonts" / "arial.ttf"} + output = yaml_util.dump(data, relative_to=anchor) + assert output.strip() == "file: fonts/arial.ttf" + + +def test_dump_path_outside_anchor_dir_walks_up() -> None: + """Test that Path values outside relative_to walk up with ".." segments.""" + anchor = Path("/config/esphome").absolute() + outside = Path("/config/fonts/file.ttf").absolute() + output = yaml_util.dump({"file": outside}, relative_to=anchor) + assert output.strip() == "file: ../fonts/file.ttf" + + +def test_dump_path_with_dotdot_segments_is_normalized() -> None: + """Test that ".." segments do not defeat relativization. + + A path like /config/other/../esphome/fonts/x.ttf is under the anchor + once normalized, so it must dump as a plain relative path. + """ + anchor = Path("/config/esphome").absolute() + path = Path("/config/other/../esphome/fonts/x.ttf").absolute() + output = yaml_util.dump({"file": path}, relative_to=anchor) + assert output.strip() == "file: fonts/x.ttf" + + +def test_dump_path_dotdot_reference_outside_anchor() -> None: + """Test the relative_config_path("../...") shape stays relative.""" + anchor = Path("/config/esphome").absolute() + path = anchor / ".." / "shared" / "font.ttf" + output = yaml_util.dump({"file": path}, relative_to=anchor) + assert output.strip() == "file: ../shared/font.ttf" + + +def test_dump_relative_to_does_not_leak_between_calls() -> None: + """Test that the relative_to flag is scoped to a single dump call.""" + anchor = Path("/config/esphome").absolute() + path = anchor / "fonts" / "arial.ttf" + assert "fonts/arial.ttf" in yaml_util.dump({"file": path}, relative_to=anchor) + assert yaml_util.dump({"file": path}).strip() == f"file: {path}" + + def test_dump__redacts_sensitive_str_by_default() -> None: out = yaml_util.dump({"password": SensitiveStr("hunter2")}) assert "\\033[8mhunter2\\033[28m" in out From 665e788cc9c040feaf649ae107ab2a37db5eb2e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:31:01 -1000 Subject: [PATCH 2101/2214] [mdns] Fix missing device info TXT records when native API is not enabled (#17520) --- esphome/components/mdns/mdns_component.cpp | 27 +++++++++++++------ esphome/components/mdns/mdns_component.h | 15 ++++++++--- esphome/components/mdns/mdns_host.cpp | 2 +- .../mdns/test-fallback.esp32-idf.yaml | 7 +++++ .../mdns/test-webserver-no-api.esp32-idf.yaml | 9 +++++++ 5 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 tests/components/mdns/test-fallback.esp32-idf.yaml create mode 100644 tests/components/mdns/test-webserver-no-api.esp32-idf.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 02b825605c..bb4271a6ca 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -47,7 +47,7 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi auto &services = services_storage; #endif -#ifdef USE_API +#ifdef USE_MDNS_DEVICE_INFO_TXT #ifdef USE_MDNS_STORE_SERVICES get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; @@ -70,17 +70,20 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi platform_register(this, services); } -void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf, - char *config_hash_buf) { +void MDNSComponent::compile_records_(StaticVector &services, + const char *mac_address_buf, const char *config_hash_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. +#ifdef USE_MDNS_DEVICE_INFO_TXT + MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); + MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); + MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash"); +#endif + #ifdef USE_API MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib"); MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name"); - MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); - MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash"); - MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform"); MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board"); MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network"); @@ -212,12 +215,18 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; +#ifndef USE_API + // Without the native API there is no _esphomelib service, so publish the + // device info here for the device builder to discover. + web_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}, + {MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}, + {MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}}; +#endif #endif #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \ !defined(USE_MDNS_EXTRA_SERVICES) MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http"); - MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); // Publish "http" service if not using native API or any other services // This is just to have *some* mDNS service so that .local resolution works @@ -225,7 +234,9 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; - fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; + fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}, + {MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}, + {MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}}; #endif } diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 9d525abc43..4f97e8cb99 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -22,6 +22,15 @@ #endif #endif +// Device info TXT records (version, mac, config_hash) are published on the _esphomelib service +// when the native API is enabled, otherwise on the _http service (web_server's or the fallback one). +// When neither applies (only prometheus, sendspin or user-defined services are configured), no +// device info records are published and the buffers below are not needed. +#if defined(USE_API) || defined(USE_WEBSERVER) || \ + (!defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_MDNS_EXTRA_SERVICES)) +#define USE_MDNS_DEVICE_INFO_TXT +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -136,7 +145,7 @@ class MDNSComponent final : public Component StaticVector dynamic_txt_values_; #endif -#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) +#if defined(USE_MDNS_DEVICE_INFO_TXT) && defined(USE_MDNS_STORE_SERVICES) /// Fixed buffer for MAC address (only needed when services are stored) char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; /// Fixed buffer for config hash hex string (only needed when services are stored) @@ -149,8 +158,8 @@ class MDNSComponent final : public Component // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif - void compile_records_(StaticVector &services, char *mac_address_buf, - char *config_hash_buf); + void compile_records_(StaticVector &services, const char *mac_address_buf, + const char *config_hash_buf); }; } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 1e66a10df0..c5d849df26 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -12,7 +12,7 @@ namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES -#ifdef USE_API +#ifdef USE_MDNS_DEVICE_INFO_TXT get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; format_hex_to(this->config_hash_str_, App.get_config_hash()); diff --git a/tests/components/mdns/test-fallback.esp32-idf.yaml b/tests/components/mdns/test-fallback.esp32-idf.yaml new file mode 100644 index 0000000000..b51dbb443f --- /dev/null +++ b/tests/components/mdns/test-fallback.esp32-idf.yaml @@ -0,0 +1,7 @@ +# No api, web_server or extra services so the fallback _http service +# (with version, mac and config_hash TXT records) is compiled. +wifi: + ssid: MySSID + password: password1 + +mdns: diff --git a/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml b/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml new file mode 100644 index 0000000000..23f3abdeb2 --- /dev/null +++ b/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml @@ -0,0 +1,9 @@ +# web_server without the native api so the version, mac and config_hash +# TXT records are attached to the web_server _http service. +wifi: + ssid: MySSID + password: password1 + +web_server: + +mdns: From 1e5cfe6b0f27ab1f8ad4a9524079b194eba48c14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:31:14 -1000 Subject: [PATCH 2102/2214] [web_server] Fix unused function warning for json_state_str (#17524) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3f4d598d48..3bba879823 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -57,7 +57,7 @@ namespace esphome::web_server { static const char *const TAG = "web_server"; // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. -static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +[[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): From a4650a23459297c29ebb5b14d191b9d4b438ebc6 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:31:39 +0200 Subject: [PATCH 2103/2214] [zigbee] Fix merge endpoint (#17511) --- esphome/components/zigbee/zigbee_ep_esp32.py | 108 +++++++++++-------- tests/components/zigbee/common_esp32.yaml | 1 + 2 files changed, 64 insertions(+), 45 deletions(-) diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index ca96e4364f..2ed3dddb67 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -94,66 +94,73 @@ def get_next_ep_num(eps: list[int]) -> int: return ep_num -def merge_endpoint( +def compare_clusters( existing_ep: dict[str, Any], - ep_num: int | None, ep: dict[str, Any], - use_type: bool | None, - skip_error: bool, -) -> bool: - add = True +) -> tuple[str | int, str] | None: existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]] for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]: if cl in existing_clusters: - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." - ) - add = False - break - if not add: + return cl + return None + + +def merge_endpoints( + existing_ep: dict[str, Any], + ep: dict[str, Any], + use_type: bool | None, +) -> bool: + if compare_clusters(existing_ep, ep): return False - if ( - use_type - and existing_ep.get(CONF_USE_DEVICE_TYPE) - and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) - ): - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has a conflicting device type {existing_ep.get(DEVICE_TYPE, 'CUSTOM_ATTR')} and use_type is set for both." - ) - return False - if use_type: - existing_ep[CONF_USE_DEVICE_TYPE] = use_type - if ep.get(DEVICE_TYPE): - existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] - else: - existing_ep.pop(DEVICE_TYPE, None) - existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) - return True - if existing_ep.get(CONF_USE_DEVICE_TYPE): - existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) - return True if ( ep.get(DEVICE_TYPE) and existing_ep.get(DEVICE_TYPE) - and ep[DEVICE_TYPE] != existing_ep[DEVICE_TYPE] + and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) ): - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has already a conflicting device type {existing_ep[DEVICE_TYPE]} and use_type is not set for both." - ) return False + if ( + ep.get(DEVICE_TYPE) + and not existing_ep.get(DEVICE_TYPE) + and existing_ep.get(CONF_USE_DEVICE_TYPE) + ): + return False + if existing_ep.get(DEVICE_TYPE) and not ep.get(DEVICE_TYPE) and use_type: + return False + if use_type: + existing_ep[CONF_USE_DEVICE_TYPE] = use_type if ep.get(DEVICE_TYPE): existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) return True +def validate_endpoints(ep_dict: dict[int, dict]) -> None: + for num, ep in ep_dict.items(): + types_dict = ep.get(CONF_USE_DEVICE_TYPE) + if not types_dict: + continue + if len(types_dict) == 1: + ep[DEVICE_TYPE] = list(types_dict.keys())[0] + del ep[CONF_USE_DEVICE_TYPE] + continue + types_list = [t[0] for t in types_dict.items() if t[1]] + if len(types_list) > 1: + raise cv.Invalid( + f"There is more than one component with endpoint: {num} and {CONF_USE_DEVICE_TYPE}: True" + ) + if not types_list: + raise cv.Invalid( + f"Multiple device types on endpoint: {num}. Set {CONF_USE_DEVICE_TYPE}: True on one component." + ) + ep[DEVICE_TYPE] = types_list[0] + del ep[CONF_USE_DEVICE_TYPE] + + def create_ep(router: bool) -> None: zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) + validate_endpoints(ep_dict) # create dummy endpoint if list is empty if not ep_dict and not ep_list: ep_type = "CUSTOM_ATTR" @@ -166,9 +173,7 @@ def create_ep(router: bool) -> None: for ep in ep_list: added = False for existing_ep in ep_list_new: - if merge_endpoint( - existing_ep, None, ep, ep.get(CONF_USE_DEVICE_TYPE), True - ): + if merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)): added = True break if not added: @@ -191,6 +196,8 @@ def create_ep(router: bool) -> None: def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + if use_type is False: + ep.pop(DEVICE_TYPE, None) if ep_num is None: if use_type: ep[CONF_USE_DEVICE_TYPE] = use_type @@ -201,8 +208,19 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non if ep_num in ep_dict: # check if the existing endpoint has same clusters existing_ep = ep_dict[ep_num] - merge_endpoint(existing_ep, ep_num, ep, use_type, False) + if cl := compare_clusters( + existing_ep, + ep, + ): + raise cv.Invalid( + f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." + ) + if ep.get(DEVICE_TYPE) or use_type: + types_dict = existing_ep.setdefault(CONF_USE_DEVICE_TYPE, {}) + if not types_dict.get(ep.get(DEVICE_TYPE)) or use_type: + types_dict[ep.get(DEVICE_TYPE)] = use_type + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) else: - if use_type is not None: - ep[CONF_USE_DEVICE_TYPE] = use_type + if use_type or ep.get(DEVICE_TYPE): + ep[CONF_USE_DEVICE_TYPE] = {ep.get(DEVICE_TYPE): use_type} ep_dict[ep_num] = ep diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 8e00e4471e..6cac9c9e2a 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -5,6 +5,7 @@ binary_sensor: - platform: template name: "Garage Door Open 10" report: "default" + use_device_type: false - platform: template name: "Garage Door Open 12" report: "force" From 9ba2cbbfdd99c8611fe46bb579409b7f34f5b6a8 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:32:01 +0200 Subject: [PATCH 2104/2214] [zigbee] prevent task watchdog trigger with large configs. (#17506) --- .../zigbee/zigbee_attribute_esp32.cpp | 19 --------- .../zigbee/zigbee_attribute_esp32.h | 1 - esphome/components/zigbee/zigbee_esp32.cpp | 42 +++++++++---------- esphome/components/zigbee/zigbee_esp32.h | 2 +- 4 files changed, 22 insertions(+), 42 deletions(-) diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index c6f2aa0af6..d7176e6ca5 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -50,25 +50,6 @@ void ZigbeeAttribute::report_(bool has_lock) { } } -void ZigbeeAttribute::setup_reporting() { - ezb_zcl_reporting_info_t reporting_info = ezb_zcl_reporting_info_find( - this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, EZB_ZCL_STD_MANUF_CODE); - if (reporting_info == EZB_ZCL_INVALID_REPORTING_INFO) { - ESP_LOGD(TAG, "Could not find reporting info for attribute 0x%04X in cluster 0x%04X in endpoint %u", this->attr_id_, - this->cluster_id_, this->endpoint_id_); - this->report_enabled = false; - this->force_report_ = false; - } else { - ESP_LOGD(TAG, "Found reporting info for attr 0x%04X in cluster 0x%04X", this->attr_id_, this->cluster_id_); - ezb_zcl_attr_variable_t delta = {.u64 = 0}; - ezb_zcl_reporting_info_update_default_interval(reporting_info, 0, 65000); - ezb_zcl_reporting_info_update(reporting_info, 0, 65000, &delta); - if (ezb_zcl_reporting_start_attr_report(reporting_info) != EZB_ERR_NONE) { - ESP_LOGE(TAG, "Could not start reporting for attribute"); - } - } -} - void ZigbeeAttribute::set_report(ZigbeeReportT report) { this->report_enabled = true; if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index b5afb57910..e5f8c8b1cf 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -42,7 +42,6 @@ class ZigbeeAttribute final : public Component { scale_(scale) {} void loop() override; template void add_attr(T value); - void setup_reporting(); template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(ZigbeeReportT report); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 03457312be..3e0f6cd745 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -53,11 +53,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { switch (signal_type) { case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); - if (ezb_bdb_is_factory_new()) { - global_zigbee->defer([]() { global_zigbee->setup_reporting(); }); - } else { - ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); - } + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); break; case EZB_BDB_SIGNAL_DEVICE_FIRST_START: case EZB_BDB_SIGNAL_DEVICE_REBOOT: { @@ -133,12 +129,12 @@ static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, voi case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message); break; -#ifdef ESPHOME_LOG_HAS_VERBOSE case EZB_ZCL_CORE_DEFAULT_RSP_CB_ID: { +#ifdef ESPHOME_LOG_HAS_VERBOSE ezb_zcl_cmd_default_rsp_message_t *default_rsp = (ezb_zcl_cmd_default_rsp_message_t *) message; ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code); - } break; #endif + } break; default: ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast(callback_id)); break; @@ -206,21 +202,30 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } -void ZigbeeComponent::setup_reporting() { - ESP_LOGD(TAG, "Setting up reporting for all attributes"); - esp_zigbee_lock_acquire(portMAX_DELAY); - for (auto &[_, attribute] : this->attributes_) { - attribute->setup_reporting(); +bool ZigbeeComponent::register_device() { + if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not register the endpoint list"); + this->mark_failed(); + return false; } - ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); - esp_zigbee_lock_release(); + return true; } static void ezb_task(void *pv_parameters) { + if (!global_zigbee->register_device()) { + vTaskDelete(NULL); + return; + } if (esp_zigbee_start(false) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); + global_zigbee->mark_failed(); vTaskDelete(NULL); + return; // vTaskDelete(NULL) never returns, but keep intent explicit } + + // Increase priority to 5 to align with openthread or BLE + vTaskPrioritySet(NULL, 5); + esp_zigbee_launch_mainloop(); esp_zigbee_deinit(); @@ -274,12 +279,6 @@ void ZigbeeComponent::setup() { return; } - if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { - ESP_LOGE(TAG, "Could not register the endpoint list"); - this->mark_failed(); - return; - } - ezb_zcl_core_action_handler_register(zb_action_handler); if (ezb_bdb_set_primary_channel_set(EZB_PRIMARY_CHANNEL_MASK) != ESP_OK) { @@ -298,7 +297,8 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); - xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 24, NULL); + // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy + xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 11289843a8..f4bafac294 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -42,7 +42,7 @@ class ZigbeeComponent final : public Component { void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); - void setup_reporting(); + bool register_device(); template void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, From 27b598c5aa12a916b947fcd102018e986a179df0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:33:29 +1200 Subject: [PATCH 2105/2214] [core] Classify entity metadata visibility for the visual editor (#17503) --- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/button/__init__.py | 4 +- esphome/components/cover/__init__.py | 4 +- esphome/components/event/__init__.py | 4 +- esphome/components/number/__init__.py | 12 ++- esphome/components/sensor/__init__.py | 26 +++-- esphome/components/switch/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/update/__init__.py | 8 +- esphome/components/valve/__init__.py | 4 +- esphome/components/web_server/__init__.py | 4 +- esphome/config_validation.py | 102 ++++++++++++------- tests/unit_tests/test_config_validation.py | 70 ++++++++++++- 13 files changed, 193 insertions(+), 57 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index a9a09363fc..5800e0bd9e 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Exclusive( CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE ): cv.boolean, - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index dd4fde5705..a4245f43e6 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -50,7 +50,9 @@ _BUTTON_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), } ) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 839ca532e6..7639e15334 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -131,7 +131,9 @@ _COVER_SCHEMA = ( cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All( cv.requires_component("mqtt"), cv.boolean ), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 4cab1bff9b..e205e4b910 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -50,7 +50,9 @@ _EVENT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent), cv.GenerateID(): cv.declare_id(Event), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_ON_EVENT): automation.validate_automation({}), } ) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index bcc609de65..ea0c2d77f6 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -212,9 +212,15 @@ _NUMBER_SCHEMA = ( }, cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), ), - cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, - cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED + ): validate_unit_of_measurement, + cv.Optional( + CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + ): cv.enum(NUMBER_MODES, upper=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, } ) ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index da8a540d8d..6ad76046a1 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -321,13 +321,25 @@ _SENSOR_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent), cv.GenerateID(): cv.declare_id(Sensor), - cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, - cv.Optional(CONF_ACCURACY_DECIMALS): validate_accuracy_decimals, - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, - cv.Optional(CONF_STATE_CLASS): validate_state_class, - cv.Optional(CONF_ENTITY_CATEGORY): sensor_entity_category, - cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, - cv.Optional(CONF_EXPIRE_AFTER): cv.All( + cv.Optional( + CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED + ): validate_unit_of_measurement, + cv.Optional( + CONF_ACCURACY_DECIMALS, visibility=cv.Visibility.ADVANCED + ): validate_accuracy_decimals, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, + cv.Optional( + CONF_STATE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_state_class, + cv.Optional( + CONF_ENTITY_CATEGORY, visibility=cv.Visibility.ADVANCED + ): sensor_entity_category, + cv.Optional( + CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED + ): cv.boolean, + cv.Optional(CONF_EXPIRE_AFTER, visibility=cv.Visibility.ADVANCED): cv.All( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 1108652e99..18b95113cc 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -78,7 +78,9 @@ _SWITCH_SCHEMA = ( cv.Optional(CONF_ON_STATE): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, } ) ) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 01a57cbaa1..a3f4999a8f 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -144,7 +144,9 @@ _TEXT_SENSOR_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor), cv.GenerateID(): cv.declare_id(TextSensor), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index ddb471be18..18d333a5ef 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -54,7 +54,9 @@ _UPDATE_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTUpdateComponent), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_ON_UPDATE_AVAILABLE): automation.validate_automation( single=True ), @@ -136,7 +138,9 @@ async def to_code(config): automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(UpdateEntity), - cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean), + cv.Optional( + CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED + ): cv.templatable(cv.boolean), } ), synchronous=True, diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index d82a9fdec2..7d98af402d 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -87,7 +87,9 @@ _VALVE_SCHEMA = ( { cv.GenerateID(): cv.declare_id(Valve), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTValveComponent), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index f4e9eae763..d9fd27dbc2 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -172,7 +172,9 @@ sorting_group = { WEBSERVER_SORTING_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEB_SERVER): cv.Schema( + # The per-entity web_server block is cosmetic dashboard ordering — + # mark the whole block advanced; the children inherit via the cascade. + cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema( { cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer), cv.Optional(CONF_SORTING_WEIGHT): cv.All( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 45fd94fd1a..16f0a63aa0 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -292,10 +292,14 @@ class Visibility(StrEnum): the same way. ESPHome itself ignores the value at runtime; consumers downstream of the schema dump act on it. - A field with no ``visibility`` set (the default) renders on the - editor's main form. The two values below are points along a - single axis of "how prominently to surface this": + Three points along a single axis of "how prominently to surface + this", from least to most hidden: + - ``UI`` — always render on the editor's main form. Use to + promote an ``Optional`` that would otherwise fall through to + the advanced disclosure (see the default rule below): the + "headline" config a user reaches for first (e.g. a sensor's + ``name`` or its primary pin/address). - ``ADVANCED`` — render under the editor's "advanced settings" disclosure. Use for fields whose default is right for ~all users (e.g. ``update_interval`` on time platforms — 15 min is @@ -307,25 +311,35 @@ class Visibility(StrEnum): tweaks can break boot). The YAML escape hatch stays available for the rare power-user override. - The single-axis shape encodes "yaml-only is strictly stronger - than advanced" at the type level — there's no way to ask for - both at once, and no way to set a contradictory state like - "advanced=False, yaml_only=True". + Default when unset (``visibility=None``): resolved by the + consumer, not encoded on the marker. A schema-aware editor + treats an ``Optional`` with no setting as ``ADVANCED`` (most + optional knobs have sensible defaults and would clutter the + form), and a ``Required`` with no setting as ``UI`` (a required + field needs the user's attention). Pass an explicit value to + override either default — most commonly ``UI`` to keep a + high-value ``Optional`` on the main form. + + The single-axis shape encodes the strictness ladder + (``UI`` < ``ADVANCED`` < ``YAML_ONLY``) at the type level — + there's no way to set a contradictory state. Per-field; the dumper walks recursively into nested schemas - and emits each field's setting independently. Cascading - semantics — "a stricter parent makes its descendants at-least - as strict" — belong on the consumer side: the schema marker - is faithfully what the field author wrote, and a consumer that - cares about effective visibility walks the parent chain and - takes the strictest setting. ``YAML_ONLY`` is strictly stronger - than ``ADVANCED``, which is strictly stronger than no setting. - Inner fields can declare their own visibility; an inner + and emits each field's setting independently, omitting the key + when unset so the dump stays compact and the per-field default + is the consumer's to apply. Cascading semantics — "a stricter + parent makes its descendants at-least as strict" — belong on the + consumer side: the schema marker is faithfully what the field + author wrote, and a consumer that cares about effective + visibility walks the parent chain and takes the strictest + setting. Inner fields can declare their own visibility; an inner ``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``, - and the consumer's cascade keeps siblings under the parent at - ``ADVANCED`` regardless of their own (less-strict) setting. + and the consumer's cascade keeps a ``UI`` sibling under an + ``ADVANCED`` parent at ``ADVANCED`` regardless of its own + (less-strict) setting. """ + UI = "ui" ADVANCED = "advanced" YAML_ONLY = "yaml_only" @@ -347,6 +361,9 @@ class Optional(vol.Optional): See :class:`Visibility` for the ``visibility`` kwarg — a UI hint for schema-driven editors that doesn't affect validation. + Left unset, an ``Optional`` is treated as ``Visibility.ADVANCED`` + by schema-aware editors; pass ``Visibility.UI`` to keep it on the + main form. """ def __init__( @@ -369,9 +386,11 @@ class Required(vol.Required): See :class:`Visibility` for the ``visibility`` kwarg — a UI hint for schema-driven editors that doesn't affect validation. - Required fields rarely need it (a required field by definition - needs the user's attention) but the kwarg is exposed for - symmetry so consumers can apply uniform logic across key markers. + Required fields rarely need it: left unset, a ``Required`` is + treated as on the main form (``Visibility.UI``) by schema-aware + editors, since a required field needs the user's attention. The + kwarg is exposed for symmetry so consumers can apply uniform + logic across key markers. """ def __init__( @@ -2274,16 +2293,25 @@ MQTT_COMPONENT_AVAILABILITY_SCHEMA = Schema( } ) +# Per-entity MQTT plumbing — integration metadata, never a primary UI field. MQTT_COMPONENT_SCHEMA = Schema( { - Optional(CONF_QOS): All(requires_component("mqtt"), mqtt_qos), - Optional(CONF_RETAIN): All(requires_component("mqtt"), boolean), - Optional(CONF_DISCOVERY): All(requires_component("mqtt"), boolean), - Optional(CONF_SUBSCRIBE_QOS): All(requires_component("mqtt"), mqtt_qos), - Optional(CONF_STATE_TOPIC): All( + Optional(CONF_QOS, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), mqtt_qos + ), + Optional(CONF_RETAIN, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), + Optional(CONF_DISCOVERY, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), + Optional(CONF_SUBSCRIBE_QOS, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), mqtt_qos + ), + Optional(CONF_STATE_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(publish_topic) ), - Optional(CONF_AVAILABILITY): All( + Optional(CONF_AVAILABILITY, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), Any(None, MQTT_COMPONENT_AVAILABILITY_SCHEMA) ), } @@ -2291,10 +2319,12 @@ MQTT_COMPONENT_SCHEMA = Schema( MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend( { - Optional(CONF_COMMAND_TOPIC): All( + Optional(CONF_COMMAND_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(subscribe_topic) ), - Optional(CONF_COMMAND_RETAIN): All(requires_component("mqtt"), boolean), + Optional(CONF_COMMAND_RETAIN, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), } ) @@ -2369,12 +2399,16 @@ def string_no_slash(value): ENTITY_BASE_SCHEMA = Schema( { - Optional(CONF_NAME): _validate_entity_name, - Optional(CONF_INTERNAL): boolean, - Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, - Optional(CONF_ICON): icon, - Optional(CONF_ENTITY_CATEGORY): entity_category, - Optional(CONF_DEVICE_ID): sub_device_id, + # The name is every entity's headline field — keep it on the + # main form rather than letting it fall through to advanced. + Optional(CONF_NAME, visibility=Visibility.UI): _validate_entity_name, + Optional(CONF_INTERNAL, visibility=Visibility.ADVANCED): boolean, + Optional( + CONF_DISABLED_BY_DEFAULT, default=False, visibility=Visibility.ADVANCED + ): boolean, + Optional(CONF_ICON, visibility=Visibility.ADVANCED): icon, + Optional(CONF_ENTITY_CATEGORY, visibility=Visibility.ADVANCED): entity_category, + Optional(CONF_DEVICE_ID, visibility=Visibility.ADVANCED): sub_device_id, } ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 6580564c65..17dfaad9b8 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1174,9 +1174,10 @@ def test_update_interval__never_passes_through() -> None: def test_optional_default_visibility_is_none() -> None: """An ``Optional`` with no ``visibility`` kwarg reports ``None``. - Consumers can read the attribute directly with plain attribute - access; absence (``None``) means "render on the editor's main - form." + The marker stays faithful to what the author wrote: ESPHome does + not encode the default on it. Resolving ``None`` to an effective + visibility is the consumer's job — a schema-aware editor treats an + unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`). """ o = cv.Optional("foo") assert o.visibility is None @@ -1194,6 +1195,17 @@ def test_optional_visibility_yaml_only() -> None: assert o.visibility is cv.Visibility.YAML_ONLY +def test_optional_visibility_ui() -> None: + """``visibility=Visibility.UI`` is recorded on the marker. + + ``UI`` promotes an ``Optional`` onto the editor's main form, + overriding the consumer's default of ``ADVANCED`` for unset + optionals. + """ + o = cv.Optional("foo", visibility=cv.Visibility.UI) + assert o.visibility is cv.Visibility.UI + + def test_visibility_str_values_match_dump_emission() -> None: """``Visibility`` is a ``StrEnum`` whose values are the literal strings the schema dumper emits. @@ -1203,6 +1215,7 @@ def test_visibility_str_values_match_dump_emission() -> None: field — pinning the on-the-wire spelling here keeps the dump contract stable. """ + assert str(cv.Visibility.UI) == "ui" assert str(cv.Visibility.ADVANCED) == "advanced" assert str(cv.Visibility.YAML_ONLY) == "yaml_only" @@ -1325,6 +1338,57 @@ def test_visibility_marker_is_per_field_no_mutation() -> None: assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY +def test_entity_metadata_visibility_hints() -> None: + """Entity and value-describing metadata is classified for visual editors. + + The headline ``name`` stays on the main form (``UI``); descriptive + metadata (device_class, unit, …), presentation options, and per-entity + integration plumbing (MQTT, web_server ordering) fall to the advanced + disclosure (``ADVANCED``). + """ + advanced = cv.Visibility.ADVANCED + + entity_base = {str(k): k for k in cv.ENTITY_BASE_SCHEMA.schema} + assert entity_base["name"].visibility is cv.Visibility.UI + for field in ( + "icon", + "internal", + "disabled_by_default", + "entity_category", + "device_id", + ): + assert entity_base[field].visibility is advanced, field + + mqtt = {str(k): k for k in cv.MQTT_COMPONENT_SCHEMA.schema} + for field in ("qos", "retain", "discovery", "state_topic", "availability"): + assert mqtt[field].visibility is advanced, field + + from esphome.components import binary_sensor, number, sensor + from esphome.components.web_server import WEBSERVER_SORTING_SCHEMA + + sensor_markers = {str(k): k for k in sensor.sensor_schema().schema} + for field in ( + "unit_of_measurement", + "accuracy_decimals", + "device_class", + "state_class", + "force_update", + ): + assert sensor_markers[field].visibility is advanced, field + + binary = {str(k): k for k in binary_sensor.binary_sensor_schema().schema} + assert binary["device_class"].visibility is advanced + + number_markers = {str(k): k for k in number.number_schema(number.Number).schema} + assert number_markers["mode"].visibility is advanced + assert number_markers["device_class"].visibility is advanced + + # The whole per-entity web_server block is advanced; children inherit + # via the consumer cascade, so only the parent key carries the hint. + web = {str(k): k for k in WEBSERVER_SORTING_SCHEMA.schema} + assert web["web_server"].visibility is advanced + + def _wrap_str(value: str) -> ESPHomeDataBase: """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" return make_data_base(value) From bcac3ebe2b7942295f0e71419357f25a22d7f9e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jul 2026 10:55:30 -1000 Subject: [PATCH 2106/2214] [api] Provision encryption keys over an encrypted zero-PSK noise connection (#17482) --- esphome/components/api/__init__.py | 7 +- esphome/components/api/api.proto | 5 + esphome/components/api/api_connection.cpp | 49 +++++++ esphome/components/api/api_connection.h | 5 + esphome/components/api/api_frame_helper.cpp | 2 + esphome/components/api/api_frame_helper.h | 11 ++ .../components/api/api_frame_helper_noise.cpp | 51 +++++-- .../components/api/api_frame_helper_noise.h | 8 ++ .../api/api_frame_helper_plaintext.cpp | 11 ++ .../api/api_frame_helper_plaintext.h | 9 ++ esphome/components/api/api_noise_context.h | 17 ++- esphome/components/api/api_pb2.cpp | 6 + esphome/components/api/api_pb2.h | 5 +- esphome/components/api/api_pb2_dump.cpp | 3 + esphome/components/mdns/mdns_component.cpp | 19 ++- .../test-dynamic-encryption.esp32-idf.yaml | 8 +- .../fixtures/api_zero_psk_provisioning.yaml | 6 + .../api_zero_psk_provisioning_plaintext.yaml | 6 + .../test_api_zero_psk_provisioning.py | 127 ++++++++++++++++++ 19 files changed, 334 insertions(+), 21 deletions(-) create mode 100644 tests/integration/fixtures/api_zero_psk_provisioning.yaml create mode 100644 tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml create mode 100644 tests/integration/test_api_zero_psk_provisioning.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 64b025fee1..0719cee352 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -488,8 +488,11 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired - # This will allow a plaintext client to provide a noise key, - # send it to the device, and then switch to noise. + # Until a key is set, the device accepts both Noise connections + # using the well-known all-zeros PSK (preferred: the key travels + # encrypted, protecting against passive sniffing) and plaintext + # connections (deprecated, remove after 2027.2.0) so a client can + # provide a noise key and the device then switches to noise only. # The key will be saved in flash and used for future connections # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 86707d9810..4b3df62ec4 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -310,6 +310,11 @@ message DeviceInfoResponse { // Serial proxy instance metadata 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 + // all-zeros PSK, so the api encryption key can be provisioned without being + // sent in plaintext (protects against passive sniffing, not active MITM) + bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index dcb1478ec8..2efdf0bc03 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -198,6 +198,29 @@ APIConnection::~APIConnection() { #endif } +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) +void APIConnection::upgrade_helper_to_noise_() { + // The client opened with a Noise hello while this device has no encryption + // key set. Replace the plaintext helper with a Noise helper so the key can + // be provisioned over an encrypted channel: the noise context PSK is all + // zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519 + // exchange, so a passive listener cannot read the session. A publicly known + // PSK authenticates nobody; this protects against sniffing only. + auto *plaintext = static_cast(this->helper_.get()); + uint8_t header[3]; + uint8_t header_len = plaintext->get_consumed_header(header); + auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx()); + // Carry over the peername-based client name (Hello has not arrived yet) + const char *name = plaintext->get_client_name(); + noise->set_client_name(name, strlen(name)); + this->helper_.reset(noise); // destroys the plaintext helper + APIError err = noise->init_from_handoff(header, header_len); + if (err != APIError::OK) { + this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err); + } +} +#endif // USE_API_NOISE && USE_API_PLAINTEXT + void APIConnection::destroy_active_iterator_() { switch (this->active_iterator_) { case ActiveIterator::LIST_ENTITIES: @@ -256,6 +279,15 @@ void APIConnection::loop() { // No more data available break; } else if (err != APIError::OK) { +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Checked inside the error branch to keep the hot err == OK path + // free of it; this can only fire on the first bytes of a plaintext + // helper on an unprovisioned device + if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) { + this->upgrade_helper_to_noise_(); + return; + } +#endif this->fatal_error_with_log_(LOG_STR("Reading failed"), err); return; } else { @@ -1860,6 +1892,12 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; +#ifndef USE_API_NOISE_PSK_FROM_YAML + // No key from YAML: while no key is set, the key can be provisioned over a + // zero-PSK Noise connection. Gated on the YAML define (not the plaintext + // one) so this advertisement survives the plaintext removal in 2027.2.0. + resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk(); +#endif #endif #ifdef USE_DEVICES size_t device_index = 0; @@ -2037,10 +2075,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } } else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); + } else if (APINoiseContext::is_all_zeros(psk)) { + // Accepting the reserved provisioning PSK would report success without + // enabling encryption (or silently clear an existing key) + ESP_LOGW(TAG, "Rejecting all-zero encryption key"); } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); } else { resp.success = true; +#ifdef USE_API_PLAINTEXT + if (this->helper_->frame_footer_size() == 0) { + // Plaintext transport has no frame footer; Noise always has the MAC footer. + // Remove after 2027.2.0 together with plaintext support on keyless devices. + ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0"); + } +#endif } return this->send_message(resp); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index d6d3e4d26b..144973fa9d 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -626,6 +626,11 @@ class APIConnection final : public APIServerConnectionBase { void destroy_active_iterator_(); void begin_iterator_(ActiveIterator type); void finalize_iterator_sync_(); +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Swap the plaintext helper for a Noise helper after the client opened + // with a Noise hello on an unprovisioned device (zero-PSK provisioning). + void upgrade_helper_to_noise_(); +#endif #ifdef USE_CAMERA std::unique_ptr image_reader_; #endif diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 90353b6402..7425304766 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE"); } #endif + // PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before + // any logging can happen, so it intentionally has no entry here. return LOG_STR("UNKNOWN"); } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f98eca8076..9cae6ba92e 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -88,6 +88,11 @@ enum class APIError : uint16_t { HANDSHAKESTATE_SPLIT_FAILED = 1020, BAD_HANDSHAKE_ERROR_BYTE = 1021, #endif +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Not an error: an unprovisioned device received a Noise client hello on a + // plaintext connection; the caller must hand the socket off to a Noise helper. + PROTOCOL_SWITCH_TO_NOISE = 1023, +#endif }; const LogString *api_error_to_logstr(APIError err); @@ -200,6 +205,12 @@ class APIFrameHelper { // or track that they stopped early and retry without this check. // See Socket::ready() for details. bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Move the socket out of this helper so a replacement helper can take it + // over (plaintext to Noise handoff on unprovisioned devices). The drained + // helper must be destroyed right after. + std::unique_ptr release_socket_for_switch() { return std::move(this->socket_); } +#endif // Release excess memory from internal buffers after initial sync void release_buffers() { // rx_buf_: Safe to clear only if no partial read in progress. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 6dba64a7f8..225bac51a6 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() { state_ = State::CLIENT_HELLO; return APIError::OK; } +#ifdef USE_API_PLAINTEXT +APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) { + APIError err = this->init(); + if (err != APIError::OK) { + return err; + } + // Seed the header bytes the plaintext helper consumed before detecting the + // Noise indicator; try_read_frame_ resumes from rx_header_buf_len_. + std::memcpy(this->rx_header_buf_, header, header_len); + this->rx_header_buf_len_ = header_len; + // Pump the handshake without gating on socket_->ready(): on LWIP the + // plaintext helper's partial read can drain rcvevent while the rest of the + // client hello sits in the lastdata cache, so ready() may report false even + // though data is available. + return this->pump_handshake_(); +} +#endif // USE_API_PLAINTEXT + +/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal +/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK +/// and resume on the next loop(). +APIError APINoiseFrameHelper::pump_handshake_() { + while (this->state_ != State::DATA) { + APIError err = this->state_action_(); + if (err == APIError::WOULD_BLOCK) { + break; + } + if (err != APIError::OK) { + return err; + } + } + return APIError::OK; +} + // Helper for handling handshake frame errors APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) { if (aerr == APIError::BAD_INDICATOR) { @@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func /// Run through handshake messages (if in that phase) APIError APINoiseFrameHelper::loop() { - // Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once - // the rx buffer is consumed. Re-checking each iteration would block handshake writes - // that must follow reads, deadlocking the handshake. state_action() will return - // WOULD_BLOCK when no more data is available to read. - bool socket_ready = this->socket_->ready(); - while (state_ != State::DATA && socket_ready) { - APIError err = state_action_(); - if (err == APIError::WOULD_BLOCK) { - break; - } + // Check ready() once, not per state transition. On ESP8266 LWIP raw TCP, + // ready() returns false once the rx buffer is consumed. Re-checking each + // iteration would block handshake writes that must follow reads, + // deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when + // no more data is available to read. + if (state_ != State::DATA && this->socket_->ready()) { + APIError err = this->pump_handshake_(); if (err != APIError::OK) { return err; } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 0676eab78d..b0ba9fd01c 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper { } ~APINoiseFrameHelper() override; APIError init() override; +#ifdef USE_API_PLAINTEXT + // Take over a connection whose first bytes were consumed by a plaintext + // helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE). + // Seeds the already-read header bytes and pumps the handshake state machine + // until it would block. + APIError init_from_handoff(const uint8_t *header, uint8_t header_len); +#endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: + APIError pump_handshake_(); APIError state_action_(); APIError state_action_client_hello_(); APIError state_action_server_hello_(); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index fa611a6e33..9359f568fb 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { // If this was the first read, validate the indicator byte if (rx_header_buf_pos_ == 0 && received > 0) { if (rx_header_buf_[0] != 0x00) { +#ifdef USE_API_NOISE + // Dual build (encryption supported but no key set): a 0x01 first byte + // is a Noise client hello. Hand the connection off to a Noise helper + // running the all-zeros provisioning PSK so the encryption key can be + // set without crossing the wire in plaintext. Preserve the bytes we + // already consumed; they are the start of the Noise 3-byte header. + if (rx_header_buf_[0] == 0x01) { + rx_header_buf_pos_ = static_cast(received); + return APIError::PROTOCOL_SWITCH_TO_NOISE; + } +#endif state_ = State::FAILED; HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); return APIError::BAD_INDICATOR; diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index 8314754715..ea3f6d7280 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; +#ifdef USE_API_NOISE + // After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the + // header bytes already consumed from the socket (at most 3, the size of the + // Noise fixed header) so the replacement Noise helper can be seeded with them. + uint8_t get_consumed_header(uint8_t out[3]) const { + memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_); + return this->rx_header_buf_pos_; + } +#endif protected: APIError try_read_frame_(); diff --git a/esphome/components/api/api_noise_context.h b/esphome/components/api/api_noise_context.h index b5f7016689..44484ffa2c 100644 --- a/esphome/components/api/api_noise_context.h +++ b/esphome/components/api/api_noise_context.h @@ -10,13 +10,20 @@ using psk_t = std::array; class APINoiseContext { public: + // The all-zeros PSK is reserved: it marks the device as unprovisioned and + // doubles as the well-known provisioning PSK that unprovisioned devices + // accept for Noise handshakes (passive-sniffing protection only, no + // authentication). It is never a valid real key. + static bool is_all_zeros(const psk_t &psk) { + uint8_t acc = 0; + for (uint8_t b : psk) { + acc |= b; + } + return acc == 0; + } void set_psk(psk_t psk) { this->psk_ = psk; - bool has_psk = false; - for (auto i : psk) { - has_psk |= i; - } - this->has_psk_ = has_psk; + this->has_psk_ = !is_all_zeros(psk); } const psk_t &get_psk() const { return this->psk_; } bool has_psk() const { return this->has_psk_; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index de6ae4751e..190bd32425 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -170,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ for (const auto &it : this->serial_proxies) { ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); } +#endif +#ifdef USE_API_NOISE + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); #endif return pos; } @@ -232,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const { for (const auto &it : this->serial_proxies) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } +#endif +#ifdef USE_API_NOISE + size += ProtoSize::calc_bool(2, this->api_encryption_provisionable); #endif return size; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d268a40c56..4d5866da0b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -533,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 309; + static constexpr uint16_t ESTIMATED_SIZE = 312; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } #endif @@ -588,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; +#endif +#ifdef USE_API_NOISE + bool api_encryption_provisionable{false}; #endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3a1ceba95f..09570b09e4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -982,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { it.dump_to(out); out.append("\n"); } +#endif +#ifdef USE_API_NOISE + dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable); #endif return out.c_str(); } diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index bb4271a6ca..fa39e86ed0 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -110,7 +110,13 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx().has_psk(); txt_count++; // api_encryption or api_encryption_supported +#ifndef USE_API_NOISE_PSK_FROM_YAML + if (!api_has_psk) { + txt_count++; // api_provisioning + } +#endif #endif #ifdef ESPHOME_PROJECT_NAME txt_count += 2; // project_name and project_version @@ -166,9 +172,18 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx().has_psk(); - const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; + const char *encryption_key = api_has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)}); +#ifndef USE_API_NOISE_PSK_FROM_YAML + if (!api_has_psk) { + // Unprovisioned device without a YAML key: advertise that the encryption + // key can be provisioned over a zero-PSK Noise connection. Gated on the + // YAML define so this survives the plaintext removal in 2027.2.0. + MDNS_STATIC_CONST_CHAR(TXT_API_PROVISIONING, "api_provisioning"); + MDNS_STATIC_CONST_CHAR(VALUE_ZERO_PSK, "zero-psk"); + txt_records.push_back({MDNS_STR(TXT_API_PROVISIONING), MDNS_STR(VALUE_ZERO_PSK)}); + } +#endif #endif #ifdef ESPHOME_PROJECT_NAME diff --git a/tests/components/api/test-dynamic-encryption.esp32-idf.yaml b/tests/components/api/test-dynamic-encryption.esp32-idf.yaml index 504871716b..7563e3e9df 100644 --- a/tests/components/api/test-dynamic-encryption.esp32-idf.yaml +++ b/tests/components/api/test-dynamic-encryption.esp32-idf.yaml @@ -1,5 +1,11 @@ -<<: !include common-base.yaml +packages: + common: !include common-base.yaml wifi: ssid: MySSID password: password1 + +# Encryption enabled without a key: compiles both frame helpers so the key +# can be provisioned at runtime (zero-PSK noise or deprecated plaintext) +api: + encryption: diff --git a/tests/integration/fixtures/api_zero_psk_provisioning.yaml b/tests/integration/fixtures/api_zero_psk_provisioning.yaml new file mode 100644 index 0000000000..1bb2a43e71 --- /dev/null +++ b/tests/integration/fixtures/api_zero_psk_provisioning.yaml @@ -0,0 +1,6 @@ +esphome: + name: zero-psk-provision-test +host: +api: + encryption: +logger: diff --git a/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml b/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml new file mode 100644 index 0000000000..a798c038d7 --- /dev/null +++ b/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml @@ -0,0 +1,6 @@ +esphome: + name: zero-psk-plaintext-test +host: +api: + encryption: +logger: diff --git a/tests/integration/test_api_zero_psk_provisioning.py b/tests/integration/test_api_zero_psk_provisioning.py new file mode 100644 index 0000000000..bcea2a2471 --- /dev/null +++ b/tests/integration/test_api_zero_psk_provisioning.py @@ -0,0 +1,127 @@ +"""Integration tests for provisioning the encryption key over a zero-PSK connection. + +A device with `api: encryption:` but no key accepts Noise handshakes using the +well-known all-zeros PSK. The ephemeral X25519 exchange protects the key from +passive sniffing while it is provisioned; plaintext provisioning still works +but is deprecated. +""" + +from __future__ import annotations + +import asyncio +import base64 + +from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The well-known provisioning PSK: base64 of 32 zero bytes +ZERO_PSK = base64.b64encode(bytes(32)).decode() +# A real key to provision +NEW_KEY = base64.b64encode(b"n" * 32) +# Time for the device to activate a newly saved key (100ms timer plus margin) +KEY_ACTIVATION_DELAY = 0.5 + + +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Keep host preferences per-test so every run starts unprovisioned.""" + monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) + + +@pytest.mark.asyncio +async def test_api_zero_psk_provisioning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Exercise the reject paths, then provision a key over the zero-PSK channel.""" + async with run_compiled(yaml_config): + # --- Pre-provisioning reject paths (device state is unchanged) --- + + # A wrong (non-zero) PSK fails against the zero provisioning PSK + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected( + noise_psk=base64.b64encode(b"w" * 32).decode(), timeout=5 + ) as client: + await client.device_info() + + # A plaintext client and a zero-PSK client can be connected at the + # same time while the device is unprovisioned + async with ( + api_client_connected() as plaintext_client, + api_client_connected(noise_psk=ZERO_PSK) as noise_client, + ): + plaintext_info = await plaintext_client.device_info() + noise_info = await noise_client.device_info() + # Both transports advertise provisioning support so old and new + # clients can decide how to provision + assert plaintext_info.api_encryption_provisionable is True + assert noise_info.api_encryption_provisionable is True + + # The all-zeros key is reserved as the provisioning PSK and is + # rejected on both transports + zero_key = base64.b64encode(bytes(32)) + assert await noise_client.noise_encryption_set_key(zero_key) is False + assert await plaintext_client.noise_encryption_set_key(zero_key) is False + + # --- Provision over the zero-PSK channel --- + + # The unprovisioned device accepts the all-zeros PSK; the handshake's + # ephemeral-ephemeral DH encrypts everything that follows + async with api_client_connected(noise_psk=ZERO_PSK) as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-provision-test" + assert device_info.api_encryption_supported is True + assert device_info.api_encryption_provisionable is True + + assert await client.noise_encryption_set_key(NEW_KEY) is True + + # The device activates the new key shortly after responding + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + # The new key now works, and the device is no longer provisionable + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-provision-test" + assert device_info.api_encryption_provisionable is False + + # The zero PSK no longer works + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() + + # Plaintext no longer works + with pytest.raises(RequiresEncryptionAPIError): + async with api_client_connected(timeout=5) as client: + await client.device_info() + + +@pytest.mark.asyncio +async def test_api_zero_psk_provisioning_plaintext( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The legacy plaintext provisioning path still works and warns.""" + log_lines: list[str] = [] + async with run_compiled(yaml_config, line_callback=log_lines.append): + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-plaintext-test" + + assert await client.noise_encryption_set_key(NEW_KEY) is True + + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + # The deprecation warning was logged + assert any("deprecated" in line for line in log_lines) + + # The new key works; the zero PSK does not + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + assert (await client.device_info()).name == "zero-psk-plaintext-test" + + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() From a9591d7aac939794498a860f0c23ad2a317b240a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sun, 12 Jul 2026 15:58:17 -0500 Subject: [PATCH 2107/2214] [zwave_proxy] Fix parser gaps and harden frame and subscription handling (#17461) --- esphome/components/api/api_connection.cpp | 2 +- .../components/zwave_proxy/zwave_proxy.cpp | 125 ++++++++++++++---- esphome/components/zwave_proxy/zwave_proxy.h | 14 +- .../components/zwave_proxy/zwave_proxy.h | 2 +- 4 files changed, 109 insertions(+), 34 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2efdf0bc03..880b7cc404 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1383,7 +1383,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet #ifdef USE_ZWAVE_PROXY void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { - zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len); + zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len); } void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 8a24bd57d6..5f56861e6d 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -18,13 +18,22 @@ static const char *const TAG = "zwave_proxy"; static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168; static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; -// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] +// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...] +// We only read the home ID, so the node ID (1 byte in 8-bit mode, 2 bytes in 16-bit mode) and +// anything after it are not required to be present static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value -static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum +static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 7; // TYPE + CMD + HOME_ID(4) + checksum +static constexpr uint8_t ZWAVE_MIN_FRAME_LENGTH = 3; // TYPE + CMD + checksum (zero-payload frame) +static constexpr uint32_t ZWAVE_FRAME_TIMEOUT_MS = 1500; // Abandon a frame this long after its start (SOF) byte static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup static constexpr uint32_t RECONNECT_DELAY_MS = 500; // Delay between home ID query attempts after reconnect static constexpr uint8_t MAX_QUERY_RETRIES = 5; // Max attempts to query home ID after reconnect +static constexpr bool is_bootloader_menu_byte(uint8_t byte) { + // Bootloader menu output is printable ASCII plus CR/LF, ending with a NUL terminator + return byte == 0 || byte == '\r' || byte == '\n' || (byte >= 0x20 && byte <= 0x7E); +} + static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { // Calculate Z-Wave frame checksum // XOR all bytes between SOF and checksum position (exclusive) @@ -74,6 +83,11 @@ bool ZWaveProxy::can_proceed() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) { ESP_LOGW(TAG, "Timeout reading Home ID during setup"); + // The modem may simply still be booting; keep querying from loop() using the same retry + // machinery as a reconnect. This adds no setup delay — clients are notified of the home ID + // via the HOME_ID_CHANGE message whenever it finally arrives. + this->reconnect_time_ = now; + this->query_retries_ = 0; return true; // Proceed anyway after timeout } @@ -98,7 +112,18 @@ void ZWaveProxy::loop() { } this->process_uart_(); - this->status_clear_warning(); + + // Abandon a stalled frame reception. The Z-Wave API specification requires a receiver to abort + // a data frame reception lasting more than 1500 ms after the SOF byte, without sending a NAK. + // Without this, the stale bytes would silently corrupt the next frame. Any SEND_* state was + // already resolved by response_handler_() above, so a state other than WAIT_START here always + // means we are mid-frame. + if (this->parsing_state_ != ZWAVE_PARSING_STATE_WAIT_START && + App.get_loop_component_start_time() - this->frame_start_time_ > ZWAVE_FRAME_TIMEOUT_MS) { + ESP_LOGW(TAG, "Timeout waiting for frame data; resetting parser"); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->buffer_index_ = 0; + } } void ZWaveProxy::process_uart_slow_() { @@ -112,19 +137,24 @@ void ZWaveProxy::process_uart_slow_() { } if (this->parse_byte_(byte)) { // Check if this is a GET_NETWORK_IDS response frame - // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] + // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...] + // Bootloader output is excluded up front: a completed bootloader "frame" is menu text, so + // buffer_[1..3] would be meaningless (and possibly never written). Outside bootloader mode, + // the parser guarantees a completed frame starts with SOF, so buffer_[0] needs no check. // We verify: - // - buffer_[0]: Start of frame marker (0x01) - // - buffer_[1]: Length field must be >= 9 to contain all required data + // - buffer_[1]: Length field must be >= 7 so the frame contains the full home ID // - buffer_[2]: Command type (0x01 for response) // - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS) - if (this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS && this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && - this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) { + if (!this->in_bootloader_ && this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && + this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS) { // Store the 4-byte Home ID, which starts at offset 4, and notify connected clients if it changed // The frame parser has already validated the checksum and ensured all bytes are present if (this->set_home_id_(&this->buffer_[4])) { + char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; + ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); this->send_homeid_changed_msg_(); } + this->home_id_ready_ = true; } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { @@ -140,14 +170,19 @@ void ZWaveProxy::process_uart_slow_() { } } } while (this->available()); + // Reaching here means every read succeeded, so clear any earlier read-failure warning. + // (An early return on read failure skips this, leaving the warning visible until the + // next successful drain.) + this->status_clear_warning(); } void ZWaveProxy::dump_config() { char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; - ESP_LOGCONFIG(TAG, - "Z-Wave Proxy:\n" - " Home ID: %s", - format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); + ESP_LOGCONFIG( + TAG, + "Z-Wave Proxy:\n" + " Home ID: %s", + this->home_id_ready_ ? format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size()) : "unknown"); } void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { @@ -160,10 +195,20 @@ void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) { switch (type) { case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE: - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + if (this->api_connection_ == api_connection) { + ESP_LOGV(TAG, "API connection is already subscribed"); return; } + if (this->api_connection_ != nullptr) { + // A living subscriber keeps exclusive access. Its connection may be dead without + // loop() having noticed yet (e.g. the client crashed and reconnected quickly); + // in that case let the new client take over instead of locking it out. + if (this->api_connection_->is_connection_setup()) { + ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + return; + } + ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + } this->api_connection_ = api_connection; ESP_LOGV(TAG, "API connection is now subscribed"); break; @@ -222,6 +267,7 @@ void ZWaveProxy::retry_home_id_query_() { void ZWaveProxy::clear_home_id_() { static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {}; if (this->set_home_id_(ZERO_HOME_ID)) { + ESP_LOGV(TAG, "Home ID cleared"); this->send_homeid_changed_msg_(); } this->home_id_ready_ = false; @@ -237,13 +283,20 @@ bool ZWaveProxy::set_home_id_(const uint8_t *new_home_id) { return false; // No change } std::memcpy(this->home_id_.data(), new_home_id, this->home_id_.size()); - char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; - ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); - this->home_id_ready_ = true; return true; // Home ID was changed } -void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { +void ZWaveProxy::send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) { + // Only the subscribed client may talk to the Z-Wave module; a frame from any other + // (authenticated but unsubscribed) client would interleave with the subscriber's traffic + if (api_connection != this->api_connection_) { + ESP_LOGW(TAG, "Ignoring frame from unsubscribed client"); + return; + } + this->send_frame_(data, length); +} + +void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) { // Safety: validate pointer before any access if (data == nullptr) { ESP_LOGE(TAG, "Null data pointer"); @@ -289,7 +342,7 @@ void ZWaveProxy::send_simple_command_(const uint8_t command_id) { // Where LENGTH=0x03 (3 bytes: TYPE + CMD + CHECKSUM) uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00}; cmd[4] = calculate_frame_checksum(cmd, sizeof(cmd)); - this->send_frame(cmd, sizeof(cmd)); + this->send_frame_(cmd, sizeof(cmd)); } bool ZWaveProxy::parse_byte_(uint8_t byte) { @@ -300,9 +353,12 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { this->parse_start_(byte); break; case ZWAVE_PARSING_STATE_WAIT_LENGTH: - if (!byte) { + if (byte < ZWAVE_MIN_FRAME_LENGTH) { ESP_LOGW(TAG, "Invalid LENGTH: %u", byte); this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; + // Send the NAK now; otherwise any bytes already buffered behind this one would be + // silently discarded by the SEND_NAK case below until the next loop() iteration + this->response_handler_(); return false; } ESP_LOGVV(TAG, "Received LENGTH: %u", byte); @@ -319,7 +375,9 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID: this->buffer_[this->buffer_index_++] = byte; ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte); - this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD; + // A zero-payload frame (LENGTH == 3) has its checksum immediately after the command ID + this->parsing_state_ = this->buffer_index_ >= this->end_frame_after_ ? ZWAVE_PARSING_STATE_WAIT_CHECKSUM + : ZWAVE_PARSING_STATE_WAIT_PAYLOAD; break; case ZWAVE_PARSING_STATE_WAIT_PAYLOAD: this->buffer_[this->buffer_index_++] = byte; @@ -347,12 +405,24 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { break; } case ZWAVE_PARSING_STATE_READ_BL_MENU: - if (this->buffer_index_ >= this->buffer_.size()) { + // This state is tentative (see parse_start_): bootloader mode is committed only when a + // plausible menu — printable text ending in a NUL terminator — completes. A byte that + // cannot be menu text means the 0x0D that started this state was not a menu after all, + // so re-parse that byte as a frame start; it may be the SOF/ACK/NAK of real traffic. + if (this->buffer_index_ >= this->buffer_.size() || !is_bootloader_menu_byte(byte)) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->parse_start_(byte); break; } this->buffer_[this->buffer_index_++] = byte; if (!byte) { + if (!this->in_bootloader_) { + ESP_LOGD(TAG, "Entered bootloader mode"); + this->in_bootloader_ = true; + // Reset response deduplication: in bootloader mode, single-byte client writes (XMODEM + // ACK/NAK/CAN) are raw data and must never be suppressed as duplicate responses + this->last_response_ = 0; + } this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; frame_completed = true; } @@ -378,15 +448,16 @@ void ZWaveProxy::parse_start_(uint8_t byte) { ESP_LOGD(TAG, "Exited bootloader mode"); this->in_bootloader_ = false; } + this->frame_start_time_ = App.get_loop_component_start_time(); this->buffer_[this->buffer_index_++] = byte; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH; return; case ZWAVE_FRAME_TYPE_BL_MENU: ESP_LOGV(TAG, "Received BL_MENU"); - if (!this->in_bootloader_) { - ESP_LOGD(TAG, "Entered bootloader mode"); - this->in_bootloader_ = true; - } + // Read the menu tentatively: a stray 0x0D can equally appear in garbled data after the + // parser loses frame alignment, so bootloader mode is only committed once a plausible + // menu completes (see READ_BL_MENU handling in parse_byte_) + this->frame_start_time_ = App.get_loop_component_start_time(); this->buffer_[this->buffer_index_++] = byte; this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU; return; @@ -403,7 +474,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { ESP_LOGV(TAG, "Received CAN"); break; default: - ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte); + ESP_LOGV(TAG, "Unrecognized START: 0x%02X", byte); return; } // Forward response (ACK/NAK/CAN) back to client for processing diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index ec52b15cd9..cb60139ef8 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -68,13 +68,16 @@ class ZWaveProxy final : public uart::UARTDevice, public Component { return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]); } - void send_frame(const uint8_t *data, size_t length); + // Send a frame from an API client to the Z-Wave module. Frames from any connection other + // than the currently subscribed one are ignored. + void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length); protected: - bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. - void clear_home_id_(); // Clear home ID and notify API clients - void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions - void retry_home_id_query_(); // Retry home ID query after reconnect + void send_frame_(const uint8_t *data, size_t length); // Write a frame to the Z-Wave module + bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. + void clear_home_id_(); // Clear home ID and notify API clients + void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions + void retry_home_id_query_(); // Retry home ID query after reconnect void send_homeid_changed_msg_(api::APIConnection *conn = nullptr); void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) @@ -114,6 +117,7 @@ class ZWaveProxy final : public uart::UARTDevice, public Component { api::APIConnection *api_connection_{nullptr}; // Current subscribed client uint32_t setup_time_{0}; // Time when setup() was called uint32_t reconnect_time_{0}; // Timestamp of reconnect detection (0 = no pending query) + uint32_t frame_start_time_{0}; // Timestamp of the current frame's start byte (reception timeout) // Small values (grouped by size to minimize padding) uint16_t buffer_index_{0}; // Index for populating the data buffer diff --git a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h index ba97e81236..b4ccd8fd00 100644 --- a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h @@ -16,7 +16,7 @@ class ZWaveProxy { public: api::APIConnection *get_api_connection() { return nullptr; } void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {} - void send_frame(const uint8_t *data, size_t length) {} + void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {} void api_connection_authenticated(api::APIConnection *conn) {} uint32_t get_feature_flags() const { return 0; } uint32_t get_home_id() { return 0; } From 2a67e5c5999609957baaea28c16bd24fe50f31bb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:19:24 +1200 Subject: [PATCH 2108/2214] Bump version to 2026.7.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 6f8b6e6664..1bcfded35d 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0b1 +PROJECT_NUMBER = 2026.7.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 faa716bdd7..f6014176b8 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0b1" +__version__ = "2026.7.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0ff11674ef2f30bcaa40efbb024e7fd089c82862 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:38:38 +1200 Subject: [PATCH 2109/2214] [mipi_rgb] Use dict-style packages in test so it can be batch-grouped (#17533) --- tests/components/mipi_rgb/test.esp32-s3-idf.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index b56ebee21e..12b45ee160 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ packages: - - !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml psram: mode: octal From 7ee7a26cad67be214794ff9b9a7a2d119ecaf6ff Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:46:13 +1200 Subject: [PATCH 2110/2214] [mipi_rgb] Use dict-style packages in test so it can be batch-grouped Convert the i2c include to a named dict-style package key so CI can group this component's build with others sharing the same bus, instead of flagging it as needing migration. --- tests/components/mipi_rgb/test.esp32-s3-idf.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index b56ebee21e..12b45ee160 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ packages: - - !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml psram: mode: octal From b3e03868b3850acdff8ead9e50553d7ad8e48603 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:00:22 -0400 Subject: [PATCH 2111/2214] [mipi_rgb] Test in isolation to avoid bus/pin merge conflicts (#17534) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 8eb80d9943..a6ccb79544 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -90,6 +90,7 @@ ISOLATED_COMPONENTS = { "openthread_info": "Conflicts with wifi: used by most components", "matrix_keypad": "Needs isolation due to keypad", "microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged", + "mipi_rgb": "RGB display occupies many GPIOs (including ones used by the shared i2c bus) that conflict when merged with other bus components", "modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", From c607f64288e3ef8c7020e4c19595961887f9ca9e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:00:22 -0400 Subject: [PATCH 2112/2214] [mipi_rgb] Test in isolation to avoid bus/pin merge conflicts (#17534) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 8eb80d9943..a6ccb79544 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -90,6 +90,7 @@ ISOLATED_COMPONENTS = { "openthread_info": "Conflicts with wifi: used by most components", "matrix_keypad": "Needs isolation due to keypad", "microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged", + "mipi_rgb": "RGB display occupies many GPIOs (including ones used by the shared i2c bus) that conflict when merged with other bus components", "modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", From 07460ebee443f718979b7b8703e126fb75409f97 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:28:55 +1200 Subject: [PATCH 2113/2214] [gsl3670] Fix i2c package variant in esp32-s3-idf test (#17535) --- tests/components/gsl3670/test.esp32-s3-idf.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 48bb9982d9..5c3f4b931c 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ packages: - i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml xl9535: @@ -10,6 +10,9 @@ display: id: gsl3670_display spi_id: spi_bus model: t-display-s3-pro + # The model's default DC pin (GPIO9) clashes with the shared i2c bus SCL + # pin, so override it onto a free pin for this test. + dc_pin: GPIO5 psram: mode: quad From 4a82b1078354d1aecb9c434c336a97a63b3a04e0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:29:35 +1200 Subject: [PATCH 2114/2214] [ci] Group component test output into collapsible CI log sections (#17536) --- script/test_build_components.py | 164 +++++++------- tests/script/test_test_build_components.py | 238 +++++++++++++++++++++ 2 files changed, 330 insertions(+), 72 deletions(-) create mode 100644 tests/script/test_test_build_components.py diff --git a/script/test_build_components.py b/script/test_build_components.py index ce2a35add3..c733e2fa3d 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -88,6 +88,38 @@ def show_disk_space_if_ci(esphome_command: str) -> None: sys.stdout.flush() +def start_log_group(title: str) -> None: + """Begin a collapsible log group in the GitHub Actions log viewer. + + Everything printed until the matching :func:`end_log_group` is folded away + by default, so the full ``esphome config``/``compile`` dump for one + configuration no longer pushes the pass/fail result thousands of lines down + the log. Outside CI this is a no-op so local runs stay plain. + + Args: + title: Text shown on the (collapsed) group header line. + """ + if not os.environ.get("GITHUB_ACTIONS"): + return + # Flush so the marker is ordered correctly relative to the child process + # output that follows (the subprocess writes straight to our stdout). + sys.stdout.flush() + print(f"::group::{title}") + sys.stdout.flush() + + +def end_log_group() -> None: + """Close the collapsible log group opened by :func:`start_log_group`. + + Outside CI this is a no-op. + """ + if not os.environ.get("GITHUB_ACTIONS"): + return + sys.stdout.flush() + print("::endgroup::") + sys.stdout.flush() + + def find_component_tests( components_dir: Path, component_pattern: str = "*", @@ -383,54 +415,48 @@ def run_esphome_test( # Build command string for display/logging cmd_str = " ".join(cmd) - # Run command - print(f"> [{component}] [{test_name}] [{platform_with_version}]") + # Run command inside a collapsible CI log group so the full esphome output + # for this configuration can be folded away by default. + group_title = f"[{component}] [{test_name}] [{platform_with_version}]" + start_log_group(group_title) + print(f"> {group_title}") if use_testing_mode: print(" (using --testing-mode)") start_time = time.time() test_id = f"{component}.{test_name}.{platform_with_version}" + # Always close the group, even if the subprocess or disk-space reporting + # raises, so later output is never folded into the wrong CI log section. try: result = subprocess.run(cmd, check=False) - success = result.returncode == 0 - duration = time.time() - start_time - # Show disk space after build in CI during compile show_disk_space_if_ci(esphome_command) + finally: + end_log_group() - if not success and not continue_on_fail: - # Print command immediately for failed tests - print(f"\n{'=' * 80}") - print("FAILED - Command to reproduce:") - print(f"{'=' * 80}") - print(cmd_str) - print() - raise subprocess.CalledProcessError(result.returncode, cmd) + success = result.returncode == 0 + duration = time.time() - start_time - return TestResult( - test_id=test_id, - components=[component], - platform=platform_with_version, - success=success, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) - except subprocess.CalledProcessError: - duration = time.time() - start_time - # Re-raise if we're not continuing on fail - if not continue_on_fail: - raise - return TestResult( - test_id=test_id, - components=[component], - platform=platform_with_version, - success=False, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) + if not success and not continue_on_fail: + # Print command immediately for failed tests. The group is already + # closed, so the failure and reproduce command stay visible. + print(f"\n{'=' * 80}") + print("FAILED - Command to reproduce:") + print(f"{'=' * 80}") + print(cmd_str) + print() + raise subprocess.CalledProcessError(result.returncode, cmd) + + return TestResult( + test_id=test_id, + components=[component], + platform=platform_with_version, + success=success, + duration=duration, + command=cmd_str, + test_type=esphome_command, + ) def run_grouped_test( @@ -534,54 +560,48 @@ def run_grouped_test( # Build command string for display/logging cmd_str = " ".join(cmd) - # Run command + # Run command inside a collapsible CI log group so the full esphome output + # for this grouped configuration can be folded away by default. components_str = ", ".join(components) - print(f"> [GROUPED: {components_str}] [{platform_with_version}]") + group_title = f"[GROUPED: {components_str}] [{platform_with_version}]" + start_log_group(group_title) + print(f"> {group_title}") print(" (using --testing-mode)") start_time = time.time() test_id = f"GROUPED[{','.join(components)}].{platform_with_version}" + # Always close the group, even if the subprocess or disk-space reporting + # raises, so later output is never folded into the wrong CI log section. try: result = subprocess.run(cmd, check=False) - success = result.returncode == 0 - duration = time.time() - start_time - # Show disk space after build in CI during compile show_disk_space_if_ci(esphome_command) + finally: + end_log_group() - if not success and not continue_on_fail: - # Print command immediately for failed tests - print(f"\n{'=' * 80}") - print("FAILED - Command to reproduce:") - print(f"{'=' * 80}") - print(cmd_str) - print() - raise subprocess.CalledProcessError(result.returncode, cmd) + success = result.returncode == 0 + duration = time.time() - start_time - return TestResult( - test_id=test_id, - components=components, - platform=platform_with_version, - success=success, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) - except subprocess.CalledProcessError: - duration = time.time() - start_time - # Re-raise if we're not continuing on fail - if not continue_on_fail: - raise - return TestResult( - test_id=test_id, - components=components, - platform=platform_with_version, - success=False, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) + if not success and not continue_on_fail: + # Print command immediately for failed tests. The group is already + # closed, so the failure and reproduce command stay visible. + print(f"\n{'=' * 80}") + print("FAILED - Command to reproduce:") + print(f"{'=' * 80}") + print(cmd_str) + print() + raise subprocess.CalledProcessError(result.returncode, cmd) + + return TestResult( + test_id=test_id, + components=components, + platform=platform_with_version, + success=success, + duration=duration, + command=cmd_str, + test_type=esphome_command, + ) def run_grouped_component_tests( diff --git a/tests/script/test_test_build_components.py b/tests/script/test_test_build_components.py new file mode 100644 index 0000000000..74e150380c --- /dev/null +++ b/tests/script/test_test_build_components.py @@ -0,0 +1,238 @@ +"""Unit tests for script/test_build_components.py logging helpers.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to the path so we can import the module under test. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import test_build_components as tbc # noqa: E402 + + +class _FakeCompleted: + """Minimal stand-in for subprocess.CompletedProcess.""" + + def __init__(self, returncode: int) -> None: + self.returncode = returncode + + +@pytest.fixture +def _no_ci(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure GITHUB_ACTIONS is unset so group markers are suppressed.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + +@pytest.fixture +def _ci(monkeypatch: pytest.MonkeyPatch) -> None: + """Pretend we are running inside GitHub Actions.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + + +def test_start_log_group_outside_ci_is_silent( + _no_ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.start_log_group("hello") + assert capsys.readouterr().out == "" + + +def test_end_log_group_outside_ci_is_silent( + _no_ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.end_log_group() + assert capsys.readouterr().out == "" + + +def test_start_log_group_in_ci_emits_marker( + _ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.start_log_group("hello") + assert capsys.readouterr().out == "::group::hello\n" + + +def test_end_log_group_in_ci_emits_marker( + _ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.end_log_group() + assert capsys.readouterr().out == "::endgroup::\n" + + +def _make_base_file(tmp_path: Path) -> Path: + base_file = tmp_path / "base.yaml" + base_file.write_text("esphome:\n name: $component_test_file\n") + return base_file + + +def test_run_esphome_test_wraps_output_in_group( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A passing single-component test is bracketed by group markers.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(0)) + repo_root = Path(tbc.__file__).parent.parent + test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml" + + result = tbc.run_esphome_test( + component="foo", + test_file=test_file, + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + out = capsys.readouterr().out + assert result.success is True + assert "::group::[foo] [test] [esp32-idf]" in out + assert "::endgroup::" in out + # The header line is printed inside the group. + assert out.index("::group::") < out.index("> [foo]") < out.index("::endgroup::") + + +def test_run_esphome_test_closes_group_before_failure_report( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """On a fail-fast failure the group closes before the reproduce report.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(1)) + repo_root = Path(tbc.__file__).parent.parent + test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml" + + # continue_on_fail=False makes the failure raise after printing the + # reproduce block, which is the path that must stay outside the group. + with pytest.raises(tbc.subprocess.CalledProcessError): + tbc.run_esphome_test( + component="foo", + test_file=test_file, + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + esphome_command="config", + continue_on_fail=False, + ) + + out = capsys.readouterr().out + assert "::endgroup::" in out + assert "FAILED - Command to reproduce:" in out + # The group must be closed before the failure report is printed. + assert out.index("::endgroup::") < out.index("FAILED - Command to reproduce:") + + +def test_run_esphome_test_closes_group_when_subprocess_raises( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """If the subprocess raises, the group is still closed (via finally).""" + + def _boom(*a: object, **k: object) -> None: + raise OSError("boom") + + monkeypatch.setattr(tbc.subprocess, "run", _boom) + repo_root = Path(tbc.__file__).parent.parent + test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml" + + with pytest.raises(OSError, match="boom"): + tbc.run_esphome_test( + component="foo", + test_file=test_file, + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + assert "::endgroup::" in capsys.readouterr().out + + +def test_run_grouped_test_wraps_output_in_group( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A grouped test is bracketed by group markers listing its components.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(0)) + monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None) + + result = tbc.run_grouped_test( + components=["foo", "bar"], + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + tests_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + out = capsys.readouterr().out + assert result.success is True + assert "::group::[GROUPED: foo, bar] [esp32-idf]" in out + assert out.index("::group::") < out.index("> [GROUPED") < out.index("::endgroup::") + + +def test_run_grouped_test_closes_group_before_failure_report( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A fail-fast grouped failure closes the group before the report.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(1)) + monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None) + + with pytest.raises(tbc.subprocess.CalledProcessError): + tbc.run_grouped_test( + components=["foo", "bar"], + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + tests_dir=tmp_path, + esphome_command="config", + continue_on_fail=False, + ) + + out = capsys.readouterr().out + assert out.index("::endgroup::") < out.index("FAILED - Command to reproduce:") + + +def test_run_grouped_test_closes_group_when_subprocess_raises( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """If the grouped subprocess raises, the group is still closed (finally).""" + + def _boom(*a: object, **k: object) -> None: + raise OSError("boom") + + monkeypatch.setattr(tbc.subprocess, "run", _boom) + monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None) + + with pytest.raises(OSError, match="boom"): + tbc.run_grouped_test( + components=["foo", "bar"], + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + tests_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + assert "::endgroup::" in capsys.readouterr().out From a8dfd00cc6cbe12ecc59a7da9e6950784310263e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:38:48 +1200 Subject: [PATCH 2115/2214] [web_server] Add CORS origin checking with allowed_origins (#17530) --- THREAT_MODEL.md | 39 +++++---- esphome/components/web_server/__init__.py | 46 +++++++++- esphome/components/web_server/web_server.cpp | 71 +++++++++++++-- esphome/components/web_server/web_server.h | 26 ++++++ esphome/core/defines.h | 1 + .../web_server/test_private_network_access.py | 86 +++++++++++++++++++ tests/components/web_server/common_v2.yaml | 3 + tests/components/web_server/common_v3.yaml | 3 + 8 files changed, 250 insertions(+), 25 deletions(-) create mode 100644 tests/component_tests/web_server/test_private_network_access.py diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 24a7fed4f2..5816f38176 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -92,18 +92,24 @@ is choosing an open control surface, in the same way that running native OTA without a password leaves OTA open. The API is documented and is meant to be called by other devices, scripts, and pages. -The device performs no CSRF token, `Origin`, or `Referer` validation and returns -a permissive CORS policy. Cross-origin requests are handled the same as any other -network request, including requests a browser is induced to make by a page the -operator visits (the "confused deputy", or CSRF, pattern). The following are -therefore **not** vulnerabilities in this repository: +As defense-in-depth, the web server checks the `Origin` header on browser requests +to its entity control and state endpoints: a request whose `Origin` does not match +the address the device is served on is rejected, and the `allowed_origins` option +widens that list. This blocks the common "confused deputy" (CSRF) case where a page +the operator visits drives the device through their browser. It is **not** an +authentication boundary: it only constrains browsers. Any client that omits the +`Origin` header — `curl`, scripts, or other non-browser callers on the same +network — reaches every endpoint exactly as before. The check also does not cover +the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer` +validation. The following are therefore **not** vulnerabilities in this repository: -- Cross-origin or CSRF requests to the control endpoints (for example, a page the - operator opens toggling a switch), whether or not `web_server` `auth:` is set. -- Cross-origin reads of device state permitted by the CORS policy. -- Cross-origin firmware upload through the web OTA endpoint (`/update`) when web - OTA is enabled without `web_server` `auth:`. This is the same exposure as - running OTA without a password. +- Requests without an `Origin` header (for example `curl`) reaching the control + endpoints, whether or not `web_server` `auth:` is set. +- Requests from an origin the operator added to `allowed_origins`. +- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when + web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not + covered by the `Origin` check; this is the same exposure as running OTA without a + password. The supported defenses are `web_server` `auth:`, protecting OTA (a web password or a native OTA password), and keeping devices on a trusted, segmented network. See @@ -113,9 +119,7 @@ What remains in scope is bypassing `web_server` `auth:` when it *is* configured, and any memory-safety or protocol bug in the server reachable without credentials. This section documents the current design and scope; it is not a judgment that the -design is optimal or that it will not change. Optional hardening (for example an -origin allowlist or opt-in CSRF checks) is welcome as a normal enhancement PR, -framed as defense-in-depth rather than a security fix. +design is optimal or that it will not change. ## Explicitly out of scope @@ -124,9 +128,10 @@ framed as defense-in-depth rather than a security fix. - Operator-supplied hostile YAML (covered above — config authoring is trusted). - Attacks that require an already-authenticated device peer (someone who already holds the API key / OTA / web credentials). -- Cross-site (CSRF), cross-origin, or CORS behavior of the device web server and - its web OTA endpoint. The web server is an open HTTP API by design (see above); - gate it with `web_server` `auth:` and network isolation. +- Access to the device web server or its web OTA endpoint by non-browser clients + (those that send no `Origin` header). The web server is an open HTTP API by + design (see above); browser cross-origin requests are blocked by default, but the + real controls are `web_server` `auth:` and network isolation. - Anything in the dashboard / device-builder — report that in its own repository (linked at the top). - Deployments where the operator removed protections or exposed credentials. See diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index d9fd27dbc2..68f1c18072 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import gzip import logging +import re import esphome.codegen as cg from esphome.components import web_server_base @@ -46,6 +47,7 @@ AUTO_LOAD = ["json", "web_server_base"] CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" +CONF_ALLOWED_ORIGINS = "allowed_origins" web_server_ns = cg.esphome_ns.namespace("web_server") @@ -104,6 +106,41 @@ def validate_ota(config: ConfigType) -> ConfigType: return config +# An Origin header is always "scheme://host[:port]" with no path or trailing slash. +_ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$") + + +def validate_origin(value: str) -> str: + # "*" is the wildcard that allows any origin. + if value == "*": + return value + value = cv.string_strict(value) + if not _ORIGIN_RE.match(value): + raise cv.Invalid( + f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no " + f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin." + ) + # Browsers send the scheme and host lowercased in the Origin header, so normalize to match. + return value.lower() + + +def validate_private_network_access(config: ConfigType) -> ConfigType: + # PNA preflights are always cross-origin, so they can only be authorized against the + # allowed_origins list. Enabling PNA without any origins would deny every PNA request. + if ( + config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS] + and config.get(CONF_ALLOWED_ORIGINS) is None + ): + raise cv.Invalid( + f"'{CONF_ALLOWED_ORIGINS}' must be set when " + f"'{CONF_ENABLE_PRIVATE_NETWORK_ACCESS}' is enabled. List each origin that is " + f"allowed to reach the device (e.g. 'https://example.com'). '*' allows any origin " + f"but is not recommended.", + path=[CONF_ENABLE_PRIVATE_NETWORK_ACCESS], + ) + return config + + def validate_sorting_groups(config: ConfigType) -> ConfigType: if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3: raise cv.Invalid( @@ -201,7 +238,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CSS_INCLUDE): cv.file_, cv.Optional(CONF_JS_URL): cv.string, cv.Optional(CONF_JS_INCLUDE): cv.file_, - cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=True): cv.boolean, + cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=False): cv.boolean, + cv.Optional(CONF_ALLOWED_ORIGINS): cv.All( + cv.ensure_list(validate_origin), cv.Length(min=1) + ), cv.Optional(CONF_AUTH): cv.Schema( { cv.Required(CONF_USERNAME): cv.All( @@ -238,6 +278,7 @@ CONFIG_SCHEMA = cv.All( validate_local, validate_sorting_groups, validate_ota, + validate_private_network_access, _consume_web_server_sockets, ) @@ -334,6 +375,9 @@ async def to_code(config): request_log_listener() # Request a log listener slot for web server log streaming if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") + if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: + cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") + cg.add(var.set_allowed_origins(allowed_origins)) if CONF_AUTH in config: cg.add_define("USE_WEBSERVER_AUTH") cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3bba879823..1e6c4e8c62 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -456,9 +456,58 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #endif +// Read a request header value portably across the Arduino and ESP-IDF web servers. +// Returns an empty string when the header is absent (only allocates when a value is present). +static std::string get_request_header(AsyncWebServerRequest *request, const char *name) { +#ifdef USE_ESP32 + // ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend. + optional value = request->get_header(name); + return value.has_value() ? std::move(*value) : std::string(); +#else + // ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend. + const AsyncWebHeader *header = request->getHeader(name); + return header != nullptr ? std::string(header->value().c_str()) : std::string(); +#endif +} + +bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) { + // No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow. + if (origin.empty()) + return true; + + // Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to. + // This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time. + const size_t scheme_sep = origin.find("://"); + if (scheme_sep != std::string::npos) { + const std::string host = get_request_header(request, "Host"); + if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + return true; + } + +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + // Otherwise the origin must be explicitly allowed via configuration. + for (const char *allowed_origin : this->allowed_origins_) { + // A single "*" entry allows any origin. + if (allowed_origin[0] == '*' && allowed_origin[1] == '\0') + return true; + if (origin == allowed_origin) + return true; + } +#endif + return false; +} + #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { + const std::string origin = get_request_header(request, "Origin"); + if (!this->is_request_origin_allowed_(request, origin)) { + request->send(403); + return; + } + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("")); + // Echo the specific origin back so the response is valid even when auth (credentials) is enabled. + 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]; @@ -2448,6 +2497,21 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { return; } +#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS + // Private Network Access preflight carries a cross-origin Origin by design; its handler does the + // origin check itself, so let it run before the general enforcement below. + if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { + this->handle_pna_cors_request(request); + return; + } +#endif + + // Reject cross-origin browser requests unless the origin is explicitly allowed. + if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) { + request->send(403); + return; + } + #if !defined(USE_ESP32) && defined(USE_ARDUINO) if (url == ESPHOME_F("/events")) { this->events_.add_new_client(this, request); @@ -2469,13 +2533,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif -#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS - if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { - this->handle_pna_cors_request(request); - return; - } -#endif - // Parse URL for component routing // Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action) UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 42182fe510..0fbe4ec551 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -242,6 +242,22 @@ class WebServer final : public Controller, public Component, public AsyncWebHand */ void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; } +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + /** Set the origins that browsers are allowed to make cross-origin requests from. + * + * Requests without an `Origin` header (e.g. non-browser clients like curl or the native API) + * are always allowed. Requests whose `Origin` matches the address the device is served on + * (same-origin) are always allowed. Any other browser origin must appear in this list, or the + * request is rejected. A single "*" entry allows any origin. Each other entry must exactly match + * the requesting page's `Origin` header (e.g. "https://example.com"). + * + * This list is also used to authorize Private Network Access requests when that feature is enabled. + * + * @param origins The list of allowed origins. + */ + void set_allowed_origins(std::initializer_list origins) { this->allowed_origins_ = origins; } +#endif + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup the internal web server and register handlers. @@ -593,6 +609,16 @@ class WebServer final : public Controller, public Component, public AsyncWebHand const char *js_include_{nullptr}; #endif bool expose_log_{true}; +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + // Extra origins allowed to make cross-origin browser requests ("*" means any origin). + // Only compiled when allowed_origins is configured; same-origin is always allowed regardless. + FixedVector allowed_origins_; +#endif + + /// Check whether the given request Origin is permitted. Same-origin (matching the Host the + /// request was sent to) and requests without an Origin header are always allowed; any other + /// origin must be listed in allowed_origins. The caller passes the already-read Origin header. + bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin); private: #ifdef USE_SENSOR diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bdb0f27f45..78f7769cf6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -302,6 +302,7 @@ #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP #define USE_WEBSERVER_SORTING +#define USE_WEBSERVER_ALLOWED_ORIGINS #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT diff --git a/tests/component_tests/web_server/test_private_network_access.py b/tests/component_tests/web_server/test_private_network_access.py new file mode 100644 index 0000000000..87911c5f9b --- /dev/null +++ b/tests/component_tests/web_server/test_private_network_access.py @@ -0,0 +1,86 @@ +"""Tests for web_server Private Network Access / allowed_origins validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.web_server import ( + CONF_ALLOWED_ORIGINS, + validate_origin, + validate_private_network_access, +) +from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS +from esphome.types import ConfigType + + +def test_pna_enabled_without_origins_fails() -> None: + """Enabling PNA without allowed_origins must fail validation.""" + config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True} + + with pytest.raises(cv.Invalid) as exc_info: + validate_private_network_access(config) + + error_msg = str(exc_info.value) + assert CONF_ALLOWED_ORIGINS in error_msg + assert "must be set" in error_msg + + +def test_pna_enabled_with_origins_passes() -> None: + """Enabling PNA with at least one allowed origin passes validation.""" + config: ConfigType = { + CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True, + CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"], + } + assert validate_private_network_access(config) == config + + +def test_origins_without_pna_passes() -> None: + """allowed_origins can be set without enabling PNA (they are independent).""" + config: ConfigType = { + CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False, + CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"], + } + assert validate_private_network_access(config) == config + + +def test_pna_disabled_without_origins_passes() -> None: + """PNA disabled and no origins specified passes validation.""" + config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False} + assert validate_private_network_access(config) == config + + +def test_validate_origin_wildcard() -> None: + """The '*' wildcard is accepted as-is.""" + assert validate_origin("*") == "*" + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com", + "http://example.com:8080", + "https://192.168.1.5", + ], +) +def test_validate_origin_valid(value: str) -> None: + """Well-formed origins pass through unchanged.""" + assert validate_origin(value) == value + + +def test_validate_origin_lowercased() -> None: + """Scheme and host are normalized to lowercase to match the browser Origin header.""" + assert validate_origin("HTTPS://App.Example.com") == "https://app.example.com" + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com/", # trailing slash + "https://example.com/path", # path segment + "example.com", # missing scheme + "", # empty + ], +) +def test_validate_origin_invalid(value: str) -> None: + """Malformed origins are rejected at config time instead of silently 403ing.""" + with pytest.raises(cv.Invalid, match="not a valid origin"): + validate_origin(value) diff --git a/tests/components/web_server/common_v2.yaml b/tests/components/web_server/common_v2.yaml index f2b15e484d..b9bc0bbf61 100644 --- a/tests/components/web_server/common_v2.yaml +++ b/tests/components/web_server/common_v2.yaml @@ -5,3 +5,6 @@ web_server: port: 8080 version: 2 compression: br + enable_private_network_access: true + allowed_origins: + - https://app.esphome.io diff --git a/tests/components/web_server/common_v3.yaml b/tests/components/web_server/common_v3.yaml index bdacaaddbe..354d7bb6ac 100644 --- a/tests/components/web_server/common_v3.yaml +++ b/tests/components/web_server/common_v3.yaml @@ -4,6 +4,9 @@ packages: web_server: port: 8080 version: 3 + # allowed_origins can be set independently of Private Network Access + allowed_origins: + - https://app.esphome.io sorting_groups: - id: sorting_group_1 name: "Group 1 Diplayed Last" From bcfb438a81814dab8e757e9347563ccea969c9a2 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sun, 12 Jul 2026 23:23:40 -0500 Subject: [PATCH 2116/2214] [esp32] Do not require verification_key with Secure Boot V2 signing schemes (#17497) --- esphome/components/esp32/__init__.py | 99 +++++++++++++++---- tests/component_tests/esp32/test_esp32.py | 75 ++++++++++++++ ...date-signed_ota_external.esp32-s3-idf.yaml | 11 +++ 3 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7c926fe28e..9b568dd629 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1160,6 +1160,74 @@ def _ota_downgrade_protection_errors( return errs +_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_SIGNING_KEY): cv.file_, + cv.Optional(CONF_VERIFICATION_KEY): cv.file_, + cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of( + *SIGNING_SCHEMES, lower=True + ), + } +) + + +@schema_extractor("schema") +def _validate_signed_ota_verification(value): + if value is SCHEMA_EXTRACT: + # Expose the inner schema so the language-schema dumper can walk the + # signing_key / verification_key / signing_scheme options. + return _SIGNED_OTA_VERIFICATION_SCHEMA + if value is None: + # A bare `signed_ota_verification:` block is valid: the default V2 + # scheme needs no keys (verify externally-signed binaries). + value = {} + return _validate_signed_ota_keys(_SIGNED_OTA_VERIFICATION_SCHEMA(value)) + + +def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: + """Validate the signing/verification key combination for the selected scheme. + + A verification key is only used by the Secure Boot V1 scheme (ecdsa_v1): + the public key is compiled into the app so it can verify externally-signed + images. ESP-IDF's CONFIG_SECURE_BOOT_VERIFICATION_KEY only takes effect + when the V1 ECDSA scheme is selected and binaries are not signed during + the build (see SECURE_BOOT_VERIFICATION_KEY in the bootloader Kconfig). + + The V2 schemes (rsa3072, ecdsa256) embed the public key in the signature + block appended to each image, so verifying externally-signed binaries + needs no key in the config at all -- omitting both keys selects that + external-signing mode. + """ + has_signing_key = CONF_SIGNING_KEY in config + has_verification_key = CONF_VERIFICATION_KEY in config + scheme = config[CONF_SIGNING_SCHEME] + if has_signing_key and has_verification_key: + raise cv.Invalid( + f"Provide at most one of '{CONF_SIGNING_KEY}' and " + f"'{CONF_VERIFICATION_KEY}', not both.", + path=[CONF_VERIFICATION_KEY], + ) + if scheme == "ecdsa_v1": + if not has_signing_key and not has_verification_key: + raise cv.Invalid( + f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' " + f"(to sign binaries during the build) or '{CONF_VERIFICATION_KEY}' " + f"(to verify binaries signed externally).", + path=[CONF_SIGNING_KEY], + ) + elif has_verification_key: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEY}' is only used with signing scheme " + f"'ecdsa_v1'. With '{scheme}' the public key is embedded in each " + f"image's signature block, so no key file is needed to verify " + f"externally-signed binaries: remove '{CONF_VERIFICATION_KEY}', and " + f"set '{CONF_SIGNING_KEY}' only if binaries should be signed during " + f"the build.", + path=[CONF_VERIFICATION_KEY], + ) + return config + + def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN @@ -1361,7 +1429,7 @@ def final_validate(config): ) else: _LOGGER.info( - "Signed OTA verification is configured with a public verification key. " + "Signed OTA verification is enabled without a signing key. " "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) @@ -1640,18 +1708,9 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional( CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False ): cv.boolean, - cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All( - cv.Schema( - { - cv.Optional(CONF_SIGNING_KEY): cv.file_, - cv.Optional(CONF_VERIFICATION_KEY): cv.file_, - cv.Optional( - CONF_SIGNING_SCHEME, default="rsa3072" - ): cv.one_of(*SIGNING_SCHEMES, lower=True), - } - ), - cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), - ), + cv.Optional( + CONF_SIGNED_OTA_VERIFICATION + ): _validate_signed_ota_verification, cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema( { # eFuse key block (0-5) that stores the HMAC key from @@ -2498,12 +2557,16 @@ async def to_code(config): signed_ota[CONF_SIGNING_KEY].resolve().as_posix(), ) else: - # Public key mode — verification only, external signing required + # External signing mode — binaries must be signed after the build add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) - add_idf_sdkconfig_option( - "CONFIG_SECURE_BOOT_VERIFICATION_KEY", - signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), - ) + if CONF_VERIFICATION_KEY in signed_ota: + # V1 ECDSA only: the public key is compiled into the app to + # verify externally-signed images. V2 schemes carry the public + # key in each image's signature block and need no key here. + add_idf_sdkconfig_option( + "CONFIG_SECURE_BOOT_VERIFICATION_KEY", + signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), + ) cg.add_define("USE_OTA_SIGNED_VERIFICATION") diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index dd8881e46f..fdca70bf2c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -665,3 +665,78 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None: # No project version and no signing -> two distinct errors. errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False) assert len(errs) == 2 + + +@pytest.mark.parametrize( + "config", + [ + # V2 schemes: signing key (sign during build) or no key at all + # (external signing; the public key travels in the signature block). + {"signing_scheme": "rsa3072", "signing_key": "key.pem"}, + {"signing_scheme": "rsa3072"}, + {"signing_scheme": "ecdsa256", "signing_key": "key.pem"}, + {"signing_scheme": "ecdsa256"}, + # V1 ECDSA: exactly one of signing key / verification key. + {"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"}, + {"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"}, + ], +) +def test_signed_ota_keys_valid_combinations(config: dict) -> None: + from esphome.components.esp32 import _validate_signed_ota_keys + + assert _validate_signed_ota_keys(config) is config + + +@pytest.mark.parametrize("value", [None, {}]) +def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -> None: + """A bare `signed_ota_verification:` block is valid: the default V2 + scheme embeds the public key in the signature block, so verifying + externally-signed binaries needs no keys in the config.""" + from esphome.components.esp32 import _validate_signed_ota_verification + + config = _validate_signed_ota_verification(value) + assert config == {"signing_scheme": "rsa3072"} + + +@pytest.mark.parametrize( + ("config", "match"), + [ + # A verification key is meaningless with the V2 schemes -- the public + # key is embedded in each image's signature block. + ( + {"signing_scheme": "rsa3072", "verification_key": "key.bin"}, + "only used with signing scheme 'ecdsa_v1'", + ), + ( + {"signing_scheme": "ecdsa256", "verification_key": "key.bin"}, + "only used with signing scheme 'ecdsa_v1'", + ), + # V1 ECDSA needs a key either way. + ( + {"signing_scheme": "ecdsa_v1"}, + "Signing scheme 'ecdsa_v1' requires either", + ), + # Never both keys at once. + ( + { + "signing_scheme": "rsa3072", + "signing_key": "key.pem", + "verification_key": "key.bin", + }, + "not both", + ), + ( + { + "signing_scheme": "ecdsa_v1", + "signing_key": "key.pem", + "verification_key": "key.bin", + }, + "not both", + ), + ], +) +def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: + from esphome.components.esp32 import _validate_signed_ota_keys + + with pytest.raises(cv.Invalid, match=match): + _validate_signed_ota_keys(config) diff --git a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml new file mode 100644 index 0000000000..5b57993e87 --- /dev/null +++ b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +# Secure Boot V2 schemes carry the public key inside each image's signature +# block, so verifying externally-signed binaries needs no key in the config: +# a bare block enables verification with the default rsa3072 scheme. +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + +<<: !include common.yaml From d78cb09b17bb12dea1a8e6cd2d6e6b67f3004a38 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:23:55 +1200 Subject: [PATCH 2117/2214] [web_server] Use dict-style packages in tests so they can be batch-grouped (#17544) --- tests/components/web_server/test.esp32-ard.yaml | 3 ++- tests/components/web_server/test.esp32-idf.yaml | 3 ++- tests/components/web_server/test.esp8266-ard.yaml | 3 ++- tests/components/web_server/test.rp2040-ard.yaml | 3 ++- tests/components/web_server/test_v1.esp32-ard.yaml | 3 ++- tests/components/web_server/test_v1.esp32-idf.yaml | 3 ++- tests/components/web_server/test_v3.esp32-ard.yaml | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/components/web_server/test.esp32-ard.yaml b/tests/components/web_server/test.esp32-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.esp32-ard.yaml +++ b/tests/components/web_server/test.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 24b292d0d6..858e3b0190 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -1,4 +1,5 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml web_server: auth: diff --git a/tests/components/web_server/test.esp8266-ard.yaml b/tests/components/web_server/test.esp8266-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.esp8266-ard.yaml +++ b/tests/components/web_server/test.esp8266-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test_v1.esp32-ard.yaml b/tests/components/web_server/test_v1.esp32-ard.yaml index 389a930284..1d563de834 100644 --- a/tests/components/web_server/test_v1.esp32-ard.yaml +++ b/tests/components/web_server/test_v1.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v1.yaml +packages: + web_server: !include common_v1.yaml diff --git a/tests/components/web_server/test_v1.esp32-idf.yaml b/tests/components/web_server/test_v1.esp32-idf.yaml index 389a930284..1d563de834 100644 --- a/tests/components/web_server/test_v1.esp32-idf.yaml +++ b/tests/components/web_server/test_v1.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common_v1.yaml +packages: + web_server: !include common_v1.yaml diff --git a/tests/components/web_server/test_v3.esp32-ard.yaml b/tests/components/web_server/test_v3.esp32-ard.yaml index 00d05521e4..956a88bc68 100644 --- a/tests/components/web_server/test_v3.esp32-ard.yaml +++ b/tests/components/web_server/test_v3.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v3.yaml +packages: + web_server: !include common_v3.yaml From 5e3e2f82c9800bc232b0c9c9d962418a1b4f7d44 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:16:53 +1000 Subject: [PATCH 2118/2214] [script] Fix duplicate import in build_codeowners.py (#17543) --- script/build_codeowners.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/script/build_codeowners.py b/script/build_codeowners.py index 10ca1295b7..be8b445542 100755 --- a/script/build_codeowners.py +++ b/script/build_codeowners.py @@ -61,6 +61,13 @@ for path in components_dir.iterdir(): codeowners[f"esphome/components/{name}/*"].extend(comp.codeowners) for platform_path in path.iterdir(): + if platform_path.name == "__init__.py": + # `import pkg.__init__` is valid but distinct from `import pkg`: it re-executes + # the component's __init__.py as a second, separate module. That's harmless for + # components whose top-level code is idempotent, but not guaranteed in general + # (e.g. code that registers into a global registry with a duplicate check), so + # never treat __init__.py itself as a platform candidate. + continue platform_name = platform_path.stem platform = get_platform(platform_name, name) if platform is None: From 65d6c028cea339b1e8baa1fcb456afbe76f3d210 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:33:44 +1200 Subject: [PATCH 2119/2214] [web_server] Add HTTP digest authentication with selectable scheme (#17541) --- esphome/components/web_server/__init__.py | 52 ++++-- .../web_server_base/web_server_base.h | 8 + .../web_server_idf/web_server_idf.cpp | 170 +++++++++++++++++- .../web_server_idf/web_server_idf.h | 2 +- esphome/core/defines.h | 3 + .../web_server/test_web_server_auth.py | 65 +++++++ .../web_server/web_server_auth_basic.yaml | 18 ++ .../web_server/web_server_auth_default.yaml | 17 ++ .../web_server/web_server_auth_digest.yaml | 18 ++ .../web_server/web_server_no_auth.yaml | 14 ++ .../components/web_server/test.esp32-idf.yaml | 1 + .../web_server/test.esp8266-ard.yaml | 6 + .../web_server/test.rp2040-ard.yaml | 6 + .../web_server/validate.esp32-idf.yaml | 8 + 14 files changed, 370 insertions(+), 18 deletions(-) create mode 100644 tests/component_tests/web_server/test_web_server_auth.py create mode 100644 tests/component_tests/web_server/web_server_auth_basic.yaml create mode 100644 tests/component_tests/web_server/web_server_auth_default.yaml create mode 100644 tests/component_tests/web_server/web_server_auth_digest.yaml create mode 100644 tests/component_tests/web_server/web_server_no_auth.yaml create mode 100644 tests/components/web_server/validate.esp32-idf.yaml diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 68f1c18072..2587d13b9e 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_TYPE, CONF_USERNAME, CONF_VERSION, CONF_WEB_SERVER, @@ -44,6 +45,9 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["json", "web_server_base"] +AUTH_TYPE_BASIC = "basic" +AUTH_TYPE_DIGEST = "digest" + CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" @@ -85,6 +89,19 @@ def validate_version_deprecated(config: ConfigType) -> ConfigType: return config +def validate_auth_type_deprecated(auth: ConfigType) -> ConfigType: + # Remove before 2027.1.0: the default auth scheme changes from basic to digest. + if CONF_TYPE not in auth: + _LOGGER.warning( + "The 'web_server' 'auth' scheme currently defaults to 'basic', which sends the " + "password over the network in an easily reversible form. The default will change " + "to 'digest' in ESPHome 2027.1.0. To keep using basic authentication, set " + "'type: basic' under 'auth:' explicitly; otherwise set 'type: digest' now to " + "adopt the more secure scheme." + ) + return auth + + def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") @@ -242,15 +259,21 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ALLOWED_ORIGINS): cv.All( cv.ensure_list(validate_origin), cv.Length(min=1) ), - cv.Optional(CONF_AUTH): cv.Schema( - { - cv.Required(CONF_USERNAME): cv.All( - cv.string_strict, cv.Length(min=1) - ), - cv.Required(CONF_PASSWORD): cv.sensitive( - cv.All(cv.string_strict, cv.Length(min=1)) - ), - } + cv.Optional(CONF_AUTH): cv.All( + cv.Schema( + { + cv.Required(CONF_USERNAME): cv.All( + cv.string_strict, cv.Length(min=1) + ), + cv.Required(CONF_PASSWORD): cv.sensitive( + cv.All(cv.string_strict, cv.Length(min=1)) + ), + cv.Optional(CONF_TYPE): cv.one_of( + AUTH_TYPE_BASIC, AUTH_TYPE_DIGEST, lower=True + ), + } + ), + validate_auth_type_deprecated, ), cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id( web_server_base.WebServerBase @@ -378,10 +401,15 @@ async def to_code(config): if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") cg.add(var.set_allowed_origins(allowed_origins)) - if CONF_AUTH in config: + if (auth := config.get(CONF_AUTH)) is not None: cg.add_define("USE_WEBSERVER_AUTH") - cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) - cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD])) + # 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: + 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 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.h b/esphome/components/web_server_base/web_server_base.h index 19c2185fb9..9657853a73 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -59,7 +59,15 @@ class AuthMiddlewareHandler : public MiddlewareHandler { bool check_auth(AsyncWebServerRequest *request) { bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str()); 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) + request->requestAuthentication(nullptr, true); +#else + request->requestAuthentication(nullptr, false); +#endif } return success; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 46a389f359..bf5a8666dc 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -16,6 +16,11 @@ #include "utils.h" #include "web_server_idf.h" +#ifdef USE_WEBSERVER_AUTH_DIGEST +#include +#include +#endif + #ifdef USE_WEBSERVER_OTA #include #include "multipart.h" // For parse_multipart_boundary and other utils @@ -372,6 +377,135 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code } #ifdef USE_WEBSERVER_AUTH + +#ifdef USE_WEBSERVER_AUTH_DIGEST +namespace { + +// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated. +void bytes_to_hex(const uint8_t *data, size_t len, char *out) { + static const char HEX[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) { + out[i * 2] = HEX[data[i] >> 4]; + out[i * 2 + 1] = HEX[data[i] & 0x0f]; + } + out[len * 2] = '\0'; +} + +// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated +// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent. +// Only whole parameter names match, so "nc" does not match inside "cnonce". +StringRef digest_param(StringRef params, const char *key) { + size_t key_len = strlen(key); + const char *base = params.c_str(); + size_t n = params.size(); + size_t i = 0; + while (i < n) { + while (i < n && (base[i] == ' ' || base[i] == ',')) + i++; + size_t name_start = i; + while (i < n && base[i] != '=' && base[i] != ',') + i++; + if (i >= n || base[i] == ',') + continue; // token without a '=', skip it + size_t name_len = i - name_start; + while (name_len > 0 && base[name_start + name_len - 1] == ' ') + name_len--; + i++; // consume '=' + const char *val_start; + size_t val_len; + if (i < n && base[i] == '"') { + i++; + val_start = base + i; + while (i < n && base[i] != '"') + i++; + val_len = (base + i) - val_start; + if (i < n) + i++; // consume closing quote + } else { + val_start = base + i; + while (i < n && base[i] != ',') + i++; + val_len = (base + i) - val_start; + } + if (name_len == key_len && memcmp(base + name_start, key, key_len) == 0) + return StringRef(val_start, val_len); + while (i < n && base[i] != ',') + i++; + } + return StringRef(); +} + +// Verify an RFC 2617 Digest response. Stateless (the nonce we issued is not tracked), which +// matches the ESPAsyncWebServer backend used on the Arduino platforms. +bool check_digest_auth(const char *username, const char *password, const std::string &header, const char *method) { + const size_t prefix_len = sizeof("Digest ") - 1; + StringRef params(header.c_str() + prefix_len, header.size() - prefix_len); + + if (digest_param(params, "username") != username) + return false; + + StringRef realm = digest_param(params, "realm"); + StringRef nonce = digest_param(params, "nonce"); + StringRef uri = digest_param(params, "uri"); + StringRef qop = digest_param(params, "qop"); + StringRef nc = digest_param(params, "nc"); + StringRef cnonce = digest_param(params, "cnonce"); + StringRef response = digest_param(params, "response"); + if (response.size() != 32) + return false; + + // Compute the three MD5 hashes by streaming the pieces straight into the ROM MD5 engine, so + // nothing is concatenated on the heap. Each hash is emitted as 32 lowercase hex characters. + md5_context_t ctx; + uint8_t digest[16]; + + // HA1 = MD5(username:realm:password) -- uses the realm the client echoed back. + char ha1[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, username, strlen(username)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, realm.c_str(), realm.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, password, strlen(password)); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha1); + + // HA2 = MD5(method:uri) -- uses the uri the client echoed back. + char ha2[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, method, strlen(method)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, uri.c_str(), uri.size()); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha2); + + // expected = MD5(HA1:nonce:nc:cnonce:qop:HA2) + char expected[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, ha1, 32); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nonce.c_str(), nonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nc.c_str(), nc.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, cnonce.c_str(), cnonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, qop.c_str(), qop.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, ha2, 32); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), expected); + + // Constant-time comparison of the two 32-char hex digests. + uint8_t result = 0; + for (size_t i = 0; i < 32; i++) + result |= static_cast(expected[i] ^ response[i]); + return result == 0; +} + +} // namespace +#endif // USE_WEBSERVER_AUTH_DIGEST + bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const { if (username == nullptr || password == nullptr || *username == 0) { return true; @@ -383,9 +517,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw auto *auth_str = auth.value().c_str(); +#ifdef USE_WEBSERVER_AUTH_DIGEST + // The build fixed the scheme to Digest, so the Basic path is compiled out entirely. + const auto auth_prefix_len = sizeof("Digest ") - 1; + if (strncmp("Digest ", auth_str, auth_prefix_len) != 0) { + ESP_LOGW(TAG, "Only Digest authorization supported"); + return false; + } + return check_digest_auth(username, password, auth.value(), http_method_str(this->method())); +#else const auto auth_prefix_len = sizeof("Basic ") - 1; if (strncmp("Basic ", auth_str, auth_prefix_len) != 0) { - ESP_LOGW(TAG, "Only Basic authorization supported yet"); + ESP_LOGW(TAG, "Only Basic authorization supported"); return false; } @@ -434,16 +577,33 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw result |= static_cast(digest[i] ^ provided_ch); } return result == 0; +#endif // USE_WEBSERVER_AUTH_DIGEST } -void AsyncWebServerRequest::requestAuthentication(const char *realm) const { +void AsyncWebServerRequest::requestAuthentication() const { httpd_resp_set_hdr(*this, "Connection", "keep-alive"); - // Note: realm is never configured in ESPHome, always nullptr -> "Login Required" - (void) realm; // Unused - always use default +#ifdef USE_WEBSERVER_AUTH_DIGEST + // Issue a fresh random nonce and opaque. The nonce is not stored, so this is stateless and + // does not defend against replay -- its purpose is to keep the password off the wire. + // The header value must stay alive until httpd_resp_send_err() below sends it, so the buffer + // lives on this stack frame (httpd_resp_set_hdr stores the pointer, it does not copy). + uint8_t random_bytes[16]; + char nonce[33]; + char opaque[33]; + char header[160]; + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), nonce); + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), opaque); + snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce, + opaque); + httpd_resp_set_hdr(*this, "WWW-Authenticate", header); +#else httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\""); +#endif // USE_WEBSERVER_AUTH_DIGEST httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr); } -#endif +#endif // USE_WEBSERVER_AUTH AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) { // Check cache first - only successful lookups are cached diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 8b5fd5b726..baa55898bb 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -129,7 +129,7 @@ class AsyncWebServerRequest { #ifdef USE_WEBSERVER_AUTH bool authenticate(const char *username, const char *password) const; // NOLINTNEXTLINE(readability-identifier-naming) - void requestAuthentication(const char *realm = nullptr) const; + void requestAuthentication() const; #endif void redirect(const std::string &url); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 78f7769cf6..5c5fc5e8b9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -298,6 +298,7 @@ #define USE_VOICE_ASSISTANT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP @@ -408,6 +409,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -438,6 +440,7 @@ #define USE_LWIP_FAST_SELECT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/component_tests/web_server/test_web_server_auth.py b/tests/component_tests/web_server/test_web_server_auth.py new file mode 100644 index 0000000000..82635b26da --- /dev/null +++ b/tests/component_tests/web_server/test_web_server_auth.py @@ -0,0 +1,65 @@ +"""Tests for web_server authentication codegen.""" + +from collections.abc import Callable + +import pytest + +from esphome.core import CORE + +_DEFAULT_CHANGE_WARNING = "default will change to 'digest' in ESPHome 2027.1.0" + + +def _has_define(name: str) -> bool: + return any(d.name == name for d in CORE.defines) + + +def test_web_server_auth_default_is_basic_with_deprecation_warning( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth without an explicit type builds Basic and warns about the upcoming default change.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_default.yaml" + ) + + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING in caplog.text + + +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") + + 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_explicit_digest( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth type digest builds Digest and does not warn.""" + generate_main("tests/component_tests/web_server/web_server_auth_digest.yaml") + + assert _has_define("USE_WEBSERVER_AUTH") + assert _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text + + +def test_web_server_without_auth( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Without an auth block, no auth is compiled in and no warning is emitted.""" + generate_main("tests/component_tests/web_server/web_server_no_auth.yaml") + + assert not _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text diff --git a/tests/component_tests/web_server/web_server_auth_basic.yaml b/tests/component_tests/web_server/web_server_auth_basic.yaml new file mode 100644 index 0000000000..70180f9fbc --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_basic.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +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_default.yaml b/tests/component_tests/web_server/web_server_auth_default.yaml new file mode 100644 index 0000000000..076180ab79 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_default.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password diff --git a/tests/component_tests/web_server/web_server_auth_digest.yaml b/tests/component_tests/web_server/web_server_auth_digest.yaml new file mode 100644 index 0000000000..f413787601 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_digest.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/component_tests/web_server/web_server_no_auth.yaml b/tests/component_tests/web_server/web_server_no_auth.yaml new file mode 100644 index 0000000000..1c7823b4ae --- /dev/null +++ b/tests/component_tests/web_server/web_server_no_auth.yaml @@ -0,0 +1,14 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 858e3b0190..2e091b905a 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -5,3 +5,4 @@ web_server: auth: username: admin password: password + type: digest diff --git a/tests/components/web_server/test.esp8266-ard.yaml b/tests/components/web_server/test.esp8266-ard.yaml index 11ad5456ef..2e091b905a 100644 --- a/tests/components/web_server/test.esp8266-ard.yaml +++ b/tests/components/web_server/test.esp8266-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 11ad5456ef..e4d50d7776 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/components/web_server/validate.esp32-idf.yaml b/tests/components/web_server/validate.esp32-idf.yaml new file mode 100644 index 0000000000..e4d50d7776 --- /dev/null +++ b/tests/components/web_server/validate.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic From 49bbceb1dad7be50364ad5db11d4796df0061d59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Jul 2026 13:51:54 -1000 Subject: [PATCH 2120/2214] [libretiny] Keep renamed board generic-ln882hki validating against generic-ln882h (#17542) --- esphome/components/libretiny/__init__.py | 15 ++++++ tests/unit_tests/components/test_libretiny.py | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/unit_tests/components/test_libretiny.py diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 3fde11b1eb..62cef331fd 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -76,12 +76,27 @@ _BLE5_BK_SYS_CONFIG_OPTIONS = [ "CFG_SUPPORT_BLE=0", ] +# Board ids upstream LibreTiny renamed; configs written against the old id +# keep validating and building against the new one (with a warning). +# generic-ln882hki -> generic-ln882h: LibreTiny v1.13.0. +_RENAMED_BOARDS = { + "generic-ln882hki": "generic-ln882h", +} + def _detect_variant(value): if KEY_LIBRETINY not in CORE.data: raise cv.Invalid("Family component didn't populate core data properly!") component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] board = value[CONF_BOARD] + if board not in component.boards and (renamed := _RENAMED_BOARDS.get(board)): + _LOGGER.warning( + "Board '%s' was renamed to '%s'; please update your configuration", + board, + renamed, + ) + value = value.copy() + value[CONF_BOARD] = board = renamed # read board-default family if not specified if board not in component.boards: if CONF_FAMILY not in value: diff --git a/tests/unit_tests/components/test_libretiny.py b/tests/unit_tests/components/test_libretiny.py new file mode 100644 index 0000000000..ee00bdc180 --- /dev/null +++ b/tests/unit_tests/components/test_libretiny.py @@ -0,0 +1,52 @@ +"""Tests for LibreTiny board detection, including renamed-board migration.""" + +import pytest + +from esphome.components.libretiny import _detect_variant +from esphome.components.libretiny.const import ( + FAMILY_LN882H, + KEY_COMPONENT_DATA, + KEY_LIBRETINY, +) +from esphome.components.ln882x import COMPONENT_DATA +import esphome.config_validation as cv +from esphome.const import CONF_BOARD, CONF_FAMILY +from esphome.core import CORE + + +@pytest.fixture +def ln882x_core_data() -> None: + """Populate CORE the way the ln882x component schema does.""" + CORE.data[KEY_LIBRETINY] = {KEY_COMPONENT_DATA: COMPONENT_DATA} + + +def test_detect_variant_known_board_passes(ln882x_core_data: None) -> None: + """A current board id resolves its family without warnings.""" + result = _detect_variant({CONF_BOARD: "generic-ln882h"}) + assert result[CONF_BOARD] == "generic-ln882h" + assert result[CONF_FAMILY] == FAMILY_LN882H + + +def test_detect_variant_renamed_board_migrates( + ln882x_core_data: None, caplog: pytest.LogCaptureFixture +) -> None: + """A pre-rename board id validates against the new id, with a warning.""" + result = _detect_variant({CONF_BOARD: "generic-ln882hki"}) + assert result[CONF_BOARD] == "generic-ln882h" + assert result[CONF_FAMILY] == FAMILY_LN882H + assert "renamed to 'generic-ln882h'" in caplog.text + + +def test_detect_variant_renamed_board_does_not_mutate_input( + ln882x_core_data: None, +) -> None: + """Migration copies the config; the caller's dict keeps the old id.""" + value = {CONF_BOARD: "generic-ln882hki"} + _detect_variant(value) + assert value[CONF_BOARD] == "generic-ln882hki" + + +def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> None: + """Ids outside the rename map keep the family-override error.""" + with pytest.raises(cv.Invalid, match="This board is unknown"): + _detect_variant({CONF_BOARD: "not-a-real-board"}) From f1e4726f4e38a464a26cbed4bbdbc95cfe6d11d7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:53:31 -0400 Subject: [PATCH 2121/2214] [veml7700][as7341][ltr501] Fix device class on raw-count sensors (#17549) --- esphome/components/as7341/sensor.py | 4 ++-- esphome/components/ltr501/sensor.py | 10 +++++----- esphome/components/veml7700/sensor.py | 5 +++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index fa51a1cdfa..8b6cf61028 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -5,7 +5,7 @@ from esphome.const import ( CONF_CLEAR, CONF_GAIN, CONF_ID, - DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_EMPTY, ICON_BRIGHTNESS_5, STATE_CLASS_MEASUREMENT, ) @@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index cca9330e76..c1fa9009b3 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_NAME, CONF_REPEAT, CONF_TYPE, - DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -159,7 +159,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -169,7 +169,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -179,7 +179,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_PROXIMITY, accuracy_decimals=0, - device_class=DEVICE_CLASS_DISTANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -188,7 +188,7 @@ CONFIG_SCHEMA = cv.All( sensor.sensor_schema( icon=ICON_GAIN, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 6ad2eb417f..d0d3584dc2 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, + DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_6, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -111,7 +112,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, From ca77cc585c6d3a00ddfd1b6eb1405924a13904a8 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 13 Jul 2026 18:56:03 -0500 Subject: [PATCH 2122/2214] [nextion] Fix unbounded queue growth and OOM crash when display sends no data (#17553) --- esphome/components/nextion/nextion.cpp | 32 +++++++++++++++++++------- esphome/components/nextion/nextion.h | 4 ++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 4ebc717552..bdc66adb70 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -1,6 +1,7 @@ #include "nextion.h" #include +#include #include "esphome/core/application.h" #include "esphome/core/helpers.h" @@ -352,8 +353,9 @@ void Nextion::loop() { this->connection_state_.ignore_is_setup_ = false; } - this->process_serial_(); // Receive serial data - this->process_nextion_commands_(); // Process nextion return commands + this->process_serial_(); // Receive serial data + this->process_nextion_commands_(); // Process nextion return commands + this->purge_stale_queue_entries_(); // Drop expired entries even when the display sends no data if (!this->connection_state_.nextion_reports_is_setup_) { if (this->started_ms_ == 0) @@ -902,6 +904,11 @@ void Nextion::process_nextion_commands_() { this->command_data_.erase(0, to_process_length + DELIMITER_SIZE + 1); } + ESP_LOGN(TAG, "Loop end"); + this->process_serial_(); +} // Nextion::process_nextion_commands_() + +void Nextion::purge_stale_queue_entries_() { const uint32_t ms = App.get_loop_component_start_time(); if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() && @@ -927,10 +934,7 @@ void Nextion::process_nextion_commands_() { } } } - ESP_LOGN(TAG, "Loop end"); - // App.feed_wdt(); Remove before master merge - this->process_serial_(); -} // Nextion::process_nextion_commands_() +} void Nextion::set_nextion_sensor_state(int queue_type, const std::string &name, float state) { this->set_nextion_sensor_state(static_cast(queue_type), name, state); @@ -1101,7 +1105,13 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { new (nextion_queue) nextion::NextionQueue(); // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new nextion::NextionComponentBase; + nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + nextion_queue->~NextionQueue(); + allocator.deallocate(nextion_queue, 1); + return; + } nextion_queue->component->set_variable_name(variable_name); nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1157,7 +1167,13 @@ void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &va } new (nextion_queue) nextion::NextionQueue(); - nextion_queue->component = new nextion::NextionComponentBase; + nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + nextion_queue->~NextionQueue(); + allocator.deallocate(nextion_queue, 1); + return; + } nextion_queue->component->set_variable_name(variable_name); nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index d361d9725b..7dc5a4fe44 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1486,6 +1486,10 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void process_nextion_commands_(); void process_serial_(); + /// Drop queue entries older than max_q_age_ms_. Called from loop() so it also runs when the + /// display sends no data at all (disconnected or asleep), which would otherwise grow the queue + /// without bound. + void purge_stale_queue_entries_(); uint16_t touch_sleep_timeout_ = 0; uint8_t wake_up_page_ = 255; #ifdef USE_NEXTION_CONF_START_UP_PAGE From 3e75020007e598fdf1794867565825cdf36c97fd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:28:55 +1200 Subject: [PATCH 2123/2214] [gsl3670] Fix i2c package variant in esp32-s3-idf test (#17535) --- tests/components/gsl3670/test.esp32-s3-idf.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 48bb9982d9..5c3f4b931c 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ packages: - i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml xl9535: @@ -10,6 +10,9 @@ display: id: gsl3670_display spi_id: spi_bus model: t-display-s3-pro + # The model's default DC pin (GPIO9) clashes with the shared i2c bus SCL + # pin, so override it onto a free pin for this test. + dc_pin: GPIO5 psram: mode: quad From f38e7f2de21b72122d53552966d4ff073265661d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:38:48 +1200 Subject: [PATCH 2124/2214] [web_server] Add CORS origin checking with allowed_origins (#17530) --- THREAT_MODEL.md | 46 ++++++++++ esphome/components/web_server/__init__.py | 46 +++++++++- esphome/components/web_server/web_server.cpp | 71 +++++++++++++-- esphome/components/web_server/web_server.h | 26 ++++++ esphome/core/defines.h | 1 + .../web_server/test_private_network_access.py | 86 +++++++++++++++++++ tests/components/web_server/common_v2.yaml | 3 + tests/components/web_server/common_v3.yaml | 3 + 8 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/web_server/test_private_network_access.py diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index a4355a5055..5816f38176 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -79,6 +79,48 @@ These *are* security bugs in this repo, and we want to hear about them privately - Flaws that weaken the device's API encryption (Noise), OTA, or web server auth below their documented guarantees. +## The web server is an open HTTP API by design + +The `web_server` component exposes a plain HTTP interface for viewing and +controlling entities, and, when the `web_server` OTA platform is enabled, for +uploading firmware at `/update`. Its only access controls are the optional +`web_server` `auth:` credentials and the network the device sits on. + +When `auth:` is not configured, every endpoint is reachable by any client that +can reach the device. This is intentional; enabling `web_server` without `auth:` +is choosing an open control surface, in the same way that running native OTA +without a password leaves OTA open. The API is documented and is meant to be +called by other devices, scripts, and pages. + +As defense-in-depth, the web server checks the `Origin` header on browser requests +to its entity control and state endpoints: a request whose `Origin` does not match +the address the device is served on is rejected, and the `allowed_origins` option +widens that list. This blocks the common "confused deputy" (CSRF) case where a page +the operator visits drives the device through their browser. It is **not** an +authentication boundary: it only constrains browsers. Any client that omits the +`Origin` header — `curl`, scripts, or other non-browser callers on the same +network — reaches every endpoint exactly as before. The check also does not cover +the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer` +validation. The following are therefore **not** vulnerabilities in this repository: + +- Requests without an `Origin` header (for example `curl`) reaching the control + endpoints, whether or not `web_server` `auth:` is set. +- Requests from an origin the operator added to `allowed_origins`. +- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when + web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not + covered by the `Origin` check; this is the same exposure as running OTA without a + password. + +The supported defenses are `web_server` `auth:`, protecting OTA (a web password or +a native OTA password), and keeping devices on a trusted, segmented network. See +the security best practices guide linked above. + +What remains in scope is bypassing `web_server` `auth:` when it *is* configured, +and any memory-safety or protocol bug in the server reachable without credentials. + +This section documents the current design and scope; it is not a judgment that the +design is optimal or that it will not change. + ## Explicitly out of scope - Local attackers who already have shell access on the host that runs `esphome`. @@ -86,6 +128,10 @@ These *are* security bugs in this repo, and we want to hear about them privately - Operator-supplied hostile YAML (covered above — config authoring is trusted). - Attacks that require an already-authenticated device peer (someone who already holds the API key / OTA / web credentials). +- Access to the device web server or its web OTA endpoint by non-browser clients + (those that send no `Origin` header). The web server is an open HTTP API by + design (see above); browser cross-origin requests are blocked by default, but the + real controls are `web_server` `auth:` and network isolation. - Anything in the dashboard / device-builder — report that in its own repository (linked at the top). - Deployments where the operator removed protections or exposed credentials. See diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index d9fd27dbc2..68f1c18072 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import gzip import logging +import re import esphome.codegen as cg from esphome.components import web_server_base @@ -46,6 +47,7 @@ AUTO_LOAD = ["json", "web_server_base"] CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" +CONF_ALLOWED_ORIGINS = "allowed_origins" web_server_ns = cg.esphome_ns.namespace("web_server") @@ -104,6 +106,41 @@ def validate_ota(config: ConfigType) -> ConfigType: return config +# An Origin header is always "scheme://host[:port]" with no path or trailing slash. +_ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$") + + +def validate_origin(value: str) -> str: + # "*" is the wildcard that allows any origin. + if value == "*": + return value + value = cv.string_strict(value) + if not _ORIGIN_RE.match(value): + raise cv.Invalid( + f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no " + f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin." + ) + # Browsers send the scheme and host lowercased in the Origin header, so normalize to match. + return value.lower() + + +def validate_private_network_access(config: ConfigType) -> ConfigType: + # PNA preflights are always cross-origin, so they can only be authorized against the + # allowed_origins list. Enabling PNA without any origins would deny every PNA request. + if ( + config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS] + and config.get(CONF_ALLOWED_ORIGINS) is None + ): + raise cv.Invalid( + f"'{CONF_ALLOWED_ORIGINS}' must be set when " + f"'{CONF_ENABLE_PRIVATE_NETWORK_ACCESS}' is enabled. List each origin that is " + f"allowed to reach the device (e.g. 'https://example.com'). '*' allows any origin " + f"but is not recommended.", + path=[CONF_ENABLE_PRIVATE_NETWORK_ACCESS], + ) + return config + + def validate_sorting_groups(config: ConfigType) -> ConfigType: if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3: raise cv.Invalid( @@ -201,7 +238,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CSS_INCLUDE): cv.file_, cv.Optional(CONF_JS_URL): cv.string, cv.Optional(CONF_JS_INCLUDE): cv.file_, - cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=True): cv.boolean, + cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=False): cv.boolean, + cv.Optional(CONF_ALLOWED_ORIGINS): cv.All( + cv.ensure_list(validate_origin), cv.Length(min=1) + ), cv.Optional(CONF_AUTH): cv.Schema( { cv.Required(CONF_USERNAME): cv.All( @@ -238,6 +278,7 @@ CONFIG_SCHEMA = cv.All( validate_local, validate_sorting_groups, validate_ota, + validate_private_network_access, _consume_web_server_sockets, ) @@ -334,6 +375,9 @@ async def to_code(config): request_log_listener() # Request a log listener slot for web server log streaming if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") + if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: + cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") + cg.add(var.set_allowed_origins(allowed_origins)) if CONF_AUTH in config: cg.add_define("USE_WEBSERVER_AUTH") cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3bba879823..1e6c4e8c62 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -456,9 +456,58 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #endif +// Read a request header value portably across the Arduino and ESP-IDF web servers. +// Returns an empty string when the header is absent (only allocates when a value is present). +static std::string get_request_header(AsyncWebServerRequest *request, const char *name) { +#ifdef USE_ESP32 + // ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend. + optional value = request->get_header(name); + return value.has_value() ? std::move(*value) : std::string(); +#else + // ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend. + const AsyncWebHeader *header = request->getHeader(name); + return header != nullptr ? std::string(header->value().c_str()) : std::string(); +#endif +} + +bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) { + // No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow. + if (origin.empty()) + return true; + + // Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to. + // This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time. + const size_t scheme_sep = origin.find("://"); + if (scheme_sep != std::string::npos) { + const std::string host = get_request_header(request, "Host"); + if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + return true; + } + +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + // Otherwise the origin must be explicitly allowed via configuration. + for (const char *allowed_origin : this->allowed_origins_) { + // A single "*" entry allows any origin. + if (allowed_origin[0] == '*' && allowed_origin[1] == '\0') + return true; + if (origin == allowed_origin) + return true; + } +#endif + return false; +} + #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { + const std::string origin = get_request_header(request, "Origin"); + if (!this->is_request_origin_allowed_(request, origin)) { + request->send(403); + return; + } + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("")); + // Echo the specific origin back so the response is valid even when auth (credentials) is enabled. + 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]; @@ -2448,6 +2497,21 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { return; } +#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS + // Private Network Access preflight carries a cross-origin Origin by design; its handler does the + // origin check itself, so let it run before the general enforcement below. + if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { + this->handle_pna_cors_request(request); + return; + } +#endif + + // Reject cross-origin browser requests unless the origin is explicitly allowed. + if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) { + request->send(403); + return; + } + #if !defined(USE_ESP32) && defined(USE_ARDUINO) if (url == ESPHOME_F("/events")) { this->events_.add_new_client(this, request); @@ -2469,13 +2533,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif -#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS - if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { - this->handle_pna_cors_request(request); - return; - } -#endif - // Parse URL for component routing // Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action) UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 42182fe510..0fbe4ec551 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -242,6 +242,22 @@ class WebServer final : public Controller, public Component, public AsyncWebHand */ void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; } +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + /** Set the origins that browsers are allowed to make cross-origin requests from. + * + * Requests without an `Origin` header (e.g. non-browser clients like curl or the native API) + * are always allowed. Requests whose `Origin` matches the address the device is served on + * (same-origin) are always allowed. Any other browser origin must appear in this list, or the + * request is rejected. A single "*" entry allows any origin. Each other entry must exactly match + * the requesting page's `Origin` header (e.g. "https://example.com"). + * + * This list is also used to authorize Private Network Access requests when that feature is enabled. + * + * @param origins The list of allowed origins. + */ + void set_allowed_origins(std::initializer_list origins) { this->allowed_origins_ = origins; } +#endif + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup the internal web server and register handlers. @@ -593,6 +609,16 @@ class WebServer final : public Controller, public Component, public AsyncWebHand const char *js_include_{nullptr}; #endif bool expose_log_{true}; +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + // Extra origins allowed to make cross-origin browser requests ("*" means any origin). + // Only compiled when allowed_origins is configured; same-origin is always allowed regardless. + FixedVector allowed_origins_; +#endif + + /// Check whether the given request Origin is permitted. Same-origin (matching the Host the + /// request was sent to) and requests without an Origin header are always allowed; any other + /// origin must be listed in allowed_origins. The caller passes the already-read Origin header. + bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin); private: #ifdef USE_SENSOR diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bdb0f27f45..78f7769cf6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -302,6 +302,7 @@ #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP #define USE_WEBSERVER_SORTING +#define USE_WEBSERVER_ALLOWED_ORIGINS #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT diff --git a/tests/component_tests/web_server/test_private_network_access.py b/tests/component_tests/web_server/test_private_network_access.py new file mode 100644 index 0000000000..87911c5f9b --- /dev/null +++ b/tests/component_tests/web_server/test_private_network_access.py @@ -0,0 +1,86 @@ +"""Tests for web_server Private Network Access / allowed_origins validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.web_server import ( + CONF_ALLOWED_ORIGINS, + validate_origin, + validate_private_network_access, +) +from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS +from esphome.types import ConfigType + + +def test_pna_enabled_without_origins_fails() -> None: + """Enabling PNA without allowed_origins must fail validation.""" + config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True} + + with pytest.raises(cv.Invalid) as exc_info: + validate_private_network_access(config) + + error_msg = str(exc_info.value) + assert CONF_ALLOWED_ORIGINS in error_msg + assert "must be set" in error_msg + + +def test_pna_enabled_with_origins_passes() -> None: + """Enabling PNA with at least one allowed origin passes validation.""" + config: ConfigType = { + CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True, + CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"], + } + assert validate_private_network_access(config) == config + + +def test_origins_without_pna_passes() -> None: + """allowed_origins can be set without enabling PNA (they are independent).""" + config: ConfigType = { + CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False, + CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"], + } + assert validate_private_network_access(config) == config + + +def test_pna_disabled_without_origins_passes() -> None: + """PNA disabled and no origins specified passes validation.""" + config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False} + assert validate_private_network_access(config) == config + + +def test_validate_origin_wildcard() -> None: + """The '*' wildcard is accepted as-is.""" + assert validate_origin("*") == "*" + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com", + "http://example.com:8080", + "https://192.168.1.5", + ], +) +def test_validate_origin_valid(value: str) -> None: + """Well-formed origins pass through unchanged.""" + assert validate_origin(value) == value + + +def test_validate_origin_lowercased() -> None: + """Scheme and host are normalized to lowercase to match the browser Origin header.""" + assert validate_origin("HTTPS://App.Example.com") == "https://app.example.com" + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com/", # trailing slash + "https://example.com/path", # path segment + "example.com", # missing scheme + "", # empty + ], +) +def test_validate_origin_invalid(value: str) -> None: + """Malformed origins are rejected at config time instead of silently 403ing.""" + with pytest.raises(cv.Invalid, match="not a valid origin"): + validate_origin(value) diff --git a/tests/components/web_server/common_v2.yaml b/tests/components/web_server/common_v2.yaml index f2b15e484d..b9bc0bbf61 100644 --- a/tests/components/web_server/common_v2.yaml +++ b/tests/components/web_server/common_v2.yaml @@ -5,3 +5,6 @@ web_server: port: 8080 version: 2 compression: br + enable_private_network_access: true + allowed_origins: + - https://app.esphome.io diff --git a/tests/components/web_server/common_v3.yaml b/tests/components/web_server/common_v3.yaml index bdacaaddbe..354d7bb6ac 100644 --- a/tests/components/web_server/common_v3.yaml +++ b/tests/components/web_server/common_v3.yaml @@ -4,6 +4,9 @@ packages: web_server: port: 8080 version: 3 + # allowed_origins can be set independently of Private Network Access + allowed_origins: + - https://app.esphome.io sorting_groups: - id: sorting_group_1 name: "Group 1 Diplayed Last" From af9a0404d9b4e32385ccd8cb412512a352870dc2 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sun, 12 Jul 2026 23:23:40 -0500 Subject: [PATCH 2125/2214] [esp32] Do not require verification_key with Secure Boot V2 signing schemes (#17497) --- esphome/components/esp32/__init__.py | 99 +++++++++++++++---- tests/component_tests/esp32/test_esp32.py | 75 ++++++++++++++ ...date-signed_ota_external.esp32-s3-idf.yaml | 11 +++ 3 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7c926fe28e..9b568dd629 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1160,6 +1160,74 @@ def _ota_downgrade_protection_errors( return errs +_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_SIGNING_KEY): cv.file_, + cv.Optional(CONF_VERIFICATION_KEY): cv.file_, + cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of( + *SIGNING_SCHEMES, lower=True + ), + } +) + + +@schema_extractor("schema") +def _validate_signed_ota_verification(value): + if value is SCHEMA_EXTRACT: + # Expose the inner schema so the language-schema dumper can walk the + # signing_key / verification_key / signing_scheme options. + return _SIGNED_OTA_VERIFICATION_SCHEMA + if value is None: + # A bare `signed_ota_verification:` block is valid: the default V2 + # scheme needs no keys (verify externally-signed binaries). + value = {} + return _validate_signed_ota_keys(_SIGNED_OTA_VERIFICATION_SCHEMA(value)) + + +def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: + """Validate the signing/verification key combination for the selected scheme. + + A verification key is only used by the Secure Boot V1 scheme (ecdsa_v1): + the public key is compiled into the app so it can verify externally-signed + images. ESP-IDF's CONFIG_SECURE_BOOT_VERIFICATION_KEY only takes effect + when the V1 ECDSA scheme is selected and binaries are not signed during + the build (see SECURE_BOOT_VERIFICATION_KEY in the bootloader Kconfig). + + The V2 schemes (rsa3072, ecdsa256) embed the public key in the signature + block appended to each image, so verifying externally-signed binaries + needs no key in the config at all -- omitting both keys selects that + external-signing mode. + """ + has_signing_key = CONF_SIGNING_KEY in config + has_verification_key = CONF_VERIFICATION_KEY in config + scheme = config[CONF_SIGNING_SCHEME] + if has_signing_key and has_verification_key: + raise cv.Invalid( + f"Provide at most one of '{CONF_SIGNING_KEY}' and " + f"'{CONF_VERIFICATION_KEY}', not both.", + path=[CONF_VERIFICATION_KEY], + ) + if scheme == "ecdsa_v1": + if not has_signing_key and not has_verification_key: + raise cv.Invalid( + f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' " + f"(to sign binaries during the build) or '{CONF_VERIFICATION_KEY}' " + f"(to verify binaries signed externally).", + path=[CONF_SIGNING_KEY], + ) + elif has_verification_key: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEY}' is only used with signing scheme " + f"'ecdsa_v1'. With '{scheme}' the public key is embedded in each " + f"image's signature block, so no key file is needed to verify " + f"externally-signed binaries: remove '{CONF_VERIFICATION_KEY}', and " + f"set '{CONF_SIGNING_KEY}' only if binaries should be signed during " + f"the build.", + path=[CONF_VERIFICATION_KEY], + ) + return config + + def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN @@ -1361,7 +1429,7 @@ def final_validate(config): ) else: _LOGGER.info( - "Signed OTA verification is configured with a public verification key. " + "Signed OTA verification is enabled without a signing key. " "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) @@ -1640,18 +1708,9 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional( CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False ): cv.boolean, - cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All( - cv.Schema( - { - cv.Optional(CONF_SIGNING_KEY): cv.file_, - cv.Optional(CONF_VERIFICATION_KEY): cv.file_, - cv.Optional( - CONF_SIGNING_SCHEME, default="rsa3072" - ): cv.one_of(*SIGNING_SCHEMES, lower=True), - } - ), - cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), - ), + cv.Optional( + CONF_SIGNED_OTA_VERIFICATION + ): _validate_signed_ota_verification, cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema( { # eFuse key block (0-5) that stores the HMAC key from @@ -2498,12 +2557,16 @@ async def to_code(config): signed_ota[CONF_SIGNING_KEY].resolve().as_posix(), ) else: - # Public key mode — verification only, external signing required + # External signing mode — binaries must be signed after the build add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) - add_idf_sdkconfig_option( - "CONFIG_SECURE_BOOT_VERIFICATION_KEY", - signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), - ) + if CONF_VERIFICATION_KEY in signed_ota: + # V1 ECDSA only: the public key is compiled into the app to + # verify externally-signed images. V2 schemes carry the public + # key in each image's signature block and need no key here. + add_idf_sdkconfig_option( + "CONFIG_SECURE_BOOT_VERIFICATION_KEY", + signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), + ) cg.add_define("USE_OTA_SIGNED_VERIFICATION") diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index dd8881e46f..fdca70bf2c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -665,3 +665,78 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None: # No project version and no signing -> two distinct errors. errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False) assert len(errs) == 2 + + +@pytest.mark.parametrize( + "config", + [ + # V2 schemes: signing key (sign during build) or no key at all + # (external signing; the public key travels in the signature block). + {"signing_scheme": "rsa3072", "signing_key": "key.pem"}, + {"signing_scheme": "rsa3072"}, + {"signing_scheme": "ecdsa256", "signing_key": "key.pem"}, + {"signing_scheme": "ecdsa256"}, + # V1 ECDSA: exactly one of signing key / verification key. + {"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"}, + {"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"}, + ], +) +def test_signed_ota_keys_valid_combinations(config: dict) -> None: + from esphome.components.esp32 import _validate_signed_ota_keys + + assert _validate_signed_ota_keys(config) is config + + +@pytest.mark.parametrize("value", [None, {}]) +def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -> None: + """A bare `signed_ota_verification:` block is valid: the default V2 + scheme embeds the public key in the signature block, so verifying + externally-signed binaries needs no keys in the config.""" + from esphome.components.esp32 import _validate_signed_ota_verification + + config = _validate_signed_ota_verification(value) + assert config == {"signing_scheme": "rsa3072"} + + +@pytest.mark.parametrize( + ("config", "match"), + [ + # A verification key is meaningless with the V2 schemes -- the public + # key is embedded in each image's signature block. + ( + {"signing_scheme": "rsa3072", "verification_key": "key.bin"}, + "only used with signing scheme 'ecdsa_v1'", + ), + ( + {"signing_scheme": "ecdsa256", "verification_key": "key.bin"}, + "only used with signing scheme 'ecdsa_v1'", + ), + # V1 ECDSA needs a key either way. + ( + {"signing_scheme": "ecdsa_v1"}, + "Signing scheme 'ecdsa_v1' requires either", + ), + # Never both keys at once. + ( + { + "signing_scheme": "rsa3072", + "signing_key": "key.pem", + "verification_key": "key.bin", + }, + "not both", + ), + ( + { + "signing_scheme": "ecdsa_v1", + "signing_key": "key.pem", + "verification_key": "key.bin", + }, + "not both", + ), + ], +) +def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: + from esphome.components.esp32 import _validate_signed_ota_keys + + with pytest.raises(cv.Invalid, match=match): + _validate_signed_ota_keys(config) diff --git a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml new file mode 100644 index 0000000000..5b57993e87 --- /dev/null +++ b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +# Secure Boot V2 schemes carry the public key inside each image's signature +# block, so verifying externally-signed binaries needs no key in the config: +# a bare block enables verification with the default rsa3072 scheme. +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + +<<: !include common.yaml From 583adc9e69a30898556ce68f945fe6718abb7829 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:23:55 +1200 Subject: [PATCH 2126/2214] [web_server] Use dict-style packages in tests so they can be batch-grouped (#17544) --- tests/components/web_server/test.esp32-ard.yaml | 3 ++- tests/components/web_server/test.esp32-idf.yaml | 3 ++- tests/components/web_server/test.esp8266-ard.yaml | 3 ++- tests/components/web_server/test.rp2040-ard.yaml | 3 ++- tests/components/web_server/test_v1.esp32-ard.yaml | 3 ++- tests/components/web_server/test_v1.esp32-idf.yaml | 3 ++- tests/components/web_server/test_v3.esp32-ard.yaml | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/components/web_server/test.esp32-ard.yaml b/tests/components/web_server/test.esp32-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.esp32-ard.yaml +++ b/tests/components/web_server/test.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 24b292d0d6..858e3b0190 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -1,4 +1,5 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml web_server: auth: diff --git a/tests/components/web_server/test.esp8266-ard.yaml b/tests/components/web_server/test.esp8266-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.esp8266-ard.yaml +++ b/tests/components/web_server/test.esp8266-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test_v1.esp32-ard.yaml b/tests/components/web_server/test_v1.esp32-ard.yaml index 389a930284..1d563de834 100644 --- a/tests/components/web_server/test_v1.esp32-ard.yaml +++ b/tests/components/web_server/test_v1.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v1.yaml +packages: + web_server: !include common_v1.yaml diff --git a/tests/components/web_server/test_v1.esp32-idf.yaml b/tests/components/web_server/test_v1.esp32-idf.yaml index 389a930284..1d563de834 100644 --- a/tests/components/web_server/test_v1.esp32-idf.yaml +++ b/tests/components/web_server/test_v1.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common_v1.yaml +packages: + web_server: !include common_v1.yaml diff --git a/tests/components/web_server/test_v3.esp32-ard.yaml b/tests/components/web_server/test_v3.esp32-ard.yaml index 00d05521e4..956a88bc68 100644 --- a/tests/components/web_server/test_v3.esp32-ard.yaml +++ b/tests/components/web_server/test_v3.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v3.yaml +packages: + web_server: !include common_v3.yaml From 989797be5356506765574b480be4681a96d7b53c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:33:44 +1200 Subject: [PATCH 2127/2214] [web_server] Add HTTP digest authentication with selectable scheme (#17541) --- esphome/components/web_server/__init__.py | 52 ++++-- .../web_server_base/web_server_base.h | 8 + .../web_server_idf/web_server_idf.cpp | 170 +++++++++++++++++- .../web_server_idf/web_server_idf.h | 2 +- esphome/core/defines.h | 3 + .../web_server/test_web_server_auth.py | 65 +++++++ .../web_server/web_server_auth_basic.yaml | 18 ++ .../web_server/web_server_auth_default.yaml | 17 ++ .../web_server/web_server_auth_digest.yaml | 18 ++ .../web_server/web_server_no_auth.yaml | 14 ++ .../components/web_server/test.esp32-idf.yaml | 1 + .../web_server/test.esp8266-ard.yaml | 6 + .../web_server/test.rp2040-ard.yaml | 6 + .../web_server/validate.esp32-idf.yaml | 8 + 14 files changed, 370 insertions(+), 18 deletions(-) create mode 100644 tests/component_tests/web_server/test_web_server_auth.py create mode 100644 tests/component_tests/web_server/web_server_auth_basic.yaml create mode 100644 tests/component_tests/web_server/web_server_auth_default.yaml create mode 100644 tests/component_tests/web_server/web_server_auth_digest.yaml create mode 100644 tests/component_tests/web_server/web_server_no_auth.yaml create mode 100644 tests/components/web_server/validate.esp32-idf.yaml diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 68f1c18072..2587d13b9e 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_TYPE, CONF_USERNAME, CONF_VERSION, CONF_WEB_SERVER, @@ -44,6 +45,9 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["json", "web_server_base"] +AUTH_TYPE_BASIC = "basic" +AUTH_TYPE_DIGEST = "digest" + CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" @@ -85,6 +89,19 @@ def validate_version_deprecated(config: ConfigType) -> ConfigType: return config +def validate_auth_type_deprecated(auth: ConfigType) -> ConfigType: + # Remove before 2027.1.0: the default auth scheme changes from basic to digest. + if CONF_TYPE not in auth: + _LOGGER.warning( + "The 'web_server' 'auth' scheme currently defaults to 'basic', which sends the " + "password over the network in an easily reversible form. The default will change " + "to 'digest' in ESPHome 2027.1.0. To keep using basic authentication, set " + "'type: basic' under 'auth:' explicitly; otherwise set 'type: digest' now to " + "adopt the more secure scheme." + ) + return auth + + def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") @@ -242,15 +259,21 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ALLOWED_ORIGINS): cv.All( cv.ensure_list(validate_origin), cv.Length(min=1) ), - cv.Optional(CONF_AUTH): cv.Schema( - { - cv.Required(CONF_USERNAME): cv.All( - cv.string_strict, cv.Length(min=1) - ), - cv.Required(CONF_PASSWORD): cv.sensitive( - cv.All(cv.string_strict, cv.Length(min=1)) - ), - } + cv.Optional(CONF_AUTH): cv.All( + cv.Schema( + { + cv.Required(CONF_USERNAME): cv.All( + cv.string_strict, cv.Length(min=1) + ), + cv.Required(CONF_PASSWORD): cv.sensitive( + cv.All(cv.string_strict, cv.Length(min=1)) + ), + cv.Optional(CONF_TYPE): cv.one_of( + AUTH_TYPE_BASIC, AUTH_TYPE_DIGEST, lower=True + ), + } + ), + validate_auth_type_deprecated, ), cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id( web_server_base.WebServerBase @@ -378,10 +401,15 @@ async def to_code(config): if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") cg.add(var.set_allowed_origins(allowed_origins)) - if CONF_AUTH in config: + if (auth := config.get(CONF_AUTH)) is not None: cg.add_define("USE_WEBSERVER_AUTH") - cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) - cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD])) + # 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: + 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 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.h b/esphome/components/web_server_base/web_server_base.h index 19c2185fb9..9657853a73 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -59,7 +59,15 @@ class AuthMiddlewareHandler : public MiddlewareHandler { bool check_auth(AsyncWebServerRequest *request) { bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str()); 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) + request->requestAuthentication(nullptr, true); +#else + request->requestAuthentication(nullptr, false); +#endif } return success; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 46a389f359..bf5a8666dc 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -16,6 +16,11 @@ #include "utils.h" #include "web_server_idf.h" +#ifdef USE_WEBSERVER_AUTH_DIGEST +#include +#include +#endif + #ifdef USE_WEBSERVER_OTA #include #include "multipart.h" // For parse_multipart_boundary and other utils @@ -372,6 +377,135 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code } #ifdef USE_WEBSERVER_AUTH + +#ifdef USE_WEBSERVER_AUTH_DIGEST +namespace { + +// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated. +void bytes_to_hex(const uint8_t *data, size_t len, char *out) { + static const char HEX[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) { + out[i * 2] = HEX[data[i] >> 4]; + out[i * 2 + 1] = HEX[data[i] & 0x0f]; + } + out[len * 2] = '\0'; +} + +// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated +// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent. +// Only whole parameter names match, so "nc" does not match inside "cnonce". +StringRef digest_param(StringRef params, const char *key) { + size_t key_len = strlen(key); + const char *base = params.c_str(); + size_t n = params.size(); + size_t i = 0; + while (i < n) { + while (i < n && (base[i] == ' ' || base[i] == ',')) + i++; + size_t name_start = i; + while (i < n && base[i] != '=' && base[i] != ',') + i++; + if (i >= n || base[i] == ',') + continue; // token without a '=', skip it + size_t name_len = i - name_start; + while (name_len > 0 && base[name_start + name_len - 1] == ' ') + name_len--; + i++; // consume '=' + const char *val_start; + size_t val_len; + if (i < n && base[i] == '"') { + i++; + val_start = base + i; + while (i < n && base[i] != '"') + i++; + val_len = (base + i) - val_start; + if (i < n) + i++; // consume closing quote + } else { + val_start = base + i; + while (i < n && base[i] != ',') + i++; + val_len = (base + i) - val_start; + } + if (name_len == key_len && memcmp(base + name_start, key, key_len) == 0) + return StringRef(val_start, val_len); + while (i < n && base[i] != ',') + i++; + } + return StringRef(); +} + +// Verify an RFC 2617 Digest response. Stateless (the nonce we issued is not tracked), which +// matches the ESPAsyncWebServer backend used on the Arduino platforms. +bool check_digest_auth(const char *username, const char *password, const std::string &header, const char *method) { + const size_t prefix_len = sizeof("Digest ") - 1; + StringRef params(header.c_str() + prefix_len, header.size() - prefix_len); + + if (digest_param(params, "username") != username) + return false; + + StringRef realm = digest_param(params, "realm"); + StringRef nonce = digest_param(params, "nonce"); + StringRef uri = digest_param(params, "uri"); + StringRef qop = digest_param(params, "qop"); + StringRef nc = digest_param(params, "nc"); + StringRef cnonce = digest_param(params, "cnonce"); + StringRef response = digest_param(params, "response"); + if (response.size() != 32) + return false; + + // Compute the three MD5 hashes by streaming the pieces straight into the ROM MD5 engine, so + // nothing is concatenated on the heap. Each hash is emitted as 32 lowercase hex characters. + md5_context_t ctx; + uint8_t digest[16]; + + // HA1 = MD5(username:realm:password) -- uses the realm the client echoed back. + char ha1[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, username, strlen(username)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, realm.c_str(), realm.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, password, strlen(password)); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha1); + + // HA2 = MD5(method:uri) -- uses the uri the client echoed back. + char ha2[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, method, strlen(method)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, uri.c_str(), uri.size()); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha2); + + // expected = MD5(HA1:nonce:nc:cnonce:qop:HA2) + char expected[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, ha1, 32); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nonce.c_str(), nonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nc.c_str(), nc.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, cnonce.c_str(), cnonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, qop.c_str(), qop.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, ha2, 32); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), expected); + + // Constant-time comparison of the two 32-char hex digests. + uint8_t result = 0; + for (size_t i = 0; i < 32; i++) + result |= static_cast(expected[i] ^ response[i]); + return result == 0; +} + +} // namespace +#endif // USE_WEBSERVER_AUTH_DIGEST + bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const { if (username == nullptr || password == nullptr || *username == 0) { return true; @@ -383,9 +517,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw auto *auth_str = auth.value().c_str(); +#ifdef USE_WEBSERVER_AUTH_DIGEST + // The build fixed the scheme to Digest, so the Basic path is compiled out entirely. + const auto auth_prefix_len = sizeof("Digest ") - 1; + if (strncmp("Digest ", auth_str, auth_prefix_len) != 0) { + ESP_LOGW(TAG, "Only Digest authorization supported"); + return false; + } + return check_digest_auth(username, password, auth.value(), http_method_str(this->method())); +#else const auto auth_prefix_len = sizeof("Basic ") - 1; if (strncmp("Basic ", auth_str, auth_prefix_len) != 0) { - ESP_LOGW(TAG, "Only Basic authorization supported yet"); + ESP_LOGW(TAG, "Only Basic authorization supported"); return false; } @@ -434,16 +577,33 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw result |= static_cast(digest[i] ^ provided_ch); } return result == 0; +#endif // USE_WEBSERVER_AUTH_DIGEST } -void AsyncWebServerRequest::requestAuthentication(const char *realm) const { +void AsyncWebServerRequest::requestAuthentication() const { httpd_resp_set_hdr(*this, "Connection", "keep-alive"); - // Note: realm is never configured in ESPHome, always nullptr -> "Login Required" - (void) realm; // Unused - always use default +#ifdef USE_WEBSERVER_AUTH_DIGEST + // Issue a fresh random nonce and opaque. The nonce is not stored, so this is stateless and + // does not defend against replay -- its purpose is to keep the password off the wire. + // The header value must stay alive until httpd_resp_send_err() below sends it, so the buffer + // lives on this stack frame (httpd_resp_set_hdr stores the pointer, it does not copy). + uint8_t random_bytes[16]; + char nonce[33]; + char opaque[33]; + char header[160]; + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), nonce); + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), opaque); + snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce, + opaque); + httpd_resp_set_hdr(*this, "WWW-Authenticate", header); +#else httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\""); +#endif // USE_WEBSERVER_AUTH_DIGEST httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr); } -#endif +#endif // USE_WEBSERVER_AUTH AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) { // Check cache first - only successful lookups are cached diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 8b5fd5b726..baa55898bb 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -129,7 +129,7 @@ class AsyncWebServerRequest { #ifdef USE_WEBSERVER_AUTH bool authenticate(const char *username, const char *password) const; // NOLINTNEXTLINE(readability-identifier-naming) - void requestAuthentication(const char *realm = nullptr) const; + void requestAuthentication() const; #endif void redirect(const std::string &url); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 78f7769cf6..5c5fc5e8b9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -298,6 +298,7 @@ #define USE_VOICE_ASSISTANT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP @@ -408,6 +409,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -438,6 +440,7 @@ #define USE_LWIP_FAST_SELECT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/component_tests/web_server/test_web_server_auth.py b/tests/component_tests/web_server/test_web_server_auth.py new file mode 100644 index 0000000000..82635b26da --- /dev/null +++ b/tests/component_tests/web_server/test_web_server_auth.py @@ -0,0 +1,65 @@ +"""Tests for web_server authentication codegen.""" + +from collections.abc import Callable + +import pytest + +from esphome.core import CORE + +_DEFAULT_CHANGE_WARNING = "default will change to 'digest' in ESPHome 2027.1.0" + + +def _has_define(name: str) -> bool: + return any(d.name == name for d in CORE.defines) + + +def test_web_server_auth_default_is_basic_with_deprecation_warning( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth without an explicit type builds Basic and warns about the upcoming default change.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_default.yaml" + ) + + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING in caplog.text + + +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") + + 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_explicit_digest( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth type digest builds Digest and does not warn.""" + generate_main("tests/component_tests/web_server/web_server_auth_digest.yaml") + + assert _has_define("USE_WEBSERVER_AUTH") + assert _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text + + +def test_web_server_without_auth( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Without an auth block, no auth is compiled in and no warning is emitted.""" + generate_main("tests/component_tests/web_server/web_server_no_auth.yaml") + + assert not _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text diff --git a/tests/component_tests/web_server/web_server_auth_basic.yaml b/tests/component_tests/web_server/web_server_auth_basic.yaml new file mode 100644 index 0000000000..70180f9fbc --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_basic.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +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_default.yaml b/tests/component_tests/web_server/web_server_auth_default.yaml new file mode 100644 index 0000000000..076180ab79 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_default.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password diff --git a/tests/component_tests/web_server/web_server_auth_digest.yaml b/tests/component_tests/web_server/web_server_auth_digest.yaml new file mode 100644 index 0000000000..f413787601 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_digest.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/component_tests/web_server/web_server_no_auth.yaml b/tests/component_tests/web_server/web_server_no_auth.yaml new file mode 100644 index 0000000000..1c7823b4ae --- /dev/null +++ b/tests/component_tests/web_server/web_server_no_auth.yaml @@ -0,0 +1,14 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 858e3b0190..2e091b905a 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -5,3 +5,4 @@ web_server: auth: username: admin password: password + type: digest diff --git a/tests/components/web_server/test.esp8266-ard.yaml b/tests/components/web_server/test.esp8266-ard.yaml index 11ad5456ef..2e091b905a 100644 --- a/tests/components/web_server/test.esp8266-ard.yaml +++ b/tests/components/web_server/test.esp8266-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 11ad5456ef..e4d50d7776 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/components/web_server/validate.esp32-idf.yaml b/tests/components/web_server/validate.esp32-idf.yaml new file mode 100644 index 0000000000..e4d50d7776 --- /dev/null +++ b/tests/components/web_server/validate.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic From 519ce38b7932fd3e4b8b3bbba0b5e709caea13d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Jul 2026 13:51:54 -1000 Subject: [PATCH 2128/2214] [libretiny] Keep renamed board generic-ln882hki validating against generic-ln882h (#17542) --- esphome/components/libretiny/__init__.py | 15 ++++++ tests/unit_tests/components/test_libretiny.py | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/unit_tests/components/test_libretiny.py diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 3fde11b1eb..62cef331fd 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -76,12 +76,27 @@ _BLE5_BK_SYS_CONFIG_OPTIONS = [ "CFG_SUPPORT_BLE=0", ] +# Board ids upstream LibreTiny renamed; configs written against the old id +# keep validating and building against the new one (with a warning). +# generic-ln882hki -> generic-ln882h: LibreTiny v1.13.0. +_RENAMED_BOARDS = { + "generic-ln882hki": "generic-ln882h", +} + def _detect_variant(value): if KEY_LIBRETINY not in CORE.data: raise cv.Invalid("Family component didn't populate core data properly!") component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] board = value[CONF_BOARD] + if board not in component.boards and (renamed := _RENAMED_BOARDS.get(board)): + _LOGGER.warning( + "Board '%s' was renamed to '%s'; please update your configuration", + board, + renamed, + ) + value = value.copy() + value[CONF_BOARD] = board = renamed # read board-default family if not specified if board not in component.boards: if CONF_FAMILY not in value: diff --git a/tests/unit_tests/components/test_libretiny.py b/tests/unit_tests/components/test_libretiny.py new file mode 100644 index 0000000000..ee00bdc180 --- /dev/null +++ b/tests/unit_tests/components/test_libretiny.py @@ -0,0 +1,52 @@ +"""Tests for LibreTiny board detection, including renamed-board migration.""" + +import pytest + +from esphome.components.libretiny import _detect_variant +from esphome.components.libretiny.const import ( + FAMILY_LN882H, + KEY_COMPONENT_DATA, + KEY_LIBRETINY, +) +from esphome.components.ln882x import COMPONENT_DATA +import esphome.config_validation as cv +from esphome.const import CONF_BOARD, CONF_FAMILY +from esphome.core import CORE + + +@pytest.fixture +def ln882x_core_data() -> None: + """Populate CORE the way the ln882x component schema does.""" + CORE.data[KEY_LIBRETINY] = {KEY_COMPONENT_DATA: COMPONENT_DATA} + + +def test_detect_variant_known_board_passes(ln882x_core_data: None) -> None: + """A current board id resolves its family without warnings.""" + result = _detect_variant({CONF_BOARD: "generic-ln882h"}) + assert result[CONF_BOARD] == "generic-ln882h" + assert result[CONF_FAMILY] == FAMILY_LN882H + + +def test_detect_variant_renamed_board_migrates( + ln882x_core_data: None, caplog: pytest.LogCaptureFixture +) -> None: + """A pre-rename board id validates against the new id, with a warning.""" + result = _detect_variant({CONF_BOARD: "generic-ln882hki"}) + assert result[CONF_BOARD] == "generic-ln882h" + assert result[CONF_FAMILY] == FAMILY_LN882H + assert "renamed to 'generic-ln882h'" in caplog.text + + +def test_detect_variant_renamed_board_does_not_mutate_input( + ln882x_core_data: None, +) -> None: + """Migration copies the config; the caller's dict keeps the old id.""" + value = {CONF_BOARD: "generic-ln882hki"} + _detect_variant(value) + assert value[CONF_BOARD] == "generic-ln882hki" + + +def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> None: + """Ids outside the rename map keep the family-override error.""" + with pytest.raises(cv.Invalid, match="This board is unknown"): + _detect_variant({CONF_BOARD: "not-a-real-board"}) From 1da8900ffc80df4c7220df9a998ee7d419c3a815 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:53:31 -0400 Subject: [PATCH 2129/2214] [veml7700][as7341][ltr501] Fix device class on raw-count sensors (#17549) --- esphome/components/as7341/sensor.py | 4 ++-- esphome/components/ltr501/sensor.py | 10 +++++----- esphome/components/veml7700/sensor.py | 5 +++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index fa51a1cdfa..8b6cf61028 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -5,7 +5,7 @@ from esphome.const import ( CONF_CLEAR, CONF_GAIN, CONF_ID, - DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_EMPTY, ICON_BRIGHTNESS_5, STATE_CLASS_MEASUREMENT, ) @@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index cca9330e76..c1fa9009b3 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_NAME, CONF_REPEAT, CONF_TYPE, - DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -159,7 +159,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -169,7 +169,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -179,7 +179,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_PROXIMITY, accuracy_decimals=0, - device_class=DEVICE_CLASS_DISTANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -188,7 +188,7 @@ CONFIG_SCHEMA = cv.All( sensor.sensor_schema( icon=ICON_GAIN, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 6ad2eb417f..d0d3584dc2 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, + DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_6, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -111,7 +112,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, From 8da377ab43922307ff40440b8c5dad4f7f5de72a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 13 Jul 2026 18:56:03 -0500 Subject: [PATCH 2130/2214] [nextion] Fix unbounded queue growth and OOM crash when display sends no data (#17553) --- esphome/components/nextion/nextion.cpp | 32 +++++++++++++++++++------- esphome/components/nextion/nextion.h | 4 ++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 4ebc717552..bdc66adb70 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -1,6 +1,7 @@ #include "nextion.h" #include +#include #include "esphome/core/application.h" #include "esphome/core/helpers.h" @@ -352,8 +353,9 @@ void Nextion::loop() { this->connection_state_.ignore_is_setup_ = false; } - this->process_serial_(); // Receive serial data - this->process_nextion_commands_(); // Process nextion return commands + this->process_serial_(); // Receive serial data + this->process_nextion_commands_(); // Process nextion return commands + this->purge_stale_queue_entries_(); // Drop expired entries even when the display sends no data if (!this->connection_state_.nextion_reports_is_setup_) { if (this->started_ms_ == 0) @@ -902,6 +904,11 @@ void Nextion::process_nextion_commands_() { this->command_data_.erase(0, to_process_length + DELIMITER_SIZE + 1); } + ESP_LOGN(TAG, "Loop end"); + this->process_serial_(); +} // Nextion::process_nextion_commands_() + +void Nextion::purge_stale_queue_entries_() { const uint32_t ms = App.get_loop_component_start_time(); if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() && @@ -927,10 +934,7 @@ void Nextion::process_nextion_commands_() { } } } - ESP_LOGN(TAG, "Loop end"); - // App.feed_wdt(); Remove before master merge - this->process_serial_(); -} // Nextion::process_nextion_commands_() +} void Nextion::set_nextion_sensor_state(int queue_type, const std::string &name, float state) { this->set_nextion_sensor_state(static_cast(queue_type), name, state); @@ -1101,7 +1105,13 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { new (nextion_queue) nextion::NextionQueue(); // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new nextion::NextionComponentBase; + nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + nextion_queue->~NextionQueue(); + allocator.deallocate(nextion_queue, 1); + return; + } nextion_queue->component->set_variable_name(variable_name); nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1157,7 +1167,13 @@ void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &va } new (nextion_queue) nextion::NextionQueue(); - nextion_queue->component = new nextion::NextionComponentBase; + nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + nextion_queue->~NextionQueue(); + allocator.deallocate(nextion_queue, 1); + return; + } nextion_queue->component->set_variable_name(variable_name); nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index d361d9725b..7dc5a4fe44 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1486,6 +1486,10 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void process_nextion_commands_(); void process_serial_(); + /// Drop queue entries older than max_q_age_ms_. Called from loop() so it also runs when the + /// display sends no data at all (disconnected or asleep), which would otherwise grow the queue + /// without bound. + void purge_stale_queue_entries_(); uint16_t touch_sleep_timeout_ = 0; uint8_t wake_up_page_ = 255; #ifdef USE_NEXTION_CONF_START_UP_PAGE From 6fadf353b196dc31c9ffba2a7ff1e8baedae5f2b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:40:45 +1200 Subject: [PATCH 2131/2214] Bump version to 2026.7.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1bcfded35d..3bd4dc140f 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0b2 +PROJECT_NUMBER = 2026.7.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 f6014176b8..01ff67e3f2 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0b2" +__version__ = "2026.7.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From db9a09d05a7b2e38f02dfe71aabc61b2ef9af627 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:11:03 +1200 Subject: [PATCH 2132/2214] [script] Recursively expand nested packages when merging component tests (#17557) --- script/merge_component_configs.py | 49 +++++++++++------ tests/script/test_merge_component_configs.py | 56 ++++++++++++++++++++ 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 5eeeafac2a..c2be7be7fd 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -263,22 +263,39 @@ def prepare_component_body(comp_data: dict, comp_name: str, comp_dir: Path) -> d else {} ) - packages_value = comp_data.get("packages") - if isinstance(packages_value, dict): - common_bus_packages = get_common_bus_packages() - for pkg_name, pkg_value in list(packages_value.items()): - if pkg_name in common_bus_packages: - continue - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if isinstance(pkg_value, dict): - comp_data = merge_config(comp_data, pkg_value) - elif isinstance(packages_value, list): - for pkg_value in packages_value: - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if isinstance(pkg_value, dict): - comp_data = merge_config(comp_data, pkg_value) + # Expand component-specific package includes inline. A package include may + # itself pull in further component-specific packages (e.g. web_server's test + # includes common_v2, which includes common with the wifi/network config), so + # keep expanding until only common bus packages remain -- otherwise the nested + # includes are silently dropped when the packages key is removed below. + common_bus_packages = get_common_bus_packages() + while True: + packages_value = comp_data.get("packages") + expanded = False + if isinstance(packages_value, dict): + for pkg_name, pkg_value in list(packages_value.items()): + if pkg_name in common_bus_packages: + continue + # Drop before merging so a nested packages dict introduced by the + # include does not re-add this same key on the next iteration. + del packages_value[pkg_name] + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + expanded = True + elif isinstance(packages_value, list): + # List-style packages never contain common bus packages, so expand + # them all and drop the key entirely. + comp_data.pop("packages", None) + for pkg_value in packages_value: + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + expanded = True + if not expanded: + break # Common bus packages are re-added once by the caller; drop them here. comp_data.pop("packages", None) diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py index 6ed1bd2c1e..27be3b5628 100644 --- a/tests/script/test_merge_component_configs.py +++ b/tests/script/test_merge_component_configs.py @@ -10,7 +10,10 @@ sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve( import merge_component_configs # noqa: E402 +from esphome import yaml_util # noqa: E402 + deduplicate_by_id = merge_component_configs.deduplicate_by_id +prepare_component_body = merge_component_configs.prepare_component_body def test_identical_duplicate_ids_collapse() -> None: @@ -99,3 +102,56 @@ def test_nested_lists_are_checked() -> None: } with pytest.raises(ValueError, match="dup"): deduplicate_by_id(data) + + +def test_nested_package_includes_are_fully_expanded(tmp_path: Path) -> None: + """A package include that itself pulls in another package expands fully. + + Mirrors web_server's tests, where test.yaml includes common_v2, which + includes common (holding the wifi/network config). Without recursive + expansion the nested include is dropped and network config is lost. + """ + (tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n") + (tmp_path / "common_v2.yaml").write_text( + "packages:\n device_base: !include common.yaml\nweb_server:\n port: 8080\n" + ) + (tmp_path / "test.yaml").write_text( + "packages:\n web_server: !include common_v2.yaml\n" + "web_server:\n auth:\n username: admin\n" + ) + + comp_data = yaml_util.load_yaml(tmp_path / "test.yaml") + result = prepare_component_body(comp_data, "web_server", tmp_path) + + assert "packages" not in result + assert result["wifi"] == {"ssid": "MySSID"} + assert result["web_server"] == {"port": 8080, "auth": {"username": "admin"}} + + +def test_common_bus_package_is_left_for_caller(tmp_path: Path) -> None: + """Common bus packages are not expanded inline; the caller re-adds them.""" + comp_data = { + "packages": { + "i2c": {"sda": 21, "scl": 22}, + "device_base": {"wifi": {"ssid": "MySSID"}}, + }, + } + result = prepare_component_body(comp_data, "mycomp", tmp_path) + + # The bus package's body must not be merged in, and the packages key is + # dropped entirely for the caller to re-add the common bus package. + assert "packages" not in result + assert "sda" not in result + assert result["wifi"] == {"ssid": "MySSID"} + + +def test_list_style_packages_are_expanded(tmp_path: Path) -> None: + """List-style package includes are expanded and the key removed.""" + (tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n") + (tmp_path / "test.yaml").write_text("packages:\n - !include common.yaml\n") + + comp_data = yaml_util.load_yaml(tmp_path / "test.yaml") + result = prepare_component_body(comp_data, "mycomp", tmp_path) + + assert "packages" not in result + assert result["wifi"] == {"ssid": "MySSID"} From 0a1065da75b3b4d6dea3ef9dd73f6789cf9e68ae Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:11:03 +1200 Subject: [PATCH 2133/2214] [script] Recursively expand nested packages when merging component tests (#17557) --- script/merge_component_configs.py | 49 +++++++++++------ tests/script/test_merge_component_configs.py | 56 ++++++++++++++++++++ 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 5eeeafac2a..c2be7be7fd 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -263,22 +263,39 @@ def prepare_component_body(comp_data: dict, comp_name: str, comp_dir: Path) -> d else {} ) - packages_value = comp_data.get("packages") - if isinstance(packages_value, dict): - common_bus_packages = get_common_bus_packages() - for pkg_name, pkg_value in list(packages_value.items()): - if pkg_name in common_bus_packages: - continue - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if isinstance(pkg_value, dict): - comp_data = merge_config(comp_data, pkg_value) - elif isinstance(packages_value, list): - for pkg_value in packages_value: - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if isinstance(pkg_value, dict): - comp_data = merge_config(comp_data, pkg_value) + # Expand component-specific package includes inline. A package include may + # itself pull in further component-specific packages (e.g. web_server's test + # includes common_v2, which includes common with the wifi/network config), so + # keep expanding until only common bus packages remain -- otherwise the nested + # includes are silently dropped when the packages key is removed below. + common_bus_packages = get_common_bus_packages() + while True: + packages_value = comp_data.get("packages") + expanded = False + if isinstance(packages_value, dict): + for pkg_name, pkg_value in list(packages_value.items()): + if pkg_name in common_bus_packages: + continue + # Drop before merging so a nested packages dict introduced by the + # include does not re-add this same key on the next iteration. + del packages_value[pkg_name] + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + expanded = True + elif isinstance(packages_value, list): + # List-style packages never contain common bus packages, so expand + # them all and drop the key entirely. + comp_data.pop("packages", None) + for pkg_value in packages_value: + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + expanded = True + if not expanded: + break # Common bus packages are re-added once by the caller; drop them here. comp_data.pop("packages", None) diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py index 6ed1bd2c1e..27be3b5628 100644 --- a/tests/script/test_merge_component_configs.py +++ b/tests/script/test_merge_component_configs.py @@ -10,7 +10,10 @@ sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve( import merge_component_configs # noqa: E402 +from esphome import yaml_util # noqa: E402 + deduplicate_by_id = merge_component_configs.deduplicate_by_id +prepare_component_body = merge_component_configs.prepare_component_body def test_identical_duplicate_ids_collapse() -> None: @@ -99,3 +102,56 @@ def test_nested_lists_are_checked() -> None: } with pytest.raises(ValueError, match="dup"): deduplicate_by_id(data) + + +def test_nested_package_includes_are_fully_expanded(tmp_path: Path) -> None: + """A package include that itself pulls in another package expands fully. + + Mirrors web_server's tests, where test.yaml includes common_v2, which + includes common (holding the wifi/network config). Without recursive + expansion the nested include is dropped and network config is lost. + """ + (tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n") + (tmp_path / "common_v2.yaml").write_text( + "packages:\n device_base: !include common.yaml\nweb_server:\n port: 8080\n" + ) + (tmp_path / "test.yaml").write_text( + "packages:\n web_server: !include common_v2.yaml\n" + "web_server:\n auth:\n username: admin\n" + ) + + comp_data = yaml_util.load_yaml(tmp_path / "test.yaml") + result = prepare_component_body(comp_data, "web_server", tmp_path) + + assert "packages" not in result + assert result["wifi"] == {"ssid": "MySSID"} + assert result["web_server"] == {"port": 8080, "auth": {"username": "admin"}} + + +def test_common_bus_package_is_left_for_caller(tmp_path: Path) -> None: + """Common bus packages are not expanded inline; the caller re-adds them.""" + comp_data = { + "packages": { + "i2c": {"sda": 21, "scl": 22}, + "device_base": {"wifi": {"ssid": "MySSID"}}, + }, + } + result = prepare_component_body(comp_data, "mycomp", tmp_path) + + # The bus package's body must not be merged in, and the packages key is + # dropped entirely for the caller to re-add the common bus package. + assert "packages" not in result + assert "sda" not in result + assert result["wifi"] == {"ssid": "MySSID"} + + +def test_list_style_packages_are_expanded(tmp_path: Path) -> None: + """List-style package includes are expanded and the key removed.""" + (tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n") + (tmp_path / "test.yaml").write_text("packages:\n - !include common.yaml\n") + + comp_data = yaml_util.load_yaml(tmp_path / "test.yaml") + result = prepare_component_body(comp_data, "mycomp", tmp_path) + + assert "packages" not in result + assert result["wifi"] == {"ssid": "MySSID"} From b6b5b6164082fcbfa8f9aba1e7bde872af24063d Mon Sep 17 00:00:00 2001 From: Daniele Palumbo Date: Tue, 14 Jul 2026 04:38:07 +0200 Subject: [PATCH 2134/2214] [mcp23017] reset IPOL registers to 0x00 on setup (#17177) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/mcp23017/mcp23017.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index 9e3d75575a..173d117457 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -19,6 +19,10 @@ void MCP23017::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_); this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); + // Reset IPOL to 0x00: ESPHome handles 'inverted' in software. + this->write_reg(mcp23x17_base::MCP23X17_IPOLA, 0x00); + this->write_reg(mcp23x17_base::MCP23X17_IPOLB, 0x00); + uint8_t iocon_flags = 0; if (this->open_drain_ints_) { iocon_flags |= IOCON_ODR; From 2753ab1f4570e76129aea55f06e89e8a7c4dcb4a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:50:08 -1000 Subject: [PATCH 2135/2214] Bump bundled esphome-device-builder to 1.5.0 (#17558) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index f09280a50e..01ff53a463 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.4.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.5.0 RUN \ platformio settings set enable_telemetry No \ From a833685a730679801e956b9e0b278affbe714a8a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:50:08 -1000 Subject: [PATCH 2136/2214] Bump bundled esphome-device-builder to 1.5.0 (#17558) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index f09280a50e..01ff53a463 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.4.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.5.0 RUN \ platformio settings set enable_telemetry No \ From 4ebe49b141f49dae8ca5815111011b80f85b2bd5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:54:02 -1000 Subject: [PATCH 2137/2214] Bump clang-tidy from 22.1.7 to 22.1.8 (#17565) Signed-off-by: dependabot[bot] --- requirements_dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index 7e66c7244d..f2cf855d6b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating -clang-tidy==22.1.7 +clang-tidy==22.1.8 yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating From 427534323114264b0f89ee3bdf0bbe95b7383c5e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:23:37 -0400 Subject: [PATCH 2138/2214] [atc_mithermometer] Make duplicate-packet counter per-instance (#17496) --- esphome/components/atc_mithermometer/atc_mithermometer.cpp | 7 +++---- esphome/components/atc_mithermometer/atc_mithermometer.h | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index f8bbd9d55e..7b5cdcfa20 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -65,12 +65,11 @@ optional ATCMiThermometer::parse_header_(const esp32_ble_tracker::S return {}; } - static uint8_t last_frame_count = 0; - if (last_frame_count == raw[12]) { - ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count); + if (this->last_frame_count_ == raw[12]) { + ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_); return {}; } - last_frame_count = raw[12]; + this->last_frame_count_ = raw[12]; return result; } diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 3dde5f1868..0f472c11b9 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -38,6 +38,8 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT sensor::Sensor *battery_voltage_{nullptr}; sensor::Sensor *signal_strength_{nullptr}; + uint8_t last_frame_count_{0}; + optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); From e2b62bcd00950fbe7b868e1513fdb9376cb5236e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:27:17 +0200 Subject: [PATCH 2139/2214] [zigbee] bump esp-zigbee-sdk to 2.0.3 (#17564) --- esphome/components/zigbee/zigbee_esp32.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 73dcd07029..116dce8cc5 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -274,7 +274,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.2", + ref="2.0.3", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7ad41fa978..60b00d33c7 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.2 + version: 2.0.3 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: From a5583dcba60946492846d0b5c7a64966b2e352aa Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:22:47 +1200 Subject: [PATCH 2140/2214] [tests] Add test_display component to free touchscreen tests from display pins (#17540) --- .../components/gsl3670/test.esp32-s3-idf.yaml | 18 ++-------- tests/components/gt911/common.yaml | 13 +------ tests/components/gt911/test.esp32-idf.yaml | 5 ++- tests/components/gt911/test.esp8266-ard.yaml | 5 ++- tests/components/gt911/test.rp2040-ard.yaml | 5 ++- tests/components/test_display/common.yaml | 13 +++++++ .../components/test_display/__init__.py | 0 .../components/test_display/display.py | 36 +++++++++++++++++++ .../components/test_display/test_display.h | 36 +++++++++++++++++++ .../test_display/test.esp32-idf.yaml | 3 ++ .../test_display/test.esp8266-ard.yaml | 3 ++ .../test_display/test.rp2040-ard.yaml | 3 ++ tests/components/tt21100/common.yaml | 13 +------ tests/components/tt21100/test.esp32-idf.yaml | 5 ++- .../components/tt21100/test.esp8266-ard.yaml | 5 ++- tests/components/tt21100/test.rp2040-ard.yaml | 5 ++- .../common/test_display/test_display.yaml | 26 ++++++++++++++ 17 files changed, 137 insertions(+), 57 deletions(-) create mode 100644 tests/components/test_display/common.yaml create mode 100644 tests/components/test_display/components/test_display/__init__.py create mode 100644 tests/components/test_display/components/test_display/display.py create mode 100644 tests/components/test_display/components/test_display/test_display.h create mode 100644 tests/components/test_display/test.esp32-idf.yaml create mode 100644 tests/components/test_display/test.esp8266-ard.yaml create mode 100644 tests/components/test_display/test.rp2040-ard.yaml create mode 100644 tests/test_build_components/common/test_display/test_display.yaml diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 5c3f4b931c..384e12eaba 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -1,32 +1,20 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml xl9535: id: expander -display: - - platform: mipi_spi - id: gsl3670_display - spi_id: spi_bus - model: t-display-s3-pro - # The model's default DC pin (GPIO9) clashes with the shared i2c bus SCL - # pin, so override it onto a free pin for this test. - dc_pin: GPIO5 - -psram: - mode: quad - touchscreen: # Firmware downloaded from the model's default release URL and cached. - platform: gsl3670 model: seeed-reterminal-d1001 - display: gsl3670_display + display: test_display_screen interrupt_pin: 18 # Explicit firmware URL + SHA-256 override. - platform: gsl3670 model: seeed-reterminal-d1001 - display: gsl3670_display + display: test_display_screen reset_pin: 10 interrupt_pin: 11 firmware: diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index 0fc40737f0..24a67e2e45 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,19 +1,8 @@ -display: - - platform: ssd1306_i2c - i2c_id: i2c_bus - id: gt911_ssd1306_i2c_display - model: SSD1306_128X64 - reset_pin: ${display_reset_pin} - pages: - - id: gt911_page1 - lambda: |- - it.rectangle(0, 0, it.get_width(), it.get_height()); - touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: gt911_ssd1306_i2c_display + display: test_display_screen interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/gt911/test.esp32-idf.yaml b/tests/components/gt911/test.esp32-idf.yaml index 3bce86d9a3..9c2de1a425 100644 --- a/tests/components/gt911/test.esp32-idf.yaml +++ b/tests/components/gt911/test.esp32-idf.yaml @@ -1,9 +1,8 @@ substitutions: - display_reset_pin: "10" interrupt_pin: "20" reset_pin: "21" packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + gt911: !include common.yaml diff --git a/tests/components/gt911/test.esp8266-ard.yaml b/tests/components/gt911/test.esp8266-ard.yaml index c3bc159b5b..59af399be8 100644 --- a/tests/components/gt911/test.esp8266-ard.yaml +++ b/tests/components/gt911/test.esp8266-ard.yaml @@ -1,9 +1,8 @@ substitutions: - display_reset_pin: "10" interrupt_pin: "12" reset_pin: "13" packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + gt911: !include common.yaml diff --git a/tests/components/gt911/test.rp2040-ard.yaml b/tests/components/gt911/test.rp2040-ard.yaml index 0c7f0bc504..efd5d9c2b1 100644 --- a/tests/components/gt911/test.rp2040-ard.yaml +++ b/tests/components/gt911/test.rp2040-ard.yaml @@ -1,9 +1,8 @@ substitutions: - display_reset_pin: "10" interrupt_pin: "20" reset_pin: "21" packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + gt911: !include common.yaml diff --git a/tests/components/test_display/common.yaml b/tests/components/test_display/common.yaml new file mode 100644 index 0000000000..c36cf4b997 --- /dev/null +++ b/tests/components/test_display/common.yaml @@ -0,0 +1,13 @@ +# The test_display platform (and its external_components entry) is provided by +# the shared package included from the test.*.yaml files. These extra instances +# exercise the remaining `dimensions` code paths: the width/height map form and +# the default when omitted. The package's own `test_display_screen` covers the +# "WIDTHxHEIGHT" string form. +display: + - platform: test_display + id: test_display_wh_dimensions + dimensions: + width: 320 + height: 240 + - platform: test_display + id: test_display_default_dimensions diff --git a/tests/components/test_display/components/test_display/__init__.py b/tests/components/test_display/components/test_display/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/components/test_display/components/test_display/display.py b/tests/components/test_display/components/test_display/display.py new file mode 100644 index 0000000000..8503053b46 --- /dev/null +++ b/tests/components/test_display/components/test_display/display.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, CONF_WIDTH +from esphome.core import CoroPriority, coroutine_with_priority + +test_display_ns = cg.esphome_ns.namespace("test_display") +TestDisplay = test_display_ns.class_("TestDisplay", display.Display) + +CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TestDisplay), + cv.Optional(CONF_DIMENSIONS, default="100x100"): cv.Any( + cv.dimensions, + cv.Schema( + { + cv.Required(CONF_WIDTH): cv.int_, + cv.Required(CONF_HEIGHT): cv.int_, + } + ), + ), + } +) + + +@coroutine_with_priority(CoroPriority.CORE) +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await display.register_display(var, config) + + dimensions = config[CONF_DIMENSIONS] + if isinstance(dimensions, dict): + width, height = dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT] + else: + width, height = dimensions + cg.add(var.set_dimensions(width, height)) diff --git a/tests/components/test_display/components/test_display/test_display.h b/tests/components/test_display/components/test_display/test_display.h new file mode 100644 index 0000000000..3f2b03a773 --- /dev/null +++ b/tests/components/test_display/components/test_display/test_display.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include "esphome/core/color.h" + +namespace esphome::test_display { + +/** A no-op display that draws nothing and uses no pins. + * + * It exists purely to satisfy components that require a display (for example + * touchscreens, which read the display dimensions) in configurations - most + * notably YAML build tests - where a real display driver would only get in the + * way by occupying GPIO pins and pulling in bus dependencies. + */ +class TestDisplay : public display::Display { + public: + void update() override { this->do_update_(); } + + void set_dimensions(int width, int height) { + this->width_ = width; + this->height_ = height; + } + + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + + void draw_pixel_at(int x, int y, Color color) override {} + + protected: + int get_width_internal() override { return this->width_; } + int get_height_internal() override { return this->height_; } + + int width_{0}; + int height_{0}; +}; + +} // namespace esphome::test_display diff --git a/tests/components/test_display/test.esp32-idf.yaml b/tests/components/test_display/test.esp32-idf.yaml new file mode 100644 index 0000000000..dfc8006c38 --- /dev/null +++ b/tests/components/test_display/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + test_display_extra: !include common.yaml diff --git a/tests/components/test_display/test.esp8266-ard.yaml b/tests/components/test_display/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dfc8006c38 --- /dev/null +++ b/tests/components/test_display/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + test_display_extra: !include common.yaml diff --git a/tests/components/test_display/test.rp2040-ard.yaml b/tests/components/test_display/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dfc8006c38 --- /dev/null +++ b/tests/components/test_display/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + test_display_extra: !include common.yaml diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 1f9249f1ba..5cb6b99a8e 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,19 +1,8 @@ -display: - - platform: ssd1306_i2c - i2c_id: i2c_bus - id: tt21100_ssd1306_i2c_display - model: SSD1306_128X64 - reset_pin: ${disp_reset_pin} - pages: - - id: tt21100_page1 - lambda: |- - it.rectangle(0, 0, it.get_width(), it.get_height()); - touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: tt21100_ssd1306_i2c_display + display: test_display_screen interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/tt21100/test.esp32-idf.yaml b/tests/components/tt21100/test.esp32-idf.yaml index 033aafb73c..a79695d611 100644 --- a/tests/components/tt21100/test.esp32-idf.yaml +++ b/tests/components/tt21100/test.esp32-idf.yaml @@ -1,9 +1,8 @@ substitutions: - disp_reset_pin: GPIO12 interrupt_pin: GPIO15 reset_pin: GPIO4 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + tt21100: !include common.yaml diff --git a/tests/components/tt21100/test.esp8266-ard.yaml b/tests/components/tt21100/test.esp8266-ard.yaml index 25d1ff82e3..ae6977c6ec 100644 --- a/tests/components/tt21100/test.esp8266-ard.yaml +++ b/tests/components/tt21100/test.esp8266-ard.yaml @@ -1,9 +1,8 @@ substitutions: - disp_reset_pin: GPIO0 interrupt_pin: GPIO15 reset_pin: GPIO16 packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + tt21100: !include common.yaml diff --git a/tests/components/tt21100/test.rp2040-ard.yaml b/tests/components/tt21100/test.rp2040-ard.yaml index 0d13628294..98b2ad600c 100644 --- a/tests/components/tt21100/test.rp2040-ard.yaml +++ b/tests/components/tt21100/test.rp2040-ard.yaml @@ -1,9 +1,8 @@ substitutions: - disp_reset_pin: GPIO10 interrupt_pin: GPIO2 reset_pin: GPIO3 packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + tt21100: !include common.yaml diff --git a/tests/test_build_components/common/test_display/test_display.yaml b/tests/test_build_components/common/test_display/test_display.yaml new file mode 100644 index 0000000000..986ab45223 --- /dev/null +++ b/tests/test_build_components/common/test_display/test_display.yaml @@ -0,0 +1,26 @@ +# Shared "test display" package for component tests. +# +# Provides a no-op display (id: test_display_screen) that uses no pins and no +# bus, so tests that only need a display to exist -- touchscreens especially -- +# don't have to instantiate a real driver and fight it over GPIOs. Include it +# like a common bus package; the consuming test does NOT need to declare +# external_components itself: +# +# packages: +# test_display: !include ../../test_build_components/common/test_display/test_display.yaml +# +# then point the touchscreen (or other display consumer) at `test_display_screen`. +# +# The test_display platform lives at tests/components/test_display/components/ and +# is loaded via external_components. The source path is written relative to the +# build directory (tests/test_build_components/build/), which every test -- +# standalone or grouped -- is generated into, so this always resolves to the +# component under tests/components/test_display/. +external_components: + - source: ../../components/test_display/components + components: [test_display] + +display: + - platform: test_display + id: test_display_screen + dimensions: 240x320 From 4d3d06959ba5cbf892006077562212ce38f7cbbb Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:59:19 -1000 Subject: [PATCH 2141/2214] Bump bundled esphome-device-builder to 1.6.0 (#17573) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 01ff53a463..e310d766b5 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.5.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.0 RUN \ platformio settings set enable_telemetry No \ From b295b8d5a2895deda98cabc2373ab8cd52e119eb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:11:47 +1200 Subject: [PATCH 2142/2214] [api] Warn when Home Assistant actions are sent with no subscribed client (#17560) --- esphome/components/api/api_connection.h | 8 +- esphome/components/api/api_server.cpp | 10 ++- ...pi_homeassistant_action_no_subscriber.yaml | 30 +++++++ ..._api_homeassistant_action_no_subscriber.py | 79 +++++++++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml create mode 100644 tests/integration/test_api_homeassistant_action_no_subscriber.py diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 144973fa9d..7df7ea1429 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -166,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase { #endif bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len); #ifdef USE_API_HOMEASSISTANT_SERVICES - void send_homeassistant_action(const HomeassistantActionRequest &call) { + // 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; + return false; this->send_message(call); + return true; } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void on_homeassistant_action_response(const HomeassistantActionResponse &msg); diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1062dfeb39..6e3448121c 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -426,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { + bool has_subscriber = false; for (auto &client : this->active_clients()) { - client->send_homeassistant_action(call); + has_subscriber |= client->send_homeassistant_action(call); + } + if (!has_subscriber) { + // Home Assistant subscribes to actions shortly *after* authenticating, so actions + // fired right at connection time (on_client_connected, on_time_sync, ...) can + // arrive before the subscription and are lost - warn instead of failing silently. + ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(), + this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected"); } } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES diff --git a/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml b/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml new file mode 100644 index 0000000000..26791e2cd1 --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml @@ -0,0 +1,30 @@ +esphome: + name: test-ha-action-no-subscriber + friendly_name: Home Assistant Action No Subscriber Test + on_boot: + # Fires before any client is connected - dropped with a warning. + - homeassistant.action: + action: test.boot_action + +host: + +api: + on_client_connected: + # Fires at authentication time, before the client has subscribed to + # Home Assistant actions - dropped with a warning. + - homeassistant.action: + action: test.connected_action + +logger: + level: DEBUG + +button: + - platform: template + name: Send Action Button + id: send_action_button + on_press: + # Pressed only after the client has subscribed - must be delivered. + - homeassistant.action: + action: test.button_action + data: + value: subscribed diff --git a/tests/integration/test_api_homeassistant_action_no_subscriber.py b/tests/integration/test_api_homeassistant_action_no_subscriber.py new file mode 100644 index 0000000000..9e7594e0ce --- /dev/null +++ b/tests/integration/test_api_homeassistant_action_no_subscriber.py @@ -0,0 +1,79 @@ +"""Integration test for Home Assistant actions fired without a subscriber. + +Home Assistant subscribes to device actions shortly after authenticating, while +on_client_connected (and similar triggers) fire right at authentication. Actions +fired before any client has subscribed cannot be delivered - they must produce a +warning in the log instead of vanishing silently. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, HomeassistantServiceCall +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_homeassistant_action_no_subscriber( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Undeliverable actions warn in the log; actions after subscribing arrive.""" + loop = asyncio.get_running_loop() + + boot_warning_future = loop.create_future() + connected_warning_future = loop.create_future() + button_action_future = loop.create_future() + + def check_output(line: str) -> None: + if ( + not boot_warning_future.done() + and "Home Assistant action 'test.boot_action' dropped; no client connected" + in line + ): + boot_warning_future.set_result(True) + if ( + not connected_warning_future.done() + and "Home Assistant action 'test.connected_action' dropped; " + "client has not subscribed to actions (yet)" + in line + ): + connected_warning_future.set_result(True) + + service_calls: list[HomeassistantServiceCall] = [] + + def on_service_call(service_call: HomeassistantServiceCall) -> None: + service_calls.append(service_call) + if ( + service_call.service == "test.button_action" + and not button_action_future.done() + ): + button_action_future.set_result(service_call) + + async with run_compiled(yaml_config, line_callback=check_output): + # The on_boot action fires with no client connected at all. + await asyncio.wait_for(boot_warning_future, timeout=10.0) + + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info.name == "test-ha-action-no-subscriber" + + # on_client_connected fired at authentication, before this client + # subscribed to Home Assistant actions. + await asyncio.wait_for(connected_warning_future, timeout=5.0) + + # After subscribing, actions must be delivered normally (and the + # dropped ones must not suddenly show up). + client.subscribe_service_calls(on_service_call) + + entities, _ = await client.list_entities_services() + button = next(e for e in entities if isinstance(e, ButtonInfo)) + client.button_command(button.key) + + button_call = await asyncio.wait_for(button_action_future, timeout=5.0) + assert button_call.data == {"value": "subscribed"} + assert [call.service for call in service_calls] == ["test.button_action"] From 053ce39fc6f0e01d84791027663783c73cf5dab7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:51:50 -0400 Subject: [PATCH 2143/2214] Bump bundled esphome-device-builder to 1.6.1 (#17575) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e310d766b5..84fd658594 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.6.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.1 RUN \ platformio settings set enable_telemetry No \ From 54987d7d23ae5ce92b2bd210c5d98241ff1c5bf0 Mon Sep 17 00:00:00 2001 From: Hajo Noerenberg Date: Wed, 15 Jul 2026 18:38:08 +0200 Subject: [PATCH 2144/2214] [cc1101] Export CC1101Listener to Python (#17576) --- esphome/components/cc1101/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 0feb384ac2..cafd894c54 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -21,6 +21,7 @@ MULTI_CONF = True ns = cg.esphome_ns.namespace("cc1101") CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice) +CC1101Listener = ns.class_("CC1101Listener") # Config keys CONF_RX_ATTENUATION = "rx_attenuation" From a3ab7961e062527d24156c89cb67d03782676d3d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:14:55 +1200 Subject: [PATCH 2145/2214] [core] Fix wait_until crash when re-entered from its own continuation (#17571) --- esphome/core/base_automation.h | 51 ++++++++--- .../wait_until_reentrant_restart.yaml | 86 ++++++++++++++++++ .../test_wait_until_reentrant_restart.py | 89 +++++++++++++++++++ 3 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 tests/integration/fixtures/wait_until_reentrant_restart.yaml create mode 100644 tests/integration/test_wait_until_reentrant_restart.py diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index cf8b05a300..38e52e44cb 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -502,6 +502,9 @@ template class WaitUntilAction : public Action, public Co void stop() override { this->var_queue_.clear(); + // Tell any process_queue_() call further down the stack that the items it is + // still holding were cancelled + this->stop_generation_++; this->disable_loop(); } @@ -511,33 +514,57 @@ template class WaitUntilAction : public Action, public Co } protected: + using QueueItem = std::tuple, std::tuple>; + // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items bool process_queue_(uint32_t now) { - // Process each queued wait_until and remove completed ones - this->var_queue_.remove_if([&](auto &queued) { - auto start = std::get(queued); - auto timeout = std::get>(queued); - auto &var = std::get>(queued); + // Completed items run the rest of the action chain synchronously, and that chain + // can re-enter this same action (e.g. a script with mode: restart that executes + // itself) and add to or clear var_queue_. Iterating the member list directly would + // then corrupt it, so move it aside and iterate a local list instead. + std::list queue; + queue.swap(this->var_queue_); + std::list pending; + while (!queue.empty()) { + auto it = queue.begin(); + auto start = std::get(*it); + auto timeout = std::get>(*it); // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(var)) { - return false; + if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + pending.splice(pending.end(), queue, it); + continue; } - // Condition met or timed out - trigger next action - this->play_next_tuple_(var); - return true; - }); + // Condition met or timed out - trigger the next action. Keep the item in a local + // holder so its arguments stay valid while the chain runs, without any nested + // process_queue_() call being able to see (and fire) it again. + std::list completed; + completed.splice(completed.begin(), queue, it); + uint8_t generation = this->stop_generation_; + this->play_next_tuple_(std::get>(completed.front())); + if (generation != this->stop_generation_) { + // stop() ran inside the chain - the items still held locally were cancelled + pending.clear(); + break; + } + } + + // Re-entrant continuations may have enqueued new waits into var_queue_; put the + // older still-waiting items back in front of them to keep FIFO firing order + this->var_queue_.splice(this->var_queue_.begin(), pending); return !this->var_queue_.empty(); } Condition *condition_; - std::list, std::tuple>> var_queue_{}; + std::list var_queue_{}; + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + uint8_t stop_generation_{0}; }; template class UpdateComponentAction : public Action { diff --git a/tests/integration/fixtures/wait_until_reentrant_restart.yaml b/tests/integration/fixtures/wait_until_reentrant_restart.yaml new file mode 100644 index 0000000000..337d0de837 --- /dev/null +++ b/tests/integration/fixtures/wait_until_reentrant_restart.yaml @@ -0,0 +1,86 @@ +esphome: + name: wait-until-reentrant-restart + +host: + +api: + actions: + - action: start_self_restart + then: + - script.execute: retry_script + - action: start_stop_during_wait + then: + - globals.set: + id: gate_open + value: 'false' + # num 0 is a blocker: its condition never becomes true, so it is still + # waiting (already checked and set aside) when num 1 stops the script - + # it must be cancelled, not restored, so its timeout must never fire + - script.execute: + id: waiter + num: 0 + - script.execute: + id: waiter + num: 1 + - script.execute: + id: waiter + num: 2 + - script.execute: + id: waiter + num: 3 + # Give all three instances time to queue in the same wait_until + - delay: 100ms + - globals.set: + id: gate_open + value: 'true' + - delay: 200ms + - logger.log: "stop test complete" + +logger: + level: DEBUG + +globals: + - id: attempt + type: int + initial_value: '0' + - id: gate_open + type: bool + initial_value: 'false' + +script: + # Self-restart retry pattern: when the wait_until times out, the rest of the + # script runs synchronously from inside the wait queue processing and restarts + # this same script - re-entering the same WaitUntilAction while it is still + # processing its queue. This used to corrupt the queue and crash. + - id: retry_script + mode: restart + then: + - wait_until: + condition: + lambda: 'return false;' + timeout: 20ms + - lambda: |- + id(attempt) += 1; + ESP_LOGD("test", "attempt %d done", id(attempt)); + - if: + condition: + lambda: 'return id(attempt) < 5;' + then: + - script.execute: retry_script + else: + - logger.log: "retry test complete" + + # Parallel waiters all queued in the same wait_until; the first one to pass the + # gate stops the script from its continuation, cancelling the other waiters + # while the queue is still being processed. + - id: waiter + mode: parallel + parameters: + num: int + then: + - wait_until: + condition: + lambda: 'return num != 0 && id(gate_open);' + timeout: 1s + - lambda: 'ESP_LOGD("test", "gate passed %d", num);' + - script.stop: waiter diff --git a/tests/integration/test_wait_until_reentrant_restart.py b/tests/integration/test_wait_until_reentrant_restart.py new file mode 100644 index 0000000000..9c73339515 --- /dev/null +++ b/tests/integration/test_wait_until_reentrant_restart.py @@ -0,0 +1,89 @@ +"""Integration test for wait_until queue reentrancy. + +When a wait_until completes, the rest of the action chain runs synchronously +from inside the wait queue processing. That chain can re-enter the very same +WaitUntilAction - for example a script with mode: restart that executes itself +as a retry pattern, or a waiter that stops its own script. Both used to mutate +the std::list while it was being iterated, corrupting it and crashing the +device (Guru Meditation StoreProhibited in _M_transfer). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wait_until_reentrant_restart( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that re-entering a wait_until from its own continuation is safe.""" + retry_complete = asyncio.Event() + stop_complete = asyncio.Event() + + attempt_pattern = re.compile(r"attempt (\d+) done") + gate_pattern = re.compile(r"gate passed (\d+)") + + attempts: list[int] = [] + gate_passed: list[int] = [] + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if mo := attempt_pattern.search(line): + attempts.append(int(mo.group(1))) + elif mo := gate_pattern.search(line): + gate_passed.append(int(mo.group(1))) + elif "retry test complete" in line: + retry_complete.set() + elif "stop test complete" in line: + stop_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "wait-until-reentrant-restart" + + _, services = await client.list_entities_services() + self_restart_service = next( + (s for s in services if s.name == "start_self_restart"), None + ) + assert self_restart_service is not None, "start_self_restart not found" + stop_service = next( + (s for s in services if s.name == "start_stop_during_wait"), None + ) + assert stop_service is not None, "start_stop_during_wait not found" + + # Scenario 1: the wait_until timeout continuation restarts its own + # script five times, re-entering the same wait_until each time. + await client.execute_service(self_restart_service, {}) + try: + await asyncio.wait_for(retry_complete.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Self-restart retry did not finish. Attempts: {attempts}") + assert attempts == [1, 2, 3, 4, 5], attempts + + # Scenario 2: the first waiter through the gate stops the script while + # the other waiters are still queued in the same wait_until; both the + # not-yet-checked waiters (2, 3) and the already-checked still-waiting + # blocker (0) must be cancelled, not fired. + await client.execute_service(stop_service, {}) + try: + await asyncio.wait_for(stop_complete.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Stop-during-wait did not finish. Gate passed: {gate_passed}") + assert gate_passed == [1], gate_passed + + # If the cancelled blocker had been kept, its 1s wait_until timeout + # would still fire - give it the chance and check it stays silent. + await asyncio.sleep(1.5) + assert gate_passed == [1], gate_passed From 26db22546d4daa073ba17b28b144481ffc34025e Mon Sep 17 00:00:00 2001 From: Daniele Palumbo Date: Tue, 14 Jul 2026 04:38:07 +0200 Subject: [PATCH 2146/2214] [mcp23017] reset IPOL registers to 0x00 on setup (#17177) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/mcp23017/mcp23017.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index 9e3d75575a..173d117457 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -19,6 +19,10 @@ void MCP23017::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_); this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); + // Reset IPOL to 0x00: ESPHome handles 'inverted' in software. + this->write_reg(mcp23x17_base::MCP23X17_IPOLA, 0x00); + this->write_reg(mcp23x17_base::MCP23X17_IPOLB, 0x00); + uint8_t iocon_flags = 0; if (this->open_drain_ints_) { iocon_flags |= IOCON_ODR; From 724df7b11ec8e86048a2ba68082df6bcd1b8e79e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:23:37 -0400 Subject: [PATCH 2147/2214] [atc_mithermometer] Make duplicate-packet counter per-instance (#17496) --- esphome/components/atc_mithermometer/atc_mithermometer.cpp | 7 +++---- esphome/components/atc_mithermometer/atc_mithermometer.h | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index f8bbd9d55e..7b5cdcfa20 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -65,12 +65,11 @@ optional ATCMiThermometer::parse_header_(const esp32_ble_tracker::S return {}; } - static uint8_t last_frame_count = 0; - if (last_frame_count == raw[12]) { - ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count); + if (this->last_frame_count_ == raw[12]) { + ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_); return {}; } - last_frame_count = raw[12]; + this->last_frame_count_ = raw[12]; return result; } diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 3dde5f1868..0f472c11b9 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -38,6 +38,8 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT sensor::Sensor *battery_voltage_{nullptr}; sensor::Sensor *signal_strength_{nullptr}; + uint8_t last_frame_count_{0}; + optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); From b5b426492aee046a93f5bd96853bdd6b66210943 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:27:17 +0200 Subject: [PATCH 2148/2214] [zigbee] bump esp-zigbee-sdk to 2.0.3 (#17564) --- esphome/components/zigbee/zigbee_esp32.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 73dcd07029..116dce8cc5 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -274,7 +274,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.2", + ref="2.0.3", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7ad41fa978..60b00d33c7 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.2 + version: 2.0.3 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: From d1150d148d60b841c5676b6f6b09bd8c472f0902 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:59:19 -1000 Subject: [PATCH 2149/2214] Bump bundled esphome-device-builder to 1.6.0 (#17573) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 01ff53a463..e310d766b5 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.5.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.0 RUN \ platformio settings set enable_telemetry No \ From 3b915cf16bb5b721be22096d28b6b0eb5a70d961 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:11:47 +1200 Subject: [PATCH 2150/2214] [api] Warn when Home Assistant actions are sent with no subscribed client (#17560) --- esphome/components/api/api_connection.h | 8 +- esphome/components/api/api_server.cpp | 10 ++- ...pi_homeassistant_action_no_subscriber.yaml | 30 +++++++ ..._api_homeassistant_action_no_subscriber.py | 79 +++++++++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml create mode 100644 tests/integration/test_api_homeassistant_action_no_subscriber.py diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 144973fa9d..7df7ea1429 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -166,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase { #endif bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len); #ifdef USE_API_HOMEASSISTANT_SERVICES - void send_homeassistant_action(const HomeassistantActionRequest &call) { + // 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; + return false; this->send_message(call); + return true; } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void on_homeassistant_action_response(const HomeassistantActionResponse &msg); diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1062dfeb39..6e3448121c 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -426,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { + bool has_subscriber = false; for (auto &client : this->active_clients()) { - client->send_homeassistant_action(call); + has_subscriber |= client->send_homeassistant_action(call); + } + if (!has_subscriber) { + // Home Assistant subscribes to actions shortly *after* authenticating, so actions + // fired right at connection time (on_client_connected, on_time_sync, ...) can + // arrive before the subscription and are lost - warn instead of failing silently. + ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(), + this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected"); } } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES diff --git a/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml b/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml new file mode 100644 index 0000000000..26791e2cd1 --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml @@ -0,0 +1,30 @@ +esphome: + name: test-ha-action-no-subscriber + friendly_name: Home Assistant Action No Subscriber Test + on_boot: + # Fires before any client is connected - dropped with a warning. + - homeassistant.action: + action: test.boot_action + +host: + +api: + on_client_connected: + # Fires at authentication time, before the client has subscribed to + # Home Assistant actions - dropped with a warning. + - homeassistant.action: + action: test.connected_action + +logger: + level: DEBUG + +button: + - platform: template + name: Send Action Button + id: send_action_button + on_press: + # Pressed only after the client has subscribed - must be delivered. + - homeassistant.action: + action: test.button_action + data: + value: subscribed diff --git a/tests/integration/test_api_homeassistant_action_no_subscriber.py b/tests/integration/test_api_homeassistant_action_no_subscriber.py new file mode 100644 index 0000000000..9e7594e0ce --- /dev/null +++ b/tests/integration/test_api_homeassistant_action_no_subscriber.py @@ -0,0 +1,79 @@ +"""Integration test for Home Assistant actions fired without a subscriber. + +Home Assistant subscribes to device actions shortly after authenticating, while +on_client_connected (and similar triggers) fire right at authentication. Actions +fired before any client has subscribed cannot be delivered - they must produce a +warning in the log instead of vanishing silently. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, HomeassistantServiceCall +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_homeassistant_action_no_subscriber( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Undeliverable actions warn in the log; actions after subscribing arrive.""" + loop = asyncio.get_running_loop() + + boot_warning_future = loop.create_future() + connected_warning_future = loop.create_future() + button_action_future = loop.create_future() + + def check_output(line: str) -> None: + if ( + not boot_warning_future.done() + and "Home Assistant action 'test.boot_action' dropped; no client connected" + in line + ): + boot_warning_future.set_result(True) + if ( + not connected_warning_future.done() + and "Home Assistant action 'test.connected_action' dropped; " + "client has not subscribed to actions (yet)" + in line + ): + connected_warning_future.set_result(True) + + service_calls: list[HomeassistantServiceCall] = [] + + def on_service_call(service_call: HomeassistantServiceCall) -> None: + service_calls.append(service_call) + if ( + service_call.service == "test.button_action" + and not button_action_future.done() + ): + button_action_future.set_result(service_call) + + async with run_compiled(yaml_config, line_callback=check_output): + # The on_boot action fires with no client connected at all. + await asyncio.wait_for(boot_warning_future, timeout=10.0) + + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info.name == "test-ha-action-no-subscriber" + + # on_client_connected fired at authentication, before this client + # subscribed to Home Assistant actions. + await asyncio.wait_for(connected_warning_future, timeout=5.0) + + # After subscribing, actions must be delivered normally (and the + # dropped ones must not suddenly show up). + client.subscribe_service_calls(on_service_call) + + entities, _ = await client.list_entities_services() + button = next(e for e in entities if isinstance(e, ButtonInfo)) + client.button_command(button.key) + + button_call = await asyncio.wait_for(button_action_future, timeout=5.0) + assert button_call.data == {"value": "subscribed"} + assert [call.service for call in service_calls] == ["test.button_action"] From e056e99fd459ee56ac645447bec699e6cc18edbe Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:51:50 -0400 Subject: [PATCH 2151/2214] Bump bundled esphome-device-builder to 1.6.1 (#17575) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e310d766b5..84fd658594 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.6.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.1 RUN \ platformio settings set enable_telemetry No \ From 878d8a2f6a404b81271705616fc1c7e96cce31ec Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:14:55 +1200 Subject: [PATCH 2152/2214] [core] Fix wait_until crash when re-entered from its own continuation (#17571) --- esphome/core/base_automation.h | 51 ++++++++--- .../wait_until_reentrant_restart.yaml | 86 ++++++++++++++++++ .../test_wait_until_reentrant_restart.py | 89 +++++++++++++++++++ 3 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 tests/integration/fixtures/wait_until_reentrant_restart.yaml create mode 100644 tests/integration/test_wait_until_reentrant_restart.py diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index cf8b05a300..38e52e44cb 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -502,6 +502,9 @@ template class WaitUntilAction : public Action, public Co void stop() override { this->var_queue_.clear(); + // Tell any process_queue_() call further down the stack that the items it is + // still holding were cancelled + this->stop_generation_++; this->disable_loop(); } @@ -511,33 +514,57 @@ template class WaitUntilAction : public Action, public Co } protected: + using QueueItem = std::tuple, std::tuple>; + // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items bool process_queue_(uint32_t now) { - // Process each queued wait_until and remove completed ones - this->var_queue_.remove_if([&](auto &queued) { - auto start = std::get(queued); - auto timeout = std::get>(queued); - auto &var = std::get>(queued); + // Completed items run the rest of the action chain synchronously, and that chain + // can re-enter this same action (e.g. a script with mode: restart that executes + // itself) and add to or clear var_queue_. Iterating the member list directly would + // then corrupt it, so move it aside and iterate a local list instead. + std::list queue; + queue.swap(this->var_queue_); + std::list pending; + while (!queue.empty()) { + auto it = queue.begin(); + auto start = std::get(*it); + auto timeout = std::get>(*it); // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(var)) { - return false; + if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + pending.splice(pending.end(), queue, it); + continue; } - // Condition met or timed out - trigger next action - this->play_next_tuple_(var); - return true; - }); + // Condition met or timed out - trigger the next action. Keep the item in a local + // holder so its arguments stay valid while the chain runs, without any nested + // process_queue_() call being able to see (and fire) it again. + std::list completed; + completed.splice(completed.begin(), queue, it); + uint8_t generation = this->stop_generation_; + this->play_next_tuple_(std::get>(completed.front())); + if (generation != this->stop_generation_) { + // stop() ran inside the chain - the items still held locally were cancelled + pending.clear(); + break; + } + } + + // Re-entrant continuations may have enqueued new waits into var_queue_; put the + // older still-waiting items back in front of them to keep FIFO firing order + this->var_queue_.splice(this->var_queue_.begin(), pending); return !this->var_queue_.empty(); } Condition *condition_; - std::list, std::tuple>> var_queue_{}; + std::list var_queue_{}; + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + uint8_t stop_generation_{0}; }; template class UpdateComponentAction : public Action { diff --git a/tests/integration/fixtures/wait_until_reentrant_restart.yaml b/tests/integration/fixtures/wait_until_reentrant_restart.yaml new file mode 100644 index 0000000000..337d0de837 --- /dev/null +++ b/tests/integration/fixtures/wait_until_reentrant_restart.yaml @@ -0,0 +1,86 @@ +esphome: + name: wait-until-reentrant-restart + +host: + +api: + actions: + - action: start_self_restart + then: + - script.execute: retry_script + - action: start_stop_during_wait + then: + - globals.set: + id: gate_open + value: 'false' + # num 0 is a blocker: its condition never becomes true, so it is still + # waiting (already checked and set aside) when num 1 stops the script - + # it must be cancelled, not restored, so its timeout must never fire + - script.execute: + id: waiter + num: 0 + - script.execute: + id: waiter + num: 1 + - script.execute: + id: waiter + num: 2 + - script.execute: + id: waiter + num: 3 + # Give all three instances time to queue in the same wait_until + - delay: 100ms + - globals.set: + id: gate_open + value: 'true' + - delay: 200ms + - logger.log: "stop test complete" + +logger: + level: DEBUG + +globals: + - id: attempt + type: int + initial_value: '0' + - id: gate_open + type: bool + initial_value: 'false' + +script: + # Self-restart retry pattern: when the wait_until times out, the rest of the + # script runs synchronously from inside the wait queue processing and restarts + # this same script - re-entering the same WaitUntilAction while it is still + # processing its queue. This used to corrupt the queue and crash. + - id: retry_script + mode: restart + then: + - wait_until: + condition: + lambda: 'return false;' + timeout: 20ms + - lambda: |- + id(attempt) += 1; + ESP_LOGD("test", "attempt %d done", id(attempt)); + - if: + condition: + lambda: 'return id(attempt) < 5;' + then: + - script.execute: retry_script + else: + - logger.log: "retry test complete" + + # Parallel waiters all queued in the same wait_until; the first one to pass the + # gate stops the script from its continuation, cancelling the other waiters + # while the queue is still being processed. + - id: waiter + mode: parallel + parameters: + num: int + then: + - wait_until: + condition: + lambda: 'return num != 0 && id(gate_open);' + timeout: 1s + - lambda: 'ESP_LOGD("test", "gate passed %d", num);' + - script.stop: waiter diff --git a/tests/integration/test_wait_until_reentrant_restart.py b/tests/integration/test_wait_until_reentrant_restart.py new file mode 100644 index 0000000000..9c73339515 --- /dev/null +++ b/tests/integration/test_wait_until_reentrant_restart.py @@ -0,0 +1,89 @@ +"""Integration test for wait_until queue reentrancy. + +When a wait_until completes, the rest of the action chain runs synchronously +from inside the wait queue processing. That chain can re-enter the very same +WaitUntilAction - for example a script with mode: restart that executes itself +as a retry pattern, or a waiter that stops its own script. Both used to mutate +the std::list while it was being iterated, corrupting it and crashing the +device (Guru Meditation StoreProhibited in _M_transfer). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wait_until_reentrant_restart( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that re-entering a wait_until from its own continuation is safe.""" + retry_complete = asyncio.Event() + stop_complete = asyncio.Event() + + attempt_pattern = re.compile(r"attempt (\d+) done") + gate_pattern = re.compile(r"gate passed (\d+)") + + attempts: list[int] = [] + gate_passed: list[int] = [] + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if mo := attempt_pattern.search(line): + attempts.append(int(mo.group(1))) + elif mo := gate_pattern.search(line): + gate_passed.append(int(mo.group(1))) + elif "retry test complete" in line: + retry_complete.set() + elif "stop test complete" in line: + stop_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "wait-until-reentrant-restart" + + _, services = await client.list_entities_services() + self_restart_service = next( + (s for s in services if s.name == "start_self_restart"), None + ) + assert self_restart_service is not None, "start_self_restart not found" + stop_service = next( + (s for s in services if s.name == "start_stop_during_wait"), None + ) + assert stop_service is not None, "start_stop_during_wait not found" + + # Scenario 1: the wait_until timeout continuation restarts its own + # script five times, re-entering the same wait_until each time. + await client.execute_service(self_restart_service, {}) + try: + await asyncio.wait_for(retry_complete.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Self-restart retry did not finish. Attempts: {attempts}") + assert attempts == [1, 2, 3, 4, 5], attempts + + # Scenario 2: the first waiter through the gate stops the script while + # the other waiters are still queued in the same wait_until; both the + # not-yet-checked waiters (2, 3) and the already-checked still-waiting + # blocker (0) must be cancelled, not fired. + await client.execute_service(stop_service, {}) + try: + await asyncio.wait_for(stop_complete.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Stop-during-wait did not finish. Gate passed: {gate_passed}") + assert gate_passed == [1], gate_passed + + # If the cancelled blocker had been kept, its 1s wait_until timeout + # would still fire - give it the chance and check it stays silent. + await asyncio.sleep(1.5) + assert gate_passed == [1], gate_passed From 63fae2c36a14b5dc042fd1ffa5fc773c52aa5cf2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:23:57 +1200 Subject: [PATCH 2153/2214] Bump version to 2026.7.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3bd4dc140f..8896ab5d18 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0b3 +PROJECT_NUMBER = 2026.7.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 01ff67e3f2..2e41e8b131 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0b3" +__version__ = "2026.7.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6c401d406fd345260b6620a95011076924579bf7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:52:34 +1200 Subject: [PATCH 2154/2214] Bump version to 2026.7.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 8896ab5d18..46f96b459a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0b4 +PROJECT_NUMBER = 2026.7.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 2e41e8b131..0b0d3c2e4a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0b4" +__version__ = "2026.7.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 5d2372937e87b99264b51f7be2a1aa41cec2fcdf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:29:23 -0400 Subject: [PATCH 2155/2214] [ci] Stop per-PR cache copies from crowding the 10GB Actions cache quota (#17463) --- .github/actions/restore-python/action.yml | 3 ++ .github/workflows/ci-api-proto.yml | 3 ++ .github/workflows/ci.yml | 65 ++++++++++++++++++++--- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 9d78b2d843..9d6dc5301c 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -35,6 +35,9 @@ runs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index ebbe720463..58fc83e3f5 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -32,6 +32,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull-request-only workflow: a save could never be shared and + # would only consume quota. + save-cache: "false" # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e93b6ece8..adf98478fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -174,6 +177,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -375,6 +381,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -828,11 +837,12 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 - with: - packages: libsdl2-dev ccache - version: 1.1 + - 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: Check out code from GitHub uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -1006,6 +1016,36 @@ jobs: # Arduino framework via PlatformIO (only components with an esp32-ard test are built): python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio + pre-commit-seed-cache: + name: Seed pre-commit cache + runs-on: ubuntu-latest + needs: + - common + # Saves a dev-scoped pre-commit cache that pull request runs can + # restore, since pre-commit.ci lite itself never runs on dev pushes. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + steps: + - name: Check out code from GitHub + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache pre-commit environments + id: cache-pre-commit + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the restore key in pre-commit-ci-lite + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Install pre-commit hook environments + if: steps.cache-pre-commit.outputs.cache-hit != 'true' + run: | + python -m pip install pre-commit + pre-commit install-hooks + pre-commit-ci-lite: name: pre-commit.ci lite runs-on: ubuntu-latest @@ -1021,9 +1061,22 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache + # Inlined from esphome/pre-commit-action with a restore-only cache + # step: the pre-commit-seed-cache job owns saving this cache, so + # pull request runs never write per-PR copies. + - name: Restore pre-commit cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the key pre-commit-seed-cache saves + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Run pre-commit env: SKIP: pylint,ci-custom + run: | + python -m pip install pre-commit + pre-commit run --show-diff-on-failure --color=always --all-files - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() From d53d4c5b58aa381aa004f80262ab4ff6579e54c1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:16:24 -0400 Subject: [PATCH 2156/2214] [emc2101] Fix negative external temperatures reported as large positives (#17494) --- esphome/components/emc2101/emc2101.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/emc2101/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index 464f49fe51..f46082f5e7 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -145,9 +145,9 @@ float Emc2101Component::get_external_temperature() { return NAN; } - // join msb and lsb (5 least significant bits are not used) - uint16_t raw = (msb << 8 | lsb) >> 5; - return raw * 0.125; + // join msb and lsb (5 least significant bits are not used); msb is signed, so read as int16_t + int16_t raw = static_cast((msb << 8) | lsb) >> 5; + return raw * 0.125f; } float Emc2101Component::get_speed() { From e700b0140601a16ed40d2dcfafc81b068ea403ba Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:16:38 -0400 Subject: [PATCH 2157/2214] [haier] Fix outdoor defrost temperature reporting the coil temperature (#17492) --- esphome/components/haier/hon_climate.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index f68404afd9..88d446829a 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -825,7 +825,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * #ifdef USE_SENSOR this->update_sub_sensor_(SubSensorType::INDOOR_COIL_TEMPERATURE, bd_packet->indoor_coil_temperature / 2.0 - 20); this->update_sub_sensor_(SubSensorType::OUTDOOR_COIL_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64); - this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64); + this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_defrost_temperature - 64); this->update_sub_sensor_(SubSensorType::OUTDOOR_IN_AIR_TEMPERATURE, bd_packet->outdoor_in_air_temperature - 64); this->update_sub_sensor_(SubSensorType::OUTDOOR_OUT_AIR_TEMPERATURE, bd_packet->outdoor_out_air_temperature - 64); this->update_sub_sensor_(SubSensorType::POWER, encode_uint16(bd_packet->power[0], bd_packet->power[1])); From 5a3c2f4d11a7be933e24ed67c6a6618578398941 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:17:21 -0400 Subject: [PATCH 2158/2214] [ci] Add rp2 clang-tidy environment (#17486) --- .github/workflows/ci.yml | 4 ++ esphome/components/debug/debug_rp2.cpp | 2 +- .../components/ethernet/ethernet_component.h | 2 + .../ethernet/ethernet_component_rp2.cpp | 13 ++-- .../components/fastled_base/fastled_light.cpp | 2 +- .../components/fastled_base/fastled_light.h | 2 +- esphome/components/midea/ac_adapter.cpp | 2 +- esphome/components/midea/ac_adapter.h | 2 +- esphome/components/midea/ac_automations.h | 2 +- esphome/components/midea/air_conditioner.cpp | 2 +- esphome/components/midea/air_conditioner.h | 2 +- esphome/components/midea/appliance_base.h | 2 +- esphome/components/midea/climate.py | 14 ++++ esphome/components/midea/ir_transmitter.h | 2 +- esphome/components/rp2/core.h | 1 + esphome/components/rp2/crash_handler.cpp | 2 +- esphome/components/rp2/hal.cpp | 3 +- esphome/components/rp2/hal.h | 8 +-- esphome/components/rp2/preferences.cpp | 9 +-- esphome/components/rp2/printf_stubs.cpp | 4 +- esphome/components/rp2040_ble/rp2040_ble.cpp | 6 +- esphome/components/rp2040_ble/rp2040_ble.h | 2 +- .../rp2040_pio_led_strip/led_strip.cpp | 47 +++++------- .../rp2040_pio_led_strip/led_strip.h | 12 ++-- esphome/components/wifi/wifi_component.h | 2 +- .../components/wifi/wifi_component_pico_w.cpp | 22 +++--- esphome/components/wireguard/__init__.py | 71 ++++++++++++------- esphome/components/wled/wled_light_effect.h | 7 ++ esphome/core/defines.h | 9 ++- esphome/core/wake/wake_rp2.cpp | 4 +- platformio.ini | 22 ++++++ script/clang-tidy | 59 ++++++++++----- 32 files changed, 214 insertions(+), 129 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e98999741..6066d0ea03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -504,6 +504,10 @@ jobs: options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 cache_sdk_nrf: true ignore_errors: false + - id: clang-tidy + name: Run script/clang-tidy for RP2 + options: --environment rp2-tidy --grep USE_RP2 + pio_cache_key: tidyrp2 steps: - name: Check out code from GitHub diff --git a/esphome/components/debug/debug_rp2.cpp b/esphome/components/debug/debug_rp2.cpp index ba6081963f..336e9c7e06 100644 --- a/esphome/components/debug/debug_rp2.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -74,7 +74,7 @@ size_t DebugComponent::get_device_info_(std::span constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - uint32_t cpu_freq = ::rp2040.f_cpu(); + uint32_t cpu_freq = RP2040::f_cpu(); ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq); pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq); diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 7160351727..9f4398c621 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -112,8 +112,10 @@ enum class EthernetComponentState : uint8_t { // Platform-neutral duplex/speed types #ifndef USE_ESP32 +// NOLINTBEGIN(readability-identifier-naming) enum eth_duplex_t { ETH_DUPLEX_HALF, ETH_DUPLEX_FULL }; enum eth_speed_t { ETH_SPEED_10M, ETH_SPEED_100M }; +// NOLINTEND(readability-identifier-naming) #endif class EthernetComponent final : public Component { diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index d2e3f14e02..4d6d6c4f5b 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -187,17 +187,18 @@ void EthernetComponent::loop() { } void EthernetComponent::dump_config() { - const char *type_str = "Unknown"; #if defined(USE_ETHERNET_W5500) - type_str = "W5500"; + const char *type_str = "W5500"; #elif defined(USE_ETHERNET_W5100) - type_str = "W5100"; + const char *type_str = "W5100"; #elif defined(USE_ETHERNET_W6100) - type_str = "W6100"; + const char *type_str = "W6100"; #elif defined(USE_ETHERNET_W6300) - type_str = "W6300"; + const char *type_str = "W6300"; #elif defined(USE_ETHERNET_ENC28J60) - type_str = "ENC28J60"; + const char *type_str = "ENC28J60"; +#else + const char *type_str = "Unknown"; #endif #if defined(USE_ETHERNET_W6300) // W6300 uses PIO QSPI with hardcoded pins — SPI pin fields are not used diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 0fa69a23b4..af6e5720ec 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #include "fastled_light.h" #include "esphome/core/log.h" diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 1261b742a1..0459777f40 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #include "esphome/core/component.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 2f4ef5c948..3611b20715 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #include "esphome/core/log.h" #include "ac_adapter.h" diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index a7924ae51e..53959efe2a 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) // MideaUART #include diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h index acd9191916..9c35e191b5 100644 --- a/esphome/components/midea/ac_automations.h +++ b/esphome/components/midea/ac_automations.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #include "esphome/core/automation.h" #include "air_conditioner.h" diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 7603dd5254..a743e867af 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index bea6c2eadb..cd04c87890 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) // MideaUART #include diff --git a/esphome/components/midea/appliance_base.h b/esphome/components/midea/appliance_base.h index d36f5a322c..d9486564c0 100644 --- a/esphome/components/midea/appliance_base.h +++ b/esphome/components/midea/appliance_base.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) // MideaUART #include diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 4a75464b90..aedb517f89 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -25,6 +25,11 @@ from esphome.const import ( ICON_POWER, ICON_THERMOMETER, ICON_WATER_PERCENT, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, UNIT_PERCENT, @@ -152,6 +157,15 @@ CONFIG_SCHEMA = cv.All( .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), cv.only_with_arduino, + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + ] + ), ) # Actions diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index f11682230d..43a2e2f261 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #ifdef USE_REMOTE_TRANSMITTER #include "esphome/components/remote_base/midea_protocol.h" diff --git a/esphome/components/rp2/core.h b/esphome/components/rp2/core.h index c53c3719eb..4ce9151d41 100644 --- a/esphome/components/rp2/core.h +++ b/esphome/components/rp2/core.h @@ -5,6 +5,7 @@ #include #include +// NOLINTNEXTLINE(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) extern "C" unsigned long ulMainGetRunTimeCounterValue(); namespace esphome::rp2 {} // namespace esphome::rp2 diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index 5553a24a60..a0fea21637 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -64,7 +64,7 @@ static struct CrashData { uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} s_crash_data __attribute__((section(".noinit"))); +} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) bool crash_handler_has_data() { return s_crash_data.valid; } diff --git a/esphome/components/rp2/hal.cpp b/esphome/components/rp2/hal.cpp index 28535cacbb..8eb1b469bc 100644 --- a/esphome/components/rp2/hal.cpp +++ b/esphome/components/rp2/hal.cpp @@ -20,8 +20,7 @@ namespace esphome { // arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2/hal.h. void arch_restart() { watchdog_reboot(0, 0, 10); - while (1) { - continue; + while (true) { } } diff --git a/esphome/components/rp2/hal.h b/esphome/components/rp2/hal.h index b16f31d797..ec46937bab 100644 --- a/esphome/components/rp2/hal.h +++ b/esphome/components/rp2/hal.h @@ -17,13 +17,13 @@ extern "C" unsigned long micros(void); extern "C" unsigned long millis(void); // NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) -// Forward decl from . +// Forward decls from and the pico-sdk / FreeRTOS port for the +// inline arch_* wrappers below. +// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) extern "C" uint64_t time_us_64(void); - -// Forward decls from pico-sdk / FreeRTOS port for the inline arch_* -// wrappers below. extern "C" void watchdog_update(void); extern "C" unsigned long ulMainGetRunTimeCounterValue(void); +// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) namespace esphome::rp2 {} diff --git a/esphome/components/rp2/preferences.cpp b/esphome/components/rp2/preferences.cpp index 778ce070a9..d1e0bc555f 100644 --- a/esphome/components/rp2/preferences.cpp +++ b/esphome/components/rp2/preferences.cpp @@ -26,6 +26,7 @@ static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avo // No preference can exceed the total flash storage, so stack buffer covers all cases. static constexpr size_t PREF_MAX_BUFFER_SIZE = RP2040_FLASH_STORAGE_SIZE; +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) extern "C" uint8_t _EEPROM_start; template uint8_t calculate_crc(It first, It last, uint32_t type) { @@ -38,9 +39,9 @@ template uint8_t calculate_crc(It first, It last, uint32_t type) { } bool RP2PreferenceBackend::save(const uint8_t *data, size_t len) { - const size_t buffer_size = len + 1; - if (buffer_size > PREF_MAX_BUFFER_SIZE) + if (len >= PREF_MAX_BUFFER_SIZE) return false; + const size_t buffer_size = len + 1; uint8_t buffer[PREF_MAX_BUFFER_SIZE]; memcpy(buffer, data, len); buffer[len] = calculate_crc(buffer, buffer + len, this->type); @@ -59,9 +60,9 @@ bool RP2PreferenceBackend::save(const uint8_t *data, size_t len) { } bool RP2PreferenceBackend::load(uint8_t *data, size_t len) { - const size_t buffer_size = len + 1; - if (buffer_size > PREF_MAX_BUFFER_SIZE) + if (len >= PREF_MAX_BUFFER_SIZE) return false; + const size_t buffer_size = len + 1; uint8_t buffer[PREF_MAX_BUFFER_SIZE]; for (size_t i = 0; i < buffer_size; i++) { diff --git a/esphome/components/rp2/printf_stubs.cpp b/esphome/components/rp2/printf_stubs.cpp index bf03565f30..47cf30b263 100644 --- a/esphome/components/rp2/printf_stubs.cpp +++ b/esphome/components/rp2/printf_stubs.cpp @@ -33,8 +33,8 @@ static int write_printf_buffer(FILE *stream, char *buf, int len) { if (write_len >= PRINTF_BUFFER_SIZE) { fwrite(buf, 1, PRINTF_BUFFER_SIZE - 1, stream); // Use fwrite for the message to avoid recursive __wrap_printf call - static const char msg[] = "\nprintf buffer overflow\n"; - fwrite(msg, 1, sizeof(msg) - 1, stream); + static const char MSG[] = "\nprintf buffer overflow\n"; + fwrite(MSG, 1, sizeof(MSG) - 1, stream); abort(); } if (fwrite(buf, 1, write_len, stream) < write_len || ferror(stream)) { diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 4125da7ec0..dca0cd4653 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -35,10 +35,10 @@ void RP2040BLE::enable() { l2cap_init(); sm_init(); - this->hci_event_callback_registration_.callback = &RP2040BLE::packet_handler_; + this->hci_event_callback_registration_.callback = &RP2040BLE::packet_handler; hci_add_event_handler(&this->hci_event_callback_registration_); - this->sm_event_callback_registration_.callback = &RP2040BLE::packet_handler_; + this->sm_event_callback_registration_.callback = &RP2040BLE::packet_handler; sm_add_event_handler(&this->sm_event_callback_registration_); this->btstack_initialized_ = true; @@ -95,7 +95,7 @@ void RP2040BLE::dump_config() { float RP2040BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } -void RP2040BLE::packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { +void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { if (global_ble == nullptr) { return; } diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 885e49f690..e9df12cfb1 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -32,7 +32,7 @@ class RP2040BLE final : public Component { void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } protected: - static void packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); btstack_packet_callback_registration_t hci_event_callback_registration_{}; btstack_packet_callback_registration_t sm_event_callback_registration_{}; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index b9c0a9c257..cf7041931e 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -14,26 +14,15 @@ namespace esphome::rp2040_pio_led_strip { -static const char *TAG = "rp2040_pio_led_strip"; - -static uint8_t num_instance_[2] = {0, 0}; -static std::map chipset_offsets_ = { - {CHIPSET_WS2812, 0}, {CHIPSET_WS2812B, 0}, {CHIPSET_SK6812, 0}, {CHIPSET_SM16703, 0}, {CHIPSET_CUSTOM, 0}, -}; -static std::map conf_count_ = { - {CHIPSET_WS2812, false}, {CHIPSET_WS2812B, false}, {CHIPSET_SK6812, false}, - {CHIPSET_SM16703, false}, {CHIPSET_CUSTOM, false}, -}; -static bool dma_chan_active_[12]; -static struct semaphore dma_write_complete_sem_[12]; +static const char *const TAG = "rp2040_pio_led_strip"; // DMA interrupt service routine -void RP2040PIOLEDStripLightOutput::dma_write_complete_handler_() { +void RP2040PIOLEDStripLightOutput::dma_write_complete_handler() { uint32_t channel = dma_hw->ints0; for (uint dma_chan = 0; dma_chan < 12; ++dma_chan) { - if (RP2040PIOLEDStripLightOutput::dma_chan_active_[dma_chan] && (channel & (1u << dma_chan))) { - dma_hw->ints0 = (1u << dma_chan); // Clear the interrupt - sem_release(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem_[dma_chan]); // Handle the interrupt + if (RP2040PIOLEDStripLightOutput::dma_chan_active[dma_chan] && (channel & (1u << dma_chan))) { + dma_hw->ints0 = (1u << dma_chan); // Clear the interrupt + sem_release(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[dma_chan]); // Handle the interrupt } } } @@ -69,22 +58,22 @@ void RP2040PIOLEDStripLightOutput::setup() { // but there are only 4 state machines on each PIO so we can only have 4 strips per PIO uint offset = 0; - if (RP2040PIOLEDStripLightOutput::num_instance_[this->pio_ == pio0 ? 0 : 1] >= 4) { + if (RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1] >= 4) { ESP_LOGE(TAG, "Too many instances of PIO program"); this->mark_failed(); return; } // keep track of how many instances of the PIO program are running on each PIO - RP2040PIOLEDStripLightOutput::num_instance_[this->pio_ == pio0 ? 0 : 1]++; + RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1]++; // if there are multiple strips of the same chipset, we can reuse the same PIO program and save space - if (this->conf_count_[this->chipset_]) { - offset = RP2040PIOLEDStripLightOutput::chipset_offsets_[this->chipset_]; + if (RP2040PIOLEDStripLightOutput::conf_count[this->chipset_]) { + offset = RP2040PIOLEDStripLightOutput::chipset_offsets[this->chipset_]; } else { // Load the assembled program into the PIO and get its location in the PIO's instruction memory and save it offset = pio_add_program(this->pio_, this->program_); - RP2040PIOLEDStripLightOutput::chipset_offsets_[this->chipset_] = offset; - RP2040PIOLEDStripLightOutput::conf_count_[this->chipset_] = true; + RP2040PIOLEDStripLightOutput::chipset_offsets[this->chipset_] = offset; + RP2040PIOLEDStripLightOutput::conf_count[this->chipset_] = true; } // Configure the state machine's PIO, and start it @@ -106,7 +95,7 @@ void RP2040PIOLEDStripLightOutput::setup() { } // Mark the DMA channel as active - RP2040PIOLEDStripLightOutput::dma_chan_active_[this->dma_chan_] = true; + RP2040PIOLEDStripLightOutput::dma_chan_active[this->dma_chan_] = true; this->dma_config_ = dma_channel_get_default_config(this->dma_chan_); channel_config_set_transfer_data_size( @@ -125,11 +114,11 @@ void RP2040PIOLEDStripLightOutput::setup() { ); // Initialize the semaphore for this DMA channel - sem_init(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem_[this->dma_chan_], 1, 1); + sem_init(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[this->dma_chan_], 1, 1); - irq_set_exclusive_handler(DMA_IRQ_0, dma_write_complete_handler_); // after DMA all data, raise an interrupt - dma_channel_set_irq0_enabled(this->dma_chan_, true); // map DMA channel to interrupt - irq_set_enabled(DMA_IRQ_0, true); // enable interrupt + irq_set_exclusive_handler(DMA_IRQ_0, dma_write_complete_handler); // after DMA all data, raise an interrupt + dma_channel_set_irq0_enabled(this->dma_chan_, true); // map DMA channel to interrupt + irq_set_enabled(DMA_IRQ_0, true); // enable interrupt this->init_(this->pio_, this->sm_, offset, this->pin_, this->max_refresh_rate_); } @@ -148,12 +137,12 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { } // the bits are already in the correct order for the pio program so we can just copy the buffer using DMA - sem_acquire_blocking(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem_[this->dma_chan_]); + sem_acquire_blocking(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[this->dma_chan_]); dma_channel_transfer_from_buffer_now(this->dma_chan_, this->buf_, this->get_buffer_size_()); } light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0, w = 0; + int32_t r = 0, g = 0, b = 0; switch (this->rgb_order_) { case ORDER_RGB: r = 0; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index b74dd14108..c499f0a7ca 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -95,7 +95,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); } - static void dma_write_complete_handler_(); + static void dma_write_complete_handler(); uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -119,11 +119,11 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { init_fn init_; private: - inline static int num_instance_[2]; - inline static std::map conf_count_; - inline static std::map chipset_offsets_; - inline static bool dma_chan_active_[12]; - inline static struct semaphore dma_write_complete_sem_[12]; + inline static int num_instance[2]; + inline static std::map conf_count; + inline static std::map chipset_offsets; + inline static bool dma_chan_active[12]; + inline static struct semaphore dma_write_complete_sem[12]; }; } // namespace esphome::rp2040_pio_led_strip diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 23b7558564..6faabc223c 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -819,7 +819,7 @@ class WiFiComponent final : public Component { #ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); - void wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); + void wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result); #endif #ifdef USE_LIBRETINY diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1a70f81a2b..69ac90822f 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -109,10 +109,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // setup depends on begin() succeeding. beginNoBlock() skips the outer wait loop, saving // up to 20 additional seconds of blocking per attempt. auto ret = WiFi.beginNoBlock(ap.ssid_.c_str(), ap.password_.c_str()); - if (ret == WL_IDLE_STATUS) - return false; - - return true; + return ret != WL_IDLE_STATUS; } bool WiFiComponent::wifi_sta_pre_setup_() { return this->wifi_mode_(true, {}); } @@ -169,11 +166,11 @@ WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { } int WiFiComponent::s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) { - global_wifi_component->wifi_scan_result(env, result); + global_wifi_component->wifi_scan_result_(env, result); return 0; } -void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) { +void WiFiComponent::wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result) { s_scan_result_count++; // CYW43 scan results have ssid as a 32-byte buffer that is NOT null-terminated. @@ -282,7 +279,7 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { // Filter out AP interface addresses — addrList includes all lwIP netifs. // The AP netif IP lingers even after the AP radio is disabled. IPAddress ap_ip = WiFi.softAPIP(); - for (auto addr : addrList) { + for (const auto &addr : addrList) { IPAddress ip(addr.ipFromNetifNum()); if (ip == ap_ip) { continue; @@ -351,12 +348,11 @@ bool WiFiComponent::wifi_loop_() { // Detect IP address changes (only when connected) if (is_connected) { - bool has_ip = false; - // Check for any IP address (IPv4 or IPv6) - for (auto addr : addrList) { - has_ip = true; - break; - } + // Check for any IP address (IPv4 or IPv6). The iterator comparison + // operators take non-const references, so the temporaries need names. + auto addr_it = addrList.begin(); + auto addr_end = addrList.end(); + bool has_ip = addr_it != addr_end; if (has_ip && !s_sta_had_ip) { // Just got IP address diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index e128b8476d..ff98cfc966 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -6,7 +6,17 @@ import esphome.codegen as cg from esphome.components import time from esphome.components.esp32 import CORE, add_idf_sdkconfig_option import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID, CONF_REBOOT_TIMEOUT, CONF_TIME_ID +from esphome.const import ( + CONF_ADDRESS, + CONF_ID, + CONF_REBOOT_TIMEOUT, + CONF_TIME_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, +) from esphome.core import TimePeriod CONF_NETMASK = "netmask" @@ -57,30 +67,41 @@ def _cidr_network(value): return value -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(Wireguard), - cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), - cv.Required(CONF_ADDRESS): cv.ipv4address, - cv.Optional(CONF_NETMASK, default="255.255.255.255"): cv.ipv4address, - cv.Required(CONF_PRIVATE_KEY): _wireguard_key, - cv.Required(CONF_PEER_ENDPOINT): cv.string, - cv.Required(CONF_PEER_PUBLIC_KEY): _wireguard_key, - cv.Optional(CONF_PEER_PORT, default=51820): cv.port, - cv.Optional(CONF_PEER_PRESHARED_KEY): _wireguard_key, - cv.Optional(CONF_PEER_ALLOWED_IPS, default=["0.0.0.0/0"]): cv.ensure_list( - _cidr_network - ), - cv.Optional(CONF_PEER_PERSISTENT_KEEPALIVE, default="0s"): cv.All( - cv.positive_time_period_seconds, - cv.Range(max=TimePeriod(seconds=65535)), - ), - cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_REQUIRE_CONNECTION_TO_PROCEED, default=False): cv.boolean, - } -).extend(cv.polling_component_schema("10s")) +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Wireguard), + cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Required(CONF_ADDRESS): cv.ipv4address, + cv.Optional(CONF_NETMASK, default="255.255.255.255"): cv.ipv4address, + cv.Required(CONF_PRIVATE_KEY): _wireguard_key, + cv.Required(CONF_PEER_ENDPOINT): cv.string, + cv.Required(CONF_PEER_PUBLIC_KEY): _wireguard_key, + cv.Optional(CONF_PEER_PORT, default=51820): cv.port, + cv.Optional(CONF_PEER_PRESHARED_KEY): _wireguard_key, + cv.Optional(CONF_PEER_ALLOWED_IPS, default=["0.0.0.0/0"]): cv.ensure_list( + _cidr_network + ), + cv.Optional(CONF_PEER_PERSISTENT_KEEPALIVE, default="0s"): cv.All( + cv.positive_time_period_seconds, + cv.Range(max=TimePeriod(seconds=65535)), + ), + cv.Optional( + CONF_REBOOT_TIMEOUT, default="15min" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_REQUIRE_CONNECTION_TO_PROCEED, default=False): cv.boolean, + } + ).extend(cv.polling_component_schema("10s")), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + ] + ), +) async def to_code(config): diff --git a/esphome/components/wled/wled_light_effect.h b/esphome/components/wled/wled_light_effect.h index bed897f5a6..085303e6c0 100644 --- a/esphome/components/wled/wled_light_effect.h +++ b/esphome/components/wled/wled_light_effect.h @@ -8,7 +8,14 @@ #include #include +#ifdef USE_RP2 +namespace arduino { class UDP; +} // namespace arduino +using arduino::UDP; // NOLINT(google-global-names-in-headers) +#else +class UDP; +#endif namespace esphome::wled { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5c5fc5e8b9..1ecc3dc4a8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -205,8 +205,11 @@ #define MAX_API_CONNECTIONS 6 #define USE_MD5 #define USE_SHA256 +#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 #define USE_MQTT #define USE_MQTT_COVER_JSON +#define USE_WIREGUARD +#endif #define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG @@ -219,7 +222,6 @@ #define USE_WIFI #define USE_WIFI_AP #define USE_WIFI_MANUAL_IP -#define USE_WIREGUARD #endif // Arduino-specific feature flags @@ -432,6 +434,11 @@ #ifndef USE_ETHERNET_SPI #define USE_ETHERNET_SPI #endif +#define USE_ETHERNET_W5500 +#define USE_WIFI_IP_STATE_LISTENERS +#define ESPHOME_WIFI_IP_STATE_LISTENERS 2 +#define USE_ETHERNET_IP_STATE_LISTENERS +#define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 #endif #ifdef USE_LIBRETINY diff --git a/esphome/core/wake/wake_rp2.cpp b/esphome/core/wake/wake_rp2.cpp index 101c87c818..ac1deba726 100644 --- a/esphome/core/wake/wake_rp2.cpp +++ b/esphome/core/wake/wake_rp2.cpp @@ -20,7 +20,7 @@ volatile bool g_main_loop_woke = false; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static volatile bool s_delay_expired = false; -static int64_t alarm_callback_(alarm_id_t id, void *user_data) { +static int64_t alarm_callback(alarm_id_t id, void *user_data) { (void) id; (void) user_data; s_delay_expired = true; @@ -43,7 +43,7 @@ void wakeable_delay(uint32_t ms) { return; } s_delay_expired = false; - alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback, nullptr, true); if (alarm <= 0) { delay(ms); return; diff --git a/platformio.ini b/platformio.ini index 061e92a64a..7e8494aea6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -214,8 +214,19 @@ lib_deps = ${common:idf-component-libs.lib_deps} ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base + WiFi ; wifi (arduino-pico built-in) + lwIP_CYW43 ; wifi (arduino-pico built-in, WiFi dependency) + HTTPClient ; http_request (arduino-pico built-in) + Updater ; ota (arduino-pico built-in) + MD5Builder ; md5 (arduino-pico built-in) + LEAmDNS ; mdns (arduino-pico built-in) + lwIP_w5500 ; ethernet (arduino-pico built-in) + lwIP-Ethernet ; ethernet (arduino-pico built-in, lwIP_w5500/lwIP_CYW43 dependency) + WebServer ; web_server_base (arduino-pico built-in, ESPAsyncWebServer dependency) + http-parser ; web_server_base (arduino-pico built-in, ESPAsyncWebServer dependency) build_flags = ${common:arduino.build_flags} + -DUSE_RP2 -DUSE_RP2040 -DUSE_RP2040_FRAMEWORK_ARDUINO build_unflags = @@ -510,6 +521,17 @@ build_flags = build_unflags = ${common.build_unflags} +[env:rp2-tidy] +extends = common:rp2040-arduino +; The W variant so the cyw43 / WiFi library paths are part of the idedata. +board = rpipicow +build_flags = + ${common:rp2040-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DPIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH +build_unflags = + ${common.build_unflags} + ;;;;;;;; LibreTiny ;;;;;;;; [env:bk72xx-arduino] diff --git a/script/clang-tidy b/script/clang-tidy index 7df46cb2d2..f463e2455d 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -29,7 +29,7 @@ from helpers import ( ) -def clang_options(idedata): +def clang_options(idedata, environment): cmd = [] # extract target architecture from triplet in g++ filename @@ -95,30 +95,42 @@ def clang_options(idedata): [ # disable built-in include directories from the host "-nostdinc", - # replace pgmspace.h, as it uses GNU extensions clang doesn't support - # https://github.com/earlephilhower/newlib-xtensa/pull/18 - "-D_PGMSPACE_H_", - "-Dpgm_read_byte(s)=(*(const uint8_t *)(s))", - "-Dpgm_read_byte_near(s)=(*(const uint8_t *)(s))", - "-Dpgm_read_word(s)=(*(const uint16_t *)(s))", - "-Dpgm_read_dword(s)=(*(const uint32_t *)(s))", - "-Dpgm_read_ptr(s)=(*(const void *const *)(s))", - "-DPROGMEM=", - "-DPGM_P=const char *", - "-DPSTR(s)=(s)", - # this next one is also needed with upstream pgmspace.h - # suppress warning about identifier naming in expansion of this macro - "-DPSTRN(s, n)=(s)", - # suppress warning about attribute cannot be applied to type - # https://github.com/esp8266/Arduino/pull/8258 - "-Ddeprecated(x)=", # allow to condition code on the presence of clang-tidy "-DCLANG_TIDY", # (esp-idf) Fix __once_callable in some libstdc++ headers "-D_GLIBCXX_HAVE_TLS", + # suppress warning about attribute cannot be applied to type + # https://github.com/esp8266/Arduino/pull/8258 + # also keeps deprecation diagnostics consistent across environments + "-Ddeprecated(x)=", ] ) + if environment.startswith("rp2"): + # clang's ARM backend doesn't know GCC's long_call attribute (IRAM_ATTR) + cmd.append("-Wno-unknown-attributes") + else: + # replace pgmspace.h, as it uses GNU extensions clang doesn't support + # https://github.com/earlephilhower/newlib-xtensa/pull/18 + # arduino-pico ships clang-parseable pgmspace inline functions, so the + # replacements are skipped there (they clash with those definitions). + cmd.extend( + [ + "-D_PGMSPACE_H_", + "-Dpgm_read_byte(s)=(*(const uint8_t *)(s))", + "-Dpgm_read_byte_near(s)=(*(const uint8_t *)(s))", + "-Dpgm_read_word(s)=(*(const uint16_t *)(s))", + "-Dpgm_read_dword(s)=(*(const uint32_t *)(s))", + "-Dpgm_read_ptr(s)=(*(const void *const *)(s))", + "-DPROGMEM=", + "-DPGM_P=const char *", + "-DPSTR(s)=(s)", + # this next one is also needed with upstream pgmspace.h + # suppress warning about identifier naming in expansion of this macro + "-DPSTRN(s, n)=(s)", + ] + ) + # Copy compiler flags, dropping: ones clang doesn't understand; -Werror* # (clang-tidy enforces .clang-tidy's WarningsAsErrors, and a build -Werror # would bypass the -clang-diagnostic-* suppressions); and -std= (the native @@ -207,6 +219,15 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): if sys.stdout.isatty(): invocation.append("--use-color") + if args.environment.startswith("rp2"): + # MMIO peripheral access on bare-metal RP2 is all fixed-address. + # bugprone-pointer-arithmetic-on-polymorphic-object (and its + # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher + # recursion on lvgl_esphome.h under the RP2 defines. + invocation.append( + "--checks=-clang-analyzer-core.FixedAddressDereference," + "-bugprone-pointer-arithmetic-on-polymorphic-object,-cert-ctr56-cpp" + ) invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") invocation.append(str(Path(path).resolve())) invocation.append("--") @@ -351,7 +372,7 @@ def main(): # Load idedata and options only if we have files to check idedata = load_idedata(args.environment) - options = clang_options(idedata) + options = clang_options(idedata, args.environment) tmpdir = None if args.fix: From c4975e1870a97dec693f5f398de96b659a25b8b0 Mon Sep 17 00:00:00 2001 From: Hajo Noerenberg Date: Thu, 16 Jul 2026 03:17:39 +0200 Subject: [PATCH 2159/2214] [cc1101] Add FOCCFG and BSCFG config options (#17577) --- esphome/components/cc1101/__init__.py | 76 ++++++++++++++++++++++++++ esphome/components/cc1101/cc1101.cpp | 63 +++++++++++++++++++++ esphome/components/cc1101/cc1101.h | 11 ++++ esphome/components/cc1101/cc1101defs.h | 50 +++++++++++++++++ tests/components/cc1101/common.yaml | 9 +++ 5 files changed, 209 insertions(+) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index cafd894c54..01e3ed0cd5 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -49,6 +49,15 @@ CONF_FILTER_LENGTH_FSK_MSK = "filter_length_fsk_msk" CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook" CONF_FREEZE = "freeze" CONF_HYST_LEVEL = "hyst_level" +CONF_FOC_BS_CS_GATE = "foc_bs_cs_gate" +CONF_FOC_LIMIT = "foc_limit" +CONF_FOC_PRE_K = "foc_pre_k" +CONF_FOC_POST_K = "foc_post_k" +CONF_BS_LIMIT = "bs_limit" +CONF_BS_PRE_KI = "bs_pre_ki" +CONF_BS_PRE_KP = "bs_pre_kp" +CONF_BS_POST_KI = "bs_post_ki" +CONF_BS_POST_KP = "bs_post_kp" # Packet mode config keys CONF_PACKET_MODE = "packet_mode" @@ -162,6 +171,64 @@ HYST_LEVEL = { "High": HystLevel.HYST_LEVEL_HIGH, } +FocLimit = ns.enum("FocLimit", True) +FOC_LIMIT = { + "Disabled": FocLimit.FOC_LIMIT_DISABLED, + "BW/8": FocLimit.FOC_LIMIT_BW_8, + "BW/4": FocLimit.FOC_LIMIT_BW_4, + "BW/2": FocLimit.FOC_LIMIT_BW_2, +} + +FocPreK = ns.enum("FocPreK", True) +FOC_PRE_K = { + "K": FocPreK.FOC_PRE_K_K, + "2K": FocPreK.FOC_PRE_K_2K, + "3K": FocPreK.FOC_PRE_K_3K, + "4K": FocPreK.FOC_PRE_K_4K, +} + +FocPostK = ns.enum("FocPostK", True) +FOC_POST_K = { + "Same": FocPostK.FOC_POST_K_SAME, + "K/2": FocPostK.FOC_POST_K_K_2, +} + +BsLimit = ns.enum("BsLimit", True) +BS_LIMIT = { + "Disabled": BsLimit.BS_LIMIT_DISABLED, + "3.125%": BsLimit.BS_LIMIT_3P125_PERCENT, + "6.25%": BsLimit.BS_LIMIT_6P25_PERCENT, + "12.5%": BsLimit.BS_LIMIT_12P5_PERCENT, +} + +BsPreKi = ns.enum("BsPreKi", True) +BS_PRE_KI = { + "KI": BsPreKi.BS_PRE_KI_KI, + "2KI": BsPreKi.BS_PRE_KI_2KI, + "3KI": BsPreKi.BS_PRE_KI_3KI, + "4KI": BsPreKi.BS_PRE_KI_4KI, +} + +BsPreKp = ns.enum("BsPreKp", True) +BS_PRE_KP = { + "KP": BsPreKp.BS_PRE_KP_KP, + "2KP": BsPreKp.BS_PRE_KP_2KP, + "3KP": BsPreKp.BS_PRE_KP_3KP, + "4KP": BsPreKp.BS_PRE_KP_4KP, +} + +BsPostKi = ns.enum("BsPostKi", True) +BS_POST_KI = { + "Same": BsPostKi.BS_POST_KI_SAME, + "KI/2": BsPostKi.BS_POST_KI_KI_2, +} + +BsPostKp = ns.enum("BsPostKp", True) +BS_POST_KP = { + "Same": BsPostKp.BS_POST_KP_SAME, + "KP": BsPostKp.BS_POST_KP_KP, +} + # Optional settings to generate setter calls for CONFIG_MAP = { cv.Optional(CONF_OUTPUT_POWER, default=10): cv.float_range(min=-30.0, max=11.0), @@ -215,6 +282,15 @@ CONFIG_MAP = { cv.Optional(CONF_FREEZE): cv.enum(FREEZE, upper=False), cv.Optional(CONF_WAIT_TIME, default="32"): cv.enum(WAIT_TIME, upper=False), cv.Optional(CONF_HYST_LEVEL): cv.enum(HYST_LEVEL, upper=False), + cv.Optional(CONF_FOC_BS_CS_GATE): cv.boolean, + cv.Optional(CONF_FOC_LIMIT): cv.enum(FOC_LIMIT, upper=False), + cv.Optional(CONF_FOC_PRE_K): cv.enum(FOC_PRE_K, upper=False), + cv.Optional(CONF_FOC_POST_K): cv.enum(FOC_POST_K, upper=False), + cv.Optional(CONF_BS_LIMIT): cv.enum(BS_LIMIT, upper=False), + cv.Optional(CONF_BS_PRE_KI): cv.enum(BS_PRE_KI, upper=False), + cv.Optional(CONF_BS_PRE_KP): cv.enum(BS_PRE_KP, upper=False), + cv.Optional(CONF_BS_POST_KI): cv.enum(BS_POST_KI, upper=False), + cv.Optional(CONF_BS_POST_KP): cv.enum(BS_POST_KP, upper=False), cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean, cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t, cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index ea0138e1dd..f7b90b91cf 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -672,6 +672,69 @@ void CC1101Component::set_hyst_level(HystLevel value) { } } +void CC1101Component::set_foc_bs_cs_gate(bool value) { + this->state_.FOC_BS_CS_GATE = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_limit(FocLimit value) { + this->state_.FOC_LIMIT = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_pre_k(FocPreK value) { + this->state_.FOC_PRE_K = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_post_k(FocPostK value) { + this->state_.FOC_POST_K = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_bs_limit(BsLimit value) { + this->state_.BS_LIMIT = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_pre_ki(BsPreKi value) { + this->state_.BS_PRE_KI = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_pre_kp(BsPreKp value) { + this->state_.BS_PRE_KP = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_post_ki(BsPostKi value) { + this->state_.BS_POST_KI = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_post_kp(BsPostKp value) { + this->state_.BS_POST_KP = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + void CC1101Component::set_packet_mode(bool value) { this->state_.PKT_FORMAT = static_cast(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL); diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 065ffd5250..79bfc9cb33 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -71,6 +71,17 @@ class CC1101Component final : public Component, void set_wait_time(WaitTime value); void set_hyst_level(HystLevel value); + // Frequency offset compensation and bit synchronization settings + void set_foc_bs_cs_gate(bool value); + void set_foc_limit(FocLimit value); + void set_foc_pre_k(FocPreK value); + void set_foc_post_k(FocPostK value); + void set_bs_limit(BsLimit value); + void set_bs_pre_ki(BsPreKi value); + void set_bs_pre_kp(BsPreKp value); + void set_bs_post_ki(BsPostKi value); + void set_bs_post_kp(BsPostKp value); + // Packet mode settings void set_packet_mode(bool value); void set_packet_length(uint8_t value); diff --git a/esphome/components/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h index 59b29f7478..6748f4369a 100644 --- a/esphome/components/cc1101/cc1101defs.h +++ b/esphome/components/cc1101/cc1101defs.h @@ -231,6 +231,56 @@ enum class HystLevel : uint8_t { HYST_LEVEL_HIGH, }; +enum class FocLimit : uint8_t { + FOC_LIMIT_DISABLED, + FOC_LIMIT_BW_8, + FOC_LIMIT_BW_4, + FOC_LIMIT_BW_2, +}; + +enum class FocPreK : uint8_t { + FOC_PRE_K_K, + FOC_PRE_K_2K, + FOC_PRE_K_3K, + FOC_PRE_K_4K, +}; + +enum class FocPostK : uint8_t { + FOC_POST_K_SAME, + FOC_POST_K_K_2, +}; + +enum class BsLimit : uint8_t { + BS_LIMIT_DISABLED, + BS_LIMIT_3P125_PERCENT, + BS_LIMIT_6P25_PERCENT, + BS_LIMIT_12P5_PERCENT, +}; + +enum class BsPreKi : uint8_t { + BS_PRE_KI_KI, + BS_PRE_KI_2KI, + BS_PRE_KI_3KI, + BS_PRE_KI_4KI, +}; + +enum class BsPreKp : uint8_t { + BS_PRE_KP_KP, + BS_PRE_KP_2KP, + BS_PRE_KP_3KP, + BS_PRE_KP_4KP, +}; + +enum class BsPostKi : uint8_t { + BS_POST_KI_SAME, + BS_POST_KI_KI_2, +}; + +enum class BsPostKp : uint8_t { + BS_POST_KP_SAME, + BS_POST_KP_KP, +}; + enum class PacketFormat : uint8_t { PACKET_FORMAT_FIFO, PACKET_FORMAT_SYNC_SERIAL, diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml index 9784bfce8b..4d2411e021 100644 --- a/tests/components/cc1101/common.yaml +++ b/tests/components/cc1101/common.yaml @@ -17,6 +17,15 @@ cc1101: sync0: 0x91 sync1: 0xD3 num_preamble: 2 + foc_bs_cs_gate: true + foc_pre_k: "2K" + foc_post_k: "K/2" + foc_limit: "BW/4" + bs_pre_ki: "3KI" + bs_pre_kp: "4KP" + bs_post_ki: "KI/2" + bs_post_kp: "KP" + bs_limit: "12.5%" on_packet: then: - lambda: |- From dedca344f980d098442a05084773edb9b36d935a Mon Sep 17 00:00:00 2001 From: Jas Strong Date: Wed, 15 Jul 2026 18:17:58 -0700 Subject: [PATCH 2160/2214] [aqi] Add extended_range option for over-range AQI values (#17570) Co-authored-by: jas Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/aqi/__init__.py | 1 + .../components/aqi/abstract_aqi_calculator.h | 2 +- esphome/components/aqi/aqi_calculator.h | 30 ++++--- esphome/components/aqi/aqi_sensor.cpp | 3 +- esphome/components/aqi/aqi_sensor.h | 2 + esphome/components/aqi/caqi_calculator.h | 23 ++--- esphome/components/aqi/sensor.py | 20 ++++- esphome/components/hm3301/hm3301.cpp | 2 +- tests/component_tests/aqi/__init__.py | 0 tests/component_tests/aqi/test_aqi.py | 35 ++++++++ tests/components/aqi/benchmark.yaml | 16 ++++ tests/components/aqi/common.yaml | 7 ++ tests/components/aqi/test_aqi_calculator.cpp | 85 +++++++++++++++++++ 13 files changed, 200 insertions(+), 26 deletions(-) create mode 100644 tests/component_tests/aqi/__init__.py create mode 100644 tests/component_tests/aqi/test_aqi.py create mode 100644 tests/components/aqi/benchmark.yaml create mode 100644 tests/components/aqi/test_aqi_calculator.cpp diff --git a/esphome/components/aqi/__init__.py b/esphome/components/aqi/__init__.py index 4b979ab406..17d434294a 100644 --- a/esphome/components/aqi/__init__.py +++ b/esphome/components/aqi/__init__.py @@ -7,6 +7,7 @@ AQICalculatorType = aqi_ns.enum("AQICalculatorType") CONF_AQI = "aqi" CONF_CALCULATION_TYPE = "calculation_type" +CONF_EXTENDED_RANGE = "extended_range" AQI_CALCULATION_TYPE = { "CAQI": AQICalculatorType.CAQI_TYPE, diff --git a/esphome/components/aqi/abstract_aqi_calculator.h b/esphome/components/aqi/abstract_aqi_calculator.h index 299962fa17..6b4c9c5e04 100644 --- a/esphome/components/aqi/abstract_aqi_calculator.h +++ b/esphome/components/aqi/abstract_aqi_calculator.h @@ -6,7 +6,7 @@ namespace esphome::aqi { class AbstractAQICalculator { public: - virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value) = 0; + virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) = 0; }; } // namespace esphome::aqi diff --git a/esphome/components/aqi/aqi_calculator.h b/esphome/components/aqi/aqi_calculator.h index bb8e402280..56b6069118 100644 --- a/esphome/components/aqi/aqi_calculator.h +++ b/esphome/components/aqi/aqi_calculator.h @@ -11,10 +11,12 @@ namespace esphome::aqi { class AQICalculator : public AbstractAQICalculator { public: - uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override { - float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); - float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); + uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) override { + float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID, extended_range); + float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID, extended_range); float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f}); + // extended_range lets the index run past the standard maximum, so clamp to the sensor's range. + aqi = std::min(aqi, static_cast(std::numeric_limits::max())); return static_cast(std::lround(aqi)); } @@ -30,7 +32,7 @@ class AQICalculator : public AbstractAQICalculator { {35.5f, 55.5f}, {55.5f, 125.5f}, {125.5f, 225.5f}, - {225.5f, std::numeric_limits::max()} + {225.5f, 500.4f} // EPA 2024: AQI 301-500 maps to PM2.5 225.5-500.4 ug/m3 // clang-format on }; @@ -41,11 +43,11 @@ class AQICalculator : public AbstractAQICalculator { {155.0f, 255.0f}, {255.0f, 355.0f}, {355.0f, 425.0f}, - {425.0f, std::numeric_limits::max()} + {425.0f, 604.0f} // EPA: AQI 301-500 maps to PM10 425-604 ug/m3 (top of the 401-500 band) // clang-format on }; - static float calculate_index(float value, const float array[NUM_LEVELS][2]) { + static float calculate_index(float value, const float array[NUM_LEVELS][2], bool extended_range) { int grid_index = get_grid_index(value, array); if (grid_index == -1) { return -1.0f; @@ -55,14 +57,22 @@ class AQICalculator : public AbstractAQICalculator { float conc_lo = array[grid_index][0]; float conc_hi = array[grid_index][1]; - return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; + float index = (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; + + // Concentrations above the highest breakpoint run the linear fit past aqi_hi. By default we + // clamp to the standard maximum; with extended_range we keep the extrapolated "over-range" + // value so heavy pollution reports numbers beyond what the standard defines. + if (grid_index == NUM_LEVELS - 1 && !extended_range && index > aqi_hi) { + return aqi_hi; + } + return index; } static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { for (int i = 0; i < NUM_LEVELS; i++) { - const bool in_range = - (value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive - : (value < array[i][1])); // others exclusive on hi + // The top band is open-ended: any value at or above its lower breakpoint falls into it, + // and calculate_index() decides whether to clamp or extrapolate. + const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]); if (in_range) { return i; } diff --git a/esphome/components/aqi/aqi_sensor.cpp b/esphome/components/aqi/aqi_sensor.cpp index 2d8a780cc7..4bb964d5ee 100644 --- a/esphome/components/aqi/aqi_sensor.cpp +++ b/esphome/components/aqi/aqi_sensor.cpp @@ -24,6 +24,7 @@ void AQISensor::setup() { void AQISensor::dump_config() { ESP_LOGCONFIG(TAG, "AQI Sensor:"); ESP_LOGCONFIG(TAG, " Calculation Type: %s", this->aqi_calc_type_ == AQI_TYPE ? "AQI" : "CAQI"); + ESP_LOGCONFIG(TAG, " Extended Range: %s", this->extended_range_ ? "enabled" : "disabled"); if (this->pm_2_5_sensor_ != nullptr) { ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str()); } @@ -44,7 +45,7 @@ void AQISensor::calculate_aqi_() { return; } - uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_); + uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_, this->extended_range_); this->publish_state(aqi); } diff --git a/esphome/components/aqi/aqi_sensor.h b/esphome/components/aqi/aqi_sensor.h index aa64fa5a4d..464c088188 100644 --- a/esphome/components/aqi/aqi_sensor.h +++ b/esphome/components/aqi/aqi_sensor.h @@ -14,6 +14,7 @@ class AQISensor final : public sensor::Sensor, public Component { void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; } void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; } void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; } + void set_extended_range(bool extended_range) { this->extended_range_ = extended_range; } protected: void calculate_aqi_(); @@ -21,6 +22,7 @@ class AQISensor final : public sensor::Sensor, public Component { sensor::Sensor *pm_2_5_sensor_{nullptr}; sensor::Sensor *pm_10_0_sensor_{nullptr}; AQICalculatorType aqi_calc_type_{AQI_TYPE}; + bool extended_range_{false}; AQICalculatorFactory aqi_calculator_factory_; float pm_2_5_value_{NAN}; diff --git a/esphome/components/aqi/caqi_calculator.h b/esphome/components/aqi/caqi_calculator.h index 3f6da45aa9..56a98682d9 100644 --- a/esphome/components/aqi/caqi_calculator.h +++ b/esphome/components/aqi/caqi_calculator.h @@ -9,25 +9,28 @@ namespace esphome::aqi { class CAQICalculator : public AbstractAQICalculator { public: - uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override { + // The CAQI (CITEAIR) scale defines no maximum: its top "Very high" class is simply ">100". We + // therefore always extrapolate the top band past 100 without limit, so the extended_range flag + // (which lifts the AQI calculator's fixed 500 cap) has no meaning here and is ignored. + uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool /*extended_range*/) override { float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f}); + aqi = std::min(aqi, static_cast(std::numeric_limits::max())); return static_cast(std::lround(aqi)); } protected: - static constexpr int NUM_LEVELS = 5; + static constexpr int NUM_LEVELS = 4; - static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}}; + static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}}; static constexpr float PM2_5_GRID[NUM_LEVELS][2] = { // clang-format off {0.0f, 15.1f}, {15.1f, 30.1f}, {30.1f, 55.1f}, - {55.1f, 110.1f}, - {110.1f, std::numeric_limits::max()} + {55.1f, 110.1f} // clang-format on }; @@ -36,8 +39,7 @@ class CAQICalculator : public AbstractAQICalculator { {0.0f, 25.1f}, {25.1f, 50.1f}, {50.1f, 90.1f}, - {90.1f, 180.1f}, - {180.1f, std::numeric_limits::max()} + {90.1f, 180.1f} // clang-format on }; @@ -52,14 +54,15 @@ class CAQICalculator : public AbstractAQICalculator { float conc_lo = array[grid_index][0]; float conc_hi = array[grid_index][1]; + // The top band is open-ended (see get_grid_index), so for concentrations above the last + // breakpoint this linear fit extrapolates past 100 unbounded, matching CAQI's open ">100" class. return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; } static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { for (int i = 0; i < NUM_LEVELS; i++) { - const bool in_range = - (value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive - : (value < array[i][1])); // others exclusive on hi + // The top band is open-ended: any value at or above its lower breakpoint falls into it. + const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]); if (in_range) { return i; } diff --git a/esphome/components/aqi/sensor.py b/esphome/components/aqi/sensor.py index 5842aea88c..9c361560df 100644 --- a/esphome/components/aqi/sensor.py +++ b/esphome/components/aqi/sensor.py @@ -8,14 +8,25 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, ) -from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, aqi_ns +from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE, aqi_ns CODEOWNERS = ["@jasstrong"] DEPENDENCIES = ["sensor"] AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = ( + +def _validate_extended_range(config): + if CONF_EXTENDED_RANGE in config and config[CONF_CALCULATION_TYPE] == "CAQI": + raise cv.Invalid( + f"'{CONF_EXTENDED_RANGE}' is not supported with 'calculation_type: CAQI'. " + "CAQI has no maximum value by specification, so it is always reported unbounded.", + [CONF_EXTENDED_RANGE], + ) + return config + + +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( AQISensor, accuracy_decimals=0, @@ -29,9 +40,11 @@ CONFIG_SCHEMA = ( cv.Required(CONF_CALCULATION_TYPE): cv.enum( AQI_CALCULATION_TYPE, upper=True ), + cv.Optional(CONF_EXTENDED_RANGE): cv.boolean, } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + _validate_extended_range, ) @@ -46,3 +59,4 @@ async def to_code(config): cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor)) cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE])) + cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False))) diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index f46a6b8580..02c6e75146 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -61,7 +61,7 @@ void HM3301Component::update() { int16_t aqi_value = -1; if (this->aqi_sensor_ != nullptr && pm_2_5_value != -1 && pm_10_0_value != -1) { aqi::AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); - aqi_value = calculator->get_aqi(pm_2_5_value, pm_10_0_value); + aqi_value = calculator->get_aqi(pm_2_5_value, pm_10_0_value, /*extended_range=*/false); } if (pm_1_0_value != -1) { diff --git a/tests/component_tests/aqi/__init__.py b/tests/component_tests/aqi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/aqi/test_aqi.py b/tests/component_tests/aqi/test_aqi.py new file mode 100644 index 0000000000..712c277508 --- /dev/null +++ b/tests/component_tests/aqi/test_aqi.py @@ -0,0 +1,35 @@ +"""Config-validation tests for the aqi sensor component.""" + +import pytest +from voluptuous import Invalid + +from esphome.components.aqi import CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE +from esphome.components.aqi.sensor import _validate_extended_range + + +def test_extended_range_rejected_with_caqi(): + """extended_range has no meaning for CAQI (no spec maximum) and must be rejected.""" + with pytest.raises(Invalid, match="CAQI"): + _validate_extended_range( + {CONF_CALCULATION_TYPE: "CAQI", CONF_EXTENDED_RANGE: True} + ) + + +def test_extended_range_rejected_with_caqi_even_when_false(): + """The option is not allowed at all with CAQI, regardless of its value.""" + with pytest.raises(Invalid, match="CAQI"): + _validate_extended_range( + {CONF_CALCULATION_TYPE: "CAQI", CONF_EXTENDED_RANGE: False} + ) + + +def test_extended_range_allowed_with_aqi(): + """extended_range is valid for the US AQI calculation.""" + config = {CONF_CALCULATION_TYPE: "AQI", CONF_EXTENDED_RANGE: True} + assert _validate_extended_range(config) is config + + +def test_caqi_without_extended_range_ok(): + """CAQI is fine as long as extended_range is not set.""" + config = {CONF_CALCULATION_TYPE: "CAQI"} + assert _validate_extended_range(config) is config diff --git a/tests/components/aqi/benchmark.yaml b/tests/components/aqi/benchmark.yaml new file mode 100644 index 0000000000..d0d54c50b0 --- /dev/null +++ b/tests/components/aqi/benchmark.yaml @@ -0,0 +1,16 @@ +# Declares the component graph the C++ unit test build needs so that the aqi +# component's sources (which include sensor.h) compile. to_code is suppressed by +# the test harness; this only pulls the sensor + aqi source/include paths in. +# Loaded with plain yaml.safe_load, so avoid lambdas / ESPHome-tagged values here. +sensor: + - platform: template + id: pm25_sensor + name: "PM2.5" + - platform: template + id: pm10_sensor + name: "PM10" + - platform: aqi + name: "AQI" + pm_2_5: pm25_sensor + pm_10_0: pm10_sensor + calculation_type: AQI diff --git a/tests/components/aqi/common.yaml b/tests/components/aqi/common.yaml index 4c8cbbfa3f..cddc1f77cd 100644 --- a/tests/components/aqi/common.yaml +++ b/tests/components/aqi/common.yaml @@ -20,3 +20,10 @@ sensor: pm_2_5: pm25_sensor pm_10_0: pm10_sensor calculation_type: CAQI + + - platform: aqi + name: "Air Quality Index (AQI, extended)" + pm_2_5: pm25_sensor + pm_10_0: pm10_sensor + calculation_type: AQI + extended_range: true diff --git a/tests/components/aqi/test_aqi_calculator.cpp b/tests/components/aqi/test_aqi_calculator.cpp new file mode 100644 index 0000000000..ab95d3ac92 --- /dev/null +++ b/tests/components/aqi/test_aqi_calculator.cpp @@ -0,0 +1,85 @@ +#include + +#include "esphome/components/aqi/aqi_calculator.h" +#include "esphome/components/aqi/caqi_calculator.h" + +namespace esphome::aqi::testing { + +// US AQI (EPA 2024): PM2.5 225.5-500.4 -> 301-500, PM10 425-604 -> 301-500. + +TEST(USAQI, LowRangeUnaffectedByExtendedFlag) { + AQICalculator calc; + // PM2.5 25 drives over PM10 50; well below the top band, so the flag changes nothing. + EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, false), 81); + EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, true), 81); +} + +TEST(USAQI, HazardousInterpolatesNotPinnedAt301) { + AQICalculator calc; + // Regression guard: the old FLT_MAX top bucket collapsed every hazardous reading to 301. + EXPECT_EQ(calc.get_aqi(225.5f, 0.0f, false), 301); // band start + EXPECT_EQ(calc.get_aqi(250.0f, 0.0f, false), 319); // interpolated, not 301 + EXPECT_EQ(calc.get_aqi(500.4f, 0.0f, false), 500); // band top +} + +TEST(USAQI, DefaultClampsAtStandardMaximum) { + AQICalculator calc; + EXPECT_EQ(calc.get_aqi(600.0f, 0.0f, false), 500); + EXPECT_EQ(calc.get_aqi(1000.0f, 0.0f, false), 500); + EXPECT_EQ(calc.get_aqi(0.0f, 604.0f, false), 500); // PM10 top breakpoint +} + +TEST(USAQI, ExtendedRangeExtrapolatesBeyond500) { + AQICalculator calc; + EXPECT_EQ(calc.get_aqi(600.0f, 0.0f, true), 572); + EXPECT_EQ(calc.get_aqi(1000.0f, 0.0f, true), 862); + EXPECT_EQ(calc.get_aqi(0.0f, 700.0f, true), 607); // PM10 extrapolated past 500 +} + +TEST(USAQI, ExtendedRangeSaturatesUint16NoWraparound) { + AQICalculator calc; + // An absurd concentration would overflow uint16_t; it must saturate, not wrap to a small value. + EXPECT_EQ(calc.get_aqi(100000.0f, 0.0f, true), 65535); +} + +TEST(USAQI, WorseOfTwoPollutantsWins) { + AQICalculator calc; + // PM10 604 -> 500 dominates PM2.5 25 -> 81. + EXPECT_EQ(calc.get_aqi(25.0f, 604.0f, false), 500); +} + +// CAQI (CITEAIR): no maximum by spec -- the top ">100" class is open, so it is always unbounded +// and the extended_range flag does not apply. + +TEST(CAQI, LowRange) { + CAQICalculator calc; + EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, false), 50); +} + +TEST(CAQI, ContinuousAt100NoPinAt101) { + CAQICalculator calc; + // Old code pinned everything above the top breakpoint to 101; now it reaches exactly 100. + EXPECT_EQ(calc.get_aqi(110.1f, 0.0f, false), 100); +} + +TEST(CAQI, UnboundedAboveTopBand) { + CAQICalculator calc; + EXPECT_EQ(calc.get_aqi(200.0f, 0.0f, false), 139); + EXPECT_EQ(calc.get_aqi(2000.0f, 0.0f, false), 925); +} + +TEST(CAQI, ExtendedRangeFlagIsIgnored) { + CAQICalculator calc; + // CAQI is always unbounded, so the flag must make no difference either way. + EXPECT_EQ(calc.get_aqi(200.0f, 0.0f, true), calc.get_aqi(200.0f, 0.0f, false)); + EXPECT_EQ(calc.get_aqi(2000.0f, 0.0f, true), calc.get_aqi(2000.0f, 0.0f, false)); +} + +TEST(CAQI, SaturatesUint16NoWraparound) { + CAQICalculator calc; + // CAQI is unbounded, so an extreme reading can extrapolate past uint16_t; it must saturate, + // not wrap around to a small (falsely "good") value. + EXPECT_EQ(calc.get_aqi(200000.0f, 0.0f, false), 65535); +} + +} // namespace esphome::aqi::testing From 3439a08b68d34c9f3d78bfae04333c68380ad485 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:19:41 +1200 Subject: [PATCH 2161/2214] [deep_sleep] Add on_wake automation triggers (#17569) --- esphome/components/deep_sleep/__init__.py | 56 +++++++++++++++--- .../deep_sleep/deep_sleep_bk72xx.cpp | 15 +++++ .../deep_sleep/deep_sleep_component.h | 59 +++++++++++++++++++ .../deep_sleep/deep_sleep_esp32.cpp | 19 ++++++ .../deep_sleep/deep_sleep_esp8266.cpp | 17 ++++++ esphome/core/defines.h | 1 + .../deep_sleep/test_deep_sleep.py | 37 ++++++++++++ .../deep_sleep/test_deep_sleep3.yaml | 23 ++++++++ .../deep_sleep/common-esp32-all.yaml | 8 ++- .../deep_sleep/common-esp32-ext1.yaml | 9 ++- tests/components/deep_sleep/common-esp32.yaml | 4 ++ .../deep_sleep/test.bk72xx-ard.yaml | 4 ++ .../deep_sleep/test.esp8266-ard.yaml | 4 ++ 13 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/deep_sleep/test_deep_sleep3.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 9666c8e507..83eff496ac 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -30,6 +30,7 @@ from esphome.const import ( CONF_SECOND, CONF_SLEEP_DURATION, CONF_TIME_ID, + CONF_TRIGGER_ID, CONF_WAKEUP_PIN, PLATFORM_BK72XX, PLATFORM_ESP32, @@ -234,6 +235,15 @@ EXT1_WAKEUP_MODES = { } WakeupCauseToRunDuration = deep_sleep_ns.struct("WakeupCauseToRunDuration") +WakeupCause = deep_sleep_ns.enum("WakeupCause") +WakeTrigger = deep_sleep_ns.class_( + "WakeTrigger", automation.Trigger.template(WakeupCause), cg.Component +) +Ext1WakeTrigger = deep_sleep_ns.class_( + "Ext1WakeTrigger", automation.Trigger.template(), cg.Component +) + +CONF_ON_WAKE = "on_wake" CONF_WAKEUP_PIN_MODE = "wakeup_pin_mode" CONF_ESP32_EXT1_WAKEUP = "esp32_ext1_wakeup" CONF_TOUCH_WAKEUP = "touch_wakeup" @@ -256,6 +266,22 @@ WAKEUP_PIN_SCHEMA = cv.Schema( } ) +EXT1_WAKEUP_PIN_SCHEMA = cv.Schema( + { + cv.Required(CONF_PIN): cv.All( + pins.internal_gpio_input_pin_schema, validate_pin_number_esp32 + ), + cv.Optional(CONF_ON_WAKE): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Ext1WakeTrigger)} + ), + } +) + +# Entries that are not in the {pin: ..., on_wake: ...} form are treated as a +# bare pin config (the original syntax, e.g. a plain "GPIO5" or {number: 5}). +validate_ext1_wakeup_pin = cv.maybe_simple_value(EXT1_WAKEUP_PIN_SCHEMA, key=CONF_PIN) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -282,8 +308,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_PINS): cv.ensure_list( - pins.internal_gpio_input_pin_schema, - validate_pin_number_esp32, + validate_ext1_wakeup_pin, ), cv.Required(CONF_MODE): cv.All( cv.enum(EXT1_WAKEUP_MODES, upper=True), @@ -292,6 +317,12 @@ CONFIG_SCHEMA = cv.All( } ), ), + cv.Optional(CONF_ON_WAKE): cv.All( + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(WakeTrigger)} + ), + ), cv.Optional(CONF_TOUCH_WAKEUP): cv.All( cv.only_on_esp32, esp32.only_on_variant( @@ -362,16 +393,27 @@ async def to_code(config): ) cg.add(var.set_run_duration(wakeup_cause_to_run_duration)) - if CONF_ESP32_EXT1_WAKEUP in config: - conf = config[CONF_ESP32_EXT1_WAKEUP] + if (ext1_conf := config.get(CONF_ESP32_EXT1_WAKEUP)) is not None: mask = 0 - for pin in conf[CONF_PINS]: - mask |= 1 << pin[CONF_NUMBER] + for pin_conf in ext1_conf[CONF_PINS]: + number = pin_conf[CONF_PIN][CONF_NUMBER] + mask |= 1 << number + for wake_conf in pin_conf.get(CONF_ON_WAKE, []): + trigger = cg.new_Pvariable(wake_conf[CONF_TRIGGER_ID], number) + await cg.register_component(trigger, wake_conf) + await automation.build_automation(trigger, [], wake_conf) + cg.add_define("USE_DEEP_SLEEP_ON_WAKE") struct = cg.StructInitializer( - Ext1Wakeup, ("mask", mask), ("wakeup_mode", conf[CONF_MODE]) + Ext1Wakeup, ("mask", mask), ("wakeup_mode", ext1_conf[CONF_MODE]) ) cg.add(var.set_ext1_wakeup(struct)) + for wake_conf in config.get(CONF_ON_WAKE, []): + trigger = cg.new_Pvariable(wake_conf[CONF_TRIGGER_ID]) + await cg.register_component(trigger, wake_conf) + await automation.build_automation(trigger, [(WakeupCause, "cause")], wake_conf) + cg.add_define("USE_DEEP_SLEEP_ON_WAKE") + if CONF_TOUCH_WAKEUP in config: cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations: diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 8dca32689b..5595b0ba89 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -7,6 +7,21 @@ namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep.bk72xx"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + switch (lt_get_reboot_reason()) { + case REBOOT_REASON_SLEEP_GPIO: + return WAKEUP_CAUSE_GPIO; + case REBOOT_REASON_SLEEP_RTC: + return WAKEUP_CAUSE_TIMER; + case REBOOT_REASON_SLEEP_USB: + return WAKEUP_CAUSE_UNKNOWN; + default: + return WAKEUP_CAUSE_NONE; + } +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() { diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 896ed092aa..05e18f8c38 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -60,6 +60,65 @@ struct WakeupCauseToRunDuration { #endif // USE_ESP32 +#ifdef USE_DEEP_SLEEP_ON_WAKE + +/// Why the device woke from deep sleep. Passed to on_wake automations. +enum WakeupCause : uint8_t { + /// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart). + WAKEUP_CAUSE_NONE = 0, + /// The device woke from deep sleep, but the source could not be identified. + WAKEUP_CAUSE_UNKNOWN, + /// The device was woken by the sleep timer. + WAKEUP_CAUSE_TIMER, + /// The device was woken by a GPIO pin (wakeup_pin or esp32_ext1_wakeup). + WAKEUP_CAUSE_GPIO, + /// The device was woken by a touch pad. + WAKEUP_CAUSE_TOUCH, +}; + +/// Return why the device woke from deep sleep. Implemented per platform. +WakeupCause get_wakeup_cause(); + +/** Setup priority of on_wake triggers. + * + * Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at + * their default priority (600), so on_wake automations can update state (e.g. globals) that + * on_boot automations then use. + */ +inline constexpr float ON_WAKE_TRIGGER_SETUP_PRIORITY = 700.0f; + +/// Fires once on boot when the device woke from deep sleep, with the wakeup cause. +class WakeTrigger : public Trigger, public Component { + public: + void setup() override { + const WakeupCause cause = get_wakeup_cause(); + if (cause != WAKEUP_CAUSE_NONE) { + this->trigger(cause); + } + } + float get_setup_priority() const override { return ON_WAKE_TRIGGER_SETUP_PRIORITY; } +}; + +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) +/// Fires once on boot when the device was woken from deep sleep by the given ext1 pin. +class Ext1WakeTrigger : public Trigger<>, public Component { + public: + explicit Ext1WakeTrigger(uint8_t pin) : pin_(pin) {} + void setup() override { + if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_EXT1 && + (esp_sleep_get_ext1_wakeup_status() & (1ULL << this->pin_))) { + this->trigger(); + } + } + float get_setup_priority() const override { return ON_WAKE_TRIGGER_SETUP_PRIORITY; } + + protected: + uint8_t pin_; +}; +#endif + +#endif // USE_DEEP_SLEEP_ON_WAKE + template class EnterDeepSleepAction; template class PreventDeepSleepAction; diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 7cb8e53efd..f64e1f37e1 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -30,6 +30,25 @@ namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + switch (esp_sleep_get_wakeup_cause()) { + case ESP_SLEEP_WAKEUP_EXT0: + case ESP_SLEEP_WAKEUP_EXT1: + case ESP_SLEEP_WAKEUP_GPIO: + return WAKEUP_CAUSE_GPIO; + case ESP_SLEEP_WAKEUP_TIMER: + return WAKEUP_CAUSE_TIMER; + case ESP_SLEEP_WAKEUP_TOUCHPAD: + return WAKEUP_CAUSE_TOUCH; + case ESP_SLEEP_WAKEUP_UNDEFINED: + return WAKEUP_CAUSE_NONE; + default: + return WAKEUP_CAUSE_UNKNOWN; + } +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { if (this->wakeup_cause_to_run_duration_.has_value()) { esp_sleep_wakeup_cause_t wakeup_cause = esp_sleep_get_wakeup_cause(); diff --git a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index 9239a7fb31..2b98f4b855 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -3,10 +3,27 @@ #include +#ifdef USE_DEEP_SLEEP_ON_WAKE +extern "C" { +#include +} +#endif + namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + // The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST). + // NOLINTNEXTLINE(readability-static-accessed-through-instance) + if (ESP.getResetInfoPtr()->reason == REASON_DEEP_SLEEP_AWAKE) { + return WAKEUP_CAUSE_TIMER; + } + return WAKEUP_CAUSE_NONE; +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1ecc3dc4a8..61de97ca74 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -56,6 +56,7 @@ #define USE_DATETIME_TIME #define USE_DEBUG #define USE_DEEP_SLEEP +#define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES #define USE_DISPLAY #define USE_ENTITY_DEVICE_CLASS diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index 84128d75d7..f105ed5888 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -33,6 +33,43 @@ def test_deep_sleep_run_duration_simple(generate_main): assert "deepsleep->set_run_duration(10000);" in main_cpp +def test_deep_sleep_on_wake_trigger(generate_main): + """ + When deep sleep is configured with a component-level on_wake automation, + a WakeTrigger component should be registered with the wakeup cause as + the automation argument. + """ + main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep3.yaml") + + assert "deep_sleep::WakeTrigger();" in main_cpp + assert "Automation" in main_cpp + + +def test_deep_sleep_ext1_on_wake_triggers(generate_main): + """ + Each esp32_ext1_wakeup pin with an on_wake automation should get its own + Ext1WakeTrigger with the pin number, and all pins (including the legacy + bare-pin shorthand) should contribute to the ext1 wakeup mask. + """ + main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep3.yaml") + + assert "deep_sleep::Ext1WakeTrigger(2);" in main_cpp + assert "deep_sleep::Ext1WakeTrigger(4);" in main_cpp + # GPIO13 has no on_wake, so no trigger is created for it + assert "deep_sleep::Ext1WakeTrigger(13)" not in main_cpp + # mask covers GPIO2, GPIO4 and GPIO13 + assert ".mask = 8212," in main_cpp + + +def test_deep_sleep_no_on_wake_no_triggers(generate_main): + """ + Without any on_wake automations, no wake trigger code should be generated. + """ + main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep1.yaml") + + assert "WakeTrigger" not in main_cpp + + def test_deep_sleep_run_duration_dictionary(generate_main): """ When deep sleep is configured with dictionary run duration, it should be set. diff --git a/tests/component_tests/deep_sleep/test_deep_sleep3.yaml b/tests/component_tests/deep_sleep/test_deep_sleep3.yaml new file mode 100644 index 0000000000..71a0340b65 --- /dev/null +++ b/tests/component_tests/deep_sleep/test_deep_sleep3.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + +deep_sleep: + id: deepsleep + sleep_duration: 1min + run_duration: 10s + on_wake: + - lambda: 'ESP_LOGD("test", "cause %d", static_cast(cause));' + esp32_ext1_wakeup: + mode: ANY_HIGH + pins: + - pin: GPIO2 + on_wake: + - lambda: 'ESP_LOGD("test", "left");' + - pin: + number: GPIO4 + on_wake: + - lambda: 'ESP_LOGD("test", "right");' + - number: GPIO13 diff --git a/tests/components/deep_sleep/common-esp32-all.yaml b/tests/components/deep_sleep/common-esp32-all.yaml index b97eec76b9..9dc2f87258 100644 --- a/tests/components/deep_sleep/common-esp32-all.yaml +++ b/tests/components/deep_sleep/common-esp32-all.yaml @@ -6,9 +6,15 @@ deep_sleep: sleep_duration: 50s wakeup_pin: ${wakeup_pin} wakeup_pin_mode: INVERT_WAKEUP + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] esp32_ext1_wakeup: pins: - - number: GPIO2 + - pin: GPIO2 + on_wake: + - logger.log: Woken by ext1 pin GPIO2 - number: GPIO13 mode: ANY_HIGH touch_wakeup: true diff --git a/tests/components/deep_sleep/common-esp32-ext1.yaml b/tests/components/deep_sleep/common-esp32-ext1.yaml index 9ed4279a33..c531d44743 100644 --- a/tests/components/deep_sleep/common-esp32-ext1.yaml +++ b/tests/components/deep_sleep/common-esp32-ext1.yaml @@ -5,8 +5,15 @@ deep_sleep: sleep_duration: 50s wakeup_pin: ${wakeup_pin} wakeup_pin_mode: INVERT_WAKEUP + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] esp32_ext1_wakeup: pins: - - number: GPIO2 + - pin: + number: GPIO2 + on_wake: + - logger.log: Woken by ext1 pin GPIO2 - number: GPIO5 mode: ANY_HIGH diff --git a/tests/components/deep_sleep/common-esp32.yaml b/tests/components/deep_sleep/common-esp32.yaml index c20e1a902e..e670787cc0 100644 --- a/tests/components/deep_sleep/common-esp32.yaml +++ b/tests/components/deep_sleep/common-esp32.yaml @@ -5,3 +5,7 @@ deep_sleep: sleep_duration: 50s wakeup_pin: ${wakeup_pin} wakeup_pin_mode: INVERT_WAKEUP + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] diff --git a/tests/components/deep_sleep/test.bk72xx-ard.yaml b/tests/components/deep_sleep/test.bk72xx-ard.yaml index 2385fbb4db..bdbd27c902 100644 --- a/tests/components/deep_sleep/test.bk72xx-ard.yaml +++ b/tests/components/deep_sleep/test.bk72xx-ard.yaml @@ -1,6 +1,10 @@ deep_sleep: run_duration: 30s sleep_duration: 12h + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] wakeup_pin: - pin: number: P6 diff --git a/tests/components/deep_sleep/test.esp8266-ard.yaml b/tests/components/deep_sleep/test.esp8266-ard.yaml index df08ec8a14..e4c592c095 100644 --- a/tests/components/deep_sleep/test.esp8266-ard.yaml +++ b/tests/components/deep_sleep/test.esp8266-ard.yaml @@ -1,5 +1,9 @@ deep_sleep: run_duration: 10s sleep_duration: 50s + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] <<: !include common.yaml From be3b27f37801b118c93ccbbc7330abf7d25d0af7 Mon Sep 17 00:00:00 2001 From: lsellens Date: Wed, 15 Jul 2026 20:20:07 -0500 Subject: [PATCH 2162/2214] [rc522_i2c] Change default address to match whats in the docs (#17566) --- esphome/components/rc522_i2c/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/rc522_i2c/__init__.py b/esphome/components/rc522_i2c/__init__.py index 7c42a12429..c67615e2d8 100644 --- a/esphome/components/rc522_i2c/__init__.py +++ b/esphome/components/rc522_i2c/__init__.py @@ -16,7 +16,7 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(RC522I2C), } - ).extend(i2c.i2c_device_schema(0x2C)) + ).extend(i2c.i2c_device_schema(0x28)) ) From f91aa7aa13d5a5c8fda868371e42069a726301ab Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:26:23 -0400 Subject: [PATCH 2163/2214] [as3935_i2c] Use repeated start when reading registers (#17584) --- esphome/components/as3935_i2c/as3935_i2c.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/as3935_i2c/as3935_i2c.cpp b/esphome/components/as3935_i2c/as3935_i2c.cpp index 4c1020daa7..b3d015114f 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.cpp +++ b/esphome/components/as3935_i2c/as3935_i2c.cpp @@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits, uint8_t I2CAS3935Component::read_register(uint8_t reg) { uint8_t value; - if (write(®, 1) != i2c::ERROR_OK) { - ESP_LOGW(TAG, "Writing register failed!"); - return 0; - } - if (read(&value, 1) != i2c::ERROR_OK) { + if (!this->read_byte(reg, &value)) { ESP_LOGW(TAG, "Reading register failed!"); return 0; } From 90e838d327510e71a2081f5c350bafbaf77e3c5d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:07:34 -0400 Subject: [PATCH 2164/2214] [ci] Extend variant clang-tidy scans to USB logger, tsens and LP peripheral code (#17580) --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6066d0ea03..59e6f006e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -748,7 +748,8 @@ jobs: include: - id: clang-tidy name: Run script/clang-tidy for ESP32 S3 - options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3 + # yamllint disable-line rule:line-length + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC - id: clang-tidy name: Run script/clang-tidy for ESP32 P4 # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, @@ -758,7 +759,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 C6 # yamllint disable-line rule:line-length - options: --environment esp32c6-idf-tidy --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE + options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE steps: - name: Check out code from GitHub From e327a7f52fc9ce21be86f75348ff090f1b599c4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 17:58:41 -1000 Subject: [PATCH 2165/2214] [scheduler] Remove deprecated set_retry/cancel_retry (#17585) --- esphome/core/base_automation.h | 4 +- esphome/core/component.cpp | 52 ---- esphome/core/component.h | 37 --- esphome/core/scheduler.cpp | 180 +---------- esphome/core/scheduler.h | 90 ++---- .../fixtures/scheduler_numeric_id_test.yaml | 33 +- .../fixtures/scheduler_retry_test.yaml | 287 ------------------ .../test_scheduler_numeric_id_test.py | 45 +-- .../integration/test_scheduler_retry_test.py | 279 ----------------- 9 files changed, 41 insertions(+), 966 deletions(-) delete mode 100644 tests/integration/fixtures/scheduler_retry_test.yaml delete mode 100644 tests/integration/test_scheduler_retry_test.py diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 38e52e44cb..276b8aa972 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,7 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + /* skip_cancel= */ this->num_running_ > 1, // Record the owning script (if any) so the blocking warning can name it; propagates across // chained delays via the scheduler. /* source= */ App.get_current_source()); @@ -215,7 +215,7 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + /* skip_cancel= */ this->num_running_ > 1, // See the no-argument branch above: record the owning script for log attribution. /* source= */ App.get_current_source()); } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 281d7aaecd..e5fbb8ba07 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -93,36 +93,6 @@ bool Component::cancel_interval(const char *name) { // NOLINT return App.scheduler.cancel_interval(this, name); } -void Component::set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function &&f, float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} - -void Component::set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function &&f, float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} - -bool Component::cancel_retry(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_retry(this, name); -#pragma GCC diagnostic pop -} - -bool Component::cancel_retry(const char *name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_retry(this, name); -#pragma GCC diagnostic pop -} - void Component::set_timeout(const char *name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } @@ -156,21 +126,6 @@ void Component::set_interval(InternalSchedulerID id, uint32_t interval, std::fun bool Component::cancel_interval(InternalSchedulerID id) { return App.scheduler.cancel_interval(this, id); } -void Component::set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function &&f, float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, id, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} - -bool Component::cancel_retry(uint32_t id) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_retry(this, id); -#pragma GCC diagnostic pop -} - void Component::call_setup() { this->setup(); } void Component::call_dump_config_() { this->dump_config(); @@ -307,13 +262,6 @@ void Component::set_timeout(uint32_t timeout, std::function &&f) { // N void Component::set_interval(uint32_t interval, std::function &&f) { // NOLINT App.scheduler.set_interval(this, static_cast(nullptr), interval, std::move(f)); } -void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, - float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, "", initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} bool Component::is_ready() const { // Bitmask check: valid states are SETUP(1), LOOP(2), LOOP_DONE(4) // (1 << state) & 0b10110 checks membership in one instruction diff --git a/esphome/core/component.h b/esphome/core/component.h index 70a051ca0b..ecaf863ecf 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -96,8 +96,6 @@ inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20; // decide whether to propagate clears to App.app_state_. Never set on a // Component's component_state_. inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40; -// Remove before 2026.8.0 -enum class RetryResult { DONE, RETRY }; inline constexpr uint8_t WARN_IF_BLOCKING_OVER_CS = 5U; // 50ms in centiseconds (1cs = 10ms) @@ -410,41 +408,6 @@ class Component { bool cancel_interval(uint32_t id); // NOLINT bool cancel_interval(InternalSchedulerID id); // NOLINT - /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT - std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT - std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT - std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, // NOLINT - float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(const std::string &name); // NOLINT - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(const char *name); // NOLINT - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(uint32_t id); // NOLINT - /** Set a timeout function with a const char* name. * * Similar to javascript's setTimeout(). Empty name means no cancelling possible. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 8449cba5e8..e9c5bf2c04 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -110,35 +110,16 @@ uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) { return static_cast((static_cast(random_uint32()) * max_offset) >> 32); } -// Check if a retry was already cancelled in items_ or to_add_ -// Extracted from set_timer_common_ to reduce code size - retry path is cold and deprecated -// Remove before 2026.8.0 along with all retry code -bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id) { - for (auto *container : {&this->items_, &this->to_add_}) { - for (auto *item : *container) { - if (item != nullptr && this->is_item_removed_locked_(item) && - this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, - /* match_retry= */ true, /* skip_removed= */ false)) { - return true; - } - } - } - return false; -} - // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel, - const LogString *source) { + std::function &&func, bool skip_cancel, const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false, - /* find_first= */ true); + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); } return; } @@ -156,23 +137,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type delay = 1; } - // Take lock early to protect scheduler_item_pool_head_ access and retry-cancelled check + // Take lock early to protect scheduler_item_pool_head_ access LockGuard guard{this->lock_}; - // For retries, check if there's a cancelled timeout first - before allocating an item. - // Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name - // Skip check for defer (delay=0) - deferred retries bypass the cancellation check - if (is_retry && delay != 0 && (name_type != NameType::STATIC_STRING || static_name != nullptr) && - type == SchedulerItem::TIMEOUT && - this->is_retry_cancelled_locked_(component, name_type, static_name, hash_or_id)) { -#ifdef ESPHOME_DEBUG_SCHEDULER - SchedulerNameLog skip_name_log; - ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", - skip_name_log.format(name_type, static_name, hash_or_id)); -#endif - return; - } - // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. @@ -192,7 +159,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type new (&item->callback) std::function(std::move(func)); // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use this->set_item_removed_(item, false); - item->is_retry = is_retry; // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. // Using a pointer lets both paths share the cancel + push_back epilogue. @@ -234,8 +200,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false, - /* find_first= */ true); + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); } target->push_back(item); if (target == &this->to_add_) { @@ -301,125 +266,6 @@ bool HOT Scheduler::cancel_interval(const void *self) { SchedulerItem::INTERVAL); } -// Suppress deprecation warnings for RetryResult usage in the still-present (but deprecated) retry implementation. -// Remove before 2026.8.0 along with all retry code. -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - -struct RetryArgs { - // Ordered to minimize padding on 32-bit systems - std::function func; - Component *component; - Scheduler *scheduler; - // Union for name storage - only one is used based on name_type - union { - const char *static_name; // For STATIC_STRING - uint32_t hash_or_id; // For HASHED_STRING or NUMERIC_ID - } name_; - uint32_t current_interval; - float backoff_increase_factor; - Scheduler::NameType name_type; // Discriminator for name_ union - uint8_t retry_countdown; -}; - -void retry_handler(const std::shared_ptr &args) { - RetryResult const retry_result = args->func(--args->retry_countdown); - if (retry_result == RetryResult::DONE || args->retry_countdown <= 0) - return; - // second execution of `func` happens after `initial_wait_time` - // args->name_ is owned by the shared_ptr - // which is captured in the lambda and outlives the SchedulerItem - const char *static_name = (args->name_type == Scheduler::NameType::STATIC_STRING) ? args->name_.static_name : nullptr; - uint32_t hash_or_id = (args->name_type != Scheduler::NameType::STATIC_STRING) ? args->name_.hash_or_id : 0; - args->scheduler->set_timer_common_( - args->component, Scheduler::SchedulerItem::TIMEOUT, args->name_type, static_name, hash_or_id, - args->current_interval, [args]() { retry_handler(args); }, - /* is_retry= */ true); - // backoff_increase_factor applied to third & later executions - args->current_interval *= args->backoff_increase_factor; -} - -void HOT Scheduler::set_retry_common_(Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor) { - this->cancel_retry_(component, name_type, static_name, hash_or_id); - - if (initial_wait_time == SCHEDULER_DONT_RUN) - return; - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - { - SchedulerNameLog name_log; - ESP_LOGVV(TAG, "set_retry(name='%s', initial_wait_time=%" PRIu32 ", max_attempts=%u, backoff_factor=%0.1f)", - name_log.format(name_type, static_name, hash_or_id), initial_wait_time, max_attempts, - backoff_increase_factor); - } -#endif - - if (backoff_increase_factor < 0.0001f) { - ESP_LOGE(TAG, "set_retry: backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, - (name_type == NameType::STATIC_STRING && static_name) ? static_name : ""); - backoff_increase_factor = 1; - } - - auto args = std::make_shared(); - args->func = std::move(func); - args->component = component; - args->scheduler = this; - args->name_type = name_type; - if (name_type == NameType::STATIC_STRING) { - args->name_.static_name = static_name; - } else { - args->name_.hash_or_id = hash_or_id; - } - args->current_interval = initial_wait_time; - args->backoff_increase_factor = backoff_increase_factor; - args->retry_countdown = max_attempts; - - // First execution of `func` immediately - use set_timer_common_ with is_retry=true - this->set_timer_common_( - component, SchedulerItem::TIMEOUT, name_type, static_name, hash_or_id, 0, [args]() { retry_handler(args); }, - /* is_retry= */ true); -} - -void HOT Scheduler::set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor) { - this->set_retry_common_(component, NameType::STATIC_STRING, name, 0, initial_wait_time, max_attempts, std::move(func), - backoff_increase_factor); -} - -bool HOT Scheduler::cancel_retry_(Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id) { - return this->cancel_item_(component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, - /* match_retry= */ true); -} -bool HOT Scheduler::cancel_retry(Component *component, const char *name) { - return this->cancel_retry_(component, NameType::STATIC_STRING, name, 0); -} - -void HOT Scheduler::set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, - uint8_t max_attempts, std::function func, - float backoff_increase_factor) { - this->set_retry_common_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), initial_wait_time, - max_attempts, std::move(func), backoff_increase_factor); -} - -bool HOT Scheduler::cancel_retry(Component *component, const std::string &name) { - return this->cancel_retry_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name)); -} - -void HOT Scheduler::set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor) { - this->set_retry_common_(component, NameType::NUMERIC_ID, nullptr, id, initial_wait_time, max_attempts, - std::move(func), backoff_increase_factor); -} - -bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { - return this->cancel_retry_(component, NameType::NUMERIC_ID, nullptr, id); -} - -#pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings - optional HOT Scheduler::next_schedule_in(uint32_t now) { // IMPORTANT: This method should only be called from the main thread (loop task). // Accesses items_[0] and the fast-path empty checks without holding a lock, which @@ -806,11 +652,11 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { // Common implementation for cancel operations - handles locking bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry) { + SchedulerItem::Type type) { LockGuard guard{this->lock_}; // Public cancel path uses default find_first=false to cancel ALL matches because // DelayAction parallel mode (skip_cancel=true) can create multiple items with the same key. - return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, match_retry); + return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); } // Helper to cancel matching items - must be called with lock held. @@ -822,11 +668,10 @@ bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const size_t Scheduler::mark_matching_items_removed_slow_locked_(std::vector &container, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry, - bool find_first) { + SchedulerItem::Type type, bool find_first) { size_t count = 0; for (auto *item : container) { - if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { + if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type)) { this->set_item_removed_(item, true); if (find_first) return 1; @@ -837,8 +682,7 @@ size_t Scheduler::mark_matching_items_removed_slow_locked_(std::vectormark_matching_items_removed_locked_(this->defer_queue_, component, name_type, static_name, - hash_or_id, type, match_retry, find_first); + hash_or_id, type, find_first); if (find_first && total_cancelled > 0) return true; } @@ -863,7 +707,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type // Only the main loop in call() should recycle items after execution completes. { size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, - hash_or_id, type, match_retry, find_first); + hash_or_id, type, find_first); total_cancelled += heap_cancelled; this->to_remove_add_locked_(heap_cancelled); if (find_first && total_cancelled > 0) @@ -872,7 +716,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type // Cancel items in to_add_ total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_type, static_name, - hash_or_id, type, match_retry, find_first); + hash_or_id, type, find_first); return total_cancelled > 0; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index c7743e5b2a..8ef3499a11 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -16,14 +16,8 @@ namespace esphome { class Component; -struct RetryArgs; - -// Forward declaration of retry_handler - needs to be non-static for friend declaration -void retry_handler(const std::shared_ptr &args); class Scheduler { - // Allow retry_handler to access protected members for internal retry mechanism - friend void ::esphome::retry_handler(const std::shared_ptr &args); // Allow DelayAction to call set_timer_common_ with skip_cancel=true for parallel script delays. // This is needed to fix issue #10264 where parallel scripts with delays interfere with each other. // We use friend instead of a public API because skip_cancel is dangerous - it can cause delays @@ -79,32 +73,6 @@ class Scheduler { SchedulerItem::INTERVAL); } - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor = 1.0f); - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor = 1.0f); - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor = 1.0f); - - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(Component *component, const std::string &name); - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(Component *component, const char *name); - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(Component *component, uint32_t id); - /// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover) uint64_t millis_64() { return esphome::millis_64(); } @@ -202,19 +170,17 @@ class Scheduler { // std::atomic inlines correctly on all platforms. std::atomic remove{0}; - // Bit-packed fields (5 bits used, 3 bits padding in 1 byte) + // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum) - bool is_retry : 1; // True if this is a retry timeout - // 3 bits padding + // 4 bits padding #else // Single-threaded or multi-threaded without atomics: can pack all fields together - // Bit-packed fields (6 bits used, 2 bits padding in 1 byte) + // Bit-packed fields (5 bits used, 3 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; bool remove : 1; NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum) - bool is_retry : 1; // True if this is a retry timeout - // 2 bits padding + // 3 bits padding #endif // Constructor @@ -226,13 +192,11 @@ class Scheduler { #ifdef ESPHOME_THREAD_MULTI_ATOMICS // remove is initialized in the member declaration type(TIMEOUT), - name_type_(NameType::STATIC_STRING), - is_retry(false) { + name_type_(NameType::STATIC_STRING) { #else type(TIMEOUT), remove(false), - name_type_(NameType::STATIC_STRING), - is_retry(false) { + name_type_(NameType::STATIC_STRING) { #endif name_.static_name = nullptr; } @@ -306,19 +270,8 @@ class Scheduler { // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, - uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false, const LogString *source = nullptr); - - // Common implementation for retry - Remove before 2026.8.0 - // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - void set_retry_common_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - uint32_t initial_wait_time, uint8_t max_attempts, std::function func, - float backoff_increase_factor); -#pragma GCC diagnostic pop - // Common implementation for cancel_retry - bool cancel_retry_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); + uint32_t hash_or_id, uint32_t delay, std::function &&func, bool skip_cancel = false, + const LogString *source = nullptr); // Extend a 32-bit millis() value to 64-bit. Use when the caller already has a fresh now. // On platforms with native 64-bit time (ESP32, Host, Zephyr, RP2040 — see @@ -374,11 +327,11 @@ class Scheduler { // mode where skip_cancel=true allows multiple items with the same key). // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id bool cancel_item_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry = false, bool find_first = false); + SchedulerItem::Type type, bool find_first = false); // Common implementation for cancel operations - handles locking bool cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry = false); + SchedulerItem::Type type); // Helper to check if two static string names match inline bool HOT names_match_static_(const char *name1, const char *name2) const { @@ -394,7 +347,7 @@ class Scheduler { // IMPORTANT: Must be called with scheduler lock held inline bool HOT matches_item_locked_(SchedulerItem *item, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, - bool match_retry, bool skip_removed = true) const { + bool skip_removed = true) const { // THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded // platforms, items can be nulled in defer_queue_ during processing. // Fixes: https://github.com/esphome/esphome/issues/11940 @@ -403,7 +356,7 @@ class Scheduler { // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they // match by the `this` key alone. if (item->get_component() != component || item->type != type || - (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { + (skip_removed && this->is_item_removed_locked_(item))) { return false; } // Name type must match @@ -448,13 +401,6 @@ class Scheduler { // IMPORTANT: Must not be inlined - called only for intervals, keeping it out of the hot path saves flash. uint32_t __attribute__((noinline)) calculate_interval_offset_(uint32_t delay); - // Helper to check if a retry was already cancelled - extracted to reduce code size of set_timer_common_ - // Remove before 2026.8.0 along with all retry code. - // IMPORTANT: Must not be inlined - retry path is cold and deprecated. - // IMPORTANT: Caller must hold the scheduler lock before calling this function. - bool __attribute__((noinline)) - is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); - #ifdef ESPHOME_DEBUG_SCHEDULER // Helper for debug logging in set_timer_common_ - extracted to reduce code size void debug_log_timer_(const SchedulerItem *item, NameType name_type, const char *static_name, uint32_t hash_or_id, @@ -556,19 +502,21 @@ class Scheduler { // Inlined: the fast path (empty container) avoids calling the out-of-line scan. inline size_t HOT mark_matching_items_removed_locked_(std::vector &container, Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, + uint32_t hash_or_id, SchedulerItem::Type type, bool find_first = false) { if (container.empty()) return 0; return this->mark_matching_items_removed_slow_locked_(container, component, name_type, static_name, hash_or_id, - type, match_retry, find_first); + type, find_first); } // Out-of-line slow path for mark_matching_items_removed_locked_ when container is non-empty. // IMPORTANT: Must be called with scheduler lock held - __attribute__((noinline)) size_t mark_matching_items_removed_slow_locked_( - std::vector &container, Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, bool find_first); + __attribute__((noinline)) size_t mark_matching_items_removed_slow_locked_(std::vector &container, + Component *component, NameType name_type, + const char *static_name, + uint32_t hash_or_id, + SchedulerItem::Type type, bool find_first); Mutex lock_; std::vector items_; diff --git a/tests/integration/fixtures/scheduler_numeric_id_test.yaml b/tests/integration/fixtures/scheduler_numeric_id_test.yaml index 25decf20f5..ae95e095f6 100644 --- a/tests/integration/fixtures/scheduler_numeric_id_test.yaml +++ b/tests/integration/fixtures/scheduler_numeric_id_test.yaml @@ -18,9 +18,6 @@ globals: - id: interval_counter type: int initial_value: '0' - - id: retry_counter - type: int - initial_value: '0' - id: defer_counter type: int initial_value: '0' @@ -118,29 +115,7 @@ script: id(timeout_counter) += 1; }); - // Test 10: set_retry with numeric ID - App.scheduler.set_retry(component1, 6001U, 50, 3, - [](uint8_t retry_countdown) { - id(retry_counter)++; - ESP_LOGI("test", "Numeric retry 6001 attempt %d (countdown=%d)", - id(retry_counter), retry_countdown); - if (id(retry_counter) >= 2) { - ESP_LOGI("test", "Numeric retry 6001 done"); - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); - - // Test 11: cancel_retry with numeric ID - App.scheduler.set_retry(component1, 6002U, 100, 5, - [](uint8_t retry_countdown) { - ESP_LOGE("test", "ERROR: Numeric retry 6002 should have been cancelled"); - return RetryResult::RETRY; - }); - App.scheduler.cancel_retry(component1, 6002U); - ESP_LOGI("test", "Cancelled numeric retry 6002"); - - // Test 12: defer with numeric ID (Component method) + // Test 10: defer with numeric ID (Component method) class TestDeferComponent : public Component { public: void test_defer_methods() { @@ -161,7 +136,7 @@ script: static TestDeferComponent test_defer_component; test_defer_component.test_defer_methods(); - // Test 13: cancel_defer with numeric ID (Component method) + // Test 11: cancel_defer with numeric ID (Component method) class TestCancelDeferComponent : public Component { public: void test_cancel_defer() { @@ -181,8 +156,8 @@ script: - id: report_results then: - lambda: |- - ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d, Defers: %d", - id(timeout_counter), id(interval_counter), id(retry_counter), id(defer_counter)); + ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Defers: %d", + id(timeout_counter), id(interval_counter), id(defer_counter)); sensor: - platform: template diff --git a/tests/integration/fixtures/scheduler_retry_test.yaml b/tests/integration/fixtures/scheduler_retry_test.yaml deleted file mode 100644 index cdf71152bd..0000000000 --- a/tests/integration/fixtures/scheduler_retry_test.yaml +++ /dev/null @@ -1,287 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: scheduler-retry-test - on_boot: - priority: -100 - then: - - logger.log: "Starting scheduler retry tests" - # Run all tests sequentially with delays - - script.execute: run_all_tests - -host: -api: -logger: - level: VERY_VERBOSE - -globals: - - id: simple_retry_counter - type: int - initial_value: '0' - - id: backoff_retry_counter - type: int - initial_value: '0' - - id: backoff_last_attempt_time - type: uint32_t - initial_value: '0' - - id: immediate_done_counter - type: int - initial_value: '0' - - id: cancel_retry_counter - type: int - initial_value: '0' - - id: empty_name_retry_counter - type: int - initial_value: '0' - - id: script_retry_counter - type: int - initial_value: '0' - - id: multiple_same_name_counter - type: int - initial_value: '0' - - id: const_char_retry_counter - type: int - initial_value: '0' - - id: static_char_retry_counter - type: int - initial_value: '0' - -# Using different component types for each test to ensure isolation -sensor: - - platform: template - name: Simple Retry Test Sensor - id: simple_retry_sensor - lambda: return 1.0; - update_interval: never - - - platform: template - name: Backoff Retry Test Sensor - id: backoff_retry_sensor - lambda: return 2.0; - update_interval: never - - - platform: template - name: Immediate Done Test Sensor - id: immediate_done_sensor - lambda: return 3.0; - update_interval: never - -binary_sensor: - - platform: template - name: Cancel Retry Test Binary Sensor - id: cancel_retry_binary_sensor - lambda: return false; - - - platform: template - name: Empty Name Test Binary Sensor - id: empty_name_binary_sensor - lambda: return true; - -switch: - - platform: template - name: Script Retry Test Switch - id: script_retry_switch - optimistic: true - - - platform: template - name: Multiple Same Name Test Switch - id: multiple_same_name_switch - optimistic: true - -script: - - id: run_all_tests - then: - # Test 1: Simple retry - - logger.log: "=== Test 1: Simple retry ===" - - lambda: |- - auto *component = id(simple_retry_sensor); - App.scheduler.set_retry(component, "simple_retry", 50, 3, - [](uint8_t retry_countdown) { - id(simple_retry_counter)++; - ESP_LOGI("test", "Simple retry attempt %d (countdown=%d)", - id(simple_retry_counter), retry_countdown); - - if (id(simple_retry_counter) >= 2) { - ESP_LOGI("test", "Simple retry succeeded on attempt %d", id(simple_retry_counter)); - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); - - # Test 2: Backoff retry - - logger.log: "=== Test 2: Retry with backoff ===" - - lambda: |- - auto *component = id(backoff_retry_sensor); - - App.scheduler.set_retry(component, "backoff_retry", 50, 4, - [](uint8_t retry_countdown) { - id(backoff_retry_counter)++; - uint32_t now = millis(); - uint32_t interval = 0; - - // Only calculate interval after first attempt - if (id(backoff_retry_counter) > 1) { - interval = now - id(backoff_last_attempt_time); - } - id(backoff_last_attempt_time) = now; - - ESP_LOGI("test", "Backoff retry attempt %d (countdown=%d, interval=%dms)", - id(backoff_retry_counter), retry_countdown, interval); - - if (id(backoff_retry_counter) == 1) { - ESP_LOGI("test", "First call was immediate"); - } else if (id(backoff_retry_counter) == 2) { - ESP_LOGI("test", "Second call interval: %dms (expected ~50ms)", interval); - } else if (id(backoff_retry_counter) == 3) { - ESP_LOGI("test", "Third call interval: %dms (expected ~100ms)", interval); - } else if (id(backoff_retry_counter) == 4) { - ESP_LOGI("test", "Fourth call interval: %dms (expected ~200ms)", interval); - ESP_LOGI("test", "Backoff retry completed"); - return RetryResult::DONE; - } - - return RetryResult::RETRY; - }, 2.0f); - - # Test 3: Immediate done - - logger.log: "=== Test 3: Immediate done ===" - - lambda: |- - auto *component = id(immediate_done_sensor); - App.scheduler.set_retry(component, "immediate_done", 50, 5, - [](uint8_t retry_countdown) { - id(immediate_done_counter)++; - ESP_LOGI("test", "Immediate done retry called (countdown=%d)", retry_countdown); - return RetryResult::DONE; - }); - - # Test 4: Cancel retry - - logger.log: "=== Test 4: Cancel retry ===" - - lambda: |- - auto *component = id(cancel_retry_binary_sensor); - App.scheduler.set_retry(component, "cancel_test", 30, 10, - [](uint8_t retry_countdown) { - id(cancel_retry_counter)++; - ESP_LOGI("test", "Cancel test retry attempt %d", id(cancel_retry_counter)); - return RetryResult::RETRY; - }); - - // Cancel it after 100ms - App.scheduler.set_timeout(component, "cancel_timer", 100, []() { - bool cancelled = App.scheduler.cancel_retry(id(cancel_retry_binary_sensor), "cancel_test"); - ESP_LOGI("test", "Retry cancellation result: %s", cancelled ? "true" : "false"); - ESP_LOGI("test", "Cancel retry ran %d times before cancellation", id(cancel_retry_counter)); - }); - - # Test 5: Empty name retry - - logger.log: "=== Test 5: Empty name retry ===" - - lambda: |- - auto *component = id(empty_name_binary_sensor); - App.scheduler.set_retry(component, "", 100, 5, - [](uint8_t retry_countdown) { - id(empty_name_retry_counter)++; - ESP_LOGI("test", "Empty name retry attempt %d", id(empty_name_retry_counter)); - return RetryResult::RETRY; - }); - - // Try to cancel after 150ms - App.scheduler.set_timeout(component, "empty_cancel_timer", 150, []() { - bool cancelled = App.scheduler.cancel_retry(id(empty_name_binary_sensor), ""); - ESP_LOGI("test", "Empty name retry cancel result: %s", - cancelled ? "true" : "false"); - ESP_LOGI("test", "Empty name retry ran %d times", id(empty_name_retry_counter)); - }); - - # Test 6: Component method - - logger.log: "=== Test 6: Component::set_retry method ===" - - lambda: |- - class TestRetryComponent : public Component { - public: - void test_retry() { - this->set_retry(50, 3, - [](uint8_t retry_countdown) { - id(script_retry_counter)++; - ESP_LOGI("test", "Component retry attempt %d", id(script_retry_counter)); - if (id(script_retry_counter) >= 2) { - return RetryResult::DONE; - } - return RetryResult::RETRY; - }, 1.5f); - } - }; - - static TestRetryComponent test_component; - test_component.test_retry(); - - # Test 7: Multiple same name - - logger.log: "=== Test 7: Multiple retries with same name ===" - - lambda: |- - auto *component = id(multiple_same_name_switch); - - // Set first retry - App.scheduler.set_retry(component, "duplicate_retry", 100, 5, - [](uint8_t retry_countdown) { - id(multiple_same_name_counter) += 1; - ESP_LOGI("test", "First duplicate retry - should not run"); - return RetryResult::RETRY; - }); - - // Set second retry with same name (should cancel first) - App.scheduler.set_retry(component, "duplicate_retry", 50, 3, - [](uint8_t retry_countdown) { - id(multiple_same_name_counter) += 10; - ESP_LOGI("test", "Second duplicate retry attempt (counter=%d)", - id(multiple_same_name_counter)); - if (id(multiple_same_name_counter) >= 20) { - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); - - # Test 8: Const char* overloads - - logger.log: "=== Test 8: Const char* overloads ===" - - lambda: |- - auto *component = id(simple_retry_sensor); - - // Test 8a: Direct string literal - App.scheduler.set_retry(component, "const_char_test", 30, 2, - [](uint8_t retry_countdown) { - id(const_char_retry_counter)++; - ESP_LOGI("test", "Const char retry %d", id(const_char_retry_counter)); - return RetryResult::DONE; - }); - - # Test 9: Static const char* variable - - logger.log: "=== Test 9: Static const char* ===" - - lambda: |- - auto *component = id(backoff_retry_sensor); - - static const char* STATIC_NAME = "static_retry_test"; - App.scheduler.set_retry(component, STATIC_NAME, 20, 1, - [](uint8_t retry_countdown) { - id(static_char_retry_counter)++; - ESP_LOGI("test", "Static const char retry %d", id(static_char_retry_counter)); - return RetryResult::DONE; - }); - - // Cancel with same static const char* - App.scheduler.set_timeout(component, "static_cancel", 10, []() { - static const char* STATIC_NAME = "static_retry_test"; - bool result = App.scheduler.cancel_retry(id(backoff_retry_sensor), STATIC_NAME); - ESP_LOGI("test", "Static cancel result: %s", result ? "true" : "false"); - }); - - # Wait for all tests to complete before reporting - - delay: 500ms - - # Final report - - logger.log: "=== Retry Test Results ===" - - lambda: |- - ESP_LOGI("test", "Simple retry counter: %d (expected 2)", id(simple_retry_counter)); - ESP_LOGI("test", "Backoff retry counter: %d (expected 4)", id(backoff_retry_counter)); - ESP_LOGI("test", "Immediate done counter: %d (expected 1)", id(immediate_done_counter)); - ESP_LOGI("test", "Cancel retry counter: %d (expected 2-4)", id(cancel_retry_counter)); - ESP_LOGI("test", "Empty name retry counter: %d (expected 1-2)", id(empty_name_retry_counter)); - ESP_LOGI("test", "Component retry counter: %d (expected 2)", id(script_retry_counter)); - ESP_LOGI("test", "Multiple same name counter: %d (expected 20+)", id(multiple_same_name_counter)); - ESP_LOGI("test", "Const char retry counter: %d (expected 1)", id(const_char_retry_counter)); - ESP_LOGI("test", "Static char retry counter: %d (expected 1)", id(static_char_retry_counter)); - ESP_LOGI("test", "All retry tests completed"); diff --git a/tests/integration/test_scheduler_numeric_id_test.py b/tests/integration/test_scheduler_numeric_id_test.py index c1958db685..3591e3014e 100644 --- a/tests/integration/test_scheduler_numeric_id_test.py +++ b/tests/integration/test_scheduler_numeric_id_test.py @@ -18,7 +18,6 @@ async def test_scheduler_numeric_id_test( # Track counts timeout_count = 0 interval_count = 0 - retry_count = 0 defer_count = 0 # Events for each test completion @@ -32,8 +31,6 @@ async def test_scheduler_numeric_id_test( component_interval_fired = asyncio.Event() zero_id_timeout_fired = asyncio.Event() max_id_timeout_fired = asyncio.Event() - numeric_retry_done = asyncio.Event() - numeric_retry_cancelled = asyncio.Event() numeric_defer_7001_fired = asyncio.Event() numeric_defer_7002_fired = asyncio.Event() numeric_defer_cancelled = asyncio.Event() @@ -41,11 +38,10 @@ async def test_scheduler_numeric_id_test( # Track interval counts numeric_interval_count = 0 - numeric_retry_count = 0 def on_log_line(line: str) -> None: - nonlocal timeout_count, interval_count, retry_count, defer_count - nonlocal numeric_interval_count, numeric_retry_count + nonlocal timeout_count, interval_count, defer_count + nonlocal numeric_interval_count # Strip ANSI color codes clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) @@ -97,18 +93,6 @@ async def test_scheduler_numeric_id_test( max_id_timeout_fired.set() timeout_count += 1 - # Check for numeric retry tests - elif "Numeric retry 6001 attempt" in clean_line: - match = re.search(r"attempt (\d+)", clean_line) - if match: - numeric_retry_count = int(match.group(1)) - - elif "Numeric retry 6001 done" in clean_line: - numeric_retry_done.set() - - elif "Cancelled numeric retry 6002" in clean_line: - numeric_retry_cancelled.set() - # Check for numeric defer tests elif "Component numeric defer 7001 fired" in clean_line: numeric_defer_7001_fired.set() @@ -122,14 +106,13 @@ async def test_scheduler_numeric_id_test( # Check for final results elif "Final results" in clean_line: match = re.search( - r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+), Defers: (\d+)", + r"Timeouts: (\d+), Intervals: (\d+), Defers: (\d+)", clean_line, ) if match: timeout_count = int(match.group(1)) interval_count = int(match.group(2)) - retry_count = int(match.group(3)) - defer_count = int(match.group(4)) + defer_count = int(match.group(3)) final_results_logged.set() async with ( @@ -200,23 +183,6 @@ async def test_scheduler_numeric_id_test( except TimeoutError: pytest.fail("Max ID timeout did not fire within 0.5 seconds") - # Wait for numeric retry tests - try: - await asyncio.wait_for(numeric_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Numeric retry 6001 did not complete. Count: {numeric_retry_count}" - ) - - assert numeric_retry_count >= 2, ( - f"Expected at least 2 numeric retry attempts, got {numeric_retry_count}" - ) - - # Verify numeric retry was cancelled - assert numeric_retry_cancelled.is_set(), ( - "Numeric retry 6002 should have been cancelled" - ) - # Wait for numeric defer tests try: await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5) @@ -245,7 +211,4 @@ async def test_scheduler_numeric_id_test( assert interval_count >= 3, ( f"Expected at least 3 interval fires, got {interval_count}" ) - assert retry_count >= 2, ( - f"Expected at least 2 retry attempts, got {retry_count}" - ) assert defer_count >= 2, f"Expected at least 2 defer fires, got {defer_count}" diff --git a/tests/integration/test_scheduler_retry_test.py b/tests/integration/test_scheduler_retry_test.py deleted file mode 100644 index 910034e5bb..0000000000 --- a/tests/integration/test_scheduler_retry_test.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Test scheduler retry functionality.""" - -import asyncio -import re - -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_retry_test( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that scheduler retry functionality works correctly.""" - # Track test progress - simple_retry_done = asyncio.Event() - backoff_retry_done = asyncio.Event() - immediate_done_done = asyncio.Event() - cancel_retry_done = asyncio.Event() - empty_name_retry_done = asyncio.Event() - component_retry_done = asyncio.Event() - multiple_name_done = asyncio.Event() - const_char_done = asyncio.Event() - static_char_done = asyncio.Event() - test_complete = asyncio.Event() - - # Track retry counts - simple_retry_count = 0 - backoff_retry_count = 0 - immediate_done_count = 0 - cancel_retry_count = 0 - empty_name_retry_count = 0 - component_retry_count = 0 - multiple_name_count = 0 - const_char_retry_count = 0 - static_char_retry_count = 0 - - # Track specific test results - cancel_result = None - empty_cancel_result = None - backoff_intervals = [] - - def on_log_line(line: str) -> None: - nonlocal simple_retry_count, backoff_retry_count, immediate_done_count - nonlocal cancel_retry_count, empty_name_retry_count, component_retry_count - nonlocal multiple_name_count, const_char_retry_count, static_char_retry_count - nonlocal cancel_result, empty_cancel_result - - # Strip ANSI color codes - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - - # Simple retry test - if "Simple retry attempt" in clean_line: - if match := re.search(r"Simple retry attempt (\d+)", clean_line): - simple_retry_count = int(match.group(1)) - - elif "Simple retry succeeded on attempt" in clean_line: - simple_retry_done.set() - - # Backoff retry test - elif "Backoff retry attempt" in clean_line: - if match := re.search( - r"Backoff retry attempt (\d+).*interval=(\d+)ms", clean_line - ): - backoff_retry_count = int(match.group(1)) - interval = int(match.group(2)) - if backoff_retry_count > 1: # Skip first (immediate) call - backoff_intervals.append(interval) - - elif "Backoff retry completed" in clean_line: - backoff_retry_done.set() - - # Immediate done test - elif "Immediate done retry called" in clean_line: - immediate_done_count += 1 - immediate_done_done.set() - - # Cancel retry test - elif "Cancel test retry attempt" in clean_line: - cancel_retry_count += 1 - - elif "Retry cancellation result:" in clean_line: - cancel_result = "true" in clean_line - cancel_retry_done.set() - - # Empty name retry test - elif "Empty name retry attempt" in clean_line: - if match := re.search(r"Empty name retry attempt (\d+)", clean_line): - empty_name_retry_count = int(match.group(1)) - - elif "Empty name retry cancel result:" in clean_line: - empty_cancel_result = "true" in clean_line - - elif "Empty name retry ran" in clean_line: - empty_name_retry_done.set() - - # Component retry test - elif "Component retry attempt" in clean_line: - if match := re.search(r"Component retry attempt (\d+)", clean_line): - component_retry_count = int(match.group(1)) - if component_retry_count >= 2: - component_retry_done.set() - - # Multiple same name test - elif "Second duplicate retry attempt" in clean_line: - if match := re.search(r"counter=(\d+)", clean_line): - multiple_name_count = int(match.group(1)) - if multiple_name_count >= 20: - multiple_name_done.set() - - # Const char retry test - elif "Const char retry" in clean_line: - if match := re.search(r"Const char retry (\d+)", clean_line): - const_char_retry_count = int(match.group(1)) - const_char_done.set() - - # Static const char retry test - elif "Static const char retry" in clean_line: - if match := re.search(r"Static const char retry (\d+)", clean_line): - static_char_retry_count = int(match.group(1)) - static_char_done.set() - - elif "Static cancel result:" in clean_line: - # This is part of test 9, but we don't track it separately - pass - - # Test completion - elif "All retry tests completed" in clean_line: - test_complete.set() - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "scheduler-retry-test" - - # Wait for simple retry test - try: - await asyncio.wait_for(simple_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Simple retry test did not complete. Count: {simple_retry_count}" - ) - - assert simple_retry_count == 2, ( - f"Expected 2 simple retry attempts, got {simple_retry_count}" - ) - - # Wait for backoff retry test - try: - await asyncio.wait_for(backoff_retry_done.wait(), timeout=3.0) - except TimeoutError: - pytest.fail( - f"Backoff retry test did not complete. Count: {backoff_retry_count}" - ) - - assert backoff_retry_count == 4, ( - f"Expected 4 backoff retry attempts, got {backoff_retry_count}" - ) - - # Verify backoff intervals (allowing for timing variations) - assert len(backoff_intervals) >= 2, ( - f"Expected at least 2 intervals, got {len(backoff_intervals)}" - ) - if len(backoff_intervals) >= 3: - # First interval should be ~50ms (very wide tolerance for heavy system load) - assert 20 <= backoff_intervals[0] <= 150, ( - f"First interval {backoff_intervals[0]}ms not ~50ms" - ) - # Second interval should be ~100ms (50ms * 2.0) - assert 50 <= backoff_intervals[1] <= 250, ( - f"Second interval {backoff_intervals[1]}ms not ~100ms" - ) - # Third interval should be ~200ms (100ms * 2.0) - assert 100 <= backoff_intervals[2] <= 500, ( - f"Third interval {backoff_intervals[2]}ms not ~200ms" - ) - - # Wait for immediate done test - try: - await asyncio.wait_for(immediate_done_done.wait(), timeout=3.0) - except TimeoutError: - pytest.fail( - f"Immediate done test did not complete. Count: {immediate_done_count}" - ) - - assert immediate_done_count == 1, ( - f"Expected 1 immediate done call, got {immediate_done_count}" - ) - - # Wait for cancel retry test - try: - await asyncio.wait_for(cancel_retry_done.wait(), timeout=3.0) - except TimeoutError: - pytest.fail( - f"Cancel retry test did not complete. Count: {cancel_retry_count}" - ) - - assert cancel_result is True, "Retry cancellation should have succeeded" - assert 2 <= cancel_retry_count <= 5, ( - f"Expected 2-5 cancel retry attempts before cancellation, got {cancel_retry_count}" - ) - - # Wait for empty name retry test - try: - await asyncio.wait_for(empty_name_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Empty name retry test did not complete. Count: {empty_name_retry_count}" - ) - - # Empty name retry should run at least once before being cancelled - assert 1 <= empty_name_retry_count <= 3, ( - f"Expected 1-3 empty name retry attempts, got {empty_name_retry_count}" - ) - assert empty_cancel_result is True, ( - "Empty name retry cancel should have succeeded" - ) - - # Wait for component retry test - try: - await asyncio.wait_for(component_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Component retry test did not complete. Count: {component_retry_count}" - ) - - assert component_retry_count >= 2, ( - f"Expected at least 2 component retry attempts, got {component_retry_count}" - ) - - # Wait for multiple same name test - try: - await asyncio.wait_for(multiple_name_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Multiple same name test did not complete. Count: {multiple_name_count}" - ) - - # Should be 20+ (only second retry should run) - assert multiple_name_count >= 20, ( - f"Expected multiple name count >= 20 (second retry only), got {multiple_name_count}" - ) - - # Wait for const char retry test - try: - await asyncio.wait_for(const_char_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Const char retry test did not complete. Count: {const_char_retry_count}" - ) - - assert const_char_retry_count == 1, ( - f"Expected 1 const char retry call, got {const_char_retry_count}" - ) - - # Wait for static char retry test - try: - await asyncio.wait_for(static_char_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Static char retry test did not complete. Count: {static_char_retry_count}" - ) - - assert static_char_retry_count == 1, ( - f"Expected 1 static char retry call, got {static_char_retry_count}" - ) - - # Wait for test completion - try: - await asyncio.wait_for(test_complete.wait(), timeout=1.0) - except TimeoutError: - pytest.fail("Test did not complete within timeout") From 49a1d2bb1b18583c6d75bf8fcdaccca30ebe5889 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 15 Jul 2026 23:08:14 -0500 Subject: [PATCH 2166/2214] [veml3235] Overhaul auto-gain and fix latent config bugs (#17551) Co-authored-by: Claude Fable 5 --- esphome/components/veml3235/sensor.py | 27 ++- esphome/components/veml3235/veml3235.cpp | 283 +++++++++++------------ esphome/components/veml3235/veml3235.h | 42 ++-- 3 files changed, 178 insertions(+), 174 deletions(-) diff --git a/esphome/components/veml3235/sensor.py b/esphome/components/veml3235/sensor.py index 862fac302f..08d3685d1f 100644 --- a/esphome/components/veml3235/sensor.py +++ b/esphome/components/veml3235/sensor.py @@ -22,13 +22,13 @@ veml3235_ns = cg.esphome_ns.namespace("veml3235") VEML3235Sensor = veml3235_ns.class_( "VEML3235Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) -VEML3235IntegrationTime = veml3235_ns.enum("VEML3235IntegrationTime") +VEML3235ComponentIntegrationTime = veml3235_ns.enum("VEML3235ComponentIntegrationTime") VEML3235_INTEGRATION_TIMES = { - "50ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_50MS, - "100ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_100MS, - "200ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_200MS, - "400ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_400MS, - "800ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_800MS, + "50ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_50MS, + "100ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_100MS, + "200ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_200MS, + "400ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_400MS, + "800ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_800MS, } VEML3235ComponentDigitalGain = veml3235_ns.enum("VEML3235ComponentDigitalGain") DIGITAL_GAINS = { @@ -40,10 +40,18 @@ GAINS = { "1X": VEML3235ComponentGain.VEML3235_GAIN_1X, "2X": VEML3235ComponentGain.VEML3235_GAIN_2X, "4X": VEML3235ComponentGain.VEML3235_GAIN_4X, - "AUTO": VEML3235ComponentGain.VEML3235_GAIN_AUTO, } -CONFIG_SCHEMA = ( + +def _validate_auto_gain_thresholds(config): + if config[CONF_AUTO_GAIN_THRESHOLD_LOW] >= config[CONF_AUTO_GAIN_THRESHOLD_HIGH]: + raise cv.Invalid( + f"'{CONF_AUTO_GAIN_THRESHOLD_LOW}' must be less than '{CONF_AUTO_GAIN_THRESHOLD_HIGH}'" + ) + return config + + +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( VEML3235Sensor, unit_of_measurement=UNIT_LUX, @@ -67,7 +75,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x10)) + .extend(i2c.i2c_device_schema(0x10)), + _validate_auto_gain_thresholds, ) diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 59892936b0..b3170469b9 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -6,6 +6,16 @@ namespace esphome::veml3235 { static const char *const TAG = "veml3235.sensor"; +// ADC counts at or above this value (98% of full scale) are treated as clipped: the true light level cannot +// be estimated from such a reading, so auto-gain restarts from minimum sensitivity instead +static const uint16_t CLIPPED_COUNTS = 64224; + +// Maximum sensitivity multiplier: integration time 800 ms (16x) * gain 4x * digital gain 2x +static const uint16_t MAX_SENSITIVITY_FACTOR = 128; + +// At most one restart from clipping plus one proportional adjustment per update cycle +static const uint8_t MAX_ADJUSTMENTS_PER_UPDATE = 2; + void VEML3235Sensor::setup() { uint8_t device_id[] = {0, 0}; if (!this->refresh_config_reg()) { @@ -22,186 +32,156 @@ void VEML3235Sensor::setup() { } } -bool VEML3235Sensor::refresh_config_reg(bool force_on) { - uint16_t data = this->power_on_ || force_on ? 0 : SHUTDOWN_BITS; +bool VEML3235Sensor::refresh_config_reg() { + uint16_t data = 0x1; // mandatory 1 per RM; shutdown bits cleared (device powered on) - data |= (uint16_t(this->integration_time_ << CONFIG_REG_IT_BIT)); - data |= (uint16_t(this->digital_gain_ << CONFIG_REG_DG_BIT)); - data |= (uint16_t(this->gain_ << CONFIG_REG_G_BIT)); - data |= 0x1; // mandatory 1 here per RM + data |= (uint16_t(this->integration_time_) << CONFIG_REG_IT_BIT); + data |= (uint16_t(this->digital_gain_) << CONFIG_REG_DG_BIT); + data |= (uint16_t(this->gain_) << CONFIG_REG_G_BIT); ESP_LOGVV(TAG, "Writing 0x%.4x to register 0x%.2x", data, CONFIG_REG); return this->write_byte_16(CONFIG_REG, data); } -float VEML3235Sensor::read_lx_() { - if (!this->power_on_) { // if off, turn on - if (!this->refresh_config_reg(true)) { - ESP_LOGW(TAG, "Turning on failed"); - this->status_set_warning(); - return NAN; - } - delay(4); // from RM: a wait time of 4 ms should be observed before the first measurement is picked up, to allow - // for a correct start of the signal processor and oscillator +void VEML3235Sensor::update() { + if (this->measurement_in_progress_) { + ESP_LOGV(TAG, "'%s': Previous measurement still in progress; skipping update", this->get_name().c_str()); + return; } + this->measurement_in_progress_ = true; + this->read_and_publish_(MAX_ADJUSTMENTS_PER_UPDATE); +} +void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { uint8_t als_regs[] = {0, 0}; if ((this->read_register(ALS_REG, als_regs, sizeof als_regs) != i2c::ERROR_OK)) { this->status_set_warning(); - return NAN; + this->publish_state(NAN); + this->measurement_in_progress_ = false; + return; } this->status_clear_warning(); - float als_raw_value_multiplier = LUX_MULTIPLIER_BASE; - uint16_t als_raw_value = encode_uint16(als_regs[1], als_regs[0]); - // determine multiplier value based on gains and integration time - if (this->digital_gain_ == VEML3235_DIGITAL_GAIN_1X) { - als_raw_value_multiplier *= 2; - } - switch (this->gain_) { - case VEML3235_GAIN_1X: - als_raw_value_multiplier *= 4; - break; - case VEML3235_GAIN_2X: - als_raw_value_multiplier *= 2; - break; - default: - break; - } - switch (this->integration_time_) { - case VEML3235_INTEGRATION_TIME_50MS: - als_raw_value_multiplier *= 16; - break; - case VEML3235_INTEGRATION_TIME_100MS: - als_raw_value_multiplier *= 8; - break; - case VEML3235_INTEGRATION_TIME_200MS: - als_raw_value_multiplier *= 4; - break; - case VEML3235_INTEGRATION_TIME_400MS: - als_raw_value_multiplier *= 2; - break; - default: - break; - } - // finally, determine and return the actual lux value - float lx = float(als_raw_value) * als_raw_value_multiplier; - ESP_LOGVV(TAG, "'%s': ALS raw = %u, multiplier = %.5f", this->get_name().c_str(), als_raw_value, - als_raw_value_multiplier); - ESP_LOGD(TAG, "'%s': Illuminance = %.4flx", this->get_name().c_str(), lx); + uint16_t als_counts = encode_uint16(als_regs[1], als_regs[0]); - if (!this->power_on_) { // turn off if required - if (!this->refresh_config_reg()) { - ESP_LOGW(TAG, "Turning off failed"); - this->status_set_warning(); + if (this->auto_gain_ && adjustments_left > 0) { + // A sample integrated with the previous settings may still be in the data register after the + // configuration changes, so wait out the old integration period plus two new ones before re-reading + const uint32_t old_integration_time_ms = this->integration_time_ms_(); + if (this->adjust_sensitivity_(als_counts)) { + const uint32_t wait_ms = old_integration_time_ms + 2 * this->integration_time_ms_(); + this->set_timeout("reread", wait_ms, + [this, adjustments_left]() { this->read_and_publish_(adjustments_left - 1); }); + return; } } - if (this->auto_gain_) { - this->adjust_gain_(als_raw_value); - } - - return lx; + float lux = this->counts_to_lux_(als_counts); + ESP_LOGVV(TAG, "'%s': ALS counts = %u, sensitivity = %ux", this->get_name().c_str(), als_counts, + this->sensitivity_factor_()); + ESP_LOGV(TAG, "'%s': Illuminance = %.4flx", this->get_name().c_str(), lux); + this->publish_state(lux); + this->measurement_in_progress_ = false; } -void VEML3235Sensor::adjust_gain_(const uint16_t als_raw_value) { - if ((als_raw_value > UINT16_MAX * this->auto_gain_threshold_low_) && - (als_raw_value < UINT16_MAX * this->auto_gain_threshold_high_)) { - return; +float VEML3235Sensor::counts_to_lux_(uint16_t counts) const { + float resolution = LUX_MULTIPLIER_BASE * (float(MAX_SENSITIVITY_FACTOR) / float(this->sensitivity_factor_())); + return float(counts) * resolution; +} + +uint8_t VEML3235Sensor::gain_factor_() const { + switch (this->gain_) { + case VEML3235_GAIN_4X: + return 4; + case VEML3235_GAIN_2X: + return 2; + default: + return 1; + } +} + +uint16_t VEML3235Sensor::sensitivity_factor_() const { + const uint8_t digital_gain_factor = this->digital_gain_ == VEML3235_DIGITAL_GAIN_2X ? 2 : 1; + return (1 << this->integration_time_) * this->gain_factor_() * digital_gain_factor; +} + +void VEML3235Sensor::set_sensitivity_factor_(uint16_t factor) { + // The factor is a power of two in [1, 128]. Prefer integration time (improves the signal-to-noise ratio), + // then analog gain; digital gain is a plain doubling of the output and is used only as a last resort. + uint8_t it_exponent = 0; // integration time is 2^n * 50 ms + while (it_exponent < VEML3235_INTEGRATION_TIME_800MS && (1u << it_exponent) < factor) { + it_exponent++; + } + this->integration_time_ = static_cast(it_exponent); + factor >>= it_exponent; + + if (factor >= 4) { + this->gain_ = VEML3235_GAIN_4X; + factor >>= 2; + } else if (factor == 2) { + this->gain_ = VEML3235_GAIN_2X; + factor >>= 1; + } else { + this->gain_ = VEML3235_GAIN_1X; } - if (als_raw_value >= UINT16_MAX * 0.9) { // over-saturated, reset all gains and start over - this->digital_gain_ = VEML3235_DIGITAL_GAIN_1X; - this->gain_ = VEML3235_GAIN_1X; - this->integration_time_ = VEML3235_INTEGRATION_TIME_50MS; - this->refresh_config_reg(); - return; + this->digital_gain_ = factor >= 2 ? VEML3235_DIGITAL_GAIN_2X : VEML3235_DIGITAL_GAIN_1X; +} + +bool VEML3235Sensor::adjust_sensitivity_(uint16_t counts) { + // Test for clipping before the window test: with an upper threshold configured at or above the clip + // point, a saturated reading would otherwise count as "in window" and sensitivity would never recover + const bool clipped = counts >= CLIPPED_COUNTS; + const uint16_t low = uint16_t(UINT16_MAX * this->auto_gain_threshold_low_); + const uint16_t high = uint16_t(UINT16_MAX * this->auto_gain_threshold_high_); + if (!clipped && counts >= low && counts <= high) { + return false; } - if (this->gain_ != VEML3235_GAIN_4X) { // increase gain if possible - switch (this->gain_) { - case VEML3235_GAIN_1X: - this->gain_ = VEML3235_GAIN_2X; - break; - case VEML3235_GAIN_2X: - this->gain_ = VEML3235_GAIN_4X; - break; - default: - break; + const uint16_t current_factor = this->sensitivity_factor_(); + uint16_t new_factor; + if (clipped) { + new_factor = 1; + } else if (counts == 0) { + new_factor = MAX_SENSITIVITY_FACTOR; + } else { + // Counts scale linearly with the sensitivity factor: in one step, pick the power of two that puts the + // next reading closest below the middle of the configured window. Rounding down means the target is + // never overshot, which also keeps the sensitivity stable when the window is narrower than one step. + float desired = float(current_factor) * ((float(low) + float(high)) * 0.5f / float(counts)); + desired = clamp(desired, 1.0f, float(MAX_SENSITIVITY_FACTOR)); + new_factor = 1; + while (new_factor * 2 <= uint16_t(desired)) { + new_factor *= 2; } - this->refresh_config_reg(); - return; } - // gain is maxed out; reset it and try to increase digital gain - if (this->digital_gain_ != VEML3235_DIGITAL_GAIN_2X) { // increase digital gain if possible - this->digital_gain_ = VEML3235_DIGITAL_GAIN_2X; - this->gain_ = VEML3235_GAIN_1X; - this->refresh_config_reg(); - return; + + if (new_factor == current_factor) { + return false; } - // digital gain is maxed out; reset it and try to increase integration time - if (this->integration_time_ != VEML3235_INTEGRATION_TIME_800MS) { // increase integration time if possible - switch (this->integration_time_) { - case VEML3235_INTEGRATION_TIME_50MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_100MS; - break; - case VEML3235_INTEGRATION_TIME_100MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_200MS; - break; - case VEML3235_INTEGRATION_TIME_200MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_400MS; - break; - case VEML3235_INTEGRATION_TIME_400MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_800MS; - break; - default: - break; - } - this->digital_gain_ = VEML3235_DIGITAL_GAIN_1X; - this->gain_ = VEML3235_GAIN_1X; - this->refresh_config_reg(); - return; + + const VEML3235ComponentIntegrationTime old_integration_time = this->integration_time_; + const VEML3235ComponentGain old_gain = this->gain_; + const VEML3235ComponentDigitalGain old_digital_gain = this->digital_gain_; + + this->set_sensitivity_factor_(new_factor); + if (!this->refresh_config_reg()) { + // Keep our state consistent with the device, which still has the old configuration + this->integration_time_ = old_integration_time; + this->gain_ = old_gain; + this->digital_gain_ = old_digital_gain; + this->status_set_warning(); + return false; } + + ESP_LOGV(TAG, "'%s': Sensitivity adjusted from %ux to %ux (ALS counts = %u)", this->get_name().c_str(), + current_factor, new_factor, counts); + return true; } void VEML3235Sensor::dump_config() { - uint8_t digital_gain = 1; - uint8_t gain = 1; - uint16_t integration_time = 0; - - if (this->digital_gain_ == VEML3235_DIGITAL_GAIN_2X) { - digital_gain = 2; - } - switch (this->gain_) { - case VEML3235_GAIN_2X: - gain = 2; - break; - case VEML3235_GAIN_4X: - gain = 4; - break; - default: - break; - } - switch (this->integration_time_) { - case VEML3235_INTEGRATION_TIME_50MS: - integration_time = 50; - break; - case VEML3235_INTEGRATION_TIME_100MS: - integration_time = 100; - break; - case VEML3235_INTEGRATION_TIME_200MS: - integration_time = 200; - break; - case VEML3235_INTEGRATION_TIME_400MS: - integration_time = 400; - break; - case VEML3235_INTEGRATION_TIME_800MS: - integration_time = 800; - break; - default: - break; - } + const uint8_t digital_gain = this->digital_gain_ == VEML3235_DIGITAL_GAIN_2X ? 2 : 1; LOG_SENSOR("", "VEML3235", this); LOG_I2C_DEVICE(this); @@ -212,8 +192,9 @@ void VEML3235Sensor::dump_config() { ESP_LOGCONFIG(TAG, " Auto-gain enabled: %s", YESNO(this->auto_gain_)); if (this->auto_gain_) { ESP_LOGCONFIG(TAG, - " Auto-gain upper threshold: %f%%\n" - " Auto-gain lower threshold: %f%%\n" + " Auto-gain thresholds:\n" + " Upper: %.0f%%\n" + " Lower: %.0f%%\n" " Values below will be used as initial values only", this->auto_gain_threshold_high_ * 100.0f, this->auto_gain_threshold_low_ * 100.0f); } @@ -221,7 +202,7 @@ void VEML3235Sensor::dump_config() { " Digital gain: %uX\n" " Gain: %uX\n" " Integration time: %ums", - digital_gain, gain, integration_time); + digital_gain, this->gain_factor_(), this->integration_time_ms_()); } } // namespace esphome::veml3235 diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index cda6d177aa..c19fc17b65 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -16,6 +15,10 @@ static const uint8_t ID_REG = 0x09; // Bit offsets within CONFIG_REG // +// The device expects the low data byte first while write_byte_16() sends the high byte first, so the 16-bit +// configuration word used here is byte-swapped relative to the datasheet: datasheet low-byte bits are word +// bits 15:8 here and datasheet high-byte bits are word bits 7:0. +// static const uint8_t CONFIG_REG_IT_BIT = 12; static const uint8_t CONFIG_REG_DG_BIT = 5; static const uint8_t CONFIG_REG_G_BIT = 3; @@ -23,18 +26,17 @@ static const uint8_t CONFIG_REG_G_BIT = 3; // Other important constants // static const uint8_t DEVICE_ID = 0x35; -static const uint16_t SHUTDOWN_BITS = 0x0018; -// Base multiplier value for lux computation +// Resolution (lx/count) at maximum sensitivity (integration time 800 ms, gain 4x, digital gain 2x) // -static const float LUX_MULTIPLIER_BASE = 0.00213; +static const float LUX_MULTIPLIER_BASE = 0.00213f; // Enum for conversion/integration time settings for the VEML3235. // // Specific values of the enum constants are register values taken from the VEML3235 datasheet. // Longer times mean more accurate results, but will take more energy/more time. // -enum VEML3235ComponentIntegrationTime { +enum VEML3235ComponentIntegrationTime : uint8_t { VEML3235_INTEGRATION_TIME_50MS = 0b000, VEML3235_INTEGRATION_TIME_100MS = 0b001, VEML3235_INTEGRATION_TIME_200MS = 0b010, @@ -45,7 +47,7 @@ enum VEML3235ComponentIntegrationTime { // Enum for digital gain settings for the VEML3235. // Higher values are better for low light situations, but can increase noise. // -enum VEML3235ComponentDigitalGain { +enum VEML3235ComponentDigitalGain : uint8_t { VEML3235_DIGITAL_GAIN_1X = 0b0, VEML3235_DIGITAL_GAIN_2X = 0b1, }; @@ -53,7 +55,7 @@ enum VEML3235ComponentDigitalGain { // Enum for gain settings for the VEML3235. // Higher values are better for low light situations, but can increase noise. // -enum VEML3235ComponentGain { +enum VEML3235ComponentGain : uint8_t { VEML3235_GAIN_1X = 0b00, VEML3235_GAIN_2X = 0b01, VEML3235_GAIN_4X = 0b11, @@ -63,7 +65,7 @@ class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, pub public: void setup() override; void dump_config() override; - void update() override { this->publish_state(this->read_lx_()); } + void update() override; // Used by ESPHome framework. Does NOT actually set the value on the device. void set_auto_gain(bool auto_gain) { this->auto_gain_ = auto_gain; } @@ -73,7 +75,6 @@ class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, pub void set_auto_gain_threshold_low(float auto_gain_threshold_low) { this->auto_gain_threshold_low_ = auto_gain_threshold_low; } - void set_power_on(bool power_on) { this->power_on_ = power_on; } void set_digital_gain(VEML3235ComponentDigitalGain digital_gain) { this->digital_gain_ = digital_gain; } void set_gain(VEML3235ComponentGain gain) { this->gain_ = gain; } void set_integration_time(VEML3235ComponentIntegrationTime integration_time) { @@ -88,19 +89,32 @@ class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, pub VEML3235ComponentIntegrationTime integration_time() { return this->integration_time_; } // Updates the configuration register on the device - bool refresh_config_reg(bool force_on = false); + bool refresh_config_reg(); protected: - float read_lx_(); - void adjust_gain_(uint16_t als_raw_value); + // One measurement pass: reads the ALS counts, possibly adjusts the sensitivity and schedules a re-read, + // otherwise publishes the result + void read_and_publish_(uint8_t adjustments_left); + // Chooses a new sensitivity for the given ALS reading and writes it to the device. + // Returns true only if the device configuration was changed. + bool adjust_sensitivity_(uint16_t counts); + float counts_to_lux_(uint16_t counts) const; - bool auto_gain_{true}; - bool power_on_{true}; + // Overall sensitivity multiplier (1x-128x, always a power of two) relative to the least sensitive + // configuration (integration time 50 ms, gain 1x, digital gain 1x). ALS counts scale linearly with it. + uint16_t sensitivity_factor_() const; + void set_sensitivity_factor_(uint16_t factor); + uint8_t gain_factor_() const; + uint16_t integration_time_ms_() const { return 50 << this->integration_time_; } + + // Members are ordered largest to smallest to minimize padding float auto_gain_threshold_high_{0.9}; float auto_gain_threshold_low_{0.2}; VEML3235ComponentDigitalGain digital_gain_{VEML3235_DIGITAL_GAIN_1X}; VEML3235ComponentGain gain_{VEML3235_GAIN_1X}; VEML3235ComponentIntegrationTime integration_time_{VEML3235_INTEGRATION_TIME_50MS}; + bool auto_gain_{true}; + bool measurement_in_progress_{false}; }; } // namespace esphome::veml3235 From b12d392e02fed88fc1df881e460a78d2c8d11820 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 19:06:30 -1000 Subject: [PATCH 2167/2214] [esp32_ble] Remove deprecated ESPBTUUID::to_string() (#17590) --- esphome/components/esp32_ble/ble_uuid.cpp | 6 ------ esphome/components/esp32_ble/ble_uuid.h | 3 --- 2 files changed, 9 deletions(-) diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index 886f8237ad..3ce05b4310 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -181,12 +181,6 @@ const char *ESPBTUUID::to_str(std::span output) const { return output.data(); } } -std::string ESPBTUUID::to_string() const { - char buf[UUID_STR_LEN]; - this->to_str(buf); - return std::string(buf); -} - } // namespace esphome::esp32_ble #endif // USE_ESP32_BLE_UUID diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 503fde6945..20b8f4e35a 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -46,9 +46,6 @@ class ESPBTUUID { esp_bt_uuid_t get_uuid() const; - // Remove before 2026.8.0 - ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const; // NOLINT const char *to_str(std::span output) const; protected: From f336c4517714d399af813c3da76984b4360c38d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 19:06:38 -1000 Subject: [PATCH 2168/2214] [water_heater] Remove deprecated WaterHeaterCall::get_state() (#17592) --- esphome/components/water_heater/water_heater.h | 4 ---- 1 file changed, 4 deletions(-) diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index a1e1ca10a6..dfec26859f 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -90,10 +90,6 @@ class WaterHeaterCall { float get_target_temperature() const { return this->target_temperature_; } float get_target_temperature_low() const { return this->target_temperature_low_; } float get_target_temperature_high() const { return this->target_temperature_high_; } - /// Get state flags value - ESPDEPRECATED("get_state() is deprecated, use get_away() and get_on() instead. (Removed in 2026.8.0)", "2026.2.0") - uint32_t get_state() const { return this->state_; } - optional get_away() const { if (this->state_mask_ & WATER_HEATER_STATE_AWAY) { return (this->state_ & WATER_HEATER_STATE_AWAY) != 0; From 880cb1db4388e9a15e82789dac3c4f28ec339a4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 19:06:49 -1000 Subject: [PATCH 2169/2214] [voice_assistant] Remove deprecated Timer::to_string() (#17591) --- esphome/components/voice_assistant/voice_assistant.h | 6 ------ 1 file changed, 6 deletions(-) diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index dd9d205aff..d46b089c2e 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -81,12 +81,6 @@ struct Timer { this->id.c_str(), this->name.c_str(), this->total_seconds, this->seconds_left, YESNO(this->is_active)); return buffer.data(); } - // Remove before 2026.8.0 - ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const { // NOLINT - char buffer[TO_STR_BUFFER_SIZE]; - return this->to_str(buffer); - } }; struct WakeWord { From ec3f7ec16e1c4c34540879eaa229a614989ca5c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 19:07:26 -1000 Subject: [PATCH 2170/2214] [api] Remove outdated API version warning (#17593) --- esphome/components/api/api_connection.cpp | 6 ------ 1 file changed, 6 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 880b7cc404..1f7f59128a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1746,12 +1746,6 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(), this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_); - // TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal) - if (!this->client_supports_api_version(1, 14)) { - ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(), - this->client_api_version_major_, this->client_api_version_minor_); - } - HelloResponse resp; resp.api_version_major = 1; resp.api_version_minor = 14; From f88621f2e169b2b7d3ea8a0af2ea244d023563cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 19:12:06 -1000 Subject: [PATCH 2171/2214] [network] Remove deprecated IPAddress::str() (#17589) --- esphome/components/network/ip_address.h | 27 ------------------------- 1 file changed, 27 deletions(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index d8a127f4a0..ec1a8c7a07 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -71,15 +71,6 @@ struct IPAddress { bool is_ip4() const { return false; } bool is_ip6() const { return this->is_set(); } bool is_multicast() const { return net_ipv6_is_addr_mcast(&ip_addr_); } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } char *str_to(char *buf) const { if (inet_ntop(AF_INET6, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr) buf[0] = '\0'; @@ -95,15 +86,6 @@ struct IPAddress { } IPAddress(const std::string &in_address) { inet_aton(in_address.c_str(), &ip_addr_); } IPAddress(const ip_addr_t *other_ip) { ip_addr_ = *other_ip; } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } /// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes. char *str_to(char *buf) const { inet_ntop(AF_INET, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE); @@ -186,15 +168,6 @@ struct IPAddress { bool is_ip4() const { return IP_IS_V4(&ip_addr_); } bool is_ip6() const { return IP_IS_V6(&ip_addr_); } bool is_multicast() const { return ip_addr_ismulticast(&ip_addr_); } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } /// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes. /// Output is lowercased per RFC 5952 (IPv6 hex digits a-f). char *str_to(char *buf) const { From 0887e01828686eefe8c57194488f8f3edc9232c5 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 16 Jul 2026 00:17:01 -0500 Subject: [PATCH 2172/2214] [improv_base] Bump Improv library to 1.2.6 (#17599) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/improv_base/__init__.py | 2 +- platformio.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index e175aa2220..5929f2b60a 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -42,4 +42,4 @@ async def setup_improv_core(var: MockObj, config: ConfigType, component: str): cg.add(var.set_next_url(_process_next_url(next_url))) cg.add_define(f"USE_{component.upper()}_NEXT_URL") - cg.add_library("improv/Improv", "1.2.4") + cg.add_library("improv/Improv", "1.2.6") diff --git a/platformio.ini b/platformio.ini index 7e8494aea6..30968e80e8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} esphome/noise-c@0.1.11 ; api - improv/Improv@1.2.4 ; improv_serial / esp32_improv + 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 https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image From 8d1a0446a2b423ce2788e356253af44091803268 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:25:37 -0400 Subject: [PATCH 2173/2214] [haier][teleinfo][hlk_fm22x][rp2040_ble] Rename enum members that collide with vendor SDK macros (#17595) --- esphome/components/haier/haier_base.cpp | 4 ++-- esphome/components/haier/haier_base.h | 8 ++++---- esphome/components/haier/hon_climate.cpp | 12 ++++++------ esphome/components/haier/hon_climate.h | 2 +- esphome/components/haier/smartair2_climate.cpp | 4 ++-- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 2 +- esphome/components/hlk_fm22x/hlk_fm22x.h | 2 +- esphome/components/rp2040_ble/rp2040_ble.cpp | 4 ++-- esphome/components/rp2040_ble/rp2040_ble.h | 4 ++-- esphome/components/teleinfo/teleinfo.cpp | 14 +++++++------- esphome/components/teleinfo/teleinfo.h | 6 +++--- 11 files changed, 31 insertions(+), 31 deletions(-) diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 74a218263d..294aa53b03 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -132,7 +132,7 @@ void HaierClimateBase::save_settings() { } bool HaierClimateBase::get_display_state() const { - return (this->display_status_ == SwitchState::ON) || (this->display_status_ == SwitchState::PENDING_ON); + return (this->display_status_ == SwitchState::SWITCH_ON) || (this->display_status_ == SwitchState::PENDING_ON); } void HaierClimateBase::set_display_state(bool state) { @@ -144,7 +144,7 @@ void HaierClimateBase::set_display_state(bool state) { } bool HaierClimateBase::get_health_mode() const { - return (this->health_mode_ == SwitchState::ON) || (this->health_mode_ == SwitchState::PENDING_ON); + return (this->health_mode_ == SwitchState::SWITCH_ON) || (this->health_mode_ == SwitchState::PENDING_ON); } void HaierClimateBase::set_health_mode(bool state) { diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index 13e8d7548d..db4c1abceb 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -147,8 +147,8 @@ class HaierClimateBase : public esphome::Component, esphome::optional message; }; enum class SwitchState { - OFF = 0b00, - ON = 0b01, + SWITCH_OFF = 0b00, + SWITCH_ON = 0b01, PENDING_OFF = 0b10, PENDING_ON = 0b11, }; @@ -157,8 +157,8 @@ class HaierClimateBase : public esphome::Component, esphome::optional action_request_; uint8_t fan_mode_speed_; uint8_t other_modes_fan_speed_; - SwitchState display_status_{SwitchState::ON}; - SwitchState health_mode_{SwitchState::OFF}; + SwitchState display_status_{SwitchState::SWITCH_ON}; + SwitchState health_mode_{SwitchState::SWITCH_OFF}; bool force_send_control_; bool forced_request_status_; bool reset_protocol_request_; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 88d446829a..881a2328cb 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -50,7 +50,7 @@ void HonClimate::set_quiet_mode_state(bool state) { this->quiet_mode_state_ = state ? SwitchState::PENDING_ON : SwitchState::PENDING_OFF; this->force_send_control_ = true; } else { - this->quiet_mode_state_ = state ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } this->settings_.quiet_mode_state = state; #ifdef USE_SWITCH @@ -63,7 +63,7 @@ void HonClimate::set_quiet_mode_state(bool state) { } bool HonClimate::get_quiet_mode_state() const { - return (this->quiet_mode_state_ == SwitchState::ON) || (this->quiet_mode_state_ == SwitchState::PENDING_ON); + return (this->quiet_mode_state_ == SwitchState::SWITCH_ON) || (this->quiet_mode_state_ == SwitchState::PENDING_ON); } esphome::optional HonClimate::get_vertical_airflow() const { @@ -513,7 +513,7 @@ void HonClimate::initialization() { } this->current_vertical_swing_ = this->settings_.last_vertiacal_swing; this->current_horizontal_swing_ = this->settings_.last_horizontal_swing; - this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } haier_protocol::HaierMessage HonClimate::get_control_message() { @@ -939,14 +939,14 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // AC just turned on from remote need to turn off display this->force_send_control_ = true; } else if ((((uint8_t) this->display_status_) & 0b10) == 0) { - this->display_status_ = disp_status ? SwitchState::ON : SwitchState::OFF; + this->display_status_ = disp_status ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } } } // Health mode if ((((uint8_t) this->health_mode_) & 0b10) == 0) { bool old_health_mode = this->get_health_mode(); - this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::ON : SwitchState::OFF; + this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; should_publish = should_publish || (old_health_mode != this->get_health_mode()); } { @@ -1008,7 +1008,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // In proper mode and not in pending state bool new_quiet_mode = packet.control.quiet_mode != 0; if (new_quiet_mode != this->get_quiet_mode_state()) { - this->quiet_mode_state_ = new_quiet_mode ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = new_quiet_mode ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; this->settings_.quiet_mode_state = new_quiet_mode; #ifdef USE_SWITCH if (this->quiet_mode_switch_ != nullptr) { diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index ba36e6a8fb..a34b4422c6 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -197,7 +197,7 @@ class HonClimate final : public HaierClimateBase { esphome::optional current_horizontal_swing_{}; HonSettings settings_{}; ESPPreferenceObject hon_rtc_; - SwitchState quiet_mode_state_{SwitchState::OFF}; + SwitchState quiet_mode_state_{SwitchState::SWITCH_OFF}; }; } // namespace esphome::haier diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index a013371649..fdb3b779e2 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -464,14 +464,14 @@ haier_protocol::HandlerError Smartair2Climate::process_status_message_(const uin // AC just turned on from remote need to turn off display this->force_send_control_ = true; } else if ((((uint8_t) this->health_mode_) & 0b10) == 0) { - this->display_status_ = disp_status ? SwitchState::ON : SwitchState::OFF; + this->display_status_ = disp_status ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } } } // Health mode if ((((uint8_t) this->health_mode_) & 0b10) == 0) { bool old_health_mode = this->get_health_mode(); - this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::ON : SwitchState::OFF; + this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; should_publish = should_publish || (old_health_mode != this->get_health_mode()); } { diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 7a0dc0690c..964d26dfbc 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -242,7 +242,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) { return; } - if (data[1] != HlkFm22xResult::SUCCESS) { + if (data[1] != HlkFm22xResult::SUCCEEDED) { ESP_LOGE(TAG, "Command <0x%.2X> failed. Error: 0x%.2X", data[0], data[1]); switch (expected) { case HlkFm22xCommand::ENROLL: diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index 34246f52f0..3bdf6e2c71 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -41,7 +41,7 @@ enum HlkFm22xNoteType { }; enum HlkFm22xResult { - SUCCESS = 0x00, + SUCCEEDED = 0x00, REJECTED = 0x01, ABORTED = 0x02, FAILED4_CAMERA = 0x04, diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index dca0cd4653..f3896f7b9c 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -48,7 +48,7 @@ void RP2040BLE::enable() { } void RP2040BLE::disable() { - if (this->state_ == BLEComponentState::DISABLED || this->state_ == BLEComponentState::OFF) { + if (this->state_ == BLEComponentState::DISABLED || this->state_ == BLEComponentState::STATE_OFF) { return; } @@ -70,7 +70,7 @@ void RP2040BLE::loop() { static const char *state_to_str(BLEComponentState state) { switch (state) { - case BLEComponentState::OFF: + case BLEComponentState::STATE_OFF: return "OFF"; case BLEComponentState::ENABLING: return "ENABLING"; diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index e9df12cfb1..a77b5fc26c 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -11,7 +11,7 @@ namespace esphome::rp2040_ble { enum class BLEComponentState : uint8_t { - OFF = 0, + STATE_OFF = 0, ENABLING, ACTIVE, DISABLING, @@ -37,7 +37,7 @@ class RP2040BLE final : public Component { btstack_packet_callback_registration_t hci_event_callback_registration_{}; btstack_packet_callback_registration_t sm_event_callback_registration_{}; - BLEComponentState state_{BLEComponentState::OFF}; + BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{true}; bool btstack_initialized_{false}; bool active_logged_{false}; diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index cd2ddbbb38..e00895d162 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -57,7 +57,7 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { */ if (buf_index_ >= (MAX_BUF_SIZE - 1)) { ESP_LOGW(TAG, "Internal buffer full"); - state_ = OFF; + state_ = STATE_OFF; return false; } buf_[buf_index_++] = received; @@ -65,18 +65,18 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { return false; } -void TeleInfo::setup() { state_ = OFF; } +void TeleInfo::setup() { state_ = STATE_OFF; } void TeleInfo::update() { - if (state_ == OFF) { + if (state_ == STATE_OFF) { buf_index_ = 0; - state_ = ON; + state_ = STATE_ON; } } void TeleInfo::loop() { switch (state_) { - case OFF: + case STATE_OFF: break; - case ON: + case STATE_ON: /* Dequeue chars until start frame (0x2) */ if (read_chars_until_(true, 0x2)) state_ = START_FRAME_RECEIVED; @@ -173,7 +173,7 @@ void TeleInfo::loop() { publish_value_(std::string(tag_), std::string(val_)); } - state_ = OFF; + state_ = STATE_OFF; break; } } diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index 83ea1474f2..4aab3bf2cd 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -40,11 +40,11 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { char val_[MAX_VAL_SIZE]; char timestamp_[MAX_TIMESTAMP_SIZE]; enum State { - OFF, - ON, + STATE_OFF, + STATE_ON, START_FRAME_RECEIVED, END_FRAME_RECEIVED, - } state_{OFF}; + } state_{STATE_OFF}; bool read_chars_until_(bool drop, uint8_t c); bool check_crc_(const char *grp, const char *grp_end); void publish_value_(const std::string &tag, const std::string &val); From d748c04b28d9248121ddda6a62a3c096d93ef670 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:25:46 -0400 Subject: [PATCH 2174/2214] [libretiny] Code fixes for upcoming LibreTiny clang-tidy scans (#17596) --- .../beken_spi_led_strip/led_strip.cpp | 53 ++++++++++--------- esphome/components/debug/debug_libretiny.cpp | 4 +- esphome/components/deep_sleep/__init__.py | 2 +- .../deep_sleep/deep_sleep_bk72xx.cpp | 12 ++--- .../deep_sleep/deep_sleep_component.h | 4 +- .../components/fastled_base/fastled_light.cpp | 2 +- .../components/fastled_base/fastled_light.h | 2 +- .../http_request/http_request_arduino.cpp | 2 +- .../http_request/http_request_arduino.h | 2 +- esphome/components/i2c/i2c_bus_arduino.cpp | 2 +- esphome/components/i2c/i2c_bus_arduino.h | 2 +- esphome/components/libretiny/hal.cpp | 2 +- esphome/components/libretiny/hal.h | 3 ++ esphome/components/libretiny/lt_component.cpp | 4 +- .../components/libretiny/preference_backend.h | 2 + .../logger/task_log_buffer_libretiny.cpp | 12 ++--- .../logger/task_log_buffer_libretiny.h | 2 +- esphome/components/mdns/mdns_libretiny.cpp | 4 +- esphome/components/nextion/nextion.h | 4 +- esphome/components/spi/spi.h | 2 +- .../uart/uart_component_libretiny.cpp | 33 ++++++------ esphome/components/wled/wled_light_effect.cpp | 2 +- esphome/components/wled/wled_light_effect.h | 2 +- 23 files changed, 85 insertions(+), 74 deletions(-) diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 4e22489844..9e14615d7a 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -37,15 +37,15 @@ namespace esphome::beken_spi_led_strip { static const char *const TAG = "beken_spi_led_strip"; -struct spi_data_t { +struct SpiData { SemaphoreHandle_t dma_tx_semaphore; volatile bool tx_in_progress; bool first_run; }; -static spi_data_t *spi_data = nullptr; +static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static void set_spi_ctrl_register(unsigned long bit, bool val) { +static void set_spi_ctrl_register(uint32_t bit, bool val) { uint32_t value = REG_READ(SPI_CTRL); if (val == 0) { value &= ~bit; @@ -55,7 +55,7 @@ static void set_spi_ctrl_register(unsigned long bit, bool val) { REG_WRITE(SPI_CTRL, value); } -static void set_spi_config_register(unsigned long bit, bool val) { +static void set_spi_config_register(uint32_t bit, bool val) { uint32_t value = REG_READ(SPI_CONFIG); if (val == 0) { value &= ~bit; @@ -67,7 +67,7 @@ static void set_spi_config_register(unsigned long bit, bool val) { void spi_dma_tx_enable(bool enable) { GDMA_CFG_ST en_cfg; - set_spi_config_register(SPI_TX_EN, enable ? 1 : 0); + set_spi_config_register(SPI_TX_EN, enable); en_cfg.channel = SPI_TX_DMA_CHANNEL; en_cfg.param = enable ? 1 : 0; sddev_control(GDMA_DEV_NAME, CMD_GDMA_SET_DMA_ENABLE, &en_cfg); @@ -110,13 +110,13 @@ static void spi_set_clock(uint32_t max_hz) { param &= ~(SPI_CKR_MASK << SPI_CKR_POSI); param |= (div << SPI_CKR_POSI); REG_WRITE(SPI_CTRL, param); - ESP_LOGD(TAG, "target frequency: %d, actual frequency: %d", max_hz, source_clk / 2 / div); + ESP_LOGD(TAG, "target frequency: %" PRIu32 ", actual frequency: %d", max_hz, source_clk / 2 / div); } void spi_dma_tx_finish_callback(unsigned int param) { spi_data->tx_in_progress = false; xSemaphoreGive(spi_data->dma_tx_semaphore); - spi_dma_tx_enable(0); + spi_dma_tx_enable(false); } void BekenSPILEDStripLightOutput::setup() { @@ -161,7 +161,7 @@ void BekenSPILEDStripLightOutput::setup() { return; } - spi_data = (spi_data_t *) calloc(1, sizeof(spi_data_t)); + spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc) if (spi_data == nullptr) { ESP_LOGE(TAG, "Cannot allocate spi_data!"); this->mark_failed(); @@ -177,20 +177,20 @@ void BekenSPILEDStripLightOutput::setup() { spi_data->first_run = true; - set_spi_ctrl_register(MSTEN, 0); - set_spi_ctrl_register(BIT_WDTH, 0); + set_spi_ctrl_register(MSTEN, false); + set_spi_ctrl_register(BIT_WDTH, false); spi_set_clock(this->spi_frequency_); - set_spi_ctrl_register(CKPOL, 0); - set_spi_ctrl_register(CKPHA, 0); - set_spi_ctrl_register(MSTEN, 1); - set_spi_ctrl_register(SPIEN, 1); + set_spi_ctrl_register(CKPOL, false); + set_spi_ctrl_register(CKPHA, false); + set_spi_ctrl_register(MSTEN, true); + set_spi_ctrl_register(SPIEN, true); - set_spi_ctrl_register(TXINT_EN, 0); - set_spi_ctrl_register(RXINT_EN, 0); - set_spi_config_register(SPI_TX_FINISH_EN, 1); - set_spi_config_register(SPI_RX_FINISH_EN, 1); - set_spi_ctrl_register(RXOVR_EN, 0); - set_spi_ctrl_register(TXOVR_EN, 0); + set_spi_ctrl_register(TXINT_EN, false); + set_spi_ctrl_register(RXINT_EN, false); + set_spi_config_register(SPI_TX_FINISH_EN, true); + set_spi_config_register(SPI_RX_FINISH_EN, true); + set_spi_ctrl_register(RXOVR_EN, false); + set_spi_ctrl_register(TXOVR_EN, false); value = REG_READ(SPI_CTRL); value &= ~CTRL_NSSMD_3; @@ -199,7 +199,7 @@ void BekenSPILEDStripLightOutput::setup() { value = GFUNC_MODE_SPI_DMA; sddev_control(GPIO_DEV_NAME, CMD_GPIO_ENABLE_SECOND, &value); - set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, 0); + set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, false); GDMA_CFG_ST en_cfg; GDMACFG_TPYES_ST init_cfg; @@ -210,7 +210,7 @@ void BekenSPILEDStripLightOutput::setup() { init_cfg.dstptr_incr = 0; init_cfg.srcptr_incr = 1; init_cfg.src_start_addr = this->dma_buf_; - init_cfg.dst_start_addr = (void *) SPI_DAT; // SPI_DMA_REG4_TXFIFO + init_cfg.dst_start_addr = (void *) SPI_DAT; // NOLINT(performance-no-int-to-ptr) SPI_DMA_REG4_TXFIFO init_cfg.channel = SPI_TX_DMA_CHANNEL; init_cfg.prio = 0; // 10 init_cfg.u.type4.src_loop_start_addr = this->dma_buf_; @@ -230,7 +230,7 @@ void BekenSPILEDStripLightOutput::setup() { en_cfg.param = 0; sddev_control(GDMA_DEV_NAME, CMD_GDMA_CFG_SRCADDR_LOOP, &en_cfg); - spi_dma_tx_enable(0); + spi_dma_tx_enable(false); value = REG_READ(SPI_CONFIG); value &= ~(0xFFF << 8); @@ -247,7 +247,8 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { // protect from refreshing too often uint32_t now = micros(); - if (*this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < *this->max_refresh_rate_) { + if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 && + (now - this->last_refresh_) < *this->max_refresh_rate_) { // try again next loop iteration, so that this change won't get lost this->schedule_show(); return; @@ -293,7 +294,7 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } spi_data->first_run = false; - spi_dma_tx_enable(1); + spi_dma_tx_enable(true); this->status_clear_warning(); } @@ -376,7 +377,7 @@ void BekenSPILEDStripLightOutput::dump_config() { " RGB Order: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, *this->max_refresh_rate_, this->num_leds_); + rgb_order, 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/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 1cc04dcbd8..55b29310a1 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -28,7 +28,7 @@ size_t DebugComponent::get_device_info_(std::span ESP_LOGD(TAG, "LibreTiny debug info:\n" " Version: %s\n" - " Chip: %s (%04x) @ %u MHz\n" + " Chip: %s (%04x) @ %" PRIu32 " MHz\n" " Chip ID: 0x%06" PRIX32 "\n" " Board: %s\n" " Flash: %" PRIu32 " KiB\n" @@ -38,7 +38,7 @@ size_t DebugComponent::get_device_info_(std::span lt_get_board_code(), flash_kib, ram_kib, reset_reason); pos = buf_append_str(buf, size, pos, "|Version: "); - pos = buf_append_str(buf, size, pos, LT_BANNER_STR + 10); + pos = buf_append_str(buf, size, pos, <_BANNER_STR[10]); pos = buf_append_str(buf, size, pos, "|Reset Reason: "); pos = buf_append_str(buf, size, pos, reset_reason); pos = buf_append_str(buf, size, pos, "|Chip Name: "); diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 83eff496ac..3b70f947d2 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -354,7 +354,7 @@ async def to_code(config): if CONF_WAKEUP_PIN in config: pins_as_list = config.get(CONF_WAKEUP_PIN, []) if CORE.is_bk72xx: - cg.add(var.init_wakeup_pins_(len(pins_as_list))) + cg.add(var.init_wakeup_pins(len(pins_as_list))) for item in pins_as_list: cg.add( var.add_wakeup_pin( diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 5595b0ba89..73e0331c76 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -30,15 +30,15 @@ void DeepSleepComponent::dump_config_platform_() { } } -bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pinItem) const { - return (pinItem.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pinItem.wakeup_pin != nullptr && - !this->sleep_duration_.has_value() && (pinItem.wakeup_level == get_real_pin_state_(*pinItem.wakeup_pin))); +bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pin_item) const { + return (pin_item.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pin_item.wakeup_pin != nullptr && + !this->sleep_duration_.has_value() && (pin_item.wakeup_level == get_real_pin_state_(*pin_item.wakeup_pin))); } bool DeepSleepComponent::prepare_to_sleep_() { - if (wakeup_pins_.size() > 0) { + if (!this->wakeup_pins_.empty()) { for (WakeUpPinItem &item : this->wakeup_pins_) { - if (pin_prevents_sleep_(item)) { + if (this->pin_prevents_sleep_(item)) { // Defer deep sleep until inactive if (!this->next_enter_deep_sleep_) { this->status_set_warning(); @@ -59,7 +59,7 @@ void DeepSleepComponent::deep_sleep_() { item.wakeup_level = !item.wakeup_level; } } - ESP_LOGI(TAG, "Wake-up on P%u %s (%d)", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW", + ESP_LOGI(TAG, "Wake-up on P%u %s (%" PRId32 ")", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW", static_cast(item.wakeup_pin_mode)); } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 05e18f8c38..a620d52a02 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -143,7 +143,7 @@ class DeepSleepComponent final : public Component { #endif // USE_ESP32 #if defined(USE_BK72XX) - void init_wakeup_pins_(size_t capacity) { this->wakeup_pins_.init(capacity); } + void init_wakeup_pins(size_t capacity) { this->wakeup_pins_.init(capacity); } void add_wakeup_pin(InternalGPIOPin *wakeup_pin, WakeupPinMode wakeup_pin_mode) { this->wakeup_pins_.emplace_back(WakeUpPinItem{wakeup_pin, wakeup_pin_mode, !wakeup_pin->is_inverted()}); } @@ -191,7 +191,7 @@ class DeepSleepComponent final : public Component { bool should_teardown_(); #ifdef USE_BK72XX - bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const; + bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const; bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } #endif // USE_BK72XX diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index af6e5720ec..da4dbf2ed7 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "fastled_light.h" #include "esphome/core/log.h" diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 0459777f40..9f903b4530 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "esphome/core/component.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 1760cb9395..84333e7169 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -1,6 +1,6 @@ #include "http_request_arduino.h" -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index c109de8a39..028b9f44a1 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -2,7 +2,7 @@ #include "http_request.h" -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #if defined(USE_RP2) #include diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 871f67a4c8..cc036b12c3 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include "i2c_bus_arduino.h" #include diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index ded28dd80c..71e91e770b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include #include "esphome/core/component.h" diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp index 67e902024d..01b276005d 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -44,7 +44,7 @@ void arch_init() { void arch_restart() { lt_reboot(); - while (1) { + while (true) { } } diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 01a7b5450b..48b94a5214 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -44,6 +44,7 @@ // it is callable from Thumb code via interworking. The MRS CPSR instruction // is ARM-only and user code here may be built in Thumb, so in_isr_context() // defers to this port helper on BK72xx instead of reading CPSR inline. +// NOLINTNEXTLINE(readability-redundant-declaration) extern "C" uint32_t platform_is_in_interrupt_context(void); #endif @@ -59,9 +60,11 @@ extern "C" void delayMicroseconds(unsigned int us); // Forward decls from libretiny's family for the inline arch_* // wrappers below. Pulling the full header would drag in the rest of the // LibreTiny C API. +// NOLINTBEGIN(readability-redundant-declaration) extern "C" void lt_wdt_feed(void); extern "C" uint32_t lt_cpu_get_cycle_count(void); extern "C" uint32_t lt_cpu_get_freq(void); +// NOLINTEND(readability-redundant-declaration) namespace esphome::libretiny {} diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index 9bbbd66be4..0ab064e3e1 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -13,14 +13,14 @@ void LTComponent::dump_config() { "LibreTiny:\n" " Version: %s\n" " Loglevel: %u", - LT_BANNER_STR + 10, LT_LOGLEVEL); + <_BANNER_STR[10], LT_LOGLEVEL); #if defined(__OPTIMIZE_SIZE__) && __OPTIMIZE_LEVEL__ > 0 && __OPTIMIZE_LEVEL__ <= 3 ESP_LOGCONFIG(TAG, " Optimization: -Os, SDK: -O" STRINGIFY_MACRO(__OPTIMIZE_LEVEL__)); #endif #ifdef USE_TEXT_SENSOR if (this->version_ != nullptr) { - this->version_->publish_state(LT_BANNER_STR + 10); + this->version_->publish_state(<_BANNER_STR[10]); } #endif // USE_TEXT_SENSOR } diff --git a/esphome/components/libretiny/preference_backend.h b/esphome/components/libretiny/preference_backend.h index 66b6847bee..f7f8279ac0 100644 --- a/esphome/components/libretiny/preference_backend.h +++ b/esphome/components/libretiny/preference_backend.h @@ -5,8 +5,10 @@ #include // Forward declare FlashDB types to avoid pulling in flashdb.h +// NOLINTBEGIN(readability-identifier-naming) struct fdb_kvdb; struct fdb_blob; +// NOLINTEND(readability-identifier-naming) namespace esphome::libretiny { diff --git a/esphome/components/logger/task_log_buffer_libretiny.cpp b/esphome/components/logger/task_log_buffer_libretiny.cpp index b6d6b22ab5..5cde18d19e 100644 --- a/esphome/components/logger/task_log_buffer_libretiny.cpp +++ b/esphome/components/logger/task_log_buffer_libretiny.cpp @@ -20,7 +20,7 @@ TaskLogBuffer::~TaskLogBuffer() { } } -size_t TaskLogBuffer::available_contiguous_space() const { +size_t TaskLogBuffer::available_contiguous_space_() const { if (this->head_ >= this->tail_) { // head is ahead of or equal to tail // Available space is from head to end, plus from start to tail @@ -81,7 +81,7 @@ void TaskLogBuffer::release_message_main_loop() { this->tail_ = 0; } - this->message_count_--; + this->message_count_ = this->message_count_ - 1; this->current_message_size_ = 0; xSemaphoreGive(this->mutex_); @@ -117,7 +117,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin } // Check if we have enough contiguous space - size_t contiguous = this->available_contiguous_space(); + size_t contiguous = this->available_contiguous_space_(); if (contiguous < total_size) { // Not enough contiguous space at end @@ -128,9 +128,9 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin } // Need at least enough space to safely write padding marker (level field is at end of struct) - constexpr size_t PADDING_MARKER_MIN_SPACE = offsetof(LogMessage, level) + 1; + constexpr size_t padding_marker_min_space = offsetof(LogMessage, level) + 1; - if (space_at_start >= total_size && this->head_ > 0 && contiguous >= PADDING_MARKER_MIN_SPACE) { + if (space_at_start >= total_size && this->head_ > 0 && contiguous >= padding_marker_min_space) { // Add padding marker (set level field to indicate this is padding, not a real message) LogMessage *padding = reinterpret_cast(this->storage_ + this->head_); padding->level = PADDING_MARKER_LEVEL; @@ -180,7 +180,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin this->head_ = 0; } - this->message_count_++; + this->message_count_ = this->message_count_ + 1; xSemaphoreGive(this->mutex_); return true; diff --git a/esphome/components/logger/task_log_buffer_libretiny.h b/esphome/components/logger/task_log_buffer_libretiny.h index b42894502a..ce469a1a18 100644 --- a/esphome/components/logger/task_log_buffer_libretiny.h +++ b/esphome/components/logger/task_log_buffer_libretiny.h @@ -84,7 +84,7 @@ class TaskLogBuffer { static inline size_t message_total_size(size_t text_length) { return sizeof(LogMessage) + text_length + 1; } // Calculate available contiguous space at write position - size_t available_contiguous_space() const; + size_t available_contiguous_space_() const; uint8_t storage_[ESPHOME_TASK_LOG_BUFFER_SIZE]; // Embedded in Logger (no separate heap allocation) size_t head_{0}; // Write position diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index a543a3809a..5354bd241c 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -27,8 +27,8 @@ static void register_libretiny(MDNSComponent *, StaticVector #include -#endif // USE_ESP32 vs USE_ESP8266 +#elif defined(USE_LIBRETINY) +#include +#endif // USE_ESP32 vs USE_ESP8266 vs USE_LIBRETINY #endif // USE_NEXTION_TFT_UPLOAD namespace esphome::nextion { diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index c038426f61..0358ed278f 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -13,7 +13,7 @@ using SPIInterface = spi_host_device_t; -#elif defined(USE_ARDUINO) +#elif defined(USE_ARDUINO) && !defined(USE_LIBRETINY) #include diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 4172e7c164..fbf0c20ded 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -18,7 +18,7 @@ namespace esphome::uart { static const char *const TAG = "uart.lt"; -static const char *UART_TYPE[] = { +static const char *const UART_TYPE[] = { "hardware", "software", }; @@ -45,19 +45,19 @@ uint16_t LibreTinyUARTComponent::get_config() { } void LibreTinyUARTComponent::setup() { - int8_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); - int8_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); - bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); - bool rx_inverted = rx_pin_ != nullptr && rx_pin_->is_inverted(); + int16_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); + int16_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); - auto shouldFallbackToSoftwareSerial = [&]() -> bool { - auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { + auto should_fallback_to_software_serial = [&]() -> bool { + auto has_flags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { return pin && (pin->get_flags() & mask) != gpio::Flags::FLAG_NONE; }; - if (hasFlags(this->tx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || - hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { + if (has_flags(this->tx_pin_, + gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || + has_flags(this->rx_pin_, + gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { #if LT_ARD_HAS_SOFTSERIAL - ESP_LOGI(TAG, "Pins has flags set. Using Software Serial"); + ESP_LOGI(TAG, "Pins have flags set. Using Software Serial"); return true; #else ESP_LOGW(TAG, "Pin flags are set but not supported for hardware serial. Ignoring"); @@ -66,25 +66,26 @@ void LibreTinyUARTComponent::setup() { return false; }; - if (false) + if (false) { // NOLINT(readability-simplify-boolean-expr) return; + } #if LT_HW_UART0 else if ((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX) && - !shouldFallbackToSoftwareSerial()) { + !should_fallback_to_software_serial()) { this->serial_ = &Serial0; this->hardware_idx_ = 0; } #endif #if LT_HW_UART1 else if ((tx_pin == -1 || tx_pin == PIN_SERIAL1_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL1_RX) && - !shouldFallbackToSoftwareSerial()) { + !should_fallback_to_software_serial()) { this->serial_ = &Serial1; this->hardware_idx_ = 1; } #endif #if LT_HW_UART2 else if ((tx_pin == -1 || tx_pin == PIN_SERIAL2_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL2_RX) && - !shouldFallbackToSoftwareSerial()) { + !should_fallback_to_software_serial()) { this->serial_ = &Serial2; this->hardware_idx_ = 2; } @@ -97,6 +98,8 @@ void LibreTinyUARTComponent::setup() { if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { this->tx_pin_->setup(); } + bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); + bool rx_inverted = rx_pin_ != nullptr && rx_pin_->is_inverted(); this->serial_ = new SoftwareSerial(rx_pin, tx_pin, rx_inverted || tx_inverted); #else this->serial_ = &Serial; @@ -133,7 +136,7 @@ void LibreTinyUARTComponent::dump_config() { ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); } ESP_LOGCONFIG(TAG, - " Baud Rate: %u baud\n" + " Baud Rate: %" PRIu32 " baud\n" " Data Bits: %u\n" " Parity: %s\n" " Stop bits: %u", diff --git a/esphome/components/wled/wled_light_effect.cpp b/esphome/components/wled/wled_light_effect.cpp index e0724aa94a..5150cda2a5 100644 --- a/esphome/components/wled/wled_light_effect.cpp +++ b/esphome/components/wled/wled_light_effect.cpp @@ -13,7 +13,7 @@ #include #endif -#ifdef USE_BK72XX +#ifdef USE_LIBRETINY #include #endif diff --git a/esphome/components/wled/wled_light_effect.h b/esphome/components/wled/wled_light_effect.h index 085303e6c0..07abb7c674 100644 --- a/esphome/components/wled/wled_light_effect.h +++ b/esphome/components/wled/wled_light_effect.h @@ -8,7 +8,7 @@ #include #include -#ifdef USE_RP2 +#if defined(USE_RP2) || defined(USE_LIBRETINY) namespace arduino { class UDP; } // namespace arduino From 2333e6eef51aeb0fcb813c9fc0dcb4b184f7d2d3 Mon Sep 17 00:00:00 2001 From: Guanzhong Chen Date: Thu, 16 Jul 2026 07:57:30 -0400 Subject: [PATCH 2175/2214] [zephyr] implement ISRInternalGPIOPin::digital_write (#17601) --- esphome/components/zephyr/gpio.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 1e4201d8f5..23da2cafac 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -173,6 +173,14 @@ bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { return bool(gpio_pin_get(arg->gpio, arg->pin % arg->gpio_size) != arg->inverted); } +void IRAM_ATTR ISRInternalGPIOPin::digital_write(bool value) { + auto *arg = (zephyr::ISRPinArg *) this->arg_; + if (arg == nullptr || arg->gpio == nullptr) { + return; + } + gpio_pin_set(arg->gpio, arg->pin % arg->gpio_size, value != arg->inverted ? 1 : 0); +} + } // namespace esphome #endif From 14e71e190c3c2a43ce0b5c6156c684fa134595a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 02:00:19 -1000 Subject: [PATCH 2176/2214] [ci] Restore memory impact detail for ESP-IDF builds (#17587) --- esphome/analyze_memory/cli.py | 35 +--- esphome/analyze_memory/toolchain.py | 72 ++++++++ esphome/espidf/idedata.py | 25 ++- esphome/espidf/toolchain.py | 9 +- script/ci_memory_impact_extract.py | 60 ++++--- tests/script/test_determine_jobs.py | 50 ++++++ .../analyze_memory/test_build_artifacts.py | 157 ++++++++++++++++++ .../test_ci_memory_impact_extract.py | 57 +++++++ tests/unit_tests/test_espidf_toolchain.py | 57 ++++++- 9 files changed, 459 insertions(+), 63 deletions(-) create mode 100644 tests/unit_tests/analyze_memory/test_build_artifacts.py create mode 100644 tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 4fbceb7e5e..ab20e4d076 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -20,6 +20,7 @@ from . import ( RAM_SECTIONS, MemoryAnalyzer, ) +from .toolchain import find_elf_path, find_idedata_path, idedata_candidates if TYPE_CHECKING: from . import ComponentMemory @@ -759,45 +760,25 @@ def main(): print(f"Error: {build_path} is not a directory", file=sys.stderr) sys.exit(1) - # Find firmware.elf - elf_file = None - for elf_candidate in [ - build_path / "firmware.elf", - build_path / ".pioenvs" / build_path.name / "firmware.elf", - ]: - if elf_candidate.exists(): - elf_file = str(elf_candidate) - break - - if not elf_file: - print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr) + elf_path = find_elf_path(build_path) + if not elf_path: + print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr) sys.exit(1) - - # Find idedata.json - check current directory first, then home - device_name = build_path.name - idedata_candidates = [ - Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json", - Path.home() / ".esphome" / "idedata" / f"{device_name}.json", - ] + elf_file = str(elf_path) idedata = None - for idedata_path in idedata_candidates: - if not idedata_path.exists(): - continue + if idedata_path := find_idedata_path(build_path): try: with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) - break except (json.JSONDecodeError, OSError) as e: print(f"Warning: Failed to load idedata: {e}", file=sys.stderr) if not idedata: - print( - f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})", - file=sys.stderr, - ) + searched = "\n ".join(str(p) for p in idedata_candidates(build_path)) + print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr) analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata) analyzer.analyze() diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index a724d52f25..19041ac807 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -23,6 +23,78 @@ TOOLCHAIN_PREFIXES = [ ] +def find_elf_path(build_path: Path) -> Path | None: + """Locate the firmware ELF inside an ESPHome build directory. + + The layout depends on the toolchain that produced the build, so try each + known one in turn. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + Path to the ELF file, or None if no known layout matches + """ + name = build_path.name + for candidate in ( + # Native ESP-IDF: idf.py writes build/.elf, which ESPHome copies + # to build/firmware.elf (see espidf.toolchain.create_elf_copy) + build_path / "build" / "firmware.elf", + # PlatformIO + build_path / "firmware.elf", + build_path / ".pioenvs" / name / "firmware.elf", + # LibreTiny uses raw_firmware.elf + build_path / "raw_firmware.elf", + build_path / ".pioenvs" / name / "raw_firmware.elf", + # Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2 + build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf", + build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf", + ): + if candidate.is_file(): + return candidate + return None + + +def idedata_candidates(build_path: Path) -> list[Path]: + """Return the idedata locations searched for a build directory, in order. + + Exposed so a caller reporting "not found" can name the paths it tried + without keeping its own copy of the list. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + The candidate idedata JSON paths, most specific first + """ + name = build_path.name + return [ + # In .pioenvs for test builds + build_path / ".pioenvs" / name / "idedata.json", + # Both toolchains cache it in the data dir, which holds this build dir: + # /idedata/.json next to /build/ + build_path.parent.parent / "idedata" / f"{name}.json", + # Regular builds, invoked from the config dir or from anywhere + Path.cwd() / ".esphome" / "idedata" / f"{name}.json", + Path.home() / ".esphome" / "idedata" / f"{name}.json", + ] + + +def find_idedata_path(build_path: Path) -> Path | None: + """Locate the idedata JSON belonging to an ESPHome build directory. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + Path to the idedata JSON, or None if it was not found + """ + for candidate in idedata_candidates(build_path): + if candidate.is_file(): + return candidate + return None + + def _find_in_platformio_packages(tool_name: str) -> str | None: """Search for a tool in PlatformIO package directories. diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py index 0ed357a759..0047d568e2 100644 --- a/esphome/espidf/idedata.py +++ b/esphome/espidf/idedata.py @@ -6,7 +6,7 @@ toolchain has no such command, but its CMake build emits turns that file into the same fields consumers (IDE integration, clang-tidy) expect: - {cxx_path, cxx_flags, defines, includes: {build, toolchain}} + {cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}} """ from __future__ import annotations @@ -197,6 +197,28 @@ def _get_toolchain_includes(cxx_path: str) -> list[str]: return includes +def _cc_path_from_cxx(cxx_path: str) -> str: + """Derive the C compiler path from the C++ compiler path. + + compile_commands.json only names the C++ compiler, but consumers reach the + rest of the toolchain (objdump, readelf, addr2line) by rewriting the tail of + ``cc_path``, so they need the ``gcc``-suffixed name. + """ + stem, suffix = ( + (cxx_path[: -len(".exe")], ".exe") + if cxx_path.endswith(".exe") + else (cxx_path, "") + ) + # Rewrite the program name only when it is g++ itself, or a toolchain + # prefixed one such as xtensa-esp32-elf-g++ -> xtensa-esp32-elf-gcc. + # Requiring a separator before the "g++" keeps names that merely end in + # those three characters intact: "clang++" must not become "clangcc". + head = stem[: -len("g++")] + if stem.endswith("g++") and (not head or head.endswith(("-", "/", "\\"))): + stem = f"{head}gcc" + return f"{stem}{suffix}" + + def idedata_from_build(compile_commands: Path) -> dict: """Parse compile_commands.json into the idedata fields consumers expect. @@ -218,6 +240,7 @@ def idedata_from_build(compile_commands: Path) -> dict: build_includes.setdefault(inc, None) return { + "cc_path": _cc_path_from_cxx(cxx_path), "cxx_path": cxx_path, "cxx_flags": cxx_flags, "defines": defines, diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 000ce739db..231763d17d 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -467,9 +467,16 @@ def get_idedata() -> dict | None: cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime: try: - return json.loads(cache.read_text(encoding="utf-8")) + cached = json.loads(cache.read_text(encoding="utf-8")) except ValueError: pass + else: + # Caches written before cc_path was emitted stay newer than + # compile_commands.json forever, so rebuild them on the field rather + # than on the timestamp. Check the type too: a corrupted cache can + # still be valid JSON, and "in" would match a substring of a string. + if isinstance(cached, dict) and "cc_path" in cached: + return cached data = idedata_from_build(compile_commands) data["prog_path"] = str(get_elf_path()) diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index 20a737cdbf..6e999a29d6 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -33,6 +33,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) # pylint: disable=wrong-import-position from esphome.analyze_memory import MemoryAnalyzer +from esphome.analyze_memory.toolchain import ( + find_elf_path, + find_idedata_path, + idedata_candidates, +) from esphome.platformio.toolchain import IDEData from script.ci_helpers import write_github_output @@ -130,53 +135,31 @@ def run_detailed_analysis(build_dir: str) -> dict | None: print(f"Build directory not found: {build_dir}", file=sys.stderr) return None - # Find firmware.elf (or raw_firmware.elf for LibreTiny) - elf_path = None - for elf_candidate in [ - build_path / "firmware.elf", - build_path / ".pioenvs" / build_path.name / "firmware.elf", - # LibreTiny uses raw_firmware.elf - build_path / "raw_firmware.elf", - build_path / ".pioenvs" / build_path.name / "raw_firmware.elf", - ]: - if elf_candidate.exists(): - elf_path = str(elf_candidate) - break - + elf_path = find_elf_path(build_path) if not elf_path: - print( - f"firmware.elf/raw_firmware.elf not found in {build_dir}", file=sys.stderr - ) + print(f"No firmware ELF found in {build_dir}", file=sys.stderr) return None - # Find idedata.json - check multiple locations - device_name = build_path.name - idedata_candidates = [ - # In .pioenvs for test builds - build_path / ".pioenvs" / device_name / "idedata.json", - # In .esphome/idedata for regular builds - Path.home() / ".esphome" / "idedata" / f"{device_name}.json", - # Check parent directories for .esphome/idedata (for test_build_components) - build_path.parent.parent.parent / "idedata" / f"{device_name}.json", - ] - idedata = None - for idedata_path in idedata_candidates: - if not idedata_path.exists(): - continue + if idedata_path := find_idedata_path(build_path): try: with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) - break except (json.JSONDecodeError, OSError) as e: print( f"Warning: Failed to load idedata from {idedata_path}: {e}", file=sys.stderr, ) + else: + # Without idedata the analyzer falls back to whatever binutils are on + # PATH, which are the wrong architecture for a cross build, so say where + # we looked rather than let the results quietly get worse. + searched = "\n ".join(str(p) for p in idedata_candidates(build_path)) + print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr) - analyzer = MemoryAnalyzer(elf_path, idedata=idedata) + analyzer = MemoryAnalyzer(str(elf_path), idedata=idedata) components = analyzer.analyze() # Convert to JSON-serializable format @@ -320,6 +303,19 @@ def main() -> int: else: print(f"{ram_bytes},{flash_bytes}") + # The build produced usable totals, so a missing detailed analysis means the + # build layout moved out from under this script rather than a broken build. + # Fail loudly: the comment would otherwise silently drop the component + # breakdown and the symbol tables, which is easy to miss for a long time. + if detailed_analysis is None: + print( + "::error::Detailed memory analysis unavailable even though the build " + f"succeeded (build directory: {build_dir or 'not detected'}). The PR " + "comment would be missing its component breakdown and symbol changes.", + file=sys.stderr, + ) + return 1 + return 0 diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index d018c6dbd0..a05b683a5f 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2993,3 +2993,53 @@ def test_main_force_all_off_uses_detection( assert output["component_test_count"] == 0 mock_determine_integration_tests.assert_called_once() mock_should_run_clang_tidy.assert_called_once() + + +# Every platform the memory impact analysis can select must produce an ELF that +# find_elf_path knows how to locate. The analysis fails the job when it cannot +# find one, so a platform with an unknown layout would turn a clean build red. +_MEMORY_IMPACT_ELF_LAYOUTS = { + # Native ESP-IDF toolchain (the esp32 default): /build/firmware.elf + "esp32-c6-idf": "build/firmware.elf", + "esp32-idf": "build/firmware.elf", + "esp32-c3-idf": "build/firmware.elf", + "esp32-s2-idf": "build/firmware.elf", + "esp32-s3-idf": "build/firmware.elf", + # PlatformIO: /.pioenvs//firmware.elf + "esp8266-ard": ".pioenvs/{name}/firmware.elf", + "rp2040-ard": ".pioenvs/{name}/firmware.elf", + "rp2350-ard": ".pioenvs/{name}/firmware.elf", + # LibreTiny: /.pioenvs//raw_firmware.elf + "bk72xx-ard": ".pioenvs/{name}/raw_firmware.elf", + "rtl87xx-ard": ".pioenvs/{name}/raw_firmware.elf", + "ln882x-ard": ".pioenvs/{name}/raw_firmware.elf", + # Zephyr: /.pioenvs//zephyr/[zephyr/]zephyr.elf + "nrf52-adafruit": ".pioenvs/{name}/zephyr/zephyr/zephyr.elf", +} + + +def test_memory_impact_platforms_have_known_elf_layout() -> None: + """Every selectable memory impact platform has a documented ELF layout. + + Adding a platform to the preference list without teaching find_elf_path + where its ELF lands would fail the memory impact job on a clean build. + """ + selectable = { + platform.value for platform in determine_jobs.MEMORY_IMPACT_PLATFORM_PREFERENCE + } + selectable.add(determine_jobs.MEMORY_IMPACT_FALLBACK_PLATFORM.value) + + assert selectable == set(_MEMORY_IMPACT_ELF_LAYOUTS) + + +def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None: + """find_elf_path locates the ELF each memory impact platform produces.""" + from esphome.analyze_memory.toolchain import find_elf_path + + for platform, layout in _MEMORY_IMPACT_ELF_LAYOUTS.items(): + build_path = tmp_path / platform / ".esphome" / "build" / "mydevice" + elf = build_path / layout.format(name=build_path.name) + elf.parent.mkdir(parents=True) + elf.write_text("") + + assert find_elf_path(build_path) == elf, f"{platform} ELF not found" diff --git a/tests/unit_tests/analyze_memory/test_build_artifacts.py b/tests/unit_tests/analyze_memory/test_build_artifacts.py new file mode 100644 index 0000000000..734f21d852 --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_build_artifacts.py @@ -0,0 +1,157 @@ +"""Tests for locating build artifacts across the supported toolchain layouts.""" + +from pathlib import Path + +import pytest + +from esphome.analyze_memory.toolchain import ( + find_elf_path, + find_idedata_path, + idedata_candidates, +) +from esphome.espidf.idedata import _cc_path_from_cxx +from esphome.platformio.toolchain import IDEData + + +def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path: + """Create /.esphome/build/, mirroring a real data dir.""" + build_path = tmp_path / ".esphome" / "build" / name + build_path.mkdir(parents=True) + return build_path + + +def _touch(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + return path + + +def test_find_elf_path_native_esp_idf(tmp_path: Path) -> None: + """The native ESP-IDF toolchain writes the ELF under build/.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / "build" / "firmware.elf") + + assert find_elf_path(build_path) == elf + + +def test_find_elf_path_platformio(tmp_path: Path) -> None: + """The PlatformIO toolchain writes the ELF under .pioenvs//.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / ".pioenvs" / build_path.name / "firmware.elf") + + assert find_elf_path(build_path) == elf + + +def test_find_elf_path_libretiny(tmp_path: Path) -> None: + """The LibreTiny toolchain names the unwrapped ELF raw_firmware.elf.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / ".pioenvs" / build_path.name / "raw_firmware.elf") + + assert find_elf_path(build_path) == elf + + +@pytest.mark.parametrize( + "relative_elf", + [ + # SDK < 2.9.2 + "zephyr/zephyr.elf", + # SDK >= 2.9.2 nests the artifacts one level deeper + "zephyr/zephyr/zephyr.elf", + ], +) +def test_find_elf_path_zephyr(tmp_path: Path, relative_elf: str) -> None: + """Zephyr (nRF52) keeps the ELF under .pioenvs//zephyr/.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / ".pioenvs" / build_path.name / relative_elf) + + assert find_elf_path(build_path) == elf + + +def test_find_elf_path_missing(tmp_path: Path) -> None: + """An unknown layout resolves to None rather than a bogus path.""" + assert find_elf_path(_make_build_dir(tmp_path)) is None + + +def test_find_idedata_path_in_data_dir(tmp_path: Path) -> None: + """The idedata cache sits in the data dir that holds the build dir.""" + build_path = _make_build_dir(tmp_path) + idedata = _touch(tmp_path / ".esphome" / "idedata" / f"{build_path.name}.json") + + assert find_idedata_path(build_path) == idedata + + +def test_find_idedata_path_in_pioenvs(tmp_path: Path) -> None: + """Test builds may keep idedata alongside the PlatformIO env.""" + build_path = _make_build_dir(tmp_path) + idedata = _touch(build_path / ".pioenvs" / build_path.name / "idedata.json") + + assert find_idedata_path(build_path) == idedata + + +def test_find_idedata_path_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A missing idedata resolves to None.""" + # Keep the cwd/home fallbacks from finding an unrelated file on this machine + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + assert find_idedata_path(_make_build_dir(tmp_path)) is None + + +def test_idedata_candidates_are_what_find_probes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Every advertised candidate is one find_idedata_path actually accepts. + + The candidates are reported to the user when idedata is missing, so a list + that drifts from the lookup would send someone hunting in the wrong place. + """ + # Two candidates are relative to the cwd and to home; keep the test from + # writing into the real ones. + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + build_path = _make_build_dir(tmp_path) + candidates = idedata_candidates(build_path) + + assert candidates, "no candidates advertised" + for candidate in candidates: + _touch(candidate) + assert find_idedata_path(build_path) == candidate + candidate.unlink() + + +@pytest.mark.parametrize( + ("cxx_path", "expected"), + [ + ("/tools/bin/xtensa-esp32-elf-g++", "/tools/bin/xtensa-esp32-elf-gcc"), + ("/tools/bin/riscv32-esp-elf-g++", "/tools/bin/riscv32-esp-elf-gcc"), + ( + r"C:\tools\bin\xtensa-esp32-elf-g++.exe", + r"C:\tools\bin\xtensa-esp32-elf-gcc.exe", + ), + # Nothing to rewrite; leave the path alone + ("/tools/bin/clang++", "/tools/bin/clang++"), + ], +) +def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None: + """cc_path is derived from the C++ compiler that compile_commands.json names.""" + assert _cc_path_from_cxx(cxx_path) == expected + + +def test_native_idedata_resolves_toolchain_tools() -> None: + """The binutils paths are derived from the native ESP-IDF cc_path. + + Without cc_path, IDEData.objdump_path raises KeyError and the memory + analysis silently degrades to no component or symbol detail. + """ + idedata = IDEData( + { + "cc_path": _cc_path_from_cxx("/tools/bin/xtensa-esp32-elf-g++"), + "cxx_path": "/tools/bin/xtensa-esp32-elf-g++", + } + ) + + assert idedata.objdump_path == "/tools/bin/xtensa-esp32-elf-objdump" + assert idedata.readelf_path == "/tools/bin/xtensa-esp32-elf-readelf" diff --git a/tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py b/tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py new file mode 100644 index 0000000000..73a1c63e1a --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py @@ -0,0 +1,57 @@ +"""Tests for script/ci_memory_impact_extract.py.""" + +import io +from pathlib import Path +import sys + +import pytest + +# Add script directory to path so we can import the module +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "script")) + +from ci_memory_impact_extract import main # noqa: E402 + +_COMPILE_OUTPUT = ( + "RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n" + "Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n" +) + + +@pytest.fixture(autouse=True) +def _no_github_output(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + + +def _run(monkeypatch: pytest.MonkeyPatch, compile_output: str, argv: list[str]) -> int: + monkeypatch.setattr(sys, "stdin", io.StringIO(compile_output)) + monkeypatch.setattr(sys, "argv", ["ci_memory_impact_extract.py", *argv]) + return main() + + +def test_missing_detailed_analysis_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A build with no usable ELF fails instead of posting a comment without detail.""" + build_dir = tmp_path / ".esphome" / "build" / "mydevice" + build_dir.mkdir(parents=True) + out_json = tmp_path / "analysis.json" + + rc = _run( + monkeypatch, + _COMPILE_OUTPUT, + ["--build-dir", str(build_dir), "--output-json", str(out_json)], + ) + + assert rc == 1 + # The totals are still written so the failure can be diagnosed from the artifact + assert out_json.is_file() + + +def test_undetected_build_dir_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Compile output without a build path cannot be analyzed, so it fails.""" + assert _run(monkeypatch, _COMPILE_OUTPUT, []) == 1 + + +def test_unparseable_output_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Output with no memory totals at all is still a failure.""" + assert _run(monkeypatch, "nothing useful here\n", []) == 1 diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 017d8c49b4..e4b8971fb7 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -7,6 +7,8 @@ import os from pathlib import Path from unittest.mock import patch +import pytest + from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE from esphome.espidf import toolchain @@ -100,7 +102,7 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: compile_commands.parent.mkdir(parents=True, exist_ok=True) compile_commands.write_text("[]") cache.parent.mkdir(parents=True, exist_ok=True) - cache.write_text('{"cxx_path": "cached"}') + cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}') cc_mtime = compile_commands.stat().st_mtime os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) @@ -108,7 +110,31 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_not_called() - assert result == {"cxx_path": "cached"} + assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"} + + +def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None: + """A cache predating cc_path is rebuilt even though it is newer. + + Such a cache stays newer than the compile DB forever, so consumers that + derive the binutils paths from cc_path would keep failing on it. + """ + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text('{"cxx_path": "cached"}') + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cc_path": "gcc", "cxx_path": "g++"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result["cc_path"] == "gcc" def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None: @@ -131,6 +157,33 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) - assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())} +@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"]) +def test_get_idedata_regenerates_on_non_dict_cache( + setup_core: Path, cached: str +) -> None: + """A newer cache holding valid JSON that is not an object is regenerated. + + A bare string would otherwise pass the cc_path check by substring and be + handed to consumers expecting a dict. + """ + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(cached) + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cc_path": "gcc", "cxx_path": "g++"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert isinstance(result, dict) + + def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: """An unparseable (but newer) cache falls back to regeneration.""" compile_commands, cache = _setup_build(setup_core) From 5b4ae22f581c8f1f83fa39ff2bcf1be0f8ded962 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 02:08:28 -1000 Subject: [PATCH 2177/2214] [api] Don't spam tracebacks when decoding a crash without a local build (#17597) --- esphome/components/api/client.py | 25 ++++--- esphome/components/esp32/__init__.py | 16 ++-- esphome/espidf/toolchain.py | 8 ++ .../unit_tests/components/api/test_client.py | 36 +++++++-- tests/unit_tests/test_espidf_toolchain.py | 74 ++++++++++++++++++- 5 files changed, 135 insertions(+), 24 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 44edc035f9..98edfef038 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -18,7 +18,7 @@ with warnings.catch_warnings(): import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ -from esphome.core import CORE, EsphomeError +from esphome.core import CORE from esphome.util import safe_print from . import CONF_ENCRYPTION @@ -36,15 +36,17 @@ class _LogLineProcessor: """Feeds incoming log lines to the stack-trace decoder. Two responsibilities beyond just calling the decoder: - 1. Catch EsphomeError. on_log runs inside an asyncio protocol - callback; if an exception escapes, the loop tears the transport - down with "Fatal error: protocol.data_received() call failed." - and ReconnectLogic immediately reconnects, the device replays - the same crash trace, and we loop forever. - 2. Disable decoding after the first failure. _decode_pc shells out - to PlatformIO via _run_idedata, which is expensive; a single - crash dump can contain many PC/BT lines and we don't want to - retry the failing subprocess for each one. + 1. Catch everything the decoder can raise. aioesphomeapi isolates + exceptions raised by log handlers, so an escaping one no longer + kills the session, but it does log a full traceback per line. A + crash dump carries a PC line plus one per backtrace frame, so the + tracebacks bury the dump the user is trying to read. Decoding is a + diagnostic nicety; nothing it raises is worth that noise. + 2. Disable decoding after the first failure. _decode_pc shells out to + the toolchain to resolve addr2line, which is expensive; a single + crash dump can contain many PC/BT lines and we don't want to retry + the failing subprocess for each one. This only works if every + failure is caught, which is why 1 is not narrowed to EsphomeError. """ def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None: @@ -61,12 +63,13 @@ class _LogLineProcessor: self.backtrace_state = self._platform_handler( self._config, raw_line, self.backtrace_state ) - except EsphomeError as exc: + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except self._decode_enabled = False self.backtrace_state = False # _run_idedata raises EsphomeError with no message; fall back # to a generic explanation when str(exc) is empty. detail = str(exc) or "build artifacts not found locally" + _LOGGER.debug("Stack-trace decoding failed", exc_info=True) _LOGGER.warning( "Crash trace decoding unavailable: %s. " "Run 'esphome compile' for this device to enable PC decoding.", diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9b568dd629..3c2fb35dde 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3058,19 +3058,23 @@ def copy_files(): def _decode_pc(config, addr): - # _decode_pc runs from the api log processor's asyncio callback, which - # only catches EsphomeError. Any other exception escaping here tears down - # the protocol and triggers an infinite reconnect/replay loop. Convert - # toolchain-resolution errors (e.g. missing build dir / cmake cache) into - # EsphomeError so the caller can disable decoding cleanly. + # Convert toolchain-resolution errors (e.g. missing build dir / cmake + # cache) into EsphomeError. The api log processor stops decoding on any + # exception, so this is about the message it reports rather than about + # catching it at all: EsphomeError carries an explanation worth showing + # the user, where a raw OSError repr does not. if CORE.using_toolchain_esp_idf: from esphome.espidf import toolchain as idf_toolchain try: addr2line_path = idf_toolchain.get_addr2line_path() firmware_elf_path = idf_toolchain.get_elf_path() - except RuntimeError as err: + except (RuntimeError, OSError) as err: + # OSError covers a missing build directory or a cmake that isn't + # on PATH; both surface from the subprocess call, not as RuntimeError. raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err + if not firmware_elf_path.is_file(): + raise EsphomeError(f"Firmware ELF not found: {firmware_elf_path}") else: from esphome.platformio import toolchain diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 231763d17d..f2bb99d970 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -94,6 +94,14 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: def _get_cmake_output(build_dir) -> str: cmake_output_cache = _cache().cmake_output if build_dir not in cmake_output_cache: + # Check the build before resolving the env: _get_idf_env() runs + # check_esp_idf_install(), which can download and install the whole + # framework. Never start that for a build that isn't there. Callers + # such as the log stack-trace decoder run against devices that were + # never compiled on this machine. + if not (Path(build_dir) / "CMakeCache.txt").is_file(): + raise EsphomeError(f"No ESP-IDF build found in {build_dir}") + cmd = ["cmake", "-LA", "-N", "."] env = _get_idf_env() diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py index 333ef70b22..cbec406a3a 100644 --- a/tests/unit_tests/components/api/test_client.py +++ b/tests/unit_tests/components/api/test_client.py @@ -12,11 +12,9 @@ from esphome.core import EsphomeError def test_decoder_swallows_esphome_error() -> None: """A failing stack-trace decode must not propagate. - on_log runs inside an asyncio protocol callback; if EsphomeError - escapes, the loop reports "Fatal error: protocol.data_received() - call failed.", tears the connection down, and ReconnectLogic loops - forever as the device replays the same crash trace on every - reconnect. + aioesphomeapi isolates exceptions raised by log handlers, so an + escaping one logs a full traceback for every line it fires on rather + than being reported once as an unavailable decoder. """ config = {"esphome": {"name": "test"}} @@ -43,6 +41,32 @@ def test_decoder_swallows_platform_handler_error() -> None: assert processor.backtrace_state is False +def test_decoder_swallows_non_esphome_error() -> None: + """Decoding failures that aren't EsphomeError must be contained too. + + A missing build directory surfaces as FileNotFoundError from the toolchain + subprocess. aioesphomeapi isolates it, so the session survives, but it logs + a traceback for every PC/BT line and decoding is never disabled, which + buries the crash dump the user is trying to read. + """ + config = {"esphome": {"name": "test"}} + + with patch.object( + esp32, + "process_stacktrace", + side_effect=FileNotFoundError( + 2, "No such file or directory", "/build/ol/build" + ), + ) as mock_process: + processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) + processor.process_line("PC: 0x4010496e") + processor.process_line("BT0: 0x4010496e") + + # Disabled after the first failure rather than retried per backtrace line. + assert mock_process.call_count == 1 + assert processor.backtrace_state is False + + def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: """_run_idedata raises EsphomeError with no message; the warning must show a useful explanation rather than empty parens. @@ -61,7 +85,7 @@ def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: def test_decoder_short_circuits_after_failure() -> None: """After one failure, subsequent lines must not retry the decoder. - _decode_pc shells out to PlatformIO; a crash dump can contain many + _decode_pc shells out to the toolchain; a crash dump can contain many PC/BT lines and retrying the failing subprocess for each one would stall log streaming. """ diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index e4b8971fb7..8731884ed3 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -5,12 +5,13 @@ import json import os from pathlib import Path +import subprocess from unittest.mock import patch import pytest from esphome.const import CONF_FRAMEWORK, CONF_SOURCE -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.espidf import toolchain @@ -237,6 +238,77 @@ 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_cmake_output_without_build_dir(setup_core: Path) -> None: + """A build dir that was never created raises EsphomeError. + + Without this, subprocess.run(cwd=build_dir) raises FileNotFoundError, which + the log stack-trace decoder doesn't recognise as a decode failure. + """ + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + assert not build_dir.exists() + + with pytest.raises(EsphomeError, match="No ESP-IDF build found"): + toolchain._get_cmake_output(build_dir) + + +def test_get_cmake_output_without_cmake_cache(setup_core: Path) -> None: + """A build dir that exists but was never configured raises EsphomeError.""" + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + build_dir.mkdir(parents=True) + + with pytest.raises(EsphomeError, match="No ESP-IDF build found"): + toolchain._get_cmake_output(build_dir) + + +def test_get_cmake_output_with_configured_build(setup_core: Path) -> None: + """A configured build still runs cmake and caches the output. + + The missing-build guard must not get in the way of a real build. + """ + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + build_dir.mkdir(parents=True) + (build_dir / "CMakeCache.txt").write_text("") + + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout="CMAKE_ADDR2LINE:FILEPATH=/tool/addr2line\n" + ) + with ( + patch.object(toolchain, "_get_idf_env", return_value={}), + patch.object(toolchain.subprocess, "run", return_value=completed) as mock_run, + ): + assert toolchain._get_cmake_output(build_dir) == completed.stdout + # Second call is served from the cache rather than re-running cmake. + assert toolchain._get_cmake_output(build_dir) == completed.stdout + + mock_run.assert_called_once() + assert toolchain._get_cmake_tool_path("CMAKE_ADDR2LINE") == Path("/tool/addr2line") + + +def test_get_cmake_output_missing_build_does_not_resolve_idf_env( + setup_core: Path, +) -> None: + """The build check runs before the env is resolved. + + Resolving the env calls check_esp_idf_install(), which can download and + extract the whole framework. A doomed call must never start that. + """ + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + + with ( + patch.object(toolchain, "_get_idf_env") as mock_env, + patch.object(toolchain.subprocess, "run") as mock_run, + pytest.raises(EsphomeError), + ): + toolchain._get_cmake_output(build_dir) + + mock_env.assert_not_called() + mock_run.assert_not_called() + + def test_get_core_framework_version_from_core_data(): """The version is read from CORE.data when validation populated it.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION From bc8510c6d888c9dbcafae680a44bea21f1831977 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 02:09:30 -1000 Subject: [PATCH 2178/2214] [micro_wake_word] Include the local model file in bundles (#17604) --- esphome/bundle.py | 37 +++++- .../components/micro_wake_word/__init__.py | 45 ++++++- .../micro_wake_word/__init__.py | 0 .../micro_wake_word/test_init.py | 110 ++++++++++++++++++ tests/unit_tests/test_bundle.py | 65 +++++++++++ 5 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/micro_wake_word/__init__.py create mode 100644 tests/component_tests/micro_wake_word/test_init.py diff --git a/esphome/bundle.py b/esphome/bundle.py index d38f68ebfd..88df87c3ba 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -7,7 +7,7 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz`` from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum import io import json @@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError _LOGGER = logging.getLogger(__name__) +DOMAIN = "bundle" + BUNDLE_EXTENSION = ".esphomebundle.tar.gz" MANIFEST_FILENAME = "manifest.json" CURRENT_MANIFEST_VERSION = 1 @@ -120,6 +122,32 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: return keys +@dataclass +class BundleData: + """Files components asked to include, keyed under DOMAIN in CORE.data.""" + + extra_files: list[Path] = field(default_factory=list) + + +def _get_data() -> BundleData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = BundleData() + return CORE.data[DOMAIN] + + +def add_bundle_file(path: Path) -> None: + """Register a file that a bundle must include. + + Bundle discovery walks the validated config, so it only finds files the config + names. Components call this during validation for files it cannot see, such as a + file that is referenced from inside another file. + + A relative path is taken as relative to the config directory. Files outside the + config directory are skipped when the bundle is built. + """ + _get_data().extra_files.append(CORE.relative_config_path(path)) + + @dataclass class BundleFile: """A file to include in the bundle.""" @@ -286,13 +314,18 @@ class ConfigBundleCreator: with known file extensions are also resolved and checked. Core ESPHome concepts that use relative paths or directories - are handled explicitly. + are handled explicitly. Files the config does not name at all are + registered by their component with add_bundle_file(). """ config = self._config # Generic walk: find all file paths in the validated config self._walk_config_for_files(config) + # Files registered by components during validation + for extra_file in _get_data().extra_files: + self._add_file(extra_file) + # --- Core ESPHome concepts needing explicit handling --- # esphome.includes / includes_c - can be relative paths and directories diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index cba6bcfa50..4b309551ba 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -6,6 +6,7 @@ from urllib.parse import urljoin from esphome import automation, external_files, git from esphome.automation import register_action, register_condition +from esphome.bundle import add_bundle_file import esphome.codegen as cg from esphome.components import esp32, microphone, ota, psram import esphome.config_validation as cv @@ -28,6 +29,7 @@ from esphome.const import ( TYPE_LOCAL, ) from esphome.core import CORE, HexInt +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -236,10 +238,45 @@ HTTP_SCHEMA = cv.All( _process_http_source, ) -LOCAL_SCHEMA = cv.Schema( - { - cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_), - } + +def _register_local_model_file(config: ConfigType) -> ConfigType: + """Register the model file that the manifest points to, so bundles include it. + + The manifest names its model file relative to itself, so that path never appears + in the YAML and bundle discovery cannot find it on its own. + + Problems with the manifest are logged and ignored here rather than raised. Loading + the manifest later reports them with better messages, and raising would be + swallowed by the shorthand validator, which then reports a confusing error about a + missing file in a git repository. Logging keeps the skipped registration + diagnosable if the manifest is only briefly unreadable, since the bundle would + then be built without the model file. + """ + manifest_path: Path = config[CONF_PATH] + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + model = manifest[CONF_MODEL] + except (OSError, ValueError, KeyError, TypeError) as err: + _LOGGER.debug("Not registering a model file from %s: %s", manifest_path, err) + return config + if not isinstance(model, str): + _LOGGER.debug( + "Not registering a model file from %s: 'model' is %s, expected a string", + manifest_path, + type(model).__name__, + ) + return config + add_bundle_file(manifest_path.parent / model) + return config + + +LOCAL_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_), + } + ), + _register_local_model_file, ) diff --git a/tests/component_tests/micro_wake_word/__init__.py b/tests/component_tests/micro_wake_word/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/micro_wake_word/test_init.py b/tests/component_tests/micro_wake_word/test_init.py new file mode 100644 index 0000000000..5e57653585 --- /dev/null +++ b/tests/component_tests/micro_wake_word/test_init.py @@ -0,0 +1,110 @@ +"""Tests for micro_wake_word local model validation.""" + +import json +import logging +from pathlib import Path +from typing import Any + +import pytest + +from esphome.components.micro_wake_word import LOCAL_SCHEMA +from esphome.core import CORE + +MANIFEST: dict[str, Any] = { + "type": "micro", + "model": "hey_jarvis.tflite", + "author": "someone", + "version": 2, + "wake_word": "hey jarvis", + "trained_languages": ["en"], + "micro": { + "feature_step_size": 10, + "tensor_arena_size": 30000, + "probability_cutoff": 0.97, + "sliding_window_size": 5, + "minimum_esphome_version": "2024.7.0", + }, +} + + +def _registered_files() -> list[Path]: + """Files components registered for bundling this run.""" + data = CORE.data.get("bundle") + return list(data.extra_files) if data else [] + + +@pytest.fixture +def config_dir(tmp_path: Path) -> Path: + """A config dir holding a manifest and its model file.""" + (tmp_path / "models").mkdir() + (tmp_path / "models" / "hey_jarvis.tflite").write_bytes(b"fake model") + (tmp_path / "models" / "hey_jarvis.json").write_text(json.dumps(MANIFEST)) + CORE.config_path = tmp_path / "test.yaml" + return tmp_path + + +def test_local_schema_registers_model_file(config_dir: Path) -> None: + """The model file named by the manifest is registered so bundles include it.""" + LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"] + + +def test_local_schema_registers_model_file_in_subdirectory(config_dir: Path) -> None: + """The model reference is resolved relative to the manifest, not the config dir.""" + nested = config_dir / "models" / "nested" + nested.mkdir() + (nested / "model.tflite").write_bytes(b"fake model") + (config_dir / "models" / "nested.json").write_text( + json.dumps({**MANIFEST, "model": "nested/model.tflite"}) + ) + + LOCAL_SCHEMA({"path": "models/nested.json"}) + + assert _registered_files() == [nested / "model.tflite"] + + +def test_local_schema_leaves_config_untouched(config_dir: Path) -> None: + """Registration is a side effect; the model file is not a config key.""" + config = LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert config == {"path": config_dir / "models" / "hey_jarvis.json"} + + +def test_local_schema_missing_model_file_still_validates(config_dir: Path) -> None: + """A model file that does not exist is registered, not rejected. + + Raising here would be swallowed by the shorthand validator, which would then + report a confusing error about a missing file in a git repository. + """ + (config_dir / "models" / "hey_jarvis.tflite").unlink() + + LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"] + + +@pytest.mark.parametrize( + "contents", + [ + pytest.param("{not valid json", id="malformed"), + pytest.param(json.dumps({"type": "micro"}), id="no_model_key"), + pytest.param(json.dumps(["a", "list"]), id="not_an_object"), + pytest.param(json.dumps({"model": 42}), id="model_not_a_string"), + ], +) +def test_local_schema_bad_manifest_does_not_raise( + config_dir: Path, contents: str, caplog: pytest.LogCaptureFixture +) -> None: + """Manifest problems are left to later stages, which report them better. + + The skipped registration is logged so a bundle built without the model file can + be diagnosed. + """ + (config_dir / "models" / "hey_jarvis.json").write_text(contents) + + with caplog.at_level(logging.DEBUG): + LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert _registered_files() == [] + assert "Not registering a model file" in caplog.text diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index f15bbf2e29..6cecb63c2d 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -22,6 +22,7 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, + add_bundle_file, extract_bundle, is_bundle_path, prepare_bundle_for_compile, @@ -611,6 +612,70 @@ def test_discover_files_includes_config(tmp_path: Path) -> None: assert "test.yaml" in paths +def test_discover_files_includes_registered_files(tmp_path: Path) -> None: + """Files registered with add_bundle_file() are included. + + The config does not name them, so discovery cannot find them on its own. + """ + config_dir = _setup_config_dir( + tmp_path, + files={"models/model.tflite": "fake model data"}, + ) + add_bundle_file(config_dir / "models" / "model.tflite") + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "models/model.tflite" in paths + + +def test_discover_files_registered_relative_file(tmp_path: Path) -> None: + """A relative registered path is taken as relative to the config directory. + + Not the working directory, which is where Path.resolve() would put it. + """ + _setup_config_dir( + tmp_path, + files={"models/model.tflite": "fake model data"}, + ) + add_bundle_file(Path("models/model.tflite")) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "models/model.tflite" in paths + + +def test_discover_files_registered_file_outside_config_dir(tmp_path: Path) -> None: + """A registered file outside the config directory is skipped, not bundled.""" + _setup_config_dir(tmp_path) + outside = tmp_path / "outside.tflite" + outside.write_text("fake model data") + add_bundle_file(outside) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + assert [f.path for f in files] == ["test.yaml"] + + +def test_discover_files_registered_file_deduplicated(tmp_path: Path) -> None: + """Registering the same file twice adds it once.""" + config_dir = _setup_config_dir( + tmp_path, + files={"models/model.tflite": "fake model data"}, + ) + add_bundle_file(config_dir / "models" / "model.tflite") + add_bundle_file(config_dir / "models" / "model.tflite") + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + assert [f.path for f in files].count("models/model.tflite") == 1 + + def test_discover_files_finds_path_objects(tmp_path: Path) -> None: """Path objects in validated config are discovered.""" config_dir = _setup_config_dir( From dcdd044234255e6a10ca59cf91d6d535e9095fc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 08:48:47 -1000 Subject: [PATCH 2179/2214] [web_server] Use alarm_control_panel as the domain in JSON (#17594) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1e6c4e8c62..2faa2ab66b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1907,7 +1907,7 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "alarm-control-panel", + set_json_icon_state_value(root, obj, "alarm_control_panel", json_state_str(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); From 2218edf20c73b3a30c1120471b8d69ba6ba514c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 08:49:43 -1000 Subject: [PATCH 2180/2214] [web_server] Switch entity id to the new format and drop name_id (#17586) --- esphome/components/web_server/web_server.cpp | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 2faa2ab66b..d06b6d6408 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -558,26 +558,19 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J size_t device_len = device_name ? strlen(device_name) : 0; #endif - // Single stack buffer for both id formats - ArduinoJson copies the string before we overwrite + // Stack buffer for the id - ArduinoJson copies the string before it goes out of scope // Buffer sizes use constants from entity_base.h validated in core/config.py // Note: Device name uses ESPHOME_FRIENDLY_NAME_MAX_LEN (sub-device max 120), not ESPHOME_DEVICE_NAME_MAX_LEN // (hostname) - // Without USE_DEVICES: legacy id ({prefix}-{object_id}) is the largest format - // With USE_DEVICES: name_id ({prefix}/{device}/{name}) is the largest format - static constexpr size_t LEGACY_ID_SIZE = ESPHOME_DOMAIN_MAX_LEN + 1 + OBJECT_ID_MAX_LEN; #ifdef USE_DEVICES static constexpr size_t ID_BUF_SIZE = - std::max(ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1, - LEGACY_ID_SIZE); + ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1; #else - static constexpr size_t ID_BUF_SIZE = - std::max(ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1, LEGACY_ID_SIZE); + static constexpr size_t ID_BUF_SIZE = ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1; #endif char id_buf[ID_BUF_SIZE]; memcpy(id_buf, prefix, prefix_len); // NOLINT(bugprone-not-null-terminated-result) - // name_id: new format {prefix}/{device?}/{name} - frontend should prefer this - // Remove in 2026.8.0 when id switches to new format permanently char *p = id_buf + prefix_len; *p++ = '/'; #ifdef USE_DEVICES @@ -589,12 +582,6 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J #endif memcpy(p, name.c_str(), name_len); p[name_len] = '\0'; - root[ESPHOME_F("name_id")] = id_buf; - - // id: old format {prefix}-{object_id} for backward compatibility - // Will switch to new format in 2026.8.0 - reuses prefix already in id_buf - id_buf[prefix_len] = '-'; - obj->write_object_id_to(id_buf + prefix_len + 1, ID_BUF_SIZE - prefix_len - 1); root[ESPHOME_F("id")] = id_buf; if (start_config == DETAIL_ALL) { From f53394e46ff1bad541189d0ec0f7264143177f0a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:14:18 -0400 Subject: [PATCH 2181/2214] [mlx90393] Compile out on BK72xx where the bundled library cannot build (#17614) --- esphome/components/mlx90393/sensor_mlx90393.cpp | 4 ++++ esphome/components/mlx90393/sensor_mlx90393.h | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/esphome/components/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index 7048302124..2288e8ff84 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -1,3 +1,5 @@ +#ifndef USE_BK72XX + #include "sensor_mlx90393.h" #include "esphome/core/log.h" @@ -270,3 +272,5 @@ void MLX90393Cls::verify_settings_timeout_(MLX90393Setting stage) { } } // namespace esphome::mlx90393 + +#endif // USE_BK72XX diff --git a/esphome/components/mlx90393/sensor_mlx90393.h b/esphome/components/mlx90393/sensor_mlx90393.h index e3b7ae5d93..03e78f51cc 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.h +++ b/esphome/components/mlx90393/sensor_mlx90393.h @@ -1,5 +1,7 @@ #pragma once +#ifndef USE_BK72XX + #include #include #include "esphome/components/i2c/i2c.h" @@ -76,3 +78,5 @@ class MLX90393Cls final : public PollingComponent, public i2c::I2CDevice, public }; } // namespace esphome::mlx90393 + +#endif // USE_BK72XX From c0e78a5574ac12c8cbefd2f8f46e1c13bd10d392 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:22:19 -0400 Subject: [PATCH 2182/2214] [core] Improve framework mirror selection, download errors, and version parsing (#17615) --- esphome/config_validation.py | 6 +- esphome/espidf/framework.py | 31 ++++-- esphome/framework_helpers.py | 71 +++++++++++-- tests/component_tests/esp32/test_esp32.py | 25 +++++ tests/unit_tests/test_config_validation.py | 36 ++++++- tests/unit_tests/test_espidf_framework.py | 23 +++++ tests/unit_tests/test_framework_helpers.py | 111 ++++++++++++++++++++- 7 files changed, 281 insertions(+), 22 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 16f0a63aa0..3f7c8ff783 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -422,12 +422,14 @@ class Version: @classmethod def parse(cls, value: str) -> Version: - match = re.match(r"^(\d+).(\d+).(\d+)[-.]?(\w*)$", value) + # The patch component is optional and defaults to 0, so "6.0" and + # "6.0-rc1" parse as 6.0.0 and 6.0.0-rc1. + match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?[-.]?(\w*)$", value) if match is None: raise ValueError(f"Not a valid version number {value}") major = int(match[1]) minor = int(match[2]) - patch = int(match[3]) + patch = int(match[3] or 0) extra = match[4] or "" return Version(major=major, minor=minor, patch=patch, extra=extra) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 810a63476f..18aa966bff 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -63,7 +63,7 @@ ESPHOME_IDF_FRAMEWORK_MIRRORS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") or [ "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", - "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}{EXTRA}/esp-idf-v{MAJOR}.{MINOR}{EXTRA}.tar.xz", + "https://github.com/esphome-libs/esp-idf/releases/download/v{SHORT_VERSION}/esp-idf-v{SHORT_VERSION}.tar.xz", ] ) @@ -536,10 +536,14 @@ def _check_esphome_idf_framework_install( env: Optional dictionary of environment variables to set source_url: Optional override URL for the framework tarball. Supports the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / - ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS - (``{EXTRA}`` includes its leading ``-``, e.g. ``-rc1``, or is empty). - When set, it replaces the default mirror list — no implicit fallback, - so a misspelled URL fails loudly. + ``{EXTRA}`` / ``{SHORT_VERSION}`` substitutions as + ESPHOME_IDF_FRAMEWORK_MIRRORS (``{EXTRA}`` includes its leading + ``-``, e.g. ``-rc1``, or is empty; ``{SHORT_VERSION}`` is ``x.y`` + plus any extra and only available for x.y.0 versions — a URL + referencing it is skipped for other versions). When set, it + replaces the default mirror list — no implicit fallback, so a + misspelled or skipped URL fails loudly with an EsphomeError naming + the URL. Returns: tuple of (framework_path, install_flag) @@ -588,7 +592,11 @@ def _check_esphome_idf_framework_install( with tempfile.NamedTemporaryFile() as tmp: _LOGGER.info("Downloading ESP-IDF %s framework ...", version) - # Create substitutions for the URLs + # Create substitutions for the URLs. SHORT_VERSION (x.y with + # optional -extra) is only provided for x.y.0 releases, since + # the vX.Y release tags only exist for those; templates that + # reference it are skipped for other versions by + # download_from_mirrors. substitutions = {"VERSION": version} try: ver = Version.parse(version) @@ -596,8 +604,17 @@ def _check_esphome_idf_framework_install( substitutions["MINOR"] = str(ver.minor) substitutions["PATCH"] = str(ver.patch) substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else "" + if ver.patch == 0: + substitutions["SHORT_VERSION"] = ( + f"{ver.major}.{ver.minor}{substitutions['EXTRA']}" + ) except ValueError: - pass + _LOGGER.warning( + "ESP-IDF version '%s' is not a valid version number; " + "only the {VERSION} substitution is available for " + "mirror URLs", + version, + ) mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS download_from_mirrors(mirrors, substitutions, tmp.file) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 70d440d995..6c055dded3 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -552,6 +552,17 @@ def archive_extract_all( matched_fct(archive_ref, extract_dir, progress_header=progress_header) +def _failure_reason(e: Exception) -> str: + """Format a download exception for the aggregated error message. + + ``requests`` appends " for url: " to HTTP errors; the URL is already + printed on the line above, so strip the suffix to keep lines short. Falls + back to the repr for exceptions with no message (e.g. ``TimeoutError()``) + so the line always names the failure. + """ + return str(e).split(" for url: ", maxsplit=1)[0] or repr(e) + + def download_from_mirrors( mirrors: list[str], substitutions: dict[str, str], @@ -570,14 +581,22 @@ def download_from_mirrors( 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. + Raises: ValueError: If mirrors list is empty. - Exception: If all download attempts fail. + 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. """ # Imported lazily: requests is a heavy import (~85ms) and is only needed # when actually downloading a toolchain, never during config validation. import requests + from esphome.core import EsphomeError + # 1. Open target file for writing if path given with ExitStack() as stack: if isinstance(target, (str, os.PathLike)): @@ -590,13 +609,31 @@ def download_from_mirrors( ) # 2. Try each mirror in order - last_exception = None + failures: list[tuple[str, Exception]] = [] + skipped: list[tuple[str, str]] = [] for mirror in mirrors: # 3. Apply substitutions to URL - url = mirror.format(**substitutions) + 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 - _LOGGER.debug("Trying downloading from %s", url) + _LOGGER.debug("Trying to download from %s", url) try: # 4. Reset file pointer and download @@ -631,9 +668,27 @@ def download_from_mirrors( except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught _LOGGER.debug("Failed to download %s: %s", url, str(e)) - last_exception = e + failures.append((url, e)) - # 7. Raise last exception if all mirrors failed - if last_exception: - raise last_exception + # 7. 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 + ) + attempts += "".join( + f"\n {mirror}\n {reason}" for mirror, reason in skipped + ) + raise EsphomeError( + f"Failed to download from all mirrors:{attempts}" + ) from failures[0][1] + if skipped: + details = "".join( + f"\n {mirror}\n {reason}" for mirror, reason in skipped + ) + raise EsphomeError( + f"No mirror URL template matched the provided substitutions:{details}" + ) raise ValueError("download_from_mirrors called with an empty mirrors list") diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index fdca70bf2c..8a116ccc27 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -740,3 +740,28 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: with pytest.raises(cv.Invalid, match=match): _validate_signed_ota_keys(config) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # Full x.y.z versions are rewritten into pioarduino release URLs + ( + "55.3.30", + "https://github.com/pioarduino/platform-espressif32/releases/download/55.03.30/platform-espressif32.zip", + ), + ( + "55.3.31-2", + "https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-2/platform-espressif32.zip", + ), + # Non-version values pass through untouched + ( + "https://github.com/pioarduino/platform-espressif32.git#develop", + "https://github.com/pioarduino/platform-espressif32.git#develop", + ), + ], +) +def test_parse_pio_platform_version(value: str, expected: str) -> None: + from esphome.components.esp32 import _parse_pio_platform_version + + assert _parse_pio_platform_version(value) == expected diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 17dfaad9b8..fd21ac92ea 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1436,9 +1436,41 @@ def test_version_parse_with_extra() -> None: assert version.extra == "dev20240101" -def test_version_parse_invalid() -> None: +def test_version_parse_without_patch() -> None: + """A two-part version parses with patch defaulting to 0, so framework + shorthands like '6.0' and '6.0-rc1' are accepted.""" + version = cv.Version.parse("6.0") + assert (version.major, version.minor, version.patch, version.extra) == ( + 6, + 0, + 0, + "", + ) + version = cv.Version.parse("6.0-rc1") + assert (version.major, version.minor, version.patch, version.extra) == ( + 6, + 0, + 0, + "rc1", + ) + + +def test_version_parse_numeric_extra() -> None: + """Four-part versions keep the trailing component as extra (pioarduino + packaging revisions, e.g. 5.5.3.1).""" + version = cv.Version.parse("5.5.3.1") + assert (version.major, version.minor, version.patch, version.extra) == ( + 5, + 5, + 3, + "1", + ) + + +@pytest.mark.parametrize("value", ["not.a.version", "6", "a.b", ""]) +def test_version_parse_invalid(value: str) -> None: with pytest.raises(ValueError, match="Not a valid version number"): - cv.Version.parse("not.a.version") + cv.Version.parse(value) def test_version_is_beta() -> None: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index c5d9ddbaf1..de02a6b227 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -489,6 +489,29 @@ def test_check_esp_idf_install_unparseable_version( espidf_mocks.extract.assert_called_once() +@pytest.mark.parametrize( + ("version", "short_version"), + [ + ("6.0.0", "6.0"), + ("6.0.0-rc1", "6.0-rc1"), + ("5.5.4", None), # vX.Y tags only exist for X.Y.0 releases + ], +) +def test_check_esp_idf_install_short_version_substitution( + espidf_mocks: SimpleNamespace, version: str, short_version: str | None +) -> None: + """SHORT_VERSION is only offered for x.y.0 releases, so the vX.Y mirror + template is never tried for versions whose tag cannot exist.""" + _get_framework_path(version).mkdir(parents=True, exist_ok=True) + check_esp_idf_install(version, force=True) + + # First call downloads the framework archive; a later call fetches the + # constraints file with its own substitutions. + substitutions = espidf_mocks.download.call_args_list[0][0][1] + assert substitutions.get("SHORT_VERSION") == short_version + assert substitutions["VERSION"] == version + + # --------------------------------------------------------------------------- # _patch_tools_json_for_linux_arm64 (arm64-only ninja backport) # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 69b9f20eaa..e662d2d015 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -16,6 +16,7 @@ import zipfile import pytest import requests as req +from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, _detect_archive_root, @@ -546,6 +547,99 @@ class TestDownloadFromMirrors: ) assert mock_get.call_args[0][0] == "https://example.com/1.2.3.bin" + def test_template_with_missing_substitution_is_skipped( + self, tmp_path: Path + ) -> None: + """A template referencing an unavailable substitution is skipped, not + formatted into a bogus URL (e.g. SHORT_VERSION only exists for x.y.0 + framework versions).""" + with patch( + "requests.get", + return_value=_mock_response(b"x"), + ) as mock_get: + url = download_from_mirrors( + [ + "https://example.com/{SHORT_VERSION}.bin", + "https://example.com/{VERSION}.bin", + ], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert url == "https://example.com/1.2.3.bin" + assert mock_get.call_count == 1 + + def test_all_templates_skipped_raises_esphome_error(self, tmp_path: Path) -> None: + with ( + patch("requests.get") as mock_get, + pytest.raises(EsphomeError, match="No mirror URL template matched") as ei, + ): + download_from_mirrors( + ["https://example.com/{MISSING}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + mock_get.assert_not_called() + # The skipped template and its missing substitution are named + assert "https://example.com/{MISSING}.bin" in str(ei.value) + assert "MISSING" in str(ei.value) + + def test_failure_message_includes_skipped_templates(self, tmp_path: Path) -> None: + """When downloads fail, templates that were skipped for missing + substitutions are also listed so a typo'd custom mirror is + attributable.""" + with ( + patch( + "requests.get", + return_value=_mock_response(b"", ok=False), + ), + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors( + [ + "https://example.com/{TYPO}.bin", + "https://example.com/{VERSION}.bin", + ], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + message = str(ei.value) + assert "https://example.com/1.2.3.bin" in message + assert ( + "https://example.com/{TYPO}.bin\n not applicable (TYPO not available)" + in message + ) + + def test_malformed_template_warns_and_is_reported( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """A structurally malformed template is an authoring error: warned + about even when another mirror succeeds, and named in the aggregate + error when everything fails.""" + with ( + patch("requests.get", return_value=_mock_response(b"x")), + caplog.at_level(logging.WARNING, logger="esphome.framework_helpers"), + ): + url = download_from_mirrors( + ["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert url == "https://example.com/1.2.3.bin" + assert "malformed mirror URL template" in caplog.text + + with ( + patch("requests.get", return_value=_mock_response(b"", ok=False)), + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors( + ["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert "https://example.com/{oops.bin\n skipped (ValueError(" in str( + ei.value + ) + def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: with patch( "requests.get", @@ -559,15 +653,26 @@ class TestDownloadFromMirrors: assert url == "https://mirror2.com/f" assert (tmp_path / "out.bin").read_bytes() == b"second" - def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None: + def test_all_mirrors_fail_raises_error_listing_every_attempt( + self, tmp_path: Path + ) -> None: with ( patch( "requests.get", return_value=_mock_response(b"", ok=False), ), - pytest.raises(req.HTTPError), + pytest.raises(EsphomeError, match="all mirrors") as excinfo, ): - download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") + download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], + {}, + tmp_path / "out.bin", + ) + # Every attempted URL appears in the message, and the first mirror's + # exception (the primary URL, usually the one that matters) is chained. + assert "https://mirror1.com/f" in str(excinfo.value) + assert "https://mirror2.com/f" in str(excinfo.value) + assert isinstance(excinfo.value.__cause__, req.HTTPError) def test_empty_mirrors_raises_value_error(self, tmp_path: Path) -> None: with pytest.raises(ValueError, match="empty mirrors list"): From 5b37049dff3cc101d7e438a442ea19bd767f101e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:23:46 -0400 Subject: [PATCH 2183/2214] [midea] Restrict to platforms where MideaUART actually builds (#17613) --- esphome/components/midea/ac_adapter.cpp | 2 +- esphome/components/midea/ac_adapter.h | 2 +- esphome/components/midea/ac_automations.h | 2 +- esphome/components/midea/air_conditioner.cpp | 2 +- esphome/components/midea/air_conditioner.h | 2 +- esphome/components/midea/appliance_base.h | 2 +- esphome/components/midea/climate.py | 6 ------ esphome/components/midea/ir_transmitter.h | 2 +- 8 files changed, 7 insertions(+), 13 deletions(-) diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 3611b20715..77bb9bbe86 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "esphome/core/log.h" #include "ac_adapter.h" diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index 53959efe2a..4545743564 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) // MideaUART #include diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h index 9c35e191b5..b595a018b3 100644 --- a/esphome/components/midea/ac_automations.h +++ b/esphome/components/midea/ac_automations.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "esphome/core/automation.h" #include "air_conditioner.h" diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index a743e867af..e55afedd8a 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index cd04c87890..089928902e 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) // MideaUART #include diff --git a/esphome/components/midea/appliance_base.h b/esphome/components/midea/appliance_base.h index d9486564c0..1b45561fab 100644 --- a/esphome/components/midea/appliance_base.h +++ b/esphome/components/midea/appliance_base.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) // MideaUART #include diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index aedb517f89..b0c102af6d 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -25,11 +25,8 @@ from esphome.const import ( ICON_POWER, ICON_THERMOMETER, ICON_WATER_PERCENT, - PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_LN882X, - PLATFORM_RTL87XX, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, UNIT_PERCENT, @@ -161,9 +158,6 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_BK72XX, - PLATFORM_RTL87XX, - PLATFORM_LN882X, ] ), ) diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index 43a2e2f261..ecf3fa1c1a 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #ifdef USE_REMOTE_TRANSMITTER #include "esphome/components/remote_base/midea_protocol.h" From 72bd45538e29e031840b550860d998209938f46b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:25:45 -0400 Subject: [PATCH 2184/2214] [opentherm] Rename OpenthermData accessors that collide with vendor SDK type macros (#17611) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/opentherm/hub.cpp | 12 ++++++------ esphome/components/opentherm/opentherm.cpp | 16 ++++++++-------- esphome/components/opentherm/opentherm.h | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/esphome/components/opentherm/hub.cpp b/esphome/components/opentherm/hub.cpp index e2828a9e30..f8b515fa05 100644 --- a/esphome/components/opentherm/hub.cpp +++ b/esphome/components/opentherm/hub.cpp @@ -27,11 +27,11 @@ uint8_t parse_u8_lb(OpenthermData &data) { return data.valueLB; } uint8_t parse_u8_hb(OpenthermData &data) { return data.valueHB; } int8_t parse_s8_lb(OpenthermData &data) { return (int8_t) data.valueLB; } int8_t parse_s8_hb(OpenthermData &data) { return (int8_t) data.valueHB; } -uint16_t parse_u16(OpenthermData &data) { return data.u16(); } +uint16_t parse_u16(OpenthermData &data) { return data.get_u16(); } uint16_t parse_u8_lb_60(OpenthermData &data) { return data.valueLB * 60; } uint16_t parse_u8_hb_60(OpenthermData &data) { return data.valueHB * 60; } -int16_t parse_s16(OpenthermData &data) { return data.s16(); } -float parse_f88(OpenthermData &data) { return data.f88(); } +int16_t parse_s16(OpenthermData &data) { return data.get_s16(); } +float parse_f88(OpenthermData &data) { return data.get_f88(); } void write_flag8_lb_0(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 0, value); } void write_flag8_lb_1(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 1, value); } @@ -53,9 +53,9 @@ void write_u8_lb(const uint8_t value, OpenthermData &data) { data.valueLB = valu void write_u8_hb(const uint8_t value, OpenthermData &data) { data.valueHB = value; } void write_s8_lb(const int8_t value, OpenthermData &data) { data.valueLB = (uint8_t) value; } void write_s8_hb(const int8_t value, OpenthermData &data) { data.valueHB = (uint8_t) value; } -void write_u16(const uint16_t value, OpenthermData &data) { data.u16(value); } -void write_s16(const int16_t value, OpenthermData &data) { data.s16(value); } -void write_f88(const float value, OpenthermData &data) { data.f88(value); } +void write_u16(const uint16_t value, OpenthermData &data) { data.set_u16(value); } +void write_s16(const int16_t value, OpenthermData &data) { data.set_s16(value); } +void write_f88(const float value, OpenthermData &data) { data.set_f88(value); } } // namespace message_data diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 5cf7c19880..e05dbf8d82 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -533,34 +533,34 @@ void OpenTherm::debug_data(OpenthermData &data) { ESP_LOGD(TAG, "%s %s %s %s", format_bin_to(type_buf, data.type), format_bin_to(id_buf, data.id), format_bin_to(hb_buf, data.valueHB), format_bin_to(lb_buf, data.valueLB)); ESP_LOGD(TAG, "type: %s; id: %u; HB: %u; LB: %u; uint_16: %u; float: %f", - this->message_type_to_str((MessageType) data.type), data.id, data.valueHB, data.valueLB, data.u16(), - data.f88()); + this->message_type_to_str((MessageType) data.type), data.id, data.valueHB, data.valueLB, data.get_u16(), + data.get_f88()); } void OpenTherm::debug_error(OpenThermError &error) const { ESP_LOGD(TAG, "data: 0x%08" PRIx32 "; clock: %u; capture: 0x%08" PRIx32 "; bit_pos: %u", error.data, this->clock_, error.capture, error.bit_pos); } -float OpenthermData::f88() { return ((float) this->s16()) / 256.0f; } +float OpenthermData::get_f88() { return ((float) this->get_s16()) / 256.0f; } -void OpenthermData::f88(float value) { this->s16((int16_t) (value * 256)); } +void OpenthermData::set_f88(float value) { this->set_s16((int16_t) (value * 256)); } -uint16_t OpenthermData::u16() { +uint16_t OpenthermData::get_u16() { uint16_t const value = this->valueHB; return (value << 8) | this->valueLB; } -void OpenthermData::u16(uint16_t value) { +void OpenthermData::set_u16(uint16_t value) { this->valueLB = value & 0xFF; this->valueHB = (value >> 8) & 0xFF; } -int16_t OpenthermData::s16() { +int16_t OpenthermData::get_s16() { int16_t const value = this->valueHB; return (value << 8) | this->valueLB; } -void OpenthermData::s16(int16_t value) { +void OpenthermData::set_s16(int16_t value) { this->valueLB = value & 0xFF; this->valueHB = (value >> 8) & 0xFF; } diff --git a/esphome/components/opentherm/opentherm.h b/esphome/components/opentherm/opentherm.h index 3078e92c9d..7aa81cd8a2 100644 --- a/esphome/components/opentherm/opentherm.h +++ b/esphome/components/opentherm/opentherm.h @@ -178,7 +178,7 @@ enum BitPositions { STOP_BIT = 33 }; /** * Structure to hold Opentherm data packet content. - * Use f88(), u16() or s16() functions to get appropriate value of data packet accoridng to id of message. + * Use get_f88(), get_u16() or get_s16() functions to get appropriate value of data packet according to id of message. */ struct OpenthermData { uint8_t type; @@ -191,32 +191,32 @@ struct OpenthermData { /** * @return float representation of data packet value */ - float f88(); + float get_f88(); /** * @param float number to set as value of this data packet */ - void f88(float value); + void set_f88(float value); /** * @return unsigned 16b integer representation of data packet value */ - uint16_t u16(); + uint16_t get_u16(); /** * @param unsigned 16b integer number to set as value of this data packet */ - void u16(uint16_t value); + void set_u16(uint16_t value); /** * @return signed 16b integer representation of data packet value */ - int16_t s16(); + int16_t get_s16(); /** * @param signed 16b integer number to set as value of this data packet */ - void s16(int16_t value); + void set_s16(int16_t value); }; struct OpenThermError { From 09da2766ed9517ce01ad7ae3df24ea4005ce8e47 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:32:09 -0400 Subject: [PATCH 2185/2214] [bluetooth_proxy] Bound GATT characteristic/descriptor enumeration to allocated size (#17625) --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 7ba9e61e19..9820977a13 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -245,7 +245,9 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.characteristics.init(total_char_count); uint16_t char_offset = 0; esp_gattc_char_elem_t char_result; - while (true) { // characteristics + // 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, @@ -287,7 +289,7 @@ void BluetoothConnection::send_service_for_discovery_() { characteristic_resp.descriptors.init(total_desc_count); uint16_t desc_offset = 0; esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors + 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); From 6c8d0050abef9d0cfacc3d9a116584f0ea2995aa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:32:44 -0400 Subject: [PATCH 2186/2214] [esp32_ble_server] Validate descriptor write length before copying (#17626) --- esphome/components/esp32_ble_server/ble_descriptor.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 5ca80d6a7a..3dcac3691c 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -77,6 +77,10 @@ void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_ case ESP_GATTS_WRITE_EVT: { if (this->handle_ != param->write.handle) break; + if (param->write.len > this->value_.attr_max_len) { + ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", param->write.len, this->value_.attr_max_len); + break; + } this->value_.attr_len = param->write.len; memcpy(this->value_.attr_value, param->write.value, param->write.len); if (this->on_write_callback_) { From a624659856904b591cf76744ebd43c4b4865f94e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:33:30 -0400 Subject: [PATCH 2187/2214] Bump github/codeql-action/analyze from 4.37.0 to 4.37.1 (#17630) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e718b481e0..4779b8059e 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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: category: "/language:${{matrix.language}}" From 3caae3031a189ddc00bd29b0b0910792817dbdc9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:33:38 -0400 Subject: [PATCH 2188/2214] Bump github/codeql-action/init from 4.37.0 to 4.37.1 (#17629) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4779b8059e..70527a0fa2 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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 4507d058221774608d70a2ed1c9dbc7b58e506d3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:34:49 -0400 Subject: [PATCH 2189/2214] [http_request] Fix use-after-return of header collection state in IDF backend (#17627) --- .../components/http_request/http_request_idf.cpp | 15 +++++---------- .../components/http_request/http_request_idf.h | 2 ++ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 3e341395a4..a437540241 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -19,11 +19,6 @@ namespace esphome::http_request { static const char *const TAG = "http_request.idf"; static constexpr uint32_t ERROR_DURATION_MS = 1000; -struct UserData { - const std::vector &lower_case_collect_headers; - std::vector

&response_headers; -}; - void HttpRequestIDF::dump_config() { HttpRequestComponent::dump_config(); ESP_LOGCONFIG(TAG, @@ -34,15 +29,15 @@ void HttpRequestIDF::dump_config() { } esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { - UserData *user_data = (UserData *) evt->user_data; + auto *container = (HttpContainerIDF *) evt->user_data; switch (evt->event_id) { case HTTP_EVENT_ON_HEADER: { const std::string header_name = str_lower_case(evt->header_key); // NOLINT - if (should_collect_header(user_data->lower_case_collect_headers, header_name)) { + if (should_collect_header(container->collect_headers_, header_name)) { const std::string header_value = evt->header_value; ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str()); - user_data->response_headers.push_back({header_name, header_value}); + container->response_headers_.push_back({header_name, header_value}); } break; } @@ -124,8 +119,8 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c container->set_secure(secure); - auto user_data = UserData{lower_case_collect_headers, container->response_headers_}; - esp_http_client_set_user_data(client, static_cast(&user_data)); + container->collect_headers_ = lower_case_collect_headers; + esp_http_client_set_user_data(client, static_cast(container.get())); for (const auto &header : request_headers) { esp_http_client_set_header(client, header.name.c_str(), header.value.c_str()); diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 8a803b5469..16a5b6a161 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -24,6 +24,8 @@ class HttpContainerIDF : public HttpContainer { protected: friend class HttpRequestIDF; esp_http_client_handle_t client_; + // Owned copy (not a reference): must outlive perform() for the response-header event handler + std::vector collect_headers_; }; class HttpRequestIDF final : public HttpRequestComponent { From e2df9fb5543746f5c8e15c1d0a4c12a012ce248f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:43:43 -0400 Subject: [PATCH 2190/2214] Bump ruff from 0.15.21 to 0.15.22 (#17628) 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 7aa8dab534..9a9b7adff5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.21 # also change in .pre-commit-config.yaml when updating +ruff==0.15.22 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 7a2d13da905b5da6496bda0ae60fdce638d475a8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:59:53 -0400 Subject: [PATCH 2191/2214] [web_server_idf] Use core format_hex_to helper for digest auth (fixes Arduino build) (#17608) --- .../web_server_idf/web_server_idf.cpp | 20 +++++-------------- .../components/web_server/test.esp32-ard.yaml | 6 ++++++ 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index bf5a8666dc..993fb6c035 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -381,16 +381,6 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code #ifdef USE_WEBSERVER_AUTH_DIGEST namespace { -// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated. -void bytes_to_hex(const uint8_t *data, size_t len, char *out) { - static const char HEX[] = "0123456789abcdef"; - for (size_t i = 0; i < len; i++) { - out[i * 2] = HEX[data[i] >> 4]; - out[i * 2 + 1] = HEX[data[i] & 0x0f]; - } - out[len * 2] = '\0'; -} - // Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated // parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent. // Only whole parameter names match, so "nc" does not match inside "cnonce". @@ -468,7 +458,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st esp_rom_md5_update(&ctx, ":", 1); esp_rom_md5_update(&ctx, password, strlen(password)); esp_rom_md5_final(digest, &ctx); - bytes_to_hex(digest, sizeof(digest), ha1); + format_hex_to(ha1, digest, sizeof(digest)); // HA2 = MD5(method:uri) -- uses the uri the client echoed back. char ha2[33]; @@ -477,7 +467,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st esp_rom_md5_update(&ctx, ":", 1); esp_rom_md5_update(&ctx, uri.c_str(), uri.size()); esp_rom_md5_final(digest, &ctx); - bytes_to_hex(digest, sizeof(digest), ha2); + format_hex_to(ha2, digest, sizeof(digest)); // expected = MD5(HA1:nonce:nc:cnonce:qop:HA2) char expected[33]; @@ -494,7 +484,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st esp_rom_md5_update(&ctx, ":", 1); esp_rom_md5_update(&ctx, ha2, 32); esp_rom_md5_final(digest, &ctx); - bytes_to_hex(digest, sizeof(digest), expected); + format_hex_to(expected, digest, sizeof(digest)); // Constant-time comparison of the two 32-char hex digests. uint8_t result = 0; @@ -592,9 +582,9 @@ void AsyncWebServerRequest::requestAuthentication() const { char opaque[33]; char header[160]; esp_fill_random(random_bytes, sizeof(random_bytes)); - bytes_to_hex(random_bytes, sizeof(random_bytes), nonce); + format_hex_to(nonce, random_bytes, sizeof(random_bytes)); esp_fill_random(random_bytes, sizeof(random_bytes)); - bytes_to_hex(random_bytes, sizeof(random_bytes), opaque); + format_hex_to(opaque, random_bytes, sizeof(random_bytes)); snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce, opaque); httpd_resp_set_hdr(*this, "WWW-Authenticate", header); diff --git a/tests/components/web_server/test.esp32-ard.yaml b/tests/components/web_server/test.esp32-ard.yaml index 11ad5456ef..2e091b905a 100644 --- a/tests/components/web_server/test.esp32-ard.yaml +++ b/tests/components/web_server/test.esp32-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: digest From cc6392785fda8c6cb12bfefd9773a3fd53117967 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:36:18 -0400 Subject: [PATCH 2192/2214] [runtime_image] Prevent integer overflow in image buffer size calculation (#17624) --- .../runtime_image/image_decoder.cpp | 4 ++++ .../components/runtime_image/image_decoder.h | 1 + .../components/runtime_image/png_decoder.cpp | 2 ++ .../runtime_image/runtime_image.cpp | 22 +++++++++++++++++-- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index 8d3320b5d1..f2c4f5c8cd 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -10,12 +10,16 @@ static const char *const TAG = "image_decoder"; bool ImageDecoder::set_size(int width, int height) { bool success = this->image_->resize(width, height) > 0; + this->size_valid_ = success; this->x_scale_ = static_cast(this->image_->get_buffer_width()) / width; this->y_scale_ = static_cast(this->image_->get_buffer_height()) / height; return success; } void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) { + if (!this->size_valid_) { + return; + } auto width = std::min(this->image_->get_buffer_width(), static_cast(std::ceil((x + w) * this->x_scale_))); auto height = std::min(this->image_->get_buffer_height(), static_cast(std::ceil((y + h) * this->y_scale_))); for (int i = x * this->x_scale_; i < width; i++) { diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index c68ea5720b..6d351a10aa 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -108,6 +108,7 @@ class ImageDecoder { size_t decoded_bytes_ = 0; // Bytes processed so far double x_scale_ = 1.0; double y_scale_ = 1.0; + bool size_valid_ = true; // Last set_size() result; draw() no-ops while false }; } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 12bce0d284..9501702711 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -96,6 +96,8 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) { if (fed < 0) { ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_)); return DECODE_ERROR_INTERNAL_DECODER_ERROR; + } else if (!this->size_valid_) { + return DECODE_ERROR_OUT_OF_MEMORY; } else { this->decoded_bytes_ += fed; } diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 4c7f1bfb6f..4b12478e4f 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -3,7 +3,9 @@ #include "esphome/core/log.h" #include "esphome/core/helpers.h" #include +#include #include +#include #ifdef USE_RUNTIME_IMAGE_BMP #include "bmp_decoder.h" @@ -19,6 +21,13 @@ namespace esphome::runtime_image { static const char *const TAG = "runtime_image"; +// Widest supported format is 4 bytes/pixel, so 32767 * 32767 * 4 still fits a 32-bit size_t +static constexpr int MAX_IMAGE_DIMENSION = 32767; +static constexpr int MAX_IMAGE_BPP = 32; +static_assert((static_cast(MAX_IMAGE_BPP) * MAX_IMAGE_DIMENSION + 7) / 8 * MAX_IMAGE_DIMENSION <= + std::numeric_limits::max(), + "MAX_IMAGE_DIMENSION must keep the worst-case buffer size within size_t"); + inline bool is_color_on(const Color &color) { // This produces the most accurate monochrome conversion, but is slightly slower. // return (0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b) > 127; @@ -257,6 +266,11 @@ void RuntimeImage::release_buffer_() { size_t RuntimeImage::resize_buffer_(int width, int height) { size_t new_size = this->get_buffer_size_(width, height); + if (new_size == 0) { + ESP_LOGE(TAG, "Refusing to allocate buffer for invalid image dimensions %dx%d", width, height); + return 0; + } + if (this->buffer_ && this->buffer_width_ == width && this->buffer_height_ == height) { // Buffer already allocated with correct size return new_size; @@ -287,11 +301,15 @@ size_t RuntimeImage::resize_buffer_(int width, int height) { } size_t RuntimeImage::get_buffer_size_(int width, int height) const { + // Dimensions come from a remote image header; reject absurd values so the size math cannot overflow + if (width <= 0 || height <= 0 || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) { + return 0; + } if (this->get_type() == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { // Add extra alpha channel for RGB565 with alpha - return width * height * 3; + return static_cast(width) * height * 3; } - return (this->get_bpp() * width + 7u) / 8u * height; + return (static_cast(this->get_bpp()) * width + 7u) / 8u * height; } int RuntimeImage::get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; } From 735f8d607dcb47d7c38f507a52c71c66d6d72250 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:04:35 +1200 Subject: [PATCH 2193/2214] [nrf52] Set ZEPHYR_SDK_INSTALL_DIR for Zephyr SDK discovery (#17633) --- esphome/components/nrf52/framework.py | 10 +++++- tests/unit_tests/test_nrf52_framework.py | 39 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index fa6f7d57ad..623cd4eef3 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -133,7 +133,15 @@ def get_build_env() -> dict: env = os.environ.copy() env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") - env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION) / "cmake") + # ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr documents for pointing at + # the SDK: FindZephyr-sdk.cmake reads it (from the environment, via + # zephyr_get) and passes it straight to find_package as a HINT. This + # matters because the SDK lives in the esphome cache dir, which is not on + # the module's static search path (/usr, /opt, $HOME, ...). A generic + # "Zephyr-sdk_DIR" environment hint proved unreliable here: containerized + # non-root builds failed to locate the SDK with it, while + # ZEPHYR_SDK_INSTALL_DIR fixed the same invocation. + env["ZEPHYR_SDK_INSTALL_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION)) return env diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index bb5bc8c064..830e9efba5 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1,6 +1,7 @@ """Tests for esphome.components.nrf52.framework helpers.""" import hashlib +import os from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -12,11 +13,13 @@ from esphome.components.nrf52.framework import ( TOOLCHAIN_VERSION, _get_toolchain_platform_info, check_and_install, + get_build_env, get_sdk_nrf_tools_path, ) from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import get_python_env_executable_path @pytest.fixture(autouse=True) @@ -252,6 +255,42 @@ class TestCheckAndInstall: assert substitutions["extension"] == "tar.xz" +# --------------------------------------------------------------------------- +# get_build_env tests +# --------------------------------------------------------------------------- + + +def test_get_build_env( + nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """get_build_env exposes ZEPHYR_SDK_INSTALL_DIR pointing at the toolchain root. + + ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr's FindZephyr-sdk.cmake + explicitly consumes (from the environment) and uses as a find_package + HINT. The old Zephyr-sdk_DIR environment hint proved unreliable in + containerized non-root builds and was removed. + """ + monkeypatch.setenv("SOME_PREEXISTING_VAR", "kept") + + env = get_build_env() + + tools = get_sdk_nrf_tools_path() + venv_bin_dir = get_python_env_executable_path( + tools / "penvs" / f"v{_TEST_SDK_VERSION}", "python" + ).parent + assert env["PATH"].startswith(str(venv_bin_dir) + os.pathsep) + assert env["ZEPHYR_BASE"] == str( + tools / "frameworks" / f"v{_TEST_SDK_VERSION}" / "zephyr" + ) + # Toolchain root, not the cmake/ subdir + assert env["ZEPHYR_SDK_INSTALL_DIR"] == str( + tools / "toolchains" / TOOLCHAIN_VERSION + ) + assert "Zephyr-sdk_DIR" not in env + # The rest of the process environment is inherited + assert env["SOME_PREEXISTING_VAR"] == "kept" + + # --------------------------------------------------------------------------- # get_sdk_nrf_tools_path tests # --------------------------------------------------------------------------- From 2132cae1c9ea2be18da0b263d3c38b588f75f941 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 17 Jul 2026 07:07:38 -0500 Subject: [PATCH 2194/2214] [nextion] Fix RP2040 clang-tidy failure from TFT upload guard mismatch (#17638) --- esphome/components/nextion/__init__.py | 1 - esphome/components/nextion/display.py | 27 +++++++++++++++++-- esphome/components/nextion/nextion.h | 4 +-- .../nextion/nextion_upload_arduino.cpp | 4 +-- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index d51155b0a4..803d7a0dd2 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -19,7 +19,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 89e9b93520..b8971fd06f 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -4,7 +4,17 @@ from esphome import automation import esphome.codegen as cg from esphome.components import display, esp32, uart import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_ID, CONF_LAMBDA, CONF_ON_TOUCH +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_ID, + CONF_LAMBDA, + CONF_ON_TOUCH, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, +) from esphome.core import CORE, TimePeriod from . import ( # noqa: F401 pylint: disable=unused-import @@ -135,7 +145,20 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_TFT_URL): cv.url, + # TFT upload needs an HTTP client and runtime UART reconfiguration, + # neither of which is implemented for the RP2 or host platforms. + cv.Optional(CONF_TFT_URL): cv.All( + cv.url, + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + ] + ), + ), cv.Optional(CONF_TOUCH_SLEEP_TIMEOUT): cv.Any( 0, cv.int_range(min=3, max=65535) ), diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 29cadf979b..aa9fe8abb3 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1566,7 +1566,7 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: * @return position of last byte transferred, -1 for failure. */ int upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &range_start); -#elif defined(USE_ARDUINO) +#elif defined(USE_ESP8266) || defined(USE_LIBRETINY) /** * will request chunk_size chunks from the web server * and send each to the nextion @@ -1575,7 +1575,7 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: * @return position of last byte transferred, -1 for failure. */ int upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start); -#endif // USE_ESP32 vs USE_ARDUINO +#endif // USE_ESP32 vs USE_ESP8266/USE_LIBRETINY /** * Ends the upload process, restart Nextion and, if successful, diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 41379c2345..2b1039fef7 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -1,7 +1,7 @@ #include "nextion.h" #ifdef USE_NEXTION_TFT_UPLOAD -#ifndef USE_ESP32 +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) #include #include "esphome/components/network/util.h" @@ -378,5 +378,5 @@ WiFiClient *Nextion::get_wifi_client_() { } // namespace esphome::nextion -#endif // NOT USE_ESP32 +#endif // USE_ESP8266 || USE_LIBRETINY #endif // USE_NEXTION_TFT_UPLOAD From 7aed5fb94bf7bf7ffc516afa03b3ec842a9ba4e5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:08:28 -0400 Subject: [PATCH 2195/2214] Bump bundled esphome-device-builder to 1.6.2 (#17640) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 84fd658594..7710256318 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.6.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.2 RUN \ platformio settings set enable_telemetry No \ From 300ab1be35ab4c10d6c9a0cae5a5160f8c0ea462 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:00:54 -1000 Subject: [PATCH 2196/2214] Bump aioesphomeapi from 45.6.0 to 45.6.1 (#17653) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5f98111445..4dbf469347 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.6.0 +aioesphomeapi==45.6.1 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From 6f27c7f3fee1a2953baae2f181a006edd701926e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:25:18 -1000 Subject: [PATCH 2197/2214] Bump bundled esphome-device-builder to 1.6.3 (#17651) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7710256318..b60bfac7a2 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.6.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.3 RUN \ platformio settings set enable_telemetry No \ From a4a9feac161f169b9136a07bc38226f0cf0de270 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:26:04 -1000 Subject: [PATCH 2198/2214] Bump esphome/workflows/.github/workflows/lock.yml from 2026.4.1 to 2026.7.0 (#17652) 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 5e70117652..ec736a2002 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@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1 + uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 From 328a2018d23324619380c90ebf642b1fd6cbb75f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:32:47 -1000 Subject: [PATCH 2199/2214] Bump aioesphomeapi from 45.6.1 to 45.6.2 (#17654) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4dbf469347..2a79a0c433 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.6.1 +aioesphomeapi==45.6.2 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From f918c299b1cd53cbdfcaa69b880837a4a0913da4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:46:45 -0400 Subject: [PATCH 2200/2214] [espidf] Suggest installing missing system libraries when the tools install fails (#17619) --- esphome/espidf/framework.py | 9 ++++++++ tests/unit_tests/test_espidf_framework.py | 28 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 18aa966bff..b8e0d4cfca 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1,5 +1,6 @@ """ESP-IDF framework tools for ESPHome.""" +from ctypes.util import find_library import json import logging import os @@ -668,6 +669,14 @@ def _check_esphome_idf_framework_install( env=env, stream_output=True, ): + if platform.system() == "Linux" and find_library("usb-1.0") is None: + _LOGGER.error( + "libusb-1.0.so.0 was not found on this system and the ESP-IDF " + "tools need it (openocd fails its install check without it). " + "Install the libusb 1.0 package, e.g. libusb-1.0-0 " + "(Debian/Ubuntu), libusb1 (Fedora) or libusb (Alpine/Arch), " + "then run the build again." + ) raise RuntimeError(f"ESP-IDF {version} framework installation failure") _write_stamp(env_stamp_file, stamp_info) diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index de02a6b227..a1af5ae54c 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -478,6 +478,34 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( espidf_mocks.venv.assert_called_once() +@pytest.mark.parametrize( + ("lib", "expect_hint"), + [ + (None, True), + ("libusb-1.0.so.0", False), + ], +) +def test_check_esp_idf_install_failure_libusb_hint( + espidf_mocks: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + lib: str | None, + expect_hint: bool, +) -> None: + """A failed tools install only shows the libusb hint when libusb-1.0 is + actually missing.""" + espidf_mocks.run_ok.return_value = False + # Fake Linux so the gate is exercised on all CI hosts; faking Linux is safe + # everywhere (unlike faking Windows, which pulls in winreg on other hosts) + with ( + patch("esphome.espidf.framework.find_library", return_value=lib), + patch("esphome.espidf.framework.platform.system", return_value="Linux"), + caplog.at_level(logging.ERROR, logger="esphome.espidf.framework"), + pytest.raises(RuntimeError, match="framework installation failure"), + ): + check_esp_idf_install(_IDF_VERSION, force=True) + assert ("libusb-1.0.so.0 was not found" in caplog.text) == expect_hint + + def test_check_esp_idf_install_unparseable_version( espidf_mocks: SimpleNamespace, ) -> None: From ebaf32c82a0706963963be009c4d9e8189427b4d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:38:55 -0400 Subject: [PATCH 2201/2214] [ci] Add LibreTiny clang-tidy environments (#17491) --- .github/workflows/ci.yml | 20 ++++++- esphome/components/nextion/__init__.py | 3 - esphome/components/nextion/display.py | 6 -- .../nextion/nextion_upload_arduino.cpp | 8 +-- platformio.ini | 56 +++++++++++++++++++ script/clang-tidy | 56 +++++++++++++++++-- 6 files changed, 127 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59e6f006e8..17b33520a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -508,6 +508,11 @@ jobs: name: Run script/clang-tidy for RP2 options: --environment rp2-tidy --grep USE_RP2 pio_cache_key: tidyrp2 + - id: clang-tidy + name: Run script/clang-tidy for LibreTiny + environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy + options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X + pio_cache_key: tidylibretiny steps: - name: Check out code from GitHub @@ -571,10 +576,21 @@ jobs: . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" - script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + changed="" else echo "Running clang-tidy on changed files only" - script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + changed="--changed" + fi + if [ -n "${{ matrix.environments }}" ]; then + rc=0 + for env in ${{ matrix.environments }}; do + echo "::group::clang-tidy $env" + script/clang-tidy --all-headers --fix $changed --environment "$env" ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} || rc=1 + echo "::endgroup::" + done + exit $rc + else + script/clang-tidy --all-headers --fix $changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} fi env: # Also cache libdeps, store them in a ~/.platformio subfolder diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index 803d7a0dd2..efb6c88d28 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -19,9 +19,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.BK72XX_ARDUINO, - PlatformFramework.RTL87XX_ARDUINO, - PlatformFramework.LN882X_ARDUINO, }, } ) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index b8971fd06f..4ab123c354 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -9,11 +9,8 @@ from esphome.const import ( CONF_ID, CONF_LAMBDA, CONF_ON_TOUCH, - PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_LN882X, - PLATFORM_RTL87XX, ) from esphome.core import CORE, TimePeriod @@ -153,9 +150,6 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_BK72XX, - PLATFORM_RTL87XX, - PLATFORM_LN882X, ] ), ), diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 2b1039fef7..2f3377d950 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -1,7 +1,7 @@ #include "nextion.h" #ifdef USE_NEXTION_TFT_UPLOAD -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) +#ifdef USE_ESP8266 #include #include "esphome/components/network/util.h" @@ -209,7 +209,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { http_client.setTimeout(this->tft_upload_http_timeout_); bool begin_status = false; -#ifdef USE_ESP8266 #if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 7, 0) http_client.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); #elif USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 6, 0) @@ -219,7 +218,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { http_client.setRedirectLimit(3); #endif begin_status = http_client.begin(*this->get_wifi_client_(), this->tft_url_.c_str()); -#endif // USE_ESP8266 if (!begin_status) { this->connection_state_.is_updating_ = false; ESP_LOGD(TAG, "Connection failed"); @@ -356,7 +354,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return upload_end_(true); } -#ifdef USE_ESP8266 WiFiClient *Nextion::get_wifi_client_() { if (this->tft_url_.starts_with("https:")) { if (this->wifi_client_secure_ == nullptr) { @@ -374,9 +371,8 @@ WiFiClient *Nextion::get_wifi_client_() { } return this->wifi_client_; } -#endif // USE_ESP8266 } // namespace esphome::nextion -#endif // USE_ESP8266 || USE_LIBRETINY +#endif // USE_ESP8266 #endif // USE_NEXTION_TFT_UPLOAD diff --git a/platformio.ini b/platformio.ini index 30968e80e8..35dec2ff76 100644 --- a/platformio.ini +++ b/platformio.ini @@ -239,9 +239,17 @@ platform = https://github.com/libretiny-eu/libretiny.git#v1.13.0 framework = arduino lib_compat_mode = soft lib_deps = + ${common.lib_deps_base} ${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 + ESP32Async/AsyncTCP@3.4.5 ; async_tcp + DNSServer ; captive_portal + heman/AsyncMqttClient-esphome@2.0.0 ; mqtt + improv/Improv@1.2.6 ; improv_serial + kikuchan98/pngle@1.1.0 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY @@ -578,6 +586,54 @@ build_flags = build_unflags = ${common.build_unflags} +[env:bk72xx-tidy] +extends = common:libretiny-arduino +board = generic-bk7231n-qfn32-tuya +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_BK72XX + -DUSE_LIBRETINY_VARIANT_BK7231N +build_unflags = + ${common.build_unflags} + +[env:ln882h-tidy] +extends = common:libretiny-arduino +board = generic-ln882h +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_LN882X + -DUSE_LIBRETINY_VARIANT_LN882H + ; the SDK lwip port dir is missing from pio idedata; lwipopts.h include_next needs it + -I${platformio.packages_dir}/framework-lightning-ln882h/components/net/lwip-2.1.3/src/port/ln_osal/include +build_unflags = + ${common.build_unflags} + +[env:rtl87xxb-tidy] +extends = common:libretiny-arduino +board = generic-rtl8710bn-2mb-788k +; mirror the libretiny codegen pin: RTL8710B needs 8.2.3+ for task notifications +custom_versions.freertos = 8.2.3 +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_RTL87XX + -DUSE_LIBRETINY_VARIANT_RTL8710B +build_unflags = + ${common.build_unflags} + +[env:rtl87xxc-tidy] +extends = common:libretiny-arduino +board = generic-rtl8720cf-2mb-992k +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_RTL87XX + -DUSE_LIBRETINY_VARIANT_RTL8720C +build_unflags = + ${common.build_unflags} + ;;;;;;;; Host ;;;;;;;; [env:host] diff --git a/script/clang-tidy b/script/clang-tidy index f463e2455d..4f1bc6021c 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -74,6 +74,8 @@ def clang_options(idedata, environment): "-fno-jump-tables", "-fno-shrink-wrap", "-mno-target-align", + # GCC-only flag emitted by the LibreTiny build + "-mthumb-interwork", ) if "zephyr" in triplet: @@ -109,6 +111,23 @@ def clang_options(idedata, environment): if environment.startswith("rp2"): # clang's ARM backend doesn't know GCC's long_call attribute (IRAM_ATTR) cmd.append("-Wno-unknown-attributes") + elif environment.startswith(("bk72xx", "ln882h", "rtl87xx")): + cmd.extend( + [ + # GCC on arm-none-eabi types (u)int32_t as (unsigned) long; clang + # types it as (unsigned) int, clashing with LibreTiny's lwip + # port typedefs. Match the GCC type model. + "-U__UINT32_TYPE__", + "-D__UINT32_TYPE__=long unsigned int", + "-U__INT32_TYPE__", + "-D__INT32_TYPE__=long int", + # newlib's machine/endian.h macroizes __bswap16 into + # __builtin_bswap16; the beken BDK then defines __bswap16 as a + # function, which GCC tolerates as a builtin redeclaration but + # clang rejects + "-D__MACHINE_ENDIAN_H__", + ] + ) else: # replace pgmspace.h, as it uses GNU extensions clang doesn't support # https://github.com/earlephilhower/newlib-xtensa/pull/18 @@ -154,8 +173,32 @@ def clang_options(idedata, environment): ) cmd.append("-std=gnu++20") - # defines - cmd.extend(f"-D{define}" for define in idedata["defines"]) + if environment.startswith(("bk72xx", "ln882h", "rtl87xx")): + # LibreTiny leaves function-like macro values unparenthesized + # (bugprone-macro-parentheses); its SDK-internal FAL_PART_TABLE macro trips the same + # check. Assumes define values are always expressions (never type- or char-literal), + # which holds for the current LibreTiny idedata. + def sanitize_define(define): + name, sep, value = define.partition("=") + if ( + sep + and value + and not value.startswith(("(", '"')) + and ("(" in name or not re.fullmatch(r"[\w.]+", value)) + ): + value = f"({value})" + return f"-D{name}{sep}{value}" + + # FAL_PART_TABLE and the delay() remap are library-scope LibreTiny flags that the real + # build never applies to esphome sources. Strip LibreTiny's shell quoting from define + # names first so the skip list matches regardless of which names it happens to quote. + for define in idedata["defines"]: + define = define.replace("'", "") + if define.startswith(("FAL_PART_TABLE", "delay(")): + continue + cmd.append(sanitize_define(define)) + else: + cmd.extend(f"-D{define}" for define in idedata["defines"]) # toolchain include directories, using -isystem to suppress their errors # idedata contains include directories for all toolchains of this platform, only use those from the one in use @@ -219,8 +262,9 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): if sys.stdout.isatty(): invocation.append("--use-color") - if args.environment.startswith("rp2"): - # MMIO peripheral access on bare-metal RP2 is all fixed-address. + if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")): + # MMIO peripheral access on these bare-metal platforms is all + # fixed-address. # bugprone-pointer-arithmetic-on-polymorphic-object (and its # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher # recursion on lvgl_esphome.h under the RP2 defines. @@ -439,7 +483,9 @@ def main(): print("Error applying fixes.\n", file=sys.stderr) raise - return len(failed_files) + # Cap at 255: shells truncate exit codes to one byte, so 256 failures + # would otherwise report success + return min(len(failed_files), 255) if __name__ == "__main__": From cb66dc01ab61e8090f3e936834aca1e51a6bd019 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:45:13 -0500 Subject: [PATCH 2202/2214] [core] Fix srcFilter exclusions being silently ignored on Windows (#17648) --- esphome/platformio/library.py | 6 ++++ tests/unit_tests/test_espidf_component.py | 43 +++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 291bedb5cd..0ffac65e0d 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -292,6 +292,12 @@ def collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[st for root, _, files in os.walk(item): matched.extend([str(Path(root) / f) for f in files]) + # glob keeps the pattern's literal separators for non-wildcard path + # components, so on Windows the same file can surface with different + # separators depending on where the wildcards sit; normalize so the + # include/exclude set operations below compare equal paths. + matched = [os.path.normpath(m) for m in matched] + # FILTER_REGEX only ever captures "+" or "-", so the else is the "-" case. if sign == "+": selected.update(matched) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index a50024b8e9..055e9c8502 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import glob import hashlib import json import os @@ -86,6 +87,48 @@ def test_collect_filtered_files_exclude(tmp_path): assert str(f2) not in result +def test_collect_filtered_files_exclude_pattern_in_subdir(tmp_path): + src = tmp_path / "lib" / "src" + src.mkdir(parents=True) + kept = src / "a.c" + excluded = src / "hasty.c" + kept.write_text("int a;") + excluded.write_text("int b;") + + result = collect_filtered_files(tmp_path, ["+", "-"]) + assert str(kept) in result + assert str(excluded) not in result + + +def test_collect_filtered_files_exclude_unnormalized_glob_output(tmp_path, monkeypatch): + # On Windows, glob keeps the pattern's literal separators for non-wildcard + # path components, so the "+" wildcard pattern and the "-" literal pattern + # yield the same file spelled differently and the exclude set difference + # misses it. Backslash is a regular filename character on POSIX (such paths + # fail the final is_file filter), so reproduce the unnormalized-output + # mismatch portably with dot segments, which normpath also collapses. + src = tmp_path / "lib" / "src" + src.mkdir(parents=True) + kept = src / "a.c" + excluded = src / "hasty.c" + kept.write_text("int a;") + excluded.write_text("int b;") + + real_glob = glob.glob + + def unnormalized_glob(pattern, recursive=False): + if "*" in pattern: + base = str(tmp_path) + return [base + "/lib/./src/a.c", base + "/lib/./src/hasty.c"] + return real_glob(pattern, recursive=recursive) + + monkeypatch.setattr(glob, "glob", unnormalized_glob) + + result = collect_filtered_files(tmp_path, ["+", "-"]) + assert [Path(r).name for r in result] == ["a.c"] + assert str(kept) in result + + def test_split_list_by_condition(): items = ["-Iinclude", "-Llib", "-Wall"] From 94be1da8938641c098e6eba900601266c204987d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2026 15:46:16 -1000 Subject: [PATCH 2203/2214] Pin cryptography to 48.0.1 on Intel macOS (#17658) --- requirements.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2a79a0c433..fbfc034268 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ -cryptography==49.0.0 +# cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there. +# Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs. +cryptography==49.0.0; platform_system != "Darwin" or platform_machine != "x86_64" +cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64" voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From bacc223360428182a85ff7e336b84dede08c048c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2026 15:59:12 -1000 Subject: [PATCH 2204/2214] Ship component requirements.txt files in the sdist and wheel (#17660) --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index e426627e8d..1626261fb6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c recursive-include esphome *.py.script recursive-include esphome *.jinja recursive-include esphome LICENSE.txt +recursive-include esphome requirements.txt From 70421fb14ba328cb7a74e7d3aece82d9d8998478 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:49:50 -0400 Subject: [PATCH 2205/2214] [espidf] Prune tool download cache after install to shrink the ESP-IDF cache (#17661) --- esphome/espidf/framework.py | 10 ++++++++++ tests/unit_tests/test_espidf_framework.py | 24 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index b8e0d4cfca..a9b3fd9644 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -679,6 +679,16 @@ def _check_esphome_idf_framework_install( ) raise RuntimeError(f"ESP-IDF {version} framework installation failure") + # idf_tools.py extracts tool archives from /dist into tools/; the + # archives are not needed afterward and, already compressed, dominate the cached install. + # Best-effort: a failure to prune must not fail an otherwise successful install. + try: + rmdir( + get_idf_tools_path() / "dist", msg="Remove ESP-IDF tool download cache" + ) + except RuntimeError as err: + _LOGGER.debug("Could not remove ESP-IDF tool download cache: %s", err) + _write_stamp(env_stamp_file, stamp_info) return framework_path, install diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index a1af5ae54c..f18a219878 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -317,7 +317,7 @@ def espidf_mocks(setup_core: Path): # extracted-marker touch writes into. _get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True) with ( - patch("esphome.espidf.framework.rmdir"), + patch("esphome.espidf.framework.rmdir") as rmdir_mock, patch( "esphome.espidf.framework.download_from_mirrors", return_value="https://example.com/idf.tar.xz", @@ -344,6 +344,7 @@ def espidf_mocks(setup_core: Path): run_ok=run_ok, tool_paths=tool_paths, clone=clone, + rmdir=rmdir_mock, ) @@ -358,6 +359,27 @@ def test_check_esp_idf_install_fresh(espidf_mocks: SimpleNamespace) -> None: espidf_mocks.extract.assert_called_once() espidf_mocks.venv.assert_called_once() espidf_mocks.clone.assert_not_called() + # the tool download cache (/dist) is pruned after install + espidf_mocks.rmdir.assert_any_call( + get_idf_tools_path() / "dist", msg="Remove ESP-IDF tool download cache" + ) + + +def test_check_esp_idf_install_dist_prune_failure_ignored( + espidf_mocks: SimpleNamespace, +) -> None: + """A failure to prune the tool download cache must not fail the install.""" + tools_dist = get_idf_tools_path() / "dist" + + def rmdir_side_effect(directory: Path, msg: str | None = None) -> None: + if directory == tools_dist: + raise RuntimeError("cannot remove dist") + + espidf_mocks.rmdir.side_effect = rmdir_side_effect + + # install still succeeds despite the failed prune + framework_path, _ = check_esp_idf_install(_IDF_VERSION, force=True) + assert framework_path == _get_framework_path(_IDF_VERSION) def test_check_esp_idf_install_git_source(espidf_mocks: SimpleNamespace) -> None: From 2a59c0ad9e85a9377737f2b46856b43dc143a3a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2026 17:16:14 -1000 Subject: [PATCH 2206/2214] [logs] Cap the logs reconnect backoff for deep-sleep devices (#17656) --- esphome/components/api/client.py | 3 ++ .../unit_tests/components/api/test_client.py | 34 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 98edfef038..7f07146dba 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -155,6 +155,9 @@ async def async_run_logs( name=name, subscribe_states=subscribe_states, allow_plaintext_fallback=True, + # 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, ) try: await asyncio.Event().wait() diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py index cbec406a3a..4ebcecbfff 100644 --- a/tests/unit_tests/components/api/test_client.py +++ b/tests/unit_tests/components/api/test_client.py @@ -2,11 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest from esphome.components import esp32 from esphome.components.api import client as api_client -from esphome.core import EsphomeError +from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE, EsphomeError def test_decoder_swallows_esphome_error() -> None: @@ -136,3 +139,30 @@ def test_decoder_uses_platform_handler_when_provided() -> None: assert calls == [(config, "BT0: 0x4010496e", False)] assert mock_generic.called is False assert processor.backtrace_state is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("extra_config", "expected_deep_sleep"), + [({"deep_sleep": {}}, True), ({}, False)], +) +async def test_async_run_logs_passes_deep_sleep( + extra_config: dict, expected_deep_sleep: bool +) -> None: + """async_run_logs tells async_run whether the device deep sleeps, from the config.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config} + # async_run blocks forever after connecting; raise to unwind async_run_logs + # once we have captured how it was called. + sentinel = RuntimeError("stop the wait") + + with ( + patch.object( + api_client, "async_run", AsyncMock(side_effect=sentinel) + ) as mock_run, + patch.object(api_client, "APIClient"), + pytest.raises(RuntimeError, match="stop the wait"), + ): + await api_client.async_run_logs(config, ["1.2.3.4"]) + + assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep From 1493a095212ee543f2ea9b53238c872b5543b1a1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:22:23 +1200 Subject: [PATCH 2207/2214] [nrf52] Install PlatformIO toolchain Python packages into a dedicated venv (#17635) --- esphome/components/nrf52/__init__.py | 12 +- esphome/components/nrf52/framework.py | 82 ++++++++++ tests/unit_tests/test_nrf52_framework.py | 181 +++++++++++++++++++++++ tests/unit_tests/test_nrf52_upload.py | 66 +++++++++ 4 files changed, 340 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 8d522a8740..5b3c250f34 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -69,7 +69,12 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) -from .framework import check_and_install, get_build_env, get_build_paths +from .framework import ( + check_and_install, + get_build_env, + get_build_paths, + setup_platformio_python_env, +) # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -514,6 +519,7 @@ def _upload_using_platformio( ) -> int | str: from esphome.platformio import toolchain + setup_platformio_python_env() if port is not None: upload_args += ["--upload-port", port] return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args) @@ -809,6 +815,10 @@ def _copy_if_exists(src: Path, dst: Path) -> None: def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: + # The actual build is done by PlatformIO (the caller falls through to + # it when this returns False); prepare the Python environment its + # Zephyr build script expects first. + setup_platformio_python_env() return False if not CORE.using_toolchain_sdk_nrf: raise EsphomeError( diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 623cd4eef3..7392ad2d60 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -4,6 +4,7 @@ import os from pathlib import Path import platform import shutil +import sys import tempfile import platformdirs @@ -27,6 +28,11 @@ _LOGGER = logging.getLogger(__name__) _REQUIREMENTS = Path(__file__).parent / "requirements.txt" TOOLCHAIN_VERSION = "0.17.4" +# Packages the PlatformIO toolchain's Zephyr build script needs beyond west +# (which comes from requirements.txt). Keep the pin in sync with +# framework-sdk-nrf scripts/platformio/platformio-build.py. +_PLATFORMIO_PENV_REQUIREMENTS: tuple[str, ...] = ("cbor2==5.6.5",) + SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( os.environ.get( "ESPHOME_SDK_NG_TOOLCHAIN_MIRRORS", @@ -145,6 +151,82 @@ def get_build_env() -> dict: return env +def _get_platformio_penv_path() -> Path: + return get_sdk_nrf_tools_path() / "penvs" / "platformio" + + +def _get_penv_site_packages(penv_path: Path) -> Path: + if os.name == "nt": + return penv_path / "Lib" / "site-packages" + python_dir = f"python{sys.version_info.major}.{sys.version_info.minor}" + return penv_path / "lib" / python_dir / "site-packages" + + +def _prepend_env_path(name: str, entry: str) -> None: + """Prepend ``entry`` to the ``os.pathsep``-separated env var ``name``.""" + current = os.environ.get(name, "") + entries = current.split(os.pathsep) if current else [] + if entry not in entries: + os.environ[name] = os.pathsep.join([entry, *entries]) + + +def setup_platformio_python_env() -> None: + """Make the Zephyr build's Python packages available to PlatformIO. + + The PlatformIO toolchain's Zephyr framework build script pip-installs + west and cbor2 (and pyocd on x86_64) into the Python environment running + PlatformIO whenever they are not importable. That environment is not + always writable — for example the docker image run as a non-root user, + where ESPHome lives in the system Python — so the install fails with + "Permission denied". Instead, pre-install those packages into a dedicated + venv under the sdk-nrf tools dir and expose it to the PlatformIO + subprocesses through the environment: + + * PYTHONPATH makes the venv's packages importable from the interpreter + that runs PlatformIO/SCons, so the build script skips its installs. + * VIRTUAL_ENV redirects any install the build script still performs via + uv (pyocd is fetched on demand) into the writable venv. + * PATH exposes console scripts installed into the venv (e.g. pyocd). + """ + penv_path = _get_platformio_penv_path() + env_python_path = get_python_env_executable_path(penv_path, "python") + sentinel = penv_path / ".ready" + # Include the Python version: the venv breaks when the interpreter it + # was created from is upgraded, so it must be rebuilt. + requirements_hash = hashlib.sha256( + _REQUIREMENTS.read_bytes() + + "\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 + ): + rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment") + + create_venv(penv_path, msg="PlatformIO toolchain") + + _LOGGER.info("Installing PlatformIO toolchain requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(_REQUIREMENTS), + *_PLATFORMIO_PENV_REQUIREMENTS, + ] + if not run_command_ok(cmd): + raise EsphomeError( + "Install requirements for PlatformIO toolchain Python environment failure" + ) + sentinel.write_text(requirements_hash, encoding="utf-8") + + os.environ["VIRTUAL_ENV"] = str(penv_path) + _prepend_env_path("PYTHONPATH", str(_get_penv_site_packages(penv_path))) + _prepend_env_path("PATH", str(env_python_path.parent)) + + def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: # SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that # Python 3.12+ flags with SyntaxWarning (a future version will reject it). diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 830e9efba5..8a5f4377d3 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -3,18 +3,23 @@ import hashlib import os from pathlib import Path +import sys from types import SimpleNamespace from unittest.mock import patch import pytest from esphome.components.nrf52.framework import ( + _PLATFORMIO_PENV_REQUIREMENTS, _REQUIREMENTS, TOOLCHAIN_VERSION, + _get_penv_site_packages, + _get_platformio_penv_path, _get_toolchain_platform_info, check_and_install, get_build_env, get_sdk_nrf_tools_path, + setup_platformio_python_env, ) from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION @@ -255,6 +260,182 @@ class TestCheckAndInstall: assert substitutions["extension"] == "tar.xz" +# --------------------------------------------------------------------------- +# setup_platformio_python_env tests +# --------------------------------------------------------------------------- + + +def _platformio_requirements_hash() -> str: + return hashlib.sha256( + _REQUIREMENTS.read_bytes() + + "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode() + + f"python{sys.version_info.major}.{sys.version_info.minor}".encode() + ).hexdigest() + + +@pytest.fixture +def platformio_penv_dir() -> Path: + """Pre-create the PlatformIO penv dir so sentinel writes succeed. + + create_venv is mocked in these tests, so the directory it would have + created must exist for ``sentinel.write_text`` to work. + """ + penv_path = _get_platformio_penv_path() + penv_path.mkdir(parents=True, exist_ok=True) + return penv_path + + +class TestSetupPlatformioPythonEnv: + def test_fresh_install_creates_venv_and_sets_env( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """No sentinel → venv created, requirements installed, env exported.""" + with patch.dict(os.environ): + os.environ.pop("PYTHONPATH", None) + + setup_platformio_python_env() + + mock_nrf52_ops.rmdir.assert_called_once() + mock_nrf52_ops.create_venv.assert_called_once_with( + platformio_penv_dir, msg="PlatformIO toolchain" + ) + mock_nrf52_ops.run_command_ok.assert_called_once() + cmd = mock_nrf52_ops.run_command_ok.call_args[0][0] + assert cmd[1:4] == ["-m", "pip", "install"] + assert "-r" in cmd + assert str(_REQUIREMENTS) in cmd + for requirement in _PLATFORMIO_PENV_REQUIREMENTS: + assert requirement in cmd + sentinel = platformio_penv_dir / ".ready" + assert sentinel.read_text(encoding="utf-8") == ( + _platformio_requirements_hash() + ) + + assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir) + site_packages = str(_get_penv_site_packages(platformio_penv_dir)) + assert os.environ["PYTHONPATH"] == site_packages + bin_dir = str( + get_python_env_executable_path(platformio_penv_dir, "python").parent + ) + assert os.environ["PATH"].split(os.pathsep)[0] == bin_dir + + def test_ready_sentinel_skips_install_but_sets_env( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Current sentinel → no install work, env vars still exported.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + + with patch.dict(os.environ): + setup_platformio_python_env() + + mock_nrf52_ops.rmdir.assert_not_called() + mock_nrf52_ops.create_venv.assert_not_called() + mock_nrf52_ops.run_command_ok.assert_not_called() + assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir) + + def test_stale_sentinel_reinstalls( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A sentinel from different requirements → venv rebuilt from scratch.""" + sentinel = platformio_penv_dir / ".ready" + sentinel.write_text("stale-hash", encoding="utf-8") + + with patch.dict(os.environ): + setup_platformio_python_env() + + mock_nrf52_ops.rmdir.assert_called_once() + mock_nrf52_ops.create_venv.assert_called_once() + mock_nrf52_ops.run_command_ok.assert_called_once() + assert sentinel.read_text(encoding="utf-8") == _platformio_requirements_hash() + + def test_install_failure_raises( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing pip install raises EsphomeError and writes no sentinel.""" + mock_nrf52_ops.run_command_ok.return_value = False + + with ( + patch.dict(os.environ), + pytest.raises( + EsphomeError, match="Install requirements for PlatformIO toolchain" + ), + ): + setup_platformio_python_env() + + assert not (platformio_penv_dir / ".ready").exists() + + def test_repeated_calls_do_not_duplicate_env_entries( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Compile then upload in one process must not grow PYTHONPATH/PATH.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + site_packages = str(_get_penv_site_packages(platformio_penv_dir)) + bin_dir = str( + get_python_env_executable_path(platformio_penv_dir, "python").parent + ) + + with patch.dict(os.environ): + setup_platformio_python_env() + setup_platformio_python_env() + + assert os.environ["PYTHONPATH"].split(os.pathsep).count(site_packages) == 1 + assert os.environ["PATH"].split(os.pathsep).count(bin_dir) == 1 + + def test_existing_pythonpath_preserved( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A pre-existing PYTHONPATH keeps its entries after the venv entry.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + site_packages = str(_get_penv_site_packages(platformio_penv_dir)) + + with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}): + setup_platformio_python_env() + + assert os.environ["PYTHONPATH"] == os.pathsep.join( + [site_packages, "/existing/path"] + ) + + +@pytest.mark.parametrize( + ("os_name", "expected_parts"), + [ + ( + "posix", + ( + "lib", + f"python{sys.version_info.major}.{sys.version_info.minor}", + "site-packages", + ), + ), + ("nt", ("Lib", "site-packages")), + ], +) +def test_get_penv_site_packages( + tmp_path: Path, os_name: str, expected_parts: tuple[str, ...] +) -> None: + penv_path = tmp_path / "penv" + with patch("os.name", os_name): + assert _get_penv_site_packages(penv_path) == penv_path.joinpath(*expected_parts) + + # --------------------------------------------------------------------------- # get_build_env tests # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py index a60e23a337..9b738ebc81 100644 --- a/tests/unit_tests/test_nrf52_upload.py +++ b/tests/unit_tests/test_nrf52_upload.py @@ -146,6 +146,72 @@ class TestUploadProgramPyocd: upload_program(config={}, args=None, host="PYOCD") +# --------------------------------------------------------------------------- +# PlatformIO toolchain paths +# --------------------------------------------------------------------------- + + +class TestRunCompilePlatformio: + def test_prepares_python_env_and_delegates_to_platformio( + self, setup_core: Path, tmp_path: Path + ) -> None: + """The PlatformIO toolchain prepares the env, then returns False so PlatformIO builds.""" + from esphome.components.nrf52 import run_compile + + _setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build") + + with patch( + "esphome.components.nrf52.setup_platformio_python_env" + ) as mock_setup: + assert run_compile(args=None, config={}) is False + + mock_setup.assert_called_once_with() + + +class TestUploadProgramSerialPlatformio: + def _upload(self, host: str, tmp_path: Path, run_result: int) -> tuple: + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + patch("esphome.components.nrf52.setup_platformio_python_env") as mock_setup, + patch( + "esphome.platformio.toolchain.run_platformio_cli_run", + return_value=run_result, + ) as mock_run, + ): + result = upload_program(config={}, args=None, host=host) + return result, mock_setup, mock_run + + def test_serial_upload_prepares_env_and_runs_platformio( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Serial upload with the PlatformIO toolchain runs pio with -t upload.""" + host = "/dev/ttyACM0" + result, mock_setup, mock_run = self._upload(host, tmp_path, run_result=0) + + assert result is True + mock_setup.assert_called_once_with() + mock_run.assert_called_once() + run_args = mock_run.call_args[0] + assert "-t" in run_args + assert "upload" in run_args + assert "--upload-port" in run_args + assert host in run_args + + def test_serial_upload_failure_raises( + self, setup_core: Path, tmp_path: Path + ) -> None: + """A non-zero PlatformIO result must raise EsphomeError.""" + with pytest.raises(EsphomeError, match="Upload failed"): + self._upload("/dev/ttyACM0", tmp_path, run_result=1) + + # --------------------------------------------------------------------------- # Serial DFU upload path # --------------------------------------------------------------------------- From a7dad14449858386238903c5c6d0234e9041d9b1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:44:52 -1000 Subject: [PATCH 2208/2214] Bump bundled esphome-device-builder to 1.6.4 (#17662) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b60bfac7a2..f804ebd148 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.6.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.4 RUN \ platformio settings set enable_telemetry No \ From 52fe461e9c92f4e82d65d35a330182476efcf83b Mon Sep 17 00:00:00 2001 From: Tom <81973502+tomwellnitz@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:01:15 +0200 Subject: [PATCH 2209/2214] [ds248x] Add OneWireBus platform for DS248x I2C-to-1Wire bridges (#12717) --- CODEOWNERS | 1 + esphome/components/ds248x/__init__.py | 112 ++++++ esphome/components/ds248x/ds248x.cpp | 320 ++++++++++++++++++ esphome/components/ds248x/ds248x.h | 133 ++++++++ .../components/ds248x/ds248x_one_wire_bus.cpp | 171 ++++++++++ .../components/ds248x/ds248x_one_wire_bus.h | 57 ++++ esphome/components/ds248x/one_wire.py | 56 +++ tests/components/ds248x/common.yaml | 115 +++++++ tests/components/ds248x/test.esp32-ard.yaml | 4 + tests/components/ds248x/test.esp32-idf.yaml | 4 + tests/components/ds248x/test.esp8266-ard.yaml | 4 + tests/components/ds248x/test.rp2040-ard.yaml | 4 + 12 files changed, 981 insertions(+) create mode 100644 esphome/components/ds248x/__init__.py create mode 100644 esphome/components/ds248x/ds248x.cpp create mode 100644 esphome/components/ds248x/ds248x.h create mode 100644 esphome/components/ds248x/ds248x_one_wire_bus.cpp create mode 100644 esphome/components/ds248x/ds248x_one_wire_bus.h create mode 100644 esphome/components/ds248x/one_wire.py create mode 100644 tests/components/ds248x/common.yaml create mode 100644 tests/components/ds248x/test.esp32-ard.yaml create mode 100644 tests/components/ds248x/test.esp32-idf.yaml create mode 100644 tests/components/ds248x/test.esp8266-ard.yaml create mode 100644 tests/components/ds248x/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 0f43cd9749..b752c9c5ce 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -145,6 +145,7 @@ esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its +esphome/components/ds248x/* @tomwellnitz esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov esphome/components/ee895/* @Stock-M diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py new file mode 100644 index 0000000000..5a26ceab50 --- /dev/null +++ b/esphome/components/ds248x/__init__.py @@ -0,0 +1,112 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE + +CODEOWNERS = ["@tomwellnitz"] +MULTI_CONF = True +DEPENDENCIES = ["i2c"] + +CONF_DS248X_ID = "ds248x_id" +CONF_BUS_SLEEP = "bus_sleep" +CONF_HUB_SLEEP = "hub_sleep" +CONF_ACTIVE_PULLUP = "active_pullup" + +CONF_RESET_LOW_TIME = "reset_low_time" +CONF_MASTER_SAMPLE_TIME = "master_sample_time" +CONF_WRITE_0_LOW_TIME = "write_0_low_time" +CONF_RECOVERY_TIME = "recovery_time" +CONF_ACTIVE_PULLUP_RESISTANCE = "active_pullup_resistance" + +TYPE_DS2482_100 = "ds2482-100" +TYPE_DS2482_101 = "ds2482-101" +TYPE_DS2482_800 = "ds2482-800" +TYPE_DS2484 = "ds2484" + +CHANNEL_COUNTS = { + TYPE_DS2482_100: 1, + TYPE_DS2482_101: 1, + TYPE_DS2482_800: 8, + TYPE_DS2484: 1, +} + +ds248x_ns = cg.esphome_ns.namespace("ds248x") +DS248xComponent = ds248x_ns.class_("DS248xComponent", cg.Component, i2c.I2CDevice) + + +def _component_schema(*extras): + schema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(DS248xComponent), + cv.Optional(CONF_ACTIVE_PULLUP, default=False): cv.boolean, + } + ) + for extra in extras: + schema = schema.extend(extra) + return schema.extend(cv.COMPONENT_SCHEMA).extend(i2c.i2c_device_schema(0x18)) + + +SLEEP_SCHEMA = { + cv.Optional(CONF_SLEEP_PIN): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_BUS_SLEEP, default=False): cv.boolean, + cv.Optional(CONF_HUB_SLEEP, default=False): cv.boolean, +} + +DS2484_SCHEMA = { + cv.Optional(CONF_RESET_LOW_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_MASTER_SAMPLE_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_WRITE_0_LOW_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_RECOVERY_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_ACTIVE_PULLUP_RESISTANCE): cv.enum( + { + # DS2484 Table 7: value codes 0-5 map to 500 ohm, 6-15 map to 1000 ohm. + "500ohm": 0, + "1000ohm": 6, + } + ), +} + +CONFIG_SCHEMA = cv.typed_schema( + { + TYPE_DS2482_100: _component_schema(), + TYPE_DS2482_101: _component_schema(SLEEP_SCHEMA), + TYPE_DS2482_800: _component_schema(), + TYPE_DS2484: _component_schema(SLEEP_SCHEMA, DS2484_SCHEMA), + }, + key=CONF_TYPE, + lower=True, +) + + +def get_channel_count(config): + return CHANNEL_COUNTS[config[CONF_TYPE]] + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_active_pullup(config[CONF_ACTIVE_PULLUP])) + cg.add(var.set_channel_count(get_channel_count(config))) + + if CONF_BUS_SLEEP in config: + cg.add(var.set_bus_sleep(config[CONF_BUS_SLEEP])) + if CONF_HUB_SLEEP in config: + cg.add(var.set_hub_sleep(config[CONF_HUB_SLEEP])) + + if CONF_RESET_LOW_TIME in config: + cg.add(var.set_val_trstl(config[CONF_RESET_LOW_TIME])) + if CONF_MASTER_SAMPLE_TIME in config: + cg.add(var.set_val_tmsp(config[CONF_MASTER_SAMPLE_TIME])) + if CONF_WRITE_0_LOW_TIME in config: + cg.add(var.set_val_tw0l(config[CONF_WRITE_0_LOW_TIME])) + if CONF_RECOVERY_TIME in config: + cg.add(var.set_val_trec0(config[CONF_RECOVERY_TIME])) + if CONF_ACTIVE_PULLUP_RESISTANCE in config: + cg.add(var.set_val_rwpu(config[CONF_ACTIVE_PULLUP_RESISTANCE])) + + if CONF_SLEEP_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_SLEEP_PIN]) + cg.add(var.set_sleep_pin(pin)) diff --git a/esphome/components/ds248x/ds248x.cpp b/esphome/components/ds248x/ds248x.cpp new file mode 100644 index 0000000000..c8f7395119 --- /dev/null +++ b/esphome/components/ds248x/ds248x.cpp @@ -0,0 +1,320 @@ +#include "ds248x.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome::ds248x { + +static const char *const TAG = "ds248x"; + +void DS248xComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up DS248x..."); + + // Wake up device if sleep pin is configured + if (this->sleep_pin_) { + this->sleep_pin_->setup(); + this->sleep_pin_->pin_mode(esphome::gpio::FLAG_OUTPUT); + this->sleep_pin_->digital_write(true); // Wake up + delay(1); // DS2482-101 Datasheet: tOSCWUP = 100μs (using 10x margin) + } + + // Probe device + ESP_LOGD(TAG, "Probing DS248x..."); + uint8_t status = 0; + if (this->read(&status, 1) == i2c::ERROR_OK) { + ESP_LOGD(TAG, "Device responded! Status: 0x%02x", status); + } else { + ESP_LOGW(TAG, "Device did not respond. Trying reset anyway..."); + } + + if (!this->device_reset_()) { + ESP_LOGW(TAG, "DS248x reset failed during setup!"); + } + + // Configure device + if (!this->device_configure_()) { + ESP_LOGE(TAG, "DS248x configuration failed!"); + this->mark_failed(); + return; + } + + // Reset to Channel 0 + this->select_channel(0); + + ESP_LOGI(TAG, "DS248x initialized successfully."); +} + +void DS248xComponent::on_shutdown() { + if (this->sleep_pin_ && (this->hub_sleep_ || this->bus_sleep_)) { + this->sleep_pin_->digital_write(false); // Sleep + } +} + +void DS248xComponent::dump_config() { + ESP_LOGCONFIG(TAG, "DS248x:"); + LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, " Channel Count: %d", this->channel_count_); + ESP_LOGCONFIG(TAG, " Active Pullup: %s", YESNO(this->active_pullup_)); + if (this->ds2484_mode_) { + ESP_LOGCONFIG(TAG, " DS2484 Mode: enabled"); + } +} + +// --- Internal Helpers --- + +// Datasheet command durations are sub-2ms; allow a little margin before forcing recovery. +static constexpr uint32_t BUSY_TIMEOUT_MS = 5; + +bool DS248xComponent::set_read_pointer_(uint8_t ptr) { return this->write_byte(DS248X_COMMAND_SETREADPTR, ptr); } + +bool DS248xComponent::wait_busy_() { + uint32_t start = millis(); + do { + uint8_t status; + if (this->read(&status, 1) == i2c::ERROR_OK && !(status & DS248X_STATUS_BUSY)) + return true; + delayMicroseconds(100); + } while (millis() - start < BUSY_TIMEOUT_MS); + ESP_LOGW(TAG, "DS248x busy timeout"); + bool recovered = this->device_reset_() && this->device_configure_(); + this->current_channel_ = -1; + if (!recovered) { + ESP_LOGE(TAG, "DS248x recovery failed after busy timeout"); + this->mark_failed(); + } + return false; +} + +bool DS248xComponent::device_reset_() { + ESP_LOGD(TAG, "Resetting device..."); + uint8_t cmd = DS248X_COMMAND_RESET; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + uint8_t status; + if (this->read(&status, 1) != i2c::ERROR_OK) + return false; + + if (!(status & DS248X_STATUS_RST)) { + ESP_LOGW(TAG, "Device reset failed (RST bit not set)"); + return false; + } + + this->current_channel_ = -1; + return true; +} + +bool DS248xComponent::device_configure_() { + ESP_LOGD(TAG, "Configuring device..."); + + if (!this->write_config_()) { + ESP_LOGW(TAG, "Config write/verify failed"); + return false; + } + + ESP_LOGD(TAG, "Configured successfully"); + + // DS2484 Configuration + if (this->ds2484_mode_) { + if (this->ds2484_trstl_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TRSTL, this->ds2484_trstl_)) + return false; + if (this->ds2484_tmsp_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TMSP, this->ds2484_tmsp_)) + return false; + if (this->ds2484_tw0l_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TW0L, this->ds2484_tw0l_)) + return false; + if (this->ds2484_trec0_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TREC0, this->ds2484_trec0_)) + return false; + if (this->ds2484_rwpu_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_RWPU, this->ds2484_rwpu_)) + return false; + } + + return true; +} + +bool DS248xComponent::configure_ds2484_port_(uint8_t param, uint8_t val) { + uint8_t cmd = DS2484_COMMAND_ADJUSTPORT; + // Control Byte format (DS2484 Table 6): P[2:0] in bits 7:5, OD in bit 4, VAL[3:0] in bits 3:0 + uint8_t data = ((param & 0x07) << 5) | (val & 0x0F); + + // The DS2484 always acknowledges the Adjust 1-Wire Port control byte (datasheet "Adjust + // 1-Wire Port"), so a successful write confirms the update. We deliberately do not read + // back to verify: a single read of the Port Configuration register always returns the + // fixed 8-byte report starting at Byte 1 (tRSTL standard speed), not the parameter that + // was just written, so a per-parameter readback comparison would spuriously fail for + // tMSP/tW0L/tREC0/RWPU. + if (!this->write_byte(cmd, data)) { + ESP_LOGW(TAG, "DS2484 port config failed (param %d)", param); + return false; + } + + return this->set_read_pointer_(DS248X_POINTER_STATUS); +} + +bool DS248xComponent::write_config_() { + uint8_t config = 0; + if (this->active_pullup_) + config |= DS248X_CONFIG_ACTIVE_PULLUP; + + // The DS248x only accepts the config byte if the upper nibble is the one's-complement of the lower nibble. + uint8_t config_byte = (config & 0x0F) | ((~config & 0x0F) << 4); + + if (!this->write_byte(DS248X_COMMAND_WRITECONFIG, config_byte)) { + ESP_LOGW(TAG, "Failed to write config byte"); + return false; + } + + if (!this->set_read_pointer_(DS248X_POINTER_CONFIG)) { + return false; + } + + uint8_t read_config; + if (this->read(&read_config, 1) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Failed to read back config byte"); + return false; + } + + if ((read_config & 0x0F) != (config_byte & 0x0F)) { + ESP_LOGW(TAG, "Config mismatch! Wrote 0x%02x, Read 0x%02x", config_byte, read_config); + return false; + } + + return this->set_read_pointer_(DS248X_POINTER_STATUS); +} + +// --- Channel Selection --- + +// Channel select codes: write code -> expected read code +static constexpr uint8_t CHANNEL_WRITE_CODES[8] = {0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87}; +static constexpr uint8_t CHANNEL_READ_CODES[8] = {0xB8, 0xB1, 0xAA, 0xA3, 0x9C, 0x95, 0x8E, 0x87}; + +bool DS248xComponent::select_channel(uint8_t channel) { + if (this->channel_count_ <= 1) + return true; + if (channel >= this->channel_count_) + return false; + + if (this->current_channel_ == channel) + return true; + + if (!this->write_byte(DS248X_COMMAND_CHANNELSELECT, CHANNEL_WRITE_CODES[channel])) { + this->current_channel_ = -1; + return false; + } + + uint8_t read_code; + if (this->read(&read_code, 1) != i2c::ERROR_OK) { + this->current_channel_ = -1; + return false; + } + + if (read_code != CHANNEL_READ_CODES[channel]) { + ESP_LOGW(TAG, "Channel select failed! Expected 0x%02x, got 0x%02x", CHANNEL_READ_CODES[channel], read_code); + this->current_channel_ = -1; + return false; + } + + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + this->current_channel_ = channel; + return true; +} + +// --- 1-Wire Bus Operations --- + +bool DS248xComponent::ow_reset(bool &presence) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + uint8_t cmd = DS248X_COMMAND_RESETWIRE; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "ow_reset: wait busy failed"); + return false; + } + + uint8_t status; + if (this->read(&status, 1) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "ow_reset: read status failed"); + return false; + } + + if (status & DS248X_STATUS_SD) { + ESP_LOGW(TAG, "Short detected on 1-Wire bus!"); + return false; + } + + presence = (status & DS248X_STATUS_PPD); + return true; +} + +bool DS248xComponent::ow_write_byte(uint8_t byte) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "Device busy before writing byte 0x%02x", byte); + return false; + } + + uint8_t cmd[2] = {DS248X_COMMAND_WRITEBYTE, byte}; + if (this->write(cmd, 2) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C write failed for byte 0x%02x", byte); + return false; + } + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "Timeout waiting for write byte to complete!"); + return false; + } + + return true; +} + +bool DS248xComponent::ow_read_byte(uint8_t &byte) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + uint8_t cmd = DS248X_COMMAND_READBYTE; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) + return false; + + if (!this->set_read_pointer_(DS248X_POINTER_DATA)) + return false; + + if (this->read(&byte, 1) != i2c::ERROR_OK) + return false; + + return true; +} + +bool DS248xComponent::search_triplet(bool search_direction, uint8_t &status) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + // DS248x Datasheet: 1-Wire Triplet command requires 2 bytes: + // Byte 1: Command code 0x78 + // Byte 2: Direction byte (bit 7 = V, search direction if discrepancy) + uint8_t buffer[2] = {DS248X_COMMAND_TRIPLET, static_cast(search_direction ? 0x80 : 0x00)}; + if (this->write(buffer, 2) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) + return false; + + if (this->read(&status, 1) != i2c::ERROR_OK) + return false; + + return true; +} + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x.h b/esphome/components/ds248x/ds248x.h new file mode 100644 index 0000000000..0873ee0e2a --- /dev/null +++ b/esphome/components/ds248x/ds248x.h @@ -0,0 +1,133 @@ +#pragma once + +// DS248x I2C-to-1-Wire Bridge Family +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2482-100.pdf +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2482-800.pdf +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2484.pdf + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::ds248x { + +// DS248x I2C Commands +static constexpr uint8_t DS248X_COMMAND_RESET = 0xF0; +static constexpr uint8_t DS248X_COMMAND_SETREADPTR = 0xE1; +static constexpr uint8_t DS248X_COMMAND_WRITECONFIG = 0xD2; +static constexpr uint8_t DS248X_COMMAND_CHANNELSELECT = 0xC3; +static constexpr uint8_t DS248X_COMMAND_RESETWIRE = 0xB4; +static constexpr uint8_t DS248X_COMMAND_WRITEBYTE = 0xA5; +static constexpr uint8_t DS248X_COMMAND_READBYTE = 0x96; +static constexpr uint8_t DS248X_COMMAND_TRIPLET = 0x78; +static constexpr uint8_t DS2484_COMMAND_ADJUSTPORT = 0xC3; + +// DS2484 "Adjust 1-Wire Port" parameter codes (datasheet Table 6, control byte P[2:0]) +static constexpr uint8_t DS2484_PORT_PARAM_TRSTL = 0x0; +static constexpr uint8_t DS2484_PORT_PARAM_TMSP = 0x1; +static constexpr uint8_t DS2484_PORT_PARAM_TW0L = 0x2; +static constexpr uint8_t DS2484_PORT_PARAM_TREC0 = 0x3; +static constexpr uint8_t DS2484_PORT_PARAM_RWPU = 0x4; + +// DS248x Status Register Bits +static constexpr uint8_t DS248X_STATUS_BUSY = 0x01; +static constexpr uint8_t DS248X_STATUS_PPD = 0x02; +static constexpr uint8_t DS248X_STATUS_SD = 0x04; +static constexpr uint8_t DS248X_STATUS_RST = 0x10; +static constexpr uint8_t DS248X_STATUS_SBR = 0x20; +static constexpr uint8_t DS248X_STATUS_TSB = 0x40; +static constexpr uint8_t DS248X_STATUS_DIR = 0x80; + +// DS248x Register Pointers +static constexpr uint8_t DS248X_POINTER_STATUS = 0xF0; +static constexpr uint8_t DS248X_POINTER_DATA = 0xE1; +static constexpr uint8_t DS248X_POINTER_CONFIG = 0xC3; + +// DS248x Configuration Bits +static constexpr uint8_t DS248X_CONFIG_ACTIVE_PULLUP = 0x01; + +/** + * @brief DS248x I2C-to-1-Wire Bridge Component. + * + * This component manages the DS248x chip (DS2482-100, DS2482-800, DS2484). + * It provides low-level 1-Wire bus operations via I2C. + * + * Usage: Configure DS248xOneWireBus instances for each channel. + * These buses implement the one_wire::OneWireBus interface for compatibility + * with all existing 1-Wire device components (dallas_temp, etc.). + */ +class DS248xComponent : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void set_sleep_pin(InternalGPIOPin *pin) { this->sleep_pin_ = pin; } + void set_bus_sleep(bool enabled) { this->bus_sleep_ = enabled; } + void set_hub_sleep(bool enabled) { this->hub_sleep_ = enabled; } + void set_channel_count(uint8_t count) { this->channel_count_ = count; } + void set_active_pullup(bool enabled) { this->active_pullup_ = enabled; } + + // DS2484 Timing Parameters + void set_val_trstl(uint8_t val) { + this->ds2484_trstl_ = val; + this->ds2484_mode_ = true; + } + void set_val_tmsp(uint8_t val) { + this->ds2484_tmsp_ = val; + this->ds2484_mode_ = true; + } + void set_val_tw0l(uint8_t val) { + this->ds2484_tw0l_ = val; + this->ds2484_mode_ = true; + } + void set_val_trec0(uint8_t val) { + this->ds2484_trec0_ = val; + this->ds2484_mode_ = true; + } + void set_val_rwpu(uint8_t val) { + this->ds2484_rwpu_ = val; + this->ds2484_mode_ = true; + } + + /// Get the channel count (1 for DS2482-100/DS2484, 8 for DS2482-800) + uint8_t get_channel_count() const { return this->channel_count_; } + + // --- Core 1-Wire API (used by DS248xOneWireBus) --- + bool select_channel(uint8_t channel); + bool ow_reset(bool &presence); + bool ow_write_byte(uint8_t byte); + bool ow_read_byte(uint8_t &byte); + + // --- Search support (used by DS248xOneWireBus) --- + bool search_triplet(bool search_direction, uint8_t &status); + + protected: + InternalGPIOPin *sleep_pin_{nullptr}; + uint8_t channel_count_ = 1; + bool bus_sleep_{false}; + bool hub_sleep_{false}; + bool active_pullup_ = false; + + // DS2484 Config + bool ds2484_mode_ = false; + static constexpr uint8_t DS2484_PARAM_UNSET = 0xFF; + uint8_t ds2484_trstl_{DS2484_PARAM_UNSET}; + uint8_t ds2484_tmsp_{DS2484_PARAM_UNSET}; + uint8_t ds2484_tw0l_{DS2484_PARAM_UNSET}; + uint8_t ds2484_trec0_{DS2484_PARAM_UNSET}; + uint8_t ds2484_rwpu_{DS2484_PARAM_UNSET}; + + int8_t current_channel_{-1}; + + // Internal helpers + bool set_read_pointer_(uint8_t ptr); + bool wait_busy_(); + bool device_reset_(); + bool device_configure_(); + bool configure_ds2484_port_(uint8_t param, uint8_t val); + bool write_config_(); +}; + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x_one_wire_bus.cpp b/esphome/components/ds248x/ds248x_one_wire_bus.cpp new file mode 100644 index 0000000000..ed5b05bab7 --- /dev/null +++ b/esphome/components/ds248x/ds248x_one_wire_bus.cpp @@ -0,0 +1,171 @@ +#include "ds248x_one_wire_bus.h" +#include "ds248x.h" +#include "esphome/core/log.h" + +namespace esphome::ds248x { + +static const char *const TAG = "ds248x.one_wire"; + +void DS248xOneWireBus::setup() { + ESP_LOGCONFIG(TAG, "Setting up DS248x 1-Wire Bus (Channel %d)...", this->channel_); + + // Parent setup happens in DS248xComponent::setup() + // We just need to scan for devices on this channel + if (!this->ensure_channel_()) { + ESP_LOGE(TAG, "Failed to select channel %d during setup", this->channel_); + this->mark_failed(); + return; + } + + // Perform device search on this channel + this->search(); + + ESP_LOGCONFIG(TAG, "Found %zu devices on channel %d", this->devices_.size(), this->channel_); +} + +void DS248xOneWireBus::dump_config() { + ESP_LOGCONFIG(TAG, "DS248x 1-Wire Bus (Channel %d):", this->channel_); + this->dump_devices_(TAG); +} + +bool DS248xOneWireBus::ensure_channel_() { + if (this->parent_ == nullptr) { + ESP_LOGE(TAG, "Parent not set!"); + return false; + } + return this->parent_->select_channel(this->channel_); +} + +int DS248xOneWireBus::reset_int() { + if (!this->ensure_channel_()) { + return -1; + } + + bool presence = false; + if (!this->parent_->ow_reset(presence)) { + return -1; + } + return presence ? 1 : 0; +} + +void DS248xOneWireBus::write8(uint8_t val) { + if (!this->ensure_channel_()) { + return; + } + if (!this->parent_->ow_write_byte(val)) { + ESP_LOGE(TAG, "Failed to write byte 0x%02X on channel %d", val, this->channel_); + } +} + +void DS248xOneWireBus::write64(uint64_t val) { + if (!this->ensure_channel_()) { + return; + } + for (uint8_t i = 0; i < 8; i++) { + uint8_t byte = static_cast(val >> (i * 8)); + if (!this->parent_->ow_write_byte(byte)) { + ESP_LOGE(TAG, "Failed to write byte %d/8 (0x%02X) on channel %d - aborting write64", i + 1, byte, this->channel_); + return; // Stop writing to prevent sending corrupted data + } + } +} + +uint8_t DS248xOneWireBus::read8() { + if (!this->ensure_channel_()) { + return 0; + } + uint8_t value = 0; + if (!this->parent_->ow_read_byte(value)) { + ESP_LOGE(TAG, "Failed to read byte on channel %d", this->channel_); + } + return value; +} + +uint64_t DS248xOneWireBus::read64() { + if (!this->ensure_channel_()) { + return 0; + } + uint64_t value = 0; + for (uint8_t i = 0; i < 8; i++) { + uint8_t byte = 0; + if (!this->parent_->ow_read_byte(byte)) { + ESP_LOGE(TAG, "Failed to read byte %d/8 on channel %d - returning partial data", i + 1, this->channel_); + return value; // Return partial data to avoid blocking, caller should validate + } + value |= (static_cast(byte) << (i * 8)); + } + return value; +} + +void DS248xOneWireBus::reset_search() { + this->search_last_discrepancy_ = 0; + this->search_last_device_flag_ = false; + this->search_address_ = 0; +} + +uint64_t DS248xOneWireBus::search_int() { + if (!this->ensure_channel_()) { + return 0; + } + + if (this->search_last_device_flag_) { + return 0; + } + + uint8_t last_zero = 0; + uint64_t address = this->search_address_; + + // Iterate through all 64 bits + for (uint8_t bit_number = 1; bit_number <= 64; bit_number++) { + uint64_t bit_mask = 1ULL << (bit_number - 1); + + // Determine search direction + bool search_direction; + if (bit_number < this->search_last_discrepancy_) { + search_direction = (address & bit_mask) != 0; + } else { + search_direction = (bit_number == this->search_last_discrepancy_); + } + + // Perform triplet operation + uint8_t status = 0; + if (!this->parent_->search_triplet(search_direction, status)) { + ESP_LOGW(TAG, "1-Wire triplet failed at bit %d on channel %d - aborting search", bit_number, this->channel_); + this->reset_search(); + return 0; + } + + bool id_bit = (status & DS248X_STATUS_SBR) != 0; + bool cmp_id_bit = (status & DS248X_STATUS_TSB) != 0; + bool dir_taken = (status & DS248X_STATUS_DIR) != 0; + + if (id_bit && cmp_id_bit) { + // No devices participating + this->reset_search(); + return 0; + } + + if (!id_bit && !cmp_id_bit && !dir_taken) { + // Discrepancy, went 0 - record position + last_zero = bit_number; + } + + // Update address based on direction taken + if (dir_taken) { + address |= bit_mask; + } else { + address &= ~bit_mask; + } + } + + // Search successful + this->search_last_discrepancy_ = last_zero; + if (last_zero == 0) { + this->search_last_device_flag_ = true; + } + this->search_address_ = address; + + return address; +} + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x_one_wire_bus.h b/esphome/components/ds248x/ds248x_one_wire_bus.h new file mode 100644 index 0000000000..0591796d60 --- /dev/null +++ b/esphome/components/ds248x/ds248x_one_wire_bus.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/one_wire/one_wire_bus.h" + +namespace esphome::ds248x { + +class DS248xComponent; + +/** + * @brief OneWireBus implementation for DS248x I2C-to-1-Wire bridges. + * + * This class wraps the DS248xComponent to provide the one_wire::OneWireBus interface, + * enabling compatibility with all existing 1-Wire device components (dallas_temp, etc.). + * + * For DS2482-800, multiple instances of this class can be created (one per channel). + * For DS2482-100/DS2484, a single instance is used. + */ +class DS248xOneWireBus : public one_wire::OneWireBus, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } + + /// Set the parent DS248x component + void set_parent(DS248xComponent *parent) { this->parent_ = parent; } + + /// Set the 1-Wire channel (0-7, only relevant for DS2482-800) + void set_channel(uint8_t channel) { this->channel_ = channel; } + + /// Get the channel number + uint8_t get_channel() const { return this->channel_; } + + // OneWireBus interface implementation + int reset_int() override; + void write8(uint8_t val) override; + void write64(uint64_t val) override; + uint8_t read8() override; + uint64_t read64() override; + + protected: + void reset_search() override; + uint64_t search_int() override; + + /// Select the channel on the DS248x before any 1-Wire operation + bool ensure_channel_(); + + DS248xComponent *parent_{nullptr}; + uint8_t channel_{0}; + + // Search state + uint64_t search_address_{0}; + uint8_t search_last_discrepancy_{0}; + bool search_last_device_flag_{false}; +}; + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py new file mode 100644 index 0000000000..19861eae36 --- /dev/null +++ b/esphome/components/ds248x/one_wire.py @@ -0,0 +1,56 @@ +"""DS248x 1-Wire Bus Platform. + +This platform creates one_wire bus instances backed by a DS248x I2C-to-1-Wire bridge. +It supports DS2482-100/101 (single channel), DS2482-800 (8 channels), and DS2484 (single channel). + +For multi-channel devices (DS2482-800), create one platform entry per channel. +Each entry becomes a separate one_wire bus that can be used by dallas_temp and other 1-Wire devices. +""" + +from esphome import final_validate as fv +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 . import CONF_DS248X_ID, DS248xComponent, ds248x_ns, get_channel_count + +CODEOWNERS = ["@tomwellnitz"] +DEPENDENCIES = ["ds248x"] + +DS248xOneWireBus = ds248x_ns.class_("DS248xOneWireBus", OneWireBus, cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(DS248xOneWireBus), + cv.GenerateID(CONF_DS248X_ID): cv.use_id(DS248xComponent), + cv.Optional(CONF_CHANNEL, default=0): cv.int_range(min=0, max=7), + } +).extend(cv.COMPONENT_SCHEMA) + + +def _final_validate(config): + """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] + parent_config = fconf.get_config_for_path(path) + channel_count = get_channel_count(parent_config) + channel = config[CONF_CHANNEL] + + if channel >= channel_count: + raise cv.Invalid( + f"Channel {channel} is invalid for DS248x with {channel_count} channel(s). " + f"Valid range: 0-{channel_count - 1}" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_DS248X_ID]) + cg.add(var.set_parent(parent)) + cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/tests/components/ds248x/common.yaml b/tests/components/ds248x/common.yaml new file mode 100644 index 0000000000..53ae56f20a --- /dev/null +++ b/tests/components/ds248x/common.yaml @@ -0,0 +1,115 @@ +# Combined DS248x test covering all chip variants and options: +# - DS2482-100: active pullup, multiple sensors + index access +# - DS2482-101: sleep pin, bus_sleep / hub_sleep +# - DS2482-800: all 8 channels +# - DS2484: adjustable 1-Wire timing + RWPU pullup resistor selection +ds248x: + - id: ds2482_100 + address: 0x18 + type: ds2482-100 + active_pullup: true + - id: ds2482_101 + address: 0x19 + type: ds2482-101 + active_pullup: true + sleep_pin: + number: GPIO12 + inverted: false + bus_sleep: true + hub_sleep: true + - id: ds2482_800 + address: 0x1a + type: ds2482-800 + active_pullup: true + - id: ds2484_hub + address: 0x1b + type: ds2484 + active_pullup: true + # DS2484-specific 1-Wire timing parameters (optional fine-tuning) + reset_low_time: 8 # tRSTL: Reset low time + master_sample_time: 8 # tMSP: Master sample point + write_0_low_time: 8 # tW0L: Write-0 low time + recovery_time: 8 # tREC0: Recovery time + active_pullup_resistance: 1000ohm # RWPU: weak pullup resistor selection + +one_wire: + - platform: ds248x + ds248x_id: ds2482_100 + channel: 0 + id: ow_100 + - platform: ds248x + ds248x_id: ds2482_101 + channel: 0 + id: ow_101 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 0 + id: ow_800_0 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 1 + id: ow_800_1 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 2 + id: ow_800_2 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 3 + id: ow_800_3 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 4 + id: ow_800_4 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 5 + id: ow_800_5 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 6 + id: ow_800_6 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 7 + id: ow_800_7 + - platform: ds248x + ds248x_id: ds2484_hub + channel: 0 + id: ow_2484 + +sensor: + # DS2482-100: explicit address + index-based access on the same bus + - platform: dallas_temp + one_wire_id: ow_100 + address: 0x1c0000031edd2a28 + name: Temp 100 by address + resolution: 12 + - platform: dallas_temp + one_wire_id: ow_100 + index: 0 + name: Temp 100 by index + # DS2482-101 (sleep variant) + - platform: dallas_temp + one_wire_id: ow_101 + address: 0x578295491f64ff28 + name: Temp 101 + # DS2482-800: sensors on a few of the eight channels + - platform: dallas_temp + one_wire_id: ow_800_0 + address: 0x1c0000031edd2a28 + name: Temp 800 CH0 + - platform: dallas_temp + one_wire_id: ow_800_3 + index: 0 + name: Temp 800 CH3 by index + - platform: dallas_temp + one_wire_id: ow_800_7 + address: 0x2800000123456789 + name: Temp 800 CH7 + # DS2484 (adjustable timing) + - platform: dallas_temp + one_wire_id: ow_2484 + address: 0x1c0000031edd2a28 + name: Temp 2484 + resolution: 12 diff --git a/tests/components/ds248x/test.esp32-ard.yaml b/tests/components/ds248x/test.esp32-ard.yaml new file mode 100644 index 0000000000..7c503b0ccb --- /dev/null +++ b/tests/components/ds248x/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ds248x/test.esp32-idf.yaml b/tests/components/ds248x/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/ds248x/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/ds248x/test.esp8266-ard.yaml b/tests/components/ds248x/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/ds248x/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ds248x/test.rp2040-ard.yaml b/tests/components/ds248x/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/ds248x/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From 017040ec8e9111c4542fa44eb1ce4869283205ac Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 18 Jul 2026 09:09:52 +0200 Subject: [PATCH 2210/2214] [zigbee] add radio power off stats (#17521) --- esphome/components/zigbee/zigbee_zephyr.cpp | 45 +++++++++++++++++++-- esphome/components/zigbee/zigbee_zephyr.h | 3 ++ esphome/components/zigbee/zigbee_zephyr.py | 8 ++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 81aad7dcb1..fedcb4a9c2 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -229,6 +229,7 @@ void ZigbeeComponent::dump_config() { " Wipe on boot: %s\n" " Device is joined to the network: %s\n" " Sleep time: %us\n" + " Radio sleep time: %us\n" " RX ON when idle: %s\n" " Current channel: %d\n" " Current page: %d\n" @@ -238,9 +239,10 @@ void ZigbeeComponent::dump_config() { " Short addr: 0x%04X\n" " Long pan id: 0x%s\n" " Short pan id: 0x%04X", - get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, YESNO(zb_get_rx_on_when_idle()), - zb_get_current_channel(), zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf, - zb_get_short_address(), extended_pan_id_buf, zb_get_pan_id()); + get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, this->radio_sleep_time_, + YESNO(zb_get_rx_on_when_idle()), zb_get_current_channel(), zb_get_current_page(), + zb_get_sleep_threshold(), role(), ieee_addr_buf, zb_get_short_address(), extended_pan_id_buf, + zb_get_pan_id()); dump_reporting_(); } @@ -251,6 +253,13 @@ static void send_attribute_report(zb_bufid_t bufid, zb_uint16_t cmd_id) { void ZigbeeComponent::force_report() { this->force_report_ = true; } +void ZigbeeComponent::add_radio_sleep_time_ms(uint32_t ms) { + this->radio_sleep_remainder_ += ms; + uint32_t seconds = this->radio_sleep_remainder_ / 1000; + this->radio_sleep_remainder_ -= seconds * 1000; + this->radio_sleep_time_ += seconds; +} + void ZigbeeComponent::loop() { if (this->force_report_) { this->force_report_ = false; @@ -327,6 +336,36 @@ zb_ret_t __wrap_zb_zcl_put_reporting_info_from_req(zb_zcl_configure_reporting_re esphome::zigbee::global_zigbee->after_reporting_info(config_rep_req, attr_addr_info); return ret; } + +extern void __real_zb_trans_enter_sleep(void); +extern void __real_zb_trans_enter_receive(void); +extern zb_bool_t __real_zb_trans_transmit(zb_uint8_t wait_type, zb_time_t tx_at, zb_uint8_t *tx_buf, + zb_uint8_t current_channel); + +static uint32_t radio_sleep_start_ms = 0; + +static void stop_radio_sleep_timer() { + if (radio_sleep_start_ms) { + esphome::zigbee::global_zigbee->add_radio_sleep_time_ms(esphome::millis() - radio_sleep_start_ms); + } + radio_sleep_start_ms = 0; +} + +void __wrap_zb_trans_enter_sleep(void) { + __real_zb_trans_enter_sleep(); + radio_sleep_start_ms = esphome::millis(); +} + +void __wrap_zb_trans_enter_receive(void) { + stop_radio_sleep_timer(); + __real_zb_trans_enter_receive(); +} + +zb_bool_t __wrap_zb_trans_transmit(zb_uint8_t wait_type, zb_time_t tx_at, zb_uint8_t *tx_buf, + zb_uint8_t current_channel) { + stop_radio_sleep_timer(); + return __real_zb_trans_transmit(wait_type, tx_at, tx_buf, current_channel); +} // NOLINTEND(readability-identifier-naming,bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) } #endif diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index 3b4a465361..8528aebff8 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -81,6 +81,7 @@ class ZigbeeComponent final : public Component { void force_report(); void loop() override; void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } + void add_radio_sleep_time_ms(uint32_t ms); protected: static void zcl_device_cb(zb_bufid_t bufid); @@ -94,6 +95,8 @@ class ZigbeeComponent final : public Component { bool force_report_{false}; uint32_t sleep_time_{}; uint32_t sleep_remainder_{}; + uint32_t radio_sleep_time_{}; + uint32_t radio_sleep_remainder_{}; bool sleepy_{}; }; diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 1647fb28ae..f47cf6bd40 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -117,6 +117,14 @@ async def zephyr_to_code(config: ConfigType) -> "MockObj": cg.add_build_flag("-Wl,--wrap=zb_zcl_put_reporting_info_from_req") + # Wrap the transceiver sleep/receive/transmit calls to measure how long the + # radio is powered down. The span between a zb_trans_enter_sleep() and the + # following zb_trans_enter_receive() or zb_trans_transmit() is time the + # radio spent asleep. + cg.add_build_flag("-Wl,--wrap=zb_trans_enter_sleep") + cg.add_build_flag("-Wl,--wrap=zb_trans_enter_receive") + cg.add_build_flag("-Wl,--wrap=zb_trans_transmit") + if CONF_IEEE802154_VENDOR_OUI in config: zephyr_add_prj_conf("IEEE802154_VENDOR_OUI_ENABLE", True) random_number = config[CONF_IEEE802154_VENDOR_OUI] From 62109611031e6290fb2ba5af76b5913a887371cc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:40:09 -0400 Subject: [PATCH 2211/2214] [esp32] Bump recommended ESP-IDF to 5.5.5 --- esphome/components/esp32/__init__.py | 7 ++++--- platformio.ini | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3c2fb35dde..4aff4f7196 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -865,9 +865,9 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 4), - "latest": cv.Version(5, 5, 4), - "dev": cv.Version(5, 5, 4), + "recommended": cv.Version(5, 5, 5), + "latest": cv.Version(5, 5, 5), + "dev": cv.Version(5, 5, 5), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { @@ -877,6 +877,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(5, 5, 5): cv.Version(55, 3, 39), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), diff --git a/platformio.ini b/platformio.ini index 35dec2ff76..d81f52b234 100644 --- a/platformio.ini +++ b/platformio.ini @@ -144,7 +144,7 @@ extends = common:arduino platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -180,7 +180,7 @@ extra_scripts = extends = common:idf platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = espidf lib_deps = From eecaeeb88e31b618840d1342d08c724554996b41 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:44:25 -0400 Subject: [PATCH 2212/2214] [esp32] Map Arduino 3.3.10 to ESP-IDF 5.5.5 --- 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 4aff4f7196..3d078588bd 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -844,6 +844,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), From 55ce4e4a9bf58e8316a3f43b74e477ab649cce9c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:47:18 -0400 Subject: [PATCH 2213/2214] [esp32] Bump recommended Arduino to 3.3.10 --- esphome/components/esp32/__init__.py | 7 ++++--- platformio.ini | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3d078588bd..7e13156b4d 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -814,14 +814,15 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 9), - "latest": cv.Version(3, 3, 9), - "dev": cv.Version(3, 3, 9), + "recommended": cv.Version(3, 3, 10), + "latest": cv.Version(3, 3, 10), + "dev": cv.Version(3, 3, 10), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 10): cv.Version(55, 3, 39), cv.Version(3, 3, 9): cv.Version(55, 3, 39), cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), diff --git a/platformio.ini b/platformio.ini index d81f52b234..255a900367 100644 --- a/platformio.ini +++ b/platformio.ini @@ -143,7 +143,7 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script extends = common:arduino platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.10/esp32-core-3.3.10.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component From 0b0e706349731bdebf6e8e95f842276adf1de3a4 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 18 Jul 2026 14:05:19 +0200 Subject: [PATCH 2214/2214] [nrf52] add platform: ultrasonic test (#17665) --- tests/components/ultrasonic/test.nrf52-adafruit.yaml | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/components/ultrasonic/test.nrf52-adafruit.yaml diff --git a/tests/components/ultrasonic/test.nrf52-adafruit.yaml b/tests/components/ultrasonic/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/ultrasonic/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml